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),
/// 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})"),
+8
View File
@@ -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)
+29
View File
@@ -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}")),
+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");
}
}
+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
//! 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<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,
}
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);
}
}
}
}
}
+12
View File
@@ -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,
+7
View File
@@ -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<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"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script_name: Option<String>,
@@ -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<String>,
pub tags: Vec<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),
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(),
+2 -2
View File
@@ -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)]
+3
View File
@@ -319,6 +319,7 @@ impl TryFrom<MapFile> 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<MapFile> 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(),
+6
View File
@@ -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(),
+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));
});
// 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| {
+1
View File
@@ -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))),
+12
View File
@@ -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<u32>,
}
/// 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 {
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,
};