- Bump client protocol version 20 → 21 to match server (#822) - Fix render_priority parameter name (was _render_priority, unused prefix) - Fix debug console type inference (var sub := → var sub: String =) - Economics panel: add population row, improve key hint text - Stance indicator: hide on gameplay_occluded (D-170 fullscreen apps) - Remove 5 broken clothing items from manifest and delete their GLBs (boots_work, coveralls_basic, jacket_utility, pants_cargo, shirt_henley) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
518 lines
15 KiB
GDScript
518 lines
15 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")
|
||
"econ":
|
||
_dispatch_econ(parts)
|
||
_:
|
||
_append_text("unknown command: '%s' (type 'help')" % cmd, ERROR_COLOR)
|
||
|
||
|
||
# #825: Economics debug console commands (D-178, D-180, D-181).
|
||
# Three subcommands: inject (fire EconEvent), param (tweak α/β/friction), inspect (read signals).
|
||
# TODO: Server-side DebugCommandKind variants (InjectEconEvent, SetEconParam, GetEconState)
|
||
# ship with #823. Until then, _send_debug will transmit the payload but the server will
|
||
# respond with an "unknown command" error. Wire format is ready; server handler is not.
|
||
func _dispatch_econ(parts: Array) -> void:
|
||
if parts.size() < 2:
|
||
_append_text(
|
||
(
|
||
"usage:\n"
|
||
+ " econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]\n"
|
||
+ " econ param <alpha|beta|friction> <value> [system_a] [system_b]\n"
|
||
+ " econ inspect <system_id>"
|
||
),
|
||
ERROR_COLOR,
|
||
)
|
||
return
|
||
var sub: String = parts[1].to_lower()
|
||
match sub:
|
||
"inject":
|
||
_econ_inject(parts)
|
||
"param":
|
||
_econ_param(parts)
|
||
"inspect":
|
||
_econ_inspect(parts)
|
||
_:
|
||
_append_text("econ: unknown subcommand '%s'" % sub, ERROR_COLOR)
|
||
|
||
|
||
# econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]
|
||
# Minimal form: econ inject Sol shock 0.5
|
||
# Full form: econ inject Sol fusion_fuel boost 1.2 100
|
||
func _econ_inject(parts: Array) -> void:
|
||
# parts[0]="econ", parts[1]="inject", rest is args
|
||
var args := parts.slice(2)
|
||
if args.size() < 3:
|
||
_append_text(
|
||
"usage: econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]",
|
||
ERROR_COLOR,
|
||
)
|
||
return
|
||
|
||
# Parse: detect whether commodity_id is present by checking if args[1] is an effect keyword
|
||
var system_id: String = args[0]
|
||
var commodity_id: String = ""
|
||
var effect: String = ""
|
||
var magnitude_str: String = ""
|
||
var duration_ticks: int = 0
|
||
|
||
var ticks_str := ""
|
||
if args[1].to_lower() in ["shock", "boost"]:
|
||
# No commodity_id: econ inject <system> <effect> <magnitude> [ticks]
|
||
effect = args[1].to_lower()
|
||
magnitude_str = args[2]
|
||
if args.size() >= 4:
|
||
ticks_str = args[3]
|
||
elif args.size() == 3:
|
||
# 3 args but args[1] isn't shock/boost — bad effect keyword, not a commodity
|
||
_append_text(
|
||
"econ inject: effect must be 'shock' or 'boost', got '%s'" % args[1], ERROR_COLOR
|
||
)
|
||
return
|
||
else:
|
||
# With commodity_id: econ inject <system> <commodity> <effect> <magnitude> [ticks]
|
||
commodity_id = args[1]
|
||
if args.size() < 4:
|
||
_append_text(
|
||
"usage: econ inject <system_id> <commodity_id> <shock|boost> <magnitude> [ticks]",
|
||
ERROR_COLOR,
|
||
)
|
||
return
|
||
effect = args[2].to_lower()
|
||
if not effect in ["shock", "boost"]:
|
||
_append_text(
|
||
"econ inject: effect must be 'shock' or 'boost', got '%s'" % effect, ERROR_COLOR
|
||
)
|
||
return
|
||
magnitude_str = args[3]
|
||
if args.size() >= 5:
|
||
ticks_str = args[4]
|
||
|
||
if not ticks_str.is_empty():
|
||
if not ticks_str.is_valid_int():
|
||
_append_text("econ inject: invalid ticks '%s'" % ticks_str, ERROR_COLOR)
|
||
return
|
||
duration_ticks = int(ticks_str)
|
||
|
||
if not magnitude_str.is_valid_float():
|
||
_append_text("econ inject: invalid magnitude '%s'" % magnitude_str, ERROR_COLOR)
|
||
return
|
||
var magnitude: float = float(magnitude_str)
|
||
|
||
var payload := {
|
||
"system_id": system_id,
|
||
"effect": effect,
|
||
"magnitude": magnitude,
|
||
}
|
||
if not commodity_id.is_empty():
|
||
payload["commodity_id"] = commodity_id
|
||
if duration_ticks > 0:
|
||
payload["duration_ticks"] = duration_ticks
|
||
|
||
_append_text(
|
||
(
|
||
"injecting %s on %s%s (mag=%.2f, ticks=%d)"
|
||
% [
|
||
effect,
|
||
system_id,
|
||
" / " + commodity_id if not commodity_id.is_empty() else "",
|
||
magnitude,
|
||
duration_ticks
|
||
]
|
||
),
|
||
TEXT_COLOR,
|
||
)
|
||
_send_debug({"InjectEconEvent": payload})
|
||
|
||
|
||
# econ param <alpha|beta|friction> <value> [system_a] [system_b]
|
||
func _econ_param(parts: Array) -> void:
|
||
var args := parts.slice(2)
|
||
if args.size() < 2:
|
||
_append_text(
|
||
"usage: econ param <alpha|beta|friction> <value> [system_a] [system_b]",
|
||
ERROR_COLOR,
|
||
)
|
||
return
|
||
|
||
var param_name: String = args[0].to_lower()
|
||
if not param_name in ["alpha", "beta", "friction"]:
|
||
_append_text(
|
||
"econ param: must be alpha, beta, or friction — got '%s'" % param_name, ERROR_COLOR
|
||
)
|
||
return
|
||
|
||
if not args[1].is_valid_float():
|
||
_append_text("econ param: invalid value '%s'" % args[1], ERROR_COLOR)
|
||
return
|
||
var value: float = float(args[1])
|
||
|
||
var payload := {"param": param_name, "value": value}
|
||
if args.size() >= 3:
|
||
payload["system_a"] = args[2]
|
||
if args.size() >= 4:
|
||
payload["system_b"] = args[3]
|
||
|
||
var scope := "global"
|
||
if payload.has("system_a") and payload.has("system_b"):
|
||
scope = "%s ↔ %s" % [payload["system_a"], payload["system_b"]]
|
||
elif payload.has("system_a"):
|
||
scope = payload["system_a"]
|
||
|
||
_append_text("setting %s = %.4f (%s)" % [param_name, value, scope], TEXT_COLOR)
|
||
_send_debug({"SetEconParam": payload})
|
||
|
||
|
||
# econ inspect <system_id> — request all 7 D-181 signals for a system
|
||
func _econ_inspect(parts: Array) -> void:
|
||
if parts.size() < 3:
|
||
_append_text("usage: econ inspect <system_id>", ERROR_COLOR)
|
||
return
|
||
var system_id: String = parts[2]
|
||
_append_text("inspecting economy: %s" % system_id, TEXT_COLOR)
|
||
_send_debug({"GetEconState": system_id})
|
||
|
||
|
||
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"
|
||
+ "\n"
|
||
+ "Economics (D-178/D-180/D-181):\n"
|
||
+ " econ inject <sys> [commodity] <shock|boost> <mag> [ticks]\n"
|
||
+ " — fire an EconEvent at a system\n"
|
||
+ " econ param <alpha|beta|friction> <val> [sys_a] [sys_b]\n"
|
||
+ " — set tâtonnement parameter\n"
|
||
+ " econ inspect <sys> — show all 7 signals for a system\n"
|
||
+ "\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)
|