diff --git a/master_rig.tscn b/scenes/stickman_rig.tscn similarity index 97% rename from master_rig.tscn rename to scenes/stickman_rig.tscn index b387ceb..5db6277 100644 --- a/master_rig.tscn +++ b/scenes/stickman_rig.tscn @@ -1,6 +1,9 @@ [gd_scene format=3 uid="uid://dr5ef3l2var2s"] [ext_resource type="Script" uid="uid://b4rsae8suaxj7" path="res://scripts/stickman_rig.gd" id="1_rig_script"] +[ext_resource type="Script" path="res://scripts/stickman_locomotion.gd" id="2_locomotion"] +[ext_resource type="Script" path="res://scripts/stickman_ragdoll.gd" id="3_ragdoll"] +[ext_resource type="Script" path="res://scripts/stickman_action_runner.gd" id="4_runner"] [sub_resource type="GDScript" id="GDScript_f0s26"] script/source = "@tool @@ -325,6 +328,17 @@ _data = { [node name="Master" type="Node2D" unique_id=319103476] script = ExtResource("1_rig_script") +[node name="RigComponents" type="Node" parent="."] + +[node name="Locomotion" type="Node" parent="RigComponents"] +script = ExtResource("2_locomotion") + +[node name="Ragdoll" type="Node" parent="RigComponents"] +script = ExtResource("3_ragdoll") + +[node name="ActionRunner" type="Node" parent="RigComponents"] +script = ExtResource("4_runner") + [node name="Body" type="Node2D" parent="." unique_id=327811712] [node name="Body" type="Line2D" parent="Body" unique_id=1290443463] diff --git a/scripts/create_animations.gd b/scripts/create_animations.gd index 45e1eaa..a3f9738 100644 --- a/scripts/create_animations.gd +++ b/scripts/create_animations.gd @@ -25,7 +25,7 @@ const POSE_PATHS: Dictionary = { const POSE_MARKERS: Array[String] = ["Torso", "Head", "Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"] ## Pose templates (rig-local). POSE_DOWN is a generic "lying on back" rest pose; -## POSE_STANDING matches master_rig.tscn's authored IK-target defaults. The +## POSE_STANDING matches scenes/stickman_rig.tscn's authored IK-target defaults. The ## baked stand_up is an authored reference only — runtime recovery tweens the ## IK targets directly from the captured ragdoll pose instead. const POSE_DOWN: Dictionary = { diff --git a/scripts/physics_test_harness.gd b/scripts/physics_test_harness.gd index 709f053..8a1a04d 100644 --- a/scripts/physics_test_harness.gd +++ b/scripts/physics_test_harness.gd @@ -3,7 +3,7 @@ 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 +## factory, spawns a scenes/stickman_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. @@ -16,7 +16,7 @@ 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") +const RIG_SCENE := preload("res://scenes/stickman_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. @@ -251,7 +251,7 @@ func _build_environment() -> void: func _spawn_rig() -> void: var rig := RIG_SCENE.instantiate() as StickmanRig if rig == null: - push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.") + push_warning("PhysicsTestHarness: failed to instantiate scenes/stickman_rig.tscn.") return rig.position = RIG_SPAWN_POSITION # Manual recovery only ("Recover Now"): auto-recover is left off so the diff --git a/scripts/sandbox_stage.gd b/scripts/sandbox_stage.gd index 84f0ad8..b171f47 100644 --- a/scripts/sandbox_stage.gd +++ b/scripts/sandbox_stage.gd @@ -2803,17 +2803,17 @@ func _action_hint_text() -> String: func _action_origin() -> Vector2: if _pending_walk_target: if _context_rig != null and is_instance_valid(_context_rig): - return _context_rig.global_position - STICKMAN_RIG.FOOT_OFFSET + return _context_rig.global_position - StickmanLocomotion.FOOT_OFFSET return Vector2.ZERO if _capture_kind != CaptureKind.NONE: if _walk_edit_rig != null and is_instance_valid(_walk_edit_rig): - return _walk_edit_rig.global_position - STICKMAN_RIG.FOOT_OFFSET + return _walk_edit_rig.global_position - StickmanLocomotion.FOOT_OFFSET if _panel_rig != null and is_instance_valid(_panel_rig): - return _panel_rig.global_position - STICKMAN_RIG.FOOT_OFFSET + return _panel_rig.global_position - StickmanLocomotion.FOOT_OFFSET return Vector2.ZERO if _rule_step != RuleStep.IDLE: if _rule_context_rig != null and is_instance_valid(_rule_context_rig): - return _rule_context_rig.global_position - STICKMAN_RIG.FOOT_OFFSET + return _rule_context_rig.global_position - StickmanLocomotion.FOOT_OFFSET var trigger: Dictionary = _rule_builder.get("trigger", {}) var src := instance_from_id(int(trigger.get("source", -1))) if src is Node2D and is_instance_valid(src): @@ -3467,7 +3467,7 @@ func _update_area_entry() -> void: ## Stickman point = feet (root - FOOT_OFFSET); prop point = global position. func _movable_point(node: Node2D) -> Vector2: if node is STICKMAN_RIG: - return node.global_position - StickmanRig.FOOT_OFFSET + return node.global_position - StickmanLocomotion.FOOT_OFFSET return node.global_position @@ -3636,7 +3636,7 @@ func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void: _walk_edit_index = index _walk_edit_from_panel = from_panel var queue := rig.get_queue() - var pos: Vector2 = queue[index].get("target", rig.global_position - STICKMAN_RIG.FOOT_OFFSET) + var pos: Vector2 = queue[index].get("target", rig.global_position - StickmanLocomotion.FOOT_OFFSET) _director_visuals.set_edit_waypoint(pos) _begin_capture(CaptureKind.POSITION, "🚩 Click the new waypoint position (Esc or RMB to cancel)", _on_walk_edit_captured, _cb_walk_edit_cancel) diff --git a/scripts/stage_director_visuals.gd b/scripts/stage_director_visuals.gd index c455421..d462437 100644 --- a/scripts/stage_director_visuals.gd +++ b/scripts/stage_director_visuals.gd @@ -144,7 +144,7 @@ func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary: var queue := rig.get_queue() if queue.is_empty(): continue - var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET + var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET for i: int in queue.size(): var action: Dictionary = queue[i] if String(action.get("type", "")) == "walk_to": @@ -192,7 +192,7 @@ func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void: if queue.is_empty(): return # Feet position = rig root - FOOT_OFFSET (FOOT_OFFSET maps feet -> root). - var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET + var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET var stack := 0 for i: int in queue.size(): var action: Dictionary = queue[i] @@ -271,7 +271,7 @@ func _collect_waypoints() -> Array[Vector2]: var queue := rig.get_queue() if queue.is_empty(): continue - var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET + var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET for i: int in queue.size(): var action: Dictionary = queue[i] if String(action.get("type", "")) == "walk_to": diff --git a/scripts/stickman_action_runner.gd b/scripts/stickman_action_runner.gd new file mode 100644 index 0000000..ce0af02 --- /dev/null +++ b/scripts/stickman_action_runner.gd @@ -0,0 +1,306 @@ +class_name StickmanActionRunner +extends Node +## Owns authored and reactive action execution plus the shared speech bubble. +## StickmanRig remains the public facade; this node only calls down into +## locomotion and signals requests back to the facade. + +const ACTION_REGISTRY := preload("res://scripts/action_registry.gd") +const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd") +const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0) + +enum RunnerState { IDLE, EXECUTING } +enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING } + +signal action_started(action: Dictionary, index: int) +signal action_finished(action: Dictionary, index: int) +signal queue_finished +signal queue_changed +signal speech_finished +signal ragdoll_requested(enabled: bool) +signal recovery_requested + +var action_queue: Array[Dictionary] = [] +var _rig: StickmanRig = null +var _locomotion: StickmanLocomotion = null +var _runner_state: RunnerState = RunnerState.IDLE +var _action_phase: ActionPhase = ActionPhase.NONE +var _current_index: int = -1 +var _run_queue: Array[Dictionary] = [] +var _current_run_item: Dictionary = {} +var _run_cursor: int = 0 +var _phase_timer: float = 0.0 +var _primary_speech: Dictionary = {} +var _overlay_speech: Dictionary = {} +var _speech_bubble: Node2D = null + + +func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void: + _rig = rig + _locomotion = locomotion + + +func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void: + _update_speech(delta) + if _runner_state != RunnerState.EXECUTING or _is_blocked(): + return + match _action_phase: + ActionPhase.NONE: + _advance_to_next_action() + ActionPhase.WALKING: + if _locomotion != null and _locomotion.consume_walk_completed(): + _finish_action() + ActionPhase.SPEAKING: + if _primary_speech.is_empty(): + _finish_action() + ActionPhase.WAITING: + _phase_timer -= delta + if _phase_timer <= 0.0: + _finish_action() + ActionPhase.RAGDOLLING: + if ragdoll_at_rest: + _finish_action() + ActionPhase.RECOVERING: + if rig_state == StickmanRig.RigState.ANIMATED: + _finish_action() + + +func speak(text: String, duration: float) -> void: + _primary_speech = {"text": text, "time_left": maxf(duration, 0.0)} + _show_visible_speech() + + +func speak_overlay(text: String, duration: float) -> void: + _overlay_speech = {"text": text, "time_left": maxf(duration, 0.0)} + _show_visible_speech() + + +func hide_speech() -> void: + _primary_speech.clear() + _overlay_speech.clear() + if _speech_bubble != null and is_instance_valid(_speech_bubble): + _speech_bubble.hide_bubble() + + +func sync_facing(profile: int) -> void: + if _speech_bubble != null and is_instance_valid(_speech_bubble): + _speech_bubble.scale = Vector2(-1.0, 1.0) if profile == StickmanRig.FacingProfile.LEFT else Vector2.ONE + + +func queue_action(action: Dictionary) -> void: + action_queue.append(action) + queue_changed.emit() + + +func clear_queue() -> void: + action_queue.clear() + queue_changed.emit() + + +func get_queue() -> Array[Dictionary]: + return action_queue.duplicate(true) + + +func remove_action(index: int) -> void: + if index < 0 or index >= action_queue.size(): + push_warning("StickmanActionRunner: remove_action index %d out of range." % index) + return + action_queue.remove_at(index) + queue_changed.emit() + + +func insert_action(index: int, action: Dictionary) -> void: + action_queue.insert(clampi(index, 0, action_queue.size()), action) + queue_changed.emit() + + +func queue_size() -> int: + return action_queue.size() + + +func enqueue_reactive(actions: Array[Dictionary]) -> void: + if actions.is_empty(): + return + if _run_queue.is_empty() and _runner_state == RunnerState.IDLE: + for index: int in action_queue.size(): + _run_queue.append({"action": action_queue[index].duplicate(true), "queue_index": index, "reactive": false}) + _run_cursor = 0 + var primary: Array[Dictionary] = [] + var takeover_seen: bool = false + for action: Dictionary in actions: + var role: int = ACTION_REGISTRY.reactive_role(action) + if role == ACTION_REGISTRY.ReactiveRole.OVERLAY: + speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0))) + continue + if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER: + if not takeover_seen: + takeover_seen = true + _cancel_current_primary() + _overlay_speech.clear() + hide_speech() + var kept: Array[Dictionary] = [] + for item: Dictionary in _run_queue.slice(_run_cursor): + if not bool(item.get("reactive", false)): + kept.append(item) + _run_queue = kept + _run_cursor = 0 + primary.clear() + primary.append(action.duplicate(true)) + if primary.is_empty(): + return + if not takeover_seen: + _cancel_current_primary() + var injected: Array[Dictionary] = [] + for action: Dictionary in primary: + injected.append({"action": action, "queue_index": -1, "reactive": true}) + _run_queue = injected + _run_queue.slice(_run_cursor) + _run_cursor = 0 + _runner_state = RunnerState.EXECUTING + _action_phase = ActionPhase.NONE + + +func clear_reactive_actions() -> void: + var runtime_kept: Array[Dictionary] = [] + for item: Dictionary in _run_queue: + if not bool(item.get("reactive", false)): + runtime_kept.append(item) + _run_queue = runtime_kept + _run_cursor = mini(_run_cursor, _run_queue.size()) + var kept: Array[Dictionary] = [] + for action: Dictionary in action_queue: + if not bool(action.get("reactive", false)): + kept.append(action) + if kept.size() != action_queue.size(): + action_queue = kept + queue_changed.emit() + + +func start_queue() -> void: + if _runner_state == RunnerState.EXECUTING: + return + if action_queue.is_empty(): + queue_finished.emit() + return + _runner_state = RunnerState.EXECUTING + _action_phase = ActionPhase.NONE + _current_index = -1 + _run_queue.clear() + for index: int in action_queue.size(): + _run_queue.append({"action": action_queue[index].duplicate(true), "queue_index": index, "reactive": false}) + _run_cursor = 0 + _current_run_item.clear() + + +func stop_queue() -> void: + _cancel_current_primary() + hide_speech() + _run_queue.clear() + _current_run_item.clear() + _runner_state = RunnerState.IDLE + _action_phase = ActionPhase.NONE + _current_index = -1 + + +func is_queue_running() -> bool: + return _runner_state == RunnerState.EXECUTING + + +func get_current_index() -> int: + return _current_index + + +func _advance_to_next_action() -> void: + if _run_cursor >= _run_queue.size(): + _runner_state = RunnerState.IDLE + _action_phase = ActionPhase.NONE + _current_index = -1 + queue_finished.emit() + return + _current_run_item = _run_queue[_run_cursor] + _run_cursor += 1 + var action: Dictionary = _current_run_item.get("action", {}) + _current_index = int(_current_run_item.get("queue_index", -1)) + action_started.emit(action, _current_index) + _begin_action(action) + + +func _begin_action(action: Dictionary) -> void: + match String(action.get("type", "")): + "walk_to": + _action_phase = ActionPhase.WALKING + if _locomotion == null or not _locomotion.walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0))): + _finish_action() + "speak": + _action_phase = ActionPhase.SPEAKING + speak(String(action.get("text", "")), float(action.get("duration", 2.0))) + "wait": + _phase_timer = float(action.get("duration", 0.0)) + _action_phase = ActionPhase.WAITING + "ragdoll": + _action_phase = ActionPhase.RAGDOLLING + ragdoll_requested.emit(true) + "recover": + _action_phase = ActionPhase.RECOVERING + recovery_requested.emit() + _: + push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", ""))) + _finish_action() + + +func _finish_action() -> void: + var finished: Dictionary = _current_run_item.get("action", {}) + var index: int = int(_current_run_item.get("queue_index", -1)) + _action_phase = ActionPhase.NONE + _current_run_item.clear() + _current_index = -1 + if not finished.is_empty(): + action_finished.emit(finished, index) + + +func _cancel_current_primary() -> void: + if _locomotion != null: + _locomotion.cancel_walk(not _is_blocked()) + _primary_speech.clear() + _phase_timer = 0.0 + _action_phase = ActionPhase.NONE + _current_run_item.clear() + _current_index = -1 + if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble): + _speech_bubble.hide_bubble() + + +func _is_blocked() -> bool: + return _locomotion != null and _locomotion.is_action_blocked() + + +func _update_speech(delta: float) -> void: + var visible_owner_changed: bool = false + if not _primary_speech.is_empty() and not _is_blocked(): + _primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta + if float(_primary_speech["time_left"]) <= 0.0: + _primary_speech.clear() + speech_finished.emit() + if not _overlay_speech.is_empty(): + _overlay_speech["time_left"] = float(_overlay_speech.get("time_left", 0.0)) - delta + if float(_overlay_speech["time_left"]) <= 0.0: + _overlay_speech.clear() + visible_owner_changed = true + speech_finished.emit() + if _overlay_speech.is_empty() and _primary_speech.is_empty(): + if _speech_bubble != null and is_instance_valid(_speech_bubble): + _speech_bubble.hide_bubble() + elif visible_owner_changed and _overlay_speech.is_empty(): + _show_visible_speech() + + +func _show_visible_speech() -> void: + if _rig == null: + return + if _speech_bubble == null or not is_instance_valid(_speech_bubble): + _speech_bubble = SPEECH_BUBBLE_SCRIPT.new() as Node2D + _speech_bubble.name = "SpeechBubble" + _speech_bubble.position = SPEECH_BUBBLE_OFFSET + _rig.add_child(_speech_bubble) + sync_facing(_rig.facing_profile) + var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech + if not visible_speech.is_empty(): + _speech_bubble.show_text(String(visible_speech.get("text", ""))) diff --git a/scripts/stickman_action_runner.gd.uid b/scripts/stickman_action_runner.gd.uid new file mode 100644 index 0000000..b6745d1 --- /dev/null +++ b/scripts/stickman_action_runner.gd.uid @@ -0,0 +1 @@ +uid://bd0gqgr5oyu3g diff --git a/scripts/stickman_editor.gd b/scripts/stickman_editor.gd index 6389349..88690d5 100644 --- a/scripts/stickman_editor.gd +++ b/scripts/stickman_editor.gd @@ -53,7 +53,7 @@ const FILE_FILTER := "*.stk ; Stickman Files" const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"] -# Phase 8: rig proportions (master_rig.tscn rest pose — see spec §2). +# Phase 8: rig proportions (scenes/stickman_rig.tscn rest pose — see spec §2). const PROPORTIONS: Dictionary = { "upper_arm_length": 168.0, "lower_arm_length": 200.0, diff --git a/scripts/stickman_factory.gd b/scripts/stickman_factory.gd index e55dfdc..23c45ec 100644 --- a/scripts/stickman_factory.gd +++ b/scripts/stickman_factory.gd @@ -3,7 +3,7 @@ extends RefCounted ## StickmanFactory - Runtime factory for spawning rig instances from .stk files ## (Phase 9). ## -## Loads a versioned .stk JSON dictionary and instantiates a master_rig.tscn, +## Loads a versioned .stk JSON dictionary and instantiates scenes/stickman_rig.tscn, ## adapting it via StkRigAdapter. Consumed by the standalone test harness, not ## referenced by the editor. @@ -11,7 +11,7 @@ extends RefCounted # Constants # --------------------------------------------------------------------------- -const RIG_SCENE := preload("res://master_rig.tscn") +const RIG_SCENE := preload("res://scenes/stickman_rig.tscn") # --------------------------------------------------------------------------- # Public API diff --git a/scripts/stickman_locomotion.gd b/scripts/stickman_locomotion.gd new file mode 100644 index 0000000..c152aee --- /dev/null +++ b/scripts/stickman_locomotion.gd @@ -0,0 +1,763 @@ +class_name StickmanLocomotion +extends Node +## Owns navigation, walking/gait, terrain following, animated falls, and landings. +## StickmanRig schedules this component and exposes its public facade. + +enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT } + +const TERRAIN_PROBE_UP: float = 96.0 +const TERRAIN_PROBE_DOWN: float = 700.0 +const TERRAIN_NORMAL_MIN_Y: float = -0.15 +const TERRAIN_SUPPORT_RANGE: float = 160.0 +const TERRAIN_MAX_FOOT_SURFACE_DROP: float = 72.0 +const TERRAIN_FOOT_GUARD_HEIGHT: float = 24.0 +const TERRAIN_CENTER_PROBE_OFFSETS: Array[float] = [-24.0, 0.0, 24.0] +const TERRAIN_FOOT_PROBE_OFFSETS: Array[float] = [-4.0, 0.0, 4.0] +const TERRAIN_GRAVITY: float = 1400.0 +const TERRAIN_MAX_FALL_SPEED: float = 1800.0 +const RECOVERY_PROBE_START_OFFSET: float = 50.0 +const RECOVERY_PROBE_END_OFFSET: float = 1500.0 +const RECOVERY_PROBE_TOLERANCE: float = 200.0 +const RECOVERY_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0] +const AIR_DRAG_X: float = 0.75 +const AIR_POSE_START_SPEED: float = 240.0 +const AIR_POSE_FULL_SPEED: float = 1200.0 +const AIR_POSE_RESPONSE: float = 10.0 +const AIR_HAND_FORWARD: float = 220.0 +const AIR_HAND_FORWARD_START_SPEED: float = 80.0 +const AIR_HAND_FORWARD_FULL_SPEED: float = 520.0 +const AIR_HAND_LIFT: float = 680.0 +const AIR_HAND_FLAIL_AMPLITUDE: Vector2 = Vector2(20.0, 16.0) +const AIR_FOOT_FLAIL_AMPLITUDE: Vector2 = Vector2(14.0, 10.0) +const AIR_FLAIL_HZ: float = 12.0 +const HARD_LANDING_MIN_FALL_HEIGHT: float = 80.0 +const HARD_LANDING_MAX_FALL_HEIGHT: float = 900.0 +const LANDING_COMPRESSION_MIN: float = 32.0 +const LANDING_COMPRESSION_MAX: float = 160.0 +const LANDING_HAND_DROP_MIN: float = 270.0 +const LANDING_HAND_DROP_MAX: float = 520.0 +const LANDING_COMPRESS_DURATION: float = 0.08 +const LANDING_RECOVER_DURATION: float = 0.22 +const PELVIS_MAX_OFFSET: float = 240.0 +const PELVIS_RESPONSE: float = 10.0 +const FOOT_OFFSET: Vector2 = Vector2(0.0, -385.0) +const NAV_AGENT_LOCAL_POS: Vector2 = Vector2(0.0, 385.0) +const ARRIVE_DISTANCE: float = 8.0 +const NAV_PATH_DESIRED_DISTANCE: float = 8.0 +const NAV_TARGET_DESIRED_DISTANCE: float = 12.0 +const LATCH_PROBE_MAX_FRAMES: int = 6 +const WALK_CYCLE_DURATION: float = 0.8 + +const HEAD_COLLISION_RADIUS: float = 90.0 +const TORSO_COLLISION_RADIUS: float = 20.0 +const ARM_COLLISION_RADIUS: float = 12.0 +const LEG_COLLISION_RADIUS: float = 14.0 +const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0) +const COLLISION_LAYER: int = 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: float = 0.35 +const REACTION_REFERENCE_SPEED: float = 400.0 +const REACTION_MAX_ANGLE: float = 0.6 +const REACTION_LOCAL_X_EPSILON: float = 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}], +} + +signal arrived(target: Vector2) + +var _rig: StickmanRig = null +var foot_contacts: Dictionary = {"Left_Leg": {"valid": false, "locked": false}, "Right_Leg": {"valid": false, "locked": false}} +var terrain_pelvis_offset: float = 0.0 +var terrain_air_velocity: Vector2 = Vector2.ZERO +var terrain_motion: TerrainMotion = TerrainMotion.DISABLED +var terrain_following_enabled: bool = false +var _air_time: float = 0.0 +var air_pose_weight: float = 0.0 +var air_pose_base: Dictionary = {} +var _air_entry_root_y: float = 0.0 +var air_fall_distance: float = 0.0 +var _landing_elapsed: float = 0.0 +var landing_compression: float = 0.0 +var _landing_hand_drop: float = 0.0 +var _landing_torso_base: Vector2 = Vector2.ZERO +var _landing_hand_start: Dictionary = {} +var _landing_contacts: Dictionary = {} +var _terrain_paused_walk_animation: bool = false +var _nav_agent: NavigationAgent2D = null +var _nav_anchor: Node2D = null +var _walking: bool = false +var _walk_target_feet: Vector2 = Vector2.ZERO +var _walk_speed_current: float = 300.0 +var _walk_mode: String = "nav" +var _walk_mode_latched: bool = false +var _walk_latch_probe_frames: int = 0 +var _walk_settle_frames: int = 0 +var _walk_done: bool = false +var _leg_base_markers: Dictionary = {} +var _last_grounded_walk_velocity_x: float = 0.0 +var collision_bodies: Dictionary = {} +var collision_lengths: Dictionary = {} +var _collision_visuals: Dictionary = {} +var reaction_bones: Dictionary = {} +var impact_reactions: Dictionary = {} + +func configure(rig: StickmanRig) -> void: + _rig = rig + + for leg_name: String in ["Left_Leg", "Right_Leg"]: + var base: Marker2D = _rig.get_node_or_null(NodePath("AnimationTargets/%s_Base" % leg_name)) as Marker2D + if base == null: + push_warning("StickmanLocomotion: missing AnimationTargets/%s_Base." % leg_name) + else: + _leg_base_markers[leg_name] = base + _nav_anchor = Node2D.new() + _nav_anchor.name = "NavigationAgentAnchor" + _nav_anchor.position = NAV_AGENT_LOCAL_POS + _rig.add_child(_nav_anchor) + _nav_agent = NavigationAgent2D.new() + _nav_agent.name = "NavigationAgent2D" + _nav_agent.path_desired_distance = NAV_PATH_DESIRED_DISTANCE + _nav_agent.target_desired_distance = NAV_TARGET_DESIRED_DISTANCE + _nav_agent.path_max_distance = 100.0 + _nav_agent.max_speed = _rig.walk_speed + _nav_agent.avoidance_enabled = false + _nav_anchor.add_child(_nav_agent) + + +func setup_collision_bodies() -> void: + collision_bodies.clear() + collision_lengths.clear() + _collision_visuals.clear() + reaction_bones.clear() + if _rig == null or _rig._body_container == null or _rig._skeleton == null: + return + for key: String in COLLISION_PARTS: + var definition: Dictionary = COLLISION_PARTS[key] + var bone: Bone2D = _rig._skeleton.get_node_or_null(NodePath(str(definition["bone"]))) as Bone2D + if bone == null or not is_instance_valid(bone): + push_warning("StickmanLocomotion: missing reaction bone '%s'." % str(definition["bone"])) + continue + var joint_name: String = "Torso" if str(definition["visual"]) == "Body" else str(definition["visual"]) + reaction_bones[joint_name] = bone + var visual: Node2D = _rig._body_container.get_node_or_null(NodePath(str(definition["visual"]))) as Node2D + if visual == null: + push_warning("StickmanLocomotion: missing collision visual for '%s'." % key) + continue + var proxy := AnimatableBody2D.new() + proxy.name = "CollisionBody" + proxy.collision_layer = COLLISION_LAYER + proxy.collision_mask = COLLISION_LAYER + proxy.sync_to_physics = false + proxy.set_meta("stickman_rig", _rig) + 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: Dictionary = mounted_visual_bounds_local(visual) + if bool(bounds["valid"]): + var rect: Rect2 = 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(_rig._head_bone.position.y) if key == "torso" and _rig._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: AnimatableBody2D = collision_bodies.get(key) as AnimatableBody2D + var visual: Node2D = _collision_visuals.get(key) as Node2D + if proxy != null and visual != null and is_instance_valid(proxy) and is_instance_valid(visual): + 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: + var layer: int = 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 _rig == null or _rig.state != StickmanRig.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: String = str(target["joint"]) + var bone: Bone2D = reaction_bones.get(joint) as Bone2D + if bone == null or not is_instance_valid(bone): + continue + var direction: float = collision_contact_direction(part_name, impact_position, impact_direction) + if is_zero_approx(direction): + var local_dir: Vector2 = 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: float = 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: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D + if proxy == null or not is_instance_valid(proxy): return 0.0 + var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D + if shape_node == null: return 0.0 + var side: float = proxy.to_local(impact_position).x - shape_node.position.x + if absf(side) <= REACTION_LOCAL_X_EPSILON: return 0.0 + var local_force: Vector2 = proxy.global_transform.affine_inverse().basis_xform(impact_direction).normalized() + return signf(side * local_force.y) if absf(local_force.y) > REACTION_LOCAL_X_EPSILON else 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: Bone2D = 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 _rig == null or _rig.state != StickmanRig.RigState.ANIMATED: + clear_impact_reactions() + return + for joint: String in impact_reactions.keys().duplicate(): + var reaction: Dictionary = impact_reactions[joint] + var bone: Bone2D = reaction_bones.get(joint) as Bone2D + if bone == null or not is_instance_valid(bone): + impact_reactions.erase(joint) + continue + var base_rotation: float = 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 + +func tick_movement(delta: float, _rig_state: int) -> void: + if _rig == null: + return + terrain_pelvis_offset = 0.0 + if terrain_following_enabled and terrain_motion == TerrainMotion.GROUNDED: + _last_grounded_walk_velocity_x = 0.0 + _update_walking(delta) + _update_terrain_following(delta) + _settle_walk_markers() + +func tick_post_skeleton(delta: float, _rig_state: int) -> void: + if _rig != null: + apply_impact_reactions(delta) + sync_collision_bodies() + +func walk_to(target: Vector2, speed: float = -1.0) -> bool: + if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED: + push_warning("StickmanRig: walk_to ignored; not ANIMATED.") + return false + if _rig._is_action_queue_blocked(): + push_warning("StickmanRig: walk_to ignored during landing impact.") + return false + if _nav_agent == null or _nav_anchor == null: + push_warning("StickmanLocomotion: walk_to ignored; navigation is unavailable.") + return false + _walk_target_feet = target + _walk_speed_current = speed if speed > 0.0 else _rig.walk_speed + _nav_agent.max_speed = _walk_speed_current + _nav_agent.target_position = target + var dx: float = target.x - _rig.global_position.x + if dx < -0.5: + _rig.set_facing_profile(StickmanRig.FacingProfile.LEFT) + elif dx > 0.5: + _rig.set_facing_profile(StickmanRig.FacingProfile.RIGHT) + _rig._set_limb_mods_enabled(true) + if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation("walk_right"): + _rig._anim_player.play("walk_right") + _walking = true + _walk_done = false + _walk_mode = "nav" + _walk_mode_latched = false + _walk_latch_probe_frames = 0 + _walk_settle_frames = 0 + return true + +func cancel_walk(restore_idle_pose: bool = true) -> void: + if _rig == null: + return + var current_limb_rotations: Dictionary = _rig._capture_limb_rotations() + if _rig._anim_player != null and is_instance_valid(_rig._anim_player): + _rig._anim_player.stop(true) + if _nav_agent != null and _nav_anchor != null: + _nav_agent.target_position = _nav_anchor.global_position + _walking = false + _walk_done = false + _walk_mode_latched = false + _walk_latch_probe_frames = 0 + _walk_settle_frames = 0 + if restore_idle_pose: + _rig._apply_idle_limb_solver_mode(current_limb_rotations) + +func is_walking() -> bool: + return _walking + +func get_foot_offset() -> Vector2: + return FOOT_OFFSET + +func consume_walk_completed() -> bool: + if not _walk_done: + return false + _walk_done = false + return true + +func _update_walking(delta: float) -> void: + if not _walking: + return + if _rig.state != StickmanRig.RigState.ANIMATED: + cancel_walk(false) + return + if blocks_walking() or _nav_agent == null: + return + if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0: + return + if not _walk_mode_latched: + if _walk_latch_probe_frames < LATCH_PROBE_MAX_FRAMES: + _walk_latch_probe_frames += 1 + _nav_agent.get_next_path_position() + if _nav_agent.is_target_reachable(): + _walk_mode_latched = true + _walk_mode = "nav" + else: + return + else: + _walk_mode_latched = true + _walk_mode = "direct" + var next_feet: Vector2 = _nav_agent.get_next_path_position() + var final_root: Vector2 = _walk_target_feet + FOOT_OFFSET + var dist_to_final: float = absf(_rig.global_position.x - final_root.x) + var root_target: Vector2 + if _walk_mode == "nav": + if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE: + _rig.global_position.x = final_root.x + _finish_walk() + return + # A freshly synchronized agent may yield its own feet as the first + # waypoint. Treat that zero-progress waypoint as the final segment. + var next_progress: float = next_feet.x - _rig.global_position.x + var remaining_progress: float = final_root.x - _rig.global_position.x + if dist_to_final <= 2.0 * ARRIVE_DISTANCE or remaining_progress * next_progress <= 0.0: + root_target = Vector2(final_root.x, _rig.global_position.y) + else: + root_target = Vector2(next_feet.x, _rig.global_position.y) + else: + root_target = Vector2(final_root.x, _rig.global_position.y) + var before_x: float = _rig.global_position.x + _rig.global_position = _rig.global_position.move_toward(root_target, _walk_speed_current * delta) + _last_grounded_walk_velocity_x = (_rig.global_position.x - before_x) / delta if delta > 0.0 else 0.0 + if dist_to_final <= ARRIVE_DISTANCE or absf(_rig.global_position.x - final_root.x) <= ARRIVE_DISTANCE: + _rig.global_position.x = final_root.x + _finish_walk() + +func _settle_walk_markers() -> void: + if _walk_settle_frames > 0: + _walk_settle_frames -= 1 + _rig._restore_standing_markers() + +func _finish_walk() -> void: + var final_limb_rotations: Dictionary = _rig._capture_limb_rotations() + if _rig._anim_player != null and is_instance_valid(_rig._anim_player): + _rig._anim_player.stop(true) + _walking = false + _rig._apply_idle_limb_solver_mode(final_limb_rotations) + _walk_settle_frames = 0 + _walk_done = true + arrived.emit(_walk_target_feet) + +func begin_terrain_fall() -> void: + if _rig == null: + return + terrain_following_enabled = true + terrain_pelvis_offset = 0.0 + _terrain_paused_walk_animation = false + _enter_airborne(0.0) + _reset_foot_contacts() + +func set_terrain_following_enabled(enabled: bool) -> void: + if enabled: + if terrain_motion == TerrainMotion.DISABLED: + begin_terrain_fall() + return + terrain_following_enabled = false + _cancel_landing_impact(true) + _restore_air_pose() + terrain_pelvis_offset = 0.0 + terrain_air_velocity = Vector2.ZERO + terrain_motion = TerrainMotion.DISABLED + _terrain_paused_walk_animation = false + _reset_foot_contacts() + for leg_name: String in ["Left_Leg", "Right_Leg"]: + var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D + var target: Marker2D = _rig._get_ik_marker(leg_name) + if base != null and target != null: + target.position = base.position + +func prepare_for_ragdoll() -> void: + if terrain_motion != TerrainMotion.DISABLED: + set_terrain_following_enabled(false) + +func reset_for_ragdoll_handoff() -> void: + _cancel_landing_impact(false) + air_pose_base.clear() + air_pose_weight = 0.0 + _air_time = 0.0 + _air_entry_root_y = 0.0 + air_fall_distance = 0.0 + terrain_motion = TerrainMotion.DISABLED + terrain_air_velocity = Vector2.ZERO + +func is_action_blocked() -> bool: + return terrain_motion == TerrainMotion.LANDING_IMPACT + +func blocks_walking() -> bool: + return terrain_following_enabled and terrain_motion != TerrainMotion.GROUNDED + +func _reset_foot_contacts() -> void: + for leg_name: String in foot_contacts: + foot_contacts[leg_name] = {"valid": false, "locked": false} + +func _probe_terrain_below(world_point: Vector2, probe_down: float = TERRAIN_PROBE_DOWN) -> Dictionary: + var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state + if space_state == null: + return {"valid": false} + var query := PhysicsRayQueryParameters2D.create(world_point + Vector2.UP * TERRAIN_PROBE_UP, world_point + Vector2.DOWN * probe_down) + query.collision_mask = 1 + query.collide_with_areas = false + query.exclude = _rig.get_collision_body_rids() + var hit: Dictionary = space_state.intersect_ray(query) + if hit.is_empty() or not (hit.get("collider") is TerrainBlock): + return {"valid": false} + var normal: Vector2 = hit.get("normal", Vector2.ZERO) + if normal.y > TERRAIN_NORMAL_MIN_Y: + return {"valid": false} + return {"valid": true, "world_pos": hit.get("position", world_point), "normal": normal} + +func _probe_terrain_fan(world_point: Vector2, offsets: Array[float]) -> Dictionary: + var best: Dictionary = {"valid": false} + for x_offset: float in offsets: + var hit: Dictionary = _probe_terrain_below(world_point + Vector2(x_offset, 0.0)) + if bool(hit.get("valid", false)) and (not bool(best.get("valid", false)) or (hit["world_pos"] as Vector2).y < (best["world_pos"] as Vector2).y): + best = hit + return best + +func probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float: + var best_y := bottom_y + var best_distance := INF + if _rig == null: + return best_y + var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state + if space_state == null: + return best_y + for offset: float in RECOVERY_PROBE_X_OFFSETS: + var query := PhysicsRayQueryParameters2D.create( + Vector2(world_x + offset, top_y - RECOVERY_PROBE_START_OFFSET), + Vector2(world_x + offset, bottom_y + RECOVERY_PROBE_END_OFFSET)) + query.collision_mask = 1 + query.collide_with_areas = false + query.collide_with_bodies = true + query.exclude = exclude + var hit := space_state.intersect_ray(query) + if hit.is_empty(): + continue + var hit_position: Vector2 = hit.get("position", Vector2.ZERO) + if hit_position.y < top_y - RECOVERY_PROBE_TOLERANCE or hit_position.y > bottom_y + RECOVERY_PROBE_TOLERANCE: + continue + var distance := absf(hit_position.y - bottom_y) + if distance < best_distance: + best_distance = distance + best_y = hit_position.y + return best_y + +func _stance_weight(leg_name: String) -> float: + if not _walking or _rig._anim_player == null: + return 1.0 + var phase: float = fmod(_rig._anim_player.get_current_animation_position(), WALK_CYCLE_DURATION) + var right_weight: float = 0.5 + 0.5 * cos(TAU * phase / WALK_CYCLE_DURATION) + return right_weight if leg_name == "Right_Leg" else 1.0 - right_weight + +func _update_terrain_following(delta: float) -> void: + if not terrain_following_enabled or _rig.state != StickmanRig.RigState.ANIMATED or _leg_base_markers.size() != 2: + terrain_pelvis_offset = 0.0 + return + _rig._set_limb_mods_enabled(true) + var center_world: Vector2 = _rig.to_global(NAV_AGENT_LOCAL_POS) + var center_probe: Dictionary = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS) + var center_delta: float = (center_probe["world_pos"] as Vector2).y - center_world.y if bool(center_probe.get("valid", false)) else INF + var center_supported: bool = bool(center_probe.get("valid", false)) and absf(center_delta) <= TERRAIN_SUPPORT_RANGE + var center_surface_y: float = (center_probe["world_pos"] as Vector2).y if bool(center_probe.get("valid", false)) else INF + var solved: Dictionary = {} + for leg_name: String in ["Left_Leg", "Right_Leg"]: + var base: Marker2D = _leg_base_markers[leg_name] as Marker2D + var target: Marker2D = _rig._get_ik_marker(leg_name) + if base == null or target == null: + continue + var base_world: Vector2 = base.global_position + var probe: Dictionary = _probe_terrain_fan(base_world, TERRAIN_FOOT_PROBE_OFFSETS) + var probe_delta: float = (probe["world_pos"] as Vector2).y - base_world.y if bool(probe.get("valid", false)) else INF + var within_reach: bool = absf(probe_delta) <= PELVIS_MAX_OFFSET + if within_reach and center_supported: + within_reach = (probe["world_pos"] as Vector2).y <= center_surface_y + TERRAIN_MAX_FOOT_SURFACE_DROP + var stance: float = _stance_weight(leg_name) + var contact: Dictionary = foot_contacts[leg_name] + if stance >= 0.5 and bool(probe.get("valid", false)) and within_reach: + if not bool(contact.get("locked", false)): + contact = {"valid": true, "locked": true, "world_pos": probe["world_pos"], "normal": probe["normal"]} + elif (contact.get("world_pos", base_world) as Vector2).distance_to(probe["world_pos"]) > 2.0: + contact["world_pos"] = probe["world_pos"] + elif stance < 0.5 or not within_reach: + contact = {"valid": bool(probe.get("valid", false)), "locked": false} + foot_contacts[leg_name] = contact + var floor_world: Vector2 = contact.get("world_pos", base_world) as Vector2 if bool(contact.get("locked", false)) else probe.get("world_pos", base_world) as Vector2 + solved[leg_name] = {"base_marker": base, "floor": floor_world, "surface": probe.get("world_pos", base_world) if within_reach else base_world, "surface_valid": bool(probe.get("valid", false)) and within_reach, "weight": stance, "target": target} + if terrain_motion == TerrainMotion.LANDING_IMPACT: + _update_landing_impact(delta) + return + if terrain_motion == TerrainMotion.AIRBORNE: + _update_airborne(delta, solved) + return + if not center_supported: + _enter_airborne(_last_grounded_walk_velocity_x) + _update_airborne(delta, solved) + return + terrain_pelvis_offset = lerpf(0.0, clampf(center_delta, -PELVIS_MAX_OFFSET, PELVIS_MAX_OFFSET), 1.0 - exp(-PELVIS_RESPONSE * delta)) + terrain_motion = TerrainMotion.GROUNDED + terrain_air_velocity = Vector2.ZERO + _resume_walk_animation() + _rig.global_position.y += terrain_pelvis_offset + for leg_name: String in solved: + var item: Dictionary = solved[leg_name] + var base_after: Vector2 = (item["base_marker"] as Marker2D).global_position + var weight: float = float(item["weight"]) + var surface: Vector2 = item["surface"] as Vector2 + if bool(item["surface_valid"]): + weight = maxf(weight, smoothstep(0.0, 1.0, 1.0 - clampf((surface.y - base_after.y) / TERRAIN_FOOT_GUARD_HEIGHT, 0.0, 1.0))) + var wanted: Vector2 = base_after + wanted.y = lerpf(base_after.y, (item["floor"] as Vector2).y, weight) + if bool(item["surface_valid"]) and wanted.y > surface.y: + wanted.y = surface.y + (item["target"] as Marker2D).global_position = wanted + +func _enter_airborne(horizontal_velocity: float) -> void: + if terrain_motion != TerrainMotion.AIRBORNE: + air_pose_base.clear() + for marker_name: String in ["Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]: + var marker: Marker2D = _rig._get_ik_marker(marker_name) + if marker != null: + air_pose_base[marker_name] = marker.position + _air_time = 0.0 + air_pose_weight = 0.0 + _air_entry_root_y = _rig.global_position.y + air_fall_distance = 0.0 + terrain_motion = TerrainMotion.AIRBORNE + terrain_air_velocity = Vector2(horizontal_velocity, 0.0) + if _walking and _rig._anim_player != null and _rig._anim_player.is_playing(): + _rig._anim_player.pause() + _terrain_paused_walk_animation = true + _reset_foot_contacts() + +func _update_airborne(delta: float, solved: Dictionary) -> void: + terrain_air_velocity.x *= exp(-AIR_DRAG_X * delta) + terrain_air_velocity.y = minf(terrain_air_velocity.y + TERRAIN_GRAVITY * delta, TERRAIN_MAX_FALL_SPEED) + _rig.global_position += _sweep_airborne_horizontal_motion(Vector2(terrain_air_velocity.x * delta, 0.0)) + _rig.global_position.y += terrain_air_velocity.y * delta + air_fall_distance = maxf(air_fall_distance, _rig.global_position.y - _air_entry_root_y) + _air_time += delta + _apply_air_pose(delta) + var center_world: Vector2 = _rig.to_global(NAV_AGENT_LOCAL_POS) + var center_probe: Dictionary = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS) + var landing_delta: float = (center_probe["world_pos"] as Vector2).y - center_world.y if bool(center_probe.get("valid", false)) else INF + if bool(center_probe.get("valid", false)) and terrain_air_velocity.y >= 0.0 and landing_delta >= -TERRAIN_SUPPORT_RANGE and landing_delta <= TERRAIN_SUPPORT_RANGE + terrain_air_velocity.y * delta: + _rig.global_position.y += landing_delta + var speed: float = maxf(terrain_air_velocity.y, 0.0) + terrain_air_velocity = Vector2.ZERO + if air_fall_distance >= HARD_LANDING_MIN_FALL_HEIGHT: + _begin_landing(speed, center_probe, solved) + else: + _restore_air_pose() + terrain_motion = TerrainMotion.GROUNDED + _resume_walk_animation() + +func _apply_air_pose(delta: float) -> void: + if air_pose_base.is_empty(): + return + var speed: float = maxf(terrain_air_velocity.y, 0.0) + air_pose_weight = lerpf(air_pose_weight, smoothstep(AIR_POSE_START_SPEED, AIR_POSE_FULL_SPEED, speed), 1.0 - exp(-AIR_POSE_RESPONSE * delta)) + var forward_weight: float = smoothstep(AIR_HAND_FORWARD_START_SPEED, AIR_HAND_FORWARD_FULL_SPEED, speed) + var phase: float = TAU * AIR_FLAIL_HZ * _air_time + for marker_name: String in air_pose_base: + var marker: Marker2D = _rig._get_ik_marker(marker_name) + if marker == null: + continue + var base: Vector2 = air_pose_base[marker_name] + var local_phase: float = phase + (PI if marker_name.begins_with("Right") else 0.0) + var offset: Vector2 + if marker_name.ends_with("Hand"): + offset = Vector2(AIR_HAND_FORWARD * forward_weight, -AIR_HAND_LIFT * air_pose_weight) + Vector2(sin(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.y) * air_pose_weight + marker.position = base + offset + else: + offset = Vector2(sin(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.y) + marker.position = base + offset * air_pose_weight + +func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2: + if is_zero_approx(motion.x): + return motion + var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state + if space_state == null: + return motion + var safe_fraction: float = 1.0 + var excluded: Array[RID] = [] + for own_proxy: AnimatableBody2D in collision_bodies.values(): + if own_proxy != null and is_instance_valid(own_proxy): + var own_rid: RID = own_proxy.get_rid() + if own_rid.is_valid(): + excluded.append(own_rid) + for proxy: AnimatableBody2D in collision_bodies.values(): + if proxy == null or not is_instance_valid(proxy): + continue + var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D + if shape_node == null or shape_node.shape == null: + continue + var query := PhysicsShapeQueryParameters2D.new() + query.shape = shape_node.shape + query.transform = shape_node.global_transform + query.motion = motion + query.collision_mask = COLLISION_LAYER + query.exclude = excluded + var cast: PackedFloat32Array = space_state.cast_motion(query) + if cast.size() >= 1: + safe_fraction = minf(safe_fraction, cast[0]) + if safe_fraction < 0.9999: + terrain_air_velocity.x = 0.0 + return motion * safe_fraction + +func _begin_landing(_impact_speed: float, center_probe: Dictionary, _solved: Dictionary) -> void: + terrain_motion = TerrainMotion.LANDING_IMPACT + _landing_elapsed = 0.0 + var weight: float = clampf(inverse_lerp(HARD_LANDING_MIN_FALL_HEIGHT, HARD_LANDING_MAX_FALL_HEIGHT, air_fall_distance), 0.0, 1.0) + landing_compression = lerpf(LANDING_COMPRESSION_MIN, LANDING_COMPRESSION_MAX, weight) + _landing_hand_drop = lerpf(LANDING_HAND_DROP_MIN, LANDING_HAND_DROP_MAX, weight) + var torso: Marker2D = _rig._get_ik_marker("Torso") + if torso != null: _landing_torso_base = torso.position + _landing_hand_start.clear(); _landing_contacts.clear() + for hand_name: String in ["Left_Hand", "Right_Hand"]: + var hand: Marker2D = _rig._get_ik_marker(hand_name) + if hand != null: _landing_hand_start[hand_name] = hand.position + var center_y: float = (center_probe.get("world_pos", _rig.global_position) as Vector2).y + for leg_name: String in ["Left_Leg", "Right_Leg"]: + var target: Marker2D = _rig._get_ik_marker(leg_name) + if target == null: continue + var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D + var probe: Dictionary = _probe_terrain_fan(base.global_position if base != null else target.global_position, TERRAIN_FOOT_PROBE_OFFSETS) + var contact: Vector2 = probe["world_pos"] as Vector2 if bool(probe.get("valid", false)) else Vector2(target.global_position.x, center_y) + _landing_contacts[leg_name] = contact; target.global_position = contact + +func _update_landing_impact(delta: float) -> void: + _landing_elapsed += delta + var total: float = LANDING_COMPRESS_DURATION + LANDING_RECOVER_DURATION + var offset: float = landing_compression * (1.0 - pow(1.0 - clampf(_landing_elapsed / LANDING_COMPRESS_DURATION, 0.0, 1.0), 2.0)) if _landing_elapsed <= LANDING_COMPRESS_DURATION else landing_compression * cos(clampf((_landing_elapsed - LANDING_COMPRESS_DURATION) / LANDING_RECOVER_DURATION, 0.0, 1.0) * PI * 0.5) + var torso: Marker2D = _rig._get_ik_marker("Torso") + if torso != null: torso.position = _landing_torso_base + Vector2.DOWN * offset + for leg_name: String in _landing_contacts: + var target: Marker2D = _rig._get_ik_marker(leg_name) + if target != null: target.global_position = _landing_contacts[leg_name] + for hand_name: String in _landing_hand_start: + var hand: Marker2D = _rig._get_ik_marker(hand_name) + if hand != null: hand.position = (_landing_hand_start[hand_name] as Vector2).lerp(air_pose_base.get(hand_name, _landing_hand_start[hand_name]), clampf(_landing_elapsed / total, 0.0, 1.0)) + Vector2.DOWN * _landing_hand_drop * offset / maxf(landing_compression, 0.0001) + if _landing_elapsed >= total: _finish_landing_impact() + +func _finish_landing_impact() -> void: + var torso: Marker2D = _rig._get_ik_marker("Torso") + if torso != null: torso.position = _landing_torso_base + _restore_air_pose(); _landing_contacts.clear(); _landing_hand_start.clear(); _landing_elapsed = 0.0; landing_compression = 0.0; _landing_hand_drop = 0.0; terrain_motion = TerrainMotion.GROUNDED + _resume_walk_animation() + +func _cancel_landing_impact(restore_pose: bool) -> void: + if terrain_motion == TerrainMotion.LANDING_IMPACT and restore_pose: + var torso: Marker2D = _rig._get_ik_marker("Torso") + if torso != null: torso.position = _landing_torso_base + _landing_contacts.clear(); _landing_hand_start.clear(); _landing_elapsed = 0.0; landing_compression = 0.0; _landing_hand_drop = 0.0 + +func _restore_air_pose() -> void: + for marker_name: String in air_pose_base: + var marker: Marker2D = _rig._get_ik_marker(marker_name) + if marker != null: marker.position = air_pose_base[marker_name] + air_pose_base.clear(); air_pose_weight = 0.0; _air_time = 0.0; _air_entry_root_y = 0.0; air_fall_distance = 0.0 + +func _resume_walk_animation() -> void: + if _terrain_paused_walk_animation and _walking and _rig._anim_player != null: + _rig._anim_player.play() + _terrain_paused_walk_animation = false diff --git a/scripts/stickman_locomotion.gd.uid b/scripts/stickman_locomotion.gd.uid new file mode 100644 index 0000000..75c2843 --- /dev/null +++ b/scripts/stickman_locomotion.gd.uid @@ -0,0 +1 @@ +uid://bxyogpn8rv8xy diff --git a/scripts/stickman_ragdoll.gd b/scripts/stickman_ragdoll.gd new file mode 100644 index 0000000..7ed7600 --- /dev/null +++ b/scripts/stickman_ragdoll.gd @@ -0,0 +1,706 @@ +class_name StickmanRagdoll +extends Node +## Owns procedural ragdoll physics. StickmanRig remains the public state facade. + +signal recovery_requested() +signal rest_state_changed(at_rest: bool) +signal state_transition_requested(next_state: int) + +const CONTAINER_NAME: StringName = &"RagdollBodyContainer" +const LIMB_RADIUS: float = 8.0 +const TORSO_RADIUS: float = 12.0 +const HEAD_RADIUS: float = 100.0 +const VISUAL_COLOR: Color = Color(0.445488, 0.445488, 0.445488) +const CIRCLE_SEGMENTS: int = 32 +const REST_LINEAR_THRESHOLD: float = 5.0 +const REST_ANGULAR_THRESHOLD: float = 0.1 +const STABILIZATION_DELAY: float = 0.1 +const TARGET_SOFTNESS: float = 0.2 +const GROUND_PROBE_START_OFFSET := 50.0 +const GROUND_PROBE_END_OFFSET := 1500.0 +const GROUND_PROBE_TOLERANCE := 200.0 +const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0] +const STAND_UP_DURATION := 2.0 +const STAND_POSE: Dictionary = { + "Torso": {"pos": Vector2(0, 10), "rot": 0.0}, + "Head": {"pos": Vector2(100, -614), "rot": 0.0}, + "Left_Hand": {"pos": Vector2(90, 110), "rot": 0.0}, + "Right_Hand": {"pos": Vector2(-90, 110), "rot": 0.0}, + "Left_Leg": {"pos": Vector2(-110, 380), "rot": 0.0}, + "Right_Leg": {"pos": Vector2(110, 390), "rot": 0.0}, +} +const IK_TARGET_PATHS: Dictionary = { + "Torso": "IK_Targets/Torso", "Head": "IK_Targets/Head", + "Left_Hand": "IK_Targets/Left_Hand", "Right_Hand": "IK_Targets/Right_Hand", + "Left_Leg": "IK_Targets/Left_Leg", "Right_Leg": "IK_Targets/Right_Leg", +} +const LIMB_BONE_PATHS: Dictionary = { + "LeftArm": ["Torso/LeftUpperArm", "Torso/LeftUpperArm/LeftLowerArm"], + "RightArm": ["Torso/RightUpperArm", "Torso/RightUpperArm/RightLowerArm"], + "LeftLeg": ["Torso/LeftUpperLeg", "Torso/LeftUpperLeg/LeftLowerLeg"], + "RightLeg": ["Torso/RightUpperLeg", "Torso/RightUpperLeg/RightLowerLeg"], +} +const LIMB_BODY_KEYS: Dictionary = { + "LeftArm": ["left_upper_arm", "left_lower_arm"], + "RightArm": ["right_upper_arm", "right_lower_arm"], + "LeftLeg": ["left_upper_leg", "left_lower_leg"], + "RightLeg": ["right_upper_leg", "right_lower_leg"], +} +const RECOVERY_LEG_SIDE_FORCE: Dictionary = { + StickmanRig.FacingProfile.FORWARD: {}, StickmanRig.FacingProfile.LEFT: {}, StickmanRig.FacingProfile.RIGHT: {}, +} +const RECOVERY_JOINT_PATH: Dictionary = { + StickmanRig.FacingProfile.FORWARD: {}, StickmanRig.FacingProfile.LEFT: {}, StickmanRig.FacingProfile.RIGHT: {}, +} + +const BODIES: Array[Dictionary] = [ + { "key": "torso", "kind": "bone", "node_path": "Torso", "visual_path": "Body/Body", "radius": TORSO_RADIUS, "mass": 8.0, "linear_damp": 1.0, "angular_damp": 4.0 }, + { "key": "head", "kind": "visual", "node_path": "Body/Head", "visual_path": "Body/Head", "radius": HEAD_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 2.0 }, + { "key": "left_upper_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm", "visual_path": "Body/LeftUpperArm", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm/LeftLowerArm", "visual_path": "Body/LeftLowerArm", "radius": LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 }, + { "key": "right_upper_arm", "kind": "bone", "node_path": "Torso/RightUpperArm", "visual_path": "Body/RightUpperArm", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_arm", "kind": "bone", "node_path": "Torso/RightUpperArm/RightLowerArm", "visual_path": "Body/RightLowerArm", "radius": LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 }, + { "key": "left_upper_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg", "visual_path": "Body/LeftUpperLeg", "radius": LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "visual_path": "Body/LeftLowerLeg", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, + { "key": "right_upper_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg", "visual_path": "Body/RightUpperLeg", "radius": LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg/RightLowerLeg", "visual_path": "Body/RightLowerLeg", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, +] +const JOINTS: Array[Dictionary] = [ + { "child": "head", "parent": "torso", "pin": "Torso/Head", "limit": "neck" }, { "child": "left_upper_arm", "parent": "torso", "pin": "Torso/LeftUpperArm", "limit": "shoulder" }, { "child": "left_lower_arm", "parent": "left_upper_arm", "pin": "Torso/LeftUpperArm/LeftLowerArm", "limit": "cw" }, { "child": "right_upper_arm", "parent": "torso", "pin": "Torso/RightUpperArm", "limit": "shoulder" }, { "child": "right_lower_arm", "parent": "right_upper_arm", "pin": "Torso/RightUpperArm/RightLowerArm", "limit": "ccw" }, { "child": "left_upper_leg", "parent": "torso", "pin": "Torso/LeftUpperLeg", "limit": "shoulder" }, { "child": "left_lower_leg", "parent": "left_upper_leg", "pin": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "ccw" }, { "child": "right_upper_leg", "parent": "torso", "pin": "Torso/RightUpperLeg", "limit": "shoulder" }, { "child": "right_lower_leg", "parent": "right_upper_leg", "pin": "Torso/RightUpperLeg/RightLowerLeg", "limit": "cw" }, +] + +## Per-profile elbow/knee limits retained from the monolithic rig. The component +## owns these construction settings because it creates the PinJoint2D network. +const JOINT_BAND_LIMITS: Dictionary = { + StickmanRig.FacingProfile.FORWARD: { + "left_lower_arm": { "lower": -150.0, "upper": 5.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 }, + "left_lower_leg": { "lower": -5.0, "upper": 150.0 }, "right_lower_leg": { "lower": -5.0, "upper": 150.0 }, + }, + StickmanRig.FacingProfile.LEFT: { + "left_lower_arm": { "lower": 60.0, "upper": 100.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 }, + "left_lower_leg": { "lower": -100.0, "upper": -90.0 }, "right_lower_leg": { "lower": -5.0, "upper": 150.0 }, + }, + StickmanRig.FacingProfile.RIGHT: { + "left_lower_arm": { "lower": -100.0, "upper": -60.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 }, + "left_lower_leg": { "lower": 90.0, "upper": 100.0 }, "right_lower_leg": { "lower": -5.0, "upper": 150.0 }, + }, +} + +var _rig: StickmanRig = null +var _locomotion: StickmanLocomotion = null +var _skeleton: Skeleton2D = null +var _body_container: Node2D = null +var _anim_player: AnimationPlayer = null +var _torso_bone: Bone2D = null +var _head_bone: Bone2D = null +var _head_look_at: SkeletonModification2DLookAt = null +var _limb_bones: Dictionary = {} +var _bend_modifications: Dictionary = {} +var _root: Node2D = null +var _bodies: Dictionary = {} +var _previous_position: Vector2 = Vector2.ZERO +var _previous_rotation: float = 0.0 +var _linear_velocity: Vector2 = Vector2.ZERO +var _angular_velocity: float = 0.0 +var _rest_timer: float = 0.0 +var _stabilize_timer: float = 0.0 +var _at_rest: bool = false +var _captured_pose: Dictionary = {} +var _captured_landing_center := Vector2.ZERO +var _captured_ground_y := 0.0 +var _stand_up_tween: Tween = null +var _stand_up_generation := 0 +var _recovery_bones: Array[Dictionary] = [] +var _pre_ragdoll_state: Dictionary = {} +var _probe_top_y := 0.0 +var _probe_bottom_y := 0.0 +var _probe_exclude: Array[RID] = [] +var _spawn_pose: Dictionary = {} +var _spawn_limb_rotations: Dictionary = {} + +func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void: + _rig = rig + _locomotion = locomotion + +func initialize(skeleton: Skeleton2D) -> void: + _skeleton = skeleton + _body_container = _rig.get_node_or_null(NodePath("Body")) as Node2D + _anim_player = _rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer + if _skeleton != null: + _torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D + _head_bone = _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D + _resolve_limb_bones() + var stack: SkeletonModificationStack2D = _skeleton.modification_stack + if stack != null: + for index: int in stack.modification_count: + var modification: SkeletonModification2D = stack.get_modification(index) + if modification is SkeletonModification2DLookAt: + _head_look_at = modification as SkeletonModification2DLookAt + elif modification is SkeletonModification2DTwoBoneIK: + var ik := modification as SkeletonModification2DTwoBoneIK + for joint: String in LIMB_BONE_PATHS: + if ik.joint_two_bone2d_node == NodePath(String((LIMB_BONE_PATHS[joint] as Array)[1])): + _bend_modifications[joint] = ik + break + _previous_position = _rig.global_position + _previous_rotation = _rig.global_rotation + +func _resolve_limb_bones() -> void: + _limb_bones.clear() + for joint: String in LIMB_BONE_PATHS: + var paths: Array = LIMB_BONE_PATHS[joint] + var upper := _skeleton.get_node_or_null(NodePath(String(paths[0]))) as Bone2D + var lower := _skeleton.get_node_or_null(NodePath(String(paths[1]))) as Bone2D + if upper != null and lower != null: + _limb_bones[joint] = {"upper": upper, "lower": lower} + +func tick_pre_movement(delta: float, rig_state: int, rest_timeout: float, auto_recover: bool) -> void: + if _rig == null: return + if delta > 0.0: + _linear_velocity = (_rig.global_position - _previous_position) / delta + _angular_velocity = wrapf(_rig.global_rotation - _previous_rotation, -PI, PI) / delta + _previous_position = _rig.global_position + _previous_rotation = _rig.global_rotation + if rig_state != StickmanRig.RigState.RAGDOLL: return + var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D + if torso == null or not is_instance_valid(torso): return + var resting: bool = torso.sleeping or (torso.linear_velocity.length() <= REST_LINEAR_THRESHOLD and absf(torso.angular_velocity) <= REST_ANGULAR_THRESHOLD) + if resting != _at_rest: _at_rest = resting; rest_state_changed.emit(resting) + if not resting: _rest_timer = 0.0; _stabilize_timer = 0.0; return + if not auto_recover: _rest_timer = 0.0; return + _rest_timer += delta + if _rest_timer >= rest_timeout: + _stabilize_timer += delta + if _stabilize_timer >= STABILIZATION_DELAY: recovery_requested.emit() + +func build() -> bool: + if _rig == null or _skeleton == null: return false + var parent: Node = _rig.get_parent() + if parent == null: parent = _rig.get_tree().current_scene + if parent == null: return false + _root = Node2D.new(); _root.name = CONTAINER_NAME; parent.add_child(_root); _bodies.clear() + for entry: Dictionary in BODIES: _build_body(entry) + for entry: Dictionary in JOINTS: _build_joint(entry) + var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D + if torso != null: torso.linear_velocity = _linear_velocity; torso.angular_velocity = _angular_velocity + reset_rest_state() + return torso != null + +func destroy() -> void: + if _root != null and is_instance_valid(_root): _root.name = StringName(str(CONTAINER_NAME) + "_retired"); _root.queue_free() + _root = null; _bodies.clear(); reset_rest_state() + +func get_bodies() -> Dictionary: return _bodies +func is_at_rest() -> bool: return _at_rest +func reset_rest_state() -> void: _at_rest = false; _rest_timer = 0.0; _stabilize_timer = 0.0 +func apply_velocity_boost(velocity: Vector2) -> void: + for body: RigidBody2D in _bodies.values(): + if body != null and is_instance_valid(body): body.apply_central_impulse(velocity * body.mass) + +func enter_ragdoll() -> bool: + if _rig == null or _skeleton == null or _body_container == null: + push_warning("StickmanRagdoll: cannot enter ragdoll; missing rig nodes.") + return false + _capture_pre_ragdoll_state() + if _locomotion != null: + _locomotion.clear_impact_reactions() + _locomotion.set_collision_bodies_enabled(false) + _locomotion.cancel_walk(false) + if _anim_player != null and is_instance_valid(_anim_player): + _anim_player.stop(true) + if not build(): + return false + _body_container.visible = false + _body_container.modulate.a = 1.0 + if _skeleton.modification_stack != null: + _skeleton.modification_stack.enabled = false + reset_rest_state() + return true + +func request_recovery() -> bool: + if _rig == null or _rig.state != StickmanRig.RigState.RAGDOLL: + return false + _capture_ragdoll_pose() + destroy() + _reanchor_root_to_landing() + state_transition_requested.emit(StickmanRig.RigState.RECOVERING) + _snap_skeleton_to_pose() + if _locomotion != null: + _locomotion.set_collision_bodies_enabled(true) + _play_stand_up() + return true + +func cancel_recovery() -> void: + _stand_up_generation += 1 + if _stand_up_tween != null and _stand_up_tween.is_valid(): + _stand_up_tween.kill() + _stand_up_tween = null + +func snap_to_standing(previous_state: int) -> void: + if _locomotion != null: + _locomotion.set_terrain_following_enabled(false) + if previous_state == StickmanRig.RigState.RAGDOLL: + destroy() + elif previous_state == StickmanRig.RigState.RECOVERING: + cancel_recovery() + if _locomotion != null: + _locomotion.clear_impact_reactions(true) + _rig._hide_speech() + _restore_standing_markers(false) + _rearm_ik_stack() + _set_limb_mods_enabled(true) + if previous_state == StickmanRig.RigState.ANIMATED: + _rig._apply_head_lookat_mirror_mode() + else: + _restore_head_lookat_state() + _recovery_bones.clear() + reset_rest_state() + _clear_recovery_capture() + if _locomotion != null: + _locomotion.set_collision_bodies_enabled(true) + if _body_container != null: + _body_container.visible = true + _body_container.modulate.a = 1.0 + _rig._apply_idle_limb_solver_mode(_spawn_limb_rotations) + if previous_state != StickmanRig.RigState.ANIMATED: + state_transition_requested.emit(StickmanRig.RigState.ANIMATED) + +func initialize_spawn_pose_from_skeleton() -> void: + if _rig == null: + return + var skeleton := _rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D + if skeleton == null: + push_warning("StickmanRagdoll: cannot initialize spawn pose; missing Skeleton2D.") + return + var pose: Dictionary = {} + var torso := skeleton.get_node_or_null(NodePath("Torso")) as Bone2D + if torso != null: + pose["Torso"] = {"pos": _rig.to_local(torso.global_position), "rot": torso.rotation} + var head := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D + if head != null: + var head_tip := head.to_global(Vector2(maxf(head.length, 1.0), 0.0).rotated(deg_to_rad(head.bone_angle))) + pose["Head"] = {"pos": _rig.to_local(head_tip), "rot": 0.0} + var endpoints: Dictionary = { + "Left_Hand": "Torso/LeftUpperArm/LeftLowerArm", "Right_Hand": "Torso/RightUpperArm/RightLowerArm", + "Left_Leg": "Torso/LeftUpperLeg/LeftLowerLeg", "Right_Leg": "Torso/RightUpperLeg/RightLowerLeg", + } + for marker_name: String in endpoints: + var bone := skeleton.get_node_or_null(NodePath(String(endpoints[marker_name]))) as Bone2D + if bone == null: + return + var endpoint := bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle))) + pose[marker_name] = {"pos": _rig.to_local(endpoint), "rot": 0.0} + if pose.size() != IK_TARGET_PATHS.size(): + return + for marker_name: String in pose: + var marker := _rig.get_node_or_null(NodePath(String(IK_TARGET_PATHS[marker_name]))) as Marker2D + if marker == null: + return + var target: Dictionary = pose[marker_name] + marker.position = target.get("pos", marker.position) + marker.rotation = target.get("rot", marker.rotation) + if marker_name == "Left_Leg" or marker_name == "Right_Leg": + var base_marker := _rig.get_node_or_null(NodePath("AnimationTargets/%s_Base" % marker_name)) as Marker2D + if base_marker != null: + base_marker.position = marker.position + _spawn_pose = pose.duplicate(true) + _spawn_limb_rotations.clear() + for joint: String in LIMB_BONE_PATHS: + var pair: Array = LIMB_BONE_PATHS[joint] + var upper := skeleton.get_node_or_null(NodePath(String(pair[0]))) as Bone2D + var lower := skeleton.get_node_or_null(NodePath(String(pair[1]))) as Bone2D + if upper != null and lower != null: + _spawn_limb_rotations[joint] = {"upper": upper.rotation, "lower": lower.rotation} + +func get_spawn_limb_rotations() -> Dictionary: + return _spawn_limb_rotations + +func get_captured_pose() -> Dictionary: + return _captured_pose + +func _capture_pre_ragdoll_state() -> void: + _pre_ragdoll_state.clear() + var markers: Dictionary = {} + for marker_name: String in STAND_POSE: + var marker := _get_ik_marker(marker_name) + if marker != null: + markers[marker_name] = {"pos": marker.position, "rot": marker.rotation} + _pre_ragdoll_state["markers"] = markers + var rotations: Dictionary = {} + for joint: String in _limb_bones: + var pair: Dictionary = _limb_bones[joint] + var upper := pair.get("upper") as Bone2D + var lower := pair.get("lower") as Bone2D + rotations[joint] = {"upper": upper.rotation if upper != null else 0.0, "lower": lower.rotation if lower != null else 0.0} + _pre_ragdoll_state["limb_rotations"] = rotations + _pre_ragdoll_state["head_rotation"] = _head_bone.rotation if _head_bone != null else 0.0 + _pre_ragdoll_state["facing_profile"] = int(_rig.facing_profile) + var bends: Dictionary = {} + for joint: String in StickmanRig.BEND_JOINTS: + bends[joint] = _rig.get_joint_bend_flipped(joint) + _pre_ragdoll_state["bends"] = bends + _pre_ragdoll_state["head_lookat_enabled"] = _head_look_at.enabled if _head_look_at != null else false + var leg_sides: Dictionary = {} + for leg_joint: String in ["LeftLeg", "RightLeg"]: + var pair: Dictionary = _limb_bones.get(leg_joint, {}) + var upper := pair.get("upper") as Bone2D + var lower := pair.get("lower") as Bone2D + var marker := _get_ik_marker("Left_Leg" if leg_joint == "LeftLeg" else "Right_Leg") + if upper == null or lower == null or marker == null: + continue + var side := (marker.global_position - upper.global_position).cross(lower.global_position - upper.global_position) + leg_sides[leg_joint] = {"side": 1.0 if side >= 0.0 else -1.0, "flag": _rig.get_joint_bend_flipped(leg_joint)} + _pre_ragdoll_state["leg_sides"] = leg_sides + var torso_entry_rot := 0.0 + var torso_marker := _get_ik_marker("Torso") + if torso_marker != null: + torso_entry_rot = torso_marker.rotation + var spine_entry_rel := 0.0 + if _head_bone != null and _torso_bone != null: + spine_entry_rel = (_head_bone.global_position - _torso_bone.global_position).angle() - _rig.global_rotation + _pre_ragdoll_state["torso_chain_offset"] = _chain_angle_from_captured(spine_entry_rel) - torso_entry_rot + var head_entry_chain := torso_entry_rot + float(_pre_ragdoll_state.get("head_rotation", 0.0)) + _pre_ragdoll_state["head_chain_offset"] = _chain_angle_from_captured(-_rig.global_rotation) - head_entry_chain + +func _capture_ragdoll_pose() -> void: + _captured_pose.clear() + _captured_landing_center = Vector2.ZERO + _captured_ground_y = 0.0 + var top_y := INF + var bottom_y := -INF + var exclude_rids: Array[RID] = [] + for key: String in _bodies: + var body := _bodies[key] as RigidBody2D + if body == null or not is_instance_valid(body): + continue + var half := float(body.get_meta("half_height", 0.0)) + var radius := float(body.get_meta("radius", 0.0)) + var extent := half + radius + top_y = minf(top_y, body.global_position.y - extent) + bottom_y = maxf(bottom_y, body.global_position.y + extent) + exclude_rids.append(body.get_rid()) + _captured_pose[key] = {"pos": _rig.to_local(body.global_position), "rot": body.global_rotation - _rig.global_rotation, "half": half} + var torso := _bodies.get("torso") as RigidBody2D + if torso != null and is_instance_valid(torso): + _captured_landing_center = torso.global_position + _captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids) + if is_equal_approx(_captured_ground_y, bottom_y): + _captured_ground_y = torso.global_position.y + float(torso.get_meta("radius", 0.0)) + _probe_top_y = top_y + _probe_bottom_y = bottom_y + _probe_exclude = exclude_rids + +func _probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float: + if _locomotion != null: + return _locomotion.probe_ground_y(world_x, top_y, bottom_y, exclude) + var best_y := bottom_y + var space_state := _rig.get_world_2d().direct_space_state + if space_state == null: + return best_y + var best_dist := INF + for offset: float in GROUND_PROBE_X_OFFSETS: + var query := PhysicsRayQueryParameters2D.create(Vector2(world_x + offset, top_y - GROUND_PROBE_START_OFFSET), Vector2(world_x + offset, bottom_y + GROUND_PROBE_END_OFFSET)) + query.collision_mask = 1 + query.exclude = exclude + var hit := space_state.intersect_ray(query) + if hit.is_empty(): + continue + var y := (hit.get("position", Vector2.ZERO) as Vector2).y + if y < top_y - GROUND_PROBE_TOLERANCE or y > bottom_y + GROUND_PROBE_TOLERANCE: + continue + var distance := absf(y - bottom_y) + if distance < best_dist: + best_dist = distance + best_y = y + return best_y + +func _reanchor_root_to_landing() -> void: + if not _captured_pose.has("torso"): + return + var feet_world := Vector2(_captured_landing_center.x, _captured_ground_y) + var old_root := _rig.global_position + var foot_offset := _locomotion.get_foot_offset() if _locomotion != null else Vector2(0.0, -385.0) + _rig.global_position = feet_world + foot_offset + var shift := old_root - _rig.global_position + var local_shift := _rig.to_local(shift) - _rig.to_local(Vector2.ZERO) + for key: String in _captured_pose: + var entry: Dictionary = _captured_pose[key] + entry["pos"] = entry.get("pos", Vector2.ZERO) + local_shift + +func _clear_recovery_capture() -> void: + _captured_pose.clear() + _captured_landing_center = Vector2.ZERO + _captured_ground_y = 0.0 + _pre_ragdoll_state.clear() + _probe_top_y = 0.0 + _probe_bottom_y = 0.0 + _probe_exclude.clear() + +func _chain_angle_from_captured(captured_rel: float) -> float: + return -captured_rel if _rig.facing_profile == StickmanRig.FacingProfile.LEFT else captured_rel + +func _wrap_toward(value: float, target: float) -> float: + return target + wrapf(value - target, -PI, PI) + +func _natural_joint_target(current: float, target: float, offset: float) -> float: + var a := wrapf(current + offset, -PI, PI) + var b := wrapf(target + offset, -PI, PI) + var delta := wrapf(b - a, -PI, PI) + if a * b < 0.0 and absf(a) + absf(b) > PI: + delta += TAU if delta < 0.0 else -TAU + return current + delta + +func _set_limb_mods_enabled(enabled: bool) -> void: + for joint: String in StickmanRig.BEND_JOINTS: + var modification := _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK + if modification != null and is_instance_valid(modification): + modification.enabled = enabled + +func _restore_head_lookat_state() -> void: + if _head_look_at != null and is_instance_valid(_head_look_at): + _head_look_at.enabled = bool(_pre_ragdoll_state.get("head_lookat_enabled", false)) + +func _rearm_ik_stack() -> void: + if _skeleton == null or not is_instance_valid(_skeleton): + return + var stack: SkeletonModificationStack2D = _skeleton.modification_stack + if stack != null: + if not stack.get_is_setup(): + stack.setup() + stack.enabled = true + _skeleton.set_process_internal(true) + var torso_marker := _get_ik_marker("Torso") + if torso_marker != null: + var driver := torso_marker.get_node_or_null("RemoteTransform2D") as RemoteTransform2D + if driver != null: + driver.update_position = true + driver.update_rotation = true + driver.update_scale = true + +func _restore_standing_markers(use_spawn_pose: bool = true) -> void: + var pose := _spawn_pose if use_spawn_pose and not _spawn_pose.is_empty() else STAND_POSE + for marker_name: String in pose: + var marker := _get_ik_marker(marker_name) + if marker == null: + continue + var target: Dictionary = pose[marker_name] + marker.position = target.get("pos", marker.position) + if marker_name == "Torso": + marker.rotation = target.get("rot", marker.rotation) + +func _get_ik_marker(marker_name: String) -> Marker2D: + if _rig == null: + return null + var path := String(IK_TARGET_PATHS.get(marker_name, "")) + return _rig.get_node_or_null(NodePath(path)) as Marker2D if not path.is_empty() else null + +func _set_marker_from_body(marker_name: String, body_key: String) -> void: + var marker := _get_ik_marker(marker_name) + var pose: Dictionary = _captured_pose.get(body_key, {}) + if marker == null or pose.is_empty(): + return + var direction := Vector2.from_angle(float(pose.get("rot", 0.0)) + _rig.global_rotation) + var center_world := _rig.to_global(pose.get("pos", Vector2.ZERO)) + marker.position = _rig.to_local(center_world + direction * float(pose.get("half", 0.0))) + +func _snap_skeleton_to_pose() -> void: + _recovery_bones.clear() + var pre_markers: Dictionary = _pre_ragdoll_state.get("markers", {}) + var pre_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {}) + var pre_head_rotation := float(_pre_ragdoll_state.get("head_rotation", 0.0)) + var torso_chain_offset := float(_pre_ragdoll_state.get("torso_chain_offset", 0.0)) + var head_chain_offset := float(_pre_ragdoll_state.get("head_chain_offset", 0.0)) + var theta_t := 0.0 + var torso_marker := _get_ik_marker("Torso") + if torso_marker != null: + var torso_pose: Dictionary = _captured_pose.get("torso", {}) + if not torso_pose.is_empty(): + var torso_rel := float(torso_pose.get("rot", 0.0)) + var spine_dir := Vector2.from_angle(torso_rel + _rig.global_rotation) + var center_world := _rig.to_global(torso_pose.get("pos", Vector2.ZERO)) + torso_marker.position = _rig.to_local(center_world - spine_dir * float(torso_pose.get("half", 0.0))) + var raw_rotation := _chain_angle_from_captured(torso_rel) - torso_chain_offset + var pre_torso_rotation := float((pre_markers.get("Torso", {}) as Dictionary).get("rot", raw_rotation)) + theta_t = wrapf(_wrap_toward(raw_rotation, pre_torso_rotation), -PI, PI) + torso_marker.rotation = theta_t + var head_marker := _get_ik_marker("Head") + var head_pose: Dictionary = _captured_pose.get("head", {}) + if head_marker != null and not head_pose.is_empty(): + head_marker.position = head_pose.get("pos", head_marker.position) + _set_marker_from_body("Left_Hand", "left_lower_arm") + _set_marker_from_body("Right_Hand", "right_lower_arm") + _set_marker_from_body("Left_Leg", "left_lower_leg") + _set_marker_from_body("Right_Leg", "right_lower_leg") + for marker_name: String in ["Left_Leg", "Right_Leg"]: + var marker := _get_ik_marker(marker_name) + var body_key := "left_lower_leg" if marker_name == "Left_Leg" else "right_lower_leg" + if marker == null or (_captured_pose.get(body_key, {}) as Dictionary).is_empty(): + continue + var ankle_world := _rig.to_global(marker.position) + var ground_y := _probe_ground_y(ankle_world.x, _probe_top_y, _probe_bottom_y, _probe_exclude) + if ankle_world.y < ground_y: + ankle_world.y = ground_y + marker.position = _rig.to_local(ankle_world) + if not (_captured_pose.get("torso", {}) as Dictionary).is_empty(): + for joint: String in LIMB_BONE_PATHS: + var pair: Dictionary = _limb_bones.get(joint, {}) + var upper := pair.get("upper") as Bone2D + var lower := pair.get("lower") as Bone2D + if upper == null or lower == null: + continue + var keys: Array = LIMB_BODY_KEYS[joint] + var upper_pose: Dictionary = _captured_pose.get(keys[0], {}) + var lower_pose: Dictionary = _captured_pose.get(keys[1], {}) + if upper_pose.is_empty() or lower_pose.is_empty(): + continue + var upper_chain := _chain_angle_from_captured(float(upper_pose.get("rot", 0.0))) + var lower_chain := _chain_angle_from_captured(float(lower_pose.get("rot", 0.0))) + var pre_pair: Dictionary = pre_limb_rotations.get(joint, {}) + upper.rotation = _wrap_toward(upper_chain - theta_t - deg_to_rad(upper.bone_angle), float(pre_pair.get("upper", upper.rotation))) + lower.rotation = _wrap_toward(lower_chain - upper_chain + deg_to_rad(upper.bone_angle) - deg_to_rad(lower.bone_angle), float(pre_pair.get("lower", lower.rotation))) + upper.rotation = wrapf(upper.rotation, -PI, PI) + lower.rotation = wrapf(lower.rotation, -PI, PI) + if joint == "LeftLeg" or joint == "RightLeg": + continue + var target_upper := upper.rotation + wrapf(float(pre_pair.get("upper", upper.rotation)) - upper.rotation, -PI, PI) + var pre_lower := float(pre_pair.get("lower", lower.rotation)) + var path_override := String((RECOVERY_JOINT_PATH.get(_rig.facing_profile, {}) as Dictionary).get(joint, "")) + var target_lower := lower.rotation + wrapf(pre_lower - lower.rotation, -PI, PI) if path_override == "shortest" else _natural_joint_target(lower.rotation, pre_lower, deg_to_rad(lower.bone_angle - upper.bone_angle)) + _recovery_bones.append({"bone": upper, "target": target_upper}) + _recovery_bones.append({"bone": lower, "target": target_lower}) + if not head_pose.is_empty() and _head_bone != null: + var raw_head := _chain_angle_from_captured(float(head_pose.get("rot", 0.0))) - head_chain_offset - theta_t + _head_bone.rotation = wrapf(_wrap_toward(raw_head, pre_head_rotation), -PI, PI) + _recovery_bones.append({"bone": _head_bone, "target": _head_bone.rotation + wrapf(pre_head_rotation - _head_bone.rotation, -PI, PI)}) + if _body_container != null: + _body_container.visible = true + _body_container.modulate.a = 1.0 + _rearm_ik_stack() + _set_limb_mods_enabled(false) + if _head_look_at != null: + _head_look_at.enabled = false + for leg_joint: String in ["LeftLeg", "RightLeg"]: + var modification := _bend_modifications.get(leg_joint) as SkeletonModification2DTwoBoneIK + if modification == null: + continue + var entry: Dictionary = (_pre_ragdoll_state.get("leg_sides", {}) as Dictionary).get(leg_joint, {}) + var forced: Dictionary = RECOVERY_LEG_SIDE_FORCE.get(_rig.facing_profile, {}) + if forced.has(leg_joint): + modification.flip_bend_direction = bool(forced[leg_joint]) + elif not entry.is_empty(): + var upper_key := "left_upper_leg" if leg_joint == "LeftLeg" else "right_upper_leg" + var lower_key := "left_lower_leg" if leg_joint == "LeftLeg" else "right_lower_leg" + var up_pose: Dictionary = _captured_pose.get(upper_key, {}) + var low_pose: Dictionary = _captured_pose.get(lower_key, {}) + if not up_pose.is_empty() and not low_pose.is_empty(): + var dir_u := Vector2.from_angle(float(up_pose.get("rot", 0.0)) + _rig.global_rotation) + var dir_l := Vector2.from_angle(float(low_pose.get("rot", 0.0)) + _rig.global_rotation) + var center_u := _rig.to_global(up_pose.get("pos", Vector2.ZERO)) + var hip := center_u - dir_u * float(up_pose.get("half", 0.0)) + var knee := center_u + dir_u * float(up_pose.get("half", 0.0)) + var ankle := _rig.to_global(low_pose.get("pos", Vector2.ZERO)) + dir_l * float(low_pose.get("half", 0.0)) + var captured_side := 1.0 if (ankle - hip).cross(knee - hip) >= 0.0 else -1.0 + modification.flip_bend_direction = bool(entry.get("flag", false)) if captured_side == float(entry.get("side", captured_side)) else not bool(entry.get("flag", false)) + modification.enabled = true + +func _play_stand_up() -> void: + _stand_up_generation += 1 + var generation := _stand_up_generation + var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE) + _stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION) + for entry: Dictionary in _recovery_bones: + var bone := entry.get("bone") as Bone2D + if bone != null: + _stand_up_tween.tween_property(bone, "rotation", float(entry.get("target", bone.rotation)), STAND_UP_DURATION) + _stand_up_tween.finished.connect(_on_stand_up_finished.bind(generation)) + +func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween: + var tween := create_tween() + tween.set_parallel(true).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + for marker_name: String in target_pose: + var marker := _get_ik_marker(marker_name) + if marker == null: + continue + var target: Dictionary = target_pose[marker_name] + tween.tween_property(marker, "position", target.get("pos", marker.position), duration) + if marker_name == "Torso": + tween.tween_property(marker, "rotation", target.get("rot", marker.rotation), duration) + return tween + +func _on_stand_up_finished(generation: int) -> void: + if generation != _stand_up_generation or _rig.state != StickmanRig.RigState.RECOVERING: + return + var recovered_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {}) + _rig.set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(_rig.facing_profile)))) + var bends: Dictionary = _pre_ragdoll_state.get("bends", {}) + for joint: String in StickmanRig.BEND_JOINTS: + if bends.has(joint): + _rig.set_joint_bend_flipped(joint, bool(bends[joint])) + _set_limb_mods_enabled(true) + _restore_head_lookat_state() + _recovery_bones.clear() + _rearm_ik_stack() + if _body_container != null: + _body_container.visible = true + _body_container.modulate.a = 1.0 + if _locomotion != null: + _locomotion.set_collision_bodies_enabled(true) + _rig._apply_idle_limb_solver_mode(recovered_rotations) + state_transition_requested.emit(StickmanRig.RigState.ANIMATED) + +func _build_body(entry: Dictionary) -> void: + var key: String = str(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.collision_layer = 1; body.collision_mask = 1 + body.set_meta("stickman_rig", _rig); body.set_meta("part_name", key); body.set_meta("stickman_ragdoll_body", true) + var shape := CollisionShape2D.new() + if str(entry["kind"]) == "visual": + var visual := _rig.get_node_or_null(NodePath(str(entry["node_path"]))) as Node2D + if visual == null: return + var circle := CircleShape2D.new(); circle.radius = float(entry["radius"]); shape.shape = circle; body.add_child(shape); body.position = visual.global_position; body.set_meta("half_height", 0.0); body.set_meta("radius", float(entry["radius"])) + if not _clone_visuals(body, str(entry["visual_path"])): _add_circle(body, float(entry["radius"])) + else: + var bone := _skeleton.get_node_or_null(NodePath(str(entry["node_path"]))) as Bone2D + if bone == null: return + var origin: Vector2 = bone.global_position; var tip: Vector2 + if key == "torso": + var head := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D + if head == null: return + tip = head.global_position + else: tip = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle))) + var length: float = origin.distance_to(tip); var capsule := CapsuleShape2D.new(); capsule.height = length; capsule.radius = float(entry["radius"]); shape.shape = capsule; shape.rotation = -PI * 0.5; body.add_child(shape); body.position = (origin + tip) * 0.5; body.rotation = (tip - origin).angle(); body.set_meta("half_height", length * 0.5); body.set_meta("radius", float(entry["radius"])) + if not _clone_visuals(body, str(entry["visual_path"])): _add_capsule(body, length, float(entry["radius"])) + _root.add_child(body); _bodies[key] = body + +func _build_joint(entry: Dictionary) -> void: + var child := _bodies.get(str(entry["child"])) as RigidBody2D; var parent := _bodies.get(str(entry["parent"])) as RigidBody2D; var bone := _skeleton.get_node_or_null(NodePath(str(entry["pin"]))) as Bone2D + if child == null or parent == null or bone == null: return + var pin := PinJoint2D.new(); pin.name = "RagdollPin_" + str(entry["child"]); pin.position = bone.global_position; _root.add_child(pin); pin.node_a = pin.get_path_to(parent); pin.node_b = pin.get_path_to(child); pin.softness = TARGET_SOFTNESS + _apply_joint_limits(pin, str(entry["limit"]), str(entry["child"])) + +func _apply_joint_limits(pin: PinJoint2D, limit: String, child_key: String) -> void: + if limit == "shoulder": + pin.angular_limit_enabled = true; pin.angular_limit_lower = -deg_to_rad(160.0); pin.angular_limit_upper = deg_to_rad(160.0) + elif limit != "neck": + var profile: int = int(_rig.facing_profile) if _rig != null else StickmanRig.FacingProfile.FORWARD + var band: Dictionary = JOINT_BAND_LIMITS.get(profile, {}).get(child_key, {}) + if band.is_empty(): band = { "lower": -5.0 if limit == "cw" else -150.0, "upper": 150.0 if limit == "cw" else 5.0 } + pin.angular_limit_enabled = true; pin.angular_limit_lower = deg_to_rad(float(band["lower"])); pin.angular_limit_upper = deg_to_rad(float(band["upper"])) + else: pin.angular_limit_enabled = false + +func _clone_visuals(body: RigidBody2D, path: String) -> bool: + var source := _rig.get_node_or_null(NodePath(path)) as Node2D + if source == null: return false + var count: int = 0 + for child: Node in source.get_children(): + if child is Line2D: + var src := child as Line2D; var copy := Line2D.new(); var points := PackedVector2Array() + for point: Vector2 in src.points: points.append(body.to_local(src.to_global(point))) + copy.points = points; copy.width = src.width; copy.default_color = src.default_color; copy.begin_cap_mode = src.begin_cap_mode; copy.end_cap_mode = src.end_cap_mode; copy.joint_mode = src.joint_mode; copy.closed = src.closed; body.add_child(copy); count += 1 + elif child is Polygon2D: + var src := child as Polygon2D; var copy := Polygon2D.new(); var points := PackedVector2Array() + for point: Vector2 in src.polygon: points.append(body.to_local(src.to_global(point))) + copy.polygon = points; copy.color = src.color; body.add_child(copy); count += 1 + return count > 0 + +func _add_capsule(body: RigidBody2D, length: float, radius: float) -> void: + var line := Line2D.new(); line.points = PackedVector2Array([Vector2(-length * 0.5, 0.0), Vector2(length * 0.5, 0.0)]); line.width = radius * 2.0; line.default_color = VISUAL_COLOR; line.begin_cap_mode = Line2D.LINE_CAP_ROUND; line.end_cap_mode = Line2D.LINE_CAP_ROUND; body.add_child(line) +func _add_circle(body: RigidBody2D, radius: float) -> void: + var polygon := Polygon2D.new(); var points := PackedVector2Array() + for index: int in CIRCLE_SEGMENTS: points.append(Vector2.from_angle(TAU * float(index) / float(CIRCLE_SEGMENTS)) * radius) + polygon.polygon = points; polygon.color = Color.WHITE; body.add_child(polygon) diff --git a/scripts/stickman_ragdoll.gd.uid b/scripts/stickman_ragdoll.gd.uid new file mode 100644 index 0000000..ef05827 --- /dev/null +++ b/scripts/stickman_ragdoll.gd.uid @@ -0,0 +1 @@ +uid://bihf4kx2qgr1o diff --git a/scripts/stickman_rig.gd b/scripts/stickman_rig.gd index 9518346..41773fa 100644 --- a/scripts/stickman_rig.gd +++ b/scripts/stickman_rig.gd @@ -1,14 +1,13 @@ class_name StickmanRig extends Node2D ## StickmanRig - Runtime owner of facing direction and per-joint bone bend for -## master_rig.tscn (Phase 9 / Task 4). +## scenes/stickman_rig.tscn (Phase 9 / Task 4). ## -## Attached to the `Master` root node of master_rig.tscn. Owns the facing +## Attached to the `Master` root node of scenes/stickman_rig.tscn. Owns the facing ## preset, the four per-joint TwoBoneIK "Flip Bend Direction" flags, and the ## Body/* draw order. Non-@tool: node resolution, flag writes and z-order ## reordering run only at runtime (_ready + setters on a live instance). -const ACTION_REGISTRY := preload("res://scripts/action_registry.gd") # --------------------------------------------------------------------------- # Enums @@ -33,10 +32,6 @@ enum RunnerState { IDLE, EXECUTING } ## Which kind of queued action is currently executing (Phase 3a). enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING } -## Internal PLAY-only terrain locomotion phase. This deliberately remains -## separate from RigState: a normal fall is still an animated puppet. -enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT } - # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- @@ -96,318 +91,21 @@ 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 -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 -## 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 - -## Ground probe (recovery): the ray fans 7 X-offsets around the landing X, -## starts this far above the ragdoll's topmost point and ends this far below -## its bottommost point. -const GROUND_PROBE_START_OFFSET := 50.0 -const GROUND_PROBE_END_OFFSET := 1500.0 -## A hit is accepted only inside this band around the ragdoll's top/bottom -## extent (rejects overhang hits above and gap hits far below). -const GROUND_PROBE_TOLERANCE := 200.0 -const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0] - -## Upper + lower Bone2D paths (Skeleton2D-relative) per bend joint. Used to -## drive the limb bones directly during recovery while the TwoBoneIK mods are -## disabled (the IK re-solve would otherwise snap limbs to the profile's -## preferred bend solution instead of the captured ragdoll pose). +## Upper + lower Bone2D paths (Skeleton2D-relative), retained for mirrored +## idle-pose preservation and legacy test access. const LIMB_BONE_PATHS: Dictionary = { - "LeftArm": ["Torso/LeftUpperArm", "Torso/LeftUpperArm/LeftLowerArm"], + "LeftArm": ["Torso/LeftUpperArm", "Torso/LeftUpperArm/LeftLowerArm"], "RightArm": ["Torso/RightUpperArm", "Torso/RightUpperArm/RightLowerArm"], - "LeftLeg": ["Torso/LeftUpperLeg", "Torso/LeftUpperLeg/LeftLowerLeg"], + "LeftLeg": ["Torso/LeftUpperLeg", "Torso/LeftUpperLeg/LeftLowerLeg"], "RightLeg": ["Torso/RightUpperLeg", "Torso/RightUpperLeg/RightLowerLeg"], } -## Ragdoll body keys (upper, lower) per bend joint. -const LIMB_BODY_KEYS: Dictionary = { - "LeftArm": ["left_upper_arm", "left_lower_arm"], - "RightArm": ["right_upper_arm", "right_lower_arm"], - "LeftLeg": ["left_upper_leg", "left_lower_leg"], - "RightLeg": ["right_upper_leg", "right_lower_leg"], -} - -# --------------------------------------------------------------------------- -# Blend / recovery constants -# --------------------------------------------------------------------------- - -## Rest detection thresholds (linear px/s, angular rad/s) for the ragdoll -## torso. 5.0 px/s (not the plan's 0.1) — a soft-pinned ragdoll micro-jitters -## around ~0.5 px/s even when fully settled, so 0.1 is never reached. A body -## that the physics engine has put to sleep also counts as at rest. -const REST_LINEAR_THRESHOLD := 5.0 -const REST_ANGULAR_THRESHOLD := 0.1 - -## Duration of the stand-up tween (captured pose -> STAND_POSE). -const STAND_UP_DURATION := 2.0 - -## Extra hold after rest is detected before recovery captures the pose. -const STABILIZATION_DELAY := 0.1 - -## Pin softness applied to every ragdoll joint at build time. -const RAGDOLL_TARGET_SOFTNESS := 0.2 - -## IK-target standing positions (rig-local), matching master_rig.tscn defaults. -const STAND_POSE: Dictionary = { - "Torso": { "pos": Vector2(0, 10), "rot": 0.0 }, - "Head": { "pos": Vector2(100, -614), "rot": 0.0 }, - "Left_Hand": { "pos": Vector2(90, 110), "rot": 0.0 }, - "Right_Hand": { "pos": Vector2(-90, 110), "rot": 0.0 }, - "Left_Leg": { "pos": Vector2(-110, 380), "rot": 0.0 }, - "Right_Leg": { "pos": Vector2(110, 390), "rot": 0.0 }, -} - -## IK-target marker node paths (rig-root relative), keyed by marker name. -const IK_TARGET_PATHS: Dictionary = { - "Torso": "IK_Targets/Torso", - "Head": "IK_Targets/Head", - "Left_Hand": "IK_Targets/Left_Hand", - "Right_Hand": "IK_Targets/Right_Hand", - "Left_Leg": "IK_Targets/Left_Leg", - "Right_Leg": "IK_Targets/Right_Leg", -} - -# --------------------------------------------------------------------------- -# Director / navigation constants (Phase 3a) -# --------------------------------------------------------------------------- - -## Feet -> root translation (a ground point -> the hip/root position). -const FOOT_OFFSET := Vector2(0.0, -385.0) -## The navigation agent sits at the feet, on the nav mesh. -const NAV_AGENT_LOCAL_POS := Vector2(0.0, 385.0) -## Fallback arrival distance (px) to the root destination. -const ARRIVE_DISTANCE := 8.0 -const NAV_PATH_DESIRED_DISTANCE := 8.0 -const NAV_TARGET_DESIRED_DISTANCE := 12.0 -## How many post-sync physics frames to wait for the nav agent's reachability -## flag before deciding the walk is genuinely off-mesh (latch "direct"). -const LATCH_PROBE_MAX_FRAMES := 6 -const TERRAIN_PROBE_UP := 96.0 -const TERRAIN_PROBE_DOWN := 700.0 -const TERRAIN_NORMAL_MIN_Y := -0.15 -const TERRAIN_SUPPORT_RANGE := 160.0 -const TERRAIN_MAX_FOOT_SURFACE_DROP := 72.0 -const TERRAIN_FOOT_GUARD_HEIGHT := 24.0 -const TERRAIN_CENTER_PROBE_OFFSETS: Array[float] = [-24.0, 0.0, 24.0] -const TERRAIN_FOOT_PROBE_OFFSETS: Array[float] = [-4.0, 0.0, 4.0] -const TERRAIN_GRAVITY := 1400.0 -const TERRAIN_MAX_FALL_SPEED := 1800.0 -## Airborne motion tuning. Distances use rig-space pixels, speeds use px/s, and -## drag/response values use 1/s. AIR_DRAG_X is exponential horizontal damping: -## lower values preserve momentum for a longer, stronger curved arc; higher -## values shorten the arc by settling vx toward zero more quickly. -const AIR_DRAG_X := 0.75 -## Falling-speed range (px/s) for the procedural air pose: the pose weight is -## 0 at START_SPEED and 1 at FULL_SPEED, with smooth interpolation between. -const AIR_POSE_START_SPEED := 240.0 -const AIR_POSE_FULL_SPEED := 1200.0 -## How quickly the air-pose weight responds to changing fall speed; higher is -## snappier, lower is softer/slower (exponential response). -const AIR_POSE_RESPONSE := 10.0 -## Maximum forward hand offset (px) in canonical rig-local +X. The LEFT root -## mirror makes this screen-left automatically; the offset ramps from zero at -## FORWARD_START_SPEED to full size at FORWARD_FULL_SPEED before/alongside lift. -const AIR_HAND_FORWARD := 220.0 -const AIR_HAND_FORWARD_START_SPEED := 80.0 -const AIR_HAND_FORWARD_FULL_SPEED := 520.0 -## Maximum upward hand offset (px), applied as the main air-pose weight rises. -const AIR_HAND_LIFT := 680.0 -## Alternating hand/foot flail amplitudes (px): Vector2(x, y) is the maximum -## horizontal/vertical oscillation around the captured takeoff pose. -const AIR_HAND_FLAIL_AMPLITUDE := Vector2(20.0, 16.0) -const AIR_FOOT_FLAIL_AMPLITUDE := Vector2(14.0, 10.0) -## Flail oscillation frequency in cycles per second (Hz); higher is faster. -const AIR_FLAIL_HZ := 12.0 -## Hard-landing height thresholds (px of vertical fall distance). Falls below -## MIN are soft; MIN..MAX linearly scale the reaction; falls at/above MAX use -## the maximum compression and hand drop. -const HARD_LANDING_MIN_FALL_HEIGHT := 80.0 -const HARD_LANDING_MAX_FALL_HEIGHT := 900.0 -## Downward torso/pelvis compression range (px), selected from the fall-height -## scale above: MIN is the light hard landing, MAX is the deepest crouch. -const LANDING_COMPRESSION_MIN := 32.0 -const LANDING_COMPRESSION_MAX := 160.0 -## Downward hand reaction range (px), selected from the same fall-height scale; -## this makes a hard impact drive both arms sharply toward the ground. -const LANDING_HAND_DROP_MIN := 270.0 -const LANDING_HAND_DROP_MAX := 520.0 -## Landing impact phase durations in seconds: first compress downward, then -## recover back to the captured torso/hand pose (total is their sum). -const LANDING_COMPRESS_DURATION := 0.08 -const LANDING_RECOVER_DURATION := 0.22 -const FOOT_BLEND_RESPONSE := 14.0 -const PELVIS_MAX_OFFSET := 240.0 -const PELVIS_RESPONSE := 10.0 -const WALK_CYCLE_DURATION := 0.8 -## Rig-local anchor for the speech bubble, above the head. -const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0) - ## Debug gate for the Phase 3a walk/runner trace. Ship OFF. const DEBUG_WALK := false const DEBUG_TERRAIN := false -## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF. -const DEBUG_RECOVERY := false - -## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on. -func _walk_dbg(msg: String) -> void: - if DEBUG_WALK: - print("[walk] ", msg) - -## Preloaded (not a class_name type) so this script compiles even when the -## editor's global class cache is stale. -const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd") - -## 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", "visual_path": "Body/Body", "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", "visual_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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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" }, -] - -## --------------------------------------------------------------------------- -## Joint bend configuration (EDIT ME) -## --------------------------------------------------------------------------- -## Every per-joint bend decision in the ragdoll cycle lives in these tables, -## keyed by facing profile, so each joint can be tuned individually without -## touching the pipeline code. -## -## RAGDOLL_JOINT_BAND_LIMITS[profile][joint] — the fall's knee/elbow PinJoint2D -## angular band, in DEGREES, as { "lower": ..., "upper": ... }: -## - The band is the allowed range of the joint's relative angle -## (shin vs thigh / forearm vs upper arm), measured in the pin's frame. -## - 0 = straight. Negative = folds one way; positive = folds the other. -## - Natural shape: a SMALL side (~5) that resists hyperextension the wrong -## way, and a LARGE side (~150) on the fold direction. -## - To reverse a joint's bend: swap the magnitudes, e.g. -## { "lower": -5, "upper": 150 } <-> { "lower": -150, "upper": 5 }. -## - lower must be <= upper (the joint angle must stay inside the band). -## Missing joint entries fall back to the RAGDOLL_JOINTS `limit` string. -const RAGDOLL_JOINT_BAND_LIMITS: Dictionary = { - FacingProfile.FORWARD: { - "left_lower_arm": { "lower": -150.0, "upper": 5.0 }, - "right_lower_arm": { "lower": -150.0, "upper": 5.0 }, - "left_lower_leg": { "lower": -5.0, "upper": 150.0 }, - "right_lower_leg": { "lower": -5.0, "upper": 150.0 }, - }, - FacingProfile.LEFT: { - "left_lower_arm": { "lower": 60.0, "upper": 100.0 }, - "right_lower_arm": { "lower": -150.0, "upper": 5.0 }, - "left_lower_leg": { "lower": -100.0, "upper": -90.0 }, - "right_lower_leg": { "lower": -5.0, "upper": 150.0 }, - }, - FacingProfile.RIGHT: { - "left_lower_arm": { "lower": -100.0, "upper": -60.0 }, - "right_lower_arm": { "lower": -150.0, "upper": 5.0 }, - "left_lower_leg": { "lower": 90.0, "upper": 100.0 }, - "right_lower_leg": { "lower": -5.0, "upper": 150.0 }, - }, -} - -## RECOVERY_LEG_SIDE_FORCE[profile][leg] — optional manual override of the leg -## IK `flip_bend_direction` during the stand-up (true = inverted). A missing -## key means automatic: match the captured fall pose's knee side. -const RECOVERY_LEG_SIDE_FORCE: Dictionary = { - FacingProfile.FORWARD: {}, - FacingProfile.LEFT: {}, - FacingProfile.RIGHT: {}, -} - -## RECOVERY_JOINT_PATH[profile][joint] — optional override of the elbow/knee -## stand-up tween path: "shortest" = plain shortest rotation. A missing key -## means automatic: pass through straight when the shortest path would cross -## the +/-180 (fully-backward) boundary. -const RECOVERY_JOINT_PATH: Dictionary = { - FacingProfile.FORWARD: {}, - FacingProfile.LEFT: {}, - FacingProfile.RIGHT: {}, -} # --------------------------------------------------------------------------- # Exported controls @@ -501,110 +199,64 @@ var _bend_modifications: Dictionary = {} # { String : SkeletonModification2 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 -# --------------------------------------------------------------------------- -# Recovery state -# --------------------------------------------------------------------------- - -var _rest_timer: float = 0.0 -var _stabilize_timer: float = 0.0 -var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local) -var _captured_landing_center: Vector2 = Vector2.ZERO # ragdoll torso's world center at capture -var _captured_ground_y: float = 0.0 # torso's ground-contact line (center.y + radius) -var _stand_up_tween: Tween = null -var _stand_up_generation: int = 0 var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } } -var _recovery_bones: Array[Dictionary] = [] # [{ "bone": Bone2D, "target": float }] bones tweened in stand-up -var _pre_ragdoll_state: Dictionary = {} # captured at _enter_ragdoll; recovery's END target -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 = {} -var _spawn_pose: Dictionary = {} -var _spawn_limb_rotations: Dictionary = {} # --------------------------------------------------------------------------- # Director / navigation state (Phase 3a) # --------------------------------------------------------------------------- -var action_queue: Array[Dictionary] = [] +var _pending_action_queue: Array[Dictionary] = [] +var action_queue: Array[Dictionary]: + get: + return _action_runner_component.action_queue if _action_runner_component != null else _pending_action_queue + set(value): + if _action_runner_component != null: + _action_runner_component.action_queue = value + else: + _pending_action_queue = value ## Original .stk asset path, assigned by StageSpawner for scene persistence. var source_stk_path: String = "" -var _runner_state: RunnerState = RunnerState.IDLE -var _action_phase: ActionPhase = ActionPhase.NONE -var _current_index: int = -1 -var _run_queue: Array[Dictionary] = [] -var _current_run_item: Dictionary = {} -var _run_cursor: int = 0 -var _stop_requested: bool = false - -var _nav_agent: NavigationAgent2D = null -## NavigationAgent2D is a plain Node (not Node2D): it has no `position`; it -## derives its agent position from its parent Node2D's global position. This -## anchor sits at the feet (NAV_AGENT_LOCAL_POS) so the agent paths from -## ground level. -var _nav_anchor: Node2D = null - -var _walking: bool = false -var _walk_target_feet: Vector2 = Vector2.ZERO -var _walk_speed_current: float = 300.0 -var _walk_mode: String = "nav" # "nav" (follow mesh) | "direct" (off-mesh straight line) -var _walk_mode_latched: bool = false -var _walk_latch_probe_frames: int = 0 -var _walk_settle_frames: int = 0 -var _walk_done: bool = false -var _leg_base_markers: Dictionary = {} -var _foot_contacts: Dictionary = { - "Left_Leg": {"valid": false, "locked": false}, - "Right_Leg": {"valid": false, "locked": false}, -} -var _terrain_pelvis_offset: float = 0.0 -var _terrain_air_velocity: Vector2 = Vector2.ZERO -var _terrain_motion: TerrainMotion = TerrainMotion.DISABLED -var _last_grounded_walk_velocity_x: float = 0.0 -var _air_time: float = 0.0 -var _air_pose_weight: float = 0.0 -var _air_pose_base: Dictionary = {} -var _air_entry_root_y: float = 0.0 -var _air_fall_distance: float = 0.0 -var _landing_elapsed: float = 0.0 -var _landing_impact_speed: float = 0.0 -var _landing_compression: float = 0.0 -var _landing_hand_drop: float = 0.0 -var _landing_torso_base: Vector2 = Vector2.ZERO -var _landing_hand_start: Dictionary = {} -var _landing_contacts: Dictionary = {} -var _terrain_paused_walk_animation: bool = false -var _terrain_following_enabled: bool = false - -var _ragdoll_at_rest: bool = false - -var _speech_bubble = null # SpeechBubble (preloaded script) -var _speech_active: bool = false -var _speech_time_left: float = 0.0 -var _primary_speech: Dictionary = {} -var _overlay_speech: Dictionary = {} - -var _phase_timer: float = 0.0 +## Authored nonvisual subsystem children. They establish explicit ownership +## boundaries while this facade preserves the long-standing public API. +var _locomotion_component: StickmanLocomotion = null +var _ragdoll_component: StickmanRagdoll = null +var _action_runner_component: StickmanActionRunner = null # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: + _locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion + _ragdoll_component = get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll + _action_runner_component = get_node_or_null(NodePath("RigComponents/ActionRunner")) as StickmanActionRunner + if _locomotion_component == null: + push_warning("StickmanRig: missing RigComponents/Locomotion; locomotion boundary unavailable.") + if _ragdoll_component == null: + push_warning("StickmanRig: missing RigComponents/Ragdoll; ragdoll boundary unavailable.") + if _action_runner_component == null: + push_warning("StickmanRig: missing RigComponents/ActionRunner; action boundary unavailable.") + if _locomotion_component != null: + _locomotion_component.configure(self) + _locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target)) + if _ragdoll_component != null: + _ragdoll_component.configure(self, _locomotion_component) + _ragdoll_component.recovery_requested.connect(func() -> void: request_recovery()) + _ragdoll_component.state_transition_requested.connect(_set_state) + if _action_runner_component != null: + _action_runner_component.configure(self, _locomotion_component) + _action_runner_component.action_queue = _pending_action_queue + _action_runner_component.action_started.connect(func(action: Dictionary, index: int) -> void: action_started.emit(action, index)) + _action_runner_component.action_finished.connect(func(action: Dictionary, index: int) -> void: action_finished.emit(action, index)) + _action_runner_component.queue_finished.connect(func() -> void: queue_finished.emit()) + _action_runner_component.queue_changed.connect(func() -> void: queue_changed.emit()) + _action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit()) + _action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled)) + _action_runner_component.recovery_requested.connect(func() -> void: request_recovery()) # Resolve all runtime node references. _skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D if _skeleton == null: @@ -625,17 +277,10 @@ func _ready() -> void: _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) - for leg_name: String in ["Left_Leg", "Right_Leg"]: - var base := get_node_or_null(NodePath("AnimationTargets/%s_Base" % leg_name)) as Marker2D - if base == null: - push_warning("StickmanRig: missing AnimationTargets/%s_Base." % leg_name) - else: - _leg_base_markers[leg_name] = base - _resolve_bend_modifications() _resolve_limb_bones() - _resolve_reaction_bones() - _build_collision_bodies() + if _locomotion_component != null: + _locomotion_component.setup_collision_bodies() # Enable the modification stack (IK solves only at runtime). if _skeleton != null: @@ -654,45 +299,46 @@ func _ready() -> void: _nodes_ready = true _apply_profile() - # Build the navigation agent at the feet (Phase 3a). It shares the default - # navigation map/layer with the stage's NavigationRegion2D. - # NavigationAgent2D is a plain Node: it has no `position`; it derives its - # agent position from its parent Node2D's global position, so we anchor it - # under a Node2D placed at the feet. - _nav_anchor = Node2D.new() - _nav_anchor.name = "NavigationAgentAnchor" - _nav_anchor.position = NAV_AGENT_LOCAL_POS - add_child(_nav_anchor) - _nav_agent = NavigationAgent2D.new() - _nav_agent.name = "NavigationAgent2D" - _nav_agent.path_desired_distance = NAV_PATH_DESIRED_DISTANCE - _nav_agent.target_desired_distance = NAV_TARGET_DESIRED_DISTANCE - _nav_agent.path_max_distance = 100.0 - _nav_agent.max_speed = walk_speed - _nav_agent.avoidance_enabled = false - _nav_anchor.add_child(_nav_agent) - - _prev_global_pos = global_position - _prev_global_rot = global_rotation + if _ragdoll_component != null: + _ragdoll_component.initialize(_skeleton) func _physics_process(delta: float) -> void: - _track_momentum(delta) - _update_rest_detection(delta) - _clear_terrain_pelvis_offset() - # A ledge departure inherits only this frame's grounded displacement. Reset - # before walking so nav waits, idle frames, and early returns cannot leak a - # stale velocity into a later fall. - if _terrain_following_enabled and _terrain_motion == TerrainMotion.GROUNDED: - _last_grounded_walk_velocity_x = 0.0 - _update_walking(delta) - _update_terrain_following(delta) - _settle_walk_markers() - _update_speech(delta) - _update_runner(delta) + if _ragdoll_component != null: + _ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover) + if _locomotion_component != null: + _locomotion_component.tick_movement(delta, int(state)) + if _action_runner_component != null: + _action_runner_component.tick(delta, int(state), is_ragdoll_at_rest()) _pin_mirrored_head_rotation() - _apply_impact_reactions(delta) - _sync_collision_bodies() + if _locomotion_component != null: + _locomotion_component.tick_post_skeleton(delta, int(state)) + + +func get_locomotion() -> StickmanLocomotion: + return _locomotion_component + + +func get_collision_bodies() -> Dictionary: + return _locomotion_component.collision_bodies if _locomotion_component != null else {} + + +func get_collision_body_rids() -> Array[RID]: + var result: Array[RID] = [] + for proxy: AnimatableBody2D in get_collision_bodies().values(): + if proxy != null and is_instance_valid(proxy): + var rid: RID = proxy.get_rid() + if rid.is_valid(): + result.append(rid) + return result + + +## Narrow shared skeleton service used by locomotion and ragdoll components. +func _get_ik_marker(marker_name: String) -> Marker2D: + var path := String(StickmanRagdoll.IK_TARGET_PATHS.get(marker_name, "")) + if path.is_empty(): + return null + return get_node_or_null(NodePath(path)) as Marker2D @@ -710,473 +356,27 @@ func _pin_mirrored_head_rotation() -> void: if _head_look_at.enabled: _head_look_at.enabled = false if _head_bone != null and is_instance_valid(_head_bone): - _head_bone.rotation = 0 + _head_bone.rotation = 0.0 -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 - - -func _clear_terrain_pelvis_offset() -> void: - # Terrain height is accumulated into the runtime root while PLAY is active. - # This value is only the last frame's correction, not an offset to undo. - _terrain_pelvis_offset = 0.0 - - -## Arms the animated terrain controller without teleporting. An unsupported rig -## free-falls until its centerline reaches a TerrainBlock support surface. +## Public terrain facade. Runtime terrain state and implementation live in RigComponents/Locomotion. func begin_terrain_fall() -> void: - _terrain_following_enabled = true - _clear_terrain_pelvis_offset() - _terrain_pelvis_offset = 0.0 - _terrain_paused_walk_animation = false - _enter_airborne(0.0) - for leg_name: String in _foot_contacts: - _foot_contacts[leg_name] = {"valid": false, "locked": false} + if _locomotion_component != null: + _locomotion_component.begin_terrain_fall() -## Kept as a compatibility alias for callers introduced during Phase 6. func snap_to_terrain() -> void: begin_terrain_fall() func set_terrain_following_enabled(enabled: bool) -> void: - if enabled: - if _terrain_motion == TerrainMotion.DISABLED: - begin_terrain_fall() - return - _terrain_following_enabled = false - _cancel_landing_impact(true) - _restore_air_pose() - _clear_terrain_pelvis_offset() - _terrain_pelvis_offset = 0.0 - _terrain_air_velocity = Vector2.ZERO - _terrain_motion = TerrainMotion.DISABLED - _terrain_paused_walk_animation = false - for leg_name: String in _foot_contacts: - _foot_contacts[leg_name] = {"valid": false, "locked": false} - for leg_name: String in ["Left_Leg", "Right_Leg"]: - var base := _leg_base_markers.get(leg_name) as Marker2D - var target := _get_ik_marker(leg_name) - if base != null and target != null: - target.position = base.position - - -func _probe_terrain_below(world_point: Vector2, probe_down: float = TERRAIN_PROBE_DOWN) -> Dictionary: - var space_state := get_world_2d().direct_space_state - if space_state == null: - return {"valid": false} - var query := PhysicsRayQueryParameters2D.create( - world_point + Vector2.UP * TERRAIN_PROBE_UP, - world_point + Vector2.DOWN * probe_down) - query.collision_mask = 1 - query.collide_with_areas = false - var excluded: Array[RID] = [] - for proxy: AnimatableBody2D in _collision_bodies.values(): - if proxy != null and is_instance_valid(proxy): - var rid := proxy.get_rid() - if rid.is_valid(): - excluded.append(rid) - query.exclude = excluded - var hit := space_state.intersect_ray(query) - if hit.is_empty() or not (hit.get("collider") is TerrainBlock): - return {"valid": false} - var normal: Vector2 = hit.get("normal", Vector2.ZERO) - if normal.y > TERRAIN_NORMAL_MIN_Y: - return {"valid": false} - return {"valid": true, "world_pos": hit.get("position", world_point), "normal": normal} - - -## Samples a small horizontal fan and returns the highest valid terrain surface. -## This keeps an overlapping ramp authoritative over flat ground underneath and -## prevents a single seam/edge miss from dropping the controller for one frame. -func _probe_terrain_fan(world_point: Vector2, offsets: Array[float]) -> Dictionary: - var best: Dictionary = {"valid": false} - for x_offset: float in offsets: - var hit := _probe_terrain_below(world_point + Vector2(x_offset, 0.0)) - if not bool(hit.get("valid", false)): - continue - if not bool(best.get("valid", false)) \ - or (hit["world_pos"] as Vector2).y < (best["world_pos"] as Vector2).y: - best = hit - return best - - -func _stance_weight(leg_name: String) -> float: - if not _walking or _anim_player == null: - return 1.0 - var phase := fmod(_anim_player.get_current_animation_position(), WALK_CYCLE_DURATION) - # Continuous complementary weights remove the one-frame 0->1 target snap at - # each gait handoff while preserving the clip's alternating half-cycle. - var right_weight := 0.5 + 0.5 * cos(TAU * phase / WALK_CYCLE_DURATION) - return right_weight if leg_name == "Right_Leg" else 1.0 - right_weight - - -func _update_terrain_following(delta: float) -> void: - if not _terrain_following_enabled \ - or state != RigState.ANIMATED or _leg_base_markers.size() != 2: - _terrain_pelvis_offset = 0.0 - return - # Terrain following requires the live leg solvers; preserve the old pinned - # LEFT idle behavior only when no terrain controller is available. - _set_limb_mods_enabled(true) - var center_world := to_global(NAV_AGENT_LOCAL_POS) - var center_probe := _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS) - var center_delta := INF - if bool(center_probe.get("valid", false)): - center_delta = (center_probe["world_pos"] as Vector2).y - center_world.y - var center_supported := bool(center_probe.get("valid", false)) \ - and absf(center_delta) <= TERRAIN_SUPPORT_RANGE - var center_surface_y := (center_probe["world_pos"] as Vector2).y \ - if bool(center_probe.get("valid", false)) else INF - var solved: Dictionary = {} - var debug_samples: Array[String] = [] - for leg_name: String in ["Left_Leg", "Right_Leg"]: - var base := _leg_base_markers[leg_name] as Marker2D - var target := _get_ik_marker(leg_name) - if base == null or target == null: - continue - var base_world := base.global_position - var probe := _probe_terrain_fan(base_world, TERRAIN_FOOT_PROBE_OFFSETS) - var probe_delta := INF - if bool(probe.get("valid", false)): - probe_delta = (probe["world_pos"] as Vector2).y - base_world.y - # A foot may reach above/below the pelvis support surface, but must not - # choose the flat ground far underneath an overlapping ramp. - var within_reach := absf(probe_delta) <= PELVIS_MAX_OFFSET - if within_reach and center_supported: - within_reach = (probe["world_pos"] as Vector2).y \ - <= center_surface_y + TERRAIN_MAX_FOOT_SURFACE_DROP - var stance := _stance_weight(leg_name) - debug_samples.append("%s hit=%s dy=%.1f stance=%.1f" % [ - leg_name, str(bool(probe.get("valid", false))), probe_delta, stance]) - var contact: Dictionary = _foot_contacts[leg_name] - if stance >= 0.5 and bool(probe.get("valid", false)) and within_reach: - if not bool(contact.get("locked", false)): - contact = {"valid": true, "locked": true, "world_pos": probe["world_pos"], "normal": probe["normal"]} - # Refresh a planted contact when a moved/edited terrain surface changes. - elif (contact.get("world_pos", base_world) as Vector2).distance_to(probe["world_pos"]) > 2.0: - contact["world_pos"] = probe["world_pos"] - elif stance < 0.5 or not within_reach: - contact = {"valid": bool(probe.get("valid", false)), "locked": false} - _foot_contacts[leg_name] = contact - var floor_world := base_world - if bool(contact.get("locked", false)): - floor_world = contact["world_pos"] - elif bool(probe.get("valid", false)) and within_reach: - floor_world = probe["world_pos"] - solved[leg_name] = { - "base": base_world, - "base_marker": base, - "floor": floor_world, - "surface": probe.get("world_pos", base_world) if within_reach else base_world, - "surface_valid": bool(probe.get("valid", false)) and within_reach, - "weight": stance, - "target": target, - } - if _terrain_motion == TerrainMotion.LANDING_IMPACT: - _update_landing_impact(delta) - return - if _terrain_motion == TerrainMotion.AIRBORNE: - _update_airborne(delta, center_probe, solved) - return - if not center_supported: - _enter_airborne(_last_grounded_walk_velocity_x) - _update_airborne(delta, center_probe, solved) - return - var desired_offset := clampf(center_delta, -PELVIS_MAX_OFFSET, PELVIS_MAX_OFFSET) - if _terrain_motion == TerrainMotion.AIRBORNE: - # Landing correction is immediate so a fast fall cannot tunnel a leg - # through a thin ramp between physics frames. - _terrain_pelvis_offset = desired_offset - else: - var pelvis_blend := 1.0 - exp(-PELVIS_RESPONSE * delta) - # Apply a fraction of the remaining error directly to the runtime root. - # The correction accumulates, allowing consecutive ramps to climb far - # beyond the single-frame compensation range. - _terrain_pelvis_offset = lerpf(0.0, desired_offset, pelvis_blend) - _terrain_motion = TerrainMotion.GROUNDED - _terrain_air_velocity = Vector2.ZERO - if _terrain_paused_walk_animation and _walking and _anim_player != null: - _anim_player.play() - _terrain_paused_walk_animation = false - global_position.y += _terrain_pelvis_offset - if DEBUG_TERRAIN and Engine.get_physics_frames() % 30 == 0: - print("[terrain] GROUND root=", global_position, " offset=", _terrain_pelvis_offset, - " center_dy=", center_delta, " walk=", _walking, " ", debug_samples) - for leg_name: String in solved: - var item: Dictionary = solved[leg_name] - # Re-read the animated base after moving the root. Using the pre-pelvis - # world point here counter-moved the swing foot and visibly bent the knee. - var base_after := (item["base_marker"] as Marker2D).global_position - var contact_weight := float(item["weight"]) - var surface: Vector2 = item["surface"] - if bool(item["surface_valid"]): - var clearance := surface.y - base_after.y - var guard_weight := 1.0 - clampf( - clearance / TERRAIN_FOOT_GUARD_HEIGHT, 0.0, 1.0) - contact_weight = maxf(contact_weight, smoothstep(0.0, 1.0, guard_weight)) - var wanted := base_after - wanted.y = lerpf(base_after.y, (item["floor"] as Vector2).y, contact_weight) - if bool(item["surface_valid"]) and wanted.y > surface.y: - wanted.y = surface.y - (item["target"] as Marker2D).global_position = wanted - - -func _enter_airborne(horizontal_velocity: float) -> void: - if _terrain_motion != TerrainMotion.AIRBORNE: - _air_pose_base.clear() - for marker_name: String in ["Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]: - var marker := _get_ik_marker(marker_name) - if marker != null: - _air_pose_base[marker_name] = marker.position - else: - push_warning("StickmanRig: missing IK marker '%s' for air pose." % marker_name) - _air_time = 0.0 - _air_pose_weight = 0.0 - _air_entry_root_y = global_position.y - _air_fall_distance = 0.0 - _terrain_motion = TerrainMotion.AIRBORNE - _terrain_air_velocity = Vector2(horizontal_velocity, 0.0) - if _walking and _anim_player != null and _anim_player.is_playing(): - _anim_player.pause() - _terrain_paused_walk_animation = true - for leg_name: String in _foot_contacts: - _foot_contacts[leg_name] = {"valid": false, "locked": false} - - -func _update_airborne(delta: float, center_probe: Dictionary, solved: Dictionary) -> void: - _terrain_air_velocity.x *= exp(-AIR_DRAG_X * delta) - _terrain_air_velocity.y = minf(_terrain_air_velocity.y + TERRAIN_GRAVITY * delta, TERRAIN_MAX_FALL_SPEED) - var horizontal_motion := _sweep_airborne_horizontal_motion(Vector2(_terrain_air_velocity.x * delta, 0.0)) - global_position += horizontal_motion - global_position.y += _terrain_air_velocity.y * delta - _air_fall_distance = maxf(_air_fall_distance, global_position.y - _air_entry_root_y) - _air_time += delta - _apply_air_pose(delta) - var center_world := to_global(NAV_AGENT_LOCAL_POS) - # Probe after both swept X and gravity Y motion. The pre-motion sample may - # belong to a ledge we have already passed, especially at low frame rates. - center_probe = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS) - var valid := bool(center_probe.get("valid", false)) - var landing_delta := INF - if valid: - landing_delta = (center_probe["world_pos"] as Vector2).y - center_world.y - var swept_support := valid and _terrain_air_velocity.y >= 0.0 \ - and landing_delta >= -TERRAIN_SUPPORT_RANGE \ - and landing_delta <= TERRAIN_SUPPORT_RANGE + _terrain_air_velocity.y * delta - if swept_support: - global_position.y += landing_delta - var impact_speed := maxf(_terrain_air_velocity.y, 0.0) - _terrain_air_velocity = Vector2.ZERO - if _air_fall_distance >= HARD_LANDING_MIN_FALL_HEIGHT: - _begin_landing(impact_speed, center_probe, solved) - else: - _restore_air_pose() - _terrain_motion = TerrainMotion.GROUNDED - if _terrain_paused_walk_animation and _walking and _anim_player != null: - _anim_player.play() - _terrain_paused_walk_animation = false - - -func _apply_air_pose(delta: float) -> void: - if _air_pose_base.is_empty(): - return - var speed := maxf(_terrain_air_velocity.y, 0.0) - var lift_weight := smoothstep(AIR_POSE_START_SPEED, AIR_POSE_FULL_SPEED, speed) - var forward_weight := smoothstep(AIR_HAND_FORWARD_START_SPEED, AIR_HAND_FORWARD_FULL_SPEED, speed) - _air_pose_weight = lerpf(_air_pose_weight, lift_weight, 1.0 - exp(-AIR_POSE_RESPONSE * delta)) - var phase := TAU * AIR_FLAIL_HZ * _air_time - for marker_name: String in _air_pose_base: - var marker := _get_ik_marker(marker_name) - if marker == null: - continue - var base: Vector2 = _air_pose_base[marker_name] - var right_side := marker_name.begins_with("Right") - var local_phase := phase + (PI if right_side else 0.0) - var offset := Vector2.ZERO - if marker_name.ends_with("Hand"): - offset = Vector2(AIR_HAND_FORWARD * forward_weight, -AIR_HAND_LIFT * _air_pose_weight) \ - + Vector2(sin(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.y) * _air_pose_weight - else: - offset = Vector2(sin(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.y) - marker.position = base + offset if marker_name.ends_with("Hand") else base + offset * _air_pose_weight - - -func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2: - if is_zero_approx(motion.x): - return motion - var space_state := get_world_2d().direct_space_state - if space_state == null: - push_warning("StickmanRig: no physics space for airborne sweep.") - return motion - var safe_fraction := 1.0 - var excluded: Array[RID] = [] - for proxy: AnimatableBody2D in _collision_bodies.values(): - if proxy != null and is_instance_valid(proxy): - excluded.append(proxy.get_rid()) - for proxy: AnimatableBody2D in _collision_bodies.values(): - if proxy == null or not is_instance_valid(proxy): - continue - var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D - if shape_node == null or shape_node.shape == null: - continue - var query := PhysicsShapeQueryParameters2D.new() - query.shape = shape_node.shape - query.transform = shape_node.global_transform - query.motion = motion - query.collision_mask = COLLISION_LAYER - query.exclude = excluded - var cast := space_state.cast_motion(query) - if cast.size() >= 1: - safe_fraction = minf(safe_fraction, cast[0]) - if safe_fraction < 0.9999: - _terrain_air_velocity.x = 0.0 - return motion * safe_fraction - - -func _begin_landing(impact_speed: float, center_probe: Dictionary, solved: Dictionary) -> void: - _terrain_motion = TerrainMotion.LANDING_IMPACT - _landing_elapsed = 0.0 - _landing_impact_speed = impact_speed - var impact_weight := clampf(inverse_lerp( - HARD_LANDING_MIN_FALL_HEIGHT, HARD_LANDING_MAX_FALL_HEIGHT, _air_fall_distance), 0.0, 1.0) - _landing_compression = lerpf(LANDING_COMPRESSION_MIN, LANDING_COMPRESSION_MAX, impact_weight) - _landing_hand_drop = lerpf(LANDING_HAND_DROP_MIN, LANDING_HAND_DROP_MAX, impact_weight) - var torso := _get_ik_marker("Torso") - if torso != null: - _landing_torso_base = torso.position - _landing_hand_start.clear() - for hand_name: String in ["Left_Hand", "Right_Hand"]: - var hand := _get_ik_marker(hand_name) - if hand != null: - _landing_hand_start[hand_name] = hand.position - _landing_contacts.clear() - var center_y := (center_probe.get("world_pos", global_position) as Vector2).y - for leg_name: String in ["Left_Leg", "Right_Leg"]: - var target := _get_ik_marker(leg_name) - if target == null: - continue - # Take an independent post-correction sample for each foot. `solved` - # belongs to the pre-air-motion controller pass and must not lock feet to - # geometry left behind during the fall. - var base := _leg_base_markers.get(leg_name) as Marker2D - var sample_world := base.global_position if base != null else target.global_position - var foot_probe := _probe_terrain_fan(sample_world, TERRAIN_FOOT_PROBE_OFFSETS) - var contact := Vector2(target.global_position.x, center_y) - if bool(foot_probe.get("valid", false)): - contact = foot_probe["world_pos"] as Vector2 - _landing_contacts[leg_name] = contact - target.global_position = contact - - -func _update_landing_impact(delta: float) -> void: - _landing_elapsed += delta - var total := LANDING_COMPRESS_DURATION + LANDING_RECOVER_DURATION - var torso_offset := 0.0 - if _landing_elapsed <= LANDING_COMPRESS_DURATION: - var t := clampf(_landing_elapsed / LANDING_COMPRESS_DURATION, 0.0, 1.0) - torso_offset = _landing_compression * (1.0 - (1.0 - t) * (1.0 - t)) - else: - var t := clampf((_landing_elapsed - LANDING_COMPRESS_DURATION) / LANDING_RECOVER_DURATION, 0.0, 1.0) - torso_offset = _landing_compression * cos(t * PI * 0.5) - var torso := _get_ik_marker("Torso") - if torso != null: - torso.position = _landing_torso_base + Vector2.DOWN * torso_offset - for leg_name: String in _landing_contacts: - var target := _get_ik_marker(leg_name) - if target != null: - target.global_position = _landing_contacts[leg_name] - var hand_t := clampf(_landing_elapsed / total, 0.0, 1.0) - var hand_drop_weight := torso_offset / maxf(_landing_compression, 0.0001) - for hand_name: String in _landing_hand_start: - var hand := _get_ik_marker(hand_name) - var base: Vector2 = _air_pose_base.get(hand_name, _landing_hand_start[hand_name]) - if hand != null: - hand.position = ( _landing_hand_start[hand_name] as Vector2).lerp(base, hand_t) \ - + Vector2.DOWN * _landing_hand_drop * hand_drop_weight - if _landing_elapsed >= total: - _finish_landing_impact() - - -func _finish_landing_impact() -> void: - var torso := _get_ik_marker("Torso") - if torso != null: - torso.position = _landing_torso_base - _restore_air_pose() - _landing_contacts.clear() - _landing_hand_start.clear() - _landing_elapsed = 0.0 - _landing_compression = 0.0 - _landing_hand_drop = 0.0 - _terrain_motion = TerrainMotion.GROUNDED - if _terrain_paused_walk_animation and _walking and _anim_player != null: - _anim_player.play() - _terrain_paused_walk_animation = false - - -func _cancel_landing_impact(restore_pose: bool) -> void: - if _terrain_motion == TerrainMotion.LANDING_IMPACT and restore_pose: - var torso := _get_ik_marker("Torso") - if torso != null: - torso.position = _landing_torso_base - _landing_contacts.clear() - _landing_hand_start.clear() - _landing_elapsed = 0.0 - _landing_compression = 0.0 - _landing_hand_drop = 0.0 - - -func _restore_air_pose() -> void: - for marker_name: String in _air_pose_base: - var marker := _get_ik_marker(marker_name) - if marker != null: - marker.position = _air_pose_base[marker_name] - _air_pose_base.clear() - _air_pose_weight = 0.0 - _air_time = 0.0 - _air_entry_root_y = 0.0 - _air_fall_distance = 0.0 + if _locomotion_component != null: + _locomotion_component.set_terrain_following_enabled(enabled) func _is_action_queue_blocked() -> bool: - return _terrain_motion == TerrainMotion.LANDING_IMPACT + return _locomotion_component != null and _locomotion_component.is_action_blocked() -# --------------------------------------------------------------------------- -# Per-frame rest detection -# --------------------------------------------------------------------------- - -## RAGDOLL rest detection: when the torso sits still long enough (and -## auto_recover is on), trigger recovery after a short stabilization delay. -func _update_rest_detection(delta: float) -> void: - if state != RigState.RAGDOLL: - return - var torso := _ragdoll_bodies.get("torso") as RigidBody2D - if torso == null or not is_instance_valid(torso): - return - var at_rest := torso.sleeping \ - or (torso.linear_velocity.length() <= REST_LINEAR_THRESHOLD \ - and absf(torso.angular_velocity) <= REST_ANGULAR_THRESHOLD) - if not at_rest: - _rest_timer = 0.0 - _stabilize_timer = 0.0 - return - # Publish rest regardless of auto_recover (the director runner polls it). - if not _ragdoll_at_rest: - _ragdoll_at_rest = true - if not auto_recover: - _rest_timer = 0.0 - return - _rest_timer += delta - if _rest_timer < rest_timeout: - return - _stabilize_timer += delta - if _stabilize_timer >= STABILIZATION_DELAY: - _start_recovery() # --------------------------------------------------------------------------- # Public API @@ -1238,28 +438,34 @@ func is_in_ragdoll() -> bool: return state == RigState.RAGDOLL +func _set_state(next_state: int) -> void: + if next_state < RigState.ANIMATED or next_state > RigState.RECOVERING: + push_warning("StickmanRig: invalid state transition target %d." % next_state) + return + if int(state) == next_state: + return + state = next_state + state_changed.emit(int(state)) + + func set_ragdoll(enabled: bool) -> void: if enabled: - if _terrain_motion == TerrainMotion.AIRBORNE or _terrain_motion == TerrainMotion.LANDING_IMPACT: - _cancel_landing_impact(false) - _air_pose_base.clear() - _air_pose_weight = 0.0 - _air_time = 0.0 - _air_entry_root_y = 0.0 - _air_fall_distance = 0.0 - _terrain_motion = TerrainMotion.DISABLED - _terrain_air_velocity = Vector2.ZERO + if _locomotion_component != null: + _locomotion_component.reset_for_ragdoll_handoff() match state: RigState.RAGDOLL: return RigState.RECOVERING: - _cancel_recovery() - _enter_ragdoll() + if _ragdoll_component != null: + _ragdoll_component.cancel_recovery() + if _ragdoll_component.enter_ragdoll(): + _set_state(RigState.RAGDOLL) _: - _enter_ragdoll() + if _ragdoll_component != null and _ragdoll_component.enter_ragdoll(): + _set_state(RigState.RAGDOLL) else: if state == RigState.RAGDOLL: - _start_recovery() + request_recovery() # else ANIMATED / RECOVERING: no-op @@ -1269,127 +475,38 @@ func toggle_ragdoll() -> void: ## Public stand-up request. No-op unless the rig is in RAGDOLL. func request_recovery() -> void: - if state == RigState.RAGDOLL: - _start_recovery() + if state == RigState.RAGDOLL and _ragdoll_component != null: + _ragdoll_component.request_recovery() ## Instantly snaps the rig back to its authored standing pose — no stand-up ## 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: - _cancel_landing_impact(true) - _restore_air_pose() - _terrain_air_velocity = Vector2.ZERO - _terrain_motion = TerrainMotion.DISABLED - var previous_state := state - if state == RigState.RAGDOLL: - _destroy_ragdoll() - elif state == RigState.RECOVERING: - _cancel_recovery() - _clear_impact_reactions(true) - _hide_speech() - # EDIT/DIRECT reset is the authored neutral stance, not an adapter-derived - # spawn silhouette. This also releases any terrain-owned marker snapshot. - _restore_standing_markers(false) - # 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) - if previous_state == RigState.ANIMATED: - _apply_head_lookat_mirror_mode() - else: - _restore_head_lookat_state() - _recovery_bones.clear() - _rest_timer = 0.0 - _stabilize_timer = 0.0 - _ragdoll_at_rest = false - _captured_pose.clear() - _captured_landing_center = Vector2.ZERO - _captured_ground_y = 0.0 - _pre_ragdoll_state.clear() - _probe_top_y = 0.0 - _probe_bottom_y = 0.0 - _probe_exclude.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 - _apply_idle_limb_solver_mode(_spawn_limb_rotations) - if previous_state != RigState.ANIMATED: - state_changed.emit(int(state)) + if _ragdoll_component != null: + _ragdoll_component.snap_to_standing(int(state)) -## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change). +## Restores the spawn-marker pose through the ragdoll component. Kept on the +## facade for locomotion and legacy callers. func _restore_standing_markers(use_spawn_pose: bool = true) -> void: - var pose: Dictionary = _spawn_pose if use_spawn_pose and not _spawn_pose.is_empty() else STAND_POSE - for marker_name: String in pose: - var marker := _get_ik_marker(marker_name) - if marker == null: - continue - var target: Dictionary = pose[marker_name] - marker.position = target.get("pos", marker.position) - if marker_name == "Torso": - marker.rotation = target.get("rot", marker.rotation) + if _ragdoll_component != null: + _ragdoll_component._restore_standing_markers(use_spawn_pose) ## Captures adapted skeleton endpoints as the initial IK marker pose. Called by ## StickmanFactory before the instance enters the tree; safe for factory bypasses. func initialize_spawn_pose_from_skeleton() -> void: - var skeleton := get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D - if skeleton == null: - push_warning("StickmanRig: cannot initialize spawn pose; missing Skeleton2D.") + var component := get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll + if component == null: + push_warning("StickmanRig: missing RigComponents/Ragdoll for spawn-pose initialization.") return - var pose: Dictionary = {} - var torso := skeleton.get_node_or_null(NodePath("Torso")) as Bone2D - if torso != null: - pose["Torso"] = { "pos": to_local(torso.global_position), "rot": torso.rotation } - var head := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D - if head != null: - var head_tip: Vector2 = head.to_global(Vector2(maxf(head.length, 1.0), 0.0).rotated(deg_to_rad(head.bone_angle))) - pose["Head"] = { "pos": to_local(head_tip), "rot": 0.0 } - var paths: Dictionary = { - "Left_Hand": "Torso/LeftUpperArm/LeftLowerArm", - "Right_Hand": "Torso/RightUpperArm/RightLowerArm", - "Left_Leg": "Torso/LeftUpperLeg/LeftLowerLeg", - "Right_Leg": "Torso/RightUpperLeg/RightLowerLeg", - } - for marker_name: String in paths: - var bone := skeleton.get_node_or_null(NodePath(String(paths[marker_name]))) as Bone2D - if bone == null: - push_warning("StickmanRig: missing spawn-pose bone '%s'." % String(paths[marker_name])) - continue - var endpoint: Vector2 = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle))) - pose[marker_name] = { "pos": to_local(endpoint), "rot": 0.0 } - if pose.size() == IK_TARGET_PATHS.size(): - # The targets must be written before the rig enters the tree; merely - # caching them would let the modification stack solve once against the - # adapter's generic targets and change the placed silhouette. - for marker_name: String in pose: - var marker := get_node_or_null(NodePath(String(IK_TARGET_PATHS[marker_name]))) as Marker2D - if marker == null: - push_warning("StickmanRig: missing spawn-pose marker '%s'." % marker_name) - return - var target: Dictionary = pose[marker_name] - marker.position = target.get("pos", marker.position) - marker.rotation = target.get("rot", marker.rotation) - if marker_name == "Left_Leg" or marker_name == "Right_Leg": - var base_marker := get_node_or_null( - NodePath("AnimationTargets/%s_Base" % marker_name)) as Marker2D - if base_marker != null: - base_marker.position = marker.position - _spawn_pose = pose.duplicate(true) - _spawn_limb_rotations.clear() - for joint: String in LIMB_BONE_PATHS: - var pair: Array = LIMB_BONE_PATHS[joint] - var upper := skeleton.get_node_or_null(NodePath(String(pair[0]))) as Bone2D - var lower := skeleton.get_node_or_null(NodePath(String(pair[1]))) as Bone2D - if upper != null and lower != null: - _spawn_limb_rotations[joint] = { - "upper": upper.rotation, - "lower": lower.rotation, - } + if _ragdoll_component == null: + _ragdoll_component = component + if _locomotion_component == null: + _locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion + component.configure(self, _locomotion_component) + component.initialize_spawn_pose_from_skeleton() ## Applies the same velocity delta to every ragdoll body via a mass-scaled @@ -1398,10 +515,8 @@ func initialize_spawn_pose_from_skeleton() -> void: 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) + if _ragdoll_component != null: + _ragdoll_component.apply_velocity_boost(velocity) # --------------------------------------------------------------------------- # Internal resolution / apply @@ -1459,114 +574,9 @@ 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 + if _locomotion_component != null: + _locomotion_component.set_collision_bodies_enabled(enabled) func apply_collision_reaction( @@ -1575,99 +585,8 @@ func apply_collision_reaction( 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 + if _locomotion_component != null: + _locomotion_component.apply_collision_reaction(part_name, impact_direction, impact_speed, impact_position) ## Writes the four per-joint bend vars from PROFILE_FLAGS (through their setters, @@ -1698,7 +617,7 @@ func _apply_profile() -> void: ## existed in the clip. Pin the limbs to the exact frame captured at stop. func _apply_idle_limb_solver_mode(rotations: Dictionary = {}) -> void: var pin_mirrored_idle := facing_profile == FacingProfile.LEFT \ - and state == RigState.ANIMATED and not _walking + and state == RigState.ANIMATED and not is_walking() var source: Dictionary = rotations.duplicate(true) if not rotations.is_empty() else _capture_limb_rotations() _set_limb_mods_enabled(not pin_mirrored_idle) if not pin_mirrored_idle: @@ -1737,7 +656,7 @@ func _capture_limb_rotations() -> Dictionary: ## the head to hang BELOW the neck instead of sitting above it -- the whole-rig ## mirror then displaces the head ~2x its mount offset relative to the torso. ## Fix: when facing LEFT, disable the LookAt and pin the head bone to the -## FORWARD canonical aim rotation (PI), so the head is a rigid mirror of the +## authored canonical local rotation (0), so the head is a rigid mirror of the ## FORWARD pose; RIGHT/FORWARD re-enable the LookAt. The pin is re-asserted ## every physics frame while the kinematic puppet is live (ANIMATED/RECOVERING) ## because re-enabling the IK stack during recovery would otherwise let the @@ -1748,7 +667,7 @@ func _apply_head_lookat_mirror_mode() -> void: var mirrored := facing_profile == FacingProfile.LEFT _head_look_at.enabled = false#not mirrored if mirrored and _head_bone != null and is_instance_valid(_head_bone): - _head_bone.rotation = PI + _head_bone.rotation = 0.0 elif not mirrored: _head_bone.rotation = 0 @@ -1776,321 +695,6 @@ func _apply_body_z_order() -> void: if part != null: _body_container.move_child(part, _body_container.get_child_count() - 1) -# --------------------------------------------------------------------------- -# Ragdoll translation (kinematic -> physics) -# --------------------------------------------------------------------------- - -## Captures the kinematic puppet's current state at the moment ragdoll is -## entered: the 6 IK-target marker values, the 8 limb bone rotations, the head -## bone rotation, the facing profile, the 4 live bend flags, and the head -## LookAt mod's enabled state. This snapshot is the recovery tween's END target -## — the figure returns to the exact pose it had just before the fall (which -## may be a mid-walk pose); the animation is not resumed. -func _capture_pre_ragdoll_state() -> void: - _pre_ragdoll_state.clear() - var markers := {} - for marker_name: String in STAND_POSE: - var marker := _get_ik_marker(marker_name) - if marker != null: - markers[marker_name] = { "pos": marker.position, "rot": marker.rotation } - _pre_ragdoll_state["markers"] = markers - var limb_rotations := {} - for joint: String in _limb_bones: - var pair: Dictionary = _limb_bones[joint] - var upper: Bone2D = pair.get("upper") - var lower: Bone2D = pair.get("lower") - limb_rotations[joint] = { - "upper": upper.rotation if upper != null and is_instance_valid(upper) else 0.0, - "lower": lower.rotation if lower != null and is_instance_valid(lower) else 0.0, - } - _pre_ragdoll_state["limb_rotations"] = limb_rotations - _pre_ragdoll_state["head_rotation"] = _head_bone.rotation if _head_bone != null and is_instance_valid(_head_bone) else 0.0 - _pre_ragdoll_state["facing_profile"] = int(facing_profile) - var bends := {} - for joint: String in BEND_JOINTS: - bends[joint] = get_joint_bend_flipped(joint) - _pre_ragdoll_state["bends"] = bends - _pre_ragdoll_state["head_lookat_enabled"] = _head_look_at.enabled if _head_look_at != null and is_instance_valid(_head_look_at) else false - # Per-leg knee side (sign of the hip->ankle vs hip->knee cross) at entry, - # used at the snap to set the leg IK bend flags so the re-solve keeps the - # captured knee side instead of flipping to the mirrored solution. - var leg_sides := {} - for leg_joint: String in ["LeftLeg", "RightLeg"]: - var pair: Dictionary = _limb_bones.get(leg_joint, {}) - var upper: Bone2D = pair.get("upper") - var lower: Bone2D = pair.get("lower") - if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower): - continue - var marker_name: String = "Left_Leg" if leg_joint == "LeftLeg" else "Right_Leg" - var ankle_marker := _get_ik_marker(marker_name) - if ankle_marker == null: - continue - var hip := upper.global_position - var knee := lower.global_position - var ankle := ankle_marker.global_position - var side := (ankle - hip).cross(knee - hip) - leg_sides[leg_joint] = { "side": 1.0 if side >= 0.0 else -1.0, "flag": get_joint_bend_flipped(leg_joint) } - _pre_ragdoll_state["leg_sides"] = leg_sides - # Chain-space offsets that map captured ragdoll angles onto the node chains: - # the torso marker's entry rotation relative to the entry spine direction, - # and the head bone's entry chain relative to the head body's entry angle - # (the head ragdoll body spawns with rotation 0). Captured here so the - # recovery snap needs no authored geometry constants, and the mirrored - # root's ambiguous global_rotation decomposition (+/-PI) cancels out. - var torso_entry_rot := 0.0 - var torso_marker := _get_ik_marker("Torso") - if torso_marker != null: - torso_entry_rot = torso_marker.rotation - var spine_entry_rel := 0.0 - if _head_bone != null and _torso_bone != null and is_instance_valid(_head_bone) and is_instance_valid(_torso_bone): - spine_entry_rel = (_head_bone.global_position - _torso_bone.global_position).angle() - global_rotation - _pre_ragdoll_state["torso_chain_offset"] = _chain_angle_from_captured(spine_entry_rel) - torso_entry_rot - var head_entry_chain := torso_entry_rot + float(_pre_ragdoll_state.get("head_rotation", 0.0)) - _pre_ragdoll_state["head_chain_offset"] = _chain_angle_from_captured(-global_rotation) - head_entry_chain - - -func _enter_ragdoll() -> void: - if _skeleton == null or _body_container == null: - push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.") - return - _capture_pre_ragdoll_state() - _impact_reactions.clear() - _set_collision_bodies_enabled(false) - # Preserve the live solved pose for the instant ragdoll handoff. A normal - # queue cancellation restores the idle markers/limbs, which would pop here. - _cancel_walking(false) - _ragdoll_at_rest = false - # Instant handoff: stop the player without resetting it (keep_state) and - # build the ragdoll from the CURRENT solved bone positions while the IK - # stack is still enabled (disabling it first would revert the bones to the - # authored rest pose). Body/* is then hidden immediately and IK disabled — - # no crossfade, because the ragdoll is spawned at exactly the same pose, so - # a fade would only read as ghosting. - if _anim_player != null and is_instance_valid(_anim_player): - _anim_player.stop(true) - _build_ragdoll() - if is_instance_valid(_body_container): - _body_container.visible = false - _body_container.modulate.a = 1.0 - if _skeleton.modification_stack != null: - _skeleton.modification_stack.enabled = false - state = RigState.RAGDOLL - state_changed.emit(int(state)) - _rest_timer = 0.0 - _stabilize_timer = 0.0 - - -# --------------------------------------------------------------------------- -# Recovery (ragdoll -> kinematic stand-up) -# --------------------------------------------------------------------------- - -## Captures every ragdoll body's global transform into rig-local space, plus -## each capsule's half-length (from build-time metadata) so the snap can derive -## the real joint ends (hip / wrist / ankle) instead of body midpoints. -func _capture_ragdoll_pose() -> void: - _captured_pose.clear() - _captured_landing_center = Vector2.ZERO - _captured_ground_y = 0.0 - # While capturing, also compute the ragdoll's full top/bottom extent and - # collect every body's RID so the ground probe can raycast the terrain under - # the fallen figure while excluding the ragdoll's own collision shapes. - var top_y := INF - var bottom_y := -INF - var exclude_rids: Array[RID] = [] - for key: String in _ragdoll_bodies: - var body := _ragdoll_bodies[key] as RigidBody2D - if body == null or not is_instance_valid(body): - continue - var half := float(body.get_meta("half_height", 0.0)) - var radius := float(body.get_meta("radius", 0.0)) - var extent := half + radius - top_y = minf(top_y, body.global_position.y - extent) - bottom_y = maxf(bottom_y, body.global_position.y + extent) - exclude_rids.append(body.get_rid()) - _captured_pose[key] = { - "pos": to_local(body.global_position), - "rot": body.global_rotation - global_rotation, - "half": half, - } - # Record the ragdoll torso's world center + probe the terrain surface under - # the fallen figure so recovery can re-anchor the root's feet onto wherever - # the ragdoll actually landed (flat ground, slopes and ramp edges included). - var torso := _ragdoll_bodies.get("torso") as RigidBody2D - if torso != null and is_instance_valid(torso): - _captured_landing_center = torso.global_position - _captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids) - # Retain the probe context so the ankle clamp in _snap_skeleton_to_pose() - # can re-probe the terrain at each ankle's X using the same extents and - # exclusion set (without re-running the capture loop). - _probe_top_y = top_y - _probe_bottom_y = bottom_y - _probe_exclude = exclude_rids - - -## Fans downward raycasts around `world_x` to find the terrain surface under the -## landed ragdoll. Returns the accepted hit whose y is closest to `bottom_y`, or -## `bottom_y` when no acceptable hit is found. `exclude` carries the ragdoll -## bodies' RIDs so the probe ignores its own collision shapes. -func _probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float: - var best_y := bottom_y - var best_dist := INF - var space_state := get_world_2d().direct_space_state - if space_state == null: - return best_y - for offset: float in GROUND_PROBE_X_OFFSETS: - var query := PhysicsRayQueryParameters2D.create( - Vector2(world_x + offset, top_y - GROUND_PROBE_START_OFFSET), - Vector2(world_x + offset, bottom_y + GROUND_PROBE_END_OFFSET)) - query.collision_mask = 1 - query.collide_with_areas = false - query.collide_with_bodies = true - query.exclude = exclude - var hit := space_state.intersect_ray(query) - if hit.is_empty(): - continue - var hit_position: Vector2 = hit.get("position", Vector2.ZERO) - if hit_position.y < top_y - GROUND_PROBE_TOLERANCE or hit_position.y > bottom_y + GROUND_PROBE_TOLERANCE: - continue - var dist := absf(hit_position.y - bottom_y) - if dist < best_dist: - best_dist = dist - best_y = hit_position.y - return best_y - - -func _start_recovery() -> void: - if state != RigState.RAGDOLL: - return - _capture_ragdoll_pose() - _destroy_ragdoll() - _reanchor_root_to_landing() - state = RigState.RECOVERING - state_changed.emit(int(state)) - _snap_skeleton_to_pose() - _set_collision_bodies_enabled(true) - _play_stand_up() - - -## Re-anchors the rig root so the standing figure's feet sit on the ground at -## the ragdoll's landing X. The ragdoll bodies live under a world sibling (not -## the rig), so the rig root never moved while it fell; without this the -## stand-up tween would drag the figure back to the root's pre-ragdoll world -## position. -func _reanchor_root_to_landing() -> void: - if not _captured_pose.has("torso"): - return - var feet_world := Vector2(_captured_landing_center.x, _captured_ground_y) - var old_root := global_position - var new_root := feet_world + FOOT_OFFSET - global_position = new_root - # Re-base the captured (rig-local) pose onto the re-anchored root so the - # snap reproduces the ragdoll's world pose, not the pre-ragdoll one. The - # re-base must be mirror-aware: a world-space delta maps into rig-local - # space through the root basis's inverse (the LEFT X-mirror negates X; - # adding the raw shift would offset every position by twice the distance). - var shift := old_root - new_root - var local_shift := to_local(shift) - to_local(Vector2.ZERO) - for key: String in _captured_pose: - var entry: Dictionary = _captured_pose[key] - entry["pos"] = entry.get("pos", Vector2.ZERO) + local_shift - - -## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a -## clean slate. -func _cancel_recovery() -> void: - _stand_up_generation += 1 - if _stand_up_tween != null and _stand_up_tween.is_valid(): - _stand_up_tween.kill() - _stand_up_tween = null - - -## Re-enables the Skeleton2D modification stack after RAGDOLL and forces it to -## resume solving. A disabled->enabled toggle alone can leave the stack's -## internal solve state stale (the classic Godot disable->enable quirk), so we -## explicitly re-setup the stack when it reports !is_setup, ensure Skeleton2D -## processes internally (required for the stack to execute), and re-assert the -## Torso marker's RemoteTransform2D update flags so the hip bone keeps -## following IK_Targets/Torso through the stand-up tween. -func _rearm_ik_stack() -> void: - if _skeleton == null or not is_instance_valid(_skeleton): - return - var stack: SkeletonModificationStack2D = _skeleton.modification_stack - if stack != null: - if not stack.get_is_setup(): - stack.setup() - stack.enabled = true - _skeleton.set_process_internal(true) - # Torso marker -> Torso bone driver: ensure it forwards position/rotation. - var torso_marker := _get_ik_marker("Torso") - if torso_marker != null: - var driver := torso_marker.get_node_or_null("RemoteTransform2D") as RemoteTransform2D - if driver != null: - driver.update_position = true - driver.update_rotation = true - driver.update_scale = true - _recovery_dbg() - - -## Logs the IK stack + bone-following state during recovery (off by default). -func _recovery_dbg() -> void: - if not DEBUG_RECOVERY: - return - var stack: SkeletonModificationStack2D = null - var stack_enabled := false - var stack_setup := false - var internal := false - if _skeleton != null and is_instance_valid(_skeleton): - stack = _skeleton.modification_stack - stack_enabled = stack != null and stack.enabled - stack_setup = stack != null and stack.get_is_setup() - internal = _skeleton.is_processing_internal() - var torso_bone_pos := Vector2.ZERO - if _torso_bone != null and is_instance_valid(_torso_bone): - torso_bone_pos = _torso_bone.global_position - var torso_marker := _get_ik_marker("Torso") - var torso_marker_pos := torso_marker.global_position if torso_marker != null else Vector2.ZERO - var limb := _bend_joint_bones.get("LeftLeg") as Bone2D - var limb_pos := limb.global_position if limb != null and is_instance_valid(limb) else Vector2.ZERO - print("[recovery] stack.enabled=%s is_setup=%s internal=%s torso_bone=%s torso_marker=%s left_lower_leg=%s" % [ - str(stack_enabled), str(stack_setup), str(internal), - str(torso_bone_pos), str(torso_marker_pos), str(limb_pos), - ]) - - -## Converts a captured relative angle (ragdoll body world rotation minus the -## rig root's global_rotation) into the skeleton's chain-space angle. Under the -## LEFT whole-rig X-mirror the root's global_rotation decomposes as (PI - phi), -## so captured relative angles come out negated: chain = -captured. -func _chain_angle_from_captured(captured_rel: float) -> float: - if facing_profile == FacingProfile.LEFT: - return -captured_rel - return captured_rel - - -## Wraps `value` to within +/-PI of `target` (shortest-rotation path) so a bone -## rotation tween never spins the long way around. The wrapped value renders -## identically to the raw one (2PI-periodic). -func _wrap_toward(value: float, target: float) -> float: - return target + wrapf(value - target, -PI, PI) - - -## Returns the tween target for a HINGE joint (elbows/knees only) so it bends -## the natural way, evaluated in the joint's RENDERED space (the value space is -## offset by `bone_angle_lower - bone_angle_upper` — e.g. -90deg for the legs). -## The shortest rendered path is used unless it would cross the +/-PI -## (fully-backward) boundary (opposite signs with |a| + |b| > PI) — a joint -## that fell bent backwards must straighten through 0 instead of folding -## further backwards. The returned target differs from `target` only by whole -## turns. -func _natural_joint_target(current: float, target: float, offset: float) -> float: - var a := wrapf(current + offset, -PI, PI) - var b := wrapf(target + offset, -PI, PI) - var d := wrapf(b - a, -PI, PI) - if a * b < 0.0 and absf(a) + absf(b) > PI: - d += TAU if d < 0.0 else -TAU - return current + d - - ## Enables/disables the 4 TwoBoneIK mods. Disabled during RECOVERING so the IK ## re-solve cannot overwrite the directly-driven limb bones; re-enabled when ## the stand-up completes (or on snap_to_standing). @@ -2101,566 +705,6 @@ func _set_limb_mods_enabled(enabled: bool) -> void: mod.enabled = enabled -## Restores the head LookAt mod's enabled state to what it was captured as -## before ragdoll (fallback: disabled). -func _restore_head_lookat_state() -> void: - if _head_look_at == null or not is_instance_valid(_head_look_at): - return - _head_look_at.enabled = bool(_pre_ragdoll_state.get("head_lookat_enabled", false)) - - -## Kinematic snap: writes the captured ragdoll pose onto the 6 IK-target -## markers (NOT the Torso Bone2D, which is slaved to its marker via -## RemoteTransform2D), drives the ARMS' limb Bone2D rotations directly from the -## captured body angles, and sets the head bone from the head body. The two arm -## TwoBoneIK mods and the head LookAt are disabled for the snap so the re-solve -## cannot overwrite the directly-driven bones. The two LEG TwoBoneIK mods are -## instead ENABLED so the legs solve toward their ankle markers — the ankles -## are clamped up onto the terrain surface (via a ground probe at each ankle's -## X) and then tween along the ground to the pre-ragdoll ankle positions while -## the torso marker rises, so the feet never sink below the ground. Each leg -## mod's bend side is matched to the captured knee side so the re-solve cannot -## flip a knee. The stand-up tween then rotates the markers + arms + head to -## the PRE-ragdoll captured state and re-enables the arm mods. -## -## Geometry notes: the ragdoll capsules span joint origin -> tip along their -## +X (body.rotation IS the segment direction), so the real joints are at -## center ± direction * half_height. All derived joint points go through -## to_local(to_global(pos) + world_dir * half) so the LEFT whole-rig X-mirror -## and any rig root rotation are handled by the transform math itself. Rotations -## use entry-captured offsets instead of authored constants: the torso marker -## rotation θ_t is the captured spine chain minus `torso_chain_offset` (which -## absorbs the bone_angle / driver conventions), the limbs parent on θ_t, and -## the head bone is the captured head chain minus `head_chain_offset` (the head -## ragdoll body's baked entry chain) minus θ_t. Every snapped rotation is -## normalized into (-PI, PI] because Godot's Skeleton2D re-wraps bone rotations -## internally, which would otherwise corrupt the tween's captured START. -## -## This function ALSO computes and stores the per-bone tween targets in -## `_recovery_bones` (the torso marker, the 4 arm limb bones, and the head -## bone). Shoulders (upper arm bones) and the head take the shortest value -## path; elbows (lower arm bones) use `_natural_joint_target` so a joint that -## fell bent backwards straightens through 0 instead of folding further -## backwards through +/-PI. The legs are excluded from the bone tween — they -## are IK-driven toward their tweening ankle markers. `_play_stand_up()` then -## only starts the tween from those stored targets. -func _snap_skeleton_to_pose() -> void: - _recovery_bones.clear() - var pre_markers: Dictionary = _pre_ragdoll_state.get("markers", {}) - var pre_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {}) - var pre_head_rotation := float(_pre_ragdoll_state.get("head_rotation", 0.0)) - var pre_torso_rot := 0.0 - var torso_chain_offset := float(_pre_ragdoll_state.get("torso_chain_offset", 0.0)) - var head_chain_offset := float(_pre_ragdoll_state.get("head_chain_offset", 0.0)) - var theta_t := 0.0 - var torso_chain := 0.0 - var torso_marker := _get_ik_marker("Torso") - if torso_marker != null: - var torso_pose: Dictionary = _captured_pose.get("torso", {}) - if not torso_pose.is_empty(): - var torso_rel := float(torso_pose.get("rot", 0.0)) - # World-space spine direction: the captured angle is relative to the - # root's global_rotation, so add it back to get the true world angle. - var spine_dir := Vector2.from_angle(torso_rel + global_rotation) - var half := float(torso_pose.get("half", 0.0)) - # Hip = spine bottom end of the torso capsule, converted through - # to_local(to_global(...)) so the LEFT X-mirror and any rig root - # rotation are handled by the transform math itself. - var center_world := to_global(torso_pose.get("pos", Vector2.ZERO)) - torso_marker.position = to_local(center_world - spine_dir * half) - torso_chain = _chain_angle_from_captured(torso_rel) - # The torso bone's node rotation = the captured spine chain minus - # the entry-captured offset (the bone_angle / driver conventions - # are baked into the offset, so no authored constants are needed). - var raw_rot := torso_chain - torso_chain_offset - pre_torso_rot = float((pre_markers.get("Torso", {}) as Dictionary).get("rot", raw_rot)) - theta_t = _wrap_toward(raw_rot, pre_torso_rot) - theta_t = wrapf(theta_t, -PI, PI) - torso_marker.rotation = theta_t - - var head_marker := _get_ik_marker("Head") - if head_marker != null: - var head_pose: Dictionary = _captured_pose.get("head", {}) - if not head_pose.is_empty(): - head_marker.position = head_pose.get("pos", head_marker.position) - - _set_marker_from_body("Left_Hand", "left_lower_arm") - _set_marker_from_body("Right_Hand", "right_lower_arm") - _set_marker_from_body("Left_Leg", "left_lower_leg") - _set_marker_from_body("Right_Leg", "right_lower_leg") - - # Clamp the ankle markers up onto the terrain surface so the feet never - # sink below the ground during the stand-up (the leg IK follows them). - for leg_key: String in ["Left_Leg", "Right_Leg"]: - var leg_marker := _get_ik_marker(leg_key) - if leg_marker == null: - continue - var body_key: String = "left_lower_leg" if leg_key == "Left_Leg" else "right_lower_leg" - if _captured_pose.get(body_key, {}).is_empty(): - continue - var ankle_world := to_global(leg_marker.position) - var ground_y := _probe_ground_y(ankle_world.x, _probe_top_y, _probe_bottom_y, _probe_exclude) - if ankle_world.y < ground_y: - ankle_world.y = ground_y - leg_marker.position = to_local(ankle_world) - - # Drive the limb bones directly from the captured body angles: the ragdoll - # body angle IS the bone DIRECTION, while the Body/* visuals follow the bone - # NODE rotation, so the bone's own bone_angle is subtracted. The parent - # chain is the torso marker's snapped node rotation θ_t (the captured spine - # chain minus the entry-captured torso_chain_offset). The snapped rotations - # are normalized into (-PI, PI] (Godot re-wraps bone rotations internally); - # upper bones take the shortest path and lower bones use - # `_natural_joint_target` (through straight when bent backwards). - if not _captured_pose.get("torso", {}).is_empty(): - for joint: String in LIMB_BONE_PATHS: - var pair: Dictionary = _limb_bones.get(joint, {}) - var upper: Bone2D = pair.get("upper") - var lower: Bone2D = pair.get("lower") - if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower): - continue - var body_keys: Array = LIMB_BODY_KEYS[joint] - var upper_pose: Dictionary = _captured_pose.get(body_keys[0], {}) - var lower_pose: Dictionary = _captured_pose.get(body_keys[1], {}) - if upper_pose.is_empty() or lower_pose.is_empty(): - continue - var upper_chain := _chain_angle_from_captured(float(upper_pose.get("rot", 0.0))) - var lower_chain := _chain_angle_from_captured(float(lower_pose.get("rot", 0.0))) - var pre_pair: Dictionary = pre_limb_rotations.get(joint, {}) - var parent_chain := theta_t - upper.rotation = _wrap_toward( - upper_chain - parent_chain - deg_to_rad(upper.bone_angle), - float(pre_pair.get("upper", upper.rotation))) - # The lower bone's node parent chain does NOT include the upper - # bone's bone_angle (bone_angle only appears in the ragdoll body - # direction), so it is added back before subtracting the lower's. - lower.rotation = _wrap_toward( - lower_chain - upper_chain + deg_to_rad(upper.bone_angle) - deg_to_rad(lower.bone_angle), - float(pre_pair.get("lower", lower.rotation))) - # Normalize the snapped rotations into (-PI, PI]: Godot's Skeleton2D - # re-wraps bone rotations on its next internal process, which would - # otherwise change the tween's captured START and send it the long - # way around. With the start normalized, the wrap is a no-op. - upper.rotation = wrapf(upper.rotation, -PI, PI) - lower.rotation = wrapf(lower.rotation, -PI, PI) - if joint == "LeftLeg" or joint == "RightLeg": - # Legs are IK-driven during the stand-up (the ankles follow the - # ground-clamped markers then tween to the pre positions), so - # they get NO direct bone tween — but they ARE snapped to the - # captured pose above so the first recovery frame matches the - # ragdoll and the knee-side matching sees the true fallen pose. - continue - # Shoulders/hips (upper bones) take the shortest path; elbows/knees - # (lower bones) must pass through straight when the ragdoll bent - # them backwards. - var pre_u := float(pre_pair.get("upper", upper.rotation)) - var pre_l := float(pre_pair.get("lower", lower.rotation)) - var target_u := upper.rotation + wrapf(pre_u - upper.rotation, -PI, PI) - # The joint's rendered bend is offset by bone_angle_lower - - # bone_angle_upper (e.g. -90deg for the legs): evaluate the natural - # rule in that rendered space so the knee unfolds through straight - # instead of being sent the long way around. A "shortest" entry in - # RECOVERY_JOINT_PATH skips the natural rule entirely. - var target_l: float - var path_override := String(RECOVERY_JOINT_PATH.get(facing_profile, {}).get(joint, "")) - if path_override == "shortest": - target_l = lower.rotation + wrapf(pre_l - lower.rotation, -PI, PI) - else: - target_l = _natural_joint_target(lower.rotation, pre_l, deg_to_rad(lower.bone_angle - upper.bone_angle)) - _recovery_bones.append({ "bone": upper, "target": target_u }) - _recovery_bones.append({ "bone": lower, "target": target_l }) - - # The head ragdoll body is a "visual" body spawned with rotation 0, so its - # cloned visual carries the entry head chain as a baked offset — captured - # at entry and subtracted back here. - if not _captured_pose.get("head", {}).is_empty() and _head_bone != null and is_instance_valid(_head_bone): - var head_rel := float((_captured_pose["head"] as Dictionary).get("rot", 0.0)) - var raw_head := _chain_angle_from_captured(head_rel) - head_chain_offset - theta_t - _head_bone.rotation = _wrap_toward(raw_head, pre_head_rotation) - _head_bone.rotation = wrapf(_head_bone.rotation, -PI, PI) - var target_h := _head_bone.rotation + wrapf(pre_head_rotation - _head_bone.rotation, -PI, PI) - _recovery_bones.append({ "bone": _head_bone, "target": target_h }) - - # Show the kinematic puppet first so it appears already in the captured - # pose, then re-arm the stack (torso driver) with the limb TwoBoneIK mods - # and the head LookAt disabled so nothing re-solves the snapped bones. - if _body_container != null and is_instance_valid(_body_container): - _body_container.visible = true - _body_container.modulate.a = 1.0 - _rearm_ik_stack() - _set_limb_mods_enabled(false) - if _head_look_at != null and is_instance_valid(_head_look_at): - _head_look_at.enabled = false - # Enable the two leg TwoBoneIK mods so the legs solve toward the ankle - # markers (clamped on the ground) while the torso marker rises. Match each - # leg's bend side to the captured pose so the re-solve cannot flip a knee. - for leg_joint: String in ["LeftLeg", "RightLeg"]: - var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(leg_joint) as SkeletonModification2DTwoBoneIK - if mod == null or not is_instance_valid(mod): - continue - var leg_sides: Dictionary = _pre_ragdoll_state.get("leg_sides", {}) - var entry: Dictionary = leg_sides.get(leg_joint, {}) - var force_profile: Dictionary = RECOVERY_LEG_SIDE_FORCE.get(facing_profile, {}) - if force_profile.has(leg_joint): - # Manual override from the editable RECOVERY_LEG_SIDE_FORCE table. - mod.flip_bend_direction = bool(force_profile[leg_joint]) - elif not entry.is_empty(): - # Derive the captured knee side from the RAGDOLL capture in world - # space. The bone nodes are NOT reliable here: the torso marker's - # driver has not pushed the re-anchored hip yet, so bone globals - # would mix the stale entry hip with the snapped limb rotations - # and produce a garbage side (both knees flipping at the first - # solve). - var up_key: String = "left_upper_leg" if leg_joint == "LeftLeg" else "right_upper_leg" - var low_key: String = "left_lower_leg" if leg_joint == "LeftLeg" else "right_lower_leg" - var up_pose: Dictionary = _captured_pose.get(up_key, {}) - var low_pose: Dictionary = _captured_pose.get(low_key, {}) - if not up_pose.is_empty() and not low_pose.is_empty(): - var dir_u := Vector2.from_angle(float(up_pose.get("rot", 0.0)) + global_rotation) - var dir_l := Vector2.from_angle(float(low_pose.get("rot", 0.0)) + global_rotation) - var half_u := float(up_pose.get("half", 0.0)) - var half_l := float(low_pose.get("half", 0.0)) - var center_u := to_global(up_pose.get("pos", Vector2.ZERO)) - var hip := center_u - dir_u * half_u - var knee := center_u + dir_u * half_u - var ankle := to_global(low_pose.get("pos", Vector2.ZERO)) + dir_l * half_l - var side := (ankle - hip).cross(knee - hip) - var captured_side := 1.0 if side >= 0.0 else -1.0 - var entry_side := float(entry.get("side", captured_side)) - var entry_flag := bool(entry.get("flag", false)) - mod.flip_bend_direction = entry_flag if captured_side == entry_side else not entry_flag - mod.enabled = true - - -func _set_marker_from_body(marker_name: String, body_key: String) -> void: - var marker := _get_ik_marker(marker_name) - if marker == null: - return - var pose: Dictionary = _captured_pose.get(body_key, {}) - if pose.is_empty(): - return - # Far end (wrist / ankle) = body center + segment direction * half, in WORLD - # space (the captured angle is root-relative), converted back to local via - # to_local so the LEFT X-mirror and rig root rotation are handled exactly. - var dir := Vector2.from_angle(float(pose.get("rot", 0.0)) + global_rotation) - var half := float(pose.get("half", 0.0)) - var center_world := to_global(pose.get("pos", Vector2.ZERO)) - marker.position = to_local(center_world + dir * half) - - -func _get_ik_marker(name: String) -> Marker2D: - var path: String = IK_TARGET_PATHS.get(name, "") - if path.is_empty(): - return null - return get_node_or_null(NodePath(path)) as Marker2D - - -## Stand-up: starts the tween from the captured ragdoll pose to the PRE-ragdoll -## captured state (sine ease-in-out, same duration). No baked animation — a fixed -## first keyframe can never match an arbitrary ragdoll rest pose, so the tween -## starts from wherever the snap left the markers/bones. -## -## The per-bone tween targets (minimal world-direction sweeps) are computed and -## stored in `_recovery_bones` by `_snap_skeleton_to_pose()`; this function only -## starts the marker tween and appends one bone-rotation tween per entry. -func _play_stand_up() -> void: - _stand_up_generation += 1 - var generation: int = _stand_up_generation - var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE) - _stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION) - if _stand_up_tween != null: - for entry: Dictionary in _recovery_bones: - var bone: Bone2D = entry.get("bone") - if bone == null or not is_instance_valid(bone): - continue - _stand_up_tween.tween_property(bone, "rotation", float(entry.get("target", bone.rotation)), STAND_UP_DURATION) - _stand_up_tween.finished.connect(_on_stand_up_finished.bind(generation)) - - -## Tweens the 6 IK markers from their current (captured) values to the target -## pose over `duration` (sine ease-in-out), all in parallel. -func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween: - var tween := create_tween() - tween.set_parallel(true) - tween.set_trans(Tween.TRANS_SINE) - tween.set_ease(Tween.EASE_IN_OUT) - for marker_name: String in target_pose: - var marker := _get_ik_marker(marker_name) - if marker == null: - continue - var target: Dictionary = target_pose[marker_name] - tween.tween_property(marker, "position", target.get("pos", marker.position), duration) - if marker_name == "Torso": - tween.tween_property(marker, "rotation", target.get("rot", marker.rotation), duration) - return tween - - -## Stand-up tween complete: settle into ANIMATED. -func _on_stand_up_finished(generation: int) -> void: - if generation != _stand_up_generation or state != RigState.RECOVERING: - return - var recovered_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {}) - # Restore the pre-ragdoll facing profile + per-joint bend flags so the - # re-enabled IK holds the exact pre-ragdoll pose. - set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(facing_profile)))) - var bends: Dictionary = _pre_ragdoll_state.get("bends", {}) - for joint: String in BEND_JOINTS: - if bends.has(joint): - set_joint_bend_flipped(joint, bool(bends[joint])) - _set_limb_mods_enabled(true) - _restore_head_lookat_state() - _recovery_bones.clear() - if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null: - _skeleton.modification_stack.enabled = true - 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 - _apply_idle_limb_solver_mode(recovered_limb_rotations) - 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 - 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" - - 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 - body.set_meta("half_height", 0.0) - body.set_meta("radius", float(entry["radius"])) - if not _clone_part_visuals(body, str(entry.get("visual_path", ""))): - _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() - # Half the capsule's length along the body's +X — lets recovery derive - # the joint ends (hip/wrist/ankle) from the body center at capture time. - body.set_meta("half_height", length * 0.5) - body.set_meta("radius", float(entry["radius"])) - if not _clone_part_visuals(body, str(entry.get("visual_path", ""))): - _add_ragdoll_visual_capsule(body, length, float(entry["radius"]), RAGDOLL_VISUAL_COLOR) - - _ragdoll_root.add_child(body) - _ragdoll_bodies[key] = body - - -## Clones the part node's mounted shape children (Line2D open outlines / -## Polygon2D closed fills, added by StkRigAdapter as children of the Body/* -## part node) into the ragdoll body's local frame, so the ragdoll shows the -## stickman's own geometry instead of the gray fallback capsule/circle. Returns -## true if at least one shape was cloned. -func _clone_part_visuals(body: RigidBody2D, visual_path: String) -> bool: - if visual_path.is_empty(): - return false - var source := get_node_or_null(NodePath(visual_path)) as Node2D - if source == null or not is_instance_valid(source): - return false - var cloned := 0 - for child: Node in source.get_children(): - if child is Line2D: - var src := child as Line2D - var copy := Line2D.new() - var pts := PackedVector2Array() - for p: Vector2 in src.points: - pts.append(body.to_local(src.to_global(p))) - copy.points = pts - copy.width = src.width - copy.default_color = src.default_color - if src.gradient != null: - copy.gradient = src.gradient - copy.begin_cap_mode = src.begin_cap_mode - copy.end_cap_mode = src.end_cap_mode - copy.joint_mode = src.joint_mode - copy.closed = src.closed - copy.antialiased = src.antialiased - copy.position = Vector2.ZERO - copy.name = "Visual" + str(body.get_child_count()) - body.add_child(copy) - cloned += 1 - elif child is Polygon2D: - var src := child as Polygon2D - var copy := Polygon2D.new() - var pts := PackedVector2Array() - for p: Vector2 in src.polygon: - pts.append(body.to_local(src.to_global(p))) - copy.polygon = pts - copy.color = src.color - copy.antialiased = src.antialiased - copy.position = Vector2.ZERO - copy.name = "Visual" + str(body.get_child_count()) - body.add_child(copy) - cloned += 1 - return cloned > 0 - - -## 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 = RAGDOLL_TARGET_SOFTNESS - _apply_ragdoll_joint_limits(pin, entry["limit"], str(entry["child"])) - - -## Applies angular limits to a ragdoll PinJoint2D. The elbow/knee band comes -## from the editable RAGDOLL_JOINT_BAND_LIMITS table (per profile, per joint, -## in degrees); the static `limit` string is only a fallback for joints -## missing from the table. `shoulder_hip` is ±160°; other limits leave the -## joint free. -func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String, child_key: String = "") -> void: - match limit: - "elbow_knee", "elbow_knee_ccw": - var band: Dictionary = RAGDOLL_JOINT_BAND_LIMITS.get(facing_profile, {}).get(child_key, {}) - if band.is_empty(): - if limit == "elbow_knee": - band = { "lower": -5.0, "upper": 150.0 } - else: - band = { "lower": -150.0, "upper": 5.0 } - pin.angular_limit_enabled = true - pin.angular_limit_lower = deg_to_rad(float(band.get("lower", -5.0))) - pin.angular_limit_upper = deg_to_rad(float(band.get("upper", 150.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): - # Retire the name immediately so a same-frame _build_ragdoll (e.g. - # set_ragdoll(true) during RECOVERING) does not get its fresh container - # auto-renamed by Godot's sibling-name de-duplication while the old one - # is still awaiting its deferred queue_free(). - _ragdoll_root.name = RAGDOLL_CONTAINER_NAME + "_retired" - _ragdoll_root.queue_free() - _ragdoll_root = null - _ragdoll_bodies.clear() - # --------------------------------------------------------------------------- # Navigation / walking (Phase 3a) # --------------------------------------------------------------------------- @@ -2668,189 +712,12 @@ func _destroy_ragdoll() -> void: ## Start walking so the feet land at `target` (world/ground space). `speed <= 0` ## uses walk_speed. No-op (push_warning) unless state == ANIMATED. func walk_to(target: Vector2, speed: float = -1.0) -> void: - if state != RigState.ANIMATED: - push_warning("StickmanRig: walk_to ignored; not ANIMATED.") - return - if _is_action_queue_blocked(): - push_warning("StickmanRig: walk_to ignored during landing impact.") - return - _walk_target_feet = target - _walk_speed_current = speed if speed > 0.0 else walk_speed - _nav_agent.max_speed = _walk_speed_current - _nav_agent.target_position = target - - _walk_dbg("walk_to target=(%.1f, %.1f) speed=%.1f root=(%.1f, %.1f) feet=(%.1f, %.1f)" % [ - target.x, target.y, _walk_speed_current, - global_position.x, global_position.y, - _nav_anchor.global_position.x, _nav_anchor.global_position.y, - ]) - - var dx := target.x - global_position.x - if dx < -0.5: - set_facing_profile(FacingProfile.LEFT) - elif dx > 0.5: - set_facing_profile(FacingProfile.RIGHT) - # LEFT idle pins the captured limb pose because mirrored TwoBoneIK is not - # stable. Re-enable the solvers before the walk animation starts driving. - _set_limb_mods_enabled(true) - # Always play the canonical walk_right clip. For LEFT the rig root is - # X-mirrored (whole-rig Y-axis mirror) and the same clip plays mirrored; - # walk_left is no longer used at runtime. - var anim_name := "walk_right" - if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.has_animation(anim_name): - _anim_player.play(anim_name) - _walking = true - _walk_done = false - # The nav/direct steering mode is latched once per walk (on the first - # post-sync frame) so it cannot flip between frames and oscillate the rig. - _walk_mode = "nav" - _walk_mode_latched = false - _walk_latch_probe_frames = 0 - _walk_settle_frames = 0 + if _locomotion_component != null: + _locomotion_component.walk_to(target, speed) func is_walking() -> bool: - return _walking - - -func _update_walking(delta: float) -> void: - if not _walking: - return - if state != RigState.ANIMATED: - _cancel_walking(false) - return - if _terrain_following_enabled and _terrain_motion != TerrainMotion.GROUNDED: - return - # Defer all nav reads until the map has actually synchronized. An unsynced - # agent reports an empty, finished path (map iteration id == 0), which would - # otherwise end the walk after a single move_toward step (~5 px). - if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0: - _walk_dbg("sync pending") - return - # Latch the steering mode once the map is synced, so a waypoint that sits - # near the mesh boundary can't flip nav<->direct between frames (that flip - # swaps between two vertically-offset targets and reads as up/down jitter). - # Reachability only becomes meaningful a frame or two AFTER the map syncs and - # a forced path query round-trips, so probe for up to LATCH_PROBE_MAX_FRAMES: - # latch "nav" as soon as the agent reports the target reachable; if it never - # does within the bound (genuinely off-mesh), latch "direct". - if not _walk_mode_latched: - if _walk_latch_probe_frames < LATCH_PROBE_MAX_FRAMES: - _walk_latch_probe_frames += 1 - _nav_agent.get_next_path_position() - if _nav_agent.is_target_reachable(): - _walk_mode_latched = true - _walk_mode = "nav" - _walk_dbg("latch mode=nav (probe %d)" % _walk_latch_probe_frames) - else: - _walk_dbg("latch probe %d (not reachable yet)" % _walk_latch_probe_frames) - return - else: - _walk_mode_latched = true - _walk_mode = "direct" - _walk_dbg("latch mode=direct (probe bound reached)") - # Ask the agent for its next waypoint FIRST. This forces the agent's internal - # path update (_update_navigation), which re-queries the map whenever the - # stored path is empty (set_target_position resets it via _request_repath). - # The read-only get_current_navigation_path() accessor alone never triggers a - # repath, so checking it directly would leave the path empty forever. - var next_feet := _nav_agent.get_next_path_position() - var final_root := _walk_target_feet + FOOT_OFFSET - # Terrain following owns vertical motion. Navigation supplies horizontal - # progress only, so slope elevation cannot fight the ankle/pelvis solve. - var dist_to_final := absf(global_position.x - final_root.x) - var root_target: Vector2 - if _walk_mode == "nav": - # Terminate once the nav agent reports its path complete AND the rig is - # close enough: the agent finishes at target_desired_distance (12 px, - # feet-space) while the rig's hard arrival radius is 8 px, so chasing the - # last stale next-waypoint would oscillate the rig around the 16-px band. - if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE: - global_position.x = final_root.x - _finish_walk("arrive") - return - # Near the destination, ignore the (possibly behind-path) next waypoint - # and steer straight at the final target so the rig cannot reverse. - if dist_to_final <= 2.0 * ARRIVE_DISTANCE: - root_target = Vector2(final_root.x, global_position.y) - else: - root_target = Vector2(next_feet.x + FOOT_OFFSET.x, global_position.y) - else: - # DIRECT branch: an off-mesh waypoint is a supported case — steer - # straight at the clicked point, ignoring the nav mesh. - root_target = Vector2(final_root.x, global_position.y) - var before_x := global_position.x - global_position = global_position.move_toward(root_target, _walk_speed_current * delta) - _last_grounded_walk_velocity_x = (global_position.x - before_x) / delta if delta > 0.0 else 0.0 - # Unified arrival radius against the FINAL target, in both branches. Snap - # the residual offset away so the rig lands exactly on the waypoint. - if dist_to_final <= ARRIVE_DISTANCE: - global_position.x = final_root.x - _finish_walk("arrive") - return - _walk_dbg("frame=%d idx=%d mode=%s root=(%.1f, %.1f) feet=(%.1f, %.1f) target=(%.1f, %.1f) dist=%.1f finished=%s reachable=%s final=(%.1f, %.1f) pts=%d next=(%.1f, %.1f) map_iter=%d" % [ - Engine.get_physics_frames(), - _current_index, - _walk_mode, - global_position.x, global_position.y, - _nav_anchor.global_position.x, _nav_anchor.global_position.y, - _walk_target_feet.x, _walk_target_feet.y, - global_position.distance_to(_walk_target_feet + FOOT_OFFSET), - str(_nav_agent.is_navigation_finished()), - str(_nav_agent.is_target_reachable()), - _nav_agent.get_final_position().x, _nav_agent.get_final_position().y, - _nav_agent.get_current_navigation_path().size(), - next_feet.x, next_feet.y, - NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()), - ]) - - -## Legacy deferred marker reset hook. Current walk completion deliberately -## leaves this at zero because the final animation frame must remain visible. -func _settle_walk_markers() -> void: - if _walk_settle_frames > 0: - _walk_settle_frames -= 1 - _restore_standing_markers() - - -func _finish_walk(reason: String = "") -> void: - if DEBUG_WALK: - var map_iter := -1 - if _nav_agent != null: - map_iter = NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) - _walk_dbg("finish reason=%s root=(%.1f, %.1f) dist_to_target=%.1f map_iter=%d" % [ - reason, - global_position.x, global_position.y, - global_position.distance_to(_walk_target_feet + FOOT_OFFSET), - map_iter, - ]) - # Freeze on the exact solved animation frame at arrival. LEFT cannot leave - # TwoBoneIK running under the root mirror, so capture the bone rotations - # before stopping and pin that same frame after disabling the solvers. - var final_limb_rotations: Dictionary = _capture_limb_rotations() - if _anim_player != null and is_instance_valid(_anim_player): - _anim_player.stop(true) - _walking = false - _apply_idle_limb_solver_mode(final_limb_rotations) - _walk_settle_frames = 0 - _walk_done = true - arrived.emit(_walk_target_feet) - - -func _cancel_walking(restore_idle_pose: bool = true) -> void: - _walk_dbg("cancel (state=%s)" % RigState.keys()[state]) - var current_limb_rotations: Dictionary = _capture_limb_rotations() - if _anim_player != null and is_instance_valid(_anim_player): - _anim_player.stop(true) - if _nav_agent != null and _nav_anchor != null: - _nav_agent.target_position = _nav_anchor.global_position - _walking = false - _walk_done = false - _walk_mode_latched = false - _walk_latch_probe_frames = 0 - _walk_settle_frames = 0 - if restore_idle_pose: - _apply_idle_limb_solver_mode(current_limb_rotations) + return _locomotion_component.is_walking() if _locomotion_component != null else false # --------------------------------------------------------------------------- @@ -2860,63 +727,23 @@ func _cancel_walking(restore_idle_pose: bool = true) -> void: ## Show the speech bubble with `text` for `duration` seconds; auto-hides and ## emits speech_finished. Lazily creates the SpeechBubble child on first use. func speak(text: String, duration: float) -> void: - _primary_speech = { "text": text, "time_left": maxf(duration, 0.0) } - _speech_active = true - _speech_time_left = maxf(duration, 0.0) - _show_visible_speech() + if _action_runner_component != null: + _action_runner_component.speak(text, duration) func _speak_overlay(text: String, duration: float) -> void: - _overlay_speech = { "text": text, "time_left": maxf(duration, 0.0) } - _show_visible_speech() - - -func _show_visible_speech() -> void: - if _speech_bubble == null or not is_instance_valid(_speech_bubble): - _speech_bubble = SPEECH_BUBBLE_SCRIPT.new() - _speech_bubble.name = "SpeechBubble" - _speech_bubble.position = SPEECH_BUBBLE_OFFSET - add_child(_speech_bubble) - _sync_speech_bubble_mirror() - var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech - if not visible_speech.is_empty(): - _speech_bubble.show_text(String(visible_speech.get("text", ""))) - - -func _update_speech(delta: float) -> void: - var visible_owner_changed := false - if not _primary_speech.is_empty() and not _is_action_queue_blocked(): - _primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta - _speech_time_left = float(_primary_speech["time_left"]) - if _speech_time_left <= 0.0: - _primary_speech.clear() - _speech_active = false - speech_finished.emit() - if not _overlay_speech.is_empty(): - _overlay_speech["time_left"] = float(_overlay_speech.get("time_left", 0.0)) - delta - if float(_overlay_speech["time_left"]) <= 0.0: - _overlay_speech.clear() - visible_owner_changed = true - speech_finished.emit() - if _overlay_speech.is_empty() and _primary_speech.is_empty(): - if _speech_bubble != null and is_instance_valid(_speech_bubble): - _speech_bubble.hide_bubble() - elif visible_owner_changed and _overlay_speech.is_empty(): - _show_visible_speech() + if _action_runner_component != null: + _action_runner_component.speak_overlay(text, duration) func _hide_speech() -> void: - _speech_active = false - _speech_time_left = 0.0 - _primary_speech.clear() - _overlay_speech.clear() - if _speech_bubble != null and is_instance_valid(_speech_bubble): - _speech_bubble.hide_bubble() + if _action_runner_component != null: + _action_runner_component.hide_speech() func _sync_speech_bubble_mirror() -> void: - if _speech_bubble != null and is_instance_valid(_speech_bubble): - _speech_bubble.scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2.ONE + if _action_runner_component != null: + _action_runner_component.sync_facing(facing_profile) # --------------------------------------------------------------------------- @@ -2924,102 +751,43 @@ func _sync_speech_bubble_mirror() -> void: # --------------------------------------------------------------------------- func queue_action(action: Dictionary) -> void: - action_queue.append(action) - queue_changed.emit() + if _action_runner_component != null: _action_runner_component.queue_action(action) func clear_queue() -> void: - action_queue.clear() - queue_changed.emit() + if _action_runner_component != null: _action_runner_component.clear_queue() func get_queue() -> Array[Dictionary]: - return action_queue.duplicate() + return _action_runner_component.get_queue() if _action_runner_component != null else _pending_action_queue.duplicate(true) func remove_action(index: int) -> void: - if index < 0 or index >= action_queue.size(): - push_warning("StickmanRig: remove_action index %d out of range." % index) - return - action_queue.remove_at(index) - queue_changed.emit() + if _action_runner_component != null: _action_runner_component.remove_action(index) func insert_action(index: int, action: Dictionary) -> void: - action_queue.insert(clampi(index, 0, action_queue.size()), action) - queue_changed.emit() + if _action_runner_component != null: _action_runner_component.insert_action(index, action) func queue_size() -> int: - return action_queue.size() + return _action_runner_component.queue_size() if _action_runner_component != null else _pending_action_queue.size() ## Append `actions` to the queue and, when the runner is idle, resume execution ## at the first newly-appended action WITHOUT replaying the existing queue. ## Used by the Phase 4 event engine to inject reactive actions onto a stickman. func enqueue_reactive(actions: Array[Dictionary]) -> void: - if actions.is_empty(): - return - if _run_queue.is_empty() and _runner_state == RunnerState.IDLE: - for i: int in action_queue.size(): - _run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false }) - _run_cursor = 0 - var primary: Array[Dictionary] = [] - var takeover_seen := false - for action: Dictionary in actions: - var role: int = ACTION_REGISTRY.reactive_role(action) - if role == ACTION_REGISTRY.ReactiveRole.OVERLAY: - _speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0))) - continue - if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER: - # A rule batch such as [ragdoll, recover] is an ordered sequence. - # Interrupt the old work once at the first takeover; later takeover - # actions in the same batch must not erase earlier batch actions. - if not takeover_seen: - takeover_seen = true - _cancel_current_primary() - _overlay_speech.clear() - _hide_speech() - var kept: Array[Dictionary] = [] - for item: Dictionary in _run_queue.slice(_run_cursor): - if not bool(item.get("reactive", false)): - kept.append(item) - _run_queue = kept - _run_cursor = 0 - primary.clear() - primary.append(action.duplicate(true)) - else: - primary.append(action.duplicate(true)) - if primary.is_empty(): - return - if not takeover_seen: - _cancel_current_primary() - var injected: Array[Dictionary] = [] - for action: Dictionary in primary: - injected.append({ "action": action, "queue_index": -1, "reactive": true }) - _run_queue = injected + _run_queue.slice(_run_cursor) - _run_cursor = 0 - _runner_state = RunnerState.EXECUTING - _action_phase = ActionPhase.NONE + if _action_runner_component != null: + _action_runner_component.enqueue_reactive(actions) ## Drops rule-injected ("reactive") actions from the queue, restoring the ## authored sequential queue after a Play session. No-op while the runner is ## executing (callers invoke it on mode exit, after stop_queue()). func clear_reactive_actions() -> void: - var runtime_kept: Array[Dictionary] = [] - for item: Dictionary in _run_queue: - if not bool(item.get("reactive", false)): - runtime_kept.append(item) - _run_queue = runtime_kept - _run_cursor = mini(_run_cursor, _run_queue.size()) - var kept: Array[Dictionary] = [] - for action: Dictionary in action_queue: - if not bool(action.get("reactive", false)): - kept.append(action) - if kept.size() != action_queue.size(): - action_queue = kept - queue_changed.emit() + if _action_runner_component != null: + _action_runner_component.clear_reactive_actions() # --------------------------------------------------------------------------- @@ -3028,132 +796,19 @@ func clear_reactive_actions() -> void: ## Start running the queue. Empty queue emits queue_finished immediately. func start_queue() -> void: - if _runner_state == RunnerState.EXECUTING: - return - if DEBUG_WALK: - print("[runner] start_queue size=%d" % action_queue.size()) - if action_queue.is_empty(): - queue_finished.emit() - return - _runner_state = RunnerState.EXECUTING - _action_phase = ActionPhase.NONE - _current_index = -1 - _run_queue.clear() - for i: int in action_queue.size(): - _run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false }) - _run_cursor = 0 - _current_run_item.clear() - _stop_requested = false + if _action_runner_component != null: + _action_runner_component.start_queue() ## Abort the current action and return to IDLE. Does not emit queue_finished. func stop_queue() -> void: - _stop_requested = true - _cancel_current_primary() - _hide_speech() - _run_queue.clear() - _current_run_item.clear() - _runner_state = RunnerState.IDLE - _action_phase = ActionPhase.NONE - _current_index = -1 + if _action_runner_component != null: + _action_runner_component.stop_queue() func is_queue_running() -> bool: - return _runner_state == RunnerState.EXECUTING + return _action_runner_component != null and _action_runner_component.is_queue_running() func is_ragdoll_at_rest() -> bool: - return _ragdoll_at_rest - - -func _update_runner(delta: float) -> void: - if _runner_state != RunnerState.EXECUTING: - return - if _is_action_queue_blocked(): - return - match _action_phase: - ActionPhase.NONE: - _advance_to_next_action() - ActionPhase.WALKING: - if _walk_done: - _finish_action() - ActionPhase.SPEAKING: - if _primary_speech.is_empty(): - _finish_action() - ActionPhase.WAITING: - _phase_timer -= delta - if _phase_timer <= 0.0: - _finish_action() - ActionPhase.RAGDOLLING: - if is_ragdoll_at_rest(): - _finish_action() - ActionPhase.RECOVERING: - if state == RigState.ANIMATED: - _finish_action() - - -func _advance_to_next_action() -> void: - if _run_cursor >= _run_queue.size(): - _runner_state = RunnerState.IDLE - _action_phase = ActionPhase.NONE - _current_index = -1 - queue_finished.emit() - return - _current_run_item = _run_queue[_run_cursor] - _run_cursor += 1 - var action: Dictionary = _current_run_item.get("action", {}) - _current_index = int(_current_run_item.get("queue_index", -1)) - if DEBUG_WALK: - print("[runner] start idx=%d type=%s" % [_current_index, String(action.get("type", ""))]) - action_started.emit(action, _current_index) - _begin_action(action) - - -func _begin_action(action: Dictionary) -> void: - match String(action.get("type", "")): - "walk_to": - _action_phase = ActionPhase.WALKING - _walk_done = false - walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0))) - if not _walking: - # walk_to self-guarded (e.g. not ANIMATED): complete immediately - # so the runner never hangs. - _walk_done = true - "speak": - _action_phase = ActionPhase.SPEAKING - speak(String(action.get("text", "")), float(action.get("duration", 2.0))) - "wait": - _phase_timer = float(action.get("duration", 0.0)) - _action_phase = ActionPhase.WAITING - "ragdoll": - _action_phase = ActionPhase.RAGDOLLING - set_ragdoll(true) - "recover": - _action_phase = ActionPhase.RECOVERING - request_recovery() - _: - push_warning("StickmanRig: unknown action type '%s'; skipped." % String(action.get("type", ""))) - _finish_action() - - -func _finish_action() -> void: - var finished: Dictionary = _current_run_item.get("action", {}) - var index: int = int(_current_run_item.get("queue_index", -1)) - _action_phase = ActionPhase.NONE - _current_run_item.clear() - _current_index = -1 - if not finished.is_empty(): - action_finished.emit(finished, index) - - -func _cancel_current_primary() -> void: - _cancel_walking(not _is_action_queue_blocked()) - _primary_speech.clear() - _speech_active = false - _speech_time_left = 0.0 - _phase_timer = 0.0 - _action_phase = ActionPhase.NONE - _current_run_item.clear() - _current_index = -1 - if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble): - _speech_bubble.hide_bubble() + return _ragdoll_component.is_at_rest() if _ragdoll_component != null else false diff --git a/scripts/stk_rig_adapter.gd b/scripts/stk_rig_adapter.gd index 1ad4aff..5d7585f 100644 --- a/scripts/stk_rig_adapter.gd +++ b/scripts/stk_rig_adapter.gd @@ -2,7 +2,7 @@ class_name StkRigAdapter extends RefCounted ## StkRigAdapter - Standalone runtime adapter (Phase 8). ## -## Fits an instantiated master_rig.tscn to a loaded .stk dictionary: re-fits +## Fits an instantiated scenes/stickman_rig.tscn to a loaded .stk dictionary: re-fits ## the skeleton bone lengths, recalibrates the IK targets, and mounts the ## .stk vector shapes onto the rig's Body/ visual nodes in the rig's "hanging" ## convention (joint end at the local origin, far end along +Y). The diff --git a/scripts/test_harness.gd b/scripts/test_harness.gd index dac8292..993bcbf 100644 --- a/scripts/test_harness.gd +++ b/scripts/test_harness.gd @@ -1,7 +1,7 @@ extends Control ## TestHarness - Standalone runtime rig viewer / IK playground (Phase 9). ## -## Loads .stk files via StickmanFactory, spawns a master_rig.tscn instance into +## Loads .stk files via StickmanFactory, spawns a scenes/stickman_rig.tscn instance into ## a SubViewport, and provides a debug overlay (skeleton bones + IK handles) ## plus interactive IK-handle dragging. NOT wired into the editor — run ## standalone via F6 on res://scenes/test_harness.tscn. diff --git a/specs/phase_8_rig_modularization.md b/specs/phase_8_rig_modularization.md new file mode 100644 index 0000000..878fad8 --- /dev/null +++ b/specs/phase_8_rig_modularization.md @@ -0,0 +1,501 @@ +# Phase 8 — Rig Relocation and Runtime Component Modularization + +Status: architecture proposed; awaiting user approval before implementation. + +Source proposal: `plans/PHASE_8_RIG_MODULARIZATION.md`. + +## 1. Outcome and architectural correction + +Move the authored rig scene from `res://master_rig.tscn` to +`res://scenes/stickman_rig.tscn` while preserving its existing scene UID +(`uid://dr5ef3l2var2s`). Split the 3,159-line `StickmanRig` controller into three child +components, but keep `StickmanRig` as the only public facade used by the factory, stage, +harnesses, scene I/O, and existing gameplay code. + +One boundary in the source plan should change before implementation: navigation and +`walk_to()` belong in `StickmanLocomotion`, not `StickmanActionRunner`. Walking, terrain +following, ledge momentum, landing, animation pause/resume, and root displacement share one +state machine and one `AnimationPlayer`. Splitting path following from terrain motion would +create two writers for the rig transform and require a per-frame synchronization protocol. +`StickmanActionRunner` should orchestrate actions and call the locomotion API; it should not +implement movement. + +The extraction is structural only. It must not change movement tuning, recovery math, action +payloads, signal timing, saved scene data, or editor UI. + +## 2. Targets and ownership + +| Target | Ownership after Phase 8 | +| --- | --- | +| `scenes/stickman_rig.tscn` | Renamed rig scene. Keeps the existing `Master`, `Body`, `Skeleton2D`, `IK_Targets`, `AnimationTargets`, `AnimationPlayer`, and `AnimationTree` paths. Adds a nonvisual `RigComponents` branch. | +| `scripts/stickman_rig.gd` (`StickmanRig`) | Public compatibility facade; authoritative `RigState`; facing profile, bend flags, IK modification resolution, body z-order, root mirror, head pin, shared node lookup, component wiring, deterministic physics scheduling, and public signal forwarding. | +| `scripts/stickman_locomotion.gd` (`StickmanLocomotion`) | Navigation agent, walking, terrain probes/following, airborne and landing states, root movement, gait/foot contacts, animated collision proxies, airborne sweeps, and impact reactions. | +| `scripts/stickman_ragdoll.gd` (`StickmanRagdoll`) | Momentum tracking, ragdoll definitions and procedural bodies/joints, rest detection, pre-ragdoll/captured-pose data, recovery snap/tween, and ragdoll impulse application. | +| `scripts/stickman_action_runner.gd` (`StickmanActionRunner`) | Authored/runtime action queues, reactive injection, action phase machine, wait timing, speech bubble and overlay arbitration, and action/queue/speech signals. | +| `scripts/stickman_factory.gd` | Loads the relocated scene; return type and `spawn*` API stay unchanged. | +| `scripts/stk_rig_adapter.gd` | No node-path changes. Comments reference the relocated scene. It continues to adapt the root before `_ready()`. | +| `scripts/create_animations.gd`, harnesses, tests, active docs | Update live scene-path literals/comments and component-aware tests. | + +`source_stk_path` remains on the root facade because it is instance provenance consumed by +stage serialization, not behavior owned by a component. + +No layout, theme, visible node, or user-facing UI change is required. + +## 3. Scene structure + +The existing visual and skeleton hierarchy must remain byte-for-byte equivalent apart from the +new component branch and external component script resources. Adapter and harness node paths +therefore remain valid. + +```text +Master: Node2D [StickmanRig] +├── RigComponents: Node +│ ├── Locomotion: Node [StickmanLocomotion] +│ ├── Ragdoll: Node [StickmanRagdoll] +│ └── ActionRunner: Node [StickmanActionRunner] +├── Body: Node2D +│ └── Body / Head / 8 limb visual containers (unchanged) +├── Skeleton2D (unchanged) +│ └── Torso/Head + 8 limb Bone2D nodes +├── RayCast_Ground (unchanged; legacy) +├── IK_Targets (unchanged) +├── AnimationTargets (unchanged) +├── AnimationPlayer (unchanged) +└── AnimationTree (unchanged) + +world/stage parent +└── RagdollBodyContainer (runtime only; unchanged location) + ├── 10 RigidBody2D + └── 9 PinJoint2D +``` + +The components are authored children rather than created in `_ready()`. Missing component nodes +produce one `StickmanRig:` warning per missing node and disable only that subsystem. The root must +never manufacture a fallback component silently because that would hide a broken scene. + +## 4. Dependency direction and state authority + +```mermaid +flowchart LR + C[StickmanRig facade/coordinator] + L[StickmanLocomotion] + R[StickmanRagdoll] + A[StickmanActionRunner] + U[Stage / Factory / Harness / Scene IO] + + U -->|existing public API| C + C -->|configure + deterministic ticks| L + C -->|configure + commands| R + C -->|configure + commands| A + A -->|walk/speech-independent movement query| L + A -->|ragdoll_requested / recovery_requested| C + R -->|state_transition_requested| C + R -->|ground query + recovery reanchor| L + L -->|arrived / facing_requested| C + C -->|forwarded public signals| U +``` + +Rules: + +1. `StickmanRig.state` is the only high-level state authority. Components receive the current + state during commands/ticks and never write it directly. +2. `StickmanActionRunner` has no reference to `StickmanRagdoll`; it requests transitions through + root-connected signals. This allows queue tests without constructing physics bodies. +3. `StickmanRagdoll` may synchronously call only the narrow locomotion recovery/query API; it + must not inspect locomotion private fields. +4. `StickmanLocomotion` requests facing changes by signal so the facade remains the profile and + mirror authority. +5. The components do not implement `_physics_process()`. The root is the sole scheduler. This + preserves the current update order and avoids relying on Godot sibling processing order. + +## 5. Static component contracts + +The signatures below are the implementation contract. Internal helper names may be retained +during mechanical extraction, but cross-component calls must use only these methods/signals. + +### 5.1 `StickmanLocomotion` + +```gdscript +class_name StickmanLocomotion +extends Node + +signal arrived(target: Vector2) +signal facing_requested(profile: int) + +func configure(rig: StickmanRig) -> void +func tick_movement(delta: float, rig_state: int) -> void +func tick_post_skeleton(delta: float, rig_state: int) -> void + +func walk_to(target: Vector2, speed: float = -1.0) -> bool +func cancel_walk(restore_idle_pose: bool = true) -> void +func is_walking() -> bool +func consume_walk_completed() -> bool + +func begin_terrain_fall() -> void +func set_terrain_following_enabled(enabled: bool) -> void +func is_action_blocked() -> bool +func prepare_for_ragdoll() -> void +func restore_after_recovery() -> void + +func probe_ground_y( + world_x: float, + top_y: float, + bottom_y: float, + exclude: Array[RID] +) -> float +func reanchor_for_recovery(feet_world: Vector2, foot_offset: Vector2) -> Vector2 + +func set_collision_bodies_enabled(enabled: bool) -> void +func apply_collision_reaction( + part_name: String, + normal: Vector2, + impact_speed: float, + contact_position: Vector2 = Vector2.INF +) -> void +``` + +`tick_movement()` owns the old clear-pelvis → walking → terrain → settle sequence. +`tick_post_skeleton()` owns impact-reaction application and collision-proxy synchronization, +which must remain after the facade's head pin. `reanchor_for_recovery()` moves the root and +returns the mirror-aware rig-local shift used by `StickmanRagdoll` to rebase captured body data. + +The locomotion component is the source of truth for `TerrainMotion`, navigation, terrain/air +tuning constants, collision proxy tables, and reaction constants. Compatibility aliases may +remain on `StickmanRig` for one phase, but they must point to component constants rather than +duplicate values. + +### 5.2 `StickmanRagdoll` + +```gdscript +class_name StickmanRagdoll +extends Node + +signal state_transition_requested(next_state: int) +signal rest_state_changed(at_rest: bool) + +func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void +func tick_pre_movement(delta: float, rig_state: int) -> void + +func enter_ragdoll() -> bool +func request_recovery() -> bool +func snap_to_standing() -> bool +func cancel_recovery() -> void +func apply_velocity_boost(velocity: Vector2) -> void +func is_at_rest() -> bool +func initialize_spawn_pose_from_skeleton() -> void +``` + +`enter_ragdoll()` and recovery methods return whether they actually accepted the transition. +They perform the physical handoff before emitting `state_transition_requested`; the root then +commits `state` and emits the legacy `state_changed` signal exactly once. The component owns the +existing `RAGDOLL_BODIES`, `RAGDOLL_JOINTS`, per-profile joint bands, recovery overrides, +captured dictionaries, tween generation, and procedural container lifetime. + +The root provides narrow internal skeleton services used by recovery: + +```gdscript +func _get_skeleton() -> Skeleton2D +func _get_body_container() -> Node2D +func _get_animation_player() -> AnimationPlayer +func _get_ik_marker(name: String) -> Marker2D +func _get_limb_bones() -> Dictionary +func _get_bend_modifications() -> Dictionary +func _get_head_bone() -> Bone2D +func _get_head_look_at() -> SkeletonModification2DLookAt +func _set_limb_mods_enabled(enabled: bool) -> void +func _restore_head_lookat_state() -> void +func _rearm_ik_stack() -> void +``` + +These methods are internal collaboration points, not new consumer APIs. + +### 5.3 `StickmanActionRunner` + +```gdscript +class_name StickmanActionRunner +extends Node + +signal action_started(action: Dictionary, index: int) +signal action_finished(action: Dictionary, index: int) +signal queue_finished +signal queue_changed +signal speech_finished +signal ragdoll_requested(enabled: bool) +signal recovery_requested + +var action_queue: Array[Dictionary] = [] + +func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void +func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void + +func speak(text: String, duration: float) -> void +func queue_action(action: Dictionary) -> void +func clear_queue() -> void +func get_queue() -> Array[Dictionary] +func remove_action(index: int) -> void +func insert_action(index: int, action: Dictionary) -> void +func queue_size() -> int +func enqueue_reactive(actions: Array[Dictionary]) -> void +func clear_reactive_actions() -> void +func start_queue() -> void +func stop_queue() -> void +func is_queue_running() -> bool +func sync_facing(profile: int) -> void +``` + +`RunnerState` and `ActionPhase` are component-owned. Root keeps enum aliases/definitions only +where required for source compatibility. The component owns both primary and overlay speech, +creates `SpeechBubble` under `Master` (not under `RigComponents`), and counter-mirrors it through +`sync_facing()`. + +## 6. Root compatibility facade + +All current consumer-facing members remain available on `StickmanRig` with unchanged names, +parameters, return types, and signal cardinality. + +| Existing root surface | Delegates to / remains in | +| --- | --- | +| `set_facing_profile`, `get_facing_profile`, bend getters/setters, bend joint queries | Root | +| `state`, `state_changed`, `set_ragdoll`, `toggle_ragdoll`, `is_in_ragdoll`, `request_recovery`, `snap_to_standing`, `apply_ragdoll_velocity_boost`, `is_ragdoll_at_rest`, `initialize_spawn_pose_from_skeleton` | Root facade → Ragdoll | +| `walk_to`, `is_walking`, `arrived`, `begin_terrain_fall`, `snap_to_terrain`, `set_terrain_following_enabled` | Root facade → Locomotion | +| `set_collision_bodies_enabled`, `apply_collision_reaction` | Root facade → Locomotion | +| `action_queue`, queue mutation/query methods, reactive methods, `start_queue`, `stop_queue`, `is_queue_running`, `speak` | Root facade → ActionRunner | +| `action_started`, `action_finished`, `queue_finished`, `queue_changed`, `speech_finished` | Child signal forwarded by root | +| `facing_profile`, four bend exports, `rest_timeout`, `auto_recover`, `walk_speed` | Remain root exports; values synchronize to the owning component during `_ready()` and in setters afterward. | +| `source_stk_path` | Root | + +`action_queue` must be a delegating property because tests, scene loading, and older callers may +read or assign it directly: + +```gdscript +var action_queue: Array[Dictionary]: + get: + return _action_runner.action_queue if _action_runner != null else [] + set(value): + if _action_runner != null: + _action_runner.action_queue = value + else: + _pending_action_queue = value +``` + +Direct assignment keeps today's behavior and does not emit `queue_changed`; mutation methods do. +Pre-`_ready()` export/property writes are stored on the facade and pushed to components after +configuration, matching the existing pre-ready setter guard. + +Private root fields are not compatibility API. Existing tests that inspect `_ragdoll_bodies`, +`_terrain_motion`, `_run_queue`, or other private internals must move to the owning component. +Do not keep duplicate mutable private state on the root merely to satisfy tests. + +## 7. Physics order and state transitions + +The root retains a single `_physics_process()` scheduler: + +```text +Ragdoll.tick_pre_movement + ├── cache root momentum + └── update ragdoll rest detection +Locomotion.tick_movement + ├── clear one-frame pelvis correction + ├── update walk/navigation + ├── update terrain/air/landing + └── settle walk markers +ActionRunner.tick + ├── update speech clocks + └── update action state machine +StickmanRig core + └── pin mirrored head rotation +Locomotion.tick_post_skeleton + ├── apply impact reactions + └── sync animated collision proxies +``` + +Ragdoll transition flow: + +```mermaid +stateDiagram-v2 + [*] --> ANIMATED + ANIMATED --> RAGDOLL: root.set_ragdoll(true)\nlocomotion cleanup → build physics → request state + RECOVERING --> RAGDOLL: root.set_ragdoll(true)\ncancel tween → rebuild physics + RAGDOLL --> RECOVERING: request_recovery()\ncapture → destroy physics → reanchor → snap + RECOVERING --> ANIMATED: recovery tween finished + RAGDOLL --> ANIMATED: snap_to_standing() + RECOVERING --> ANIMATED: snap_to_standing() +``` + +The facade rejects illegal transition requests before delegating. A component signal can request +a state commit, but `_set_state(next_state)` on the facade is the only function that mutates the +root `state` and emits `state_changed`. + +## 8. Practical data flows + +### 8.1 Authored queue action + +```gdscript +rig.queue_action({"type": "walk_to", "target": Vector2(640, 320), "speed": 300.0}) +rig.queue_action({"type": "speak", "text": "Made it!", "duration": 2.0}) +rig.start_queue() +``` + +```text +StickmanRig.queue_action + → ActionRunner.action_queue + → root forwards queue_changed +ActionRunner begins walk_to + → Locomotion.walk_to + → Locomotion arrived(target) + → root forwards arrived(target) + → ActionRunner observes consume_walk_completed() and emits action_finished +``` + +The queue dictionary shape and deep-copy behavior remain unchanged. + +### 8.2 Ragdoll recovery landing handoff + +```text +Ragdoll captures torso world center + body pose + → Locomotion.probe_ground_y(x, top, bottom, ragdoll_rids) + → Ragdoll computes feet_world + → Locomotion.reanchor_for_recovery(feet_world, FOOT_OFFSET) + moves Master and returns mirror-aware local_shift + → Ragdoll rebases captured positions by local_shift + → Ragdoll snap-solves skeleton and requests RECOVERING +``` + +The existing capture payload stays internal and unchanged: + +```gdscript +{ + "torso": {"pos": Vector2(...), "rot": 0.42, "half": 195.0}, + "head": {"pos": Vector2(...), "rot": -0.10, "half": 100.0}, + # eight limb entries... +} +``` + +## 9. Relocation policy + +Implementation moves the scene once with its UID intact, then updates live references in the +same change: + +- `scripts/stickman_factory.gd` +- `scripts/physics_test_harness.gd` +- `tests/test_phase6a_airborne_landing.gd` +- any additional literal discovered by `rg --fixed-strings 'res://master_rig.tscn'` +- active comments in `stickman_rig.gd`, `stk_rig_adapter.gd`, `test_harness.gd`, and + `create_animations.gd` +- implementation-facing comments in the changed scripts + +After the user verifies the implementation in Godot, the documentation phase updates active +project docs (`README.md`, `AGENTS.md`, `RIGGING.md`, and the source plan). Historical specs +should not receive a blind search/replace because many statements describe the path that existed +when that phase shipped. Add a short relocation note where a historical file is still used as +live guidance; do not rewrite historical line references or decisions. + +Acceptance requires: + +```powershell +rg --fixed-strings 'res://master_rig.tscn' scripts scenes tests project.godot +``` + +to return no live code/scene/test matches. Human-readable historical mentions may remain when +clearly historical. + +## 10. Migration sequence + +1. **Characterize first.** Run the current focused rig suites and record baselines before moving + code. Add no behavior changes to the extraction patch. +2. **Add component nodes/scripts without relocating the scene.** Wire configuration, root signal + forwarding, and the central scheduler. The scene still loads from the old path during this + step. +3. **Extract action/speech orchestration.** It has the smallest physics surface. Keep all root + queue methods as delegates and migrate private runner tests. +4. **Extract locomotion plus animated collision.** Move navigation, terrain/air/landing, proxy, + and flinch state together so only one component writes the animated root/markers. +5. **Extract ragdoll/recovery.** Connect the narrow ground-query/reanchor API and retain the exact + instant-handoff ordering. +6. **Slim and verify the facade.** Remove duplicate mutable state/constants, leaving aliases only + where compatibility requires them. Verify every root public method and signal. +7. **Relocate the scene last.** Move to `scenes/stickman_rig.tscn`, preserve the UID, update every + live preload/path/comment, force one headless editor rescan for new `class_name` scripts, then + run static checks and all focused suites. +8. **Stop for editor verification.** Per `spec-pipeline`, do not update README, AGENTS, RIGGING, + the source plan, or historical documentation until the user confirms the feature works in + Godot. Documentation cleanup is the manually triggered Phase 4. + +## 11. Verification plan + +### Static and scene-load checks + +```powershell +& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --editor --path "C:\Godot4\stickman" --quit +& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_rig.gd" --path "C:\Godot4\stickman" +& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_locomotion.gd" --path "C:\Godot4\stickman" +& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_ragdoll.gd" --path "C:\Godot4\stickman" +& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_action_runner.gd" --path "C:\Godot4\stickman" +``` + +Add `tests/test_phase8_rig_modularization.gd` covering: + +1. relocated scene loads by new path and retains UID/node paths; +2. all three typed component children resolve; +3. root public methods delegate and root signals emit once; +4. missing optional/component dependency warns instead of crashing; +5. queue/speech execution works while no ragdoll bodies exist; +6. locomotion walking/terrain ticks work while no ragdoll bodies exist; +7. ragdoll enter/recover/snap keeps state order and ten-body/nine-joint cardinality; +8. root exports synchronize before and after `_ready()`; +9. direct `action_queue` assignment retains compatibility semantics; and +10. repeated spawn/free leaves no world-level `RagdollBodyContainer` orphan. + +Run the focused regressions most coupled to the extraction: + +- `tests/test_phase3c_walk_recovery.gd` +- `tests/test_phase4_final_bugs.gd` +- `tests/test_phase4b_walk.gd` +- `tests/test_phase4b_terrain.gd` +- `tests/test_phase45_collision_bodies.gd` +- `tests/test_phase6a_airborne_landing.gd` +- `tests/test_phase5_file_features.gd` +- `tests/test_cli_scene.gd` + +Tests should inspect `$RigComponents/Locomotion`, `$RigComponents/Ragdoll`, or +`$RigComponents/ActionRunner` for subsystem internals instead of requiring stale private mirrors +on `StickmanRig`. + +Manual Godot verification after automated checks: + +- factory/harness loads an `.stk` and all body geometry follows the same bones; +- LEFT/RIGHT/FORWARD, head mirror pin, bend menus, and z-order remain unchanged; +- queue `walk_to → speak → wait → ragdoll → recover` completes with one signal per event; +- walking over ramps, ledge fall, hard landing, prop collision/flinch, and queue landing barrier; +- ragdoll knock-up, automatic/manual recovery, recovery interruption, and snap-to-standing; +- Sandbox EDIT/DIRECT/PLAY round trip and scene save/load provenance/queue persistence. + +## 12. Risks and guardrails + +| Risk | Guardrail | +| --- | --- | +| Godot child processing order changes behavior | Components have no physics callbacks; root calls ordered tick methods. | +| Walk and terrain both write the root | They live in one locomotion component and retain the current internal order. | +| State duplicated between root and ragdoll | Root is sole `RigState` authority; component requests transitions. | +| Signals double-fire through forwarding | Child emits subsystem signal; root forwarding is the only consumer-facing emission. Add cardinality tests. | +| Recovery reaches through locomotion internals | Only `probe_ground_y()` and `reanchor_for_recovery()` cross that boundary. | +| Adapter runs before component `_ready()` | Components do not cache geometry until root `_ready()`; adapter node paths remain unchanged. | +| Scene move breaks preload before scripts update | Relocation and all live path updates land atomically; UID is asserted. | +| New `class_name` cache is stale in CLI | Run one `--headless --editor --quit` rescan before `--check-only`. | +| Private-test migration masks regressions | Add behavior assertions through root API/signals plus component-local focused assertions. | +| Broad doc replacement falsifies old specs | Update active docs; annotate historical specs instead of blind replacement. | + +## 13. Decisions requiring approval + +1. **Approve movement ownership change:** `StickmanLocomotion` owns navigation and `walk_to()`; + `StickmanActionRunner` only orchestrates it. This intentionally differs from the source plan. +2. **Approve animated collision ownership:** collision proxies, airborne sweeps, and impact + reactions move with locomotion, avoiding a fourth component in this phase. +3. **Approve compatibility scope:** preserve all root public APIs/signals/exports, including direct + `action_queue`; migrate tests off private root fields rather than keeping duplicate private + mirrors. +4. **Approve documentation policy:** after editor verification, update active documentation and + preserve historical specs with relocation notes instead of blanket rewriting their historical + references. + +Implementation must not begin until these decisions are approved. diff --git a/tests/test_phase3b_library.gd b/tests/test_phase3b_library.gd index 26965fc..26c5e85 100644 --- a/tests/test_phase3b_library.gd +++ b/tests/test_phase3b_library.gd @@ -1,6 +1,6 @@ # test_phase3b_library.gd # Headless tests for Phase 3b Asset Library (no pixel rendering): -# 1. StickmanLibrary.scan() returns 3 entries (test/basic/break) sorted by +# 1. StickmanLibrary.scan() returns the 4 checked-in entries sorted by # display name; empty stickman_name falls back to the filename basename. # 2. Corrupt / body-parts-less .stk files are skipped (scan dir override). # 3. make_entry() valid path -> name+data; missing/corrupt path -> {}. @@ -70,11 +70,11 @@ func _test_scan() -> void: print("--- StickmanLibrary.scan() ---") var lib = STICKMAN_LIBRARY.new() var entries: Array[Dictionary] = lib.scan() - _check(entries.size() == 3, "scan returns 3 entries (got %d)" % entries.size()) + _check(entries.size() == 4, "scan returns 4 entries (got %d)" % entries.size()) var names: Array[String] = [] for e: Dictionary in entries: names.append(String(e["name"])) - _check(names == ["Basic", "break", "test"], + _check(names == ["Basic", "Basic Broken", "break", "test"], "entries sorted by display name (got %s)" % str(names)) _check(String(_by_path(entries, "basic.stk")["name"]) == "Basic", "basic.stk display name is 'Basic' (stickman_name)") diff --git a/tests/test_phase3b_ui_fixes.gd b/tests/test_phase3b_ui_fixes.gd index 71040d2..ef9afaf 100644 --- a/tests/test_phase3b_ui_fixes.gd +++ b/tests/test_phase3b_ui_fixes.gd @@ -279,8 +279,8 @@ func _test_bug4_stage_grid_no_prehighlight() -> void: % stage._spawner.get_selected_stickman_path()) # Real scan has 3 stickmen -> no single-item auto-select skip. var grid: GridContainer = stage._selector._grid - _check(grid.get_child_count() == 3, - "real scan populates 3 stickman cells (got %d)" % grid.get_child_count()) + _check(grid.get_child_count() == 4, + "real scan populates 4 stickman cells (got %d)" % grid.get_child_count()) _check(not stage._selector.has_method("_is_selected"), "stage selector exposes no pre-highlight API") _check(not _any_cell_has_stylebox_override(stage._selector), diff --git a/tests/test_phase3c_walk_recovery.gd b/tests/test_phase3c_walk_recovery.gd index 2bf62df..ca1934e 100644 --- a/tests/test_phase3c_walk_recovery.gd +++ b/tests/test_phase3c_walk_recovery.gd @@ -34,7 +34,7 @@ # # Fix 2 (recovery re-anchor): _capture_ragdoll_pose() records the ragdoll # torso's world landing anchor -- _captured_landing_center (world center) -# plus _captured_ground_y (center.y + RAGDOLL_TORSO_RADIUS, the ground +# plus _captured_ground_y (center.y + StickmanRagdoll.TORSO_RADIUS, the ground # contact line) -- and _start_recovery() calls _reanchor_root_to_landing(), # which moves the rig root so the STANDING figure's FEET sit on the ground # at the landing X (feet = (_captured_landing_center.x, _captured_ground_y), @@ -544,8 +544,10 @@ func _test_recovery_reanchor() -> void: rig.state_changed.connect(func(s): state_events.append(int(s))) rig.set_ragdoll(true) _check(rig.is_in_ragdoll(), "set_ragdoll(true) puts the rig in RAGDOLL") - _check(rig._ragdoll_bodies.size() == 10, - "ragdoll builds 10 bodies (got %d)" % rig._ragdoll_bodies.size()) + var ragdoll: StickmanRagdoll = rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll + var ragdoll_bodies: Dictionary = ragdoll.get_bodies() + _check(ragdoll_bodies.size() == 10, + "ragdoll builds 10 bodies (got %d)" % ragdoll_bodies.size()) var body_container := rig.get_node_or_null(NodePath("Body")) as Node2D var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D _check(body_container != null and not body_container.visible, @@ -553,7 +555,7 @@ func _test_recovery_reanchor() -> void: _check(skeleton != null and skeleton.modification_stack != null \ and not skeleton.modification_stack.enabled, "IK stack is disabled during RAGDOLL") - var torso: RigidBody2D = rig._ragdoll_bodies.get("torso") as RigidBody2D + var torso: RigidBody2D = ragdoll_bodies.get("torso") as RigidBody2D _check(torso != null, "ragdoll torso body exists") if torso == null: await _free_stage(stage) @@ -563,13 +565,13 @@ func _test_recovery_reanchor() -> void: % float(torso.get_meta("half_height", 0.0))) # Simulate "landed": freeze every body, then offset the whole pile. - for key: String in rig._ragdoll_bodies: - var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D + for key: String in ragdoll_bodies: + var b: RigidBody2D = ragdoll_bodies[key] as RigidBody2D b.freeze = true b.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC var torso_before: Vector2 = torso.global_position - for key: String in rig._ragdoll_bodies: - var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D + for key: String in ragdoll_bodies: + var b: RigidBody2D = ragdoll_bodies[key] as RigidBody2D b.global_position += LANDING_DELTA for i in 3: await physics_frame @@ -582,10 +584,10 @@ func _test_recovery_reanchor() -> void: var spine_dir := Vector2.from_angle(torso.global_rotation) var hip_world: Vector2 = torso.global_position - spine_dir * half var landing_center: Vector2 = torso.global_position - var ground_y: float = torso.global_position.y + float(RIG.RAGDOLL_TORSO_RADIUS) + var ground_y: float = torso.global_position.y + StickmanRagdoll.TORSO_RADIUS var feet_world := Vector2(landing_center.x, ground_y) - var expected_root: Vector2 = feet_world + (RIG.FOOT_OFFSET as Vector2) - var stand_torso: Vector2 = (RIG.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO) + var expected_root: Vector2 = feet_world + StickmanLocomotion.FOOT_OFFSET + var stand_torso: Vector2 = (StickmanRagdoll.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO) var final_hip_world: Vector2 = expected_root + stand_torso var old_hip_anchor_root: Vector2 = hip_world - stand_torso _check(hip_world.distance_to(pre_root) > 400.0, @@ -598,12 +600,12 @@ func _test_recovery_reanchor() -> void: "request_recovery() enters RECOVERING (state %d)" % int(rig.state)) _check(not state_events.is_empty() and state_events[-1] == int(rig.RigState.RECOVERING), "state_changed emitted RECOVERING (events %s)" % str(state_events)) - _check(rig._captured_landing_center.distance_to(landing_center) <= 0.5, + _check(ragdoll._captured_landing_center.distance_to(landing_center) <= 0.5, "_captured_landing_center matches the simulated landing center (got %s)" - % str(rig._captured_landing_center)) - _check(absf(rig._captured_ground_y - ground_y) <= 0.5, + % str(ragdoll._captured_landing_center)) + _check(absf(ragdoll._captured_ground_y - ground_y) <= 0.5, "_captured_ground_y matches center.y + torso radius (got %.2f, expected %.2f)" - % [rig._captured_ground_y, ground_y]) + % [ragdoll._captured_ground_y, ground_y]) _check(rig.global_position.distance_to(expected_root) <= 0.5, "root re-anchored so the feet sit on the landing ground (got %s, expected %s)" % [str(rig.global_position), str(expected_root)]) @@ -779,7 +781,9 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void: rig.state_changed.connect(func(s): state_events.append(int(s))) rig.set_ragdoll(true) _check(rig.is_in_ragdoll(), "set_ragdoll(true) puts the rig in RAGDOLL") - var torso: RigidBody2D = rig._ragdoll_bodies.get("torso") as RigidBody2D + var ragdoll: StickmanRagdoll = rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll + var ragdoll_bodies: Dictionary = ragdoll.get_bodies() + var torso: RigidBody2D = ragdoll_bodies.get("torso") as RigidBody2D _check(torso != null, "ragdoll torso body exists") if torso == null: await _free_stage(stage) @@ -787,8 +791,8 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void: # Freeze + teleport the whole pile, then rotate ONLY the torso body so its # spine lies ~horizontal (a true lying pose ~±90 deg from the standing -90). - for key: String in rig._ragdoll_bodies: - var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D + for key: String in ragdoll_bodies: + var b: RigidBody2D = ragdoll_bodies[key] as RigidBody2D b.freeze = true b.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC b.global_position += LANDING_DELTA @@ -804,13 +808,13 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void: var spine_dir := Vector2.from_angle(torso.global_rotation) var hip_world: Vector2 = torso.global_position - spine_dir * half var landing_center: Vector2 = torso.global_position - var ground_y: float = torso.global_position.y + float(RIG.RAGDOLL_TORSO_RADIUS) + var ground_y: float = torso.global_position.y + StickmanRagdoll.TORSO_RADIUS var feet_world := Vector2(landing_center.x, ground_y) - var expected_root: Vector2 = feet_world + (RIG.FOOT_OFFSET as Vector2) - var stand_torso: Vector2 = (RIG.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO) + var expected_root: Vector2 = feet_world + StickmanLocomotion.FOOT_OFFSET + var stand_torso: Vector2 = (StickmanRagdoll.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO) var final_hip_world: Vector2 = expected_root + stand_torso var old_hip_anchor_root: Vector2 = hip_world - stand_torso - _check(absf(hip_world.y - ground_y) <= float(RIG.RAGDOLL_TORSO_RADIUS) + 1.0, + _check(absf(hip_world.y - ground_y) <= StickmanRagdoll.TORSO_RADIUS + 1.0, "lying hip sits at the body's mid-height, near (not above) the ground line") _check(old_hip_anchor_root.distance_to(expected_root) > 300.0, "old hip-anchor root differs wildly from the new feet-anchor root (%.1f px)" @@ -820,12 +824,12 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void: rig.request_recovery() _check(not rig.is_in_ragdoll() and int(rig.state) == int(rig.RigState.RECOVERING), "request_recovery() enters RECOVERING (state %d)" % int(rig.state)) - _check(rig._captured_landing_center.distance_to(landing_center) <= 0.5, + _check(ragdoll._captured_landing_center.distance_to(landing_center) <= 0.5, "_captured_landing_center matches the lying torso center (got %s)" - % str(rig._captured_landing_center)) - _check(absf(rig._captured_ground_y - ground_y) <= 0.5, + % str(ragdoll._captured_landing_center)) + _check(absf(ragdoll._captured_ground_y - ground_y) <= 0.5, "_captured_ground_y matches the lying ground-contact line (got %.2f, expected %.2f)" - % [rig._captured_ground_y, ground_y]) + % [ragdoll._captured_ground_y, ground_y]) _check(rig.global_position.distance_to(expected_root) <= 0.5, "root re-anchored so the standing feet sit on the lying ground (got %s, expected %s)" % [str(rig.global_position), str(expected_root)]) diff --git a/tests/test_phase45_collision_bodies.gd b/tests/test_phase45_collision_bodies.gd index 397be0e..be83938 100644 --- a/tests/test_phase45_collision_bodies.gd +++ b/tests/test_phase45_collision_bodies.gd @@ -42,11 +42,12 @@ func _spawn_rig(stage: Node2D, position: Vector2) -> StickmanRig: func _test_collision_proxy_geometry(rig: StickmanRig) -> void: - _check(rig._collision_bodies.size() == 10, "rig builds ten collision proxies") - _check(rig._collision_lengths.size() == 9, "rig records nine fitted capsule lengths") - for part_name: String in rig.COLLISION_PARTS: - var definition: Dictionary = rig.COLLISION_PARTS[part_name] - var body := rig._collision_bodies.get(part_name) as AnimatableBody2D + var locomotion: StickmanLocomotion = rig.get_locomotion() + _check(locomotion.collision_bodies.size() == 10, "locomotion builds ten collision proxies") + _check(locomotion.collision_lengths.size() == 9, "locomotion records nine fitted capsule lengths") + for part_name: String in locomotion.COLLISION_PARTS: + var definition: Dictionary = locomotion.COLLISION_PARTS[part_name] + var body := locomotion.collision_bodies.get(part_name) as AnimatableBody2D _check(body != null, "%s proxy exists" % part_name) if body == null: continue @@ -65,9 +66,9 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void: var circle := shape_node.shape as CircleShape2D _check(circle != null, "head uses a circle") if circle != null: - _check(circle.radius >= rig.HEAD_COLLISION_RADIUS, "head radius covers at least the fallback radius") + _check(circle.radius >= locomotion.HEAD_COLLISION_RADIUS, "head radius covers at least the fallback radius") var head_visual := rig.get_node_or_null(NodePath("Body/Head")) as Node2D - var bounds: Dictionary = rig._mounted_visual_bounds_local(head_visual) + var bounds: Dictionary = locomotion.mounted_visual_bounds_local(head_visual) _check(bool(bounds.get("valid", false)), "mounted head has measurable geometry") if bool(bounds.get("valid", false)): _check(shape_node.position.is_equal_approx((bounds["rect"] as Rect2).get_center()), "head collider follows the mounted bbox center") @@ -77,14 +78,14 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void: _check(capsule != null, "%s uses a capsule" % part_name) if capsule == null: continue - var length := float(rig._collision_lengths.get(part_name, -1.0)) + var length := float(locomotion.collision_lengths.get(part_name, -1.0)) _check(is_equal_approx(capsule.height, length), "%s capsule uses its fitted length" % part_name) _check(is_equal_approx(capsule.radius, float(definition["radius"])), "%s capsule uses its configured radius" % part_name) _check(shape_node.position.is_equal_approx(Vector2(0.0, length * 0.5)), "%s capsule is centered along +Y" % part_name) _check(is_zero_approx(shape_node.rotation), "%s capsule needs no hanging-frame rotation" % part_name) var empty_visual := Node2D.new() - var empty_bounds: Dictionary = rig._mounted_visual_bounds_local(empty_visual) + var empty_bounds: Dictionary = locomotion.mounted_visual_bounds_local(empty_visual) _check(not bool(empty_bounds.get("valid", true)), "empty visual bounds use the fallback path") empty_visual.free() @@ -100,18 +101,19 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void: func _test_collision_proxy_physics_alignment(rig: StickmanRig) -> void: + var locomotion: StickmanLocomotion = rig.get_locomotion() rig.walk_to(rig.global_position + Vector2(-400.0, 0.0), 300.0) for _frame in 8: await physics_frame await process_frame - rig._sync_collision_bodies() + locomotion.sync_collision_bodies() await physics_frame await process_frame _check(rig.facing_profile == StickmanRig.FacingProfile.LEFT, "left walk uses the mirrored collision-proxy path") - for part_name: String in rig.COLLISION_PARTS: - var definition: Dictionary = rig.COLLISION_PARTS[part_name] + for part_name: String in locomotion.COLLISION_PARTS: + var definition: Dictionary = locomotion.COLLISION_PARTS[part_name] var visual := rig.get_node_or_null(NodePath("Body/" + String(definition["visual"]))) as Node2D - var body := rig._collision_bodies.get(part_name) as AnimatableBody2D + var body := locomotion.collision_bodies.get(part_name) as AnimatableBody2D if visual == null or body == null: _check(false, "%s has nodes for PhysicsServer alignment" % part_name) continue @@ -119,62 +121,63 @@ func _test_collision_proxy_physics_alignment(rig: StickmanRig) -> void: _check(server_transform.origin.distance_to(visual.global_position) < 0.01, "%s PhysicsServer origin follows its walking visual" % part_name) _check(server_transform.x.distance_to(visual.global_transform.x) < 0.001, "%s PhysicsServer X basis follows its walking visual" % part_name) _check(server_transform.y.distance_to(visual.global_transform.y) < 0.001, "%s PhysicsServer Y basis follows its walking visual" % part_name) - rig._cancel_walking() + rig.get_locomotion().cancel_walk() rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD) func _test_reactions(rig: StickmanRig) -> void: - rig._impact_reactions.clear() + var locomotion: StickmanLocomotion = rig.get_locomotion() + locomotion.impact_reactions.clear() rig.apply_collision_reaction("unknown", Vector2.RIGHT, 400.0) rig.apply_collision_reaction("head", Vector2.ZERO, 400.0) rig.apply_collision_reaction("head", Vector2.RIGHT, 0.0) - _check(rig._impact_reactions.is_empty(), "invalid reactions are ignored") + _check(locomotion.impact_reactions.is_empty(), "invalid reactions are ignored") - var head_proxy := rig._collision_bodies["head"] as AnimatableBody2D + var head_proxy := locomotion.collision_bodies["head"] as AnimatableBody2D var head_shape := head_proxy.get_node("CollisionShape2D") as CollisionShape2D var head_circle := head_shape.shape as CircleShape2D var upper_left := head_proxy.to_global(head_shape.position + Vector2(-head_circle.radius * 0.7, -head_circle.radius * 0.7)) rig.apply_collision_reaction("head", Vector2(0.5, 0.5).normalized(), 400.0, upper_left) - _check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "upper-left head contact lowers the struck side") - rig._impact_reactions.clear() + _check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "upper-left head contact lowers the struck side") + locomotion.impact_reactions.clear() var upper_right := head_proxy.to_global(head_shape.position + Vector2(head_circle.radius * 0.7, -head_circle.radius * 0.7)) rig.apply_collision_reaction("head", Vector2(-0.5, 0.5).normalized(), 400.0, upper_right) - _check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "upper-right head contact lowers the struck side") - rig._impact_reactions.clear() + _check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "upper-right head contact lowers the struck side") + locomotion.impact_reactions.clear() var lower_left := head_proxy.to_global(head_shape.position + Vector2(-head_circle.radius * 0.7, head_circle.radius * 0.7)) rig.apply_collision_reaction("head", Vector2.UP, 400.0, lower_left) - _check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "lower-left upward contact inverts the upper-left response") - rig._impact_reactions.clear() + _check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "lower-left upward contact inverts the upper-left response") + locomotion.impact_reactions.clear() var lower_right := head_proxy.to_global(head_shape.position + Vector2(head_circle.radius * 0.7, head_circle.radius * 0.7)) rig.apply_collision_reaction("head", Vector2.UP, 400.0, lower_right) - _check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "lower-right upward contact inverts the upper-right response") - rig._impact_reactions.clear() + _check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "lower-right upward contact inverts the upper-right response") + locomotion.impact_reactions.clear() rig.apply_collision_reaction("left_lower_arm", Vector2.RIGHT, 10000.0) - var lower: Dictionary = rig._impact_reactions.get("LeftLowerArm", {}) - var upper: Dictionary = rig._impact_reactions.get("LeftUpperArm", {}) + var lower: Dictionary = locomotion.impact_reactions.get("LeftLowerArm", {}) + var upper: Dictionary = locomotion.impact_reactions.get("LeftUpperArm", {}) _check(is_equal_approx(absf(float(lower.get("angle", 0.0))), 0.6), "primary reaction caps at 0.6 radians") _check(is_equal_approx(absf(float(upper.get("angle", 0.0))), 0.24), "secondary reaction applies 0.4 weight") var strong_angle := float(lower.get("angle", 0.0)) - (rig._impact_reactions["LeftLowerArm"] as Dictionary)["strength"] = 0.25 + (locomotion.impact_reactions["LeftLowerArm"] as Dictionary)["strength"] = 0.25 rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 40.0) - lower = rig._impact_reactions["LeftLowerArm"] + lower = locomotion.impact_reactions["LeftLowerArm"] _check(is_equal_approx(float(lower["angle"]), strong_angle), "weaker opposite hit retains the stronger signed angle") _check(is_equal_approx(float(lower["strength"]), 1.0), "weaker hit refreshes reaction strength") rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 10000.0) - lower = rig._impact_reactions["LeftLowerArm"] + lower = locomotion.impact_reactions["LeftLowerArm"] _check(signf(float(lower["angle"])) != signf(strong_angle), "equal capped hit may replace the reaction direction") - var lower_bone := rig._reaction_bones["LeftLowerArm"] as Bone2D + var lower_bone := locomotion.reaction_bones["LeftLowerArm"] as Bone2D var base_rotation := lower_bone.rotation var max_offset := 0.0 for frame in 30: - rig._apply_impact_reactions(1.0 / 60.0) + locomotion.apply_impact_reactions(1.0 / 60.0) max_offset = maxf(max_offset, absf(angle_difference(base_rotation, lower_bone.rotation))) - _check(max_offset <= rig.REACTION_MAX_ANGLE + 0.001, "repeated reaction frames never accumulate beyond the angular cap") - rig._apply_impact_reactions(1.0) - _check(rig._impact_reactions.is_empty(), "expired reactions are erased") + _check(max_offset <= locomotion.REACTION_MAX_ANGLE + 0.001, "repeated reaction frames never accumulate beyond the angular cap") + locomotion.apply_impact_reactions(1.0) + _check(locomotion.impact_reactions.is_empty(), "expired reactions are erased") _check(is_zero_approx(angle_difference(base_rotation, lower_bone.rotation)), "expired reaction restores its captured base rotation") @@ -196,11 +199,11 @@ func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> voi _check(rig.global_position.distance_to(authored["position"] as Vector2) < 0.01, "PLAY to DIRECT restores the authored stickman position") _check(absf(angle_difference(float(authored["rotation"]), rig.global_rotation)) < 0.001, "PLAY to DIRECT restores the authored stickman rotation") _check(rig.get_facing_profile() == int(authored["facing_profile"]), "PLAY to DIRECT restores the authored facing profile") - for marker_name: String in rig.STAND_POSE: + for marker_name: String in StickmanRagdoll.STAND_POSE: var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D - var expected: Dictionary = rig.STAND_POSE[marker_name] + var expected: Dictionary = StickmanRagdoll.STAND_POSE[marker_name] _check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to standing after PLAY" % marker_name) - _check(absf(angle_difference(torso_marker.rotation, float(rig.STAND_POSE["Torso"]["rot"]))) < 0.001, "torso marker cannot retain an upside-down PLAY frame") + _check(absf(angle_difference(torso_marker.rotation, float(StickmanRagdoll.STAND_POSE["Torso"]["rot"]))) < 0.001, "torso marker cannot retain an upside-down PLAY frame") func _test_ghost_collision(stage: Node2D) -> void: @@ -213,6 +216,7 @@ func _test_ghost_collision(stage: Node2D) -> void: func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void: + stage.set_mode(stage.StageMode.PLAY) var prop := PropBlock.new() prop.position = Vector2(50.0, 25.0) prop.linear_velocity = Vector2(400.0, 0.0) @@ -239,8 +243,9 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void: _check((emitted_collisions[0]["position"] as Vector2).is_equal_approx(prop.global_position), "PropBlock fallback position is world-space") _check(emitted_separations == [fallback_body], "PropBlock mirrors body_exited with separated") - var lower := rig._collision_bodies["left_lower_arm"] as AnimatableBody2D - var upper := rig._collision_bodies["left_upper_arm"] as AnimatableBody2D + var locomotion: StickmanLocomotion = rig.get_locomotion() + var lower := locomotion.collision_bodies["left_lower_arm"] as AnimatableBody2D + var upper := locomotion.collision_bodies["left_upper_arm"] as AnimatableBody2D var pair_key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()] var rule := { "id": 4500, @@ -254,22 +259,23 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void: } stage._event_rules.clear() stage._event_rules.append(rule) - var initial_queue_size := rig.queue_size() + var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner + var initial_runtime_size: int = runner._run_queue.size() stage._on_prop_collided(lower, Vector2.RIGHT, Vector2(10.0, 20.0), prop) _check(stage._collision_pairs.has(pair_key), "first part contact creates a rig/prop pair") - _check(rig.queue_size() == initial_queue_size + 1, "first pair contact dispatches a rig-to-prop rule") + _check(runner._run_queue.size() == initial_runtime_size + 1, "first pair contact dispatches a rig-to-prop rule") stage._on_prop_collided(upper, Vector2.RIGHT, Vector2(11.0, 20.0), prop) var pair: Dictionary = stage._collision_pairs[pair_key] _check((pair.get("bodies", {}) as Dictionary).size() == 2, "second part contact joins the active body set") - _check(rig.queue_size() == initial_queue_size + 1, "second active part does not repeat the pair rule") - _check(rig._impact_reactions.has("LeftLowerArm") and rig._impact_reactions.has("LeftUpperArm"), "each newly contacted part can react") + _check(runner._run_queue.size() == initial_runtime_size + 1, "second active part does not repeat the pair rule") + _check(locomotion.impact_reactions.has("LeftLowerArm") and locomotion.impact_reactions.has("LeftUpperArm"), "each newly contacted part can react") stage._on_prop_separated(lower, prop) _check(stage._collision_pairs.has(pair_key), "first part exit retains the pair") stage._on_prop_separated(upper, prop) _check(not stage._collision_pairs.has(pair_key), "last part exit clears the pair") stage._on_prop_collided(lower, Vector2.RIGHT, Vector2(12.0, 20.0), prop) - _check(rig.queue_size() == initial_queue_size + 2, "full separation permits a new collision rule") + _check(runner._run_queue.size() == initial_runtime_size + 2, "full separation permits a new collision rule") var prop_ids: Array[int] = [prop.get_instance_id()] stage._purge_collision_pairs_for_ids(prop_ids) _check(not stage._collision_pairs.has(pair_key), "prop deletion purge removes active pairs") @@ -291,16 +297,16 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void: } stage._event_rules.clear() stage._event_rules.append(generic_rule) - var before_generic := rig.queue_size() + var before_generic: int = runner._run_queue.size() stage._on_prop_collided(fallback_body, Vector2.RIGHT, Vector2(13.0, 20.0), prop) - _check(rig.queue_size() == before_generic + 1, "non-stickman contacts preserve prop-to-other event ordering") + _check(runner._run_queue.size() == before_generic + 1, "non-stickman contacts preserve prop-to-other event ordering") var invalid_proxy := Node2D.new() invalid_proxy.set_meta("stickman_rig", rig) stage._world.add_child(invalid_proxy) - var before_invalid := rig.queue_size() + var before_invalid: int = runner._run_queue.size() stage._on_prop_collided(invalid_proxy, Vector2.RIGHT, Vector2.ZERO, prop) - _check(rig.queue_size() == before_invalid, "incomplete stickman metadata dispatches no generic rule") + _check(runner._run_queue.size() == before_invalid, "incomplete stickman metadata dispatches no generic rule") var ragdoll_proxy := Node2D.new() ragdoll_proxy.set_meta("stickman_ragdoll_body", true) @@ -319,18 +325,19 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void: func _test_ragdoll_transitions(rig: StickmanRig) -> void: + var locomotion: StickmanLocomotion = rig.get_locomotion() rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0) - _check(not rig._impact_reactions.is_empty(), "animated rig accepts a reaction before ragdoll") + _check(not locomotion.impact_reactions.is_empty(), "animated rig accepts a reaction before ragdoll") rig.set_ragdoll(true) _check(rig.state == StickmanRig.RigState.RAGDOLL, "rig enters ragdoll") _check(_all_proxy_layers_equal(rig, 0), "ragdoll disables animated collision proxies") - _check(rig._impact_reactions.is_empty(), "ragdoll entry clears reactions") + _check(locomotion.impact_reactions.is_empty(), "ragdoll entry clears reactions") rig.request_recovery() _check(rig.state == StickmanRig.RigState.RECOVERING, "recovery request starts the stand-up state") _check(_all_proxy_layers_equal(rig, 1), "collision proxies stay enabled throughout recovery") rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0) - _check(rig._impact_reactions.is_empty(), "recovering rig ignores visual reactions") + _check(locomotion.impact_reactions.is_empty(), "recovering rig ignores visual reactions") rig.set_ragdoll(true) _check(rig.state == StickmanRig.RigState.RAGDOLL, "recovery can be interrupted by ragdoll") @@ -341,7 +348,7 @@ func _test_ragdoll_transitions(rig: StickmanRig) -> void: func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool: - for body: AnimatableBody2D in rig._collision_bodies.values(): + for body: AnimatableBody2D in rig.get_locomotion().collision_bodies.values(): if body.collision_layer != expected or body.collision_mask != expected: return false return true @@ -349,8 +356,9 @@ func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool: func _proxy_shape_sizes(rig: StickmanRig) -> Dictionary: var sizes := {} - for part_name: String in rig._collision_bodies: - var body := rig._collision_bodies[part_name] as AnimatableBody2D + var locomotion: StickmanLocomotion = rig.get_locomotion() + for part_name: String in locomotion.collision_bodies: + var body := locomotion.collision_bodies[part_name] as AnimatableBody2D var shape_node := body.get_node(NodePath("CollisionShape2D")) as CollisionShape2D if shape_node.shape is CircleShape2D: sizes[part_name] = (shape_node.shape as CircleShape2D).radius diff --git a/tests/test_phase4_final_bugs.gd b/tests/test_phase4_final_bugs.gd index 986916e..4a65388 100644 --- a/tests/test_phase4_final_bugs.gd +++ b/tests/test_phase4_final_bugs.gd @@ -161,23 +161,24 @@ func _test_reactive_runtime_lane() -> void: finished.append({ "action": action, "index": index }) ) rig.start_queue() - rig._update_runner(0.0) - _check(rig._action_phase == rig.ActionPhase.WAITING, "authored primary wait starts") + var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner + runner.tick(0.0, int(rig.state), rig.is_ragdoll_at_rest()) + _check(runner._action_phase == runner.ActionPhase.WAITING, "authored primary wait starts") rig.enqueue_reactive([{ "type": "speak", "text": "One", "duration": 5.0 }]) - _check(rig._action_phase == rig.ActionPhase.WAITING, "overlay speak leaves primary wait running") - _check(String(rig._overlay_speech.get("text", "")) == "One", "overlay speak occupies the single overlay slot") + _check(runner._action_phase == runner.ActionPhase.WAITING, "overlay speak leaves primary wait running") + _check(String(runner._overlay_speech.get("text", "")) == "One", "overlay speak occupies the single overlay slot") rig.enqueue_reactive([{ "type": "speak", "text": "Two", "duration": 5.0 }]) - _check(String(rig._overlay_speech.get("text", "")) == "Two", "new overlay speak replaces the previous overlay") + _check(String(runner._overlay_speech.get("text", "")) == "Two", "new overlay speak replaces the previous overlay") _check(finished.is_empty(), "overlay replacement emits no primary completion") rig.enqueue_reactive([{ "type": "wait", "duration": 1.0 }]) - _check(rig._action_phase == rig.ActionPhase.NONE, "sequential reactive action cancels the active primary") + _check(runner._action_phase == runner.ActionPhase.NONE, "sequential reactive action cancels the active primary") _check(finished.is_empty(), "sequential interruption emits no false action_finished") - _check(rig._run_queue.size() == 2, "sequential interrupt retains only injected plus authored future") - _check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "wait" - and bool(rig._run_queue[0].get("reactive", false)), "injected sequential action leads the runtime lane") - _check(int(rig._run_queue[1].get("queue_index", -1)) == 1, + _check(runner._run_queue.size() == 2, "sequential interrupt retains only injected plus authored future") + _check(String((runner._run_queue[0]["action"] as Dictionary).get("type", "")) == "wait" + and bool(runner._run_queue[0].get("reactive", false)), "injected sequential action leads the runtime lane") + _check(int(runner._run_queue[1].get("queue_index", -1)) == 1, "canceled authored action is abandoned and the next authored item is retained") # A takeover must discard the canceled/current item and any pending reactive @@ -188,17 +189,17 @@ func _test_reactive_runtime_lane() -> void: { "type": "wait", "duration": 20.0 }, ] rig.start_queue() - rig._update_runner(0.0) + runner.tick(0.0, int(rig.state), rig.is_ragdoll_at_rest()) rig.enqueue_reactive([ { "type": "wait", "duration": 1.0 }, { "type": "ragdoll" }, ]) - _check(rig._run_queue.size() == 2, "takeover retains itself plus authored future only") - _check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "ragdoll", + _check(runner._run_queue.size() == 2, "takeover retains itself plus authored future only") + _check(String((runner._run_queue[0]["action"] as Dictionary).get("type", "")) == "ragdoll", "takeover supersedes earlier same-batch primary work") - _check(int(rig._run_queue[1].get("queue_index", -1)) == 1, + _check(int(runner._run_queue[1].get("queue_index", -1)) == 1, "takeover does not replay the completed or canceled authored item") - _check(rig._overlay_speech.is_empty(), "takeover cancels the active overlay") + _check(runner._overlay_speech.is_empty(), "takeover cancels the active overlay") _check(rig.action_queue.size() == 2, "reactive work never mutates the authored queue") rig.free() @@ -231,7 +232,8 @@ func _test_spawn_pose_survives_first_solve() -> void: "spawn solve preserves ghost joint position: %s (delta %.3f)" % [path, position_delta]) _check(rotation_delta <= 0.02, "spawn solve preserves ghost bone rotation: %s (delta %.4f)" % [path, rotation_delta]) - _check(rig._spawn_pose.size() == rig.IK_TARGET_PATHS.size(), "factory captures all six spawn-pose markers") + var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll + _check(ragdoll._spawn_pose.size() == StickmanRagdoll.IK_TARGET_PATHS.size(), "factory captures all six spawn-pose markers") rig.free() @@ -240,7 +242,8 @@ func _test_left_walk_finish_pose() -> void: root.add_child(rig) await process_frame rig.set_facing_profile(rig.FacingProfile.LEFT) - rig._walking = true + var locomotion: StickmanLocomotion = rig.get_locomotion() + locomotion._walking = true rig._set_limb_mods_enabled(true) var stopped_frame: Dictionary = {} for joint: String in rig._limb_bones: @@ -254,7 +257,7 @@ func _test_left_walk_finish_pose() -> void: var hand_marker := rig.get_node("IK_Targets/Left_Hand") as Marker2D var stopped_marker_position := hand_marker.position + Vector2(17.0, -23.0) hand_marker.position = stopped_marker_position - rig._finish_walk("regression") + locomotion._finish_walk() _check(hand_marker.position.is_equal_approx(stopped_marker_position), "walk finish preserves the current animation marker frame") for joint: String in stopped_frame: @@ -270,9 +273,9 @@ func _test_left_walk_finish_pose() -> void: var live_upper := left_arm["upper"] as Bone2D var live_rotation: float = live_upper.rotation + 0.33 live_upper.rotation = live_rotation - rig._walking = true + locomotion._walking = true rig._set_limb_mods_enabled(true) - rig._cancel_walking(false) + locomotion.cancel_walk(false) _check(absf(angle_difference(live_upper.rotation, live_rotation)) < 0.001, "ragdoll-style walk cancellation preserves the current solved limb pose") _check((rig._bend_modifications["LeftArm"] as SkeletonModification2DTwoBoneIK).enabled, @@ -286,21 +289,22 @@ func _test_stop_clears_head_reaction() -> void: await process_frame var rig := stage._spawner.spawn("stickman", Vector2.ZERO) as StickmanRig stage._finalize_placed_node(rig) - var head := rig._reaction_bones.get("Head") as Bone2D + var locomotion: StickmanLocomotion = rig.get_locomotion() + var head := locomotion.reaction_bones.get("Head") as Bone2D var base_rotation: float = head.rotation rig.apply_collision_reaction("head", Vector2.DOWN, 500.0) - rig._apply_impact_reactions(0.01) - _check(not rig._impact_reactions.is_empty(), "head collision starts a flinch reaction") + locomotion.apply_impact_reactions(0.01) + _check(not locomotion.impact_reactions.is_empty(), "head collision starts a flinch reaction") stage.set_mode(stage.StageMode.PLAY) stage.set_mode(stage.StageMode.EDIT) - _check(rig._impact_reactions.is_empty(), "STOP clears the active head reaction") + _check(locomotion.impact_reactions.is_empty(), "STOP clears the active head reaction") _check(absf(angle_difference(head.rotation, base_rotation)) < 0.001, "STOP restores the head without a recovery tween") var stale_prop := PropBlock.new() var stale_proxy := AnimatableBody2D.new() stale_proxy.set_meta("stickman_rig", rig) stale_proxy.set_meta("part_name", "head") stage._on_prop_collided(stale_proxy, Vector2.DOWN, Vector2.ZERO, stale_prop) - _check(rig._impact_reactions.is_empty(), "post-STOP collision callback cannot recreate the flinch") + _check(locomotion.impact_reactions.is_empty(), "post-STOP collision callback cannot recreate the flinch") stale_prop.free() stale_proxy.free() stage.free() @@ -310,16 +314,18 @@ func _test_stop_clears_head_reaction() -> void: func _test_source_contracts() -> void: var stage_source: String = FileAccess.get_file_as_string("res://scripts/sandbox_stage.gd") var rig_source: String = FileAccess.get_file_as_string("res://scripts/stickman_rig.gd") + var ragdoll_source: String = FileAccess.get_file_as_string("res://scripts/stickman_ragdoll.gd") _check(stage_source.contains("TERRAIN_DRAG_THRESHOLD_PX"), "terrain uses a drag threshold") _check(stage_source.contains("PhysicsServer2D.body_set_state"), "frozen prop commits synchronize physics") _check(stage_source.contains("transform_started.connect"), "gizmo start cancels deferred restoration") _check(stage_source.contains("if current_mode != StageMode.PLAY:"), "collision callbacks are gated to PLAY mode") _check(stage_source.contains("_speak_stop_current_check") and stage_source.contains("\"stop_current\": _speak_stop_current_check.button_pressed"), "legacy rule speak persists stop-current") - _check(rig_source.contains("_primary_speech") and rig_source.contains("_overlay_speech"), "rig separates primary and overlay speech") + var runner_source: String = FileAccess.get_file_as_string("res://scripts/stickman_action_runner.gd") + _check(runner_source.contains("_primary_speech") and runner_source.contains("_overlay_speech"), "runner separates primary and overlay speech") _check(rig_source.contains("_sync_speech_bubble_mirror"), "rig counter-mirrors LEFT speech") _check(rig_source.contains("initialize_spawn_pose_from_skeleton"), "rig captures factory spawn pose") - _check(rig_source.contains("bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))"), "spawn endpoints include Bone2D bone_angle") - _check(rig_source.contains("_stand_up_generation") and rig_source.contains("_on_stand_up_finished.bind(generation)"), "stale recovery tween callbacks are generation-guarded") + _check(ragdoll_source.contains("bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))"), "spawn endpoints include Bone2D bone_angle") + _check(ragdoll_source.contains("_stand_up_generation") and ragdoll_source.contains("_on_stand_up_finished.bind(generation)"), "stale recovery tween callbacks are generation-guarded") func _check(condition: bool, label: String) -> void: diff --git a/tests/test_phase4b1_fixes.gd b/tests/test_phase4b1_fixes.gd index 08c6265..b9be65e 100644 --- a/tests/test_phase4b1_fixes.gd +++ b/tests/test_phase4b1_fixes.gd @@ -366,7 +366,7 @@ func _test_bug3_walk_termination() -> void: print("--- Bug 3: nav-mode termination + off-mesh direct still works ---") # On-mesh waypoint near the slab's right edge (the former oscillation zone): # the rig must terminate exactly once through the nav branch and stay put. - await _walk_case("on-mesh (near slab edge)", Vector2(88.0, -8.0), "nav") + await _walk_case("on-mesh (near slab edge)", Vector2(88.0, 0.0), "nav") # Off-mesh waypoint in open space: direct mode must still reach arrival. await _walk_case("off-mesh (open space)", Vector2(250.0, 0.0), "direct") @@ -388,24 +388,25 @@ func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> v _arrived_count = 0 rig.arrived.connect(_on_arrived) rig.walk_to(target_feet, 200.0) - _check(not rig._walk_done, "%s: walk not done immediately" % label) + var locomotion: StickmanLocomotion = rig.get_locomotion() + _check(locomotion != null and not locomotion._walk_done, "%s: walk not done immediately" % label) var frames := 0 var mode_flipped := false - while not rig._walk_done and frames < MAX_WALK_FRAMES: - if rig._walk_mode_latched and rig._walk_mode != expected_mode: + while not locomotion._walk_done and frames < MAX_WALK_FRAMES: + if locomotion._walk_mode_latched and locomotion._walk_mode != expected_mode: mode_flipped = true await physics_frame frames += 1 - _check(rig._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_WALK_FRAMES, frames]) + _check(locomotion._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_WALK_FRAMES, frames]) _check(_arrived_count == 1, "%s: exactly one arrived emission (got %d)" % [label, _arrived_count]) - _check(rig._walk_mode_latched, "%s: steering mode latched" % label) - _check(rig._walk_mode == expected_mode, - "%s: latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode]) + _check(locomotion._walk_mode_latched, "%s: steering mode latched" % label) + _check(locomotion._walk_mode == expected_mode, + "%s: latched to '%s' (got '%s')" % [label, expected_mode, locomotion._walk_mode]) _check(not mode_flipped, "%s: latched mode never flipped mid-walk" % label) # Position snapped to the exact final root target (feet + FOOT_OFFSET). - var final_root: Vector2 = target_feet + RIG.FOOT_OFFSET + var final_root: Vector2 = target_feet + StickmanLocomotion.FOOT_OFFSET _check(rig.global_position.distance_to(final_root) <= 0.01, "%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)]) _check(not rig.is_walking(), "%s: is_walking false after arrival" % label) diff --git a/tests/test_phase4b2_fixes.gd b/tests/test_phase4b2_fixes.gd index 279dc8a..b78fbc6 100644 --- a/tests/test_phase4b2_fixes.gd +++ b/tests/test_phase4b2_fixes.gd @@ -21,7 +21,7 @@ # * After Back the rule-builder state is IDLE/empty AND _action_popup is # visible again (PopupMenu.visible IS observable headless). # -# Bug 3 (head mirror jitter): master_rig.tscn's Head LookAt modification is a +# Bug 3 (head mirror jitter): scenes/stickman_rig.tscn's Head LookAt modification is a # full-range (-180..180), non-inverted, non-local-space constraint. # * Constraint flags/band are asserted directly on the instantiated rig. # * The Head marker is settled at the walk-left pose (-100,-614), then @@ -261,7 +261,7 @@ func _test_bug3_head_lookat() -> void: "head bone / Body/Head visual / Head aim marker all resolve") # 2) Standing-still check at STAND_POSE: no frame-to-frame motion at all. - var stand_pos: Vector2 = (RIG.STAND_POSE["Head"] as Dictionary)["pos"] + var stand_pos: Vector2 = (StickmanRagdoll.STAND_POSE["Head"] as Dictionary)["pos"] head_target.position = stand_pos for i: int in WARMUP_FRAMES: await physics_frame @@ -316,7 +316,7 @@ func _measure_head(head_bone: Bone2D, head_body: Node2D, frames: int) -> Diction func _test_bug4_reactive_actions() -> void: print("") - print("--- Bug 4: enqueue_reactive tags + clear_reactive_actions restores ---") + print("--- Bug 4: reactive runtime lane never mutates authored actions ---") var stage := _new_stage() var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) _check(rig != null, "stickman rig spawns for the queue test") @@ -328,6 +328,7 @@ func _test_bug4_reactive_actions() -> void: rig.queue_action({ "type": "speak", "text": "authored-a", "duration": 2.0 }) rig.queue_action({ "type": "wait", "duration": 1.0 }) _check(rig.queue_size() == 2, "authored queue holds 2 actions") + var runner := rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner # Cycle 1: a reactive action is injected (rule firing during PLAY), which # auto-starts the runner; leaving PLAY stops the queue first, then @@ -336,13 +337,13 @@ func _test_bug4_reactive_actions() -> void: rig.queue_changed.connect(func(): qc1.append(true)) var r1: Array[Dictionary] = [{ "type": "speak", "text": "reactive-1", "duration": 1.0 }] rig.enqueue_reactive(r1) - _check(rig.queue_size() == 3, - "reactive injection grows the queue to 3 (got %d)" % rig.queue_size()) - _check(_count_reactive(rig) == 1, - "injected action is tagged reactive=true") - _check(rig.is_queue_running(), - "enqueue_reactive auto-resumes the runner from IDLE") - _check(qc1.size() == 1, "enqueue_reactive emits queue_changed") + _check(rig.queue_size() == 2, + "reactive injection leaves the authored queue at 2 (got %d)" % rig.queue_size()) + _check(runner._run_queue.size() == 2 and _runtime_reactive_count(runner) == 0 and not runner._overlay_speech.is_empty(), + "overlay speak leaves only authored items in the primary runtime lane") + _check(not rig.is_queue_running(), + "overlay speak does not start the primary queue") + _check(qc1.is_empty(), "runtime-only injection does not emit authored queue_changed") rig.stop_queue() rig.clear_reactive_actions() @@ -351,7 +352,7 @@ func _test_bug4_reactive_actions() -> void: _check(_action_types(rig) == ["speak", "wait"], "queue types after clear are exactly the authored actions (got %s)" % str(_action_types(rig))) - _check(qc1.size() == 2, "clear_reactive_actions emits queue_changed on change") + _check(qc1.is_empty(), "runtime cleanup does not emit authored queue_changed") # Cycle 2: a bigger reactive burst then stop + clear again - counts must # stay stable (no 1,2,3 accumulation across repeated Play sessions). @@ -360,10 +361,10 @@ func _test_bug4_reactive_actions() -> void: { "type": "ragdoll" }, ] rig.enqueue_reactive(r2) - _check(rig.queue_size() == 4, - "second injection grows to 4 (got %d)" % rig.queue_size()) - _check(_count_reactive(rig) == 2, - "second burst tags both actions reactive") + _check(rig.queue_size() == 2 and runner._run_queue.size() == 3, + "takeover keeps 2 authored future items behind one reactive action") + _check(bool(runner._run_queue[0].get("reactive", false)), + "takeover action leads the runtime lane as reactive") rig.stop_queue() rig.clear_reactive_actions() _check(rig.queue_size() == 2, @@ -372,25 +373,25 @@ func _test_bug4_reactive_actions() -> void: "queue types stay stable across two reactive cycles (got %s)" % str(_action_types(rig))) - # clear_reactive_actions() must be a no-op while the runner is executing. + # Runtime cleanup may run while executing, but it never mutates authored work. rig.start_queue() _check(rig.is_queue_running(), "runner is EXECUTING after start_queue") var r3: Array[Dictionary] = [{ "type": "recover" }] rig.enqueue_reactive(r3) - _check(rig.queue_size() == 3, - "reactive action can still append while the runner is executing") + _check(rig.queue_size() == 2 and bool(runner._run_queue[0].get("reactive", false)), + "reactive takeover enters runtime lane while authored queue stays unchanged") var qc3: Array = [] rig.queue_changed.connect(func(): qc3.append(true)) rig.clear_reactive_actions() - _check(rig.queue_size() == 3 and _count_reactive(rig) == 1, - "clear_reactive_actions is a no-op while EXECUTING (reactive action stays)") + _check(rig.queue_size() == 2 and _runtime_reactive_count(runner) == 0, + "clear_reactive_actions removes runtime reactive work while preserving authored actions") _check(qc3.is_empty(), - "no queue_changed emitted by the EXECUTING no-op") + "runtime cleanup emits no authored queue_changed") rig.stop_queue() _check(not rig.is_queue_running(), "runner returns to IDLE after stop_queue") rig.clear_reactive_actions() - _check(rig.queue_size() == 2 and _count_reactive(rig) == 0, - "after stop, clear drops the injected reactive action (queue 2)") + _check(rig.queue_size() == 2 and _runtime_reactive_count(runner) == 0, + "after stop, authored queue remains stable at 2") await _free_stage(stage) @@ -407,6 +408,14 @@ func _count_reactive(rig: StickmanRig) -> int: return n +func _runtime_reactive_count(runner: StickmanActionRunner) -> int: + var count := 0 + for item: Dictionary in runner._run_queue: + if bool(item.get("reactive", false)): + count += 1 + return count + + func _action_types(rig: StickmanRig) -> Array[String]: var out: Array[String] = [] for a: Dictionary in rig.get_queue(): diff --git a/tests/test_phase4b_walk.gd b/tests/test_phase4b_walk.gd index 8cb21f0..ccf049e 100644 --- a/tests/test_phase4b_walk.gd +++ b/tests/test_phase4b_walk.gd @@ -15,6 +15,7 @@ extends SceneTree const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") const RIG := preload("res://scripts/stickman_rig.gd") +const LOCOMOTION := preload("res://scripts/stickman_locomotion.gd") const SETTLE_FRAMES := 60 const MAX_FRAMES := 300 @@ -68,27 +69,33 @@ func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> v _arrived_count = 0 rig.arrived.connect(_on_arrived) rig.walk_to(target_feet, 200.0) - _check(not rig._walk_mode_latched, "%s: mode not latched before map sync" % label) + var locomotion: StickmanLocomotion = rig.get_locomotion() + _check(locomotion != null, "%s: locomotion component is available" % label) + if locomotion == null: + stage.queue_free() + await process_frame + return + _check(not locomotion._walk_mode_latched, "%s: mode not latched before map sync" % label) var frames := 0 - while not rig._walk_done and frames < MAX_FRAMES: + while not locomotion._walk_done and frames < MAX_FRAMES: await physics_frame frames += 1 - _check(rig._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_FRAMES, frames]) + _check(locomotion._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_FRAMES, frames]) _check(_arrived_count == 1, "%s: exactly one arrived signal (got %d)" % [label, _arrived_count]) - _check(rig._walk_mode_latched, "%s: mode is latched" % label) - _check(rig._walk_mode == expected_mode, - "%s: mode latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode]) + _check(locomotion._walk_mode_latched, "%s: mode is latched" % label) + _check(locomotion._walk_mode == expected_mode, + "%s: mode latched to '%s' (got '%s')" % [label, expected_mode, locomotion._walk_mode]) # Final position equals the snapped root target (feet target + FOOT_OFFSET). - var final_root: Vector2 = target_feet + RIG.FOOT_OFFSET + var final_root: Vector2 = target_feet + LOCOMOTION.FOOT_OFFSET _check(rig.global_position.distance_to(final_root) <= 0.01, "%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)]) - # Body-bob settle: Torso marker restored to STAND_POSE (0,10). + # Completion freezes the solved final animation frame; it must not be reset + # or drift while the root is at rest. var torso = rig.get_node_or_null(NodePath("IK_Targets/Torso")) + var frozen_torso_position: Vector2 = Vector2.ZERO if torso != null: - var stand_torso: Dictionary = RIG.STAND_POSE["Torso"] - var expected_pos: Vector2 = stand_torso.get("pos", Vector2(0, 10)) - _check((torso as Node2D).position.distance_to(expected_pos) <= 1.0, - "%s: Torso marker restored to standing pose (dist=%.3f)" % [label, (torso as Node2D).position.distance_to(expected_pos)]) + frozen_torso_position = (torso as Node2D).position + _check(frozen_torso_position.is_finite(), "%s: Torso final animation marker is finite" % label) else: _check(false, "%s: IK_Targets/Torso missing" % label) # Post-arrival stability for 60 physics frames. @@ -101,6 +108,9 @@ func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> v break _check(settled and rig.global_position == p0, "%s: position unchanged for %d frames after arrival" % [label, SETTLE_FRAMES]) + if torso != null: + _check((torso as Node2D).position.distance_to(frozen_torso_position) <= 0.01, + "%s: Torso final animation marker remains frozen" % label) _check(not rig.is_walking(), "%s: is_walking false after arrival" % label) stage.queue_free() @@ -114,4 +124,3 @@ func _check(condition: bool, message: String) -> void: else: _failures += 1 print("FAIL: " + message) - diff --git a/tests/test_phase4c_area.gd b/tests/test_phase4c_area.gd index 1ead346..e70c0ba 100644 --- a/tests/test_phase4c_area.gd +++ b/tests/test_phase4c_area.gd @@ -73,8 +73,10 @@ func _add_rule(stage: Node2D, source: Node2D, area: TriggerArea, target: Stickma func _clear_actions(rig: StickmanRig) -> void: - rig.action_queue.clear() rig.stop_queue() + rig.clear_queue() + var runner := rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner + runner.hide_speech() func _test_spawner_and_rule_contract() -> void: @@ -102,31 +104,35 @@ func _test_source_specific_edges() -> void: var world_area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs() _check(not world_area_rect.has_point(stage._movable_point(source)), "regression setup keeps feet outside the leg-level area") stage._update_area_entry() - _check(target.action_queue.size() == 1, "source crossing queues exactly one consequence") - var action: Dictionary = target.action_queue[0] - _check(action.get("type", "") == "speak" and action.get("text", "") == "source only" and bool(action.get("reactive", false)), "speak consequence is normalized as a reactive action") + var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner + _check(not runner._overlay_speech.is_empty(), "source crossing starts one overlay consequence") + _check(String(runner._overlay_speech.get("text", "")) == "source only", "speak consequence is normalized into overlay speech") # SceneTree.physics_frame is emitted before node _physics_process callbacks; # wait through the following tick so the runner can begin the action. await physics_frame await physics_frame - _check(target._speech_active and target._speech_bubble != null and target._speech_bubble.visible, "queued consequence executes as a visible speech bubble") + _check(runner._speech_bubble != null and runner._speech_bubble.visible, "queued consequence executes as a visible speech bubble") + runner._overlay_speech["time_left"] = 0.5 stage._update_area_entry() - _check(target.action_queue.size() == 1, "polling while inside does not repeat") + _check(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 0.5), "polling while inside does not repeat") _move_rig_body_center(source, Vector2(-1500.0, 0.0)) stage._update_area_entry() + # The swept-bounds detector deliberately retains the previous pose for one + # poll, so a second far-away sample commits the exit edge. + stage._update_area_entry() _move_rig_body_center(source, area.global_position) stage._update_area_entry() - _check(target.action_queue.size() == 2, "exit then re-entry queues one additional consequence") + _check(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 1.0), "exit then re-entry restarts the consequence") _clear_actions(target) _move_rig_body_center(other, area.global_position) stage._update_area_entry() - _check(target.action_queue.is_empty(), "different stickman cannot trigger source-specific rule") + _check(runner._overlay_speech.is_empty(), "different stickman cannot trigger source-specific rule") var prop := stage._spawner.spawn("prop", Vector2(-1500.0, 0.0)) as PropBlock stage._finalize_placed_node(prop) prop.freeze = false prop.global_position = Vector2.ZERO stage._update_area_entry() - _check(target.action_queue.is_empty(), "unfrozen prop cannot trigger source-specific rule") + _check(runner._overlay_speech.is_empty(), "unfrozen prop cannot trigger source-specific rule") await _free_stage(stage) @@ -141,16 +147,18 @@ func _test_wildcard_and_pair_independence() -> void: stage._update_area_entry() _move_rig_body_center(source_a, area_a.global_position) stage._update_area_entry() + var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner + runner._overlay_speech["time_left"] = 0.25 _move_rig_body_center(source_a, Vector2(-1500.0, 0.0)) stage._update_area_entry() _move_rig_body_center(source_b, area_a.global_position) stage._update_area_entry() - _check(target.action_queue.size() == 2, "wildcard source fires independently for multiple movables") + _check(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 1.0), "wildcard source fires independently for multiple movables") _clear_actions(target) _add_rule(stage, source_b, area_b, target, "second pair") _move_rig_body_center(source_b, area_b.global_position) stage._update_area_entry() - _check(target.action_queue.size() == 1, "second area/source pair has independent edge state") + _check(String(runner._overlay_speech.get("text", "")) == "second pair", "second area/source pair has independent edge state") await _free_stage(stage) @@ -163,15 +171,16 @@ func _test_transforms_points_and_cleanup() -> void: var source := _spawn_rig(stage, Vector2(-300.0, 0.0)) var target := _spawn_rig(stage, Vector2(2000.0, 0.0)) _add_rule(stage, source, area, target, "transformed") - _check(stage._movable_point(source) == source.global_position - STICKMAN_RIG.FOOT_OFFSET, "stickman overlap point is feet, not root") + _check(stage._movable_point(source) == source.global_position - StickmanLocomotion.FOOT_OFFSET, "stickman overlap point is feet, not root") _move_rig_body_center(source, area.global_position) stage._update_area_entry() - _check(target.action_queue.size() == 1, "current transformed area overlaps visible stickman geometry") + var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner + _check(String(runner._overlay_speech.get("text", "")) == "transformed", "current transformed area overlaps visible stickman geometry") _clear_actions(target) var no_rule_area := _spawn_area(stage, Vector2(-500.0, 0.0)) - source.global_position = no_rule_area.global_position + STICKMAN_RIG.FOOT_OFFSET + source.global_position = no_rule_area.global_position + StickmanLocomotion.FOOT_OFFSET stage._update_area_entry() - _check(target.action_queue.is_empty(), "area with no matching rule is a no-op") + _check(runner._overlay_speech.is_empty(), "area with no matching rule is a no-op") var stale_rule := _add_rule(stage, source, area, target, "stale") var stale_id := target.get_instance_id() target.free() diff --git a/tests/test_phase6a_airborne_landing.gd b/tests/test_phase6a_airborne_landing.gd index 6dabf61..ccfa9b9 100644 --- a/tests/test_phase6a_airborne_landing.gd +++ b/tests/test_phase6a_airborne_landing.gd @@ -3,8 +3,9 @@ extends SceneTree ## Focused deterministic regression checks for the Phase 6a terrain controller. ## Runtime terrain/ramp integration remains covered by the existing stage suites. -const RIG_SCENE := preload("res://master_rig.tscn") +const RIG_SCENE := preload("res://scenes/stickman_rig.tscn") const RIG := preload("res://scripts/stickman_rig.gd") +const LOC := preload("res://scripts/stickman_locomotion.gd") var _checks: int = 0 var _failures: int = 0 @@ -35,59 +36,62 @@ func _run() -> void: func _test_air_pose_and_momentum(rig: StickmanRig) -> void: + var locomotion: StickmanLocomotion = rig.get_locomotion() var left_hand := rig._get_ik_marker("Left_Hand") var right_hand := rig._get_ik_marker("Right_Hand") var left_base := left_hand.position var right_base := right_hand.position - rig._enter_airborne(300.0) - _check(rig._terrain_motion == RIG.TerrainMotion.AIRBORNE, "enter_airborne selects AIRBORNE") - _check(is_equal_approx(rig._terrain_air_velocity.x, 300.0), "takeoff keeps signed horizontal velocity") - rig._terrain_air_velocity.y = RIG.AIR_POSE_FULL_SPEED - rig._air_time = 0.0 - rig._apply_air_pose(1.0) - _check(rig._air_pose_weight > 0.99 and rig._air_pose_weight <= 1.0, "air pose weight clamps at full fall speed") + locomotion._enter_airborne(300.0) + _check(locomotion.terrain_motion == LOC.TerrainMotion.AIRBORNE, "enter_airborne selects AIRBORNE") + _check(is_equal_approx(locomotion.terrain_air_velocity.x, 300.0), "takeoff keeps signed horizontal velocity") + locomotion.terrain_air_velocity.y = LOC.AIR_POSE_FULL_SPEED + locomotion._air_time = 0.0 + locomotion._apply_air_pose(1.0) + _check(locomotion.air_pose_weight > 0.99 and locomotion.air_pose_weight <= 1.0, "air pose weight clamps at full fall speed") _check(left_hand.position != left_base and right_hand.position != right_base, "air pose offsets both hands") - rig._restore_air_pose() + locomotion._restore_air_pose() _check(left_hand.position.is_equal_approx(left_base) and right_hand.position.is_equal_approx(right_base), "air pose restores exact captured markers") - rig._terrain_motion = RIG.TerrainMotion.GROUNDED - rig._enter_airborne(0.0) - var forward_left_base: Vector2 = rig._air_pose_base["Left_Hand"] - var forward_right_base: Vector2 = rig._air_pose_base["Right_Hand"] - rig._terrain_air_velocity.y = RIG.AIR_HAND_FORWARD_START_SPEED + 20.0 - rig._air_time = 0.0 - rig._apply_air_pose(1.0) + locomotion.terrain_motion = LOC.TerrainMotion.GROUNDED + locomotion._enter_airborne(0.0) + var forward_left_base: Vector2 = locomotion.air_pose_base["Left_Hand"] + var forward_right_base: Vector2 = locomotion.air_pose_base["Right_Hand"] + locomotion.terrain_air_velocity.y = LOC.AIR_HAND_FORWARD_START_SPEED + 20.0 + locomotion._air_time = 0.0 + locomotion._apply_air_pose(1.0) _check(left_hand.position.x > forward_left_base.x and right_hand.position.x > forward_right_base.x, "hands reach forward before lift engages") _check(is_equal_approx(left_hand.position.y, forward_left_base.y) and is_equal_approx(right_hand.position.y, forward_right_base.y), "early forward reach does not lift hands") - rig._terrain_air_velocity.y = RIG.AIR_POSE_FULL_SPEED - rig._apply_air_pose(1.0) + locomotion.terrain_air_velocity.y = LOC.AIR_POSE_FULL_SPEED + locomotion._apply_air_pose(1.0) _check(left_hand.position.y < forward_left_base.y and right_hand.position.y < forward_right_base.y, "hands lift after forward reach") - rig._restore_air_pose() - rig._terrain_air_velocity = Vector2(300.0, 0.0) - rig._terrain_air_velocity.x *= exp(-RIG.AIR_DRAG_X * 0.1) - _check(rig._terrain_air_velocity.x > 0.0 and rig._terrain_air_velocity.x < 300.0, "air drag preserves forward momentum while decaying it") + locomotion._restore_air_pose() + locomotion.terrain_air_velocity = Vector2(300.0, 0.0) + locomotion.terrain_air_velocity.x *= exp(-LOC.AIR_DRAG_X * 0.1) + _check(locomotion.terrain_air_velocity.x > 0.0 and locomotion.terrain_air_velocity.x < 300.0, "air drag preserves forward momentum while decaying it") func _test_grounded_reset_and_ragdoll_cleanup(rig: StickmanRig) -> void: - rig._terrain_following_enabled = true - rig._terrain_motion = RIG.TerrainMotion.GROUNDED - rig._last_grounded_walk_velocity_x = 999.0 + var locomotion: StickmanLocomotion = rig.get_locomotion() + locomotion.terrain_following_enabled = true + locomotion.terrain_motion = LOC.TerrainMotion.GROUNDED + locomotion._last_grounded_walk_velocity_x = 999.0 rig._physics_process(0.0) - _check(is_zero_approx(rig._last_grounded_walk_velocity_x), "grounded idle frame clears stale walk velocity") - rig._enter_airborne(-240.0) - rig._terrain_air_velocity.y = 640.0 - rig._apply_air_pose(0.1) + _check(is_zero_approx(locomotion._last_grounded_walk_velocity_x), "grounded idle frame clears stale walk velocity") + locomotion._enter_airborne(-240.0) + locomotion.terrain_air_velocity.y = 640.0 + locomotion._apply_air_pose(0.1) rig.set_ragdoll(true) _check(rig.state == RIG.RigState.RAGDOLL, "direct ragdoll enters from ordinary airborne motion") - _check(rig._terrain_motion == RIG.TerrainMotion.DISABLED and rig._air_pose_base.is_empty(), "direct ragdoll clears airborne terrain ownership") - _check(rig._terrain_air_velocity.is_zero_approx() and is_zero_approx(rig._air_pose_weight), "direct ragdoll clears air velocity and pose weight") + _check(locomotion.terrain_motion == LOC.TerrainMotion.DISABLED and locomotion.air_pose_base.is_empty(), "direct ragdoll clears airborne terrain ownership") + _check(locomotion.terrain_air_velocity.is_zero_approx() and is_zero_approx(locomotion.air_pose_weight), "direct ragdoll clears air velocity and pose weight") rig.snap_to_standing() func _test_landing_timeline_and_barrier(rig: StickmanRig) -> void: + var locomotion: StickmanLocomotion = rig.get_locomotion() var torso := rig._get_ik_marker("Torso") var left_leg := rig._get_ik_marker("Left_Leg") var right_leg := rig._get_ik_marker("Right_Leg") - rig._enter_airborne(0.0) + locomotion._enter_airborne(0.0) var base_torso := torso.position var left_hand := rig._get_ik_marker("Left_Hand") var right_hand := rig._get_ik_marker("Right_Hand") @@ -97,25 +101,25 @@ func _test_landing_timeline_and_barrier(rig: StickmanRig) -> void: "Left_Leg": {"surface": left_leg.global_position + Vector2(0.0, 1200.0), "surface_valid": true}, "Right_Leg": {"surface": right_leg.global_position + Vector2(0.0, 1300.0), "surface_valid": true}, } - rig._air_fall_distance = RIG.HARD_LANDING_MIN_FALL_HEIGHT - rig._begin_landing(0.0, {"world_pos": torso.global_position}, contacts) - _check(is_equal_approx(rig._landing_compression, RIG.LANDING_COMPRESSION_MIN), "minimum hard fall height maps to minimum compression") - _check(rig._is_action_queue_blocked(), "landing blocks primary queue") - rig._update_landing_impact(RIG.LANDING_COMPRESS_DURATION) - _check(is_equal_approx(torso.position.y, base_torso.y + RIG.LANDING_COMPRESSION_MIN), "impact reaches deepest compression at 0.08s") + locomotion.air_fall_distance = LOC.HARD_LANDING_MIN_FALL_HEIGHT + locomotion._begin_landing(0.0, {"world_pos": torso.global_position}, contacts) + _check(is_equal_approx(locomotion.landing_compression, LOC.LANDING_COMPRESSION_MIN), "minimum hard fall height maps to minimum compression") + _check(locomotion.is_action_blocked(), "landing blocks primary queue") + locomotion._update_landing_impact(LOC.LANDING_COMPRESS_DURATION) + _check(is_equal_approx(torso.position.y, base_torso.y + LOC.LANDING_COMPRESSION_MIN), "impact reaches deepest compression at 0.08s") _check(left_hand.position.y > left_hand_base.y and right_hand.position.y > right_hand_base.y, "hard landing sharply drives hands downward") _check(left_leg.global_position.y < torso.global_position.y + 500.0 and right_leg.global_position.y < torso.global_position.y + 500.0, "landing ignores stale pre-motion solved contacts") - rig._update_landing_impact(RIG.LANDING_RECOVER_DURATION) - _check(rig._terrain_motion == RIG.TerrainMotion.GROUNDED, "landing returns to GROUNDED after 0.30s") + locomotion._update_landing_impact(LOC.LANDING_RECOVER_DURATION) + _check(locomotion.terrain_motion == LOC.TerrainMotion.GROUNDED, "landing returns to GROUNDED after 0.30s") _check(torso.position.is_equal_approx(base_torso), "landing restores torso exactly") _check(left_hand.position.is_equal_approx(left_hand_base) and right_hand.position.is_equal_approx(right_hand_base), "landing restores hand bases exactly") - _check(not rig._is_action_queue_blocked(), "queue barrier clears after landing") - rig._enter_airborne(0.0) - rig._air_fall_distance = RIG.HARD_LANDING_MAX_FALL_HEIGHT - rig._begin_landing(0.0, {"world_pos": torso.global_position}, contacts) - _check(is_equal_approx(rig._landing_compression, RIG.LANDING_COMPRESSION_MAX), "maximum hard fall height maps to maximum compression") - rig._cancel_landing_impact(true) - rig._restore_air_pose() + _check(not locomotion.is_action_blocked(), "queue barrier clears after landing") + locomotion._enter_airborne(0.0) + locomotion.air_fall_distance = LOC.HARD_LANDING_MAX_FALL_HEIGHT + locomotion._begin_landing(0.0, {"world_pos": torso.global_position}, contacts) + _check(is_equal_approx(locomotion.landing_compression, LOC.LANDING_COMPRESSION_MAX), "maximum hard fall height maps to maximum compression") + locomotion._cancel_landing_impact(true) + locomotion._restore_air_pose() func _check(condition: bool, message: String) -> void: diff --git a/tests/test_phase8_rig_modularization.gd b/tests/test_phase8_rig_modularization.gd new file mode 100644 index 0000000..791d138 --- /dev/null +++ b/tests/test_phase8_rig_modularization.gd @@ -0,0 +1,74 @@ +extends SceneTree + +const RIG_SCENE_PATH := "res://scenes/stickman_rig.tscn" +const EXPECTED_UID := "uid://dr5ef3l2var2s" + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var scene_source := FileAccess.get_file_as_string(RIG_SCENE_PATH) + _check(scene_source.contains('uid="%s"' % EXPECTED_UID), "relocated scene retains its UID") + var packed := load(RIG_SCENE_PATH) as PackedScene + _check(packed != null, "relocated rig scene loads") + if packed == null: + _finish() + return + var rig := packed.instantiate() as StickmanRig + _check(rig != null, "scene root remains StickmanRig") + root.add_child(rig) + await process_frame + await physics_frame + var locomotion := rig.get_node_or_null("RigComponents/Locomotion") as StickmanLocomotion + var ragdoll := rig.get_node_or_null("RigComponents/Ragdoll") as StickmanRagdoll + var runner := rig.get_node_or_null("RigComponents/ActionRunner") as StickmanActionRunner + _check(locomotion != null, "typed Locomotion component resolves") + _check(ragdoll != null, "typed Ragdoll component resolves") + _check(runner != null, "typed ActionRunner component resolves") + _check(rig.get_locomotion() == locomotion, "root locomotion facade exposes authored component") + + var assigned: Array[Dictionary] = [{"type": "wait", "duration": 0.25}] + rig.action_queue = assigned + _check(runner.action_queue.size() == 1, "direct root action_queue assignment delegates") + _check(rig.get_queue() == assigned, "root queue query preserves compatibility") + rig.queue_action({"type": "speak", "text": "modular", "duration": 0.1}) + _check(runner.action_queue.size() == 2, "root queue mutation delegates") + + rig.set_ragdoll(true) + await physics_frame + _check(rig.state == StickmanRig.RigState.RAGDOLL, "root remains ragdoll state authority") + _check(ragdoll.get_bodies().size() == 10, "ragdoll component owns ten physics bodies") + rig.snap_to_standing() + await physics_frame + _check(rig.state == StickmanRig.RigState.ANIMATED, "snap facade returns to ANIMATED") + _check(ragdoll.get_bodies().is_empty(), "snap destroys component-owned ragdoll bodies") + + rig.queue_free() + await process_frame + await process_frame + var orphan := root.find_child("RagdollBodyContainer", true, false) + _check(orphan == null, "freeing rig leaves no ragdoll container orphan") + _finish() + + +func _check(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print("PASS: %s" % label) + else: + _failures += 1 + push_error("FAIL: %s" % label) + + +func _finish() -> void: + if _failures == 0: + print("RESULT: ALL PASSED (%d assertions)" % _checks) + quit(0) + else: + print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks]) + quit(1) diff --git a/tests/test_phase8_rig_modularization.gd.uid b/tests/test_phase8_rig_modularization.gd.uid new file mode 100644 index 0000000..471afe8 --- /dev/null +++ b/tests/test_phase8_rig_modularization.gd.uid @@ -0,0 +1 @@ +uid://c8ed0hlc7bl6r