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
+4
View File
@@ -72,6 +72,9 @@ pub enum Action {
Move(Direction), Move(Direction),
/// Set the source object's glyph tile index. /// Set the source object's glyph tile index.
SetTile(u32), SetTile(u32),
/// Set the source object's light radius in cells (0 = no light). Zero time
/// cost. Applied to the source [`ObjectDef::light`](crate::object_def::ObjectDef::light).
SetLight(u32),
/// Add (`present = true`) or remove (`present = false`) `tag` on `target`. /// Add (`present = true`) or remove (`present = false`) `tag` on `target`.
SetTag { SetTag {
target: ObjectId, target: ObjectId,
@@ -131,6 +134,7 @@ impl Debug for Action {
match self { match self {
Action::Move(dir) => write!(f, "Move({:?})", dir), Action::Move(dir) => write!(f, "Move({:?})", dir),
Action::SetTile(i) => write!(f, "SetTile({i})"), Action::SetTile(i) => write!(f, "SetTile({i})"),
Action::SetLight(r) => write!(f, "SetLight({r})"),
Action::SetTag { .. } => write!(f, "SetTag"), Action::SetTag { .. } => write!(f, "SetTag"),
Action::Say(_, _) => write!(f, "Say"), Action::Say(_, _) => write!(f, "Say"),
Action::Delay(t) => write!(f, "Delay({t})"), Action::Delay(t) => write!(f, "Delay({t})"),
+8
View File
@@ -113,6 +113,14 @@ impl Registerable for ObjectInfo {
obj.glyph obj.glyph
}); });
// me.light: the object's current emitted light radius in cells (0 = none).
engine.register_get("light", |o: &mut ObjectInfo| -> i64 {
match o.board.borrow().objects.get(&o.id) {
Some(ObjectDef { light, .. }) => *light as i64,
None => 0,
}
});
engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| { engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| {
if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) { if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) {
tags.contains(&t) tags.contains(&t)
+29
View File
@@ -184,6 +184,9 @@ pub enum Archetype {
HCrate, HCrate,
/// A crate that can only be pushed north/south (vertical axis). Glyph ↕. /// A crate that can only be pushed north/south (vertical axis). Glyph ↕.
VCrate, VCrate,
/// A wall-mounted torch: non-solid, non-opaque decorative terrain that emits
/// warm light on a `dark` board (radius from [`Archetype::light`]). Glyph ☼.
Torch,
/// A script-backed archetype expanded from a map-file keyword. /// A script-backed archetype expanded from a map-file keyword.
/// ///
/// - `Builtin` is the family (e.g. `Builtin::Pusher`), which selects the /// - `Builtin` is the family (e.g. `Builtin::Pusher`), which selects the
@@ -234,6 +237,13 @@ impl Archetype {
pushable: Pushable::Vertical, pushable: Pushable::Vertical,
grab: false, grab: false,
}, },
// A torch you can walk past and see through; it only lights the room.
Archetype::Torch => Behavior {
solid: false,
opaque: false,
pushable: Pushable::No,
grab: false,
},
Archetype::ErrorBlock => Behavior { Archetype::ErrorBlock => Behavior {
solid: true, solid: true,
opaque: true, opaque: true,
@@ -252,10 +262,23 @@ impl Archetype {
Archetype::Crate => "crate", Archetype::Crate => "crate",
Archetype::HCrate => "hcrate", Archetype::HCrate => "hcrate",
Archetype::VCrate => "vcrate", Archetype::VCrate => "vcrate",
Archetype::Torch => "torch",
Archetype::ErrorBlock => "error_block", Archetype::ErrorBlock => "error_block",
} }
} }
/// Light radius in cells this archetype emits on a `dark` board (0 = none).
///
/// The emitted *color* is the cell's glyph foreground color (see [`crate::fov`]).
/// Only `Torch` glows today; everything else is dark. Script-backed builtins
/// carry their light on the expanded [`ObjectDef::light`] instead.
pub fn light(&self) -> u32 {
match self {
Archetype::Torch => 6,
_ => 0,
}
}
/// Returns the default glyph painted when the editor stamps this archetype. /// Returns the default glyph painted when the editor stamps this archetype.
/// ///
/// This glyph is used only for new cells created in the editor; existing /// This glyph is used only for new cells created in the editor; existing
@@ -289,6 +312,11 @@ impl Archetype {
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}, },
Archetype::Torch => Glyph {
tile: 15, // CP437 ☼ (sun) — a warm point of light
fg: Rgba8 { r: 0xFF, g: 0xB0, b: 0x40, a: 255 }, // warm amber (also its light color)
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
// Visually distinct so malformed map files are immediately obvious. // Visually distinct so malformed map files are immediately obvious.
Archetype::ErrorBlock => Glyph { Archetype::ErrorBlock => Glyph {
tile: 63, tile: 63,
@@ -319,6 +347,7 @@ impl TryFrom<&str> for Archetype {
"crate" => Ok(Archetype::Crate), "crate" => Ok(Archetype::Crate),
"hcrate" => Ok(Archetype::HCrate), "hcrate" => Ok(Archetype::HCrate),
"vcrate" => Ok(Archetype::VCrate), "vcrate" => Ok(Archetype::VCrate),
"torch" => Ok(Archetype::Torch),
// "object", "portal", "player" are intentionally absent: they are // "object", "portal", "player" are intentionally absent: they are
// meta-kinds handled by the layer builder, not Archetype variants. // meta-kinds handled by the layer builder, not Archetype variants.
_ => Err(format!("unknown archetype: {name}")), _ => Err(format!("unknown archetype: {name}")),
+101 -43
View File
@@ -1,8 +1,7 @@
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::floor::Floor; use crate::floor::Floor;
use crate::fov::{SIGHT_RADIUS, Visibility}; use crate::fov::{FovCaster, Lighting, color_to_rgb};
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;
@@ -104,9 +103,9 @@ 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 /// When `true`, this board is "dark": front-ends reveal only the cells the
/// within the player's field of view (see [`Board::player_fov`]) and draw /// player can see and that receive light (see [`Board::lighting`]) and draw
/// everything else as unlit darkness. Sight is blocked by opaque cells. /// 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; /// Loaded from / saved to the `dark` key in the map file's `[map]` header;
/// defaults to `false` (fully lit). /// defaults to `false` (fully lit).
pub dark: bool, pub dark: bool,
@@ -275,48 +274,74 @@ 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. /// 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`) /// 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 /// **or** any object on it is opaque. This is the input to lighting on
/// [`dark`](Board::dark) boards; see [`Board::player_fov`]. /// [`dark`](Board::dark) boards; see [`Board::lighting`].
/// Panics if `x` or `y` are out of bounds. /// Panics if `x` or `y` are out of bounds.
pub fn is_opaque_at(&self, x: usize, y: usize) -> bool { pub fn is_opaque_at(&self, x: usize, y: usize) -> bool {
self.get(x, y).1.behavior().opaque self.get(x, y).1.behavior().opaque
|| self.objects.values().any(|o| o.x == x && o.y == y && o.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 /// 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 /// needs no lighting and front-ends draw every cell at full color. On a dark
/// [`doryen_fov`] transparency map (opaque cells from [`is_opaque_at`](Board::is_opaque_at) /// board it (a) casts an unbounded line-of-sight field from the player, then
/// block sight), casts recursive shadow-casting from the player out to /// (b) accumulates colored light from every source — the player's torch
/// [`SIGHT_RADIUS`](crate::fov::SIGHT_RADIUS), and returns the resulting /// (radius `player_torch`, white), each object with `light > 0`, and each
/// [`Visibility`] for the front-end to query per cell. /// terrain cell with [`Archetype::light`] `> 0` — each source colored by its
pub fn player_fov(&self) -> Option<Visibility> { /// 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 { if !self.dark {
return None; return None;
} }
// Seed every cell's transparency; MapData defaults to all-transparent, so let (w, h) = (self.width, self.height);
// we only need to knock out the opaque ones — but set all to stay explicit. let mut lighting = Lighting::new(w, h);
let mut map = MapData::new(self.width, self.height); // One caster whose transparency is seeded once from the opaque cells;
for y in 0..self.height { // reused for the LOS pass and every light source (its FOV is cleared per cast).
for x in 0..self.width { let mut caster = FovCaster::new(w, h, |x, y| !self.is_opaque_at(x, y));
map.set_transparent(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 // Glowing terrain (e.g. a `Torch` cell): color = the cell's glyph foreground.
// you're looking *at* is visible), rather than only the open cells before it. for y in 0..h {
let mut algo = FovRecursiveShadowCasting::new(); for x in 0..w {
algo.compute_fov( let (glyph, arch) = self.get(x, y);
&mut map, let radius = arch.light();
self.player.x as usize, if radius > 0 {
self.player.y as usize, add_source(&mut lighting, x, y, radius, color_to_rgb(glyph.fg));
SIGHT_RADIUS, }
true, }
); }
Some(Visibility::new(map)) Some(lighting)
} }
/// 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`.
@@ -1184,11 +1209,11 @@ pub(crate) mod tests {
} }
#[test] #[test]
fn player_fov_none_when_not_dark() { fn lighting_none_when_not_dark() {
// A lit board needs no FOV; front-ends draw every cell. // A lit board needs no lighting; front-ends draw every cell.
let board = open_board(5, 1, (0, 0), vec![]); let board = open_board(5, 1, (0, 0), vec![]);
assert!(!board.dark); assert!(!board.dark);
assert!(board.player_fov().is_none()); assert!(board.lighting(10).is_none());
} }
#[test] #[test]
@@ -1202,17 +1227,50 @@ pub(crate) mod tests {
#[test] #[test]
fn dark_board_hides_cells_behind_a_wall() { fn dark_board_hides_cells_behind_a_wall() {
// Player at the left end of a 1-wide corridor; a wall at x=2 occludes // 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 // everything past it. The player's torch lights cells before the wall
// visible; the cell behind the wall is not. // (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![]); let mut board = open_board(5, 1, (0, 0), vec![]);
board.dark = true; board.dark = true;
wall_at(&mut board, 2, 0); wall_at(&mut board, 2, 0);
let vis = board.player_fov().expect("dark board yields a Visibility"); let lit = board.lighting(10).expect("dark board yields Lighting");
assert!(vis.is_visible(0, 0)); // the player's own cell assert!(lit.is_visible(0, 0)); // the player's own cell
assert!(vis.is_visible(1, 0)); // open cell before the wall assert!(lit.is_visible(1, 0)); // open cell before the wall
assert!(vis.is_visible(2, 0)); // the wall itself (light_walls = true) assert!(lit.is_visible(2, 0)); // the wall itself (light_walls = true)
assert!(!vis.is_visible(3, 0)); // occluded behind the wall assert!(!lit.is_visible(3, 0)); // occluded behind the wall
assert!(!vis.is_visible(4, 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");
} }
} }
+138 -25
View File
@@ -1,41 +1,154 @@
//! Field-of-view computation for "dark" boards. //! Lighting and field-of-view for "dark" boards.
//! //!
//! A [`Board`](crate::board::Board) marked `dark` only reveals cells the player //! A [`Board`](crate::board::Board) marked `dark` reveals a cell only when the
//! can actually see; everything else is drawn as unlit darkness by the //! player has a **line of sight** to it *and* it receives **light** from some
//! front-end. Visibility is computed with the [`doryen_fov`] crate's recursive //! source. Both are computed with the [`doryen_fov`] crate's recursive
//! shadow-casting algorithm — pure Rust and WASM-safe, matching kiln's //! shadow-casting algorithm — pure Rust and WASM-safe, matching kiln's
//! WASM-first requirement. //! WASM-first requirement.
//! //!
//! [`Board::player_fov`](crate::board::Board::player_fov) builds a [`Visibility`] //! [`Board::lighting`](crate::board::Board::lighting) builds a [`Lighting`] by:
//! by marking each opaque cell as sight-blocking and casting from the player's //! 1. casting an unbounded line-of-sight field from the player (pure geometry),
//! position; the front-end then queries [`Visibility::is_visible`] per cell. //! 2. accumulating per-cell colored light from every source — the player's
//! torch plus each light-emitting object and glowing terrain cell — where a
//! source's color is its own glyph foreground and its contribution falls off
//! linearly to zero at its radius.
//!
//! The front-end then, per cell, asks [`Lighting::is_visible`] (LOS ∩ lit) and
//! [`Lighting::tint`] (modulate the glyph's colors by the received light).
use color::Rgba8;
use doryen_fov::MapData; use doryen_fov::MapData;
/// How far the player can see on a dark board, in cells. /// Default player torch radius in cells, used when a world omits `torch = N`
/// from its `[world]` header. See [`World::torch`](crate::world::World::torch).
pub const SIGHT_RADIUS: usize = 10; pub const SIGHT_RADIUS: usize = 10;
/// The result of a field-of-view computation: which board cells are currently /// A finished lighting computation for one dark-board frame: per-cell player
/// visible to the player. Produced by /// line-of-sight plus accumulated colored light. Produced by
/// [`Board::player_fov`](crate::board::Board::player_fov) and queried per cell /// [`Board::lighting`](crate::board::Board::lighting); queried per cell by
/// by front-ends when rendering a dark board. /// front-ends when rendering a dark board.
pub struct Visibility { pub struct Lighting {
/// The `doryen_fov` map holding per-cell transparency + computed visibility. /// Board width in cells (row stride for `los` / `light`).
width: usize,
/// Player line-of-sight: `true` where the player has an unobstructed
/// sightline, regardless of whether the cell is lit.
los: Vec<bool>,
/// Accumulated linear light per cell as `[r, g, b]` in `0.0..` (may exceed
/// `1.0` where lights overlap; clamped when applied). Zero means unlit.
light: Vec<[f32; 3]>,
}
impl Lighting {
/// Builds an all-dark lighting buffer of `width * height` cells for the
/// caller ([`Board::lighting`]) to fill.
pub(crate) fn new(width: usize, height: usize) -> Self {
Self {
width,
los: vec![false; width * height],
light: vec![[0.0; 3]; width * height],
}
}
/// Is board cell `(x, y)` visible — player has a sightline **and** it is lit?
///
/// This is the render/speech gate: a lit area the player can't line up to
/// (around a corner) is not visible, and a cell in view but unlit is dark.
pub fn is_visible(&self, x: usize, y: usize) -> bool {
let i = y * self.width + x;
self.los[i] && intensity(self.light[i]) > LIGHT_EPSILON
}
/// Modulates a base color by the light this cell receives: each channel is
/// multiplied by the accumulated light (clamped to `1.0`), so a white light
/// just dims with distance while a colored light tints toward its own hue.
/// Alpha is preserved. Callers should only tint cells that
/// [`is_visible`](Lighting::is_visible).
pub fn tint(&self, x: usize, y: usize, base: Rgba8) -> Rgba8 {
let l = self.light[y * self.width + x];
Rgba8 {
r: (base.r as f32 * l[0].min(1.0)) as u8,
g: (base.g as f32 * l[1].min(1.0)) as u8,
b: (base.b as f32 * l[2].min(1.0)) as u8,
a: base.a,
}
}
/// Marks cell `(x, y)` as within the player's line of sight. Used by the
/// LOS pass in [`Board::lighting`].
pub(crate) fn set_los(&mut self, x: usize, y: usize) {
self.los[y * self.width + x] = true;
}
/// Adds `amount` linear light (already color- and falloff-scaled) to cell
/// `(x, y)`. Used by the per-source accumulation in [`Board::lighting`].
pub(crate) fn add_light(&mut self, x: usize, y: usize, amount: [f32; 3]) {
let cell = &mut self.light[y * self.width + x];
cell[0] += amount[0];
cell[1] += amount[1];
cell[2] += amount[2];
}
}
/// Cells dimmer than this (summed linear intensity) count as unlit.
const LIGHT_EPSILON: f32 = 0.001;
/// Summed linear intensity of an accumulated light value — the "is it lit at
/// all" measure used by [`Lighting::is_visible`].
fn intensity(l: [f32; 3]) -> f32 {
l[0] + l[1] + l[2]
}
/// Normalizes an 8-bit color to a `0.0..=1.0` linear-ish RGB triple, used as a
/// light source's color (a source emits its own glyph's foreground color).
pub(crate) fn color_to_rgb(c: Rgba8) -> [f32; 3] {
[c.r as f32 / 255.0, c.g as f32 / 255.0, c.b as f32 / 255.0]
}
/// A reusable helper wrapping the `doryen_fov` shadow-caster and a single
/// scratch [`MapData`] whose transparency is set once from the board's opaque
/// cells, so [`Board::lighting`] can cast many sources without reallocating.
pub(crate) struct FovCaster {
algo: doryen_fov::FovRecursiveShadowCasting,
map: MapData, map: MapData,
} }
impl Visibility { impl FovCaster {
/// Wraps a finished [`MapData`] (visibility already computed) as a /// Builds a caster over a `width * height` map. `transparent(x, y)` supplies
/// [`Visibility`]. Called by [`Board::player_fov`](crate::board::Board::player_fov). /// each cell's transparency (`true` = light/sight passes through).
pub(crate) fn new(map: MapData) -> Self { pub(crate) fn new(width: usize, height: usize, transparent: impl Fn(usize, usize) -> bool) -> Self {
Self { map } let mut map = MapData::new(width, height);
for y in 0..height {
for x in 0..width {
map.set_transparent(x, y, transparent(x, y));
}
}
Self {
algo: doryen_fov::FovRecursiveShadowCasting::new(),
map,
}
} }
/// Is board cell `(x, y)` currently visible to the player? /// Casts a field of view from `(x, y)` out to `radius` (0 = unbounded) and
/// /// invokes `visit(cx, cy)` for every cell it lights. `light_walls = true`
/// Out-of-bounds coordinates are not expected (the caller iterates the /// includes opaque cells at the edge of the field. Clears prior results
/// board grid) and would panic inside [`MapData::is_in_fov`]. /// first, so successive calls are independent.
pub fn is_visible(&self, x: usize, y: usize) -> bool { pub(crate) fn cast(
self.map.is_in_fov(x, y) &mut self,
x: usize,
y: usize,
radius: usize,
mut visit: impl FnMut(usize, usize),
) {
use doryen_fov::FovAlgorithm;
self.map.clear_fov();
self.algo.compute_fov(&mut self.map, x, y, radius, true);
let (w, h) = (self.map.width, self.map.height);
for cy in 0..h {
for cx in 0..w {
if self.map.is_in_fov(cx, cy) {
visit(cx, cy);
}
}
}
} }
} }
+12
View File
@@ -161,6 +161,7 @@ impl GameState {
let world = World { let world = World {
name: String::new(), name: String::new(),
start: "board".to_string(), start: "board".to_string(),
torch: crate::fov::SIGHT_RADIUS as u32,
scripts, scripts,
boards: std::collections::HashMap::from([("board".to_string(), board_ref)]), boards: std::collections::HashMap::from([("board".to_string(), board_ref)]),
}; };
@@ -182,6 +183,12 @@ impl GameState {
&self.current_board_name &self.current_board_name
} }
/// The player's torch radius (world-wide config; see [`World::torch`]),
/// passed to [`Board::lighting`] when rendering a dark board.
pub fn torch(&self) -> u32 {
self.world.torch
}
/// Returns a clone of the `Rc` for the active board. /// Returns a clone of the `Rc` for the active board.
/// ///
/// Lets a front-end hold a reference to the current board across a board /// Lets a front-end hold a reference to the current board across a board
@@ -304,6 +311,11 @@ impl GameState {
obj.glyph.tile = tile; obj.glyph.tile = tile;
} }
} }
Action::SetLight(radius) => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.light = radius;
}
}
Action::SetTag { Action::SetTag {
target, target,
tag, tag,
+7
View File
@@ -85,6 +85,10 @@ pub(crate) struct PaletteEntry {
/// Object pushability (defaults `false`). Only for `kind = "object"`. /// Object pushability (defaults `false`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub pushable: Option<bool>, pub pushable: Option<bool>,
/// Light radius in cells emitted on a dark board (defaults `0` = none).
/// Only for `kind = "object"`. See [`ObjectDef::light`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub light: Option<u32>,
/// Rhai script name. Only for `kind = "object"`. /// Rhai script name. Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub script_name: Option<String>, pub script_name: Option<String>,
@@ -126,6 +130,8 @@ pub(crate) struct ObjectTemplate {
pub solid: bool, pub solid: bool,
pub opaque: bool, pub opaque: bool,
pub pushable: bool, pub pushable: bool,
/// Light radius in cells (0 = none); see [`ObjectDef::light`].
pub light: u32,
pub script_name: Option<String>, pub script_name: Option<String>,
pub tags: Vec<String>, pub tags: Vec<String>,
pub name: Option<String>, pub name: Option<String>,
@@ -174,6 +180,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
solid: e.solid.unwrap_or(true), solid: e.solid.unwrap_or(true),
opaque: e.opaque.unwrap_or(true), opaque: e.opaque.unwrap_or(true),
pushable: e.pushable.unwrap_or(false), pushable: e.pushable.unwrap_or(false),
light: e.light.unwrap_or(0),
script_name: e.script_name.clone(), script_name: e.script_name.clone(),
tags: e.tags.clone().unwrap_or_default(), tags: e.tags.clone().unwrap_or_default(),
name: e.name.clone(), name: e.name.clone(),
+2 -2
View File
@@ -8,7 +8,7 @@ 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`]). /// Lighting & field-of-view for dark boards ([`fov::Lighting`]).
pub mod fov; 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;
@@ -28,7 +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 fov::{Lighting, SIGHT_RADIUS};
pub use utils::Direction; pub use utils::Direction;
#[cfg(test)] #[cfg(test)]
+3
View File
@@ -319,6 +319,7 @@ impl TryFrom<MapFile> for Board {
solid: t.solid, solid: t.solid,
opaque: t.opaque, opaque: t.opaque,
pushable: t.pushable, pushable: t.pushable,
light: t.light,
// Hand-placed objects are never grab targets; only expanded // Hand-placed objects are never grab targets; only expanded
// grab archetypes (e.g. gems) set this (see expand_builtin_archetypes). // grab archetypes (e.g. gems) set this (see expand_builtin_archetypes).
grab: false, grab: false,
@@ -357,6 +358,7 @@ impl TryFrom<MapFile> for Board {
solid: false, solid: false,
opaque: false, opaque: false,
pushable: false, pushable: false,
light: 0,
grab: false, grab: false,
script_name: Some(t.script_name), script_name: Some(t.script_name),
builtin_script: None, builtin_script: None,
@@ -564,6 +566,7 @@ fn grid_to_data(board: &Board) -> GridData {
solid: Some(o.solid), solid: Some(o.solid),
opaque: Some(o.opaque), opaque: Some(o.opaque),
pushable: Some(o.pushable), pushable: Some(o.pushable),
light: (o.light > 0).then_some(o.light),
script_name: o.script_name.clone(), script_name: o.script_name.clone(),
tags: (!tags.is_empty()).then_some(tags), tags: (!tags.is_empty()).then_some(tags),
name: o.name.clone(), name: o.name.clone(),
+6
View File
@@ -51,6 +51,11 @@ pub struct ObjectDef {
/// remove itself via `die()`). Set when a grab archetype (e.g. a gem) is /// remove itself via `die()`). Set when a grab archetype (e.g. a gem) is
/// expanded into an object. Defaults to `false`. /// expanded into an object. Defaults to `false`.
pub grab: bool, pub grab: bool,
/// Light radius in cells that this object emits on a `dark` board (0 = no
/// light). The emitted *color* is this object's own glyph foreground color,
/// so a red glyph casts red light. See [`crate::fov`] for the lighting model.
/// Scripts can change it at runtime via `set_light(n)`. Defaults to `0`.
pub light: u32,
/// Compile-key of the Rhai script that drives this object: a name in /// Compile-key of the Rhai script that drives this object: a name in
/// [`World::scripts`](crate::world::World::scripts) for a hand-authored object, /// [`World::scripts`](crate::world::World::scripts) for a hand-authored object,
/// or a synthetic `BUILTIN_*` name set when a script-backed archetype is /// or a synthetic `BUILTIN_*` name set when a script-backed archetype is
@@ -102,6 +107,7 @@ impl ObjectDef {
opaque: true, opaque: true,
pushable: false, pushable: false,
grab: false, grab: false,
light: 0,
script_name: None, script_name: None,
builtin_script: None, builtin_script: None,
tags: HashSet::new(), tags: HashSet::new(),
+7
View File
@@ -418,6 +418,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
emit(&b, source_of(&ctx), Action::SetTile(tile as u32)); emit(&b, source_of(&ctx), Action::SetTile(tile as u32));
}); });
// set_light(radius): change the source object's emitted light radius in cells
// (0 = no light). Negatives clamp to 0. Only visible on `dark` boards.
let b = board.clone();
engine.register_fn("set_light", move |ctx: NativeCallContext, radius: i64| {
emit(&b, source_of(&ctx), Action::SetLight(radius.max(0) as u32));
});
// alter_gems(n): change the player's gem count (negative subtracts; clamped at 0). // alter_gems(n): change the player's gem count (negative subtracts; clamped at 0).
let b = board.clone(); let b = board.clone();
engine.register_fn("alter_gems", move |ctx: NativeCallContext, n: i64| { engine.register_fn("alter_gems", move |ctx: NativeCallContext, n: i64| {
+1
View File
@@ -59,6 +59,7 @@ fn two_board_world() -> World {
World { World {
name: "test".into(), name: "test".into(),
start: "b1".into(), start: "b1".into(),
torch: 10,
scripts: HashMap::new(), scripts: HashMap::new(),
boards: HashMap::from([ boards: HashMap::from([
("b1".to_string(), Rc::new(RefCell::new(b1))), ("b1".to_string(), Rc::new(RefCell::new(b1))),
+12
View File
@@ -1,6 +1,7 @@
//! World type: a named collection of boards loaded from a single `.toml` file. //! World type: a named collection of boards loaded from a single `.toml` file.
use crate::board::Board; use crate::board::Board;
use crate::fov::SIGHT_RADIUS;
use crate::map_file::MapFile; use crate::map_file::MapFile;
use serde::Deserialize; use serde::Deserialize;
use std::cell::RefCell; use std::cell::RefCell;
@@ -22,6 +23,11 @@ pub struct World {
pub name: String, pub name: String,
/// Key of the starting board; matches a key in [`World::boards`]. /// Key of the starting board; matches a key in [`World::boards`].
pub start: String, pub start: String,
/// The player's torch radius in cells on `dark` boards — world-wide config
/// from the `[world]` header (`torch = N`), defaulting to [`SIGHT_RADIUS`]
/// when absent. The player's torch is a white light source centered on the
/// player; see [`Board::lighting`](crate::board::Board::lighting).
pub torch: u32,
/// Named Rhai scripts shared across all boards: script name → source text. /// Named Rhai scripts shared across all boards: script name → source text.
/// ///
/// Objects on any board reference a script by name via /// Objects on any board reference a script by name via
@@ -49,6 +55,7 @@ impl World {
World { World {
name: self.name.clone(), name: self.name.clone(),
start: self.start.clone(), start: self.start.clone(),
torch: self.torch,
scripts: self.scripts.clone(), scripts: self.scripts.clone(),
boards: self boards: self
.boards .boards
@@ -78,6 +85,9 @@ struct WorldHeader {
name: String, name: String,
/// Key of the board to start on; must match a key in `[boards]`. /// Key of the board to start on; must match a key in `[boards]`.
start: String, start: String,
/// Optional player torch radius on dark boards; defaults to [`SIGHT_RADIUS`].
#[serde(default)]
torch: Option<u32>,
} }
/// Load a world from a `.toml` world file at `path`. /// Load a world from a `.toml` world file at `path`.
@@ -110,6 +120,7 @@ pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>> {
Ok(World { Ok(World {
name: wf.world.name, name: wf.world.name,
start: wf.world.start, start: wf.world.start,
torch: wf.world.torch.unwrap_or(SIGHT_RADIUS as u32),
scripts: wf.scripts, scripts: wf.scripts,
boards, boards,
}) })
@@ -134,6 +145,7 @@ mod tests {
let world = World { let world = World {
name: "w".into(), name: "w".into(),
start: "start".into(), start: "start".into(),
torch: 10,
scripts: HashMap::new(), scripts: HashMap::new(),
boards, boards,
}; };
+4 -4
View File
@@ -424,10 +424,10 @@ fn draw_board_area(frame: &mut Frame, inner: Rect, game: &GameState, ui: &mut Ui
); );
} else { } else {
let board = &game.board(); let board = &game.board();
// On a dark board this is the player's field of view; on a lit board it's // On a dark board this is the player's lighting (LOS + colored light); on a
// None (draw everything). Computed once and shared by both widgets so the // lit board it's None (draw everything). Computed once and shared by both
// board and its speech bubbles agree on what's visible. // widgets so the board and its speech bubbles agree on what's visible.
let fov = board.player_fov(); let fov = board.lighting(game.torch());
frame.render_widget(BoardWidget::new(board).fov(fov.as_ref()), inner); frame.render_widget(BoardWidget::new(board).fov(fov.as_ref()), inner);
frame.render_widget( frame.render_widget(
SpeechBubblesWidget::new(board, &game.speech_bubbles).fov(fov.as_ref()), SpeechBubblesWidget::new(board, &game.speech_bubbles).fov(fov.as_ref()),
+56 -18
View File
@@ -8,7 +8,7 @@
use crate::utils::rgba8_to_color; use crate::utils::rgba8_to_color;
use color::Rgba8; use color::Rgba8;
use kiln_core::Board; use kiln_core::Board;
use kiln_core::Visibility; use kiln_core::Lighting;
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;
@@ -27,10 +27,10 @@ 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 /// The player's lighting on a [`dark`](Board::dark) board, if any. When
/// `Some`, cells not in view are drawn as darkness instead of their glyph. /// `Some`, cells that aren't visible are drawn as darkness and visible cells
/// `None` (a lit board) draws every cell normally. /// are tinted by the light they receive. `None` (a lit board) draws normally.
fov: Option<&'a Visibility>, fov: Option<&'a Lighting>,
} }
impl<'a> BoardWidget<'a> { impl<'a> BoardWidget<'a> {
@@ -47,9 +47,10 @@ impl<'a> BoardWidget<'a> {
self self
} }
/// Supplies the player's field of view (see [`Board::player_fov`]). When /// Supplies the player's lighting (see [`Board::lighting`]). When `Some`,
/// `Some`, cells outside it render as darkness; `None` renders everything. /// cells outside view render as darkness and visible cells are tinted by
pub fn fov(mut self, fov: Option<&'a Visibility>) -> Self { /// their received light; `None` renders everything at full color.
pub fn fov(mut self, fov: Option<&'a Lighting>) -> Self {
self.fov = fov; self.fov = fov;
self self
} }
@@ -147,16 +148,22 @@ impl Widget for BoardWidget<'_> {
let bx = off_x + col; let bx = off_x + col;
let sx = area.x + pad_x + col as u16; let sx = area.x + pad_x + col as u16;
// On a dark board, a cell outside the player's field of view is // On a dark board, a cell the player can't see (no sightline or
// drawn as darkness (blank cell, dark-gray background) instead of // unlit) is drawn as darkness (blank on dark gray). A visible cell
// its real glyph. A lit board (`fov == None`) shows every cell. // is drawn with its glyph, tinted by the light it receives. A lit
let visible = self.fov.is_none_or(|v| v.is_visible(bx, by)); // board (`fov == None`) shows every cell at full color.
let visible = self.fov.is_none_or(|l| l.is_visible(bx, by));
if let Some(cell) = buf.cell_mut((sx, sy)) { if let Some(cell) = buf.cell_mut((sx, sy)) {
if visible { if visible {
let glyph = board.glyph_at(bx, by); let glyph = board.glyph_at(bx, by);
// Modulate by lighting when the board is dark; pass through otherwise.
let (fg, bg) = match self.fov {
Some(l) => (l.tint(bx, by, glyph.fg), l.tint(bx, by, glyph.bg)),
None => (glyph.fg, glyph.bg),
};
cell.set_char(tile_to_char(glyph.tile)) cell.set_char(tile_to_char(glyph.tile))
.set_fg(rgba8_to_color(glyph.fg)) .set_fg(rgba8_to_color(fg))
.set_bg(rgba8_to_color(glyph.bg)); .set_bg(rgba8_to_color(bg));
} else { } else {
cell.set_char(' ') cell.set_char(' ')
.set_fg(rgba8_to_color(DARKNESS_BG)) .set_fg(rgba8_to_color(DARKNESS_BG))
@@ -176,6 +183,7 @@ mod tests {
use kiln_core::map_file::MapFile; use kiln_core::map_file::MapFile;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
/// Builds a tiny dark board: a 5×1 corridor with the player at the left end /// Builds a tiny dark board: a 5×1 corridor with the player at the left end
@@ -200,8 +208,8 @@ mod tests {
#[test] #[test]
fn dark_board_renders_occluded_cells_as_darkness() { fn dark_board_renders_occluded_cells_as_darkness() {
let board = dark_corridor(); let board = dark_corridor();
let fov = board.player_fov(); let fov = board.lighting(10);
assert!(fov.is_some(), "a dark board must produce a field of view"); assert!(fov.is_some(), "a dark board must produce lighting");
let area = Rect::new(0, 0, 5, 1); let area = Rect::new(0, 0, 5, 1);
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);
@@ -220,10 +228,10 @@ mod tests {
#[test] #[test]
fn lit_board_renders_every_cell_normally() { fn lit_board_renders_every_cell_normally() {
// The same board without `dark` gets no FOV, so nothing is hidden. // The same board without `dark` gets no lighting, so nothing is hidden.
let mut board = dark_corridor(); let mut board = dark_corridor();
board.dark = false; board.dark = false;
assert!(board.player_fov().is_none()); assert!(board.lighting(10).is_none());
let area = Rect::new(0, 0, 5, 1); let area = Rect::new(0, 0, 5, 1);
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);
@@ -232,6 +240,36 @@ mod tests {
assert_ne!(buf[(4, 0)].bg, rgba8_to_color(DARKNESS_BG)); assert_ne!(buf[(4, 0)].bg, rgba8_to_color(DARKNESS_BG));
} }
#[test]
fn colored_object_light_tints_nearby_and_darkens_far() {
// A dark 6×1 corridor, no player torch: a single red light object at x=2
// (radius 2) tints its own cell red; a cell beyond its reach is darkness.
let toml = r##"
[map]
name = "lit"
width = 6
height = 1
dark = true
[grid]
sparse = [ { x = 2, y = 0, ch = "L" } ]
[grid.palette]
"L" = { kind = "object", tile = 1, fg = "#ff0000", bg = "#000000", solid = false, light = 2 }
"##;
let board = Board::try_from(toml::from_str::<MapFile>(toml).unwrap()).unwrap();
let fov = board.lighting(0); // no player torch — only the object lights
let area = Rect::new(0, 0, 6, 1);
let mut buf = Buffer::empty(area);
BoardWidget::new(&board).fov(fov.as_ref()).render(area, &mut buf);
// The light's own cell is visible and tinted red (green/blue killed).
let lit = buf[(2, 0)].fg;
assert!(matches!(lit, Color::Rgb(r, 0, 0) if r > 0), "light cell tinted red, got {lit:?}");
// x=5 is out of the light's radius → darkness.
assert_eq!(buf[(5, 0)].symbol(), " ");
assert_eq!(buf[(5, 0)].bg, rgba8_to_color(DARKNESS_BG));
}
#[test] #[test]
fn axis_centers_a_small_board() { fn axis_centers_a_small_board() {
// 10-cell board in a 20-cell view: no scroll, 5 cells of pad each side. // 10-cell board in a 20-cell view: no scroll, 5 cells of pad each side.
+7 -7
View File
@@ -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, Visibility}; use kiln_core::{Board, Direction, Lighting};
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,10 +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 /// The player's lighting on a dark board, if any. A speaker the player can't
/// still gets a bubble, but with no tail (like an off-screen speaker) — /// see 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. /// you hear the unseen thing without a line pointing to where it hides.
fov: Option<&'a Visibility>, fov: Option<&'a Lighting>,
} }
/// A fully-resolved bubble placement ready for the three draw passes. /// A fully-resolved bubble placement ready for the three draw passes.
@@ -47,9 +47,9 @@ impl<'a> SpeechBubblesWidget<'a> {
Self { board, bubbles, fov: None } Self { board, bubbles, fov: None }
} }
/// Supplies the player's field of view (see [`Board::player_fov`]). A speaker /// Supplies the player's lighting (see [`Board::lighting`]). A speaker the
/// outside it has its tail suppressed; `None` (a lit board) leaves tails as-is. /// player can't see has its tail suppressed; `None` (a lit board) leaves tails as-is.
pub fn fov(mut self, fov: Option<&'a Visibility>) -> Self { pub fn fov(mut self, fov: Option<&'a Lighting>) -> Self {
self.fov = fov; self.fov = fov;
self self
} }
+11 -6
View File
@@ -1,6 +1,9 @@
[world] [world]
name = "Dark Maze" name = "Dark Maze"
start = "cave" start = "cave"
# The player's own torch is small, so external lights clearly matter. Remove this
# line to fall back to the default radius of 10.
torch = 4
[scripts] [scripts]
# An always-talking object. Placed behind a wall so it starts out of the player's # An always-talking object. Placed behind a wall so it starts out of the player's
@@ -31,9 +34,10 @@ height = 15
dark = true dark = true
floor = { generator = "stone" } floor = { generator = "stone" }
# A room the player stands in, with a wall dividing it. The 'H' object sits in # The 'H' object glows magenta inside a walled chamber: its light fills that room
# the far chamber behind the wall (out of sight until you walk around); the 'L' # but the player can't see in until they line up with the gap in the wall. 'L' is
# lantern sits in the open with the player. # an amber lantern out in the open, and 'T' is a wall torch (glowing terrain) off
# in a far corner — a warm point of light you'll only reach by walking to it.
[boards.cave.grid] [boards.cave.grid]
content = """ content = """
######################################## ########################################
@@ -46,7 +50,7 @@ content = """
# #### ###### # # #### ###### #
# # # #
# # # #
# # # T #
# # # #
# # # #
# # # #
@@ -55,5 +59,6 @@ content = """
[boards.cave.grid.palette] [boards.cave.grid.palette]
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#404040" } "#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#404040" }
"@" = { kind = "player" } "@" = { kind = "player" }
"H" = { kind = "object", tile = 2, fg = "#cc66cc", bg = "#000000", solid = true, name = "hidden", script_name = "hidden" } "T" = { kind = "torch" } # glowing terrain: emits its own warm light
"L" = { kind = "object", tile = 15, fg = "#ffdd88", bg = "#000000", solid = true, name = "lantern", script_name = "lantern" } "H" = { kind = "object", tile = 2, fg = "#cc66cc", bg = "#000000", solid = true, light = 5, name = "hidden", script_name = "hidden" }
"L" = { kind = "object", tile = 15, fg = "#ffcc55", bg = "#000000", solid = true, light = 6, name = "lantern", script_name = "lantern" }