feat: add enemies, spatial grid and soft separation
Enemies spawn on the arena rim, walk radially at the tower and stop at the attack range. The spawner is cap-driven — it tops the field up to the target population instead of placing N enemies on a schedule — so the target population is the binding quantity the difficulty curve will stand on. The spatial grid of ADR-0004 lands with them, because it now has its first consumer and #17 needs it unchanged for target selection and projectile collision. One full rebuild per frame is the only pass over the enemy set; every consumer asks the grid. Soft separation is a clamped positional offset, not a force: overlap stays allowed, and capping the offset below one movement step is what keeps the tower ring from pulsing. Closes #16.
This commit is contained in:
283
src/enemy.rs
Normal file
283
src/enemy.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
//! Enemies: they appear on the arena rim, walk radially at the tower, and push each other
|
||||
//! apart softly once they get close (spec feature 3, ADR-0004).
|
||||
//!
|
||||
//! One archetype. The three to four from the calibration, the boss, the wave tick and the
|
||||
//! attack on the tower all belong to later tickets; what is here is the part the walking
|
||||
//! skeleton needs to show a field of enemies converging on the tower.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use core::f32::consts::TAU;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::arena::ARENA_Z;
|
||||
use crate::balance::{Balance, Stat};
|
||||
use crate::spatial::SpatialGrid;
|
||||
|
||||
/// Marks an enemy. The only thing that identifies the enemy set to the grid rebuild.
|
||||
#[derive(Component)]
|
||||
pub struct Enemy;
|
||||
|
||||
/// Remaining hit points. Nothing reduces it yet — #17 brings the projectiles that do.
|
||||
///
|
||||
/// It is carried from the start anyway, so that the enemy archetype does not change shape the
|
||||
/// moment shooting lands. `expect` rather than `allow`: the compiler asks for this line back
|
||||
/// once #17 has a reader.
|
||||
#[expect(dead_code, reason = "read by the projectile hit in #17")]
|
||||
#[derive(Component)]
|
||||
pub struct Health(pub Stat);
|
||||
|
||||
/// Edge length of the enemy sprite. Programmer art, not a balancing value — no rule reads it.
|
||||
const ENEMY_SIZE: f32 = 8.0;
|
||||
const ENEMY_COLOR: Color = Color::srgb(0.85, 0.25, 0.25);
|
||||
|
||||
/// Fills the field up to the target population.
|
||||
///
|
||||
/// **Cap-driven, not plan-driven** (cap analysis, issue #1): it asks how many are alive and
|
||||
/// tops up, rather than placing N enemies on a schedule. That is what makes the target
|
||||
/// population the binding quantity — an effect promising "more enemies" raises it, and a spawn
|
||||
/// multiplier would be exactly nothing.
|
||||
pub fn maintain_population(
|
||||
mut commands: Commands,
|
||||
balance: Res<Balance>,
|
||||
enemies: Query<(), With<Enemy>>,
|
||||
// ponytail: a counter through a hash instead of the `rand` crate — deterministic, so a run
|
||||
// replays identically, and the dependency list stays at Bevy alone. `Local` rather than a
|
||||
// `Resource`: nothing outside this system ever reads it.
|
||||
mut seed: Local<u32>,
|
||||
) {
|
||||
// The hard cap binds here and nowhere else — it is the single place a population number
|
||||
// turns into entities, so clamping it here means nothing can exceed it.
|
||||
let target = balance.enemy.target_population.min(balance.enemy.hard_cap);
|
||||
let alive = enemies.iter().len();
|
||||
|
||||
for _ in alive..target {
|
||||
*seed = seed.wrapping_add(1);
|
||||
let position = Vec2::from_angle(hash01(*seed) * TAU) * balance.arena.radius;
|
||||
commands.spawn((
|
||||
Enemy,
|
||||
Health(balance.enemy.health),
|
||||
// Run-bound, like the tower (ADR-0002 §6): the end of a run takes them with it and
|
||||
// there is no cleanup list to forget.
|
||||
DespawnOnExit(AppState::Run),
|
||||
Sprite::from_color(ENEMY_COLOR, Vec2::splat(ENEMY_SIZE)),
|
||||
Transform::from_xyz(position.x, position.y, ARENA_Z + 0.5),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Walks every enemy straight at the tower and stops it at the attack range.
|
||||
///
|
||||
/// Runs **before** the grid rebuild (ADR-0004 §6), so the grid describes the positions the
|
||||
/// consumers actually see.
|
||||
pub fn move_enemies(
|
||||
time: Res<Time>,
|
||||
balance: Res<Balance>,
|
||||
mut enemies: Query<&mut Transform, With<Enemy>>,
|
||||
) {
|
||||
let step_length = balance.enemy.speed * time.delta_secs();
|
||||
let stop_at = balance.enemy.attack_range;
|
||||
|
||||
for mut transform in &mut enemies {
|
||||
let position = transform.translation.truncate();
|
||||
let distance = position.length();
|
||||
let remaining = distance - stop_at;
|
||||
if remaining <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// `min(remaining)` keeps an enemy from stepping through the ring and out the far side
|
||||
// at low frame rates. Division is safe: `distance > stop_at > 0`.
|
||||
let step = position / distance * -step_length.min(remaining);
|
||||
transform.translation.x += step.x;
|
||||
transform.translation.y += step.y;
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft separation: a clamped positional offset, not a force (ADR-0004 §5).
|
||||
///
|
||||
/// This is **not** collision resolution — overlap stays allowed. The goal is a readable ring,
|
||||
/// because the target population is the quantity the whole difficulty curve stands on and it
|
||||
/// cannot be read off a pile of coincident sprites. Forbidding penetration instead would build
|
||||
/// the queue at the tower ring that no amount of DPS dissolves.
|
||||
///
|
||||
/// The clamp is what keeps the ring from pulsing. Density peaks at the tower ring, and a
|
||||
/// neighbourhood there is isotropic tangentially but not radially — nobody stands behind an
|
||||
/// enemy, towards the tower, to push back — so the residual vector points *outwards*. Capping
|
||||
/// the offset below one movement step makes reversing the approach constructively impossible.
|
||||
pub fn separate_enemies(
|
||||
time: Res<Time>,
|
||||
balance: Res<Balance>,
|
||||
grid: Res<SpatialGrid>,
|
||||
mut enemies: Query<(Entity, &mut Transform), With<Enemy>>,
|
||||
) {
|
||||
let enemy = &balance.enemy;
|
||||
let max_offset = enemy.separation_clamp * enemy.speed * time.delta_secs();
|
||||
let radius = enemy.separation_radius;
|
||||
if max_offset <= 0.0 || radius <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
for (entity, mut transform) in &mut enemies {
|
||||
let position = transform.translation.truncate();
|
||||
let mut push = Vec2::ZERO;
|
||||
let mut counted = 0usize;
|
||||
|
||||
grid.for_each_in_radius(position, radius, |other, other_position| {
|
||||
// The neighbour budget caps cost and force in one number — the same rule shape as
|
||||
// the target limit per ability trigger, rather than two separate ones.
|
||||
if other == entity || counted >= enemy.separation_neighbours {
|
||||
return;
|
||||
}
|
||||
let away = position - other_position;
|
||||
let distance = away.length();
|
||||
let direction = if distance > f32::EPSILON {
|
||||
away / distance
|
||||
} else {
|
||||
// ponytail: coincident enemies get a per-entity fixed direction instead of a
|
||||
// random one. Two enemies on the same pixel then push apart rather than in
|
||||
// lockstep, and it stays deterministic.
|
||||
Vec2::from_angle(hash01(entity.to_bits() as u32) * TAU)
|
||||
};
|
||||
push += direction * (1.0 - distance / radius);
|
||||
counted += 1;
|
||||
});
|
||||
|
||||
if counted == 0 {
|
||||
continue;
|
||||
}
|
||||
let offset = push.clamp_length_max(max_offset);
|
||||
transform.translation.x += offset.x;
|
||||
transform.translation.y += offset.y;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap deterministic hash into `[0, 1)`.
|
||||
fn hash01(n: u32) -> f32 {
|
||||
let mut x = n.wrapping_mul(0x9E37_79B9).wrapping_add(0x1234_5678);
|
||||
x ^= x >> 16;
|
||||
x = x.wrapping_mul(0x85EB_CA6B);
|
||||
x ^= x >> 13;
|
||||
(x >> 8) as f32 / (1u32 << 24) as f32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::spatial::rebuild_spatial_grid;
|
||||
use bevy::state::app::StatesPlugin;
|
||||
use core::time::Duration;
|
||||
|
||||
/// A frame at 60 fps. The app runs without `TimePlugin` so the test drives `Time` itself —
|
||||
/// with real time, six hundred `update()` calls would advance the simulation by a few
|
||||
/// milliseconds and no enemy would ever reach the ring.
|
||||
const FRAME: Duration = Duration::from_micros(16_667);
|
||||
|
||||
/// The gameplay half of the app without a window, in the frame order of ADR-0004 §6.
|
||||
fn headless_app() -> App {
|
||||
let balance = Balance::from_constants();
|
||||
let mut app = App::new();
|
||||
app.add_plugins(StatesPlugin)
|
||||
.insert_resource(Time::<()>::default())
|
||||
.insert_resource(SpatialGrid::from_balance(&balance))
|
||||
.insert_resource(balance)
|
||||
.init_state::<AppState>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
maintain_population,
|
||||
move_enemies,
|
||||
rebuild_spatial_grid,
|
||||
separate_enemies,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
fn step(app: &mut App) {
|
||||
app.world_mut().resource_mut::<Time>().advance_by(FRAME);
|
||||
app.update();
|
||||
}
|
||||
|
||||
/// Distances to the tower, keyed by entity and sorted — query iteration order is not part
|
||||
/// of Bevy's contract, and comparing two frames positionally would silently compare
|
||||
/// different enemies.
|
||||
fn distances(app: &mut App) -> Vec<(Entity, f32)> {
|
||||
let mut query = app
|
||||
.world_mut()
|
||||
.query_filtered::<(Entity, &Transform), With<Enemy>>();
|
||||
let mut out: Vec<(Entity, f32)> = query
|
||||
.iter(app.world())
|
||||
.map(|(e, t)| (e, t.translation.truncate().length()))
|
||||
.collect();
|
||||
out.sort_by_key(|(e, _)| e.to_bits());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spawner_fills_up_to_the_target_and_then_stops() {
|
||||
let mut app = headless_app();
|
||||
let target = app.world().resource::<Balance>().enemy.target_population;
|
||||
|
||||
step(&mut app);
|
||||
assert_eq!(distances(&mut app).len(), target, "first fill");
|
||||
|
||||
step(&mut app);
|
||||
assert_eq!(
|
||||
distances(&mut app).len(),
|
||||
target,
|
||||
"a filled field must not keep spawning"
|
||||
);
|
||||
}
|
||||
|
||||
/// The claim ADR-0004 §5 rests on, and the acceptance criterion of spec feature 3: the
|
||||
/// tower ring must not pulse, and the field must still converge instead of stalling in its
|
||||
/// own push-back.
|
||||
///
|
||||
/// The property is *not* "distance never grows". An enemy standing at the attack range
|
||||
/// takes no step inwards that frame, so there is nothing for the clamp to be smaller than
|
||||
/// and the separation can move it outwards. What the clamp guarantees is that it can never
|
||||
/// move it further than one movement step — the overshoot a pulse would need.
|
||||
#[test]
|
||||
fn separation_never_reverses_the_radial_approach() {
|
||||
let mut app = headless_app();
|
||||
step(&mut app);
|
||||
|
||||
let (spawn_radius, budget) = {
|
||||
let balance = app.world().resource::<Balance>();
|
||||
(
|
||||
balance.arena.radius,
|
||||
balance.enemy.speed * FRAME.as_secs_f32(),
|
||||
)
|
||||
};
|
||||
let first = distances(&mut app);
|
||||
let mut previous = first.clone();
|
||||
|
||||
// Long enough for the front rank to reach the ring, which is where density peaks and
|
||||
// the residual separation vector points outwards.
|
||||
for frame in 0..600 {
|
||||
step(&mut app);
|
||||
let current = distances(&mut app);
|
||||
assert_eq!(current.len(), previous.len(), "population changed mid-test");
|
||||
for (&(entity, before), &(same, after)) in previous.iter().zip(¤t) {
|
||||
assert_eq!(entity, same, "enemy sets diverged");
|
||||
assert!(
|
||||
after - before <= budget,
|
||||
"frame {frame}: {entity} was pushed {} outwards, more than the {budget} it \
|
||||
walks in a frame",
|
||||
after - before
|
||||
);
|
||||
assert!(
|
||||
after <= spawn_radius,
|
||||
"frame {frame}: {entity} was pushed past the spawn rim, to {after}"
|
||||
);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
|
||||
let mean = |v: &[(Entity, f32)]| v.iter().map(|(_, d)| d).sum::<f32>() / v.len() as f32;
|
||||
assert!(
|
||||
mean(&previous) < mean(&first),
|
||||
"the field did not converge: separation ate the radial approach"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user