Files
settled-reach/client/ui/debug_console.gd
T
2026-04-05 11:09:10 +02:00

342 lines
9.2 KiB
GDScript

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.
## D-088: triggers Overlay pause while open — sim must not advance during debug input.
signal pause_requested # D-088: pause sim while console is open
signal unpause_requested # D-088: unpause sim when console closes
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
pause_requested.emit() # D-088: pause sim while typing debug commands
func _close() -> void:
_open = false
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
_input_line.release_focus()
unpause_requested.emit() # D-088: resume sim when console closes
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 := 0
if parts.size() >= 4:
if parts[3].is_valid_int():
z = int(parts[3])
else:
_append_text("tp: invalid z '%s' — defaulting to 0" % parts[3], ERROR_COLOR)
_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
## (only if console is enabled — respect user's settings toggle).
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 and _enabled:
_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)