Files
InfiniTower/src/balance/mod.rs
MDietermann 0f93a939c0 feat: add enemies, spatial grid and soft separation
Enemies spawn on the arena rim, walk radially at the tower and stop at the
attack range. The spawner is cap-driven — it tops the field up to the target
population instead of placing N enemies on a schedule — so the target
population is the binding quantity the difficulty curve will stand on.

The spatial grid of ADR-0004 lands with them, because it now has its first
consumer and #17 needs it unchanged for target selection and projectile
collision. One full rebuild per frame is the only pass over the enemy set;
every consumer asks the grid.

Soft separation is a clamped positional offset, not a force: overlap stays
allowed, and capping the offset below one movement step is what keeps the
tower ring from pulsing.

Closes #16.
2026-08-11 09:03:36 +02:00

139 lines
5.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Balancing data (ADR-0001).
//!
//! This module holds **only data and data structures**. No gameplay logic, no Bevy systems.
//! Gameplay reads `Res<Balance>` and never sees the constants in [`values`].
//!
//! The numbers themselves live in [`values`] and are all placeholders; this file only gives
//! them their shape. `Balance` is deliberately built from plain fields, numbers and vectors —
//! no `Handle`, no `Entity` — so it could be serialized without a redesign if the revision
//! triggers of ADR-0001 ever fire. A `serde` derive is explicitly *not* added now.
mod values;
use bevy::prelude::*;
/// The numeric type of every stat and balancing value (ADR-0002 §4).
///
/// An alias, not a newtype. `f32` carries seven significant digits, which is enough for the
/// slice; ADR-0002 revision trigger 1 says when this is reconsidered.
pub type Stat = f32;
/// All balancing data of the game, in one place.
///
/// Built by exactly one function, [`Balance::from_constants`]. Nothing else constructs a
/// `Balance`, which is what makes that function the seam an asset loading path would replace.
#[derive(Resource, Debug, Clone)]
pub struct Balance {
pub arena: ArenaBalance,
pub tower: TowerBalance,
pub enemy: EnemyBalance,
pub combat: CombatBalance,
pub meta: MetaBalance,
pub caps: Caps,
}
/// The playing field.
#[derive(Debug, Clone)]
pub struct ArenaBalance {
/// Distance from the tower in the centre to the rim enemies spawn on, in world units.
pub radius: Stat,
}
/// Base values of the tower, before any modifier from ADR-0002 is applied.
#[derive(Debug, Clone)]
pub struct TowerBalance {
/// Damage of a single shot.
pub damage: Stat,
/// Seconds between two shots — an interval, not a rate (ADR-0002 §3).
pub attack_interval: Stat,
/// Tower health. The tower dying is what ends a run.
pub health: Stat,
}
/// Base values of the single enemy archetype the skeleton knows.
#[derive(Debug, Clone)]
pub struct EnemyBalance {
/// Movement speed in world units per second.
pub speed: Stat,
/// Health at wave 1.
pub health: Stat,
/// Distance from the tower centre at which an enemy stops walking.
pub attack_range: Stat,
/// Enemies closer than this push each other apart (ADR-0004 §5).
pub separation_radius: Stat,
/// ‹k›: how many neighbours one enemy takes into account (ADR-0004 §5).
pub separation_neighbours: usize,
/// ‹f› < 1: the separation offset is clamped to `f x (speed x dt)` (ADR-0004 §5).
pub separation_clamp: Stat,
/// Living enemies the spawner fills the field up to. Becomes `target_population(wave)`.
pub target_population: usize,
/// `HARD_ENEMY_CAP`: the measured performance limit, never exceeded by anything.
pub hard_cap: usize,
}
/// Values that describe how things touch each other.
#[derive(Debug, Clone)]
pub struct CombatBalance {
/// A projectile within this distance of an enemy counts as a hit, in world units.
pub hit_radius: Stat,
}
/// Meta progression income — the currency that survives a run.
#[derive(Debug, Clone)]
pub struct MetaBalance {
/// Meta currency granted per killed enemy.
pub gain_per_kill: Stat,
}
/// Upper bounds for the percentage-point sums of the stat pipeline (ADR-0002 §2).
///
/// Empty on purpose. ADR-0002 §2 requires every clamp limit to be a balancing value living
/// here — one per clamped stat — with no global clamp and no default in code. The skeleton
/// has none of the stats that carry a cap (`crit_chance`, `defense_percent`,
/// `cooldown_reduction`, `wave_cooldown_reduction`, `free_upgrade_chance`), so there is
/// nothing to put in yet.
///
// ponytail: an empty struct rather than five caps nothing reads. It exists so the place is
// named and reachable as `balance.caps.*`; the fields land with the stats that need them in
// the thickening phase, and the compiler will point at this struct when they do.
#[derive(Debug, Clone, Default)]
pub struct Caps {}
impl Balance {
/// Builds `Balance` from the constants in [`values`].
///
/// **The one and only constructor** (ADR-0001, decision point 3). Everything that wants
/// balancing data goes through the resource this returns; nothing else assembles a
/// `Balance`. That makes this function the single seam a later asset loading path — or a
/// debug panel that mutates `Balance` in the running process — attaches to.
pub fn from_constants() -> Self {
Self {
arena: ArenaBalance {
radius: values::ARENA_RADIUS,
},
tower: TowerBalance {
damage: values::TOWER_DAMAGE,
attack_interval: values::TOWER_ATTACK_INTERVAL,
health: values::TOWER_HEALTH,
},
enemy: EnemyBalance {
speed: values::ENEMY_SPEED,
health: values::ENEMY_HEALTH,
attack_range: values::ENEMY_ATTACK_RANGE,
separation_radius: values::ENEMY_SEPARATION_RADIUS,
separation_neighbours: values::ENEMY_SEPARATION_NEIGHBOURS,
separation_clamp: values::ENEMY_SEPARATION_CLAMP,
target_population: values::ENEMY_TARGET_POPULATION,
hard_cap: values::HARD_ENEMY_CAP,
},
combat: CombatBalance {
hit_radius: values::HIT_RADIUS,
},
meta: MetaBalance {
gain_per_kill: values::META_GAIN_PER_KILL,
},
caps: Caps::default(),
}
}
}