Pathfinding and re-pickup of the project
This commit is contained in:
+171
-9
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user