1. 为什么我选Canvas写弹珠游戏以及这个项目最终长什么样弹珠游戏是个很有意思的命题。市面上你搜canvas 弹珠能找到一大堆教程但大多停在画了一个圆、让它往下掉、碰到挡板就反弹这种程度然后就没下文了。真正把一局游戏做完、做到能玩、敢拿给朋友试玩的程度其实需要处理的东西远比想象中多。我这次用原生 Canvas 2D 写了一个完整的弹珠游戏没有引入任何游戏引擎逻辑和渲染全部手写整个项目跑下来大概三千多行 JavaScript在这个过程中踩了不少坑也把弹珠类游戏的核心问题——渲染、物理、碰撞、交互、状态管理——彻底摸了一遍。先说说这个项目最终长什么样。画面是 800x600 的竖版弹珠台顶部有一个发球区玩家通过鼠标拖动调整角度和力度松开后弹珠被发射出去。台面上布置了若干组方块砖块、几块弧面挡板、两侧的反弹壁和底部的可控挡板。弹珠在台面内弹跳碰到砖块会得分砖块会被击碎连续命中会触发连击加分。弹珠从底部漏掉后有两种处理如果还有剩余弹珠就进入下一轮发球如果全部漏完游戏结束。为了增加后期可玩性我还加了一个简单的道具系统偶尔会从砖块里掉出加速、加宽挡板之类的小道具这部分放在了最后。选择 Canvas 而不是直接上 Phaser 这类引擎原因很直接弹珠游戏的物理其实并不复杂用引擎反而会把碰撞和渲染逻辑包起来不利于理解核心机制。手写一遍你才知道 requestAnimationFrame 的回调里到底发生了什么才知道碰撞检测为什么不能简单用两圆圆心距离判断才知道为什么游戏里物体速度一快就会出现穿模。这些东西在引擎里都是配置项但在手写项目里是必须自己解决的数学问题。如果你是想拿这类项目练手、做课程设计或者准备前端面试的作品集我非常推荐完整走一遍这条路。2. 渲染层弹珠、挡板与背景的绘制细节2.1 画布尺寸与高分屏适配Canvas 的绘制逻辑本身不复杂但第一步就有一个很隐蔽的坑Canvas 的物理像素与 CSS 像素不一致。如果只是简单写canvas width800 height600在普通屏幕上没问题但在高分屏上会出现明显的模糊感。原因是 Canvas 的绘制缓冲区尺寸等于你设定的 width/height而 CSS 会把它拉伸到实际显示尺寸高分屏会要求更多物理像素于是浏览器只能插值放大画面就发虚。解决办法是让 Canvas 缓冲区的像素数等于物理像素数。我在代码里写了一个通用适配函数function setupCanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return ctx; }后面所有绘图都直接用 CSS 像素坐标不用再去关心设备像素比。这个函数每次看到都有人忽略但它的作用很直接忽略了整体画面糊成一团游戏品质感立刻下降一个档次。2.2 弹珠的绘制阴影、高光与拖尾弹珠在物理上是一个圆但绘制时不能只画一个纯色圆。一个没有任何光影的圆形球体在深色背景上看起来像一张纸片完全没有弹珠感。我给弹珠画了三层底色、高光、阴影。function drawBall(ctx, ball) { // 阴影 ctx.beginPath(); ctx.arc(ball.x 2, ball.y 3, ball.radius, 0, Math.PI * 2); ctx.fillStyle rgba(0, 0, 0, 0.25); ctx.fill(); // 本体 const gradient ctx.createRadialGradient( ball.x - ball.radius * 0.3, ball.y - ball.radius * 0.3, ball.radius * 0.1, ball.x, ball.y, ball.radius ); gradient.addColorStop(0, #ffffff); gradient.addColorStop(0.6, #c8d6e5); gradient.addColorStop(1, #576574); ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2); ctx.fillStyle gradient; ctx.fill(); // 高光点 ctx.beginPath(); ctx.arc(ball.x - ball.radius * 0.25, ball.y - ball.radius * 0.25, ball.radius * 0.2, 0, Math.PI * 2); ctx.fillStyle rgba(255, 255, 255, 0.9); ctx.fill(); }阴影偏移两个像素模拟光源从左上角来的效果。高光点放在球体左上象限会让球看起来带一点玻璃质感。如果后续想做金属质感可以把渐变改成环形渐变加上环境反射这个看个人审美不必过度设计。2.3 砖块与挡板的绘制细节砖块我用了简单的矩形加内边框。内边框是 Canvas 里很好用的一招——用稍浅的颜色填充矩形再用更亮或更暗的颜色描边视觉上立刻有立体感。function drawBrick(ctx, brick) { const padding 2; ctx.fillStyle brick.hit 0 ? #feca57 : #ff6b6b; ctx.fillRect(brick.x, brick.y, brick.width, brick.height); ctx.strokeStyle rgba(255,255,255,0.4); ctx.lineWidth 2; ctx.strokeRect(brick.x padding, brick.y padding, brick.width - padding * 2, brick.height - padding * 2); }挡板有左右两种分别是两侧的固定反弹壁和底部可移动的挡板。固定反弹壁其实就是一个薄矩形碰撞时当作静态矩形处理。底部挡板我画成圆角矩形圆角半径取 8让视觉更柔和同时注意物理计算时用的是矩形的包围盒圆角不影响碰撞逻辑。背景部分我不建议画得太花。深色底加大号文字水印或者极简网格线都行。弹珠类游戏中最重要的是玩家能一眼看清弹珠、砖块、挡板三个核心元素背景过花只会干扰判断。3. 物理层让弹珠像真的的三个关键设计物理是这个项目的灵魂也是大部分新手半途放弃的地方。我拆解下来其实只有三件事循环、受力、碰撞。把这三件事分别做好弹珠自然就活了。3.1 帧循环与固定时间步长很多人一开始会直接用 requestAnimationFrame 每次回调都更新一帧然后通过乘以 deltaTime 来保证不同帧率下速度一致。思路没错但在弹珠这种高频碰撞的场景里帧率波动会带来一个隐患如果某一帧的 deltaTime 特别大弹珠的位移会一次性跨越多个碰撞体直接穿透过去。我采用的方案是固定时间步长再加累积器。游戏逻辑按照固定步长比如 1/120 秒更新渲染则每帧绘制一次。这样不管显示器是 60Hz 还是 144Hz游戏物理都跑在同一个节奏上碰撞计算完全可预测。let lastTime 0; let accumulator 0; const STEP 1000 / 120; function frame(time) { accumulator time - lastTime; lastTime time; while (accumulator STEP) { update(STEP / 1000); accumulator - STEP; } render(); requestAnimationFrame(frame); }注意这里有一个细节update 接收的时间参数是固定的 1/120 秒而不是实际耗时。物理计算内部全部基于这个固定值速度、位移、碰撞响应都与实际帧率解耦。这是我从一些知名物理引擎实现里学到的思路虽然对弹珠项目来说有点大材小用但它让后续调试变得非常舒服——同一个状态永远不会因为帧率不同而出现不同的结果。3.2 重力、空气阻力与地面感弹珠台的重力需要调到一个明显但不失控的值。我试过直接拿真实重力 9.8 来算结果弹珠下落速度太快玩家完全没有反应时间游戏体验很差。最后定下来的参数是重力加速度 0.35 像素每平方帧乘以 STEP 的平方换算——简单来说每帧竖直方向速度增加约 2.9 像素整个下落过程从台面顶部到底部大约一秒多既有紧张感又不至于无法拦截。代码里加速度、速度、位移的关系写成这样velocity.x acceleration.x * dt; velocity.y gravity * dt; ball.x velocity.x * dt; ball.y velocity.y * dt;这里 dt 是固定的 1/120 秒。注意方向和像素单位的统一Canvas 坐标系里 y 轴向下所以重力加速度是正值。空气阻力我用了简单的线性阻尼速度每一帧乘以一个 0.995 左右的系数。这个系数看起来很小但在几百帧的累积下能有效防止弹珠在台面内越弹越快、最后永远停不下来的情况。没有阻尼的话弹珠会像一个永动机玩家反而觉得不真实。3.3 碰撞检测圆与矩形、圆与圆碰撞检测分成两套。弹珠和砖块、挡板之间的碰撞是圆与矩形弹珠和弹珠之间多球模式是圆与圆。圆与矩形的检测有一个经典做法把圆心映射到矩形内部最近的点再判断圆心到该点的距离是否小于半径。function collideCircleRect(circle, rect) { const closestX Math.max(rect.x, Math.min(circle.x, rect.x rect.width)); const closestY Math.max(rect.y, Math.min(circle.y, rect.y rect.height)); const dx circle.x - closestX; const dy circle.y - closestY; const distSq dx * dx dy * dy; return distSq circle.radius * circle.radius; }这个函数返回的是布尔值但实际项目中我更喜欢让它返回碰撞点因为后续反弹方向需要用到法向量。改造很简单返回值改成{ hit: boolean, closestX, closestY }然后法向量就是(circle.x - closestX, circle.y - closestY)归一化后的结果。圆与圆之间的检测就更直接了两圆圆心距离平方小于半径和平方即发生碰撞。碰撞响应则用弹性碰撞公式两个球交换沿法线方向的速度分量。3.4 反弹方向与能量损耗碰撞之后的反弹方向直接取法向量方向投影。为了让球反弹后不粘在墙上速度在法线方向的反向分量需要乘以一个反弹系数我定在 0.85。切向分量乘以一个 0.98 的系数模拟一点摩擦感让球碰到砖块后不至于完全保持原来的横向速度。function resolveCircleRect(ball, rect) { const closestX Math.max(rect.x, Math.min(ball.x, rect.x rect.width)); const closestY Math.max(rect.y, Math.min(ball.y, rect.y rect.height)); const dx ball.x - closestX; const dy ball.y - closestY; const distSq dx * dx dy * dy; if (distSq ball.radius * ball.radius) return null; const dist Math.sqrt(distSq); let nx dist 0 ? dx / dist : 0; let ny dist 0 ? dy / dist : -1; // 将圆心推出矩形避免贯穿 ball.x closestX nx * ball.radius; ball.y closestY ny * ball.radius; const vDotN ball.vx * nx ball.vy * ny; if (vDotN 0) { ball.vx - (1 BOUNCE) * vDotN * nx; ball.vy - (1 BOUNCE) * vDotN * ny; } return { nx, ny }; }有一个特别容易踩坑的细节碰撞处理里必须先做位置修正再做速度修正。如果只改速度不改位置下一帧球还在碰撞体内检测会再次触发导致球被反复弹射甚至从另一侧穿出来。先推出碰撞区域再计算反弹才能保证球始终处于合法位置。4. 交互与控制发球、挡板与道具拾取4.1 拖动发射角度与力度的换算发球我采用拖动发射方案玩家在发球区域按下鼠标向拖动的反方向发射弹珠。处理逻辑是记录按下时的坐标和松开时的坐标两点之间构成一个向量向量的反方向就是发射方向向量的长度经过缩放就是发射速度。canvas.addEventListener(mousedown, (e) { if (gameState ! READY) return; const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; dragStart { x, y }; }); canvas.addEventListener(mouseup, (e) { if (!dragStart) return; const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; const dx x - dragStart.x; const dy y - dragStart.y; launchBall(-dx, -dy); dragStart null; });发射速度不能直接等于拖动距离要做一个映射。我用的式子是最小速度 8、最大速度 22拖动距离 200 像素时达到最大值整体做一个平方根缩放让短距离拖动更容易控制力度。4.2 挡板控制的两种方案底部挡板有两种常见控制方案鼠标跟随和键盘左右键。鼠标跟随的体验更直觉但有一个问题——玩家把鼠标从发球区移到挡板区需要时间。键盘控制在弹珠快速下落时更精准但需要两根手指同时操作。我最后实现的是双方案并存键盘左右键控制挡板水平移动鼠标移动也可以控制挡板跟随。键盘优先鼠标移动时如果检测到 recentMouseMove 标志则切换到鼠标跟踪。从实际试玩反馈来看多数人最终习惯用鼠标但键盘在关键时刻更可靠。挡板的移动速度我也限制了一下最大 12 像素每帧防止它瞬间穿场。这个限制很重要特别是接住快速反弹的弹珠时如果挡板能瞬移游戏就失去挑战性了。4.3 道具拾取与效果窗口道具掉落是从被击碎的砖块位置随机生成一个小圆形图标然后以较低的速度下落。弹珠碰到道具图标后触发拾取效果。效果用临时 buff 实现比如挡板宽度加倍直接修改挡板对象的 width 属性并设置一个倒计时效果结束后再恢复。function applyPowerUp(powerUp) { if (powerUp.type widePaddle) { paddle.targetWidth 160; paddle.timer 10; // 10秒倒计时 } }倒计时放在 update() 里递减到零时恢复原值。注意挡板的宽度变化要做平滑过渡而不是瞬间变化我用了 lerp线性插值让 paddle.width 逐步向 targetWidth 靠拢这样视觉效果更自然。5. 游戏状态、计分与音频让项目从会动变成能玩5.1 游戏状态机弹珠游戏虽然小但状态管理必须认真做。我定义了四个状态READY发球准备、RUNNING弹珠在台面、GAMEOVER弹珠耗尽、PAUSED暂停。状态之间跳转有严格边界READY 状态等待玩家拖动发球此时弹珠静止在发射点挡板可用。RUNNING 状态弹珠在台面运动所有碰撞、计分逻辑生效。弹珠从底部漏出且剩余生命值为 0进入 GAMEOVER还有剩余则回到 READY。键盘按 P 或 Esc在任何非 GAMEOVER 状态都能进入 PAUSED。const GameState { READY: READY, RUNNING: RUNNING, PAUSED: PAUSED, GAMEOVER: GAMEOVER };状态机写清楚之后后面加暂停菜单、加结算界面都只是加一个 switch 分支不会牵一发而动全身。5.2 计分与连击计分规则普通砖块 100 分连续命中不同砖块时连击倍率从 1 开始逐步提升。我这里设计的是连击区间为 2 秒只要在 2 秒内再次击碎砖块连击数加 1倍率等于连击数最高 10 倍。漏掉弹珠会重置连击。function onBrickHit(brick) { const now performance.now(); if (now - lastHitTime 2000) { combo 1; } else { combo 1; } lastHitTime now; const comboMultiplier Math.min(combo, 10); score 100 * comboMultiplier; }这种计分方式很朴素但非常有效。玩家为了保持连击会主动调整弹珠落点游戏深度立刻上来了。5.3 音效用 Web Audio 现场合成音效我没有准备任何音频文件直接用 Web Audio API 实时合成。思路是每次碰撞事件触发一个短促的振荡器波形频率和力度不同音色也不同。function playTone(freq, duration, type triangle, volume 0.2) { const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.type type; osc.frequency.value freq; gain.gain.setValueAtTime(volume, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime duration); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime duration); }砖块命中用高频短音挡板碰撞用中频弹珠漏掉用低频下坠音音效合成控制在几行代码之内效果却非常明显。这里提醒一下音频上下文必须在用户交互之后再创建否则浏览器会阻止音频播放。我在第一次鼠标按下时才初始化 audioCtx避免了页面刚加载时自动播放被拦截的问题。6. 实测中踩过的几个坑穿透、抖动和抢帧6.1 高速穿透第一次跑通物理时我发现弹珠速度快了以后偶尔会穿过砖块尤其是斜向撞击砖块角的时候。排查下来问题出在更新步长太大。固定步长虽然解决了帧率波动但每步移动距离仍然可能大于砖块厚度。弹珠速度上限是 22 像素每帧砖块厚度是 12 像素所以一次运动完全可能越过砖块。第一个方案是把固定步长从 1/60 改成 1/120问题明显减少但偶尔还会出现。第二个方案是我在 update 里把位移拆成两次半段移动每次半段移动后都做碰撞检测。这个方案在弹珠游戏场景里已经足够而且性能开销可忽略。const substeps 2; for (let i 0; i substeps; i) { ball.x ball.vx * dt / substeps; ball.y ball.vy * dt / substeps; checkCollisions(ball); }实际项目中如果弹珠速度上限继续提高可以把 substeps 改成根据速度动态计算速度超过某阈值时拆成 4 步。不要无脑提高物理步长到 1/240移动端设备上的性能会吃不消。6.2 挡板抖动挡板在接住弹珠的瞬间会轻微抖动看起来像是弹珠把挡板顶开了。原因是碰撞响应里位置修正力度过大——我把弹珠推出矩形时弹珠中心被强行移到矩形边缘之外这个修正量如果超过挡板厚度视觉上就会出现明显跳变。修复方式是把修正量限制在一个合理范围内同时挡板本身也要参与位置修正。圆形撞矩形时矩形一样要往后推一点只是矩形有质量移动量比弹珠小。我在挡板类上加了一个质量系数弹珠和挡板的质量比为 1:3位置修正按质量比例分配抖动就消失了。6.3 拖尾残影与渲染顺序游戏早期版本里弹珠快速移动时会出现一小段残影。我排查了很久最后发现是 Canvas 每一帧没有完全清屏。我用了ctx.clearRect(0, 0, width, height)但实际上监听了 resize 事件后 width 和 height 变量没更新导致清屏范围只是原画布的一部分。清理干净之后我又主动给弹珠加了一个运动拖尾效果用一组历史坐标存储最近 8 帧位置每帧绘制不同透明度的圆。这个小效果让弹珠高速运动时视觉反馈更清晰比纯一个圆好看很多。6.4 requestAnimationFrame 的抢帧问题如果浏览器标签页切到后台requestAnimationFrame 会暂停回到前台后时间差可能高达几百毫秒。如果不处理accumulator 会一直累积导致回到前台的瞬间一次性执行几十次物理更新画面直接卡一下。解决方式是限制累积器最大值累积时间超过 250 毫秒时直接丢弃多余部分。accumulator Math.min(accumulator, 250);这个处理非常关键否则玩家切换窗口再回来游戏会有明显卡顿甚至直接掉到 GAMEOVER。7. 如果重做一遍我会改掉哪些设计做完这个项目回头看有一些地方是我会调整的。第一是砖块布局和碰撞算法。目前的砖块碰撞用的是轴对齐矩形一次 update 里可能同时碰撞到多个砖块导致弹珠速度方向被多次修正出现抖动式反弹。如果重做我会把砖块细化成边碰撞、顶点碰撞两类处理或者改用更标准的分离轴定理从根源上避免多碰撞叠加。对于 Canvas 弹珠级的小项目现有方案够用但作为资深从业者的角度看它经不起极端情况的考验。第二是发射手感。现在的发射力度映射是平方根函数在中低力度区间偏敏感玩家很不容易控制到一个精确的速度。如果改成更陡的曲线或者加入蓄力发条效果操作天花板会更高。不过这个要看目标用户休闲玩家反而喜欢当前的宽松手感。第三是道具系统的平衡性。宽挡板效果太强捡到一个基本能保送好几关。后续如果做版本更新我会给宽挡板加上负收益——宽度变大的同时移动速度也变慢形成取舍这样玩家不是无脑捡道具而是要动一点策略。8. 在移动端再做一遍有哪些适配要点如果你打算把这个项目拓展到手机端有几个适配要注意。触摸事件不能直接用 mousedown/mouseup 那一套要改用 touchstart/touchmove/touchend并且记得在事件里调用preventDefault()阻止页面滚动。拖动发射的逻辑可以保留但手指会遮挡视线建议在玩家手指按下时绘制一个瞄准箭头让玩家在手指被挡住的情况下也能看清发射方向。移动端的性能瓶颈主要在 Canvas 的绘制面积。600 像素高度的画布在手机屏幕上可能被放大到两倍以上绘制时每帧都会全屏重绘。优化方式是只在需要重绘的区域使用局部清除或者把背景砖块绘制到一个离屏 Canvas每帧只更新活动物体。弹珠和砖块数量都不多但离屏缓存能显著降低 GPU 负载。还有一点移动端 Safari 对 Canvas 的硬件加速支持有差异实测中发现部分设备上渐变填充很慢可以考虑把弹珠的高光从径向渐变改成纯色加描边看起来差距不大性能提升明显。如果你准备继续深挖还可以试试把砖块换成物理多边形配合分离轴定理做刚体碰撞或者加入屏幕震动、粒子特效让打击感更强。我觉得弹珠游戏的妙处在于物理边界无限每一个细节都能单独扩展成一个大项目。我目前这个版本的代码虽然不算优雅但所有的基础设施——固定步长、碰撞体系、状态机——都在后续加任何新玩法都不会推翻重来。这大概就是小项目该有的样子麻雀虽小五脏俱全而且每一次重构都能学到新东西。