The client becomes the game: it owns a Sim instance and advances it with a fixed-timestep accumulator (24 Hz ticks, rendering at frame rate). Rendering, pathfinding and collision read the World directly instead of a chunk cache. Sim::pending (window slot maps, late rule, horizon) is replaced by one intent per entity for the next movement window — set_action replaces, executing the window consumes. The whole client prediction/reconciliation machinery (tick estimation, RTT lead, retraction, replay) is gone with the latency it was built for; click-to-move now syncs the route against the sim position after every window. Removed: server, netsim, shared (wire types), client/net.rs. client renamed to game. Headless tests cover the intent model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
4.4 KiB
Rust
Executable File
129 lines
4.4 KiB
Rust
Executable File
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<u32, u16>,
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|