用Canvas写一个弹珠游戏,是很多前端开发者入坑游戏开发的第一站。我去年在个人项目里也做了一版,从最开始的画一个圆、让它动起来,到最后加了弹簧蓄力、挡板碰撞、连击得分,踩了不少坑。这篇就把整个实现思路和关键细节完整梳理一遍,如果你正准备动手做类似的小游戏,可以直接照着思路去写,能少走不少弯路。
先说清楚这篇在聊什么:全程用原生Canvas API实现,不用游戏引擎(不碰那些现成物理库),弹珠的运动、碰撞、反弹全由我们自己写的数学逻辑驱动。听起来很难,实际上核心计算就三部分——重力、速度、碰撞后的方向修正。搞清楚这三件事,一个能玩的弹珠台就已经完成了七八成。
1. 弹珠游戏的Canvas实现:目标与整体设计
1.1 为什么选原生Canvas而不是游戏引擎
我之前也试过用现成的物理引擎(比如某个2D物理库)去跑弹珠,几行代码就能模拟重力、碰撞、摩擦,效果确实逼真。但用在弹珠游戏上,有几个绕不开的问题。
第一,通用物理引擎算的是完全刚体动力学,弹珠台里大量使用的弧形轨道、圆形触发器、多段反弹边界,在引擎里需要一个个拼碰撞体,配置成本比手写高得多。第二,引擎自带的物理步进和渲染循环有自己的节奏,想要精确控制“球到某个区域触发计分”“球碰到挡板时赋予一个特定方向的力”,要么得往引擎内部塞逻辑,要么得反复查API,远不如自己掌控循环来得顺手。
弹珠游戏的物理特性非常固定:一个简单的重力加速度、圆形碰撞体、几个矩形或圆弧边界、一个挡板碰撞时给球的冲量。这些内容手写一个轻量物理层,工作量在两三百行以内,而且每一步计算我们心里都有数,出了问题直接在代码里定位,不需要去查引擎文档。
所以我最终的选型是:纯Canvas API做渲染,自己写一个极简物理模块,游戏逻辑和渲染逻辑完全分开。这个分工在项目后期帮了大忙,后面加连击加分、特效提示时,基本上没影响到物理计算部分。
1.2 弹珠台的玩法设定和循环结构
弹珠台的核心玩法说起来并不复杂:玩家按下弹簧蓄力,松手发射弹珠;弹珠在台面上靠重力下落,撞击挡板、圈圈叉叉之类的得分点;最后如果从底部漏出去,这一局就算结束。
我给自己定的需求有以下几条:
- 一台宽400、高700的竖向Canvas,模拟弹珠台面板。
- 左右两侧各一个挡板,玩家按键控制挡板向上拍击弹珠。
- 底部一个弹簧蓄力条,按住蓄力、松开发射。
- 台面上分布若干得分触发器,弹珠碰一次得10分,连续碰不同触发器有连击加成。
- 弹珠漏出底边后重置,等下一轮发射。
这个结构其实是最简版本,但已经能覆盖Canvas游戏开发中的绝大多数典型问题:运动物体的连续更新、碰撞检测与响应、输入控制、状态切换、计分UI。把这些吃透了,后面往里面加资产、加多关卡,都是同一套思路的延伸。
1.3 代码目录怎么组织不混乱
我的项目文件结构是这样的:
src/ main.js —— 入口,创建Canvas,启动游戏循环 physics.js —— 弹珠的物理更新、碰撞检测与反弹计算 render.js —— 所有绘图逻辑,按层绘制 input.js —— 键盘/触屏事件监听 game.js —— 状态机、计分逻辑、触发器管理不建议把所有代码堆在一个文件里,尤其是物理计算和渲染绘制,很容易越写越乱。我当时第一版就是把物理和绘制混在一起写,指针数据、颜色、位置全在一个大循环里,结果每次调参数都要小心翼翼,后来痛下决心拆成独立模块,代码清晰度立刻不一样了。
2. 物理建模:让弹珠的运动看起来“对味”
2.1 坐标系、速度和重力的基础设置
Canvas坐标系是左上角为原点、x向右、y向下。这意味着重力加速度是正值(y方向增加),和我们在数学课上学到的直角坐标系是反的。这个方向问题别看小,写代码时不注意,最后弹珠会往上飞。
弹珠在每一帧的运动状态用四个基本量描述:
- x、y:圆心坐标
- vx、vy:水平和垂直方向的速度分量
- 半径 r:碰撞检测用的圆形边界
每帧的运动更新公式:
// 每帧执行一次,deltaTime是距上一帧的时间差 ball.vy += GRAVITY * deltaTime; ball.x += ball.vx * deltaTime; ball.y += ball.vy * deltaTime;这里的重力加速度我用了每平方秒600,单位是像素。这个值不是从物理课本抄的,而是反复调出来的——太小弹珠飘,太大弹珠一下子冲到台底,根本来不及反应。600这个数值在400x700的Canvas台面上,下落节奏和真实弹珠台的视觉感受比较接近。
deltaTime的存在非常关键,它让游戏在不同刷新率的屏幕上表现一致。60Hz屏幕每帧约16.7毫秒,144Hz屏幕每帧约7毫秒,如果不乘这个时间差,144Hz屏幕上弹珠速度会快一倍,游戏直接失衡。
2.2 弹珠和边界的碰撞检测:圆与矩形的碰撞
弹珠台上的边界,比如侧墙、底沿、挡板,全部抽象成矩形,弹珠抽象成圆。圆和矩形的碰撞检测有一个非常简洁的做法:
把圆心往矩形内部投影,找矩形的最近点,然后算圆心到这个最近点的距离,如果距离小于半径,就说明碰到了。
function collideCircleRect(circle, rect) { // 找到矩形上离圆心最近的点 let closestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.width)); let closestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.height)); let dx = circle.x - closestX; let dy = circle.y - closestY; let distance = Math.sqrt(dx * dx + dy * dy); if (distance < circle.r) { // 碰撞了,计算碰撞法线方向(从最近点指向圆心) let nx = dx / distance; let ny = dy / distance; return { hit: true, nx, ny }; } return { hit: false }; }这个思路在大部分矩形碰撞场景里都够用,关键优势是代码量少、逻辑直观。注意一个边界情况:当圆心完全在矩形内部时,distance会算成0,这时直接除以0会得到NaN。我的处理办法是在返回前判断一下,如果距离为0,就随机取一个反弹方向,这种情况在弹珠游戏里几乎不会出现(弹珠足够小),但防御性判断不写的话,等它真出现一次就不好收拾了。
2.3 碰撞响应:反弹方向和能量损失
碰撞后弹珠怎么弹出去,是影响手感的决定性因素。我的做法是:根据碰撞法线方向计算反弹速度,同时乘一个恢复系数,模拟碰撞过程中的能量损失。
// 速度沿法线方向的投影 let dot = ball.vx * nx + ball.vy * ny; // 如果dot小于0,说明弹珠正在朝碰撞面方向移动,才需要反弹 if (dot < 0) { ball.vx = ball.vx - (1 + restitution) * dot * nx; ball.vy = ball.vy - (1 + restitution) * dot * ny; }这里的restitution(恢复系数)我设置在0.7到0.85之间。越大,弹珠弹得越高,越接近理想弹性碰撞;越小,能量损失越多,球的运动越“肉”。弹珠台的真实手感介于两者之间,0.75左右更合适,能让弹珠台面上的活动时间够长,又不至于半天停不下来。
摩擦力的模拟我没有单独做一套库仑摩擦模型,而是用了最简单的方式:每次碰撞后,将弹珠的速度乘以一个小于1的系数(比如0.995),模拟滑动过程中的持续阻力。再叠加一个很小的线性阻尼,速度越快阻力越大,这样弹珠在台面上滚动的感觉更自然。
弹珠真实感的关键其实不是碰撞有多精确,而是碰撞后会不会出现“黏墙”现象。如果弹珠碰到边界后没有及时弹开,或者反弹距离小于每帧的运动步进,弹珠就会贴在墙上来回抖动。针对这个问题,我的处理手段是:碰撞响应之后直接把弹珠的位置推出到边界表面之外,确保下一帧计算时它已经和边界分离。
// 推出去:把弹珠沿法线方向移动,直到刚好不再重叠 ball.x = closestX + nx * (circle.r + 0.1); ball.y = closestY + ny * (circle.r + 0.1);这0.1像素的微小余量,基本杜绝了下一帧因为浮点误差再次进入重叠状态的尴尬情况。
3. 主循环与绘制流程:让游戏跑起来的基本架子
3.1 requestAnimationFrame的主循环怎么写
小程序游戏、浏览器页面游戏,主循环的骨架非常固定,就是requestAnimationFrame重复回调自己。
let lastTime = 0; function gameLoop(timestamp) { let deltaTime = (timestamp - lastTime) / 1000; if (deltaTime > 0.05) { // 防止切后台后回来deltaTime过大导致位移暴走 deltaTime = 0.05; } lastTime = timestamp; update(deltaTime); render(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);对deltaTime做上限钳制这件事,是坑过我才加的。曾经有一版没做这个处理,切到别的页面一会儿再切回来,弹珠直接以一个不可描述的速度飞出屏幕,因为中间那几秒钟全部被当作一帧算了。现在每次超过50毫秒就按50毫秒算,虽然意味着切后台那段时间在游戏里是“暂停”的,但总比弹珠飞出地球要合理得多。
3.2 绘制分层:先画背景再画游戏元素
绘制顺序直接叠加在同一个Canvas上,先画的内容会被后画的覆盖。所以我的绘制顺序是:
- 台面底板(深色背景加几条装饰线纹)。
- 得分触发器(画成实心圆环或半透明圆弧,触发后闪烁高亮)。
- 弹簧发射轨道(底部竖着的蓄力条)。
- 弹珠本身(带一圈高光,模拟钢珠质感)。
- 左右挡板(画成斜置的圆弧形短棒)。
- 分数等UI(最上层,保证不被游戏元素遮挡)。
弹珠的质感值得花点功夫。纯画一个实心圆会显得很扁平,我的做法是:画一个径向渐变圆,亮部偏向左上方,暗部在右下,再画一个白色小半圆模拟高光。这样弹珠滚动起来会有轻微的光泽变化,视觉上一下子从“测试代码”升级到“能拿得出手的Demo”。
function drawBall(ctx, ball) { let gradient = ctx.createRadialGradient( ball.x - ball.r * 0.3, ball.y - ball.r * 0.3, ball.r * 0.1, ball.x, ball.y, ball.r ); gradient.addColorStop(0, '#f5f5f5'); gradient.addColorStop(0.5, '#c0c0c0'); gradient.addColorStop(1, '#666666'); ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill(); }3.3 为什么渲染和物理更新要分开
主循环里的update和render两个函数,我强制自己写成互不访问内部状态的模块。update只负责修改模型数据(弹珠的位置、速度、触发器状态等),render只负责读取这些数据并绘制。
好处是在排查bug时非常明显:画面不对,先想想是数据算错了,还是绘制画错了。比如弹珠有时在墙上抖动,我先去physics里加日志看位置数据,如果数据正常无抖动,那问题就在绘制层;反之就是物理计算有问题。混在一起写的话,每次排查都要同时考虑两边,效率至少慢一半。
4. 游戏机制的实现:从能跑起来到“能玩”
4.1 弹簧蓄力与发射:一个简单却关键的交互
底部弹簧的交互逻辑很清晰:按住空格(或触摸底部区域)蓄力,松开时根据蓄力的力度给弹珠一个向上的初速度。
蓄力的力度我用一个progress变量来记录,按着的时候持续增加,上限设为一个百分数。蓄力力度和弹珠的初速度之间做一次简单的线性换算:
const MAX_CHARGE = 1.0; const MAX_LAUNCH_SPEED = 800; function launch() { let speed = MAX_LAUNCH_SPEED * ball.charge; ball.vx = 0; ball.vy = -speed; // 负值是因为Canvas的y轴向下 }蓄力过程中的视觉反馈很重要,我画了一个从底部向上伸长的弹簧矩形,蓄力越足矩形越高,颜色从绿色渐变到红色。这样玩家看到颜色变红,就知道蓄力已满,不用看代码也能明白交互意图。
发射的瞬间要注意:弹珠如果还停在底部,给个负的垂直速度就对了;但如果弹珠已经弹出去,此时弹簧还在蓄力,发射事件不应生效。我加了状态判断,只有弹珠处于“待发射”状态时才响应发射。
4.2 挡板控制:最影响手感的部分
挡板是弹珠台的灵魂。左右两个挡板,位置在底部两侧,初始状态是朝向斜上方的,玩家按键后挡板向上拍一下,松开后弹回原位。
挡板本质上是一个矩形边框,绕着它的底端中心点旋转。碰撞检测时,直接把旋转后的矩形四个角算出来,再走之前说过的圆与矩形碰撞逻辑。
function rotateRectByAngle(cx, cy, width, height, angle) { // 返回旋转后的四个顶点坐标 let corners = [ { x: -width / 2, y: -height / 2 }, { x: width / 2, y: -height / 2 }, { x: width / 2, y: height / 2 }, { x: -width / 2, y: height / 2 } ]; return corners.map(p => ({ x: cx + p.x * Math.cos(angle) - p.y * Math.sin(angle), y: cy + p.x * Math.sin(angle) + p.y * Math.cos(angle) })); }挡板拍击的瞬间给弹珠额外冲量,是弹珠台“爽感”的重要来源。如果只靠碰撞反弹,弹珠被挡板碰到后最多是原路轻弹,完全无法形成向上猛打的效果。我给挡板设了一个“active”状态,当挡板刚从初始角度向目标角度快速旋转时,碰撞后再沿挡板法线方向叠加一个向上的额外速度,这样弹珠被拍一下就会向高处飞。
手感调节的经验:挡板的旋转速度不要太快,太快了一帧之间挡板可能穿过弹珠而没有触发碰撞。我用的是旋转角度每帧变化约3度,在60Hz下正好能保持碰撞检测的连贯性。
4.3 触发器与计分系统:分值和连击逻辑
得分触发器是台面上分布的圆形感应点。弹珠每次碰到,就按触发器的编号加分。为了让计分有点层次,不同位置的触发器分值不一样:角落和难以撞击的区域给高分解(比如50分),开阔区域给低分(10分)。
连击逻辑我设计成:5秒内连续触发不同编号的触发器,连击数递增,额外加分=连击数 * 10。如果5秒内没有新的触发器被触发,连击数清零。
let combo = 0; let comboTimer = 0; function hitTrigger(id, score) { // 如果当前连击目标不是这个触发器,且不是同时重复触发,视为有效连击 if (lastHitId !== id) { combo++; lastHitId = id; comboTimer = 5; totalScore += score + combo * 10; } }注意重复触发同一个触发器要防止连续得分刷分。弹珠在触发器附近来回蹭的时候,如果不加限制,分数会瞬间爆炸。我的做法是给每个触发器一个冷却时间:同一个触发器被触发后,0.5秒内再次接触不产生分数。
4.4 游戏状态机:待发射、飞行中、结束
状态机是整个游戏框架的中枢。我把游戏分为三个状态:
- READY:弹珠在弹簧上等待发射。
- PLAYING:弹珠在台面上运动,玩家操作挡板。
- GAME_OVER:弹珠从底部漏出,展示分数。
状态转移的发生点在:发射指令(READY到PLAYING)、弹珠y坐标超过底板高度(PLAYING到GAME_OVER)、玩家点击重开(GAME_OVER到READY)。
状态机的好处是让逻辑不会互相干扰。比如在READY状态下,挡板按了也不该响应;在GAME_OVER状态下,物理更新可以跳过。如果没有状态机,所有逻辑都要靠if去判断当前能不能做,代码会迅速变成一团乱麻。
5. 常见问题与排查技巧实录
5.1 弹珠穿墙:步长与物体尺寸的博弈
我遇到的第一个严重bug是弹珠高速移动时直接穿过挡板或台面边界。原因很好理解:每帧弹珠移动了几十甚至上百像素,而挡板厚度只有几个像素,上一帧弹珠在墙的前面,下一帧已经到了墙的后面,碰撞检测自然抓不到。
我当时的处理办法有两个层面。第一层是缩小每帧最大位移。做法是在update里把一次物理更新拆成几个小步骤执行,每步最多移动弹珠半径的一半:
function updateBall(ball, deltaTime) { let remainingTime = deltaTime; let stepTime = 0.008; // 每步8毫秒 while (remainingTime > 0) { let dt = Math.min(stepTime, remainingTime); applyPhysics(ball, dt); checkCollisions(ball); remainingTime -= dt; } }这个“子步进”方案几乎能解决所有高速穿透问题,代价是多花一点CPU时间,但对弹珠台这种元素数量很少的游戏来说完全可忽略。
第二层是对挡板这类关键碰撞体做了特殊处理:把挡板的有效碰撞厚度从视觉厚度扩了一圈。这样即使偶尔速度突发过高,至少挡板能挡住弹珠,不会出现弹珠直接穿过挡板飞出屏幕的致命问题。
5.2 deltaTime计算不当造成的行为漂移
之前提过切后台导致弹珠暴走的问题,还有一个更隐蔽的坑:如果你把deltaTime用在多个地方重复叠加,位移会越算越大。
举个例子:在update里先做了ball.vy += GRAVITY * deltaTime,然后又写了一句ball.y += ball.vy * deltaTime,如果这两段逻辑被放在两个不同的函数里,而其中一处又额外再乘了一次deltaTime,那弹珠的加速度就会变成重力的两倍。
这类bug排查起来特别难受,因为弹珠的运动看起来只是“有点快”,不仔细对比根本发现不了。我的经验教训是:把deltaTime的统一处理放在主循环的update入口处,后面的子函数一律只接收已经算好的位移量,不再自行乘时间差。这样从源头上杜绝了重复累加。
5.3 弹珠在角落里疯狂抖动
弹珠撞进两个矩形边界的夹角处时,经常会陷入一种“疯狂抖动”的状态——每一帧都在反复检测碰撞和反弹,位置来回跳变,肉眼看到的是弹珠在角落里抽搐。
抖动根源是:反弹后弹珠仍然处在重叠区域,下一帧又触发方向相反的碰撞,往复循环。我解决这个问题靠两层措施:
第一,碰撞响应后把弹珠推到边界表面之外,这个前面提过。第二,在同一次碰撞检测循环里,一个弹珠对同一个边界最多只处理一次碰撞响应,不重复响应同一个碰撞对象。
这两条一加,抖动问题基本绝迹。如果偶尔还有轻微抖动,多半是两块边界几乎平行但留下极小间隙,这时候只要把间隙扩大一点就行。
5.4 真机性能和Canvas渲染优化
Canvas游戏在桌面浏览器里跑得很顺,一放到手机端就开始掉帧,这是老生常谈了。弹珠台这种场景简单的小游戏,最容易出现的性能瓶颈有两个。
第一个是每帧都调用了大量不必要的Canvas状态切换。比如绘制多个触发器时每次都重新设置fillStyle、strokeStyle,甚至用了阴影效果。Canvas的阴影(shadowBlur)是性能杀手,我在调试动画特效时一旦忘关,帧率直接从60掉到30。
第二个是Canvas的像素尺寸和设备像素比不匹配。手机屏幕的物理像素是CSS像素的好几倍,如果不做适配,Canvas默认会按CSS像素大小绘制,在高密度屏上就会模糊。我用的是固定逻辑宽高,不追求高清屏适配,但会手动把Canvas画布尺寸乘以设备像素比,并用ctx.scale做整体缩放:
const dpr = window.devicePixelRatio || 1; canvas.width = GAME_WIDTH * dpr; canvas.height = GAME_HEIGHT * dpr; ctx.scale(dpr, dpr);这样绘制代码里用的还是原来的逻辑坐标,但渲染精度已经跟上了屏幕,画面锐利很多。代价是GPU负担变大,但对弹珠台这种简单场景来说值得。
5.5 挡板灵敏度调整:不同设备的输入差异
键盘按键和触屏点击在响应速度上有本质差异。键盘的keydown事件可以在按下瞬间触发,触屏的touch事件理论上也很快,但移动设备的浏览器会有轻微的触摸延迟。
我在手机端加了touch-action: none样式,阻止了浏览器默认的触摸滚动行为,并且用pointerdown而不是touchstart来监听事件。pointerdown的好处是鼠标、触摸、手写笔都统一处理,不需要为不同设备写两套逻辑。
手感调优的另一处是:挡板的起始反应不做延迟,只要按下就立刻抬板;但是拍击效果(附加冲量)加了一个30毫秒的小延迟,这样视觉上挡板往上抬的瞬间,弹珠感受到推力的时机更符合人们的预期。
6. 还有一些值得升级的方向
基础版完成后,我留了几个后续想补的内容点,如果你想把项目往上再做一步,可以参考这些方向。
弹珠台最吸引人的其实是灯光和声效。Canvas的绘图能力完全可以模拟舞台灯效果——触发器被点亮时做一圈向外扩散的渐变光环,连击时整个台面背景色轻微变亮。还有弹珠滚动时的音效,用Web Audio API合成一个低频的滚动噪声,碰撞时播放一个短促的撞击声,游戏体验会上一个档次。
另外挡板和触发器可以加“奖励机制”,比如连续触发同一侧三个触发器后,台面中部的光圈变成金色,弹珠碰到金色光圈获得三倍分数。这类内容从技术上来说并不复杂,核心还是对碰撞和状态机的理解,但能把游戏从“能玩”带向“有乐趣”。
如果你正在做自己的弹珠台Demo,最需要记住的建议就三条:物理计算和渲染绘制彻底分开、碰撞响应后务必推出重叠区域、deltaTime的钳制和统一处理。把这三条执行到位,你的弹珠游戏离一个能拿得出手的作品已经非常近了。