Embed the sim in the game binary; drop networking

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 <noreply@anthropic.com>
This commit is contained in:
irrlicht
2026-09-18 22:56:30 +02:00
co-authored by Claude Opus 5
parent 303c587aee
commit f594f12f35
20 changed files with 496 additions and 1500 deletions
Generated
+8 -30
View File
@@ -228,15 +228,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "client"
version = "0.1.0"
dependencies = [
"bytemuck",
"pbio",
"shared",
]
[[package]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.12.0" version = "0.12.0"
@@ -443,6 +434,14 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "game"
version = "0.1.0"
dependencies = [
"pbio",
"sim",
]
[[package]] [[package]]
name = "gethostname" name = "gethostname"
version = "1.1.0" version = "1.1.0"
@@ -854,10 +853,6 @@ dependencies = [
"jni-sys 0.3.0", "jni-sys 0.3.0",
] ]
[[package]]
name = "netsim"
version = "0.1.0"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1471,23 +1466,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "server"
version = "0.1.0"
dependencies = [
"bytemuck",
"shared",
"sim",
]
[[package]]
name = "shared"
version = "0.1.0"
dependencies = [
"bytemuck",
"sim",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
+1 -1
View File
@@ -1,3 +1,3 @@
[workspace] [workspace]
members = ["sim", "shared", "client", "server", "netsim"] members = ["sim", "game"]
resolver = "2" resolver = "2"
-560
View File
@@ -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<PathStep>,
/// 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<EntityInfo>,
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
/// logic keeps using the authoritative tile positions.
lerp: HashMap<u32, EntityLerp>,
}
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<GameSignal>
{
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::<Vec<_>>().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);
}
}
}
-211
View File
@@ -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<EntityInfo> },
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<Instant>,
last_action_sent: Instant,
last_state_received: Option<Instant>,
pub chunk_cache: HashMap<u32, CachedChunk>,
last_state_chunks: Option<[ChunkEntry; 9]>,
pending_entities: Vec<EntityInfo>,
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<NetEvent> {
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::<Header>() { continue; }
let header: Header = *bytemuck::from_bytes(&buf[..size_of::<Header>()]);
if header.magic != MAGIC || header.version != VERSION { continue; }
match header.packet_type {
packet_type::PONG if n >= size_of::<PongPacket>() => {
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::<StatePacket>() => {
let pkt: StatePacket = *bytemuck::from_bytes(&buf[..size_of::<StatePacket>()]);
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::<EntityPacket>() => {
let pkt: EntityPacket = *bytemuck::from_bytes(&buf[..size_of::<EntityPacket>()]);
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::<ChunkPacket>() => {
let pkt: ChunkPacket = *bytemuck::from_bytes(&buf[..size_of::<ChunkPacket>()]);
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
}
}
+2 -3
View File
@@ -1,9 +1,8 @@
[package] [package]
name = "client" name = "game"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
shared = { path = "../shared" } sim = { path = "../sim" }
pbio = { git = "https://git.bnd.wtf/irrlicht/rust-pbio.git" } pbio = { git = "https://git.bnd.wtf/irrlicht/rust-pbio.git" }
bytemuck = { version = "1.24.0", features = ["derive"] }
+385
View File
@@ -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<u32, EntityLerp>,
}
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<GameSignal>
{
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);
}
}
}
@@ -75,7 +75,7 @@ pub fn find_path(
(1, -1), (1, 1), (-1, 1), (-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1),
] { ] {
let next = (cur.0 + dx, cur.1 + dy); let next = (cur.0 + dx, cur.1 + dy);
if !shared::step_allowed(cur, next, &blocked) { if !sim::step_allowed(cur, next, &blocked) {
continue; continue;
} }
let ng = g + if dx != 0 && dy != 0 { DIAGONAL_COST } else { CARDINAL_COST }; 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) { fn assert_valid(start: (i32, i32), path: &[(i32, i32)], blocked: impl Fn(i32, i32) -> bool) {
let mut from = start; let mut from = start;
for &to in path { 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; from = to;
} }
} }
+2 -8
View File
@@ -4,11 +4,10 @@ use std::time::{Duration, Instant};
mod assets; mod assets;
mod game; mod game;
mod input; mod input;
mod net;
fn main() { fn main() {
let mut plat = Platform::new(PlatformConfig { let mut plat = Platform::new(PlatformConfig {
title: "Roguelike".into(), title: "Forgotten Caves".into(),
window_size: (800, 600), window_size: (800, 600),
framebuffer_size: (320, 240), framebuffer_size: (320, 240),
aspect_ratio: Some(4.0 / 3.0), aspect_ratio: Some(4.0 / 3.0),
@@ -21,12 +20,7 @@ fn main() {
let input_map = input::InputMap::new(); let input_map = input::InputMap::new();
let mut input_state = input::InputState::new(); let mut input_state = input::InputState::new();
let mut last_update = Instant::now(); let mut last_update = Instant::now();
// Optional server address (e.g. netsim proxy): `client 127.0.0.1:7778`. let mut game = game::Game::start();
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);
while !plat.should_close() { while !plat.should_close() {
plat.poll_events(Some(Duration::from_millis(20))); plat.poll_events(Some(Duration::from_millis(20)));
-6
View File
@@ -1,6 +0,0 @@
[package]
name = "netsim"
version = "0.1.0"
edition = "2024"
[dependencies]
-170
View File
@@ -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<Duration> {
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<u8>,
}
// 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<std::cmp::Ordering> { 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::<f32>; 3]; // delay, jitter, loss
let mut down_over = [None::<f32>; 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::<f32>().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<f32>; 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<Reverse<Pending>> = BinaryHeap::new();
// The one game client, learned from its first datagram (re-learned on address change).
let mut client_addr: Option<SocketAddr> = 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));
}
}
-9
View File
@@ -1,9 +0,0 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
shared = { path = "../shared" }
bytemuck = "1.24.0"
sim = { path = "../sim" }
-40
View File
@@ -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);
}
}
-256
View File
@@ -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<SocketAddr, NetClient>,
new_addrs: Vec<SocketAddr>,
recv_rx: Receiver<(SocketAddr, Vec<u8>)>,
send_tx: SyncSender<(SocketAddr, Vec<u8>)>,
}
impl Net {
pub fn new(addr: &str) -> std::io::Result<Self> {
let socket = UdpSocket::bind(addr)?;
let (recv_tx, recv_rx) = mpsc::sync_channel::<(SocketAddr, Vec<u8>)>(256);
let (send_tx, send_rx) = mpsc::sync_channel::<(SocketAddr, Vec<u8>)>(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<u8>) {
if data.len() < size_of::<Header>() {
return;
}
let header: Header = bytemuck::pod_read_unaligned(&data[..size_of::<Header>()]);
if header.magic != MAGIC || header.version != VERSION {
return;
}
if header.packet_type == packet_type::ACTION && data.len() == size_of::<ActionPacket>() {
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::<PingPacket>() {
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<SocketAddr> {
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<EntityEntry> = 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 = <EntityPacket as bytemuck::Zeroable>::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 = <ChunkPacket as bytemuck::Zeroable>::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
}
-8
View File
@@ -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" }
-139
View File
@@ -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::<ChunkEntry>() == 6);
const _: () = assert!(std::mem::size_of::<ActionPacket>() == 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::<StatePacket>() == 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::<ChunkPacket>() == 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::<EntityEntry>() == 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::<EntityPacket>() == 1200);
-4
View File
@@ -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`. /// `TICKS_PER_MOVE` ticks (6 Hz). Window `w` executes at tick `w * TICKS_PER_MOVE`.
pub const TICKS_PER_MOVE: u32 = 4; 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 /// Movement vocabulary: whether a tile id blocks movement. Keep this in lockstep with
/// the tileset in `overworld.tga`. /// the tileset in `overworld.tga`.
/// ///
+92 -49
View File
@@ -1,70 +1,52 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::HashMap;
use crate::{ACTION_WINDOW_HORIZON, TICKS_PER_MOVE}; use crate::TICKS_PER_MOVE;
use crate::world::World; use crate::world::World;
pub struct Sim { pub struct Sim {
pub world: World, pub world: World,
/// Scheduled actions per entity, keyed by movement window (`tick / TICKS_PER_MOVE`). /// Base tick counter (24 Hz). Movement resolves every `TICKS_PER_MOVE`-th tick.
/// The window slot is the whole ordering model: an action addressed to an occupied pub tick: u32,
/// window *replaces* it (retraction and rescheduling by the client, dedup of /// Each entity's intent for the next movement window. Setting it again replaces
/// retransmits), a late action only fills the next window if it is empty, and /// it, executing the window consumes it — so an actor moves at most one tile per
/// anything past `ACTION_WINDOW_HORIZON` is dropped — so no client can grow server /// window no matter how often it changes its mind in between.
/// memory or move faster than one action per window. intents: HashMap<u32, u16>,
pending: HashMap<u32, BTreeMap<u32, u16>>,
} }
impl Sim { impl Sim {
pub fn new(world: World) -> Self { 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)]) { /// Schedule `action` for `entity_id`'s next movement window. NOOP clears the intent.
// The earliest window still addressable at this tick. On a movement tick that is pub fn set_action(&mut self, entity_id: u32, action: u16) {
// 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() { if crate::action_delta(action).is_some() {
let gap = (min_window..min_window + ACTION_WINDOW_HORIZON as u32) self.intents.insert(entity_id, action);
.find(|w| !slots.contains_key(w)); } else {
if let Some(w) = gap { self.intents.remove(&entity_id);
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) { pub fn clear_action(&mut self, entity_id: u32) {
self.entity_tick(tick / TICKS_PER_MOVE); self.intents.remove(&entity_id);
}
/// 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, window: u32) { fn entity_tick(&mut self) {
// Exactly one action per entity per movement window, then the window is gone. for (entity_id, action) in self.intents.drain() {
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());
for (entity_id, action) in actions {
let delta = match crate::action_delta(action) { let delta = match crate::action_delta(action) {
Some(d) => d, Some(d) => d,
None => continue, // NOOP or garbage None => continue,
}; };
let pos = match self.world.entities.get(&entity_id) { let pos = match self.world.entities.get(&entity_id) {
Some(e) => e.pos, Some(e) => e.pos,
@@ -73,7 +55,7 @@ impl Sim {
let from = (pos.0 as i32, pos.1 as i32); let from = (pos.0 as i32, pos.1 as i32);
let to = (from.0 + delta.0, from.1 + delta.1); let to = (from.0 + delta.0, from.1 + delta.1);
// `step_allowed` is the shared movement rule (chessboard geometry, no corner // `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| { 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()) 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));
}
}