【游戏】2048plus-vue3

703 阅读5分钟

我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

前言

恰逢春之四月,天气忽热忽凉,遇游戏大赛,以笨拙之技,书一篇小文。

游戏规则:2048。

环境

主要环境:

vue3 version:3.2.4
vite version:2.5.0

主要插件:

windicss version:3.5.1
mitt version:3.0.0

预览:vaggchen.github.io/2048plus/

代码地址:github.com/vaggchen/20…

运行如图:

2048.gif

思路

  1. 搭建环境,下载依赖
  2. 运行项目
  3. 利用windicss主体兼容pc和移动端 姑且认为小于1024的是平板或者手机 lg(1024px)
App.vue
<div class="relative w-full h-full lg:(w-750px h-800px)">
    <Game />
</div>
  1. 主体Game.vue设置3个主体组件:GameTool.vue游戏工具栏(重新开始和得分统计&最高分)、GameCnt.vue游戏主体、GameOver.vue游戏结束

实现

GameOver

提示 游戏结束 且 输出最终得分

image.png

GameTool

  1. Logo
  2. 重新开始
  3. 得分
  4. 最高分 使用的是本地持久缓存

logo来源百度 ,重新开始按钮来源iconFont

GameCnt

布局

  1. 先画出4*4的格子,这里有多种方法,笔者这里采取最简单的动态grid布局实现,
  2. 宽高获取,这里要获取,原因是想让宽高一致,但每个手机不一样,所以使用的动态获取
  3. 设置lazyShow,让第一次渲染不会有transform动画
// 定义行个数
const rowLen = 4
// 定义cnt宽高和item的宽高
// 定义cnt
const cnt = ref(null)
const cntWidth = ref(0)
const itemWidth = ref(0)
// lazy show
const lazyShow = ref(true)

//获取dom和渲染
onMounted(() => {
  getCntWidth()
  lazyShow.value = false
})
// 获取宽高
const getCntWidth = () => {
  cntWidth.value = cnt.value.clientWidth
  itemWidth.value = (cnt.value.clientWidth - 2 * 16) / rowLen
}

<!-- 展示底部格子 -->
<div v-show="!lazyShow" v-for="(item, index ) in boxLists" class="box rounded-md "
  :style="{ width: itemWidth + 'px', height: itemWidth + 'px' }">
</div>

image.png

实现随机格子

2048一般来说都是有随机生成2/4值的格子

  1. 生成随机数 通过Math.random() > 0.7 ? 2 : 4,这个数值越接近1越难,因为2越多,后期越难处理
  2. 生成随机序号
    • 这个地方我的第一版使用的是Math.random()生成随机序号,判断随机序号所在值是否为0,不是则重新来过但其实有很大的效率问题
    • 第二版使用的是数组的filter方法筛选出所有为0的格子,然后使用Math.random()和当前筛选出数组长度生成一个随机序号,再返回真实的序号即可,如代码所示
// 生成 2或4 
const randomNums = () => {
  console.log('randomNums')
  
  ...略
  
  // 生成随机数 生成2的概率越大越难
  const num = Math.random() > 0.7 ? 2 : 4

  // 生成随机序号的方法
  const createIndex = () => {
    // 筛选剩余空位
    let arr = valLists.value.filter(item => item.val === 0)
    console.log(arr)
    // 随机一个序号
    const index = parseInt(Math.random() * arr.length)
    // 返回真实的序号值
    return arr[index].originKey
  }
  // 生成随机序号
  const index = createIndex()
  // 放入数组
  valLists.value[index] = {
    val: num,
    originKey: index,
    left: (index % rowLen) * (itemWidth.value + 10),
    top: parseInt(index / rowLen) * (1 / rowLen) * cntWidth.value,
  }
}

判断是否游戏结束

2048游戏结束有两个条件

  1. 盘面没有空位

    • valLists.value.every(item => item.val !== 0)即可
  2. 每个数字上下左右都不相同

    • 必须判断每个数值上下左右是否相同,这个地方因为时间问题,暂时没做优化了,其实这个地方有判断重复的
  3. 需要2次判断

    • 在移动后生成随机数前判断一次
    • 在生成随机数后判断一次 核心代码:
    const isGameOver = () => {
  // 如果不存在0时
  if (valLists.value.every(item => item.val !== 0)) {
    // 且每一个元素上下左右都不同
    let noSame = true
    for (let i = 0; i < rowLen * rowLen; i++) {
      // 该元素之上的值如果相等 则跳出
      let topInxdex = i - rowLen;
      if (topInxdex > -1 && (valLists.value[i].val === valLists.value[topInxdex].val)) {
        noSame = false
        break
      }

      // 该元素之下的值如果相等 则跳出
      let botInxdex = i + rowLen;
      if (botInxdex < valLists.value.length && (valLists.value[i].val === valLists.value[botInxdex].val)) {
        noSame = false
        break
      }

      // 该元素之左的值如果相等 则跳出
      let leftInxdex = i - 1;
      if (leftInxdex > -1 && (parseInt(i / rowLen) === parseInt(leftInxdex / rowLen)) && (valLists.value[i].val === valLists.value[leftInxdex].val)) {
        noSame = false
        break
      }

      // 该元素之右的值如果相等 则跳出
      let rightInxdex = i + 1;
      if (rightInxdex < valLists.value.length && (parseInt(i / rowLen) === parseInt(rightInxdex / rowLen)) && (valLists.value[i].val === valLists.value[rightInxdex].val)) {
        noSame = false
        break
      }
    }
    if (noSame) {
      return true
    }

  }
  return false
}

实现滑动监听

相关代码位于src/utils/listenTouch.js

  1. 先监听touchstart(监听一定要带上参数{passive: false},加上e.preventDefault()阻止浏览器默认),记录触屏开始的pageX和pageY

阻止默认好处是,移动端浏览器滑动时不会触发浏览器的滑动监听,导致页面乱跑
debounce的加入使得滑动不会触发的太频繁

    document.addEventListener("touchstart", debounce(function(e) {
      // 阻止浏览器默认
        e.preventDefault()
        startx = e.touches[0].pageX;
        starty = e.touches[0].pageY;
    }), {passive: false});
  1. 监听手指离开屏幕touchend,再根据触屏离开和触屏开始的点的坐标差,计算出滑动角度(不太精确的一种方案) ,然后根据角度可以通知游戏主体滑动方向,这里使用的是mitt事件线程通知 核心代码:
var angle = getAngle(angx, angy);
//获得角度
function getAngle(angx, angy) {
    return Math.atan2(angy, angx) * 180 / Math.PI;
};
if (angle >= -135 && angle <= -45) {
    result = 1;
} else if (angle > 45 && angle < 135) {
    result = 2;
} else if ((angle >= 135 && angle <= 180) || (angle >= -180 && angle < -135)) {
    result = 3;
} else if (angle >= -45 && angle <= 45) {
    result = 4;
}
  1. 调用事件触发
var direction = getDirection(startx, starty, endx, endy);
        switch (direction) {
            case 0:
                // alert("未滑动!");
                break;
            case 1:
                // alert("向上!")
                EventBus.$emit('touchToTop')
                break;
            case 2:
                // alert("向下!")
                EventBus.$emit('touchToBot')
                break;
            case 3:
                // alert("向左!")
                EventBus.$emit('touchToLeft')
                break;
            case 4:
                // alert("向右!")
                EventBus.$emit('touchToRight')
                break;
            default:
        }

画出含值方块儿

这个地方想有个动画,就使用的是transform实现,于是格子需要item.moveIndex确定位置

// 定义数组
const valLists = ref([])
valLists.value = new Array(rowLen * rowLen).fill(0).map((item, index) => ({
    val: 0,
    moveIndex: index
  }))

 <!-- 展示值 -->
    <div v-show="!lazyShow" v-for="(item, index ) in valLists"
      class="val rounded-md absolute flex justify-center items-center p-2"
      :class="[item.val ? getClass(item.val) : 'hide', item.class]" :style="{
        left: `${item.left}px`, top: `${item.top}px`, width: itemWidth + 'px', height: itemWidth + 'px'
      }">
      <!-- {{ item.val ? item.val : '' }} -->
      <img :src="getSrc(item.val)" alt="">
    </div>

含值方块移动

这个地方是本游戏的核心,上下左右都差不多,主要是方法问题

  1. 使用纵向遍历,遍历每一层
  2. 寻找可以合并的值,进行数字移动动画,即滑动元素的left改变,设置条件取消动画和滑动元素left的还原和值重置为空,被合并元素值加倍
  3. 使用arr记录每一层的数值
  4. 根据方向进行去0规整数组
    • 例向左则:[0, 4, 0, 4] => [4, 4, 0, 0]
  5. 然后动画将每一层的方块进行动画调整左坐标或者上坐标

使用requestAnimationFrame完成动画效果,cancelAnimationFrame取消动画

// 监听滑动向左
EventBus.$on('touchToLeft', () => {
  console.log('touchToLeft')
  let flag = arrToLeft()
  continueGame(flag)
})

// 数组向左的事件
const arrToLeft = () => {
  // 默认没有发生移动
  let isMove = false
  // 遍历
  for (let i = 0; i < rowLen; i++) {
    let arr = new Array(rowLen).fill(0)
    for (let j = 0; j < rowLen - 1; j++) {
      // 获取真实数组的序号
      let index = i * rowLen + j

      // 获取当前
      let tempItem = valLists.value[index]
      // 获取当前下一个
      let tempItemNext = valLists.value[index + 1]
      arr[j] = tempItem.val
      arr[j + 1] = tempItemNext.val

      // 判断能否合并
      if (tempItem.val === tempItemNext.val && tempItem.val !== 0) {

        let animate
        // 动画
        const animLoop = () => {
          tempItemNext.left -= (tempItemNext.left - tempItem.left) / addRatio
          animate = window.requestAnimationFrame(animLoop)
          // 结束循环条件 ,偏移量是为了纠正移动轨迹
          if (tempItemNext.left <= tempItem.left + addOffset) {
            // 取消动画
            cancelAnimationFrame(animate)

            // 动画后赋值
            tempItemNext.left = (tempItemNext.originKey % rowLen) * (itemWidth.value + 10)
            tempItemNext.val = 0
            tempItem.val = tempItem.val * 2
            tempItem.class = 'mix'
          }
        }
        tempItem.class = ''
        animLoop()

        arr[j] = tempItem.val * 2
        arr[j + 1] = 0

        // 合并即发生了移动
        isMove = true
        // 分数改变事件
        scoreChange(arr[j])
        j++
        // 如果和为rowLen - 2,弥补边界问题
        if (j === rowLen - 2) {
          arr[j + 1] = valLists.value[i * rowLen + j + 1].val
        }
      }

      // 末尾开始移动0位的
      if (j === rowLen - 2 || j === rowLen - 1) {
        console.log(arr)
        let sort = 0
        arr.forEach((it, zIndex) => {
          // 记录新的zIndex
          let index2 = i * rowLen + zIndex
          // 记录当前对象
          let newItem = valLists.value[index2]
          // 如果下标不匹配,且arr[zIndex]当前不为0
          if (sort !== zIndex && it !== 0) {
            // 是否发生移动
            isMove = true
          }
          if (it !== 0) {
            // 存贮目的地index
            let lastIndex = i * rowLen + sort
            // 存贮目的地左距离
            let lastLeft = valLists.value[lastIndex].left
            let animate
            // 动画
            const animLoop = () => {
              newItem.left -= (newItem.left - lastLeft) / moveRatio
              animate = window.requestAnimationFrame(animLoop)
              // 结束循环条件 ,偏移量是为了纠正移动轨迹
              if (newItem.left <= lastLeft + moveOffset) {
                // 解除动画
                cancelAnimationFrame(animate)

                // 渲染正确的值并恢复之前的渲染
                newItem.left = (newItem.originKey % rowLen) * (itemWidth.value + 10)
                newItem.val = 0
                valLists.value[lastIndex].val = it

              }
            }
            // 如果发生序号错位情况,要么移动,要么合并了,再执行动画
            sort !== zIndex && animLoop()
            sort++
          }
        })

      }
    }
  }

  return isMove
}

注:有想法可以沟通下,一起提升!

使用图片替换数值

使用map对象键值对应设定的,暂时不考虑大于4096的,应该没人这么厉害吧!


// 生成对应src
const getSrc = (val) => {
  let map = {
    2: haicaoPng,
    4: xiaPng,
    8: xiaoyuPng,
    16: jinliyuPng,
    32: wuguiPng,
    64: shuimuPng,
    128: zhangyuPng,
    256: haitunPng,
    512: shayuPng,
    1024: jingyuPng,
    2048: longPng,
    4096: supermanPng,
  }
  return val in map ? map[val] : '-'
}

最后

GameCnt 可以写一些测试代码,方便测试下所有块级颜色啥的,上线记得注释

GameCnt 的块级效果优化过,否则只有颜色,不太好看

Game.tool 已加入提示功能

listenTouch加入debounce防止事件频繁触发

动画使用的window.requestAnimationFrame,这个动画也做了兼容处理

整体难度中等,可以练习下vue3,以及游戏思维

笔者比较菜,感觉游戏动画还有问题,欢迎各位大佬建议和试玩!