打豆豆游戏开发避坑:3个致命错误与完整示例

发布时间:2026/9/22 3:56:16
打豆豆游戏开发避坑:3个致命错误与完整示例 打豆豆游戏开发避坑:3个致命错误与完整示例 看了一堆教程还是不会写项目?别怪自己笨,是教程都在教“Happy Path”(理想路径),没告诉你那些让代码崩掉的暗坑。做打豆豆这种看似简单的小游戏,最容易翻车的地方往往藏在边界条件、状态同步和渲染逻辑里。今天不整虚的,直接拆解三个最常见的坑,并给出可运行的完整示例代码。咱们用 JavaScript 和 Canvas 来实现,这是前端实现这类游戏最通用、性能也最稳的方案。 坑一:碰撞检测的“假阳性”与“漏判” 很多新手写碰撞检测,第一反应就是算两个圆心距离小于半径之和。听起来没毛病,对吧?但在打豆豆游戏里,豆子是不断下落的,帧率越高,豆子移动速度越快。如果你每帧只检测一次,当豆子速度极快时,它可能直接“穿过”接盘(Paddle)而不触发碰撞,这就是典型的隧穿效应(Tunneling)。反过来,如果接盘很宽,而你的碰撞判定区域设置得太大,豆子明明在接盘上方一点点,却判定为碰撞了,这就是假阳性,会导致玩家明明没接住却得分,体验极差。 根本原因在于,简单的欧几里得距离计算忽略了时间步长(Delta Time)的影响。在低帧率下,物体移动距离大,简单判定失效;在高帧率下,精度又可能不够。 错误写法:静态距离判断 // 错误示例:忽略帧率与移动距离 function checkCollision(bean, paddle) {const dx = bean.x - paddle.x;const dy = bean.y - paddle.y;const distance = Math.sqrt(dx * dx + dy * dy);// 只要距离小于半径和,就判定碰撞return distance bean.radius + paddle.width / 2; }这段代码的问题在于,它假设物体是静止的,或者移动极慢。当豆子一帧内下落 10px,而接盘厚度只有 5px 时,豆子可能从上边缘直接跳到下边缘之外,但中点距离仍可能小于阈值,或者反之完全错过。 正确写法:扫掠体积(Swept Volume)近似 更稳妥的方式是检查豆子当前位置与上一帧位置之间的线段,是否与接盘矩形相交。或者,更简单粗暴但有效的办法是:在垂直方向上,检查豆子是否从接盘上方“穿越”到了接盘内部。 // 正确示例:基于垂直穿越的检测 function checkCollision(bean, paddle, lastBeanY) {// 1. 水平方向:豆子中心必须在接盘宽度范围内const inHorizontalRange = bean.x paddle.x - paddle.width / 2 bean.x paddle.x + paddle.width / 2;// 2. 垂直方向:豆子底部是否触及接盘顶部// 注意:这里用 lastBeanY 是为了防止高速穿越const beanBottom = bean.y + bean.radius;const paddleTop = paddle.y - paddle.height / 2;// 如果上一帧豆子底部在接盘上方,且当前帧在接盘内部或下方,则判定碰撞const wasAbove = lastBeanY + bean.radius = paddleTop;const isNowBelowOrInside = beanBottom = paddleTop;return inHorizontalRange wasAbove isNowBelowOrInside; }这种写法通过记录上一帧的位置,确保了即使豆子一帧内移动了多个像素,只要它“穿过”了接盘平面,就能被捕获。这是处理高速物体碰撞的经典技巧。 坑二:游戏状态不同步导致的“幽灵豆子” 这是新手最容易忽略的坑。你写了生成豆子的逻辑,也写了删除豆子的逻辑,但有时候你会看到屏幕上有豆子突然消失,或者接住豆子后分数没变,甚至豆子“卡”在接盘上不掉下去。根本原因往往是游戏循环中的状态更新顺序错了,或者删除逻辑没有正确地从数组中移除元素。 很多教程喜欢用 for 循环遍历豆子数组,如果发生碰撞,就 splice 掉当前元素。但 splice 会改变数组长度和索引,导致后续元素被跳过或重复处理。这就是“幽灵豆子”的来源——某些豆子因为索引错位,既没被检测碰撞,也没被更新位置。 错误写法:正向遍历并删除 // 错误示例:在遍历中直接删除 function updateBeans(beans, paddle) {for (let i = 0; i beans.length; i++) {const bean = beans[i];bean.y += bean.speed;if (checkCollision(bean, paddle)) {score += 10;beans.splice(i, 1); // 危险!数组长度变了,i 没变}// 如果豆子落地,也应该删除if (bean.y canvas.height) {beans.splice(i, 1); // 同样危险}} }这里的问题非常隐蔽。假设数组是 [A, B, C],i=1 时删除了 B,数组变成 [A, C],但 i 还是 1,下一次循环处理的是 C(原本应该是 C 的索引 2 对应的元素),而 C 之后的元素(如果有的话)就被跳过了。更糟糕的是,如果 B 和 C 都碰撞,第二次 splice(i, 1) 删除的其实是 A(因为 B 已经被删了,索引 1 现在是 C,但如果逻辑混乱,可能删错对象)。 正确写法:反向遍历或过滤 最安全的做法是反向遍历,或者使用 filter 方法创建新数组。反向遍历可以保证删除当前元素不影响前面元素的索引。 // 正确示例:反向遍历删除 function updateBeans(beans, paddle) {for (let i = beans.length - 1; i = 0; i--) {const bean = beans[i];bean.y += bean.speed;let shouldRemove = false;if (checkCollision(bean, paddle)) {score += 10;shouldRemove = true;}if (bean.y canvas.height) {shouldRemove = true;}if (shouldRemove) {beans.splice(i, 1);}} }另一种更现代、更函数式的写法是使用 filter,虽然它会创建新数组,但在豆子数量不多(几百个以内)时性能完全可接受,且代码更清晰,不容易出错。 // 替代方案:使用 filter function updateBeans(beans, paddle) {return beans.filter(bean = {bean.y += bean.speed;if (checkCollision(bean, paddle)) {score += 10;return false; // 不保留}if (bean.y canvas.height) {return false; // 不保留}return true; // 保留}); }坑三:Canvas 渲染残留与内存泄漏 第三个坑是视觉上的:你明明删掉了豆子,但屏幕上还留着它的残影。或者玩久了,浏览器内存暴涨,最后崩溃。这是因为你在每帧绘制时,没有正确清空画布,或者没有正确回收不再使用的对象。 很多教程在 requestAnimationFrame 回调里直接画豆子,却忘了先调用 ctx.clearRect(0, 0, canvas.width, canvas.height)。这导致每一帧都叠加在上一帧之上,画面越来越亮,豆子轨迹变成连续的线条,看起来像故障。 另一个更严重的坑是内存泄漏。如果你使用对象池(Object Pooling)来复用豆子对象,但忘记将已删除的豆子放回池中,或者在删除时没有将引用置为 null,JavaScript 的垃圾回收机制(GC)就无法及时回收这些对象,导致内存持续上涨。 错误写法:不清空画布 未管理对象生命周期 // 错误示例:渲染循环 function draw() {// 忘记清空画布!// ctx.clearRect(0, 0, canvas.width, canvas.height);beans.forEach(bean = {ctx.beginPath();ctx.arc(bean.x, bean.y, bean.radius, 0, Math.PI * 2);ctx.fillStyle = 'red';ctx.fill();});requestAnimationFrame(draw); }这段代码运行后,屏幕会迅速变红,因为每一帧的豆子都画在了之前的上面。 正确写法:清空画布 使用对象池 // 正确示例:渲染循环 function draw() {// 1. 清空画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 2. 绘制背景(可选)ctx.fillStyle = '#222';ctx.fillRect(0, 0, canvas.width, canvas.height);// 3. 绘制豆子beans.forEach(bean = {ctx.beginPath();ctx.arc(bean.x, bean.y, bean.radius, 0, Math.PI * 2);ctx.fillStyle = 'red';ctx.fill();});// 4. 绘制接盘ctx.fillStyle = 'blue';ctx.fillRect(paddle.x - paddle.width / 2, paddle.y - paddle.height / 2, paddle.width, paddle.height);// 5. 请求下一帧requestAnimationFrame(draw); }关于内存管理,推荐引入简单的对象池。当豆子落地或碰撞后,不直接 delete 或让 GC 处理,而是将其标记为 inactive 并放回一个 pool 数组中。生成新豆子时,优先从 pool 中取一个 inactive 的对象,重置其属性后使用。这样避免了频繁的 new 和 GC 压力。 // 对象池示例 const beanPool = [];function getBean() {if (beanPool.length 0) {const bean = beanPool.pop();bean.active = true;return bean;}return new Bean(); // 池空时才创建新对象 }function releaseBean(bean) {bean.active = false;beanPool.push(bean); }// 在 updateBeans 中,删除豆子时调用 releaseBean(bean)完整示例:整合避坑方案 下面是一个整合了上述所有避坑技巧的完整 Game 类骨架。你可以直接复制运行,只需补充 Bean 和 Paddle 的基本实现。 class Game {constructor(canvas) {this.canvas = canvas;this.ctx = canvas.getContext('2d');this.beans = [];this.paddle = { x: canvas.width / 2, y: canvas.height - 50, width: 100, height: 20 };this.score = 0;this.lastTime = 0;this.beanPool = [];this.bindEvents();this.loop = this.loop.bind(this);requestAnimationFrame(this.loop);}bindEvents() {document.addEventListener('mousemove', (e) = {const rect = this.canvas.getBoundingClientRect();this.paddle.x = e.clientX - rect.left;});}spawnBean() {const bean = this.getBeanFromPool();bean.x = Math.random() * this.canvas.width;bean.y = -bean.radius;bean.speed = 2 + Math.random() * 3;bean.lastY = bean.y;this.beans.push(bean);}getBeanFromPool() {if (this.beanPool.length 0) {const b = this.beanPool.pop();b.active = true;return b;}return { x: 0, y: 0, radius: 10, speed: 0, lastY: 0, active: true };}releaseBean(bean) {bean.active = false;this.beanPool.push(bean);}update() {// 控制生成频率if (Math.random() 0.05) {this.spawnBean();}// 反向遍历更新豆子for (let i = this.beans.length - 1; i = 0; i--) {const bean = this.beans[i];bean.lastY = bean.y;bean.y += bean.speed;// 碰撞检测const dx = bean.x - this.paddle.x;const dy = bean.y - this.paddle.y;const inHorizontalRange = Math.abs(dx) this.paddle.width / 2 + bean.radius;const wasAbove = bean.lastY + bean.radius = this.paddle.y - this.paddle.height / 2;const isNowBelow = bean.y + bean.radius = this.paddle.y - this.paddle.height / 2;if (inHorizontalRange wasAbove isNowBelow) {this.score += 10;this.releaseBean(bean);this.beans.splice(i, 1);continue;}// 落地检测if (bean.y - bean.radius this.canvas.height) {this.releaseBean(bean);this.beans.splice(i, 1);}}}draw() {// 清空画布this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);// 绘制豆子this.ctx.fillStyle = 'red';this.beans.forEach(bean = {this.ctx.beginPath();this.ctx.arc(bean.x, bean.y, bean.radius, 0, Math.PI * 2);this.ctx.fill();});// 绘制接盘this.ctx.fillStyle = 'blue';this.ctx.fillRect(this.paddle.x - this.paddle.width / 2,this.paddle.y - this.paddle.height / 2,this.paddle.width,this.paddle.height);// 绘制分数this.ctx.fillStyle = 'white';this.ctx.font = '20px Arial';this.ctx.fillText(`Score: ${this.score}`, 10, 30);}loop(timestamp) {// 计算 delta time (可选,用于平滑移动)// const dt = timestamp - this.lastTime;// this.lastTime = timestamp;this.update();this.draw();requestAnimationFrame(this.loop);} }// 启动 const canvas = document.getElementById('gameCanvas'); const game = new Game(canvas);规避建议与进阶思路始终使用 Delta Time:上述示例中 bean.speed 是固定值,这在帧率波动时会导致豆子速度忽快忽慢。更专业的做法是计算 dt = (timestamp - lastTime) / 16.6,然后 bean.y += bean.speed * dt。这样无论帧率是 30 还是 144,豆子每秒下落的距离是一样的。 分离逻辑与渲染:不要在一个函数里既更新状态又绘制。像上述 update 和 draw 分开,是游戏开发的黄金法则。 调试技巧:在 Canvas 上画出碰撞边界(比如用绿色线画出接盘的碰撞矩形),能帮你快速发现“假阳性”问题。 参考官方源码:如果你想看更复杂的实现,可以研究一下 Phaser 3 的官方源码仓库(github.com/phaserjs/phaser)。虽然它是框架,但其内部的碰撞检测模块(Arcade Physics)实现非常扎实,尤其是关于 AABB(轴对齐边界框)和 SAT(分离轴定理)的优化,值得深入阅读。打豆豆游戏看似简单,实则涵盖了游戏开发的多个核心痛点:输入处理、物理模拟、碰撞检测、内存管理和渲染优化。踩完这三个坑,你写其他 2D 游戏也会顺手很多。 你更常用哪种写法?是喜欢用对象池手动管理内存,还是依赖 GC 直接 new 对象?评论区交流下你的实战经验。