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

资讯详情

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

微信小游戏别踩白块开发实战:canvas渲染与状态机设计

微信小游戏别踩白块开发实战:canvas渲染与状态机设计 简介这是一份面向微信小程序开发学习者与毕业设计/期末大作业场景的完整小游戏源码完整实现经典“别踩白块”玩法。项目基于微信小程序原生框架构建逻辑、样式与配置分离页面交互、音效反馈和计分逻辑均已跑通适合作为课程设计或毕设演示项目直接运行也便于二次扩展。压缩包共36个文件核心代码包含8个JavaScript逻辑文件、7套WXML页面结构、7套WXSS样式及7个JSON配置另附项目说明文档、授权文件与二维码预览图整体仅142KB轻量简洁。已有574人学习下载适合小程序入门者和需要快速完成前端作业的高校学生。深入阅读源码可理解事件绑定、数据同步、页面渲染等开发要点还可参考如何将经典小游戏移植到移动端并完成从素材准备到页面发布的完整流程。1. 别踩白块微信小游戏一份能直接跑的毕设代码怎么读期末或毕设交一个微信小程序小游戏别踩白块几乎是出现频率最高的选题规则一句话能说清演示效果又足够直观评审老师一眼就能看懂玩法和完成度。但真正动手之后才会发现这个项目的难度不在“判断踩没踩到白块”而在触摸响应速度与方块下落速度的配合——延迟 50ms 就能让玩家觉得手感发黏加速逻辑没控制好又会在三秒内崩成满屏白块。整套代码的核心其实是一个状态机加上一套矩形碰撞判定写完之后还能顺手讲出性能优化的思路作为课程设计或毕业设计的展示材料也说得过去。这篇内容从渲染选型讲起把方块生成、触摸判定、下落加速、计分结束这一条链路拆开再给出一套可以直接放进微信开发者工具里跑通的最小实现最后落到手感调校的参数和两个优化技巧。无论是想拿现成源码包改造还是从空项目手写一遍下面的内容都按可复现的标准来组织。2. 别踩白块小游戏的“快”从哪来渲染选型与状态机2.1 canvas 渲染为什么比 WXML 节点方案更合适别踩白块这类小游戏对渲染路径的要求很明确高频更新、无滚动、全屏点击。用 WXML 加 CSS 动画也能做view 节点配合 transform 实现方块下落动画结束再移除节点但方块数量一多就会出现两个问题。第一节点创建与销毁开销每下落一块就创建一个 view屏幕上同时存在十几块时微信小程序的视图层与逻辑层之间的通信频率会明显上升在低端安卓机上容易出现掉帧。第二触摸命中误差CSS 动画的位移是视图层计算的触摸事件回传的坐标与动画中的实际位置存在一帧左右的偏差玩家快速连点时会觉得点到了却没反应。canvas 的方案把渲染和判定都收拢到逻辑侧。方块的坐标就是内存里的 JS 数组每一帧按时间计算出位置drawImage 画上去点击事件拿到坐标后直接与数组里的矩形做相交判断没有视图层的中间环节。渲染频率完全由自己控制60fps 或 30fps 都能稳定运行这才是这类游戏该走的路线。2.2 四个必要状态WAIT、PLAY、OVER、PAUSE拿到源码包后先看它的状态管理。别踩白块的逻辑不算复杂但没有状态机的话触摸事件会在游戏结束后的瞬间继续命中判定或者方块在下落过程中重新生成产生“死了还能得分”的怪现象。一套常见做法是四个常量const STATE { WAIT: 0, // 等待开始显示点击任意位置开始 PLAY: 1, // 对局中方块下落、触摸判定有效 OVER: 2, // 游戏结束显示得分与重新开始 PAUSE: 3 // 暂停冻结下落计时 };为什么 WAIT 和 OVER 要分开而不是合并成一个“非游戏状态”因为进入 OVER 后需要播放结束音效或展示得分面板而 WAIT 阶段要监听首次触摸作为开始信号两者对触摸事件的处理逻辑完全不同。PAUSE 单独占一个状态是因为微信小程序切后台时 onHide 会被触发此时需要冻结下落计时而不是重置整个游戏。状态机的作用在触摸事件回调里体现得最明显。比如在touchstart里先判断当前状态不是 PLAY 就直接返回这样能挡住绝大多数误触在 PLAY 状态里再区分“这是第一次点击WAIT 转 PLAY”还是“过程中的点击”。WAIT 转 PLAY 时还需要同时记录起始时间戳否则第一块方块会在游戏开始前就下落导致玩家看到的是空中掉下来的第一块。2.3 源码包落地前的三个检查文件从 zip 压缩包把小程序源码导入微信开发者工具时如果打不开或者打开后白屏先别急着看 game.js按顺序检查三个文件。首先是project.config.json这个文件里有 appid 配置。如果是拿别人的源码改appid 还是原作者的测试号导入时开发者工具会提示“appid 无效”。课程设计一般用自己的测试号把appid字段改成touristappid或自己的小程序 AppID 就行。注意compileType字段如果源码是“小游戏”项目这个值应该是game写成了miniprogram会直接编译失败。第二个是game.json小游戏项目的配置文件。里面有一个deviceOrientation字段别踩白块需要竖屏值必须是portrait。改成landscape后 canvas 的宽高计算全部会乱。另外showStatusBar如果设置为true顶部状态栏会占掉一块屏幕高度canvas 获取的宽高需要二次修正。第三个是game.js里对wx.createCanvas()的调用方式。标准写法是wx.createCanvas()创建主屏 canvas然后canvas.width window.innerWidth。如果源码里写死了固定数值比如 375 x 667在刘海屏或平板尺寸上就会出现方块区域偏移。建议在初始化时统一读取window.innerWidth和window.innerHeight后续所有坐标计算都基于这两个值做相对布局。3. 用 canvas 把别踩白块的核心判定写对3.1 方块生成四列布局与“非白即黑”规则别踩白块的棋盘本质是一个 4 列网格黑色方块永远只会出现在每一行中的一列剩余三列是白色玩家必须点击黑色方块才能继续。一旦点到白色区域游戏立即结束。定义游戏区域时要考虑两个变量列数COLS 4和方块高度BLOCK_H。方块高度常见做法是屏幕高度除以 4这样一屏正好显示 4 行方块后有来者、前有去者视觉上最舒服。在代码里的写法通常是这样const COLS 4; const ROWS 4; const screenW window.innerWidth; const screenH window.innerHeight; const BLOCK_W Math.floor(screenW / COLS); const BLOCK_H Math.floor(screenH / ROWS); let blocks []; function generateRow(y) { const blackIdx Math.floor(Math.random() * COLS); for (let i 0; i COLS; i) { blocks.push({ x: i * BLOCK_W, y: y, w: BLOCK_W, h: BLOCK_H, color: i blackIdx ? #000000 : #ffffff, alive: i blackIdx }); } return y - BLOCK_H; // 返回下一行要放置的位置 }generateRow每次生成一行4 个矩形对象其中alive为 true 的那个是黑色目标块。y从屏幕底部开始向上递减返回值给调用方形成不断向上排列的效果。这里把行生成的返回逻辑保留下来的原因是渲染时只需要遍历 blocks 数组按各自 y 坐标画矩形就行不需要额外维护行对象。有个细节值得提方块高度用Math.floor而不是Math.round是因为浮点数取整不一致会导致相邻两行之间出现一条像素缝隙屏幕刷新后这条缝会闪烁像白线一样破坏视觉完整性。总共 4 块拼满屏幕宽度如果BLOCK_W计算有误差右侧会露出背景色这个问题在真机上比开发者工具里更明显。3.2 触摸判定坐标与矩形的相交测试canvas 上的触摸事件通过wx.onTouchStart注册与普通小程序页面的bindtap不同这个 API 拿到的对象上带有touches数组其中每一项包含clientX和clientY。这组坐标是屏幕物理像素坐标而 canvas 绘制时用的坐标体系也是窗口坐标所以可以直接拿来做判定不需要换算这一点是小游戏项目比小程序页面方便的地方。判定逻辑并不复杂从 blocks 数组的末尾倒序遍历找到第一个 y 坐标小于触摸点 y 且 y 加 h 大于触摸点 y 的矩形。因为方块是从下往上生成的数组末尾的元素是屏幕上最新的、最靠近底部的一行玩家触碰到的一定是这块区域。function checkTap(touch) { const touchY touch.clientY; const touchX touch.clientX; // 倒序遍历优先匹配最新生成的行 for (let i blocks.length - 1; i 0; i--) { const b blocks[i]; if (touchY b.y touchY b.y b.h) { if (touchX b.x touchX b.x b.w) { if (b.alive) { b.alive false; b.color #7f7f7f; // 被点中的黑块变灰表示已经踩过 addScore(); return true; } else { // 点到了白色块 gameOver(); return false; } } } } return false; }这里有一个容易踩坑的点点中白色块时游戏结束的时机。很多初版代码会在touchX b.x touchX b.x b.w命中的那一刻直接判定失败但如果多个方块在 y 方向上有重叠比如下落速度不均匀时可能出现倒序遍历先命中的白色块不一定是最上面的那个。严格做法是先找到所有命中行中 y 最小也就是最靠上的那一行再做颜色判断。不过这里由于一行只有 4 个块且都填满屏幕宽度同一行的白块一定在同一条水平线上所以简单倒序已经够用。判定命中后立即把alive置为 false 还有一个作用防止同一块被连续点击多次而重复计分。3.3 下落逻辑与加速曲线方块下落用的是每帧固定位移的方式。在主循环requestAnimationFrame里根据当前分数动态计算下落速度function getSpeed(score) { // 基础速度 2.0每得 5 分增加 0.1上限 5.0 return Math.min(2.0 Math.floor(score / 5) * 0.1, 5.0); } function update() { if (state ! STATE.PLAY) return; const speed getSpeed(score); blocks.forEach(b b.y - speed); // 移除已经滚出屏幕顶部的方块 blocks blocks.filter(b b.y b.h 0); // 当最上面的方块露出完整一行后生成新行 const topY Math.min(...blocks.map(b b.y)); if (topY 0) { const nextY topY - BLOCK_H; generateRow(nextY); } // 如果新的行生成后仍然无法满足屏幕填充继续补行 while (blocks.length COLS * 8) { const minY Math.min(...blocks.map(b b.y)); generateRow(minY - BLOCK_H); } render(); requestAnimationFrame(update); }这段代码里有两个容易出错的地方。第一blocks.filter(b b.y b.h 0)会把已经完全滚动出屏幕上方的行垃圾回收但要注意判定条件写成b.y b.h 0而不是b.y 0因为高速下落时一帧的位移可能超过一个方块的高度部分方块还没完全滚出屏幕就被错误回收画面顶部会出现一整行闪断。第二补行逻辑用while循环而不是 if是因为加速后一帧可能下落多个像素只补一行会跟不上下落速度不过topY 0这个条件本身限制了最多只补一行所以 while 在这里是防御性写法。加速曲线的选择直接影响游戏难度。线性加速每 5 分加 0.1在 30 分左右就会让人感到明显压力对课设演示来说刚好如果评审阶段只需要展示玩法可以把增速改成每 8 分加 0.05节奏更平缓。分数与速度的对应关系建议集中放在一个函数里方便评审时现场调参。4. 源码包落地从 zip 到真机的参数调整4.1 解压后优先检查的最小文件集下载到一份“别踩白块微信小程序源码.zip”后解压导入开发者工具前先对照目录结构检查以下内容。小游戏项目通常包括 game.js、game.json、project.config.json 三个必备文件以及 images、audio 等资源目录。如果解压后缺少 game.json工具会直接报“找不到 game.json”错误这说明压缩包本身不是完整的小游戏源码而是某个小程序的片段需要进一步补全配置。资源路径是一个高频错误源。源码里如果引用了images/block.png但压缩包内该目录名是asset或img运行时会报“文件不存在”这种错误在开发者工具的 Console 面板能看到明确的路径输出。常见的处理是把图片资源统一放到images目录并在代码里全部改成相对路径images/xxx.png避免因为路径大小写或目录层次不一致导致的加载失败。4.2 一套可直接运行的最小核心代码下面给出一套去掉音效与分数动画后的最小可运行版本结构精炼到 80 行左右方便已经有一份源码包但想理解原理的读者对照阅读。完整源码包的代码量通常在 500 行以上多出来的部分主要在对局结束的弹窗、最高分本地存储和音效播放上核心逻辑与下面的代码是一致的。// game.js const STATE { WAIT: 0, PLAY: 1, OVER: 2 }; let state STATE.WAIT; let score 0; let blocks []; let canvas, ctx; const COLS 4; const screenW window.innerWidth; const screenH window.innerHeight; const BLOCK_W Math.floor(screenW / COLS); const BLOCK_H Math.floor(screenH / 4); function generateRow(y) { const blackIdx Math.floor(Math.random() * COLS); for (let i 0; i COLS; i) { blocks.push({ x: i * BLOCK_W, y: y, w: BLOCK_W, h: BLOCK_H, alive: i blackIdx }); } return y - BLOCK_H; } function init() { canvas wx.createCanvas(); ctx canvas.getContext(2d); blocks []; score 0; let y screenH; for (let i 0; i 4; i) { y generateRow(y); } state STATE.WAIT; requestAnimationFrame(loop); } function getSpeed() { return Math.min(2 Math.floor(score / 5) * 0.15, 5.5); } function render() { ctx.clearRect(0, 0, screenW, screenH); ctx.fillStyle #ffffff; ctx.fillRect(0, 0, screenW, screenH); for (const b of blocks) { ctx.fillStyle b.alive ? #000000 : (b.color || #ffffff); ctx.fillRect(b.x, b.y, b.w, b.h); ctx.strokeStyle #e0e0e0; ctx.strokeRect(b.x, b.y, b.w, b.h); } } function loop() { if (state STATE.PLAY) { const speed getSpeed(); for (const b of blocks) b.y - speed; blocks blocks.filter(b b.y b.h 0); const topY blocks.reduce((m, b) Math.min(m, b.y), screenH); if (topY 0) generateRow(topY - BLOCK_H); } render(); requestAnimationFrame(loop); } wx.onTouchStart(e { const t e.touches[0]; if (state STATE.WAIT) { state STATE.PLAY; return; } if (state ! STATE.PLAY) return; for (let i blocks.length - 1; i 0; i--) { const b blocks[i]; if (t.clientY b.y t.clientY b.y b.h) { if (t.clientX b.x t.clientX b.x b.w) { if (b.alive) { b.alive false; b.color #a0a0a0; score; console.log(score:, score); } else { state STATE.OVER; console.log(game over, score:, score); } break; } } } }); init();这段代码把渲染、更新、状态管理糅合在四个函数里。render()先画白色背景再遍历方块绘制矩形用strokeRect加边框线以便区分相邻方块。loop()是每帧执行的入口PLAY 状态下更新方块位置并补齐新行。wx.onTouchStart中的逻辑与第 3 章的判定一致只是去掉了计分动画。实际运行时你会发现 WAIT 状态下点击任意位置开始游戏第一帧可能就会丢失一次下落。这是因为 touchstart 回调里把状态切到 PLAY 后loop 已经在这一帧里执行完了更新逻辑相当于丢失了一帧的时间差。这个问题在真机上几乎感知不到但如果在意可以把 WAIT 切 PLAY 的语句放在requestAnimationFrame的回调里再切换保证第一帧正常下落。4.3 需要手工调的 6 个参数对照表从源码包拿到的代码不一定按上文的逻辑组织但参数名和位置大同小异。重点是找到下面这些数值理解它们的含义再决定改不改。参数常见位置建议值影响BLOCK_H初始化区域屏幕高 / 4值越大单块面积越大越容易点击getSpeed 基础速度加速函数2.0 ~ 2.5初始下落速度2.5 以上新手容易手忙脚乱每 5 分的增量加速函数0.1 ~ 0.15增速过快会在 20 分左右变得不可玩最大速度加速函数5.0 ~ 6.0超过 6.0 后一帧位移超过方块高度判定会出错屏幕行数初始化时的生成行数4超过 5 行会同时出现多个黑色目标块补行触发条件loop 中的 topY 判断topY 0改为topY BLOCK_H会导致方块间距过大画面看起来稀疏4.4 编译报错的三类典型问题第一类是window.innerWidth is not defined。小游戏环境支持window.innerWidth但部分安卓真机的 webview 版本返回为 0稳妥写法是同时读取screenWidthconst screenW wx.getWindowInfo ? wx.getWindowInfo().screenWidth : window.innerWidth;强制兼容。第二类是wx.createCanvas is not a function。这个错误几乎都是因为编译类型错误即 project.config.json 中compileType不是game。把配置改成compileType: game后重新编译即可。第三类是触摸无反应。检查wx.onTouchStart是否被放在了 init 之前执行。如果源码里把事件绑定写在文件最顶部而 init 在下方逻辑上没问题反过来则会因为 canvas 尚未创建导致事件丢失。正确的做法是把事件绑定放到文件末尾的全部代码之前执行或者放在 init 内部。5. 提升手感与性能的两个调优技巧5.1 用离屏 canvas 预渲染方块每次渲染中ctx.fillRect和ctx.strokeRect各执行一次一帧内至少 16 次绘制调用。性能开销虽不至于让中端手机掉帧但连续快速下落时绘制调用次数会翻倍在低端机上仍有掉帧风险。常见的优化方案是预先画好一张黑块小图渲染时直接drawImage绘制// 在 init 阶段创建离屏 canvas const offCanvas wx.createCanvas(); offCanvas.width BLOCK_W; offCanvas.height BLOCK_H; const offCtx offCanvas.getContext(2d); offCtx.fillStyle #000000; offCtx.fillRect(0, 0, BLOCK_W, BLOCK_H); // 渲染时优先绘制预渲染块 if (b.alive) { ctx.drawImage(offCanvas, b.x, b.y, BLOCK_W, BLOCK_H); } else { ctx.fillStyle #f5f5f5; ctx.fillRect(b.x, b.y, b.w, b.h); }这样黑块的渲染从 fillRect 变成了 drawImage单次绘制性能更高同时把不变的内容固化成位图。白块因为数量多且需要靠 fillRect 覆盖背景色不适合做同样优化。5.2 命中判定加 3 像素余量物理真机上触摸坐标与视觉反馈之间存在极小的偏移尤其是在快速下滑时玩家手指边缘先触到屏幕clientY 记录的坐标会比玩家“以为”的位置略高一点。因此在命中判定时给黑色块的高方向加上余量if (t.clientY b.y - 3 t.clientY b.y b.h 3) { ... }余量只加在 y 方向不加在 x 方向。因为方块两侧紧挨着白块x 方向加余量会把相邻的白块误判成黑块导致点的明明是黑块边缘却被判失败。3 像素是经验值再大就接近白块边缘的视觉边界容易产生“我还没点到就死了”的反感。把这两个技巧用上后一局游戏里触摸的响应速度和稳定性会有明显体感提升。预先画好黑块位图再在判定时宽容 3 像素的偏差这两个细节也适合写进毕设报告的“性能优化”或“交互改进”章节作为技术亮点。本文还有配套的精品资源点击获取
返回列表