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:
+88
-13
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user