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
+26 -3
View File
@@ -33,6 +33,7 @@ const CIRCLE_SEGMENTS: int = 48
const DEFAULT_FILL_COLOR := Color(0.55, 0.35, 0.15, 1.0)
const DEFAULT_OUTLINE_COLOR := Color(0.15, 0.08, 0.02, 1.0)
const DEFAULT_OUTLINE_WIDTH: float = 2.0
const MAX_CONTACTS_REPORTED := 32
# ---------------------------------------------------------------------------
# Exported properties
@@ -94,7 +95,8 @@ var _circle_shape: CircleShape2D
## Emitted when this prop's body makes contact with another physics body.
## `other` is the body that entered contact (Phase 4 trigger source).
signal collided(other: Node)
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
signal separated(other: Node)
# ---------------------------------------------------------------------------
# Lifecycle
@@ -106,13 +108,34 @@ func _ready() -> void:
_apply_style()
# Enable contact reporting so the body_entered signal fires (Phase 4).
contact_monitor = true
max_contacts_reported = 8
max_contacts_reported = MAX_CONTACTS_REPORTED
continuous_cd = RigidBody2D.CCD_MODE_CAST_SHAPE
if not body_entered.is_connected(_on_body_entered):
body_entered.connect(_on_body_entered)
if not body_exited.is_connected(_on_body_exited):
body_exited.connect(_on_body_exited)
func _on_body_entered(body: Node) -> void:
collided.emit(body)
var center: Vector2 = (body as Node2D).global_position if body is Node2D else global_position
var normal: Vector2 = (center - global_position).normalized()
var position: Vector2 = global_position
var direct_state := PhysicsServer2D.body_get_direct_state(get_rid())
if direct_state != null:
for index: int in direct_state.get_contact_count():
if direct_state.get_contact_collider_id(index) == body.get_instance_id():
var reported: Vector2 = direct_state.get_contact_local_normal(index)
if not reported.is_zero_approx():
normal = reported.normalized()
if normal.dot(center - global_position) < 0.0:
normal = -normal
position = direct_state.get_contact_local_position(index)
break
collided.emit(body, normal, position)
func _on_body_exited(body: Node) -> void:
separated.emit(body)
# ---------------------------------------------------------------------------
# Internal build / apply
+77 -29
View File
@@ -419,7 +419,6 @@ func _physics_process(_delta: float) -> void:
_restore_authored_state()
if current_mode == StageMode.PLAY:
_update_area_entry()
_update_stickman_prop_collision()
# ---------------------------------------------------------------------------
# Input
@@ -682,11 +681,14 @@ func _enter_play_mode() -> void:
func _save_object_state(node: Node2D) -> void:
_authored[node.get_instance_id()] = {
var entry := {
"node": node,
"position": node.global_position,
"rotation": node.global_rotation,
}
if node is STICKMAN_RIG:
entry["facing_profile"] = (node as STICKMAN_RIG).get_facing_profile()
_authored[node.get_instance_id()] = entry
func _clear_object_state(node: Node2D) -> void:
@@ -702,6 +704,8 @@ func _restore_authored_state() -> void:
continue
node.global_position = entry.get("position", node.global_position)
node.global_rotation = entry.get("rotation", node.global_rotation)
if node is STICKMAN_RIG and entry.has("facing_profile"):
(node as STICKMAN_RIG).set_facing_profile(int(entry["facing_profile"]))
if node is RigidBody2D:
var body := node as RigidBody2D
body.linear_velocity = Vector2.ZERO
@@ -753,10 +757,12 @@ func _finalize_placed_node(node: Node2D) -> void:
rig.arrived.connect(_on_rig_arrived.bind(rig))
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
rig.state_changed.connect(_on_rig_state_changed.bind(rig))
_director_visuals.mark_dirty()
if node is PropBlock:
var prop := node as PropBlock
prop.collided.connect(_on_prop_collided.bind(prop))
prop.separated.connect(_on_prop_separated.bind(prop))
func _spawn_ghost() -> void:
@@ -797,6 +803,7 @@ func _configure_ghost_collision(ghost: Node2D) -> void:
var anim := rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
if anim != null:
anim.stop()
rig.set_collision_bodies_enabled(false)
func _free_ghost() -> void:
@@ -1099,6 +1106,8 @@ func delete_selected() -> void:
if node is TerrainBlock:
nav_changed = true
_clear_object_state(node)
var deleted_ids: Array[int] = [node.get_instance_id()]
_purge_collision_pairs_for_ids(deleted_ids)
node.queue_free()
_rebuild_grid_cells()
_cleanup_rules_for_nodes(selected)
@@ -2399,8 +2408,72 @@ func _on_rig_speech_finished(rig: StickmanRig) -> void:
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
func _on_prop_collided(other: Node, prop: PropBlock) -> void:
_handle_event({ "type": "collided", "source": prop, "target": other, "position": prop.global_position, "action": {} })
func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: PropBlock) -> void:
if other == null or not is_instance_valid(other):
return
if bool(other.get_meta("stickman_ragdoll_body", false)):
return
var has_rig_metadata := other.has_meta("stickman_rig") or other.has_meta("part_name")
var rig := other.get_meta("stickman_rig") as StickmanRig if other.has_meta("stickman_rig") else null
var part := String(other.get_meta("part_name")) if other.has_meta("part_name") else ""
if rig == null or not is_instance_valid(rig) or part.is_empty():
if has_rig_metadata:
return
_handle_event({ "type": "collided", "source": prop, "target": other, "position": position, "action": {} })
return
if rig.state == StickmanRig.RigState.RAGDOLL:
return
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var pair: Dictionary = _collision_pairs.get(key, {
"rig_id": rig.get_instance_id(),
"prop_id": prop.get_instance_id(),
"bodies": {},
})
var bodies: Dictionary = pair.get("bodies", {})
var body_id := other.get_instance_id()
if bodies.has(body_id):
return
var first_contact := bodies.is_empty()
bodies[body_id] = true
pair["bodies"] = bodies
_collision_pairs[key] = pair
rig.apply_collision_reaction(part, normal, prop.linear_velocity.length(), position)
if first_contact:
_handle_event({ "type": "collided", "source": rig, "target": prop, "position": position, "action": {} })
func _on_prop_separated(other: Node, prop: PropBlock) -> void:
if other == null or not is_instance_valid(other):
return
if not other.has_meta("stickman_rig"):
return
var rig := other.get_meta("stickman_rig") as StickmanRig
if rig == null or not is_instance_valid(rig):
return
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var pair: Dictionary = _collision_pairs.get(key, {})
if pair.is_empty():
return
var bodies: Dictionary = pair.get("bodies", {})
bodies.erase(other.get_instance_id())
if bodies.is_empty():
_collision_pairs.erase(key)
else:
pair["bodies"] = bodies
_collision_pairs[key] = pair
func _on_rig_state_changed(new_state: int, rig: StickmanRig) -> void:
if new_state == StickmanRig.RigState.RAGDOLL:
var rig_ids: Array[int] = [rig.get_instance_id()]
_purge_collision_pairs_for_ids(rig_ids)
func _purge_collision_pairs_for_ids(ids: Array[int]) -> void:
for key: String in _collision_pairs.keys().duplicate():
var pair: Dictionary = _collision_pairs[key]
if ids.has(int(pair.get("rig_id", -1))) or ids.has(int(pair.get("prop_id", -1))):
_collision_pairs.erase(key)
## Iterates every rule in order; every matching rule executes (no short-circuit).
@@ -2501,31 +2574,6 @@ func _update_area_entry() -> void:
_area_overlap[key] = false
## Geometric stickman-vs-prop collision (ANIMATED rigs x unfrozen props),
## edge-triggered via the prop's world AABB containing the rig's feet/root point.
func _update_stickman_prop_collision() -> void:
var rigs: Array[StickmanRig] = []
var props: Array[PropBlock] = []
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
if rig.state == StickmanRig.RigState.ANIMATED:
rigs.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze:
props.append(node as PropBlock)
for rig: StickmanRig in rigs:
for prop: PropBlock in props:
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var feet: Vector2 = rig.global_position - StickmanRig.FOOT_OFFSET
var overlap: bool = STAGE_SELECTION.get_world_aabb(prop).has_point(feet)
var was: bool = bool(_collision_pairs.get(key, false))
if overlap and not was:
_collision_pairs[key] = true
_handle_event({ "type": "collided", "source": rig, "target": prop, "position": rig.global_position, "action": {} })
elif not overlap and was:
_collision_pairs[key] = false
## Stickman point = feet (root - FOOT_OFFSET); prop point = global position.
func _movable_point(node: Node2D) -> Vector2:
if node is STICKMAN_RIG:
+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"