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:
2026-08-11 09:03:36 +02:00
parent afeb09bdcd
commit 0f93a939c0
5 changed files with 604 additions and 6 deletions

View File

@@ -57,6 +57,18 @@ pub struct EnemyBalance {
pub speed: Stat, pub speed: Stat,
/// Health at wave 1. /// Health at wave 1.
pub health: Stat, pub health: Stat,
/// Distance from the tower centre at which an enemy stops walking.
pub attack_range: Stat,
/// Enemies closer than this push each other apart (ADR-0004 §5).
pub separation_radius: Stat,
/// ‹k›: how many neighbours one enemy takes into account (ADR-0004 §5).
pub separation_neighbours: usize,
/// ‹f› < 1: the separation offset is clamped to `f x (speed x dt)` (ADR-0004 §5).
pub separation_clamp: Stat,
/// Living enemies the spawner fills the field up to. Becomes `target_population(wave)`.
pub target_population: usize,
/// `HARD_ENEMY_CAP`: the measured performance limit, never exceeded by anything.
pub hard_cap: usize,
} }
/// Values that describe how things touch each other. /// Values that describe how things touch each other.
@@ -107,6 +119,12 @@ impl Balance {
enemy: EnemyBalance { enemy: EnemyBalance {
speed: values::ENEMY_SPEED, speed: values::ENEMY_SPEED,
health: values::ENEMY_HEALTH, health: values::ENEMY_HEALTH,
attack_range: values::ENEMY_ATTACK_RANGE,
separation_radius: values::ENEMY_SEPARATION_RADIUS,
separation_neighbours: values::ENEMY_SEPARATION_NEIGHBOURS,
separation_clamp: values::ENEMY_SEPARATION_CLAMP,
target_population: values::ENEMY_TARGET_POPULATION,
hard_cap: values::HARD_ENEMY_CAP,
}, },
combat: CombatBalance { combat: CombatBalance {
hit_radius: values::HIT_RADIUS, hit_radius: values::HIT_RADIUS,

View File

@@ -23,6 +23,42 @@ pub(super) const ENEMY_SPEED: Stat = 50.0;
/// later be derived from. /// later be derived from.
pub(super) const ENEMY_HEALTH: Stat = 10.0; pub(super) const ENEMY_HEALTH: Stat = 10.0;
/// PLACEHOLDER. Distance from the tower centre at which an enemy stops walking.
///
/// In the skeleton this only ends the radial approach — the ring it produces is what makes
/// the separation of ADR-0004 observable at all. #18 turns standing here into attacking.
pub(super) const ENEMY_ATTACK_RANGE: Stat = 28.0;
/// PLACEHOLDER. Enemies closer than this push each other apart (ADR-0004 §5).
///
/// This is also the value the grid's cell size follows (ADR-0004 §2), because the separation
/// query is the one that runs once per enemy rather than once per frame.
pub(super) const ENEMY_SEPARATION_RADIUS: Stat = 14.0;
/// PLACEHOLDER. ‹k› from ADR-0004 §5: how many neighbours one enemy takes into account.
/// Caps cost and force in one number.
pub(super) const ENEMY_SEPARATION_NEIGHBOURS: usize = 8;
/// PLACEHOLDER. ‹f› from ADR-0004 §5, and it must stay **below 1**: the separation offset is
/// clamped to `f x (speed x dt)`, so an enemy can never be displaced further than it walks.
/// That is what makes reversing the radial approach constructively impossible.
pub(super) const ENEMY_SEPARATION_CLAMP: Stat = 0.5;
/// PLACEHOLDER. Living enemies the spawner keeps the field filled to.
///
/// A constant, because the skeleton has no waves. The spec calls for `target_population(wave)`
/// — a function of the wave counter and itself part of the difficulty curve. The wave ticket
/// replaces this field with that function; the spawner above it does not change, because it is
/// cap-driven ("fill up to the target") rather than plan-driven.
pub(super) const ENEMY_TARGET_POPULATION: usize = 400;
/// The hard upper bound on simultaneous enemies — **not a placeholder**.
///
/// Measured in #4: the frame budget breaks at ~70 000 untextured sprites, which leaves this
/// 7x headroom. It is a performance limit, never a design tool: no effect raises it, and
/// nothing exceeds it. What effects raise is the target population above.
pub(super) const HARD_ENEMY_CAP: usize = 10_000;
/// PLACEHOLDER. Damage of a single tower shot. ADR-0002 §3: `Damage`, mode `Direct`, base > 0. /// PLACEHOLDER. Damage of a single tower shot. ADR-0002 §3: `Damage`, mode `Direct`, base > 0.
pub(super) const TOWER_DAMAGE: Stat = 5.0; pub(super) const TOWER_DAMAGE: Stat = 5.0;

283
src/enemy.rs Normal file
View 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(&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"
);
}
}

View File

@@ -5,17 +5,20 @@
//! balancing constant directly. //! balancing constant directly.
mod arena; mod arena;
/// Most balancing values exist before the systems that read them: #16 (enemies), #17 /// Some balancing values still exist before the systems that read them: #17 (shooting) and
/// (shooting) and #18 (tower death, meta payout) consume them. `expect` rather than `allow`, /// #18 (tower death, meta payout) consume the rest. `expect` rather than `allow`, so the
/// so the compiler asks for this line back once the last field has found its reader. /// compiler asks for this line back once the last field has found its reader.
#[expect(dead_code, reason = "read by the gameplay tickets #16-#18")] #[expect(dead_code, reason = "read by the gameplay tickets #17-#18")]
mod balance; mod balance;
mod enemy;
mod spatial;
mod tower; mod tower;
use bevy::camera::ScalingMode; use bevy::camera::ScalingMode;
use bevy::prelude::*; use bevy::prelude::*;
use balance::Balance; use balance::Balance;
use spatial::SpatialGrid;
/// The large-scale structure of the program. /// The large-scale structure of the program.
/// ///
@@ -41,6 +44,10 @@ const VIEW_MARGIN: f32 = 1.08;
const BACKGROUND: Color = Color::srgb(0.05, 0.05, 0.07); const BACKGROUND: Color = Color::srgb(0.05, 0.05, 0.07);
fn main() { fn main() {
// The single source of every balancing number (ADR-0001). The grid is sized from it, so
// it has to exist before the app is assembled.
let balance = Balance::from_constants();
App::new() App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin { .add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
@@ -50,12 +57,25 @@ fn main() {
..default() ..default()
})) }))
.insert_resource(ClearColor(BACKGROUND)) .insert_resource(ClearColor(BACKGROUND))
// The single source of every balancing number (ADR-0001). .insert_resource(SpatialGrid::from_balance(&balance))
.insert_resource(Balance::from_constants()) .insert_resource(balance)
.init_state::<AppState>() .init_state::<AppState>()
.add_systems(Startup, (spawn_camera, arena::spawn_arena)) .add_systems(Startup, (spawn_camera, arena::spawn_arena))
.add_systems(OnEnter(AppState::Run), tower::spawn_tower) .add_systems(OnEnter(AppState::Run), tower::spawn_tower)
.add_systems(Update, toggle_run_state) .add_systems(Update, toggle_run_state)
// The frame order of ADR-0004 §6, stated rather than inherited from the registration
// order: move, then rebuild the grid, then every consumer reading it.
.add_systems(
Update,
(
enemy::maintain_population,
enemy::move_enemies,
spatial::rebuild_spatial_grid,
enemy::separate_enemies,
)
.chain()
.run_if(in_state(AppState::Run)),
)
.run(); .run();
} }

241
src/spatial.rs Normal file
View File

@@ -0,0 +1,241 @@
//! The one neighbourhood query in the game (ADR-0004).
//!
//! A uniform grid over the arena, rebuilt from scratch every frame. It exists because four
//! systems want the same question answered — soft separation, target selection, the shared
//! range scan and projectile collision — and each of them would otherwise walk the whole enemy
//! set to answer it.
//!
//! The binding rule that comes with it (ADR-0004 §6): **apart from [`rebuild_spatial_grid`],
//! no system iterates the enemy query to find neighbours.** Consumers ask the grid.
use bevy::prelude::*;
use crate::balance::Balance;
use crate::enemy::Enemy;
/// A dense uniform grid over the arena, one bucket per cell.
///
/// A `Resource`, not a component on the tower: it describes the world rather than the tower,
/// and it carries no state across frames, so the placement rule of ADR-0002 §6 is untouched.
/// It changes every single frame, which makes `is_changed()` on it worthless as a signal — it
/// must never become an input to the `EffectiveStats` recomputation (ADR-0004, consequences).
#[derive(Resource)]
pub struct SpatialGrid {
/// Edge length of one cell. Follows the separation radius, *not* the tower range
/// (ADR-0004 §2) — the small query is the one that runs 10 000 times per frame.
cell_size: f32,
/// Cells per side. The grid is square and centred on the tower.
dim: usize,
/// Half the edge length of the covered square, in world units.
half_extent: f32,
/// The buckets, row-major. Positions are stored next to the entity: the separation system
/// writes `Transform` while reading its neighbours' positions, and a grid that held bare
/// `Entity` would force it to borrow the enemy query mutably and immutably at once.
cells: Vec<Vec<(Entity, Vec2)>>,
}
impl SpatialGrid {
/// Sizes the grid from `Balance`, so a changed arena radius or separation radius reshapes
/// it without any further code change.
pub fn from_balance(balance: &Balance) -> Self {
let cell_size = balance
.enemy
.separation_radius
.max(balance.combat.hit_radius);
let half_extent = balance.arena.radius;
let dim = ((half_extent * 2.0 / cell_size).ceil() as usize).max(1);
Self {
cell_size,
dim,
half_extent,
cells: vec![Vec::new(); dim * dim],
}
}
/// Cell coordinates of a world position. Positions outside the grid clamp into the edge
/// cell (ADR-0004 §1) — the spawn rim sits exactly on the boundary.
fn cell_of(&self, pos: Vec2) -> (usize, usize) {
let last = (self.dim - 1) as isize;
let index = |v: f32| {
(((v + self.half_extent) / self.cell_size).floor() as isize).clamp(0, last) as usize
};
(index(pos.x), index(pos.y))
}
/// Empties every bucket while keeping its allocation. There is no allocation per frame.
fn clear(&mut self) {
for cell in &mut self.cells {
cell.clear();
}
}
fn insert(&mut self, entity: Entity, pos: Vec2) {
let (x, y) = self.cell_of(pos);
self.cells[y * self.dim + x].push((entity, pos));
}
/// Visits every entity within `radius` of `centre`, narrow phase included — the callback
/// only ever sees real hits, never block candidates.
///
/// This is the query all four consumers of ADR-0004 share. The caller's own entity is
/// visited too if it is in the grid; whoever cares filters it out.
pub fn for_each_in_radius(
&self,
centre: Vec2,
radius: f32,
mut visit: impl FnMut(Entity, Vec2),
) {
let (min_x, min_y) = self.cell_of(centre - Vec2::splat(radius));
let (max_x, max_y) = self.cell_of(centre + Vec2::splat(radius));
let radius_sq = radius * radius;
for y in min_y..=max_y {
for x in min_x..=max_x {
for &(entity, pos) in &self.cells[y * self.dim + x] {
if centre.distance_squared(pos) <= radius_sq {
visit(entity, pos);
}
}
}
}
}
}
/// Rebuilds the whole grid from the enemy positions of this frame.
///
/// Full rebuild rather than an incremental update: it costs 0.057 ms at the hard cap, a third
/// of a percent of the frame, and an incremental one would pay the same scattered bucket write
/// on every cell crossing while adding a per-entity back-index and a class of bugs (ADR-0004
/// §3 with its 2026-08-11 addendum). The same trade ADR-0002 §6 made for the accumulators.
///
/// **The one and only pass over the enemy set.** Ordered after `move_enemies` and before every
/// consumer (ADR-0004 §6).
pub fn rebuild_spatial_grid(
mut grid: ResMut<SpatialGrid>,
enemies: Query<(Entity, &Transform), With<Enemy>>,
) {
grid.clear();
for (entity, transform) in &enemies {
grid.insert(entity, transform.translation.truncate());
}
}
#[cfg(test)]
mod tests {
use super::*;
use core::time::Duration;
use std::time::Instant;
/// The bound ADR-0004 §3 rests on: the rebuild costs at most this at `HARD_ENEMY_CAP`.
const BUDGET_MS: f64 = 0.10;
fn grid() -> SpatialGrid {
SpatialGrid::from_balance(&Balance::from_constants())
}
/// Real entity ids, taken from a throwaway world — there is no supported way to conjure an
/// `Entity` out of a number that survives a Bevy upgrade.
fn ids(count: usize) -> Vec<Entity> {
let mut world = World::new();
(0..count).map(|_| world.spawn_empty().id()).collect()
}
/// The narrow phase is part of the query, so a candidate from the same cell block that is
/// out of range must not reach the callback. Without this the separation would push
/// against neighbours it never actually had.
#[test]
fn the_query_returns_neighbours_in_range_and_nothing_else() {
let mut grid = grid();
let cell_size = grid.cell_size;
let e = ids(3);
grid.insert(e[0], Vec2::new(3.0, 0.0));
// Inside the cell block the query walks, but outside the query radius.
grid.insert(e[1], Vec2::new(cell_size * 1.2, 0.0));
grid.insert(e[2], Vec2::new(200.0, 200.0));
let mut found = Vec::new();
grid.for_each_in_radius(Vec2::ZERO, 10.0, |entity, _| found.push(entity));
assert_eq!(found, vec![e[0]], "query returned {found:?}");
}
/// The spawn rim sits exactly on the grid boundary, and a separation offset may push an
/// enemy past it. Both must land in a cell instead of panicking on an index.
#[test]
fn positions_on_and_beyond_the_rim_clamp_into_the_edge_cell() {
let grid = grid();
let radius = Balance::from_constants().arena.radius;
let last = grid.dim - 1;
assert_eq!(grid.cell_of(Vec2::splat(radius)), (last, last));
assert_eq!(grid.cell_of(Vec2::splat(-radius * 3.0)), (0, 0));
}
/// Revision trigger 1 of ADR-0004, as a runnable check: the decision against an incremental
/// update rests on the rebuild staying under 0.10 ms at the hard cap. If this fails, the
/// ADR is due — not the number.
///
/// It prints `move_enemies` next to it because the ADR originally used that as its yardstick.
/// The measurement in #16 refuted the yardstick (0.057 ms against 0.021 ms — the scattered
/// bucket writes cost more than the arithmetic the ADR counted) without touching the bound.
/// Printed, not asserted, for exactly that reason.
///
/// Ignored by default because it is a measurement, not a unit test:
/// cargo test --release -- --ignored --nocapture
#[test]
#[ignore = "perf measurement; run with: cargo test --release -- --ignored --nocapture"]
fn the_rebuild_stays_within_its_budget_at_the_hard_cap() {
use crate::enemy::{Enemy, move_enemies};
let balance = Balance::from_constants();
let count = balance.enemy.hard_cap;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(SpatialGrid::from_balance(&balance))
.insert_resource(balance.clone());
// Spread over the whole disc, all of them outside the attack range, so `move_enemies`
// does its full work for every single one.
for i in 0..count {
let angle = (i as f32 / count as f32) * core::f32::consts::TAU;
let radius = balance.arena.radius * (0.2 + 0.8 * (i % 97) as f32 / 97.0);
let pos = Vec2::from_angle(angle) * radius;
app.world_mut()
.spawn((Enemy, Transform::from_xyz(pos.x, pos.y, 0.0)));
}
let world = app.world_mut();
world
.resource_mut::<Time>()
.advance_by(Duration::from_micros(16_667));
let move_id = world.register_system(move_enemies);
let rebuild_id = world.register_system(rebuild_spatial_grid);
let median = |world: &mut World, id| {
let mut samples: Vec<f64> = (0..24)
.map(|_| {
let t0 = Instant::now();
world.run_system(id).expect("system ran");
t0.elapsed().as_secs_f64() * 1e3
})
.skip(4) // discard the cold runs
.collect();
samples.sort_by(f64::total_cmp);
samples[samples.len() / 2]
};
let move_ms = median(world, move_id);
let rebuild_ms = median(world, rebuild_id);
println!(
"{count} enemies: rebuild_spatial_grid {rebuild_ms:.3} ms (budget {BUDGET_MS:.3} ms) \
| move_enemies {move_ms:.3} ms"
);
assert!(
rebuild_ms <= BUDGET_MS,
"ADR-0004 revision trigger 1 fired: rebuild {rebuild_ms:.3} ms exceeds the \
{BUDGET_MS:.3} ms the decision rests on, at {count} enemies"
);
}
}