挑战每月一款小游戏「贪吃蛇」总结与分享

·  阅读 467
挑战每月一款小游戏「贪吃蛇」总结与分享

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

演示地址:wanghaida.com/demo/2201-s…

Github 仓库:github.com/wanghaida/g…

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

地图

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

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

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

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

/**
 * 地图
 * @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 判定来确定具体使用哪个。

<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 的正方形。

// 游戏区
#game {
    display: grid;
    grid-template-columns: repeat(30, 20px);
    grid-template-rows: repeat(30, 20px);
    width: 600px;
    height: 600px;
}
复制代码

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

/**
 * 绘制地图
 */
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 来表示当前行进方向。

const snake = {
    // 蛇
    body: [[2, 4], [2, 3], [2, 2]],
    // 被移除的蛇尾(用来清除样式)
    last: [2, 1],
    // 方向(top right bottom left)
    direction: 'right',
    
    ...
};
复制代码

绘制

资源:

220106165519.png

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

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);
}
复制代码

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

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

食物

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

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 等参数:

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 来确定蛇头的下一个坐标点:

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;
            }
            
            ...
        };
    },
};
复制代码

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

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 掉,在重新绘制蛇时恢复样式:

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();
        };
    },
};
复制代码

以上

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改