diff --git a/Cargo.lock b/Cargo.lock index 2f8d023..de78b18 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -1477,6 +1477,7 @@ version = "0.1.0" dependencies = [ "bytemuck", "shared", + "sim", ] [[package]] @@ -1484,6 +1485,7 @@ name = "shared" version = "0.1.0" dependencies = [ "bytemuck", + "sim", ] [[package]] @@ -1492,6 +1494,10 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "sim" +version = "0.1.0" + [[package]] name = "simd_cesu8" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index 97ffb1d..24d3509 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["shared", "client", "server", "netsim"] +members = ["sim", "shared", "client", "server", "netsim"] resolver = "2" diff --git a/server/Cargo.toml b/server/Cargo.toml index 613395c..a4d3ee9 100755 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -6,3 +6,4 @@ edition = "2024" [dependencies] shared = { path = "../shared" } bytemuck = "1.24.0" +sim = { path = "../sim" } diff --git a/server/src/main.rs b/server/src/main.rs index 8dcd7ec..f005542 100755 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,46 +1,15 @@ -mod chunk; -mod entity; -mod world; -mod sim; mod net; -mod map; use std::thread; use std::time::{Duration, Instant}; -use chunk::{Chunk, TileDef, TileFlags}; -use world::World; -use sim::Sim; +use sim::map::TileMap; +use sim::{load_world, Sim}; use net::Net; -use map::TileMap; const TICK_HZ: u32 = 24; const TICK_DURATION: Duration = Duration::from_micros(1_000_000 / TICK_HZ as u64); -/// 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 -/// become an invisible solid border (id 0, collidable), so the walkable world edge sits flush -/// with the visible map rim instead of with the larger chunk boundary. -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); - if wx < 0 || wy < 0 || wx >= map.width as i16 || wy >= map.height as i16 { - return TileDef { tile_id: 0, flags: TileFlags(TileFlags::COLLIDABLE) }; - } - let id = map.tile(wx, wy); - TileDef { tile_id: id, flags: map::tile_flags(id) } - }).expect("palette overflow during world gen"); - w.set_chunk(cx, cy, chunk); - } - } - w -} - fn main() { let map = TileMap::from_csv("assets/map_test"); println!("loaded map {}×{} ({} tiles)", map.width, map.height, map.tiles.len()); diff --git a/server/src/net.rs b/server/src/net.rs index 50a8730..2547d4a 100755 --- a/server/src/net.rs +++ b/server/src/net.rs @@ -12,7 +12,7 @@ use shared::{ MAGIC, VERSION, packet_type, }; -use crate::world::World; +use sim::world::World; pub struct NetClient { pub entity_id: u32, @@ -153,7 +153,7 @@ impl Net { // Step 1 — resolve player position let Some(entity) = world.entities.get(&client.entity_id) else { continue }; let pos = entity.pos; - let (player_cx, player_cy) = crate::world::tile_to_chunk(pos.0, pos.1); + let (player_cx, player_cy) = sim::world::tile_to_chunk(pos.0, pos.1); // Step 2 — build 9-slot chunk manifest let mut chunks = [ChunkEntry { chunk_id: 0, version: 0 }; 9]; diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 603fdb4..e3f8bb2 100755 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -5,3 +5,4 @@ edition = "2024" [dependencies] bytemuck = { version = "1.24.0", features = ["derive", "min_const_generics"] } +sim = { path = "../sim" } diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 559a2d2..3f73fde 100755 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,81 +1,9 @@ use bytemuck::{Pod, Zeroable}; -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 server sim, client prediction and the -/// client pathfinder so all three 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)) -} - -/// Server base tick rate. The shared 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; - -/// How many *future* movement windows a client may address. Part of the protocol -/// contract: actions targeted beyond this horizon are dropped, and the client's -/// unconfirmed in-flight steps must stay within it. -pub const ACTION_WINDOW_HORIZON: usize = 3; +// Game rules and tick constants live in the `sim` crate; re-exported here so the +// wire layer and both binaries share one vocabulary. +pub use sim::{player_action, action_delta, delta_action, step_allowed, tile_collidable, + TICK_HZ, TICKS_PER_MOVE, ACTION_WINDOW_HORIZON}; pub const MAGIC: u16 = 0x524C; pub const VERSION: u8 = 1; @@ -101,20 +29,6 @@ pub fn chunk_coords(id: u32) -> (i16, i16) { (id as u16 as i16, (id >> 16) as u16 as i16) } -/// Shared movement vocabulary: whether a tile id blocks movement. -/// -/// Single source of truth for both the server (collision in the sim) and the client -/// (movement prediction). 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 (the server pads chunks past the map rim with id 0) - 146 => true, // trees / rocks - _ => false, - } -} - #[derive(Clone, Copy, Pod, Zeroable)] #[repr(C, packed)] pub struct Header { diff --git a/sim/Cargo.toml b/sim/Cargo.toml new file mode 100644 index 0000000..0d42fab --- /dev/null +++ b/sim/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "sim" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/server/src/chunk.rs b/sim/src/chunk.rs similarity index 100% rename from server/src/chunk.rs rename to sim/src/chunk.rs diff --git a/server/src/entity.rs b/sim/src/entity.rs similarity index 100% rename from server/src/entity.rs rename to sim/src/entity.rs diff --git a/sim/src/lib.rs b/sim/src/lib.rs new file mode 100644 index 0000000..5d5f8fb --- /dev/null +++ b/sim/src/lib.rs @@ -0,0 +1,126 @@ +//! 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::Sim; + +use chunk::{Chunk, TileDef, TileFlags}; +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; + +/// How many *future* movement windows an actor may address. Actions targeted beyond +/// this horizon are dropped. +pub const ACTION_WINDOW_HORIZON: usize = 3; + +/// 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 +/// become an invisible solid border (id 0, 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); + if wx < 0 || wy < 0 || wx >= map.width as i16 || wy >= map.height as i16 { + return TileDef { tile_id: 0, flags: TileFlags(TileFlags::COLLIDABLE) }; + } + let id = map.tile(wx, wy); + TileDef { tile_id: id, flags: map::tile_flags(id) } + }).expect("palette overflow during world gen"); + w.set_chunk(cx, cy, chunk); + } + } + w +} diff --git a/server/src/map.rs b/sim/src/map.rs similarity index 92% rename from server/src/map.rs rename to sim/src/map.rs index 997414b..274ddf4 100644 --- a/server/src/map.rs +++ b/sim/src/map.rs @@ -65,11 +65,11 @@ impl TileMap { } /// Gameplay flags for a global tile id. Collision comes from the shared movement -/// vocabulary (`shared::tile_collidable`) so the client can predict it identically; -/// other flags (opaque, …) stay server-side. Tile ids index into `overworld.tga`. +/// vocabulary (`crate::tile_collidable`) so the client can predict it identically; +/// other flags (opaque, …) stay sim-side. Tile ids index into `overworld.tga`. pub fn tile_flags(tile_id: u16) -> TileFlags { let mut bits = 0u8; - if shared::tile_collidable(tile_id) { + if crate::tile_collidable(tile_id) { bits |= TileFlags::COLLIDABLE; } TileFlags(bits) diff --git a/server/src/sim.rs b/sim/src/sim.rs similarity index 93% rename from server/src/sim.rs rename to sim/src/sim.rs index b8c6c84..09ccb15 100755 --- a/server/src/sim.rs +++ b/sim/src/sim.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeMap, HashMap}; -use shared::{ACTION_WINDOW_HORIZON, TICKS_PER_MOVE}; +use crate::{ACTION_WINDOW_HORIZON, TICKS_PER_MOVE}; use crate::world::World; pub struct Sim { @@ -33,7 +33,7 @@ impl Sim { // dropped — once its window has passed, its cancellation intent is // ambiguous, and as a gap-filler it would block real steps (worst case // one stale step executes; the client's reconciliation handles that). - if shared::action_delta(action).is_some() { + if crate::action_delta(action).is_some() { let gap = (min_window..min_window + ACTION_WINDOW_HORIZON as u32) .find(|w| !slots.contains_key(w)); if let Some(w) = gap { @@ -62,7 +62,7 @@ impl Sim { self.pending.retain(|_, slots| !slots.is_empty()); for (entity_id, action) in actions { - let delta = match shared::action_delta(action) { + let delta = match crate::action_delta(action) { Some(d) => d, None => continue, // NOOP or garbage }; @@ -74,7 +74,7 @@ impl Sim { let to = (from.0 + delta.0, from.1 + delta.1); // `step_allowed` is the shared movement rule (chessboard geometry, no corner // cutting) — the client predicts with the exact same function. - let allowed = shared::step_allowed(from, to, |x, y| { + 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 { diff --git a/server/src/world.rs b/sim/src/world.rs similarity index 100% rename from server/src/world.rs rename to sim/src/world.rs