ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Web技术从零实现消除游戏:数据结构、核心算法与交互优化

Web技术从零实现消除游戏:数据结构、核心算法与交互优化

消除类游戏看起来简单,好像随便拖几个方块凑三个一样的就能跑起来,但真动手从零搭一个能玩、手感顺、不卡顿的版本,中间要处理的事情比想象中多得多。棋盘数据结构怎么设计、消除之后怎么下落、下落之后怎么补新块、连消怎么计分、动画和逻辑怎么解耦,每一步都有取舍。这篇内容就是围绕“用Web技术从零做一个消除游戏”这件事,把我在实际搭建过程中踩过的坑、做过的选型、验证过的参数,完整地摊开讲一遍。

适合谁看?如果你已经会一点HTML、CSS和JavaScript,能看懂基本的DOM操作和数组循环,那这篇内容基本可以照着复现;如果你是完全的新手,也能看懂整体思路,把每一步当成一个可执行的任务清单来跟。核心关键词就三个:消除游戏、Web技术、从零实现。下面所有内容都围绕这三个词展开,不跑偏。

1. 先把棋盘的数据结构定下来,别急着写界面

很多人做消除游戏的第一反应是打开编辑器写HTML,摆一堆div出来,然后开始绑点击事件。这个顺序是反的。消除游戏的本质是一张二维网格上的状态机,界面只是这张网格的投影。数据结构没定好,后面每加一个功能都要回头改渲染逻辑,越写越乱。

1.1 为什么用一维数组而不是二维数组

最直觉的做法是board[row][col]这样的二维数组。我一开始也是这么写的,写到下落逻辑的时候发现特别别扭:你要判断某一列从上往下哪些格子是空的,得写两层循环,而且交换、消除、下落三套逻辑各自遍历的方式还不一样,很容易下标写错。

后来我改成了一维数组,长度是rows * cols,用index = row * cols + col来定位。好处是遍历的时候一个for循环从头到尾扫一遍就行,下落逻辑可以按列切片处理,代码短很多。代价是每次取值要算一次下标,但这个计算成本可以忽略。

const ROWS = 8; const COLS = 8; const TYPES = 5; // 五种方块颜色/类型 // 用 0 表示空格,1~5 表示五种方块 let board = new Array(ROWS * COLS).fill(0); function idx(row, col) { return row * COLS + col; }

这里有个细节值得说:空格用 0 表示,而不是 null 或 undefined。原因是后续做类型判断、做数组填充的时候,数字 0 参与运算不会出问题,而 null 在比较和类型转换时容易埋雷。方块类型从 1 开始编号,这样“0 是空”这个约定在整个项目里始终成立,读代码的人一眼就能分清“这里没块”和“这里有个某类型的块”。

1.2 初始化棋盘时必须保证“开局无三连”

如果开局就随机填满,大概率一上来就有现成的三连,玩家还没操作就自动消了一波,体验很怪。所以初始化要带一个约束:每生成一个方块,检查它左边两个和上边两个是否和自己同类型,如果是,就换一个类型重新生成。

function randomType(exclude) { let t; do { t = 1 + Math.floor(Math.random() * TYPES); } while (exclude.includes(t)); return t; } function initBoard() { for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS; c++) { const exclude = []; // 检查左边两个 if (c >= 2 && board[idx(r, c - 1)] === board[idx(r, c - 2)]) { exclude.push(board[idx(r, c - 1)]); } // 检查上边两个 if (r >= 2 && board[idx(r - 1, c)] === board[idx(r - 2, c)]) { exclude.push(board[idx(r - 1, c)]); } board[idx(r, c)] = randomType(exclude); } } }

这个exclude数组的思路很实用:不是生成完再检查再重来,而是在生成的那一刻就把会导致三连的类型排除掉,一次成型。实测下来初始化耗时几乎为零,8×8 的棋盘瞬间完成。

注意:这里只排除了“横向两个相同”和“纵向两个相同”的情况,因为只要保证新块不和前两个构成三连,就不会出现开局即消。不需要检查更复杂的组合,那是过度设计。

1.3 棋盘尺寸和类型数量的取舍

这两个参数直接决定游戏难度和可玩性,我试过好几组:

棋盘尺寸类型数实测感受
6×64太容易连消,节奏拖沓,缺乏挑战
8×85比较均衡,单局时长适中,推荐起步用这组
10×106视觉上偏密,小屏幕上方块太小,误触率高
8×86难度偏高,新手容易卡住无解

类型数越多,凑三连越难,但连消的爽感也越强。我的建议是从 8×8、5 种类型起步,跑通之后再调。类型数不要超过 6,否则随机性太强,玩家会觉得“怎么凑都凑不齐”,挫败感明显。

2. 交换、判定、消除:把核心循环拆成三个独立函数

消除游戏的主循环其实就三步:玩家交换两个相邻方块 → 判定是否形成三连 → 有则消除并进入下落补充。这三步如果揉在一个函数里,调试会非常痛苦,因为一旦出问题你分不清是交换错了、判定错了还是消除错了。我的做法是拆成三个纯函数,各自只做一件事,输入输出明确。

2.1 交换函数只负责交换,不做判定

function swap(i, j) { const tmp = board[i]; board[i] = board[j]; board[j] = tmp; }

就这么简单。判定逻辑放在外面,因为交换之后可能合法也可能不合法,不合法要换回来。把“换回来”这件事也放在调用方处理,交换函数保持无状态、无副作用判断,这样它可以在任何地方复用。

2.2 三连判定:从交换点出发,而不是全盘扫描

最笨的判定方式是每次交换后全盘扫一遍找三连。8×8 是 64 个格子,扫一遍也就几百次比较,性能上其实没问题。但逻辑上有个坑:全盘扫描会把“交换前就存在的三连”也算进去,而正常情况下棋盘上不应该有现成的三连(每次消除后都会处理干净)。如果因为某个 bug 残留了三连,全盘扫描会把它当成新消除,导致计分错乱。

更稳的做法是只从交换的两个位置出发判定。因为一次合法交换最多影响这两个点所在的行和列,其他位置不可能因为这次交换产生新的三连。

function hasMatchAt(row, col) { const type = board[idx(row, col)]; if (type === 0) return false; // 横向计数 let count = 1; for (let c = col - 1; c >= 0 && board[idx(row, c)] === type; c--) count++; for (let c = col + 1; c < COLS && board[idx(row, c)] === type; c++) count++; if (count >= 3) return true; // 纵向计数 count = 1; for (let r = row - 1; r >= 0 && board[idx(r, col)] === type; r--) count++; for (let r = row + 1; r < ROWS && board[idx(r, col)] === type; r++) count++; return count >= 3; }

这个函数从中心向两边扩散计数,比“取三个连续格子比较”的写法更通用,因为它天然支持四连、五连的情况,后面做特殊方块扩展时不用重写。

2.3 找出所有要消除的格子,而不是找到一个就消

判定“有没有三连”和“找出所有三连格子”是两件事。前者返回布尔值用于决定交换是否合法,后者返回一个坐标集合用于实际消除。这两个必须分开,因为一次交换可能同时触发横向和纵向两个三连(T型或十字型消除),如果找到一个就消,会漏掉另一组。

function findMatches() { const matched = new Set(); // 横向扫描 for (let r = 0; r < ROWS; r++) { let runStart = 0; for (let c = 1; c <= COLS; c++) { const same = c < COLS && board[idx(r, c)] !== 0 && board[idx(r, c)] === board[idx(r, runStart)]; if (!same) { if (c - runStart >= 3 && board[idx(r, runStart)] !== 0) { for (let k = runStart; k < c; k++) matched.add(idx(r, k)); } runStart = c; } } } // 纵向扫描,逻辑同上,行列互换 // ... return matched; }

用Set而不是数组来收集坐标,是因为横向和纵向扫描可能命中同一个格子(十字消除的中心点),Set自动去重,省掉手动判重的代码。这个细节虽小,但在连消场景下能避免同一个格子被消除两次导致计分翻倍。

提示:扫描时一定要跳过空格(!== 0的判断),否则连续三个空格会被误判成三连。这个 bug 我在第一版就踩了,表现是消除后棋盘突然大面积塌陷,排查了半天才发现是空格被当成了同类型。

3. 下落与补充:消除之后棋盘怎么“塌”下来

消除只是把格子置空,真正让棋盘恢复完整的是下落和补充。这一步的逻辑如果写错,会出现“方块悬空”“新块叠在老块上”“某列永远补不满”这类问题。核心思路是按列处理,因为下落是列内行为,列与列之间互不影响。

3.1 按列压缩:把非空格子往下挤

对每一列,从下往上收集所有非空格子,然后从底部重新填回去,剩下的位置补新块。这个“压缩”思路比逐个格子判断“下面是不是空的、要不要往下移”要简洁得多,而且不会出现移动顺序导致的覆盖问题。

function collapseColumn(col) { const column = []; // 从下往上收集非空格子 for (let r = ROWS - 1; r >= 0; r--) { const v = board[idx(r, col)]; if (v !== 0) column.push(v); } // 从底部往上填回 for (let r = ROWS - 1, k = 0; r >= 0; r--, k++) { board[idx(r, col)] = k < column.length ? column[k] : randomType([]); } }

注意这里补新块用的是randomType([]),也就是完全随机,不做三连排除。原因是下落补充时如果也做排除,逻辑会变得很复杂(新块的位置和周围已有块的关系是动态的),而且补充后本来就要再跑一次findMatches做连消判定,让连消机制自然处理掉新产生的三连,反而更符合消除游戏的爽感设计——下落补充后触发连锁消除,正是玩家想要的效果。

3.2 连消循环:用 while 而不是递归

消除之后下落,下落之后可能又形成新的三连,需要再消除、再下落,直到棋盘稳定。这个循环用while写比递归清晰:

function resolveBoard() { let combo = 0; while (true) { const matched = findMatches(); if (matched.size === 0) break; combo++; // 计分:连消次数越多,单格得分越高 const score = matched.size * 10 * combo; totalScore += score; matched.forEach(i => { board[i] = 0; }); for (let c = 0; c < COLS; c++) collapseColumn(c); } return combo; }

combo这个变量是连消的核心。第一次消除 combo 是 1,第二次是 2,得分按倍数递增。实测下来这个设计对玩家体验影响很大:单纯消三个得 30 分,但如果能触发三连消,同样消三个格子能得 90 分,玩家会主动去追求连锁,游戏深度一下就出来了。

3.3 下落动画和逻辑解耦

逻辑上棋盘是瞬间完成下落和补充的,但视觉上如果方块“啪”一下全跳到位,观感很生硬。我的做法是逻辑先算完,动画后播放。具体说就是先把board数组更新到最终状态,同时记录每个格子的“起始位置”和“目标位置”,然后交给渲染层做过渡动画。

// 逻辑层:记录移动轨迹 const moves = []; // { fromRow, toRow, col, type } // 渲染层:根据 moves 播放 CSS transition

这样做的最大好处是动画不影响游戏状态。玩家在动画播放期间点击,逻辑层已经是最新状态,不会出现“动画没播完导致状态错乱”的问题。如果反过来让动画驱动逻辑,就得处理各种动画中断、快速连点的情况,复杂度翻好几倍。

注意:动画时长建议控制在 150~250 毫秒。太短看不出下落感,太长会让连续操作变得粘滞。我最后定的是 180 毫秒,配合ease-out缓动,下落有加速感,落地干脆。

4. 交互层:点击选中与拖拽交换的取舍

消除游戏的输入方式主要有两种:点两下交换(先点一个再点相邻的另一个)和拖拽交换(按住一个往相邻方向拖)。两种都实现过之后,我的结论是移动端优先做拖拽,桌面端两种都支持,因为鼠标拖拽和触摸拖拽的体验差异不大,但纯点击在手机上容易误触。

4.1 点击选中的状态机

点击模式本质是一个两态状态机:没有选中任何格子时,点击选中;已有选中格子时,点击相邻格子则尝试交换,点击非相邻格子则改选新格子,点击自己则取消选中。

let selected = -1; function onCellClick(i) { if (selected === -1) { selected = i; renderHighlight(i); return; } if (selected === i) { selected = -1; clearHighlight(); return; } if (isAdjacent(selected, i)) { trySwap(selected, i); selected = -1; clearHighlight(); } else { selected = i; renderHighlight(i); } }

isAdjacent的判断要注意:一维下标下,左右相邻是下标差 1,但必须保证两个格子在同一行,否则行末和下一行行首会被误判为相邻。上下相邻是下标差COLS。

function isAdjacent(a, b) { const ra = Math.floor(a / COLS), ca = a % COLS; const rb = Math.floor(b / COLS), cb = b % COLS; return Math.abs(ra - rb) + Math.abs(ca - cb) === 1; }

用曼哈顿距离等于 1 来判断相邻,一行代码搞定,比分别判断上下左右四种情况清晰。

4.2 拖拽交换的方向判定

拖拽的核心是判断拖动方向。记录按下时的坐标,移动超过阈值(比如 20 像素)后,比较横向位移和纵向位移的绝对值,哪个大就往哪个方向交换。

function onDragEnd(startX, startY, endX, endY, startIndex) { const dx = endX - startX; const dy = endY - startY; if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return; // 位移太小,视为点击 const row = Math.floor(startIndex / COLS); const col = startIndex % COLS; let targetRow = row, targetCol = col; if (Math.abs(dx) > Math.abs(dy)) { targetCol += dx > 0 ? 1 : -1; } else { targetRow += dy > 0 ? 1 : -1; } if (targetRow < 0 || targetRow >= ROWS || targetCol < 0 || targetCol >= COLS) return; trySwap(startIndex, idx(targetRow, targetCol)); }

阈值 20 像素是试出来的:太小容易把点击误判成拖拽,太大则拖拽响应迟钝。不同设备上可以微调,但 20 是个不错的起点。

4.3 交换合法性校验与回退

trySwap是交互层和逻辑层的交界,它要做的事是:交换 → 判定 → 不合法则换回来。这里有个容易忽略的点:换回来之后要重新渲染,否则界面会停留在交换后的错误状态。

function trySwap(a, b) { swap(a, b); if (!hasMatchAt(Math.floor(a / COLS), a % COLS) && !hasMatchAt(Math.floor(b / COLS), b % COLS)) { swap(a, b); // 换回来 playInvalidFeedback(); // 可选:抖动或音效 render(); return false; } render(); resolveBoard(); render(); return true; }

playInvalidFeedback这个反馈很重要。如果交换不合法但没有任何提示,玩家会以为是自己没点中,反复尝试。加一个轻微的抖动动画或者短促音效,玩家立刻明白“这个方向不行”,操作信心会强很多。

5. 渲染策略:DOM 还是 Canvas,我为什么最后选了 DOM

这是个绕不开的选型问题。消除游戏的渲染有两种主流方案:用 DOM 元素(div)拼格子,或者用 Canvas 画。我两个版本都写过,最后在“简单教程”这个定位下选了 DOM,理由如下。

5.1 两种方案的实测对比

维度DOM 方案Canvas 方案
上手难度低,会 HTML/CSS 就能写中,要懂绘图 API 和坐标换算
动画实现CSS transition 直接搞定要手写 requestAnimationFrame 循环
点击命中浏览器原生处理要自己算坐标落在哪个格子
大量元素性能64 个格子无压力更优,但本项目用不上
样式调整改 CSS 即可要改绘制代码

8×8 的棋盘最多 64 个格子,DOM 方案的性能完全够用,而且 CSS 动画、点击事件、高亮样式都是现成的,开发效率高出一大截。Canvas 的优势在元素数量大(比如几百上千个粒子)时才体现,消除游戏这个规模用不上。

5.2 用 CSS Grid 布局棋盘

棋盘布局用 CSS Grid 是最省事的,一行代码定义行列,格子自动排布:

.board { display: grid; grid-template-columns: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr); gap: 4px; width: min(90vw, 480px); aspect-ratio: 1; }

aspect-ratio: 1保证棋盘是正方形,min(90vw, 480px)保证小屏幕上不溢出、大屏幕上不过大。这两个组合起来,一套样式适配手机和桌面,不用写媒体查询。

5.3 渲染函数只做“全量重绘”,不做增量更新

一开始我想做增量更新,只更新变化的格子,性能更好。但实际写下来发现,消除、下落、补充涉及的变化格子太多,增量更新的判断逻辑比全量重绘还复杂,而且容易漏更新导致界面和状态不一致。

全量重绘就是每次状态变化后,把 64 个格子的样式全部重设一遍。64 个元素的样式赋值在现代浏览器里是微秒级的,完全感觉不到卡顿。代码也简单:

function render() { for (let i = 0; i < board.length; i++) { const cell = cells[i]; cell.dataset.type = board[i]; cell.classList.toggle('empty', board[i] === 0); } }

用dataset.type存类型,CSS 里用属性选择器配颜色:

.cell[data-type="1"] { background: #e74c3c; } .cell[data-type="2"] { background: #3498db; } .cell[data-type="3"] { background: #2ecc71; } .cell[data-type="4"] { background: #f1c40f; } .cell[data-type="5"] { background: #9b59b6; } .cell.empty { background: transparent; }

这样颜色和逻辑完全分离,想换配色只改 CSS,不碰 JS。这个分离在后期调优时特别省事。

提示:方块颜色要选对比度足够的组合,尤其是红色和绿色不要同时用(色弱玩家难以区分)。我最后用的是红、蓝、绿、黄、紫,红绿虽然都在但明度差异大,实测辨识度可以接受。更严谨的做法是给每种类型加不同的形状或图标,但那是锦上添花,先把核心跑通。

6. 计分、连消与难度曲线:让游戏“有得玩”

能消能落只是及格线,真正让玩家愿意多玩几局的是计分和难度设计。这部分没有标准答案,但有几个原则我验证下来是有效的。

6.1 基础分加连消倍率

前面代码里已经体现了:单格基础 10 分,连消次数作为倍率。这个设计的心理效果是“鼓励玩家制造连锁”。实测中,玩家会主动寻找能触发多消的交换,而不是随便消三个了事,游戏的策略性就出来了。

连消次数单格得分消 3 格总得分
11030
22060
33090
440120

倍率不要设太高,否则一次大连消分数爆炸,后面的操作显得没意义。线性递增(10、20、30……)比指数递增更稳。

6.2 步数限制比时间限制更适合新手

限制方式有两种:限时(比如 60 秒内尽量多得分)和限步(比如 30 步内尽量多得分)。我两个都试过,限步更适合消除游戏,原因是限时会让玩家在动画播放期间焦虑,而消除游戏的动画和连消本身就要占时间,限时体验很割裂。限步则让玩家可以慢慢思考每一步,节奏更从容。

步数计数放在trySwap成功之后减一,交换不合法不减步。这个细节很重要,否则玩家误操作也扣步,会很烦躁。

6.3 无解检测:什么时候该洗牌

棋盘有可能进入“怎么交换都凑不出三连”的死局。如果不处理,玩家会卡在那里干瞪眼。检测方法是遍历所有相邻对,模拟交换后看是否产生三连,全部试完都没有就说明无解。

function hasAnyValidMove() { for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS; c++) { const i = idx(r, c); // 只试右边和下边,避免重复 if (c + 1 < COLS) { const j = idx(r, c + 1); swap(i, j); const ok = hasMatchAt(r, c) || hasMatchAt(r, c + 1); swap(i, j); if (ok) return true; } if (r + 1 < ROWS) { const j = idx(r + 1, c); swap(i, j); const ok = hasMatchAt(r, c) || hasMatchAt(r + 1, c); swap(i, j); if (ok) return true; } } } return false; }

这个检测在每次resolveBoard结束后跑一次,返回 false 就重新洗牌(保留现有方块类型,只打乱位置,并保证洗牌后无三连且有解)。洗牌时给个提示动画,玩家知道“哦,没得走了,系统帮我换了”,体验就顺了。

注意:洗牌后要重新跑一次无解检测,极端情况下洗出来的盘面还是无解,虽然概率很低,但加个循环兜底更稳。我设了最多重试 10 次,超过就直接重新初始化棋盘。

7. 我在实际搭建中踩过的几个坑

这部分是纯经验,文档里不会写,但实际动手一定会遇到。

第一个坑:消除后忘记把格子置 0 就下落。表现是方块消了但位置还在,下落时把旧方块又挤上去了,棋盘越玩越乱。根因是matched.forEach(i => board[i] = 0)这行漏写或者写在了collapseColumn之后。顺序必须是先置空、再下落、再补充。

第二个坑:连消循环没有终止条件。如果findMatches有 bug 导致空格被误判为三连,while循环会无限跑,页面直接卡死。加一个最大连消次数保护(比如 20 次)能防止这种情况,虽然正常游戏到不了这个数,但兜底很重要。

第三个坑:拖拽和点击事件冲突。移动端上,touchstart和click会同时触发,导致一次操作被处理两次。解决办法是在touchstart里记录状态,touchend里判断位移,位移小才当作点击处理,并且用preventDefault阻止后续的 click 事件。

第四个坑:CSS transition 在快速连续操作时堆积。玩家手速快的时候,上一次下落动画还没播完,下一次消除又开始了,方块位置会跳。解决办法是动画用transform而不是top/left(transform走合成层,性能好且不触发重排),并且在逻辑层保证状态始终是最新的,动画只是视觉表现,不参与状态计算。

第五个坑:随机补充导致连续无解。补充新块完全随机,有可能补出来的盘面就是死局。所以resolveBoard之后的无解检测不能省,这是保证游戏能一直玩下去的关键。

8. 后续可以怎么扩展

核心版本跑通之后,扩展方向很多,按实现难度从低到高排一下,方便你按需选择。

最简单的扩展是特殊方块:四连生成一个“炸弹”,消除时炸掉周围一圈;五连生成一个“彩虹块”,消除时清掉所有同类型。实现上就是在findMatches里判断连的长度,长度 4 或 5 时在中心位置标记特殊类型,消除时对特殊类型做额外处理。

中等难度的是关卡目标:比如“消除 20 个红色方块”“在 15 步内达到 1000 分”。这需要在计分逻辑里加目标追踪,以及胜利/失败的判定。

再往上可以做道具系统和存档,道具比如“锤子”可以敲掉任意一个格子,“换位”可以交换任意两个格子。存档用localStorage存最高分和当前关卡进度就够,不需要后端。

我个人建议先把核心版本打磨到手感顺滑,再考虑扩展。很多消除游戏的问题不在功能少,而在基础操作不跟手、动画卡顿、判定有延迟。把交换响应、下落动画、连消节奏这三件事调好,一个“简单”的消除游戏就已经很耐玩了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进