Vue2+Vant2:一个可定制图标的简易扫雷小游戏

304 阅读4分钟

使用到的库: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('上传成功');
            }
        }
    },

五、预览效果 

image.png

image.png


总结

以上就是一个简易的可自定义图标的扫雷小游戏,本文仅仅简单介绍了内容逻辑和方法,样式已经做自适应,完整代码可以在github中查看,有些地方做的可能也不够精细,如果有什么更好的建议和想法欢迎提出。