portals
This commit is contained in:
@@ -119,6 +119,11 @@ impl Board {
|
|||||||
return glyph
|
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]
|
self.floor[y * self.width + x]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-1
@@ -5,7 +5,7 @@ use crate::script::ScriptHost;
|
|||||||
use crate::world::World;
|
use crate::world::World;
|
||||||
use std::cell::{Ref, RefMut};
|
use std::cell::{Ref, RefMut};
|
||||||
use std::time::Duration;
|
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.
|
/// How long a `say()` speech bubble stays on screen, in seconds.
|
||||||
pub const SAY_DURATION: f64 = 3.0;
|
pub const SAY_DURATION: f64 = 3.0;
|
||||||
@@ -57,6 +57,10 @@ pub struct GameState {
|
|||||||
/// An active scroll overlay opened by `scroll()`. `Some` while the overlay is
|
/// 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).
|
/// visible; the front-end pauses ticks and closes it via [`close_scroll`](GameState::close_scroll).
|
||||||
pub active_scroll: Option<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 {
|
impl GameState {
|
||||||
@@ -79,6 +83,7 @@ impl GameState {
|
|||||||
scripts: host,
|
scripts: host,
|
||||||
speech_bubbles: Vec::new(),
|
speech_bubbles: Vec::new(),
|
||||||
active_scroll: None,
|
active_scroll: None,
|
||||||
|
board_transition: None,
|
||||||
};
|
};
|
||||||
state.drain_errors();
|
state.drain_errors();
|
||||||
state
|
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`.
|
/// 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
|
/// 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).
|
/// object's `bump(-1)` hook fires (whether or not the player ends up moving).
|
||||||
pub fn try_move(&mut self, dir: Direction) {
|
pub fn try_move(&mut self, dir: Direction) {
|
||||||
let bumped;
|
let bumped;
|
||||||
|
let portal_target;
|
||||||
{
|
{
|
||||||
let (dx, dy): (i32, i32) = dir.into();
|
let (dx, dy): (i32, i32) = dir.into();
|
||||||
let mut board = self.board_mut();
|
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.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
|
||||||
board.player.x = nx as i32;
|
board.player.x = nx as i32;
|
||||||
board.player.y = ny 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 {
|
if let Some(idx) = bumped {
|
||||||
self.scripts.run_bump(idx, -1);
|
self.scripts.run_bump(idx, -1);
|
||||||
self.drain_errors();
|
self.drain_errors();
|
||||||
|
|||||||
+157
-4
@@ -37,7 +37,7 @@ pub struct MapFile {
|
|||||||
pub(crate) objects: Vec<MapFileObjectEntry>,
|
pub(crate) objects: Vec<MapFileObjectEntry>,
|
||||||
/// Any `[[portals]]` entries. Optional; defaults to empty.
|
/// Any `[[portals]]` entries. Optional; defaults to empty.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_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.
|
/// The `[map]` header section of a map file.
|
||||||
@@ -178,6 +178,34 @@ pub(crate) struct MapFileObjectEntry {
|
|||||||
name: Option<String>,
|
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 {
|
fn default_true() -> bool {
|
||||||
true
|
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
|
// become `Empty` floor (with their positions remembered); truly unknown
|
||||||
// chars become a visible `ErrorBlock` so the cell count stays correct.
|
// chars become a visible `ErrorBlock` so the cell count stays correct.
|
||||||
let canonical_empty = (Archetype::Empty.default_glyph(), Archetype::Empty);
|
let canonical_empty = (Archetype::Empty.default_glyph(), Archetype::Empty);
|
||||||
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h);
|
let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h);
|
||||||
// All grid positions (in reading order) at which each object palette char appears.
|
// 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();
|
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).
|
// 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_pos: Option<(usize, usize)> = None;
|
||||||
let mut player_grid_count: usize = 0;
|
let mut player_grid_count: usize = 0;
|
||||||
@@ -411,6 +495,9 @@ impl TryFrom<MapFile> for Board {
|
|||||||
} else if palette_char_owner.contains_key(&ch) {
|
} else if palette_char_owner.contains_key(&ch) {
|
||||||
cells.push(canonical_empty);
|
cells.push(canonical_empty);
|
||||||
obj_palette_positions.entry(ch).or_default().push((x, y));
|
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 {
|
} else {
|
||||||
load_errors.push(LogLine::error(format!(
|
load_errors.push(LogLine::error(format!(
|
||||||
"unknown grid character '{ch}' at ({x}, {y}); using error block"
|
"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.
|
// Build the cached floor layer from the (optional) `[floor]` declaration.
|
||||||
// Best-effort: any problem is recorded on `load_errors`, not fatal. The
|
// 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.
|
// 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 {
|
Ok(Board {
|
||||||
name: mf.map.name,
|
name: mf.map.name,
|
||||||
width: w,
|
width: w,
|
||||||
@@ -564,7 +709,7 @@ impl TryFrom<MapFile> for Board {
|
|||||||
},
|
},
|
||||||
objects,
|
objects,
|
||||||
next_object_id,
|
next_object_id,
|
||||||
portals: mf.portals,
|
portals,
|
||||||
board_script_name: mf.map.board_script_name,
|
board_script_name: mf.map.board_script_name,
|
||||||
load_errors,
|
load_errors,
|
||||||
})
|
})
|
||||||
@@ -646,7 +791,15 @@ impl From<&Board> for MapFile {
|
|||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
MapFile {
|
||||||
map: MapHeader {
|
map: MapHeader {
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
mod grid_errors;
|
mod grid_errors;
|
||||||
mod object_placement;
|
mod object_placement;
|
||||||
mod player_placement;
|
mod player_placement;
|
||||||
|
mod portal_placement;
|
||||||
mod round_trip;
|
mod round_trip;
|
||||||
|
|
||||||
use crate::board::Board;
|
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,5 +1,6 @@
|
|||||||
mod actions;
|
mod actions;
|
||||||
mod collision;
|
mod collision;
|
||||||
|
mod game_portals;
|
||||||
mod map_file;
|
mod map_file;
|
||||||
mod movement;
|
mod movement;
|
||||||
mod scripting;
|
mod scripting;
|
||||||
|
|||||||
+27
-8
@@ -1,5 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use color::Rgba8;
|
||||||
use crate::archetype::Archetype;
|
use crate::archetype::Archetype;
|
||||||
|
use crate::glyph::Glyph;
|
||||||
|
|
||||||
/// Which directions a solid may be pushed in.
|
/// 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.
|
/// 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
|
/// Portals are loaded from `[[portals]]` entries in `.toml` map files and
|
||||||
/// stored on [`Board`]. When the player steps onto a portal's cell, they
|
/// stored on [`Board`]. When the player steps onto a portal's cell, the engine
|
||||||
/// should be moved to `target_entry` on the board named by `target_map`.
|
/// 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
|
/// In map files, portals are conventionally placed using digit characters
|
||||||
/// switching) is a future feature.
|
/// (`'1'`–`'9'`) as palette keys — parallel to the uppercase-letter convention
|
||||||
#[derive(Deserialize, Serialize, Clone)]
|
/// 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 {
|
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).
|
/// Column of this portal on the board (0-indexed).
|
||||||
pub x: usize,
|
pub x: usize,
|
||||||
/// Row of this portal on the board (0-indexed).
|
/// Row of this portal on the board (0-indexed).
|
||||||
pub y: usize,
|
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,
|
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,
|
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's current position on the board.
|
||||||
///
|
///
|
||||||
/// The player is currently a special entity rendered on top of the board
|
/// The player is currently a special entity rendered on top of the board
|
||||||
|
|||||||
+14
-2
@@ -215,7 +215,7 @@ content = """
|
|||||||
# #
|
# #
|
||||||
# #
|
# #
|
||||||
# #
|
# #
|
||||||
# #
|
# 1 #
|
||||||
# ###### B #
|
# ###### B #
|
||||||
# # #
|
# # #
|
||||||
# +++ # #
|
# +++ # #
|
||||||
@@ -277,6 +277,12 @@ solid = true
|
|||||||
name = "noticeboard"
|
name = "noticeboard"
|
||||||
script_name = "noticeboard"
|
script_name = "noticeboard"
|
||||||
|
|
||||||
|
[[boards.start.portals]]
|
||||||
|
name = "to_house"
|
||||||
|
palette = "1"
|
||||||
|
target_map = "house"
|
||||||
|
target_entry = "from_start"
|
||||||
|
|
||||||
[boards.house.map]
|
[boards.house.map]
|
||||||
name = "The House"
|
name = "The House"
|
||||||
width = 60
|
width = 60
|
||||||
@@ -299,7 +305,7 @@ content = """
|
|||||||
# K ###F### #
|
# K ###F### #
|
||||||
# # # #
|
# # # #
|
||||||
# ####### #
|
# ####### #
|
||||||
# #
|
# 1 #
|
||||||
# #
|
# #
|
||||||
# ######### T #
|
# ######### T #
|
||||||
# # # T #
|
# # # T #
|
||||||
@@ -355,3 +361,9 @@ fg = "#99ff44"
|
|||||||
bg = "#000000"
|
bg = "#000000"
|
||||||
solid = true
|
solid = true
|
||||||
script_name = "yammerer"
|
script_name = "yammerer"
|
||||||
|
|
||||||
|
[[boards.house.portals]]
|
||||||
|
name = "from_start"
|
||||||
|
palette = "1"
|
||||||
|
target_map = "start"
|
||||||
|
target_entry = "to_house"
|
||||||
|
|||||||
Reference in New Issue
Block a user