关闭浏览器标签页不应该抹去四个小时的进度。这听起来显而易见,但许多浏览器游戏都把本地存储(local storage)当作事后才考虑的事情。玩家解锁了高分,调整了设置,第二天回来,却发现一无所有。更糟的是,他们在补丁更新后返回,游戏却报错了,因为他们机器上的存档文件与你刚刚发布的代码不再匹配。使用 Phaser 4 开发一款类幸存者射击游戏意味着要应对源源不断的敌人,但真正的长期威胁来自于你未来的更新。
大多数开发者构建第一个存档系统的方法是:抓取一个对象,通过 JSON.stringify 处理,然后将其丢进 localStorage。加载时,他们对其进行解析并将其原样交给游戏。这在第一天确实有效。但一旦你添加了新设置、新的解锁标志或第三层嵌套配置,它就会崩溃。如果回归玩家拥有一个缺少 vignette 属性的旧存档,而你的新代码期望它存在,你就会在原本期望布尔值的地方得到 undefined。将这种情况乘以十几个新功能,你就会面临一个首先冲击你最忠实玩家的调试噩梦。
从契约开始,而不是原始对象
在接触 localStorage 之前,先在代码库中定义一个默认的存档模式(schema)。把它想象成一份每个存档文件都必须遵守的契约,无论它是五分钟前创建的还是五个月前创建的。一个清晰的起点可能如下所示:
const defaultSave = {
highScore: 0,
settings: {
screenShake: true,
vignette: true
}
};
这个对象存在于你的源代码中。当游戏启动时,你总是有这个结构可用。它为你提供了一个基准。它还迫使你在序列化任何内容之前思考结构。如果你跳过这一步,只是简单地存储当时方便的任何状态对象,那么当旧存档与你的预期脱节时,你最终会遇到键名不一致、字段缺失和静默失败的问题。
使用 Try/Catch 进行防御性加载
本地存储不是数据库。它是浏览器里的一个字符串储藏室,任何东西都可能掉进里面。用户可能手动编辑了某个值,或者一个写了一半的操作被中断了,又或者某个浏览器扩展程序向你声明的键中丢进了垃圾数据。当你把那个字符串取出来并交给 JSON.parse 时,哪怕只有一个损坏的字符也会抛出硬异常。在 Phaser 游戏中,这种未处理的错误可能会冻结你的启动序列,或者让玩家直接面对一个空白屏幕。
始终将你的读取和解析逻辑包裹在 try/catch 块中。如果失败,则回退到你的默认模式。目标很简单:如果存档文件无法读取,请将玩家视为新用户,而不是让整个会话崩溃。这种习惯将业余项目与生产级构建区分开来。实现它的成本几乎为零,却能让你免于那些无法复现的神秘 Bug 报告。
将旧数据与默认值合并
解析成功并不意味着你就安全了。永远不要用解析结果完全替换你的默认对象。旧的存档文件可能不包含你最新的设置。它可能存储了 screenShake 但没有 vignette。如果你的游戏逻辑假设 vignette 存在(因为它随最新更新发布),那么你又会陷入追逐 undefined 错误的循环。
相反,应将加载的数据与你的默认值进行合并。使用 Object.assign 将保存的值叠加在基准模式之上。默认值会自动填补每一个缺失的空隙。你在第二版中添加的新属性会从默认对象中获取初始值。玩家实际更改过的现有属性会被其存储的偏好所覆盖。这样大家都赢了:回归玩家保留了他们的最高分,而游戏也能在不崩溃的情况下访问你昨天添加的新开关。
请记住,Object.assign 执行的是浅合并(shallow merge)。如果你的设置对象随着时间的推移变得深度嵌套,你可能需要更仔细地处理这些内部对象。尽管如此,原则依然适用:玩家的数据应该是对你默认值的装饰,而不是直接替换它们。
为你的键设置版本
浏览器不会自动删除旧的本地存储条目。如果你大幅度更改了数据结构,你需要一种干净的方法来放弃旧格式。为你的存储键添加版本后缀。bitSurvivorsSave_v1 是明确的。它准确地告诉你哪个模式写入了该文件。稍后,当你彻底重构进度系统或添加完整的物品栏系统时,请迁移到 bitSurvivorsSave_v2。
这为你带来了两个实际的好处。首先,你永远不会误用 v2 逻辑去解析 v1 数据块。其次,如果你愿意,你可以编写迁移代码。在启动时,检查是否存在 v1。如果存在且不存在 v2,则将旧数据迁移到新结构,将其写入新键(key),然后继续。如果你不想迁移,至少旧键会安全地留在存储中,而你的新代码会忽略它。无论哪种方式,版本控制都能防止静默损坏。
让保存变得无感
持久化应该像呼吸一样自然。玩家永远不应该去思考这件事。不要在设置菜单中添加“应用(Apply)”按钮。“应用”按钮会产生摩擦感,并让用户开始担心他们的选择是否真的生效了。当玩家切换了三个选项,却漏掉了“应用”并关闭了标签页时,这些按钮还会引发数据丢失。
在交互发生的那一刻就进行保存。当玩家点击复选框以禁用屏幕抖动时,立即调用你的写入函数。当单局游戏结束且最终得分结算时,在“游戏结束”画面动画播放完毕之前,写入新的最高分。事件驱动的保存方式能让你的架构保持可预测性,因为保存操作始终紧随改变数据的动作。你永远不需要去寻找一个中央批处理函数,也不必担心状态过时。
这种方法还简化了你的思维模型。你确切地知道持久化发生在何处:在处理切换(toggle)的回调函数中,以及在处理死亡的函数中。代码库中不会散布着各种莫名其妙的写入操作。
为你自己构建一个重置按钮
在开发过程中,你会损坏自己的存档。你会写入错误的数据,测试边缘情况,并需要快速返回到干净的状态。在调试菜单或隐藏的组合键中构建一个重置按钮。让这个重置按钮按以下确切顺序执行两件事:将你的内存状态重置为默认 schema,然后立即调用那个写入 local storage 的保存函数。
如果你只是清除了本地变量而跳过了写入步骤,那你什么也没做到。下一次页面刷新会从浏览器中重新拉取旧数据并使其“复活”。一个忘记持久化的重置是那种会浪费你整个下午的 Bug。只要一次性搞定这个顺序,在项目的剩余时间里,你的测试循环就能保持高效。
核心要点
保存不是你在最后才强行添加的功能。它是定义你的游戏是否感觉稳健且尊重玩家时间的底层架构。一款 Phaser 4 生存者射击游戏(survivor shooter)的成败取决于重复游玩。如果浏览器标签页就像一把指向玩家进度的枪,他们最终会停止回归。编写 schema,防御错误数据,使用合并而非替换,为你的键进行版本控制,并在每一个有意义的事件发生时进行保存。未来的你,以及在下次更新后回归的每一位玩家,都会感谢你的。
