Godot 多存档槽系统实战:从 0 到 1 实现完整的游戏存档管理

发布时间:2026/8/29 8:39:59
Godot 多存档槽系统实战:从 0 到 1 实现完整的游戏存档管理 Godot 多存档槽系统实战从 0 到 1 实现完整的游戏存档管理【免费下载链接】godotGodot Engine – Multi-platform 2D and 3D game engine项目地址: https://gitcode.com/GitHub_Trending/go/godot你有没有遇到过这种情况玩家为了测试一个新策略不得不把唯一的存档覆盖掉结果想回头继续原进度时发现无从找起只有一个存档位看似小事却是让留存率悄悄流失的细节。本文带你用 Godot 引擎从零实现一套完整的多存档槽系统——数据落盘、槽位管理、自动备份、界面交互全部讲透。学完这篇你会掌握基于user://的多槽位存档目录设计与元数据管理能写出带自动备份、损坏安全临时文件 重命名的SaveManager单例会动手搭出选择存档槽界面并能把它接到存档流程上。背景与准备三个核心模块这套系统不依赖任何第三方库只需要引擎自带的三块能力。先看它们分别扮演什么角色FileAccess跨平台的文件读写句柄负责打开、写文本、读回存档还提供了copy()、get_modified_time()这类静态方法备份逻辑几乎全靠它。DirAccess目录操作负责创建槽位目录、枚举备份文件、删除槽位。make_dir_recursive_absolute()一行就能递归建目录。JSON把字典/数组与字符串双向转换是存档序列化格式的核心。JSON.stringify()写出JSON.parse_string()读回。设计思路先定目录结构再定数据格式在写第一行代码前先想清楚两件事存档文件放哪、元信息存哪。我们的方案是每个槽位一个子目录 一份全局元数据user:// ├── saves/ │ ├── slots_metadata.json # 槽位元数据是否占用、名称、时间、时长 │ ├── slot_0/ │ │ └── data.json # 该槽位的完整游戏数据 │ ├── slot_1/ │ │ └── data.json │ └── backups/ │ ├── slot_0_1724650000.json # 时间戳命名的自动备份 │ └── slot_0_1724653600.json对应的数据流转是save_game(slot_id) ├─ 校验槽位 ID ├─ 写入临时文件 data.tmp 并关闭 ← 写一半断电也不污染正式档 ├─ rename 为 data.json ← 原子替换失败即回滚 ├─ copy 出时间戳备份 → backups/ ├─ 裁剪每槽只保留最近 N 份备份 └─ 更新 slots_metadata.json load_game(slot_id) └─ 读 slots_metadata.json → 取 data.json → JSON.parse_string()两个关键决策说明一下为什么用 JSON 而不是二进制 ResourceSaver存档是跨版本生存的数据。JSON 是人类可读的玩家手滑改错、你后续加字段都能肉眼排查而二进制资源格式一旦引擎大版本变更迁移成本极高。存档体积对普通单机游戏来说远不到需要压缩的程度可读性优先。为什么元数据单独放一份UI 打开存档界面时不想逐个打开每个槽位的数据文件读一个小 JSON 就能画出全部槽位状态。元数据只存摘要不存游戏数据本体。分步实现第一步初始化存档目录结构这一步要达成程序启动后user://saves/及其子目录必然存在任何槽位随时可写。class_name SaveManager extends Node const SAVE_DIR : user://saves/ const BACKUP_DIR : user://saves/backups/ const MAX_SLOTS : 5 const MAX_BACKUPS_PER_SLOT : 3 static var instance: SaveManager func _ready() - void: if instance: queue_free() return instance self # 递归创建两级目录幂等已存在也不报错 DirAccess.make_dir_recursive_absolute(SAVE_DIR) DirAccess.make_dir_recursive_absolute(BACKUP_DIR)关键点用user://而不是res://或绝对路径。前者在 Windows、macOS、Android、Web 各自解析到该平台的合法可写位置后者在只读打包体尤其是 PCK里会直接失败。⚠️ 易错make_dir_recursive_absolute()传的是绝对形式路径user://前缀已满足不要传res://相对路径。第二步管理槽位元数据这一步要达成UI 能一眼知道哪个槽位被占用、上次玩的什么时候。元数据是一个定长数组下标即槽位号比字典多一层数组的开销但取用代码最直白func _metadata_path() - String: return SAVE_DIR slots_metadata.json func load_metadata() - Array: if FileAccess.file_exists(_metadata_path()): var text : FileAccess.get_file_as_string(_metadata_path()) var parsed JSON.parse_string(text) # 文件存在但被写坏时必须兜底否则整个存档系统瘫掉 if parsed is Array and parsed.size() MAX_SLOTS: return parsed var empty : [] for i in MAX_SLOTS: empty.append({name: , timestamp: 0, playtime: 0}) return empty func save_metadata(metadata: Array) - void: var f : FileAccess.open(_metadata_path(), FileAccess.WRITE) if f: f.store_string(JSON.stringify(metadata, ))关键点读元数据一定做存在性 结构双重校验。存档文件被玩家手动删了一半、或者上次进程写了一半被杀都会留下非法 JSONJSON.parse_string()失败返回null不判空直接.size()就是运行时错误。第三步实现带自动备份的保存流程这一步是核心要达成三件事安全落盘、留备份、能裁剪。func save_game(slot_id: int, game_data: Dictionary, slot_name: String, extra_playtime: float) - bool: if slot_id 0 or slot_id MAX_SLOTS: push_error(Invalid slot: %d % slot_id) return false DirAccess.make_dir_recursive_absolute(SAVE_DIR slot_%d % slot_id) # 1) 先写临时文件写坏/写一半只毁掉 tmp不碰正式档 var tmp_path : %sslot_%d/data.tmp % [SAVE_DIR, slot_id] var f : FileAccess.open(tmp_path, FileAccess.WRITE) if not f: push_error(Cannot open temp save: tmp_path) return false f.store_string(JSON.stringify(game_data)) f.close() # 必须先关闭否则 rename 时句柄仍占用文件 # 2) 原子替换失败则正式档原样保留 var target : %sslot_%d/data.json % [SAVE_DIR, slot_id] if FileAccess.rename(tmp_path, target) ! OK: return false # 3) 时间戳备份 裁剪 _make_backup(slot_id) # 4) 更新元数据 var meta : load_metadata() meta[slot_id] { name: slot_name, timestamp: int(Time.get_unix_time_from_system()), playtime: int(meta[slot_id].get(playtime, 0)) int(extra_playtime), } save_metadata(meta) return true func _make_backup(slot_id: int) - void: var src : %sslot_%d/data.json % [SAVE_DIR, slot_id] var ts : int(Time.get_unix_time_from_system()) var ok : FileAccess.copy(src, %sslot_%d_%d.json % [BACKUP_DIR, slot_id, ts]) OK if ok: _prune_backups(slot_id) func _prune_backups(slot_id: int) - void: var dir : DirAccess.open(BACKUP_DIR) var mine: Array[String] [] for file_name in dir.get_files(): if file_name.begins_with(slot_%d_ % slot_id): mine.append(file_name) # 文件名自带时间戳按名字倒序即按时间倒序 mine.sort_custom(func(a, b): return a b) for old in mine.slice(MAX_BACKUPS_PER_SLOT): dir.remove(old)关键点先写data.tmp再rename而不是直接open(data.json, WRITE)——后者会立即清空原文件写进程中途崩溃就得到一份 0 字节或半截 JSON 的正式档。⚠️ 易错FileAccess.open()返回的是引用对象失败时为nullGDScript 里对null调store_string()直接报错所以每步都要判空。第四步加载与删除槽位承接上一步存档能写也要能读回、能清掉。func load_game(slot_id: int) - Dictionary: var path : %sslot_%d/data.json % [SAVE_DIR, slot_id] if not FileAccess.file_exists(path): return {} var parsed JSON.parse_string(FileAccess.get_file_as_string(path)) return parsed if parsed is Dictionary else {} # 坏档返回空由上层弹提示 func delete_slot(slot_id: int) - void: var dir : DirAccess.open(BACKUP_DIR) for file_name in dir.get_files(): if file_name.begins_with(slot_%d_ % slot_id): dir.remove(file_name) DirAccess.remove_absolute(%sslot_%d % [SAVE_DIR, slot_id]) var meta : load_metadata() meta[slot_id] {name: , timestamp: 0, playtime: 0} save_metadata(meta)关键点DirAccess.remove_absolute()可以直接递归删除槽位目录比逐个get_files()再remove()省事。删除槽位时别忘了把备份目录里对应文件一并清掉否则垃圾会越积越多。第五步搭建存档选择界面这一步要达成把数据能力变成玩家能点的按钮。建议的节点结构CanvasLayer └── PanelContainer # 半透明背景面板 └── VBoxContainer ├── Label # 选择存档槽 ├── GridContainer # columns 5动态填槽位按钮 └── HBoxContainer ├── Button # 保存 ├── Button # 删除 └── Button # 返回运行时动态生成槽位按钮元数据驱动显示内容func _ready() - void: var grid : $PanelContainer/VBoxContainer/GridContainer for i in SaveManager.MAX_SLOTS: var btn : Button.new() btn.custom_minimum_size Vector2(180, 60) grid.add_child(btn) _refresh_label(btn, i) btn.pressed.connect(_on_slot_pressed.bind(i)) func _refresh_label(btn: Button, i: int) - void: var info : SaveManager.instance.load_metadata()[i] if info[timestamp]: var t : Time.get_datetime_string_from_unix_time(int(info[timestamp])) btn.text 槽位 %d\n%s\n%s % [i 1, info[name], t] else: btn.text 槽位 %d空 % (i 1) btn.disabled true func _on_slot_pressed(slot_id: int) - void: # 在这里弹出继续 / 新建 / 删除二级确认 # 确认后调用 SaveManager.instance.load_game(slot_id) 等接口 print(slot , slot_id)关键点UI 层只读元数据、只发信号绝不自己碰文件路径。这样存档格式、目录结构将来怎么改界面一行不用动。完整示例可直接粘贴的 SaveManager上面各步骤拼在一起就是下面这份完整实现。新建save_manager.gd在场景树里挂一个节点即可使用class_name SaveManager extends Node const SAVE_DIR : user://saves/ const BACKUP_DIR : user://saves/backups/ const MAX_SLOTS : 5 const MAX_BACKUPS_PER_SLOT : 3 static var instance: SaveManager func _ready() - void: if instance: queue_free() return instance self DirAccess.make_dir_recursive_absolute(SAVE_DIR) DirAccess.make_dir_recursive_absolute(BACKUP_DIR) func _metadata_path() - String: return SAVE_DIR slots_metadata.json func load_metadata() - Array: if FileAccess.file_exists(_metadata_path()): var parsed JSON.parse_string(FileAccess.get_file_as_string(_metadata_path())) if parsed is Array and parsed.size() MAX_SLOTS: return parsed var empty : [] for i in MAX_SLOTS: empty.append({name: , timestamp: 0, playtime: 0}) return empty func save_metadata(metadata: Array) - void: var f : FileAccess.open(_metadata_path(), FileAccess.WRITE) if f: f.store_string(JSON.stringify(metadata, )) func save_game(slot_id: int, game_data: Dictionary, slot_name: String, extra_playtime: float) - bool: if slot_id 0 or slot_id MAX_SLOTS: push_error(Invalid slot: %d % slot_id) return false DirAccess.make_dir_recursive_absolute(SAVE_DIR slot_%d % slot_id) var tmp_path : %sslot_%d/data.tmp % [SAVE_DIR, slot_id] var f : FileAccess.open(tmp_path, FileAccess.WRITE) if not f: push_error(Cannot open temp save: tmp_path) return false f.store_string(JSON.stringify(game_data)) f.close() var target : %sslot_%d/data.json % [SAVE_DIR, slot_id] if FileAccess.rename(tmp_path, target) ! OK: return false _make_backup(slot_id) var meta : load_metadata() meta[slot_id] { name: slot_name, timestamp: int(Time.get_unix_time_from_system()), playtime: int(meta[slot_id].get(playtime, 0)) int(extra_playtime), } save_metadata(meta) return true func load_game(slot_id: int) - Dictionary: var path : %sslot_%d/data.json % [SAVE_DIR, slot_id] if not FileAccess.file_exists(path): return {} var parsed JSON.parse_string(FileAccess.get_file_as_string(path)) return parsed if parsed is Dictionary else {} func _make_backup(slot_id: int) - void: var src : %sslot_%d/data.json % [SAVE_DIR, slot_id] var ts : int(Time.get_unix_time_from_system()) if FileAccess.copy(src, %sslot_%d_%d.json % [BACKUP_DIR, slot_id, ts]) OK: _prune_backups(slot_id) func _prune_backups(slot_id: int) - void: var dir : DirAccess.open(BACKUP_DIR) var mine: Array[String] [] for file_name in dir.get_files(): if file_name.begins_with(slot_%d_ % slot_id): mine.append(file_name) mine.sort_custom(func(a, b): return a b) for old in mine.slice(MAX_BACKUPS_PER_SLOT): dir.remove(old) func delete_slot(slot_id: int) - void: var dir : DirAccess.open(BACKUP_DIR) for file_name in dir.get_files(): if file_name.begins_with(slot_%d_ % slot_id): dir.remove(file_name) DirAccess.remove_absolute(%sslot_%d % [SAVE_DIR, slot_id]) var meta : load_metadata() meta[slot_id] {name: , timestamp: 0, playtime: 0} save_metadata(meta)调用侧只需要三行var data : {player: {hp: 80, pos: [1.0, 2.0]}, chapter: 3} SaveManager.instance.save_game(0, data, 艾拉, 120.0) var loaded : SaveManager.instance.load_game(0) print(loaded.get(chapter)) # 3踩坑与优化别直接覆盖写正式档。写临时文件再rename是唯一能同时防写一半崩溃和半截 JSON的姿势rename在同一文件系统内是原子操作。user://是唯一安全的前缀。想写res://或项目路径的话发布包里它是只读的存档会静默失败。移动端尤其 Android 旧版本还有权限差异user://由引擎统一兜底。JSON 解析结果永远判类型。JSON.parse_string()失败返回null、解析出数组时parsed is Dictionary不成立这两种情况都要有返回空 上层提示坏档的兜底否则一个坏档能拖垮整个游戏。大存档考虑压缩。数据膨胀到 MB 级后可把字典先JSON.stringify再用 Compression 压成PackedByteArray存盘读档时解压还原接口不变。延伸方向想让玩家手动找回旧进度给backups/目录加一个恢复备份入口逻辑就是再写一份FileAccess.copy想让存档防篡改/防手改用引擎自带的 Crypto 模块对存档文件做哈希校验或加密FileAccess已提供get_md5()辅助想支持多设备同步存档已天然按槽位隔离接入 HTTPClient 上传下载data.json即可无需改本地结构。多存档槽系统不复杂复杂的是把它做稳——目录结构定好、临时文件用对、元数据判空做全剩下的就是界面与手感。把SaveManager挂进你的项目跑一遍保存—关游戏—读档—删档的完整循环再欢迎到社区晒晒你的槽位 UI 设计。【免费下载链接】godotGodot Engine – Multi-platform 2D and 3D game engine项目地址: https://gitcode.com/GitHub_Trending/go/godot创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考