diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index 0a24176..fc656e7 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -9,6 +9,7 @@ use std::fmt::Debug; use crate::utils::{Direction, ObjectId}; use color::Rgba8; use rhai::Dynamic; +use crate::Board; /// How long a move occupies an object before it can act again, in seconds. pub(crate) const MOVE_COST: f64 = 0.25; @@ -159,4 +160,63 @@ pub struct BoardAction { pub(crate) source: ObjectId, /// The action to apply. 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 { + 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(()) } \ No newline at end of file diff --git a/kiln-core/src/api/board.rs b/kiln-core/src/api/board.rs index 8b161a3..0f34464 100644 --- a/kiln-core/src/api/board.rs +++ b/kiln-core/src/api/board.rs @@ -62,35 +62,21 @@ impl Registerable for BoardRef { // Board.named(name) -> ObjectInfo | () engine.register_fn("named", move |board_ref: &mut BoardRef, name: ImmutableString| -> Dynamic { - let board = board_ref.borrow(); - board - .objects - .iter() - .find_map(|(_id, def)| { - 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) - }, - ); + let board = board_ref.borrow(); + if let Some(obj) = board.get_named(name.as_str()) { + Dynamic::from(ObjectInfo::from_hookable(obj, board_ref.clone())) + } else { + Dynamic::UNIT + } + }); // Board.tagged(tag) -> Array[ObjectInfo] engine.register_fn("tagged", move |board_ref: &mut BoardRef, tag: ImmutableString| -> rhai::Array { - let board = board_ref.borrow(); - board - .objects.values().filter_map(|def| { - if def.tags.contains(tag.as_str()) { - Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone()))) - } else { - None - } - }) - .collect() - }, - ); + let board = board_ref.borrow(); + board.get_tagged(tag.as_str()).into_iter().map(|obj| { + Dynamic::from(ObjectInfo::from_hookable(obj, board_ref.clone())) + }).collect() + }); // Board.registry -> Registry engine.register_get("registry", |b: &mut BoardRef| Registry(b.clone())); diff --git a/kiln-core/src/api/object_info.rs b/kiln-core/src/api/object_info.rs index 0347196..a1c2cee 100644 --- a/kiln-core/src/api/object_info.rs +++ b/kiln-core/src/api/object_info.rs @@ -24,11 +24,12 @@ use rhai::{Dynamic, Engine}; use crate::api::board::BoardRef; use crate::api::queue::ObjQueue; -use crate::Direction; +use crate::{Board, Direction}; use crate::action::BoardAction; use crate::object_def::ObjectDef; 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`, /// and `Board.get`. Passed by value — scripts read fields, not a live reference. @@ -48,32 +49,36 @@ pub struct ObjectInfo { impl ObjectInfo { pub fn from_id(id: ObjectId, board: BoardRef) -> Option { let b = board.borrow(); - let obj = b.objects.get(&id)?; - Some(ObjectInfo { - id, - x: obj.x as i64, - y: obj.y as i64, - board: board.clone(), - script_name: obj.script_name.clone(), - queue: obj.queue.clone() - }) + let hookable = b.get_hookable(id)?; + Some(Self::from_hookable(hookable, board.clone())) } - pub fn from_def(obj: &ObjectDef, board: BoardRef) -> ObjectInfo { + pub fn from_hookable(hookable: Box, board: BoardRef) -> ObjectInfo { + let (x, y) = hookable.location(); Self { - id: obj.id, - x: obj.x as i64, - y: obj.y as i64, + id: hookable.id(), + x: x 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(), - script_name: obj.script_name.clone(), - queue: obj.queue.clone() + script_name: obj.scripting.script_name.clone(), + queue: obj.scripting.queue.clone() } } pub fn drain(&mut self, target: &mut Vec, dt: f64) { - let mut b = self.board.borrow_mut(); - if let Some(def) = b.objects.get_mut(&self.id) { - def.queue.drain(self.id, target, dt) + if let Some(scr) = self.board.borrow_mut().scripting_mut(self.id) { + scr.queue.drain(self.id, target, dt) } } } @@ -89,8 +94,7 @@ impl Registerable for ObjectInfo { engine.register_get("name", |o: &mut ObjectInfo| { let board = o.board.borrow(); - let obj = board.objects.get(&o.id); - if let Some(ObjectDef { name: Some(name), ..}) = obj { + if let Some(hookable) = board.get_hookable(o.id) && let Some(name) = hookable.name() { Dynamic::from(name.clone()) } else { Dynamic::UNIT @@ -99,9 +103,8 @@ impl Registerable for ObjectInfo { engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array { let board = o.board.borrow(); - let obj = board.objects.get(&o.id); - if let Some(ObjectDef { tags, .. }) = obj { - tags.iter().map(|t| Dynamic::from(t.clone())).collect() + if let Some(hookable) = board.get_hookable(o.id) { + hookable.tags().iter().map(|t| Dynamic::from(t.clone())).collect() } else { rhai::Array::new() } @@ -109,21 +112,20 @@ impl Registerable for ObjectInfo { engine.register_get("glyph", |o: &mut ObjectInfo| { let board = o.board.borrow(); - let obj = board.objects.get(&o.id).unwrap(); - obj.glyph + let obj = board.get_hookable(o.id).unwrap(); + obj.glyph() }); // me.light: the object's current emitted light radius in cells (0 = none). engine.register_get("light", |o: &mut ObjectInfo| -> i64 { - match o.board.borrow().objects.get(&o.id) { - Some(ObjectDef { behavior: Behavior { glow, .. }, .. }) => *glow as i64, - None => 0, - } + if let Some(hookable) = o.board.borrow().get_hookable(o.id) { + hookable.optics().glow as i64 + } else { 0 } }); engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| { - if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) { - tags.contains(&t) + if let Some(hookable) = o.board.borrow().get_hookable(o.id) { + hookable.scriptable().tags.contains(&t) } else { false } diff --git a/kiln-core/src/api/player.rs b/kiln-core/src/api/player.rs index fee13df..9773858 100644 --- a/kiln-core/src/api/player.rs +++ b/kiln-core/src/api/player.rs @@ -16,7 +16,7 @@ impl Registerable for PlayerWithPos { .register_get("health", |player: &mut PlayerWithPos| player.0.borrow().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("x", |player: &mut PlayerWithPos| player.1.borrow().player.x) - .register_get("y", |player: &mut PlayerWithPos| player.1.borrow().player.y); + .register_get("x", |player: &mut PlayerWithPos| player.1.borrow().player_pos().0) + .register_get("y", |player: &mut PlayerWithPos| player.1.borrow().player_pos().1); } } diff --git a/kiln-core/src/archetype.rs b/kiln-core/src/archetype.rs deleted file mode 100644 index 527017e..0000000 --- a/kiln-core/src/archetype.rs +++ /dev/null @@ -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_` 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/.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_` 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_` 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 { - // 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" - ); - } - } -} diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 7d7a9b4..68b9562 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -1,47 +1,12 @@ -use crate::archetype::Archetype; use crate::floor::Floor; -use crate::fov::{FovCaster, Lighting, color_to_rgb}; +use crate::fov::{color_to_rgb, FovCaster, Lighting}; use crate::glyph::Glyph; use crate::log::LogLine; -use crate::object_def::ObjectDef; use crate::utils::Direction; -use crate::utils::{ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid}; -use std::collections::{BTreeMap, HashMap, HashSet}; -use crate::tile::{EnterResponse, Sensor}; - -/// The result of [`Board::apply_shift`]: any error lines to log, plus the -/// `(from, to)` cell relocations the shift actually performed. -/// -/// The `moves` let the caller fire an `enter` hook on any non-solid object each -/// shifted solid landed on; the direction is derived best-effort from `to - from` -/// (a shift can rotate cells that aren't cardinally adjacent). -pub struct ShiftOutcome { - /// Error lines (e.g. an out-of-bounds cell) for the caller to log. - pub errors: Vec, - /// Each `(from, to)` relocation a non-blocked solid underwent. - pub moves: Vec<((i64, i64), (i64, i64))>, -} - -/// A non-solid `(glyph, archetype)` placed at a board coordinate, **outside** the -/// main grid, drawn only when the grid cell at `(x, y)` is empty. -/// -/// Decorations exist so a single file format can serve both world files and save -/// files: at authoring time every cell holds at most one thing, but during play a -/// runtime solid can end up sharing a cell with a non-solid that was already there. -/// The non-solid is recorded here (the grid keeps the solid). The editor does not -/// place decorations; they are a save/runtime concern. A decoration's archetype is -/// always non-solid (a solid one is rejected at load). -#[derive(Clone)] -pub struct Decoration { - /// Column (0-indexed). - pub x: usize, - /// Row (0-indexed). - pub y: usize, - /// The decoration's visual. - pub glyph: Glyph, - /// The decoration's (non-solid) archetype. - pub archetype: Archetype, -} +use crate::utils::{ObjectId, RegistryValue}; +use std::collections::{HashMap, HashSet}; +use crate::portal::Portal; +use crate::tile::{DrawLayer, EnterResponse, Hookable, IntoTile, LocatedObject, ScriptAttributes, Sensor, Tile, TileSpec}; /// The complete state of one game board (a single room or screen). /// @@ -73,50 +38,29 @@ pub struct Board { pub width: usize, /// Height of the board in cells. pub height: usize, - /// The single row-major grid of `(Glyph, Archetype)` cells (`width * height`), - /// holding every solid and most non-solids. A transparent cell (glyph tile 0) - /// draws nothing, revealing a [`decoration`](Board::decorations) or the + /// The single row-major grid of `Option` cells (`width * height`), + /// holding every solid. A transparent cell (glyph tile 0) + /// draws nothing, revealing a `Sensor` or the /// [`floor`](Board::floor) beneath. Access a cell with [`Board::get`]/ /// [`Board::get_mut`] by `(x, y)`. - pub(crate) grid: Vec<(Glyph, Archetype)>, + pub(crate) grid: Vec>, /// The board's cosmetic floor (blank / one fixed glyph / a biome), drawn beneath - /// everything. Replaces the old dedicated floor layer. + /// everything. pub(crate) floor: Floor, - /// Non-solid things placed off the main grid, drawn only where the grid cell is - /// empty (see [`Decoration`]). Normally empty; populated by save files. - pub(crate) decorations: Vec, /// Non-solid things placed off the main grid, can't affect movement but see other hooks pub sensors: Vec, - /// Current player position on this board. See [`PlayerPos`] for caveats - /// about its future. Game-global player *stats* live in [`crate::player::Player`]. - pub player: PlayerPos, - /// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap` - /// (not a `Vec`) so an object can be removed without invalidating other - /// objects' ids; iteration is in ascending-id order, which equals load order - /// (ids are assigned sequentially as the map loads). - pub objects: BTreeMap, + /// The portals aren't really a kind of sensor, and they can't be on the grid because the player + /// can share a space with them: + pub portals: Vec, /// The next [`ObjectId`] to hand out (starts at 1, monotonically increasing). /// See [`Board::add_object`]. pub next_object_id: ObjectId, - /// Portals on this board. Parsed from the map file; not yet active. - pub portals: Vec, - /// Name of the board-level script in the world script pool, if any. - /// - /// A board script runs on the board as a whole (e.g. `on_enter`, `on_tick`) - /// rather than being tied to a specific object cell. Scripts live in - /// [`World::scripts`](crate::world::World) and are looked up by this name. - pub board_script_name: Option, /// When `true`, this board is "dark": front-ends reveal only the cells the /// player can see and that receive light (see [`Board::lighting`]) and draw /// everything else as unlit darkness. Sight and light are blocked by opaque cells. /// Loaded from / saved to the `dark` key in the map file's `[map]` header; /// defaults to `false` (fully lit). pub dark: bool, - /// Nonfatal problems collected while loading this map (e.g. unknown - /// archetypes, dropped objects, recovered placement chars), as red-on-black - /// [`LogLine`]s. Empty for a clean load; see [`Board::is_valid`]. Not part of - /// the map file (purely a load diagnostic). - pub(crate) load_errors: Vec, /// Per-board key→value store written and read by Rhai scripts via the /// `Registry` scope constant. Persists automatically across board transitions /// because all boards live as `Rc>` in `World::boards` and are @@ -127,29 +71,35 @@ pub struct Board { impl Board { /// Return a list of all `ObjectId`s currently on the board. pub fn all_ids(&self) -> Vec { - self.objects.keys().cloned().collect() + self.grid.iter().filter_map(|cell| { + if let Some(Tile::Object(def)) = cell { + Some(def.scripting.id) + } else { + None + } + }).collect() } /// Returns a reference to the `(Glyph, Archetype)` cell at `(x, y)`. /// /// Panics if `x` or `y` are out of bounds. - pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) { + pub fn get(&self, x: usize, y: usize) -> &Option { &self.grid[y * self.width + x] } /// Returns a mutable reference to the cell at `(x, y)`. /// /// Panics if `x` or `y` are out of bounds. - pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) { + pub fn get_mut(&mut self, x: usize, y: usize) -> &mut Option { let w = self.width; &mut self.grid[y * w + x] } /// Replace the solid terrain (if any) at `(x, y)` with a transparent `Empty` /// cell, revealing the floor beneath. - pub fn clear_solid(&mut self, x: usize, y: usize) { - if self.in_bounds((x as i64, y as i64)) && self.get(x, y).1.behavior().solid { - *self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty); + pub fn clear_cell(&mut self, x: usize, y: usize) { + if self.in_bounds((x as i64, y as i64)) { + *self.get_mut(x, y) = None; } } @@ -170,46 +120,29 @@ impl Board { /// /// Panics if out of bounds. pub fn glyph_at(&self, x: usize, y: usize) -> Glyph { - // The player is rendered above everything (see the `Player` notes). - if self.player.x == x as i64 && self.player.y == y as i64 { - return Glyph::player(); + let grid_glyph = self.get(x, y).as_ref().map(Tile::glyph); + + let sensors = self.sensors.iter().filter(|&s| s.x == x && s.y == y); + + // Is there a sensor above the grid? + if let Some(above) = sensors.clone().find(|s| s.draw_layer == DrawLayer::Above && s.scripting.glyph.tile != 0) { + return above.scripting.glyph; } - // Objects: a solid object always draws; otherwise the first non-transparent - // non-solid object (lets invisible trigger objects exist). - let mut nonsolid: Option = None; - for o in self.objects.values().filter(|o| o.x == x && o.y == y) { - if o.behavior.solid { - return o.glyph; - } - if nonsolid.is_none() && o.glyph.tile != 0 { - nonsolid = Some(o.glyph); - } - } - if let Some(g) = nonsolid { - return g; + // Does the grid have a good glyph? + if let Some(glyph) = grid_glyph && glyph.tile != 0 { + return glyph; } - // The grid cell: a solid always draws; a non-solid only if visible. - let (glyph, arch) = self.get(x, y); - if arch.behavior().solid || glyph.tile != 0 { - return *glyph; + // Is there a sensor below the grid? + if let Some(below) = sensors.clone().find(|s| s.draw_layer == DrawLayer::Below && s.scripting.glyph.tile != 0) { + return below.scripting.glyph; } - // A portal sits on its (transparent) grid cell. - if self.portals.iter().any(|p| p.x == x && p.y == y) { - return PortalDef::default_glyph(); - } - - // The grid cell was empty: a decoration may show here. - if let Some(d) = self.decorations.iter().find(|d| d.x == x && d.y == y) { - return d.glyph; - } - - // Then the floor, else the canonical black empty cell. + // Otherwise the floor, or the canonical black empty cell. self.floor .glyph_at(x, y, self.width) - .unwrap_or_else(|| Archetype::Empty.default_glyph()) + .unwrap_or_else(|| Glyph::transparent()) } /// Returns `true` if `(x, y)` is a valid cell coordinate on this board. @@ -221,50 +154,12 @@ impl Board { x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height } - /// Records a nonfatal error: appends `message` as a red-on-black line to the - /// board's [`load_errors`](Board::load_errors). Used by the map loader (and - /// available at runtime) to surface recoverable problems. - pub fn report_error(&mut self, message: impl Into) { - self.load_errors.push(LogLine::error(message)); - } - - /// Returns `true` if the map loaded with no nonfatal errors (the error list - /// is empty). - pub fn is_valid(&self) -> bool { - self.load_errors.is_empty() - } - - /// Returns the single solid entity occupying `(x, y)`, if any. - /// - /// Checks player first, then objects, then the grid archetype. Because at most one solid - /// may occupy a cell (an invariant enforced when the board is loaded — see - /// [`crate::map_file`]), this returns that one occupant or `None`. - /// Panics if `x` or `y` are out of bounds. - pub fn solid_at(&self, x: usize, y: usize) -> Option { - // The player wins its cell (load-time invariant), so it is the solid there. - if self.player.x == x as i64 && self.player.y == y as i64 { - return Some(Solid::player_at(x, y)); - } - // A solid object shadows the cell it sits on; capture its behavior now. - if let Some(id) = self.solid_object_id_at(x, y) { - let obj = &self.objects[&id]; - let behavior = obj.behavior; - return Some(Solid::object_at(x, y, id, behavior)); - } - // Otherwise the grid cell's terrain archetype may be solid (e.g. a wall). - let (glyph, arch) = *self.get(x, y); - if arch.behavior().solid { - return Some(Solid::terrain_at(x, y, glyph, arch)); - } - None - } - /// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it. /// /// Convenience inverse of [`solid_at`](Board::solid_at). /// Panics if `x` or `y` are out of bounds. pub fn is_passable(&self, x: usize, y: usize) -> bool { - self.solid_at(x, y).is_none() + self.get(x, y).is_none() } /// Returns `true` if cell `(x, y)` blocks line of sight (and light). @@ -274,8 +169,17 @@ impl Board { /// [`dark`](Board::dark) boards; see [`Board::lighting`]. /// Panics if `x` or `y` are out of bounds. pub fn is_opaque_at(&self, x: usize, y: usize) -> bool { - self.get(x, y).1.behavior().opaque - || self.objects.values().any(|o| o.x == x && o.y == y && o.behavior.opaque) + if self.sensors.iter().any(|s| s.x == x && s.y == y && s.scripting.optics.opaque) { + true + } else { + match self.get(x, y) { + None => false, + Some(Tile::Player) => false, + Some(Tile::Object(def)) => { + def.scripting.optics.opaque + } + } + } } /// Computes lighting + line-of-sight for the player on this board. @@ -298,7 +202,8 @@ impl Board { // reused for the LOS pass and every light source (its FOV is cleared per cast). let mut caster = FovCaster::new(w, h, |x, y| !self.is_opaque_at(x, y)); - let (px, py) = (self.player.x as usize, self.player.y as usize); + let (px, py) = self.player_pos(); + // (a) Player line of sight — unbounded (radius 0), pure geometry. caster.cast(px, py, 0, |x, y| lighting.set_los(x, y)); @@ -318,19 +223,14 @@ impl Board { if player_torch > 0 { add_source(&mut lighting, px, py, player_torch, [1.0, 1.0, 1.0]); } - // Light-emitting objects: color = their own glyph foreground. - for o in self.objects.values() { - if o.behavior.glow > 0 { - add_source(&mut lighting, o.x, o.y, o.behavior.glow, color_to_rgb(o.glyph.fg)); - } - } // Glowing terrain (e.g. a `Torch` cell): color = the cell's glyph foreground. for y in 0..h { for x in 0..w { - let (glyph, arch) = self.get(x, y); - let radius = arch.light(); - if radius > 0 { - add_source(&mut lighting, x, y, radius, color_to_rgb(glyph.fg)); + if let Some(Tile::Object(obj)) = self.get(x, y) { + let radius = obj.scripting.optics.glow; + if radius > 0 { + add_source(&mut lighting, x, y, radius, color_to_rgb(obj.scripting.glyph.fg)); + } } } } @@ -339,24 +239,30 @@ impl Board { /// Whether the cell's single solid occupant (if any) can be pushed in `dir`. /// - /// Non-solid things are never pushable: `pushable` only matters for solids. - /// Grid archetypes may restrict the axis (see [`Pushable`]); pushable objects - /// can be shoved in any direction. + /// This is whether the thing in this cell will _transmit_ a push impulse through + /// it. Empty cells and cells that can't be pushed in that direction break the + /// chain. fn is_pushable(&self, x: usize, y: usize, dir: Direction) -> bool { - // The captured `pushable` is `Any` for the player and a pushable object, - // axis-constrained for directional crates, `No` otherwise. - self.solid_at(x, y) - .is_some_and(|s| s.pushable().allows(dir)) + match self.get(x, y) { + Some(Tile::Player) => true, + Some(Tile::Object(obj)) => { + if let EnterResponse::Push(p) = obj.enter_response { + p.allows(dir) + } else { + false + } + } + _ => false, + } } /// The object that a move **into** `(x, y)` heading `dir` bumps, if any. /// /// Walks the chain of pushable crates from the target cell in `dir` until it /// reaches something that stops it, and reports the object it presses against: - /// - a solid object in the target cell (or at the end of a crate chain) is the + /// - a `Block` object in the target cell (or at the end of a pushable chain) is the /// bumped object, /// - open space or the player means nothing is bumped, - /// - a non-pushable terrain cell (a wall) blocks the chain with no object to bump. /// /// This is what lets *any* solid — the player, another object, or a pushed /// crate — trigger a `bump`: the bumper need not be an object, since we only @@ -366,33 +272,19 @@ impl Board { let (dx, dy): (i64, i64) = dir.into(); let (mut cx, mut cy) = (x, y); loop { - // Open space ends the walk with nothing bumped. - let solid = self.solid_at(cx, cy)?; - // A solid object — directly, or at the end of a crate chain — is the target. - if let Some(id) = solid.object_id() { - let obj = self.objects.get(&id)?; - let resp = EnterResponse::from(obj.behavior); - if let EnterResponse::Push(p) = resp && !p.allows(dir) { - // This object can't be pushed that way, so, this is a bump. If it's pushable, the - // default push behavior happens (it just gets pushed) - return Some(id); - } else if resp == EnterResponse::Block { - // It's just a wall, block everything - return Some(id); + if !self.in_bounds((cx as i64, cy as i64)) { + return None; // push chain runs off the board + } + + if let Some(Tile::Object(obj)) = self.get(cx, cy) { + if obj.enter_response.transmits_push(dir) { + // An object we can push through, go to the next cell + cx = (cx as i64 + dx) as usize; + cy = (cy as i64 + dy) as usize; + } else if obj.enter_response.bumpable(dir) { + return Some(obj.scripting.id) } } - // The player is never itself "bumped"; a wall (non-pushable terrain) stops - // the chain with no object behind it. Only a pushable crate is walked - // through — reuse the already-fetched `solid` rather than re-scanning. - if solid.player() || !solid.pushable().allows(dir) { - return None; - } - let next = (cx as i64 + dx, cy as i64 + dy); - if !self.in_bounds(next) { - return None; // crate chain runs off the board - } - cx = next.0 as usize; - cy = next.1 as usize; } } @@ -453,7 +345,7 @@ impl Board { // The player always blocks a shift: a shift can't relocate it and // `apply_swap` refuses to overwrite it. (The player reads as pushable, so it // must be excluded explicitly before the cell-ahead test below.) - if self.solid_at(nx, ny).is_some_and(|s| s.player()) { + if self.get(nx, ny).as_ref().is_some_and(Tile::player) { return false; } // The cell ahead is acceptable if it is empty or another pushable solid. @@ -501,25 +393,11 @@ impl Board { /// caller guarantees the destination is already clear. fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) { let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize); - let Some(solid) = self.solid_at(x, y) else { + if self.get(x, y).is_none() { return; // nothing to shift - }; - // A terrain cell leaves a transparent cell behind (revealing any floor); the - // player and objects carry no grid cell, so there is nothing to vacate. `place` - // captured the glyph/arch, so clearing the source first is safe. - if self.get(x, y).1.behavior().solid { - *self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty); } - solid.place(self, tx, ty); - } - /// Returns the [`ObjectId`]s of the objects at `(x, y)`, if any. - pub fn object_ids_at(&self, x: usize, y: usize) -> Vec { - self.objects - .iter() - .filter(|(_, o)| o.x == x && o.y == y) - .map(|(&id, _)| id) - .collect() + *self.get_mut(tx, ty) = self.get_mut(x, y).take(); } /// Returns the [`ObjectId`]s of the **non-solid** objects at `(x, y)`. @@ -527,62 +405,17 @@ impl Board { /// These are the targets of an `enter` hook when a solid relocates onto the /// cell (terrain is always solid, so only objects can be non-solid). Mirrors /// [`object_ids_at`](Board::object_ids_at) / [`solid_object_id_at`](Board::solid_object_id_at). - pub fn non_solid_object_ids_at(&self, x: usize, y: usize) -> Vec { - self.objects + pub fn sensor_ids_at(&self, x: usize, y: usize) -> Vec { + self.sensors .iter() - .filter(|(_, o)| o.x == x && o.y == y && !o.behavior.solid) - .map(|(&id, _)| id) + .filter(|s| s.x == x && s.y == y) + .map(|s| s.scripting.id) .collect() } - /// Returns a borrow of the actual object at `(x, y)` if any - pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option { - self.objects.iter().find_map(|(&id, o)| { - if o.x == x && o.y == y && o.behavior.solid { - Some(id) - } else { - None - } - }) - } - - /// Returns the [`ObjectId`] of a solid, **grab**bable object at `(x, y)`, if any. - /// - /// Used by [`GameState::try_move`](crate::game::GameState::try_move) to detect - /// the player walking onto a grab thing (e.g. a gem): the move isn't blocked, - /// the object's `grab()` hook fires instead. - pub fn grab_object_at(&self, x: usize, y: usize) -> Option { - self.objects.iter().find_map(|(&id, o)| { - if o.x == x && o.y == y && EnterResponse::from(o.behavior) == EnterResponse::Grab { - Some(id) - } else { - None - } - }) - } - - /// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id. - /// - /// Ids start at 1 and increase monotonically; an id is never reused, so it - /// stays a valid handle to this object for the board's lifetime. - pub fn add_object(&mut self, mut object: ObjectDef) -> ObjectId { - let id = self.next_object_id; - self.next_object_id += 1; - object.id = id; - self.objects.insert(id, object); - id - } - - /// Removes the object with `id`, returning its [`ObjectDef`] if it existed. - /// - /// This only touches the board's `objects` map. A live [`ScriptHost`] built - /// before the removal keeps a stale `ObjectRuntime` for the gone object; its - /// subsequent host-fn calls resolve to a missing id and become no-ops, so the - /// removal is benign even mid-game (see CLAUDE.md's runtime spawn/destroy note). - /// - /// [`ScriptHost`]: crate::script::ScriptHost - pub fn remove_object(&mut self, id: ObjectId) -> Option { - self.objects.remove(&id) + /// Find and return the portal at the given location + pub fn portal_at(&self, x: usize, y: usize) -> Option<&Portal> { + self.portals.iter().find(|&portal| portal.location() == (x, y)) } /// Editor primitive: stamps `arch` (with visual `glyph`) into the cell at @@ -598,105 +431,34 @@ impl Board { /// /// A vacated grid cell becomes a transparent `Empty` so the floor shows through. /// Panics if `(x, y)` is out of bounds. - pub fn place_archetype(&mut self, x: usize, y: usize, arch: Archetype, glyph: Glyph) { - if arch == Archetype::Empty { - // Erase: drop every object in the cell and clear its grid cell (keep floor). - for id in self.object_ids_at(x, y) { - self.objects.remove(&id); - } - *self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty); - return; + pub fn place(&mut self, x: usize, y: usize, spec: Option) -> Result<(), String> { + if let Some(spec) = spec { + let tile = spec.into_tile(&mut self.next_object_id)?; + *self.get_mut(x, y) = Some(tile); + } else { + *self.get_mut(x, y) = None; } - - // Placing solid terrain: a solid object can't share the cell, so drop it. - if let Some(id) = self.solid_object_id_at(x, y) { - self.objects.remove(&id); - } - *self.get_mut(x, y) = (glyph, arch); + Ok(()) } - /// Replaces every terrain cell whose archetype is script-backed (e.g. a - /// `Spinner` or `Pusher`) with the scripted object it expands to — the same - /// transformation the map loader applies in [`layer::build_layer`](crate::layer), - /// but run against a live, already-built board. - /// - /// The editor stamps these archetypes as plain terrain cells (via - /// [`place_archetype`](Board::place_archetype)); they only come alive once - /// expanded into objects carrying their embedded script + `BUILTIN_*` tag. Call - /// this before running a board assembled in memory (e.g. entering a playtest), so - /// editor-placed machines actually run. A save→reload round-trip expands them via - /// the normal load path, so this is only needed for the in-memory path. Cells - /// already loaded as objects are untouched, so it is safe to call more than once. - pub fn expand_builtin_archetypes(&mut self) { - use crate::builtin_scripts::builtin_tag; - // Collect first: the loop below mutates both the grid and the object map. - let mut found: Vec<(usize, usize, Glyph, Archetype)> = Vec::new(); - for y in 0..self.height { - for x in 0..self.width { - let (glyph, arch) = *self.get(x, y); - if matches!(arch, Archetype::Builtin(_, _)) { - found.push((x, y, glyph, arch)); - } - } - } - for (x, y, glyph, arch) in found { - // Vacate the grid cell (revealing any floor beneath), then spawn the - // object — mirroring `resolve_entry`'s object template. - *self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty); - let Archetype::Builtin(b, _alias) = arch else { continue }; - let beh = b.behavior(); - let mut obj = ObjectDef::new(x, y); - obj.glyph = glyph; - obj.behavior = beh; - obj.builtin_script = Some(b.script()); - // The compile-cache key and the BUILTIN_* tag are both derived from the - // alias (e.g. "BUILTIN_pusher_east"), so each alias gets its own cached - // AST — a tiny bit less sharing than before, but the source is identical. - let tag = builtin_tag(arch); - obj.script_name = Some(tag.clone()); - obj.tags.insert(tag); - self.add_object(obj); - } - } - /// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script /// `shift()` fn. Returns a [`ShiftOutcome`] carrying any error [`LogLine`]s for /// the caller to log plus the `(from, to)` relocations it performed (so the /// caller can fire `enter` on non-solids each moved solid landed on). - pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> ShiftOutcome { + pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> Result, String> { // Validate all the cells are in bounds, error if not: if cells.iter().any(|&c| !self.in_bounds(c)) { - return ShiftOutcome { - errors: vec![LogLine::error("Called shift() with a cell out of bounds")], - moves: Vec::new(), - }; + return Err("Called shift() with a cell out of bounds".to_string()) } // Get all the Solids at these cells: - let solids: Vec<_> = cells.iter().map(|&c| self.solid_at(c.0 as usize, c.1 as usize)).collect(); - - // Tell whether these are normally pushable in this direction. This doesn't count whether - // their target zone will be empty, just whether they would be willing to move that way at - // all. The direction comes in because of hcrates / vcrates: if their target cell is adjacent, - // then we'll check directions - //let mut pushable: Vec = Vec::with_capacity(solids.len()); + let solids: Vec<_> = cells.iter().map(|&c| self.get_mut(c.0 as usize, c.1 as usize).take()).collect(); + // Find which ones are blockers let mut immobile = HashSet::new(); for (curr_idx, curr) in solids.iter().enumerate() { - let origin = cells[curr_idx]; - let target = cells[(curr_idx + 1) % cells.len()]; - - // Whether these represent a single-cell h or v move. - let hmove = target.1 == origin.1 && (target.0 - origin.0).abs() == 1; - let vmove = target.0 == origin.0 && (target.1 - origin.1).abs() == 1; - - // We are never allowed to push a thing that won't push - // We won't push a horizontal-only thing vertically - // We won't push a vertical-only thing horizontally - let pushable = curr.as_ref().map_or(Pushable::Any, |c| c.pushable()); - if pushable == Pushable::No || - pushable == Pushable::Vertical && hmove || - pushable == Pushable::Horizontal && vmove { + let pushable = curr.as_ref().map_or(true, |c| c.shiftable()); + if !pushable { immobile.insert(curr_idx); } } @@ -715,52 +477,158 @@ impl Board { } } - // Clear all the cells so everything can be placed: - for (curr_idx, curr) in cells.iter().enumerate() { - if !blocked.contains(&curr_idx) { - self.clear_solid(curr.0 as usize, curr.1 as usize); - } - } - // Now, move anything that we've decided is not blocked, recording each // relocation so the caller can fire `enter` at every destination. let mut moves = Vec::new(); - for (curr_idx, curr) in solids.iter().enumerate() { - if let Some(solid) = curr && !blocked.contains(&curr_idx) { - let origin = cells[curr_idx]; - let target = cells[(curr_idx + 1) % cells.len()]; - solid.place(self, target.0 as usize, target.1 as usize); - moves.push((origin, target)); + for (curr_idx, curr) in solids.into_iter().enumerate() { + if let Some(solid) = curr { + if !blocked.contains(&curr_idx) { + // Not blocked, write it to target + let origin = cells[curr_idx]; + let target = cells[(curr_idx + 1) % cells.len()]; + *self.get_mut(target.0 as usize, target.1 as usize) = Some(solid); + moves.push((origin, target)); + } else { + // it was blocked so just write it back where it was + let origin = cells[curr_idx]; + *self.get_mut(origin.0 as usize, origin.1 as usize) = Some(solid); + } } } - ShiftOutcome { errors: Vec::new(), moves } + Ok(moves) } /// Clear the queues of all objects on this board: called when entering a board, objects /// don't retain their state across board visits (they get initialized again, but can /// store things in the board registry) pub fn clear_all_queues(&mut self) { - for obj in self.objects.values_mut() { - obj.queue.clear() + for cell in self.grid.iter_mut() { + if let Some(Tile::Object(obj)) = cell { + obj.scripting.queue.clear() + } + } + } + + pub fn player_pos(&self) -> (usize, usize) { + self.grid.iter().enumerate().find_map(|(i, cell)| { + if matches!(cell, Some(Tile::Player)) { + Some((i % self.width, i / self.width)) + } else { None } + }).expect("No player found!") // This should never happen, player presence is validated when building a board + } + + pub fn get_hookable(&self, id: ObjectId) -> Option> { + // Search sensors first because it's probably shorter + for sensor in self.sensors.iter() { + if sensor.scripting.id == id { + return Some(Box::new(sensor)) + } + } + + for (i, tile) in self.grid.iter().enumerate() { + if let Some(Tile::Object(obj)) = tile && obj.scripting.id == id { + return Some(Box::new(LocatedObject(obj, (i % self.width, i / self.width)))) + } + } + + None + } + + pub fn get_named(&self, name: &str) -> Option> { + // Search sensors first because it's probably shorter + for sensor in self.sensors.iter() { + if let Some(n) = sensor.scripting.name.as_ref() && + n.as_str() == name { + return Some(Box::new(sensor)) + } + } + + for (i, tile) in self.grid.iter().enumerate() { + if let Some(Tile::Object(obj)) = tile && + let Some(n) = obj.scripting.name.as_ref() && + n.as_str() == name { + return Some(Box::new(LocatedObject(obj, (i % self.width, i / self.width)))) + } + } + + None + } + + pub fn get_tagged(&self, tag: &str) -> Vec> { + self.sorted_hookables().into_iter().filter(|hookable| { + hookable.scriptable().tags.contains(tag) + }).collect() + } + + pub fn scripting_mut(&mut self, id: ObjectId) -> Option<&mut ScriptAttributes> { + for sensor in self.sensors.iter_mut() { + if sensor.scripting.id == id { + return Some(&mut sensor.scripting) + } + } + + for tile in self.grid.iter_mut() { + if let Some(Tile::Object(obj)) = tile && obj.scripting.id == id { + return Some(&mut obj.scripting) + } + } + + None + } + + pub fn sorted_hookables(&self) -> Vec> { + // Collect all the sensors + let mut hookables = self.sensors.iter().map(|s| Box::new(s) as Box).collect::>(); + + // Add the objects into it + for (i, tile) in self.grid.iter().enumerate() { + if let Some(Tile::Object(obj)) = tile { + hookables.push(Box::new(LocatedObject(obj, (i % self.width, i / self.width)))) + } + } + + // Sort by id + hookables.sort_by(|a, b| a.id().cmp(&b.id())); + hookables + } + + pub fn remove_object(&mut self, id: ObjectId) { + if let Some(tile) = self.grid.iter_mut().find(|cell| { matches!(cell, Some(Tile::Object(obj)) if obj.scripting.id == id) }) { + tile.take(); + } else { + self.sensors.retain(|s| s.id() != id); + } + } + + pub fn named_portal(&self, name: &str) -> Option<&Portal> { + self.portals.iter().find(|p| p.name == name) + } + + pub fn move_sensor(&mut self, id: ObjectId, dir: Direction) { + if let Some((sensor_idx, _)) = self.sensors.iter().enumerate().find(|(idx, s)| s.scripting.id == id) { + let new_loc = (self.sensors[sensor_idx].x as i64 + dir.dx(), self.sensors[sensor_idx].y as i64 + dir.dy()); + if self.in_bounds(new_loc) { + self.sensors[sensor_idx].x = new_loc.0 as usize; + self.sensors[sensor_idx].y = new_loc.1 as usize; + } } } } #[cfg(test)] pub(crate) mod tests { + use std::assert_matches; use super::Board; - use crate::archetype::{Archetype, Builtin}; + use crate::builtin::Builtin; use crate::floor::Floor; use crate::glyph::Glyph; - use crate::object_def::ObjectDef; - use crate::utils::{Direction, Pushable}; - use crate::utils::{ObjectId, PlayerPos}; + use crate::utils::Direction; use color::Rgba8; - use std::collections::{BTreeMap, HashMap}; + use std::collections::HashMap; + use crate::tile::{DrawLayer, IntoTile, Optics, ScriptAttributes, Sensor, Tile, TileSpec}; - /// Builds an all-empty `w×h` board with the given player position and objects. - /// Assigns sequential ids (1..=n) to objects. + /// Builds an all-empty `w×h` board. /// /// The grid is fully transparent (a blank floor), so terrain stamped via /// [`crate_at`] etc. lands on the single grid. Use [`add_floor`] to give the @@ -768,36 +636,22 @@ pub(crate) mod tests { pub(crate) fn open_board( w: usize, h: usize, - player: (i64, i64), - objects: Vec, + player_pos: (usize, usize) ) -> Board { - let mut object_map: BTreeMap = BTreeMap::new(); - let mut next_object_id: ObjectId = 1; - for mut o in objects { - o.id = next_object_id; - object_map.insert(next_object_id, o); - next_object_id += 1; - } - Board { + let mut board = Board { name: "test".into(), width: w, height: h, - grid: vec![(Glyph::transparent(), Archetype::Empty); w * h], + grid: vec![None; w * h], floor: Floor::Blank, - decorations: Vec::new(), sensors: Vec::new(), - player: PlayerPos { - x: player.0, - y: player.1, - }, - objects: object_map, - next_object_id, portals: Vec::new(), - board_script_name: None, + next_object_id: 1, dark: false, - load_errors: Vec::new(), registry: HashMap::new(), - } + }; + player_at(&mut board, player_pos.0, player_pos.1); + board } /// Gives the board a uniform fixed floor glyph (the single-grid replacement for @@ -808,73 +662,52 @@ pub(crate) mod tests { /// Stamps a crate cell onto the grid. pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) { - *board.get_mut(x, y) = (Archetype::Builtin(Builtin::Crate, "crate").default_glyph(), Archetype::Builtin(Builtin::Crate, "crate")); + *board.get_mut(x, y) = Some(TileSpec::krate().into_tile(&mut board.next_object_id).unwrap()); } /// Stamps a wall cell onto the grid. pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) { - *board.get_mut(x, y) = (Builtin::Wall.default_glyph_for("wall"), Archetype::Builtin(Builtin::Wall, "wall")); + *board.get_mut(x, y) = Some(TileSpec::wall().into_tile(&mut board.next_object_id).unwrap()); } - /// Stamps an arbitrary archetype cell onto the grid. - pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) { - *board.get_mut(x, y) = (arch.default_glyph(), arch); + /// Stamps a gem cell onto the grid. + pub(crate) fn gem_at(board: &mut Board, x: usize, y: usize) { + *board.get_mut(x, y) = Some(TileSpec::gem().into_tile(&mut board.next_object_id).unwrap()); } - #[test] - fn solid_at_reports_wall_object_and_empty() { - let mut board = open_board(4, 1, (3, 0), vec![]); - wall_at(&mut board, 1, 0); - board.add_object(ObjectDef::new(2, 0)); - - assert!(board.solid_at(0, 0).is_none()); - assert!(board.is_passable(0, 0)); - - let wall = board.solid_at(1, 0).expect("a wall is solid"); - assert_eq!(wall.archetype(), Some(Archetype::Builtin(Builtin::Wall, "wall"))); - assert!(!board.is_passable(1, 0)); - - let obj = board.solid_at(2, 0).expect("an object is solid"); - let id = obj.object_id().expect("expected a solid object"); - assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0)); - assert!(!board.is_passable(2, 0)); + /// Stamps a player cell onto the grid. + pub(crate) fn player_at(board: &mut Board, x: usize, y: usize) { + *board.get_mut(x, y) = Some(TileSpec::player().into_tile(&mut board.next_object_id).unwrap()); } - #[test] - fn grab_object_at_detects_a_gem() { - // A grabbable gem object at (1,0); the player at (2,0). - let mut gem = ObjectDef::new(1, 0); - gem.behavior.grab = true; - gem.behavior.pushable = Pushable::Any; - let board = open_board(3, 1, (2, 0), vec![gem]); - - // grab_object_at finds the gem on its own cell, nowhere else. - assert_eq!(board.grab_object_at(1, 0), Some(1)); - assert_eq!(board.grab_object_at(0, 0), None); + pub(crate) fn lamp_at(board: &mut Board, x: usize, y: usize) { + let lamp = Sensor { + x, + y, + draw_layer: DrawLayer::Above, + scripting: ScriptAttributes { + id: board.next_object_id, + glyph: Glyph { tile: 1, fg: Rgba8 { r: 255, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } }, + optics: Optics { + glow: 4, + opaque: false + }, + ..Default::default() + } + }; + board.next_object_id += 1; + board.sensors.push(lamp); } - #[test] - fn non_solid_object_does_not_block() { - let mut obj = ObjectDef::new(1, 0); - obj.behavior.solid = false; - let board = open_board(3, 1, (0, 0), vec![obj]); - assert!(board.solid_at(1, 0).is_none()); - assert!(board.is_passable(1, 0)); - } - - #[test] - fn solid_at_reports_player() { - let board = open_board(3, 1, (1, 0), vec![]); - assert!( - board.solid_at(1, 0).is_some_and(|s| s.player()), - "expected the player at its own cell" - ); - assert!(!board.is_passable(1, 0)); + pub(crate) fn is_builtin(board: &Board, x: usize, y: usize, tag: &str) -> bool { + if let Some(Tile::Object(obj)) = board.get(x, y) { + obj.scripting.tags.contains(&format!("BUILTIN_{tag}")) + } else { false } } #[test] fn in_bounds_checks_grid_boundaries() { - let board = open_board(3, 2, (0, 0), vec![]); + let board = open_board(3, 2, (0, 0)); assert!(board.in_bounds((0, 0))); assert!(board.in_bounds((2, 1))); assert!(!board.in_bounds((-1, 0))); @@ -885,13 +718,13 @@ pub(crate) mod tests { #[test] fn can_push_is_read_only_and_correct() { - let mut board = open_board(3, 1, (0, 0), vec![]); + let mut board = open_board(3, 1, (0, 0)); crate_at(&mut board, 1, 0); assert!(board.can_push(1, 0, Direction::East)); - assert_eq!(board.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // no mutation - assert_eq!(board.get(2, 0).1, Archetype::Empty); + assert_matches!(board.get(1, 0), Some(Tile::Object(_))); + assert_matches!(board.get(2, 0), None); - let mut board = open_board(3, 1, (0, 0), vec![]); + let mut board = open_board(3, 1, (0, 0)); crate_at(&mut board, 1, 0); wall_at(&mut board, 2, 0); assert!(!board.can_push(1, 0, Direction::East)); @@ -900,17 +733,17 @@ pub(crate) mod tests { #[test] fn can_shift_only_checks_the_cell_ahead() { // Source must be pushable. - let mut board = open_board(4, 1, (3, 0), vec![]); + let mut board = open_board(4, 1, (3, 0)); assert!(!board.can_shift(0, 0, Direction::East)); // empty source // Crate with open space ahead: shiftable. crate_at(&mut board, 0, 0); assert!(board.can_shift(0, 0, Direction::East)); - assert_eq!(board.get(0, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // read-only + assert_matches!(board.get(0, 0), Some(Tile::Object(_))); // Crate with another pushable crate ahead: still shiftable (unlike can_push, // which would follow the chain to the wall and fail). - let mut board = open_board(4, 1, (3, 0), vec![]); + let mut board = open_board(4, 1, (3, 0)); crate_at(&mut board, 0, 0); crate_at(&mut board, 1, 0); wall_at(&mut board, 2, 0); @@ -918,13 +751,13 @@ pub(crate) mod tests { assert!(!board.can_push(0, 0, Direction::East)); // Crate with a non-pushable wall ahead: not shiftable. - let mut board = open_board(3, 1, (2, 0), vec![]); + let mut board = open_board(3, 1, (2, 0)); crate_at(&mut board, 0, 0); wall_at(&mut board, 1, 0); assert!(!board.can_shift(0, 0, Direction::East)); // Crate at the board edge facing off-board: not shiftable. - let mut board = open_board(2, 1, (0, 0), vec![]); + let mut board = open_board(2, 1, (0, 0)); crate_at(&mut board, 1, 0); assert!(!board.can_shift(1, 0, Direction::East)); } @@ -933,14 +766,12 @@ pub(crate) mod tests { fn can_shift_treats_the_player_as_a_blocker() { // The player is always a blocker for a shift — even a grab gem may not shift // onto it (grab now fires only on player movement, not on being shifted in). - let mut gem = ObjectDef::new(0, 0); - gem.behavior.grab = true; - gem.behavior.pushable = Pushable::Any; - let board = open_board(2, 1, (1, 0), vec![gem]); + let mut board = open_board(2, 1, (1, 0)); + gem_at(&mut board, 0, 0); assert!(!board.can_shift(0, 0, Direction::East)); // A plain crate likewise may not shift onto the player. - let mut board = open_board(2, 1, (1, 0), vec![]); + let mut board = open_board(2, 1, (1, 0)); crate_at(&mut board, 0, 0); assert!(!board.can_shift(0, 0, Direction::East)); } @@ -948,31 +779,31 @@ pub(crate) mod tests { #[test] fn push_into_player_pushes_player() { // Crate shoved east into the player slides the player along into open space. - let mut board = open_board(4, 1, (2, 0), vec![]); + let mut board = open_board(4, 1, (2, 0)); crate_at(&mut board, 1, 0); assert!(board.can_push(1, 0, Direction::East)); board.push(1, 0, Direction::East); - assert_eq!(board.get(1, 0).1, Archetype::Empty); - assert_eq!(board.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); - assert_eq!((board.player.x, board.player.y), (3, 0)); + assert!(board.get(1, 0).is_none()); + assert!(is_builtin(&board, 2, 0,"crate")); + assert_eq!(board.player_pos(), (3, 0)); } #[test] fn push_into_player_blocked_by_wall() { // Player backed against a wall: push has nowhere to go, nothing moves. - let mut board = open_board(4, 1, (2, 0), vec![]); + let mut board = open_board(4, 1, (2, 0)); crate_at(&mut board, 1, 0); wall_at(&mut board, 3, 0); assert!(!board.can_push(1, 0, Direction::East)); board.push(1, 0, Direction::East); // no-op - assert_eq!(board.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); - assert_eq!((board.player.x, board.player.y), (2, 0)); + assert!(is_builtin(&board, 1, 0, "crate")); + assert_eq!(board.player_pos(), (2, 0)); } #[test] fn glyph_at_uses_floor_for_empty_and_grid_for_solid() { // Player parked at (2,0) so it doesn't overlap either asserted cell. - let mut board = open_board(3, 1, (2, 0), vec![]); + let mut board = open_board(3, 1, (2, 0)); let floor_glyph = Glyph { tile: '.' as u32, fg: Rgba8 { @@ -996,106 +827,14 @@ pub(crate) mod tests { assert_eq!(board.glyph_at(1, 0), floor_glyph); } - #[test] - fn place_wall_keeps_floor_and_removes_solid_object() { - // A fixed floor attribute; a solid object sits on the grid at (1,0). - let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]); - let floor = Glyph { - tile: '.' as u32, - ..Glyph::transparent() - }; - add_floor(&mut board, floor); - - let wall = Builtin::Wall.default_glyph_for("wall"); - board.place_archetype(1, 0, Archetype::Builtin(Builtin::Wall, "wall"), wall); - - // The wall landed on the grid; the floor attribute is untouched. - assert_eq!(board.get(1, 0), &(wall, Archetype::Builtin(Builtin::Wall, "wall"))); - // The solid object that was there is gone. - assert!(board.object_ids_at(1, 0).is_empty()); - assert_eq!(board.glyph_at(1, 0), wall); - } - - #[test] - fn place_wall_overwrites_existing_terrain_in_place() { - // A crate already occupies the grid at (1,0). - let mut board = open_board(3, 1, (2, 0), vec![]); - crate_at(&mut board, 1, 0); - let wall = Builtin::Wall.default_glyph_for("wall"); - board.place_archetype(1, 0, Archetype::Builtin(Builtin::Wall, "wall"), wall); - assert_eq!(board.get(1, 0), &(wall, Archetype::Builtin(Builtin::Wall, "wall"))); - } - - #[test] - fn erase_removes_terrain_and_objects_but_keeps_floor() { - // A fixed floor attribute, a wall on the grid, and a (non-solid) object at (1,0). - let mut obj = ObjectDef::new(1, 0); - obj.behavior.solid = false; - let mut board = open_board(3, 1, (2, 0), vec![obj]); - let floor = Glyph { - tile: '.' as u32, - ..Glyph::transparent() - }; - add_floor(&mut board, floor); - wall_at(&mut board, 1, 0); - - board.place_archetype(1, 0, Archetype::Empty, Glyph::transparent()); - - // Grid cell cleared to transparent Empty; object removed; floor still there. - assert_eq!( - board.get(1, 0), - &(Glyph::transparent(), Archetype::Empty) - ); - assert!(board.object_ids_at(1, 0).is_empty()); - assert_eq!(board.glyph_at(1, 0), floor); - } - - #[test] - fn fresh_board_is_valid_and_reports_errors() { - let mut board = open_board(1, 1, (0, 0), vec![]); - assert!(board.is_valid()); - board.report_error("something went wrong"); - assert!(!board.is_valid()); - assert_eq!(board.load_errors.len(), 1); - } - - #[test] - fn expand_builtin_archetypes_replaces_a_spinner_cell_with_an_object() { - let mut board = open_board(3, 1, (2, 0), vec![]); - stamp(&mut board, 0, 0, Archetype::Builtin(Builtin::Spinner, "spinner_cw")); - board.expand_builtin_archetypes(); - - // The terrain cell is vacated and a scripted object takes its place. - assert_eq!(board.get(0, 0).1, Archetype::Empty); - let obj = board.objects.values().next().expect("spinner object"); - assert_eq!((obj.x, obj.y), (0, 0)); - assert!(obj.behavior.solid); - assert!(obj.builtin_script.is_some()); - assert!(obj.tags.contains("BUILTIN_spinner_cw")); - - // Idempotent: nothing left to expand on a second pass. - board.expand_builtin_archetypes(); - assert_eq!(board.objects.len(), 1); - } - - #[test] - fn remove_object_deletes_from_map() { - let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(0, 0)]); - assert!(board.solid_object_id_at(0, 0).is_some()); - let removed = board.remove_object(1); - assert!(removed.is_some()); - assert!(board.solid_object_id_at(0, 0).is_none()); - assert!(board.remove_object(1).is_none()); // already gone - } - #[test] fn apply_shift_out_of_bounds_rejects_immediately() { // apply_shift validates all cells upfront; any out-of-bounds cell causes immediate failure. - let mut board = open_board(3, 1, (2, 0), vec![]); + let mut board = open_board(3, 1, (2, 0)); crate_at(&mut board, 0, 0); let errs = board.apply_shift(&[(0, 0), (9, 0)]); - assert_eq!(errs.errors.len(), 1); - assert_eq!(board.get(0, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // unchanged + assert!(errs.is_err()); + assert!(is_builtin(&board, 0, 0, "crate")); // unchanged } #[test] @@ -1103,7 +842,7 @@ pub(crate) mod tests { // A non-pushable Wall is immobile. Backward cascade from the wall traces // through preceding solids until it hits empty, marking those as blocked. // Solids on the *other* side of the empty (outside the blocked region) still move. - let mut board = open_board(6, 1, (5, 0), vec![]); + let mut board = open_board(6, 1, (5, 0)); crate_at(&mut board, 0, 0); // (1,0) stays empty wall_at(&mut board, 2, 0); @@ -1119,103 +858,24 @@ pub(crate) mod tests { // Crate(3,0)→(4,0), Crate(4,0)→(0,0) wrap let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]); - assert_eq!(board.get(0, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // wrapped from (4,0) - assert_eq!(board.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (0,0) - assert_eq!(board.get(2, 0).1, Archetype::Builtin(Builtin::Wall, "wall")); // blocked, immobile - assert_eq!(board.get(3, 0).1, Archetype::Empty); // cleared, crate moved - assert_eq!(board.get(4, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (3,0) - } - - #[test] - fn apply_shift_hcrate_moves_horizontally_vcrate_stays() { - // In a horizontal (same-row) adjacent cycle: - // - HCrate (Pushable::Horizontal) moves freely (hmove, not blocked). - // - VCrate (Pushable::Vertical) is immobile (Vertical && hmove). - // Both can share the same pattern without blocking each other's neighbors. - - // Subcase A: HCrate moves - { - let mut board = open_board(4, 1, (3, 0), vec![]); - stamp(&mut board, 0, 0, Archetype::Builtin(Builtin::HCrate, "hcrate")); - crate_at(&mut board, 1, 0); - // (2,0) empty - - let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]); - - assert_eq!(board.get(0, 0).1, Archetype::Empty); // HCrate moved out - assert_eq!(board.get(1, 0).1, Archetype::Builtin(Builtin::HCrate, "hcrate")); // moved from (0,0) - assert_eq!(board.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (1,0) - } - - // Subcase B: VCrate stays; Crate behind it still moves - { - let mut board = open_board(4, 1, (3, 0), vec![]); - stamp(&mut board, 0, 0, Archetype::Builtin(Builtin::VCrate, "vcrate")); - crate_at(&mut board, 1, 0); - // (2,0) empty - - // VCrate (idx 0): target (1,0), origin (0,0), hmove=true. - // Pushable::Vertical && hmove=true → immobile - // Backward: prev=idx 2, solids[2]=None → stop. blocked={0}. - // Crate at (1,0) is NOT in blocked, so it moves. - let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]); - - assert_eq!(board.get(0, 0).1, Archetype::Builtin(Builtin::VCrate, "vcrate")); // immobile - assert_eq!(board.get(1, 0).1, Archetype::Empty); // crate moved out - assert_eq!(board.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (1,0) - } - } - - #[test] - fn apply_shift_vcrate_moves_vertically_hcrate_stays() { - // In a vertical (same-column) adjacent cycle: - // - VCrate (Pushable::Vertical) moves freely (vmove, not blocked). - // - HCrate (Pushable::Horizontal) is immobile (Horizontal && vmove). - - // Subcase A: VCrate moves - { - let mut board = open_board(1, 4, (0, 3), vec![]); - stamp(&mut board, 0, 0, Archetype::Builtin(Builtin::VCrate, "vcrate")); - stamp(&mut board, 0, 1, Archetype::Builtin(Builtin::Crate, "crate")); - // (0,2) empty - - let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]); - - assert_eq!(board.get(0, 0).1, Archetype::Empty); // VCrate moved out - assert_eq!(board.get(0, 1).1, Archetype::Builtin(Builtin::VCrate, "vcrate")); // moved from (0,0) - assert_eq!(board.get(0, 2).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (0,1) - } - - // Subcase B: HCrate stays; Crate behind it still moves - { - let mut board = open_board(1, 4, (0, 3), vec![]); - stamp(&mut board, 0, 0, Archetype::Builtin(Builtin::HCrate, "hcrate")); - stamp(&mut board, 0, 1, Archetype::Builtin(Builtin::Crate, "crate")); - // (0,2) empty - - // HCrate (idx 0): target (0,1), origin (0,0), vmove=true. - // Pushable::Horizontal && vmove=true → immobile - // Backward: prev=idx 2, solids[2]=None → stop. blocked={0}. - // Crate at (0,1) is NOT in blocked, so it moves. - let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]); - - assert_eq!(board.get(0, 0).1, Archetype::Builtin(Builtin::HCrate, "hcrate")); // immobile - assert_eq!(board.get(0, 1).1, Archetype::Empty); // crate moved out - assert_eq!(board.get(0, 2).1, Archetype::Builtin(Builtin::Crate, "crate")); // moved from (0,1) - } + assert!(is_builtin(&board, 0, 0, "crate")); // wrapped from (4,0) + assert!(is_builtin(&board, 1, 0, "crate")); // moved from (0,0) + assert!(is_builtin(&board, 2, 0, "wall")); // blocked, immobile + assert!(board.get(3, 0).is_none()); // cleared, crate moved + assert!(is_builtin(&board, 4, 0, "crate")); // moved from (3,0) } #[test] fn lighting_none_when_not_dark() { // A lit board needs no lighting; front-ends draw every cell. - let board = open_board(5, 1, (0, 0), vec![]); + let board = open_board(5, 1, (0, 0)); assert!(!board.dark); assert!(board.lighting(10).is_none()); } #[test] fn wall_is_opaque_empty_is_not() { - let mut board = open_board(3, 1, (0, 0), vec![]); + let mut board = open_board(3, 1, (0, 0)); wall_at(&mut board, 1, 0); assert!(board.is_opaque_at(1, 0)); // wall blocks sight assert!(!board.is_opaque_at(2, 0)); // empty cell is transparent @@ -1227,7 +887,7 @@ pub(crate) mod tests { // everything past it. The player's torch lights cells before the wall // (and the wall itself); the cells behind the wall are neither lit nor // in line of sight, so they are not visible. - let mut board = open_board(5, 1, (0, 0), vec![]); + let mut board = open_board(5, 1, (0, 0)); board.dark = true; wall_at(&mut board, 2, 0); @@ -1243,7 +903,7 @@ pub(crate) mod tests { fn unlit_cell_in_sight_is_not_visible() { // A long lit-free corridor: with a tiny torch, far cells are in line of // sight but receive no light, so they are not visible (LOS ∩ lit). - let mut board = open_board(10, 1, (0, 0), vec![]); + let mut board = open_board(10, 1, (0, 0)); board.dark = true; let lit = board.lighting(2).expect("dark board yields Lighting"); assert!(lit.is_visible(0, 0)); // at the torch @@ -1255,13 +915,9 @@ pub(crate) mod tests { fn object_light_tints_toward_its_color() { // A dark board with no player torch and one red-glyph light object: the // object's cell is lit red, so a white base tints red (green/blue killed). - let mut board = open_board(3, 1, (1, 0), vec![]); + let mut board = open_board(3, 1, (1, 0)); board.dark = true; - let mut lamp = ObjectDef::new(1, 0); - lamp.behavior.solid = false; - lamp.behavior.glow = 4; - lamp.glyph = Glyph { tile: 1, fg: Rgba8 { r: 255, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } }; - board.add_object(lamp); + lamp_at(&mut board, 1, 0); let lit = board.lighting(0).expect("dark board yields Lighting"); // no player torch let white = Rgba8 { r: 255, g: 255, b: 255, a: 255 }; diff --git a/kiln-core/src/board_spec.rs b/kiln-core/src/board_spec.rs new file mode 100644 index 0000000..42f976f --- /dev/null +++ b/kiln-core/src/board_spec.rs @@ -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, + /// List of all the sensors (if any) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sensors: Vec, + /// List of all the portals (if any) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub portals: Vec, + /// What we want for a floor + #[serde(default, skip_serializing_if = "Option::is_none")] + pub floor: Option, + /// 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>, 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 = 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>, String> { + let grid = self.grid_chars()?; + + // Walk the grid, filling cells and collecting placements. + let mut cells: Vec> = 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>, valid_script_names: &HashSet<&String>) -> Result<(), Vec> { + 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(hash: &mut HashMap, 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::>(); + let portal_dupes = portal_names.into_iter().filter_map(|(name, count)| if count > 1 { Some(name) } else { None }).collect::>(); + 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::>(); + + 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::>(); + 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 { + 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) + } +} \ No newline at end of file diff --git a/kiln-core/src/builtin.rs b/kiln-core/src/builtin.rs new file mode 100644 index 0000000..034fb17 --- /dev/null +++ b/kiln-core/src/builtin.rs @@ -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_` 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/.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" + ); + } + } +} diff --git a/kiln-core/src/builtin_scripts.rs b/kiln-core/src/builtin_scripts.rs deleted file mode 100644 index 6c5cfe1..0000000 --- a/kiln-core/src/builtin_scripts.rs +++ /dev/null @@ -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_` -//! 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_` 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 { - let name = tag.strip_prefix(BUILTIN_TAG_PREFIX)?; - Archetype::try_from(name).ok() -} diff --git a/kiln-core/src/colors.rs b/kiln-core/src/colors.rs index 99dd28f..9f210f8 100644 --- a/kiln-core/src/colors.rs +++ b/kiln-core/src/colors.rs @@ -31,3 +31,21 @@ pub const NAMED_COLORS: [(&str, Rgba8); 16] = [ ("Yellow", rgb(0xFF, 0xFF, 0x55)), ("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 } +} \ No newline at end of file diff --git a/kiln-core/src/floor.rs b/kiln-core/src/floor.rs index 29ef175..a7b0b72 100644 --- a/kiln-core/src/floor.rs +++ b/kiln-core/src/floor.rs @@ -5,13 +5,14 @@ //! give boards some non-distracting visual flavor (textured ground) with little //! 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 //! glyph is rolled per grid cell) or gives a fixed glyph. This module only owns //! the generators themselves. use crate::glyph::Glyph; use color::Rgba8; +use serde::{Deserialize, Serialize}; use tinyrand::{Probability, Rand, Seeded, StdRand}; /// 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 /// 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 /// buffer pre-rolled once at load from [`FLOOR_SEED`] — deterministic, and the /// 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). Biome { /// 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). glyphs: Vec, }, @@ -52,7 +53,7 @@ impl Floor { /// 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 /// 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 glyphs = (0..width * height).map(|_| generator.generate(&mut rng)).collect(); 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 /// colors are deliberately dark and low-saturation so foreground objects stay /// readable against them. -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum FloorGenerator { +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FloorBiome { /// Random green-to-greenish-yellow ground with a fairly high chance of grassy /// characters (comma, period, backquote, apostrophe). Grass, @@ -91,24 +93,24 @@ pub enum FloorGenerator { Stone, } -impl FloorGenerator { +impl FloorBiome { /// Parses a generator from its map-file name, or `None` if unrecognized. - pub fn from_name(name: &str) -> Option { + pub fn from_name(name: &str) -> Option { match name { - "grass" => Some(FloorGenerator::Grass), - "dirt" => Some(FloorGenerator::Dirt), - "stone" => Some(FloorGenerator::Stone), + "grass" => Some(FloorBiome::Grass), + "dirt" => Some(FloorBiome::Dirt), + "stone" => Some(FloorBiome::Stone), _ => 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. pub fn name(&self) -> &'static str { match self { - FloorGenerator::Grass => "grass", - FloorGenerator::Dirt => "dirt", - FloorGenerator::Stone => "stone", + FloorBiome::Grass => "grass", + FloorBiome::Dirt => "dirt", + FloorBiome::Stone => "stone", } } @@ -121,7 +123,7 @@ impl FloorGenerator { // (background color ranges, texture probability, texture chars). let (bg, chars, prob) = match self { // Green → greenish-yellow: g dominant, a touch of r for the yellow tilt. - FloorGenerator::Grass => ( + FloorBiome::Grass => ( Rgba8 { r: shade(rng, 20, 55), g: shade(rng, 45, 85), @@ -132,7 +134,7 @@ impl FloorGenerator { 0.35, ), // Brown: r highest, g mid, b low. - FloorGenerator::Dirt => ( + FloorBiome::Dirt => ( Rgba8 { r: shade(rng, 45, 75), g: shade(rng, 30, 50), @@ -143,7 +145,7 @@ impl FloorGenerator { 0.20, ), // Gray: all channels share one shade. - FloorGenerator::Stone => { + FloorBiome::Stone => { let v = shade(rng, 40, 70); ( Rgba8 { @@ -199,7 +201,7 @@ mod tests { /// Rolls `count` glyphs from `generator` against a freshly-seeded RNG, the /// same way the layer builder does. - fn roll(generator: FloorGenerator, count: usize) -> Vec { + fn roll(generator: FloorBiome, count: usize) -> Vec { let mut rng = StdRand::seed(FLOOR_SEED); (0..count).map(|_| generator.generate(&mut rng)).collect() } @@ -207,24 +209,24 @@ mod tests { #[test] fn from_name_parses_known_generators() { assert_eq!( - FloorGenerator::from_name("grass"), - Some(FloorGenerator::Grass) + FloorBiome::from_name("grass"), + Some(FloorBiome::Grass) ); assert_eq!( - FloorGenerator::from_name("dirt"), - Some(FloorGenerator::Dirt) + FloorBiome::from_name("dirt"), + Some(FloorBiome::Dirt) ); assert_eq!( - FloorGenerator::from_name("stone"), - Some(FloorGenerator::Stone) + FloorBiome::from_name("stone"), + Some(FloorBiome::Stone) ); - assert_eq!(FloorGenerator::from_name("lava"), None); + assert_eq!(FloorBiome::from_name("lava"), None); } #[test] fn grass_generator_stays_in_scheme_and_is_deterministic() { - let a = roll(FloorGenerator::Grass, 64); - let b = roll(FloorGenerator::Grass, 64); + let a = roll(FloorBiome::Grass, 64); + let b = roll(FloorBiome::Grass, 64); assert_eq!(a.len(), 64); // Same seed → identical rolls across builds. assert!(a.iter().zip(&b).all(|(x, y)| x == y)); diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index fe728aa..ef8b6ae 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -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::log::LogLine; use crate::script::ScriptHost; -use crate::utils::{Direction, ObjectId, PlayerPos}; +use crate::utils::{Direction, ObjectId}; use crate::world::World; use std::cell::{Ref, RefMut}; -use std::collections::HashSet; +use std::collections::{BTreeSet, HashSet, VecDeque}; +use std::hash::Hash; 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 -/// follow-up [`GameState::settle`] pass runs them after all object hooks, so a -/// bumped object reacts to the fully-updated board. -#[derive(Default)] -struct Events { - /// `(bumped object, direction the bump came from)` for each triggered `bump`. - bumps: Vec<(ObjectId, Direction)>, - /// `(entered non-solid object, direction the entrant came from)` for each - /// `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)>, -} +/// Most things (bump, etc) are only triggered by player actions now. +/// However, sends still might trigger other sends! So we need to keep a +/// list of sends that we trigger in the process of resolving a list of +/// actions. When we resolve these sends, we'll keep a list of things we've +/// resolved, so we refuse to do the same send twice in a tick: this prevents +/// us from accidentally doing an infinite recursion. +#[derive(Clone, Debug)] +struct SendAction(ObjectId, String, SendArg); -impl Events { - /// Appends `other`'s reactions onto `self`. - fn merge(&mut self, other: Events) { - self.bumps.extend(other.bumps); - 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() +impl Hash for SendAction { + fn hash(&self, state: &mut H) { + self.0.hash(state); + self.1.hash(state); } } -/// Records which `(object, hook/fn, args)` reactions have already fired during a -/// single `tick` / `try_move` / `run_init`. [`GameState::settle`] refuses to fire -/// a key twice, so a bump/send cascade always terminates — even if two objects -/// bump each other in a cycle, each side fires at most once. Reset per invocation. -type CalledSet = HashSet<(ObjectId, String, String)>; +impl PartialEq for SendAction { + fn eq(&self, other: &Self) -> bool { + self.1 == other.1 && self.0 == other.0 + } +} + +impl Eq for SendAction {} /// How long a `say()` speech bubble stays on screen, in seconds. pub const SAY_DURATION: f64 = 3.0; @@ -51,6 +42,8 @@ pub const SAY_DURATION: f64 = 3.0; // accessing the private `action` module directly. pub use crate::action::ScrollLine; 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()`. /// @@ -210,16 +203,11 @@ impl GameState { pub fn run_init(&mut self) { // 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. - let mut ev = Events::default(); let ids = self.board().all_ids(); for id in ids { 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). @@ -240,16 +228,11 @@ impl GameState { }); // Run each object's tick in ascending id order, applying its drained // actions immediately so the next object sees the updated board. - let mut ev = Events::default(); let ids = self.board().all_ids(); for id in ids { 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 @@ -261,302 +244,105 @@ impl GameState { self.log.extend(lines); } - /// Applies one object's drained `actions` to the board and returns the `bump` - /// and `send` reactions they triggered (fired later by [`settle`](GameState::settle)). - /// - /// 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) -> Events { + /// Applies one object's drained `actions` to the board + fn apply_actions(&mut self, actions: Vec) { // Application-time errors (teleport/push/shift failures) go straight onto // 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 // `drain_log`. A cheap Rc clone lets us push while the board borrow (which // also borrows `self`) is held. 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 = Vec::new(); - // Collected outside the board borrow so we can assign to self.active_scroll. - let mut new_scroll: Option = None; - { - let mut board = self.board_mut(); - for ba in actions { - match ba.action { - Action::Move(dir) => { - let StepOutcome { bumped, entered } = - step_object(&mut board, ba.source, dir); - // The bump / enter "comes from" the side the mover advanced - // 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())); - } + for ba in actions { + match ba.action { + Action::Move(dir) => { + step_object(&mut self.board_mut(), ba.source, dir); + } + Action::SetTile(tile) => { + if let Some(scr) = self.board_mut().scripting_mut(ba.source) { + scr.glyph.tile = tile; } - Action::SetTile(tile) => { - if let Some(obj) = board.objects.get_mut(&ba.source) { - obj.glyph.tile = tile; - } + } + Action::SetLight(radius) => { + 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) { - obj.behavior.glow = radius; - } - } - Action::SetTag { - target, - tag, - present, - } => { - 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)); - } - } - } - } - } + } + Action::SetTag { + target, + tag, + present, + } => { + if let Some(obj) = self.board_mut().scripting_mut(target) { + if present { + obj.tags.insert(tag); } else { - log_sink.error(format!( - "teleport({target},{x},{y}): invalid target id" - )); + obj.tags.remove(&tag); } } - // push() self-checks can_push, so an in-bounds guard is all we add. - Action::Push { x, y, dir } => { - if !board.in_bounds((x, y)) { - log_sink.error(format!("push({x},{y}): out of bounds")); - } else { - // Each pushed solid stepped one cell in `dir`; fire `enter` - // on any non-solid it landed on (came-from `dir.opposite()`). - for (cx, cy) in board.push(x as usize, y as usize, dir) { - for id in board.non_solid_object_ids_at(cx, cy) { - enters.push((id, dir.opposite())); - } - } + } + // 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) => { + // One bubble per object: replace the existing one if present. + self.speech_bubbles + .retain(|b| b.object_id != ba.source); + self.speech_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) = 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::Shift(cells) => { - let outcome = board.apply_shift(&cells); - for line in outcome.errors { - log_sink.line(line); - } - for (from, to) in outcome.moves { - // A shift can rotate non-adjacent cells, so the came-from - // 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) - { - for id in - board.non_solid_object_ids_at(to.0 as usize, to.1 as usize) - { - enters.push((id, from_dir)); - } - } - } - } - // Accumulated and applied to `self.player.gems` after the borrow drops. - Action::AddGems(n) => gem_delta += n, - // Accumulated and applied to `self.player.health` after the borrow drops. - Action::AlterHealth(dh) => health_delta += dh, - // Collected and applied to `self.player.keys` after the borrow drops. - Action::SetKey(color, present) => key_changes.push((color, present)), - // A grab thing despawns itself from its grab() hook. - Action::Die => { - board.remove_object(ba.source); + } + Action::Send { target, fn_name, arg} => { + self.scripts.run_send(target, &fn_name, arg); + } + Action::Scroll(lines) => { + self.active_scroll.replace(Scroll { + source: ba.source, + lines, + choice: None, + }); + } + Action::Teleport { target, x, y } => { + apply_teleport(&mut self.board_mut(), target, x, y).unwrap_or_else(|e| log_sink.error(e)) + } + Action::Push { x, y, dir } => { + apply_push(&mut self.board_mut(), x, y, dir).unwrap_or_else(|e| log_sink.error(e)) + } + Action::Shift(cells) => { + apply_shift(&mut self.board_mut(), &cells).unwrap_or_else(|e| log_sink.error(e)) + } + Action::AddGems(n) => { + self.player.borrow_mut().alter_gems(n); + }, + Action::AlterHealth(dh) => { + self.player.borrow_mut().alter_health(dh) + }, + Action::SetKey(color, present) => { + if !self.player.borrow_mut().keys.set_by_name(&color, present) { + log_sink.error(format!("set_key: unknown color {color:?}")); } + }, + Action::Die => { + self.board_mut().remove_object(ba.source); } } } - for bubble in new_bubbles { - // 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; - } + self.drain_log(); } /// 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 // queues (plus any bump/send cascade), the same as a tick. 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). let arrival = self.world.boards[target_map] .borrow() - .portals - .iter() - .find(|p| p.name == target_entry) - .map(|p| (p.x, p.y)); + .named_portal(target_entry).map(Portal::location); let (ax, ay) = match arrival { Some(pos) => pos, None => { @@ -614,10 +393,7 @@ impl GameState { self.active_scroll = None; // Switch to the new board and place the player at the arrival portal. self.current_board_name = target_map.to_string(); - self.board_mut().player = PlayerPos { - x: ax as i64, - y: ay as i64, - }; + self.board_mut().get_mut(ax, ay).replace(Tile::Player); self.board_mut().clear_all_queues(); // Rebuild the script host for the new board's objects. 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 /// fires with the direction the bump came from (whether or not the player moves). pub fn try_move(&mut self, dir: Direction) { - let bumped; - let grabbed; - let portal_target; - // Non-solid objects the player (or the crates it shoved) landed on this move, - // collected while the board is borrowed and fired as `enter` after the borrow. - let mut entered: Vec = 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); + let (dx, dy): (i64, i64) = dir.into(); + let player_loc = self.board().player_pos(); + let target = (player_loc.0 as i64 + dx, player_loc.1 as i64 + dy); + if !self.board().in_bounds(target) { return; } - let mut ev = Events::default(); - // 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. - if let Some(id) = grabbed { - let actions = self.scripts.run_grab(id); - ev.merge(self.apply_actions(actions)); + let (nx, ny) = (target.0 as usize, target.1 as usize); + + // We need to be able to call hooks on objects, so we can't hold a mut reference to the + // board going into this match + let obj_data = { + 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. - ev.bumps.push((idx, dir.opposite())); + + // Check if we actually moved + 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(); } } @@ -736,46 +544,28 @@ struct StepOutcome { /// 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) /// and each cell a pushed crate moved into (crates are always solid entrants). -fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> StepOutcome { - let mut out = StepOutcome { - bumped: None, - entered: Vec::new(), - }; - let (dx, dy): (i64, i64) = dir.into(); - let Some((ox, oy, solid)) = board.objects.get(&id).map(|o| (o.x, o.y, o.behavior.solid)) else { - return out; - }; - let target = (ox as i64 + dx, oy as i64 + dy); - if !board.in_bounds(target) { - return out; +fn step_object(board: &mut Board, id: ObjectId, dir: Direction) { + // TODO when an object pushes the player, it should still trigger actions on + // what the player is pushed into. But, for right now, just call board::push + let (loc, solid) = if let Some(obj) = board.get_hookable(id) { + (obj.location(), obj.solid()) + } else { return }; + + if solid { + // This is a real object on the board, try and push it + board.push(loc.0, loc.1, dir); + } else { + // 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)] mod tests { use super::GameState; use crate::Direction; - use crate::archetype::{Archetype, Builtin}; - use crate::board::tests::{crate_at, open_board, stamp, wall_at}; + use crate::builtin::Builtin; + use crate::board::tests::{crate_at, gem_at, open_board, wall_at}; use crate::object_def::ObjectDef; use std::collections::HashMap; use std::time::Duration; @@ -784,9 +574,8 @@ mod tests { fn walking_onto_a_gem_grabs_it() { // 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). - let mut board = open_board(3, 1, (0, 0), vec![]); - stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem")); - board.expand_builtin_archetypes(); + let mut board = open_board(3, 1, (0, 0)); + gem_at(&mut board, 1, 0); let mut game = GameState::new(board); game.run_init(); @@ -794,8 +583,7 @@ mod tests { // The gem was grabbed: gem count up, gem object gone, player on its cell. assert_eq!(game.player.borrow().gems, 1); - assert!(game.board().objects.is_empty()); - assert_eq!((game.board().player.x, game.board().player.y), (1, 0)); + assert_eq!(game.board().player_pos(), (1, 0)); } #[test] @@ -806,7 +594,7 @@ mod tests { // the board edge), so nothing happens and the gem is not collected. let mut sobj = ObjectDef::new(0, 0); 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]); stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem")); board.expand_builtin_archetypes(); @@ -830,7 +618,7 @@ mod tests { fn game_with_object_script(board_w: usize, src: &str) -> GameState { let mut obj = ObjectDef::new(0, 0); 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]); crate_at(&mut board, 2, 0); let scripts = HashMap::from([("s".to_string(), src.to_string())]); @@ -860,7 +648,7 @@ mod tests { // genuinely empty in-bounds cell. let mut obj = ObjectDef::new(0, 0); 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]); crate_at(&mut board, 2, 0); let src = "fn init(m) { \ @@ -887,7 +675,7 @@ mod tests { // would have been stuck behind the delay and absent here. let mut obj = ObjectDef::new(0, 0); 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 src = "fn init(m) { delay(5.0); log(\"immediate\"); }"; let scripts = HashMap::from([("s".to_string(), src.to_string())]); @@ -917,9 +705,9 @@ mod tests { ) -> GameState { let mut obj = ObjectDef::new(1, 1); obj.behavior.solid = false; - obj.script_name = Some("spinner".to_string()); + obj.scripting.script_name = Some("spinner".to_string()); 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]); for &(x, y) in crates { @@ -1000,7 +788,7 @@ mod tests { fn set_key_gives_and_takes_keys() { let mut sobj = ObjectDef::new(0, 0); 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 scripts = HashMap::from([( "s".to_string(), @@ -1017,7 +805,7 @@ mod tests { // A second script can take a key. let mut sobj2 = ObjectDef::new(0, 0); 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 scripts2 = HashMap::from([( "t".to_string(), @@ -1033,7 +821,7 @@ mod tests { fn set_key_unknown_color_logs_error() { let mut sobj = ObjectDef::new(0, 0); 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 scripts = HashMap::from([( "s".to_string(), diff --git a/kiln-core/src/glyph.rs b/kiln-core/src/glyph.rs index bbaa364..3aef67c 100644 --- a/kiln-core/src/glyph.rs +++ b/kiln-core/src/glyph.rs @@ -19,7 +19,7 @@ use crate::utils::LogSink; /// (see [`Glyph::player`]). #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub struct Glyph { - /// Tile index into the board's bitmap font (left-to-right, top-to-bottom). + /// Which tile to draw pub tile: u32, /// Foreground color, applied to non-background pixels of the tile. pub fg: Rgba8, @@ -27,6 +27,10 @@ pub struct Glyph { pub bg: Rgba8, } +impl Default for Glyph { + fn default() -> Self { Self::transparent() } +} + impl Hash for Glyph { /// Hash via packed u32 representations so the impl stays in sync with Eq. fn hash(&self, state: &mut H) { @@ -77,4 +81,55 @@ impl Glyph { 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 for TileIndex { + fn into(self) -> u32 { + match self { + TileIndex::Num(n) => n, + TileIndex::Chr(c) => c as u32, + } + } +} + */ \ No newline at end of file diff --git a/kiln-core/src/layer.rs b/kiln-core/src/layer.rs deleted file mode 100644 index df28134..0000000 --- a/kiln-core/src/layer.rs +++ /dev/null @@ -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, - /// Single character to fill the whole `width × height` grid with. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fill: Option, - /// Individual cells over an otherwise all-spaces grid. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sparse: Option>, - /// Char (as a one-character string key) → palette entry. - #[serde(default)] - pub palette: HashMap, -} - -/// 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, - /// Foreground `"#RRGGBB"`. Used by archetype/object kinds. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fg: Option, - /// Background `"#RRGGBB"`. Used by archetype/object kinds. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bg: Option, - /// Object solidity (defaults `true`). Only for `kind = "object"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub solid: Option, - /// Object opacity (defaults `true`). Only for `kind = "object"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opaque: Option, - /// Object pushability (defaults `false`). Only for `kind = "object"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pushable: Option, - /// Light radius in cells emitted on a dark board (defaults `0` = none). - /// Only for `kind = "object"`. See [`ObjectDef::light`]. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub light: Option, - /// Rhai script name. Only for `kind = "object"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub script_name: Option, - /// Open-ended labels. Only for `kind = "object"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tags: Option>, - /// Unique name. For `kind = "object"` (optional) or `kind = "portal"` (required). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Target board key. Required for `kind = "portal"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_map: Option, - /// Target portal name on the destination board. Required for `kind = "portal"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_entry: Option, -} - -/// 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, - pub tags: Vec, - pub name: Option, -} - -/// 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) -> 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`), 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, -) -> Result>, 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| { - 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 = 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, -) -> Result<(Vec, Vec), 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 = 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 = Vec::with_capacity(width * height); - let mut placements: Vec = 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)) -} diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index c2d7b53..f6bb8cf 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -1,23 +1,21 @@ mod action; -mod archetype; +mod builtin; mod board; -mod builtin_scripts; /// The 16 EGA/VGA named colors ([`colors::NAMED_COLORS`]), shared by scripts and the editor. pub mod colors; /// CP437 tile-index → character mapping ([`cp437::tile_to_char`]) for the default font. pub mod cp437; -/// Procedural floor generators ([`floor::FloorGenerator`]). +/// Procedural floor generators ([`floor::FloorBiome`]). pub mod floor; /// Lighting & field-of-view for dark boards ([`fov::Lighting`]). 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 glyph; -mod layer; +/// Serializable representation of a Board +pub mod board_spec; /// Styled log messages ([`log::LogLine`]) for the in-game message feed. pub mod log; -/// Map file loading and saving (`.toml` format). -pub mod map_file; mod object_def; /// Rhai scripting runtime for board objects ([`script::ScriptHost`]). pub mod script; @@ -26,7 +24,7 @@ mod utils; pub mod world; pub mod player; pub mod keys; -pub use archetype::{Archetype, Builtin}; +pub use builtin::Builtin; pub use board::Board; pub use fov::{Lighting, SIGHT_RADIUS}; pub use utils::Direction; @@ -35,3 +33,4 @@ pub use utils::Direction; mod tests; mod api; pub mod tile; +mod portal; diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs deleted file mode 100644 index 777a9ea..0000000 --- a/kiln-core/src/map_file.rs +++ /dev/null @@ -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, - /// 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, -} - -/// 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, - /// 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, - /// 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, - /// Fixed-glyph tile index (int or single-char string). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tile: Option, - /// Fixed-glyph foreground `"#RRGGBB"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fg: Option, - /// Fixed-glyph background `"#RRGGBB"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bg: Option, -} - -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) -> 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, - /// Optional open-ended labels. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tags: Option>, -} - -/// 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, - /// Foreground `"#RRGGBB"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fg: Option, - /// Background `"#RRGGBB"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bg: Option, -} - -/// 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 for Board { - type Error = String; - - fn try_from(mf: MapFile) -> Result { - let w = mf.map.width; - let h = mf.map.height; - let mut load_errors: Vec = 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 = BTreeMap::new(); - let mut next_object_id: ObjectId = 1; - let mut seen_names: HashMap = 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 = HashSet::new(); - let mut portals: Vec = 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 = 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, - errors: &mut Vec, -) -> Option { - 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 { - 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 { - 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 = HashMap::new(); - let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new(); - let mut grid: Vec> = 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 = 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::()) - .collect::>() - .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 { - 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 = 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> { - 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> { - let map_file = MapFile::from(board); - let toml_str = toml::to_string_pretty(&map_file)?; - std::fs::write(path, toml_str)?; - Ok(()) -} diff --git a/kiln-core/src/object_def.rs b/kiln-core/src/object_def.rs index 01a9d30..e1f2c60 100644 --- a/kiln-core/src/object_def.rs +++ b/kiln-core/src/object_def.rs @@ -1,8 +1,7 @@ +use std::fmt::{Debug, Formatter}; use crate::glyph::Glyph; -use crate::utils::{Behavior, ObjectId, Pushable}; use color::Rgba8; -use std::collections::HashSet; -use crate::api::queue::ObjQueue; +use crate::tile::{EnterResponse, Optics, ScriptAttributes}; /// 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. #[derive(Clone)] pub struct ObjectDef { - /// 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, - /// Column of this object on the board (0-indexed). - pub x: usize, - /// Row of this object on the board (0-indexed). - pub y: usize, - /// 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, - /// 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, - /// 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, - /// The output queue of actions for this object - pub queue: ObjQueue, + /// How this object affects movement + pub enter_response: EnterResponse, + /// Everything we need to run our script + pub scripting: ScriptAttributes, +} + +impl Debug for ObjectDef { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "ObjectDef({:?}, {:?}, {:?})", self.scripting.id, self.scripting.glyph, self.scripting.script_name) + } } 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 /// round-trip correctly. - pub fn new(x: usize, y: usize) -> Self { + pub fn new() -> Self { Self { - id: 0, - x, - y, - glyph: Self::default_glyph(), - behavior: Behavior { - solid: true, - opaque: true, - pushable: Pushable::No, - grab: false, - glow: 0, - }, - script_name: None, - builtin_script: None, - tags: HashSet::new(), - queue: ObjQueue::new(), - name: None, + enter_response: EnterResponse::Block, + scripting: ScriptAttributes { + id: 0, + glyph: Self::default_glyph(), + optics: Optics { opaque: true, glow: 0 }, + ..Default::default() + } } } } diff --git a/kiln-core/src/portal.rs b/kiln-core/src/portal.rs new file mode 100644 index 0000000..d8df624 --- /dev/null +++ b/kiln-core/src/portal.rs @@ -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 +} + +impl Portal { + pub fn location(&self) -> (usize, usize) { + (self.x, self.y) + } +} \ No newline at end of file diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 0070cce..06fcd3b 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -36,7 +36,6 @@ use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST}; use crate::game::SAY_DURATION; use crate::log::LogLine; -use crate::map_file::parse_color; use crate::object_def::ObjectDef; use crate::utils::{Direction, LogSink, Hook, ObjectId}; use rhai::{ @@ -49,6 +48,7 @@ use crate::api::object_info::ObjectInfo; use crate::api::player::PlayerWithPos; use crate::api::queue::ObjQueue; use crate::api::registry::Registry; +use crate::colors::parse_color; use crate::glyph::Glyph; use crate::keys::Keyring; 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 { - obj.script_name.clone() -} - // ── ScriptHost ──────────────────────────────────────────────────────────────── /// 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`. let mut scripts: HashMap = HashMap::new(); let mut failed: HashSet = HashSet::new(); - for obj in board.objects.values() { - let Some(key) = script_key(obj) else { + let all_scriptables = board.sorted_hookables(); + for obj in all_scriptables.iter() { + let Some(key) = obj.script_key() else { continue; }; - if scripts.contains_key(&key) || failed.contains(&key) { + if scripts.contains_key(key) || failed.contains(key) { continue; } // 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 } else { - match script_sources.get(&key) { + match script_sources.get(key) { Some(src) => src, None => { failed.insert(key.clone()); @@ -162,7 +153,7 @@ impl ScriptHost { .any(|f| f.name == n && f.params.len() == params) }; scripts.insert( - key, + key.clone(), CompiledScript { has_init: defines("init", 1), has_tick: defines("tick", 2), @@ -181,16 +172,13 @@ impl ScriptHost { } // One runtime per object whose script compiled. - for (&id, obj) in board.objects.iter() { - let Some(key) = script_key(obj) else { - continue; - }; - if !scripts.contains_key(&key) { - continue; + for obj in all_scriptables.iter() { + if let Some(key) = obj.script_key() && scripts.contains_key(key) { + scopes.insert(obj.id(), Scope::new()); } - scopes.insert(id, Scope::new()); } + drop(all_scriptables); drop(board); Self { engine, @@ -299,9 +287,12 @@ impl ScriptHost { /// - 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`. - /// Returns the actions the call drained. - pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) -> Vec { - let mut actions = Vec::new(); + /// Note: does not drain the queue! If the send triggers an action it gets drained in the + /// next tick (or next hook, anyway). This is because of possible deadlocks: if we drained + /// 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(script_key) = info.script_name.as_ref() && let Some(script) = self.scripts.get(script_key) @@ -319,7 +310,7 @@ impl ScriptHost { // If it's not there at all, just bail: if arities.is_empty() { self.log_sink.error(format!("script '{}' send({}) error: function not found", script_key, fn_name)); - return actions; + return; } // Assemble the args @@ -342,12 +333,10 @@ impl ScriptHost { ) { self.log_sink.error(format!("script '{}' send({}) error: {err}", script_key, fn_name)); } - info.drain(&mut actions, 0.0) } } else { unreachable!("Object id not found, tried to send"); } - actions } /// 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(); engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { let src = source_of(&ctx); - if let Some(def) = b.borrow_mut().objects.get_mut(&src) { - def.queue.act(Action::Move(dir)); - def.queue.delay(MOVE_COST); + if let Some(scr) = b.borrow_mut().scripting_mut(src) { + scr.queue.act(Action::Move(dir)); + scr.queue.delay(MOVE_COST); } }); let b = board.clone(); engine.register_fn("delay", move |ctx: NativeCallContext, dt: Dynamic| { 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() { - def.queue.delay(dt); + scr.queue.delay(dt); } }); let b = board.clone(); engine.register_fn("now", move |ctx: NativeCallContext| { let src = source_of(&ctx); - if let Some(def) = b.borrow_mut().objects.get_mut(&src) { - def.queue.now() + if let Some(scr) = b.borrow_mut().scripting_mut(src) { + 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`. fn emit(board: &BoardRef, source: ObjectId, action: Action) { - if let Some(def) = board.borrow_mut().objects.get_mut(&source) { - def.queue.act(action); + if let Some(scripting) = board.borrow_mut().scripting_mut(source) { + scripting.queue.act(action); } } diff --git a/kiln-core/src/scripts/transporter.rhai b/kiln-core/src/scripts/transporter.rhai index 4d0492a..9dcbc91 100644 --- a/kiln-core/src/scripts/transporter.rhai +++ b/kiln-core/src/scripts/transporter.rhai @@ -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 // whatever bumps into it from its facing side. It animates through a 4-frame diff --git a/kiln-core/src/tests/actions.rs b/kiln-core/src/tests/actions.rs index 5beeddf..f2cef61 100644 --- a/kiln-core/src/tests/actions.rs +++ b/kiln-core/src/tests/actions.rs @@ -1,5 +1,5 @@ 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::game::GameState; use std::time::Duration; diff --git a/kiln-core/src/tests/game_portals.rs b/kiln-core/src/tests/game_portals.rs index 8332811..3764fd4 100644 --- a/kiln-core/src/tests/game_portals.rs +++ b/kiln-core/src/tests/game_portals.rs @@ -1,16 +1,17 @@ -use crate::archetype::Archetype; +use crate::builtin::Archetype; use crate::board::Board; use crate::floor::Floor; use crate::game::GameState; use crate::glyph::Glyph; -use crate::utils::{Direction, PlayerPos, PortalDef}; +use crate::utils::{Direction, PlayerPos}; use crate::world::World; use std::cell::RefCell; use std::collections::{BTreeMap, HashMap}; use std::rc::Rc; +use crate::portal::Portal; /// Builds a 3×3 board with the player at `(px, py)` and the given portals. -fn make_board(px: i64, py: i64, portals: Vec) -> Board { +fn make_board(px: i64, py: i64, portals: Vec) -> Board { Board { name: "test".into(), width: 3, @@ -38,23 +39,23 @@ fn two_board_world() -> World { let b1 = make_board( 0, 0, - vec![PortalDef { + vec![Portal { name: "to_b2".into(), x: 2, y: 0, - target_map: "b2".into(), - target_entry: "from_b1".into(), + target_board: "b2".into(), + target_name: "from_b1".into(), }], ); let b2 = make_board( 0, 0, - vec![PortalDef { + vec![Portal { name: "from_b1".into(), x: 1, y: 1, - target_map: "b1".into(), - target_entry: "to_b2".into(), + target_board: "b1".into(), + target_name: "to_b2".into(), }], ); World { diff --git a/kiln-core/src/tests/map_file/fill_sparse.rs b/kiln-core/src/tests/map_file/fill_sparse.rs index f0baf35..6884437 100644 --- a/kiln-core/src/tests/map_file/fill_sparse.rs +++ b/kiln-core/src/tests/map_file/fill_sparse.rs @@ -1,5 +1,5 @@ use super::load_board; -use crate::archetype::Archetype; +use crate::builtin::Archetype; #[test] 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()); } else { 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") } diff --git a/kiln-core/src/tests/map_file/grid_errors.rs b/kiln-core/src/tests/map_file/grid_errors.rs index 289f1d0..f02581d 100644 --- a/kiln-core/src/tests/map_file/grid_errors.rs +++ b/kiln-core/src/tests/map_file/grid_errors.rs @@ -1,5 +1,5 @@ use super::{grid, load_board, map}; -use crate::archetype::Archetype; +use crate::builtin::Archetype; use crate::board::Board; use crate::map_file::MapFile; diff --git a/kiln-core/src/tests/map_file/object_placement.rs b/kiln-core/src/tests/map_file/object_placement.rs index 93fa337..3544fc3 100644 --- a/kiln-core/src/tests/map_file/object_placement.rs +++ b/kiln-core/src/tests/map_file/object_placement.rs @@ -1,5 +1,5 @@ use super::{grid, load_board, map, map_3x1_object}; -use crate::archetype::Archetype; +use crate::builtin::Archetype; /// Palette shorthand. 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[&1].name.as_deref(), Some("gate")); - assert_eq!(board.objects[&2].name, None); + assert_eq!(board.objects[&1].scripting.name.as_deref(), Some("gate")); + assert_eq!(board.objects[&2].scripting.name, None); 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[&1].name.as_deref(), Some("guard")); - assert_eq!(board.objects[&2].name, None); - assert_eq!(board.objects[&3].name, None); + assert_eq!(board.objects[&1].scripting.name.as_deref(), Some("guard")); + assert_eq!(board.objects[&2].scripting.name, None); + assert_eq!(board.objects[&3].scripting.name, None); } #[test] diff --git a/kiln-core/src/tests/map_file/player_placement.rs b/kiln-core/src/tests/map_file/player_placement.rs index f16f262..43c79f7 100644 --- a/kiln-core/src/tests/map_file/player_placement.rs +++ b/kiln-core/src/tests/map_file/player_placement.rs @@ -1,5 +1,5 @@ use super::{grid, load_board, map}; -use crate::archetype::Archetype; +use crate::builtin::Archetype; /// Palette shorthands shared by these tests. const EMPTY: (&str, &str) = (".", "kind = \"empty\""); diff --git a/kiln-core/src/tests/map_file/pushers.rs b/kiln-core/src/tests/map_file/pushers.rs index 78e1ab7..459c3a3 100644 --- a/kiln-core/src/tests/map_file/pushers.rs +++ b/kiln-core/src/tests/map_file/pushers.rs @@ -2,19 +2,19 @@ //! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_` tag). use super::{grid, load_board, map}; -use crate::archetype::Archetype; +use crate::builtin::Archetype; use crate::game::GameState; use crate::map_file::MapFile; use crate::object_def::ObjectDef; use std::time::Duration; -use crate::{Board, Builtin}; +use crate::Board; /// 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 { let (&oid, obj) = board .objects .iter() - .find(|(_, o)| o.tags.contains("BUILTIN_pusher_east")) + .find(|(_, o)| o.scripting.tags.contains("BUILTIN_pusher_east")) .expect("pusher object"); *id = oid; obj @@ -34,17 +34,17 @@ fn pusher_loads_as_a_tagged_scripted_solid_object() { let p = pusher(&board, &mut id); assert_eq!((p.x, p.y), (0, 0)); assert!( - p.builtin_script.is_some(), + p.scripting.builtin_script.is_some(), "carries the embedded pusher script" ); // 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"). - 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"); } 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] @@ -135,5 +135,5 @@ fn pusher_round_trips_to_its_keyword() { let mut id = 0; let p = pusher(&board2, &mut id); assert_eq!((p.x, p.y), (0, 0)); - assert!(p.builtin_script.is_some()); + assert!(p.scripting.builtin_script.is_some()); } diff --git a/kiln-core/src/tests/map_file/round_trip.rs b/kiln-core/src/tests/map_file/round_trip.rs index 6b9335b..cf5a5e7 100644 --- a/kiln-core/src/tests/map_file/round_trip.rs +++ b/kiln-core/src/tests/map_file/round_trip.rs @@ -60,8 +60,8 @@ fn object_tags_round_trip_through_toml() { ); let board = load_board(&toml); let obj0 = &board.objects[&1]; - assert!(obj0.tags.contains("enemy") && obj0.tags.contains("boss")); - assert_eq!(obj0.tags.len(), 2); + assert!(obj0.scripting.tags.contains("enemy") && obj0.scripting.tags.contains("boss")); + assert_eq!(obj0.scripting.tags.len(), 2); // Saved tags must be sorted alphabetically (boss before enemy). 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"); 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] @@ -95,7 +95,7 @@ fn object_name_round_trips_through_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(); assert!( @@ -103,7 +103,7 @@ fn object_name_round_trips_through_toml() { "name must appear in saved TOML" ); 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] @@ -111,7 +111,7 @@ fn unnamed_object_name_stays_none_through_toml() { let toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY])); let board2 = round_trip(&toml); assert_eq!( - board2.objects[&1].name, None, + board2.objects[&1].scripting.name, None, "unnamed object must round-trip as None" ); } diff --git a/kiln-core/src/tests/map_file/spinners.rs b/kiln-core/src/tests/map_file/spinners.rs index ddd5f6d..4110846 100644 --- a/kiln-core/src/tests/map_file/spinners.rs +++ b/kiln-core/src/tests/map_file/spinners.rs @@ -18,16 +18,16 @@ fn spinner_loads_as_a_tagged_scripted_solid_object() { let (_, obj) = board .objects .iter() - .find(|(_, o)| o.tags.contains("BUILTIN_spinner_cw")) + .find(|(_, o)| o.scripting.tags.contains("BUILTIN_spinner_cw")) .expect("spinner object"); assert_eq!((obj.x, obj.y), (0, 0)); assert!( - obj.builtin_script.is_some(), + obj.scripting.builtin_script.is_some(), "carries the embedded spinner script" ); // 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"). - 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"); } @@ -53,8 +53,8 @@ fn spinner_round_trips_to_its_keyword() { let (_, obj) = board2 .objects .iter() - .find(|(_, o)| o.tags.contains("BUILTIN_spinner_ccw")) + .find(|(_, o)| o.scripting.tags.contains("BUILTIN_spinner_ccw")) .expect("spinner object after round-trip"); assert_eq!((obj.x, obj.y), (0, 0)); - assert!(obj.builtin_script.is_some()); + assert!(obj.scripting.builtin_script.is_some()); } diff --git a/kiln-core/src/tests/map_file/transporters.rs b/kiln-core/src/tests/map_file/transporters.rs index 8a904b5..78b2365 100644 --- a/kiln-core/src/tests/map_file/transporters.rs +++ b/kiln-core/src/tests/map_file/transporters.rs @@ -22,10 +22,10 @@ fn transporter_loads_as_a_tagged_scripted_object() { let (_, obj) = board .objects .iter() - .find(|(_, o)| o.tags.contains("BUILTIN_transporter_east")) + .find(|(_, o)| o.scripting.tags.contains("BUILTIN_transporter_east")) .expect("transporter object"); 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] @@ -145,7 +145,7 @@ fn transporter_round_trips_to_its_keyword() { board2 .objects .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", ); } diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index 7110cc7..ba2d16e 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -5,7 +5,7 @@ mod map_file; mod movement; mod scripting; -use crate::archetype::Archetype; +use crate::builtin::Archetype; use crate::board::Board; use crate::floor::Floor; use crate::game::GameState; @@ -24,7 +24,7 @@ fn board_with_object( ) -> (Board, HashMap) { let mut object = ObjectDef::new(0, 0); 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 { name: "test".into(), width: 1, @@ -57,7 +57,7 @@ fn scripts_from(pairs: &[(&str, &str)]) -> HashMap { /// Returns an `ObjectDef` at `(x, y)` bound to the named script. fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef { let mut o = ObjectDef::new(x, y); - o.script_name = Some(script.to_string()); + o.scripting.script_name = Some(script.to_string()); o } diff --git a/kiln-core/src/tests/movement.rs b/kiln-core/src/tests/movement.rs index 55a7507..8f9a629 100644 --- a/kiln-core/src/tests/movement.rs +++ b/kiln-core/src/tests/movement.rs @@ -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::game::GameState; use crate::glyph::Glyph; diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 875e611..f18f893 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -131,7 +131,7 @@ fn set_tag_adds_and_removes_via_my_id() { ); let mut game = GameState::with_scripts(board, scripts); 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. let (mut board2, scripts2) = board_with_object( @@ -143,11 +143,12 @@ fn set_tag_adds_and_removes_via_my_id() { .objects .get_mut(&1) .unwrap() + .scripting .tags .insert("active".to_string()); let mut game2 = GameState::with_scripts(board2, scripts2); game2.run_init(); - assert!(!game2.board().objects[&1].tags.contains("active")); + assert!(!game2.board().objects[&1].scripting.tags.contains("active")); } #[test] @@ -175,7 +176,7 @@ fn objects_with_tag_returns_matching_ids() { let obj1 = scripted_object(0, 0, "q"); let mut obj2 = scripted_object(1, 0, "none"); // 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 mut game = GameState::with_scripts( 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(). let (mut board, scripts) = 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); game.run_init(); 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. let obj1 = scripted_object(0, 0, "q"); 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 mut game = GameState::with_scripts( board, diff --git a/kiln-core/src/tile.rs b/kiln-core/src/tile.rs index 0a9d138..528c723 100644 --- a/kiln-core/src/tile.rs +++ b/kiln-core/src/tile.rs @@ -1,16 +1,21 @@ use std::collections::HashSet; use serde::{Deserialize, Serialize}; use crate::api::queue::ObjQueue; +use crate::{Builtin, Direction}; +use crate::floor::{Floor, FloorBiome}; use crate::glyph::Glyph; 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 #[derive(Serialize, Deserialize, PartialEq, Copy, Clone, Debug)] +#[serde(rename_all = "lowercase")] pub enum EnterResponse { /// Flat denial: don't let the move happen, block it. 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 Grab, /// 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 /// on top of it. 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, /// 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) Squish } -impl From for EnterResponse { - fn from(behavior: Behavior) -> EnterResponse { - if behavior.grab { - EnterResponse::Grab - } else if behavior.pushable != Pushable::No { - EnterResponse::Push(behavior.pushable) - } else if behavior.solid { - EnterResponse::Block - } else { - EnterResponse::Squish +impl EnterResponse { + /// Returns whether this object will transmit a push of the given direction through it: + /// - `Push(p)`, if p allows that direction + /// - `Grab`, because grabbable things act like push if something pushes them + /// - `Swap`, same reason + pub fn transmits_push(self, dir: Direction) -> bool { + match self { + EnterResponse::Grab | EnterResponse::Swap => true, + EnterResponse::Push(p) if p.allows(dir) => true, + _ => 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: /// - `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 -/// 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)] #[serde(rename = "lowercase")] pub enum DrawLayer { Above, Below } @@ -67,6 +86,7 @@ pub enum DrawLayer { Above, Below } #[serde(rename = "lowercase")] pub struct Optics { /// Opaque things block field of view + #[serde(default = "default_as_true")] pub opaque: bool, /// The brightness (in number of cells' radius) of light this /// 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 /// /// Its color will be tinted by the light sources illuminating it. + #[serde(default)] 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. /// TODO clean this up some, especially the script_name-vs-builtin_script dichotomy #[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 /// [`World::scripts`](crate::world::World::scripts) for a hand-authored object, /// 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 /// affect board movement, as it doesn't live in the grid. -#[derive(Serialize, Deserialize, Clone)] +#[derive(Clone)] pub struct Sensor { - /// The unique ID of this sensor - pub id: ObjectId, /// Where it is on the board: x coord pub x: usize, /// Where it is on the board: y coord pub y: usize, - /// What it looks like - pub glyph: Glyph, - /// Sensors interact with FOV and lighting - pub optics: Optics, + /// Whether this is drawn above or below the grid + pub draw_layer: DrawLayer, /// Sensors have scripting ability - #[serde(skip)] - pub scripting: Scripting, -} \ No newline at end of file + pub scripting: ScriptAttributes, +} + +/// 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, + pub glyph: Glyph, + #[serde(flatten, default)] + pub optics: Optics, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[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 { + &self.scriptable().name + } + fn tags(&self) -> &HashSet { + &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), +} + +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, + enter: EnterResponse, + #[serde(flatten)] + glyph: Glyph, + #[serde(flatten, default)] + optics: Optics, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tags: Vec, + }, + Builtin { + kind: String, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + glyph: Option, + } +} + +pub trait IntoTile { + fn into_tile(self, next_object_id: &mut ObjectId) -> Result; +} + +impl IntoTile for TileSpec { + fn into_tile(self, next_object_id: &mut ObjectId) -> Result { + 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 { + 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 + } + } +} diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index 8b6f822..de0e49c 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -1,14 +1,9 @@ use std::cell::RefCell; use std::fmt::Display; use std::rc::Rc; -use crate::archetype::Archetype; -use crate::glyph::Glyph; -use color::Rgba8; use rhai::Dynamic; use serde::{Deserialize, Serialize}; -use crate::Board; use crate::log::LogLine; -use crate::tile::EnterResponse; /// 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. /// /// Ids are handed out by [`Board::add_object`] from the per-board @@ -78,263 +44,6 @@ pub struct Behavior { /// objects were reordered/destroyed. 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 { - 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 { - 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. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Direction { @@ -518,4 +227,31 @@ impl LogSink { pub fn take(&mut self) -> Vec { std::mem::take(&mut self.0.borrow_mut()) } -} \ No newline at end of file +} + +#[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)); + } +} diff --git a/kiln-core/src/world.rs b/kiln-core/src/world.rs index 3cd3511..4a4ef71 100644 --- a/kiln-core/src/world.rs +++ b/kiln-core/src/world.rs @@ -2,11 +2,11 @@ use crate::board::Board; use crate::fov::SIGHT_RADIUS; -use crate::map_file::MapFile; use serde::Deserialize; use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; +use crate::board_spec::BoardSpec; /// 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. #[serde(default, skip_serializing_if = "HashMap::is_empty")] scripts: HashMap, - /// 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`. - boards: HashMap, + boards: HashMap, } /// 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]`. start: String, /// Optional player torch radius on dark boards; defaults to [`SIGHT_RADIUS`]. + /// TODO goes away with inventory #[serde(default)] torch: Option, } @@ -100,11 +101,14 @@ pub fn load(path: &str) -> Result> { let text = std::fs::read_to_string(path)?; 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::>(); + + // Convert each BoardSpec into a Board. Grid-dimension mismatches are the only // hard errors; everything else is nonfatal and lands on Board::load_errors. let mut boards = HashMap::new(); - for (key, map_file) in wf.boards { - let board = Board::try_from(map_file).map_err(|e| format!("board '{}': {}", key, e))?; + for (key, spec) in wf.boards { + let board = spec.into_board(&script_names).map_err(|e| format!("board '{}': {}", key, e))?; boards.insert(key, Rc::new(RefCell::new(board))); } @@ -128,19 +132,19 @@ pub fn load(path: &str) -> Result> { #[cfg(test)] mod tests { + use std::assert_matches; use super::World; - use crate::archetype::Archetype; use crate::board::tests::open_board; use std::cell::RefCell; use std::collections::HashMap; 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 /// original board untouched (the property the editor's playtest relies on). #[test] 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(); boards.insert("start".to_string(), Rc::new(RefCell::new(board))); let world = World { @@ -153,22 +157,11 @@ mod tests { let copy = world.deep_clone(); // Stamp a wall into the copy's board. - copy.boards["start"].borrow_mut().place_archetype( - 1, - 0, - Archetype::Builtin(Builtin::Wall, "wall"), - Builtin::Wall.default_glyph_for("wall"), - ); + copy.boards["start"].borrow_mut().place(1, 0, Some(TileSpec::wall())).expect("could not place wall"); // The copy changed; the original is still empty at that cell. - assert_eq!( - copy.boards["start"].borrow().get(1, 0).1, - Archetype::Builtin(Builtin::Wall, "wall") - ); - assert_eq!( - world.boards["start"].borrow().get(1, 0).1, - Archetype::Empty - ); + assert_matches!(copy.boards["start"].borrow().get(1, 0), Some(Tile::Object(_))); + assert!(world.boards["start"].borrow().get(1, 0).is_none()); // And they are genuinely different allocations. assert!(!Rc::ptr_eq(&world.boards["start"], ©.boards["start"])); } diff --git a/kiln-tui/src/editor.rs b/kiln-tui/src/editor.rs index 934768b..5e09ee7 100644 --- a/kiln-tui/src/editor.rs +++ b/kiln-tui/src/editor.rs @@ -20,7 +20,7 @@ use kiln_core::game::GameState; use kiln_core::glyph::Glyph; use kiln_core::log::LogLine; 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::dialog::{Dialog, DialogResult, ListDialogResponse}; use ratatui::Frame; @@ -31,6 +31,7 @@ use ratatui::symbols::merge::MergeStrategy; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph}; use std::cell::{Ref, RefMut}; +use kiln_core::tile::TileSpec; /// How long each half of the cursor blink lasts, in seconds. 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). /// Defaults to [`Archetype::Wall`]; changed via the Terrain / Pushers menus /// ([`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 /// whenever the archetype changes ([`set_current`](EditorState::set_current)), and /// overridable via the glyph picker (`g`). @@ -112,7 +113,7 @@ impl EditorState { code_editor: None, glyph_dialog: None, cursor, - current_archetype: Archetype::Builtin(Builtin::Wall, "wall"), + current_archetype: "wall", current_glyph: Builtin::Wall.default_glyph_for("wall"), draw_mode: false, sidebar_width: DEFAULT_SIDEBAR_WIDTH, @@ -241,17 +242,21 @@ impl EditorState { /// 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 /// the Terrain / Pushers menu choices. - pub(crate) fn set_current(&mut self, archetype: Archetype) { - self.current_archetype = archetype; - self.current_glyph = self.current_archetype.default_glyph(); + pub(crate) fn set_current(&mut self, archetype: &'static str) { + if let Some((builtin, variant)) = Builtin::from_name(archetype) { + self.current_archetype = variant; + self.current_glyph = builtin.default_glyph_for(variant); + } } /// Stamps the current drawing thing (archetype + glyph) into the cell under the /// cursor, applying the placement rules (see [`Board::place_archetype`]). pub(crate) fn place_current(&mut self) { let (x, y) = (self.cursor.0 as usize, self.cursor.1 as usize); - let (arch, glyph) = (self.current_archetype, self.current_glyph); - self.board_mut().place_archetype(x, y, arch, glyph); + let res = self.board_mut().place(x, y, Some(TileSpec::Builtin { kind: self.current_archetype.to_string(), glyph: Some(self.current_glyph) })); + if let Err(e) = res { + self.log.push(LogLine::error(e)) + } } /// Toggles draw mode (whether cursor movement auto-stamps the current thing). @@ -330,12 +335,6 @@ impl EditorState { let mut world = self.world.deep_clone(); // Start the playtest on the board open in the editor, not world.start. 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); game.log(LogLine::raw("[esc] to return to the editor")); game.run_init(); @@ -520,7 +519,7 @@ fn draw_footer_lines<'a>( // Separator marking off the footer from the menu above. Line::from(Span::styled("─".repeat(64), sep_style)), // 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![ Span::styled("[G] ", key_style), Span::styled("Glyph ", label_style), diff --git a/kiln-tui/src/editor_menu.rs b/kiln-tui/src/editor_menu.rs index a7e8fa9..10bb411 100644 --- a/kiln-tui/src/editor_menu.rs +++ b/kiln-tui/src/editor_menu.rs @@ -1,7 +1,7 @@ use crate::editor::EditorState; use crate::menu::{MenuItem, MenuKey}; use crate::mode::PendingMode; -use kiln_core::{Archetype, Builtin}; +use kiln_core::Builtin; use kiln_core::keys::KeyType; use kiln_core::glyph::Glyph; @@ -77,10 +77,10 @@ impl MenuLevel { MenuEntry::item('c', "Custom glyph [TODO]", |_| {}), ], MenuLevel::Terrain => vec![ - MenuEntry::item('w', "Wall", |ed| ed.set_current(Archetype::Builtin(Builtin::Wall, "wall"))), - MenuEntry::item('c', "■ Crate", |ed| ed.set_current(Archetype::Builtin(Builtin::Crate, "crate"))), - MenuEntry::item('v', "↕ Crate", |ed| ed.set_current(Archetype::Builtin(Builtin::VCrate, "vcrate"))), - MenuEntry::item('h', "↔ Crate", |ed| ed.set_current(Archetype::Builtin(Builtin::HCrate, "hcrate"))), + MenuEntry::item('w', "Wall", |ed| ed.set_current("wall")), + MenuEntry::item('c', "■ Crate", |ed| ed.set_current("crate")), + MenuEntry::item('v', "↕ Crate", |ed| ed.set_current("vcrate")), + MenuEntry::item('h', "↔ Crate", |ed| ed.set_current("hcrate")), ], MenuLevel::Machines => vec![ MenuEntry::item('p', "Pushers...", |ed| ed.menu.push(MenuLevel::Pushers)), @@ -88,56 +88,56 @@ impl MenuLevel { ed.menu.push(MenuLevel::Transporters) }), 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| { - ed.set_current(Archetype::Builtin(Builtin::Spinner, "spinner_ccw")) + ed.set_current("spinner_ccw") }), ], MenuLevel::Pushers => vec![ MenuEntry::item('n', "▲ Pusher", |ed| { - ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_north")) + ed.set_current("pusher_north") }), MenuEntry::item('s', "▼ Pusher", |ed| { - ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_south")) + ed.set_current("pusher_south") }), MenuEntry::item('e', "► Pusher", |ed| { - ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_east")) + ed.set_current("pusher_east") }), MenuEntry::item('w', "◄ Pusher", |ed| { - ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west")) + ed.set_current("pusher_west") }), ], MenuLevel::Transporters => vec![ MenuEntry::item('n', "▲ Transporter", |ed| { - ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_north")) + ed.set_current("transporter_north") }), MenuEntry::item('s', "▼ Transporter", |ed| { - ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_south")) + ed.set_current("transporter_south") }), MenuEntry::item('e', "► Transporter", |ed| { - ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_east")) + ed.set_current("transporter_east") }), MenuEntry::item('w', "◄ Transporter", |ed| { - ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_west")) + ed.set_current("transporter_west") }), ], 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 - ed.set_current(Archetype::Builtin(Builtin::Gem, "gem")) + ed.set_current("gem") }), - MenuEntry::glyph('h', Archetype::Builtin(Builtin::Heart, "heart").default_glyph(), "Heart", - |ed| ed.set_current(Archetype::Builtin(Builtin::Heart, "heart"))), + MenuEntry::glyph('h', Builtin::Heart.default_glyph_for("heart"), "Heart", + |ed| ed.set_current("heart")), MenuEntry::Separator, - MenuEntry::glyph('b', KeyType::Blue.glyph(), "Blue key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_blue"))), - MenuEntry::glyph('g', KeyType::Green.glyph(),"Green key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_green"))), - MenuEntry::glyph('c', KeyType::Cyan.glyph(),"Cyan key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_cyan"))), - MenuEntry::glyph('r', KeyType::Red.glyph(),"Red key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_red"))), - MenuEntry::glyph('p', KeyType::Purple.glyph(),"Purple key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_purple"))), - MenuEntry::glyph('o', KeyType::Orange.glyph(), "Orange key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_orange"))), - MenuEntry::glyph('y', KeyType::Yellow.glyph(), "Yellow key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_yellow"))), - MenuEntry::glyph('w', KeyType::White.glyph(), "White key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_white"))), + 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("key_green")), + 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("key_red")), + 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("key_orange")), + 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("key_white")), ], } } diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index 798deac..8185d79 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -36,7 +36,8 @@ pub struct BoardWidget<'a> { impl<'a> BoardWidget<'a> { /// Creates a widget that renders `board`, scrolling to follow the player. 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 } } @@ -180,11 +181,11 @@ mod tests { use super::{BoardWidget, DARKNESS_BG}; use crate::utils::rgba8_to_color; use kiln_core::Board; - use kiln_core::map_file::MapFile; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; 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 /// and a wall at x=2 occluding the two cells behind it. @@ -201,7 +202,7 @@ mod tests { "@" = { kind = "player" } "#" = { 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() } @@ -255,7 +256,7 @@ mod tests { [grid.palette] "L" = { kind = "object", tile = 1, fg = "#ff0000", bg = "#000000", solid = false, light = 2 } "##; - let board = Board::try_from(toml::from_str::(toml).unwrap()).unwrap(); + let board = Board::try_from(toml::from_str::(toml).unwrap()).unwrap(); let fov = board.lighting(0); // no player torch — only the object lights let area = Rect::new(0, 0, 6, 1); diff --git a/kiln-tui/src/speech.rs b/kiln-tui/src/speech.rs index 76c213a..ae9c220 100644 --- a/kiln-tui/src/speech.rs +++ b/kiln-tui/src/speech.rs @@ -113,11 +113,12 @@ impl Widget for SpeechBubblesWidget<'_> { .bg(Color::Rgb(20, 20, 40)); // Determine the player's screen position for bubble avoidance. + let player = self.board.player_pos(); let (px, py, player_vis) = board_screen_pos( area, self.board, - self.board.player.x.max(0) as usize, - self.board.player.y.max(0) as usize, + player.0, + player.1, ); let player = player_vis.then_some((px, py)); @@ -126,11 +127,12 @@ impl Widget for SpeechBubblesWidget<'_> { .bubbles .iter() .filter_map(|b| { - let obj = self.board.objects.get(&b.object_id)?; - let (sx, sy, on_screen) = board_screen_pos(area, self.board, obj.x, obj.y); + let obj = self.board.get_hookable(b.object_id)?; + 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 // 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)) }) .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; /// `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) { - let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x as i32); - let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y as i32); + let player = board.player_pos(); + 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_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; diff --git a/kiln-tui/src/status.rs b/kiln-tui/src/status.rs index 523b6da..adf527a 100644 --- a/kiln-tui/src/status.rs +++ b/kiln-tui/src/status.rs @@ -7,7 +7,7 @@ use std::collections::VecDeque; use crate::utils::rgba8_to_color; -use kiln_core::{Archetype, Builtin}; +use kiln_core::Builtin; use kiln_core::cp437::tile_to_char; use ratatui::buffer::Buffer; 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 // 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_style = Style::default().fg(rgba8_to_color(gem_glyph.fg)); diff --git a/maps/start.toml b/maps/start.toml index d71fd7c..928f5b5 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -191,7 +191,7 @@ width = 60 height = 25 # A single procedural grass floor across the whole board (the old mixed # 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 portal). A space is always a transparent empty cell, so the floor shows through. diff --git a/maps/tiny.toml b/maps/tiny.toml index 836df4f..57253a3 100644 --- a/maps/tiny.toml +++ b/maps/tiny.toml @@ -21,16 +21,13 @@ fn tick(me, dt) { } """ -[boards.start.map] +[boards.start] name = "Starting Room" width = 21 height = 6 # No floor attribute → a blank (black) floor. -# The single grid: all solids and most non-solids. A space is always a transparent -# empty cell, so the floor shows through. -[boards.start.grid] -content = """ +grid = """ ##################### # # # G @ # @@ -38,8 +35,9 @@ content = """ # # ##################### """ -[boards.start.grid.palette] -"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" } -"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } -"@" = { kind = "player" } -"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000", solid = false, script_name = "greeter" } +[boards.start.palette] +"#" = { type = "builtin", kind = "wall" } +"o" = { type = "builtin", kind = "crate", glyph = { tile = 254, fg = "#aaaaaa", bg = "#000000" } } +"@" = { type = "player" } +# "G" = { type = "object", tile = 35, fg = "#aa3333", bg = "#000000", enter = "block", script_name = "greeter" } +"G" = { type = "builtin", kind = "gem" }