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
+1
View File
@@ -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"
+85
View File
@@ -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<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
/// 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<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`.
///
/// 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
}
}
+41
View File
@@ -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)
}
}
+3
View File
@@ -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)]
+13
View File
@@ -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<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]`).
@@ -401,6 +412,7 @@ impl TryFrom<MapFile> 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,
+1
View File
@@ -23,6 +23,7 @@ fn make_board(px: i64, py: i64, portals: Vec<PortalDef>) -> Board {
next_object_id: 1,
portals,
board_script_name: None,
dark: false,
load_errors: Vec::new(),
registry: HashMap::new(),
}
+1
View File
@@ -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(),
};