- Defer LOAD_GAME dispatch until SimBridge reaches CONNECTED (critical) - Guard _build_saves_list() against queue_free() race on rapid reopen - Disable save entries with empty newest_save, guard in _on_save_selected - Send before show_loading on F6 quickload, skip overlay on send failure - Clear pending_load_path in _on_new_game()/_on_continue() (stale path) - Add hide_loading(success: bool) API for future failure-state UI - Add test_save_load_flow_sprint21.gd covering LoadingScreen + GameState Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+40
-11
@@ -50,18 +50,16 @@ func _ready() -> void:
|
|||||||
# Connect to simulation (test mode sets CONNECTED immediately)
|
# Connect to simulation (test mode sets CONNECTED immediately)
|
||||||
SimBridge.connect_to_sim()
|
SimBridge.connect_to_sim()
|
||||||
|
|
||||||
# #257: If returning from main menu "Load Game" selection, send load command immediately.
|
# #257: If returning from main menu "Load Game" selection, defer dispatch until connected.
|
||||||
# pending_load_path is set by main_menu.gd before changing scene; cleared after dispatch.
|
# 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():
|
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:
|
if loading_screen:
|
||||||
loading_screen.show_loading()
|
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.
|
# Camera anchor: snap to player position before the first frame renders.
|
||||||
# In test mode poll_snapshot() returns synchronously — position is set
|
# 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:
|
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
||||||
_toggle_journal()
|
_toggle_journal()
|
||||||
continue
|
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 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()
|
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
|
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||||
if input.action == InputMapper.Action.OPEN_MENU:
|
if input.action == InputMapper.Action.OPEN_MENU:
|
||||||
if settings_dialog:
|
if settings_dialog:
|
||||||
@@ -483,6 +486,32 @@ func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_stat
|
|||||||
gauntlet_hud.finalize()
|
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).
|
# #501: Detect large position jump indicating a teleport (not normal movement).
|
||||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -39,5 +39,6 @@ func show_loading() -> void:
|
|||||||
visible = true
|
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
|
visible = false
|
||||||
|
|||||||
+17
-3
@@ -41,6 +41,7 @@ func _refresh_continue_state() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _on_new_game() -> void:
|
func _on_new_game() -> void:
|
||||||
|
GameState.pending_load_path = "" # clear stale load path from previous Load selection
|
||||||
var game_id := SessionManager.new_game()
|
var game_id := SessionManager.new_game()
|
||||||
if game_id.is_empty():
|
if game_id.is_empty():
|
||||||
push_error("MainMenu: new_game() failed to create save directory — cannot start")
|
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:
|
func _on_continue() -> void:
|
||||||
|
GameState.pending_load_path = "" # clear stale load path from previous Load selection
|
||||||
var saves := SessionManager.list_game_dirs()
|
var saves := SessionManager.list_game_dirs()
|
||||||
if saves.is_empty():
|
if saves.is_empty():
|
||||||
return
|
return
|
||||||
@@ -56,13 +58,19 @@ func _on_continue() -> void:
|
|||||||
get_tree().change_scene_to_file(GAME_SCENE)
|
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:
|
func _on_load_game_browse() -> void:
|
||||||
|
if not _list_built:
|
||||||
_build_saves_list()
|
_build_saves_list()
|
||||||
|
_list_built = true
|
||||||
_load_panel.visible = true
|
_load_panel.visible = true
|
||||||
|
|
||||||
|
|
||||||
func _on_load_back() -> void:
|
func _on_load_back() -> void:
|
||||||
_load_panel.visible = false
|
_load_panel.visible = false
|
||||||
|
_list_built = false # allow rebuild on next open
|
||||||
|
|
||||||
|
|
||||||
func _on_quit() -> void:
|
func _on_quit() -> void:
|
||||||
@@ -87,17 +95,23 @@ func _build_saves_list() -> void:
|
|||||||
var btn := Button.new()
|
var btn := Button.new()
|
||||||
btn.text = _format_save_entry(save)
|
btn.text = _format_save_entry(save)
|
||||||
btn.add_theme_font_size_override("font_size", FONT_SIZE_BTN)
|
btn.add_theme_font_size_override("font_size", FONT_SIZE_BTN)
|
||||||
|
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.add_theme_color_override("font_color", BTN_NORMAL_COLOR)
|
||||||
btn.pressed.connect(_on_save_selected.bind(save))
|
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)
|
_saves_list.add_child(btn)
|
||||||
|
|
||||||
|
|
||||||
func _on_save_selected(save: Dictionary) -> void:
|
func _on_save_selected(save: Dictionary) -> void:
|
||||||
var game_id: String = save.get("game_id", "")
|
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)
|
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)
|
get_tree().change_scene_to_file(GAME_SCENE)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user