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:
2026-08-27 00:05:17 -04:00
parent 6edf3e53e3
commit e3df1cc5c0
12 changed files with 1325 additions and 57 deletions
+106 -17
View File
@@ -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