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 ", 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 := 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 ", 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 ## (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 — 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)