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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user