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:
+282
-5
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user