From cc6610af760e22ca0bf2ded9273b3f65c5bcd27c Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 13 Aug 2026 23:46:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=B8=A4=E7=BA=A7=E5=AD=98?= =?UTF-8?q?=E6=A1=A3=E7=B3=BB=E7=BB=9F=EF=BC=88=E6=B3=A8=E5=86=8C=E8=A1=A8?= =?UTF-8?q?=E5=BC=8F=E6=95=B0=E6=8D=AE=E6=94=B6=E9=9B=86=EF=BC=89=E5=B9=B6?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=B8=B8=E6=88=8F=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/SaveSystem.md | 139 +++++++++++++++++++++++++++++++++++++++++ project.godot | 1 - src/Core/GameState.gd | 18 ++++++ src/Core/SaveSystem.gd | 133 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 doc/SaveSystem.md diff --git a/doc/SaveSystem.md b/doc/SaveSystem.md new file mode 100644 index 0000000..12e8c2b --- /dev/null +++ b/doc/SaveSystem.md @@ -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 槽"的清理逻辑 diff --git a/project.godot b/project.godot index 257bb4f..8fa90eb 100644 --- a/project.godot +++ b/project.godot @@ -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] diff --git a/src/Core/GameState.gd b/src/Core/GameState.gd index 2c87b8e..e36c0ee 100644 --- a/src/Core/GameState.gd +++ b/src/Core/GameState.gd @@ -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: diff --git a/src/Core/SaveSystem.gd b/src/Core/SaveSystem.gd index 3ea4544..9491b29 100644 --- a/src/Core/SaveSystem.gd +++ b/src/Core/SaveSystem.gd @@ -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]: