Compare commits
4 Commits
master
...
f1b85862da
| Author | SHA1 | Date | |
|---|---|---|---|
| f1b85862da | |||
| 0f93a939c0 | |||
| afeb09bdcd | |||
| bcc6d333d4 |
@@ -68,6 +68,13 @@ System, das einen Durchlauf über dieselbe Gegnermenge macht, kostet höchstens
|
||||
sein) und der Turm steht fest in der Mitte — es gibt keinen Grund für eine Hashmap. Der Zellindex
|
||||
ist zwei Divisionen und zwei `floor`. Gegner außerhalb des Gitters werden in die Randzelle geklemmt.
|
||||
|
||||
**Nachtrag vom 2026-08-11 (#16): je Zelle ein `Vec<(Entity, Vec2)>`, nicht `Vec<Entity>`.** Die
|
||||
Position wird neben der `Entity` mitgeführt. Der Grund ist die weiche Separation aus §5: sie schreibt
|
||||
`Transform` und liest dabei die `Transform`s ihrer Nachbarn. Mit bloßen `Entity`s müsste sie dieselbe
|
||||
Gegner-Query gleichzeitig lesend und schreibend halten — in Bevy nicht ausleihbar, und die Auswege
|
||||
(`ParamSet`, ein zweiter Puffer-Durchlauf) kosten mehr als das eine `Vec2` je Bucket-Eintrag. Die
|
||||
Entscheidung ändert sich dadurch nicht; die Ablage tut es.
|
||||
|
||||
Das Grid ist **kein** `Component` an der Turm-Entity: Es beschreibt nicht den Turm, sondern die
|
||||
Welt, und es überlebt keinen Run-Wechsel als Zustand — es wird ohnehin jeden Frame neu gebaut.
|
||||
Damit bleibt die Ablageregel aus ADR-0002 §6 unberührt.
|
||||
@@ -118,6 +125,38 @@ schiebt eine `Entity` in einen `Vec`.
|
||||
|
||||
Die 10 ns sind eine großzügige Obergrenze, weil sie den teureren Nachbarn als Maßstab nimmt.
|
||||
|
||||
#### Nachtrag vom 2026-08-11 (nachgemessen in #16): der Maßstab war falsch, die Schranke hält
|
||||
|
||||
Die Rechnung oben ist beim Bau des Grids nachgemessen worden — Release-Profil, `HARD_ENEMY_CAP`
|
||||
Gegner, Median über 20 Läufe je System (`the_rebuild_stays_cheaper_than_move_enemies_at_the_hard_cap`
|
||||
in `src/spatial.rs`):
|
||||
|
||||
| System | gemessen bei 10 000 Gegnern |
|
||||
|---|---|
|
||||
| `move_enemies` | **0,021 ms** |
|
||||
| `rebuild_spatial_grid` | **0,057 ms** |
|
||||
|
||||
**Der Rebuild ist rund 2,7× teurer als `move_enemies`, nicht billiger.** Der Absatz oben zählt
|
||||
Arithmetik — zwei Divisionen und zwei `floor` gegen eine Normalisierung mit Wurzel — und übersieht
|
||||
die Kosten, die hier dominieren: `move_enemies` ist ein **linearer** Lauf über eine Tabelle, der
|
||||
Rebuild schreibt in ~7 000 verstreute `Vec`s und zahlt Cache-Misses, die keine FLOP-Zählung sieht.
|
||||
Der `Vec`-Push, den der Absatz als „Rest der Ersparnis" abtut, **ist** der Kostenpunkt.
|
||||
|
||||
Was das ändert und was nicht:
|
||||
|
||||
- **Die Entscheidung bleibt.** Sie stand nie auf dem Vergleich, sondern auf der absoluten Schranke:
|
||||
0,057 ms sind **0,34 % des Frame-Budgets** und liegen unter den hier veranschlagten ≤0,10 ms. Die
|
||||
Logik gesamt bleibt bei ≤0,18 ms.
|
||||
- **Das inkrementelle Update wird dadurch nicht attraktiver.** Es würde denselben verstreuten
|
||||
Schreibzugriff bezahlen, sobald ein Gegner die Zelle wechselt — und bei radialer Konvergenz
|
||||
wechseln ständig welche —, plus Rückindex und Entfernen aus der Mitte eines `Vec`. Der Preis, der
|
||||
hier wehtut, ist genau der, den es nicht einspart.
|
||||
- **Der Maßstab „billiger als `move_enemies`" ist gestrichen.** Er war eine Bequemlichkeit, kein
|
||||
Argument. Revisions-Auslöser 1 unten ist entsprechend auf die absolute Schranke umgestellt.
|
||||
|
||||
Zu messen ist erneut nach dem Wechsel auf echte Texturen und mit dem HUD — dieselbe Auflage, unter
|
||||
der die Zahlen aus #4 stehen.
|
||||
|
||||
**Was ein inkrementelles Update dagegen einspart, ist nach oben durch dieselben ≤0,10 ms begrenzt —
|
||||
und es spart nicht einmal die, denn den Zellindex muss es genauso je Gegner ausrechnen, um einen
|
||||
Zellwechsel überhaupt zu erkennen.** Der Rest der Ersparnis ist der `Vec`-Push. Dagegen steht: ein
|
||||
@@ -280,10 +319,13 @@ und die Auslöse-Bedingungen, wo sie sichtbarer wäre.
|
||||
|
||||
Diese Entscheidung wird neu getroffen, sobald **eines** der folgenden Ereignisse eintritt:
|
||||
|
||||
1. **Der Rebuild ist nicht mehr der billigere Durchlauf.** Eine Messung mit dem Aufbau aus
|
||||
`examples/perf_spike.rs` bei `HARD_ENEMY_CAP` zeigt `rebuild_spatial_grid` teurer als
|
||||
`move_enemies` im selben Frame. Die Entscheidung gegen das inkrementelle Update steht und fällt
|
||||
mit dieser Annahme; ist sie widerlegt, wird sie neu getroffen — nicht vorher.
|
||||
1. **Der Rebuild sprengt seine absolute Schranke.** Eine Messung bei `HARD_ENEMY_CAP` zeigt
|
||||
`rebuild_spatial_grid` über **0,10 ms**. Die Entscheidung gegen das inkrementelle Update steht und
|
||||
fällt mit dieser Schranke; ist sie gerissen, wird sie neu getroffen — nicht vorher.
|
||||
*Fassung bis 2026-08-11: „teurer als `move_enemies` im selben Frame". Dieser Maßstab ist in #16
|
||||
widerlegt worden (Nachtrag zu §3) — der Rebuild war von Anfang an teurer, ohne dass die
|
||||
Entscheidung dadurch fällig wäre. Er ist durch die Schranke ersetzt, auf der das Argument
|
||||
tatsächlich steht.*
|
||||
2. **Radien-Spreizung.** Ein Konsument braucht einen Abfrageradius kleiner als eine halbe Zelle oder
|
||||
größer als ‹20› Zellkanten. Dann trägt **eine** Auflösung nicht mehr für alle Abfragen, und zwei
|
||||
Auflösungen oder eine hierarchische Struktur sind zu prüfen.
|
||||
|
||||
@@ -141,9 +141,11 @@ Auslöse-System und die Projektil-Kollision bedient.
|
||||
Sprung-zu-Welle auf die höchste im Debug-Panel erreichbare Welle.
|
||||
- Bei 10 000 gleichzeitigen Gegnern bleibt die Frametime unter 16,67 ms (Nachmessung mit dem Aufbau
|
||||
aus `examples/perf_spike.rs`, nach dem Wechsel auf echte Sprites zu wiederholen).
|
||||
- Der Rebuild des `SpatialGrid` kostet bei 10 000 Gegnern **nicht mehr als `move_enemies` im selben
|
||||
Frame** (ADR-0004 §3 — die Entscheidung gegen ein inkrementelles Update steht auf dieser Annahme;
|
||||
reißt sie, ist die ADR fällig, nicht die Zahl).
|
||||
- Der Rebuild des `SpatialGrid` kostet bei 10 000 Gegnern **höchstens 0,10 ms** (ADR-0004 §3 — die
|
||||
Entscheidung gegen ein inkrementelles Update steht auf dieser Schranke; reißt sie, ist die ADR
|
||||
fällig, nicht die Zahl). *Bis 2026-08-11 stand hier „nicht mehr als `move_enemies` im selben
|
||||
Frame"; die Messung in #16 hat diesen Maßstab widerlegt — gemessen 0,057 ms gegen 0,021 ms —, ohne
|
||||
die Schranke zu reißen. Siehe den Nachtrag zu ADR-0004 §3.*
|
||||
- Kein Gegner bewegt sich in einem Frame vom Turm **weg**, solange ihn keine Fähigkeit dorthin
|
||||
drückt — die Separationsklemmung macht das konstruktiv unmöglich, und der Turmring pulsiert
|
||||
sichtbar nicht.
|
||||
|
||||
41
src/arena.rs
Normal file
41
src/arena.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! The arena: the disc the tower stands on and enemies walk in across.
|
||||
//!
|
||||
//! Programmer art, no asset files. The arena is scenery and outlives a run, so it is spawned
|
||||
//! once at startup and is *not* bound to the tower entity (ADR-0002 §6).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::balance::Balance;
|
||||
|
||||
/// Draw order. The arena is the backdrop everything else sits on.
|
||||
pub const ARENA_Z: f32 = 0.0;
|
||||
|
||||
const FLOOR_COLOR: Color = Color::srgb(0.10, 0.11, 0.14);
|
||||
const RIM_COLOR: Color = Color::srgb(0.22, 0.24, 0.30);
|
||||
/// How much wider than the arena the rim ring is drawn, so it reads as an outline.
|
||||
const RIM_WIDTH: f32 = 6.0;
|
||||
|
||||
/// Spawns the arena floor, sized from `Res<Balance>` so a changed radius moves the rim
|
||||
/// without any further code change.
|
||||
pub fn spawn_arena(
|
||||
mut commands: Commands,
|
||||
balance: Res<Balance>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<ColorMaterial>>,
|
||||
) {
|
||||
let radius = balance.arena.radius;
|
||||
|
||||
// ponytail: two circle meshes instead of a ring mesh — the outer one is just a slightly
|
||||
// larger disc behind the floor. No asset files, and the visible edge is the actual spawn
|
||||
// rim rather than a decorative square.
|
||||
commands.spawn((
|
||||
Mesh2d(meshes.add(Circle::new(radius + RIM_WIDTH))),
|
||||
MeshMaterial2d(materials.add(RIM_COLOR)),
|
||||
Transform::from_xyz(0.0, 0.0, ARENA_Z),
|
||||
));
|
||||
commands.spawn((
|
||||
Mesh2d(meshes.add(Circle::new(radius))),
|
||||
MeshMaterial2d(materials.add(FLOOR_COLOR)),
|
||||
Transform::from_xyz(0.0, 0.0, ARENA_Z + 0.1),
|
||||
));
|
||||
}
|
||||
138
src/balance/mod.rs
Normal file
138
src/balance/mod.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! Balancing data (ADR-0001).
|
||||
//!
|
||||
//! This module holds **only data and data structures**. No gameplay logic, no Bevy systems.
|
||||
//! Gameplay reads `Res<Balance>` and never sees the constants in [`values`].
|
||||
//!
|
||||
//! The numbers themselves live in [`values`] and are all placeholders; this file only gives
|
||||
//! them their shape. `Balance` is deliberately built from plain fields, numbers and vectors —
|
||||
//! no `Handle`, no `Entity` — so it could be serialized without a redesign if the revision
|
||||
//! triggers of ADR-0001 ever fire. A `serde` derive is explicitly *not* added now.
|
||||
|
||||
mod values;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// The numeric type of every stat and balancing value (ADR-0002 §4).
|
||||
///
|
||||
/// An alias, not a newtype. `f32` carries seven significant digits, which is enough for the
|
||||
/// slice; ADR-0002 revision trigger 1 says when this is reconsidered.
|
||||
pub type Stat = f32;
|
||||
|
||||
/// All balancing data of the game, in one place.
|
||||
///
|
||||
/// Built by exactly one function, [`Balance::from_constants`]. Nothing else constructs a
|
||||
/// `Balance`, which is what makes that function the seam an asset loading path would replace.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct Balance {
|
||||
pub arena: ArenaBalance,
|
||||
pub tower: TowerBalance,
|
||||
pub enemy: EnemyBalance,
|
||||
pub combat: CombatBalance,
|
||||
pub meta: MetaBalance,
|
||||
pub caps: Caps,
|
||||
}
|
||||
|
||||
/// The playing field.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArenaBalance {
|
||||
/// Distance from the tower in the centre to the rim enemies spawn on, in world units.
|
||||
pub radius: Stat,
|
||||
}
|
||||
|
||||
/// Base values of the tower, before any modifier from ADR-0002 is applied.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TowerBalance {
|
||||
/// Damage of a single shot.
|
||||
pub damage: Stat,
|
||||
/// Seconds between two shots — an interval, not a rate (ADR-0002 §3).
|
||||
pub attack_interval: Stat,
|
||||
/// Tower health. The tower dying is what ends a run.
|
||||
pub health: Stat,
|
||||
}
|
||||
|
||||
/// Base values of the single enemy archetype the skeleton knows.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnemyBalance {
|
||||
/// Movement speed in world units per second.
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CombatBalance {
|
||||
/// A projectile within this distance of an enemy counts as a hit, in world units.
|
||||
pub hit_radius: Stat,
|
||||
}
|
||||
|
||||
/// Meta progression income — the currency that survives a run.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MetaBalance {
|
||||
/// Meta currency granted per killed enemy.
|
||||
pub gain_per_kill: Stat,
|
||||
}
|
||||
|
||||
/// Upper bounds for the percentage-point sums of the stat pipeline (ADR-0002 §2).
|
||||
///
|
||||
/// Empty on purpose. ADR-0002 §2 requires every clamp limit to be a balancing value living
|
||||
/// here — one per clamped stat — with no global clamp and no default in code. The skeleton
|
||||
/// has none of the stats that carry a cap (`crit_chance`, `defense_percent`,
|
||||
/// `cooldown_reduction`, `wave_cooldown_reduction`, `free_upgrade_chance`), so there is
|
||||
/// nothing to put in yet.
|
||||
///
|
||||
// ponytail: an empty struct rather than five caps nothing reads. It exists so the place is
|
||||
// named and reachable as `balance.caps.*`; the fields land with the stats that need them in
|
||||
// the thickening phase, and the compiler will point at this struct when they do.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Caps {}
|
||||
|
||||
impl Balance {
|
||||
/// Builds `Balance` from the constants in [`values`].
|
||||
///
|
||||
/// **The one and only constructor** (ADR-0001, decision point 3). Everything that wants
|
||||
/// balancing data goes through the resource this returns; nothing else assembles a
|
||||
/// `Balance`. That makes this function the single seam a later asset loading path — or a
|
||||
/// debug panel that mutates `Balance` in the running process — attaches to.
|
||||
pub fn from_constants() -> Self {
|
||||
Self {
|
||||
arena: ArenaBalance {
|
||||
radius: values::ARENA_RADIUS,
|
||||
},
|
||||
tower: TowerBalance {
|
||||
damage: values::TOWER_DAMAGE,
|
||||
attack_interval: values::TOWER_ATTACK_INTERVAL,
|
||||
health: values::TOWER_HEALTH,
|
||||
},
|
||||
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,
|
||||
},
|
||||
meta: MetaBalance {
|
||||
gain_per_kill: values::META_GAIN_PER_KILL,
|
||||
},
|
||||
caps: Caps::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
84
src/balance/values.rs
Normal file
84
src/balance/values.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! The raw balancing numbers (ADR-0001).
|
||||
//!
|
||||
//! **Every value in this file is a placeholder.** None of them has been tuned, and four of
|
||||
//! the numbers this file will eventually hold are not derivable from any source at all
|
||||
//! (see issue #8) — they can only be found by playing. Treat a value here as "a number that
|
||||
//! lets the skeleton walk", never as a design decision.
|
||||
//!
|
||||
//! This is the only file in the crate that holds balancing numbers. Gameplay code never
|
||||
//! sees these constants; it reads `Res<Balance>` (ADR-0001, decision point 4).
|
||||
|
||||
use super::Stat;
|
||||
|
||||
/// PLACEHOLDER. Distance from the tower to the rim enemies spawn on, in world units.
|
||||
/// Taken from `examples/perf_spike.rs`, where it was chosen so the whole arena fits into a
|
||||
/// 1920x1080 window without frustum culling.
|
||||
pub(super) const ARENA_RADIUS: Stat = 500.0;
|
||||
|
||||
/// PLACEHOLDER. Enemy movement speed in world units per second.
|
||||
pub(super) const ENEMY_SPEED: Stat = 50.0;
|
||||
|
||||
/// PLACEHOLDER. Enemy health of the one archetype the skeleton knows. The real curve is
|
||||
/// per-wave and exponential (spec, "Feststehende Zahlen"); this is the wave-1 base it will
|
||||
/// 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;
|
||||
|
||||
/// PLACEHOLDER. Seconds between two tower shots.
|
||||
///
|
||||
/// This is an **interval, not a rate** — ADR-0002 §3 is explicit that the stat is called
|
||||
/// `AttackInterval` and not `AttackSpeed`, so that "attack speed x1.25" becomes a divisor on
|
||||
/// a duration instead of a second accumulator. The skeleton keeps the same unit.
|
||||
pub(super) const TOWER_ATTACK_INTERVAL: Stat = 0.5;
|
||||
|
||||
/// PLACEHOLDER. Tower health. ADR-0002 §3: `MaxHealth`, mode `Direct`, base > 0.
|
||||
pub(super) const TOWER_HEALTH: Stat = 100.0;
|
||||
|
||||
/// PLACEHOLDER. A projectile within this distance of an enemy counts as a hit, in world
|
||||
/// units. Taken from `examples/perf_spike.rs`.
|
||||
pub(super) const HIT_RADIUS: Stat = 10.0;
|
||||
|
||||
/// PLACEHOLDER. Meta currency granted per killed enemy.
|
||||
///
|
||||
/// The skeleton pays out a flat amount per kill. The spec ("Feststehende Zahlen") calls for
|
||||
/// drop-based meta income — a chance per normal enemy, guaranteed per boss, amount scaling
|
||||
/// with the wave. That shape needs bosses and waves, neither of which exists yet.
|
||||
pub(super) const META_GAIN_PER_KILL: Stat = 1.0;
|
||||
283
src/enemy.rs
Normal file
283
src/enemy.rs
Normal 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(¤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"
|
||||
);
|
||||
}
|
||||
}
|
||||
119
src/main.rs
119
src/main.rs
@@ -1,3 +1,118 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
//! InfiniTower — walking skeleton.
|
||||
//!
|
||||
//! Module layout follows ADR-0001: [`balance`] holds data only and knows nothing about
|
||||
//! gameplay; gameplay modules ([`arena`], [`tower`]) read `Res<Balance>` and never touch a
|
||||
//! balancing constant directly.
|
||||
|
||||
mod arena;
|
||||
/// Some balancing values still exist before the systems that read them: #17 (shooting) and
|
||||
/// #18 (tower death, meta payout) consume the rest. `expect` rather than `allow`, so the
|
||||
/// compiler asks for this line back once the last field has found its reader.
|
||||
#[expect(dead_code, reason = "read by the gameplay tickets #17-#18")]
|
||||
mod balance;
|
||||
mod enemy;
|
||||
mod spatial;
|
||||
mod tower;
|
||||
|
||||
use bevy::camera::ScalingMode;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use balance::Balance;
|
||||
use spatial::SpatialGrid;
|
||||
|
||||
/// The large-scale structure of the program.
|
||||
///
|
||||
/// `Run` is a single playthrough from wave 1 to the tower's death; `Meta` is everything
|
||||
/// between two runs (workshop, cards). The transition between them is where run start and run
|
||||
/// end will hang — it exists from the first commit so that #18 does not have to thread it
|
||||
/// through every system afterwards.
|
||||
#[derive(States, Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
|
||||
pub enum AppState {
|
||||
/// A run is in progress.
|
||||
// ponytail: `Run` is the default so `cargo run` lands in a run and the tower is on screen
|
||||
// without a keypress. There is no meta screen to show yet. #18 brings the real run
|
||||
// start/end and should flip the default to `Meta` — a one-line change here.
|
||||
#[default]
|
||||
Run,
|
||||
/// Between two runs.
|
||||
Meta,
|
||||
}
|
||||
|
||||
/// How much of the view is padding around the arena. Keeps the spawn rim off the screen edge.
|
||||
const VIEW_MARGIN: f32 = 1.08;
|
||||
|
||||
const BACKGROUND: Color = Color::srgb(0.05, 0.05, 0.07);
|
||||
|
||||
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()
|
||||
.add_plugins(DefaultPlugins.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "InfiniTower".into(),
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
}))
|
||||
.insert_resource(ClearColor(BACKGROUND))
|
||||
.insert_resource(SpatialGrid::from_balance(&balance))
|
||||
.insert_resource(balance)
|
||||
.init_state::<AppState>()
|
||||
.add_systems(Startup, (spawn_camera, arena::spawn_arena))
|
||||
.add_systems(OnEnter(AppState::Run), tower::spawn_tower)
|
||||
.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();
|
||||
}
|
||||
|
||||
/// Orthographic camera, fixed on the centre of the arena.
|
||||
///
|
||||
/// The projection is anchored to the arena radius rather than to the window size, so the whole
|
||||
/// spawn periphery stays visible at any window size — that is the acceptance criterion of
|
||||
/// spec feature 1, and tying it to `Res<Balance>` means a changed radius keeps it true.
|
||||
fn spawn_camera(mut commands: Commands, balance: Res<Balance>) {
|
||||
let span = balance.arena.radius * 2.0 * VIEW_MARGIN;
|
||||
commands.spawn((
|
||||
Camera2d,
|
||||
Projection::Orthographic(OrthographicProjection {
|
||||
scaling_mode: ScalingMode::AutoMin {
|
||||
min_width: span,
|
||||
min_height: span,
|
||||
},
|
||||
..OrthographicProjection::default_2d()
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
/// Space toggles between `Run` and `Meta`.
|
||||
///
|
||||
// ponytail: a debug keybinding, not a game mechanic — it is the cheapest way to exercise the
|
||||
// transition by hand while the skeleton has neither a tower death (#18) nor a meta screen.
|
||||
// It goes away with the real run end.
|
||||
fn toggle_run_state(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
state: Res<State<AppState>>,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::Space) {
|
||||
return;
|
||||
}
|
||||
next_state.set(match state.get() {
|
||||
AppState::Run => AppState::Meta,
|
||||
AppState::Meta => AppState::Run,
|
||||
});
|
||||
}
|
||||
|
||||
241
src/spatial.rs
Normal file
241
src/spatial.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
85
src/tower.rs
Normal file
85
src/tower.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! The tower: the one player-owned unit, fixed in the centre of the arena.
|
||||
//!
|
||||
//! The tower entity is spawned on entering [`AppState::Run`] and dies on leaving it
|
||||
//! (ADR-0002 §6). Everything run-bound — `RunUpgrades`, `EffectiveStats`, ability components —
|
||||
//! will hang off this entity, so that the end of a run cleans itself up and no reset list
|
||||
//! exists that a later feature can forget to extend.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::arena::ARENA_Z;
|
||||
|
||||
/// Marks the tower entity. There is exactly one while a run is in progress, and none outside.
|
||||
#[derive(Component)]
|
||||
pub struct Tower;
|
||||
|
||||
/// Edge length of the tower sprite in world units. Programmer art, not a balancing value —
|
||||
/// nothing in the game reads the tower's visual size.
|
||||
const TOWER_SIZE: f32 = 32.0;
|
||||
const TOWER_COLOR: Color = Color::srgb(0.90, 0.85, 0.25);
|
||||
|
||||
/// Spawns the tower. Registered on `OnEnter(AppState::Run)`.
|
||||
pub fn spawn_tower(mut commands: Commands) {
|
||||
commands.spawn((
|
||||
Tower,
|
||||
// The despawn half of ADR-0002 §6. Bevy removes the entity on the transition out of
|
||||
// `Run`, so there is no cleanup system that could be forgotten or ordered wrongly.
|
||||
DespawnOnExit(AppState::Run),
|
||||
Sprite::from_color(TOWER_COLOR, Vec2::splat(TOWER_SIZE)),
|
||||
Transform::from_xyz(0.0, 0.0, ARENA_Z + 1.0),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::balance::Balance;
|
||||
use bevy::state::app::StatesPlugin;
|
||||
|
||||
/// The gameplay half of the app without a window: enough to drive state transitions.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, StatesPlugin))
|
||||
.insert_resource(Balance::from_constants())
|
||||
.init_state::<AppState>()
|
||||
.add_systems(OnEnter(AppState::Run), spawn_tower);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn go_to(app: &mut App, state: AppState) {
|
||||
app.world_mut()
|
||||
.resource_mut::<NextState<AppState>>()
|
||||
.set(state);
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn tower_count(app: &mut App) -> usize {
|
||||
let mut query = app.world_mut().query_filtered::<Entity, With<Tower>>();
|
||||
query.iter(app.world()).count()
|
||||
}
|
||||
|
||||
/// The acceptance criterion of #14: a Meta -> Run -> Meta round trip leaves nothing
|
||||
/// behind. Counting *all* entities, not just towers, is what makes this catch a future
|
||||
/// run-bound entity that someone spawns without tying it to the run.
|
||||
#[test]
|
||||
fn meta_run_meta_leaves_nothing_behind() {
|
||||
let mut app = headless_app();
|
||||
|
||||
go_to(&mut app, AppState::Meta);
|
||||
let between_runs = app.world().entities().len();
|
||||
assert_eq!(tower_count(&mut app), 0, "no tower exists between runs");
|
||||
|
||||
go_to(&mut app, AppState::Run);
|
||||
assert_eq!(tower_count(&mut app), 1, "entering Run spawns exactly one tower");
|
||||
|
||||
go_to(&mut app, AppState::Meta);
|
||||
assert_eq!(tower_count(&mut app), 0, "leaving Run despawns the tower");
|
||||
assert_eq!(
|
||||
app.world().entities().len(),
|
||||
between_runs,
|
||||
"a Meta -> Run -> Meta round trip left entities behind"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user