the huge refactor

This commit is contained in:
2026-07-24 21:52:05 -05:00
parent 5146cc9bcc
commit 917f4b1bf0
43 changed files with 1807 additions and 3267 deletions
+60
View File
@@ -9,6 +9,7 @@ use std::fmt::Debug;
use crate::utils::{Direction, ObjectId}; use crate::utils::{Direction, ObjectId};
use color::Rgba8; use color::Rgba8;
use rhai::Dynamic; use rhai::Dynamic;
use crate::Board;
/// How long a move occupies an object before it can act again, in seconds. /// How long a move occupies an object before it can act again, in seconds.
pub(crate) const MOVE_COST: f64 = 0.25; pub(crate) const MOVE_COST: f64 = 0.25;
@@ -160,3 +161,62 @@ pub struct BoardAction {
/// The action to apply. /// The action to apply.
pub(crate) action: Action, pub(crate) action: Action,
} }
#[derive(Clone, Debug, PartialEq)]
pub enum Consequence {
Enter(ObjectId),
Bump(ObjectId, Direction),
Send(ObjectId, String, SendArg)
}
fn enters_for_cell(board: &Board, x: usize, y: usize) -> Vec<Consequence> {
board.sensor_ids_at(x, y).into_iter().map(|id| Consequence::Enter(id)).collect()
}
pub fn apply_teleport(board: &mut Board, target: i64, x: i64, y: i64) -> Result<(), String> {
if !board.in_bounds((x, y)) {
Err(format!("teleport({target},{x},{y}): out of bounds"))
} else {
// if they're in bounds, they can be converted to usize
let (x, y) = (x as usize, y as usize);
let from = if target == -1 {
Some(board.player_pos())
} else if let Some(obj) = board.get_hookable(target as ObjectId) {
Some(obj.location())
} else {
None
};
if let Some(from) = from {
if from.0 != x || from.1 != y {
// Check if we're blocked:
if board.get(x, y).is_none() {
// Not blocked, move it
let thing = board.get_mut(from.0, from.1).take();
*board.get_mut(x, y) = thing;
Ok(())
} else {
Err(format!("teleport({target},{x},{y}): destination is solid"))
}
} else {
// We're teleporting to the same place, which is fine I guess, but no effect:
Ok(())
}
} else {
Err(format!("teleport({target},{x},{y}): no such object"))
}
}
}
pub fn apply_push(board: &mut Board, x: i64, y: i64, dir: Direction) -> Result<(), String> {
if !board.in_bounds((x, y)) {
Err(format!("push({x},{y}): out of bounds"))
} else {
board.push(x as usize, y as usize, dir);
Ok(())
}
}
pub fn apply_shift(board: &mut Board, cells: &[(i64, i64)]) -> Result<(), String> {
board.apply_shift(cells)?;
Ok(())
}
+12 -26
View File
@@ -62,35 +62,21 @@ impl Registerable for BoardRef {
// Board.named(name) -> ObjectInfo | () // Board.named(name) -> ObjectInfo | ()
engine.register_fn("named", move |board_ref: &mut BoardRef, name: ImmutableString| -> Dynamic { engine.register_fn("named", move |board_ref: &mut BoardRef, name: ImmutableString| -> Dynamic {
let board = board_ref.borrow(); let board = board_ref.borrow();
board if let Some(obj) = board.get_named(name.as_str()) {
.objects Dynamic::from(ObjectInfo::from_hookable(obj, board_ref.clone()))
.iter() } else {
.find_map(|(_id, def)| { Dynamic::UNIT
if def.name.as_deref() == Some(name.as_str()) { }
Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone()))) });
} else {
None
}
})
.unwrap_or(Dynamic::UNIT)
},
);
// Board.tagged(tag) -> Array[ObjectInfo] // Board.tagged(tag) -> Array[ObjectInfo]
engine.register_fn("tagged", move |board_ref: &mut BoardRef, tag: ImmutableString| -> rhai::Array { engine.register_fn("tagged", move |board_ref: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
let board = board_ref.borrow(); let board = board_ref.borrow();
board board.get_tagged(tag.as_str()).into_iter().map(|obj| {
.objects.values().filter_map(|def| { Dynamic::from(ObjectInfo::from_hookable(obj, board_ref.clone()))
if def.tags.contains(tag.as_str()) { }).collect()
Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone()))) });
} else {
None
}
})
.collect()
},
);
// Board.registry -> Registry // Board.registry -> Registry
engine.register_get("registry", |b: &mut BoardRef| Registry(b.clone())); engine.register_get("registry", |b: &mut BoardRef| Registry(b.clone()));
+35 -33
View File
@@ -24,11 +24,12 @@
use rhai::{Dynamic, Engine}; use rhai::{Dynamic, Engine};
use crate::api::board::BoardRef; use crate::api::board::BoardRef;
use crate::api::queue::ObjQueue; use crate::api::queue::ObjQueue;
use crate::Direction; use crate::{Board, Direction};
use crate::action::BoardAction; use crate::action::BoardAction;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use crate::script::Registerable; use crate::script::Registerable;
use crate::utils::{Behavior, LogSink, ObjectId}; use crate::tile::{Hookable, Optics, ScriptAttributes, Tile};
use crate::utils::{LogSink, ObjectId};
/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`, /// A snapshot of one board object, returned by `Board.tagged`, `Board.named`,
/// and `Board.get`. Passed by value — scripts read fields, not a live reference. /// and `Board.get`. Passed by value — scripts read fields, not a live reference.
@@ -48,32 +49,36 @@ pub struct ObjectInfo {
impl ObjectInfo { impl ObjectInfo {
pub fn from_id(id: ObjectId, board: BoardRef) -> Option<ObjectInfo> { pub fn from_id(id: ObjectId, board: BoardRef) -> Option<ObjectInfo> {
let b = board.borrow(); let b = board.borrow();
let obj = b.objects.get(&id)?; let hookable = b.get_hookable(id)?;
Some(ObjectInfo { Some(Self::from_hookable(hookable, board.clone()))
id,
x: obj.x as i64,
y: obj.y as i64,
board: board.clone(),
script_name: obj.script_name.clone(),
queue: obj.queue.clone()
})
} }
pub fn from_def(obj: &ObjectDef, board: BoardRef) -> ObjectInfo { pub fn from_hookable(hookable: Box<dyn Hookable + '_>, board: BoardRef) -> ObjectInfo {
let (x, y) = hookable.location();
Self { Self {
id: obj.id, id: hookable.id(),
x: obj.x as i64, x: x as i64,
y: obj.y as i64, y: y as i64,
board,
script_name: hookable.scriptable().script_name.clone(),
queue: hookable.scriptable().queue.clone(),
}
}
pub fn from_def(obj: &ObjectDef, board: BoardRef, x: usize, y: usize) -> ObjectInfo {
Self {
id: obj.scripting.id,
x: x as i64,
y: y as i64,
board: board.clone(), board: board.clone(),
script_name: obj.script_name.clone(), script_name: obj.scripting.script_name.clone(),
queue: obj.queue.clone() queue: obj.scripting.queue.clone()
} }
} }
pub fn drain(&mut self, target: &mut Vec<BoardAction>, dt: f64) { pub fn drain(&mut self, target: &mut Vec<BoardAction>, dt: f64) {
let mut b = self.board.borrow_mut(); if let Some(scr) = self.board.borrow_mut().scripting_mut(self.id) {
if let Some(def) = b.objects.get_mut(&self.id) { scr.queue.drain(self.id, target, dt)
def.queue.drain(self.id, target, dt)
} }
} }
} }
@@ -89,8 +94,7 @@ impl Registerable for ObjectInfo {
engine.register_get("name", |o: &mut ObjectInfo| { engine.register_get("name", |o: &mut ObjectInfo| {
let board = o.board.borrow(); let board = o.board.borrow();
let obj = board.objects.get(&o.id); if let Some(hookable) = board.get_hookable(o.id) && let Some(name) = hookable.name() {
if let Some(ObjectDef { name: Some(name), ..}) = obj {
Dynamic::from(name.clone()) Dynamic::from(name.clone())
} else { } else {
Dynamic::UNIT Dynamic::UNIT
@@ -99,9 +103,8 @@ impl Registerable for ObjectInfo {
engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array { engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array {
let board = o.board.borrow(); let board = o.board.borrow();
let obj = board.objects.get(&o.id); if let Some(hookable) = board.get_hookable(o.id) {
if let Some(ObjectDef { tags, .. }) = obj { hookable.tags().iter().map(|t| Dynamic::from(t.clone())).collect()
tags.iter().map(|t| Dynamic::from(t.clone())).collect()
} else { } else {
rhai::Array::new() rhai::Array::new()
} }
@@ -109,21 +112,20 @@ impl Registerable for ObjectInfo {
engine.register_get("glyph", |o: &mut ObjectInfo| { engine.register_get("glyph", |o: &mut ObjectInfo| {
let board = o.board.borrow(); let board = o.board.borrow();
let obj = board.objects.get(&o.id).unwrap(); let obj = board.get_hookable(o.id).unwrap();
obj.glyph obj.glyph()
}); });
// me.light: the object's current emitted light radius in cells (0 = none). // me.light: the object's current emitted light radius in cells (0 = none).
engine.register_get("light", |o: &mut ObjectInfo| -> i64 { engine.register_get("light", |o: &mut ObjectInfo| -> i64 {
match o.board.borrow().objects.get(&o.id) { if let Some(hookable) = o.board.borrow().get_hookable(o.id) {
Some(ObjectDef { behavior: Behavior { glow, .. }, .. }) => *glow as i64, hookable.optics().glow as i64
None => 0, } else { 0 }
}
}); });
engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| { engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| {
if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) { if let Some(hookable) = o.board.borrow().get_hookable(o.id) {
tags.contains(&t) hookable.scriptable().tags.contains(&t)
} else { } else {
false false
} }
+2 -2
View File
@@ -16,7 +16,7 @@ impl Registerable for PlayerWithPos {
.register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health) .register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health)
.register_get("max_health", |player: &mut PlayerWithPos| player.0.borrow().max_health) .register_get("max_health", |player: &mut PlayerWithPos| player.0.borrow().max_health)
.register_get("keys", |player: &mut PlayerWithPos| player.0.borrow().keys) .register_get("keys", |player: &mut PlayerWithPos| player.0.borrow().keys)
.register_get("x", |player: &mut PlayerWithPos| player.1.borrow().player.x) .register_get("x", |player: &mut PlayerWithPos| player.1.borrow().player_pos().0)
.register_get("y", |player: &mut PlayerWithPos| player.1.borrow().player.y); .register_get("y", |player: &mut PlayerWithPos| player.1.borrow().player_pos().1);
} }
} }
-384
View File
@@ -1,384 +0,0 @@
use crate::glyph::Glyph;
use crate::utils::{Behavior, Pushable};
use color::Rgba8;
use crate::keys::KeyType;
use serde::{Serialize, Deserialize};
use crate::tile::EnterResponse;
/// Declares the set of script-backed archetype families.
///
/// Each entry specifies:
/// - A `Variant` name (becomes a [`Builtin`] enum variant).
/// - A `["name" => Glyph { … }, …]` list: one map-file keyword per alias with the
/// default [`Glyph`] for the editor. Per-alias glyphs allow aliases in the same
/// family to differ in color (e.g. the eight `Key` variants).
/// - `behavior`: shared across all aliases in the family.
/// - `script`: the embedded Rhai source; `include_str!` paths are relative to this
/// file, so `include_str!("scripts/pusher.rhai")` resolves to
/// `kiln-core/src/scripts/pusher.rhai`.
///
/// **To add a new builtin archetype:** add one entry here + write the `.rhai` file.
/// No other code needs to change — `TryFrom<&str>`, `behavior()`, `name()`,
/// `default_glyph()`, the expansion pass, and the save round-trip all derive from
/// the macro output automatically.
macro_rules! builtins {
(
$(
$variant:ident => [ $( $name:literal => $glyph:expr ),+ $(,)? ] {
behavior: $behavior:expr,
script: $script:expr $(,)?
}
),+ $(,)?
) => {
/// A family of script-backed archetypes, generated by the [`builtins!`] macro.
///
/// Each variant groups one or more map-file keywords (aliases) that share one
/// embedded Rhai script and a uniform [`Behavior`]. The specific alias used in
/// the map file is preserved as the `&'static str` in [`Archetype::Builtin`]
/// so scripts can read it via the `BUILTIN_<alias>` tag (e.g. a pusher reads
/// `Me.has_tag("BUILTIN_pusher_north")` to know its direction).
///
/// ## Adding a new builtin
///
/// Add one entry to the `builtins!` invocation in `archetype.rs` and write
/// `kiln-core/src/scripts/<name>.rhai`. No other files need to change.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Builtin {
$( $variant ),+
}
impl Builtin {
/// Returns `(family_variant, matched_alias)` for `name`, or `None` if `name`
/// is not a known builtin keyword. The returned `&'static str` is the exact
/// literal from the macro (always valid for a `Archetype::Builtin` field).
pub fn from_name(name: &str) -> Option<(Self, &'static str)> {
match name {
$(
$( $name => Some((Builtin::$variant, $name)), )+
)+
_ => None,
}
}
/// Returns the uniform behavior shared by all aliases in this family.
pub fn behavior(self) -> Behavior {
match self {
$( Builtin::$variant => $behavior, )+
}
}
/// Returns the default glyph for `alias`. Each alias owns its own glyph,
/// so aliases within a family can differ in color (e.g. colored keys).
/// Falls back to a transparent glyph for unrecognized aliases (shouldn't
/// happen in practice since aliases are all from the macro).
pub fn default_glyph_for(self, alias: &str) -> Glyph {
match alias {
$(
$( $name => $glyph, )+
)+
_ => Glyph::transparent(),
}
}
/// Returns the embedded Rhai source shared by all aliases in this family.
pub fn script(self) -> &'static str {
match self {
$( Builtin::$variant => $script, )+
}
}
}
};
}
// Shorthand helpers used only within the builtins! invocation below.
// `g(tile, r, g, b)` builds a Glyph with the given tile and fg on black bg.
const fn g(tile: u32, r: u8, gr: u8, b: u8) -> Glyph {
Glyph {
tile,
fg: Rgba8 { r, g: gr, b, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}
}
builtins! {
Gem => ["gem" => g(4, 0x50, 0x50, 0xFF)] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true, glow: 0 },
script: include_str!("scripts/gem.rhai"),
},
Heart => ["heart" => g(3, 0xCC, 0x22, 0x22)] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true, glow: 0 },
script: include_str!("scripts/heart.rhai"),
},
Pusher => [
"pusher_north" => g(30, 0xAA, 0xAA, 0xAA),
"pusher_south" => g(31, 0xAA, 0xAA, 0xAA),
"pusher_east" => g(16, 0xAA, 0xAA, 0xAA),
"pusher_west" => g(17, 0xAA, 0xAA, 0xAA),
] {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false, glow: 0 },
script: include_str!("scripts/pusher.rhai"),
},
Spinner => [
"spinner_cw" => g(47, 0xAA, 0xAA, 0xAA),
"spinner_ccw" => g(92, 0xAA, 0xAA, 0xAA),
] {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false, glow: 0 },
script: include_str!("scripts/spinner.rhai"),
},
// Solid, see-through (opaque: false), unpushable teleporters. Each direction's
// default glyph is the first frame of its animation loop (see transporter.rhai).
Transporter => [
"transporter_north" => g(94, 0x55, 0xFF, 0xFF), // '^'
"transporter_south" => g(118, 0x55, 0xFF, 0xFF), // 'v'
"transporter_east" => g(41, 0x55, 0xFF, 0xFF), // ')'
"transporter_west" => g(40, 0x55, 0xFF, 0xFF), // '('
] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::No, grab: false, glow: 0 },
script: include_str!("scripts/transporter.rhai"),
},
Key => [ // TODO these should refer to the key colors in Keyring
"key_blue" => KeyType::Blue.glyph(),
"key_green" => KeyType::Green.glyph(),
"key_cyan" => KeyType::Cyan.glyph(),
"key_red" => KeyType::Red.glyph(),
"key_purple" => KeyType::Purple.glyph(),
"key_orange" => KeyType::Orange.glyph(),
"key_yellow" => KeyType::Yellow.glyph(),
"key_white" => KeyType::White.glyph(),
] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true, glow: 0 },
script: include_str!("scripts/key.rhai"),
},
Wall => ["wall" => Glyph {
tile: 35,
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 }}] {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false, glow: 0 },
script: ""
},
Crate => ["crate" => g(254, 0xaa, 0xaa, 0xaa)] { // CP437 ■ (small filled square)
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::Any, grab: false, glow: 0 },
script: ""
},
HCrate => ["hcrate" => g(29, 0xaa, 0xaa, 0xaa)] { // CP437 ↔ (left-right arrow) — pushable east/west
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::Horizontal, grab: false, glow: 0 },
script: ""
},
VCrate => ["vcrate" => g(18, 0xaa, 0xaa, 0xaa)] { // CP437 ↕ (up-down arrow) — pushable north/south
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::Vertical, grab: false, glow: 0 },
script: ""
},
}
/// A class of board cell, encoding its default behavior and appearance.
///
/// `Archetype` is an enum of the element types the engine knows about. Each
/// variant provides a default [`Behavior`] (via [`Archetype::behavior`]) and a
/// default [`Glyph`] (via [`Archetype::default_glyph`]) used when the editor
/// stamps a cell.
///
/// Map files reference archetypes by [`name`](Archetype::name) (e.g. `"wall"`),
/// so the list of variants can be reordered without breaking saved games.
///
/// ## Script-backed archetypes
///
/// The [`Builtin`] variant covers all script-backed types (pushers, spinners,
/// gems). These are map-file keywords only: at load they expand into scripted
/// [`ObjectDef`]s carrying the embedded Rhai source and a `BUILTIN_<alias>` tag
/// (see [`crate::builtin_scripts`] and [`Board::expand_builtin_archetypes`]).
///
/// `ErrorBlock` is used as a sentinel for unrecognized archetype names in map
/// files — it should never appear in a valid board.
///
/// [`ObjectDef`]: crate::object_def::ObjectDef
/// [`Board::expand_builtin_archetypes`]: crate::board::Board::expand_builtin_archetypes
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Archetype {
/// An open cell; the player and other entities can pass through it.
Empty,
/// A wall-mounted torch: non-solid, non-opaque decorative terrain that emits
/// warm light on a `dark` board (radius from [`Archetype::light`]). Glyph ☼.
Torch,
/// Sentinel for map files that reference an unknown archetype name.
/// Renders as a yellow `?` on red to make the error visible in-game.
ErrorBlock,
/// A script-backed archetype expanded from a map-file keyword.
///
/// - `Builtin` is the family (e.g. `Builtin::Pusher`), which selects the
/// shared script and behavior.
/// - `&'static str` is the specific alias matched during parsing (e.g.
/// `"pusher_north"`), used as the per-alias glyph key and the
/// `BUILTIN_<alias>` tag on the expanded object.
///
/// See [`Builtin`] and the [`builtins!`] invocation for the full registry.
#[serde(untagged)]
Builtin(Builtin, &'static str),
}
impl Archetype {
/// Returns the default [`Behavior`] for this archetype.
pub fn behavior(&self) -> Behavior {
match self {
Archetype::Builtin(b, _) => b.behavior(),
Archetype::Empty => Behavior {
solid: false,
opaque: false,
pushable: Pushable::No,
grab: false,
glow: 0,
},
// 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,
glow: 6,
},
Archetype::ErrorBlock => Behavior {
solid: true,
opaque: true,
pushable: Pushable::No,
grab: false,
glow: 0,
},
}
}
/// Returns the canonical name used to reference this archetype in map files.
pub fn name(&self) -> &'static str {
match self {
Archetype::Builtin(_, alias) => alias,
Archetype::Empty => "empty",
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
/// cells retain their own per-cell glyph.
#[rustfmt::skip]
pub fn default_glyph(&self) -> Glyph {
match self {
Archetype::Builtin(b, alias) => b.default_glyph_for(alias),
Archetype::Empty => Glyph {
tile: 0,
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::Torch => Glyph {
tile: 15, // CP437 ☼ (sun) — a warm point of light
fg: Rgba8 { r: 0xFF, g: 0xB0, b: 0x40, a: 255 }, // warm amber (also its light color)
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
// Visually distinct so malformed map files are immediately obvious.
Archetype::ErrorBlock => Glyph {
tile: 63,
fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow on red
bg: Rgba8 { r: 255, g: 0, b: 0, a: 255 },
},
}
}
}
impl TryFrom<&str> for Archetype {
type Error = String;
/// Parses an archetype by its map-file name.
///
/// Checks the [`Builtin`] registry first (pushers, spinners, gems), then
/// falls through to the hard-coded terrain archetypes. Returns an error for
/// unrecognized names; the caller should substitute [`Archetype::ErrorBlock`]
/// and log the error so the problem is visible.
fn try_from(name: &str) -> Result<Self, Self::Error> {
// Script-backed families (pushers, spinners, gems) are in the registry.
if let Some((b, alias)) = Builtin::from_name(name) {
return Ok(Archetype::Builtin(b, alias));
}
match name {
"empty" => Ok(Archetype::Empty),
"torch" => Ok(Archetype::Torch),
// "object", "portal", "player" are intentionally absent: they are
// meta-kinds handled by the layer builder, not Archetype variants.
_ => {
// is it a valid builtin?
if let Some((b, s)) = Builtin::from_name(name) {
Ok(Archetype::Builtin(b, s))
} else {
Err(format!("unknown archetype: {name}"))
}
},
}
}
}
#[cfg(test)]
mod tests {
use super::Archetype;
#[test]
fn builtin_names_glyphs_and_round_trip() {
// All known aliases must parse, round-trip via name(), and give the right tile.
for (name, tile) in [
("gem", 4u32),
("pusher_north", 30),
("pusher_south", 31),
("pusher_east", 16),
("pusher_west", 17),
("spinner_cw", 47),
("spinner_ccw", 92),
("key_red", 12),
("key_blue", 12),
("key_white", 12),
] {
let arch = Archetype::try_from(name)
.unwrap_or_else(|_| panic!("'{name}' should parse as a builtin"));
assert_eq!(arch.name(), name, "'{name}' round-trips via name()");
assert_eq!(
arch.default_glyph().tile,
tile,
"'{name}' has the correct default tile"
);
}
}
#[test]
fn key_aliases_have_distinct_fg_colors() {
use crate::keys::KeyType;
// Each alias must match the corresponding KeyType glyph — single source of truth.
let cases = [
("key_blue", KeyType::Blue),
("key_green", KeyType::Green),
("key_cyan", KeyType::Cyan),
("key_red", KeyType::Red),
("key_purple", KeyType::Purple),
("key_orange", KeyType::Orange),
("key_yellow", KeyType::Yellow),
("key_white", KeyType::White),
];
for (name, key_type) in cases {
let arch = Archetype::try_from(name)
.unwrap_or_else(|_| panic!("'{name}' should parse"));
assert_eq!(
arch.default_glyph().fg,
key_type.glyph().fg,
"'{name}' fg doesn't match KeyType"
);
}
}
}
+309 -653
View File
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
//! The board grid: the palette+char map-file unit and its load-time conversion.
//!
//! A board is a single **grid** — a character grid plus a palette mapping each
//! character to one *kind* of thing: an archetype (terrain), a scripted object, a
//! portal, or the player. (The board's cosmetic floor is a separate `[map]`
//! attribute, not a grid cell; see [`crate::floor`].)
//!
//! This module owns the grid serde type ([`BoardSpec`], [`PaletteEntry`]) and the
//! load-time conversion ([`build_grid`]) that turns one `GridData` into the board's
//! `Vec<(Glyph, Archetype)>` cells plus a list of [`Placement`]s (objects/portals/
//! player) for the map loader to resolve. Cross-cell validation (one solid per
//! cell, unique names, the player winning its cell) lives in [`crate::map_file`].
use crate::log::LogLine;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::hash::Hash;
use crate::Board;
use crate::portal::Portal;
use crate::tile::{FloorSpec, IntoFloor, IntoTile, SensorSpec, TileSpec};
/// Serde representation of the board `[grid]`: a char grid plus its palette.
///
/// - `content` — a multi-line grid string, one char per cell (`width × height`).
///
/// A space (`' '`) is always a transparent empty cell and is never a palette key
/// (any `" "` entry in `palette` is ignored).
#[derive(Deserialize, Serialize, Default)]
pub struct BoardSpec {
/// Player-presentable name of the board. The slug used for portal targets, etc is the key one
/// level up from this
pub name: String,
/// Width of the grid
pub width: usize,
/// Height of the grid
pub height: usize,
/// Multi-line grid string; one char per cell, looked up in `palette`.
pub grid: String,
/// Char (as a one-character string key) → palette entry.
#[serde(default)]
pub palette: HashMap<String, TileSpec>,
/// List of all the sensors (if any)
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sensors: Vec<SensorSpec>,
/// List of all the portals (if any)
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub portals: Vec<Portal>,
/// What we want for a floor
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor: Option<FloorSpec>,
/// When `true`, this is a "dark" board: front-ends reveal only cells within
/// the player's field of view. Absent ⇒ `false` (fully lit); omitted from
/// the saved TOML when `false`. See [`Board::dark`](crate::board::Board::dark).
#[serde(default, skip_serializing_if = "is_false")]
pub dark: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl BoardSpec {
/// Resolves the grid to a `height × width` matrix of chars from whichever of
/// `content` / `fill` / `sparse` is supplied (in that precedence; none ⇒ all spaces).
///
/// Only an explicit `content` can mismatch the board dimensions — the single hard
/// error. `fill`/`sparse` always produce an exactly-sized grid; a non-single-char
/// `fill`/`ch` or an out-of-bounds `sparse` cell is recorded on `errors` and the
/// offending cell falls back to (or stays) a space.
fn grid_chars(&self) -> Result<Vec<Vec<char>>, String> {
let rows: Vec<&str> = self.grid.lines().collect();
if rows.len() != self.height {
return Err(format!(
"grid has {} rows but the board is {} tall",
rows.len(), self.height
));
}
let mut grid = Vec::with_capacity(self.height);
for (i, line) in rows.iter().enumerate() {
let row: Vec<char> = line.chars().collect();
if row.len() != self.width {
return Err(format!(
"grid row {i} has {} characters but the board is {} wide",
row.len(), self.width
));
}
grid.push(row);
}
Ok(grid)
}
/// Builds the board's grid cells from its [`BoardSpec`], plus the non-terrain
/// placements it contains (with their `(x, y)`).
///
/// Returns `Err` only on a grid-dimension mismatch (the single hard error);
/// every other problem is recorded on `errors`.
pub(crate) fn build_grid(&self) -> Result<Vec<Option<TileSpec>>, String> {
let grid = self.grid_chars()?;
// Walk the grid, filling cells and collecting placements.
let mut cells: Vec<Option<TileSpec>> = Vec::with_capacity(self.width * self.height);
for (y, row) in grid.iter().enumerate() {
for (x, &ch) in row.iter().enumerate() {
// A space is always a transparent empty cell, palette or not.
let ch = ch.to_string();
if ch == " " {
cells.push(None);
} else if self.palette.contains_key(&ch) {
cells.push(Some(self.palette[&ch].clone()))
} else {
return Err(format!("unknown grid character '{ch}' at ({x}, {y}); using error block"));
}
}
}
Ok(cells)
}
/// Try to check for validity of the board, return a list of errors we find (if any)
pub(crate) fn validate(&self, grid: &Vec<Option<TileSpec>>, valid_script_names: &HashSet<&String>) -> Result<(), Vec<String>> {
let mut errors = vec![];
// Check for player being positioned exactly once
let players = grid.iter().filter(|c| matches!(c, Some(TileSpec::Player))).count();
if players == 0 {
errors.push("no player cell (kind = \"player\") found".to_string())
} else if players > 1 {
errors.push("player appears {players} times, can only appear once".to_string())
}
// Check for duplicate object or portal names
let mut obj_names = HashMap::new();
let mut portal_names = HashMap::new();
let mut portal_locations = HashMap::new();
let mut obj_script_names = HashSet::new();
fn count<T: Eq + Hash + Clone>(hash: &mut HashMap<T, i32>, name: &T) {
if hash.contains_key(name) { *hash.get_mut(name).unwrap() += 1 }
else { hash.insert(name.clone(), 1); }
}
for cell in grid.iter() {
if let Some(TileSpec::Object { name: Some(name), script, .. }) = cell {
count(&mut obj_names, name);
script.as_ref().map(|script_name| obj_script_names.insert(script_name));
}
}
for sensor in self.sensors.iter() {
if let Some(name) = sensor.name.as_ref() {
count(&mut obj_names, name);
}
if let Some(script_name) = sensor.script.as_ref() {
obj_script_names.insert(script_name);
}
}
for Portal { name, x, y, .. } in self.portals.iter() {
count(&mut portal_names, name);
count(&mut portal_locations, &(x + y * self.width));
}
let obj_dupes = obj_names.into_iter().filter_map(|(name, count)| if count > 1 { Some(name) } else { None }).collect::<Vec<_>>();
let portal_dupes = portal_names.into_iter().filter_map(|(name, count)| if count > 1 { Some(name) } else { None }).collect::<Vec<_>>();
let portal_loc_dupes = portal_locations.into_iter().filter_map(|(loc, count)| {
if count > 1 {
Some(format!("({}, {}", loc % self.width, loc / self.width))
} else { None }
}).collect::<Vec<_>>();
if !obj_dupes.is_empty() {
errors.push(format!("Object names used multiple times: {obj_dupes:?}"));
}
if !portal_dupes.is_empty() {
errors.push(format!("Portal names used multiple times: {portal_dupes:?}"));
}
if !portal_loc_dupes.is_empty() {
errors.push(format!("Portal locations used multiple times: {portal_loc_dupes:?}"));
}
// Check for objects using scripts that don't exist
let missing = obj_script_names.difference(&valid_script_names).collect::<Vec<_>>();
if !missing.is_empty() {
errors.push(format!("Missing scripts: {missing:?}"));
}
if errors.is_empty() { Ok(()) } else { Err(errors) }
}
pub(crate) fn into_board(self, script_names: &HashSet<&String>) -> Result<Board, String> {
let grid = self.build_grid()?;
if let Err(errors) = self.validate(&grid, script_names) {
return Err(errors.join("\n"));
}
let mut next_object_id = 0;
let mut tile_grid = Vec::with_capacity(grid.len());
for spec in grid.into_iter() {
match spec {
None => tile_grid.push(None),
Some(spec) => {
tile_grid.push(Some(spec.into_tile(&mut next_object_id)?))
}
}
}
let sensors = self.sensors.into_iter().map(|spec| spec.into_sensor(&mut next_object_id)).collect();
let board = Board {
name: self.name.to_string(),
width: self.width,
height: self.height,
grid: tile_grid,
floor: self.floor.into_floor(self.width, self.height),
sensors,
portals: self.portals,
next_object_id: 0,
dark: self.dark,
registry: Default::default(),
};
Ok(board)
}
}
+245
View File
@@ -0,0 +1,245 @@
use crate::glyph::Glyph;
use crate::utils::Pushable;
use color::Rgba8;
use crate::keys::KeyType;
use serde::{Serialize, Deserialize};
use crate::tile::{EnterResponse, Optics};
/// Declares the set of script-backed archetype families.
///
/// Each entry specifies:
/// - A `Variant` name (becomes a [`Builtin`] enum variant).
/// - A `["name" => Glyph { … }, …]` list: one map-file keyword per alias with the
/// default [`Glyph`] for the editor. Per-alias glyphs allow aliases in the same
/// family to differ in color (e.g. the eight `Key` variants).
/// - `behavior`: shared across all aliases in the family.
/// - `script`: the embedded Rhai source; `include_str!` paths are relative to this
/// file, so `include_str!("scripts/pusher.rhai")` resolves to
/// `kiln-core/src/scripts/pusher.rhai`.
///
/// **To add a new builtin archetype:** add one entry here + write the `.rhai` file.
/// No other code needs to change — `TryFrom<&str>`, `behavior()`, `name()`,
/// `default_glyph()`, the expansion pass, and the save round-trip all derive from
/// the macro output automatically.
macro_rules! builtins {
(
$(
$variant:ident => [ $( $name:literal => $glyph:expr ),+ $(,)? ] {
enter: $enter:expr,
optics: $optics:expr,
script: $script:expr $(,)?
}
),+ $(,)?
) => {
/// A family of script-backed archetypes, generated by the [`builtins!`] macro.
///
/// Each variant groups one or more map-file keywords (aliases) that share one
/// embedded Rhai script and a uniform [`Behavior`]. The specific alias used in
/// the map file is preserved as the `&'static str` in [`Archetype::Builtin`]
/// so scripts can read it via the `BUILTIN_<alias>` tag (e.g. a pusher reads
/// `Me.has_tag("BUILTIN_pusher_north")` to know its direction).
///
/// ## Adding a new builtin
///
/// Add one entry to the `builtins!` invocation in `builtin` and write
/// `kiln-core/src/scripts/<name>.rhai`. No other files need to change.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Builtin {
$( $variant ),+
}
impl Builtin {
/// Returns `(family_variant, matched_alias)` for `name`, or `None` if `name`
/// is not a known builtin keyword. The returned `&'static str` is the exact
/// literal from the macro (always valid for a `Archetype::Builtin` field).
pub fn from_name(name: &str) -> Option<(Self, &'static str)> {
match name {
$(
$( $name => Some((Builtin::$variant, $name)), )+
)+
_ => None,
}
}
/// Returns the uniform enter response shared by all aliases in this family.
pub fn enter_response(self) -> EnterResponse {
match self {
$( Builtin::$variant => $enter, )+
}
}
/// Returns the uniform optics shared by all aliases in this family.
pub fn optics(self) -> Optics {
match self {
$( Builtin::$variant => $optics, )+
}
}
/// Returns the default glyph for `alias`. Each alias owns its own glyph,
/// so aliases within a family can differ in color (e.g. colored keys).
/// Falls back to a transparent glyph for unrecognized aliases (shouldn't
/// happen in practice since aliases are all from the macro).
pub fn default_glyph_for(self, alias: &str) -> Glyph {
match alias {
$(
$( $name => $glyph, )+
)+
_ => Glyph::transparent(),
}
}
/// Returns the embedded Rhai source shared by all aliases in this family.
pub fn script(self) -> &'static str {
match self {
$( Builtin::$variant => $script, )+
}
}
}
};
}
// Shorthand helpers used only within the builtins! invocation below.
// `g(tile, r, g, b)` builds a Glyph with the given tile and fg on black bg.
const fn g(tile: u32, r: u8, gr: u8, b: u8) -> Glyph {
Glyph {
tile,
fg: Rgba8 { r, g: gr, b, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}
}
builtins! {
Gem => ["gem" => g(4, 0x50, 0x50, 0xFF)] {
enter: EnterResponse::Grab,
optics: Optics { opaque: false, glow: 0 },
script: include_str!("scripts/gem.rhai"),
},
Heart => ["heart" => g(3, 0xCC, 0x22, 0x22)] {
enter: EnterResponse::Grab,
optics: Optics { opaque: false, glow: 0 },
script: include_str!("scripts/heart.rhai"),
},
Pusher => [
"pusher_north" => g(30, 0xAA, 0xAA, 0xAA),
"pusher_south" => g(31, 0xAA, 0xAA, 0xAA),
"pusher_east" => g(16, 0xAA, 0xAA, 0xAA),
"pusher_west" => g(17, 0xAA, 0xAA, 0xAA),
] {
enter: EnterResponse::Block,
optics: Optics { opaque: true, glow: 0 },
script: include_str!("scripts/pusher.rhai"),
},
Spinner => [
"spinner_cw" => g(47, 0xAA, 0xAA, 0xAA),
"spinner_ccw" => g(92, 0xAA, 0xAA, 0xAA),
] {
enter: EnterResponse::Block,
optics: Optics { opaque: true, glow: 0 },
script: include_str!("scripts/spinner.rhai"),
},
// Solid, see-through (opaque: false), unpushable teleporters. Each direction's
// default glyph is the first frame of its animation loop (see transporter.rhai).
Transporter => [
"transporter_north" => g(94, 0x55, 0xFF, 0xFF), // '^'
"transporter_south" => g(118, 0x55, 0xFF, 0xFF), // 'v'
"transporter_east" => g(41, 0x55, 0xFF, 0xFF), // ')'
"transporter_west" => g(40, 0x55, 0xFF, 0xFF), // '('
] {
enter: EnterResponse::Hook,
optics: Optics { opaque: false, glow: 0 },
script: include_str!("scripts/transporter.rhai"),
},
Key => [ // TODO these should refer to the key colors in Keyring
"key_blue" => KeyType::Blue.glyph(),
"key_green" => KeyType::Green.glyph(),
"key_cyan" => KeyType::Cyan.glyph(),
"key_red" => KeyType::Red.glyph(),
"key_purple" => KeyType::Purple.glyph(),
"key_orange" => KeyType::Orange.glyph(),
"key_yellow" => KeyType::Yellow.glyph(),
"key_white" => KeyType::White.glyph(),
] {
enter: EnterResponse::Grab,
optics: Optics { opaque: false, glow: 0 },
script: include_str!("scripts/key.rhai"),
},
Wall => ["wall" => Glyph {
tile: 35,
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 }}] {
enter: EnterResponse::Block,
optics: Optics { opaque: true, glow: 0 },
script: ""
},
Crate => ["crate" => g(254, 0xaa, 0xaa, 0xaa)] { // CP437 ■ (small filled square)
enter: EnterResponse::Push(Pushable::Any),
optics: Optics { opaque: true, glow: 0 },
script: ""
},
HCrate => ["hcrate" => g(29, 0xaa, 0xaa, 0xaa)] { // CP437 ↔ (left-right arrow) — pushable east/west
enter: EnterResponse::Push(Pushable::Horizontal),
optics: Optics { opaque: true, glow: 0 },
script: ""
},
VCrate => ["vcrate" => g(18, 0xaa, 0xaa, 0xaa)] { // CP437 ↕ (up-down arrow) — pushable north/south
enter: EnterResponse::Push(Pushable::Vertical),
optics: Optics { opaque: true, glow: 0 },
script: ""
},
}
#[cfg(test)]
mod tests {
use super::Builtin;
#[test]
fn builtin_names_glyphs_and_round_trip() {
// All known aliases must parse, round-trip via name(), and give the right tile.
for (name, tile) in [
("gem", 4u32),
("pusher_north", 30),
("pusher_south", 31),
("pusher_east", 16),
("pusher_west", 17),
("spinner_cw", 47),
("spinner_ccw", 92),
("key_red", 12),
("key_blue", 12),
("key_white", 12),
] {
let (builtin, kind) = Builtin::from_name(name)
.unwrap_or_else(|| panic!("'{name}' should parse as a builtin"));
assert_eq!(kind, name, "'{name}' round-trips");
assert_eq!(
builtin.default_glyph_for(kind).tile,
tile,
"'{name}' has the correct default tile"
);
}
}
#[test]
fn key_aliases_have_distinct_fg_colors() {
use crate::keys::KeyType;
// Each alias must match the corresponding KeyType glyph — single source of truth.
let cases = [
("key_blue", KeyType::Blue),
("key_green", KeyType::Green),
("key_cyan", KeyType::Cyan),
("key_red", KeyType::Red),
("key_purple", KeyType::Purple),
("key_orange", KeyType::Orange),
("key_yellow", KeyType::Yellow),
("key_white", KeyType::White),
];
for (name, key_type) in cases {
let (builtin, kind) = Builtin::from_name(name)
.unwrap_or_else(|| panic!("'{name}' should parse as a builtin"));
assert_eq!(
builtin.default_glyph_for(kind).fg,
key_type.glyph().fg,
"'{name}' fg doesn't match KeyType"
);
}
}
}
-39
View File
@@ -1,39 +0,0 @@
//! Tag helpers for script-backed archetypes expanded from map-file keywords.
//!
//! When a [`Builtin`] archetype cell is expanded into an [`ObjectDef`] by
//! [`Board::expand_builtin_archetypes`], the object receives a `BUILTIN_<alias>`
//! tag (e.g. `"BUILTIN_pusher_north"`) so its Rhai script can read which specific
//! variant it is (via `Me.has_tag("BUILTIN_pusher_north")`).
//!
//! The save path ([`map_file`]) uses [`archetype_from_builtin_tag`] to collapse
//! an expanded object back into its original map-file keyword so worlds
//! round-trip correctly.
//!
//! The full builtin registry — which archetypes exist, their behaviors, glyphs,
//! and embedded scripts — lives in [`crate::archetype`] via the `builtins!` macro.
//!
//! [`Builtin`]: crate::archetype::Builtin
//! [`ObjectDef`]: crate::object_def::ObjectDef
//! [`Board::expand_builtin_archetypes`]: crate::board::Board::expand_builtin_archetypes
//! [`map_file`]: crate::map_file
use crate::archetype::Archetype;
/// Prefix for the tag that marks an object as an expanded built-in archetype and
/// names which alias it came from (e.g. `"BUILTIN_pusher_east"`).
pub(crate) const BUILTIN_TAG_PREFIX: &str = "BUILTIN_";
/// Returns the `BUILTIN_<alias>` tag for `arch` — e.g. `"BUILTIN_pusher_east"`.
///
/// For a `Builtin` archetype, `arch.name()` returns the alias (e.g. `"pusher_east"`).
/// For terrain archetypes (wall, crate, etc.) this is never called in practice.
pub(crate) fn builtin_tag(arch: Archetype) -> String {
format!("{BUILTIN_TAG_PREFIX}{}", arch.name())
}
/// Recovers the `Archetype` a `BUILTIN_*` tag came from, or `None` if `tag` is not
/// a built-in tag naming a known archetype. Used by the save path to round-trip.
pub(crate) fn archetype_from_builtin_tag(tag: &str) -> Option<Archetype> {
let name = tag.strip_prefix(BUILTIN_TAG_PREFIX)?;
Archetype::try_from(name).ok()
}
+18
View File
@@ -31,3 +31,21 @@ pub const NAMED_COLORS: [(&str, Rgba8); 16] = [
("Yellow", rgb(0xFF, 0xFF, 0x55)), ("Yellow", rgb(0xFF, 0xFF, 0x55)),
("White", rgb(0xFF, 0xFF, 0xFF)), ("White", rgb(0xFF, 0xFF, 0xFF)),
]; ];
/// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`].
/// Returns opaque black on any parse failure.
pub(crate) fn parse_color(hex: &str) -> Rgba8 {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Rgba8 {
r: 0,
g: 0,
b: 0,
a: 255,
};
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
Rgba8 { r, g, b, a: 255 }
}
+30 -28
View File
@@ -5,13 +5,14 @@
//! give boards some non-distracting visual flavor (textured ground) with little //! give boards some non-distracting visual flavor (textured ground) with little
//! authoring effort, including randomly-generated "grass" / "dirt" / "stone". //! authoring effort, including randomly-generated "grass" / "dirt" / "stone".
//! //!
//! The actual placement / per-cell expansion happens in [`crate::layer`] during //! The actual placement / per-cell expansion happens in [`crate::board_spec`] during
//! map load: a floor palette entry either names one of these generators (a fresh //! map load: a floor palette entry either names one of these generators (a fresh
//! glyph is rolled per grid cell) or gives a fixed glyph. This module only owns //! glyph is rolled per grid cell) or gives a fixed glyph. This module only owns
//! the generators themselves. //! the generators themselves.
use crate::glyph::Glyph; use crate::glyph::Glyph;
use color::Rgba8; use color::Rgba8;
use serde::{Deserialize, Serialize};
use tinyrand::{Probability, Rand, Seeded, StdRand}; use tinyrand::{Probability, Rand, Seeded, StdRand};
/// A board's floor: the cosmetic backdrop drawn beneath everything, replacing the /// A board's floor: the cosmetic backdrop drawn beneath everything, replacing the
@@ -21,7 +22,7 @@ use tinyrand::{Probability, Rand, Seeded, StdRand};
/// ///
/// Three forms: [`Blank`](Floor::Blank) (the canonical empty/black cell shows /// Three forms: [`Blank`](Floor::Blank) (the canonical empty/black cell shows
/// through), [`Fixed`](Floor::Fixed) (one glyph tiled across the whole board), or /// through), [`Fixed`](Floor::Fixed) (one glyph tiled across the whole board), or
/// [`Biome`](Floor::Biome) (a procedural [`FloorGenerator`] texture). A biome keeps /// [`Biome`](Floor::Biome) (a procedural [`FloorBiome`] texture). A biome keeps
/// its generator (so save re-emits the generator name) alongside a per-cell glyph /// its generator (so save re-emits the generator name) alongside a per-cell glyph
/// buffer pre-rolled once at load from [`FLOOR_SEED`] — deterministic, and the /// buffer pre-rolled once at load from [`FLOOR_SEED`] — deterministic, and the
/// direct replacement for the old per-cell floor-layer rolling. /// direct replacement for the old per-cell floor-layer rolling.
@@ -35,7 +36,7 @@ pub enum Floor {
/// `glyphs` buffer holding one pre-rolled glyph per cell (row-major). /// `glyphs` buffer holding one pre-rolled glyph per cell (row-major).
Biome { Biome {
/// The generator this biome was built from; re-emitted on save. /// The generator this biome was built from; re-emitted on save.
generator: FloorGenerator, generator: FloorBiome,
/// One pre-rolled glyph per cell (`width * height`, row-major). /// One pre-rolled glyph per cell (`width * height`, row-major).
glyphs: Vec<Glyph>, glyphs: Vec<Glyph>,
}, },
@@ -52,7 +53,7 @@ impl Floor {
/// Builds a [`Floor::Biome`] for a `width × height` board, pre-rolling one glyph /// Builds a [`Floor::Biome`] for a `width × height` board, pre-rolling one glyph
/// per cell from a [`FLOOR_SEED`]-seeded PRNG (so the result is deterministic and /// per cell from a [`FLOOR_SEED`]-seeded PRNG (so the result is deterministic and
/// depends only on the board dimensions + generator). /// depends only on the board dimensions + generator).
pub(crate) fn biome(generator: FloorGenerator, width: usize, height: usize) -> Floor { pub(crate) fn biome(generator: FloorBiome, width: usize, height: usize) -> Floor {
let mut rng = StdRand::seed(FLOOR_SEED); let mut rng = StdRand::seed(FLOOR_SEED);
let glyphs = (0..width * height).map(|_| generator.generate(&mut rng)).collect(); let glyphs = (0..width * height).map(|_| generator.generate(&mut rng)).collect();
Floor::Biome { generator, glyphs } Floor::Biome { generator, glyphs }
@@ -80,8 +81,9 @@ pub(crate) const FLOOR_SEED: u64 = 0x_C0FF_EE15_F100_0001;
/// and the probability/character set of its scattered "texture" glyphs; the /// and the probability/character set of its scattered "texture" glyphs; the
/// colors are deliberately dark and low-saturation so foreground objects stay /// colors are deliberately dark and low-saturation so foreground objects stay
/// readable against them. /// readable against them.
#[derive(Copy, Clone, Debug, PartialEq, Eq)] #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum FloorGenerator { #[serde(rename_all = "lowercase")]
pub enum FloorBiome {
/// Random green-to-greenish-yellow ground with a fairly high chance of grassy /// Random green-to-greenish-yellow ground with a fairly high chance of grassy
/// characters (comma, period, backquote, apostrophe). /// characters (comma, period, backquote, apostrophe).
Grass, Grass,
@@ -91,24 +93,24 @@ pub enum FloorGenerator {
Stone, Stone,
} }
impl FloorGenerator { impl FloorBiome {
/// Parses a generator from its map-file name, or `None` if unrecognized. /// Parses a generator from its map-file name, or `None` if unrecognized.
pub fn from_name(name: &str) -> Option<FloorGenerator> { pub fn from_name(name: &str) -> Option<FloorBiome> {
match name { match name {
"grass" => Some(FloorGenerator::Grass), "grass" => Some(FloorBiome::Grass),
"dirt" => Some(FloorGenerator::Dirt), "dirt" => Some(FloorBiome::Dirt),
"stone" => Some(FloorGenerator::Stone), "stone" => Some(FloorBiome::Stone),
_ => None, _ => None,
} }
} }
/// The generator's map-file name (inverse of [`from_name`](FloorGenerator::from_name)), /// The generator's map-file name (inverse of [`from_name`](FloorBiome::from_name)),
/// re-emitted on save so a biome floor round-trips. /// re-emitted on save so a biome floor round-trips.
pub fn name(&self) -> &'static str { pub fn name(&self) -> &'static str {
match self { match self {
FloorGenerator::Grass => "grass", FloorBiome::Grass => "grass",
FloorGenerator::Dirt => "dirt", FloorBiome::Dirt => "dirt",
FloorGenerator::Stone => "stone", FloorBiome::Stone => "stone",
} }
} }
@@ -121,7 +123,7 @@ impl FloorGenerator {
// (background color ranges, texture probability, texture chars). // (background color ranges, texture probability, texture chars).
let (bg, chars, prob) = match self { let (bg, chars, prob) = match self {
// Green → greenish-yellow: g dominant, a touch of r for the yellow tilt. // Green → greenish-yellow: g dominant, a touch of r for the yellow tilt.
FloorGenerator::Grass => ( FloorBiome::Grass => (
Rgba8 { Rgba8 {
r: shade(rng, 20, 55), r: shade(rng, 20, 55),
g: shade(rng, 45, 85), g: shade(rng, 45, 85),
@@ -132,7 +134,7 @@ impl FloorGenerator {
0.35, 0.35,
), ),
// Brown: r highest, g mid, b low. // Brown: r highest, g mid, b low.
FloorGenerator::Dirt => ( FloorBiome::Dirt => (
Rgba8 { Rgba8 {
r: shade(rng, 45, 75), r: shade(rng, 45, 75),
g: shade(rng, 30, 50), g: shade(rng, 30, 50),
@@ -143,7 +145,7 @@ impl FloorGenerator {
0.20, 0.20,
), ),
// Gray: all channels share one shade. // Gray: all channels share one shade.
FloorGenerator::Stone => { FloorBiome::Stone => {
let v = shade(rng, 40, 70); let v = shade(rng, 40, 70);
( (
Rgba8 { Rgba8 {
@@ -199,7 +201,7 @@ mod tests {
/// Rolls `count` glyphs from `generator` against a freshly-seeded RNG, the /// Rolls `count` glyphs from `generator` against a freshly-seeded RNG, the
/// same way the layer builder does. /// same way the layer builder does.
fn roll(generator: FloorGenerator, count: usize) -> Vec<Glyph> { fn roll(generator: FloorBiome, count: usize) -> Vec<Glyph> {
let mut rng = StdRand::seed(FLOOR_SEED); let mut rng = StdRand::seed(FLOOR_SEED);
(0..count).map(|_| generator.generate(&mut rng)).collect() (0..count).map(|_| generator.generate(&mut rng)).collect()
} }
@@ -207,24 +209,24 @@ mod tests {
#[test] #[test]
fn from_name_parses_known_generators() { fn from_name_parses_known_generators() {
assert_eq!( assert_eq!(
FloorGenerator::from_name("grass"), FloorBiome::from_name("grass"),
Some(FloorGenerator::Grass) Some(FloorBiome::Grass)
); );
assert_eq!( assert_eq!(
FloorGenerator::from_name("dirt"), FloorBiome::from_name("dirt"),
Some(FloorGenerator::Dirt) Some(FloorBiome::Dirt)
); );
assert_eq!( assert_eq!(
FloorGenerator::from_name("stone"), FloorBiome::from_name("stone"),
Some(FloorGenerator::Stone) Some(FloorBiome::Stone)
); );
assert_eq!(FloorGenerator::from_name("lava"), None); assert_eq!(FloorBiome::from_name("lava"), None);
} }
#[test] #[test]
fn grass_generator_stays_in_scheme_and_is_deterministic() { fn grass_generator_stays_in_scheme_and_is_deterministic() {
let a = roll(FloorGenerator::Grass, 64); let a = roll(FloorBiome::Grass, 64);
let b = roll(FloorGenerator::Grass, 64); let b = roll(FloorBiome::Grass, 64);
assert_eq!(a.len(), 64); assert_eq!(a.len(), 64);
// Same seed → identical rolls across builds. // Same seed → identical rolls across builds.
assert!(a.iter().zip(&b).all(|(x, y)| x == y)); assert!(a.iter().zip(&b).all(|(x, y)| x == y));
+237 -449
View File
@@ -1,48 +1,39 @@
use crate::action::{Action, BoardAction, SendArg}; use crate::action::{apply_push, apply_shift, apply_teleport, Action, BoardAction, Consequence, SendArg};
use crate::board::Board; use crate::board::Board;
use crate::log::LogLine; use crate::log::LogLine;
use crate::script::ScriptHost; use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, PlayerPos}; use crate::utils::{Direction, ObjectId};
use crate::world::World; use crate::world::World;
use std::cell::{Ref, RefMut}; use std::cell::{Ref, RefMut};
use std::collections::HashSet; use std::collections::{BTreeSet, HashSet, VecDeque};
use std::hash::Hash;
use std::time::Duration; use std::time::Duration;
/// The bump and send reactions produced while applying a batch of actions. /// A single `send` to an object, with an arg.
/// ///
/// [`GameState::apply_actions`] collects these but does not fire them; the /// Most things (bump, etc) are only triggered by player actions now.
/// follow-up [`GameState::settle`] pass runs them after all object hooks, so a /// However, sends still might trigger other sends! So we need to keep a
/// bumped object reacts to the fully-updated board. /// list of sends that we trigger in the process of resolving a list of
#[derive(Default)] /// actions. When we resolve these sends, we'll keep a list of things we've
struct Events { /// resolved, so we refuse to do the same send twice in a tick: this prevents
/// `(bumped object, direction the bump came from)` for each triggered `bump`. /// us from accidentally doing an infinite recursion.
bumps: Vec<(ObjectId, Direction)>, #[derive(Clone, Debug)]
/// `(entered non-solid object, direction the entrant came from)` for each struct SendAction(ObjectId, String, SendArg);
/// `enter` — a solid relocating onto a non-solid object's cell.
enters: Vec<(ObjectId, Direction)>,
/// `(target object, function name, argument)` for each `send`.
sends: Vec<(ObjectId, String, SendArg)>,
}
impl Events { impl Hash for SendAction {
/// Appends `other`'s reactions onto `self`. fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
fn merge(&mut self, other: Events) { self.0.hash(state);
self.bumps.extend(other.bumps); self.1.hash(state);
self.enters.extend(other.enters);
self.sends.extend(other.sends);
}
/// Whether there is anything left to fire.
fn is_empty(&self) -> bool {
self.bumps.is_empty() && self.enters.is_empty() && self.sends.is_empty()
} }
} }
/// Records which `(object, hook/fn, args)` reactions have already fired during a impl PartialEq for SendAction {
/// single `tick` / `try_move` / `run_init`. [`GameState::settle`] refuses to fire fn eq(&self, other: &Self) -> bool {
/// a key twice, so a bump/send cascade always terminates — even if two objects self.1 == other.1 && self.0 == other.0
/// bump each other in a cycle, each side fires at most once. Reset per invocation. }
type CalledSet = HashSet<(ObjectId, String, String)>; }
impl Eq for SendAction {}
/// How long a `say()` speech bubble stays on screen, in seconds. /// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0; pub const SAY_DURATION: f64 = 3.0;
@@ -51,6 +42,8 @@ pub const SAY_DURATION: f64 = 3.0;
// accessing the private `action` module directly. // accessing the private `action` module directly.
pub use crate::action::ScrollLine; pub use crate::action::ScrollLine;
use crate::player::{Player, PlayerRef}; use crate::player::{Player, PlayerRef};
use crate::portal::Portal;
use crate::tile::{EnterResponse, LocatedObject, Tile};
/// An active scroll overlay opened by a scripted object via `scroll()`. /// An active scroll overlay opened by a scripted object via `scroll()`.
/// ///
@@ -210,16 +203,11 @@ impl GameState {
pub fn run_init(&mut self) { pub fn run_init(&mut self) {
// Run each object's init hook in ascending id order, applying its actions // Run each object's init hook in ascending id order, applying its actions
// immediately so a later object's init sees what an earlier one did. // immediately so a later object's init sees what an earlier one did.
let mut ev = Events::default();
let ids = self.board().all_ids(); let ids = self.board().all_ids();
for id in ids { for id in ids {
let actions = self.scripts.run_init_on(id); let actions = self.scripts.run_init_on(id);
ev.merge(self.apply_actions(actions)); self.apply_actions(actions);
} }
// Fire any bump/send reactions, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
} }
/// Advances real-time game state by `dt` (the elapsed time since the last tick). /// Advances real-time game state by `dt` (the elapsed time since the last tick).
@@ -240,16 +228,11 @@ impl GameState {
}); });
// Run each object's tick in ascending id order, applying its drained // Run each object's tick in ascending id order, applying its drained
// actions immediately so the next object sees the updated board. // actions immediately so the next object sees the updated board.
let mut ev = Events::default();
let ids = self.board().all_ids(); let ids = self.board().all_ids();
for id in ids { for id in ids {
let actions = self.scripts.run_tick_on(id, secs); let actions = self.scripts.run_tick_on(id, secs);
ev.merge(self.apply_actions(actions)); self.apply_actions(actions);
} }
// Fire the bump/send reactions those ticks triggered, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
} }
/// Drains the log lines collected by the script host — script `log()` output /// Drains the log lines collected by the script host — script `log()` output
@@ -261,302 +244,105 @@ impl GameState {
self.log.extend(lines); self.log.extend(lines);
} }
/// Applies one object's drained `actions` to the board and returns the `bump` /// Applies one object's drained `actions` to the board
/// and `send` reactions they triggered (fired later by [`settle`](GameState::settle)). fn apply_actions(&mut self, actions: Vec<BoardAction>) {
///
/// Done in two phases so no `board_mut` borrow is held while scripts run
/// (they read the board through its getters): phase A mutates the board and records
/// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B applies the
/// player-stat / bubble / scroll changes after the borrow drops. The collected
/// reactions are returned rather than fired here, so the caller can run them once
/// all object hooks in this pass have applied.
fn apply_actions(&mut self, actions: Vec<BoardAction>) -> Events {
// Application-time errors (teleport/push/shift failures) go straight onto // Application-time errors (teleport/push/shift failures) go straight onto
// the shared LogSink — the same immediate channel as script `log()` output, // the shared LogSink — the same immediate channel as script `log()` output,
// so everything lands in the log in one emission order and is flushed by // so everything lands in the log in one emission order and is flushed by
// `drain_log`. A cheap Rc clone lets us push while the board borrow (which // `drain_log`. A cheap Rc clone lets us push while the board borrow (which
// also borrows `self`) is held. // also borrows `self`) is held.
let log_sink = self.scripts.log_sink().clone(); let log_sink = self.scripts.log_sink().clone();
let mut bumps: Vec<(ObjectId, Direction)> = Vec::new();
// `enter` reactions: a solid relocating onto a non-solid object's cell.
let mut enters: Vec<(ObjectId, Direction)> = Vec::new();
// Net change to player stats from AddGems / AlterHealth actions; applied
// to `self` after the board borrow drops.
let mut gem_delta: i64 = 0;
let mut health_delta: i64 = 0;
let mut key_changes: Vec<(String, bool)> = Vec::new();
let mut sends: Vec<(ObjectId, String, SendArg)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
// Collected outside the board borrow so we can assign to self.active_scroll.
let mut new_scroll: Option<Scroll> = None;
{ for ba in actions {
let mut board = self.board_mut(); match ba.action {
for ba in actions { Action::Move(dir) => {
match ba.action { step_object(&mut self.board_mut(), ba.source, dir);
Action::Move(dir) => { }
let StepOutcome { bumped, entered } = Action::SetTile(tile) => {
step_object(&mut board, ba.source, dir); if let Some(scr) = self.board_mut().scripting_mut(ba.source) {
// The bump / enter "comes from" the side the mover advanced scr.glyph.tile = tile;
// from, i.e. the opposite of its travel direction.
if let Some(bumped) = bumped {
bumps.push((bumped, dir.opposite()));
}
for id in entered {
enters.push((id, dir.opposite()));
}
} }
Action::SetTile(tile) => { }
if let Some(obj) = board.objects.get_mut(&ba.source) { Action::SetLight(radius) => {
obj.glyph.tile = tile; if let Some(obj) = self.board_mut().scripting_mut(ba.source) {
} obj.optics.glow = radius;
} }
Action::SetLight(radius) => { }
if let Some(obj) = board.objects.get_mut(&ba.source) { Action::SetTag {
obj.behavior.glow = radius; target,
} tag,
} present,
Action::SetTag { } => {
target, if let Some(obj) = self.board_mut().scripting_mut(target) {
tag, if present {
present, obj.tags.insert(tag);
} => {
if let Some(obj) = board.objects.get_mut(&target) {
if present {
obj.tags.insert(tag);
} else {
obj.tags.remove(&tag);
}
}
}
// Replace any existing bubble from this object so repeated say() calls
// don't stack visually — the new text resets the timer.
Action::Say(text, duration) => new_bubbles.push(SpeechBubble {
object_id: ba.source,
text,
remaining: duration,
}),
// Delays are consumed by ScriptHost::drain and never reach the board queue.
Action::Delay(_) => {}
Action::SetColor { fg, bg } => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
if let Some(c) = fg {
obj.glyph.fg = c;
}
if let Some(c) = bg {
obj.glyph.bg = c;
}
}
}
// Collected and fired after the board borrow drops, like bumps.
Action::Send {
target,
fn_name,
arg,
} => {
sends.push((target, fn_name, arg));
}
// Later scrolls overwrite earlier ones from the same tick.
Action::Scroll(lines) => {
new_scroll = Some(Scroll {
source: ba.source,
lines,
choice: None,
});
}
Action::Teleport { target, x, y } => {
if !board.in_bounds((x, y)) {
log_sink.error(format!(
"teleport({target},{x},{y}): out of bounds"
));
} else if target == -1 {
// Move the player. A solid *other than the player itself*
// blocks the destination.
let (ux, uy) = (x as usize, y as usize);
let blocked = matches!(
board.solid_at(ux, uy),
Some(s) if !s.player()
);
if blocked {
log_sink.error(format!(
"teleport(player,{x},{y}): destination is solid"
));
} else {
let (old_x, old_y) = (board.player.x, board.player.y);
board.player.x = ux as i64;
board.player.y = uy as i64;
// The player is solid, so it may land on non-solids:
// fire `enter` with a best-effort came-from direction
// (the jump is arbitrary, so it has no exact cardinal).
if let Some(from) =
Direction::from_delta(old_x - ux as i64, old_y - uy as i64)
{
for id in board.non_solid_object_ids_at(ux, uy) {
enters.push((id, from));
}
}
}
} else if let Ok(tid) = ObjectId::try_from(target) {
// Move object `tid` to (x, y). A solid destination blocks
// a solid mover, unless the occupant is that same object.
let (ux, uy) = (x as usize, y as usize);
match board.objects.get(&tid) {
None => log_sink.error(format!(
"teleport({target},{x},{y}): no such object"
)),
Some(obj) => {
let source_solid = obj.behavior.solid;
let (old_x, old_y) = (obj.x as i64, obj.y as i64);
let blocked = source_solid
&& matches!(
board.solid_at(ux, uy),
Some(s) if s.object_id() != Some(tid)
);
if blocked {
log_sink.error(format!(
"teleport({target},{x},{y}): destination is solid"
));
} else {
if let Some(obj) = board.objects.get_mut(&tid) {
obj.x = ux;
obj.y = uy;
}
// Only a solid mover triggers `enter`; the
// direction is best-effort (arbitrary jump).
if source_solid
&& let Some(from) = Direction::from_delta(
old_x - ux as i64,
old_y - uy as i64,
)
{
for id in board.non_solid_object_ids_at(ux, uy) {
enters.push((id, from));
}
}
}
}
}
} else { } else {
log_sink.error(format!( obj.tags.remove(&tag);
"teleport({target},{x},{y}): invalid target id"
));
} }
} }
// push() self-checks can_push, so an in-bounds guard is all we add. }
Action::Push { x, y, dir } => { // Replace any existing bubble from this object so repeated say() calls
if !board.in_bounds((x, y)) { // don't stack visually — the new text resets the timer.
log_sink.error(format!("push({x},{y}): out of bounds")); Action::Say(text, duration) => {
} else { // One bubble per object: replace the existing one if present.
// Each pushed solid stepped one cell in `dir`; fire `enter` self.speech_bubbles
// on any non-solid it landed on (came-from `dir.opposite()`). .retain(|b| b.object_id != ba.source);
for (cx, cy) in board.push(x as usize, y as usize, dir) { self.speech_bubbles.push(
for id in board.non_solid_object_ids_at(cx, cy) { SpeechBubble {
enters.push((id, dir.opposite())); object_id: ba.source,
} text,
} remaining: duration,
}
);
},
// Delays are consumed by ScriptHost::drain and never reach the board queue.
Action::Delay(_) => {}
Action::SetColor { fg, bg } => {
if let Some(obj) = self.board_mut().scripting_mut(ba.source) {
if let Some(c) = fg {
obj.glyph.fg = c;
}
if let Some(c) = bg {
obj.glyph.bg = c;
} }
} }
// apply_shift moves the named cells, returning error lines plus the }
// relocations it performed (for `enter` at each destination). Action::Send { target, fn_name, arg} => {
Action::Shift(cells) => { self.scripts.run_send(target, &fn_name, arg);
let outcome = board.apply_shift(&cells); }
for line in outcome.errors { Action::Scroll(lines) => {
log_sink.line(line); self.active_scroll.replace(Scroll {
} source: ba.source,
for (from, to) in outcome.moves { lines,
// A shift can rotate non-adjacent cells, so the came-from choice: None,
// direction is best-effort (dominant axis of the jump). });
if let Some(from_dir) = }
Direction::from_delta(from.0 - to.0, from.1 - to.1) Action::Teleport { target, x, y } => {
{ apply_teleport(&mut self.board_mut(), target, x, y).unwrap_or_else(|e| log_sink.error(e))
for id in }
board.non_solid_object_ids_at(to.0 as usize, to.1 as usize) Action::Push { x, y, dir } => {
{ apply_push(&mut self.board_mut(), x, y, dir).unwrap_or_else(|e| log_sink.error(e))
enters.push((id, from_dir)); }
} Action::Shift(cells) => {
} apply_shift(&mut self.board_mut(), &cells).unwrap_or_else(|e| log_sink.error(e))
} }
} Action::AddGems(n) => {
// Accumulated and applied to `self.player.gems` after the borrow drops. self.player.borrow_mut().alter_gems(n);
Action::AddGems(n) => gem_delta += n, },
// Accumulated and applied to `self.player.health` after the borrow drops. Action::AlterHealth(dh) => {
Action::AlterHealth(dh) => health_delta += dh, self.player.borrow_mut().alter_health(dh)
// Collected and applied to `self.player.keys` after the borrow drops. },
Action::SetKey(color, present) => key_changes.push((color, present)), Action::SetKey(color, present) => {
// A grab thing despawns itself from its grab() hook. if !self.player.borrow_mut().keys.set_by_name(&color, present) {
Action::Die => { log_sink.error(format!("set_key: unknown color {color:?}"));
board.remove_object(ba.source);
} }
},
Action::Die => {
self.board_mut().remove_object(ba.source);
} }
} }
} }
for bubble in new_bubbles { self.drain_log();
// One bubble per object: replace the existing one if present.
self.speech_bubbles
.retain(|b| b.object_id != bubble.object_id);
self.speech_bubbles.push(bubble);
}
if let Some(scroll) = new_scroll {
self.active_scroll = Some(scroll);
}
// Apply the net gem change (clamped at 0, since the count is unsigned).
if gem_delta != 0 {
self.player.borrow_mut().alter_gems(gem_delta);
}
// Apply the net health change (clamped to [0, max_health]).
if health_delta != 0 {
self.player.borrow_mut().alter_health(health_delta);
}
for (color, present) in key_changes {
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
log_sink.error(format!("set_key: unknown color {color:?}"));
}
}
// Return the reactions for the caller's settle pass rather than firing them here.
Events {
bumps,
enters,
sends,
}
}
/// Fires the `bump` / `send` reactions in `events` (and any they cascade into)
/// until the board is quiescent, applying each hook's actions as it runs.
///
/// `called` records every `(object, hook/fn, args)` already fired this
/// invocation; a reaction whose key is already present is skipped. Since each
/// key fires at most once, the loop is finite even when objects bump each
/// other in a cycle — the guard is what makes bump-loops impossible.
fn settle(&mut self, initial: Events, called: &mut CalledSet) {
let mut pending = initial;
while !pending.is_empty() {
let mut next = Events::default();
for (id, dir) in std::mem::take(&mut pending.bumps) {
// Skip a bump already fired this pass (dedup key includes the direction).
if !called.insert((id, "bump".to_string(), format!("{dir:?}"))) {
continue;
}
let actions = self.scripts.run_bump(id, dir);
next.merge(self.apply_actions(actions));
}
for (id, dir) in std::mem::take(&mut pending.enters) {
// Skip an enter already fired this pass (dedup key includes the direction).
if !called.insert((id, "enter".to_string(), format!("{dir:?}"))) {
continue;
}
let actions = self.scripts.run_enter(id, dir);
next.merge(self.apply_actions(actions));
}
for (id, fn_name, arg) in std::mem::take(&mut pending.sends) {
// Dedup key: target + function name + argument.
if !called.insert((id, fn_name.clone(), format!("{arg:?}"))) {
continue;
}
let actions = self.scripts.run_send(id, &fn_name, arg);
next.merge(self.apply_actions(actions));
}
pending = next;
}
} }
/// Consumes the active scroll, dispatching the player's choice (if any) back /// Consumes the active scroll, dispatching the player's choice (if any) back
@@ -573,10 +359,6 @@ impl GameState {
// Dispatch the choice back to the source object and apply whatever it // Dispatch the choice back to the source object and apply whatever it
// queues (plus any bump/send cascade), the same as a tick. // queues (plus any bump/send cascade), the same as a tick.
let actions = self.scripts.run_send(scroll.source, &choice, SendArg::None); let actions = self.scripts.run_send(scroll.source, &choice, SendArg::None);
let ev = self.apply_actions(actions);
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
} }
} }
@@ -596,10 +378,7 @@ impl GameState {
// Find the named arrival portal on the target board (borrow then release). // Find the named arrival portal on the target board (borrow then release).
let arrival = self.world.boards[target_map] let arrival = self.world.boards[target_map]
.borrow() .borrow()
.portals .named_portal(target_entry).map(Portal::location);
.iter()
.find(|p| p.name == target_entry)
.map(|p| (p.x, p.y));
let (ax, ay) = match arrival { let (ax, ay) = match arrival {
Some(pos) => pos, Some(pos) => pos,
None => { None => {
@@ -614,10 +393,7 @@ impl GameState {
self.active_scroll = None; self.active_scroll = None;
// Switch to the new board and place the player at the arrival portal. // Switch to the new board and place the player at the arrival portal.
self.current_board_name = target_map.to_string(); self.current_board_name = target_map.to_string();
self.board_mut().player = PlayerPos { self.board_mut().get_mut(ax, ay).replace(Tile::Player);
x: ax as i64,
y: ay as i64,
};
self.board_mut().clear_all_queues(); self.board_mut().clear_all_queues();
// Rebuild the script host for the new board's objects. // Rebuild the script host for the new board's objects.
self.scripts = ScriptHost::new( self.scripts = ScriptHost::new(
@@ -639,77 +415,109 @@ impl GameState {
/// or at the end of a chain of crates the player is shoving — its `bump` hook /// or at the end of a chain of crates the player is shoving — its `bump` hook
/// fires with the direction the bump came from (whether or not the player moves). /// fires with the direction the bump came from (whether or not the player moves).
pub fn try_move(&mut self, dir: Direction) { pub fn try_move(&mut self, dir: Direction) {
let bumped; let (dx, dy): (i64, i64) = dir.into();
let grabbed; let player_loc = self.board().player_pos();
let portal_target; let target = (player_loc.0 as i64 + dx, player_loc.1 as i64 + dy);
// Non-solid objects the player (or the crates it shoved) landed on this move, if !self.board().in_bounds(target) {
// collected while the board is borrowed and fired as `enter` after the borrow.
let mut entered: Vec<ObjectId> = Vec::new();
{
let (dx, dy): (i64, i64) = dir.into();
let mut board = self.board_mut();
let target = (board.player.x + dx, board.player.y + dy);
if !board.in_bounds(target) {
return;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// Walking onto a grab thing (e.g. a gem) is never blocked: the player
// moves onto it and its grab() hook fires (the thing despawns itself).
grabbed = board.grab_object_at(nx, ny);
// A solid object in the way is bumped by the player — possibly through a
// chain of crates the player is shoving (see `bump_target`) — but a grab
// thing fires grab() instead of bump(), so don't also bump it.
bumped = if grabbed.is_none() {
board.bump_target(nx, ny, dir)
} else {
None
};
if grabbed.is_some() || board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
// Don't push a grab thing aside — walk onto it. Otherwise shove any
// pushable chain out of the way (no-op when there's nothing to push).
if grabbed.is_none() {
// Each pushed solid lands on a cell that may hold a non-solid.
for (cx, cy) in board.push(nx, ny, dir) {
entered.extend(board.non_solid_object_ids_at(cx, cy));
}
}
board.player.x = nx as i64;
board.player.y = ny as i64;
// The player is solid, so any non-solid on its new cell gets `enter`.
entered.extend(board.non_solid_object_ids_at(nx, ny));
// Check for a portal at the new position; clone strings to release the borrow.
portal_target = board
.portals
.iter()
.find(|p| p.x == nx && p.y == ny)
.map(|p| (p.target_map.clone(), p.target_entry.clone()));
} else {
portal_target = None;
}
}
// A portal takes priority: board transitions skip the bump/enter hooks.
if let Some((target_map, target_entry)) = portal_target {
self.enter_board(&target_map, &target_entry);
return; return;
} }
let mut ev = Events::default(); let (nx, ny) = (target.0 as usize, target.1 as usize);
// Fire the grab hook and apply it immediately so the grabbed thing's
// die()/alter_gems() apply now — no player+object overlap survives this call. // We need to be able to call hooks on objects, so we can't hold a mut reference to the
if let Some(id) = grabbed { // board going into this match
let actions = self.scripts.run_grab(id); let obj_data = {
ev.merge(self.apply_actions(actions)); if let Some(Tile::Object(b)) = self.board_mut().get(nx, ny) && let def = b.as_ref() {
Some((def.scripting.id, def.enter_response))
} else {
None
}
};
if let Some((id, enter_response)) = obj_data {
let actions = match enter_response {
EnterResponse::Block => {
// Call the bump hook
self.scripts.run_bump(id, dir.opposite())
}
EnterResponse::Grab => {
// Call the hook to get the actions and then stamp the player on top
let a = self.scripts.run_grab(id);
{
let mut board = self.board_mut();
board.get_mut(player_loc.0, player_loc.1).take();
board.get_mut(nx, ny).replace(Tile::Player);
}
a
}
EnterResponse::Push(pushable) => {
// First, can we push?
if self.board().can_push(nx, ny, dir) {
// The player is pushable, so, this amounts to the same thing and saves a
// couple replace()s
self.board_mut().push(player_loc.0, player_loc.1, dir);
vec![] // There's no push hook, just pushing doesn't call scripts
} else {
// This is actually not pushable in this way, so we're gonna bump instead:
self.scripts.run_bump(id, dir.opposite())
}
}
EnterResponse::Hook => {
vec![] // TODO this hook needs to exist and work. It's documented in tile.rb. Has implications for push as well
// plan: get rid of can_push in board. Make a board::pushes_into_hook or something, find the hook-enter
// object at the end of this chain. Trying to push calls that, if there's no hook object then it pushes, if
// that returns false then it bumps. If there is a hook object, call the hook, run the actions. If it _leaves
// the cell empty,_ then call push. Otherwise bump.
// Maybe have a pushresult enum or something that board::push returns, "hook(id, bumpid)" or "bump(id)" or "moved".
// If the situation is: `@b++h` then moving to the east, the bumpid would be b (the thing you actually touched),
// hook id would be h (the thing with the hook enterresponse). Board can identify object chains but not actually
// call hooks.
}
EnterResponse::Swap => {
let mut board = self.board_mut();
// Swap the two
let tgt = board.get_mut(nx, ny).replace(Tile::Player);
*board.get_mut(player_loc.0, player_loc.1) = tgt;
vec![]
}
EnterResponse::Squish => {
let mut board = self.board_mut();
// Stamp over it, squishing it
board.get_mut(player_loc.0, player_loc.1).take();
board.get_mut(nx, ny).replace(Tile::Player);
vec![]
}
};
self.apply_actions(actions);
} else {
// There's not an object there, we can just move the player
let mut board = self.board_mut();
board.get_mut(player_loc.0, player_loc.1).take();
board.get_mut(nx, ny).replace(Tile::Player);
} }
if let Some(idx) = bumped {
// The player advanced in `dir`, so the bump arrives from the opposite side. // Check if we actually moved
ev.bumps.push((idx, dir.opposite())); let new_loc = self.board().player_pos();
if new_loc != player_loc {
// Portals take priority: if we're on a portal it doesn't matter what else we entered:
let portal_info = {
if let Some(Portal { target_board, target_name, ..}) = self.board().portal_at(new_loc.0, new_loc.1) {
Some((target_board.clone(), target_name.clone()))
} else { None }
};
if let Some((target_board, target_name)) = portal_info {
self.enter_board(&target_board, &target_name)
} else {
// We're still on the board, so see if we stepped on any sensors:
let sensor_ids = self.board().sensor_ids_at(new_loc.0, new_loc.1);
for id in sensor_ids {
let actions = self.scripts.run_enter(id, dir.opposite());
self.apply_actions(actions)
}
}
} }
for id in entered {
// The player advanced in `dir`, so it entered from the opposite side.
ev.enters.push((id, dir.opposite()));
}
// Settle the grab/bump reactions (and any they cascade into) before returning.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log(); self.drain_log();
} }
} }
@@ -736,46 +544,28 @@ struct StepOutcome {
/// solid objects yield a bump. An `enter` is recorded for every non-solid object a /// solid objects yield a bump. An `enter` is recorded for every non-solid object a
/// solid lands on: the mover's destination cell (only when the mover is itself solid) /// solid lands on: the mover's destination cell (only when the mover is itself solid)
/// and each cell a pushed crate moved into (crates are always solid entrants). /// and each cell a pushed crate moved into (crates are always solid entrants).
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> StepOutcome { fn step_object(board: &mut Board, id: ObjectId, dir: Direction) {
let mut out = StepOutcome { // TODO when an object pushes the player, it should still trigger actions on
bumped: None, // what the player is pushed into. But, for right now, just call board::push
entered: Vec::new(), let (loc, solid) = if let Some(obj) = board.get_hookable(id) {
}; (obj.location(), obj.solid())
let (dx, dy): (i64, i64) = dir.into(); } else { return };
let Some((ox, oy, solid)) = board.objects.get(&id).map(|o| (o.x, o.y, o.behavior.solid)) else {
return out; if solid {
}; // This is a real object on the board, try and push it
let target = (ox as i64 + dx, oy as i64 + dy); board.push(loc.0, loc.1, dir);
if !board.in_bounds(target) { } else {
return out; // This is a sensor, we can just teleport it
board.move_sensor(id, dir);
} }
let (nx, ny) = (target.0 as usize, target.1 as usize);
// Capture the bumped object before any push relocates it (its id is stable).
// Walks through a pushed crate chain to the object it presses against.
out.bumped = board.bump_target(nx, ny, dir);
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
// Shove a crate/object out of the way (no-op otherwise); each pushed solid
// may land on a non-solid, which gets `enter` regardless of the mover.
for (cx, cy) in board.push(nx, ny, dir) {
out.entered.extend(board.non_solid_object_ids_at(cx, cy));
}
let obj = board.objects.get_mut(&id).expect("id checked above");
obj.x = nx;
obj.y = ny;
// Only a solid mover triggers `enter` on non-solids under its own new cell.
if solid {
out.entered.extend(board.non_solid_object_ids_at(nx, ny));
}
}
out
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::GameState; use super::GameState;
use crate::Direction; use crate::Direction;
use crate::archetype::{Archetype, Builtin}; use crate::builtin::Builtin;
use crate::board::tests::{crate_at, open_board, stamp, wall_at}; use crate::board::tests::{crate_at, gem_at, open_board, wall_at};
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
@@ -784,9 +574,8 @@ mod tests {
fn walking_onto_a_gem_grabs_it() { fn walking_onto_a_gem_grabs_it() {
// A gem terrain cell at (1,0); expanding turns it into the builtin gem // A gem terrain cell at (1,0); expanding turns it into the builtin gem
// object running scripts/gem.rhai. The player starts at (0,0). // object running scripts/gem.rhai. The player starts at (0,0).
let mut board = open_board(3, 1, (0, 0), vec![]); let mut board = open_board(3, 1, (0, 0));
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem")); gem_at(&mut board, 1, 0);
board.expand_builtin_archetypes();
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.run_init(); game.run_init();
@@ -794,8 +583,7 @@ mod tests {
// The gem was grabbed: gem count up, gem object gone, player on its cell. // The gem was grabbed: gem count up, gem object gone, player on its cell.
assert_eq!(game.player.borrow().gems, 1); assert_eq!(game.player.borrow().gems, 1);
assert!(game.board().objects.is_empty()); assert_eq!(game.board().player_pos(), (1, 0));
assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
} }
#[test] #[test]
@@ -806,7 +594,7 @@ mod tests {
// the board edge), so nothing happens and the gem is not collected. // the board edge), so nothing happens and the gem is not collected.
let mut sobj = ObjectDef::new(0, 0); let mut sobj = ObjectDef::new(0, 0);
sobj.behavior.solid = false; sobj.behavior.solid = false;
sobj.script_name = Some("s".to_string()); sobj.scripting.script_name = Some("s".to_string());
let mut board = open_board(3, 1, (2, 0), vec![sobj]); let mut board = open_board(3, 1, (2, 0), vec![sobj]);
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem")); stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem"));
board.expand_builtin_archetypes(); board.expand_builtin_archetypes();
@@ -830,7 +618,7 @@ mod tests {
fn game_with_object_script(board_w: usize, src: &str) -> GameState { fn game_with_object_script(board_w: usize, src: &str) -> GameState {
let mut obj = ObjectDef::new(0, 0); let mut obj = ObjectDef::new(0, 0);
obj.behavior.solid = false; obj.behavior.solid = false;
obj.script_name = Some("s".to_string()); obj.scripting.script_name = Some("s".to_string());
let mut board = open_board(board_w, 1, (board_w as i64 - 1, 0), vec![obj]); let mut board = open_board(board_w, 1, (board_w as i64 - 1, 0), vec![obj]);
crate_at(&mut board, 2, 0); crate_at(&mut board, 2, 0);
let scripts = HashMap::from([("s".to_string(), src.to_string())]); let scripts = HashMap::from([("s".to_string(), src.to_string())]);
@@ -860,7 +648,7 @@ mod tests {
// genuinely empty in-bounds cell. // genuinely empty in-bounds cell.
let mut obj = ObjectDef::new(0, 0); let mut obj = ObjectDef::new(0, 0);
obj.behavior.solid = false; obj.behavior.solid = false;
obj.script_name = Some("s".to_string()); obj.scripting.script_name = Some("s".to_string());
let mut board = open_board(4, 1, (3, 0), vec![obj]); let mut board = open_board(4, 1, (3, 0), vec![obj]);
crate_at(&mut board, 2, 0); crate_at(&mut board, 2, 0);
let src = "fn init(m) { \ let src = "fn init(m) { \
@@ -887,7 +675,7 @@ mod tests {
// would have been stuck behind the delay and absent here. // would have been stuck behind the delay and absent here.
let mut obj = ObjectDef::new(0, 0); let mut obj = ObjectDef::new(0, 0);
obj.behavior.solid = false; obj.behavior.solid = false;
obj.script_name = Some("s".to_string()); obj.scripting.script_name = Some("s".to_string());
let board = open_board(4, 1, (3, 0), vec![obj]); let board = open_board(4, 1, (3, 0), vec![obj]);
let src = "fn init(m) { delay(5.0); log(\"immediate\"); }"; let src = "fn init(m) { delay(5.0); log(\"immediate\"); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]); let scripts = HashMap::from([("s".to_string(), src.to_string())]);
@@ -917,9 +705,9 @@ mod tests {
) -> GameState { ) -> GameState {
let mut obj = ObjectDef::new(1, 1); let mut obj = ObjectDef::new(1, 1);
obj.behavior.solid = false; obj.behavior.solid = false;
obj.script_name = Some("spinner".to_string()); obj.scripting.script_name = Some("spinner".to_string());
if ccw { if ccw {
obj.tags.insert("BUILTIN_spinner_ccw".to_string()); obj.scripting.tags.insert("BUILTIN_spinner_ccw".to_string());
} }
let mut board = open_board(3, 3, (1, 1), vec![obj]); let mut board = open_board(3, 3, (1, 1), vec![obj]);
for &(x, y) in crates { for &(x, y) in crates {
@@ -1000,7 +788,7 @@ mod tests {
fn set_key_gives_and_takes_keys() { fn set_key_gives_and_takes_keys() {
let mut sobj = ObjectDef::new(0, 0); let mut sobj = ObjectDef::new(0, 0);
sobj.behavior.solid = false; sobj.behavior.solid = false;
sobj.script_name = Some("s".to_string()); sobj.scripting.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]); let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([( let scripts = HashMap::from([(
"s".to_string(), "s".to_string(),
@@ -1017,7 +805,7 @@ mod tests {
// A second script can take a key. // A second script can take a key.
let mut sobj2 = ObjectDef::new(0, 0); let mut sobj2 = ObjectDef::new(0, 0);
sobj2.behavior.solid = false; sobj2.behavior.solid = false;
sobj2.script_name = Some("t".to_string()); sobj2.scripting.script_name = Some("t".to_string());
let board2 = open_board(2, 1, (1, 0), vec![sobj2]); let board2 = open_board(2, 1, (1, 0), vec![sobj2]);
let scripts2 = HashMap::from([( let scripts2 = HashMap::from([(
"t".to_string(), "t".to_string(),
@@ -1033,7 +821,7 @@ mod tests {
fn set_key_unknown_color_logs_error() { fn set_key_unknown_color_logs_error() {
let mut sobj = ObjectDef::new(0, 0); let mut sobj = ObjectDef::new(0, 0);
sobj.behavior.solid = false; sobj.behavior.solid = false;
sobj.script_name = Some("s".to_string()); sobj.scripting.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]); let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([( let scripts = HashMap::from([(
"s".to_string(), "s".to_string(),
+56 -1
View File
@@ -19,7 +19,7 @@ use crate::utils::LogSink;
/// (see [`Glyph::player`]). /// (see [`Glyph::player`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Glyph { pub struct Glyph {
/// Tile index into the board's bitmap font (left-to-right, top-to-bottom). /// Which tile to draw
pub tile: u32, pub tile: u32,
/// Foreground color, applied to non-background pixels of the tile. /// Foreground color, applied to non-background pixels of the tile.
pub fg: Rgba8, pub fg: Rgba8,
@@ -27,6 +27,10 @@ pub struct Glyph {
pub bg: Rgba8, pub bg: Rgba8,
} }
impl Default for Glyph {
fn default() -> Self { Self::transparent() }
}
impl Hash for Glyph { impl Hash for Glyph {
/// Hash via packed u32 representations so the impl stays in sync with Eq. /// Hash via packed u32 representations so the impl stays in sync with Eq.
fn hash<H: Hasher>(&self, state: &mut H) { fn hash<H: Hasher>(&self, state: &mut H) {
@@ -77,4 +81,55 @@ impl Glyph {
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
} }
} }
/// The default glyph for a portal: CP437 char 240 (`≡`), black on white.
#[rustfmt::skip]
pub const fn portal() -> Self {
Self {
tile: 240,
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: Rgba8 { r: 255, g: 255, b: 255, a: 255 },
}
}
} }
// TODO make TileIndex work again
/*
/// A tile index in a palette entry: either a plain integer or a character literal.
///
/// Accepting both forms lets map files write `tile = 35` or `tile = "#"` (the char
/// is converted to its Unicode scalar) interchangeably.
#[derive(Deserialize, Serialize, Eq, Clone, Copy, Debug)]
#[serde(untagged)]
pub enum TileIndex {
/// A direct tile index (e.g. `tile = 35`).
Num(u32),
/// A single-character shorthand (e.g. `tile = "#"`); converted to its Unicode scalar.
Chr(char),
}
impl TileIndex {
/// Returns the tile index as a `u32`, converting a char to its scalar value.
pub(crate) fn into_u32(self) -> u32 {
match self {
TileIndex::Num(n) => n,
TileIndex::Chr(c) => c as u32,
}
}
}
impl PartialEq for TileIndex {
fn eq(&self, other: &Self) -> bool {
self.into_u32() == other.into_u32()
}
}
impl Into<u32> for TileIndex {
fn into(self) -> u32 {
match self {
TileIndex::Num(n) => n,
TileIndex::Chr(c) => c as u32,
}
}
}
*/
-366
View File
@@ -1,366 +0,0 @@
//! The board grid: the palette+char map-file unit and its load-time conversion.
//!
//! A board is a single **grid** — a character grid plus a palette mapping each
//! character to one *kind* of thing: an archetype (terrain), a scripted object, a
//! portal, or the player. (The board's cosmetic floor is a separate `[map]`
//! attribute, not a grid cell; see [`crate::floor`].)
//!
//! This module owns the grid serde type ([`GridData`], [`PaletteEntry`]) and the
//! load-time conversion ([`build_grid`]) that turns one `GridData` into the board's
//! `Vec<(Glyph, Archetype)>` cells plus a list of [`Placement`]s (objects/portals/
//! player) for the map loader to resolve. Cross-cell validation (one solid per
//! cell, unique names, the player winning its cell) lives in [`crate::map_file`].
use crate::archetype::Archetype;
use crate::glyph::Glyph;
use crate::log::LogLine;
use crate::map_file::{TileIndex, parse_color};
use crate::object_def::ObjectDef;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::utils::Pushable;
/// Serde representation of the board `[grid]`: a char grid plus its palette.
///
/// The grid is given in exactly one of three ways (precedence: `content`, then
/// `fill`, then `sparse`; none of them ⇒ an all-spaces grid):
/// - `content` — a multi-line grid string, one char per cell (`width × height`).
/// - `fill` — a single character; the whole grid is filled with it.
/// - `sparse` — a list of `{ x, y, ch }` cells over an otherwise all-spaces grid
/// (handy for a grid holding just a few things).
///
/// A space (`' '`) is always a transparent empty cell and is never a palette key
/// (any `" "` entry in `palette` is ignored).
#[derive(Deserialize, Serialize, Default)]
pub(crate) struct GridData {
/// Multi-line grid string; one char per cell, looked up in `palette`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Single character to fill the whole `width × height` grid with.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill: Option<String>,
/// Individual cells over an otherwise all-spaces grid.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sparse: Option<Vec<SparseCell>>,
/// Char (as a one-character string key) → palette entry.
#[serde(default)]
pub palette: HashMap<String, PaletteEntry>,
}
/// One cell in a [`GridData::sparse`] list: a single character at `(x, y)`.
#[derive(Deserialize, Serialize, Clone)]
pub(crate) struct SparseCell {
/// Column (0-indexed).
pub x: usize,
/// Row (0-indexed).
pub y: usize,
/// The grid character at this cell (a one-character string).
pub ch: String,
}
/// One palette entry, discriminated by [`kind`](PaletteEntry::kind).
///
/// A single flat struct (rather than an enum) because `kind` is open-ended: it is
/// any archetype name (`"wall"`, `"crate"`, `"pusher_east"`, …) *or* one of the
/// meta-kinds `empty`, `object`, `portal`, `player`. Only the fields relevant to a
/// given kind are read; the rest stay `None`. See [`resolve_entry`].
#[derive(Deserialize, Serialize, Default, Clone)]
pub(crate) struct PaletteEntry {
/// What this entry is: an archetype name, or `empty`/`object`/`portal`/`player`.
pub kind: String,
/// Tile index (int or single-char string). Used by archetype/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<TileIndex>,
/// Foreground `"#RRGGBB"`. Used by archetype/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
/// Background `"#RRGGBB"`. Used by archetype/object kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
/// Object solidity (defaults `true`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub solid: Option<bool>,
/// Object opacity (defaults `true`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opaque: Option<bool>,
/// Object pushability (defaults `false`). Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pushable: Option<Pushable>,
/// 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>,
/// Open-ended labels. Only for `kind = "object"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
/// Unique name. For `kind = "object"` (optional) or `kind = "portal"` (required).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Target board key. Required for `kind = "portal"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_map: Option<String>,
/// Target portal name on the destination board. Required for `kind = "portal"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_entry: Option<String>,
}
/// A single grid cell: its visual and its behavioral class.
pub(crate) type GridCell = (Glyph, Archetype);
/// A non-terrain thing the grid places at a cell, resolved by the map loader.
///
/// Terrain goes straight into the grid cells; these need cross-cell handling (ids,
/// name uniqueness, the single player), so [`build_grid`] returns them separately
/// with their `(x, y)` for [`crate::map_file`] to finish.
pub(crate) enum Placement {
/// A scripted object to spawn at `(x, y)`.
Object(ObjectTemplate, usize, usize),
/// A portal at `(x, y)`.
Portal(PortalTemplate, usize, usize),
/// The player's start cell `(x, y)`.
Player(usize, usize),
}
/// A resolved object definition minus board-assigned fields (`id`).
#[derive(Clone)]
pub(crate) struct ObjectTemplate {
pub glyph: Glyph,
pub solid: bool,
pub opaque: bool,
pub pushable: Pushable,
/// 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>,
}
/// A resolved portal definition minus `(x, y)`.
#[derive(Clone)]
pub(crate) struct PortalTemplate {
pub name: String,
pub target_map: String,
pub target_entry: String,
}
/// What a palette character resolves to during the grid walk.
#[derive(Clone)]
enum Resolved {
/// A fixed cell written straight into the grid (terrain, empty, error block).
Cell(Glyph, Archetype),
/// A scripted object placed per occurrence.
Object(ObjectTemplate),
/// A portal placed per occurrence.
Portal(PortalTemplate),
/// The player's start cell.
Player,
}
/// Resolves one palette entry to a [`Resolved`], recording any nonfatal problem.
///
/// Unknown archetype names become a visible [`Archetype::ErrorBlock`]; a `portal`
/// missing required fields falls back to a transparent cell (the grid char is
/// still consumed). Object/archetype glyphs fall back to the relevant default for
/// any absent visual field.
fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resolved {
// Builds a glyph from the entry's tile/fg/bg, each falling back to `default`.
let glyph_with_default = |default: Glyph| Glyph {
tile: e.tile.map(TileIndex::into_u32).unwrap_or(default.tile),
fg: e.fg.as_deref().map(parse_color).unwrap_or(default.fg),
bg: e.bg.as_deref().map(parse_color).unwrap_or(default.bg),
};
match e.kind.as_str() {
// Transparent: the floor / a lower thing shows through here.
"empty" => Resolved::Cell(Glyph::transparent(), Archetype::Empty),
"object" => Resolved::Object(ObjectTemplate {
glyph: glyph_with_default(ObjectDef::default_glyph()),
solid: e.solid.unwrap_or(true),
opaque: e.opaque.unwrap_or(true),
pushable: e.pushable.unwrap_or(Pushable::No),
light: e.light.unwrap_or(0),
script_name: e.script_name.clone(),
tags: e.tags.clone().unwrap_or_default(),
name: e.name.clone(),
}),
"portal" => match (e.name.clone(), e.target_map.clone(), e.target_entry.clone()) {
(Some(name), Some(target_map), Some(target_entry)) => {
Resolved::Portal(PortalTemplate {
name,
target_map,
target_entry,
})
}
_ => {
errors.push(LogLine::error(format!(
"portal palette '{ch}' needs name, target_map and target_entry; skipping"
)));
Resolved::Cell(Glyph::transparent(), Archetype::Empty)
}
},
"player" => Resolved::Player,
// Any other kind must be an archetype name. Script-backed archetypes (e.g.
// pushers/spinners) load as a plain terrain cell here and are turned into
// their scripted objects afterward by `Board::expand_builtin_archetypes`
// (called from `TryFrom<MapFile>`), so the expansion lives in one place.
other => match Archetype::try_from(other) {
Ok(a) => Resolved::Cell(glyph_with_default(a.default_glyph()), a),
Err(msg) => {
errors.push(LogLine::error(format!(
"palette '{ch}': {msg}; using error block"
)));
Resolved::Cell(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)
}
},
}
}
/// Resolves the grid to a `height × width` matrix of chars from whichever of
/// `content` / `fill` / `sparse` is supplied (in that precedence; none ⇒ all spaces).
///
/// Only an explicit `content` can mismatch the board dimensions — the single hard
/// error. `fill`/`sparse` always produce an exactly-sized grid; a non-single-char
/// `fill`/`ch` or an out-of-bounds `sparse` cell is recorded on `errors` and the
/// offending cell falls back to (or stays) a space.
fn grid_chars(
data: &GridData,
width: usize,
height: usize,
errors: &mut Vec<LogLine>,
) -> Result<Vec<Vec<char>>, String> {
// Reads a one-character string field, recording `context` and returning `None`
// when it is empty or longer than one char.
let single_char = |s: &str, context: String, errors: &mut Vec<LogLine>| {
let mut chs = s.chars();
match (chs.next(), chs.next()) {
(Some(c), None) => Some(c),
_ => {
errors.push(LogLine::error(context));
None
}
}
};
if let Some(content) = &data.content {
// Explicit grid: validate it matches the declared dimensions.
let rows: Vec<&str> = content.lines().collect();
if rows.len() != height {
return Err(format!(
"grid has {} rows but the board is {height} tall",
rows.len()
));
}
let mut grid = Vec::with_capacity(height);
for (i, line) in rows.iter().enumerate() {
let row: Vec<char> = line.chars().collect();
if row.len() != width {
return Err(format!(
"grid row {i} has {} characters but the board is {width} wide",
row.len()
));
}
grid.push(row);
}
return Ok(grid);
}
if let Some(fill) = &data.fill {
// A whole grid of one character.
let ch = single_char(
fill,
format!("grid fill must be a single character (got {fill:?}); using a space"),
errors,
)
.unwrap_or(' ');
return Ok(vec![vec![ch; width]; height]);
}
// `sparse` (or nothing): an all-spaces grid with the listed cells stamped in.
let mut grid = vec![vec![' '; width]; height];
for cell in data.sparse.iter().flatten() {
let Some(ch) = single_char(
&cell.ch,
format!(
"sparse cell ch must be a single character (got {:?}); skipping",
cell.ch
),
errors,
) else {
continue;
};
if cell.x >= width || cell.y >= height {
errors.push(LogLine::error(format!(
"sparse cell ({}, {}) is out of bounds; skipping",
cell.x, cell.y
)));
continue;
}
grid[cell.y][cell.x] = ch;
}
Ok(grid)
}
/// Builds the board's grid cells from its [`GridData`], plus the non-terrain
/// placements it contains (with their `(x, y)`).
///
/// Returns `Err` only on a grid-dimension mismatch (the single hard error);
/// every other problem is recorded on `errors`.
pub(crate) fn build_grid(
data: &GridData,
width: usize,
height: usize,
errors: &mut Vec<LogLine>,
) -> Result<(Vec<GridCell>, Vec<Placement>), String> {
// Resolve each palette entry once. Space is always a transparent empty cell, so
// it is never a palette key — any `" "` entry is ignored.
let resolved: HashMap<char, Resolved> = data
.palette
.iter()
.filter_map(|(key, entry)| {
let ch = key.chars().next().unwrap_or(' ');
(ch != ' ').then(|| (ch, resolve_entry(ch, entry, errors)))
})
.collect();
// Resolve the grid (content / fill / sparse) before walking it.
let grid = grid_chars(data, width, height, errors)?;
// Walk the grid, filling cells and collecting placements.
let mut cells: Vec<GridCell> = Vec::with_capacity(width * height);
let mut placements: Vec<Placement> = Vec::new();
for (y, row) in grid.iter().enumerate() {
for (x, &ch) in row.iter().enumerate() {
// A space is always a transparent empty cell, palette or not.
if ch == ' ' {
cells.push((Glyph::transparent(), Archetype::Empty));
continue;
}
match resolved.get(&ch) {
Some(Resolved::Cell(g, a)) => cells.push((*g, *a)),
Some(Resolved::Object(t)) => {
cells.push((Glyph::transparent(), Archetype::Empty));
placements.push(Placement::Object(t.clone(), x, y));
}
Some(Resolved::Portal(t)) => {
cells.push((Glyph::transparent(), Archetype::Empty));
placements.push(Placement::Portal(t.clone(), x, y));
}
Some(Resolved::Player) => {
cells.push((Glyph::transparent(), Archetype::Empty));
placements.push(Placement::Player(x, y));
}
None => {
errors.push(LogLine::error(format!(
"unknown grid character '{ch}' at ({x}, {y}); using error block"
)));
cells.push((Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock));
}
}
}
}
Ok((cells, placements))
}
+7 -8
View File
@@ -1,23 +1,21 @@
mod action; mod action;
mod archetype; mod builtin;
mod board; mod board;
mod builtin_scripts;
/// The 16 EGA/VGA named colors ([`colors::NAMED_COLORS`]), shared by scripts and the editor. /// The 16 EGA/VGA named colors ([`colors::NAMED_COLORS`]), shared by scripts and the editor.
pub mod colors; pub mod colors;
/// CP437 tile-index → character mapping ([`cp437::tile_to_char`]) for the default font. /// CP437 tile-index → character mapping ([`cp437::tile_to_char`]) for the default font.
pub mod cp437; pub mod cp437;
/// Procedural floor generators ([`floor::FloorGenerator`]). /// Procedural floor generators ([`floor::FloorBiome`]).
pub mod floor; pub mod floor;
/// Lighting & field-of-view for dark boards ([`fov::Lighting`]). /// Lighting & field-of-view for dark boards ([`fov::Lighting`]).
pub mod fov; pub mod fov;
/// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc. /// Core game types: [`board::Board`], [`glyph::Glyph`], [`builtin::Archetype`], etc.
pub mod game; pub mod game;
pub mod glyph; pub mod glyph;
mod layer; /// Serializable representation of a Board
pub mod board_spec;
/// Styled log messages ([`log::LogLine`]) for the in-game message feed. /// Styled log messages ([`log::LogLine`]) for the in-game message feed.
pub mod log; pub mod log;
/// Map file loading and saving (`.toml` format).
pub mod map_file;
mod object_def; mod object_def;
/// Rhai scripting runtime for board objects ([`script::ScriptHost`]). /// Rhai scripting runtime for board objects ([`script::ScriptHost`]).
pub mod script; pub mod script;
@@ -26,7 +24,7 @@ mod utils;
pub mod world; pub mod world;
pub mod player; pub mod player;
pub mod keys; pub mod keys;
pub use archetype::{Archetype, Builtin}; pub use builtin::Builtin;
pub use board::Board; pub use board::Board;
pub use fov::{Lighting, SIGHT_RADIUS}; pub use fov::{Lighting, SIGHT_RADIUS};
pub use utils::Direction; pub use utils::Direction;
@@ -35,3 +33,4 @@ pub use utils::Direction;
mod tests; mod tests;
mod api; mod api;
pub mod tile; pub mod tile;
mod portal;
-721
View File
@@ -1,721 +0,0 @@
//! Per-board map-file load/save and the small serde types that orchestrate it.
//!
//! A board in a `.toml` world file is a `[map]` header plus an ordered array of
//! `[[layers]]` (see [`crate::layer`]). This module owns the [`MapFile`]/
//! [`MapHeader`] serde shells and the conversions to and from a runtime
//! [`Board`]; the per-layer grid/palette work lives in [`crate::layer`].
//!
//! Loading is **best-effort/nonfatal**: only a layer grid-dimension mismatch is a
//! hard error. Every other problem (unknown archetype/char → `ErrorBlock`, missing
//! or duplicate player cell, two solids stacked on a cell, duplicate object/portal
//! names) is recovered and recorded on [`Board::load_errors`].
use color::Rgba8;
use serde::{Deserialize, Serialize};
use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::convert::TryFrom;
use std::path::Path;
use crate::api::queue::ObjQueue;
use crate::archetype::Archetype;
use crate::board::{Board, Decoration};
use crate::builtin_scripts::archetype_from_builtin_tag;
use crate::floor::{Floor, FloorGenerator};
use crate::glyph::Glyph;
use crate::layer::{GridData, PaletteEntry, Placement, build_grid};
use crate::log::LogLine;
use crate::object_def::ObjectDef;
use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable};
/// The serde shell for one board in a `.toml` file: a header, the single grid, and
/// the off-grid trigger/decoration lists.
///
/// On load this is converted into a [`Board`] via [`TryFrom`] and discarded; on
/// save a [`Board`] is converted back via [`From<&Board>`]. See `maps/start.toml`
/// for a complete example of the format.
#[derive(Deserialize, Serialize)]
pub struct MapFile {
/// The `[map]` header: name, dimensions, floor, optional board script.
pub map: MapHeader,
/// The single `[grid]`: palette + char map for all solids and most non-solids.
#[serde(default)]
pub(crate) grid: GridData,
/// Invisible, non-solid, script-only objects (`[[triggers]]`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) triggers: Vec<TriggerSpec>,
/// Non-solid `(glyph, archetype)` cells drawn only where the grid is empty
/// (`[[decorations]]`). Normally absent; used by save files.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) decorations: Vec<DecorationSpec>,
}
/// The `[map]` header section of a board.
#[derive(Deserialize, Serialize)]
pub struct MapHeader {
/// Human-readable name for this board, e.g. `"Opening Room"`.
pub name: String,
/// Width of the board in cells. Must match the grid row length.
pub width: usize,
/// Height of the board in cells. Must match the grid row count.
pub height: usize,
/// The board's optional cosmetic floor. Absent ⇒ blank; see [`FloorSpec`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor: Option<FloorSpec>,
/// Name of the board-level script in the `[scripts]` table, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub board_script_name: Option<String>,
/// When `true`, this is a "dark" board: front-ends reveal only cells within
/// the player's field of view. Absent ⇒ `false` (fully lit); omitted from
/// the saved TOML when `false`. See [`Board::dark`](crate::board::Board::dark).
#[serde(default, skip_serializing_if = "is_false")]
pub dark: bool,
}
/// serde `skip_serializing_if` predicate: omit a `bool` field when it is `false`
/// (so the common non-dark board doesn't emit a `dark = false` line).
fn is_false(b: &bool) -> bool {
!*b
}
/// Serde form of the board floor attribute (`floor = { … }` in `[map]`).
///
/// Resolves (in [`FloorSpec::resolve`]) to a [`Floor`]: a `generator` name gives a
/// biome, otherwise any of `tile`/`fg`/`bg` gives a single fixed glyph, and an
/// empty spec is blank.
#[derive(Deserialize, Serialize, Clone)]
pub struct FloorSpec {
/// Procedural biome name (`"grass"`/`"dirt"`/`"stone"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generator: Option<String>,
/// Fixed-glyph tile index (int or single-char string).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<TileIndex>,
/// Fixed-glyph foreground `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
/// Fixed-glyph background `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
}
impl FloorSpec {
/// Resolves this spec to a [`Floor`] for a `width × height` board, recording a
/// nonfatal error (and falling back to [`Floor::Blank`]) for an unknown generator.
fn resolve(&self, width: usize, height: usize, errors: &mut Vec<LogLine>) -> Floor {
if let Some(name) = &self.generator {
return match FloorGenerator::from_name(name) {
Some(g) => Floor::biome(g, width, height),
None => {
errors.push(LogLine::error(format!(
"floor names unknown generator '{name}'; using blank floor"
)));
Floor::Blank
}
};
}
// A fixed glyph if any visual field is given, else a blank floor.
if self.tile.is_some() || self.fg.is_some() || self.bg.is_some() {
Floor::Fixed(Glyph {
tile: self.tile.map(TileIndex::into_u32).unwrap_or(32),
fg: self.fg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }),
bg: self.bg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }),
})
} else {
Floor::Blank
}
}
}
/// Serde form of one `[[triggers]]` entry: an invisible, non-solid, script-only
/// object at `(x, y)`. Triggers are folded into [`Board::objects`] at load; they
/// are re-emitted here on save (recognised as scripted, non-solid, glyphless).
#[derive(Deserialize, Serialize, Clone)]
pub(crate) struct TriggerSpec {
/// Column (0-indexed).
pub x: usize,
/// Row (0-indexed).
pub y: usize,
/// Name of the Rhai script (in `[scripts]`) this trigger runs.
pub script_name: String,
/// Optional board-unique name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Optional open-ended labels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
/// Serde form of one `[[decorations]]` entry: a non-solid `(glyph, archetype)` at
/// `(x, y)`, drawn only where the grid cell is empty. A solid archetype is rejected.
#[derive(Deserialize, Serialize, Clone)]
pub(crate) struct DecorationSpec {
/// Column (0-indexed).
pub x: usize,
/// Row (0-indexed).
pub y: usize,
/// Archetype name (or `"empty"`); must be non-solid.
pub kind: String,
/// Tile index (int or single-char string).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<TileIndex>,
/// Foreground `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
/// Background `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
}
/// A tile index in a palette entry: either a plain integer or a character literal.
///
/// Accepting both forms lets map files write `tile = 35` or `tile = "#"` (the char
/// is converted to its Unicode scalar) interchangeably.
#[derive(Deserialize, Serialize, Clone, Copy)]
#[serde(untagged)]
pub enum TileIndex {
/// A direct tile index (e.g. `tile = 35`).
Num(u32),
/// A single-character shorthand (e.g. `tile = "#"`); converted to its Unicode scalar.
Chr(char),
}
impl TileIndex {
/// Returns the tile index as a `u32`, converting a char to its scalar value.
pub(crate) fn into_u32(self) -> u32 {
match self {
TileIndex::Num(n) => n,
TileIndex::Chr(c) => c as u32,
}
}
}
/// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`].
/// Returns opaque black on any parse failure.
pub(crate) fn parse_color(hex: &str) -> Rgba8 {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Rgba8 {
r: 0,
g: 0,
b: 0,
a: 255,
};
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
Rgba8 { r, g, b, a: 255 }
}
/// Converts an [`Rgba8`] to an `"#RRGGBB"` hex string (alpha is ignored).
pub(crate) fn color_to_hex(color: Rgba8) -> String {
format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b)
}
/// Converts a parsed map file into a runtime [`Board`].
///
/// Builds the single grid (collecting object/portal/player placements), resolves
/// the floor, then runs the cross-cell validations that span the whole board:
/// - the player must appear exactly once (missing → `(0, 0)`; multiple → the first), and wins its cell;
/// - at most one solid may occupy a cell (a conflicting solid object is dropped);
/// - object names must be board-unique (a duplicate is cleared) and portal names unique (a duplicate is dropped).
///
/// Finally the `[[triggers]]` load as non-solid glyphless objects and the
/// `[[decorations]]` as off-grid non-solid cells.
///
/// Returns `Err` only when the grid dimensions disagree with the header.
impl TryFrom<MapFile> for Board {
type Error = String;
fn try_from(mf: MapFile) -> Result<Self, Self::Error> {
let w = mf.map.width;
let h = mf.map.height;
let mut load_errors: Vec<LogLine> = Vec::new();
// Build the single grid, collecting non-terrain placements.
let (mut grid, placements) = build_grid(&mf.grid, w, h, &mut load_errors)?;
let mut object_specs: Vec<(crate::layer::ObjectTemplate, usize, usize)> = Vec::new();
let mut portal_specs: Vec<(crate::layer::PortalTemplate, usize, usize)> = Vec::new();
let mut player_positions: Vec<(usize, usize)> = Vec::new();
for p in placements {
match p {
Placement::Object(t, x, y) => object_specs.push((t, x, y)),
Placement::Portal(t, x, y) => portal_specs.push((t, x, y)),
Placement::Player(x, y) => player_positions.push((x, y)),
}
}
// Resolve the cosmetic floor attribute (blank / fixed glyph / biome).
let floor = mf
.map
.floor
.as_ref()
.map(|f| f.resolve(w, h, &mut load_errors))
.unwrap_or(Floor::Blank);
// The player must be placed exactly once.
let (px, py) = match player_positions.len() {
1 => player_positions[0],
0 => {
load_errors.push(LogLine::error(
"no player cell (kind = \"player\") found; placing player at (0, 0)",
));
(0, 0)
}
n => {
load_errors.push(LogLine::error(format!(
"player cell appears {n} times; using the first"
)));
player_positions[0]
}
};
let pidx = py * w + px;
// Track which cells hold a solid (the grid's own solids seed the map).
let mut solid_occupied = vec![false; w * h];
for (idx, cell) in grid.iter().enumerate() {
if cell.1.behavior().solid {
solid_occupied[idx] = true;
}
}
// The player wins its cell: clear any solid terrain under it and claim the
// cell so a solid object placed here is dropped below.
if grid[pidx].1.behavior().solid {
grid[pidx] = (Glyph::transparent(), Archetype::Empty);
}
solid_occupied[pidx] = true;
// Spawn objects in reading order, so ids are deterministic and "lowest id
// wins a collision" / "first claimant keeps the name" hold.
let mut objects: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
let mut next_object_id: ObjectId = 1;
let mut seen_names: HashMap<String, ObjectId> = HashMap::new();
for (t, x, y) in object_specs {
let idx = y * w + x;
// A solid object may not share a cell with another solid.
if t.solid && solid_occupied[idx] {
// The player silently wins its cell; any other conflict is reported.
if idx != pidx {
load_errors.push(LogLine::error(format!(
"solid object at ({x}, {y}) conflicts with an existing solid; skipping object"
)));
}
continue;
}
let id = next_object_id;
// Name uniqueness: first claimant keeps it; later duplicates are cleared.
let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors));
if t.solid {
solid_occupied[idx] = true;
}
objects.insert(
id,
ObjectDef {
id,
x,
y,
glyph: t.glyph,
behavior: Behavior {
solid: t.solid,
opaque: t.opaque,
pushable: t.pushable,
glow: t.light,
// Hand-placed objects are never grab targets; only expanded
// grab archetypes (e.g. gems) set this (see expand_builtin_archetypes).
// TODO this is wrong, obviously we want grabbable objects
grab: false,
},
script_name: t.script_name,
// Script-backed archetypes are expanded after the board is built
// (see `expand_builtin_archetypes` below), not via templates.
builtin_script: None,
tags: t.tags.into_iter().collect(),
queue: ObjQueue::new(),
name,
},
);
next_object_id += 1;
}
// Triggers: invisible, non-solid, script-only objects. They join the same
// `objects` map (so ids/name-uniqueness/script dispatch all apply), after the
// hand-placed grid objects.
for t in mf.triggers {
if !t.x.lt(&w) || !t.y.lt(&h) {
load_errors.push(LogLine::error(format!(
"trigger at ({}, {}) is out of bounds; skipping",
t.x, t.y
)));
continue;
}
let id = next_object_id;
let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors));
objects.insert(
id,
ObjectDef {
id,
x: t.x,
y: t.y,
glyph: Glyph::transparent(),
behavior: Behavior {
solid: false,
opaque: false,
pushable: Pushable::No,
glow: 0,
grab: false,
},
script_name: Some(t.script_name),
builtin_script: None,
tags: t.tags.unwrap_or_default().into_iter().collect(),
queue: ObjQueue::new(),
name,
},
);
next_object_id += 1;
}
// Build the portal list, dropping duplicate names (first claimant wins).
let mut seen_portal_names: HashSet<String> = HashSet::new();
let mut portals: Vec<PortalDef> = Vec::new();
for (t, x, y) in portal_specs {
if !seen_portal_names.insert(t.name.clone()) {
load_errors.push(LogLine::error(format!(
"portal name {:?} already used by another portal; skipping portal",
t.name
)));
continue;
}
portals.push(PortalDef {
name: t.name,
x,
y,
target_map: t.target_map,
target_entry: t.target_entry,
});
}
// Decorations: non-solid off-grid cells (a solid archetype is rejected).
let mut decorations: Vec<Decoration> = Vec::new();
for d in mf.decorations {
match resolve_decoration(&d) {
Ok(dec) => decorations.push(dec),
Err(msg) => load_errors.push(LogLine::error(msg)),
}
}
let mut board = Board {
name: mf.map.name,
width: w,
height: h,
grid,
floor,
decorations,
sensors: Vec::new(),
player: PlayerPos {
x: px as i64,
y: py as i64,
},
objects,
next_object_id,
portals,
board_script_name: mf.map.board_script_name,
dark: mf.map.dark,
load_errors,
registry: HashMap::new(),
};
// Turn script-backed archetype cells (pushers/spinners) into their scripted
// objects. Runs after the cross-cell validation above, so board invariants
// hold; the same call also fixes editor-placed machines before a playtest.
board.expand_builtin_archetypes();
Ok(board)
}
}
/// Claims `name` for object `id` in `seen_names`, returning `Some(name)` for the
/// first claimant and `None` (with a logged error) for any later duplicate.
fn claim_name(
n: String,
id: ObjectId,
seen_names: &mut HashMap<String, ObjectId>,
errors: &mut Vec<LogLine>,
) -> Option<String> {
match seen_names.entry(n.clone()) {
Entry::Vacant(v) => {
v.insert(id);
Some(n)
}
Entry::Occupied(o) => {
errors.push(LogLine::error(format!(
"object name {n:?} already used by object {}; clearing name",
o.get()
)));
None
}
}
}
/// Resolves a [`DecorationSpec`] into a [`Decoration`], erroring if its archetype is
/// unknown or solid (decorations must be non-solid).
fn resolve_decoration(d: &DecorationSpec) -> Result<Decoration, String> {
let arch = if d.kind == "empty" {
Archetype::Empty
} else {
Archetype::try_from(d.kind.as_str())
.map_err(|msg| format!("decoration at ({}, {}): {msg}; skipping", d.x, d.y))?
};
if arch.behavior().solid {
return Err(format!(
"decoration at ({}, {}) has solid archetype {:?}; skipping",
d.x, d.y, d.kind
));
}
let default = arch.default_glyph();
Ok(Decoration {
x: d.x,
y: d.y,
glyph: Glyph {
tile: d.tile.map(TileIndex::into_u32).unwrap_or(default.tile),
fg: d.fg.as_deref().map(parse_color).unwrap_or(default.fg),
bg: d.bg.as_deref().map(parse_color).unwrap_or(default.bg),
},
archetype: arch,
})
}
/// Pool of palette characters for save: printable ASCII (plus a leading space for
/// the common transparent-empty cell), excluding `"` and `\` which would need
/// escaping inside a TOML string.
fn char_pool() -> Vec<char> {
let mut pool = vec![' '];
pool.extend(
(33u8..=126u8)
.filter(|&b| b != b'"' && b != b'\\')
.map(|b| b as char),
);
pool
}
/// Builds the [`PaletteEntry`] for a grid terrain cell `(glyph, arch)`.
///
/// A grid `Empty` cell is always transparent now (floors are a board attribute),
/// so it maps to `kind = "empty"`; anything else is its archetype keyword.
fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
if arch == Archetype::Empty {
PaletteEntry {
kind: "empty".into(),
..Default::default()
}
} else {
PaletteEntry {
kind: arch.name().into(),
tile: Some(TileIndex::Num(glyph.tile)),
fg: Some(color_to_hex(glyph.fg)),
bg: Some(color_to_hex(glyph.bg)),
..Default::default()
}
}
}
/// Whether an object is a **trigger** — an invisible, non-solid, script-only object
/// authored/serialized in `[[triggers]]` rather than the grid palette.
fn is_trigger(o: &ObjectDef) -> bool {
!o.behavior.solid && o.glyph.tile == 0 && o.script_name.is_some() && o.builtin_script.is_none()
}
/// Serializes `board`'s single grid (terrain + non-trigger objects + portals +
/// player) into a [`GridData`].
fn grid_to_data(board: &Board) -> GridData {
let (w, h) = (board.width, board.height);
let mut pool = char_pool().into_iter();
let mut palette: HashMap<String, PaletteEntry> = HashMap::new();
let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new();
let mut grid: Vec<Vec<char>> = vec![vec![' '; w]; h];
// Terrain cells: dedup each unique (glyph, archetype) to one palette char.
for (y, row) in grid.iter_mut().enumerate() {
for (x, slot) in row.iter_mut().enumerate() {
let (glyph, arch) = *board.get(x, y);
*slot = *cell_to_key.entry((glyph, arch)).or_insert_with(|| {
let ch = pool.next().expect("ran out of palette characters");
palette.insert(ch.to_string(), cell_entry(glyph, arch));
ch
});
}
}
// Objects overwrite their (transparent) grid cell with an object char. Triggers
// are written to `[[triggers]]` instead (see `From<&Board>`), so skip them here.
for o in board.objects.values().filter(|o| !is_trigger(o)) {
// A built-in archetype object (e.g. a pusher) round-trips back to its
// archetype keyword: emit it as a terrain cell (deduped with real terrain),
// using the object's current glyph, rather than a `kind = "object"` entry.
if let Some(arch) = o.tags.iter().find_map(|t| archetype_from_builtin_tag(t)) {
let ch = *cell_to_key.entry((o.glyph, arch)).or_insert_with(|| {
let ch = pool.next().expect("ran out of palette characters");
palette.insert(ch.to_string(), cell_entry(o.glyph, arch));
ch
});
grid[o.y][o.x] = ch;
continue;
}
let ch = pool.next().expect("ran out of palette characters");
let mut tags: Vec<String> = o.tags.iter().cloned().collect();
tags.sort();
palette.insert(
ch.to_string(),
PaletteEntry {
kind: "object".into(),
tile: Some(TileIndex::Num(o.glyph.tile)),
fg: Some(color_to_hex(o.glyph.fg)),
bg: Some(color_to_hex(o.glyph.bg)),
solid: Some(o.behavior.solid),
opaque: Some(o.behavior.opaque),
pushable: Some(o.behavior.pushable),
light: (o.behavior.glow > 0).then_some(o.behavior.glow),
script_name: o.script_name.clone(),
tags: (!tags.is_empty()).then_some(tags),
name: o.name.clone(),
..Default::default()
},
);
grid[o.y][o.x] = ch;
}
// Portals.
for p in board.portals.iter() {
let ch = pool.next().expect("ran out of palette characters");
palette.insert(
ch.to_string(),
PaletteEntry {
kind: "portal".into(),
name: Some(p.name.clone()),
target_map: Some(p.target_map.clone()),
target_entry: Some(p.target_entry.clone()),
..Default::default()
},
);
grid[p.y][p.x] = ch;
}
// The player.
{
let ch = pool.next().expect("ran out of palette characters");
palette.insert(
ch.to_string(),
PaletteEntry {
kind: "player".into(),
..Default::default()
},
);
grid[board.player.y as usize][board.player.x as usize] = ch;
}
let content = grid
.into_iter()
.map(|row| row.into_iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
+ "\n";
// Save always emits an explicit grid; `fill`/`sparse` are load-time conveniences.
GridData {
content: Some(content),
fill: None,
sparse: None,
palette,
}
}
/// Builds the [`FloorSpec`] for `board`'s floor, or `None` for a blank floor. A
/// biome re-emits its generator name (so the procedural floor round-trips); a fixed
/// glyph re-emits its tile/fg/bg.
fn floor_to_spec(floor: &Floor) -> Option<FloorSpec> {
match floor {
Floor::Blank => None,
Floor::Fixed(g) => Some(FloorSpec {
generator: None,
tile: Some(TileIndex::Num(g.tile)),
fg: Some(color_to_hex(g.fg)),
bg: Some(color_to_hex(g.bg)),
}),
Floor::Biome { generator, .. } => Some(FloorSpec {
generator: Some(generator.name().into()),
tile: None,
fg: None,
bg: None,
}),
}
}
/// Converts a runtime [`Board`] back into a serializable [`MapFile`].
///
/// Emits the single `[grid]`, the `[[triggers]]` (invisible script objects) and
/// `[[decorations]]` lists, and the `floor` attribute (a biome re-emits its
/// generator name, so procedural floors round-trip).
impl From<&Board> for MapFile {
fn from(board: &Board) -> Self {
let grid = grid_to_data(board);
// Trigger objects → `[[triggers]]`.
let triggers = board
.objects
.values()
.filter(|o| is_trigger(o))
.map(|o| {
let mut tags: Vec<String> = o.tags.iter().cloned().collect();
tags.sort();
TriggerSpec {
x: o.x,
y: o.y,
script_name: o.script_name.clone().unwrap_or_default(),
name: o.name.clone(),
tags: (!tags.is_empty()).then_some(tags),
}
})
.collect();
// Decorations → `[[decorations]]`.
let decorations = board
.decorations
.iter()
.map(|d| DecorationSpec {
x: d.x,
y: d.y,
kind: d.archetype.name().into(),
tile: Some(TileIndex::Num(d.glyph.tile)),
fg: Some(color_to_hex(d.glyph.fg)),
bg: Some(color_to_hex(d.glyph.bg)),
})
.collect();
MapFile {
map: MapHeader {
name: board.name.clone(),
width: board.width,
height: board.height,
floor: floor_to_spec(&board.floor),
board_script_name: board.board_script_name.clone(),
dark: board.dark,
},
grid,
triggers,
decorations,
}
}
}
/// Loads a map file from disk and returns a ready-to-use [`Board`].
///
/// Reads the file at `path`, deserializes it as a single-board [`MapFile`], then
/// converts it via [`TryFrom`]. Production code uses [`crate::world::load`] for
/// multi-board world files instead.
pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let map_file: MapFile = toml::from_str(&content)?;
Board::try_from(map_file).map_err(|e| e.into())
}
/// Serializes a [`Board`] to a `.toml` map file at `path`.
pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let map_file = MapFile::from(board);
let toml_str = toml::to_string_pretty(&map_file)?;
std::fs::write(path, toml_str)?;
Ok(())
}
+22 -59
View File
@@ -1,8 +1,7 @@
use std::fmt::{Debug, Formatter};
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::utils::{Behavior, ObjectId, Pushable};
use color::Rgba8; use color::Rgba8;
use std::collections::HashSet; use crate::tile::{EnterResponse, Optics, ScriptAttributes};
use crate::api::queue::ObjQueue;
/// A scripted object placed on the board, loaded from a map file. /// A scripted object placed on the board, loaded from a map file.
/// ///
@@ -25,43 +24,16 @@ use crate::api::queue::ObjQueue;
/// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work. /// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work.
#[derive(Clone)] #[derive(Clone)]
pub struct ObjectDef { pub struct ObjectDef {
/// Stable identity assigned by [`crate::board::Board::add_object`]. /// How this object affects movement
/// `0` is the sentinel meaning "not yet inserted into a board". pub enter_response: EnterResponse,
/// Real ids start at 1 and never change after assignment. /// Everything we need to run our script
/// Not serialized — the id is stamped on insert, not stored in map files. pub scripting: ScriptAttributes,
pub id: ObjectId, }
/// Column of this object on the board (0-indexed).
pub x: usize, impl Debug for ObjectDef {
/// Row of this object on the board (0-indexed). fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
pub y: usize, write!(f, "ObjectDef({:?}, {:?}, {:?})", self.scripting.id, self.scripting.glyph, self.scripting.script_name)
/// Visual representation of this object. Owned by the object (not derived }
/// from the grid cell), so scripts can change tile, fg, and bg at runtime.
pub glyph: Glyph,
/// All the normal `Behavior` things about this object
pub behavior: Behavior,
/// 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
/// expanded (see [`builtin_script`](ObjectDef::builtin_script)). `None` means
/// this object has no script yet.
pub script_name: Option<String>,
/// Embedded built-in script source, set when a script-backed archetype (e.g. a
/// `pusher_*` or `gem`) is expanded into an object at load time (see
/// [`crate::builtin_scripts`]). When set, this is the object's script *source*
/// (its compile-key is the synthetic `BUILTIN_*` [`script_name`](ObjectDef::script_name)
/// the same expansion assigns). Not part of the map file — it is regenerated
/// from the archetype on load.
pub builtin_script: Option<&'static str>,
/// Open-ended string labels for this object. Serialized as a TOML array;
/// not subject to any rate limit — mutations take effect immediately after
/// the frame's action queue is drained.
pub tags: HashSet<String>,
/// Optional unique human-readable name for this object. `None` if unnamed.
/// Names are validated for uniqueness at map-load time; a duplicate name is
/// cleared to `None` (the object survives but becomes anonymous).
pub name: Option<String>,
/// The output queue of actions for this object
pub queue: ObjQueue,
} }
impl ObjectDef { impl ObjectDef {
@@ -75,29 +47,20 @@ impl ObjectDef {
} }
} }
/// Creates a new object at `(x, y)` with default glyph and blocking behavior. /// Creates a new object with default glyph and blocking behavior.
/// ///
/// Defaults: `solid = true`, `opaque = true`, `pushable = false`, no script. /// Defaults: block on enter, opaque no glow, no script
/// These match the serde defaults in the map file format so new objects /// These match the serde defaults in the map file format so new objects
/// round-trip correctly. /// round-trip correctly.
pub fn new(x: usize, y: usize) -> Self { pub fn new() -> Self {
Self { Self {
id: 0, enter_response: EnterResponse::Block,
x, scripting: ScriptAttributes {
y, id: 0,
glyph: Self::default_glyph(), glyph: Self::default_glyph(),
behavior: Behavior { optics: Optics { opaque: true, glow: 0 },
solid: true, ..Default::default()
opaque: true, }
pushable: Pushable::No,
grab: false,
glow: 0,
},
script_name: None,
builtin_script: None,
tags: HashSet::new(),
queue: ObjQueue::new(),
name: None,
} }
} }
} }
+36
View File
@@ -0,0 +1,36 @@
use serde::{Deserialize, Serialize};
use crate::glyph::Glyph;
/// A portal that teleports the player to a named entry point on another board.
///
/// Portals are loaded from `[[portals]]` entries in `.toml` map files and
/// stored on [`Board`]. When the player steps onto a portal's cell, the engine
/// calls [`crate::game::GameState::enter_board`] with the `target_map` and
/// `target_entry`, placing the player at the matching named portal on the
/// destination board.
///
/// In map files, portals are conventionally placed using digit characters
/// (`'1'``'9'`) as palette keys — parallel to the uppercase-letter convention
/// for objects. A portal's `name` is board-unique: it is also used as the
/// `target_entry` value on the other end of the connection.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub struct Portal {
pub x: usize,
pub y: usize,
/// Board-unique name for this portal, also used as `target_entry` by portals
/// on other boards that want to arrive here.
pub name: String,
/// Key of the target board in `World::boards`.
pub target_board: String,
/// Name of the arrival portal on the target board.
pub target_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
glyph: Option<Glyph>
}
impl Portal {
pub fn location(&self) -> (usize, usize) {
(self.x, self.y)
}
}
+28 -39
View File
@@ -36,7 +36,6 @@
use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST}; use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST};
use crate::game::SAY_DURATION; use crate::game::SAY_DURATION;
use crate::log::LogLine; use crate::log::LogLine;
use crate::map_file::parse_color;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use crate::utils::{Direction, LogSink, Hook, ObjectId}; use crate::utils::{Direction, LogSink, Hook, ObjectId};
use rhai::{ use rhai::{
@@ -49,6 +48,7 @@ use crate::api::object_info::ObjectInfo;
use crate::api::player::PlayerWithPos; use crate::api::player::PlayerWithPos;
use crate::api::queue::ObjQueue; use crate::api::queue::ObjQueue;
use crate::api::registry::Registry; use crate::api::registry::Registry;
use crate::colors::parse_color;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::keys::Keyring; use crate::keys::Keyring;
use crate::player::PlayerRef; use crate::player::PlayerRef;
@@ -81,16 +81,6 @@ impl CompiledScript {
} }
} }
/// The compile-key for an object's script: the object's `script_name` — a
/// world-pool name for a named script, or a synthetic `BUILTIN_*` name set by
/// [`Board::expand_builtin_archetypes`](crate::board::Board::expand_builtin_archetypes)
/// for an expanded built-in (so identical built-ins share one compiled AST,
/// while the source still comes from `builtin_script`). `None` if the object has
/// no script.
fn script_key(obj: &ObjectDef) -> Option<String> {
obj.script_name.clone()
}
// ── ScriptHost ──────────────────────────────────────────────────────────────── // ── ScriptHost ────────────────────────────────────────────────────────────────
/// Owns the Rhai engine and per-object script state for a board. /// Owns the Rhai engine and per-object script state for a board.
@@ -135,18 +125,19 @@ impl ScriptHost {
// source for a built-in still comes from its embedded `builtin_script`. // source for a built-in still comes from its embedded `builtin_script`.
let mut scripts: HashMap<String, CompiledScript> = HashMap::new(); let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
let mut failed: HashSet<String> = HashSet::new(); let mut failed: HashSet<String> = HashSet::new();
for obj in board.objects.values() { let all_scriptables = board.sorted_hookables();
let Some(key) = script_key(obj) else { for obj in all_scriptables.iter() {
let Some(key) = obj.script_key() else {
continue; continue;
}; };
if scripts.contains_key(&key) || failed.contains(&key) { if scripts.contains_key(key) || failed.contains(key) {
continue; continue;
} }
// Source: the embedded built-in, or a lookup in the world script pool. // Source: the embedded built-in, or a lookup in the world script pool.
let source: &str = if let Some(src) = obj.builtin_script { let source: &str = if let Some(src) = obj.scriptable().builtin_script {
src src
} else { } else {
match script_sources.get(&key) { match script_sources.get(key) {
Some(src) => src, Some(src) => src,
None => { None => {
failed.insert(key.clone()); failed.insert(key.clone());
@@ -162,7 +153,7 @@ impl ScriptHost {
.any(|f| f.name == n && f.params.len() == params) .any(|f| f.name == n && f.params.len() == params)
}; };
scripts.insert( scripts.insert(
key, key.clone(),
CompiledScript { CompiledScript {
has_init: defines("init", 1), has_init: defines("init", 1),
has_tick: defines("tick", 2), has_tick: defines("tick", 2),
@@ -181,16 +172,13 @@ impl ScriptHost {
} }
// One runtime per object whose script compiled. // One runtime per object whose script compiled.
for (&id, obj) in board.objects.iter() { for obj in all_scriptables.iter() {
let Some(key) = script_key(obj) else { if let Some(key) = obj.script_key() && scripts.contains_key(key) {
continue; scopes.insert(obj.id(), Scope::new());
};
if !scripts.contains_key(&key) {
continue;
} }
scopes.insert(id, Scope::new());
} }
drop(all_scriptables);
drop(board); drop(board);
Self { Self {
engine, engine,
@@ -299,9 +287,12 @@ impl ScriptHost {
/// - If it has arity 0, we pass nothing /// - If it has arity 0, we pass nothing
/// ///
/// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT`. /// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT`.
/// Returns the actions the call drained. /// Note: does not drain the queue! If the send triggers an action it gets drained in the
pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) -> Vec<BoardAction> { /// next tick (or next hook, anyway). This is because of possible deadlocks: if we drained
let mut actions = Vec::new(); /// the queue we would need to run the actions immediately, and if one of those actions caused
/// the same send, we could fail to terminate. The only way to cut that loop would be to drop
/// a send action we've already seen this tick.
pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) {
if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) { if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) {
if let Some(script_key) = info.script_name.as_ref() if let Some(script_key) = info.script_name.as_ref()
&& let Some(script) = self.scripts.get(script_key) && let Some(script) = self.scripts.get(script_key)
@@ -319,7 +310,7 @@ impl ScriptHost {
// If it's not there at all, just bail: // If it's not there at all, just bail:
if arities.is_empty() { if arities.is_empty() {
self.log_sink.error(format!("script '{}' send({}) error: function not found", script_key, fn_name)); self.log_sink.error(format!("script '{}' send({}) error: function not found", script_key, fn_name));
return actions; return;
} }
// Assemble the args // Assemble the args
@@ -342,12 +333,10 @@ impl ScriptHost {
) { ) {
self.log_sink.error(format!("script '{}' send({}) error: {err}", script_key, fn_name)); self.log_sink.error(format!("script '{}' send({}) error: {err}", script_key, fn_name));
} }
info.drain(&mut actions, 0.0)
} }
} else { } else {
unreachable!("Object id not found, tried to send"); unreachable!("Object id not found, tried to send");
} }
actions
} }
/// Removes and returns the log lines (script `log()` output and errors) /// Removes and returns the log lines (script `log()` output and errors)
@@ -390,26 +379,26 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
let b = board.clone(); let b = board.clone();
engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| {
let src = source_of(&ctx); let src = source_of(&ctx);
if let Some(def) = b.borrow_mut().objects.get_mut(&src) { if let Some(scr) = b.borrow_mut().scripting_mut(src) {
def.queue.act(Action::Move(dir)); scr.queue.act(Action::Move(dir));
def.queue.delay(MOVE_COST); scr.queue.delay(MOVE_COST);
} }
}); });
let b = board.clone(); let b = board.clone();
engine.register_fn("delay", move |ctx: NativeCallContext, dt: Dynamic| { engine.register_fn("delay", move |ctx: NativeCallContext, dt: Dynamic| {
let src = source_of(&ctx); let src = source_of(&ctx);
if let Some(def) = b.borrow_mut().objects.get_mut(&src) if let Some(scr) = b.borrow_mut().scripting_mut(src)
&& let Ok(dt) = dt.as_float() { && let Ok(dt) = dt.as_float() {
def.queue.delay(dt); scr.queue.delay(dt);
} }
}); });
let b = board.clone(); let b = board.clone();
engine.register_fn("now", move |ctx: NativeCallContext| { engine.register_fn("now", move |ctx: NativeCallContext| {
let src = source_of(&ctx); let src = source_of(&ctx);
if let Some(def) = b.borrow_mut().objects.get_mut(&src) { if let Some(scr) = b.borrow_mut().scripting_mut(src) {
def.queue.now() scr.queue.now()
} }
}); });
@@ -675,8 +664,8 @@ fn register_global_constants(engine: &mut Engine, board: BoardRef, player: Playe
/// Appends `action` to the output queue of the object identified by `source`. /// Appends `action` to the output queue of the object identified by `source`.
fn emit(board: &BoardRef, source: ObjectId, action: Action) { fn emit(board: &BoardRef, source: ObjectId, action: Action) {
if let Some(def) = board.borrow_mut().objects.get_mut(&source) { if let Some(scripting) = board.borrow_mut().scripting_mut(source) {
def.queue.act(action); scripting.queue.act(action);
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in script for the `transporter_*` archetypes (see archetype.rs). // Built-in script for the `transporter_*` archetypes (see builtin).
// //
// A transporter is a solid, see-through, unpushable machine that teleports // A transporter is a solid, see-through, unpushable machine that teleports
// whatever bumps into it from its facing side. It animates through a 4-frame // whatever bumps into it from its facing side. It animates through a 4-frame
+1 -1
View File
@@ -1,5 +1,5 @@
use super::{log_texts, scripted_object, scripts_from}; use super::{log_texts, scripted_object, scripts_from};
use crate::archetype::Archetype; use crate::builtin::Archetype;
use crate::board::tests::{crate_at, open_board, wall_at}; use crate::board::tests::{crate_at, open_board, wall_at};
use crate::game::GameState; use crate::game::GameState;
use std::time::Duration; use std::time::Duration;
+10 -9
View File
@@ -1,16 +1,17 @@
use crate::archetype::Archetype; use crate::builtin::Archetype;
use crate::board::Board; use crate::board::Board;
use crate::floor::Floor; use crate::floor::Floor;
use crate::game::GameState; use crate::game::GameState;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::utils::{Direction, PlayerPos, PortalDef}; use crate::utils::{Direction, PlayerPos};
use crate::world::World; use crate::world::World;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::rc::Rc; use std::rc::Rc;
use crate::portal::Portal;
/// Builds a 3×3 board with the player at `(px, py)` and the given portals. /// Builds a 3×3 board with the player at `(px, py)` and the given portals.
fn make_board(px: i64, py: i64, portals: Vec<PortalDef>) -> Board { fn make_board(px: i64, py: i64, portals: Vec<Portal>) -> Board {
Board { Board {
name: "test".into(), name: "test".into(),
width: 3, width: 3,
@@ -38,23 +39,23 @@ fn two_board_world() -> World {
let b1 = make_board( let b1 = make_board(
0, 0,
0, 0,
vec![PortalDef { vec![Portal {
name: "to_b2".into(), name: "to_b2".into(),
x: 2, x: 2,
y: 0, y: 0,
target_map: "b2".into(), target_board: "b2".into(),
target_entry: "from_b1".into(), target_name: "from_b1".into(),
}], }],
); );
let b2 = make_board( let b2 = make_board(
0, 0,
0, 0,
vec![PortalDef { vec![Portal {
name: "from_b1".into(), name: "from_b1".into(),
x: 1, x: 1,
y: 1, y: 1,
target_map: "b1".into(), target_board: "b1".into(),
target_entry: "to_b2".into(), target_name: "to_b2".into(),
}], }],
); );
World { World {
+2 -2
View File
@@ -1,5 +1,5 @@
use super::load_board; use super::load_board;
use crate::archetype::Archetype; use crate::builtin::Archetype;
#[test] #[test]
fn fill_builds_a_full_grid_of_one_char() { fn fill_builds_a_full_grid_of_one_char() {
@@ -23,7 +23,7 @@ palette = { "#" = { kind = "wall", tile = 35, fg = "#808080", bg = "#606060" } }
assert!(board.solid_at(x, y).unwrap().player()); assert!(board.solid_at(x, y).unwrap().player());
} else { } else {
let obj = &board.objects[&board.object_ids_at(x, y)[0]]; let obj = &board.objects[&board.object_ids_at(x, y)[0]];
let tag = obj.tags.iter().next().unwrap(); let tag = obj.scripting.tags.iter().next().unwrap();
assert_eq!(tag, "BUILTIN_wall") assert_eq!(tag, "BUILTIN_wall")
} }
+1 -1
View File
@@ -1,5 +1,5 @@
use super::{grid, load_board, map}; use super::{grid, load_board, map};
use crate::archetype::Archetype; use crate::builtin::Archetype;
use crate::board::Board; use crate::board::Board;
use crate::map_file::MapFile; use crate::map_file::MapFile;
@@ -1,5 +1,5 @@
use super::{grid, load_board, map, map_3x1_object}; use super::{grid, load_board, map, map_3x1_object};
use crate::archetype::Archetype; use crate::builtin::Archetype;
/// Palette shorthand. /// Palette shorthand.
const PLAYER: (&str, &str) = ("@", "kind = \"player\""); const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
@@ -30,8 +30,8 @@ fn duplicate_name_clears_second_but_keeps_both_objects() {
), ),
)); ));
assert_eq!(board.objects.len(), 2, "both objects survive"); assert_eq!(board.objects.len(), 2, "both objects survive");
assert_eq!(board.objects[&1].name.as_deref(), Some("gate")); assert_eq!(board.objects[&1].scripting.name.as_deref(), Some("gate"));
assert_eq!(board.objects[&2].name, None); assert_eq!(board.objects[&2].scripting.name, None);
assert!(!board.is_valid(), "duplicate name is a nonfatal load error"); assert!(!board.is_valid(), "duplicate name is a nonfatal load error");
} }
@@ -66,9 +66,9 @@ fn palette_char_multi_occurrence_only_first_keeps_name() {
), ),
)); ));
assert_eq!(board.objects.len(), 3); assert_eq!(board.objects.len(), 3);
assert_eq!(board.objects[&1].name.as_deref(), Some("guard")); assert_eq!(board.objects[&1].scripting.name.as_deref(), Some("guard"));
assert_eq!(board.objects[&2].name, None); assert_eq!(board.objects[&2].scripting.name, None);
assert_eq!(board.objects[&3].name, None); assert_eq!(board.objects[&3].scripting.name, None);
} }
#[test] #[test]
@@ -1,5 +1,5 @@
use super::{grid, load_board, map}; use super::{grid, load_board, map};
use crate::archetype::Archetype; use crate::builtin::Archetype;
/// Palette shorthands shared by these tests. /// Palette shorthands shared by these tests.
const EMPTY: (&str, &str) = (".", "kind = \"empty\""); const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
+7 -7
View File
@@ -2,19 +2,19 @@
//! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_<dir>` tag). //! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_<dir>` tag).
use super::{grid, load_board, map}; use super::{grid, load_board, map};
use crate::archetype::Archetype; use crate::builtin::Archetype;
use crate::game::GameState; use crate::game::GameState;
use crate::map_file::MapFile; use crate::map_file::MapFile;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use std::time::Duration; use std::time::Duration;
use crate::{Board, Builtin}; use crate::Board;
/// Finds the pusher object on a board (by its built-in tag). /// Finds the pusher object on a board (by its built-in tag).
fn pusher<'a>(board: &'a crate::board::Board, id: &mut u32) -> &'a ObjectDef { fn pusher<'a>(board: &'a crate::board::Board, id: &mut u32) -> &'a ObjectDef {
let (&oid, obj) = board let (&oid, obj) = board
.objects .objects
.iter() .iter()
.find(|(_, o)| o.tags.contains("BUILTIN_pusher_east")) .find(|(_, o)| o.scripting.tags.contains("BUILTIN_pusher_east"))
.expect("pusher object"); .expect("pusher object");
*id = oid; *id = oid;
obj obj
@@ -34,17 +34,17 @@ fn pusher_loads_as_a_tagged_scripted_solid_object() {
let p = pusher(&board, &mut id); let p = pusher(&board, &mut id);
assert_eq!((p.x, p.y), (0, 0)); assert_eq!((p.x, p.y), (0, 0));
assert!( assert!(
p.builtin_script.is_some(), p.scripting.builtin_script.is_some(),
"carries the embedded pusher script" "carries the embedded pusher script"
); );
// Each alias gets its own compile-cache key (e.g. "BUILTIN_pusher_east") so the // Each alias gets its own compile-cache key (e.g. "BUILTIN_pusher_east") so the
// script can read direction from Me.has_tag("BUILTIN_pusher_east"). // script can read direction from Me.has_tag("BUILTIN_pusher_east").
assert_eq!(p.script_name.as_deref(), Some("BUILTIN_pusher_east")); assert_eq!(p.scripting.script_name.as_deref(), Some("BUILTIN_pusher_east"));
assert!(!board.is_passable(0, 0), "pusher is solid"); assert!(!board.is_passable(0, 0), "pusher is solid");
} }
fn is_tag(board: &Board, x: usize, y: usize, tag: &str) -> bool { fn is_tag(board: &Board, x: usize, y: usize, tag: &str) -> bool {
board.object_ids_at(x, y).iter().any(|id| board.objects[id].tags.contains(tag)) board.object_ids_at(x, y).iter().any(|id| board.objects[id].scripting.tags.contains(tag))
} }
#[test] #[test]
@@ -135,5 +135,5 @@ fn pusher_round_trips_to_its_keyword() {
let mut id = 0; let mut id = 0;
let p = pusher(&board2, &mut id); let p = pusher(&board2, &mut id);
assert_eq!((p.x, p.y), (0, 0)); assert_eq!((p.x, p.y), (0, 0));
assert!(p.builtin_script.is_some()); assert!(p.scripting.builtin_script.is_some());
} }
+6 -6
View File
@@ -60,8 +60,8 @@ fn object_tags_round_trip_through_toml() {
); );
let board = load_board(&toml); let board = load_board(&toml);
let obj0 = &board.objects[&1]; let obj0 = &board.objects[&1];
assert!(obj0.tags.contains("enemy") && obj0.tags.contains("boss")); assert!(obj0.scripting.tags.contains("enemy") && obj0.scripting.tags.contains("boss"));
assert_eq!(obj0.tags.len(), 2); assert_eq!(obj0.scripting.tags.len(), 2);
// Saved tags must be sorted alphabetically (boss before enemy). // Saved tags must be sorted alphabetically (boss before enemy).
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
@@ -70,7 +70,7 @@ fn object_tags_round_trip_through_toml() {
assert!(boss < enemy, "tags must be sorted: boss before enemy"); assert!(boss < enemy, "tags must be sorted: boss before enemy");
let board2 = load_board(&toml_out); let board2 = load_board(&toml_out);
assert_eq!(board2.objects[&1].tags, obj0.tags); assert_eq!(board2.objects[&1].scripting.tags, obj0.scripting.tags);
} }
#[test] #[test]
@@ -95,7 +95,7 @@ fn object_name_round_trips_through_toml() {
), ),
); );
let board = load_board(&toml); let board = load_board(&toml);
assert_eq!(board.objects[&1].name.as_deref(), Some("beacon")); assert_eq!(board.objects[&1].scripting.name.as_deref(), Some("beacon"));
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
assert!( assert!(
@@ -103,7 +103,7 @@ fn object_name_round_trips_through_toml() {
"name must appear in saved TOML" "name must appear in saved TOML"
); );
let board2 = load_board(&toml_out); let board2 = load_board(&toml_out);
assert_eq!(board2.objects[&1].name.as_deref(), Some("beacon")); assert_eq!(board2.objects[&1].scripting.name.as_deref(), Some("beacon"));
} }
#[test] #[test]
@@ -111,7 +111,7 @@ fn unnamed_object_name_stays_none_through_toml() {
let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY])); let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY]));
let board2 = round_trip(&toml); let board2 = round_trip(&toml);
assert_eq!( assert_eq!(
board2.objects[&1].name, None, board2.objects[&1].scripting.name, None,
"unnamed object must round-trip as None" "unnamed object must round-trip as None"
); );
} }
+5 -5
View File
@@ -18,16 +18,16 @@ fn spinner_loads_as_a_tagged_scripted_solid_object() {
let (_, obj) = board let (_, obj) = board
.objects .objects
.iter() .iter()
.find(|(_, o)| o.tags.contains("BUILTIN_spinner_cw")) .find(|(_, o)| o.scripting.tags.contains("BUILTIN_spinner_cw"))
.expect("spinner object"); .expect("spinner object");
assert_eq!((obj.x, obj.y), (0, 0)); assert_eq!((obj.x, obj.y), (0, 0));
assert!( assert!(
obj.builtin_script.is_some(), obj.scripting.builtin_script.is_some(),
"carries the embedded spinner script" "carries the embedded spinner script"
); );
// Each alias gets its own compile-cache key (e.g. "BUILTIN_spinner_cw") so the // Each alias gets its own compile-cache key (e.g. "BUILTIN_spinner_cw") so the
// script can read direction from Me.has_tag("BUILTIN_spinner_cw"). // script can read direction from Me.has_tag("BUILTIN_spinner_cw").
assert_eq!(obj.script_name.as_deref(), Some("BUILTIN_spinner_cw")); assert_eq!(obj.scripting.script_name.as_deref(), Some("BUILTIN_spinner_cw"));
assert!(!board.is_passable(0, 0), "spinner is solid"); assert!(!board.is_passable(0, 0), "spinner is solid");
} }
@@ -53,8 +53,8 @@ fn spinner_round_trips_to_its_keyword() {
let (_, obj) = board2 let (_, obj) = board2
.objects .objects
.iter() .iter()
.find(|(_, o)| o.tags.contains("BUILTIN_spinner_ccw")) .find(|(_, o)| o.scripting.tags.contains("BUILTIN_spinner_ccw"))
.expect("spinner object after round-trip"); .expect("spinner object after round-trip");
assert_eq!((obj.x, obj.y), (0, 0)); assert_eq!((obj.x, obj.y), (0, 0));
assert!(obj.builtin_script.is_some()); assert!(obj.scripting.builtin_script.is_some());
} }
+3 -3
View File
@@ -22,10 +22,10 @@ fn transporter_loads_as_a_tagged_scripted_object() {
let (_, obj) = board let (_, obj) = board
.objects .objects
.iter() .iter()
.find(|(_, o)| o.tags.contains("BUILTIN_transporter_east")) .find(|(_, o)| o.scripting.tags.contains("BUILTIN_transporter_east"))
.expect("transporter object"); .expect("transporter object");
assert_eq!((obj.x, obj.y), (1, 0)); assert_eq!((obj.x, obj.y), (1, 0));
assert!(obj.builtin_script.is_some(), "carries the embedded script"); assert!(obj.scripting.builtin_script.is_some(), "carries the embedded script");
} }
#[test] #[test]
@@ -145,7 +145,7 @@ fn transporter_round_trips_to_its_keyword() {
board2 board2
.objects .objects
.values() .values()
.any(|o| o.tags.contains("BUILTIN_transporter_east") && o.builtin_script.is_some()), .any(|o| o.scripting.tags.contains("BUILTIN_transporter_east") && o.scripting.builtin_script.is_some()),
"reloads as a tagged transporter object", "reloads as a tagged transporter object",
); );
} }
+3 -3
View File
@@ -5,7 +5,7 @@ mod map_file;
mod movement; mod movement;
mod scripting; mod scripting;
use crate::archetype::Archetype; use crate::builtin::Archetype;
use crate::board::Board; use crate::board::Board;
use crate::floor::Floor; use crate::floor::Floor;
use crate::game::GameState; use crate::game::GameState;
@@ -24,7 +24,7 @@ fn board_with_object(
) -> (Board, HashMap<String, String>) { ) -> (Board, HashMap<String, String>) {
let mut object = ObjectDef::new(0, 0); let mut object = ObjectDef::new(0, 0);
object.id = 1; object.id = 1;
object.script_name = object_script.map(str::to_string); object.scripting.script_name = object_script.map(str::to_string);
let board = Board { let board = Board {
name: "test".into(), name: "test".into(),
width: 1, width: 1,
@@ -57,7 +57,7 @@ fn scripts_from(pairs: &[(&str, &str)]) -> HashMap<String, String> {
/// Returns an `ObjectDef` at `(x, y)` bound to the named script. /// Returns an `ObjectDef` at `(x, y)` bound to the named script.
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef { fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
let mut o = ObjectDef::new(x, y); let mut o = ObjectDef::new(x, y);
o.script_name = Some(script.to_string()); o.scripting.script_name = Some(script.to_string());
o o
} }
+1 -1
View File
@@ -1,4 +1,4 @@
use crate::archetype::Archetype; use crate::builtin::Archetype;
use crate::board::tests::{add_floor, crate_at, open_board, stamp, wall_at}; use crate::board::tests::{add_floor, crate_at, open_board, stamp, wall_at};
use crate::game::GameState; use crate::game::GameState;
use crate::glyph::Glyph; use crate::glyph::Glyph;
+6 -5
View File
@@ -131,7 +131,7 @@ fn set_tag_adds_and_removes_via_my_id() {
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
assert!(game.board().objects[&1].tags.contains("active")); assert!(game.board().objects[&1].scripting.tags.contains("active"));
// A script removes a pre-existing tag. // A script removes a pre-existing tag.
let (mut board2, scripts2) = board_with_object( let (mut board2, scripts2) = board_with_object(
@@ -143,11 +143,12 @@ fn set_tag_adds_and_removes_via_my_id() {
.objects .objects
.get_mut(&1) .get_mut(&1)
.unwrap() .unwrap()
.scripting
.tags .tags
.insert("active".to_string()); .insert("active".to_string());
let mut game2 = GameState::with_scripts(board2, scripts2); let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init(); game2.run_init();
assert!(!game2.board().objects[&1].tags.contains("active")); assert!(!game2.board().objects[&1].scripting.tags.contains("active"));
} }
#[test] #[test]
@@ -175,7 +176,7 @@ fn objects_with_tag_returns_matching_ids() {
let obj1 = scripted_object(0, 0, "q"); let obj1 = scripted_object(0, 0, "q");
let mut obj2 = scripted_object(1, 0, "none"); let mut obj2 = scripted_object(1, 0, "none");
// obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not. // obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not.
obj2.tags.insert("enemy".to_string()); obj2.scripting.tags.insert("enemy".to_string());
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]); let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
@@ -202,7 +203,7 @@ fn my_name_returns_name_or_empty_string() {
// An object with a name set on its ObjectDef should see it via my_name(). // An object with a name set on its ObjectDef should see it via my_name().
let (mut board, scripts) = let (mut board, scripts) =
board_with_object(Some("n"), &[("n", r#"fn init(m) { log(m.name); }"#)]); board_with_object(Some("n"), &[("n", r#"fn init(m) { log(m.name); }"#)]);
board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string()); board.objects.get_mut(&1).unwrap().scripting.name = Some("beacon".to_string());
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
assert_eq!(log_texts(&game), vec!["beacon"]); assert_eq!(log_texts(&game), vec!["beacon"]);
@@ -221,7 +222,7 @@ fn object_id_for_name_finds_by_name() {
// object_id_for_name to find the named one and logs its id. // object_id_for_name to find the named one and logs its id.
let obj1 = scripted_object(0, 0, "q"); let obj1 = scripted_object(0, 0, "q");
let mut obj2 = scripted_object(1, 0, "none"); let mut obj2 = scripted_object(1, 0, "none");
obj2.name = Some("target".to_string()); obj2.scripting.name = Some("target".to_string());
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]); let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
+318 -25
View File
@@ -1,16 +1,21 @@
use std::collections::HashSet; use std::collections::HashSet;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::api::queue::ObjQueue; use crate::api::queue::ObjQueue;
use crate::{Builtin, Direction};
use crate::floor::{Floor, FloorBiome};
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use crate::utils::{Behavior, ObjectId, Pushable}; use crate::utils::{ObjectId, Pushable};
use crate::utils::Pushable::No;
/// The various ways that a tile might respond to another tile trying to move on top of it /// The various ways that a tile might respond to another tile trying to move on top of it
#[derive(Serialize, Deserialize, PartialEq, Copy, Clone, Debug)] #[derive(Serialize, Deserialize, PartialEq, Copy, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub enum EnterResponse { pub enum EnterResponse {
/// Flat denial: don't let the move happen, block it. /// Flat denial: don't let the move happen, block it.
Block, Block,
/// Call the `grab` hook and then remove this object. /// If the player moves, call the `grab` hook and then remove this object. Anything else, act as
/// `Pushable::Any`.
/// TODO will do something different once inventory exists as a concept /// TODO will do something different once inventory exists as a concept
Grab, Grab,
/// Attempt to exit the cell ourselves, the opposite direction. This of course recurses; if our move /// Attempt to exit the cell ourselves, the opposite direction. This of course recurses; if our move
@@ -32,32 +37,46 @@ pub enum EnterResponse {
/// returns true without teleporting the crate, the crate will be destroyed by the player moving /// returns true without teleporting the crate, the crate will be destroyed by the player moving
/// on top of it. /// on top of it.
Hook, Hook,
/// Swap places with whatever moved on top of you /// Swap places with whatever moved on top of you, as long as it was the player. Anything else,
/// act as `Pushable::Any`.
Swap, Swap,
/// Get overwritten and destroyed by whatever moved on top of us. Right now, equivalent to `Grab` /// Get overwritten and destroyed by whatever moved on top of us. Right now, equivalent to `Grab`
/// if the hook does nothing (but in the future `Grab` will have other builtin behavior) /// if the hook does nothing (but in the future `Grab` will have other builtin behavior)
Squish Squish
} }
impl From<Behavior> for EnterResponse { impl EnterResponse {
fn from(behavior: Behavior) -> EnterResponse { /// Returns whether this object will transmit a push of the given direction through it:
if behavior.grab { /// - `Push(p)`, if p allows that direction
EnterResponse::Grab /// - `Grab`, because grabbable things act like push if something pushes them
} else if behavior.pushable != Pushable::No { /// - `Swap`, same reason
EnterResponse::Push(behavior.pushable) pub fn transmits_push(self, dir: Direction) -> bool {
} else if behavior.solid { match self {
EnterResponse::Block EnterResponse::Grab | EnterResponse::Swap => true,
} else { EnterResponse::Push(p) if p.allows(dir) => true,
EnterResponse::Squish _ => false
}
}
/// Returns whether this object will sense a bump from the given direction:
/// - `Push(p)`, if p disallows that direction
/// - `Block`, because nothing can move through it
/// - `Hook`, because it's obligated to handle the entry and not pass it on
/// - but nothing else
pub fn bumpable(self, dir: Direction) -> bool {
match self {
EnterResponse::Block | EnterResponse::Hook => true,
EnterResponse::Push(p) if !p.allows(dir) => true,
_ => false
} }
} }
} }
/// Where `Sensor`s are drawn, in relation to the grid: /// Where `Sensor`s are drawn, in relation to the grid:
/// - `Above` is above everything, including the player. If the glyph has a nonzero tile, and it's /// - `Above` is above everything, including the player. If the glyph has a nonzero tile, and it's
/// visible / lit, it will be drawn. /// visible / lit, it will be drawn.
/// - `Below` is below the grid but above the floor. A nonzero-tile-glyph will be drawn only if /// - `Below` is below the grid but above the floor. A nonzero-tile-glyph will be drawn only if
/// there's not a grid-thing in the same cell. /// there's not a grid-thing in the same cell.
#[derive(Serialize, Deserialize, PartialEq, Copy, Clone, Debug)] #[derive(Serialize, Deserialize, PartialEq, Copy, Clone, Debug)]
#[serde(rename = "lowercase")] #[serde(rename = "lowercase")]
pub enum DrawLayer { Above, Below } pub enum DrawLayer { Above, Below }
@@ -67,6 +86,7 @@ pub enum DrawLayer { Above, Below }
#[serde(rename = "lowercase")] #[serde(rename = "lowercase")]
pub struct Optics { pub struct Optics {
/// Opaque things block field of view /// Opaque things block field of view
#[serde(default = "default_as_true")]
pub opaque: bool, pub opaque: bool,
/// The brightness (in number of cells' radius) of light this /// The brightness (in number of cells' radius) of light this
/// emits. It will emit light in the color of the foreground of its glyph. /// emits. It will emit light in the color of the foreground of its glyph.
@@ -76,13 +96,41 @@ pub struct Optics {
/// - the cell is within the glow radius of at least one light source /// - the cell is within the glow radius of at least one light source
/// ///
/// Its color will be tinted by the light sources illuminating it. /// Its color will be tinted by the light sources illuminating it.
#[serde(default)]
pub glow: u32, pub glow: u32,
} }
impl Default for Optics {
fn default() -> Self {
Self {
opaque: false,
glow: 0,
}
}
}
const fn default_as_true() -> bool {
true
}
const fn default_as_below() -> DrawLayer {
DrawLayer::Below
}
/// Everything an object-or-sensor needs to have a Rhai script attached. /// Everything an object-or-sensor needs to have a Rhai script attached.
/// TODO clean this up some, especially the script_name-vs-builtin_script dichotomy /// TODO clean this up some, especially the script_name-vs-builtin_script dichotomy
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct Scripting { pub struct ScriptAttributes {
/// Stable identity assigned by [`crate::board::Board::add_object`].
/// `0` is the sentinel meaning "not yet inserted into a board".
/// Real ids start at 1 and never change after assignment.
/// Not serialized — the id is stamped on insert, not stored in map files.
pub id: ObjectId,
/// Visual representation of this object. Owned by the object (not derived
/// from the grid cell), so scripts can change tile, fg, and bg at runtime.
pub glyph: Glyph,
/// How this object affects FOV and lighting
pub optics: Optics,
/// Compile-key of the Rhai script that drives this object: a name in /// Compile-key of the Rhai script that drives this object: a name in
/// [`World::scripts`](crate::world::World::scripts) for a hand-authored object, /// [`World::scripts`](crate::world::World::scripts) for a hand-authored object,
/// or a synthetic `BUILTIN_*` name set when a script-backed archetype is /// or a synthetic `BUILTIN_*` name set when a script-backed archetype is
@@ -110,19 +158,264 @@ pub struct Scripting {
/// A `Sensor` is some kind of object that exists alongside the map: it's drawn on the board but can't /// A `Sensor` is some kind of object that exists alongside the map: it's drawn on the board but can't
/// affect board movement, as it doesn't live in the grid. /// affect board movement, as it doesn't live in the grid.
#[derive(Serialize, Deserialize, Clone)] #[derive(Clone)]
pub struct Sensor { pub struct Sensor {
/// The unique ID of this sensor
pub id: ObjectId,
/// Where it is on the board: x coord /// Where it is on the board: x coord
pub x: usize, pub x: usize,
/// Where it is on the board: y coord /// Where it is on the board: y coord
pub y: usize, pub y: usize,
/// What it looks like /// Whether this is drawn above or below the grid
pub glyph: Glyph, pub draw_layer: DrawLayer,
/// Sensors interact with FOV and lighting
pub optics: Optics,
/// Sensors have scripting ability /// Sensors have scripting ability
#[serde(skip)] pub scripting: ScriptAttributes,
pub scripting: Scripting, }
/// The serialized representation of a Sensor. Can be turned into a Sensor, or vice versa
#[derive(Serialize, Deserialize, Clone)]
pub struct SensorSpec {
pub x: usize,
pub y: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<String>,
pub glyph: Glyph,
#[serde(flatten, default)]
pub optics: Optics,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default = "default_as_below")]
pub draw_layer: DrawLayer,
}
impl SensorSpec {
pub fn into_sensor(self, next_object_id: &mut ObjectId) -> Sensor {
let id = *next_object_id;
*next_object_id += 1;
Sensor {
x: self.x,
y: self.y,
draw_layer: self.draw_layer,
scripting: ScriptAttributes {
id,
glyph: self.glyph,
optics: self.optics,
name: self.name,
tags: self.tags.into_iter().collect(),
script_name: self.script,
..Default::default()
}
}
}
}
pub trait Hookable {
fn scriptable(&self) -> &ScriptAttributes;
fn location(&self) -> (usize, usize);
fn id(&self) -> ObjectId {
self.scriptable().id
}
fn glyph(&self) -> Glyph {
self.scriptable().glyph
}
fn optics(&self) -> Optics {
self.scriptable().optics
}
fn name(&self) -> &Option<String> {
&self.scriptable().name
}
fn tags(&self) -> &HashSet<String> {
&self.scriptable().tags
}
fn solid(&self) -> bool;
/// The compile-key for an object's script: the object's `script_name` — a
/// world-pool name for a named script, or a synthetic `BUILTIN_*` name set by
/// [`Board::expand_builtin_archetypes`](crate::board::Board::expand_builtin_archetypes)
/// for an expanded built-in (so identical built-ins share one compiled AST,
/// while the source still comes from `builtin_script`). `None` if the object has
/// no script.
fn script_key(&self) -> Option<&String> {
self.scriptable().script_name.as_ref()
}
}
impl Hookable for &Sensor {
fn scriptable(&self) -> &ScriptAttributes {
&self.scripting
}
fn location(&self) -> (usize, usize) {
(self.x, self.y)
}
fn solid(&self) -> bool {
false
}
}
pub struct LocatedObject<'a>(pub &'a ObjectDef, pub (usize, usize));
impl Hookable for LocatedObject<'_> {
fn scriptable(&self) -> &ScriptAttributes {
&self.0.scripting
}
fn location(&self) -> (usize, usize) {
self.1
}
fn solid(&self) -> bool {
true
}
}
#[derive(Clone, Debug)]
pub enum Tile {
Player,
Object(Box<ObjectDef>),
}
impl Tile {
pub fn glyph(&self) -> Glyph {
match self {
Tile::Player => Glyph::player(),
Tile::Object(obj) => obj.scripting.glyph,
}
}
pub fn player(&self) -> bool {
matches!(self, Self::Player)
}
/// Can this tile be moved by a `shift`?
pub fn shiftable(&self) -> bool {
match self {
Tile::Player => true, // Player will shift anywhere
Tile::Object(obj) => {
match obj.enter_response {
EnterResponse::Push(p) => p != No,
EnterResponse::Hook | EnterResponse::Block => false, // Blockers never shift
EnterResponse::Grab | EnterResponse::Swap | EnterResponse::Squish => true,
}
}
}
}
}
/// The serialized representation of a Tile. Can be turned into a Tile, or vice versa
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "lowercase", tag = "type")]
pub enum TileSpec {
Player,
Object {
#[serde(default, skip_serializing_if = "Option::is_none")]
script: Option<String>,
enter: EnterResponse,
#[serde(flatten)]
glyph: Glyph,
#[serde(flatten, default)]
optics: Optics,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>,
},
Builtin {
kind: String,
#[serde(flatten, default, skip_serializing_if = "Option::is_none")]
glyph: Option<Glyph>,
}
}
pub trait IntoTile {
fn into_tile(self, next_object_id: &mut ObjectId) -> Result<Tile, String>;
}
impl IntoTile for TileSpec {
fn into_tile(self, next_object_id: &mut ObjectId) -> Result<Tile, String> {
match self {
TileSpec::Player => Ok(Tile::Player),
// TileSpec::Portal { name, target_board, target_name } => {
// let def = PortalDef {
// name,
// target_map: target_board,
// target_entry: target_name,
// };
// Ok(Tile::Portal(Box::new(def)))
// }
TileSpec::Object { script, enter, glyph, optics, name, tags } => {
let def = ObjectDef {
enter_response: enter,
scripting: ScriptAttributes {
id: *next_object_id,
glyph,
optics,
script_name: script,
builtin_script: None,
tags: tags.into_iter().collect(),
name,
queue: ObjQueue::new(),
}
};
*next_object_id = *next_object_id + 1;
Ok(Tile::Object(Box::new(def)))
},
TileSpec::Builtin { kind, glyph } => {
if let Some((builtin, variant)) = Builtin::from_name(&kind) {
let def = ObjectDef {
enter_response: builtin.enter_response(),
scripting: ScriptAttributes {
id: *next_object_id,
glyph: glyph.unwrap_or(builtin.default_glyph_for(variant)),
optics: builtin.optics(),
script_name: None,
builtin_script: Some(builtin.script()),
tags: HashSet::from([format!("BUILTIN_{}", variant)]),
name: None,
queue: ObjQueue::new(),
}
};
*next_object_id = *next_object_id + 1;
Ok(Tile::Object(Box::new(def)))
} else {
Err(format!("Unknown builtin kind {}", kind))
}
}
}
}
}
/// Convenience functions for tests
#[cfg(test)]
impl TileSpec {
pub fn wall() -> Self {
TileSpec::Builtin { kind: "wall".to_string(), glyph: None }
}
pub fn player() -> Self {
TileSpec::Builtin { kind: "wall".to_string(), glyph: None }
}
pub fn krate() -> Self {
TileSpec::Builtin { kind: "wall".to_string(), glyph: None }
}
pub fn gem() -> Self {
TileSpec::Builtin { kind: "gem".to_string(), glyph: None }
}
}
#[derive(Serialize, Deserialize, Copy, Clone)]
#[serde(rename_all = "lowercase")]
pub enum FloorSpec {
Biome(FloorBiome),
Glyph(Glyph),
}
pub trait IntoFloor {
fn into_floor(self, width: usize, height: usize) -> Floor;
}
impl IntoFloor for Option<FloorSpec> {
fn into_floor(self, width: usize, height: usize) -> Floor {
match self {
Some(FloorSpec::Biome(biome)) => Floor::biome(biome, width, height),
Some(FloorSpec::Glyph(glyph)) => Floor::Fixed(glyph),
None => Floor::Blank
}
}
} }
+27 -291
View File
@@ -1,14 +1,9 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::fmt::Display; use std::fmt::Display;
use std::rc::Rc; use std::rc::Rc;
use crate::archetype::Archetype;
use crate::glyph::Glyph;
use color::Rgba8;
use rhai::Dynamic; use rhai::Dynamic;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::Board;
use crate::log::LogLine; use crate::log::LogLine;
use crate::tile::EnterResponse;
/// Which directions a solid may be pushed in. /// Which directions a solid may be pushed in.
/// ///
@@ -40,35 +35,6 @@ impl Pushable {
} }
} }
/// The behavioral properties of a board cell at runtime.
///
/// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It
/// contains the properties the engine needs to simulate a cell — currently
/// solidity, opacity, and pushability. Future properties (shootable, etc.) can
/// be added here without changing call sites.
///
/// For scripted objects, solidity and opacity are stored directly on
/// [`ObjectDef`] and will eventually be overridable by Rhai scripts at runtime.
#[derive(Copy, Clone, Debug)]
pub struct Behavior {
/// Whether this cell blocks / participates in movement. A solid cell stops a
/// mover (and is the only kind of cell that can later be pushed or receive a
/// collision event). This is the inverse of the old `passable` flag.
pub solid: bool,
/// Whether this cell blocks line of sight (reserved for future rendering).
pub opaque: bool,
/// Which directions a mover can shove this cell in (only meaningful when `solid`).
pub pushable: Pushable,
/// Whether walking into this solid **grabs** it instead of being blocked: the
/// player passes onto the cell and the thing's `grab()` script hook fires (and
/// the thing is expected to remove itself via `die()`). Only meaningful when
/// `solid`. The same grab fires if the thing is pushed into the player.
pub grab: bool,
/// The radius of light this emits, if any. Light _color_ is determined by the
/// glyph foreground color of whatever it is.
pub glow: u32,
}
/// A stable, unique identifier for a board object. /// A stable, unique identifier for a board object.
/// ///
/// Ids are handed out by [`Board::add_object`] from the per-board /// Ids are handed out by [`Board::add_object`] from the per-board
@@ -78,263 +44,6 @@ pub struct Behavior {
/// objects were reordered/destroyed. /// objects were reordered/destroyed.
pub type ObjectId = u32; pub type ObjectId = u32;
/// Which kind of thing a [`Solid`] is, plus the data needed to relocate it.
///
/// Private to [`Solid`]: callers ask through the [`Solid`] accessors
/// ([`Solid::player`], [`Solid::object_id`], [`Solid::archetype`]) rather than
/// matching the kind directly.
#[derive(Copy, Clone)]
enum SolidKind {
/// The player occupies the cell. The player carries no grid cell of its own.
Player,
/// A solid scripted object, identified by its stable [`ObjectId`].
Object(ObjectId),
/// A solid terrain cell, with everything needed to rewrite it elsewhere.
Terrain {
/// The cell's glyph.
glyph: Glyph,
/// The cell's archetype.
arch: Archetype,
},
}
/// The single solid occupant of a board cell, captured from a `&Board` at given
/// coordinates by [`Board::solid_at`].
///
/// At most one solid — the player, a grid [`Archetype`], *or* an [`ObjectDef`] — may
/// occupy a cell (the invariant enforced at load time), so this represents the one
/// thing a mover would collide with there. Absence of a solid is `None`, not a
/// variant of this type.
///
/// `Solid` is `Copy`: it captures its occupant's coordinates, behavior, and (for
/// terrain) the glyph/archetype/layer needed to relocate it *at construction time*,
/// so movement logic can answer behavior questions and [`place`](Solid::place) the
/// occupant elsewhere without re-borrowing the board. This is what lets
/// [`Board::apply_swap`] read every source before writing any destination.
#[derive(Copy, Clone)]
pub struct Solid {
/// Column the occupant was read from.
x: usize,
/// Row the occupant was read from.
y: usize,
/// What kind of occupant this is (and its relocation data).
kind: SolidKind,
/// The occupant's behavior, captured at creation: the player's is synthesized
/// (solid + opaque + pushable any direction + not grabbable), terrain reads
/// [`Archetype::behavior`], an object's is built from its `solid`/`opaque`/
/// `pushable`/`grab` flags.
behavior: Behavior,
}
impl Solid {
/// Builds the player solid at `(x, y)`.
pub(crate) fn player_at(x: usize, y: usize) -> Solid {
Solid {
x,
y,
kind: SolidKind::Player,
// The player is solid + opaque, pushable in any direction, never grabbable.
behavior: Behavior {
solid: true,
opaque: true,
pushable: Pushable::Any,
grab: false,
glow: 6 // TODO player should not inherently glow, but glowing inventory items need to wait for inventory
},
}
}
/// Builds a solid object at `(x, y)` from its [`ObjectDef`]-derived flags.
pub(crate) fn object_at(x: usize, y: usize, id: ObjectId, behavior: Behavior) -> Solid {
Solid {
x,
y,
kind: SolidKind::Object(id),
behavior,
}
}
/// Builds a solid terrain cell at `(x, y)`.
pub(crate) fn terrain_at(x: usize, y: usize, glyph: Glyph, arch: Archetype) -> Solid {
Solid {
x,
y,
kind: SolidKind::Terrain { glyph, arch },
behavior: arch.behavior(),
}
}
/// The coordinates the occupant was read from.
pub fn coords(&self) -> (usize, usize) {
(self.x, self.y)
}
/// Whether this occupant is the player.
pub fn player(&self) -> bool {
matches!(self.kind, SolidKind::Player)
}
/// The occupant's [`ObjectId`], if it is a scripted object.
pub fn object_id(&self) -> Option<ObjectId> {
match self.kind {
SolidKind::Object(id) => Some(id),
_ => None,
}
}
/// The occupant's terrain archetype, if it is a terrain cell.
pub fn archetype(&self) -> Option<Archetype> {
match self.kind {
SolidKind::Terrain { arch, .. } => Some(arch),
_ => None,
}
}
/// Which directions the occupant may be pushed (the player → [`Pushable::Any`]).
pub fn pushable(&self) -> Pushable {
if let EnterResponse::Push(p) = EnterResponse::from(self.behavior) {
p
} else {
Pushable::No
}
}
/// Whether this occupant is a **grab** thing (a gem-like solid the player
/// collects on contact). The player is never grabbable.
pub fn grab(&self) -> bool {
EnterResponse::from(self.behavior) == EnterResponse::Grab
}
/// Whether this occupant blocks movement (always true for a `Solid`).
pub fn solid(&self) -> bool {
EnterResponse::from(self.behavior) == EnterResponse::Block
}
/// Whether this occupant blocks line of sight.
pub fn opaque(&self) -> bool {
self.behavior.opaque
}
/// Writes this occupant into the cell at `(x, y)`, relocating it there.
///
/// Writes the **destination only** — it does *not* vacate the occupant's
/// original cell. Callers that need the source cleared (e.g.
/// [`Board::shift_solid`]) do so separately; [`Board::apply_swap`] relies on
/// this by clearing all sources in a dedicated phase before installing
/// destinations, so cyclic moves and swaps resolve correctly.
///
/// The player moves via [`Board::player`]; an object updates its
/// [`ObjectDef`] position; a terrain cell rewrites `(glyph, arch)` at `(x, y)`.
pub fn place(self, board: &mut Board, x: usize, y: usize) {
match self.kind {
SolidKind::Player => {
board.player.x = x as i64;
board.player.y = y as i64;
}
SolidKind::Object(id) => {
if let Some(obj) = board.objects.get_mut(&id) {
obj.x = x;
obj.y = y;
}
}
SolidKind::Terrain { glyph, arch } => {
*board.get_mut(x, y) = (glyph, arch);
}
}
}
}
/// A portal that teleports the player to a named entry point on another board.
///
/// Portals are loaded from `[[portals]]` entries in `.toml` map files and
/// stored on [`Board`]. When the player steps onto a portal's cell, the engine
/// calls [`crate::game::GameState::enter_board`] with the `target_map` and
/// `target_entry`, placing the player at the matching named portal on the
/// destination board.
///
/// In map files, portals are conventionally placed using digit characters
/// (`'1'``'9'`) as palette keys — parallel to the uppercase-letter convention
/// for objects. A portal's `name` is board-unique: it is also used as the
/// `target_entry` value on the other end of the connection.
#[derive(Clone)]
pub struct PortalDef {
/// Board-unique name for this portal, also used as `target_entry` by portals
/// on other boards that want to arrive here.
pub name: String,
/// Column of this portal on the board (0-indexed).
pub x: usize,
/// Row of this portal on the board (0-indexed).
pub y: usize,
/// Key of the target board in `World::boards`.
pub target_map: String,
/// Name of the arrival portal on the target board.
pub target_entry: String,
}
impl PortalDef {
/// The default glyph for a portal: CP437 char 240 (`≡`), black on white.
pub fn default_glyph() -> Glyph {
Glyph {
tile: 240,
fg: Rgba8 {
r: 0,
g: 0,
b: 0,
a: 255,
},
bg: Rgba8 {
r: 255,
g: 255,
b: 255,
a: 255,
},
}
}
}
/// The player's current position on the board.
///
/// The player is currently a special entity rendered on top of the board
/// rather than being stored as a board cell. This is expected to change:
/// the player will eventually become a scripted object that responds to
/// input events, at which point this struct may be removed or made optional.
/// See the "player may become an object" notes in `CLAUDE.md` for the design
/// tensions this implies.
#[derive(Copy, Clone)]
pub struct PlayerPos {
/// Column position (0-indexed, increasing rightward).
pub x: i64,
/// Row position (0-indexed, increasing downward).
pub y: i64,
}
#[cfg(test)]
mod tests {
use super::Pushable;
use crate::utils::Direction;
#[test]
fn pushable_allows_only_its_axis() {
for d in [
Direction::North,
Direction::South,
Direction::East,
Direction::West,
] {
assert!(!Pushable::No.allows(d));
assert!(Pushable::Any.allows(d));
}
assert!(Pushable::Horizontal.allows(Direction::East));
assert!(Pushable::Horizontal.allows(Direction::West));
assert!(!Pushable::Horizontal.allows(Direction::North));
assert!(!Pushable::Horizontal.allows(Direction::South));
assert!(Pushable::Vertical.allows(Direction::North));
assert!(Pushable::Vertical.allows(Direction::South));
assert!(!Pushable::Vertical.allows(Direction::East));
assert!(!Pushable::Vertical.allows(Direction::West));
}
}
/// A cardinal movement direction. /// A cardinal movement direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Direction { pub enum Direction {
@@ -519,3 +228,30 @@ impl LogSink {
std::mem::take(&mut self.0.borrow_mut()) std::mem::take(&mut self.0.borrow_mut())
} }
} }
#[cfg(test)]
mod tests {
use super::Pushable;
use crate::utils::Direction;
#[test]
fn pushable_allows_only_its_axis() {
for d in [
Direction::North,
Direction::South,
Direction::East,
Direction::West,
] {
assert!(!Pushable::No.allows(d));
assert!(Pushable::Any.allows(d));
}
assert!(Pushable::Horizontal.allows(Direction::East));
assert!(Pushable::Horizontal.allows(Direction::West));
assert!(!Pushable::Horizontal.allows(Direction::North));
assert!(!Pushable::Horizontal.allows(Direction::South));
assert!(Pushable::Vertical.allows(Direction::North));
assert!(Pushable::Vertical.allows(Direction::South));
assert!(!Pushable::Vertical.allows(Direction::East));
assert!(!Pushable::Vertical.allows(Direction::West));
}
}
+17 -24
View File
@@ -2,11 +2,11 @@
use crate::board::Board; use crate::board::Board;
use crate::fov::SIGHT_RADIUS; use crate::fov::SIGHT_RADIUS;
use crate::map_file::MapFile;
use serde::Deserialize; use serde::Deserialize;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::rc::Rc; use std::rc::Rc;
use crate::board_spec::BoardSpec;
/// A named world containing one or more boards, loaded from a `.toml` file. /// A named world containing one or more boards, loaded from a `.toml` file.
/// ///
@@ -73,9 +73,9 @@ struct WorldFile {
/// The `[scripts]` table: script name → Rhai source. Shared across all boards. /// The `[scripts]` table: script name → Rhai source. Shared across all boards.
#[serde(default, skip_serializing_if = "HashMap::is_empty")] #[serde(default, skip_serializing_if = "HashMap::is_empty")]
scripts: HashMap<String, String>, scripts: HashMap<String, String>,
/// Each value deserializes as a [`MapFile`], reusing the existing per-board /// Each value deserializes as a [`BoardSpec`], reusing the existing per-board
/// serde types from `map_file.rs`. /// serde types from `map_file.rs`.
boards: HashMap<String, MapFile>, boards: HashMap<String, BoardSpec>,
} }
/// The `[world]` header section of a world file. /// The `[world]` header section of a world file.
@@ -86,6 +86,7 @@ struct WorldHeader {
/// Key of the board to start on; must match a key in `[boards]`. /// Key of the board to start on; must match a key in `[boards]`.
start: String, start: String,
/// Optional player torch radius on dark boards; defaults to [`SIGHT_RADIUS`]. /// Optional player torch radius on dark boards; defaults to [`SIGHT_RADIUS`].
/// TODO goes away with inventory
#[serde(default)] #[serde(default)]
torch: Option<u32>, torch: Option<u32>,
} }
@@ -100,11 +101,14 @@ pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)?; let text = std::fs::read_to_string(path)?;
let wf: WorldFile = toml::from_str(&text)?; let wf: WorldFile = toml::from_str(&text)?;
// Convert each MapFile into a Board. Grid-dimension mismatches are the only // Make a list of the script names, for validating board specs
let script_names = wf.scripts.keys().collect::<HashSet<_>>();
// Convert each BoardSpec into a Board. Grid-dimension mismatches are the only
// hard errors; everything else is nonfatal and lands on Board::load_errors. // hard errors; everything else is nonfatal and lands on Board::load_errors.
let mut boards = HashMap::new(); let mut boards = HashMap::new();
for (key, map_file) in wf.boards { for (key, spec) in wf.boards {
let board = Board::try_from(map_file).map_err(|e| format!("board '{}': {}", key, e))?; let board = spec.into_board(&script_names).map_err(|e| format!("board '{}': {}", key, e))?;
boards.insert(key, Rc::new(RefCell::new(board))); boards.insert(key, Rc::new(RefCell::new(board)));
} }
@@ -128,19 +132,19 @@ pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::assert_matches;
use super::World; use super::World;
use crate::archetype::Archetype;
use crate::board::tests::open_board; use crate::board::tests::open_board;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::rc::Rc; use std::rc::Rc;
use crate::Builtin; use crate::tile::{Tile, TileSpec};
/// A `deep_clone`d world owns independent boards: mutating the copy must leave the /// A `deep_clone`d world owns independent boards: mutating the copy must leave the
/// original board untouched (the property the editor's playtest relies on). /// original board untouched (the property the editor's playtest relies on).
#[test] #[test]
fn deep_clone_isolates_boards() { fn deep_clone_isolates_boards() {
let board = open_board(3, 1, (0, 0), vec![]); let mut board = open_board(3, 1, (0, 0));
let mut boards = HashMap::new(); let mut boards = HashMap::new();
boards.insert("start".to_string(), Rc::new(RefCell::new(board))); boards.insert("start".to_string(), Rc::new(RefCell::new(board)));
let world = World { let world = World {
@@ -153,22 +157,11 @@ mod tests {
let copy = world.deep_clone(); let copy = world.deep_clone();
// Stamp a wall into the copy's board. // Stamp a wall into the copy's board.
copy.boards["start"].borrow_mut().place_archetype( copy.boards["start"].borrow_mut().place(1, 0, Some(TileSpec::wall())).expect("could not place wall");
1,
0,
Archetype::Builtin(Builtin::Wall, "wall"),
Builtin::Wall.default_glyph_for("wall"),
);
// The copy changed; the original is still empty at that cell. // The copy changed; the original is still empty at that cell.
assert_eq!( assert_matches!(copy.boards["start"].borrow().get(1, 0), Some(Tile::Object(_)));
copy.boards["start"].borrow().get(1, 0).1, assert!(world.boards["start"].borrow().get(1, 0).is_none());
Archetype::Builtin(Builtin::Wall, "wall")
);
assert_eq!(
world.boards["start"].borrow().get(1, 0).1,
Archetype::Empty
);
// And they are genuinely different allocations. // And they are genuinely different allocations.
assert!(!Rc::ptr_eq(&world.boards["start"], &copy.boards["start"])); assert!(!Rc::ptr_eq(&world.boards["start"], &copy.boards["start"]));
} }
+14 -15
View File
@@ -20,7 +20,7 @@ use kiln_core::game::GameState;
use kiln_core::glyph::Glyph; use kiln_core::glyph::Glyph;
use kiln_core::log::LogLine; use kiln_core::log::LogLine;
use kiln_core::world::World; use kiln_core::world::World;
use kiln_core::{Archetype, Board, Builtin}; use kiln_core::{Board, Builtin};
use kiln_ui::code_editor::{CodeEditor, CodeEditorOutcome}; use kiln_ui::code_editor::{CodeEditor, CodeEditorOutcome};
use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse}; use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse};
use ratatui::Frame; use ratatui::Frame;
@@ -31,6 +31,7 @@ use ratatui::symbols::merge::MergeStrategy;
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph}; use ratatui::widgets::{Block, Paragraph};
use std::cell::{Ref, RefMut}; use std::cell::{Ref, RefMut};
use kiln_core::tile::TileSpec;
/// How long each half of the cursor blink lasts, in seconds. /// How long each half of the cursor blink lasts, in seconds.
const BLINK_SECS: f32 = 0.5; const BLINK_SECS: f32 = 0.5;
@@ -70,7 +71,7 @@ pub(crate) struct EditorState {
/// The archetype the drawing tools stamp on [`place_current`](EditorState::place_current). /// The archetype the drawing tools stamp on [`place_current`](EditorState::place_current).
/// Defaults to [`Archetype::Wall`]; changed via the Terrain / Pushers menus /// Defaults to [`Archetype::Wall`]; changed via the Terrain / Pushers menus
/// ([`set_current`](EditorState::set_current)). /// ([`set_current`](EditorState::set_current)).
current_archetype: Archetype, current_archetype: &'static str,
/// The glyph the drawing tools stamp. Reset to the current archetype's default /// The glyph the drawing tools stamp. Reset to the current archetype's default
/// whenever the archetype changes ([`set_current`](EditorState::set_current)), and /// whenever the archetype changes ([`set_current`](EditorState::set_current)), and
/// overridable via the glyph picker (`g`). /// overridable via the glyph picker (`g`).
@@ -112,7 +113,7 @@ impl EditorState {
code_editor: None, code_editor: None,
glyph_dialog: None, glyph_dialog: None,
cursor, cursor,
current_archetype: Archetype::Builtin(Builtin::Wall, "wall"), current_archetype: "wall",
current_glyph: Builtin::Wall.default_glyph_for("wall"), current_glyph: Builtin::Wall.default_glyph_for("wall"),
draw_mode: false, draw_mode: false,
sidebar_width: DEFAULT_SIDEBAR_WIDTH, sidebar_width: DEFAULT_SIDEBAR_WIDTH,
@@ -241,17 +242,21 @@ impl EditorState {
/// Sets the archetype the drawing tools stamp, resetting the drawing glyph to /// Sets the archetype the drawing tools stamp, resetting the drawing glyph to
/// that archetype's default (the glyph picker `g` can then override it). Called by /// that archetype's default (the glyph picker `g` can then override it). Called by
/// the Terrain / Pushers menu choices. /// the Terrain / Pushers menu choices.
pub(crate) fn set_current(&mut self, archetype: Archetype) { pub(crate) fn set_current(&mut self, archetype: &'static str) {
self.current_archetype = archetype; if let Some((builtin, variant)) = Builtin::from_name(archetype) {
self.current_glyph = self.current_archetype.default_glyph(); self.current_archetype = variant;
self.current_glyph = builtin.default_glyph_for(variant);
}
} }
/// Stamps the current drawing thing (archetype + glyph) into the cell under the /// Stamps the current drawing thing (archetype + glyph) into the cell under the
/// cursor, applying the placement rules (see [`Board::place_archetype`]). /// cursor, applying the placement rules (see [`Board::place_archetype`]).
pub(crate) fn place_current(&mut self) { pub(crate) fn place_current(&mut self) {
let (x, y) = (self.cursor.0 as usize, self.cursor.1 as usize); let (x, y) = (self.cursor.0 as usize, self.cursor.1 as usize);
let (arch, glyph) = (self.current_archetype, self.current_glyph); let res = self.board_mut().place(x, y, Some(TileSpec::Builtin { kind: self.current_archetype.to_string(), glyph: Some(self.current_glyph) }));
self.board_mut().place_archetype(x, y, arch, glyph); if let Err(e) = res {
self.log.push(LogLine::error(e))
}
} }
/// Toggles draw mode (whether cursor movement auto-stamps the current thing). /// Toggles draw mode (whether cursor movement auto-stamps the current thing).
@@ -330,12 +335,6 @@ impl EditorState {
let mut world = self.world.deep_clone(); let mut world = self.world.deep_clone();
// Start the playtest on the board open in the editor, not world.start. // Start the playtest on the board open in the editor, not world.start.
world.start = self.board_name.clone(); world.start = self.board_name.clone();
// Editor-stamped machines (spinners, pushers) are plain terrain cells; expand
// them into their scripted objects so they actually run, just as the map loader
// does on disk-loaded worlds.
for board in world.boards.values() {
board.borrow_mut().expand_builtin_archetypes();
}
let mut game = GameState::from_world(world); let mut game = GameState::from_world(world);
game.log(LogLine::raw("[esc] to return to the editor")); game.log(LogLine::raw("[esc] to return to the editor"));
game.run_init(); game.run_init();
@@ -520,7 +519,7 @@ fn draw_footer_lines<'a>(
// Separator marking off the footer from the menu above. // Separator marking off the footer from the menu above.
Line::from(Span::styled("".repeat(64), sep_style)), Line::from(Span::styled("".repeat(64), sep_style)),
// The archetype currently being drawn (no UI to change it yet). // The archetype currently being drawn (no UI to change it yet).
Line::from(Span::styled(ed.current_archetype.name(), label_style)), Line::from(Span::styled(ed.current_archetype, label_style)),
Line::from(vec![ Line::from(vec![
Span::styled("[G] ", key_style), Span::styled("[G] ", key_style),
Span::styled("Glyph ", label_style), Span::styled("Glyph ", label_style),
+27 -27
View File
@@ -1,7 +1,7 @@
use crate::editor::EditorState; use crate::editor::EditorState;
use crate::menu::{MenuItem, MenuKey}; use crate::menu::{MenuItem, MenuKey};
use crate::mode::PendingMode; use crate::mode::PendingMode;
use kiln_core::{Archetype, Builtin}; use kiln_core::Builtin;
use kiln_core::keys::KeyType; use kiln_core::keys::KeyType;
use kiln_core::glyph::Glyph; use kiln_core::glyph::Glyph;
@@ -77,10 +77,10 @@ impl MenuLevel {
MenuEntry::item('c', "Custom glyph [TODO]", |_| {}), MenuEntry::item('c', "Custom glyph [TODO]", |_| {}),
], ],
MenuLevel::Terrain => vec![ MenuLevel::Terrain => vec![
MenuEntry::item('w', "Wall", |ed| ed.set_current(Archetype::Builtin(Builtin::Wall, "wall"))), MenuEntry::item('w', "Wall", |ed| ed.set_current("wall")),
MenuEntry::item('c', "■ Crate", |ed| ed.set_current(Archetype::Builtin(Builtin::Crate, "crate"))), MenuEntry::item('c', "■ Crate", |ed| ed.set_current("crate")),
MenuEntry::item('v', "↕ Crate", |ed| ed.set_current(Archetype::Builtin(Builtin::VCrate, "vcrate"))), MenuEntry::item('v', "↕ Crate", |ed| ed.set_current("vcrate")),
MenuEntry::item('h', "↔ Crate", |ed| ed.set_current(Archetype::Builtin(Builtin::HCrate, "hcrate"))), MenuEntry::item('h', "↔ Crate", |ed| ed.set_current("hcrate")),
], ],
MenuLevel::Machines => vec![ MenuLevel::Machines => vec![
MenuEntry::item('p', "Pushers...", |ed| ed.menu.push(MenuLevel::Pushers)), MenuEntry::item('p', "Pushers...", |ed| ed.menu.push(MenuLevel::Pushers)),
@@ -88,56 +88,56 @@ impl MenuLevel {
ed.menu.push(MenuLevel::Transporters) ed.menu.push(MenuLevel::Transporters)
}), }),
MenuEntry::item('s', "/ CW spinner", |ed| { MenuEntry::item('s', "/ CW spinner", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Spinner, "spinner_cw")) ed.set_current("spinner_cw")
}), }),
MenuEntry::item('c', "\\ CCW spinner", |ed| { MenuEntry::item('c', "\\ CCW spinner", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Spinner, "spinner_ccw")) ed.set_current("spinner_ccw")
}), }),
], ],
MenuLevel::Pushers => vec![ MenuLevel::Pushers => vec![
MenuEntry::item('n', "▲ Pusher", |ed| { MenuEntry::item('n', "▲ Pusher", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_north")) ed.set_current("pusher_north")
}), }),
MenuEntry::item('s', "▼ Pusher", |ed| { MenuEntry::item('s', "▼ Pusher", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_south")) ed.set_current("pusher_south")
}), }),
MenuEntry::item('e', "► Pusher", |ed| { MenuEntry::item('e', "► Pusher", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_east")) ed.set_current("pusher_east")
}), }),
MenuEntry::item('w', "◄ Pusher", |ed| { MenuEntry::item('w', "◄ Pusher", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west")) ed.set_current("pusher_west")
}), }),
], ],
MenuLevel::Transporters => vec![ MenuLevel::Transporters => vec![
MenuEntry::item('n', "▲ Transporter", |ed| { MenuEntry::item('n', "▲ Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_north")) ed.set_current("transporter_north")
}), }),
MenuEntry::item('s', "▼ Transporter", |ed| { MenuEntry::item('s', "▼ Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_south")) ed.set_current("transporter_south")
}), }),
MenuEntry::item('e', "► Transporter", |ed| { MenuEntry::item('e', "► Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_east")) ed.set_current("transporter_east")
}), }),
MenuEntry::item('w', "◄ Transporter", |ed| { MenuEntry::item('w', "◄ Transporter", |ed| {
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_west")) ed.set_current("transporter_west")
}), }),
], ],
MenuLevel::Items => vec![ MenuLevel::Items => vec![
MenuEntry::glyph('t', Archetype::Builtin(Builtin::Gem, "gem").default_glyph(), "Gem", |ed| { MenuEntry::glyph('t', Builtin::Gem.default_glyph_for("gem"), "Gem", |ed| {
// 't' for 'treasure' — 'g' conflicts with glyph picker // 't' for 'treasure' — 'g' conflicts with glyph picker
ed.set_current(Archetype::Builtin(Builtin::Gem, "gem")) ed.set_current("gem")
}), }),
MenuEntry::glyph('h', Archetype::Builtin(Builtin::Heart, "heart").default_glyph(), "Heart", MenuEntry::glyph('h', Builtin::Heart.default_glyph_for("heart"), "Heart",
|ed| ed.set_current(Archetype::Builtin(Builtin::Heart, "heart"))), |ed| ed.set_current("heart")),
MenuEntry::Separator, MenuEntry::Separator,
MenuEntry::glyph('b', KeyType::Blue.glyph(), "Blue key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_blue"))), MenuEntry::glyph('b', KeyType::Blue.glyph(), "Blue key", |ed| ed.set_current("key_blue")),
MenuEntry::glyph('g', KeyType::Green.glyph(),"Green key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_green"))), MenuEntry::glyph('g', KeyType::Green.glyph(),"Green key", |ed| ed.set_current("key_green")),
MenuEntry::glyph('c', KeyType::Cyan.glyph(),"Cyan key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_cyan"))), MenuEntry::glyph('c', KeyType::Cyan.glyph(),"Cyan key", |ed| ed.set_current("key_cyan")),
MenuEntry::glyph('r', KeyType::Red.glyph(),"Red key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_red"))), MenuEntry::glyph('r', KeyType::Red.glyph(),"Red key", |ed| ed.set_current("key_red")),
MenuEntry::glyph('p', KeyType::Purple.glyph(),"Purple key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_purple"))), MenuEntry::glyph('p', KeyType::Purple.glyph(),"Purple key", |ed| ed.set_current("key_purple")),
MenuEntry::glyph('o', KeyType::Orange.glyph(), "Orange key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_orange"))), MenuEntry::glyph('o', KeyType::Orange.glyph(), "Orange key", |ed| ed.set_current("key_orange")),
MenuEntry::glyph('y', KeyType::Yellow.glyph(), "Yellow key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_yellow"))), MenuEntry::glyph('y', KeyType::Yellow.glyph(), "Yellow key", |ed| ed.set_current("key_yellow")),
MenuEntry::glyph('w', KeyType::White.glyph(), "White key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_white"))), MenuEntry::glyph('w', KeyType::White.glyph(), "White key", |ed| ed.set_current("key_white")),
], ],
} }
} }
+5 -4
View File
@@ -36,7 +36,8 @@ pub struct BoardWidget<'a> {
impl<'a> BoardWidget<'a> { impl<'a> BoardWidget<'a> {
/// Creates a widget that renders `board`, scrolling to follow the player. /// Creates a widget that renders `board`, scrolling to follow the player.
pub fn new(board: &'a Board) -> Self { pub fn new(board: &'a Board) -> Self {
let focus = (board.player.x as i32, board.player.y as i32); let (x, y) = board.player_pos();
let focus = (x as i32, y as i32);
Self { board, focus, fov: None } Self { board, focus, fov: None }
} }
@@ -180,11 +181,11 @@ mod tests {
use super::{BoardWidget, DARKNESS_BG}; use super::{BoardWidget, DARKNESS_BG};
use crate::utils::rgba8_to_color; use crate::utils::rgba8_to_color;
use kiln_core::Board; use kiln_core::Board;
use kiln_core::map_file::MapFile;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use kiln_core::board_spec::BoardSpec;
/// Builds a tiny dark board: a 5×1 corridor with the player at the left end /// Builds a tiny dark board: a 5×1 corridor with the player at the left end
/// and a wall at x=2 occluding the two cells behind it. /// and a wall at x=2 occluding the two cells behind it.
@@ -201,7 +202,7 @@ mod tests {
"@" = { kind = "player" } "@" = { kind = "player" }
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#404040" } "#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#404040" }
"##; "##;
let mf: MapFile = toml::from_str(toml).unwrap(); let mf: BoardSpec = toml::from_str(toml).unwrap();
Board::try_from(mf).unwrap() Board::try_from(mf).unwrap()
} }
@@ -255,7 +256,7 @@ mod tests {
[grid.palette] [grid.palette]
"L" = { kind = "object", tile = 1, fg = "#ff0000", bg = "#000000", solid = false, light = 2 } "L" = { kind = "object", tile = 1, fg = "#ff0000", bg = "#000000", solid = false, light = 2 }
"##; "##;
let board = Board::try_from(toml::from_str::<MapFile>(toml).unwrap()).unwrap(); let board = Board::try_from(toml::from_str::<BoardSpec>(toml).unwrap()).unwrap();
let fov = board.lighting(0); // no player torch — only the object lights let fov = board.lighting(0); // no player torch — only the object lights
let area = Rect::new(0, 0, 6, 1); let area = Rect::new(0, 0, 6, 1);
+10 -7
View File
@@ -113,11 +113,12 @@ impl Widget for SpeechBubblesWidget<'_> {
.bg(Color::Rgb(20, 20, 40)); .bg(Color::Rgb(20, 20, 40));
// Determine the player's screen position for bubble avoidance. // Determine the player's screen position for bubble avoidance.
let player = self.board.player_pos();
let (px, py, player_vis) = board_screen_pos( let (px, py, player_vis) = board_screen_pos(
area, area,
self.board, self.board,
self.board.player.x.max(0) as usize, player.0,
self.board.player.y.max(0) as usize, player.1,
); );
let player = player_vis.then_some((px, py)); let player = player_vis.then_some((px, py));
@@ -126,11 +127,12 @@ impl Widget for SpeechBubblesWidget<'_> {
.bubbles .bubbles
.iter() .iter()
.filter_map(|b| { .filter_map(|b| {
let obj = self.board.objects.get(&b.object_id)?; let obj = self.board.get_hookable(b.object_id)?;
let (sx, sy, on_screen) = board_screen_pos(area, self.board, obj.x, obj.y); let (x, y) = obj.location();
let (sx, sy, on_screen) = board_screen_pos(area, self.board, x, y);
// On a dark board, a speaker the player can't see is treated like an // On a dark board, a speaker the player can't see is treated like an
// off-screen speaker: the box still draws, but its tail is suppressed. // off-screen speaker: the box still draws, but its tail is suppressed.
let visible = self.fov.is_none_or(|v| v.is_visible(obj.x, obj.y)); let visible = self.fov.is_none_or(|v| v.is_visible(x, y));
Some((sx, sy, on_screen && visible, b)) Some((sx, sy, on_screen && visible, b))
}) })
.collect(); .collect();
@@ -423,8 +425,9 @@ fn center_top(obj_sy: u16, box_h: u16, area: Rect) -> u16 {
/// Returns `(sx, sy, on_screen)`. Off-screen coordinates are clamped to the viewport edge; /// Returns `(sx, sy, on_screen)`. Off-screen coordinates are clamped to the viewport edge;
/// `on_screen` is `false` when clamping occurred (tail should be suppressed for that bubble). /// `on_screen` is `false` when clamping occurred (tail should be suppressed for that bubble).
fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> (u16, u16, bool) { fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> (u16, u16, bool) {
let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x as i32); let player = board.player_pos();
let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y as i32); let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, player.0 as i32);
let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, player.1 as i32);
let raw_sx = area.x as i32 + pad_x as i32 + (bx as i32 - off_x as i32); let raw_sx = area.x as i32 + pad_x as i32 + (bx as i32 - off_x as i32);
let raw_sy = area.y as i32 + pad_y as i32 + (by as i32 - off_y as i32); let raw_sy = area.y as i32 + pad_y as i32 + (by as i32 - off_y as i32);
let sx = raw_sx.clamp(area.left() as i32, area.right() as i32 - 1) as u16; let sx = raw_sx.clamp(area.left() as i32, area.right() as i32 - 1) as u16;
+2 -2
View File
@@ -7,7 +7,7 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use crate::utils::rgba8_to_color; use crate::utils::rgba8_to_color;
use kiln_core::{Archetype, Builtin}; use kiln_core::Builtin;
use kiln_core::cp437::tile_to_char; use kiln_core::cp437::tile_to_char;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
@@ -44,7 +44,7 @@ impl Widget for StatusSidebarWidget {
// Draw the gem indicator from the gem archetype's default glyph, so the // Draw the gem indicator from the gem archetype's default glyph, so the
// sidebar matches what gems look like on the board (no hardcoded ♦/color). // sidebar matches what gems look like on the board (no hardcoded ♦/color).
let gem_glyph = Archetype::Builtin(Builtin::Gem, "gem").default_glyph(); let gem_glyph = Builtin::Gem.default_glyph_for("gem");
let gem_char = tile_to_char(gem_glyph.tile).to_string(); let gem_char = tile_to_char(gem_glyph.tile).to_string();
let gem_style = Style::default().fg(rgba8_to_color(gem_glyph.fg)); let gem_style = Style::default().fg(rgba8_to_color(gem_glyph.fg));
+1 -1
View File
@@ -191,7 +191,7 @@ width = 60
height = 25 height = 25
# A single procedural grass floor across the whole board (the old mixed # A single procedural grass floor across the whole board (the old mixed
# grass/dirt/stone/water floor is not representable with one floor attribute). # grass/dirt/stone/water floor is not representable with one floor attribute).
floor = { generator = "grass" } floor = { biome = "grass" }
# The single grid: all solids and most non-solids (terrain, the player, objects and # The single grid: all solids and most non-solids (terrain, the player, objects and
# the portal). A space is always a transparent empty cell, so the floor shows through. # the portal). A space is always a transparent empty cell, so the floor shows through.
+8 -10
View File
@@ -21,16 +21,13 @@ fn tick(me, dt) {
} }
""" """
[boards.start.map] [boards.start]
name = "Starting Room" name = "Starting Room"
width = 21 width = 21
height = 6 height = 6
# No floor attribute → a blank (black) floor. # No floor attribute → a blank (black) floor.
# The single grid: all solids and most non-solids. A space is always a transparent grid = """
# empty cell, so the floor shows through.
[boards.start.grid]
content = """
##################### #####################
# # # #
# G @ # # G @ #
@@ -38,8 +35,9 @@ content = """
# # # #
##################### #####################
""" """
[boards.start.grid.palette] [boards.start.palette]
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" } "#" = { type = "builtin", kind = "wall" }
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } "o" = { type = "builtin", kind = "crate", glyph = { tile = 254, fg = "#aaaaaa", bg = "#000000" } }
"@" = { kind = "player" } "@" = { type = "player" }
"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000", solid = false, script_name = "greeter" } # "G" = { type = "object", tile = 35, fg = "#aa3333", bg = "#000000", enter = "block", script_name = "greeter" }
"G" = { type = "builtin", kind = "gem" }