简介:这是一份面向微信小程序开发学习者与毕业设计/期末大作业场景的完整小游戏源码,完整实现经典“别踩白块”玩法。项目基于微信小程序原生框架构建,逻辑、样式与配置分离,页面交互、音效反馈和计分逻辑均已跑通,适合作为课程设计或毕设演示项目直接运行,也便于二次扩展。压缩包共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,稳妥写法是同时读取screenWidth:const 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 像素的偏差,这两个细节也适合写进毕设报告的“性能优化”或“交互改进”章节作为技术亮点。
本文还有配套的精品资源,点击获取