From 03185c9c688dbdd516e56a92a78b9de9086fbcfe Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sat, 11 Jul 2026 14:47:08 -0500 Subject: [PATCH] lighting --- kiln-core/src/action.rs | 4 + kiln-core/src/api/object_info.rs | 8 ++ kiln-core/src/archetype.rs | 29 +++++ kiln-core/src/board.rs | 144 ++++++++++++++++-------- kiln-core/src/fov.rs | 163 +++++++++++++++++++++++----- kiln-core/src/game.rs | 12 ++ kiln-core/src/layer.rs | 7 ++ kiln-core/src/lib.rs | 4 +- kiln-core/src/map_file.rs | 3 + kiln-core/src/object_def.rs | 6 + kiln-core/src/script.rs | 7 ++ kiln-core/src/tests/game_portals.rs | 1 + kiln-core/src/world.rs | 12 ++ kiln-tui/src/main.rs | 8 +- kiln-tui/src/render.rs | 74 ++++++++++--- kiln-tui/src/speech.rs | 14 +-- maps/dark.toml | 17 ++- 17 files changed, 408 insertions(+), 105 deletions(-) diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index 759a8a8..0a24176 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -72,6 +72,9 @@ pub enum Action { Move(Direction), /// Set the source object's glyph tile index. 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`. SetTag { target: ObjectId, @@ -131,6 +134,7 @@ impl Debug for Action { match self { Action::Move(dir) => write!(f, "Move({:?})", dir), Action::SetTile(i) => write!(f, "SetTile({i})"), + Action::SetLight(r) => write!(f, "SetLight({r})"), Action::SetTag { .. } => write!(f, "SetTag"), Action::Say(_, _) => write!(f, "Say"), Action::Delay(t) => write!(f, "Delay({t})"), diff --git a/kiln-core/src/api/object_info.rs b/kiln-core/src/api/object_info.rs index 59fc683..d68f849 100644 --- a/kiln-core/src/api/object_info.rs +++ b/kiln-core/src/api/object_info.rs @@ -113,6 +113,14 @@ impl Registerable for ObjectInfo { 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| { if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) { tags.contains(&t) diff --git a/kiln-core/src/archetype.rs b/kiln-core/src/archetype.rs index cb472af..1669361 100644 --- a/kiln-core/src/archetype.rs +++ b/kiln-core/src/archetype.rs @@ -184,6 +184,9 @@ pub enum Archetype { HCrate, /// A crate that can only be pushed north/south (vertical axis). Glyph ↕. 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. /// /// - `Builtin` is the family (e.g. `Builtin::Pusher`), which selects the @@ -234,6 +237,13 @@ impl Archetype { pushable: Pushable::Vertical, 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 { solid: true, opaque: true, @@ -252,10 +262,23 @@ impl Archetype { Archetype::Crate => "crate", Archetype::HCrate => "hcrate", Archetype::VCrate => "vcrate", + Archetype::Torch => "torch", 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. /// /// 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 }, 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. Archetype::ErrorBlock => Glyph { tile: 63, @@ -319,6 +347,7 @@ impl TryFrom<&str> for Archetype { "crate" => Ok(Archetype::Crate), "hcrate" => Ok(Archetype::HCrate), "vcrate" => Ok(Archetype::VCrate), + "torch" => Ok(Archetype::Torch), // "object", "portal", "player" are intentionally absent: they are // meta-kinds handled by the layer builder, not Archetype variants. _ => Err(format!("unknown archetype: {name}")), diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 4518df3..c4b3414 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -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, - /// 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 { + /// 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 { 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"); } } diff --git a/kiln-core/src/fov.rs b/kiln-core/src/fov.rs index 6ba2114..c219eb0 100644 --- a/kiln-core/src/fov.rs +++ b/kiln-core/src/fov.rs @@ -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 -//! can actually see; everything else is drawn as unlit darkness by the -//! front-end. Visibility is computed with the [`doryen_fov`] crate's recursive +//! A [`Board`](crate::board::Board) marked `dark` reveals a cell only when the +//! player has a **line of sight** to it *and* it receives **light** from some +//! source. Both are 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. +//! [`Board::lighting`](crate::board::Board::lighting) builds a [`Lighting`] by: +//! 1. casting an unbounded line-of-sight field from the player (pure geometry), +//! 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; -/// 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; -/// 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. +/// A finished lighting computation for one dark-board frame: per-cell player +/// line-of-sight plus accumulated colored light. Produced by +/// [`Board::lighting`](crate::board::Board::lighting); queried per cell by +/// front-ends when rendering a dark board. +pub struct Lighting { + /// 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, + /// 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, } -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 } +impl FovCaster { + /// Builds a caster over a `width * height` map. `transparent(x, y)` supplies + /// each cell's transparency (`true` = light/sight passes through). + pub(crate) fn new(width: usize, height: usize, transparent: impl Fn(usize, usize) -> bool) -> Self { + 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? - /// - /// 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) + /// 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` + /// includes opaque cells at the edge of the field. Clears prior results + /// first, so successive calls are independent. + pub(crate) fn cast( + &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); + } + } + } } } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 5286baf..64fa0f6 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -161,6 +161,7 @@ impl GameState { let world = World { name: String::new(), start: "board".to_string(), + torch: crate::fov::SIGHT_RADIUS as u32, scripts, boards: std::collections::HashMap::from([("board".to_string(), board_ref)]), }; @@ -182,6 +183,12 @@ impl GameState { &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. /// /// Lets a front-end hold a reference to the current board across a board @@ -304,6 +311,11 @@ impl GameState { obj.glyph.tile = tile; } } + Action::SetLight(radius) => { + if let Some(obj) = board.objects.get_mut(&ba.source) { + obj.light = radius; + } + } Action::SetTag { target, tag, diff --git a/kiln-core/src/layer.rs b/kiln-core/src/layer.rs index 22790d6..abb3747 100644 --- a/kiln-core/src/layer.rs +++ b/kiln-core/src/layer.rs @@ -85,6 +85,10 @@ pub(crate) struct PaletteEntry { /// Object pushability (defaults `false`). Only for `kind = "object"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub pushable: Option, + /// 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, /// Rhai script name. Only for `kind = "object"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub script_name: Option, @@ -126,6 +130,8 @@ pub(crate) struct ObjectTemplate { pub solid: bool, pub opaque: bool, pub pushable: bool, + /// Light radius in cells (0 = none); see [`ObjectDef::light`]. + pub light: u32, pub script_name: Option, pub tags: Vec, pub name: Option, @@ -174,6 +180,7 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec) -> Resol solid: e.solid.unwrap_or(true), opaque: e.opaque.unwrap_or(true), pushable: e.pushable.unwrap_or(false), + light: e.light.unwrap_or(0), script_name: e.script_name.clone(), tags: e.tags.clone().unwrap_or_default(), name: e.name.clone(), diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index 3719edb..4779470 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -8,7 +8,7 @@ pub mod colors; pub mod cp437; /// Procedural floor generators ([`floor::FloorGenerator`]). pub mod floor; -/// Field-of-view computation for dark boards ([`fov::Visibility`]). +/// Lighting & field-of-view for dark boards ([`fov::Lighting`]). pub mod fov; /// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc. pub mod game; @@ -28,7 +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 fov::{Lighting, SIGHT_RADIUS}; pub use utils::Direction; #[cfg(test)] diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index f539ef1..2119885 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -319,6 +319,7 @@ impl TryFrom for Board { solid: t.solid, opaque: t.opaque, pushable: t.pushable, + light: t.light, // Hand-placed objects are never grab targets; only expanded // grab archetypes (e.g. gems) set this (see expand_builtin_archetypes). grab: false, @@ -357,6 +358,7 @@ impl TryFrom for Board { solid: false, opaque: false, pushable: false, + light: 0, grab: false, script_name: Some(t.script_name), builtin_script: None, @@ -564,6 +566,7 @@ fn grid_to_data(board: &Board) -> GridData { solid: Some(o.solid), opaque: Some(o.opaque), pushable: Some(o.pushable), + light: (o.light > 0).then_some(o.light), script_name: o.script_name.clone(), tags: (!tags.is_empty()).then_some(tags), name: o.name.clone(), diff --git a/kiln-core/src/object_def.rs b/kiln-core/src/object_def.rs index f6b1795..243a2b1 100644 --- a/kiln-core/src/object_def.rs +++ b/kiln-core/src/object_def.rs @@ -51,6 +51,11 @@ pub struct ObjectDef { /// remove itself via `die()`). Set when a grab archetype (e.g. a gem) is /// expanded into an object. Defaults to `false`. 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 /// [`World::scripts`](crate::world::World::scripts) for a hand-authored object, /// or a synthetic `BUILTIN_*` name set when a script-backed archetype is @@ -102,6 +107,7 @@ impl ObjectDef { opaque: true, pushable: false, grab: false, + light: 0, script_name: None, builtin_script: None, tags: HashSet::new(), diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index c3ad3ce..0070cce 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -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)); }); + // 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). let b = board.clone(); engine.register_fn("alter_gems", move |ctx: NativeCallContext, n: i64| { diff --git a/kiln-core/src/tests/game_portals.rs b/kiln-core/src/tests/game_portals.rs index d13703d..0e52959 100644 --- a/kiln-core/src/tests/game_portals.rs +++ b/kiln-core/src/tests/game_portals.rs @@ -59,6 +59,7 @@ fn two_board_world() -> World { World { name: "test".into(), start: "b1".into(), + torch: 10, scripts: HashMap::new(), boards: HashMap::from([ ("b1".to_string(), Rc::new(RefCell::new(b1))), diff --git a/kiln-core/src/world.rs b/kiln-core/src/world.rs index 5da572c..d9d0f57 100644 --- a/kiln-core/src/world.rs +++ b/kiln-core/src/world.rs @@ -1,6 +1,7 @@ //! World type: a named collection of boards loaded from a single `.toml` file. use crate::board::Board; +use crate::fov::SIGHT_RADIUS; use crate::map_file::MapFile; use serde::Deserialize; use std::cell::RefCell; @@ -22,6 +23,11 @@ pub struct World { pub name: String, /// Key of the starting board; matches a key in [`World::boards`]. 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. /// /// Objects on any board reference a script by name via @@ -49,6 +55,7 @@ impl World { World { name: self.name.clone(), start: self.start.clone(), + torch: self.torch, scripts: self.scripts.clone(), boards: self .boards @@ -78,6 +85,9 @@ struct WorldHeader { name: String, /// Key of the board to start on; must match a key in `[boards]`. start: String, + /// Optional player torch radius on dark boards; defaults to [`SIGHT_RADIUS`]. + #[serde(default)] + torch: Option, } /// Load a world from a `.toml` world file at `path`. @@ -110,6 +120,7 @@ pub fn load(path: &str) -> Result> { Ok(World { name: wf.world.name, start: wf.world.start, + torch: wf.world.torch.unwrap_or(SIGHT_RADIUS as u32), scripts: wf.scripts, boards, }) @@ -134,6 +145,7 @@ mod tests { let world = World { name: "w".into(), start: "start".into(), + torch: 10, scripts: HashMap::new(), boards, }; diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 4bfea88..3a6ef5b 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -424,10 +424,10 @@ fn draw_board_area(frame: &mut Frame, inner: Rect, game: &GameState, ui: &mut Ui ); } else { let board = &game.board(); - // On a dark board this is the player's field of view; on a lit board it's - // None (draw everything). Computed once and shared by both widgets so the - // board and its speech bubbles agree on what's visible. - let fov = board.player_fov(); + // On a dark board this is the player's lighting (LOS + colored light); on a + // lit board it's None (draw everything). Computed once and shared by both + // widgets so the board and its speech bubbles agree on what's visible. + let fov = board.lighting(game.torch()); frame.render_widget(BoardWidget::new(board).fov(fov.as_ref()), inner); frame.render_widget( SpeechBubblesWidget::new(board, &game.speech_bubbles).fov(fov.as_ref()), diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index 687cc43..798deac 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -8,7 +8,7 @@ use crate::utils::rgba8_to_color; use color::Rgba8; use kiln_core::Board; -use kiln_core::Visibility; +use kiln_core::Lighting; use kiln_core::cp437::tile_to_char; use ratatui::buffer::Buffer; 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 player position so play mode requires no extra setup. focus: (i32, i32), - /// The player's field of view on a [`dark`](Board::dark) board, if any. When - /// `Some`, cells not in view are drawn as darkness instead of their glyph. - /// `None` (a lit board) draws every cell normally. - fov: Option<&'a Visibility>, + /// The player's lighting on a [`dark`](Board::dark) board, if any. When + /// `Some`, cells that aren't visible are drawn as darkness and visible cells + /// are tinted by the light they receive. `None` (a lit board) draws normally. + fov: Option<&'a Lighting>, } impl<'a> BoardWidget<'a> { @@ -47,9 +47,10 @@ impl<'a> BoardWidget<'a> { self } - /// Supplies the player's field of view (see [`Board::player_fov`]). When - /// `Some`, cells outside it render as darkness; `None` renders everything. - pub fn fov(mut self, fov: Option<&'a Visibility>) -> Self { + /// Supplies the player's lighting (see [`Board::lighting`]). When `Some`, + /// cells outside view render as darkness and visible cells are tinted by + /// their received light; `None` renders everything at full color. + pub fn fov(mut self, fov: Option<&'a Lighting>) -> Self { self.fov = fov; self } @@ -147,16 +148,22 @@ impl Widget for BoardWidget<'_> { 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)); + // On a dark board, a cell the player can't see (no sightline or + // unlit) is drawn as darkness (blank on dark gray). A visible cell + // is drawn with its glyph, tinted by the light it receives. A lit + // 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 visible { 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)) - .set_fg(rgba8_to_color(glyph.fg)) - .set_bg(rgba8_to_color(glyph.bg)); + .set_fg(rgba8_to_color(fg)) + .set_bg(rgba8_to_color(bg)); } else { cell.set_char(' ') .set_fg(rgba8_to_color(DARKNESS_BG)) @@ -176,6 +183,7 @@ mod tests { use kiln_core::map_file::MapFile; use ratatui::buffer::Buffer; use ratatui::layout::Rect; + use ratatui::style::Color; use ratatui::widgets::Widget; /// Builds a tiny dark board: a 5×1 corridor with the player at the left end @@ -200,8 +208,8 @@ mod tests { #[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 fov = board.lighting(10); + assert!(fov.is_some(), "a dark board must produce lighting"); let area = Rect::new(0, 0, 5, 1); let mut buf = Buffer::empty(area); @@ -220,10 +228,10 @@ mod tests { #[test] 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(); board.dark = false; - assert!(board.player_fov().is_none()); + assert!(board.lighting(10).is_none()); let area = Rect::new(0, 0, 5, 1); let mut buf = Buffer::empty(area); @@ -232,6 +240,36 @@ mod tests { 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::(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] fn axis_centers_a_small_board() { // 10-cell board in a 20-cell view: no scroll, 5 cells of pad each side. diff --git a/kiln-tui/src/speech.rs b/kiln-tui/src/speech.rs index 47c822e..76c213a 100644 --- a/kiln-tui/src/speech.rs +++ b/kiln-tui/src/speech.rs @@ -1,7 +1,7 @@ use crate::render::BoardWidget; use crate::utils::rects_overlap; use kiln_core::game::SpeechBubble; -use kiln_core::{Board, Direction, Visibility}; +use kiln_core::{Board, Direction, Lighting}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::{Color, Line, Style, Widget}; @@ -19,10 +19,10 @@ pub struct SpeechBubblesWidget<'a> { pub board: &'a Board, /// The active speech bubbles to render. pub bubbles: &'a [SpeechBubble], - /// The player's field of view on a dark board, if any. A speaker outside it - /// still gets a bubble, but with no tail (like an off-screen speaker) — + /// The player's lighting on a dark board, if any. A speaker the player can't + /// 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. - fov: Option<&'a Visibility>, + fov: Option<&'a Lighting>, } /// A fully-resolved bubble placement ready for the three draw passes. @@ -47,9 +47,9 @@ impl<'a> SpeechBubblesWidget<'a> { 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 { + /// Supplies the player's lighting (see [`Board::lighting`]). A speaker the + /// player can't see has its tail suppressed; `None` (a lit board) leaves tails as-is. + pub fn fov(mut self, fov: Option<&'a Lighting>) -> Self { self.fov = fov; self } diff --git a/maps/dark.toml b/maps/dark.toml index 7c38925..3748593 100644 --- a/maps/dark.toml +++ b/maps/dark.toml @@ -1,6 +1,9 @@ [world] name = "Dark Maze" 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] # An always-talking object. Placed behind a wall so it starts out of the player's @@ -31,9 +34,10 @@ 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. +# The 'H' object glows magenta inside a walled chamber: its light fills that room +# but the player can't see in until they line up with the gap in the wall. 'L' is +# 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] content = """ ######################################## @@ -46,7 +50,7 @@ content = """ # #### ###### # # # # # -# # +# T # # # # # # # @@ -55,5 +59,6 @@ content = """ [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" } +"T" = { kind = "torch" } # glowing terrain: emits its own warm light +"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" }