Files
stickman/docs/specs/phase-4.5-collision-bodies.md
T

20 KiB

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:

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:

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:

  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:

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:

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:

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 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:

    {"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

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.