feat(client): game session management (#258, D-085)
Per-game save directories under user://saves/<timestamp>-<seed>/. SessionManager autoload handles new_game(), resume_game(), quit flow. Main menu scene with New Game / Continue / Quit buttons. Game-id passed to server subprocess via --game-id flag. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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: <YYYYMMDD>-<HHMMSS>-<hex6>
|
||||
# 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 = []
|
||||
|
||||
@@ -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/<game-id>/ where game-id is
|
||||
## <YYYYMMDD>-<HHMMSS>-<hex6> (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
|
||||
@@ -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 <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)
|
||||
|
||||
@@ -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: <YYYYMMDD>-<HHMMSS>-<hex6> — 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 <YYYYMMDD>-<HHMMSS>-<hex6> (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")
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user