diff --git a/src/arena.rs b/src/arena.rs new file mode 100644 index 0000000..8a8c9ea --- /dev/null +++ b/src/arena.rs @@ -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` so a changed radius moves the rim +/// without any further code change. +pub fn spawn_arena( + mut commands: Commands, + balance: Res, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + 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), + )); +} diff --git a/src/balance/mod.rs b/src/balance/mod.rs new file mode 100644 index 0000000..7bb21b6 --- /dev/null +++ b/src/balance/mod.rs @@ -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` 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(), + } + } +} diff --git a/src/balance/values.rs b/src/balance/values.rs new file mode 100644 index 0000000..1b799c8 --- /dev/null +++ b/src/balance/values.rs @@ -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` (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; diff --git a/src/main.rs b/src/main.rs index e7a11a9..246597d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,98 @@ -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` 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::() + .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` means a changed radius keeps it true. +fn spawn_camera(mut commands: Commands, balance: Res) { + 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>, + state: Res>, + mut next_state: ResMut>, +) { + if !keys.just_pressed(KeyCode::Space) { + return; + } + next_state.set(match state.get() { + AppState::Run => AppState::Meta, + AppState::Meta => AppState::Run, + }); } diff --git a/src/tower.rs b/src/tower.rs new file mode 100644 index 0000000..441980f --- /dev/null +++ b/src/tower.rs @@ -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::() + .add_systems(OnEnter(AppState::Run), spawn_tower); + app.update(); + app + } + + fn go_to(app: &mut App, state: AppState) { + app.world_mut() + .resource_mut::>() + .set(state); + app.update(); + } + + fn tower_count(app: &mut App) -> usize { + let mut query = app.world_mut().query_filtered::>(); + 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" + ); + } +}