实现两级存档系统(注册表式数据收集)并接入游戏时间
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
# 存档系统(SaveSystem)使用说明
|
||||
|
||||
Autoload 单例,路径 `res://src/Core/SaveSystem.gd`,已在 `project.godot` 注册。
|
||||
|
||||
## 架构
|
||||
|
||||
两级存档:
|
||||
|
||||
```
|
||||
存档组 SaveGroup(一次轮回/一个角色) profiles.json 中一条记录
|
||||
└── 存档槽 SaveSlot(进度快照) user://saves/profile_{组id}/slot_{时间戳}.json
|
||||
```
|
||||
|
||||
- 每个"新游戏"创建一个组,组 id 由 SaveSystem 分配(1、2、3…)
|
||||
- 每次 `save()` 在组下**新建**一个槽,槽与组数量无上限
|
||||
- 存档文件为 JSON,含 `version`、`date`、`data` 三段,`data` 为各系统数据合并后的字典
|
||||
|
||||
## 核心接口
|
||||
|
||||
| 接口 | 说明 |
|
||||
| --- | --- |
|
||||
| `create_profile() -> int` | 新建存档组,返回组 id。主菜单"新游戏"调用 |
|
||||
| `get_profiles() -> Array[Dictionary]` | 组列表,每条含 `id / name / created / last_date` |
|
||||
| `save(profile_id, data = {}) -> String` | 保存游戏,返回槽文件名。`data` 缺省时自动收集各系统数据 |
|
||||
| `load(profile_id, slot_name) -> Dictionary` | 读取槽位数据,槽不存在或版本高于当前返回 `{}` |
|
||||
| `list_slots(profile_id) -> Array[Dictionary]` | 组下槽列表(`name / date`),最新在前 |
|
||||
| `delete_slot(profile_id, slot_name)` | 删除槽位 |
|
||||
| `register_saver(callable) -> void` | 注册数据提供者(见下) |
|
||||
| `collect_data() -> Dictionary` | 手动收集所有注册系统的数据 |
|
||||
|
||||
## 各系统如何接入存档
|
||||
|
||||
新增需要存档的系统只需两步,**无需改动 SaveSystem**:
|
||||
|
||||
```gdscript
|
||||
# 例:SectSystem.gd
|
||||
extends Node
|
||||
|
||||
func _ready() -> void:
|
||||
SaveSystem.register_saver(_to_save_dict)
|
||||
|
||||
## 提供存档数据,返回字典,key 用系统名避免冲突
|
||||
func _to_save_dict() -> Dictionary:
|
||||
return {
|
||||
"sect": {
|
||||
"name": sect_name,
|
||||
"resources": resources,
|
||||
"members": members,
|
||||
},
|
||||
}
|
||||
|
||||
## 读档时恢复(由 MainGame 在 load 后按 key 分发调用)
|
||||
func load_from_dict(d: Dictionary) -> void:
|
||||
if not d.has("sect"):
|
||||
return
|
||||
var sect: Dictionary = d["sect"]
|
||||
sect_name = sect.get("name", "")
|
||||
...
|
||||
```
|
||||
|
||||
约定:
|
||||
- 每个系统实现 `_to_save_dict()`(提供数据)和 `load_from_dict()`(恢复状态)
|
||||
- key 用系统名(如 `"sect"`),避免系统间冲突
|
||||
- 读档用 `d.get(key, {})` / `.get(field, 默认值)` 容错,保证旧档兼容
|
||||
- 只把**游戏内数据**放进去;会话参数(当前 mode、profile_id 等)由 GameState 管理,不落档
|
||||
|
||||
### 示例:GameState 保存游戏时间
|
||||
|
||||
GameState 是 Autoload(持久对象),在其 `_ready` 中注册:
|
||||
|
||||
```gdscript
|
||||
# GameState.gd
|
||||
extends Node
|
||||
|
||||
var year: int = 1
|
||||
var month: int = 1
|
||||
|
||||
func _ready() -> void:
|
||||
SaveSystem.register_saver(_to_save_dict)
|
||||
|
||||
## 提供存档数据:key 为 "game_time",值是游戏时间字段
|
||||
func _to_save_dict() -> Dictionary:
|
||||
return {
|
||||
"game_time": {
|
||||
"year": year,
|
||||
"month": month,
|
||||
},
|
||||
}
|
||||
|
||||
## 读档恢复(MainGame 在 load 后调用 GameState.load_from_dict(data))
|
||||
func load_from_dict(d: Dictionary) -> void:
|
||||
var t: Dictionary = d.get("game_time", {})
|
||||
year = int(t.get("year", 1))
|
||||
month = int(t.get("month", 1))
|
||||
```
|
||||
|
||||
存档后 JSON 中的形态:
|
||||
|
||||
```json
|
||||
"game_time": { "year": 12, "month": 7 }
|
||||
```
|
||||
|
||||
要点:
|
||||
- `register_saver` 只接收无参 Callable,方法名直接传,**不要**写成 `register_saver(_to_save_dict())`(那是调用而不是注册)
|
||||
- `year`/`month` 读档用 `get()` 带默认值,旧存档没有该字段时也能启动
|
||||
|
||||
## 存档与读档流程
|
||||
|
||||
### 存档(回合结算 / 月度结算 / 关窗前)
|
||||
|
||||
```gdscript
|
||||
# TimeSystem 月度结算处
|
||||
var slot := SaveSystem.save(GameState.profile_id)
|
||||
print("已保存:%s" % slot)
|
||||
```
|
||||
|
||||
### 读档(主菜单进入游戏)
|
||||
|
||||
```gdscript
|
||||
# MainGame 场景启动时
|
||||
var data := SaveSystem.load(GameState.profile_id, GameState.save_slot)
|
||||
if data.is_empty():
|
||||
push_error("存档读取失败")
|
||||
return
|
||||
# 按 key 分发给各系统恢复
|
||||
SectSystem.load_from_dict(data)
|
||||
...
|
||||
|
||||
# 主菜单:列出组和槽供选择
|
||||
var profiles := SaveSystem.get_profiles()
|
||||
var slots := SaveSystem.list_slots(pid) # 选中某组后
|
||||
GameState.request_load_game(pid, slot["name"])
|
||||
```
|
||||
|
||||
## 存档时机建议(回合制)
|
||||
|
||||
- **每月结算时**自动 `save()` 一次,覆盖当前组新建槽
|
||||
- **关窗前**(`NOTIFICATION_WM_CLOSE_REQUEST`)补存一次,避免丢失当月操作
|
||||
- 不需要手动存档 UI;如担心槽无限增长,可加"保留最近 N 槽"的清理逻辑
|
||||
@@ -20,7 +20,6 @@ config/icon="res://icon.svg"
|
||||
GameState="*res://src/Core/GameState.gd"
|
||||
TimeSystem="*res://src/Core/TimeSystem.gd"
|
||||
Settings="*res://src/Core/Settings.gd"
|
||||
GameConfig="*res://src/Core/GameConfig.gd"
|
||||
SaveSystem="*res://src/Core/SaveSystem.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -16,6 +16,24 @@ var save_slot: String = ""
|
||||
var year: int = 1 # 年
|
||||
var month: int = 1 # 月(1-12)
|
||||
|
||||
func _ready() -> void:
|
||||
SaveSystem.register_saver(_to_save_dict)
|
||||
|
||||
## 提供存档数据:key 为 "game_time",值是游戏时间字段
|
||||
func _to_save_dict() -> Dictionary:
|
||||
return {
|
||||
"game_time": {
|
||||
"year": year,
|
||||
"month": month,
|
||||
},
|
||||
}
|
||||
|
||||
## 读档恢复(MainGame 在 load 后调用 GameState.load_from_dict(data))
|
||||
func load_from_dict(d: Dictionary) -> void:
|
||||
var t: Dictionary = d.get("game_time", {})
|
||||
year = int(t.get("year", 1))
|
||||
month = int(t.get("month", 1))
|
||||
|
||||
## 请求开始新游戏。
|
||||
## @param pid: 新存档组 ID(主菜单创建)
|
||||
func request_new_game(pid: int) -> void:
|
||||
|
||||
+130
-3
@@ -1,18 +1,34 @@
|
||||
## 存档系统(Autoload 单例)。
|
||||
## 两级存档:存档组 SaveGroup(一个角色/一次轮回)→ 存档槽 SaveSlot(该组下任意多个进度快照)。
|
||||
## 每个新游戏创建一个组;每次存档在该组下新建一个槽;组与槽数量无上限。
|
||||
## 文件:user://saves/profile_{组id}/{auto|slot_{时间戳}}.json
|
||||
## 文件:user://saves/profile_{组id}/slot_{时间戳}.json
|
||||
## 各系统通过 register_saver() 注册数据提供者,save() 自动收集,无需改动存档核心。
|
||||
extends Node
|
||||
|
||||
## 存档根目录。
|
||||
const SAVE_ROOT := "user://saves"
|
||||
## 存档组列表文件。
|
||||
const PROFILES_PATH := "user://saves/profiles.json"
|
||||
## 自动槽文件名(固定)。
|
||||
const AUTO_SLOT := "auto"
|
||||
## 存档格式版本(兼容迁移预留)。
|
||||
const VERSION := 1
|
||||
|
||||
var _savers: Array[Callable] = []
|
||||
|
||||
## 注册存档数据提供者(各系统在 _ready 中调用一次)。
|
||||
## @param c: 无参 Callable,返回 Dictionary(key 为系统名),如 `_to_save_dict`
|
||||
func register_saver(c: Callable) -> void:
|
||||
if not _savers.has(c):
|
||||
_savers.append(c)
|
||||
|
||||
## 遍历收集所有注册系统的数据,合并为一个大 Dictionary。
|
||||
func collect_data() -> Dictionary:
|
||||
var data := {}
|
||||
for c in _savers:
|
||||
var result: Variant = c.call()
|
||||
if result is Dictionary:
|
||||
data.merge(result)
|
||||
return data
|
||||
|
||||
## 新建存档组(主菜单"新游戏"调用),返回组 id(名"存档N")。
|
||||
func create_profile() -> int:
|
||||
var profiles := _load_profiles()
|
||||
@@ -28,7 +44,118 @@ func create_profile() -> int:
|
||||
_save_profiles(profiles)
|
||||
return id
|
||||
|
||||
func get_profiles() -> Array[Dictionary]:
|
||||
return _load_profiles()
|
||||
|
||||
## 保存游戏:在该组下新建一个槽位(文件名 slot_{时间戳}.json),返回槽文件名。
|
||||
## 不传 data 时自动调用 collect_data() 收集所有注册系统的数据。
|
||||
## @param profile_id: 存档组 ID
|
||||
## @param data: 要保存的游戏数据(可选,默认自动收集)
|
||||
func save(profile_id: int, data: Dictionary = {}) -> String:
|
||||
if data.is_empty():
|
||||
data = collect_data()
|
||||
var slot_name := "slot_%d_%03d" % [Time.get_unix_time_from_system(), Time.get_ticks_msec() % 1000]
|
||||
_write_slot(profile_id, slot_name, {
|
||||
"version": VERSION,
|
||||
"date": Time.get_datetime_string_from_system(),
|
||||
"data": data,
|
||||
})
|
||||
_update_profile_last_date(profile_id)
|
||||
return slot_name
|
||||
|
||||
## 读取指定槽位的游戏数据,槽不存在或损坏返回空 Dictionary。
|
||||
## 返回的 data 字典由调用方(MainGame)按 key 分发给各系统恢复状态。
|
||||
## @param profile_id: 存档组 ID
|
||||
## @param slot_name: 槽文件名(不含 .json,如 "slot_1754734800")
|
||||
func load(profile_id: int, slot_name: String) -> Dictionary:
|
||||
var path := "user://saves/profile_%d/%s.json" % [profile_id, slot_name]
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return {}
|
||||
var parsed: Variant = JSON.parse_string(f.get_as_text())
|
||||
if parsed is not Dictionary or int(parsed.get("version", 0)) > VERSION:
|
||||
return {}
|
||||
return parsed.get("data", {})
|
||||
|
||||
## 列出该组下所有槽位信息(文件名 + 存档日期),按时间倒序(最新在前)。
|
||||
## @param profile_id: 存档组 ID
|
||||
func list_slots(profile_id: int) -> Array[Dictionary]:
|
||||
var dir_path := "user://saves/profile_%d" % profile_id
|
||||
var da := DirAccess.open(dir_path)
|
||||
if da == null:
|
||||
return []
|
||||
var result: Array[Dictionary] = []
|
||||
da.list_dir_begin()
|
||||
var fname := da.get_next()
|
||||
while fname != "":
|
||||
if fname.ends_with(".json") and not da.current_is_dir():
|
||||
var path := "%s/%s" % [dir_path, fname]
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f != null:
|
||||
var parsed: Variant = JSON.parse_string(f.get_as_text())
|
||||
if parsed is Dictionary:
|
||||
result.append({
|
||||
"name": fname.trim_suffix(".json"),
|
||||
"date": str(parsed.get("date", "")),
|
||||
})
|
||||
fname = da.get_next()
|
||||
da.list_dir_end()
|
||||
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return a["name"] > b["name"])
|
||||
return result
|
||||
|
||||
## 删除指定槽位。
|
||||
func delete_slot(profile_id: int, slot_name: String) -> void:
|
||||
DirAccess.remove_absolute("user://saves/profile_%d/%s.json" % [profile_id, slot_name])
|
||||
|
||||
## 删除整个存档组(含其下所有槽位文件)。
|
||||
## @param profile_id: 存档组 ID
|
||||
func delete_profile(profile_id: int) -> void:
|
||||
var profiles := _load_profiles()
|
||||
profiles = profiles.filter(func(p: Dictionary) -> bool: return int(p["id"]) != profile_id)
|
||||
_save_profiles(profiles)
|
||||
_delete_dir_recursive("user://saves/profile_%d" % profile_id)
|
||||
|
||||
## 递归删除目录及其内容。
|
||||
func _delete_dir_recursive(path: String) -> void:
|
||||
var da := DirAccess.open(path)
|
||||
if da == null:
|
||||
return
|
||||
da.list_dir_begin()
|
||||
var fname := da.get_next()
|
||||
while fname != "":
|
||||
if fname in [".", ".."]:
|
||||
fname = da.get_next()
|
||||
continue
|
||||
if da.current_is_dir():
|
||||
_delete_dir_recursive("%s/%s" % [path, fname])
|
||||
else:
|
||||
DirAccess.remove_absolute("%s/%s" % [path, fname])
|
||||
fname = da.get_next()
|
||||
da.list_dir_end()
|
||||
da.remove(path)
|
||||
|
||||
## 写入槽位文件。
|
||||
func _write_slot(profile_id: int, slot_name: String, payload: Dictionary) -> void:
|
||||
var dir_path := "user://saves/profile_%d" % profile_id
|
||||
var da := DirAccess.open("user://")
|
||||
if da != null:
|
||||
da.make_dir_recursive(dir_path)
|
||||
var f := FileAccess.open("%s/%s.json" % [dir_path, slot_name], FileAccess.WRITE)
|
||||
if f == null:
|
||||
return
|
||||
f.store_string(JSON.stringify(payload, ""))
|
||||
|
||||
## 保存后更新该组的最近存档时间。
|
||||
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"] = Time.get_datetime_string_from_system()
|
||||
break
|
||||
_save_profiles(profiles)
|
||||
|
||||
## 读取存档组列表。
|
||||
func _load_profiles() -> Array[Dictionary]:
|
||||
|
||||
Reference in New Issue
Block a user