diff --git a/client/data/ui-strings.yaml b/client/data/ui-strings.yaml index 61906917c..4f1f8b581 100644 --- a/client/data/ui-strings.yaml +++ b/client/data/ui-strings.yaml @@ -109,6 +109,7 @@ notifications: load_failed: "Load failed." connection_lost: "Signal interrupted." connection_restored: "Signal restored." + loading: "Resuming..." # ============================================================ # KNOWLEDGE PANEL LABELS @@ -183,6 +184,9 @@ menu: confirm_quit: "Unsaved progress will be lost." confirm_yes: "Yes" confirm_no: "No" + load_game_browse: "LOAD GAME" + load_game_back: "BACK" + load_game_empty: "No saves found." settings: audio_volume: "Volume" diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 30c3381b7..e3d7600d6 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=26 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=27 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -21,10 +21,11 @@ [ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"] [ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"] [ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"] -[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"] +[ext_resource type="Script" path="res://ui/debug_overlay.gd" id="22_debug"] [ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"] [ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"] [ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"] +[ext_resource type="PackedScene" path="res://ui/loading_screen.tscn" id="26_loading"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -190,3 +191,6 @@ layer = 30 ; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle [node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")] + +; #257: Loading screen — full-screen overlay during save/load round-trip +[node name="LoadingScreen" parent="ModalLayer" instance=ExtResource("26_loading")] diff --git a/client/scenes/main_menu.tscn b/client/scenes/main_menu.tscn index b9cab3c20..09afdc2d1 100644 --- a/client/scenes/main_menu.tscn +++ b/client/scenes/main_menu.tscn @@ -59,8 +59,66 @@ text = "CONTINUE" theme_override_font_sizes/font_size = 15 theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0) +[node name="LoadGameBtn" type="Button" parent="VBox"] +layout_mode = 2 +text = "LOAD GAME" +theme_override_font_sizes/font_size = 15 +theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0) + [node name="QuitBtn" type="Button" parent="VBox"] layout_mode = 2 text = "QUIT" theme_override_font_sizes/font_size = 15 theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0) + +[node name="LoadGamePanel" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +visible = false + +[node name="PanelBg" type="ColorRect" parent="LoadGamePanel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.05, 0.05, 0.08, 0.96) +mouse_filter = 2 + +[node name="VBox" type="VBoxContainer" parent="LoadGamePanel"] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -160.0 +offset_top = -180.0 +offset_right = 160.0 +offset_bottom = 180.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 12 + +[node name="TitleLabel" type="Label" parent="LoadGamePanel/VBox"] +layout_mode = 2 +text = "LOAD GAME" +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 20 +theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0) + +[node name="SavesScroll" type="ScrollContainer" parent="LoadGamePanel/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(320, 240) + +[node name="SavesList" type="VBoxContainer" parent="LoadGamePanel/VBox/SavesScroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 + +[node name="BackBtn" type="Button" parent="LoadGamePanel/VBox"] +layout_mode = 2 +text = "BACK" +theme_override_font_sizes/font_size = 14 +theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 305e6cf69..a8a66f64c 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -75,6 +75,11 @@ var rng_seed: Variant = null # One-shot: consumed by main.gd after display, then set back to null. var save_result: Variant = null +# #257: Pending load path — set by main menu "Load Game" selection. +# main.gd sends LOAD_GAME on startup if non-empty, then clears this field. +# Format: user://saves//.sav or "" if no pending load. +var pending_load_path: String = "" + # v7 fields (#431, D-059/D-060) var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 54aed56ed..95ec6518a 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -21,6 +21,7 @@ extends Node2D @onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button @onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) +@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution @@ -49,6 +50,19 @@ 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. + 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() + # Camera anchor: snap to player position before the first frame renders. # In test mode poll_snapshot() returns synchronously — position is set # immediately. In live mode the snapshot isn't available yet — _process @@ -161,6 +175,10 @@ 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 + if input.action == InputMapper.Action.LOAD_GAME: + if loading_screen: + loading_screen.show_loading() # #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog if input.action == InputMapper.Action.OPEN_MENU: if settings_dialog: @@ -368,12 +386,15 @@ func _consume_dialogue_response() -> void: GameState.dialogue_response = null -# #554: Show save/load result notification from server response. +# #554/#257: Show save/load result notification; hide loading screen on load complete. func _consume_save_result() -> void: if GameState.save_result == null: return var result: Dictionary = GameState.save_result GameState.save_result = null # consume once + # #257: Dismiss loading screen regardless of success/failure + if loading_screen: + loading_screen.hide_loading() var msg: String if result.get("success", false): if result.get("kind", "") == "save": diff --git a/client/tests/test_debug_overlay_sprint19.gd b/client/tests/test_debug_overlay_sprint19.gd index b87810ada..1661dcaed 100644 --- a/client/tests/test_debug_overlay_sprint19.gd +++ b/client/tests/test_debug_overlay_sprint19.gd @@ -10,7 +10,7 @@ extends GdUnitTestSuite # --------------------------------------------------------------------------- const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn" -const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd" +const DEBUG_SCRIPT_PATH: String = "res://ui/debug_overlay.gd" func _make_overlay() -> Control: @@ -51,7 +51,7 @@ func after_test() -> void: func test_debug_overlay_script_exists() -> void: assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message( - "debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)" + "debug_overlay.gd must exist at res://ui/debug_overlay.gd (#348)" ).is_true() diff --git a/client/scripts/ui/debug_overlay.gd b/client/ui/debug_overlay.gd similarity index 100% rename from client/scripts/ui/debug_overlay.gd rename to client/ui/debug_overlay.gd diff --git a/client/scripts/ui/debug_overlay.gd.uid b/client/ui/debug_overlay.gd.uid similarity index 100% rename from client/scripts/ui/debug_overlay.gd.uid rename to client/ui/debug_overlay.gd.uid diff --git a/client/ui/loading_screen.gd b/client/ui/loading_screen.gd new file mode 100644 index 000000000..b87940d6a --- /dev/null +++ b/client/ui/loading_screen.gd @@ -0,0 +1,43 @@ +extends Control +## #257: Loading screen overlay — blocks input during save/load round-trip. +## Shown when LOAD_GAME fires; hidden when save_result arrives (success or failure). +## Full-screen, dark overlay with centered status text. + +const BG_COLOR := Color(0.0, 0.0, 0.0, 0.75) +const TEXT_COLOR := Color("#c8d0e0") +const FONT_SIZE := 18 + +var _label: Label = null + + +func _ready() -> void: + visible = false + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _build_ui() + + +func _build_ui() -> void: + var bg := ColorRect.new() + bg.color = BG_COLOR + bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + bg.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(bg) + + _label = Label.new() + _label.text = UIStrings.get_text("notifications.loading") + _label.add_theme_font_size_override("font_size", FONT_SIZE) + _label.add_theme_color_override("font_color", TEXT_COLOR) + _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _label.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_label) + + +func show_loading() -> void: + visible = true + + +func hide_loading() -> void: + visible = false diff --git a/client/ui/loading_screen.gd.uid b/client/ui/loading_screen.gd.uid new file mode 100644 index 000000000..29248a1a3 --- /dev/null +++ b/client/ui/loading_screen.gd.uid @@ -0,0 +1 @@ +uid://c6wtn7qk3mv2x diff --git a/client/ui/loading_screen.tscn b/client/ui/loading_screen.tscn new file mode 100644 index 000000000..388e82640 --- /dev/null +++ b/client/ui/loading_screen.tscn @@ -0,0 +1,15 @@ +[gd_scene load_steps=2 format=3 uid="uid://b7rv9mkl4qpw3"] + +[ext_resource type="Script" path="res://ui/loading_screen.gd" id="1_loading"] + +; #257: Loading screen — full-screen overlay shown during save/load round-trip. +; Blocks input; dismissed when save_result arrives from server. +[node name="LoadingScreen" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_loading") diff --git a/client/ui/main_menu.gd b/client/ui/main_menu.gd index 705abc86e..1b3c1a617 100644 --- a/client/ui/main_menu.gd +++ b/client/ui/main_menu.gd @@ -1,7 +1,8 @@ extends Control -## #258: Main menu — New Game / Continue / Quit. +## #258: Main menu — New Game / Continue / Load Game / Quit. ## New Game: generates per-game save directory (D-085), starts game. -## Continue: loads most recent save directory (#554 full loading screen deferred). +## Continue: loads most recent save directory. +## Load Game: shows sorted save list for manual selection (#257). const GAME_SCENE := "res://scenes/main.tscn" @@ -16,19 +17,27 @@ const FONT_SIZE_BTN := 15 @onready var _new_game_btn: Button = $VBox/NewGameBtn @onready var _continue_btn: Button = $VBox/ContinueBtn +@onready var _load_game_btn: Button = $VBox/LoadGameBtn @onready var _quit_btn: Button = $VBox/QuitBtn +@onready var _load_panel: Control = $LoadGamePanel +@onready var _saves_list: VBoxContainer = $LoadGamePanel/VBox/SavesScroll/SavesList +@onready var _load_back_btn: Button = $LoadGamePanel/VBox/BackBtn func _ready() -> void: _new_game_btn.pressed.connect(_on_new_game) _continue_btn.pressed.connect(_on_continue) + _load_game_btn.pressed.connect(_on_load_game_browse) _quit_btn.pressed.connect(_on_quit) + _load_back_btn.pressed.connect(_on_load_back) + _load_panel.visible = false _refresh_continue_state() func _refresh_continue_state() -> void: var saves := SessionManager.list_game_dirs() _continue_btn.disabled = saves.is_empty() + _load_game_btn.disabled = saves.is_empty() func _on_new_game() -> void: @@ -40,8 +49,6 @@ func _on_new_game() -> void: func _on_continue() -> void: - # #554: Full loading screen blocked until server SaveCommand lands. - # For now: automatically load the most recent game directory. var saves := SessionManager.list_game_dirs() if saves.is_empty(): return @@ -49,5 +56,61 @@ func _on_continue() -> void: get_tree().change_scene_to_file(GAME_SCENE) +func _on_load_game_browse() -> void: + _build_saves_list() + _load_panel.visible = true + + +func _on_load_back() -> void: + _load_panel.visible = false + + func _on_quit() -> void: get_tree().quit() + + +## Build the saves list panel from existing save directories, sorted by date (newest first). +func _build_saves_list() -> void: + for child in _saves_list.get_children(): + child.queue_free() + + var saves := SessionManager.list_game_dirs() + if saves.is_empty(): + var lbl := Label.new() + lbl.text = UIStrings.get_text("menu.load_game_empty") + lbl.add_theme_color_override("font_color", Color(BTN_DISABLED_COLOR)) + lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _saves_list.add_child(lbl) + return + + for save in saves: + 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)) + _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") + 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 + get_tree().change_scene_to_file(GAME_SCENE) + + +## Format a save entry for display. game_id format: YYYYMMDD-HHMMSS-hex6. +static func _format_save_entry(save: Dictionary) -> String: + var game_id: String = save.get("game_id", "") + var parts := game_id.split("-") + if parts.size() >= 2 and parts[0].length() == 8 and parts[1].length() == 6: + var d := parts[0] + var t := parts[1] + return "%s-%s-%s %s:%s:%s" % [ + d.substr(0, 4), d.substr(4, 2), d.substr(6, 2), + t.substr(0, 2), t.substr(2, 2), t.substr(4, 2), + ] + return game_id