Files
stickman/specs/phase-4.5-collision-bodies.md
T
ryan ce6bcecd00 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.
2026-09-13 10:31:41 -04:00

361 lines
20 KiB
Markdown

# Phase 4.5a — Collision Bodies, Physics Events, and Hit Reactions
Source plan: `plans/PHASE_4.5_COLLISION_BODIES.md`
## Scope and verified corrections
Implement the feature in `scripts/stickman_rig.gd`, `scripts/prop_block.gd`, and
`scripts/sandbox_stage.gd`. Add a focused headless regression script, but no scene, `.stk`,
registry, rule-payload, layout, or theme changes.
The implementation must correct these plan/code mismatches:
- `Body/*` mounted geometry is in the adapter's hanging frame: attachment at local origin,
segment along local `+Y`. Animated capsules therefore need no `-PI / 2` rotation. The
existing ragdoll capsules do rotate because their rigid-body frame is deliberately `+X`.
- `Body/Head` is driven from the neck/chin attachment, not the visible head center. The adapter
maps mounted head geometry above that origin (normally centered near local `(0, -72)`). A
circle at `(0, 0)` misses much of the face. Derive its local center from mounted geometry;
use `(0, -72)` only when no geometry is available.
- The torso's fitted length is not `Torso.length`. The adapter expresses it as the Head bone's
local Y displacement. Use `absf(_head_bone.position.y)`; the eight limb lengths come from the
corresponding `Bone2D.length` values.
- Disabling proxies on ragdoll entry and re-enabling them only after stand-up contradicts the
requested RECOVERING behavior. Re-enable after the recovery snap and before its tween, and
also restore them in `snap_to_standing()`.
- Existing `collided` rules are authored as `source = StickmanRig`, `target = PropBlock`.
Preserve that ordering. Prop-originated non-stickman contacts keep the existing
`source = prop`, `target = other` ordering; do not make rule matching bidirectional.
- One pair boolean is insufficient when a prop touches two part bodies. Track the active
collision-body instance IDs per `(rig, prop)` pair. Only an empty-to-nonempty transition fires
the rule, while each newly entered part may independently react.
- In Godot 2D, despite the method names, `get_contact_local_position()` is already a world-space
point and `get_contact_local_normal()` is reported in the physics/world frame. Do not apply
`global_transform` again. Normalize and orient the normal into the documented signal contract
below.
## Nodes and metadata
At `StickmanRig._ready()`, after the skeleton/body references and limb bones are resolved, build
one proxy under each existing visual part node:
```text
Body/<PartVisual> (existing Node2D/Line2D, RemoteTransform2D target)
└── CollisionBody: AnimatableBody2D
└── CollisionShape2D
```
`CollisionBody` contract:
- `collision_layer = 1`, `collision_mask = 1`, `sync_to_physics = true`.
- metadata `"stickman_rig" = self` and `"part_name" = <snake_case part key>`.
- stored in `_collision_bodies: Dictionary` as `{String: AnimatableBody2D}`.
- missing visual/bone nodes warn with the `StickmanRig:` prefix and skip only that part.
Use a single constant definition table so paths and shape families cannot drift:
```gdscript
const COLLISION_PARTS: Dictionary = {
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
"left_upper_arm": {"visual": "LeftUpperArm", "bone": "Torso/LeftUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
"left_lower_arm": {"visual": "LeftLowerArm", "bone": "Torso/LeftUpperArm/LeftLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
"right_upper_arm": {"visual": "RightUpperArm", "bone": "Torso/RightUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
"right_lower_arm": {"visual": "RightLowerArm", "bone": "Torso/RightUpperArm/RightLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
"left_upper_leg": {"visual": "LeftUpperLeg", "bone": "Torso/LeftUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
"left_lower_leg": {"visual": "LeftLowerLeg", "bone": "Torso/LeftUpperLeg/LeftLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
"right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
"right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
}
```
Constants:
```gdscript
const HEAD_COLLISION_RADIUS := 90.0
const TORSO_COLLISION_RADIUS := 20.0
const ARM_COLLISION_RADIUS := 12.0
const LEG_COLLISION_RADIUS := 14.0
const HEAD_COLLISION_FALLBACK_CENTER := Vector2(0.0, -72.0)
const COLLISION_LAYER := 1
```
Capsules use `height = fitted_length`, `radius = table radius`, `position = Vector2(0,
fitted_length * 0.5)`, `rotation = 0`. Store fitted values in `_collision_lengths: Dictionary`
for torso and the eight limbs. `CapsuleShape2D.height` is the total end-to-end height, so this
centering spans the attachment-to-far-end interval.
For the head only, `_mounted_visual_bounds_local(part: Node2D) -> Dictionary` recursively visits
the part and its `Line2D`/`Polygon2D` descendants, converts every geometry point back into
`part` local space, and returns `{"valid": bool, "rect": Rect2}`. Put the fixed-radius circle at
the valid rect center or `HEAD_COLLISION_FALLBACK_CENTER`. Geometry affects the center only; the
90 px approximation remains intentionally fixed.
When procedural ragdoll bodies are built, also set `"stickman_rig" = self`,
`"part_name" = entry["key"]`, and `"stickman_ragdoll_body" = true`. This lets the stage reject
ragdoll contacts explicitly instead of accidentally dispatching a wildcard prop event.
## `StickmanRig` API and state transitions
Add:
```gdscript
func set_collision_bodies_enabled(enabled: bool) -> void
func _set_collision_bodies_enabled(enabled: bool) -> void
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float) -> void
func _apply_impact_reactions(delta: float) -> void
func _mounted_visual_bounds_local(part: Node2D) -> Dictionary
```
The public enable method is a narrow stage/ghost hook and delegates to the private helper. The
helper sets both layer and mask to `COLLISION_LAYER` or `0`; it does not free/rebuild proxies.
`SandboxStage._configure_ghost_collision()` must call the public method with `false` for a
stickman ghost, otherwise the now-physical placement preview blocks props.
State order:
1. `_ready()`: build proxies and enable them for initial `ANIMATED` state.
2. `_enter_ragdoll()`: clear `_impact_reactions`, disable proxies before `_build_ragdoll()`, then
retain the existing capture/build/hide/IK-disable/state-signal order.
3. `_start_recovery()`: retain capture/destroy/reanchor/state/snap order; after
`_snap_skeleton_to_pose()` has re-shown and rearmed the puppet, enable proxies, then start
`_play_stand_up()`. They remain enabled throughout `RECOVERING`.
4. `_on_stand_up_finished()`: idempotently enable proxies before publishing `ANIMATED`.
5. `snap_to_standing()`: after restoring markers/body/IK, clear reactions and enable proxies
before publishing `ANIMATED`.
6. `RECOVERING -> RAGDOLL`: the existing cancellation calls `_enter_ragdoll()`, which disables
the live proxies again before creating the new ragdoll.
## Reaction contract
Resolve `_reaction_bones: Dictionary` in `_ready()` under stable keys `Head`, `Torso`,
`LeftUpperArm`, `LeftLowerArm`, `RightUpperArm`, `RightLowerArm`, `LeftUpperLeg`,
`LeftLowerLeg`, `RightUpperLeg`, and `RightLowerLeg`. These keys name actual bone nodes; do not
confuse them with the existing bend-group keys (`LeftArm`, `RightArm`, etc.).
Constants and mapping:
```gdscript
const REACTION_BASE_ANGLE := 0.35
const REACTION_REFERENCE_SPEED := 400.0
const REACTION_MAX_ANGLE := 0.6
const REACTION_LOCAL_X_EPSILON := 0.05
const REACTION_TARGETS: Dictionary = {
"head": [{"joint": "Head", "weight": 1.0, "decay": 0.50}],
"torso": [{"joint": "Torso", "weight": 0.5, "decay": 0.40}],
"left_upper_arm": [{"joint": "LeftUpperArm", "weight": 1.0, "decay": 0.40}],
"left_lower_arm": [{"joint": "LeftLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperArm", "weight": 0.4, "decay": 0.25}],
"right_upper_arm": [{"joint": "RightUpperArm", "weight": 1.0, "decay": 0.40}],
"right_lower_arm": [{"joint": "RightLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperArm", "weight": 0.4, "decay": 0.25}],
"left_upper_leg": [{"joint": "LeftUpperLeg", "weight": 1.0, "decay": 0.40}],
"left_lower_leg": [{"joint": "LeftLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperLeg", "weight": 0.4, "decay": 0.25}],
"right_upper_leg": [{"joint": "RightUpperLeg", "weight": 1.0, "decay": 0.40}],
"right_lower_leg": [{"joint": "RightLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperLeg", "weight": 0.4, "decay": 0.25}],
}
```
`apply_collision_reaction()` is a no-op unless `state == RigState.ANIMATED`, the part/joint is
known, `impact_direction` is nonzero, and `impact_speed > 0`. For each target:
1. Convert the world direction without translation using
`bone.global_transform.affine_inverse().basis_xform(impact_direction).normalized()`. The
affine inverse keeps the LEFT root reflection correct.
2. Direction is `signf(local_dir.x)`. If `absf(local_dir.x) <= EPSILON`, use `-1` for left
joints, `+1` for right joints, and `+1` for Head/Torso. Keep this as a single easily tunable
branch; editor verification must confirm the visual sign.
3. Candidate angle is `direction * minf(REACTION_MAX_ANGLE,
REACTION_BASE_ANGLE * impact_speed / REACTION_REFERENCE_SPEED) * weight`.
4. `_impact_reactions[joint]` stores `{"angle": float, "strength": 1.0, "decay": float}`.
Compare angles by absolute magnitude (never signed `max`). Replace angle/decay when the new
magnitude is greater or equal; otherwise retain the stronger existing angle. Every accepted
hit resets strength to `1.0`.
Call `_apply_impact_reactions(delta)` last in `_physics_process()`, immediately after
`_pin_mirrored_head_rotation()`. Iterate a duplicated key list so entries may be erased. For an
ANIMATED, valid bone, apply `bone.rotation += angle * strength`, then reduce strength by
`delta / maxf(decay, 0.001)` and erase at zero. Clear/skip outside ANIMATED. This ordering lets a
LEFT-facing head pin establish its baseline before the flinch and otherwise preserves the
current post-IK strategy. If runtime validation shows the modification stack overwrites this
slot, moving both the existing head pin and reaction pass to a later internal callback requires
separate approval; do not silently change skeleton timing.
## `PropBlock` physics-contact contract
Signals:
```gdscript
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
signal separated(other: Node)
```
In `_ready()`, connect both inherited `body_entered` and `body_exited`. Increase
`max_contacts_reported` from 8 to `MAX_CONTACTS_REPORTED := 32` so a contact involving terrain
and several of the ten proxy bodies still has a useful direct-state entry.
`_on_body_entered(body: Node) -> void`:
1. Build a fallback direction from prop center toward `(body as Node2D).global_position` and a
fallback position of `global_position`.
2. Query `PhysicsServer2D.body_get_direct_state(get_rid())`. Match with
`state.get_contact_collider_id(i) == body.get_instance_id()` (object equality may be used as
a second guard).
3. Read and normalize `state.get_contact_local_normal(i)` and read
`state.get_contact_local_position(i)` directly. Both are already world-space in 2D; do not
transform either value.
4. Orient a nonzero normal so `normal.dot(body_center - prop_center) >= 0`; the emitted contract
is therefore **world-space push direction from this prop toward the other body**, independent
of the engine's local-contact naming/sign. If centers coincide, retain the reported normal.
5. If no matching contact exists, emit the center-direction fallback (possibly zero) and
fallback position. The rig safely ignores a zero direction.
6. Emit `collided(body, normal, position)` once for the inherited entry edge.
`_on_body_exited(body: Node) -> void` only emits `separated(body)`.
## `SandboxStage` wiring and rule preservation
Keep the existing per-prop connection point in `_finalize_placed_node()`; no prop registry is
needed:
```gdscript
prop.collided.connect(_on_prop_collided.bind(prop))
prop.separated.connect(_on_prop_separated.bind(prop))
```
Bound-call signatures are therefore:
```gdscript
func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: PropBlock) -> void
func _on_prop_separated(other: Node, prop: PropBlock) -> void
func _on_rig_state_changed(new_state: int, rig: StickmanRig) -> void
func _purge_collision_pairs_for_ids(ids: Array[int]) -> void
```
Also connect `rig.state_changed` in `_finalize_placed_node()`. On `RAGDOLL`, purge every active
pair for that rig. In `delete_selected()`, purge selected rig/prop IDs before `queue_free()`.
Existing PLAY/EDIT transition clears remain in place. `_collision_pairs` is repurposed from
`{pair_key: bool}` to:
```gdscript
{
"<rig_id>:<prop_id>": {
"rig_id": int,
"prop_id": int,
"bodies": { <collision_body_instance_id>: true },
},
}
```
Delete `_update_stickman_prop_collision()` and only its call from
`SandboxStage._physics_process()`. Leave `_update_area_entry()` and all other event plumbing
unchanged.
Collision dispatch:
- If `other` has `stickman_ragdoll_body == true`, return without a rule or reaction.
- If `other` has valid `stickman_rig` and `part_name` metadata, cast/validate the rig. If its
state is RAGDOLL, return. Add `other.get_instance_id()` to the pair's body set. If it was new,
call `rig.apply_collision_reaction(part, normal, prop.linear_velocity.length())`, including
when another part is already active. Only when the set was previously empty call the existing
event engine with:
```gdscript
{"type": "collided", "source": rig, "target": prop,
"position": position, "action": {}}
```
This preserves authored stickman->prop rule IDs and existing ordered/all-matches rule
execution through `_handle_event()`; do not scan `_event_rules` in the new handler.
- For every other body, preserve the existing path exactly, except use the contact position:
`{"type":"collided", "source":prop, "target":other, "position":position, "action":{}}`.
This includes prop-prop ordering and its current duplicate/wildcard characteristics.
- On separation from a proxy, remove only that body ID. Erase the pair entry only after its body
set becomes empty. Non-stickman separations require no bookkeeping.
No new mode gate is added: the current PropBlock signal path is not PLAY-gated. Frozen EDIT-mode
props normally produce no impact, while the existing mode-transition clears prevent stale
stage bookkeeping. Changing physics-event mode semantics is outside this phase.
## Signal flow and application order
```text
RemoteTransform2D moves Body/<PartVisual>/CollisionBody
-> physics contact against PropBlock
-> PropBlock.body_entered
-> PropBlock.collided(other, world_normal, world_position)
-> SandboxStage._on_prop_collided(..., prop)
-> new rig/prop pair edge -> existing _handle_event(rig -> prop)
-> every new part edge -> StickmanRig.apply_collision_reaction(...)
-> next StickmanRig physics tick
existing momentum/rest/walk/speech/runner/head-pin order
-> _apply_impact_reactions(delta) last
```
## Cleanup and failure behavior
- Proxy nodes free with the rig; stage pair entries are proactively purged on deletion and
ragdoll entry, and defensively tolerate late exit signals.
- Deleting a prop retains existing authored-state and rule cleanup, plus pair purge.
- Invalid metadata, stale objects, missing nodes, missing direct state, and absent contact rows
are null-guarded; no crash and no rule with a fake rig.
- A prop touching two parts fires one rule until all parts separate, but each newly entered part
can flinch. Re-entering one part while another remains touching can flinch again without
re-firing the pair rule.
- Ragdoll bodies continue normal physical collision but never dispatch a stickman collision
rule or animated reaction.
## Layout/theme
None. Collision nodes are runtime-only and invisible. No editor controls, labels, menus, scene
nodes, or theme tokens are added.
## Validation
Add `tests/test_phase45_collision_bodies.gd`, following existing stage/rig headless suites.
Required deterministic checks:
1. A spawned rig has ten `CollisionBody` proxies with correct metadata, layer/mask, shape type,
radii, hanging-frame capsule orientation/centering, and fitted lengths.
2. Head collision center equals mounted head geometry's local bbox center and is visibly above
the driven origin; empty geometry uses `(0, -72)`.
3. LEFT mirroring leaves proxy count/shape sizes intact and yields no new normal-conversion
error; ghost rigs have all proxies disabled.
4. RAGDOLL disables proxies and clears reactions; `_start_recovery()` re-enables after snap;
`snap_to_standing()` restores them; RECOVERING->RAGDOLL disables again.
5. `PropBlock` reports entered/exited payloads, direct-state position is not double-transformed,
emitted normal is normalized and points prop->other, and fallback is safe.
6. Stickman contact emits an internal event with source rig/target prop/contact position. A
matching existing rule executes through `_handle_event`; reversed IDs do not match.
7. Prop-prop contact preserves source prop/target other.
8. Two part entries for one pair fire one rule but two reaction dispatches. First exit retains
the pair; last exit erases it; full separation then re-entry fires the rule again.
9. Ragdoll-body contact fires no rule/reaction. RECOVERING contact physically resolves and may
fire the rule, but `apply_collision_reaction()` remains a no-op.
10. Reaction magnitude scales with speed, caps at `0.6`, secondary weight is `0.4`, stronger
signed merge uses absolute magnitude, and decay erases state. Manual editor validation must
confirm the sign is visually away from left/right impacts and that animation catches up.
11. Existing `entered_area`, arrival, action-finished, speech-finished, reactive queue,
deletion-cleanup, and ragdoll/recovery tests remain green; exercise 5 rigs + 10 props for
obvious errors or unacceptable frame loss.
Developer validation order after approval:
1. Run `--check-only` for the three modified scripts and the new test script with Godot 4.7.1.
2. Run the new headless suite and the directly relevant Phase 4c/ragdoll suites.
3. Hand off for Godot Editor testing; do not update README/docs until the user confirms the
feature is working.
## Approval decisions / trade-offs
Approval authorizes these plan corrections: geometry-derived head center with fixed 90 px
radius; torso length from Head-bone displacement; RECOVERING re-enable before the tween;
source-rig/target-prop rule ordering; multi-body pair sets; world-space direct-contact values;
and a public collision-enable hook solely for ghost setup. It also accepts approximate
capsule/circle proxies rather than per-shape polygons and keeps reaction timing in the current
post-head-pin physics slot.
After editor verification, append these deferred trade-offs to
`docs/tech_debt_and_optimizations.md`: fixed proxy radii do not follow arbitrary mounted widths;
the head radius is fixed although its center follows geometry; signal-time direct state may use
the documented fallback on rare timing edges; contact speed is total prop speed rather than
normal relative speed/impulse; and the post-IK reaction slot remains engine-order sensitive.