Smooth lerping
This commit is contained in:
+135
-24
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user