尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

微信小程序泡泡龙游戏开发:Canvas 2D与碰撞检测实战解析

微信小程序泡泡龙游戏开发:Canvas 2D与碰撞检测实战解析 简介这是一份面向微信小程序初学者与游戏开发爱好者的泡泡龙小游戏实战源码聚焦前端逻辑实现与小程序生态实践帮助开发者快速掌握小游戏开发核心技能。资源以ZIP格式封装共包含game.js核心逻辑文件及配套图片、音效等静态资源整体包体大小为6.34MB其中game.js承载全部游戏机制——包括泡泡发射、碰撞检测、连消判定、得分计算与状态管理图片资源支撑界面渲染音效增强交互反馈无需后端即可独立运行。已有577人学习下载反映出其作为教学案例的广泛认可度。读者可直接导入微信开发者工具调试运行深入理解WXML/WXSS结构样式绑定、JavaScript事件监听与定时器驱动的游戏循环、小程序生命周期与数据响应式更新等关键机制是打通理论到实践、构建完整小游戏开发认知链路的优质入门项目。 前两天一个读者问我网上下了份泡泡龙微信小游戏的源码怎么折腾都跑不起来就算跑起来了泡泡撞墙后的落点也乱七八糟。这其实不是源码问题而是大多数人拿到代码就直接看逻辑没先理解这类 H5 小游戏移植到微信小程序环境后的几个关键差异。泡泡龙作为经典三消门类的代表逻辑不算复杂但要真正跑得流畅、手感对涉及网格映射、碰撞判定、流程状态管理、Canvas 渲染优化这一整套东西。这篇文章我就从零开始把一版可用的泡泡龙微信小程序源码按模块拆开讲包括核心算法、适配方案、性能优化要点以及我在真机调试时踩过的几个坑。适合刚接触微信小游戏开发、想拿经典玩法练手的开发者参考也适合拿到源码但不知道怎么改的人对照排查。1. 项目概述与技术选型1.1 泡泡龙小游戏的核心体验拆解泡泡龙玩起来很简单屏幕下方有一个发射器你点击或拖动调整角度发射一个彩色泡泡泡泡飞行撞到顶部区域后吸附在已有泡泡旁边凑齐三个及以上同色泡泡就会消除消除后如果有没有依托的悬空泡泡也会一并掉落。这个玩法听起来简单但要做到手感顺滑背后需要处理三个核心问题泡泡飞行的轨迹计算和碰撞检测要够快不能有明显延迟。泡泡落在蜂窝网格上的位置必须精准不能出现看着碰上了结果钻进缝里的穿模感。三连消除和悬空掉落的判定要正确否则游戏会出现逻辑错误玩家很容易察觉。微信小程序环境下做这类游戏还有个额外约束——运行环境是 WebView 加小程序运行时性能比原生 App 差一截所以渲染方案和对象管理方式直接影响用户体验。这也决定了我们不能像写网页那样随意操作 DOM而要直接用 Canvas 绘制 手动管理渲染循环。我做的第一版就是典型反面教材直接拿网页版改编DOM 节点铺了上百个结果在低端安卓机上直接卡成 PPT。后来全部改成 Canvas 2D 单层绘制帧率才稳定在 60 FPS 附近。1.2 为什么选择 Canvas 2D 而不是 WebGL 或 DOM 方案在小程序里展示游戏画面主流可选方案有三个DOM 节点动画、Canvas 2D、WebGL。DOM 方案在网页里最省事用 div CSS 加绝对定位就能做但在小程序里性能很差因为每次移动都要触发视图层和逻辑层之间的通信。泡泡龙一屏最多几十个泡泡如果每个泡泡都是一个 DOM 节点光通信开销就够呛我实测在小程序里这种方案连 30 FPS 都保不住。WebGL 性能最强能扛复杂特效和大量粒子但开发门槛高而且对泡泡龙这种休闲游戏来说属于杀鸡用牛刀。除非你要加非常炫酷的背景特效否则不建议选。Canvas 2D 是最平衡的方案既能精确控制每一帧的绘制又不需要处理着色器性能完全够用。微信小程序的 Canvas 接口比较特殊有旧版的 wx.createCanvasContext 和新版的 Canvas 2D 接口后者更接近 Web 标准性能也更好我后面讲实操时会具体说怎么用。1.3 项目整体结构规划拿到源码后不要急着看游戏逻辑先把工程结构理顺。一版结构合理的泡泡龙微信小程序项目大概是这样的├── game.js // 小游戏入口文件 ├── game.json // 小游戏配置 ├── project.config.json // 项目配置 ├── js │ ├── main.js // 游戏主逻辑初始化场景和循环 │ ├── grid.js // 蜂窝网格数据结构与算法 │ ├── bubble.js // 泡泡对象 │ ├── shooter.js // 发射器逻辑 │ ├── collision.js // 碰撞检测 │ ├── renderer.js // Canvas 绘制 │ ├── audio.js // 音频管理 │ └── utils.js // 工具函数 └── assets ├── images // 泡泡贴图、背景图 └── audio // 发射、消除、掉落音效我习惯把网格算法单独放到 grid.js 里因为这是整个游戏的大脑和渲染完全解耦。这样做的好处是后续如果要做 AI 自动解法或关卡编辑器直接调用网格接口就行不用动渲染层。微信小游戏不像小程序有页面结构它的入口是 game.js你需要调用wx.createCanvas()创建画布然后手动开启游戏循环。这个和浏览器里的 requestAnimationFrame 有区别需要特殊处理我会在后面的帧循环部分详细说。2. 核心玩法与算法实现2.1 蜂窝网格映射泡泡龙的地基泡泡龙的泡泡排列方式不是简单的方阵而是蜂窝状奇数行比偶数行偏移半个泡泡宽度。这种排列方式的专业称呼是六边形网格Hexagonal Grid但泡泡龙只用了其中一行行的逻辑不需要铺满六边形。理解网格映射是整个项目最重要的一步。我习惯用行和列来定位泡泡行号从顶部开始往下递增列号从左边开始往右递增。核心映射公式如下假设泡泡半径为r垂直方向相邻两个泡泡圆心的距离是2r实际上因为气泡贴图周围会有透明边距通常用2r * 0.98之类的小于1的系数微调让泡泡看起来挨得更紧水平方向相邻圆心的距离是2r * cos(30°)也就是sqrt(3) * r。网格坐标转像素坐标// row 从 0 开始col 从 0 开始 function gridToPixel(row, col, radius) { const x col * Math.sqrt(3) * radius (row % 2 1 ? Math.sqrt(3) * radius / 2 : 0); const y row * radius * 2; return { x, y, isOdd: row % 2 1 }; }这里的(row % 2 1 ? ... : 0)就是奇数行偏移半个列宽的逻辑。很多新手在这里写错导致泡泡跑位看起来像悬浮在半空或者重叠。像素坐标转网格坐标是反向操作但要特别注意舍入规则。发射出去的泡泡撞到已有泡泡时我们需要找到最近的网格位置来吸附这个最近不能简单四舍五入而要遍历当前碰撞点附近的几个候选网格选距离最近的可用空位function pixelToGrid(x, y, radius) { const row Math.round(y / (2 * radius)); const halfOffset (row % 2 1) ? (Math.sqrt(3) * radius / 2) : 0; const col Math.round((x - halfOffset) / (Math.sqrt(3) * radius)); return { row, col }; }这个函数返回的 row 和 col 可能越界或者对应的网格位置已经有泡泡调用方需要判断。我在实际项目里通常会在 pixelToGrid 外层包一层findNearestEmptySlot它接受碰撞点遍历周围最多四个候选格返回真正可用的那个。这比直接舍入稳定得多实测下来大幅减少了泡泡飞过去了却吸不进网格的问题。2.2 发射与碰撞检测泡泡飞行的物理逻辑泡泡发射的物理逻辑是从发射器位置出发沿某个角度直线飞行每帧更新位置检测是否碰到顶部、左右墙壁或已有泡泡。泡泡碰撞检测有两种方案圆与圆的距离检测计算飞行中泡泡的圆心和已有泡泡圆心的距离如果小于两倍半径判定为碰撞。向量/射线检测沿运动方向做射线投射找最早的相交点。泡泡龙这种游戏用第一种就够了因为泡泡飞行速度不算太快只要每帧位移不超过泡泡直径的一半距离检测就不会漏掉碰撞。这里有个经验值如果希望帧率稳定在 60 FPS一帧飞行距离建议控制在radius * 0.3以内超过这个值就容易出现穿模——上一帧还没碰到下一帧已经飞过老远了。具体实现时我维护了一个flyBubble对象它有一个vx和vy速度向量每帧更新坐标// 在 game loop 中更新飞行中的泡泡 const speed 1200; // 像素/秒这个值可以调影响手感 flyBubble.x flyBubble.vx * speed * dt; flyBubble.y flyBubble.vy * speed * dt;每次更新后先检测墙壁反弹if (flyBubble.x radius) { flyBubble.x radius; flyBubble.vx Math.abs(flyBubble.vx); } if (flyBubble.x canvasWidth - radius) { flyBubble.x canvasWidth - radius; flyBubble.vx -Math.abs(flyBubble.vx); }然后遍历所有已固定的泡泡检测距离。如果碰撞了就调用前面说的findNearestEmptySlot找到吸附位置把飞行的泡泡转成网格泡泡。这里有一个细节碰撞吸附完成后要把飞行泡泡的坐标强制设置为目标网格的像素坐标而不是保留碰撞点的坐标。否则画出来的泡泡会处于网格之外的偏移位置后续的消除判定也会错乱。我第一次写的时候偷懒直接用了碰撞点坐标结果泡泡虽然吸附了但出现连接线位置对不齐的情况看着非常别扭。2.3 三连消除与悬空掉落BFS 算法的应用泡泡吸附成功后紧接要做两个判定同色连通消除和悬空掉落。这两个判定都可以用广度优先搜索BFS实现但搜索的起点和条件完全不同。三连消除的搜索逻辑是以新吸附的泡泡为起点往上、左、右、斜方向找相邻的同色泡泡把所有连通的同色泡泡都找出来如果数量大于等于 3就全部消除。蜂窝网格的相邻关系稍微绕一点因为奇数行和偶数行的邻居偏移不同。我用一个函数来获取某个格子的所有邻居坐标function getNeighbors(row, col) { const odd row % 2 1; const offsets odd ? [[-1, 0], [-1, 1], [0, -1], [0, 1], [1, 0], [1, 1]] : [[-1, -1], [-1, 0], [0, -1], [0, 1], [1, -1], [1, 0]]; return offsets .map(([dr, dc]) [row dr, col dc]) .filter(([r, c]) r 0 c 0 c cols(r)); }这里cols(r)表示第 r 行最多有多少列奇数行比偶数行少一列。每行的列数和屏幕宽度及泡泡直径有关是提前算好的。BFS 实现不复杂用一个队列和一个 visited 集合function findConnected(row, col, color, grid) { const queue [[row, col]]; const visited new Set([${row}-${col}]); const result []; while (queue.length) { const [r, c] queue.shift(); result.push([r, c]); for (const [nr, nc] of getNeighbors(r, c)) { const key ${nr}-${nc}; if (!visited.has(key) grid[nr]?.[nc]?.color color) { visited.add(key); queue.push([nr, nc]); } } } return result; }消除后要找悬空掉落也就是检查哪些泡泡失去了与顶部行的连接。这个用 BFS 也简单从第一行的所有泡泡出发沿着所有方向的邻居做遍历能访问到的都属于安全泡泡剩下的就是要掉落的。掉落泡泡不用一帧一帧模拟重力直接设置一个动画从当前位置往下移动到屏幕外然后从网格中移除。这样视觉上顺滑逻辑实现也简单。如果对物理效果要求高可以用重力加速度加初速度模拟但对休闲游戏来说没必要匀速掉落已经够自然了。2.4 发射角度控制触摸交互与辅助射线发射角度控制有两种交互方式拖动发射器瞄准或者点击屏幕确定方向。微信小程序上最常用的是点击/拖动屏幕发射器跟随手指角度转动松手发射。这里的核心是把触摸点的屏幕坐标转换成相对于发射器中心的角度const angle Math.atan2(touchY - shooterY, touchX - shooterX);拿到角度后把角度限制在垂直向上左右各 60 度范围内避免玩家往下方发射。然后速度向量就是const vx Math.cos(angle); const vy Math.sin(angle);注意 vy 的方向因为 Canvas 坐标系 y 轴向下所以朝上发射时 vy 是负值。很多新手在这里忽略了正负号导致泡泡往反方向飞检查了半天发现只是符号问题。为了帮助玩家瞄准我加了一条辅助射线从发射器出发模拟泡泡飞行路径预测它会落在哪个位置并画一条虚线。这个其实很有用它让玩家不需要靠感觉盲射游戏上线后明显降低了误触率。实现方式是做一个简化版的飞行模拟固定角度按每帧步进模拟泡泡飞行遇到墙壁反弹遇到已有泡泡就停止记录轨迹点。每帧都模拟的话性能堪忧所以我只在触摸移动时重新计算不移动就不重算实测性能开销可以忽略。路径模拟的伪代码function simulatePath(angle, radius, grid, canvasWidth, canvasHeight) { let x shooterX; let y shooterY; let vx Math.cos(angle); let vy Math.sin(angle); const points []; let maxSteps 500; while (maxSteps-- 0) { x vx * STEP; y vy * STEP; // 撞墙反弹 if (x radius || x canvasWidth - radius) { vx -vx; x Math.max(radius, Math.min(canvasWidth - radius, x)); } // 撞顶部 if (y radius) { points.push({ x, y }); break; } // 检查碰撞已有泡泡 const { row, col } pixelToGrid(x, y, radius); if (grid[row] grid[row][col]) { points.push({ x, y }); break; } if (step % 5 0) points.push({ x, y }); } return points; }STEP 取 2 像素比较合适太大会漏碰撞检测太小则性能吃紧。这版模拟不是 100% 精确但用于辅助瞄准完全够用。3. 微信小程序适配与性能调优3.1 像素比适配避免 Canvas 模糊的必修课微信小游戏拿到手的 Canvas 尺寸默认不是物理像素尺寸而是 CSS 逻辑像素。如果直接用逻辑像素作为画布宽高在 Retina 屏上绘制出来的画面会发糊因为一个逻辑像素被映射到多个物理像素上时画布没有对应的分辨率去渲染。解决方案是调用wx.getSystemInfoSync()获取设备的pixelRatio然后把 Canvas 的实际宽高乘以这个比例再通过ctx.scale(pixelRatio, pixelRatio)来做缩放const info wx.getSystemInfoSync(); const dpr info.pixelRatio; const canvas wx.createCanvas(); const width info.windowWidth; const height info.windowHeight; canvas.width width * dpr; canvas.height height * dpr; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr);这里有一个容易踩的坑wx.createCanvas创建的画布是上屏画布主画布它会自动铺满整个屏幕不需要手动设置宽高样式。但它的像素宽度确实需要乘以 dpr否则模糊。代码里ctx.scale(dpr, dpr)的目的是让后续绘制坐标仍然以逻辑像素为准这样你在画布上画(100, 100)的位置时实际落点是(100*dpr, 100*dpr)清晰度就对了。另外微信小游戏还支持创建离屏 Canvas也就是wx.createCanvas()之后再创建的都是离屏画布常用于预渲染。后面讲对象池时会提到用途。3.2 渲染分层与对象池告别频繁创建对象泡泡龙游戏一屏最多几十个泡泡性能压力不大但一些小的编程习惯会导致不必要的性能损耗。最常见的问题是每帧绘制时都创建新对象、拼接数组、动态分配内存导致垃圾回收频繁触发帧率出现卡顿。我做过一个简单测试同样的泡泡画面一个版本每帧都ctx.fillStyle #ff0000并新创建路径另一个版本提前把所有颜色对应的路径预生成性能差距在低端机上能达到 15% 以上。所以对象池和预生成思想很重要。具体到泡泡龙项目我做以下优化泡泡贴图预渲染每种颜色的泡泡单独画到一个离屏 Canvas 上游戏运行时直接通过ctx.drawImage绘制而不是每帧调用ctx.arcctx.fill。这样渲染开销直接降了一个量级。网格数据用普通数组存储避免使用 Map/Set 做高频访问。BFS 的 visited 我用了 Set但只在消除判定时用频率低可以接受。飞行和渲染循环中不创建新对象。发射的泡泡对象创建后不销毁飞完或被消除后放回池子复用。消除的泡泡也只标记为不可见等帧结束统一回收。对象池实现很轻量class BubblePool { constructor() { this.pool []; } get() { return this.pool.pop() || { active: false, color: null, x: 0, y: 0 }; } put(bubble) { bubble.active false; bubble.color null; this.pool.push(bubble); } }不要小看这些细节。微信小游戏对内存和性能是有监控的如果代码写得太粗糙在开发者工具里看不出问题但一上真机尤其是安卓中低端机就能明显感到掉帧。3.3 帧循环与帧率控制setInterval 和 requestAnimationFrame 的选择浏览器里有requestAnimationFrame微信小游戏环境也对requestAnimationFrame做了支持但不同基础库版本行为有差异。最稳的方式是直接调用全局的requestAnimationFrame如果发现某些老版本不支持再用setInterval兜底。我比较推荐直接使用requestAnimationFrame因为它是帧同步的能自动适配屏幕刷新率而且可以在页面切后台时自动暂停。有一个重要细节微信小游戏的requestAnimationFrame回调参数是时间戳单位是毫秒你需要用它计算两帧之间的时间差 dt而不是想当然地认为每帧间隔是固定 16.67ms。标准帧循环模板let lastTime Date.now(); function gameLoop() { const now Date.now(); const dt (now - lastTime) / 1000; lastTime now; update(dt); render(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);为什么 dt 这么重要因为不同设备的帧率不同同一台设备在复杂场景下也可能从 60 帧掉到 30 帧。如果运动代码里把速度写成固定每帧位移掉帧时游戏就会变慢用 dt 做时间比例换算才能保证不同帧率下游戏速度一致。有读者问到一帧飞行距离控制在 radius * 0.3的经验是怎么来的。这里展开说一下假设泡泡半径是 24 像素一帧飞行距离应该是 7.2 像素。如果帧率 60 FPS那发射速度大概是7.2 * 60 432 像素/秒不对前面说的 1200 像素/秒其实对应一帧 20 像素超过了 7.2 的限制。为了兼顾手感和防穿模我给飞行中的泡泡额外做了一步小步长迭代——每帧物理更新内部再分为 N 个小步每步只移动一小段距离每步都做碰撞检测。这样即使发射速度很快碰撞检测也不会漏。3.4 音频处理格式选择与预加载音效在小游戏中属于常被忽略但影响体验的因素。泡泡发射、碰撞、消除、掉落、游戏结束每个操作都应该有对应音效反馈。微信小游戏支持wx.createInnerAudioContext()创建音频实例但创建太多实例会导致内存压力。一个简单的做法是全局维护一个音频对象池音效是短文件一次只播放一个实例播放完就复用。音频格式建议用 m4a 或 aac体积小加载快。小游戏包体有 4MB 主包限制超出后要走分包所以资源能压缩就压缩。音效文件控制在 50KB 以内一两个音效无所谓多了就明显影响包体。另外音频需要在用户交互之后才能播放的规则在小游戏里也适用首次播放前最好先做一次静音播放热身否则在部分 iOS 设备上可能无声。这个属于比较隐蔽的坑我在真机测试时遇到过好几次都是因为脚本里直接调用播放没有先经过触摸事件。4. 完整实操过程与核心代码实现4.1 创建项目与初始配置在微信开发者工具里新建小游戏项目时选小游戏分类而不是小程序这一步很容易选错。两者虽然界面相似但文件结构和入口完全不同。小游戏的入口是game.js小程序的入口是app.js。game.json是最基础的配置至少要声明界面方向{ deviceOrientation: portrait }如果要用到本地资源还需要配置workers、subpackages等字段但一个基础版泡泡龙用不到就不展开了。入口文件game.js只需要三行代码把主逻辑模块引进来并启动import Main from ./js/main; new Main();可能需要配置project.config.json里的compileType为game否则开发者工具不识别小游戏项目。这个字段一般创建项目时会自动生成但如果你拿到的是源码包最好检查一下。4.2 场景搭建画布、背景、发射器场景搭建在Main类的init方法里完成。我习惯拆成几个子方法initCanvas、initGrid、initShooter、initAudio、initTouch。初始化画布时除了前面讲的 dpr 适配还要注意获取 Canvas 的 2D context 的方式。微信小游戏支持两种接口旧版不推荐const ctx canvas.getContext(2d);新版Canvas 2Dconst ctx canvas.getContext(2d);看起来一样其实微信小游戏的 Canvas 有两种创建方式wx.createCanvas()返回的是上屏 Canvas还有一个废弃的wx.createContext()。在老基础库版本中canvas.getContext(2d)返回的可能是旧版 context不支持ctx.arc、ctx.fill等标准方法。如果遇到这种情况检查基础库版本升级后一般都能解决。背景绘制我直接用一个纯色渐变填充不加载图片节省包体。如果想让画面更精致可以预渲染一张小尺寸背景图拉伸铺满。注意图片资源要走本地路径wx.createImage()加载加载完成后再绘制。初始化发射器发射器位置固定在屏幕底部中央偏上一点例如(canvasWidth / 2, canvasHeight - 80)。发射器的视觉呈现是一个半圆底座加上当前待发射的泡泡预览。4.3 网格数据结构与初始化网格我用二维数组grid[row][col]表示grid[row][col]为null或未定义表示该位置没有泡泡。每行列数可能不同所以我会存一个colsPerRow数组表示每一行有多少列。一列的宽度是sqrt(3) * radius所以const maxCols Math.floor((canvasWidth - radius * 2) / (Math.sqrt(3) * radius)) 1;初始化时顶部 5~6 行随机填充若干颜色的泡泡作为游戏开始的局面。这里有几个控制项初始行数太少游戏难度低太多玩家可操作空间小。我默认填 5 行。随机颜色数量3 色最简单4 色中等5 色偏难。上线版我用了 4 色兼顾趣味和难度。不要把每行都填满偶尔留一些空位游戏会更有变化。但第一行必须填满否则悬空判定会出问题。填充代码如下function initGrid(rows) { const grid []; const colors [COLOR_RED, COLOR_BLUE, COLOR_GREEN, COLOR_YELLOW]; for (let r 0; r rows; r) { const cols r % 2 0 ? maxCols : maxCols - 1; grid[r] []; for (let c 0; c cols; c) { const color colors[Math.floor(Math.random() * colors.length)]; const pos gridToPixel(r, c, radius); grid[r][c] { row: r, col: c, color, x: pos.x, y: pos.y, active: true, }; } } return grid; }4.4 发射逻辑实现从触摸到碰撞吸附发射流程分四步玩家松手触发发射 → 创建飞行泡泡 → 物理更新与碰撞检测 → 碰撞后吸附进网格并触发消除。触摸事件绑定用wx.onTouchStart、wx.onTouchMove、wx.onTouchEnd注意事件回调里拿到的坐标是逻辑像素坐标已经被小程序处理过不需要再做 dpr 换算wx.onTouchStart((e) { const touch e.touches[0]; // 更新瞄准角度 this.aimAngle Math.atan2(touch.clientY - shooterY, touch.clientX - shooterX); this.isAiming true; }); wx.onTouchMove((e) { const touch e.touches[0]; this.aimAngle Math.atan2(touch.clientY - shooterY, touch.clientX - shooterX); this.isAiming true; }); wx.onTouchEnd(() { if (this.isAiming) { this.shoot(); } this.isAiming false; });发射方法里从发射器中心位置创建泡泡根据当前角度设定 vx、vy并做角度范围限制shoot() { const angle this.clampAngle(this.aimAngle); if (angle null) return; const bubble this.pool.get(); bubble.active true; bubble.color this.currentColor; bubble.x this.shooterX; bubble.y this.shooterY; bubble.vx Math.cos(angle); bubble.vy Math.sin(angle); this.flyBubble bubble; this.currentColor this.nextColor(); }clampAngle把角度限制在垂直方向左右各 60 度范围内。具体来说如果角度在水平线以下或者偏离垂直方向太远就拒绝发射或者强制修正。物理更新我用了前面提到的小步长碰撞检测update(dt) { if (!this.flyBubble || !this.flyBubble.active) return; const speed 900; // 发射速度像素/秒这个值多调几次手感就出来了 const steps 8; const subDt dt / steps; const subStep speed * subDt; const b this.flyBubble; for (let i 0; i steps; i) { b.x b.vx * subStep; b.y b.vy * subStep; // 墙壁和顶部碰撞检测 this.checkBoundary(b); // 泡泡碰撞检测 const hit this.checkBubbleCollision(b); if (hit) { this.attachBubble(b); break; } } }checkBubbleCollision遍历网格里的所有活跃泡泡计算圆心距离。这里可以做一层空间哈希优化比如按行分桶只检测碰撞点附近的几行但几十个泡泡规模下直接遍历就够快了为了代码可读性我没有做复杂优化。4.5 吸附、消除与掉落完整流程吸附逻辑把飞行泡泡转为网格泡泡。核心代码attachBubble(b) { const { row, col } this.findNearestEmptySlot(b.x, b.y); const pos gridToPixel(row, col, this.radius); b.x pos.x; b.y pos.y; b.row row; b.col col; b.active false; // 不再参与飞行标记为静态 this.grid[row][col] b; this.flyBubble null; // 消除判定 const matched this.findConnected(row, col, b.color); if (matched.length 3) { this.removeBubbles(matched); this.handleFalling(); this.increaseScore(matched.length); } else { // 切换下一个待发射颜色 this.prepareNextShoot(); } }findConnected已经在上文给出。removeBubbles是把匹配到的泡泡的active标记为 false并从网格数组里置空然后播放消除音效和粒子动画。粒子动画不是必须的但加上后观感提升明显。我用的是一种很轻的粒子系统消除时生成 6~8 个小圆圈向四周扩散并逐渐变小消失持续约 0.3 秒。handleFalling是处理悬空掉落。用 BFS 找所有被悬挂的泡泡然后给它们设置一个掉落速度和目标位置。掉落动画期间它们已经从网格数据结构中移除不参与后续碰撞判定。这里有一个容易被忽视的细节掉落动画执行期间玩家不能发射新的泡泡否则新泡泡可能和正在掉落的泡泡发生位置重叠视觉上穿帮。所以我加了一个isAnimating状态掉落动画播放期间屏蔽发射输入。动画时长通常在 0.5~0.8 秒玩家可以接受。4.6 计分、关卡与游戏结束计分规则我采用经典三消加分制每消除一个泡泡得 10 分连续消除有连击加成。掉落泡泡每个额外加 5 分。分数显示直接用wx.setUserCloudStorage或本地存储保存最高分排行榜功能如果要接微信开放数据域需要单独实现这里先不做展开。游戏结束的判定有两类泡泡堆积到发射器高度玩家没有空间发射了。所有泡泡被清空玩家胜利。在每帧更新完成后检查最高泡泡所在行checkGameOver() { for (let r 0; r this.grid.length; r) { const row this.grid[r]; if (!row) continue; for (let c 0; c row.length; c) { if (row[c]) { const pos gridToPixel(r, c, this.radius); if (pos.y this.shooterY - 50) { this.gameOver(); return; } } } } }判断条件里的- 50是为了保留一点容错空间避免泡泡刚好贴着发射器还没碰到就判负。游戏结束时弹出一个简单的结果面板显示分数和重新开始按钮。5. 常见问题与排查技巧实录5.1 泡泡穿透、吸附偏移问题排查这个是我被问得最多的问题。表现是泡泡飞到已有泡泡附近时有时候会直接穿过去或者吸附后位置歪了跟网格对不上。排查思路按顺序来先确认像素比适配是否正确。如果 canvas 宽度没乘 dpr绘制坐标系错乱像素坐标和网格坐标不一致就会出现吸附偏移。检查pixelToGrid的偏移计算。奇数行偏移用的是sqrt(3) * radius / 2这里半径必须和绘制用的半径一致。如果你用了radius 24绘制网格计算也必须是 24不能一个用直径一个用半径。检查飞行速度和步长。如果一帧位移太大即使有碰撞检测也会漏。我建议把步长调整为Math.ceil(speed * dt / (radius * 0.3))动态计算需要拆分的步数这样无论帧率怎么波动都不会穿模。检查findNearestEmptySlot是否考虑了边界。如果最近的候选格是越界的或者已经有泡泡要继续找次近的格子否则会出现吸入异常。还有一个容易被忽略的网格行列的坐标方向。行号从上往下递增列号从左往右递增。如果你在代码里搞反了方向泡泡会镜像对齐玩起来感觉整个布局偏了。5.2 iOS 上画布模糊与渲染异常在 iOS 上画布模糊大概率是 dpr 适配没有做或做错了。有一种情况是wx.getSystemInfoSync()在部分 iOS 版本上返回的windowWidth和windowHeight不是逻辑像素而是物理像素直接乘 dpr 会导致画布尺寸过大反而更模糊。稳妥做法是用wx.getWindowInfo()替代wx.getSystemInfoSync()它在新版基础库中返回的是更准确的窗口信息。如果两者结果有偏差以其中一个为准并保证绘制坐标与网格坐标使用同一套坐标系。渲染异常更常见的是canvas.getContext(2d)返回了旧版 context。旧版 context 没有setTransform等方法绘制圆形路径时可能出现锯齿。解决方式是把基础库版本提到 2.9.0 以上并使用标准的 Canvas 2D 接口。我一般会在项目入口加一个版本判断低版本时提示升级微信。5.3 真机调试与开发者工具表现不一致很多人在开发者工具里跑得好好的一上真机就卡、就闪退。这不是玄学主要是两个差异开发者工具用的是桌面浏览器的渲染引擎性能远超手机端。所以代码里如果有一些性能隐患比如每帧创建大量对象、频繁fillText桌面端看不出问题真机就暴露。微信小游戏有调试模式和预览两种形态。调试模式性能更差因为会实时传输日志如果你在调试模式下测性能被吓到切到预览模式再测一次通常会有明显提升。真机上还有一个特殊限制主包大小 4MB超出无法预览。如果项目里图片资源多一定要走 CDN 或分包。泡泡龙的资源不算多我一般把图片压缩到 100KB 以内主包控制在 2MB 左右这样还能留空间给后续扩展。如果真机出现异常闪退优先查看wx.onError回调捕获的错误日志它能帮你定位是 JS 异常还是资源加载失败。异常闪退大概率是某个 API 在低版本基础库上不可用用wx.canIUse()做兼容判断可以提前规避。5.4 开发者工具里的常见警告与错误处理我整理了一个备忘录列出平时会被问到的几个报错错误信息原因解决方案canvas.getContext is not a function基础库版本过低或拿错 Canvas 对象升级基础库确认用的是wx.createCanvas()返回的实例wx.onTouchStart is not a function在非小游戏环境调用小游戏 API检查project.config.json的compileType是否为gameCannot read property color of undefined网格数组越界或访问了未初始化行检查getNeighbors是否过滤边界grid[row]是否存在requestAnimationFrame is not defined部分低版本基础库不支持用setTimeout做降级方案音频无法播放未经过用户交互或音频格式不支持首次播放前先手动触发一次使用 m4a/aac 格式这些错误大部分是环境适配问题和游戏逻辑无关。遇到报错先定位是哪一层的问题再用wx.canIUse排查兼容性通常能快速解决。6. 项目扩展与微信生态能力接入6.1 分包异步化与资源按需加载泡泡龙这种休闲游戏资源量不大但如果后期加大量关卡图、音效、皮肤主包 4MB 的限制很容易触顶。微信小游戏支持分包加载可以把关卡数据、高级音效、背景图放到分包里在游戏启动后再按需加载。分包异步化的核心思路是主包只保留首屏必需逻辑和基础资源子包通过wx.loadSubpackage预加载或者在使用时动态 require。对于泡泡龙这种轻量游戏一般不需要把所有逻辑分包把资源分出去就够了。我建议的做法主包游戏核心逻辑、基础泡泡贴图、基础音效。分包关卡配置、皮肤资源、进阶音效。这样首包控制在 2MB 以内启动速度能明显提升。启动速度在小游戏里影响很大加载超过 3 秒玩家的流失率会显著上升。6.2 开放数据域与排行榜微信小游戏的排行榜需要用到开放数据域它是一个独立的 JS 运行环境不能直接操作主域 Canvas只能在共享画布上绘制排行榜内容。实现思路是主域通过wx.setUserCloudStorage把分数写入用户数据开放数据域里通过wx.getFriendCloudStorage获取好友分数然后在共享 Canvas 上绘制。这部分不需要做到特别复杂但需要注意的是开放数据域的代码是独立打包的不能引用主域代码所以数据结构和绘制逻辑要单独维护一份。如果只是做个简单的分数排行榜工作量不大我建议上线版至少做一个能显著提升玩家的复玩意愿。6.3 从 Canvas 到 WebGL 的迁移展望如果你的泡泡龙后续要做大量特效比如泡泡带光效、消除时粒子爆炸、背景动态流动Canvas 2D 可能会达到性能瓶颈。这时候可以考虑迁移到 WebGL 渲染。WebGL 对大部分开发者来说上手成本高但微信小游戏提供了wx.createCanvas()返回的 WebGL 上下文配合 pixi.js 这类渲染库能大幅降低开发难度。pixi.js 的 WebGL 渲染器对小游戏友好它支持批量绘制精灵性能比 Canvas 2D 高一个量级。不过我的建议是在游戏玩法验证完成之前不要轻易上 WebGL。Canvas 2D 足够覆盖泡泡龙的需求先上线拿到真实用户反馈再决定是否值得为视觉表现投入更多开发成本。7. 项目源码清单与文件说明最后整理一下源码文件的职责划分方便你拿到代码后快速定位。game.js入口文件创建 Main 实例。game.json小游戏配置声明屏幕方向。js/main.js主逻辑类负责初始化和游戏循环调度。js/grid.js网格算法包括坐标转换、邻居查找、BFS 消除和掉落判定。js/bubble.js泡泡类包含飞行泡泡和静态泡泡的属性和方法。js/shooter.js发射器逻辑角度计算、发射动作。js/collision.js碰撞检测相关函数。js/renderer.jsCanvas 绘制包括背景、泡泡、辅助线、粒子效果。js/audio.js音效管理封装了音频播放和对象池。js/utils.js通用工具函数比如像素比适配、角度限制。文件拆分不是越多越好关键是每个模块职责单一。如果整个项目就几百行全写在一个文件里也完全没问题。但如果你打算长期维护、加新功能模块化带来的好处会放大。我在写的时候把网格算法和渲染完全解耦这样以后如果要把泡泡龙变成连连看或者消消乐只需要改网格算法层渲染层可以直接复用。拿到源码后建议从grid.js开始读搞清楚网格坐标和像素坐标的关系再读main.js的更新循环最后看renderer.js的绘制。按这个顺序读一遍整个项目的逻辑就串起来了。本文还有配套的精品资源点击获取
返回列表