//! 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