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>
610 lines
24 KiB
Rust
Executable File
610 lines
24 KiB
Rust
Executable File
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();
|
||
}
|
||
}
|