diff --git a/client/src/game.rs b/client/src/game.rs index 773bd1e..3527049 100755 --- a/client/src/game.rs +++ b/client/src/game.rs @@ -1,7 +1,7 @@ mod pathfind; mod pixelhelper; -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use crate::assets::Image; use crate::input::{GameAction, InputState}; @@ -44,6 +44,23 @@ 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, +} pub struct Game { #[allow(dead_code)] @@ -59,9 +76,15 @@ pub struct Game { /// Planned route from click-to-move: tiles not yet sent to the server. Fed into /// `path` one step per `MOVE_INTERVAL_MS`. Keyboard input cancels it. route: VecDeque<(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), move_accum_ms: usize, idle_ms: usize, 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 { @@ -71,17 +94,23 @@ impl Game { let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset(); let server_addr = "127.0.0.1:7777".parse().unwrap(); + let player_pos = (16, 16); Game { tileset, entity_tileset, net: NetClient::new(server_addr), player_entity_id: 0, - player_pos: (16, 16), + player_pos, path: VecDeque::new(), route: VecDeque::new(), + cam: ( + (player_pos.0 * TILE_PX - VIEW_PX / 2) as f32, + (player_pos.1 * TILE_PX - VIEW_PX / 2) as f32, + ), move_accum_ms: MOVE_INTERVAL_MS, // ready, so the first held step fires instantly idle_ms: 0, entities: Vec::new(), + lerp: HashMap::new(), } } @@ -126,6 +155,7 @@ impl Game { .map(|e| (e.pos_x as i32, e.pos_y as i32)) .unwrap_or(self.player_pos); self.reconcile_path(); + self.track_lerp(&entities); self.entities = entities; } NetEvent::Disconnected => println!("disconnected from server"), @@ -137,6 +167,8 @@ impl Game { return Some(GameSignal::Quit); } + self.step_camera(dt); + self.step_lerp(dt); self.render_viewport(render_frame); None } @@ -147,16 +179,43 @@ impl Game { self.path.back().copied().unwrap_or(self.player_pos) } + /// 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 plan a route /// there. The route is only a client-side plan — it is executed as ordinary cardinal /// actions in `step_movement`, so the server keeps full authority over every step. fn handle_click(&mut self, (mx, my): (i32, i32)) { - if mx < 0 || my < 0 || mx >= VIEW_TILES * TILE_PX || my >= VIEW_TILES * TILE_PX { + if mx < 0 || my < 0 || mx >= VIEW_PX || my >= VIEW_PX { return; // outside the world viewport } - let ox = self.player_pos.0 - 15; - let oy = self.player_pos.1 - 15; - let goal = (ox + mx / TILE_PX, oy + my / TILE_PX); + let (cx, cy) = self.cam_px(); + let goal = ((cx + mx).div_euclid(TILE_PX), (cy + my).div_euclid(TILE_PX)); let found = pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y)); match found { @@ -216,6 +275,47 @@ impl Game { } } + /// 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, + } + } + /// Reconcile the queued path against a fresh authoritative position: drop every queued /// tile up to and including the one the server has now reached. fn reconcile_path(&mut self) { @@ -230,20 +330,25 @@ impl Game { frame.fill(0); - let ox = self.player_pos.0 - 15; - let oy = self.player_pos.1 - 15; + 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); - for vy in 0..30i32 { - for vx in 0..30i32 { - let wx = ox + vx; - let wy = oy + vy; + // 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 = vx * 8; - let py = vy * 8; + 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 { @@ -261,11 +366,9 @@ impl Game { let marks = self.path.iter().map(|&p| (p, PATH_COLOR)) .chain(self.route.iter().map(|&p| (p, ROUTE_COLOR))); for ((wx, wy), color) in marks { - let vx = wx - ox; - let vy = wy - oy; - if vx < 0 || vx >= 30 || vy < 0 || vy >= 30 { continue; } - let px = vx * 8; - let py = vy * 8; + 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); @@ -273,14 +376,22 @@ impl Game { } } - // Entity pass — every entity at its authoritative server position. + // Entity pass — interpolated between the last two confirmed positions. for e in &self.entities { - let vx = e.pos_x as i32 - ox; - let vy = e.pos_y as i32 - oy; - if vx < 0 || vx >= 30 || vy < 0 || vy >= 30 { continue; } + 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, vx * 8, vy * 8, tile); + 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); + } } } diff --git a/notes/roadmap.md b/notes/roadmap.md index 75ef9ca..93c352d 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -92,14 +92,18 @@ for entity in entities_by_player_proximity() { --- -### 09 — Client camera *(sprite rendering already done)* +### 09 — Client camera *(camera follow done, entity interpolation open)* -Sprite rendering already landed (see Achieved). What remains is the camera: +Sprite rendering already landed (see Achieved). Camera status: -- Smooth camera: lerp between last known and current server position; do not snap - (currently `player_pos` snaps hard to the server position in `game.rs`) -- Store previous + current position per entity, interpolate on render -- Camera math is architectural — affects how entity state is stored. Do it before UI. +- [x] Smooth camera follow: the camera is a float pixel position (`cam` in `game.rs`) + that tracks the player linearly at ~64 px/s, snapping only to whole pixels at + render time (and outright on teleport-sized corrections). The viewport renders + with sub-tile offsets (31×31 tile pass + right-strip clip). +- [x] Entity interpolation: per-id lerp table (`EntityLerp` in `game.rs`) beside the + entity list — previous/current tile plus a clock, rendered as a pixel lerp over + one movement interval. Jumps of more than one tile (Chebyshev) snap. Purely + cosmetic; game logic keeps using authoritative tile positions. ---