简介:这是一套功能完备、已上线验证的微信小程序源码,专为开发者快速构建看图找茬类休闲游戏提供开箱即用的解决方案。资源包含2510关卡题库、好友实时对战(支持8人同场)、能量与金币双系统、段位升级机制及完整的广告接入逻辑(横幅/插屏/激励视频),适配流量主商业化部署,适合前端工程师、小程序初学者及独立开发者学习交互逻辑、游戏状态管理与广告集成实践。压缩包共277个文件,主体为145张PNG游戏素材、18个JS业务逻辑脚本、15个HTML页面模板、14个JSON配置文件及10个WXSS样式文件,另有少量PHP后端接口、SQL数据库结构与Token认证文件,整体体积仅4.48MB,结构清晰、模块解耦。目前已有97人下载学习,可直接运行调试,快速掌握小游戏开发中闯关控制、倒计时延展、提示消耗、段位展示等核心交互实现细节。
1. 看图找茬类微信小程序不是“套模板就能上线”,它考验的是前端交互精度、图片资源管理能力和小程序生命周期控制能力
最近在多个技术社群和外包接单平台看到“看图找茬微信小程序源码带过关前端素材.zip”被高频转发,下载量动辄过万。但实际打开发现:多数所谓“源码”只是把wx:for循环渲染 9 张图、用>// pages/game/game.js Page({ data: { canvasWidth: 0, canvasHeight: 0, isCanvasReady: false }, onLoad() { const systemInfo = wx.getSystemInfoSync(); this.setData({ canvasWidth: systemInfo.windowWidth, canvasHeight: systemInfo.windowHeight * 0.7 // 占屏 70% }); // 使用 Promise.all 确保双图加载完成 Promise.all([ this.loadImage('/assets/images/scene1_origin.jpg'), this.loadImage('/assets/images/scene1_diff.jpg') ]).then(([originImg, diffImg]) => { this.originImage = originImg; this.diffImage = diffImg; this.initCanvas(); this.drawImages(); }); }, loadImage(src) { return new Promise((resolve, reject) => { const image = wx.createImage(); image.onload = () => resolve(image); image.onerror = () => reject(new Error(`Failed to load ${src}`)); image.src = src; }); }, initCanvas() { const query = wx.createSelectorQuery(); query.select('#gameCanvas').fields({ node: true, size: true }).exec((res) => { 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); this.ctx = ctx; this.setData({ isCanvasReady: true }); }); }, drawImages() { if (!this.ctx || !this.originImage || !this.diffImage) return; const { canvasWidth, canvasHeight } = this.data; const scale = Math.min( canvasWidth / this.originImage.width, canvasHeight / this.originImage.height ); // 绘制原图(左半区) this.ctx.drawImage( this.originImage, 0, 0, this.originImage.width, this.originImage.height, 0, 0, this.originImage.width * scale, this.originImage.height * scale ); // 绘制差异图(右半区) this.ctx.drawImage( this.diffImage, 0, 0, this.diffImage.width, this.diffImage.height, canvasWidth / 2, 0, this.diffImage.width * scale, this.diffImage.height * scale ); } });
提示:
wx.createImage()是微信小程序 2.24.0+ 版本新增 API,替代已废弃的wx.downloadFile+wx.getImageInfo组合。旧版本需用wx.downloadFile下载后调用wx.getImageInfo获取宽高,再创建canvas,流程多 2 步且易出错。
2.3 将找茬点坐标从设计稿(750rpx)映射到 Canvas 物理像素
设计师给的 PSD 标注中,找茬点 A 坐标为{x: 120rpx, y: 86rpx},但 Canvas 坐标系以左上角为原点,且单位是物理像素。需做三步转换:
- 将 rpx 转为 px:
px = rpx * (windowWidth / 750) - 按 Canvas 缩放系数修正:
canvasX = px * dpr - 按双图并排布局偏移右半区:若点在右图,则
canvasX += canvasWidth / 2
// utils/coordinate.js export function rpxToCanvas(rpxX, rpxY, canvasWidth, canvasHeight, isRightImage = false) { const systemInfo = wx.getSystemInfoSync(); const pxX = rpxX * (systemInfo.windowWidth / 750); const pxY = rpxY * (systemInfo.windowHeight / 750); const dpr = systemInfo.pixelRatio; let canvasX = pxX * dpr; let canvasY = pxY * dpr; if (isRightImage) { canvasX += (canvasWidth / 2) * dpr; // 右图起始 X 坐标 } return { x: canvasX, y: canvasY }; } // 使用示例:加载关卡 1 的找茬点 const level1Points = [ { id: 'p1', x: 120, y: 86, isRight: false }, // 左图点 { id: 'p2', x: 420, y: 150, isRight: true } // 右图点 ].map(p => ({ ...p, ...rpxToCanvas(p.x, p.y, 375, 500, p.isRight) // canvasWidth=375px(半屏) }));2.3.1 关键参数表:不同机型下 Canvas 缩放系数与找茬点容差阈值
| 机型 | pixelRatio | Canvas 宽度(px) | 推荐找茬点判定半径(px) |
|---|---|---|---|
| iPhone SE (2nd) | 2 | 750 | 12 |
| iPhone 13 Pro | 3 | 1170 | 18 |
| Huawei P50 | 2.75 | 1072.5 | 16 |
| Xiaomi Mi 12 | 2.75 | 1072.5 | 16 |
注意:判定半径不能简单设为固定值。实测表明,当
pixelRatio=3时,用户手指触控中心点与 Canvas 像素坐标的平均偏差为 ±15px;pixelRatio=2时为 ±10px。因此需动态设置hitRadius = Math.round(10 * pixelRatio)。
3. 构建可复用的状态机管理游戏流程,避免setData频繁触发导致卡顿
3.1 为什么不用data存储游戏状态?
看图找茬典型流程包含:加载 → 计时开始 → 用户点击 → 判定正确/错误 → 播放音效 → 更新 UI → 进入下一关。若全部状态存于this.data,每点击一次就调用this.setData({ clickedPoints: [...], score: 10 }),在低端 Android 机上会导致setData调用堆积,UI 响应延迟超 300ms。更优方案是用内存对象管理核心状态,仅在必要节点(如关卡结束、分数更新)批量setData。
3.2 定义状态机 Schema 与流转规则
状态机必须覆盖 5 种核心状态及 7 类事件:
| 当前状态 | 触发事件 | 下一状态 | 数据变更动作 |
|---|---|---|---|
loading | imagesLoaded | ready | 初始化gameState.points、gameState.timer |
ready | startGame | playing | 启动setInterval计时器 |
playing | pointClicked | playing | 更新gameState.clickedPoints,播放音效 |
playing | allFound | success | 停止计时器,计算得分 |
success | nextLevel | loading | 清空当前关卡数据,加载新图 |
// pages/game/game.js Page({ data: { gameState: 'loading', score: 0, timeUsed: 0, foundCount: 0 }, // 内存状态机实例 gameState: { points: [], // 所有找茬点 {id, x, y, isFound} clickedPoints: [], // 已点击点 ID 数组 timer: null, level: 1, startTime: 0 }, // 状态流转方法 transition(state, event, payload = {}) { const transitions = { loading: { imagesLoaded: 'ready' }, ready: { startGame: 'playing' }, playing: { pointClicked: 'playing', allFound: 'success' }, success: { nextLevel: 'loading' } }; const nextState = transitions[state]?.[event]; if (!nextState) return; switch (nextState) { case 'ready': this.gameState.points = this.loadLevelPoints(this.gameState.level); break; case 'playing': this.gameState.startTime = Date.now(); this.gameState.timer = setInterval(() => { this.setData({ timeUsed: Math.floor((Date.now() - this.gameState.startTime) / 1000) }); }, 1000); break; case 'success': clearInterval(this.gameState.timer); const baseScore = 1000; const timePenalty = Math.min(300, this.data.timeUsed * 10); // 每秒扣 10 分,最多扣 300 const bonus = this.gameState.points.length === this.gameState.clickedPoints.length ? 200 : 0; this.setData({ score: baseScore - timePenalty + bonus, foundCount: this.gameState.points.length }); break; } this.setData({ gameState: nextState }); }, // Canvas 点击事件处理器(防抖 + 状态校验) onCanvasClick(e) { if (this.data.gameState !== 'playing') return; const touch = e.touches[0]; const { x, y } = touch; const hitRadius = Math.round(10 * wx.getSystemInfoSync().pixelRatio); // 遍历所有未找到的点,计算距离 for (let point of this.gameState.points) { if (point.isFound) continue; const distance = Math.sqrt(Math.pow(x - point.x, 2) + Math.pow(y - point.y, 2)); if (distance <= hitRadius) { // 防抖:100ms 内重复点击同一位置视为无效 if (this.lastClickTime && Date.now() - this.lastClickTime < 100 && this.lastClickId === point.id) { return; } this.lastClickTime = Date.now(); this.lastClickId = point.id; point.isFound = true; this.gameState.clickedPoints.push(point.id); // 播放音效(使用 createInnerAudioContext 避免全局音频冲突) const audio = wx.createInnerAudioContext(); audio.src = '/assets/sounds/correct.mp3'; audio.play(); // 检查是否全部找到 const allFound = this.gameState.points.every(p => p.isFound); if (allFound) { this.transition('playing', 'allFound'); } return; } } } });3.2.1setData优化技巧:合并多次调用为单次
微信小程序setData是异步操作,频繁调用会排队。以下写法效率低下:
// ❌ 错误示范:3 次 setData this.setData({ score: 100 }); this.setData({ timeUsed: 32 }); this.setData({ foundCount: 5 });应改为:
// ✅ 正确:1 次 setData this.setData({ score: 100, timeUsed: 32, foundCount: 5 });4. 解决微信小程序图片资源加载慢、内存溢出、H5 兼容性三大痛点
4.1 图片预加载策略:用wx.preloadWebview提前加载下关卡资源
用户通关后立即跳转下一关,若此时才开始下载图片,会出现 1–2 秒白屏。利用小程序preloadWebviewAPI(需基础库 2.27.0+)在当前关卡进行中时,后台预加载下一关图片:
// pages/game/game.js loadNextLevel(level) { // 预加载下一关图片资源 if (level < MAX_LEVELS) { const nextAssets = [ `/assets/images/scene${level + 1}_origin.jpg`, `/assets/images/scene${level + 1}_diff.jpg` ]; // 使用 wx.preloadWebview 加载静态资源(非页面) nextAssets.forEach(src => { wx.preloadWebview({ url: src, success: () => console.log(`Preloaded ${src}`), fail: err => console.warn(`Preload failed: ${src}`, err) }); }); } }注意:
wx.preloadWebview实际是将资源加入本地缓存队列,不保证立即下载完成。需配合wx.getFileSystemManager().accessSync()检查文件是否存在,不存在时再走wx.downloadFile回退逻辑。
4.2 内存泄漏防护:Canvas 图片释放与定时器清理
未清理的 Canvas 图像引用和setInterval是小程序内存泄漏主因。必须在onHide和onUnload中执行清理:
onHide() { // 清理 Canvas 图像引用 if (this.ctx) { this.ctx.clearRect(0, 0, 375, 500); // 清空画布 } // 清理计时器 if (this.gameState.timer) { clearInterval(this.gameState.timer); this.gameState.timer = null; } }, onUnload() { // 彻底释放图像对象 this.originImage?.destroy && this.originImage.destroy(); this.diffImage?.destroy && this.diffImage.destroy(); this.originImage = null; this.diffImage = null; }4.3 H5 端兼容:用uni-app重构时保留 Canvas 逻辑
若需同时发布微信小程序和 H5 版,直接使用uni-app的canvas组件即可复用大部分逻辑。关键差异点:
| 微信小程序 | uni-app H5 | 说明 |
|---|---|---|
wx.createCanvasContext('id') | uni.createCanvasContext('id', this) | H5 需传入 Vue 实例 |
image.onload | image.addEventListener('load', handler) | H5 用标准事件监听 |
wx.getSystemInfoSync() | uni.getSystemInfoSync() | API 名称一致,返回结构相同 |
// uni-app pages/game/game.vue export default { methods: { initCanvas() { const query = uni.createSelectorQuery(); query.select('#gameCanvas').fields({ node: true, size: true }).exec(res => { const canvas = res[0].node; const ctx = uni.createCanvasContext('gameCanvas', this); const dpr = uni.getSystemInfoSync().pixelRatio; canvas.width = res[0].width * dpr; canvas.height = res[0].height * dpr; ctx.scale(dpr, dpr); this.ctx = ctx; }); } } }5. 修改刚进入的加载页面:从onLaunch到onShow的全流程控制技巧
5.1 微信小程序启动生命周期中,哪个时机最适合显示自定义加载页?
很多人误以为onLaunch是最佳入口,但onLaunch仅在冷启动时触发,热启动(从后台切回)时不会执行。真正可靠的入口是onShow—— 它在冷/热启动、前台切回时均触发,且能获取scene参数判断来源(如扫码、群聊分享、桌面图标)。
// app.js App({ onShow(options) { // options.scene 值示例:1007(群聊)、1047(公众号菜单)、1038(微信聊天顶部) this.showLoadingPage(options.scene); }, showLoadingPage(scene) { // 根据 scene 类型显示不同加载文案 const tips = { 1007: '正在加载群聊专属关卡...', 1047: '公众号推送内容加载中', 1038: '聊天窗口跳转,准备就绪' }; wx.showLoading({ title: tips[scene] || '游戏加载中', mask: true }); // 模拟资源加载(实际替换为图片预加载逻辑) setTimeout(() => { wx.hideLoading(); // 导航到游戏页,携带 scene 参数用于关卡匹配 wx.navigateTo { url: `/pages/game/game?scene=${scene}` }; }, 800); } });5.2 加载页动画优化:用 CSSanimation替代 JS 定时器
避免在showLoading中用setInterval控制旋转动画,改用纯 CSS:
/* app.wxss */ .loading-spinner { width: 40rpx; height: 40rpx; border: 4rpx solid #e0e0e0; border-top-color: #007AFF; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }<!-- app.wxml --> <view class="loading-container" wx:if="{{isLoading}}"> <view class="loading-spinner"></view> <text class="loading-text">{{loadingText}}</text> </view>5.3 关键参数调试表:不同启动场景下的options字段含义
options.scene | 启动场景 | options.query是否可用 | 典型用途 |
|---|---|---|---|
1007 | 群聊分享 | ✅ | 读取query.group_id加载群专属关卡 |
1047 | 公众号菜单 | ✅ | 读取query.from_mp_id匹配公众号定制皮肤 |
1038 | 微信聊天顶部 | ✅ | 读取query.msg_id追踪用户行为路径 |
1001 | 扫码 | ✅ | 读取query.code解析二维码参数 |
1089 | 桌面图标 | ❌ | 无 query,走默认关卡 |
提示:
options.query在onShow中始终存在,但值可能为空对象{}。务必用Object.keys(options.query).length > 0判断是否有有效参数,而非options.query !== undefined。
5.4 实战验证:用wx.getLaunchOptionsSync()捕获冷启动参数
onShow无法区分冷启动和热启动,但wx.getLaunchOptionsSync()可在onLaunch中获取首次启动参数,并存入wx.setStorageSync供后续使用:
// app.js App({ onLaunch() { const launchOpts = wx.getLaunchOptionsSync(); wx.setStorageSync('launchOptions', launchOpts); // 冷启动时预加载首关资源 if (launchOpts.scene) { this.preloadFirstLevel(launchOpts.scene); } }, onShow(options) { const launchOpts = wx.getStorageSync('launchOptions') || {}; // 合并冷启动参数与当前 onShow 参数 const mergedOptions = { ...launchOpts, ...options }; this.showLoadingPage(mergedOptions.scene); } });本文还有配套的精品资源,点击获取