From f594f12f351b55ca9e03c8ef3b8f5019e17840ad Mon Sep 17 00:00:00 2001 From: irrlicht Date: Fri, 18 Sep 2026 22:56:30 +0200 Subject: [PATCH] Embed the sim in the game binary; drop networking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client becomes the game: it owns a Sim instance and advances it with a fixed-timestep accumulator (24 Hz ticks, rendering at frame rate). Rendering, pathfinding and collision read the World directly instead of a chunk cache. Sim::pending (window slot maps, late rule, horizon) is replaced by one intent per entity for the next movement window — set_action replaces, executing the window consumes. The whole client prediction/reconciliation machinery (tick estimation, RTT lead, retraction, replay) is gone with the latency it was built for; click-to-move now syncs the route against the sim position after every window. Removed: server, netsim, shared (wire types), client/net.rs. client renamed to game. Headless tests cover the intent model. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 38 +- Cargo.toml | 2 +- client/src/game.rs | 560 ----------------------- client/src/net.rs | 211 --------- {client => game}/Cargo.toml | 5 +- {client => game}/src/assets.rs | 0 game/src/game.rs | 385 ++++++++++++++++ {client => game}/src/game/pathfind.rs | 4 +- {client => game}/src/game/pixelhelper.rs | 0 {client => game}/src/input.rs | 0 {client => game}/src/main.rs | 10 +- netsim/Cargo.toml | 6 - netsim/src/main.rs | 170 ------- server/Cargo.toml | 9 - server/src/main.rs | 40 -- server/src/net.rs | 256 ----------- shared/Cargo.toml | 8 - shared/src/lib.rs | 139 ------ sim/src/lib.rs | 4 - sim/src/sim.rs | 149 +++--- 20 files changed, 496 insertions(+), 1500 deletions(-) delete mode 100755 client/src/game.rs delete mode 100755 client/src/net.rs rename {client => game}/Cargo.toml (53%) rename {client => game}/src/assets.rs (100%) create mode 100755 game/src/game.rs rename {client => game}/src/game/pathfind.rs (97%) rename {client => game}/src/game/pixelhelper.rs (100%) rename {client => game}/src/input.rs (100%) rename {client => game}/src/main.rs (86%) delete mode 100644 netsim/Cargo.toml delete mode 100644 netsim/src/main.rs delete mode 100755 server/Cargo.toml delete mode 100755 server/src/main.rs delete mode 100755 server/src/net.rs delete mode 100755 shared/Cargo.toml delete mode 100755 shared/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index de78b18..01e5b36 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,15 +228,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "client" -version = "0.1.0" -dependencies = [ - "bytemuck", - "pbio", - "shared", -] - [[package]] name = "codespan-reporting" version = "0.12.0" @@ -443,6 +434,14 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "game" +version = "0.1.0" +dependencies = [ + "pbio", + "sim", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -854,10 +853,6 @@ dependencies = [ "jni-sys 0.3.0", ] -[[package]] -name = "netsim" -version = "0.1.0" - [[package]] name = "num-traits" version = "0.2.19" @@ -1471,23 +1466,6 @@ dependencies = [ "syn", ] -[[package]] -name = "server" -version = "0.1.0" -dependencies = [ - "bytemuck", - "shared", - "sim", -] - -[[package]] -name = "shared" -version = "0.1.0" -dependencies = [ - "bytemuck", - "sim", -] - [[package]] name = "shlex" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 24d3509..ae6c755 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["sim", "shared", "client", "server", "netsim"] +members = ["sim", "game"] resolver = "2" diff --git a/client/src/game.rs b/client/src/game.rs deleted file mode 100755 index b021aab..0000000 --- a/client/src/game.rs +++ /dev/null @@ -1,560 +0,0 @@ -mod pathfind; -mod pixelhelper; - -use std::collections::{HashMap, VecDeque}; - -use crate::assets::Image; -use crate::input::{GameAction, InputState}; -use crate::net::{EntityInfo, NetClient, NetEvent}; -use shared::{chunk_id, delta_action, player_action, step_allowed, tile_collidable, TICKS_PER_MOVE}; - -pub enum GameSignal { - Quit, -} - -/// The server's movement cadence (24 Hz base tick, movement on every 4th tick -/// → 6 Hz ≈ 167 ms/tile). Entity interpolation lerps over this interval. -const MOVE_INTERVAL_MS: usize = 167; - -/// Length of one server tick. Steps are not paced by a local timer but scheduled onto -/// the server's tick timeline, estimated from the last `StatePacket` plus elapsed time. -const TICK_MS: f32 = 1000.0 / shared::TICK_HZ as f32; - -/// Length of one movement window. -const WINDOW_MS: f32 = TICK_MS * TICKS_PER_MOVE as f32; - -/// Safety margin on top of the measured RTT when choosing the scheduling lead — covers -/// jitter and the server answering pings on its tick grid. -const LEAD_MARGIN_MS: f32 = 50.0; - -/// RTT sampling interval; the measurement drives the scheduling lead. -const PING_INTERVAL_MS: usize = 1000; - -/// Upper bound on queued-but-unconfirmed steps — purely a prediction bound: window -/// addressing already caps what the server will hold (windows are consecutive and only -/// `sched_lead` ahead), while confirmations lag a full RTT behind, so on a slow link -/// several correct steps are legitimately in flight at once. Steps whose window the -/// acknowledgment cursor has passed expire automatically, so the path can never go -/// permanently stale. -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; -const VIEW_PX: i32 = VIEW_TILES * TILE_PX; - -/// Camera pan speed. The player walks 8 px per 167 ms ≈ 48 px/s; the camera is a bit -/// faster, so it trails during movement and settles right after the player stops. -const CAM_SPEED: f32 = 64.0; // px/s - -/// A position correction farther than this is a teleport — snap instead of panning. -const CAM_SNAP_PX: f32 = 96.0; - -/// Render-side smoothing state for one entity: the previously confirmed tile and how -/// long ago the current one was confirmed. Positions are tiles; rendering lerps between -/// them in pixels over one movement interval. -struct EntityLerp { - prev: (i32, i32), - cur: (i32, i32), - t_ms: usize, -} - -/// One sent-but-unacknowledged step: the movement window it is scheduled for and its -/// *delta* — deliberately not an absolute tile. The server executes deltas, so the -/// prediction is derived by replaying pending deltas on top of the last authoritative -/// position: a server surprise shifts the whole prediction instead of invalidating it. -/// The window is both the retraction address and the expiry key against the ack cursor. -struct PathStep { - window: u32, - delta: (i32, i32), -} - -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 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, - /// Planned route from click-to-move: tiles not yet sent to the server. Fed into - /// `path` one step per movement window. Keyboard input cancels it. - route: VecDeque<(i32, i32)>, - /// The persistent movement goal. Outlives the route: any surprise (blocked step, - /// server divergence) triggers a replan toward it, and while the mouse is held it - /// tracks the tile under the cursor. Cleared on arrival, unreachability, keyboard - /// override, or a click on an unreachable tile. - goal: Option<(i32, i32)>, - /// Tick clock: the last tick announced by a `StatePacket` and how long ago it - /// arrived — together an estimate of the server's current tick. - server_tick: u32, - tick_age_ms: usize, - /// Smoothed round-trip time from the automatic pings; `0` until the first pong. - rtt_ms: f32, - ping_accum_ms: usize, - /// Highest movement window a step was scheduled for. At most one step is scheduled - /// per window; retraction lowers this so freed windows can be re-addressed. - last_sched_window: u32, - /// Viewport top-left in world pixels. Follows the player linearly instead of - /// snapping tile-to-tile; only rendering rounds it to whole pixels. - cam: (f32, f32), - entities: Vec, - /// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game - /// logic keeps using the authoritative tile positions. - lerp: HashMap, -} - -impl Game { - pub fn start(server_addr: std::net::SocketAddr) -> Self { - let image = Image::from_tga("assets/tilesets/overworld.tga"); - let tileset = image.to_tileset(); - let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset(); - let player_pos = (16, 16); - Game { - tileset, - entity_tileset, - net: NetClient::new(server_addr), - player_entity_id: 0, - player_pos, - path: VecDeque::new(), - route: VecDeque::new(), - goal: None, - server_tick: 0, - tick_age_ms: 0, - rtt_ms: 0.0, - ping_accum_ms: PING_INTERVAL_MS, // first ping fires immediately - last_sched_window: 0, - cam: ( - (player_pos.0 * TILE_PX - VIEW_PX / 2) as f32, - (player_pos.1 * TILE_PX - VIEW_PX / 2) as f32, - ), - entities: Vec::new(), - lerp: HashMap::new(), - } - } - - /// 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 - { - self.tick_age_ms += dt; - - // Periodic RTT sampling — the measurement drives how far ahead steps are - // scheduled (`sched_lead`), so it has to stay current on a changing link. - self.ping_accum_ms += dt; - if self.ping_accum_ms >= PING_INTERVAL_MS { - self.ping_accum_ms = 0; - self.net.send_ping(); - } - - if input.mouse_clicked() || input.mouse_held() { - self.handle_click(input.mouse_pos(), !input.mouse_clicked()); - } - - self.step_movement(input); - - if input.button_pressed(GameAction::Confirm) { - self.net.send_ping(); - println!("ping sent"); - } - - for event in self.net.poll() { - match event { - NetEvent::Pong { rtt_ms } => { - self.rtt_ms = if self.rtt_ms == 0.0 { - rtt_ms as f32 - } else { - 0.8 * self.rtt_ms + 0.2 * rtt_ms as f32 - }; - } - NetEvent::State { tick, player_entity_id } => { - println!("state tick={tick} player_entity_id={player_entity_id}"); - self.player_entity_id = player_entity_id; - self.server_tick = tick; - self.tick_age_ms = 0; - } - NetEvent::Chunk { chunk_id } => println!("chunk cached id={chunk_id}"), - NetEvent::Entity { tick, entities } => { - self.player_pos = entities.iter() - .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(tick); - self.track_lerp(&entities); - self.entities = entities; - } - NetEvent::Disconnected => println!("disconnected from server"), - } - } - - if input.button_pressed(GameAction::Cancel) { - println!("Goodbye!"); - return Some(GameSignal::Quit); - } - - self.step_camera(dt); - self.step_lerp(dt); - self.render_viewport(render_frame); - None - } - - /// Replay one pending delta the way the server will: through the shared movement - /// rule. A delta the server is going to reject does not move the prediction either — - /// so the prediction can never sit inside a wall, however far position and pending - /// steps have diverged. - fn replay_step(&self, p: (i32, i32), delta: (i32, i32)) -> (i32, i32) { - let to = (p.0 + delta.0, p.1 + delta.1); - if step_allowed(p, to, |x, y| self.tile_blocked(x, y)) { to } else { p } - } - - /// The position movement continues from: the last authoritative position with every - /// pending delta replayed on top. Never stored — always derived, so it follows the - /// server automatically when a step was rejected or executed elsewhere. - fn predicted_pos(&self) -> (i32, i32) { - self.path.iter() - .fold(self.player_pos, |p, s| self.replay_step(p, s.delta)) - } - - /// Estimated current server tick: the last announced tick advanced by local elapsed - /// time. Broadcast latency makes this lag slightly behind the real server clock — - /// scheduling one window ahead plus the server's lateness rule absorbs that. - fn est_tick(&self) -> f32 { - self.server_tick as f32 + self.tick_age_ms as f32 / TICK_MS - } - - /// The movement window the estimated server time is currently in. - fn est_window(&self) -> u32 { - (self.est_tick() / TICKS_PER_MOVE as f32) as u32 - } - - /// How many windows ahead to schedule so actions arrive *before* their window. The - /// tick estimate lags by the downlink and the action spends the uplink in transit — - /// together one RTT — so the lead must cover the RTT plus a jitter margin. At least - /// one: never schedule into the current window. - fn sched_lead(&self) -> u32 { - (((self.rtt_ms + LEAD_MARGIN_MS) / WINDOW_MS).ceil() as u32).max(1) - } - - /// The newest window whose scheduled step can no longer be retracted in time: a - /// cancellation sent now needs the same lead an action does. - fn retract_horizon(&self) -> u32 { - self.est_window() + self.sched_lead() - 1 - } - - /// Where the camera wants to be: the viewport centered on the player's tile. - fn cam_target(&self) -> (f32, f32) { - ( - (self.player_pos.0 * TILE_PX - VIEW_PX / 2) as f32, - (self.player_pos.1 * TILE_PX - VIEW_PX / 2) as f32, - ) - } - - /// The camera rounded to the pixel grid — the actual top-left of the rendered view. - fn cam_px(&self) -> (i32, i32) { - (self.cam.0.round() as i32, self.cam.1.round() as i32) - } - - /// Follow the player linearly at `CAM_SPEED`, axis by axis. Corrections beyond - /// `CAM_SNAP_PX` (teleports, respawns) snap outright instead of panning across. - fn step_camera(&mut self, dt: usize) { - let (tx, ty) = self.cam_target(); - if (tx - self.cam.0).abs().max((ty - self.cam.1).abs()) > CAM_SNAP_PX { - self.cam = (tx, ty); - return; - } - let step = CAM_SPEED * dt as f32 / 1000.0; - let approach = |c: f32, t: f32| { - if (t - c).abs() <= step { t } else { c + step * (t - c).signum() } - }; - self.cam = (approach(self.cam.0, tx), approach(self.cam.1, ty)); - } - - /// Click-to-move: translate a framebuffer click into a world tile and adopt it as - /// the movement goal. The route is only a client-side plan — executed as ordinary - /// tick-addressed actions in `step_movement`, so the server keeps full authority. - /// With `hold` (button held after the initial click) this runs every frame and keeps - /// steering toward the tile under the cursor, replanning only when that tile changes - /// — camera movement alone shifts it too, not just moving the mouse. - fn handle_click(&mut self, (mx, my): (i32, i32), hold: bool) { - if mx < 0 || my < 0 || mx >= VIEW_PX || my >= VIEW_PX { - return; // outside the world viewport - } - let (cx, cy) = self.cam_px(); - let goal = ((cx + mx).div_euclid(TILE_PX), (cy + my).div_euclid(TILE_PX)); - - // While steering, an unchanged goal needs no replan — unless the route was voided - // (blocked step) before reaching it; an empty route with the goal still ahead of - // the prediction means exactly that, so plan again. - if hold && self.goal == Some(goal) - && (!self.route.is_empty() || self.predicted_pos() == goal) - { - return; - } - - // Plan from where the player will be once the retractable steps are withdrawn: - // the confirmed position plus the deltas that can no longer be cancelled in - // time — not the tip of a prediction we are about to cancel. - let horizon = self.retract_horizon(); - let start = self.path.iter() - .take_while(|s| s.window <= horizon) - .fold(self.player_pos, |p, s| self.replay_step(p, s.delta)); - - match pathfind::find_path(start, goal, |x, y| self.tile_blocked(x, y)) { - Some(steps) => { - // The plan changed: retract every scheduled-but-unexecuted step so the - // old intent cannot keep playing out on the server. - self.retract_future_steps(); - self.route = steps.into(); - self.goal = Some(goal); - } - // A discrete click on an unreachable tile cancels the plan; while steering, - // sweeping the cursor across a blocked tile keeps the current plan alive. - None if !hold => { - self.route.clear(); - self.goal = None; - } - None => {} - } - } - - /// Movement scheduling on the server timeline. A held direction key (which cancels - /// any goal) or the next planned route tile is addressed to the *next* movement - /// window — at most one step per window, so the send rate follows the server's - /// cadence by construction instead of racing it with a local timer. Steps are - /// validated locally with the shared movement rule; a blocked route step triggers - /// a replan toward the goal. - fn step_movement(&mut self, input: &InputState) { - // Both axes combine, so two held keys walk diagonally (a king move). - let kx = input.button_held(GameAction::Right) as i32 - - input.button_held(GameAction::Left) as i32; - let ky = input.button_held(GameAction::Down) as i32 - - input.button_held(GameAction::Up) as i32; - let dir = (kx != 0 || ky != 0).then_some((kx, ky)); - - if dir.is_some() { - // Manual input overrides click-to-move entirely, goal included. - self.route.clear(); - self.goal = None; - } - - let target = self.est_window() + self.sched_lead(); - let ready = target > self.last_sched_window && 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 - && ready - { - let allowed = step_allowed(from, (nx, ny), |x, y| self.tile_blocked(x, y)); - if allowed { - if dir.is_none() { - self.route.pop_front(); - } - self.path.push_back(PathStep { window: target, delta: (nx - from.0, ny - from.1) }); - self.net.send_action( - delta_action(nx - from.0, ny - from.1), - target * TICKS_PER_MOVE, - ); - self.last_sched_window = target; - } else if dir.is_none() { - self.replan_route(); - } - } - } - - /// Retract queued steps the server has (very likely) not executed yet: every step - /// addressed past the current estimated window gets a NOOP sent to its slot - /// (replacement = cancellation) and leaves the prediction. Steps at or before the - /// current window are left to be confirmed — cancelling those would race their - /// execution. Freed windows become addressable again. - fn retract_future_steps(&mut self) { - let horizon = self.retract_horizon(); - while let Some(step) = self.path.back() { - if step.window <= horizon { - break; - } - self.net.send_action(player_action::NOOP, step.window * TICKS_PER_MOVE); - self.path.pop_back(); - } - self.last_sched_window = self.path.back().map_or(horizon, |s| s.window); - } - - /// Replan the route from the current prediction toward the persistent goal — the - /// client-side reaction to any surprise (blocked step, server divergence). Gives - /// the goal up only when it is reached or has become unreachable. - fn replan_route(&mut self) { - self.route.clear(); - let Some(goal) = self.goal else { return }; - if self.predicted_pos() == goal { - self.goal = None; - return; - } - match pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y)) { - Some(steps) => self.route = steps.into(), - None => self.goal = None, - } - } - - /// Fold a fresh authoritative entity list into the interpolation table: a changed - /// position starts a new lerp from the old one, a jump of more than one tile - /// (Chebyshev — a teleport) snaps, and vanished entities are dropped. - fn track_lerp(&mut self, entities: &[EntityInfo]) { - for e in entities { - let cur = (e.pos_x as i32, e.pos_y as i32); - self.lerp.entry(e.id) - .and_modify(|l| { - if l.cur != cur { - let far = (cur.0 - l.cur.0).abs().max((cur.1 - l.cur.1).abs()) > 1; - l.prev = if far { cur } else { l.cur }; - l.cur = cur; - l.t_ms = 0; - } - }) - .or_insert(EntityLerp { prev: cur, cur, t_ms: 0 }); - } - self.lerp.retain(|id, _| entities.iter().any(|e| e.id == *id)); - } - - /// Advance all interpolation clocks; each lerp completes after one movement interval. - fn step_lerp(&mut self, dt: usize) { - for l in self.lerp.values_mut() { - l.t_ms = (l.t_ms + dt).min(MOVE_INTERVAL_MS); - } - } - - /// An entity's render position in world pixels: between its previous and current - /// tile, proportional to the time since the current one was confirmed. - fn entity_px(&self, e: &EntityInfo) -> (i32, i32) { - let cur = (e.pos_x as i32 * TILE_PX, e.pos_y as i32 * TILE_PX); - match self.lerp.get(&e.id) { - Some(l) => { - let f = l.t_ms as f32 / MOVE_INTERVAL_MS as f32; - let mix = |a: i32, b: i32| a + ((b - a) as f32 * f).round() as i32; - (mix(l.prev.0 * TILE_PX, cur.0), mix(l.prev.1 * TILE_PX, cur.1)) - } - None => cur, - } - } - - /// Fold a fresh authoritative snapshot into the prediction. The snapshot's tick is - /// an acknowledgment cursor on the shared timeline: every movement window at or - /// before it has been consumed — executed, rejected or lost, it no longer matters - /// which — so those steps expire, and the remaining deltas replay on top of the - /// confirmed position (`predicted_pos`). A server surprise thus *shifts* the - /// prediction instead of invalidating it; if the shifted route no longer connects - /// or misses the goal, send-time validation triggers a replan toward the goal. - fn reconcile_path(&mut self, tick: u32) { - let ack_window = tick / TICKS_PER_MOVE; - while self.path.front().is_some_and(|s| s.window <= ack_window) { - self.path.pop_front(); - } - if self.path.is_empty() && self.route.is_empty() && self.goal == Some(self.player_pos) { - self.goal = None; // arrived — a later server correction must not walk us back - } - } - - fn render_viewport(&self, frame: &mut [u8]) { - const WALL: u8 = 0x00; - const W: usize = 320; - - frame.fill(0); - - let (cx, cy) = self.cam_px(); - let tx0 = cx.div_euclid(TILE_PX); - let ty0 = cy.div_euclid(TILE_PX); - let sx = -cx.rem_euclid(TILE_PX); - let sy = -cy.rem_euclid(TILE_PX); - - // One extra row and column: with a sub-tile camera offset the viewport spans - // partial tiles on both edges. - for vy in 0..=VIEW_TILES { - for vx in 0..=VIEW_TILES { - let wx = tx0 + vx; - let wy = ty0 + vy; - let cid = chunk_id(wx.div_euclid(32) as i16, wy.div_euclid(32) as i16); - let tile_idx = wy.rem_euclid(32) as usize * 32 + wx.rem_euclid(32) as usize; - let tile_id = self.net.chunk_cache.get(&cid) - .map(|c| c.tiles[tile_idx]) - .unwrap_or(0); - let px = sx + vx * TILE_PX; - let py = sy + vy * TILE_PX; - if let Some(tile) = self.tileset.get(tile_id as usize) { - pixelhelper::blit_tile(frame, W, px, py, tile); - } else { - for dy in 0..8 { - for dx in 0..8 { - pixelhelper::set_pixel(frame, W, px + dx, py + dy, WALL); - } - } - } - } - } - - // Path pass — in-flight steps bright, planned-route tiles dim; over the world, - // under entities. - // Path tiles are derived by replaying the pending deltas from the confirmed - // position — the same fold the prediction uses (a rejected step marks in place). - let mut acc = self.player_pos; - let marks = self.path.iter() - .map(|s| { - acc = self.replay_step(acc, s.delta); - (acc, PATH_COLOR) - }) - .collect::>().into_iter() - .chain(self.route.iter().map(|&p| (p, ROUTE_COLOR))); - for ((wx, wy), color) in marks { - let px = wx * TILE_PX - cx; - let py = wy * TILE_PX - cy; - if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; } - for dy in 0..8 { - for dx in 0..8 { - pixelhelper::set_pixel(frame, W, px + dx, py + dy, color); - } - } - } - - // Entity pass — interpolated between the last two confirmed positions. - for e in &self.entities { - let (ex, ey) = self.entity_px(e); - let px = ex - cx; - let py = ey - cy; - if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; } - if let Some(tile) = self.entity_tileset.get(e.type_id as usize) { - pixelhelper::blit_tile(frame, W, px, py, tile); - } - } - - // The blit primitives clip against the framebuffer, not the viewport, so partial - // tiles on the right edge bleed into the 240..320 strip. Clear it; a real - // clip-rect belongs to the UI pass (see roadmap). - for y in 0..240usize { - frame[y * W + VIEW_PX as usize..y * W + W].fill(0); - } - } -} diff --git a/client/src/net.rs b/client/src/net.rs deleted file mode 100755 index b0aaef6..0000000 --- a/client/src/net.rs +++ /dev/null @@ -1,211 +0,0 @@ -use std::collections::HashMap; -use std::mem::size_of; -use std::net::{SocketAddr, UdpSocket}; -use std::time::{Duration, Instant}; -use bytemuck::bytes_of; -use shared::{ActionPacket, ChunkEntry, ChunkPacket, EntityPacket, - Header, PingPacket, PongPacket, StatePacket, - MAGIC, VERSION, packet_type}; - -pub struct CachedChunk { - pub version: u16, - pub tiles: Box<[u16; 1024]>, // decoded global tile IDs -} - -pub struct EntityInfo { - pub id: u32, - pub type_id: u16, - pub pos_x: i16, - pub pos_y: i16, - pub hp: u16, - pub hp_max: u16, - pub flags: u8, -} - -pub enum NetEvent { - Pong { rtt_ms: u64 }, - State { tick: u32, player_entity_id: u32 }, - Chunk { chunk_id: u32 }, - /// An authoritative entity snapshot taken at server tick `tick` — the tick doubles - /// as the acknowledgment cursor for tick-addressed actions: every movement window - /// at or before it has been consumed. - Entity { tick: u32, entities: Vec }, - Disconnected, -} - -const FNV_BASIS: u32 = 2_166_136_261; -const FNV_PRIME: u32 = 16_777_619; - -pub struct NetClient { - socket: UdpSocket, - ping_sent_at: Option, - last_action_sent: Instant, - last_state_received: Option, - pub chunk_cache: HashMap, - last_state_chunks: Option<[ChunkEntry; 9]>, - pending_entities: Vec, - last_entity_checksum: u32, - last_state_tick: u32, - entity_checksum_accum: u32, -} - -impl NetClient { - pub fn new(server_addr: SocketAddr) -> Self { - let socket = UdpSocket::bind("0.0.0.0:0").expect("bind failed"); - socket.connect(server_addr).expect("connect failed"); - socket.set_nonblocking(true).expect("set_nonblocking failed"); - Self { - socket, - ping_sent_at: None, - last_action_sent: Instant::now(), - last_state_received: None, - chunk_cache: HashMap::new(), - last_state_chunks: None, - pending_entities: Vec::new(), - last_entity_checksum: 0, - last_state_tick: 0, - entity_checksum_accum: FNV_BASIS, - } - } - - /// Send an action addressed to a server tick (see `ActionPacket::target_tick`). - /// `target_tick == 0` is a pure keep-alive / cache-ack with no scheduling intent. - pub fn send_action(&mut self, player_action: u16, target_tick: u32) { - let packet = ActionPacket { - header: Header::new(packet_type::ACTION), - auth_token: 0, - target_tick, - cache: { - let mut cache = [ChunkEntry { chunk_id: 0, version: 0 }; 9]; - if let Some(slots) = self.last_state_chunks { - for (i, slot) in slots.iter().enumerate() { - let cid = slot.chunk_id; - let ver = self.chunk_cache.get(&cid) - .map(|c| c.version).unwrap_or(0); - cache[i] = ChunkEntry { chunk_id: cid, version: ver }; - } - } - cache - }, - player_action, - }; - self.socket.send(bytes_of(&packet)).ok(); - self.last_action_sent = Instant::now(); - } - - pub fn send_ping(&mut self) { - let ping = PingPacket { header: Header::new(packet_type::PING), timestamp_ms: 0 }; - self.ping_sent_at = Some(Instant::now()); - self.socket.send(bytes_of(&ping)).ok(); - } - - pub fn poll(&mut self) -> Vec { - const KEEPALIVE: Duration = Duration::from_secs(2); - const TIMEOUT: Duration = Duration::from_secs(10); - - if self.last_action_sent.elapsed() >= KEEPALIVE { - self.send_action(shared::player_action::NOOP, 0); - } - - let mut events = Vec::new(); - let mut buf = [0u8; 1200]; - - loop { - match self.socket.recv(&mut buf) { - Ok(n) => { - if n < size_of::
() { continue; } - let header: Header = *bytemuck::from_bytes(&buf[..size_of::
()]); - if header.magic != MAGIC || header.version != VERSION { continue; } - - match header.packet_type { - packet_type::PONG if n >= size_of::() => { - if let Some(sent_at) = self.ping_sent_at.take() { - let rtt_ms = sent_at.elapsed().as_millis() as u64; - events.push(NetEvent::Pong { rtt_ms }); - } - } - packet_type::STATE if n >= size_of::() => { - let pkt: StatePacket = *bytemuck::from_bytes(&buf[..size_of::()]); - self.last_state_received = Some(Instant::now()); - self.last_state_chunks = Some(pkt.chunks); - self.last_entity_checksum = pkt.entity_checksum; - self.last_state_tick = pkt.tick; - self.pending_entities.clear(); - self.entity_checksum_accum = FNV_BASIS; - events.push(NetEvent::State { tick: pkt.tick, player_entity_id: pkt.player_entity_id }); - } - packet_type::ENTITY if n >= size_of::() => { - let pkt: EntityPacket = *bytemuck::from_bytes(&buf[..size_of::()]); - if pkt.tick != self.last_state_tick { - // stale datagram from a previous state cycle - } else { - for i in 0..pkt.entity_count as usize { - let e = pkt.entities[i]; - for byte in bytemuck::bytes_of(&e) { - self.entity_checksum_accum ^= *byte as u32; - self.entity_checksum_accum = - self.entity_checksum_accum.wrapping_mul(FNV_PRIME); - } - self.pending_entities.push(EntityInfo { - id: e.id, - type_id: e.type_id, - pos_x: e.pos_x, - pos_y: e.pos_y, - hp: e.hp, - hp_max: e.hp_max, - flags: e.entity_flags, - }); - } - if pkt.packet_flags & 1 == 0 { - // last datagram in sequence - if self.entity_checksum_accum != self.last_entity_checksum { - self.send_action(shared::player_action::NOOP, 0); - } - events.push(NetEvent::Entity { - tick: pkt.tick, - entities: std::mem::take(&mut self.pending_entities), - }); - } - } - } - packet_type::CHUNK if n >= size_of::() => { - let pkt: ChunkPacket = *bytemuck::from_bytes(&buf[..size_of::()]); - let id = pkt.chunk.chunk_id; - let version = pkt.chunk.version; - let palette = pkt.palette; - let raw = pkt.tiles; - let mut tiles = Box::new([0u16; 1024]); - for i in 0..256usize { - let b0 = raw[i * 3]; - let b1 = raw[i * 3 + 1]; - let b2 = raw[i * 3 + 2]; - let ia = (b0 & 0x3F) as usize; - let ib = ((b0 >> 6) | ((b1 & 0x0F) << 2)) as usize; - let ic = ((b1 >> 4) | ((b2 & 0x03) << 4)) as usize; - let id_ = (b2 >> 2) as usize; - tiles[i * 4] = palette[ia]; - tiles[i * 4 + 1] = palette[ib]; - tiles[i * 4 + 2] = palette[ic]; - tiles[i * 4 + 3] = palette[id_]; - } - self.chunk_cache.insert(id, CachedChunk { version, tiles }); - events.push(NetEvent::Chunk { chunk_id: id }); - } - _ => {} - } - } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, - Err(_) => break, - } - } - - if let Some(t) = self.last_state_received { - if t.elapsed() >= TIMEOUT { - self.last_state_received = None; - events.push(NetEvent::Disconnected); - } - } - - events - } -} diff --git a/client/Cargo.toml b/game/Cargo.toml similarity index 53% rename from client/Cargo.toml rename to game/Cargo.toml index 6d5b2bf..618001d 100755 --- a/client/Cargo.toml +++ b/game/Cargo.toml @@ -1,9 +1,8 @@ [package] -name = "client" +name = "game" version = "0.1.0" edition = "2024" [dependencies] -shared = { path = "../shared" } +sim = { path = "../sim" } pbio = { git = "https://git.bnd.wtf/irrlicht/rust-pbio.git" } -bytemuck = { version = "1.24.0", features = ["derive"] } diff --git a/client/src/assets.rs b/game/src/assets.rs similarity index 100% rename from client/src/assets.rs rename to game/src/assets.rs diff --git a/game/src/game.rs b/game/src/game.rs new file mode 100755 index 0000000..4c7add1 --- /dev/null +++ b/game/src/game.rs @@ -0,0 +1,385 @@ +mod pathfind; +mod pixelhelper; + +use std::collections::{HashMap, VecDeque}; + +use crate::assets::Image; +use crate::input::{GameAction, InputState}; +use sim::entity::Entity; +use sim::map::TileMap; +use sim::{delta_action, load_world, step_allowed, Sim, TICK_HZ, TICKS_PER_MOVE}; + +pub enum GameSignal { + Quit, +} + +/// Length of one base tick. The sim advances in whole ticks; rendering runs at frame +/// rate on top and accumulates frame time into ticks. +const TICK_MS: f32 = 1000.0 / TICK_HZ as f32; + +/// The movement cadence (movement on every 4th tick → 6 Hz ≈ 167 ms/tile). Entity +/// interpolation lerps over this interval. +const MOVE_INTERVAL_MS: usize = (TICK_MS * TICKS_PER_MOVE as f32) as usize; + +/// Longest frame the sim will catch up on in one go. Anything slower (debugger, window +/// drag) is dropped instead of spiralling into a burst of ticks. +const MAX_FRAME_MS: f32 = 250.0; + +/// Palette index for the planned route. Dim blue in RGB332 (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; +const VIEW_PX: i32 = VIEW_TILES * TILE_PX; + +/// Camera pan speed. The player walks 8 px per 167 ms ≈ 48 px/s; the camera is a bit +/// faster, so it trails during movement and settles right after the player stops. +const CAM_SPEED: f32 = 64.0; // px/s + +/// A position correction farther than this is a teleport — snap instead of panning. +const CAM_SNAP_PX: f32 = 96.0; + +/// Render-side smoothing state for one entity: the previous tile and how long ago the +/// current one was taken. Positions are tiles; rendering lerps between them in pixels +/// over one movement interval. +struct EntityLerp { + prev: (i32, i32), + cur: (i32, i32), + t_ms: usize, +} + +pub struct Game { + tileset: Vec<[u8; 64]>, + entity_tileset: Vec<[u8; 64]>, + sim: Sim, + player_id: u32, + /// Frame time not yet consumed by whole ticks. + tick_accum_ms: f32, + /// Planned route from click-to-move: tiles still to visit, fed into the sim one + /// step per movement window. Keyboard input cancels it. + route: VecDeque<(i32, i32)>, + /// The persistent movement goal. Outlives the route: a blocked step triggers a + /// replan toward it, and while the mouse is held it tracks the tile under the + /// cursor. Cleared on arrival, unreachability, keyboard override, or a click on an + /// unreachable tile. + goal: Option<(i32, i32)>, + /// Viewport top-left in world pixels. Follows the player linearly instead of + /// snapping tile-to-tile; only rendering rounds it to whole pixels. + cam: (f32, f32), + /// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game + /// logic keeps using the sim's tile positions. + lerp: HashMap, +} + +impl Game { + pub fn start() -> Self { + let tileset = Image::from_tga("assets/tilesets/overworld.tga").to_tileset(); + let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset(); + + let map = TileMap::from_csv("assets/map_test"); + println!("loaded map {}×{} ({} tiles)", map.width, map.height, map.tiles.len()); + let mut sim = Sim::new(load_world(&map)); + let player_id = sim.world.spawn_entity(0, (0, 0), 100); + + let mut game = Game { + tileset, + entity_tileset, + sim, + player_id, + tick_accum_ms: 0.0, + route: VecDeque::new(), + goal: None, + cam: (0.0, 0.0), + lerp: HashMap::new(), + }; + game.cam = game.cam_target(); + game.track_lerp(); + game + } + + fn player_pos(&self) -> (i32, i32) { + let p = self.sim.world.entities[&self.player_id].pos; + (p.0 as i32, p.1 as i32) + } + + /// True if the world tile at `(wx, wy)` blocks movement. Outside the loaded world + /// counts as blocked — the same rule the sim applies. + fn tile_blocked(&self, wx: i32, wy: i32) -> bool { + self.sim.world.tile_flags(wx as i16, wy as i16).map_or(true, |f| f.collidable()) + } + + pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState) + -> Option + { + if input.mouse_clicked() || input.mouse_held() { + self.handle_click(input.mouse_pos(), !input.mouse_clicked()); + } + + self.step_movement(input); + + // Advance the world in whole ticks. After every movement window the route is + // synced against the new position and the next step is scheduled right away, + // so a slow frame that spans several windows still walks every one of them. + self.tick_accum_ms += (dt as f32).min(MAX_FRAME_MS); + while self.tick_accum_ms >= TICK_MS { + self.tick_accum_ms -= TICK_MS; + if self.sim.step() { + self.track_lerp(); + self.sync_route(); + self.step_movement(input); + } + } + + if input.button_pressed(GameAction::Cancel) { + println!("Goodbye!"); + return Some(GameSignal::Quit); + } + + self.step_camera(dt); + self.step_lerp(dt); + self.render_viewport(render_frame); + None + } + + /// Where the camera wants to be: the viewport centered on the player's tile. + fn cam_target(&self) -> (f32, f32) { + let (px, py) = self.player_pos(); + ( + (px * TILE_PX - VIEW_PX / 2) as f32, + (py * TILE_PX - VIEW_PX / 2) as f32, + ) + } + + /// The camera rounded to the pixel grid — the actual top-left of the rendered view. + fn cam_px(&self) -> (i32, i32) { + (self.cam.0.round() as i32, self.cam.1.round() as i32) + } + + /// Follow the player linearly at `CAM_SPEED`, axis by axis. Corrections beyond + /// `CAM_SNAP_PX` (teleports, respawns) snap outright instead of panning across. + fn step_camera(&mut self, dt: usize) { + let (tx, ty) = self.cam_target(); + if (tx - self.cam.0).abs().max((ty - self.cam.1).abs()) > CAM_SNAP_PX { + self.cam = (tx, ty); + return; + } + let step = CAM_SPEED * dt as f32 / 1000.0; + let approach = |c: f32, t: f32| { + if (t - c).abs() <= step { t } else { c + step * (t - c).signum() } + }; + self.cam = (approach(self.cam.0, tx), approach(self.cam.1, ty)); + } + + /// Click-to-move: translate a framebuffer click into a world tile and adopt it as + /// the movement goal. With `hold` (button held after the initial click) this runs + /// every frame and keeps steering toward the tile under the cursor, replanning only + /// when that tile changes — camera movement alone shifts it too, not just moving + /// the mouse. + fn handle_click(&mut self, (mx, my): (i32, i32), hold: bool) { + if mx < 0 || my < 0 || mx >= VIEW_PX || my >= VIEW_PX { + return; // outside the world viewport + } + let (cx, cy) = self.cam_px(); + let goal = ((cx + mx).div_euclid(TILE_PX), (cy + my).div_euclid(TILE_PX)); + + // While steering, an unchanged goal needs no replan — unless the route was voided + // (blocked step) before reaching it; an empty route with the goal still ahead + // means exactly that, so plan again. + if hold && self.goal == Some(goal) + && (!self.route.is_empty() || self.player_pos() == goal) + { + return; + } + + match pathfind::find_path(self.player_pos(), goal, |x, y| self.tile_blocked(x, y)) { + Some(steps) => { + self.route = steps.into(); + self.goal = Some(goal); + } + // A discrete click on an unreachable tile cancels the plan; while steering, + // sweeping the cursor across a blocked tile keeps the current plan alive. + None if !hold => { + self.route.clear(); + self.goal = None; + self.sim.clear_action(self.player_id); + } + None => {} + } + } + + /// Schedule the player's next step: a held direction key (which cancels any goal) + /// or the head of the planned route becomes the intent for the next movement + /// window. The sim executes at most one intent per window, so holding a key walks + /// at the movement cadence by construction. Steps are validated with the shared + /// movement rule; a blocked route step triggers a replan toward the goal. + fn step_movement(&mut self, input: &InputState) { + // Both axes combine, so two held keys walk diagonally (a king move). A tap + // shorter than a frame shows up as pressed only. + let axis = |neg: GameAction, pos: GameAction| { + (input.button_held(pos) || input.button_pressed(pos)) as i32 + - (input.button_held(neg) || input.button_pressed(neg)) as i32 + }; + let kx = axis(GameAction::Left, GameAction::Right); + let ky = axis(GameAction::Up, GameAction::Down); + let dir = (kx != 0 || ky != 0).then_some((kx, ky)); + + if dir.is_some() { + // Manual input overrides click-to-move entirely, goal included. + self.route.clear(); + self.goal = None; + } + + let from = self.player_pos(); + let step = match dir { + Some((dx, dy)) => Some((from.0 + dx, from.1 + dy)), + None => self.route.front().copied(), + }; + let Some((nx, ny)) = step else { return }; + + if step_allowed(from, (nx, ny), |x, y| self.tile_blocked(x, y)) { + self.sim.set_action(self.player_id, delta_action(nx - from.0, ny - from.1)); + } else if dir.is_none() { + self.replan_route(); + } + } + + /// After a movement window: drop the route head once the player stands on it, and + /// release the goal on arrival. + fn sync_route(&mut self) { + let pos = self.player_pos(); + if self.route.front() == Some(&pos) { + self.route.pop_front(); + } + if self.route.is_empty() && self.goal == Some(pos) { + self.goal = None; + } + } + + /// Replan the route from the current position toward the persistent goal — the + /// reaction to a blocked step. Gives the goal up only when it is reached or has + /// become unreachable. + fn replan_route(&mut self) { + self.route.clear(); + let Some(goal) = self.goal else { return }; + let pos = self.player_pos(); + if pos == goal { + self.goal = None; + return; + } + match pathfind::find_path(pos, goal, |x, y| self.tile_blocked(x, y)) { + Some(steps) => self.route = steps.into(), + None => self.goal = None, + } + } + + /// Fold the sim's entity positions into the interpolation table: a changed position + /// starts a new lerp from the old one, a jump of more than one tile (Chebyshev — a + /// teleport) snaps, and vanished entities are dropped. + fn track_lerp(&mut self) { + let entities = &self.sim.world.entities; + for e in entities.values() { + let cur = (e.pos.0 as i32, e.pos.1 as i32); + self.lerp.entry(e.id) + .and_modify(|l| { + if l.cur != cur { + let far = (cur.0 - l.cur.0).abs().max((cur.1 - l.cur.1).abs()) > 1; + l.prev = if far { cur } else { l.cur }; + l.cur = cur; + l.t_ms = 0; + } + }) + .or_insert(EntityLerp { prev: cur, cur, t_ms: 0 }); + } + self.lerp.retain(|id, _| entities.contains_key(id)); + } + + /// Advance all interpolation clocks; each lerp completes after one movement interval. + fn step_lerp(&mut self, dt: usize) { + for l in self.lerp.values_mut() { + l.t_ms = (l.t_ms + dt).min(MOVE_INTERVAL_MS); + } + } + + /// An entity's render position in world pixels: between its previous and current + /// tile, proportional to the time since the current one was taken. + fn entity_px(&self, e: &Entity) -> (i32, i32) { + let cur = (e.pos.0 as i32 * TILE_PX, e.pos.1 as i32 * TILE_PX); + match self.lerp.get(&e.id) { + Some(l) => { + let f = l.t_ms as f32 / MOVE_INTERVAL_MS as f32; + let mix = |a: i32, b: i32| a + ((b - a) as f32 * f).round() as i32; + (mix(l.prev.0 * TILE_PX, cur.0), mix(l.prev.1 * TILE_PX, cur.1)) + } + None => cur, + } + } + + fn render_viewport(&self, frame: &mut [u8]) { + const WALL: u8 = 0x00; + const W: usize = 320; + + frame.fill(0); + + let (cx, cy) = self.cam_px(); + let tx0 = cx.div_euclid(TILE_PX); + let ty0 = cy.div_euclid(TILE_PX); + let sx = -cx.rem_euclid(TILE_PX); + let sy = -cy.rem_euclid(TILE_PX); + + // One extra row and column: with a sub-tile camera offset the viewport spans + // partial tiles on both edges. + for vy in 0..=VIEW_TILES { + for vx in 0..=VIEW_TILES { + let wx = tx0 + vx; + let wy = ty0 + vy; + let (ccx, ccy) = sim::world::tile_to_chunk(wx as i16, wy as i16); + let (lx, ly) = sim::world::tile_to_local(wx as i16, wy as i16); + let tile_id = self.sim.world.chunk(ccx, ccy) + .map(|c| c.get_tile(lx, ly).tile_id) + .unwrap_or(0); + let px = sx + vx * TILE_PX; + let py = sy + vy * TILE_PX; + if let Some(tile) = self.tileset.get(tile_id as usize) { + pixelhelper::blit_tile(frame, W, px, py, tile); + } else { + for dy in 0..8 { + for dx in 0..8 { + pixelhelper::set_pixel(frame, W, px + dx, py + dy, WALL); + } + } + } + } + } + + // Route pass — over the world, under entities. + for &(wx, wy) in &self.route { + let px = wx * TILE_PX - cx; + let py = wy * TILE_PX - cy; + if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; } + for dy in 0..8 { + for dx in 0..8 { + pixelhelper::set_pixel(frame, W, px + dx, py + dy, ROUTE_COLOR); + } + } + } + + // Entity pass — interpolated between the last two tiles. + for e in self.sim.world.entities.values() { + let (ex, ey) = self.entity_px(e); + let px = ex - cx; + let py = ey - cy; + if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; } + if let Some(tile) = self.entity_tileset.get(e.type_id as usize) { + pixelhelper::blit_tile(frame, W, px, py, tile); + } + } + + // The blit primitives clip against the framebuffer, not the viewport, so partial + // tiles on the right edge bleed into the 240..320 strip. Clear it; a real + // clip-rect belongs to the UI pass (see roadmap). + for y in 0..240usize { + frame[y * W + VIEW_PX as usize..y * W + W].fill(0); + } + } +} diff --git a/client/src/game/pathfind.rs b/game/src/game/pathfind.rs similarity index 97% rename from client/src/game/pathfind.rs rename to game/src/game/pathfind.rs index 974b934..17cd0e5 100644 --- a/client/src/game/pathfind.rs +++ b/game/src/game/pathfind.rs @@ -75,7 +75,7 @@ pub fn find_path( (1, -1), (1, 1), (-1, 1), (-1, -1), ] { let next = (cur.0 + dx, cur.1 + dy); - if !shared::step_allowed(cur, next, &blocked) { + if !sim::step_allowed(cur, next, &blocked) { continue; } let ng = g + if dx != 0 && dy != 0 { DIAGONAL_COST } else { CARDINAL_COST }; @@ -96,7 +96,7 @@ mod tests { fn assert_valid(start: (i32, i32), path: &[(i32, i32)], blocked: impl Fn(i32, i32) -> bool) { let mut from = start; for &to in path { - assert!(shared::step_allowed(from, to, &blocked), "illegal step {from:?} → {to:?}"); + assert!(sim::step_allowed(from, to, &blocked), "illegal step {from:?} → {to:?}"); from = to; } } diff --git a/client/src/game/pixelhelper.rs b/game/src/game/pixelhelper.rs similarity index 100% rename from client/src/game/pixelhelper.rs rename to game/src/game/pixelhelper.rs diff --git a/client/src/input.rs b/game/src/input.rs similarity index 100% rename from client/src/input.rs rename to game/src/input.rs diff --git a/client/src/main.rs b/game/src/main.rs similarity index 86% rename from client/src/main.rs rename to game/src/main.rs index a69787a..c2d8af8 100755 --- a/client/src/main.rs +++ b/game/src/main.rs @@ -4,11 +4,10 @@ use std::time::{Duration, Instant}; mod assets; mod game; mod input; -mod net; fn main() { let mut plat = Platform::new(PlatformConfig { - title: "Roguelike".into(), + title: "Forgotten Caves".into(), window_size: (800, 600), framebuffer_size: (320, 240), aspect_ratio: Some(4.0 / 3.0), @@ -21,12 +20,7 @@ fn main() { let input_map = input::InputMap::new(); let mut input_state = input::InputState::new(); let mut last_update = Instant::now(); - // Optional server address (e.g. netsim proxy): `client 127.0.0.1:7778`. - let server_addr = std::env::args().nth(1) - .unwrap_or_else(|| "127.0.0.1:7777".into()) - .parse() - .expect("bad server address"); - let mut game = game::Game::start(server_addr); + let mut game = game::Game::start(); while !plat.should_close() { plat.poll_events(Some(Duration::from_millis(20))); diff --git a/netsim/Cargo.toml b/netsim/Cargo.toml deleted file mode 100644 index dbaccbb..0000000 --- a/netsim/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "netsim" -version = "0.1.0" -edition = "2024" - -[dependencies] diff --git a/netsim/src/main.rs b/netsim/src/main.rs deleted file mode 100644 index 3bf3709..0000000 --- a/netsim/src/main.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Bad-internet simulator: a UDP proxy between game client and server that adds -//! delay, jitter and packet loss, per direction. Dev tool only — the game itself -//! never knows it exists. -//! -//! Usage: -//! cargo run -p netsim -- [--listen 7778] [--upstream 127.0.0.1:7777] -//! [--delay MS] [--jitter MS] [--loss PERCENT] -//! [--up-delay MS] [--up-jitter MS] [--up-loss PERCENT] -//! [--down-delay MS] [--down-jitter MS] [--down-loss PERCENT] -//! -//! `--delay/--jitter/--loss` set both directions; the `--up-*` (client → server) and -//! `--down-*` (server → client) variants override one direction. Jitter is uniform in -//! ±MS around the delay; reordering emerges from jitter naturally, as on a real link. -//! Point the client at the listen port (e.g. `client 127.0.0.1:7778`). - -use std::cmp::Reverse; -use std::collections::BinaryHeap; -use std::net::{SocketAddr, UdpSocket}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -#[derive(Clone, Copy)] -struct LinkParams { - delay_ms: f32, - jitter_ms: f32, - loss_pct: f32, -} - -/// Xorshift64* — plenty for impairment dice; avoids pulling in a rand dependency. -struct Rng(u64); - -impl Rng { - fn new() -> Self { - let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64; - Rng(seed | 1) - } - - fn next(&mut self) -> u64 { - let mut x = self.0; - x ^= x >> 12; - x ^= x << 25; - x ^= x >> 27; - self.0 = x; - x.wrapping_mul(0x2545_F491_4F6C_DD1D) - } - - /// Uniform in [0, 1). - fn unit(&mut self) -> f32 { - (self.next() >> 40) as f32 / (1u64 << 24) as f32 - } -} - -impl LinkParams { - /// Roll the dice for one datagram: `None` = lost, `Some(d)` = deliver after `d`. - fn impair(&self, rng: &mut Rng) -> Option { - if rng.unit() * 100.0 < self.loss_pct { - return None; - } - let jitter = (rng.unit() * 2.0 - 1.0) * self.jitter_ms; - Some(Duration::from_secs_f32((self.delay_ms + jitter).max(0.0) / 1000.0)) - } -} - -/// A datagram waiting for its delivery time. `Up` = client → server. -enum Dir { - Up, - Down, -} - -struct Pending { - due: Instant, - dir: Dir, - data: Vec, -} - -// BinaryHeap ordering: only `due` matters (earliest first via Reverse). -impl PartialEq for Pending { - fn eq(&self, other: &Self) -> bool { self.due == other.due } -} -impl Eq for Pending {} -impl PartialOrd for Pending { - fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } -} -impl Ord for Pending { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.due.cmp(&other.due) } -} - -fn parse_args() -> (u16, SocketAddr, LinkParams, LinkParams) { - let mut listen: u16 = 7778; - let mut upstream: SocketAddr = "127.0.0.1:7777".parse().unwrap(); - let mut both = LinkParams { delay_ms: 0.0, jitter_ms: 0.0, loss_pct: 0.0 }; - let mut up_over = [None::; 3]; // delay, jitter, loss - let mut down_over = [None::; 3]; - - let mut args = std::env::args().skip(1); - while let Some(flag) = args.next() { - let val = args.next().unwrap_or_else(|| panic!("missing value for {flag}")); - let num = || val.parse::().unwrap_or_else(|_| panic!("bad number for {flag}: {val}")); - match flag.as_str() { - "--listen" => listen = val.parse().expect("bad listen port"), - "--upstream" => upstream = val.parse().expect("bad upstream address"), - "--delay" => both.delay_ms = num(), - "--jitter" => both.jitter_ms = num(), - "--loss" => both.loss_pct = num(), - "--up-delay" => up_over[0] = Some(num()), - "--up-jitter" => up_over[1] = Some(num()), - "--up-loss" => up_over[2] = Some(num()), - "--down-delay" => down_over[0] = Some(num()), - "--down-jitter" => down_over[1] = Some(num()), - "--down-loss" => down_over[2] = Some(num()), - _ => panic!("unknown flag {flag}"), - } - } - - let apply = |over: [Option; 3]| LinkParams { - delay_ms: over[0].unwrap_or(both.delay_ms), - jitter_ms: over[1].unwrap_or(both.jitter_ms), - loss_pct: over[2].unwrap_or(both.loss_pct), - }; - (listen, upstream, apply(up_over), apply(down_over)) -} - -fn main() { - let (listen, upstream, up, down) = parse_args(); - - let client_sock = UdpSocket::bind(("127.0.0.1", listen)).expect("bind listen port"); - let server_sock = UdpSocket::bind("127.0.0.1:0").expect("bind upstream socket"); - server_sock.connect(upstream).expect("connect upstream"); - client_sock.set_nonblocking(true).unwrap(); - server_sock.set_nonblocking(true).unwrap(); - - println!("netsim: 127.0.0.1:{listen} ⇄ {upstream}"); - println!(" up: {:.0} ms ±{:.0} ms, {:.1}% loss", up.delay_ms, up.jitter_ms, up.loss_pct); - println!(" down: {:.0} ms ±{:.0} ms, {:.1}% loss", down.delay_ms, down.jitter_ms, down.loss_pct); - - let mut rng = Rng::new(); - let mut queue: BinaryHeap> = BinaryHeap::new(); - // The one game client, learned from its first datagram (re-learned on address change). - let mut client_addr: Option = None; - let mut buf = [0u8; 2048]; - - loop { - let now = Instant::now(); - - while let Ok((n, from)) = client_sock.recv_from(&mut buf) { - client_addr = Some(from); - if let Some(d) = up.impair(&mut rng) { - queue.push(Reverse(Pending { due: now + d, dir: Dir::Up, data: buf[..n].to_vec() })); - } - } - while let Ok(n) = server_sock.recv(&mut buf) { - if let Some(d) = down.impair(&mut rng) { - queue.push(Reverse(Pending { due: now + d, dir: Dir::Down, data: buf[..n].to_vec() })); - } - } - - while queue.peek().is_some_and(|Reverse(p)| p.due <= now) { - let Reverse(p) = queue.pop().unwrap(); - match p.dir { - Dir::Up => { server_sock.send(&p.data).ok(); } - Dir::Down => { - if let Some(addr) = client_addr { - client_sock.send_to(&p.data, addr).ok(); - } - } - } - } - - std::thread::sleep(Duration::from_millis(1)); - } -} diff --git a/server/Cargo.toml b/server/Cargo.toml deleted file mode 100755 index a4d3ee9..0000000 --- a/server/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "server" -version = "0.1.0" -edition = "2024" - -[dependencies] -shared = { path = "../shared" } -bytemuck = "1.24.0" -sim = { path = "../sim" } diff --git a/server/src/main.rs b/server/src/main.rs deleted file mode 100755 index f005542..0000000 --- a/server/src/main.rs +++ /dev/null @@ -1,40 +0,0 @@ -mod net; - -use std::thread; -use std::time::{Duration, Instant}; - -use sim::map::TileMap; -use sim::{load_world, Sim}; -use net::Net; - -const TICK_HZ: u32 = 24; -const TICK_DURATION: Duration = Duration::from_micros(1_000_000 / TICK_HZ as u64); - -fn main() { - let map = TileMap::from_csv("assets/map_test"); - println!("loaded map {}×{} ({} tiles)", map.width, map.height, map.tiles.len()); - let world = load_world(&map); - let mut sim = Sim::new(world); - - let mut net = Net::new("127.0.0.1:7777").expect("failed to bind UDP socket"); - - let mut next_tick = Instant::now() + TICK_DURATION; - let mut tick = 0u32; - loop { - net.drain_recv(); - net.evict_stale(); - - for addr in net.drain_new_clients() { - let entity_id = sim.world.spawn_entity(0, (0, 0), 100); - net.add_client(addr, entity_id); - println!("client connected: {addr} → entity {entity_id}"); - } - - let actions = net.drain_actions(); - sim.tick(tick, &actions); - net.broadcast(&sim.world, tick); - thread::sleep(next_tick.saturating_duration_since(Instant::now())); - next_tick += TICK_DURATION; - tick = tick.wrapping_add(1); - } -} diff --git a/server/src/net.rs b/server/src/net.rs deleted file mode 100755 index 2547d4a..0000000 --- a/server/src/net.rs +++ /dev/null @@ -1,256 +0,0 @@ -use std::collections::{HashMap, VecDeque}; -use std::mem::size_of; -use std::net::{SocketAddr, UdpSocket}; -use std::sync::mpsc::{self, Receiver, SyncSender}; -use std::thread; -use std::time::Instant; - -use bytemuck::bytes_of; -use shared::{ - ActionPacket, ChunkEntry, ChunkPacket, EntityEntry, EntityPacket, - Header, PingPacket, PongPacket, StatePacket, - MAGIC, VERSION, packet_type, -}; - -use sim::world::World; - -pub struct NetClient { - pub entity_id: u32, - pub last_seen: Instant, - pub send_phase: u8, - /// Tick-addressed actions received since the last drain, in arrival order — - /// `(target_tick, action)`. Window resolution, replacement and the lateness rule - /// all live in the sim; this is only the per-tick hand-off buffer, bounded so a - /// flood between drains cannot grow it. - pub pending_actions: VecDeque<(u32, u16)>, - pub last_cache: [ChunkEntry; 9], -} - -/// Hand-off buffer bound. Drained every tick; an honest client sends at most one -/// action per movement window, so a handful is already generous. -const PENDING_ACTIONS_MAX: usize = 8; - -pub struct Net { - clients: HashMap, - new_addrs: Vec, - recv_rx: Receiver<(SocketAddr, Vec)>, - send_tx: SyncSender<(SocketAddr, Vec)>, -} - -impl Net { - pub fn new(addr: &str) -> std::io::Result { - let socket = UdpSocket::bind(addr)?; - - let (recv_tx, recv_rx) = mpsc::sync_channel::<(SocketAddr, Vec)>(256); - let (send_tx, send_rx) = mpsc::sync_channel::<(SocketAddr, Vec)>(256); - - let recv_sock = socket.try_clone()?; - thread::spawn(move || { - let mut buf = [0u8; 1200]; - loop { - match recv_sock.recv_from(&mut buf) { - Ok((n, addr)) => { - if recv_tx.send((addr, buf[..n].to_vec())).is_err() { - break; - } - } - Err(_) => break, - } - } - }); - - let send_sock = socket.try_clone()?; - thread::spawn(move || { - loop { - match send_rx.recv() { - Ok((addr, data)) => { let _ = send_sock.send_to(&data, addr); } - Err(_) => break, - } - } - }); - - // socket is dropped here; send/recv threads own their copies - drop(socket); - - Ok(Net { clients: HashMap::new(), new_addrs: Vec::new(), recv_rx, send_tx }) - } - - pub fn add_client(&mut self, addr: SocketAddr, entity_id: u32) { - self.clients.insert(addr, NetClient { - entity_id, - last_seen: Instant::now(), - send_phase: (entity_id % 2) as u8, - pending_actions: VecDeque::new(), - last_cache: [ChunkEntry { chunk_id: 0, version: 0 }; 9], - }); - } - - pub fn drain_recv(&mut self) { - loop { - match self.recv_rx.try_recv() { - Ok((addr, data)) => self.handle_packet(addr, data), - Err(_) => break, - } - } - } - - fn handle_packet(&mut self, addr: SocketAddr, data: Vec) { - if data.len() < size_of::
() { - return; - } - let header: Header = bytemuck::pod_read_unaligned(&data[..size_of::
()]); - if header.magic != MAGIC || header.version != VERSION { - return; - } - - if header.packet_type == packet_type::ACTION && data.len() == size_of::() { - let pkt: ActionPacket = bytemuck::pod_read_unaligned(&data); - if let Some(client) = self.clients.get_mut(&addr) { - client.last_seen = Instant::now(); - client.last_cache = pkt.cache; - // `target_tick == 0` marks pure keep-alive / cache-ack packets — they - // must never enter the schedule. Everything else (including NOOPs, which - // *retract* the addressed window) is handed to the sim in arrival order. - let target_tick = pkt.target_tick; - if target_tick != 0 && client.pending_actions.len() < PENDING_ACTIONS_MAX { - client.pending_actions.push_back((target_tick, pkt.player_action)); - } - } else if !self.new_addrs.contains(&addr) { - self.new_addrs.push(addr); - } - } else if header.packet_type == packet_type::PING && data.len() == size_of::() { - let pkt: PingPacket = bytemuck::pod_read_unaligned(&data); - let pong = PongPacket { - header: Header::new(packet_type::PONG), - timestamp_ms: pkt.timestamp_ms, - }; - let _ = self.send_tx.send((addr, bytes_of(&pong).to_vec())); - } - } - - pub fn drain_new_clients(&mut self) -> Vec { - std::mem::take(&mut self.new_addrs) - } - - pub fn evict_stale(&mut self) { - self.clients.retain(|_, c| c.last_seen.elapsed().as_secs() < 10); - } - - pub fn drain_actions(&mut self) -> Vec<(u32, u32, u16)> { - self.clients.values_mut() - .flat_map(|c| { - let entity_id = c.entity_id; - c.pending_actions.drain(..) - .map(move |(target_tick, action)| (entity_id, target_tick, action)) - }) - .collect() - } - - pub fn broadcast(&self, world: &World, tick: u32) { - for (addr, client) in &self.clients { - if tick % 2 != client.send_phase as u32 { continue; } - - // Step 1 — resolve player position - let Some(entity) = world.entities.get(&client.entity_id) else { continue }; - let pos = entity.pos; - let (player_cx, player_cy) = sim::world::tile_to_chunk(pos.0, pos.1); - - // Step 2 — build 9-slot chunk manifest - let mut chunks = [ChunkEntry { chunk_id: 0, version: 0 }; 9]; - for dy in -1i16..=1 { - for dx in -1i16..=1 { - let slot = ((dy + 1) * 3 + (dx + 1)) as usize; - let cx = player_cx + dx; - let cy = player_cy + dy; - chunks[slot] = ChunkEntry { - chunk_id: shared::chunk_id(cx, cy), - version: world.chunk(cx, cy).map_or(0, |c| c.version), - }; - } - } - - // Step 3 — build EntityEntry list + checksum - let viewport_entities = world.entities_in_viewport(pos); - let mut entries: Vec = viewport_entities.iter().map(|e| EntityEntry { - id: e.id, - type_id: e.type_id, - pos_x: e.pos.0, - pos_y: e.pos.1, - hp: e.hp, - hp_max: e.hp_max, - elo: 0, - entity_flags: 0, - meta_len: 0, - }).collect(); - if entries.len() > 66 { - entries.truncate(66); // TODO: multi-datagram - } - let checksum = fnv1a_entities(&entries); - - // Step 4 — send StatePacket (72 bytes) - let state = StatePacket { - header: Header::new(packet_type::STATE), - tick, - chunks, - player_entity_id: client.entity_id, - entity_checksum: checksum, - }; - let _ = self.send_tx.send((*addr, bytes_of(&state).to_vec())); - - // Step 5 — send EntityPacket (1200 bytes) - let mut pkt = ::zeroed(); - pkt.header = Header::new(packet_type::ENTITY); - pkt.tick = tick; - pkt.entity_count = entries.len() as u8; - pkt.packet_flags = 0; // no more-flag; single datagram - for (i, e) in entries.iter().enumerate() { pkt.entities[i] = *e; } - let _ = self.send_tx.send((*addr, bytes_of(&pkt).to_vec())); - - // Step 6 — send ChunkPackets for cache misses - for slot in 0..9 { - let current = chunks[slot]; - let cached = client.last_cache[slot]; - let miss = cached.chunk_id != current.chunk_id || cached.version != current.version; - if miss { - let (cx, cy) = shared::chunk_coords(current.chunk_id); - if let Some(chunk) = world.chunk(cx, cy) { - let mut cpkt = ::zeroed(); - cpkt.header = Header::new(packet_type::CHUNK); - cpkt.chunk = current; - cpkt.pal_count = chunk.pal_count; - for i in 0..64 { cpkt.palette[i] = chunk.palette[i].tile_id; } - cpkt.tiles = pack_tiles(&chunk.tiles); - let _ = self.send_tx.send((*addr, bytes_of(&cpkt).to_vec())); - } - } - } - } - } -} - -fn fnv1a_entities(entries: &[EntityEntry]) -> u32 { - const FNV_BASIS: u32 = 2_166_136_261; - const FNV_PRIME: u32 = 16_777_619; - let mut h = FNV_BASIS; - for e in entries { - for &byte in bytes_of(e) { - h ^= byte as u32; - h = h.wrapping_mul(FNV_PRIME); - } - } - h -} - -fn pack_tiles(tiles: &[u8; 1024]) -> [u8; 768] { - let mut raw = [0u8; 768]; - for i in 0..256 { - let ia = tiles[i*4] & 0x3F; - let ib = tiles[i*4+1] & 0x3F; - let ic = tiles[i*4+2] & 0x3F; - let id = tiles[i*4+3] & 0x3F; - raw[i*3] = ia | (ib << 6); - raw[i*3+1] = (ib >> 2) | (ic << 4); - raw[i*3+2] = (ic >> 4) | (id << 2); - } - raw -} diff --git a/shared/Cargo.toml b/shared/Cargo.toml deleted file mode 100755 index e3f8bb2..0000000 --- a/shared/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "shared" -version = "0.1.0" -edition = "2024" - -[dependencies] -bytemuck = { version = "1.24.0", features = ["derive", "min_const_generics"] } -sim = { path = "../sim" } diff --git a/shared/src/lib.rs b/shared/src/lib.rs deleted file mode 100755 index 3f73fde..0000000 --- a/shared/src/lib.rs +++ /dev/null @@ -1,139 +0,0 @@ -use bytemuck::{Pod, Zeroable}; - -// Game rules and tick constants live in the `sim` crate; re-exported here so the -// wire layer and both binaries share one vocabulary. -pub use sim::{player_action, action_delta, delta_action, step_allowed, tile_collidable, - TICK_HZ, TICKS_PER_MOVE, ACTION_WINDOW_HORIZON}; - -pub const MAGIC: u16 = 0x524C; -pub const VERSION: u8 = 1; - -pub mod packet_type { - pub const STATE: u8 = 0; - pub const ACTION: u8 = 1; - pub const CHUNK: u8 = 2; - pub const ENTITY: u8 = 3; - pub const ENTITY_QUERY: u8 = 4; - pub const ENTITY_DETAIL: u8 = 5; - pub const PING: u8 = 6; - pub const PONG: u8 = 7; -} - -/// Encodes signed chunk grid coordinates into the wire chunk_id format. -pub fn chunk_id(cx: i16, cy: i16) -> u32 { - (cx as u16 as u32) | ((cy as u16 as u32) << 16) -} - -/// Decodes a wire chunk_id back to signed chunk grid coordinates. -pub fn chunk_coords(id: u32) -> (i16, i16) { - (id as u16 as i16, (id >> 16) as u16 as i16) -} - -#[derive(Clone, Copy, Pod, Zeroable)] -#[repr(C, packed)] -pub struct Header { - pub magic: u16, - pub version: u8, - pub packet_type: u8, - pub client_type: u16, -} - -impl Header { - pub fn new(packet_type: u8) -> Self { - Self { magic: MAGIC, version: VERSION, packet_type, client_type: 0 } - } -} - -#[derive(Clone, Copy, Pod, Zeroable)] -#[repr(C, packed)] -pub struct ChunkEntry { - pub chunk_id: u32, - pub version: u16, -} - -#[derive(Clone, Copy, Pod, Zeroable)] -#[repr(C, packed)] -pub struct ActionPacket { - pub header: Header, - pub auth_token: u64, - /// The server tick this action is scheduled for: it executes in the movement window - /// covering that tick, or — if it arrives late — in the next window *if that slot is - /// still empty* (late actions fill gaps, they never override newer intent). A second - /// action addressed to the same window replaces the first, so a scheduled step can be - /// retracted (NOOP) or changed until its window executes. `0` means "no scheduling - /// intent": pure keep-alive / cache-ack packets that must never touch the queue. - pub target_tick: u32, - /// Slot index = (dy+1)*3 + (dx+1), dx/dy ∈ {-1, 0, 1}. - /// Slot 4 is always the player's current chunk. - pub cache: [ChunkEntry; 9], - pub player_action: u16, -} - -const _: () = assert!(std::mem::size_of::() == 6); -const _: () = assert!(std::mem::size_of::() == 74); - -#[derive(Clone, Copy, Pod, Zeroable)] -#[repr(C, packed)] -pub struct StatePacket { - pub header: Header, // 6 (offset 0) - pub tick: u32, // 4 (offset 6) - /// Slot index = (dy+1)*3 + (dx+1), dx/dy ∈ {-1, 0, 1}. - /// Slot 4 is always the player's current chunk. - pub chunks: [ChunkEntry; 9], // 54 (offset 10) - pub player_entity_id: u32, // 4 (offset 64) - pub entity_checksum: u32, // 4 (offset 68) -} // total: 72 bytes - -const _: () = assert!(std::mem::size_of::() == 72); - -#[derive(Clone, Copy, Pod, Zeroable)] -#[repr(C, packed)] -pub struct PingPacket { - pub header: Header, - pub timestamp_ms: u64, -} - -#[derive(Clone, Copy, Pod, Zeroable)] -#[repr(C, packed)] -pub struct PongPacket { - pub header: Header, - pub timestamp_ms: u64, -} - -#[derive(Clone, Copy, Pod, Zeroable)] -#[repr(C, packed)] -pub struct ChunkPacket { - pub header: Header, // 6 bytes - pub chunk: ChunkEntry, // 6 bytes - pub pal_count: u8, // 1 byte - pub palette: [u16; 64], // 128 bytes - pub tiles: [u8; 768], // 768 bytes — 1024 tiles, 6-bit packed -} // total: 909 bytes - -const _: () = assert!(std::mem::size_of::() == 909); - -#[repr(C, packed)] -#[derive(Clone, Copy, Pod, Zeroable)] -pub struct EntityEntry { - pub id: u32, - pub type_id: u16, - pub pos_x: i16, - pub pos_y: i16, - pub hp: u16, - pub hp_max: u16, - pub elo: u16, - pub entity_flags: u8, - pub meta_len: u8, -} -const _: () = assert!(std::mem::size_of::() == 18); - -#[repr(C, packed)] -#[derive(Clone, Copy, Pod, Zeroable)] -pub struct EntityPacket { - pub header: Header, - pub tick: u32, - pub entity_count: u8, - pub packet_flags: u8, - pub entities: [EntityEntry; 66], -} -const _: () = assert!(std::mem::size_of::() == 1200); diff --git a/sim/src/lib.rs b/sim/src/lib.rs index 5d5f8fb..4fd7bf2 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -85,10 +85,6 @@ pub const TICK_HZ: u32 = 24; /// `TICKS_PER_MOVE` ticks (6 Hz). Window `w` executes at tick `w * TICKS_PER_MOVE`. pub const TICKS_PER_MOVE: u32 = 4; -/// How many *future* movement windows an actor may address. Actions targeted beyond -/// this horizon are dropped. -pub const ACTION_WINDOW_HORIZON: usize = 3; - /// Movement vocabulary: whether a tile id blocks movement. Keep this in lockstep with /// the tileset in `overworld.tga`. /// diff --git a/sim/src/sim.rs b/sim/src/sim.rs index 09ccb15..dabb25d 100755 --- a/sim/src/sim.rs +++ b/sim/src/sim.rs @@ -1,70 +1,52 @@ -use std::collections::{BTreeMap, HashMap}; -use crate::{ACTION_WINDOW_HORIZON, TICKS_PER_MOVE}; +use std::collections::HashMap; +use crate::TICKS_PER_MOVE; use crate::world::World; pub struct Sim { - pub world: World, - /// Scheduled actions per entity, keyed by movement window (`tick / TICKS_PER_MOVE`). - /// The window slot is the whole ordering model: an action addressed to an occupied - /// window *replaces* it (retraction and rescheduling by the client, dedup of - /// retransmits), a late action only fills the next window if it is empty, and - /// anything past `ACTION_WINDOW_HORIZON` is dropped — so no client can grow server - /// memory or move faster than one action per window. - pending: HashMap>, + pub world: World, + /// Base tick counter (24 Hz). Movement resolves every `TICKS_PER_MOVE`-th tick. + pub tick: u32, + /// Each entity's intent for the next movement window. Setting it again replaces + /// it, executing the window consumes it — so an actor moves at most one tile per + /// window no matter how often it changes its mind in between. + intents: HashMap, } impl Sim { pub fn new(world: World) -> Self { - Self { world, pending: HashMap::new() } + Self { world, tick: 0, intents: HashMap::new() } } - pub fn tick(&mut self, tick: u32, actions: &[(u32, u32, u16)]) { - // The earliest window still addressable at this tick. On a movement tick that is - // the window executing *this call* — actions arriving the same tick still count. - let min_window = tick.div_ceil(TICKS_PER_MOVE); - - for &(entity_id, target_tick, action) in actions { - let slots = self.pending.entry(entity_id).or_default(); - let window = target_tick.div_ceil(TICKS_PER_MOVE); - if window < min_window { - // Late. A movement action keeps its *order* instead of its time: it - // fills the first still-empty upcoming window, so bunched late arrivals - // don't collapse onto one slot and eat each other. A late NOOP is - // dropped — once its window has passed, its cancellation intent is - // ambiguous, and as a gap-filler it would block real steps (worst case - // one stale step executes; the client's reconciliation handles that). - if crate::action_delta(action).is_some() { - let gap = (min_window..min_window + ACTION_WINDOW_HORIZON as u32) - .find(|w| !slots.contains_key(w)); - if let Some(w) = gap { - slots.insert(w, action); - } - } - } else if window - min_window < ACTION_WINDOW_HORIZON as u32 { - slots.insert(window, action); // newest addressing wins - } - } - - if tick.is_multiple_of(TICKS_PER_MOVE) { - self.entity_tick(tick / TICKS_PER_MOVE); + /// Schedule `action` for `entity_id`'s next movement window. NOOP clears the intent. + pub fn set_action(&mut self, entity_id: u32, action: u16) { + if crate::action_delta(action).is_some() { + self.intents.insert(entity_id, action); + } else { + self.intents.remove(&entity_id); } } - fn entity_tick(&mut self, window: u32) { - // Exactly one action per entity per movement window, then the window is gone. - let actions: Vec<(u32, u16)> = self.pending.iter_mut() - .filter_map(|(&id, slots)| { - let action = slots.remove(&window); - slots.retain(|&w, _| w > window); // drop anything the timeline passed by - action.map(|a| (id, a)) - }) - .collect(); - self.pending.retain(|_, slots| !slots.is_empty()); + pub fn clear_action(&mut self, entity_id: u32) { + self.intents.remove(&entity_id); + } - for (entity_id, action) in actions { + /// Advance the world by one base tick. Returns `true` when this tick executed a + /// movement window, i.e. entity positions may have changed. + pub fn step(&mut self) -> bool { + self.tick = self.tick.wrapping_add(1); + if self.tick.is_multiple_of(TICKS_PER_MOVE) { + self.entity_tick(); + true + } else { + false + } + } + + fn entity_tick(&mut self) { + for (entity_id, action) in self.intents.drain() { let delta = match crate::action_delta(action) { Some(d) => d, - None => continue, // NOOP or garbage + None => continue, }; let pos = match self.world.entities.get(&entity_id) { Some(e) => e.pos, @@ -73,7 +55,7 @@ impl Sim { let from = (pos.0 as i32, pos.1 as i32); let to = (from.0 + delta.0, from.1 + delta.1); // `step_allowed` is the shared movement rule (chessboard geometry, no corner - // cutting) — the client predicts with the exact same function. + // cutting) — the pathfinder plans with the exact same function. let allowed = crate::step_allowed(from, to, |x, y| { self.world.tile_flags(x as i16, y as i16).map_or(true, |f| f.collidable()) }); @@ -83,3 +65,64 @@ impl Sim { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::chunk::{Chunk, TileDef, TileFlags}; + use crate::player_action; + + fn open_world() -> World { + let mut w = World::new(); + w.set_chunk(0, 0, Chunk::generate(|lx, ly| { + // A single wall tile at (5, 4). + let flags = if (lx, ly) == (5, 4) { TileFlags::COLLIDABLE } else { 0 }; + TileDef { tile_id: 1, flags: TileFlags(flags) } + }).unwrap()); + w + } + + fn step_window(sim: &mut Sim) { + for _ in 0..TICKS_PER_MOVE { + sim.step(); + } + } + + #[test] + fn one_step_per_window_and_replacement() { + let mut sim = Sim::new(open_world()); + let id = sim.world.spawn_entity(0, (4, 4), 1); + sim.set_action(id, player_action::SOUTH); + sim.set_action(id, player_action::NORTH); // replaces + sim.step(); // no window yet + assert_eq!(sim.world.entities[&id].pos, (4, 4)); + step_window(&mut sim); + assert_eq!(sim.world.entities[&id].pos, (4, 3)); + step_window(&mut sim); // intent consumed — no second step + assert_eq!(sim.world.entities[&id].pos, (4, 3)); + } + + #[test] + fn blocked_and_out_of_world_steps_are_rejected() { + let mut sim = Sim::new(open_world()); + let id = sim.world.spawn_entity(0, (4, 4), 1); + sim.set_action(id, player_action::EAST); // into the wall at (5, 4) + step_window(&mut sim); + assert_eq!(sim.world.entities[&id].pos, (4, 4)); + + let edge = sim.world.spawn_entity(0, (0, 0), 1); + sim.set_action(edge, player_action::WEST); // no chunk there + step_window(&mut sim); + assert_eq!(sim.world.entities[&edge].pos, (0, 0)); + } + + #[test] + fn noop_clears_intent() { + let mut sim = Sim::new(open_world()); + let id = sim.world.spawn_entity(0, (4, 4), 1); + sim.set_action(id, player_action::SOUTH); + sim.set_action(id, player_action::NOOP); + step_window(&mut sim); + assert_eq!(sim.world.entities[&id].pos, (4, 4)); + } +}