current state
This commit is contained in:
+248
-85
@@ -6,33 +6,36 @@ use std::collections::{HashMap, VecDeque};
|
||||
use crate::assets::Image;
|
||||
use crate::input::{GameAction, InputState};
|
||||
use crate::net::{EntityInfo, NetClient, NetEvent};
|
||||
use shared::{chunk_id, player_action, tile_collidable};
|
||||
use shared::{chunk_id, delta_action, player_action, step_allowed, tile_collidable, TICKS_PER_MOVE};
|
||||
|
||||
pub enum GameSignal {
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// The cardinal action that moves `from` → `to`. This is the only movement vocabulary
|
||||
/// the server understands; the pathfinder's output is translated through here.
|
||||
fn step_action(from: (i32, i32), to: (i32, i32)) -> u16 {
|
||||
match (to.0 - from.0, to.1 - from.1) {
|
||||
(0, -1) => player_action::NORTH,
|
||||
(0, 1) => player_action::SOUTH,
|
||||
(-1, 0) => player_action::WEST,
|
||||
(1, 0) => player_action::EAST,
|
||||
d => unreachable!("non-cardinal step {d:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One queued step per this interval while a direction is held. Mirrors the server's
|
||||
/// movement cadence (24 Hz base tick, movement on every 4th tick → 6 Hz ≈ 167 ms/tile).
|
||||
/// 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;
|
||||
|
||||
/// After this long without movement input, drop any unconfirmed queued path (the server
|
||||
/// is the truth; whatever it hasn't acted on is discarded).
|
||||
const RECONCILE_IDLE_MS: usize = 300;
|
||||
/// 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;
|
||||
|
||||
/// Upper bound on queued-but-unconfirmed steps, so a server-side block can't grow it forever.
|
||||
/// 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).
|
||||
@@ -62,6 +65,16 @@ struct EntityLerp {
|
||||
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]>,
|
||||
@@ -70,17 +83,30 @@ pub struct Game {
|
||||
player_entity_id: u32,
|
||||
/// Authoritative position, as last confirmed by the server.
|
||||
player_pos: (i32, i32),
|
||||
/// Queued future tiles: steps sent to the server but not yet confirmed. Shown as the
|
||||
/// 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<(i32, i32)>,
|
||||
path: VecDeque<PathStep>,
|
||||
/// Planned route from click-to-move: tiles not yet sent to the server. Fed into
|
||||
/// `path` one step per `MOVE_INTERVAL_MS`. Keyboard input cancels it.
|
||||
/// `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),
|
||||
move_accum_ms: usize,
|
||||
idle_ms: usize,
|
||||
entities: Vec<EntityInfo>,
|
||||
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
|
||||
/// logic keeps using the authoritative tile positions.
|
||||
@@ -88,12 +114,10 @@ pub struct Game {
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub fn start() -> Self {
|
||||
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 server_addr = "127.0.0.1:7777".parse().unwrap();
|
||||
let player_pos = (16, 16);
|
||||
Game {
|
||||
tileset,
|
||||
@@ -103,12 +127,16 @@ impl Game {
|
||||
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,
|
||||
),
|
||||
move_accum_ms: MOVE_INTERVAL_MS, // ready, so the first held step fires instantly
|
||||
idle_ms: 0,
|
||||
entities: Vec::new(),
|
||||
lerp: HashMap::new(),
|
||||
}
|
||||
@@ -130,11 +158,21 @@ impl Game {
|
||||
pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState)
|
||||
-> Option<GameSignal>
|
||||
{
|
||||
if input.mouse_clicked() {
|
||||
self.handle_click(input.mouse_pos());
|
||||
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();
|
||||
}
|
||||
|
||||
self.step_movement(dt, input);
|
||||
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();
|
||||
@@ -143,18 +181,26 @@ impl Game {
|
||||
|
||||
for event in self.net.poll() {
|
||||
match event {
|
||||
NetEvent::Pong { rtt_ms } => println!("pong! rtt = {rtt_ms} ms"),
|
||||
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 { entities } => {
|
||||
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();
|
||||
self.reconcile_path(tick);
|
||||
self.track_lerp(&entities);
|
||||
self.entities = entities;
|
||||
}
|
||||
@@ -173,10 +219,47 @@ impl Game {
|
||||
None
|
||||
}
|
||||
|
||||
/// The position movement continues from: the last queued step, or where the server
|
||||
/// last put us.
|
||||
/// 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.back().copied().unwrap_or(self.player_pos)
|
||||
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.
|
||||
@@ -207,40 +290,76 @@ impl Game {
|
||||
self.cam = (approach(self.cam.0, tx), approach(self.cam.1, ty));
|
||||
}
|
||||
|
||||
/// Click-to-move: translate a framebuffer click into a world tile and plan a route
|
||||
/// there. The route is only a client-side plan — it is executed as ordinary cardinal
|
||||
/// actions in `step_movement`, so the server keeps full authority over every step.
|
||||
fn handle_click(&mut self, (mx, my): (i32, i32)) {
|
||||
/// 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));
|
||||
|
||||
let found = pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y));
|
||||
match found {
|
||||
Some(steps) => self.route = steps.into(),
|
||||
None => self.route.clear(), // unreachable — cancel any current route
|
||||
// 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 queueing. A held direction key (which cancels any planned route) or the
|
||||
/// next planned route tile appends one step per `MOVE_INTERVAL_MS` to the in-flight
|
||||
/// path, checking walkability locally and forwarding the cardinal action to the
|
||||
/// server. When idle, drop any still-unconfirmed path.
|
||||
fn step_movement(&mut self, dt: usize, input: &InputState) {
|
||||
let dir = if input.button_held(GameAction::Up) { Some((0, -1)) }
|
||||
else if input.button_held(GameAction::Down) { Some((0, 1)) }
|
||||
else if input.button_held(GameAction::Left) { Some((-1, 0)) }
|
||||
else if input.button_held(GameAction::Right) { Some((1, 0)) }
|
||||
else { None };
|
||||
/// 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() {
|
||||
self.route.clear(); // manual input overrides click-to-move
|
||||
// Manual input overrides click-to-move entirely, goal included.
|
||||
self.route.clear();
|
||||
self.goal = None;
|
||||
}
|
||||
|
||||
self.move_accum_ms = (self.move_accum_ms + dt).min(MOVE_INTERVAL_MS);
|
||||
let ready = self.move_accum_ms >= MOVE_INTERVAL_MS && self.path.len() < PATH_MAX_LEN;
|
||||
let 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.
|
||||
@@ -249,29 +368,56 @@ impl Game {
|
||||
None => self.route.front().copied(),
|
||||
};
|
||||
|
||||
if let Some((nx, ny)) = step {
|
||||
self.idle_ms = 0;
|
||||
if ready {
|
||||
// Route steps are re-validated at send time: the world may have changed
|
||||
// since planning (or the plan may have desynced). A bad step voids the
|
||||
// whole route rather than walking blindly on.
|
||||
let adjacent = (nx - from.0).abs() + (ny - from.1).abs() == 1;
|
||||
if adjacent && !self.tile_blocked(nx, ny) {
|
||||
if dir.is_none() {
|
||||
self.route.pop_front();
|
||||
}
|
||||
self.path.push_back((nx, ny));
|
||||
self.net.send_action(step_action(from, (nx, ny)));
|
||||
} else if dir.is_none() {
|
||||
self.route.clear();
|
||||
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.move_accum_ms = 0;
|
||||
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();
|
||||
}
|
||||
} else {
|
||||
self.idle_ms += dt;
|
||||
if self.idle_ms >= RECONCILE_IDLE_MS {
|
||||
self.path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,11 +462,20 @@ impl Game {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile the queued path against a fresh authoritative position: drop every queued
|
||||
/// tile up to and including the one the server has now reached.
|
||||
fn reconcile_path(&mut self) {
|
||||
if let Some(idx) = self.path.iter().position(|&p| p == self.player_pos) {
|
||||
self.path.drain(0..=idx);
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +518,15 @@ impl Game {
|
||||
|
||||
// Path pass — in-flight steps bright, planned-route tiles dim; over the world,
|
||||
// under entities.
|
||||
let marks = self.path.iter().map(|&p| (p, PATH_COLOR))
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user