Add bitmap font, UI primitives and a HUD with message log
Ports the CP437 font renderer from wds to the framebuffer: proportional advance from measured ink bounds, luminance-keyed mask, word wrap. The right 80 px strip becomes a HUD with name, HP bar, population and a log fed from sim events; Attacked/Died now carry the type ids the log needs, since the victim is gone from the world by the time events are read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+114
-37
@@ -1,8 +1,10 @@
|
||||
mod pixelhelper;
|
||||
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;
|
||||
@@ -52,8 +54,20 @@ const SPRITE_PLAYER: u16 = 0;
|
||||
const SPRITE_HEN: u16 = 1;
|
||||
const SPRITE_FOX: u16 = 2;
|
||||
|
||||
/// Interval of the population report on stdout.
|
||||
const REPORT_MS: usize = 5000;
|
||||
/// 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 = 9;
|
||||
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
|
||||
@@ -67,6 +81,7 @@ struct EntityLerp {
|
||||
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.
|
||||
@@ -85,16 +100,15 @@ pub struct Game {
|
||||
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
|
||||
/// logic keeps using the sim's tile positions.
|
||||
lerp: HashMap<u32, EntityLerp>,
|
||||
/// Running totals for the population report.
|
||||
attacks: u32,
|
||||
deaths: u32,
|
||||
report_ms: usize,
|
||||
/// 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/cga-8x8.png", 8, 8);
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let map = TileMap::from_csv(map_path);
|
||||
@@ -125,6 +139,7 @@ impl Game {
|
||||
let mut game = Game {
|
||||
tileset,
|
||||
entity_tileset,
|
||||
font,
|
||||
sim,
|
||||
player_id,
|
||||
tick_accum_ms: 0.0,
|
||||
@@ -132,9 +147,7 @@ impl Game {
|
||||
goal: None,
|
||||
cam: (0.0, 0.0),
|
||||
lerp: HashMap::new(),
|
||||
attacks: 0,
|
||||
deaths: 0,
|
||||
report_ms: 0,
|
||||
log: VecDeque::new(),
|
||||
};
|
||||
game.cam = game.cam_target();
|
||||
game.track_lerp();
|
||||
@@ -179,45 +192,53 @@ impl Game {
|
||||
self.step_lerp(dt);
|
||||
self.step_camera(dt);
|
||||
self.render_viewport(render_frame);
|
||||
|
||||
self.report_ms += dt;
|
||||
if self.report_ms >= REPORT_MS {
|
||||
self.report_ms = 0;
|
||||
self.print_report();
|
||||
}
|
||||
self.render_hud(render_frame);
|
||||
None
|
||||
}
|
||||
|
||||
fn print_report(&self) {
|
||||
let count = |sprite| self.sim.world.entities.values().filter(|e| e.type_id == sprite).count();
|
||||
println!("hens: {:3} foxes: {:2} attacks: {:4} deaths: {:3}",
|
||||
count(SPRITE_HEN), count(SPRITE_FOX), self.attacks, self.deaths);
|
||||
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 the player's own fights are logged; the rest is tallied for the report.
|
||||
// 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, damage } => {
|
||||
self.attacks += 1;
|
||||
if by == self.player_id || target == self.player_id {
|
||||
println!("#{by} hits #{target} for {damage}");
|
||||
}
|
||||
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, at } => {
|
||||
self.deaths += 1;
|
||||
if id == self.player_id {
|
||||
dead = true;
|
||||
} else {
|
||||
println!("#{id} dies at {at:?}");
|
||||
}
|
||||
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
|
||||
@@ -486,6 +507,57 @@ impl Game {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -502,12 +574,13 @@ mod snapshot {
|
||||
for _ in 0..secs * 60 {
|
||||
g.update(&mut frame, 16, &input);
|
||||
}
|
||||
g.print_report();
|
||||
println!("after {secs}s: {} hens, {} foxes", g.count(super::SPRITE_HEN), g.count(super::SPRITE_FOX));
|
||||
}
|
||||
|
||||
/// Renders the first frame headlessly to the PNG named by `$SNAPSHOT` — a way to
|
||||
/// eyeball the renderer without a window. `$SNAPSHOT_MAP` picks the map:
|
||||
/// `SNAPSHOT=/tmp/frame.png cargo test -p game snapshot -- --ignored`
|
||||
/// 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() {
|
||||
@@ -516,6 +589,10 @@ mod snapshot {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user