Pathfinding and re-pickup of the project
This commit is contained in:
+171
-9
@@ -1,21 +1,66 @@
|
|||||||
|
mod pathfind;
|
||||||
mod pixelhelper;
|
mod pixelhelper;
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use crate::assets::Image;
|
use crate::assets::Image;
|
||||||
use crate::input::{GameAction, InputState};
|
use crate::input::{GameAction, InputState};
|
||||||
use crate::net::{EntityInfo, NetClient, NetEvent};
|
use crate::net::{EntityInfo, NetClient, NetEvent};
|
||||||
use shared::{chunk_id, player_action};
|
use shared::{chunk_id, player_action, tile_collidable};
|
||||||
|
|
||||||
pub enum GameSignal {
|
pub enum GameSignal {
|
||||||
Quit,
|
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 {
|
pub struct Game {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
tileset: Vec<[u8; 64]>,
|
tileset: Vec<[u8; 64]>,
|
||||||
entity_tileset: Vec<[u8; 64]>,
|
entity_tileset: Vec<[u8; 64]>,
|
||||||
net: NetClient,
|
net: NetClient,
|
||||||
player_entity_id: u32,
|
player_entity_id: u32,
|
||||||
|
/// Authoritative position, as last confirmed by the server.
|
||||||
player_pos: (i32, i32),
|
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>,
|
entities: Vec<EntityInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,17 +77,35 @@ impl Game {
|
|||||||
net: NetClient::new(server_addr),
|
net: NetClient::new(server_addr),
|
||||||
player_entity_id: 0,
|
player_entity_id: 0,
|
||||||
player_pos: (16, 16),
|
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(),
|
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>
|
-> Option<GameSignal>
|
||||||
{
|
{
|
||||||
if input.button_held(GameAction::Up) { self.net.send_action(player_action::NORTH); }
|
if input.mouse_clicked() {
|
||||||
if input.button_held(GameAction::Down) { self.net.send_action(player_action::SOUTH); }
|
self.handle_click(input.mouse_pos());
|
||||||
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); }
|
|
||||||
|
self.step_movement(dt, input);
|
||||||
|
|
||||||
if input.button_pressed(GameAction::Confirm) {
|
if input.button_pressed(GameAction::Confirm) {
|
||||||
self.net.send_ping();
|
self.net.send_ping();
|
||||||
@@ -62,6 +125,7 @@ impl Game {
|
|||||||
.find(|e| e.id == self.player_entity_id)
|
.find(|e| e.id == self.player_entity_id)
|
||||||
.map(|e| (e.pos_x as i32, e.pos_y as i32))
|
.map(|e| (e.pos_x as i32, e.pos_y as i32))
|
||||||
.unwrap_or(self.player_pos);
|
.unwrap_or(self.player_pos);
|
||||||
|
self.reconcile_path();
|
||||||
self.entities = entities;
|
self.entities = entities;
|
||||||
}
|
}
|
||||||
NetEvent::Disconnected => println!("disconnected from server"),
|
NetEvent::Disconnected => println!("disconnected from server"),
|
||||||
@@ -77,6 +141,89 @@ impl Game {
|
|||||||
None
|
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]) {
|
fn render_viewport(&self, frame: &mut [u8]) {
|
||||||
const WALL: u8 = 0x00;
|
const WALL: u8 = 0x00;
|
||||||
const W: usize = 320;
|
const W: usize = 320;
|
||||||
@@ -109,9 +256,24 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity pass
|
// Path pass — in-flight steps bright, planned-route tiles dim; over the world,
|
||||||
let ox = self.player_pos.0 - 15;
|
// under entities.
|
||||||
let oy = self.player_pos.1 - 15;
|
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 {
|
for e in &self.entities {
|
||||||
let vx = e.pos_x as i32 - ox;
|
let vx = e.pos_x as i32 - ox;
|
||||||
let vy = e.pos_y as i32 - oy;
|
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>,
|
pressed: Vec<GameAction>,
|
||||||
held: Vec<GameAction>,
|
held: Vec<GameAction>,
|
||||||
released: Vec<GameAction>,
|
released: Vec<GameAction>,
|
||||||
|
/// Cursor position in framebuffer pixels (pbio delivers framebuffer coordinates).
|
||||||
|
mouse_pos: (i32, i32),
|
||||||
|
mouse_clicked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InputState {
|
impl InputState {
|
||||||
pub fn new() -> Self {
|
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) {
|
pub fn push(&mut self, action: GameAction) {
|
||||||
@@ -79,5 +105,6 @@ impl InputState {
|
|||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.pressed.clear();
|
self.pressed.clear();
|
||||||
self.released.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};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
mod assets;
|
mod assets;
|
||||||
@@ -39,6 +39,12 @@ fn main() {
|
|||||||
input_state.release(action);
|
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(),
|
Event::CloseRequested => plat.request_close(),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ terminal). The platform layer — window, GPU, input, RGB332 palette — lives i
|
|||||||
- [x] Map loader: Tiled-CSV parser in `server/src/map.rs` (flip-flag masking, content-derived
|
- [x] Map loader: Tiled-CSV parser in `server/src/map.rs` (flip-flag masking, content-derived
|
||||||
dimensions), `load_world` slices it into chunks, invisible solid border at the map rim.
|
dimensions), `load_world` slices it into chunks, invisible solid border at the map rim.
|
||||||
Replaces the hardcoded `build_world`. See `notes/07-map-loader.md`
|
Replaces the hardcoded `build_world`. See `notes/07-map-loader.md`
|
||||||
|
- [x] Client movement prediction: queued unconfirmed steps (`path` in `client/src/game.rs`),
|
||||||
|
paced at the server's 6 Hz movement cadence, reconciled against `EntityPacket` positions,
|
||||||
|
dropped after idle timeout. Shared collision vocabulary `shared::tile_collidable`
|
||||||
|
(currently empty — every tile walkable) keeps client prediction and server sim in lockstep.
|
||||||
|
- [x] Click-to-move: framebuffer click → world tile, A* over the chunk cache
|
||||||
|
(`client/src/game/pathfind.rs`, unknown chunks count as blocked), route translated into
|
||||||
|
cardinal actions one step per movement interval — the server only ever sees N/E/S/W and
|
||||||
|
stays authoritative. Keyboard input cancels the route; each step is re-validated at send
|
||||||
|
time and a blocked step voids the route. In-flight steps render bright blue, planned
|
||||||
|
route dim blue.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -105,6 +115,9 @@ Sprite rendering already landed (see Achieved). What remains is the camera:
|
|||||||
|
|
||||||
### Later — Robustness + auth
|
### Later — Robustness + auth
|
||||||
|
|
||||||
|
- Reactive pathfinding: replan from the confirmed position toward the original goal when a
|
||||||
|
route step is blocked or the server diverges from the prediction (currently the route is
|
||||||
|
simply voided)
|
||||||
- Auth token handshake: replace source-address identity (`auth_token` field exists but is unused)
|
- Auth token handshake: replace source-address identity (`auth_token` field exists but is unused)
|
||||||
- Multi-datagram `EntityPacket`: server currently truncates at 66 entities (`net.rs` TODO)
|
- Multi-datagram `EntityPacket`: server currently truncates at 66 entities (`net.rs` TODO)
|
||||||
- Asset embedding: `include_bytes!` for single-binary distribution
|
- Asset embedding: `include_bytes!` for single-binary distribution
|
||||||
|
|||||||
+7
-8
@@ -64,14 +64,13 @@ impl TileMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gameplay flags for a global tile id. Hand-maintained vocabulary — extend as the
|
/// Gameplay flags for a global tile id. Collision comes from the shared movement
|
||||||
/// tileset grows. Unknown ids default to a plain walkable floor tile.
|
/// vocabulary (`shared::tile_collidable`) so the client can predict it identically;
|
||||||
///
|
/// other flags (opaque, …) stay server-side. Tile ids index into `overworld.tga`.
|
||||||
/// Tile ids correspond to indices into `overworld.tga` on the client side.
|
|
||||||
pub fn tile_flags(tile_id: u16) -> TileFlags {
|
pub fn tile_flags(tile_id: u16) -> TileFlags {
|
||||||
match tile_id {
|
let mut bits = 0u8;
|
||||||
// TODO: fill in collidable / opaque tiles from overworld.tga, e.g.
|
if shared::tile_collidable(tile_id) {
|
||||||
// 146 => TileFlags(TileFlags::COLLIDABLE | TileFlags::OPAQUE),
|
bits |= TileFlags::COLLIDABLE;
|
||||||
_ => TileFlags::default(),
|
|
||||||
}
|
}
|
||||||
|
TileFlags(bits)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ pub fn chunk_coords(id: u32) -> (i16, i16) {
|
|||||||
(id as u16 as i16, (id >> 16) as u16 as i16)
|
(id as u16 as i16, (id >> 16) as u16 as i16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shared movement vocabulary: whether a tile id blocks movement.
|
||||||
|
///
|
||||||
|
/// Single source of truth for both the server (collision in the sim) and the client
|
||||||
|
/// (movement prediction). Keep this in lockstep with the tileset in `overworld.tga`.
|
||||||
|
/// Currently every authored tile is walkable.
|
||||||
|
pub fn tile_collidable(tile_id: u16) -> bool {
|
||||||
|
match tile_id {
|
||||||
|
// e.g. 146 => true, // trees / rocks
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||||
#[repr(C, packed)]
|
#[repr(C, packed)]
|
||||||
pub struct Header {
|
pub struct Header {
|
||||||
|
|||||||
Reference in New Issue
Block a user