feat(ui): debug visualization overlay (#348)

F3-toggled dev overlay: LOS rays, vision cone arcs, NPC path trails,
knowledge confidence tags, tick timing sparkline. Guarded by
OS.is_debug_build() for export builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 11:45:51 +01:00
co-authored by Claude Opus 4.6
parent ef135830f2
commit 1d2a1fb77d
2 changed files with 670 additions and 17 deletions
+353 -17
View File
@@ -1,5 +1,15 @@
extends Control
# #511: F3 debug overlay — real-time game state display for dev use.
## #348: F3 debug overlay — real-time visualization of game state for dev use.
## Dev-only: disabled entirely in export builds (OS.is_debug_build() = false).
##
## Panels:
## 1. Stats text (top-left): tick, pos, fps, etc.
## 2. World overlays (over game): LOS rays, vision cone, NPC paths, info tags
## 3. Tick timing graph (bottom-left): last-30-tick delta sparkline
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const HEADER_COLOR := Color("#e8c547")
const LABEL_COLOR := Color("#8890a0")
@@ -8,31 +18,141 @@ const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
const FONT_SIZE := 12
const LINE_HEIGHT := 16
const PADDING := Vector2(10, 8)
const COL_GAP := 16 # gap between left and right columns
const COL_GAP := 16
# World overlay colors
const LOS_COLOR := Color(0.27, 0.78, 0.65, 0.50)
const PLAYER_DOT_COLOR := Color(0.88, 0.77, 0.28, 0.85)
const CONE_FORWARD_COLOR := Color(0.27, 0.78, 0.65, 0.12)
const CONE_PERIPHERAL_COLOR := Color(0.20, 0.55, 0.80, 0.07)
const CONE_RING_COLOR := Color(0.27, 0.78, 0.65, 0.55)
const NPC_PATH_COLOR := Color(0.83, 0.48, 0.35, 0.75)
const NPC_DOT_COLOR := Color(0.83, 0.48, 0.35, 0.90)
const TAG_BG_COLOR := Color(0.05, 0.05, 0.10, 0.80)
const TAG_TEXT_COLOR := Color("#c8d0e0")
const GRAPH_BG_COLOR := Color(0.06, 0.06, 0.10, 0.82)
const GRAPH_LINE_COLOR := Color("#6bc9a6")
const GRAPH_WARN_COLOR := Color("#e8c547")
# Vision cone geometry (radians)
# Forward: ±60° around facing direction (120° total)
# Peripheral: ±60° to ±120° on each side (60° band each side)
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120°
const CONE_ARC_STEPS: int = 20
# NPC path history
const NPC_HISTORY_LEN: int = 12
const NPC_DOT_RADIUS: float = 3.5
const PLAYER_DOT_RADIUS: float = 5.0
# Tick timing graph
const GRAPH_W: float = 160.0
const GRAPH_H: float = 48.0
const GRAPH_MARGIN: float = 10.0
const TICK_HISTORY_LEN: int = 30
const TICK_WARN_MS: float = 120.0
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _cached_font: Font = null
var _dev_mode: bool = false
# NPC path history: entity_id (int) → Array of Vector2 (world positions)
var _npc_paths: Dictionary = {}
var _last_tick_processed: int = -1
# Tick timing ring
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
var _tick_deltas: Array = [] # ms between consecutive snapshots
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_dev_mode = OS.is_debug_build()
visible = false
_cached_font = ThemeDB.fallback_font
func _unhandled_input(event: InputEvent) -> void:
if not _dev_mode:
return
if event.is_action_pressed("debug_overlay"):
visible = not visible
if visible:
queue_redraw()
func update_from_state() -> void:
if not visible:
if not _dev_mode or not visible:
return
# Record tick arrival time for timing graph
var now_ms := Time.get_ticks_msec()
if _last_tick_processed != GameState.current_tick:
_last_tick_processed = GameState.current_tick
if _tick_times.size() > 0:
_tick_deltas.append(float(now_ms - _tick_times.back()))
if _tick_deltas.size() > TICK_HISTORY_LEN:
_tick_deltas.pop_front()
_tick_times.append(now_ms)
if _tick_times.size() > TICK_HISTORY_LEN + 1:
_tick_times.pop_front()
_update_npc_paths()
queue_redraw()
func _update_npc_paths() -> void:
var seen_ids: Dictionary = {}
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
var kind_variant: String = entity.get("kind", {}).get("variant", "")
if kind_variant != "Npc":
continue
var eid: int = entity.get("entity_id", -1)
if eid < 0:
continue
seen_ids[eid] = true
var pos := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
if not _npc_paths.has(eid):
_npc_paths[eid] = []
var path: Array = _npc_paths[eid]
if path.size() == 0 or path.back() != pos:
path.append(pos)
if path.size() > NPC_HISTORY_LEN:
path.pop_front()
# Prune entities no longer visible
for eid in _npc_paths.keys():
if not seen_ids.has(eid):
_npc_paths.erase(eid)
# ---------------------------------------------------------------------------
# Draw dispatch
# ---------------------------------------------------------------------------
func _draw() -> void:
if not visible:
return
_draw_stats_panel()
_draw_world_overlays()
_draw_tick_graph()
# ---------------------------------------------------------------------------
# Panel 1: Stats text (top-left)
# ---------------------------------------------------------------------------
func _draw_stats_panel() -> void:
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
# Build lines as [label, value, label, value] pairs (two columns)
var left_lines: Array = []
var right_lines: Array = []
@@ -71,7 +191,10 @@ func _draw() -> void:
left_lines.append(["mode", mode_str])
right_lines.append(["gauntlet", gauntlet_str])
# Measure column widths
var gid := GameState.current_game_id
left_lines.append(["game_id", gid if gid != "" else "-"])
right_lines.append(["npc_paths", str(_npc_paths.size())])
var left_label_w: float = 0.0
var left_value_w: float = 0.0
var right_label_w: float = 0.0
@@ -89,27 +212,240 @@ func _draw() -> void:
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
var box_w: float = max(header_w, content_w) + PADDING.x * 2
var line_count: int = maxi(left_lines.size(), right_lines.size())
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
# Header
var y: float = PADDING.y + FONT_SIZE
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
y += LINE_HEIGHT
# Data lines (two columns)
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
for i in range(line_count):
if i < left_lines.size():
var lbl: String = left_lines[i][0] + ": "
var val: String = left_lines[i][1]
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
if i < right_lines.size():
var lbl: String = right_lines[i][0] + ": "
var val: String = right_lines[i][1]
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
y += LINE_HEIGHT
# ---------------------------------------------------------------------------
# Panel 2: World overlays
# ---------------------------------------------------------------------------
func _draw_world_overlays() -> void:
var vp := get_viewport()
if vp == null:
return
# get_canvas_transform() applies Camera2D — valid for CanvasLayer 0 content.
# The DebugOverlay is on UILayer (layer 20) so its own draw space IS screen space.
# Using this transform converts world coords → screen pixel coords for the overlays.
var canvas_xf := vp.get_canvas_transform()
var player_screen := _w2s(GameState.player_position, canvas_xf)
_draw_vision_cone(player_screen, canvas_xf)
_draw_los_rays(player_screen, canvas_xf)
_draw_npc_paths(canvas_xf)
_draw_info_tags(canvas_xf)
# Convert world tile position → screen pixel position
func _w2s(world_pos: Vector2, canvas_xf: Transform2D) -> Vector2:
return canvas_xf * (world_pos * Constants.TILE_SIZE)
# Vision cone: filled forward sector + peripheral bands.
# Uses player facing direction and visibility sector distance.
func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
var facing_angle := _facing_to_angle(GameState.player_facing)
# Estimate visible radius from furthest visibility sector tile
var max_d: float = 4.0
for vpos in GameState.visibility_sectors.keys():
var d := Vector2(vpos.x, vpos.y).distance_to(GameState.player_position)
if d > max_d:
max_d = d
var scale_x := canvas_xf.x.length()
var r: float = clampf(max_d * Constants.TILE_SIZE * scale_x, 40.0, 280.0)
# Helper: build a polygon fan from center outward over arc [angle_from, angle_to]
var forward_from := facing_angle - CONE_FORWARD_HALF
var forward_to := facing_angle + CONE_FORWARD_HALF
var perip_l_from := facing_angle - CONE_PERIPHERAL_HALF
var perip_l_to := facing_angle - CONE_FORWARD_HALF
var perip_r_from := facing_angle + CONE_FORWARD_HALF
var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF
draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR)
draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR)
draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR)
# Forward arc boundary ring
draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0)
# Player dot
draw_circle(player_screen, PLAYER_DOT_RADIUS, PLAYER_DOT_COLOR)
# Build a filled polygon fan from center through an arc
func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array:
var pts := PackedVector2Array()
pts.append(center)
for i in range(CONE_ARC_STEPS + 1):
var t := float(i) / float(CONE_ARC_STEPS)
var a := angle_from + t * (angle_to - angle_from)
pts.append(center + Vector2(cos(a), sin(a)) * radius)
return pts
# Dashed LOS lines from player to each visible non-player entity
func _draw_los_rays(player_screen: Vector2, canvas_xf: Transform2D) -> void:
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
if entity.get("kind", {}).get("variant", "") == "Player":
continue
var entity_world := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
var entity_screen := _w2s(entity_world, canvas_xf)
var rel: String = entity.get("relationship", "Unknown")
var color := Constants.color_for_relationship(rel)
color.a = 0.45
draw_dashed_line(player_screen, entity_screen, color, 1.0, 6.0)
draw_circle(entity_screen, NPC_DOT_RADIUS, Color(color.r, color.g, color.b, 0.7))
# Fading NPC movement path trails from position history
func _draw_npc_paths(canvas_xf: Transform2D) -> void:
for eid in _npc_paths.keys():
var path: Array = _npc_paths[eid]
if path.size() < 2:
continue
for i in range(1, path.size()):
var a_screen := _w2s(path[i - 1], canvas_xf)
var b_screen := _w2s(path[i], canvas_xf)
var alpha := float(i) / float(path.size())
draw_line(a_screen, b_screen, Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR)
# Information state tags above visible NPCs from player_knowledge
func _draw_info_tags(canvas_xf: Transform2D) -> void:
if GameState.player_knowledge == null:
return
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
var knowledge: Dictionary = GameState.player_knowledge
# Build entity_id → knowledge entry lookup
var kg_by_id: Dictionary = {}
for entry in knowledge.get("entities", []):
if entry is Dictionary and entry.has("entity_id"):
kg_by_id[entry.entity_id] = entry
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
if entity.get("kind", {}).get("variant", "") != "Npc":
continue
var eid: int = entity.get("entity_id", -1)
if not kg_by_id.has(eid):
continue
var kg_entry: Dictionary = kg_by_id[eid]
var confidence: String = kg_entry.get("confidence", "Unknown")
var name_str: String = kg_entry.get("name", "?")
var label := "%s [%s]" % [name_str, confidence]
var entity_screen := _w2s(Vector2(entity.get("x", 0.0), entity.get("y", 0.0)), canvas_xf)
var tag_baseline := entity_screen.y - 18.0
var text_w := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1).x
var tag_rect := Rect2(
entity_screen.x - text_w / 2.0 - 3.0,
tag_baseline - FONT_SIZE + 2.0,
text_w + 6.0,
FONT_SIZE)
draw_rect(tag_rect, TAG_BG_COLOR)
draw_string(font,
Vector2(entity_screen.x - text_w / 2.0, tag_baseline),
label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR)
# ---------------------------------------------------------------------------
# Panel 3: Tick timing sparkline (bottom-left)
# ---------------------------------------------------------------------------
func _draw_tick_graph() -> void:
if _tick_deltas.size() < 2:
return
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
var vp_size := get_viewport_rect().size
var box_x := GRAPH_MARGIN
var label_h := LINE_HEIGHT
var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN
draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR)
draw_string(font,
Vector2(box_x + 4, box_y + FONT_SIZE + 1),
"tick ms (n=%d)" % _tick_deltas.size(),
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
var chart_top := box_y + label_h
var chart_left := box_x + 4.0
var chart_w := GRAPH_W - 8.0
var chart_h := GRAPH_H - 4.0
# Max value for scale
var max_ms: float = TICK_WARN_MS
for d in _tick_deltas:
if float(d) > max_ms:
max_ms = float(d)
max_ms *= 1.1
# Warn threshold dashed line
var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms)
draw_dashed_line(
Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y),
Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3),
1.0, 4.0)
# Sparkline
var n := _tick_deltas.size()
var prev_pt := Vector2.ZERO
for i in range(n):
var x := chart_left + chart_w * (float(i) / float(n - 1))
var clamped := clampf(float(_tick_deltas[i]), 0.0, max_ms)
var y := chart_top + chart_h * (1.0 - clamped / max_ms)
var pt := Vector2(x, y)
var color := GRAPH_WARN_COLOR if float(_tick_deltas[i]) > TICK_WARN_MS else GRAPH_LINE_COLOR
if i > 0:
draw_line(prev_pt, pt, color, 1.5)
draw_circle(pt, 2.0, color)
prev_pt = pt
# Average label
var avg_ms := 0.0
for d in _tick_deltas:
avg_ms += float(d)
avg_ms /= float(_tick_deltas.size())
draw_string(font,
Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2),
"avg %.0fms" % avg_ms,
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North)
static func _facing_to_angle(facing: String) -> float:
match facing:
"North": return -PI / 2.0
"Northeast": return -PI / 4.0
"East": return 0.0
"Southeast": return PI / 4.0
"South": return PI / 2.0
"Southwest": return PI * 3.0 / 4.0
"West": return PI
"Northwest": return -PI * 3.0 / 4.0
_: return -PI / 2.0
+317
View File
@@ -0,0 +1,317 @@
## Sprint 19 — Debug visualization overlay (#348)
## F3 toggle, world overlays, tick timing graph.
## Extends the existing debug_overlay.gd stub.
class_name TestDebugOverlaySprint19
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd"
func _make_overlay() -> Control:
## Instantiate a standalone DebugOverlay control for unit testing.
## Does not require the full main.tscn scene tree.
var script := load(DEBUG_SCRIPT_PATH)
if script == null:
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
return null
var node := Control.new()
node.set_script(script)
add_child(node)
return node
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.player_position = Vector2(10.0, 10.0)
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.current_tick = 1
GameState.player_knowledge = null
func after_test() -> void:
GameState.visible_entities = []
GameState.player_knowledge = null
# ---------------------------------------------------------------------------
# Script existence
# ---------------------------------------------------------------------------
func test_debug_overlay_script_exists() -> void:
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
"debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)"
).is_true()
# ---------------------------------------------------------------------------
# Instantiation
# ---------------------------------------------------------------------------
func test_debug_overlay_instantiates_without_crash() -> void:
var ol := _make_overlay()
if ol == null: return
assert_that(ol).is_not_null()
ol.queue_free()
func test_debug_overlay_starts_hidden() -> void:
## Overlay starts hidden — only appears when F3 pressed.
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.visible).override_failure_message(
"DebugOverlay must start hidden (visible=false)"
).is_false()
ol.queue_free()
# ---------------------------------------------------------------------------
# Dev-only guard
# ---------------------------------------------------------------------------
func test_update_from_state_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has_method("update_from_state")).override_failure_message(
"DebugOverlay must have update_from_state() method"
).is_true()
ol.queue_free()
func test_update_from_state_does_not_crash_when_hidden() -> void:
## update_from_state() called while hidden must not crash.
var ol := _make_overlay()
if ol == null: return
ol.visible = false
ol.update_from_state() # Should be a no-op, no crash
ol.queue_free()
func test_update_from_state_does_not_crash_when_visible() -> void:
var ol := _make_overlay()
if ol == null: return
ol.visible = true
# Simulate a minimal snapshot tick
GameState.current_tick = 42
ol.update_from_state()
ol.queue_free()
# ---------------------------------------------------------------------------
# NPC path tracking
# ---------------------------------------------------------------------------
func test_npc_paths_field_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("_npc_paths")).override_failure_message(
"DebugOverlay must have _npc_paths field for NPC movement history"
).is_true()
ol.queue_free()
func test_npc_paths_updated_on_state_update() -> void:
## After update_from_state with an NPC entity, _npc_paths should have an entry.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
GameState.visible_entities = [{
"entity_id": 2,
"x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"relationship": "Unknown",
}]
GameState.current_tick = 100
ol.update_from_state()
assert_int(ol._npc_paths.size()).override_failure_message(
"_npc_paths must record NPC positions from visible_entities"
).is_greater(0)
ol.queue_free()
func test_npc_paths_not_populated_for_player_entity() -> void:
## Player entities must not appear in NPC path history.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
GameState.visible_entities = [{
"entity_id": 1,
"x": 10.0, "y": 10.0, "z": 0,
"kind": {"variant": "Player", "data": null},
}]
GameState.current_tick = 101
ol.update_from_state()
assert_int(ol._npc_paths.size()).override_failure_message(
"Player entity must not appear in _npc_paths"
).is_equal(0)
ol.queue_free()
func test_npc_paths_max_length_respected() -> void:
## Path history must not grow beyond NPC_HISTORY_LEN entries.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
# Simulate NPC moving each tick — inject 20 ticks of movement
for i in range(20):
GameState.visible_entities = [{
"entity_id": 5,
"x": float(12 + i), "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"relationship": "Unknown",
}]
GameState.current_tick = 200 + i
ol.update_from_state()
var path: Array = ol._npc_paths.get(5, [])
assert_int(path.size()).override_failure_message(
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
).is_less_equal(ol.NPC_HISTORY_LEN)
ol.queue_free()
# ---------------------------------------------------------------------------
# Tick timing ring
# ---------------------------------------------------------------------------
func test_tick_deltas_field_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("_tick_deltas")).override_failure_message(
"DebugOverlay must have _tick_deltas field for timing sparkline"
).is_true()
ol.queue_free()
func test_tick_deltas_accumulate_over_state_updates() -> void:
## Each new tick snapshot should add a delta to _tick_deltas.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
for i in range(5):
GameState.current_tick = 300 + i
ol.update_from_state()
assert_int(ol._tick_deltas.size()).override_failure_message(
"_tick_deltas must accumulate entries from successive ticks"
).is_greater(0)
ol.queue_free()
func test_tick_deltas_max_length_respected() -> void:
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
for i in range(50):
GameState.current_tick = 400 + i
ol.update_from_state()
assert_int(ol._tick_deltas.size()).override_failure_message(
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
).is_less_equal(ol.TICK_HISTORY_LEN)
ol.queue_free()
# ---------------------------------------------------------------------------
# Constants defined
# ---------------------------------------------------------------------------
func test_npc_history_len_constant_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
"DebugOverlay must have NPC_HISTORY_LEN constant"
).is_true()
ol.queue_free()
func test_tick_history_len_constant_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
"DebugOverlay must have TICK_HISTORY_LEN constant"
).is_true()
ol.queue_free()
func test_tick_warn_ms_constant_defined() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
).is_true()
ol.queue_free()
# ---------------------------------------------------------------------------
# Facing angle helper
# ---------------------------------------------------------------------------
func test_facing_to_angle_north() -> void:
## North = -PI/2 in Godot 2D (up on screen)
var angle := _fetch_facing_angle("North")
assert_float(angle).override_failure_message(
"_facing_to_angle('North') must return -PI/2"
).is_equal_approx(-PI / 2.0, 0.001)
func test_facing_to_angle_east() -> void:
var angle := _fetch_facing_angle("East")
assert_float(angle).is_equal_approx(0.0, 0.001)
func test_facing_to_angle_south() -> void:
var angle := _fetch_facing_angle("South")
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
func test_facing_to_angle_west() -> void:
var angle := _fetch_facing_angle("West")
assert_float(angle).is_equal_approx(PI, 0.001)
func _fetch_facing_angle(facing: String) -> float:
## Helper: load script and call static method.
var script = load(DEBUG_SCRIPT_PATH)
if script == null:
return 0.0
# In GDScript 4, static methods can be called via an instance
var tmp := Control.new()
tmp.set_script(script)
add_child(tmp)
var result := tmp._facing_to_angle(facing)
tmp.queue_free()
return result
# ---------------------------------------------------------------------------
# In-scene placement: DebugOverlay on UILayer
# ---------------------------------------------------------------------------
func test_debug_overlay_in_main_scene_ui_layer() -> void:
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
auto_free(scene)
add_child(scene)
var ui_layer := scene.get_node_or_null("UILayer")
assert_that(ui_layer != null).override_failure_message(
"UILayer must exist in main.tscn"
).is_true()
if ui_layer == null: return
var overlay := ui_layer.get_node_or_null("DebugOverlay")
assert_that(overlay != null).override_failure_message(
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
).is_true()