fov
This commit is contained in:
Generated
+8
@@ -420,6 +420,12 @@ dependencies = [
|
|||||||
"litrs",
|
"litrs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "doryen-fov"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a8e6ef8177b52d581c728655ff05fe03a6634ed496fc2c6642bb17b91d27aa9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "downcast-rs"
|
name = "downcast-rs"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -754,6 +760,7 @@ name = "kiln-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"color",
|
"color",
|
||||||
|
"doryen-fov",
|
||||||
"log",
|
"log",
|
||||||
"rhai",
|
"rhai",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -769,6 +776,7 @@ dependencies = [
|
|||||||
"kiln-core",
|
"kiln-core",
|
||||||
"kiln-ui",
|
"kiln-ui",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
color = "0.3.3"
|
color = "0.3.3"
|
||||||
|
doryen-fov = "0.1"
|
||||||
rhai = "1"
|
rhai = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
tinyrand = "0.5"
|
tinyrand = "0.5"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::archetype::Archetype;
|
use crate::archetype::Archetype;
|
||||||
use crate::floor::Floor;
|
use crate::floor::Floor;
|
||||||
|
use crate::fov::{SIGHT_RADIUS, Visibility};
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
|
use doryen_fov::{FovAlgorithm, FovRecursiveShadowCasting, MapData};
|
||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use crate::utils::Direction;
|
use crate::utils::Direction;
|
||||||
@@ -102,6 +104,12 @@ pub struct Board {
|
|||||||
/// rather than being tied to a specific object cell. Scripts live in
|
/// rather than being tied to a specific object cell. Scripts live in
|
||||||
/// [`World::scripts`](crate::world::World) and are looked up by this name.
|
/// [`World::scripts`](crate::world::World) and are looked up by this name.
|
||||||
pub board_script_name: Option<String>,
|
pub board_script_name: Option<String>,
|
||||||
|
/// 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
|
/// Nonfatal problems collected while loading this map (e.g. unknown
|
||||||
/// archetypes, dropped objects, recovered placement chars), as red-on-black
|
/// archetypes, dropped objects, recovered placement chars), as red-on-black
|
||||||
/// [`LogLine`]s. Empty for a clean load; see [`Board::is_valid`]. Not part of
|
/// [`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()
|
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<Visibility> {
|
||||||
|
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`.
|
/// 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.
|
/// Non-solid things are never pushable: `pushable` only matters for solids.
|
||||||
@@ -720,6 +772,7 @@ pub(crate) mod tests {
|
|||||||
next_object_id,
|
next_object_id,
|
||||||
portals: Vec::new(),
|
portals: Vec::new(),
|
||||||
board_script_name: None,
|
board_script_name: None,
|
||||||
|
dark: false,
|
||||||
load_errors: Vec::new(),
|
load_errors: Vec::new(),
|
||||||
registry: HashMap::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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ pub mod colors;
|
|||||||
pub mod cp437;
|
pub mod cp437;
|
||||||
/// Procedural floor generators ([`floor::FloorGenerator`]).
|
/// Procedural floor generators ([`floor::FloorGenerator`]).
|
||||||
pub mod floor;
|
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.
|
/// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc.
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod glyph;
|
pub mod glyph;
|
||||||
@@ -26,6 +28,7 @@ pub mod player;
|
|||||||
pub mod keys;
|
pub mod keys;
|
||||||
pub use archetype::{Archetype, Builtin};
|
pub use archetype::{Archetype, Builtin};
|
||||||
pub use board::Board;
|
pub use board::Board;
|
||||||
|
pub use fov::{SIGHT_RADIUS, Visibility};
|
||||||
pub use utils::Direction;
|
pub use utils::Direction;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ pub struct MapHeader {
|
|||||||
/// Name of the board-level script in the `[scripts]` table, if any.
|
/// Name of the board-level script in the `[scripts]` table, if any.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub board_script_name: Option<String>,
|
pub board_script_name: Option<String>,
|
||||||
|
/// 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]`).
|
/// Serde form of the board floor attribute (`floor = { … }` in `[map]`).
|
||||||
@@ -401,6 +412,7 @@ impl TryFrom<MapFile> for Board {
|
|||||||
next_object_id,
|
next_object_id,
|
||||||
portals,
|
portals,
|
||||||
board_script_name: mf.map.board_script_name,
|
board_script_name: mf.map.board_script_name,
|
||||||
|
dark: mf.map.dark,
|
||||||
load_errors,
|
load_errors,
|
||||||
registry: HashMap::new(),
|
registry: HashMap::new(),
|
||||||
};
|
};
|
||||||
@@ -671,6 +683,7 @@ impl From<&Board> for MapFile {
|
|||||||
height: board.height,
|
height: board.height,
|
||||||
floor: floor_to_spec(&board.floor),
|
floor: floor_to_spec(&board.floor),
|
||||||
board_script_name: board.board_script_name.clone(),
|
board_script_name: board.board_script_name.clone(),
|
||||||
|
dark: board.dark,
|
||||||
},
|
},
|
||||||
grid,
|
grid,
|
||||||
triggers,
|
triggers,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ fn make_board(px: i64, py: i64, portals: Vec<PortalDef>) -> Board {
|
|||||||
next_object_id: 1,
|
next_object_id: 1,
|
||||||
portals,
|
portals,
|
||||||
board_script_name: None,
|
board_script_name: None,
|
||||||
|
dark: false,
|
||||||
load_errors: Vec::new(),
|
load_errors: Vec::new(),
|
||||||
registry: HashMap::new(),
|
registry: HashMap::new(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ fn board_with_object(
|
|||||||
next_object_id: 2,
|
next_object_id: 2,
|
||||||
portals: Vec::new(),
|
portals: Vec::new(),
|
||||||
board_script_name: None,
|
board_script_name: None,
|
||||||
|
dark: false,
|
||||||
load_errors: Vec::new(),
|
load_errors: Vec::new(),
|
||||||
registry: HashMap::new(),
|
registry: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,3 +9,7 @@ kiln-core = { path = "../kiln-core" }
|
|||||||
kiln-ui = { git = "https://notpi.com/randrews/kiln-ui.git" }
|
kiln-ui = { git = "https://notpi.com/randrews/kiln-ui.git" }
|
||||||
color = "0.3.3"
|
color = "0.3.3"
|
||||||
ratatui = { workspace = true, features = ["unstable-rendered-line-info"] }
|
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"] }
|
||||||
|
|||||||
@@ -424,7 +424,14 @@ fn draw_board_area(frame: &mut Frame, inner: Rect, game: &GameState, ui: &mut Ui
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let board = &game.board();
|
let board = &game.board();
|
||||||
frame.render_widget(BoardWidget::new(board), inner);
|
// On a dark board this is the player's field of view; on a lit board it's
|
||||||
frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner);
|
// 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
@@ -6,12 +6,19 @@
|
|||||||
//! player is drawn on top of everything.
|
//! player is drawn on top of everything.
|
||||||
|
|
||||||
use crate::utils::rgba8_to_color;
|
use crate::utils::rgba8_to_color;
|
||||||
|
use color::Rgba8;
|
||||||
use kiln_core::Board;
|
use kiln_core::Board;
|
||||||
|
use kiln_core::Visibility;
|
||||||
use kiln_core::cp437::tile_to_char;
|
use kiln_core::cp437::tile_to_char;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::widgets::Widget;
|
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
|
/// 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.
|
/// rectangular area, scrolled so a chosen focus cell stays visible on larger boards.
|
||||||
pub struct BoardWidget<'a> {
|
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 board cell (x, y) the viewport scrolls to keep visible. Defaults to
|
||||||
/// the player position so play mode requires no extra setup.
|
/// the player position so play mode requires no extra setup.
|
||||||
focus: (i32, i32),
|
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> {
|
impl<'a> BoardWidget<'a> {
|
||||||
/// Creates a widget that renders `board`, scrolling to follow the player.
|
/// Creates a widget that renders `board`, scrolling to follow the player.
|
||||||
pub fn new(board: &'a Board) -> Self {
|
pub fn new(board: &'a Board) -> Self {
|
||||||
let focus = (board.player.x as i32, board.player.y as i32);
|
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
|
/// Overrides the scroll focus to `(x, y)` — use in the editor to follow the
|
||||||
@@ -36,6 +47,13 @@ impl<'a> BoardWidget<'a> {
|
|||||||
self
|
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.
|
/// 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,
|
/// 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))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn axis_centers_a_small_board() {
|
fn axis_centers_a_small_board() {
|
||||||
@@ -139,32 +254,3 @@ mod tests {
|
|||||||
assert_eq!(BoardWidget::axis(100, 20, 99), (80, 0, 20));
|
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
@@ -1,7 +1,7 @@
|
|||||||
use crate::render::BoardWidget;
|
use crate::render::BoardWidget;
|
||||||
use crate::utils::rects_overlap;
|
use crate::utils::rects_overlap;
|
||||||
use kiln_core::game::SpeechBubble;
|
use kiln_core::game::SpeechBubble;
|
||||||
use kiln_core::{Board, Direction};
|
use kiln_core::{Board, Direction, Visibility};
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::prelude::{Color, Line, Style, Widget};
|
use ratatui::prelude::{Color, Line, Style, Widget};
|
||||||
@@ -19,6 +19,10 @@ pub struct SpeechBubblesWidget<'a> {
|
|||||||
pub board: &'a Board,
|
pub board: &'a Board,
|
||||||
/// The active speech bubbles to render.
|
/// The active speech bubbles to render.
|
||||||
pub bubbles: &'a [SpeechBubble],
|
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.
|
/// A fully-resolved bubble placement ready for the three draw passes.
|
||||||
@@ -40,7 +44,14 @@ struct Placement<'a> {
|
|||||||
impl<'a> SpeechBubblesWidget<'a> {
|
impl<'a> SpeechBubblesWidget<'a> {
|
||||||
/// Creates a widget for the given board and bubble slice.
|
/// Creates a widget for the given board and bubble slice.
|
||||||
pub fn new(board: &'a Board, bubbles: &'a [SpeechBubble]) -> Self {
|
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)`.
|
/// Places a bubble for a speaker at `(obj_sx, obj_sy)`.
|
||||||
@@ -117,7 +128,10 @@ impl Widget for SpeechBubblesWidget<'_> {
|
|||||||
.filter_map(|b| {
|
.filter_map(|b| {
|
||||||
let obj = self.board.objects.get(&b.object_id)?;
|
let obj = self.board.objects.get(&b.object_id)?;
|
||||||
let (sx, sy, on_screen) = board_screen_pos(area, self.board, obj.x, obj.y);
|
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();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
+4
-4
@@ -158,10 +158,8 @@ fn bump(me, _dir) {
|
|||||||
|
|
||||||
yammerer = """
|
yammerer = """
|
||||||
fn tick(me, dt) {
|
fn tick(me, dt) {
|
||||||
if !me.waiting {
|
say("blahblahblah", 2.0);
|
||||||
say("blahblahblah");
|
delay(2);
|
||||||
delay(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -257,6 +255,8 @@ script_name = "tripwire"
|
|||||||
name = "The House"
|
name = "The House"
|
||||||
width = 60
|
width = 60
|
||||||
height = 25
|
height = 25
|
||||||
|
dark = true
|
||||||
|
|
||||||
# A single fixed floor glyph across the whole board.
|
# A single fixed floor glyph across the whole board.
|
||||||
floor = { tile = 176, fg = "#888888", bg = "#444444" }
|
floor = { tile = 176, fg = "#888888", bg = "#444444" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user