简介:面向微信小程序初学者与游戏开发爱好者,这套打地鼠游戏源码可作为入门练手项目,覆盖界面布局、图片素材调用、点击交互与游戏逻辑等常见环节。压缩包共27个文件,以23个PNG格式图片素材为主,配合2个HTML页面和1个说明文档,另含一个7Z压缩包,整体大小18.71MB;PNG素材用于背景、地鼠、按钮等视觉元素,HTML文件承载页面结构与交互脚本,目录清晰便于二次修改。已有392人学习/下载,适合需要快速完成小程序课程设计或互动娱乐demo的开发者。参考其随机出现、计时计分与音效/图片切换思路,可迁移到其他休闲小游戏场景;附带readme说明也能降低上手门槛,方便直接替换素材或调整难度参数。
1. 为什么打地鼠游戏要用 canvas 方案落地微信小程序
微信小程序的游戏化场景,第一反应可能是用 view/button 堆叠,但涉及高速落洞、连续敲击、多洞并发时,DOM 层的渲染瓶颈会很快暴露。打地鼠的核心是大量瞬时状态切换(洞出现、洞消失、敲击特效),每次切换都走 setData 会让页面反复进入差异计算流程,低端机上肉眼可见掉帧。canvas 绘图引擎因为直接走渲染层,避开 setData 的序列化开销,成为休闲小游戏在原生小程序里更稳的选择。
这套方案适合准备做毕业设计或作品集的小程序开发者,需要一整个有交互、有状态、有动效的完整项目;也适合做运营活动页、想嵌入轻量游戏化互动的工程师。正文从数据模型、绘制调度、命中检测到性能验证完整走一遍,不熟悉 canvas 也能跟着跑出一个能玩、能改的版本。标题里的 "dadishu-canvas" 正是一个把全部游戏画面交给 canvas 绘制的参考设计,我们把它拆开讲透。
2. 打地鼠游戏的数据模型与 canvas 渲染前的状态设计
2.1 从 UI 直绘到 canvas 绘制的思路转变
在传统小程序页面里,一个地鼠洞可以是<view>加背景,地鼠出现就是切换 display 属性。工具里很少需要你手动管理坐标,但 canvas 里没有洞、没有地鼠“组件”,只有一张画布上的像素。它要求你先维护一个数据模型,每次状态变化后主动重绘帧。这个思维转变是“微信小程序游戏开发”与普通页面开发的根本差异:前者是数据驱动的帧渲染,后者是状态驱动的组件更新。
常见做法是用一个二维数组定义地图,每个格子记录洞的坐标、出现状态、剩余显示时间、是否被击中。游戏主循环通过requestAnimationFrame驱动,每一帧检查时间戳,决定哪些地鼠该出现、该消失,然后按状态绘制。重点理解“数据驱动渲染”而不是“事件驱动渲染”,事件只负责改数据,画面永远跟着数据走。
const CELL_COLS = 3; // 3 列 const CELL_ROWS = 3; // 3 行 const CELL_SIZE = 100; // 每个格子的像素大小 function initGameMap() { const cellMap = []; for (let row = 0; row < CELL_ROWS; row++) { for (let col = 0; col < CELL_COLS; col++) { cellMap.push({ x: col * CELL_SIZE, y: row * CELL_SIZE, active: false, // 当前是否显示地鼠 appearTimestamp: 0, // 出现的时间戳,用于计算消失时机 duration: 800, // 单只地鼠停留时长(毫秒) hit: false // 是否已被敲击 }); } } return cellMap; }每个格子是对象而不是独立数组,坐标在初始化时一次性算好,避免每帧绘制时反复计算偏移。appearTimestamp是判断地鼠消失的唯一依据,不要用自增计数器替代,因为 setTimeout 在页面切后台时会挂起,时间戳才是真实时间的锚。
2.2 canvas 坐标系统与 dpr 换算
微信小程序 canvas 的坐标系原点在画布左上角,x 轴向右,y 轴向下,绘制任何元素都需要换算像素坐标。如果设计稿是 750 宽(小程序里常见的 rpx 基准),canvas 里不能直接吃 rpx,必须拿到实际画布宽度后换算缩放比例。这里有个关键选择:旧接口wx.createCanvasContext操作的是 canvas 组件的上下文,靠draw()提交渲染;新接口 Canvas 2D 直接操作节点上下文,配合requestAnimationFrame更接近浏览器习惯,性能也好一截。
const query = wx.createSelectorQuery(); query.select('#gameCanvas').fields({ node: true, size: true }).exec((res) => { if (!res || !res[0]) return; const canvas = res[0].node; const ctx = canvas.getContext('2d'); const dpr = wx.getSystemInfoSync().pixelRatio; canvas.width = res[0].width * dpr; canvas.height = res[0].height * dpr; ctx.scale(dpr, dpr); const scaleRatio = res[0].width / 750; });真机上 dpr 必须乘进画布宽高,否则高分屏绘制出来是模糊的。ctx.scale(dpr, dpr)之后,后续代码统一按逻辑像素(即 res[0].width)绘制,不需要在每个绘图命令里再手动乘以 dpr。scaleRatio用于把设计稿里的 rpx 数值换算为当前设备的逻辑像素。
2.3 游戏状态机的划分与帧循环
打地鼠不会只有“进行中”这一种状态。暂停、结束、等待开始、敲击成功后的短暂停顿,每帧都要先查状态再决定是否推进逻辑。用小型状态机远比一堆 if/else 标志位清晰:
| 状态 | 触发条件 | 绘制行为 | 定时行为 |
|---|---|---|---|
| READY | 页面 onLoad 完成,canvas 初始化 | 绘制静态开始画面 | 主循环运行但不生成地鼠 |
| PLAYING | 用户点击“开始”按钮 | 完整绘制地面、地鼠与得分 | 按概率生成地鼠 |
| PAUSED | 点击暂停或页面 onHide | 绘制蒙层 | 循环挂起,暂停计时 |
| OVER | 倒计时归零 | 绘制分数与重新开始按钮 | 循环终止 |
状态机的价值在于不需要在绘制代码里到处判断“是不是暂停”,一个currentState就可决定是否进入 update 流程,也为后续扩展“倒计时中间插屏”这类需求留下干净的口子。
const GameState = { READY: 0, PLAYING: 1, PAUSED: 2, OVER: 3 }; let currentState = GameState.READY; let lastFrameTime = 0; function frameLoop(ts) { const delta = ts - lastFrameTime; lastFrameTime = ts; if (currentState === GameState.PLAYING) { updateGame(delta); // 更新地鼠存活时间、生成概率 renderFrame(ts); // 全量重绘 } else if (currentState === GameState.PAUSED) { renderPauseLayer(); // 只绘制蒙层,不推进游戏逻辑 } canvas.requestAnimationFrame(frameLoop); } function startLoop() { lastFrameTime = 0; currentState = GameState.READY; canvas.requestAnimationFrame(frameLoop); }delta计算是这类循环的标准写法,但注意首次进入循环时lastFrameTime为 0,delta会是一个极大的异常值。初始化后先把lastFrameTime置为当前时间戳,或者用上方startLoop里的清零逻辑,让第一帧的 delta 自然归零,避免把游戏逻辑推到坏状态。
3. canvas 绘制流程拆解:从背景、地鼠到敲击动画
3.1 绘制优先级与场景分层
canvas 绘制覆盖顺序严格:先画远的,再画近的。背景、洞口、地鼠、特效这个顺序不能乱,否则地鼠会被洞口盖住,或者特效盖住计分板。分层的目的不只是视觉正确,也决定每帧重绘的成本——背景永远最便宜,粒子系统永远最贵。
实际绘制时,不追求主流游戏引擎里的脏矩形方案。小程序 canvas 在单页元素量有限时,全量重绘 60 次/秒整体可控,而脏矩形逻辑在 canvas 2d 接口上调试成本高,远不如全量绘制来得可读、可维护。
function renderFrame(timestamp) { // 1. 背景:整体矩形填充,性能上优于每次绘制图片 ctx.fillStyle = '#7ec850'; ctx.fillRect(0, 0, canvasWidth, canvasHeight); // 2. 洞口:每个格子一个椭圆洞 cellMap.forEach((cell) => { drawHole(cell.x, cell.y); }); // 3. 地鼠:只绘制 active 为 true 的格子 cellMap.forEach((cell) => { if (cell.active && !cell.hit) { drawMole(cell.x, cell.y, getAppearProgress(cell, timestamp)); } }); // 4. 敲击特效:命中后的粒子动画 hitParticles.forEach((p) => p.draw(ctx)); }| 绘制层级 | 对象 | 关键操作 | 说明 |
|---|---|---|---|
| 1 | 地面背景 | fillRect | 遮盖上一帧残留,确定视觉基调 |
| 2 | 洞口 | ellipse/arc | 决定地鼠的“窝”位置,先于地鼠存在 |
| 3 | 地鼠 | arc + 矩形 | 核心交互对象,必须盖住洞口上部 |
| 4 | 命中特效 | 粒子列表 | 反馈手感,延迟消隐 |
getAppearProgress返回 0 到 1 的值,控制地鼠从洞口冒出的半程动画。动画曲线建议用缓动函数,直接线性会导致视觉效果生硬,代码上只需要一行progress = 1 - Math.pow(1 - rawProgress, 3)就能拿到 easeOutCubic。这里不需要额外引入动画库,用数学表达式足够。
3.2 用基础图形绘制地鼠身体
地鼠最简洁且辨识度高的画法是圆头、耳朵、眼睛、牙齿,身体被洞口打断。纯 canvas 矢量绘制的好处在于不依赖外部图片资源,跳过了包体积管理和加载时序的问题。
function drawMole(cx, cy, progress) { const bodyR = 35; const headY = cy - 20 - (1 - progress) * 30; // 未完全露出时,头部越低 ctx.save(); // 身体:一个圆 ctx.fillStyle = '#8B5A2B'; ctx.beginPath(); ctx.arc(cx, headY, bodyR, 0, Math.PI * 2); ctx.fill(); // 眼睛:两个黑点 ctx.fillStyle = '#222222'; ctx.beginPath(); ctx.arc(cx - 12, headY - 8, 5, 0, Math.PI * 2); ctx.arc(cx + 12, headY - 8, 5, 0, Math.PI * 2); ctx.fill(); // 牙齿:白色矩形 ctx.fillStyle = '#ffffff'; ctx.fillRect(cx - 5, headY + 8, 10, 8); ctx.restore(); }headY的补间逻辑是动画的核心:progress 从 0 到 1,头部从下方 30 像素处抬升到目标位置,视觉上地鼠从洞里“冒头”。save和restore包裹每个独立绘制单元,确保坐标变换、透明度、描边设置不串到下一格。如果你将来替换成美术素材,需要注意图片绘制与矢量绘制在坐标系上完全一致,外部图片要额外处理资源的宽高比。
3.3 敲击命中检测的坐标换算
这是最容易被坑出 bug 的地方。屏幕点击坐标相对于页面,canvas 内绘制坐标基于格子,二者不换算就会出现“明明点中了却判定 miss”。
query.select('#gameCanvas').boundingClientRect((rect) => { canvas.onTouchStart((e) => { if (!rect) return; const touch = e.touches[0]; // 将页面坐标换算成 canvas 内部坐标 const localX = touch.clientX - rect.left; const localY = touch.clientY - rect.top; cellMap.forEach((cell) => { if (cell.hit || !cell.active) return; const dx = localX - (cell.x + CELL_SIZE / 2); const dy = localY - (cell.y + CELL_SIZE / 2); const hitRadius = 40; if (Math.sqrt(dx * dx + dy * dy) < hitRadius) { cell.hit = true; score += 10; spawnHitParticle(cell.x, cell.y); } }); }); });注意boundingClientRect拿到的坐标包含页面滚动偏移,clientX必须减掉 rect.left/top。如果 canvas 外层还有 padding 或 margin,换算要分别计入;最稳妥的是让 canvas 直接贴到页面边缘,从布局上消除偏移误差。判定半径要匹配视觉大小,圆与圆的碰撞模型在这一场景下成本最低,不需要引入rect.intersects这类几何库。
4. 手感调优:帧率、定时生成与声音反馈的平衡
4.1 地鼠生成概率的动态调节
一个让人想一直玩的打地鼠,难点和奖励感必须随时间爬坡。固定 5 秒生成一只的玩法,玩家摸清节奏后就会枯燥。常见做法是随剩余时间缩短生成间隔,或提高多只并发的概率。
function trySpawnMole(now) { const timeLeft = totalDuration - (now - gameStartTime); const progress = 1 - timeLeft / totalDuration; // 0 -> 1 const spawnInterval = lerp(1200, 300, progress); // 从 1200ms 缩短到 300ms const elapsed = now - lastSpawnTime; if (elapsed >= spawnInterval) { lastSpawnTime = now; const available = cellMap.filter(c => !c.active && !c.hit); if (available.length > 0) { const pick = available[Math.floor(Math.random() * available.length)]; pick.active = true; pick.appearTimestamp = now; pick.hit = false; } } } function lerp(start, end, percent) { return start + (end - start) * percent; }lerp线性插值在这里只做难度曲线,spawnInterval的两端值直接影响手感。调参时优先动 1200 和 300 这两个端点,不要直接改duration——duration 管的是单只地鼠停留时长,改小了玩家根本来不及敲,改大了又让游戏变“等”,体验上是两码事。
4.2 低端机的降级策略与帧率控制
canvas 游戏不是帧率越高越好。微信小程序的游戏循环 60 帧足够,强推 120 帧只会消耗电量。更现实的瓶颈是低端安卓机 Canvas 绘制复杂路径时的耗时,把地鼠拆成基础图形(圆、矩形、弧线),不画渐变色和杂毛质感,能明显缓解真机掉帧。
启动时通过wx.getSystemInfoSync()拿到设备型号或 benchmarkLevel,在低端机上关闭阴影特效、粒子减半、帧率上限设为 30。注意不要用Math.random()临时判断降级,而是初始化时一次性确定 qualityLevel,后续绘制分支只读它,避免每帧产生分支预测开销。
| 绘制项 | 标准模式 | 降级模式 |
|---|---|---|
| 地鼠阴影 | 绘制 | 不绘制 |
| 敲击粒子数 | 12 个 | 6 个 |
| 动画帧率上限 | 60 FPS | 30 FPS |
| 地鼠最大并发数 | 5 只 | 3 只 |
4.3 音频池与真机播放限制
敲击的爽感一半来自音效,但小程序单次播放音频有并发限制。wx.createInnerAudioContext()每个实例只能播一个音频,如果每次点击都 new 一个,达到上限后实例会静默失败。
实践中维护 3 个 InnerAudioContext 的实例池,轮流分配。每秒最多敲 3-4 次,池子放 3 个足够。
const audioPool = []; for (let i = 0; i < 3; i++) { const ctx = wx.createInnerAudioContext(); ctx.src = '/assets/hit.mp3'; ctx.onEnded(() => { ctx.stop(); }); audioPool.push(ctx); } let audioIndex = 0; function playHitSound() { const audio = audioPool[audioIndex]; audio.stop(); audio.play(); audioIndex = (audioIndex + 1) % audioPool.length; }每次播放前调用一次stop(),因为上一个若未结束就再次 play,小程序内部会处于残留状态,可能导致本次无声。这个坑在开发者工具里几乎碰不到,真机上却非常容易出现,属于必须提前处理的细节。
5. 从“能玩”到“能上线”:canvas 帧率实测与异常排查
页面里加一个实时帧率面板是判断性能最直接的手段。刚才做得增加条:面板不展示给用户,用wx.setEnableDebug开关环境变量控制渲染,约 30 行代码就能做完。帧率计算靠requestAnimationFrame打点统计:
let frameCount = 0; let lastFpsTime = 0; let currentFps = 0; function measureFps(ts) { frameCount++; if (ts - lastFpsTime >= 1000) { currentFps = frameCount; frameCount = 0; lastFpsTime = ts; console.log('当前帧率:', currentFps); if (currentFps < 30) { // 触发降级逻辑 applyQualityDegrade(); } } }开发者工具的模拟器帧率与真机差异极大,掉帧检测必须在真机上进行。开启微信开发者工具的“真机调试”的性能监控浮层,观察主线程的 Script 和 Render 两条曲线。Script 长任务超过 50ms,说明 updateGame 里有阻塞;Render 曲线抖动,说明绘制调用次数过多。二者定位路径不同:前者看循环里的数据操作,后者看 renderFrame 里的绘图原语数量。如果 Script 频繁超过 50ms,多半是地鼠生成时对 cellMap 做了 filter 全遍历,低端机上把 filter 改成维护一个 activeIndex 数组能显著降耗时。
另一个高频问题是 canvas 在页面 onHide 时没有自动停止循环。切后台再回前台,requestAnimationFrame虽然会暂停,但内部定时器和状态都已错乱,最常见表现是切回来后游戏时间耗尽或地鼠全部冻结。必须在 onHide 里挂起循环,在 onShow 重置lastFrameTime:
Page({ onHide() { this.currentState = GameState.PAUSED; }, onShow() { if (this.currentState === GameState.PAUSED) { this.currentState = GameState.PLAYING; this.lastFrameTime = 0; // 让下一帧 delta 归零,避免跳变 } } });最后,canvas 组件本身是原生组件,真机上会被置于 web-view、map 等原生组件之上,布局时不要把其他原生控件盖在 canvas 区域。若日后用 uni-app 做跨端版本,canvas 指令集与渲染时机也存在差异,社区里普遍做法是把绘图层独立成 mixin,适配层只做接口映射。这个项目作为参考设计已经是“启动-绘制-交互-结算”闭环,替换美术资源、音效素材,或者扩展成多关卡,核心状态机与帧循环不需要动。
本文还有配套的精品资源,点击获取