561 lines
24 KiB
Rust
Executable File
561 lines
24 KiB
Rust
Executable File
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);
|
||
}
|
||
}
|
||
}
|