成就系统不是弹一个“已完成”
成就看起来像外围系统:击杀 100 个敌人,通关第一章,收集 10 个隐藏物,弹一个提示。真正上线后,它会牵涉事件统计、存档、云同步、奖励、平台成就、弹窗节流和防重复解锁。若每个玩法系统自己判断成就,就会出现重复弹窗、进度不准、离线解锁丢失、版本更新后旧玩家无法补发成就等问题。成就系统要像一条流水线,持续接收游戏事件,更新进度,判断条件,发出解锁结果。
Phaser Scene 不应该直接写“如果击杀数大于 100 就弹成就”。Scene 或玩法系统只发事件:敌人死亡、关卡完成、物品获得、连击达成。AchievementService 订阅事件并维护进度。弹窗只是解锁结果的一种表现。这样成就可以跨场景、跨会话、跨设备工作,也能和平台成就或运营活动复用同一套事件。
成就分为瞬时、累计和状态型
瞬时成就是某个事件发生时立即判断,比如“第一次死亡”“无伤击败 Boss”。累计成就是长期计数,比如“累计消灭 1000 个敌人”。状态型成就是检查玩家状态,比如“拥有 10 件紫色装备”。三类成就的实现不同。瞬时成就依赖事件上下文,累计成就依赖计数器,状态型成就需要在状态变化或登录时重新评估。
如果所有成就都写成事件触发,会漏掉状态型补发;如果每次登录全量扫描所有成就,又可能性能差。建议配置里声明 evaluator 类型,服务按类型选择更新策略。成就系统越早区分这些类型,后期越容易扩展。
flowchart TD
A["GameplayEvent:击杀、通关、拾取、连击"] --> B["AchievementEventBus"]
B --> C["ProgressStore:累计计数和状态快照"]
C --> D["ConditionEvaluator:判断成就条件"]
D --> E{"是否首次解锁"}
E -- "是" --> F["UnlockRegistry 写入 unlockedAt"]
F --> G["PopupQueue:节流展示"]
F --> H["RewardService:发放奖励"]
F --> I["PlatformSync:同步平台成就"]
E -- "否" --> J["忽略重复事件"]
事件要语义化
成就事件不要直接传 Phaser 对象。传递语义数据:enemyId、enemyType、levelId、damageTaken、comboCount、itemRarity、timestamp。这样事件可以存档、回放和测试。不要让 AchievementService 依赖某个 Enemy Sprite 是否还存在。事件一旦发出,就应该包含判断所需的上下文。
事件命名也要稳定。enemyKilled、levelCompleted、itemCollected 这类可以长期复用。临时活动成就也尽量复用通用事件,通过条件过滤。不要为每个成就创建一个事件,否则事件系统会膨胀,玩法层也会知道太多成就细节。
进度统计要幂等
累计成就最怕重复统计。玩家击杀一个敌人,死亡事件可能因为同步、回放或对象销毁被触发两次。ProgressStore 可以记录事件 id 或来源 id,关键事件只统计一次。不是所有事件都需要全量去重,但奖励和成就相关的关键事件要有幂等保护。比如 enemyKill:runId:enemyInstanceId,重复到达时忽略。
离线游戏也需要保存进度。每次进度变化都立即保存可能频繁,批量保存又可能崩溃丢失。可以在关键节点和定时器保存,成就解锁时立即保存。若成就带奖励,解锁和发奖最好在同一个持久化事务中完成。
一个成就判断器
下面的代码展示累计型成就的基本结构。真实项目还要支持复杂条件、分组和平台同步。
interface AchievementConfig {
id: string;
type: "counter";
counterKey: string;
target: number;
}
interface AchievementState {
counters: Record<string, number>;
unlockedAt: Record<string, number | undefined>;
}
export function evaluateAchievements(
configs: AchievementConfig[],
state: AchievementState,
nowMs: number,
) {
const unlocked: string[] = [];
for (const cfg of configs) {
if (state.unlockedAt[cfg.id]) continue;
const value = state.counters[cfg.counterKey] ?? 0;
if (value >= cfg.target) {
state.unlockedAt[cfg.id] = nowMs;
unlocked.push(cfg.id);
}
}
return unlocked;
}
这个函数只处理条件,不播放弹窗也不发奖励。解锁结果交给后续流水线。这样你可以单独测试“计数达到目标只解锁一次”,也可以在登录时重新评估旧玩家是否满足新增成就。
弹窗要排队和节流
成就弹窗很容易打扰玩家。一次连锁结算可能同时解锁 5 个成就,如果同时弹出,屏幕会很乱。PopupQueue 应按优先级排队,每次只显示一个或合并显示。战斗高压、剧情演出、广告播放、拍照模式时,可以延迟展示。成就已解锁的事实先写入状态,弹窗只是稍后表现。
弹窗内容要短:成就名、图标、一句话描述、奖励摘要。点击可以打开成就详情,但不要强制打断。移动端上,弹窗位置避开虚拟摇杆和关键按钮。若玩家关闭通知,仍应在成就页看到新解锁标记。
奖励和平台同步
有些成就只有荣誉,有些成就给奖励。奖励发放要幂等,绑定 achievementId。若平台成就同步失败,不应影响本地奖励。若本地解锁成功但服务端失败,需要进入 pending sync,下次登录重试。平台成就通常有自己的 id,配置中要记录映射。不要在代码里写死平台 id,否则多平台发布很难维护。
成就新增或条件调整时,要考虑旧玩家。新增“通关第一章”成就时,已经通关的玩家登录后应补发。状态型和累计型成就可以通过存档评估;瞬时成就若没有历史数据,就无法补发,需要在版本说明中接受这个限制。以后重要事件要尽量记录历史。
隐藏成就和剧透
隐藏成就可以显示问号,也可以完全隐藏。若成就名称本身剧透 Boss 或剧情,列表中不应提前展示。解锁后再显示完整信息。条件判断仍正常运行。隐藏成就的弹窗也要注意时机,剧情关键瞬间不要用成就标题破坏氛围。
成就描述和实际条件必须一致。玩家按描述完成却不解锁,会非常挫败。复杂条件可以在详情页显示进度,比如“无伤通关:本局受伤 0/0”。若条件故意隐藏,也要避免误导。
调试和数据分析
开发模式需要成就面板:所有成就状态、计数器、最近事件、解锁时间、奖励状态、同步状态。支持重置某个成就、强制解锁、模拟事件。成就 bug 很多发生在边界,比如离线、重复、版本迁移,有工具才能快速定位。
数据分析上,成就完成率能反映关卡难度和系统参与度。若“第一次合成”完成率很低,说明引导或入口有问题;若“无伤 Boss”极低,可能成就太难或描述不清。成就不只是奖励,也是一套行为观察点。
上线前检查清单
确认玩法系统只发语义事件;确认成就区分瞬时、累计、状态型;确认累计事件有幂等保护;确认解锁和奖励持久化可靠;确认弹窗队列能延迟和合并;确认平台同步失败不影响本地状态;确认新增成就能为旧玩家补评估;确认隐藏成就不会剧透;确认调试面板能查看计数器和最近事件;确认成就描述与真实条件一致。
成就系统做得好,会让玩家觉得自己的行为被世界记住。Phaser 负责把弹窗和图标做得轻巧,真正重要的是事件、进度和解锁流水线。把成就从 UI 功能提升为稳定系统,后续活动、平台同步和长期目标都会更容易。
分阶段成就和进度展示
很多成就适合做阶段:击杀 10、100、1000 个敌人;收集 5、20、50 个宝物。阶段成就可以共享同一个 counter,但有多个目标。UI 展示时,玩家看到下一阶段进度,而不是列表里堆三条相似成就。配置中可以用 achievement group 表达阶段关系,解锁低阶段后自动显示高阶段。
进度展示要避免误导。若成就是隐藏条件,不显示具体进度;若是累计目标,就显示当前值和目标值。状态型成就可能需要显示“已拥有 7/10 件紫装”,但如果装备被分解后是否回退,要提前定。通常成就进度一旦达到就不回退,状态型解锁可以在达到瞬间固定。否则玩家会因为换装备导致进度下降而困惑。
奖励领取和自动发放
成就奖励可以自动发,也可以手动领取。自动发放顺滑,但玩家可能错过奖励来源;手动领取能增加回访成就页,却增加操作。无论哪种,奖励状态要独立于解锁状态。unlockedAt 表示条件达成,rewardClaimedAt 表示奖励已领。这样自动发放失败时可以重试,手动领取也能防重复。
如果成就奖励通过邮件补发,也要保留 achievementId。玩家打开邮件能看到“成就奖励:初次通关”。奖励系统不要只知道“发 100 金币”,还要知道来源。经济日志和成就日志能互相对上,客服排查会容易很多。
跨平台同步冲突
玩家可能在离线设备 A 解锁成就,又在设备 B 在线游玩。同步时要合并,而不是互相覆盖。成就解锁是单向增加,合并规则通常是取已解锁并保留最早时间;计数器则取最大值或按事件日志合并,取决于系统设计。若简单用云端覆盖本地,离线成就可能丢。
平台成就也可能不同步。Steam、Google Play、Apple Game Center 等平台都有自己的延迟和失败。客户端应把平台同步视为附加任务,本地成就状态先稳定。同步失败进入队列,下次启动或网络恢复时重试。不要因为平台 API 错误阻止本地弹窗和奖励。
成就配置的内容校验
成就配置要做校验。图标是否存在,标题和描述是否本地化,counterKey 是否被事件系统产生,奖励 itemId 是否有效,隐藏成就是否被列表正确遮蔽。配置错误不应到线上才发现。导出内容时跑校验脚本,开发模式启动也可以警告。
成就描述尤其要校验数字。配置目标是 100,描述写“击败 50 个敌人”,玩家会困惑。可以在描述中使用占位符 {target},由配置渲染。这样调数值时文案自动更新。本地化也更安全。
与活动任务的复用边界
成就和活动任务都监听事件、累计进度、发奖励,很容易合并。但它们生命周期不同。成就是长期永久目标,活动任务有开始和结束时间,奖励过期规则也不同。可以复用事件和进度基础设施,但不要让活动任务直接写进永久成就状态。否则活动结束后遗留条件会污染成就系统。
一个好的边界是:ProgressService 负责计数,AchievementService 和 EventTaskService 各自解释进度。这样“击杀火焰怪”这个计数可以同时服务成就和活动,但解锁、领奖、过期互不干扰。
成就页的信息架构
成就页不要只是长列表。可以按章节、玩法系统、稀有度和完成状态分组,默认显示接近完成和新解锁内容。隐藏成就放在单独区域,避免剧透。每个成就展示图标、名称、描述、进度、奖励和完成时间。移动端上,列表项要足够高,领取按钮不要和滚动手势冲突。
成就页也可以反向引导玩法。点击未完成成就,显示推荐入口,例如“前往训练场练习连击”或“查看还缺的收集品区域”。这不是强推,而是让长期目标变得可行动。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。