diff --git a/client/data/ui-strings.yaml b/client/data/ui-strings.yaml index 3f4d2d452..4cca59cca 100644 --- a/client/data/ui-strings.yaml +++ b/client/data/ui-strings.yaml @@ -170,6 +170,8 @@ dialogue: menu: pause_title: "Paused" resume: "Resume" + new_game: "New Game" + continue: "Continue" settings: "Settings" save_game: "Save" load_game: "Load" diff --git a/client/project.godot b/client/project.godot index 479146b4c..eee056cf3 100644 --- a/client/project.godot +++ b/client/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="The Settled Reach" -run/main_scene="res://scenes/main.tscn" +run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" @@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd" UIStrings="*res://scripts/autoloads/ui_strings.gd" FogState="*res://scripts/autoloads/fog_state.gd" AudioManager="*res://scripts/autoloads/audio_manager.gd" +SessionManager="*res://scripts/autoloads/session_manager.gd" [audio] diff --git a/client/scenes/main_menu.tscn b/client/scenes/main_menu.tscn new file mode 100644 index 000000000..b9cab3c20 --- /dev/null +++ b/client/scenes/main_menu.tscn @@ -0,0 +1,66 @@ +[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"] + +[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"] + +; Main menu — New Game / Continue / Quit. +; #258: D-085 per-game save directory created on New Game. + +[node name="MainMenu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_mainmenu") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.05, 0.05, 0.08, 1.0) +mouse_filter = 2 + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -120.0 +offset_top = -80.0 +offset_right = 120.0 +offset_bottom = 100.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 16 +alignment = 1 + +[node name="TitleLabel" type="Label" parent="VBox"] +layout_mode = 2 +text = "THE SETTLED REACH" +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 36 +theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0) + +[node name="Spacer" type="Control" parent="VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 24) + +[node name="NewGameBtn" type="Button" parent="VBox"] +layout_mode = 2 +text = "NEW GAME" +theme_override_font_sizes/font_size = 15 +theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0) + +[node name="ContinueBtn" type="Button" parent="VBox"] +layout_mode = 2 +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="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) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 881be4bd3..8294f030b 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -4,6 +4,11 @@ extends Node # Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}. # Tiles use format: [{x, y, z, type}]. var current_snapshot: Dictionary = {} + +# D-085 (#258): Active game session identifier. Format: -- +# Set by SessionManager.new_game() or SessionManager.resume_game(). +# Empty string when no session is active (main menu state). +var current_game_id: String = "" var current_tick: int = 0 var player_position: Vector2 = Vector2.ZERO var visible_entities: Array = [] diff --git a/client/scripts/autoloads/session_manager.gd b/client/scripts/autoloads/session_manager.gd new file mode 100644 index 000000000..e15148dcb --- /dev/null +++ b/client/scripts/autoloads/session_manager.gd @@ -0,0 +1,117 @@ +extends Node +## D-085 (#258): Game session lifecycle manager. +## Creates per-game save directories on New Game, resumes existing sessions, +## and handles quit-to-menu flow with save confirmation. +## +## All save dirs live under user://saves// where game-id is +## -- (e.g. "20260225-143022-a7b3f1"). + +const SAVES_DIR := "user://saves/" +const GAME_SCENE := "res://scenes/main.tscn" +const MENU_SCENE := "res://scenes/main_menu.tscn" + +var _quit_dialog: ConfirmationDialog = null + + +## Generate a new game-id, create its save directory, and activate the session. +## Returns the new game-id string. +func new_game() -> String: + var now := Time.get_datetime_dict_from_system() + var timestamp := "%04d%02d%02d-%02d%02d%02d" % [ + now.year, now.month, now.day, + now.hour, now.minute, now.second, + ] + var rng := RandomNumberGenerator.new() + rng.randomize() + var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF) + var game_id := "%s-%s" % [timestamp, hex_seed] + var save_path := SAVES_DIR + game_id + "/" + var err := DirAccess.make_dir_recursive_absolute(save_path) + if err != OK: + push_error("SessionManager: failed to create save dir %s: %s" % [ + save_path, error_string(err)]) + GameState.current_game_id = game_id + return game_id + + +## Resume an existing game session by setting the active game-id. +func resume_game(game_id: String) -> void: + GameState.current_game_id = game_id + + +## List all game directories under user://saves/ sorted by last-modified (most recent first). +## Returns Array of {game_id: String, modified_time: int, newest_save: String}. +func list_game_dirs() -> Array: + var dir := DirAccess.open(SAVES_DIR) + if dir == null: + return [] + var results: Array = [] + dir.list_dir_begin() + var entry := dir.get_next() + while entry != "": + if dir.current_is_dir() and not entry.begins_with("."): + var dir_path := SAVES_DIR + entry + "/" + var newest_save := _find_newest_save(dir_path) + var mtime: int = 0 + if newest_save != "": + mtime = FileAccess.get_modified_time(dir_path + newest_save) + results.append({ + "game_id": entry, + "modified_time": mtime, + "newest_save": newest_save, + }) + entry = dir.get_next() + dir.list_dir_end() + results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return a.modified_time > b.modified_time) + return results + + +## Show "Save before quitting?" confirmation dialog, then return to main menu. +## node: the calling scene node (used as dialog parent). +## #554: The actual F5 save will be wired here once server supports SaveCommand. +func quit_to_menu(node: Node) -> void: + if _quit_dialog != null and is_instance_valid(_quit_dialog): + return # Dialog already open + _quit_dialog = ConfirmationDialog.new() + _quit_dialog.title = "" + _quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit") + _quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes") + _quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no") + node.add_child(_quit_dialog) + _quit_dialog.confirmed.connect(_do_quit_to_menu) + _quit_dialog.canceled.connect(_cleanup_quit_dialog) + _quit_dialog.popup_centered() + + +func _do_quit_to_menu() -> void: + _cleanup_quit_dialog() + # #554: F5 quicksave will be triggered here before scene change when server + # supports SaveCommand. For now: navigate to menu without saving. + GameState.current_game_id = "" + get_tree().change_scene_to_file(MENU_SCENE) + + +func _cleanup_quit_dialog() -> void: + if _quit_dialog != null and is_instance_valid(_quit_dialog): + _quit_dialog.queue_free() + _quit_dialog = null + + +func _find_newest_save(dir_path: String) -> String: + var dir := DirAccess.open(dir_path) + if dir == null: + return "" + var best_name := "" + var best_time: int = 0 + dir.list_dir_begin() + var entry := dir.get_next() + while entry != "": + if not dir.current_is_dir() and entry.ends_with(".sav"): + var mtime := FileAccess.get_modified_time(dir_path + entry) + if mtime > best_time: + best_time = mtime + best_name = entry + entry = dir.get_next() + dir.list_dir_end() + return best_name diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index fa91fb3c3..f0466ddb5 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -66,8 +66,13 @@ func connect_to_sim() -> void: # Spawn server subprocess if not server_path.is_empty(): _server = ServerProcess.new() - # Server reads first positional arg as bind address (e.g. "127.0.0.1:9876") - var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)]) + # Server reads first positional arg as bind address (e.g. "127.0.0.1:9876"). + # D-085 (#258): pass --game-id so server logs use the same session identifier. + var args := ["127.0.0.1:" + str(server_port)] + var game_id: String = GameState.current_game_id + if not game_id.is_empty(): + args.append_array(["--game-id", game_id]) + var pid := _server.start(server_path, args) if pid <= 0: push_error("SimBridge: failed to start server") _set_state(ConnectionState.ERROR) diff --git a/client/tests/test_session_manager_sprint19.gd b/client/tests/test_session_manager_sprint19.gd new file mode 100644 index 000000000..44bc41d81 --- /dev/null +++ b/client/tests/test_session_manager_sprint19.gd @@ -0,0 +1,189 @@ +## Sprint 19 — Game session management (#258, D-085) +## Per-game save directories: created on New Game, resumed via game-id. +## SessionManager autoload: new_game(), resume_game(), list_game_dirs(). +class_name TestSessionManagerSprint19 +extends GdUnitTestSuite + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func before_test() -> void: + GameState.current_game_id = "" + + +func after_test() -> void: + GameState.current_game_id = "" + + +# --------------------------------------------------------------------------- +# GameState.current_game_id field +# --------------------------------------------------------------------------- + +func test_current_game_id_field_exists() -> void: + ## D-085: GameState must have current_game_id field. + assert_bool(GameState.has("current_game_id")).override_failure_message( + "GameState must have 'current_game_id' field (D-085 #258)" + ).is_true() + + +func test_current_game_id_default_is_empty_string() -> void: + ## Before any session starts, current_game_id is empty. + GameState.current_game_id = "" + assert_str(GameState.current_game_id).override_failure_message( + "GameState.current_game_id default must be empty string" + ).is_empty() + + +# --------------------------------------------------------------------------- +# SessionManager autoload exists +# --------------------------------------------------------------------------- + +func test_session_manager_autoload_exists() -> void: + ## SessionManager must be registered as an autoload. + var sm := Engine.get_singleton("SessionManager") + assert_that(sm != null).override_failure_message( + "SessionManager must be registered as autoload in project.godot (#258)" + ).is_true() + + +# --------------------------------------------------------------------------- +# new_game() — game-id format and GameState update +# --------------------------------------------------------------------------- + +func test_new_game_returns_non_empty_string() -> void: + var game_id := SessionManager.new_game() + assert_str(game_id).override_failure_message( + "SessionManager.new_game() must return a non-empty game-id string" + ).is_not_empty() + + +func test_new_game_sets_current_game_id_on_gamestate() -> void: + var game_id := SessionManager.new_game() + assert_str(GameState.current_game_id).override_failure_message( + "new_game() must set GameState.current_game_id" + ).is_equal(game_id) + + +func test_new_game_id_format_has_two_dashes() -> void: + ## Format: -- — two separator dashes. + var game_id := SessionManager.new_game() + var parts := game_id.split("-") + assert_int(parts.size()).override_failure_message( + "game-id must have format -- (3 parts separated by '-')" + ).is_equal(3) + + +func test_new_game_id_first_part_is_8_digits() -> void: + ## First part is YYYYMMDD — 8 decimal digits. + var game_id := SessionManager.new_game() + var parts := game_id.split("-") + assert_int(parts[0].length()).override_failure_message( + "game-id first part (date) must be 8 characters (YYYYMMDD)" + ).is_equal(8) + + +func test_new_game_id_second_part_is_6_digits() -> void: + ## Second part is HHMMSS — 6 decimal digits. + var game_id := SessionManager.new_game() + var parts := game_id.split("-") + assert_int(parts[1].length()).override_failure_message( + "game-id second part (time) must be 6 characters (HHMMSS)" + ).is_equal(6) + + +func test_new_game_id_third_part_is_6_hex_chars() -> void: + ## Third part is 6 hex characters (RNG seed). + var game_id := SessionManager.new_game() + var parts := game_id.split("-") + assert_int(parts[2].length()).override_failure_message( + "game-id third part (hex seed) must be 6 characters" + ).is_equal(6) + + +func test_new_game_ids_are_unique() -> void: + ## Two rapid new_game() calls should produce different IDs + ## (different RNG seeds; same-second timestamps are valid but seeds differ). + var id1 := SessionManager.new_game() + var id2 := SessionManager.new_game() + # Check that hex seeds differ (they almost certainly will) + var seed1 := id1.split("-")[2] + var seed2 := id2.split("-")[2] + assert_str(seed1).override_failure_message( + "Successive new_game() calls should have different RNG seeds" + ).is_not_equal(seed2) + + +# --------------------------------------------------------------------------- +# resume_game() — sets GameState.current_game_id +# --------------------------------------------------------------------------- + +func test_resume_game_sets_current_game_id() -> void: + var test_id := "20260225-143022-a7b3f1" + SessionManager.resume_game(test_id) + assert_str(GameState.current_game_id).override_failure_message( + "resume_game() must set GameState.current_game_id to the given id" + ).is_equal(test_id) + + +func test_resume_game_overwrites_previous_game_id() -> void: + SessionManager.resume_game("20260225-100000-aabbcc") + SessionManager.resume_game("20260225-120000-112233") + assert_str(GameState.current_game_id).is_equal("20260225-120000-112233") + + +# --------------------------------------------------------------------------- +# Main menu scene +# --------------------------------------------------------------------------- + +func test_main_menu_scene_exists() -> void: + assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message( + "Main menu scene must exist at res://scenes/main_menu.tscn (#258)" + ).is_true() + + +func test_main_menu_instantiates_without_crash() -> void: + if not ResourceLoader.exists("res://scenes/main_menu.tscn"): + push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip") + return + var scene: Node = load("res://scenes/main_menu.tscn").instantiate() + auto_free(scene) + add_child(scene) + assert_that(scene).is_not_null() + + +func test_main_menu_has_new_game_button() -> void: + if not ResourceLoader.exists("res://scenes/main_menu.tscn"): + return + var scene: Node = load("res://scenes/main_menu.tscn").instantiate() + auto_free(scene) + add_child(scene) + var btn := scene.get_node_or_null("VBox/NewGameBtn") + assert_that(btn != null).override_failure_message( + "Main menu must have VBox/NewGameBtn (#258)" + ).is_true() + + +func test_main_menu_has_continue_button() -> void: + if not ResourceLoader.exists("res://scenes/main_menu.tscn"): + return + var scene: Node = load("res://scenes/main_menu.tscn").instantiate() + auto_free(scene) + add_child(scene) + var btn := scene.get_node_or_null("VBox/ContinueBtn") + assert_that(btn != null).override_failure_message( + "Main menu must have VBox/ContinueBtn (#258)" + ).is_true() + + +# --------------------------------------------------------------------------- +# Project main scene changed to main_menu.tscn +# --------------------------------------------------------------------------- + +func test_project_main_scene_is_main_menu() -> void: + ## D-085: project boots to main menu, not directly to game scene. + var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "") + assert_str(scene_path).override_failure_message( + "project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)" + ).is_equal("res://scenes/main_menu.tscn") diff --git a/client/ui/main_menu.gd b/client/ui/main_menu.gd new file mode 100644 index 000000000..0c6e4a22b --- /dev/null +++ b/client/ui/main_menu.gd @@ -0,0 +1,50 @@ +extends Control +## #258: Main menu — New Game / Continue / Quit. +## New Game: generates per-game save directory (D-085), starts game. +## Continue: loads most recent save directory (#554 full loading screen deferred). + +const GAME_SCENE := "res://scenes/main.tscn" + +const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0) +const TITLE_COLOR := Color("#c8d0e0") +const SUBTITLE_COLOR := Color("#8890a0") +const BTN_NORMAL_COLOR := Color("#e8c547") +const BTN_DISABLED_COLOR := Color("#4a5060") +const FONT_SIZE_TITLE := 36 +const FONT_SIZE_SUBTITLE := 14 +const FONT_SIZE_BTN := 15 + +@onready var _new_game_btn: Button = $VBox/NewGameBtn +@onready var _continue_btn: Button = $VBox/ContinueBtn +@onready var _quit_btn: Button = $VBox/QuitBtn + + +func _ready() -> void: + _new_game_btn.pressed.connect(_on_new_game) + _continue_btn.pressed.connect(_on_continue) + _quit_btn.pressed.connect(_on_quit) + _refresh_continue_state() + + +func _refresh_continue_state() -> void: + var saves := SessionManager.list_game_dirs() + _continue_btn.disabled = saves.is_empty() + + +func _on_new_game() -> void: + SessionManager.new_game() + get_tree().change_scene_to_file(GAME_SCENE) + + +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 + SessionManager.resume_game(saves[0].game_id) + get_tree().change_scene_to_file(GAME_SCENE) + + +func _on_quit() -> void: + get_tree().quit() diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index 588e9b4eb..55ed74591 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -121,6 +121,14 @@ func _build_ui() -> void: close_btn.pressed.connect(close) _container.add_child(close_btn) + # Quit to Menu button (#258: D-085 session management) + var quit_btn := Button.new() + quit_btn.text = UIStrings.get_text("menu.quit_to_menu") + quit_btn.add_theme_font_size_override("font_size", FONT_SIZE) + quit_btn.add_theme_color_override("font_color", Color("#c87040")) + quit_btn.pressed.connect(_on_quit_to_menu) + _container.add_child(quit_btn) + func _destroy_ui() -> void: if _container: @@ -153,6 +161,11 @@ func _draw() -> void: HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR) +func _on_quit_to_menu() -> void: + close() + SessionManager.quit_to_menu(get_tree().root) + + static func _format_db(db: float) -> String: if db <= -40.0: return "mute"