先看看游戏界面
小游戏非常简单,就不放游戏效果了
实现的逻辑是贪食蛇的每一个节点,都存储在数组里
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
}
}