extends Control ## #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") const VALUE_COLOR := Color("#c8d0e0") 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 # 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 # Position set in main.tscn: below merged HUD status panel (starts at y:16) _cached_font = ThemeDB.fallback_font # Clear NPC path history on session change to prevent entity ID collisions GameState.connect("game_id_changed", _on_game_id_changed) func _on_game_id_changed(_new_id: String) -> void: _npc_paths.clear() _tick_deltas.clear() _tick_times.clear() _last_tick_processed = -1 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 _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 var left_lines: Array = [] var right_lines: Array = [] left_lines.append(["tick", str(GameState.current_tick)]) right_lines.append(["fps", str(Engine.get_frames_per_second())]) var pos := GameState.player_position left_lines.append(["pos", "(%d, %d)" % [int(pos.x), int(pos.y)]]) right_lines.append(["facing", GameState.player_facing]) left_lines.append(["stance", GameState.player_stance]) right_lines.append( ["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"] ) left_lines.append(["entities", str(GameState.visible_entities.size())]) right_lines.append(["tiles", str(GameState.visible_tiles.size())]) left_lines.append(["interactions", str(GameState.nearby_interactions.size())]) right_lines.append(["recognitions", str(GameState.pending_recognitions.size())]) var mono_status := "active" if GameState.current_monologue != null else "idle" var dlg_status := "active" if GameState.dialogue_active else "idle" left_lines.append(["monologue", mono_status]) right_lines.append(["dialogue", dlg_status]) left_lines.append(["stationary", str(GameState.stationary_ticks)]) right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"]) var gt := GameState.game_time var time_str := ( "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-" ) var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-" left_lines.append(["time", time_str]) right_lines.append(["tick_rate", rate_str]) var mode_str := "test" if SimBridge.test_mode else "live" var gauntlet_str := "ON" if GameState.gauntlet_mode else "OFF" left_lines.append(["mode", mode_str]) right_lines.append(["gauntlet", gauntlet_str]) 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 var right_value_w: float = 0.0 for line in left_lines: left_label_w = max( left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x ) left_value_w = max( left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x ) for line in right_lines: right_label_w = max( right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x ) right_value_w = max( right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x ) var header_text := "F3 DEBUG" var header_w := ( font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x ) 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 draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR) 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 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(): 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(): 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