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"
dependencies = [
"color",
"log",
"rhai",
"serde",
"tinyrand",
@@ -850,9 +851,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru"
@@ -1109,7 +1110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
+1
View File
@@ -9,3 +9,4 @@ rhai = "1"
serde = { version = "1", features = ["derive"] }
tinyrand = "0.5"
toml = { version = "0.8", features = ["preserve_order"] }
log = "0.4.33"
+78 -12
View File
@@ -4,9 +4,10 @@
//! functions), the rate-limiting delay constant, and [`action_to_map`] which
//! converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`.
use std::fmt::Debug;
use crate::log::LogLine;
use crate::map_file::color_to_hex;
use crate::utils::{Direction, ObjectId, ScriptArg};
use crate::utils::{Direction, ObjectId};
use color::Rgba8;
use rhai::Dynamic;
@@ -27,13 +28,47 @@ pub enum ScrollLine {
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`]
/// after it is promoted onto 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`
/// 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(Direction),
/// Set the source object's glyph tile index.
@@ -59,7 +94,7 @@ pub(crate) enum Action {
Send {
target: ObjectId,
fn_name: String,
arg: Option<ScriptArg>,
arg: SendArg,
},
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice
/// 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
/// the source is solid and the destination already has a solid occupant.
/// 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
/// 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
/// 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.
/// Zero time cost.
Shift(Vec<(i32, i32)>),
Shift(Vec<(i64, i64)>),
/// Add `n` to the player's gem count (negative subtracts; the count is
/// clamped at 0). Zero time cost. Applied to `GameState::player.gems`.
AddGems(i64),
@@ -93,6 +128,29 @@ pub(crate) enum Action {
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 ──────────────────────────────────────────────────────────
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("target".into(), Dynamic::from(*target as i64));
m.insert("name".into(), Dynamic::from(fn_name.clone()));
if let Some(a) = arg {
let dyn_arg = match a {
ScriptArg::Str(s) => Dynamic::from(s.clone()),
ScriptArg::Num(n) => Dynamic::from(*n),
match arg {
SendArg::Int(i) => m.insert("arg".into(), Dynamic::from(*i)),
SendArg::Float(f) => m.insert("arg".into(), Dynamic::from(*f)),
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.
Action::Scroll(_) => {
@@ -215,3 +272,12 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
}
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()
}
/// 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`.
///
/// 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`
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) {
*self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty)
}
@@ -121,7 +126,7 @@ impl Board {
/// Panics if out of bounds.
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
// 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();
}
@@ -169,7 +174,7 @@ impl Board {
///
/// Takes signed coords so callers can pass a raw `pos + delta` without first
/// 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;
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.
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.
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));
}
// 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()
}
/// 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`.
///
/// 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
/// ahead shovable?" half of a "can I move here?" query.
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);
loop {
// This cell must hold a solid pushable in `dir` to advance the chain.
if !self.is_pushable(cx, cy, dir) {
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) {
return false; // chain runs off the board
}
@@ -329,8 +301,8 @@ impl Board {
if !self.is_pushable(x, y, dir) {
return false;
}
let (dx, dy): (i32, i32) = dir.into();
let next = (x as i32 + dx, y as i32 + dy);
let (dx, dy): (i64, i64) = dir.into();
let next = (x as i64 + dx, y as i64 + dy);
if !self.in_bounds(next) {
return false; // nothing to shift into off the board
}
@@ -354,7 +326,7 @@ impl Board {
if !self.can_push(x, y, dir) {
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
// re-walk it (no bounds checks needed) and shift the far end first, which
// keeps each destination cell vacated before its occupant arrives.
@@ -362,8 +334,8 @@ impl Board {
let (mut cx, mut cy) = (x, y);
while !self.is_passable(cx, cy) {
chain.push((cx, cy));
cx = (cx as i32 + dx) as usize;
cy = (cy as i32 + dy) as usize;
cx = (cx as i64 + dx) as usize;
cy = (cy as i64 + dy) as usize;
}
for &(px, py) in chain.iter().rev() {
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
/// transparent cell behind so the layer beneath (e.g. floor) shows through.
/// The caller guarantees the destination is already clear.
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) {
let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize);
let Some(solid) = self.solid_at(x, y) else {
return; // nothing to shift
};
@@ -541,7 +513,7 @@ impl Board {
/// 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.
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:
if cells.iter().any(|&c| !self.in_bounds(c)) {
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(
w: usize,
h: usize,
player: (i32, i32),
player: (i64, i64),
objects: Vec<ObjectDef>,
) -> Board {
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::log::LogLine;
use crate::script::{ScriptHost, ScriptState};
use crate::utils::{Direction, ObjectId, PlayerPos, ScriptArg};
use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, PlayerPos};
use crate::world::World;
use std::cell::{Ref, RefMut};
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
// accessing the private `action` module directly.
pub use crate::action::ScrollLine;
use crate::api::state::ScriptState;
use crate::player::Player;
/// 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
/// may inspect the board.
pub fn run_init(&mut self) {
self.scripts.run_init(ScriptState(self.player));
self.scripts.run_init(ScriptState::from_game_state(&self));
self.resolve();
}
@@ -176,7 +177,7 @@ impl GameState {
// this runs exactly once per player interaction with a scroll.
self.handle_scroll();
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()
// this frame isn't immediately culled.
self.speech_bubbles.retain_mut(|b| {
@@ -212,10 +213,11 @@ impl GameState {
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, Option<ScriptArg>)> = Vec::new();
let mut sends: Vec<(ObjectId, String, SendArg)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
// Collected outside the board borrow so we can assign to self.active_scroll.
let mut new_scroll: Option<Scroll> = None;
{
let mut board = self.board_mut();
for ba in actions {
@@ -347,11 +349,12 @@ impl GameState {
self.log.push(LogLine::error(format!("set_key: unknown color {color:?}")));
}
}
let state = ScriptState::from_game_state(self);
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 {
self.scripts.run_send(ScriptState(self.player), target, &fn_name, arg);
self.scripts.run_send(state.clone(), target, &fn_name, arg);
}
self.drain_errors();
}
@@ -367,7 +370,7 @@ impl GameState {
if let Some(scroll) = self.active_scroll.take()
&& 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();
}
}
@@ -407,8 +410,8 @@ impl GameState {
// Switch to the new board and place the player at the arrival portal.
self.current_board_name = target_map.to_string();
self.board_mut().player = PlayerPos {
x: ax as i32,
y: ay as i32,
x: ax as i64,
y: ay as i64,
};
// Rebuild the script host for the new board's objects.
self.scripts = ScriptHost::new(
@@ -432,9 +435,9 @@ impl GameState {
let grabbed;
let portal_target;
{
let (dx, dy): (i32, i32) = dir.into();
let (dx, dy): (i64, i64) = dir.into();
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) {
return;
}
@@ -454,8 +457,8 @@ impl GameState {
if grabbed.is_none() {
board.push(nx, ny, dir);
}
board.player.x = nx as i32;
board.player.y = ny as i32;
board.player.x = nx as i64;
board.player.y = ny as i64;
// Check for a portal at the new position; clone strings to release the borrow.
portal_target = board
.portals
@@ -473,12 +476,13 @@ impl GameState {
}
// 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.
let state = ScriptState::from_game_state(&self);
if let Some(id) = grabbed {
self.scripts.run_grab(ScriptState(self.player), id);
self.scripts.run_grab(state.clone(), id);
self.resolve();
}
if let Some(idx) = bumped {
self.scripts.run_bump(ScriptState(self.player), idx, -1);
self.scripts.run_bump(state, idx, -1);
self.drain_errors();
}
}
@@ -493,9 +497,9 @@ impl GameState {
/// 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.
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 target = (ox as i32 + dx, oy as i32 + dy);
let target = (ox as i64 + dx, oy as i64 + dy);
if !board.in_bounds(target) {
return None;
}
@@ -572,7 +576,7 @@ mod tests {
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
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);
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
@@ -580,26 +584,13 @@ mod tests {
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]
fn script_shift_rotates_crates() {
// 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).
let mut game = game_with_object_script(
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));
let b = game.board();
@@ -617,10 +608,10 @@ mod tests {
obj.script_name = Some("s".to_string());
let mut board = open_board(4, 1, (3, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let src = "fn init() { \
log(if passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
let src = "fn init(m,s) { \
log(if s.board.passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if s.board.passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if s.board.passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
@@ -735,7 +726,7 @@ mod tests {
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
"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);
game.run_init();
@@ -751,7 +742,7 @@ mod tests {
let board2 = open_board(2, 1, (1, 0), vec![sobj2]);
let scripts2 = HashMap::from([(
"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);
game2.run_init();
@@ -767,7 +758,7 @@ mod tests {
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
"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);
game.run_init();
+16
View File
@@ -1,5 +1,8 @@
use color::Rgba8;
use std::hash::{Hash, Hasher};
use rhai::Engine;
use crate::script::Registerable;
use crate::utils::ErrorSink;
/// 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 {
/// 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 rhai::Engine;
use crate::glyph::Glyph;
use crate::script::Registerable;
use crate::utils::ErrorSink;
#[derive(Copy, Clone, Debug)]
pub enum KeyType {
@@ -83,3 +86,17 @@ impl Keyring {
]
}
}
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 player;
pub mod keys;
pub use archetype::{Archetype, Builtin};
pub use board::Board;
pub use utils::Direction;
#[cfg(test)]
mod tests;
mod api;
+4 -3
View File
@@ -17,7 +17,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use std::convert::TryFrom;
use std::path::Path;
use tinyrand::{Seeded, StdRand};
use crate::api::queue::ObjQueue;
use crate::archetype::Archetype;
use crate::board::Board;
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.
builtin_script: None,
tags: t.tags.into_iter().collect(),
queue: ObjQueue::new(),
name,
},
);
@@ -278,8 +279,8 @@ impl TryFrom<MapFile> for Board {
height: h,
layers,
player: PlayerPos {
x: px as i32,
y: py as i32,
x: px as i64,
y: py as i64,
},
objects,
next_object_id,
+4
View File
@@ -2,6 +2,7 @@ use crate::glyph::Glyph;
use crate::utils::ObjectId;
use color::Rgba8;
use std::collections::HashSet;
use crate::api::queue::ObjQueue;
/// 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
/// 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 {
@@ -107,6 +110,7 @@ impl ObjectDef {
script_name: None,
builtin_script: None,
tags: HashSet::new(),
queue: ObjQueue::new(),
name: None,
}
}
+233 -785
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
// player) fires this `grab()` hook instead of blocking. We bump the player's gem
// count and remove ourselves from the board.
fn grab() {
fn grab(me, state) {
add_gems(1);
die();
}
+1 -1
View File
@@ -2,7 +2,7 @@
//
// A heart is a grabbable collectible: walking onto it fires `grab()` instead
// of blocking. It restores 1 health and removes itself from the board.
fn grab() {
fn grab(me, state) {
alter_health(1);
die();
}
+2 -2
View File
@@ -3,7 +3,7 @@
// 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
// count and remove ourselves from the board.
fn grab() {
fn grab(me, state) {
let colors = [
"red",
"orange",
@@ -16,7 +16,7 @@ fn grab() {
];
for c in colors {
if Me.has_tag(`BUILTIN_key_${c}`) {
if me.has_tag(`BUILTIN_key_${c}`) {
set_key(c, true);
}
}
+5 -5
View File
@@ -6,17 +6,17 @@
// pusher shares one compiled copy and learns its direction from the
// `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
// 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.
//
// 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.
if Queue.length() == 0 {
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_east") { East }
if !me.waiting {
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_east") { East }
else { West };
move(dir);
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
// 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.
fn tick(dt) {
fn tick(me, state, dt) {
// Only start a new rotation when the previous one (and its delay) has drained,
// 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.
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.
let cw_ring = [
[Me.x, Me.y-1], // N
[Me.x+1, Me.y-1], // NE
[Me.x+1, Me.y], // E
[Me.x+1, Me.y+1], // SE
[Me.x, Me.y+1], // S
[Me.x-1, Me.y+1], // SW
[Me.x-1, Me.y], // W
[Me.x-1, Me.y-1], // NW
[me.x, me.y-1], // N
[me.x+1, me.y-1], // NE
[me.x+1, me.y], // E
[me.x+1, me.y+1], // SE
[me.x, me.y+1], // S
[me.x-1, me.y+1], // SW
[me.x-1, me.y], // W
[me.x-1, me.y-1], // NW
];
// 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
// 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 fkey = `spin_${Me.id}`;
let fkey = `spin_${me.id}`;
let f = Registry.get_or(fkey, 0);
set_tile(frames[f % 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() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]);
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();
let b = game.board();
// 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.
let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]);
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();
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() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]);
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();
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")]);
crate_at(&mut board, 2, 0);
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();
let b = game.board();
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.
let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
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();
{
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")]);
wall_at(&mut board, 3, 0);
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();
let b = game.board();
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 mut game = GameState::with_scripts(
board,
scripts_from(&[("m", "fn init() { move(East); move(East); }")]),
scripts_from(&[("m", "fn init(m,s) { move(East); move(East); }")]),
);
game.run_init();
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);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("m", "fn init() { move(East); move(South); }")]),
scripts_from(&[("m", "fn init(m,s) { move(East); move(South); }")]),
);
game.run_init();
// First (eastward) move is blocked by the wall: object hasn't moved.
@@ -136,7 +136,7 @@ fn queue_length_reports_pending_actions() {
board,
scripts_from(&[(
"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();
@@ -166,7 +166,7 @@ fn blocked_reports_solid_and_clear() {
board,
scripts_from(&[(
"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();
@@ -178,38 +178,9 @@ fn blocked_reports_solid_and_clear() {
board,
scripts_from(&[(
"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();
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(&[
(
"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",
"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;
/// 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 {
name: "test".into(),
width: 3,
+22 -22
View File
@@ -8,7 +8,7 @@ use std::time::Duration;
fn init_runs_only_on_run_init_not_at_construction() {
let (board, scripts) = board_with_object(
Some("greet"),
&[("greet", r#"fn init() { log("hello"); }"#)],
&[("greet", r#"fn init(m,s) { log("hello"); }"#)],
);
let mut game = GameState::with_scripts(board, scripts);
// 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() {
let (board, scripts) = board_with_object(
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);
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 mut game = GameState::with_scripts(
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();
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(
board,
scripts_from(&[
("e", "fn init() { move(East); }"),
("w", "fn init() { move(West); }"),
("e", "fn init(m,s) { move(East); }"),
("w", "fn init(m,s) { move(West); }"),
]),
);
game.run_init();
@@ -127,7 +127,7 @@ fn set_tag_adds_and_removes_via_my_id() {
// present on the object afterward.
let (board, scripts) = board_with_object(
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);
game.run_init();
@@ -136,7 +136,7 @@ fn set_tag_adds_and_removes_via_my_id() {
// A script removes a pre-existing tag.
let (mut board2, scripts2) = board_with_object(
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.
board2
@@ -158,8 +158,8 @@ fn has_tag_reads_own_tags() {
Some("t"),
&[(
"t",
r#"fn init() { set_tag(Me.id, "active", true); }
fn tick(dt) { log(Me.has_tag("active").to_string()); }"#,
r#"fn init(m,s) { set_tag(m.id, "active", true); }
fn tick(m,s,dt) { log(m.has_tag("active").to_string()); }"#,
)],
);
let mut game = GameState::with_scripts(board, scripts);
@@ -182,8 +182,8 @@ fn objects_with_tag_returns_matching_ids() {
scripts_from(&[
(
"q",
r#"fn init() {
let infos = Board.tagged("enemy");
r#"fn init(m,s) {
let infos = s.board.tagged("enemy");
log(infos.len().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() {
// An object with a name set on its ObjectDef should see it via my_name().
let (mut board, scripts) =
board_with_object(Some("n"), &[("n", r#"fn init() { 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());
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert_eq!(log_texts(&game), vec!["beacon"]);
// An unnamed object should get an empty string.
// An unnamed object should get ().
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);
game2.run_init();
assert_eq!(log_texts(&game2), vec![""]);
assert_eq!(log_texts(&game2), vec!["null"]);
}
#[test]
@@ -228,9 +228,9 @@ fn object_id_for_name_finds_by_name() {
scripts_from(&[
(
"q",
r#"fn init() {
log(Board.named("target").id.to_string());
let miss = Board.named("missing");
r#"fn init(m,s) {
log(s.board.named("target").id.to_string());
let miss = s.board.named("missing");
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 mut game = GameState::with_scripts(
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.try_move(Direction::East);
@@ -271,7 +271,7 @@ fn scroll_opens_on_player_bump() {
board,
scripts_from(&[(
"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();
@@ -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 mut game = GameState::with_scripts(
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.try_move(Direction::East);
@@ -317,7 +317,7 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
scripts_from(&[(
"s",
r#"
fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn bump(m,s,id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
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::glyph::Glyph;
use color::Rgba8;
use rhai::Dynamic;
use crate::Board;
use crate::log::LogLine;
/// 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) {
match self.kind {
SolidKind::Player => {
board.player.x = x as i32;
board.player.y = y as i32;
board.player.x = x as i64;
board.player.y = y as i64;
}
SolidKind::Object(id) => {
if let Some(obj) = board.objects.get_mut(&id) {
@@ -293,9 +297,9 @@ impl PortalDef {
#[derive(Copy, Clone)]
pub struct PlayerPos {
/// Column position (0-indexed, increasing rightward).
pub x: i32,
pub x: i64,
/// Row position (0-indexed, increasing downward).
pub y: i32,
pub y: i64,
}
#[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.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Direction {
@@ -342,6 +338,37 @@ pub enum Direction {
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.
///
/// 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).
fn from(d: Direction) -> Self {
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> {
/// Creates a widget that renders `board`, scrolling to follow the player.
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 }
}
+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;
/// `on_screen` is `false` when clamping occurred (tail should be suppressed for that bubble).
fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> (u16, u16, bool) {
let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x);
let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y);
let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x as i32);
let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y as i32);
let raw_sx = area.x as i32 + pad_x as i32 + (bx as i32 - off_x as i32);
let raw_sy = area.y as i32 + pad_y as i32 + (by as i32 - off_y as i32);
let sx = raw_sx.clamp(area.left() as i32, area.right() as i32 - 1) as u16;
+30 -31
View File
@@ -6,66 +6,65 @@ start = "start"
# Objects reference a script by name via `script_name`.
[scripts]
greeter = """
fn init() {
log(`hello from object player at ${Board.player_x}, ${Board.player_y}`);
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: ${Player.health}`);
log(`Player health: ${state.player.health}`);
}
fn tick(dt) {
//log(`x: ${Me.x} ${Board.player_x} y: ${Me.y} ${Board.player_y} q: ${Queue.length()}`);
if Board.player_x == Me.x && Board.player_y == Me.y && Queue.length() == 0 {
fn tick(me, state, dt) {
if state.player.x == me.x && state.player.y == me.y && !me.waiting {
say("Hey! Get offa me!");
delay(5.0);
me.delay(5.0);
}
}
"""
mover = """
fn init() {
fn init(me, state) {
if Registry.get("dancer_x") != () {
let new_x = Registry.get("dancer_x");
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);
}
} else {
Registry.set("dancer_x", Me.x);
Registry.set("dancer_y", Me.y);
log(`Set reg to ${Me.x} ${Me.y}`);
Registry.set("dancer_x", me.x);
Registry.set("dancer_y", 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
// tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking
// into a wall (or another object's already-queued move this tick).
fn tick(dt) {
if Queue.length() == 0 { dance(); }
fn tick(me, state, dt) {
if !me.waiting { dance(me); }
}
fn dance() {
fn dance(me) {
move(East);
move(South);
move(North);
move(East);
say("Hey!", 0.5);
delay(0.5);
delay(me, 0.5);
move(West);
move(South);
move(North);
move(West);
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,
// or -1 for the player.
fn bump(id) {
fn bump(me, state, id) {
log(`mover bumped by ${id}`);
say("Ow!");
}
"""
muffin = """
fn bump(id) {
fn bump(me, state, _id) {
scroll([
"You find a small muffin on the ground, your favorite kind.",
"It smells of cinnamon and warm mornings.",
@@ -73,7 +72,7 @@ fn bump(id) {
["ignore", "This is obviously a trap — ignore it."]
]);
}
fn eat() {
fn eat(me, state) {
say("Yeah it was poisoned.");
alter_health(-2);
}
@@ -84,7 +83,7 @@ fn ignore() {
"""
noticeboard = """
fn bump(id) {
fn bump(me, state, id) {
scroll([
" TOWN NOTICE BOARD",
" ",
@@ -111,13 +110,13 @@ fn bump(id) {
"dusk. All residents are warmly encouraged to attend.",
" ",
" Posted by order of the Town Council.",
" Unauthorised notices will be removed.",
" Unauthorized notices will be removed.",
]);
}
"""
bookshelf = """
fn bump(id) {
fn bump(me, state, id) {
scroll([
" THE BOOKSHELF",
" ",
@@ -136,13 +135,13 @@ fn bump(id) {
"""
fireplace = """
fn bump(id) {
fn bump(me, state, id) {
say("The fire crackles warmly.\\nYou feel at ease.");
}
"""
chest = """
fn bump(id) {
fn bump(me, state, id) {
scroll([
" THE CHEST",
" ",
@@ -160,8 +159,8 @@ fn bump(id) {
"""
yammerer = """
fn tick(dt) {
if Queue.length() == 0 {
fn tick(me, state, dt) {
if !me.waiting {
say("blahblahblah");
delay(1);
}
@@ -169,11 +168,11 @@ fn tick(dt) {
"""
shifter = """
fn tick(dt) {
let x = Me.x;
let y = Me.y;
fn tick(me, state, dt) {
let x = me.x;
let y = me.y;
if Queue.length() == 0 {
if !me.waiting {
shift([[x-1, y], [x, y-1], [x+1, y], [x, y+1]]);
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" }