feat: 两级存档系统——无限组/槽,建组建槽/自动档/两级浏览/读档还原/删除组与槽/游戏内加载(D8 完成)
This commit is contained in:
+14
-8
@@ -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
@@ -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,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"]
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwt83xydsgcir
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user