Implement collision bodies, physics events, and hit reactions for StickmanRig
- Added a new specification for collision bodies, detailing the implementation requirements and corrections needed in the StickmanRig, PropBlock, and SandboxStage scripts. - Created a comprehensive test suite for collision bodies, including tests for proxy geometry, physics alignment, reaction handling, ghost collision, and stage contact routing. - Ensured that the collision system correctly handles ragdoll transitions and maintains the integrity of existing event rules. - Introduced new methods in StickmanRig for managing collision bodies and applying reactions based on impact direction and speed. - Updated PropBlock to emit signals for collisions and separations, preserving the order of events for stickman and prop interactions.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
# Phase 4.5a — Per-Part Collision Bodies, Physics-Based Prop Events, and Reaction System
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This phase gives the ANIMATED stickman a physical presence and makes stickman-prop interaction feel real. Three outcomes:
|
||||
|
||||
1. **Props bounce off the animated stickman** — a set of per-part kinematic collision bodies so props physically collide with the figure instead of passing through it.
|
||||
2. **Collision events fire from real physics contact** — replace the feet-point geometric test with the contact signal from the prop's `RigidBody2D`, so a `collided` trigger fires whenever a prop visibly touches the stickman.
|
||||
3. **The stickman visibly reacts to hits** — when a prop hits a limb, that limb briefly flinches away from the impact, then springs back to its animation.
|
||||
|
||||
The impact direction comes from the **physics contact normal** reported by `body_entered` on the prop side, not from the prop's velocity. This is the physically accurate direction the prop is pushing the stickman.
|
||||
|
||||
**Not in scope:** `explode_prop`, `remove_prop`, `spawn_prop`, `prop_impulse`, or any prop-targeted actions. Those belong to a future phase.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope
|
||||
|
||||
| In Scope | Out of Scope |
|
||||
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| 10 `AnimatableBody2D` + `CollisionShape2D` per stickman (one per body part) | Prop-targeted actions |
|
||||
| Physics-based `collided` events | Ragdoll-prop reaction (ragdoll bodies handle physics, no rule fires during ragdoll) |
|
||||
| Collision normal as impact direction | Continuous contact debouncing |
|
||||
| Transient bone reaction (flinch-and-recover) | Precise polygon geometry (capsules and circles only) |
|
||||
| RECOVERING keeps collision bodies enabled | Limb animation-driven collision shape resizing |
|
||||
| LEFT mirror tolerance (option A) | Reaction for ragdoll or recovering state |
|
||||
|
||||
---
|
||||
|
||||
## 3. Changes to `StickmanRig`
|
||||
|
||||
### 3.1 Build collision bodies
|
||||
|
||||
In `_ready()`, after resolving the `Body/*` part nodes, create one `AnimatableBody2D` + `CollisionShape2D` per visual part, parented to the `Body/*` node. The `Body/*` node is already driven by its `RemoteTransform2D`, so each collision body follows the visual automatically.
|
||||
|
||||
```
|
||||
Body/LeftUpperArm (Node2D, driven by RemoteTransform2D)
|
||||
├── (visual: Line2D or Polygon2D mounted by the adapter)
|
||||
└── CollisionBody (AnimatableBody2D, collision_layer = 1, collision_mask = 1)
|
||||
└── CollisionShape2D (CapsuleShape2D)
|
||||
```
|
||||
|
||||
Name the collision body `"CollisionBody"` for consistency with `test_harness` conventions.
|
||||
|
||||
**Metadata:** On creation, call `body.set_meta("stickman_rig", self)` and `body.set_meta("part_name", "<part_key>")` (e.g. `"left_upper_arm"`). The stage uses these to resolve which rig was hit and which part.
|
||||
|
||||
Store the 10 collision bodies in a dictionary keyed by part name: `_collision_bodies: Dictionary[String, AnimatableBody2D]`.
|
||||
|
||||
### 3.2 Shape sizing
|
||||
|
||||
Each collision shape sits in `Body/*` local coordinates, matching the "hanging convention" the adapter uses (joint at local origin, far end along local +Y).
|
||||
|
||||
| Part key | Shape | Height | Radius | Local position |
|
||||
| ----------------------------------- | ---------------- | ------------------ | -------------------------------- | -------------------------------------------------------- |
|
||||
| `head` | `CircleShape2D` | — | `HEAD_COLLISION_RADIUS := 90.0` | `(0, 0)` — head visual is already centered on its origin |
|
||||
| `torso` | `CapsuleShape2D` | `torso_length` | `TORSO_COLLISION_RADIUS := 20.0` | `(0, torso_length / 2)` |
|
||||
| `left_upper_arm`, `right_upper_arm` | `CapsuleShape2D` | `upper_arm_length` | `ARM_COLLISION_RADIUS := 12.0` | `(0, upper_arm_length / 2)` |
|
||||
| `left_lower_arm`, `right_lower_arm` | `CapsuleShape2D` | `lower_arm_length` | `ARM_COLLISION_RADIUS` | `(0, lower_arm_length / 2)` |
|
||||
| `left_upper_leg`, `right_upper_leg` | `CapsuleShape2D` | `upper_leg_length` | `LEG_COLLISION_RADIUS := 14.0` | `(0, upper_leg_length / 2)` |
|
||||
| `left_lower_leg`, `right_lower_leg` | `CapsuleShape2D` | `lower_leg_length` | `LEG_COLLISION_RADIUS` | `(0, lower_leg_length / 2)` |
|
||||
|
||||
**Bone lengths** come from the fitted rig (already applied by `StkRigAdapter`). Store them as `_collision_lengths: Dictionary[String, float]` in `_ready()` by reading each `Bone2D`'s `length` property. Constants for radii and the head radius live at the top of the script alongside the existing constants.
|
||||
|
||||
**Head sizing note:** the head visual is a full geometry (circle, rectangle, etc.), not a bone. A fixed `CircleShape2D` of radius 90 (matching the pose-guide circle) is a reasonable approximation. Future refinement: read the mounted head's local bbox.
|
||||
|
||||
**LEFT mirror (option A):** the parent `Master.scale.x = -1` applies to the collision shapes. Since capsules and circles are symmetric, mirroring has no visual effect. Godot may emit a warning about non-uniform scale on `CollisionShape2D` — this is expected and harmless. Do not attempt to counter-mirror.
|
||||
|
||||
### 3.3 State management
|
||||
|
||||
| State | Collision bodies |
|
||||
| ------------ | --------------------------------------------------------------------------------- |
|
||||
| `ANIMATED` | Enabled (`collision_layer = 1`, `collision_mask = 1`) |
|
||||
| `RECOVERING` | Enabled — props continue to bounce off the recovering figure |
|
||||
| `RAGDOLL` | Disabled (`collision_layer = 0`, `collision_mask = 0`) — ragdoll bodies take over |
|
||||
|
||||
Implement a helper `_set_collision_bodies_enabled(enabled: bool)` that iterates `_collision_bodies` and toggles both `collision_layer` and `collision_mask`.
|
||||
|
||||
Call `_set_collision_bodies_enabled(false)` at the start of `_enter_ragdoll()`.
|
||||
Call `_set_collision_bodies_enabled(true)` at the end of `_on_stand_up_finished()`.
|
||||
Do **not** toggle during `RECOVERING` — the bodies stay enabled.
|
||||
|
||||
**Contact settling (optional):** if recovering stickmen re-enable bodies that contain resting props and the resulting `body_entered` spam feels wrong, add a `_contact_settling_timer` of 0.2 s during which reaction dispatches are suppressed but physics contacts still resolve. Start without this; add only if it's needed.
|
||||
|
||||
### 3.4 Reaction system
|
||||
|
||||
The rig maintains a per-joint transient reaction offset applied additively after the IK stack solves each frame.
|
||||
|
||||
**State:**
|
||||
\_impact_reactions: Dictionary = {}
|
||||
|
||||
```
|
||||
key: joint name (e.g. "LeftUpperArm", "LeftLowerArm", "Head", "Torso")
|
||||
value: { "angle": float, "strength": float, "decay": float }
|
||||
```
|
||||
|
||||
**Public entry point:**
|
||||
|
||||
```
|
||||
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float) -> void
|
||||
```
|
||||
|
||||
The method:
|
||||
|
||||
1. Maps the hit part to affected joints (see §6.1).
|
||||
2. Converts `impact_direction` and `impact_speed` into an angle offset per joint (see §6.2).
|
||||
3. Merges into `_impact_reactions` (if an entry exists, take the stronger angle and reset strength to 1.0).
|
||||
|
||||
**Apply pass:** call `_apply_impact_reactions(delta)` from `_physics_process()` **after** `_pin_mirrored_head_rotation()` (matching the existing pattern that runs after the IK stack). For each entry:
|
||||
|
||||
```
|
||||
joint_bone.rotation += reaction["angle"] * reaction["strength"]
|
||||
reaction["strength"] -= delta / reaction["decay"]
|
||||
if reaction["strength"] <= 0.0:
|
||||
_impact_reactions.erase(joint)
|
||||
```
|
||||
|
||||
**Timing note:** the codebase already relies on `_pin_mirrored_head_rotation()` being called from `_physics_process` _after_ the modification stack solves. If for any reason reaction offsets don't show (i.e. they're being overwritten before render), the fallback is to move both the pin and the reaction pass into a `_process_internal` override on the rig. Do not change the existing pin behavior when adding the reaction pass — match it.
|
||||
|
||||
**Interaction with ragdoll:** clear `_impact_reactions` at the start of `_enter_ragdoll()`. Do not apply reactions during `RAGDOLL` or during the `RECOVERING` tween.
|
||||
|
||||
---
|
||||
|
||||
## 4. Changes to `PropBlock`
|
||||
|
||||
### 4.1 Extended `collided` signal
|
||||
|
||||
Change the existing signal signature:
|
||||
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
|
||||
|
||||
text
|
||||
|
||||
Existing listeners must be updated (the stage is the only one).
|
||||
|
||||
### 4.2 Contact normal extraction
|
||||
|
||||
In `_on_body_entered(body)`:
|
||||
|
||||
1. Query the body state: `var state := PhysicsServer2D.body_get_direct_state(get_rid())`.
|
||||
2. If the state is valid, iterate `state.get_contact_count()` and find the contact whose collider matches `body`.
|
||||
3. Read `state.get_contact_local_normal(i)` and `state.get_contact_local_position(i)` — these are the contact normal and position in the prop's local frame.
|
||||
4. Convert to world space via `global_transform` before emitting.
|
||||
5. If no matching contact is found (timing edge case), fall back to:
|
||||
- `contact_normal` = `(body.global_position - global_position).normalized()` (rough approximation)
|
||||
- `contact_position` = `global_position`
|
||||
|
||||
Emit `collided.emit(body, contact_normal, contact_position)`.
|
||||
|
||||
**Why not `body_entered` alone:** the `body_entered` signal gives the colliding body but not the contact normal. `PhysicsServer2D.body_get_direct_state()` exposes the contact list at the moment the signal fires, which is when we need it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Changes to `SandboxStage`
|
||||
|
||||
### 5.1 Remove the geometric feet-point test
|
||||
|
||||
Delete `_update_stickman_prop_collision()` and its call from `_physics_process()`. The physics-based path fully replaces it.
|
||||
|
||||
### 5.2 Event routing
|
||||
|
||||
When a stickman is placed (`_place_at()` → after spawning), connect its rig's `PropBlock`-related contacts. Since props are spawned independently and can collide with any stickman, the cleaner approach is:
|
||||
|
||||
- On `object_placed(node)`, if `node is StickmanRig`, iterate all existing `PropBlock` children of `World` and connect their `collided` signal to `_on_prop_collided(stickman_rig, prop, other, normal, position)` (via `.bind()`).
|
||||
- When a `PropBlock` is placed, iterate all existing `StickmanRig` children of `World` and connect the prop's `collided` to the same handler for each rig.
|
||||
|
||||
Alternatively, use a global registry: `PropBlock.collided` is a signal on the prop, and the stage listens to _all_ props. But the prop doesn't know about stickmen — the stage needs to dispatch based on `other`.
|
||||
|
||||
Simplest implementation:
|
||||
|
||||
- The stage maintains `_prop_blocks: Array[PropBlock]` (populated on place, cleaned on delete).
|
||||
- Every `PropBlock.collided(other, normal, pos)` fires → stage handler `_on_prop_collided(prop, other, normal, pos)`.
|
||||
- The handler resolves `other`:
|
||||
- If `other.has_meta("stickman_rig")` → `var rig = other.get_meta("stickman_rig")`, `var part = other.get_meta("part_name")`.
|
||||
- Otherwise → the collision is prop-vs-prop; **keep the existing prop-vs-prop behavior** (whatever the current system does with `collided` — probably a rule trigger with `collided` trigger type where the source and target are both props). Do not regress this path.
|
||||
|
||||
### 5.3 Stickman-prop event dispatch
|
||||
|
||||
When the handler resolves a stickman:
|
||||
|
||||
1. Compute a stable pair key: `"%d_%d" % [rig.get_instance_id(), prop.get_instance_id()]`.
|
||||
2. Check `_stickman_prop_collisions: Dictionary` (edge-trigger state). If the key is already present, do nothing (the pair is already "touching").
|
||||
3. Otherwise, mark the key, then fire the `collided` trigger for any rule whose `trigger.type == "collided"` and whose `trigger.source`/`trigger.target` match `(rig, prop)` or `(prop, rig)` (either order).
|
||||
4. Clear the key when the prop separates. Use `PropBlock.body_exited` if the prop has it — add a `body_exited` signal mirroring `body_entered` — OR poll the contact count each frame. Cleaner: extend `PropBlock` with `signal separated(other: Node)` fired from `body_exited`.
|
||||
5. **Also** dispatch the reaction: `rig.apply_collision_reaction(part, normal, prop.linear_velocity.length())`.
|
||||
|
||||
### 5.4 Reaction dispatch
|
||||
|
||||
The `apply_collision_reaction` call is on the stickman, not the prop. Pass:
|
||||
|
||||
- `part_name` (from metadata on the collision body)
|
||||
- `impact_direction` = `contact_normal` (world-space, from the prop's body state)
|
||||
- `impact_speed` = `prop.linear_velocity.length()` (used as a magnitude scale)
|
||||
|
||||
The rig maps these to bone offsets (see §6).
|
||||
|
||||
---
|
||||
|
||||
## 6. Reaction Details
|
||||
|
||||
### 6.1 Part → affected joints
|
||||
|
||||
| Hit part | Primary joint | Secondary joint |
|
||||
| ----------------- | -------------------------- | ------------------------------- |
|
||||
| `head` | `Head` (bone) | — |
|
||||
| `torso` | `Torso` (bone, small tilt) | — |
|
||||
| `left_upper_arm` | `LeftUpperArm` | — |
|
||||
| `left_lower_arm` | `LeftLowerArm` | `LeftUpperArm` (half strength) |
|
||||
| `right_upper_arm` | `RightUpperArm` | — |
|
||||
| `right_lower_arm` | `RightLowerArm` | `RightUpperArm` (half strength) |
|
||||
| `left_upper_leg` | `LeftUpperLeg` | — |
|
||||
| `left_lower_leg` | `LeftLowerLeg` | `LeftUpperLeg` (half strength) |
|
||||
| `right_upper_leg` | `RightUpperLeg` | — |
|
||||
| `right_lower_leg` | `RightLowerLeg` | `RightUpperLeg` (half strength) |
|
||||
|
||||
The secondary joint gets the same angle offset but a **weaker magnitude** (e.g. 0.4×) and a **shorter decay** (e.g. 0.25 s vs 0.4 s).
|
||||
|
||||
### 6.2 Impact direction → angle offset
|
||||
|
||||
The contact normal points from the prop toward the stickman's surface (or vice versa — sign depends on Godot's convention, verify during implementation).
|
||||
|
||||
Compute the angle offset per joint as a function of the impact normal in the joint's local frame:
|
||||
|
||||
1. Convert the world-space impact direction into the joint bone's local frame: `local_dir = bone.global_transform.basis_xform_inv(normal)`.
|
||||
2. The rotation direction is determined by `sign(local_dir.x)` — the sign of the direction the prop is pushing in the limb's local X.
|
||||
3. `angle_offset = REACTION_BASE_ANGLE * sign(local_dir.x) * (impact_speed / REACTION_REFERENCE_SPEED)` clamped to a max.
|
||||
|
||||
Constants:
|
||||
|
||||
- `REACTION_BASE_ANGLE := 0.35` (radians, ~20°) — the angle offset for a reference-speed impact.
|
||||
- `REACTION_REFERENCE_SPEED := 400.0` (px/s) — the impact speed that produces the base angle.
|
||||
- `REACTION_MAX_ANGLE := 0.6` (radians, ~34°) — cap.
|
||||
|
||||
If `local_dir.x` is near zero (a head-on impact along the bone axis), pick a default direction based on the joint side (left limbs push left, right limbs push right).
|
||||
|
||||
### 6.3 Decay tuning
|
||||
|
||||
| Joint | Decay (s) |
|
||||
| -------------------- | --------- |
|
||||
| Head | 0.5 |
|
||||
| Torso | 0.4 |
|
||||
| Upper limbs | 0.4 |
|
||||
| Lower limbs | 0.35 |
|
||||
| Secondary (adjacent) | 0.25 |
|
||||
|
||||
Decay is the time for `strength` to fall from 1.0 to 0.0. Shorter = snappier recovery.
|
||||
|
||||
### 6.4 What the reaction _is not_
|
||||
|
||||
- Not a physics force — it doesn't move the rig root, doesn't push the prop.
|
||||
- Not persistent — it fully decays.
|
||||
- Not additive over multiple impacts beyond `max(angle)` — a new impact replaces the existing angle if stronger, else takes the max and resets `strength = 1.0`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge Cases
|
||||
|
||||
| Case | Behavior |
|
||||
| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Prop rests on stickman's head | `body_entered` fires once; head recoils briefly; prop rolls off |
|
||||
| Prop hits two stickman parts in the same frame | Each contact fires separately; the edge-trigger dict dedupes to one `collided` rule fire per `(stickman, prop)` pair; reaction applies to both hit parts |
|
||||
| Walking stickman shoves a resting prop | `AnimatableBody2D` pushes the prop; contact fires once; both react |
|
||||
| Prop hits stickman during RECOVERING | Physics still bounces (bodies enabled); reaction is skipped (state is not `ANIMATED`) |
|
||||
| Prop is deleted | Existing cleanup runs; the edge-trigger entry is orphaned but harmless (key includes the prop's instance ID, which won't recur) |
|
||||
| Stickman is deleted | All 10 collision bodies freed with the rig; no orphaned listeners |
|
||||
| Recovering stickman re-enables bodies over a resting prop | `body_entered` fires — edge-trigger dedupes; optional settling timer if this feels wrong |
|
||||
| Left-facing stickman (`Master.scale.x = -1`) | Capsules and circles are symmetric; mirroring is a no-op visually; Godot may warn about non-uniform scale — tolerated |
|
||||
| Ragdoll | All 10 bodies disabled; ragdoll bodies handle physics; no reaction (reactions are an ANIMATED-only feature) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance Criteria
|
||||
|
||||
### 8.1 Physical bounce
|
||||
|
||||
- [ ] A prop thrown at an ANIMATED stickman **bounces off** and lands on the ground.
|
||||
- [ ] A prop thrown at the stickman's **arm, leg, torso, and head** individually bounces off each part.
|
||||
- [ ] A prop resting on the ground next to an ANIMATED stickman stays put.
|
||||
- [ ] A walking stickman **shoves** a resting prop out of the way.
|
||||
- [ ] In RAGDOLL mode, props no longer collide with the collision bodies; ragdoll bodies handle physics.
|
||||
- [ ] In RECOVERING mode, props still bounce off the recovering figure.
|
||||
|
||||
### 8.2 Collision event
|
||||
|
||||
- [ ] A prop visibly touching any part of the stickman fires the `collided` trigger.
|
||||
- [ ] The rule `When [Stickman A] collides with [Prop X] → [Action]` fires once per contact.
|
||||
- [ ] A prop bouncing off and re-colliding fires again.
|
||||
- [ ] Prop-vs-prop collisions still fire the `collided` trigger through the existing path.
|
||||
- [ ] No console errors on any collision.
|
||||
|
||||
### 8.3 Reaction
|
||||
|
||||
- [ ] A prop hitting the stickman's **lower arm** makes that arm flinch away from the impact and recover within ~0.4 s.
|
||||
- [ ] A prop hitting the **head** makes the head recoil and recover.
|
||||
- [ ] The flinch direction is **away from the impact** (matches the contact normal).
|
||||
- [ ] A gentle prop (slow speed) produces a **small** flinch; a fast prop produces a **large** flinch (capped at `REACTION_MAX_ANGLE`).
|
||||
- [ ] The walk animation continues uninterrupted — the limb is momentarily offset, then catches up.
|
||||
- [ ] Multiple hits in quick succession don't accumulate beyond the cap.
|
||||
- [ ] Reactions do **not** fire during `RAGDOLL` or `RECOVERING`.
|
||||
|
||||
### 8.4 Regression
|
||||
|
||||
- [ ] Existing rules (`arrived_at_waypoint`, `entered_area`, `action_finished`, `speech_finished`) still work.
|
||||
- [ ] Sequential action queues still run.
|
||||
- [ ] Ragdoll entry/exit and stand-up recovery still work.
|
||||
- [ ] No `.stk` format change.
|
||||
- [ ] No new warnings during normal play.
|
||||
- [ ] Performance remains acceptable with 5 stickmen and 10 props.
|
||||
|
||||
---
|
||||
|
||||
## 9. Files Changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `scripts/stickman_rig.gd` | Add 10 collision bodies + shapes; add `_set_collision_bodies_enabled()`; add `apply_collision_reaction()`; add `_apply_impact_reactions()`; hook the state toggles in `_enter_ragdoll()` / `_on_stand_up_finished()`. |
|
||||
| `scripts/prop_block.gd` | Extend `collided(other)` → `collided(other, normal, position)`; add `separated(other)` signal (from `body_exited`); extract contact normal via `PhysicsServer2D.body_get_direct_state()`. |
|
||||
| `scripts/sandbox_stage.gd` | Remove `_update_stickman_prop_collision()`; add `_on_prop_collided()` and `_on_prop_separated()` handlers; add `_stickman_prop_collisions` edge dict; dispatch rule trigger + `apply_collision_reaction()`. |
|
||||
|
||||
No new scenes. No `.tscn` changes. No `.stk` format change.
|
||||
|
||||
---
|
||||
|
||||
## 10. Notes for the Developer
|
||||
|
||||
- **Timing for reactions:** match the existing `_pin_mirrored_head_rotation()` pattern (called from `_physics_process`). If the offsets don't visibly appear, the fallback is `_process_internal` — but try the existing slot first; do not change the existing head-pin behavior.
|
||||
- **Contact normal sign:** Godot's `get_contact_local_normal(i)` returns the normal in the prop's local frame, pointing toward the collision surface. Verify the direction empirically — a push test with a resting prop should have the stickman limb flinch _away_ from the prop. Invert the sign if it goes the wrong way.
|
||||
- **`AnimatableBody2D` vs `StaticBody2D`:** `AnimatableBody2D` correctly transfers the kinematic body's velocity to RigidBody2D props. `StaticBody2D` does not — a walking stickman would let props clip through. Do not substitute.
|
||||
- **Left-mirror warning:** Godot 4 may emit "Non-uniform scale is not supported on CollisionShape2D" when `Master.scale.x = -1`. This is expected and does not affect correctness. Do not silence or work around it.
|
||||
- **Reaction scope:** reactions are a visual garnish, not a physics force. Keep them additive and decaying. Do not feed them back into the IK solver or the ragdoll.
|
||||
- **Reaction key:** use the rig's bone names (`"LeftUpperArm"`, `"Head"`, etc. — the ones in `BEND_JOINT_BONE_PATHS` and `Z_ORDER_BY_PROFILE`) for consistency.
|
||||
Reference in New Issue
Block a user