From 2104348000f46f5592bc8a324786a1c0511b4e44 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 20 Apr 2026 11:30:15 +0200 Subject: [PATCH] feat(client): Option A pre-game flow + ESC priority chain (Workstream 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main_menu now connects SimBridge before character_creation opens, gating the transition on first ObserverSnapshot carrying a bookmark_catalog. Loading screen is shown during the connect; on cancel the SimBridge subprocess is torn down and the player returns to main_menu. Catalog is read straight from GameState.bookmark_catalog in W6. Flow (Option A): 1. New Game → SessionManager.new_game() creates save dir 2. main_menu pushes loading_screen via MetaStack with "Connecting to simulation..." message 3. SimBridge.connect_to_sim() spawned; main_menu listens on connection_state_changed, then on snapshot_received for the catalog 4. On catalog arrival: loading_screen closed, scene-transition to character_creation 5. character_creation Cancel → SimBridge.disconnect_from_sim() + scene transition back to main_menu (Tyre's recommendation: clean state per session over warm-start savings) 6. character_creation Start → ConfirmBookmark sent (stubbed for W4 with first catalog entry; real bookmark + location from W6's UI) ESC priority chain in main.gd OPEN_MENU handler: - MetaStack.handle_escape() first — closes the topmost meta overlay - HudGroups.is_implant_active() / close_app() — closes active implant - Fallback: toggle settings_dialog (existing W2 behavior) Files: - sim_bridge.gd: send_named_action(action_name, action_data) helper. Bridges named tag-enum PlayerActions (RequestBookmarkCatalog, ConfirmBookmark) into the existing outbound buffer, parallel to send_input's InputMapper.Action handling. - loading_screen.gd: set_message(text) for the connecting/loading label. - main_menu.gd: full Option A flow rewrite. Tracks _waiting_for_catalog so re-clicking New Game during connect is a no-op. - character_creation.gd: _on_back disconnect path + _on_start ConfirmBookmark stub. MAIN_MENU_SCENE / GAME_SCENE constants. - main.gd: connect_to_sim guard (don't reconnect when Option A leaves it CONNECTED). ESC chain wiring. Verification: - gdlint clean - godot --headless --path client --quit — no SCRIPT ERROR - test_protocol 62/62, test_implant_nav_stack 52/52, test_client_p3 24/24, test_ui_framework_sprint15 54/54 Pre-existing failing suites unchanged: test_sprint2_proof, test_dialogue_sprint18, test_client_p2 (camera-smoothing assertions that pre-date W4 — main.gd has disabled position_smoothing_enabled since #117 / #501 / #117 manual-lerp; tests were stale). Workstream 5 (3-tab restructure of character_creation: Bookmark / Appearance with sub-nav / Skills / Debug) lands next. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/sim_bridge.gd | 13 +++ client/scripts/main.gd | 13 ++- .../character_creation/character_creation.gd | 15 ++++ .../ui/meta/screens/loading/loading_screen.gd | 6 ++ client/ui/meta/screens/main_menu/main_menu.gd | 81 +++++++++++-------- 5 files changed, 91 insertions(+), 37 deletions(-) diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 6efbbe5ff..1e8758502 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -387,6 +387,19 @@ func send_input(player_input: Dictionary) -> Error: return OK +## Queue a named PlayerAction by wire string (e.g. "RequestBookmarkCatalog"). +## For use outside the input event loop — protocol-level requests that aren't +## bound to an InputMapper.Action enum value. +func send_named_action(action_name: String, action_data: Variant = null) -> void: + if state != ConnectionState.CONNECTED: + push_warning("SimBridge.send_named_action(%s): not connected" % action_name) + return + var entry: Dictionary = {"tick": GameState.current_tick, "action_name": action_name} + if action_data != null: + entry["action_data"] = action_data + _outbound_buffer.append(entry) + + # Poll for snapshot from simulation. # In test mode delegates to test harness. In live mode, returns the last decoded snapshot. func poll_snapshot() -> Variant: diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 0c51f267f..34a553f59 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -47,8 +47,10 @@ func _ready() -> void: # #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing. camera.position_smoothing_enabled = false - # Connect to simulation (test mode sets CONNECTED immediately) - SimBridge.connect_to_sim() + # Connect to simulation (test mode sets CONNECTED immediately). + # Guard: Option A flow leaves SimBridge CONNECTED when main.tscn loads — don't drop it. + if SimBridge.state == SimBridge.ConnectionState.DISCONNECTED: + SimBridge.connect_to_sim() # #257: Deferred load dispatch if not GameState.pending_load_path.is_empty(): @@ -265,8 +267,13 @@ func _process(delta: float) -> void: elif err != OK: push_error("main.gd: LOAD_GAME send_input failed: %s" % error_string(err)) continue - # #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog + # #528: ESC/OPEN_MENU — priority chain: MetaStack modal → implant app → settings dialog if input.action == InputMapper.Action.OPEN_MENU: + if MetaStack.handle_escape(): + continue + if HudGroups.is_implant_active(): + HudGroups.close_app() + continue if settings_dialog: if settings_dialog.is_open(): settings_dialog.close() diff --git a/client/ui/meta/screens/character_creation/character_creation.gd b/client/ui/meta/screens/character_creation/character_creation.gd index 27cee6e39..9d7746eaf 100644 --- a/client/ui/meta/screens/character_creation/character_creation.gd +++ b/client/ui/meta/screens/character_creation/character_creation.gd @@ -186,6 +186,9 @@ const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail) const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level) const CAM_ZOOM_STEP: float = 0.12 +const MAIN_MENU_SCENE := "res://scenes/main_menu.tscn" +const GAME_SCENE := "res://scenes/main.tscn" + const MANIFEST_PATH := "res://assets/characters/manifest.json" const SCREENSHOT_DIR := "user://screenshots/" @@ -1683,10 +1686,22 @@ func _input(event: InputEvent) -> void: func _on_back() -> void: creation_cancelled.emit() + SimBridge.disconnect_from_sim() + get_tree().change_scene_to_file(MAIN_MENU_SCENE) func _on_start() -> void: creation_confirmed.emit(_descriptor) + var bookmark_id: String = "" + var location_id: String = "" + if GameState.bookmark_catalog.size() > 0: + var first_bm: Dictionary = GameState.bookmark_catalog[0] + bookmark_id = first_bm.get("id", "") + location_id = first_bm.get("default_location", "") + SimBridge.send_named_action( + "ConfirmBookmark", {"bookmark_id": bookmark_id, "starting_location_id": location_id} + ) + get_tree().change_scene_to_file(GAME_SCENE) # ============================================================================= diff --git a/client/ui/meta/screens/loading/loading_screen.gd b/client/ui/meta/screens/loading/loading_screen.gd index c7f343595..e9f2f3347 100644 --- a/client/ui/meta/screens/loading/loading_screen.gd +++ b/client/ui/meta/screens/loading/loading_screen.gd @@ -76,6 +76,12 @@ func _read_client_version() -> String: return "?.?.?" +## Update the status text shown while loading. Call before show_loading() or after. +func set_message(text: String) -> void: + if _label != null: + _label.text = text + + func show_loading() -> void: MetaStack.push(self) open() diff --git a/client/ui/meta/screens/main_menu/main_menu.gd b/client/ui/meta/screens/main_menu/main_menu.gd index f861851bc..36ec88065 100644 --- a/client/ui/meta/screens/main_menu/main_menu.gd +++ b/client/ui/meta/screens/main_menu/main_menu.gd @@ -6,6 +6,7 @@ extends MetaScreen const GAME_SCENE := "res://scenes/main.tscn" const CHARACTER_CREATION_SCENE := "res://scenes/character_creation.tscn" +const LOADING_SCREEN_SCENE := "res://ui/loading_screen.tscn" const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0) const TITLE_COLOR := Color("#c8d0e0") @@ -16,8 +17,9 @@ const FONT_SIZE_TITLE := 36 const FONT_SIZE_SUBTITLE := 14 const FONT_SIZE_BTN := 15 -var _char_creation: Control = null var _list_built: bool = false +var _loading_screen = null # LoadingScreen — instantiated on demand +var _waiting_for_catalog: bool = false @onready var _new_game_btn: Button = $VBox/NewGameBtn @onready var _continue_btn: Button = $VBox/ContinueBtn @@ -45,44 +47,55 @@ func _refresh_continue_state() -> void: func _on_new_game() -> void: - GameState.pending_load_path = "" - _show_character_creation() - - -func _show_character_creation() -> void: - if _char_creation != null and is_instance_valid(_char_creation): - _char_creation.queue_free() - var scene := load(CHARACTER_CREATION_SCENE) as PackedScene - if scene == null: - push_error("MainMenu: failed to load character_creation.tscn — skipping to game") - _start_game_with_defaults() + if _waiting_for_catalog: return - _char_creation = scene.instantiate() - add_child(_char_creation) - _char_creation.creation_confirmed.connect(_on_creation_confirmed) - _char_creation.creation_cancelled.connect(_on_creation_cancelled) - - -func _on_creation_confirmed(descriptor) -> void: - if _char_creation != null and is_instance_valid(_char_creation): - _char_creation.queue_free() - _char_creation = null - GameState.character_visual_descriptor = descriptor - _start_game_with_defaults() - - -func _on_creation_cancelled() -> void: - if _char_creation != null and is_instance_valid(_char_creation): - _char_creation.queue_free() - _char_creation = null - - -func _start_game_with_defaults() -> void: var game_id := SessionManager.new_game() if game_id.is_empty(): push_error("MainMenu: new_game() failed to create save directory — cannot start") return - get_tree().change_scene_to_file(GAME_SCENE) + GameState.pending_load_path = "" + _waiting_for_catalog = true + _ensure_loading_screen() + _loading_screen.set_message("Connecting to simulation...") + _loading_screen.show_loading() + SimBridge.connection_state_changed.connect(_on_sim_state_changed_for_new_game) + SimBridge.connect_to_sim() + + +func _ensure_loading_screen() -> void: + if _loading_screen != null and is_instance_valid(_loading_screen): + return + var scene := load(LOADING_SCREEN_SCENE) as PackedScene + if scene == null: + push_error("MainMenu: failed to load loading_screen.tscn") + return + _loading_screen = scene.instantiate() + add_child(_loading_screen) + + +func _on_sim_state_changed_for_new_game(_old_state, new_state) -> void: + if new_state == SimBridge.ConnectionState.CONNECTED: + SimBridge.connection_state_changed.disconnect(_on_sim_state_changed_for_new_game) + SimBridge.send_named_action("RequestBookmarkCatalog") + SimBridge.snapshot_received.connect(_on_snapshot_received_for_catalog) + elif new_state == SimBridge.ConnectionState.ERROR: + SimBridge.connection_state_changed.disconnect(_on_sim_state_changed_for_new_game) + _waiting_for_catalog = false + if _loading_screen: + _loading_screen.set_message("Connection failed. Try again.") + + +func _on_snapshot_received_for_catalog(snapshot: Dictionary) -> void: + if not _waiting_for_catalog: + return + if snapshot.get("bookmark_catalog") == null: + return + _waiting_for_catalog = false + SimBridge.snapshot_received.disconnect(_on_snapshot_received_for_catalog) + GameState.apply_snapshot(snapshot) + if _loading_screen: + _loading_screen.hide_loading() + get_tree().change_scene_to_file(CHARACTER_CREATION_SCENE) func _on_continue() -> void: