- 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.
23 KiB
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:
- 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.
- Collision events fire from real physics contact — replace the feet-point geometric test with the contact signal from the prop's
RigidBody2D, so acollidedtrigger fires whenever a prop visibly touches the stickman. - 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:
- Maps the hit part to affected joints (see §6.1).
- Converts
impact_directionandimpact_speedinto an angle offset per joint (see §6.2). - 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):
- Query the body state:
var state := PhysicsServer2D.body_get_direct_state(get_rid()). - If the state is valid, iterate
state.get_contact_count()and find the contact whose collider matchesbody. - Read
state.get_contact_local_normal(i)andstate.get_contact_local_position(i)— these are the contact normal and position in the prop's local frame. - Convert to world space via
global_transformbefore emitting. - 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), ifnode is StickmanRig, iterate all existingPropBlockchildren ofWorldand connect theircollidedsignal to_on_prop_collided(stickman_rig, prop, other, normal, position)(via.bind()). - When a
PropBlockis placed, iterate all existingStickmanRigchildren ofWorldand connect the prop'scollidedto 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 withcollidedtrigger type where the source and target are both props). Do not regress this path.
- If
5.3 Stickman-prop event dispatch
When the handler resolves a stickman:
- Compute a stable pair key:
"%d_%d" % [rig.get_instance_id(), prop.get_instance_id()]. - Check
_stickman_prop_collisions: Dictionary(edge-trigger state). If the key is already present, do nothing (the pair is already "touching"). - Otherwise, mark the key, then fire the
collidedtrigger for any rule whosetrigger.type == "collided"and whosetrigger.source/trigger.targetmatch(rig, prop)or(prop, rig)(either order). - Clear the key when the prop separates. Use
PropBlock.body_exitedif the prop has it — add abody_exitedsignal mirroringbody_entered— OR poll the contact count each frame. Cleaner: extendPropBlockwithsignal separated(other: Node)fired frombody_exited. - 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:
- Convert the world-space impact direction into the joint bone's local frame:
local_dir = bone.global_transform.basis_xform_inv(normal). - 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. 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 resetsstrength = 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
collidedtrigger. - 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
collidedtrigger 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
RAGDOLLorRECOVERING.
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
.stkformat 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. AnimatableBody2DvsStaticBody2D:AnimatableBody2Dcorrectly transfers the kinematic body's velocity to RigidBody2D props.StaticBody2Ddoes 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 inBEND_JOINT_BONE_PATHSandZ_ORDER_BY_PROFILE) for consistency.