diff --git a/client/project.godot b/client/project.godot index cb5202bbf..8997d2f61 100644 --- a/client/project.godot +++ b/client/project.godot @@ -29,6 +29,7 @@ FogState="*res://scripts/autoloads/fog_state.gd" AudioManager="*res://scripts/autoloads/audio_manager.gd" SessionManager="*res://scripts/autoloads/session_manager.gd" HudGroups="*res://scripts/autoloads/hud_groups.gd" +MetaStack="*res://ui/meta/meta_stack.gd" ImplantRegistry="*res://ui/implant/implant_registry.gd" HardwareDetector="*res://ui/hardware_detector.gd" diff --git a/client/scripts/character_profile.gd b/client/scripts/character_profile.gd new file mode 100644 index 000000000..7473e5a75 --- /dev/null +++ b/client/scripts/character_profile.gd @@ -0,0 +1,8 @@ +class_name CharacterProfile +extends Resource +## Collects all character creation choices into a single transferable object (#618). +## Passed as the argument to character_creation's creation_confirmed signal. + +@export var descriptor: CharacterVisualDescriptor +@export var bookmark_id: String = "" +@export var start_location_id: String = "" diff --git a/client/ui/meta/meta_screen.gd b/client/ui/meta/meta_screen.gd new file mode 100644 index 000000000..4d47e17ca --- /dev/null +++ b/client/ui/meta/meta_screen.gd @@ -0,0 +1,60 @@ +class_name MetaScreen +extends Control +## Base class for all meta-UI screens (#618, #680). +## Scene-root screens (main_menu, character_creation) extend this for the +## lifecycle contract. Overlay screens (settings, debug_console, bug_report, +## loading_screen) extend this AND push onto MetaStack. + +signal closed +signal escape_pressed + +enum Phase { HIDDEN, OPENING, OPEN, CLOSING } + +@export var pauses_sim: bool = false +@export var closable_by_escape: bool = true +@export var captures_input: bool = true + +var _phase: Phase = Phase.HIDDEN + + +func open() -> void: + if _phase != Phase.HIDDEN: + return + _phase = Phase.OPENING + visible = true + if captures_input: + mouse_filter = Control.MOUSE_FILTER_STOP + on_open() + _phase = Phase.OPEN + + +func close() -> void: + if _phase != Phase.OPEN: + return + _phase = Phase.CLOSING + on_close() + visible = false + _phase = Phase.HIDDEN + closed.emit() + + +func is_open() -> bool: + return _phase == Phase.OPEN + + +## Called by MetaStack when ESC is pressed with this screen on top. +## Return true to consume the event (prevent stack pop); false to allow pop. +func on_escape() -> bool: + escape_pressed.emit() + return false + + +# --- Lifecycle hooks — subclasses override --- + + +func on_open() -> void: + pass + + +func on_close() -> void: + pass diff --git a/client/ui/meta/meta_stack.gd b/client/ui/meta/meta_stack.gd new file mode 100644 index 000000000..1462502b6 --- /dev/null +++ b/client/ui/meta/meta_stack.gd @@ -0,0 +1,72 @@ +extends Node +## Autoload coordinator for overlay MetaScreens (#618, #680). +## Scene-root screens (main_menu, character_creation) do NOT push onto this stack. +## Only overlay screens (settings, debug_console, bug_report, loading_screen) push. +## +## Autoload parse-order: MetaScreen is a class_name type — referenced here only +## inside method bodies called at runtime, never at the top level or in _ready(). + +signal meta_active_changed(active: bool) + +var _stack: Array = [] # Array[MetaScreen] — untyped per parse-order rule + + +func push(screen) -> void: # screen: MetaScreen + if screen in _stack: + return + _stack.append(screen) + screen.closed.connect(_on_screen_closed.bind(screen), CONNECT_ONE_SHOT) + if _stack.size() == 1: + meta_active_changed.emit(true) + if screen.pauses_sim: + _request_pause(true) + + +func pop() -> void: + if _stack.is_empty(): + return + _stack[-1].close() + + +func top(): # returns MetaScreen or null + return _stack[-1] if not _stack.is_empty() else null + + +func is_active() -> bool: + return not _stack.is_empty() + + +## Handle ESC key. Call from main.gd before HudGroups ESC handling. +## Returns true if the event was consumed (callers must return after). +func handle_escape() -> bool: + var t = top() + if t == null: + return false + if not t.closable_by_escape: + return t.on_escape() + if t.on_escape(): + return true + t.close() + return true + + +func _on_screen_closed(screen) -> void: # screen: MetaScreen + _stack.erase(screen) + if screen.pauses_sim and not _any_pausing(): + _request_pause(false) + if _stack.is_empty(): + meta_active_changed.emit(false) + + +func _any_pausing() -> bool: + for s in _stack: + if s.pauses_sim: + return true + return false + + +func _request_pause(pause: bool) -> void: + if SimBridge.state != SimBridge.ConnectionState.CONNECTED: + return + var action = InputMapper.Action.PAUSE if pause else InputMapper.Action.UNPAUSE + SimBridge.send_input({"action": action, "timestamp_msec": Time.get_ticks_msec()})