diff --git a/README.md b/README.md index 1f11708..e8d9278 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/tech_debt_and_optimizations.md b/docs/tech_debt_and_optimizations.md index 09de207..add481d 100644 --- a/docs/tech_debt_and_optimizations.md +++ b/docs/tech_debt_and_optimizations.md @@ -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. | --- diff --git a/game_scenes/manwalk.scene b/game_scenes/manwalk.scene index 5df04b0..790adbf 100644 --- a/game_scenes/manwalk.scene +++ b/game_scenes/manwalk.scene @@ -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": "" } diff --git a/game_scenes/throw_case.scene b/game_scenes/throw_case.scene new file mode 100644 index 0000000..b344cfc --- /dev/null +++ b/game_scenes/throw_case.scene @@ -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": "" + } +} \ No newline at end of file diff --git a/plans/PHASE_11_PROP_INTERACTIONS.md b/plans/PHASE_11_PROP_INTERACTIONS.md new file mode 100644 index 0000000..719d8e0 --- /dev/null +++ b/plans/PHASE_11_PROP_INTERACTIONS.md @@ -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. diff --git a/props/briefcase.prop b/props/briefcase.prop index ddf4ae0..2801bb1 100644 --- a/props/briefcase.prop +++ b/props/briefcase.prop @@ -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": [ diff --git a/scenes/stickman_rig.tscn b/scenes/stickman_rig.tscn index 2bd87d6..203c184 100644 --- a/scenes/stickman_rig.tscn +++ b/scenes/stickman_rig.tscn @@ -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] diff --git a/scripts/action_editor.gd b/scripts/action_editor.gd index 85de401..a0ffa26 100644 --- a/scripts/action_editor.gd +++ b/scripts/action_editor.gd @@ -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 diff --git a/scripts/action_registry.gd b/scripts/action_registry.gd index 40e1d14..423628e 100644 --- a/scripts/action_registry.gd +++ b/scripts/action_registry.gd @@ -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 diff --git a/scripts/missing_prop_placeholder.gd b/scripts/missing_prop_placeholder.gd index 663f06c..cde2620 100644 --- a/scripts/missing_prop_placeholder.gd +++ b/scripts/missing_prop_placeholder.gd @@ -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: diff --git a/scripts/prop_block.gd b/scripts/prop_block.gd index 1b0eab5..e116140 100644 --- a/scripts/prop_block.gd +++ b/scripts/prop_block.gd @@ -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. diff --git a/scripts/prop_library.gd b/scripts/prop_library.gd index f430857..908eb1b 100644 --- a/scripts/prop_library.gd +++ b/scripts/prop_library.gd @@ -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: diff --git a/scripts/sandbox_stage.gd b/scripts/sandbox_stage.gd index 985bf3d..9948a7f 100644 --- a/scripts/sandbox_stage.gd +++ b/scripts/sandbox_stage.gd @@ -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() diff --git a/scripts/stage_placement_overlay.gd b/scripts/stage_placement_overlay.gd index 2e938c1..d678675 100644 --- a/scripts/stage_placement_overlay.gd +++ b/scripts/stage_placement_overlay.gd @@ -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) diff --git a/scripts/stage_scene_io.gd b/scripts/stage_scene_io.gd index bcbfc23..53589c8 100644 --- a/scripts/stage_scene_io.gd +++ b/scripts/stage_scene_io.gd @@ -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: diff --git a/scripts/stage_spawner.gd b/scripts/stage_spawner.gd index b00e12f..4962278 100644 --- a/scripts/stage_spawner.gd +++ b/scripts/stage_spawner.gd @@ -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 diff --git a/scripts/stickman_action_runner.gd b/scripts/stickman_action_runner.gd index 7eac562..994ad1b 100644 --- a/scripts/stickman_action_runner.gd +++ b/scripts/stickman_action_runner.gd @@ -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 diff --git a/scripts/stickman_prop_interactor.gd b/scripts/stickman_prop_interactor.gd new file mode 100644 index 0000000..713243a --- /dev/null +++ b/scripts/stickman_prop_interactor.gd @@ -0,0 +1,1104 @@ +class_name StickmanPropInteractor +extends Node +## Owns one-stickman/one-hand prop interaction state during Sandbox PLAY. + +const ANCHOR_DATA := preload("res://scripts/prop_anchor_data.gd") + +enum Hand { LEFT, RIGHT } +enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING } + +const MAX_REACH: float = 390.0 +const APPROACH_GAP: float = 90.0 +const APPROACH_REPLAN_LIMIT: int = 1 +const REACH_DURATION: float = 0.34 +const PICKUP_RECOVER_DURATION: float = 0.32 +const LOW_PICKUP_THRESHOLD: float = 110.0 +const LOW_PICKUP_MAX_DROP: float = 110.0 +const LOW_PICKUP_LEAN: float = 28.0 +const THROW_LIFT_DURATION: float = 0.30 +const THROW_SWING_DURATION: float = 0.16 +const THROW_FOLLOW_THROUGH: float = 0.24 +const THROW_PREVIEW_HORIZON: float = 2.0 +const MIN_THROW_FLIGHT_TIME: float = 0.35 +const MAX_THROW_RANGE: float = 3600.0 +const MAX_THROW_SPEED: float = 2100.0 +const ZERO_THROW_DISTANCE: float = 4.0 +const THROW_DESCENT_EPSILON: float = 0.001 +const THROW_SPIN_SPEED: float = 2.4 +const THROW_TRAJECTORY_SEGMENTS: int = 24 +const THROW_HEAD_CLEARANCE: float = 36.0 +const THROW_REACH_MARGIN: float = 8.0 + +signal action_completed +signal prop_event(event_type: String, prop: PropBlock, reason: String) + +var _rig: Node2D +var _locomotion: Node +var _resolver: Callable +var _phase: Phase = Phase.IDLE +var _held_prop: PropBlock +var _held_anchor: Dictionary = {} +var _hand: Hand = Hand.RIGHT +var _active_action: Dictionary = {} +var _reach_start: Vector2 = Vector2.ZERO +var _reach_target: Vector2 = Vector2.ZERO +var _reach_elapsed: float = 0.0 +var _pickup_recover_elapsed: float = 0.0 +var _pickup_torso_start: Vector2 = Vector2.ZERO +var _pickup_torso_start_rotation: float = 0.0 +var _pickup_torso_crouched: Vector2 = Vector2.ZERO +var _pickup_torso_crouched_rotation: float = 0.0 +var _pickup_uses_crouch: bool = false +var _throw_elapsed: float = 0.0 +var _throw_released: bool = false +var _throw_base_marker_local: Vector2 = Vector2.ZERO +var _throw_windup_marker_local: Vector2 = Vector2.ZERO +var _throw_overhead_marker_local: Vector2 = Vector2.ZERO +var _throw_swing_control_marker_local: Vector2 = Vector2.ZERO +var _throw_release_marker_local: Vector2 = Vector2.ZERO +var _throw_follow_control_marker_local: Vector2 = Vector2.ZERO +var _throw_follow_marker_local: Vector2 = Vector2.ZERO +var _throw_release_attachment_local: Transform2D = Transform2D.IDENTITY +var _throw_facing_left: bool = false +var _throw_effective_target: Vector2 = Vector2.ZERO +var _throw_torso_start: Vector2 = Vector2.ZERO +var _throw_torso_windup: Vector2 = Vector2.ZERO +var _throw_torso_start_rotation: float = 0.0 +var _throw_torso_windup_rotation: float = 0.0 +var _approach_replans: int = 0 +var _marker_start_position: Vector2 = Vector2.ZERO +var _saved_body_state: Dictionary = {} +var _last_transform: Transform2D = Transform2D.IDENTITY +var _sampled_linear_velocity: Vector2 = Vector2.ZERO +var _sampled_angular_velocity: float = 0.0 + + +func _exit_tree() -> void: + if _held_prop != null and is_instance_valid(_held_prop): + _release_held("scene_reset", false) + else: + _clear_reservation() + + +func configure(rig: Node2D, locomotion: Node, resolver: Callable) -> void: + _rig = rig + _locomotion = locomotion + _resolver = resolver + + +## Returns true when the queue runner must wait for action_completed. +## Immediate success and safe no-op failures both return false. +func start_action(action: Dictionary) -> bool: + var type := String(action.get("type", "")) + match type: + "pick_up_prop": + return _begin_pickup(String(action.get("target_prop_id", "")), String(action.get("hand", "auto")), action) + "drop_prop": + if _held_prop != null and is_instance_valid(_held_prop): + _release_held("manual", false) + return false + "throw_prop": + if _held_prop == null or not is_instance_valid(_held_prop): + return false + var target: Variant = action.get("target_position", Vector2.INF) + if not target is Vector2 or not (target as Vector2).is_finite(): + push_warning("StickmanPropInteractor: invalid throw target; skipped.") + return false + var raw_target := target as Vector2 + if _rig == null or raw_target.distance_to(_rig.global_position) <= ZERO_THROW_DISTANCE: + return false + var effective_target := _clamp_throw_target(raw_target) + if absf(effective_target.x - _rig.global_position.x) > 0.5: + _rig.call("set_facing_profile", 0 if effective_target.x < _rig.global_position.x else 1) + var marker := _hand_marker() + if marker == null: + return false + var pose := _build_throw_pose(effective_target, marker, _held_prop, _hand, Vector2.ZERO) + if pose.is_empty(): + return false + var throw_facing_left := bool(pose["facing_left"]) + var pose_transform := _rig_transform_for_facing(throw_facing_left) + var release_marker_world: Vector2 = pose_transform * (pose["release"] as Vector2) + var release_attachment := _predicted_attachment_transform(release_marker_world, throw_facing_left, _held_prop, _held_anchor, _hand) + var release_attachment_local: Transform2D = pose_transform.affine_inverse() * release_attachment + var spin_sign := -1.0 if throw_facing_left else 1.0 + var predicted_release := pose_transform * release_attachment_local + if _solve_throw(predicted_release, effective_target, _held_prop, spin_sign * THROW_SPIN_SPEED).is_empty(): + return false + _store_throw_pose(pose, marker) + _throw_facing_left = throw_facing_left + _throw_release_attachment_local = release_attachment_local + _throw_effective_target = effective_target + _active_action = action.duplicate(true) + _throw_elapsed = 0.0 + _throw_released = false + _phase = Phase.THROWING + return true + return false + + +func tick(delta: float) -> void: + if _rig == null or not is_instance_valid(_rig): + return + match _phase: + Phase.APPROACHING: + if _locomotion != null and bool(_locomotion.call("consume_walk_completed")): + _on_approach_arrived() + Phase.REACHING: + _update_reach(delta) + Phase.PICKUP_RECOVERING: + _update_pickup_recovery(delta) + Phase.HELD: + _update_held_transform(delta) + Phase.THROWING: + _update_throw(delta) + + +func cancel_action() -> void: + if _phase == Phase.APPROACHING and _locomotion != null: + _locomotion.call("cancel_walk") + if _phase == Phase.APPROACHING or _phase == Phase.REACHING: + _clear_reservation() + _restore_hand_target() + _restore_pickup_torso() + elif _phase == Phase.PICKUP_RECOVERING: + _restore_pickup_torso() + _phase = Phase.HELD + _active_action.clear() + return + elif _phase == Phase.THROWING and not _throw_released: + var marker := _hand_marker() + if marker != null: + marker.position = _throw_base_marker_local + _restore_throw_torso() + _phase = Phase.HELD if _held_prop != null else Phase.IDLE + _active_action.clear() + return + elif _phase == Phase.THROWING and _throw_released: + _throw_released = false + _throw_elapsed = 0.0 + _restore_hand_target() + _restore_throw_torso() + if _phase != Phase.HELD: + _phase = Phase.IDLE + _active_action.clear() + + +func preview_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary: + if not target.is_finite(): + return {} + var context := _build_throw_preview_context(queue_prefix) + if not bool(context.get("available", false)): + return { + "preview_available": false, + "preview_reason": String(context.get("reason", "No prop is held or planned before this throw.")), + } + var prop: PropBlock = context["prop"] + var anchor: Dictionary = context["anchor"] + var hand_value := int(context["hand"]) + var root_offset: Vector2 = context["root_offset"] + var effective_target := _clamp_throw_target(target, context["root_position"]) + if _rig == null or target.distance_to(context["root_position"]) <= ZERO_THROW_DISTANCE: + var invalid := _invalid_throw_preview(effective_target, _rig.global_position if _rig != null else target) + invalid.merge(_throw_preview_metadata(context), true) + return invalid + var marker := _hand_marker_for(hand_value) + if marker == null: + return {"preview_available": false, "preview_reason": "The planned pickup hand is unavailable."} + var pose := _build_throw_pose(effective_target, marker, prop, hand_value, root_offset) + if pose.is_empty(): + return {"preview_available": false, "preview_reason": "The throw pose cannot be estimated from the current rig."} + var facing_left := bool(pose["facing_left"]) + var release_marker_world := _rig_transform_for_facing(facing_left, root_offset) * (pose["release"] as Vector2) + var release_transform := _predicted_attachment_transform(release_marker_world, facing_left, prop, anchor, hand_value, root_offset) + var spin_speed := -THROW_SPIN_SPEED if facing_left else THROW_SPIN_SPEED + var solution := _solve_throw(release_transform, effective_target, prop, spin_speed) + if solution.is_empty(): + var invalid := _invalid_throw_preview(effective_target, release_transform.origin) + invalid.merge(_throw_preview_metadata(context), true) + return invalid + solution.merge(_throw_preview_metadata(context), true) + solution["effective_target"] = effective_target + solution["target_reachable"] = true + return _clip_preview_to_collision(solution, prop) + + +func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary: + if _rig == null or not is_instance_valid(_rig): + return {"available": false, "reason": "The acting stickman is unavailable."} + var prop: PropBlock = _held_prop if _held_prop != null and is_instance_valid(_held_prop) else null + var anchor: Dictionary = _held_anchor.duplicate(true) if prop != null else {} + var hand_value := int(_hand) + var has_prop := prop != null + var root_position := _rig.global_position + var root_offset := Vector2.ZERO + var predicted_facing_left := _is_facing_left() + var planned := false + var uncertain := _phase != Phase.IDLE and _phase != Phase.HELD + var plan_step := 0 + for entry: Dictionary in queue_prefix: + var action: Dictionary = entry.get("action", {}) + if action.is_empty(): + continue + if bool(entry.get("active", false)): + uncertain = true + var action_type := String(action.get("type", "")) + match action_type: + "walk_to": + var feet_target: Variant = action.get("target", Vector2.INF) + if feet_target is Vector2 and (feet_target as Vector2).is_finite() and _locomotion != null: + var previous_root := root_position + var foot_offset: Vector2 = _locomotion.call("get_foot_offset") + root_position = (feet_target as Vector2) + foot_offset + root_offset = root_position - _rig.global_position + if not is_zero_approx(root_position.x - previous_root.x): + predicted_facing_left = root_position.x < previous_root.x + uncertain = true + "pick_up_prop": + if has_prop: + continue + var prop_id := String(action.get("target_prop_id", "")) + if prop_id.is_empty() or not _resolver.is_valid(): + return {"available": false, "reason": "A pickup before this throw has no resolvable prop."} + var resolved: Variant = _resolver.call(prop_id) + if not resolved is PropBlock or not is_instance_valid(resolved) or (resolved as PropBlock).is_queued_for_deletion(): + return {"available": false, "reason": "A pickup before this throw refers to a missing prop."} + var candidate_prop := resolved as PropBlock + var owner_id := int(candidate_prop.interaction_owner_id) + var reserved_by := int(candidate_prop.interaction_reserved_by) + if (owner_id != 0 and owner_id != int(_rig.get_instance_id())) or (reserved_by != 0 and reserved_by != int(_rig.get_instance_id())): + return {"available": false, "reason": "The planned pickup prop is held or reserved by another stickman."} + var requested_hand := String(action.get("hand", "auto")) + var picked := _choose_preview_grip_and_hand(candidate_prop, requested_hand, root_offset, predicted_facing_left) + if picked.is_empty(): + return {"available": false, "reason": "The planned prop has no compatible grip for this pickup."} + var candidate_anchor: Dictionary = picked["anchor"] + var candidate_hand := int(picked["hand"]) + var grip_position := _grip_world_position_for_facing(candidate_prop, candidate_anchor, predicted_facing_left) + var wrist_position := _preview_wrist_position(candidate_hand, root_offset, predicted_facing_left) + if wrist_position.distance_to(grip_position) > MAX_REACH: + uncertain = true + var side := -1.0 if root_position.x <= grip_position.x else 1.0 + var approach_root := Vector2(grip_position.x + side * APPROACH_GAP, root_position.y) + if not is_equal_approx(approach_root.x, root_position.x): + predicted_facing_left = approach_root.x < root_position.x + root_position = approach_root + root_offset = root_position - _rig.global_position + grip_position = _grip_world_position_for_facing(candidate_prop, candidate_anchor, predicted_facing_left) + wrist_position = _preview_wrist_position(candidate_hand, root_offset, predicted_facing_left) + if wrist_position.distance_to(grip_position) > MAX_REACH: + uncertain = true + prop = candidate_prop + anchor = candidate_anchor.duplicate(true) + hand_value = candidate_hand + has_prop = true + planned = true + plan_step = int(entry.get("step", 0)) + "drop_prop", "throw_prop": + if has_prop: + prop = null + anchor.clear() + has_prop = false + planned = false + uncertain = true + "ragdoll": + uncertain = true + if not has_prop or prop == null or not is_instance_valid(prop): + return {"available": false, "reason": "No prop is held or picked up earlier in this queue."} + return { + "available": true, + "prop": prop, + "anchor": anchor, + "hand": hand_value, + "root_position": root_position, + "root_offset": root_offset, + "planned": planned, + "uncertain": uncertain, + "plan_step": plan_step, + } + + +func _choose_preview_grip_and_hand(prop: PropBlock, requested_hand: String, root_offset: Vector2, facing_left: bool) -> Dictionary: + var anchors := prop.get_interaction_anchors() + var candidates: Array[Dictionary] = [] + for hand_name: String in ["left", "right"]: + if requested_hand != "auto" and requested_hand != hand_name: + continue + var hand_value: Hand = Hand.LEFT if hand_name == "left" else Hand.RIGHT + var compatible: Array[String] = ANCHOR_DATA.compatible_grip_ids(anchors, hand_value) + for anchor_id: String in compatible: + var candidate_anchor: Dictionary = anchors.get(anchor_id, {}) + var wrist_position := _preview_wrist_position(hand_value, root_offset, facing_left) + var grip_position := _grip_world_position_for_facing(prop, candidate_anchor, facing_left) + candidates.append({"anchor_id": anchor_id, "anchor": candidate_anchor, "hand": hand_value, "distance": wrist_position.distance_to(grip_position)}) + if candidates.is_empty(): + return {} + candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var a_primary := String(a.get("anchor_id", "")) == "primary_grip" + var b_primary := String(b.get("anchor_id", "")) == "primary_grip" + if a_primary != b_primary: + return a_primary + return float(a.get("distance", INF)) < float(b.get("distance", INF)) + ) + return candidates[0] + + +func _preview_wrist_position(hand_value: int, root_offset: Vector2, facing_left: bool) -> Vector2: + var wrist := _solved_wrist_transform_for(hand_value) + var rig_transform := _rig_transform_for_facing(facing_left, root_offset) + return rig_transform * _rig.to_local(wrist.origin) + + +func _throw_preview_metadata(context: Dictionary) -> Dictionary: + var planned := bool(context.get("planned", false)) + var uncertain := bool(context.get("uncertain", false)) + var hint := "" + var step := int(context.get("plan_step", 0)) + if planned and step > 0: + hint = "Assumes Step %d pickup succeeds." % step + elif planned: + hint = "Assumes an earlier pickup succeeds." + if uncertain: + hint = "Estimated preview. " + hint if not hint.is_empty() else "Estimated preview from earlier actions." + return {"preview_available": true, "preview_planned": planned, "preview_uncertain": uncertain, "preview_hint": hint} + + +func _build_throw_pose(target: Vector2, marker: Marker2D, prop: PropBlock, hand_value: int, root_offset: Vector2) -> Dictionary: + var prethrow_delta := target - prop.global_position + if not prethrow_delta.is_finite(): + return {} + var root_position := _rig.global_position + root_offset + var facing_left := target.x < root_position.x if absf(target.x - root_position.x) > 0.5 else _is_facing_left() + var suffix := "Left" if hand_value == Hand.LEFT else "Right" + var shoulder_bone := _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % suffix)) as Bone2D + var lower_bone := _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D + var head_node := _rig.get_node_or_null(NodePath("Body/Head")) as Node2D + if shoulder_bone == null or lower_bone == null or head_node == null: + return {} + var pose_transform := _rig_transform_for_facing(facing_left, root_offset) + var head_center := pose_transform * _rig.to_local(head_node.global_position) + var shoulder := pose_transform * _rig.to_local(shoulder_bone.global_position) + var head_radius := 100.0 + var radius_value: Variant = head_node.get("radius") + if radius_value is float or radius_value is int: + head_radius = maxf(float(radius_value), 20.0) + var aim_sign := signf(target.x - root_position.x) + if is_zero_approx(aim_sign): + aim_sign = -1.0 if facing_left else 1.0 + var max_reach := maxf(1.0, shoulder_bone.length + lower_bone.length - THROW_REACH_MARGIN) + var behind_control := head_center + Vector2(-aim_sign * head_radius * 0.78, -head_radius * 0.20) + var overhead := head_center + Vector2(-aim_sign * 8.0, -head_radius - THROW_HEAD_CLEARANCE) + var swing_control := head_center + Vector2(aim_sign * (head_radius * 0.62), -head_radius - THROW_HEAD_CLEARANCE * 0.45) + var release := head_center + Vector2(aim_sign * (head_radius + 44.0), -head_radius * 0.26) + var follow_control := release + Vector2(aim_sign * 44.0, -12.0) + var follow := release + Vector2(aim_sign * 88.0, 46.0) + behind_control = _clamp_to_arm_reach(behind_control, shoulder, max_reach) + overhead = _clamp_to_arm_reach(overhead, shoulder, max_reach) + swing_control = _clamp_to_arm_reach(swing_control, shoulder, max_reach) + release = _clamp_to_arm_reach(release, shoulder, max_reach) + follow_control = _clamp_to_arm_reach(follow_control, shoulder, max_reach) + follow = _clamp_to_arm_reach(follow, shoulder, max_reach) + var torso_marker := _torso_marker() + var torso_start := torso_marker.position if torso_marker != null else Vector2.ZERO + var torso_start_rotation := torso_marker.rotation if torso_marker != null else 0.0 + var local_aim_sign := -aim_sign if facing_left else aim_sign + return { + "facing_left": facing_left, + "base": marker.position, + "windup": pose_transform.affine_inverse() * behind_control, + "overhead": pose_transform.affine_inverse() * overhead, + "swing_control": pose_transform.affine_inverse() * swing_control, + "release": pose_transform.affine_inverse() * release, + "follow_control": pose_transform.affine_inverse() * follow_control, + "follow": pose_transform.affine_inverse() * follow, + "torso_start": torso_start, + "torso_start_rotation": torso_start_rotation, + "torso_windup": torso_start + Vector2(-local_aim_sign * 24.0, -14.0), + "torso_windup_rotation": torso_start_rotation - local_aim_sign * 0.12, + } + + +func _store_throw_pose(pose: Dictionary, marker: Marker2D) -> void: + _throw_base_marker_local = marker.position + _throw_windup_marker_local = pose["windup"] + _throw_overhead_marker_local = pose["overhead"] + _throw_swing_control_marker_local = pose["swing_control"] + _throw_release_marker_local = pose["release"] + _throw_follow_control_marker_local = pose["follow_control"] + _throw_follow_marker_local = pose["follow"] + _throw_torso_start = pose["torso_start"] + _throw_torso_start_rotation = float(pose["torso_start_rotation"]) + _throw_torso_windup = pose["torso_windup"] + _throw_torso_windup_rotation = float(pose["torso_windup_rotation"]) + + +func _rig_transform_for_facing(facing_left: bool, root_offset: Vector2 = Vector2.ZERO) -> Transform2D: + var result := _rig.global_transform + result.origin += root_offset + var x_axis := result.x + if _is_facing_left(): + x_axis = -x_axis + if facing_left: + x_axis = -x_axis + result.x = x_axis + return result + + +func _clamp_to_arm_reach(point: Vector2, shoulder: Vector2, max_reach: float) -> Vector2: + var delta := point - shoulder + if delta.length() <= max_reach or delta.is_zero_approx(): + return point + return shoulder + delta.normalized() * max_reach + + +func _clamp_throw_target(target: Vector2, root_position: Vector2 = Vector2.INF) -> Vector2: + if _rig == null: + return target + var center := root_position if root_position.is_finite() else _rig.global_position + var offset := target - center + if offset.length() <= MAX_THROW_RANGE or offset.is_zero_approx(): + return target + return center + offset.normalized() * MAX_THROW_RANGE + + +func _invalid_throw_preview(effective_target: Vector2, start_position: Vector2) -> Dictionary: + return { + "start_position": start_position, + "effective_target": effective_target, + "landing_position": effective_target, + "collision_position": Vector2.INF, + "collision_before_target": false, + "target_reachable": false, + "flight_time": 0.0, + "trajectory_points": PackedVector2Array(), + } + + +func disarm_for_ragdoll() -> void: + var interrupted_pickup := _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING + var interrupted_windup := _phase == Phase.THROWING and not _throw_released + if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING: + cancel_action() + elif interrupted_windup: + cancel_action() + if _held_prop != null and is_instance_valid(_held_prop): + _release_held("ragdoll", false) + if interrupted_pickup or interrupted_windup: + action_completed.emit() + + +func reset_for_scene_exit() -> void: + if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING or (_phase == Phase.THROWING and not _throw_released): + cancel_action() + if _held_prop != null and is_instance_valid(_held_prop): + _release_held("scene_reset", false) + _phase = Phase.IDLE + _active_action.clear() + _restore_hand_target() + + +func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary) -> bool: + if _phase != Phase.IDLE or _held_prop != null or _rig == null: + return false + if int(_rig.get("state")) != 0: + return false + if prop_id.is_empty() or not _resolver.is_valid(): + return false + var resolved: Variant = _resolver.call(prop_id) + if not resolved is PropBlock: + return false + var prop := resolved as PropBlock + if not is_instance_valid(prop) or prop.is_queued_for_deletion(): + return false + if prop.interaction_owner_id != 0 or prop.interaction_reserved_by != 0: + return false + var picked := _choose_grip_and_hand(prop, requested_hand) + if picked.is_empty(): + push_warning("StickmanPropInteractor: no compatible one-hand grip; pickup skipped.") + return false + _held_anchor = (picked["anchor"] as Dictionary).duplicate(true) + _hand = int(picked["hand"]) + _held_prop = prop + _held_prop.interaction_reserved_by = _rig.get_instance_id() + _active_action = action.duplicate(true) + _approach_replans = 0 + var marker := _hand_marker() + if marker == null: + _clear_reservation() + _phase = Phase.IDLE + return false + _marker_start_position = marker.position + var grip_position := _grip_world_position(prop, _held_anchor) + if _solved_wrist_transform().origin.distance_to(grip_position) > MAX_REACH: + if not _start_approach(grip_position): + _clear_reservation() + _phase = Phase.IDLE + return false + _phase = Phase.APPROACHING + return true + _start_reach(grip_position) + return true + + +func _choose_grip_and_hand(prop: PropBlock, requested_hand: String) -> Dictionary: + var anchors := prop.get_interaction_anchors() + var candidates: Array[Dictionary] = [] + for hand_name: String in ["left", "right"]: + if requested_hand != "auto" and requested_hand != hand_name: + continue + var hand_value: int = Hand.LEFT if hand_name == "left" else Hand.RIGHT + var compatible: Array[String] = ANCHOR_DATA.compatible_grip_ids(anchors, hand_value) + for anchor_id: String in compatible: + var anchor: Dictionary = anchors.get(anchor_id, {}) + var distance: float = _solved_wrist_transform_for(hand_value).origin.distance_to(_grip_world_position(prop, anchor)) + candidates.append({"anchor_id": anchor_id, "anchor": anchor, "hand": hand_value, "distance": distance}) + if candidates.is_empty(): + return {} + candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var a_primary := String(a.get("anchor_id", "")) == "primary_grip" + var b_primary := String(b.get("anchor_id", "")) == "primary_grip" + if a_primary != b_primary: + return a_primary + return float(a.get("distance", INF)) < float(b.get("distance", INF)) + ) + return candidates[0] + + +func _start_approach(grip_position: Vector2) -> bool: + if _locomotion == null: + return false + var side := -1.0 if _rig.global_position.x <= grip_position.x else 1.0 + var foot_offset: Vector2 = _locomotion.call("get_foot_offset") + var target_root := Vector2(grip_position.x + side * APPROACH_GAP, _rig.global_position.y) + var target_feet := target_root - foot_offset + return bool(_locomotion.call("walk_to", target_feet, -1.0)) + + +func _on_approach_arrived() -> void: + if _held_prop == null or not is_instance_valid(_held_prop): + _clear_reservation() + _restore_hand_target() + _restore_pickup_torso() + _phase = Phase.IDLE + _active_action.clear() + action_completed.emit() + return + var grip_position := _grip_world_position(_held_prop, _held_anchor) + var marker := _hand_marker() + if marker == null: + _clear_reservation() + _restore_hand_target() + _phase = Phase.IDLE + _active_action.clear() + action_completed.emit() + return + if _solved_wrist_transform().origin.distance_to(grip_position) > MAX_REACH: + if _approach_replans < APPROACH_REPLAN_LIMIT: + _approach_replans += 1 + if _start_approach(grip_position): + return + _clear_reservation() + _restore_hand_target() + _phase = Phase.IDLE + _active_action.clear() + action_completed.emit() + return + _start_reach(grip_position) + + +func _start_reach(grip_position: Vector2) -> void: + var marker := _hand_marker() + if marker == null: + _clear_reservation() + _restore_hand_target() + _phase = Phase.IDLE + _active_action.clear() + action_completed.emit() + return + _reach_start = marker.global_position + _reach_target = grip_position + _reach_elapsed = 0.0 + var torso_marker := _torso_marker() + _pickup_uses_crouch = false + if torso_marker != null: + _pickup_torso_start = torso_marker.position + _pickup_torso_start_rotation = torso_marker.rotation + _pickup_torso_crouched = _pickup_torso_start + _pickup_torso_crouched_rotation = _pickup_torso_start_rotation + var grip_local := _rig.to_local(grip_position) + var low_amount := clampf((grip_local.y - _pickup_torso_start.y - LOW_PICKUP_THRESHOLD) / LOW_PICKUP_MAX_DROP, 0.0, 1.0) + if low_amount > 0.0: + var toward_prop := signf(grip_local.x - _pickup_torso_start.x) + if is_zero_approx(toward_prop): + toward_prop = -1.0 if _is_facing_left() else 1.0 + _pickup_torso_crouched += Vector2(toward_prop * LOW_PICKUP_LEAN * low_amount, LOW_PICKUP_MAX_DROP * low_amount) + _pickup_torso_crouched_rotation -= toward_prop * 0.10 * low_amount + _pickup_uses_crouch = true + _rig.call("_set_limb_mods_enabled", true) + _phase = Phase.REACHING + + +func _update_reach(delta: float) -> void: + if _held_prop == null or not is_instance_valid(_held_prop): + _clear_reservation() + _restore_hand_target() + _phase = Phase.IDLE + action_completed.emit() + return + var marker := _hand_marker() + if marker == null: + cancel_action() + action_completed.emit() + return + _reach_elapsed += delta + var weight := clampf(_reach_elapsed / REACH_DURATION, 0.0, 1.0) + var eased_weight := smoothstep(0.0, 1.0, weight) + _reach_target = _grip_world_position(_held_prop, _held_anchor) + marker.global_position = _reach_start.lerp(_reach_target, eased_weight) + _update_pickup_crouch(eased_weight) + if weight < 1.0: + return + if _solved_wrist_transform().origin.distance_to(_reach_target) > MAX_REACH: + cancel_action() + action_completed.emit() + return + _commit_pickup(marker) + + +func _commit_pickup(marker: Marker2D) -> void: + if _held_prop == null or not is_instance_valid(_held_prop): + cancel_action() + action_completed.emit() + return + _saved_body_state = { + "freeze": _held_prop.freeze, + "freeze_mode": _held_prop.freeze_mode, + "collision_layer": _held_prop.collision_layer, + "collision_mask": _held_prop.collision_mask, + "z_index": _held_prop.z_index, + "z_as_relative": _held_prop.z_as_relative, + "linear_velocity": _held_prop.linear_velocity, + "angular_velocity": _held_prop.angular_velocity, + } + _held_prop.interaction_reserved_by = 0 + _held_prop.interaction_owner_id = _rig.get_instance_id() + _held_prop.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC + _held_prop.freeze = true + _held_prop.sleeping = false + _held_prop.collision_layer = 0 + _held_prop.collision_mask = 0 + if _pickup_uses_crouch: + _pickup_recover_elapsed = 0.0 + _phase = Phase.PICKUP_RECOVERING + else: + _phase = Phase.HELD + _active_action.clear() + _update_held_transform(0.0) + prop_event.emit("prop_picked_up", _held_prop, "") + if not _pickup_uses_crouch: + action_completed.emit() + + +func _update_pickup_recovery(delta: float) -> void: + _pickup_recover_elapsed += delta + var weight := smoothstep(0.0, 1.0, clampf(_pickup_recover_elapsed / PICKUP_RECOVER_DURATION, 0.0, 1.0)) + _update_pickup_crouch(1.0 - weight) + _update_held_transform(delta) + if weight < 1.0: + return + _restore_pickup_torso() + _phase = Phase.HELD + _active_action.clear() + action_completed.emit() + + +func _update_pickup_crouch(weight: float) -> void: + var torso_marker := _torso_marker() + if torso_marker == null or not _pickup_uses_crouch: + return + torso_marker.position = _pickup_torso_start.lerp(_pickup_torso_crouched, weight) + torso_marker.rotation = lerpf(_pickup_torso_start_rotation, _pickup_torso_crouched_rotation, weight) + + +func _restore_pickup_torso() -> void: + var torso_marker := _torso_marker() + if torso_marker != null and _pickup_uses_crouch: + torso_marker.position = _pickup_torso_start + torso_marker.rotation = _pickup_torso_start_rotation + _pickup_uses_crouch = false + + +func _update_held_transform(delta: float, follow_hand_marker: bool = false) -> void: + if _held_prop == null or not is_instance_valid(_held_prop): + _clear_held_state() + return + var marker := _hand_marker() + if follow_hand_marker and marker != null: + _held_prop.global_transform = _predicted_attachment_transform(marker.global_position, _is_facing_left()) + else: + var wrist := _solved_wrist_transform() + var anchor := _scaled_anchor(_held_anchor, _held_prop) + _held_prop.global_transform = ANCHOR_DATA.solve_attachment_transform(wrist, anchor, Vector2.ONE, _is_facing_left()) + _held_prop.z_as_relative = true + _held_prop.z_index = _rig.z_index + int(_held_anchor.get("z_index_offset", 0)) + if delta > 0.0: + var current := _held_prop.global_transform + _sampled_linear_velocity = (current.origin - _last_transform.origin) / delta + _sampled_angular_velocity = wrapf(current.get_rotation() - _last_transform.get_rotation(), -PI, PI) / delta + _last_transform = _held_prop.global_transform + + +func _update_throw(delta: float) -> void: + _throw_elapsed += delta + var marker := _hand_marker() + var release_time := THROW_LIFT_DURATION + THROW_SWING_DURATION + if not _throw_released: + if _held_prop == null or not is_instance_valid(_held_prop): + _phase = Phase.IDLE + _active_action.clear() + action_completed.emit() + return + if marker != null: + if _throw_elapsed < THROW_LIFT_DURATION: + var lift_weight := smoothstep(0.0, 1.0, clampf(_throw_elapsed / THROW_LIFT_DURATION, 0.0, 1.0)) + marker.position = _quadratic_bezier(_throw_base_marker_local, _throw_windup_marker_local, _throw_overhead_marker_local, lift_weight) + _update_throw_torso(lift_weight) + else: + var swing_weight := smoothstep(0.0, 1.0, clampf((_throw_elapsed - THROW_LIFT_DURATION) / THROW_SWING_DURATION, 0.0, 1.0)) + marker.position = _quadratic_bezier(_throw_overhead_marker_local, _throw_swing_control_marker_local, _throw_release_marker_local, swing_weight) + _update_throw_torso(1.0 - swing_weight * 0.65) + _update_held_transform(delta, true) + if not _throw_released and _throw_elapsed >= release_time: + if marker != null: + marker.position = _throw_release_marker_local + _update_throw_torso(0.35) + if _held_prop != null and is_instance_valid(_held_prop): + _held_prop.global_transform = _rig_transform_for_facing(_throw_facing_left) * _throw_release_attachment_local + var target := _throw_effective_target + var spin_sign := -1.0 if _throw_facing_left else 1.0 + var solution := _solve_throw(_held_prop.global_transform, target, _held_prop, spin_sign * THROW_SPIN_SPEED) + if solution.is_empty(): + if marker != null: + marker.position = _throw_base_marker_local + _restore_throw_torso() + _phase = Phase.HELD + _active_action.clear() + action_completed.emit() + return + _release_held("throw", true, solution["velocity"], spin_sign * THROW_SPIN_SPEED) + _throw_released = true + if _throw_released and marker != null: + var follow_weight := smoothstep(0.0, 1.0, clampf((_throw_elapsed - release_time) / THROW_FOLLOW_THROUGH, 0.0, 1.0)) + marker.position = _quadratic_bezier(_throw_release_marker_local, _throw_follow_control_marker_local, _throw_follow_marker_local, follow_weight) + _update_throw_torso(0.35 * (1.0 - follow_weight)) + if _throw_released and _throw_elapsed >= release_time + THROW_FOLLOW_THROUGH: + _phase = Phase.IDLE + _throw_released = false + _active_action.clear() + _restore_hand_target() + _restore_throw_torso() + action_completed.emit() + + +func _update_throw_torso(weight: float) -> void: + var torso_marker := _torso_marker() + if torso_marker == null: + return + torso_marker.position = _throw_torso_start.lerp(_throw_torso_windup, weight) + torso_marker.rotation = lerpf(_throw_torso_start_rotation, _throw_torso_windup_rotation, weight) + + +func _restore_throw_torso() -> void: + var torso_marker := _torso_marker() + if torso_marker != null: + torso_marker.position = _throw_torso_start + torso_marker.rotation = _throw_torso_start_rotation + + +func _solve_throw(release_transform: Transform2D, target: Vector2, prop: PropBlock, spin_speed: float) -> Dictionary: + var start := release_transform.origin + var delta_position := target - start + if not start.is_finite() or not target.is_finite() or not delta_position.is_finite(): + return {} + if delta_position.is_zero_approx(): + return {} + var gravity_direction: Vector2 = ProjectSettings.get_setting("physics/2d/default_gravity_vector", Vector2.DOWN) + var gravity_magnitude := float(ProjectSettings.get_setting("physics/2d/default_gravity", 980.0)) * prop.gravity_scale + if not gravity_direction.is_finite() or not is_finite(gravity_magnitude) or not is_finite(spin_speed): + return {} + var gravity := gravity_direction.normalized() * gravity_magnitude + if not gravity.is_finite(): + return {} + var flight_time := _shortest_valid_throw_time(delta_position, gravity) + if flight_time <= 0.0: + return {} + var velocity := (delta_position - 0.5 * gravity * flight_time * flight_time) / flight_time + if not velocity.is_finite() or velocity.length() > MAX_THROW_SPEED + 0.01: + return {} + return _make_throw_solution(start, target, velocity, gravity, release_transform.get_rotation(), spin_speed, flight_time) + + +func _shortest_valid_throw_time(displacement: Vector2, gravity: Vector2) -> float: + var gravity_squared := gravity.length_squared() + var lower_time := MIN_THROW_FLIGHT_TIME + var upper_time := THROW_PREVIEW_HORIZON + if gravity_squared > 0.000001: + var a := 0.25 * gravity_squared + var b := -(displacement.dot(gravity) + MAX_THROW_SPEED * MAX_THROW_SPEED) + var c := displacement.length_squared() + var discriminant := b * b - 4.0 * a * c + if discriminant < 0.0: + return 0.0 + var root := sqrt(maxf(discriminant, 0.0)) + var lower_time_squared := (-b - root) / (2.0 * a) + var upper_time_squared := (-b + root) / (2.0 * a) + if upper_time_squared <= 0.0: + return 0.0 + lower_time = maxf(lower_time, sqrt(maxf(lower_time_squared, 0.0))) + upper_time = minf(upper_time, sqrt(upper_time_squared)) + var down_direction := gravity / sqrt(gravity_squared) + var down_displacement := displacement.dot(down_direction) + if down_displacement < 0.0: + var descending_time := sqrt(-2.0 * down_displacement / sqrt(gravity_squared)) + THROW_DESCENT_EPSILON + lower_time = maxf(lower_time, descending_time) + else: + lower_time = maxf(lower_time, displacement.length() / MAX_THROW_SPEED) + if lower_time > upper_time or lower_time > THROW_PREVIEW_HORIZON: + return 0.0 + return lower_time + + +func _make_throw_solution(start: Vector2, target: Vector2, velocity: Vector2, gravity: Vector2, release_rotation: float, spin_speed: float, flight_time: float) -> Dictionary: + var points := PackedVector2Array() + for index: int in THROW_TRAJECTORY_SEGMENTS + 1: + var t := flight_time * float(index) / float(THROW_TRAJECTORY_SEGMENTS) + points.append(start + velocity * t + gravity * (0.5 * t * t)) + return { + "velocity": velocity, + "landing_position": target, + "start_position": start, + "effective_target": target, + "release_rotation": release_rotation, + "spin_speed": spin_speed, + "flight_time": flight_time, + "trajectory_points": points, + } + + +func _predicted_attachment_transform(wrist_position: Vector2, facing_left: bool, prop_override: PropBlock = null, anchor_override: Dictionary = {}, hand_override: int = -1, root_offset: Vector2 = Vector2.ZERO) -> Transform2D: + var prop := _held_prop if prop_override == null else prop_override + var anchor := _held_anchor if anchor_override.is_empty() else anchor_override + var hand_value := int(_hand) if hand_override < 0 else hand_override + if _rig == null or prop == null or not is_instance_valid(prop): + return Transform2D(0.0, wrist_position) + var suffix := "Left" if hand_value == Hand.LEFT else "Right" + var lower_visual := _rig.get_node_or_null(NodePath("Body/%sLowerArm" % suffix)) as Node2D + var elbow_world := _solved_wrist_transform_for(hand_value).origin + if lower_visual != null: + elbow_world = lower_visual.global_position + var rig_transform := _rig_transform_for_facing(facing_left, root_offset) + var elbow_position := rig_transform * _rig.to_local(elbow_world) + var arm_direction := wrist_position - elbow_position + var arm_angle := arm_direction.angle() if not arm_direction.is_zero_approx() else _solved_wrist_transform_for(hand_value).get_rotation() + var wrist_transform := Transform2D(arm_angle, wrist_position) + return ANCHOR_DATA.solve_attachment_transform(wrist_transform, _scaled_anchor(anchor, prop), Vector2.ONE, facing_left) + + +func _clip_preview_to_collision(solution: Dictionary, prop: PropBlock) -> Dictionary: + if prop == null or not is_instance_valid(prop): + return solution + var collision_shape: Shape2D + var collision_local_transform := Transform2D.IDENTITY + var polygon_node := prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D + if polygon_node != null and polygon_node.polygon.size() >= 3: + var convex_points := Geometry2D.convex_hull(polygon_node.polygon) + if convex_points.size() >= 3: + var convex_shape := ConvexPolygonShape2D.new() + convex_shape.points = convex_points + collision_shape = convex_shape + collision_local_transform = prop.global_transform.affine_inverse() * polygon_node.global_transform + if polygon_node.disabled: + collision_shape = null + var circle_node := prop.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D + if collision_shape == null and circle_node != null and circle_node.shape != null and not circle_node.disabled: + collision_shape = circle_node.shape + collision_local_transform = prop.global_transform.affine_inverse() * circle_node.global_transform + if collision_shape == null: + return solution + var collision_mask := int(_saved_body_state.get("collision_mask", prop.collision_mask)) + if collision_mask == 0: + return solution + var points: PackedVector2Array = solution.get("trajectory_points", PackedVector2Array()) + if points.size() < 2: + return solution + var space_state := prop.get_world_2d().direct_space_state + if space_state == null: + return solution + var excluded: Array[RID] = [prop.get_rid()] + if _rig != null and _rig.has_method("get_collision_body_rids"): + for body_rid: RID in _rig.call("get_collision_body_rids"): + if body_rid.is_valid(): + excluded.append(body_rid) + var clipped_points := PackedVector2Array([points[0]]) + var impact_position := points[-1] + var impact_time := float(solution.get("flight_time", THROW_PREVIEW_HORIZON)) + var collision_position := Vector2.INF + var collision_before_target := false + var segment_count := points.size() - 1 + var horizon := float(solution.get("flight_time", THROW_PREVIEW_HORIZON)) + var start_rotation := float(solution.get("release_rotation", 0.0)) + for index: int in segment_count: + var from_position := points[index] + var to_position := points[index + 1] + var t0 := horizon * float(index) / float(segment_count) + var dt := horizon / float(segment_count) + var query := PhysicsShapeQueryParameters2D.new() + query.shape = collision_shape + query.transform = Transform2D(start_rotation + float(solution.get("spin_speed", THROW_SPIN_SPEED)) * t0, from_position) * collision_local_transform + query.motion = to_position - from_position + query.collision_mask = collision_mask + query.exclude = excluded + var cast := space_state.cast_motion(query) + if cast.size() >= 2 and cast[0] < 0.9999: + var safe_fraction := clampf(cast[0], 0.0, 1.0) + impact_position = from_position.lerp(to_position, safe_fraction) + impact_time = t0 + dt * safe_fraction + collision_position = impact_position + collision_before_target = true + clipped_points.append(impact_position) + break + clipped_points.append(to_position) + var clipped := solution.duplicate(true) + clipped["landing_position"] = impact_position + clipped["flight_time"] = impact_time + clipped["trajectory_points"] = clipped_points + clipped["collision_position"] = collision_position + clipped["collision_before_target"] = collision_before_target + return clipped + + +func _quadratic_bezier(from: Vector2, control: Vector2, to: Vector2, weight: float) -> Vector2: + var t := clampf(weight, 0.0, 1.0) + var inverse := 1.0 - t + return from * inverse * inverse + control * (2.0 * inverse * t) + to * t * t + + +func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vector2.ZERO, throw_spin: float = 0.0) -> void: + if _held_prop == null or not is_instance_valid(_held_prop): + _clear_held_state() + return + var prop := _held_prop + var release_velocity := throw_velocity if throwing else _sampled_linear_velocity + var release_angular := _sampled_angular_velocity + if not release_velocity.is_finite(): + release_velocity = Vector2.ZERO + if not is_finite(release_angular): + release_angular = 0.0 + prop.interaction_owner_id = 0 + prop.interaction_reserved_by = 0 + prop.collision_layer = int(_saved_body_state.get("collision_layer", 1)) + prop.collision_mask = int(_saved_body_state.get("collision_mask", 1)) + prop.z_index = int(_saved_body_state.get("z_index", prop.z_index)) + prop.z_as_relative = bool(_saved_body_state.get("z_as_relative", prop.z_as_relative)) + prop.freeze_mode = int(_saved_body_state.get("freeze_mode", RigidBody2D.FREEZE_MODE_KINEMATIC)) + prop.freeze = false if throwing else bool(_saved_body_state.get("freeze", false)) + if throwing: + prop.sleeping = false + prop.linear_velocity = release_velocity + prop.angular_velocity = throw_spin + elif not prop.freeze: + prop.sleeping = false + prop.linear_velocity = release_velocity + prop.angular_velocity = release_angular + _held_prop = null + _held_anchor.clear() + _saved_body_state.clear() + _sampled_linear_velocity = Vector2.ZERO + _sampled_angular_velocity = 0.0 + if not throwing: + _restore_hand_target() + _phase = Phase.IDLE if not throwing else Phase.THROWING + if reason == "manual" or reason == "ragdoll": + prop_event.emit("prop_dropped", prop, reason) + elif reason == "throw": + prop_event.emit("prop_thrown", prop, "") + + +func _clear_reservation() -> void: + if _held_prop != null and is_instance_valid(_held_prop): + if _held_prop.interaction_reserved_by == _rig.get_instance_id(): + _held_prop.interaction_reserved_by = 0 + _held_prop = null + _held_anchor.clear() + _active_action.clear() + + +func _clear_held_state() -> void: + _restore_pickup_torso() + _held_prop = null + _held_anchor.clear() + _saved_body_state.clear() + _phase = Phase.IDLE + _active_action.clear() + + +func _restore_hand_target() -> void: + if _rig == null: + return + var marker := _hand_marker() + if marker != null: + marker.position = _marker_start_position + + +func _hand_marker() -> Marker2D: + return _hand_marker_for(_hand) + + +func _torso_marker() -> Marker2D: + return _rig.get_node_or_null(NodePath("IK_Targets/Torso")) as Marker2D if _rig != null else null + + +func _hand_marker_for(hand: int) -> Marker2D: + if _rig == null: + return null + var path := "IK_Targets/Left_Hand" if hand == Hand.LEFT else "IK_Targets/Right_Hand" + return _rig.get_node_or_null(NodePath(path)) as Marker2D + + +func _solved_wrist_transform() -> Transform2D: + return _solved_wrist_transform_for(_hand) + + +func _solved_wrist_transform_for(hand: Hand) -> Transform2D: + var suffix := "Left" if hand == Hand.LEFT else "Right" + var lower_visual := _rig.get_node_or_null(NodePath("Body/%sLowerArm" % suffix)) as Node2D + var lower_bone := _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D + if lower_visual == null or lower_bone == null: + var marker := _hand_marker_for(hand) + return Transform2D(0.0, marker.global_position if marker != null else _rig.global_position) + var elbow := lower_visual.to_global(Vector2.ZERO) + var wrist := lower_visual.to_global(Vector2(0.0, lower_bone.length)) + var direction := (wrist - elbow).normalized() + return Transform2D(direction.angle(), wrist) + + +func _scaled_anchor(anchor: Dictionary, prop: PropBlock) -> Dictionary: + var result := anchor.duplicate(true) + result["position"] = (anchor.get("position", Vector2.ZERO) as Vector2) * prop.get_interaction_scale() + return result + + +func _grip_world_position(prop: PropBlock, anchor: Dictionary) -> Vector2: + return _grip_world_position_for_facing(prop, anchor, _is_facing_left()) + + +func _grip_world_position_for_facing(prop: PropBlock, anchor: Dictionary, facing_left: bool) -> Vector2: + var scaled := _scaled_anchor(anchor, prop) + var local_position: Vector2 = scaled.get("position", Vector2.ZERO) + if facing_left: + local_position.x = -local_position.x + return prop.global_transform * local_position + + +func _is_facing_left() -> bool: + return _rig != null and int(_rig.call("get_facing_profile")) == 0 diff --git a/scripts/stickman_prop_interactor.gd.uid b/scripts/stickman_prop_interactor.gd.uid new file mode 100644 index 0000000..9f3665a --- /dev/null +++ b/scripts/stickman_prop_interactor.gd.uid @@ -0,0 +1 @@ +uid://bsdnwby48vl5 diff --git a/scripts/stickman_rig.gd b/scripts/stickman_rig.gd index 63ea989..d0bce9b 100644 --- a/scripts/stickman_rig.gd +++ b/scripts/stickman_rig.gd @@ -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: diff --git a/scripts/trigger_registry.gd b/scripts/trigger_registry.gd index 9f0c7a4..e7b2c41 100644 --- a/scripts/trigger_registry.gd +++ b/scripts/trigger_registry.gd @@ -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", + }, } diff --git a/specs/phase_11_prop_interactions.md b/specs/phase_11_prop_interactions.md new file mode 100644 index 0000000..235a735 --- /dev/null +++ b/specs/phase_11_prop_interactions.md @@ -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).