Files
InfiniTower/src/enemy.rs
MDietermann 0f93a939c0 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.
2026-08-11 09:03:36 +02:00

284 lines
11 KiB
Rust

//! 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(&current) {
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"
);
}
}