# JavaScript 经典小游戏系列之连连看
## 前言
其实想写经典小游戏系列很久了,但苦于一直没有时间,这几个周末我会抽空写几篇小游戏相关的文章出来。
90后们应该都对电脑手机刚普及的时候流行过的那些经典小游戏有着难忘的回忆,连连看我相信大多数人都有在各类设备上玩过,规则也很简单,就是将相同的方块连接起来消除,要求连接线不能大于3条,这一篇我会讲一下如何从零开始制作一个连连看的小游戏。为了方便操作 DOM 元素,这里使用了 Vuejs 2.0 来进行游戏的制作。
先放上 Demo 地址: [Demo](https://link.zhihu.com/?target=https%3A//woshiguabi.000webhostapp.com/linklinkup/index.html)
_update: 已将 Demo 放到 000 上,如果上面的访问不了可以试试 [Github Pages](https://link.zhihu.com/?target=http%3A//woshiguabi.github.io/linklinkup/) 上的_ 。(_可能需要梯子_)
## 如何连线
连连看游戏的核心在于如何判断两个方块是相连的。这其实是一般游戏中寻路算法的一种,如何寻找从一个点到另一个点的最短路径,但是实现一个寻路算法的过程比较复杂,本文就不深入展开了。这里会使用另一种思路来寻找路径。
在连连看中,能消除的情况只有以下三种。
1. 直线相连。 2. 一个拐角。 3. 两个拐角。
![](https://pic2.zhimg.com/v2-a1b2196829204c918d612ad99b8b1da1_b.jpg)
前两种情况相对来说比较简单,但第三种情况在游戏中可能出现多种连线。
![](https://pic3.zhimg.com/v2-f73f1fb9e4b8779e128575051c716f9a_b.jpg)
看似比较复杂,其实只需要先分别计算出两个方块在地图上 X 轴与 Y 轴上能直接联通的坐标,每个方块得到两个数组(X 轴与 Y 轴上能直接联通的点),然后对两个方块的 X 轴和 Y 轴的数组做一个比较运算,即可得到拐角所在的位置。
上面简单分析了一下连线基本的实现思路,下面开始写代码。
## 流程
1. 生成地图 2. 点击事件 3. 获得连接线 4. 绘制连接线
## 生成地图
第一件要干的事当然是随机生成一个地图。
game.vue
```text <template>
<div :class="currentTheme.name"> <table class="game" @click="handleClick"> <tr :key="row" v-for="(cols, row) in cellData"> <cell :key="col" v-for="(cell, col) in cols" :isSelected="cell.isSelected" :isLine="cell.isLine" :lineClass="cell.lineClass" :isBlank="cell.isBlank" :className="cell.className"></cell> </tr> </table> </div>
</template> <script> import Cell from './cell' import Utils from '../utils' import config from '../config' import themes from '../themes' export default {
components: { Cell }, data () { return { cellData: [], // 地图数据数组 currentSelect: null, // 当前选中的方块 config: Object.assign(config) // 配置信息 } }, computed: { currentTheme () { // 当前theme return themes.filter(e => e.name === this.config.defaultTheme)[0] } }, mounted () { this.init() }, methods: { // ... }
}
cell.vue 这里使用了一个空的 div 的 :before 和 :after 伪类来展示连接线
<template> <td :class="classNames"> <div v-if="isLine" :class="lineClass"></div> </td> </template>
<script>
export default {
name: 'cell', props: ['isSelected', 'isBlank', 'className', 'lineClass', 'isLine'], computed: { classNames () { return { 'selected': this.isSelected, // 选中 'blank': this.isBlank, // 空白 [this.className]: true } } }
}
</script>
为了更好的对连连看的方块内容进行拓展,我们使用一个数组来装不同色块的 className,然后将对应的 className 放到色块上,通过 css 来控制色块的背景图片。
init () { console.time('initData') this.cellData = this.initData() console.timeEnd('initData') }, initData () { // classNames => ['a','b','c','d'] 每个元素代表一个方块的className // 生成一个方块的数组,将className放到其中 let cellGroup = this.currentTheme.classNames.map(e => {
return { isBlank: false, // 是否空白方块 className: e, // 方块的className lineClass: '', // 连接线的className isLine: false, // 是否显示连接线 isSelected: false }
}) // 空白方块 let blankCell = {
isBlank: true, className: '', lineClass: '', isLine: false, isSelected: false
}
// 先根据配置中的方块个数从方块数组中随机取出几条 let randomCellGroup = Utils.arrayRandom(cellGroup, this.config.cellGroupCount)
// 再根据配置中的行和列随机填充一个地图数据 let cellData = Utils.arrayFillByRandomGroup(this.config.row * this.config.col, randomCellGroup)
// 将数据根据行的大小转为二维数组,然后外部包裹一层空白节点 /*
// 最后把行和列的坐标设置到节点上 result.forEach((cols, row) => {
cols.forEach((cell, col) => { cell.row = row cell.col = col })
}) return result }
最后我们得到了一个地图数据的二维数组。 ## 选取事件 接下来就是选取方块的事件了。 为了提高性能,没有将点击事件直接绑定到方块上,而是通过绑定在外层的 table 上,用事件代理来实现。这里也吐槽一下 Vue 目前是没有事件代理的,为了提高绑定的性能需要自己实现一个事件代理。
// 点击事件代理 handleClick (ev) { // 如果点击事件不是触发在方块上那么退出 if (ev.target.nodeName !== 'TD') return
// 获取点击方块的坐标,如果方块是空的那么退出 let col = ev.target.cellIndex let row = ev.target.parentNode.rowIndex let currentCell = this.cellData[row][col] if (currentCell.isBlank === true) return
this.selectCell(currentCell) }, // 选择方块 selectCell (currCell) { if (!this.currentSelect) {
// 如果没有选中任何方块那么就直接设置选中 currCell.isSelected = true this.currentSelect = currCell return
} if (this.currentSelect === currCell) {
// 如果点击的方块和已选中方块是同一个,那么就取消这个方块的选中状态 currCell.isSelected = false this.currentSelect = null return
}
let prevCell = this.currentSelect // 通过className来判断前后两个方块的图片是否相同 if (prevCell.className !== currCell.className) {
// 如果两个方块的className不同,那么将点击的方块设置为选中状态 prevCell.isSelected = false currCell.isSelected = true this.currentSelect = currCell return
}
// 获取两个方块的连接线路径数组 console.time('getLine') let result = this.getLine(prevCell, currCell) console.timeEnd('getLine')
if (result.length === 0) {
// 如果没有获取到连接线,说明两个方块无法连接,那么将点击的方块设置为选中状态 prevCell.isSelected = false currCell.isSelected = true this.currentSelect = currCell
} else {
// 如果获取到连接线,那么将两个方块设置为空白方块 prevCell.isBlank = true currCell.isBlank = true prevCell.className = '' currCell.className = '' prevCell.isSelected = false currCell.isSelected = false this.currentSelect = null // 最后绘制连接线 this.drawLine(result)
} }
选择的逻辑在这里就判断完毕了,接下来也是本文的核心,如何获得连接线路径。. ## 获得连接线 这个内容稍微有点长,分为几块来写。 首先描述一下在下文中多次提到的**可连接线。** ![](https://pic2.zhimg.com/v2-6c88655b3d60c87fab769f4f998f8369_b.jpg) 在查找可连接线时,红色方块是需要查找的方块,黑色是其他方块,白色是空白方块,那么可以从红色方块开始向前与向后遍历得到一个可以达到的方块Set对象,也就是图中所有的白色方块。 **getLine** 获取连接线的入口
getLine (prev, curr) { // 连接线数组 let result = []
// 一条直线连通的情况 // 分别获取上一个选中方块的X轴与Y轴上的可连接线,这里getHorizontalLine与getVerticalLine返回的均为一个Set对象,使用has来快速高效地判断在可连接线上是否包含某个方块 let prevH = this.getHorizontalLine(prev) if (prevH.has(curr)) return this.getBeeline(prev, curr) let prevV = this.getVerticalLine(prev) if (prevV.has(curr)) return this.getBeeline(prev, curr)
// 如果直线连通失败了,那么获取另一个方块的可连接线 let currH = this.getHorizontalLine(curr) let currV = this.getVerticalLine(curr)
// 做一个快速判断,如果其中一个方块在X轴和Y轴上的可连接线长度都为0,那么返回空数组 if ((!prevH.size && !prevV.size) || (!currH.size && !currV.size)) return result
// 一个拐角可以连通的情况 // 分别对X轴和Y轴上的可连接线做一个交点判断 let intersection = this.getIntersection(prevH, currV) || this.getIntersection(prevV, currH) // 如果获取到交点,那么返回连接路径 // 上一个选中 => 第一个拐角 => 当前选中 if (intersection) return this.getBeeline(prev, intersection).concat(this.getBeeline(intersection, curr).slice(1))
// 最后处理两个拐角的情况 let intersectionArr = this.getIntersectionArr(prevH, currH, prev.row, curr.row, true) if (intersectionArr.length === 0) {
intersectionArr = this.getIntersectionArr(prevV, currV, prev.col, curr.col, false)
}
// 如果getIntersectionArr成功,返回一个包含两个拐角方块的数组 // 依次根据 上一个选中 => 第一个拐角 => 第二个拐角 => 当前选中 绘制连接线 if (intersectionArr.length > 0) {
result = this.getBeeline(prev, intersectionArr[0]).concat(this.getBeeline(intersectionArr[0], intersectionArr[1]).slice(1)).concat(this.getBeeline(intersectionArr[1], curr).slice(1))
}
return result }
**getHorizontalLine \&\& getVerticalLine** 获取一个方块在 X 轴或 Y 轴上的**可连接线**
// X轴 getHorizontalLine (curr) { return this.checkCell(this.cellData[curr.row], curr.col) }, // Y轴 getVerticalLine (curr) { return this.checkCell(this.cellData.map(e => e[curr.col]), curr.row) }, // 查找,通过一个数组与需要要查找的数组下标来计算 checkCell (arr, index) { let set = new Set() // 向后查找 for (let i = index - 1; i >= 0; i--) {
let cell = arr[i] // 判断className相同或者是空白方块 if (cell.className === arr[index].className || cell.isBlank) { set.add(cell) } // 如果不是空白方块那么终止查找 if (!cell.isBlank) { break }
} // 向前查找 for (let i = index + 1, l = arr.length; i < l; i++) {
let cell = arr[i] if (cell.className === arr[index].className || cell.isBlank) { set.add(cell) } if (!cell.isBlank) { break }
} return set }
**getBeeline** 获得从 start 到 end 的直线路径数组
getBeeline (start, end) { let startIndex let endIndex let arr if (start.row === end.row) {
startIndex = start.col endIndex = end.col arr = this.cellData[start.row]
} else {
startIndex = start.row endIndex = end.row arr = this.cellData.map(e => e[start.col])
} // 判断一下直线的方向,如果是反方向那么将数组reverse return startIndex < endIndex ? arr.slice(startIndex, endIndex + 1) : arr.slice(endIndex, startIndex + 1).reverse() }
**getIntersection** 处理一个拐角的情况,传入两条相垂直的**可连接线**,然后查找其中一条线是否包含另一条线上的某个空节点
getIntersection (prevLine, currLine) { let intersection = null for (let cell of prevLine) {
if (currLine.has(cell) && cell.isBlank) { intersection = cell break }
} return intersection }
**getIntersectionArr** 处理两个拐角的情况,将两个方块同一方向的**可连接线**传入,然后查找是否存在一条**垂直线**能够连通
getIntersectionArr (prev, curr, prevIndex, currIndex, isRow) { let result = [] if (!prev.size || !curr.size) {
return result
} // 判断方向并从地图数据中获取完整的线 let rowKey = isRow ? 'col' : 'row' let prevFullLine = isRow ? this.cellData[prevIndex] : this.cellData.map(e => e[prevIndex]) let currFullLine = isRow ? this.cellData[currIndex] : this.cellData.map(e => e[currIndex])
// 遍历其中一条线 for (let prevCell of prev) {
if (!prevCell.isBlank) continue /* * target 和 prevCell 是两条平行线上的一条垂线的两个端点 * prevCell * ----------•------------ prevFullLine * ┊ * ┊ * ----------•------------ currFullLine * target */ let target = currFullLine[prevCell[rowKey]] // 判断target是否在可连接线中 if (curr.has(target)) { let index = target[rowKey] // 然后检查这条垂线是否是连通的 let isBeeline = this.checkBeeline(prevFullLine[index], currFullLine[index]) if (isBeeline) { // 返回两个端点 result = [prevFullLine[index], currFullLine[index]] break } }
} return result }, // 检查两个点是否连通的 checkBeeline (start, end) { let result = true // 先获取这两个点的连线 let beeline = this.getBeeline(start, end) for (let lineCell of beeline) {
// 然后检查线上是否存在非空节点,如果存在非空节点说明这两点之间是无法连通的 if (!lineCell.isBlank) { result = false break }
} return result }
到此,连接线的三种情况全部处理完毕,我们得到的是一条从 **上一个选中方块** 到 **当前点击的方块** 的连线(一个包含了路径上所有节点的数组),接下来就只剩绘制了。 ## **绘制连接线** 绘制连接线的流程是遍历所有节点,然后根据这个节点的上一个节点与下一个节点的坐标来计算出这个节点上的 lineClass ,然后使用 css 来为连接线添加样式。 具体的连接线 class 可以参考下图。 ![](https://pic3.zhimg.com/v2-6b1d725374106be4643039a1b07a85ba_b.jpg)
drawLine (line) { // 遍历线上的节点 line.forEach((e, i) => {
e.isLine = true // 通过节点的上一个与下一个节点来计算lineClass e.lineClass = this.addLineClass(line[i - 1], e, line[i + 1])
})
// 根据设置中的延迟来隐藏连接线 setTimeout(() => {
this.hideLine(line)
}, this.config.lineDelay) }, // 清空线上的所有连接线 hideLine (line) { line.forEach((e, i) => {
e.isLine = false e.lineClass = ''
}) }, addLineClass (prev, curr, next) { let result if (!prev) {
// 开始节点 result = 'line-start line-' + this.getDirection(curr, next)
} else if (!next) {
// 结束节点 result = 'line-end line-' + this.getDirection(curr, prev)
} else {
result = 'line-' + this.getDirection(curr, prev) + ' line-' + this.getDirection(curr, next)
} return 'line ' + result }, // 判断两个相邻节点的方向 getDirection (curr, next) { return curr.row === next.row ? (curr.col > next.col ? 'l' : 'r') : (curr.row > next.row ? 't' : 'b') }
连连看游戏的核心逻辑到这里就结束了。 ## 结语 新人第一次写文章,感觉好像代码放太多了,真的非常感谢能看完的阅读者... 若有什么错误望大佬们指出。 Utils 中的代码就不放在这里了,有兴趣的可以前往 github 中阅读。 放上 Demo 的 github 地址:[戳我](https://link.zhihu.com/?target=https%3A//github.com/woshiguabi/vue-linklinkup) Demo 中只是实现了基本的游戏逻辑,还有很多东西没有实现,例如计时器与计分板,游戏难度的提高(闯关模式),自定义难度等等。 大家如果有什么印象深刻的经典小游戏可以在评论里面留言哦,也许我会在某天写一篇相关的文章。 update: 2017-6-14 00:03:32:已修改逻辑并将文件传到不用梯子的站点。 > 饥人谷一直致力于培养有灵魂的编程者,打造专业有爱的国内前端技术圈子。如造梦师一般帮助近千名不甘寂寞的追梦人把编程梦变为现实,他们以饥人谷为起点,足迹遍布包括facebook、阿里巴巴、百度、网易、京东、今日头条、大众美团、饿了么、ofo在内的国内外大小企业。 了解培训课程:加微信 [xiedaimala03](https://wiki.jirengu.com/lib/exe/fetch.php?w=400&tok=5c45ca&media=%E9%A5%A5%E4%BA%BA%E8%B0%B7%E4%BC%81%E4%B8%9A%E5%BE%AE%E4%BF%A1%E5%B0%8F%E5%8A%A9%E7%90%86black.png),官网:https://jirengu.com > 本文作者:饥人谷方应杭老师