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:
irrlicht
2026-09-18 22:56:30 +02:00
co-authored by Claude Opus 5
parent 303c587aee
commit f594f12f35
20 changed files with 496 additions and 1500 deletions
+385
View File
@@ -0,0 +1,385 @@
mod pathfind;
mod pixelhelper;
use std::collections::{HashMap, VecDeque};
use crate::assets::Image;
use crate::input::{GameAction, InputState};
use sim::entity::Entity;
use sim::map::TileMap;
use sim::{delta_action, load_world, step_allowed, Sim, TICK_HZ, TICKS_PER_MOVE};
pub enum GameSignal {
Quit,
}
/// Length of one base tick. The sim advances in whole ticks; rendering runs at frame
/// rate on top and accumulates frame time into ticks.
const TICK_MS: f32 = 1000.0 / TICK_HZ as f32;
/// The movement cadence (movement on every 4th tick → 6 Hz ≈ 167 ms/tile). Entity
/// interpolation lerps over this interval.
const MOVE_INTERVAL_MS: usize = (TICK_MS * TICKS_PER_MOVE as f32) as usize;
/// Longest frame the sim will catch up on in one go. Anything slower (debugger, window
/// drag) is dropped instead of spiralling into a burst of ticks.
const MAX_FRAME_MS: f32 = 250.0;
/// Palette index for the planned route. Dim blue in RGB332 (r=0, g=0, b=2).
const ROUTE_COLOR: u8 = 0b000_000_10;
/// Viewport geometry: 30×30 tiles of 8 px, top-left of the framebuffer.
const VIEW_TILES: i32 = 30;
const TILE_PX: i32 = 8;
const VIEW_PX: i32 = VIEW_TILES * TILE_PX;
/// Camera pan speed. The player walks 8 px per 167 ms ≈ 48 px/s; the camera is a bit
/// faster, so it trails during movement and settles right after the player stops.
const CAM_SPEED: f32 = 64.0; // px/s
/// A position correction farther than this is a teleport — snap instead of panning.
const CAM_SNAP_PX: f32 = 96.0;
/// Render-side smoothing state for one entity: the previous tile and how long ago the
/// current one was taken. Positions are tiles; rendering lerps between them in pixels
/// over one movement interval.
struct EntityLerp {
prev: (i32, i32),
cur: (i32, i32),
t_ms: usize,
}
pub struct Game {
tileset: Vec<[u8; 64]>,
entity_tileset: Vec<[u8; 64]>,
sim: Sim,
player_id: u32,
/// Frame time not yet consumed by whole ticks.
tick_accum_ms: f32,
/// Planned route from click-to-move: tiles still to visit, fed into the sim one
/// step per movement window. Keyboard input cancels it.
route: VecDeque<(i32, i32)>,
/// The persistent movement goal. Outlives the route: a blocked step triggers a
/// replan toward it, and while the mouse is held it tracks the tile under the
/// cursor. Cleared on arrival, unreachability, keyboard override, or a click on an
/// unreachable tile.
goal: Option<(i32, i32)>,
/// Viewport top-left in world pixels. Follows the player linearly instead of
/// snapping tile-to-tile; only rendering rounds it to whole pixels.
cam: (f32, f32),
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
/// logic keeps using the sim's tile positions.
lerp: HashMap<u32, EntityLerp>,
}
impl Game {
pub fn start() -> Self {
let tileset = Image::from_tga("assets/tilesets/overworld.tga").to_tileset();
let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset();
let map = TileMap::from_csv("assets/map_test");
println!("loaded map {}×{} ({} tiles)", map.width, map.height, map.tiles.len());
let mut sim = Sim::new(load_world(&map));
let player_id = sim.world.spawn_entity(0, (0, 0), 100);
let mut game = Game {
tileset,
entity_tileset,
sim,
player_id,
tick_accum_ms: 0.0,
route: VecDeque::new(),
goal: None,
cam: (0.0, 0.0),
lerp: HashMap::new(),
};
game.cam = game.cam_target();
game.track_lerp();
game
}
fn player_pos(&self) -> (i32, i32) {
let p = self.sim.world.entities[&self.player_id].pos;
(p.0 as i32, p.1 as i32)
}
/// True if the world tile at `(wx, wy)` blocks movement. Outside the loaded world
/// counts as blocked — the same rule the sim applies.
fn tile_blocked(&self, wx: i32, wy: i32) -> bool {
self.sim.world.tile_flags(wx as i16, wy as i16).map_or(true, |f| f.collidable())
}
pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState)
-> Option<GameSignal>
{
if input.mouse_clicked() || input.mouse_held() {
self.handle_click(input.mouse_pos(), !input.mouse_clicked());
}
self.step_movement(input);
// Advance the world in whole ticks. After every movement window the route is
// synced against the new position and the next step is scheduled right away,
// so a slow frame that spans several windows still walks every one of them.
self.tick_accum_ms += (dt as f32).min(MAX_FRAME_MS);
while self.tick_accum_ms >= TICK_MS {
self.tick_accum_ms -= TICK_MS;
if self.sim.step() {
self.track_lerp();
self.sync_route();
self.step_movement(input);
}
}
if input.button_pressed(GameAction::Cancel) {
println!("Goodbye!");
return Some(GameSignal::Quit);
}
self.step_camera(dt);
self.step_lerp(dt);
self.render_viewport(render_frame);
None
}
/// Where the camera wants to be: the viewport centered on the player's tile.
fn cam_target(&self) -> (f32, f32) {
let (px, py) = self.player_pos();
(
(px * TILE_PX - VIEW_PX / 2) as f32,
(py * TILE_PX - VIEW_PX / 2) as f32,
)
}
/// The camera rounded to the pixel grid — the actual top-left of the rendered view.
fn cam_px(&self) -> (i32, i32) {
(self.cam.0.round() as i32, self.cam.1.round() as i32)
}
/// Follow the player linearly at `CAM_SPEED`, axis by axis. Corrections beyond
/// `CAM_SNAP_PX` (teleports, respawns) snap outright instead of panning across.
fn step_camera(&mut self, dt: usize) {
let (tx, ty) = self.cam_target();
if (tx - self.cam.0).abs().max((ty - self.cam.1).abs()) > CAM_SNAP_PX {
self.cam = (tx, ty);
return;
}
let step = CAM_SPEED * dt as f32 / 1000.0;
let approach = |c: f32, t: f32| {
if (t - c).abs() <= step { t } else { c + step * (t - c).signum() }
};
self.cam = (approach(self.cam.0, tx), approach(self.cam.1, ty));
}
/// Click-to-move: translate a framebuffer click into a world tile and adopt it as
/// the movement goal. With `hold` (button held after the initial click) this runs
/// every frame and keeps steering toward the tile under the cursor, replanning only
/// when that tile changes — camera movement alone shifts it too, not just moving
/// the mouse.
fn handle_click(&mut self, (mx, my): (i32, i32), hold: bool) {
if mx < 0 || my < 0 || mx >= VIEW_PX || my >= VIEW_PX {
return; // outside the world viewport
}
let (cx, cy) = self.cam_px();
let goal = ((cx + mx).div_euclid(TILE_PX), (cy + my).div_euclid(TILE_PX));
// While steering, an unchanged goal needs no replan — unless the route was voided
// (blocked step) before reaching it; an empty route with the goal still ahead
// means exactly that, so plan again.
if hold && self.goal == Some(goal)
&& (!self.route.is_empty() || self.player_pos() == goal)
{
return;
}
match pathfind::find_path(self.player_pos(), goal, |x, y| self.tile_blocked(x, y)) {
Some(steps) => {
self.route = steps.into();
self.goal = Some(goal);
}
// A discrete click on an unreachable tile cancels the plan; while steering,
// sweeping the cursor across a blocked tile keeps the current plan alive.
None if !hold => {
self.route.clear();
self.goal = None;
self.sim.clear_action(self.player_id);
}
None => {}
}
}
/// Schedule the player's next step: a held direction key (which cancels any goal)
/// or the head of the planned route becomes the intent for the next movement
/// window. The sim executes at most one intent per window, so holding a key walks
/// at the movement cadence by construction. Steps are validated with the shared
/// movement rule; a blocked route step triggers a replan toward the goal.
fn step_movement(&mut self, input: &InputState) {
// Both axes combine, so two held keys walk diagonally (a king move). A tap
// shorter than a frame shows up as pressed only.
let axis = |neg: GameAction, pos: GameAction| {
(input.button_held(pos) || input.button_pressed(pos)) as i32
- (input.button_held(neg) || input.button_pressed(neg)) as i32
};
let kx = axis(GameAction::Left, GameAction::Right);
let ky = axis(GameAction::Up, GameAction::Down);
let dir = (kx != 0 || ky != 0).then_some((kx, ky));
if dir.is_some() {
// Manual input overrides click-to-move entirely, goal included.
self.route.clear();
self.goal = None;
}
let from = self.player_pos();
let step = match dir {
Some((dx, dy)) => Some((from.0 + dx, from.1 + dy)),
None => self.route.front().copied(),
};
let Some((nx, ny)) = step else { return };
if step_allowed(from, (nx, ny), |x, y| self.tile_blocked(x, y)) {
self.sim.set_action(self.player_id, delta_action(nx - from.0, ny - from.1));
} else if dir.is_none() {
self.replan_route();
}
}
/// After a movement window: drop the route head once the player stands on it, and
/// release the goal on arrival.
fn sync_route(&mut self) {
let pos = self.player_pos();
if self.route.front() == Some(&pos) {
self.route.pop_front();
}
if self.route.is_empty() && self.goal == Some(pos) {
self.goal = None;
}
}
/// Replan the route from the current position toward the persistent goal — the
/// reaction to a blocked step. Gives the goal up only when it is reached or has
/// become unreachable.
fn replan_route(&mut self) {
self.route.clear();
let Some(goal) = self.goal else { return };
let pos = self.player_pos();
if pos == goal {
self.goal = None;
return;
}
match pathfind::find_path(pos, goal, |x, y| self.tile_blocked(x, y)) {
Some(steps) => self.route = steps.into(),
None => self.goal = None,
}
}
/// Fold the sim's entity positions into the interpolation table: a changed position
/// starts a new lerp from the old one, a jump of more than one tile (Chebyshev — a
/// teleport) snaps, and vanished entities are dropped.
fn track_lerp(&mut self) {
let entities = &self.sim.world.entities;
for e in entities.values() {
let cur = (e.pos.0 as i32, e.pos.1 as i32);
self.lerp.entry(e.id)
.and_modify(|l| {
if l.cur != cur {
let far = (cur.0 - l.cur.0).abs().max((cur.1 - l.cur.1).abs()) > 1;
l.prev = if far { cur } else { l.cur };
l.cur = cur;
l.t_ms = 0;
}
})
.or_insert(EntityLerp { prev: cur, cur, t_ms: 0 });
}
self.lerp.retain(|id, _| entities.contains_key(id));
}
/// Advance all interpolation clocks; each lerp completes after one movement interval.
fn step_lerp(&mut self, dt: usize) {
for l in self.lerp.values_mut() {
l.t_ms = (l.t_ms + dt).min(MOVE_INTERVAL_MS);
}
}
/// An entity's render position in world pixels: between its previous and current
/// tile, proportional to the time since the current one was taken.
fn entity_px(&self, e: &Entity) -> (i32, i32) {
let cur = (e.pos.0 as i32 * TILE_PX, e.pos.1 as i32 * TILE_PX);
match self.lerp.get(&e.id) {
Some(l) => {
let f = l.t_ms as f32 / MOVE_INTERVAL_MS as f32;
let mix = |a: i32, b: i32| a + ((b - a) as f32 * f).round() as i32;
(mix(l.prev.0 * TILE_PX, cur.0), mix(l.prev.1 * TILE_PX, cur.1))
}
None => cur,
}
}
fn render_viewport(&self, frame: &mut [u8]) {
const WALL: u8 = 0x00;
const W: usize = 320;
frame.fill(0);
let (cx, cy) = self.cam_px();
let tx0 = cx.div_euclid(TILE_PX);
let ty0 = cy.div_euclid(TILE_PX);
let sx = -cx.rem_euclid(TILE_PX);
let sy = -cy.rem_euclid(TILE_PX);
// One extra row and column: with a sub-tile camera offset the viewport spans
// partial tiles on both edges.
for vy in 0..=VIEW_TILES {
for vx in 0..=VIEW_TILES {
let wx = tx0 + vx;
let wy = ty0 + vy;
let (ccx, ccy) = sim::world::tile_to_chunk(wx as i16, wy as i16);
let (lx, ly) = sim::world::tile_to_local(wx as i16, wy as i16);
let tile_id = self.sim.world.chunk(ccx, ccy)
.map(|c| c.get_tile(lx, ly).tile_id)
.unwrap_or(0);
let px = sx + vx * TILE_PX;
let py = sy + vy * TILE_PX;
if let Some(tile) = self.tileset.get(tile_id as usize) {
pixelhelper::blit_tile(frame, W, px, py, tile);
} else {
for dy in 0..8 {
for dx in 0..8 {
pixelhelper::set_pixel(frame, W, px + dx, py + dy, WALL);
}
}
}
}
}
// Route pass — over the world, under entities.
for &(wx, wy) in &self.route {
let px = wx * TILE_PX - cx;
let py = wy * TILE_PX - cy;
if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; }
for dy in 0..8 {
for dx in 0..8 {
pixelhelper::set_pixel(frame, W, px + dx, py + dy, ROUTE_COLOR);
}
}
}
// Entity pass — interpolated between the last two tiles.
for e in self.sim.world.entities.values() {
let (ex, ey) = self.entity_px(e);
let px = ex - cx;
let py = ey - cy;
if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; }
if let Some(tile) = self.entity_tileset.get(e.type_id as usize) {
pixelhelper::blit_tile(frame, W, px, py, tile);
}
}
// The blit primitives clip against the framebuffer, not the viewport, so partial
// tiles on the right edge bleed into the 240..320 strip. Clear it; a real
// clip-rect belongs to the UI pass (see roadmap).
for y in 0..240usize {
frame[y * W + VIEW_PX as usize..y * W + W].fill(0);
}
}
}