wip 5 transporters work right

This commit is contained in:
2026-08-11 02:31:46 -05:00
parent 1a4d894336
commit 456b3448eb
7 changed files with 127 additions and 136 deletions
+27 -3
View File
@@ -8,8 +8,8 @@
//! //!
//! ## Cell queries //! ## Cell queries
//! //!
//! - `board.passable(x, y) -> bool` — is `(x, y)` on-board and free of any solid (a hole) //! - `board.passable(x, y) -> bool` / `board.passable(point) -> bool` — is the cell on-board
//! cell (one empty, or a grab thing and the player) //! and free of any solid (a hole)?
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@@ -18,7 +18,8 @@ use crate::{Board, Direction};
use crate::api::object_info::ObjectInfo; use crate::api::object_info::ObjectInfo;
use crate::api::registry::Registry; use crate::api::registry::Registry;
use crate::script::Registerable; use crate::script::Registerable;
use crate::utils::{LogSink, ObjectId}; use crate::tile::Tile;
use crate::utils::{LogSink, ObjectId, Point};
/// A read-only handle to the world, exposed to scripts as `Board`. /// A read-only handle to the world, exposed to scripts as `Board`.
pub type BoardRef = Rc<RefCell<Board>>; pub type BoardRef = Rc<RefCell<Board>>;
@@ -37,6 +38,18 @@ impl Registerable for BoardRef {
board.in_bounds((x, y)) && board.is_empty((x, y)) board.in_bounds((x, y)) && board.is_empty((x, y))
}); });
// The Point overload of the same query, so a script that already holds a
// cell (from `dir.from_point(…)` / `me.location`) needn't unpack it.
engine.register_fn("passable", move |board: BoardRef, p: Point| -> bool {
let board = board.borrow();
board.in_bounds(p) && board.is_empty(p)
});
engine.register_fn("mobile", move |board: BoardRef, p: Point| -> bool {
let board = board.borrow();
board.in_bounds(p) && board.is_mobile(p)
});
// Board.get(id) -> ObjectInfo | () (unknown id logs error) // Board.get(id) -> ObjectInfo | () (unknown id logs error)
engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic { engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic {
if id <= 0 { if id <= 0 {
@@ -72,5 +85,16 @@ impl Registerable for BoardRef {
// Board.registry -> Registry // Board.registry -> Registry
engine.register_get("registry", |b: &mut BoardRef| Registry(b.clone())); engine.register_get("registry", |b: &mut BoardRef| Registry(b.clone()));
// Board.get(id) -> ObjectInfo | () (unknown id logs error)
engine.register_fn("id_at", move |board: &mut BoardRef, point: Point| -> Dynamic {
let board = board.borrow();
if !board.in_bounds(point) { return Dynamic::UNIT }
match board.get(point) {
Some(Tile::Object(obj)) => Dynamic::from(obj.scripting.id as i64),
Some(Tile::Player) => Dynamic::from(-1i64),
None => Dynamic::UNIT
}
});
} }
} }
+4
View File
@@ -3,6 +3,7 @@
//! ### Getters //! ### Getters
//! //!
//! - x, y -> Board location of object //! - x, y -> Board location of object
//! - location -> The same cell as a single Point
//! - id -> The object's board-unique id //! - id -> The object's board-unique id
//! - name -> The object's name, or () //! - name -> The object's name, or ()
//! - waiting -> bool for whether or not the front of this object's queue is a delay action //! - waiting -> bool for whether or not the front of this object's queue is a delay action
@@ -74,6 +75,9 @@ impl Registerable for ObjectInfo {
engine.register_type_with_name::<ObjectInfo>("ObjectInfo") engine.register_type_with_name::<ObjectInfo>("ObjectInfo")
.register_get("x", |obj: &mut ObjectInfo| obj.location.x) .register_get("x", |obj: &mut ObjectInfo| obj.location.x)
.register_get("y", |obj: &mut ObjectInfo| obj.location.y) .register_get("y", |obj: &mut ObjectInfo| obj.location.y)
// The same cell as a single `Point`, for the coordinate-taking API
// (`dir.from_point(me.location)`, `Board.passable(o.location)`, `shift`).
.register_get("location", |obj: &mut ObjectInfo| obj.location)
.register_get("id", |obj: &mut ObjectInfo| obj.id as i64) .register_get("id", |obj: &mut ObjectInfo| obj.id as i64)
.register_get("waiting", |obj: &mut ObjectInfo| obj.queue.waiting()) .register_get("waiting", |obj: &mut ObjectInfo| obj.queue.waiting())
.register_get("queue", |obj: &mut ObjectInfo| obj.queue.clone()); .register_get("queue", |obj: &mut ObjectInfo| obj.queue.clone());
+9
View File
@@ -488,6 +488,15 @@ impl Board {
pub fn is_object<P: Into<Point>>(&self, p: P) -> bool { pub fn is_object<P: Into<Point>>(&self, p: P) -> bool {
matches!(self.get(p), Some(Tile::Object(_))) matches!(self.get(p), Some(Tile::Object(_)))
} }
/// Return if there's an object at the given point and if it's mobile
pub fn is_mobile<P: Into<Point>>(&self, p: P) -> bool {
if let Some(Tile::Object(o)) = self.get(p) {
o.mobile
} else {
false
}
}
} }
#[cfg(test)] #[cfg(test)]
-51
View File
@@ -296,7 +296,6 @@ impl GameState {
} }
Action::Push { x, y, dir } => { Action::Push { x, y, dir } => {
self.resolve_move((x, y), dir); self.resolve_move((x, y), dir);
//apply_push(&mut self.board_mut(), x, y, dir).unwrap_or_else(|e| log_sink.error(e))
} }
Action::Shift(cells) => { Action::Shift(cells) => {
let cells = cells.into_iter().map(|c| c.into()).collect::<Vec<Point>>(); let cells = cells.into_iter().map(|c| c.into()).collect::<Vec<Point>>();
@@ -412,60 +411,10 @@ impl GameState {
} }
}; };
// Whether the player is what initiated the move directly. Matters for grab / swap
// let from_player = matches!(self.board().get((from.0, from.1)), Some(Tile::Player));
// Call the target's bump hook and resolve whatever it did // Call the target's bump hook and resolve whatever it did
let actions = self.scripts.run_bump(id, dir.opposite()); let actions = self.scripts.run_bump(id, dir.opposite());
self.apply_actions(actions); self.apply_actions(actions);
// First do things to try and call any hooks relevant:
// let actions = match resp {
// // We block all moves, return false
// EnterResponse::Block => { self.scripts.run_bump(id, dir.opposite()) }
//
// // The player can grab things directly, so let's do that
// EnterResponse::Grab if from_player => {
// let a = self.scripts.run_grab(id);
// self.board_mut().get_mut((target.0, target.1)).take();
// a
// }
//
// // Grabbables not from the player act as pushable, we need to recurse
// EnterResponse::Grab => { self.resolve_move(target, dir); vec![] }
//
// // Pushable if we're allowed to push that way recurses
// EnterResponse::Push(p) if p.allows(dir) => { self.resolve_move(target, dir); vec![] }
//
// // Pushable in a disallowed direction blocks
// EnterResponse::Push(p) => { self.scripts.run_bump(id, dir.opposite()) }
//
// // Swaps let the player swap places, but we should return false afterward because we
// // haven't left the source cell empty. In practice this won't matter because right
// // now we never recurse _into_ a player-source-cell, all moves are initiated by the
// // player, but still.
// EnterResponse::Swap if from_player => {
// let mut board = self.board_mut();
// let mover = board.get_mut((from.0, from.1)).take();
// let swapper = board.get_mut((target.0, target.1)).take();
// board.get_mut((target.0, target.1)).replace(mover.unwrap());
// board.get_mut((from.0, from.1)).replace(swapper.unwrap());
// vec![]
// }
//
// // Swaps in a chain are just normal pushable, recurse:
// EnterResponse::Swap => { self.resolve_move(target, dir); vec![] }
//
// // Squish just lets the mover overwrite, and always leaves the source cell empty:
// EnterResponse::Squish => { self.board_mut().get_mut((from.0, from.1)).take(); vec![] }
//
// // Finally, hook, we need to call a hook and let it do its thing:
// EnterResponse::Hook => { self.scripts.run_bump(id, dir.opposite()) }
// };
// If there were actions performed by the hooks, do them.
// self.apply_actions(actions);
// Now, check again if the target cell is empty. If it is, put the mover there. Also // Now, check again if the target cell is empty. If it is, put the mover there. Also
// check that the source cell still contains something! It may have been teleported away. // check that the source cell still contains something! It may have been teleported away.
if !self.board().is_empty(from) && self.board().is_empty(target) { if !self.board().is_empty(from) && self.board().is_empty(target) {
+10 -3
View File
@@ -632,9 +632,10 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
} }
); );
// shift([[x, y], ...): Emits a shift action which moves the contents of each given cell in // shift([[x, y], ...]): Emits a shift action which moves the contents of each given cell in
// a loop (the last cell is moved to the first coord). Doesn't move things that aren't pushable, // a loop (the last cell is moved to the first coord). Doesn't move things that aren't pushable,
// and won't move anything into a cell that's not vacant (or vacated by this shift). // and won't move anything into a cell that's not vacant (or vacated by this shift).
// Each entry may be a `[x, y]` pair or a `Point`.
let b = board.clone(); let b = board.clone();
let sink = log_sink.clone(); let sink = log_sink.clone();
engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| { engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| {
@@ -647,11 +648,17 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
}); });
} }
/// Read a `Vec<(i32, i32)>` from a Rhai array, to receive a list of coordinates from a script. /// Read a `Vec<(i64, i64)>` from a Rhai array, to receive a list of coordinates from a script.
/// Returns Err if the array isn't `[[x, y], ...]` /// Each element may be a `Point` or an `[x, y]` pair; returns `Err` for anything else.
fn read_coord_array(arr: &Array) -> Result<Vec<(i64, i64)>, ()> { fn read_coord_array(arr: &Array) -> Result<Vec<(i64, i64)>, ()> {
let mut pairs = Vec::new(); let mut pairs = Vec::new();
for elem in arr { for elem in arr {
// A `Point` (from `me.location`, `dir.from_point(…)`, `xy(…)`) is taken as-is;
// otherwise the element must be a two-int array.
if let Some(p) = elem.read_lock::<Point>() {
pairs.push((*p).into());
continue;
}
let coords: Option<Vec<i64>> = elem.read_lock::<Array>().and_then(|inner| { let coords: Option<Vec<i64>> = elem.read_lock::<Array>().and_then(|inner| {
inner.iter().map(|v| v.as_int().ok()).collect() inner.iter().map(|v| v.as_int().ok()).collect()
}); });
+63 -77
View File
@@ -2,45 +2,51 @@
// //
// A transporter is a solid, see-through, unpushable machine that teleports // A transporter is a solid, see-through, unpushable machine that teleports
// whatever bumps into it from its facing side. It animates through a 4-frame // whatever bumps into it from its facing side. It animates through a 4-frame
// loop (500 ms/frame) and, on a front bump, moves the bumper either to the cell // loop and, on a front bump, moves the bumper either to the cell just past it
// just past it or — if that's blocked — out of the nearest opposite-facing // or — if that's blocked — out of the far side of the nearest opposite-facing
// transporter further along its axis (skipping any whose entrance is blocked). // transporter along its axis (skipping any whose far side is blocked too).
// //
// The direction is not baked into this source: every transporter shares one // The direction is not baked into this source: every transporter shares one
// compiled copy and reads its `BUILTIN_transporter_<dir>` tag. The world is // compiled copy and reads its `BUILTIN_transporter_<dir>` tag. The world is
// reached through the global `Board`/`Player` constants. The bumper is moved by // reached through the global `Board` constant. The bumper is moved by
// coordinate via `shift([[from], [to]])`, which relocates whatever solid sits at // coordinate via `shift([from, to])`, which relocates whatever solid sits at
// the entrance — player, object, or a pushed crate — onto the empty destination. // the entrance — player, object, or a pushed crate — onto the empty destination.
// This transporter's facing unit vector, from its direction tag. // The builtin tag worn by a transporter facing `d`.
fn tag_for(d) { "BUILTIN_transporter_" + d.to_string().to_lower() }
// This transporter's facing direction, from its tag (west if it somehow has none).
fn facing(me) { fn facing(me) {
if me.has_tag("BUILTIN_transporter_north") { [0, -1] } for d in [North, South, East, West] {
else if me.has_tag("BUILTIN_transporter_south") { [0, 1] } if me.has_tag(tag_for(d)) { return d; }
else if me.has_tag("BUILTIN_transporter_east") { [1, 0] } }
else { [-1, 0] } West
} }
// The tag of the opposite-facing transporter this one pairs with. // The 4-frame animation loop for a facing direction.
fn opposite_tag(me) { fn frames(d) {
if me.has_tag("BUILTIN_transporter_north") { "BUILTIN_transporter_south" } switch d.to_string() {
else if me.has_tag("BUILTIN_transporter_south") { "BUILTIN_transporter_north" } "North" => ["^", "-", "^", "~"],
else if me.has_tag("BUILTIN_transporter_east") { "BUILTIN_transporter_west" } "South" => ["v", "_", "v", "-"],
else { "BUILTIN_transporter_east" } "East" => [")", "|", ")", ">"],
_ => ["(", "|", "(", "<"],
}
} }
// The 4-frame animation loop for this direction. // Move whatever solid sits at `from` onto the empty cell `to`, immediately. A
fn frames(me) { // two-cell `shift` relocates the source solid — player, object, or crate — and
if me.has_tag("BUILTIN_transporter_north") { ["^", "-", "^", "~"] } // leaves its old cell empty (the caller has already checked `to` is free).
else if me.has_tag("BUILTIN_transporter_south") { ["v", "_", "v", "-"] } fn transport(my_dir, traveler, to) {
else if me.has_tag("BUILTIN_transporter_east") { [")", "|", ")", ">"] } push(to.x, to.y, my_dir);
else { ["(", "|", "(", "<"] } teleport(traveler, to.x, to.y);
now(); now();
} }
fn tick(me, dt) { fn tick(me, dt) {
// Advance one animation frame every 0.5s, like the spinner: frame state lives // Advance one animation frame per drain, like the spinner: frame state lives
// in the board Registry (script scope resets each tick), keyed per instance. // in the board Registry (script scope resets each tick), keyed per instance.
if me.waiting { return; } if me.waiting { return; }
let fs = frames(me); let fs = frames(facing(me));
let fkey = `xport_${me.id}`; let fkey = `xport_${me.id}`;
let f = Board.registry.get_or(fkey, 0); let f = Board.registry.get_or(fkey, 0);
set_tile(fs[f % 4]); set_tile(fs[f % 4]);
@@ -48,65 +54,45 @@ fn tick(me, dt) {
me.delay(0.15); me.delay(0.15);
} }
// Move whatever solid sits at (sx, sy) onto the empty cell (tx, ty) immediately.
// A two-cell `shift` relocates the source solid — player, object, or crate — and
// leaves its old cell empty (the destination is checked empty by the caller). Kept
// as a helper so the nested-array literal stays at a shallow expression depth.
fn transport(sx, sy, tx, ty) {
shift([[sx, sy], [tx, ty]]); now();
}
fn bump(me, dir) { fn bump(me, dir) {
let d = facing(me); let d = facing(me);
let dx = d[0];
let dy = d[1];
// Only transport things that hit us from the front — the entrance side (-facing). // Only transport things that hit us from the front. `dir` is the side the bump
// `dir` is the side the bump came from, so its offset must be the opposite of our // came from, so a front bump arrives from our entrance side — the opposite of
// facing; a bump from any other side does nothing. // our facing; a bump from any other side does nothing.
if dir.dx != -dx || dir.dy != -dy { return; } if dir != d.opposite { return; }
// Whatever solid is pressed against our entrance cell (me - d) gets moved: // Whatever solid is pressed against our entrance cell gets moved: the player,
// the player, another object, or a pushed crate — all handled uniformly by // another object, or a pushed crate — all handled uniformly by shifting the
// shifting the solid at that coordinate onto an empty destination. // solid at that coordinate onto an empty destination.
let ex = me.x - dx; let entrance = dir.from_point(me.location);
let ey = me.y - dy; let traveler = Board.id_at(entrance);
// 1. The cell just past us (the opposite side): if nothing solid is there, // 1. The cell just past us (our facing side): if nothing solid is there, drop
// drop the bumper onto it. // the bumper onto it.
let fx = me.x + dx; let exit = d.from_point(me.location);
let fy = me.y + dy; if Board.passable(exit) || Board.mobile(exit) {
if Board.passable(fx, fy) { transport(d, traveler, exit);
transport(ex, ey, fx, fy);
return; return;
} }
// 2. Otherwise scan along our axis for the nearest opposite-facing transporter // 2. Otherwise emerge from the *nearest* opposite-facing transporter ahead of
// whose far side (the cell just past it) is free, and drop the bumper there. // us on our axis whose own far side is free; a blocked pair is skipped, and
// A blocked pair is skipped; give up at the board edge. // nothing happens if no pair qualifies. Walls between us don't matter — we
let opp = Board.tagged(opposite_tag(me)); // only look at where the paired transporters are, not the cells in between.
let cx = fx; let best = ();
let cy = fy; let best_dist = 0;
loop { for o in Board.tagged(tag_for(d.opposite)) {
cx += dx; // Split its offset from us into distance along our facing and distance to
cy += dy; // the side, so "on our axis, in front of us" is `aside == 0 && ahead > 0`.
if cx < 0 || cy < 0 || cx >= Board.width || cy >= Board.height { let ahead = (o.x - me.x) * d.dx + (o.y - me.y) * d.dy;
return; let aside = (o.x - me.x) * d.dy - (o.y - me.y) * d.dx;
} if aside != 0 || ahead < 1 { continue; }
// Is one of the opposite transporters sitting at (cx, cy)? if best_dist != 0 && ahead > best_dist { continue; }
let here = false;
for o in opp { // Emerge out its back — the cell just past it along our scan.
if o.x == cx && o.y == cy { here = true; } best = d.from_point(o.location);
} best_dist = ahead;
if here {
// Emerge on the paired transporter's far side — the cell just past it
// along our scan (cx + d), out its back.
let tx = cx + dx;
let ty = cy + dy;
if Board.passable(tx, ty) {
transport(ex, ey, tx, ty);
return;
}
}
} }
} if best_dist > 0 { transport(d, traveler, best); }
}
+14 -2
View File
@@ -131,7 +131,10 @@ impl Registerable for Direction {
fn register(engine: &mut Engine, _log_sink: LogSink) { fn register(engine: &mut Engine, _log_sink: LogSink) {
engine.register_type_with_name::<Direction>("Direction") engine.register_type_with_name::<Direction>("Direction")
.register_get("opposite", |dir: &mut Direction| dir.opposite()) .register_get("opposite", |dir: &mut Direction| dir.opposite())
.register_fn("from_point", |dir: &mut Direction, x: i64, y: i64| Point::from(dir.from_point((x, y)))); .register_fn("from_point", |dir: &mut Direction, x: i64, y: i64| Point::from(dir.from_point((x, y))))
// The Point overload, so a script can walk a coordinate along an axis
// (`p = d.from_point(p)`) without unpacking it into two ints.
.register_fn("from_point", |dir: &mut Direction, p: Point| dir.from_point(p));
} }
} }
@@ -179,8 +182,17 @@ impl Registerable for Point {
fn register(engine: &mut Engine, _log_sink: LogSink) { fn register(engine: &mut Engine, _log_sink: LogSink) {
engine.register_type_with_name::<Point>("Point") engine.register_type_with_name::<Point>("Point")
.register_get("x", |p: &mut Point| p.x) .register_get("x", |p: &mut Point| p.x)
.register_get("y", |p: &mut Point| p.y); .register_get("y", |p: &mut Point| p.y)
// A step from this point, the mirror of `dir.from_point(p)`.
.register_fn("in_dir", |p: &mut Point, dir: Direction| p.in_dir(dir));
engine.register_fn("xy", |x: i64, y: i64| Point::from((x, y))); engine.register_fn("xy", |x: i64, y: i64| Point::from((x, y)));
// Rhai derives no operators for custom types: register comparison so cells
// can be matched (`if o.location == here`), and printing so a Point
// interpolates as "(x, y)" in a log message.
engine.register_fn("==", |a: Point, b: Point| a == b);
engine.register_fn("!=", |a: Point, b: Point| a != b);
engine.register_fn("to_string", |p: Point| p.to_string());
engine.register_fn("to_debug", |p: Point| p.to_string());
} }
} }
/// 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.