# 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/ (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" = `. - 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": int, "prop_id": int, "bodies": { : 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//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.