存档系统
存档文件是一个游戏状态的序列化快照,可在重启后继续存在。 难点不在于写入字节——而在于选择*要保存什么*、确保保存中途崩溃也不会损坏文件, 以及发布补丁后仍能读取*旧*存档。把这三件事做好,其余都是管道工作。
何时使用
- 用于持久化进度:玩家属性、物品栏、世界标记、设置、
- 用于设计存档槽位、快速存档/自动存档,以及崩溃安全写入。
- 用于内容/代码变更后旧存档损坏的情况(版本控制与
位置——跨会话和游戏更新。
迁移)。
不适用的情况:对于 Roblox 云持久化具体细节,使用 roblox-datastores。对于存档所序列化的数据模型(resources/SOs),使用 godot-resources / unity-scriptableobjects。对于 Godot 的 FileAccess/ ResourceSaver 和 user:// 路径,在应用这里模式的同时,请遵循 Godot 引擎技能。
核心工作流
- 决定哪些状态是权威的。保存*数据*(hp、position、seed、
- 定义带版本号的 schema。每个存档都嵌入一个
version整数。对于打算持续补丁的游戏,这是最重要的字段。 - 选择格式。JSON/文本便于阅读和调试;二进制格式
- 原子写入。序列化到临时文件,刷新,然后重命名覆盖
- 防御式加载。读取 version → 迁移到当前版本 → 校验 →
- 在安全边界自动存档(关卡切换、检查点),并进行限频,且写入
- 验证:保存、完全退出、重新启动、加载——并通过检查确认
解锁标记),而不是引擎对象或场景节点。加载时你将 从数据重建对象——绝不序列化活动节点引用。
用于大小/速度或轻度防篡改。从 JSON 开始。
真实文件。崩溃只会留下旧存档或新存档——绝不会留下 半写入的文件。
实例化。保留最后一次有效存档的备份,并在解析错误时回退。
单独槽位,以免覆盖手动存档。
状态匹配。测试加载上一版本的存档。
模式
1. 将状态序列化为纯数据(而非引擎对象)
# Build a dictionary of pure data. Each savable object reports its own state.
func capture_state() -> Dictionary:
return {
"version": SAVE_VERSION, # ALWAYS stamp the schema version
"player": { "hp": player.hp, "pos": [player.position.x, player.position.y] },
"inventory": player.inventory.to_array(), # ids + counts, not Item nodes
"flags": world.flags, # e.g. {"met_guard": true}
"seed": world.seed, # regenerate procedural content
}
# On load, RECONSTRUCT objects from the data — do not expect live references back.
func apply_state(data: Dictionary) -> void:
player.hp = data["player"]["hp"]
player.position = Vector2(data["player"]["pos"][0], data["player"]["pos"][1])
player.inventory.from_array(data["inventory"])
world.flags = data["flags"]
2. 原子且崩溃安全的写入(临时文件 + 重命名)
# RIGHT: write to a temp file, then atomically rename over the target.
func save_atomic(path: String, data: Dictionary) -> void:
var tmp := path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
f.store_string(JSON.stringify(data))
f.flush() # ensure bytes hit disk
f.close()
DirAccess.rename_absolute(tmp, path) # replaces the target; atomic on POSIX
# WRONG: opening `path` directly and writing in place — a crash mid-write leaves a
# truncated, unloadable save and destroys the player's progress.
在 POSIX(同一卷)上,重命名覆盖目标文件是原子的;在 Windows 上,通过重命名替换 不保证原子性,因此在重命名之前将旧文件保留为 path + ".bak"——该备份才是真正保证你能从错误写入中恢复的因素。
3. 带迁移的版本化加载
SAVE_VERSION = 3
def load_save(raw_bytes):
data = parse(raw_bytes) # JSON/binary -> dict
v = data.get("version", 0)
if v > SAVE_VERSION:
raise NewerSaveError(v) # save is from a newer build; refuse
while v < SAVE_VERSION: # apply migrations in order, v -> v+1
data = MIGRATIONS[v](data)
v += 1
data["version"] = v
validate(data) # check required keys / ranges
return data
# Each migration is a pure function from one version's shape to the next.
def migrate_1_to_2(d):
d["flags"] = {k: True for k in d.pop("completed_quests", [])} # list -> set-map
return d
MIGRATIONS = {1: migrate_1_to_2, 2: migrate_2_to_3}
4. 存档槽位 + 限频自动存档
const SLOT_PATH := "user://save_%d.json" # manual slots 0..N
const AUTOSAVE_PATH := "user://autosave.json" # separate file: never clobbers a slot
var _autosave_cooldown := 0.0
func autosave_if_due(dt: float) -> void:
_autosave_cooldown -= dt
if _autosave_cooldown <= 0.0:
save_atomic(AUTOSAVE_PATH, capture_state())
_autosave_cooldown = 60.0 # throttle: at most once a minute
# Trigger an immediate autosave on checkpoints/level transitions, not mid-combat.
常见陷阱
- 序列化引擎对象/节点路径会使存档绑定到场景结构;
- 没有 version 字段。当你发布补丁的那天,每个现有存档都成了
- 原地写入会在崩溃/断电时损坏存档。始终先写入临时文件再
- 盲目信任文件。存档可能被截断、手动编辑,或
- 浮点数与区域设置。文本序列化器可能丢失精度,或在某些区域设置中使用逗号作为
- 自动存档覆盖手动存档,或在动作中途触发并保存不一致
- 在多人游戏中存储秘密或信任客户端存档。本地存档由
重命名节点会破坏所有旧存档。保存数据,加载时重建对象。
猜测游戏。从 version 1 开始标记 version。
重命名;保留 .bak。
云同步为过期数据。加载时校验,失败时回退到备份。
小数分隔符。使用区域设置无关的序列化器。
状态。使用专用自动存档槽位,并在安全边界保存。
玩家控制;绝不能将其视为在线状态的权威。对于云端, 处理设备数据限制和冲突(roblox-datastores)。
参考
references/versioning-and-migration.md— schema 演进策略、
迁移链、备份/回滚、格式权衡(JSON 与二进制),以及 加载时校验清单。
相关技能
roblox-datastores— 云持久化、请求限制、会话锁定。godot-resources、unity-scriptableobjects— 你要序列化的数据模型。procedural-gen— 存储 seed 以重新生成世界,而不是保存世界。rpg、survival-crafting、visual-novel— 组合使用本技能的类型。