feat(ui): add in-game debug console with tilde toggle and command dispatch (#581)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 09:50:23 +01:00
co-authored by Claude Sonnet 4.6
parent 60d2c5bb90
commit c3abf32185
10 changed files with 402 additions and 2 deletions
+5 -1
View File
@@ -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")]
+11
View File
@@ -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/<game-id>/<filename>.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:
+1
View File
@@ -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] = []
+2
View File
@@ -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 ""
+16
View File
@@ -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({
+12
View File
@@ -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,
}
+314
View File
@@ -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 <n>", 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 <x> <y> [z] or tp <location_name>", 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 <id>", ERROR_COLOR)
return
_send_debug({"ForceTriangleActivation": parts[1]})
"npc":
if parts.size() < 2 or not parts[1].is_valid_int():
_append_text("usage: npc <entity_id>", 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 <n> — fast-forward N ticks\n"
+ " contaminate — skip to contamination phase\n"
+ " tp <x> <y> [z] — teleport to tile position\n"
+ " tp <location> — teleport to named location\n"
+ " activate — force contamination activate\n"
+ " triangle <id> — force triangle activation\n"
+ " npc <entity_id> — 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)
+1
View File
@@ -0,0 +1 @@
uid://c8pvt3xr7kmd2
+15
View File
@@ -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")
+25 -1
View File
@@ -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)