feat: 两级存档系统——无限组/槽,建组建槽/自动档/两级浏览/读档还原/删除组与槽/游戏内加载(D8 完成)

This commit is contained in:
2026-08-09 19:17:21 +08:00
parent 2bb1486df6
commit b687ab153d
19 changed files with 851 additions and 63 deletions
+14 -8
View File
@@ -7,24 +7,30 @@ enum GameMode { NEW_GAME, LOAD_GAME }
## 当前进入游戏的方式。
var mode: GameMode = GameMode.NEW_GAME
## 要加载的存档 ID仅 LOAD_GAME 时有效)。
var save_id: String = ""
## 当前存档 ID两级存档第一级,SaveSystem 分配)。
var profile_id: int = 0
## 要加载的槽位文件名(仅 LOAD_GAME 时有效,如 "auto"/"slot_1754734800")。
var save_slot: String = ""
# 游戏内时间(12 月 × 30 天 = 360 天/年),实际推进由 TimeSystem 负责
var year: int = 1
var month: int = 1
var day: int = 1
## 请求开始新游戏:重置模式与时间
func request_new_game() -> void:
## 请求开始新游戏。
## @param pid: 新存档组 ID(主菜单创建)
func request_new_game(pid: int) -> void:
mode = GameMode.NEW_GAME
save_id = ""
profile_id = pid
save_slot = ""
year = 1
month = 1
day = 1
## 请求加载指定存档。
## @param id: 存档 ID
func request_load_game(id: String) -> void:
## @param pid: 存档 ID
## @param slot_name: 槽位文件名(不含 .json
func request_load_game(pid: int, slot_name: String) -> void:
mode = GameMode.LOAD_GAME
save_id = id
profile_id = pid
save_slot = slot_name
+25 -8
View File
@@ -7,16 +7,29 @@ func _ready() -> void:
add_to_group("game_scene")
# 应用用户设置中的默认流速
TimeSystem.apply_default_speed()
# 开局招募:清空旧数据 → 生成 5 名候选 → 暂停时间等待选择(读档流程接入后由存档还原)
DiscipleManager.start_new_game()
# 开局建筑:清空旧建筑(读档流程接入后由存档还原)
BuildingManager.reset()
# 开局赠送资源(数量来自配置;读档流程接入后由存档还原)
SectManager.add_spirit_stones(GameConfig.starting_spirit_stones)
SectManager.add_food(GameConfig.starting_food)
match GameState.mode:
GameState.GameMode.NEW_GAME:
_start_new_game()
GameState.GameMode.LOAD_GAME:
# 读档还原(含日期/资源/名录/建筑);失败回退新游戏
if not SaveSystem.load_game(GameState.profile_id, GameState.save_slot):
push_warning("读档失败,回退新游戏")
_start_new_game()
## 顶部 HUD 的返回主菜单请求
## 新游戏初始化:清空旧数据 → 开局招募弹窗(暂停)→ 设定开局资源
func _start_new_game() -> void:
# 开局招募:清空旧数据 → 生成 5 名候选 → 暂停时间等待选择
DiscipleManager.start_new_game()
# 开局建筑:清空旧建筑
BuildingManager.reset()
# 开局资源:直接设定(set 而非 add,避免与上次会话残留混淆)
SectManager.set_spirit_stones(GameConfig.starting_spirit_stones)
SectManager.set_food(GameConfig.starting_food)
SectManager.set_reputation(0)
## 顶部 HUD 的返回主菜单请求(先写自动存档)。
func _on_top_bar_hud_sign_return_mainmenu() -> void:
SaveSystem.save_auto(GameState.profile_id)
get_tree().change_scene_to_file("res://src/UI/Panels/MainMenu/MainMenu.tscn")
## 顶部 HUD 的"管理"按钮请求:打开弟子名录面板。
@@ -26,3 +39,7 @@ func _on_top_bar_hud_sign_open_disciples() -> void:
## 顶部 HUD 的"资源"按钮请求:打开建筑面板。
func _on_top_bar_hud_sign_open_buildings() -> void:
$BuildingsPanel.show_panel()
## 顶部 HUD 的"存档"按钮请求:打开存档面板(存档模式)。
func _on_top_bar_hud_sign_open_save() -> void:
$SavePanel.open_save()
+5
View File
@@ -5,6 +5,7 @@
[ext_resource type="PackedScene" uid="uid://dl0tb68vsw3c6" path="res://src/UI/HUD/TopBarHUD.tscn" id="1_g6xmk"]
[ext_resource type="PackedScene" uid="uid://cahbpjpnmbcfv" path="res://src/UI/Panels/RecruitPanel/RecruitPanel.tscn" id="1_k5nrm"]
[ext_resource type="PackedScene" path="res://src/UI/Panels/BuildingsPanel/BuildingsPanel.tscn" id="1_b9dpm"]
[ext_resource type="PackedScene" uid="uid://dj7b5vssydifa" path="res://src/UI/Panels/SavePanel.tscn" id="1_s4vpm"]
[ext_resource type="Texture2D" uid="uid://cweu17mwblxmg" path="res://assets/sprites/ui/宗门后山.png" id="2_2atgf"]
[ext_resource type="Texture2D" uid="uid://dnjuv1btbxm4o" path="res://assets/sprites/ui/宗门全景.png" id="5_58g0x"]
@@ -38,6 +39,10 @@ layout_mode = 3
[node name="BuildingsPanel" parent="." unique_id=795000012 instance=ExtResource("1_b9dpm")]
[node name="SavePanel" parent="." unique_id=794000020 instance=ExtResource("1_s4vpm")]
visible = false
[connection signal="sign_open_buildings" from="TopBarHud" to="." method="_on_top_bar_hud_sign_open_buildings"]
[connection signal="sign_open_disciples" from="TopBarHud" to="." method="_on_top_bar_hud_sign_open_disciples"]
[connection signal="sign_open_save" from="TopBarHud" to="." method="_on_top_bar_hud_sign_open_save"]
[connection signal="sign_return_mainmenu" from="TopBarHud" to="." method="_on_top_bar_hud_sign_return_mainmenu"]
+286
View File
@@ -0,0 +1,286 @@
## 存档系统(Autoload 单例)。
## 两级存档:存档组 SaveGroup(一个角色/一次轮回)→ 存档槽 SaveSlot(该组下任意多个进度快照)。
## 每个新游戏创建一个组;每次存档在该组下新建一个槽;组与槽数量无上限。
## 文件:user://saves/profile_{组id}/{auto|slot_{时间戳}}.json
extends Node
## 存档根目录。
const SAVE_ROOT := "user://saves"
## 存档组列表文件。
const PROFILES_PATH := "user://saves/profiles.json"
## 自动槽文件名(固定)。
const AUTO_SLOT := "auto"
## 存档格式版本(兼容迁移预留)。
const VERSION := 1
## 新建存档组(主菜单"新游戏"调用),返回组 id(名"存档N")。
func create_profile() -> int:
var profiles := _load_profiles()
var id := 1
for p in profiles:
id = maxi(id, int(p["id"]) + 1)
profiles.append({
"id": id,
"name": "存档%d" % id,
"created": Time.get_date_string_from_system(),
"last_date": "",
})
_save_profiles(profiles)
return id
## 存档组列表(id/name/created/last_date)。
func get_profiles() -> Array[Dictionary]:
return _load_profiles()
## 删除存档组:从列表移除并删除该组目录(含全部槽位)。
## @param profile_id: 存档组 ID
## @return 是否删除成功
func delete_profile(profile_id: int) -> bool:
var profiles := _load_profiles()
var removed := false
for i in profiles.size():
if int(profiles[i]["id"]) == profile_id:
profiles.remove_at(i)
removed = true
break
if not removed:
return false
_save_profiles(profiles)
_remove_dir_recursive("%s/profile_%d" % [SAVE_ROOT, profile_id])
return true
## 保存游戏:在指定组下**新建一个槽位**,返回槽文件名;失败返回空串。
## 槽位命名:组内序号递增(slot_1、slot_2…),删除后从当前最大序号 +1 继续,不重用。
## @param profile_id: 存档组 ID
func save_game(profile_id: int) -> String:
_ensure_profile_dir(profile_id)
var slot_name := "slot_%d" % _next_slot_index(profile_id)
if not _write_slot(profile_id, slot_name):
return ""
_update_profile_last_date(profile_id)
return slot_name
## 保存到自动槽(返回主菜单时调用)。
## @param profile_id: 存档组 ID
func save_auto(profile_id: int) -> bool:
_ensure_profile_dir(profile_id)
return _write_slot(profile_id, AUTO_SLOT)
## 该组全部槽位(含自动槽,自动槽排最前;手动槽按时间升序)。
## 返回 [{name, date_text, disciple_count, spirit_stones}]。
## @param profile_id: 存档组 ID
func get_slots(profile_id: int) -> Array[Dictionary]:
var result: Array[Dictionary] = []
var dir := DirAccess.open("%s/profile_%d" % [SAVE_ROOT, profile_id])
if dir == null:
return result
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if not dir.current_is_dir() and file_name.ends_with(".json"):
var meta := get_slot_meta(profile_id, file_name.trim_suffix(".json"))
if not meta.is_empty():
meta["name"] = file_name.trim_suffix(".json")
result.append(meta)
file_name = dir.get_next()
dir.list_dir_end()
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var a_auto: bool = a["name"] == AUTO_SLOT
var b_auto: bool = b["name"] == AUTO_SLOT
if a_auto != b_auto:
return a_auto
return str(a["name"]) < str(b["name"])
)
return result
## 加载存档:清空当前数据后还原各系统。
## @param profile_id: 存档组 ID
## @param slot_name: 槽文件名(不含 .json
## @return 是否加载成功
func load_game(profile_id: int, slot_name: String) -> bool:
var f := FileAccess.open(_slot_path(profile_id, slot_name), FileAccess.READ)
if f == null:
push_warning("存档不存在: " + _slot_path(profile_id, slot_name))
return false
var parsed: Variant = JSON.parse_string(f.get_as_text())
if parsed is not Dictionary:
push_warning("存档解析失败: " + _slot_path(profile_id, slot_name))
return false
_restore(parsed)
return true
## 删除槽位文件。
## @param profile_id: 存档组 ID
## @param slot_name: 槽文件名(不含 .json
## @return 是否删除成功
func delete_slot(profile_id: int, slot_name: String) -> bool:
if slot_name == AUTO_SLOT:
return false
var path := _slot_path(profile_id, slot_name)
if not FileAccess.file_exists(path):
return false
return DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK
## 槽位元数据(列表显示用:日期/弟子数/灵石),空档返回空字典。
## @param profile_id: 存档组 ID
## @param slot_name: 槽文件名(不含 .json
func get_slot_meta(profile_id: int, slot_name: String) -> Dictionary:
var f := FileAccess.open(_slot_path(profile_id, slot_name), FileAccess.READ)
if f == null:
return {}
var parsed: Variant = JSON.parse_string(f.get_as_text())
if parsed is not Dictionary:
return {}
var meta: Dictionary = parsed
var d: Dictionary = meta["saved_date"]
return {
"date_text": "%d%d%d" % [d["year"], d["month"], d["day"]],
"disciple_count": int(meta["disciples"].size()),
"spirit_stones": int(meta["resources"]["spirit_stones"]),
}
## 清空内存中所有游戏数据(读档/新游戏前调用)。
func reset() -> void:
DiscipleManager.reset()
BuildingManager.reset()
SectManager.set_spirit_stones(0)
SectManager.set_reputation(0)
SectManager.set_food(0)
## 收集当前游戏数据并写入槽位文件。
## @param profile_id: 存档组 ID
## @param slot_name: 槽文件名(不含 .json
func _write_slot(profile_id: int, slot_name: String) -> bool:
var data := _collect_data(profile_id)
var f := FileAccess.open(_slot_path(profile_id, slot_name), FileAccess.WRITE)
if f == null:
push_warning("存档写入失败: " + _slot_path(profile_id, slot_name))
return false
f.store_string(JSON.stringify(data, "\t"))
return true
## 收集当前游戏数据为字典。
## @param profile_id: 存档组 ID
func _collect_data(profile_id: int) -> Dictionary:
var disciples_data: Array = []
for d in DiscipleManager.disciples:
disciples_data.append(d.to_dict())
var buildings_data: Array = []
for b in BuildingManager.buildings:
buildings_data.append(b.to_dict())
return {
"version": VERSION,
"profile_id": profile_id,
"saved_date": {"year": GameState.year, "month": GameState.month, "day": GameState.day},
"date": {"year": GameState.year, "month": GameState.month, "day": GameState.day},
"resources": {
"spirit_stones": SectManager.spirit_stones,
"reputation": SectManager.reputation,
"food": SectManager.food,
},
"next_disciple_id": DiscipleManager.get_next_disciple_id(),
"disciples": disciples_data,
"buildings": buildings_data,
}
## 将存档数据还原到各系统(setter 带信号,HUD 自动刷新)。
## @param data: 存档字典
func _restore(data: Dictionary) -> void:
reset()
var d: Dictionary = data["date"]
TimeSystem.set_date(int(d["year"]), int(d["month"]), int(d["day"]))
var r: Dictionary = data["resources"]
SectManager.set_spirit_stones(int(r["spirit_stones"]))
SectManager.set_reputation(int(r["reputation"]))
SectManager.set_food(int(r["food"]))
DiscipleManager.set_next_disciple_id(int(data["next_disciple_id"]))
for item in data["disciples"]:
DiscipleManager.disciples.append(Disciple.from_dict(item))
DiscipleManager.disciples_changed.emit()
for item in data["buildings"]:
BuildingManager.buildings.append(Building.from_dict(item))
BuildingManager.buildings_changed.emit()
## 槽位文件路径。
func _slot_path(profile_id: int, slot_name: String) -> String:
return "%s/profile_%d/%s.json" % [SAVE_ROOT, profile_id, slot_name]
## 确保存档组目录存在。
func _ensure_profile_dir(profile_id: int) -> void:
var da := DirAccess.open("user://")
if da == null:
return
da.make_dir_recursive("saves")
da.make_dir_recursive("saves/profile_%d" % profile_id)
## 递归删除目录(Godot 无原生递归删除,手动遍历)。
## @param path: 目录路径(user:// 风格)
func _remove_dir_recursive(path: String) -> void:
var da := DirAccess.open(path)
if da == null:
return
da.list_dir_begin()
var f := da.get_next()
while f != "":
if da.current_is_dir():
_remove_dir_recursive(path + "/" + f)
else:
DirAccess.remove_absolute(ProjectSettings.globalize_path(path + "/" + f))
f = da.get_next()
da.list_dir_end()
da.remove(da.get_current_dir())
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
## 该组下一个槽位序号(现有最大 slot_N 序号 +1,删除后不重用)。
## @param profile_id: 存档组 ID
func _next_slot_index(profile_id: int) -> int:
var max_index := 0
var dir := DirAccess.open("%s/profile_%d" % [SAVE_ROOT, profile_id])
if dir == null:
return 1
dir.list_dir_begin()
var f := dir.get_next()
while f != "":
if f.begins_with("slot_") and f.ends_with(".json"):
var idx := int(f.trim_suffix(".json").trim_prefix("slot_"))
max_index = maxi(max_index, idx)
f = dir.get_next()
dir.list_dir_end()
return max_index + 1
## 读取存档组列表。
func _load_profiles() -> Array[Dictionary]:
if not FileAccess.file_exists(PROFILES_PATH):
return []
var f := FileAccess.open(PROFILES_PATH, FileAccess.READ)
if f == null:
return []
var parsed: Variant = JSON.parse_string(f.get_as_text())
if parsed is not Dictionary or not parsed.has("profiles"):
return []
var result: Array[Dictionary] = []
for p in parsed["profiles"]:
result.append(p)
return result
## 写入存档组列表。
## @param profiles: 存档组列表
func _save_profiles(profiles: Array[Dictionary]) -> void:
var da := DirAccess.open("user://")
if da != null:
da.make_dir_recursive("saves")
var f := FileAccess.open(PROFILES_PATH, FileAccess.WRITE)
if f == null:
return
f.store_string(JSON.stringify({"profiles": profiles}, "\t"))
## 更新存档组的最后存档日期。
## @param profile_id: 存档组 ID
func _update_profile_last_date(profile_id: int) -> void:
var profiles := _load_profiles()
for p in profiles:
if int(p["id"]) == profile_id:
p["last_date"] = "%d%d%d" % [GameState.year, GameState.month, GameState.day]
break
_save_profiles(profiles)
+1
View File
@@ -0,0 +1 @@
uid://bwt83xydsgcir
+15
View File
@@ -33,3 +33,18 @@ func add_food(amount: int) -> void:
func remove_food(amount: int) -> void:
food -= amount
emit_signal("food_changed", food, -amount)
func set_spirit_stones(value: int) -> void:
var delta := value - spirit_stones
spirit_stones = value
spirit_stones_changed.emit(spirit_stones, delta)
func set_reputation(value: int) -> void:
var delta := value - reputation
reputation = value
reputation_changed.emit(reputation, delta)
func set_food(value: int) -> void:
var delta := value - food
food = value
food_changed.emit(food, delta)