361 lines
20 KiB
Markdown
361 lines
20 KiB
Markdown
# Phase 4.5a — Collision Bodies, Physics Events, and Hit Reactions
|
|
|
|
Source plan: `docs/archive/plans/PHASE_4.5_COLLISION_BODIES.md`
|
|
|
|
## Scope and verified corrections
|
|
|
|
Implement the feature in `scripts/characters/stickman/stickman_rig.gd`, `scripts/props/prop_block.gd`, and
|
|
`scripts/editors/stage/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/architecture/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.
|