lighting
This commit is contained in:
@@ -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})"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user