feat(client): Option A pre-game flow + ESC priority chain (Workstream 4)

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 11:30:15 +02:00
co-authored by Claude Opus 4.6
parent 41e895796c
commit 2104348000
5 changed files with 91 additions and 37 deletions
+13
View File
@@ -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:
+10 -3
View File
@@ -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()
@@ -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)
# =============================================================================
@@ -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()
+47 -34
View File
@@ -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: