React · 游戏 · KeyDo

在 React 里跑一个命令式游戏循环——单词雨

键道 KeyDo 复盘:单词从天而降,requestAnimationFrame 每帧推进物理。React 每帧 setState 会卡死,状态得放 ref,渲染只靠一个空 tick 触发。

键道 KeyDo 的第四个模式「单词雨」是个小游戏:单词从顶部落下,你敲首字母锁定目标,整词打完就消灭它,落地扣一条命。它需要一个每秒 60 帧的游戏循环。而在 React 里跑游戏循环,第一反应的写法会直接把页面卡死。这篇讲怎么让 React 和 requestAnimationFrame 和平共处。

别每帧 setState

游戏循环每一帧要做的事:推进每个单词的 y 坐标、检测落地、生成新词、检查生命。如果这些状态都是 React state,每帧都 setState 一次——一秒 60 次触发组件重渲、diff、reconcile。词一多,帧率崩,输入卡顿,游戏体验直接报废。

React 的渲染是为「用户交互驱动的离散更新」设计的,不是为「每 16 毫秒推进一次的连续模拟」。硬把高频游戏状态塞进 React state,是拿声明式框架去干命令式的活。

状态全进 ref,渲染只靠一个空 tick

解法是把游戏世界完全搬出 React:

  • 所有可变状态放 ref:wordsRef(屏上所有单词的数组)、lockedRef(当前锁定的目标 id)、loopRef(等级、击杀数、生命、计时、requestAnimationFrame 句柄);
  • 每帧在 frame(now)直接改这些 ref,不碰 React state;
  • 渲染怎么办?循环末尾来一个 setTick(t => t + 1)——一个纯粹用来「戳一下让 React 重画」的空状态。React 重渲时直接读 wordsRef.current 画出当前帧。
function frame(now) {
  const dt = Math.min(now - L.lastTime, 100) / 1000   // 秒
  L.lastTime = now
  for (const w of wordsRef.current) w.y += w.speed * dt  // 命令式推进物理
  // …检测落地、扣命、生成新词,全在 ref 上…
  setTick(t => t + 1)                                  // 只为触发一次重画
  L.raf = requestAnimationFrame(frame)
}

真相在 ref,React 只是个画布。 每帧一次 setTick 仍然会重渲,但它只做一件轻活——读 ref 里的坐标摆好 DOM,没有复杂的 state diff 链。

时间步长:dt 要有,还要封顶

物理推进不能按「每帧固定移动几像素」——不同刷新率的屏幕(60Hz / 120Hz)会让游戏快慢不一。得用时间步长 dt:这一帧距上一帧过了多少秒,位移就是 speed * dt。这样 120Hz 屏每帧动得少、帧多,60Hz 屏每帧动得多、帧少,最终速度一致。

dt 有个陷阱:Math.min(now - L.lastTime, 100) 那个封顶很关键。如果用户切走标签页再回来,requestAnimationFrame 会暂停,now - lastTime 可能是好几秒——不封顶的话,所有单词「唰」地瞬移到屏幕底部,一次扣光所有命。封顶到 100ms,最坏也就跳一小步。

更彻底的处理是直接暂停

if (document.hidden) {
  L.lastTime = 0            // 清掉时间基准
  L.raf = requestAnimationFrame(frame)
  return                    // 不推进任何状态
}

标签页不可见时,循环空转但不推进游戏,并把 lastTime 清零;等切回来重新以当前时刻为基准,游戏无缝续上。练习时长 playMs 也只在可见时累计——挂后台的时间不算你在练。

锁定「最危险」的那个词

输入逻辑是 ztype 那套:敲一个字母,如果还没锁定目标,就在所有以该字母开头的词里选一个锁定;已锁定就继续打它下一个字母,对了推进、错了「噗」一声。

选哪个锁定有讲究——选位置最低的那个

const candidates = words.filter(w => w.text[0] === key)
target = candidates.reduce((a, b) => (a.y > b.y ? a : b))  // y 最大 = 最靠下 = 最危险

最靠下的词最快落地,威胁最大,优先干掉它符合直觉。生成新词时也顺手规避歧义:不生成和现有词首字母相同的(否则你敲那个字母,系统不知道你要锁哪个),也不生成同屏重复词。首字母锁定这套玩法的前提,是任一时刻不能有两个词的首字母撞车,这个约束得在生成侧就守住。

一个闭包读到旧值的小坑

endGame 里要读最终得分,但它是在 effect 建立时的闭包里定义的,直接读 score 会读到那一帧的旧值。得分又是 React state(HUD 要显示,没法只放 ref)。解法是加一个 scoreRef 做镜像:

const scoreRef = useRef(0)
useEffect(() => { scoreRef.current = score }, [score])
// endGame 里读 scoreRef.current 拿最新分

这是「ref 存真相」在混合场景下的一个补丁——score 既要参与 React 渲染(留 state),又要在命令式回调里读最新值(加 ref 镜像)。两头都要,就两头都留一份。

小结

在 React 里跑一个 60fps 的命令式游戏循环:

  • 别每帧 setState:高频连续模拟不该走 React 的离散更新,会拖垮帧率;
  • 状态进 ref,渲染靠空 tick:游戏世界全放 ref,每帧一个 setTick 只负责「戳一下重画」;
  • 用带封顶的时间步长speed * dt 抹平刷新率差异,dt 封顶 + document.hidden 暂停,防切标签页后瞬移;
  • 锁定最危险的目标:选最靠下的词,生成侧规避首字母撞车保证锁定无歧义;
  • 混合状态加 ref 镜像:既要渲染又要在命令式回调里读最新值的量,state 和 ref 各留一份。

一句话:React 不擅长跑游戏循环,那就别让它跑——让 requestAnimationFrame 在 ref 上跑模拟,React 退回到它最擅长的:把当前帧画出来。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。