从一个真实问题开始
一次活动版本后,客服收到不少反馈:玩家明明昨天解锁了皮肤,今天打开又没了。排查发现,部分玩家使用隐私浏览器,部分玩家清理了站点数据,还有一部分是新版本改了存档字段,旧数据读取失败后被默认值覆盖。localStorage 没有错,错的是团队把它当成了数据库。
这个问题发生在一款带成长系统的休闲放置 H5 游戏里。当时我的角色是负责本地数据和同步链路的客户端工程师,最先做的不是马上改代码,而是把玩家路径、设备环境、资源状态和场景切换顺序重新走了一遍。Phaser 项目很容易给人一种“代码都在前端,问题应该很好定位”的错觉;实际到了线上,浏览器、渠道容器、资源缓存、输入焦点和玩家习惯会一起参与结果。
这篇文章讨论的核心是:存档是用户资产的本地投影,不是随手写入的一段 JSON。如果只看 API,很容易把 Phaser 学成一组函数;如果从项目交付看,就必须关心边界、生命周期、失败兜底和调试证据。下面会围绕 玩家离线游玩、升级版本、恢复登录、云端同步和冲突处理 展开,把经验落到可执行的工程判断上。
先看整体结构
flowchart TD
A[启动游戏] --> B[读取本地存档]
B --> C{是否存在}
C -->|否| D[创建默认存档]
C -->|是| E[校验版本和完整性]
E --> F{需要迁移}
F -->|是| G[逐版本迁移并备份旧档]
F -->|否| H[进入游戏]
G --> H
H --> I[本地写入]
I --> J{登录态可用}
J -->|是| K[云端同步和冲突解决]
J -->|否| L[等待下次同步]
这张图不是为了显得复杂,而是提醒我们:玩家看到的是一个连续体验,工程上却是多个系统串起来的结果。localStorage、IndexedDB、schema version、migration、cloud sync、conflict resolution 都有自己的职责,任何一个环节偷懒,最后都会变成“怎么偶尔不对”的线上问题。
一段可以落地的代码切口
下面这段示例不是完整框架,只是为了说明 存档读写与迁移 应该如何从一开始就留下边界。真实项目里可以继续封装,但不要在还没说清职责前就追求抽象。
const CURRENT_SAVE_VERSION = 4;
function migrateSave(raw) {
let save = raw;
while (save.version < CURRENT_SAVE_VERSION) {
save = migrations[save.version](save);
}
return save;
}
function safeWrite(save) {
const next = { ...save, updatedAt: Date.now(), version: CURRENT_SAVE_VERSION };
localStorage.setItem('game.save.tmp', JSON.stringify(next));
localStorage.setItem('game.save', JSON.stringify(next));
localStorage.removeItem('game.save.tmp');
}
代码里的重点不是语法,而是控制权。Phaser 的对象和插件都很好调用,难点是不要让每个回调都直接修改全局状态。只要控制权分散,后续就会出现“这个字段到底是谁改的”“为什么第二次进入场景不一样”“为什么关闭弹窗后玩法状态变了”之类的问题。
存档先分级
不是所有数据都值得同等保护。音量、画质、引导是否看过,可以放在 localStorage;关卡进度、金币、皮肤、抽卡结果、付费权益就要更谨慎。数据越接近用户资产,越不能只靠本地浏览器存储兜底。
Phaser 本身不限制你怎么存数据,H5 平台也提供了多种能力。关键是项目要先定义数据等级:设置类、进度类、资产类、临时缓存类。等级不同,写入频率、校验方式、同步策略也不同。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
localStorage 适合小而低频的数据
localStorage 简单同步,适合配置和小型存档,但它有容量限制、阻塞主线程、容易被清理,也不适合频繁写入。把每一帧的状态都写进去,既慢又危险。
如果游戏有较多关卡记录、背包条目、离线收益流水,IndexedDB 更合适。它异步、容量更友好,也适合存结构化数据。但 IndexedDB API 复杂一些,需要封装好读写、事务和失败处理。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
写入要有事务感
浏览器存储并不等于永远成功。写入过程中页面关闭、存储空间不足、JSON 序列化失败、隐私模式限制,都可能让存档损坏。至少要做临时键写入、完整性校验和旧档备份。
简单项目可以用 save.tmp 和 save 双键策略。复杂项目则可以维护 save.current、save.backup、save.meta。启动时如果 current 损坏,可以尝试 backup,而不是直接创建新档覆盖玩家数据。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
版本迁移不要跳着写
存档字段会随着版本变化。新增体力系统、改名货币字段、拆分皮肤表、加入赛季进度,这些都需要迁移。最稳的方式是逐版本迁移:1 到 2,2 到 3,3 到 4。不要只写一个“旧档转新档”的大函数,因为你很快会忘记旧版本长什么样。
迁移前要备份旧档,迁移后要校验关键字段。迁移失败时不要静默重置,应该进入修复流程或提示玩家重试。对资产类数据,宁愿保守保留,也不要轻易删除未知字段。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
云同步要处理冲突
玩家可能在手机和电脑上分别玩,离线一段时间后再登录。云端和本地都发生变化时,不能简单用后写覆盖先写。不同数据需要不同冲突策略:最高关卡取最大值,货币要看服务端流水,设置可以用最新值,已获得皮肤取并集。
如果游戏涉及付费或排行榜,权威数据必须在服务端。客户端本地存档可以提升体验,但不能成为最终资产来源。Phaser 项目再轻量,也要尊重这个边界。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
离线收益最容易出事故
放置类游戏常用本地时间计算离线收益。玩家改系统时间、浏览器休眠、页面后台冻结都会影响计算。纯本地离线收益适合非竞争、非付费核心的小项目;一旦涉及经济平衡,就应该让服务端参与时间校验。
客户端可以记录 lastSeenAt、lastServerAt、localElapsed 等信息。重新联网时,根据服务端时间修正收益。不要只用 Date.now 直接发奖励,否则很容易被普通玩家无意中刷坏。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
存档调试工具很值钱
开发版最好提供存档查看、导出、导入、清理、迁移重放、模拟旧版本、模拟写入失败等工具。存档问题往往只出现在版本升级或特定历史数据上,没有工具就只能手工改浏览器存储。
客服侧也可以提供复制诊断信息按钮,包含存档版本、更新时间、登录状态、同步状态、最近错误码。不要让玩家截图一堆浏览器控制台。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
落地清单
检查 Phaser H5 存档时,问这些问题:哪些字段是资产;localStorage 是否只放小数据;是否有备份键;是否逐版本迁移;读取失败是否不会覆盖旧档;云同步冲突是否按字段处理;隐私模式和清理数据是否有提示;离线收益是否防止时间异常。
存档系统看起来不像画面和战斗那么显眼,但它承载的是玩家投入。一个小游戏也需要认真对待存档,因为对玩家来说,丢失一晚上的进度和大型游戏丢档一样令人沮丧。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
排查问题时的顺序
遇到相关问题时,不建议先凭经验改参数。更稳的顺序是先复现,再缩小范围,最后才动代码。复现时要记录设备、浏览器、渠道容器、网络、页面可见状态、游戏版本和资源版本。很多 H5 游戏问题只在特定容器里出现,如果只在桌面 Chrome 里验证,很容易得到错误结论。
缩小范围时,可以把链路拆成输入、状态、资源、表现和持久化几段。先确认玩家意图有没有被收到,再确认状态机有没有接受,再确认 Phaser 对象有没有正确执行,再确认表现层有没有被镜头、缩放、缓存或音频策略影响。这样的排查路径比“看哪里像问题就改哪里”慢一点,但能避免改出新问题。
最后是留证据。开发版日志、调试面板、可视化边界、状态快照和小型回放,都比口头描述可靠。尤其是涉及 存档读写与迁移 的问题,录屏只能告诉你现象,不能告诉你内部状态。把内部状态展示出来,团队才有共同语言。
团队协作里的责任划分
Phaser 项目经常由少数工程师快速推进,因此容易忽略协作边界。可是一旦项目进入运营,策划会改配置,美术会换资源,运营会调整活动,渠道会接 SDK,测试会覆盖多设备。工程代码如果没有把责任划清,每个角色都会被迫理解太多底层细节。
比较健康的方式是让配置描述意图,让服务层解释规则,让 Scene 编排生命周期,让表现对象执行动画和反馈,让平台适配器处理浏览器或渠道差异。这样策划新增内容时不需要知道 Scene 的内部结构,美术替换资源时不会改变玩法规则,运营关闭活动时不会留下半开半关的 UI 状态。
这不是大团队才需要的流程。越小的团队越需要减少隐性沟通成本。一个清楚的边界,可以让后续每一次临时需求都少一点风险。
上线前最后一轮检查
最后一轮检查不要只点一遍主流程。至少要覆盖首次进入、第二次进入、弱网、低端设备、后台恢复、快速重复点击、资源失败、配置缺字段和旧数据升级。很多 Phaser Bug 都出现在“第二次”或者“恢复后”:第二次开局、第二次打开弹窗、第二次播放音频、第二次加载同一图集。
如果这篇文章讨论的系统已经接近上线,我会要求团队给出三类证据。第一是功能证据,证明主流程确实可用;第二是边界证据,证明失败和异常路径不会把玩家卡死;第三是观测证据,证明线上再出问题时能定位。只有这三类证据都存在,才算不是靠运气发布。
结语
Phaser 的优势是轻、快、直接。它能让一个想法很快变成可以玩的东西,也正因为如此,项目很容易在“先跑起来”之后忽略工程边界。存档是用户资产的本地投影,不是随手写入的一段 JSON。把这个原则落实到代码里,项目就不会因为功能增加而迅速失控。
真正可靠的 Phaser 游戏,不是每个模块都写得很重,而是关键链路有清楚的生命周期、明确的责任、可降级的失败路径和能解释问题的调试证据。做到这些,即使项目仍然保持轻量,也能承受上线后的真实流量和频繁改动。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。