//! Headless world simulation: tiles, entities, movement rules and the tick loop. //! No platform or rendering dependencies — the game binary drives `Sim::tick`. pub mod chunk; pub mod entity; pub mod map; pub mod world; mod sim; pub use sim::{Event, Sim}; use chunk::Chunk; use map::TileMap; use world::World; pub mod player_action { pub const NOOP: u16 = 0; pub const NORTH: u16 = 1; pub const EAST: u16 = 2; pub const SOUTH: u16 = 3; pub const WEST: u16 = 4; pub const NORTH_EAST: u16 = 5; pub const SOUTH_EAST: u16 = 6; pub const SOUTH_WEST: u16 = 7; pub const NORTH_WEST: u16 = 8; } /// The movement delta of an action, or `None` for `NOOP` and unknown values. pub fn action_delta(action: u16) -> Option<(i32, i32)> { match action { player_action::NORTH => Some((0, -1)), player_action::EAST => Some((1, 0)), player_action::SOUTH => Some((0, 1)), player_action::WEST => Some((-1, 0)), player_action::NORTH_EAST => Some((1, -1)), player_action::SOUTH_EAST => Some((1, 1)), player_action::SOUTH_WEST => Some((-1, 1)), player_action::NORTH_WEST => Some((-1, -1)), _ => None, } } /// The action for a single-step delta. Inverse of [`action_delta`]; panics on anything /// that is not a king move. pub fn delta_action(dx: i32, dy: i32) -> u16 { match (dx, dy) { (0, -1) => player_action::NORTH, (1, 0) => player_action::EAST, (0, 1) => player_action::SOUTH, (-1, 0) => player_action::WEST, (1, -1) => player_action::NORTH_EAST, (1, 1) => player_action::SOUTH_EAST, (-1, 1) => player_action::SOUTH_WEST, (-1, -1) => player_action::NORTH_WEST, d => panic!("non-step delta {d:?}"), } } /// The single-step movement rule, shared by the sim and the pathfinder so both agree /// on the same physics. /// /// World geometry is chessboard (Chebyshev): diagonal and cardinal steps are the same /// distance, so a step is any king move onto a free tile. A diagonal step additionally /// requires *both* orthogonal neighbor tiles to be free — no squeezing between two /// diagonally touching blockers (corner cutting). pub fn step_allowed( from: (i32, i32), to: (i32, i32), blocked: impl Fn(i32, i32) -> bool, ) -> bool { let (dx, dy) = (to.0 - from.0, to.1 - from.1); if dx.abs() > 1 || dy.abs() > 1 || (dx == 0 && dy == 0) { return false; } if blocked(to.0, to.1) { return false; } dx == 0 || dy == 0 || (!blocked(from.0 + dx, from.1) && !blocked(from.0, from.1 + dy)) } /// Base tick rate. The timeline all action scheduling is expressed in. pub const TICK_HZ: u32 = 24; /// Movement resolves on every `TICKS_PER_MOVE`-th tick — one "movement window" per /// `TICKS_PER_MOVE` ticks (6 Hz). Window `w` executes at tick `w * TICKS_PER_MOVE`. pub const TICKS_PER_MOVE: u32 = 4; /// Movement vocabulary: whether a tile id blocks movement. Keep this in lockstep with /// the tileset in `overworld.tga`. /// /// Test values for now — a proper tile-data file format replaces this table later. pub fn tile_collidable(tile_id: u16) -> bool { match tile_id { 0 => true, // empty / world border (chunks past the map rim are padded with id 0) 146 => true, // trees / rocks _ => false, } } /// Build a world from a loaded tile map. The map's top-left tile sits at world (0, 0); the /// map is sliced into 32×32 chunks. Tiles inside a loaded chunk but outside the authored map /// get id 0 (solid, see `tile_collidable`), so the walkable world edge sits flush with the /// visible map rim instead of with the larger chunk boundary. pub fn load_world(map: &TileMap) -> World { let mut w = World::new(); let max_cx = (map.width.saturating_sub(1) >> 5) as i16; let max_cy = (map.height.saturating_sub(1) >> 5) as i16; for cy in 0..=max_cy { for cx in 0..=max_cx { let chunk = Chunk::generate(|lx, ly| { let (wx, wy) = world::local_to_tile(cx, cy, lx, ly); map.tile(wx, wy) // 0 outside the map }); w.set_chunk(cx, cy, chunk); } } w }