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
Executable
+385
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user