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:
2026-09-18 14:11:53 -04:00
parent 9a22322dba
commit db4ff8d939
29 changed files with 2820 additions and 2747 deletions
+32 -23
View File
@@ -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():