diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index d6d793c..850d473 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -41,8 +41,8 @@ pub(crate) enum Action { Log(LogLine), /// Add (`present = true`) or remove (`present = false`) `tag` on `target`. SetTag { target: ObjectId, tag: String, present: bool }, - /// Display a speech bubble above the source object for [`crate::game::SAY_DURATION`] seconds. - Say(String), + /// Display a speech bubble above the source object for `duration` seconds. + Say(String, f64), /// Pause draining this object's queue for `secs` seconds. Never reaches the board queue. Delay(f64), /// Set the source object's foreground and/or background color. @@ -95,9 +95,10 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map { m.insert("tag".into(), Dynamic::from(tag.clone())); m.insert("present".into(), Dynamic::from(*present)); } - Action::Say(text) => { - m.insert("type".into(), ds("Say")); - m.insert("msg".into(), Dynamic::from(text.clone())); + Action::Say(text, dur) => { + m.insert("type".into(), ds("Say")); + m.insert("msg".into(), Dynamic::from(text.clone())); + m.insert("duration".into(), Dynamic::from(*dur)); } Action::Delay(secs) => { m.insert("type".into(), ds("Delay")); diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 164df97..aea44ba 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -1,7 +1,6 @@ use std::collections::BTreeMap; use crate::archetype::Archetype; use crate::archetype::Archetype::Empty; -use crate::font::FontSpec; use crate::glyph::Glyph; use crate::log::LogLine; use crate::object_def::ObjectDef; @@ -57,10 +56,6 @@ pub struct Board { pub next_object_id: ObjectId, /// Portals on this board. Parsed from the map file; not yet active. pub portals: Vec, - /// Optional font override for this board. When `None`, the app default is used. - pub font: Option, - /// Integer scale factor applied to tiles when rendering this board. 1 = natural size. - pub zoom: u32, /// 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`) @@ -343,8 +338,6 @@ pub(crate) mod tests { objects: object_map, next_object_id, portals: Vec::new(), - font: None, - zoom: 1, board_script_name: None, load_errors: Vec::new(), } diff --git a/kiln-core/src/font.rs b/kiln-core/src/font.rs deleted file mode 100644 index 6a76891..0000000 --- a/kiln-core/src/font.rs +++ /dev/null @@ -1,13 +0,0 @@ -/// Specifies a bitmap font for a board. -/// -/// Each board can optionally specify a font image and tile dimensions. -/// When absent, the app's default embedded CP437 font is used. -#[derive(Clone, PartialEq, Eq)] -pub struct FontSpec { - /// Path to the PNG font image, relative to the working directory. - pub path: String, - /// Width of each tile in the font image, in pixels. - pub tile_w: u32, - /// Height of each tile in the font image, in pixels. - pub tile_h: u32, -} \ No newline at end of file diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index f07c70d..a083bad 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -197,10 +197,10 @@ impl GameState { } // Replace any existing bubble from this object so repeated say() calls // don't stack visually — the new text resets the timer. - Action::Say(text) => new_bubbles.push(SpeechBubble { + Action::Say(text, duration) => new_bubbles.push(SpeechBubble { object_id: ba.source, text, - remaining: SAY_DURATION, + remaining: duration, }), // Delays are consumed by ScriptHost::drain and never reach the board queue. Action::Delay(_) => {} diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index a933753..17369cf 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -12,7 +12,6 @@ pub mod world; pub mod script; pub mod glyph; mod action; -mod font; mod utils; mod archetype; mod object_def; diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 8b302dd..f949c25 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -3,11 +3,11 @@ use crate::board::Board; use crate::log::LogLine; use color::Rgba8; use serde::{Deserialize, Serialize}; +use std::collections::hash_map::Entry; use std::collections::{BTreeMap, HashMap}; use std::convert::TryFrom; use std::path::Path; use crate::archetype::Archetype; -use crate::font::FontSpec; use crate::glyph::Glyph; use crate::object_def::ObjectDef; use crate::utils::{ObjectId, Player, PortalDef}; @@ -28,9 +28,6 @@ pub struct MapFile { pub palette: HashMap, /// The `[grid]` section: the multi-line string that defines cell layout. pub grid: GridData, - /// Optional `[font]` section specifying a bitmap font for this board. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub font: Option, /// Optional `[floor]` declaration: the visual floor layer (a generator name, /// a single glyph, or a grid with its own palette). See [`FloorSpec`]. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -58,22 +55,6 @@ pub struct MapHeader { /// 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, - /// Integer tile scale factor. 1 = natural size. Omitted from files when 1 (the default). - #[serde(default = "default_zoom", skip_serializing_if = "is_zoom_default")] - pub zoom: u32, -} - -/// The `[font]` section of a map file, specifying a bitmap font for this board. -/// -/// When absent, the app's default embedded CP437 font is used. -#[derive(Deserialize, Serialize)] -pub struct FontHeader { - /// Path to the PNG font image, relative to the working directory. - pub path: String, - /// Width of each tile in the font image, in pixels. - pub tile_w: u32, - /// Height of each tile in the font image, in pixels. - pub tile_h: u32, } /// A tile index in a palette entry: either a plain integer or a character literal. @@ -200,11 +181,59 @@ pub(crate) struct MapFileObjectEntry { fn default_true() -> bool { true } -fn default_zoom() -> u32 { - 1 + +/// Constructs a [`Glyph`] from the three raw TOML-sourced fields. +fn make_glyph(tile: u32, fg: &str, bg: &str) -> Glyph { + Glyph { tile, fg: parse_color(fg), bg: parse_color(bg) } } -fn is_zoom_default(z: &u32) -> bool { - *z == 1 + +/// Constructs a serializable [`PaletteEntry`] from core board types. +fn make_palette_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry { + PaletteEntry { + archetype: arch.name().to_string(), + tile: TileIndex::Num(glyph.tile), + fg: color_to_hex(glyph.fg), + bg: color_to_hex(glyph.bg), + } +} + +/// Resolves the player's starting cell from `player_start`, falling back to `(0, 0)` on any error. +fn resolve_player_start( + start: &PlayerStart, + w: usize, + h: usize, + player_grid_pos: Option<(usize, usize)>, + player_grid_count: usize, + errors: &mut Vec, +) -> (usize, usize) { + match start { + PlayerStart::Coord([x, y]) + if *x >= 0 && *y >= 0 && (*x as usize) < w && (*y as usize) < h => + { + (*x as usize, *y as usize) + } + PlayerStart::Coord([x, y]) => { + errors.push(LogLine::error(format!( + "player_start ({x}, {y}) is out of bounds; placing player at (0, 0)" + ))); + (0, 0) + } + PlayerStart::Char(c) => match (player_grid_pos, player_grid_count) { + (Some(pos), 1) => pos, + (Some(pos), n) => { + errors.push(LogLine::error(format!( + "player char '{c}' appears {n} times in the grid; using the first" + ))); + pos + } + _ => { + errors.push(LogLine::error(format!( + "player char '{c}' not found in the grid; placing player at (0, 0)" + ))); + (0, 0) + } + }, + } } /// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`]. @@ -270,11 +299,7 @@ impl TryFrom for Board { let tile = entry.tile.into_u32(); match Archetype::try_from(entry.archetype.as_str()) { Ok(archetype) => { - let glyph = Glyph { - tile, - fg: parse_color(&entry.fg), - bg: parse_color(&entry.bg), - }; + let glyph = make_glyph(tile, &entry.fg, &entry.bg); palette.insert(key_char, (glyph, archetype)); } Err(e) => { @@ -351,7 +376,6 @@ impl TryFrom for Board { continue; } // It must be unique across objects — keep the first claimant, drop later ones. - use std::collections::hash_map::Entry; match palette_char_owner.entry(ch) { Entry::Occupied(_) => { load_errors.push(LogLine::error(format!( @@ -402,7 +426,7 @@ impl TryFrom for Board { if skip[owner] { continue; } - if obj_palette_positions.get(&ch).map_or(true, Vec::is_empty) { + if obj_palette_positions.get(&ch).is_none_or(Vec::is_empty) { load_errors.push(LogLine::error(format!( "object palette char '{ch}' not found in the grid; skipping object" ))); @@ -410,12 +434,6 @@ impl TryFrom for Board { } } - let font = mf.font.map(|f| FontSpec { - path: f.path, - tile_w: f.tile_w, - tile_h: f.tile_h, - }); - // Build the cached floor layer from the (optional) `[floor]` declaration. // Best-effort: any problem is recorded on `load_errors`, not fatal. The // raw spec is kept on the board so a save can round-trip it. @@ -430,34 +448,14 @@ impl TryFrom for Board { // Resolve the player's cell (nonfatal; fall back to (0, 0) on any problem), // then let the player *win* its cell: clear solid terrain under it and claim // the cell so a solid object placed here is dropped by the loop below. - let (px, py) = match mf.map.player_start { - PlayerStart::Coord([x, y]) - if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h => - { - (x as usize, y as usize) - } - PlayerStart::Coord([x, y]) => { - load_errors.push(LogLine::error(format!( - "player_start ({x}, {y}) is out of bounds; placing player at (0, 0)" - ))); - (0, 0) - } - PlayerStart::Char(c) => match (player_grid_pos, player_grid_count) { - (Some(pos), 1) => pos, - (Some(pos), n) => { - load_errors.push(LogLine::error(format!( - "player char '{c}' appears {n} times in the grid; using the first" - ))); - pos - } - _ => { - load_errors.push(LogLine::error(format!( - "player char '{c}' not found in the grid; placing player at (0, 0)" - ))); - (0, 0) - } - }, - }; + let (px, py) = resolve_player_start( + &mf.map.player_start, + w, + h, + player_grid_pos, + player_grid_count, + &mut load_errors, + ); let pidx = py * w + px; if solid_occupied[pidx] { cells[pidx] = canonical_empty; @@ -504,7 +502,6 @@ impl TryFrom for Board { // Validate name uniqueness: first claimant keeps the name, later ones // have their name cleared and a nonfatal error is recorded. e.name.as_ref().and_then(|n| { - use std::collections::hash_map::Entry; match seen_names.entry(n.clone()) { Entry::Vacant(v) => { v.insert(i); Some(n.clone()) } Entry::Occupied(o) => { @@ -524,11 +521,7 @@ impl TryFrom for Board { id: 0, // stamped by Board::add_object x, y, - glyph: Glyph { - tile: e.tile.into_u32(), - fg: parse_color(&e.fg), - bg: parse_color(&e.bg), - }, + glyph: make_glyph(e.tile.into_u32(), &e.fg, &e.bg), solid: e.solid, opaque: e.opaque, pushable: e.pushable, @@ -572,8 +565,6 @@ impl TryFrom for Board { objects, next_object_id, portals: mf.portals, - font, - zoom: mf.map.zoom, board_script_name: mf.map.board_script_name, load_errors, }) @@ -607,33 +598,14 @@ impl From<&Board> for MapFile { for x in 0..board.width { let (glyph, arch) = board.get(x, y); let key = (*glyph, *arch); - if let std::collections::hash_map::Entry::Vacant(e) = cell_to_key.entry(key) { - if key == canonical_empty { - e.insert(' '); - palette.insert( - " ".to_string(), - PaletteEntry { - archetype: arch.name().to_string(), - tile: TileIndex::Num(glyph.tile), - fg: color_to_hex(glyph.fg), - bg: color_to_hex(glyph.bg), - }, - ); + if let Entry::Vacant(e) = cell_to_key.entry(key) { + let ch = if key == canonical_empty { + ' ' } else { - let ch = *pool_iter - .next() - .expect("board has more than 92 unique non-canonical cell types"); - e.insert(ch); - palette.insert( - ch.to_string(), - PaletteEntry { - archetype: arch.name().to_string(), - tile: TileIndex::Num(glyph.tile), - fg: color_to_hex(glyph.fg), - bg: color_to_hex(glyph.bg), - }, - ); - } + *pool_iter.next().expect("board has more than 92 unique non-canonical cell types") + }; + e.insert(ch); + palette.insert(ch.to_string(), make_palette_entry(*glyph, *arch)); } } } @@ -651,12 +623,6 @@ impl From<&Board> for MapFile { // Trailing newline ensures the last row is complete; str::lines handles it correctly. let content = rows.join("\n") + "\n"; - let font = board.font.as_ref().map(|f| FontHeader { - path: f.path.clone(), - tile_w: f.tile_w, - tile_h: f.tile_h, - }); - // Convert ObjectDef → MapFileObjectEntry, encoding the glyph as TOML-compatible fields. let objects: Vec = board .objects @@ -680,16 +646,7 @@ impl From<&Board> for MapFile { }) .collect(); - let portals = board - .portals - .iter() - .map(|p| PortalDef { - x: p.x, - y: p.y, - target_map: p.target_map.clone(), - target_entry: p.target_entry.clone(), - }) - .collect(); + let portals = board.portals.clone(); MapFile { map: MapHeader { @@ -698,11 +655,9 @@ impl From<&Board> for MapFile { height: board.height, player_start: PlayerStart::Coord([board.player.x, board.player.y]), board_script_name: board.board_script_name.clone(), - zoom: board.zoom, }, palette, grid: GridData { content }, - font, // Round-trip the original floor declaration verbatim (generators // re-randomize on the next load; literal glyph grids are exact). floor: board.floor_spec.clone(), diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 8db1c7e..92e66dd 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -9,15 +9,22 @@ //! - `bump(id)` — run when another mover steps into this object's cell, with the //! bumper's object index (`-1` for the player); see [`ScriptHost::run_bump`]. //! -//! ## Script constants in scope +//! ## Script constants //! -//! Every object scope contains: +//! Object-specific handles live in the per-object scope (visible to the +//! top-level hook Rust calls): //! //! | Name | Type | Description | //! |---|---|---| //! | `Board` | `Board` | Read-only world handle. | //! | `Queue` | `Queue` | This object's pending-action queue. | //! | `Me` | `Me` | Self-reference (id, name, x, y, tags, glyph). | +//! +//! Direction and color constants are registered as a global Rhai module and +//! are visible to all functions at any call depth, including Rhai-to-Rhai calls: +//! +//! | Name | Type | Description | +//! |---|---|---| //! | `North`/`South`/`East`/`West` | `Direction` | Cardinal directions. | //! | `Black`..`White` | `String` | 16 EGA/VGA color hex strings. | //! @@ -46,11 +53,12 @@ //! `Queue.length()`, `Queue.clear()`, `Queue.peek()`, `Queue.pop()` use crate::action::{action_to_map, Action, ScrollLine, MOVE_COST}; +use crate::game::SAY_DURATION; use crate::board::Board; use crate::log::LogLine; use crate::map_file::{color_to_hex, parse_color}; use crate::utils::{Direction, ObjectId, ScriptArg}; -use rhai::{Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope, AST}; +use rhai::{Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module, NativeCallContext, Scope, AST}; use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; @@ -179,6 +187,7 @@ impl ScriptHost { register_me_type(&mut engine); register_object_info_type(&mut engine); register_glyph_type(&mut engine); + register_global_constants(&mut engine); let board = board_cell.borrow(); @@ -612,7 +621,11 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { let q = queues.clone(); engine.register_fn("say", move |ctx: NativeCallContext, msg: ImmutableString| { - emit(&q, source_of(&ctx), Action::Say(msg.to_string())); + emit(&q, source_of(&ctx), Action::Say(msg.to_string(), SAY_DURATION)); + }); + let q = queues.clone(); + engine.register_fn("say", move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| { + emit(&q, source_of(&ctx), Action::Say(msg.to_string(), dur)); }); // scroll(lines): open a full-screen scrollable overlay. Each element of `lines` @@ -739,36 +752,44 @@ fn register_queue_api(engine: &mut Engine) { }); } -// ── Scope construction ──────────────────────────────────────────────────────── +// ── Scope construction & global constants ───────────────────────────────────── -/// Builds a fresh per-object scope with the Board view, Queue, Me, direction -/// constants, and the 16 EGA/VGA named color constants. +/// Registers direction and color constants as a global Rhai module so they are +/// visible to every function at any call depth, including Rhai-to-Rhai calls. +/// (Scope-level constants are only visible to the top-level function Rust calls.) +fn register_global_constants(engine: &mut Engine) { + let mut m = Module::new(); + m.set_var("North", Direction::North); + m.set_var("South", Direction::South); + m.set_var("East", Direction::East); + m.set_var("West", Direction::West); + // 16 EGA/VGA color constants as "#RRGGBB" strings. + m.set_var("Black", "#000000"); + m.set_var("Blue", "#0000AA"); + m.set_var("Green", "#00AA00"); + m.set_var("Cyan", "#00AAAA"); + m.set_var("Red", "#AA0000"); + m.set_var("Magenta", "#AA00AA"); + m.set_var("Brown", "#AA5500"); + m.set_var("LightGray", "#AAAAAA"); + m.set_var("DarkGray", "#555555"); + m.set_var("BrightBlue", "#5555FF"); + m.set_var("BrightGreen", "#55FF55"); + m.set_var("BrightCyan", "#55FFFF"); + m.set_var("BrightRed", "#FF5555"); + m.set_var("BrightMagenta", "#FF55FF"); + m.set_var("Yellow", "#FFFF55"); + m.set_var("White", "#FFFFFF"); + engine.register_global_module(m.into()); +} + +/// Builds a fresh per-object scope containing only the object-specific handles: +/// the Board view, the object's Queue, and the Me self-reference. fn new_object_scope(board: &BoardRef, queue: &ObjQueue, object_id: ObjectId) -> Scope<'static> { let mut scope = Scope::new(); scope.push_constant("Board", board.clone()); scope.push_constant("Queue", queue.clone()); scope.push_constant("Me", Me { object_id, board: board.clone() }); - scope.push_constant("North", Direction::North); - scope.push_constant("South", Direction::South); - scope.push_constant("East", Direction::East); - scope.push_constant("West", Direction::West); - // 16 EGA/VGA color constants as "#RRGGBB" strings. - scope.push_constant("Black", "#000000"); - scope.push_constant("Blue", "#0000AA"); - scope.push_constant("Green", "#00AA00"); - scope.push_constant("Cyan", "#00AAAA"); - scope.push_constant("Red", "#AA0000"); - scope.push_constant("Magenta", "#AA00AA"); - scope.push_constant("Brown", "#AA5500"); - scope.push_constant("LightGray", "#AAAAAA"); - scope.push_constant("DarkGray", "#555555"); - scope.push_constant("BrightBlue", "#5555FF"); - scope.push_constant("BrightGreen", "#55FF55"); - scope.push_constant("BrightCyan", "#55FFFF"); - scope.push_constant("BrightRed", "#FF5555"); - scope.push_constant("BrightMagenta","#FF55FF"); - scope.push_constant("Yellow", "#FFFF55"); - scope.push_constant("White", "#FFFFFF"); scope } diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index fe81fd6..3983bac 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -30,8 +30,6 @@ fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> ( objects: BTreeMap::from([(1, object)]), next_object_id: 2, portals: Vec::new(), - font: None, - zoom: 1, board_script_name: None, load_errors: Vec::new(), }; diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index 58b2685..54668c6 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -83,7 +83,7 @@ pub enum Solid { /// /// **Not yet runtime-wired.** Portal navigation (multi-board loading and /// switching) is a future feature. -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone)] pub struct PortalDef { /// Column of this portal on the board (0-indexed). pub x: usize, diff --git a/maps/start.toml b/maps/start.toml index b04d465..5a8ef24 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -1,6 +1,6 @@ [world] name = "Kiln Demo" -start = "house" +start = "start" # Named Rhai scripts shared across all boards in this world. # Objects reference a script by name via `script_name`. @@ -26,9 +26,21 @@ mover = """ // tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking // into a wall (or another object's already-queued move this tick). fn tick(dt) { - if Queue.length() == 0 { - move(East); - } + if Queue.length() == 0 { init(); } +} +fn init() { + move(East); + move(South); + move(North); + move(East); + say("Hey!", 0.5); + delay(0.5); + move(West); + move(South); + move(North); + move(West); + say("Ho!", 0.5); + delay(0.5); } // Fires when something steps into this object; id is the bumper's object id, // or -1 for the player.