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 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<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 {
@@ -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);
}
}
}