use macroquad::prelude::*;
use crate::{
behavior::{
enemy::{dispatch_death, dispatch_enemy_tick},
pool::dispatch_pool_tick,
projectile::{
ProjectileBehavior, dispatch_enemy_hit, dispatch_expire, dispatch_projectile_tick,
dispatch_wall_hit,
},
status::dispatch_status_tick,
},
command::CommandBuffer,
context::{DeathContext, ExpireContext, HitContext, TickContext},
enemy::*,
geometry::normal_off_enemy,
rng::Rng,
spatial::{EnemySnapshot, SpatialQuery},
types::{DamageDelivery, GameEvent},
world::*,
};
fn run_system(world: &mut World, f: impl FnOnce(&mut World, &mut CommandBuffer, &mut Rng)) {
let mut cmd = CommandBuffer::default();
let mut rng = world.rng.fork();
f(world, &mut cmd, &mut rng);
let mut events = Vec::new();
apply_commands(world, cmd, &mut events);
deliver_events(world, events);
}
fn pool_pass(world: &mut World, dt: f32) {
run_system(world, |world, cmd, rng| {
let snapshot = EnemySnapshot::collect(&world.enemies);
world.grid.rebuild(&snapshot);
let query = SpatialQuery::new(&snapshot, &world.grid); // borrows world.grid
for pool in world.pools.iter_mut() {
// disjoint field — coexists
let mut tc = TickContext {
core: ctx(cmd, &query, rng, pool.source, world.now),
dt,
};
dispatch_pool_tick(pool, &mut tc); // mem::take, like dispatch_tick
}
});
}
pub fn step_world(world: &mut World, dt: f32) {
world.now += dt;
tick_wave(world, dt);
tick_enemies(world, dt);
tick_tower(world, dt);
tick_projectiles(world, dt);
death_sweep(world);
animation_system(world, dt);
}
pub fn tick_wave(world: &mut World, dt: f32) {
world.wave_timer -= dt;
if world.wave_timer <= 0.0 && world.enemies.len() < MAX_ENEMIES {
let blob = world.enemy_registry.id_of("blob").unwrap();
let tier = (10.0 + world.now) / 10.0;
println!("Tier {} at {}", tier, world.now);
let mut enemy = world.enemy_registry.make_kind(blob, tier.round() as u32);
enemy.pos = Vec2 {
x: 0.0,
y: world.arena.height * 0.5,
};
world.spawn_enemy(enemy);
world.wave_timer = WAVE_INTERVAL;
}
}
pub fn animation_system(world: &mut World, dt: f32) {
let mut removed: Vec<EntityId> = Vec::new();
for animation in &mut world.animations {
animation.elapsed += dt;
if animation.duration - animation.elapsed <= 0.0 {
removed.push(animation.id);
}
animation.animate();
}
world.animations.retain(|a| !removed.contains(&a.id));
}
pub fn tick_enemies(world: &mut World, dt: f32) {
run_system(world, |world, cmd, rng| {
let snapshot = EnemySnapshot::collect(&world.enemies);
world.grid.rebuild(&snapshot);
let query = SpatialQuery::new(&snapshot, &world.grid);
for e in world.enemies.iter_mut() {
let mut c = TickContext {
core: ctx(cmd, &query, rng, crate::types::Source::None, world.now),
dt,
};
dispatch_enemy_tick(e, &mut c);
dispatch_status_tick(e, &mut c);
e.pos += e.vel * dt;
}
})
}
pub fn tick_tower(world: &mut World, dt: f32) {
let center = Vec2::new(CENTER_X, CENTER_Y);
if let Some(nearest) = world
.enemies
.iter()
.min_by(|a, b| a.pos.distance(center).total_cmp(&b.pos.distance(center)))
{
let dir = nearest.pos - center;
world.tower.angle = dir.y.atan2(dir.x);
}
world.tower.cooldown -= dt;
if world.tower.cooldown <= 0.0 && !world.enemies.is_empty() {
world.tower.cooldown = 1.0 / FIRE_RATE;
let vel = Vec2::new(world.tower.angle.cos(), world.tower.angle.sin()) * PROJ_SPEED;
let id = world.alloc_id();
// Materialized fresh from the tower's (id, tier) loadout every shot — `Tower`
// holds no `Box<dyn ProjectileBehavior>` state of its own, so a tier bump from a
// weapon-crate pick is picked up on the very next shot with no separate rebuild step.
let mut behaviors: Vec<Box<dyn ProjectileBehavior>> = world
.tower
.loadout()
.map(|(wid, tier)| world.weapon_registry.make(wid, tier))
.collect();
behaviors.sort_by_key(|b| b.priority());
world.projectiles.push(Projectile {
id,
pos: world.tower.pos,
vel,
born: world.now,
lifetime: 1000.0,
source: crate::types::Source::Tower,
radius: PROJ_RADIUS,
behaviors,
});
}
}
fn death_sweep(world: &mut World) {
run_system(world, |world, cmd, rng| {
let snapshot = EnemySnapshot::collect(&world.enemies);
world.grid.rebuild(&snapshot);
let query = SpatialQuery::new(&snapshot, &world.grid); // borrows world.grid
for e in world.enemies.iter_mut() {
if !e.alive() {
let mut dc = DeathContext {
core: ctx(cmd, &query, rng, e.killer, world.now),
pos: e.pos,
killer: e.killer,
};
dispatch_death(e, &mut dc); // ← on_death runs HERE, on the live enemy
cmd.emit(GameEvent::EnemyKilled(Corpse::of(e))); // ← lightweight notify for OTHERS
}
}
});
world.enemies.retain(|e| e.alive());
// Deferred until after the loop/borrow above ends: opening a crate needs `&mut World`
// as a whole (rng + registry + state), which can't be split from the `enemies`
// borrow the way a single-field access like `world.player.add_xp` can.
if world.player.has_level_ups()
&& world.player.status != PlayerStatus::ManuallyClosed
&& matches!(world.state, Screen::Playing)
{
world.open_weapon_crate();
}
}
pub fn tick_projectiles(world: &mut World, dt: f32) {
run_system(world, |world, cmd, rng| {
let snapshot = EnemySnapshot::collect(&world.enemies);
world.grid.rebuild(&snapshot);
let query = SpatialQuery::new(&snapshot, &world.grid);
let mut expired: Vec<EntityId> = Vec::new();
for p in world.projectiles.iter_mut() {
{
let mut tc = TickContext {
core: ctx(cmd, &query, rng, p.source, world.now),
dt,
};
dispatch_projectile_tick(p, &mut tc);
}
p.pos += p.vel * dt;
let core = ctx(cmd, &query, rng, p.source, world.now);
let mut hc = HitContext::new(core, p.pos, Vec2::ZERO);
if let Some(eid) = query.first_hit(p.pos, p.radius)
&& let Some(e) = world.enemies.iter_mut().find(|e| e.id == eid)
{
hc.normal = normal_off_enemy(p, e);
let consumed = dispatch_enemy_hit(p, e, &mut hc);
if consumed {
expired.push(p.id);
}
hc.core
.cmd
.damage(eid, hc.damage.clone(), hc.source, DamageDelivery::Hit);
for status in hc.status.drain(..) {
hc.core.cmd.apply_status(eid, status);
}
} else if let Some(n) = world.arena.wall_hit(p) {
hc.normal = n;
if dispatch_wall_hit(p, &mut hc) {
expired.push(p.id)
}
}
if p.expired(world.now) {
let mut ec = ExpireContext {
core: ctx(cmd, &query, rng, p.source, world.now),
reason: crate::context::ExpireReason::Lifetime,
};
dispatch_expire(p, &mut ec);
expired.push(p.id);
}
}
world.projectiles.retain(|p| !expired.contains(&p.id));
});
}
struct CrateLayout {
cards: [Rect; 3],
}
fn crate_layout(sw: f32, sh: f32) -> CrateLayout {
let (bw, bh, gap) = (200.0, 260.0, 24.0);
let total = bw * 3.0 + gap * 2.0;
let (x0, y0) = ((sw - total) / 2.0, (sh - bh) / 2.0);
CrateLayout {
cards: std::array::from_fn(|i| Rect::new(x0 + i as f32 * (bw + gap), y0, bw, bh)),
}
}
fn draw_box(r: Rect, selected: bool) {
let bg = if selected {
Color::new(0.20, 0.30, 0.50, 1.0)
} else {
Color::new(0.10, 0.10, 0.12, 1.0)
};
draw_rectangle(r.x, r.y, r.w, r.h, bg);
draw_rectangle_lines(
r.x,
r.y,
r.w,
r.h,
2.0,
if selected { YELLOW } else { GRAY },
);
}
fn draw_centered(text: String, cx: f32, y: f32, size: u16, color: Color) {
let d = measure_text(&text, None, size, 1.0);
draw_text(text, cx - d.width / 2.0, y, size as f32, color);
}
pub fn weapons_crate(world: &mut World, _t: f64, _dt: f32) -> Option<UiAction> {
let Screen::WeaponCrate(s) = &mut world.state else {
return None;
};
let offers = s.offered;
let lay = crate_layout(screen_width(), screen_height());
if is_key_pressed(KeyCode::Left) && s.cursor > 0 {
s.cursor -= 1
}
if is_key_pressed(KeyCode::Right) && s.cursor < 2 {
s.cursor += 1
}
if is_key_pressed(KeyCode::Space) {
return Some(UiAction::ClosePanel);
}
let mut chosen = None;
if is_key_pressed(KeyCode::Enter) {
chosen = Some(s.cursor as u32)
}
// draw — read-only over s + lay
for (i, r) in lay.cards.iter().enumerate() {
draw_box(*r, i == s.cursor);
let id = offers[i];
let name = world.weapon_registry.display_name(id);
draw_centered(name.to_owned(), r.x + r.w / 2.0, r.y + 40.0, 24, WHITE);
}
chosen.map(|e| UiAction::PickWeaponCrate(offers[e as usize]))
}
pub fn apply_action(world: &mut World, action: UiAction) {
match action {
UiAction::PickWeaponCrate(id) => {
world.tower.equip(id); // adds it fresh at tier 1, or bumps an existing tier
world.dismiss_weapon_crate();
world.player.status = PlayerStatus::None
}
UiAction::ClosePanel => world.close_weapon_crate_without_choice(),
}
}
pub fn draw_simulation(world: &World) {
clear_background(Color::from_rgba(18, 18, 28, 255));
// Arena
draw_rectangle_lines(ARENA_X, ARENA_Y, ARENA_W, ARENA_H, 2.0, DARKGRAY);
// Spawn point markers
for sp in &[
Vec2::new(CENTER_X, ARENA_Y),
Vec2::new(ARENA_X + ARENA_W, CENTER_Y),
Vec2::new(CENTER_X, ARENA_Y + ARENA_H),
Vec2::new(ARENA_X, CENTER_Y),
] {
draw_circle_lines(sp.x, sp.y, 8.0, 1.5, DARKGRAY);
}
// Tower
let t = &world.tower;
draw_circle(t.pos.x, t.pos.y, TOWER_RADIUS, BLUE);
let tip = t.pos + Vec2::new(t.angle.cos(), t.angle.sin()) * (TOWER_RADIUS + 14.0);
draw_line(t.pos.x, t.pos.y, tip.x, tip.y, 4.0, SKYBLUE);
// Enemies
for enemy in &world.enemies {
enemy.animate();
}
// Projectiles
for proj in &world.projectiles {
draw_circle(proj.pos.x, proj.pos.y, PROJ_RADIUS, YELLOW);
}
for animation in &world.animations {
animation.animate();
}
draw_text(format!("FPS: {}", get_fps()), 10.0, 20.0, 18.0, GRAY);
draw_text(
format!("Hit DPS: {:.1}", world.tower.meters.hit.dps()),
10.0,
20.0 + 18.0,
18.0,
GRAY,
);
draw_text(
format!("DoT DPS: {:.1}", world.tower.meters.dot.dps()),
10.0,
20.0 + 18.0 * 2.0,
18.0,
GRAY,
);
draw_text(
format!("Total DPS: {:.1}", world.tower.meters.combined_dps()),
10.0,
20.0 + 18.0 * 3.0,
18.0,
GRAY,
);
}