跳到主内容
Zhimalab

吹泡泡游戏点着点着就卡死?一个藏在碰撞循环里的死循环

2026-08-04 · 1 分钟

问题现象

吹泡泡童话 上线后,用户反馈「点着点着,页面就卡死」。本地静态审查代码没有发现问题,于是直接对线上页面做复现。

排查过程

1. Playwright 线上复现

对游戏区域连续点击 200 次后,页面主线程彻底冻结——连 location.href 都取不回来;继续施压时,浏览器渲染进程直接崩溃。

2. 反编译线上产物

抓取线上 scene chunk 并反编译,确认线上运行的正是「内层碰撞循环里直接调用 mergeBubbles」的旧写法,与本地代码一致。

3. 算法模拟定位根因

handleCollisions 的算法原样搬进 Node 模拟,20 组随机泡泡全部死循环

根因:边遍历边融合

for (let i = 0; i < list.length; i++) {
  const a = list[i];
  for (let j = i + 1; j < list.length; j++) {
    if (可以融合) this.mergeBubbles(a, b); // ← 问题在这
  }
}

mergeBubbles(a, b) 做了两件事:销毁 ab,并在数组尾部 push 一个新泡泡。于是:

  1. 内层循环继续拿已经销毁的 a 去匹配;
  2. 新泡泡位于 ab 的中点,距离正好是 d/2,而 d < r_a + r_b,所以新泡泡必然与 a 重叠
  3. j < list.lengthlength 每次循环都在增长,条件永真 → 死循环
  4. 每迭代一次数组就膨胀一个元素,每帧创建成千上万个 Phaser Sprite/Tween → 内存爆炸,渲染进程崩溃

一个几何事实(中点必然重叠)+ 一个遍历习惯(循环中修改集合),叠加成必现的卡死。

修复:扫描与融合分离

// 扫描阶段:只记录配对,不修改数组
for (let i = 0; i < list.length; i++) {
  if (可以融合) merges.push([a, b]);
  else this.separate(a, b);
}
// 融合阶段:逐对处理,跳过已销毁,单帧封顶 8 次
for (const [a, b] of merges) {
  if (a.dead || b.dead) continue;
  if (mergedCount >= MAX_MERGES_PER_FRAME) break;
  this.mergeBubbles(a, b);
}
  • 先扫描、后融合,遍历期间数组不被修改;
  • 融合前检查 a.dead || b.dead,杜绝重复融合;
  • 单帧融合数量封顶,避免级联融合的突发开销。

修复后同一模拟跑 120 帧 × 20 组,全部正常终止,数组长度稳定。融合玩法不变:同色泡泡触碰照常融合,满 3 次照常触发 10 秒「泡泡童话」。

经验

  • 不要在遍历集合的过程中修改集合。push、删除、销毁对象都会让 length 和索引失去语义。
  • 死循环不一定藏在显眼的 while (true) 里,也可能藏在「看似有界的双重循环」中——只要循环条件引用了会被循环体修改的量。
  • 排查「点着点着卡死」这类问题,算法级模拟比盯着代码看更快:把核心逻辑抽出来跑一遍,几十毫秒就能给出答案。