一、引言
在 Web 开发的世界中,利用 HTML、CSS 和 JavaScript 构建有趣的互动游戏是一项充满乐趣和挑战的任务。本文将详细介绍一个基于 HTML5 的贪吃蛇小游戏的实现过程,通过对代码的解析,带你了解如何打造一个经典的贪吃蛇游戏,并在网页上流畅运行。
二、游戏界面与基本样式
游戏的界面主要由一个表示游戏地图的div
(类名为snake-map
)、用于显示操作按钮的div
(类名为operate
)以及用于展示游戏说明的div
(类名为desc
)组成。
在 CSS 部分,snake-map
被设置为相对定位,并通过flex
布局使其内部元素水平垂直居中。游戏中的每个方块(蛇身、苹果和空白区域)都是一个绝对定位的div
(类名为snake-item
),通过设置不同的背景颜色来区分它们,如蛇身是绿色(类名为snake
),苹果是深红色(类名为apple
)。
操作按钮(类名为btn
)同样使用flex
布局,方便用户通过点击来控制蛇的移动方向。为了提升用户体验,当按钮被按下时,会通过active
伪类改变背景颜色和文字颜色。
三、游戏逻辑代码解析
- 引入外部工具库:通过
import
语句从https://unpkg.com/@3r/tool
引入了一些工具函数,如v2
(可能用于表示二维向量)、Maths
(数学相关操作)、Randoms
(生成随机数)和cloneDeep
(深度克隆对象)。 - 游戏参数初始化:定义了游戏地图的宽度
width
和高度height
,并创建了一个二维数组map
来表示游戏地图,初始值都为0
(表示空白格)。同时,定义了一个枚举对象mapEnum
,用于标识地图上不同元素的类型,如blank
(空白格)、snake
(蛇)和apple
(苹果)。 - 蛇和移动方向:用一个数组
snake
来存储蛇的身体部分的位置,初始时蛇有两个身体部分。movingDirection
变量用于记录蛇的移动方向,初始方向为向右。 - DOM 元素获取与变量声明:获取游戏地图的 DOM 元素
snakeConDom
、操作按钮的 DOM 元素集合operateDom
,并创建一个对象snakeMapDom
用于存储地图上每个方块对应的 DOM 元素。此外,还声明了一些变量用于记录地图的最大边界maxY
和maxX
,以及控制游戏循环的intervalTime
和intervalId
。 - 初始化函数
init
:该函数负责创建游戏地图的 DOM 结构,根据map
数组的大小动态生成每个方块的 DOM 元素,并设置它们的位置和初始样式。同时,将蛇的初始位置在地图上进行标记,设置操作按钮的点击事件监听器,获取地图的最大边界,并生成游戏中的第一个苹果。 - 渲染地图函数
renderMap
:遍历map
数组,根据每个位置的元素类型,为对应的 DOM 元素添加或移除相应的类名,从而更新游戏地图的显示状态。 - 生成苹果函数
generateApple
:通过生成随机坐标,检查该位置是否为空白格,如果是则将其设置为苹果(在map
数组中标记为mapEnum.apple
),否则重新生成随机坐标,直到找到合适的位置。 - 移动函数
moving
:这是游戏的核心逻辑函数。首先判断当前的移动方向是否为0
(即停止移动),如果是则直接返回。然后计算蛇头的新位置newHead
,根据新位置的元素类型进行不同的处理:- 如果新位置是空白格,将蛇尾从数组中移除,并在新位置添加蛇头,同时更新地图上的元素标记。
- 如果新位置是苹果,生成一个新的苹果,并在新位置添加蛇头,增加蛇的长度。
- 如果新位置是蛇本身或超出地图边界,则游戏结束,清除游戏循环定时器,并在游戏地图上显示游戏结束的提示信息。
- 按键处理函数
onKeyDown
:根据用户按下的按键(向上、向下、向左、向右箭头键),计算对应的移动方向。在设置新的移动方向之前,会检查是否与当前禁止的移动方向(即蛇当前移动的反方向)相同,如果相同则不进行操作,避免蛇反向移动导致游戏逻辑错误。 - 事件监听器与游戏启动:通过
document.addEventListener("keydown", (ev) => onKeyDown(ev.key))
监听键盘按键事件,调用onKeyDown
函数处理用户的按键操作。最后,调用init
函数初始化游戏,并调用renderMap
函数渲染初始地图,通过setInterval(moving, intervalTime)
启动游戏循环,每隔intervalTime
时间调用moving
函数更新游戏状态。
完整代码展示
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="./assets/global.css">
<style>
.snake-map {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.snake-map .snake-item {
background-color: saddlebrown;
position: absolute;
width: 20px;
height: 20px;
}
.snake-map .snake-item.snake {
background-color: green;
}
.snake-map .snake-item.apple {
background-color: crimson;
}
.operate {
display: flex;
flex-wrap: wrap;
margin: 0 20px;
width: 120px;
padding: 20px 0;
}
.operate .btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.operate .btn:active {
background-color: saddlebrown;
color: #fff;
transition: all .4s ease-in-out;
}
.operate .flex {
width: 100%;
}
.desc {
margin: 20px;
font-size: 12px;
color: rgba(0, 0, 0, 0.7);
}
</style>
</head>
<body>
<div class="snake-map"> </div>
<div class="desc">玩法:通过上下左右键或屏幕上的上下左右将使其绿色线条(🐍)吃到红色方块(🍎)记录得分。</div>
<div class="operate">
<div class="btn flex">上</div>
<div class="btn">左</div>
<div class="btn">下</div>
<div class="btn">右</div>
</div>
<script type="module">
import { v2, Maths, Randoms, cloneDeep } from "https://unpkg.com/@3r/tool";
let width = 20, height = 20;
let map = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
let mapEnum = {
blank: 0, // 空白格
snake: 1, // 蛇 🐍
apple: 2, // 苹果 🍎
}
// 蛇数据
let snake = [v2(5, 5), v2(5, 6)]
// 移动方向
let movingDirection = v2(1, 0)
// 容器
let snakeConDom = document.querySelector('.snake-map')
// 操作元素
let operateDom = document.querySelectorAll('.operate > .btn')
// 地图
let snakeMapDom = {}
// 地图最大边界
let maxY = 0;
let maxX = 0;
// 延迟
let intervalTime = 500;
let intervalId = 0;
// 初始化
function init() {
let ylength = map.length, xlength = 0;
for (let y = 0; y < map.length; y++) {
const xmap = map[y];
xlength = Math.max(xlength, xmap.length)
for (let x = 0; x < xmap.length; x++) {
let snakeItem = document.createElement('div')
snakeItem.classList.add('snake-item')
snakeItem.setAttribute(`style`, `left:${x * width}px;top:${y * height}px`)
snakeConDom.appendChild(snakeItem)
snakeMapDom[`${x},${y}`] = snakeItem
}
}
for (let i = 0; i < snake.length; i++) {
const snakeBody = snake[i];
map[snakeBody.y][snakeBody.x] = mapEnum.snake;
}
snakeConDom.setAttribute(`style`, `width:${xlength * width}px;height:${ylength * height}px`)
for (let i = 0; i < operateDom.length; i++) {
const btn = operateDom.item(i);
btn.addEventListener('click', function () {
onKeyDown(['ArrowUp', 'ArrowLeft', 'ArrowDown', 'ArrowRight'][i])
})
}
maxY = ylength;
maxX = xlength;
generateApple();
}
// 渲染地图
function renderMap() {
for (let y = 0; y < map.length; y++) {
const xmap = map[y];
for (let x = 0; x < xmap.length; x++) {
let snakeItem = snakeMapDom[`${x},${y}`]
if (xmap[x] == mapEnum.snake) snakeItem.classList.add('snake')
else snakeItem.classList.remove('snake')
if (xmap[x] == mapEnum.apple) snakeItem.classList.add('apple')
else snakeItem.classList.remove('apple')
}
}
}
// 生成苹果
function generateApple() {
let apple = v2(Randoms.getRandomInt(0, maxX), Randoms.getRandomInt(0, maxY))
if (map[apple.y][apple.x] != mapEnum.blank) return generateApple();
map[apple.y][apple.x] = mapEnum.apple;
return;
}
// 移动
function moving() {
// 运动为0时则停止运动
if (Maths.equal(movingDirection, v2(0, 0))) return;
// 头
let head = cloneDeep(snake[0])
// 新头
let newHead = head.plus(movingDirection)
// 当新头部是空白时
if (map?.[newHead.y]?.[newHead.x] == mapEnum.blank) {
// 尾巴
let tail = snake.pop()
map[tail.y][tail.x] = mapEnum.blank;
map[newHead.y][newHead.x] = mapEnum.snake;
snake.unshift(newHead)
}
// 再生成一个新🍎
else if (map?.[newHead.y]?.[newHead.x] == mapEnum.apple) {
generateApple()
map[newHead.y][newHead.x] = mapEnum.snake;
snake.unshift(newHead)
}
else {
clearInterval(intervalId)
snakeConDom.innerHTML = `游戏结束,得分为${snake.length - 2}`
}
renderMap()
}
function onKeyDown(key) {
let direction = v2(0, 0)
if (key == 'ArrowUp') direction = v2(0, -1)
if (key == 'ArrowDown') direction = v2(0, 1)
if (key == 'ArrowLeft') direction = v2(-1, 0)
if (key == 'ArrowRight') direction = v2(1, 0)
// 如果没有运动方向
if (Maths.equal(movingDirection, direction)) return;
let cannotMovingDirection = snake[1].subtract(snake[0]);
// 如果移动方向是禁止的
if (Maths.equal(cannotMovingDirection, direction)) return;
movingDirection = direction;
}
document.addEventListener("keydown", (ev) => onKeyDown(ev.key))
init();
renderMap()
intervalId = setInterval(moving, intervalTime)
</script>
</body>
</html>