transporters
This commit is contained in:
+2
-2
@@ -20,7 +20,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
||||
**`kiln-core/src/archetype.rs`** — element taxonomy:
|
||||
- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Builtin(Builtin, &'static str)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` checks the `Builtin` registry first (via `Builtin::from_name`), then the hard-coded terrain names; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
|
||||
- `Builtin` (`Copy`, `PartialEq`, `Hash`) — the family enum for all script-backed archetypes: `Gem`, `Heart`, `Pusher`, `Spinner`, `Key`. Generated by the `builtins!` macro (see below) along with all its methods. `Archetype::Builtin(family, alias)` carries both the family (selects the behavior and script source) and the specific alias matched during parsing (e.g. `"pusher_north"` or `"spinner_cw"`); the alias drives the per-alias glyph, the `BUILTIN_<alias>` tag on the expanded object, and the compile-cache key.
|
||||
- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable). All aliases in a family share one Rhai script; the script reads `me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality.
|
||||
- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable), `Transporter` (`"transporter_north|south|east|west"` → 94/118/41/40 `^`/`v`/`)`/`(`; cyan, solid + **see-through** (`opaque: false`) + unpushable — animates a 4-frame loop and teleports whatever bumps it from the front, either past it or out of a paired opposite-facing transporter). All aliases in a family share one Rhai script; the script reads `me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality.
|
||||
|
||||
**`kiln-core/src/builtin_scripts.rs`** — tag helpers for script-backed archetypes (`pub(crate)`):
|
||||
- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. `expand_builtin_archetypes` tags each expanded object with `BUILTIN_<alias>` (e.g. `"BUILTIN_pusher_east"`) and also uses this string as the object's `script_name` compile-cache key (so each alias gets its own cached AST, though the Rhai source is the same for all aliases in a family). The script reads its tag to determine per-instance direction. `map_file::save` uses `archetype_from_builtin_tag` to collapse the object back into its archetype keyword so maps round-trip. The script sources and behavior definitions now live in `archetype.rs` via the `builtins!` macro (via `include_str!` of `scripts/*.rhai`); this file has only the three tag helpers.
|
||||
@@ -92,7 +92,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
||||
- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, state, id)` (the bumper's `ObjectId`, or `-1` for the player), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (2/3/3/2). Driven by `run_init(state)` / `run_tick(state, dt)` / `run_bump(state, id, bumper)` / `run_grab(state, id)`, all funneling through `run_hook_on_one`: it builds a fresh `ObjectInfo` for the object, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal.
|
||||
- `run_send(state, id, fn_name, arg)` — calls an arbitrary named function on an object (backs the `send()` action and scroll-choice dispatch). Picks the param list by the function's arity: 3 → `(me, state, arg)`, 2 → `(me, state)`, 1 → `(arg)`, 0 → `()`; a missing arg is `Dynamic::UNIT`. Errors if no function of that name exists.
|
||||
- **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore.
|
||||
- **Write API** (`register_write_api`) — free functions that enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `log(s)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(x, y)`, `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error) and rotates that ring of cells via `Board::apply_shift`.
|
||||
- **Write API** (`register_write_api`) — free functions that enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `log(s)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error) and rotates that ring of cells via `Board::apply_shift`.
|
||||
- **Queue draining (two-tier):** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(board_queue, dt)` promotes them onto the shared **board queue** (`Vec<BoardAction>`): it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. `GameState` takes the board queue with `take_board_queue()` and applies it after the batch, so scripts mutate without a `&mut GameState` borrow. Errors bypass the queues via the error sink (`take_errors()`).
|
||||
- **Constants in scope:** only `Registry` is pushed into each object's scope (the `me`/`state` views are hook *parameters* now). `register_global_constants` registers the `North`/`South`/`East`/`West` `Direction` values and the 16 named colors as a **global module**, visible at any call depth (including Rhai→Rhai calls and helper functions) — unlike scope constants such as `Registry`.
|
||||
- `Registry` (a `Board`-handle wrapper) — `Registry.get(key)` / `set(key, value)` / `get_or(key, default)` read and write the board's `registry: HashMap<String, RegistryValue>`, which **persists across board transitions**. `set(key, ())` removes a key; unsupported value types are ignored; `get_or` only returns the stored value when it matches the default's Rhai type.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user