Embed the sim in the game binary; drop networking
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
303c587aee
commit
f594f12f35
@@ -85,10 +85,6 @@ pub const TICK_HZ: u32 = 24;
|
||||
/// `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`.
|
||||
///
|
||||
|
||||
+96
-53
@@ -1,70 +1,52 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use crate::{ACTION_WINDOW_HORIZON, TICKS_PER_MOVE};
|
||||
use std::collections::HashMap;
|
||||
use crate::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>>,
|
||||
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, pending: HashMap::new() }
|
||||
Self { world, tick: 0, intents: 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);
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
pub fn clear_action(&mut self, entity_id: u32) {
|
||||
self.intents.remove(&entity_id);
|
||||
}
|
||||
|
||||
for (entity_id, action) in actions {
|
||||
/// 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, // NOOP or garbage
|
||||
None => continue,
|
||||
};
|
||||
let pos = match self.world.entities.get(&entity_id) {
|
||||
Some(e) => e.pos,
|
||||
@@ -73,7 +55,7 @@ impl Sim {
|
||||
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.
|
||||
// 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())
|
||||
});
|
||||
@@ -83,3 +65,64 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user