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