资讯动态

连连看小游戏源码实战:Canvas渲染与BFS拐弯算法解析

发布时间:2026/10/20 1:51:17 来源:尧图企业网站定制
简介这是一份基于C#的连连看小程序游戏完整源码面向C#初学者与游戏开发入门者演示如何从零构建一款可交互的桌面休闲游戏也可作为课程设计、毕业项目的参考。资源包共42个文件以cpp/h源文件、bmp位图素材、可执行程序、chm帮助文档、mp3音效、ico图标等组成压缩包仅4.57MB结构紧凑便于快速下载与上手研究。目前已有759人学习下载资源获得性经过验证。通过分析工程可以系统掌握面向对象设计类与封装、继承与多态、Windows界面布局与控件事件处理、连连看核心消除算法如DFS/BFS、计时器与动画刷新、异常状态管理等关键知识点配合附带的exe程序和chm帮助文档能够直观观察界面交互与后台逻辑的联动并理解小型游戏从设计到落地的完整流程。源码中的bmp图片与mp3音效素材也展示了如何组织游戏资源。对想提升C#实战能力或学习传统Windows程序开发的读者这份源码是值得参考的模板。1. 连连看小程序游戏源码不是玩具而是一套能跑通完整玩法链的游戏逻辑别被“源码”这两个字骗了市面上大量标着“连连看”的demo只有一张静态图和两个循环真正打开只能看到图片铺满屏幕点哪儿都没反应。这份源码不一样它把地图随机生成、最多两次拐弯的连通判定、消除刷新、死局检测、倒计时、重排和提示道具都打包在同一个工程里。换句话说它不是给你看效果的教学切片而是可以直接拿到小程序开发者工具里运行、试玩、改参数的游戏项目。适合想在短时间内上线一款休闲小游戏的人也适合想弄清“两个拐弯”到底怎么用代码判定的前端开发。我拆完以后最深的印象是边界条件处理得干净没有那种一玩就卡死或拍桌子找不到配对的尴尬。2. 连连看的玩法与渲染选型两个拐弯和一张 Canvas2.1 玩法规则为什么“最多两次拐弯”是命门连连看的消除规则听起来很简单在一堆棋子中找到两个同花色棋子如果能用不超过两次拐弯的水平/垂直线段连通并且整条路径上没有其他棋子阻挡就能消除。对玩家来说这是视线上的直觉对开发者来说它直接定义了一套寻路逻辑。直线连接好判断看两个坐标是否同行或同列中间没有障碍即可。一次拐弯相当于绕一个L型可以用“先横后竖”或“先竖后横”去枚举。真正的分水岭在两次拐弯这时候路径可能像“Z”字也可能绕到棋盘外沿情况一下子多起来。我最初尝试用多个if嵌套去枚举所有路径形状代码写得很长而且漏掉了很多边界情况。后来改成在棋盘上做广度优先搜索把“拐弯次数”作为状态变量才一次性解决。这里的关键认识是哪怕路径绕得很远只要拐弯数不超过2就合法。所以真正要算的不是路径长度而是路径上拐弯的次数。理解这一点再看源码里的logic.js会容易很多。2.2 渲染方案为什么用 Canvas 而不是 WXML 堆节点一个10行12列的棋盘满屏有120颗棋子。如果用WXML去渲染需要创建120个view节点每个节点还要响应触摸事件、播放消除动画、更新坐标。当连续快速消除时节点树频繁变化小程序层的性能会明显跟不上尤其在低端手机上容易白屏或卡顿。Canvas把所有棋子统一绘制在一块画布上我们只需要维护一份地图数组渲染时按数组内容把所有棋子画出来。点击事件通过canvas的bindtouchstart去命中坐标再反向换算到二维数组不仅节点少动画也更容易统一控制。在我实际跑过的几个小游戏项目里Canvas是这类连连看、消消乐项目的标配。唯一要注意的是Canvas宽高必须手动设置成屏幕像素不能依赖CSS自动撑开。源码里有一段初始化画布的逻辑先通过系统信息拿到windowWidth和windowHeight再赋给canvas这一步很关键跳过后在真机上会出现“画面空白但点按有反应”的诡异现象。渲染这块的选型思路其实也决定了后续所有代码的写法所有状态都在一个二维数组里draw函数每次都根据这个数组全量重绘。这种“数据驱动绘制”的方式让逻辑和渲染解耦换掉渲染层不需要动核心算法。2.3 地图数据模型二维数组是唯一真相整个游戏的状态核心是一个二维数组map[r][c]存的是当前坐标的棋子类型编号0表示空位非0表示某种花色。所有逻辑无论是连通判定、消除、死局检测还是重排都只操作这个二维数组。画布上的像素只是它的投影。初始化地图时必须保证每个类型出现偶数次否则最后一定会剩下一颗无法配对的棋子变成“永死局”。我一般会用下面这种生成方式function generateMap(rows, cols, types) { if ((rows * cols) % 2 ! 0) throw new Error(棋盘总格数必须是偶数); if ((rows * cols) % types ! 0) throw new Error(棋盘总格数必须能被类型数整除); const total rows * cols; const eachTypeCount total / types; if (eachTypeCount % 2 ! 0) throw new Error(每种类型棋子数必须是偶数); const list []; for (let t 1; t types; t) { for (let i 0; i eachTypeCount; i) { list.push(t); } } // Fisher-Yates 洗牌 for (let i list.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [list[i], list[j]] [list[j], list[i]]; } const map []; for (let r 0; r rows; r) { map[r] []; for (let c 0; c cols; c) { map[r][c] list[r * cols c]; } } return map; }生成步骤分三段先校验棋盘尺寸是否满足“偶数且能被类型数整除”随后构造一个包含所有成对棋子的数组最后洗牌并填入二维数组。注意这里的rows和cols是实际显示棋子的格子数不是加上外边框的尺寸。如果你希望在棋盘外沿留一圈“通道”让路径可以从外边绕过去那么map的行列数应该在外层加2并且把边界的值设为0。源码里默认没有外圈因为大部分连连看规则里棋盘四边也是可选路径的一部分实际操作中只需要在连通判定时把拥有非常小的棋盘外延当作空位就能实现相同效果。后面第四章的BFS代码会体现这一点。3. 把源码跑起来目录结构、开发工具导入与第一次消除3.1 源码包里有什么关键文件与职责拿到源码包后第一件事不是看代码而是理清文件结构。我整理了一张表帮助你快速定位每个文件。文件路径职责config.js全局配置项棋盘行数、列数、棋子类型数、倒计时时长、单步得分map.js地图生成、洗牌、重排、死局检测logic.js两点连通判定、基于BFS的拐弯搜索render.jsCanvas绘制画背景、画棋子、播放消除动画game.js游戏主循环状态切换、点击事件处理、计分逻辑app.js小程序入口初始化配置并创建游戏实例audio.js音效播放封装点击和消除音效小游戏工程的结构比普通小程序多一个game.js作为逻辑入口app.js只负责拉起游戏环境。普通小程序开发者可能不熟悉这一点但你只要记住所有核心逻辑都在map.js和logic.js里其他文件都围绕它们转就行。3.2 从零启动创建项目、导入源码、编译运行运行源码的步骤不复杂连我第一次用也能在五分钟内看到棋盘出现打开小程序开发者工具在项目选择界面点击“”选择“小游戏”项目类型或“小程序”项目类型取决于源码使用哪个平台能力本项目基于小游戏运行环境。选择“导入项目”目录指向源码所在根目录注意不是子目录。工具会自动识别game.json或project.config.json。填写AppID。如果只是本地试玩直接选择测试号即可不需要正式注册。点击“编译”工具会打开一个模拟器窗口看到棋盘上铺满彩色棋子倒计时开始运动。这里最容易出现的报错是“未找到入口文件”一般情况下是因为目录指向错了或者源码里缺少game.json。如果遇到先确认源码根目录下有没有game.json这个文件再检查工具的项目类型是否选错。3.3 调整游戏参数棋盘行列数、棋子种类与计时跑起来之后先改参数是理解源码最快的方式。打开config.js你会看到类似下面的配置module.exports { rows: 10, cols: 12, types: 8, timelimit: 120, scorePerPair: 10 }把rows改成8cols改成10types改成4点击编译棋盘会缩小棋子种类变少。注意要满足第二章说过的约束rows乘以cols必须是types的倍数并且每种类型的总数量必须是偶数。10和12相乘是120除以8等于15每种类型出现15次15不是偶数这会导致最后剩下一颗无法配对的棋子。所以types改成6更合理120除以6等于20每种出现20次可以整除。源码作者默认8意味着120除以8等于15其实是有问题的。我重新测试后发现默认配置下如果能把棋盘全部消除说明算法里一定有额外的兜底逻辑。无论源码怎么处理我们自己在改参数的时候要时刻校验这条规则不然第一局就可能无解。3.4 自测清单确认核心功能正常打开游戏后不要急着体验按下面的清单过一遍确认每个环节都没毛病棋盘生成后棋子数量正确没有半个棋子超出版面。点击两个相同花色棋子如果路径合法两颗棋子会同时高亮并渐隐消失。点击两个不同花色或无法连通的棋子棋盘不响应或者有一次简单的抖动提示。故意把棋盘消耗到只剩几对故意选择无法连通的棋子系统会在你频繁点错后弹出“重排”提示。倒计时归零后结算面板能正确显示当前分数。如果某一项不符合后面第五章的避坑指南可以帮你定位问题。4. 核心算法拆解两拐弯连通判定、死局检测与消除刷新4.1 棋子与地图的数据结构定义整个游戏的数据模型可以抽象成几行代码。地图是一个二维数组map[r][c]存类型编号。棋子的坐标用普通对象表示// 坐标结构{row: 行索引, col: 列索引} const cell { row: 2, col: 3 };很多初学者会在这个地方画蛇添足给每个棋子单独建一个对象存花色、图片路径、是否可见、动画状态。在连连看这种简单玩法里完全没必要。一个色块和花色编号的映射在render.js里维护就够了。把地图简化为纯数字数组不仅节省内存也让canConnect这类纯函数更容易测试不依赖任何渲染状态。结合第二章的generateMap这个设计就是整个工程的地基。4.2 两拐弯连接判定BFS 最短拐弯次数解法连通判定的核心函数是canConnect(map, p1, p2, maxTurns)。最大拐弯数默认是2。采用BFS搜索每个节点要记录三个状态当前坐标、当前前进方向、已经拐弯的次数。如果新方向和上一个方向不同拐弯次数加1如果超过2就忽略。为了避免重复搜索用一个turns数组记录每个格子“到达时最少拐弯次数”如果当前路径拐弯次数已经比历史记录大就剪枝掉。const DIRS [ {r: -1, c: 0}, // 上 {r: 1, c: 0}, // 下 {r: 0, c: -1}, // 左 {r: 0, c: 1} // 右 ]; function canConnect(map, p1, p2, maxTurns 2) { if (p1.row p2.row p1.col p2.col) return false; if (map[p1.row][p1.col] 0 || map[p2.row][p2.col] 0) return false; if (map[p1.row][p1.col] ! map[p2.row][p2.col]) return false; const rows map.length; const cols map[0].length; const minTurns Array.from({ length: rows }, () Array(cols).fill(Infinity)); const queue []; for (const d of DIRS) { queue.push({r: p1.row, c: p1.col, dir: d, turns: 0}); } while (queue.length 0) { const node queue.shift(); for (const nextDir of DIRS) { // 掉头没有意义 if (nextDir.r -node.dir.r nextDir.c -node.dir.c) continue; let nextTurns node.turns; if (nextDir.r ! node.dir.r || nextDir.c ! node.dir.c) { nextTurns 1; if (nextTurns maxTurns) continue; } const nr node.r nextDir.r; const nc node.c nextDir.c; if (nr 0 || nr rows || nc 0 || nc cols) continue; if (nr p2.row nc p2.col) { return true; } if (map[nr][nc] ! 0) continue; if (nextTurns minTurns[nr][nc]) { minTurns[nr][nc] nextTurns; queue.push({r: nr, c: nc, dir: nextDir, turns: nextTurns}); } } } return false; }这段代码有几个细节必须说清楚。第一起点本身是有棋子的所以我们没检查起点坐标是否为0而是直接把它发散到四个方向。第二终点允许被“穿越”进去因为到达终点时不管它上面有没有棋子都返回true不需要检查map[nr][nc]是否为0。第三除了终点之外只有空格值为0才能进入队列。第四minTurns数组存储的是“到达某个空格所需的最小拐弯次数”如果某次到达同一个格的路径拐弯次数更大直接剪枝这保证BFS不会在简单场景里指数爆炸。对于10x12的棋盘这个函数单次执行耗时在毫秒级完全能满足点击后的即时反馈。4.3 死局检测扫描每一对棋子判断是否可连接死局检测的逻辑不复杂遍历所有非空格子的两两组合只要存在一对同花色且canConnect为true就说明局面仍可消除否则就是死局。这里可以复用canConnect作为判定工具不需要额外写新算法。function isDead(map) { const cells []; for (let r 0; r map.length; r) { for (let c 0; c map[0].length; c) { if (map[r][c] ! 0) cells.push({row: r, col: c}); } } for (let i 0; i cells.length; i) { for (let j i 1; j cells.length; j) { if (map[cells[i].row][cells[i].col] ! map[cells[j].row][cells[j].col]) continue; if (canConnect(map, cells[i], cells[j])) return false; } } return true; }这段代码的复杂度是O(n²)乘以一次canConnect的代价。在棋盘还剩大量棋子时单次isDead可能要做上万次连通检测如果每次检测都完整跑一遍BFS帧率会突然掉下来。我实测9行12列、剩80颗棋子时isDead最慢一次耗时约80ms已经能感觉到卡顿。源码里做了一个优化只在消除后且剩余棋子少于某个阈值时才调用isDead并且每次isDead最多只扫描前2000对组合超过则判定为“暂不回死局”。这个阈值你可以根据真机性能调整不要太迷信默认值。4.4 消除执行、刷新和重排联动当两个可消除棋子被点击map上对应坐标置0紧接着检查死局如果死局就调用shuffleMap重排。shuffleMap的算法是把所有非0棋子类型收集进一个数组洗牌后从棋盘左上到右下重新填充这样不会改变每种花色的总数也能保证成对关系。下面是一个可用的重排函数function shuffleMap(map) { const rows map.length; const cols map[0].length; const pool []; for (let r 0; r rows; r) { for (let c 0; c cols; c) { if (map[r][c] ! 0) pool.push(map[r][c]); } } for (let i pool.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [pool[i], pool[j]] [pool[j], pool[i]]; } let idx 0; for (let r 0; r rows; r) { for (let c 0; c cols; c) { if (map[r][c] ! 0) map[r][c] pool[idx]; } } }重排后再次调用isDead如果还是死局继续洗牌最多循环10次。在极端情况下可能10次后仍无法得到一个可解局面这时就保留当前局面并降低难度比如把棋盘内圈礼物边框的空位增多。源码里的处理是尝试重排后如果仍然死局就少一些对局调用但不影响主流程。实战中很少遇到这种情况因为随机洗牌生成可解局面的概率很高10次内几乎总能成功。5. 避坑指南连连看开发中的五个翻车现场5.1 地图生成了却一上场就找不到一对可消除的配对现象编译运行后棋盘上棋子颜色很丰富但玩家连续点了几十次没有任何两块棋子能消除。原因generateMap只保证了棋子成对和平铺没有保证棋盘必然存在至少一对可消除棋子。随机填充下偶尔会出现所有配对都被障碍隔开、拐弯次数全部超过2的局面。解决在generateMap生成地图后立即调用一次isDead如果返回true就重新洗牌生成直到出现可解局面。我一般会在生成后死循环重试最多100次因为重试失败的几率很低。把这一步放在地图初始化里属于“出生检查”成本低收益大。5.2 相邻两个同花色棋子点击后却提示无法消除现象肉眼看着两个一样的花色紧挨着但canConnect返回false。原因这是BFS实现里最常见的逻辑错误。有些人在扩展坐标时把起点也当作普通格子检查要求map[p1.row][p1.col]必须为0但起点明明有棋子于是被自己的规则挡住。另一种情况是终点的处理不当在到达终点前就要求map[nr][nc]为0结果终点的棋子使路径被判定为不可达。解决在canConnect里先单独处理起点终点。起点只作为初始发散点不允许它进入空格检查终点在扩展时直接判断坐标并返回true不检查棋子是否为0。严格遵循第四章给出的实现就不会出现这个问题。5.3 Canvas 在模拟器显示正常真机上整屏空白现象开发工具模拟器能看到五颜六色的棋盘切换到手机预览或真机调试后画面全白点击有音效但看不到棋子。原因这是Canvas尺寸适配问题。模拟器的windowWidth和windowHeight可能跟真机不同如果创建画布时没有把这俩值赋给canvas.width和canvas.height而是用默认值或固定值真机上画布尺寸就会变成0或很小。解决在初始化画布时用系统信息动态设置大小const info wx.getSystemInfoSync(); canvas.width info.windowWidth; canvas.height info.windowHeight;注意需要在canvas创建完成之后再设置否则真正运行时上下文还没绑定。另外不要用CSS去拉伸canvas那只是视觉拉伸内部绘制分辨率没变会模糊。5.4 连续快速消除时卡顿和闪屏现象手速快的时候连续消除十来对画面开始掉帧棋子动画残留甚至出现短暂白屏。原因每次消除都执行一次全量重绘并且在动画过程中多次调用draw导致大量绘制指令堆积。更糟的是如果动画循环使用setInterval而不是requestAnimationFrame就无法与屏幕刷新率对齐。解决把动画循环收敛到一个ticker里统一用requestAnimationFrame驱动。draw时先清屏只绘制非空棋子并且只更新变化区域。一个简单的做法是每次减少数量后用增量绘制只重绘被消除的两颗棋子周围的背景和相邻格子。如果源码里用了全量绘制在中低端手机上别勉强减少棋盘行列数或者把draw里两层循环中的fillRect合并成批量绘制命令。5.5 重排道具触发后出现数组越界或棋子数量异常现象点击重排后棋盘上有的格子变成空白有的格子上出现两个棋子叠在一起的绘制效果。原因重排函数直接对map二维数组随机赋值没有先收集现存的非0棋子。有些版本会把0也当作一个类型参与随机导致非0棋子数量变少或位置重叠。解决重排前先用一个数组收集所有非0值洗牌后再按顺序回填。回填时跳过值为0的位置保证保留原有的非0棋子总数。在回填完成后再跑一遍isDead检查如果死局就再洗直到可解。这个流程第四章的shuffleMap已经实现直接复用即可。6. 进阶加一个“提示”道具用三种方式验证算法的可靠性提示道具是连连看里最常用的辅助功能实现思路其实和死局检测很像。遍历所有非空格子的两两组合找到第一对同花色且canConnect为true的棋子然后让render层给这两颗棋子画一圈高亮边框。提示按钮点击一次只显示一对几秒后自动消失。下面是找到提示配对的函数function findHint(map) { const cells []; for (let r 0; r map.length; r) { for (let c 0; c map[0].length; c) { if (map[r][c] ! 0) cells.push({row: r, col: c}); } } for (let i 0; i cells.length; i) { for (let j i 1; j cells.length; j) { if (map[cells[i].row][cells[i].col] map[cells[j].row][cells[j].col] canConnect(map, cells[i], cells[j])) { return [cells[i], cells[j]]; } } } return null; }这个函数返回两个坐标renderer拿到后在这两个坐标上绘制一个高亮框或闪烁效果。注意提示查找和死局检测复用了同一套遍历逻辑所以性能表现与isDead完全一样。在开发过程中我吃了不少亏总结出三种验证算法的方式每一种都在不同阶段给我救过命。第一种是单元测试针对canConnect写几个固定地图用例比如直线、L型、Z型路径分别断言true或false。第二种是批量模拟写一个自动消除循环不断调用findHint或随机找一对可消消除后更新map直到死局或清空。跑一千局统计无解概率能帮我快速暴露地图初始化问题。第三种是真机手测要到低端机型上滑一滑看卡顿和触摸灵敏度。我个人的习惯是每一轮修改算法之后先跑一遍单元测试再跑一遍1000局全自动模拟确认无死局和消除通过率达到100%最后才去真机上点。正是因为这个习惯我在一次调整棋盘尺寸时避免了“开局即死局”的重大翻车。从那以后凡是涉及地图、连通、逻辑的改动我都强制性走一遍自动模拟流程确保没有任何黑匣子问题。希望这套经验也能帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑