the huge refactor
This commit is contained in:
@@ -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;
|
||||
@@ -160,3 +161,62 @@ pub struct BoardAction {
|
||||
/// 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<Consequence> {
|
||||
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(())
|
||||
}
|
||||
+12
-26
@@ -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()));
|
||||
|
||||
@@ -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<ObjectInfo> {
|
||||
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<dyn Hookable + '_>, 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<BoardAction>, 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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_<alias>` 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/<name>.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_<alias>` 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_<alias>` 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<Self, Self::Error> {
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+309
-653
File diff suppressed because it is too large
Load Diff
@@ -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<String, TileSpec>,
|
||||
/// List of all the sensors (if any)
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub sensors: Vec<SensorSpec>,
|
||||
/// List of all the portals (if any)
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub portals: Vec<Portal>,
|
||||
/// What we want for a floor
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub floor: Option<FloorSpec>,
|
||||
/// 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<Vec<Vec<char>>, 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<char> = 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<Vec<Option<TileSpec>>, String> {
|
||||
let grid = self.grid_chars()?;
|
||||
|
||||
// Walk the grid, filling cells and collecting placements.
|
||||
let mut cells: Vec<Option<TileSpec>> = 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<Option<TileSpec>>, valid_script_names: &HashSet<&String>) -> Result<(), Vec<String>> {
|
||||
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<T: Eq + Hash + Clone>(hash: &mut HashMap<T, i32>, 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::<Vec<_>>();
|
||||
let portal_dupes = portal_names.into_iter().filter_map(|(name, count)| if count > 1 { Some(name) } else { None }).collect::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
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<Board, String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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_<alias>` 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/<name>.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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_<alias>`
|
||||
//! 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_<alias>` 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<Archetype> {
|
||||
let name = tag.strip_prefix(BUILTIN_TAG_PREFIX)?;
|
||||
Archetype::try_from(name).ok()
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
+30
-28
@@ -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<Glyph>,
|
||||
},
|
||||
@@ -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<FloorGenerator> {
|
||||
pub fn from_name(name: &str) -> Option<FloorBiome> {
|
||||
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<Glyph> {
|
||||
fn roll(generator: FloorBiome, count: usize) -> Vec<Glyph> {
|
||||
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));
|
||||
|
||||
+237
-449
@@ -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<H: std::hash::Hasher>(&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<BoardAction>) -> Events {
|
||||
/// Applies one object's drained `actions` to the board
|
||||
fn apply_actions(&mut self, actions: Vec<BoardAction>) {
|
||||
// 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<SpeechBubble> = Vec::new();
|
||||
// Collected outside the board borrow so we can assign to self.active_scroll.
|
||||
let mut new_scroll: Option<Scroll> = 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<ObjectId> = 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(),
|
||||
|
||||
+56
-1
@@ -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<H: Hasher>(&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<u32> for TileIndex {
|
||||
fn into(self) -> u32 {
|
||||
match self {
|
||||
TileIndex::Num(n) => n,
|
||||
TileIndex::Chr(c) => c as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -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<String>,
|
||||
/// Single character to fill the whole `width × height` grid with.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fill: Option<String>,
|
||||
/// Individual cells over an otherwise all-spaces grid.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sparse: Option<Vec<SparseCell>>,
|
||||
/// Char (as a one-character string key) → palette entry.
|
||||
#[serde(default)]
|
||||
pub palette: HashMap<String, PaletteEntry>,
|
||||
}
|
||||
|
||||
/// 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<TileIndex>,
|
||||
/// Foreground `"#RRGGBB"`. Used by archetype/object kinds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fg: Option<String>,
|
||||
/// Background `"#RRGGBB"`. Used by archetype/object kinds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bg: Option<String>,
|
||||
/// Object solidity (defaults `true`). Only for `kind = "object"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub solid: Option<bool>,
|
||||
/// Object opacity (defaults `true`). Only for `kind = "object"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opaque: Option<bool>,
|
||||
/// Object pushability (defaults `false`). Only for `kind = "object"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pushable: Option<Pushable>,
|
||||
/// 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<u32>,
|
||||
/// Rhai script name. Only for `kind = "object"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub script_name: Option<String>,
|
||||
/// Open-ended labels. Only for `kind = "object"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// Unique name. For `kind = "object"` (optional) or `kind = "portal"` (required).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Target board key. Required for `kind = "portal"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_map: Option<String>,
|
||||
/// Target portal name on the destination board. Required for `kind = "portal"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_entry: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<LogLine>) -> 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<MapFile>`), 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<LogLine>,
|
||||
) -> Result<Vec<Vec<char>>, 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<LogLine>| {
|
||||
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<char> = 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<LogLine>,
|
||||
) -> Result<(Vec<GridCell>, Vec<Placement>), 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<char, Resolved> = 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<GridCell> = Vec::with_capacity(width * height);
|
||||
let mut placements: Vec<Placement> = 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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TriggerSpec>,
|
||||
/// 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<DecorationSpec>,
|
||||
}
|
||||
|
||||
/// 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<FloorSpec>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// Fixed-glyph tile index (int or single-char string).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tile: Option<TileIndex>,
|
||||
/// Fixed-glyph foreground `"#RRGGBB"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fg: Option<String>,
|
||||
/// Fixed-glyph background `"#RRGGBB"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bg: Option<String>,
|
||||
}
|
||||
|
||||
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<LogLine>) -> 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<String>,
|
||||
/// Optional open-ended labels.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// 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<TileIndex>,
|
||||
/// Foreground `"#RRGGBB"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fg: Option<String>,
|
||||
/// Background `"#RRGGBB"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bg: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<MapFile> for Board {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(mf: MapFile) -> Result<Self, Self::Error> {
|
||||
let w = mf.map.width;
|
||||
let h = mf.map.height;
|
||||
let mut load_errors: Vec<LogLine> = 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<ObjectId, ObjectDef> = BTreeMap::new();
|
||||
let mut next_object_id: ObjectId = 1;
|
||||
let mut seen_names: HashMap<String, ObjectId> = 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<String> = HashSet::new();
|
||||
let mut portals: Vec<PortalDef> = 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<Decoration> = 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<String, ObjectId>,
|
||||
errors: &mut Vec<LogLine>,
|
||||
) -> Option<String> {
|
||||
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<Decoration, String> {
|
||||
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<char> {
|
||||
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<String, PaletteEntry> = HashMap::new();
|
||||
let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new();
|
||||
let mut grid: Vec<Vec<char>> = 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<String> = 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::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.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<FloorSpec> {
|
||||
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<String> = 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<Board, Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
let map_file = MapFile::from(board);
|
||||
let toml_str = toml::to_string_pretty(&map_file)?;
|
||||
std::fs::write(path, toml_str)?;
|
||||
Ok(())
|
||||
}
|
||||
+22
-59
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Glyph>
|
||||
}
|
||||
|
||||
impl Portal {
|
||||
pub fn location(&self) -> (usize, usize) {
|
||||
(self.x, self.y)
|
||||
}
|
||||
}
|
||||
+28
-39
@@ -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<String> {
|
||||
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<String, CompiledScript> = HashMap::new();
|
||||
let mut failed: HashSet<String> = 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<BoardAction> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PortalDef>) -> Board {
|
||||
fn make_board(px: i64, py: i64, portals: Vec<Portal>) -> 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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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\"");
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
//! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_<dir>` 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());
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String, String>) {
|
||||
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<String, String> {
|
||||
/// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+318
-25
@@ -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<Behavior> 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,
|
||||
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<String>,
|
||||
pub glyph: Glyph,
|
||||
#[serde(flatten, default)]
|
||||
pub optics: Optics,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
#[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<String> {
|
||||
&self.scriptable().name
|
||||
}
|
||||
fn tags(&self) -> &HashSet<String> {
|
||||
&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<ObjectDef>),
|
||||
}
|
||||
|
||||
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<String>,
|
||||
enter: EnterResponse,
|
||||
#[serde(flatten)]
|
||||
glyph: Glyph,
|
||||
#[serde(flatten, default)]
|
||||
optics: Optics,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
tags: Vec<String>,
|
||||
},
|
||||
Builtin {
|
||||
kind: String,
|
||||
#[serde(flatten, default, skip_serializing_if = "Option::is_none")]
|
||||
glyph: Option<Glyph>,
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoTile {
|
||||
fn into_tile(self, next_object_id: &mut ObjectId) -> Result<Tile, String>;
|
||||
}
|
||||
|
||||
impl IntoTile for TileSpec {
|
||||
fn into_tile(self, next_object_id: &mut ObjectId) -> Result<Tile, String> {
|
||||
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<FloorSpec> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-291
@@ -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<ObjectId> {
|
||||
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<Archetype> {
|
||||
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 {
|
||||
@@ -519,3 +228,30 @@ impl LogSink {
|
||||
std::mem::take(&mut self.0.borrow_mut())
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
||||
+17
-24
@@ -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<String, String>,
|
||||
/// 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<String, MapFile>,
|
||||
boards: HashMap<String, BoardSpec>,
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
}
|
||||
@@ -100,11 +101,14 @@ pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>> {
|
||||
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::<HashSet<_>>();
|
||||
|
||||
// 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<World, Box<dyn std::error::Error>> {
|
||||
|
||||
#[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"]));
|
||||
}
|
||||
|
||||
+14
-15
@@ -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),
|
||||
|
||||
+27
-27
@@ -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")),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<MapFile>(toml).unwrap()).unwrap();
|
||||
let board = Board::try_from(toml::from_str::<BoardSpec>(toml).unwrap()).unwrap();
|
||||
let fov = board.lighting(0); // no player torch — only the object lights
|
||||
|
||||
let area = Rect::new(0, 0, 6, 1);
|
||||
|
||||
+10
-7
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+8
-10
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user