Implement interactive prop handling for StickmanRig
- Added PropTrajectory class for deterministic ballistic calculations. - Introduced StickmanPropInteraction component to manage prop interactions (pickup, drop, throw). - Implemented grip resolution and attachment logic for props. - Added signals for interaction events (prop_picked_up, prop_dropped, prop_thrown). - Defined specifications for prop interaction lifecycle and event rules. - Ensured compatibility with existing prop systems and added new capabilities for prop pickup.
This commit is contained in:
@@ -107,6 +107,7 @@ 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 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-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-23 | Phase 11 interactive-prop specification written (specs/phase_11_prop_interaction.md), awaiting user sign-off. Approved direction: gated 11A pickup/drop plus triggers, 11B deterministic exact-target throws, and 11C ragdoll disarm; add a backward-compatible pickup capability defaulting true so authored props such as trees can opt out; preserve grip anchors into PropBlock and reuse PropAnchorData; serialize queue prop targets by scene object ID; keep held props under World; dispatch prop events through SandboxStage; cap throws by a tunable 900 px/s launch speed and 0.35–1.5 s flight time. No implementation or debt-status changes are recorded in this entry. |
|
||||||
---
|
---
|
||||||
|
|
||||||
_End of Log_
|
_End of Log_
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"scene_name": "Untitled",
|
||||||
|
"camera": {
|
||||||
|
"position": {
|
||||||
|
"x": 0.0,
|
||||||
|
"y": -400.0
|
||||||
|
},
|
||||||
|
"zoom": 0.5
|
||||||
|
},
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"file_id": "terrain_1",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -1344.0,
|
||||||
|
"y": 128.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": 128.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": 128.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": 128.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": 128.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": 128.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": 128.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": 128.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": 128.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": 128.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": 128.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": 128.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": 128.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "stickman_1",
|
||||||
|
"type": "stickman",
|
||||||
|
"position": {
|
||||||
|
"x": -216.0,
|
||||||
|
"y": -348.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"stk_path": "res://stickmen/break.stk",
|
||||||
|
"facing_profile": "FORWARD",
|
||||||
|
"action_queue": [
|
||||||
|
{
|
||||||
|
"type": "wait",
|
||||||
|
"duration": 1.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "pick_up_prop",
|
||||||
|
"target_prop_id": "prop_1",
|
||||||
|
"hand": "auto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "walk_to",
|
||||||
|
"target": {
|
||||||
|
"x": 300.0,
|
||||||
|
"y": 84.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "throw_prop",
|
||||||
|
"target_prop_id": "prop_1",
|
||||||
|
"target_position": {
|
||||||
|
"x": 924.0,
|
||||||
|
"y": -540.0
|
||||||
|
},
|
||||||
|
"flight_time": 1.45
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "prop_1",
|
||||||
|
"type": "prop",
|
||||||
|
"position": {
|
||||||
|
"x": 204.0,
|
||||||
|
"y": -264.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "res://props/briefcase.prop",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
},
|
||||||
|
"material_preset": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"event_rules": [],
|
||||||
|
"metadata": {
|
||||||
|
"created_unix": 1790175307,
|
||||||
|
"modified_unix": 1790175629.897,
|
||||||
|
"autosave": false,
|
||||||
|
"source_path": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- **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$.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -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_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_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_action_runner.gd" id="4_runner"]
|
||||||
|
[ext_resource type="Script" path="res://scripts/stickman_prop_interaction.gd" id="5_prop_interaction"]
|
||||||
|
|
||||||
[sub_resource type="GDScript" id="GDScript_f0s26"]
|
[sub_resource type="GDScript" id="GDScript_f0s26"]
|
||||||
script/source = "@tool
|
script/source = "@tool
|
||||||
@@ -335,6 +336,9 @@ script = ExtResource("3_ragdoll")
|
|||||||
[node name="ActionRunner" type="Node" parent="RigComponents"]
|
[node name="ActionRunner" type="Node" parent="RigComponents"]
|
||||||
script = ExtResource("4_runner")
|
script = ExtResource("4_runner")
|
||||||
|
|
||||||
|
[node name="PropInteraction" type="Node" parent="RigComponents"]
|
||||||
|
script = ExtResource("5_prop_interaction")
|
||||||
|
|
||||||
[node name="Body" type="Node2D" parent="." unique_id=327811712]
|
[node name="Body" type="Node2D" parent="." unique_id=327811712]
|
||||||
|
|
||||||
[node name="Body" type="Line2D" parent="Body" unique_id=1290443463]
|
[node name="Body" type="Line2D" parent="Body" unique_id=1290443463]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
|||||||
signal committed(action: Dictionary)
|
signal committed(action: Dictionary)
|
||||||
signal cancelled()
|
signal cancelled()
|
||||||
signal target_requested()
|
signal target_requested()
|
||||||
|
signal prop_requested()
|
||||||
|
|
||||||
var _mode: String = "new"
|
var _mode: String = "new"
|
||||||
var _initial: Dictionary = {}
|
var _initial: Dictionary = {}
|
||||||
@@ -21,11 +22,17 @@ var _params_box: VBoxContainer = null
|
|||||||
var _text_edit: LineEdit = null
|
var _text_edit: LineEdit = null
|
||||||
var _duration_spin: SpinBox = null
|
var _duration_spin: SpinBox = null
|
||||||
var _target_label: Label = null
|
var _target_label: Label = null
|
||||||
|
var _prop_target_label: Label = null
|
||||||
|
var _target_prop_display_name: String = ""
|
||||||
var _set_target_btn: Button = null
|
var _set_target_btn: Button = null
|
||||||
var _stop_current_check: CheckBox = null
|
var _stop_current_check: CheckBox = null
|
||||||
var _show_reactive_options: bool = false
|
var _show_reactive_options: bool = false
|
||||||
|
var _allow_prop_actions: bool = true
|
||||||
var _has_target: bool = false
|
var _has_target: bool = false
|
||||||
var _target_pos: Vector2 = Vector2.ZERO
|
var _target_pos: Vector2 = Vector2.ZERO
|
||||||
|
var _target_prop_id: int = -1
|
||||||
|
var _flight_time_spin: SpinBox = null
|
||||||
|
var _hand_option: OptionButton = null
|
||||||
|
|
||||||
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||||
## override (engine default); Font null = no override.
|
## override (engine default); Font null = no override.
|
||||||
@@ -49,10 +56,14 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
## Opens the editor in "add" mode (empty, default type walk_to).
|
## Opens the editor in "add" mode (empty, default type walk_to).
|
||||||
func open_new(show_reactive_options: bool = false) -> void:
|
func open_new(show_reactive_options: bool = false) -> void:
|
||||||
_show_reactive_options = show_reactive_options
|
_show_reactive_options = show_reactive_options
|
||||||
|
_allow_prop_actions = not show_reactive_options
|
||||||
|
_rebuild_type_options()
|
||||||
_mode = "new"
|
_mode = "new"
|
||||||
_initial = {}
|
_initial = {}
|
||||||
_has_target = false
|
_has_target = false
|
||||||
_target_pos = Vector2.ZERO
|
_target_pos = Vector2.ZERO
|
||||||
|
_target_prop_id = -1
|
||||||
|
_target_prop_display_name = ""
|
||||||
_select_type("walk_to")
|
_select_type("walk_to")
|
||||||
_rebuild_params("walk_to")
|
_rebuild_params("walk_to")
|
||||||
title = "Add Action"
|
title = "Add Action"
|
||||||
@@ -62,11 +73,15 @@ func open_new(show_reactive_options: bool = false) -> void:
|
|||||||
## Opens the editor pre-filled from a flat queue action.
|
## Opens the editor pre-filled from a flat queue action.
|
||||||
func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
|
func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
|
||||||
_show_reactive_options = show_reactive_options
|
_show_reactive_options = show_reactive_options
|
||||||
|
_allow_prop_actions = not show_reactive_options
|
||||||
|
_rebuild_type_options()
|
||||||
_mode = "edit"
|
_mode = "edit"
|
||||||
_initial = action.duplicate(true)
|
_initial = action.duplicate(true)
|
||||||
var type := String(action.get("type", "walk_to"))
|
var type := String(action.get("type", "walk_to"))
|
||||||
_has_target = action.has("target") and action["target"] is Vector2
|
_has_target = (action.has("target") and action["target"] is Vector2) or (action.has("target_position") and action["target_position"] is Vector2)
|
||||||
_target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO
|
_target_pos = action.get("target_position", action.get("target", Vector2.ZERO)) if _has_target else Vector2.ZERO
|
||||||
|
_target_prop_id = int(action.get("target_prop", -1))
|
||||||
|
_target_prop_display_name = ""
|
||||||
_select_type(type)
|
_select_type(type)
|
||||||
_rebuild_params(type)
|
_rebuild_params(type)
|
||||||
title = "Edit Action"
|
title = "Edit Action"
|
||||||
@@ -80,6 +95,18 @@ func set_walk_target(pos: Vector2) -> void:
|
|||||||
_update_walk_target_label()
|
_update_walk_target_label()
|
||||||
popup_centered()
|
popup_centered()
|
||||||
|
|
||||||
|
func set_prop_target(prop: PropBlock, display_name: String = "") -> void:
|
||||||
|
_target_prop_id = prop.get_instance_id() if prop != null and is_instance_valid(prop) else -1
|
||||||
|
_target_prop_display_name = display_name
|
||||||
|
_update_prop_target_label()
|
||||||
|
popup_centered()
|
||||||
|
|
||||||
|
func set_throw_target(pos: Vector2) -> void:
|
||||||
|
_has_target = true
|
||||||
|
_target_pos = pos
|
||||||
|
_update_walk_target_label()
|
||||||
|
popup_centered()
|
||||||
|
|
||||||
|
|
||||||
func _build_ui() -> void:
|
func _build_ui() -> void:
|
||||||
var margin := MarginContainer.new()
|
var margin := MarginContainer.new()
|
||||||
@@ -100,7 +127,7 @@ func _build_ui() -> void:
|
|||||||
type_row.add_child(type_label)
|
type_row.add_child(type_label)
|
||||||
_type_option = OptionButton.new()
|
_type_option = OptionButton.new()
|
||||||
_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
for type: String in ACTION_REGISTRY.types():
|
for type: String in _available_types():
|
||||||
_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
|
_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
|
||||||
_type_option.item_selected.connect(_on_type_selected)
|
_type_option.item_selected.connect(_on_type_selected)
|
||||||
type_row.add_child(_type_option)
|
type_row.add_child(_type_option)
|
||||||
@@ -158,19 +185,42 @@ func _apply_font_to(c: Control, size: int) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _select_type(type: String) -> void:
|
func _select_type(type: String) -> void:
|
||||||
var idx := ACTION_REGISTRY.types().find(type)
|
var idx := _available_types().find(type)
|
||||||
if idx < 0:
|
if idx < 0:
|
||||||
idx = 0
|
idx = 0
|
||||||
_type_option.select(idx)
|
_type_option.select(idx)
|
||||||
|
|
||||||
|
|
||||||
func _current_type() -> String:
|
func _current_type() -> String:
|
||||||
var types := ACTION_REGISTRY.types()
|
var types := _available_types()
|
||||||
var idx := _type_option.selected
|
var idx := _type_option.selected
|
||||||
if idx < 0 or idx >= types.size():
|
if idx < 0 or idx >= types.size():
|
||||||
return "walk_to"
|
return "walk_to"
|
||||||
return types[idx]
|
return types[idx]
|
||||||
|
|
||||||
|
func get_action_type() -> String:
|
||||||
|
return _current_type()
|
||||||
|
|
||||||
|
func _available_types() -> Array[String]:
|
||||||
|
var types: Array[String] = ACTION_REGISTRY.types()
|
||||||
|
if not _allow_prop_actions:
|
||||||
|
types.erase("pick_up_prop")
|
||||||
|
types.erase("drop_prop")
|
||||||
|
types.erase("throw_prop")
|
||||||
|
return types
|
||||||
|
|
||||||
|
func _rebuild_type_options() -> void:
|
||||||
|
if _type_option == null: return
|
||||||
|
_type_option.clear()
|
||||||
|
for type: String in _available_types():
|
||||||
|
_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
|
||||||
|
|
||||||
|
func get_target_prop_id() -> int:
|
||||||
|
return _target_prop_id
|
||||||
|
|
||||||
|
func get_flight_time() -> float:
|
||||||
|
return _flight_time_spin.value if _flight_time_spin != null else 0.9
|
||||||
|
|
||||||
|
|
||||||
func _on_type_selected(_index: int) -> void:
|
func _on_type_selected(_index: int) -> void:
|
||||||
_rebuild_params(_current_type())
|
_rebuild_params(_current_type())
|
||||||
@@ -183,8 +233,11 @@ func _rebuild_params(type: String) -> void:
|
|||||||
_text_edit = null
|
_text_edit = null
|
||||||
_duration_spin = null
|
_duration_spin = null
|
||||||
_target_label = null
|
_target_label = null
|
||||||
|
_prop_target_label = null
|
||||||
_set_target_btn = null
|
_set_target_btn = null
|
||||||
_stop_current_check = null
|
_stop_current_check = null
|
||||||
|
_flight_time_spin = null
|
||||||
|
_hand_option = null
|
||||||
|
|
||||||
match type:
|
match type:
|
||||||
"walk_to":
|
"walk_to":
|
||||||
@@ -222,6 +275,24 @@ func _rebuild_params(type: String) -> void:
|
|||||||
_params_box.add_child(wlabel)
|
_params_box.add_child(wlabel)
|
||||||
_duration_spin = _make_duration_spin(float(_initial.get("duration", 1.0)))
|
_duration_spin = _make_duration_spin(float(_initial.get("duration", 1.0)))
|
||||||
_params_box.add_child(_duration_spin)
|
_params_box.add_child(_duration_spin)
|
||||||
|
"pick_up_prop", "throw_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)
|
||||||
|
var prop_button := Button.new(); prop_button.text = "🧱 Click prop…"; prop_button.pressed.connect(func() -> void: prop_requested.emit()); prop_row.add_child(prop_button)
|
||||||
|
_update_prop_target_label()
|
||||||
|
if type == "pick_up_prop":
|
||||||
|
var hand_label := Label.new(); hand_label.text = "Hand:"; _params_box.add_child(hand_label)
|
||||||
|
_hand_option = OptionButton.new()
|
||||||
|
for hand: String in ["auto", "left", "right"]: _hand_option.add_item(hand.capitalize())
|
||||||
|
_hand_option.select(maxi(0, ["auto", "left", "right"].find(String(_initial.get("hand", "auto")))))
|
||||||
|
_params_box.add_child(_hand_option)
|
||||||
|
if type == "throw_prop":
|
||||||
|
var target_row := HBoxContainer.new(); _params_box.add_child(target_row)
|
||||||
|
_target_label = Label.new(); _target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL; target_row.add_child(_target_label)
|
||||||
|
var target_button := Button.new(); target_button.text = "🎯 Click landing point…"; target_button.pressed.connect(func() -> void: target_requested.emit()); target_row.add_child(target_button)
|
||||||
|
_update_walk_target_label()
|
||||||
|
var flight_label := Label.new(); flight_label.text = "Flight (s):"; _params_box.add_child(flight_label)
|
||||||
|
_flight_time_spin = _make_duration_spin(float(_initial.get("flight_time", 0.9))); _flight_time_spin.min_value = 0.35; _flight_time_spin.max_value = 1.5; _params_box.add_child(_flight_time_spin)
|
||||||
_:
|
_:
|
||||||
# ragdoll / recover (and any future no-param action) need no fields.
|
# ragdoll / recover (and any future no-param action) need no fields.
|
||||||
pass
|
pass
|
||||||
@@ -246,6 +317,15 @@ func _update_walk_target_label() -> void:
|
|||||||
else:
|
else:
|
||||||
_target_label.text = "Target: not set"
|
_target_label.text = "Target: not set"
|
||||||
|
|
||||||
|
func _update_prop_target_label() -> void:
|
||||||
|
if _prop_target_label != null:
|
||||||
|
if _target_prop_id < 0:
|
||||||
|
_prop_target_label.text = "Prop: not set"
|
||||||
|
elif not _target_prop_display_name.is_empty():
|
||||||
|
_prop_target_label.text = "Prop: %s" % _target_prop_display_name
|
||||||
|
else:
|
||||||
|
_prop_target_label.text = "Prop: selected"
|
||||||
|
|
||||||
|
|
||||||
func _on_ok_pressed() -> void:
|
func _on_ok_pressed() -> void:
|
||||||
var type := _current_type()
|
var type := _current_type()
|
||||||
@@ -264,4 +344,11 @@ func _on_ok_pressed() -> void:
|
|||||||
action["stop_current"] = _stop_current_check.button_pressed
|
action["stop_current"] = _stop_current_check.button_pressed
|
||||||
"wait":
|
"wait":
|
||||||
action["duration"] = _duration_spin.value if _duration_spin != null else 1.0
|
action["duration"] = _duration_spin.value if _duration_spin != null else 1.0
|
||||||
|
"pick_up_prop":
|
||||||
|
if _target_prop_id < 0: prop_requested.emit(); return
|
||||||
|
action["target_prop"] = _target_prop_id; action["hand"] = ["auto", "left", "right"][_hand_option.selected] if _hand_option != null else "auto"
|
||||||
|
"throw_prop":
|
||||||
|
if _target_prop_id < 0: prop_requested.emit(); return
|
||||||
|
if not _has_target: target_requested.emit(); return
|
||||||
|
action["target_prop"] = _target_prop_id; action["target_position"] = _target_pos; action["flight_time"] = _flight_time_spin.value if _flight_time_spin != null else 0.9
|
||||||
committed.emit(action)
|
committed.emit(action)
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ const ACTION_TEMPLATES := {
|
|||||||
"params": [],
|
"params": [],
|
||||||
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
|
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
|
||||||
},
|
},
|
||||||
|
"pick_up_prop": { "label": "Pick Up Prop", "icon": "🖐️", "params": [{"key": "target_prop", "type": "prop", "required": true}, {"key": "hand", "type": "hand", "default": "auto"}], "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_prop", "type": "prop", "required": true}, {"key": "target_position", "type": "position", "required": true}, {"key": "flight_time", "type": "float", "default": 0.9}], "reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +105,10 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
|
|||||||
params["stop_current"] = true
|
params["stop_current"] = true
|
||||||
"wait":
|
"wait":
|
||||||
params["duration"] = float(action.get("duration", 0.0))
|
params["duration"] = float(action.get("duration", 0.0))
|
||||||
|
"pick_up_prop":
|
||||||
|
params["target_prop"] = int(action.get("target_prop", -1)); params["hand"] = String(action.get("hand", "auto"))
|
||||||
|
"throw_prop":
|
||||||
|
params["target_prop"] = int(action.get("target_prop", -1)); params["target_position"] = action.get("target_position", Vector2.ZERO); params["flight_time"] = float(action.get("flight_time", 0.9))
|
||||||
return {
|
return {
|
||||||
"type": String(action.get("type", "")),
|
"type": String(action.get("type", "")),
|
||||||
"target": target_id,
|
"target": target_id,
|
||||||
@@ -126,6 +133,9 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
|
|||||||
return speak
|
return speak
|
||||||
"wait":
|
"wait":
|
||||||
return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
|
return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
|
||||||
|
"pick_up_prop": return {"type": "pick_up_prop", "target_prop": int(params.get("target_prop", -1)), "hand": String(params.get("hand", "auto"))}
|
||||||
|
"drop_prop": return {"type": "drop_prop"}
|
||||||
|
"throw_prop": return {"type": "throw_prop", "target_prop": int(params.get("target_prop", -1)), "target_position": params.get("target_position", Vector2.ZERO), "flight_time": float(params.get("flight_time", 0.9))}
|
||||||
_:
|
_:
|
||||||
return { "type": String(rule_action.get("type", "")) }
|
return { "type": String(rule_action.get("type", "")) }
|
||||||
|
|
||||||
@@ -155,6 +165,9 @@ static func summarize(action: Dictionary) -> String:
|
|||||||
return "Ragdoll"
|
return "Ragdoll"
|
||||||
"recover":
|
"recover":
|
||||||
return "Recover"
|
return "Recover"
|
||||||
|
"pick_up_prop": return "Pick Up Prop"
|
||||||
|
"drop_prop": return "Drop Prop"
|
||||||
|
"throw_prop": return "Throw Prop"
|
||||||
_:
|
_:
|
||||||
return type
|
return type
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ extends RigidBody2D
|
|||||||
## friction/bounce (PhysicsMaterial), and themed visuals.
|
## friction/bounce (PhysicsMaterial), and themed visuals.
|
||||||
|
|
||||||
const PropVisuals := preload("res://scripts/prop_visuals.gd")
|
const PropVisuals := preload("res://scripts/prop_visuals.gd")
|
||||||
|
const PropAnchorData := preload("res://scripts/prop_anchor_data.gd")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Enums
|
# Enums
|
||||||
@@ -22,6 +23,11 @@ enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
|
|||||||
|
|
||||||
## Stable library provenance used by Sandbox Stage scene files.
|
## Stable library provenance used by Sandbox Stage scene files.
|
||||||
var spawn_id: String = ""
|
var spawn_id: String = ""
|
||||||
|
## Runtime interaction metadata. Both values are additive so existing scenes
|
||||||
|
## and built-in templates remain pickup-enabled.
|
||||||
|
var can_be_picked_up: bool = true
|
||||||
|
var anchors: Dictionary = PropAnchorData.default_anchors()
|
||||||
|
var held_by_instance_id: int = 0
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Child node names
|
# Child node names
|
||||||
@@ -275,7 +281,16 @@ func has_custom_shapes() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
func get_visual_local_bounds() -> Rect2:
|
func get_visual_local_bounds() -> Rect2:
|
||||||
|
if _custom_shapes:
|
||||||
return _visual_local_bounds
|
return _visual_local_bounds
|
||||||
|
if shape_type == ShapeType.CIRCLE:
|
||||||
|
return Rect2(Vector2.ONE * -radius, Vector2.ONE * radius * 2.0)
|
||||||
|
if not polygon_points.is_empty():
|
||||||
|
var bounds: Rect2 = Rect2(polygon_points[0], Vector2.ZERO)
|
||||||
|
for point: Vector2 in polygon_points:
|
||||||
|
bounds = bounds.expand(point)
|
||||||
|
return bounds
|
||||||
|
return Rect2()
|
||||||
|
|
||||||
## RigidBody2D transforms do not reliably retain scale during simulation. Move
|
## RigidBody2D transforms do not reliably retain scale during simulation. Move
|
||||||
## the authored uniform scale into child visuals and collision geometry for
|
## the authored uniform scale into child visuals and collision geometry for
|
||||||
|
|||||||
+14
-1
@@ -110,6 +110,7 @@ var _current_path: String = ""
|
|||||||
var _dirty: bool = false
|
var _dirty: bool = false
|
||||||
var _clipboard: Dictionary = {}
|
var _clipboard: Dictionary = {}
|
||||||
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
|
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
|
||||||
|
var _can_be_picked_up: bool = true
|
||||||
var _selected_anchor_id: String = ""
|
var _selected_anchor_id: String = ""
|
||||||
var _thumb: PropThumbnail
|
var _thumb: PropThumbnail
|
||||||
var _thumb_cache: ThumbnailCache
|
var _thumb_cache: ThumbnailCache
|
||||||
@@ -121,6 +122,7 @@ var _material_section: VBoxContainer
|
|||||||
var _mass_label: Label
|
var _mass_label: Label
|
||||||
var _friction_label: Label
|
var _friction_label: Label
|
||||||
var _bounce_label: Label
|
var _bounce_label: Label
|
||||||
|
var _can_be_picked_up_check: CheckBox
|
||||||
var _fill_button: ColorPickerButton
|
var _fill_button: ColorPickerButton
|
||||||
var _outline_button: ColorPickerButton
|
var _outline_button: ColorPickerButton
|
||||||
var _width_spin: SpinBox
|
var _width_spin: SpinBox
|
||||||
@@ -790,6 +792,8 @@ func _do_new() -> void:
|
|||||||
_set_whole_prop_resize_enabled(false)
|
_set_whole_prop_resize_enabled(false)
|
||||||
_prop_name_edit.text = ""
|
_prop_name_edit.text = ""
|
||||||
_material_preset = PropBlockScript.MaterialPreset.NONE
|
_material_preset = PropBlockScript.MaterialPreset.NONE
|
||||||
|
_can_be_picked_up = true
|
||||||
|
if _can_be_picked_up_check != null: _can_be_picked_up_check.button_pressed = true
|
||||||
_preset_option.selected = _material_preset
|
_preset_option.selected = _material_preset
|
||||||
_update_physics_labels()
|
_update_physics_labels()
|
||||||
_canvas.clear()
|
_canvas.clear()
|
||||||
@@ -855,6 +859,13 @@ func _build_inspector() -> void:
|
|||||||
_material_section.add_child(_mass_label)
|
_material_section.add_child(_mass_label)
|
||||||
_material_section.add_child(_friction_label)
|
_material_section.add_child(_friction_label)
|
||||||
_material_section.add_child(_bounce_label)
|
_material_section.add_child(_bounce_label)
|
||||||
|
_can_be_picked_up_check = CheckBox.new()
|
||||||
|
_can_be_picked_up_check.text = "Can be picked up"
|
||||||
|
_can_be_picked_up_check.button_pressed = _can_be_picked_up
|
||||||
|
_can_be_picked_up_check.toggled.connect(func(enabled: bool) -> void:
|
||||||
|
_can_be_picked_up = enabled
|
||||||
|
_mark_dirty())
|
||||||
|
_material_section.add_child(_can_be_picked_up_check)
|
||||||
_update_physics_labels()
|
_update_physics_labels()
|
||||||
|
|
||||||
# Selected-shape section
|
# Selected-shape section
|
||||||
@@ -1454,7 +1465,7 @@ func _check_autosave_recovery() -> void:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _build_json_data() -> Dictionary:
|
func _build_json_data() -> Dictionary:
|
||||||
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors)
|
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors, _can_be_picked_up)
|
||||||
|
|
||||||
|
|
||||||
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
||||||
@@ -1465,6 +1476,8 @@ func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
|||||||
return "The file could not be read: unsupported version or malformed data."
|
return "The file could not be read: unsupported version or malformed data."
|
||||||
_prop_name_edit.text = str(model.get("prop_name", ""))
|
_prop_name_edit.text = str(model.get("prop_name", ""))
|
||||||
_material_preset = int(model.get("material_preset", PropBlockScript.MaterialPreset.NONE))
|
_material_preset = int(model.get("material_preset", PropBlockScript.MaterialPreset.NONE))
|
||||||
|
_can_be_picked_up = bool(model.get("can_be_picked_up", true))
|
||||||
|
if _can_be_picked_up_check != null: _can_be_picked_up_check.button_pressed = _can_be_picked_up
|
||||||
if _preset_option != null:
|
if _preset_option != null:
|
||||||
_preset_option.selected = _material_preset
|
_preset_option.selected = _material_preset
|
||||||
_update_physics_labels()
|
_update_physics_labels()
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScr
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}) -> Dictionary:
|
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}, can_be_picked_up: bool = true) -> Dictionary:
|
||||||
var serialized: Array = []
|
var serialized: Array = []
|
||||||
for shape: Dictionary in shapes:
|
for shape: Dictionary in shapes:
|
||||||
serialized.append(_serialize_shape(shape))
|
serialized.append(_serialize_shape(shape))
|
||||||
@@ -73,6 +73,7 @@ static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[
|
|||||||
"material_preset": preset_to_string(material_preset),
|
"material_preset": preset_to_string(material_preset),
|
||||||
"shapes": serialized,
|
"shapes": serialized,
|
||||||
"anchors": PropAnchorDataScript.serialize_anchors(anchors),
|
"anchors": PropAnchorDataScript.serialize_anchors(anchors),
|
||||||
|
"can_be_picked_up": can_be_picked_up,
|
||||||
"metadata": {"created_at": now, "modified_at": now},
|
"metadata": {"created_at": now, "modified_at": now},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ static func from_file_dict(data: Variant) -> Dictionary:
|
|||||||
"material_preset": preset,
|
"material_preset": preset,
|
||||||
"shapes": shapes,
|
"shapes": shapes,
|
||||||
"anchors": PropAnchorDataScript.normalize_anchors(dict.get("anchors", {})),
|
"anchors": PropAnchorDataScript.normalize_anchors(dict.get("anchors", {})),
|
||||||
|
"can_be_picked_up": dict.get("can_be_picked_up") if dict.get("can_be_picked_up") is bool else true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ static func make_custom_entry(path: String) -> Dictionary:
|
|||||||
var name := String(parsed.get("prop_name", "")).strip_edges()
|
var name := String(parsed.get("prop_name", "")).strip_edges()
|
||||||
if name.is_empty(): name = normalized_path.get_file().get_basename()
|
if name.is_empty(): name = normalized_path.get_file().get_basename()
|
||||||
var preset: int = int(parsed.get("material_preset", PROP_BLOCK.MaterialPreset.NONE))
|
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", {}), "can_be_picked_up": bool(parsed.get("can_be_picked_up", true))}
|
||||||
|
|
||||||
|
|
||||||
static func is_custom_path(path: String) -> bool:
|
static func is_custom_path(path: String) -> bool:
|
||||||
@@ -110,4 +110,6 @@ static func _make(id: String, name: String, preset: int, label: String, payload:
|
|||||||
"path": "",
|
"path": "",
|
||||||
"shapes": [],
|
"shapes": [],
|
||||||
"collision_hull": PackedVector2Array(),
|
"collision_hull": PackedVector2Array(),
|
||||||
|
"anchors": {},
|
||||||
|
"can_be_picked_up": true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
class_name PropTrajectory
|
||||||
|
extends RefCounted
|
||||||
|
## Shared deterministic ballistic solver for director feedback and playback.
|
||||||
|
|
||||||
|
const MIN_FLIGHT_TIME: float = 0.35
|
||||||
|
const MAX_FLIGHT_TIME: float = 1.5
|
||||||
|
const MAX_LAUNCH_SPEED: float = 900.0
|
||||||
|
|
||||||
|
static func solve_throw_velocity(release_point: Vector2, target_point: Vector2, flight_time: float, gravity_vector: Vector2) -> Dictionary:
|
||||||
|
if not release_point.is_finite() or not target_point.is_finite() or not gravity_vector.is_finite():
|
||||||
|
return {"ok": false, "reason": "non_finite"}
|
||||||
|
if not is_finite(flight_time) or flight_time < MIN_FLIGHT_TIME or flight_time > MAX_FLIGHT_TIME:
|
||||||
|
return {"ok": false, "reason": "flight_time"}
|
||||||
|
var velocity: Vector2 = (target_point - release_point - gravity_vector * 0.5 * flight_time * flight_time) / flight_time
|
||||||
|
if not velocity.is_finite() or velocity.length() > MAX_LAUNCH_SPEED:
|
||||||
|
return {"ok": false, "reason": "speed"}
|
||||||
|
return {"ok": true, "velocity": velocity, "flight_time": flight_time, "reason": ""}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ddw3n0jjlduk
|
||||||
+150
-15
@@ -40,6 +40,7 @@ const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd")
|
|||||||
const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd")
|
const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd")
|
||||||
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd")
|
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd")
|
||||||
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd")
|
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd")
|
||||||
|
const PROP_TRAJECTORY := preload("res://scripts/prop_trajectory.gd")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Enums
|
# Enums
|
||||||
@@ -101,7 +102,10 @@ const TRIG_ACTION_FINISHED := 1
|
|||||||
const TRIG_SPEECH_FINISHED := 2
|
const TRIG_SPEECH_FINISHED := 2
|
||||||
const TRIG_ENTERED_AREA := 3
|
const TRIG_ENTERED_AREA := 3
|
||||||
const TRIG_COLLIDED := 4
|
const TRIG_COLLIDED := 4
|
||||||
const TRIG_BACK := 5
|
const TRIG_PROP_PICKED_UP := 5
|
||||||
|
const TRIG_PROP_DROPPED := 6
|
||||||
|
const TRIG_PROP_THROWN := 7
|
||||||
|
const TRIG_BACK := 8
|
||||||
|
|
||||||
## Phase 4 "add another / done" popup item ids.
|
## Phase 4 "add another / done" popup item ids.
|
||||||
const RULE_MORE_ADD := 0
|
const RULE_MORE_ADD := 0
|
||||||
@@ -392,6 +396,11 @@ var _action_editor_rig: StickmanRig = null
|
|||||||
var _action_editor_actor_id: int = -1
|
var _action_editor_actor_id: int = -1
|
||||||
var _action_editor_restore_queue: bool = false
|
var _action_editor_restore_queue: bool = false
|
||||||
var _pending_rule_action_flat: Dictionary = {}
|
var _pending_rule_action_flat: Dictionary = {}
|
||||||
|
## Active only while the Action Editor is capturing a throw landing point.
|
||||||
|
## Kept separately from generic position capture so walk/rule capture remains
|
||||||
|
## unchanged and preview/commit share one explicit contract.
|
||||||
|
var _throw_capture_prop_id: int = -1
|
||||||
|
var _throw_capture_flight_time: float = 0.9
|
||||||
|
|
||||||
## Rule editor came from the rule panel (restore it on close).
|
## Rule editor came from the rule panel (restore it on close).
|
||||||
var _rule_editor_from_panel: bool = false
|
var _rule_editor_from_panel: bool = false
|
||||||
@@ -674,6 +683,7 @@ func _enter_edit_shared() -> void:
|
|||||||
for node: Node2D in _world_children_selectable():
|
for node: Node2D in _world_children_selectable():
|
||||||
if node is STICKMAN_RIG:
|
if node is STICKMAN_RIG:
|
||||||
var rig := node as STICKMAN_RIG
|
var rig := node as STICKMAN_RIG
|
||||||
|
rig.release_held_prop_for_lifecycle(&"play_exit")
|
||||||
rig.stop_queue()
|
rig.stop_queue()
|
||||||
rig.set_terrain_following_enabled(false)
|
rig.set_terrain_following_enabled(false)
|
||||||
rig.snap_to_standing()
|
rig.snap_to_standing()
|
||||||
@@ -871,6 +881,9 @@ func _finalize_placed_node(node: Node2D) -> void:
|
|||||||
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
|
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
|
||||||
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
|
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
|
||||||
rig.state_changed.connect(_on_rig_state_changed.bind(rig))
|
rig.state_changed.connect(_on_rig_state_changed.bind(rig))
|
||||||
|
var interaction: StickmanPropInteraction = rig.get_prop_interaction()
|
||||||
|
if interaction != null:
|
||||||
|
interaction.prop_transition.connect(_on_rig_prop_transition)
|
||||||
_director_visuals.mark_dirty()
|
_director_visuals.mark_dirty()
|
||||||
if node is PropBlock:
|
if node is PropBlock:
|
||||||
var prop := node as PropBlock
|
var prop := node as PropBlock
|
||||||
@@ -1234,6 +1247,16 @@ func delete_selected() -> void:
|
|||||||
var nav_changed := false
|
var nav_changed := false
|
||||||
for node: Node2D in selected:
|
for node: Node2D in selected:
|
||||||
if is_instance_valid(node):
|
if is_instance_valid(node):
|
||||||
|
# Disarm before either endpoint leaves World so a held body is never
|
||||||
|
# left frozen or referenced by a surviving IK marker.
|
||||||
|
if node is StickmanRig:
|
||||||
|
(node as StickmanRig).release_held_prop_for_lifecycle(&"holder_deleted")
|
||||||
|
elif node is PropBlock:
|
||||||
|
for child: Node in _world.get_children():
|
||||||
|
if child is StickmanRig:
|
||||||
|
var interaction: StickmanPropInteraction = (child as StickmanRig).get_prop_interaction()
|
||||||
|
if interaction != null and interaction.get_held_prop() == node:
|
||||||
|
interaction.release_for_lifecycle(&"prop_deleted")
|
||||||
if node is TerrainBlock:
|
if node is TerrainBlock:
|
||||||
nav_changed = true
|
nav_changed = true
|
||||||
_clear_object_state(node)
|
_clear_object_state(node)
|
||||||
@@ -1274,6 +1297,20 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
|
|||||||
var objects: Array[Dictionary] = []
|
var objects: Array[Dictionary] = []
|
||||||
var runtime_to_file: Dictionary = {}
|
var runtime_to_file: Dictionary = {}
|
||||||
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
|
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
|
||||||
|
# Queue actions may point at a prop that appears later in World. Build the
|
||||||
|
# complete runtime→file map before encoding any individual rig queue.
|
||||||
|
for node: Node in _world.get_children():
|
||||||
|
if not node is Node2D: continue
|
||||||
|
var map_node: Node2D = node as Node2D
|
||||||
|
var map_type: String = ""
|
||||||
|
if map_node is TerrainBlock: map_type = "terrain"
|
||||||
|
elif map_node is PropBlock or map_node.get_script() == MISSING_PROP_PLACEHOLDER: map_type = "prop"
|
||||||
|
elif map_node is STICKMAN_RIG or map_node.get_script() == MISSING_STICKMAN_PLACEHOLDER: map_type = "stickman"
|
||||||
|
elif map_node is TriggerArea: map_type = "area"
|
||||||
|
if map_type.is_empty(): continue
|
||||||
|
counters[map_type] = int(counters[map_type]) + 1
|
||||||
|
runtime_to_file[map_node.get_instance_id()] = "%s_%d" % [map_type, int(counters[map_type])]
|
||||||
|
counters = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
|
||||||
for node: Node in _world.get_children():
|
for node: Node in _world.get_children():
|
||||||
if not node is Node2D: continue
|
if not node is Node2D: continue
|
||||||
var n := node as Node2D
|
var n := node as Node2D
|
||||||
@@ -1285,7 +1322,6 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
|
|||||||
else: continue
|
else: continue
|
||||||
counters[type] = int(counters[type]) + 1
|
counters[type] = int(counters[type]) + 1
|
||||||
var file_id := "%s_%d" % [type, int(counters[type])]
|
var file_id := "%s_%d" % [type, int(counters[type])]
|
||||||
runtime_to_file[n.get_instance_id()] = file_id
|
|
||||||
var authored: Dictionary = _authored.get(n.get_instance_id(), {})
|
var authored: Dictionary = _authored.get(n.get_instance_id(), {})
|
||||||
var pos: Vector2 = authored.get("position", n.global_position)
|
var pos: Vector2 = authored.get("position", n.global_position)
|
||||||
var rot: float = rad_to_deg(float(authored.get("rotation", n.global_rotation)))
|
var rot: float = rad_to_deg(float(authored.get("rotation", n.global_rotation)))
|
||||||
@@ -1313,7 +1349,7 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
|
|||||||
var rig := n as StickmanRig
|
var rig := n as StickmanRig
|
||||||
entry["stk_path"] = rig.source_stk_path
|
entry["stk_path"] = rig.source_stk_path
|
||||||
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[int(authored.get("facing_profile", rig.get_facing_profile()))]
|
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[int(authored.get("facing_profile", rig.get_facing_profile()))]
|
||||||
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
|
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue(), runtime_to_file)
|
||||||
entry["action_queue"] = queue_result["actions"]
|
entry["action_queue"] = queue_result["actions"]
|
||||||
objects.append(entry)
|
objects.append(entry)
|
||||||
var rules_result: Dictionary = STAGE_SCENE_IO.encode_rules(_event_rules, runtime_to_file)
|
var rules_result: Dictionary = STAGE_SCENE_IO.encode_rules(_event_rules, runtime_to_file)
|
||||||
@@ -1355,7 +1391,7 @@ func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool:
|
|||||||
func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool:
|
func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool:
|
||||||
var was_suppressed := _suppress_dirty; _suppress_dirty = true
|
var was_suppressed := _suppress_dirty; _suppress_dirty = true
|
||||||
_reset_scene_content()
|
_reset_scene_content()
|
||||||
var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = []
|
var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = []; var pending_queues: Array[Dictionary] = []
|
||||||
for raw: Variant in data.get("objects", []):
|
for raw: Variant in data.get("objects", []):
|
||||||
if not raw is Dictionary: warnings.append("Malformed object"); continue
|
if not raw is Dictionary: warnings.append("Malformed object"); continue
|
||||||
var entry: Dictionary = raw; var file_id := String(entry.get("file_id", "")); var type := String(entry.get("type", ""))
|
var entry: Dictionary = raw; var file_id := String(entry.get("file_id", "")); var type := String(entry.get("type", ""))
|
||||||
@@ -1416,14 +1452,16 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
|
|||||||
if facing >= 0: (node as StickmanRig).set_facing_profile(facing)
|
if facing >= 0: (node as StickmanRig).set_facing_profile(facing)
|
||||||
var raw_queue: Variant = entry.get("action_queue", [])
|
var raw_queue: Variant = entry.get("action_queue", [])
|
||||||
if raw_queue is Array:
|
if raw_queue is Array:
|
||||||
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array)
|
pending_queues.append({"rig": node, "entries": raw_queue})
|
||||||
for warning: String in queue.get("warnings", []):
|
|
||||||
warnings.append(warning)
|
|
||||||
for action: Dictionary in queue["actions"]:
|
|
||||||
(node as StickmanRig).queue_action(action)
|
|
||||||
else:
|
else:
|
||||||
warnings.append("Invalid stickman action queue")
|
warnings.append("Invalid stickman action queue")
|
||||||
_finalize_placed_node(node); _save_object_state(node); file_to_runtime[file_id] = node.get_instance_id()
|
_finalize_placed_node(node); _save_object_state(node); file_to_runtime[file_id] = node.get_instance_id()
|
||||||
|
for pending: Dictionary in pending_queues:
|
||||||
|
var queued_rig: StickmanRig = pending.get("rig") as StickmanRig
|
||||||
|
if queued_rig == null or not is_instance_valid(queued_rig): continue
|
||||||
|
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(pending.get("entries", []) as Array, file_to_runtime, unavailable)
|
||||||
|
for warning: String in queue.get("warnings", []): warnings.append(warning)
|
||||||
|
for action: Dictionary in queue.get("actions", []): queued_rig.queue_action(action)
|
||||||
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
|
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
|
||||||
_event_rules.assign(decoded_rules["rules"]); _next_rule_id = 0
|
_event_rules.assign(decoded_rules["rules"]); _next_rule_id = 0
|
||||||
for rule: Dictionary in _event_rules: _next_rule_id = maxi(_next_rule_id, int(rule["id"]) + 1)
|
for rule: Dictionary in _event_rules: _next_rule_id = maxi(_next_rule_id, int(rule["id"]) + 1)
|
||||||
@@ -2030,6 +2068,9 @@ func _build_ui() -> void:
|
|||||||
_trigger_popup.add_item("💬 Finishes speaking", TRIG_SPEECH_FINISHED)
|
_trigger_popup.add_item("💬 Finishes speaking", TRIG_SPEECH_FINISHED)
|
||||||
_trigger_popup.add_item("🎯 Enters trigger area", TRIG_ENTERED_AREA)
|
_trigger_popup.add_item("🎯 Enters trigger area", TRIG_ENTERED_AREA)
|
||||||
_trigger_popup.add_item("💥 Collides with something", TRIG_COLLIDED)
|
_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_separator()
|
||||||
_trigger_popup.add_item("⬅ Back to actions", TRIG_BACK)
|
_trigger_popup.add_item("⬅ Back to actions", TRIG_BACK)
|
||||||
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
|
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
|
||||||
@@ -2209,6 +2250,7 @@ func _build_ui() -> void:
|
|||||||
_action_editor.committed.connect(_on_action_editor_committed)
|
_action_editor.committed.connect(_on_action_editor_committed)
|
||||||
_action_editor.cancelled.connect(_on_action_editor_cancelled)
|
_action_editor.cancelled.connect(_on_action_editor_cancelled)
|
||||||
_action_editor.target_requested.connect(_on_action_editor_target_requested)
|
_action_editor.target_requested.connect(_on_action_editor_target_requested)
|
||||||
|
_action_editor.prop_requested.connect(_on_action_editor_prop_requested)
|
||||||
ui.add_child(_action_editor)
|
ui.add_child(_action_editor)
|
||||||
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
|
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||||||
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
|
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
|
||||||
@@ -2871,10 +2913,16 @@ func _update_action_overlay() -> void:
|
|||||||
var awaiting := _is_awaiting_click()
|
var awaiting := _is_awaiting_click()
|
||||||
if _placement_overlay != null:
|
if _placement_overlay != null:
|
||||||
if awaiting:
|
if awaiting:
|
||||||
var origin := _action_origin()
|
var throw_capture: bool = _is_throw_capture_active()
|
||||||
|
var origin: Vector2 = _throw_release_point() if throw_capture else _action_origin()
|
||||||
var target := _camera.get_global_mouse_position()
|
var target := _camera.get_global_mouse_position()
|
||||||
if _snap_enabled and _rule_step == RuleStep.ACTION_POSITION:
|
if _snap_enabled and (_capture_kind == CaptureKind.POSITION or _rule_step == RuleStep.ACTION_POSITION):
|
||||||
target = _snap_to_grid(target)
|
target = _snap_to_grid(target)
|
||||||
|
if throw_capture:
|
||||||
|
var solution: Dictionary = _solve_throw_capture(target)
|
||||||
|
var arc: PackedVector2Array = _sample_throw_arc(origin, solution) if bool(solution.get("ok", false)) else PackedVector2Array([origin, target])
|
||||||
|
_placement_overlay.set_throw_trajectory(origin, target, arc, bool(solution.get("ok", false)))
|
||||||
|
else:
|
||||||
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
|
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
|
||||||
else:
|
else:
|
||||||
_placement_overlay.clear_action()
|
_placement_overlay.clear_action()
|
||||||
@@ -2999,7 +3047,7 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
|
|||||||
var trigger: Dictionary = _rule_builder.get("trigger", {})
|
var trigger: Dictionary = _rule_builder.get("trigger", {})
|
||||||
trigger["type"] = _trigger_type_string(id)
|
trigger["type"] = _trigger_type_string(id)
|
||||||
_rule_builder["trigger"] = trigger
|
_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
|
_rule_step = RuleStep.TRIGGER_TARGET
|
||||||
match id:
|
match id:
|
||||||
TRIG_ARRIVED:
|
TRIG_ARRIVED:
|
||||||
@@ -3008,6 +3056,12 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
|
|||||||
_rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)"
|
_rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)"
|
||||||
TRIG_COLLIDED:
|
TRIG_COLLIDED:
|
||||||
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
|
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
|
||||||
|
TRIG_PROP_PICKED_UP:
|
||||||
|
_rule_hint = "Click the prop to watch being picked up (Esc or RMB to cancel)"
|
||||||
|
TRIG_PROP_DROPPED:
|
||||||
|
_rule_hint = "Click the prop to watch being dropped (Esc or RMB to cancel)"
|
||||||
|
TRIG_PROP_THROWN:
|
||||||
|
_rule_hint = "Click the prop to watch being thrown (Esc or RMB to cancel)"
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
else:
|
else:
|
||||||
_rule_step = RuleStep.SELECT_ACTION
|
_rule_step = RuleStep.SELECT_ACTION
|
||||||
@@ -3048,6 +3102,12 @@ func _trigger_type_string(id: int) -> String:
|
|||||||
return "entered_area"
|
return "entered_area"
|
||||||
TRIG_COLLIDED:
|
TRIG_COLLIDED:
|
||||||
return "collided"
|
return "collided"
|
||||||
|
TRIG_PROP_PICKED_UP:
|
||||||
|
return "prop_picked_up"
|
||||||
|
TRIG_PROP_DROPPED:
|
||||||
|
return "prop_dropped"
|
||||||
|
TRIG_PROP_THROWN:
|
||||||
|
return "prop_thrown"
|
||||||
_:
|
_:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -3118,7 +3178,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
|
|||||||
else:
|
else:
|
||||||
_rule_hint = "Click a trigger area (Esc or RMB to cancel)"
|
_rule_hint = "Click a trigger area (Esc or RMB to cancel)"
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
"collided":
|
"collided", "prop_picked_up", "prop_dropped", "prop_thrown":
|
||||||
var hit2 := _selection.hit_test(world_pos)
|
var hit2 := _selection.hit_test(world_pos)
|
||||||
if hit2 is PropBlock:
|
if hit2 is PropBlock:
|
||||||
trigger["target"] = hit2.get_instance_id()
|
trigger["target"] = hit2.get_instance_id()
|
||||||
@@ -3126,7 +3186,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
|
|||||||
_rule_step = RuleStep.SELECT_ACTION
|
_rule_step = RuleStep.SELECT_ACTION
|
||||||
_open_rule_action_popup()
|
_open_rule_action_popup()
|
||||||
else:
|
else:
|
||||||
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
|
_rule_hint = "Click the prop to use for this trigger (Esc or RMB to cancel)"
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
|
|
||||||
|
|
||||||
@@ -3296,6 +3356,11 @@ func _on_rig_arrived(target: Vector2, rig: StickmanRig) -> void:
|
|||||||
func _on_rig_action_finished(action: Dictionary, _index: int, rig: StickmanRig) -> void:
|
func _on_rig_action_finished(action: Dictionary, _index: int, rig: StickmanRig) -> void:
|
||||||
_handle_event({ "type": "action_finished", "source": rig, "target": null, "position": rig.global_position, "action": action })
|
_handle_event({ "type": "action_finished", "source": rig, "target": null, "position": rig.global_position, "action": action })
|
||||||
|
|
||||||
|
func _on_rig_prop_transition(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName) -> void:
|
||||||
|
if rig == null or prop == null or not is_instance_valid(rig) or not is_instance_valid(prop):
|
||||||
|
return
|
||||||
|
_handle_event({"type": String(action_type), "source": rig, "target": prop, "position": prop.global_position, "action": {"type": String(action_type), "reason": String(reason)}})
|
||||||
|
|
||||||
|
|
||||||
func _on_rig_speech_finished(rig: StickmanRig) -> void:
|
func _on_rig_speech_finished(rig: StickmanRig) -> void:
|
||||||
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
|
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
|
||||||
@@ -3410,6 +3475,8 @@ func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
|
|||||||
return _ids_match(source_id, source) and _ids_match(target_id, target)
|
return _ids_match(source_id, source) and _ids_match(target_id, target)
|
||||||
"collided":
|
"collided":
|
||||||
return _ids_match(source_id, source) and _ids_match(target_id, target)
|
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
|
return false
|
||||||
|
|
||||||
@@ -3624,6 +3691,8 @@ func _end_capture() -> void:
|
|||||||
_capture_hint = ""
|
_capture_hint = ""
|
||||||
_capture_callback = Callable()
|
_capture_callback = Callable()
|
||||||
_capture_cancel = Callable()
|
_capture_cancel = Callable()
|
||||||
|
_throw_capture_prop_id = -1
|
||||||
|
_throw_capture_flight_time = 0.9
|
||||||
_apply_cursor()
|
_apply_cursor()
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
|
|
||||||
@@ -3659,6 +3728,10 @@ func _resolve_capture(world_pos: Vector2) -> void:
|
|||||||
if value == null:
|
if value == null:
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
return
|
return
|
||||||
|
if _capture_kind == CaptureKind.POSITION and _is_throw_capture_active() and not bool(_solve_throw_capture(value as Vector2).get("ok", false)):
|
||||||
|
_show_toast("That throw is too far for this flight time.")
|
||||||
|
_refresh_status()
|
||||||
|
return
|
||||||
var cb := _capture_callback
|
var cb := _capture_callback
|
||||||
_end_capture()
|
_end_capture()
|
||||||
if cb.is_valid():
|
if cb.is_valid():
|
||||||
@@ -4061,18 +4134,80 @@ func _on_action_editor_cancelled() -> void:
|
|||||||
|
|
||||||
func _on_action_editor_target_requested() -> void:
|
func _on_action_editor_target_requested() -> void:
|
||||||
_action_editor.hide()
|
_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 is_throw: bool = _action_editor != null and _action_editor.get_action_type() == "throw_prop"
|
||||||
|
if is_throw:
|
||||||
|
_throw_capture_prop_id = _action_editor.get_target_prop_id()
|
||||||
|
_throw_capture_flight_time = _action_editor.get_flight_time()
|
||||||
|
_begin_capture(CaptureKind.POSITION, "🎯 Click where the prop should land (Esc or RMB to cancel)" if is_throw else "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_throw_target if is_throw else _cb_editor_walk_target, _cb_editor_walk_target_cancel)
|
||||||
|
|
||||||
|
|
||||||
func _cb_editor_walk_target(value: Vector2) -> void:
|
func _cb_editor_walk_target(value: Vector2) -> void:
|
||||||
if _action_editor != null:
|
if _action_editor != null:
|
||||||
_action_editor.set_walk_target(value)
|
_action_editor.set_walk_target(value)
|
||||||
|
|
||||||
|
func _cb_editor_throw_target(value: Vector2) -> void:
|
||||||
|
if _action_editor != null:
|
||||||
|
_action_editor.set_throw_target(value)
|
||||||
|
|
||||||
|
func _on_action_editor_prop_requested() -> void:
|
||||||
|
_action_editor.hide()
|
||||||
|
_begin_capture(CaptureKind.PROP, "🧱 Click the prop to use (Esc or RMB to cancel)", _cb_editor_prop_target, _cb_editor_walk_target_cancel)
|
||||||
|
|
||||||
|
func _cb_editor_prop_target(value: PropBlock) -> void:
|
||||||
|
if _action_editor != null:
|
||||||
|
_action_editor.set_prop_target(value, _prop_action_target_label(value))
|
||||||
|
|
||||||
|
func _prop_action_target_label(prop: PropBlock) -> String:
|
||||||
|
if prop == null or not is_instance_valid(prop):
|
||||||
|
return ""
|
||||||
|
var entry: Dictionary = PROP_LIBRARY.get_entry(prop.spawn_id)
|
||||||
|
var label: String = String(entry.get("name", ""))
|
||||||
|
if label.is_empty():
|
||||||
|
label = prop.spawn_id.get_file().get_basename() if prop.spawn_id.contains(".prop") else prop.spawn_id.capitalize()
|
||||||
|
if label.is_empty():
|
||||||
|
label = "Prop"
|
||||||
|
var ordinal: int = 0
|
||||||
|
for node: Node in _world.get_children():
|
||||||
|
if node is PropBlock and (node as PropBlock).spawn_id == prop.spawn_id:
|
||||||
|
ordinal += 1
|
||||||
|
if node == prop:
|
||||||
|
break
|
||||||
|
return "%s #%d" % [label, maxi(1, ordinal)]
|
||||||
|
|
||||||
|
|
||||||
func _cb_editor_walk_target_cancel() -> void:
|
func _cb_editor_walk_target_cancel() -> void:
|
||||||
if _action_editor != null:
|
if _action_editor != null:
|
||||||
_action_editor.popup_centered()
|
_action_editor.popup_centered()
|
||||||
|
|
||||||
|
func _is_throw_capture_active() -> bool:
|
||||||
|
return _capture_kind == CaptureKind.POSITION and _throw_capture_prop_id >= 0
|
||||||
|
|
||||||
|
func _throw_release_point() -> Vector2:
|
||||||
|
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
|
||||||
|
var marker: Marker2D = _action_editor_rig._get_ik_marker("Right_Hand")
|
||||||
|
if marker != null and is_instance_valid(marker): return marker.global_position
|
||||||
|
return _action_editor_rig.global_position
|
||||||
|
return Vector2.ZERO
|
||||||
|
|
||||||
|
func _solve_throw_capture(target: Vector2) -> Dictionary:
|
||||||
|
var prop: PropBlock = instance_from_id(_throw_capture_prop_id) as PropBlock
|
||||||
|
if prop == null or not is_instance_valid(prop): return {"ok": false, "reason": "missing_prop"}
|
||||||
|
return PROP_TRAJECTORY.solve_throw_velocity(_throw_release_point(), target, _throw_capture_flight_time, StickmanPropInteraction.gravity_for(prop))
|
||||||
|
|
||||||
|
func _sample_throw_arc(origin: Vector2, solution: Dictionary) -> PackedVector2Array:
|
||||||
|
var points := PackedVector2Array()
|
||||||
|
if not bool(solution.get("ok", false)): return points
|
||||||
|
var prop: PropBlock = instance_from_id(_throw_capture_prop_id) as PropBlock
|
||||||
|
if prop == null or not is_instance_valid(prop): return points
|
||||||
|
var velocity: Vector2 = solution.get("velocity", Vector2.ZERO)
|
||||||
|
var duration: float = float(solution.get("flight_time", 0.0))
|
||||||
|
var gravity: Vector2 = StickmanPropInteraction.gravity_for(prop)
|
||||||
|
const SEGMENTS: int = 24
|
||||||
|
for index: int in range(SEGMENTS + 1):
|
||||||
|
var time: float = duration * float(index) / float(SEGMENTS)
|
||||||
|
points.append(origin + velocity * time + gravity * 0.5 * time * time)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
func _restore_queue_panel() -> void:
|
func _restore_queue_panel() -> void:
|
||||||
if _action_editor_restore_queue and _queue_panel != null:
|
if _action_editor_restore_queue and _queue_panel != null:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ var action_visible: bool = false
|
|||||||
var action_origin: Vector2 = Vector2.ZERO
|
var action_origin: Vector2 = Vector2.ZERO
|
||||||
var action_target: Vector2 = Vector2.ZERO
|
var action_target: Vector2 = Vector2.ZERO
|
||||||
var action_valid: bool = true
|
var action_valid: bool = true
|
||||||
|
var action_arc: PackedVector2Array = PackedVector2Array()
|
||||||
|
var action_is_throw: bool = false
|
||||||
var valid_color: Color = Color(0.3, 0.9, 0.4)
|
var valid_color: Color = Color(0.3, 0.9, 0.4)
|
||||||
var invalid_color: Color = Color(1.0, 0.3, 0.3)
|
var invalid_color: Color = Color(1.0, 0.3, 0.3)
|
||||||
var line_width_px: float = 2.0
|
var line_width_px: float = 2.0
|
||||||
@@ -63,10 +65,24 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi
|
|||||||
action_origin = origin
|
action_origin = origin
|
||||||
action_target = target
|
action_target = target
|
||||||
action_valid = valid
|
action_valid = valid
|
||||||
|
action_arc = PackedVector2Array()
|
||||||
|
action_is_throw = false
|
||||||
|
|
||||||
|
## Displays a sampled ballistic arc. `points` must come from the same solver
|
||||||
|
## that validates the eventual physics release.
|
||||||
|
func set_throw_trajectory(origin: Vector2, target: Vector2, points: PackedVector2Array, valid: bool) -> void:
|
||||||
|
action_visible = true
|
||||||
|
action_origin = origin
|
||||||
|
action_target = target
|
||||||
|
action_valid = valid
|
||||||
|
action_arc = points.duplicate()
|
||||||
|
action_is_throw = true
|
||||||
|
|
||||||
|
|
||||||
func clear_action() -> void:
|
func clear_action() -> void:
|
||||||
action_visible = false
|
action_visible = false
|
||||||
|
action_arc = PackedVector2Array()
|
||||||
|
action_is_throw = false
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
@@ -77,8 +93,24 @@ func _draw() -> void:
|
|||||||
_draw_cell_marker(terrain_target, guide_line_color)
|
_draw_cell_marker(terrain_target, guide_line_color)
|
||||||
if action_visible:
|
if action_visible:
|
||||||
var color := valid_color if action_valid else invalid_color
|
var color := valid_color if action_valid else invalid_color
|
||||||
|
if action_is_throw and action_arc.size() >= 2:
|
||||||
|
_draw_arc_path(action_arc, color)
|
||||||
|
else:
|
||||||
_draw_dashed(action_origin, action_target, color)
|
_draw_dashed(action_origin, action_target, color)
|
||||||
_draw_ghost_marker(action_target, color)
|
_draw_ghost_marker(action_target, color)
|
||||||
|
if not action_valid:
|
||||||
|
_draw_invalid_marker(action_target, color)
|
||||||
|
|
||||||
|
func _draw_arc_path(points: PackedVector2Array, color: Color) -> void:
|
||||||
|
var zoom: float = _zoom()
|
||||||
|
for index: int in range(points.size() - 1):
|
||||||
|
draw_line(points[index], points[index + 1], color, line_width_px / zoom, true)
|
||||||
|
|
||||||
|
func _draw_invalid_marker(pos: Vector2, color: Color) -> void:
|
||||||
|
var zoom: float = _zoom()
|
||||||
|
var radius: float = MARKER_RADIUS_PX * 1.35 / zoom
|
||||||
|
draw_line(pos - Vector2(radius, radius), pos + Vector2(radius, radius), color, LINE_WIDTH_PX * 1.5 / zoom, true)
|
||||||
|
draw_line(pos + Vector2(radius, -radius), pos + Vector2(-radius, radius), color, LINE_WIDTH_PX * 1.5 / zoom, true)
|
||||||
|
|
||||||
|
|
||||||
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
|
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
|
||||||
|
|||||||
@@ -22,25 +22,25 @@ static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2:
|
|||||||
var data: Dictionary = value
|
var data: Dictionary = value
|
||||||
return Vector2(float(data["x"]), float(data["y"]))
|
return Vector2(float(data["x"]), float(data["y"]))
|
||||||
|
|
||||||
static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
|
static func encode_queue(actions: Array[Dictionary], runtime_to_file: Dictionary = {}) -> Dictionary:
|
||||||
var out: Array[Dictionary] = []
|
var out: Array[Dictionary] = []
|
||||||
var warnings: Array[String] = []
|
var warnings: Array[String] = []
|
||||||
for action: Dictionary in actions:
|
for action: Dictionary in actions:
|
||||||
var encoded := _encode_flat_action(action)
|
var encoded := _encode_flat_action(action, runtime_to_file)
|
||||||
if encoded.is_empty():
|
if encoded.is_empty():
|
||||||
warnings.append("Skipped invalid queue action.")
|
warnings.append("Skipped invalid queue action.")
|
||||||
else:
|
else:
|
||||||
out.append(encoded)
|
out.append(encoded)
|
||||||
return {"actions": out, "warnings": warnings}
|
return {"actions": out, "warnings": warnings}
|
||||||
|
|
||||||
static func decode_queue(entries: Array) -> Dictionary:
|
static func decode_queue(entries: Array, file_to_runtime: Dictionary = {}, unavailable_ids: Dictionary = {}) -> Dictionary:
|
||||||
var out: Array[Dictionary] = []
|
var out: Array[Dictionary] = []
|
||||||
var warnings: Array[String] = []
|
var warnings: Array[String] = []
|
||||||
for entry: Variant in entries:
|
for entry: Variant in entries:
|
||||||
if not entry is Dictionary:
|
if not entry is Dictionary:
|
||||||
warnings.append("Skipped malformed queue action.")
|
warnings.append("Skipped malformed queue action.")
|
||||||
continue
|
continue
|
||||||
var decoded := _decode_flat_action(entry as Dictionary)
|
var decoded := _decode_flat_action(entry as Dictionary, file_to_runtime, unavailable_ids)
|
||||||
if decoded.is_empty():
|
if decoded.is_empty():
|
||||||
warnings.append("Skipped invalid queue action.")
|
warnings.append("Skipped invalid queue action.")
|
||||||
else:
|
else:
|
||||||
@@ -171,7 +171,7 @@ static func _recover_backup(path: String) -> void:
|
|||||||
static func _finite_number(value: Variant) -> bool:
|
static func _finite_number(value: Variant) -> bool:
|
||||||
return (value is float or value is int) and is_finite(float(value))
|
return (value is float or value is int) and is_finite(float(value))
|
||||||
|
|
||||||
static func _encode_flat_action(action: Dictionary) -> Dictionary:
|
static func _encode_flat_action(action: Dictionary, refs: Dictionary = {}) -> Dictionary:
|
||||||
var type := String(action.get("type", ""))
|
var type := String(action.get("type", ""))
|
||||||
if not ACTION_REGISTRY.has_type(type): return {}
|
if not ACTION_REGISTRY.has_type(type): return {}
|
||||||
match type:
|
match type:
|
||||||
@@ -180,9 +180,20 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
|
|||||||
return {"type": type, "target": vector_to_data(action["target"])}
|
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 {}
|
"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 {}
|
"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: Variant = _map_ref(action.get("target_prop", -1), refs)
|
||||||
|
var hand: String = String(action.get("hand", "auto"))
|
||||||
|
return {"type": type, "target_prop_id": prop_id, "hand": hand} if prop_id != null and ["auto", "left", "right"].has(hand) else {}
|
||||||
|
"drop_prop": return {"type": type}
|
||||||
|
"throw_prop":
|
||||||
|
var throw_id: Variant = _map_ref(action.get("target_prop", -1), refs)
|
||||||
|
var target: Variant = action.get("target_position")
|
||||||
|
var time: float = float(action.get("flight_time", 0.0))
|
||||||
|
if throw_id == null or not target is Vector2 or not target.is_finite() or not _finite_number(time): return {}
|
||||||
|
return {"type": type, "target_prop_id": throw_id, "target_position": vector_to_data(target as Vector2), "flight_time": time}
|
||||||
_: return {"type": type}
|
_: return {"type": type}
|
||||||
|
|
||||||
static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
static func _decode_flat_action(action: Dictionary, refs: Dictionary = {}, unavailable: Dictionary = {}) -> Dictionary:
|
||||||
var type := String(action.get("type", ""))
|
var type := String(action.get("type", ""))
|
||||||
if not ACTION_REGISTRY.has_type(type): return {}
|
if not ACTION_REGISTRY.has_type(type): return {}
|
||||||
match type:
|
match type:
|
||||||
@@ -195,6 +206,17 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
|||||||
if bool(action.get("stop_current", false)): out["stop_current"] = true
|
if bool(action.get("stop_current", false)): out["stop_current"] = true
|
||||||
return out
|
return out
|
||||||
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.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 pickup_id: int = _unmap_ref(action.get("target_prop_id"), refs, unavailable)
|
||||||
|
var hand: String = String(action.get("hand", "auto"))
|
||||||
|
return {"type": type, "target_prop": pickup_id, "hand": hand} if pickup_id >= 0 and ["auto", "left", "right"].has(hand) else {}
|
||||||
|
"drop_prop": return {"type": type}
|
||||||
|
"throw_prop":
|
||||||
|
var throw_id: int = _unmap_ref(action.get("target_prop_id"), refs, unavailable)
|
||||||
|
var time: float = float(action.get("flight_time", 0.0))
|
||||||
|
if throw_id < 0 or not is_vector_data(action.get("target_position")) or not _finite_number(time): return {}
|
||||||
|
if time < 0.35 or time > 1.5: return {}
|
||||||
|
return {"type": type, "target_prop": throw_id, "target_position": data_to_vector(action["target_position"], Vector2.ZERO), "flight_time": time}
|
||||||
_: return {"type": type}
|
_: return {"type": type}
|
||||||
|
|
||||||
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
|
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
|
||||||
@@ -229,12 +251,12 @@ static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Di
|
|||||||
|
|
||||||
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
|
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
|
||||||
var id: Variant = _map_ref(action.get("target", -1), refs); if id == null and int(action.get("target", -1)) != -1: return {}
|
var id: Variant = _map_ref(action.get("target", -1), refs); if id == null and int(action.get("target", -1)) != -1: return {}
|
||||||
var flat := _encode_flat_action(ACTION_REGISTRY.from_rule_action(action)); if flat.is_empty(): return {}
|
var flat := _encode_flat_action(ACTION_REGISTRY.from_rule_action(action), refs); if flat.is_empty(): return {}
|
||||||
var params := flat.duplicate(); params.erase("type")
|
var params := flat.duplicate(); params.erase("type")
|
||||||
return {"type": String(flat["type"]), "target_id": id, "params": params}
|
return {"type": String(flat["type"]), "target_id": id, "params": params}
|
||||||
|
|
||||||
static func _decode_rule_action(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
|
static func _decode_rule_action(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
|
||||||
var target := _unmap_ref(value.get("target_id"), refs, unavailable); if target == -2: return {}
|
var target := _unmap_ref(value.get("target_id"), refs, unavailable); if target == -2: return {}
|
||||||
var flat: Dictionary = value.get("params", {}).duplicate() if value.get("params") is Dictionary else {}
|
var flat: Dictionary = value.get("params", {}).duplicate() if value.get("params") is Dictionary else {}
|
||||||
flat["type"] = String(value.get("type", "")); var decoded := _decode_flat_action(flat); if decoded.is_empty(): return {}
|
flat["type"] = String(value.get("type", "")); var decoded := _decode_flat_action(flat, refs, unavailable); if decoded.is_empty(): return {}
|
||||||
return ACTION_REGISTRY.to_rule_action(decoded, target)
|
return ACTION_REGISTRY.to_rule_action(decoded, target)
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock:
|
|||||||
else:
|
else:
|
||||||
prop = PROP_UTILS.spawn_prop(_world, object_position, template["payload"], int(template["material_preset"]), Vector2.ZERO)
|
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.spawn_id = String(template.get("id", asset_id))
|
||||||
|
prop.anchors = template.get("anchors", {}).duplicate(true)
|
||||||
|
prop.can_be_picked_up = bool(template.get("can_be_picked_up", true))
|
||||||
return prop
|
return prop
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
|
|||||||
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
|
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
|
||||||
|
|
||||||
enum RunnerState { IDLE, EXECUTING }
|
enum RunnerState { IDLE, EXECUTING }
|
||||||
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }
|
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP }
|
||||||
|
|
||||||
signal action_started(action: Dictionary, index: int)
|
signal action_started(action: Dictionary, index: int)
|
||||||
signal action_finished(action: Dictionary, index: int)
|
signal action_finished(action: Dictionary, index: int)
|
||||||
@@ -22,6 +22,7 @@ signal recovery_requested
|
|||||||
var action_queue: Array[Dictionary] = []
|
var action_queue: Array[Dictionary] = []
|
||||||
var _rig: StickmanRig = null
|
var _rig: StickmanRig = null
|
||||||
var _locomotion: StickmanLocomotion = null
|
var _locomotion: StickmanLocomotion = null
|
||||||
|
var _prop_interaction: StickmanPropInteraction = null
|
||||||
var _runner_state: RunnerState = RunnerState.IDLE
|
var _runner_state: RunnerState = RunnerState.IDLE
|
||||||
var _action_phase: ActionPhase = ActionPhase.NONE
|
var _action_phase: ActionPhase = ActionPhase.NONE
|
||||||
var _current_index: int = -1
|
var _current_index: int = -1
|
||||||
@@ -34,9 +35,12 @@ var _overlay_speech: Dictionary = {}
|
|||||||
var _speech_bubble: Node2D = null
|
var _speech_bubble: Node2D = null
|
||||||
|
|
||||||
|
|
||||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
func configure(rig: StickmanRig, locomotion: StickmanLocomotion, prop_interaction: StickmanPropInteraction = null) -> void:
|
||||||
_rig = rig
|
_rig = rig
|
||||||
_locomotion = locomotion
|
_locomotion = locomotion
|
||||||
|
_prop_interaction = prop_interaction
|
||||||
|
if _prop_interaction != null and not _prop_interaction.interaction_finished.is_connected(_on_interaction_finished):
|
||||||
|
_prop_interaction.interaction_finished.connect(_on_interaction_finished)
|
||||||
|
|
||||||
|
|
||||||
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
|
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
|
||||||
@@ -62,6 +66,9 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
|
|||||||
ActionPhase.RECOVERING:
|
ActionPhase.RECOVERING:
|
||||||
if rig_state == StickmanRig.RigState.ANIMATED:
|
if rig_state == StickmanRig.RigState.ANIMATED:
|
||||||
_finish_action()
|
_finish_action()
|
||||||
|
ActionPhase.PROP:
|
||||||
|
if _prop_interaction != null:
|
||||||
|
_prop_interaction.tick(delta)
|
||||||
|
|
||||||
|
|
||||||
func speak(text: String, duration: float) -> void:
|
func speak(text: String, duration: float) -> void:
|
||||||
@@ -241,6 +248,10 @@ func _begin_action(action: Dictionary) -> void:
|
|||||||
"recover":
|
"recover":
|
||||||
_action_phase = ActionPhase.RECOVERING
|
_action_phase = ActionPhase.RECOVERING
|
||||||
recovery_requested.emit()
|
recovery_requested.emit()
|
||||||
|
"pick_up_prop", "drop_prop", "throw_prop":
|
||||||
|
_action_phase = ActionPhase.PROP
|
||||||
|
if _prop_interaction == null or not _prop_interaction.begin_action(action):
|
||||||
|
_finish_action()
|
||||||
_:
|
_:
|
||||||
push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", "")))
|
push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", "")))
|
||||||
_finish_action()
|
_finish_action()
|
||||||
@@ -257,6 +268,8 @@ func _finish_action() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _cancel_current_primary() -> void:
|
func _cancel_current_primary() -> void:
|
||||||
|
if _prop_interaction != null:
|
||||||
|
_prop_interaction.cancel()
|
||||||
if _locomotion != null:
|
if _locomotion != null:
|
||||||
_locomotion.cancel_walk(not _is_blocked())
|
_locomotion.cancel_walk(not _is_blocked())
|
||||||
_primary_speech.clear()
|
_primary_speech.clear()
|
||||||
@@ -267,6 +280,10 @@ func _cancel_current_primary() -> void:
|
|||||||
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
|
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||||
_speech_bubble.hide_bubble()
|
_speech_bubble.hide_bubble()
|
||||||
|
|
||||||
|
func _on_interaction_finished(_succeeded: bool) -> void:
|
||||||
|
if _action_phase == ActionPhase.PROP:
|
||||||
|
_finish_action()
|
||||||
|
|
||||||
|
|
||||||
func _is_blocked() -> bool:
|
func _is_blocked() -> bool:
|
||||||
return _locomotion != null and _locomotion.is_action_blocked()
|
return _locomotion != null and _locomotion.is_action_blocked()
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
class_name StickmanPropInteraction
|
||||||
|
extends Node
|
||||||
|
## Owns the one-prop hand interaction lifecycle for a StickmanRig.
|
||||||
|
|
||||||
|
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||||
|
const PROP_ANCHOR_DATA := preload("res://scripts/prop_anchor_data.gd")
|
||||||
|
const PROP_TRAJECTORY := preload("res://scripts/prop_trajectory.gd")
|
||||||
|
|
||||||
|
const REACH_DURATION: float = 0.18
|
||||||
|
const ARM_REACH: float = 360.0
|
||||||
|
const RELEASE_SPIN: float = 1.5
|
||||||
|
enum State { IDLE, APPROACHING, REACHING, HOLDING, WINDUP }
|
||||||
|
|
||||||
|
signal interaction_finished(succeeded: bool)
|
||||||
|
signal prop_transition(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName)
|
||||||
|
|
||||||
|
var _rig: StickmanRig = null
|
||||||
|
var _locomotion: StickmanLocomotion = null
|
||||||
|
var _state: State = State.IDLE
|
||||||
|
var _held_prop: PropBlock = null
|
||||||
|
var _reserved_prop: PropBlock = null
|
||||||
|
var _hand: StringName = &"right"
|
||||||
|
var _grip: Dictionary = {}
|
||||||
|
var _saved_hand_target: Vector2 = Vector2.ZERO
|
||||||
|
var _timer: float = 0.0
|
||||||
|
var _pending_action: Dictionary = {}
|
||||||
|
var _saved_physics: Dictionary = {}
|
||||||
|
var _last_rig_rotation: float = 0.0
|
||||||
|
var _angular_snapshot: float = 0.0
|
||||||
|
|
||||||
|
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
||||||
|
_rig = rig
|
||||||
|
_locomotion = locomotion
|
||||||
|
|
||||||
|
func has_held_prop() -> bool:
|
||||||
|
return _held_prop != null and is_instance_valid(_held_prop)
|
||||||
|
|
||||||
|
func get_held_prop() -> PropBlock:
|
||||||
|
return _held_prop if has_held_prop() else null
|
||||||
|
|
||||||
|
func begin_action(action: Dictionary) -> bool:
|
||||||
|
var type: String = String(action.get("type", ""))
|
||||||
|
match type:
|
||||||
|
"pick_up_prop":
|
||||||
|
return _begin_pickup(action)
|
||||||
|
"drop_prop":
|
||||||
|
if not has_held_prop():
|
||||||
|
return false
|
||||||
|
release_held(&"drop", _rig_velocity(), 0.0, &"prop_dropped")
|
||||||
|
interaction_finished.emit(true)
|
||||||
|
return true
|
||||||
|
"throw_prop":
|
||||||
|
return _begin_throw(action)
|
||||||
|
return false
|
||||||
|
|
||||||
|
func tick(delta: float) -> void:
|
||||||
|
if has_held_prop():
|
||||||
|
_update_attachment()
|
||||||
|
if _state == State.APPROACHING:
|
||||||
|
if _locomotion != null and _locomotion.consume_walk_completed():
|
||||||
|
_state = State.REACHING
|
||||||
|
_timer = REACH_DURATION
|
||||||
|
return
|
||||||
|
if _state != State.REACHING and _state != State.WINDUP:
|
||||||
|
return
|
||||||
|
_timer -= delta
|
||||||
|
if _state == State.REACHING:
|
||||||
|
_update_reach_target()
|
||||||
|
if _timer <= 0.0:
|
||||||
|
_complete_pickup()
|
||||||
|
elif _timer <= 0.0:
|
||||||
|
_complete_throw()
|
||||||
|
|
||||||
|
func update_held_attachment(delta: float) -> void:
|
||||||
|
if _rig != null and is_instance_valid(_rig) and delta > 0.0:
|
||||||
|
_angular_snapshot = wrapf(_rig.global_rotation - _last_rig_rotation, -PI, PI) / delta
|
||||||
|
_last_rig_rotation = _rig.global_rotation
|
||||||
|
if has_held_prop(): _update_attachment()
|
||||||
|
|
||||||
|
func cancel() -> void:
|
||||||
|
if _state == State.APPROACHING or _state == State.REACHING or _state == State.WINDUP:
|
||||||
|
_restore_hand_target()
|
||||||
|
if _reserved_prop != null and is_instance_valid(_reserved_prop):
|
||||||
|
_reserved_prop.held_by_instance_id = 0
|
||||||
|
_reserved_prop = null
|
||||||
|
_pending_action.clear()
|
||||||
|
_state = State.IDLE
|
||||||
|
|
||||||
|
func release_for_ragdoll() -> void:
|
||||||
|
if has_held_prop():
|
||||||
|
release_held(&"ragdoll", _rig_velocity(), _angular_snapshot if is_finite(_angular_snapshot) else 0.0, &"prop_dropped")
|
||||||
|
|
||||||
|
func release_for_lifecycle(reason: StringName = &"lifecycle") -> void:
|
||||||
|
if has_held_prop():
|
||||||
|
release_held(reason, _rig_velocity(), 0.0, &"prop_dropped")
|
||||||
|
else:
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
func release_held(reason: StringName, linear_velocity: Vector2, angular_velocity: float, event_type: StringName) -> bool:
|
||||||
|
if not has_held_prop():
|
||||||
|
return false
|
||||||
|
var prop: PropBlock = _held_prop
|
||||||
|
_update_attachment()
|
||||||
|
prop.freeze = bool(_saved_physics.get("freeze", false))
|
||||||
|
prop.freeze_mode = int(_saved_physics.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
|
||||||
|
prop.collision_layer = int(_saved_physics.get("collision_layer", prop.collision_layer))
|
||||||
|
prop.collision_mask = int(_saved_physics.get("collision_mask", prop.collision_mask))
|
||||||
|
prop.z_index = int(_saved_physics.get("z_index", prop.z_index))
|
||||||
|
prop.gravity_scale = float(_saved_physics.get("gravity_scale", prop.gravity_scale))
|
||||||
|
prop.linear_damp = float(_saved_physics.get("linear_damp", prop.linear_damp))
|
||||||
|
prop.angular_damp = float(_saved_physics.get("angular_damp", prop.angular_damp))
|
||||||
|
prop.linear_velocity = linear_velocity if linear_velocity.is_finite() else Vector2.ZERO
|
||||||
|
prop.angular_velocity = angular_velocity if is_finite(angular_velocity) else 0.0
|
||||||
|
prop.held_by_instance_id = 0
|
||||||
|
_held_prop = null
|
||||||
|
_reserved_prop = null
|
||||||
|
_saved_physics.clear()
|
||||||
|
_restore_hand_target()
|
||||||
|
_state = State.IDLE
|
||||||
|
prop_transition.emit(event_type, _rig, prop, reason)
|
||||||
|
return true
|
||||||
|
|
||||||
|
static func gravity_for(prop: PropBlock) -> Vector2:
|
||||||
|
var direction: Vector2 = ProjectSettings.get_setting("physics/2d/default_gravity_vector", Vector2.DOWN)
|
||||||
|
var magnitude: float = float(ProjectSettings.get_setting("physics/2d/default_gravity", 980.0))
|
||||||
|
return direction * magnitude * prop.gravity_scale
|
||||||
|
|
||||||
|
func _begin_pickup(action: Dictionary) -> bool:
|
||||||
|
if has_held_prop() or _state != State.IDLE:
|
||||||
|
return false
|
||||||
|
var prop: PropBlock = instance_from_id(int(action.get("target_prop", 0))) as PropBlock
|
||||||
|
if prop == null or not is_instance_valid(prop) or not prop.can_be_picked_up or prop.held_by_instance_id != 0:
|
||||||
|
return false
|
||||||
|
var choice: Dictionary = _choose_grip(prop, String(action.get("hand", "auto")))
|
||||||
|
if choice.is_empty():
|
||||||
|
return false
|
||||||
|
_reserved_prop = prop
|
||||||
|
_reserved_prop.held_by_instance_id = _rig.get_instance_id()
|
||||||
|
_hand = StringName(choice["hand"])
|
||||||
|
_grip = choice["grip"]
|
||||||
|
_saved_hand_target = _hand_marker().global_position
|
||||||
|
_last_rig_rotation = _rig.global_rotation
|
||||||
|
_pending_action = action.duplicate(true)
|
||||||
|
var grip_world: Vector2 = _grip_world_point(prop, _grip)
|
||||||
|
if _can_reach(grip_world):
|
||||||
|
_state = State.REACHING
|
||||||
|
_timer = REACH_DURATION
|
||||||
|
else:
|
||||||
|
var bounds: Rect2 = prop.get_visual_local_bounds()
|
||||||
|
var left: float = prop.to_global(Vector2(bounds.position.x, bounds.get_center().y)).x
|
||||||
|
var right: float = prop.to_global(Vector2(bounds.end.x, bounds.get_center().y)).x
|
||||||
|
var side: float = -1.0 if _rig.global_position.x <= grip_world.x else 1.0
|
||||||
|
var feet: Vector2 = _rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||||
|
var stand_point: Vector2 = Vector2(left - ARM_REACH * 0.65 if side < 0.0 else right + ARM_REACH * 0.65, feet.y)
|
||||||
|
if _locomotion == null or not _locomotion.walk_to(stand_point):
|
||||||
|
cancel()
|
||||||
|
return false
|
||||||
|
_state = State.APPROACHING
|
||||||
|
return true
|
||||||
|
|
||||||
|
func _complete_pickup() -> void:
|
||||||
|
if _reserved_prop == null or not is_instance_valid(_reserved_prop) or not _can_reach(_grip_world_point(_reserved_prop, _grip)):
|
||||||
|
cancel()
|
||||||
|
interaction_finished.emit(false)
|
||||||
|
return
|
||||||
|
_held_prop = _reserved_prop
|
||||||
|
_saved_physics = {"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, "gravity_scale": _held_prop.gravity_scale, "linear_damp": _held_prop.linear_damp, "angular_damp": _held_prop.angular_damp}
|
||||||
|
_held_prop.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||||
|
_held_prop.freeze = true
|
||||||
|
_held_prop.collision_layer = 0
|
||||||
|
_held_prop.collision_mask = 0
|
||||||
|
_held_prop.linear_velocity = Vector2.ZERO
|
||||||
|
_held_prop.angular_velocity = 0.0
|
||||||
|
_held_prop.z_index += int(_grip.get("z_index_offset", 0))
|
||||||
|
_state = State.HOLDING
|
||||||
|
_update_attachment()
|
||||||
|
prop_transition.emit(&"prop_picked_up", _rig, _held_prop, &"pickup")
|
||||||
|
interaction_finished.emit(true)
|
||||||
|
|
||||||
|
func _begin_throw(action: Dictionary) -> bool:
|
||||||
|
if not has_held_prop() or _state != State.HOLDING:
|
||||||
|
return false
|
||||||
|
var requested: int = int(action.get("target_prop", _held_prop.get_instance_id()))
|
||||||
|
if requested != _held_prop.get_instance_id() or not action.get("target_position") is Vector2:
|
||||||
|
return false
|
||||||
|
var time: float = float(action.get("flight_time", 0.0))
|
||||||
|
var solved: Dictionary = PROP_TRAJECTORY.solve_throw_velocity(_hand_marker().global_position, action["target_position"] as Vector2, time, gravity_for(_held_prop))
|
||||||
|
if not bool(solved.get("ok", false)):
|
||||||
|
return false
|
||||||
|
_pending_action = action.duplicate(true)
|
||||||
|
_timer = 0.10
|
||||||
|
_state = State.WINDUP
|
||||||
|
return true
|
||||||
|
|
||||||
|
func _complete_throw() -> void:
|
||||||
|
if not has_held_prop() or not _pending_action.get("target_position") is Vector2:
|
||||||
|
cancel()
|
||||||
|
interaction_finished.emit(false)
|
||||||
|
return
|
||||||
|
var solved: Dictionary = PROP_TRAJECTORY.solve_throw_velocity(_hand_marker().global_position, _pending_action["target_position"] as Vector2, float(_pending_action.get("flight_time", 0.0)), gravity_for(_held_prop))
|
||||||
|
if not bool(solved.get("ok", false)):
|
||||||
|
_restore_hand_target(); _state = State.HOLDING; interaction_finished.emit(false); return
|
||||||
|
release_held(&"throw", solved["velocity"] as Vector2, RELEASE_SPIN, &"prop_thrown")
|
||||||
|
interaction_finished.emit(true)
|
||||||
|
|
||||||
|
func _choose_grip(prop: PropBlock, requested_hand: String) -> Dictionary:
|
||||||
|
var options: Array[Dictionary] = []
|
||||||
|
for hand_id: String in ["right", "left"]:
|
||||||
|
if requested_hand != "auto" and requested_hand != hand_id:
|
||||||
|
continue
|
||||||
|
var enum_hand: int = PROP_ANCHOR_DATA.Hand.RIGHT if hand_id == "right" else PROP_ANCHOR_DATA.Hand.LEFT
|
||||||
|
for id: String in PROP_ANCHOR_DATA.compatible_grip_ids(prop.anchors, enum_hand):
|
||||||
|
options.append({"hand": hand_id, "grip": prop.anchors[id], "id": id})
|
||||||
|
if options.is_empty():
|
||||||
|
var bounds: Rect2 = prop.get_visual_local_bounds()
|
||||||
|
var fallback: Dictionary = {"position": bounds.get_center(), "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
|
||||||
|
for fallback_hand: String in ["right", "left"]:
|
||||||
|
if requested_hand == "auto" or requested_hand == fallback_hand:
|
||||||
|
options.append({"hand": fallback_hand, "grip": fallback, "id": "fallback"})
|
||||||
|
options.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||||
|
var ad: float = _marker_for(String(a["hand"])).global_position.distance_squared_to(_grip_world_point(prop, a["grip"] as Dictionary))
|
||||||
|
var bd: float = _marker_for(String(b["hand"])).global_position.distance_squared_to(_grip_world_point(prop, b["grip"] as Dictionary))
|
||||||
|
if not is_equal_approx(ad, bd): return ad < bd
|
||||||
|
if String(a["hand"]) != String(b["hand"]): return String(a["hand"]) == "right"
|
||||||
|
return String(a["id"]) < String(b["id"])
|
||||||
|
)
|
||||||
|
return options[0]
|
||||||
|
|
||||||
|
func _update_reach_target() -> void:
|
||||||
|
if _reserved_prop == null or not is_instance_valid(_reserved_prop):
|
||||||
|
return
|
||||||
|
var goal: Vector2 = _reserved_prop.global_transform * (_grip.get("position", Vector2.ZERO) as Vector2)
|
||||||
|
_hand_marker().global_position = _saved_hand_target.lerp(goal, clampf(1.0 - _timer / REACH_DURATION, 0.0, 1.0))
|
||||||
|
|
||||||
|
func _update_attachment() -> void:
|
||||||
|
if not has_held_prop(): return
|
||||||
|
var wrist: Transform2D = _hand_marker().global_transform
|
||||||
|
_held_prop.global_transform = PROP_ANCHOR_DATA.solve_attachment_transform(wrist, _grip, _held_prop.scale, _rig.facing_profile == StickmanRig.FacingProfile.LEFT)
|
||||||
|
|
||||||
|
func _can_reach(point: Vector2) -> bool:
|
||||||
|
return _hand_marker().global_position.distance_to(point) <= ARM_REACH
|
||||||
|
|
||||||
|
func _grip_world_point(prop: PropBlock, grip: Dictionary) -> Vector2:
|
||||||
|
return prop.global_transform * (grip.get("position", Vector2.ZERO) as Vector2)
|
||||||
|
|
||||||
|
func _hand_marker() -> Marker2D:
|
||||||
|
return _marker_for(String(_hand))
|
||||||
|
|
||||||
|
func _marker_for(hand: String) -> Marker2D:
|
||||||
|
return _rig._get_ik_marker("Right_Hand" if hand == "right" else "Left_Hand")
|
||||||
|
|
||||||
|
func _restore_hand_target() -> void:
|
||||||
|
var marker: Marker2D = _hand_marker()
|
||||||
|
if marker != null and is_instance_valid(marker): marker.global_position = _saved_hand_target
|
||||||
|
|
||||||
|
func _rig_velocity() -> Vector2:
|
||||||
|
if _locomotion != null and _locomotion.terrain_air_velocity.is_finite(): return _locomotion.terrain_air_velocity
|
||||||
|
return Vector2.ZERO
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://p3owck52oug2
|
||||||
+18
-1
@@ -225,6 +225,7 @@ var source_stk_path: String = ""
|
|||||||
var _locomotion_component: StickmanLocomotion = null
|
var _locomotion_component: StickmanLocomotion = null
|
||||||
var _ragdoll_component: StickmanRagdoll = null
|
var _ragdoll_component: StickmanRagdoll = null
|
||||||
var _action_runner_component: StickmanActionRunner = null
|
var _action_runner_component: StickmanActionRunner = null
|
||||||
|
var _prop_interaction_component: StickmanPropInteraction = null
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
@@ -234,12 +235,15 @@ func _ready() -> void:
|
|||||||
_locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion
|
_locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion
|
||||||
_ragdoll_component = get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll
|
_ragdoll_component = get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll
|
||||||
_action_runner_component = get_node_or_null(NodePath("RigComponents/ActionRunner")) as StickmanActionRunner
|
_action_runner_component = get_node_or_null(NodePath("RigComponents/ActionRunner")) as StickmanActionRunner
|
||||||
|
_prop_interaction_component = get_node_or_null(NodePath("RigComponents/PropInteraction")) as StickmanPropInteraction
|
||||||
if _locomotion_component == null:
|
if _locomotion_component == null:
|
||||||
push_warning("StickmanRig: missing RigComponents/Locomotion; locomotion boundary unavailable.")
|
push_warning("StickmanRig: missing RigComponents/Locomotion; locomotion boundary unavailable.")
|
||||||
if _ragdoll_component == null:
|
if _ragdoll_component == null:
|
||||||
push_warning("StickmanRig: missing RigComponents/Ragdoll; ragdoll boundary unavailable.")
|
push_warning("StickmanRig: missing RigComponents/Ragdoll; ragdoll boundary unavailable.")
|
||||||
if _action_runner_component == null:
|
if _action_runner_component == null:
|
||||||
push_warning("StickmanRig: missing RigComponents/ActionRunner; action boundary unavailable.")
|
push_warning("StickmanRig: missing RigComponents/ActionRunner; action boundary unavailable.")
|
||||||
|
if _prop_interaction_component == null:
|
||||||
|
push_warning("StickmanRig: missing RigComponents/PropInteraction; prop interaction boundary unavailable.")
|
||||||
if _locomotion_component != null:
|
if _locomotion_component != null:
|
||||||
_locomotion_component.configure(self)
|
_locomotion_component.configure(self)
|
||||||
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
|
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
|
||||||
@@ -248,7 +252,7 @@ func _ready() -> void:
|
|||||||
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
|
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
|
||||||
_ragdoll_component.state_transition_requested.connect(_set_state)
|
_ragdoll_component.state_transition_requested.connect(_set_state)
|
||||||
if _action_runner_component != null:
|
if _action_runner_component != null:
|
||||||
_action_runner_component.configure(self, _locomotion_component)
|
_action_runner_component.configure(self, _locomotion_component, _prop_interaction_component)
|
||||||
_action_runner_component.action_queue = _pending_action_queue
|
_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_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))
|
_action_runner_component.action_finished.connect(func(action: Dictionary, index: int) -> void: action_finished.emit(action, index))
|
||||||
@@ -257,6 +261,8 @@ func _ready() -> void:
|
|||||||
_action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit())
|
_action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit())
|
||||||
_action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled))
|
_action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled))
|
||||||
_action_runner_component.recovery_requested.connect(func() -> void: request_recovery())
|
_action_runner_component.recovery_requested.connect(func() -> void: request_recovery())
|
||||||
|
if _prop_interaction_component != null:
|
||||||
|
_prop_interaction_component.configure(self, _locomotion_component)
|
||||||
# Resolve all runtime node references.
|
# Resolve all runtime node references.
|
||||||
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
|
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
|
||||||
if _skeleton == null:
|
if _skeleton == null:
|
||||||
@@ -313,6 +319,8 @@ func _physics_process(delta: float) -> void:
|
|||||||
_pin_mirrored_head_rotation()
|
_pin_mirrored_head_rotation()
|
||||||
if _locomotion_component != null:
|
if _locomotion_component != null:
|
||||||
_locomotion_component.tick_post_skeleton(delta, int(state))
|
_locomotion_component.tick_post_skeleton(delta, int(state))
|
||||||
|
if _prop_interaction_component != null:
|
||||||
|
_prop_interaction_component.update_held_attachment(delta)
|
||||||
|
|
||||||
|
|
||||||
func get_locomotion() -> StickmanLocomotion:
|
func get_locomotion() -> StickmanLocomotion:
|
||||||
@@ -450,6 +458,8 @@ func _set_state(next_state: int) -> void:
|
|||||||
|
|
||||||
func set_ragdoll(enabled: bool) -> void:
|
func set_ragdoll(enabled: bool) -> void:
|
||||||
if enabled:
|
if enabled:
|
||||||
|
if _prop_interaction_component != null:
|
||||||
|
_prop_interaction_component.release_for_ragdoll()
|
||||||
if _locomotion_component != null:
|
if _locomotion_component != null:
|
||||||
_locomotion_component.reset_for_ragdoll_handoff()
|
_locomotion_component.reset_for_ragdoll_handoff()
|
||||||
match state:
|
match state:
|
||||||
@@ -805,6 +815,13 @@ func stop_queue() -> void:
|
|||||||
if _action_runner_component != null:
|
if _action_runner_component != null:
|
||||||
_action_runner_component.stop_queue()
|
_action_runner_component.stop_queue()
|
||||||
|
|
||||||
|
func get_prop_interaction() -> StickmanPropInteraction:
|
||||||
|
return _prop_interaction_component
|
||||||
|
|
||||||
|
func release_held_prop_for_lifecycle(reason: StringName = &"lifecycle") -> void:
|
||||||
|
if _prop_interaction_component != null:
|
||||||
|
_prop_interaction_component.release_for_lifecycle(reason)
|
||||||
|
|
||||||
|
|
||||||
func is_queue_running() -> bool:
|
func is_queue_running() -> bool:
|
||||||
return _action_runner_component != null and _action_runner_component.is_queue_running()
|
return _action_runner_component != null and _action_runner_component.is_queue_running()
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ const TRIGGER_TEMPLATES := {
|
|||||||
"icon": "💥",
|
"icon": "💥",
|
||||||
"target_type": "prop",
|
"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" },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# Phase 11 — Interactive Props and Event Rules
|
||||||
|
|
||||||
|
Status: **approved — implementation complete, awaiting Editor verification** (2026-09-23).
|
||||||
|
|
||||||
|
Source: plans/PHASE_11_PROP_INTERACTION.md, refined through the spec-pipeline architecture round.
|
||||||
|
|
||||||
|
## 1. Accepted decisions
|
||||||
|
|
||||||
|
1. Deliver the work as gated milestones. Milestone 11A adds the interaction foundation, pickup/drop, and their triggers. Milestone 11B adds deterministic aimed throws and the trajectory preview. Milestone 11C adds ragdoll disarm and lifecycle hardening. Each milestone must work before the next begins.
|
||||||
|
2. Valid props are pickup-enabled by default and use authored grip anchors when available. An explicit **can_be_picked_up** capability lets a prop such as a tree opt out. Props without a compatible authored grip use a deterministic bounds-center fallback.
|
||||||
|
3. Throws target an exact point and save the selected arc/flight-time setting. The same solver drives the preview and PLAY release. A finite launch-speed cap and flight-time range reject targets that would require an implausible throw.
|
||||||
|
|
||||||
|
These decisions are requirements for implementation.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
### 2.1 Milestone 11A — pickup, hold, drop, and triggers
|
||||||
|
|
||||||
|
- Add the can_be_picked_up asset capability to .prop data and the Prop Editor. Missing values default to true for old .prop files. Existing built-in props remain pickup-enabled; future built-ins can opt out in their library entry.
|
||||||
|
- Carry normalized grip anchors and the pickup capability through PropLibrary, StageSpawner, and PropBlock. Custom props currently lose their anchors at the library boundary; this closes that gap.
|
||||||
|
- Add a dedicated StickmanPropInteraction component under RigComponents. It owns one held prop, the selected hand and grip, saved IK target state, the pickup/approach lifecycle, and release.
|
||||||
|
- Add pick_up_prop and drop_prop queue actions. The queue runner delegates these actions to the component and waits for completion. Auto-approach is a private subgoal that calls the existing horizontal walk_to behavior; it does not insert a visible or saved queue action.
|
||||||
|
- Extend Director action editing/target capture so the actor and a specific prop instance can be selected. Prop identity uses a stage object file ID, not spawn_id, because several instances can share one asset.
|
||||||
|
- Extend StageSceneIO queue serialization to map runtime prop instance IDs to and from scene file IDs. Preserve scene version 1.0 with additive action fields. A missing target drops only the unresolved action with a warning, following the existing unresolved-reference policy.
|
||||||
|
- Add prop_picked_up and prop_dropped trigger types. Emit them from the actual successful state transition, with source = acting StickmanRig and target = affected PropBlock. Drop includes manual release; ragdoll disarm is added in 11C.
|
||||||
|
- Add the trigger choices to TriggerRegistry and RuleEditor. Existing consequences remain the current stickman action set; prop-targeted consequence actions are out of scope.
|
||||||
|
|
||||||
|
### 2.2 Milestone 11B — aimed throw
|
||||||
|
|
||||||
|
- Add throw_prop with a specific prop instance, exact world target, and flight_time/arc setting.
|
||||||
|
- In DIRECT, capture the landing point through the stage's existing target-capture flow and show a live parabolic preview. The action is saved in the actor's queue and runs in PLAY; there is no free-aim input during playback.
|
||||||
|
- Use one pure trajectory solver for preview and release. It uses the release point, target, flight time, and the live 2D gravity vector scaled by the prop's gravity_scale.
|
||||||
|
- Initial tuning envelope: minimum flight time 0.35 s, maximum flight time 1.5 s, and maximum launch speed 900 px/s. Treat these as named tunable constants. Validate finite inputs and reject a target whose required velocity exceeds the speed cap. Do not clamp the velocity, since that would miss the stored target.
|
||||||
|
- The Director preview marks an invalid target clearly and does not commit the action until it is valid. Changing the arc setting re-solves both preview and stored velocity from the same function.
|
||||||
|
- Release uses the computed initial velocity directly as PropBlock.linear_velocity, not as an unscaled impulse. This keeps a selected trajectory independent of material mass. Apply angular spin as a separate bounded setting or deterministic default; it must not alter the linear landing target.
|
||||||
|
- Add prop_thrown, emitted once at the successful physics release. It uses the same source/target direction as the other prop triggers.
|
||||||
|
|
||||||
|
### 2.3 Milestone 11C — ragdoll disarm and lifecycle hardening
|
||||||
|
|
||||||
|
- Before StickmanRagdoll begins building bodies, StickmanRig asks StickmanPropInteraction to release any held prop. The release must happen before the animated body is hidden and before the rig pose is handed to physics.
|
||||||
|
- Transfer a finite linear and angular momentum snapshot from the rig to the released PropBlock. Add a typed snapshot API at the rig/ragdoll boundary if needed; capture values before the ragdoll transition changes the source pose.
|
||||||
|
- Emit exactly one prop_dropped event with a ragdoll release reason.
|
||||||
|
- Release safely on PLAY exit and actor/prop deletion. PLAY exit restores the stage's authored transforms through the existing authored-state snapshot. Invalid references must never leave a prop frozen or an IK marker targeting a freed node.
|
||||||
|
- Cover interruption during approach, reach, windup, or release without duplicate triggers or stale physics state.
|
||||||
|
|
||||||
|
### 2.4 Explicitly deferred
|
||||||
|
|
||||||
|
- Two-hand carries, inventory/equipment slots, multiple held props per rig, melee/use actions, prop-targeted rule consequences, and AI choice of which prop to use.
|
||||||
|
- Slope-aware approach, full obstacle avoidance, or a general 2D path/reach planner. Approach uses the existing horizontal locomotion and reports a clean failure if it cannot reach a valid stand point.
|
||||||
|
- Multiplayer, network replication, persistent physics state, and new scene-file versioning.
|
||||||
|
- A dedicated stage event registry node. SandboxStage remains the event dispatcher and matcher.
|
||||||
|
- Ragdolling a held prop together with a character; a held prop is disarmed into ordinary world physics before ragdoll construction.
|
||||||
|
|
||||||
|
## 3. Existing architecture constraints
|
||||||
|
|
||||||
|
- StickmanRig is the public facade. It owns RigComponents/Locomotion, Ragdoll, and ActionRunner; prop interaction belongs beside those components rather than in the facade or a second stage-only controller.
|
||||||
|
- StickmanActionRunner uses action phases and emits action_started/action_finished. A prop phase must preserve queue order and action lifecycle signals. Internal approach steps must not appear as authored queue rows.
|
||||||
|
- Locomotion is horizontal, feet-based, and exposes walk_to. The pickup stand point is beside the prop bounds on the ground plane. If navigation cannot reach it, the pickup action fails without attaching.
|
||||||
|
- DIRECT authors queue actions while PLAY executes them. Props are frozen for authored editing and simulate in PLAY. Prop actions are authored in DIRECT and remain deterministic in PLAY.
|
||||||
|
- PropAnchorData.compatible_grip_ids() and solve_attachment_transform() already provide hand compatibility and mirror-aware grip alignment. Reuse those helpers. PropAttachPreview is the reference for sampling solved wrist endpoints and converting between the wrist and authored grip.
|
||||||
|
- There is no StageEventRegistry node. TriggerRegistry describes trigger metadata; SandboxStage owns _handle_event(), matching, and reactive action dispatch.
|
||||||
|
- StageSceneIO currently serializes queues without an object-reference map. Rules already encode runtime object references to scene file IDs; queue serialization must gain the equivalent mapping for prop targets.
|
||||||
|
- PropBlock is a RigidBody2D and remains parented under World during a hold. Do not use RemoteTransform2D reparenting for runtime socket attachment.
|
||||||
|
|
||||||
|
## 4. Data model and asset compatibility
|
||||||
|
|
||||||
|
### 4.1 Pickup capability
|
||||||
|
|
||||||
|
Add **can_be_picked_up: bool** to the PropFile in-memory model and additive top-level .prop field:
|
||||||
|
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"prop_name": "Oak Tree",
|
||||||
|
"can_be_picked_up": false,
|
||||||
|
"anchors": {},
|
||||||
|
"shapes": []
|
||||||
|
}
|
||||||
|
|
||||||
|
PropFile.to_file_dict() always writes the boolean. PropFile.from_file_dict() reads only an actual boolean; absent or malformed values become true. Keep FILE_VERSION at 1.0, matching the project's existing additive-field policy. Loading an old file does not dirty it merely because the default was supplied.
|
||||||
|
|
||||||
|
Add a kid-facing “Can be picked up” checkbox to the Prop Editor's general prop settings. It is persisted through normal save, save-as, autosave, recovery, and New. This capability is asset metadata; it does not change the prop's collision, mass, or gravity.
|
||||||
|
|
||||||
|
PropLibrary.make_custom_entry() must preserve can_be_picked_up and normalized anchors. Built-in templates include an explicit value, initially true for crate, ball, plank, and triangle. StageSpawner transfers both fields to PropBlock. PropBlock exposes a typed runtime property with a safe default of true for scenes and callers that do not come through StageSpawner.
|
||||||
|
|
||||||
|
### 4.2 Grip resolution
|
||||||
|
|
||||||
|
For an eligible prop, pickup resolves a hand/grip pair in this order:
|
||||||
|
|
||||||
|
1. Enumerate authored grip anchors compatible with each candidate hand using PropAnchorData.compatible_grip_ids().
|
||||||
|
2. For hand: "left" or "right", choose the compatible grip that requires the least reach for that hand. For hand: "auto", compare compatible hand/grip pairs and choose the shortest valid reach. Break ties deterministically in favor of the right hand, then lexicographic grip ID.
|
||||||
|
3. If no authored grip is compatible, synthesize a runtime-only grip at the center of PropBlock's full visual local bounds, with zero rotation and both hands allowed. Do not serialize this fallback into the asset.
|
||||||
|
4. Reject pickup immediately if can_be_picked_up is false, the prop is invalid/already held, the rig already holds a different prop, or no arm can reach after approach. Report a short Director status message and finish the action cleanly.
|
||||||
|
|
||||||
|
Only one prop per rig and one rig per prop are supported in this phase. The check and hold reservation occur synchronously before the reach animation begins, so two queued actors cannot both acquire one prop.
|
||||||
|
|
||||||
|
## 5. Runtime ownership and interaction lifecycle
|
||||||
|
|
||||||
|
### 5.1 Component boundary
|
||||||
|
|
||||||
|
Add scripts/stickman_prop_interaction.gd and a PropInteraction child to scenes/stickman_rig.tscn under RigComponents. StickmanRig resolves/configures it, exposes narrow public forwarding methods or typed signals where the stage needs access, and invokes release before ragdoll handoff.
|
||||||
|
|
||||||
|
The component owns:
|
||||||
|
|
||||||
|
- A small state machine for idle, approaching, reaching, holding, winding up, and recovery.
|
||||||
|
- Held PropBlock, chosen hand/grip ID, saved IK target/rest pose, and any interpolation timers.
|
||||||
|
- A single release kernel used by drop, throw, ragdoll, mode exit, and object cleanup.
|
||||||
|
- The authoritative attachment transform update and its physics-state snapshot.
|
||||||
|
|
||||||
|
Suggested typed completion contract:
|
||||||
|
|
||||||
|
signal interaction_finished(action: Dictionary, succeeded: bool)
|
||||||
|
signal prop_action_executed(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName)
|
||||||
|
|
||||||
|
Use the project's normal typed-signal style if names differ, but the contract must distinguish failed actions from successful transitions and must emit each transition once.
|
||||||
|
|
||||||
|
### 5.2 Pickup and hold
|
||||||
|
|
||||||
|
1. Resolve and reserve the target prop. If the rig is farther than its current arm reach, compute a stand point adjacent to the prop's left or right bounds, on the ground plane, and call the existing locomotion.walk_to(). Select the side that is reachable and requires less horizontal travel; use a deterministic tie-break.
|
||||||
|
2. When locomotion reports arrival, recompute reach from current bone/wrist positions. If the target is still outside the solver's safe arm reach, fail cleanly rather than repeatedly walking or forcing a stretched IK limb.
|
||||||
|
3. Interpolate the selected IK hand marker toward the authored grip's current world position for the configured reach duration. Preserve the opposite hand target. Cache the pre-interaction hand target once so release restores the actual locomotion/idle pose.
|
||||||
|
4. At reach completion, solve the prop transform from the solved wrist transform and selected anchor with PropAnchorData.solve_attachment_transform(). Freeze the prop, keep it under World, suppress collisions against that rig's animated collision proxies, and latch the hand target.
|
||||||
|
5. While holding, update the prop's global transform from the solved wrist and grip using the same helper. Apply the anchor's z_index_offset relative to the holder while preserving the prop's authored z state for restoration.
|
||||||
|
6. Prop movement follows the hand during locomotion. The prop remains frozen and does not participate in collision contacts while held.
|
||||||
|
|
||||||
|
The exact process callback/order must be selected during implementation by matching the solved wrist update order. The attachment may never lag a frame behind a visible wrist or feed the target marker back into itself.
|
||||||
|
|
||||||
|
### 5.3 Drop and release
|
||||||
|
|
||||||
|
The central release operation accepts a reason and a linear/angular velocity payload. It is idempotent and:
|
||||||
|
|
||||||
|
- Validates and clears the held-prop reference and hand latch.
|
||||||
|
- Restores the cached freeze mode, collision layer/mask/exceptions, z state, and saved gravity/damping values.
|
||||||
|
- Sets final global transform, linear_velocity, and angular_velocity before enabling dynamic simulation.
|
||||||
|
- Restores the selected hand marker smoothly to its cached pose.
|
||||||
|
- Emits the successful release event exactly once after the prop is in a valid world-physics state.
|
||||||
|
|
||||||
|
Manual drop uses the rig's current finite root/locomotion velocity and a neutral angular velocity unless a deterministic small spin is approved during implementation. An interrupted pickup before the hold latch cancels the reach and restores the marker without emitting picked-up or dropped. Stopping a queue after a completed pickup does not silently drop the held prop; only a drop action or a lifecycle release does.
|
||||||
|
|
||||||
|
PLAY exit releases first, then the existing authored-state restore returns prop and rig transforms to the EDIT/DIRECT snapshot. Deleting the held prop clears the hold and hand target without attempting to restore a freed node. Deleting the holder drops the valid prop safely before the rig leaves World.
|
||||||
|
|
||||||
|
## 6. Action and scene-file contracts
|
||||||
|
|
||||||
|
### 6.1 Runtime payloads
|
||||||
|
|
||||||
|
Runtime actions use node instance IDs; an asset path or spawn_id is never used to choose among duplicate prop instances.
|
||||||
|
|
||||||
|
{ "type": "pick_up_prop", "target_prop": 123456, "hand": "auto" }
|
||||||
|
{ "type": "drop_prop" }
|
||||||
|
{ "type": "throw_prop", "target_prop": 123456, "target_position": Vector2(...), "flight_time": 0.9 }
|
||||||
|
|
||||||
|
hand accepts auto, left, or right. flight_time is stored in seconds and validated against the configured bounds. Drop acts on the rig's currently held prop and carries no prop ID.
|
||||||
|
|
||||||
|
### 6.2 Saved payloads
|
||||||
|
|
||||||
|
StageSceneIO maps target_prop through the same scene-object reference tables used by rules:
|
||||||
|
|
||||||
|
{ "type": "pick_up_prop", "target_prop_id": "prop_3", "hand": "auto" }
|
||||||
|
{ "type": "drop_prop" }
|
||||||
|
{ "type": "throw_prop", "target_prop_id": "prop_3", "target_position": { "x": 500, "y": 80 }, "flight_time": 0.9 }
|
||||||
|
|
||||||
|
Queue encode/decode receives the relevant runtime-to-file and file-to-runtime maps. Reject non-finite target coordinates, invalid hand values, non-finite/out-of-range flight times, missing prop references, and a throw target that fails the same solver validation. Invalid actions are skipped with the existing queue warning mechanism; unrelated actions and the rest of the queue remain loadable.
|
||||||
|
|
||||||
|
ActionRegistry, ActionEditor, QueuePanel, stage action capture, and ActionRegistry summaries expose the new actions. The queue editor's prop picker selects an object instance from World and displays its current asset name plus a stable per-scene ordinal so duplicate crates are distinguishable.
|
||||||
|
|
||||||
|
### 6.3 Trigger/event contract
|
||||||
|
|
||||||
|
Register:
|
||||||
|
|
||||||
|
| Type | Source | Target | Firing point |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| prop_picked_up | acting StickmanRig | acquired PropBlock | Hold latch succeeds and the prop is aligned to the wrist |
|
||||||
|
| prop_dropped | acting StickmanRig | released PropBlock | Manual drop or lifecycle disarm completes |
|
||||||
|
| prop_thrown | acting StickmanRig | released PropBlock | Throw velocity is assigned and physics is enabled |
|
||||||
|
|
||||||
|
The event dictionary includes type, source, target, event position, and reason/action data. Rule matching honors exact runtime source and target IDs and wildcard -1 values as it does for existing events. The trigger UI identifies the target kind as prop. prop_picked_up and prop_dropped are added in 11A; prop_thrown is added in 11B. A ragdoll release is prop_dropped with reason ragdoll, not a second new trigger type.
|
||||||
|
|
||||||
|
Emit transitions from StickmanPropInteraction through StickmanRig to SandboxStage. Do not infer pickup/drop/throw from action_finished: failed actions also finish, while lifecycle releases may not have a matching queue action.
|
||||||
|
|
||||||
|
## 7. Throw solver and Director preview
|
||||||
|
|
||||||
|
### 7.1 Solver
|
||||||
|
|
||||||
|
Create one pure helper, owned by StickmanPropInteraction or a small stateless PropTrajectory utility:
|
||||||
|
|
||||||
|
solve_throw_velocity(release_point, target_point, flight_time, gravity_vector) -> result
|
||||||
|
|
||||||
|
For time t, return:
|
||||||
|
|
||||||
|
velocity = (target_point - release_point - 0.5 * gravity_vector * t * t) / t
|
||||||
|
|
||||||
|
The result contains ok, velocity, flight_time, and an error reason. Reject zero/negative time, non-finite vectors, time outside 0.35–1.5 seconds, or velocity magnitude over 900 px/s. Resolve gravity using the current project 2D gravity direction and magnitude multiplied by PropBlock.gravity_scale. Preview and release call this exact helper.
|
||||||
|
|
||||||
|
The initial cap and range are intentionally tied to the project's pixel scale: with standard downward gravity near 980 px/s², a same-height throw at the speed cap has a theoretical range of about 826 px, approximately one current stickman height. The speed cap is the hard physical bound; the flight-time bound prevents long, slow, implausible arcs. Keep both values named and tunable, and show invalidity instead of silently modifying the authored target.
|
||||||
|
|
||||||
|
### 7.2 Playback release
|
||||||
|
|
||||||
|
The throw uses a brief IK windup, then at its release moment:
|
||||||
|
|
||||||
|
1. Sample the current solved wrist transform.
|
||||||
|
2. Solve velocity again from the stored exact target, current release point, flight_time, and live gravity.
|
||||||
|
3. If the result is no longer valid (for example, the actor moved after authoring), abort with a concise message and restore the arm. Do not emit prop_thrown.
|
||||||
|
4. Release through the central kernel, set the computed linear velocity directly, apply the bounded spin, and emit prop_thrown.
|
||||||
|
5. Blend the arm back to its cached rest target.
|
||||||
|
|
||||||
|
Windup/recovery are target-marker tweens rather than a new animation resource or body-rotation system. The plan's approximate 100 ms windup, 150 ms release, and 50 ms follow-through are initial tuning targets, not hard timing compatibility requirements.
|
||||||
|
|
||||||
|
### 7.3 Director capture
|
||||||
|
|
||||||
|
Throw target capture is available while authoring a queue in DIRECT. The reticle shows the solved arc from the current expected release point to the pointer. A compact arc control stores flight_time. Invalid targets use the existing stage feedback/status path, are visibly distinct, and cannot be committed. The saved queue stores target position and flight_time; playback never reads the mouse.
|
||||||
|
|
||||||
|
## 8. Ragdoll and lifecycle order
|
||||||
|
|
||||||
|
StickmanRig.set_ragdoll(true) performs this order:
|
||||||
|
|
||||||
|
1. Ask StickmanPropInteraction for a finite rig momentum snapshot while the animated pose and root movement data are still available.
|
||||||
|
2. Release a held prop through the central release kernel with that snapshot and reason ragdoll.
|
||||||
|
3. Emit prop_dropped once through SandboxStage so matching rules can enqueue their normal reactive actions.
|
||||||
|
4. Continue the existing locomotion handoff and StickmanRagdoll.enter_ragdoll() path.
|
||||||
|
|
||||||
|
The release precedes body hiding and ragdoll construction. If ragdoll construction fails after a release, the prop remains safely released; it is never refrozen or rebound implicitly.
|
||||||
|
|
||||||
|
On PLAY exit, release any held prop before _restore_authored_state(). If queue/actor cleanup occurs while approaching or reaching, cancel the private subgoal and restore marker state. If the actor is removed while holding, drop into World using its last valid transform and finite velocity. All cleanup paths are idempotent.
|
||||||
|
|
||||||
|
## 9. Acceptance and user verification
|
||||||
|
|
||||||
|
### Milestone 11A
|
||||||
|
|
||||||
|
- Old v1.0 .prop assets load pickup-enabled and keep their existing anchors; a saved false capability survives Prop Editor save/load/autosave.
|
||||||
|
- A custom grip anchor reaches the hand with authored rotation, scale, mirror, and allowed-hand rules respected. Props without compatible grips use the deterministic bounds-center fallback.
|
||||||
|
- A non-pickup prop fails with feedback and never freezes. Existing built-ins remain eligible.
|
||||||
|
- Picking a nearby prop reaches, aligns, freezes, and holds it through walking. Dropping restores physics and collision behavior, hand pose, z state, and movement velocity.
|
||||||
|
- A distant target uses one private horizontal walk_to subgoal before reaching. An unreachable target ends cleanly with no queue mutation or stale hold.
|
||||||
|
- Duplicate instances of one asset can be independently targeted, save/reload retains the target instance, and unresolved IDs do not corrupt other queue rows.
|
||||||
|
- Pickup/drop triggers fire once only after successful transitions, identify the correct source and prop, and execute matching rules.
|
||||||
|
- DIRECT only authors the actions; PLAY runs them. PLAY exit restores the authored scene transforms.
|
||||||
|
|
||||||
|
### Milestone 11B
|
||||||
|
|
||||||
|
- The live preview and playback compute the same velocity for the same release point, target, flight time, gravity, and prop scale.
|
||||||
|
- Valid targets land at the stored point when unobstructed within normal physics tolerance. Gravity scale is respected.
|
||||||
|
- Targets outside the speed or time envelope are visibly invalid and cannot be saved. A prop whose release point changes enough during playback to exceed the cap aborts without a throw event.
|
||||||
|
- Throw velocity is mass-independent. prop_thrown fires once at release; no throw event fires on cancellation or invalid playback.
|
||||||
|
|
||||||
|
### Milestone 11C
|
||||||
|
|
||||||
|
- Entering ragdoll releases a held prop before kinematic body hiding and the physics handoff. The prop receives finite rig linear/angular motion and then responds to gravity/collisions.
|
||||||
|
- Ragdoll disarm fires one prop_dropped event with reason ragdoll.
|
||||||
|
- PLAY exit, deleted prop, deleted holder, queue cancellation, and invalid/stale references leave no frozen prop, dangling target, duplicate event, or lost authored state.
|
||||||
|
|
||||||
|
Manual verification takes place in the Godot editor and Sandbox Stage. Implementation must run the skill-prescribed headless per-script syntax check for changed scripts. Documentation/README updates are deferred until the user confirms: “Feature verified and working.”
|
||||||
|
|
||||||
|
## 10. Trade-offs and known limits
|
||||||
|
|
||||||
|
- The internal approach is horizontal because that is the existing locomotion contract. It will not find a route around an obstacle; it fails cleanly when the current navigation cannot reach the chosen stand point.
|
||||||
|
- A bounds-center fallback keeps built-ins and older props usable without inventing saved anchor data. Authors can add explicit grip anchors for better hand placement.
|
||||||
|
- One held prop per rig keeps state ownership simple. Two-hand grips are authored metadata but simultaneous two-hand solving remains deferred.
|
||||||
|
- A speed cap plus maximum flight time makes aiming deterministic and prevents arbitrary-distance throws. The initial cap is a tuning value that should be adjusted only against the visible stage/rig scale.
|
||||||
|
- Keeping the prop under World avoids fragile RemoteTransform2D paths and reparenting transforms, but requires a correctly ordered transform update from the solved wrist.
|
||||||
|
- Trigger matching is edge-triggered at the semantic transition. Rules may enqueue reactive stickman actions; this phase does not add prop manipulation as a rule consequence.
|
||||||
Reference in New Issue
Block a user