Extract headless sim crate from server

Move chunk, entity, world, map, sim and load_world into a new sim library
crate together with the game rules from shared (player_action, action_delta,
delta_action, step_allowed, tile_collidable, tick constants). shared keeps
only the wire types and re-exports the rules, so server and client compile
unchanged. First step toward a local single-player build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irrlicht
2026-09-18 22:52:10 +02:00
co-authored by Claude Opus 5
parent 5610c2c343
commit 303c587aee
14 changed files with 156 additions and 133 deletions
+102
View File
@@ -0,0 +1,102 @@
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub struct TileFlags(pub u8);
impl TileFlags {
pub const COLLIDABLE: u8 = 1 << 0;
pub const OPAQUE: u8 = 1 << 1;
pub const LIQUID: u8 = 1 << 2;
pub fn collidable(self) -> bool { self.0 & Self::COLLIDABLE != 0 }
pub fn opaque(self) -> bool { self.0 & Self::OPAQUE != 0 }
pub fn liquid(self) -> bool { self.0 & Self::LIQUID != 0 }
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub struct TileDef { pub tile_id: u16, pub flags: TileFlags }
#[derive(Debug)]
pub struct PaletteFullError;
pub struct Chunk {
pub palette: [TileDef; 64],
pub pal_count: u8,
pub tiles: Box<[u8; 1024]>,
pub version: u16,
}
impl Chunk {
/// Runtime use: all tiles empty, pal_count=1, version=1.
/// Clients with version=0 will always request this chunk.
pub fn new() -> Self {
Self {
palette: [TileDef::default(); 64],
pal_count: 1,
tiles: Box::new([0u8; 1024]),
version: 1,
}
}
/// Find existing palette entry or insert a new one.
/// Index 0 is always the null sentinel; this short-circuits for default TileDef.
/// Returns (palette_index, was_new). Does NOT bump version.
fn find_or_insert_raw(&mut self, def: TileDef) -> Result<(u8, bool), PaletteFullError> {
if def == TileDef::default() {
return Ok((0, false));
}
for i in 1..self.pal_count as usize {
if self.palette[i] == def {
return Ok((i as u8, false));
}
}
if self.pal_count >= 64 {
return Err(PaletteFullError);
}
let idx = self.pal_count;
self.palette[idx as usize] = def;
self.pal_count += 1;
Ok((idx, true))
}
/// World gen path. Calls the closure for every tile coordinate.
/// version=0 so the first StatePacket causes all clients to request it.
pub fn generate(f: impl Fn(u8, u8) -> TileDef) -> Result<Self, PaletteFullError> {
let mut chunk = Self {
palette: [TileDef::default(); 64],
pal_count: 1,
tiles: Box::new([0u8; 1024]),
version: 0,
};
for ly in 0u8..32 {
for lx in 0u8..32 {
let def = f(lx, ly);
let (idx, _) = chunk.find_or_insert_raw(def)?;
chunk.tiles[ly as usize * 32 + lx as usize] = idx;
}
}
chunk.version = 1;
Ok(chunk)
}
/// Set a tile at local coords. Bumps version if tile index or palette changed.
pub fn set_tile(&mut self, lx: u8, ly: u8, def: TileDef) -> Result<(), PaletteFullError> {
let (idx, was_new) = self.find_or_insert_raw(def)?;
let slot = &mut self.tiles[ly as usize * 32 + lx as usize];
let changed = *slot != idx;
*slot = idx;
if changed || was_new {
self.version = self.version.wrapping_add(1);
if self.version == 0 {
self.version = 1;
}
}
Ok(())
}
pub fn get_tile(&self, lx: u8, ly: u8) -> TileDef {
self.palette[self.tiles[ly as usize * 32 + lx as usize] as usize]
}
pub fn tile_flags(&self, lx: u8, ly: u8) -> TileFlags {
self.get_tile(lx, ly).flags
}
}
+19
View File
@@ -0,0 +1,19 @@
pub struct Entity {
pub id: u32,
pub type_id: u16, // 0 = player
pub pos: (i16, i16),
pub hp: u16,
pub hp_max: u16,
}
impl Entity {
pub fn new(id: u32, type_id: u16, pos: (i16, i16), hp: u16) -> Self {
Self {
id,
type_id,
pos,
hp,
hp_max: hp,
}
}
}
+126
View File
@@ -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
}
+76
View File
@@ -0,0 +1,76 @@
//! Minimal Tiled-CSV map loader.
//!
//! Reads a bare comma-separated tile grid (Tiled's "CSV" layer export, stripped of the
//! surrounding TMX/XML). Dimensions are derived from the content: columns from the first
//! row, height from the row count. Tiled encodes per-tile flip/rotation in the top bits of
//! each 32-bit GID, which makes flipped tiles show up as large negative decimals — those
//! bits are masked off here, leaving the bare tile_id. Flip orientation is discarded for now.
use crate::chunk::TileFlags;
/// Top three bits hold horizontal / vertical / diagonal flip flags; the rest is the tile id.
const GID_FLIP_MASK: u32 = 0x1FFF_FFFF;
pub struct TileMap {
pub width: u16,
pub height: u16,
pub tiles: Vec<u16>, // row-major, len == width * height
}
impl TileMap {
pub fn from_csv(path: &str) -> Self {
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("failed to read map {path}: {e}"));
let mut tiles = Vec::new();
let mut width = 0usize;
let mut height = 0usize;
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let mut cols = 0usize;
for field in line.split(',') {
let field = field.trim();
if field.is_empty() {
continue; // tolerate a trailing comma
}
// Parse as i64 so the "negative" flipped GIDs fit, then reinterpret the low
// 32 bits as u32 and strip the flip flags.
let raw = field.parse::<i64>()
.unwrap_or_else(|_| panic!("invalid tile value {field:?} in {path}"));
let gid = (raw as u32) & GID_FLIP_MASK;
tiles.push(gid as u16);
cols += 1;
}
if width == 0 {
width = cols;
} else if cols != width {
panic!("ragged map row in {path}: expected {width} cols, got {cols}");
}
height += 1;
}
TileMap { width: width as u16, height: height as u16, tiles }
}
/// Tile id at map coordinates, or 0 (empty) when outside the map bounds.
pub fn tile(&self, x: i16, y: i16) -> u16 {
if x < 0 || y < 0 || x >= self.width as i16 || y >= self.height as i16 {
return 0;
}
self.tiles[y as usize * self.width as usize + x as usize]
}
}
/// Gameplay flags for a global tile id. Collision comes from the shared movement
/// 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 crate::tile_collidable(tile_id) {
bits |= TileFlags::COLLIDABLE;
}
TileFlags(bits)
}
Executable
+85
View File
@@ -0,0 +1,85 @@
use std::collections::{BTreeMap, HashMap};
use crate::{ACTION_WINDOW_HORIZON, TICKS_PER_MOVE};
use crate::world::World;
pub struct Sim {
pub world: World,
/// Scheduled actions per entity, keyed by movement window (`tick / TICKS_PER_MOVE`).
/// The window slot is the whole ordering model: an action addressed to an occupied
/// window *replaces* it (retraction and rescheduling by the client, dedup of
/// retransmits), a late action only fills the next window if it is empty, and
/// anything past `ACTION_WINDOW_HORIZON` is dropped — so no client can grow server
/// memory or move faster than one action per window.
pending: HashMap<u32, BTreeMap<u32, u16>>,
}
impl Sim {
pub fn new(world: World) -> Self {
Self { world, pending: HashMap::new() }
}
pub fn tick(&mut self, tick: u32, actions: &[(u32, u32, u16)]) {
// The earliest window still addressable at this tick. On a movement tick that is
// the window executing *this call* — actions arriving the same tick still count.
let min_window = tick.div_ceil(TICKS_PER_MOVE);
for &(entity_id, target_tick, action) in actions {
let slots = self.pending.entry(entity_id).or_default();
let window = target_tick.div_ceil(TICKS_PER_MOVE);
if window < min_window {
// Late. A movement action keeps its *order* instead of its time: it
// fills the first still-empty upcoming window, so bunched late arrivals
// don't collapse onto one slot and eat each other. A late NOOP is
// 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 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 {
slots.insert(w, action);
}
}
} else if window - min_window < ACTION_WINDOW_HORIZON as u32 {
slots.insert(window, action); // newest addressing wins
}
}
if tick.is_multiple_of(TICKS_PER_MOVE) {
self.entity_tick(tick / TICKS_PER_MOVE);
}
}
fn entity_tick(&mut self, window: u32) {
// Exactly one action per entity per movement window, then the window is gone.
let actions: Vec<(u32, u16)> = self.pending.iter_mut()
.filter_map(|(&id, slots)| {
let action = slots.remove(&window);
slots.retain(|&w, _| w > window); // drop anything the timeline passed by
action.map(|a| (id, a))
})
.collect();
self.pending.retain(|_, slots| !slots.is_empty());
for (entity_id, action) in actions {
let delta = match crate::action_delta(action) {
Some(d) => d,
None => continue, // NOOP or garbage
};
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 client predicts 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));
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
use std::collections::HashMap;
use crate::chunk::{Chunk, TileDef, TileFlags, PaletteFullError};
use crate::entity::Entity;
pub fn tile_to_chunk(tx: i16, ty: i16) -> (i16, i16) {
(tx >> 5, ty >> 5)
}
pub fn tile_to_local(tx: i16, ty: i16) -> (u8, u8) {
((tx & 31) as u8, (ty & 31) as u8)
}
pub fn local_to_tile(cx: i16, cy: i16, lx: u8, ly: u8) -> (i16, i16) {
((cx << 5) | lx as i16, (cy << 5) | ly as i16)
}
pub struct World {
chunks: HashMap<(i16, i16), Chunk>,
pub entities: HashMap<u32, Entity>,
chunk_entities: HashMap<(i16, i16), Vec<u32>>,
next_entity_id: u32,
}
impl World {
pub fn new() -> Self {
Self {
chunks: HashMap::new(),
entities: HashMap::new(),
chunk_entities: HashMap::new(),
next_entity_id: 1,
}
}
pub fn chunk(&self, cx: i16, cy: i16) -> Option<&Chunk> {
self.chunks.get(&(cx, cy))
}
pub fn set_chunk(&mut self, cx: i16, cy: i16, chunk: Chunk) {
self.chunks.insert((cx, cy), chunk);
}
pub fn tile_flags(&self, wx: i16, wy: i16) -> Option<TileFlags> {
let (cx, cy) = tile_to_chunk(wx, wy);
let (lx, ly) = tile_to_local(wx, wy);
self.chunks.get(&(cx, cy)).map(|c| c.tile_flags(lx, ly))
}
pub fn set_tile(&mut self, wx: i16, wy: i16, def: TileDef) -> Result<(), PaletteFullError> {
let (cx, cy) = tile_to_chunk(wx, wy);
let (lx, ly) = tile_to_local(wx, wy);
let chunk = self.chunks.entry((cx, cy)).or_insert_with(Chunk::new);
chunk.set_tile(lx, ly, def)
}
pub fn spawn_entity(&mut self, type_id: u16, pos: (i16, i16), hp: u16) -> u32 {
let id = self.next_entity_id;
self.next_entity_id += 1;
let entity = Entity::new(id, type_id, pos, hp);
self.add_entity(entity);
id
}
pub fn add_entity(&mut self, entity: Entity) {
let chunk_key = tile_to_chunk(entity.pos.0, entity.pos.1);
let id = entity.id;
self.entities.insert(id, entity);
self.chunk_entities.entry(chunk_key).or_default().push(id);
}
pub fn remove_entity(&mut self, id: u32) {
if let Some(entity) = self.entities.remove(&id) {
let chunk_key = tile_to_chunk(entity.pos.0, entity.pos.1);
if let Some(list) = self.chunk_entities.get_mut(&chunk_key) {
list.retain(|&eid| eid != id);
}
}
}
pub fn move_entity(&mut self, id: u32, new_pos: (i16, i16)) {
if let Some(entity) = self.entities.get_mut(&id) {
let old_chunk = tile_to_chunk(entity.pos.0, entity.pos.1);
let new_chunk = tile_to_chunk(new_pos.0, new_pos.1);
entity.pos = new_pos;
if old_chunk != new_chunk {
if let Some(list) = self.chunk_entities.get_mut(&old_chunk) {
list.retain(|&eid| eid != id);
}
self.chunk_entities.entry(new_chunk).or_default().push(id);
}
}
}
pub fn entities_in_chunk(&self, cx: i16, cy: i16) -> &[u32] {
self.chunk_entities.get(&(cx, cy)).map_or(&[], Vec::as_slice)
}
/// Returns all entities within a 30×30 tile viewport centered on `center`.
pub fn entities_in_viewport(&self, center: (i16, i16)) -> Vec<&Entity> {
let half: i16 = 15;
let min_x = center.0 - half;
let max_x = center.0 + half - 1;
let min_y = center.1 - half;
let max_y = center.1 + half - 1;
let min_cx = tile_to_chunk(min_x, min_y).0;
let max_cx = tile_to_chunk(max_x, max_y).0;
let min_cy = tile_to_chunk(min_x, min_y).1;
let max_cy = tile_to_chunk(max_x, max_y).1;
let mut result = Vec::new();
for cy in min_cy..=max_cy {
for cx in min_cx..=max_cx {
for &eid in self.entities_in_chunk(cx, cy) {
if let Some(e) = self.entities.get(&eid) {
if e.pos.0 >= min_x && e.pos.0 <= max_x
&& e.pos.1 >= min_y && e.pos.1 <= max_y
{
result.push(e);
}
}
}
}
}
result
}
}