use crate::{
animations::Animation,
behavior::projectile::ProjectileBehavior,
command::{Command, CommandBuffer},
context::{BehaviorCtx, EventContext},
enemy::{Corpse, Enemy},
pool::GroundPool,
registry::{DamageRegistry, EnemyRegistry, StatusRegistry, WeaponID, WeaponRegistry},
rng::Rng,
spatial::{EnemySnapshot, Grid, SpatialQuery},
tower::Tower,
types::{GameEvent, Source},
};
use macroquad::math::Vec2;
pub const ARENA_W: f32 = 800.0;
pub const ARENA_H: f32 = 800.0;
pub const ARENA_X: f32 = 50.0;
pub const ARENA_Y: f32 = 50.0;
pub const CENTER_X: f32 = ARENA_X + ARENA_W * 0.5;
pub const CENTER_Y: f32 = ARENA_Y + ARENA_H * 0.5;
pub const TOWER_RADIUS: f32 = 16.0;
pub const ENEMY_RADIUS: f32 = 10.0;
pub const PROJ_RADIUS: f32 = 5.0;
pub const ENEMY_SPEED: f32 = 60.0;
pub const PROJ_SPEED: f32 = 300.0;
pub const FIRE_RATE: f32 = 4.2;
pub const WAVE_INTERVAL: f32 = 2.5;
pub const MAX_ENEMIES: usize = 8;
pub const ENEMY_MAX_HP: f32 = 3.0;
pub type EntityId = u64;
pub enum UiAction {
PickWeaponCrate(WeaponID),
ClosePanel,
}
#[derive(PartialEq)]
pub struct WeaponCrateState {
pub offered: [WeaponID; 3],
pub cursor: usize,
}
#[derive(PartialEq)]
pub enum Screen {
Playing,
WeaponCrate(WeaponCrateState),
}
#[derive(Clone)]
pub struct Projectile {
pub id: EntityId,
pub pos: Vec2,
pub vel: Vec2,
pub born: f32,
pub lifetime: f32,
pub source: Source,
pub radius: f32,
pub behaviors: Vec<Box<dyn ProjectileBehavior>>,
}
impl Projectile {
pub fn with_vel(&self, v: Vec2) -> Projectile {
let mut p = self.clone();
p.vel = v;
p
}
pub fn expired(&self, now: f32) -> bool {
now - self.born >= self.lifetime
}
}
#[derive(PartialEq, Clone, Copy, Debug)]
pub enum PlayerStatus {
None,
ManuallyClosed,
ToOpen,
}
pub struct Player {
pub level_ups: u32,
pub level: u32,
pub xp: u32,
next_level: u32,
pub status: PlayerStatus,
}
impl Player {
const BASE_XP: f32 = 10.0;
const GROWTH: f32 = 1.6;
fn xp_required(level: u32) -> u32 {
(Self::BASE_XP * Self::GROWTH.powi(level as i32)).round() as u32
}
pub fn new() -> Self {
Self {
level_ups: 0,
level: 0,
xp: 0,
next_level: Self::xp_required(0),
status: PlayerStatus::None,
}
}
pub fn add_xp(&mut self, xp: u32) {
self.xp += xp;
while self.xp >= self.next_level {
self.xp -= self.next_level;
self.level += 1;
self.level_ups += 1;
self.next_level = Self::xp_required(self.level);
}
}
pub fn consume_level(&mut self) {
self.level_ups = self.level_ups.saturating_sub(1);
}
pub fn has_level_ups(&self) -> bool {
self.level_ups > 0
}
}
pub struct World {
pub state: Screen,
pub tower: Tower,
pub player: Player,
pub enemies: Vec<Enemy>,
pub pools: Vec<GroundPool>,
pub animations: Vec<Animation>,
pub projectiles: Vec<Projectile>,
pub wave_timer: f32,
pub weapon_registry: WeaponRegistry,
pub enemy_registry: EnemyRegistry,
pub damage_registry: DamageRegistry,
pub status_registry: StatusRegistry,
pub grid: Grid,
pub rng: Rng,
pub now: f32,
pub arena: Arena,
next_id: EntityId,
}
impl World {
pub fn new() -> Self {
let arena = Arena {
width: ARENA_W,
height: ARENA_H,
};
let cell_size = ENEMY_RADIUS;
let damage_registry = DamageRegistry::new();
let weapon_registry = WeaponRegistry::new(&damage_registry);
let enemy_registry = EnemyRegistry::new();
let status_registry = StatusRegistry::new(&damage_registry);
Self {
state: Screen::Playing,
tower: Tower::new(&weapon_registry),
player: Player::new(),
enemies: Vec::new(),
projectiles: Vec::new(),
animations: Vec::new(),
pools: Vec::new(),
weapon_registry,
enemy_registry,
damage_registry,
rng: Rng::seeded(1),
grid: Grid::new(arena.width, arena.height, cell_size),
arena,
wave_timer: 0.0,
next_id: 0,
now: 0.0,
status_registry,
}
}
pub fn alloc_id(&mut self) -> EntityId {
let id = self.next_id;
self.next_id += 1;
id
}
pub fn enemy_mut(&mut self, id: EntityId) -> Option<&mut Enemy> {
self.enemies.iter_mut().find(|e| e.id == id)
}
pub fn spawn_projectile(&mut self, mut p: Projectile) {
p.id = self.alloc_id();
self.projectiles.push(p);
}
pub fn spawn_pool(&mut self, p: GroundPool) {
self.pools.push(p);
}
pub fn spawn_enemy(&mut self, mut e: Enemy) {
e.id = self.alloc_id();
self.enemies.push(e);
}
fn despawn(&mut self, id: EntityId) {
self.enemies.retain(|e| e.id != id);
self.projectiles.retain(|p| p.id != id);
}
/// Picks 3 offers, with repeats if the catalog has fewer than 3 entries — the catalog
/// is expected to grow, but this should never panic in the meantime.
fn roll_weapon_offers(&mut self) -> [WeaponID; 3] {
let all = self.weapon_registry.all();
std::array::from_fn(|_| all[self.rng.next_u32() as usize % all.len()])
}
/// The one place a weapon crate gets opened, so a fresh level-up and a re-roll after
/// dismissing one crate (when more level-ups are still queued) can't drift apart.
pub fn open_weapon_crate(&mut self) {
if !self.player.has_level_ups() {
return;
}
let offered = self.roll_weapon_offers();
self.state = Screen::WeaponCrate(WeaponCrateState { offered, cursor: 0 });
}
/// Called when the player picks or closes a weapon crate. If leveling up granted more
/// than one pick (a big xp reward crossed several thresholds at once), immediately
/// rolls the next crate instead of dropping back to `Playing` with picks still owed.
pub fn dismiss_weapon_crate(&mut self) {
self.player.consume_level();
if self.player.level_ups > 0 {
self.open_weapon_crate();
} else {
self.state = Screen::Playing;
}
}
/// Just hides the panel — any pending level-ups survive, since closing without
/// picking shouldn't spend a credit you haven't used. `Space` (see `main.rs`) is what
/// brings the panel back later while `level_ups > 0`.
pub fn close_weapon_crate_without_choice(&mut self) {
self.state = Screen::Playing;
self.player.status = PlayerStatus::ManuallyClosed;
}
}
// convenience constructor for the shared core
pub fn ctx<'a>(
cmd: &'a mut CommandBuffer,
query: &'a SpatialQuery<'a>,
rng: &'a mut Rng,
source: Source,
now: f32,
) -> BehaviorCtx<'a> {
BehaviorCtx {
cmd,
query,
rng,
source,
now,
}
}
pub struct Arena {
pub width: f32,
pub height: f32,
}
impl Arena {
pub fn wall_hit(&self, p: &Projectile) -> Option<Vec2> {
wall_hit(p, self)
}
}
pub fn wall_hit(p: &Projectile, a: &Arena) -> Option<Vec2> {
let mut n = Vec2 { x: 0.0, y: 0.0 };
if p.pos.x - p.radius < 0.0 {
n.x += 1.0;
} else if p.pos.x + p.radius > a.width {
n.x -= 1.0;
}
if p.pos.y - p.radius < 0.0 {
n.y += 1.0;
} else if p.pos.y + p.radius > a.height {
n.y -= 1.0;
}
if n.x == 0.0 && n.y == 0.0 {
None
} else {
Some(n.normalize())
}
}
/// The ONE function that mutates the world from commands. Intentionally dumb: it never
/// calls back into behaviors, so there's no re-entrancy. Deaths caused here have their
/// consequences (on_death, drops, emits) handled in later phases, not recursively.
pub fn apply_commands(world: &mut World, buf: CommandBuffer, events: &mut Vec<GameEvent>) {
for cmd in buf.cmds {
match cmd {
Command::SpawnProjectile(p) => world.spawn_projectile(p),
Command::SpawnPool(p) => world.spawn_pool(p),
Command::SpawnEnemy(e) => world.spawn_enemy(e),
Command::Damage {
target,
amount,
source,
delivery,
} => {
if let Some(e) = world.enemy_mut(target) {
let d: f32;
match e.apply_damage(amount, source) {
crate::enemy::EnemyState::Alive(dmg) => d = dmg,
crate::enemy::EnemyState::Killed(dmg) => {
d = dmg;
events.push(GameEvent::EnemyKilled(Corpse::of(e)));
}
}
if matches!(source, Source::Tower) {
world.tower.meters.record(delivery, world.now, d);
}
}
}
Command::ApplyStatus { target, status } => {
if let Some(e) = world.enemy_mut(target) {
e.push_status(status);
}
}
Command::Despawn(id) => world.despawn(id),
Command::GrantResource { kind, amount } => match kind {
crate::types::ResourceKind::Wp => world.player.add_xp(amount),
crate::types::ResourceKind::Sp => {}
crate::types::ResourceKind::Tp => {}
},
Command::Emit(ev) => events.push(ev),
Command::SpawnAnimation(mut animation) => {
animation.id = world.alloc_id();
world.animations.push(animation);
}
Command::Replicate(transform) => match transform.entity {
crate::command::TransformEntity::Enemy => {
let mut id = None;
let mut behaviors = None;
let mut tier = 1;
if let Some(e) = world.enemy_mut(transform.id) {
id = Some(e.kind);
behaviors = Some(e.behaviors.clone());
tier = e.tier;
}
if let Some(id) = id
&& let Some(behaviors) = behaviors
{
let mut template = world.enemy_registry.make_kind(id, tier);
template.behaviors = behaviors;
template.pos = transform.location;
template.id = world.alloc_id(); // else every split child is id 0
world.enemies.push(template);
}
}
},
}
}
}
pub fn deliver_events(world: &mut World, mut events: Vec<GameEvent>) {
let mut guard = 0;
while !events.is_empty() && guard < 8 {
guard += 1;
let mut cmd = CommandBuffer::default();
let mut rng = world.rng.fork();
// read side for this wave (scoped so the &world.grid borrow ends before apply_commands)
{
let snapshot = EnemySnapshot::collect(&world.enemies);
world.grid.rebuild(&snapshot);
let query = SpatialQuery::new(&snapshot, &world.grid);
for ev in &events {
let mut ec = EventContext {
core: ctx(&mut cmd, &query, &mut rng, Source::None, world.now),
};
handle_event(ev, &mut ec); // ← THE missing reaction: pushes commands
}
}
let mut next = Vec::new();
apply_commands(world, cmd, &mut next); // Command::Emit → next → next wave
events = next;
}
// (optional) if guard hit 8 with events remaining, a cascade ran away — log/debug_assert
}
fn handle_event(ev: &GameEvent, c: &mut EventContext) {
match ev {
GameEvent::EnemyKilled(corpse) => {
// built-in death VFX (kind → animation), spawned at the corpse position
c.cmd.spawn_animation(Animation {
kind: corpse.death_anim,
pos: corpse.pos,
duration: 2.5,
elapsed: 0.0,
id: 0,
});
c.cmd
.grant_resource(crate::types::ResourceKind::Wp, corpse.xp);
}
GameEvent::Custom(_) => {}
GameEvent::DespawnEnemy(e) => {
c.cmd.despawn(*e);
}
}
}