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:
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,184 @@
|
||||
//! CP437 bitmap font with proportional advance.
|
||||
//!
|
||||
//! The atlas is 32 columns × 8 rows of fixed cells, codepoint = index (`A` at 65 →
|
||||
//! column 1, row 2). Glyphs are white on black; the mask is derived from luminance
|
||||
//! on load, so the caller paints it in any color.
|
||||
//!
|
||||
//! Proportional width: each glyph's ink span (leftmost/rightmost lit column) is
|
||||
//! measured on load. Drawing crops to that span and advances by ink width plus
|
||||
//! tracking; blank glyphs (space) get a fixed advance. Ported from the `wds` project.
|
||||
|
||||
use crate::assets::Image;
|
||||
use crate::game::pixelhelper::set_pixel;
|
||||
|
||||
const COLS: usize = 32;
|
||||
|
||||
pub struct Font {
|
||||
pub glyph_w: usize,
|
||||
pub glyph_h: usize,
|
||||
atlas_w: usize,
|
||||
/// One flag per atlas pixel: `true` = ink.
|
||||
mask: Vec<bool>,
|
||||
/// Ink start column inside the cell, per codepoint.
|
||||
left: [u8; 256],
|
||||
/// Ink width per codepoint (0 = blank).
|
||||
width: [u8; 256],
|
||||
tracking: i32,
|
||||
space_adv: i32,
|
||||
}
|
||||
|
||||
impl Font {
|
||||
/// Load a 256-glyph atlas with cells of `glyph_w`×`glyph_h`.
|
||||
pub fn from_png(path: &str, glyph_w: usize, glyph_h: usize) -> Font {
|
||||
let img = Image::from_png(path);
|
||||
assert_eq!(img.width as usize, COLS * glyph_w, "{path}: atlas width");
|
||||
assert_eq!(img.height as usize, 256 / COLS * glyph_h, "{path}: atlas height");
|
||||
let mask: Vec<bool> = img.pixels.iter().map(|&p| p != 0).collect();
|
||||
|
||||
let mut left = [0u8; 256];
|
||||
let mut width = [0u8; 256];
|
||||
for code in 0..256 {
|
||||
let (cx, cy) = ((code % COLS) * glyph_w, (code / COLS) * glyph_h);
|
||||
let (mut lo, mut hi) = (glyph_w, 0);
|
||||
for y in 0..glyph_h {
|
||||
for x in 0..glyph_w {
|
||||
if mask[(cy + y) * img.width as usize + cx + x] {
|
||||
lo = lo.min(x);
|
||||
hi = hi.max(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
if lo <= hi {
|
||||
left[code] = lo as u8;
|
||||
width[code] = (hi - lo + 1) as u8;
|
||||
}
|
||||
}
|
||||
Font {
|
||||
glyph_w,
|
||||
glyph_h,
|
||||
atlas_w: img.width as usize,
|
||||
mask,
|
||||
left,
|
||||
width,
|
||||
tracking: 1,
|
||||
space_adv: (glyph_w / 2) as i32,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the cursor moves after this glyph.
|
||||
fn advance(&self, code: u8) -> i32 {
|
||||
match self.width[code as usize] {
|
||||
0 => self.space_adv,
|
||||
w => w as i32 + self.tracking,
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of a string in pixels.
|
||||
pub fn text_width(&self, s: &str) -> i32 {
|
||||
s.chars().map(|ch| self.advance(cp437(ch))).sum()
|
||||
}
|
||||
|
||||
/// Draw one line of text with its top-left at `(x, y)`, clipped against the
|
||||
/// framebuffer. Returns the x position after the last glyph.
|
||||
pub fn draw(&self, frame: &mut [u16], frame_w: usize, x: i32, y: i32, color: u16, s: &str) -> i32 {
|
||||
let mut cx = x;
|
||||
for ch in s.chars() {
|
||||
let code = cp437(ch) as usize;
|
||||
let w = self.width[code] as usize;
|
||||
if w > 0 {
|
||||
let ax = (code % COLS) * self.glyph_w + self.left[code] as usize;
|
||||
let ay = (code / COLS) * self.glyph_h;
|
||||
for gy in 0..self.glyph_h {
|
||||
for gx in 0..w {
|
||||
if self.mask[(ay + gy) * self.atlas_w + ax + gx] {
|
||||
set_pixel(frame, frame_w, cx + gx as i32, y + gy as i32, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += self.advance(code as u8);
|
||||
}
|
||||
cx
|
||||
}
|
||||
|
||||
/// Break `text` into lines no wider than `max_w`. `\n` is a hard break; a single
|
||||
/// word wider than `max_w` overflows on its own line instead of looping.
|
||||
pub fn wrap(&self, text: &str, max_w: i32) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
for para in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
for word in para.split_whitespace() {
|
||||
let trial = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if line.is_empty() || self.text_width(&trial) <= max_w {
|
||||
line = trial;
|
||||
} else {
|
||||
lines.push(std::mem::replace(&mut line, word.to_string()));
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a char to its CP437 codepoint. ASCII is identity; the accented letters the
|
||||
/// atlas has get their slots, everything else becomes `?`.
|
||||
pub fn cp437(ch: char) -> u8 {
|
||||
match ch {
|
||||
c if (c as u32) < 128 => c as u8,
|
||||
'Ç' => 128, 'ü' => 129, 'é' => 130, 'ä' => 132, 'à' => 133,
|
||||
'å' => 134, 'ç' => 135, 'ë' => 137, 'è' => 138, 'ï' => 139,
|
||||
'î' => 140, 'ì' => 141, 'Ä' => 142, 'Å' => 143, 'É' => 144,
|
||||
'ö' => 148, 'ò' => 149, 'û' => 150, 'ù' => 151, 'Ö' => 153,
|
||||
'Ü' => 154, 'á' => 160, 'í' => 161, 'ó' => 162, 'ú' => 163,
|
||||
'ñ' => 164, 'Ñ' => 165, 'ß' => 225, '°' => 248,
|
||||
_ => b'?',
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cga() -> Font {
|
||||
Font::from_png(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/fonts/cga-8x8.png"), 8, 8)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_ink_and_advances_proportionally() {
|
||||
let f = cga();
|
||||
assert_eq!(f.width[b' ' as usize], 0);
|
||||
assert!(f.width[b'i' as usize] < f.width[b'M' as usize]);
|
||||
assert_eq!(f.text_width(" "), 4);
|
||||
assert_eq!(f.text_width("ii"), 2 * (f.width[b'i' as usize] as i32 + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draws_only_ink_in_the_given_color() {
|
||||
let f = cga();
|
||||
let mut frame = vec![0u16; 32 * 8];
|
||||
let end = f.draw(&mut frame, 32, 0, 0, 0xFFFF, "I");
|
||||
assert_eq!(end, f.width[b'I' as usize] as i32 + 1);
|
||||
let lit = frame.iter().filter(|&&p| p == 0xFFFF).count();
|
||||
assert!(lit > 0 && lit < 64);
|
||||
// Nothing painted beyond the ink span.
|
||||
assert!(frame.iter().enumerate().all(|(i, &p)| p == 0 || (i % 32) < end as usize));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_on_width_and_hard_newlines() {
|
||||
let f = cga();
|
||||
let w3 = f.text_width("aaa");
|
||||
assert_eq!(f.wrap("aaa bbb ccc", w3), vec!["aaa", "bbb", "ccc"]);
|
||||
assert_eq!(f.wrap("aaa bbb ccc", f.text_width("aaa bbb")), vec!["aaa bbb", "ccc"]);
|
||||
assert_eq!(f.wrap("a\nb", 999), vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cp437_maps_ascii_and_umlauts() {
|
||||
assert_eq!(cp437('A'), 65);
|
||||
assert_eq!(cp437('ä'), 132);
|
||||
assert_eq!(cp437('ß'), 225);
|
||||
assert_eq!(cp437('世'), b'?');
|
||||
}
|
||||
}
|
||||
+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);
|
||||
|
||||
@@ -2,8 +2,10 @@ use pbio::{Event, MouseButton, Platform, PlatformConfig};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod assets;
|
||||
mod font;
|
||||
mod game;
|
||||
mod input;
|
||||
mod ui;
|
||||
|
||||
fn main() {
|
||||
let mut plat = Platform::new(PlatformConfig {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Procedural UI primitives on the RGB565 framebuffer. Rectangles are `(x, y, w, h)`
|
||||
//! in framebuffer pixels, clipped against its edges.
|
||||
|
||||
use crate::game::pixelhelper::set_pixel;
|
||||
|
||||
pub fn fill_rect(frame: &mut [u16], frame_w: usize, x: i32, y: i32, w: i32, h: i32, color: u16) {
|
||||
let frame_h = (frame.len() / frame_w) as i32;
|
||||
let (x0, x1) = (x.max(0), (x + w).min(frame_w as i32));
|
||||
let (y0, y1) = (y.max(0), (y + h).min(frame_h));
|
||||
for row in y0..y1 {
|
||||
let start = row as usize * frame_w;
|
||||
frame[start + x0 as usize..start + x1.max(x0) as usize].fill(color);
|
||||
}
|
||||
}
|
||||
|
||||
/// One-pixel outline just inside `(x, y, w, h)`.
|
||||
pub fn draw_rect(frame: &mut [u16], frame_w: usize, x: i32, y: i32, w: i32, h: i32, color: u16) {
|
||||
for i in 0..w {
|
||||
set_pixel(frame, frame_w, x + i, y, color);
|
||||
set_pixel(frame, frame_w, x + i, y + h - 1, color);
|
||||
}
|
||||
for i in 0..h {
|
||||
set_pixel(frame, frame_w, x, y + i, color);
|
||||
set_pixel(frame, frame_w, x + w - 1, y + i, color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal bar: `bg` across the full width, `fg` for the `value / max` share.
|
||||
pub fn draw_hbar(
|
||||
frame: &mut [u16], frame_w: usize,
|
||||
x: i32, y: i32, w: i32, h: i32,
|
||||
value: u32, max: u32, fg: u16, bg: u16,
|
||||
) {
|
||||
fill_rect(frame, frame_w, x, y, w, h, bg);
|
||||
if max > 0 {
|
||||
let filled = (w as i64 * value.min(max) as i64 / max as i64) as i32;
|
||||
fill_rect(frame, frame_w, x, y, filled, h, fg);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fill_rect_clips_to_frame() {
|
||||
let mut f = vec![0u16; 4 * 4];
|
||||
fill_rect(&mut f, 4, -1, -1, 3, 3, 1);
|
||||
assert_eq!(f, [1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
fill_rect(&mut f, 4, 3, 3, 10, 10, 2);
|
||||
assert_eq!(f[15], 2);
|
||||
assert_eq!(f[14], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hbar_fills_proportionally() {
|
||||
let mut f = vec![0u16; 10];
|
||||
draw_hbar(&mut f, 10, 0, 0, 10, 1, 3, 10, 1, 2);
|
||||
assert_eq!(f, [1, 1, 1, 2, 2, 2, 2, 2, 2, 2]);
|
||||
draw_hbar(&mut f, 10, 0, 0, 10, 1, 50, 10, 1, 2); // over max clamps
|
||||
assert!(f.iter().all(|&p| p == 1));
|
||||
}
|
||||
}
|
||||
+13
-22
@@ -2,29 +2,20 @@
|
||||
|
||||
## Current state
|
||||
|
||||
No text rendering exists yet. No font loading or glyph blitting code exists.
|
||||
`game/src/font.rs` — a CP437 bitmap font ported from the `wds` project.
|
||||
|
||||
## Goal
|
||||
|
||||
Draw ASCII text into the RGB565 framebuffer using a bitmap font.
|
||||
|
||||
## Design notes
|
||||
|
||||
- A bitmap font is the right fit: each glyph is a small masked tile, blitted
|
||||
directly into the framebuffer using the existing blit primitive in `pixelhelper.rs`.
|
||||
- Likely approach: a fixed 8×8 or similar glyph grid packed into a PNG,
|
||||
one glyph per ASCII codepoint starting at 0x20 (space).
|
||||
- The font image only needs its alpha mask: glyph pixels are opaque, background transparent;
|
||||
the caller supplies the color and `draw_text` paints the mask with it.
|
||||
- API sketch:
|
||||
```rust
|
||||
fn draw_text(frame: &mut [u16], font: &Font, x: i32, y: i32, color: u16, text: &str);
|
||||
```
|
||||
- `Font` holds the glyph sheet as an `Image` plus glyph width/height.
|
||||
- Atlas: 32 columns × 8 rows of fixed cells, codepoint = index (`A` at 65 → column 1,
|
||||
row 2). Loaded with `Image::from_png`; the mask is derived from luminance (white on
|
||||
black), so the caller paints glyphs in any RGB565 color.
|
||||
- Proportional advance: on load the ink span of every glyph (leftmost/rightmost lit
|
||||
column) is measured. `draw` crops to that span and advances by ink width + 1 px
|
||||
tracking; blank glyphs (space) advance half a cell.
|
||||
- `cp437(char)` maps ASCII 1:1 and the accented letters the atlas has (ä ö ü ß …);
|
||||
everything else becomes `?`.
|
||||
- `text_width` and `wrap` (word wrap with hard `\n`) for layout.
|
||||
- Font in use: `assets/fonts/cga-8x8.png` (Oldschool PC Font Pack, CGA 8×8).
|
||||
|
||||
## Open questions
|
||||
|
||||
- How do we handle proportional spacing of different characters?
|
||||
- How do we store font data in memory?
|
||||
- How do we handle font color rendering?
|
||||
- How do we handle special characters like Ä, Ö, Ü, ß, etc.?
|
||||
- A second, smaller or larger font (the EGA 8×14 from `wds`) once the UI needs hierarchy.
|
||||
- Text color per span / inline markup for the log — not before it is needed.
|
||||
|
||||
+12
-27
@@ -2,35 +2,20 @@
|
||||
|
||||
## Current state
|
||||
|
||||
No UI primitives exist. The framebuffer is currently filled by the game world only
|
||||
(`render_viewport` in `game.rs`: tile pass + entity sprite pass). `pixelhelper.rs` has
|
||||
low-level pixel/blit helpers but nothing higher-level.
|
||||
`game/src/ui.rs` — procedural primitives on the RGB565 framebuffer, all clipped:
|
||||
`fill_rect`, `draw_rect` (1 px outline), `draw_hbar` (value/max bar).
|
||||
|
||||
## Goal
|
||||
The HUD (`Game::render_hud` in `game.rs`) lives in the 80 px strip right of the 240×240
|
||||
viewport: player name, HP bar, population counters, a divider, then the message log
|
||||
filling the rest bottom-up (newest line always visible, lines wrapped to the panel).
|
||||
The log is a `VecDeque<String>` fed from `Sim` events in `Game::handle_events`.
|
||||
|
||||
Draw common roguelike UI elements (panels, borders, HUD bars, etc.) procedurally into the
|
||||
`u8` framebuffer, composited on top of the game world.
|
||||
|
||||
## Design notes
|
||||
|
||||
The virtual resolution is 320×240, so UI layout should be designed in those pixel units.
|
||||
|
||||
Likely primitives needed (built on top of `pixelhelper.rs`):
|
||||
|
||||
- `fill_rect(frame, x, y, w, h, color)` — solid filled rectangle
|
||||
- `draw_rect(frame, x, y, w, h, color)` — 1-pixel border rectangle
|
||||
- `draw_border_box(frame, x, y, w, h, tileset)` — box drawn with corner/edge tiles from
|
||||
a tileset (classic roguelike panel look)
|
||||
- `draw_hbar(frame, x, y, w, value, max, fg, bg)` — horizontal progress/HP bar
|
||||
|
||||
Panels are typically fixed regions of the screen (e.g. a status bar at the bottom 40px,
|
||||
a message log on the right). Hard-coding these regions first is fine; extract to a layout
|
||||
system only if needed.
|
||||
|
||||
The UI layer draws after the world layer so it always appears on top. Draw order within
|
||||
the UI should be back-to-front (backgrounds before text).
|
||||
Draw order: world, then HUD. The HUD overwrites the right-edge bleed of the tile pass,
|
||||
which is why there is still no clip-rect on the blit primitives.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Tileset-based borders vs. line-drawing: which look are we going for?
|
||||
- Does the message log need scrolling? Probably not at first.
|
||||
- Tileset-based borders vs. line-drawing: line-drawing for now (`draw_rect`); a 9-slice
|
||||
ornament frame like `wds` has is an option once the look matters.
|
||||
- Does the message log need scrolling? Not yet — `LOG_LINES` keeps 40, the panel shows
|
||||
what fits.
|
||||
|
||||
+13
-6
@@ -158,13 +158,20 @@ Sprite rendering, camera follow and entity interpolation have all landed.
|
||||
|
||||
---
|
||||
|
||||
### Later — UI / HUD
|
||||
### UI / HUD *(first pass done)*
|
||||
|
||||
- Bitmap font renderer: 8×8 glyph sheet PNG, `draw_text(frame, font, x, y, color, text)` — see `notes/04`
|
||||
- UI primitives in `game/src/ui.rs`: `fill_rect`, `draw_rect`, `draw_hbar` — see `notes/05`
|
||||
- HUD layout (320×240): HP bar + player name bottom 16px, message log right panel
|
||||
- A real clip-rect on the blit primitives belongs here — once the viewport and HUD panels are
|
||||
two distinct regions, the abstraction earns its keep. Not before.
|
||||
- [x] Bitmap font: `game/src/font.rs`, CP437 atlas (32×8 cells, codepoint = index),
|
||||
proportional advance from measured ink bounds, luminance-keyed mask, `wrap`.
|
||||
Ported from `wds`; the atlas is `assets/fonts/cga-8x8.png` (Oldschool PC Font
|
||||
Pack CGA 8×8). See `notes/04-text_renderer.md`
|
||||
- [x] UI primitives in `game/src/ui.rs`: `fill_rect`, `draw_rect`, `draw_hbar`
|
||||
- [x] HUD in the 80 px strip right of the viewport (`Game::render_hud`): name, HP bar,
|
||||
population, and a message log fed from the sim events (only fights involving the
|
||||
player; newest at the bottom, wrapped to the panel)
|
||||
- A real clip-rect on the blit primitives — the HUD currently relies on being drawn
|
||||
*after* the viewport, which overwrites the right-edge bleed. Fine until a panel
|
||||
overlaps the world.
|
||||
- Player name is a constant; a character/stat screen comes with inventory.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-12
@@ -15,11 +15,13 @@ const SIGHT_RANGE: i32 = 8;
|
||||
/// back — they are an output channel, drained with `Sim::take_events`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Event {
|
||||
/// `by` bumped into `target` and dealt `damage`.
|
||||
Attacked { by: u32, target: u32, damage: u16 },
|
||||
/// An entity's hp reached zero; it has already been removed from the world. `at`
|
||||
/// is where it stood, since the entity itself is gone.
|
||||
Died { id: u32, at: (i16, i16) },
|
||||
/// `by` bumped into `target` and dealt `damage`. `target_type` is carried so a
|
||||
/// listener can still name a target that died of this hit.
|
||||
Attacked { by: u32, target: u32, target_type: u16, damage: u16 },
|
||||
/// An entity's hp reached zero and it has been removed from the world. Carries
|
||||
/// what listeners need, since the entity itself is gone: what it was, where it
|
||||
/// stood, who killed it.
|
||||
Died { id: u32, type_id: u16, at: (i16, i16), by: u32 },
|
||||
}
|
||||
|
||||
pub struct Sim {
|
||||
@@ -174,11 +176,11 @@ impl Sim {
|
||||
let damage = self.world.entities[&by].attack;
|
||||
let t = self.world.entities.get_mut(&target).unwrap();
|
||||
t.hp = t.hp.saturating_sub(damage);
|
||||
let (hp, at) = (t.hp, t.pos);
|
||||
self.events.push(Event::Attacked { by, target, damage });
|
||||
let (hp, type_id, at) = (t.hp, t.type_id, t.pos);
|
||||
self.events.push(Event::Attacked { by, target, target_type: type_id, damage });
|
||||
if hp == 0 {
|
||||
self.world.remove_entity(target);
|
||||
self.events.push(Event::Died { id: target, at });
|
||||
self.events.push(Event::Died { id: target, type_id, at, by });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,7 +292,7 @@ mod tests {
|
||||
sim.set_action(p, player_action::SOUTH);
|
||||
step_window(&mut sim);
|
||||
assert_eq!(sim.world.entities[&n].hp, 2);
|
||||
assert_eq!(sim.take_events(), vec![Event::Attacked { by: p, target: n, damage: 3 }]);
|
||||
assert_eq!(sim.take_events(), vec![Event::Attacked { by: p, target: n, target_type: 1, damage: 3 }]);
|
||||
assert!(sim.take_events().is_empty()); // drained
|
||||
|
||||
sim.set_action(p, player_action::SOUTH);
|
||||
@@ -298,8 +300,8 @@ mod tests {
|
||||
assert!(!sim.world.entities.contains_key(&n));
|
||||
assert!(!sim.world.blocked(4, 5));
|
||||
assert_eq!(sim.take_events(), vec![
|
||||
Event::Attacked { by: p, target: n, damage: 3 },
|
||||
Event::Died { id: n, at: (4, 5) },
|
||||
Event::Attacked { by: p, target: n, target_type: 1, damage: 3 },
|
||||
Event::Died { id: n, type_id: 1, at: (4, 5), by: p },
|
||||
]);
|
||||
|
||||
// The tile is free again: the same intent now moves.
|
||||
@@ -346,7 +348,7 @@ mod tests {
|
||||
assert_eq!(sim.world.entities[&hen].hp, 5);
|
||||
step_window(&mut sim);
|
||||
assert_eq!(sim.world.entities[&hen].hp, 3);
|
||||
assert_eq!(sim.take_events(), vec![Event::Attacked { by: fox, target: hen, damage: 2 }]);
|
||||
assert_eq!(sim.take_events(), vec![Event::Attacked { by: fox, target: hen, target_type: 1, damage: 2 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user