使用到的库:Vue2,Vant2,Less
前言
一开始在力扣看到扫雷的题,发现内容并不复杂,就想通过这个逻辑写一个单个html页面就能解决的小游戏,使用了Vue2+Vant2,因为内容不多所以没有使用脚手架,所有库直接使用cdn引入,所有功能在一个页面里解决,通过v-if进行Dom节点的渲染。
一、HTML部分
- 顶部信息栏:进入页面后显示请选择难度提示,在选择难度后展示当前难度和进行的时间、剩余的炸弹数量以及当前最高纪录。
<div class="top">
<div v-if="hasChoseLevel" class="top-info">
<div class="top-info__list">
<div>难度:{{ gameInfo.level }}</div>
<div>时间:{{ gameInfo.time }}s</div>
</div>
<div class="top-info__list">
<div>剩余:{{ gameInfo.boomNum - flagNum < 0 ? 0 : gameInfo.boomNum - flagNum }}</div>
<div>最高纪录:{{ gameInfo.record ? `${gameInfo.record}s` : '无' }}</div>
</div>
</div>
<div v-else>请选择难度</div>
</div>
- 主页:进行游戏难度的选择和自定义图标
<div v-if="!hasChoseLevel">
<div class="level">
<div class="level-list" v-for="(item,index) in config" :key="index" @click="choseLevel(item)">
{{`${item.level} (${item.xNum}×${item.yNum})`}}
</div>
</div>
<div class="custom">
<van-button plain type="info" @click="showOverlay = true">自定义棋盘图片</van-button>
</div>
</div>
-
棋盘:根据生成的随机二维数组生成对应难度的棋盘格,并根据格子状态显示对应的样式,底部排列重新开始和插旗和返回主页按钮(这里的数字格子我用了自己的图片,如果没有的话直接使用column.data即可)
<div v-else class="game-content"> <div class="board"> <div v-for="(row,index) in board" class="board-row"> <div v-for="(column,key) in row" :class="['board-column',gameInfo.yNum <= 9?'board-column--small':gameInfo.yNum < 30?'board-column--middle':'board-column--large']" @click="isSetFlag ? setFlag([index,key], column) : isInit ? updateBoard([index,key],column.data) : setBoom([index,key])"> <div :class="['board-column__list',column.isShow ? 'board-column__list--show':'board-column__list--unknown']"> <img v-if="column.data === 'X'" class="board-column__img" :src="boomImg" alt="炸弹"> <img v-else-if="column.data === 'F'" :class="['board-column__img',!isSetFlag ? 'board-column__img--disable' : '']" :src="flagImg" alt="旗帜"> <!-- <div v-else-if="parseInt(column.data) > 0">{{ column.data }}</div>--> <img v-else-if="parseInt(column.data) > 0" class="board-column__img board-column__img--number" :src="`static/images/shuzi${column.data}.svg`" :alt="column.data"> </div> </div> </div> </div> <div class="bottom"> <div class="bottom-button"> <div v-if="!over" :class="['bottom-button__list bottom-button__flag',isSetFlag ? 'bottom-button__flag--active':'']" @click="isSetFlag = !isSetFlag"> 插旗 </div> <div class="bottom-button__list bottom-button__restart" @click="handleRestart">重新开始 </div> <div class="bottom-button__list bottom-button__choose" @click="handleRestart(false)">重新选择难度 </div> </div> <div v-if="over" class="bottom-tips">踩到地雷,游戏结束</div> </div> </div> -
自定义图标的弹出层:弹出一个遮罩层,进行自定义图标的上传和效果预览
<van-overlay :show="showOverlay" @click="showOverlay = false"> <div class="custom-wrapper" @click.stop> <div class="custom-wrapper__upload"> <van-uploader class="custom-wrapper__upload-list" :before-read="beforeRead" :after-read="(file)=> afterRead(file,'boom')" @oversize="onOversize"> <van-button icon="plus" type="default">更换地雷图标</van-button> </van-uploader> <van-uploader class="custom-wrapper__upload-list" :before-read="beforeRead" :after-read="(file)=> afterRead(file,'flag')" @oversize="onOversize"> <van-button icon="plus" type="default">更换旗帜图标</van-button> </van-uploader> <van-button class="custom-wrapper__upload-list" icon="replay" type="default" @click="resetImg">还原默认图标 </van-button> </div> <div class="custom-wrapper__preview"> <div class="custom-wrapper__preview-title">预览效果</div> <div class="custom-wrapper__preview-board"> <div class="board-row"> <div :class="['board-column','board-column--small']"> <div :class="['board-column__list','board-column__list--unknown']"> </div> </div> <div :class="['board-column','board-column--small']"> <div :class="['board-column__list','board-column__list--show']"> <img class="board-column__img" :src="boomImg" alt="炸弹"> </div> </div> <div :class="['board-column','board-column--small']"> <div :class="['board-column__list','board-column__list--unknown']"> <img class="board-column__img" :src="flagImg" alt="旗帜"> </div> </div> </div> </div> </div> <div class="custom-wrapper__tips"> 请上传图片(建议使用白色或透明底的正方形图片)<br/> 注意:清除缓存会还原设置的图片 </div> </div> </van-overlay>
二、CSS样式(LESS)
// 自定义遮罩层
.custom {
text-align: center;
margin: 20px auto;
&-wrapper {
width: 300px;
margin: 30px auto;
padding: 20px;
background: #fff;
border-radius: 4px;
&__upload {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
margin: 10px 0;
&-list {
margin: 5px 0 !important;
}
}
&__tips {
margin-top: 30px;
font-size: 12px;
color: #606266;
text-align: center;
}
&__preview {
margin-top: 30px;
text-align: center;
&-title {
margin: 10px;
}
}
}
&-cropper {
width: 500px;
height: 500px;
}
}
//顶部
.top {
margin: 10px auto;
color: #222;
font-weight: bold;
max-width: 370px;
font-size: 14px;
&-info {
display: flex;
flex-direction: column;
&__list {
display: flex;
justify-content: space-between;
padding: 0 10px;
flex: 1;
}
}
}
//棋盘
.board {
max-width: 370px;
background: whitesmoke;
padding: 10px;
margin: 10px auto;
&-row {
display: flex;
overflow: hidden;
justify-content: center;
}
&-column {
display: flex;
justify-content: center;
align-items: center;
//flex: 1;
margin: 1px;
background: gainsboro;
overflow: hidden;
&--small {
width: 38px;
height: 38px;
}
&--middle {
width: 20px;
height: 20px;
font-size: 12px;
}
&--large {
width: 20px;
height: 20px;
font-size: 12px;
}
&__list {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
overflow: hidden;
&--unknown {
cursor: pointer;
}
&--show {
box-sizing: border-box;
background: #fff;
box-shadow: gainsboro 2px 2px inset;
padding: 2px 0 0 2px;
}
}
&__img {
width: 85%;
&--disable {
cursor: auto;
}
}
}
}
//底部
.bottom {
margin-top: 20px;
&-tips {
margin-top: 20px;
text-align: center;
color: #aaa;
font-size: 24px;
font-weight: 600;
}
&-button {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
&__list {
display: inline-block;
width: 130px;
height: 44px;
line-height: 44px;
text-align: center;
white-space: nowrap;
cursor: pointer;
background: #fff;
border: 1px solid #dcdfe6;
color: #606266;
box-sizing: border-box;
outline: none;
margin: 10px;
transition: .1s;
font-weight: 500;
font-size: 14px;
border-radius: 4px;
}
&__restart {
color: #fff;
background: #909399;
}
&__flag {
color: #fff;
background-color: #409eff;
border-color: #409eff;
&--active {
color: #409eff;
background: #ecf5ff;
border-color: #b3d8ff;
}
}
}
}
//等级
.level {
display: flex;
align-items: center;
flex-direction: column;
justify-content: space-around;
max-width: 360px;
margin: 10px auto;
padding: 16px;
border: 2px dashed #409eff;
&-list {
display: inline-block;
width: 200px;
height: 30px;
line-height: 30px;
margin: 10px;
white-space: nowrap;
cursor: pointer;
-webkit-appearance: none;
text-align: center;
box-sizing: border-box;
outline: none;
transition: .1s;
font-weight: 500;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
font-size: 14px;
border-radius: 20px;
color: #fff;
background-color: #409eff;
border-color: #409eff;
}
}
三、Data数据
- 初始化的数据
created() {
// 判断本地缓存中是否已上传自定义图标
['boom', 'flag'].forEach((item) => {
if (localStorage.getItem(`${item}_img`)) {
this[`${item}Img`] = localStorage.getItem(`${item}_img`);
}
})
},
- Data内
data() {
return {
showOverlay: false,// 遮罩层开关
hasChoseLevel: false, // 难度选择页面开关
boomImg: 'static/images/icon_boom1.svg',
flagImg: 'static/images/flag.svg', // 默认
//难度配置
config: [{
alias: 'easy',
level: '青铜',
xNum: 9, // 列数
yNum: 9, // 行数
boomNum: 10, // 炸弹数
}, {
alias: 'middle',
level: '白银',
xNum: 16,
yNum: 16,
boomNum: 40,
}, {
alias: 'hard',
level: '黄金',
xNum: 16,
yNum: 30,
boomNum: 99,
}],
// 难度评级配置
scoreLevel: {
easy: {
0.49: '100',
40: '99',
70: '88',
90: '80',
110: '77',
180: '66',
240: '55',
500: '35',
800: '15',
1000: '10',
1300: '1'
},
// 452 293
middle: {
7.03: '100',
250: '99',
500: '88',
800: '77',
1000: '66',
1200: '55',
1500: '35',
2000: '15',
2500: '10'
},
hard: {
31.13: '100',
500: '99',
800: '88',
1200: '77',
1800: '66',
2300: '55',
3000: '35',
5000: '15',
8000: '10'
}
},
// 当前游戏信息
gameInfo: {
alias: 'easy',
level: '青铜', // 难度等级
time: 0, // 时间
record: undefined,// 最高纪录
xNum: 9, // 列数
yNum: 9, // 行数
boomNum: 10, // 炸弹数
},
flagNum: 0, // 旗帜数
isInit: false, // 保证第一个点击的格子不是雷
board: [], // 棋盘
over: false, // 游戏是否结束
isSetFlag: false,// 是否插旗状态
timer: undefined,// 计时器
}
},
- 计算属性
computed: {
// 计算已翻开的格子数 当翻开的格子数=安全的格子数时游戏胜利
showCount() {
const {board} = this;
const arr = board.flat();
let num = 0;
arr.forEach((item) => {
if (item.isShow) {
num++
}
})
return num
},
// 当前难度的非雷格子数
saveNum() {
const {gameInfo: {xNum, yNum, boomNum}} = this;
return yNum * xNum - boomNum
},
},
- 监听
watch: {
// 监听翻开的格子数,判断游戏是否结束
showCount() {
const {gameInfo: {time, alias, record}, over, showCount, saveNum, scoreLevel} = this;
if (over) return; // 处理最后一个点击到炸弹的异常情况
if (showCount === saveNum) {
// 停止计时
clearInterval(this.timer)
// 击败玩家百分比
let percent = '1';
for (const key in scoreLevel[alias]) {
console.log(key)
if (parseFloat(time) < key) {
percent = scoreLevel[alias][key]
console.log(key, '是这里', percent)
break
}
}
// 判断是否为最高纪录
if (!record || parseFloat(time) < parseFloat(record)) {
localStorage.setItem(`${alias}_record`, time)
this.gameInfo.record = time;
}
// 弹出对话框
vant.Dialog({
message: `恭喜用时${time}秒挑战成功!\n击败了${percent}%的玩家!\n您的最高纪录:${this.gameInfo.record}秒`,
confirmButtonColor: '#409eff',
confirmButtonText: '重新开始',
showCancelButton: true,
cancelButtonText: '返回主页'
}).then(() => {
// 重新开始
this.restart();
}).catch(() => {
// 返回主页
this.setLevelPage();
});
}
}
},
四、methods
- 选择难度:将选择的难度配置赋值到当前游戏信息上,并获取最高记录
/**
* 选择难度
*
* @param {Object} item 选择的难度数据
*/
choseLevel(item) {
const {alias} = item;
this.gameInfo = {
...this.gameInfo,
...item,
record: localStorage.getItem(`${alias}_record`)
}
this.hasChoseLevel = true;
this.restart();
},
- 开始游戏:根据难度生成指定二维数组(全部不为炸弹,避免出现第一个点击的格子为炸弹的情况),初始化所有数据,开始执行定时器计时。
// 开始/重新开始
restart() {
const {yNum, xNum} = this.gameInfo;
this.board = new Array(yNum).fill(new Array(xNum).fill({
data: '-1',
isShow: false,
isBoom: false
}));
[this.isInit, this.over, this.isSetFlag, this.gameInfo.time, this.flagNum] = [false, false, false, 0, 0];
this.timer = setInterval(() => {
const num = parseFloat(this.gameInfo.time) + 0.01
this.gameInfo.time = num.toFixed(2)
}, 10)
}
- 第一下点击棋盘后,初始化棋盘,生成指定数目炸弹并随机放置在除当前点击的格子外的位置
/**
* 修改坐标数据
*
* @param {String|Number} x 需要修改的x坐标
* @param {String|Number} y 需要修改的y坐标
* @param {Object} data 改变后的数据
*/
editBoard(x, y, data) {
const {board} = this;
const row = [...board[y]];// 获取那一行的数据
row.splice(x, 1, data) // 修改
this.$set(board, y, row)
},
/**
* 初始化棋盘
* @return {Promise} Promise
* @param {Array} click 点击的坐标
*
**/
setBoom(click) {
const {gameInfo: {xNum, yNum, boomNum}} = this;
const [cY, cX] = click;
const dx = [], dy = [];
//生成炸弹
while (dx.length < boomNum) {
const randomX = Math.round(Math.random() * (xNum - 1));//获取一个范围内的随机数
const randomY = Math.round(Math.random() * (yNum - 1));//获取一个范围内的随机数
const isClick = (randomX === parseInt(cX)) && (randomY === parseInt(cY));// 是
if (!isClick && (dx.indexOf(randomX) === -1 || dx.indexOf(randomX) !== dy.ind
// 如果没有重复且不是点击的坐标则推入
dx.push(randomX)
dy.push(randomY)
}
}
// 修改炸弹数据
for (let i = 0; i < dx.length; i++) {
// console.log(JSON.stringify([dx[i]][dy[i]]))
const obj = {
data: '-1',
isShow: false,
isBoom: true //是炸弹
}
this.editBoard(dx[i], dy[i], obj);
}
this.isInit = true;
this.updateBoard(click);
},
- 根据当前点击的格子信息更新格子状态,如果周围都没有雷则递归其它格子进行展开(根据Leecode题的基础逻辑进行了一些修改)
/**
* 更新格子状态
*
* @param {Array} click 点击的坐标
* @param {Object} data 点击坐标数据值
*/
updateBoard(click, data = undefined) {
const {board, over, gameInfo: {xNum, yNum}} = this;
if (over || data === 'F') return;// 如果游戏已经结束或为旗帜 直接返回
const dx = [1, -1, 0, 0, -1, 1, -1, 1]; // 横坐标
const dy = [0, 0, 1, -1, 1, -1, -1, 1]; // 纵坐标
const inBound = (x, y) => x >= 0 && x < xNum && y >= 0 && y < yNum; /
const update = (x, y) => {
if (!inBound(x, y) || board[y][x].isShow || board[y][x].data ===
let count = 0;
for (let i = 0; i < 8; i++) { // 统计周围雷的个数
const nX = x + dx[i];
const nY = y + dy[i];
if (inBound(nX, nY) && board[nY][nX].isBoom) {
count++;
}
}
if (count === 0) { // 如果周围没有雷,翻开且标记0,递归
const obj = {
data: '0',// 0翻开的空格子
isShow: true,// 已翻开
isBoom: false // 不是炸弹
}
this.editBoard(x, y, obj)
for (let i = 0; i < 8; i++) {
update(x + dx[i], y + dy[i]);
}
} else {
const obj = {
data: count + '',// 数字1-9附近有炸弹
isShow: true,// 已翻开
isBoom: false // 不是炸弹
}
this.editBoard(x, y, obj)
}
};
const [cY, cX] = click;
if (board[cY][cX].isBoom) { // 踩雷了
const obj = {
data: 'X',// X炸弹
isShow: true,// 已翻开
isBoom: true // 是炸弹
};
this.editBoard(cX, cY, obj);
this.over = true;
clearInterval(this.timer)
} else {
update(cX, cY); // 开启dfs
}
}
- 开启插旗后的更新逻辑
/**
* 插旗
*
* @param {Array} click 点击的坐标
* @param {Object} column 点击坐标数据
*/
setFlag(click, column) {
const [cY, cX] = click;
const {data, isBoom, isShow} = column;
if (isShow) return; // 如果这个格子已经翻开 直接返回
const obj = {
data: 'F',// F旗帜
isShow: false,// 未翻开
isBoom: isBoom // 炸弹
};
if (data === 'F') {
obj.data = '-1'; // 未翻开的空格子
this.flagNum -= 1
} else {
this.flagNum += 1
}
this.editBoard(cX, cY, obj);
},
- 重新开始,重新选择难度
// 打开难度选择页面
setLevelPage() {
clearInterval(this.timer); // 清除定时器
this.hasChoseLevel = false;
},
// 重新开始确认
handleRestart(isRestart = true) {
// 停止计时
clearInterval(this.timer)
if (this.over) {
if (isRestart) {
// 重新开始
this.restart();
} else {
// 返回主页
this.setLevelPage();
}
} else {
// 弹出对话框
vant.Dialog({
message: `确认要重新${isRestart ? '开始' : '选择难度'}吗?`,
confirmButtonColor: '#409eff',
confirmButtonText: '确认',
showCancelButton: true,
cancelButtonText: '取消'
}).then(() => {
if (isRestart) {
// 重新开始
this.restart();
} else {
// 返回主页
this.setLevelPage();
}
}).catch(() => {
// 重新计时
this.timer = setInterval(() => {
const num = parseFloat(this.gameInfo.time) + 0.01
this.gameInfo.time = num.toFixed(2)
}, 10)
});
}
},
- 上传图标:将上传的图片存入本地缓存,如果图片太大则压缩图片再进行上传
// 返回布尔值
beforeRead(file) {
if (file.type.indexOf('image') === -1) {
vant.Toast('请上传正确的图片');
return false;
}
return true;
},
// 上传回调
afterRead(files) {
const {file} = files;
console.log(files, file)
// 保存图片到本地缓存
let canvas = document.createElement('canvas') // 创建Canvas对象(画布)
let context = canvas.getContext('2d')
let img = new Image()
img.src = files.content
img.onload = () => {
canvas.width = 100
canvas.height = 100
context.drawImage(img, 0, 0, canvas.width, canvas.height)
if (file.size > 2 * 1024) {
//如果图片大小大于2M
vant.Toast.loading({
message: '正在压缩图片...',
forbidClick: true,
});
files.content = canvas.toDataURL(file.type)
localStorage.setItem(`${string}_img`, files.content)
this[`${string}Img`] = files.content;
vant.Toast('上传成功');
} else {
localStorage.setItem(`${string}_img`, files.content)
this[`${string}Img`] = files.content;
vant.Toast('上传成功');
}
}
},
五、预览效果
总结
以上就是一个简易的可自定义图标的扫雷小游戏,本文仅仅简单介绍了内容逻辑和方法,样式已经做自适应,完整代码可以在github中查看,有些地方做的可能也不够精细,如果有什么更好的建议和想法欢迎提出。