最近在项目里做了个小玩法:屏幕上方不断掉字符,玩家在字符落到键位附近时按对应按键,命中就加分,漏掉就掉血。

我一开始没有先抠视觉,而是先把几件事定住:

  • 一个下落字符要有哪些字段(出现时间、目标键位、当前位置、是否命中)
  • 命中后特效怎么记录(位置、开始时间、类型)
  • 难度怎么控制(掉落速度、同屏数量、容错)

这些先定好,后面就不容易乱。

先说最核心的一点:别用“每帧位移”,用“时间算位置”

我最开始就避开了“每帧 y + 速度”这套。
因为帧率一抖,手感就会变,低性能机器和高性能机器会像两个游戏。

所以我直接按时间算:

  • 音符什么时候生成
  • 当前时间离生成时间过去了多久
  • 再换算成当前位置

这样即使偶尔掉帧,整体节奏也不会散。

1
2
3
4
5
6
7
8
const getNoteY = useCallback((note: FallingNote, currentMs: number, keyPositions: Map<string, any>) => {
const targetPos = keyPositions.get(note.keyId)
if (!targetPos) return -100
const top = -NOTE_SIZE
const targetY = targetPos.y
const progress = (currentMs - note.spawnAtMs) / settings.dropMs
return top + progress * (targetY - top)
}, [settings.dropMs])

位置计算逻辑是线性的插值:

  • 起点:top = -NOTE_SIZE(音符刚出现时在屏幕上方)
  • 终点:targetY = 对应键位的 y
  • 进度:progress = (currentMs - spawnAtMs) / dropMs
  • 最终:y = top + progress * (targetY - top)

也就是说:

  • progress = 0 时,音符在起点
  • progress = 1 时,音符到达键位
  • 中间按比例移动

所以你掉帧时,下一帧会直接按“真实经过的时间”算到正确位置,不会因为少渲染几帧就整体变慢。

输入判定也要提前设计,不然会很难补

键盘事件和字符不是天然一一对应的,尤其是大小写、符号、空格这些细节。
我的做法是先把“字符 -> 可触发按键”这层映射建好,按下去时只做筛选:

  1. 先找这次按键可能命中的目标
  2. 再从场上挑“在判定区里、并且最近的那个”
  3. 命中就给 Perfect/Good,没打中就 Miss

这样做的好处是:同屏多个字符时,不会乱判。

真正踩坑的是暂停恢复

这个坑我中招过一次:
暂停时看起来停住了,但系统时间没停。恢复那一瞬间,很多字符会“直接穿越到底部”,然后连着触发 Miss。

后来我的修法很直接:恢复时统一补时间,把暂停这段加回去。

为什么我最后还是选 Canvas

这类玩法如果用 DOM 也能做,但对象一多、运动一复杂,Canvas 会更稳一些。
我现在的拆法是:

  • 运动对象(下落字符、特效)走 Canvas
  • UI(分数、血条、按钮)继续放在 React/DOM

这样调试和维护都比较顺手,性能也更可控。