refactor(ui): migrate 6 meta screens to MetaScreen pattern (Workstream 2)

Relocates main_menu, character_creation, settings_dialog, debug_console,
bug_report_dialog, loading_screen from flat client/ui/ into structured
client/ui/meta/screens/<name>/. All six now extend MetaScreen instead
of Control; the base handles open/close lifecycle, visibility,
captures_input, and — for overlays — the sim-pause contract.

Screen policies set per Tyre's proposal:
- settings_dialog: pauses_sim=false, PUSHES onto MetaStack
- debug_console: pauses_sim=true, PUSHES (D-088 routing via base)
- bug_report_dialog: pauses_sim=true, PUSHES
- loading_screen: closable_by_escape=false, PUSHES
- main_menu, character_creation: scene-roots, extend MetaScreen for
  the lifecycle contract only, do NOT push onto the stack

character_creation stays at its current surface (tabs, descriptor,
creation_confirmed signal unchanged). Tab consolidation and
CharacterProfile migration happen in Workstreams 5 and 6.

Knock-on changes:
- main.tscn ModalLayer CanvasLayer renamed to MetaLayer; main.gd
  @onready refs updated; constants.gd comment updated; test_client_p3
  and test_ui_framework_sprint15 assertions updated; test_monologue_display
  and .tscn header comments updated.
- OPEN_MENU handler now pushes settings_dialog onto MetaStack before
  calling open(). Full ESC priority chain lands in Workstream 4.
- atlas_app.gd: _unhandled_key_input signature widened from
  InputEventKey to InputEvent with an is-check, per Godot 4 API. Pre-
  existing narrowing was silently tolerated until main.tscn started
  fully instantiating under the new pattern.
- test_client_p3: entity_renderer type annotations corrected from
  ColorRect to Sprite2D (stale since a prior refactor); facing
  indicator rotation assertion switched to angle_difference() for
  modular-safe comparison.

Verification:
- gdlint client/scripts/ client/ui/ — zero problems
- godot --headless --path client --quit — no SCRIPT ERROR
- test_client_p3: 24/24 pass
- test_ui_framework_sprint15: 54/54 pass
- test_implant_nav_stack: 52/52 pass
- test_implant_registry: 42/42 pass
- test_implant_app_lifecycle: 36/36 pass

Workstream 1 foundation (84105916) remains unchanged. Workstreams 3-8
follow: protocol layer, Option A sequencing, 3-tab restructure,
Bookmark tab, location picker, Skills stub.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 21:58:24 +02:00
co-authored by Claude Opus 4.6
parent 84105916cd
commit f24d08f756
19 changed files with 86 additions and 130 deletions
@@ -0,0 +1,510 @@
class_name DebugConsole
extends MetaScreen
## 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 _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:
pauses_sim = true
_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 is_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 is_open():
close()
else:
MetaStack.push(self)
open()
func on_open() -> void:
_input_line.clear()
_input_line.grab_focus()
_history_idx = -1
pause_requested.emit() # D-088: pause sim while typing debug commands
func on_close() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_input_line.release_focus()
unpause_requested.emit() # D-088: resume sim when console closes
# -- 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 is_open() and _enabled:
MetaStack.push(self)
open()
# -- 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 is_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)