Add Phase 4 Final Bugs Specification and Tests

- Introduced a new architecture specification for Phase 4 final bugs, detailing scope, decisions, runtime data contracts, action roles, execution lanes, speech ownership, and signal flow.
- Implemented changes in `scripts/action_registry.gd`, `scripts/action_editor.gd`, `scripts/sandbox_stage.gd`, `scripts/stage_gizmos.gd`, `scripts/stickman_factory.gd`, and `scripts/stickman_rig.gd` to address identified bugs.
- Created a new test suite `tests/test_phase4_final_bugs.gd` to validate the implementation, covering action roles, grid snapping, prop manipulation, reactive runtime lane, spawn pose preservation, and more.
- Added a unique identifier for the test file to ensure proper tracking and management.
This commit is contained in:
2026-09-13 23:20:01 -04:00
parent ce6bcecd00
commit 3a1d522ec9
11 changed files with 1281 additions and 79 deletions
+261 -46
View File
@@ -8,6 +8,8 @@ extends Node2D
## Body/* draw order. Non-@tool: node resolution, flag writes and z-order
## reordering run only at runtime (_ready + setters on a live instance).
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
@@ -455,6 +457,7 @@ var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (
var _captured_landing_center: Vector2 = Vector2.ZERO # ragdoll torso's world center at capture
var _captured_ground_y: float = 0.0 # torso's ground-contact line (center.y + radius)
var _stand_up_tween: Tween = null
var _stand_up_generation: int = 0
var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } }
var _recovery_bones: Array[Dictionary] = [] # [{ "bone": Bone2D, "target": float }] bones tweened in stand-up
var _pre_ragdoll_state: Dictionary = {} # captured at _enter_ragdoll; recovery's END target
@@ -466,6 +469,8 @@ var _collision_lengths: Dictionary = {}
var _collision_visuals: Dictionary = {}
var _reaction_bones: Dictionary = {}
var _impact_reactions: Dictionary = {}
var _spawn_pose: Dictionary = {}
var _spawn_limb_rotations: Dictionary = {}
# ---------------------------------------------------------------------------
# Director / navigation state (Phase 3a)
@@ -476,6 +481,9 @@ var action_queue: Array[Dictionary] = []
var _runner_state: RunnerState = RunnerState.IDLE
var _action_phase: ActionPhase = ActionPhase.NONE
var _current_index: int = -1
var _run_queue: Array[Dictionary] = []
var _current_run_item: Dictionary = {}
var _run_cursor: int = 0
var _stop_requested: bool = false
var _nav_agent: NavigationAgent2D = null
@@ -499,6 +507,8 @@ var _ragdoll_at_rest: bool = false
var _speech_bubble = null # SpeechBubble (preloaded script)
var _speech_active: bool = false
var _speech_time_left: float = 0.0
var _primary_speech: Dictionary = {}
var _overlay_speech: Dictionary = {}
var _phase_timer: float = 0.0
@@ -737,6 +747,7 @@ func snap_to_standing() -> void:
elif state == RigState.RECOVERING:
_cancel_recovery()
_clear_impact_reactions(true)
_hide_speech()
_restore_standing_markers()
# Re-show the kinematic puppet and re-enable IK.
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
@@ -747,28 +758,93 @@ func snap_to_standing() -> void:
else:
_restore_head_lookat_state()
_recovery_bones.clear()
_rest_timer = 0.0
_stabilize_timer = 0.0
_ragdoll_at_rest = false
_captured_pose.clear()
_captured_landing_center = Vector2.ZERO
_captured_ground_y = 0.0
_pre_ragdoll_state.clear()
_probe_top_y = 0.0
_probe_bottom_y = 0.0
_probe_exclude.clear()
_set_collision_bodies_enabled(true)
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
state = RigState.ANIMATED
_apply_idle_limb_solver_mode(_spawn_limb_rotations)
if previous_state != RigState.ANIMATED:
state_changed.emit(int(state))
## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change).
func _restore_standing_markers() -> void:
for marker_name: String in STAND_POSE:
var pose: Dictionary = _spawn_pose if 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 = STAND_POSE[marker_name]
var target: Dictionary = pose[marker_name]
marker.position = target.get("pos", marker.position)
if marker_name == "Torso":
marker.rotation = target.get("rot", marker.rotation)
## Captures adapted skeleton endpoints as the initial IK marker pose. Called by
## StickmanFactory before the instance enters the tree; safe for factory bypasses.
func initialize_spawn_pose_from_skeleton() -> void:
var skeleton := get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
if skeleton == null:
push_warning("StickmanRig: cannot initialize spawn pose; missing Skeleton2D.")
return
var pose: Dictionary = {}
var torso := skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
if torso != null:
pose["Torso"] = { "pos": to_local(torso.global_position), "rot": torso.rotation }
var head := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if head != null:
var head_tip: Vector2 = head.to_global(Vector2(maxf(head.length, 1.0), 0.0).rotated(deg_to_rad(head.bone_angle)))
pose["Head"] = { "pos": to_local(head_tip), "rot": 0.0 }
var paths: Dictionary = {
"Left_Hand": "Torso/LeftUpperArm/LeftLowerArm",
"Right_Hand": "Torso/RightUpperArm/RightLowerArm",
"Left_Leg": "Torso/LeftUpperLeg/LeftLowerLeg",
"Right_Leg": "Torso/RightUpperLeg/RightLowerLeg",
}
for marker_name: String in paths:
var bone := skeleton.get_node_or_null(NodePath(String(paths[marker_name]))) as Bone2D
if bone == null:
push_warning("StickmanRig: missing spawn-pose bone '%s'." % String(paths[marker_name]))
continue
var endpoint: Vector2 = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
pose[marker_name] = { "pos": to_local(endpoint), "rot": 0.0 }
if pose.size() == IK_TARGET_PATHS.size():
# The targets must be written before the rig enters the tree; merely
# caching them would let the modification stack solve once against the
# adapter's generic targets and change the placed silhouette.
for marker_name: String in pose:
var marker := get_node_or_null(NodePath(String(IK_TARGET_PATHS[marker_name]))) as Marker2D
if marker == null:
push_warning("StickmanRig: missing spawn-pose marker '%s'." % marker_name)
return
var target: Dictionary = pose[marker_name]
marker.position = target.get("pos", marker.position)
marker.rotation = target.get("rot", marker.rotation)
_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,
}
## Applies the same velocity delta to every ragdoll body via a mass-scaled
## central impulse, preserving the ragdoll's internal structure. No-op outside
## RAGDOLL mode. Used by the physics harness "Knock Up" button.
@@ -1063,10 +1139,48 @@ func _apply_profile() -> void:
# scale. An X-mirror does not affect depth (draw order), so
# Z_ORDER_BY_PROFILE is unchanged.
scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2(1.0, 1.0)
_sync_speech_bubble_mirror()
_apply_head_lookat_mirror_mode()
_apply_idle_limb_solver_mode()
facing_profile_changed.emit(int(facing_profile))
## Godot's TwoBoneIK solver is not mirror-invariant under a negative X scale.
## While a LEFT-facing walk is active the animation must keep driving IK, but
## once the walk stops an enabled solver can settle into a pose that never
## existed in the clip. Pin the limbs to the exact frame captured at stop.
func _apply_idle_limb_solver_mode(rotations: Dictionary = {}) -> void:
var pin_mirrored_idle := facing_profile == FacingProfile.LEFT \
and state == RigState.ANIMATED and not _walking
var source: Dictionary = rotations.duplicate(true) if not rotations.is_empty() else _capture_limb_rotations()
_set_limb_mods_enabled(not pin_mirrored_idle)
if not pin_mirrored_idle:
return
for joint: String in source:
var bones: Dictionary = _limb_bones.get(joint, {})
var saved: Dictionary = source[joint]
var upper := bones.get("upper") as Bone2D
var lower := bones.get("lower") as Bone2D
if upper != null and is_instance_valid(upper):
upper.rotation = float(saved.get("upper", upper.rotation))
if lower != null and is_instance_valid(lower):
lower.rotation = float(saved.get("lower", lower.rotation))
func _capture_limb_rotations() -> Dictionary:
var rotations: Dictionary = {}
for joint: String in _limb_bones:
var bones: Dictionary = _limb_bones[joint]
var upper := bones.get("upper") as Bone2D
var lower := bones.get("lower") as Bone2D
if upper != null and lower != null and is_instance_valid(upper) and is_instance_valid(lower):
rotations[joint] = {
"upper": upper.rotation,
"lower": lower.rotation,
}
return rotations
## The SkeletonModification2DLookAt that aims the Head bone is NOT
## mirror-invariant: under the whole-rig Y-axis mirror (FacingProfile.LEFT) the
## root's negative X scale reflects the head-bone frame, and the LookAt writes a
@@ -1195,7 +1309,9 @@ func _enter_ragdoll() -> void:
_capture_pre_ragdoll_state()
_impact_reactions.clear()
_set_collision_bodies_enabled(false)
_cancel_walking()
# Preserve the live solved pose for the instant ragdoll handoff. A normal
# queue cancellation restores the idle markers/limbs, which would pop here.
_cancel_walking(false)
_ragdoll_at_rest = false
# Instant handoff: stop the player without resetting it (keep_state) and
# build the ragdoll from the CURRENT solved bone positions while the IK
@@ -1335,6 +1451,7 @@ func _reanchor_root_to_landing() -> void:
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
## clean slate.
func _cancel_recovery() -> void:
_stand_up_generation += 1
if _stand_up_tween != null and _stand_up_tween.is_valid():
_stand_up_tween.kill()
_stand_up_tween = null
@@ -1700,6 +1817,8 @@ func _get_ik_marker(name: String) -> Marker2D:
## stored in `_recovery_bones` by `_snap_skeleton_to_pose()`; this function only
## starts the marker tween and appends one bone-rotation tween per entry.
func _play_stand_up() -> void:
_stand_up_generation += 1
var generation: int = _stand_up_generation
var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE)
_stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION)
if _stand_up_tween != null:
@@ -1708,7 +1827,7 @@ func _play_stand_up() -> void:
if bone == null or not is_instance_valid(bone):
continue
_stand_up_tween.tween_property(bone, "rotation", float(entry.get("target", bone.rotation)), STAND_UP_DURATION)
_stand_up_tween.finished.connect(_on_stand_up_finished)
_stand_up_tween.finished.connect(_on_stand_up_finished.bind(generation))
## Tweens the 6 IK markers from their current (captured) values to the target
@@ -1730,7 +1849,10 @@ func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
## Stand-up tween complete: settle into ANIMATED.
func _on_stand_up_finished() -> void:
func _on_stand_up_finished(generation: int) -> void:
if generation != _stand_up_generation or state != RigState.RECOVERING:
return
var recovered_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
# Restore the pre-ragdoll facing profile + per-joint bend flags so the
# re-enabled IK holds the exact pre-ragdoll pose.
set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(facing_profile))))
@@ -1748,6 +1870,7 @@ func _on_stand_up_finished() -> void:
_body_container.modulate.a = 1.0
_set_collision_bodies_enabled(true)
state = RigState.ANIMATED
_apply_idle_limb_solver_mode(recovered_limb_rotations)
state_changed.emit(int(state))
@@ -2017,6 +2140,9 @@ func walk_to(target: Vector2, speed: float = -1.0) -> void:
set_facing_profile(FacingProfile.LEFT)
elif dx > 0.5:
set_facing_profile(FacingProfile.RIGHT)
# LEFT idle pins the captured limb pose because mirrored TwoBoneIK is not
# stable. Re-enable the solvers before the walk animation starts driving.
_set_limb_mods_enabled(true)
# Always play the canonical walk_right clip. For LEFT the rig root is
# X-mirrored (whole-rig Y-axis mirror) and the same clip plays mirrored;
# walk_left is no longer used at runtime.
@@ -2041,7 +2167,7 @@ func _update_walking(delta: float) -> void:
if not _walking:
return
if state != RigState.ANIMATED:
_cancel_walking()
_cancel_walking(false)
return
# Defer all nav reads until the map has actually synchronized. An unsynced
# agent reports an empty, finished path (map iteration id == 0), which would
@@ -2123,9 +2249,8 @@ func _update_walking(delta: float) -> void:
])
## Re-asserts the standing pose one extra physics frame after a walk ends, so
## any residual walk-animation body bob (a ±12.5 px torso keyframe) is not left
## on the markers when the animation stop and marker restore race.
## Legacy deferred marker reset hook. Current walk completion deliberately
## leaves this at zero because the final animation frame must remain visible.
func _settle_walk_markers() -> void:
if _walk_settle_frames > 0:
_walk_settle_frames -= 1
@@ -2143,19 +2268,24 @@ func _finish_walk(reason: String = "") -> void:
global_position.distance_to(_walk_target_feet + FOOT_OFFSET),
map_iter,
])
# Freeze on the exact solved animation frame at arrival. LEFT cannot leave
# TwoBoneIK running under the root mirror, so capture the bone rotations
# before stopping and pin that same frame after disabling the solvers.
var final_limb_rotations: Dictionary = _capture_limb_rotations()
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_restore_standing_markers()
_walk_settle_frames = 1
_walk_done = true
_anim_player.stop(true)
_walking = false
_apply_idle_limb_solver_mode(final_limb_rotations)
_walk_settle_frames = 0
_walk_done = true
arrived.emit(_walk_target_feet)
func _cancel_walking() -> void:
func _cancel_walking(restore_idle_pose: bool = true) -> void:
_walk_dbg("cancel (state=%s)" % RigState.keys()[state])
var current_limb_rotations: Dictionary = _capture_limb_rotations()
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_anim_player.stop(true)
if _nav_agent != null and _nav_anchor != null:
_nav_agent.target_position = _nav_anchor.global_position
_walking = false
@@ -2163,6 +2293,8 @@ func _cancel_walking() -> void:
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
if restore_idle_pose:
_apply_idle_limb_solver_mode(current_limb_rotations)
# ---------------------------------------------------------------------------
@@ -2172,32 +2304,65 @@ func _cancel_walking() -> void:
## Show the speech bubble with `text` for `duration` seconds; auto-hides and
## emits speech_finished. Lazily creates the SpeechBubble child on first use.
func speak(text: String, duration: float) -> void:
_primary_speech = { "text": text, "time_left": maxf(duration, 0.0) }
_speech_active = true
_speech_time_left = maxf(duration, 0.0)
_show_visible_speech()
func _speak_overlay(text: String, duration: float) -> void:
_overlay_speech = { "text": text, "time_left": maxf(duration, 0.0) }
_show_visible_speech()
func _show_visible_speech() -> void:
if _speech_bubble == null or not is_instance_valid(_speech_bubble):
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new()
_speech_bubble.name = "SpeechBubble"
_speech_bubble.position = SPEECH_BUBBLE_OFFSET
add_child(_speech_bubble)
_speech_bubble.show_text(text)
_speech_active = true
_speech_time_left = maxf(duration, 0.0)
_sync_speech_bubble_mirror()
var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech
if not visible_speech.is_empty():
_speech_bubble.show_text(String(visible_speech.get("text", "")))
func _update_speech(delta: float) -> void:
if not _speech_active:
return
_speech_time_left -= delta
if _speech_time_left <= 0.0:
_hide_speech()
speech_finished.emit()
var visible_owner_changed := false
if not _primary_speech.is_empty():
_primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta
_speech_time_left = float(_primary_speech["time_left"])
if _speech_time_left <= 0.0:
_primary_speech.clear()
_speech_active = false
speech_finished.emit()
if not _overlay_speech.is_empty():
_overlay_speech["time_left"] = float(_overlay_speech.get("time_left", 0.0)) - delta
if float(_overlay_speech["time_left"]) <= 0.0:
_overlay_speech.clear()
visible_owner_changed = true
speech_finished.emit()
if _overlay_speech.is_empty() and _primary_speech.is_empty():
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
elif visible_owner_changed and _overlay_speech.is_empty():
_show_visible_speech()
func _hide_speech() -> void:
_speech_active = false
_speech_time_left = 0.0
_primary_speech.clear()
_overlay_speech.clear()
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
func _sync_speech_bubble_mirror() -> void:
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2.ONE
# ---------------------------------------------------------------------------
# Action queue (Phase 3a)
# ---------------------------------------------------------------------------
@@ -2239,26 +2404,52 @@ func queue_size() -> int:
func enqueue_reactive(actions: Array[Dictionary]) -> void:
if actions.is_empty():
return
if _run_queue.is_empty() and _runner_state == RunnerState.IDLE:
for i: int in action_queue.size():
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
_run_cursor = 0
var primary: Array[Dictionary] = []
for action: Dictionary in actions:
action["reactive"] = true
var start := action_queue.size()
action_queue.append_array(actions)
queue_changed.emit()
if _runner_state == RunnerState.IDLE:
# Resume at the first appended action (not at index 0), so any queued
# sequential actions are skipped over rather than replayed.
_current_index = start - 1
_runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE
_stop_requested = false
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:
_cancel_current_primary()
_overlay_speech.clear()
_hide_speech()
var kept: Array[Dictionary] = []
for item: Dictionary in _run_queue.slice(_run_cursor):
if not bool(item.get("reactive", false)):
kept.append(item)
_run_queue = kept
_run_cursor = 0
primary.clear()
primary.append(action.duplicate(true))
else:
primary.append(action.duplicate(true))
if primary.is_empty():
return
_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
## Drops rule-injected ("reactive") actions from the queue, restoring the
## authored sequential queue after a Play session. No-op while the runner is
## executing (callers invoke it on mode exit, after stop_queue()).
func clear_reactive_actions() -> void:
if _runner_state == RunnerState.EXECUTING:
return
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)):
@@ -2284,14 +2475,21 @@ func start_queue() -> void:
_runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE
_current_index = -1
_run_queue.clear()
for i: int in action_queue.size():
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
_run_cursor = 0
_current_run_item.clear()
_stop_requested = false
## Abort the current action and return to IDLE. Does not emit queue_finished.
func stop_queue() -> void:
_stop_requested = true
_cancel_walking()
_cancel_current_primary()
_hide_speech()
_run_queue.clear()
_current_run_item.clear()
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
@@ -2315,7 +2513,7 @@ func _update_runner(delta: float) -> void:
if _walk_done:
_finish_action()
ActionPhase.SPEAKING:
if not _speech_active:
if _primary_speech.is_empty():
_finish_action()
ActionPhase.WAITING:
_phase_timer -= delta
@@ -2330,14 +2528,16 @@ func _update_runner(delta: float) -> void:
func _advance_to_next_action() -> void:
_current_index += 1
if _current_index >= action_queue.size():
if _run_cursor >= _run_queue.size():
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
queue_finished.emit()
return
var action := action_queue[_current_index]
_current_run_item = _run_queue[_run_cursor]
_run_cursor += 1
var action: Dictionary = _current_run_item.get("action", {})
_current_index = int(_current_run_item.get("queue_index", -1))
if DEBUG_WALK:
print("[runner] start idx=%d type=%s" % [_current_index, String(action.get("type", ""))])
action_started.emit(action, _current_index)
@@ -2372,8 +2572,23 @@ func _begin_action(action: Dictionary) -> void:
func _finish_action() -> void:
if DEBUG_WALK and _current_index >= 0 and _current_index < action_queue.size():
print("[runner] finish idx=%d type=%s" % [_current_index, String(action_queue[_current_index].get("type", ""))])
if _current_index >= 0 and _current_index < action_queue.size():
action_finished.emit(action_queue[_current_index], _current_index)
var finished: Dictionary = _current_run_item.get("action", {})
var index: int = int(_current_run_item.get("queue_index", -1))
_action_phase = ActionPhase.NONE
_current_run_item.clear()
_current_index = -1
if not finished.is_empty():
action_finished.emit(finished, index)
func _cancel_current_primary() -> void:
_cancel_walking()
_primary_speech.clear()
_speech_active = false
_speech_time_left = 0.0
_phase_timer = 0.0
_action_phase = ActionPhase.NONE
_current_run_item.clear()
_current_index = -1
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()