- 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.
20 KiB
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 / 2rotation. The existing ragdoll capsules do rotate because their rigid-body frame is deliberately+X.Body/Headis 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. Useabsf(_head_bone.position.y); the eight limb lengths come from the correspondingBone2D.lengthvalues. - 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
collidedrules are authored assource = StickmanRig,target = PropBlock. Preserve that ordering. Prop-originated non-stickman contacts keep the existingsource = prop,target = otherordering; 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 andget_contact_local_normal()is reported in the physics/world frame. Do not applyglobal_transformagain. 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:
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" = selfand"part_name" = <snake_case part key>. - stored in
_collision_bodies: Dictionaryas{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:
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:
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:
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:
_ready(): build proxies and enable them for initialANIMATEDstate._enter_ragdoll(): clear_impact_reactions, disable proxies before_build_ragdoll(), then retain the existing capture/build/hide/IK-disable/state-signal order._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 throughoutRECOVERING._on_stand_up_finished(): idempotently enable proxies before publishingANIMATED.snap_to_standing(): after restoring markers/body/IK, clear reactions and enable proxies before publishingANIMATED.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:
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:
- 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. - Direction is
signf(local_dir.x). Ifabsf(local_dir.x) <= EPSILON, use-1for left joints,+1for right joints, and+1for Head/Torso. Keep this as a single easily tunable branch; editor verification must confirm the visual sign. - Candidate angle is
direction * minf(REACTION_MAX_ANGLE, REACTION_BASE_ANGLE * impact_speed / REACTION_REFERENCE_SPEED) * weight. _impact_reactions[joint]stores{"angle": float, "strength": 1.0, "decay": float}. Compare angles by absolute magnitude (never signedmax). Replace angle/decay when the new magnitude is greater or equal; otherwise retain the stronger existing angle. Every accepted hit resets strength to1.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:
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:
- Build a fallback direction from prop center toward
(body as Node2D).global_positionand a fallback position ofglobal_position. - Query
PhysicsServer2D.body_get_direct_state(get_rid()). Match withstate.get_contact_collider_id(i) == body.get_instance_id()(object equality may be used as a second guard). - Read and normalize
state.get_contact_local_normal(i)and readstate.get_contact_local_position(i)directly. Both are already world-space in 2D; do not transform either value. - 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. - If no matching contact exists, emit the center-direction fallback (possibly zero) and fallback position. The rig safely ignores a zero direction.
- 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:
prop.collided.connect(_on_prop_collided.bind(prop))
prop.separated.connect(_on_prop_separated.bind(prop))
Bound-call signatures are therefore:
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:
{
"<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
otherhasstickman_ragdoll_body == true, return without a rule or reaction. -
If
otherhas validstickman_rigandpart_namemetadata, cast/validate the rig. If its state is RAGDOLL, return. Addother.get_instance_id()to the pair's body set. If it was new, callrig.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:{"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_rulesin 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
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:
- A spawned rig has ten
CollisionBodyproxies with correct metadata, layer/mask, shape type, radii, hanging-frame capsule orientation/centering, and fitted lengths. - Head collision center equals mounted head geometry's local bbox center and is visibly above
the driven origin; empty geometry uses
(0, -72). - LEFT mirroring leaves proxy count/shape sizes intact and yields no new normal-conversion error; ghost rigs have all proxies disabled.
- RAGDOLL disables proxies and clears reactions;
_start_recovery()re-enables after snap;snap_to_standing()restores them; RECOVERING->RAGDOLL disables again. PropBlockreports entered/exited payloads, direct-state position is not double-transformed, emitted normal is normalized and points prop->other, and fallback is safe.- 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. - Prop-prop contact preserves source prop/target other.
- 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.
- Ragdoll-body contact fires no rule/reaction. RECOVERING contact physically resolves and may
fire the rule, but
apply_collision_reaction()remains a no-op. - Reaction magnitude scales with speed, caps at
0.6, secondary weight is0.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. - 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:
- Run
--check-onlyfor the three modified scripts and the new test script with Godot 4.7.1. - Run the new headless suite and the directly relevant Phase 4c/ragdoll suites.
- 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.