use std::collections::HashMap; use crate::TICKS_PER_MOVE; use crate::world::World; pub struct Sim { pub world: World, /// Base tick counter (24 Hz). Movement resolves every `TICKS_PER_MOVE`-th tick. pub tick: u32, /// Each entity's intent for the next movement window. Setting it again replaces /// it, executing the window consumes it — so an actor moves at most one tile per /// window no matter how often it changes its mind in between. intents: HashMap, } impl Sim { pub fn new(world: World) -> Self { Self { world, tick: 0, intents: HashMap::new() } } /// Schedule `action` for `entity_id`'s next movement window. NOOP clears the intent. pub fn set_action(&mut self, entity_id: u32, action: u16) { if crate::action_delta(action).is_some() { self.intents.insert(entity_id, action); } else { self.intents.remove(&entity_id); } } pub fn clear_action(&mut self, entity_id: u32) { self.intents.remove(&entity_id); } /// Advance the world by one base tick. Returns `true` when this tick executed a /// movement window, i.e. entity positions may have changed. pub fn step(&mut self) -> bool { self.tick = self.tick.wrapping_add(1); if self.tick.is_multiple_of(TICKS_PER_MOVE) { self.entity_tick(); true } else { false } } fn entity_tick(&mut self) { for (entity_id, action) in self.intents.drain() { let delta = match crate::action_delta(action) { Some(d) => d, None => continue, }; let pos = match self.world.entities.get(&entity_id) { Some(e) => e.pos, None => continue, }; let from = (pos.0 as i32, pos.1 as i32); let to = (from.0 + delta.0, from.1 + delta.1); // `step_allowed` is the shared movement rule (chessboard geometry, no corner // cutting) — the pathfinder plans with the exact same function. let allowed = crate::step_allowed(from, to, |x, y| { self.world.tile_flags(x as i16, y as i16).map_or(true, |f| f.collidable()) }); if allowed { self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16)); } } } } #[cfg(test)] mod tests { use super::*; use crate::chunk::{Chunk, TileDef, TileFlags}; use crate::player_action; fn open_world() -> World { let mut w = World::new(); w.set_chunk(0, 0, Chunk::generate(|lx, ly| { // A single wall tile at (5, 4). let flags = if (lx, ly) == (5, 4) { TileFlags::COLLIDABLE } else { 0 }; TileDef { tile_id: 1, flags: TileFlags(flags) } }).unwrap()); w } fn step_window(sim: &mut Sim) { for _ in 0..TICKS_PER_MOVE { sim.step(); } } #[test] fn one_step_per_window_and_replacement() { let mut sim = Sim::new(open_world()); let id = sim.world.spawn_entity(0, (4, 4), 1); sim.set_action(id, player_action::SOUTH); sim.set_action(id, player_action::NORTH); // replaces sim.step(); // no window yet assert_eq!(sim.world.entities[&id].pos, (4, 4)); step_window(&mut sim); assert_eq!(sim.world.entities[&id].pos, (4, 3)); step_window(&mut sim); // intent consumed — no second step assert_eq!(sim.world.entities[&id].pos, (4, 3)); } #[test] fn blocked_and_out_of_world_steps_are_rejected() { let mut sim = Sim::new(open_world()); let id = sim.world.spawn_entity(0, (4, 4), 1); sim.set_action(id, player_action::EAST); // into the wall at (5, 4) step_window(&mut sim); assert_eq!(sim.world.entities[&id].pos, (4, 4)); let edge = sim.world.spawn_entity(0, (0, 0), 1); sim.set_action(edge, player_action::WEST); // no chunk there step_window(&mut sim); assert_eq!(sim.world.entities[&edge].pos, (0, 0)); } #[test] fn noop_clears_intent() { let mut sim = Sim::new(open_world()); let id = sim.world.spawn_entity(0, (4, 4), 1); sim.set_action(id, player_action::SOUTH); sim.set_action(id, player_action::NOOP); step_window(&mut sim); assert_eq!(sim.world.entities[&id].pos, (4, 4)); } }