简介:这款源码以经典麻将连连看玩法为载体,采用HTML5与JavaScript实现,免安装、跨平台,可在主流浏览器中直接运行;面向H5游戏开发初学者、前端工程师及休闲游戏爱好者,既能用于理解开发流程,也适合作为练习项目或二次修改的起点。游戏规则简单直观,玩家需匹配并消除相同麻将牌,源码覆盖页面布局、牌组生成、匹配消除逻辑、计分机制及动画反馈等核心模块。压缩包共112个文件,其中108个PNG图片资源承担了全部视觉呈现,从麻将牌面、背景图、开始按钮到胜利界面一应俱全;1个HTML文件搭建页面骨架,1个JavaScript文件封装核心玩法,另有1个MP3背景音乐和1个TXT说明文档;整包仅1.27MB,结构紧凑,便于直接运行和替换素材。已有112人学习下载,通过研读源码可理解事件监听、DOM操作、游戏状态管理和音画资源调用的常见实现方式,并可作为扩展关卡、调整规则或增加特效的基础。
1. 麻将连连看不是运气游戏:拆开这份H5源码能学到什么
这份H5小游戏源码 麻将连连看.zip解压后只有十来张 PNG、一个 MP3 和两个脚本文件,却能完整跑起一局带有主菜单、暂停、胜利界面的 H5 麻将连连看。很多人以为连连看就是"找两张一样的点掉",实际上参数到位才好玩:牌组数量、棋盘行列、折角上限、洗牌算法共同决定一关能不能解,而 H5 小游戏源码的价值正在于把这些逻辑用纯前端方式直接摊开。适合刚接触 H5 游戏开发、想理解事件驱动与路径搜索的读者,也适合手头有uniapp或微信小游戏需求、想找一个轻量交互模板的人参考。接下来的拆解会按资源加载、牌组生成、连通检测、状态机、渲染优化一路推进,所有代码都能直接放到本地试跑。
2. 从index.html和ga.js看H5小游戏源码的启动流程与资源组织
拿到一个压缩包式的 H5 小游戏源码,第一件事不是双击 index.html,而是按资源类型建立一张"谁负责什么"的对照表。这份麻将连连看的文件不多,但结构很典型,足够说明一个前端小项目的启动顺序。
2.1 十个文件里谁在负责什么
下表是按我拆包后的理解给每个文件定的角色。注意ga.js这个名字很容易被误认为 Google Analytics 统计脚本,但在这个项目里它承担的是游戏主逻辑,这种命名属于历史遗留习惯,二次开发时第一件事建议重命名。
| 文件 | 类型 | 职责 |
|---|---|---|
| index.html | HTML | 页面骨架、CSS 内联、脚本加载入口 |
| ga.js | JavaScript | 游戏主循环、交互逻辑、渲染控制 |
| htmlMahjong.mp3 | 音频 | 消除或点击音效 |
| majong_BG.png | 图片 | 游戏内主背景 |
| MAIN_MENU_AND_LOADING_BG.png | 图片 | 主菜单与加载页共用背景 |
| YOU_WIN_BG.png | 图片 | 胜利结果页背景 |
| icon.png | 图片 | 页面图标与按钮装饰 |
| game_in_BG.png | 图片 | 对局进行中的背景图层 |
| PAUSE_BG.png | 图片 | 暂停界面背景 |
| BTN_START.png | 图片 | 开始按钮的常态与按下态素材 |
从文件命名可以反推出流程:加载页显示MAIN_MENU_AND_LOADING_BG,点击BTN_START后切入game_in_BG对应的棋盘,暂停时覆盖PAUSE_BG,消除动作触发htmlMahjong.mp3音效,通关后切到YOU_WIN_BG。这种"背景图按状态切换"的做法在轻量级 H5 小游戏里非常常见,因为它不需要写复杂的层级管理器,直接替换整个 canvas 背景或 DOM 背景即可。
2.2 用静态服务器启动而不是直接双击
直接双击 index.html 在某些浏览器里会出现音频无法自动播放、Canvas 跨域纹理加载失败等问题。我一般会起一个本地静态服务:
cd H5小游戏源码\ 麻将连连看 python3 -m http.server 8080然后在浏览器访问http://localhost:8080。http.server是 Python 自带模块,不需要额外装依赖;如果你本机只装了 Node,也可以用npx serve .达到同样效果。端口 8080 可以换成任意未占用端口,关键是让页面通过http://协议加载,而不是file://协议,这样才能规避音频播放策略和图片 CORS 限制。
这里有一个容易被忽略的参数:python3与python在 Windows 上可能指向不同版本,建议先执行python3 --version确认。静态服务启动后,控制台如果出现 404,优先检查文件名是majong_BG.png还是实际解压后的大小写,MAIN_MENU_AND_LOADING_BG.png这种长文件名很容易在手动改名时被截断。
2.3 index.html 里的骨架与加载顺序
这类源码一般没有构建步骤,直接靠 script 标签顺序控制加载。典型骨架如下:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>麻将连连看</title> <style> html, body { margin: 0; height: 100%; background: #000; touch-action: none; } canvas { display: block; margin: 0 auto; } </style> </head> <body> <canvas id="gameCanvas"></canvas> <script src="ga.js"></script> </body> </html>代码里的touch-action: none是移动端 H5 的关键参数,它告诉浏览器不要拦截触摸手势,否则在手机上点击牌面时可能会触发页面滚动或双指缩放。canvas没有在 HTML 里写死尺寸,我一般会在ga.js里用window.innerWidth/window.innerHeight动态设置,这样能适配不同分辨率。脚本放在 body 末尾,保证 DOM 节点已经存在,不需要再写DOMContentLoaded监听。
2.4 ga.js 命名背后的代码组织习惯
ga.js这种单文件承载全部逻辑的写法,在小游戏源码里并不少见。它通常包含四个区块:全局配置、资源加载、游戏循环、事件绑定。全局配置要重点看开头的常量区,例如ROWS、COLS、TILE_WIDTH、TIME_LIMIT,这些参数直接决定难度。代码组织上有一个习惯:把所有document.getElementById或ctx缓存到全局变量,而不是在每次绘制时重复查找 DOM,后文第五章的状态机也会沿用这个思路。
3. 二维数组洗牌与奇偶校验:牌组生成的正确姿势
麻将连连看的牌组不是美术素材,而是一组可计算的数字集合。核心问题只有一个:如何把 N 对牌随机放进一个二维棋盘,并且这个棋盘理论上可解。很多 H5 小游戏源码抄来就能跑,但换个行列数就死局,问题就出在牌组生成阶段。
3.1 为什么牌组必须成对并且总数能被 2 整除
最基本的约束是:一张牌必须且只能被消除一次,所以每张麻将必须有且只有一个孪生兄弟。棋盘总格数必须为偶数,牌型数量必须整除总对数,否则会出现最后一对牌永远配不上的情况。常见棋盘通常是10 x 14或8 x 12,四周还要留一圈空位作为通路,真正放牌的格子是(rows - 2) * (cols - 2)。
这就带来一个隐蔽的坑:很多源码只检查了rows * cols是否为偶数,却忘了实际可放置区域是去除边缘后的区域。比如9 x 11总格数是奇数,直接按 99 张分配就会多一张。正确做法是在生成牌组前先计算可放置区域:
const ROWS = 12; const COLS = 14; const TILE_TYPE_COUNT = 18; // 实际可放牌的内层区域 const innerRows = ROWS - 2; const innerCols = COLS - 2; const totalTiles = innerRows * innerCols; if (totalTiles % 2 !== 0) { throw new Error('内层棋盘格数必须为偶数'); } const pairs = totalTiles / 2; const deck = []; for (let i = 0; i < pairs; i++) { deck.push(i % TILE_TYPE_COUNT); deck.push(i % TILE_TYPE_COUNT); }TILE_TYPE_COUNT表示麻将牌的种类数,这里用 18 只是示例,实际资源里的牌面图需要匹配这个数量。i % TILE_TYPE_COUNT会让所有牌型均匀循环,保证每种牌的出现次数接近相等。如果你把TILE_TYPE_COUNT调大但牌面图片不够,渲染时会出现空白图形,这是排错时首先要想到的。
3.2 用 Fisher-Yates 洗牌打乱一维数组
生成有序数组后要洗牌。最常见、统计上均匀的做法是 Fisher-Yates 洗牌:
function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } const shuffledDeck = shuffle(deck);这里的核心参数是i和j。j的取值范围必须包含i,否则最后一张牌永远不会参与交换,会导致末尾元素固定,测试时你会觉得某些牌总出现在同一位置。Math.random()无种子,每次刷新都会得到不同布局;如果你希望复现某个 bug,可以临时用固定种子的伪随机数函数替换它,但正式版本不必这么做。
3.3 把一维数组折叠成二维棋盘并做奇偶修正
洗牌后的一维数组长度等于内层格数,接下来需要按行折叠成二维数组。我一般还会做一次"奇偶修正",这里的奇偶不是指数组长度,而是保证棋盘初始状态就满足"可解性"的常见经验值——每层外圈留空、内部牌面成对。折叠逻辑如下:
function createBoard(shuffled, rows, cols) { const board = []; for (let r = 0; r < rows; r++) { board[r] = []; for (let c = 0; c < cols; c++) { // 外圈留空,内层取牌 if (r === 0 || r === rows - 1 || c === 0 || c === cols - 1) { board[r][c] = 0; } else { const idx = (r - 1) * (cols - 2) + (c - 1); board[r][c] = shuffled[idx] + 1; // 用0表示空,牌值从1开始 } } } return board; } const gameBoard = createBoard(shuffledDeck, ROWS, COLS);board[r][c] = 0表示空格,这是所有后续连线算法的基础。把内部牌的 ID 统一加 1,是为了避免牌值 0 与空格混淆。如果你在调试时发现两张牌本应相同但一直不能消除,先检查这里是不是忘了偏移。
常见的棋盘尺寸与牌型数匹配参考如下:
| 内层尺寸 | 总牌数 | 推荐牌型数 | 说明 |
|---|---|---|---|
| 8 x 10 | 80 | 20 | 适合入门关卡 |
| 10 x 12 | 120 | 24 | 中等难度 |
| 12 x 14 | 168 | 28 | 高阶分布在二维棋盘里 |
| 14 x 16 | 224 | 28 | 牌面密度高,连线路径会变长 |
推荐牌型数只是让每种牌出现 4 到 6 次的可玩区间,不是硬性规范。牌型越少,同名牌越多,开局越容易消,后期越容易出现死局;牌型越多,单牌差异明显,但路径搜索压力也更大。
4. 连通路径检测:连连看核心算法的三种拐点与BFS实现
如果说牌组生成是 H5 小游戏源码的地基,那么连通路径检测就是承重墙。连连看的规则严格来说不是"找相同",而是"找一对相同且能用折角不超过两次的路径连起来的牌"。路径只能经过空格,不能穿过其他牌。
4.1 从"找到两张相同牌"到"路径折角不超过两个"
很多初学者会先判断两张牌是否同名,再去画线,这是性能最差的顺序。正确做法是先判断射线是否有障碍,再判断折角次数。折角为 0 就是同行或同列的直线,中间不能有牌;折角为 1 是 L 形路径,需要一个拐点;折角为 2 是 Z 形或 U 形路径,需要两个拐点。在代码里不区分 L 和 Z,统一交给搜索算法处理。
另一种实现方式是分别枚举水平/垂直扫描线,但 BFS 更好扩展,尤其是后期加入"最多允许 3 个拐点"的玩法时,只改一个参数。
4.2 BFS找路径:方向数组与visited记录
我用 BFS 做连通检测时,把方向、拐点数和路径一块塞进队列,实现对maxTurn的硬限制:
const DIRS = [ [-1, 0], // 上 [1, 0], // 下 [0, -1], // 左 [0, 1] // 右 ]; function findPath(board, start, end, maxTurn) { const rows = board.length; const cols = board[0].length; const visited = new Set(); const queue = [{ r: start.r, c: start.c, dir: -1, turns: -1, path: [] }]; while (queue.length) { const cur = queue.shift(); if (cur.r === end.r && cur.c === end.c && cur.turns <= maxTurn) { return cur.path.concat([end]); } for (let d = 0; d < DIRS.length; d++) { const nr = cur.r + DIRS[d][0]; const nc = cur.c + DIRS[d][1]; if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue; if (board[nr][nc] !== 0 && !(nr === end.r && nc === end.c)) continue; if (nr === start.r && nc === start.c) continue; const turns = (cur.dir === -1 || cur.dir === d) ? cur.turns : cur.turns + 1; if (turns > maxTurn) continue; const key = `${nr},${nc},${d}`; if (visited.has(key)) continue; visited.add(key); queue.push({ r: nr, c: nc, dir: d, turns, path: cur.path.concat([{ r: nr, c: nc }]) }); } } return null; }这个 BFS 里maxTurn从cur.turns = -1开始,初始方向dir = -1,第一次移动时cur.turns + 1得到 0,因此允许两个拐点时传maxTurn = 2即可。visited的 key 同时包含方向d,这非常重要:同一个格子可能从不同方向到来,带不同的已用拐点数,如果把方向漏掉,会错误地剪掉某些合法路径。路径数组cur.path.concat([end])在平时用于调试画线很方便,但在高密度棋盘上会生成大量中间数组,生产环境可以改成只存父节点,最后回溯。
以下是从这个算法里归纳出的三种情况对照:
| 几何特征 | 实际拐点数 | 是否命中 |
|---|---|---|
| 同行且中间空格连续无障 | 0 | 直接连线 |
| 需要经过一个拐点,拐点周围两条射线都无牌 | 1 | 返回拐点 |
| 需要经过两个拐点,且两段路径都无牌 | 2 | 返回完整路径 |
| 任意路径折角数大于 2 | 3 | 不满足,返回 null |
findPath的返回值不是布尔值,而是路径数组,这有两个好处:成功时可以直接画消除指引线,失败时能用null做未命中逻辑。如果你只想判断可消除,传maxTurn = 2后判断返回值即可。
4.3 点击配对、消除与未命中回弹
点击逻辑通常放在click事件里,但要注意坐标换算。canvas 的点击坐标必须减去画布偏移,再除以格子宽高:
canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const gridX = Math.floor((e.clientX - rect.left) * scaleX / TILE_WIDTH); const gridY = Math.floor((e.clientY - rect.top) * scaleY / TILE_HEIGHT); const first = selectedTile; const second = { r: gridY, c: gridX }; if (!first) { selectedTile = second; drawHighlight(second); return; } if (board[first.r][first.c] !== board[second.r][second.c]) { // 牌面不同,清空选择 clearHighlight(first); selectedTile = second; return; } const path = findPath(board, first, second, 2); if (path) { board[first.r][first.c] = 0; board[second.r][second.c] = 0; erasePair(path); playSound('htmlMahjong.mp3'); } else { // 无法连通,视觉回弹 bounceTile(first, second); } selectedTile = null; });scaleX和scaleY解决的是高分屏适配问题:CSS 尺寸和 canvas 像素尺寸可能不一致,直接相除会点偏格子。erasePair(path)可以拆成一个把路径点依次画成高亮线的函数,这样玩家会看到一条消隐轨迹,比直接消失更有手感。未命中时的bounceTile可以用 CSS transform 加 200ms 位移实现,不需要引入动画库。
5. 点击状态机、计时器与得分倍率的联调方法
连连看的交互逻辑看似是"点两张牌",但中间状态特别容易乱:快速双击同一张牌、连续点三张、消除动画还没播完又点下一组,都会触发 bug。解决办法是引入一个微型状态机。
5.1 游戏状态机的四个状态
我把状态定义为IDLE -> SELECTED -> CHECKING -> ANIMATING,但为降低复杂度,实际代码里可以只保留IDLE和SELECTED两个状态,动画期间用isAnimating布尔值锁住输入。状态与行为的映射如下:
| 状态 | 允许的操作 | 说明 |
|---|---|---|
| IDLE | 选中一张牌 | 记录坐标,切换为 SELECTED |
| SELECTED | 选择另一张牌或点击原牌取消 | 点击原牌回 IDLE;点击新牌触发匹配 |
| CHECKING | 等待 findPath 返回 | 阻塞输入,防止重复点击 |
| ANIMATING | 播放消除动画 | isAnimating = true,结束后解锁 |
5.2 事件委托减少监听器数量
不要给每张牌都添加addEventListener。不管棋盘用 DOM 还是 canvas,都用一层委托:
const game = { status: 'IDLE', selected: null, isAnimating: false, score: 0, combo: 0, timer: null, timeLeft: 0 }; canvas.addEventListener('click', handleCanvasClick); function handleCanvasClick(e) { if (game.isAnimating) return; const pos = getGridPosition(e); if (!isInsideBoard(pos)) return; const tile = board[pos.r][pos.c]; if (tile === 0) return; if (game.status === 'IDLE') { game.selected = pos; game.status = 'SELECTED'; drawSelection(pos); return; } if (game.status === 'SELECTED') { if (samePosition(pos, game.selected)) { clearSelection(game.selected); game.selected = null; game.status = 'IDLE'; return; } if (board[pos.r][pos.c] !== board[game.selected.r][game.selected.c]) { // 换了目标,取消原选择 clearSelection(game.selected); game.selected = pos; drawSelection(pos); return; } const path = findPath(board, game.selected, pos, 2); if (path) { destroyPair(game.selected, pos, path); } else { shakeTile(pos); } game.status = 'IDLE'; } }这段代码把isAnimating从状态机里单独拆出来,是因为动画通常是异步的,适合用锁而非状态切换。samePosition比较r和c两个字段,不要用对象引用比较,因为pos每次都是新对象。
5.3 计时、得分与连击倍率的计算公式
计时器用setInterval每 1000ms 递减。注意把timeLeft存在 game 对象里而不是直接读 DOM,避免重复getElementById:
function startTimer(duration) { game.timeLeft = duration; game.timer = setInterval(() => { game.timeLeft--; updateTimerText(game.timeLeft); if (game.timeLeft <= 0) { clearInterval(game.timer); showGameOver(); } }, 1000); }得分我采用基准分乘连击系数的方案,连击定义是"没有点错且连续消除":
function addScore(baseScore = 100) { game.combo++; const multiplier = 1 + Math.min(game.combo - 1, 5) * 0.1; game.score += Math.round(baseScore * multiplier); updateScoreText(game.score); } function resetCombo() { game.combo = 0; }Math.min(game.combo - 1, 5)把倍率上限锁在 1.5,防止后期积分膨胀。连击系数是 H5 小游戏常用的留存手段,但对休闲棋牌类来说倍数过高会让数值溢出显示,建议整数显示在 UI 上。计时结束触发showGameOver()时,记得先清空selected和status,否则胜利或失败弹窗会被点击事件穿透。
6. 渲染性能优化与H5小游戏源码的二次开发落点
最后一章聊几句真正动手改这套源码时最值得做的三件事:渲染优化、资源预加载、平台迁移。
6.1 用Canvas替代DOM批量消除时的重绘
如果原版用的是 DOM 节点,一次消除两张牌会触发两次布局抖动,在高分屏上尤其明显。常见的做法是维护一个脏矩形列表,只重绘被影响的棋盘区域。更简单有效的办法是把整块棋盘放在一个离屏 canvas 上,消除时按牌格坐标绘制背景块,而不是重新遍历整个二维数组。
6.2 资源预加载与音频播放的兼容性处理
把图片和音频统一用Promise预加载,等所有素材 ready 后再进入主菜单:
function preloadAssets() { const imageSources = { bgGame: 'game_in_BG.png', bgWin: 'YOU_WIN_BG.png', bgStart: 'MAIN_MENU_AND_LOADING_BG.png' }; return Promise.all( Object.entries(imageSources).map(([key, src]) => new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve({ key, img }); img.onerror = reject; img.src = src; }) ) ).then((results) => { const cache = {}; results.forEach(({ key, img }) => { cache[key] = img; }); return cache; }); }音频方面要注意移动端 H5 要求用户手势后触发play(),所以开始按钮的点击回调里应当先调用一次audio.play()再audio.pause()来解锁音频环境。如果你的调试环境是 H5 页面嵌入微信公众号,还要额外处理WeixinJSBridgeReady事件,否则首次音效可能失效。
6.3 把这份源码改造成限时挑战或关卡制
麻将连连看非常适合改成"层层递进难度"的小游戏:每关降低TIME_LIMIT或增加TILE_TYPE_COUNT。棋盘不变时,牌型越多,相同牌间隔越远,连线难度自然上升。将findPath的maxTurn从 2 改成 3,就能得到"三连弯"变体,但注意 3 拐点时路径搜索量会显著增加,300ms 内算不完的话,优先把 visited 改成Uint8Array而不是Set。
如果你后续想把它迁到uniapp或微信小游戏,第一刀就是替换浏览器 API:window.addEventListener改成小游戏全局对象,document.createElement全部改为 canvas 绘图,DOM 点击事件改为touchstart。同样是这套源码,理解了状态机和 BFS 之后,迁移成本主要在渲染层而不在逻辑层。最后一次调试时重点看preloadAssets里是否把图标和背景图路径写错,game_in_BG.png与majong_BG.png都是背景,别在加载完成后才发现覆盖顺序反了。
本文还有配套的精品资源,点击获取