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.
284 lines
11 KiB
Rust
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(¤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"
|
|
);
|
|
}
|
|
}
|