diff --git a/assets/fonts/cga-8x8.png b/assets/fonts/cga-8x8.png new file mode 100644 index 0000000..0ebc4f0 Binary files /dev/null and b/assets/fonts/cga-8x8.png differ diff --git a/game/src/font.rs b/game/src/font.rs new file mode 100644 index 0000000..6ca5be7 --- /dev/null +++ b/game/src/font.rs @@ -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, + /// 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 = 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 { + 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'?'); + } +} diff --git a/game/src/game.rs b/game/src/game.rs index a8e94b5..987d3cb 100755 --- a/game/src/game.rs +++ b/game/src/game.rs @@ -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, entity_tileset: Vec, + 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, - /// Running totals for the population report. - attacks: u32, - deaths: u32, - report_ms: usize, + /// Message log, newest last. + log: VecDeque, } 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 = 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); diff --git a/game/src/main.rs b/game/src/main.rs index 6e8584b..4431469 100755 --- a/game/src/main.rs +++ b/game/src/main.rs @@ -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 { diff --git a/game/src/ui.rs b/game/src/ui.rs new file mode 100644 index 0000000..c8ebe76 --- /dev/null +++ b/game/src/ui.rs @@ -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)); + } +} diff --git a/notes/04-text_renderer.md b/notes/04-text_renderer.md index 849a9a2..e53332a 100755 --- a/notes/04-text_renderer.md +++ b/notes/04-text_renderer.md @@ -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. diff --git a/notes/05-ui_renderer.md b/notes/05-ui_renderer.md index 65b1f99..b6b2e39 100755 --- a/notes/05-ui_renderer.md +++ b/notes/05-ui_renderer.md @@ -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` 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. diff --git a/notes/roadmap.md b/notes/roadmap.md index 4c6fe04..7a2cc29 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -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. --- diff --git a/sim/src/sim.rs b/sim/src/sim.rs index 18a8002..e06e441 100755 --- a/sim/src/sim.rs +++ b/sim/src/sim.rs @@ -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]