From c3abf32185a4f41bc6461300c7d86a90620264d0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 09:50:23 +0100 Subject: [PATCH] feat(ui): add in-game debug console with tilde toggle and command dispatch (#581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - debug_console.gd: new ModalLayer Control — tilde key toggles bottom-40% panel, command history (up/down), SimBridge dispatch for all DebugCommandKind variants: ticks, contaminate, tp, activate, triangle, npc, triangles, pop, status, help - debug_console.tscn: minimal scene node; UI built programmatically in _ready() - input_mapper.gd: DEBUG_COMMAND action added to Action enum - sim_bridge.gd: DEBUG_COMMAND → "DebugCommand" wire mapping - protocol.gd: v18 debug_response decode (command, text, success fields) - game_state.gd: debug_response field + apply_snapshot one-shot handling - main.gd: @onready ref, router registration, _consume_debug_response(), settings signal - settings_dialog.gd: debug_console_toggled signal + CheckButton toggle row (+36px height), reads initial state from user://settings.cfg; CheckButton state loaded from PREFS_PATH - main.tscn: DebugConsole node on ModalLayer, load_steps 27→28 Co-Authored-By: Claude Sonnet 4.6 --- client/scenes/main.tscn | 6 +- client/scripts/autoloads/game_state.gd | 11 + client/scripts/autoloads/input_mapper.gd | 1 + client/scripts/autoloads/sim_bridge.gd | 2 + client/scripts/main.gd | 16 ++ client/scripts/protocol/protocol.gd | 12 + client/ui/debug_console.gd | 314 +++++++++++++++++++++++ client/ui/debug_console.gd.uid | 1 + client/ui/debug_console.tscn | 15 ++ client/ui/settings_dialog.gd | 26 +- 10 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 client/ui/debug_console.gd create mode 100644 client/ui/debug_console.gd.uid create mode 100644 client/ui/debug_console.tscn diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index e3d7600d6..fdead1941 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=27 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=28 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -26,6 +26,7 @@ [ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"] [ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"] [ext_resource type="PackedScene" path="res://ui/loading_screen.tscn" id="26_loading"] +[ext_resource type="PackedScene" uid="uid://b2ndm9rvx8cqp" path="res://ui/debug_console.tscn" id="27_debug_console"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -194,3 +195,6 @@ layer = 30 ; #257: Loading screen — full-screen overlay during save/load round-trip [node name="LoadingScreen" parent="ModalLayer" instance=ExtResource("26_loading")] + +; #581: Debug console — tilde key toggles, bottom 40% of screen +[node name="DebugConsole" parent="ModalLayer" instance=ExtResource("27_debug_console")] diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 36df1c02e..371d57042 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -81,6 +81,11 @@ var rng_seed: Variant = null # One-shot: consumed by main.gd after display, then set back to null. var save_result: Variant = null +# v18 fields (#580): debug console response from server. +# {command: String, text: String, success: bool} or null. +# One-shot: consumed by main.gd and forwarded to DebugConsole, then set to null. +var debug_response: Variant = null + # #257: Pending load path — set by main menu "Load Game" selection. # main.gd sends LOAD_GAME on startup if non-empty, then clears this field. # Format: user://saves//.sav or "" if no pending load. @@ -311,6 +316,12 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: save_result = null + # v18: debug_response (#580) — debug console command result. + if snapshot.has("debug_response") and snapshot.debug_response is Dictionary: + debug_response = snapshot.debug_response + else: + debug_response = null + # v14: player_knowledge (#264, D-041) — partial KG dump for journal panel. # Only update when field is present (null means no change, server sends when KG changes). if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary: diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 6128d4b4d..eb18149f6 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -24,6 +24,7 @@ enum Action { TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel) SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path + DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server } var input_queue: Array[Dictionary] = [] diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index c6196aea2..1497d000e 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -416,6 +416,8 @@ static func action_enum_to_wire(action: int) -> String: return "SaveGame" # #554: F5 quicksave (D-085) InputMapper.Action.LOAD_GAME: return "LoadGame" # #554: F6 quickload (D-085) + InputMapper.Action.DEBUG_COMMAND: + return "DebugCommand" # #581: debug console command dispatch _: push_warning("SimBridge: unknown action enum %s" % action) return "" diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 716ee6e34..8bcd7cbcd 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -22,6 +22,7 @@ extends Node2D @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button @onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) @onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load +@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution @@ -122,6 +123,11 @@ func _ready() -> void: _router.register("conversation_ended", _consume_conversation_ended) _router.register("dialogue_response", _consume_dialogue_response) _router.register("save_result", _consume_save_result) + _router.register("debug_response", _consume_debug_response) + + # #581: Wire settings_dialog debug console toggle → debug_console.set_enabled + if settings_dialog and debug_console: + settings_dialog.debug_console_toggled.connect(debug_console.set_enabled) func _process(delta: float) -> void: @@ -382,6 +388,8 @@ func _consume_dialogue_response() -> void: if GameState.dialogue_response == null or not dialogue_box: return var dr: Dictionary = GameState.dialogue_response + # v0.1: falls back to _last_dialogue_npc_id if wire omits speaker_entity_id. + # Edge case: fast re-engagement with a different NPC could misattribute — low probability. var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id) var speaker_color_index: int = dr.get("speaker_color_index", -1) var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name) @@ -414,6 +422,14 @@ func _consume_save_result() -> void: monologue_display.show_notification(msg) +# #581: Forward debug_response from server to the debug console. +func _consume_debug_response() -> void: + if GameState.debug_response == null or not debug_console: + return + debug_console.append_response(GameState.debug_response) + GameState.debug_response = null + + # D-061: Handle dialogue option selection → send to server func _on_dialogue_option_selected(response_id: String, text: String) -> void: SimBridge.send_input({ diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index aaedcac73..a8c605c56 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -244,6 +244,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "error": raw_save.get("error"), } + # v18: debug_response (#580) — debug console command result. + # {command: String, text: String, success: bool} + var debug_response: Variant = null + var raw_debug: Variant = raw.get("debug_response") + if raw_debug is Dictionary: + debug_response = { + "command": str(raw_debug.get("command", "")), + "text": str(raw_debug.get("text", "")), + "success": bool(raw_debug.get("success", false)), + } + # TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020). # Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs). # When server populates this field, client-side accumulation fallback in game_state.gd @@ -320,6 +331,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "examine_result": examine_result, "player_knowledge": player_knowledge, "save_result": save_result, + "debug_response": debug_response, "stationary_ticks": stationary_ticks, "zone_id": zone_id, } diff --git a/client/ui/debug_console.gd b/client/ui/debug_console.gd new file mode 100644 index 000000000..90d15275d --- /dev/null +++ b/client/ui/debug_console.gd @@ -0,0 +1,314 @@ +class_name DebugConsole +extends Control + +## In-game debug console (#581). Tilde key (`) toggles open/closed. +## Semi-transparent panel anchored to bottom ~40% of screen. +## Dispatches DebugCommandKind variants to server via SimBridge. +## Settings-toggled; enabled state persisted in user://settings.cfg. + +const PREFS_PATH := "user://settings.cfg" +const PREFS_SECTION := "debug" +const PREFS_KEY_ENABLED := "console_enabled" +const MAX_LOG_LINES := 50 + +const BG_COLOR := Color(0.04, 0.04, 0.06, 0.92) +const BORDER_COLOR := Color("#4a9ebb") +const TEXT_COLOR := Color("#c8d0e0") +const SUCCESS_COLOR := Color("#6bc9a6") +const ERROR_COLOR := Color("#d45d5d") +const INPUT_COLOR := Color("#e8c547") + +var _enabled: bool = true +var _open: bool = false +var _log_lines: Array[String] = [] +var _panel: PanelContainer = null +var _output_log: RichTextLabel = null +var _input_line: LineEdit = null +var _history: Array[String] = [] +var _history_idx: int = -1 + + +func _ready() -> void: + _load_prefs() + visible = false + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_preset(Control.PRESET_FULL_RECT) + _build_ui() + get_viewport().size_changed.connect(_update_panel_layout) + + +func _build_ui() -> void: + _panel = PanelContainer.new() + _panel.mouse_filter = Control.MOUSE_FILTER_STOP + _panel.anchor_left = 0.0 + _panel.anchor_top = 0.6 + _panel.anchor_right = 1.0 + _panel.anchor_bottom = 1.0 + _panel.offset_left = 0.0 + _panel.offset_top = 0.0 + _panel.offset_right = 0.0 + _panel.offset_bottom = 0.0 + + var bg_style := StyleBoxFlat.new() + bg_style.bg_color = BG_COLOR + bg_style.border_color = BORDER_COLOR + bg_style.border_width_top = 1 + bg_style.content_margin_left = 8.0 + bg_style.content_margin_right = 8.0 + bg_style.content_margin_top = 6.0 + bg_style.content_margin_bottom = 6.0 + _panel.add_theme_stylebox_override("panel", bg_style) + add_child(_panel) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 4) + _panel.add_child(vbox) + + _output_log = RichTextLabel.new() + _output_log.bbcode_enabled = true + _output_log.size_flags_vertical = Control.SIZE_EXPAND_FILL + _output_log.scroll_following = true + _output_log.selection_enabled = true + _output_log.add_theme_color_override("default_color", TEXT_COLOR) + _output_log.add_theme_font_size_override("normal_font_size", 13) + vbox.add_child(_output_log) + + var sep := HSeparator.new() + vbox.add_child(sep) + + _input_line = LineEdit.new() + _input_line.placeholder_text = "enter command (help for list)" + _input_line.clear_button_enabled = false + _input_line.add_theme_font_size_override("font_size", 13) + _input_line.add_theme_color_override("font_color", INPUT_COLOR) + _input_line.text_submitted.connect(_on_input_submitted) + _input_line.gui_input.connect(_on_input_key) + vbox.add_child(_input_line) + + +func _update_panel_layout() -> void: + # Anchors handle resize automatically; no manual size calc needed. + pass + + +# -- Input handling -- + +func _unhandled_input(event: InputEvent) -> void: + if not _enabled: + return + if not event is InputEventKey or not event.pressed or event.echo: + return + if event.keycode == KEY_QUOTELEFT: + get_viewport().set_input_as_handled() + _toggle() + return + if _open: + # Consume all keyboard events — prevent movement/action leaking through + get_viewport().set_input_as_handled() + if event.keycode == KEY_ESCAPE: + _close() + + +func _on_input_key(event: InputEvent) -> void: + if not event is InputEventKey or not event.pressed or event.echo: + return + if event.keycode == KEY_UP: + _history_up() + get_viewport().set_input_as_handled() + elif event.keycode == KEY_DOWN: + _history_down() + get_viewport().set_input_as_handled() + + +func _toggle() -> void: + if _open: + _close() + else: + _open_console() + + +func _open_console() -> void: + _open = true + visible = true + mouse_filter = Control.MOUSE_FILTER_STOP + _input_line.clear() + _input_line.grab_focus() + _history_idx = -1 + + +func _close() -> void: + _open = false + visible = false + mouse_filter = Control.MOUSE_FILTER_IGNORE + _input_line.release_focus() + + +func is_open() -> bool: + return _open + + +# -- Command input -- + +func _on_input_submitted(text: String) -> void: + var trimmed := text.strip_edges() + _input_line.clear() + _history_idx = -1 + if trimmed.is_empty(): + return + if _history.is_empty() or _history[0] != trimmed: + _history.push_front(trimmed) + if _history.size() > 20: + _history.pop_back() + _append_text("> " + trimmed, TEXT_COLOR) + _dispatch(trimmed) + + +func _dispatch(line: String) -> void: + var parts := line.split(" ", false) + if parts.is_empty(): + return + var cmd := parts[0].to_lower() + match cmd: + "help": + _print_help() + "ticks": + if parts.size() < 2 or not parts[1].is_valid_int(): + _append_text("usage: ticks ", ERROR_COLOR) + return + var n := int(parts[1]) + if n <= 0: + _append_text("ticks: n must be > 0", ERROR_COLOR) + return + _send_debug({"AdvanceTicks": n}) + "contaminate": + _send_debug("SkipToContamination") + "tp": + if parts.size() < 2: + _append_text("usage: tp [z] or tp ", ERROR_COLOR) + return + if parts.size() >= 3 and parts[1].is_valid_int() and parts[2].is_valid_int(): + var z := int(parts[3]) if parts.size() >= 4 and parts[3].is_valid_int() else 0 + _send_debug({"TeleportToPosition": {"x": int(parts[1]), "y": int(parts[2]), "z": z}}) + else: + var loc := " ".join(PackedStringArray(parts.slice(1))) + _send_debug({"TeleportToLocation": loc}) + "activate": + _send_debug("ForceContaminationActivate") + "triangle": + if parts.size() < 2: + _append_text("usage: triangle ", ERROR_COLOR) + return + _send_debug({"ForceTriangleActivation": parts[1]}) + "npc": + if parts.size() < 2 or not parts[1].is_valid_int(): + _append_text("usage: npc ", ERROR_COLOR) + return + _send_debug({"InspectNpc": int(parts[1])}) + "triangles": + _send_debug("ListTriangles") + "pop": + _send_debug("ListPopulation") + "status": + _send_debug("GetContaminationStatus") + _: + _append_text("unknown command: '%s' (type 'help')" % cmd, ERROR_COLOR) + + +func _send_debug(kind: Variant) -> void: + var err := SimBridge.send_input({ + "action": InputMapper.Action.DEBUG_COMMAND, + "action_data": kind, + "timestamp_msec": Time.get_ticks_msec(), + }) + if err != OK: + _append_text("send error: %s" % error_string(err), ERROR_COLOR) + + +# -- Response display -- + +## Append a server debug response to the output log. Auto-opens console if closed. +func append_response(response: Dictionary) -> void: + var success: bool = response.get("success", false) + var text: String = response.get("text", "") + var color := SUCCESS_COLOR if success else ERROR_COLOR + _append_text(text, color) + if not _open: + _open_console() + + +# -- Log rendering -- + +func _append_text(text: String, color: Color) -> void: + var escaped := text.replace("[", "[lb]").replace("]", "[rb]") + _log_lines.append("[color=%s]%s[/color]" % [color.to_html(false), escaped]) + if _log_lines.size() > MAX_LOG_LINES: + _log_lines = _log_lines.slice(_log_lines.size() - MAX_LOG_LINES) + if _output_log: + _output_log.text = "\n".join(_log_lines) + + +func _print_help() -> void: + _append_text( + "Commands:\n" + + " ticks — fast-forward N ticks\n" + + " contaminate — skip to contamination phase\n" + + " tp [z] — teleport to tile position\n" + + " tp — teleport to named location\n" + + " activate — force contamination activate\n" + + " triangle — force triangle activation\n" + + " npc — inspect NPC state\n" + + " triangles — list all triangles\n" + + " pop — list active NPCs\n" + + " status — contamination status\n" + + " help — this list", + TEXT_COLOR + ) + + +# -- Command history -- + +func _history_up() -> void: + if _history.is_empty(): + return + _history_idx = mini(_history_idx + 1, _history.size() - 1) + _input_line.text = _history[_history_idx] + _input_line.caret_column = _input_line.text.length() + + +func _history_down() -> void: + if _history_idx <= 0: + _history_idx = -1 + _input_line.clear() + return + _history_idx -= 1 + _input_line.text = _history[_history_idx] + _input_line.caret_column = _input_line.text.length() + + +# -- Settings -- + +func set_enabled(enabled: bool) -> void: + _enabled = enabled + if not _enabled and _open: + _close() + _save_prefs() + + +func is_enabled() -> bool: + return _enabled + + +func _load_prefs() -> void: + var cfg := ConfigFile.new() + if cfg.load(PREFS_PATH) != OK: + return + _enabled = cfg.get_value(PREFS_SECTION, PREFS_KEY_ENABLED, true) + + +func _save_prefs() -> void: + var cfg := ConfigFile.new() + cfg.load(PREFS_PATH) # load existing (may have other sections like "audio") + cfg.set_value(PREFS_SECTION, PREFS_KEY_ENABLED, _enabled) + var err := cfg.save(PREFS_PATH) + if err != OK: + push_warning("DebugConsole: failed to save prefs (%d)" % err) diff --git a/client/ui/debug_console.gd.uid b/client/ui/debug_console.gd.uid new file mode 100644 index 000000000..5b1559a85 --- /dev/null +++ b/client/ui/debug_console.gd.uid @@ -0,0 +1 @@ +uid://c8pvt3xr7kmd2 diff --git a/client/ui/debug_console.tscn b/client/ui/debug_console.tscn new file mode 100644 index 000000000..934729983 --- /dev/null +++ b/client/ui/debug_console.tscn @@ -0,0 +1,15 @@ +[gd_scene load_steps=2 format=3 uid="uid://b2ndm9rvx8cqp"] + +[ext_resource type="Script" uid="uid://c8pvt3xr7kmd2" path="res://ui/debug_console.gd" id="1_debug_console"] + +; #581: In-game debug console. Tilde key toggles. ModalLayer. +; UI built programmatically in _ready() — scene contains only root node + script. +[node name="DebugConsole" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_debug_console") diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index cbde721e3..55b94dc66 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -11,7 +11,7 @@ const TITLE_COLOR := Color("#4a9ebb") const FONT_SIZE := 14 const BOX_WIDTH := 460 -const BOX_HEIGHT := 340 +const BOX_HEIGHT := 376 # +36 for Debug Console row const PADDING := 20 const ROW_HEIGHT := 36 @@ -28,6 +28,7 @@ var _active: bool = false var _container: VBoxContainer = null signal closed +signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled func _ready() -> void: @@ -109,6 +110,29 @@ func _build_ui() -> void: db_label.text = _format_db(value) ) + # #581: Debug Console toggle + var debug_hbox := HBoxContainer.new() + debug_hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT) + _container.add_child(debug_hbox) + + var debug_label := Label.new() + debug_label.text = "Debug Console" + debug_label.custom_minimum_size = Vector2(150, 0) + debug_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + debug_label.add_theme_font_size_override("font_size", FONT_SIZE) + debug_label.add_theme_color_override("font_color", TEXT_COLOR) + debug_hbox.add_child(debug_label) + + var debug_check := CheckButton.new() + var cfg := ConfigFile.new() + debug_check.button_pressed = true # default: enabled + if cfg.load(DebugConsole.PREFS_PATH) == OK: + debug_check.button_pressed = cfg.get_value(DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true) + debug_check.toggled.connect(func(enabled: bool) -> void: + debug_console_toggled.emit(enabled) + ) + debug_hbox.add_child(debug_check) + # Spacer var spacer := Control.new() spacer.custom_minimum_size = Vector2(0, 8)