Vue3使用hooks实现一个简单的贪食蛇小游戏

先看看游戏界面

小游戏非常简单,就不放游戏效果了

实现的逻辑是贪食蛇的每一个节点,都存储在数组里

ini 复制代码
type MoveListType = Array<Record<'left' | 'top', number>>
const moveList = ref<MoveListType>([])

然后遍历在页面上

arduino 复制代码
return () => {
  return (
    <>
      {props.moveList.map(
        (item: Record<'left' | 'top', number>, index: number) => {
          return (
            <div
              class="fighter"
              style={[
                `left:${item.left}px;top:${item.top}px`,
                `width:${props.fighterSize.width}px;height:${props.fighterSize.height}px`,
                `background-color: ${
                  index % 2 === 1 ? '#b5b7b7' : '#06a182'
                }`
              ]}
            ></div>
          )
        }
      )}
    </>
  )
}
scss 复制代码
const { moveList, onEatFood } = useWatchMove(
  snakeLength,
  foodLeft,
  foodTop
)

onEatFood(setFood)

useWatchMove就是我们检测贪食蛇移动,是否碰撞自身的hooks,传入的参数有蛇的长度,食物的坐标

scss 复制代码
const onEatFood = (callback: () => void) => {
    watchEffect(() => {
      moveList.value.unshift({ left: left.value, top: top.value })
      if (moveList.value.length > snakeLength.value) {
        // 如果超过蛇的长度,截取掉
        moveList.value.splice(snakeLength.value)
      }
      // 检查重复位置
      if (isDetectCollisionItself && checkDuplicates(moveList.value)) {
        failMsg.value = '咬到自己!'
      }
      // 检测是否进食
      const isObjectInArray = moveList.value.some(
        (item) => item.left === foodLeft.value && item.top === foodTop.value
      )
      if (isObjectInArray) {
        // 蛇的长度加一
        snakeLength.value += 1
        callback()
      }
    })
 }

onEatFood是导出的一个函数,用来在贪食蛇进食后执行相应的操作,这里进食后执行setFood生成新的食物

ini 复制代码
  // 生成食物
  const generateRandomNumberInRange = (maxValue: number): number => {
    // 生成随机小数,范围在0到1之间
    const randomDecimal = Math.random()
    const randomNumber =
      Math.floor(randomDecimal * ((maxValue - 10) / 10 + 1)) * 10
    return randomNumber
  }

  const setFood = () => {
    foodLeft.value = generateRandomNumberInRange(wrapSize.width)
    foodTop.value = generateRandomNumberInRange(wrapSize.height)
  }

useKeyDown是键盘事件的hooks

typescript 复制代码
export const useKeyDown = () => {
  const onKeyDown = (callback: (direction: Direction) => void) => {
    // 键盘操作
    const handleKeyPress = (event: KeyboardEvent) => {
      if (['w', 'a', 's', 'd'].includes(event.key)) {
        const keyMap: Record<string, Direction> = {
          w: 'up',
          a: 'left',
          s: 'down',
          d: 'right'
        }
        const direction: Direction = keyMap[event.key] || ''
        callback(direction)
      }
    }
    // 当组件挂载时添加事件监听器
    onMounted(() => {
      window.addEventListener('keydown', handleKeyPress)
    })
    // 当组件卸载时移除事件监听器
    onUnmounted(() => {
      window.removeEventListener('keydown', handleKeyPress)
    })
  }
  return {
    onKeyDown
  }
}

导出onKeyDown的回调函数

scss 复制代码
    // 方向
    const direction = ref<Direction>('')
    const { onKeyDown } = useKeyDown()
    onKeyDown((code: Direction) => {
      direction.value = code as Direction
      // 控制贪吃蛇方向
      if (!failMsg.value) {
        getDirection(direction.value)
      }
    })

useSetMove,用来设置贪食蛇的移动,检测是否到达边界,并且在撞到边界的时候弹出游戏失败的信息

scss 复制代码
  // 设置移动
  const getDirection = (val: Direction) => {
    if (val === 'left') {
      leftMove()
    } else if (val === 'right') {
      rightMove()
    } else if (val === 'up') {
      upMove()
    } else if (val === 'down') {
      downMove()
    }
  }
  
  // 设置计时器开启,然后如果配置了自动开启游戏,就随机一个方向开始
  const onRandomDirection = (callback: () => void) => {
    callback()
    if (gameConfig.autoPlay) {
      const randomNumber = Math.random()
      if (randomNumber < 0.25) {
        leftMove()
      } else if (randomNumber < 0.5) {
        rightMove()
      } else if (randomNumber < 0.75) {
        downMove()
      } else {
        upMove()
      }
    }
  }

贪食蛇的大小,速度,移动的距离都配置在config.data中

csharp 复制代码
difficulty: [
    [4000, 20],
    [3500, 30],
    [3000, 40],
    [2500, 50],
    [2000, 60],
    [1500, 70],
    [1000, 80],
    [500, 90],
    [0, 100]
  ]

这个数组,是用来设置游戏的难度,随着分数的增加,贪食蛇的速度也在增加,数组中左边是分数,右边是移动10像素需要的毫秒数

scss 复制代码
// 设置游戏难度
watch(
  () => score.value,
  () => {
    for (let i = 0; i < gameConfig.difficulty.length; i++) {
      if (score.value >= gameConfig.difficulty[i][0]) {
        speed.value = gameConfig.difficulty[i][1]
        break // 找到匹配的速度后退出循环
      }
    }
  }
)

所有的代码如下

index.tsx 复制代码
/*
 * @Date: 2023-11-20 15:42:54
 * @LastEditTime: 2023-11-21 15:04:01
 * @Description: 贪食蛇主页
 * @FilePath: /zzy/src/views/Test/SnakeGame/index.tsx
 */
import {
  defineAsyncComponent,
  defineComponent,
  watch,
  ref,
  computed
} from 'vue'
import './index.scss'
import { useSetMove } from './hooks/useSetMove'
import { useWatchMove } from './hooks/useWatchMove'
import { useKeyDown } from './hooks/useKeyDown'
import { useSetFood } from './hooks/useSetFood'
import { useTimer } from './hooks/useTimer'

import { wrapSize, fighterSize, gameConfig } from './config.data'
import { Direction, FailMsg } from './types'

export default defineComponent({
  components: {
    moveTarget: defineAsyncComponent(() => import('./components/moveTarget'))
  },
  props: {},
  emits: [''],
  setup() {
    // 蛇的长度
    const snakeLength = ref(1)
    // 蛇的速度
    const speed = ref(100)
    // 错误提示
    const failMsg = ref<FailMsg>('')

    const { start, stop, time } = useTimer()

    // 方向
    const direction = ref<Direction>('')
    const { onKeyDown } = useKeyDown()
    onKeyDown((code: Direction) => {
      direction.value = code as Direction
      // 控制贪吃蛇方向
      if (!failMsg.value) {
        getDirection(direction.value)
      }
    })

    // 控制贪吃蛇移动,位置
    const {
      left,
      top,
      getDirection,
      StopMoving,
      setInitalPosition,
      onRandomDirection,
      startDirection
    } = useSetMove(fighterSize, wrapSize, failMsg, speed)

    onRandomDirection(() => {
      start()
    })

    const { foodLeft, foodTop, setFood } = useSetFood()

    // 检测移动路径设置长度 碰撞检测 吞食检测
    const { moveList, onEatFood } = useWatchMove(
      snakeLength,
      left,
      top,
      failMsg,
      foodLeft,
      foodTop
    )

    onEatFood(setFood)

    /**
     * @description: 重新开始
     * @Date: 2023-11-21 10:17:45
     */
    const restart = () => {
      failMsg.value = ''
      snakeLength.value = 1
      startDirection.value = ''
      start()
      setInitalPosition()
      setFood()
      onRandomDirection(() => {
        start()
      })
    }

    watch(
      () => failMsg.value,
      () => {
        if (failMsg.value) {
          stop()
          StopMoving()
        }
      }
    )

    // 计算分数
    const score = computed(() => {
      return snakeLength.value === 1 ? 0 : (snakeLength.value - 1) * 100
    })

    // 设置游戏难度
    watch(
      () => score.value,
      () => {
        for (let i = 0; i < gameConfig.difficulty.length; i++) {
          if (score.value >= gameConfig.difficulty[i][0]) {
            speed.value = gameConfig.difficulty[i][1]
            break // 找到匹配的速度后退出循环
          }
        }
      }
    )

    return () => {
      return (
        <div class="game-page">
          <div class="game-msg" style={[`width:${wrapSize.width}px;`]}>
            <div>游戏时间:{time.value}</div>
            <div>游戏分数:{score.value}</div>
          </div>
          <div
            class="game-wrap"
            style={[`width:${wrapSize.width}px;height:${wrapSize.height}px`]}
          >
            {/* 错误提示 */}
            {failMsg.value ? (
              <div class="fail-msg">
                <p>游戏失败:{failMsg.value}</p>
                <p>分数:{score.value}分</p>
                <el-button
                  onClick={() => {
                    restart()
                  }}
                >
                  重新开始
                </el-button>
              </div>
            ) : (
              ''
            )}
            {/* 贪吃蛇 */}
            <moveTarget moveList={moveList.value} fighterSize={fighterSize} />
            {/* 食物 */}
            <div
              class="food"
              style={[
                `left:${foodLeft.value}px;top:${foodTop.value}px`,
                `width:${fighterSize.width}px;height:${fighterSize.height}px`
              ]}
            ></div>
          </div>
        </div>
      )
    }
  }
})
types.ts 复制代码
export type Direction = 'left' | 'right' | 'up' | 'down' | ''

export type FailMsg = '撞墙了!' | '咬到自己!' | ''
index.scss 复制代码
.game-page {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
}
.game-msg {
  display: flex;
  flex-direction: row;
  height: 30px;
  line-height: 30px;
  > div {
    margin-right: 10px;
  }
}
.game-wrap {
  position: relative;
  border: 1px solid #ccc;
  .fail-msg {
    position: absolute;
    width: 100%;
    height: 100%;
    background-color: rgba(220,20,60, 0.2);
    display: flex;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    p {
      color: #fff;
      font-size: 16px;
    }
  }
  .fighter {
    position: absolute;
  }
  .food {
    position: absolute;
    background-color: #33413e;
  }
}
config.data.ts 复制代码
type Size = {
  width: number
  height: number
}

// 游戏活动区域
export const wrapSize: Size = {
  width: 810,
  height: 710
}

// 贪吃蛇的宽高
export const fighterSize: Size = {
  width: 10,
  height: 10
}

// 是否检查咬到自己
export const isDetectCollisionItself = true

// 游戏配置
export const gameConfig = {
  // 是否自动开始
  autoPlay: true,
  // 移动距离 像素
  distance: 10,
  // 设置游戏难度 分数 速度
  difficulty: [
    [4000, 20],
    [3500, 30],
    [3000, 40],
    [2500, 50],
    [2000, 60],
    [1500, 70],
    [1000, 80],
    [500, 90],
    [0, 100]
  ]
}
moveTarget.tsx 复制代码
/*
 * @Date: 2023-11-20 17:03:57
 * @LastEditTime: 2023-11-21 13:16:10
 * @Description: 设置贪吃蛇DOM节点
 * @FilePath: /zzy/src/views/Test/SnakeGame/components/moveTarget.tsx
 */

import { defineComponent } from 'vue'

export default defineComponent({
  components: {},
  props: {
    moveList: {
      type: Array,
      default: () => {
        return []
      }
    },
    fighterSize: {
      type: Object,
      default: () => {
        return {}
      }
    }
  },
  emits: [''],
  setup(props) {
    return () => {
      return (
        <>
          {props.moveList.map(
            (item: Record<'left' | 'top', number>, index: number) => {
              return (
                <div
                  class="fighter"
                  style={[
                    `left:${item.left}px;top:${item.top}px`,
                    `width:${props.fighterSize.width}px;height:${props.fighterSize.height}px`,
                    `background-color: ${
                      index % 2 === 1 ? '#b5b7b7' : '#06a182'
                    }`
                  ]}
                ></div>
              )
            }
          )}
        </>
      )
    }
  }
})
useKeyDown.ts 复制代码
import { onMounted, onUnmounted } from 'vue'
import { Direction } from '../types'

export const useKeyDown = () => {
  const onKeyDown = (callback: (direction: Direction) => void) => {
    // 键盘操作
    const handleKeyPress = (event: KeyboardEvent) => {
      if (['w', 'a', 's', 'd'].includes(event.key)) {
        const keyMap: Record<string, Direction> = {
          w: 'up',
          a: 'left',
          s: 'down',
          d: 'right'
        }
        const direction: Direction = keyMap[event.key] || ''
        callback(direction)
      }
    }
    // 当组件挂载时添加事件监听器
    onMounted(() => {
      window.addEventListener('keydown', handleKeyPress)
    })
    // 当组件卸载时移除事件监听器
    onUnmounted(() => {
      window.removeEventListener('keydown', handleKeyPress)
    })
  }
  return {
    onKeyDown
  }
}
useSetFood.ts 复制代码
// 设置食物位置
import { ref, Ref } from 'vue'
import { wrapSize } from '../config.data'

export const useSetFood = () => {
  const foodLeft: Ref<number> = ref(0)
  const foodTop: Ref<number> = ref(0)
  // 生成食物
  const generateRandomNumberInRange = (maxValue: number): number => {
    // 生成随机小数,范围在0到1之间
    const randomDecimal = Math.random()
    const randomNumber =
      Math.floor(randomDecimal * ((maxValue - 10) / 10 + 1)) * 10
    return randomNumber
  }

  const setFood = () => {
    foodLeft.value = generateRandomNumberInRange(wrapSize.width)
    foodTop.value = generateRandomNumberInRange(wrapSize.height)
  }
  setFood()

  return {
    foodLeft,
    foodTop,
    setFood
  }
}
useSetMove.ts 复制代码
/*
 * @Date: 2023-11-20 15:48:57
 * @LastEditTime: 2023-11-21 15:45:38
 * @Description: 贪吃蛇移动
 * @FilePath: /zzy/src/views/Test/SnakeGame/hooks/useSetMove.ts
 */
import { onMounted, ref, Ref } from 'vue'
import { Direction, FailMsg } from '../types'
import { gameConfig } from '../config.data'

export const useSetMove = (
  fighterSize: { width: number; height: number },
  wrapSize: { width: number; height: number },
  failMsg: Ref<FailMsg>,
  speed: Ref<number>
) => {
  const left = ref(0)
  const top = ref(0)
  // 已经开始进行的方向
  const startDirection = ref<Direction>('')

  /**
   * @description: 设置初始位置
   * @Date: 2023-11-21 09:29:23
   */
  const setInitalPosition = () => {
    left.value = (wrapSize.width - fighterSize.width) / 2
    top.value = (wrapSize.height - fighterSize.height) / 2
  }

  onMounted(setInitalPosition)

  /**
   * @description: 设置移动
   * @Date: 2023-11-21 09:28:24
   * @param {Direction} val
   */

  const getDirection = (val: Direction) => {
    if (val === 'left') {
      leftMove()
    } else if (val === 'right') {
      rightMove()
    } else if (val === 'up') {
      upMove()
    } else if (val === 'down') {
      downMove()
    }
  }

  /**
   * @description: 设置计时器开始,然后随机开始一个方向移动
   * @Date: 2023-11-21 09:30:52
   */
  const onRandomDirection = (callback: () => void) => {
    callback()
    if (gameConfig.autoPlay) {
      const randomNumber = Math.random()
      if (randomNumber < 0.25) {
        leftMove()
      } else if (randomNumber < 0.5) {
        rightMove()
      } else if (randomNumber < 0.75) {
        downMove()
      } else {
        upMove()
      }
    }
  }

  /**
   * @description: 清除所有移动事件
   * @Date: 2023-11-21 09:28:45
   */
  const StopMoving = () => {
    if (leftInter !== null) {
      clearInterval(leftInter)
    }
    if (rightInter !== null) {
      clearInterval(rightInter)
    }
    if (upInter !== null) {
      clearInterval(upInter)
    }
    if (downInter !== null) {
      clearInterval(downInter)
    }
  }

  // 左
  let leftInter: ReturnType<typeof setInterval> | null = null
  const leftMove = () => {
    if (startDirection.value === 'right') return // 禁止反向移动
    StopMoving()
    leftInter = setInterval(() => {
      startDirection.value = 'left'
      if (left.value > 0) {
        left.value -= gameConfig.distance
      } else {
        failMsg.value = '撞墙了!'
        left.value = 0
      }
    }, speed.value)
  }

  // 右
  let rightInter: ReturnType<typeof setInterval> | null = null
  const rightMove = () => {
    if (startDirection.value === 'left') return
    StopMoving()
    rightInter = setInterval(() => {
      startDirection.value = 'right'
      if (left.value < wrapSize.width - fighterSize.width) {
        left.value += gameConfig.distance
      } else {
        failMsg.value = '撞墙了!'
        left.value = wrapSize.width - fighterSize.width
      }
    }, speed.value)
  }

  // 上
  let upInter: ReturnType<typeof setInterval> | null = null
  const upMove = () => {
    if (startDirection.value === 'down') return
    StopMoving()
    upInter = setInterval(() => {
      startDirection.value = 'up'
      if (top.value > 0) {
        top.value -= gameConfig.distance
      } else {
        failMsg.value = '撞墙了!'
        StopMoving()
      }
    }, speed.value)
  }

  // 下
  let downInter: ReturnType<typeof setInterval> | null = null
  const downMove = () => {
    if (startDirection.value === 'up') return
    StopMoving()
    downInter = setInterval(() => {
      startDirection.value = 'down'
      if (top.value < wrapSize.height - fighterSize.height) {
        top.value += gameConfig.distance
      } else {
        failMsg.value = '撞墙了!'
      }
    }, speed.value)
  }

  return {
    left,
    top,
    getDirection,
    StopMoving,
    setInitalPosition,
    onRandomDirection,
    startDirection
  }
}
useTimer.ts 复制代码
/*
 * @Date: 2023-11-21 13:23:14
 * @LastEditTime: 2023-11-21 14:05:02
 * @Description: 计时器,用来计算游戏时间
 * @FilePath: /zzy/src/views/Test/SnakeGame/hooks/useTimer.ts
 */
import { ref } from 'vue'

export const useTimer = () => {
  let startTime: number | null = null
  let timerId: NodeJS.Timeout | null = null
  const start = () => {
    startTime = Date.now()
    timerId = setInterval(() => {
      tick()
    }, 1000)
  }

  const stop = () => {
    if (timerId !== null) {
      clearInterval(timerId)
      timerId = null
      startTime = null
    }
  }

  const time = ref('00:00:00')
  const tick = () => {
    if (startTime !== null) {
      const elapsedTime = Date.now() - startTime
      const hours = Math.floor(elapsedTime / 3600000)
      const minutes = Math.floor((elapsedTime % 3600000) / 60000)
      const seconds = Math.floor((elapsedTime % 60000) / 1000)
      time.value = `${formatTime(hours)}:${formatTime(minutes)}:${formatTime(
        seconds
      )}`
    }
  }

  const formatTime = (value: number): string => {
    return value < 10 ? `0${value}` : `${value}`
  }

  return { start, stop, timerId, time }
}
useWatchMove.ts 复制代码
/*
 * @Date: 2023-11-20 17:21:18
 * @LastEditTime: 2023-11-21 15:21:49
 * @Description:
 * @FilePath: /zzy/src/views/Test/SnakeGame/hooks/useWatchMove.ts
 */
import { watchEffect, ref, Ref } from 'vue'
import { isDetectCollisionItself } from '../config.data'
import { FailMsg } from '../types'

type MoveListType = Array<Record<'left' | 'top', number>>

export const useWatchMove = (
  snakeLength: Ref<number>,
  left: Ref<number>,
  top: Ref<number>,
  failMsg: Ref<FailMsg>,
  foodLeft: Ref<number>,
  foodTop: Ref<number>
) => {
  const moveList = ref<MoveListType>([])

  /**
   * @description: 检查重复位置
   * @Date: 2023-11-21 09:54:26
   * @param {*} moveList
   */
  const checkDuplicates = (moveList: MoveListType) => {
    const seenPositions = new Set()
    for (const { left, top } of moveList) {
      const positionKey = `${left}-${top}`
      if (seenPositions.has(positionKey)) {
        // 发现重复位置,可以进行相应的处理
        return true // 或者触发其他逻辑
      } else {
        seenPositions.add(positionKey)
      }
    }
    return false
  }

  const onEatFood = (callback: () => void) => {
    watchEffect(() => {
      moveList.value.unshift({ left: left.value, top: top.value })
      if (moveList.value.length > snakeLength.value) {
        // 如果超过蛇的长度,截取掉
        moveList.value.splice(snakeLength.value)
      }
      // 检查重复位置
      if (isDetectCollisionItself && checkDuplicates(moveList.value)) {
        failMsg.value = '咬到自己!'
      }
      // 检测是否进食
      const isObjectInArray = moveList.value.some(
        (item) => item.left === foodLeft.value && item.top === foodTop.value
      )
      if (isObjectInArray) {
        // 蛇的长度加一
        snakeLength.value += 1
        callback()
      }
    })
  }

  return {
    moveList,
    onEatFood
  }
}
相关推荐
编程百晓君2 小时前
一文解释清楚OpenHarmony面向全场景的分布式操作系统
vue.js
暴富的Tdy2 小时前
【CryptoJS库AES加密】
前端·javascript·vue.js
neeef_se2 小时前
Vue中使用a标签下载静态资源文件(比如excel、pdf等),纯前端操作
前端·vue.js·excel
z千鑫3 小时前
【前端】入门指南:Vue中使用Node.js进行数据库CRUD操作的详细步骤
前端·vue.js·node.js
生产队队长5 小时前
项目练习:element-ui的valid表单验证功能用法
前端·vue.js·ui
web137656076435 小时前
WebStorm 创建一个Vue项目
ide·vue.js·webstorm
秃头女孩y5 小时前
【React中最优雅的异步请求】
javascript·vue.js·react.js
小马哥编程8 小时前
原型链(Prototype Chain)入门
css·vue.js·chrome·node.js·原型模式·chrome devtools
娃哈哈哈哈呀11 小时前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
真滴book理喻14 小时前
Vue(四)
前端·javascript·vue.js