Files
forgotten_caves/client/src/game.rs
T
2026-09-18 22:41:20 +02:00

561 lines
24 KiB
Rust
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
mod pathfind;
mod pixelhelper;
use std::collections::{HashMap, VecDeque};
use crate::assets::Image;
use crate::input::{GameAction, InputState};
use crate::net::{EntityInfo, NetClient, NetEvent};
use shared::{chunk_id, delta_action, player_action, step_allowed, tile_collidable, TICKS_PER_MOVE};
pub enum GameSignal {
Quit,
}
/// The server's movement cadence (24 Hz base tick, movement on every 4th tick
/// → 6 Hz ≈ 167 ms/tile). Entity interpolation lerps over this interval.
const MOVE_INTERVAL_MS: usize = 167;
/// Length of one server tick. Steps are not paced by a local timer but scheduled onto
/// the server's tick timeline, estimated from the last `StatePacket` plus elapsed time.
const TICK_MS: f32 = 1000.0 / shared::TICK_HZ as f32;
/// Length of one movement window.
const WINDOW_MS: f32 = TICK_MS * TICKS_PER_MOVE as f32;
/// Safety margin on top of the measured RTT when choosing the scheduling lead — covers
/// jitter and the server answering pings on its tick grid.
const LEAD_MARGIN_MS: f32 = 50.0;
/// RTT sampling interval; the measurement drives the scheduling lead.
const PING_INTERVAL_MS: usize = 1000;
/// Upper bound on queued-but-unconfirmed steps — purely a prediction bound: window
/// addressing already caps what the server will hold (windows are consecutive and only
/// `sched_lead` ahead), while confirmations lag a full RTT behind, so on a slow link
/// several correct steps are legitimately in flight at once. Steps whose window the
/// acknowledgment cursor has passed expire automatically, so the path can never go
/// permanently stale.
const PATH_MAX_LEN: usize = 8;
/// Palette index for the path indicator. Pure blue in RGB332 (r=0, g=0, b=3).
const PATH_COLOR: u8 = 0b000_000_11;
/// Dimmer blue for planned-but-not-yet-sent route tiles (r=0, g=0, b=2).
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,
}
/// One sent-but-unacknowledged step: the movement window it is scheduled for and its
/// *delta* — deliberately not an absolute tile. The server executes deltas, so the
/// prediction is derived by replaying pending deltas on top of the last authoritative
/// position: a server surprise shifts the whole prediction instead of invalidating it.
/// The window is both the retraction address and the expiry key against the ack cursor.
struct PathStep {
window: u32,
delta: (i32, i32),
}
pub struct Game {
#[allow(dead_code)]
tileset: Vec<[u8; 64]>,
entity_tileset: Vec<[u8; 64]>,
net: NetClient,
player_entity_id: u32,
/// Authoritative position, as last confirmed by the server.
player_pos: (i32, i32),
/// Queued future steps: sent to the server but not yet confirmed. Shown as the
/// planned path; the head is consumed as the server confirms each move.
path: VecDeque<PathStep>,
/// Planned route from click-to-move: tiles not yet sent to the server. Fed into
/// `path` one step per movement window. Keyboard input cancels it.
route: VecDeque<(i32, i32)>,
/// The persistent movement goal. Outlives the route: any surprise (blocked step,
/// server divergence) triggers a replan toward it, and while the mouse is held it
/// tracks the tile under the cursor. Cleared on arrival, unreachability, keyboard
/// override, or a click on an unreachable tile.
goal: Option<(i32, i32)>,
/// Tick clock: the last tick announced by a `StatePacket` and how long ago it
/// arrived — together an estimate of the server's current tick.
server_tick: u32,
tick_age_ms: usize,
/// Smoothed round-trip time from the automatic pings; `0` until the first pong.
rtt_ms: f32,
ping_accum_ms: usize,
/// Highest movement window a step was scheduled for. At most one step is scheduled
/// per window; retraction lowers this so freed windows can be re-addressed.
last_sched_window: u32,
/// 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),
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 {
pub fn start(server_addr: std::net::SocketAddr) -> Self {
let image = Image::from_tga("assets/tilesets/overworld.tga");
let tileset = image.to_tileset();
let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset();
let player_pos = (16, 16);
Game {
tileset,
entity_tileset,
net: NetClient::new(server_addr),
player_entity_id: 0,
player_pos,
path: VecDeque::new(),
route: VecDeque::new(),
goal: None,
server_tick: 0,
tick_age_ms: 0,
rtt_ms: 0.0,
ping_accum_ms: PING_INTERVAL_MS, // first ping fires immediately
last_sched_window: 0,
cam: (
(player_pos.0 * TILE_PX - VIEW_PX / 2) as f32,
(player_pos.1 * TILE_PX - VIEW_PX / 2) as f32,
),
entities: Vec::new(),
lerp: HashMap::new(),
}
}
/// True if the world tile at `(wx, wy)` blocks movement. An unknown chunk (outside the
/// loaded world) counts as blocked, so we never predict into the void.
fn tile_blocked(&self, wx: i32, wy: i32) -> bool {
let cid = chunk_id(wx.div_euclid(32) as i16, wy.div_euclid(32) as i16);
match self.net.chunk_cache.get(&cid) {
Some(c) => {
let idx = wy.rem_euclid(32) as usize * 32 + wx.rem_euclid(32) as usize;
tile_collidable(c.tiles[idx])
}
None => true,
}
}
pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState)
-> Option<GameSignal>
{
self.tick_age_ms += dt;
// Periodic RTT sampling — the measurement drives how far ahead steps are
// scheduled (`sched_lead`), so it has to stay current on a changing link.
self.ping_accum_ms += dt;
if self.ping_accum_ms >= PING_INTERVAL_MS {
self.ping_accum_ms = 0;
self.net.send_ping();
}
if input.mouse_clicked() || input.mouse_held() {
self.handle_click(input.mouse_pos(), !input.mouse_clicked());
}
self.step_movement(input);
if input.button_pressed(GameAction::Confirm) {
self.net.send_ping();
println!("ping sent");
}
for event in self.net.poll() {
match event {
NetEvent::Pong { rtt_ms } => {
self.rtt_ms = if self.rtt_ms == 0.0 {
rtt_ms as f32
} else {
0.8 * self.rtt_ms + 0.2 * rtt_ms as f32
};
}
NetEvent::State { tick, player_entity_id } => {
println!("state tick={tick} player_entity_id={player_entity_id}");
self.player_entity_id = player_entity_id;
self.server_tick = tick;
self.tick_age_ms = 0;
}
NetEvent::Chunk { chunk_id } => println!("chunk cached id={chunk_id}"),
NetEvent::Entity { tick, entities } => {
self.player_pos = entities.iter()
.find(|e| e.id == self.player_entity_id)
.map(|e| (e.pos_x as i32, e.pos_y as i32))
.unwrap_or(self.player_pos);
self.reconcile_path(tick);
self.track_lerp(&entities);
self.entities = entities;
}
NetEvent::Disconnected => println!("disconnected from server"),
}
}
if input.button_pressed(GameAction::Cancel) {
println!("Goodbye!");
return Some(GameSignal::Quit);
}
self.step_camera(dt);
self.step_lerp(dt);
self.render_viewport(render_frame);
None
}
/// Replay one pending delta the way the server will: through the shared movement
/// rule. A delta the server is going to reject does not move the prediction either —
/// so the prediction can never sit inside a wall, however far position and pending
/// steps have diverged.
fn replay_step(&self, p: (i32, i32), delta: (i32, i32)) -> (i32, i32) {
let to = (p.0 + delta.0, p.1 + delta.1);
if step_allowed(p, to, |x, y| self.tile_blocked(x, y)) { to } else { p }
}
/// The position movement continues from: the last authoritative position with every
/// pending delta replayed on top. Never stored — always derived, so it follows the
/// server automatically when a step was rejected or executed elsewhere.
fn predicted_pos(&self) -> (i32, i32) {
self.path.iter()
.fold(self.player_pos, |p, s| self.replay_step(p, s.delta))
}
/// Estimated current server tick: the last announced tick advanced by local elapsed
/// time. Broadcast latency makes this lag slightly behind the real server clock —
/// scheduling one window ahead plus the server's lateness rule absorbs that.
fn est_tick(&self) -> f32 {
self.server_tick as f32 + self.tick_age_ms as f32 / TICK_MS
}
/// The movement window the estimated server time is currently in.
fn est_window(&self) -> u32 {
(self.est_tick() / TICKS_PER_MOVE as f32) as u32
}
/// How many windows ahead to schedule so actions arrive *before* their window. The
/// tick estimate lags by the downlink and the action spends the uplink in transit —
/// together one RTT — so the lead must cover the RTT plus a jitter margin. At least
/// one: never schedule into the current window.
fn sched_lead(&self) -> u32 {
(((self.rtt_ms + LEAD_MARGIN_MS) / WINDOW_MS).ceil() as u32).max(1)
}
/// The newest window whose scheduled step can no longer be retracted in time: a
/// cancellation sent now needs the same lead an action does.
fn retract_horizon(&self) -> u32 {
self.est_window() + self.sched_lead() - 1
}
/// 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 adopt it as
/// the movement goal. The route is only a client-side plan — executed as ordinary
/// tick-addressed actions in `step_movement`, so the server keeps full authority.
/// With `hold` (button held after the initial click) this runs every frame and keeps
/// steering toward the tile under the cursor, replanning only when that tile changes
/// — camera movement alone shifts it too, not just moving the mouse.
fn handle_click(&mut self, (mx, my): (i32, i32), hold: bool) {
if mx < 0 || my < 0 || mx >= VIEW_PX || my >= VIEW_PX {
return; // outside the world viewport
}
let (cx, cy) = self.cam_px();
let goal = ((cx + mx).div_euclid(TILE_PX), (cy + my).div_euclid(TILE_PX));
// While steering, an unchanged goal needs no replan — unless the route was voided
// (blocked step) before reaching it; an empty route with the goal still ahead of
// the prediction means exactly that, so plan again.
if hold && self.goal == Some(goal)
&& (!self.route.is_empty() || self.predicted_pos() == goal)
{
return;
}
// Plan from where the player will be once the retractable steps are withdrawn:
// the confirmed position plus the deltas that can no longer be cancelled in
// time — not the tip of a prediction we are about to cancel.
let horizon = self.retract_horizon();
let start = self.path.iter()
.take_while(|s| s.window <= horizon)
.fold(self.player_pos, |p, s| self.replay_step(p, s.delta));
match pathfind::find_path(start, goal, |x, y| self.tile_blocked(x, y)) {
Some(steps) => {
// The plan changed: retract every scheduled-but-unexecuted step so the
// old intent cannot keep playing out on the server.
self.retract_future_steps();
self.route = steps.into();
self.goal = Some(goal);
}
// A discrete click on an unreachable tile cancels the plan; while steering,
// sweeping the cursor across a blocked tile keeps the current plan alive.
None if !hold => {
self.route.clear();
self.goal = None;
}
None => {}
}
}
/// Movement scheduling on the server timeline. A held direction key (which cancels
/// any goal) or the next planned route tile is addressed to the *next* movement
/// window — at most one step per window, so the send rate follows the server's
/// cadence by construction instead of racing it with a local timer. Steps are
/// validated locally with the shared movement rule; a blocked route step triggers
/// a replan toward the goal.
fn step_movement(&mut self, input: &InputState) {
// Both axes combine, so two held keys walk diagonally (a king move).
let kx = input.button_held(GameAction::Right) as i32
- input.button_held(GameAction::Left) as i32;
let ky = input.button_held(GameAction::Down) as i32
- input.button_held(GameAction::Up) as i32;
let dir = (kx != 0 || ky != 0).then_some((kx, ky));
if dir.is_some() {
// Manual input overrides click-to-move entirely, goal included.
self.route.clear();
self.goal = None;
}
let target = self.est_window() + self.sched_lead();
let ready = target > self.last_sched_window && self.path.len() < PATH_MAX_LEN;
let from = self.predicted_pos();
// The next step: either from the held key, or the head of the planned route.
let step = match dir {
Some((dx, dy)) => Some((from.0 + dx, from.1 + dy)),
None => self.route.front().copied(),
};
if let Some((nx, ny)) = step
&& ready
{
let allowed = step_allowed(from, (nx, ny), |x, y| self.tile_blocked(x, y));
if allowed {
if dir.is_none() {
self.route.pop_front();
}
self.path.push_back(PathStep { window: target, delta: (nx - from.0, ny - from.1) });
self.net.send_action(
delta_action(nx - from.0, ny - from.1),
target * TICKS_PER_MOVE,
);
self.last_sched_window = target;
} else if dir.is_none() {
self.replan_route();
}
}
}
/// Retract queued steps the server has (very likely) not executed yet: every step
/// addressed past the current estimated window gets a NOOP sent to its slot
/// (replacement = cancellation) and leaves the prediction. Steps at or before the
/// current window are left to be confirmed — cancelling those would race their
/// execution. Freed windows become addressable again.
fn retract_future_steps(&mut self) {
let horizon = self.retract_horizon();
while let Some(step) = self.path.back() {
if step.window <= horizon {
break;
}
self.net.send_action(player_action::NOOP, step.window * TICKS_PER_MOVE);
self.path.pop_back();
}
self.last_sched_window = self.path.back().map_or(horizon, |s| s.window);
}
/// Replan the route from the current prediction toward the persistent goal — the
/// client-side reaction to any surprise (blocked step, server divergence). Gives
/// the goal up only when it is reached or has become unreachable.
fn replan_route(&mut self) {
self.route.clear();
let Some(goal) = self.goal else { return };
if self.predicted_pos() == goal {
self.goal = None;
return;
}
match pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y)) {
Some(steps) => self.route = steps.into(),
None => self.goal = None,
}
}
/// 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,
}
}
/// Fold a fresh authoritative snapshot into the prediction. The snapshot's tick is
/// an acknowledgment cursor on the shared timeline: every movement window at or
/// before it has been consumed — executed, rejected or lost, it no longer matters
/// which — so those steps expire, and the remaining deltas replay on top of the
/// confirmed position (`predicted_pos`). A server surprise thus *shifts* the
/// prediction instead of invalidating it; if the shifted route no longer connects
/// or misses the goal, send-time validation triggers a replan toward the goal.
fn reconcile_path(&mut self, tick: u32) {
let ack_window = tick / TICKS_PER_MOVE;
while self.path.front().is_some_and(|s| s.window <= ack_window) {
self.path.pop_front();
}
if self.path.is_empty() && self.route.is_empty() && self.goal == Some(self.player_pos) {
self.goal = None; // arrived — a later server correction must not walk us back
}
}
fn render_viewport(&self, frame: &mut [u8]) {
const WALL: u8 = 0x00;
const W: usize = 320;
frame.fill(0);
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);
// 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 = 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 {
for dy in 0..8 {
for dx in 0..8 {
pixelhelper::set_pixel(frame, W, px + dx, py + dy, WALL);
}
}
}
}
}
// Path pass — in-flight steps bright, planned-route tiles dim; over the world,
// under entities.
// Path tiles are derived by replaying the pending deltas from the confirmed
// position — the same fold the prediction uses (a rejected step marks in place).
let mut acc = self.player_pos;
let marks = self.path.iter()
.map(|s| {
acc = self.replay_step(acc, s.delta);
(acc, PATH_COLOR)
})
.collect::<Vec<_>>().into_iter()
.chain(self.route.iter().map(|&p| (p, ROUTE_COLOR)));
for ((wx, wy), color) in marks {
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);
}
}
}
// Entity pass — interpolated between the last two confirmed positions.
for e in &self.entities {
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, 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);
}
}
}