Compare commits
5 Commits
bcc6d333d4
...
slice/skel
| Author | SHA1 | Date | |
|---|---|---|---|
| f1b85862da | |||
| 0f93a939c0 | |||
| afeb09bdcd | |||
| d9688c74dc | |||
| 158d10b498 |
13
CLAUDE.md
Normal file
13
CLAUDE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# InfiniTower
|
||||
|
||||
Bevy-Spiel in Rust (edition 2024, bevy 0.19).
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues und Specs leben als **Gitea**-Issues auf `gitlab.hive-brain.de/MDietermann/InfiniTower` — trotz des Hostnamens kein GitLab. CLI ist `tea`, nicht `glab`. Siehe `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context: `CONTEXT.md` und `docs/adr/` im Repo-Root, beide werden erst bei Bedarf angelegt. Siehe `docs/agents/domain.md`.
|
||||
@@ -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.
|
||||
|
||||
46
docs/agents/lessons.md
Normal file
46
docs/agents/lessons.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Was man sonst zweimal lernt
|
||||
|
||||
Projektwissen, das weder aus dem Code noch aus den ADRs hervorgeht. Kurz halten — hier steht nur, was beim ersten Mal Zeit gekostet hat.
|
||||
|
||||
## Das Referenz-Wiki ist nicht abrufbar
|
||||
|
||||
Das [Fandom-Wiki zu „The Tower — Idle Tower Defense"](https://the-tower-idle-tower-defense.fandom.com/wiki/The_Tower_-_Idle_Tower_Defense_Wiki) antwortet auf **jede** Unterseite mit `HTTP 402`; Mirrors (`game-vault.net`, `en.namu.wiki`, `tower-hub.com`) laufen in Timeout, 403 oder 502. Das gilt für automatisierte Abrufe, nicht für den Browser.
|
||||
|
||||
Der Primärtext liegt deshalb von Hand gesammelt in **`docs/WikiData.md`**. Wer mehr braucht, öffnet die Seite im Browser und hängt sie dort an — Suchmaschinen-Snippets sind es nicht wert: die erste Recherche lief nur über sie und hatte vier Fehler, darunter einen Boss-HP-Wert, der in Wahrheit zu den Fleet-Gegnern gehörte.
|
||||
|
||||
**Zwei Seiten existieren nicht** — im Browser gegengeprüft, nicht bloß nicht gefunden:
|
||||
|
||||
- die HP-/Schadens-Skalierungsformel pro Welle
|
||||
- die Workshop-Kurven (Level-Zahlen, Kosten, Softcaps)
|
||||
|
||||
Beides ist damit **Design-Freiheitsgrad, nicht fehlender Beleg**. Nicht noch einmal danach suchen lassen.
|
||||
|
||||
## `examples/perf_spike.rs` ist die beste Bevy-0.19-Referenz im Repo
|
||||
|
||||
Bevy 0.19 ist neuer als der Wissensstand gängiger Sprachmodelle, und die API hat sich in mehreren Bereichen geändert. Der Perf-Spike ist verifizierter, laufender Code aus diesem Repo: Fenster, Kamera, Sprites, Spawn, Bewegung, Kollision, Diagnostics.
|
||||
|
||||
Wer gegen Bevy baut, schaut zuerst dort nach, dann in `src/`, dann auf docs.rs — und rekonstruiert die API nicht aus dem Gedächtnis. Widersprechen sich Gedächtnis und Compiler, hat der Compiler recht.
|
||||
|
||||
Der Spike bleibt deshalb liegen, obwohl seine Messung abgeschlossen ist.
|
||||
|
||||
## Die Perf-Zahlen sind eine untere Schranke, kein Versprechen
|
||||
|
||||
Gemessen auf einer **Notebook-iGPU** (Intel Core Ultra 7 265U) mit **untexturierten, gleichfarbigen Sprites**, ohne UI und ohne Audio. Bevy batcht das optimal — die 0,25 µs pro Sprite sind ein Best-Case.
|
||||
|
||||
Die Obergrenze von 10 000 gleichzeitigen Gegnern hat darauf 3–7× Reserve. **Nach dem Wechsel auf echte Texturen und mit dem HUD ist neu zu messen**, nicht zu hoffen. Der Spike ist genau dafür reproduzierbar gehalten; die Kommandos stehen in seinem Kopfkommentar.
|
||||
|
||||
## Werkzeuge
|
||||
|
||||
**`tea` muss ≥ 0.15.0 sein.** Ältere Builds kennen weder `--labels` beim Anlegen noch `issues edit`, `comment` oder `close` — damit fällt die halbe Konvention in `issue-tracker.md` aus, und der Fehler sieht aus wie ein Konfigurationsproblem. `tea --version` prüfen, bevor man lange sucht.
|
||||
|
||||
**Der Rebuild ist schneller im Release-Profil als im Debug** (9–10 s gegen 22–26 s) — das Linken der Debug-Info dominiert. ADR-0001 stützt sich darauf. Die Zahl gilt für ein **warmes** Target-Verzeichnis; auf einer frischen Maschine kompiliert der erste Build die gesamte Bevy-Toolchain.
|
||||
|
||||
## Die Widersprüche in der Spec sind Absicht
|
||||
|
||||
`docs/spec-vertical-slice.md` schließt mit einem Abschnitt „Widersprüche und Lücken" — sechzehn Einträge. Das ist kein liegengebliebener Entwurf, sondern das Ergebnis davon, dass beim Schreiben zum ersten Mal alle Quellen gleichzeitig gelesen wurden. Ein Eintrag hat einen Formelfehler in ADR-0002 aufgedeckt, der Crit Chance auf null rechnete; ein anderer, dass das geplante Debug-Panel den Spielstand hätte zerstören können.
|
||||
|
||||
Wer dort aufräumt, ohne zu entscheiden, löscht die Fundstelle und nicht das Problem. Einträge werden von *offen* auf *entschieden, siehe X* umgestellt — nicht entfernt.
|
||||
|
||||
## Widerspricht der Code einer ADR, gewinnt nicht der Code
|
||||
|
||||
Fällt beim Bauen auf, dass eine Entscheidung nicht trägt, wird **die ADR geändert** und der Fund am zuständigen Ticket vermerkt. Der Code wird nicht daran vorbeigebaut. Das steht so in beiden Karten und ist der Grund, warum die ADRs nach der ersten Bauphase noch etwas wert sind.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
32
src/main.rs
32
src/main.rs
@@ -5,17 +5,20 @@
|
||||
//! balancing constant directly.
|
||||
|
||||
mod arena;
|
||||
/// Most balancing values exist before the systems that read them: #16 (enemies), #17
|
||||
/// (shooting) and #18 (tower death, meta payout) consume them. `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 #16-#18")]
|
||||
/// 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.
|
||||
///
|
||||
@@ -41,6 +44,10 @@ 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 {
|
||||
@@ -50,12 +57,25 @@ fn main() {
|
||||
..default()
|
||||
}))
|
||||
.insert_resource(ClearColor(BACKGROUND))
|
||||
// The single source of every balancing number (ADR-0001).
|
||||
.insert_resource(Balance::from_constants())
|
||||
.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();
|
||||
}
|
||||
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user