This commit is contained in:
2026-07-11 12:40:35 -05:00
parent f1eaaae5d0
commit 6dccf5fc23
14 changed files with 363 additions and 40 deletions
+4
View File
@@ -9,3 +9,7 @@ kiln-core = { path = "../kiln-core" }
kiln-ui = { git = "https://notpi.com/randrews/kiln-ui.git" }
color = "0.3.3"
ratatui = { workspace = true, features = ["unstable-rendered-line-info"] }
[dev-dependencies]
# Used only by rendering tests to build a Board from an inline map-file string.
toml = { version = "0.8", features = ["preserve_order"] }
+9 -2
View File
@@ -424,7 +424,14 @@ fn draw_board_area(frame: &mut Frame, inner: Rect, game: &GameState, ui: &mut Ui
);
} else {
let board = &game.board();
frame.render_widget(BoardWidget::new(board), inner);
frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner);
// On a dark board this is the player's field of view; on a lit board it's
// None (draw everything). Computed once and shared by both widgets so the
// board and its speech bubbles agree on what's visible.
let fov = board.player_fov();
frame.render_widget(BoardWidget::new(board).fov(fov.as_ref()), inner);
frame.render_widget(
SpeechBubblesWidget::new(board, &game.speech_bubbles).fov(fov.as_ref()),
inner,
);
}
}
+117 -31
View File
@@ -6,12 +6,19 @@
//! player is drawn on top of everything.
use crate::utils::rgba8_to_color;
use color::Rgba8;
use kiln_core::Board;
use kiln_core::Visibility;
use kiln_core::cp437::tile_to_char;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
/// The background color drawn for cells outside the field of view on a dark
/// board — a near-black gray, so unseen terrain reads as "dark" rather than
/// pure void (which is what the normal empty floor already is).
const DARKNESS_BG: Rgba8 = Rgba8 { r: 32, g: 32, b: 32, a: 255 };
/// A ratatui widget that draws a [`Board`] (with objects and the player) into a
/// rectangular area, scrolled so a chosen focus cell stays visible on larger boards.
pub struct BoardWidget<'a> {
@@ -20,13 +27,17 @@ pub struct BoardWidget<'a> {
/// The board cell (x, y) the viewport scrolls to keep visible. Defaults to
/// the player position so play mode requires no extra setup.
focus: (i32, i32),
/// The player's field of view on a [`dark`](Board::dark) board, if any. When
/// `Some`, cells not in view are drawn as darkness instead of their glyph.
/// `None` (a lit board) draws every cell normally.
fov: Option<&'a Visibility>,
}
impl<'a> BoardWidget<'a> {
/// Creates a widget that renders `board`, scrolling to follow the player.
pub fn new(board: &'a Board) -> Self {
let focus = (board.player.x as i32, board.player.y as i32);
Self { board, focus }
Self { board, focus, fov: None }
}
/// Overrides the scroll focus to `(x, y)` — use in the editor to follow the
@@ -36,6 +47,13 @@ impl<'a> BoardWidget<'a> {
self
}
/// Supplies the player's field of view (see [`Board::player_fov`]). When
/// `Some`, cells outside it render as darkness; `None` renders everything.
pub fn fov(mut self, fov: Option<&'a Visibility>) -> Self {
self.fov = fov;
self
}
/// Lays out one axis of the board within a viewport `view` cells long.
///
/// Returns `(off, pad, count)` where `off` is the first board cell to draw,
@@ -113,9 +131,106 @@ pub fn screen_to_board(
Some((off_x + col as usize, off_y + row as usize))
}
impl Widget for BoardWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let board = self.board;
// Resolve each axis independently: a small board is centered, a large
// one scrolls to keep the focus cell on screen.
let (off_x, pad_x, cols) = Self::axis(board.width, area.width as usize, self.focus.0);
let (off_y, pad_y, rows) = Self::axis(board.height, area.height as usize, self.focus.1);
// Walk the visible board cells, placing each after the centering pad.
for row in 0..rows {
let by = off_y + row;
let sy = area.y + pad_y + row as u16;
for col in 0..cols {
let bx = off_x + col;
let sx = area.x + pad_x + col as u16;
// On a dark board, a cell outside the player's field of view is
// drawn as darkness (blank cell, dark-gray background) instead of
// its real glyph. A lit board (`fov == None`) shows every cell.
let visible = self.fov.is_none_or(|v| v.is_visible(bx, by));
if let Some(cell) = buf.cell_mut((sx, sy)) {
if visible {
let glyph = board.glyph_at(bx, by);
cell.set_char(tile_to_char(glyph.tile))
.set_fg(rgba8_to_color(glyph.fg))
.set_bg(rgba8_to_color(glyph.bg));
} else {
cell.set_char(' ')
.set_fg(rgba8_to_color(DARKNESS_BG))
.set_bg(rgba8_to_color(DARKNESS_BG));
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::BoardWidget;
use super::{BoardWidget, DARKNESS_BG};
use crate::utils::rgba8_to_color;
use kiln_core::Board;
use kiln_core::map_file::MapFile;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
/// Builds a tiny dark board: a 5×1 corridor with the player at the left end
/// and a wall at x=2 occluding the two cells behind it.
fn dark_corridor() -> Board {
let toml = r##"
[map]
name = "corridor"
width = 5
height = 1
dark = true
[grid]
content = "@ # "
[grid.palette]
"@" = { kind = "player" }
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#404040" }
"##;
let mf: MapFile = toml::from_str(toml).unwrap();
Board::try_from(mf).unwrap()
}
#[test]
fn dark_board_renders_occluded_cells_as_darkness() {
let board = dark_corridor();
let fov = board.player_fov();
assert!(fov.is_some(), "a dark board must produce a field of view");
let area = Rect::new(0, 0, 5, 1);
let mut buf = Buffer::empty(area);
BoardWidget::new(&board).fov(fov.as_ref()).render(area, &mut buf);
// The wall at x=2 is visible (light_walls); the cells behind it are drawn
// as darkness: a blank cell on the dark-gray background.
let dark = rgba8_to_color(DARKNESS_BG);
assert_eq!(buf[(2, 0)].symbol(), "#", "the wall itself is lit");
for x in [3u16, 4] {
let cell = &buf[(x, 0)];
assert_eq!(cell.symbol(), " ", "occluded cell is blank");
assert_eq!(cell.bg, dark, "occluded cell uses the darkness background");
}
}
#[test]
fn lit_board_renders_every_cell_normally() {
// The same board without `dark` gets no FOV, so nothing is hidden.
let mut board = dark_corridor();
board.dark = false;
assert!(board.player_fov().is_none());
let area = Rect::new(0, 0, 5, 1);
let mut buf = Buffer::empty(area);
BoardWidget::new(&board).fov(None).render(area, &mut buf);
// The far cell behind the wall is drawn from the board, not as darkness.
assert_ne!(buf[(4, 0)].bg, rgba8_to_color(DARKNESS_BG));
}
#[test]
fn axis_centers_a_small_board() {
@@ -139,32 +254,3 @@ mod tests {
assert_eq!(BoardWidget::axis(100, 20, 99), (80, 0, 20));
}
}
impl Widget for BoardWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let board = self.board;
// Resolve each axis independently: a small board is centered, a large
// one scrolls to keep the focus cell on screen.
let (off_x, pad_x, cols) = Self::axis(board.width, area.width as usize, self.focus.0);
let (off_y, pad_y, rows) = Self::axis(board.height, area.height as usize, self.focus.1);
// Walk the visible board cells, placing each after the centering pad.
for row in 0..rows {
let by = off_y + row;
let sy = area.y + pad_y + row as u16;
for col in 0..cols {
let bx = off_x + col;
let sx = area.x + pad_x + col as u16;
let glyph = board.glyph_at(bx, by);
// Paint the resolved glyph into the terminal cell.
if let Some(cell) = buf.cell_mut((sx, sy)) {
cell.set_char(tile_to_char(glyph.tile))
.set_fg(rgba8_to_color(glyph.fg))
.set_bg(rgba8_to_color(glyph.bg));
}
}
}
}
}
+17 -3
View File
@@ -1,7 +1,7 @@
use crate::render::BoardWidget;
use crate::utils::rects_overlap;
use kiln_core::game::SpeechBubble;
use kiln_core::{Board, Direction};
use kiln_core::{Board, Direction, Visibility};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::{Color, Line, Style, Widget};
@@ -19,6 +19,10 @@ pub struct SpeechBubblesWidget<'a> {
pub board: &'a Board,
/// The active speech bubbles to render.
pub bubbles: &'a [SpeechBubble],
/// The player's field of view on a dark board, if any. A speaker outside it
/// still gets a bubble, but with no tail (like an off-screen speaker) —
/// you hear the unseen thing without a line pointing to where it hides.
fov: Option<&'a Visibility>,
}
/// A fully-resolved bubble placement ready for the three draw passes.
@@ -40,7 +44,14 @@ struct Placement<'a> {
impl<'a> SpeechBubblesWidget<'a> {
/// Creates a widget for the given board and bubble slice.
pub fn new(board: &'a Board, bubbles: &'a [SpeechBubble]) -> Self {
Self { board, bubbles }
Self { board, bubbles, fov: None }
}
/// Supplies the player's field of view (see [`Board::player_fov`]). A speaker
/// outside it has its tail suppressed; `None` (a lit board) leaves tails as-is.
pub fn fov(mut self, fov: Option<&'a Visibility>) -> Self {
self.fov = fov;
self
}
/// Places a bubble for a speaker at `(obj_sx, obj_sy)`.
@@ -117,7 +128,10 @@ impl Widget for SpeechBubblesWidget<'_> {
.filter_map(|b| {
let obj = self.board.objects.get(&b.object_id)?;
let (sx, sy, on_screen) = board_screen_pos(area, self.board, obj.x, obj.y);
Some((sx, sy, on_screen, b))
// On a dark board, a speaker the player can't see is treated like an
// off-screen speaker: the box still draws, but its tail is suppressed.
let visible = self.fov.is_none_or(|v| v.is_visible(obj.x, obj.y));
Some((sx, sy, on_screen && visible, b))
})
.collect();