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;
|
||||
|
||||
Reference in New Issue
Block a user