介绍
大家好,我是大胆的番茄。本文是「挑战 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
|
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 地图:
绘制
资源:
地图的资源总共 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 = 900
个 div
,使用网格系统将每个 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 = () => { 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');
let classname = '';
if (map[i][j] === 1) { classname = 'grass_top';
if (map[i - 1]?.[j] === 1) { classname = '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
。如果采用 1
为 grass_top_left
、2
为 grass_top
等(或用对象)将所有素材都表示详细,这里的 for
循环就不需要了。
贪吃蛇
将所有游戏逻辑写到了一个 snake
对象当中,用 body
来表示蛇,第 0 个元素为蛇头,当移动时将新节点 unshift
到 body
当中,同时进行 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], direction: 'right',
... };
|
绘制
资源:
蛇的资源并没有把方方面面都包含,所以我通过 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'; 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}`; } } 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]) ) { 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.body
、snake.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(); }; }, };
|
以上。