ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用原生JavaScript实现打砖块:游戏循环、碰撞检测与浏览器动画机制详解

用原生JavaScript实现打砖块:游戏循环、碰撞检测与浏览器动画机制详解 我入门前端那会儿写完“身份证校验”和“轮播图”之后一度不知道下一步该练什么。直到我把打砖块游戏完整做了一遍才觉得自己把 HTML、CSS、JavaScript 这三门课真正打通了。打砖块是个特别经典的小游戏下面的挡板接住弹球球撞碎上面的砖块砖块全消就过关。听起来简单但一个能正常玩的版本背后涉及游戏循环、碰撞检测、状态管理、事件处理这些硬骨头。更妙的是它不需要任何框架原生三件套就能写完跑起来还相当流畅。这篇文章我会把整个项目的技术细节拆开讲包括为什么用 Canvas 而不是一堆 div、球撞砖块到底怎么判断方向、键盘鼠标事件怎么协同、以及几个我调试了很久的 bug。文末会附上一版可以直接打开的完整代码方便你对照着改。1. 为什么我坚持用原生三件套写打砖块游戏1.1 这个项目天然覆盖前端三大基本功打砖块游戏最吸引我的一点是它的需求非常完整但又不复杂到需要引入框架。HTML 负责搭建页面骨架比如游戏的倒计时提示、分数面板、生命值和画布区域CSS 负责把这些面板排得好看顺便做一些按钮、阴影、渐变色这些视觉细节JavaScript 则承担了所有“动”的部分——球的移动、砖块的生成与消失、挡板的响应、分数的更新。很多入门者会陷入一个误区觉得写游戏必须用 Canvas 或者游戏引擎但其实用纯 DOM 也能做成打砖块。球是一个圆角 div挡板是一个 div每块砖也是一个 div然后每一帧修改它们的 left/top。这样做的好处是 CSS 样式的表现力强坏处是砖块一多、帧率一高DOM 操作就可能成为性能瓶颈。我最终选择用 Canvas 画游戏区因为 canvas 只需要重绘画布不需要频繁操作几十个节点。但页面里的计分板、开始按钮、残局提示这些静态或低频更新的部分我用 HTMLCSS 来写。这个分工就是前端最基本的分层思路不动的交给结构轻量更新交给 DOM高频重绘交给 Canvas。1.2 不选框架反而能看见浏览器的真实工作方式现在前端社区里框架的覆盖面太广了很多人一上来就是 React/Vue导致对浏览器原生的渲染机制、事件循环、requestAnimationFrame 的理解很模糊。打砖块这个项目我用原生 JavaScript 带大家一起写是因为它的体量刚好能让你感知到“浏览器是怎么一步一步把动画画出来的”。大概梳理一下浏览器的工作链路你的脚本创建 canvas拿到上下文对象每一帧你调用绘制函数浏览器把它转换成 GPU 指令最终显示在屏幕上。如果你用 React 去写打砖块还要考虑虚拟 DOM 什么时候该同步组件什么时候重新渲染这些其实都是额外的负担。用原生写断点一打单步执行每一步在干什么都清清楚楚。踩过的坑也更接近底层比如我发现用 setInterval 驱动游戏循环时帧率不稳定换到 requestAnimationFrame 之后动画立刻顺了。这种发现只有在原生环境里才会留下深刻印象。2. 游戏循环与运动机制让球动起来的核心2.1 requestAnimationFrame 为什么比 setInterval 更适合做游戏循环先看最常见的错误写法setInterval(() { update(); draw(); }, 16);这个 16 毫秒是拍脑袋估计的“60帧”但实际显示器刷新率可能是 144Hz也可能是 60HzsetInterval 不会跟随刷新率而且当页面切到后台时它仍可能在空转浪费电量。更重要的是它无法告诉我们“上一帧到现在过了多久”这就导致不同设备上球速不一样。正确做法是用 requestAnimationFrame。它有两个核心特性一是与显示器的刷新频率同步一般每秒调用 60 次或 144 次浏览器会在重绘之前执行回调二是页面在后台时会自动暂停调用不会白白消耗资源。let lastTime 0; function gameLoop(time) { const dt (time - lastTime) / 16.667; // 把 dt 归一化到 60帧为基准 lastTime time; update(dt); draw(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);这里我特意把 dt 做了归一化。假如球每帧水平移动 5 像素在 60Hz 屏幕上每秒移动 300 像素如果在 144Hz 屏幕上requestAnimationFrame 每秒调用 144 次如果还按每帧 5 像素球速就会变成每秒 720 像素明显过快。所以要用帧间时间差来缩放移动距离确保所有设备上都是差不多的速度。还有一个小细节当页面切到后台再切回来时time 的差值会变得巨大所以我在完整代码里加了if (dt 3) return;这样的保护避免游戏瞬间跳帧导致球直接穿墙。2.2 球的移动与碰撞用速度向量而不是角度处理球的运动我习惯用两个方向分量dx 和 dy也就是每帧在 x 轴和 y 轴上的位移。每次更新只要ball.x ball.dx * dt; ball.y ball.dy * dt;即可。有的教材喜欢用角度 angle 加速度 speed然后dx speed * Math.cos(angle)这样做还要处理角度换算时不时出现负角度的边界问题。直接用向量反转方向就是 dx 或 dy 取负简单直观。球的反弹无非三种场合撞到画布边界、撞到挡板、撞到砖块。前两种非常简单x - radius 0或x radius width时把 dx 取反y - radius 0时把 dy 取反。需要注意顶部反弹后球会往下掉但底部如果漏接游戏直接判定丢一条命——这里不需要反弹而是重置球的位置。说到挡板很多新手是这样写的碰到挡板就dy -dy不管球落在挡板的哪个位置。这会导致一个很讨厌的“机械感”球永远是同一个角度弹回去玩起来单调也很难有意瞄准砖块。更好的做法是根据球击中挡板的位置来调整反弹方向。球撞到挡板中心时应该直直向上偏左就往左弹偏右就往右弹。这个我们可以写成function bounceOffPaddle(ball, paddle) { const hitPos (ball.x - paddle.x) / paddle.width; // 0 到 1 const angle hitPos * Math.PI - Math.PI / 2; // -90 度到 90 度 const speed Math.sqrt(ball.dx * ball.dx ball.dy * ball.dy); ball.dx speed * Math.cos(angle); ball.dy -Math.abs(speed * Math.sin(angle)); // dy 必须为负向上弹 }这里把 hitPos 映射到 01再映射到 -90 度到 90 度最后恢复成向量。注意 dy 必须取绝对值再取负强制球向上弹。这个设计的好处是玩家可以通过移动挡板改变球接下来的走向游戏的操作感一下就上来了。如果你不要求那么精细用“碰到挡板就 dy 取反”也能玩但手感会很差。2.3 碰撞检测的三种常见做法碰撞检测是打砖块的核心也是新手最容易晕的地方。我总结了三个层级第一边界碰撞只需要判断球心坐标与画布边缘的距离4 条边上做 4 个 if。第二挡板碰撞本质是“圆”和“矩形”是否相交可以用最近点距离判定。第三砖块碰撞本质是“圆”与“众多矩形”的相交判定不能简单遍历后直接反转还要判断反转哪个方向。判断圆和矩形是否相交最经典的是“最近点法”。取矩形区域内的最接近圆心的点算它与圆心的距离如果小于等于半径就说明碰撞function checkCircleRect(ball, r) { const cx Math.max(r.x, Math.min(ball.x, r.x r.w)); const cy Math.max(r.y, Math.min(ball.y, r.y r.h)); const dx ball.x - cx; const dy ball.y - cy; return dx * dx dy * dy ball.radius * ball.radius; }这个函数判断“是否碰了”很可靠但它不能告诉你该反向 dx 还是 dy。所以我在代码里用的是另一种计算“重叠量”的方式分别算出球从左右和上下方向“嵌入”矩形多深哪个方向嵌入得浅就认为球是从那个方向来的然后反转对应的速度分量。这个方法在单个砖块上非常准也是多款经典打砖块游戏的通用做法。3. 砖块布局与 CSS 视觉效果游戏的好看与好用缺一不可3.1 一维数组描述二维地图的砖块数据砖块地图是整个游戏里数据结构的“定海神针”。最简单的方式是用一个二维数组每一行代表一层砖每一列代表一个位置。比如 5 行 8 列const BRICK_COLS 8; const BRICK_ROWS 5; const bricks []; for (let row 0; row BRICK_ROWS; row) { for (let col 0; col BRICK_COLS; col) { bricks.push({ x: 20 col * (BRICK_WIDTH BRICK_GAP), y: 60 row * (BRICK_HEIGHT BRICK_GAP), w: BRICK_WIDTH, h: BRICK_HEIGHT, alive: true, row: row, col: col, color: hsl(${20 row * 20}, 80%, 60%) }); } }我故意把它做成一维数组而不是二维嵌套数组原因是便利性后面遍历检测时一个 for 循环就能扫完所有砖块根本不用管理行列下标。当然你也可以保留二维结构那样在处理“整行消失后下移”时会更方便但对打砖块来说意义不大。重要的是每块砖都有 x、y、w、h、alive 这些属性。alive 这个布尔值很重要它标记砖块是否还存在。碰撞命中后不要立即从数组里 splice 掉而是先把 alive 设为 false因为数组的长度在遍历期间发生变化会导致跳坑或越界。splice 的时机应该放在一帧结束后的统一清理阶段或者干脆不清理反正遍历时会跳过 dead 砖块。这也是很多初学者容易踩的坑。3.2 碰撞后消失还是反弹处理临界情况球撞到砖块理论上砖块应该消失球应该反弹。但“反弹”这个动作比看起来复杂因为球撞到砖块的左边、右边、上面或下面需要反转的轴不同。如果球从左侧撞过来应该反转 dx从上方撞过来应该反转 dy。怎么判断我用的方法是计算四种重叠量const overlapLeft ball.x ball.radius - brick.x; const overlapRight brick.x brick.w - (ball.x - ball.radius); const overlapTop ball.y ball.radius - brick.y; const overlapBottom brick.y brick.h - (ball.y - ball.radius); const min Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom); if (min overlapLeft || min overlapRight) { ball.dx -ball.dx; } else { ball.dy -ball.dy; }这个逻辑本身足够但实际运行时还有一个更烦人的问题球在一帧内可能同时碰到两块砖。如果循环检测到第一块砖反转了 dx继续循环又检测到第二块砖再次反转 dx球的方向就被翻回去了视觉上球像穿过了砖块。我的处理办法是在checkBricks里检测到一次有效碰撞之后立刻标记砖块为 dead然后break跳出循环一帧最多只处理一次砖块碰撞。这样虽然会丢掉一个帧内多块碰撞的“理论上优化机会”但在球速不过分快时肉眼完全看不出问题。如果你希望更精确可以把球的运动拆成多个子步每子步只移动 1 像素然后逐子步检测碰撞。这是最稳妥的方案代价是循环次数变多不过在打砖块这种砖块数量不超过几十个的场景里性能完全够用。3.3 用 CSS 提升视觉质感渐变、光晕与弹性动画原生 Canvas 的优势在于像素级控制但要做好看的界面还是得靠 CSS。我的做法是游戏区域用 Canvas 渲染围绕 Canvas 的 UI 层全部交给 HTMLCSS。比如最上面的 HUD 计分条我用了一个半透明的深色胶囊背景配合白色文字和一点border-radius看起来比默认的黑底白字舒服很多。再来是按钮光设个背景色不够我加了一点渐变和 hover 时的位移效果视觉反馈更明确。所有 CSS 都用 flex 布局来居中这是我的习惯——用 flex 做整体排版用 absolute 做覆盖层。另外我强烈推荐大家给砖块的颜色加上 HSL 渐变。代码里我用hsl(${20 row * 20}, 80%, 60%)每一行色相差 20 度从上到下从橙红渐变到紫红视觉层次立刻出来。你还可以用 CSS 给挡板加一条高光带模拟圆润质感这些细节不需要多少代码但能让游戏截图看起来像样。如果你想让击碎砖块更有反馈感可以做一个“涟漪光圈扩散”的效果在砖块位置插入一个 div用 CSS 动画让它的 border-radius 从中心慢慢扩散成大圆同时透明度从 1 渐变到 0。说白了就是一个定时销毁的 DOM 节点配合transform: scale()和opacity就能实现。这个效果我在扩展部分会更详细地讲因为完整代码里为了保持核心逻辑清晰暂时没加。4. 交互控制与状态管理从“能玩”到“好玩”4.1 键盘、鼠标和触屏的协同打砖块的经典玩法是用鼠标控制挡板左右移动。鼠标控制的实现很简单监听 canvas 的mousemove用event.clientX - rect.left算出鼠标在画布内的逻辑坐标然后赋值给挡板。这里必须注意canvas.getBoundingClientRect()返回的是 CSS 像素尺寸而 canvas 的逻辑坐标是它的width/height属性值比如 800。如果画布被 CSS 缩放过需要做一个比例换算mouseX * (canvas.width / rect.width)。很多教程没提这一步导致在某些高清屏或布局缩放的情况下挡板会“追不上”鼠标或者滞后很多。键盘控制则相对粗放按下左方向键或 A 键设置paddle.leftDown true松开时设置为 false然后在每一帧根据状态移动。这种方式比 keydown/keyup 里直接修改挡板位置平滑得多因为键盘事件不是每帧都触发直接改位置会出现一顿一顿的感觉。触屏支持也很容易加只要监听到touchmove时取得e.touches[0].clientX其他逻辑和鼠标一致。做完这三套输入之后你会发现同样是“移动挡板”这一个动作不同设备的体验差异很大。鼠标精度高键盘有停顿感触屏响应快但手指会遮住屏幕。我通常会默认用鼠标键盘作为辅助触屏做一个后备。这些输入事件要特别小心重复监听如果你在某个函数里每次调用都新增一个addEventListener又没做节流那么事件回调会越积越多游戏越玩越卡。稳妥做法是在初始化时只绑定一次后续通过状态判断是否响应。4.2 用状态机管理游戏流程没有状态机的打砖块项目代码容易变成一团乱麻。你会在update里判断“球是否在移动”在draw里判断“是否显示开始画面”在事件回调里又判断“游戏是否结束”三个地方四处开花。我的做法是定义一个gameState取值只有几个ready等待开始、playing运行中、over失败、win胜利。整个项目里所有分支都以这个状态为准let gameState ready; function setState(state) { gameState state; // 同步更新界面覆盖层 const overlay document.getElementById(overlay); if (state ready) { overlay.style.display flex; ... } else if (state playing) { overlay.style.display none; } else if (state over) { overlay.style.display flex; ... } }这样做的最大好处是事件处理函数里只需要关心“当前状态是否允许执行动作”。比如点击开始按钮只有在ready/over/win三种状态下才允许启动在playing状态按下空格应该什么都不做。游戏结束时球的位置已经越界但update函数第一行就会检查if (gameState ! playing) return;因此不会继续产生无效计算。状态机还让“重新开始”变得非常干净把分数、生命、砖块全部初始化然后调用setState(playing)就行。没有状态机的话复位逻辑会散落在各个地方漏一处就是鬼畜 bug。4.3 分数、生命、关卡与道具的简单扩展一个能玩的打砖块游戏至少应该有分数和生命。我的项目里每砸碎一块砖加 10 分漏球扣一条命命用完游戏结束砖块全清则过关。分数和生命的更新被封装成updateHUD()直接操作 DOM 的textContent。为什么不用innerHTML因为innerHTML每次都会解析 HTML 字符串而textContent直接插入文本性能更好也没有被注入恶意标签的隐患。虽然分数变化不频繁性能差异可忽略但养成好习惯没坏处。关卡怎么设计最简单的是提高砖块行数或者在“进入下一关”时把球速加快。我建议用一个level变量砖块行数固定为 5但砖块的生命值随关卡增加。比如等级 1 的砖块打一下碎等级 2 的砖块打两下碎需要给每块砖加一个hp属性每次碰撞减 1减到 0 才消失。道具系统呢常见的有加长挡板、多球、慢速球。实现多球时需要把球对象变成一个数组每次碰撞复制一个新球这会把碰撞逻辑复杂度拉高一个量级。我建议一开始先别碰道具先跑通基础版本再试着加“加长挡板”这种最简单道具——它只需要修改paddle.width一个值风险最小。5. 我在实际开发中踩过的四个坑排错实战5.1 球速一快就穿砖第一个让我调到崩溃的 bug 是穿砖。最初我的球速是每帧 5 像素60 帧下每秒 300 像素看起来挺正常。后来加了“加速”机制球速翻倍很快发现球会直接从砖块边缘“飞过去”尤其是两个砖块之间的缝隙。原因很简单一帧内球的位移长度大于砖块的厚度。比如球速是每帧 20 像素砖块厚度是 24 像素单看这一步球可能从砖块左侧直接跳到右侧物理检测根本没有发生重叠。解决方案有两种一是限制最大球速保证每帧位移不超过砖块厚度的一半二是把移动拆成子步。我用的是后一种思路在完整代码里把碰撞检测放在移动之后但如果你要做更稳健的版本建议把 update 改为const steps Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / 2)); for (let i 0; i steps; i) { ball.x ball.dx * dt / steps; ball.y ball.dy * dt / steps; checkBricks(); bounceOffPaddle(); }这样一来每一步移动都不超过 2 像素穿模概率大大降低。5.2 挡板边缘反弹乱跳第二个坑出现在挡板反弹角度上。我最早直接“碰到就反转 dy”结果球总是以固定角度弹走很难控制。后来改了角度映射却出现一个新问题当球打在挡板两个角附近时反弹角度接近正负 90 度几乎水平飞出去再弹一下就直接越过挡板上方甚至在一侧来回跳动视觉上很怪。我排查后发现是hitPos超出 [0, 1] 区间导致的。因为在碰撞检测里用的是ball.x paddle.x - ball.radius ball.x paddle.x paddle.width ball.radius这会导致球心可能在挡板左右边缘外时也能触发碰撞而hitPos计算出的值会小于 0 或大于 1角度被映射到超出 -90~90 的范围。解决办法是先把hitPos夹紧到 01 之间let hitPos (ball.x - paddle.x) / paddle.width; hitPos Math.max(0, Math.min(1, hitPos));这样即使球从侧面蹭到挡板反弹角也不会那么极端。5.3 requestAnimationFrame 的 dt 陷阱第三个坑是关于 dt 的累积误差。我一开始用固定步长每帧移动 5 像素游戏在 60Hz 下很流畅。后来发现同一套代码在 144Hz 显示器上快得离谱。改成按 dt 缩放后新的问题又来了当页面切到后台一会儿再回来time - lastTime可能达到几百毫秒dt 变成十几球“刷”地一下飞到画面外。我在代码里加了一个上限判断if (dt 3) return;——直接跳过这一帧不执行更新。这相当于把游戏循环的时间差做了钳位。其实更优雅的方案是用一个accumulated累加器固定时间步长更新但打砖块这种轻量游戏用钳位完全够用。5.4 页面滚动与按键冲突最后一个坑很实用用户按方向键或空格时页面会默认滚动。如果是长页面游戏时按一下方向键整个页面往下滚非常出戏。解决办法是在keydown事件里对相关按键调用event.preventDefault()。但要注意preventDefault必须放在事件处理函数的默认分支里如果在判断条件之后才调用某些浏览器可能不认。我习惯在按键开始就判断if ([ArrowLeft, ArrowRight, , ArrowUp].includes(e.key)) { e.preventDefault(); }然后继续写游戏逻辑。这样空格键还能顺便作为“重开游戏”快捷键而不会触发滚动。另外如果你项目中还有其它滚动区域记得不要全页面preventDefault只对游戏监听区域进行处理。6. 完整代码与后续扩展建议6.1 可直接运行的完整代码下面是整个项目最核心的一版完整代码保存成index.html浏览器直接打开就能玩。我把注释写在核心逻辑周围方便你对照前文理解。代码刻意保持精简没有加入粒子特效和道具但手感已经不错砖块碰撞、挡板角度反弹、键盘/鼠标控制、计分、生命、胜利/失败状态都有。!DOCTYPE html html langzh-cn head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title打砖块/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1a1a2e; font-family: Arial, sans-serif; overflow: hidden; } #game { position: relative; width: 800px; height: 600px; border-radius: 8px; border: 2px solid #444; background: #16213e; box-shadow: 0 0 40px rgba(0,0,0,0.5); } canvas { display: block; } #hud { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); display: flex; gap: 24px; background: rgba(0,0,0,0.35); padding: 6px 18px; border-radius: 20px; color: #eee; font-size: 16px; z-index: 10; pointer-events: none; backdrop-filter: blur(4px); } #overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 20px; background: rgba(0,0,0,0.65); color: white; text-align: center; z-index: 20; } #overlay h1 { font-size: 48px; letter-spacing: 4px; } #overlay p { font-size: 18px; opacity: 0.9; } #overlay button { padding: 10px 30px; font-size: 18px; font-weight: bold; border: none; border-radius: 30px; cursor: pointer; color: #1a1a2e; background: linear-gradient(135deg, #f39c12, #e67e22); transition: transform 0.2s; } #overlay button:hover { transform: scale(1.05); } /style /head body div idgame div idhud span分数span idscore0/span/span span生命span idlives3/span/span span关卡span idlevel1/span/span /div canvas idcanvas width800 height600/canvas div idoverlay h1打砖块/h1 p鼠标 / 方向键移动挡板br接住球撞碎所有砖块/p button idstartBtn开始游戏/button /div /div script const canvas document.getElementById(canvas); const ctx canvas.getContext(2d); const W 800; const H 600; const BRICK_COLS 8; const BRICK_ROWS 5; const BRICK_WIDTH 80; const BRICK_HEIGHT 24; const BRICK_GAP 5; const BRICK_TOP 60; let score 0; let lives 3; let level 1; let gameState ready; const paddle { width: 120, height: 16, x: W / 2 - 60, y: H - 30, speed: 8, leftDown: false, rightDown: false }; const ball { x: W / 2, y: paddle.y - 10, dx: 3, dy: -3, radius: 8 }; let bricks []; function initBricks() { bricks []; for (let row 0; row BRICK_ROWS; row) { for (let col 0; col BRICK_COLS; col) { bricks.push({ x: 20 col * (BRICK_WIDTH BRICK_GAP), y: BRICK_TOP row * (BRICK_HEIGHT BRICK_GAP), w: BRICK_WIDTH, h: BRICK_HEIGHT, alive: true, hp: 1, color: hsl(${20 row * 20}, 80%, 60%) }); } } } function resetBall() { ball.x paddle.x paddle.width / 2; ball.y paddle.y - ball.radius - 2; // 给一个随机左右初速度避免每次都完全一样 ball.dx 3 * (Math.random() 0.5 ? 1 : -1); ball.dy -3; } function updateHUD() { document.getElementById(score).textContent score; document.getElementById(lives).textContent lives; document.getElementById(level).textContent level; } function setState(state) { gameState state; const overlay document.getElementById(overlay); const h1 overlay.querySelector(h1); const p overlay.querySelector(p); const btn overlay.querySelector(button); if (state ready) { overlay.style.display flex; h1.textContent 打砖块; p.textContent 鼠标 / 方向键移动挡板\n接住球撞碎所有砖块; btn.textContent 开始游戏; } else if (state playing) { overlay.style.display none; } else if (state over) { overlay.style.display flex; h1.textContent 游戏结束; p.textContent 最终得分 score; btn.textContent 重新开始; } else if (state win) { overlay.style.display flex; h1.textContent 你赢了; p.textContent 最终得分 score; btn.textContent 再来一局; } } function startGame() { if (gameState playing) return; score 0; lives 3; level 1; initBricks(); resetBall(); updateHUD(); setState(playing); } function draw() { ctx.clearRect(0, 0, W, H); // 背景 ctx.fillStyle #16213e; ctx.fillRect(0, 0, W, H); // 砖块 bricks.forEach(brick { if (!brick.alive) return; ctx.fillStyle brick.color; ctx.fillRect(brick.x, brick.y, brick.w, brick.h); // 高光 ctx.fillStyle rgba(255,255,255,0.25); ctx.fillRect(brick.x, brick.y, brick.w, brick.h / 3); }); // 挡板 ctx.fillStyle #f39c12; ctx.fillRect(paddle.x, paddle.y, paddle.width, paddle.height); ctx.fillStyle rgba(255,255,255,0.2); ctx.fillRect(paddle.x, paddle.y, paddle.width, 4); // 球 ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2); ctx.fillStyle #eee; ctx.fill(); // 球的高光 ctx.beginPath(); ctx.arc(ball.x - 2, ball.y - 2, ball.radius * 0.4, 0, Math.PI * 2); ctx.fillStyle rgba(255,255,255,0.6); ctx.fill(); } function moveBall(dt) { ball.x ball.dx * dt; ball.y ball.dy * dt; // 左右边界 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; } // 下边界漏球 if (ball.y - ball.radius H) { lives--; updateHUD(); if (lives 0) { setState(over); } else { resetBall(); setState(ready); } } } function bounceOffPaddle() { if (ball.dy 0 ball.y ball.radius paddle.y ball.y ball.radius paddle.y paddle.height 8 ball.x paddle.x - ball.radius ball.x paddle.x paddle.width ball.radius) { let hitPos (ball.x - paddle.x) / paddle.width; hitPos Math.max(0, Math.min(1, hitPos)); const angle hitPos * Math.PI - Math.PI / 2; const speed Math.sqrt(ball.dx * ball.dx ball.dy * ball.dy); ball.dx speed * Math.cos(angle); ball.dy -Math.abs(speed * Math.sin(angle)); } } function checkBricks() { for (let i 0; i bricks.length; i) { const b bricks[i]; if (!b.alive) continue; if (ball.x ball.radius b.x ball.x - ball.radius b.x b.w ball.y ball.radius b.y ball.y - ball.radius b.y b.h) { b.alive false; score 10; updateHUD(); const overlapLeft ball.x ball.radius - b.x; const overlapRight b.x b.w - (ball.x - ball.radius); const overlapTop ball.y ball.radius - b.y; const overlapBottom b.y b.h - (ball.y - ball.radius); const min Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom); if (min overlapLeft || min overlapRight) { ball.dx -ball.dx; } else { ball.dy -ball.dy; } break; } } // 胜利检测 const remaining bricks.some(b b.alive); if (!remaining) { setState(win); } } function movePaddle(dt) { if (paddle.leftDown) paddle.x - paddle.speed * dt; if (paddle.rightDown) paddle.x paddle.speed * dt; paddle.x Math.max(0, Math.min(W - paddle.width, paddle.x)); // ready 状态下让球贴在挡板上 if (gameState ready || gameState over || gameState win) { ball.x paddle.x paddle.width / 2; ball.y paddle.y - ball.radius - 2; } } function update(dt) { if (gameState ! playing) return; movePaddle(dt); moveBall(dt); bounceOffPaddle(); // 防穿模子步检测 const steps Math.max(1, Math.ceil(Math.max(Math.abs(ball.dx), Math.abs(ball.dy)) / 2)); for (let i 0; i steps; i) { ball.x ball.dx * dt / steps; ball.y ball.dy * dt / steps; bounceOffPaddle(); checkBricks(); } } let lastTime 0; function loop(time) { const dt (time - lastTime) / 16.667; if (dt 3) { lastTime time; requestAnimationFrame(loop); return; } lastTime time; update(dt); draw(); requestAnimationFrame(loop); } document.getElementById(startBtn).addEventListener(click, startGame); document.addEventListener(keydown, (e) { if ([ArrowLeft, ArrowRight, , ArrowUp, ArrowDown].includes(e.key)) { e.preventDefault(); } if (e.key ArrowLeft || e.key a || e.key A) { paddle.leftDown true; } if (e.key ArrowRight || e.key d || e.key D) { paddle.rightDown true; } if (e.key ) { if (gameState ready || gameState over || gameState win) { startGame(); } } }); document.addEventListener(keyup, (e) { if (e.key ArrowLeft || e.key a || e.key A) paddle.leftDown false; if (e.key ArrowRight || e.key d || e.key D) paddle.rightDown false; }); canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); const mouseX (e.clientX - rect.left) * (W / rect.width); paddle.x mouseX - paddle.width / 2; paddle.x Math.max(0, Math.min(W - paddle.width, paddle.x)); if (gameState ready || gameState over || gameState win) { ball.x paddle.x paddle.width / 2; ball.y paddle.y - ball.radius - 2; } }); // 初始化 initBricks(); resetBall(); setState(ready); requestAnimationFrame(loop); /script /body /html6.2 下一步可以怎么玩代码跑通之后你已经拥有一个基础但完整的打砖块游戏。这时候最值得做的不是继续堆功能而是先把自己的代码彻底读懂把gameState的每个状态都打一遍断点观察draw和update的调用顺序试着手动把ball.dx改成5看看会出什么问题。我有一次把moveBall里的dt忘写了导致球速在 144Hz 屏幕上是 60Hz 显示器的三倍这个感受比任何文档都直观。接下来可以按难度递增的顺序加三个扩展。第一给砖块加hp和不同颜色让高级砖块需要打两次。第二加一个“加长挡板”道具每打破某块特定砖块有概率掉出道具挡板碰到道具后paddle.width 160持续 10 秒。第三引入 Web Audio API 生成简单的碰撞音效——两行代码就能合成一个短促的哔声不用找音频文件。这几个扩展分别在数据结构、状态管理和浏览器能力上深化你的理解。等这些都做完你还可以试着把 Canvas 换成 WebGL 试试 3D 打砖块那时候你对 CSS 和 Canvas 的取舍会有更清晰的判断。我在做这个项目的过程中最大的体会是很多表面上的“小 bug”根子都在基础概念的模糊上。把碰撞检测和游戏循环想明白了后面的路会顺很多。
返回列表