时间:2023-01-22 11:44:41 | 栏目:vue | 点击:次
贪食蛇是一个非常经典的游戏, 在游戏中, 玩家操控一条细长的直线(俗称蛇或虫), 它会不停前进, 玩家只能操控蛇的头部朝向(上下左右), 一路拾起触碰到之物(或称作“豆”), 并要避免触碰到自身或者边界. 每次贪吃蛇吃掉一件食物, 它的身体便增长一些.
本项目使用的技术栈和标题一样非常的简单, 只有用到 vue, 主要实现使用的是 HTML + CSS 动画
代码实现可以参考: CodeSandbox
在游戏描述中有提到, 玩家操纵的蛇要避免触碰到自身或者边界. 这就需要我们实现一个有边界的游戏棋盘.
在 html 中, 我们可以使用 css 的 width、border 和 height 属性来实现一个简单的、具有边界的容器:
在 App.vue 中的实现(功能节选)
<template> <div class="game-box"></div> </template> <style> body { display: flex; width: 100vw; height: 100vh; margin: 0; } .game-box { position: relative; width: 500px; height: 500px; border: 1px solid #ddd; margin: auto; } </style>
其中 position: relative;
是为了之后的 position: absolute
元素能够在游戏棋盘中的显示正确的位置.
展示豆的方式可以使用一个 div 元素, 使用 position: absolute 与 left、top 属性来实现豆的位置:
在 App.vue 中的实现(功能节选)
<template> <div class="game-box"> <div class="snake-food" :style="{ top: foodPos.y + 'px', left: foodPos.x + 'px' }" /> </div> </template> <script> export default { data() { return { foodPos: {}, }; }, }; </script> <style> .snake-foot { position: absolute; /* 保证初始位置不可见 */ top: -9999px; left: -9999px; width: 10px; height: 10px; /* 你也可以与众不同 */ background-color: rgb(207, 38, 38); z-index: 2; } </style>
实现蛇就需要稍稍拆解一下需求. 我们知道蛇在吃了豆之后, 就会增长一些. 这看起来就像是一条单向的链表, 在蛇吃到豆之后便插入一条. 而且插入数据的部分只有在其尾部, 并不需要链表的便捷插入特性, 所以我们可以使用一个保存位置信息的数组来实现蛇的身体. 并且独立出蛇的头部来引导蛇的移动. 在这里我们保留了指向尾部的引用, 以便在蛇吃到豆之后, 可以快速的将新的蛇尾插入到最后:
在 App.vue 中的实现(功能节选)
<template> <div class="game-box"> <div ref="snake" class="snake"> <!-- 蛇的头部用来引导蛇的移动 --> <div :style="{ top: headerPos.y + 'px', left: headerPos.x + 'px' }" ref="snakeHeader" class="snake-header" /> <!-- 蛇的身体, 使用连续的数组实现 --> <div :key="uuid" :uid="uuid" v-for="{ pos: { y, x }, uuid } in snakeBodyList" :style="{ top: y + 'px', left: x + 'px' }" class="snake-body" /> </div> </div> </template> <script> // 蛇身的大小单位 const defaultUnit = 10; function updatePos(pos, direction) { // 规避引用 const newPos = { ...pos }; switch (direction) { case directionKeys.up: newPos.y -= defaultUnit; break; case directionKeys.down: newPos.y += defaultUnit; break; case directionKeys.left: newPos.x -= defaultUnit; break; case directionKeys.right: newPos.x += defaultUnit; break; default: throw new Error('NotFind'); } return newPos; } export default { data() { return { // 蛇身自增的 uuid id: 0, // 蛇的头部位置 headerPos: {}, // 保存尾部的位置信息 lastPos: {}, // 保存蛇的身体位置信息 snakeBodyList: [], }; }, methods: { init() { // 初始化数据 const initData = { x: 250, y: 250 }; this.direction = directionKeys.left; this.lastPos = { ...initData, direction: this.direction }; this.headerPos = { ...initData }; this.snakeBodyList = Array(defaultUnit).fill(0).map(this.createBody); }, createBody() { const { x, y } = this.lastPos; // 判断是否属于同水平方向 const isLower = this.direction === directionKeys.up || this.direction === directionKeys.left; const pos = { // 同水平方向刚好差 2 的数值, 40 - 38 = 2, 39 - 37 = 2 ...updatePos({ x, y }, isLower ? this.direction + 2 : this.direction - 2), }; // 保存尾部的位置信息 this.lastPos = pos; return { uuid: this.id++, pos, }; }, }, }; </script>
当我们需要添加新的蛇身时, 只需要调用 createBody
方法, 并将其添加至蛇的身体数组尾部即可:
// 使用push方法添加蛇身至身体数组尾部 this.snakeBodyList.push(this.createBody());
我们知道, 用户在键入一个按键时, 如果我们有监听 keydown
事件, 浏览器会触发回调函数并提供一个KeyboardEvent 对象. 当我们要使用键盘来控制蛇的移动方向时, 就可以使用该事件对象的 keyCode
属性来获取键盘按键的编码.
其中 keyCode
属性的值可以参考 键盘编码.
实现这个功能我们可以在全局对象 window 上添加一个 keydown
事件监听函数, 并将键盘按键的编码保存在实例中, 考虑到用户可能会输入多个键盘按键, 所以我们需要检查是否为方向键, 并且跳过同一个水平方向上的输入:
在 App.vue 中的实现(功能节选)
<script> // 方向键的键盘按键的编码 const directionKeys = { up: 38, down: 40, left: 37, right: 39, }; // 检查是否在水平方向上 function checkIsLevel(direction) { return direction === directionKeys.right || direction === directionKeys.left; } export default { data () { return { // 当前的方向键的编码 direction: undefined, // 最终输入的方向键的编码 lastInputDirection: undefined, } } mounted() { window.addEventListener('keydown', this.onKeydown); }, methods: { onKeydown(e) { if ( // 检查是否为方向键 ![38, 40, 37, 39].includes(keyCode) || // 检查是否在同一个水平方向上 checkIsLevel(keyCode) === checkIsLevel(this.direction) ) { return; } // 保存输入的方向 this.lastInputDirection = keyCode; }, }, }; </script>
游戏要求玩家避免触碰到自身或者边界, 我们自然而然的就需要去检测它们是否发生了碰撞.
检测与自身碰撞的方法是, 判断蛇头的位置是否与蛇身体的位置相同:
// 检测是否发生碰撞 function isRepeat(list, pos) { return list.some(({ pos: itemPos }) => pos.x === itemPos.x && pos.y === itemPos.y); } // 使用的地方传入蛇身体数组和蛇头的位置 isRepeat(snakeBodyList, headerPos);
而检测与边界碰撞的方法是, 判断蛇头的位置是否超出了游戏区域:
const MAX_X = 500; const MAX_Y = 500; // 检测是否超出边界 function isCrossedLine(x, y) { // 因为是使用position, 我们的位置计算需要考虑到 { x: 0, y: 0 } 的位置不为边界 return x >= MAX_X || x < 0 || y >= MAX_Y || y < 0; }
当蛇头的位置将要超出了游戏区域或者与蛇身体的位置相同时, 游戏结束:
const next = updatePos(this.headerPos, this.direction); if (isCrossedLine(next.x, next.y) || isRepeat(this.snakeBodyList, next)) { alert('你输了'); return; }
为了写出渲染动画, 我们需要尝试理解蛇的运动方式.
当玩家输入操作的时候, 蛇会根据用户输入的方向进行移动, 在这个过程中蛇头的位置会发生变化, 而蛇身体的位置也会随之发生变化. 仔细观察可以发现, 其实不断变化的每个蛇身就是将它的位置替换成上一个蛇身的位置:
let head = this.headerPos; const snakeBodyList = this.snakeBodyList; for (const body of snakeBodyList) { const nextPos = body.pos; body.pos = head; head = nextPos; }
除了这种逐步更新的方式也可以使用更简单的直接更新数组的方式, 比如:
这样会使 uuid 无法更新, vue 不会重新渲染 DOM, 导致 transition 无法生效
// 移除蛇尾 const snakeBodyList = this.snakeBodyList.slice(0, this.snakeBodyList.length - 1); // 添加当前的蛇头至蛇身的最前方 snakeBodyList.unshift({ pos: this.headerPos, uuid: this.id++ });
而当蛇头触碰到豆的时候, 豆会被消除并且延长蛇身:
if (isRepeat(snakeBodyList, this.foodPos)) { snakeBodyList.push(this.createBody()); }
有了检测逻辑, 我们再将动画添加上. 因为蛇是一步一步的移动, 所以可以使用 setTimeout 来实现动画:
render 函数最终会挂载在 vue 实例上
function render() { const next = updatePos(this.headerPos, this.lastInputDirection); if (isCrossedLine(next.x, next.y) || isRepeat(this.snakeBodyList, next)) { clearTimeout(this._timer); alert('你输了'); return; } const snakeBodyList = this.snakeBodyList.slice(0, this.snakeBodyList.length - 1); snakeBodyList.unshift({ pos: this.headerPos, uuid: this.id++ }); this.headerPos = next; this.lastPos = snakeBodyList[snakeBodyList.length - 1].pos; if (isRepeat(snakeBodyList, this.foodPos)) { snakeBodyList.push(this.createBody()); } this.snakeBodyList = snakeBodyList; this.direction = this.lastInputDirection; this._timer = setTimeout(() => this.render(), 100); }
我们添加一下生成豆的方法, 并且保证它的位置不会出现在游戏区域的边界或者蛇身体的位置上:
genFoot 函数最终会挂载在 vue 实例上
// 生成随机数 function genRandom(max, start) { return start + (((Math.random() * (max - start)) / start) >>> 0) * start; } // 随机生成豆的位置 function genFoot() { const x = genRandom(MAX_X, defaultUnit); const y = genRandom(MAX_Y, defaultUnit); // 如果出现在游戏区域的边界或者蛇身体的位置上则重新生成 if (isRepeat(this.snakeBodyList, { x, y }) || isCrossedLine(x, y)) { this.genFoot(); } else { this.foodPos = { x, y }; } } // 添加到render方法中 function render() { // ... if (isRepeat(snakeBodyList, this.foodPos)) { snakeBodyList.push(this.createBody()); this.genFoot(); } // ... }
再添加一下开始与结束游戏, 以及一些展示当前蛇的信息的地方:
在 App.vue 中的实现(功能节选)
<template> <div class="game-box"> <div class="tools"> <button @click="playGame"> {{ isPlaying ? '停止' : isLose ? '重新开始' : '开始' }} </button> <div class="info-bar"> <p>? 的长度: {{ snakeBodyList.length }}</p> </div> <p class="count">得分: {{ count }}</p> </div> </div> </template> <script> export default { data: () => ({ // 游戏状态 isPlaying: false, // 是否失败 isLose: false, // 蛇的步行速度 speed: 100, }), methods: { playGame() { if (this.isPlaying) { clearTimeout(this._timer); } else { this.isLose = false; this.init(); this.genFoot(); this.render(); } this.isPlaying = !this.isPlaying; }, }, }; </script>
这样我们就使用 vue 实现了一个简单的贪吃蛇游戏了.
效果图