Files
forgotten_caves/game/src/game.rs
T
irrlichtandClaude Opus 5 335962800f Reorganize assets; switch the HUD to the Micro Chat font
Source packs move to assets/packs (untouched), maps to assets/maps, and the
palette/sketch leftovers from the 8-bit era go. tools/mkfont.py repacks
VEXED's Micro Chat sheet (8-column grid, 8x10 cells) into the CP437 atlas
layout the font loader already reads, cropped to 8x9 so the HUD can use an
8 px line height. The CGA atlas is dropped in its favor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 19:47:33 +02:00

610 lines
24 KiB
Rust
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
pub(crate) mod pixelhelper;
use std::collections::{HashMap, VecDeque};
use crate::assets::{Image, Tile};
use crate::font::Font;
use crate::ui;
use crate::input::{GameAction, InputState};
use sim::entity::{Brain, Entity, EntityKind, Prey};
use sim::map::TileMap;
use sim::{delta_action, load_world, step_allowed, Event, 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;
/// Color of the planned route: dim blue.
const ROUTE_COLOR: u16 = pbio::rgb565(0, 0, 170);
/// Fill for tiles outside the tileset / world.
const VOID_COLOR: u16 = pbio::rgb565(0, 0, 0);
/// 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 smoothing time constant: each frame the camera closes this fraction
/// `1 - exp(-dt/tau)` of the remaining distance to its target, so it eases in and
/// out instead of chasing at constant speed. Smaller = snappier, larger = floatier.
const CAM_SMOOTH_TAU_MS: f32 = 120.0;
/// A position correction farther than this is a teleport — snap instead of easing.
const CAM_SNAP_PX: f32 = 96.0;
/// Animals scattered over the map at start, one per free tile picked at random.
const HEN_COUNT: usize = 150;
const FOX_COUNT: usize = 6;
/// Sprite indices in `entities.png`.
const SPRITE_PLAYER: u16 = 0;
const SPRITE_HEN: u16 = 1;
const SPRITE_FOX: u16 = 2;
/// HUD panel: the 80 px strip right of the viewport.
const HUD_X: i32 = VIEW_PX;
const HUD_W: i32 = 320 - VIEW_PX;
const HUD_PAD: i32 = 3;
const LINE_H: i32 = 8;
const HUD_BG: u16 = pbio::rgb565(16, 12, 24);
const HUD_FRAME: u16 = pbio::rgb565(120, 100, 140);
const HUD_TEXT: u16 = pbio::rgb565(235, 235, 255);
const HUD_DIM: u16 = pbio::rgb565(140, 140, 160);
const HP_FG: u16 = pbio::rgb565(200, 40, 40);
const HP_BG: u16 = pbio::rgb565(60, 20, 20);
/// Lines kept in the message log; only as many as fit the panel are shown.
const LOG_LINES: usize = 40;
/// 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<Tile>,
entity_tileset: Vec<Tile>,
font: Font,
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>,
/// Message log, newest last.
log: VecDeque<String>,
}
impl Game {
pub fn start(map_path: &str) -> Self {
let tileset = Image::from_png("assets/tilesets/overworld.png").to_tileset();
let entity_tileset = Image::from_png("assets/tilesets/entities.png").to_tileset();
let font = Font::from_png("assets/fonts/micro-chat-8x9.png", 8, 9);
let t0 = std::time::Instant::now();
let map = TileMap::from_csv(map_path);
let world = load_world(&map);
println!("loaded {map_path}: {}×{} tiles in {:.1} ms",
map.width, map.height, t0.elapsed().as_secs_f64() * 1e3);
let mut sim = Sim::new(world);
let player_id = sim.world.spawn_entity(
EntityKind::Player, SPRITE_PLAYER, Brain::Idle, (0, 0), 100, 3);
// Scatter animals over free tiles. A cheap LCG is plenty for placement.
let mut seed: u32 = 12345;
let mut scatter = |sim: &mut Sim, count: usize, sprite: u16, brain: Brain, hp: u16, attack: u16| {
let mut placed = 0;
while placed < count {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let x = ((seed >> 8) % map.width as u32) as i32;
let y = ((seed >> 20) % map.height as u32) as i32;
if !sim.world.blocked(x, y) {
sim.world.spawn_entity(EntityKind::Npc, sprite, brain, (x as i16, y as i16), hp, attack);
placed += 1;
}
}
};
scatter(&mut sim, HEN_COUNT, SPRITE_HEN, Brain::Wander, 5, 1);
scatter(&mut sim, FOX_COUNT, SPRITE_FOX, Brain::Hunt(Prey::Kind(SPRITE_HEN)), 8, 2);
let mut game = Game {
tileset,
entity_tileset,
font,
sim,
player_id,
tick_accum_ms: 0.0,
route: VecDeque::new(),
goal: None,
cam: (0.0, 0.0),
lerp: HashMap::new(),
log: VecDeque::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)
}
pub fn update(&mut self, render_frame: &mut [u16], 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() {
if self.handle_events() {
return Some(GameSignal::Quit);
}
self.track_lerp();
self.sync_route();
self.step_movement(input);
}
}
if input.button_pressed(GameAction::Cancel) {
println!("Goodbye!");
return Some(GameSignal::Quit);
}
self.step_lerp(dt);
self.step_camera(dt);
self.render_viewport(render_frame);
self.render_hud(render_frame);
None
}
fn count(&self, sprite: u16) -> usize {
self.sim.world.entities.values().filter(|e| e.type_id == sprite).count()
}
fn push_log(&mut self, line: String) {
if self.log.len() == LOG_LINES {
self.log.pop_front();
}
self.log.push_back(line);
}
/// How an entity is referred to in the log. Ids only carry meaning for debugging.
fn name(&self, id: u32, type_id: u16) -> &'static str {
if id == self.player_id { "you" } else { species(type_id) }
}
/// React to what the sim reported for the last window. Returns `true` once the
/// player is dead — the one event the game cannot continue past.
fn handle_events(&mut self) -> bool {
let mut dead = false;
// Only fights involving the player make the log; the hens' own drama is
// visible on the map.
for ev in self.sim.take_events() {
match ev {
Event::Attacked { by, target, target_type, damage }
if by == self.player_id || target == self.player_id =>
{
// The attacker may itself have died later in the same window.
let by_type = self.sim.world.entities.get(&by).map_or(u16::MAX, |e| e.type_id);
let verb = if by == self.player_id { "hit" } else { "hits" };
let line = format!("{} {verb} {} ({damage})",
self.name(by, by_type), self.name(target, target_type));
self.push_log(line);
}
Event::Died { id, .. } if id == self.player_id => dead = true,
Event::Died { type_id, by, .. } if by == self.player_id => {
self.push_log(format!("you kill the {}", species(type_id)));
}
Event::Attacked { .. } | Event::Died { .. } => {}
}
}
if dead {
self.push_log("You died.".into());
println!("You died.");
}
dead
}
/// Where the camera wants to be: the viewport centered on the player's rendered
/// (interpolated) position, not the coarser sim tile — otherwise the target itself
/// jumps in discrete per-tile steps and the easing below has nothing smooth to chase.
fn cam_target(&self) -> (f32, f32) {
let player = &self.sim.world.entities[&self.player_id];
let (px, py) = self.entity_px(player);
(
(px - VIEW_PX / 2) as f32,
(py - 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)
}
/// Ease toward the target: each frame closes `1 - exp(-dt/tau)` of the remaining
/// gap, so the camera eases out of a stop and eases into a catch-up rather than
/// tracking at constant speed. Corrections beyond `CAM_SNAP_PX` (teleports,
/// respawns) snap outright instead of easing 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 f = 1.0 - (-(dt as f32) / CAM_SMOOTH_TAU_MS).exp();
self.cam = (self.cam.0 + (tx - self.cam.0) * f, self.cam.1 + (ty - self.cam.1) * f);
}
/// 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 self.plan(goal) {
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 };
// A keyboard step or the route's final step may bump into an entity (the sim
// turns that into an attack); an entity blocking the route midway is walked
// around instead.
let may_bump = dir.is_some() || self.goal == Some((nx, ny));
let legal = step_allowed(from, (nx, ny), |x, y| {
if may_bump && (x, y) == (nx, ny) { self.sim.world.solid(x, y) }
else { self.sim.world.blocked(x, y) }
});
if legal {
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 self.plan(goal) {
Some(steps) => self.route = steps.into(),
None => self.goal = None,
}
}
/// Route from the player to `goal`. An entity standing on the goal does not make
/// it unreachable — the route ends with a bump into it — but entities along the
/// way are walked around.
fn plan(&self, goal: (i32, i32)) -> Option<Vec<(i32, i32)>> {
if self.sim.world.solid(goal.0, goal.1) {
return None;
}
sim::pathfind::find_path(self.player_pos(), goal, |x, y| {
(x, y) != goal && self.sim.world.blocked(x, y)
})
}
/// 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 [u16]) {
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))
.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, VOID_COLOR);
}
}
}
}
}
// 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);
}
}
}
fn species(type_id: u16) -> &'static str {
match type_id {
SPRITE_HEN => "hen",
SPRITE_FOX => "fox",
_ => "something",
}
}
impl Game {
fn render_hud(&self, frame: &mut [u16]) {
const W: usize = 320;
ui::fill_rect(frame, W, HUD_X, 0, HUD_W, 240, HUD_BG);
ui::draw_rect(frame, W, HUD_X, 0, HUD_W, 240, HUD_FRAME);
let x = HUD_X + HUD_PAD + 1;
let inner_w = HUD_W - 2 * (HUD_PAD + 1);
let mut y = HUD_PAD + 1;
let player = &self.sim.world.entities[&self.player_id];
self.font.draw(frame, W, x, y, HUD_TEXT, "Adventurer");
y += LINE_H;
ui::draw_hbar(frame, W, x, y, inner_w, 5,
player.hp as u32, player.hp_max as u32, HP_FG, HP_BG);
y += 7;
let hp = format!("{}/{}", player.hp, player.hp_max);
self.font.draw(frame, W, x, y, HUD_DIM, &hp);
y += LINE_H;
for (label, sprite) in [("hens", SPRITE_HEN), ("foxes", SPRITE_FOX)] {
let n = self.count(sprite).to_string();
self.font.draw(frame, W, x, y, HUD_DIM, label);
self.font.draw(frame, W, x + inner_w - self.font.text_width(&n), y, HUD_DIM, &n);
y += LINE_H;
}
y += 2;
// Divider, then the log filling the rest bottom-up so the newest line is
// always visible.
ui::fill_rect(frame, W, x, y, inner_w, 1, HUD_FRAME);
y += 3;
let lines: Vec<String> = self.log.iter()
.flat_map(|l| self.font.wrap(l, inner_w))
.collect();
let fit = ((240 - HUD_PAD - 1 - y) / LINE_H) as usize;
for line in lines.iter().rev().take(fit).rev() {
self.font.draw(frame, W, x, y, HUD_TEXT, line);
y += LINE_H;
}
}
}
#[cfg(test)]
mod snapshot {
/// Runs the world headlessly for `$SECONDS` (default 60) of sim time and prints the
/// population report — to watch the hens without a window:
/// `cargo test -p game population -- --ignored --nocapture`
#[test]
#[ignore]
fn population() {
std::env::set_current_dir("..").unwrap();
let secs: usize = std::env::var("SECONDS").ok().and_then(|s| s.parse().ok()).unwrap_or(60);
let mut g = super::Game::start("assets/maps/map_test");
let input = crate::input::InputState::new();
let mut frame = vec![0u16; 320 * 240];
for _ in 0..secs * 60 {
g.update(&mut frame, 16, &input);
}
println!("after {secs}s: {} hens, {} foxes", g.count(super::SPRITE_HEN), g.count(super::SPRITE_FOX));
}
/// Renders a frame headlessly to the PNG named by `$SNAPSHOT` — a way to eyeball
/// the renderer without a window. `$SNAPSHOT_MAP` picks the map, `$SECONDS` how
/// much sim time passes first (default 0):
/// `SNAPSHOT=/tmp/frame.png cargo test -p game dump_frame -- --ignored`
#[test]
#[ignore]
fn dump_frame() {
std::env::set_current_dir("..").unwrap();
let map = std::env::var("SNAPSHOT_MAP").unwrap_or_else(|_| "assets/maps/map_test".into());
let mut g = super::Game::start(&map);
let input = crate::input::InputState::new();
let mut frame = vec![0u16; 320 * 240];
let secs: usize = std::env::var("SECONDS").ok().and_then(|s| s.parse().ok()).unwrap_or(0);
for _ in 0..secs * 60 {
g.update(&mut frame, 16, &input);
}
g.update(&mut frame, 0, &input);
let out = std::env::var("SNAPSHOT").unwrap();
let mut rgb = Vec::with_capacity(320 * 240 * 3);
for p in &frame {
rgb.push(((p >> 11) as u8) << 3);
rgb.push((((p >> 5) & 63) as u8) << 2);
rgb.push(((p & 31) as u8) << 3);
}
let f = std::fs::File::create(out).unwrap();
let mut enc = png::Encoder::new(std::io::BufWriter::new(f), 320, 240);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&rgb).unwrap();
}
}