用Canvas和JavaScript实现打砖块游戏:碰撞检测与游戏循环全解析
打砖块这个项目我在带新人前端时几乎必推。它能把 Canvas 渲染、游戏循环、碰撞检测、事件处理这些平时写静态页面根本碰不到的硬核点全部浓缩在一个不到 200 行的 HTML 文件里。你只要亲手写完一版对 JavaScript 的理解会明显上一个台阶——这比背十遍语法书都管用。这篇文章我会把整个打砖块游戏拆开讲从渲染方案的选择到碰撞检测的数学原理从挡板手感调校到那些让人挠头的穿模 Bug。完整代码放在文章最后中间是逐段精读和踩坑记录。无论你是刚学完 JS 基础想找个练手项目还是做课程设计缺一个自带讲解的成品这篇都适配。唯一的要求是别直接复制跟着思路走一遍你的收获才算数。1. 项目设计与技术点拆解1.1 打砖块到底在练什么能力很多人觉得打砖块是个小游戏代码量不大没什么含金量。这话对了一半。代码量确实不大但它是少数能把前端几个核心底层能力一次性串起来的项目。首先是 Canvas 绘制。打砖块里的所有物体——球、挡板、砖块、文字——都是在canvas上即时绘制的。它和操作 DOM 的思维方式完全不同DOM 是改属性浏览器帮你重绘Canvas 是你告诉画笔在哪画什么画面立刻变化。这种裸绘模式让你真正理解浏览器渲染的一帧是怎么来的。其次是游戏循环。这个项目里你必须引入requestAnimationFrame否则游戏画面根本动不起来。理解了这个循环机制后面接触任何游戏引擎、动画库都会轻车熟路。再就是碰撞检测。打砖块的核心玩法就是球碰到东西要反弹这就逼迫你去接触矩形相交判定、圆周与矩形的碰撞检测、速度向量取反这些偏数学的东西。这些内容在普通业务开发里几乎不会出现但它们恰恰是程序思维的分水岭。会写页面的人很多能自己算清楚球应该往哪弹的人才算真正迈过了逻辑这道坎。最后是状态管理。游戏有待开始、运行中、游戏结束、通关几种状态每种状态下玩家操作的含义完全不同。你自己设计这套状态机以后再去看复杂的业务系统会更容易抓住状态决定行为这条主线。1.2 功能范围与预期效果这套打砖块实现的核心功能可以列一下画布画布尺寸 800x600球、挡板、砖块全部用 Canvas 绘制鼠标控制挡板左右移动点击开始游戏小球碰到砖块时砖块消除、加分并沿正确方向反弹小球碰到挡板时按击打位置改变反弹角度小球落出底部扣一条命共用 3 条命清空全部砖块判定通关命用尽判定失败游戏结束或通关后点击画布重新开始这套功能完成以后它已经从一个会动的小球变成了一个完整可玩的游戏。如果你拿它做课程设计这个完成度完全够了如果你拿它给自己练手下一步就是在这个骨架上加功能了。2. 关键技术原理解析2.1 小球运动模型速度向量与逐帧位移先解决最核心的问题球是怎么动起来的。这里借用一个初中物理概念——位移等于速度乘以时间。游戏的每一帧我们都让球的位置加上一个速度向量ball.x ball.dx; ball.y ball.dy;ball.dx和ball.dy分别代表球在 x 轴和 y 轴上的每帧位移量。比如dx 4, dy -3意思就是每渲染一帧球向右走 4 像素、向上走 3 像素。方向由正负号决定速度大小由数值绝对值决定。为什么要用两个分量而不是一个角度加一个速度因为碰撞计算时就数向量分量最方便。球碰到左侧墙壁只需要把dx取反dy保持不变碰到顶部墙壁只需要把dy取反。如果存的是角度你得重新算反射角麻烦得多。一个小细节我建议每一帧都重新归一化速度向量保证球长期保持恒定速度。因为碰撞过程中如果只是简单取反速度长度理论上不变但经过多次碰撞和位置修正后可能会出现轻微漂移。归一化的写法很简单const spd Math.hypot(ball.dx, ball.dy); ball.dx (ball.dx / spd) * BALL_SPEED; ball.dy (ball.dy / spd) * BALL_SPEED;这段代码的意思是先算出当前速度向量的长度然后让每个分量除以长度再乘以目标速度相当于不管之前多快多慢我强制拉回标准速度。这样游戏手感稳定不会出现球越弹越快或者越弹越慢的毛病。2.2 碰撞检测从矩形相交到圆周矩形判定打砖块的碰撞检测是整个项目的灵魂。我先说最常见的误区新手喜欢拿球心是否进入砖块来判断碰撞。也就是判断ball.x是否在砖块的 x 范围内、ball.y是否在砖块的 y 范围内。这会导致一个很尴尬的场景——球的大半个身体已经穿进砖块了代码还没判定碰撞视觉上就是穿模。正确的做法是用球的圆形范围去和砖块的矩形范围做相交判定。具体算法分两步。第一步找出矩形上离球心最近的点。这个计算用了一个非常巧妙的手法——Math.max和Math.min的组合function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } const closestX clamp(ball.x, brick.x, brick.x brick.w); const closestY clamp(ball.y, brick.y, brick.y brick.h);clamp函数把球心的 x 坐标夹在砖块的左右边界之间。如果球心在砖块左边closestX 就是砖块左边球心在砖块右边closestX 就是砖块右边球心在砖块正上方closestX 就是球心本身的 x 坐标。Y 方向同理。这样求出来的(closestX, closestY)就是矩形上距离球心最近的点。第二步计算球心到最近点的距离看是否小于等于球的半径const dx ball.x - closestX; const dy ball.y - closestY; return dx * dx dy * dy r * r;这里用平方距离比较省了开平方计算。这个算法有个正经名字叫AABB 与圆的碰撞检测AABB 指轴上对齐的矩形包围盒。在打砖块这个场景里所有砖块都是正的矩形、球是圆这个算法就是最优解。它的好处是精确、简单、不用考虑旋转而且在后面判断从哪个方向反弹时我们可以顺手复用它的中间数据。2.3 反弹方向判定最小重叠量法检测到碰撞以后下一个问题更关键球到底该往哪弹直观想法是看球从哪个方向飞来的——如果球向下飞碰到砖块顶部那应该反弹向上。但这里有个隐患如果球速度很快或者一帧里球移动了很多像素球可能已经深入砖块内部这时候单靠速度方向判断会出错。更可靠的方案是最小重叠量法。思路是这样的把球想象成一个圆砖块想象成一个矩形。碰撞发生时圆一定同时侵入了矩形的两边上下和左右各有一个重叠量。比如球从左上方撞向砖块它同时侵入砖块的上边和左边。我们计算这两个方向的重叠量选择重叠量小的方向作为反弹方向——因为小的重叠量意味着球在那一轴上是刚刚碰到更接近真实的碰撞边缘。具体实现里我先算出球心到砖块中心的偏移量const ox ball.x - (brick.x brick.w / 2); const oy ball.y - (brick.y brick.h / 2);再算出两个方向的重叠量const overlapX (brick.w / 2 BALL_RADIUS) - Math.abs(ox); const overlapY (brick.h / 2 BALL_RADIUS) - Math.abs(oy);overlapX的含义是砖块半宽加上球半径减去球心到砖块中心的水平距离。注意只有当球心水平偏移小于半宽加半径时这个值才是正的才是真正碰撞了。如果球在砖块正上方Math.abs(ox)很小overlapX很大说明水平方向重叠很多而overlapY很小说明垂直方向刚刚碰到——那就该往上弹。判定方向后不光要反转速度还要把球推出砖块否则下一帧球还在砖块内部会再次触发碰撞造成抖动或者卡死if (overlapX overlapY) { ball.x ox 0 ? brickCenterX brick.w / 2 BALL_RADIUS : brickCenterX - brick.w / 2 - BALL_RADIUS; ball.dx -ball.dx; } else { ball.y oy 0 ? brickCenterY brick.h / 2 BALL_RADIUS : brickCenterY - brick.h / 2 - BALL_RADIUS; ball.dy -ball.dy; }把球心放到砖块边缘再加一个半径的距离让球刚好贴着砖块外侧然后翻转那一轴的速度。这一步就是消除穿透误差的关键也是整个碰撞系统稳定运行的基础。2.4 挡板反弹用击打位置控制角度打砖块好不好玩百分之八十看挡板的手感。如果球撞到挡板永远是竖直弹回去游戏就很单调好的挡板应该让玩家指哪打哪——打在挡板左边球往左弹打在右边球往右弹。这个功能实现起来很巧妙。首先计算球撞到挡板时球心在挡板宽度内的相对位置const hitPos clamp((ball.x - paddle.x) / PADDLE_WIDTH, 0, 1);hitPos是一个 0 到 1 之间的小数。球打在挡板最左边是 0正中间是 0.5最右边是 1。接下来把它映射成反弹角度const angle (hitPos * 2 - 1) * (Math.PI / 3);(hitPos * 2 - 1)把 0 到 1 映射成 -1 到 1再乘以 60 度Math.PI / 3得到 -60 度到 60 度的反弹角。角度是 -60 度表示向左上方 60 度飞60 度表示向右上方 60 度飞中间是垂直向上飞。这个范围是我实测下来手感最好的太大球太平、太小没有操控感。然后用三角函数把角度转成速度分量ball.dx speed * Math.sin(angle); ball.dy -speed * Math.cos(angle);Math.sin负责水平分量Math.cos负责垂直分量取负号是因为我们要让球向上走。这里有个小细节我用了Math.hypot(ball.dx, ball.dy)取当前速度长度作为speed这样不管球经历过各种碰撞反弹后的速度大小保持稳定不会被角度计算破坏。最后还需要把球强行拉到挡板上方防止它嵌入挡板ball.y PADDLE_Y - BALL_RADIUS - 1;这个减 1是为了留出 1 像素的余量避免下一帧因为浮点误差又被判定为碰撞。这种毫厘之间的修正在多帧碰撞系统里非常常见。3. 代码实现与逐段精读3.1 配置集中管理把魔法数字清零我不喜欢在代码里到处散落魔法数字比如砖块宽 86、挡板高 16、球半径 8。项目小的时候无所谓但一旦你想调参——比如砖块变多、挡板变宽——散落各处的数字改起来很容易漏。所以我在代码开头把所有可调参数集中成了常量const PADDLE_WIDTH 110; const BALL_RADIUS 8; const BALL_SPEED 6; const BRICK_CFG { rows: 5, cols: 8, width: 86, height: 24, gap: 8, offsetTop: 60, offsetLeft: 28 };这里我顺带解释一下offsetLeft是怎么算出来的。画布宽 800砖块一行 8 列每块宽 86间距 8那么一行总宽是8 * 86 7 * 8 744。想让砖块水平居中左偏移就是(800 - 744) / 2 28。这 28 不是拍脑袋来的是为了让砖块矩阵正对画布中心视觉效果才对称。养成先算参数再写代码的习惯比很多人想象的重要。它逼你把几何关系想清楚而不是边写边猜。3.2 砖块数据结构设计砖块我选择了扁平数组而不是二维数组。很多人一听到砖块矩阵就会本能地写成bricks[row][col]但在这个场景里扁平数组更简洁function initBricks() { bricks []; for (let r 0; r BRICK_CFG.rows; r) { for (let c 0; c BRICK_CFG.cols; c) { bricks.push({ x: BRICK_CFG.offsetLeft c * (BRICK_CFG.width BRICK_CFG.gap), y: BRICK_CFG.offsetTop r * (BRICK_CFG.height BRICK_CFG.gap), w: BRICK_CFG.width, h: BRICK_CFG.height, color: BRICK_COLORS[r], alive: true, value: (BRICK_CFG.rows - r) * 10 }); } } }每个砖块对象自带坐标、宽高、颜色、存活状态和分值。遍历起来就是一个for...of过滤掉alive false的即可不需要维护二维索引。砖块数量只有 40 个这种写法完全够用代码可读性还更好。两处细节说明一下。颜色数组按行对应第 0 行是最顶层的砖我给它设置最醒目的红色分值value我让最顶层的砖值最大5 * 10 50越往下越低这样玩家会有先打高层更赚的决策感。游戏里看似不起眼的数值设计往往决定了玩起来的味道。3.3 碰撞系统的完整串联碰撞处理我拆成了两个函数handlePaddleCollision管挡板handleBrickCollision管砖块。两者共用rectCircleCollide做相交检测。function handlePaddleCollision() { const paddleRect { x: paddle.x, y: PADDLE_Y, w: PADDLE_WIDTH, h: PADDLE_HEIGHT }; if (ball.dy 0 rectCircleCollide(ball.x, ball.y, BALL_RADIUS, paddleRect)) { // 按击打位置算角度 } }这里有个容易被忽略的细节先判断ball.dy 0也就是球正在向下运动。如果球在向上运动时被检测到穿过挡板上缘比如从挡板下方往上撞你强行反弹就会出现球被挡板吸住的诡异画面。加上方向判断等于告诉程序只有下落中的球才能被挡板接住。砖块碰撞的遍历顺序我也提一下。代码里handleBrickCollision每帧会遍历所有存活砖块碰到第一个命中的砖块就break。当前项目 40 个砖块每帧最多 40 次矩形判定对现代浏览器来说可以忽略不计。但如果砖块数量扩大到上千我建议按行分桶只检测球心周围两三行的砖块。这是空间换时间的优化现在不需要做但心态上要留有碰撞检测是有开销的这个概念。3.4 生命周期管理与状态机整个游戏的状态我用一个字符串变量state管理取值有四种ready、running、over、win。每个状态下输入处理逻辑完全不同ready球还没发射显示点击开始点击后变成runningrunning球在运动每帧调用updateover生命耗尽显示游戏结束点击调用init重开win砖块清空显示恭喜通关点击重开canvas.addEventListener(click, () { if (state ready) { state running; } else if (state over || state win) { init(); } });这种状态决定行为的设计比散落的if (lives 0 !win)组合条件干净得多。人脑一次只能理解一个状态别让代码在一个画面里同时处理没死、还没赢、还没开始三种情况。游戏循环里的逻辑也随之简化function loop() { if (state running) { update(); } draw(); requestAnimationFrame(loop); }不是运行状态就不更新逻辑但每一帧仍然绘制画面。这样做的好处是在待开始状态你依然看得到球和挡板静止的画面而不是黑屏一片。界面状态变化是自然衔接的玩家体验就好。3.5 渲染层的绘制顺序与细节Canvas 的绘制顺序有讲究本质是后来者居上。我的绘制顺序是背景、砖块、挡板、球、文字。背景最先画垫在底层球最后画浮在最上面文字最最后画确保任何情况下计分信息可见。function draw() { ctx.clearRect(0, 0, W, H); ctx.fillStyle #1a1a2e; ctx.fillRect(0, 0, W, H); // 接着画砖块、挡板、球、文字 }clearRect把整块画布清透明然后我立刻用深蓝色背景fillRect整个盖住。这一步不能省否则上一帧的画面残影会留在画布上形成拖尾效果。文字部分我用ctx.textAlign在左右两端分别画分数和生命值这样信息分布均匀。提示文字用居中对齐放在画面中央。字体选Courier New, monospace是刻意的——等宽字体在 Canvas 里渲染更整齐数字跳动时不会忽宽忽窄看起来专业一些。关于高分屏的问题这里也顺带说清楚。Canvas 元素上通过width属性和 CSS 设置的尺寸是两回事。canvas width800 height600决定的是绘图缓冲区分辨率而 CSS 的width: 100%决定的是元素显示尺寸。如果你只在 CSS 里写尺寸、没设属性画布会用默认的 300x150 分辨率拉伸显示画面必然模糊。我在 HTML 里直接写了width800 height600CSS 里只用border和圆角修饰外观从根上避开了这个坑。3.6 完整代码代码整合在一起保存在一个 HTML 文件里双击用浏览器打开就能玩。没有引入任何外部依赖纯 HTML CSS JavaScript。!DOCTYPE html html langzh-cn head meta charsetutf-8 title打砖块游戏/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { background: #0f0f23; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: Courier New, monospace; } #game { border: 2px solid #333a56; border-radius: 8px; background: #1a1a2e; cursor: none; } #tip { position: fixed; bottom: 20px; width: 100%; text-align: center; color: #666; font-size: 14px; } /style /head body canvas idgame width800 height600/canvas div idtip鼠标控制挡板 · 掉球扣一条命 · 清空砖块获胜/div script // 配置 const canvas document.getElementById(game); const ctx canvas.getContext(2d); const W canvas.width; const H canvas.height; const PADDLE_WIDTH 110; const PADDLE_HEIGHT 16; const PADDLE_Y H - 40; const BALL_RADIUS 8; const BALL_SPEED 6; const BRICK_CFG { rows: 5, cols: 8, width: 86, height: 24, gap: 8, offsetTop: 60, offsetLeft: 28 }; const BRICK_COLORS [#ff6b6b, #ffa502, #2ed573, #1e90ff, #a29bfe]; // 游戏状态 let score 0; let lives 3; let state ready; let bricks []; let paddle { x: 0 }; let ball { x: 0, y: 0, dx: 0, dy: 0 }; // 初始化 function initBricks() { bricks []; for (let r 0; r BRICK_CFG.rows; r) { for (let c 0; c BRICK_CFG.cols; c) { bricks.push({ x: BRICK_CFG.offsetLeft c * (BRICK_CFG.width BRICK_CFG.gap), y: BRICK_CFG.offsetTop r * (BRICK_CFG.height BRICK_CFG.gap), w: BRICK_CFG.width, h: BRICK_CFG.height, color: BRICK_COLORS[r], alive: true, value: (BRICK_CFG.rows - r) * 10 }); } } } function resetBall() { ball.x W / 2; ball.y H - 70; const angle -Math.PI / 3 (Math.random() - 0.5) * 0.2; ball.dx BALL_SPEED * Math.cos(angle); ball.dy BALL_SPEED * Math.sin(angle); state ready; } function init() { score 0; lives 3; initBricks(); paddle.x (W - PADDLE_WIDTH) / 2; resetBall(); } // 输入 canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); const scale W / rect.width; const mouseX (e.clientX - rect.left) * scale; paddle.x mouseX - PADDLE_WIDTH / 2; }); canvas.addEventListener(click, () { if (state ready) { state running; } else if (state over || state win) { init(); } }); // 工具函数 function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function rectCircleCollide(cx, cy, r, rect) { const closestX clamp(cx, rect.x, rect.x rect.w); const closestY clamp(cy, rect.y, rect.y rect.h); const dx cx - closestX; const dy cy - closestY; return dx * dx dy * dy r * r; } // 碰撞 function bounceBall(rect, rectCenterX, rectCenterY) { const ox ball.x - rectCenterX; const oy ball.y - rectCenterY; const overlapX (rect.w / 2 BALL_RADIUS) - Math.abs(ox); const overlapY (rect.h / 2 BALL_RADIUS) - Math.abs(oy); if (overlapX overlapY) { ball.x ox 0 ? rectCenterX rect.w / 2 BALL_RADIUS : rectCenterX - rect.w / 2 - BALL_RADIUS; ball.dx -ball.dx; } else { ball.y oy 0 ? rectCenterY rect.h / 2 BALL_RADIUS : rectCenterY - rect.h / 2 - BALL_RADIUS; ball.dy -ball.dy; } } function handleBrickCollision() { for (const brick of bricks) { if (!brick.alive) continue; if (rectCircleCollide(ball.x, ball.y, BALL_RADIUS, brick)) { bounceBall(brick, brick.x brick.w / 2, brick.y brick.h / 2); brick.alive false; score brick.value; break; } } } function handlePaddleCollision() { const paddleRect { x: paddle.x, y: PADDLE_Y, w: PADDLE_WIDTH, h: PADDLE_HEIGHT }; if (ball.dy 0 rectCircleCollide(ball.x, ball.y, BALL_RADIUS, paddleRect)) { const hitPos clamp((ball.x - paddle.x) / PADDLE_WIDTH, 0, 1); const angle (hitPos * 2 - 1) * (Math.PI / 3); const speed Math.hypot(ball.dx, ball.dy); ball.dx speed * Math.sin(angle); ball.dy -speed * Math.cos(angle); ball.y PADDLE_Y - BALL_RADIUS - 1; } } // 更新 function update() { ball.x ball.dx; ball.y ball.dy; if (ball.x - BALL_RADIUS 0) { ball.x BALL_RADIUS; ball.dx -ball.dx; } if (ball.x BALL_RADIUS W) { ball.x W - BALL_RADIUS; ball.dx -ball.dx; } if (ball.y - BALL_RADIUS 0) { ball.y BALL_RADIUS; ball.dy -ball.dy; } handlePaddleCollision(); handleBrickCollision(); const spd Math.hypot(ball.dx, ball.dy); ball.dx (ball.dx / spd) * BALL_SPEED; ball.dy (ball.dy / spd) * BALL_SPEED; if (ball.y - BALL_RADIUS H) { lives--; if (lives 0) { state over; } else { resetBall(); } } if (bricks.every((b) !b.alive)) { state win; } } // 渲染 function draw() { ctx.clearRect(0, 0, W, H); ctx.fillStyle #1a1a2e; ctx.fillRect(0, 0, W, H); for (const brick of bricks) { if (!brick.alive) continue; ctx.fillStyle brick.color; ctx.fillRect(brick.x, brick.y, brick.w, brick.h); } ctx.fillStyle #e94560; ctx.fillRect(paddle.x, PADDLE_Y, PADDLE_WIDTH, PADDLE_HEIGHT); ctx.beginPath(); ctx.arc(ball.x, ball.y, BALL_RADIUS, 0, Math.PI * 2); ctx.fillStyle #ffffff; ctx.fill(); ctx.fillStyle #ffffff; ctx.font 18px Courier New, monospace; ctx.textAlign left; ctx.fillText(分数: score, 16, 30); ctx.textAlign right; ctx.fillText(生命: lives, W - 16, 30); if (state ready) { ctx.textAlign center; ctx.font 20px Courier New, monospace; ctx.fillText(点击开始, W / 2, H / 2); } else if (state over) { ctx.textAlign center; ctx.font 28px Courier New, monospace; ctx.fillText(游戏结束, W / 2, H / 2 - 10); ctx.font 16px Courier New, monospace; ctx.fillText(点击重新开始, W / 2, H / 2 24); } else if (state win) { ctx.textAlign center; ctx.font 28px Courier New, monospace; ctx.fillText(恭喜通关, W / 2, H / 2 - 10); ctx.font 16px Courier New, monospace; ctx.fillText(点击重新开始, W / 2, H / 2 24); } } // 主循环 function loop() { if (state running) { update(); } draw(); requestAnimationFrame(loop); } // 启动 init(); requestAnimationFrame(loop); /script /body /html运行效果就是经典的打砖块手感鼠标在画布上滑动挡板跟着走点击画布发射小球球撞砖块消砖加分撞挡板变角度掉出底部扣命清空砖块或生命耗尽后点击重新开局。4. 常见问题与排查技巧实录4.1 球穿砖块高速运动的经典隐患我第一版代码写出来砖块永远是漏球。球速度稍微调高一点就会从砖块角上挤过去。问题根源在于碰撞检测是每帧做一次的如果一帧里球移动的距离超过了砖块的宽度球可能直接越过砖块根本检测不到。打个比方你用肉眼盯着一个人跑步每隔十秒看一眼。这个人速度慢的时候你每次看都在视野内他跑得飞快你两次眨眼之间他就从画面左边跑到右边你完全看不见他经过中间。这就是隧穿效应。解决办法有三个层面第一限制速度上限。砖块高 24、宽 86球直径 16我设置BALL_SPEED 6一帧最多移动 6 像素无论如何不可能一次跳过 24 像素高的砖块。只要球速小于最小的砖块尺寸宽或高的最小值减去 2隧穿就不会发生。这是最简单暴力的方案。第二碰撞后位置修正。这就是我在bounceBall里做的推出操作确保碰撞判定后的下一帧球的位置在砖块外部不会在同一帧被重复判定。第三如果未来你要做高速球加奖励加速可以考虑扫描线方案把球这一帧的移动轨迹看成一条线段检测线段是否穿越矩形。这个更精确但实现复杂度上一个量级。当前项目用限速方案足够。4.2 球卡在挡板里抖动另一个我踩过的坑是球明明接到挡板上却像被吸住一样在挡板边缘来回弹跳速度降不下来。原因是挡板反弹时我把球的位置修正到挡板上方但下一帧挡板又移动了或者球又落下来了导致反复判定。解决办法我在代码里做了两层防护一是ball.dy 0判断只有正在下落的球才会与挡板碰撞二是修正位置时多留 1 像素余量PADDLE_Y - BALL_RADIUS - 1。这两条组合起来抖动问题基本消失。如果你加键盘控制挡板还有一个额外问题按键的按下和抬起事件里要小心同时按两个键的情况。建议维护一个keys对象记录当前按下的键然后在update里根据状态移动挡板不要直接在keydown里改挡板位置否则会出现挡板一卡一卡的手感。4.3 鼠标位置和挡板对不上有同学反馈把浏览器窗口缩放后鼠标明明在画布中心挡板却偏到了右边。这是因为 Canvas 的坐标系统有两个画布内部分辨率坐标0 到 800和 CSS 显示的屏幕坐标。你直接用e.clientX去赋值没有考虑两者比例。正确的映射公式是const rect canvas.getBoundingClientRect(); const scale W / rect.width; const mouseX (e.clientX - rect.left) * scale; paddle.x mouseX - PADDLE_WIDTH / 2;先用getBoundingClientRect拿到画布元素在屏幕上的实际位置和尺寸算出缩放比例再把鼠标的屏幕坐标映射回画布内部坐标。(e.clientX - rect.left)是把鼠标位置换算成相对于画布左上角的偏移乘以scale后才和内部坐标一致。这个坑在做任何 Canvas 交互时都会遇到值得记牢。4.4 页面切后台回来小球突然飞出requestAnimationFrame在浏览器标签页切到后台时会暂停。切回来时它继续执行循环但一段时间的帧被压缩到短时间补上来球会以肉眼不可见的速度连跳多帧可能直接飞出边界。这个问题的标准解法是用时间戳做增量。循环函数会收到一个timestamp参数记录当前帧的时间用它和上一帧的时间差来计算这一帧应该移动多少let lastTime 0; function loop(timestamp) { const dt Math.min((timestamp - lastTime) / 16.667, 2); lastTime timestamp; if (state running) { update(dt); } draw(); requestAnimationFrame(loop); }Math.min把时间差限制在不超过两帧的长度防止切后台回来后瞬间补帧。当前项目我用了每帧固定移动的简化方案正常在页面上玩不会遇到问题。但如果你要做移动端或者更复杂的游戏建议上来就做时间增量省得后面返工。4.5 体验与性能调试清单我把开发过程中的一些性能观察整理成表格方便你排查问题现象可能原因检查方法画面卡顿每帧大量fillRect绘制打开浏览器开发者工具的 Performance 面板看 Main 线程耗时球速不稳定浏览器帧率波动用timestamp做增量或用BALL_SPEED归一化砖块偶尔漏消碰撞遍历提前break检查break位置一帧只处理一个砖块碰撞是刻意的挡板位置偏未处理 Canvas 缩放比例对比rect.width和W是否一致文字模糊Canvas 尺寸与 CSS 尺寸不一致确认width、height属性已设置4.6 想继续加功能怎么下手打完基础版以后很多人会手痒想加新东西。我推荐按这个顺序加每一步的难度递进且都有明确收益加道具系统。砖块被打掉时随机掉落增益道具比如挡板变宽、球变慢、多一条命。道具是下落的小矩形碰到挡板触发效果本质上就是给每个道具加一组坐标和一次矩形碰撞检测。实现成本低游戏趣味性提升明显。加计分连击。连续击打砖块不落地的话分值倍率递增。这需要你记录一个combo变量每次接到球重置为 1每打中一块砖加 0.1 倍。它锻炼的是在游戏循环里维护跨帧状态的能力。加砖块血量。普通砖打一下碎铁砖要打三下。只需要给砖块对象加一个hp属性碰撞时减 1归零再置alive false。加音效。用 Web Audio API 的OscillatorNode生成简单音效或者用 Audio 标签播放短音频。音效是游戏手感的重要组成部分加上后发现好玩程度直线上升。加移动端支持。把鼠标事件替换成touchmove事件处理触摸坐标和画布缩放。移动端的 Canvas 游戏是另一个话题但打砖块是很好的起步项目。5. 写在最后的经验之谈这个项目我前前后后改过好几版踩过不少坑有几点体会很深。第一碰撞检测永远比你想的复杂别在 40 个砖块这种小规模里过度设计但一定要把位置修正和方向判断这两条底线守住否则 Bug 列表会永无止境。第二手感是靠参数试出来的。BALL_SPEED从 4 调到 6挡板宽度从 100 调到 110反弹角度范围从 45 度调到 60 度每一步都是实际跑起来体验后的结果。第三把代码写清楚、参数集中管理、函数职责单一这些工程习惯在这个小项目里就值得养成——你后面写的任何项目都会受益。如果你照着代码写完并跑通了建议立刻做一件事把BALL_SPEED改成 10再看看游戏会出什么问题。观察球是怎么穿过砖块的、挡板怎么都接不住然后回来读一遍这篇文章的最小重叠量法和位置修正你会有完全不同的理解。这个项目最大的价值不是让你抄一个游戏而是让你第一次亲手掌控物体如何运动、如何碰撞、如何响应输入这一整套逻辑。把它内化了你就从一个会用标签和样式的人变成了一个能写逻辑的人。把这份代码保存成 HTML 文件打开玩一局然后动手改点什么吧。改坏了才是真的学会了。