Implement collision bodies, physics events, and hit reactions for StickmanRig

- 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.
This commit is contained in:
2026-09-13 10:31:41 -04:00
parent 04e0f1bea2
commit ce6bcecd00
12 changed files with 1698 additions and 58 deletions
+282 -5
View File
@@ -100,6 +100,52 @@ const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
const RAGDOLL_LIMB_RADIUS := 8.0
const RAGDOLL_TORSO_RADIUS := 12.0
const RAGDOLL_HEAD_RADIUS := 100.0
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
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},
}
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},
],
}
## Visual mesh colors: the rig's authored part color (gray Line2D limbs) and the
## head's filled white circle. Collision shapes are invisible in-game, so each
@@ -415,6 +461,11 @@ var _pre_ragdoll_state: Dictionary = {} # captured at _enter_ragdoll; recov
var _probe_top_y: float = 0.0 # ground-probe context retained for the ankle clamp
var _probe_bottom_y: float = 0.0
var _probe_exclude: Array[RID] = []
var _collision_bodies: Dictionary = {}
var _collision_lengths: Dictionary = {}
var _collision_visuals: Dictionary = {}
var _reaction_bones: Dictionary = {}
var _impact_reactions: Dictionary = {}
# ---------------------------------------------------------------------------
# Director / navigation state (Phase 3a)
@@ -479,6 +530,8 @@ func _ready() -> void:
_resolve_bend_modifications()
_resolve_limb_bones()
_resolve_reaction_bones()
_build_collision_bodies()
# Enable the modification stack (IK solves only at runtime).
if _skeleton != null:
@@ -527,6 +580,8 @@ func _physics_process(delta: float) -> void:
_update_speech(delta)
_update_runner(delta)
_pin_mirrored_head_rotation()
_apply_impact_reactions(delta)
_sync_collision_bodies()
@@ -676,25 +731,30 @@ func request_recovery() -> void:
## tween. Used by the sandbox stage so a stickman "reappears" at its starting
## position/state on return to EDIT (instead of animating the recovery glide).
func snap_to_standing() -> void:
if state == RigState.ANIMATED:
return
var previous_state := state
if state == RigState.RAGDOLL:
_destroy_ragdoll()
else:
elif state == RigState.RECOVERING:
_cancel_recovery()
_clear_impact_reactions(true)
_restore_standing_markers()
# Re-show the kinematic puppet and re-enable IK.
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
_set_limb_mods_enabled(true)
_restore_head_lookat_state()
if previous_state == RigState.ANIMATED:
_apply_head_lookat_mirror_mode()
else:
_restore_head_lookat_state()
_recovery_bones.clear()
_set_collision_bodies_enabled(true)
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
state = RigState.ANIMATED
state_changed.emit(int(state))
if previous_state != RigState.ANIMATED:
state_changed.emit(int(state))
## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change).
@@ -776,6 +836,216 @@ func _resolve_limb_bones() -> void:
continue
_limb_bones[joint] = { "upper": upper, "lower": lower }
func _resolve_reaction_bones() -> void:
_reaction_bones.clear()
if _skeleton == null:
return
for key: String in COLLISION_PARTS:
var definition: Dictionary = COLLISION_PARTS[key]
var bone := _skeleton.get_node_or_null(NodePath(str(definition["bone"]))) as Bone2D
if bone == null or not is_instance_valid(bone):
push_warning("StickmanRig: missing reaction bone '%s'." % str(definition["bone"]))
continue
var joint_name := "Torso" if str(definition["visual"]) == "Body" else str(definition["visual"])
_reaction_bones[joint_name] = bone
func _build_collision_bodies() -> void:
_collision_bodies.clear()
_collision_lengths.clear()
_collision_visuals.clear()
if _body_container == null or _skeleton == null:
return
for key: String in COLLISION_PARTS:
var definition: Dictionary = COLLISION_PARTS[key]
var visual := _body_container.get_node_or_null(NodePath(str(definition["visual"]))) as Node2D
var bone := _skeleton.get_node_or_null(NodePath(str(definition["bone"]))) as Bone2D
if visual == null or bone == null:
push_warning("StickmanRig: missing collision visual or bone for '%s'." % key)
continue
var proxy := AnimatableBody2D.new()
proxy.name = "CollisionBody"
proxy.collision_layer = COLLISION_LAYER
proxy.collision_mask = COLLISION_LAYER
# RemoteTransform2D moves the Body/* visual after the physics body's own
# synchronization step. Letting a child AnimatableBody2D synchronize
# itself makes Godot counter-transform it, leaving capsules upright at
# stale world positions. Keep the proxy top-level and copy the rendered
# visual transform explicitly once per physics frame instead.
proxy.sync_to_physics = false
proxy.set_meta("stickman_rig", self)
proxy.set_meta("part_name", key)
var shape_node := CollisionShape2D.new()
shape_node.name = "CollisionShape2D"
if str(definition["shape"]) == "circle":
var circle := CircleShape2D.new()
var bounds := _mounted_visual_bounds_local(visual)
if bool(bounds["valid"]):
var rect := bounds["rect"] as Rect2
shape_node.position = rect.get_center()
circle.radius = maxf(float(definition["radius"]), maxf(rect.size.x, rect.size.y) * 0.5)
else:
shape_node.position = HEAD_COLLISION_FALLBACK_CENTER
circle.radius = float(definition["radius"])
shape_node.shape = circle
else:
var length: float = absf(_head_bone.position.y) if key == "torso" and _head_bone != null else bone.length
_collision_lengths[key] = length
var capsule := CapsuleShape2D.new()
capsule.height = length
capsule.radius = float(definition["radius"])
shape_node.shape = capsule
shape_node.position = Vector2(0.0, length * 0.5)
proxy.add_child(shape_node)
visual.add_child(proxy)
proxy.top_level = true
proxy.global_transform = visual.global_transform
_collision_bodies[key] = proxy
_collision_visuals[key] = visual
func _sync_collision_bodies() -> void:
for key: String in _collision_bodies:
var proxy := _collision_bodies.get(key) as AnimatableBody2D
var visual := _collision_visuals.get(key) as Node2D
if proxy == null or visual == null or not is_instance_valid(proxy) or not is_instance_valid(visual):
continue
proxy.global_transform = visual.global_transform
func _mounted_visual_bounds_local(part: Node2D) -> Dictionary:
var points: Array[Vector2] = []
var nodes: Array[Node] = [part]
while not nodes.is_empty():
var child: Node = nodes.pop_back()
if child is Line2D:
for point: Vector2 in (child as Line2D).points:
points.append(part.to_local((child as Node2D).to_global(point)))
elif child is Polygon2D:
for point: Vector2 in (child as Polygon2D).polygon:
points.append(part.to_local((child as Node2D).to_global(point)))
for grandchild: Node in child.get_children():
nodes.append(grandchild)
if points.is_empty():
return {"valid": false, "rect": Rect2()}
var rect := Rect2(points[0], Vector2.ZERO)
for point: Vector2 in points:
rect = rect.expand(point)
return {"valid": true, "rect": rect}
func set_collision_bodies_enabled(enabled: bool) -> void:
_set_collision_bodies_enabled(enabled)
func _set_collision_bodies_enabled(enabled: bool) -> void:
var layer := COLLISION_LAYER if enabled else 0
for proxy: AnimatableBody2D in _collision_bodies.values():
if proxy != null and is_instance_valid(proxy):
proxy.collision_layer = layer
proxy.collision_mask = layer
func apply_collision_reaction(
part_name: String,
impact_direction: Vector2,
impact_speed: float,
impact_position: Vector2 = Vector2.INF
) -> void:
if state != RigState.ANIMATED or impact_direction.is_zero_approx() or impact_speed <= 0.0:
return
for target: Dictionary in REACTION_TARGETS.get(part_name, []):
var joint := str(target["joint"])
var bone := _reaction_bones.get(joint) as Bone2D
if bone == null or not is_instance_valid(bone):
continue
var direction := _collision_contact_direction(part_name, impact_position, impact_direction)
if is_zero_approx(direction):
var local_dir := bone.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
direction = signf(local_dir.x) if absf(local_dir.x) > REACTION_LOCAL_X_EPSILON else 0.0
if is_zero_approx(direction):
direction = -1.0 if joint.begins_with("Left") else 1.0
var angle := direction * minf(
REACTION_MAX_ANGLE,
REACTION_BASE_ANGLE * impact_speed / REACTION_REFERENCE_SPEED
) * float(target["weight"])
var old: Dictionary = _impact_reactions.get(joint, {})
if old.is_empty():
_impact_reactions[joint] = {
"angle": angle,
"strength": 1.0,
"decay": float(target["decay"]),
"base_rotation": bone.rotation,
}
else:
if absf(angle) >= absf(float(old.get("angle", 0.0))):
old["angle"] = angle
old["decay"] = float(target["decay"])
old["strength"] = 1.0
_impact_reactions[joint] = old
func _collision_contact_direction(
part_name: String,
impact_position: Vector2,
impact_direction: Vector2
) -> float:
if not impact_position.is_finite():
return 0.0
var proxy := _collision_bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
return 0.0
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null:
return 0.0
# Proxies use the hanging frame, so local X identifies which side was hit.
# A downward hit on the upper-left must rotate opposite to an upward hit on
# the lower-left. Combining the side with the force's local Y component
# supplies that top/bottom inversion and remains mirror-correct.
var local_hit := proxy.to_local(impact_position)
var side := local_hit.x - shape_node.position.x
if absf(side) <= REACTION_LOCAL_X_EPSILON:
return 0.0
var local_force := proxy.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
if absf(local_force.y) > REACTION_LOCAL_X_EPSILON:
return signf(side * local_force.y)
# A nearly horizontal impact has no useful top/bottom component; retain the
# established side-only response for that case.
return signf(side)
func _clear_impact_reactions(restore_bones: bool = false) -> void:
if restore_bones:
for joint: String in _impact_reactions:
var reaction: Dictionary = _impact_reactions[joint]
var bone := _reaction_bones.get(joint) as Bone2D
if bone != null and is_instance_valid(bone) and reaction.has("base_rotation"):
bone.rotation = float(reaction["base_rotation"])
_impact_reactions.clear()
func _apply_impact_reactions(delta: float) -> void:
if state != RigState.ANIMATED:
_clear_impact_reactions()
return
for joint: String in _impact_reactions.keys().duplicate():
var reaction: Dictionary = _impact_reactions[joint]
var bone := _reaction_bones.get(joint) as Bone2D
if bone == null or not is_instance_valid(bone):
_impact_reactions.erase(joint)
continue
# Always pose relative to the rotation captured at impact. Adding the
# whole offset every frame accumulates several complete revolutions during
# a 0.5 s head flinch and bypasses the intended angular cap.
var base_rotation := float(reaction.get("base_rotation", bone.rotation))
bone.rotation = base_rotation + float(reaction["angle"]) * float(reaction["strength"])
reaction["strength"] = float(reaction["strength"]) - delta / maxf(float(reaction["decay"]), 0.001)
if float(reaction["strength"]) <= 0.0:
bone.rotation = base_rotation
_impact_reactions.erase(joint)
else:
_impact_reactions[joint] = reaction
## Writes the four per-joint bend vars from PROFILE_FLAGS (through their setters,
## so the live mods stay in sync), reorders Body/* children, then emits
@@ -923,6 +1193,8 @@ func _enter_ragdoll() -> void:
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
return
_capture_pre_ragdoll_state()
_impact_reactions.clear()
_set_collision_bodies_enabled(false)
_cancel_walking()
_ragdoll_at_rest = false
# Instant handoff: stop the player without resetting it (keep_state) and
@@ -1032,6 +1304,7 @@ func _start_recovery() -> void:
state = RigState.RECOVERING
state_changed.emit(int(state))
_snap_skeleton_to_pose()
_set_collision_bodies_enabled(true)
_play_stand_up()
@@ -1473,6 +1746,7 @@ func _on_stand_up_finished() -> void:
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
_set_collision_bodies_enabled(true)
state = RigState.ANIMATED
state_changed.emit(int(state))
@@ -1513,6 +1787,9 @@ func _build_ragdoll_body(entry: Dictionary) -> void:
body.freeze = false
body.collision_layer = 1
body.collision_mask = 1
body.set_meta("stickman_rig", self)
body.set_meta("part_name", key)
body.set_meta("stickman_ragdoll_body", true)
var shape := CollisionShape2D.new()
shape.name = "CollisionShape2D"