Add prop interaction system and throw mechanics specification

- Introduced `stickman_prop_interactor.gd` to manage prop interactions.
- Created `phase_11_prop_interactions.md` detailing the design and implementation of prop pickup, drop, and throw mechanics.
- Defined interaction states, action lifecycles, and event triggers for prop handling.
- Specified constraints for throw actions, including target positioning and velocity calculations.
- Established acceptance criteria for prop interactions and lifecycle management.
This commit is contained in:
2026-09-25 23:42:26 -04:00
parent 730a9a966b
commit 6a1359f244
22 changed files with 3433 additions and 34 deletions
+12
View File
@@ -801,6 +801,18 @@ The result: `mode` stays constant for the whole walk, exactly one `arrived` fire
Spec: `docs/phase_3c_editor_spec.md`.
### 24. Prop Interactions and Throw Planning (Phase 11)
The Sandbox Stage action queue supports one-hand prop pickup, drop, and targeted throws. In **Direct**, select a stickman and add or edit **Pick Up Prop**, **Drop Prop**, or **Throw Prop** in its action queue. A pickup selects a stage prop and a hand (`Auto`, `Left`, or `Right`); a throw captures a stage-world point.
**Throw target and guide:**
- The cursor point is the requested target. The effective target is clamped to **3,600 px from the stickman's root**; if the cursor is farther away, the guide ends at the capped point while still marking the cursor position.
- The solver accounts for stage gravity and chooses a valid descending arc whose release speed does not exceed **2,100 px/s**. Upward targets are supported when reachable; gravity can reduce their reachable distance. Targets that cannot be reached within the solver's speed and flight-time limits are shown as unreachable.
- The guide is clipped at the first prop/world collision. In open air, it ends at the selected target; the prop continues under normal physics after that point.
**Queue-aware editing preview:** when editing a queued throw, the planning guide looks at earlier actions in that queue. If an earlier pickup can provide the prop, the preview uses its grip and hand and estimates the pickup approach when needed. A hint such as **“Assumes Step 2 pickup succeeds.”** identifies the assumption; uncertain preceding actions label the guide as estimated. This preview does not run the pickup or change live prop, rig, or queue state. If no held or planned prop can be resolved, the guide is replaced by a neutral marker and an informational hint rather than a misleading trajectory. At runtime, the throw is recalculated from the actual prop, actor position, and release pose.
## File format (`.stk`)
Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files.
+7 -1
View File
@@ -33,7 +33,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| 21 | **Phase 3c drag-reorder uses nearest-row-center, not an insertion point** — `QueuePanel` / `RulePanel` reorder computes the drop target as the row whose vertical center is nearest the mouse, then moves the dragged item to that index. | Low | Open | Functional, but the drop position can read off-by-one near row boundaries (it snaps to a whole row rather than an edge between rows). A true insertion-point indicator (a line drawn between rows) would be clearer; not worth the complexity until reordering long queues/rules is common. (2026-09-04) |
| 22 | **`--check-only --script` needs a prior `--editor` rescan for new `class_name` scripts** — a freshly added `class_name` (e.g. `QueuePanel`) is not resolvable by `--check-only --script res://scripts/sandbox_stage.gd` until `--headless --editor --quit` regenerates `.godot/global_script_class_cache.cfg`. | Low | Open | Verification-workflow gotcha only (not a runtime bug): in-editor the cache is always current. CI/headless scripts that add new `class_name`s should run an `--editor` pass before per-script `--check-only`. (2026-09-04) |
| 23 | **Phase 3c confirmation dialog is popped over an exclusive panel** — Queue delete / Clear All (`_on_queue_panel_delete_requested` / `_on_queue_panel_clear_requested`) and Rule delete / Clear All (`_on_rule_panel_delete_requested` / `_on_rule_panel_clear_requested`) call the shared `ConfirmationDialog` (`_ask_confirm`) **without first hiding the `exclusive = true` `QueuePanel` / `RulePanel`**. | Low | Open | Distinct facet of #19 (host-visibility discipline): popping a second exclusive window over a visible exclusive panel logs a **non-fatal engine warning** and the confirmation may render **non-modal**. It still functions (the panel is re-shown after the callback) — cosmetic. A fix would hide the host panel around `_ask_confirm` and re-show it in the confirm callback (and on the dialog's cancel/hide). Deliberate non-debt note: plan §11.2's separate confirmation dialog for editing `ragdoll`/`recover` actions was implemented instead as a paramless `ActionEditor` pre-fill — recorded in `docs/phase_3c_editor_spec.md` §2 decision 5, not as a debt row. (2026-09-04) |
| 24 | **Sandbox trigger rules use session-only instance IDs** — `entered_area` source/target references and action targets are `Object.get_instance_id()` integers. | Low | Open | Correct for the current in-memory rule engine and shared by the builder, matcher, visualizer, cleanup, and action executor. Rules survive `EDIT ⇄ DIRECT ⇄ PLAY` but are not serialized; a future saved-stage format needs stable authored object IDs instead of runtime instance IDs. (2026‑09‑11) |
| 24 | **Sandbox rule references use session instance IDs at runtime** — trigger sources/targets and action actors use `Object.get_instance_id()` integers; StageSceneIO remaps these through per-save `file_id` values. | Low | Open | This is correct for the current in-memory engine and save/load remapping. Phase 11’s approved spec adds persistent per-instance prop `stage_object_id` values for queue/rule prop actions and upgrades the scene format to 1.1; implementation and compatibility verification remain open. (2026‑09‑11; spec decision 2026‑09‑24) |
| 25 | **Contact direct-state timing fallback** — `PropBlock` may receive `body_entered` before a matching direct-state contact row is available. | Low | Open | `_on_body_entered()` uses the documented center-direction / prop-position fallback when the row is absent, so the signal remains safe but the normal and position are less precise on that edge. (2026-09-12) |
| 26 | **Impact speed is a coarse magnitude** — collision reactions currently scale from `prop.linear_velocity.length()`, not normal-relative velocity or an impulse computed from contact mass. | Low | Open | This keeps the signal contract simple and deterministic. Use the normal component and/or contact impulse if reaction strength needs to reflect glancing impacts accurately. (2026-09-12) |
| 27 | **Post-IK reaction timing is engine-order-sensitive** — bounded flinch offsets are applied at the end of `StickmanRig._physics_process()`, after the IK/head-pin work. | Low | Open | The current slot is verified and prevents cumulative rotation, but future changes to Skeleton2D modification ordering could overwrite or delay the visible reaction. Revisit only with a targeted engine-order regression. (2026-09-12) |
@@ -60,6 +60,8 @@ This document tracks known technical debt, optimization opportunities, and minor
| 48 | **Phase 12 paused paths use one representative ground anchor** — the nearest terrain ray below live feet or ragdoll bounds supplies the pending path origin. | Low | ✅ Resolved | Implemented the approved representative ground anchor, with a live-feet fallback when the probe finds no surface. Multi-contact path planning across a sprawled ragdoll and uneven terrain remains outside Phase 12, but the current anchor is stable and inspectable for paused queue authoring. (2026-09-24) |
| 49 | **Phase 12 event edges must be rebaselined without replay** — freezing bodies may emit contact exits/entries, and the stage polls area overlap before child rig movement. | Medium | ✅ Resolved | Event dispatch remains gated until a silent contact/overlap baseline is established on Resume. Existing overlaps do not refire because of the pause transition, and rules authored while paused apply to future events only. Reset clears runtime event edges. (2026-09-24) |
| 50 | **Phase 12 paused-edit autosave stores authored state, not the frozen snapshot** — queue/rule edits occur at `t > 0` while stage bodies are at temporary physical transforms. | Medium | ✅ Resolved | A 1.0 s debounce writes the existing `.scene` schema using authored object transforms plus current queues/rules. The timer can finish after Resume; runtime pose, velocities, reactive consequences, and action history are session-only. Paused edits therefore autosave without turning the live pose into the authored starting layout. (2026-09-24) |
| 51 | **Phase 11 throw distance and speed are shared across prop types** — the landing-target solver does not yet tune throwability by material or weight. | Low | Open | Deliberately deferred from the approved landing-target amendment. If playtesting needs it, add an explicit gameplay throwability factor based on prop weight/material; do not rely on rigid-body mass to change gravity or mutate physical mass for throw balance. Current shared limits are a 3,600 px target radius and 2,100 px/s speed cap. (2026-09-25) |
| 52 | **Throw target preview ignores earlier queued pickups** — the live preview requires `_held_prop`, so editing a pending throw after a pending pickup at `t = 0` cannot show the actual planned throw arc. | Medium | ✅ Resolved | The preview now infers a virtual held-prop state from the authored queue prefix and provides a pure, non-mutating planning trajectory using the prop/grip and predicted pickup stand-off. When a prop cannot be resolved, it shows a neutral explanation; runtime still recomputes from actual state. User verified the result. (2026-09-25) |
---
@@ -112,6 +114,10 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-09-22 | Phase 10a whole-prop transform extension approved and implemented (`specs/phase_10a_prop_scaling.md` §3.4). The prior Resize Whole Prop toggle becomes one **Transform Whole Prop** tool that mirrors Arrange: drag visible geometry to move the whole document, drag the union rotation dot to rotate it about canonical `(0,0)`, and drag a corner for the existing uniform origin-based resize. Move translates every point and non-origin anchor (snapping the baseline delta when Snap is ON); rotate transforms every point/anchor position and adds the angle to editable anchor orientations; circle radius changes only on resize. All gestures preview from snapshots outside authoritative save state and commit once on release; Esc cancellation, selection restoration, mode lifecycle, `.prop` v1.0, and deferred document undo remain unchanged. |
| 2026-09-22 | Phase 10a implementation and final UI were user verified. The Arrange top bar is ordered **Transform Whole Prop**, **Normalize Size**, **Center Prop**, **Show Stickman Guide**; the numeric scale textbox and Apply Scale button are removed. Transform Whole Prop and Normalize Size are hidden in Reshape and Attach, while Center Prop and Show Stickman Guide remain available. Whole-prop move, 15-degree Ctrl rotation, uniform resize, anchor following, Snap movement, and Esc cancellation are documented in `docs/prop_editor_user_guide.md`; focused tests and the Prop Editor startup smoke test passed. |
| 2026-09-24 | Phase 12 Live Director implemented and editor-verified. PLAY/DIRECT pause and resume preserve simulation time and live state; the top-bar clock is visible, Space toggles on key press, and paused DIRECT supports action/event editing without object transforms. Pending-only reorder, consumed-action deletion, interrupted-action history, contact rebaseline, and authored-state autosave are documented in the README and spec. Follow-up fixes make locomotion animation resume reliably, preserve a paused recovery tween instead of re-ragdolling, and clear stale foot-contact locks so recovery does not cross the feet. Rows #46–#50 resolved; no `.scene` format change. |
| 2026-09-24 | Phase 11 Prop Interactions architecture spec written (`specs/phase_11_prop_interactions.md`) after approval of fixed stage-world throw targets, one prop in one compatible hand per stickman, and persistent per-instance prop IDs. The design adds a per-rig PropInteractor, keeps pickup auto-approach inside one authored queue action, carries existing .prop anchors into runtime, uses a bounded Godot-coordinate ballistic solver, emits events only after committed prop transitions, and migrates StageSceneIO 1.0 scenes to 1.1. Row #24 remains Open pending implementation. |
| 2026-09-25 | Phase 11 throw UX amendment approved (`specs/phase_11_prop_interactions.md` §1.1): treat the saved cursor point as aim plus power (distance maps smoothly to a 300–1,250 px/s capped speed), preview the collision-limited ballistic impact, and replace the short windup with a reach-clamped overhand arc that releases during its forward sweep. The existing `{type, target_position}` queue payload remains unchanged. |
| 2026-09-25 | Phase 11 throw landing-target amendment approved (`specs/phase_11_prop_interactions.md` §1.2): clamp the cursor's effective target to 800 px from the stickman root, solve the fastest valid descending trajectory under a 1,250 px/s cap, distinguish the raw cursor/effective target/early collision markers, and preserve the fixed world-space queue payload. Open-air targets remain valid; prop weight/material throwability is deferred under #51. |
| 2026-09-25 | Phase 11 throw-range tuning updated after user feedback: target radius 3,600 px and speed cap 2,100 px/s. The queue-aware, non-mutating target-preview amendment (`specs/phase_11_prop_interactions.md` §1.3) was approved, implemented, and user verified; row #52 is resolved. Throw limits remain shared across prop types under open row #51. |
---
+292 -10
View File
@@ -1,12 +1,12 @@
{
"version": "1.0",
"version": "1.1",
"scene_name": "Untitled",
"camera": {
"position": {
"x": 240.183868408203,
"y": -357.797393798828
"x": 1721.90795898438,
"y": -1162.85546875
},
"zoom": 0.341506749391556
"zoom": 0.413223177194595
},
"objects": [
{
@@ -224,17 +224,43 @@
"type": "stickman",
"position": {
"x": -1116.0,
"y": -313.0
"y": -312.0
},
"rotation": 0.0,
"stk_path": "res://stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "ragdoll"
"type": "recover"
},
{
"type": "walk_to",
"target": {
"x": -433.3017578125,
"y": 137.068603515625
}
},
{
"type": "pick_up_prop",
"target_prop_id": "prop_1",
"hand": "auto"
},
{
"type": "recover"
},
{
"type": "walk_to",
"target": {
"x": 1373.3974609375,
"y": 142.9248046875
}
},
{
"type": "throw_prop",
"target_position": {
"x": 2352.0,
"y": -1176.0
}
}
]
},
@@ -242,22 +268,278 @@
"file_id": "prop_1",
"type": "prop",
"position": {
"x": -108.0,
"y": -1152.0
"x": -96.0,
"y": -72.0
},
"rotation": 0.0,
"asset_id": "res://props/briefcase.prop",
"stage_object_id": "prop_1",
"scale": {
"x": 1.0,
"y": 1.0
},
"material_preset": 1
},
{
"file_id": "terrain_16",
"type": "terrain",
"position": {
"x": 2304.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_17",
"type": "terrain",
"position": {
"x": 2496.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_18",
"type": "terrain",
"position": {
"x": 2688.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_19",
"type": "terrain",
"position": {
"x": 2880.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_20",
"type": "terrain",
"position": {
"x": 3072.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_21",
"type": "terrain",
"position": {
"x": 3264.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_22",
"type": "terrain",
"position": {
"x": 3456.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_23",
"type": "terrain",
"position": {
"x": 3648.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "area_1",
"type": "area",
"position": {
"x": 624.0,
"y": -36.0
},
"rotation": 0.0,
"scale": {
"x": 1.0,
"y": 1.0
},
"size": {
"x": 96.0,
"y": 96.0
}
},
{
"file_id": "stickman_2",
"type": "stickman",
"position": {
"x": 3516.0,
"y": -1273.0
},
"rotation": 0.0,
"stk_path": "res://stickmen/test.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "walk_to",
"target": {
"x": 3011.76782226563,
"y": -930.535400390625
}
},
{
"type": "speak",
"text": "THROW ME THAT CASE BRO!",
"duration": 2.0,
"stop_current": false
},
{
"type": "wait",
"duration": 9.0
},
{
"type": "pick_up_prop",
"target_prop_id": "prop_1",
"hand": "auto"
}
]
}
],
"event_rules": [
{
"id": 0,
"trigger": {
"type": "entered_area",
"source_id": "stickman_1",
"target_id": "area_1",
"params": {}
},
"actions": [
{
"type": "speak",
"target_id": "stickman_1",
"params": {
"text": "I'm gonna throw this!",
"duration": 2.0,
"stop_current": false
}
}
]
},
{
"id": 1,
"trigger": {
"type": "prop_thrown",
"source_id": "stickman_1",
"target_id": "prop_1",
"params": {}
},
"actions": [
{
"type": "speak",
"target_id": "stickman_1",
"params": {
"text": "HYAHHH!!!",
"duration": 2.0,
"stop_current": false
}
}
]
},
{
"id": 2,
"trigger": {
"type": "prop_picked_up",
"source_id": "stickman_1",
"target_id": "prop_1",
"params": {}
},
"actions": [
{
"type": "speak",
"target_id": "stickman_1",
"params": {
"text": "Max is a BABY!",
"duration": 2.0,
"stop_current": false
}
}
]
},
{
"id": 3,
"trigger": {
"type": "prop_picked_up",
"source_id": "stickman_2",
"target_id": "prop_1",
"params": {}
},
"actions": [
{
"type": "speak",
"target_id": "stickman_2",
"params": {
"text": "I'm GONNA JUMP!",
"duration": 2.0,
"stop_current": false
}
},
{
"type": "walk_to",
"target_id": "stickman_2",
"params": {
"target": {
"x": 4416.0,
"y": -960.0
}
}
}
]
}
],
"event_rules": [],
"metadata": {
"created_unix": 1790221912,
"modified_unix": 1790268751.405,
"modified_unix": 1790393015.073,
"autosave": false,
"source_path": ""
}
+546
View File
@@ -0,0 +1,546 @@
{
"version": "1.1",
"scene_name": "Untitled",
"camera": {
"position": {
"x": 1721.90795898438,
"y": -1162.85546875
},
"zoom": 0.413223177194595
},
"objects": [
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -1344.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -1152.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": -960.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": -768.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": -576.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_6",
"type": "terrain",
"position": {
"x": -384.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_7",
"type": "terrain",
"position": {
"x": -192.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_8",
"type": "terrain",
"position": {
"x": 0.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_9",
"type": "terrain",
"position": {
"x": 192.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_10",
"type": "terrain",
"position": {
"x": 384.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_11",
"type": "terrain",
"position": {
"x": 576.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_12",
"type": "terrain",
"position": {
"x": 768.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_13",
"type": "terrain",
"position": {
"x": 960.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_14",
"type": "terrain",
"position": {
"x": 1152.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_15",
"type": "terrain",
"position": {
"x": 1344.0,
"y": 192.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -1116.0,
"y": -312.0
},
"rotation": 0.0,
"stk_path": "res://stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "recover"
},
{
"type": "walk_to",
"target": {
"x": -433.3017578125,
"y": 137.068603515625
}
},
{
"type": "pick_up_prop",
"target_prop_id": "prop_1",
"hand": "auto"
},
{
"type": "recover"
},
{
"type": "walk_to",
"target": {
"x": 1373.3974609375,
"y": 142.9248046875
}
},
{
"type": "throw_prop",
"target_position": {
"x": 2352.0,
"y": -1176.0
}
}
]
},
{
"file_id": "prop_1",
"type": "prop",
"position": {
"x": -96.0,
"y": -72.0
},
"rotation": 0.0,
"asset_id": "res://props/briefcase.prop",
"stage_object_id": "prop_1",
"scale": {
"x": 1.0,
"y": 1.0
},
"material_preset": 1
},
{
"file_id": "terrain_16",
"type": "terrain",
"position": {
"x": 2304.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_17",
"type": "terrain",
"position": {
"x": 2496.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_18",
"type": "terrain",
"position": {
"x": 2688.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_19",
"type": "terrain",
"position": {
"x": 2880.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_20",
"type": "terrain",
"position": {
"x": 3072.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_21",
"type": "terrain",
"position": {
"x": 3264.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_22",
"type": "terrain",
"position": {
"x": 3456.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_23",
"type": "terrain",
"position": {
"x": 3648.0,
"y": -864.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "area_1",
"type": "area",
"position": {
"x": 624.0,
"y": -36.0
},
"rotation": 0.0,
"scale": {
"x": 1.0,
"y": 1.0
},
"size": {
"x": 96.0,
"y": 96.0
}
},
{
"file_id": "stickman_2",
"type": "stickman",
"position": {
"x": 3516.0,
"y": -1273.0
},
"rotation": 0.0,
"stk_path": "res://stickmen/test.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "walk_to",
"target": {
"x": 3011.76782226563,
"y": -930.535400390625
}
},
{
"type": "speak",
"text": "THROW ME THAT CASE BRO!",
"duration": 2.0,
"stop_current": false
},
{
"type": "wait",
"duration": 9.0
},
{
"type": "pick_up_prop",
"target_prop_id": "prop_1",
"hand": "auto"
}
]
}
],
"event_rules": [
{
"id": 0,
"trigger": {
"type": "entered_area",
"source_id": "stickman_1",
"target_id": "area_1",
"params": {}
},
"actions": [
{
"type": "speak",
"target_id": "stickman_1",
"params": {
"text": "I'm gonna throw this!",
"duration": 2.0,
"stop_current": false
}
}
]
},
{
"id": 1,
"trigger": {
"type": "prop_thrown",
"source_id": "stickman_1",
"target_id": "prop_1",
"params": {}
},
"actions": [
{
"type": "speak",
"target_id": "stickman_1",
"params": {
"text": "HYAHHH!!!",
"duration": 2.0,
"stop_current": false
}
}
]
},
{
"id": 2,
"trigger": {
"type": "prop_picked_up",
"source_id": "stickman_1",
"target_id": "prop_1",
"params": {}
},
"actions": [
{
"type": "speak",
"target_id": "stickman_1",
"params": {
"text": "Max is a BABY!",
"duration": 2.0,
"stop_current": false
}
}
]
},
{
"id": 3,
"trigger": {
"type": "prop_picked_up",
"source_id": "stickman_2",
"target_id": "prop_1",
"params": {}
},
"actions": [
{
"type": "speak",
"target_id": "stickman_2",
"params": {
"text": "I'm GONNA JUMP!",
"duration": 2.0,
"stop_current": false
}
},
{
"type": "walk_to",
"target_id": "stickman_2",
"params": {
"target": {
"x": 4416.0,
"y": -960.0
}
}
}
]
}
],
"metadata": {
"created_unix": 1790221912,
"modified_unix": 1790393027.475,
"autosave": false,
"source_path": ""
}
}
+268
View File
@@ -0,0 +1,268 @@
# Architecture Specification: Interactive Prop System & Event Engine
This specification details the technical architecture for bringing interactive prop capabilities—including auto-approach, IK socket binding, parabolic throwing, ragdoll disarming, and "When..." rule triggers—into the Sandbox Stage.
---
## 1. System Overview & Layer Interactions
Prop interactions bridge five core subsystems across the runtime stack:
```
[ Director UI / Target Selector ]
│
▼
[ StickmanActionRunner ] ──(Auto-Approach)──► [ StickmanLocomotion ]
│
▼
[ StickmanRig IK ] ──(Socket Binding)──► [ PropBlock Physics ]
│
▼
[ StageEventRegistry ] ──("When..." Rules)──► [ Reactive Actions ]
```
1. **Director UI & Target Selection:** Captures spatial throw targets and enqueues prop interaction commands.
2. **Action Runner & Locomotion:** Evaluates spatial reach; automatically inserts `walk_to` sub-goals when props are out of arm's reach.
3. **IK & Bone Socketing:** Extends arm IK targets toward prop `grip` anchors and manages `RemoteTransform2D` binding.
4. **Physics Lifecycle:** Unfreezes/freezes `PropBlock` bodies and inherits velocity during release, throw, or ragdoll disarm transitions.
5. **Event Registry:** Emits and evaluates edge-triggered prop rules within the stage's "When → Then" engine.
---
## 2. Dynamic Action Queue & Auto-Approach (`walk_to`)
To ensure smooth director interaction, the action queue automatically resolves spatial gaps between the stickman and target props.
### Action Payload Schemas
- **`pick_up_prop`**: `{ "type": "pick_up_prop", "target_prop_id": String, "hand": "auto" | "left" | "right" }`
- **`drop_prop`**: `{ "type": "drop_prop" }`
- **`throw_prop`**: `{ "type": "throw_prop", "target_position": Vector2, "force": float }`
### Smart Approach Execution
When `StickmanActionRunner` receives a `pick_up_prop` payload:
```
┌──────────────────────────────────────────────┐
│ pick_up_prop Action Start │
└──────────────────────┬───────────────────────┘
│
┌──────────────────────▼───────────────────────┐
│ Distance = |rig.position - prop.position| │
└──────────────────────┬───────────────────────┘
│
Is Distance > Reach Radius (e.g. 40px)?
┌───────────────────────┴───────────────────────┐
│ │
[ YES ] [ NO ]
│ │
┌─────────────────▼─────────────────┐ ┌─────────────────▼─────────────────┐
│ 1. Pause pick_up_prop sequence │ │ 1. Skip locomotion step │
│ 2. Prepend walk_to(prop_offset) │ │ 2. Trigger arm IK reach animation │
│ 3. Resume pickup on arrival │ └───────────────────────────────────┘
└───────────────────────────────────┘
```
1. **Distance Check:** Evaluates global horizontal/spatial distance between the stickman root and prop `origin` pivot.
2. **Sub-Goal Injection:** If outside reach radius, `StickmanActionRunner` pauses the pickup sequence and internally issues a `walk_to` command to a stand point adjacent to the prop.
3. **Sequence Resume:** Upon receiving `StickmanLocomotion.arrived_at_waypoint`, the runner resumes the pickup sequence and triggers arm IK extension.
---
## 3. Pickup, Drop, and Throw Pipelines
### A. Prop Pickup Sequence & IK Socketing
1. **IK Reach Phase (150 ms):** `TwoBoneIK` targets for the selected arm interpolate toward the world position of the prop's `grip` anchor.
**Edit:** Torso position should also move up/down based on grip anchor. The stickman should look like it is bending or extending legs to make pickup look more realistic.
2. **Socket Attachment:**
- Set `PropBlock.freeze = true` (kinematic mode) to suspend environmental physics forces.
- Instantiate or activate a `RemoteTransform2D` node attached to the lower arm / wrist joint, targeting the `PropBlock`.
- Apply anchor local offsets (`x, y, rotation_deg, z_index_offset`) to align the prop grip directly within the hand geometry.
3. **Holding Stance Latch:** Lock upper limb IK targets into a carried stance during subsequent walk cycles.
### B. Prop Drop Sequence
1. **Unbinding:** Clear `RemoteTransform2D` target and reparent `PropBlock` back to the stage world root.
2. **Physics Activation:** Set `PropBlock.freeze = false` to re-enable rigid body simulation, gravity, and collision shapes.
3. **Inertia Inheritance:** Set `PropBlock.linear_velocity` equal to the stickman's current movement velocity (`_last_grounded_walk_velocity_x`) so dropped items retain forward momentum.
4. **Arm Recovery:** Blend wrist IK targets smoothly back to standard locomotion/idle rest positions.
### C. Prop Throw Sequence & Trajectory Selection
#### Director Target UI Flow
- **Targeting Mode:** Selecting "Throw Prop" in Director/Play mode activates an aim reticle cursor and dynamic arc preview.
**EDIT:** When the user clicks the stickman in DIRECT mode, 3 new additions should appear in the action context menu
1. Pick up
2. Drop
3. Throw
[Give these proper emoji]
- If Drop is added to the queue, and the action is run when the stickman holds nothing, the action is skipped (marked as done)
- If Throw is added to the queue, and the action is run when the stickman holds nothing, the action is skipped (marked as done)
- These 3 actions should also be selectable when handling a 'When' event
- **Trajectory Math:** Given hand release point $P_0$, target landing point $P_t$, base speed $v_0$, and gravity $g$:
$$\theta = \arctan\left(\frac{v_0^2 \pm \sqrt{v_0^4 - g(g \cdot x^2 + 2 y \cdot v_0^2)}}{g \cdot x}\right)$$
Calculates exact initial launch velocity components $V_{\text{launch}} = (v_0 \cos\theta, v_0 \sin\theta)$.
#### Keyframe Throw Animation Arc
```
[ Windup: 100ms ] ──► [ Release Frame: 150ms ] ──► [ Follow-Through: 50ms ]
- Torso tilts -15° - Arm sweeps to angle - Arm extends along arc
- Arm pulls backward - Prop unfreezes + impulse - Blends back to idle
```
1. **Windup (100 ms):** Torso rotates backward opposite the launch vector while the arm pulls back.
2. **Release Frame (150 ms):**
- Arm sweeps rapidly along the calculated launch angle $\theta$.
**EDIT:** The arm should go overhead - not an underhand throw.
- Unbind `RemoteTransform2D` and reparent prop to world root.
- Set `PropBlock.freeze = false` and execute `apply_central_impulse(V_launch)`.
- Apply rotational torque proportional to the distance between hand grip and prop center-of-mass.
3. **Follow-Through (50 ms):** Arm extends toward launch vector, then smoothly dampens back to default idle/walking pose.
---
## 4. Physics & State Machine Lifecycle (Ragdoll Disarm Handoff)
When `StickmanRig.set_ragdoll(true)` is executed (via impact, command, or keypress), any held prop must be immediately disarmed before kinematic nodes are hidden and ragdoll bodies are built.
```
┌──────────────────────────────────────────────┐
│ StickmanRig.set_ragdoll(true) │
└──────────────────────┬───────────────────────┘
│
┌──────────────────────▼───────────────────────┐
│ 1. Pre-Ragdoll Intercept │
│ Query if held_prop != null │
└──────────────────────┬───────────────────────┘
│
┌──────────────────────▼───────────────────────┐
│ 2. Execute Disarm Handoff │
│ - Clear RemoteTransform2D binding │
│ - Reparent PropBlock to world root │
│ - Set prop.freeze = false │
│ - Apply cached momentum (_track_momentum) │
└──────────────────────┬───────────────────────┘
│
┌──────────────────────▼───────────────────────┐
│ 3. Build Physics Ragdoll │
│ Hide animated body, build RigidBody2D │
│ network at current solved bone positions │
└──────────────────────────────────────────────┘
```
1. **Pre-Ragdoll Intercept:** Prior to clearing collision proxies or hiding body meshes, check for an active `held_prop` binding.
2. **Physics Unbind:**
- Detach the `RemoteTransform2D` socket.
- Reparent prop to the world root and set `PropBlock.freeze = false`.
- Transfer cached rig momentum (`_track_momentum()`) directly to `PropBlock.linear_velocity` and `angular_velocity` so the prop tumbles naturally alongside the falling figure.
3. **Ragdoll Construction:** Proceed with standard `_build_ragdoll()` processing at current solved bone transforms.
---
## 5. Event System Integration ("When..." Triggers)
Three new edge-triggered prop event types are added to `StageEventRegistry` (`sandbox_stage.gd`) to support the director's "When → Then" mental model.
### Prop Trigger Specifications
| Trigger Type | Source Object | Target Object | Firing Condition |
| ---------------- | --------------- | --------------- | ------------------------------------------------------------------------------- |
| `prop_picked_up` | Stickman / Prop | Prop / Stickman | Emitted as soon as IK arm reach completes and `RemoteTransform2D` socket locks. |
|
| `prop_dropped` | Stickman / Prop | Prop / Stickman | Emitted when a stickman drops a prop manually or via ragdoll disarm.
|
| `prop_thrown` | Stickman / Prop | Prop / Stickman | Emitted at the exact release frame of a throw animation arc.
|
### Signal Routing & Evaluation
1. `StickmanActionRunner` emits `prop_action_executed(action_type, stickman, prop)` upon completing or releasing an anchor attachment.
2. `StageEventRegistry` matches active stage rules where `rule.trigger.type` equals `prop_picked_up`, `prop_dropped`, or `prop_thrown`.
3. Matching rules trigger reactive actions on target stickmen or props (e.g., _When Stickman A picks up Sword X $\rightarrow$ Stickman B ragdolls_).
---
## 6. Implementation Roadmap for Architect Agent
```
[ Phase 1: Action Queue & Locomotion ]
├─ Add pick_up_prop, drop_prop, and throw_prop to StickmanActionRunner
└─ Implement distance evaluation & auto-enqueueing of walk_to sub-goals
[ Phase 2: Socket Attachment & IK Handshake ]
├─ Implement RemoteTransform2D socketing to wrist joint with grip offsets
└─ Implement holding stance arm pose blending
[ Phase 3: Throw Mechanics & Trajectory UI ]
├─ Add trajectory solver & reticle target selector to Director UI
└─ Build throw animation timeline (windup -> release impulse -> follow-through)
[ Phase 4: State Machine Integration & Disarm ]
├─ Intercept held props during set_ragdoll(true) transitions
└─ Transfer cached linear/angular momentum to disarmed prop physics body
[ Phase 5: Event System Expansion ]
├─ Register prop_picked_up, prop_dropped, prop_thrown triggers in StageEventRegistry
└─ Wire action signals to trigger "When -> Then" rule evaluations
```
## 7. Design Questions / Rules
1. When creating a Throw action, the user must create an arc defining the throw. However, when the scene is playing, the stickman might be in a whole different position for when the throw must happen. Should we define a new arc and make a best effort to throw the prop towards the (x,y) location selected or should we use the same arc, but translate the throw endpoint relevant to the stickman's current position.
2. The stickman profile direction should change to whichever direction the prop will be thrown.
### Architectural Acceptance Criteria Checklist
- [ ] **Auto-Approach:** Requesting pickup of a distant prop automatically causes the stickman to walk to the prop before reaching out.
- [ ] **Grip Alignment:** Hand attaches precisely to the prop's authored `grip` anchor, respecting local position, rotation, and Z-index offsets.
- [ ] **Trajectory Accuracy:** Thrown props follow the calculated parabolic arc toward the director's targeted ground point.
- [ ] **Ragdoll Disarm:** Entering ragdoll mode immediately detaches held props and applies rig momentum so items tumble realistically.
- [ ] **Event Rule Engine:** Rules configured with `When prop_picked_up`, `When prop_dropped`, or `When prop_thrown` execute downstream actions reliably.
+3 -3
View File
@@ -11,7 +11,7 @@
"primary_grip": {
"allowed_hands": "both",
"role": "grip",
"rotation_deg": 0.0,
"rotation_deg": 90.0,
"x": 0.0,
"y": -82.066291809082,
"z_index_offset": 1
@@ -19,8 +19,8 @@
},
"material_preset": "wood",
"metadata": {
"created_at": "2026-09-22T00:22:32",
"modified_at": "2026-09-22T00:22:32"
"created_at": "2026-09-24T23:17:51",
"modified_at": "2026-09-24T23:17:51"
},
"prop_name": "Brown Briefcase",
"shapes": [
+4
View File
@@ -4,6 +4,7 @@
[ext_resource type="Script" path="res://scripts/stickman_locomotion.gd" id="2_locomotion"]
[ext_resource type="Script" path="res://scripts/stickman_ragdoll.gd" id="3_ragdoll"]
[ext_resource type="Script" path="res://scripts/stickman_action_runner.gd" id="4_runner"]
[ext_resource type="Script" path="res://scripts/stickman_prop_interactor.gd" id="5_prop_interactor"]
[sub_resource type="GDScript" id="GDScript_f0s26"]
script/source = "@tool
@@ -335,6 +336,9 @@ script = ExtResource("3_ragdoll")
[node name="ActionRunner" type="Node" parent="RigComponents"]
script = ExtResource("4_runner")
[node name="PropInteractor" type="Node" parent="RigComponents"]
script = ExtResource("5_prop_interactor")
[node name="Body" type="Node2D" parent="." unique_id=327811712]
[node name="Body" type="Line2D" parent="Body" unique_id=1290443463]
+91
View File
@@ -22,10 +22,16 @@ var _text_edit: LineEdit = null
var _duration_spin: SpinBox = null
var _target_label: Label = null
var _set_target_btn: Button = null
var _prop_target_label: Label = null
var _set_prop_target_btn: Button = null
var _hand_option: OptionButton = null
var _stop_current_check: CheckBox = null
var _show_reactive_options: bool = false
var _has_target: bool = false
var _target_pos: Vector2 = Vector2.ZERO
var _prop_target_id: String = ""
var _has_throw_target: bool = false
var _throw_target_pos: Vector2 = Vector2.ZERO
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
## override (engine default); Font null = no override.
@@ -53,6 +59,9 @@ func open_new(show_reactive_options: bool = false) -> void:
_initial = {}
_has_target = false
_target_pos = Vector2.ZERO
_prop_target_id = ""
_has_throw_target = false
_throw_target_pos = Vector2.ZERO
_select_type("walk_to")
_rebuild_params("walk_to")
title = "Add Action"
@@ -67,6 +76,9 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
var type := String(action.get("type", "walk_to"))
_has_target = action.has("target") and action["target"] is Vector2
_target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO
_prop_target_id = String(action.get("target_prop_id", ""))
_has_throw_target = action.has("target_position") and action["target_position"] is Vector2
_throw_target_pos = action.get("target_position", Vector2.ZERO) if _has_throw_target else Vector2.ZERO
_select_type(type)
_rebuild_params(type)
title = "Edit Action"
@@ -81,6 +93,23 @@ func set_walk_target(pos: Vector2) -> void:
popup_centered()
func set_prop_target(prop_id: String) -> void:
_prop_target_id = prop_id
_update_prop_target_label()
popup_centered()
func set_throw_target(pos: Vector2) -> void:
_has_throw_target = true
_throw_target_pos = pos
_update_throw_target_label()
popup_centered()
func current_action_type() -> String:
return _current_type()
func _build_ui() -> void:
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 12)
@@ -184,6 +213,9 @@ func _rebuild_params(type: String) -> void:
_duration_spin = null
_target_label = null
_set_target_btn = null
_prop_target_label = null
_set_prop_target_btn = null
_hand_option = null
_stop_current_check = null
match type:
@@ -198,6 +230,39 @@ func _rebuild_params(type: String) -> void:
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
trow.add_child(_set_target_btn)
_update_walk_target_label()
"pick_up_prop":
var prop_row := HBoxContainer.new()
_params_box.add_child(prop_row)
_prop_target_label = Label.new()
_prop_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
prop_row.add_child(_prop_target_label)
_set_prop_target_btn = Button.new()
_set_prop_target_btn.text = "🎯 Click prop…"
_set_prop_target_btn.pressed.connect(func() -> void: target_requested.emit())
prop_row.add_child(_set_prop_target_btn)
_update_prop_target_label()
var hand_row := HBoxContainer.new()
_params_box.add_child(hand_row)
var hand_label := Label.new()
hand_label.text = "Hand:"
hand_row.add_child(hand_label)
_hand_option = OptionButton.new()
for label: String in ["Auto", "Left", "Right"]:
_hand_option.add_item(label)
var hand_idx := ["auto", "left", "right"].find(String(_initial.get("hand", "auto")))
_hand_option.select(maxi(hand_idx, 0))
hand_row.add_child(_hand_option)
"throw_prop":
var throw_row := HBoxContainer.new()
_params_box.add_child(throw_row)
_target_label = Label.new()
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
throw_row.add_child(_target_label)
_set_target_btn = Button.new()
_set_target_btn.text = "🎯 Click target…"
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
throw_row.add_child(_set_target_btn)
_update_throw_target_label()
"speak":
var tlabel := Label.new()
tlabel.text = "Text:"
@@ -247,6 +312,20 @@ func _update_walk_target_label() -> void:
_target_label.text = "Target: not set"
func _update_prop_target_label() -> void:
if _prop_target_label != null:
_prop_target_label.text = "Prop: %s" % (_prop_target_id if not _prop_target_id.is_empty() else "not set")
func _update_throw_target_label() -> void:
if _target_label == null:
return
if _has_throw_target:
_target_label.text = "Target: (%d, %d)" % [int(roundf(_throw_target_pos.x)), int(roundf(_throw_target_pos.y))]
else:
_target_label.text = "Target: not set"
func _on_ok_pressed() -> void:
var type := _current_type()
var action: Dictionary = { "type": type }
@@ -257,6 +336,18 @@ func _on_ok_pressed() -> void:
target_requested.emit()
return
action["target"] = _target_pos
"pick_up_prop":
if _prop_target_id.is_empty():
target_requested.emit()
return
var hands: Array[String] = ["auto", "left", "right"]
action["target_prop_id"] = _prop_target_id
action["hand"] = hands[_hand_option.selected] if _hand_option != null and _hand_option.selected >= 0 else "auto"
"throw_prop":
if not _has_throw_target:
target_requested.emit()
return
action["target_position"] = _throw_target_pos
"speak":
action["text"] = _text_edit.text if _text_edit != null else ""
action["duration"] = _duration_spin.value if _duration_spin != null else 2.0
+42
View File
@@ -47,6 +47,29 @@ const ACTION_TEMPLATES := {
"params": [],
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
},
"pick_up_prop": {
"label": "Pick Up Prop",
"icon": "🫴",
"params": [
{ "key": "target_prop_id", "type": "prop", "required": true },
{ "key": "hand", "type": "choice", "default": "auto", "choices": ["auto", "left", "right"] },
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
"drop_prop": {
"label": "Drop Prop",
"icon": "⬇️",
"params": [],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
"throw_prop": {
"label": "Throw Prop",
"icon": "🏹",
"params": [
{ "key": "target_position", "type": "position", "required": true },
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
}
@@ -102,6 +125,11 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
params["stop_current"] = true
"wait":
params["duration"] = float(action.get("duration", 0.0))
"pick_up_prop":
params["target_prop_id"] = String(action.get("target_prop_id", ""))
params["hand"] = String(action.get("hand", "auto"))
"throw_prop":
params["target_position"] = action.get("target_position", Vector2.ZERO)
return {
"type": String(action.get("type", "")),
"target": target_id,
@@ -126,6 +154,12 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
return speak
"wait":
return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
"pick_up_prop":
return { "type": "pick_up_prop", "target_prop_id": String(params.get("target_prop_id", "")), "hand": String(params.get("hand", "auto")) }
"drop_prop":
return { "type": "drop_prop" }
"throw_prop":
return { "type": "throw_prop", "target_position": params.get("target_position", Vector2.ZERO) }
_:
return { "type": String(rule_action.get("type", "")) }
@@ -155,6 +189,14 @@ static func summarize(action: Dictionary) -> String:
return "Ragdoll"
"recover":
return "Recover"
"pick_up_prop":
return "Pick Up Prop (%s)" % String(action.get("target_prop_id", params.get("target_prop_id", "")))
"drop_prop":
return "Drop Prop"
"throw_prop":
var raw_position: Variant = action.get("target_position", params.get("target_position", Vector2.ZERO))
var target_position: Vector2 = raw_position as Vector2 if raw_position is Vector2 else Vector2.ZERO
return "Throw Prop (%d, %d)" % [int(roundf(target_position.x)), int(roundf(target_position.y))]
_:
return type
+2
View File
@@ -3,11 +3,13 @@ extends Node2D
var original_entry: Dictionary = {}
var missing_prop_path: String = ""
var stage_object_id: String = ""
const MARKER_SIZE := Vector2(180.0, 54.0)
func configure(entry: Dictionary) -> void:
original_entry = entry.duplicate(true)
missing_prop_path = String(entry.get("asset_id", ""))
stage_object_id = String(entry.get("stage_object_id", ""))
queue_redraw()
func get_area_rect() -> Rect2:
+39
View File
@@ -11,6 +11,7 @@ extends RigidBody2D
## friction/bounce (PhysicsMaterial), and themed visuals.
const PropVisuals := preload("res://scripts/prop_visuals.gd")
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
# ---------------------------------------------------------------------------
# Enums
@@ -23,6 +24,13 @@ enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
## Stable library provenance used by Sandbox Stage scene files.
var spawn_id: String = ""
## Stable identity of this placed prop in one Sandbox Stage scene. Unlike
## spawn_id, this value is unique per instance and survives scene save/load.
var stage_object_id: String = ""
var interaction_anchors: Dictionary = {}
var interaction_reserved_by: int = 0
var interaction_owner_id: int = 0
# ---------------------------------------------------------------------------
# Child node names
# ---------------------------------------------------------------------------
@@ -126,6 +134,33 @@ func _ready() -> void:
body_exited.connect(_on_body_exited)
func set_interaction_anchors(value: Dictionary) -> void:
interaction_anchors = PropAnchorDataScript.normalize_anchors(value)
func get_interaction_anchors() -> Dictionary:
return PropAnchorDataScript.normalize_anchors(interaction_anchors, false)
func ensure_default_grip() -> void:
var normalized := get_interaction_anchors()
if not PropAnchorDataScript.ids_for_role(normalized, "grip").is_empty():
interaction_anchors = normalized
return
var bounds := _visual_local_bounds
if bounds.size == Vector2.ZERO:
if shape_type == ShapeType.CIRCLE:
bounds = Rect2(Vector2(-radius, -radius), Vector2(radius * 2.0, radius * 2.0))
elif polygon_points.size() >= 3:
var min_point := polygon_points[0]
var max_point := polygon_points[0]
for point: Vector2 in polygon_points:
min_point = min_point.min(point)
max_point = max_point.max(point)
bounds = Rect2(min_point, max_point - min_point)
interaction_anchors = PropAnchorDataScript.preset_layout("one_handed", bounds)
func _on_body_entered(body: Node) -> void:
var center: Vector2 = (body as Node2D).global_position if body is Node2D else global_position
var normal: Vector2 = (center - global_position).normalized()
@@ -277,6 +312,10 @@ func has_custom_shapes() -> bool:
func get_visual_local_bounds() -> Rect2:
return _visual_local_bounds
func get_interaction_scale() -> Vector2:
return _play_instance_scale if _play_scale_active else scale.abs()
## RigidBody2D transforms do not reliably retain scale during simulation. Move
## the authored uniform scale into child visuals and collision geometry for
## PLAY, then restore the root scale on return to EDIT for scene persistence.
+1 -1
View File
@@ -75,7 +75,7 @@ static func make_custom_entry(path: String) -> Dictionary:
var name := String(parsed.get("prop_name", "")).strip_edges()
if name.is_empty(): name = normalized_path.get_file().get_basename()
var preset: int = int(parsed.get("material_preset", PROP_BLOCK.MaterialPreset.NONE))
return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull}
return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull, "anchors": parsed.get("anchors", {})}
static func is_custom_path(path: String) -> bool:
+332 -9
View File
@@ -52,7 +52,7 @@ enum RunState { STOPPED, RUNNING, PAUSED }
enum RuleStep { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARGET, ACTION_POSITION, PARAMS }
## Phase 3c target-capture kinds (what the next stage click resolves to).
enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION }
enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION }
enum PendingFileAction { NONE, NEW_SCENE, OPEN_DIALOG, OPEN_PATH, QUIT }
# ---------------------------------------------------------------------------
@@ -106,6 +106,9 @@ const TRIG_SPEECH_FINISHED := 2
const TRIG_ENTERED_AREA := 3
const TRIG_COLLIDED := 4
const TRIG_BACK := 5
const TRIG_PROP_PICKED_UP := 6
const TRIG_PROP_DROPPED := 7
const TRIG_PROP_THROWN := 8
## Phase 4 "add another / done" popup item ids.
const RULE_MORE_ADD := 0
@@ -118,6 +121,9 @@ const RULE_ACTION_DONE := 6
## Phase 3c action-popup item ids ("Edit Queue…" / "Edit Rules…").
const ACT_EDIT_QUEUE := 7
const ACT_EDIT_RULES := 8
const ACT_PICK_UP_PROP := 9
const ACT_DROP_PROP := 10
const ACT_THROW_PROP := 11
## Phase 3c stickman right-click context-menu item ids.
const RIG_CTX_EDIT_QUEUE := 0
@@ -276,6 +282,7 @@ var _edit_physics_settle_frames: int = 0
var _action_popup: PopupMenu = null
var _context_rig: StickmanRig = null
var _pending_walk_target: bool = false
var _next_stage_prop_serial: int = 0
var _speak_dialog: AcceptDialog = null
var _speak_edit: LineEdit = null
var _speak_stop_current_check: CheckBox = null
@@ -399,6 +406,9 @@ var _capture_kind: CaptureKind = CaptureKind.NONE
var _capture_hint: String = ""
var _capture_callback: Callable = Callable()
var _capture_cancel: Callable = Callable()
var _capture_throw_preview_rig: StickmanRig = null
var _capture_throw_preview_prefix: Array[Dictionary] = []
var _throw_preview_cache: Dictionary = {}
## Walk-edit state (Phase 3c.3): re-placing an existing walk_to target.
var _walk_edit_rig: StickmanRig = null
@@ -492,6 +502,7 @@ func _process(delta: float) -> void:
func _physics_process(delta: float) -> void:
_update_throw_preview_cache()
if _run_state == RunState.RUNNING:
_elapsed_seconds += delta
_set_timestamp(_elapsed_seconds)
@@ -507,6 +518,19 @@ func _physics_process(delta: float) -> void:
if current_mode == StageMode.PLAY and _event_dispatch_ready and _run_state == RunState.RUNNING:
_update_area_entry()
func _update_throw_preview_cache() -> void:
if _capture_kind != CaptureKind.THROW_POSITION or _capture_throw_preview_rig == null or not is_instance_valid(_capture_throw_preview_rig):
_throw_preview_cache.clear()
return
if _camera == null:
_throw_preview_cache.clear()
return
var target := _camera.get_global_mouse_position()
if _snap_enabled:
target = _snap_to_grid(target)
_throw_preview_cache = _capture_throw_preview_rig.preview_prop_throw(target, _capture_throw_preview_prefix)
# ---------------------------------------------------------------------------
# Input
# ---------------------------------------------------------------------------
@@ -924,6 +948,7 @@ func _reset_run() -> void:
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as StickmanRig
rig.reset_prop_interactions_for_scene_exit()
rig.stop_queue()
rig.set_terrain_following_enabled(false)
rig.snap_to_standing()
@@ -1118,6 +1143,11 @@ func _place_at(world_pos: Vector2) -> void:
## Common post-spawn wiring: freeze kinematic props in EDIT, save authored state,
## and connect the Phase 3a/4 signals for stickmen/props.
func _finalize_placed_node(node: Node2D) -> void:
if node is PropBlock:
var placed_prop := node as PropBlock
if placed_prop.stage_object_id.is_empty():
placed_prop.stage_object_id = _new_stage_prop_id()
placed_prop.ensure_default_grip()
if current_mode == StageMode.EDIT and node is RigidBody2D:
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
@@ -1125,11 +1155,13 @@ func _finalize_placed_node(node: Node2D) -> void:
_save_object_state(node)
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.set_prop_resolver(Callable(self, "_resolve_prop_by_stage_id"))
rig.queue_changed.connect(_on_rig_queue_changed)
rig.arrived.connect(_on_rig_arrived.bind(rig))
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
rig.state_changed.connect(_on_rig_state_changed.bind(rig))
rig.prop_interaction_event.connect(_on_rig_prop_interaction_event.bind(rig))
_director_visuals.mark_dirty()
if node is PropBlock:
var prop := node as PropBlock
@@ -1137,6 +1169,39 @@ func _finalize_placed_node(node: Node2D) -> void:
prop.separated.connect(_on_prop_separated.bind(prop))
func _new_stage_prop_id() -> String:
while true:
_next_stage_prop_serial += 1
var candidate := "prop_%d_%d_%d" % [Time.get_ticks_usec(), randi(), _next_stage_prop_serial]
if not _stage_prop_id_in_use(candidate):
return candidate
return ""
func _stage_prop_id_in_use(stage_id: String) -> bool:
for child: Node in _world.get_children():
if child is PropBlock and (child as PropBlock).stage_object_id == stage_id:
return true
if child.get_script() == MISSING_PROP_PLACEHOLDER and String(child.get("stage_object_id")) == stage_id:
return true
return false
func _resolve_prop_by_stage_id(stage_id: String) -> PropBlock:
if stage_id.is_empty():
return null
for child: Node in _world.get_children():
if child is PropBlock and (child as PropBlock).stage_object_id == stage_id:
return child as PropBlock
return null
func _on_rig_prop_interaction_event(event_type: String, prop: PropBlock, reason: String, rig: StickmanRig) -> void:
if prop == null or not is_instance_valid(prop) or rig == null or not is_instance_valid(rig):
return
_handle_event({ "type": event_type, "source": rig, "target": prop, "position": prop.global_position, "reason": reason, "action": {} })
func _spawn_ghost() -> void:
_free_ghost()
if _placement_id == "":
@@ -1559,9 +1624,11 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
elif type == "prop" and n.get_script() == MISSING_PROP_PLACEHOLDER:
entry = (n.get("original_entry") as Dictionary).duplicate(true)
entry["file_id"] = file_id; entry["type"] = "prop"; entry["position"] = STAGE_SCENE_IO.vector_to_data(pos); entry["rotation"] = rot; entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
entry["stage_object_id"] = String(n.get("stage_object_id"))
elif type == "prop":
var prop := n as PropBlock
entry["asset_id"] = prop.spawn_id
entry["stage_object_id"] = prop.stage_object_id
var prop_scale: Vector2 = authored.get("scale", prop.scale)
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
entry["material_preset"] = prop.material_preset
@@ -1662,6 +1729,7 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
continue
(node as TriggerArea).size = area_size
if node is PropBlock:
(node as PropBlock).stage_object_id = String(entry.get("stage_object_id", ""))
var preset_names: Array = PropBlock.MaterialPreset.keys()
var raw_preset: Variant = entry.get("material_preset", PropBlock.MaterialPreset.NONE)
var index := -1
@@ -2333,6 +2401,9 @@ func _build_ui() -> void:
_action_popup.add_item("⏳ Wait", ACT_WAIT)
_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_action_popup.add_item("🫴 Pick Up Prop…", ACT_PICK_UP_PROP)
_action_popup.add_item("⬇️ Drop Prop", ACT_DROP_PROP)
_action_popup.add_item("🏹 Throw Prop…", ACT_THROW_PROP)
_action_popup.add_separator()
_action_popup.add_item("⚡ When...", ACT_WHEN)
_action_popup.add_separator()
@@ -2348,6 +2419,9 @@ func _build_ui() -> void:
_trigger_popup.add_item("💬 Finishes speaking", TRIG_SPEECH_FINISHED)
_trigger_popup.add_item("🎯 Enters trigger area", TRIG_ENTERED_AREA)
_trigger_popup.add_item("💥 Collides with something", TRIG_COLLIDED)
_trigger_popup.add_item("🫴 Picks up a prop", TRIG_PROP_PICKED_UP)
_trigger_popup.add_item("⬇️ Drops a prop", TRIG_PROP_DROPPED)
_trigger_popup.add_item("🏹 Throws a prop", TRIG_PROP_THROWN)
_trigger_popup.add_separator()
_trigger_popup.add_item("⬅ Back to actions", TRIG_BACK)
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
@@ -2359,6 +2433,9 @@ func _build_ui() -> void:
_rule_action_popup.add_item("⏳ Wait", ACT_WAIT)
_rule_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_rule_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_rule_action_popup.add_item("🫴 Pick Up Prop…", ACT_PICK_UP_PROP)
_rule_action_popup.add_item("⬇️ Drop Prop", ACT_DROP_PROP)
_rule_action_popup.add_item("🏹 Throw Prop…", ACT_THROW_PROP)
_rule_action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed)
ui.add_child(_rule_action_popup)
@@ -3110,6 +3187,18 @@ func _on_action_popup_id_pressed(id: int) -> void:
_context_rig.queue_action({ "type": "ragdoll" })
ACT_RECOVER:
_context_rig.queue_action({ "type": "recover" })
ACT_PICK_UP_PROP:
_begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_direct_pickup_prop, _cb_direct_capture_cancel)
ACT_DROP_PROP:
_context_rig.queue_action({ "type": "drop_prop" })
ACT_THROW_PROP:
_begin_throw_position_capture(
_context_rig,
"🏹 Click where to throw (Esc or RMB to cancel)",
_cb_direct_throw_target,
_cb_direct_capture_cancel,
_throw_preview_prefix_for(_context_rig)
)
ACT_WHEN:
_begin_rule_build(_context_rig.get_instance_id())
ACT_EDIT_QUEUE:
@@ -3118,6 +3207,26 @@ func _on_action_popup_id_pressed(id: int) -> void:
_open_rules_panel_for_rig(_context_rig)
func _cb_direct_pickup_prop(value: Node2D) -> void:
if _context_rig != null and is_instance_valid(_context_rig) and value is PropBlock:
var prop := value as PropBlock
if not prop.stage_object_id.is_empty():
_context_rig.queue_action({ "type": "pick_up_prop", "target_prop_id": prop.stage_object_id, "hand": "auto" })
else:
_show_toast("That prop is missing its scene ID")
_context_rig = null
func _cb_direct_throw_target(value: Vector2) -> void:
if _context_rig != null and is_instance_valid(_context_rig):
_context_rig.queue_action({ "type": "throw_prop", "target_position": value })
_context_rig = null
func _cb_direct_capture_cancel() -> void:
_context_rig = null
# ---------------------------------------------------------------------------
# Director UX (Phase 4b): cursor-attached tooltip, trajectory, ghost marker
# ---------------------------------------------------------------------------
@@ -3136,6 +3245,12 @@ func _action_hint_text() -> String:
if _pending_walk_target:
return "🚩 Click to set walk target"
if _capture_kind != CaptureKind.NONE:
if _capture_kind == CaptureKind.THROW_POSITION and not _throw_preview_cache.is_empty():
if not bool(_throw_preview_cache.get("preview_available", true)):
return "ℹ %s Click to set the throw target." % String(_throw_preview_cache.get("preview_reason", "No throw preview is available."))
var preview_hint := String(_throw_preview_cache.get("preview_hint", ""))
if not preview_hint.is_empty():
return "%s %s" % [_capture_hint, preview_hint]
return _capture_hint
match _rule_step:
RuleStep.TRIGGER_TARGET:
@@ -3157,6 +3272,10 @@ func _action_hint_text() -> String:
func _action_origin() -> Vector2:
if _capture_kind == CaptureKind.THROW_POSITION and _capture_throw_preview_rig != null and is_instance_valid(_capture_throw_preview_rig):
if not _throw_preview_cache.is_empty():
return _throw_preview_cache.get("start_position", _capture_throw_preview_rig.global_position)
return _capture_throw_preview_rig.global_position
if _pending_walk_target:
if _context_rig != null and is_instance_valid(_context_rig):
return _context_rig.global_position - StickmanLocomotion.FOOT_OFFSET
@@ -3191,9 +3310,37 @@ func _update_action_overlay() -> void:
if awaiting:
var origin := _action_origin()
var target := _camera.get_global_mouse_position()
if _snap_enabled and _rule_step == RuleStep.ACTION_POSITION:
if _snap_enabled and (_rule_step == RuleStep.ACTION_POSITION or _capture_kind == CaptureKind.THROW_POSITION):
target = _snap_to_grid(target)
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
if _capture_kind == CaptureKind.THROW_POSITION:
if _capture_throw_preview_rig == null or not is_instance_valid(_capture_throw_preview_rig):
_placement_overlay.set_throw_preview_unavailable(target)
else:
var throw_preview := _throw_preview_cache
if throw_preview.is_empty():
_placement_overlay.set_throw_preview_unavailable(target)
elif not bool(throw_preview.get("preview_available", true)):
_placement_overlay.set_throw_preview_unavailable(target)
else:
var trajectory_points: PackedVector2Array = throw_preview.get("trajectory_points", PackedVector2Array())
var effective_target: Vector2 = throw_preview.get("effective_target", target)
var collision_position: Vector2 = throw_preview.get("collision_position", Vector2.INF)
var target_reachable := bool(throw_preview.get("target_reachable", true))
var preview_planned := bool(throw_preview.get("preview_planned", false))
var preview_uncertain := bool(throw_preview.get("preview_uncertain", false))
_placement_overlay.set_throw_trajectory(
throw_preview["start_position"],
target,
throw_preview["landing_position"],
target_reachable,
trajectory_points,
effective_target,
collision_position,
preview_planned,
preview_uncertain
)
else:
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
else:
_placement_overlay.clear_action()
if _tooltip != null:
@@ -3317,7 +3464,7 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
var trigger: Dictionary = _rule_builder.get("trigger", {})
trigger["type"] = _trigger_type_string(id)
_rule_builder["trigger"] = trigger
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED:
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED or id == TRIG_PROP_PICKED_UP or id == TRIG_PROP_DROPPED or id == TRIG_PROP_THROWN:
_rule_step = RuleStep.TRIGGER_TARGET
match id:
TRIG_ARRIVED:
@@ -3326,6 +3473,8 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
_rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)"
TRIG_COLLIDED:
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN:
_rule_hint = "Click the prop to watch (Esc or RMB to cancel)"
_refresh_status()
else:
_rule_step = RuleStep.SELECT_ACTION
@@ -3366,6 +3515,12 @@ func _trigger_type_string(id: int) -> String:
return "entered_area"
TRIG_COLLIDED:
return "collided"
TRIG_PROP_PICKED_UP:
return "prop_picked_up"
TRIG_PROP_DROPPED:
return "prop_dropped"
TRIG_PROP_THROWN:
return "prop_thrown"
_:
return ""
@@ -3382,6 +3537,12 @@ func _action_type_string(id: int) -> String:
return "ragdoll"
ACT_RECOVER:
return "recover"
ACT_PICK_UP_PROP:
return "pick_up_prop"
ACT_DROP_PROP:
return "drop_prop"
ACT_THROW_PROP:
return "throw_prop"
_:
return ""
@@ -3398,6 +3559,12 @@ func _action_verb(id: int) -> String:
return "ragdoll"
ACT_RECOVER:
return "recover"
ACT_PICK_UP_PROP:
return "pick up a prop"
ACT_DROP_PROP:
return "drop a prop"
ACT_THROW_PROP:
return "throw a prop"
_:
return "act"
@@ -3436,7 +3603,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
else:
_rule_hint = "Click a trigger area (Esc or RMB to cancel)"
_refresh_status()
"collided":
"collided", "prop_picked_up", "prop_dropped", "prop_thrown":
var hit2 := _selection.hit_test(world_pos)
if hit2 is PropBlock:
trigger["target"] = hit2.get_instance_id()
@@ -3476,8 +3643,12 @@ func _handle_action_target_click(world_pos: Vector2) -> void:
_rule_step = RuleStep.PARAMS
_wait_spin.value = 1.0
_wait_dialog.popup_centered()
"pick_up_prop":
_begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_rule_action_prop_target, _cb_rule_action_capture_cancel)
"throw_prop":
_begin_throw_position_capture(hit as StickmanRig, "🏹 Click where to throw (Esc or RMB to cancel)", _cb_rule_action_throw_target, _cb_rule_action_capture_cancel)
_:
# ragdoll / recover need no params.
# Ragdoll, recover, and drop need no extra parameters.
_open_rule_more_popup()
@@ -3496,6 +3667,45 @@ func _handle_action_position_click(world_pos: Vector2) -> void:
_open_rule_more_popup()
func _cb_rule_action_prop_target(value: Node2D) -> void:
if not value is PropBlock:
return
var prop := value as PropBlock
if prop.stage_object_id.is_empty():
_show_toast("That prop is missing its scene ID")
return
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions.back()
var params: Dictionary = action.get("params", {})
params["target_prop_id"] = prop.stage_object_id
params["hand"] = String(params.get("hand", "auto"))
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
_open_rule_more_popup()
func _cb_rule_action_throw_target(value: Vector2) -> void:
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions.back()
var params: Dictionary = action.get("params", {})
params["target_position"] = value
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
_open_rule_more_popup()
func _cb_rule_action_capture_cancel() -> void:
_rule_step = RuleStep.ACTION_TARGET
_rule_hint = "Click the stickman who will act (Esc or RMB to cancel)"
_refresh_status()
## Writes dialog-confirmed params onto the last action in the builder.
func _set_rule_action_params(params: Dictionary) -> void:
var actions: Array = _rule_builder.get("actions", [])
@@ -3737,6 +3947,8 @@ func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
return _ids_match(source_id, source) and _ids_match(target_id, target)
"collided":
return _ids_match(source_id, source) and _ids_match(target_id, target)
"prop_picked_up", "prop_dropped", "prop_thrown":
return _ids_match(source_id, source) and _ids_match(target_id, target)
_:
return false
@@ -3776,6 +3988,11 @@ func _action_for_rig(a: Dictionary) -> Dictionary:
out["stop_current"] = bool(params.get("stop_current", false))
"wait":
out["duration"] = float(params.get("duration", 0.0))
"pick_up_prop":
out["target_prop_id"] = String(params.get("target_prop_id", ""))
out["hand"] = String(params.get("hand", "auto"))
"throw_prop":
out["target_position"] = params.get("target_position", Vector2.ZERO)
return out
@@ -3952,19 +4169,58 @@ func _begin_capture(kind: CaptureKind, hint: String, on_resolve: Callable, on_ca
_capture_hint = hint
_capture_callback = on_resolve
_capture_cancel = on_cancel
_capture_throw_preview_rig = null
_capture_throw_preview_prefix.clear()
_throw_preview_cache.clear()
_apply_cursor()
_refresh_status()
func _begin_throw_position_capture(rig: StickmanRig, hint: String, on_resolve: Callable, on_cancel: Callable = Callable(), queue_prefix: Array[Dictionary] = []) -> void:
_begin_capture(CaptureKind.THROW_POSITION, hint, on_resolve, on_cancel)
_capture_throw_preview_rig = rig if rig != null and is_instance_valid(rig) else null
_capture_throw_preview_prefix = queue_prefix.duplicate(true)
_throw_preview_cache.clear()
func _end_capture() -> void:
_capture_kind = CaptureKind.NONE
_capture_hint = ""
_capture_callback = Callable()
_capture_cancel = Callable()
_capture_throw_preview_rig = null
_capture_throw_preview_prefix.clear()
_throw_preview_cache.clear()
_apply_cursor()
_refresh_status()
func _throw_preview_prefix_for(rig: StickmanRig, before_action_id: int = -1) -> Array[Dictionary]:
var result: Array[Dictionary] = []
if rig == null or not is_instance_valid(rig):
return result
var queue_view: Array[Dictionary] = rig.get_queue_view()
var limit := queue_view.size()
if before_action_id >= 0:
limit = rig.index_for_action_id(before_action_id)
if limit < 0:
return result
for index: int in mini(limit, queue_view.size()):
var entry: Dictionary = queue_view[index]
var status := int(entry.get("status", StickmanActionRunner.QueueStatus.NOT_STARTED))
if status == StickmanActionRunner.QueueStatus.COMPLETED or status == StickmanActionRunner.QueueStatus.INTERRUPTED:
continue
var action: Dictionary = entry.get("action", {})
if action.is_empty():
continue
result.append({
"action": action.duplicate(true),
"step": index + 1,
"active": status == StickmanActionRunner.QueueStatus.ACTIVE,
})
return result
func _cancel_capture() -> void:
var cb := _capture_cancel
_end_capture()
@@ -3993,6 +4249,8 @@ func _resolve_capture(world_pos: Vector2) -> void:
value = hit3
CaptureKind.POSITION:
value = _snap_to_grid(world_pos) if _snap_enabled else world_pos
CaptureKind.THROW_POSITION:
value = _snap_to_grid(world_pos) if _snap_enabled else world_pos
if value == null:
_refresh_status()
return
@@ -4412,8 +4670,12 @@ func _on_action_editor_committed(flat: Dictionary) -> void:
else:
_show_toast("That action can no longer be used as an insert point")
"rule_add":
_pending_rule_action_flat = flat
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
if _action_editor_actor_id >= 0:
if _rule_editor != null:
_rule_editor.set_action(-1, ACTION_REGISTRY.to_rule_action(flat, _action_editor_actor_id))
else:
_pending_rule_action_flat = flat
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
return
"rule_edit":
if _rule_editor != null:
@@ -4435,7 +4697,33 @@ func _on_action_editor_cancelled() -> void:
func _on_action_editor_target_requested() -> void:
_action_editor.hide()
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
var action_type := _action_editor.current_action_type()
if _action_editor_kind == "rule_add" and _action_editor_actor_id < 0 and (action_type == "pick_up_prop" or action_type == "throw_prop"):
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_editor_actor_for_target, _cb_editor_actor_capture_cancel)
return
_begin_editor_action_target_capture()
func _begin_editor_action_target_capture() -> void:
match _action_editor.current_action_type():
"pick_up_prop":
_begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_editor_prop_target, _cb_editor_walk_target_cancel)
"throw_prop":
var preview_rig := _action_editor_preview_rig()
var queue_prefix: Array[Dictionary] = []
if _action_editor_kind == "queue_edit":
queue_prefix = _throw_preview_prefix_for(preview_rig, _action_editor_action_id)
elif _action_editor_kind == "queue_add":
queue_prefix = _throw_preview_prefix_for(preview_rig)
_begin_throw_position_capture(
preview_rig,
"🏹 Click where to throw (Esc or RMB to cancel)",
_cb_editor_throw_target,
_cb_editor_walk_target_cancel,
queue_prefix
)
_:
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
func _cb_editor_walk_target(value: Vector2) -> void:
@@ -4443,6 +4731,41 @@ func _cb_editor_walk_target(value: Vector2) -> void:
_action_editor.set_walk_target(value)
func _cb_editor_prop_target(value: Node2D) -> void:
if _action_editor != null and value is PropBlock:
var prop := value as PropBlock
if not prop.stage_object_id.is_empty():
_action_editor.set_prop_target(prop.stage_object_id)
else:
_action_editor.popup_centered()
func _cb_editor_throw_target(value: Vector2) -> void:
if _action_editor != null:
_action_editor.set_throw_target(value)
func _cb_editor_actor_for_target(value: Node2D) -> void:
if not value is StickmanRig:
if _action_editor != null:
_action_editor.popup_centered()
return
_action_editor_actor_id = int(value.get_instance_id())
_begin_editor_action_target_capture()
func _cb_editor_actor_capture_cancel() -> void:
if _action_editor != null:
_action_editor.popup_centered()
func _action_editor_preview_rig() -> StickmanRig:
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
return _action_editor_rig
var actor := instance_from_id(_action_editor_actor_id) as StickmanRig
return actor if actor != null and is_instance_valid(actor) else null
func _cb_editor_walk_target_cancel() -> void:
if _action_editor != null:
_action_editor.popup_centered()
+83 -3
View File
@@ -19,7 +19,16 @@ var guide_line_color: Color = Color("#22c6ff")
var action_visible: bool = false
var action_origin: Vector2 = Vector2.ZERO
var action_target: Vector2 = Vector2.ZERO
var action_curve_points: PackedVector2Array = PackedVector2Array()
var action_valid: bool = true
var action_requested_target: Vector2 = Vector2.ZERO
var action_show_requested_target: bool = false
var action_effective_target: Vector2 = Vector2.ZERO
var action_collision_position: Vector2 = Vector2.INF
var action_show_collision: bool = false
var action_preview_unavailable: bool = false
var action_preview_planned: bool = false
var action_preview_uncertain: bool = false
var valid_color: Color = Color(0.3, 0.9, 0.4)
var invalid_color: Color = Color(1.0, 0.3, 0.3)
var line_width_px: float = 2.0
@@ -62,11 +71,53 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi
action_visible = true
action_origin = origin
action_target = target
action_curve_points.clear()
action_valid = valid
action_show_requested_target = false
action_show_collision = false
action_preview_unavailable = false
action_preview_planned = false
action_preview_uncertain = false
func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_target: Vector2, valid: bool, trajectory_points: PackedVector2Array = PackedVector2Array(), effective_target: Vector2 = Vector2.INF, collision_position: Vector2 = Vector2.INF, planned: bool = false, uncertain: bool = false) -> void:
action_visible = true
action_origin = origin
action_target = landing_target
action_curve_points = trajectory_points.duplicate()
action_valid = valid
action_requested_target = requested_target
action_show_requested_target = true
action_effective_target = effective_target if effective_target.is_finite() else landing_target
action_collision_position = collision_position
action_show_collision = collision_position.is_finite()
action_preview_unavailable = false
action_preview_planned = planned
action_preview_uncertain = uncertain
func set_throw_preview_unavailable(requested_target: Vector2) -> void:
action_visible = true
action_origin = requested_target
action_target = requested_target
action_curve_points.clear()
action_valid = false
action_requested_target = requested_target
action_show_requested_target = false
action_show_collision = false
action_preview_unavailable = true
action_preview_planned = false
action_preview_uncertain = false
func clear_action() -> void:
action_visible = false
action_curve_points.clear()
action_show_requested_target = false
action_show_collision = false
action_preview_unavailable = false
action_preview_planned = false
action_preview_uncertain = false
queue_redraw()
@@ -76,9 +127,29 @@ func _draw() -> void:
_draw_cell_marker(terrain_anchor, guide_line_color)
_draw_cell_marker(terrain_target, guide_line_color)
if action_visible:
var color := valid_color if action_valid else invalid_color
_draw_dashed(action_origin, action_target, color)
_draw_ghost_marker(action_target, color)
if action_preview_unavailable:
var zoom := _zoom()
var unavailable_color := Color(0.74, 0.79, 0.85, 0.9)
draw_arc(action_target, MARKER_RADIUS_PX / zoom, 0.0, TAU, 24, unavailable_color, LINE_WIDTH_PX / zoom, true)
else:
var color := valid_color if action_valid else invalid_color
if action_valid and action_preview_planned:
color = Color(0.35, 0.8, 1.0, 0.95)
elif action_valid and action_preview_uncertain:
color = Color(0.8, 0.68, 1.0, 0.95)
if action_curve_points.size() >= 2:
draw_polyline(action_curve_points, color, line_width_px / _zoom(), true)
else:
_draw_dashed(action_origin, action_target, color)
if action_show_requested_target:
_draw_ghost_marker(action_effective_target, color)
var request_color := Color(1.0, 0.85, 0.25, 0.9)
var r := 7.0 / _zoom()
draw_arc(action_requested_target, r, 0.0, TAU, 20, request_color, LINE_WIDTH_PX / _zoom(), true)
if action_show_collision:
_draw_collision_marker(action_collision_position)
else:
_draw_ghost_marker(action_target, color)
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
@@ -113,6 +184,15 @@ func _draw_ghost_marker(pos: Vector2, color: Color) -> void:
draw_line(pos + Vector2(0.0, -r), pos + Vector2(0.0, r), color, LINE_WIDTH_PX / zoom, true)
func _draw_collision_marker(pos: Vector2) -> void:
var zoom := _zoom()
var r := 8.0 / zoom
var color := Color(1.0, 0.25, 0.15, 0.95)
draw_arc(pos, r, 0.0, TAU, 24, color, LINE_WIDTH_PX / zoom, true)
draw_line(pos + Vector2(-r * 0.55, -r * 0.55), pos + Vector2(r * 0.55, r * 0.55), color, LINE_WIDTH_PX / zoom, true)
draw_line(pos + Vector2(-r * 0.55, r * 0.55), pos + Vector2(r * 0.55, -r * 0.55), color, LINE_WIDTH_PX / zoom, true)
func _zoom() -> float:
if camera != null and is_instance_valid(camera):
return maxf(camera.zoom.x, 0.0001)
+44 -3
View File
@@ -3,7 +3,7 @@ extends RefCounted
## JSON boundary for Sandbox Stage documents. This class deliberately has no
## scene-tree dependencies, which makes rejected documents non-destructive.
const FILE_VERSION: String = "1.0"
const FILE_VERSION: String = "1.1"
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
@@ -149,17 +149,38 @@ static func write_file_safe(path: String, data: Dictionary) -> Dictionary:
return {"ok": true, "error": ""}
static func _migrate_to_current(data: Dictionary) -> Dictionary:
if String(data.get("version", "")) != FILE_VERSION: return {}
return data
var version := String(data.get("version", ""))
if version == FILE_VERSION:
return data
if version != "1.0":
return {}
var migrated: Dictionary = data.duplicate(true)
for raw: Variant in migrated.get("objects", []):
if not raw is Dictionary:
continue
var object: Dictionary = raw
if String(object.get("type", "")) == "prop":
object["stage_object_id"] = String(object.get("file_id", ""))
migrated["version"] = FILE_VERSION
return migrated
static func _validate_root(data: Dictionary) -> Dictionary:
var seen: Dictionary = {}
var seen_stage_ids: Dictionary = {}
for raw: Variant in data["objects"]:
if not raw is Dictionary: continue
var entry: Dictionary = raw
var file_id := String(entry.get("file_id", ""))
if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."}
seen[file_id] = true
if String(entry.get("type", "")) == "prop":
var raw_stage_id: Variant = entry.get("stage_object_id", "")
if not raw_stage_id is String:
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty strings."}
var stage_id := String(raw_stage_id)
if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id):
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty."}
seen_stage_ids[stage_id] = true
return {"ok": true, "error": ""}
static func _recover_backup(path: String) -> void:
@@ -180,6 +201,17 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
return {"type": type, "target": vector_to_data(action["target"])}
"speak": return {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0)), "stop_current": bool(action.get("stop_current", false))} if _finite_number(action.get("duration", 2.0)) else {}
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
"pick_up_prop":
var prop_id := String(action.get("target_prop_id", ""))
var hand := String(action.get("hand", "auto"))
if prop_id.is_empty() or not ["auto", "left", "right"].has(hand): return {}
return {"type": type, "target_prop_id": prop_id, "hand": hand}
"drop_prop": return {"type": type}
"throw_prop":
if not action.get("target_position") is Vector2: return {}
var target_position: Vector2 = action["target_position"]
if not target_position.is_finite(): return {}
return {"type": type, "target_position": vector_to_data(target_position)}
_: return {"type": type}
static func _decode_flat_action(action: Dictionary) -> Dictionary:
@@ -195,6 +227,15 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
if bool(action.get("stop_current", false)): out["stop_current"] = true
return out
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
"pick_up_prop":
var prop_id := String(action.get("target_prop_id", ""))
var hand := String(action.get("hand", "auto"))
if prop_id.is_empty() or not ["auto", "left", "right"].has(hand): return {}
return {"type": type, "target_prop_id": prop_id, "hand": hand}
"drop_prop": return {"type": type}
"throw_prop":
if not is_vector_data(action.get("target_position")): return {}
return {"type": type, "target_position": data_to_vector(action["target_position"], Vector2.ZERO)}
_: return {"type": type}
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
+2
View File
@@ -176,6 +176,8 @@ func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock:
else:
prop = PROP_UTILS.spawn_prop(_world, object_position, template["payload"], int(template["material_preset"]), Vector2.ZERO)
prop.spawn_id = String(template.get("id", asset_id))
prop.set_interaction_anchors(template.get("anchors", {}) as Dictionary)
prop.ensure_default_grip()
return prop
+27 -3
View File
@@ -9,7 +9,7 @@ const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
enum RunnerState { IDLE, EXECUTING }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION }
enum QueueStatus { NOT_STARTED, COMPLETED, INTERRUPTED, ACTIVE, PENDING }
signal action_started(action: Dictionary, index: int)
@@ -29,6 +29,7 @@ var action_queue: Array[Dictionary]:
set_authored_queue(value)
var _rig: StickmanRig = null
var _locomotion: StickmanLocomotion = null
var _prop_interactor: Node = null
var _runner_state: RunnerState = RunnerState.IDLE
var _action_phase: ActionPhase = ActionPhase.NONE
var _current_index: int = -1
@@ -46,11 +47,15 @@ var _completed_ids: Dictionary = {}
var _interrupted_ids: Dictionary = {}
var _paused: bool = false
var _session_started: bool = false
var _prop_action_finished: bool = false
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
func configure(rig: StickmanRig, locomotion: StickmanLocomotion, prop_interactor: Node = null) -> void:
_rig = rig
_locomotion = locomotion
_prop_interactor = prop_interactor
if _prop_interactor != null and not _prop_interactor.is_connected("action_completed", _on_prop_action_completed):
_prop_interactor.connect("action_completed", _on_prop_action_completed)
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
@@ -78,6 +83,10 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
ActionPhase.RECOVERING:
if rig_state == StickmanRig.RigState.ANIMATED:
_finish_action()
ActionPhase.PROP_INTERACTION:
if _prop_action_finished:
_prop_action_finished = false
_finish_action()
func speak(text: String, duration: float) -> void:
@@ -358,7 +367,9 @@ func _advance_to_next_action() -> void:
_current_action_id = int(_current_run_item.get("action_id", -1))
_current_index = _index_for_action_id(_current_action_id) if _current_action_id >= 0 else -1
var action: Dictionary = _current_run_item.get("action", {})
if String(action.get("type", "")) == "walk_to" and _rig != null:
var action_type := String(action.get("type", ""))
var requires_animated := action_type == "walk_to" or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop"
if requires_animated and _rig != null:
if _rig.state == StickmanRig.RigState.RECOVERING:
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
_current_run_item.clear()
@@ -394,6 +405,12 @@ func _begin_action(action: Dictionary) -> void:
"recover":
_action_phase = ActionPhase.RECOVERING
recovery_requested.emit()
"pick_up_prop", "drop_prop", "throw_prop":
if _rig == null or not _rig.start_prop_action(action):
_finish_action()
else:
_prop_action_finished = false
_action_phase = ActionPhase.PROP_INTERACTION
_:
push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", "")))
_finish_action()
@@ -420,9 +437,12 @@ func _cancel_current_primary(notify: bool = false) -> void:
active_action_cancelled.emit(action.duplicate(true))
if _locomotion != null:
_locomotion.cancel_walk(not _is_blocked())
if _rig != null:
_rig.cancel_prop_action()
_primary_speech.clear()
_phase_timer = 0.0
_action_phase = ActionPhase.NONE
_prop_action_finished = false
_current_run_item.clear()
_current_index = -1
_current_action_id = -1
@@ -430,6 +450,10 @@ func _cancel_current_primary(notify: bool = false) -> void:
_speech_bubble.hide_bubble()
func _on_prop_action_completed() -> void:
_prop_action_finished = true
func _allocate_action_id() -> int:
var result: int = _next_action_id
_next_action_id += 1
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://bsdnwby48vl5
+42 -1
View File
@@ -179,6 +179,7 @@ signal action_finished(action: Dictionary, index: int)
signal queue_finished # queue ran to completion (not on stop)
signal queue_changed # any queue mutation
signal speech_finished # bubble auto-hid after speak()
signal prop_interaction_event(event_type: String, prop: PropBlock, reason: String)
# ---------------------------------------------------------------------------
# Internal state
@@ -225,6 +226,7 @@ var source_stk_path: String = ""
var _locomotion_component: StickmanLocomotion = null
var _ragdoll_component: StickmanRagdoll = null
var _action_runner_component: StickmanActionRunner = null
var _prop_interactor_component: Node = null
var _simulation_paused: bool = false
var _phase12_paused_animation: bool = false
var _phase12_paused_animation_name: StringName = &""
@@ -238,12 +240,15 @@ func _ready() -> void:
_locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion
_ragdoll_component = get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll
_action_runner_component = get_node_or_null(NodePath("RigComponents/ActionRunner")) as StickmanActionRunner
_prop_interactor_component = get_node_or_null(NodePath("RigComponents/PropInteractor"))
if _locomotion_component == null:
push_warning("StickmanRig: missing RigComponents/Locomotion; locomotion boundary unavailable.")
if _ragdoll_component == null:
push_warning("StickmanRig: missing RigComponents/Ragdoll; ragdoll boundary unavailable.")
if _action_runner_component == null:
push_warning("StickmanRig: missing RigComponents/ActionRunner; action boundary unavailable.")
if _prop_interactor_component == null:
push_warning("StickmanRig: missing RigComponents/PropInteractor; prop interactions unavailable.")
if _locomotion_component != null:
_locomotion_component.configure(self)
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
@@ -251,8 +256,11 @@ func _ready() -> void:
_ragdoll_component.configure(self, _locomotion_component)
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
_ragdoll_component.state_transition_requested.connect(_set_state)
if _prop_interactor_component != null:
_prop_interactor_component.call("configure", self, _locomotion_component, Callable())
_prop_interactor_component.connect("prop_event", _on_prop_interaction_event)
if _action_runner_component != null:
_action_runner_component.configure(self, _locomotion_component)
_action_runner_component.configure(self, _locomotion_component, _prop_interactor_component)
_action_runner_component.action_queue = _pending_action_queue
_action_runner_component.action_started.connect(func(action: Dictionary, index: int) -> void: action_started.emit(action, index))
_action_runner_component.action_finished.connect(func(action: Dictionary, index: int) -> void: action_finished.emit(action, index))
@@ -315,6 +323,8 @@ func _physics_process(delta: float) -> void:
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
if _locomotion_component != null:
_locomotion_component.tick_movement(delta, int(state))
if _prop_interactor_component != null:
_prop_interactor_component.call("tick", delta)
if _action_runner_component != null:
_action_runner_component.tick(delta, int(state), is_ragdoll_at_rest())
_pin_mirrored_head_rotation()
@@ -457,6 +467,8 @@ func _set_state(next_state: int) -> void:
func set_ragdoll(enabled: bool) -> void:
if enabled:
if _prop_interactor_component != null:
_prop_interactor_component.call("disarm_for_ragdoll")
if _locomotion_component != null:
_locomotion_component.reset_for_ragdoll_handoff()
match state:
@@ -480,6 +492,35 @@ func toggle_ragdoll() -> void:
set_ragdoll(not is_in_ragdoll())
func set_prop_resolver(resolver: Callable) -> void:
if _prop_interactor_component != null:
_prop_interactor_component.call("configure", self, _locomotion_component, resolver)
func start_prop_action(action: Dictionary) -> bool:
return bool(_prop_interactor_component.call("start_action", action)) if _prop_interactor_component != null else false
func cancel_prop_action() -> void:
if _prop_interactor_component != null:
_prop_interactor_component.call("cancel_action")
func preview_prop_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary:
if _prop_interactor_component == null:
return {}
return _prop_interactor_component.call("preview_throw", target, queue_prefix)
func reset_prop_interactions_for_scene_exit() -> void:
if _prop_interactor_component != null:
_prop_interactor_component.call("reset_for_scene_exit")
func _on_prop_interaction_event(event_type: String, prop: PropBlock, reason: String) -> void:
prop_interaction_event.emit(event_type, prop, reason)
## Public stand-up request. No-op unless the rig is in RAGDOLL.
func request_recovery() -> void:
if state == RigState.RAGDOLL and _ragdoll_component != null:
+15
View File
@@ -32,6 +32,21 @@ const TRIGGER_TEMPLATES := {
"icon": "💥",
"target_type": "prop",
},
"prop_picked_up": {
"label": "Picks up a prop",
"icon": "🫴",
"target_type": "prop",
},
"prop_dropped": {
"label": "Drops a prop",
"icon": "⬇️",
"target_type": "prop",
},
"prop_thrown": {
"label": "Throws a prop",
"icon": "🏹",
"target_type": "prop",
},
}
+476
View File
@@ -0,0 +1,476 @@
# Phase 11 — Prop Interactions and Event Triggers
Status: **Approved for implementation (2026-09-24).**
Throw UX amendment: **Approved for implementation (2026-09-25).**
Throw landing-target amendment: **Approved for implementation (2026-09-25).**
Queue-aware throw-preview amendment: **Approved for implementation (2026-09-25).**
Source: `plans/PHASE_11_PROP_INTERACTIONS.md`. This spec keeps the requested
pickup, drop, throw, ragdoll handoff, and “When…” features while adapting them to
the current Sandbox Stage architecture and closing the design gaps identified
during review.
## 1. Approved design decisions
1. A throw action stores a **fixed stage-world target position**. At run time the
trajectory starts at the actor’s current held-prop transform and aims at
that saved point. Actor-relative throws are deferred.
2. Phase 11 supports **one prop held in one compatible hand per stickman**.
Two-handed grips and holding multiple props are deferred.
3. Each placed prop has a **persistent per-instance stage ID**. The ID is
separate from the asset’s `spawn_id` and from Godot’s session-only instance
ID. Duplicates of the same library prop therefore remain distinguishable.
## 1.1 Previously approved throw UX amendment (2026-09-25; superseded by §1.2)
This amendment refines the approved throw action and does not add fields to its
saved payload. It supersedes the exact-landing interpretation of decision 1
and §3.3 while keeping `target_position` as a fixed stage-world point.
- **Cursor meaning:** the saved point is an aim-and-power handle. At release,
the vector from the predicted release point to that handle sets the launch
bearing; its length sets launch speed. The prop is allowed to land short of
or beyond the cursor as gravity and collisions dictate.
- **Power curve:** map cursor distance monotonically and smoothly to launch
speed, clamp at a maximum, and keep the limits in one set of tunable
constants. Initial tuning recommendation: 300 px/s at minimum input, rising
to a 1,250 px/s cap at 700 px of cursor distance. Distances beyond that stay
at the cap. Retain the existing near-zero-distance no-op guard.
- **Prediction:** preview and execution use the same release point, cursor
vector, power mapping, velocity, gravity, and prop gravity scale. Draw the
sampled ballistic path and show the cursor separately from the predicted
impact point. Use the held prop's collision shape to estimate its first
world collision along the path, ignoring the actor and held prop; if no
collision occurs within the existing 2.0-second maximum horizon, mark the
horizon endpoint. This remains an estimate for moving bodies.
- **Overhand motion:** replace the short backward/upward wrist offset with a
head-relative IK arc. Lift the held prop behind and beside the head, carry
it above the crown, then sweep forward over the head. Release during the
forward sweep and finish with a follow-through. Derive points from the live
rig pose and facing, and clamp them within the selected arm's reachable
length so the IK solver does not snap or overextend. A small torso lean may
support the motion; do not reposition the legs.
- **Release origin:** calculate the preview from the prop's predicted
attachment transform at the release waypoint, not its pre-windup transform.
Recompute the same origin at the release frame before applying the bounded
launch velocity. Keep the action payload as `{ type, target_position }`;
power is derived at runtime, so existing saved queues remain readable.
Acceptance checks for the amendment: launch speed increases monotonically with
cursor distance until the cap; launch bearing follows the cursor vector;
preview points agree with the chosen release pose and solver; the overhand
hand path clears the head and remains within arm reach; release happens during
the forward sweep; the predicted impact marker is distinct from the cursor.
## 1.2 Approved throw landing-target amendment (2026-09-25)
This approved amendment supersedes §1.1's aim-and-power mapping. It keeps the
saved `{ type, target_position }` payload and treats its fixed stage-world point
as the requested landing/aim point.
- **Range cap:** cap the raw cursor point to a 3,600 px radius from the
stickman's root position at preview time and again when the throw action
starts. If it is farther away, project it onto that radius along the
root-to-cursor vector. The root is the intuitive range reference; the actual
ballistic path still starts at the prop's predicted hand-release transform.
A cursor within the existing 4 px no-op threshold of the root skips the
throw and leaves the prop held.
Keep the raw saved world point unchanged, so queued actions remain readable.
When a queued actor has moved by the time the throw starts, recompute only
the effective capped point from its current root position.
- **Target solver:** in unobstructed flight, solve the launch velocity so the
prop reaches the effective target on the descending part of its arc. Continue
using project gravity and the prop's `gravity_scale`. Search flight times
from 0.35 s to the 2.0 s horizon and select the shortest valid time whose
launch speed does not exceed 2,100 px/s. This favors a fast trajectory while
preserving the selected endpoint. If no valid candidate exists, show the
target as unreachable and skip the throw without releasing the prop.
- **Preview and physical collisions:** preview and execution use the same
effective target, release transform, selected flight time, velocity, gravity,
and prop gravity scale. Shape-cast the held prop along the path, excluding
the thrower's collision bodies and the prop itself. A world collision before
the target ends the guide at its first safe contact point. If no collision
occurs before the requested endpoint, the guide reaches that endpoint. If
the point is in open air, the prop continues under normal physics after
passing it; the guide marks the requested point rather than the rest of that
continuing flight. Moving-body impacts remain an estimate.
- **Target markers:** show the raw cursor separately from the effective
capped target. When a collision truncates the guide before that target, show
the collision point separately as well. Thus a cursor at 1,200 px from the
root shows an effective target at 3,600 px, while retaining the cursor marker
at 1,200 px.
- **Prop weight/material:** keep one shared range and speed policy in this
change. Physical mass does not change gravitational acceleration. A future
balance pass may add an explicit gameplay throwability factor derived from
prop weight or material; it must not silently change the physics mass or
existing `.prop` schema.
The previously approved overhand IK path, its reach clamp, forward-sweep
release, follow-through, and release-origin prediction remain in force.
Acceptance checks for this amendment: a target within 3,600 px remains exact;
farther targets clamp to a 3,600 px radius from the current stickman root; the
cursor and effective target markers remain distinct; the solver chooses the
shortest valid descending arc under the speed cap; earlier collisions truncate
the guide; unreachable targets release no prop; and queued throws preserve the
raw fixed world point while recomputing a bound when necessary.
## 1.3 Approved queue-aware throw-planning preview (2026-09-25)
At queue-edit time, a throw may follow a queued pickup that has not executed yet.
The preview must model that planned held-prop state without mutating the live
rig, prop, queue, reservations, or physics. This is a planning estimate; runtime
continues to solve from the actual held prop and release transform.
- **Queue prefix:** for an edited throw, inspect authored actions before its
stable action ID. For a newly appended queue or Direct throw, inspect the
current queue as the prefix. Start from the rig's current held-prop state,
then fold only pending actions in order: a valid `pick_up_prop` selects its
stage prop ID and requested hand; `drop_prop` or `throw_prop` clears the
virtual held prop. Completed actions are already reflected in live state and
are not replayed. A missing or incompatible prop leaves the preview
unavailable.
- **Predicted pickup pose:** use the same grip-selection and pickup stand-off
calculations as runtime. For an out-of-reach pickup, predict the approach
root position from the selected grip and `APPROACH_GAP`; also account for
preceding authored `walk_to` targets. This predicts the ordinary queue case
without starting locomotion or attaching the prop. If an earlier action makes
the future actor or prop transform indeterminate (for example, an active
ragdoll or a prior physical throw), do not present the path as exact; show a
planning/uncertain status and use the best available pose estimate.
- **Pure preview context:** pass the resolved prop, chosen grip/hand, and
predicted root transform as explicit preview inputs to the existing
gravity-aware solver and collision shape cast. Do not temporarily set the
interactor's `_held_prop`, reserve the prop, change the hand target, or move
either scene object to make the preview work.
- **Feedback:** show a plan-preview hint such as “Assumes Step 2 pickup
succeeds.” Keep raw cursor, capped target, and collision markers. Reserve red
for a genuinely unreachable solved target; if no planned or live prop can be
resolved, show a neutral explanation instead of a misleading straight red
trajectory. Preview status must update as the queue prefix or target changes.
- **Scope:** queue edit, queue append, and Direct throw captures have a known
authored prefix and use this model. Rule-authored throws have no fixed action
prefix, so they use the live held-prop state and report preview unavailable
when no prop is currently held.
Acceptance checks for this amendment: at `t = 0`, editing a pending throw after
a pending pickup displays a gravity/collision-aware planning guide; the preview
does not change prop ownership, transforms, reservations, the queue, or live
physics; a preceding drop/throw clears the inferred held prop; the predicted
pickup approach moves the guide origin; and actual execution recomputes the
trajectory from the real release transform. A missing, invalid, or ambiguous
prefix never masquerades as a solver-unreachable red trajectory.
## 2. Existing contracts and scope
- `StickmanRig` is the public runtime facade. It already composes separate
Locomotion, Ragdoll, and ActionRunner components under `RigComponents`.
- `StickmanActionRunner` owns authored queue IDs and emits one start/finish
pair per authored action. Walking is currently a normal queue action.
Pickup’s internal approach must not be inserted into the authored queue:
pickup remains one authored action, with one action ID and one completion.
- `StickmanLocomotion.walk_to()` drives the existing navigation/terrain
behavior, but its final root displacement is horizontal. Prop approach must
choose a grounded stand point and verify hand reach; it cannot assume
arbitrary 2D walking.
- `PropAnchorData` already normalizes grip roles, allowed hands, local
transforms, and attachment-transform math. `PropFile` already serializes
anchors, but `PropLibrary` currently drops them and `StageSpawner` does not
pass them into `PropBlock`. Phase 11 must close that runtime data path.
- `StickmanRig` uses animated IK target markers. A prop hold/throw pose needs a
single explicit owner for the active hand target after animation updates and
before the IK solve. Do not let an AnimationPlayer track and the interaction
code fight over the same target.
- Rule/action templates live in `TriggerRegistry` and `ActionRegistry`.
`SandboxStage` handles rule matching and event dispatch; there is no current
`StageEventRegistry`. Extend these existing paths.
- `PropBlock.begin_play_instance_scale()` moves authored instance scale into
its visuals/collision during PLAY. Grip alignment must use that effective
scale, not assume the RigidBody root retains the EDIT scale.
In scope: one-hand pickup, drop, and targeted throw; automatic approach;
prop/rig state handoff; stage-scene persistence for prop identity and queue
payloads; Direct and queue/rule authoring; and three edge-triggered prop rules.
Out of scope: two-hand or multiple-prop holding; torso/leg bending to improve
pickup reach; prop-to-prop mounting; melee hitboxes; moving-target tracking;
actor-relative throws; a general-purpose event bus rewrite; and document-wide
undo/redo.
## 3. Runtime ownership and action lifecycle
Add a `StickmanPropInteractor` component under each rig’s `RigComponents`.
It owns the selected hand, held prop reference, chosen grip ID, reservation,
saved collision/freeze settings, active interaction phase, and recent held
transform samples. `StickmanRig` exposes a narrow facade to the ActionRunner
and calls the interactor’s ragdoll/reset hooks. The ActionRunner remains the
owner of authored queue progression; it delegates prop work to the interactor
and waits in a prop-interaction phase until that action succeeds, fails, or is
cancelled.
The interaction state is:
| State | Contract |
| --- | --- |
| Idle | No prop is held and no pickup is reserved. |
| Approaching | The pickup action owns one prop reservation and uses locomotion to reach a grounded stand point. It is still one active authored action. |
| Reaching | The chosen arm target blends to the chosen grip; the prop is not yet reported as held. |
| Held | Exactly one prop is aligned to the solved wrist and cannot be claimed by another rig. |
| Throwing | The arm performs windup/follow-through; release is committed once at the release boundary. |
Cancellation before pickup attachment stops the approach/arm blend and releases
the prop reservation. Cancellation of a throw before its release boundary
leaves the prop held; cancellation after release never pulls it back and only
ends the remaining follow-through.
Action payloads:
| Type | Runtime payload | Behavior |
| --- | --- | --- |
| `pick_up_prop` | `{ type, target_prop_id: String, hand: "auto" | "left" | "right" }` | Resolve and reserve that exact prop, approach if needed, then attach one compatible grip. |
| `drop_prop` | `{ type }` | Release the currently held prop. If none is held, complete as a skipped no-op. |
| `throw_prop` | `{ type, target_position: Vector2 }` | Throw the held prop toward the saved stage-world point. If none is held, complete as a skipped no-op. |
Skipped means the queue item reaches its existing completed state with a
no-op outcome; it does not add a new persistent queue status. The existing
`action_finished` signal continues to mean that a queue item ended. The
dedicated `prop_*` triggers below are the success signals for actual prop
state changes.
### 3.1 Pickup selection and auto-approach
At action start, resolve `target_prop_id` to a live `PropBlock` in this stage.
Reject a missing ID, a placeholder, an already-held/reserved prop, a rig
already holding a prop, or a hand/anchor combination with no compatible grip.
These failures finish the queue item as skipped and emit no pickup event.
Use the prop’s normalized authored anchors. For `hand: "auto"`, consider
compatible grip/hand pairs, prefer `primary_grip`, then choose the reachable
pair with the shortest wrist-to-grip distance. An explicit left/right request
must use that hand; do not silently switch hands when its selected grip forbids
it. If no editable grip exists, use a generated one-hand fallback grip at the
center of the prop’s visual bounds. Built-in props use that fallback unless
their template provides an explicit grip.
Measure reach from the selected solved wrist to the transformed grip, not from
the rig root to the prop origin. If the grip is out of reach, compute a grounded
stand point on a reachable side of the prop and ask existing locomotion to walk
there. The stand point must account for current terrain height and the chosen
hand. After arrival, re-resolve the prop, recompute its grip transform, and
confirm actual reach before starting the arm blend. A moving prop may cause at
most one approach replan; if it is still unreachable, release the reservation
and skip the action. No unbounded “chase the prop” loop is allowed.
Reservations are claimed synchronously when pickup begins so two actors cannot
both approach and attach the same prop. Release the reservation on success,
failure, cancellation, prop deletion, or actor deletion. Do not emit a
`walk_to` completion or waypoint-arrival event for the internal approach.
### 3.2 Attachment and hold
Interpolate the selected hand’s IK target to the grip over a short reach blend.
Once the arm is solved, align the prop with
`PropAnchorData.solve_attachment_transform()`, using the solved wrist
transform, the grip’s local transform, effective PLAY scale, and the
left-facing mirror convention. Apply the anchor’s z-index offset relative to
the rig’s draw order.
Keep the `PropBlock` under the stage World. Do not reparent it to the animated
rig or use reparenting as the attachment mechanism. During hold, use
`RigidBody2D.FREEZE_MODE_KINEMATIC`, update the prop transform from the solved
wrist in the interaction update, and temporarily disable its collision layer
and mask so it cannot snag terrain, its owner’s collision proxies, or generate
ordinary contact events while held. Save and restore all modified physics
settings on release. The hold transform must be updated only after the wrist
pose has been solved for that frame.
Sample the held prop transform each physics frame. Those samples provide
release linear/angular velocity for drops and ragdoll disarm; do not use
`_last_grounded_walk_velocity_x` as full actor momentum. It is a grounded,
horizontal locomotion value and does not describe airborne or wrist motion.
### 3.3 Drop and throw
A manual drop detaches at the current prop transform, restores its collision
settings and dynamic state, and applies the most recent finite sampled
attachment velocity. A throw uses the same release handoff, then overrides
linear velocity with the trajectory solver result and adds a small deterministic
spin. Release and its event are committed exactly once.
The approved baseline uses a bounded ballistic solver and no-op guards. The
approved §1.2 amendment changes its target handling as follows:
1. Keep the saved absolute `target_position`. At preview and throw start,
compute the effective point by clamping its radial distance from the current
stickman root to `MAX_THROW_RANGE` (3,600 px). Use the prop's actual predicted
attachment transform at the release waypoint as the ballistic origin.
2. For a candidate flight time `t`, compute the required initial velocity as
`(effective_target - release_position - 0.5 * gravity * t * t) / t`. Search
`[0.35 s, 2.0 s]` and choose the shortest finite candidate that reaches the
effective point on descent and stays at or under `MAX_THROW_SPEED`
(2,100 px/s). The time interval is the preferred tuning seam if playtesting
shows that the default arc needs to be more or less lofted.
3. If no candidate can reach the target within those bounds, mark the preview
invalid and leave the prop held when the action runs. Never substitute an
undershot point while presenting it as the requested landing target.
The preview uses the same effective target, release transform, flight time,
velocity, gravity, and prop gravity scale as execution. Shape-cast the prop
along the path up to the effective target, excluding the thrower's collision
bodies and the held prop. An earlier world collision truncates the guide at
first safe contact; otherwise the guide reaches the effective target. In open
air, the target is an aim point and the physical prop continues until a later
collision. Show the raw cursor, effective capped target, and an earlier
collision point as separate markers.
A queue-authored throw may use the queue-aware planning preview in §1.3. Its
inferred prop, grip/hand, and estimated root pose are immutable solver inputs;
the preview must not simulate pickup by changing live ownership or transforms.
Runtime release always recomputes from actual state.
A cursor within the 4 px zero-distance threshold of the stickman root is a
no-op and leaves the prop held. Godot's positive-Y-down gravity vector must be used consistently. Set
`linear_velocity` from the solved velocity; do not pass a velocity vector to
`apply_central_impulse()`. Never pass a NaN or infinite vector into physics.
Prop mass does not change the solver result; prop-specific throwability remains
future tuning work.
The rig faces toward the saved target before throw windup when the horizontal
offset is outside a small dead zone; it retains that direction afterward.
Use a scripted arm IK pose for windup, release, and follow-through. Do not
directly rotate the torso or reposition the legs in this phase.
## 4. Ragdoll and Sandbox lifecycle
Before `StickmanRig.set_ragdoll(true)` resets locomotion or begins the ragdoll
handoff, ask the interactor to detach any held prop. Restore its collision and
physics settings and apply the latest sampled attachment velocity. This path is
idempotent and works for direct ragdoll, collision-triggered ragdoll, and a
queued ragdoll action.
Ragdoll disarm emits one `prop_dropped` event with reason `ragdoll`; it does
not run the manual drop animation. A manual drop emits the same event with
reason `manual`. A scene reset or leaving PLAY detaches silently, clears
reservations/pose overrides, then lets the existing authored-state restore
return props to their starting transforms. It must not create a gameplay rule
event during cleanup.
Pause/resume preserves a held attachment and its sampled transform state.
Reset/restart restores the unheld authored prop state. Prop interaction actions
require an animated rig; if queued while ragdolled, use the existing recovery
barrier behavior before pickup/throw. A no-held drop/throw after recovery
remains a skipped no-op.
## 5. Events and authoring
Add ActionRegistry entries and labels/icons for `pick_up_prop`, `drop_prop`,
and `throw_prop`. Extend ActionEditor/QueuePanel serialization and the Direct
action popup so actions can be added and edited. Direct popup labels should
read **🫴 Pick Up Prop…**, **⬇️ Drop Prop**, and **🏹 Throw Prop…**. Pick Up
captures a prop; Throw captures a stage-world point; Drop queues immediately.
The When/Then action editor must support those same payloads, including the
second stage capture needed to choose the prop or throw point after the acting
stickman is selected.
Add TriggerRegistry entries for:
| Type | Source | Target | Fire condition |
| --- | --- | --- | --- |
| `prop_picked_up` | Acting stickman | Picked-up prop | After grip alignment and held-state ownership commit. |
| `prop_dropped` | Acting stickman | Released prop | After manual or ragdoll release commits; event payload includes the reason. |
| `prop_thrown` | Acting stickman | Thrown prop | At the exact release boundary, after physics velocity is assigned. |
Prop trigger rules select a stickman source and prop target. Use the existing
ordered, all-matching rule evaluation and normal reactive action queues. Emit
events from the interactor’s successful state transitions, not merely because
an ActionRunner item finished. Do not emit while a prop is being continuously
held, after a failed action, or during scene cleanup. Rule actions can target a
different stickman and can refer to their own prop ID/throw point.
User-authored rules may form a real cycle (for example, pick up → drop → pick
up). Do not synthesize rules or dispatch prop events inline from a failed
attempt. Keep transition events edge-triggered and preserve the existing
stage-run/event-dispatch guards.
## 6. Stage identity, anchors, and saved data
Add a non-empty, opaque `stage_object_id` to every spawned `PropBlock`.
Generate it for each placement/instance; never copy it from the asset or from a
duplicated prop. Keep `spawn_id` exclusively as library/template provenance.
The stage owns resolution and uniqueness checks. Missing custom-prop
placeholders preserve the same ID so replacing a missing asset with its real
asset restores queue/rule references.
Bump StageSceneIO to version 1.1. Keep reading 1.0 scenes; migration assigns
each prop’s current unique `file_id` as its initial `stage_object_id`.
Write `stage_object_id` on each prop entry thereafter. Reject duplicate or
empty IDs in a 1.1 file before applying it, preserving the current scene on
load failure. Existing per-save `file_id` mappings continue to serialize
runtime rule references; do not use Godot instance IDs as saved references.
StageSceneIO queue encode/decode and rule-action conversion must preserve
`target_prop_id` as a stable string and `target_position` as finite vector
data. A queued action whose prop ID no longer resolves stays in the queue and
finishes as skipped when run. A missing-prop placeholder is inert and cannot
be picked up. A rule action with a missing prop target is skipped at execution;
it must not crash or silently retarget another prop.
Carry anchors from `PropLibrary.make_custom_entry()` through `StageSpawner`
into `PropBlock`. Built-in props get a generated centered one-hand grip from
their visual bounds unless their template already defines one. Preserve the
existing .prop anchor schema; do not add runtime-only hand state to .prop
assets.
## 7. Implementation sequence
1. **Identity and data path:** persistent stage IDs, 1.0→1.1 scene migration,
action payload serialization, and custom/built-in grip availability.
2. **Pickup/drop vertical slice:** PropInteractor, one-hand state ownership,
approach/revalidation, hold pose, drop, action cancellation, and Direct /
queue editing.
3. **Throw:** fixed world-target capture, root-relative 3,600 px range clamp,
shortest-valid bounded landing solver, facing/overhand arm arc, release
velocity, collision-aware trajectory preview, and queue-prefix planning
context for pending throw targets.
4. **Lifecycle:** ragdoll disarm, PLAY pause/resume/reset, deletion, and
recovery-barrier integration.
5. **Rules:** three prop triggers, source/target capture, When/Then prop
actions, persistence, and missing-target behavior.
Each step should be headless-verifiable with pure serialization/solver cases
and followed by editor/runtime verification in the Sandbox Stage.
## 8. Acceptance criteria
- A distant pickup approaches using one active queue item, then attaches one
compatible grip to one hand without emitting a synthetic walk event.
- Left/right facing, authored anchor position/rotation, prop scale, and
z-index offset produce stable grip alignment. Built-in props remain pickable.
- Missing, deleted, reserved, incompatible, or unreachable pickup targets fail
safely, release their reservation, and emit no successful prop event.
- Drop restores physics/collision properties and transfers sampled release
motion. Drop and throw with no held prop complete as skipped no-ops.
- A throw queued before the actor moves keeps its saved stage-world point; if
the range cap binds at runtime, the effective point is clamped from the
actor's then-current root. Preview and runtime use the same gravity and
target solver.
- A throw within range reaches its selected point on the descending arc when
unobstructed; targets beyond range show the 3,600 px effective point separately
from the cursor. Earlier collisions stop the guide at the collision point.
- At `t = 0`, a pending throw after a pending pickup has a non-mutating,
queue-aware planning guide; no held prop or invalid prefix shows a neutral
explanation instead of a false red ballistic path.
- Ragdoll releases a held prop before hiding the animated rig, transfers
sampled motion, and emits exactly one ragdoll-reason drop event.
- Pause/resume preserves the hold; PLAY reset/exit releases silently and
restores authored state.
- Prop pickup/drop/throw triggers fire exactly once after their respective
committed transitions and execute matching rules in existing rule order.
- Prop IDs and new queue/rule action payloads survive save/load, missing custom
prop placeholders, and object reordering without binding to the wrong copy.
Final user sign-off on §1.3 was received 2026-09-25. Implementation and editor
verification are complete (2026-09-25).