Compare commits

..

1 Commits

Author SHA1 Message Date
bcc6d333d4 feat: add app skeleton with balance module, arena and tower 2026-08-10 15:50:58 +02:00
7 changed files with 391 additions and 61 deletions

View File

@@ -1,13 +0,0 @@
# 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`.

View File

@@ -1,46 +0,0 @@
# 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.

41
src/arena.rs Normal file
View 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),
));
}

120
src/balance/mod.rs Normal file
View File

@@ -0,0 +1,120 @@
//! 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,
}
/// 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,
},
combat: CombatBalance {
hit_radius: values::HIT_RADIUS,
},
meta: MetaBalance {
gain_per_kill: values::META_GAIN_PER_KILL,
},
caps: Caps::default(),
}
}
}

48
src/balance/values.rs Normal file
View File

@@ -0,0 +1,48 @@
//! 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. 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;

View File

@@ -1,3 +1,98 @@
fn main() { //! InfiniTower — walking skeleton.
println!("Hello, world!"); //!
//! 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;
/// 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")]
mod balance;
mod tower;
use bevy::camera::ScalingMode;
use bevy::prelude::*;
use balance::Balance;
/// 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() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "InfiniTower".into(),
..default()
}),
..default()
}))
.insert_resource(ClearColor(BACKGROUND))
// The single source of every balancing number (ADR-0001).
.insert_resource(Balance::from_constants())
.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)
.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,
});
} }

85
src/tower.rs Normal file
View 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"
);
}
}