This commit is contained in:
2026-07-11 14:47:08 -05:00
parent 6dccf5fc23
commit 03185c9c68
17 changed files with 408 additions and 105 deletions
+101 -43
View File
@@ -1,8 +1,7 @@
use crate::archetype::Archetype;
use crate::floor::Floor;
use crate::fov::{SIGHT_RADIUS, Visibility};
use crate::fov::{FovCaster, Lighting, color_to_rgb};
use crate::glyph::Glyph;
use doryen_fov::{FovAlgorithm, FovRecursiveShadowCasting, MapData};
use crate::log::LogLine;
use crate::object_def::ObjectDef;
use crate::utils::Direction;
@@ -104,9 +103,9 @@ 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.
/// When `true`, this board is "dark": front-ends reveal only the cells the
/// player can see and that receive light (see [`Board::lighting`]) and draw
/// everything else as unlit darkness. Sight and light are 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,
@@ -275,48 +274,74 @@ impl Board {
self.solid_at(x, y).is_none()
}
/// Returns `true` if cell `(x, y)` blocks line of sight.
/// Returns `true` if cell `(x, y)` blocks line of sight (and light).
///
/// 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`].
/// **or** any object on it is opaque. This is the input to lighting on
/// [`dark`](Board::dark) boards; see [`Board::lighting`].
/// 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.
/// Computes lighting + line-of-sight for the player 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> {
/// needs no lighting and front-ends draw every cell at full color. On a dark
/// board it (a) casts an unbounded line-of-sight field from the player, then
/// (b) accumulates colored light from every source — the player's torch
/// (radius `player_torch`, white), each object with `light > 0`, and each
/// terrain cell with [`Archetype::light`] `> 0` — each source colored by its
/// own glyph fg and falling off linearly to its radius. Opaque cells (via
/// [`is_opaque_at`](Board::is_opaque_at)) block both sight and light.
pub fn lighting(&self, player_torch: u32) -> Option<Lighting> {
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));
let (w, h) = (self.width, self.height);
let mut lighting = Lighting::new(w, h);
// One caster whose transparency is seeded once from the opaque cells;
// reused for the LOS pass and every light source (its FOV is cleared per cast).
let mut caster = FovCaster::new(w, h, |x, y| !self.is_opaque_at(x, y));
let (px, py) = (self.player.x as usize, self.player.y as usize);
// (a) Player line of sight — unbounded (radius 0), pure geometry.
caster.cast(px, py, 0, |x, y| lighting.set_los(x, y));
// (b) Accumulate each light source into the per-cell color buffer. A
// source paints every cell it can see within its radius, tinted by its
// glyph fg and dimmed by a linear falloff (full at the source, 0 at the edge).
let mut add_source = |lighting: &mut Lighting, sx: usize, sy: usize, radius: u32, color: [f32; 3]| {
let r = radius as f32;
caster.cast(sx, sy, radius as usize, |x, y| {
let d = ((x as f32 - sx as f32).powi(2) + (y as f32 - sy as f32).powi(2)).sqrt();
let falloff = (1.0 - d / r).max(0.0);
lighting.add_light(x, y, [color[0] * falloff, color[1] * falloff, color[2] * falloff]);
});
};
// The player's torch: a white light centered on the player.
if player_torch > 0 {
add_source(&mut lighting, px, py, player_torch, [1.0, 1.0, 1.0]);
}
// Light-emitting objects: color = their own glyph foreground.
for o in self.objects.values() {
if o.light > 0 {
add_source(&mut lighting, o.x, o.y, o.light, color_to_rgb(o.glyph.fg));
}
}
// `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))
// Glowing terrain (e.g. a `Torch` cell): color = the cell's glyph foreground.
for y in 0..h {
for x in 0..w {
let (glyph, arch) = self.get(x, y);
let radius = arch.light();
if radius > 0 {
add_source(&mut lighting, x, y, radius, color_to_rgb(glyph.fg));
}
}
}
Some(lighting)
}
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
@@ -1184,11 +1209,11 @@ pub(crate) mod tests {
}
#[test]
fn player_fov_none_when_not_dark() {
// A lit board needs no FOV; front-ends draw every cell.
fn lighting_none_when_not_dark() {
// A lit board needs no lighting; front-ends draw every cell.
let board = open_board(5, 1, (0, 0), vec![]);
assert!(!board.dark);
assert!(board.player_fov().is_none());
assert!(board.lighting(10).is_none());
}
#[test]
@@ -1202,17 +1227,50 @@ pub(crate) mod tests {
#[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.
// everything past it. The player's torch lights cells before the wall
// (and the wall itself); the cells behind the wall are neither lit nor
// in line of sight, so they are not visible.
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
let lit = board.lighting(10).expect("dark board yields Lighting");
assert!(lit.is_visible(0, 0)); // the player's own cell
assert!(lit.is_visible(1, 0)); // open cell before the wall
assert!(lit.is_visible(2, 0)); // the wall itself (light_walls = true)
assert!(!lit.is_visible(3, 0)); // occluded behind the wall
assert!(!lit.is_visible(4, 0)); // occluded behind the wall
}
#[test]
fn unlit_cell_in_sight_is_not_visible() {
// A long lit-free corridor: with a tiny torch, far cells are in line of
// sight but receive no light, so they are not visible (LOS ∩ lit).
let mut board = open_board(10, 1, (0, 0), vec![]);
board.dark = true;
let lit = board.lighting(2).expect("dark board yields Lighting");
assert!(lit.is_visible(0, 0)); // at the torch
assert!(lit.is_visible(1, 0)); // within the torch radius
assert!(!lit.is_visible(8, 0)); // in sight but unlit → dark
}
#[test]
fn object_light_tints_toward_its_color() {
// A dark board with no player torch and one red-glyph light object: the
// object's cell is lit red, so a white base tints red (green/blue killed).
let mut board = open_board(3, 1, (1, 0), vec![]);
board.dark = true;
let mut lamp = ObjectDef::new(1, 0);
lamp.solid = false;
lamp.light = 4;
lamp.glyph = Glyph { tile: 1, fg: Rgba8 { r: 255, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } };
board.add_object(lamp);
let lit = board.lighting(0).expect("dark board yields Lighting"); // no player torch
let white = Rgba8 { r: 255, g: 255, b: 255, a: 255 };
let t = lit.tint(1, 0, white);
assert!(t.r > 0, "red channel survives");
assert_eq!(t.g, 0, "green killed by red light");
assert_eq!(t.b, 0, "blue killed by red light");
}
}