This commit is contained in:
2026-06-13 16:24:29 -05:00
parent 78547696d7
commit 49d32eedf5
9 changed files with 410 additions and 15 deletions
+5
View File
@@ -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]
}
+60 -1
View File
@@ -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<Scroll>,
/// 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<f64>,
}
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();
+157 -4
View File
@@ -37,7 +37,7 @@ pub struct MapFile {
pub(crate) objects: Vec<MapFileObjectEntry>,
/// Any `[[portals]]` entries. Optional; defaults to empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub portals: Vec<PortalDef>,
pub(crate) portals: Vec<MapFilePortalEntry>,
}
/// The `[map]` header section of a map file.
@@ -178,6 +178,34 @@ pub(crate) struct MapFileObjectEntry {
name: Option<String>,
}
/// 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<usize>,
/// Row of this portal (0-indexed). See `x`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y: Option<usize>,
/// 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<String>,
/// 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<MapFile> 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<char, usize> = 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<char, Vec<(usize, usize)>> = HashMap::new();
// The single grid position at which each portal palette char appears (first sighting).
let mut portal_palette_positions: HashMap<char, Vec<(usize, usize)>> = 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<MapFile> 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<MapFile> 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<MapFile> for Board {
}
}
// Build the validated portal list, resolving palette chars to concrete (x, y).
let mut seen_portal_names: HashMap<String, ()> = HashMap::new();
let mut portals: Vec<PortalDef> = 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<MapFile> 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<MapFilePortalEntry> = 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 {
+98
View File
@@ -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<PortalDef>) -> 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");
}
+1
View File
@@ -1,6 +1,7 @@
mod grid_errors;
mod object_placement;
mod player_placement;
mod portal_placement;
mod round_trip;
use crate::board::Board;
@@ -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");
}
+1
View File
@@ -1,5 +1,6 @@
mod actions;
mod collision;
mod game_portals;
mod map_file;
mod movement;
mod scripting;
+27 -8
View File
@@ -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
+14 -2
View File
@@ -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"