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 # Manual recovery only ("Recover Now"): auto-recover is left off so the # ragdoll stays down until the operator triggers it, matching sandbox PLAY. rig.auto_recover = false 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