diff --git a/src/balance/mod.rs b/src/balance/mod.rs index 7bb21b6..af00148 100644 --- a/src/balance/mod.rs +++ b/src/balance/mod.rs @@ -57,6 +57,18 @@ pub struct EnemyBalance { pub speed: Stat, /// Health at wave 1. 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. @@ -107,6 +119,12 @@ impl Balance { enemy: EnemyBalance { speed: values::ENEMY_SPEED, 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 { hit_radius: values::HIT_RADIUS, diff --git a/src/balance/values.rs b/src/balance/values.rs index 1b799c8..d23a138 100644 --- a/src/balance/values.rs +++ b/src/balance/values.rs @@ -23,6 +23,42 @@ pub(super) const ENEMY_SPEED: Stat = 50.0; /// later be derived from. 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. pub(super) const TOWER_DAMAGE: Stat = 5.0; diff --git a/src/enemy.rs b/src/enemy.rs new file mode 100644 index 0000000..15bc4aa --- /dev/null +++ b/src/enemy.rs @@ -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, + enemies: Query<(), With>, + // 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, +) { + // 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