Big API refactor

This commit is contained in:
2026-06-28 00:12:52 -05:00
parent db9a5d37b6
commit 9de31d933b
31 changed files with 1036 additions and 1029 deletions
Generated
+4 -3
View File
@@ -754,6 +754,7 @@ name = "kiln-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"color", "color",
"log",
"rhai", "rhai",
"serde", "serde",
"tinyrand", "tinyrand",
@@ -850,9 +851,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "lru" name = "lru"
@@ -1109,7 +1110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
+1
View File
@@ -9,3 +9,4 @@ rhai = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
tinyrand = "0.5" tinyrand = "0.5"
toml = { version = "0.8", features = ["preserve_order"] } toml = { version = "0.8", features = ["preserve_order"] }
log = "0.4.33"
+79 -13
View File
@@ -4,9 +4,10 @@
//! functions), the rate-limiting delay constant, and [`action_to_map`] which //! functions), the rate-limiting delay constant, and [`action_to_map`] which
//! converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. //! converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`.
use std::fmt::Debug;
use crate::log::LogLine; use crate::log::LogLine;
use crate::map_file::color_to_hex; use crate::map_file::color_to_hex;
use crate::utils::{Direction, ObjectId, ScriptArg}; use crate::utils::{Direction, ObjectId};
use color::Rgba8; use color::Rgba8;
use rhai::Dynamic; use rhai::Dynamic;
@@ -27,13 +28,47 @@ pub enum ScrollLine {
Choice { choice: String, display: String }, Choice { choice: String, display: String },
} }
#[derive(Clone, PartialEq)]
pub enum SendArg {
None,
Int(i64),
Float(f64),
String(String),
}
impl From<Dynamic> for SendArg {
fn from(value: Dynamic) -> Self {
if value.is_int() {
Self::Int(value.as_int().unwrap())
} else if value.is_float() {
Self::Float(value.as_float().unwrap())
} else if value.is_string() {
Self::String(value.into_string().unwrap())
} else {
Self::None
}
}
}
impl Into<Dynamic> for SendArg {
fn into(self) -> Dynamic {
match self {
SendArg::None => Dynamic::UNIT,
SendArg::Int(value) => Dynamic::from(value),
SendArg::Float(value) => Dynamic::from(value),
SendArg::String(value) => Dynamic::from(value),
}
}
}
/// One deferred mutation emitted by a script, applied by [`crate::game::GameState`] /// One deferred mutation emitted by a script, applied by [`crate::game::GameState`]
/// after it is promoted onto the board queue. /// after it is promoted onto the board queue.
/// ///
/// [`Action::Delay`] is the exception: it is **never** promoted to the board queue. /// [`Action::Delay`] is the exception: it is **never** promoted to the board queue.
/// It lives only in the per-object output queue and is consumed by `ScriptHost::drain` /// It lives only in the per-object output queue and is consumed by `ScriptHost::drain`
/// to pace how quickly other actions are released. /// to pace how quickly other actions are released.
pub(crate) enum Action { #[derive(Clone)]
pub enum Action {
/// Move the source object one cell in a direction (subject to passability). /// Move the source object one cell in a direction (subject to passability).
Move(Direction), Move(Direction),
/// Set the source object's glyph tile index. /// Set the source object's glyph tile index.
@@ -59,7 +94,7 @@ pub(crate) enum Action {
Send { Send {
target: ObjectId, target: ObjectId,
fn_name: String, fn_name: String,
arg: Option<ScriptArg>, arg: SendArg,
}, },
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice /// Open a scrollable text overlay. Pauses game ticks while shown; a choice
/// selection dispatches [`Send`](Action::Send) to the source object. /// selection dispatches [`Send`](Action::Send) to the source object.
@@ -67,15 +102,15 @@ pub(crate) enum Action {
/// Teleport the source object to `(x, y)`. Blocked (with a logged error) if /// Teleport the source object to `(x, y)`. Blocked (with a logged error) if
/// the source is solid and the destination already has a solid occupant. /// the source is solid and the destination already has a solid occupant.
/// Zero time cost — multiple teleports may fire in one frame. /// Zero time cost — multiple teleports may fire in one frame.
Teleport { x: i32, y: i32 }, Teleport { x: i64, y: i64 },
/// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on /// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on
/// arbitrary cells, not the source object). Zero time cost. /// arbitrary cells, not the source object). Zero time cost.
Push { x: i32, y: i32, dir: Direction }, Push { x: i64, y: i64, dir: Direction },
/// Shift a set of cells, given as `(x, y)` coordinates: each cell moves to the /// Shift a set of cells, given as `(x, y)` coordinates: each cell moves to the
/// next coordinate in the list, unless it can't move, or that cell is blocked. /// next coordinate in the list, unless it can't move, or that cell is blocked.
/// Rotates the contents of the last cell back to the beginning. /// Rotates the contents of the last cell back to the beginning.
/// Zero time cost. /// Zero time cost.
Shift(Vec<(i32, i32)>), Shift(Vec<(i64, i64)>),
/// Add `n` to the player's gem count (negative subtracts; the count is /// Add `n` to the player's gem count (negative subtracts; the count is
/// clamped at 0). Zero time cost. Applied to `GameState::player.gems`. /// clamped at 0). Zero time cost. Applied to `GameState::player.gems`.
AddGems(i64), AddGems(i64),
@@ -93,6 +128,29 @@ pub(crate) enum Action {
Die, Die,
} }
impl Debug for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Action::Move(dir) => write!(f, "Move({:?})", dir),
Action::SetTile(i) => write!(f, "SetTile({i})"),
Action::Log(msg) => write!(f, "Log({:?})", msg),
Action::SetTag { .. } => write!(f, "SetTag"),
Action::Say(_, _) => write!(f, "Say"),
Action::Delay(t) => write!(f, "Delay({t})"),
Action::SetColor { .. } => write!(f, "SetColor"),
Action::Send { .. } => write!(f, "Send"),
Action::Scroll(_) => write!(f, "Scroll"),
Action::Teleport { .. } => write!(f, "Teleport"),
Action::Push { .. } => write!(f, "Push"),
Action::Shift(_) => write!(f, "Shift"),
Action::AddGems(n) => write!(f, "AddGems({n}"),
Action::AlterHealth(health) => write!(f, "AlterHealth({health})"),
Action::SetKey(_, _) => write!(f, "SetKey"),
Action::Die => write!(f, "Die")
}
}
}
// ── Direction helper ────────────────────────────────────────────────────────── // ── Direction helper ──────────────────────────────────────────────────────────
fn dir_to_str(d: Direction) -> &'static str { fn dir_to_str(d: Direction) -> &'static str {
@@ -169,13 +227,12 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
m.insert("type".into(), ds("Send")); m.insert("type".into(), ds("Send"));
m.insert("target".into(), Dynamic::from(*target as i64)); m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("name".into(), Dynamic::from(fn_name.clone())); m.insert("name".into(), Dynamic::from(fn_name.clone()));
if let Some(a) = arg { match arg {
let dyn_arg = match a { SendArg::Int(i) => m.insert("arg".into(), Dynamic::from(*i)),
ScriptArg::Str(s) => Dynamic::from(s.clone()), SendArg::Float(f) => m.insert("arg".into(), Dynamic::from(*f)),
ScriptArg::Num(n) => Dynamic::from(*n), SendArg::String(s) => m.insert("arg".into(), Dynamic::from(s.clone())),
}; SendArg::None => m.insert("arg".into(), Dynamic::UNIT)
m.insert("arg".into(), dyn_arg); };
}
} }
// Scroll lines are not inspectable via the queue map API; emit type only. // Scroll lines are not inspectable via the queue map API; emit type only.
Action::Scroll(_) => { Action::Scroll(_) => {
@@ -215,3 +272,12 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
} }
m m
} }
/// An action promoted from an object's output queue onto the board queue, tagged
/// with the object that issued it.
pub struct BoardAction {
/// The stable [`ObjectId`] of the object that issued the action.
pub(crate) source: ObjectId,
/// The action to apply.
pub(crate) action: Action,
}
+96
View File
@@ -0,0 +1,96 @@
//! ## Board read API (`state.board.*`)
//!
//! - `board.tagged(tag) -> Array[ObjectInfo]` — objects carrying a tag
//! - `board.named(name) -> ObjectInfo | ()` — object with that name (or unit)
//! - `board.get(id) -> ObjectInfo | ()` — look up by id; `-1` returns player info
//! - `board.width` - Board width
//! - `board.height` - Board height
//!
//! ## Cell queries
//!
//! - `board.can_push(x, y, dir) -> bool` — is the pushable chain at `(x, y)` shovable in `dir`
//! - `board.passable(x, y) -> bool` — is `(x, y)` on-board and free of any solid (a hole)
//! cell (one empty, or a grab thing and the player)
use std::cell::RefCell;
use std::rc::Rc;
use rhai::{Dynamic, Engine, ImmutableString};
use crate::{Board, Direction};
use crate::api::object_info::ObjectInfo;
use crate::script::Registerable;
use crate::utils::{ErrorSink, ObjectId};
/// A read-only handle to the world, exposed to scripts as `Board`.
pub type BoardRef = Rc<RefCell<Board>>;
impl Registerable for BoardRef {
fn register(engine: &mut Engine, error_sink: ErrorSink) {
engine.register_type_with_name::<BoardRef>("Board");
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
// can_push(x, y, dir) — true if (x, y) holds a pushable whose chain can be
// shoved one step in dir (the read-only half of push()). False off-board.
engine.register_fn("can_push", move |board: BoardRef, x: i64, y: i64, dir: Direction| -> bool {
let board = board.borrow();
board.in_bounds((x, y)) && board.can_push(x as usize, y as usize, dir)
});
// passable(x, y) — true if (x, y) is on-board and holds no solid (an empty cell
// a mover could enter). Off-board is not passable. Lets a script distinguish a
// hole from a blocked solid (which can_shift/can_push alone cannot).
engine.register_fn("passable", move |board: BoardRef, x: i64, y: i64| -> bool {
let board = board.borrow();
board.in_bounds((x, y)) && board.is_passable(x as usize, y as usize)
});
// Board.get(id) -> ObjectInfo | () (unknown id logs error)
engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic {
if id <= 0 {
error_sink.error(format!("Board.get: invalid id {id}"));
return Dynamic::UNIT;
}
if let Some(obj) = ObjectInfo::from_id(id as ObjectId, board.clone()) {
Dynamic::from(obj)
} else {
error_sink.error(format!("Board.get: no object with id {id}"));
Dynamic::UNIT
}
});
// Board.named(name) -> ObjectInfo | ()
engine.register_fn("named", move |board_ref: &mut BoardRef, name: ImmutableString| -> Dynamic {
let board = board_ref.borrow();
board
.objects
.iter()
.find_map(|(_id, def)| {
if def.name.as_deref() == Some(name.as_str()) {
Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone())))
} else {
None
}
})
.unwrap_or(Dynamic::UNIT)
},
);
// Board.tagged(tag) -> Array[ObjectInfo]
engine.register_fn("tagged", move |board_ref: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
let board = board_ref.borrow();
board
.objects
.iter()
.filter_map(|(_id, def)| {
if def.tags.contains(tag.as_str()) {
Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone())))
} else {
None
}
})
.collect()
},
);
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod state;
pub mod player;
pub mod board;
pub mod object_info;
pub mod queue;
+137
View File
@@ -0,0 +1,137 @@
//! ## Object Rhai API
//!
//! ### Getters
//!
//! - x, y -> Board location of object
//! - id -> The object's board-unique id
//! - name -> The object's name, or ()
//! - waiting -> bool for whether or not the front of this object's queue is a delay action
//! - queue -> the action queue for this object
//! - tags -> Array of tags for this object
//! - glyph -> The object's current glyph
//!
//! ### Functions
//!
//! - has_tag(tag) -> Whether the object has this tag
//! - delay(secs) -> Queue a delay action
//! - can_push(dir) -> Whether a push in this direction would succeed
//! - blocked(dir) -> Whether a solid neighbors me in this direction
//!
//! Note on blocking: can_push and blocked only take into account the contents of the board
//! at the start of the call; if another thing moves before your actions are flushed to the
//! gamestate, a move might still fail. This is a TODO.
use rhai::{Dynamic, Engine};
use crate::api::board::BoardRef;
use crate::api::queue::ObjQueue;
use crate::Direction;
use crate::object_def::ObjectDef;
use crate::script::{BoardQueue, Registerable};
use crate::utils::{ErrorSink, ObjectId};
/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`,
/// and `Board.get`. Passed by value — scripts read fields, not a live reference.
/// It also contains things like script_name used by ScriptHost, since we can't keep a live
/// borrow of the board while doing anything: we create one of these loose and then
/// it borrows the board only for the duration of what it needs.
#[derive(Clone)]
pub struct ObjectInfo {
pub id: ObjectId,
pub x: i64,
pub y: i64,
pub board: BoardRef,
pub script_name: Option<String>,
pub queue: ObjQueue
}
impl ObjectInfo {
pub fn from_id(id: ObjectId, board: BoardRef) -> Option<ObjectInfo> {
let b = board.borrow();
let obj = b.objects.get(&id)?;
Some(ObjectInfo {
id,
x: obj.x as i64,
y: obj.y as i64,
board: board.clone(),
script_name: obj.script_name.clone(),
queue: obj.queue.clone()
})
}
pub fn from_def(obj: &ObjectDef, board: BoardRef) -> ObjectInfo {
Self {
id: obj.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 drain(&mut self, target: BoardQueue, dt: f64) {
let mut b = self.board.borrow_mut();
if let Some(def) = b.objects.get_mut(&self.id) {
def.queue.drain(self.id, target, dt)
}
}
}
impl Registerable for ObjectInfo {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<ObjectInfo>("ObjectInfo")
.register_get("x", |obj: &mut ObjectInfo| obj.x)
.register_get("y", |obj: &mut ObjectInfo| obj.y)
.register_get("id", |obj: &mut ObjectInfo| obj.id as i64)
.register_get("waiting", |obj: &mut ObjectInfo| obj.queue.waiting())
.register_get("queue", |obj: &mut ObjectInfo| obj.queue.clone());
engine.register_get("name", |o: &mut ObjectInfo| {
let board = o.board.borrow();
let obj = board.objects.get(&o.id);
if let Some(ObjectDef { name: Some(name), ..}) = obj {
Dynamic::from(name.clone())
} else {
Dynamic::UNIT
}
});
engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array {
let board = o.board.borrow();
let obj = board.objects.get(&o.id);
if let Some(ObjectDef { tags, .. }) = obj {
tags.iter().map(|t| Dynamic::from(t.clone())).collect()
} else {
rhai::Array::new()
}
});
engine.register_get("glyph", |o: &mut ObjectInfo| {
let board = o.board.borrow();
let obj = board.objects.get(&o.id).unwrap();
obj.glyph
});
engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| {
if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) {
tags.contains(&t)
} else {
false
}
});
engine.register_fn("delay", |o: &mut ObjectInfo, dt: f64| o.queue.delay(dt));
engine.register_fn("can_push", move |o: &mut ObjectInfo, dir: Direction| {
let board = o.board.borrow();
board.in_bounds((o.x, o.y)) && board.can_push(o.x as usize, o.y as usize, dir)
});
engine.register_fn("blocked", move |o: &mut ObjectInfo, dir: Direction| -> bool {
let board = o.board.borrow();
let tx = o.x + dir.dx();
let ty = o.y + dir.dy();
board.in_bounds((o.x, o.y)) && !board.is_passable(tx as usize, ty as usize)
});
}
}
+21
View File
@@ -0,0 +1,21 @@
use rhai::Engine;
use crate::player::Player;
use crate::script::Registerable;
use crate::utils::{ErrorSink, PlayerPos};
/// GameState stores player state but Board stores its position, and we want one
/// object to register with Rhai
#[derive(Copy, Clone)]
pub struct PlayerWithPos(pub Player, pub PlayerPos);
impl Registerable for PlayerWithPos {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<PlayerWithPos>("Player")
.register_get("gems", |player: &mut PlayerWithPos| player.0.gems)
.register_get("health", |player: &mut PlayerWithPos| player.0.health)
.register_get("max_health", |player: &mut PlayerWithPos| player.0.max_health)
.register_get("keys", |player: &mut PlayerWithPos| player.0.keys)
.register_get("x", |player: &mut PlayerWithPos| player.1.x)
.register_get("y", |player: &mut PlayerWithPos| player.1.y);
}
}
+118
View File
@@ -0,0 +1,118 @@
//! ## Queue API
//!
//! `queue.length`, `queue.clear()`, `queue.peek()`, `queue.pop()`, `queue.delay()`
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use rhai::{Dynamic, Engine};
use crate::action::{action_to_map, Action, BoardAction};
use crate::script::{BoardQueue, Registerable};
use crate::utils::{ErrorSink, ObjectId};
/// A single object's output queue.
#[derive(Clone)]
pub struct ObjQueue(Rc<RefCell<VecDeque<Action>>>);
impl ObjQueue {
pub fn new() -> Self {
Self(Rc::new(RefCell::new(VecDeque::new())))
}
/// Add a delay to the tail of the queue, or alter the value already there by the given amount.
/// A delay action's value can never be less than 0.0
/// TODO This really ought to be Duration
pub fn delay(&mut self, delay: f64) {
let mut q = self.0.borrow_mut();
if let Some(Action::Delay(r)) = q.back_mut() {
*r = (*r + delay).max(0.0)
} else {
q.push_back(Action::Delay(delay.max(0.0)))
}
}
/// Add an action to the tail of the queue
pub fn act(&mut self, action: Action) {
self.0.borrow_mut().push_back(action);
}
/// promote the most recently enqueued action to the front.
pub fn now(&mut self) {
let mut q = self.0.borrow_mut();
if let Some(back) = q.pop_back() {
q.push_front(back);
}
}
/// Drains object `i`'s output queue onto a target queue: first advances
/// leading `Delay` actions by dt, then drains actions into the target
/// queue until we run out (or hit another delay)
pub fn drain(&mut self, source: ObjectId, target: BoardQueue, mut dt: f64) {
let mut queue = self.0.borrow_mut();
loop {
match queue.front_mut() {
None => break, // No more actions, we're done
Some(Action::Delay(time)) => { // A delay, decrease it by dt
if dt < *time { // Delay is too long, bail out
*time -= dt;
break;
} else { // dt eats the delay, pop it and continue
dt -= *time;
queue.pop_front();
}
}
Some(_) => {
let action = queue.pop_front().unwrap();
target.borrow_mut().push(BoardAction { source, action });
}
}
}
}
/// Return whether this queue has an `Action::Delay` in the front
pub fn waiting(&mut self) -> bool {
matches!(self.0.borrow().front(), Some(Action::Delay(_)))
}
}
impl Registerable for ObjQueue {
fn register(engine: &mut Engine, error_sink: ErrorSink) {
engine.register_type_with_name::<ObjQueue>("Queue");
engine.register_get("length", |q: &mut ObjQueue| q.0.borrow().len() as i64);
engine.register_fn("clear", |q: &mut ObjQueue| q.0.borrow_mut().clear());
// peek(): front action as a Rhai map, or () if empty.
engine.register_fn("peek", |q: &mut ObjQueue| -> Dynamic {
q.0.borrow()
.front()
.map(|a| Dynamic::from(action_to_map(a)))
.unwrap_or(Dynamic::UNIT)
});
// pop(): same, but removes the front.
engine.register_fn("pop", |q: &mut ObjQueue| -> Dynamic {
q.0.borrow_mut()
.pop_front()
.as_ref()
.map(|a| Dynamic::from(action_to_map(a)))
.unwrap_or(Dynamic::UNIT)
});
// Appends a [`Action::Delay`] to the back of `queue`, merging with an existing
// trailing delay to prevent adjacent delays from accumulating.
engine.register_fn("delay", move |q: &mut ObjQueue, secs: Dynamic| {
let secs: Result<f64, &str> = if secs.is_float() {
secs.as_float()
} else if secs.is_int() {
secs.as_int().map(|i| i as f64)
} else {
Err("delay() must be an int or float")
};
match secs {
Ok(secs) => q.delay(secs),
Err(msg) => error_sink.error(msg.to_string())
}
});
}
}
+32
View File
@@ -0,0 +1,32 @@
use rhai::Engine;
use crate::api::board::BoardRef;
use crate::api::player::PlayerWithPos;
use crate::game::GameState;
use crate::script::Registerable;
use crate::utils::ErrorSink;
/// The host-provided context handed to every script hook for the duration of one
/// call. Currently just the player snapshot, but it exists so more host state can
/// be threaded through the `run_*` methods without changing each signature again.
#[derive(Clone)]
pub struct ScriptState {
pub player: PlayerWithPos,
pub board: BoardRef,
}
impl ScriptState {
pub fn from_game_state(game_state: &GameState) -> Self {
Self {
player: PlayerWithPos(game_state.player, game_state.board().player),
board: game_state.board_rc(),
}
}
}
impl Registerable for ScriptState {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<ScriptState>("State")
.register_get("player", |state: &mut ScriptState| state.player)
.register_get("board", |state: &mut ScriptState| state.board.clone());
}
}
+20 -48
View File
@@ -80,6 +80,11 @@ impl Board {
self.layers.len() self.layers.len()
} }
/// Return a list of all `ObjectId`s currently on the board.
pub fn all_ids(&self) -> Vec<ObjectId> {
self.objects.keys().cloned().collect()
}
/// Returns a reference to the cell at `(x, y)` on layer `z`. /// Returns a reference to the cell at `(x, y)` on layer `z`.
/// ///
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are /// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are
@@ -98,7 +103,7 @@ impl Board {
/// Replace the solid (if any) at `(x, y)` with `Empty` /// Replace the solid (if any) at `(x, y)` with `Empty`
pub fn clear_solid(&mut self, x: usize, y: usize) { pub fn clear_solid(&mut self, x: usize, y: usize) {
if self.in_bounds((x as i32, y as i32)) { if self.in_bounds((x as i64, y as i64)) {
if let Some(z) = self.solid_cell_layer(x, y) { if let Some(z) = self.solid_cell_layer(x, y) {
*self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty) *self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty)
} }
@@ -121,7 +126,7 @@ impl Board {
/// Panics if out of bounds. /// Panics if out of bounds.
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph { pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
// The player is rendered above the whole stack (see the `Player` notes). // The player is rendered above the whole stack (see the `Player` notes).
if self.player.x == x as i32 && self.player.y == y as i32 { if self.player.x == x as i64 && self.player.y == y as i64 {
return Glyph::player(); return Glyph::player();
} }
@@ -169,7 +174,7 @@ impl Board {
/// ///
/// Takes signed coords so callers can pass a raw `pos + delta` without first /// Takes signed coords so callers can pass a raw `pos + delta` without first
/// checking for negatives. /// checking for negatives.
pub fn in_bounds(&self, pos: (i32, i32)) -> bool { pub fn in_bounds(&self, pos: (i64, i64)) -> bool {
let (x, y) = pos; let (x, y) = pos;
x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height
} }
@@ -195,7 +200,7 @@ impl Board {
/// Panics if `x` or `y` are out of bounds. /// Panics if `x` or `y` are out of bounds.
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid> { pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid> {
// The player wins its cell (load-time invariant), so it is the solid there. // The player wins its cell (load-time invariant), so it is the solid there.
if self.player.x == x as i32 && self.player.y == y as i32 { if self.player.x == x as i64 && self.player.y == y as i64 {
return Some(Solid::player_at(x, y)); return Some(Solid::player_at(x, y));
} }
// A solid object shadows the cell it sits on; capture its behavior now. // A solid object shadows the cell it sits on; capture its behavior now.
@@ -236,39 +241,6 @@ impl Board {
self.solid_at(x, y).is_none() self.solid_at(x, y).is_none()
} }
/// Returns `true` if the solids at `(x1, y1)` and `(x2, y2)` may share a cell —
/// i.e. moving one onto the other wouldn't break the "one solid per cell"
/// invariant. That holds when at least one cell is empty, or one holds the
/// player and the other a **grab** thing (the player collects it).
///
/// Off-board coordinates are never combinable (returns `false` rather than
/// panicking). Backs the script-facing `combinable(x1, y1, x2, y2)` fn.
pub fn is_combinable(&self, x1: usize, y1: usize, x2: usize, y2: usize) -> bool {
// Are either out of bounds?
if !self.in_bounds((x1 as i32, y1 as i32)) || !self.in_bounds((x2 as i32, y2 as i32)) {
return false
}
// Grab the solids
let solid1 = self.solid_at(x1, y1);
let solid2 = self.solid_at(x2, y2);
// Is one cell empty?
if solid1.is_none() || solid2.is_none() { return true }
// They're both present, unwrap them:
let solid1 = solid1.unwrap();
let solid2 = solid2.unwrap();
// This is probably disallowed then, but let's check for a player coexisting with a grab:
if solid1.player() && solid2.grab() || solid2.player() && solid1.grab() {
return true
}
// Nope, two solids that can't coexist:
false
}
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`. /// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
/// ///
/// Non-solid things are never pushable: `pushable` only matters for solids. /// Non-solid things are never pushable: `pushable` only matters for solids.
@@ -289,14 +261,14 @@ impl Board {
/// `(x, y)` itself holds no pushable solid, so it doubles as the "is the cell /// `(x, y)` itself holds no pushable solid, so it doubles as the "is the cell
/// ahead shovable?" half of a "can I move here?" query. /// ahead shovable?" half of a "can I move here?" query.
pub fn can_push(&self, x: usize, y: usize, dir: Direction) -> bool { pub fn can_push(&self, x: usize, y: usize, dir: Direction) -> bool {
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
let (mut cx, mut cy) = (x, y); let (mut cx, mut cy) = (x, y);
loop { loop {
// This cell must hold a solid pushable in `dir` to advance the chain. // This cell must hold a solid pushable in `dir` to advance the chain.
if !self.is_pushable(cx, cy, dir) { if !self.is_pushable(cx, cy, dir) {
return false; return false;
} }
let next = (cx as i32 + dx, cy as i32 + dy); let next = (cx as i64 + dx, cy as i64 + dy);
if !self.in_bounds(next) { if !self.in_bounds(next) {
return false; // chain runs off the board return false; // chain runs off the board
} }
@@ -329,8 +301,8 @@ impl Board {
if !self.is_pushable(x, y, dir) { if !self.is_pushable(x, y, dir) {
return false; return false;
} }
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
let next = (x as i32 + dx, y as i32 + dy); let next = (x as i64 + dx, y as i64 + dy);
if !self.in_bounds(next) { if !self.in_bounds(next) {
return false; // nothing to shift into off the board return false; // nothing to shift into off the board
} }
@@ -354,7 +326,7 @@ impl Board {
if !self.can_push(x, y, dir) { if !self.can_push(x, y, dir) {
return; return;
} }
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
// can_push guaranteed the chain ends at an in-bounds passable cell, so // can_push guaranteed the chain ends at an in-bounds passable cell, so
// re-walk it (no bounds checks needed) and shift the far end first, which // re-walk it (no bounds checks needed) and shift the far end first, which
// keeps each destination cell vacated before its occupant arrives. // keeps each destination cell vacated before its occupant arrives.
@@ -362,8 +334,8 @@ impl Board {
let (mut cx, mut cy) = (x, y); let (mut cx, mut cy) = (x, y);
while !self.is_passable(cx, cy) { while !self.is_passable(cx, cy) {
chain.push((cx, cy)); chain.push((cx, cy));
cx = (cx as i32 + dx) as usize; cx = (cx as i64 + dx) as usize;
cy = (cy as i32 + dy) as usize; cy = (cy as i64 + dy) as usize;
} }
for &(px, py) in chain.iter().rev() { for &(px, py) in chain.iter().rev() {
self.shift_solid(px, py, dx, dy); self.shift_solid(px, py, dx, dy);
@@ -376,8 +348,8 @@ impl Board {
/// terrain archetype (a crate) is moved within its own layer, leaving a /// terrain archetype (a crate) is moved within its own layer, leaving a
/// transparent cell behind so the layer beneath (e.g. floor) shows through. /// transparent cell behind so the layer beneath (e.g. floor) shows through.
/// The caller guarantees the destination is already clear. /// The caller guarantees the destination is already clear.
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) { fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) {
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize); let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize);
let Some(solid) = self.solid_at(x, y) else { let Some(solid) = self.solid_at(x, y) else {
return; // nothing to shift return; // nothing to shift
}; };
@@ -541,7 +513,7 @@ impl Board {
/// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script /// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script
/// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log. /// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log.
pub fn apply_shift(&mut self, cells: &[(i32, i32)]) -> Vec<LogLine> { pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> Vec<LogLine> {
// Validate all the cells are in bounds, error if not: // Validate all the cells are in bounds, error if not:
if cells.iter().any(|&c| !self.in_bounds(c)) { if cells.iter().any(|&c| !self.in_bounds(c)) {
return vec![LogLine::error("Called shift() with a cell out of bounds")] return vec![LogLine::error("Called shift() with a cell out of bounds")]
@@ -637,7 +609,7 @@ pub(crate) mod tests {
pub(crate) fn open_board( pub(crate) fn open_board(
w: usize, w: usize,
h: usize, h: usize,
player: (i32, i32), player: (i64, i64),
objects: Vec<ObjectDef>, objects: Vec<ObjectDef>,
) -> Board { ) -> Board {
let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new(); let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
+32 -41
View File
@@ -1,8 +1,8 @@
use crate::action::Action; use crate::action::{Action, SendArg};
use crate::board::Board; use crate::board::Board;
use crate::log::LogLine; use crate::log::LogLine;
use crate::script::{ScriptHost, ScriptState}; use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, PlayerPos, ScriptArg}; use crate::utils::{Direction, ObjectId, PlayerPos};
use crate::world::World; use crate::world::World;
use std::cell::{Ref, RefMut}; use std::cell::{Ref, RefMut};
use std::time::Duration; use std::time::Duration;
@@ -13,6 +13,7 @@ pub const SAY_DURATION: f64 = 3.0;
// Re-export ScrollLine so kiln-tui can pattern-match scroll content without // Re-export ScrollLine so kiln-tui can pattern-match scroll content without
// accessing the private `action` module directly. // accessing the private `action` module directly.
pub use crate::action::ScrollLine; pub use crate::action::ScrollLine;
use crate::api::state::ScriptState;
use crate::player::Player; use crate::player::Player;
/// An active scroll overlay opened by a scripted object via `scroll()`. /// An active scroll overlay opened by a scripted object via `scroll()`.
@@ -162,7 +163,7 @@ impl GameState {
/// the game is about to start — never during map deserialization, since a script /// the game is about to start — never during map deserialization, since a script
/// may inspect the board. /// may inspect the board.
pub fn run_init(&mut self) { pub fn run_init(&mut self) {
self.scripts.run_init(ScriptState(self.player)); self.scripts.run_init(ScriptState::from_game_state(&self));
self.resolve(); self.resolve();
} }
@@ -176,7 +177,7 @@ impl GameState {
// this runs exactly once per player interaction with a scroll. // this runs exactly once per player interaction with a scroll.
self.handle_scroll(); self.handle_scroll();
let secs = dt.as_secs_f64(); let secs = dt.as_secs_f64();
self.scripts.run_tick(ScriptState(self.player), secs); self.scripts.run_tick(ScriptState::from_game_state(&self), secs);
// Expire speech bubbles before resolving new actions so a fresh say() // Expire speech bubbles before resolving new actions so a fresh say()
// this frame isn't immediately culled. // this frame isn't immediately culled.
self.speech_bubbles.retain_mut(|b| { self.speech_bubbles.retain_mut(|b| {
@@ -212,10 +213,11 @@ impl GameState {
let mut gem_delta: i64 = 0; let mut gem_delta: i64 = 0;
let mut health_delta: i64 = 0; let mut health_delta: i64 = 0;
let mut key_changes: Vec<(String, bool)> = Vec::new(); let mut key_changes: Vec<(String, bool)> = Vec::new();
let mut sends: Vec<(ObjectId, String, Option<ScriptArg>)> = Vec::new(); let mut sends: Vec<(ObjectId, String, SendArg)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new(); let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
// Collected outside the board borrow so we can assign to self.active_scroll. // Collected outside the board borrow so we can assign to self.active_scroll.
let mut new_scroll: Option<Scroll> = None; let mut new_scroll: Option<Scroll> = None;
{ {
let mut board = self.board_mut(); let mut board = self.board_mut();
for ba in actions { for ba in actions {
@@ -347,11 +349,12 @@ impl GameState {
self.log.push(LogLine::error(format!("set_key: unknown color {color:?}"))); self.log.push(LogLine::error(format!("set_key: unknown color {color:?}")));
} }
} }
let state = ScriptState::from_game_state(self);
for (bumped, bumper) in bumps { for (bumped, bumper) in bumps {
self.scripts.run_bump(ScriptState(self.player), bumped, bumper); self.scripts.run_bump(state.clone(), bumped, bumper);
} }
for (target, fn_name, arg) in sends { for (target, fn_name, arg) in sends {
self.scripts.run_send(ScriptState(self.player), target, &fn_name, arg); self.scripts.run_send(state.clone(), target, &fn_name, arg);
} }
self.drain_errors(); self.drain_errors();
} }
@@ -367,7 +370,7 @@ impl GameState {
if let Some(scroll) = self.active_scroll.take() if let Some(scroll) = self.active_scroll.take()
&& let Some(choice) = scroll.choice && let Some(choice) = scroll.choice
{ {
self.scripts.run_send(ScriptState(self.player), scroll.source, &choice, None); self.scripts.run_send(ScriptState::from_game_state(&self), scroll.source, &choice, SendArg::None);
self.drain_errors(); self.drain_errors();
} }
} }
@@ -407,8 +410,8 @@ impl GameState {
// 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().player = PlayerPos {
x: ax as i32, x: ax as i64,
y: ay as i32, y: ay as i64,
}; };
// 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(
@@ -432,9 +435,9 @@ impl GameState {
let grabbed; let grabbed;
let portal_target; let portal_target;
{ {
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
let mut board = self.board_mut(); let mut board = self.board_mut();
let target = (board.player.x + dx, board.player.y + dy); let target = (board.player.x as i64 + dx, board.player.y as i64 + dy);
if !board.in_bounds(target) { if !board.in_bounds(target) {
return; return;
} }
@@ -454,8 +457,8 @@ impl GameState {
if grabbed.is_none() { if grabbed.is_none() {
board.push(nx, ny, dir); board.push(nx, ny, dir);
} }
board.player.x = nx as i32; board.player.x = nx as i64;
board.player.y = ny as i32; board.player.y = ny as i64;
// Check for a portal at the new position; clone strings to release the borrow. // Check for a portal at the new position; clone strings to release the borrow.
portal_target = board portal_target = board
.portals .portals
@@ -473,12 +476,13 @@ impl GameState {
} }
// Fire the grab hook and resolve it immediately so the grabbed thing's // Fire the grab hook and resolve it immediately so the grabbed thing's
// die()/add_gems() apply now — no player+object overlap survives this call. // die()/add_gems() apply now — no player+object overlap survives this call.
let state = ScriptState::from_game_state(&self);
if let Some(id) = grabbed { if let Some(id) = grabbed {
self.scripts.run_grab(ScriptState(self.player), id); self.scripts.run_grab(state.clone(), id);
self.resolve(); self.resolve();
} }
if let Some(idx) = bumped { if let Some(idx) = bumped {
self.scripts.run_bump(ScriptState(self.player), idx, -1); self.scripts.run_bump(state, idx, -1);
self.drain_errors(); self.drain_errors();
} }
} }
@@ -493,9 +497,9 @@ impl GameState {
/// pushed aside or blocks the move — since something tried to move into it. Walls and /// pushed aside or blocks the move — since something tried to move into it. Walls and
/// crates carry no script, so only solid objects yield a bump. /// crates carry no script, so only solid objects yield a bump.
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> { fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> {
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?; let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?;
let target = (ox as i32 + dx, oy as i32 + dy); let target = (ox as i64 + dx, oy as i64 + dy);
if !board.in_bounds(target) { if !board.in_bounds(target) {
return None; return None;
} }
@@ -572,7 +576,7 @@ mod tests {
let mut obj = ObjectDef::new(0, 0); let mut obj = ObjectDef::new(0, 0);
obj.solid = false; obj.solid = false;
obj.script_name = Some("s".to_string()); obj.script_name = Some("s".to_string());
let mut board = open_board(board_w, 1, (board_w as i32 - 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())]);
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
@@ -580,26 +584,13 @@ mod tests {
game game
} }
#[test]
fn script_push_shoves_a_crate() {
// The object pushes the crate at (2,0) one step east on its first tick.
let mut game = game_with_object_script(
5,
"fn tick(dt) { if Queue.length() == 0 { push(2, 0, East); } }",
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
}
#[test] #[test]
fn script_shift_rotates_crates() { fn script_shift_rotates_crates() {
// Rotate the crate at (2,0) with the empty cell at (3,0) in a two-cell cycle: // Rotate the crate at (2,0) with the empty cell at (3,0) in a two-cell cycle:
// crate moves to (3,0), empty moves back to (2,0). // crate moves to (3,0), empty moves back to (2,0).
let mut game = game_with_object_script( let mut game = game_with_object_script(
5, 5,
"fn tick(dt) { if Queue.length() == 0 { shift([[2, 0], [3, 0]]); } }", "fn tick(m,s,dt) { if m.queue.length == 0 { shift([[2, 0], [3, 0]]); } }",
); );
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
let b = game.board(); let b = game.board();
@@ -617,10 +608,10 @@ mod tests {
obj.script_name = Some("s".to_string()); obj.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() { \ let src = "fn init(m,s) { \
log(if passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \ log(if s.board.passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \ log(if s.board.passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }"; log(if s.board.passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]); let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
@@ -735,7 +726,7 @@ mod tests {
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(),
"fn init() { set_key(\"blue\", true); set_key(\"red\", true); }".to_string(), "fn init(m, s) { set_key(\"blue\", true); set_key(\"red\", true); }".to_string(),
)]); )]);
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
@@ -751,7 +742,7 @@ mod tests {
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(),
"fn init() { set_key(\"blue\", true); set_key(\"blue\", false); }".to_string(), "fn init(m, s) { set_key(\"blue\", true); set_key(\"blue\", false); }".to_string(),
)]); )]);
let mut game2 = GameState::with_scripts(board2, scripts2); let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init(); game2.run_init();
@@ -767,7 +758,7 @@ mod tests {
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(),
r#"fn init() { set_key("chartreuse", true); }"#.to_string(), r#"fn init(m,s) { set_key("chartreuse", true); }"#.to_string(),
)]); )]);
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
+16
View File
@@ -1,5 +1,8 @@
use color::Rgba8; use color::Rgba8;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use rhai::Engine;
use crate::script::Registerable;
use crate::utils::ErrorSink;
/// The visual representation of a single board cell. /// The visual representation of a single board cell.
/// ///
@@ -32,6 +35,19 @@ impl Hash for Glyph {
} }
} }
impl Registerable for Glyph {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<Glyph>("Glyph");
engine.register_get("tile", |g: &mut Glyph| g.tile);
engine.register_get("fg", |g: &mut Glyph| {
format!("#{:02x}{:02x}{:02x}", g.fg.r, g.fg.g, g.fg.b)
});
engine.register_get("bg", |g: &mut Glyph| {
format!("#{:02x}{:02x}{:02x}", g.bg.r, g.bg.g, g.bg.b)
});
}
}
impl Glyph { impl Glyph {
/// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue. /// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue.
/// ///
+17
View File
@@ -1,5 +1,8 @@
use color::Rgba8; use color::Rgba8;
use rhai::Engine;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::script::Registerable;
use crate::utils::ErrorSink;
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug)]
pub enum KeyType { pub enum KeyType {
@@ -82,4 +85,18 @@ impl Keyring {
(self.white, KeyType::White.glyph().fg), (self.white, KeyType::White.glyph().fg),
] ]
} }
}
impl Registerable for Keyring {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<Keyring>("Keyring")
.register_get("red", |keyring: &mut Keyring| keyring.red)
.register_get("orange", |keyring: &mut Keyring| keyring.orange)
.register_get("yellow", |keyring: &mut Keyring| keyring.yellow)
.register_get("green", |keyring: &mut Keyring| keyring.green)
.register_get("blue", |keyring: &mut Keyring| keyring.blue)
.register_get("cyan", |keyring: &mut Keyring| keyring.cyan)
.register_get("purple", |keyring: &mut Keyring| keyring.purple)
.register_get("white", |keyring: &mut Keyring| keyring.white);
}
} }
+1 -1
View File
@@ -24,10 +24,10 @@ 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 archetype::{Archetype, Builtin};
pub use board::Board; pub use board::Board;
pub use utils::Direction; pub use utils::Direction;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
mod api;
+4 -3
View File
@@ -17,7 +17,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use std::convert::TryFrom; use std::convert::TryFrom;
use std::path::Path; use std::path::Path;
use tinyrand::{Seeded, StdRand}; use tinyrand::{Seeded, StdRand};
use crate::api::queue::ObjQueue;
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::board::Board; use crate::board::Board;
use crate::builtin_scripts::archetype_from_builtin_tag; use crate::builtin_scripts::archetype_from_builtin_tag;
@@ -245,6 +245,7 @@ impl TryFrom<MapFile> for Board {
// (see `expand_builtin_archetypes` below), not via templates. // (see `expand_builtin_archetypes` below), not via templates.
builtin_script: None, builtin_script: None,
tags: t.tags.into_iter().collect(), tags: t.tags.into_iter().collect(),
queue: ObjQueue::new(),
name, name,
}, },
); );
@@ -278,8 +279,8 @@ impl TryFrom<MapFile> for Board {
height: h, height: h,
layers, layers,
player: PlayerPos { player: PlayerPos {
x: px as i32, x: px as i64,
y: py as i32, y: py as i64,
}, },
objects, objects,
next_object_id, next_object_id,
+4
View File
@@ -2,6 +2,7 @@ use crate::glyph::Glyph;
use crate::utils::ObjectId; use crate::utils::ObjectId;
use color::Rgba8; use color::Rgba8;
use std::collections::HashSet; use std::collections::HashSet;
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.
/// ///
@@ -75,6 +76,8 @@ pub struct ObjectDef {
/// Names are validated for uniqueness at map-load time; a duplicate name is /// Names are validated for uniqueness at map-load time; a duplicate name is
/// cleared to `None` (the object survives but becomes anonymous). /// cleared to `None` (the object survives but becomes anonymous).
pub name: Option<String>, pub name: Option<String>,
/// The output queue of actions for this object
pub queue: ObjQueue,
} }
impl ObjectDef { impl ObjectDef {
@@ -107,6 +110,7 @@ impl ObjectDef {
script_name: None, script_name: None,
builtin_script: None, builtin_script: None,
tags: HashSet::new(), tags: HashSet::new(),
queue: ObjQueue::new(),
name: None, name: None,
} }
} }
+234 -786
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
// A gem is a grabbable collectible: walking onto it (or pushing it into the // A gem is a grabbable collectible: walking onto it (or pushing it into the
// player) fires this `grab()` hook instead of blocking. We bump the player's gem // player) fires this `grab()` hook instead of blocking. We bump the player's gem
// count and remove ourselves from the board. // count and remove ourselves from the board.
fn grab() { fn grab(me, state) {
add_gems(1); add_gems(1);
die(); die();
} }
+1 -1
View File
@@ -2,7 +2,7 @@
// //
// A heart is a grabbable collectible: walking onto it fires `grab()` instead // A heart is a grabbable collectible: walking onto it fires `grab()` instead
// of blocking. It restores 1 health and removes itself from the board. // of blocking. It restores 1 health and removes itself from the board.
fn grab() { fn grab(me, state) {
alter_health(1); alter_health(1);
die(); die();
} }
+2 -2
View File
@@ -3,7 +3,7 @@
// A gem is a grabbable collectible: walking onto it (or pushing it into the // A gem is a grabbable collectible: walking onto it (or pushing it into the
// player) fires this `grab()` hook instead of blocking. We bump the player's gem // player) fires this `grab()` hook instead of blocking. We bump the player's gem
// count and remove ourselves from the board. // count and remove ourselves from the board.
fn grab() { fn grab(me, state) {
let colors = [ let colors = [
"red", "red",
"orange", "orange",
@@ -16,7 +16,7 @@ fn grab() {
]; ];
for c in colors { for c in colors {
if Me.has_tag(`BUILTIN_key_${c}`) { if me.has_tag(`BUILTIN_key_${c}`) {
set_key(c, true); set_key(c, true);
} }
} }
+5 -5
View File
@@ -6,17 +6,17 @@
// pusher shares one compiled copy and learns its direction from the // pusher shares one compiled copy and learns its direction from the
// `BUILTIN_pusher_<dir>` tag the map loader attached to it. // `BUILTIN_pusher_<dir>` tag the map loader attached to it.
fn tick(dt) { fn tick(me, state, dt) {
// Only queue a step when idle. `move` shoves the chain ahead via step_object // Only queue a step when idle. `move` shoves the chain ahead via step_object
// and is a no-op when blocked; the delay pads each step out to ~0.5s (move // and is a no-op when blocked; the delay pads each step out to ~0.5s (move
// itself costs 0.25s), matching the old global pusher heartbeat. // itself costs 0.25s), matching the old global pusher heartbeat.
// //
// The direction is read here, at the top level of the hook, because `Me` is a // The direction is read here, at the top level of the hook, because `Me` is a
// per-object scope constant and is not visible inside other script functions. // per-object scope constant and is not visible inside other script functions.
if Queue.length() == 0 { if !me.waiting {
let dir = if Me.has_tag("BUILTIN_pusher_north") { North } let dir = if me.has_tag("BUILTIN_pusher_north") { North }
else if Me.has_tag("BUILTIN_pusher_south") { South } else if me.has_tag("BUILTIN_pusher_south") { South }
else if Me.has_tag("BUILTIN_pusher_east") { East } else if me.has_tag("BUILTIN_pusher_east") { East }
else { West }; else { West };
move(dir); move(dir);
delay(0.25); delay(0.25);
+13 -13
View File
@@ -8,24 +8,24 @@
// can't decide that with `can_shift` alone — `can_shift(j) == false` is ambiguous // can't decide that with `can_shift` alone — `can_shift(j) == false` is ambiguous
// between "j is empty" and "j is a blocked solid" — so we seed with `can_shift` // between "j is empty" and "j is a blocked solid" — so we seed with `can_shift`
// and then cascade-demote using `passable` to spot the genuine holes. // and then cascade-demote using `passable` to spot the genuine holes.
fn tick(dt) { fn tick(me, state, dt) {
// Only start a new rotation when the previous one (and its delay) has drained, // Only start a new rotation when the previous one (and its delay) has drained,
// exactly like the built-in pusher's pacing. // exactly like the built-in pusher's pacing.
if Queue.length() != 0 { return; } if me.waiting { return; }
// Direction from the builtin tag; clockwise unless explicitly counter-clockwise. // Direction from the builtin tag; clockwise unless explicitly counter-clockwise.
let cw = !Me.has_tag("BUILTIN_spinner_ccw"); let cw = !me.has_tag("BUILTIN_spinner_ccw");
// The 8 neighbour offsets in clockwise order, starting at North. // The 8 neighbour offsets in clockwise order, starting at North.
let cw_ring = [ let cw_ring = [
[Me.x, Me.y-1], // N [me.x, me.y-1], // N
[Me.x+1, Me.y-1], // NE [me.x+1, me.y-1], // NE
[Me.x+1, Me.y], // E [me.x+1, me.y], // E
[Me.x+1, Me.y+1], // SE [me.x+1, me.y+1], // SE
[Me.x, Me.y+1], // S [me.x, me.y+1], // S
[Me.x-1, Me.y+1], // SW [me.x-1, me.y+1], // SW
[Me.x-1, Me.y], // W [me.x-1, me.y], // W
[Me.x-1, Me.y-1], // NW [me.x-1, me.y-1], // NW
]; ];
// For CCW, it's the same list in reverse // For CCW, it's the same list in reverse
@@ -45,10 +45,10 @@ fn tick(dt) {
// line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in // line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in
// the board Registry, keyed per spinner, since script scope resets each tick. // the board Registry, keyed per spinner, since script scope resets each tick.
let frames = if cw { [47, 0xc4, 92, 0xb3] } else { [92, 0xc4, 47, 0xb3] }; let frames = if cw { [47, 0xc4, 92, 0xb3] } else { [92, 0xc4, 47, 0xb3] };
let fkey = `spin_${Me.id}`; let fkey = `spin_${me.id}`;
let f = Registry.get_or(fkey, 0); let f = Registry.get_or(fkey, 0);
set_tile(frames[f % 4]); set_tile(frames[f % 4]);
Registry.set(fkey, (f + 1) % 4); Registry.set(fkey, (f + 1) % 4);
delay(0.5); me.delay(0.5);
} }
+11 -40
View File
@@ -8,7 +8,7 @@ use std::time::Duration;
fn move_command_relocates_the_source_object() { fn move_command_relocates_the_source_object() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]); let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")]));
game.run_init(); game.run_init();
let b = game.board(); let b = game.board();
// East increments x by one; the object started at (2, 1). // East increments x by one; the object started at (2, 1).
@@ -20,7 +20,7 @@ fn move_into_a_wall_or_edge_is_a_noop() {
// Object at the west edge moving west: out of bounds, ignored. // Object at the west edge moving west: out of bounds, ignored.
let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]); let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(West); }")]));
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].x, 0); assert_eq!(game.board().objects[&1].x, 0);
} }
@@ -29,7 +29,7 @@ fn move_into_a_wall_or_edge_is_a_noop() {
fn set_tile_command_changes_the_source_glyph() { fn set_tile_command_changes_the_source_glyph() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]); let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("s", "fn init() { set_tile(7); }")])); GameState::with_scripts(board, scripts_from(&[("s", "fn init(m,s) { set_tile(7); }")]));
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7); assert_eq!(game.board().objects[&1].glyph.tile, 7);
} }
@@ -40,7 +40,7 @@ fn object_pushes_crate_on_init() {
let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]); let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
crate_at(&mut board, 2, 0); crate_at(&mut board, 2, 0);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")]));
game.run_init(); game.run_init();
let b = game.board(); let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
@@ -53,7 +53,7 @@ fn object_push_into_player() {
// A scripted object moving into the player pushes the player when there's room. // A scripted object moving into the player pushes the player when there's room.
let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]); let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")]));
game.run_init(); game.run_init();
{ {
let b = game.board(); let b = game.board();
@@ -65,7 +65,7 @@ fn object_push_into_player() {
let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]); let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
wall_at(&mut board, 3, 0); wall_at(&mut board, 3, 0);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")]));
game.run_init(); game.run_init();
let b = game.board(); let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0));
@@ -79,7 +79,7 @@ fn move_cost_rate_limits_repeated_moves() {
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]); let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("m", "fn init() { move(East); move(East); }")]), scripts_from(&[("m", "fn init(m,s) { move(East); move(East); }")]),
); );
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2) assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2)
@@ -102,7 +102,7 @@ fn inline_delay_paces_subsequent_moves() {
wall_at(&mut board, 2, 1); wall_at(&mut board, 2, 1);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("m", "fn init() { move(East); move(South); }")]), scripts_from(&[("m", "fn init(m,s) { move(East); move(South); }")]),
); );
game.run_init(); game.run_init();
// First (eastward) move is blocked by the wall: object hasn't moved. // First (eastward) move is blocked by the wall: object hasn't moved.
@@ -136,7 +136,7 @@ fn queue_length_reports_pending_actions() {
board, board,
scripts_from(&[( scripts_from(&[(
"q", "q",
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }", "fn init(m,s) { set_tile(5); set_tile(6); log(`len=${m.queue.length}`); }",
)]), )]),
); );
game.run_init(); game.run_init();
@@ -166,7 +166,7 @@ fn blocked_reports_solid_and_clear() {
board, board,
scripts_from(&[( scripts_from(&[(
"b", "b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", "fn init(m, s) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]), )]),
); );
game.run_init(); game.run_init();
@@ -178,38 +178,9 @@ fn blocked_reports_solid_and_clear() {
board, board,
scripts_from(&[( scripts_from(&[(
"b", "b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", "fn init(m, s) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]), )]),
); );
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7); assert_eq!(game.board().objects[&1].glyph.tile, 7);
} }
#[test]
fn blocked_sees_earlier_objects_pending_move() {
// obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked()
// toward that same cell and must see the pending move.
let board = open_board(
5,
3,
(0, 0),
vec![
scripted_object(1, 1, "mover"),
scripted_object(3, 1, "checker"),
],
);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("mover", "fn tick(dt) { move(East); }"),
(
"checker",
"fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }",
),
]),
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1));
assert_eq!(b.objects[&2].glyph.tile, 1); // checker saw the pending move
}
+2 -2
View File
@@ -18,11 +18,11 @@ fn collision_priority_resolves_in_array_order_and_bumps() {
scripts_from(&[ scripts_from(&[
( (
"e", "e",
"fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }", "fn init(m,s) { move(East); } fn bump(m,s,id) { log(`o0 by ${id}`); }",
), ),
( (
"w", "w",
"fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }", "fn init(m,s) { move(West); } fn bump(m,s,id) { log(`o1 by ${id}`); }",
), ),
]), ]),
); );
+1 -1
View File
@@ -10,7 +10,7 @@ use std::collections::{BTreeMap, HashMap};
use std::rc::Rc; use std::rc::Rc;
/// 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: i32, py: i32, portals: Vec<PortalDef>) -> Board { fn make_board(px: i64, py: i64, portals: Vec<PortalDef>) -> Board {
Board { Board {
name: "test".into(), name: "test".into(),
width: 3, width: 3,
+22 -22
View File
@@ -8,7 +8,7 @@ use std::time::Duration;
fn init_runs_only_on_run_init_not_at_construction() { fn init_runs_only_on_run_init_not_at_construction() {
let (board, scripts) = board_with_object( let (board, scripts) = board_with_object(
Some("greet"), Some("greet"),
&[("greet", r#"fn init() { log("hello"); }"#)], &[("greet", r#"fn init(m,s) { log("hello"); }"#)],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
// init must not fire during construction / deserialization. // init must not fire during construction / deserialization.
@@ -22,7 +22,7 @@ fn init_runs_only_on_run_init_not_at_construction() {
fn tick_calls_script_tick_with_elapsed_seconds() { fn tick_calls_script_tick_with_elapsed_seconds() {
let (board, scripts) = board_with_object( let (board, scripts) = board_with_object(
Some("t"), Some("t"),
&[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)], &[("t", r#"fn tick(m,s,dt) { log(dt.to_string()); }"#)],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
@@ -69,7 +69,7 @@ fn script_reads_board_through_view() {
let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]); let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")]), scripts_from(&[("r", "fn init(m,s) { log(s.player.x.to_string()); }")]),
); );
game.run_init(); game.run_init();
assert_eq!(log_texts(&game), vec!["3"]); assert_eq!(log_texts(&game), vec!["3"]);
@@ -88,8 +88,8 @@ fn commands_are_routed_to_their_own_source_object() {
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[ scripts_from(&[
("e", "fn init() { move(East); }"), ("e", "fn init(m,s) { move(East); }"),
("w", "fn init() { move(West); }"), ("w", "fn init(m,s) { move(West); }"),
]), ]),
); );
game.run_init(); game.run_init();
@@ -127,7 +127,7 @@ fn set_tag_adds_and_removes_via_my_id() {
// present on the object afterward. // present on the object afterward.
let (board, scripts) = board_with_object( let (board, scripts) = board_with_object(
Some("t"), Some("t"),
&[("t", r#"fn init() { set_tag(Me.id, "active", true); }"#)], &[("t", r#"fn init(m,s) { set_tag(m.id, "active", true); }"#)],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
@@ -136,7 +136,7 @@ fn set_tag_adds_and_removes_via_my_id() {
// 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(
Some("t2"), Some("t2"),
&[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)], &[("t2", r#"fn init(m,s) { set_tag(m.id, "active", false); }"#)],
); );
// Seed the tag before construction. // Seed the tag before construction.
board2 board2
@@ -158,8 +158,8 @@ fn has_tag_reads_own_tags() {
Some("t"), Some("t"),
&[( &[(
"t", "t",
r#"fn init() { set_tag(Me.id, "active", true); } r#"fn init(m,s) { set_tag(m.id, "active", true); }
fn tick(dt) { log(Me.has_tag("active").to_string()); }"#, fn tick(m,s,dt) { log(m.has_tag("active").to_string()); }"#,
)], )],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
@@ -182,8 +182,8 @@ fn objects_with_tag_returns_matching_ids() {
scripts_from(&[ scripts_from(&[
( (
"q", "q",
r#"fn init() { r#"fn init(m,s) {
let infos = Board.tagged("enemy"); let infos = s.board.tagged("enemy");
log(infos.len().to_string()); log(infos.len().to_string());
log(infos[0].id.to_string()); log(infos[0].id.to_string());
}"#, }"#,
@@ -201,18 +201,18 @@ fn objects_with_tag_returns_matching_ids() {
fn my_name_returns_name_or_empty_string() { 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() { log(Me.name); }"#)]); board_with_object(Some("n"), &[("n", r#"fn init(m,s) { log(m.name); }"#)]);
board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string()); board.objects.get_mut(&1).unwrap().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"]);
// An unnamed object should get an empty string. // An unnamed object should get ().
let (board2, scripts2) = let (board2, scripts2) =
board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]); board_with_object(Some("n"), &[("n", r#"fn init(m,s) { if m.name == () { log("null"); }}"#)]);
let mut game2 = GameState::with_scripts(board2, scripts2); let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init(); game2.run_init();
assert_eq!(log_texts(&game2), vec![""]); assert_eq!(log_texts(&game2), vec!["null"]);
} }
#[test] #[test]
@@ -228,9 +228,9 @@ fn object_id_for_name_finds_by_name() {
scripts_from(&[ scripts_from(&[
( (
"q", "q",
r#"fn init() { r#"fn init(m,s) {
log(Board.named("target").id.to_string()); log(s.board.named("target").id.to_string());
let miss = Board.named("missing"); let miss = s.board.named("missing");
log(if miss == () { "not_found" } else { miss.id.to_string() }); log(if miss == () { "not_found" } else { miss.id.to_string() });
}"#, }"#,
), ),
@@ -251,7 +251,7 @@ fn player_bump_fires_with_negative_one() {
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")]), scripts_from(&[("b", "fn bump(m,s,id) { log(`bumped by ${id}`); }")]),
); );
game.run_init(); game.run_init();
game.try_move(Direction::East); game.try_move(Direction::East);
@@ -271,7 +271,7 @@ fn scroll_opens_on_player_bump() {
board, board,
scripts_from(&[( scripts_from(&[(
"s", "s",
r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#, r#"fn bump(m,s,id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#,
)]), )]),
); );
game.run_init(); game.run_init();
@@ -295,7 +295,7 @@ fn handle_scroll_without_choice_clears_it() {
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)]), scripts_from(&[("s", r#"fn bump(m,s,id) { scroll(["Hello"]); }"#)]),
); );
game.run_init(); game.run_init();
game.try_move(Direction::East); game.try_move(Direction::East);
@@ -317,7 +317,7 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
scripts_from(&[( scripts_from(&[(
"s", "s",
r#" r#"
fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); } fn bump(m,s,id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn eat() { log("eaten"); } fn eat() { log("eaten"); }
"#, "#,
)]), )]),
+80 -13
View File
@@ -1,8 +1,12 @@
use std::cell::RefCell;
use std::fmt::Display;
use std::rc::Rc;
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use color::Rgba8; use color::Rgba8;
use rhai::Dynamic; use rhai::Dynamic;
use crate::Board; use crate::Board;
use crate::log::LogLine;
/// Which directions a solid may be pushed in. /// Which directions a solid may be pushed in.
/// ///
@@ -215,8 +219,8 @@ impl Solid {
pub fn place(self, board: &mut Board, x: usize, y: usize) { pub fn place(self, board: &mut Board, x: usize, y: usize) {
match self.kind { match self.kind {
SolidKind::Player => { SolidKind::Player => {
board.player.x = x as i32; board.player.x = x as i64;
board.player.y = y as i32; board.player.y = y as i64;
} }
SolidKind::Object(id) => { SolidKind::Object(id) => {
if let Some(obj) = board.objects.get_mut(&id) { if let Some(obj) = board.objects.get_mut(&id) {
@@ -293,9 +297,9 @@ impl PortalDef {
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub struct PlayerPos { pub struct PlayerPos {
/// Column position (0-indexed, increasing rightward). /// Column position (0-indexed, increasing rightward).
pub x: i32, pub x: i64,
/// Row position (0-indexed, increasing downward). /// Row position (0-indexed, increasing downward).
pub y: i32, pub y: i64,
} }
#[cfg(test)] #[cfg(test)]
@@ -325,14 +329,6 @@ mod tests {
} }
} }
/// An optional argument passed to a script function via [`crate::action::Action::Send`].
pub(crate) enum ScriptArg {
/// A string argument.
Str(String),
/// A numeric argument (stored as `f64` to cover both integer and float callers).
Num(f64),
}
/// 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 {
@@ -342,6 +338,37 @@ pub enum Direction {
West, West,
} }
impl Direction {
pub fn char(self) -> char {
match self {
Direction::North => 'n',
Direction::South => 's',
Direction::East => 'e',
Direction::West => 'w',
}
}
pub fn all() -> [Direction; 4] {
[Direction::North, Direction::South, Direction::East, Direction::West]
}
pub fn dx(self) -> i64 {
match self {
Direction::North | Direction::South => 0,
Direction::East => 1,
Direction::West => -1,
}
}
pub fn dy(self) -> i64 {
match self {
Direction::East | Direction::West => 0,
Direction::South => 1,
Direction::North => -1,
}
}
}
/// A value that can be stored in a board's script registry across board transitions. /// A value that can be stored in a board's script registry across board transitions.
/// ///
/// Restricted to primitive types that convert cleanly to and from `rhai::Dynamic` /// Restricted to primitive types that convert cleanly to and from `rhai::Dynamic`
@@ -389,7 +416,7 @@ impl Into<Dynamic> for RegistryValue {
} }
} }
impl From<Direction> for (i32, i32) { impl From<Direction> for (i64, i64) {
/// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down). /// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down).
fn from(d: Direction) -> Self { fn from(d: Direction) -> Self {
match d { match d {
@@ -400,3 +427,43 @@ impl From<Direction> for (i32, i32) {
} }
} }
} }
#[derive(Copy,Clone,Debug, PartialEq)]
pub enum Hook {
Init, Tick, Bump, Grab
}
impl Hook {
pub fn to_str(&self) -> &'static str {
match self {
Hook::Init => "init",
Hook::Bump => "bump",
Hook::Grab => "grab",
Hook::Tick => "tick"
}
}
}
impl Display for Hook {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_str())
}
}
/// Channel for engine/compile/runtime errors.
#[derive(Clone)]
pub struct ErrorSink(Rc<RefCell<Vec<LogLine>>>);
impl ErrorSink {
pub fn new() -> Self {
Self(Rc::new(RefCell::new(Vec::new())))
}
pub fn error(&self, msg: String) {
self.0.borrow_mut().push(LogLine::error(msg));
}
pub fn take(&mut self) -> Vec<LogLine> {
std::mem::take(&mut self.0.borrow_mut())
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ 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, board.player.y); let focus = (board.player.x as i32, board.player.y as i32);
Self { board, focus } Self { board, focus }
} }
+2 -2
View File
@@ -409,8 +409,8 @@ 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); let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x as i32);
let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y); let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y 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;
+30 -31
View File
@@ -6,66 +6,65 @@ start = "start"
# Objects reference a script by name via `script_name`. # Objects reference a script by name via `script_name`.
[scripts] [scripts]
greeter = """ greeter = """
fn init() { fn init(me, state) {
log(`hello from object player at ${Board.player_x}, ${Board.player_y}`); log(`hello from object player at ${state.player.x}, ${state.player.y}`);
set_tile(2); // change my glyph to proves the write path set_tile(2); // change my glyph to proves the write path
say("Hello there,\\ntraveller!"); say("Hello there,\\ntraveller!");
log(`Player health: ${Player.health}`); log(`Player health: ${state.player.health}`);
} }
fn tick(dt) { fn tick(me, state, dt) {
//log(`x: ${Me.x} ${Board.player_x} y: ${Me.y} ${Board.player_y} q: ${Queue.length()}`); if state.player.x == me.x && state.player.y == me.y && !me.waiting {
if Board.player_x == Me.x && Board.player_y == Me.y && Queue.length() == 0 {
say("Hey! Get offa me!"); say("Hey! Get offa me!");
delay(5.0); me.delay(5.0);
} }
} }
""" """
mover = """ mover = """
fn init() { fn init(me, state) {
if Registry.get("dancer_x") != () { if Registry.get("dancer_x") != () {
let new_x = Registry.get("dancer_x"); let new_x = Registry.get("dancer_x");
let new_y = Registry.get("dancer_y"); let new_y = Registry.get("dancer_y");
if Me.x != new_x || Me.y != new_y { if me.x != new_x || me.y != new_y {
teleport(new_x, new_y); teleport(new_x, new_y);
} }
} else { } else {
Registry.set("dancer_x", Me.x); Registry.set("dancer_x", me.x);
Registry.set("dancer_y", Me.y); Registry.set("dancer_y", me.y);
log(`Set reg to ${Me.x} ${Me.y}`); log(`Set reg to ${me.x} ${me.y}`);
} }
} }
// move() costs 250 ms, so the object steps ~4 cells/sec no matter how often // move() costs 250 ms, so the object steps ~4 cells/sec no matter how often
// tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking // tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking
// into a wall (or another object's already-queued move this tick). // into a wall (or another object's already-queued move this tick).
fn tick(dt) { fn tick(me, state, dt) {
if Queue.length() == 0 { dance(); } if !me.waiting { dance(me); }
} }
fn dance() { fn dance(me) {
move(East); move(East);
move(South); move(South);
move(North); move(North);
move(East); move(East);
say("Hey!", 0.5); say("Hey!", 0.5);
delay(0.5); delay(me, 0.5);
move(West); move(West);
move(South); move(South);
move(North); move(North);
move(West); move(West);
say("Ho!", 0.5); say("Ho!", 0.5);
delay(0.5); me.delay(0.5);
} }
// Fires when something steps into this object; id is the bumper's object id, // Fires when something steps into this object; id is the bumper's object id,
// or -1 for the player. // or -1 for the player.
fn bump(id) { fn bump(me, state, id) {
log(`mover bumped by ${id}`); log(`mover bumped by ${id}`);
say("Ow!"); say("Ow!");
} }
""" """
muffin = """ muffin = """
fn bump(id) { fn bump(me, state, _id) {
scroll([ scroll([
"You find a small muffin on the ground, your favorite kind.", "You find a small muffin on the ground, your favorite kind.",
"It smells of cinnamon and warm mornings.", "It smells of cinnamon and warm mornings.",
@@ -73,7 +72,7 @@ fn bump(id) {
["ignore", "This is obviously a trap — ignore it."] ["ignore", "This is obviously a trap — ignore it."]
]); ]);
} }
fn eat() { fn eat(me, state) {
say("Yeah it was poisoned."); say("Yeah it was poisoned.");
alter_health(-2); alter_health(-2);
} }
@@ -84,7 +83,7 @@ fn ignore() {
""" """
noticeboard = """ noticeboard = """
fn bump(id) { fn bump(me, state, id) {
scroll([ scroll([
" TOWN NOTICE BOARD", " TOWN NOTICE BOARD",
" ", " ",
@@ -111,13 +110,13 @@ fn bump(id) {
"dusk. All residents are warmly encouraged to attend.", "dusk. All residents are warmly encouraged to attend.",
" ", " ",
" Posted by order of the Town Council.", " Posted by order of the Town Council.",
" Unauthorised notices will be removed.", " Unauthorized notices will be removed.",
]); ]);
} }
""" """
bookshelf = """ bookshelf = """
fn bump(id) { fn bump(me, state, id) {
scroll([ scroll([
" THE BOOKSHELF", " THE BOOKSHELF",
" ", " ",
@@ -136,13 +135,13 @@ fn bump(id) {
""" """
fireplace = """ fireplace = """
fn bump(id) { fn bump(me, state, id) {
say("The fire crackles warmly.\\nYou feel at ease."); say("The fire crackles warmly.\\nYou feel at ease.");
} }
""" """
chest = """ chest = """
fn bump(id) { fn bump(me, state, id) {
scroll([ scroll([
" THE CHEST", " THE CHEST",
" ", " ",
@@ -160,8 +159,8 @@ fn bump(id) {
""" """
yammerer = """ yammerer = """
fn tick(dt) { fn tick(me, state, dt) {
if Queue.length() == 0 { if !me.waiting {
say("blahblahblah"); say("blahblahblah");
delay(1); delay(1);
} }
@@ -169,11 +168,11 @@ fn tick(dt) {
""" """
shifter = """ shifter = """
fn tick(dt) { fn tick(me, state, dt) {
let x = Me.x; let x = me.x;
let y = Me.y; let y = me.y;
if Queue.length() == 0 { if !me.waiting {
shift([[x-1, y], [x, y-1], [x+1, y], [x, y+1]]); shift([[x-1, y], [x, y-1], [x+1, y], [x, y+1]]);
delay(0.25); delay(0.25);
} }
+44
View File
@@ -0,0 +1,44 @@
[world]
name = "Tiny"
start = "start"
# Named Rhai scripts shared across all boards in this world.
# Objects reference a script by name via `script_name`.
[scripts]
greeter = """
fn init(me, state) {
log(`hello from object player at ${state.player.x}, ${state.player.y}`);
set_tile(2); // change my glyph to proves the write path
//say("Hello there,\\ntraveller!");
log(`Player health: ${state.player.health}`);
}
fn tick(me, state, dt) {
if state.player.x == me.x && state.player.y == me.y && !me.waiting {
say("Hey! Get offa me!");
me.queue.delay(5.0);
}
}
"""
[boards.start.map]
name = "Starting Room"
width = 21
height = 6
# Layer 1: terrain, the player, objects and the portal. A space is always a
# transparent empty cell, so the floor below shows through.
[[boards.start.layers]]
content = """
#####################
# #
# G @ #
# oo #
# #
#####################
"""
[boards.start.layers.palette]
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" }
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
"@" = { kind = "player" }
"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000", solid = false, script_name = "greeter" }