diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 95ec6518a..934423248 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -50,18 +50,16 @@ func _ready() -> void: # Connect to simulation (test mode sets CONNECTED immediately) SimBridge.connect_to_sim() - # #257: If returning from main menu "Load Game" selection, send load command immediately. - # pending_load_path is set by main_menu.gd before changing scene; cleared after dispatch. + # #257: If returning from main menu "Load Game" selection, defer dispatch until connected. + # In test mode, connect_to_sim() sets CONNECTED synchronously — dispatch fires immediately. + # In live mode, state is CONNECTING — signal handler dispatches once connected. if not GameState.pending_load_path.is_empty(): - var load_path := GameState.pending_load_path - GameState.pending_load_path = "" - SimBridge.send_input({ - "action": InputMapper.Action.LOAD_GAME, - "timestamp_msec": Time.get_ticks_msec(), - "action_data": {"path": load_path}, - }) if loading_screen: loading_screen.show_loading() + if SimBridge.state == SimBridge.ConnectionState.CONNECTED: + _dispatch_pending_load() + else: + SimBridge.connection_state_changed.connect(_on_sim_connected_for_load) # Camera anchor: snap to player position before the first frame renders. # In test mode poll_snapshot() returns synchronously — position is set @@ -175,10 +173,15 @@ func _process(delta: float) -> void: if input.action == InputMapper.Action.OPEN_JOURNAL: _toggle_journal() continue - # #257: LOAD_GAME — show loading screen before sending to server + # #257: LOAD_GAME — send first, then show loading screen (avoids stuck overlay if send fails) if input.action == InputMapper.Action.LOAD_GAME: - if loading_screen: + var err := SimBridge.send_input(input) + _pending_record_inputs.append(input) + if err == OK and loading_screen: loading_screen.show_loading() + 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 if input.action == InputMapper.Action.OPEN_MENU: if settings_dialog: @@ -483,6 +486,32 @@ func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_stat gauntlet_hud.finalize() +# #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED. +# pending_load_path is set by main_menu.gd before scene change. +func _on_sim_connected_for_load(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void: + if new_state != SimBridge.ConnectionState.CONNECTED: + return + if SimBridge.connection_state_changed.is_connected(_on_sim_connected_for_load): + SimBridge.connection_state_changed.disconnect(_on_sim_connected_for_load) + _dispatch_pending_load() + + +func _dispatch_pending_load() -> void: + var load_path := GameState.pending_load_path + if load_path.is_empty(): + return + GameState.pending_load_path = "" + var err := SimBridge.send_input({ + "action": InputMapper.Action.LOAD_GAME, + "timestamp_msec": Time.get_ticks_msec(), + "action_data": {"path": load_path}, + }) + if err != OK: + push_error("main.gd: failed to send LOAD_GAME after connection — %s" % error_string(err)) + if loading_screen: + loading_screen.hide_loading(false) + + # #501: Detect large position jump indicating a teleport (not normal movement). const TELEPORT_DISTANCE_THRESHOLD: float = 5.0 diff --git a/client/tests/test_save_load_flow_sprint21.gd b/client/tests/test_save_load_flow_sprint21.gd new file mode 100644 index 000000000..6bf433e39 --- /dev/null +++ b/client/tests/test_save_load_flow_sprint21.gd @@ -0,0 +1,110 @@ +## Sprint 21 — Save/load game flow (#257) +## Tests: LoadingScreen overlay, pending_load_path field, deferred dispatch guards. +class_name TestSaveLoadFlowSprint21 +extends GdUnitTestSuite + + +const LOADING_SCREEN_SCENE_PATH: String = "res://ui/loading_screen.tscn" + + +func _make_loading_screen() -> Control: + if not ResourceLoader.exists(LOADING_SCREEN_SCENE_PATH): + push_warning("TestSaveLoadFlowSprint21: loading_screen.tscn not found — skip") + return null + var node: Control = load(LOADING_SCREEN_SCENE_PATH).instantiate() + add_child(node) + return node + + +func before_test() -> void: + GameState.pending_load_path = "" + + +func after_test() -> void: + GameState.pending_load_path = "" + + +# --------------------------------------------------------------------------- +# LoadingScreen scene +# --------------------------------------------------------------------------- + +func test_loading_screen_scene_exists() -> void: + assert_bool(ResourceLoader.exists(LOADING_SCREEN_SCENE_PATH)).override_failure_message( + "LoadingScreen scene must exist at res://ui/loading_screen.tscn (#257)" + ).is_true() + + +func test_loading_screen_hidden_on_ready() -> void: + var ls := _make_loading_screen() + if ls == null: return + assert_bool(ls.visible).override_failure_message( + "LoadingScreen must be hidden on _ready (#257)" + ).is_false() + ls.queue_free() + + +func test_show_loading_makes_visible() -> void: + var ls := _make_loading_screen() + if ls == null: return + ls.show_loading() + assert_bool(ls.visible).override_failure_message( + "show_loading() must make LoadingScreen visible" + ).is_true() + ls.queue_free() + + +func test_hide_loading_makes_invisible() -> void: + var ls := _make_loading_screen() + if ls == null: return + ls.show_loading() + ls.hide_loading() + assert_bool(ls.visible).override_failure_message( + "hide_loading() must hide LoadingScreen" + ).is_false() + ls.queue_free() + + +func test_hide_loading_accepts_success_param() -> void: + var ls := _make_loading_screen() + if ls == null: return + ls.show_loading() + ls.hide_loading(true) + assert_bool(ls.visible).is_false() + ls.show_loading() + ls.hide_loading(false) + assert_bool(ls.visible).is_false() + ls.queue_free() + + +func test_hide_loading_default_param_is_true() -> void: + var ls := _make_loading_screen() + if ls == null: return + ls.show_loading() + ls.hide_loading() # no argument — default success=true + assert_bool(ls.visible).is_false() + ls.queue_free() + + +# --------------------------------------------------------------------------- +# GameState.pending_load_path field +# --------------------------------------------------------------------------- + +func test_pending_load_path_default_is_empty() -> void: + GameState.pending_load_path = "" + assert_str(GameState.pending_load_path).override_failure_message( + "GameState.pending_load_path default must be empty string" + ).is_empty() + + +func test_pending_load_path_can_be_set_and_read() -> void: + var path := "user://saves/20260225-143022-a7b3f1/quicksave.sav" + GameState.pending_load_path = path + assert_str(GameState.pending_load_path).override_failure_message( + "GameState.pending_load_path must persist the value set" + ).is_equal(path) + + +func test_pending_load_path_can_be_cleared() -> void: + GameState.pending_load_path = "user://saves/test/quicksave.sav" + GameState.pending_load_path = "" + assert_str(GameState.pending_load_path).is_empty() diff --git a/client/ui/loading_screen.gd b/client/ui/loading_screen.gd index b87940d6a..c254e210b 100644 --- a/client/ui/loading_screen.gd +++ b/client/ui/loading_screen.gd @@ -39,5 +39,6 @@ func show_loading() -> void: visible = true -func hide_loading() -> void: +## Hide the loading overlay. success=false is reserved for future failure-state UI. +func hide_loading(success: bool = true) -> void: visible = false diff --git a/client/ui/main_menu.gd b/client/ui/main_menu.gd index 1b3c1a617..33405255d 100644 --- a/client/ui/main_menu.gd +++ b/client/ui/main_menu.gd @@ -41,6 +41,7 @@ func _refresh_continue_state() -> void: func _on_new_game() -> void: + GameState.pending_load_path = "" # clear stale load path from previous Load selection var game_id := SessionManager.new_game() if game_id.is_empty(): push_error("MainMenu: new_game() failed to create save directory — cannot start") @@ -49,6 +50,7 @@ func _on_new_game() -> void: func _on_continue() -> void: + GameState.pending_load_path = "" # clear stale load path from previous Load selection var saves := SessionManager.list_game_dirs() if saves.is_empty(): return @@ -56,13 +58,19 @@ func _on_continue() -> void: get_tree().change_scene_to_file(GAME_SCENE) +var _list_built: bool = false # guard against queue_free() race on rapid reopen + + func _on_load_game_browse() -> void: - _build_saves_list() + if not _list_built: + _build_saves_list() + _list_built = true _load_panel.visible = true func _on_load_back() -> void: _load_panel.visible = false + _list_built = false # allow rebuild on next open func _on_quit() -> void: @@ -87,18 +95,24 @@ func _build_saves_list() -> void: var btn := Button.new() btn.text = _format_save_entry(save) btn.add_theme_font_size_override("font_size", FONT_SIZE_BTN) - btn.add_theme_color_override("font_color", BTN_NORMAL_COLOR) - btn.pressed.connect(_on_save_selected.bind(save)) + var has_save_file: bool = not save.get("newest_save", "").is_empty() + if has_save_file: + btn.add_theme_color_override("font_color", BTN_NORMAL_COLOR) + btn.pressed.connect(_on_save_selected.bind(save)) + else: + btn.add_theme_color_override("font_color", BTN_DISABLED_COLOR) + btn.disabled = true _saves_list.add_child(btn) func _on_save_selected(save: Dictionary) -> void: var game_id: String = save.get("game_id", "") - var save_file: String = save.get("newest_save", "quicksave.sav") + var save_file: String = save.get("newest_save", "") + if save_file.is_empty(): + push_error("MainMenu: save entry '%s' has no newest_save — load cancelled" % game_id) + return SessionManager.resume_game(game_id) - # #257: Signal main.gd to send LOAD_GAME on scene startup - if not save_file.is_empty(): - GameState.pending_load_path = "user://saves/" + game_id + "/" + save_file + GameState.pending_load_path = "user://saves/" + game_id + "/" + save_file get_tree().change_scene_to_file(GAME_SCENE)