很久以前在学校图书馆里发现一本介绍 Canvas 的书,给出的最简单的一个项目是写个贪吃蛇的游戏。
I. 准备工作
- 首先找个2D的贪吃蛇游戏玩一玩找找感觉,留意一下游戏的实现细节
- 打开 VSCode,或者别的你感觉比较趁手的代码编辑器
- 新建一个空白的HTML文档
index.html之类的,名字无所谓,把这段东西贴进去
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Snake</title>
<style>
#game {
border: 1px solid #333;
}
</style>
</head>
<body>
<canvas id="game" width="400" height="300"></canvas>
<script>
const gameCanvas = document.getElementById('game');
const ctx = gameCanvas.getContext('2d');
ctx.fillStyle = 'green';
ctx.fillRect(10, 20, 100, 200);
</script>
</body>
</html>
如果你打开创建好的 HTML 文件,会看到一个黑色的框框,里面有个绿色的矩形。这个框框里就是 <canvas>,宽 400 像素,高 300 像素,黑色边框没什么别的用处,纯碎是方便确认画布的位置。框框里的矩形则是用 JavaScript 绘制出来的图形。
HTML 中 <canvas> 是画布,HTML 中一个专门拿来画画绘制图形的元素,width 和 height 指定了画布的大小。想要在 canvas 上面画东西,首先你得获取到那个画布(想想看如果你有多个 canvas 元素在页面上是吧),document.getElementById(xxx) 这句代码已经十分直白了,注意到没,代码里的 canvas 上有一句 id="game",所以你就可以通过这个 id 获取到对应的画布。
不过只是获取到画布是不够的,还要拿到它的上下文(context),所以这里用了一句 gameCanvas.getContext('2d'),gameCanvas 是前一句定义好的变量,虽然这里通常会顺便写成 canvas 或者 game,不过对于编程没什么概念的话可能会产生困惑。
ctx.fillStyle = 'green';
ctx.fillRect(10, 20, 100, 200);
这两句就很直白了,fill 填充,rect 矩形,style 样式,凑在一起就可以理解了。
- 关于 canvas 元素的详细的介绍可以看: https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/canvas
- 关于 JavaScript Canvas API 的介绍可以看:https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API
II. 准备舞台
首先,请回想一下见到过的贪吃蛇游戏,蛇的身体是分成一个一个小格子的,食物自己是占据一个小格子的,整个地图(画布)都是分成好多个小格子的,每个小格子都是一个放大了的「像素」。我们姑且称它为 cell 好了。
我们可以让一个 cell 的宽高都为 20 像素,这样画布就会有 20列x15行。现在,将 <script></script> 标签中的代码替换为下面这个样子,然后保存并刷新之前的 HTML 文档。
const gameCanvas = document.getElementById('game');
const ctx = gameCanvas.getContext('2d');
const cellSize = 20;
const yCount = gameCanvas.height / cellSize;
const xCount = gameCanvas.width / cellSize;
// 绘制网格
ctx.strokeStyle = '#dddddd';
for (let i = 0; i < xCount; i++) {
for (let r = 0; r < yCount; r++) {
ctx.strokeRect(i * cellSize, r * cellSize, cellSize, cellSize);
}
}
你会看到原本的黑色框框被网格填充了,这个网格和刚刚的边框一样没什么用处,纯粹是方便观察。
#dddddd 是一种颜色的表示方法,是三个十六进制数分别代表红(R)绿(G)蓝(B),三个数字一样的话就是某种程度的灰。
III. 画蛇
画蛇,不过首先得知道蛇是什么样子,以及它是怎么移动的。如果你有思考过,可能会意识到,蛇在移动的时候,其实只有尾巴在动,最后一节的尾巴会 duang 一下子跑到蛇的前面去,其他部分都没有真的动。
然后,蛇的每一节都占据一个格子,我们可以取个坐标。还记得前面的行数和列数嘛,如果坐标都从 0 开始的话,就很简单了,大概这个样子:
| 列1 | 列2 |
|---|---|
| 0 0 | 1 0 |
| 0 1 | 1 1 |
然后我们可以定义一条刚刚开始的两节的蛇。这里用了 10,11,7 是尽可能把它放在中间,不过更合理的做法是用 xCount 和 yCount 去计算合适的位置,我们偷个懒先。
const snake = [
{ x: 10, y: 7 },
{ x: 11, y: 7 },
];
那么怎么画蛇?
// 定义一个函数来画蛇
function drawSnake() { // 姑且继续让 snake 作为全局变量
snake.forEach(cell => { // cell => {} 是另一种写法更简单的定义函数的写法
ctx.fillRect(cell.x * cellSize, cell.y * cellSize, cellSize, cellSize);
});
}
drawSnake(); // 可以这样子调用我们定义的方法
总之现在大概可以看到两个连在一起的方块在画布中间,当前的 JS 代码大概是这个样子:
const gameCanvas = document.getElementById('game');
const ctx = gameCanvas.getContext('2d');
const cellSize = 20;
const yCount = gameCanvas.height / cellSize;
const xCount = gameCanvas.width / cellSize;
const snake = [
{ x: 9, y: 7 },
{ x: 10, y: 7 },
];
drawSnake();
function drawSnake() { // 函数可以定义在任意位置
snake.forEach(cell => {
ctx.fillRect(cell.x * cellSize, cell.y * cellSize, cellSize, cellSize);
});
}
IV. 蛇啊,动起来吧
说起来,我们可能会分不清哪边是蛇头,可以考虑给第一个 cell 改成不同的颜色,不过这不重要。不过呢,蛇的前进方向是重要的,来定义一个好了
// 这些东西最好放在 snake 的前面去
const TOP = 0;
const RIGHT = 1;
const DOWN = 2;
const LEFT = 3;
let dir = LEFT;
现在问题来了,要怎么让蛇动起来呢?没有什么好的办法,需要重绘画布。多久重新回一次呢,无所谓,大概一两百毫秒?久了就会感觉巨卡,短了可能反应不过来。一个比较好的做法是蛇越长间隔越短,相当于难度提高。
JavaScript 里面正好有个方法可以搞这件事⬇️,不过有两个问题,一是作为背景的网格没了,而是蛇没动(当然)。
setInterval(function() {
drawSnake();
}, 200);
网格好说,我们把那段绘制网格的代码塞进来就好了,不过会变很长,所以可以定义成一个函数,比如叫 drawGrid() 之类的。
setInterval(function() {
drawGrid();
drawSnake();
}, 200);
我们还需要一个方法来移动蛇,前面讲过我们不需要改变每一个 cell,而只需要把最后一个 cell 拼到最前面去。
snake.pop() 可以把 snake 这个数组的最后一个丢出来,snake.unshift(x) 则可以在数组的头部插入元素 x。
setInterval(function() {
ctx.clearRect(0, 0, gameCanvas.width, gameCanvas.height);
drawGrid();
moveSnake();
drawSnake();
}, 200);
// ...
function moveSnake() {
snake.pop();
const head = snake[0];
switch(dir) {
case UP:
snake.unshift({ x: head.x, y: head.y - 1 });
break;
case RIGHT:
snake.unshift({ x: head.x + 1, y: head.y });
break;
case DOWN:
snake.unshift({ x: head.x, y: head.y + 1 });
break;
case LEFT:
snake.unshift({ x: head.x - 1, y: head.y });
break;
}
}
现在的代码大概是这个样子的
const gameCanvas = document.getElementById('game');
const ctx = gameCanvas.getContext('2d');
const cellSize = 20;
const yCount = gameCanvas.height / cellSize;
const xCount = gameCanvas.width / cellSize;
const UP = 0;
const RIGHT = 1;
const DOWN = 2;
const LEFT = 3;
let dir = LEFT;
const snake = [
{ x: 9, y: 7 },
{ x: 10, y: 7 },
];
drawGrid();
setInterval(function() {
ctx.clearRect(0, 0, gameCanvas.width, gameCanvas.height);
drawGrid();
moveSnake();
drawSnake();
}, 200);
function moveSnake() {
snake.pop();
const head = snake[0];
switch(dir) {
case UP:
snake.unshift({ x: head.x, y: head.y - 1 });
break;
case RIGHT:
snake.unshift({ x: head.x + 1, y: head.y });
break;
case DOWN:
snake.unshift({ x: head.x, y: head.y + 1 });
break;
case LEFT:
snake.unshift({ x: head.x - 1, y: head.y });
break;
}
}
function drawGrid() {
// 绘制网格
ctx.strokeStyle = '#dddddd';
for (let i = 0; i < xCount; i++) {
for (let r = 0; r < yCount; r++) {
ctx.strokeRect(i * cellSize, r * cellSize, cellSize, cellSize);
}
}
}
function drawSnake() {
snake.forEach(cell => {
ctx.fillRect(cell.x * cellSize, cell.y * cellSize, cellSize, cellSize);
});
}