H5微信小游戏源码改造:Canvas接水杯游戏实战与性能优化
简介一套完整的H5微信小游戏“能接多少杯”源码包适合有一定前端基础、想学习小游戏开发或快速搭建同类接物游戏的开发者。压缩包内共10个文件涵盖HTML入口、核心逻辑脚本、PNG图标与分享图、MP3音频、JSON/PLIST配置及修改说明文档整体仅267KB结构精简便于快速定位。目前已有103人学习/下载。通过源码可全面学习H5游戏开发所需的CSS3界面样式、canvas绘图与动画、碰撞检测、计分与计时器实现并了解微信小程序对H5游戏的封装与API调用流程修改说明还能帮助调整角色、速度、难度便于二次开发适合作为教学示例、毕业设计或个人项目参考。1. 接杯游戏源码不是“页面搬进微信”而是把Canvas生命周期搬到小游戏“能接多少杯”这类接水杯小游戏规则一句话就能讲完屏幕里一个杯子靠触摸左右滑动接住连续下落的液体水滴接得越多分越高。正因为规则足够单纯它成了 H5 微信小游戏源码里很常见的一个品类拿来学习、改原型、或者把玩法换成接水果、接金币都很顺手。但“H5微信小游戏”这个连写词里藏着两层认知差。第一层很多所谓 H5 源码其实是 DOM 加 CSS 写的浏览器页面微信小游戏渲染层没有 DOM直接搬过去跑就是黑屏第二层小游戏的入口、生命周期、触摸事件体系和普通网页完全是两套约定项目目录里也没有 index.html。这篇文章顺着“能接多少杯”把最核心的做法讲透先搭出 Canvas 初始化框架再写接水杯的碰撞判定和触摸手感最后处理适配、性能与提审前的软著准备。2. H5源码落地微信小游戏运行环境、初始化顺序与最小框架2.1 微信小游戏与普通H5页面的关键差异没有DOM入口只有game.js拿到的源码如果是网页版先不要急着进微信开发者工具。微信小游戏的项目结构里没有 index.html、没有 window 对象也没有 document。你在 H5 里写的document.getElementById(game).appendChild(canvas)在小游戏启动环境里第一行就会报错。开发工具实际加载的是根目录下的 game.json 与 game.jsgame.json 声明屏幕方向、显示区域等game.js 是全部逻辑的入口渲染、输入、存储都发生在同一个全局作用域里。这个差异决定了后续所有代码的书写方式也决定了选型。做一个接水杯的小游戏原生 Canvas 2D 是改动最小、最好调的一条路线不引入引擎、不需要构建链路开发者工具打开直接跑。如果你拿到的是“能接多少杯”这类轻量源码包里也大多是这种原生写法。反过来团队已经有 Unity 工程想复用美术和逻辑走团结引擎打包微信小游戏的路线也很成熟但那时要重点检查 WebGL 模板的目录配置否则首次加载会卡在着色器编译上。用不用引擎取决于你要不要保留接杯游戏这个体量的轻量属性。场景网页H5微信小游戏创建画布document.createElement(canvas)wx.createCanvas()取上下文canvas.getContext(2d)同上触摸监听canvas.addEventListener(touchmove)wx.onTouchMove主循环requestAnimationFrame同上本地存储localStoragewx.setStorageSync同样的游戏逻辑在两种环境里只是渲染入口和事件入口不同update 和 render 这部分是可以共用的。所以跨端调试的常见做法是网页端把 canvas 挂到 div 里小游戏端用 wx.createCanvas两边的游戏核心代码保持一致。2.2 源码包解压后的目录结构与初始化顺序这类源码包解压之后我一般先看三个文件不看别的game.json小游戏配置只声明设备方向、状态栏不写页面路径game.js启动文件创建 canvas、获取上下文、绑定触摸、启动主循环根目录下的 js 或 src 目录放 update、render、碰撞检测等游戏逻辑。game.json 里最常用的一份配置长这样{ deviceOrientation: portrait, showStatusBar: false, networkTimeout: { request: 5000, connectSocket: 5000 } }逻辑说明portrait 强制竖屏接水杯玩法不需要横屏锁定朝向可以省去旋转处理的麻烦showStatusBar 置 false 保证全屏沉浸networkTimeout 用来兜底网络请求避免异常请求长时间占住线程。参数说明如果后续要支持横屏或者有人想把它改成横版接物游戏game.json 改 orientation 只是第一步安全区、触摸坐标、UI 布局全要跟着换工作量不小。没有换向需求就锁竖屏这是做这类小游戏最稳的配置。2.3 最小可运行的Canvas循环从game.js手写主循环接下来是 game.js 里最核心的一段。微信小游戏环境里拿 canvas 的方式与网页不同但初始化代码非常短// game.js 微信小游戏入口 const canvas wx.createCanvas(); // 创建主屏画布 const ctx canvas.getContext(2d); // 2D绘制上下文 // 获取设备信息新老API兼容 const info wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); const dpr info.pixelRatio || 1; // 物理像素比 const W info.windowWidth; // 逻辑宽度 const H info.windowHeight; // 逻辑高度 // 把canvas物理像素尺寸设置为逻辑宽高乘以DPR canvas.width W * dpr; canvas.height H * dpr; ctx.scale(dpr, dpr); // 之后绘制全用逻辑坐标 let lastTs 0; function loop(ts) { const dt lastTs ? (ts - lastTs) / 1000 : 0.016; lastTs ts; update(dt); // 更新水滴、杯位与碰撞 render(ctx); // 绘制当前帧 requestAnimationFrame(loop); } requestAnimationFrame(loop);逻辑说明wx.createCanvas 是小游戏环境里创建主屏画布的 API一个项目只有主屏 canvas 可以这么调用后续如果要离屏画布必须用 wx.createOffscreenCanvas。ctx.scale(dpr, dpr)这一行很关键之后所有坐标按 windowWidth/windowHeight 书写不用再手工乘像素比碰撞检测和触摸坐标都会简单很多。参数说明dt 是两帧间隔单位是秒update 里移动水滴都用它乘速度这样 Android 30 帧与 iOS 60 帧下的速度观感一致。不要直接把 requestAnimationFrame 回调里的时间戳当间隔第一帧时 lastTs 是 0不处理会得到极端的 dt。上面用三元表达式兜底默认给 0.016 秒之后每帧都按真实间隔计算。3. 接杯碰撞判定与触摸跟随把“能接多少杯”的玩法做实3.1 水滴与杯口碰撞判定圆形落入矩形区域的必要条件接水杯的核心碰撞发生在一滴液滴与杯口之间。把水滴简化成圆形杯口简化成一条水平方向、有厚度的矩形判定带这一层抽象足够支撑大部分玩法。判定带不需要和杯身一样宽通常会比杯身窄一点也就是“视觉杯口很宽、实际判定偏严格”反过来也可以比杯身宽一点做成“判定带冗余”新手更容易上手。这是整个游戏手感的分水岭建议放到最后调。实际判定函数可以长这样function checkCatch(drop, cup) { // drop: { x, y, r, state } // cup: { left, right, top, bottom }判定带矩形 const inX (drop.x drop.r cup.left) (drop.x - drop.r cup.right); const inY (drop.y drop.r cup.top) (drop.y - drop.r cup.bottom); return inX inY; }逻辑说明注意这里不是“圆心是否在矩形内”而是圆与矩形是否相交。圆的最终判定效果是水滴的边缘碰到判定带边缘就算接住视觉上和杯口碰水一致。inX 与 inY 同时成立才返回真少一个条件都会漏接。参数说明cup.left 与 cup.right 控制判定带的水平宽度。如果觉得“水滴明明碰到了杯口却漏了”把 left 向右缩、right 向左扩反过来觉得“接到太容易”就把判定带做窄。这两个值推荐做成动态参数而不是写死很多 H5 源码里会把这类参数做成屏幕上的拖动调节滑块放在真机上一边跑一边拉找到手感后写回配置。这比来回改代码、重新编译要快得多。3.2 触摸控制杯子移动目标坐标与插值跟随最省事的杯子跟手写法是“触摸点到哪杯子就到哪”也就是每帧把杯子的 x 赋值为触摸点 x。但这个做法在真机上有两个明显问题一是手指本身会挡住杯口玩家不知道自己有没有对准二是在部分 Android 机器上触摸事件频率高于渲染帧率杯子会来回抖一两个像素肉眼看起来很毛糙。我一般把“目标位置”和“绘制位置”拆开中间用一阶低通滤波做插值let cupX W / 2; // 当前绘制位置 let targetX W / 2; // 触摸目标位置 const CUP_LERP 0.25; // 跟随系数范围建议0.1~0.35 // 小游戏触摸监听 wx.onTouchMove((e) { const t e.touches[0]; const maxX W - CUP_WIDTH / 2; const minX CUP_WIDTH / 2; targetX Math.max(minX, Math.min(maxX, t.clientX)); }); // 每帧update里执行 function update(dt) { cupX (targetX - cupX) * CUP_LERP; }逻辑说明wx.onTouchMove 会以较高频率回调回调里只记录目标坐标不直接修改绘制坐标真正的位移发生在下一帧渲染前这样触摸频率和帧率解耦。Math.max 与 Math.min 把杯子限制在屏幕内防止杯子一半跑到屏幕外导致接水判定区域不可见。参数说明CUP_LERP 越大杯子追得越快但越容易表现出抖动0.25 是比较常见的起步值。手感偏“肉”就把值降到 0.15 以下偏“跟手”就提到 0.3 以上。想要更细腻可以把 LERP 换成带加速度的临界阻尼模型不过接水杯游戏里一阶滤波已经够用。3.3 一帧只结算一次接住、漏掉与连击的计数逻辑碰撞判定写完紧接着会踩到典型坑水滴经过判定带的那两三帧里checkCatch 每次都返回 true如果每次都为它加一分一滴水就变三分。解决方式很简单给每滴水加一个状态字段只有 falling 状态下判定成功才进入 caught其余帧的碰撞直接忽略const DROP_STATE { FALLING: 0, CAUGHT: 1, MISSED: 2 }; function updateDrop(drop, dt, cup) { if (drop.state ! DROP_STATE.FALLING) return; drop.y drop.vy * dt; if (checkCatch(drop, cup)) { drop.state DROP_STATE.CAUGHT; score 1; combo 1; return; } if (drop.y H drop.r) { drop.state DROP_STATE.MISSED; combo 0; life - 1; if (life 0) gameOver(); } }逻辑说明state 是水滴自身的运行时属性CAUGHT 和 MISSED 都算“已结算”后续渲染可以做飞溅粒子然后回收但不会再进碰撞分支。状态判断放在 updateDrop 最前面保证一帧内重复调用也不会重复计分。参数说明drop.vy 是下落速度单位是 px/s。写法上不要用“每帧固定移动 5px”微信小游戏里掉帧是常态帧率一变速度就跟着变用 dt 相乘才能保证低端机上速度稳定。苹果机屏幕逻辑宽度常见 375安卓常见 360-412下落速度建议按“屏幕高度的百分比每秒”来设比如 60%H/s 起随着关卡等级提高乘系数而不是写死 300px/s。下面这张手感参数表适合复制到项目注释里实测时对照调参数改动方向结果CUP_LERP 0.15 → 0.3调大更跟手但也更容易抖判定带宽度 82% → 95%调大更容易接住手感变宽下落速度 60%H/s → 80%H/s调大难度提升容错降低4. 微信小游戏适配与性能优化让“能接多少杯”在安卓机上也不掉帧4.1 画布尺寸、安全区与刘海屏适配参数表直接照抄适配问题的根源是小游戏全屏 Canvas没有浏览器 viewport也没有系统级安全距离处理。不处理刘海屏的话顶部得分区会被摄像头挖孔盖住底部杯底会被手势条遮住而且 iPhone 和 Android 的 inset 数值还不一样。配置项取值方式说明逻辑宽高wx.getWindowInfo()windowWidth/windowHeight物理像素逻辑宽高 × pixelRatio赋值给 canvas.width/height顶部安全距离safeArea.top刘海机下 UI 起始 Y底部安全距离windowHeight - safeArea.bottom防手势条遮挡杯底以下是我常用的一套初始化适配代码const s wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); const safeTop s.safeArea ? s.safeArea.top : 0; const safeBottom s.safeArea ? s.windowHeight - s.safeArea.bottom : 0; const H s.windowHeight; const CUP_LOWEST_Y H - safeBottom - 30;逻辑说明safeArea 是微信提供的可视安全区域 JSON包含 top、bottom 等坐标顶部 UI 的 y 从 safeTop 往下画杯子最低运动位置要留出 safeBottom 再加 30px 余量避免“杯子正好卡在手势条上沿”的视觉尴尬。app 内嵌 H5 页面通常可以由外层原生容器处理安全区小游戏里没有这层包装canvas 全屏就必须自己拿到 safeArea 再算。参数说明不同机型 safeArea.bottom 从 0 到几十像素不等把安全区写死成某个值会在 Android 刘海机与普通屏上失效所以一定要用 API 实时读不要参考某一台手机缓存参数。这个逻辑放初始化阶段执行一次即可不要放进主循环。4.2 水滴对象池把每帧创建销毁的开销干掉“能接多少杯”玩到后面屏幕上同时存在的水滴可能在 20 到 50 个之间。如果每个水滴都是对象字面量创建、接住后销毁小游戏的 JavaScript 引擎在 Android 中低端机上会频繁 GC导致帧率周期性抖动。常见做法是维护一个对象池const dropPool []; function createDrop(props) { const drop dropPool.pop() || {}; drop.x props.x; drop.y props.y; drop.r props.r; drop.vy props.vy; drop.state DROP_STATE.FALLING; return drop; } function recycleDrop(drop) { drop.state DROP_STATE.MISSED; dropPool.push(drop); }逻辑说明池里没有对象时pop 返回 undefined用|| {}兜底新建一个有对象时直接复用实例只重置与本次下落相关的属性。这样水滴只在开局阶段创建游戏中后期不再分配新内存。回收时把 state 改成 MISSED 而不是 CAUGHT是为了让同一套回收逻辑也能覆盖漏接的水滴。参数说明对象池不是越大越好。单局同时活跃的水滴数量假设上限是 60 个池容量定在 100 即可超过上限的新增水滴直接销毁不再入池避免池子占着内存不还。4.3 主循环性能“三不原则”与离屏Canvas第一不要在 update 里做任何查询式调用getSystemInfoSync、Storage 读取这些 IO 操作放初始化阶段做一次。第二不要在 render 里写超过 1000 次迭代的循环水滴数量用对象池控住上限之后这层压力基本消失。第三不要用 setInterval 驱动主循环必须用 requestAnimationFrame它会在屏幕刷新前回调setInterval 做不到帧同步掉帧时还会堆积回调。这里还有一个值得做的渲染优化。杯子如果每帧都用 fillRect 或 arc 重画路径计算会占不少 CPU 时间常见做法是画一次离屏 Canvas之后每帧 drawImage// 初始化阶段 const offCanvas wx.createOffscreenCanvas({ type: 2d, width: 160, height: 160 }); const offCtx offCanvas.getContext(2d); // 在offCtx上绘制杯身、杯口、阴影一次之后不再重绘 function render(ctx) { ctx.drawImage(offCanvas, cupX - CUP_WIDTH / 2, cupY - CUP_HEIGHT / 2); }逻辑说明wx.createOffscreenCanvas 创建的离屏 Canvas 不参与屏幕合成只在初始化时画一次杯子主循环里只做一次 drawImageGPU 合成效率高于 CPU 反复绘制圆弧路径。水滴数量少时可以继续用 fillRect/arc不需要额外离屏。参数说明离屏 canvas 的宽高不要做得太大160x160 足够画一个杯子如果之后要加光影特效建议单独建一个特效离屏 Canvas避免杯子和水滴混在同一张图上造成整块重绘。5. 上线前的真机验证与手感微调命中率面板和判定带滑块5.1 用FPS面板验证优化结果别靠“感觉流畅”优化是否有效真机上用肉眼不可靠可以在右上角放一个调试面板。实现很薄十几行代码let fps 60, frameTime 0, lastTs 0; function loop(ts) { const dt ts - lastTs; lastTs ts; frameTime frameTime * 0.9 dt * 0.1; // 低通平滑 fps Math.round(1000 / frameTime); if (DEBUG_SHOW_FPS) { ctx.fillStyle rgba(0,0,0,0.5); ctx.fillRect(0, 0, 80, 40); ctx.fillStyle #fff; ctx.fillText(FPS: fps, 10, 20); } render(ctx); requestAnimationFrame(loop); }逻辑说明frameTime 做指数平滑避免帧率数字在 50 到 60 之间乱跳至少连续观察 10 秒再下结论。如果安卓中低端机稳定在 50 以下优先检查 4.2 的对象池是否真的生效、杯子绘制是否走了离屏 Canvas。提审之前把 DEBUG_SHOW_FPS 置为 false这个开关留在代码里不影响审核。5.2 把判定带做成可拖动滑块手感参数随调随存手感最终要落到参数上。常见做法是把 cup.left/right 的偏移比值、CUP_LERP、下落速度系数存进一个参数对象并在开发模式下画一条可拖动的调节条let params wx.getStorageSync(cup_debug_params) || { catchRatio: 0.9, // 判定带宽度与杯身宽度比值 lerp: 0.25, speedH: 0.6 // 每秒下落高度占比 }; wx.setStorageSync(cup_debug_params, params); // 拖动回调里写入逻辑说明真机上画一条带手柄的横线把 catchRatio 数值映射到手柄 x 坐标touchmove 回调更新后立即写 Storage。下次启动自动读取上次调好的参数不需要每次重录。正式发布版本默认读取代码内置值不走 Storage防止玩家设备上残留脏配置。到这里一套“能接多少杯”H5 微信小游戏源码的工程化改造就闭环了。还剩两件上线前必须盯着的事一是游戏类目普遍要求提交计算机软件著作权登记证书等材料具体以微信后台当时的要求为准别等提审当天才发现缺材料二是不要在小游戏里做 H5 网页式的 OAuth 跳转授权常见误区是照搬“嵌入到微信内的 H5 页面如何获取授权”那套网页授权流程小游戏有自己的用户信息接口。另外如果后续加网络排行榜websocket 在 H5 页面能连、打包成真机连不上这类问题大概率是 mp 后台的 Socket 合法域名没配和代码逻辑无关。改动后先在开发者工具里跑通再到低端 Android 机上验证一整局FPS 面板和参数滑块这两个工具能省掉一半的手感调试时间。本文还有配套的精品资源点击获取