Implement Phase 8: Rig Relocation and Component Modularization
- Moved the rig scene from `res://master_rig.tscn` to `res://scenes/stickman_rig.tscn`, preserving its UID. - Split the `StickmanRig` controller into three components: `StickmanLocomotion`, `StickmanRagdoll`, and `StickmanActionRunner`. - Updated the architecture to ensure `StickmanRig` acts as the public facade for the new components. - Created a new test suite `test_phase8_rig_modularization.gd` to verify the integrity of the relocated rig and its components. - Added checks for scene loading, component resolution, action queue delegation, and state authority.
This commit is contained in:
@@ -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]
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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", "")))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd0gqgr5oyu3g
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxyogpn8rv8xy
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bihf4kx2qgr1o
|
||||
+163
-2508
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
+22
-13
@@ -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)
|
||||
|
||||
|
||||
+24
-15
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8ed0hlc7bl6r
|
||||
Reference in New Issue
Block a user