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;
+106
View File
@@ -0,0 +1,106 @@
//! Grid A* over the client's chunk cache. The client only *plans* here — every step
//! still goes to the server as a cardinal action, and the server stays authoritative.
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};
/// Hard cap on expanded nodes, so a click on an unreachable tile can't stall the frame.
const MAX_EXPANSIONS: usize = 4096;
/// Longest route we will plan. Keeps the reply small and bounds replan cost.
const MAX_ROUTE_LEN: usize = 64;
/// 4-connected A* from `start` to `goal` (manhattan heuristic — admissible on a
/// cardinal grid). Returns the tiles to walk, excluding `start`, ending on `goal`,
/// or `None` if the goal is unreachable within the search budget.
pub fn find_path(
start: (i32, i32),
goal: (i32, i32),
blocked: impl Fn(i32, i32) -> bool,
) -> Option<Vec<(i32, i32)>> {
if start == goal || blocked(goal.0, goal.1) {
return None;
}
let h = |p: (i32, i32)| (p.0 - goal.0).abs() + (p.1 - goal.1).abs();
// (f, tile) min-heap; g and parent per visited tile.
let mut open = BinaryHeap::new();
let mut best: HashMap<(i32, i32), (i32, (i32, i32))> = HashMap::new();
open.push(Reverse((h(start), start)));
best.insert(start, (0, start));
let mut expanded = 0;
while let Some(Reverse((_, cur))) = open.pop() {
if cur == goal {
let mut route = Vec::new();
let mut p = goal;
while p != start {
route.push(p);
p = best[&p].1;
}
if route.len() > MAX_ROUTE_LEN {
return None;
}
route.reverse();
return Some(route);
}
expanded += 1;
if expanded > MAX_EXPANSIONS {
return None;
}
let g = best[&cur].0;
for (dx, dy) in [(0, -1), (0, 1), (-1, 0), (1, 0)] {
let next = (cur.0 + dx, cur.1 + dy);
if blocked(next.0, next.1) {
continue;
}
let ng = g + 1;
if best.get(&next).is_none_or(|&(og, _)| ng < og) {
best.insert(next, (ng, cur));
open.push(Reverse((ng + h(next), next)));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::find_path;
#[test]
fn straight_line() {
let path = find_path((0, 0), (3, 0), |_, _| false).unwrap();
assert_eq!(path, vec![(1, 0), (2, 0), (3, 0)]);
}
#[test]
fn detours_around_wall() {
// Vertical wall at x=2 with a gap at y=5.
let blocked = |x: i32, y: i32| x == 2 && y != 5;
let path = find_path((0, 0), (4, 0), blocked).unwrap();
assert_eq!(path.last(), Some(&(4, 0)));
assert!(path.iter().all(|&(x, y)| !blocked(x, y)));
assert!(path.windows(2).all(|w| {
(w[1].0 - w[0].0).abs() + (w[1].1 - w[0].1).abs() == 1
}));
assert!(path.contains(&(2, 5)));
assert_eq!(path.len(), 14); // 4 across + 2·5 detour
}
#[test]
fn unreachable_is_none() {
// Goal sealed in by a ring.
let blocked = |x: i32, y: i32| (x - 10).abs().max((y - 10).abs()) == 1;
assert_eq!(find_path((0, 0), (10, 10), blocked), None);
}
#[test]
fn degenerate_cases() {
assert_eq!(find_path((5, 5), (5, 5), |_, _| false), None);
assert_eq!(find_path((0, 0), (1, 0), |x, y| (x, y) == (1, 0)), None);
}
}
+28 -1
View File
@@ -41,11 +41,37 @@ pub struct InputState {
pressed: Vec<GameAction>,
held: Vec<GameAction>,
released: Vec<GameAction>,
/// Cursor position in framebuffer pixels (pbio delivers framebuffer coordinates).
mouse_pos: (i32, i32),
mouse_clicked: bool,
}
impl InputState {
pub fn new() -> Self {
Self { pressed: Vec::new(), held: Vec::new(), released: Vec::new() }
Self {
pressed: Vec::new(),
held: Vec::new(),
released: Vec::new(),
mouse_pos: (-1, -1),
mouse_clicked: false,
}
}
pub fn set_mouse_pos(&mut self, x: i32, y: i32) {
self.mouse_pos = (x, y);
}
pub fn push_click(&mut self) {
self.mouse_clicked = true;
}
pub fn mouse_pos(&self) -> (i32, i32) {
self.mouse_pos
}
/// True if the left button was pressed since the last `clear()`.
pub fn mouse_clicked(&self) -> bool {
self.mouse_clicked
}
pub fn push(&mut self, action: GameAction) {
@@ -79,5 +105,6 @@ impl InputState {
pub fn clear(&mut self) {
self.pressed.clear();
self.released.clear();
self.mouse_clicked = false;
}
}
+7 -1
View File
@@ -1,4 +1,4 @@
use pbio::{Event, Platform, PlatformConfig};
use pbio::{Event, MouseButton, Platform, PlatformConfig};
use std::time::{Duration, Instant};
mod assets;
@@ -39,6 +39,12 @@ fn main() {
input_state.release(action);
}
}
Event::MouseMove { x, y } => {
input_state.set_mouse_pos(x as i32, y as i32);
}
Event::MouseBtn { button: MouseButton::Left, pressed: true } => {
input_state.push_click();
}
Event::CloseRequested => plat.request_close(),
_ => {}
}