挑战 100 款小游戏之「贪吃蛇」

介绍

大家好,我是大胆的番茄。本文是「挑战 100 款小游戏」的第一款游戏:「贪吃蛇」的总结与分享。

演示地址:https://wanghaida.com/demo/2201-snake/index.html

Github 仓库:https://github.com/wanghaida/games/tree/master/2201-snake

这款游戏的代码我并没有进行多么的精简,主要是为了好看,添加了地图、墙、石头、蛇尾来回摇等资源,所以判定比较多。

地图

一般对于 2D 小游戏来说,一种常见的地图就是一个小的显示窗口下面有个大的背景画布,通过移动背景画布来达到地图变化的目的。像贪吃蛇大作战,球球大作战之类的游戏,都属于这一类的地图。

另外一种最常见的就是采用 二维数组 来表示横纵坐标及其每个坐标上所对应的属性。像这个游戏来说,我用 0 来表示 空地1 来表示 草墙2-4 来表示 石头,还可以用其他数字来代表 道具洞穴 等等。如果类型过多太难记忆,还可以用字符串、对象来表示格子对应的图形。关卡就是多个二维数组。

本游戏采用的素材来自 Flash 游戏“贪吃蛇竞技场”,这个游戏还是挺有意思的,以后再安排。

下面是一个 5x5 大小的二维数组地图示例:

1
2
3
4
5
6
7
8
9
10
11
/**
* 地图
* @desc 0: 空地, 1: 草墙, 2-4: 石头
*/
const map = [
[1, 0, 0, 1, 1],
[1, 0, 0, 0, 0],
[0, 0, 2, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 4, 0],
];

下面是完整的 30x30 地图:

image.png

绘制

资源:

220106161510.png

地图的资源总共 2 种,草墙和石头。而每个资源又有不同的设计,我完全可以将资源设定为一个对象,由另外一个参数来表示它具体是 grass_top 还是 grass_bottom_right,但我为了在改地图时少改点东西,就用了 if 判定来确定具体使用哪个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="root">
<!-- 操作区 -->
<div class="actions">
<div>
<button id="start">开始</button>
<button id="pause">暂停</button>
</div>

<!-- 分数 -->
<div id="score">分数:0</div>
</div>

<!-- 游戏区 -->
<div id="game" />
</div>

绘制地图会在 div#game 当中插入 30 x 30 = 900div,使用网格系统将每个 div 设置为宽高都为 20 的正方形。

1
2
3
4
5
6
7
8
// 游戏区
#game {
display: grid;
grid-template-columns: repeat(30, 20px);
grid-template-rows: repeat(30, 20px);
width: 600px;
height: 600px;
}

下面是绘制地图的具体函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
/**
* 绘制地图
*/
const drawMap = () => {
// 虚拟节点用来承载 dom 节点,方便一次性添加
const oFragment = document.createDocumentFragment();

for (let i = 0; i < map.length; i++) {
for (let j = 0; j < map[i].length; j++) {
// 创建坐标节点
const oDiv = document.createElement('div');

/**
* 根据坐标节点地图类型来显示对应的图案
*
* 最少一个方向、最多有两个方向的草墙和当前草墙相连,查找当前草墙的上右下左哪个方向是草墙,进行对应的图案显示
*
* 地图坐标点属性用对象表示的话,这里的 if 判定完全可以删除
*/
let classname = '';

// 当前坐标节点地图类型 === 草墙
if (map[i][j] === 1) {
classname = 'grass_top'; // 默认下方是草墙

// 上(判定上方是不是草墙)
if (map[i - 1]?.[j] === 1) {
classname = 'grass_bottom'; // 如果上方是草墙,默认采用 grass_bottom 图案
// 右(判定右方是不是草墙)
if (map[i][j + 1] === 1) {
classname = 'grass_bottom_left'; // 如果上方、右方是草墙
}
// 下(判定下方是不是草墙)
if (map[i + 1]?.[j] === 1) {
classname = 'grass_vertical'; // 如果上方、下方是草墙
}
// 左(判定左方是不是草墙)
if (map[i][j - 1] === 1) {
classname = 'grass_bottom_right'; // 如果上方、左方是草墙
}
}
// 右
if (map[i][j + 1] === 1) {
classname = 'grass_left';
// 上
if (map[i - 1]?.[j] === 1) {
classname = 'grass_bottom_left';
}
// 下
if (map[i + 1]?.[j] === 1) {
classname = 'grass_top_left';
}
// 左
if (map[i][j - 1] === 1) {
classname = 'grass_horizontal';
}
}
// 左
if (map[i][j - 1] === 1) {
classname = 'grass_right';
// 上
if (map[i - 1]?.[j] === 1) {
classname = 'grass_bottom_right';
}
// 右
if (map[i][j + 1] === 1) {
classname = 'grass_horizontal';
}
// 下
if (map[i + 1]?.[j] === 1) {
classname = 'grass_top_right';
}
}
}
// 当前坐标节点地图类型 === 石头
if ([2, 3, 4].includes(map[i][j])) {
classname = `stone_0${map[i][j]}`;
}

oDiv.className = classname;

// 将坐标节点放入虚拟节点
oFragment.appendChild(oDiv);
}
}

// 将虚拟节点放入游戏区
document.getElementById('game').appendChild(oFragment);
};

在上面的双层 for 循环中,通过嗅探当前坐标的上、右、下、左四个方向,来确定具体采用某个 classname。如果采用 1grass_top_left2grass_top 等(或用对象)将所有素材都表示详细,这里的 for 循环就不需要了。

image.png

贪吃蛇

将所有游戏逻辑写到了一个 snake 对象当中,用 body 来表示蛇,第 0 个元素为蛇头,当移动时将新节点 unshiftbody 当中,同时进行 pop 操作,用 last 来存储,主要是为了重绘贪吃蛇时恢复样式。用 direction 来表示当前行进方向。

1
2
3
4
5
6
7
8
9
10
const snake = {
// 蛇
body: [[2, 4], [2, 3], [2, 2]],
// 被移除的蛇尾(用来清除样式)
last: [2, 1],
// 方向(top right bottom left)
direction: 'right',

...
};

绘制

资源:

220106165519.png

蛇的资源并没有把方方面面都包含,所以我通过 css 设置 transform 来表示各类情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
div {
position: relative;

&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
transform-origin: 0 0;
}
}

...

.snake_head::before {
width: 26px;
height: 26px;
background: url('./images/snake_head.png') center / 26px no-repeat;
z-index: 1;
}
.snake_head_top::before {
transform: rotate(0deg) translate(-14px, -16px);
}
.snake_head_right::before {
transform: rotate(90deg) translate(-14px, -16px);
}
.snake_head_bottom::before {
transform: rotate(180deg) translate(-13px, -16px);
}
.snake_head_left::before {
transform: rotate(270deg) translate(-13px, -16px);
}

下面是绘制贪吃蛇的具体函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
const snake = {
...

// 绘制蛇
drawSnake() {
// 所有坐标点
const oDivs = document.querySelectorAll('#game div');

// 被移除的蛇尾
oDivs[this.last[0] * 30 + this.last[1]].className = '';

for (let i = 0; i < this.body.length; i++) {
const node = this.body[i];
const prev = this.body[i - 1] ?? [];
const next = this.body[i + 1] ?? [];
const isBody = i !== this.body.length - 1;

// 蛇头
if (i === 0) {
oDivs[node[0] * 30 + node[1]].className = `snake_head snake_head_${this.direction}`;
continue;
}

/**
* 判断蛇节点的四周哪里有其他节点来确定图形
*/
let classname = '';

// 上(判定上方是不是蛇节点)
if (node[0] - 1 === prev[0] || node[0] - 1 === next[0]) {
if (isBody) {
classname = 'snake_body_vertical'; // 如果上方是蛇节点,默认采用 snake_body_vertical 图案
// 右(判定右方是不是蛇节点)
if (node[1] + 1 === prev[1] || node[1] + 1 === next[1]) {
classname = 'snake_body_bottom_left'; // 如果上方、右方是蛇节点
}
// 左(判定左方是不是蛇节点)
if (node[1] - 1 === prev[1] || node[1] - 1 === next[1]) {
classname = 'snake_body_bottom_right'; // 如果上方、左方是蛇节点
}
} else {
classname = `snake_tail_bottom_${(node[0] + node[1]) % 2}`; // 蛇尾,通过坐标的 (x + y) % 2 来达到摇晃尾巴的目的
}
}
// 右
if (node[1] + 1 === prev[1] || node[1] + 1 === next[1]) {
if (isBody) {
classname = 'snake_body_horizontal';
// 上
if (node[0] - 1 === prev[0] || node[0] - 1 === next[0]) {
classname = 'snake_body_bottom_left';
}
// 下
if (node[0] + 1 === prev[0] || node[0] + 1 === next[0]) {
classname = 'snake_body_top_left';
}
} else {
classname = `snake_tail_left_${(node[0] + node[1]) % 2}`; // 蛇尾
}
}
// 下
if (node[0] + 1 === prev[0] || node[0] + 1 === next[0]) {
if (isBody) {
classname = 'snake_body_vertical';
// 右
if (node[1] + 1 === prev[1] || node[1] + 1 === next[1]) {
classname = 'snake_body_top_left';
}
// 左
if (node[1] - 1 === prev[1] || node[1] - 1 === next[1]) {
classname = 'snake_body_top_right';
}
} else {
classname = `snake_tail_top_${(node[0] + node[1]) % 2}`; // 蛇尾
}
}
// 左
if (node[1] - 1 === prev[1] || node[1] - 1 === next[1]) {
if (isBody) {
classname = 'snake_body_horizontal';
// 上
if (node[0] - 1 === prev[0] || node[0] - 1 === next[0]) {
classname = 'snake_body_bottom_right';
}
// 下
if (node[0] + 1 === prev[0] || node[0] + 1 === next[0]) {
classname = 'snake_body_top_right';
}
} else {
classname = `snake_tail_right_${(node[0] + node[1]) % 2}`; // 蛇尾
}
}

oDivs[node[0] * 30 + node[1]].className = classname;
}
},
};

和绘制地图类似,通过嗅探当前蛇节点的上右下左来确定具体的 classname

食物

食物的判定逻辑很简单,生成一个不在地图障碍物,也不在蛇身上的坐标点即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const snake = {
...

// 食物
food: [2, 8],
// 绘制食物
drawFood() {
// 所有坐标点
const oDivs = document.querySelectorAll('#game div');

// 清除旧位置食物
oDivs[this.food[0] * 30 + this.food[1]].className = '';

// 生成新位置食物
while (
// 地图中的障碍物
map[this.food[0]][this.food[1]] !== 0 ||
// 蛇自身
this.body.find((item) => item[0] === this.food[0] && item[1] === this.food[1])
) {
// 生成 (1-28, 1-28) 的坐标,因为 0 和 29 是墙
this.food = [Math.floor(Math.random() * 28 + 1), Math.floor(Math.random() * 28 + 1)]
}

oDivs[this.food[0] * 30 + this.food[1]].className = 'food';
},
};

游戏逻辑

初始化

游戏初始化时清空定时器、重置地图,重置 snake.bodysnake.direction 等参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const snake = {
...

// 定时器
timer: null,
// 初始化
init() {
this.pause();

document.getElementById('game').innerHTML = '';
drawMap();

this.body = [[2, 4], [2, 3], [2, 2]];
this.last = [2, 1];
this.direction = 'right';
this.drawFood();
this.drawSnake();
},
};

开始游戏

设定一个定时器来启动游戏,通过方向 snake.direction 来确定蛇头的下一个坐标点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const snake = {
...

// 开始游戏
start() {
this.pause();
this.timer = setTimeout(() => {
// 蛇头
const head = [];

switch (this.direction) {
case 'top': // 向上移动
head.push(this.body[0][0] - 1, this.body[0][1]);
break;
case 'right': // 向右移动
head.push(this.body[0][0], this.body[0][1] + 1);
break;
case 'bottom': // 向下移动
head.push(this.body[0][0] + 1, this.body[0][1]);
break;
case 'left': // 向左移动
head.push(this.body[0][0], this.body[0][1] - 1);
break;
}

...
};
},
};

和食物生成判定逻辑一致,查看新生成的蛇头坐标是否是地图障碍物,或者在蛇自身上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const snake = {
...

// 开始游戏
start() {
this.pause();
this.timer = setTimeout(() => {
...

// 判断是否撞墙
if (
// 地图中的障碍物
map[head[0]][head[1]] !== 0 ||
// 蛇自身
this.body.find((item) => item[0] === head[0] && item[1] === head[1])
) {
alert('Game Over!');
return this.init();
}

...
};
},
};

如果没有结束游戏,那么就添加蛇头进 body 里,然后判定蛇头是否和食物重叠,如果重叠则重新生成食物并更新游戏分数,如果没有重叠,就将蛇尾 pop 掉,在重新绘制蛇时恢复样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const snake = {
...

// 开始游戏
start() {
this.pause();
this.timer = setTimeout(() => {
...

// 将蛇头添加
this.body.unshift(head);

// 如果吃到食物,就重绘食物、不移除蛇尾
if (head[0] === this.food[0] && head[1] === this.food[1]) {
this.drawFood();
document.getElementById('score').innerHTML = `分数:${this.body.length - 3}`;
} else {
// 将蛇尾移除
this.last = this.body.pop();
}

// 重绘蛇
snake.drawSnake();

this.start();
};
},
};

以上。