Pathfinding and re-pickup of the project

This commit is contained in:
2026-07-16 21:08:30 +02:00
parent 5c8494961c
commit 674cf91ef3
7 changed files with 344 additions and 19 deletions
+171 -9
View File
@@ -1,21 +1,66 @@
mod pathfind;
mod pixelhelper;
use std::collections::VecDeque;
use crate::assets::Image;
use crate::input::{GameAction, InputState};
use crate::net::{EntityInfo, NetClient, NetEvent};
use shared::{chunk_id, player_action};
use shared::{chunk_id, player_action, tile_collidable};
pub enum GameSignal {
Quit,
}
/// The cardinal action that moves `from` → `to`. This is the only movement vocabulary
/// the server understands; the pathfinder's output is translated through here.
fn step_action(from: (i32, i32), to: (i32, i32)) -> u16 {
match (to.0 - from.0, to.1 - from.1) {
(0, -1) => player_action::NORTH,
(0, 1) => player_action::SOUTH,
(-1, 0) => player_action::WEST,
(1, 0) => player_action::EAST,
d => unreachable!("non-cardinal step {d:?}"),
}
}
/// One queued step per this interval while a direction is held. Mirrors the server's
/// movement cadence (24 Hz base tick, movement on every 4th tick → 6 Hz ≈ 167 ms/tile).
const MOVE_INTERVAL_MS: usize = 167;
/// After this long without movement input, drop any unconfirmed queued path (the server
/// is the truth; whatever it hasn't acted on is discarded).
const RECONCILE_IDLE_MS: usize = 300;
/// Upper bound on queued-but-unconfirmed steps, so a server-side block can't grow it forever.
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;
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 tiles: 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<(i32, i32)>,
/// 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)>,
move_accum_ms: usize,
idle_ms: usize,
entities: Vec<EntityInfo>,
}
@@ -32,17 +77,35 @@ impl Game {
net: NetClient::new(server_addr),
player_entity_id: 0,
player_pos: (16, 16),
path: VecDeque::new(),
route: VecDeque::new(),
move_accum_ms: MOVE_INTERVAL_MS, // ready, so the first held step fires instantly
idle_ms: 0,
entities: Vec::new(),
}
}
pub fn update(&mut self, render_frame: &mut [u8], _dt: usize, input: &InputState)
/// 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>
{
if input.button_held(GameAction::Up) { self.net.send_action(player_action::NORTH); }
if input.button_held(GameAction::Down) { self.net.send_action(player_action::SOUTH); }
if input.button_held(GameAction::Left) { self.net.send_action(player_action::WEST); }
if input.button_held(GameAction::Right) { self.net.send_action(player_action::EAST); }
if input.mouse_clicked() {
self.handle_click(input.mouse_pos());
}
self.step_movement(dt, input);
if input.button_pressed(GameAction::Confirm) {
self.net.send_ping();
@@ -62,6 +125,7 @@ impl Game {
.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();
self.entities = entities;
}
NetEvent::Disconnected => println!("disconnected from server"),
@@ -77,6 +141,89 @@ impl Game {
None
}
/// The position movement continues from: the last queued step, or where the server
/// last put us.
fn predicted_pos(&self) -> (i32, i32) {
self.path.back().copied().unwrap_or(self.player_pos)
}
/// 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 {
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 found = pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y));
match found {
Some(steps) => self.route = steps.into(),
None => self.route.clear(), // unreachable — cancel any current route
}
}
/// Movement queueing. A held direction key (which cancels any planned route) or the
/// next planned route tile appends one step per `MOVE_INTERVAL_MS` to the in-flight
/// path, checking walkability locally and forwarding the cardinal action to the
/// server. When idle, drop any still-unconfirmed path.
fn step_movement(&mut self, dt: usize, input: &InputState) {
let dir = if input.button_held(GameAction::Up) { Some((0, -1)) }
else if input.button_held(GameAction::Down) { Some((0, 1)) }
else if input.button_held(GameAction::Left) { Some((-1, 0)) }
else if input.button_held(GameAction::Right) { Some((1, 0)) }
else { None };
if dir.is_some() {
self.route.clear(); // manual input overrides click-to-move
}
self.move_accum_ms = (self.move_accum_ms + dt).min(MOVE_INTERVAL_MS);
let ready = self.move_accum_ms >= MOVE_INTERVAL_MS && 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 {
self.idle_ms = 0;
if ready {
// Route steps are re-validated at send time: the world may have changed
// since planning (or the plan may have desynced). A bad step voids the
// whole route rather than walking blindly on.
let adjacent = (nx - from.0).abs() + (ny - from.1).abs() == 1;
if adjacent && !self.tile_blocked(nx, ny) {
if dir.is_none() {
self.route.pop_front();
}
self.path.push_back((nx, ny));
self.net.send_action(step_action(from, (nx, ny)));
} else if dir.is_none() {
self.route.clear();
}
self.move_accum_ms = 0;
}
} else {
self.idle_ms += dt;
if self.idle_ms >= RECONCILE_IDLE_MS {
self.path.clear();
}
}
}
/// 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) {
if let Some(idx) = self.path.iter().position(|&p| p == self.player_pos) {
self.path.drain(0..=idx);
}
}
fn render_viewport(&self, frame: &mut [u8]) {
const WALL: u8 = 0x00;
const W: usize = 320;
@@ -109,9 +256,24 @@ impl Game {
}
}
// Entity pass
let ox = self.player_pos.0 - 15;
let oy = self.player_pos.1 - 15;
// Path pass — in-flight steps bright, planned-route tiles dim; over the world,
// under entities.
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;
for dy in 0..8 {
for dx in 0..8 {
pixelhelper::set_pixel(frame, W, px + dx, py + dy, color);
}
}
}
// Entity pass — every entity at its authoritative server position.
for e in &self.entities {
let vx = e.pos_x as i32 - ox;
let vy = e.pos_y as i32 - oy;