存档系统 SaveManager
Sep 3
游戏里已经有「最高分」了 — 但你关掉游戏再打开,它就变回 0 了 😢
这一章做一个最小的存档系统,让最高分真正留在硬盘上。
为什么现在存不住?
回头看 GameManager 里的这行:
var high_score: int = 0
变量是存在内存里的。内存的特点就是断电即失 — 游戏进程一关,这个 0 和你打出来的 999 一起烟消云散。
想留住它,就得写到硬盘上的一个文件里。
res:// 和 user:// — 别写错地方
Godot 里有两个特殊路径前缀,这是新手第一个坑:
| 路径 | 指向哪 | 能不能写 |
|---|---|---|
res:// | 你的项目文件夹(游戏本体) | ❌ 导出后只读 |
user:// | 玩家电脑上的用户数据目录 | ✅ 可读可写 |
为什么 res:// 不能写?
游戏导出之后,所有 res:// 里的东西会被打包进一个 .pck 文件(你在 第 9 章 导出时见过它)。打包好的文件是只读的 — 就像你不能修改一个已经刻好的光盘。
在编辑器里测试的时候 res:// 可能写得进去,但导出之后必定失败 — 这个坑特别隐蔽,一定要记住。
user:// 实际在哪?
| 平台 | 真实路径 |
|---|---|
| Windows | %APPDATA%\Godot\app_userdata\XGGame-Bird\ |
| macOS | ~/Library/Application Support/Godot/app_userdata/XGGame-Bird/ |
| Linux | ~/.local/share/godot/app_userdata/XGGame-Bird/ |
| Web | 浏览器的 IndexedDB(不是真文件) |
编辑器里可以直接项目 → 打开用户数据文件夹跳过去看,调试存档很方便。
⚠️ Web 版要注意:存档在浏览器的 IndexedDB 里,玩家清缓存 / 换浏览器 / 用无痕模式,存档就没了。我们这个游戏只存一个最高分,丢了无所谓;但如果以后做长流程游戏要上 Web,就得考虑云存档了。
创建 SaveManager
新建文件 scripts/autoload/save_manager.gd:
extends Node
## 存档管理:读写 user://save.json
const SAVE_PATH := "user://save.json"
func save_game() -> void:
var data := {
"high_score": GameManager.high_score,
}
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
file.store_string(JSON.stringify(data))
func load_game() -> void:
if not FileAccess.file_exists(SAVE_PATH):
return # 首次游玩,没有存档,用默认值
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
var data = JSON.parse_string(file.get_as_text())
if data == null:
return # 存档损坏,防崩
GameManager.high_score = data.get("high_score", 0)
然后照例注册 Autoload,命名 SaveManager。
顺序很重要:
SaveManager必须排在GameManager上面 — 因为下面我们会在 GameManager 的_ready()里调用SaveManager.load_game()。完整顺序见 上一章的表格。
逐行拆解
const SAVE_PATH := "user://save.json"
const 是常量 — 定义之后不能改。
存档路径这种"全文件都要用、但永远不会变"的值,用 const 而不是 var:写错了编辑器会直接报错,而不是等到运行时才发现路径拼错。
:= 是什么?
var data := { ... } # 用了 :=
var data = { ... } # 没用
:= 叫类型推断赋值。意思是「变量的类型由右边的值自动决定,之后就锁死了」。
var data := {}→ Godot 自动判定data是Dictionary,以后你想给它塞个数字,编辑器直接报错var data = {}→ 类型随便变,写错了要等运行时才炸
能用 := 就用,等于免费多一层错误检查。
JSON.stringify() 和 JSON.parse_string()
硬盘上的文件本质是一串文本,没法直接存 Godot 的字典对象。所以要转换:
JSON.stringify(data)— 把字典变成文本:{"high_score":42}JSON.parse_string(文本)— 把文本变回字典
一存一取,正好是一对反操作。
为什么选 JSON 而不是 Godot 自己的二进制格式?因为 JSON 是纯文本,你可以直接用记事本打开看里面存了啥 — 调试的时候方便太多了。
为什么没有 file.close()?
Godot 3 里必须手动 file.close(),忘了就可能丢数据。
Godot 4 不用了 — FileAccess 用的是引用计数:当 file 这个变量随着函数结束而消失时,Godot 会自动帮你关闭并写入。
不过如果你想立刻确保写进硬盘(比如后面还要马上读它),可以显式写一句 file = null 来提前触发关闭。
两道防崩保险
if not FileAccess.file_exists(SAVE_PATH):
return
第一次玩游戏时存档文件根本不存在。不判断就直接 open,会拿到一个 null,下一行 file.get_as_text() 立刻崩。
if data == null:
return
存档文件存在但内容坏了(写到一半断电、玩家手贱改坏了),JSON.parse_string 会返回 null。不判断的话下一行 data.get(...) 一样崩。
这两个判断的效果是:读档失败就静悄悄用默认值继续玩,而不是弹一个报错把玩家挡在门外。对一个休闲小游戏来说,这是对的取舍。
data.get("high_score", 0)
GameManager.high_score = data.get("high_score", 0)
.get(键, 默认值) — 取不到就用默认值。
这一手是为了向后兼容:假如以后你给存档加了新字段(比如 total_play_count),老玩家的存档文件里没有这个键。用 .get() 带默认值,老存档照样能读;直接写 data["total_play_count"] 就会崩。
接进 GameManager
改两个地方:
func _ready() -> void:
SaveManager.load_game() # 👈 启动时读档
EventBus.point_scored.connect(add_score)
EventBus.bird_died.connect(game_over)
## 游戏结束
func game_over():
set_state(GameState.GAME_OVER)
if high_score < total_score:
high_score = total_score
SaveManager.save_game() # 👈 只在破纪录时写档
读档时机:_ready() 里,越早越好
_ready() 是这个节点准备就绪时执行一次的函数。Autoload 的 _ready() 在游戏刚启动时就跑,比任何游戏场景都早 — 所以等玩家看到菜单的时候,high_score 已经是硬盘里的真实值了。
写档时机:只在破纪录的那一刻
注意这行 SaveManager.save_game() 的缩进位置 — 它在 if 里面。
这不是随便放的:
| 写档时机 | 后果 |
|---|---|
| 每帧写 | 💀 一秒 60 次硬盘 IO,游戏卡成幻灯片 |
| 每次死亡都写 | 😐 能用,但大部分时候在写一模一样的内容,纯浪费 |
| 只在破纪录时写 | ✅ 只有数据真的变了才写 |
多数玩家一局游戏破不了几次纪录,所以实际写盘次数可能一整晚就几次。
这是个通用原则:存档写入要由"数据变化"驱动,不要由"时间"或"事件"驱动。
要不要给存档加密?
单机小游戏:不要。
Godot 确实提供了 FileAccess.open_encrypted_with_pass(),但对这个游戏来说是负收益:
- 玩家改自己的最高分 — 不伤害任何人,没有排行榜、没有联机、没有内购
- 加密之后你自己也没法用记事本看存档了,调试变麻烦
- 密钥就硬编码在游戏里,稍微懂点的人一样能扒出来 — 挡不住真想改的人,只烦到了自己
什么时候才需要加密?有在线排行榜、有联机对战、有内购解锁的时候 — 那时候作弊会影响到别的玩家。
测试一下
- 玩一局,打出一个分数(比如 5 分)
- 完全关掉游戏,重新运行
- 结束画面的
High Score应该还是 5 ✅ - 去 项目 → 打开用户数据文件夹,能看到
save.json,记事本打开是{"high_score":5}
如果第 3 步失败,先检查 Autoload 里 SaveManager 是不是排在 GameManager 上面。
到这里,《XGGame-Bird》的完整结构就都记录完了:
- ✅ 从零搭出一只会飞的鸟
- ✅ 水管、死区、计分
- ✅ GameManager 管状态,EventBus 管通信
- ✅ 美术、音效、UI
- ✅ 导出上架 itch.io
- ✅ 存档留住最高分
下一款游戏见 💪