Smooth lerping

This commit is contained in:
2026-07-17 22:58:00 +02:00
parent 674cf91ef3
commit 5cc156992f
2 changed files with 145 additions and 30 deletions
+135 -24
View File
@@ -1,7 +1,7 @@
mod pathfind; mod pathfind;
mod pixelhelper; mod pixelhelper;
use std::collections::VecDeque; use std::collections::{HashMap, VecDeque};
use crate::assets::Image; use crate::assets::Image;
use crate::input::{GameAction, InputState}; 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. /// Viewport geometry: 30×30 tiles of 8 px, top-left of the framebuffer.
const VIEW_TILES: i32 = 30; const VIEW_TILES: i32 = 30;
const TILE_PX: i32 = 8; 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 { pub struct Game {
#[allow(dead_code)] #[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 /// 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. /// `path` one step per `MOVE_INTERVAL_MS`. Keyboard input cancels it.
route: VecDeque<(i32, i32)>, 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, move_accum_ms: usize,
idle_ms: usize, idle_ms: usize,
entities: Vec<EntityInfo>, entities: Vec<EntityInfo>,
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
/// logic keeps using the authoritative tile positions.
lerp: HashMap<u32, EntityLerp>,
} }
impl Game { impl Game {
@@ -71,17 +94,23 @@ impl Game {
let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset(); let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset();
let server_addr = "127.0.0.1:7777".parse().unwrap(); let server_addr = "127.0.0.1:7777".parse().unwrap();
let player_pos = (16, 16);
Game { Game {
tileset, tileset,
entity_tileset, entity_tileset,
net: NetClient::new(server_addr), net: NetClient::new(server_addr),
player_entity_id: 0, player_entity_id: 0,
player_pos: (16, 16), player_pos,
path: VecDeque::new(), path: VecDeque::new(),
route: 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 move_accum_ms: MOVE_INTERVAL_MS, // ready, so the first held step fires instantly
idle_ms: 0, idle_ms: 0,
entities: Vec::new(), entities: Vec::new(),
lerp: HashMap::new(),
} }
} }
@@ -126,6 +155,7 @@ impl Game {
.map(|e| (e.pos_x as i32, e.pos_y as i32)) .map(|e| (e.pos_x as i32, e.pos_y as i32))
.unwrap_or(self.player_pos); .unwrap_or(self.player_pos);
self.reconcile_path(); self.reconcile_path();
self.track_lerp(&entities);
self.entities = entities; self.entities = entities;
} }
NetEvent::Disconnected => println!("disconnected from server"), NetEvent::Disconnected => println!("disconnected from server"),
@@ -137,6 +167,8 @@ impl Game {
return Some(GameSignal::Quit); return Some(GameSignal::Quit);
} }
self.step_camera(dt);
self.step_lerp(dt);
self.render_viewport(render_frame); self.render_viewport(render_frame);
None None
} }
@@ -147,16 +179,43 @@ impl Game {
self.path.back().copied().unwrap_or(self.player_pos) 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 /// 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 /// 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. /// actions in `step_movement`, so the server keeps full authority over every step.
fn handle_click(&mut self, (mx, my): (i32, i32)) { 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 return; // outside the world viewport
} }
let ox = self.player_pos.0 - 15; let (cx, cy) = self.cam_px();
let oy = self.player_pos.1 - 15; let goal = ((cx + mx).div_euclid(TILE_PX), (cy + my).div_euclid(TILE_PX));
let goal = (ox + mx / TILE_PX, oy + my / TILE_PX);
let found = pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y)); let found = pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y));
match found { 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 /// Reconcile the queued path against a fresh authoritative position: drop every queued
/// tile up to and including the one the server has now reached. /// tile up to and including the one the server has now reached.
fn reconcile_path(&mut self) { fn reconcile_path(&mut self) {
@@ -230,20 +330,25 @@ impl Game {
frame.fill(0); frame.fill(0);
let ox = self.player_pos.0 - 15; let (cx, cy) = self.cam_px();
let oy = self.player_pos.1 - 15; 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 { // One extra row and column: with a sub-tile camera offset the viewport spans
for vx in 0..30i32 { // partial tiles on both edges.
let wx = ox + vx; for vy in 0..=VIEW_TILES {
let wy = oy + vy; 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 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_idx = wy.rem_euclid(32) as usize * 32 + wx.rem_euclid(32) as usize;
let tile_id = self.net.chunk_cache.get(&cid) let tile_id = self.net.chunk_cache.get(&cid)
.map(|c| c.tiles[tile_idx]) .map(|c| c.tiles[tile_idx])
.unwrap_or(0); .unwrap_or(0);
let px = vx * 8; let px = sx + vx * TILE_PX;
let py = vy * 8; let py = sy + vy * TILE_PX;
if let Some(tile) = self.tileset.get(tile_id as usize) { if let Some(tile) = self.tileset.get(tile_id as usize) {
pixelhelper::blit_tile(frame, W, px, py, tile); pixelhelper::blit_tile(frame, W, px, py, tile);
} else { } else {
@@ -261,11 +366,9 @@ impl Game {
let marks = self.path.iter().map(|&p| (p, PATH_COLOR)) let marks = self.path.iter().map(|&p| (p, PATH_COLOR))
.chain(self.route.iter().map(|&p| (p, ROUTE_COLOR))); .chain(self.route.iter().map(|&p| (p, ROUTE_COLOR)));
for ((wx, wy), color) in marks { for ((wx, wy), color) in marks {
let vx = wx - ox; let px = wx * TILE_PX - cx;
let vy = wy - oy; let py = wy * TILE_PX - cy;
if vx < 0 || vx >= 30 || vy < 0 || vy >= 30 { continue; } if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; }
let px = vx * 8;
let py = vy * 8;
for dy in 0..8 { for dy in 0..8 {
for dx in 0..8 { for dx in 0..8 {
pixelhelper::set_pixel(frame, W, px + dx, py + dy, color); 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 { for e in &self.entities {
let vx = e.pos_x as i32 - ox; let (ex, ey) = self.entity_px(e);
let vy = e.pos_y as i32 - oy; let px = ex - cx;
if vx < 0 || vx >= 30 || vy < 0 || vy >= 30 { continue; } 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) { 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);
}
} }
} }
+10 -6
View File
@@ -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 - [x] Smooth camera follow: the camera is a float pixel position (`cam` in `game.rs`)
(currently `player_pos` snaps hard to the server position in `game.rs`) that tracks the player linearly at ~64 px/s, snapping only to whole pixels at
- Store previous + current position per entity, interpolate on render render time (and outright on teleport-sized corrections). The viewport renders
- Camera math is architectural — affects how entity state is stored. Do it before UI. 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.
--- ---