Add headless regression tests for Phase 4b features

- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
This commit is contained in:
2026-09-04 15:08:08 -04:00
parent d77b5e5441
commit 1f91f3d2e5
54 changed files with 7960 additions and 162 deletions
+88 -13
View File
@@ -160,6 +160,9 @@ const NAV_AGENT_LOCAL_POS := Vector2(0.0, 385.0)
const ARRIVE_DISTANCE := 8.0
const NAV_PATH_DESIRED_DISTANCE := 8.0
const NAV_TARGET_DESIRED_DISTANCE := 12.0
## How many post-sync physics frames to wait for the nav agent's reachability
## flag before deciding the walk is genuinely off-mesh (latch "direct").
const LATCH_PROBE_MAX_FRAMES := 6
## Rig-local anchor for the speech bubble, above the head.
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
@@ -335,6 +338,9 @@ var _walking: bool = false
var _walk_target_feet: Vector2 = Vector2.ZERO
var _walk_speed_current: float = 300.0
var _walk_mode: String = "nav" # "nav" (follow mesh) | "direct" (off-mesh straight line)
var _walk_mode_latched: bool = false
var _walk_latch_probe_frames: int = 0
var _walk_settle_frames: int = 0
var _walk_done: bool = false
var _ragdoll_at_rest: bool = false
@@ -412,6 +418,7 @@ func _physics_process(delta: float) -> void:
_track_momentum(delta)
_update_rest_detection(delta)
_update_walking(delta)
_settle_walk_markers()
_update_speech(delta)
_update_runner(delta)
@@ -1058,6 +1065,12 @@ func walk_to(target: Vector2, speed: float = -1.0) -> void:
_anim_player.play(anim_name)
_walking = true
_walk_done = false
# The nav/direct steering mode is latched once per walk (on the first
# post-sync frame) so it cannot flip between frames and oscillate the rig.
_walk_mode = "nav"
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
func is_walking() -> bool:
@@ -1076,29 +1089,61 @@ func _update_walking(delta: float) -> void:
if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0:
_walk_dbg("sync pending")
return
# Latch the steering mode once the map is synced, so a waypoint that sits
# near the mesh boundary can't flip nav<->direct between frames (that flip
# swaps between two vertically-offset targets and reads as up/down jitter).
# Reachability only becomes meaningful a frame or two AFTER the map syncs and
# a forced path query round-trips, so probe for up to LATCH_PROBE_MAX_FRAMES:
# latch "nav" as soon as the agent reports the target reachable; if it never
# does within the bound (genuinely off-mesh), latch "direct".
if not _walk_mode_latched:
if _walk_latch_probe_frames < LATCH_PROBE_MAX_FRAMES:
_walk_latch_probe_frames += 1
_nav_agent.get_next_path_position()
if _nav_agent.is_target_reachable():
_walk_mode_latched = true
_walk_mode = "nav"
_walk_dbg("latch mode=nav (probe %d)" % _walk_latch_probe_frames)
else:
_walk_dbg("latch probe %d (not reachable yet)" % _walk_latch_probe_frames)
return
else:
_walk_mode_latched = true
_walk_mode = "direct"
_walk_dbg("latch mode=direct (probe bound reached)")
# Ask the agent for its next waypoint FIRST. This forces the agent's internal
# path update (_update_navigation), which re-queries the map whenever the
# stored path is empty (set_target_position resets it via _request_repath).
# The read-only get_current_navigation_path() accessor alone never triggers a
# repath, so checking it directly would leave the path empty forever.
var next_feet := _nav_agent.get_next_path_position()
var final_root := _walk_target_feet + FOOT_OFFSET
var dist_to_final := global_position.distance_to(final_root)
var root_target: Vector2
if _nav_agent.is_target_reachable():
# NAV branch: the target lies on the mesh — follow the path to it.
_walk_mode = "nav"
if _nav_agent.is_navigation_finished():
_finish_walk("finished")
if _walk_mode == "nav":
# Terminate once the nav agent reports its path complete AND the rig is
# close enough: the agent finishes at target_desired_distance (12 px,
# feet-space) while the rig's hard arrival radius is 8 px, so chasing the
# last stale next-waypoint would oscillate the rig around the 16-px band.
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
global_position = final_root
_finish_walk("arrive")
return
root_target = next_feet + FOOT_OFFSET
# Near the destination, ignore the (possibly behind-path) next waypoint
# and steer straight at the final target so the rig cannot reverse.
if dist_to_final <= 2.0 * ARRIVE_DISTANCE:
root_target = final_root
else:
root_target = next_feet + FOOT_OFFSET
else:
# DIRECT branch: an off-mesh waypoint is now a normal, supported case —
# steer straight at the clicked point, ignoring the nav mesh.
if _walk_mode != "direct":
_walk_dbg("off-mesh waypoint: switching to direct steering")
_walk_mode = "direct"
root_target = _walk_target_feet + FOOT_OFFSET
# DIRECT branch: an off-mesh waypoint is a supported case — steer
# straight at the clicked point, ignoring the nav mesh.
root_target = final_root
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
if global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE:
# Unified arrival radius against the FINAL target, in both branches. Snap
# the residual offset away so the rig lands exactly on the waypoint.
if dist_to_final <= ARRIVE_DISTANCE:
global_position = final_root
_finish_walk("arrive")
return
_walk_dbg("frame=%d idx=%d mode=%s root=(%.1f, %.1f) feet=(%.1f, %.1f) target=(%.1f, %.1f) dist=%.1f finished=%s reachable=%s final=(%.1f, %.1f) pts=%d next=(%.1f, %.1f) map_iter=%d" % [
@@ -1118,6 +1163,15 @@ 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.
func _settle_walk_markers() -> void:
if _walk_settle_frames > 0:
_walk_settle_frames -= 1
_restore_standing_markers()
func _finish_walk(reason: String = "") -> void:
if DEBUG_WALK:
var map_iter := -1
@@ -1132,6 +1186,7 @@ func _finish_walk(reason: String = "") -> void:
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_restore_standing_markers()
_walk_settle_frames = 1
_walk_done = true
_walking = false
arrived.emit(_walk_target_feet)
@@ -1145,6 +1200,9 @@ func _cancel_walking() -> void:
_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
# ---------------------------------------------------------------------------
@@ -1221,6 +1279,8 @@ func queue_size() -> int:
func enqueue_reactive(actions: Array[Dictionary]) -> void:
if actions.is_empty():
return
for action: Dictionary in actions:
action["reactive"] = true
var start := action_queue.size()
action_queue.append_array(actions)
queue_changed.emit()
@@ -1233,6 +1293,21 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
_stop_requested = false
## 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 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()
# ---------------------------------------------------------------------------
# Runner state machine (Phase 3a)
# ---------------------------------------------------------------------------