- Removed the old create_walk.gd script, which generated walk animations. - Introduced create_animations.gd to unify the generation of walk_left, walk_right, and stand_up animations. - Added a new SpinBox for rest timeout configuration in physics_test_harness.gd. - Enhanced StickmanRig to support automatic recovery from ragdoll state with configurable timeout. - Implemented recovery logic in StickmanRig, allowing for smooth transitions from ragdoll to animated state. - Updated animation generation logic to use new pose templates for standing and lying down positions.
338 lines
10 KiB
GDScript
338 lines
10 KiB
GDScript
class_name PhysicsTestHarness
|
|
extends Node2D
|
|
## PhysicsTestHarness - Standalone vector-terrain physics test scene (Phase 2).
|
|
##
|
|
## 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 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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
const MIN_ZOOM: float = 0.25
|
|
const MAX_ZOOM: float = 3.0
|
|
const ZOOM_STEP: float = 1.10
|
|
|
|
const RIG_SCENE := preload("res://master_rig.tscn")
|
|
|
|
## Preloaded prop scripts: resolved via preload (not the global class registry)
|
|
## so the harness compiles even when the editor's class cache is stale.
|
|
const PROP_UTILS_SCRIPT := preload("res://scripts/prop_utils.gd")
|
|
const PROP_BLOCK_SCRIPT := preload("res://scripts/prop_block.gd")
|
|
|
|
## World-space Y of the flat ground's top surface.
|
|
const GROUND_TOP_Y: float = 0.0
|
|
|
|
## Rig root placement: the rig's feet rest ~385 px below its root, so placing
|
|
## the root 385 px above the ground puts the feet on the top surface.
|
|
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)
|
|
const RIG_PROXY_CENTER := Vector2(0.0, -500.0)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Node references
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@onready var _camera: Camera2D = $Camera2D
|
|
@onready var _environment: Node2D = $Environment
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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
|
|
|
|
## Rest-timeout spinbox (director-controlled auto-recovery delay).
|
|
var _rest_timeout_spinbox: SpinBox = null
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _ready() -> void:
|
|
_camera.make_current()
|
|
_build_environment()
|
|
_spawn_rig()
|
|
_build_ui()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Input (camera zoom / pan)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _input(event: InputEvent) -> void:
|
|
if event is InputEventMouseButton:
|
|
_handle_mouse_button(event as InputEventMouseButton)
|
|
elif event is InputEventMouseMotion:
|
|
_handle_mouse_motion(event as InputEventMouseMotion)
|
|
|
|
|
|
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
|
|
match mb.button_index:
|
|
MOUSE_BUTTON_WHEEL_UP:
|
|
if mb.pressed:
|
|
_set_zoom(_camera.zoom.x * ZOOM_STEP)
|
|
MOUSE_BUTTON_WHEEL_DOWN:
|
|
if mb.pressed:
|
|
_set_zoom(_camera.zoom.x / ZOOM_STEP)
|
|
MOUSE_BUTTON_MIDDLE:
|
|
_is_panning = mb.pressed
|
|
_pan_last = mb.position
|
|
|
|
|
|
func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
|
|
if _is_panning:
|
|
_camera.position -= mm.relative / _camera.zoom.x
|
|
|
|
|
|
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)
|
|
|
|
var rest_label := Label.new()
|
|
rest_label.text = "Rest"
|
|
hbox.add_child(rest_label)
|
|
|
|
_rest_timeout_spinbox = SpinBox.new()
|
|
_rest_timeout_spinbox.min_value = 0.1
|
|
_rest_timeout_spinbox.max_value = 10.0
|
|
_rest_timeout_spinbox.step = 0.1
|
|
_rest_timeout_spinbox.value = 2.0
|
|
_rest_timeout_spinbox.value_changed.connect(_on_rest_timeout_changed)
|
|
hbox.add_child(_rest_timeout_spinbox)
|
|
|
|
var recover_btn := Button.new()
|
|
recover_btn.text = "Recover Now"
|
|
recover_btn.pressed.connect(_recover_now)
|
|
hbox.add_child(recover_btn)
|
|
|
|
if _rig != null:
|
|
_rest_timeout_spinbox.value = _rig.rest_timeout
|
|
|
|
|
|
func _on_rest_timeout_changed(v: float) -> void:
|
|
if _rig != null:
|
|
_rig.rest_timeout = v
|
|
|
|
|
|
func _recover_now() -> void:
|
|
if _rig != null:
|
|
_rig.request_recovery()
|
|
|
|
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _build_environment() -> void:
|
|
# Flat ground: top surface at GROUND_TOP_Y, extending downward (solid).
|
|
TerrainUtils.spawn_block(_environment, PackedVector2Array([
|
|
Vector2(-800.0, GROUND_TOP_Y),
|
|
Vector2(800.0, GROUND_TOP_Y),
|
|
Vector2(800.0, GROUND_TOP_Y + 64.0),
|
|
Vector2(-800.0, GROUND_TOP_Y + 64.0),
|
|
]))
|
|
|
|
# Angled ramp (a sloped quad rising 128 px over its 192 px run).
|
|
TerrainUtils.spawn_block(_environment, PackedVector2Array([
|
|
Vector2(208.0, GROUND_TOP_Y),
|
|
Vector2(400.0, GROUND_TOP_Y - 128.0),
|
|
Vector2(400.0, GROUND_TOP_Y - 64.0),
|
|
Vector2(208.0, GROUND_TOP_Y + 64.0),
|
|
]))
|
|
|
|
# Stepped terrain (a single concave staircase — exercises BUILD_SOLIDS).
|
|
TerrainUtils.spawn_block(_environment, PackedVector2Array([
|
|
Vector2(496.0, GROUND_TOP_Y + 64.0),
|
|
Vector2(752.0, GROUND_TOP_Y + 64.0),
|
|
Vector2(752.0, GROUND_TOP_Y - 192.0),
|
|
Vector2(688.0, GROUND_TOP_Y - 192.0),
|
|
Vector2(688.0, GROUND_TOP_Y - 128.0),
|
|
Vector2(624.0, GROUND_TOP_Y - 128.0),
|
|
Vector2(624.0, GROUND_TOP_Y - 64.0),
|
|
Vector2(560.0, GROUND_TOP_Y - 64.0),
|
|
Vector2(560.0, GROUND_TOP_Y),
|
|
Vector2(496.0, GROUND_TOP_Y),
|
|
]))
|
|
|
|
|
|
func _spawn_rig() -> void:
|
|
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
|
|
rig.state_changed.connect(_on_rig_state_changed)
|
|
_add_rig_collision_proxy()
|
|
|
|
|
|
## Keeps the best-effort collision proxy in sync with the rig's physics mode:
|
|
## the ragdoll collides directly with the terrain, so the proxy is removed
|
|
## during RAGDOLL and re-added otherwise. Proxy helpers are idempotent.
|
|
func _on_rig_state_changed(new_state: int) -> void:
|
|
if new_state == StickmanRig.RigState.RAGDOLL:
|
|
_remove_rig_collision_proxy()
|
|
else:
|
|
_add_rig_collision_proxy()
|
|
_update_ragdoll_toggle()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dynamic prop spawning (keys 1/2/3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _spawn_prop_crate() -> void:
|
|
PROP_UTILS_SCRIPT.spawn_prop(
|
|
_environment,
|
|
PROP_SPAWN_POSITION + Vector2(-24.0, 0.0),
|
|
PROP_UTILS_SCRIPT.create_box(),
|
|
PROP_BLOCK_SCRIPT.MaterialPreset.WOOD,
|
|
Vector2(60.0, 0.0)
|
|
)
|
|
|
|
|
|
func _spawn_prop_ball() -> void:
|
|
PROP_UTILS_SCRIPT.spawn_prop(
|
|
_environment,
|
|
PROP_SPAWN_POSITION,
|
|
PROP_UTILS_SCRIPT.create_ball(),
|
|
PROP_BLOCK_SCRIPT.MaterialPreset.RUBBER,
|
|
Vector2(-80.0, 0.0)
|
|
)
|
|
|
|
|
|
func _spawn_prop_plank() -> void:
|
|
PROP_UTILS_SCRIPT.spawn_prop(
|
|
_environment,
|
|
PROP_SPAWN_POSITION + Vector2(24.0, 0.0),
|
|
PROP_UTILS_SCRIPT.create_plank(),
|
|
PROP_BLOCK_SCRIPT.MaterialPreset.METAL,
|
|
Vector2(30.0, -40.0)
|
|
)
|
|
|
|
|
|
## 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
|
|
|
|
var shape := CollisionShape2D.new()
|
|
shape.name = "CollisionShape2D"
|
|
var rect := RectangleShape2D.new()
|
|
rect.size = RIG_PROXY_SIZE
|
|
shape.shape = rect
|
|
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
|