From 1d2a1fb77d4fa69ff13a2ed26e151a581df0a00f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 11:45:51 +0100 Subject: [PATCH] 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 --- client/scripts/ui/debug_overlay.gd | 370 +++++++++++++++++++- client/tests/test_debug_overlay_sprint19.gd | 317 +++++++++++++++++ 2 files changed, 670 insertions(+), 17 deletions(-) create mode 100644 client/tests/test_debug_overlay_sprint19.gd diff --git a/client/scripts/ui/debug_overlay.gd b/client/scripts/ui/debug_overlay.gd index b2f6eb8cb..041f92b0a 100644 --- a/client/scripts/ui/debug_overlay.gd +++ b/client/scripts/ui/debug_overlay.gd @@ -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 diff --git a/client/tests/test_debug_overlay_sprint19.gd b/client/tests/test_debug_overlay_sprint19.gd new file mode 100644 index 000000000..b87810ada --- /dev/null +++ b/client/tests/test_debug_overlay_sprint19.gd @@ -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()