Phaser 音游判定系统:节拍、延迟校准和判定窗口不能靠感觉

从 Phaser 实战角度拆解音游的节拍时钟、输入采样、延迟校准、判定窗口和回放调试,帮助小游戏团队做出稳定可信的节奏玩法。

为什么音游最怕“看起来差不多”

很多团队第一次用 Phaser 做音游,会把音符下落写成一个普通动画:每帧根据 delta 改变 y,音符碰到判定线时玩家点击就算命中。这个方案在演示机上看起来能跑,但只要换一台手机、切到后台再回来、蓝牙耳机多了几十毫秒延迟,判定立刻变得飘。玩家不会说“你的 delta 积累误差了”,他只会说“这游戏打起来不跟手”。音游系统的核心不是 Sprite 下落,而是让所有画面、输入、音乐和判定都围绕同一个可信时间轴工作。

在 Phaser 里,动画帧率、音频播放、浏览器事件和业务逻辑来自不同系统。scene.time.now 是渲染循环时间,WebAudio 有自己的播放时间,触摸事件到达 JavaScript 时已经经过系统队列,移动端还可能需要用户手势解锁音频。做普通动作游戏时,这些差异可以被手感参数掩盖;做音游时,20 毫秒就足以让 Perfect 变成 Great,50 毫秒会让熟练玩家怀疑人生。所以我们要先定一条原则:判定只信“歌曲时间”,画面只是歌曲时间的可视化。

这篇文章讨论一个可上线的小型音游架构:谱面以毫秒为单位记录音符时间,播放时用 WebAudio/Phaser Sound 推导当前歌曲时间,输入事件只记录发生时间和轨道,不直接改分数;每一帧由判定器消费输入和音符,给出 Perfect、Great、Bad、Miss。对于复杂逻辑,我们还要加上延迟校准、暂停恢复、调速预览和回放日志。这样做会比“点到音符就销毁”麻烦,但它能让问题可测、可复现、可调参。

把时间轴拆清楚

音游里至少有四种时间。第一种是谱面时间,也就是第 32000 毫秒出现一个鼓点;第二种是音频时间,也就是歌曲实际播放到哪里;第三种是渲染时间,用来算音符应该画在什么位置;第四种是输入到达时间,浏览器把按键或触摸事件交给你的那一刻。新手常犯的错是把这四种混在一起,最后所有修补都变成“把音符往上挪 12 像素”“判定线提前一点”。这些补丁没有单位,无法解释,也无法对不同设备生效。

更稳的做法是统一回到毫秒。谱面中的 timeMs 表示音符理论命中时间;当前歌曲时间 songMs 来自音频播放开始时间和当前上下文时间;输入事件记录 inputMs = songMs + calibrationOffsetMs,其中 calibrationOffsetMs 是玩家或设备校准出的补偿。判定时只比较 inputMs - note.timeMs,不关心当前帧率,也不关心音符在屏幕上画到哪里。渲染层则根据 note.timeMs - songMs 计算音符距离判定线还有多少时间,再映射成位置。

flowchart TD
  A["谱面 JSON:note.timeMs / lane / type"] --> B["SongClock:读取音频播放进度"]
  B --> C["Renderer:按 timeToHit 计算音符位置"]
  B --> D["InputSampler:把点击转换为 songMs"]
  D --> E["JudgeEngine:比较 inputMs 与 note.timeMs"]
  A --> E
  E --> F["ScoreModel:连击、准确率、结算"]
  E --> G["ReplayLog:记录输入与判定结果"]

谱面数据要保持朴素

谱面文件不要一开始就设计得像编辑器导出的黑盒。一个实用版本可以包含 BPM 段、轨道数量、音符列表和资源引用。音符只需要 idlanetimeMstypedurationMs,长按音符才使用持续时间。BPM 段主要用于编辑器网格和视觉辅助,实际判定仍然以毫秒为准。这样美术、策划和程序对齐时不会被节拍换算绕晕,导入导出也容易写测试。

谱面解析时要做严格校验:时间必须递增或至少可排序;同一轨道同一时间不能出现两个普通音符;长按音符不能重叠;第一颗音符不要小于预备倒计时时间;最后一颗音符之后要留结算缓冲。校验失败时不要在游戏里静默跳过,应该在开发模式直接抛出带行号或音符 id 的错误。音游问题很多来自脏谱面,运行时临时容错只会把锅推给玩家。

SongClock 是系统核心

Phaser 的 Sound 系统对不同浏览器有封装,但我们仍然需要一个自己的 SongClock。它负责记录开始播放时的音频时间、暂停时的偏移、恢复时的基准,以及全局校准。注意,SongClock 不应该知道音符,也不应该知道分数。它只回答一个问题:现在歌曲播放到多少毫秒。暂停、重开、从副歌位置预览,都应该通过它设置基准。

下面是一个精简版本,真实项目里还要处理音频解锁、失焦、倍速和错误事件:

type ClockState = "idle" | "playing" | "paused";

export class SongClock {
  private state: ClockState = "idle";
  private baseAudioSec = 0;
  private baseSongMs = 0;
  private pausedSongMs = 0;

  constructor(
    private readonly sound: Phaser.Sound.WebAudioSound,
    private readonly audioContext: AudioContext,
    private readonly calibrationMs: () => number,
  ) {}

  start(fromMs = 0) {
    this.baseSongMs = fromMs;
    this.baseAudioSec = this.audioContext.currentTime;
    this.pausedSongMs = fromMs;
    this.state = "playing";
    this.sound.play({ seek: fromMs / 1000 });
  }

  pause() {
    if (this.state !== "playing") return;
    this.pausedSongMs = this.rawSongMs();
    this.sound.pause();
    this.state = "paused";
  }

  resume() {
    if (this.state !== "paused") return;
    this.baseSongMs = this.pausedSongMs;
    this.baseAudioSec = this.audioContext.currentTime;
    this.sound.resume();
    this.state = "playing";
  }

  songMs() {
    return this.rawSongMs();
  }

  inputMs() {
    return this.rawSongMs() + this.calibrationMs();
  }

  private rawSongMs() {
    if (this.state === "paused") return this.pausedSongMs;
    if (this.state !== "playing") return this.baseSongMs;
    return this.baseSongMs + (this.audioContext.currentTime - this.baseAudioSec) * 1000;
  }
}

这个类的关键不是代码多复杂,而是把“音频时间”作为可信来源。不要在 update(time, delta) 里累加歌曲时间,因为浏览器降帧、切后台、调试器断点都会让累加值偏离真实播放进度。也不要把输入时间记成 Date.now(),它和音频上下文没有共同基准。只要所有系统都问 SongClock,调试时就能把误差定位到校准、输入、音频启动或谱面数据中的某一层。

判定窗口要能解释

判定窗口通常不是一个数字,而是一组区间。比如 Perfect <= 32msGreat <= 70msGood <= 110ms,超过 140ms 未击中就是 Miss。这个配置要写成数据,不要散落在判断语句里。更重要的是,窗口应区分早击和晚击的提示。玩家点早了,反馈应该告诉他 Early;点晚了,反馈应该告诉他 Late。没有方向的 “Great” 对练习没有帮助。

判定器要维护一个待判定音符队列。输入到来时,只在对应轨道找离输入时间最近且尚未判定的音符。如果差值在最大窗口内,给出评级并标记音符;如果没有找到,不要立刻扣 Miss,可以记录一次空击。Miss 应由时间推进触发:当 songMs 已经超过音符时间加最大晚判窗口,且音符仍未命中,才判 Miss。这样可以避免输入和帧更新顺序导致的边界问题。

长按音符要拆成按下、保持、释放三个阶段。按下判头部,保持阶段根据当前歌曲时间检查是否断开,释放判尾部。不要用 Sprite 是否还在触摸区域判断长按,因为手指轻微移动、浏览器触摸取消、屏幕边缘手势都会带来误差。输入层只告诉你某条轨道按下或抬起,判定层根据时间决定结果。

延迟校准不是设置页摆设

校准页面应该是音游的正式功能,不是开发调试工具。一个可信的校准流程可以播放稳定节拍,让玩家连续点击 20 到 40 次,去掉最早和最晚的异常值,再计算平均偏差。如果玩家总是比节拍晚 45ms 点击,系统可以给出 calibrationOffsetMs = -45,让输入时间向前补偿。这里的符号一定要写清楚,并在代码和 UI 文案中保持一致。

移动端还要考虑音频输出延迟,尤其是蓝牙耳机。蓝牙延迟可能达到 100ms 以上,并且不同设备差异明显。不要把校准值存在服务器账号全局配置里直接套到所有设备,至少要按设备或浏览器环境单独保存。存储时可以记录 userAgent、音频输出选择、校准日期和样本标准差。标准差过大说明玩家没有跟准节拍,应该提示重新校准,而不是保存一个看似精确的补偿值。

渲染层只负责“看起来对”

音符下落距离可以用 timeToHit = note.timeMs - songMs 计算。假设预览时间是 1800ms,音符从屏幕上方出现到判定线正好 1.8 秒。位置映射可以是线性的,也可以略带缓动,但判定不能依赖它。低端手机掉帧时,音符可能跳过几个像素,但只要它的位置来自歌曲时间,玩家看到的节奏仍然和音乐一致。

音符对象可以使用对象池,因为高 BPM 谱面中频繁创建销毁 Sprite 会造成 GC 抖动。池化时要小心重置所有状态:贴图、轨道、透明度、缩放、tint、交互状态、长按尾部长度。对象池的 bug 很隐蔽,经常表现为某个音符继承了上一颗音符的闪光或判定标记。建议在开发模式给每个音符显示 id 和 timeMs,出现错位时能直接对应谱面。

回放日志让争议有证据

音游上线后,玩家会反馈“这一段明明点到了”。没有回放日志时,团队只能猜。一个轻量日志可以记录歌曲 id、谱面版本、校准值、每次输入的 lane 和 inputMs、每次判定的 noteId、delta、rating。日志不需要上传所有玩家数据,但本地调试、客服复现和自动化测试都很有价值。尤其是谱面更新后,如果旧回放无法重放,说明谱面版本或判定规则没有被固定下来。

回放系统还有一个好处:可以做判定回归测试。把一段输入序列喂给判定器,断言结果仍然是某个准确率。调整窗口、修正长按逻辑、改动暂停恢复时,测试可以立刻告诉你是否影响旧谱面。对于音游这种对毫秒敏感的玩法,回归测试比肉眼试玩可靠得多。

上线前检查清单

第一,确认歌曲时间来自音频上下文,而不是帧循环累加。第二,所有判定窗口都有配置表,并能输出 Early/Late。第三,暂停恢复后连续播放 5 次,第一颗音符的判定时间没有漂移。第四,校准页面能过滤异常样本,并按设备保存。第五,长按音符在触摸取消、切后台、弹窗打断时有明确处理。第六,低帧率模式下音符位置可能跳动,但判定结果不受帧率影响。第七,谱面版本进入回放日志,旧回放不会被新谱面误解释。第八,调试 UI 能显示当前 songMs、audio currentTime、校准值和最近 10 次 delta。

如果只能做一个最小版本,我会优先实现 SongClock、判定窗口配置和输入日志,而不是先做华丽特效。音游的爽感来自“我知道自己为什么打得好或差”,不是来自音符爆炸多漂亮。Phaser 很适合做轻量音游,但前提是你把时间当作系统边界,而不是把它藏在动画参数里。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页