为什么这个系统值得单独设计
俯视角竞速游戏里,玩家在夜间城市赛道刷圈速。最后 0.08 秒的差距决定是否进入好友榜前三。系统必须准确记录每一圈、每个检查点和每次重置,否则玩家会怀疑排行榜不公平。
竞速计时不是从起点到终点的秒表。它要处理检查点顺序、反向通过、抄近路、暂停、重开、碰撞重置、分段成绩和排行榜提交。Phaser 的物理触发区只是输入,真正的成绩规则要独立出来。 本文按实际项目会遇到的问题来拆,不停留在“能跑”的 Demo 层。重点会放在数据边界、状态流、玩家反馈、调试方式和后续维护成本上。Phaser 很适合快速做出手感,但越是能快速表现,越需要把规则层写清楚。
核心架构
flowchart TD
N1["CheckpointTrigger"] --> N2["LapTracker"]
N2["LapTracker"] --> N3["ShortcutGuard"]
N2["LapTracker"] --> N4["SplitTimer"]
N4["SplitTimer"] --> N5["HUD"]
N2["LapTracker"] --> N6["RunRecorder"]
N6["RunRecorder"] --> N7["成绩摘要"]
N7["成绩摘要"] --> N8["LeaderboardClient"]
这套结构的原则是单向流动:输入或场景事件进入 CheckpointTrigger,核心模型完成计算,再由 Phaser 表现层消费结果。LapTracker、SplitTimer、ShortcutGuard、RunRecorder、LeaderboardClient 都应尽量保持可序列化、可测试、可回放。不要让某个 Tween 完成回调、某个 Sprite 是否可见、某个按钮是否高亮成为玩法事实。
检查点必须有顺序
每个检查点都有 index、宽度、方向和容错范围。车辆进入触发区时,LapTracker 只接受下一个期望 index。反向穿过或跳过检查点都不推进。这样玩家从近路穿回终点也不会得到合法圈速。触发区可以比赛道稍宽,但方向判断能防止倒车刷点。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
圈速和分段要分开
完整圈速用于成绩,分段 split 用于反馈。玩家通过每个检查点时记录 splitTime,并和个人最佳分段比较。HUD 可以显示绿色提前或红色落后。即使本圈最终无效,分段也能用于训练反馈,但不能提交排行榜。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
暂停和重置要清楚
如果游戏允许暂停,计时应基于比赛时间而不是真实时间。重置车辆到赛道时,本圈是否作废要明确。常见做法是碰撞重置不作废,手动回到赛道作废当前圈,离开赛道太久作废。规则写在 LapTracker,不要散在按钮和碰撞回调里。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
防抄近路不能只靠墙
玩家总能找到跳过碰撞的办法。ShortcutGuard 可以记录最近通过的检查点、离开赛道区域的时长和车辆位置是否穿越非法区域。赛道编辑器给危险区域画 invalid zones,进入后标记当前圈 invalid,但仍允许玩家继续练习。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
成绩摘要比完整回放轻
完整输入回放很有价值,但排行榜提交不一定需要上传全部数据。RunRecorder 可以保存每个检查点时间、最大速度、碰撞次数、越界次数、车辆配置和随机种子。服务端或本地验证可以先用摘要判断明显异常,再决定是否请求完整回放。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
HUD 反馈要低干扰
竞速时玩家注意力集中在路线。圈速、分段和无效提示要在固定区域显示,不要弹大窗。当前圈无效后,提示要清楚但不遮挡驾驶;下一次过起点时自动恢复。声音反馈可以比文字更快,但要允许关闭。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
编辑器要检查赛道闭环
赛道发布前检查起点、检查点顺序、触发区覆盖、方向向量和 invalid zones 是否完整。还要用一条模拟路线验证从起点按顺序能跑完一圈。没有工具时,竞速项目很容易出现某个检查点太窄或方向写反。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
TypeScript 实现骨架
interface Checkpoint { index: number; x: number; y: number; direction: Phaser.Math.Vector2 }
type LapState = "ready" | "running" | "invalid" | "finished";
class LapTracker {
state: LapState = "ready";
expected = 0;
splits: number[] = [];
private startTime = 0;
begin(now: number) { this.state = "running"; this.expected = 0; this.splits = []; this.startTime = now; }
pass(checkpoint: Checkpoint, vehicleVelocity: Phaser.Math.Vector2, now: number) {
if (this.state !== "running") return;
if (checkpoint.index !== this.expected) { this.state = "invalid"; return; }
if (vehicleVelocity.dot(checkpoint.direction) < 0) return;
this.splits.push(now - this.startTime);
this.expected += 1;
}
finish(totalCheckpoints: number, now: number) {
if (this.state === "running" && this.expected === totalCheckpoints) {
this.state = "finished";
return { lapMs: now - this.startTime, splits: [...this.splits] };
}
}
invalidate(reason: string) { this.state = "invalid"; }
}
这段代码不是完整框架,而是把关键边界先立出来。实际项目里应继续补上配置加载、错误码、事件派发、性能统计和单元测试。只要骨架保持清楚,后续接入 Phaser 的 Graphics、Sprite、Matter、Tilemap 或 Sound 都不会污染规则层。
具体落地步骤
- 第一步,把 CheckpointTrigger 和 LapTracker 从 Scene 中拆出来,写成可以直接用 TypeScript 调用的模型。这个模型只接收普通对象,不接收 Sprite、Camera 或 Tween。只要这一步做到,后面的测试、调试、存档和工具预览都会简单很多。
- 第二步,在 Phaser Scene 里建立很薄的适配层。输入事件、物理回调、计时器和资源加载都可以在适配层发生,但它们只提交意图,不直接改核心状态。核心系统产出快照后,适配层再更新显示对象、音效、粒子和 HUD。
- 第三步,给每个关键状态准备调试可视化。不要等 QA 报问题才补日志。开发模式下至少能看到当前状态、最近输入、失败原因、候选列表、耗时和重要阈值。对复杂玩法来说,能看见中间状态比多写一层封装更重要。
- 第四步,用三类样例保护系统:正常流程、边界流程、错误配置。正常流程证明体验能跑通,边界流程证明快速输入、暂停、切场景和重复触发不会破坏状态,错误配置证明系统会给出明确报告,而不是静默失败。
项目检查清单
- 确认 CheckpointTrigger 的输入输出能被 JSON 记录,便于复现玩家操作。
- 确认 LapTracker 的配置有默认值、版本号和校验错误信息。
- 确认快速点击、暂停、切后台、重开场景和读档不会重复提交关键状态。
- 确认失败反馈比成功反馈更具体,玩家能理解自己为什么没有成功。
- 确认低端机或高负载场景有降级策略,而不是等帧率下降后再猜瓶颈。
- 确认调试面板能在不改代码的情况下打开,并能导出最近关键事件。
常见误区
第一类误区,是把 Phaser 的显示对象当成状态来源。显示对象适合表达结果,却不适合保存规则事实。它可能被对象池回收、被摄像机隐藏、被动画临时修改,也可能因为画质档变化而不存在。核心状态必须独立存在。
第二类误区,是只为当前关卡写逻辑。当前关卡对象少、节奏慢、输入简单,临时判断看起来没有问题。等到内容增加、节奏加快、平台变多,临时逻辑会互相覆盖。每个系统至少要提前考虑配置错误、重复触发和性能上限。
第三类误区,是没有把失败当成流程设计。复杂系统一定会失败:条件不满足、资源缺失、网络超时、玩家中断、配置非法。失败不应该只是 console 里的一行错误,而应该是玩家、QA 和内容团队都能理解的状态。
个人最佳与本圈反馈
竞速游戏的成就感来自连续比较。玩家通过第二个检查点时,不只是想知道当前用时,还想知道自己比个人最佳快了多少。SplitTimer 可以保存 bestSplits,并在每次通过检查点后计算 delta。HUD 上的 delta 要稳定,不要每帧跳动;通常在通过检查点后显示一到两秒即可。若本圈已经无效,仍然可以显示练习分段,但颜色和文案要明确区分“训练参考”和“合法成绩”,避免玩家以为无效圈还能提交。
成绩提交要保留证据
即使是纯客户端小游戏,也建议把成绩摘要写得严肃一点。提交数据至少包含赛道 id、赛道版本、车辆配置、圈速、分段、是否使用辅助、重置次数、输入设备和客户端版本。这样以后发现某个版本的车辆碰撞有漏洞,可以筛选受影响成绩。Phaser 端不需要承担所有反作弊,但它必须提供足够清楚的运行证据,否则排行榜问题只能靠删除和道歉解决。
结语
竞速圈速系统:检查点、防抄近路和成绩回放摘要 的难点不在某个 API,而在边界。把数据、规则、表现和调试分开后,Phaser 的优势会更明显:你可以很快做出反馈,也可以放心迭代规则。反过来,如果所有逻辑都散落在 Scene 的回调里,第一版越快,后续越难维护。
额外实践建议
- 把计时源统一成比赛时间,暂停、慢动作和回放才不会污染成绩。
- 排行榜提交要带赛道版本,赛道一改,旧成绩和新成绩不能混排。
- 调试模式要显示下一个期望检查点和当前圈是否有效,测试抄近路会快很多。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。