mod pathfind; mod pixelhelper; use std::collections::{HashMap, VecDeque}; use crate::assets::Image; use crate::input::{GameAction, InputState}; use crate::net::{EntityInfo, NetClient, NetEvent}; use shared::{chunk_id, delta_action, player_action, step_allowed, tile_collidable, TICKS_PER_MOVE}; pub enum GameSignal { Quit, } /// The server's movement cadence (24 Hz base tick, movement on every 4th tick /// → 6 Hz ≈ 167 ms/tile). Entity interpolation lerps over this interval. const MOVE_INTERVAL_MS: usize = 167; /// Length of one server tick. Steps are not paced by a local timer but scheduled onto /// the server's tick timeline, estimated from the last `StatePacket` plus elapsed time. const TICK_MS: f32 = 1000.0 / shared::TICK_HZ as f32; /// Length of one movement window. const WINDOW_MS: f32 = TICK_MS * TICKS_PER_MOVE as f32; /// Safety margin on top of the measured RTT when choosing the scheduling lead — covers /// jitter and the server answering pings on its tick grid. const LEAD_MARGIN_MS: f32 = 50.0; /// RTT sampling interval; the measurement drives the scheduling lead. const PING_INTERVAL_MS: usize = 1000; /// Upper bound on queued-but-unconfirmed steps — purely a prediction bound: window /// addressing already caps what the server will hold (windows are consecutive and only /// `sched_lead` ahead), while confirmations lag a full RTT behind, so on a slow link /// several correct steps are legitimately in flight at once. Steps whose window the /// acknowledgment cursor has passed expire automatically, so the path can never go /// permanently stale. const PATH_MAX_LEN: usize = 8; /// Palette index for the path indicator. Pure blue in RGB332 (r=0, g=0, b=3). const PATH_COLOR: u8 = 0b000_000_11; /// Dimmer blue for planned-but-not-yet-sent route tiles (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 previously confirmed tile and how /// long ago the current one was confirmed. Positions are tiles; rendering lerps between /// them in pixels over one movement interval. struct EntityLerp { prev: (i32, i32), cur: (i32, i32), t_ms: usize, } /// One sent-but-unacknowledged step: the movement window it is scheduled for and its /// *delta* — deliberately not an absolute tile. The server executes deltas, so the /// prediction is derived by replaying pending deltas on top of the last authoritative /// position: a server surprise shifts the whole prediction instead of invalidating it. /// The window is both the retraction address and the expiry key against the ack cursor. struct PathStep { window: u32, delta: (i32, i32), } pub struct Game { #[allow(dead_code)] tileset: Vec<[u8; 64]>, entity_tileset: Vec<[u8; 64]>, net: NetClient, player_entity_id: u32, /// Authoritative position, as last confirmed by the server. player_pos: (i32, i32), /// Queued future steps: sent to the server but not yet confirmed. Shown as the /// planned path; the head is consumed as the server confirms each move. path: VecDeque, /// Planned route from click-to-move: tiles not yet sent to the server. Fed into /// `path` one step per movement window. Keyboard input cancels it. route: VecDeque<(i32, i32)>, /// The persistent movement goal. Outlives the route: any surprise (blocked step, /// server divergence) 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)>, /// Tick clock: the last tick announced by a `StatePacket` and how long ago it /// arrived — together an estimate of the server's current tick. server_tick: u32, tick_age_ms: usize, /// Smoothed round-trip time from the automatic pings; `0` until the first pong. rtt_ms: f32, ping_accum_ms: usize, /// Highest movement window a step was scheduled for. At most one step is scheduled /// per window; retraction lowers this so freed windows can be re-addressed. last_sched_window: u32, /// 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), entities: Vec, /// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game /// logic keeps using the authoritative tile positions. lerp: HashMap, } impl Game { pub fn start(server_addr: std::net::SocketAddr) -> Self { let image = Image::from_tga("assets/tilesets/overworld.tga"); let tileset = image.to_tileset(); let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset(); let player_pos = (16, 16); Game { tileset, entity_tileset, net: NetClient::new(server_addr), player_entity_id: 0, player_pos, path: VecDeque::new(), route: VecDeque::new(), goal: None, server_tick: 0, tick_age_ms: 0, rtt_ms: 0.0, ping_accum_ms: PING_INTERVAL_MS, // first ping fires immediately last_sched_window: 0, cam: ( (player_pos.0 * TILE_PX - VIEW_PX / 2) as f32, (player_pos.1 * TILE_PX - VIEW_PX / 2) as f32, ), entities: Vec::new(), lerp: HashMap::new(), } } /// True if the world tile at `(wx, wy)` blocks movement. An unknown chunk (outside the /// loaded world) counts as blocked, so we never predict into the void. fn tile_blocked(&self, wx: i32, wy: i32) -> bool { let cid = chunk_id(wx.div_euclid(32) as i16, wy.div_euclid(32) as i16); match self.net.chunk_cache.get(&cid) { Some(c) => { let idx = wy.rem_euclid(32) as usize * 32 + wx.rem_euclid(32) as usize; tile_collidable(c.tiles[idx]) } None => true, } } pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState) -> Option { self.tick_age_ms += dt; // Periodic RTT sampling — the measurement drives how far ahead steps are // scheduled (`sched_lead`), so it has to stay current on a changing link. self.ping_accum_ms += dt; if self.ping_accum_ms >= PING_INTERVAL_MS { self.ping_accum_ms = 0; self.net.send_ping(); } if input.mouse_clicked() || input.mouse_held() { self.handle_click(input.mouse_pos(), !input.mouse_clicked()); } self.step_movement(input); if input.button_pressed(GameAction::Confirm) { self.net.send_ping(); println!("ping sent"); } for event in self.net.poll() { match event { NetEvent::Pong { rtt_ms } => { self.rtt_ms = if self.rtt_ms == 0.0 { rtt_ms as f32 } else { 0.8 * self.rtt_ms + 0.2 * rtt_ms as f32 }; } NetEvent::State { tick, player_entity_id } => { println!("state tick={tick} player_entity_id={player_entity_id}"); self.player_entity_id = player_entity_id; self.server_tick = tick; self.tick_age_ms = 0; } NetEvent::Chunk { chunk_id } => println!("chunk cached id={chunk_id}"), NetEvent::Entity { tick, entities } => { self.player_pos = entities.iter() .find(|e| e.id == self.player_entity_id) .map(|e| (e.pos_x as i32, e.pos_y as i32)) .unwrap_or(self.player_pos); self.reconcile_path(tick); self.track_lerp(&entities); self.entities = entities; } NetEvent::Disconnected => println!("disconnected from server"), } } 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 } /// Replay one pending delta the way the server will: through the shared movement /// rule. A delta the server is going to reject does not move the prediction either — /// so the prediction can never sit inside a wall, however far position and pending /// steps have diverged. fn replay_step(&self, p: (i32, i32), delta: (i32, i32)) -> (i32, i32) { let to = (p.0 + delta.0, p.1 + delta.1); if step_allowed(p, to, |x, y| self.tile_blocked(x, y)) { to } else { p } } /// The position movement continues from: the last authoritative position with every /// pending delta replayed on top. Never stored — always derived, so it follows the /// server automatically when a step was rejected or executed elsewhere. fn predicted_pos(&self) -> (i32, i32) { self.path.iter() .fold(self.player_pos, |p, s| self.replay_step(p, s.delta)) } /// Estimated current server tick: the last announced tick advanced by local elapsed /// time. Broadcast latency makes this lag slightly behind the real server clock — /// scheduling one window ahead plus the server's lateness rule absorbs that. fn est_tick(&self) -> f32 { self.server_tick as f32 + self.tick_age_ms as f32 / TICK_MS } /// The movement window the estimated server time is currently in. fn est_window(&self) -> u32 { (self.est_tick() / TICKS_PER_MOVE as f32) as u32 } /// How many windows ahead to schedule so actions arrive *before* their window. The /// tick estimate lags by the downlink and the action spends the uplink in transit — /// together one RTT — so the lead must cover the RTT plus a jitter margin. At least /// one: never schedule into the current window. fn sched_lead(&self) -> u32 { (((self.rtt_ms + LEAD_MARGIN_MS) / WINDOW_MS).ceil() as u32).max(1) } /// The newest window whose scheduled step can no longer be retracted in time: a /// cancellation sent now needs the same lead an action does. fn retract_horizon(&self) -> u32 { self.est_window() + self.sched_lead() - 1 } /// Where the camera wants to be: the viewport centered on the player's tile. fn cam_target(&self) -> (f32, f32) { ( (self.player_pos.0 * TILE_PX - VIEW_PX / 2) as f32, (self.player_pos.1 * 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. The route is only a client-side plan — executed as ordinary /// tick-addressed actions in `step_movement`, so the server keeps full authority. /// 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 of // the prediction means exactly that, so plan again. if hold && self.goal == Some(goal) && (!self.route.is_empty() || self.predicted_pos() == goal) { return; } // Plan from where the player will be once the retractable steps are withdrawn: // the confirmed position plus the deltas that can no longer be cancelled in // time — not the tip of a prediction we are about to cancel. let horizon = self.retract_horizon(); let start = self.path.iter() .take_while(|s| s.window <= horizon) .fold(self.player_pos, |p, s| self.replay_step(p, s.delta)); match pathfind::find_path(start, goal, |x, y| self.tile_blocked(x, y)) { Some(steps) => { // The plan changed: retract every scheduled-but-unexecuted step so the // old intent cannot keep playing out on the server. self.retract_future_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; } None => {} } } /// Movement scheduling on the server timeline. A held direction key (which cancels /// any goal) or the next planned route tile is addressed to the *next* movement /// window — at most one step per window, so the send rate follows the server's /// cadence by construction instead of racing it with a local timer. Steps are /// validated locally 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). let kx = input.button_held(GameAction::Right) as i32 - input.button_held(GameAction::Left) as i32; let ky = input.button_held(GameAction::Down) as i32 - input.button_held(GameAction::Up) as i32; 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 target = self.est_window() + self.sched_lead(); let ready = target > self.last_sched_window && self.path.len() < PATH_MAX_LEN; let from = self.predicted_pos(); // The next step: either from the held key, or the head of the planned route. let step = match dir { Some((dx, dy)) => Some((from.0 + dx, from.1 + dy)), None => self.route.front().copied(), }; if let Some((nx, ny)) = step && ready { let allowed = step_allowed(from, (nx, ny), |x, y| self.tile_blocked(x, y)); if allowed { if dir.is_none() { self.route.pop_front(); } self.path.push_back(PathStep { window: target, delta: (nx - from.0, ny - from.1) }); self.net.send_action( delta_action(nx - from.0, ny - from.1), target * TICKS_PER_MOVE, ); self.last_sched_window = target; } else if dir.is_none() { self.replan_route(); } } } /// Retract queued steps the server has (very likely) not executed yet: every step /// addressed past the current estimated window gets a NOOP sent to its slot /// (replacement = cancellation) and leaves the prediction. Steps at or before the /// current window are left to be confirmed — cancelling those would race their /// execution. Freed windows become addressable again. fn retract_future_steps(&mut self) { let horizon = self.retract_horizon(); while let Some(step) = self.path.back() { if step.window <= horizon { break; } self.net.send_action(player_action::NOOP, step.window * TICKS_PER_MOVE); self.path.pop_back(); } self.last_sched_window = self.path.back().map_or(horizon, |s| s.window); } /// Replan the route from the current prediction toward the persistent goal — the /// client-side reaction to any surprise (blocked step, server divergence). 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 }; if self.predicted_pos() == goal { self.goal = None; return; } match pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y)) { Some(steps) => self.route = steps.into(), None => self.goal = None, } } /// Fold a fresh authoritative entity list 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, entities: &[EntityInfo]) { for e in entities { let cur = (e.pos_x as i32, e.pos_y 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.iter().any(|e| e.id == *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 confirmed. fn entity_px(&self, e: &EntityInfo) -> (i32, i32) { let cur = (e.pos_x as i32 * TILE_PX, e.pos_y 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, } } /// Fold a fresh authoritative snapshot into the prediction. The snapshot's tick is /// an acknowledgment cursor on the shared timeline: every movement window at or /// before it has been consumed — executed, rejected or lost, it no longer matters /// which — so those steps expire, and the remaining deltas replay on top of the /// confirmed position (`predicted_pos`). A server surprise thus *shifts* the /// prediction instead of invalidating it; if the shifted route no longer connects /// or misses the goal, send-time validation triggers a replan toward the goal. fn reconcile_path(&mut self, tick: u32) { let ack_window = tick / TICKS_PER_MOVE; while self.path.front().is_some_and(|s| s.window <= ack_window) { self.path.pop_front(); } if self.path.is_empty() && self.route.is_empty() && self.goal == Some(self.player_pos) { self.goal = None; // arrived — a later server correction must not walk us back } } 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 cid = chunk_id(wx.div_euclid(32) as i16, wy.div_euclid(32) as i16); let tile_idx = wy.rem_euclid(32) as usize * 32 + wx.rem_euclid(32) as usize; let tile_id = self.net.chunk_cache.get(&cid) .map(|c| c.tiles[tile_idx]) .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); } } } } } // Path pass — in-flight steps bright, planned-route tiles dim; over the world, // under entities. // Path tiles are derived by replaying the pending deltas from the confirmed // position — the same fold the prediction uses (a rejected step marks in place). let mut acc = self.player_pos; let marks = self.path.iter() .map(|s| { acc = self.replay_step(acc, s.delta); (acc, PATH_COLOR) }) .collect::>().into_iter() .chain(self.route.iter().map(|&p| (p, ROUTE_COLOR))); for ((wx, wy), color) in marks { 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, color); } } } // Entity pass — interpolated between the last two confirmed positions. for e in &self.entities { 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); } } }