谢夏戈 @ xiexiage.com

存档系统 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 自动判定 dataDictionary,以后你想给它塞个数字,编辑器直接报错
  • 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(),但对这个游戏来说是负收益:

  • 玩家改自己的最高分 — 不伤害任何人,没有排行榜、没有联机、没有内购
  • 加密之后你自己也没法用记事本看存档了,调试变麻烦
  • 密钥就硬编码在游戏里,稍微懂点的人一样能扒出来 — 挡不住真想改的人,只烦到了自己

什么时候才需要加密?有在线排行榜、有联机对战、有内购解锁的时候 — 那时候作弊会影响到别的玩家。

测试一下

  1. 玩一局,打出一个分数(比如 5 分)
  2. 完全关掉游戏,重新运行
  3. 结束画面的 High Score 应该还是 5 ✅
  4. 项目 → 打开用户数据文件夹,能看到 save.json,记事本打开是 {"high_score":5}

如果第 3 步失败,先检查 Autoload 里 SaveManager 是不是排在 GameManager 上面


到这里,《XGGame-Bird》的完整结构就都记录完了:

  • ✅ 从零搭出一只会飞的鸟
  • ✅ 水管、死区、计分
  • ✅ GameManager 管状态,EventBus 管通信
  • ✅ 美术、音效、UI
  • ✅ 导出上架 itch.io
  • ✅ 存档留住最高分

下一款游戏见 💪

2023-PRESENT © 谢夏戈