Embed the sim in the game binary; drop networking

The client becomes the game: it owns a Sim instance and advances it with a
fixed-timestep accumulator (24 Hz ticks, rendering at frame rate). Rendering,
pathfinding and collision read the World directly instead of a chunk cache.

Sim::pending (window slot maps, late rule, horizon) is replaced by one intent
per entity for the next movement window — set_action replaces, executing the
window consumes. The whole client prediction/reconciliation machinery
(tick estimation, RTT lead, retraction, replay) is gone with the latency it
was built for; click-to-move now syncs the route against the sim position
after every window.

Removed: server, netsim, shared (wire types), client/net.rs. client renamed
to game. Headless tests cover the intent model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irrlicht
2026-09-18 22:56:30 +02:00
co-authored by Claude Opus 5
parent 303c587aee
commit f594f12f35
20 changed files with 496 additions and 1500 deletions
+8
View File
@@ -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" }
+72
View File
@@ -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;
}
}
+385
View File
@@ -0,0 +1,385 @@
mod pathfind;
mod pixelhelper;
use std::collections::{HashMap, VecDeque};
use crate::assets::Image;
use crate::input::{GameAction, InputState};
use sim::entity::Entity;
use sim::map::TileMap;
use sim::{delta_action, load_world, step_allowed, Sim, TICK_HZ, TICKS_PER_MOVE};
pub enum GameSignal {
Quit,
}
/// Length of one base tick. The sim advances in whole ticks; rendering runs at frame
/// rate on top and accumulates frame time into ticks.
const TICK_MS: f32 = 1000.0 / TICK_HZ as f32;
/// The movement cadence (movement on every 4th tick → 6 Hz ≈ 167 ms/tile). Entity
/// interpolation lerps over this interval.
const MOVE_INTERVAL_MS: usize = (TICK_MS * TICKS_PER_MOVE as f32) as usize;
/// Longest frame the sim will catch up on in one go. Anything slower (debugger, window
/// drag) is dropped instead of spiralling into a burst of ticks.
const MAX_FRAME_MS: f32 = 250.0;
/// Palette index for the planned route. Dim blue in RGB332 (r=0, g=0, b=2).
const ROUTE_COLOR: u8 = 0b000_000_10;
/// Viewport geometry: 30×30 tiles of 8 px, top-left of the framebuffer.
const VIEW_TILES: i32 = 30;
const TILE_PX: i32 = 8;
const VIEW_PX: i32 = VIEW_TILES * TILE_PX;
/// Camera pan speed. The player walks 8 px per 167 ms ≈ 48 px/s; the camera is a bit
/// faster, so it trails during movement and settles right after the player stops.
const CAM_SPEED: f32 = 64.0; // px/s
/// A position correction farther than this is a teleport — snap instead of panning.
const CAM_SNAP_PX: f32 = 96.0;
/// Render-side smoothing state for one entity: the previous tile and how long ago the
/// current one was taken. Positions are tiles; rendering lerps between them in pixels
/// over one movement interval.
struct EntityLerp {
prev: (i32, i32),
cur: (i32, i32),
t_ms: usize,
}
pub struct Game {
tileset: Vec<[u8; 64]>,
entity_tileset: Vec<[u8; 64]>,
sim: Sim,
player_id: u32,
/// Frame time not yet consumed by whole ticks.
tick_accum_ms: f32,
/// Planned route from click-to-move: tiles still to visit, fed into the sim one
/// step per movement window. Keyboard input cancels it.
route: VecDeque<(i32, i32)>,
/// The persistent movement goal. Outlives the route: a blocked step triggers a
/// replan toward it, and while the mouse is held it tracks the tile under the
/// cursor. Cleared on arrival, unreachability, keyboard override, or a click on an
/// unreachable tile.
goal: Option<(i32, i32)>,
/// Viewport top-left in world pixels. Follows the player linearly instead of
/// snapping tile-to-tile; only rendering rounds it to whole pixels.
cam: (f32, f32),
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
/// logic keeps using the sim's tile positions.
lerp: HashMap<u32, EntityLerp>,
}
impl Game {
pub fn start() -> Self {
let tileset = Image::from_tga("assets/tilesets/overworld.tga").to_tileset();
let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset();
let map = TileMap::from_csv("assets/map_test");
println!("loaded map {}×{} ({} tiles)", map.width, map.height, map.tiles.len());
let mut sim = Sim::new(load_world(&map));
let player_id = sim.world.spawn_entity(0, (0, 0), 100);
let mut game = Game {
tileset,
entity_tileset,
sim,
player_id,
tick_accum_ms: 0.0,
route: VecDeque::new(),
goal: None,
cam: (0.0, 0.0),
lerp: HashMap::new(),
};
game.cam = game.cam_target();
game.track_lerp();
game
}
fn player_pos(&self) -> (i32, i32) {
let p = self.sim.world.entities[&self.player_id].pos;
(p.0 as i32, p.1 as i32)
}
/// True if the world tile at `(wx, wy)` blocks movement. Outside the loaded world
/// counts as blocked — the same rule the sim applies.
fn tile_blocked(&self, wx: i32, wy: i32) -> bool {
self.sim.world.tile_flags(wx as i16, wy as i16).map_or(true, |f| f.collidable())
}
pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState)
-> Option<GameSignal>
{
if input.mouse_clicked() || input.mouse_held() {
self.handle_click(input.mouse_pos(), !input.mouse_clicked());
}
self.step_movement(input);
// Advance the world in whole ticks. After every movement window the route is
// synced against the new position and the next step is scheduled right away,
// so a slow frame that spans several windows still walks every one of them.
self.tick_accum_ms += (dt as f32).min(MAX_FRAME_MS);
while self.tick_accum_ms >= TICK_MS {
self.tick_accum_ms -= TICK_MS;
if self.sim.step() {
self.track_lerp();
self.sync_route();
self.step_movement(input);
}
}
if input.button_pressed(GameAction::Cancel) {
println!("Goodbye!");
return Some(GameSignal::Quit);
}
self.step_camera(dt);
self.step_lerp(dt);
self.render_viewport(render_frame);
None
}
/// Where the camera wants to be: the viewport centered on the player's tile.
fn cam_target(&self) -> (f32, f32) {
let (px, py) = self.player_pos();
(
(px * TILE_PX - VIEW_PX / 2) as f32,
(py * TILE_PX - VIEW_PX / 2) as f32,
)
}
/// The camera rounded to the pixel grid — the actual top-left of the rendered view.
fn cam_px(&self) -> (i32, i32) {
(self.cam.0.round() as i32, self.cam.1.round() as i32)
}
/// Follow the player linearly at `CAM_SPEED`, axis by axis. Corrections beyond
/// `CAM_SNAP_PX` (teleports, respawns) snap outright instead of panning across.
fn step_camera(&mut self, dt: usize) {
let (tx, ty) = self.cam_target();
if (tx - self.cam.0).abs().max((ty - self.cam.1).abs()) > CAM_SNAP_PX {
self.cam = (tx, ty);
return;
}
let step = CAM_SPEED * dt as f32 / 1000.0;
let approach = |c: f32, t: f32| {
if (t - c).abs() <= step { t } else { c + step * (t - c).signum() }
};
self.cam = (approach(self.cam.0, tx), approach(self.cam.1, ty));
}
/// Click-to-move: translate a framebuffer click into a world tile and adopt it as
/// the movement goal. With `hold` (button held after the initial click) this runs
/// every frame and keeps steering toward the tile under the cursor, replanning only
/// when that tile changes — camera movement alone shifts it too, not just moving
/// the mouse.
fn handle_click(&mut self, (mx, my): (i32, i32), hold: bool) {
if mx < 0 || my < 0 || mx >= VIEW_PX || my >= VIEW_PX {
return; // outside the world viewport
}
let (cx, cy) = self.cam_px();
let goal = ((cx + mx).div_euclid(TILE_PX), (cy + my).div_euclid(TILE_PX));
// While steering, an unchanged goal needs no replan — unless the route was voided
// (blocked step) before reaching it; an empty route with the goal still ahead
// means exactly that, so plan again.
if hold && self.goal == Some(goal)
&& (!self.route.is_empty() || self.player_pos() == goal)
{
return;
}
match pathfind::find_path(self.player_pos(), goal, |x, y| self.tile_blocked(x, y)) {
Some(steps) => {
self.route = steps.into();
self.goal = Some(goal);
}
// A discrete click on an unreachable tile cancels the plan; while steering,
// sweeping the cursor across a blocked tile keeps the current plan alive.
None if !hold => {
self.route.clear();
self.goal = None;
self.sim.clear_action(self.player_id);
}
None => {}
}
}
/// Schedule the player's next step: a held direction key (which cancels any goal)
/// or the head of the planned route becomes the intent for the next movement
/// window. The sim executes at most one intent per window, so holding a key walks
/// at the movement cadence by construction. Steps are validated with the shared
/// movement rule; a blocked route step triggers a replan toward the goal.
fn step_movement(&mut self, input: &InputState) {
// Both axes combine, so two held keys walk diagonally (a king move). A tap
// shorter than a frame shows up as pressed only.
let axis = |neg: GameAction, pos: GameAction| {
(input.button_held(pos) || input.button_pressed(pos)) as i32
- (input.button_held(neg) || input.button_pressed(neg)) as i32
};
let kx = axis(GameAction::Left, GameAction::Right);
let ky = axis(GameAction::Up, GameAction::Down);
let dir = (kx != 0 || ky != 0).then_some((kx, ky));
if dir.is_some() {
// Manual input overrides click-to-move entirely, goal included.
self.route.clear();
self.goal = None;
}
let from = self.player_pos();
let step = match dir {
Some((dx, dy)) => Some((from.0 + dx, from.1 + dy)),
None => self.route.front().copied(),
};
let Some((nx, ny)) = step else { return };
if step_allowed(from, (nx, ny), |x, y| self.tile_blocked(x, y)) {
self.sim.set_action(self.player_id, delta_action(nx - from.0, ny - from.1));
} else if dir.is_none() {
self.replan_route();
}
}
/// After a movement window: drop the route head once the player stands on it, and
/// release the goal on arrival.
fn sync_route(&mut self) {
let pos = self.player_pos();
if self.route.front() == Some(&pos) {
self.route.pop_front();
}
if self.route.is_empty() && self.goal == Some(pos) {
self.goal = None;
}
}
/// Replan the route from the current position toward the persistent goal — the
/// reaction to a blocked step. Gives the goal up only when it is reached or has
/// become unreachable.
fn replan_route(&mut self) {
self.route.clear();
let Some(goal) = self.goal else { return };
let pos = self.player_pos();
if pos == goal {
self.goal = None;
return;
}
match pathfind::find_path(pos, goal, |x, y| self.tile_blocked(x, y)) {
Some(steps) => self.route = steps.into(),
None => self.goal = None,
}
}
/// Fold the sim's entity positions into the interpolation table: a changed position
/// starts a new lerp from the old one, a jump of more than one tile (Chebyshev — a
/// teleport) snaps, and vanished entities are dropped.
fn track_lerp(&mut self) {
let entities = &self.sim.world.entities;
for e in entities.values() {
let cur = (e.pos.0 as i32, e.pos.1 as i32);
self.lerp.entry(e.id)
.and_modify(|l| {
if l.cur != cur {
let far = (cur.0 - l.cur.0).abs().max((cur.1 - l.cur.1).abs()) > 1;
l.prev = if far { cur } else { l.cur };
l.cur = cur;
l.t_ms = 0;
}
})
.or_insert(EntityLerp { prev: cur, cur, t_ms: 0 });
}
self.lerp.retain(|id, _| entities.contains_key(id));
}
/// Advance all interpolation clocks; each lerp completes after one movement interval.
fn step_lerp(&mut self, dt: usize) {
for l in self.lerp.values_mut() {
l.t_ms = (l.t_ms + dt).min(MOVE_INTERVAL_MS);
}
}
/// An entity's render position in world pixels: between its previous and current
/// tile, proportional to the time since the current one was taken.
fn entity_px(&self, e: &Entity) -> (i32, i32) {
let cur = (e.pos.0 as i32 * TILE_PX, e.pos.1 as i32 * TILE_PX);
match self.lerp.get(&e.id) {
Some(l) => {
let f = l.t_ms as f32 / MOVE_INTERVAL_MS as f32;
let mix = |a: i32, b: i32| a + ((b - a) as f32 * f).round() as i32;
(mix(l.prev.0 * TILE_PX, cur.0), mix(l.prev.1 * TILE_PX, cur.1))
}
None => cur,
}
}
fn render_viewport(&self, frame: &mut [u8]) {
const WALL: u8 = 0x00;
const W: usize = 320;
frame.fill(0);
let (cx, cy) = self.cam_px();
let tx0 = cx.div_euclid(TILE_PX);
let ty0 = cy.div_euclid(TILE_PX);
let sx = -cx.rem_euclid(TILE_PX);
let sy = -cy.rem_euclid(TILE_PX);
// One extra row and column: with a sub-tile camera offset the viewport spans
// partial tiles on both edges.
for vy in 0..=VIEW_TILES {
for vx in 0..=VIEW_TILES {
let wx = tx0 + vx;
let wy = ty0 + vy;
let (ccx, ccy) = sim::world::tile_to_chunk(wx as i16, wy as i16);
let (lx, ly) = sim::world::tile_to_local(wx as i16, wy as i16);
let tile_id = self.sim.world.chunk(ccx, ccy)
.map(|c| c.get_tile(lx, ly).tile_id)
.unwrap_or(0);
let px = sx + vx * TILE_PX;
let py = sy + vy * TILE_PX;
if let Some(tile) = self.tileset.get(tile_id as usize) {
pixelhelper::blit_tile(frame, W, px, py, tile);
} else {
for dy in 0..8 {
for dx in 0..8 {
pixelhelper::set_pixel(frame, W, px + dx, py + dy, WALL);
}
}
}
}
}
// Route pass — over the world, under entities.
for &(wx, wy) in &self.route {
let px = wx * TILE_PX - cx;
let py = wy * TILE_PX - cy;
if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; }
for dy in 0..8 {
for dx in 0..8 {
pixelhelper::set_pixel(frame, W, px + dx, py + dy, ROUTE_COLOR);
}
}
}
// Entity pass — interpolated between the last two tiles.
for e in self.sim.world.entities.values() {
let (ex, ey) = self.entity_px(e);
let px = ex - cx;
let py = ey - cy;
if px <= -TILE_PX || px >= VIEW_PX || py <= -TILE_PX || py >= VIEW_PX { continue; }
if let Some(tile) = self.entity_tileset.get(e.type_id as usize) {
pixelhelper::blit_tile(frame, W, px, py, tile);
}
}
// The blit primitives clip against the framebuffer, not the viewport, so partial
// tiles on the right edge bleed into the 240..320 strip. Clear it; a real
// clip-rect belongs to the UI pass (see roadmap).
for y in 0..240usize {
frame[y * W + VIEW_PX as usize..y * W + W].fill(0);
}
}
}
+166
View File
@@ -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);
}
}
+71
View File
@@ -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;
}
}
}
}
+122
View File
@@ -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;
}
}
+68
View File
@@ -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();
}
}