feat: Implement kinematic-to-ragdoll transition system
- Added KINEMATIC_BLENDING_AND_RECOVERY.md to outline features for smooth transitions between kinematic and ragdoll states, including visual and physical blending, and ragdoll recovery. - Introduced KINEMATIC_TO_RAGDOLL.md detailing the objectives, scope, and core architecture for transitioning the stickman from kinematic to ragdoll mode. - Created KINEMATIC_TO_RAGDOLL_SPEC.md as an implementation specification, verifying codebase facts and correcting the initial plan based on Godot 4.4 source. - Enhanced StickmanRig with state management for animated and ragdoll modes, including momentum preservation and ragdoll construction. - Updated physics_test_harness to support toggling between kinematic and ragdoll states with user input.
This commit is contained in:
+106
-17
@@ -4,8 +4,9 @@ extends Node2D
|
||||
##
|
||||
## Builds flat ground, an angled ramp and stepped terrain via the TerrainUtils
|
||||
## factory, spawns a master_rig.tscn instance standing on the flat ground, and
|
||||
## provides camera zoom/pan input. NOT wired into the editor — run standalone
|
||||
## via F6 on res://scenes/physics_test_harness.tscn.
|
||||
## provides camera zoom/pan input plus a top-bar UI (spawn buttons, a
|
||||
## Stickman/Ragdoll mode toggle, and a "Knock Up" force button). NOT wired into
|
||||
## the editor — run standalone via F6 on res://scenes/physics_test_harness.tscn.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -32,6 +33,9 @@ const RIG_SPAWN_POSITION := Vector2(0.0, -385.0)
|
||||
## Spawn point for dynamic props: above the angled ramp so they tumble down.
|
||||
const PROP_SPAWN_POSITION := Vector2(300.0, -300.0)
|
||||
|
||||
## Upward velocity delta applied by the "Knock Up" button (negative Y = up).
|
||||
const KNOCK_UP_VELOCITY := Vector2(0.0, -450.0)
|
||||
|
||||
## Best-effort collision proxy for the rig (which has no physics bodies): a
|
||||
## static box matching the standing figure's world bounds (x ±120, y 0..-1000).
|
||||
const RIG_PROXY_SIZE := Vector2(240.0, 1000.0)
|
||||
@@ -51,6 +55,13 @@ const RIG_PROXY_CENTER := Vector2(0.0, -500.0)
|
||||
var _is_panning: bool = false
|
||||
var _pan_last: Vector2 = Vector2.ZERO
|
||||
|
||||
## The spawned rig instance (StickmanRig). Stored so the mode toggle can
|
||||
## switch between Stickman (animated) and Ragdoll modes.
|
||||
var _rig: StickmanRig = null
|
||||
|
||||
## Top-bar mode toggle button (text flips "Stickman" <-> "Ragdoll").
|
||||
var _ragdoll_toggle: Button = null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -59,6 +70,7 @@ func _ready() -> void:
|
||||
_camera.make_current()
|
||||
_build_environment()
|
||||
_spawn_rig()
|
||||
_build_ui()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input (camera zoom / pan)
|
||||
@@ -69,20 +81,6 @@ func _input(event: InputEvent) -> void:
|
||||
_handle_mouse_button(event as InputEventMouseButton)
|
||||
elif event is InputEventMouseMotion:
|
||||
_handle_mouse_motion(event as InputEventMouseMotion)
|
||||
elif event is InputEventKey:
|
||||
_handle_key(event as InputEventKey)
|
||||
|
||||
|
||||
func _handle_key(key: InputEventKey) -> void:
|
||||
if not key.pressed or key.echo:
|
||||
return
|
||||
match key.keycode:
|
||||
KEY_1:
|
||||
_spawn_prop_crate()
|
||||
KEY_2:
|
||||
_spawn_prop_ball()
|
||||
KEY_3:
|
||||
_spawn_prop_plank()
|
||||
|
||||
|
||||
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
|
||||
@@ -107,6 +105,80 @@ func _set_zoom(value: float) -> void:
|
||||
var z := clampf(value, MIN_ZOOM, MAX_ZOOM)
|
||||
_camera.zoom = Vector2(z, z)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Top-bar UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _build_ui() -> void:
|
||||
var ui := CanvasLayer.new()
|
||||
ui.name = "UI"
|
||||
add_child(ui)
|
||||
|
||||
var top_bar := PanelContainer.new()
|
||||
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||||
top_bar.offset_bottom = 40.0
|
||||
ui.add_child(top_bar)
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 8)
|
||||
top_bar.add_child(hbox)
|
||||
|
||||
var crate_btn := Button.new()
|
||||
crate_btn.text = "Spawn Crate"
|
||||
crate_btn.pressed.connect(_spawn_prop_crate)
|
||||
hbox.add_child(crate_btn)
|
||||
|
||||
var ball_btn := Button.new()
|
||||
ball_btn.text = "Spawn Ball"
|
||||
ball_btn.pressed.connect(_spawn_prop_ball)
|
||||
hbox.add_child(ball_btn)
|
||||
|
||||
var plank_btn := Button.new()
|
||||
plank_btn.text = "Spawn Plank"
|
||||
plank_btn.pressed.connect(_spawn_prop_plank)
|
||||
hbox.add_child(plank_btn)
|
||||
|
||||
_ragdoll_toggle = Button.new()
|
||||
_ragdoll_toggle.toggle_mode = true
|
||||
_ragdoll_toggle.toggled.connect(_on_ragdoll_toggled)
|
||||
hbox.add_child(_ragdoll_toggle)
|
||||
_update_ragdoll_toggle()
|
||||
|
||||
var knock_btn := Button.new()
|
||||
knock_btn.text = "Knock Up"
|
||||
knock_btn.pressed.connect(_knock_up)
|
||||
hbox.add_child(knock_btn)
|
||||
|
||||
|
||||
func _on_ragdoll_toggled(pressed: bool) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
_rig.set_ragdoll(pressed)
|
||||
if pressed:
|
||||
_remove_rig_collision_proxy()
|
||||
else:
|
||||
_add_rig_collision_proxy()
|
||||
_update_ragdoll_toggle()
|
||||
|
||||
|
||||
func _update_ragdoll_toggle() -> void:
|
||||
if _ragdoll_toggle == null or _rig == null:
|
||||
return
|
||||
var in_ragdoll: bool = _rig.is_in_ragdoll()
|
||||
_ragdoll_toggle.set_pressed_no_signal(in_ragdoll)
|
||||
_ragdoll_toggle.text = "Ragdoll" if in_ragdoll else "Stickman"
|
||||
|
||||
|
||||
func _knock_up() -> void:
|
||||
# Ragdoll: boost every ragdoll body upward (preserves internal structure).
|
||||
if _rig != null and _rig.is_in_ragdoll():
|
||||
_rig.apply_ragdoll_velocity_boost(KNOCK_UP_VELOCITY)
|
||||
# Dynamic props: same upward delta so everything flies together.
|
||||
for child: Node in _environment.get_children():
|
||||
if child is RigidBody2D and is_instance_valid(child):
|
||||
var body := child as RigidBody2D
|
||||
body.apply_central_impulse(KNOCK_UP_VELOCITY * body.mass)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environment / rig construction
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -144,12 +216,13 @@ func _build_environment() -> void:
|
||||
|
||||
|
||||
func _spawn_rig() -> void:
|
||||
var rig := RIG_SCENE.instantiate() as Node2D
|
||||
var rig := RIG_SCENE.instantiate() as StickmanRig
|
||||
if rig == null:
|
||||
push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.")
|
||||
return
|
||||
rig.position = RIG_SPAWN_POSITION
|
||||
add_child(rig)
|
||||
_rig = rig
|
||||
_add_rig_collision_proxy()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -189,6 +262,9 @@ func _spawn_prop_plank() -> void:
|
||||
## The rig has no physics bodies, so a code-only StaticBody2D proxy provides a
|
||||
## collision surface matching its standing bounds. Props bounce/rest against it.
|
||||
func _add_rig_collision_proxy() -> void:
|
||||
if _find_rig_collision_proxy() != null:
|
||||
return
|
||||
|
||||
var proxy := StaticBody2D.new()
|
||||
proxy.name = "RigCollisionProxy"
|
||||
proxy.position = RIG_PROXY_CENTER
|
||||
@@ -201,3 +277,16 @@ func _add_rig_collision_proxy() -> void:
|
||||
proxy.add_child(shape)
|
||||
|
||||
add_child(proxy)
|
||||
|
||||
|
||||
func _remove_rig_collision_proxy() -> void:
|
||||
var proxy := _find_rig_collision_proxy()
|
||||
if proxy != null:
|
||||
proxy.queue_free()
|
||||
|
||||
|
||||
func _find_rig_collision_proxy() -> Node:
|
||||
for child: Node in get_children():
|
||||
if child.name == "RigCollisionProxy":
|
||||
return child
|
||||
return null
|
||||
|
||||
@@ -19,6 +19,10 @@ enum FacingProfile { LEFT, RIGHT, FORWARD }
|
||||
## Per-joint bend direction. INVERTED == flip_bend_direction = true.
|
||||
enum BendDirection { NORMAL, INVERTED }
|
||||
|
||||
## Rig physics mode. ANIMATED drives the skeleton + IK; RAGDOLL swaps in a
|
||||
## procedural RigidBody2D + PinJoint2D network (see _build_ragdoll).
|
||||
enum RigState { ANIMATED, RAGDOLL }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -78,6 +82,55 @@ const Z_ORDER_BY_PROFILE: Dictionary = {
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ragdoll constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const ANIMATION_PLAYER_PATH := "AnimationPlayer"
|
||||
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||||
|
||||
const RAGDOLL_LIMB_RADIUS := 8.0
|
||||
const RAGDOLL_TORSO_RADIUS := 12.0
|
||||
const RAGDOLL_HEAD_RADIUS := 100.0
|
||||
|
||||
## 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
|
||||
## ragdoll body gets a matching visible mesh (Line2D capsule / Polygon2D circle).
|
||||
const RAGDOLL_VISUAL_COLOR := Color(0.445488, 0.445488, 0.445488)
|
||||
const RAGDOLL_HEAD_VISUAL_COLOR := Color.WHITE
|
||||
const RAGDOLL_CIRCLE_SEGMENTS := 32
|
||||
|
||||
## Ragdoll body definitions, ordered parent-before-child. `node_path` is
|
||||
## Skeleton2D-relative for bones and rig-root-relative for the head visual.
|
||||
## `kind` is "bone" (capsule along a Bone2D) or "visual" (circle at Body/Head).
|
||||
const RAGDOLL_BODIES: Array[Dictionary] = [
|
||||
{ "key": "torso", "kind": "bone", "node_path": "Torso", "parent": "", "shape": "capsule", "radius": RAGDOLL_TORSO_RADIUS, "mass": 8.0, "linear_damp": 1.0, "angular_damp": 4.0 },
|
||||
{ "key": "head", "kind": "visual", "node_path": "Body/Head", "parent": "torso", "shape": "circle", "radius": RAGDOLL_HEAD_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 2.0 },
|
||||
{ "key": "left_upper_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "left_lower_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm/LeftLowerArm", "parent": "left_upper_arm", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_upper_arm", "kind": "bone", "node_path": "Torso/RightUpperArm", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_lower_arm", "kind": "bone", "node_path": "Torso/RightUpperArm/RightLowerArm", "parent": "right_upper_arm", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "left_upper_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "left_lower_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "parent": "left_upper_leg", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_upper_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_lower_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg/RightLowerLeg", "parent": "right_upper_leg", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
]
|
||||
|
||||
## Ragdoll joint definitions: one PinJoint2D per non-root body, pinned at the
|
||||
## child bone's origin. `limit` selects the angular-limit band (neck free,
|
||||
## shoulder/hip ±160°, elbow/knee −5°..+150°, or its mirrored CCW variant).
|
||||
const RAGDOLL_JOINTS: Array[Dictionary] = [
|
||||
{ "child": "head", "parent": "torso", "pin_node_path": "Torso/Head", "limit": "neck" },
|
||||
{ "child": "left_upper_arm", "parent": "torso", "pin_node_path": "Torso/LeftUpperArm", "limit": "shoulder_hip" },
|
||||
{ "child": "left_lower_arm", "parent": "left_upper_arm", "pin_node_path": "Torso/LeftUpperArm/LeftLowerArm", "limit": "elbow_knee" },
|
||||
{ "child": "right_upper_arm", "parent": "torso", "pin_node_path": "Torso/RightUpperArm", "limit": "shoulder_hip" },
|
||||
{ "child": "right_lower_arm", "parent": "right_upper_arm", "pin_node_path": "Torso/RightUpperArm/RightLowerArm", "limit": "elbow_knee_ccw" },
|
||||
{ "child": "left_upper_leg", "parent": "torso", "pin_node_path": "Torso/LeftUpperLeg", "limit": "shoulder_hip" },
|
||||
{ "child": "left_lower_leg", "parent": "left_upper_leg", "pin_node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "elbow_knee_ccw" },
|
||||
{ "child": "right_upper_leg", "parent": "torso", "pin_node_path": "Torso/RightUpperLeg", "limit": "shoulder_hip" },
|
||||
{ "child": "right_lower_leg", "parent": "right_upper_leg", "pin_node_path": "Torso/RightUpperLeg/RightLowerLeg", "limit": "elbow_knee" },
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exported controls
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -124,6 +177,10 @@ signal facing_profile_changed(profile: int)
|
||||
## flip_bend_direction value (true == inverted).
|
||||
signal bend_flag_changed(joint: String, flipped: bool)
|
||||
|
||||
## Emitted when the rig's ANIMATED/RAGDOLL state changes. `new_state` carries
|
||||
## the RigState enum value.
|
||||
signal state_changed(new_state: int)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal state
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -134,6 +191,20 @@ var _body_container: Node2D = null
|
||||
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
|
||||
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rig state (ANIMATED / RAGDOLL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var state: RigState = RigState.ANIMATED
|
||||
|
||||
var _ragdoll_root: Node2D = null
|
||||
var _ragdoll_bodies: Dictionary = {} # { String : RigidBody2D }
|
||||
var _anim_player: AnimationPlayer = null
|
||||
var _prev_global_pos: Vector2 = Vector2.ZERO
|
||||
var _prev_global_rot: float = 0.0
|
||||
var _cached_linear_velocity: Vector2 = Vector2.ZERO
|
||||
var _cached_angular_velocity: float = 0.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -148,6 +219,10 @@ func _ready() -> void:
|
||||
if _body_container == null:
|
||||
push_warning("StickmanRig: missing '%s' node in rig." % BODY_CONTAINER_PATH)
|
||||
|
||||
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
|
||||
if _anim_player == null:
|
||||
push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
|
||||
|
||||
_resolve_bend_modifications()
|
||||
|
||||
# Enable the modification stack (IK solves only at runtime).
|
||||
@@ -165,6 +240,20 @@ func _ready() -> void:
|
||||
# setters only stored values).
|
||||
_nodes_ready = true
|
||||
_apply_profile()
|
||||
_prev_global_pos = global_position
|
||||
_prev_global_rot = global_rotation
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_track_momentum(delta)
|
||||
|
||||
|
||||
func _track_momentum(delta: float) -> void:
|
||||
if delta > 0.0:
|
||||
_cached_linear_velocity = (global_position - _prev_global_pos) / delta
|
||||
_cached_angular_velocity = wrapf(global_rotation - _prev_global_rot, -PI, PI) / delta
|
||||
_prev_global_pos = global_position
|
||||
_prev_global_rot = global_rotation
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -221,6 +310,33 @@ func get_bend_joint_global_position(joint: String) -> Vector2:
|
||||
return Vector2.ZERO
|
||||
return bone.global_position
|
||||
|
||||
|
||||
func is_in_ragdoll() -> bool:
|
||||
return state == RigState.RAGDOLL
|
||||
|
||||
|
||||
func set_ragdoll(enabled: bool) -> void:
|
||||
if enabled and not is_in_ragdoll():
|
||||
_enter_ragdoll()
|
||||
elif not enabled and is_in_ragdoll():
|
||||
_exit_ragdoll()
|
||||
|
||||
|
||||
func toggle_ragdoll() -> void:
|
||||
set_ragdoll(not is_in_ragdoll())
|
||||
|
||||
|
||||
## Applies the same velocity delta to every ragdoll body via a mass-scaled
|
||||
## central impulse, preserving the ragdoll's internal structure. No-op outside
|
||||
## RAGDOLL mode. Used by the physics harness "Knock Up" button.
|
||||
func apply_ragdoll_velocity_boost(velocity: Vector2) -> void:
|
||||
if not is_in_ragdoll():
|
||||
return
|
||||
for key: String in _ragdoll_bodies:
|
||||
var body := _ragdoll_bodies[key] as RigidBody2D
|
||||
if body != null and is_instance_valid(body):
|
||||
body.apply_central_impulse(velocity * body.mass)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal resolution / apply
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -306,3 +422,207 @@ func _apply_body_z_order() -> void:
|
||||
var part := _body_container.get_node_or_null(NodePath(part_name))
|
||||
if part != null:
|
||||
_body_container.move_child(part, _body_container.get_child_count() - 1)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ragdoll translation (kinematic -> physics)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _enter_ragdoll() -> void:
|
||||
if _skeleton == null or _body_container == null:
|
||||
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
|
||||
return
|
||||
# Freeze the kinematic puppet: disable IK, stop animation, hide visuals.
|
||||
if _skeleton.modification_stack != null:
|
||||
_skeleton.modification_stack.enabled = false
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop()
|
||||
_body_container.visible = false
|
||||
_build_ragdoll()
|
||||
state = RigState.RAGDOLL
|
||||
state_changed.emit(int(state))
|
||||
|
||||
|
||||
func _exit_ragdoll() -> void:
|
||||
_destroy_ragdoll()
|
||||
if _body_container != null and is_instance_valid(_body_container):
|
||||
_body_container.visible = true
|
||||
if _skeleton != null and is_instance_valid(_skeleton):
|
||||
if _skeleton.modification_stack != null:
|
||||
_skeleton.modification_stack.enabled = true
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop()
|
||||
state = RigState.ANIMATED
|
||||
state_changed.emit(int(state))
|
||||
|
||||
|
||||
func _build_ragdoll() -> void:
|
||||
var parent: Node = get_parent()
|
||||
if parent == null:
|
||||
parent = get_tree().current_scene
|
||||
if parent == null:
|
||||
push_warning("StickmanRig: cannot reparent ragdoll container; no parent or current scene.")
|
||||
return
|
||||
|
||||
_ragdoll_root = Node2D.new()
|
||||
_ragdoll_root.name = RAGDOLL_CONTAINER_NAME
|
||||
parent.add_child(_ragdoll_root)
|
||||
|
||||
_ragdoll_bodies.clear()
|
||||
for entry: Dictionary in RAGDOLL_BODIES:
|
||||
_build_ragdoll_body(entry)
|
||||
for entry: Dictionary in RAGDOLL_JOINTS:
|
||||
_build_ragdoll_joint(entry)
|
||||
|
||||
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
|
||||
if torso != null:
|
||||
torso.linear_velocity = _cached_linear_velocity
|
||||
torso.angular_velocity = _cached_angular_velocity
|
||||
|
||||
|
||||
func _build_ragdoll_body(entry: Dictionary) -> void:
|
||||
var key: String = entry["key"]
|
||||
var body := RigidBody2D.new()
|
||||
body.name = "Ragdoll_" + key
|
||||
body.mass = float(entry["mass"])
|
||||
body.linear_damp = float(entry["linear_damp"])
|
||||
body.angular_damp = float(entry["angular_damp"])
|
||||
body.gravity_scale = 1.0
|
||||
body.lock_rotation = false
|
||||
body.freeze = false
|
||||
body.collision_layer = 1
|
||||
body.collision_mask = 1
|
||||
|
||||
var shape := CollisionShape2D.new()
|
||||
shape.name = "CollisionShape2D"
|
||||
|
||||
if entry["kind"] == "visual":
|
||||
var visual := get_node_or_null(NodePath(entry["node_path"])) as Node2D
|
||||
if visual == null or not is_instance_valid(visual):
|
||||
push_warning("StickmanRig: missing ragdoll visual node '%s'." % entry["node_path"])
|
||||
body.queue_free()
|
||||
return
|
||||
var circle := CircleShape2D.new()
|
||||
circle.radius = float(entry["radius"])
|
||||
shape.shape = circle
|
||||
body.add_child(shape)
|
||||
body.position = visual.global_position
|
||||
body.rotation = 0.0
|
||||
_add_ragdoll_visual_circle(body, float(entry["radius"]), RAGDOLL_HEAD_VISUAL_COLOR)
|
||||
else:
|
||||
var bone := _skeleton.get_node_or_null(NodePath(entry["node_path"])) as Bone2D
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
push_warning("StickmanRig: missing ragdoll bone '%s'." % entry["node_path"])
|
||||
body.queue_free()
|
||||
return
|
||||
var origin: Vector2 = bone.global_position
|
||||
var tip: Vector2
|
||||
if key == "torso":
|
||||
var head_bone := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||||
if head_bone == null or not is_instance_valid(head_bone):
|
||||
push_warning("StickmanRig: missing Head bone for torso ragdoll body.")
|
||||
body.queue_free()
|
||||
return
|
||||
tip = head_bone.global_position
|
||||
else:
|
||||
# A Bone2D's length runs along its local +X rotated by `bone_angle`
|
||||
# (stored in degrees). to_global(Vector2(length, 0)) alone ignores
|
||||
# bone_angle, so rotate the tip vector by it to reach the real
|
||||
# far-end joint (which coincides with the child bone's origin).
|
||||
tip = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
|
||||
var length: float = origin.distance_to(tip)
|
||||
var midpoint: Vector2 = (origin + tip) * 0.5
|
||||
var capsule := CapsuleShape2D.new()
|
||||
capsule.height = length
|
||||
capsule.radius = float(entry["radius"])
|
||||
shape.shape = capsule
|
||||
# CapsuleShape2D spans local +Y, but a Bone2D's length runs along local
|
||||
# +X: rotate the shape -90° so the capsule aligns with the body's +X,
|
||||
# which we point along the bone's origin->tip direction below.
|
||||
shape.rotation = -PI / 2.0
|
||||
body.add_child(shape)
|
||||
body.position = midpoint
|
||||
body.rotation = (tip - origin).angle()
|
||||
_add_ragdoll_visual_capsule(body, length, float(entry["radius"]), RAGDOLL_VISUAL_COLOR)
|
||||
|
||||
_ragdoll_root.add_child(body)
|
||||
_ragdoll_bodies[key] = body
|
||||
|
||||
|
||||
## Visible capsule mesh (Line2D with round caps) spanning the body's local +X,
|
||||
## which `_build_ragdoll_body` already aligns with the bone's origin->tip
|
||||
## direction. Collision shapes never render in-game, so this is what the player
|
||||
## actually sees in RAGDOLL mode.
|
||||
func _add_ragdoll_visual_capsule(body: RigidBody2D, length: float, radius: float, color: Color) -> void:
|
||||
var line := Line2D.new()
|
||||
line.name = "VisualCapsule"
|
||||
line.points = PackedVector2Array([Vector2(-length * 0.5, 0.0), Vector2(length * 0.5, 0.0)])
|
||||
line.width = radius * 2.0
|
||||
line.default_color = color
|
||||
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
|
||||
line.end_cap_mode = Line2D.LINE_CAP_ROUND
|
||||
line.joint_mode = Line2D.LINE_JOINT_ROUND
|
||||
body.add_child(line)
|
||||
|
||||
|
||||
## Visible filled circle for the head body, matching the authored head circle.
|
||||
func _add_ragdoll_visual_circle(body: RigidBody2D, radius: float, color: Color) -> void:
|
||||
var poly := Polygon2D.new()
|
||||
poly.name = "VisualCircle"
|
||||
var points := PackedVector2Array()
|
||||
for i: int in RAGDOLL_CIRCLE_SEGMENTS:
|
||||
var angle: float = TAU * float(i) / float(RAGDOLL_CIRCLE_SEGMENTS)
|
||||
points.append(Vector2(cos(angle), sin(angle)) * radius)
|
||||
poly.polygon = points
|
||||
poly.color = color
|
||||
body.add_child(poly)
|
||||
|
||||
|
||||
func _build_ragdoll_joint(entry: Dictionary) -> void:
|
||||
var child_key: String = entry["child"]
|
||||
var parent_key: String = entry["parent"]
|
||||
var child_body := _ragdoll_bodies.get(child_key) as RigidBody2D
|
||||
var parent_body := _ragdoll_bodies.get(parent_key) as RigidBody2D
|
||||
if child_body == null or parent_body == null:
|
||||
return
|
||||
var pin_bone := _skeleton.get_node_or_null(NodePath(entry["pin_node_path"])) as Bone2D
|
||||
if pin_bone == null or not is_instance_valid(pin_bone):
|
||||
push_warning("StickmanRig: missing pin bone '%s' for ragdoll joint '%s'." % [entry["pin_node_path"], child_key])
|
||||
return
|
||||
|
||||
var pin := PinJoint2D.new()
|
||||
pin.name = "RagdollPin_" + child_key
|
||||
pin.position = pin_bone.global_position
|
||||
_ragdoll_root.add_child(pin)
|
||||
pin.node_a = pin.get_path_to(parent_body)
|
||||
pin.node_b = pin.get_path_to(child_body)
|
||||
pin.softness = 0.0
|
||||
_apply_ragdoll_joint_limits(pin, entry["limit"])
|
||||
|
||||
|
||||
func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String) -> void:
|
||||
match limit:
|
||||
"elbow_knee":
|
||||
# Fold (natural bend) toward +CW, resist hyperextension past -5°.
|
||||
pin.angular_limit_enabled = true
|
||||
pin.angular_limit_lower = -deg_to_rad(5.0)
|
||||
pin.angular_limit_upper = deg_to_rad(150.0)
|
||||
"elbow_knee_ccw":
|
||||
# Mirrored limb: its natural bend folds -CCW (the rig's TwoBoneIK
|
||||
# bend flag for this limb is inverted), so the large allowance goes
|
||||
# on the negative side and hyperextension is capped at +5°.
|
||||
pin.angular_limit_enabled = true
|
||||
pin.angular_limit_lower = -deg_to_rad(150.0)
|
||||
pin.angular_limit_upper = deg_to_rad(5.0)
|
||||
"shoulder_hip":
|
||||
pin.angular_limit_enabled = true
|
||||
pin.angular_limit_lower = -deg_to_rad(160.0)
|
||||
pin.angular_limit_upper = deg_to_rad(160.0)
|
||||
_:
|
||||
pin.angular_limit_enabled = false
|
||||
|
||||
|
||||
func _destroy_ragdoll() -> void:
|
||||
if _ragdoll_root != null and is_instance_valid(_ragdoll_root):
|
||||
_ragdoll_root.queue_free()
|
||||
_ragdoll_root = null
|
||||
_ragdoll_bodies.clear()
|
||||
|
||||
Reference in New Issue
Block a user