transporters

This commit is contained in:
2026-07-08 10:06:25 -05:00
parent e43dae54a8
commit f407b5d9a6
14 changed files with 323 additions and 34 deletions
+5 -4
View File
@@ -99,10 +99,11 @@ pub enum Action {
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice
/// selection dispatches [`Send`](Action::Send) to the source object.
Scroll(Vec<ScrollLine>),
/// 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: i64, y: i64 },
/// Teleport object `target` to `(x, y)` (`target == -1` is the player).
/// Blocked (with a logged error) if that entity is solid and the destination
/// already has a *different* solid occupant. Zero time cost — multiple
/// teleports may fire in one frame.
Teleport { target: i64, 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: i64, y: i64, dir: Direction },
+11
View File
@@ -123,6 +123,17 @@ builtins! {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false },
script: include_str!("scripts/spinner.rhai"),
},
// Solid, see-through (opaque: false), unpushable teleporters. Each direction's
// default glyph is the first frame of its animation loop (see transporter.rhai).
Transporter => [
"transporter_north" => g(94, 0x55, 0xFF, 0xFF), // '^'
"transporter_south" => g(118, 0x55, 0xFF, 0xFF), // 'v'
"transporter_east" => g(41, 0x55, 0xFF, 0xFF), // ')'
"transporter_west" => g(40, 0x55, 0xFF, 0xFF), // '('
] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::No, grab: false },
script: include_str!("scripts/transporter.rhai"),
},
Key => [ // TODO these should refer to the key colors in Keyring
"key_blue" => KeyType::Blue.glyph(),
"key_green" => KeyType::Green.glyph(),
+45 -13
View File
@@ -282,24 +282,56 @@ impl GameState {
choice: None,
});
}
Action::Teleport { x, y } => {
Action::Teleport { target, x, y } => {
if !board.in_bounds((x, y)) {
logs.push(LogLine::error(format!("teleport({x},{y}): out of bounds")));
} else {
logs.push(LogLine::error(format!(
"teleport({target},{x},{y}): out of bounds"
)));
} else if target == -1 {
// Move the player. A solid *other than the player itself*
// blocks the destination.
let (ux, uy) = (x as usize, y as usize);
let source_solid = board
.objects
.get(&ba.source)
.map(|o| o.solid)
.unwrap_or(false);
if source_solid && board.solid_at(ux, uy).is_some() {
let blocked = matches!(
board.solid_at(ux, uy),
Some(s) if !s.player()
);
if blocked {
logs.push(LogLine::error(format!(
"teleport({x},{y}): destination is solid"
"teleport(player,{x},{y}): destination is solid"
)));
} else if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.x = ux;
obj.y = uy;
} else {
board.player.x = ux as i64;
board.player.y = uy as i64;
}
} else if let Ok(tid) = ObjectId::try_from(target) {
// Move object `tid` to (x, y). A solid destination blocks
// a solid mover, unless the occupant is that same object.
let (ux, uy) = (x as usize, y as usize);
match board.objects.get(&tid) {
None => logs.push(LogLine::error(format!(
"teleport({target},{x},{y}): no such object"
))),
Some(obj) => {
let source_solid = obj.solid;
let blocked = source_solid
&& matches!(
board.solid_at(ux, uy),
Some(s) if s.object_id() != Some(tid)
);
if blocked {
logs.push(LogLine::error(format!(
"teleport({target},{x},{y}): destination is solid"
)));
} else if let Some(obj) = board.objects.get_mut(&tid) {
obj.x = ux;
obj.y = uy;
}
}
}
} else {
logs.push(LogLine::error(format!(
"teleport({target},{x},{y}): invalid target id"
)));
}
}
// push() self-checks can_push, so an in-bounds guard is all we add.
+7 -6
View File
@@ -27,7 +27,7 @@
//!
//! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`,
//! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`,
//! `send(target_id, fn_name [, arg])`, `teleport(x, y)`, `push(x, y, dir)`,
//! `send(target_id, fn_name [, arg])`, `teleport(id, x, y)`, `push(x, y, dir)`,
//! `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`
use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST};
@@ -554,12 +554,13 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) {
},
);
// teleport(x, y): move the calling object to an arbitrary cell. Zero time cost.
// Blocked (with an error logged at resolve time) if the object is solid and the
// destination already has a solid occupant.
// teleport(target, x, y): move object `target` to an arbitrary cell (pass your
// own `me.id` to move yourself; `target == -1` moves the player). Zero time
// cost. Blocked (with an error logged at resolve time) if that entity is solid
// and the destination already holds a different solid occupant.
let b = board.clone();
engine.register_fn("teleport", move |ctx: NativeCallContext, x: i64, y: i64| {
emit(&b, source_of(&ctx), Action::Teleport { x, y });
engine.register_fn("teleport", move |ctx: NativeCallContext, target: i64, x: i64, y: i64| {
emit(&b, source_of(&ctx), Action::Teleport { target, x, y });
});
// push(x, y, dir): shove the pushable chain at (x, y) one step in dir. Acts on
+105
View File
@@ -0,0 +1,105 @@
// Built-in script for the `transporter_*` archetypes (see archetype.rs).
//
// 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
// loop (500 ms/frame) and, on a front bump, moves the bumper either to the cell
// just past it or — if that's blocked — out of the nearest opposite-facing
// transporter further along its axis (skipping any whose entrance is blocked).
//
// The direction is not baked into this source: every transporter shares one
// compiled copy and reads its `BUILTIN_transporter_<dir>` tag. The world is
// reached through the global `Board`/`Player` constants; `teleport(id, x, y)`
// moves an arbitrary entity (`-1` is the player).
// This transporter's facing unit vector, from its direction tag.
fn facing(me) {
if me.has_tag("BUILTIN_transporter_north") { [0, -1] }
else if me.has_tag("BUILTIN_transporter_south") { [0, 1] }
else if me.has_tag("BUILTIN_transporter_east") { [1, 0] }
else { [-1, 0] }
}
// The tag of the opposite-facing transporter this one pairs with.
fn opposite_tag(me) {
if me.has_tag("BUILTIN_transporter_north") { "BUILTIN_transporter_south" }
else if me.has_tag("BUILTIN_transporter_south") { "BUILTIN_transporter_north" }
else if me.has_tag("BUILTIN_transporter_east") { "BUILTIN_transporter_west" }
else { "BUILTIN_transporter_east" }
}
// The 4-frame animation loop for this direction (CP437 tile codes).
fn frames(me) {
if me.has_tag("BUILTIN_transporter_north") { [94, 45, 94, 126] } // ^ - ^ ~
else if me.has_tag("BUILTIN_transporter_south") { [118, 95, 118, 45] } // v _ v -
else if me.has_tag("BUILTIN_transporter_east") { [41, 124, 41, 62] } // ) | ) >
else { [40, 124, 40, 60] } // ( | ( <
}
fn tick(me, dt) {
// Advance one animation frame every 0.5s, like the spinner: frame state lives
// in the board Registry (script scope resets each tick), keyed per instance.
if me.waiting { return; }
let fs = frames(me);
let fkey = `xport_${me.id}`;
let f = Board.registry.get_or(fkey, 0);
set_tile(fs[f % 4]);
Board.registry.set(fkey, (f + 1) % 4);
me.delay(0.15);
}
fn bump(me, id) {
let d = facing(me);
let dx = d[0];
let dy = d[1];
// Only transport things that hit us from the front — the entrance cell at
// (me - d). A bump from any other side does nothing.
let bx;
let by;
if id == -1 {
bx = Player.x;
by = Player.y;
} else {
let o = Board.get(id);
if o == () { return; }
bx = o.x;
by = o.y;
}
if bx != me.x - dx || by != me.y - dy { return; }
// 1. The cell just past us (the opposite side): if nothing solid is there,
// drop the bumper onto it.
let fx = me.x + dx;
let fy = me.y + dy;
if Board.passable(fx, fy) {
teleport(id, fx, fy); now();
return;
}
// 2. Otherwise scan along our axis for the nearest opposite-facing transporter
// whose entrance (the cell just before it) is free, and drop the bumper
// there. A blocked pair is skipped; give up at the board edge.
let opp = Board.tagged(opposite_tag(me));
let cx = fx;
let cy = fy;
loop {
cx += dx;
cy += dy;
if cx < 0 || cy < 0 || cx >= Board.width || cy >= Board.height {
return;
}
// Is one of the opposite transporters sitting at (cx, cy)?
let here = false;
for o in opp {
if o.x == cx && o.y == cy { here = true; }
}
if here {
let ex = cx + dx;
let ey = cy + dy;
if Board.passable(ex, ey) {
teleport(id, ex, ey); now();
return;
}
}
}
}
+1
View File
@@ -6,6 +6,7 @@ mod portal_placement;
mod pushers;
mod round_trip;
mod spinners;
mod transporters;
use crate::board::Board;
use crate::map_file::MapFile;
@@ -0,0 +1,115 @@
//! Transporters are scripted objects (the `transporter_*` archetypes expand into
//! objects carrying the embedded `transporter.rhai` plus a
//! `BUILTIN_transporter_<dir>` tag). Bumping one from its facing side teleports
//! the bumper past it, or out of a paired opposite-facing transporter.
use super::{layer, load_board, map};
use crate::game::GameState;
use crate::map_file::MapFile;
use crate::utils::Direction;
use std::time::Duration;
#[test]
fn transporter_loads_as_a_tagged_scripted_solid_object() {
let board = load_board(&map(
3,
1,
&[layer(
"@T ",
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
)],
));
let (_, obj) = board
.objects
.iter()
.find(|(_, o)| o.tags.contains("BUILTIN_transporter_east"))
.expect("transporter object");
assert_eq!((obj.x, obj.y), (1, 0));
assert!(obj.builtin_script.is_some(), "carries the embedded script");
assert!(obj.solid, "transporter is solid");
assert!(!obj.opaque, "transporter is see-through");
assert!(!obj.pushable, "transporter cannot be pushed");
}
#[test]
fn bumping_a_transporter_drops_you_on_its_far_side() {
// Player at (0,0), an east transporter at (1,0), and a free cell at (2,0).
let board = load_board(&map(
3,
1,
&[layer(
"@T ",
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
)],
));
let mut game = GameState::new(board);
game.run_init();
// Walk east into the transporter: it's solid so the player doesn't step onto
// it, but the bump queues a teleport that the next tick applies.
game.try_move(Direction::East);
game.tick(Duration::from_secs_f64(0.1));
let b = game.board();
assert_eq!((b.player.x, b.player.y), (2, 0), "transported past the transporter");
}
#[test]
fn a_blocked_far_side_transports_out_of_the_paired_transporter() {
// Player, east transporter, a wall blocking its far side, the free entrance
// cell, then the paired west transporter. The player should pop out at the
// west transporter's entrance (the cell just before it), across the wall.
let board = load_board(&map(
6,
1,
&[layer(
"@T# W ",
&[
("@", "kind = \"player\""),
("T", "kind = \"transporter_east\""),
("#", "kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\""),
("W", "kind = \"transporter_west\""),
],
)],
));
let mut game = GameState::new(board);
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_secs_f64(0.1));
let b = game.board();
assert_eq!(
(b.player.x, b.player.y),
(3, 0),
"transported to the paired transporter's entrance",
);
}
#[test]
fn transporter_round_trips_to_its_keyword() {
let board = load_board(&map(
3,
1,
&[layer(
"@T ",
&[("@", "kind = \"player\""), ("T", "kind = \"transporter_east\"")],
)],
));
// Save collapses the expanded object back into the `transporter_east` keyword.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
assert!(
toml_out.contains("transporter_east"),
"transporter should save as its archetype keyword, got:\n{toml_out}"
);
// Reload: it is a working transporter object again.
let board2 = load_board(&toml_out);
assert!(
board2
.objects
.values()
.any(|o| o.tags.contains("BUILTIN_transporter_east") && o.builtin_script.is_some()),
"reloads as a tagged transporter object",
);
}