- Add D-088 Overlay pause/unpause signals to DebugConsole, wire in main.gd so sim does not advance while typing debug commands - Settings dialog reads live DebugConsole.is_enabled() instead of ConfigFile directly, preventing checkbox/state divergence - append_response respects disabled state — no auto-open when user disabled console via settings - tp command warns on invalid z value instead of silently defaulting to 0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
327 lines
9.2 KiB
GDScript
327 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)
|