diff --git a/client/src/game.rs b/client/src/game.rs index cfe2b4f..773bd1e 100755 --- a/client/src/game.rs +++ b/client/src/game.rs @@ -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, } @@ -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 { - 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; diff --git a/client/src/game/pathfind.rs b/client/src/game/pathfind.rs new file mode 100644 index 0000000..94754c4 --- /dev/null +++ b/client/src/game/pathfind.rs @@ -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> { + 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); + } +} diff --git a/client/src/input.rs b/client/src/input.rs index 44111af..d9f1df9 100755 --- a/client/src/input.rs +++ b/client/src/input.rs @@ -41,11 +41,37 @@ pub struct InputState { pressed: Vec, held: Vec, released: Vec, + /// 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; } } diff --git a/client/src/main.rs b/client/src/main.rs index b77bf68..b756793 100755 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -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(), _ => {} } diff --git a/notes/roadmap.md b/notes/roadmap.md index 34a1153..75ef9ca 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -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 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` +- [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 +- 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) - Multi-datagram `EntityPacket`: server currently truncates at 66 entities (`net.rs` TODO) - Asset embedding: `include_bytes!` for single-binary distribution diff --git a/server/src/map.rs b/server/src/map.rs index 6d436be..997414b 100644 --- a/server/src/map.rs +++ b/server/src/map.rs @@ -64,14 +64,13 @@ impl TileMap { } } -/// Gameplay flags for a global tile id. Hand-maintained vocabulary — extend as the -/// tileset grows. Unknown ids default to a plain walkable floor tile. -/// -/// Tile ids correspond to indices into `overworld.tga` on the client side. +/// Gameplay flags for a global tile id. Collision comes from the shared movement +/// vocabulary (`shared::tile_collidable`) so the client can predict it identically; +/// other flags (opaque, …) stay server-side. Tile ids index into `overworld.tga`. pub fn tile_flags(tile_id: u16) -> TileFlags { - match tile_id { - // TODO: fill in collidable / opaque tiles from overworld.tga, e.g. - // 146 => TileFlags(TileFlags::COLLIDABLE | TileFlags::OPAQUE), - _ => TileFlags::default(), + let mut bits = 0u8; + if shared::tile_collidable(tile_id) { + bits |= TileFlags::COLLIDABLE; } + TileFlags(bits) } diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 29f2aba..02e10c6 100755 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -32,6 +32,18 @@ pub fn chunk_coords(id: u32) -> (i16, 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)] #[repr(C, packed)] pub struct Header {