还记得刚上大学的时候,第一节编程课,老师说:“不要让我抓到你们玩游戏,否则玩什么就让你们写什么”。当时什么也不会,也不敢玩。
如果现在能回到那节课,我就可以肆无忌惮的玩我的 贪吃蛇 了,被她抓到直接把源码地址给她一丢,岂不快哉。
言归正传,那么本文将带你实现一个网页版贪吃蛇小游戏,技术栈选用当前热门的 Vite
+ Vue3
+ Ts
。
在线试玩 源码地址
建议结合源码阅读本文,效果更佳哦 ~
├── src
├── assets // 存放静态资源
├── components // vue组件
│ ├── Cell.vue // 每一个小方块
│ ├── Controller.vue // 游戏控制器
│ ├── KeyBoard.vue // 移动端软键盘
│ └── Map.vue // 地图组件
├── game // 游戏核心逻辑
│ ├── GameControl.ts // 控制器类
│ ├── Food.ts // 食物类
│ ├── Snake.ts // 蛇类
│ ├── hit.ts // 碰撞的逻辑
│ ├── render.ts // 渲染视图的逻辑
│ ├── map.ts // 跟地图相关的逻辑
│ └── index.ts // 主流程
├── types // TS类型
├── utils // 工具函数
├── main.ts // 主入口文件
└── App.vue // vue根组件
......
注:实现过程只截取关键代码进行讲解,建议对照源码进行阅读,更容易理解。
/src/game/map.ts
// 获取屏幕尺寸
const clientWidth = document.documentElement.clientWidth - 20;
const clientHeight = document.documentElement.clientHeight - 40;
// 行数
export const gameRow = clientWidth > 700 ? Math.floor(clientHeight / 54) : Math.floor(clientHeight / 34);
// 列数
export const gameCol = clientWidth > 700 ? Math.floor(clientWidth / 54) : Math.floor(clientWidth / 34);
// 初始化地图 现在所有的位置type都是0
export function initMap(map: Map) {
for (let i = 0; i < gameRow; i++) {
const arr: Array<number> = [];
for (let j = 0; j < gameCol; j++) {
arr.push(0);
}
map.push(arr);
}
return map;
}
如何计算格子的数量?
我这里通过获取设备屏幕的宽高,来进行一个设备的判断,屏幕大的格子就大一点(50px
),屏幕小的格子就小一点(30px
)。这里我的宽高都减去了一点,目的是让画面有区域感,看起来更美观。
然后通过计算屏幕的宽高除以每个格子的大小,获取到地图的 行数
和 列数
,因为每个格子直接有 2px
的margin
,所以是 54
和 34
。
如何产生地图?
然后,我们根据上一步计算出来的 行数
和 列数
,通过 二维数组
来进行地图的渲染。二维数组的元素值决定着每一个小格子的颜色。因为是初始化,我们就先默认全部设为 0
,再把元素的值传递给子组件 Cell.vue
。
/src/components/Map.vue
<template>
<div class="game-box">
<!-- 行 -->
<div class="row"
v-for='row in gameRow'
:key='row'>
<!-- 列 -->
<div class="col"
v-for='col in gameCol'
:key='col'>
<!-- 小格子 -->
<Cell :type='map[row-1][col-1]'></Cell>
</div>
</div>
</div>
</template>
如何区分元素?
/src/components/Cell.vue
<template>
<div class='cell-box'
:class='classes'>
</div>
</template>
<script lang='ts' setup>
import { computed, defineProps } from 'vue';
const props = defineProps(['type']);
// 小格子的颜色
const classes = computed(() => {
return {
head: props.type === 2,
body: props.type === 1,
food: props.type === -1,
};
});
</script>
想一下整个游戏地图上都会出现哪些元素呢,蛇头(2
),蛇身(1
)和食物(-1
)。所以我们根据不同的元素值赋予不同的 class
,就可以让不同的元素在地图上展示不同的样式了。
/src/game/GameControl.ts
export class GameControl {
// 蛇
snake: Snake;
// 食物
private _food: Food;
// 地图
private _map: Map;
// 游戏状态
private _isLive: IsLive;
constructor(map: Map, isLive: IsLive) {
this._map = map;
this.snake = new Snake();
this._food = new Food();
this._isLive = isLive;
}
// 开始游戏
start() {
// 绑定键盘按键按下的事件
document.addEventListener('keydown', this.keydownHandler.bind(this));
// 添加到帧循环列表
addTicker(this.handlerTicker.bind(this));
// 标记游戏状态为开始
this._isLive.value = 2;
}
// 创建一个键盘按下的响应函数
keydownHandler(event: KeyboardEvent) {
this.snake.direction = event.key;
}
// 渲染map
private _timeInterval = 200;
// 是否移动蛇
private _isMove = intervalTimer(this._timeInterval);
// 定义帧循环函数
handlerTicker(n: number) {
if (this._isMove(n)) {
try {
this.snake.move(this.snake.direction, this._food);
} catch (error: any) {
// 标记游戏状态为结束
this._isLive.value = 3;
// 停止循环
stopTicker();
}
}
render(this._map, this.snake, this._food);
}
// 重新开始游戏
replay() {
reset(this._map);
this.snake.direction = 'Right';
this.snake = new Snake();
this._food = new Food();
this._isLive.value = 2;
addTicker(this.handlerTicker.bind(this));
}
}
开始游戏
开始游戏的时候我们要做三件事情,首先绑定键盘事件,然后添加帧循环让游戏动起来,最后把游戏状态置为游戏中。
如何添加/停止帧循环?
不有了解帧循环的可以参考我下面这篇文章。
一个神奇的前端动画 API requestAnimationFrame
/src/utils/ticker.ts
let startTime = Date.now();
type Ticker = Function;
let tickers: Array<Ticker> = [];
const handleFrame = () => {
tickers.forEach((ticker) => {
ticker(Date.now() - startTime);
});
startTime = Date.now();
requestAnimationFrame(handleFrame);
};
requestAnimationFrame(handleFrame);
//添加帧循环
export function addTicker(ticker: Ticker) {
tickers.push(ticker);
}
//停止帧循环
export function stopTicker() {
tickers = [];
}
// 时间累加器
export function intervalTimer(interval: number) {
let t = 0;
return (n: number) => {
t += n;
if (t >= interval) {
t = 0;
return true;
}
return false;
};
}
重新开始游戏
重新开始游戏的时候我们同样要做三件事情,重置地图,添加帧循环,把游戏状态置为游戏中。
/src/game/Snake.ts
export class Snake {
bodies: SnakeBodies;
head: SnakeHead;
// 创建一个属性来存储蛇的移动方向(也就是按键的方向)
direction: string;
constructor() {
this.direction = 'Right';
this.head = {
x: 1,
y: 0,
status: 2,
};
this.bodies = [
{
x: 0,
y: 0,
status: 1,
},
];
}
// 定义一个方法,用来检查蛇是否吃到食物
checkEat(food: Food) {
if (this.head.x === food.x && this.head.y === food.y) {
// 分数增加
// this.scorePanel.addScore();
// 食物的位置要进行重置
food.change(this);
// 蛇要增加一节
this.bodies.unshift({
x: food.x,
y: food.y,
status: 1,
});
}
}
// 控制蛇移动
move(food: Food) {
// 判断是否游戏结束
if (hitFence(this.head, this.direction) || hitSelf(this.head, this.bodies)) {
throw new Error('游戏结束');
}
const headX = this.head.x;
const headY = this.head.y;
const bodyX = this.bodies[this.bodies.length - 1].x;
const bodyY = this.bodies[this.bodies.length - 1].y;
switch (this.direction) {
case 'ArrowUp':
case 'Up':
// 向上移动 需要检测按键是否相反方向
if (headY - 1 === bodyY && headX === bodyX) {
moveDown(this.head, this.bodies);
this.direction = 'Down';
return;
}
moveUp(this.head, this.bodies);
break;
case 'ArrowDown':
case 'Down':
// 向下移动 需要检测按键是否相反方向
if (headY + 1 === bodyY && headX === bodyX) {
moveUp(this.head, this.bodies);
this.direction = 'Up';
return;
}
moveDown(this.head, this.bodies);
break;
case 'ArrowLeft':
case 'Left':
// 向左移动 需要检测按键是否相反方向
if (headY === bodyY && headX - 1 === bodyX) {
moveRight(this.head, this.bodies);
this.direction = 'Right';
return;
}
moveLeft(this.head, this.bodies);
break;
case 'ArrowRight':
case 'Right':
// 向右移动 需要检测按键是否相反方向
if (headY === bodyY && headX + 1 === bodyX) {
moveLeft(this.head, this.bodies);
this.direction = 'Left';
return;
}
moveRight(this.head, this.bodies);
break;
default:
break;
}
// 检查蛇是否吃到食物
this.checkEat(food);
}
// 移动端修改移动方向
changeDirection(direction: string) {
if (direction === 'Left' && this.direction !== 'Left' && this.direction !== 'Right') {
this.direction = 'Left';
return;
}
if (direction === 'Right' && this.direction !== 'Left' && this.direction !== 'Right') {
this.direction = 'Right';
return;
}
if (direction === 'Up' && this.direction !== 'Up' && this.direction !== 'Down') {
this.direction = 'Up';
return;
}
if (direction === 'Down' && this.direction !== 'Up' && this.direction !== 'Down') {
this.direction = 'Down';
return;
}
}
}
蛇如何移动?
这个地方是困扰我最长时间的,但是只要想通了就不是很难。我们需要根据方向
去修改蛇头的坐标
,然后我们把蛇头的坐标放进蛇身体的数组的最后一个元素
,然后再删掉蛇身体的数组的第一个元素
。因为蛇移动永远都是下一节的蛇身走到上一节蛇身的位置,这样视图上看起来就像是蛇在移动了。
/src/game/Snake.ts
// 向上移动
function moveUp(head: SnakeHead, bodies: SnakeBodies) {
head.y--;
bodies.push({
x: head.x,
y: head.y + 1,
status: 1,
});
bodies.shift();
}
// 向下移动
function moveDown(head: SnakeHead, bodies: SnakeBodies) {
head.y++;
bodies.push({
x: head.x,
y: head.y - 1,
status: 1,
});
bodies.shift();
}
// 向右移动
function moveRight(head: SnakeHead, bodies: SnakeBodies) {
head.x++;
bodies.push({
x: head.x - 1,
y: head.y,
status: 1,
});
bodies.shift();
}
// 向左移动
function moveLeft(head: SnakeHead, bodies: SnakeBodies) {
head.x--;
bodies.push({
x: head.x + 1,
y: head.y,
status: 1,
});
bodies.shift();
}
然后我们要把新的蛇的位置信息渲染到视图上。
/src/game/render.ts
// 每一次渲染都需要将map重置,然后再进行新数据的渲染
export function render(map: Map, snake: Snake, food: Food) {
// 重置map
reset(map);
// 渲染蛇头
_renderSnakeHead(map, snake.head);
// 渲染蛇身
_renderSnakeBody(map, snake.bodies);
// 渲染食物
_renderFood(map, food);
}
// 重置map 将二维数组所有元素重置为0
export function reset(map: Map) {
for (let i = 0; i < map.length; i++) {
for (let j = 0; j < map[0].length; j++) {
if (map[i][j] !== 0) {
map[i][j] = 0;
}
}
}
}
// 渲染蛇身 -1 食物 1 蛇身体 2 蛇头
function _renderSnakeBody(map: Map, bodies: SnakeBodies) {
for (let i = 0; i < bodies.length; i++) {
const row = bodies[i].y;
const col = bodies[i].x;
map[row][col] = 1;
}
}
// 渲染蛇头 -1 食物 1 蛇身体 2 蛇头
function _renderSnakeHead(map: Map, head: SnakeHead) {
const row = head.y;
const col = head.x;
map[row][col] = 2;
}
// 渲染食物 -1 食物 1 蛇身体 2 蛇头
function _renderFood(map: Map, food: Food) {
const row = food.y;
const col = food.x;
map[row][col] = -1;
}
如何检测蛇是否吃到食物?
这个就很简单了,只要判断蛇头的坐标和蛇身体是否一样就行了。当相同的时候我们往蛇身体的数组
里 push
当前蛇头的位置,但是不删掉蛇尾的元素
,视图上看起来就像是蛇增加了一节。
如何检测蛇的碰撞?
游戏结束有两种情况,一种是碰到边界,一种是碰到自己。碰到边界的判断就是蛇头的坐标是否超过了行数
和列数
。碰到自己的判断就是蛇头的坐标
是否和蛇身体的某一节
重合。
/src/game/hit.ts
// 蛇头是否触碰到边界
export function hitFence(head: SnakeHead, direction: string) {
// 1.获取蛇头的位置
// 2.检测蛇头是不是超出了游戏的范围
let isHitFence = false;
switch (direction) {
case 'ArrowUp':
case 'Up':
// 向上移动
isHitFence = head.y - 1 < 0;
break;
case 'ArrowDown':
case 'Down':
// 向下移动 因为head.y是从0开始的 gameRow是从1开始的 所以gameRow要-1
isHitFence = head.y + 1 > gameRow - 1;
break;
case 'ArrowLeft':
case 'Left':
// 向左移动
isHitFence = head.x - 1 < 0;
break;
case 'ArrowRight':
case 'Right':
// 向右移动
isHitFence = head.x + 1 > gameCol - 1;
break;
default:
break;
}
return isHitFence;
}
// 蛇头是否触碰到自己
export function hitSelf(head: SnakeHead, bodies: SnakeBodies) {
// 1.获取蛇头的坐标
const x = head.x;
const y = head.y;
// 2.获取身体
const snakeBodies = bodies;
// 3.检测蛇头是不是撞到了自己,也就是蛇头的下一步移动会不会和身体数组的元素重复
const isHitSelf = snakeBodies.some((body) => {
return body.x === x && body.y === y;
});
return isHitSelf;
}
如何改变蛇的移动方向?
这个也很简单,修改对应的 direction
值就好了,但是要注意判断蛇是不可以回头的。
如何随机生成食物?
通过生成随机数,产生一个随机的坐标,当新坐标与蛇重合时,调用自身再次生成即可。
/src/game/Food.ts
export class Food {
// 食物的坐标
x: number;
y: number;
status = -1;
constructor() {
this.x = randomIntegerInRange(0, gameCol - 1);
this.y = randomIntegerInRange(0, gameRow - 1);
}
// 修改食物的位置
change(snake: Snake) {
// 生成一个随机的位置
const newX = randomIntegerInRange(0, gameCol - 1);
const newY = randomIntegerInRange(0, gameRow - 1);
// 1.获取蛇头的坐标
const x = snake.head.x;
const y = snake.head.y;
// 2.获取身体
const bodies = snake.bodies;
// 3.食物不可以和头部以及身体重合
const isRepeatBody = bodies.some((body) => {
return body.x === newX && body.y === newY;
});
const isRepeatHead = newX === x && newY === y;
// 不满足条件重新随机
if (isRepeatBody || isRepeatHead) {
this.change(snake);
} else {
this.x = newX;
this.y = newY;
}
}
}
想要练习的小伙伴们可以照着我的思路走一下,有不懂的可以评论区问我,我看到了会第一时间回复的。
在线试玩 源码地址
喜欢的小伙伴不要忘了点赞和 star 哦 ~