made grab simpler
This commit is contained in:
+6
-6
@@ -27,7 +27,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
|||||||
|
|
||||||
**`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`):
|
**`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`):
|
||||||
- `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`.
|
- `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`.
|
||||||
- `Behavior` — plain data struct from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`, `grab: bool` (walking into a `solid + grab` thing isn't blocked — the player moves onto it and its `grab()` hook fires; only `Gem` sets it).
|
- `Behavior` — plain data struct from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`, `grab: bool` (the player walking onto a `solid + grab` thing isn't blocked — it moves onto it and the thing's `grab()` hook fires; this is the *only* grab trigger, and only `Gem` sets the flag).
|
||||||
- `ObjectId = u32` — stable identifier for board objects.
|
- `ObjectId = u32` — stable identifier for board objects.
|
||||||
- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Cell(Archetype)`, or `Object(ObjectId)`.
|
- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Cell(Archetype)`, or `Object(ObjectId)`.
|
||||||
- `Player { x: i32, y: i32 }` — current player position.
|
- `Player { x: i32, y: i32 }` — current player position.
|
||||||
@@ -49,11 +49,11 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
|||||||
- `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`).
|
- `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`).
|
||||||
- `is_passable(x, y)` — convenience inverse of `solid_at`.
|
- `is_passable(x, y)` — convenience inverse of `solid_at`.
|
||||||
- `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
|
- `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
|
||||||
- `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The right gate for a simultaneous shift/rotation via `apply_swap`. Shifting onto the **player** counts only when the source is a `grab` thing (it gets grabbed, the player isn't moved); any other solid treats the player as a blocker.
|
- `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The right gate for a simultaneous shift/rotation via `apply_swap`. The **player** is always a blocker (a shift can't relocate it and `apply_swap` refuses to overwrite it).
|
||||||
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via `solid_cell_layer`).
|
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via `solid_cell_layer`).
|
||||||
- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries.
|
- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries.
|
||||||
- `grab_object_at(x, y) -> Option<ObjectId>` (a solid `grab` object on the cell — the player-walks-onto-it case) and `pushed_grab_into_player(x, y, dir) -> Option<ObjectId>` (walk the pushable chain from `(x,y)`; if a chain cell is a `grab` object whose forward neighbour is the player, return it — the grab-thing-shoved-into-the-player case). `GameState` uses both to decide when to fire `grab()` instead of pushing/sliding the player. (`remove_object` only edits the `objects` map; a live `ScriptHost` keeps a stale `ObjectRuntime` whose later host-fn calls resolve to a missing id and no-op — benign.)
|
- `grab_object_at(x, y) -> Option<ObjectId>` — a solid `grab` object on the cell (the player-walks-onto-it case). `GameState::try_move` uses it to fire `grab()` instead of bumping when the player steps onto a grab thing. Grab is **only** a player-movement event: a grab thing pushed/swapped into the player is treated as an ordinary solid (no special-case). (`remove_object` only edits the `objects` map; a live `ScriptHost` keeps a stale `ObjectRuntime` whose later host-fn calls resolve to a missing id and no-op — benign.)
|
||||||
- `apply_swap(pairs: &[(i32,i32,i32,i32)]) -> (Vec<LogLine>, Vec<ObjectId>)` — applies a batch of one-way solid moves **simultaneously** (reads every source's solid occupant — player/object/terrain — into a private `SolidSnapshot` before writing any destination, so cycles and two-cell swaps resolve). A source with no solid moves an "empty", removing the destination's solid (terrain cleared; a displaced object despawned via `remove_object`). The **player is never destroyed** — a write that would overwrite it without relocating it is skipped + logged. A **grab** object whose destination is the player isn't written onto it — it is left in place and its id is returned (the second tuple element) so `GameState` fires its `grab()` hook (collected into the same `grabs` list as the push path). Because a grabbed object stays put (and its `grab()` may not despawn it), a final **overlap sweep** over the swapped cells keeps one solid per cell — preferring a grabbed object so its `grab()` can still fire — and deletes the rest (never the player), logging an error per deletion. Out-of-bounds entries are skipped + logged. Backs the script `swap()` fn.
|
- `apply_swap(pairs: &[(i32,i32,i32,i32)]) -> Vec<LogLine>` — applies a batch of one-way solid moves **simultaneously** (reads every source's solid occupant — player/object/terrain — into a private `SolidSnapshot` before writing any destination, so cycles and two-cell swaps resolve). A source with no solid moves an "empty", removing the destination's solid (terrain cleared; a displaced object despawned via `remove_object`). The **player is never destroyed** — a write that would overwrite it without relocating it is skipped + logged (a grab object is no exception: it is refused like any other solid). Because a refused solid stays at its source cell, which another entry may also target, a final **overlap sweep** over the swapped cells keeps one solid per cell and deletes the rest (never the player), logging an error per deletion. Out-of-bounds entries are skipped + logged. Backs the script `swap()` fn.
|
||||||
- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving any floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the existing terrain layer (`terrain_layer_at`, the single non-`Empty` cell) or else the top layer; `Empty` erases — removes every object plus the terrain cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`.
|
- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving any floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the existing terrain layer (`terrain_layer_at`, the single non-`Empty` cell) or else the top layer; `Empty` erases — removes every object plus the terrain cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`.
|
||||||
- `expand_builtin_archetypes()` — the **single** expansion point: replaces every script-backed terrain cell (e.g. `Spinner`/`Pusher`/`Gem`, those with an `archetype_script`) with the scripted object it expands to (embedded script + `BUILTIN_*` tag + behavior, copying the cell's glyph so palette overrides survive). The object's `pushable`/`grab` flags are taken from the archetype's `Behavior` (so a gem becomes pushable + grabbable; pushers/spinners stay unpushable). Idempotent (cells already loaded as objects are skipped). Called by `TryFrom<MapFile> for Board` after cross-layer validation (so disk loads get working machines) and again by the editor's `playtest()` on the `World::deep_clone` (so editor-stamped machines, which `place_archetype` writes as inert terrain, also run — the playtest skips the reload path). Because it runs after explicit-object placement, builtin objects get ids after hand-placed ones (still layer-then-reading order among themselves).
|
- `expand_builtin_archetypes()` — the **single** expansion point: replaces every script-backed terrain cell (e.g. `Spinner`/`Pusher`/`Gem`, those with an `archetype_script`) with the scripted object it expands to (embedded script + `BUILTIN_*` tag + behavior, copying the cell's glyph so palette overrides survive). The object's `pushable`/`grab` flags are taken from the archetype's `Behavior` (so a gem becomes pushable + grabbable; pushers/spinners stay unpushable). Idempotent (cells already loaded as objects are skipped). Called by `TryFrom<MapFile> for Board` after cross-layer validation (so disk loads get working machines) and again by the editor's `playtest()` on the `World::deep_clone` (so editor-stamped machines, which `place_archetype` writes as inert terrain, also run — the playtest skips the reload path). Because it runs after explicit-object placement, builtin objects get ids after hand-placed ones (still layer-then-reading order among themselves).
|
||||||
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
|
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
|
||||||
@@ -63,7 +63,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
|||||||
- `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one).
|
- `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one).
|
||||||
- `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds).
|
- `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds).
|
||||||
- `Scroll { source: ObjectId, lines: Vec<ScrollLine> }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`.
|
- `Scroll { source: ObjectId, lines: Vec<ScrollLine> }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`.
|
||||||
- `GameState` — owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, `pub player_health: u32` (starts 5) and `pub player_gems: u32` (starts 0) — game-global player stats that persist across board transitions. `player_gems` is changed at runtime by the `add_gems(n)` script fn (e.g. grabbing a gem); `player_health` is still display-only. Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc<RefCell<Board>>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` + an immediate `resolve()` so its `die()`/`add_gems()` apply before the call returns (no player+object overlap). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log` → `log`, `SetTile` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player_gems`, `Die` → `remove_object(source)`). `step_object` and `Action::Push` route a grab-thing-shoved-into-the-player (via `Board::pushed_grab_into_player`) into a deferred `grab()` instead of sliding the player, collected and fired in phase B alongside `bump`s. Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object.
|
- `GameState` — owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, `pub player_health: u32` (starts 5) and `pub player_gems: u32` (starts 0) — game-global player stats that persist across board transitions. `player_gems` is changed at runtime by the `add_gems(n)` script fn (e.g. grabbing a gem); `player_health` is still display-only. Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc<RefCell<Board>>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` + an immediate `resolve()` so its `die()`/`add_gems()` apply before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log` → `log`, `SetTile` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player_gems`, `Die` → `remove_object(source)`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object.
|
||||||
|
|
||||||
**`kiln-core/src/action.rs`** — the `Action` enum and its Rhai-facing conversion:
|
**`kiln-core/src/action.rs`** — the `Action` enum and its Rhai-facing conversion:
|
||||||
- `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s).
|
- `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s).
|
||||||
@@ -80,7 +80,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
|||||||
|
|
||||||
**`kiln-core/src/script.rs`** — Rhai scripting runtime:
|
**`kiln-core/src/script.rs`** — Rhai scripting runtime:
|
||||||
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects, a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a `(key, source)` compiled once per key: the key is the object's `script_name` (a world-pool name for a named script, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins, e.g. all pushers, share one AST); the source is the pool entry for that name, or the object's embedded `builtin_script` when present. Reports compile/unknown-script failures onto the error sink; runs nothing.
|
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects, a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a `(key, source)` compiled once per key: the key is the object's `script_name` (a world-pool name for a named script, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins, e.g. all pushers, share one AST); the source is the pool entry for that name, or the object's embedded `builtin_script` when present. Reports compile/unknown-script failures onto the error sink; runs nothing.
|
||||||
- Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), and `grab()` (zero-arg; fired when the player walks onto / pushes a `grab` thing into themselves), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)` / `run_grab(object_id)`; runtime errors go to the error sink (drained to the log), not fatal.
|
- Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), and `grab()` (zero-arg; fired when the player walks onto a `grab` thing — the only grab trigger), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)` / `run_grab(object_id)`; runtime errors go to the error sink (drained to the log), not fatal.
|
||||||
- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `can_push(x, y, dir) -> bool` is a read fn mirroring `Board::can_push`: true if `(x, y)` holds a pushable whose chain can be shoved in `dir` (false off-board). `can_shift(x, y, dir) -> bool` mirrors `Board::can_shift`: like `can_push` but only checks the single cell ahead (pushable source + an empty-or-pushable next cell), the right gate for a simultaneous shift/rotation via `swap`. `passable(x, y) -> bool` mirrors `Board::is_passable`: true if `(x, y)` is on-board and holds no solid (off-board → false), letting a script tell a hole apart from a blocked solid (which `can_shift`/`can_push` alone cannot). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`.
|
- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `can_push(x, y, dir) -> bool` is a read fn mirroring `Board::can_push`: true if `(x, y)` holds a pushable whose chain can be shoved in `dir` (false off-board). `can_shift(x, y, dir) -> bool` mirrors `Board::can_shift`: like `can_push` but only checks the single cell ahead (pushable source + an empty-or-pushable next cell), the right gate for a simultaneous shift/rotation via `swap`. `passable(x, y) -> bool` mirrors `Board::is_passable`: true if `(x, y)` is on-board and holds no solid (off-board → false), letting a script tell a hole apart from a blocked solid (which `can_shift`/`can_push` alone cannot). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`.
|
||||||
- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)`, `swap(pairs)`, `add_gems(n)` (adjust the player's gem count), `die()` (remove the calling object) append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`/`Push`/`Swap`; `MOVE_COST = 0.25` s for `Move`, else 0 — `push`/`swap` add no delay) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap<usize, ObjQueue>`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `push(x, y, dir)` shoves the pushable chain at arbitrary coords `(x, y)`. `swap(pairs)` takes an array of four-int `[src_x, src_y, dst_x, dst_y]` arrays (a malformed entry is skipped + logged) and moves all the named solids simultaneously (see `Board::apply_swap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`).
|
- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)`, `swap(pairs)`, `add_gems(n)` (adjust the player's gem count), `die()` (remove the calling object) append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`/`Push`/`Swap`; `MOVE_COST = 0.25` s for `Move`, else 0 — `push`/`swap` add no delay) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap<usize, ObjQueue>`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `push(x, y, dir)` shoves the pushable chain at arbitrary coords `(x, y)`. `swap(pairs)` takes an array of four-int `[src_x, src_y, dst_x, dst_y]` arrays (a malformed entry is skipped + logged) and moves all the named solids simultaneously (see `Board::apply_swap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`).
|
||||||
- The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()`, `Queue.clear()`, `Queue.peek()` (front action as a Rhai map, or `()` if empty), and `Queue.pop()` (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during `pump`).
|
- The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()`, `Queue.clear()`, `Queue.peek()` (front action as a Rhai map, or `()` if empty), and `Queue.pop()` (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during `pump`).
|
||||||
|
|||||||
+114
-228
@@ -4,31 +4,10 @@ use crate::layer::Layer;
|
|||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use crate::utils::Direction;
|
use crate::utils::Direction;
|
||||||
use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid};
|
use crate::utils::{Behavior, ObjectId, Player, PortalDef, Pushable, RegistryValue, Solid};
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use crate::builtin_scripts::archetype_script_key;
|
use crate::builtin_scripts::archetype_script_key;
|
||||||
|
|
||||||
/// A captured solid occupant of a cell, used by [`Board::apply_swap`] to read
|
|
||||||
/// every source cell before writing any destination (so cyclic moves work).
|
|
||||||
#[derive(Clone)]
|
|
||||||
enum SolidSnapshot {
|
|
||||||
/// The player occupied the cell.
|
|
||||||
Player,
|
|
||||||
/// A solid scripted object occupied the cell, identified by its stable id.
|
|
||||||
Object(ObjectId),
|
|
||||||
/// A solid terrain cell — its visual/behavior plus the layer it lived on.
|
|
||||||
Terrain {
|
|
||||||
/// Layer the terrain lived on (restored on the destination).
|
|
||||||
z: usize,
|
|
||||||
/// The cell's glyph.
|
|
||||||
glyph: Glyph,
|
|
||||||
/// The cell's archetype.
|
|
||||||
arch: Archetype,
|
|
||||||
},
|
|
||||||
/// No solid occupant.
|
|
||||||
Empty,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The complete state of one game board (a single room or screen).
|
/// The complete state of one game board (a single room or screen).
|
||||||
///
|
///
|
||||||
/// `Board` is the central data structure of the engine, equivalent to a
|
/// `Board` is the central data structure of the engine, equivalent to a
|
||||||
@@ -208,15 +187,28 @@ impl Board {
|
|||||||
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid> {
|
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid> {
|
||||||
// The player wins its cell (load-time invariant), so it is the solid there.
|
// The player wins its cell (load-time invariant), so it is the solid there.
|
||||||
if self.player.x == x as i32 && self.player.y == y as i32 {
|
if self.player.x == x as i32 && self.player.y == y as i32 {
|
||||||
return Some(Solid::Player);
|
return Some(Solid::player_at(x, y));
|
||||||
}
|
}
|
||||||
// A solid object shadows the cell it sits on.
|
// A solid object shadows the cell it sits on; capture its behavior now.
|
||||||
if let Some(id) = self.solid_object_id_at(x, y) {
|
if let Some(id) = self.solid_object_id_at(x, y) {
|
||||||
return Some(Solid::Object(id));
|
let obj = &self.objects[&id];
|
||||||
|
let behavior = Behavior {
|
||||||
|
solid: obj.solid,
|
||||||
|
opaque: obj.opaque,
|
||||||
|
// ObjectDef stores pushability as a bool meaning "any direction".
|
||||||
|
pushable: if obj.pushable {
|
||||||
|
Pushable::Any
|
||||||
|
} else {
|
||||||
|
Pushable::No
|
||||||
|
},
|
||||||
|
grab: obj.grab,
|
||||||
|
};
|
||||||
|
return Some(Solid::object_at(x, y, id, behavior));
|
||||||
}
|
}
|
||||||
// Otherwise some layer's terrain archetype may be solid (e.g. a wall).
|
// Otherwise some layer's terrain archetype may be solid (e.g. a wall).
|
||||||
if let Some(z) = self.solid_cell_layer(x, y) {
|
if let Some(z) = self.solid_cell_layer(x, y) {
|
||||||
return Some(Solid::Cell(self.get(z, x, y).1));
|
let (glyph, arch) = *self.get(z, x, y);
|
||||||
|
return Some(Solid::terrain_at(x, y, z, glyph, arch));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -260,8 +252,7 @@ impl Board {
|
|||||||
let solid2 = solid2.unwrap();
|
let solid2 = solid2.unwrap();
|
||||||
|
|
||||||
// This is probably disallowed then, but let's check for a player coexisting with a grab:
|
// This is probably disallowed then, but let's check for a player coexisting with a grab:
|
||||||
if solid1.player() && solid2.grab(self) ||
|
if solid1.player() && solid2.grab() || solid2.player() && solid1.grab() {
|
||||||
solid2.player() && solid1.grab(self) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,12 +266,10 @@ impl Board {
|
|||||||
/// Grid archetypes may restrict the axis (see [`Pushable`]); pushable objects
|
/// Grid archetypes may restrict the axis (see [`Pushable`]); pushable objects
|
||||||
/// can be shoved in any direction.
|
/// can be shoved in any direction.
|
||||||
fn is_pushable(&self, x: usize, y: usize, dir: Direction) -> bool {
|
fn is_pushable(&self, x: usize, y: usize, dir: Direction) -> bool {
|
||||||
match self.solid_at(x, y) {
|
// The captured `pushable` is `Any` for the player and a pushable object,
|
||||||
Some(Solid::Player) => true, // the player is pushable in any direction
|
// axis-constrained for directional crates, `No` otherwise.
|
||||||
Some(Solid::Cell(a)) => a.behavior().pushable.allows(dir),
|
self.solid_at(x, y)
|
||||||
Some(Solid::Object(id)) => self.objects[&id].pushable,
|
.is_some_and(|s| s.pushable().allows(dir))
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the chain of pushable solids starting at `(x, y)` can be shoved one
|
/// Whether the chain of pushable solids starting at `(x, y)` can be shoved one
|
||||||
@@ -323,11 +312,9 @@ impl Board {
|
|||||||
/// another pushable that is itself moving the same frame. Returns `false` if
|
/// another pushable that is itself moving the same frame. Returns `false` if
|
||||||
/// `(x, y)` holds no pushable, or the cell ahead runs off the board.
|
/// `(x, y)` holds no pushable, or the cell ahead runs off the board.
|
||||||
///
|
///
|
||||||
/// Shifting onto the **player** is allowed only when the source is a
|
/// The **player** is always a blocker: a shift can't relocate the player, and
|
||||||
/// [`grab`](crate::object_def::ObjectDef::grab) thing: it isn't shoving the
|
/// `apply_swap` refuses to overwrite it, so a cell holding the player is never
|
||||||
/// player aside (a shift can't relocate the player, and `apply_swap` would
|
/// an acceptable shift destination.
|
||||||
/// refuse to overwrite it), it's being grabbed — the player stays put and the
|
|
||||||
/// thing despawns. Any other solid treats the player as a blocker.
|
|
||||||
pub fn can_shift(&self, x: usize, y: usize, dir: Direction) -> bool {
|
pub fn can_shift(&self, x: usize, y: usize, dir: Direction) -> bool {
|
||||||
// The source must hold a solid pushable in `dir`.
|
// The source must hold a solid pushable in `dir`.
|
||||||
if !self.is_pushable(x, y, dir) {
|
if !self.is_pushable(x, y, dir) {
|
||||||
@@ -339,10 +326,11 @@ impl Board {
|
|||||||
return false; // nothing to shift into off the board
|
return false; // nothing to shift into off the board
|
||||||
}
|
}
|
||||||
let (nx, ny) = (next.0 as usize, next.1 as usize);
|
let (nx, ny) = (next.0 as usize, next.1 as usize);
|
||||||
// Shifting onto the player is only legitimate for a grab thing (it gets
|
// The player always blocks a shift: a shift can't relocate it and
|
||||||
// grabbed, the player isn't moved); otherwise the player is a blocker.
|
// `apply_swap` refuses to overwrite it. (The player reads as pushable, so it
|
||||||
if matches!(self.solid_at(nx, ny), Some(Solid::Player)) {
|
// must be excluded explicitly before the cell-ahead test below.)
|
||||||
return self.grab_object_at(x, y).is_some();
|
if self.solid_at(nx, ny).is_some_and(|s| s.player()) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
// The cell ahead is acceptable if it is empty or another pushable solid.
|
// The cell ahead is acceptable if it is empty or another pushable solid.
|
||||||
self.is_passable(nx, ny) || self.is_pushable(nx, ny, dir)
|
self.is_passable(nx, ny) || self.is_pushable(nx, ny, dir)
|
||||||
@@ -381,19 +369,16 @@ impl Board {
|
|||||||
/// The caller guarantees the destination is already clear.
|
/// The caller guarantees the destination is already clear.
|
||||||
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
|
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
|
||||||
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
|
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
|
||||||
// The player owns its cell, so move it before considering objects/terrain.
|
let Some(solid) = self.solid_at(x, y) else {
|
||||||
if self.player.x == x as i32 && self.player.y == y as i32 {
|
return; // nothing to shift
|
||||||
self.player.x = tx as i32;
|
};
|
||||||
self.player.y = ty as i32;
|
// A terrain cell leaves a transparent cell behind (revealing any floor); the
|
||||||
} else if let Some(id) = self.solid_object_id_at(x, y) {
|
// player and objects carry no grid cell, so there is nothing to vacate. `place`
|
||||||
let obj = self.objects.get_mut(&id).expect("id from object_id_at");
|
// captured the glyph/arch, so clearing the source first is safe.
|
||||||
obj.x = tx;
|
if let Some(z) = self.solid_cell_layer(x, y) {
|
||||||
obj.y = ty;
|
|
||||||
} else if let Some(z) = self.solid_cell_layer(x, y) {
|
|
||||||
let moved = *self.get(z, x, y);
|
|
||||||
*self.get_mut(z, tx, ty) = moved;
|
|
||||||
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
|
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
|
||||||
}
|
}
|
||||||
|
solid.place(self, tx, ty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the [`ObjectId`]s of the objects at `(x, y)`, if any.
|
/// Returns the [`ObjectId`]s of the objects at `(x, y)`, if any.
|
||||||
@@ -431,35 +416,6 @@ impl Board {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the [`ObjectId`] of a **grab** object that a push at `(x, y)` in
|
|
||||||
/// `dir` would shove into the player, if any.
|
|
||||||
///
|
|
||||||
/// Walks the pushable chain from `(x, y)`; if a chain cell holds a grab object
|
|
||||||
/// whose immediate forward neighbour is the player, that object is returned.
|
|
||||||
/// This is the "grab thing pushed into the player" case: rather than sliding
|
|
||||||
/// the player along, the caller grabs the thing (see
|
|
||||||
/// [`GameState`](crate::game::GameState)). Returns `None` for an ordinary push.
|
|
||||||
pub fn pushed_grab_into_player(&self, x: usize, y: usize, dir: Direction) -> Option<ObjectId> {
|
|
||||||
let (dx, dy): (i32, i32) = dir.into();
|
|
||||||
let (mut cx, mut cy) = (x, y);
|
|
||||||
// Advance through the contiguous run of pushable solids.
|
|
||||||
while self.is_pushable(cx, cy, dir) {
|
|
||||||
let next = (cx as i32 + dx, cy as i32 + dy);
|
|
||||||
if !self.in_bounds(next) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let (nx, ny) = (next.0 as usize, next.1 as usize);
|
|
||||||
// If the next cell is the player and this cell is a grab object, the
|
|
||||||
// push would drive that grab thing into the player: grab it instead.
|
|
||||||
if matches!(self.solid_at(nx, ny), Some(Solid::Player)) {
|
|
||||||
return self.grab_object_at(cx, cy);
|
|
||||||
}
|
|
||||||
cx = nx;
|
|
||||||
cy = ny;
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id.
|
/// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id.
|
||||||
///
|
///
|
||||||
/// Ids start at 1 and increase monotonically; an id is never reused, so it
|
/// Ids start at 1 and increase monotonically; an id is never reused, so it
|
||||||
@@ -585,20 +541,12 @@ impl Board {
|
|||||||
/// a write that would overwrite the player without relocating it is skipped and
|
/// a write that would overwrite the player without relocating it is skipped and
|
||||||
/// logged (the player wins its cell, per the one-solid-per-cell invariant).
|
/// logged (the player wins its cell, per the one-solid-per-cell invariant).
|
||||||
///
|
///
|
||||||
/// **Grab:** a [`grab`](crate::object_def::ObjectDef::grab) object whose
|
/// A solid refused onto the player is left at its source cell, which another
|
||||||
/// destination is the player isn't written onto the player — it is left where it
|
/// entry may also target; a final sweep over the swapped cells resolves any
|
||||||
/// is and its id is returned in the second tuple element so the caller can fire
|
/// such **overlap** — it keeps one solid, deletes the rest (never the player),
|
||||||
/// its `grab()` hook (it gets grabbed, the player isn't moved). Because a
|
/// and logs an error per deletion.
|
||||||
/// grabbed object stays put (and its `grab()` may not despawn it), it can end up
|
pub fn apply_swap(&mut self, pairs: &[(i32, i32, i32, i32)]) -> Vec<LogLine> {
|
||||||
/// sharing a cell with a solid that moved in; a final sweep over the swapped
|
|
||||||
/// cells resolves any such **overlap** — it keeps one solid (preferring a
|
|
||||||
/// grabbed object so its `grab()` can still fire), deletes the rest (never the
|
|
||||||
/// player), and logs an error per deletion.
|
|
||||||
pub fn apply_swap(&mut self, pairs: &[(i32, i32, i32, i32)]) -> (Vec<LogLine>, Vec<ObjectId>) {
|
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
// Grab objects whose destination was the player: left in place here, their
|
|
||||||
// grab() hooks fired by the caller (GameState).
|
|
||||||
let mut grabbed: Vec<ObjectId> = Vec::new();
|
|
||||||
|
|
||||||
// 1. Validate: keep only entries whose source and destination are in bounds.
|
// 1. Validate: keep only entries whose source and destination are in bounds.
|
||||||
let mut valid: Vec<((i32, i32), (i32, i32))> = Vec::new();
|
let mut valid: Vec<((i32, i32), (i32, i32))> = Vec::new();
|
||||||
@@ -613,26 +561,27 @@ impl Board {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Snapshot the solid at each unique source (read phase). Reading every
|
// 2. Snapshot the solid at each unique source (read phase). Reading every
|
||||||
// source before any write is what lets cycles/swaps resolve.
|
// source before any write is what lets cycles/swaps resolve. `None` is an
|
||||||
let mut snapshots: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
|
// empty source (the old `SolidSnapshot::Empty`).
|
||||||
|
let mut snapshots: HashMap<(i32, i32), Option<Solid>> = HashMap::new();
|
||||||
for &(src, _) in &valid {
|
for &(src, _) in &valid {
|
||||||
snapshots
|
snapshots.entry(src).or_insert_with(|| {
|
||||||
.entry(src)
|
self.solid_at(src.0 as usize, src.1 as usize)
|
||||||
.or_insert_with(|| self.snapshot_solid(src));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Compute the final occupant of every affected cell. A source that is not
|
// 3. Compute the final occupant of every affected cell. A source that is not
|
||||||
// anyone's destination is vacated (Empty); each entry writes its source's
|
// anyone's destination is vacated (None); each entry writes its source's
|
||||||
// snapshot into its destination (a later entry wins a repeated destination).
|
// snapshot into its destination (a later entry wins a repeated destination).
|
||||||
let dsts: HashSet<(i32, i32)> = valid.iter().map(|&(_, d)| d).collect();
|
let dsts: HashSet<(i32, i32)> = valid.iter().map(|&(_, d)| d).collect();
|
||||||
let mut final_state: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
|
let mut final_state: HashMap<(i32, i32), Option<Solid>> = HashMap::new();
|
||||||
for &(src, _) in &valid {
|
for &(src, _) in &valid {
|
||||||
if !dsts.contains(&src) {
|
if !dsts.contains(&src) {
|
||||||
final_state.insert(src, SolidSnapshot::Empty);
|
final_state.insert(src, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for &(src, dst) in &valid {
|
for &(src, dst) in &valid {
|
||||||
final_state.insert(dst, snapshots[&src].clone());
|
final_state.insert(dst, snapshots[&src]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The player's final cell: where a Player snapshot is installed, else its
|
// The player's final cell: where a Player snapshot is installed, else its
|
||||||
@@ -640,7 +589,7 @@ impl Board {
|
|||||||
// overwritten — computed up front so it's stable across the write loop.
|
// overwritten — computed up front so it's stable across the write loop.
|
||||||
let player_final = final_state
|
let player_final = final_state
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, s)| matches!(s, SolidSnapshot::Player))
|
.find(|(_, s)| s.is_some_and(|s| s.player()))
|
||||||
.map(|(&c, _)| c)
|
.map(|(&c, _)| c)
|
||||||
.unwrap_or((self.player.x, self.player.y));
|
.unwrap_or((self.player.x, self.player.y));
|
||||||
|
|
||||||
@@ -649,10 +598,7 @@ impl Board {
|
|||||||
// objects keep their entity and are repositioned by the install step.
|
// objects keep their entity and are repositioned by the install step.
|
||||||
let survivors: HashSet<ObjectId> = final_state
|
let survivors: HashSet<ObjectId> = final_state
|
||||||
.values()
|
.values()
|
||||||
.filter_map(|s| match s {
|
.filter_map(|s| s.and_then(|s| s.object_id()))
|
||||||
SolidSnapshot::Object(id) => Some(*id),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
let affected: HashSet<(i32, i32)> = snapshots
|
let affected: HashSet<(i32, i32)> = snapshots
|
||||||
.keys()
|
.keys()
|
||||||
@@ -662,10 +608,10 @@ impl Board {
|
|||||||
for &(cx, cy) in &affected {
|
for &(cx, cy) in &affected {
|
||||||
let (ux, uy) = (cx as usize, cy as usize);
|
let (ux, uy) = (cx as usize, cy as usize);
|
||||||
match self.solid_at(ux, uy) {
|
match self.solid_at(ux, uy) {
|
||||||
Some(Solid::Object(id)) if !survivors.contains(&id) => {
|
Some(s) if s.object_id().is_some_and(|id| !survivors.contains(&id)) => {
|
||||||
self.remove_object(id);
|
self.remove_object(s.object_id().unwrap());
|
||||||
}
|
}
|
||||||
Some(Solid::Cell(_)) => {
|
Some(s) if s.archetype().is_some() => {
|
||||||
if let Some(z) = self.solid_cell_layer(ux, uy) {
|
if let Some(z) = self.solid_cell_layer(ux, uy) {
|
||||||
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty);
|
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty);
|
||||||
}
|
}
|
||||||
@@ -677,58 +623,36 @@ impl Board {
|
|||||||
// 4b. Install each cell's computed occupant.
|
// 4b. Install each cell's computed occupant.
|
||||||
for (&(cx, cy), snap) in &final_state {
|
for (&(cx, cy), snap) in &final_state {
|
||||||
let (ux, uy) = (cx as usize, cy as usize);
|
let (ux, uy) = (cx as usize, cy as usize);
|
||||||
|
// Empty cells were already cleared in phase 4a.
|
||||||
|
let Some(solid) = snap else { continue };
|
||||||
// The player wins its cell: never overwrite player_final with anything
|
// The player wins its cell: never overwrite player_final with anything
|
||||||
// other than the player itself.
|
// other than the player itself. A refused solid stays at its source
|
||||||
if (cx, cy) == player_final && !matches!(snap, SolidSnapshot::Player) {
|
// cell; the overlap sweep below cleans up if that cell is now also
|
||||||
// A grab object shoved onto the player is grabbed, not blocked: leave
|
// someone else's destination.
|
||||||
// it where it is (don't install onto the player) and report it so the
|
if (cx, cy) == player_final && !solid.player() {
|
||||||
// caller fires its grab() hook. The overlap sweep below cleans up if
|
|
||||||
// its source cell is now also someone else's destination.
|
|
||||||
if let SolidSnapshot::Object(id) = snap
|
|
||||||
&& self.objects.get(id).is_some_and(|o| o.grab)
|
|
||||||
{
|
|
||||||
grabbed.push(*id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
errors.push(LogLine::error(format!(
|
errors.push(LogLine::error(format!(
|
||||||
"swap: cannot overwrite the player at ({cx},{cy})"
|
"swap: cannot overwrite the player at ({cx},{cy})"
|
||||||
)));
|
)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match snap {
|
solid.place(self, ux, uy);
|
||||||
SolidSnapshot::Empty => {} // already cleared
|
|
||||||
SolidSnapshot::Terrain { z, glyph, arch } => {
|
|
||||||
*self.get_mut(*z, ux, uy) = (*glyph, *arch);
|
|
||||||
}
|
|
||||||
SolidSnapshot::Object(id) => {
|
|
||||||
if let Some(obj) = self.objects.get_mut(id) {
|
|
||||||
obj.x = ux;
|
|
||||||
obj.y = uy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SolidSnapshot::Player => {
|
|
||||||
self.player.x = cx;
|
|
||||||
self.player.y = cy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Overlap sweep: a grabbed object left in place (above) may now share a
|
// 5. Overlap sweep: a solid refused onto the player (above) stays at its
|
||||||
// cell with a solid that moved in. For any swapped cell holding more than
|
// source cell, which may now also be a destination another entry wrote
|
||||||
// one solid, keep a single occupant — a grabbed object if present (so its
|
// into. For any swapped cell holding more than one solid, keep a single
|
||||||
// grab() can still fire), otherwise whatever remains — and delete the rest
|
// occupant and delete the rest (never the player). Logs an error per
|
||||||
// (never the player). Logs an error per deletion.
|
// deletion.
|
||||||
for &(cx, cy) in &affected {
|
for &(cx, cy) in &affected {
|
||||||
let (ux, uy) = (cx as usize, cy as usize);
|
let (ux, uy) = (cx as usize, cy as usize);
|
||||||
let player_here = self.player.x == cx && self.player.y == cy;
|
let player_here = self.player.x == cx && self.player.y == cy;
|
||||||
// Solid objects on this cell, with grabbed ones first so we keep one.
|
// Solid objects on this cell; keep the first, delete the rest.
|
||||||
let mut objs: Vec<ObjectId> = self
|
let objs: Vec<ObjectId> = self
|
||||||
.objects
|
.objects
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, o)| o.x == ux && o.y == uy && o.solid)
|
.filter(|(_, o)| o.x == ux && o.y == uy && o.solid)
|
||||||
.map(|(&id, _)| id)
|
.map(|(&id, _)| id)
|
||||||
.collect();
|
.collect();
|
||||||
objs.sort_by_key(|id| !grabbed.contains(id));
|
|
||||||
let has_terrain = self.solid_cell_layer(ux, uy).is_some();
|
let has_terrain = self.solid_cell_layer(ux, uy).is_some();
|
||||||
|
|
||||||
// How many solids share the cell (player + solid objects + solid terrain).
|
// How many solids share the cell (player + solid objects + solid terrain).
|
||||||
@@ -740,9 +664,9 @@ impl Board {
|
|||||||
"swap: {total} solids overlap at ({cx},{cy}); deleting extras"
|
"swap: {total} solids overlap at ({cx},{cy}); deleting extras"
|
||||||
)));
|
)));
|
||||||
|
|
||||||
// Pick the survivor (priority: player > grabbed/first object > terrain)
|
// Pick the survivor (priority: player > first object > terrain) and
|
||||||
// and delete every other solid. The player is only ever a survivor, so
|
// delete every other solid. The player is only ever a survivor, so it
|
||||||
// it is never deleted. `keep_obj` is the object we keep, if any.
|
// is never deleted. `keep_obj` is the object we keep, if any.
|
||||||
let keep_obj = (!player_here).then(|| objs.first().copied()).flatten();
|
let keep_obj = (!player_here).then(|| objs.first().copied()).flatten();
|
||||||
for id in &objs {
|
for id in &objs {
|
||||||
if Some(*id) != keep_obj {
|
if Some(*id) != keep_obj {
|
||||||
@@ -758,28 +682,7 @@ impl Board {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(errors, grabbed)
|
errors
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the solid occupant of `(x, y)` into a [`SolidSnapshot`] (for
|
|
||||||
/// [`apply_swap`](Board::apply_swap)). Coordinates must be in bounds.
|
|
||||||
fn snapshot_solid(&self, (x, y): (i32, i32)) -> SolidSnapshot {
|
|
||||||
let (ux, uy) = (x as usize, y as usize);
|
|
||||||
match self.solid_at(ux, uy) {
|
|
||||||
Some(Solid::Player) => SolidSnapshot::Player,
|
|
||||||
Some(Solid::Object(id)) => SolidSnapshot::Object(id),
|
|
||||||
Some(Solid::Cell(arch)) => {
|
|
||||||
let z = self
|
|
||||||
.solid_cell_layer(ux, uy)
|
|
||||||
.expect("a solid terrain cell has a layer");
|
|
||||||
SolidSnapshot::Terrain {
|
|
||||||
z,
|
|
||||||
glyph: self.get(z, ux, uy).0,
|
|
||||||
arch,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => SolidSnapshot::Empty,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty`
|
/// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty`
|
||||||
@@ -798,7 +701,7 @@ pub(crate) mod tests {
|
|||||||
use crate::layer::Layer;
|
use crate::layer::Layer;
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use crate::utils::Direction;
|
use crate::utils::Direction;
|
||||||
use crate::utils::{ObjectId, Player, Solid};
|
use crate::utils::{ObjectId, Player};
|
||||||
use color::Rgba8;
|
use color::Rgba8;
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
@@ -888,23 +791,18 @@ pub(crate) mod tests {
|
|||||||
assert!(board.solid_at(0, 0).is_none());
|
assert!(board.solid_at(0, 0).is_none());
|
||||||
assert!(board.is_passable(0, 0));
|
assert!(board.is_passable(0, 0));
|
||||||
|
|
||||||
match board.solid_at(1, 0) {
|
let wall = board.solid_at(1, 0).expect("a wall is solid");
|
||||||
Some(Solid::Cell(Archetype::Wall)) => {}
|
assert_eq!(wall.archetype(), Some(Archetype::Wall));
|
||||||
other => panic!("expected Solid::Cell(Wall), got {:?}", other.is_some()),
|
|
||||||
}
|
|
||||||
assert!(!board.is_passable(1, 0));
|
assert!(!board.is_passable(1, 0));
|
||||||
|
|
||||||
match board.solid_at(2, 0) {
|
let obj = board.solid_at(2, 0).expect("an object is solid");
|
||||||
Some(Solid::Object(id)) => {
|
let id = obj.object_id().expect("expected a solid object");
|
||||||
assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0))
|
assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0));
|
||||||
}
|
|
||||||
_ => panic!("expected Solid::Object"),
|
|
||||||
}
|
|
||||||
assert!(!board.is_passable(2, 0));
|
assert!(!board.is_passable(2, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn grab_helpers_detect_a_gem() {
|
fn grab_object_at_detects_a_gem() {
|
||||||
// A grabbable gem object at (1,0); the player at (2,0).
|
// A grabbable gem object at (1,0); the player at (2,0).
|
||||||
let mut gem = ObjectDef::new(1, 0);
|
let mut gem = ObjectDef::new(1, 0);
|
||||||
gem.grab = true;
|
gem.grab = true;
|
||||||
@@ -914,14 +812,6 @@ pub(crate) mod tests {
|
|||||||
// grab_object_at finds the gem on its own cell, nowhere else.
|
// grab_object_at finds the gem on its own cell, nowhere else.
|
||||||
assert_eq!(board.grab_object_at(1, 0), Some(1));
|
assert_eq!(board.grab_object_at(1, 0), Some(1));
|
||||||
assert_eq!(board.grab_object_at(0, 0), None);
|
assert_eq!(board.grab_object_at(0, 0), None);
|
||||||
|
|
||||||
// Pushing the gem east shoves it into the player → reported as a grab.
|
|
||||||
assert_eq!(
|
|
||||||
board.pushed_grab_into_player(1, 0, Direction::East),
|
|
||||||
Some(1)
|
|
||||||
);
|
|
||||||
// Pushing it west (away from the player) is an ordinary push.
|
|
||||||
assert_eq!(board.pushed_grab_into_player(1, 0, Direction::West), None);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -936,10 +826,10 @@ pub(crate) mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn solid_at_reports_player() {
|
fn solid_at_reports_player() {
|
||||||
let board = open_board(3, 1, (1, 0), vec![]);
|
let board = open_board(3, 1, (1, 0), vec![]);
|
||||||
match board.solid_at(1, 0) {
|
assert!(
|
||||||
Some(Solid::Player) => {}
|
board.solid_at(1, 0).is_some_and(|s| s.player()),
|
||||||
_ => panic!("expected Solid::Player at the player's cell"),
|
"expected the player at its own cell"
|
||||||
}
|
);
|
||||||
assert!(!board.is_passable(1, 0));
|
assert!(!board.is_passable(1, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,16 +891,16 @@ pub(crate) mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn can_shift_into_player_only_for_a_grab_thing() {
|
fn can_shift_treats_the_player_as_a_blocker() {
|
||||||
// A grab gem at (0,0) may shift east onto the player at (1,0): it gets
|
// The player is always a blocker for a shift — even a grab gem may not shift
|
||||||
// grabbed rather than blocked.
|
// onto it (grab now fires only on player movement, not on being shifted in).
|
||||||
let mut gem = ObjectDef::new(0, 0);
|
let mut gem = ObjectDef::new(0, 0);
|
||||||
gem.grab = true;
|
gem.grab = true;
|
||||||
gem.pushable = true;
|
gem.pushable = true;
|
||||||
let board = open_board(2, 1, (1, 0), vec![gem]);
|
let board = open_board(2, 1, (1, 0), vec![gem]);
|
||||||
assert!(board.can_shift(0, 0, Direction::East));
|
assert!(!board.can_shift(0, 0, Direction::East));
|
||||||
|
|
||||||
// A plain crate may not shift onto the player — the player blocks it.
|
// A plain crate likewise may not shift onto the player.
|
||||||
let mut board = open_board(2, 1, (1, 0), vec![]);
|
let mut board = open_board(2, 1, (1, 0), vec![]);
|
||||||
crate_at(&mut board, 0, 0);
|
crate_at(&mut board, 0, 0);
|
||||||
assert!(!board.can_shift(0, 0, Direction::East));
|
assert!(!board.can_shift(0, 0, Direction::East));
|
||||||
@@ -1192,7 +1082,7 @@ pub(crate) mod tests {
|
|||||||
let mut board = open_board(3, 1, (1, 0), vec![]);
|
let mut board = open_board(3, 1, (1, 0), vec![]);
|
||||||
crate_at(&mut board, 0, 0);
|
crate_at(&mut board, 0, 0);
|
||||||
wall_at(&mut board, 2, 0);
|
wall_at(&mut board, 2, 0);
|
||||||
let (errs, _) = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
|
let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
|
||||||
assert!(errs.is_empty());
|
assert!(errs.is_empty());
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Wall);
|
assert_eq!(board.get(0, 0, 0).1, Archetype::Wall);
|
||||||
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
|
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
|
||||||
@@ -1206,7 +1096,7 @@ pub(crate) mod tests {
|
|||||||
// a = (0,0) empty, b = (1,0) crate, c = (2,0) wall.
|
// a = (0,0) empty, b = (1,0) crate, c = (2,0) wall.
|
||||||
crate_at(&mut board, 1, 0);
|
crate_at(&mut board, 1, 0);
|
||||||
wall_at(&mut board, 2, 0);
|
wall_at(&mut board, 2, 0);
|
||||||
let (errs, _) = board.apply_swap(&[(0, 0, 1, 0), (1, 0, 2, 0)]);
|
let errs = board.apply_swap(&[(0, 0, 1, 0), (1, 0, 2, 0)]);
|
||||||
assert!(errs.is_empty());
|
assert!(errs.is_empty());
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
|
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
|
||||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
|
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
|
||||||
@@ -1217,7 +1107,7 @@ pub(crate) mod tests {
|
|||||||
fn apply_swap_empty_onto_object_removes_it() {
|
fn apply_swap_empty_onto_object_removes_it() {
|
||||||
// Moving an empty source onto an object despawns the object.
|
// Moving an empty source onto an object despawns the object.
|
||||||
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
|
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
|
||||||
let (errs, _) = board.apply_swap(&[(0, 0, 1, 0)]);
|
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
|
||||||
assert!(errs.is_empty());
|
assert!(errs.is_empty());
|
||||||
assert!(board.solid_object_id_at(1, 0).is_none());
|
assert!(board.solid_object_id_at(1, 0).is_none());
|
||||||
assert!(board.objects.is_empty());
|
assert!(board.objects.is_empty());
|
||||||
@@ -1229,7 +1119,7 @@ pub(crate) mod tests {
|
|||||||
let mut board = open_board(3, 1, (2, 0), vec![]);
|
let mut board = open_board(3, 1, (2, 0), vec![]);
|
||||||
crate_at(&mut board, 0, 0);
|
crate_at(&mut board, 0, 0);
|
||||||
wall_at(&mut board, 1, 0);
|
wall_at(&mut board, 1, 0);
|
||||||
let (errs, _) = board.apply_swap(&[(0, 0, 1, 0)]);
|
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
|
||||||
assert!(errs.is_empty());
|
assert!(errs.is_empty());
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
|
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
|
||||||
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
|
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
|
||||||
@@ -1239,7 +1129,7 @@ pub(crate) mod tests {
|
|||||||
fn apply_swap_moves_object_and_player() {
|
fn apply_swap_moves_object_and_player() {
|
||||||
// An object and the player relocate (swap places) in one batch.
|
// An object and the player relocate (swap places) in one batch.
|
||||||
let mut board = open_board(3, 1, (0, 0), vec![ObjectDef::new(2, 0)]);
|
let mut board = open_board(3, 1, (0, 0), vec![ObjectDef::new(2, 0)]);
|
||||||
let (errs, _) = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
|
let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
|
||||||
assert!(errs.is_empty());
|
assert!(errs.is_empty());
|
||||||
assert_eq!((board.player.x, board.player.y), (2, 0));
|
assert_eq!((board.player.x, board.player.y), (2, 0));
|
||||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
|
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
|
||||||
@@ -1249,7 +1139,7 @@ pub(crate) mod tests {
|
|||||||
fn apply_swap_out_of_bounds_skips_and_logs() {
|
fn apply_swap_out_of_bounds_skips_and_logs() {
|
||||||
let mut board = open_board(3, 1, (2, 0), vec![]);
|
let mut board = open_board(3, 1, (2, 0), vec![]);
|
||||||
crate_at(&mut board, 0, 0);
|
crate_at(&mut board, 0, 0);
|
||||||
let (errs, _) = board.apply_swap(&[(0, 0, 9, 0)]);
|
let errs = board.apply_swap(&[(0, 0, 9, 0)]);
|
||||||
assert_eq!(errs.len(), 1);
|
assert_eq!(errs.len(), 1);
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged
|
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged
|
||||||
}
|
}
|
||||||
@@ -1259,41 +1149,37 @@ pub(crate) mod tests {
|
|||||||
// A crate moved onto the (non-relocating) player is rejected and logged.
|
// A crate moved onto the (non-relocating) player is rejected and logged.
|
||||||
let mut board = open_board(3, 1, (1, 0), vec![]);
|
let mut board = open_board(3, 1, (1, 0), vec![]);
|
||||||
crate_at(&mut board, 0, 0);
|
crate_at(&mut board, 0, 0);
|
||||||
let (errs, _) = board.apply_swap(&[(0, 0, 1, 0)]);
|
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
|
||||||
assert_eq!(errs.len(), 1);
|
assert_eq!(errs.len(), 1);
|
||||||
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
|
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // source still vacated
|
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // source still vacated
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_swap_grab_onto_player_is_reported_not_moved() {
|
fn apply_swap_grab_onto_player_is_refused_like_any_solid() {
|
||||||
// A grab gem swapped onto the player is reported (for its grab() hook) and
|
// A grab gem swapped onto the player is no longer special: it is refused and
|
||||||
// left at its source rather than overwriting / sliding the player.
|
// logged (the player wins its cell), and left at its source. Grab fires only
|
||||||
|
// when the player walks onto a grab thing, not when one is swapped in.
|
||||||
let mut gem = ObjectDef::new(0, 0);
|
let mut gem = ObjectDef::new(0, 0);
|
||||||
gem.grab = true;
|
gem.grab = true;
|
||||||
gem.pushable = true;
|
gem.pushable = true;
|
||||||
let mut board = open_board(2, 1, (1, 0), vec![gem]);
|
let mut board = open_board(2, 1, (1, 0), vec![gem]);
|
||||||
let (errs, grabbed) = board.apply_swap(&[(0, 0, 1, 0)]);
|
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
|
||||||
assert!(errs.is_empty());
|
assert_eq!(errs.len(), 1); // refusal logged
|
||||||
assert_eq!(grabbed, vec![1]);
|
|
||||||
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
|
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
|
||||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // gem stayed
|
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // gem stayed
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_swap_overlap_after_grab_deletes_the_other_solid() {
|
fn apply_swap_overlap_after_refused_player_write_deletes_the_other_solid() {
|
||||||
// gem (0,0)→player (1,0) [grabbed, stays at (0,0)] while a crate (2,0)→(0,0)
|
// object (0,0)→player (1,0) [refused, stays at (0,0)] while a crate (2,0)→(0,0)
|
||||||
// moves into the gem's cell. The sweep keeps the grabbed gem and deletes the
|
// moves into the object's cell. The sweep keeps the object and deletes the
|
||||||
// crate, logging the overlap.
|
// crate; one error logs the refusal, one logs the overlap.
|
||||||
let mut gem = ObjectDef::new(0, 0);
|
let mut board = open_board(3, 1, (1, 0), vec![ObjectDef::new(0, 0)]);
|
||||||
gem.grab = true;
|
|
||||||
gem.pushable = true;
|
|
||||||
let mut board = open_board(3, 1, (1, 0), vec![gem]);
|
|
||||||
crate_at(&mut board, 2, 0);
|
crate_at(&mut board, 2, 0);
|
||||||
let (errs, grabbed) = board.apply_swap(&[(0, 0, 1, 0), (2, 0, 0, 0)]);
|
let errs = board.apply_swap(&[(0, 0, 1, 0), (2, 0, 0, 0)]);
|
||||||
assert_eq!(grabbed, vec![1]);
|
assert_eq!(errs.len(), 2); // the refusal and the overlap were logged
|
||||||
assert_eq!(errs.len(), 1); // the overlap was logged
|
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // object kept
|
||||||
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // gem kept
|
|
||||||
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // crate deleted
|
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // crate deleted
|
||||||
assert_eq!((board.player.x, board.player.y), (1, 0)); // player untouched
|
assert_eq!((board.player.x, board.player.y), (1, 0)); // player untouched
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-100
@@ -2,7 +2,7 @@ use crate::action::Action;
|
|||||||
use crate::board::Board;
|
use crate::board::Board;
|
||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use crate::script::ScriptHost;
|
use crate::script::ScriptHost;
|
||||||
use crate::utils::{Direction, ObjectId, Player, ScriptArg, Solid};
|
use crate::utils::{Direction, ObjectId, Player, ScriptArg};
|
||||||
use crate::world::World;
|
use crate::world::World;
|
||||||
use std::cell::{Ref, RefMut};
|
use std::cell::{Ref, RefMut};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -210,9 +210,6 @@ impl GameState {
|
|||||||
// below also borrows `self`.
|
// below also borrows `self`.
|
||||||
let mut logs: Vec<LogLine> = Vec::new();
|
let mut logs: Vec<LogLine> = Vec::new();
|
||||||
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
|
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
|
||||||
// Grab hooks to fire (player walked into / pushed a grab thing); fired after
|
|
||||||
// the board borrow drops, like bumps.
|
|
||||||
let mut grabs: Vec<ObjectId> = Vec::new();
|
|
||||||
// Net change to the player's gem count from AddGems actions; applied to
|
// Net change to the player's gem count from AddGems actions; applied to
|
||||||
// `self` after the board borrow drops.
|
// `self` after the board borrow drops.
|
||||||
let mut gem_delta: i64 = 0;
|
let mut gem_delta: i64 = 0;
|
||||||
@@ -225,13 +222,9 @@ impl GameState {
|
|||||||
for ba in actions {
|
for ba in actions {
|
||||||
match ba.action {
|
match ba.action {
|
||||||
Action::Move(dir) => {
|
Action::Move(dir) => {
|
||||||
let outcome = step_object(&mut board, ba.source, dir);
|
if let Some(bumped) = step_object(&mut board, ba.source, dir) {
|
||||||
if let Some(bumped) = outcome.bumped {
|
|
||||||
bumps.push((bumped, ba.source as i64));
|
bumps.push((bumped, ba.source as i64));
|
||||||
}
|
}
|
||||||
if let Some(grabbed) = outcome.grabbed {
|
|
||||||
grabs.push(grabbed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Action::SetTile(tile) => {
|
Action::SetTile(tile) => {
|
||||||
if let Some(obj) = board.objects.get_mut(&ba.source) {
|
if let Some(obj) = board.objects.get_mut(&ba.source) {
|
||||||
@@ -311,23 +304,14 @@ impl GameState {
|
|||||||
Action::Push { x, y, dir } => {
|
Action::Push { x, y, dir } => {
|
||||||
if !board.in_bounds((x, y)) {
|
if !board.in_bounds((x, y)) {
|
||||||
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
|
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
|
||||||
} else if let Some(grabbed) =
|
|
||||||
board.pushed_grab_into_player(x as usize, y as usize, dir)
|
|
||||||
{
|
|
||||||
// A grab thing shoved into the player is grabbed, not pushed.
|
|
||||||
grabs.push(grabbed);
|
|
||||||
} else {
|
} else {
|
||||||
board.push(x as usize, y as usize, dir);
|
board.push(x as usize, y as usize, dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// apply_swap reads all sources then writes all destinations, so a
|
// apply_swap reads all sources then writes all destinations, so a
|
||||||
// whole batch of moves resolves simultaneously (cycles included).
|
// whole batch of moves resolves simultaneously (cycles included).
|
||||||
// A grab thing swapped onto the player is reported back for its
|
|
||||||
// grab() hook (fired in phase B with the other grabs).
|
|
||||||
Action::Swap(pairs) => {
|
Action::Swap(pairs) => {
|
||||||
let (errs, grabbed) = board.apply_swap(&pairs);
|
logs.extend(board.apply_swap(&pairs));
|
||||||
logs.extend(errs);
|
|
||||||
grabs.extend(grabbed);
|
|
||||||
}
|
}
|
||||||
// Accumulated and applied to `self.player_gems` after the borrow drops.
|
// Accumulated and applied to `self.player_gems` after the borrow drops.
|
||||||
Action::AddGems(n) => gem_delta += n,
|
Action::AddGems(n) => gem_delta += n,
|
||||||
@@ -355,11 +339,6 @@ impl GameState {
|
|||||||
for (bumped, bumper) in bumps {
|
for (bumped, bumper) in bumps {
|
||||||
self.scripts.run_bump(bumped, bumper);
|
self.scripts.run_bump(bumped, bumper);
|
||||||
}
|
}
|
||||||
// Fire grab() hooks (player walked into / pushed a grab thing). The hook
|
|
||||||
// typically adds a stat and calls die(); those actions resolve next pass.
|
|
||||||
for grabbed in grabs {
|
|
||||||
self.scripts.run_grab(grabbed);
|
|
||||||
}
|
|
||||||
for (target, fn_name, arg) in sends {
|
for (target, fn_name, arg) in sends {
|
||||||
self.scripts.run_send(target, &fn_name, arg);
|
self.scripts.run_send(target, &fn_name, arg);
|
||||||
}
|
}
|
||||||
@@ -454,10 +433,10 @@ impl GameState {
|
|||||||
grabbed = board.grab_object_at(nx, ny);
|
grabbed = board.grab_object_at(nx, ny);
|
||||||
// A solid object in the way is bumped by the player (id -1) — but a
|
// A solid object in the way is bumped by the player (id -1) — but a
|
||||||
// grab thing fires grab() instead of bump(), so don't also bump it.
|
// grab thing fires grab() instead of bump(), so don't also bump it.
|
||||||
bumped = match board.solid_at(nx, ny) {
|
bumped = board
|
||||||
Some(Solid::Object(_)) if grabbed.is_none() => board.solid_object_id_at(nx, ny),
|
.solid_at(nx, ny)
|
||||||
_ => None,
|
.filter(|_| grabbed.is_none())
|
||||||
};
|
.and_then(|s| s.object_id());
|
||||||
if grabbed.is_some() || board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
|
if grabbed.is_some() || board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
|
||||||
// Don't push a grab thing aside — walk onto it. Otherwise shove any
|
// Don't push a grab thing aside — walk onto it. Otherwise shove any
|
||||||
// pushable chain out of the way (no-op when there's nothing to push).
|
// pushable chain out of the way (no-op when there's nothing to push).
|
||||||
@@ -494,61 +473,31 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The side effects of a [`step_object`] call that the caller resolves after the
|
/// Moves object `id` one cell in `dir` on `board`, returning the object it bumped
|
||||||
/// board borrow drops: a `bump`ed object and/or a `grab`bed object.
|
/// (if any) for the caller to resolve after the board borrow drops.
|
||||||
#[derive(Default)]
|
|
||||||
struct StepOutcome {
|
|
||||||
/// A solid object the mover collided with (target cell was occupied by it).
|
|
||||||
bumped: Option<ObjectId>,
|
|
||||||
/// A grab object the move shoved into the player (its `grab()` hook should fire).
|
|
||||||
grabbed: Option<ObjectId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Moves object `id` one cell in `dir` on `board`, returning the objects it bumped
|
|
||||||
/// and/or grabbed (see [`StepOutcome`]).
|
|
||||||
///
|
///
|
||||||
/// The move itself proceeds only if the target is in bounds and either passable or a
|
/// The move itself proceeds only if the target is in bounds and either passable or a
|
||||||
/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The
|
/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The
|
||||||
/// bump is recorded whenever a solid object occupies the target — whether it gets
|
/// bump is recorded whenever a solid object occupies the target — whether it gets
|
||||||
/// pushed aside or blocks the move — since something tried to move into it. Walls and
|
/// pushed aside or blocks the move — since something tried to move into it. Walls and
|
||||||
/// crates carry no script, so only solid objects yield a bump.
|
/// crates carry no script, so only solid objects yield a bump.
|
||||||
///
|
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> {
|
||||||
/// If the push would shove a **grab** thing into the player (see
|
|
||||||
/// [`Board::pushed_grab_into_player`]), the push is skipped and the grab thing is
|
|
||||||
/// reported instead of sliding the player along.
|
|
||||||
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> StepOutcome {
|
|
||||||
let (dx, dy): (i32, i32) = dir.into();
|
let (dx, dy): (i32, i32) = dir.into();
|
||||||
let Some((ox, oy)) = board.objects.get(&id).map(|o| (o.x, o.y)) else {
|
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?;
|
||||||
return StepOutcome::default();
|
|
||||||
};
|
|
||||||
let target = (ox as i32 + dx, oy as i32 + dy);
|
let target = (ox as i32 + dx, oy as i32 + dy);
|
||||||
if !board.in_bounds(target) {
|
if !board.in_bounds(target) {
|
||||||
return StepOutcome::default();
|
return None;
|
||||||
}
|
}
|
||||||
let (nx, ny) = (target.0 as usize, target.1 as usize);
|
let (nx, ny) = (target.0 as usize, target.1 as usize);
|
||||||
// A push that would drive a grab thing into the player grabs it instead of
|
|
||||||
// sliding the player; skip the move entirely (the grab thing despawns itself).
|
|
||||||
if let Some(grabbed) = board.pushed_grab_into_player(nx, ny, dir) {
|
|
||||||
return StepOutcome {
|
|
||||||
bumped: None,
|
|
||||||
grabbed: Some(grabbed),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Capture the bumped object before any push relocates it (its id is stable).
|
// Capture the bumped object before any push relocates it (its id is stable).
|
||||||
let bumped = match board.solid_at(nx, ny) {
|
let bumped = board.solid_at(nx, ny).and_then(|s| s.object_id());
|
||||||
Some(Solid::Object(_)) => board.solid_object_id_at(nx, ny),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
|
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
|
||||||
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
|
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
|
||||||
let obj = board.objects.get_mut(&id).expect("id checked above");
|
let obj = board.objects.get_mut(&id).expect("id checked above");
|
||||||
obj.x = nx;
|
obj.x = nx;
|
||||||
obj.y = ny;
|
obj.y = ny;
|
||||||
}
|
}
|
||||||
StepOutcome {
|
bumped
|
||||||
bumped,
|
|
||||||
grabbed: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -580,9 +529,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pushing_a_gem_into_the_player_grabs_it() {
|
fn pushing_a_gem_into_the_player_does_not_grab() {
|
||||||
// A non-solid script object at (0,0) pushes the gem at (1,0) east into the
|
// A non-solid script object at (0,0) pushes the gem at (1,0) east toward the
|
||||||
// player at (2,0); the gem is grabbed rather than sliding the player.
|
// player at (2,0). Grab now fires only on player movement, so the gem is an
|
||||||
|
// ordinary solid here: the chain can't move (the player is backed against
|
||||||
|
// the board edge), so nothing happens and the gem is not collected.
|
||||||
let mut sobj = ObjectDef::new(0, 0);
|
let mut sobj = ObjectDef::new(0, 0);
|
||||||
sobj.solid = false;
|
sobj.solid = false;
|
||||||
sobj.script_name = Some("s".to_string());
|
sobj.script_name = Some("s".to_string());
|
||||||
@@ -596,41 +547,12 @@ mod tests {
|
|||||||
let mut game = GameState::with_scripts(board, scripts);
|
let mut game = GameState::with_scripts(board, scripts);
|
||||||
game.run_init();
|
game.run_init();
|
||||||
|
|
||||||
// First tick: the push resolves into a grab (gem's grab() is queued).
|
|
||||||
// Second tick: the gem's add_gems()/die() resolve.
|
|
||||||
game.tick(Duration::from_millis(16));
|
game.tick(Duration::from_millis(16));
|
||||||
game.tick(Duration::from_millis(16));
|
game.tick(Duration::from_millis(16));
|
||||||
|
|
||||||
assert_eq!(game.player_gems, 1);
|
// No grab: the gem is untouched and the player never moved.
|
||||||
// The gem (the only non-script object) is gone; the player never moved.
|
assert_eq!(game.player_gems, 0);
|
||||||
assert!(!game.board().objects.values().any(|o| o.grab));
|
assert!(game.board().objects.values().any(|o| o.grab));
|
||||||
assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn swapping_a_gem_into_the_player_grabs_it() {
|
|
||||||
// A non-solid script object swaps the gem at (1,0) onto the player at (2,0).
|
|
||||||
// apply_swap reports the grab; the gem's grab() collects it and die()s.
|
|
||||||
let mut sobj = ObjectDef::new(0, 0);
|
|
||||||
sobj.solid = false;
|
|
||||||
sobj.script_name = Some("s".to_string());
|
|
||||||
let mut board = open_board(3, 1, (2, 0), vec![sobj]);
|
|
||||||
stamp(&mut board, 1, 0, Archetype::Gem);
|
|
||||||
board.expand_builtin_archetypes();
|
|
||||||
let scripts = HashMap::from([(
|
|
||||||
"s".to_string(),
|
|
||||||
"fn tick(dt) { if Queue.length() == 0 { swap([[1, 0, 2, 0]]); } }".to_string(),
|
|
||||||
)]);
|
|
||||||
let mut game = GameState::with_scripts(board, scripts);
|
|
||||||
game.run_init();
|
|
||||||
|
|
||||||
// First tick: the swap reports the grab (gem's grab() is queued).
|
|
||||||
// Second tick: the gem's add_gems()/die() resolve.
|
|
||||||
game.tick(Duration::from_millis(16));
|
|
||||||
game.tick(Duration::from_millis(16));
|
|
||||||
|
|
||||||
assert_eq!(game.player_gems, 1);
|
|
||||||
assert!(!game.board().objects.values().any(|o| o.grab));
|
|
||||||
assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
|
assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+148
-18
@@ -68,35 +68,165 @@ pub struct Behavior {
|
|||||||
/// objects were reordered/destroyed.
|
/// objects were reordered/destroyed.
|
||||||
pub type ObjectId = u32;
|
pub type ObjectId = u32;
|
||||||
|
|
||||||
/// The single solid occupant of a board cell, returned by [`Board::solid_at`].
|
/// Which kind of thing a [`Solid`] is, plus the data needed to relocate it.
|
||||||
|
///
|
||||||
|
/// Private to [`Solid`]: callers ask through the [`Solid`] accessors
|
||||||
|
/// ([`Solid::player`], [`Solid::object_id`], [`Solid::archetype`]) rather than
|
||||||
|
/// matching the kind directly.
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
enum SolidKind {
|
||||||
|
/// The player occupies the cell. The player carries no grid cell of its own.
|
||||||
|
Player,
|
||||||
|
/// A solid scripted object, identified by its stable [`ObjectId`].
|
||||||
|
Object(ObjectId),
|
||||||
|
/// A solid terrain cell, with everything needed to rewrite it elsewhere.
|
||||||
|
Terrain {
|
||||||
|
/// Layer the terrain lives on.
|
||||||
|
z: usize,
|
||||||
|
/// The cell's glyph.
|
||||||
|
glyph: Glyph,
|
||||||
|
/// The cell's archetype.
|
||||||
|
arch: Archetype,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single solid occupant of a board cell, captured from a `&Board` at given
|
||||||
|
/// coordinates by [`Board::solid_at`].
|
||||||
///
|
///
|
||||||
/// At most one solid — the player, a grid [`Archetype`], *or* an [`ObjectDef`] — may
|
/// At most one solid — the player, a grid [`Archetype`], *or* an [`ObjectDef`] — may
|
||||||
/// occupy a cell (the invariant enforced at load time), so this represents the one
|
/// occupy a cell (the invariant enforced at load time), so this represents the one
|
||||||
/// thing a mover would collide with there.
|
/// thing a mover would collide with there. Absence of a solid is `None`, not a
|
||||||
pub enum Solid {
|
/// variant of this type.
|
||||||
/// The player occupies the cell. The player is solid (it blocks movers) and
|
///
|
||||||
/// pushable in any direction (see [`Board::is_pushable`]).
|
/// `Solid` is `Copy`: it captures its occupant's coordinates, behavior, and (for
|
||||||
Player,
|
/// terrain) the glyph/archetype/layer needed to relocate it *at construction time*,
|
||||||
/// The cell's grid archetype is itself solid (e.g. [`Archetype::Wall`]).
|
/// so movement logic can answer behavior questions and [`place`](Solid::place) the
|
||||||
Cell(Archetype),
|
/// occupant elsewhere without re-borrowing the board. This is what lets
|
||||||
/// A solid [`ObjectId`] occupies the cell.
|
/// [`Board::apply_swap`] read every source before writing any destination.
|
||||||
Object(ObjectId),
|
#[derive(Copy, Clone)]
|
||||||
|
pub struct Solid {
|
||||||
|
/// Column the occupant was read from.
|
||||||
|
x: usize,
|
||||||
|
/// Row the occupant was read from.
|
||||||
|
y: usize,
|
||||||
|
/// What kind of occupant this is (and its relocation data).
|
||||||
|
kind: SolidKind,
|
||||||
|
/// The occupant's behavior, captured at creation: the player's is synthesized
|
||||||
|
/// (solid + opaque + pushable any direction + not grabbable), terrain reads
|
||||||
|
/// [`Archetype::behavior`], an object's is built from its `solid`/`opaque`/
|
||||||
|
/// `pushable`/`grab` flags.
|
||||||
|
behavior: Behavior,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Solid {
|
impl Solid {
|
||||||
|
/// Builds the player solid at `(x, y)`.
|
||||||
|
pub(crate) fn player_at(x: usize, y: usize) -> Solid {
|
||||||
|
Solid {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
kind: SolidKind::Player,
|
||||||
|
// The player is solid + opaque, pushable in any direction, never grabbable.
|
||||||
|
behavior: Behavior {
|
||||||
|
solid: true,
|
||||||
|
opaque: true,
|
||||||
|
pushable: Pushable::Any,
|
||||||
|
grab: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a solid object at `(x, y)` from its [`ObjectDef`]-derived flags.
|
||||||
|
pub(crate) fn object_at(x: usize, y: usize, id: ObjectId, behavior: Behavior) -> Solid {
|
||||||
|
Solid {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
kind: SolidKind::Object(id),
|
||||||
|
behavior,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a solid terrain cell at `(x, y)` on layer `z`.
|
||||||
|
pub(crate) fn terrain_at(x: usize, y: usize, z: usize, glyph: Glyph, arch: Archetype) -> Solid {
|
||||||
|
Solid {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
kind: SolidKind::Terrain { z, glyph, arch },
|
||||||
|
behavior: arch.behavior(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The coordinates the occupant was read from.
|
||||||
|
pub fn coords(&self) -> (usize, usize) {
|
||||||
|
(self.x, self.y)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this occupant is the player.
|
/// Whether this occupant is the player.
|
||||||
pub fn player(&self) -> bool {
|
pub fn player(&self) -> bool {
|
||||||
matches!(self, Solid::Player)
|
matches!(self.kind, SolidKind::Player)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The occupant's [`ObjectId`], if it is a scripted object.
|
||||||
|
pub fn object_id(&self) -> Option<ObjectId> {
|
||||||
|
match self.kind {
|
||||||
|
SolidKind::Object(id) => Some(id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The occupant's terrain archetype, if it is a terrain cell.
|
||||||
|
pub fn archetype(&self) -> Option<Archetype> {
|
||||||
|
match self.kind {
|
||||||
|
SolidKind::Terrain { arch, .. } => Some(arch),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which directions the occupant may be pushed (the player → [`Pushable::Any`]).
|
||||||
|
pub fn pushable(&self) -> Pushable {
|
||||||
|
self.behavior.pushable
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this occupant is a **grab** thing (a gem-like solid the player
|
/// Whether this occupant is a **grab** thing (a gem-like solid the player
|
||||||
/// collects on contact). The player itself is never grabbable; a terrain cell
|
/// collects on contact). The player is never grabbable.
|
||||||
/// reports its archetype's `grab` flag, and an object reports its own.
|
pub fn grab(&self) -> bool {
|
||||||
pub fn grab(&self, board: &Board) -> bool {
|
self.behavior.grab
|
||||||
match self {
|
}
|
||||||
Solid::Player => false,
|
|
||||||
Solid::Cell(arch) => arch.behavior().grab,
|
/// Whether this occupant blocks movement (always true for a `Solid`).
|
||||||
Solid::Object(id) => board.objects.get(id).is_some_and(|obj| obj.grab),
|
pub fn solid(&self) -> bool {
|
||||||
|
self.behavior.solid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this occupant blocks line of sight.
|
||||||
|
pub fn opaque(&self) -> bool {
|
||||||
|
self.behavior.opaque
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes this occupant into the cell at `(x, y)`, relocating it there.
|
||||||
|
///
|
||||||
|
/// Writes the **destination only** — it does *not* vacate the occupant's
|
||||||
|
/// original cell. Callers that need the source cleared (e.g.
|
||||||
|
/// [`Board::shift_solid`]) do so separately; [`Board::apply_swap`] relies on
|
||||||
|
/// this by clearing all sources in a dedicated phase before installing
|
||||||
|
/// destinations, so cyclic moves and swaps resolve correctly.
|
||||||
|
///
|
||||||
|
/// The player moves via [`Board::player`]; an object updates its
|
||||||
|
/// [`ObjectDef`] position; a terrain cell rewrites `(glyph, arch)` at `(x, y)`.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
SolidKind::Object(id) => {
|
||||||
|
if let Some(obj) = board.objects.get_mut(&id) {
|
||||||
|
obj.x = x;
|
||||||
|
obj.y = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SolidKind::Terrain { z, glyph, arch } => {
|
||||||
|
*board.get_mut(z, x, y) = (glyph, arch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user