the huge refactor
This commit is contained in:
@@ -9,6 +9,7 @@ use std::fmt::Debug;
|
|||||||
use crate::utils::{Direction, ObjectId};
|
use crate::utils::{Direction, ObjectId};
|
||||||
use color::Rgba8;
|
use color::Rgba8;
|
||||||
use rhai::Dynamic;
|
use rhai::Dynamic;
|
||||||
|
use crate::Board;
|
||||||
|
|
||||||
/// How long a move occupies an object before it can act again, in seconds.
|
/// How long a move occupies an object before it can act again, in seconds.
|
||||||
pub(crate) const MOVE_COST: f64 = 0.25;
|
pub(crate) const MOVE_COST: f64 = 0.25;
|
||||||
@@ -159,4 +160,63 @@ pub struct BoardAction {
|
|||||||
pub(crate) source: ObjectId,
|
pub(crate) source: ObjectId,
|
||||||
/// The action to apply.
|
/// The action to apply.
|
||||||
pub(crate) action: Action,
|
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 | ()
|
// Board.named(name) -> ObjectInfo | ()
|
||||||
engine.register_fn("named", move |board_ref: &mut BoardRef, name: ImmutableString| -> Dynamic {
|
engine.register_fn("named", move |board_ref: &mut BoardRef, name: ImmutableString| -> Dynamic {
|
||||||
let board = board_ref.borrow();
|
let board = board_ref.borrow();
|
||||||
board
|
if let Some(obj) = board.get_named(name.as_str()) {
|
||||||
.objects
|
Dynamic::from(ObjectInfo::from_hookable(obj, board_ref.clone()))
|
||||||
.iter()
|
} else {
|
||||||
.find_map(|(_id, def)| {
|
Dynamic::UNIT
|
||||||
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)
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Board.tagged(tag) -> Array[ObjectInfo]
|
// Board.tagged(tag) -> Array[ObjectInfo]
|
||||||
engine.register_fn("tagged", move |board_ref: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
|
engine.register_fn("tagged", move |board_ref: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
|
||||||
let board = board_ref.borrow();
|
let board = board_ref.borrow();
|
||||||
board
|
board.get_tagged(tag.as_str()).into_iter().map(|obj| {
|
||||||
.objects.values().filter_map(|def| {
|
Dynamic::from(ObjectInfo::from_hookable(obj, board_ref.clone()))
|
||||||
if def.tags.contains(tag.as_str()) {
|
}).collect()
|
||||||
Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone())))
|
});
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Board.registry -> Registry
|
// Board.registry -> Registry
|
||||||
engine.register_get("registry", |b: &mut BoardRef| Registry(b.clone()));
|
engine.register_get("registry", |b: &mut BoardRef| Registry(b.clone()));
|
||||||
|
|||||||
@@ -24,11 +24,12 @@
|
|||||||
use rhai::{Dynamic, Engine};
|
use rhai::{Dynamic, Engine};
|
||||||
use crate::api::board::BoardRef;
|
use crate::api::board::BoardRef;
|
||||||
use crate::api::queue::ObjQueue;
|
use crate::api::queue::ObjQueue;
|
||||||
use crate::Direction;
|
use crate::{Board, Direction};
|
||||||
use crate::action::BoardAction;
|
use crate::action::BoardAction;
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use crate::script::Registerable;
|
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`,
|
/// 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.
|
/// and `Board.get`. Passed by value — scripts read fields, not a live reference.
|
||||||
@@ -48,32 +49,36 @@ pub struct ObjectInfo {
|
|||||||
impl ObjectInfo {
|
impl ObjectInfo {
|
||||||
pub fn from_id(id: ObjectId, board: BoardRef) -> Option<ObjectInfo> {
|
pub fn from_id(id: ObjectId, board: BoardRef) -> Option<ObjectInfo> {
|
||||||
let b = board.borrow();
|
let b = board.borrow();
|
||||||
let obj = b.objects.get(&id)?;
|
let hookable = b.get_hookable(id)?;
|
||||||
Some(ObjectInfo {
|
Some(Self::from_hookable(hookable, board.clone()))
|
||||||
id,
|
|
||||||
x: obj.x as i64,
|
|
||||||
y: obj.y as i64,
|
|
||||||
board: board.clone(),
|
|
||||||
script_name: obj.script_name.clone(),
|
|
||||||
queue: obj.queue.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 {
|
Self {
|
||||||
id: obj.id,
|
id: hookable.id(),
|
||||||
x: obj.x as i64,
|
x: x as i64,
|
||||||
y: obj.y 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(),
|
board: board.clone(),
|
||||||
script_name: obj.script_name.clone(),
|
script_name: obj.scripting.script_name.clone(),
|
||||||
queue: obj.queue.clone()
|
queue: obj.scripting.queue.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn drain(&mut self, target: &mut Vec<BoardAction>, dt: f64) {
|
pub fn drain(&mut self, target: &mut Vec<BoardAction>, dt: f64) {
|
||||||
let mut b = self.board.borrow_mut();
|
if let Some(scr) = self.board.borrow_mut().scripting_mut(self.id) {
|
||||||
if let Some(def) = b.objects.get_mut(&self.id) {
|
scr.queue.drain(self.id, target, dt)
|
||||||
def.queue.drain(self.id, target, dt)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,8 +94,7 @@ impl Registerable for ObjectInfo {
|
|||||||
|
|
||||||
engine.register_get("name", |o: &mut ObjectInfo| {
|
engine.register_get("name", |o: &mut ObjectInfo| {
|
||||||
let board = o.board.borrow();
|
let board = o.board.borrow();
|
||||||
let obj = board.objects.get(&o.id);
|
if let Some(hookable) = board.get_hookable(o.id) && let Some(name) = hookable.name() {
|
||||||
if let Some(ObjectDef { name: Some(name), ..}) = obj {
|
|
||||||
Dynamic::from(name.clone())
|
Dynamic::from(name.clone())
|
||||||
} else {
|
} else {
|
||||||
Dynamic::UNIT
|
Dynamic::UNIT
|
||||||
@@ -99,9 +103,8 @@ impl Registerable for ObjectInfo {
|
|||||||
|
|
||||||
engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array {
|
engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array {
|
||||||
let board = o.board.borrow();
|
let board = o.board.borrow();
|
||||||
let obj = board.objects.get(&o.id);
|
if let Some(hookable) = board.get_hookable(o.id) {
|
||||||
if let Some(ObjectDef { tags, .. }) = obj {
|
hookable.tags().iter().map(|t| Dynamic::from(t.clone())).collect()
|
||||||
tags.iter().map(|t| Dynamic::from(t.clone())).collect()
|
|
||||||
} else {
|
} else {
|
||||||
rhai::Array::new()
|
rhai::Array::new()
|
||||||
}
|
}
|
||||||
@@ -109,21 +112,20 @@ impl Registerable for ObjectInfo {
|
|||||||
|
|
||||||
engine.register_get("glyph", |o: &mut ObjectInfo| {
|
engine.register_get("glyph", |o: &mut ObjectInfo| {
|
||||||
let board = o.board.borrow();
|
let board = o.board.borrow();
|
||||||
let obj = board.objects.get(&o.id).unwrap();
|
let obj = board.get_hookable(o.id).unwrap();
|
||||||
obj.glyph
|
obj.glyph()
|
||||||
});
|
});
|
||||||
|
|
||||||
// me.light: the object's current emitted light radius in cells (0 = none).
|
// me.light: the object's current emitted light radius in cells (0 = none).
|
||||||
engine.register_get("light", |o: &mut ObjectInfo| -> i64 {
|
engine.register_get("light", |o: &mut ObjectInfo| -> i64 {
|
||||||
match o.board.borrow().objects.get(&o.id) {
|
if let Some(hookable) = o.board.borrow().get_hookable(o.id) {
|
||||||
Some(ObjectDef { behavior: Behavior { glow, .. }, .. }) => *glow as i64,
|
hookable.optics().glow as i64
|
||||||
None => 0,
|
} else { 0 }
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| {
|
engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| {
|
||||||
if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) {
|
if let Some(hookable) = o.board.borrow().get_hookable(o.id) {
|
||||||
tags.contains(&t)
|
hookable.scriptable().tags.contains(&t)
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ impl Registerable for PlayerWithPos {
|
|||||||
.register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health)
|
.register_get("health", |player: &mut PlayerWithPos| player.0.borrow().health)
|
||||||
.register_get("max_health", |player: &mut PlayerWithPos| player.0.borrow().max_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("keys", |player: &mut PlayerWithPos| player.0.borrow().keys)
|
||||||
.register_get("x", |player: &mut PlayerWithPos| player.1.borrow().player.x)
|
.register_get("x", |player: &mut PlayerWithPos| player.1.borrow().player_pos().0)
|
||||||
.register_get("y", |player: &mut PlayerWithPos| player.1.borrow().player.y);
|
.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)),
|
("Yellow", rgb(0xFF, 0xFF, 0x55)),
|
||||||
("White", rgb(0xFF, 0xFF, 0xFF)),
|
("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
|
//! give boards some non-distracting visual flavor (textured ground) with little
|
||||||
//! authoring effort, including randomly-generated "grass" / "dirt" / "stone".
|
//! 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
|
//! 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
|
//! glyph is rolled per grid cell) or gives a fixed glyph. This module only owns
|
||||||
//! the generators themselves.
|
//! the generators themselves.
|
||||||
|
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
use color::Rgba8;
|
use color::Rgba8;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use tinyrand::{Probability, Rand, Seeded, StdRand};
|
use tinyrand::{Probability, Rand, Seeded, StdRand};
|
||||||
|
|
||||||
/// A board's floor: the cosmetic backdrop drawn beneath everything, replacing the
|
/// 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
|
/// Three forms: [`Blank`](Floor::Blank) (the canonical empty/black cell shows
|
||||||
/// through), [`Fixed`](Floor::Fixed) (one glyph tiled across the whole board), or
|
/// 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
|
/// 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
|
/// buffer pre-rolled once at load from [`FLOOR_SEED`] — deterministic, and the
|
||||||
/// direct replacement for the old per-cell floor-layer rolling.
|
/// 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).
|
/// `glyphs` buffer holding one pre-rolled glyph per cell (row-major).
|
||||||
Biome {
|
Biome {
|
||||||
/// The generator this biome was built from; re-emitted on save.
|
/// 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).
|
/// One pre-rolled glyph per cell (`width * height`, row-major).
|
||||||
glyphs: Vec<Glyph>,
|
glyphs: Vec<Glyph>,
|
||||||
},
|
},
|
||||||
@@ -52,7 +53,7 @@ impl Floor {
|
|||||||
/// Builds a [`Floor::Biome`] for a `width × height` board, pre-rolling one glyph
|
/// 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
|
/// per cell from a [`FLOOR_SEED`]-seeded PRNG (so the result is deterministic and
|
||||||
/// depends only on the board dimensions + generator).
|
/// 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 mut rng = StdRand::seed(FLOOR_SEED);
|
||||||
let glyphs = (0..width * height).map(|_| generator.generate(&mut rng)).collect();
|
let glyphs = (0..width * height).map(|_| generator.generate(&mut rng)).collect();
|
||||||
Floor::Biome { generator, glyphs }
|
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
|
/// and the probability/character set of its scattered "texture" glyphs; the
|
||||||
/// colors are deliberately dark and low-saturation so foreground objects stay
|
/// colors are deliberately dark and low-saturation so foreground objects stay
|
||||||
/// readable against them.
|
/// readable against them.
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum FloorGenerator {
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FloorBiome {
|
||||||
/// Random green-to-greenish-yellow ground with a fairly high chance of grassy
|
/// Random green-to-greenish-yellow ground with a fairly high chance of grassy
|
||||||
/// characters (comma, period, backquote, apostrophe).
|
/// characters (comma, period, backquote, apostrophe).
|
||||||
Grass,
|
Grass,
|
||||||
@@ -91,24 +93,24 @@ pub enum FloorGenerator {
|
|||||||
Stone,
|
Stone,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FloorGenerator {
|
impl FloorBiome {
|
||||||
/// Parses a generator from its map-file name, or `None` if unrecognized.
|
/// 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 {
|
match name {
|
||||||
"grass" => Some(FloorGenerator::Grass),
|
"grass" => Some(FloorBiome::Grass),
|
||||||
"dirt" => Some(FloorGenerator::Dirt),
|
"dirt" => Some(FloorBiome::Dirt),
|
||||||
"stone" => Some(FloorGenerator::Stone),
|
"stone" => Some(FloorBiome::Stone),
|
||||||
_ => None,
|
_ => 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.
|
/// re-emitted on save so a biome floor round-trips.
|
||||||
pub fn name(&self) -> &'static str {
|
pub fn name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
FloorGenerator::Grass => "grass",
|
FloorBiome::Grass => "grass",
|
||||||
FloorGenerator::Dirt => "dirt",
|
FloorBiome::Dirt => "dirt",
|
||||||
FloorGenerator::Stone => "stone",
|
FloorBiome::Stone => "stone",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +123,7 @@ impl FloorGenerator {
|
|||||||
// (background color ranges, texture probability, texture chars).
|
// (background color ranges, texture probability, texture chars).
|
||||||
let (bg, chars, prob) = match self {
|
let (bg, chars, prob) = match self {
|
||||||
// Green → greenish-yellow: g dominant, a touch of r for the yellow tilt.
|
// Green → greenish-yellow: g dominant, a touch of r for the yellow tilt.
|
||||||
FloorGenerator::Grass => (
|
FloorBiome::Grass => (
|
||||||
Rgba8 {
|
Rgba8 {
|
||||||
r: shade(rng, 20, 55),
|
r: shade(rng, 20, 55),
|
||||||
g: shade(rng, 45, 85),
|
g: shade(rng, 45, 85),
|
||||||
@@ -132,7 +134,7 @@ impl FloorGenerator {
|
|||||||
0.35,
|
0.35,
|
||||||
),
|
),
|
||||||
// Brown: r highest, g mid, b low.
|
// Brown: r highest, g mid, b low.
|
||||||
FloorGenerator::Dirt => (
|
FloorBiome::Dirt => (
|
||||||
Rgba8 {
|
Rgba8 {
|
||||||
r: shade(rng, 45, 75),
|
r: shade(rng, 45, 75),
|
||||||
g: shade(rng, 30, 50),
|
g: shade(rng, 30, 50),
|
||||||
@@ -143,7 +145,7 @@ impl FloorGenerator {
|
|||||||
0.20,
|
0.20,
|
||||||
),
|
),
|
||||||
// Gray: all channels share one shade.
|
// Gray: all channels share one shade.
|
||||||
FloorGenerator::Stone => {
|
FloorBiome::Stone => {
|
||||||
let v = shade(rng, 40, 70);
|
let v = shade(rng, 40, 70);
|
||||||
(
|
(
|
||||||
Rgba8 {
|
Rgba8 {
|
||||||
@@ -199,7 +201,7 @@ mod tests {
|
|||||||
|
|
||||||
/// Rolls `count` glyphs from `generator` against a freshly-seeded RNG, the
|
/// Rolls `count` glyphs from `generator` against a freshly-seeded RNG, the
|
||||||
/// same way the layer builder does.
|
/// 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);
|
let mut rng = StdRand::seed(FLOOR_SEED);
|
||||||
(0..count).map(|_| generator.generate(&mut rng)).collect()
|
(0..count).map(|_| generator.generate(&mut rng)).collect()
|
||||||
}
|
}
|
||||||
@@ -207,24 +209,24 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn from_name_parses_known_generators() {
|
fn from_name_parses_known_generators() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
FloorGenerator::from_name("grass"),
|
FloorBiome::from_name("grass"),
|
||||||
Some(FloorGenerator::Grass)
|
Some(FloorBiome::Grass)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
FloorGenerator::from_name("dirt"),
|
FloorBiome::from_name("dirt"),
|
||||||
Some(FloorGenerator::Dirt)
|
Some(FloorBiome::Dirt)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
FloorGenerator::from_name("stone"),
|
FloorBiome::from_name("stone"),
|
||||||
Some(FloorGenerator::Stone)
|
Some(FloorBiome::Stone)
|
||||||
);
|
);
|
||||||
assert_eq!(FloorGenerator::from_name("lava"), None);
|
assert_eq!(FloorBiome::from_name("lava"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn grass_generator_stays_in_scheme_and_is_deterministic() {
|
fn grass_generator_stays_in_scheme_and_is_deterministic() {
|
||||||
let a = roll(FloorGenerator::Grass, 64);
|
let a = roll(FloorBiome::Grass, 64);
|
||||||
let b = roll(FloorGenerator::Grass, 64);
|
let b = roll(FloorBiome::Grass, 64);
|
||||||
assert_eq!(a.len(), 64);
|
assert_eq!(a.len(), 64);
|
||||||
// Same seed → identical rolls across builds.
|
// Same seed → identical rolls across builds.
|
||||||
assert!(a.iter().zip(&b).all(|(x, y)| x == y));
|
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::board::Board;
|
||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use crate::script::ScriptHost;
|
use crate::script::ScriptHost;
|
||||||
use crate::utils::{Direction, ObjectId, PlayerPos};
|
use crate::utils::{Direction, ObjectId};
|
||||||
use crate::world::World;
|
use crate::world::World;
|
||||||
use std::cell::{Ref, RefMut};
|
use std::cell::{Ref, RefMut};
|
||||||
use std::collections::HashSet;
|
use std::collections::{BTreeSet, HashSet, VecDeque};
|
||||||
|
use std::hash::Hash;
|
||||||
use std::time::Duration;
|
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
|
/// Most things (bump, etc) are only triggered by player actions now.
|
||||||
/// follow-up [`GameState::settle`] pass runs them after all object hooks, so a
|
/// However, sends still might trigger other sends! So we need to keep a
|
||||||
/// bumped object reacts to the fully-updated board.
|
/// list of sends that we trigger in the process of resolving a list of
|
||||||
#[derive(Default)]
|
/// actions. When we resolve these sends, we'll keep a list of things we've
|
||||||
struct Events {
|
/// resolved, so we refuse to do the same send twice in a tick: this prevents
|
||||||
/// `(bumped object, direction the bump came from)` for each triggered `bump`.
|
/// us from accidentally doing an infinite recursion.
|
||||||
bumps: Vec<(ObjectId, Direction)>,
|
#[derive(Clone, Debug)]
|
||||||
/// `(entered non-solid object, direction the entrant came from)` for each
|
struct SendAction(ObjectId, String, SendArg);
|
||||||
/// `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)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Events {
|
impl Hash for SendAction {
|
||||||
/// Appends `other`'s reactions onto `self`.
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||||
fn merge(&mut self, other: Events) {
|
self.0.hash(state);
|
||||||
self.bumps.extend(other.bumps);
|
self.1.hash(state);
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records which `(object, hook/fn, args)` reactions have already fired during a
|
impl PartialEq for SendAction {
|
||||||
/// single `tick` / `try_move` / `run_init`. [`GameState::settle`] refuses to fire
|
fn eq(&self, other: &Self) -> bool {
|
||||||
/// a key twice, so a bump/send cascade always terminates — even if two objects
|
self.1 == other.1 && self.0 == other.0
|
||||||
/// bump each other in a cycle, each side fires at most once. Reset per invocation.
|
}
|
||||||
type CalledSet = HashSet<(ObjectId, String, String)>;
|
}
|
||||||
|
|
||||||
|
impl Eq for SendAction {}
|
||||||
|
|
||||||
/// How long a `say()` speech bubble stays on screen, in seconds.
|
/// How long a `say()` speech bubble stays on screen, in seconds.
|
||||||
pub const SAY_DURATION: f64 = 3.0;
|
pub const SAY_DURATION: f64 = 3.0;
|
||||||
@@ -51,6 +42,8 @@ pub const SAY_DURATION: f64 = 3.0;
|
|||||||
// accessing the private `action` module directly.
|
// accessing the private `action` module directly.
|
||||||
pub use crate::action::ScrollLine;
|
pub use crate::action::ScrollLine;
|
||||||
use crate::player::{Player, PlayerRef};
|
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()`.
|
/// An active scroll overlay opened by a scripted object via `scroll()`.
|
||||||
///
|
///
|
||||||
@@ -210,16 +203,11 @@ impl GameState {
|
|||||||
pub fn run_init(&mut self) {
|
pub fn run_init(&mut self) {
|
||||||
// Run each object's init hook in ascending id order, applying its actions
|
// 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.
|
// immediately so a later object's init sees what an earlier one did.
|
||||||
let mut ev = Events::default();
|
|
||||||
let ids = self.board().all_ids();
|
let ids = self.board().all_ids();
|
||||||
for id in ids {
|
for id in ids {
|
||||||
let actions = self.scripts.run_init_on(id);
|
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).
|
/// 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
|
// Run each object's tick in ascending id order, applying its drained
|
||||||
// actions immediately so the next object sees the updated board.
|
// actions immediately so the next object sees the updated board.
|
||||||
let mut ev = Events::default();
|
|
||||||
let ids = self.board().all_ids();
|
let ids = self.board().all_ids();
|
||||||
for id in ids {
|
for id in ids {
|
||||||
let actions = self.scripts.run_tick_on(id, secs);
|
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
|
/// Drains the log lines collected by the script host — script `log()` output
|
||||||
@@ -261,302 +244,105 @@ impl GameState {
|
|||||||
self.log.extend(lines);
|
self.log.extend(lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies one object's drained `actions` to the board and returns the `bump`
|
/// Applies one object's drained `actions` to the board
|
||||||
/// and `send` reactions they triggered (fired later by [`settle`](GameState::settle)).
|
fn apply_actions(&mut self, actions: Vec<BoardAction>) {
|
||||||
///
|
|
||||||
/// 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 {
|
|
||||||
// Application-time errors (teleport/push/shift failures) go straight onto
|
// Application-time errors (teleport/push/shift failures) go straight onto
|
||||||
// the shared LogSink — the same immediate channel as script `log()` output,
|
// 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
|
// 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
|
// `drain_log`. A cheap Rc clone lets us push while the board borrow (which
|
||||||
// also borrows `self`) is held.
|
// also borrows `self`) is held.
|
||||||
let log_sink = self.scripts.log_sink().clone();
|
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;
|
|
||||||
|
|
||||||
{
|
for ba in actions {
|
||||||
let mut board = self.board_mut();
|
match ba.action {
|
||||||
for ba in actions {
|
Action::Move(dir) => {
|
||||||
match ba.action {
|
step_object(&mut self.board_mut(), ba.source, dir);
|
||||||
Action::Move(dir) => {
|
}
|
||||||
let StepOutcome { bumped, entered } =
|
Action::SetTile(tile) => {
|
||||||
step_object(&mut board, ba.source, dir);
|
if let Some(scr) = self.board_mut().scripting_mut(ba.source) {
|
||||||
// The bump / enter "comes from" the side the mover advanced
|
scr.glyph.tile = tile;
|
||||||
// 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()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Action::SetTile(tile) => {
|
}
|
||||||
if let Some(obj) = board.objects.get_mut(&ba.source) {
|
Action::SetLight(radius) => {
|
||||||
obj.glyph.tile = tile;
|
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) {
|
Action::SetTag {
|
||||||
obj.behavior.glow = radius;
|
target,
|
||||||
}
|
tag,
|
||||||
}
|
present,
|
||||||
Action::SetTag {
|
} => {
|
||||||
target,
|
if let Some(obj) = self.board_mut().scripting_mut(target) {
|
||||||
tag,
|
if present {
|
||||||
present,
|
obj.tags.insert(tag);
|
||||||
} => {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
log_sink.error(format!(
|
obj.tags.remove(&tag);
|
||||||
"teleport({target},{x},{y}): invalid target id"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// push() self-checks can_push, so an in-bounds guard is all we add.
|
}
|
||||||
Action::Push { x, y, dir } => {
|
// Replace any existing bubble from this object so repeated say() calls
|
||||||
if !board.in_bounds((x, y)) {
|
// don't stack visually — the new text resets the timer.
|
||||||
log_sink.error(format!("push({x},{y}): out of bounds"));
|
Action::Say(text, duration) => {
|
||||||
} else {
|
// One bubble per object: replace the existing one if present.
|
||||||
// Each pushed solid stepped one cell in `dir`; fire `enter`
|
self.speech_bubbles
|
||||||
// on any non-solid it landed on (came-from `dir.opposite()`).
|
.retain(|b| b.object_id != ba.source);
|
||||||
for (cx, cy) in board.push(x as usize, y as usize, dir) {
|
self.speech_bubbles.push(
|
||||||
for id in board.non_solid_object_ids_at(cx, cy) {
|
SpeechBubble {
|
||||||
enters.push((id, dir.opposite()));
|
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::Send { target, fn_name, arg} => {
|
||||||
Action::Shift(cells) => {
|
self.scripts.run_send(target, &fn_name, arg);
|
||||||
let outcome = board.apply_shift(&cells);
|
}
|
||||||
for line in outcome.errors {
|
Action::Scroll(lines) => {
|
||||||
log_sink.line(line);
|
self.active_scroll.replace(Scroll {
|
||||||
}
|
source: ba.source,
|
||||||
for (from, to) in outcome.moves {
|
lines,
|
||||||
// A shift can rotate non-adjacent cells, so the came-from
|
choice: None,
|
||||||
// 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)
|
Action::Teleport { target, x, y } => {
|
||||||
{
|
apply_teleport(&mut self.board_mut(), target, x, y).unwrap_or_else(|e| log_sink.error(e))
|
||||||
for id in
|
}
|
||||||
board.non_solid_object_ids_at(to.0 as usize, to.1 as usize)
|
Action::Push { x, y, dir } => {
|
||||||
{
|
apply_push(&mut self.board_mut(), x, y, dir).unwrap_or_else(|e| log_sink.error(e))
|
||||||
enters.push((id, from_dir));
|
}
|
||||||
}
|
Action::Shift(cells) => {
|
||||||
}
|
apply_shift(&mut self.board_mut(), &cells).unwrap_or_else(|e| log_sink.error(e))
|
||||||
}
|
}
|
||||||
}
|
Action::AddGems(n) => {
|
||||||
// Accumulated and applied to `self.player.gems` after the borrow drops.
|
self.player.borrow_mut().alter_gems(n);
|
||||||
Action::AddGems(n) => gem_delta += n,
|
},
|
||||||
// Accumulated and applied to `self.player.health` after the borrow drops.
|
Action::AlterHealth(dh) => {
|
||||||
Action::AlterHealth(dh) => health_delta += dh,
|
self.player.borrow_mut().alter_health(dh)
|
||||||
// Collected and applied to `self.player.keys` after the borrow drops.
|
},
|
||||||
Action::SetKey(color, present) => key_changes.push((color, present)),
|
Action::SetKey(color, present) => {
|
||||||
// A grab thing despawns itself from its grab() hook.
|
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
|
||||||
Action::Die => {
|
log_sink.error(format!("set_key: unknown color {color:?}"));
|
||||||
board.remove_object(ba.source);
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
Action::Die => {
|
||||||
|
self.board_mut().remove_object(ba.source);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for bubble in new_bubbles {
|
self.drain_log();
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consumes the active scroll, dispatching the player's choice (if any) back
|
/// 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
|
// Dispatch the choice back to the source object and apply whatever it
|
||||||
// queues (plus any bump/send cascade), the same as a tick.
|
// queues (plus any bump/send cascade), the same as a tick.
|
||||||
let actions = self.scripts.run_send(scroll.source, &choice, SendArg::None);
|
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).
|
// Find the named arrival portal on the target board (borrow then release).
|
||||||
let arrival = self.world.boards[target_map]
|
let arrival = self.world.boards[target_map]
|
||||||
.borrow()
|
.borrow()
|
||||||
.portals
|
.named_portal(target_entry).map(Portal::location);
|
||||||
.iter()
|
|
||||||
.find(|p| p.name == target_entry)
|
|
||||||
.map(|p| (p.x, p.y));
|
|
||||||
let (ax, ay) = match arrival {
|
let (ax, ay) = match arrival {
|
||||||
Some(pos) => pos,
|
Some(pos) => pos,
|
||||||
None => {
|
None => {
|
||||||
@@ -614,10 +393,7 @@ impl GameState {
|
|||||||
self.active_scroll = None;
|
self.active_scroll = None;
|
||||||
// Switch to the new board and place the player at the arrival portal.
|
// Switch to the new board and place the player at the arrival portal.
|
||||||
self.current_board_name = target_map.to_string();
|
self.current_board_name = target_map.to_string();
|
||||||
self.board_mut().player = PlayerPos {
|
self.board_mut().get_mut(ax, ay).replace(Tile::Player);
|
||||||
x: ax as i64,
|
|
||||||
y: ay as i64,
|
|
||||||
};
|
|
||||||
self.board_mut().clear_all_queues();
|
self.board_mut().clear_all_queues();
|
||||||
// Rebuild the script host for the new board's objects.
|
// Rebuild the script host for the new board's objects.
|
||||||
self.scripts = ScriptHost::new(
|
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
|
/// 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).
|
/// fires with the direction the bump came from (whether or not the player moves).
|
||||||
pub fn try_move(&mut self, dir: Direction) {
|
pub fn try_move(&mut self, dir: Direction) {
|
||||||
let bumped;
|
let (dx, dy): (i64, i64) = dir.into();
|
||||||
let grabbed;
|
let player_loc = self.board().player_pos();
|
||||||
let portal_target;
|
let target = (player_loc.0 as i64 + dx, player_loc.1 as i64 + dy);
|
||||||
// Non-solid objects the player (or the crates it shoved) landed on this move,
|
if !self.board().in_bounds(target) {
|
||||||
// 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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut ev = Events::default();
|
let (nx, ny) = (target.0 as usize, target.1 as usize);
|
||||||
// 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.
|
// We need to be able to call hooks on objects, so we can't hold a mut reference to the
|
||||||
if let Some(id) = grabbed {
|
// board going into this match
|
||||||
let actions = self.scripts.run_grab(id);
|
let obj_data = {
|
||||||
ev.merge(self.apply_actions(actions));
|
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.
|
// Check if we actually moved
|
||||||
ev.bumps.push((idx, dir.opposite()));
|
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();
|
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 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)
|
/// 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).
|
/// and each cell a pushed crate moved into (crates are always solid entrants).
|
||||||
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> StepOutcome {
|
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) {
|
||||||
let mut out = StepOutcome {
|
// TODO when an object pushes the player, it should still trigger actions on
|
||||||
bumped: None,
|
// what the player is pushed into. But, for right now, just call board::push
|
||||||
entered: Vec::new(),
|
let (loc, solid) = if let Some(obj) = board.get_hookable(id) {
|
||||||
};
|
(obj.location(), obj.solid())
|
||||||
let (dx, dy): (i64, i64) = dir.into();
|
} else { return };
|
||||||
let Some((ox, oy, solid)) = board.objects.get(&id).map(|o| (o.x, o.y, o.behavior.solid)) else {
|
|
||||||
return out;
|
if solid {
|
||||||
};
|
// This is a real object on the board, try and push it
|
||||||
let target = (ox as i64 + dx, oy as i64 + dy);
|
board.push(loc.0, loc.1, dir);
|
||||||
if !board.in_bounds(target) {
|
} else {
|
||||||
return out;
|
// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::GameState;
|
use super::GameState;
|
||||||
use crate::Direction;
|
use crate::Direction;
|
||||||
use crate::archetype::{Archetype, Builtin};
|
use crate::builtin::Builtin;
|
||||||
use crate::board::tests::{crate_at, open_board, stamp, wall_at};
|
use crate::board::tests::{crate_at, gem_at, open_board, wall_at};
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -784,9 +574,8 @@ mod tests {
|
|||||||
fn walking_onto_a_gem_grabs_it() {
|
fn walking_onto_a_gem_grabs_it() {
|
||||||
// A gem terrain cell at (1,0); expanding turns it into the builtin gem
|
// 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).
|
// object running scripts/gem.rhai. The player starts at (0,0).
|
||||||
let mut board = open_board(3, 1, (0, 0), vec![]);
|
let mut board = open_board(3, 1, (0, 0));
|
||||||
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem"));
|
gem_at(&mut board, 1, 0);
|
||||||
board.expand_builtin_archetypes();
|
|
||||||
let mut game = GameState::new(board);
|
let mut game = GameState::new(board);
|
||||||
game.run_init();
|
game.run_init();
|
||||||
|
|
||||||
@@ -794,8 +583,7 @@ mod tests {
|
|||||||
|
|
||||||
// The gem was grabbed: gem count up, gem object gone, player on its cell.
|
// The gem was grabbed: gem count up, gem object gone, player on its cell.
|
||||||
assert_eq!(game.player.borrow().gems, 1);
|
assert_eq!(game.player.borrow().gems, 1);
|
||||||
assert!(game.board().objects.is_empty());
|
assert_eq!(game.board().player_pos(), (1, 0));
|
||||||
assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -806,7 +594,7 @@ mod tests {
|
|||||||
// the board edge), so nothing happens and the gem is not collected.
|
// the board edge), so nothing happens and the gem is not collected.
|
||||||
let mut sobj = ObjectDef::new(0, 0);
|
let mut sobj = ObjectDef::new(0, 0);
|
||||||
sobj.behavior.solid = false;
|
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]);
|
let mut board = open_board(3, 1, (2, 0), vec![sobj]);
|
||||||
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem"));
|
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem"));
|
||||||
board.expand_builtin_archetypes();
|
board.expand_builtin_archetypes();
|
||||||
@@ -830,7 +618,7 @@ mod tests {
|
|||||||
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
|
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
|
||||||
let mut obj = ObjectDef::new(0, 0);
|
let mut obj = ObjectDef::new(0, 0);
|
||||||
obj.behavior.solid = false;
|
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]);
|
let mut board = open_board(board_w, 1, (board_w as i64 - 1, 0), vec![obj]);
|
||||||
crate_at(&mut board, 2, 0);
|
crate_at(&mut board, 2, 0);
|
||||||
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
|
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
|
||||||
@@ -860,7 +648,7 @@ mod tests {
|
|||||||
// genuinely empty in-bounds cell.
|
// genuinely empty in-bounds cell.
|
||||||
let mut obj = ObjectDef::new(0, 0);
|
let mut obj = ObjectDef::new(0, 0);
|
||||||
obj.behavior.solid = false;
|
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]);
|
let mut board = open_board(4, 1, (3, 0), vec![obj]);
|
||||||
crate_at(&mut board, 2, 0);
|
crate_at(&mut board, 2, 0);
|
||||||
let src = "fn init(m) { \
|
let src = "fn init(m) { \
|
||||||
@@ -887,7 +675,7 @@ mod tests {
|
|||||||
// would have been stuck behind the delay and absent here.
|
// would have been stuck behind the delay and absent here.
|
||||||
let mut obj = ObjectDef::new(0, 0);
|
let mut obj = ObjectDef::new(0, 0);
|
||||||
obj.behavior.solid = false;
|
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 board = open_board(4, 1, (3, 0), vec![obj]);
|
||||||
let src = "fn init(m) { delay(5.0); log(\"immediate\"); }";
|
let src = "fn init(m) { delay(5.0); log(\"immediate\"); }";
|
||||||
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
|
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
|
||||||
@@ -917,9 +705,9 @@ mod tests {
|
|||||||
) -> GameState {
|
) -> GameState {
|
||||||
let mut obj = ObjectDef::new(1, 1);
|
let mut obj = ObjectDef::new(1, 1);
|
||||||
obj.behavior.solid = false;
|
obj.behavior.solid = false;
|
||||||
obj.script_name = Some("spinner".to_string());
|
obj.scripting.script_name = Some("spinner".to_string());
|
||||||
if ccw {
|
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]);
|
let mut board = open_board(3, 3, (1, 1), vec![obj]);
|
||||||
for &(x, y) in crates {
|
for &(x, y) in crates {
|
||||||
@@ -1000,7 +788,7 @@ mod tests {
|
|||||||
fn set_key_gives_and_takes_keys() {
|
fn set_key_gives_and_takes_keys() {
|
||||||
let mut sobj = ObjectDef::new(0, 0);
|
let mut sobj = ObjectDef::new(0, 0);
|
||||||
sobj.behavior.solid = false;
|
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 board = open_board(2, 1, (1, 0), vec![sobj]);
|
||||||
let scripts = HashMap::from([(
|
let scripts = HashMap::from([(
|
||||||
"s".to_string(),
|
"s".to_string(),
|
||||||
@@ -1017,7 +805,7 @@ mod tests {
|
|||||||
// A second script can take a key.
|
// A second script can take a key.
|
||||||
let mut sobj2 = ObjectDef::new(0, 0);
|
let mut sobj2 = ObjectDef::new(0, 0);
|
||||||
sobj2.behavior.solid = false;
|
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 board2 = open_board(2, 1, (1, 0), vec![sobj2]);
|
||||||
let scripts2 = HashMap::from([(
|
let scripts2 = HashMap::from([(
|
||||||
"t".to_string(),
|
"t".to_string(),
|
||||||
@@ -1033,7 +821,7 @@ mod tests {
|
|||||||
fn set_key_unknown_color_logs_error() {
|
fn set_key_unknown_color_logs_error() {
|
||||||
let mut sobj = ObjectDef::new(0, 0);
|
let mut sobj = ObjectDef::new(0, 0);
|
||||||
sobj.behavior.solid = false;
|
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 board = open_board(2, 1, (1, 0), vec![sobj]);
|
||||||
let scripts = HashMap::from([(
|
let scripts = HashMap::from([(
|
||||||
"s".to_string(),
|
"s".to_string(),
|
||||||
|
|||||||
+56
-1
@@ -19,7 +19,7 @@ use crate::utils::LogSink;
|
|||||||
/// (see [`Glyph::player`]).
|
/// (see [`Glyph::player`]).
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||||
pub struct Glyph {
|
pub struct Glyph {
|
||||||
/// Tile index into the board's bitmap font (left-to-right, top-to-bottom).
|
/// Which tile to draw
|
||||||
pub tile: u32,
|
pub tile: u32,
|
||||||
/// Foreground color, applied to non-background pixels of the tile.
|
/// Foreground color, applied to non-background pixels of the tile.
|
||||||
pub fg: Rgba8,
|
pub fg: Rgba8,
|
||||||
@@ -27,6 +27,10 @@ pub struct Glyph {
|
|||||||
pub bg: Rgba8,
|
pub bg: Rgba8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for Glyph {
|
||||||
|
fn default() -> Self { Self::transparent() }
|
||||||
|
}
|
||||||
|
|
||||||
impl Hash for Glyph {
|
impl Hash for Glyph {
|
||||||
/// Hash via packed u32 representations so the impl stays in sync with Eq.
|
/// Hash via packed u32 representations so the impl stays in sync with Eq.
|
||||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
@@ -77,4 +81,55 @@ impl Glyph {
|
|||||||
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
|
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 action;
|
||||||
mod archetype;
|
mod builtin;
|
||||||
mod board;
|
mod board;
|
||||||
mod builtin_scripts;
|
|
||||||
/// The 16 EGA/VGA named colors ([`colors::NAMED_COLORS`]), shared by scripts and the editor.
|
/// The 16 EGA/VGA named colors ([`colors::NAMED_COLORS`]), shared by scripts and the editor.
|
||||||
pub mod colors;
|
pub mod colors;
|
||||||
/// CP437 tile-index → character mapping ([`cp437::tile_to_char`]) for the default font.
|
/// CP437 tile-index → character mapping ([`cp437::tile_to_char`]) for the default font.
|
||||||
pub mod cp437;
|
pub mod cp437;
|
||||||
/// Procedural floor generators ([`floor::FloorGenerator`]).
|
/// Procedural floor generators ([`floor::FloorBiome`]).
|
||||||
pub mod floor;
|
pub mod floor;
|
||||||
/// Lighting & field-of-view for dark boards ([`fov::Lighting`]).
|
/// Lighting & field-of-view for dark boards ([`fov::Lighting`]).
|
||||||
pub mod fov;
|
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 game;
|
||||||
pub mod glyph;
|
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.
|
/// Styled log messages ([`log::LogLine`]) for the in-game message feed.
|
||||||
pub mod log;
|
pub mod log;
|
||||||
/// Map file loading and saving (`.toml` format).
|
|
||||||
pub mod map_file;
|
|
||||||
mod object_def;
|
mod object_def;
|
||||||
/// Rhai scripting runtime for board objects ([`script::ScriptHost`]).
|
/// Rhai scripting runtime for board objects ([`script::ScriptHost`]).
|
||||||
pub mod script;
|
pub mod script;
|
||||||
@@ -26,7 +24,7 @@ mod utils;
|
|||||||
pub mod world;
|
pub mod world;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
pub use archetype::{Archetype, Builtin};
|
pub use builtin::Builtin;
|
||||||
pub use board::Board;
|
pub use board::Board;
|
||||||
pub use fov::{Lighting, SIGHT_RADIUS};
|
pub use fov::{Lighting, SIGHT_RADIUS};
|
||||||
pub use utils::Direction;
|
pub use utils::Direction;
|
||||||
@@ -35,3 +33,4 @@ pub use utils::Direction;
|
|||||||
mod tests;
|
mod tests;
|
||||||
mod api;
|
mod api;
|
||||||
pub mod tile;
|
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::glyph::Glyph;
|
||||||
use crate::utils::{Behavior, ObjectId, Pushable};
|
|
||||||
use color::Rgba8;
|
use color::Rgba8;
|
||||||
use std::collections::HashSet;
|
use crate::tile::{EnterResponse, Optics, ScriptAttributes};
|
||||||
use crate::api::queue::ObjQueue;
|
|
||||||
|
|
||||||
/// A scripted object placed on the board, loaded from a map file.
|
/// 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.
|
/// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ObjectDef {
|
pub struct ObjectDef {
|
||||||
/// Stable identity assigned by [`crate::board::Board::add_object`].
|
/// How this object affects movement
|
||||||
/// `0` is the sentinel meaning "not yet inserted into a board".
|
pub enter_response: EnterResponse,
|
||||||
/// Real ids start at 1 and never change after assignment.
|
/// Everything we need to run our script
|
||||||
/// Not serialized — the id is stamped on insert, not stored in map files.
|
pub scripting: ScriptAttributes,
|
||||||
pub id: ObjectId,
|
}
|
||||||
/// Column of this object on the board (0-indexed).
|
|
||||||
pub x: usize,
|
impl Debug for ObjectDef {
|
||||||
/// Row of this object on the board (0-indexed).
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
pub y: usize,
|
write!(f, "ObjectDef({:?}, {:?}, {:?})", self.scripting.id, self.scripting.glyph, self.scripting.script_name)
|
||||||
/// 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObjectDef {
|
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
|
/// These match the serde defaults in the map file format so new objects
|
||||||
/// round-trip correctly.
|
/// round-trip correctly.
|
||||||
pub fn new(x: usize, y: usize) -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: 0,
|
enter_response: EnterResponse::Block,
|
||||||
x,
|
scripting: ScriptAttributes {
|
||||||
y,
|
id: 0,
|
||||||
glyph: Self::default_glyph(),
|
glyph: Self::default_glyph(),
|
||||||
behavior: Behavior {
|
optics: Optics { opaque: true, glow: 0 },
|
||||||
solid: true,
|
..Default::default()
|
||||||
opaque: true,
|
}
|
||||||
pushable: Pushable::No,
|
|
||||||
grab: false,
|
|
||||||
glow: 0,
|
|
||||||
},
|
|
||||||
script_name: None,
|
|
||||||
builtin_script: None,
|
|
||||||
tags: HashSet::new(),
|
|
||||||
queue: ObjQueue::new(),
|
|
||||||
name: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST};
|
||||||
use crate::game::SAY_DURATION;
|
use crate::game::SAY_DURATION;
|
||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use crate::map_file::parse_color;
|
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use crate::utils::{Direction, LogSink, Hook, ObjectId};
|
use crate::utils::{Direction, LogSink, Hook, ObjectId};
|
||||||
use rhai::{
|
use rhai::{
|
||||||
@@ -49,6 +48,7 @@ use crate::api::object_info::ObjectInfo;
|
|||||||
use crate::api::player::PlayerWithPos;
|
use crate::api::player::PlayerWithPos;
|
||||||
use crate::api::queue::ObjQueue;
|
use crate::api::queue::ObjQueue;
|
||||||
use crate::api::registry::Registry;
|
use crate::api::registry::Registry;
|
||||||
|
use crate::colors::parse_color;
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
use crate::keys::Keyring;
|
use crate::keys::Keyring;
|
||||||
use crate::player::PlayerRef;
|
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 ────────────────────────────────────────────────────────────────
|
// ── ScriptHost ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Owns the Rhai engine and per-object script state for a board.
|
/// 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`.
|
// source for a built-in still comes from its embedded `builtin_script`.
|
||||||
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
|
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
|
||||||
let mut failed: HashSet<String> = HashSet::new();
|
let mut failed: HashSet<String> = HashSet::new();
|
||||||
for obj in board.objects.values() {
|
let all_scriptables = board.sorted_hookables();
|
||||||
let Some(key) = script_key(obj) else {
|
for obj in all_scriptables.iter() {
|
||||||
|
let Some(key) = obj.script_key() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if scripts.contains_key(&key) || failed.contains(&key) {
|
if scripts.contains_key(key) || failed.contains(key) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Source: the embedded built-in, or a lookup in the world script pool.
|
// 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
|
src
|
||||||
} else {
|
} else {
|
||||||
match script_sources.get(&key) {
|
match script_sources.get(key) {
|
||||||
Some(src) => src,
|
Some(src) => src,
|
||||||
None => {
|
None => {
|
||||||
failed.insert(key.clone());
|
failed.insert(key.clone());
|
||||||
@@ -162,7 +153,7 @@ impl ScriptHost {
|
|||||||
.any(|f| f.name == n && f.params.len() == params)
|
.any(|f| f.name == n && f.params.len() == params)
|
||||||
};
|
};
|
||||||
scripts.insert(
|
scripts.insert(
|
||||||
key,
|
key.clone(),
|
||||||
CompiledScript {
|
CompiledScript {
|
||||||
has_init: defines("init", 1),
|
has_init: defines("init", 1),
|
||||||
has_tick: defines("tick", 2),
|
has_tick: defines("tick", 2),
|
||||||
@@ -181,16 +172,13 @@ impl ScriptHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One runtime per object whose script compiled.
|
// One runtime per object whose script compiled.
|
||||||
for (&id, obj) in board.objects.iter() {
|
for obj in all_scriptables.iter() {
|
||||||
let Some(key) = script_key(obj) else {
|
if let Some(key) = obj.script_key() && scripts.contains_key(key) {
|
||||||
continue;
|
scopes.insert(obj.id(), Scope::new());
|
||||||
};
|
|
||||||
if !scripts.contains_key(&key) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
scopes.insert(id, Scope::new());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drop(all_scriptables);
|
||||||
drop(board);
|
drop(board);
|
||||||
Self {
|
Self {
|
||||||
engine,
|
engine,
|
||||||
@@ -299,9 +287,12 @@ impl ScriptHost {
|
|||||||
/// - If it has arity 0, we pass nothing
|
/// - 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`.
|
/// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT`.
|
||||||
/// Returns the actions the call drained.
|
/// Note: does not drain the queue! If the send triggers an action it gets drained in the
|
||||||
pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) -> Vec<BoardAction> {
|
/// next tick (or next hook, anyway). This is because of possible deadlocks: if we drained
|
||||||
let mut actions = Vec::new();
|
/// 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(mut info) = ObjectInfo::from_id(id, self.board.clone()) {
|
||||||
if let Some(script_key) = info.script_name.as_ref()
|
if let Some(script_key) = info.script_name.as_ref()
|
||||||
&& let Some(script) = self.scripts.get(script_key)
|
&& let Some(script) = self.scripts.get(script_key)
|
||||||
@@ -319,7 +310,7 @@ impl ScriptHost {
|
|||||||
// If it's not there at all, just bail:
|
// If it's not there at all, just bail:
|
||||||
if arities.is_empty() {
|
if arities.is_empty() {
|
||||||
self.log_sink.error(format!("script '{}' send({}) error: function not found", script_key, fn_name));
|
self.log_sink.error(format!("script '{}' send({}) error: function not found", script_key, fn_name));
|
||||||
return actions;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assemble the args
|
// Assemble the args
|
||||||
@@ -342,12 +333,10 @@ impl ScriptHost {
|
|||||||
) {
|
) {
|
||||||
self.log_sink.error(format!("script '{}' send({}) error: {err}", script_key, fn_name));
|
self.log_sink.error(format!("script '{}' send({}) error: {err}", script_key, fn_name));
|
||||||
}
|
}
|
||||||
info.drain(&mut actions, 0.0)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
unreachable!("Object id not found, tried to send");
|
unreachable!("Object id not found, tried to send");
|
||||||
}
|
}
|
||||||
actions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes and returns the log lines (script `log()` output and errors)
|
/// 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();
|
let b = board.clone();
|
||||||
engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| {
|
engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| {
|
||||||
let src = source_of(&ctx);
|
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) {
|
||||||
def.queue.act(Action::Move(dir));
|
scr.queue.act(Action::Move(dir));
|
||||||
def.queue.delay(MOVE_COST);
|
scr.queue.delay(MOVE_COST);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let b = board.clone();
|
let b = board.clone();
|
||||||
engine.register_fn("delay", move |ctx: NativeCallContext, dt: Dynamic| {
|
engine.register_fn("delay", move |ctx: NativeCallContext, dt: Dynamic| {
|
||||||
let src = source_of(&ctx);
|
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() {
|
&& let Ok(dt) = dt.as_float() {
|
||||||
def.queue.delay(dt);
|
scr.queue.delay(dt);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let b = board.clone();
|
let b = board.clone();
|
||||||
engine.register_fn("now", move |ctx: NativeCallContext| {
|
engine.register_fn("now", move |ctx: NativeCallContext| {
|
||||||
let src = source_of(&ctx);
|
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) {
|
||||||
def.queue.now()
|
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`.
|
/// Appends `action` to the output queue of the object identified by `source`.
|
||||||
fn emit(board: &BoardRef, source: ObjectId, action: Action) {
|
fn emit(board: &BoardRef, source: ObjectId, action: Action) {
|
||||||
if let Some(def) = board.borrow_mut().objects.get_mut(&source) {
|
if let Some(scripting) = board.borrow_mut().scripting_mut(source) {
|
||||||
def.queue.act(action);
|
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
|
// 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
|
// 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 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::board::tests::{crate_at, open_board, wall_at};
|
||||||
use crate::game::GameState;
|
use crate::game::GameState;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
use crate::archetype::Archetype;
|
use crate::builtin::Archetype;
|
||||||
use crate::board::Board;
|
use crate::board::Board;
|
||||||
use crate::floor::Floor;
|
use crate::floor::Floor;
|
||||||
use crate::game::GameState;
|
use crate::game::GameState;
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
use crate::utils::{Direction, PlayerPos, PortalDef};
|
use crate::utils::{Direction, PlayerPos};
|
||||||
use crate::world::World;
|
use crate::world::World;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use crate::portal::Portal;
|
||||||
|
|
||||||
/// Builds a 3×3 board with the player at `(px, py)` and the given portals.
|
/// 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 {
|
Board {
|
||||||
name: "test".into(),
|
name: "test".into(),
|
||||||
width: 3,
|
width: 3,
|
||||||
@@ -38,23 +39,23 @@ fn two_board_world() -> World {
|
|||||||
let b1 = make_board(
|
let b1 = make_board(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
vec![PortalDef {
|
vec![Portal {
|
||||||
name: "to_b2".into(),
|
name: "to_b2".into(),
|
||||||
x: 2,
|
x: 2,
|
||||||
y: 0,
|
y: 0,
|
||||||
target_map: "b2".into(),
|
target_board: "b2".into(),
|
||||||
target_entry: "from_b1".into(),
|
target_name: "from_b1".into(),
|
||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
let b2 = make_board(
|
let b2 = make_board(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
vec![PortalDef {
|
vec![Portal {
|
||||||
name: "from_b1".into(),
|
name: "from_b1".into(),
|
||||||
x: 1,
|
x: 1,
|
||||||
y: 1,
|
y: 1,
|
||||||
target_map: "b1".into(),
|
target_board: "b1".into(),
|
||||||
target_entry: "to_b2".into(),
|
target_name: "to_b2".into(),
|
||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
World {
|
World {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::load_board;
|
use super::load_board;
|
||||||
use crate::archetype::Archetype;
|
use crate::builtin::Archetype;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fill_builds_a_full_grid_of_one_char() {
|
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());
|
assert!(board.solid_at(x, y).unwrap().player());
|
||||||
} else {
|
} else {
|
||||||
let obj = &board.objects[&board.object_ids_at(x, y)[0]];
|
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")
|
assert_eq!(tag, "BUILTIN_wall")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::{grid, load_board, map};
|
use super::{grid, load_board, map};
|
||||||
use crate::archetype::Archetype;
|
use crate::builtin::Archetype;
|
||||||
use crate::board::Board;
|
use crate::board::Board;
|
||||||
use crate::map_file::MapFile;
|
use crate::map_file::MapFile;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::{grid, load_board, map, map_3x1_object};
|
use super::{grid, load_board, map, map_3x1_object};
|
||||||
use crate::archetype::Archetype;
|
use crate::builtin::Archetype;
|
||||||
|
|
||||||
/// Palette shorthand.
|
/// Palette shorthand.
|
||||||
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
|
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.len(), 2, "both objects survive");
|
||||||
assert_eq!(board.objects[&1].name.as_deref(), Some("gate"));
|
assert_eq!(board.objects[&1].scripting.name.as_deref(), Some("gate"));
|
||||||
assert_eq!(board.objects[&2].name, None);
|
assert_eq!(board.objects[&2].scripting.name, None);
|
||||||
assert!(!board.is_valid(), "duplicate name is a nonfatal load error");
|
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.len(), 3);
|
||||||
assert_eq!(board.objects[&1].name.as_deref(), Some("guard"));
|
assert_eq!(board.objects[&1].scripting.name.as_deref(), Some("guard"));
|
||||||
assert_eq!(board.objects[&2].name, None);
|
assert_eq!(board.objects[&2].scripting.name, None);
|
||||||
assert_eq!(board.objects[&3].name, None);
|
assert_eq!(board.objects[&3].scripting.name, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::{grid, load_board, map};
|
use super::{grid, load_board, map};
|
||||||
use crate::archetype::Archetype;
|
use crate::builtin::Archetype;
|
||||||
|
|
||||||
/// Palette shorthands shared by these tests.
|
/// Palette shorthands shared by these tests.
|
||||||
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
|
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
//! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_<dir>` tag).
|
//! carrying the embedded `pusher.rhai` plus a `BUILTIN_pusher_<dir>` tag).
|
||||||
|
|
||||||
use super::{grid, load_board, map};
|
use super::{grid, load_board, map};
|
||||||
use crate::archetype::Archetype;
|
use crate::builtin::Archetype;
|
||||||
use crate::game::GameState;
|
use crate::game::GameState;
|
||||||
use crate::map_file::MapFile;
|
use crate::map_file::MapFile;
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use crate::{Board, Builtin};
|
use crate::Board;
|
||||||
|
|
||||||
/// Finds the pusher object on a board (by its built-in tag).
|
/// 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 {
|
fn pusher<'a>(board: &'a crate::board::Board, id: &mut u32) -> &'a ObjectDef {
|
||||||
let (&oid, obj) = board
|
let (&oid, obj) = board
|
||||||
.objects
|
.objects
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, o)| o.tags.contains("BUILTIN_pusher_east"))
|
.find(|(_, o)| o.scripting.tags.contains("BUILTIN_pusher_east"))
|
||||||
.expect("pusher object");
|
.expect("pusher object");
|
||||||
*id = oid;
|
*id = oid;
|
||||||
obj
|
obj
|
||||||
@@ -34,17 +34,17 @@ fn pusher_loads_as_a_tagged_scripted_solid_object() {
|
|||||||
let p = pusher(&board, &mut id);
|
let p = pusher(&board, &mut id);
|
||||||
assert_eq!((p.x, p.y), (0, 0));
|
assert_eq!((p.x, p.y), (0, 0));
|
||||||
assert!(
|
assert!(
|
||||||
p.builtin_script.is_some(),
|
p.scripting.builtin_script.is_some(),
|
||||||
"carries the embedded pusher script"
|
"carries the embedded pusher script"
|
||||||
);
|
);
|
||||||
// Each alias gets its own compile-cache key (e.g. "BUILTIN_pusher_east") so the
|
// 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").
|
// 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");
|
assert!(!board.is_passable(0, 0), "pusher is solid");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_tag(board: &Board, x: usize, y: usize, tag: &str) -> bool {
|
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]
|
#[test]
|
||||||
@@ -135,5 +135,5 @@ fn pusher_round_trips_to_its_keyword() {
|
|||||||
let mut id = 0;
|
let mut id = 0;
|
||||||
let p = pusher(&board2, &mut id);
|
let p = pusher(&board2, &mut id);
|
||||||
assert_eq!((p.x, p.y), (0, 0));
|
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 board = load_board(&toml);
|
||||||
let obj0 = &board.objects[&1];
|
let obj0 = &board.objects[&1];
|
||||||
assert!(obj0.tags.contains("enemy") && obj0.tags.contains("boss"));
|
assert!(obj0.scripting.tags.contains("enemy") && obj0.scripting.tags.contains("boss"));
|
||||||
assert_eq!(obj0.tags.len(), 2);
|
assert_eq!(obj0.scripting.tags.len(), 2);
|
||||||
|
|
||||||
// Saved tags must be sorted alphabetically (boss before enemy).
|
// Saved tags must be sorted alphabetically (boss before enemy).
|
||||||
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
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");
|
assert!(boss < enemy, "tags must be sorted: boss before enemy");
|
||||||
|
|
||||||
let board2 = load_board(&toml_out);
|
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]
|
#[test]
|
||||||
@@ -95,7 +95,7 @@ fn object_name_round_trips_through_toml() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
let board = load_board(&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();
|
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -103,7 +103,7 @@ fn object_name_round_trips_through_toml() {
|
|||||||
"name must appear in saved TOML"
|
"name must appear in saved TOML"
|
||||||
);
|
);
|
||||||
let board2 = load_board(&toml_out);
|
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]
|
#[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 toml = map(3, 1, &grid("@O.", &[PLAYER, ("O", &obj("")), EMPTY]));
|
||||||
let board2 = round_trip(&toml);
|
let board2 = round_trip(&toml);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
board2.objects[&1].name, None,
|
board2.objects[&1].scripting.name, None,
|
||||||
"unnamed object must round-trip as None"
|
"unnamed object must round-trip as None"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ fn spinner_loads_as_a_tagged_scripted_solid_object() {
|
|||||||
let (_, obj) = board
|
let (_, obj) = board
|
||||||
.objects
|
.objects
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, o)| o.tags.contains("BUILTIN_spinner_cw"))
|
.find(|(_, o)| o.scripting.tags.contains("BUILTIN_spinner_cw"))
|
||||||
.expect("spinner object");
|
.expect("spinner object");
|
||||||
assert_eq!((obj.x, obj.y), (0, 0));
|
assert_eq!((obj.x, obj.y), (0, 0));
|
||||||
assert!(
|
assert!(
|
||||||
obj.builtin_script.is_some(),
|
obj.scripting.builtin_script.is_some(),
|
||||||
"carries the embedded spinner script"
|
"carries the embedded spinner script"
|
||||||
);
|
);
|
||||||
// Each alias gets its own compile-cache key (e.g. "BUILTIN_spinner_cw") so the
|
// 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").
|
// 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");
|
assert!(!board.is_passable(0, 0), "spinner is solid");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +53,8 @@ fn spinner_round_trips_to_its_keyword() {
|
|||||||
let (_, obj) = board2
|
let (_, obj) = board2
|
||||||
.objects
|
.objects
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, o)| o.tags.contains("BUILTIN_spinner_ccw"))
|
.find(|(_, o)| o.scripting.tags.contains("BUILTIN_spinner_ccw"))
|
||||||
.expect("spinner object after round-trip");
|
.expect("spinner object after round-trip");
|
||||||
assert_eq!((obj.x, obj.y), (0, 0));
|
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
|
let (_, obj) = board
|
||||||
.objects
|
.objects
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, o)| o.tags.contains("BUILTIN_transporter_east"))
|
.find(|(_, o)| o.scripting.tags.contains("BUILTIN_transporter_east"))
|
||||||
.expect("transporter object");
|
.expect("transporter object");
|
||||||
assert_eq!((obj.x, obj.y), (1, 0));
|
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]
|
#[test]
|
||||||
@@ -145,7 +145,7 @@ fn transporter_round_trips_to_its_keyword() {
|
|||||||
board2
|
board2
|
||||||
.objects
|
.objects
|
||||||
.values()
|
.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",
|
"reloads as a tagged transporter object",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ mod map_file;
|
|||||||
mod movement;
|
mod movement;
|
||||||
mod scripting;
|
mod scripting;
|
||||||
|
|
||||||
use crate::archetype::Archetype;
|
use crate::builtin::Archetype;
|
||||||
use crate::board::Board;
|
use crate::board::Board;
|
||||||
use crate::floor::Floor;
|
use crate::floor::Floor;
|
||||||
use crate::game::GameState;
|
use crate::game::GameState;
|
||||||
@@ -24,7 +24,7 @@ fn board_with_object(
|
|||||||
) -> (Board, HashMap<String, String>) {
|
) -> (Board, HashMap<String, String>) {
|
||||||
let mut object = ObjectDef::new(0, 0);
|
let mut object = ObjectDef::new(0, 0);
|
||||||
object.id = 1;
|
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 {
|
let board = Board {
|
||||||
name: "test".into(),
|
name: "test".into(),
|
||||||
width: 1,
|
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.
|
/// Returns an `ObjectDef` at `(x, y)` bound to the named script.
|
||||||
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
|
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
|
||||||
let mut o = ObjectDef::new(x, y);
|
let mut o = ObjectDef::new(x, y);
|
||||||
o.script_name = Some(script.to_string());
|
o.scripting.script_name = Some(script.to_string());
|
||||||
o
|
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::board::tests::{add_floor, crate_at, open_board, stamp, wall_at};
|
||||||
use crate::game::GameState;
|
use crate::game::GameState;
|
||||||
use crate::glyph::Glyph;
|
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);
|
let mut game = GameState::with_scripts(board, scripts);
|
||||||
game.run_init();
|
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.
|
// A script removes a pre-existing tag.
|
||||||
let (mut board2, scripts2) = board_with_object(
|
let (mut board2, scripts2) = board_with_object(
|
||||||
@@ -143,11 +143,12 @@ fn set_tag_adds_and_removes_via_my_id() {
|
|||||||
.objects
|
.objects
|
||||||
.get_mut(&1)
|
.get_mut(&1)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
.scripting
|
||||||
.tags
|
.tags
|
||||||
.insert("active".to_string());
|
.insert("active".to_string());
|
||||||
let mut game2 = GameState::with_scripts(board2, scripts2);
|
let mut game2 = GameState::with_scripts(board2, scripts2);
|
||||||
game2.run_init();
|
game2.run_init();
|
||||||
assert!(!game2.board().objects[&1].tags.contains("active"));
|
assert!(!game2.board().objects[&1].scripting.tags.contains("active"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -175,7 +176,7 @@ fn objects_with_tag_returns_matching_ids() {
|
|||||||
let obj1 = scripted_object(0, 0, "q");
|
let obj1 = scripted_object(0, 0, "q");
|
||||||
let mut obj2 = scripted_object(1, 0, "none");
|
let mut obj2 = scripted_object(1, 0, "none");
|
||||||
// obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not.
|
// 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 board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
|
||||||
let mut game = GameState::with_scripts(
|
let mut game = GameState::with_scripts(
|
||||||
board,
|
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().
|
// An object with a name set on its ObjectDef should see it via my_name().
|
||||||
let (mut board, scripts) =
|
let (mut board, scripts) =
|
||||||
board_with_object(Some("n"), &[("n", r#"fn init(m) { log(m.name); }"#)]);
|
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);
|
let mut game = GameState::with_scripts(board, scripts);
|
||||||
game.run_init();
|
game.run_init();
|
||||||
assert_eq!(log_texts(&game), vec!["beacon"]);
|
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.
|
// object_id_for_name to find the named one and logs its id.
|
||||||
let obj1 = scripted_object(0, 0, "q");
|
let obj1 = scripted_object(0, 0, "q");
|
||||||
let mut obj2 = scripted_object(1, 0, "none");
|
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 board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
|
||||||
let mut game = GameState::with_scripts(
|
let mut game = GameState::with_scripts(
|
||||||
board,
|
board,
|
||||||
|
|||||||
+319
-26
@@ -1,16 +1,21 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::api::queue::ObjQueue;
|
use crate::api::queue::ObjQueue;
|
||||||
|
use crate::{Builtin, Direction};
|
||||||
|
use crate::floor::{Floor, FloorBiome};
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
use crate::object_def::ObjectDef;
|
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
|
/// 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)]
|
#[derive(Serialize, Deserialize, PartialEq, Copy, Clone, Debug)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum EnterResponse {
|
pub enum EnterResponse {
|
||||||
/// Flat denial: don't let the move happen, block it.
|
/// Flat denial: don't let the move happen, block it.
|
||||||
Block,
|
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
|
/// TODO will do something different once inventory exists as a concept
|
||||||
Grab,
|
Grab,
|
||||||
/// Attempt to exit the cell ourselves, the opposite direction. This of course recurses; if our move
|
/// 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
|
/// returns true without teleporting the crate, the crate will be destroyed by the player moving
|
||||||
/// on top of it.
|
/// on top of it.
|
||||||
Hook,
|
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,
|
Swap,
|
||||||
/// Get overwritten and destroyed by whatever moved on top of us. Right now, equivalent to `Grab`
|
/// 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)
|
/// if the hook does nothing (but in the future `Grab` will have other builtin behavior)
|
||||||
Squish
|
Squish
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Behavior> for EnterResponse {
|
impl EnterResponse {
|
||||||
fn from(behavior: Behavior) -> EnterResponse {
|
/// Returns whether this object will transmit a push of the given direction through it:
|
||||||
if behavior.grab {
|
/// - `Push(p)`, if p allows that direction
|
||||||
EnterResponse::Grab
|
/// - `Grab`, because grabbable things act like push if something pushes them
|
||||||
} else if behavior.pushable != Pushable::No {
|
/// - `Swap`, same reason
|
||||||
EnterResponse::Push(behavior.pushable)
|
pub fn transmits_push(self, dir: Direction) -> bool {
|
||||||
} else if behavior.solid {
|
match self {
|
||||||
EnterResponse::Block
|
EnterResponse::Grab | EnterResponse::Swap => true,
|
||||||
} else {
|
EnterResponse::Push(p) if p.allows(dir) => true,
|
||||||
EnterResponse::Squish
|
_ => 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:
|
/// 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
|
/// - `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
|
/// - `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)]
|
#[derive(Serialize, Deserialize, PartialEq, Copy, Clone, Debug)]
|
||||||
#[serde(rename = "lowercase")]
|
#[serde(rename = "lowercase")]
|
||||||
pub enum DrawLayer { Above, Below }
|
pub enum DrawLayer { Above, Below }
|
||||||
@@ -67,6 +86,7 @@ pub enum DrawLayer { Above, Below }
|
|||||||
#[serde(rename = "lowercase")]
|
#[serde(rename = "lowercase")]
|
||||||
pub struct Optics {
|
pub struct Optics {
|
||||||
/// Opaque things block field of view
|
/// Opaque things block field of view
|
||||||
|
#[serde(default = "default_as_true")]
|
||||||
pub opaque: bool,
|
pub opaque: bool,
|
||||||
/// The brightness (in number of cells' radius) of light this
|
/// The brightness (in number of cells' radius) of light this
|
||||||
/// emits. It will emit light in the color of the foreground of its glyph.
|
/// 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
|
/// - the cell is within the glow radius of at least one light source
|
||||||
///
|
///
|
||||||
/// Its color will be tinted by the light sources illuminating it.
|
/// Its color will be tinted by the light sources illuminating it.
|
||||||
|
#[serde(default)]
|
||||||
pub glow: u32,
|
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.
|
/// 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
|
/// TODO clean this up some, especially the script_name-vs-builtin_script dichotomy
|
||||||
#[derive(Clone, Default)]
|
#[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
|
/// Compile-key of the Rhai script that drives this object: a name in
|
||||||
/// [`World::scripts`](crate::world::World::scripts) for a hand-authored object,
|
/// [`World::scripts`](crate::world::World::scripts) for a hand-authored object,
|
||||||
/// or a synthetic `BUILTIN_*` name set when a script-backed archetype is
|
/// 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
|
/// 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.
|
/// affect board movement, as it doesn't live in the grid.
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Sensor {
|
pub struct Sensor {
|
||||||
/// The unique ID of this sensor
|
|
||||||
pub id: ObjectId,
|
|
||||||
/// Where it is on the board: x coord
|
/// Where it is on the board: x coord
|
||||||
pub x: usize,
|
pub x: usize,
|
||||||
/// Where it is on the board: y coord
|
/// Where it is on the board: y coord
|
||||||
pub y: usize,
|
pub y: usize,
|
||||||
/// What it looks like
|
/// Whether this is drawn above or below the grid
|
||||||
pub glyph: Glyph,
|
pub draw_layer: DrawLayer,
|
||||||
/// Sensors interact with FOV and lighting
|
|
||||||
pub optics: Optics,
|
|
||||||
/// Sensors have scripting ability
|
/// Sensors have scripting ability
|
||||||
#[serde(skip)]
|
pub scripting: ScriptAttributes,
|
||||||
pub scripting: Scripting,
|
}
|
||||||
}
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+28
-292
@@ -1,14 +1,9 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use crate::archetype::Archetype;
|
|
||||||
use crate::glyph::Glyph;
|
|
||||||
use color::Rgba8;
|
|
||||||
use rhai::Dynamic;
|
use rhai::Dynamic;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::Board;
|
|
||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use crate::tile::EnterResponse;
|
|
||||||
|
|
||||||
/// Which directions a solid may be pushed in.
|
/// 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.
|
/// A stable, unique identifier for a board object.
|
||||||
///
|
///
|
||||||
/// Ids are handed out by [`Board::add_object`] from the per-board
|
/// Ids are handed out by [`Board::add_object`] from the per-board
|
||||||
@@ -78,263 +44,6 @@ pub struct Behavior {
|
|||||||
/// objects were reordered/destroyed.
|
/// objects were reordered/destroyed.
|
||||||
pub type ObjectId = u32;
|
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.
|
/// A cardinal movement direction.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub enum Direction {
|
pub enum Direction {
|
||||||
@@ -518,4 +227,31 @@ impl LogSink {
|
|||||||
pub fn take(&mut self) -> Vec<LogLine> {
|
pub fn take(&mut self) -> Vec<LogLine> {
|
||||||
std::mem::take(&mut self.0.borrow_mut())
|
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::board::Board;
|
||||||
use crate::fov::SIGHT_RADIUS;
|
use crate::fov::SIGHT_RADIUS;
|
||||||
use crate::map_file::MapFile;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use crate::board_spec::BoardSpec;
|
||||||
|
|
||||||
/// A named world containing one or more boards, loaded from a `.toml` file.
|
/// 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.
|
/// The `[scripts]` table: script name → Rhai source. Shared across all boards.
|
||||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
scripts: HashMap<String, String>,
|
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`.
|
/// serde types from `map_file.rs`.
|
||||||
boards: HashMap<String, MapFile>,
|
boards: HashMap<String, BoardSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `[world]` header section of a world file.
|
/// 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]`.
|
/// Key of the board to start on; must match a key in `[boards]`.
|
||||||
start: String,
|
start: String,
|
||||||
/// Optional player torch radius on dark boards; defaults to [`SIGHT_RADIUS`].
|
/// Optional player torch radius on dark boards; defaults to [`SIGHT_RADIUS`].
|
||||||
|
/// TODO goes away with inventory
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
torch: Option<u32>,
|
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 text = std::fs::read_to_string(path)?;
|
||||||
let wf: WorldFile = toml::from_str(&text)?;
|
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.
|
// hard errors; everything else is nonfatal and lands on Board::load_errors.
|
||||||
let mut boards = HashMap::new();
|
let mut boards = HashMap::new();
|
||||||
for (key, map_file) in wf.boards {
|
for (key, spec) in wf.boards {
|
||||||
let board = Board::try_from(map_file).map_err(|e| format!("board '{}': {}", key, e))?;
|
let board = spec.into_board(&script_names).map_err(|e| format!("board '{}': {}", key, e))?;
|
||||||
boards.insert(key, Rc::new(RefCell::new(board)));
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::assert_matches;
|
||||||
use super::World;
|
use super::World;
|
||||||
use crate::archetype::Archetype;
|
|
||||||
use crate::board::tests::open_board;
|
use crate::board::tests::open_board;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::rc::Rc;
|
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
|
/// 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).
|
/// original board untouched (the property the editor's playtest relies on).
|
||||||
#[test]
|
#[test]
|
||||||
fn deep_clone_isolates_boards() {
|
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();
|
let mut boards = HashMap::new();
|
||||||
boards.insert("start".to_string(), Rc::new(RefCell::new(board)));
|
boards.insert("start".to_string(), Rc::new(RefCell::new(board)));
|
||||||
let world = World {
|
let world = World {
|
||||||
@@ -153,22 +157,11 @@ mod tests {
|
|||||||
|
|
||||||
let copy = world.deep_clone();
|
let copy = world.deep_clone();
|
||||||
// Stamp a wall into the copy's board.
|
// Stamp a wall into the copy's board.
|
||||||
copy.boards["start"].borrow_mut().place_archetype(
|
copy.boards["start"].borrow_mut().place(1, 0, Some(TileSpec::wall())).expect("could not place wall");
|
||||||
1,
|
|
||||||
0,
|
|
||||||
Archetype::Builtin(Builtin::Wall, "wall"),
|
|
||||||
Builtin::Wall.default_glyph_for("wall"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The copy changed; the original is still empty at that cell.
|
// The copy changed; the original is still empty at that cell.
|
||||||
assert_eq!(
|
assert_matches!(copy.boards["start"].borrow().get(1, 0), Some(Tile::Object(_)));
|
||||||
copy.boards["start"].borrow().get(1, 0).1,
|
assert!(world.boards["start"].borrow().get(1, 0).is_none());
|
||||||
Archetype::Builtin(Builtin::Wall, "wall")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
world.boards["start"].borrow().get(1, 0).1,
|
|
||||||
Archetype::Empty
|
|
||||||
);
|
|
||||||
// And they are genuinely different allocations.
|
// And they are genuinely different allocations.
|
||||||
assert!(!Rc::ptr_eq(&world.boards["start"], ©.boards["start"]));
|
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::glyph::Glyph;
|
||||||
use kiln_core::log::LogLine;
|
use kiln_core::log::LogLine;
|
||||||
use kiln_core::world::World;
|
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::code_editor::{CodeEditor, CodeEditorOutcome};
|
||||||
use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse};
|
use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -31,6 +31,7 @@ use ratatui::symbols::merge::MergeStrategy;
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Paragraph};
|
use ratatui::widgets::{Block, Paragraph};
|
||||||
use std::cell::{Ref, RefMut};
|
use std::cell::{Ref, RefMut};
|
||||||
|
use kiln_core::tile::TileSpec;
|
||||||
|
|
||||||
/// How long each half of the cursor blink lasts, in seconds.
|
/// How long each half of the cursor blink lasts, in seconds.
|
||||||
const BLINK_SECS: f32 = 0.5;
|
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).
|
/// The archetype the drawing tools stamp on [`place_current`](EditorState::place_current).
|
||||||
/// Defaults to [`Archetype::Wall`]; changed via the Terrain / Pushers menus
|
/// Defaults to [`Archetype::Wall`]; changed via the Terrain / Pushers menus
|
||||||
/// ([`set_current`](EditorState::set_current)).
|
/// ([`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
|
/// The glyph the drawing tools stamp. Reset to the current archetype's default
|
||||||
/// whenever the archetype changes ([`set_current`](EditorState::set_current)), and
|
/// whenever the archetype changes ([`set_current`](EditorState::set_current)), and
|
||||||
/// overridable via the glyph picker (`g`).
|
/// overridable via the glyph picker (`g`).
|
||||||
@@ -112,7 +113,7 @@ impl EditorState {
|
|||||||
code_editor: None,
|
code_editor: None,
|
||||||
glyph_dialog: None,
|
glyph_dialog: None,
|
||||||
cursor,
|
cursor,
|
||||||
current_archetype: Archetype::Builtin(Builtin::Wall, "wall"),
|
current_archetype: "wall",
|
||||||
current_glyph: Builtin::Wall.default_glyph_for("wall"),
|
current_glyph: Builtin::Wall.default_glyph_for("wall"),
|
||||||
draw_mode: false,
|
draw_mode: false,
|
||||||
sidebar_width: DEFAULT_SIDEBAR_WIDTH,
|
sidebar_width: DEFAULT_SIDEBAR_WIDTH,
|
||||||
@@ -241,17 +242,21 @@ impl EditorState {
|
|||||||
/// Sets the archetype the drawing tools stamp, resetting the drawing glyph to
|
/// 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
|
/// that archetype's default (the glyph picker `g` can then override it). Called by
|
||||||
/// the Terrain / Pushers menu choices.
|
/// the Terrain / Pushers menu choices.
|
||||||
pub(crate) fn set_current(&mut self, archetype: Archetype) {
|
pub(crate) fn set_current(&mut self, archetype: &'static str) {
|
||||||
self.current_archetype = archetype;
|
if let Some((builtin, variant)) = Builtin::from_name(archetype) {
|
||||||
self.current_glyph = self.current_archetype.default_glyph();
|
self.current_archetype = variant;
|
||||||
|
self.current_glyph = builtin.default_glyph_for(variant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stamps the current drawing thing (archetype + glyph) into the cell under the
|
/// Stamps the current drawing thing (archetype + glyph) into the cell under the
|
||||||
/// cursor, applying the placement rules (see [`Board::place_archetype`]).
|
/// cursor, applying the placement rules (see [`Board::place_archetype`]).
|
||||||
pub(crate) fn place_current(&mut self) {
|
pub(crate) fn place_current(&mut self) {
|
||||||
let (x, y) = (self.cursor.0 as usize, self.cursor.1 as usize);
|
let (x, y) = (self.cursor.0 as usize, self.cursor.1 as usize);
|
||||||
let (arch, glyph) = (self.current_archetype, self.current_glyph);
|
let res = self.board_mut().place(x, y, Some(TileSpec::Builtin { kind: self.current_archetype.to_string(), glyph: Some(self.current_glyph) }));
|
||||||
self.board_mut().place_archetype(x, y, arch, glyph);
|
if let Err(e) = res {
|
||||||
|
self.log.push(LogLine::error(e))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggles draw mode (whether cursor movement auto-stamps the current thing).
|
/// Toggles draw mode (whether cursor movement auto-stamps the current thing).
|
||||||
@@ -330,12 +335,6 @@ impl EditorState {
|
|||||||
let mut world = self.world.deep_clone();
|
let mut world = self.world.deep_clone();
|
||||||
// Start the playtest on the board open in the editor, not world.start.
|
// Start the playtest on the board open in the editor, not world.start.
|
||||||
world.start = self.board_name.clone();
|
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);
|
let mut game = GameState::from_world(world);
|
||||||
game.log(LogLine::raw("[esc] to return to the editor"));
|
game.log(LogLine::raw("[esc] to return to the editor"));
|
||||||
game.run_init();
|
game.run_init();
|
||||||
@@ -520,7 +519,7 @@ fn draw_footer_lines<'a>(
|
|||||||
// Separator marking off the footer from the menu above.
|
// Separator marking off the footer from the menu above.
|
||||||
Line::from(Span::styled("─".repeat(64), sep_style)),
|
Line::from(Span::styled("─".repeat(64), sep_style)),
|
||||||
// The archetype currently being drawn (no UI to change it yet).
|
// 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![
|
Line::from(vec![
|
||||||
Span::styled("[G] ", key_style),
|
Span::styled("[G] ", key_style),
|
||||||
Span::styled("Glyph ", label_style),
|
Span::styled("Glyph ", label_style),
|
||||||
|
|||||||
+27
-27
@@ -1,7 +1,7 @@
|
|||||||
use crate::editor::EditorState;
|
use crate::editor::EditorState;
|
||||||
use crate::menu::{MenuItem, MenuKey};
|
use crate::menu::{MenuItem, MenuKey};
|
||||||
use crate::mode::PendingMode;
|
use crate::mode::PendingMode;
|
||||||
use kiln_core::{Archetype, Builtin};
|
use kiln_core::Builtin;
|
||||||
use kiln_core::keys::KeyType;
|
use kiln_core::keys::KeyType;
|
||||||
use kiln_core::glyph::Glyph;
|
use kiln_core::glyph::Glyph;
|
||||||
|
|
||||||
@@ -77,10 +77,10 @@ impl MenuLevel {
|
|||||||
MenuEntry::item('c', "Custom glyph [TODO]", |_| {}),
|
MenuEntry::item('c', "Custom glyph [TODO]", |_| {}),
|
||||||
],
|
],
|
||||||
MenuLevel::Terrain => vec![
|
MenuLevel::Terrain => vec![
|
||||||
MenuEntry::item('w', "Wall", |ed| ed.set_current(Archetype::Builtin(Builtin::Wall, "wall"))),
|
MenuEntry::item('w', "Wall", |ed| ed.set_current("wall")),
|
||||||
MenuEntry::item('c', "■ Crate", |ed| ed.set_current(Archetype::Builtin(Builtin::Crate, "crate"))),
|
MenuEntry::item('c', "■ Crate", |ed| ed.set_current("crate")),
|
||||||
MenuEntry::item('v', "↕ Crate", |ed| ed.set_current(Archetype::Builtin(Builtin::VCrate, "vcrate"))),
|
MenuEntry::item('v', "↕ Crate", |ed| ed.set_current("vcrate")),
|
||||||
MenuEntry::item('h', "↔ Crate", |ed| ed.set_current(Archetype::Builtin(Builtin::HCrate, "hcrate"))),
|
MenuEntry::item('h', "↔ Crate", |ed| ed.set_current("hcrate")),
|
||||||
],
|
],
|
||||||
MenuLevel::Machines => vec![
|
MenuLevel::Machines => vec![
|
||||||
MenuEntry::item('p', "Pushers...", |ed| ed.menu.push(MenuLevel::Pushers)),
|
MenuEntry::item('p', "Pushers...", |ed| ed.menu.push(MenuLevel::Pushers)),
|
||||||
@@ -88,56 +88,56 @@ impl MenuLevel {
|
|||||||
ed.menu.push(MenuLevel::Transporters)
|
ed.menu.push(MenuLevel::Transporters)
|
||||||
}),
|
}),
|
||||||
MenuEntry::item('s', "/ CW spinner", |ed| {
|
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| {
|
MenuEntry::item('c', "\\ CCW spinner", |ed| {
|
||||||
ed.set_current(Archetype::Builtin(Builtin::Spinner, "spinner_ccw"))
|
ed.set_current("spinner_ccw")
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
MenuLevel::Pushers => vec![
|
MenuLevel::Pushers => vec![
|
||||||
MenuEntry::item('n', "▲ Pusher", |ed| {
|
MenuEntry::item('n', "▲ Pusher", |ed| {
|
||||||
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_north"))
|
ed.set_current("pusher_north")
|
||||||
}),
|
}),
|
||||||
MenuEntry::item('s', "▼ Pusher", |ed| {
|
MenuEntry::item('s', "▼ Pusher", |ed| {
|
||||||
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_south"))
|
ed.set_current("pusher_south")
|
||||||
}),
|
}),
|
||||||
MenuEntry::item('e', "► Pusher", |ed| {
|
MenuEntry::item('e', "► Pusher", |ed| {
|
||||||
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_east"))
|
ed.set_current("pusher_east")
|
||||||
}),
|
}),
|
||||||
MenuEntry::item('w', "◄ Pusher", |ed| {
|
MenuEntry::item('w', "◄ Pusher", |ed| {
|
||||||
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west"))
|
ed.set_current("pusher_west")
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
MenuLevel::Transporters => vec![
|
MenuLevel::Transporters => vec![
|
||||||
MenuEntry::item('n', "▲ Transporter", |ed| {
|
MenuEntry::item('n', "▲ Transporter", |ed| {
|
||||||
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_north"))
|
ed.set_current("transporter_north")
|
||||||
}),
|
}),
|
||||||
MenuEntry::item('s', "▼ Transporter", |ed| {
|
MenuEntry::item('s', "▼ Transporter", |ed| {
|
||||||
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_south"))
|
ed.set_current("transporter_south")
|
||||||
}),
|
}),
|
||||||
MenuEntry::item('e', "► Transporter", |ed| {
|
MenuEntry::item('e', "► Transporter", |ed| {
|
||||||
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_east"))
|
ed.set_current("transporter_east")
|
||||||
}),
|
}),
|
||||||
MenuEntry::item('w', "◄ Transporter", |ed| {
|
MenuEntry::item('w', "◄ Transporter", |ed| {
|
||||||
ed.set_current(Archetype::Builtin(Builtin::Transporter, "transporter_west"))
|
ed.set_current("transporter_west")
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
MenuLevel::Items => vec![
|
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
|
// '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",
|
MenuEntry::glyph('h', Builtin::Heart.default_glyph_for("heart"), "Heart",
|
||||||
|ed| ed.set_current(Archetype::Builtin(Builtin::Heart, "heart"))),
|
|ed| ed.set_current("heart")),
|
||||||
MenuEntry::Separator,
|
MenuEntry::Separator,
|
||||||
MenuEntry::glyph('b', KeyType::Blue.glyph(), "Blue key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_blue"))),
|
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(Archetype::Builtin(Builtin::Key, "key_green"))),
|
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(Archetype::Builtin(Builtin::Key, "key_cyan"))),
|
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(Archetype::Builtin(Builtin::Key, "key_red"))),
|
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(Archetype::Builtin(Builtin::Key, "key_purple"))),
|
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(Archetype::Builtin(Builtin::Key, "key_orange"))),
|
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(Archetype::Builtin(Builtin::Key, "key_yellow"))),
|
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(Archetype::Builtin(Builtin::Key, "key_white"))),
|
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> {
|
impl<'a> BoardWidget<'a> {
|
||||||
/// Creates a widget that renders `board`, scrolling to follow the player.
|
/// Creates a widget that renders `board`, scrolling to follow the player.
|
||||||
pub fn new(board: &'a Board) -> Self {
|
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 }
|
Self { board, focus, fov: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,11 +181,11 @@ mod tests {
|
|||||||
use super::{BoardWidget, DARKNESS_BG};
|
use super::{BoardWidget, DARKNESS_BG};
|
||||||
use crate::utils::rgba8_to_color;
|
use crate::utils::rgba8_to_color;
|
||||||
use kiln_core::Board;
|
use kiln_core::Board;
|
||||||
use kiln_core::map_file::MapFile;
|
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use ratatui::widgets::Widget;
|
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
|
/// 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.
|
/// and a wall at x=2 occluding the two cells behind it.
|
||||||
@@ -201,7 +202,7 @@ mod tests {
|
|||||||
"@" = { kind = "player" }
|
"@" = { kind = "player" }
|
||||||
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#404040" }
|
"#" = { 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()
|
Board::try_from(mf).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +256,7 @@ mod tests {
|
|||||||
[grid.palette]
|
[grid.palette]
|
||||||
"L" = { kind = "object", tile = 1, fg = "#ff0000", bg = "#000000", solid = false, light = 2 }
|
"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 fov = board.lighting(0); // no player torch — only the object lights
|
||||||
|
|
||||||
let area = Rect::new(0, 0, 6, 1);
|
let area = Rect::new(0, 0, 6, 1);
|
||||||
|
|||||||
+10
-7
@@ -113,11 +113,12 @@ impl Widget for SpeechBubblesWidget<'_> {
|
|||||||
.bg(Color::Rgb(20, 20, 40));
|
.bg(Color::Rgb(20, 20, 40));
|
||||||
|
|
||||||
// Determine the player's screen position for bubble avoidance.
|
// Determine the player's screen position for bubble avoidance.
|
||||||
|
let player = self.board.player_pos();
|
||||||
let (px, py, player_vis) = board_screen_pos(
|
let (px, py, player_vis) = board_screen_pos(
|
||||||
area,
|
area,
|
||||||
self.board,
|
self.board,
|
||||||
self.board.player.x.max(0) as usize,
|
player.0,
|
||||||
self.board.player.y.max(0) as usize,
|
player.1,
|
||||||
);
|
);
|
||||||
let player = player_vis.then_some((px, py));
|
let player = player_vis.then_some((px, py));
|
||||||
|
|
||||||
@@ -126,11 +127,12 @@ impl Widget for SpeechBubblesWidget<'_> {
|
|||||||
.bubbles
|
.bubbles
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|b| {
|
.filter_map(|b| {
|
||||||
let obj = self.board.objects.get(&b.object_id)?;
|
let obj = self.board.get_hookable(b.object_id)?;
|
||||||
let (sx, sy, on_screen) = board_screen_pos(area, self.board, obj.x, obj.y);
|
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
|
// 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.
|
// 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))
|
Some((sx, sy, on_screen && visible, b))
|
||||||
})
|
})
|
||||||
.collect();
|
.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;
|
/// 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).
|
/// `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) {
|
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 player = board.player_pos();
|
||||||
let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y as i32);
|
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_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 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;
|
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 std::collections::VecDeque;
|
||||||
use crate::utils::rgba8_to_color;
|
use crate::utils::rgba8_to_color;
|
||||||
use kiln_core::{Archetype, Builtin};
|
use kiln_core::Builtin;
|
||||||
use kiln_core::cp437::tile_to_char;
|
use kiln_core::cp437::tile_to_char;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
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
|
// 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).
|
// 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_char = tile_to_char(gem_glyph.tile).to_string();
|
||||||
let gem_style = Style::default().fg(rgba8_to_color(gem_glyph.fg));
|
let gem_style = Style::default().fg(rgba8_to_color(gem_glyph.fg));
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -191,7 +191,7 @@ width = 60
|
|||||||
height = 25
|
height = 25
|
||||||
# A single procedural grass floor across the whole board (the old mixed
|
# A single procedural grass floor across the whole board (the old mixed
|
||||||
# grass/dirt/stone/water floor is not representable with one floor attribute).
|
# 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 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.
|
# 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"
|
name = "Starting Room"
|
||||||
width = 21
|
width = 21
|
||||||
height = 6
|
height = 6
|
||||||
# No floor attribute → a blank (black) floor.
|
# No floor attribute → a blank (black) floor.
|
||||||
|
|
||||||
# The single grid: all solids and most non-solids. A space is always a transparent
|
grid = """
|
||||||
# empty cell, so the floor shows through.
|
|
||||||
[boards.start.grid]
|
|
||||||
content = """
|
|
||||||
#####################
|
#####################
|
||||||
# #
|
# #
|
||||||
# G @ #
|
# G @ #
|
||||||
@@ -38,8 +35,9 @@ content = """
|
|||||||
# #
|
# #
|
||||||
#####################
|
#####################
|
||||||
"""
|
"""
|
||||||
[boards.start.grid.palette]
|
[boards.start.palette]
|
||||||
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" }
|
"#" = { type = "builtin", kind = "wall" }
|
||||||
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
|
"o" = { type = "builtin", kind = "crate", glyph = { tile = 254, fg = "#aaaaaa", bg = "#000000" } }
|
||||||
"@" = { kind = "player" }
|
"@" = { type = "player" }
|
||||||
"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000", solid = false, script_name = "greeter" }
|
# "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