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
+1 -1
View File
@@ -1,6 +1,6 @@
[gd_scene load_steps=3 format=3 uid="uid://char_creation_scene_sr"]
[ext_resource type="Script" path="res://ui/character_creation.gd" id="1_charcreation"]
[ext_resource type="Script" path="res://ui/meta/screens/character_creation/character_creation.gd" id="1_charcreation"]
; #705: Character creation screen — 3D preview + 5-tab customisation panel.
; SubViewport renders CharacterVisual live. Tab panel: Body/Head/Hair/Clothing/Accessories.
+5 -5
View File
@@ -187,17 +187,17 @@ script = ExtResource("10_cursor")
; --- Modal layer (CanvasLayer 30) ---
; Full-screen overlays: pause menu, inventory modal, death screen.
[node name="ModalLayer" type="CanvasLayer" parent="."]
[node name="MetaLayer" type="CanvasLayer" parent="."]
layer = 30
; #495: WRONG button (F12) — bug report capture dialog
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
[node name="BugReportDialog" parent="MetaLayer" instance=ExtResource("19_bugreport")]
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
[node name="SettingsDialog" parent="MetaLayer" instance=ExtResource("21_settings")]
; #257: Loading screen — full-screen overlay during save/load round-trip
[node name="LoadingScreen" parent="ModalLayer" instance=ExtResource("26_loading")]
[node name="LoadingScreen" parent="MetaLayer" instance=ExtResource("26_loading")]
; #581: Debug console — tilde key toggles, bottom 40% of screen
[node name="DebugConsole" parent="ModalLayer" instance=ExtResource("27_debug_console")]
[node name="DebugConsole" parent="MetaLayer" instance=ExtResource("27_debug_console")]
+1 -1
View File
@@ -1,6 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"]
[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"]
[ext_resource type="Script" path="res://ui/meta/screens/main_menu/main_menu.gd" id="1_mainmenu"]
; Main menu — New Game / Continue / Quit.
; #258: D-085 per-game save directory created on New Game.
+1 -1
View File
@@ -40,7 +40,7 @@ const CANVAS_UI: int = 20 # CanvasLayer number for UILayer
#
# MODAL SCOPE (CanvasLayer 30)
# Full-screen overlays: pause, inventory modal, death screen.
const CANVAS_MODAL: int = 30 # CanvasLayer number for ModalLayer
const CANVAS_MODAL: int = 30 # CanvasLayer number for MetaLayer
#
# Rendering ceiling: 10 floors (25m) above current floor.
# Above this: no sprites, ground shadows + environmental effects only.
+5 -4
View File
@@ -30,10 +30,10 @@ var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
@onready var bug_report_dialog = $MetaLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $MetaLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
@onready var loading_screen = $MetaLayer/LoadingScreen # #257: blocking overlay during load
@onready var debug_console = $MetaLayer/DebugConsole # #581: tilde debug console
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
@@ -271,6 +271,7 @@ func _process(delta: float) -> void:
if settings_dialog.is_open():
settings_dialog.close()
else:
MetaStack.push(settings_dialog)
settings_dialog.open()
continue
if input.action == InputMapper.Action.INTERACT:
+10 -9
View File
@@ -124,7 +124,7 @@ func test_z_ui_layer_above_world() -> void:
var inst := _make_scene()
var ui_layer = inst.get_node("UILayer") as CanvasLayer
var insert_layer = inst.get_node("InsertOverlay") as CanvasLayer
var modal_layer = inst.get_node("ModalLayer") as CanvasLayer
var modal_layer = inst.get_node("MetaLayer") as CanvasLayer
assert_that(insert_layer.layer).override_failure_message(
"InsertOverlay must be CanvasLayer %d" % Constants.CANVAS_INSERT
).is_equal(Constants.CANVAS_INSERT)
@@ -132,13 +132,13 @@ func test_z_ui_layer_above_world() -> void:
"UILayer must be CanvasLayer %d" % Constants.CANVAS_UI
).is_equal(Constants.CANVAS_UI)
assert_that(modal_layer.layer).override_failure_message(
"ModalLayer must be CanvasLayer %d" % Constants.CANVAS_MODAL
"MetaLayer must be CanvasLayer %d" % Constants.CANVAS_MODAL
).is_equal(Constants.CANVAS_MODAL)
assert_that(ui_layer.layer > insert_layer.layer).override_failure_message(
"UILayer must render above InsertOverlay"
).is_true()
assert_that(modal_layer.layer > ui_layer.layer).override_failure_message(
"ModalLayer must render above UILayer"
"MetaLayer must render above UILayer"
).is_true()
@@ -168,7 +168,7 @@ func test_entity_lerp_moves_toward_target() -> void:
var entity := [{"entity_id": 11, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var node: ColorRect = renderer.entity_nodes[11]
var node: Sprite2D = renderer.entity_nodes[11]
var start_pos: Vector2 = node.position
# Move target to (6, 5)
var entity_moved := [{"entity_id": 11, "x": 6.0, "y": 5.0, "z": 0,
@@ -212,7 +212,7 @@ func test_entity_lerp_converges_within_300ms() -> void:
# Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s)
for i in 20:
renderer._process(0.016)
var final_node: ColorRect = renderer.entity_nodes[12]
var final_node: Sprite2D = renderer.entity_nodes[12]
var final_pos: Vector2 = final_node.position
# Should be within 5% of target (97% convergence at 0.3s)
var dist: float = final_pos.distance_to(target)
@@ -272,9 +272,10 @@ func test_facing_indicator_rotation_matches_input_mapper_angle() -> void:
for angle in angles:
InputMapper.facing_angle = angle
renderer.update_entities(entity)
assert_that(indicator.rotation).override_failure_message(
"angle %.3f: expected rotation %.3f, got %.3f" % [angle, angles[angle], indicator.rotation]
).is_equal_approx(angles[angle], 0.001)
var diff := absf(angle_difference(indicator.rotation, angles[angle]))
assert_that(diff).override_failure_message(
"angle %.3f: expected rotation %.3f, got %.3f (diff %.4f)" % [angle, angles[angle], indicator.rotation, diff]
).is_less_equal(0.001)
InputMapper.facing_angle = -PI / 2.0 # Reset to default
renderer.queue_free()
@@ -292,7 +293,7 @@ func test_lerp_weight_increases_with_delta() -> void:
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity_moved)
# Small delta step
var small_node: ColorRect = renderer.entity_nodes[20]
var small_node: Sprite2D = renderer.entity_nodes[20]
var small_start: float = small_node.position.x
renderer._process(0.008)
var small_progress: float = small_node.position.x - small_start
+1 -1
View File
@@ -472,7 +472,7 @@ func test_monologue_display_parented_to_canvas_layer_20_in_main_scene() -> void:
## D-049: Structural verification — MonologueDisplay must be a direct child of
## UILayer (CanvasLayer, layer=20) in the live scene tree, not the world layer.
## Catches regressions where the node gets accidentally moved to InsertOverlay
## (layer=10) or ModalLayer (layer=30), or dropped into the world z-stack.
## (layer=10) or MetaLayer (layer=30), or dropped into the world z-stack.
##
## Scene path verified: Game/UILayer/MonologueDisplay (main.tscn line 141).
if not ResourceLoader.exists("res://scenes/main.tscn"):
+3 -3
View File
@@ -66,13 +66,13 @@ func test_ui_layer_is_canvas_layer_20() -> void:
func test_modal_layer_is_canvas_layer_30() -> void:
# D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30.
# D-049: MetaLayer = pause/inventory modal scope = CanvasLayer 30.
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var modal_layer: CanvasLayer = _instance.get_node("ModalLayer")
var modal_layer: CanvasLayer = _instance.get_node("MetaLayer")
assert_that(modal_layer).is_not_null()
assert_that(modal_layer.layer).is_equal(Constants.CANVAS_MODAL)
@@ -83,7 +83,7 @@ func test_ui_layer_above_insert_overlay() -> void:
func test_modal_layer_above_ui_layer() -> void:
# D-049: ModalLayer (30) must render above UILayer (20).
# D-049: MetaLayer (30) must render above UILayer (20).
assert_that(Constants.CANVAS_MODAL).is_greater(Constants.CANVAS_UI)
+2 -2
View File
@@ -1,8 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/bug_report_dialog.gd" id="1_bugreport"]
[ext_resource type="Script" path="res://ui/meta/screens/bug_report/bug_report_dialog.gd" id="1_bugreport"]
; #495: WRONG button (F12) — bug report capture dialog, ModalLayer
; #495: WRONG button (F12) — bug report capture dialog, MetaLayer
[node name="BugReportDialog" type="Control"]
layout_mode = 3
anchors_preset = 15
+2 -2
View File
@@ -1,8 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://b2ndm9rvx8cqp"]
[ext_resource type="Script" uid="uid://c8pvt3xr7kmd2" path="res://ui/debug_console.gd" id="1_debug_console"]
[ext_resource type="Script" path="res://ui/meta/screens/debug_console/debug_console.gd" id="1_debug_console"]
; #581: In-game debug console. Tilde key toggles. ModalLayer.
; #581: In-game debug console. Tilde key toggles. MetaLayer.
; UI built programmatically in _ready() — scene contains only root node + script.
[node name="DebugConsole" type="Control"]
layout_mode = 3
+4 -2
View File
@@ -54,14 +54,16 @@ func on_open(_mode: int) -> void:
_reach_screen.refresh_info_panel_visibility()
func _unhandled_key_input(event: InputEventKey) -> void:
func _unhandled_key_input(event: InputEvent) -> void:
if not event is InputEventKey:
return
if manifest == null or not HudGroups.is_app_active(manifest.app_path):
return
if not event.is_pressed() or event.is_echo():
return
if current_screen_id() == "regional":
return # AtlasViewer handles its own keyboard input
_handle_key(event)
_handle_key(event as InputEventKey)
get_viewport().set_input_as_handled()
+1 -1
View File
@@ -1,6 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://b7rv9mkl4qpw3"]
[ext_resource type="Script" path="res://ui/loading_screen.gd" id="1_loading"]
[ext_resource type="Script" path="res://ui/meta/screens/loading/loading_screen.gd" id="1_loading"]
; #257: Loading screen — full-screen overlay shown during save/load round-trip.
; Blocks input; dismissed when save_result arrives from server.
@@ -1,4 +1,4 @@
extends Control
extends MetaScreen
## #507: WRONG button — full 60-tick capture: ring buffer, snapshot history, replay seed.
## Upgrade of the Sprint 9 MVP (#495).
@@ -36,7 +36,6 @@ const PADDING := 16
const RING_SIZE := 60
var _line_edit: LineEdit = null
var _active: bool = false
var _captured_screenshot: Image = null
# #507: Pre-allocated ring buffers (no per-tick allocation after _ready).
@@ -56,8 +55,9 @@ var _snapshot_count: int = 0
func _ready() -> void:
pauses_sim = true
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_filter = Control.MOUSE_FILTER_IGNORE
# Pre-allocate ring buffers — resize then fill sentinels.
# The ring array itself never grows after _ready. Each write replaces the GDScript
@@ -208,25 +208,15 @@ func _get_filled_snapshot_count() -> int:
func start_capture() -> void:
if _active:
if is_open():
return
# Capture screenshot BEFORE showing the dialog overlay
_captured_screenshot = get_viewport().get_texture().get_image()
_active = true
visible = true
MetaStack.push(self)
open()
# Pause the simulation
(
SimBridge
. send_input(
{
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
}
)
)
# Create the LineEdit dynamically
func on_open() -> void:
_line_edit = LineEdit.new()
_line_edit.placeholder_text = "Describe the issue..."
_line_edit.size = Vector2(BOX_WIDTH - PADDING * 2, 30)
@@ -240,41 +230,23 @@ func start_capture() -> void:
_line_edit.grab_focus()
func _on_text_submitted(text: String) -> void:
_save_report(text)
_close()
capture_completed.emit()
func _unhandled_input(event: InputEvent) -> void:
if not _active:
return
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
_close()
capture_cancelled.emit()
get_viewport().set_input_as_handled()
func _close() -> void:
_active = false
visible = false
func on_close() -> void:
if _line_edit:
_line_edit.release_focus()
_line_edit.queue_free()
_line_edit = null
_captured_screenshot = null
# Unpause the simulation
(
SimBridge
. send_input(
{
"action": InputMapper.Action.UNPAUSE,
"timestamp_msec": Time.get_ticks_msec(),
}
)
)
func on_escape() -> bool:
capture_cancelled.emit()
return false # let MetaStack close
func _on_text_submitted(text: String) -> void:
_save_report(text)
close()
capture_completed.emit()
func _save_report(description: String) -> void:
@@ -456,7 +428,7 @@ func _render_snapshot_text() -> String:
func _draw() -> void:
if not _active:
if not is_open():
return
var viewport_size := get_viewport_rect().size
# Full-screen dim
@@ -489,4 +461,4 @@ func _draw() -> void:
func is_active() -> bool:
return _active
return is_open()
@@ -1,6 +1,6 @@
# gdlint:disable=max-file-lines
class_name CharacterCreation
extends Control
extends MetaScreen
## #705: Character creation screen.
## Live 3D preview via SubViewport + 5-tab customisation panel (Body/Head/Hair/Clothing/Accessories).
## Emits creation_confirmed(descriptor) on start, creation_cancelled on back.
@@ -1,5 +1,5 @@
class_name DebugConsole
extends Control
extends MetaScreen
## In-game debug console (#581). Tilde key (`) toggles open/closed.
## Semi-transparent panel anchored to bottom ~40% of screen.
@@ -23,7 +23,6 @@ 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
@@ -33,6 +32,7 @@ var _history_idx: int = -1
func _ready() -> void:
pauses_sim = true
_load_prefs()
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -107,11 +107,11 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
_toggle()
return
if _open:
if is_open():
# Consume all keyboard events — prevent movement/action leaking through
get_viewport().set_input_as_handled()
if event.keycode == KEY_ESCAPE:
_close()
close()
func _on_input_key(event: InputEvent) -> void:
@@ -126,34 +126,26 @@ func _on_input_key(event: InputEvent) -> void:
func _toggle() -> void:
if _open:
_close()
if is_open():
close()
else:
_open_console()
MetaStack.push(self)
open()
func _open_console() -> void:
_open = true
visible = true
mouse_filter = Control.MOUSE_FILTER_STOP
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 _close() -> void:
_open = false
visible = false
func on_close() -> void:
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 --
@@ -422,8 +414,9 @@ func append_response(response: Dictionary) -> void:
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()
if not is_open() and _enabled:
MetaStack.push(self)
open()
# -- Log rendering --
@@ -492,8 +485,8 @@ func _history_down() -> void:
func set_enabled(enabled: bool) -> void:
_enabled = enabled
if not _enabled and _open:
_close()
if not _enabled and is_open():
close()
_save_prefs()
@@ -1,4 +1,4 @@
extends Control
extends MetaScreen
## #257: Loading screen overlay — blocks input during save/load round-trip.
## Shown when LOAD_GAME fires; hidden when save_result arrives (success or failure).
## Full-screen, dark overlay with centered status text.
@@ -15,8 +15,9 @@ var _version_label: Label = null
func _ready() -> void:
closable_by_escape = false
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_ui()
@@ -76,9 +77,10 @@ func _read_client_version() -> String:
func show_loading() -> void:
visible = true
MetaStack.push(self)
open()
## Hide the loading overlay. success=false is reserved for future failure-state UI.
func hide_loading(_success: bool = true) -> void:
visible = false
close()
@@ -1,4 +1,4 @@
extends Control
extends MetaScreen
## #258: Main menu — New Game / Continue / Load Game / Quit.
## New Game: opens character creation screen, then starts game.
## Continue: loads most recent save directory.
@@ -1,4 +1,4 @@
extends Control
extends MetaScreen
## #528: Audio settings dialog — 5-bus volume sliders.
## #646: AI-Enhanced Dialogue toggle + hardware detection status (D-138).
@@ -6,7 +6,6 @@ extends Control
## Volumes persist via AudioManager._save_prefs() on each slider change.
## AI Dialogue toggle persists via ConfigFile (client-local) + ChangeSettings IPC (server SQLite).
signal closed
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
@@ -37,7 +36,6 @@ const BUS_ROWS: Array = [
["UI Sounds", "UISounds"],
]
var _active: bool = false
var _container: VBoxContainer = null
# #646: AI Dialogue hardware status and toggle node ref — used by testable API methods
@@ -49,30 +47,17 @@ var _ai_battery_warning_label: Label = null # shown when on battery; toggle sta
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_filter = Control.MOUSE_FILTER_IGNORE
func open() -> void:
if _active:
return
_active = true
visible = true
func on_open() -> void:
_build_ui()
queue_redraw()
func close() -> void:
if not _active:
return
_active = false
visible = false
func on_close() -> void:
_destroy_ui()
queue_redraw()
closed.emit()
func is_open() -> bool:
return _active
func _build_ui() -> void:
@@ -139,7 +124,7 @@ func _build_ui() -> void:
var debug_check := CheckButton.new()
# Query live DebugConsole node if available; fall back to prefs file
var console_node := get_node_or_null("/root/Main/ModalLayer/DebugConsole")
var console_node := get_node_or_null("/root/Main/MetaLayer/DebugConsole")
if console_node and console_node.has_method("is_enabled"):
debug_check.button_pressed = console_node.is_enabled()
else:
@@ -351,7 +336,7 @@ func _save_ai_pref(enabled: bool) -> void:
func _draw() -> void:
if not _active:
if not is_open():
return
var viewport_size := get_viewport_rect().size
+1 -1
View File
@@ -1,6 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/settings_dialog.gd" id="1_settings"]
[ext_resource type="Script" path="res://ui/meta/screens/settings/settings_dialog.gd" id="1_settings"]
; #528: Audio settings dialog — 5-bus volume sliders, OPEN_MENU (ESC) to toggle
[node name="SettingsDialog" type="Control"]