diff --git a/Cargo.lock b/Cargo.lock index 2de19e1..c825cae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -420,6 +420,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "doryen-fov" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8e6ef8177b52d581c728655ff05fe03a6634ed496fc2c6642bb17b91d27aa9" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -754,6 +760,7 @@ name = "kiln-core" version = "0.1.0" dependencies = [ "color", + "doryen-fov", "log", "rhai", "serde", @@ -769,6 +776,7 @@ dependencies = [ "kiln-core", "kiln-ui", "ratatui", + "toml", ] [[package]] diff --git a/kiln-core/Cargo.toml b/kiln-core/Cargo.toml index 76a3916..fe548dc 100644 --- a/kiln-core/Cargo.toml +++ b/kiln-core/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] color = "0.3.3" +doryen-fov = "0.1" rhai = "1" serde = { version = "1", features = ["derive"] } tinyrand = "0.5" diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 1233a56..4518df3 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -1,6 +1,8 @@ use crate::archetype::Archetype; use crate::floor::Floor; +use crate::fov::{SIGHT_RADIUS, Visibility}; use crate::glyph::Glyph; +use doryen_fov::{FovAlgorithm, FovRecursiveShadowCasting, MapData}; use crate::log::LogLine; use crate::object_def::ObjectDef; use crate::utils::Direction; @@ -102,6 +104,12 @@ pub struct Board { /// rather than being tied to a specific object cell. Scripts live in /// [`World::scripts`](crate::world::World) and are looked up by this name. pub board_script_name: Option, + /// When `true`, this board is "dark": front-ends reveal only the cells + /// within the player's field of view (see [`Board::player_fov`]) and draw + /// everything else as unlit darkness. Sight is blocked by opaque cells. + /// Loaded from / saved to the `dark` key in the map file's `[map]` header; + /// defaults to `false` (fully lit). + pub dark: bool, /// Nonfatal problems collected while loading this map (e.g. unknown /// archetypes, dropped objects, recovered placement chars), as red-on-black /// [`LogLine`]s. Empty for a clean load; see [`Board::is_valid`]. Not part of @@ -267,6 +275,50 @@ impl Board { self.solid_at(x, y).is_none() } + /// Returns `true` if cell `(x, y)` blocks line of sight. + /// + /// A cell is sight-blocking if its grid terrain is opaque (e.g. a `Wall`) + /// **or** any object on it is opaque. This is the input to field-of-view on + /// [`dark`](Board::dark) boards; see [`Board::player_fov`]. + /// Panics if `x` or `y` are out of bounds. + pub fn is_opaque_at(&self, x: usize, y: usize) -> bool { + self.get(x, y).1.behavior().opaque + || self.objects.values().any(|o| o.x == x && o.y == y && o.opaque) + } + + /// Computes the player's field of view on this board. + /// + /// Returns `None` unless the board is [`dark`](Board::dark) — a lit board + /// needs no FOV, and front-ends draw every cell. On a dark board it builds a + /// [`doryen_fov`] transparency map (opaque cells from [`is_opaque_at`](Board::is_opaque_at) + /// block sight), casts recursive shadow-casting from the player out to + /// [`SIGHT_RADIUS`](crate::fov::SIGHT_RADIUS), and returns the resulting + /// [`Visibility`] for the front-end to query per cell. + pub fn player_fov(&self) -> Option { + if !self.dark { + return None; + } + // Seed every cell's transparency; MapData defaults to all-transparent, so + // we only need to knock out the opaque ones — but set all to stay explicit. + let mut map = MapData::new(self.width, self.height); + for y in 0..self.height { + for x in 0..self.width { + map.set_transparent(x, y, !self.is_opaque_at(x, y)); + } + } + // `light_walls = true` lights opaque cells at the edge of sight (the wall + // you're looking *at* is visible), rather than only the open cells before it. + let mut algo = FovRecursiveShadowCasting::new(); + algo.compute_fov( + &mut map, + self.player.x as usize, + self.player.y as usize, + SIGHT_RADIUS, + true, + ); + Some(Visibility::new(map)) + } + /// Whether the cell's single solid occupant (if any) can be pushed in `dir`. /// /// Non-solid things are never pushable: `pushable` only matters for solids. @@ -720,6 +772,7 @@ pub(crate) mod tests { next_object_id, portals: Vec::new(), board_script_name: None, + dark: false, load_errors: Vec::new(), registry: HashMap::new(), } @@ -1130,4 +1183,36 @@ pub(crate) mod tests { } } + #[test] + fn player_fov_none_when_not_dark() { + // A lit board needs no FOV; front-ends draw every cell. + let board = open_board(5, 1, (0, 0), vec![]); + assert!(!board.dark); + assert!(board.player_fov().is_none()); + } + + #[test] + fn wall_is_opaque_empty_is_not() { + let mut board = open_board(3, 1, (0, 0), vec![]); + wall_at(&mut board, 1, 0); + assert!(board.is_opaque_at(1, 0)); // wall blocks sight + assert!(!board.is_opaque_at(2, 0)); // empty cell is transparent + } + + #[test] + fn dark_board_hides_cells_behind_a_wall() { + // Player at the left end of a 1-wide corridor; a wall at x=2 occludes + // everything past it. Cells before the wall (and the wall itself) are + // visible; the cell behind the wall is not. + let mut board = open_board(5, 1, (0, 0), vec![]); + board.dark = true; + wall_at(&mut board, 2, 0); + + let vis = board.player_fov().expect("dark board yields a Visibility"); + assert!(vis.is_visible(0, 0)); // the player's own cell + assert!(vis.is_visible(1, 0)); // open cell before the wall + assert!(vis.is_visible(2, 0)); // the wall itself (light_walls = true) + assert!(!vis.is_visible(3, 0)); // occluded behind the wall + assert!(!vis.is_visible(4, 0)); // occluded behind the wall + } } diff --git a/kiln-core/src/fov.rs b/kiln-core/src/fov.rs new file mode 100644 index 0000000..6ba2114 --- /dev/null +++ b/kiln-core/src/fov.rs @@ -0,0 +1,41 @@ +//! Field-of-view computation for "dark" boards. +//! +//! A [`Board`](crate::board::Board) marked `dark` only reveals cells the player +//! can actually see; everything else is drawn as unlit darkness by the +//! front-end. Visibility is computed with the [`doryen_fov`] crate's recursive +//! shadow-casting algorithm — pure Rust and WASM-safe, matching kiln's +//! WASM-first requirement. +//! +//! [`Board::player_fov`](crate::board::Board::player_fov) builds a [`Visibility`] +//! by marking each opaque cell as sight-blocking and casting from the player's +//! position; the front-end then queries [`Visibility::is_visible`] per cell. + +use doryen_fov::MapData; + +/// How far the player can see on a dark board, in cells. +pub const SIGHT_RADIUS: usize = 10; + +/// The result of a field-of-view computation: which board cells are currently +/// visible to the player. Produced by +/// [`Board::player_fov`](crate::board::Board::player_fov) and queried per cell +/// by front-ends when rendering a dark board. +pub struct Visibility { + /// The `doryen_fov` map holding per-cell transparency + computed visibility. + map: MapData, +} + +impl Visibility { + /// Wraps a finished [`MapData`] (visibility already computed) as a + /// [`Visibility`]. Called by [`Board::player_fov`](crate::board::Board::player_fov). + pub(crate) fn new(map: MapData) -> Self { + Self { map } + } + + /// Is board cell `(x, y)` currently visible to the player? + /// + /// Out-of-bounds coordinates are not expected (the caller iterates the + /// board grid) and would panic inside [`MapData::is_in_fov`]. + pub fn is_visible(&self, x: usize, y: usize) -> bool { + self.map.is_in_fov(x, y) + } +} diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index 17085b2..3719edb 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -8,6 +8,8 @@ pub mod colors; pub mod cp437; /// Procedural floor generators ([`floor::FloorGenerator`]). pub mod floor; +/// Field-of-view computation for dark boards ([`fov::Visibility`]). +pub mod fov; /// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc. pub mod game; pub mod glyph; @@ -26,6 +28,7 @@ pub mod player; pub mod keys; pub use archetype::{Archetype, Builtin}; pub use board::Board; +pub use fov::{SIGHT_RADIUS, Visibility}; pub use utils::Direction; #[cfg(test)] diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index a38df8f..f539ef1 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -64,6 +64,17 @@ pub struct MapHeader { /// Name of the board-level script in the `[scripts]` table, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub board_script_name: Option, + /// When `true`, this is a "dark" board: front-ends reveal only cells within + /// the player's field of view. Absent ⇒ `false` (fully lit); omitted from + /// the saved TOML when `false`. See [`Board::dark`](crate::board::Board::dark). + #[serde(default, skip_serializing_if = "is_false")] + pub dark: bool, +} + +/// serde `skip_serializing_if` predicate: omit a `bool` field when it is `false` +/// (so the common non-dark board doesn't emit a `dark = false` line). +fn is_false(b: &bool) -> bool { + !*b } /// Serde form of the board floor attribute (`floor = { … }` in `[map]`). @@ -401,6 +412,7 @@ impl TryFrom for Board { next_object_id, portals, board_script_name: mf.map.board_script_name, + dark: mf.map.dark, load_errors, registry: HashMap::new(), }; @@ -671,6 +683,7 @@ impl From<&Board> for MapFile { height: board.height, floor: floor_to_spec(&board.floor), board_script_name: board.board_script_name.clone(), + dark: board.dark, }, grid, triggers, diff --git a/kiln-core/src/tests/game_portals.rs b/kiln-core/src/tests/game_portals.rs index f38664c..d13703d 100644 --- a/kiln-core/src/tests/game_portals.rs +++ b/kiln-core/src/tests/game_portals.rs @@ -23,6 +23,7 @@ fn make_board(px: i64, py: i64, portals: Vec) -> Board { next_object_id: 1, portals, board_script_name: None, + dark: false, load_errors: Vec::new(), registry: HashMap::new(), } diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index 16f9ac0..445e566 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -37,6 +37,7 @@ fn board_with_object( next_object_id: 2, portals: Vec::new(), board_script_name: None, + dark: false, load_errors: Vec::new(), registry: HashMap::new(), }; diff --git a/kiln-tui/Cargo.toml b/kiln-tui/Cargo.toml index d526c17..5a97765 100644 --- a/kiln-tui/Cargo.toml +++ b/kiln-tui/Cargo.toml @@ -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"] } diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 525bfaf..4bfea88 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -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, + ); } } diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index e56137f..687cc43 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -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)); - } - } - } - } -} diff --git a/kiln-tui/src/speech.rs b/kiln-tui/src/speech.rs index 124d3a1..47c822e 100644 --- a/kiln-tui/src/speech.rs +++ b/kiln-tui/src/speech.rs @@ -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(); diff --git a/maps/dark.toml b/maps/dark.toml new file mode 100644 index 0000000..7c38925 --- /dev/null +++ b/maps/dark.toml @@ -0,0 +1,59 @@ +[world] +name = "Dark Maze" +start = "cave" + +[scripts] +# An always-talking object. Placed behind a wall so it starts out of the player's +# field of view — its bubble should draw with NO tail until you can see it. +hidden = """ +fn tick(me, dt) { + if !me.waiting { + say("Who's there?"); + delay(2); + } +} +""" + +# A talker placed in the open near the player, so its bubble keeps its tail. +lantern = """ +fn init(me) { + say("A warm light\\nflickers here."); +} +fn bump(me, _dir) { + say("The lantern\\nglows steadily."); +} +""" + +[boards.cave.map] +name = "The Cave" +width = 40 +height = 15 +dark = true +floor = { generator = "stone" } + +# A room the player stands in, with a wall dividing it. The 'H' object sits in +# the far chamber behind the wall (out of sight until you walk around); the 'L' +# lantern sits in the open with the player. +[boards.cave.grid] +content = """ +######################################## +# # +# # +# ########### # +# # # # +# @ # H # L # +# # # # +# #### ###### # +# # +# # +# # +# # +# # +# # +######################################## +""" +[boards.cave.grid.palette] +"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#404040" } +"@" = { kind = "player" } +"H" = { kind = "object", tile = 2, fg = "#cc66cc", bg = "#000000", solid = true, name = "hidden", script_name = "hidden" } +"L" = { kind = "object", tile = 15, fg = "#ffdd88", bg = "#000000", solid = true, name = "lantern", script_name = "lantern" } diff --git a/maps/start.toml b/maps/start.toml index ff97aef..d71fd7c 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -158,10 +158,8 @@ fn bump(me, _dir) { yammerer = """ fn tick(me, dt) { - if !me.waiting { - say("blahblahblah"); - delay(1); - } + say("blahblahblah", 2.0); + delay(2); } """ @@ -257,6 +255,8 @@ script_name = "tripwire" name = "The House" width = 60 height = 25 +dark = true + # A single fixed floor glyph across the whole board. floor = { tile = 176, fg = "#888888", bg = "#444444" }