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:
co-authored by
Claude Opus 5
parent
303c587aee
commit
f594f12f35
Generated
+8
-30
@@ -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"
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
members = ["sim", "shared", "client", "server", "netsim"]
|
||||
members = ["sim", "game"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
Executable
+385
@@ -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),
|
||||
] {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
@@ -1,6 +0,0 @@
|
||||
[package]
|
||||
name = "netsim"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
shared = { path = "../shared" }
|
||||
bytemuck = "1.24.0"
|
||||
sim = { path = "../sim" }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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);
|
||||
@@ -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`.
|
||||
///
|
||||
|
||||
+92
-49
@@ -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<u32, BTreeMap<u32, u16>>,
|
||||
/// 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<u32, u16>,
|
||||
}
|
||||
|
||||
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).
|
||||
/// 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() {
|
||||
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
|
||||
self.intents.insert(entity_id, action);
|
||||
} else {
|
||||
self.intents.remove(&entity_id);
|
||||
}
|
||||
}
|
||||
|
||||
if tick.is_multiple_of(TICKS_PER_MOVE) {
|
||||
self.entity_tick(tick / TICKS_PER_MOVE);
|
||||
pub fn clear_action(&mut self, entity_id: u32) {
|
||||
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) {
|
||||
// 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());
|
||||
|
||||
for (entity_id, action) in actions {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user