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
Executable
+8
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "game"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sim = { path = "../sim" }
|
||||
pbio = { git = "https://git.bnd.wtf/irrlicht/rust-pbio.git" }
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Load an 8-bit indexed TGA (type 1 or type 9) from disk.
|
||||
pub fn from_tga(path: &str) -> Self {
|
||||
let mut file = File::open(path).unwrap();
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data).unwrap();
|
||||
|
||||
let image_type = data[2];
|
||||
let width = u16::from_le_bytes([data[12], data[13]]) as u32;
|
||||
let height = u16::from_le_bytes([data[14], data[15]]) as u32;
|
||||
let colormap_entries = u16::from_le_bytes([data[5], data[6]]) as usize;
|
||||
let colormap_entry_bytes = data[7] as usize / 8;
|
||||
let pixel_offset = 18 + colormap_entries * colormap_entry_bytes;
|
||||
|
||||
let pixel_count = (width * height) as usize;
|
||||
let mut pixels = vec![0u8; pixel_count];
|
||||
match image_type {
|
||||
1 => pixels.copy_from_slice(&data[pixel_offset..pixel_offset + pixel_count]),
|
||||
9 => decode_rle(&data[pixel_offset..], &mut pixels),
|
||||
_ => panic!("unsupported TGA image type: {}", image_type),
|
||||
}
|
||||
|
||||
Image { width, height, pixels }
|
||||
}
|
||||
|
||||
/// Split this image into 8×8 tiles, row-major. Tile ID = flat index into the Vec.
|
||||
pub fn to_tileset(&self) -> Vec<[u8; 64]> {
|
||||
let w = self.width as usize;
|
||||
let cols = w / 8;
|
||||
let rows = self.height as usize / 8;
|
||||
let mut tiles = Vec::with_capacity(rows * cols);
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let mut tile = [0u8; 64];
|
||||
for tr in 0..8 {
|
||||
for tc in 0..8 {
|
||||
tile[tr * 8 + tc] = self.pixels[w * (row * 8 + tr) + col * 8 + tc];
|
||||
}
|
||||
}
|
||||
tiles.push(tile);
|
||||
}
|
||||
}
|
||||
tiles
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_rle(src: &[u8], dst: &mut [u8]) {
|
||||
let mut si = 0;
|
||||
let mut di = 0;
|
||||
while di < dst.len() {
|
||||
let header = src[si];
|
||||
si += 1;
|
||||
let count = (header & 0x7f) as usize + 1;
|
||||
if header & 0x80 != 0 {
|
||||
dst[di..di + count].fill(src[si]);
|
||||
si += 1;
|
||||
} else {
|
||||
dst[di..di + count].copy_from_slice(&src[si..si + count]);
|
||||
si += count;
|
||||
}
|
||||
di += count;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Grid A* over the client's chunk cache. The client only *plans* here — every step
|
||||
//! still goes to the server as a cardinal action, and the server stays authoritative.
|
||||
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BinaryHeap, HashMap};
|
||||
|
||||
/// Hard cap on expanded nodes, so a click on an unreachable tile can't stall the frame.
|
||||
const MAX_EXPANSIONS: usize = 4096;
|
||||
|
||||
/// Longest route we will plan. Keeps the reply small and bounds replan cost.
|
||||
const MAX_ROUTE_LEN: usize = 64;
|
||||
|
||||
/// Step costs. In game terms every step costs the same (Chebyshev geometry) — the +1 on
|
||||
/// diagonals is purely a tie-breaker so that among equally short paths the one with the
|
||||
/// fewest diagonals wins (straight lines stay straight instead of zigzagging). Because a
|
||||
/// route is at most `MAX_ROUTE_LEN` steps, the accumulated surcharge (≤ 64) can never
|
||||
/// outweigh one extra step (100): the step count always stays Chebyshev-minimal.
|
||||
const CARDINAL_COST: i32 = 100;
|
||||
const DIAGONAL_COST: i32 = 101;
|
||||
|
||||
/// 8-connected A* from `start` to `goal`. World geometry is chessboard (Chebyshev):
|
||||
/// every step is one game turn, so routes are minimal in step count; the tiny diagonal
|
||||
/// surcharge (see `DIAGONAL_COST`) only breaks ties among equally short paths. Each
|
||||
/// expansion goes through the shared `step_allowed` rule — so the plan can never contain
|
||||
/// a step the server would refuse (including corner cutting). Returns the tiles to walk,
|
||||
/// excluding `start`, ending on `goal`, or `None` if the goal is unreachable within the
|
||||
/// search budget.
|
||||
pub fn find_path(
|
||||
start: (i32, i32),
|
||||
goal: (i32, i32),
|
||||
blocked: impl Fn(i32, i32) -> bool,
|
||||
) -> Option<Vec<(i32, i32)>> {
|
||||
if start == goal || blocked(goal.0, goal.1) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Exact open-field cost: `max` steps, of which `min` must be diagonal — admissible
|
||||
// and consistent, since obstacles can only make a path more expensive.
|
||||
let h = |p: (i32, i32)| {
|
||||
let dx = (p.0 - goal.0).abs();
|
||||
let dy = (p.1 - goal.1).abs();
|
||||
CARDINAL_COST * dx.max(dy) + (DIAGONAL_COST - CARDINAL_COST) * dx.min(dy)
|
||||
};
|
||||
|
||||
// (f, tile) min-heap; g and parent per visited tile.
|
||||
let mut open = BinaryHeap::new();
|
||||
let mut best: HashMap<(i32, i32), (i32, (i32, i32))> = HashMap::new();
|
||||
open.push(Reverse((h(start), start)));
|
||||
best.insert(start, (0, start));
|
||||
|
||||
let mut expanded = 0;
|
||||
while let Some(Reverse((_, cur))) = open.pop() {
|
||||
if cur == goal {
|
||||
let mut route = Vec::new();
|
||||
let mut p = goal;
|
||||
while p != start {
|
||||
route.push(p);
|
||||
p = best[&p].1;
|
||||
}
|
||||
if route.len() > MAX_ROUTE_LEN {
|
||||
return None;
|
||||
}
|
||||
route.reverse();
|
||||
return Some(route);
|
||||
}
|
||||
|
||||
expanded += 1;
|
||||
if expanded > MAX_EXPANSIONS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let g = best[&cur].0;
|
||||
for (dx, dy) in [
|
||||
(0, -1), (0, 1), (-1, 0), (1, 0),
|
||||
(1, -1), (1, 1), (-1, 1), (-1, -1),
|
||||
] {
|
||||
let next = (cur.0 + dx, cur.1 + dy);
|
||||
if !sim::step_allowed(cur, next, &blocked) {
|
||||
continue;
|
||||
}
|
||||
let ng = g + if dx != 0 && dy != 0 { DIAGONAL_COST } else { CARDINAL_COST };
|
||||
if best.get(&next).is_none_or(|&(og, _)| ng < og) {
|
||||
best.insert(next, (ng, cur));
|
||||
open.push(Reverse((ng + h(next), next)));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::find_path;
|
||||
|
||||
/// Every consecutive pair must be a legal step under the shared movement rule.
|
||||
fn assert_valid(start: (i32, i32), path: &[(i32, i32)], blocked: impl Fn(i32, i32) -> bool) {
|
||||
let mut from = start;
|
||||
for &to in path {
|
||||
assert!(sim::step_allowed(from, to, &blocked), "illegal step {from:?} → {to:?}");
|
||||
from = to;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_line_stays_straight() {
|
||||
// The diagonal tie-breaker rules out equal-length zigzags.
|
||||
let path = find_path((0, 0), (3, 0), |_, _| false).unwrap();
|
||||
assert_eq!(path, vec![(1, 0), (2, 0), (3, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagonal_is_one_step_per_tile() {
|
||||
let path = find_path((0, 0), (3, 3), |_, _| false).unwrap();
|
||||
assert_eq!(path, vec![(1, 1), (2, 2), (3, 3)]); // not an L of length 6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_route_is_step_minimal() {
|
||||
// (5,2): 5 steps — 2 diagonal, 3 cardinal, never more.
|
||||
let path = find_path((0, 0), (5, 2), |_, _| false).unwrap();
|
||||
assert_eq!(path.len(), 5);
|
||||
assert_eq!(path.last(), Some(&(5, 2)));
|
||||
let diagonals = std::iter::once((0, 0)).chain(path.iter().copied())
|
||||
.zip(path.iter().copied())
|
||||
.filter(|(a, b)| a.0 != b.0 && a.1 != b.1)
|
||||
.count();
|
||||
assert_eq!(diagonals, 2);
|
||||
assert_valid((0, 0), &path, |_, _| false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detours_around_wall() {
|
||||
// Vertical wall at x=2 with a gap at y=5. The corner rule forces the gap to be
|
||||
// entered and left orthogonally: 5 steps down to (1,5), through (2,5) and (3,5),
|
||||
// 5 steps back up to (4,0).
|
||||
let blocked = |x: i32, y: i32| x == 2 && y != 5;
|
||||
let path = find_path((0, 0), (4, 0), blocked).unwrap();
|
||||
assert_eq!(path.last(), Some(&(4, 0)));
|
||||
assert!(path.contains(&(2, 5)));
|
||||
assert_valid((0, 0), &path, blocked);
|
||||
assert_eq!(path.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_corner_cutting() {
|
||||
// Blockers at (1,0) and (0,1) touch diagonally; the direct king move
|
||||
// (0,0) → (1,1) must not squeeze between them.
|
||||
let blocked = |x: i32, y: i32| (x, y) == (1, 0) || (x, y) == (0, 1);
|
||||
let path = find_path((0, 0), (1, 1), blocked).unwrap();
|
||||
assert_valid((0, 0), &path, blocked);
|
||||
assert_eq!(path.len(), 6); // shortest legal detour around either blocker
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_is_none() {
|
||||
// Goal sealed in by a ring (Chebyshev radius 1 — also seals diagonals).
|
||||
let blocked = |x: i32, y: i32| (x - 10).abs().max((y - 10).abs()) == 1;
|
||||
assert_eq!(find_path((0, 0), (10, 10), blocked), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_cases() {
|
||||
assert_eq!(find_path((5, 5), (5, 5), |_, _| false), None);
|
||||
assert_eq!(find_path((0, 0), (1, 0), |x, y| (x, y) == (1, 0)), None);
|
||||
}
|
||||
}
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
/// Write a single palette index. Out-of-bounds coordinates are silently ignored.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_pixel(dst: &mut [u8], dst_w: usize, x: i32, y: i32, color: u8) {
|
||||
let dst_h = (dst.len() / dst_w) as i32;
|
||||
if x < 0 || x >= dst_w as i32 || y < 0 || y >= dst_h {
|
||||
return;
|
||||
}
|
||||
dst[dst_w * y as usize + x as usize] = color;
|
||||
}
|
||||
|
||||
/// Copy a rectangle out of `src` into a new Vec (for building sprites/glyphs).
|
||||
#[allow(dead_code)]
|
||||
pub fn extract(src: &[u8], src_w: usize, x: usize, y: usize, w: usize, h: usize) -> Vec<u8> {
|
||||
let mut out = vec![0u8; w * h];
|
||||
for row in 0..h {
|
||||
let s = src_w * (y + row) + x;
|
||||
let d = w * row;
|
||||
out[d..d + w].copy_from_slice(&src[s..s + w]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Blit `src` (width `src_w`, height derived from src.len()) into `dst` at (dx, dy).
|
||||
/// Clips against all four edges of `dst`; the destination position may be negative.
|
||||
#[allow(dead_code)]
|
||||
pub fn blit(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, src: &[u8], src_w: usize) {
|
||||
let dst_w_i = dst_w as i32;
|
||||
let dst_h = (dst.len() / dst_w) as i32;
|
||||
let src_h = (src.len() / src_w) as i32;
|
||||
|
||||
for row in 0..src_h {
|
||||
let y = dy + row;
|
||||
if y < 0 || y >= dst_h {
|
||||
continue;
|
||||
}
|
||||
// Clip the horizontal span once per row, then copy it in one shot.
|
||||
let x0 = dx.max(0);
|
||||
let x1 = (dx + src_w as i32).min(dst_w_i);
|
||||
if x0 >= x1 {
|
||||
continue;
|
||||
}
|
||||
let d = dst_w * y as usize + x0 as usize;
|
||||
let s = (row * src_w as i32 + (x0 - dx)) as usize;
|
||||
let len = (x1 - x0) as usize;
|
||||
dst[d..d + len].copy_from_slice(&src[s..s + len]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Blit an 8×8 tile with transparency: index 0 is skipped.
|
||||
/// Clips against all four edges of `dst`; the destination position may be negative.
|
||||
pub fn blit_tile(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, tile: &[u8; 64]) {
|
||||
let dst_w_i = dst_w as i32;
|
||||
let dst_h = (dst.len() / dst_w) as i32;
|
||||
|
||||
for row in 0..8i32 {
|
||||
let y = dy + row;
|
||||
if y < 0 || y >= dst_h {
|
||||
continue;
|
||||
}
|
||||
for col in 0..8i32 {
|
||||
let x = dx + col;
|
||||
if x < 0 || x >= dst_w_i {
|
||||
continue;
|
||||
}
|
||||
let px = tile[(row * 8 + col) as usize];
|
||||
if px != 0 {
|
||||
dst[dst_w * y as usize + x as usize] = px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
use pbio::Key;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GameAction {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Confirm,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub struct InputMap {
|
||||
bindings: Vec<(Key, GameAction)>,
|
||||
}
|
||||
|
||||
impl InputMap {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
bindings: vec![
|
||||
(Key::Up, GameAction::Up),
|
||||
(Key::Down, GameAction::Down),
|
||||
(Key::Left, GameAction::Left),
|
||||
(Key::Right, GameAction::Right),
|
||||
(Key::W, GameAction::Up),
|
||||
(Key::S, GameAction::Down),
|
||||
(Key::A, GameAction::Left),
|
||||
(Key::D, GameAction::Right),
|
||||
(Key::Enter, GameAction::Confirm),
|
||||
(Key::Escape, GameAction::Cancel),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn translate(&self, key: Key) -> Option<GameAction> {
|
||||
self.bindings.iter().find(|(k, _)| *k == key).map(|(_, a)| *a)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InputState {
|
||||
pressed: Vec<GameAction>,
|
||||
held: Vec<GameAction>,
|
||||
released: Vec<GameAction>,
|
||||
/// Cursor position in framebuffer pixels (pbio delivers framebuffer coordinates).
|
||||
mouse_pos: (i32, i32),
|
||||
mouse_clicked: bool,
|
||||
mouse_held: bool,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pressed: Vec::new(),
|
||||
held: Vec::new(),
|
||||
released: Vec::new(),
|
||||
mouse_pos: (-1, -1),
|
||||
mouse_clicked: false,
|
||||
mouse_held: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_mouse_pos(&mut self, x: i32, y: i32) {
|
||||
self.mouse_pos = (x, y);
|
||||
}
|
||||
|
||||
pub fn push_click(&mut self) {
|
||||
self.mouse_clicked = true;
|
||||
self.mouse_held = true;
|
||||
}
|
||||
|
||||
pub fn release_click(&mut self) {
|
||||
self.mouse_held = false;
|
||||
}
|
||||
|
||||
pub fn mouse_pos(&self) -> (i32, i32) {
|
||||
self.mouse_pos
|
||||
}
|
||||
|
||||
/// True if the left button was pressed since the last `clear()`.
|
||||
pub fn mouse_clicked(&self) -> bool {
|
||||
self.mouse_clicked
|
||||
}
|
||||
|
||||
/// True while the left button is down. Like `held` keys, this survives `clear()`.
|
||||
pub fn mouse_held(&self) -> bool {
|
||||
self.mouse_held
|
||||
}
|
||||
|
||||
pub fn push(&mut self, action: GameAction) {
|
||||
self.pressed.push(action);
|
||||
if !self.held.contains(&action) {
|
||||
self.held.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn release(&mut self, action: GameAction) {
|
||||
self.held.retain(|a| *a != action);
|
||||
if !self.released.contains(&action) {
|
||||
self.released.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn button_pressed(&self, action: GameAction) -> bool {
|
||||
self.pressed.contains(&action)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn button_held(&self, action: GameAction) -> bool {
|
||||
self.held.contains(&action)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn button_released(&self, action: GameAction) -> bool {
|
||||
self.released.contains(&action)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.pressed.clear();
|
||||
self.released.clear();
|
||||
self.mouse_clicked = false;
|
||||
}
|
||||
}
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
use pbio::{Event, MouseButton, Platform, PlatformConfig};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod assets;
|
||||
mod game;
|
||||
mod input;
|
||||
|
||||
fn main() {
|
||||
let mut plat = Platform::new(PlatformConfig {
|
||||
title: "Forgotten Caves".into(),
|
||||
window_size: (800, 600),
|
||||
framebuffer_size: (320, 240),
|
||||
aspect_ratio: Some(4.0 / 3.0),
|
||||
palette: pbio::palette::RGB332,
|
||||
vsync: true,
|
||||
mouse_capture: false,
|
||||
mouse_visible: true,
|
||||
});
|
||||
|
||||
let input_map = input::InputMap::new();
|
||||
let mut input_state = input::InputState::new();
|
||||
let mut last_update = Instant::now();
|
||||
let mut game = game::Game::start();
|
||||
|
||||
while !plat.should_close() {
|
||||
plat.poll_events(Some(Duration::from_millis(20)));
|
||||
|
||||
let events: Vec<Event> = plat.drain_events().collect();
|
||||
for ev in events {
|
||||
match ev {
|
||||
Event::Key { key, pressed: true, .. } => {
|
||||
if let Some(action) = input_map.translate(key) {
|
||||
input_state.push(action);
|
||||
}
|
||||
}
|
||||
Event::Key { key, pressed: false, .. } => {
|
||||
if let Some(action) = input_map.translate(key) {
|
||||
input_state.release(action);
|
||||
}
|
||||
}
|
||||
Event::MouseMove { x, y } => {
|
||||
input_state.set_mouse_pos(x as i32, y as i32);
|
||||
}
|
||||
Event::MouseBtn { button: MouseButton::Left, pressed: true } => {
|
||||
input_state.push_click();
|
||||
}
|
||||
Event::MouseBtn { button: MouseButton::Left, pressed: false } => {
|
||||
input_state.release_click();
|
||||
}
|
||||
Event::CloseRequested => plat.request_close(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let dt = last_update.elapsed().as_millis();
|
||||
if dt < 16 { continue; }
|
||||
last_update = Instant::now();
|
||||
|
||||
if let Some(game::GameSignal::Quit) =
|
||||
game.update(plat.framebuffer_mut(), dt as usize, &input_state)
|
||||
{
|
||||
plat.request_close();
|
||||
}
|
||||
input_state.clear();
|
||||
|
||||
plat.present();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user