为什么要把它当成系统来做
休闲射击游戏开启第三赛季,玩家打一局后获得 850 点赛季经验,从 12 级升到 14 级,免费轨道拿到金币,付费轨道有皮肤但尚未解锁。界面要让玩家清楚发生了什么。
通行证系统不只是画一条奖励轨道。它要处理经验分段、任务来源、补领、付费解锁后的历史奖励、动画节奏和跨设备刷新。客户端要把奖励状态解释清楚,不能制造误领或漏领。 本文不把它写成一个一次性 Demo,而是按可上线、可维护的小系统拆开。重点不是堆 API,而是回答几个真实问题:数据从哪里来,谁有权修改状态,失败时玩家看到什么,调试时程序能看到什么,内容增加后系统还能不能承受。
核心架构
flowchart TD
Ne5afb9e5b1["对局结算 XP"] --> N50726f6772["ProgressLedger"]
N50726f6772["ProgressLedger"] --> Ne7ad89e7ba["等级计算"]
Ne7ad89e7ba["等级计算"] --> N5265776172["RewardTrack"]
N5265776172["RewardTrack"] --> Ne58fafe9a2["可领取状态"]
Ne58fafe9a2["可领取状态"] --> Ne9a286e58f["领取请求"]
Ne9a286e58f["领取请求"] --> Ne5a596e58a["奖励到账"]
Ne5a596e58a["奖励到账"] --> Ne58aa8e794["动画与红点"]
这张图的关键,是把 PassConfig、ProgressLedger、RewardTrack、ClaimState、MissionPanel、LevelUpAnimator 放在单向流里。玩家输入或系统 tick 进入核心模型,模型产出结果,Phaser 再把结果转成动画、粒子、声音和界面。不要让显示对象反向决定规则。只要核心模型能在没有 Canvas 的环境中运行,就能写测试、做编辑器预览,也能在以后接入服务端校验或云存档。
等级计算要用配置表驱动
每级所需经验通常不是线性的,赛季中还可能调整追赶倍率。客户端应加载 PassConfig,包含 level、requiredXp、freeReward、premiumReward、displayGroup 等字段。当前经验只是一条数字,等级和进度条都由配置推导。这样策划改曲线时不需要改 UI 逻辑。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
经验结算要可回放
对局结束后,结算面板最好展示经验来源:完成比赛、每日任务、首胜、活动加成。ProgressLedger 记录每一项来源和数值,LevelUpAnimator 按这些来源依次推进。若网络刷新后总经验不同,客户端可以重新从 ledger 对齐,而不是让进度条卡在错误位置。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
免费和付费轨道共享等级但状态不同
同一级可能有免费奖励和付费奖励。玩家未购买时,付费奖励显示已达成但锁定;购买后,历史已达成的付费奖励应进入可补领状态。不要把付费奖励简单隐藏,否则购买前玩家无法评估价值,购买后也容易漏领。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
领取状态必须幂等
奖励领取是高风险操作。每个奖励有 rewardKey 和 claimState:locked、claimable、claimed、pending。点击领取后进入 pending,服务端确认变 claimed,失败回到 claimable。重复点击、网络重试和跨设备领取都应以 rewardKey 去重。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
动画节奏服务理解
一次升多级时,不要用过长动画把玩家困住。可以先播放经验条快速推进,再在关键等级停顿展示奖励。跳过按钮不应跳过状态更新,只是把动画时间压缩到结尾。动画结束时,UI 根据最终状态刷新红点和可领取按钮。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
任务刷新要和赛季时间绑定
每日和每周任务会给通行证经验,刷新时间要显示具体倒计时,并处理时区。客户端可以倒计时,但真正刷新由服务端时间决定。若玩家跨天在线,任务面板要在下一次同步后更新,避免旧任务继续显示可做。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
赛季结束要有只读模式
赛季结束后,玩家可能还能补领奖励,但不能继续获得经验。界面应进入 ended 状态,隐藏任务入口或标注已结束,领取入口保留到宽限期。宽限期结束后,通行证只作为历史展示。这个状态如果不提前设计,赛季切换时最容易出事故。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
TypeScript 实现骨架
interface LevelReward { level: number; xp: number; free?: string; premium?: string }
type ClaimState = "locked" | "claimable" | "pending" | "claimed";
function levelFromXp(totalXp: number, config: LevelReward[]) {
let level = 0;
for (const row of config) if (totalXp >= row.xp) level = row.level;
const current = config.find(r => r.level === level);
const next = config.find(r => r.level === level + 1);
const base = current?.xp ?? 0;
const span = next ? next.xp - base : 1;
return { level, progress: next ? (totalXp - base) / span : 1 };
}
function rewardState(row: LevelReward, totalXp: number, claimed: Set<string>, premium: boolean) {
const reached = totalXp >= row.xp;
const free = !row.free ? "locked" : claimed.has(row.free) ? "claimed" : reached ? "claimable" : "locked";
const paid = !row.premium ? "locked" : claimed.has(row.premium) ? "claimed" : reached && premium ? "claimable" : "locked";
return { free: free as ClaimState, premium: paid as ClaimState };
}
这段代码只是骨架,真正项目里还要加事件派发、错误码、配置校验和日志。但骨架已经表达了方向:核心概念是普通 TypeScript 对象,Phaser 类型只出现在输入适配或表现需要的地方。若你发现某个函数越来越依赖 Scene、Camera 或 Sprite,就应该停下来判断它是不是被放错层了。
落地步骤
- 第一,确认 PassConfig 的输入输出是否是纯数据。若需要 Phaser.GameObjects 才能计算结果,说明边界还没有切开。
- 第二,给 ProgressLedger 或同等级的核心概念写三个最小样例:正常路径、边界路径、失败路径。样例要能在没有浏览器画面的情况下运行。
- 第三,把 UI 上每个可点击动作都映射成明确意图,不要让按钮直接修改深层状态。意图里带 requestId,便于防重复和追踪。
- 第四,失败反馈要比成功反馈更早接入。成功时玩家通常愿意接受,失败时才会质疑系统是否可靠。
- 第五,内容配置要有默认值和校验脚本。字段缺失时宁可在启动时报错,也不要在玩家操作到一半才静默失败。
- 第六,性能指标要提前量化:每帧最多处理多少对象、单次刷新允许多少毫秒、低端机是否需要降级显示。
常见坑
- 最容易踩的坑,是让表现层过早成为事实来源。比如动画播完才算成功、按钮亮着就代表可用、某个 Sprite 存在就说明状态存在。这些判断在演示机上没问题,一到跳过、暂停、断线、切场景和重连就会变成隐性故障。
- 第二个坑是只为第一关写逻辑。第一关对象少、路径短、输入慢,任何写法都像是正确的。等内容增加到几十张地图、几百个配置和各种活动修正时,临时判断会互相覆盖。写系统时要假设它会被复用、被误用、被配置错。
- 第三个坑是没有留下证据。玩家反馈“刚才没生效”时,如果没有事件日志、状态快照或 requestId,只能靠猜。哪怕是单机项目,也可以保留最近 50 条关键事件,开发包里导出文本,定位速度会快很多。
项目里的验证方式
把这套系统放进 Phaser 项目时,我会先建一个不依赖 Scene 的核心目录,例如 src/gameplay/season-pass-progression。里面只放模型、求解器、状态机和测试夹具。Scene 只负责输入适配、对象池、摄像机、音效和 UI。这个边界看似多写几行代码,但它换来的是可测试、可回放和可迁移。等项目进入内容生产阶段,最值钱的不是某个特效多漂亮,而是当策划说某个状态不对时,程序能在五分钟内复现并解释。
数据格式要尽量像内容团队会填写的表,而不是像程序临时拼出来的对象。每个 id 都要稳定,每个状态都要能序列化,每个失败原因都要有明确枚举。Phaser 的优势是快速把反馈做出来,但反馈越快,越容易掩盖规则层的混乱。先把规则层写清楚,再接动画,后续加新模式、新活动或新平台才不会反复拆墙。
在调试阶段,建议给这个系统加一个小面板:显示当前输入、核心状态、最近事件、最后一次失败原因和关键耗时。面板不需要好看,但要准确。很多客户端问题在日志里只是一句 undefined,在调试面板里却能看到完整链路。尤其是多人、存档、复杂 UI 或长时间运行的玩法,调试可见性直接决定维护成本。
最后检查
做完第一版后,不要只看一次演示是否顺滑。至少准备三组数据:一组正常流程,一组边界流程,一组故意配置错误的流程。正常流程证明体验成立,边界流程证明状态不会漂移,错误流程证明系统会给出可理解的失败原因。赛季通行证进度:等级、任务、补领和动画节奏 的质量不取决于第一眼多热闹,而取决于玩家反复操作、内容不断扩张、版本持续迭代时,它还能保持清楚、稳定和可解释。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。