feat: add app skeleton with balance module, arena and tower
This commit is contained in:
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),
|
||||||
|
));
|
||||||
|
}
|
||||||
120
src/balance/mod.rs
Normal file
120
src/balance/mod.rs
Normal 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
48
src/balance/values.rs
Normal 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;
|
||||||
99
src/main.rs
99
src/main.rs
@@ -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
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