diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index aea44ba..8acdb55 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -119,6 +119,11 @@ impl Board { return glyph } + // Portal: passable and non-opaque, but visible above the floor. + if self.portals.iter().any(|p| p.x == x && p.y == y) { + return PortalDef::default_glyph(); + } + self.floor[y * self.width + x] } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index a083bad..bf6efb0 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -5,7 +5,7 @@ use crate::script::ScriptHost; use crate::world::World; use std::cell::{Ref, RefMut}; use std::time::Duration; -use crate::utils::{Direction, ObjectId, ScriptArg, Solid}; +use crate::utils::{Direction, ObjectId, Player, ScriptArg, Solid}; /// How long a `say()` speech bubble stays on screen, in seconds. pub const SAY_DURATION: f64 = 3.0; @@ -57,6 +57,10 @@ pub struct GameState { /// An active scroll overlay opened by `scroll()`. `Some` while the overlay is /// visible; the front-end pauses ticks and closes it via [`close_scroll`](GameState::close_scroll). pub active_scroll: Option, + /// Remaining seconds for the board-entry transition animation, set to `1.0` + /// by [`enter_board`](GameState::enter_board). Front-ends tick this down and + /// may block input or show a visual effect while it is `Some(t)` where `t > 0`. + pub board_transition: Option, } impl GameState { @@ -79,6 +83,7 @@ impl GameState { scripts: host, speech_bubbles: Vec::new(), active_scroll: None, + board_transition: None, }; state.drain_errors(); state @@ -253,6 +258,48 @@ impl GameState { } } + /// Switches the active board, placing the player at the named arrival portal, + /// rebuilding the script host, and running `init()` hooks on the new board's objects. + /// + /// Called automatically by [`try_move`](GameState::try_move) when the player + /// steps onto a portal. Front-ends may check [`board_transition`](GameState::board_transition) + /// to play a visual effect during the switch. + pub fn enter_board(&mut self, target_map: &str, target_entry: &str) { + if !self.world.boards.contains_key(target_map) { + self.log.push(LogLine::error(format!("portal target board {target_map:?} not found"))); + return; + } + // 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)); + let (ax, ay) = match arrival { + Some(pos) => pos, + None => { + self.log.push(LogLine::error(format!( + "portal entry {target_entry:?} not found on board {target_map:?}" + ))); + return; + } + }; + // Clear per-board transient state. + self.speech_bubbles.clear(); + 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 = Player { x: ax as i32, y: ay as i32 }; + // Rebuild the script host for the new board's objects. + self.scripts = ScriptHost::new( + &self.world.boards[&self.current_board_name], + &self.world.scripts, + ); + // Stub hook for the front-end transition animation (1 second). + self.board_transition = Some(1.0); + // Run init hooks and resolve their queued actions. + self.run_init(); + } + /// Attempts to move the player one cell in `dir`. /// /// The move is ignored if the target cell is out of bounds, or it is neither @@ -261,6 +308,7 @@ impl GameState { /// object's `bump(-1)` hook fires (whether or not the player ends up moving). pub fn try_move(&mut self, dir: Direction) { let bumped; + let portal_target; { let (dx, dy): (i32, i32) = dir.into(); let mut board = self.board_mut(); @@ -278,8 +326,19 @@ impl GameState { board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise board.player.x = nx as i32; board.player.y = ny as i32; + // 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 hook. + if let Some((target_map, target_entry)) = portal_target { + self.enter_board(&target_map, &target_entry); + return; + } if let Some(idx) = bumped { self.scripts.run_bump(idx, -1); self.drain_errors(); diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index f949c25..3377f2d 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -37,7 +37,7 @@ pub struct MapFile { pub(crate) objects: Vec, /// Any `[[portals]]` entries. Optional; defaults to empty. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub portals: Vec, + pub(crate) portals: Vec, } /// The `[map]` header section of a map file. @@ -178,6 +178,34 @@ pub(crate) struct MapFileObjectEntry { name: Option, } +/// One `[[portals]]` entry in a map file. +/// +/// Used only for TOML serialization/deserialization. Converted to [`PortalDef`] +/// in the [`TryFrom`] impl. On save, always written with concrete `x`/`y` +/// (the `palette` form is a load-time convenience only). +/// +/// By convention, portal palette chars are digits (`'1'`–`'9'`), parallel to the +/// uppercase-letter convention for objects. +#[derive(Deserialize, Serialize)] +pub(crate) struct MapFilePortalEntry { + /// Board-unique name; also used as `target_entry` by the other end. + pub name: String, + /// Column of this portal (0-indexed). Mutually exclusive with `palette`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x: Option, + /// Row of this portal (0-indexed). See `x`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub y: Option, + /// Single grid character (conventionally a digit). Alternative to `x`/`y`. + /// A portal's char may appear only once in the grid. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub palette: Option, + /// 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, +} + fn default_true() -> bool { true } @@ -389,13 +417,69 @@ impl TryFrom for Board { } } - // Pass 2: walk the validated grid and build cells. Object palette chars + // Validate portal `palette` references — same rules as objects, plus portals + // may not claim chars already taken by objects (and vice versa). + // `portal_skip[i]` drops portal `i`; `portal_palette_char_owner` maps char → portal index. + let mut portal_skip = vec![false; mf.portals.len()]; + let mut portal_palette_char_owner: HashMap = HashMap::new(); + for (i, e) in mf.portals.iter().enumerate() { + let Some(p) = e.palette.as_deref() else { continue; }; + if e.x.is_some() || e.y.is_some() { + load_errors.push(LogLine::error(format!( + "portal {i} ({:?}): specify either x/y or palette, not both; skipping portal", e.name + ))); + portal_skip[i] = true; + continue; + } + let mut chs = p.chars(); + let (Some(ch), None) = (chs.next(), chs.next()) else { + load_errors.push(LogLine::error(format!( + "portal {i} ({:?}): palette must be a single character (got {p:?}); skipping portal", e.name + ))); + portal_skip[i] = true; + continue; + }; + if Some(ch) == player_char { + load_errors.push(LogLine::error(format!( + "portal {i} ({:?}): palette char '{ch}' is reserved for the player; skipping portal", e.name + ))); + portal_skip[i] = true; + continue; + } + if palette.contains_key(&ch) { + load_errors.push(LogLine::error(format!( + "portal {i} ({:?}): palette char '{ch}' is already a [palette] key; skipping portal", e.name + ))); + portal_skip[i] = true; + continue; + } + if palette_char_owner.contains_key(&ch) { + load_errors.push(LogLine::error(format!( + "portal {i} ({:?}): palette char '{ch}' is already used by an object; skipping portal", e.name + ))); + portal_skip[i] = true; + continue; + } + match portal_palette_char_owner.entry(ch) { + Entry::Occupied(_) => { + load_errors.push(LogLine::error(format!( + "portal {i} ({:?}): palette char '{ch}' is already used by another portal; skipping portal", e.name + ))); + portal_skip[i] = true; + } + Entry::Vacant(v) => { v.insert(i); } + } + } + + // Pass 2: walk the validated grid and build cells. Object/portal palette chars // become `Empty` floor (with their positions remembered); truly unknown // chars become a visible `ErrorBlock` so the cell count stays correct. let canonical_empty = (Archetype::Empty.default_glyph(), Archetype::Empty); let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h); // All grid positions (in reading order) at which each object palette char appears. let mut obj_palette_positions: HashMap> = HashMap::new(); + // The single grid position at which each portal palette char appears (first sighting). + let mut portal_palette_positions: HashMap> = HashMap::new(); // Where the player's char was first seen, and how many times (for recovery). let mut player_grid_pos: Option<(usize, usize)> = None; let mut player_grid_count: usize = 0; @@ -411,6 +495,9 @@ impl TryFrom for Board { } else if palette_char_owner.contains_key(&ch) { cells.push(canonical_empty); obj_palette_positions.entry(ch).or_default().push((x, y)); + } else if portal_palette_char_owner.contains_key(&ch) { + cells.push(canonical_empty); + portal_palette_positions.entry(ch).or_default().push((x, y)); } else { load_errors.push(LogLine::error(format!( "unknown grid character '{ch}' at ({x}, {y}); using error block" @@ -434,6 +521,26 @@ impl TryFrom for Board { } } + // Resolve portal palette chars: must appear exactly once (warn + use first if multiple). + for (&ch, &owner) in &portal_palette_char_owner { + if portal_skip[owner] { continue; } + let positions = portal_palette_positions.get(&ch).map(Vec::as_slice).unwrap_or(&[]); + match positions.len() { + 0 => { + load_errors.push(LogLine::error(format!( + "portal palette char '{ch}' not found in the grid; skipping portal" + ))); + portal_skip[owner] = true; + } + n if n > 1 => { + load_errors.push(LogLine::error(format!( + "portal palette char '{ch}' appears {n} times in the grid; using the first" + ))); + } + _ => {} + } + } + // 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. @@ -551,6 +658,44 @@ impl TryFrom for Board { } } + // Build the validated portal list, resolving palette chars to concrete (x, y). + let mut seen_portal_names: HashMap = HashMap::new(); + let mut portals: Vec = Vec::new(); + for (i, e) in mf.portals.into_iter().enumerate() { + if portal_skip[i] { continue; } + let (x, y) = if let Some(p) = e.palette.as_deref() { + let ch = p.chars().next().unwrap(); + let positions = portal_palette_positions.get(&ch).map(Vec::as_slice).unwrap_or(&[]); + if positions.is_empty() { continue; } + positions[0] + } else { + match (e.x, e.y) { + (Some(x), Some(y)) if x < w && y < h => (x, y), + (Some(x), Some(y)) => { + load_errors.push(LogLine::error(format!( + "portal {i} ({:?}): x/y ({x}, {y}) out of bounds; skipping portal", e.name + ))); + continue; + } + _ => { + load_errors.push(LogLine::error(format!( + "portal {i} ({:?}): must specify both x and y (or a palette char); skipping portal", e.name + ))); + continue; + } + } + }; + // Duplicate names: first claimant keeps the name, later ones are dropped. + if seen_portal_names.contains_key(&e.name) { + load_errors.push(LogLine::error(format!( + "portal {i}: name {:?} already used by another portal; skipping portal", e.name + ))); + continue; + } + seen_portal_names.insert(e.name.clone(), ()); + portals.push(PortalDef { name: e.name, x, y, target_map: e.target_map, target_entry: e.target_entry }); + } + Ok(Board { name: mf.map.name, width: w, @@ -564,7 +709,7 @@ impl TryFrom for Board { }, objects, next_object_id, - portals: mf.portals, + portals, board_script_name: mf.map.board_script_name, load_errors, }) @@ -646,7 +791,15 @@ impl From<&Board> for MapFile { }) .collect(); - let portals = board.portals.clone(); + // Always save portals with concrete x/y; the palette form is load-time only. + let portals: Vec = board.portals.iter().map(|p| MapFilePortalEntry { + name: p.name.clone(), + x: Some(p.x), + y: Some(p.y), + palette: None, + target_map: p.target_map.clone(), + target_entry: p.target_entry.clone(), + }).collect(); MapFile { map: MapHeader { diff --git a/kiln-core/src/tests/game_portals.rs b/kiln-core/src/tests/game_portals.rs new file mode 100644 index 0000000..5a76ff5 --- /dev/null +++ b/kiln-core/src/tests/game_portals.rs @@ -0,0 +1,98 @@ +use std::collections::{BTreeMap, HashMap}; +use std::rc::Rc; +use std::cell::RefCell; +use crate::archetype::Archetype; +use crate::board::Board; +use crate::game::GameState; +use crate::utils::{Direction, Player, PortalDef}; +use crate::world::World; + +/// Builds a 3×3 board with the player at `(px, py)` and the given portals. +fn make_board(px: i32, py: i32, portals: Vec) -> Board { + Board { + name: "test".into(), + width: 3, + height: 3, + cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); 9], + floor: vec![Archetype::Empty.default_glyph(); 9], + floor_spec: None, + player: Player { x: px, y: py }, + objects: BTreeMap::new(), + next_object_id: 1, + portals, + board_script_name: None, + load_errors: Vec::new(), + } +} + +/// Two-board world used by all tests in this module. +/// +/// - `"b1"`: player at `(0, 0)`, portal `"to_b2"` at `(2, 0)` → `"b2"` / `"from_b1"` +/// - `"b2"`: player at `(0, 0)`, portal `"from_b1"` at `(1, 1)` → `"b1"` / `"to_b2"` +fn two_board_world() -> World { + let b1 = make_board(0, 0, vec![ + PortalDef { name: "to_b2".into(), x: 2, y: 0, target_map: "b2".into(), target_entry: "from_b1".into() }, + ]); + let b2 = make_board(0, 0, vec![ + PortalDef { name: "from_b1".into(), x: 1, y: 1, target_map: "b1".into(), target_entry: "to_b2".into() }, + ]); + World { + name: "test".into(), + start: "b1".into(), + scripts: HashMap::new(), + boards: HashMap::from([ + ("b1".to_string(), Rc::new(RefCell::new(b1))), + ("b2".to_string(), Rc::new(RefCell::new(b2))), + ]), + } +} + +#[test] +fn enter_board_switches_board() { + let mut game = GameState::from_world(two_board_world()); + game.enter_board("b2", "from_b1"); + assert_eq!(game.current_board_name(), "b2"); +} + +#[test] +fn enter_board_places_player_at_arrival_portal() { + let mut game = GameState::from_world(two_board_world()); + game.enter_board("b2", "from_b1"); + let board = game.board(); + // Arrival portal "from_b1" is at (1, 1) on b2. + assert_eq!(board.player.x, 1); + assert_eq!(board.player.y, 1); +} + +#[test] +fn enter_board_sets_board_transition() { + let mut game = GameState::from_world(two_board_world()); + game.enter_board("b2", "from_b1"); + assert_eq!(game.board_transition, Some(1.0)); +} + +#[test] +fn enter_board_unknown_board_logs_error() { + let mut game = GameState::from_world(two_board_world()); + game.enter_board("nope", "entry"); + assert_eq!(game.current_board_name(), "b1", "board should not change"); + assert!(!game.log.is_empty(), "an error should be logged"); +} + +#[test] +fn enter_board_unknown_entry_logs_error() { + let mut game = GameState::from_world(two_board_world()); + game.enter_board("b2", "nope"); + assert_eq!(game.current_board_name(), "b1", "board should not change"); + assert!(!game.log.is_empty(), "an error should be logged"); +} + +#[test] +fn try_move_onto_portal_switches_board() { + let world = two_board_world(); + // Start the player one cell west of the portal at (2, 0). + world.boards["b1"].borrow_mut().player = Player { x: 1, y: 0 }; + let mut game = GameState::from_world(world); + game.try_move(Direction::East); + assert_eq!(game.current_board_name(), "b2"); +} diff --git a/kiln-core/src/tests/map_file/mod.rs b/kiln-core/src/tests/map_file/mod.rs index 133bfb7..0f36f4f 100644 --- a/kiln-core/src/tests/map_file/mod.rs +++ b/kiln-core/src/tests/map_file/mod.rs @@ -1,6 +1,7 @@ mod grid_errors; mod object_placement; mod player_placement; +mod portal_placement; mod round_trip; use crate::board::Board; diff --git a/kiln-core/src/tests/map_file/portal_placement.rs b/kiln-core/src/tests/map_file/portal_placement.rs new file mode 100644 index 0000000..b13b32c --- /dev/null +++ b/kiln-core/src/tests/map_file/portal_placement.rs @@ -0,0 +1,47 @@ +use super::{load_board, map_3x1}; + +fn portal_toml(portals: &str) -> String { + format!( + "{}{}", + map_3x1("...", ""), + portals + ) +} + +#[test] +fn portal_duplicate_name_drops_second() { + let toml = portal_toml( + "[[portals]]\nname = \"a\"\nx = 0\ny = 0\ntarget_map = \"x\"\ntarget_entry = \"b\"\n\ + [[portals]]\nname = \"a\"\nx = 1\ny = 0\ntarget_map = \"x\"\ntarget_entry = \"c\"\n", + ); + let board = load_board(&toml); + assert_eq!(board.portals.len(), 1, "second portal with duplicate name should be dropped"); + assert_eq!(board.portals[0].x, 0); + assert!(!board.is_valid(), "duplicate name should be a load error"); +} + +#[test] +fn portal_palette_char_places_portal_at_grid_position() { + let toml = format!( + "{}{}", + map_3x1("1..", ""), + "[[portals]]\nname = \"p\"\npalette = \"1\"\ntarget_map = \"x\"\ntarget_entry = \"e\"\n" + ); + let board = load_board(&toml); + assert_eq!(board.portals.len(), 1); + assert_eq!(board.portals[0].x, 0); + assert_eq!(board.portals[0].y, 0); + assert_eq!(board.portals[0].name, "p"); +} + +#[test] +fn portal_palette_char_colliding_with_object_is_dropped() { + let toml = format!( + "{}{}{}", + map_3x1("1..", ""), + "[[objects]]\npalette = \"1\"\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\n", + "[[portals]]\nname = \"p\"\npalette = \"1\"\ntarget_map = \"x\"\ntarget_entry = \"e\"\n" + ); + let board = load_board(&toml); + assert_eq!(board.portals.len(), 0, "portal whose palette char is already an object char should be dropped"); +} diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index 3983bac..59b575c 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -1,5 +1,6 @@ mod actions; mod collision; +mod game_portals; mod map_file; mod movement; mod scripting; diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index 54668c6..3a5a9be 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -1,5 +1,6 @@ -use serde::{Deserialize, Serialize}; +use color::Rgba8; use crate::archetype::Archetype; +use crate::glyph::Glyph; /// Which directions a solid may be pushed in. /// @@ -78,23 +79,41 @@ pub enum Solid { /// 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, they -/// should be moved to `target_entry` on the board named by `target_map`. +/// 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. /// -/// **Not yet runtime-wired.** Portal navigation (multi-board loading and -/// switching) is a future feature. -#[derive(Deserialize, Serialize, Clone)] +/// 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, - /// File name (without extension) of the target board, e.g. `"cave"`. + /// Key of the target board in `World::boards`. pub target_map: String, - /// Named entry point on the target board where the player arrives. + /// 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 diff --git a/maps/start.toml b/maps/start.toml index 5a8ef24..49d5ead 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -215,7 +215,7 @@ content = """ # # # # # # -# # +# 1 # # ###### B # # # # # +++ # # @@ -277,6 +277,12 @@ solid = true name = "noticeboard" script_name = "noticeboard" +[[boards.start.portals]] +name = "to_house" +palette = "1" +target_map = "house" +target_entry = "from_start" + [boards.house.map] name = "The House" width = 60 @@ -299,7 +305,7 @@ content = """ # K ###F### # # # # # # ####### # -# # +# 1 # # # # ######### T # # # # T # @@ -355,3 +361,9 @@ fg = "#99ff44" bg = "#000000" solid = true script_name = "yammerer" + +[[boards.house.portals]] +name = "from_start" +palette = "1" +target_map = "start" +target_entry = "to_house"