自学系列之小游戏---贪吃蛇(vue3+ts+vite+element-plus+sass)(module.scss + tsx)

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

主要技术如下:vue3vitetselement-plustsxsassxxx.module.scss


一、逻辑设计分析

数据定义定义

index  : 总下标
colNum : 列数
colIdx : 列下标
rowNum : 行数
rowIdx : 行下标
isSnake: 是否属于模型内容
isHead : 是否属于模型头部
isTail : 是否属于模型尾部

生成网格

1、生成整体网格数据单位
2、生成所有行数据
3、生成所有列数据

初始化snake

1、生成默认snake长度
2、生成默认snake位置
3、定义snake首尾

速度

1、定义初始速度
2、定义加速度模型

生成目标

1、随机生成
2、新增eat目标单元
    * 生成时排除snake模型部分,随机生成位置

snake移动逻辑

1、移动UI方案
    * 网格背景变化
    * 变化速度取移动速度间隔值
2、移动方向方案
    * 记录移动方向 默认向右
    * 当前方向向右
        `
            向上取当前snake头部行数加一与行下标取下一次位移目标位置为头部数据
                改变前进方向为向上
                撞自己身体判断
            向右操作无效
                向右撞墙判断
                撞自己身体判断
                (后期加入向右加速度,keydown加速度,keyup取消加速度)
            向下取当前snake头部行数减一与行下标取下一次位移目标位置为头部数据
                改变前进方向为向下
                撞自己身体判断
            向左操作无效
        `
    * 当前方向向左
        `
            向上取当前snake头部行数加一与行下标取下一次位移目标位置为头部数据
                改变前进方向为向上
                撞自己身体判断
            向右操作无效
            向下取当前snake头部行数减一与行下标取下一次位移目标位置为头部数据
                改变前进方向为向下
                撞自己身体判断
            向左操作无效
                向左撞墙判断
                撞自己身体判断
                (后期加入向左加速度,keydown加速度,keyup取消加速度)
        `
    * 当前方向向上
        `
            向上操作无效
                向上撞墙判断
                撞自己身体判断
                (后期加入向上加速度,keydown加速度,keyup取消加速度)
            向右取当前snake头部列数加一与列下标取下一次位移目标位置为头部数据
                改变前进方向为向右
                撞自己身体判断
            向下操作无效
            向左操作取当前snake头部列数减一与列下标取下一次位移目标位置为头部数据
                改变前进方向为向左
                撞自己身体判断
        `
    * 当前方向向下
        `
            向上操作无效
            向右取当前snake头部列数加一与列下标取下一次位移目标位置为头部数据
                改变前进方向为向右
                撞自己身体判断
            向下操作无效
                向下撞墙判断
                撞自己身体判断
                (后期加入向上加速度,keydown加速度,keyup取消加速度)
            向左操作取当前snake头部列数减一与列下标取下一次位移目标位置为头部数据
                改变前进方向为向左
                撞自己身体判断
        `

游戏结束判断

1、撞墙死亡
2、撞自己身体死亡
3、身体占满网格游戏通关

分数计算

1、身总长度减去初始身体长度

二、代码实现

1.TS interface

代码如下(示例):

declare namespace Ad{
    namespace Game{
        namespace Snake {
            interface BaseItem {
                /** 下标 */
                index:number,
                /** 列数 */
                colNum:number,
                /** 列坐标 */
                colIdx:number,
                /** 行数 */
                rowNum:number,
                /** 行坐标 */
                rowIdx:number,
                /** 是否属于目标得分点 */
                newSpot:boolean,
                /** 是否属于模型内容 */
                isSnake:boolean,
                /** 是否属于模型头部 */
                isHead:boolean,
                /** 是否属于模型尾部 */
                isTail:boolean
            }
        }
    }
}

2.javascript

代码如下:

import { Ref, computed, defineComponent, reactive, ref } from "vue";
import SnakeScss from './greedySnake.module.scss'
import '../game.scss'
import { ElMessageBox } from 'element-plus'
import { secondsToDate } from "@/utils/utils";

export default defineComponent({
    setup(props, ctx) {
        /** 行-基点数 */
        const row    = 29
        /** 列-基点数 */
        const column = 29
        /** 时间 */
        let timer    = ref(0)
        /** 当前游戏状态 */
        let curState = ref(false)
        /** 贪吃蛇数据模型 */
        let snakeData:Ref<Array<Ad.Game.Snake.BaseItem>> = ref([])
        /** 时间记录器 */
        let timeStamp: undefined | NodeJS.Timer;
        /** 速度移动记录器 */
        let timeStampMove: undefined | NodeJS.Timer;
        /** 前进方向 */
        let direction = ref('right')
        /** 能用做得分点的框 */
        let canCreatStops:Ref<Array<number>> = ref([])

        /** 得分 */
        let score = computed(() => snakeData.value.length - 5 )
        /** 移动速度 */
        let speed = computed(() => {
            const baseSpeed = 500
            return baseSpeed - score.value * ((500 - 40) / gridData.length)
        })

        const snakeMove = () => {
            let nextItem:Ad.Game.Snake.BaseItem | null = null
            const curLastSpot = snakeData.value[snakeData.value.length-1]
            switch(direction.value){
                case 'top':
                    nextItem = gridData[curLastSpot.index - row]
                    break;
                case 'right':
                    nextItem = gridData[curLastSpot.index + 1]
                    if(nextItem.rowNum !== curLastSpot.rowNum) nextItem = null
                    break;
                case 'bottom':
                    nextItem = gridData[curLastSpot.index + row]
                    break;
                case 'left':
                    nextItem = gridData[curLastSpot.index - 1]
                    if(nextItem.rowNum !== curLastSpot.rowNum) nextItem = null
                    break;
                default:
                    console.error('方向错误:', direction.value)
            }
            if(nextItem?.isSnake || !nextItem){
                gameOver()
            } else {
                updateSnake(nextItem)
            }
        }

        const windowKeyDown = (ev:KeyboardEvent) => {
            const { keyCode } = ev
            ev.preventDefault()
            switch(keyCode){
                case 37: //左键
                    if(direction.value === 'right') return
                    else direction.value = 'left'
                    break;
                case 38://上键
                    if(direction.value === 'bottom') return
                    else direction.value = 'top'
                    break;
                case 39://右键
                    if(direction.value === 'left') return
                    else direction.value = 'right'
                    break;
                case 40://下键
                    if(direction.value === 'top') return
                    else direction.value = 'bottom'
                    break;
                default:
                    console.log('keyCode:', keyCode)
            }
            snakeMove()
        }

        /** 设置运动时间更新机制 */
        const setSpeedTime = () => {
            if(!curState.value) return
            if(timeStampMove)clearTimeout(timeStampMove)
            timeStampMove = setTimeout(() => {
                snakeMove()
                setSpeedTime()
            }, speed.value)
        }

        /** 开始游戏 */
        const startGame = () => {
            window.addEventListener('keydown', windowKeyDown)
            initNewSpot()
            curState.value = true
            setSpeedTime()
            timeStamp = setInterval(() => timer.value += 1, 1000)
        }

        /** 重置 */
        const resetGame = () => {
            window.removeEventListener('keydown', windowKeyDown)
            clearInterval(timeStamp)
            clearTimeout(timeStampMove)
            curState.value  = false
        }

        /** 游戏结束 */
        const gameOver = () => {
            resetGame()
            ElMessageBox.confirm(`本次存活时间${secondsToDate(timer.value, 'HH*mm*ss', true )},本次得分${score.value},再接再厉。`,'游戏结束',{
                confirmButtonText: 'OK',
                showCancelButton:false,
                showClose:false,
                closeOnClickModal:false,
                closeOnPressEscape:false,
                type: 'warning',
            }).then(res => {
                timer.value     = 0
                snakeData.value = []
                direction.value = 'right'

                initGrid()
            })
        }

        /** 得分、计时、操作面板生成 */
        const renderHeader = () => {
            return <div class='header'>
                <div class='header-item'>
                    <div>时间:</div>
                    <div class='number-value'>{ secondsToDate(timer.value, 'HH:mm:ss', true) || '00:00:00' }</div>
                </div>
                <div class='header-item'>
                    <div>得分:</div>
                    <div class='number-value'>{ score.value || 0 }</div>
                </div>
                <div class='header-item'>
                    {
                        curState.value ? <div class='btn' onClick={ gameOver }>结束游戏</div> : <div class='btn' onClick={ startGame }>开始游戏</div>
                    }
                </div>
            </div>
        }

        /** 网格数据 */
        let gridData:Array<Ad.Game.Snake.BaseItem> = reactive([])

        const renderGrid = () => {
            return <div class={SnakeScss['snake-grid']}>
                { gridData.map(gridItem => renderGridItem(gridItem)) }
            </div>
        }

        const renderGridItem = (gridItem:Ad.Game.Snake.BaseItem) => {
            const activeClass = gridItem.isSnake ? SnakeScss['grid-item_active'] : ''
            let directionClass:{[key:string]:string} = {
                top:'snake-title_top',
                right:'snake-title_right',
                bottom:'snake-title_bottom',
                left:'snake-title_left'
            }
            const headClass = gridItem.isHead ? SnakeScss[directionClass[direction.value]] : ''
            const newSpotClass = gridItem.newSpot ? SnakeScss['grid-item_new-spot'] : ''
            return <div class={[SnakeScss['grid-item'], activeClass, headClass, newSpotClass] }>
                {
                    gridItem.isHead && [
                        <div class={SnakeScss['eye']}></div>,
                        <div class={SnakeScss['eye']}></div>
                    ]
                }
            </div>
        }
        /** 初始化网格 */
        const initGrid = () => {
            gridData = []

            const allGridNums = row * column

            for (let index = 0; index < allGridNums; index++) {
                let currentRow    = Math.ceil((index + 1) / row)
                let currentColumn = (index+1) % row
                let currentGrid:Ad.Game.Snake.BaseItem = {
                    index:index,
                    rowNum:currentRow,
                    rowIdx:currentColumn,
                    colNum:currentColumn,
                    colIdx:currentRow,
                    newSpot:false,
                    isSnake:false,
                    isHead:false,
                    isTail:false
                }
                gridData.push(currentGrid)
            }
            initSnakePosition()
        }
        /** 初始化贪吃蛇数据模型 */
        const initSnakePosition = () => {

            const rowMidDrop    = Math.ceil(row / 2)
            const columnMidDrop = Math.ceil(column / 2)

            const mindSpotIdx = ((rowMidDrop - 1) * row) + (columnMidDrop - 1)
            const snakeDefaultSpotIdxs = [mindSpotIdx-2, mindSpotIdx-1, mindSpotIdx, mindSpotIdx+1, mindSpotIdx+2]
            for (let index = 0; index < snakeDefaultSpotIdxs.length; index++) {
                gridData[snakeDefaultSpotIdxs[index]].isSnake = true
                if(index === 0){
                    gridData[snakeDefaultSpotIdxs[index]].isTail = true
                }
                if(index === snakeDefaultSpotIdxs.length - 1){
                    gridData[snakeDefaultSpotIdxs[index]].isHead = true
                }
                snakeData.value.push(gridData[snakeDefaultSpotIdxs[index]])
            }
        }

        /**
         * 更新贪吃蛇位置和长度
         * @param nextItem 下一个目标位置
         */
        const updateSnake = (nextItem:Ad.Game.Snake.BaseItem) => {
            const preIdx = snakeData.value[snakeData.value.length - 1].index
            gridData[preIdx].isHead = false
            snakeData.value.push(nextItem)
            if(snakeData.value.length == gridData.length){
                resetGame()
                ElMessageBox.confirm(`本次存活时间${secondsToDate(timer.value, 'HH*mm*ss', true )},本次得分${score.value},太厉害了,游戏通关。`,'通关',{
                    confirmButtonText: 'OK',
                    showCancelButton:false,
                    showClose:false,
                    closeOnClickModal:false,
                    closeOnPressEscape:false,
                    type: 'success',
                }).then(res => {
                    timer.value     = 0
                    snakeData.value = []
                    direction.value = 'right'
                    initGrid()
                })
                return
            }
            gridData[nextItem.index].isHead = true
            gridData[nextItem.index].isSnake = true
            if(!nextItem.newSpot) {
                const delItme:Ad.Game.Snake.BaseItem = snakeData.value.shift() as Ad.Game.Snake.BaseItem
                gridData[delItme.index].isSnake = false
            } else {
                gridData[snakeData.value[snakeData.value.length - 1].index].newSpot = false
                initNewSpot()
            }
        }

        /** 生成新的目标得分点 */
        const initNewSpot = () => {
            const snakeDataIdxs = snakeData.value.map(item => item.index)
            canCreatStops.value = gridData.filter(item => !snakeDataIdxs.includes(item.index)).map(item => item.index)
            const idx = Number((Math.random() * canCreatStops.value.length - 1).toFixed(0))
            gridData[canCreatStops.value[idx]].newSpot = true
        }

        initGrid()

        return () => <div class='game-content'>
            { renderHeader() }
            { renderGrid() }
        </div>
    }
})

3.页面样式(Sass)

greedySnake.module.scss

.snake-grid{
    display: flex;
    flex-wrap: wrap;
    border: 1px solid #eee;
    border-radius: 4px;
    margin: 0 auto;
    margin-top: 20px;
    width: 580px;
    .grid-item{
        width: 20px;
        height: 20px;
        .eye{
            width: 5px;
            height: 5px;
            background-color: #fff;
            border-radius: 50%;
            margin-left: 10px;
            margin-top: 3px;
        }
    }
    .grid-item:nth-child(2n){
        background-color:rgb(248, 248, 248)
    }
    .grid-item:nth-child(2n-1){
        background-color:rgb(255, 255, 255)
    }
    .grid-item_active{
        background-color: rgb(0, 0, 0) !important;
    }
    .snake-title_top{
        border-top-right-radius: 50%;
        border-top-left-radius: 50%;
    }
    .snake-title_right{
        border-top-right-radius: 50%;
        border-bottom-right-radius: 50%;
    }
    .snake-title_bottom{
        border-bottom-right-radius: 50%;
        border-bottom-left-radius: 50%;
    }
    .snake-title_left{
        border-top-left-radius: 50%;
        border-bottom-left-radius: 50%;
    }
    .grid-item_new-spot{
        background-color: rgb(0, 0, 0) !important;
    }
}

game.scss

.game-content{
    width: 900px;
    margin: 0 auto;
    height: 700px;
    margin-top: 40px;
    box-shadow: 0px 1px 10px 4px #ccc;
    border-radius: 10px;
    user-select: none;
    .header{
        display: flex;
        justify-content: space-around;
        height: 60px;
        border-bottom: 1px solid #eee;
        margin: 0 20px;
        align-items: center;
        .header-item{
            display: flex;
            align-items: center;
            font-weight: bold;
            font-size: 16px;
            .number-value{
                font-size: 18px;
            }
            .btn{
                width: 120px;
                border-radius: 10px;
                height: 36px;
                text-align: center;
                line-height: 36px;
                box-shadow: 0px 1px 10px 0px #ccc;
                cursor: pointer;
            }
        }
    }
}

三、截图展示

在这里插入图片描述
在这里插入图片描述

四、总结

实现贪吃蛇小游戏使用的技术有为了使用而使用的嫌疑,使用还有些不太熟练,望大家多多理解,如有建议欢迎多多评论或私信指教。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

白开水丶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值