使用 canvas 实现贪吃蛇
童年使用的诺基亚基本上都会搭载这款小游戏,最近心血来潮想用 canvas 来实现这个游戏就有了这篇文章,下面讲解一下实现的思路,本文的最终代码已经放到了codesandbox上。
当然实现的方式并不止 canvas
这一种,还可以使用 html
+ css
的形式,这里不讨论优劣取舍,让我们快速开始吧。
游戏规则
在开发之前先要设计游戏的具体规则是怎么样的,就像是实现polyfill
也要对照规范一样,贪吃蛇游戏规则还是蛮简单下面概括一下:
- 初始的时候会被四周墙体包围着,贪吃蛇这时候很虚弱只有一节,墙的周围随机分布着一个鸡蛋(奖励品)
- 按下方向键游戏开始,吃到鸡蛋身体会边长,同时会生成新的鸡蛋
- 撞到墙体和撞到身体会结束游戏
上面规则概括成代码就是我们需要给一个界面范围、绘制蛇和鸡蛋、让蛇动起来。
基本概念
canvas
是 html5
出现的元素,它可以用于数据可视化、动画、游戏、图像操作、视频等方面,它以 JavaScript
的方式来进行交互。
为了方便下面的理解介绍几个基本概念
var ctx = canvas.getContext(contextType);
getContext
返回 canvas
的上下文,contextType
指定了何种上下文,它有 2d
、webgl
、webgl2
、bitmaprenderer
这几种选项,这里只需要 2d 界面,所以填写 2d 即可。
可以根据
canvas.getContext
存在判断浏览器支不支持canvas
元素
CanvasRenderingContext2D.clearRect
指定矩形区域的像素都变成透明,注意这个方法不等于完全清除画布,对于一些 path
的方法记得手动 closePath()
- 动画的组成原理
后续我们需要让贪吃蛇动起来,但是动这个概念是一个伪命题,想一下电视和电影是怎么让我们感觉到动的呢?就是通过一帧帧的画面快速播放得到的视觉效果。
后面让蛇动起来也是通过定时器来让 canvas
不断重绘达到动画的效果。
实现思路
如文章首页的示例图片,贪吃蛇可以想象成一个坐标,根据这个坐标很容易得到下面启示:
- 蛇和鸡蛋都会在坐标中出现,但不会超出
- 撞到坐标的边界即是撞墙
- 每次贪吃蛇移动都需要擦拭移动的路径
- 判断有没有撞到身体和吃不是吃到鸡蛋,判断移动的坐标值是什么即可
也就是说,贪吃蛇这个游戏最核心的就是坐标这个概念,为了快速得到坐标的信息,我们期待以下面的形式来储存数据。
interface CoordinateAll {
[x: number]: {
[y: number]: 'block' | 'egg' | 'sanke';
};
}
// 转化成JavaScript表示
{
1: {1: 'block', 2: 'egg', 'block'},
2: {1: 'block', 2: 'block', 'block'},
// ...
}
为了后续的使用方便,提前定义两个文件
// utils.js
// 传递一个整数,来循环它
export const eachNumber = (n, fn) => {
for (let i = 0; i < n; i++) {
fn(i);
}
};
// 返回随机整数
export const randomNumber = (min, max) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
// const.js
// canvas宽度
export const WIDTH = 400;
// 高度
export const HEIGHT = 400;
// 像素x
export const PIXEL_X = 20;
// 像素y
export const PIXEL_Y = 20;
// 行
export const ROW = HEIGHT / PIXEL_Y;
// 列
export const COLUMN = WIDTH / PIXEL_X;
// 半径
export const RADIUS = 10;
// 定时器间隔
export const INTERVAL = 150;
// 鸡蛋颜色
export const EGG_COLOR = '#767803';
// 蛇颜色
export const SANKE_COLOR = '#6f5f06';
// 边框颜色
export const BORDER_COLOR = '#796e09';
后面就开始绘制初始的界面。
初始化界面
class Sanke {
constructor() {
const canvas = document.createElement('canvas');
canvas.width = WIDTH;
canvas.height = HEIGHT;
this.canvas = canvas;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
this.ctx = ctx;
// 蛇身
this.currentSanke = [];
// 出现的egg位置
this.currentEgg = null;
// 坐标系
this.coordinateAll = {};
this.init();
}
init() {
// 创建坐标系
this.coordinateAll = {};
eachNumber(ROW, (x) => {
this.coordinateAll[x] = {};
eachNumber(COLUMN, (y) => {
this.coordinateAll[x][y] = 'block';
});
});
const sanke = this.randomCoordinate();
this.setCoordinate(sanke, 'sanke');
this.currentSanke = [sanke];
this.currentEgg = this.randomCoordinate();
this.setCoordinate(this.currentEgg, 'egg');
this.repaint();
}
repaint() {
const { ctx, canvas, currentSanke } = this;
// 绘制egg和绘制sanke
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = BORDER_COLOR;
ctx.strokeRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = SANKE_COLOR;
currentSanke.forEach((item) => {
ctx.fillRect(item.x * PIXEL_X, item.y * PIXEL_Y, PIXEL_X, PIXEL_Y);
});
// 绘制egg
ctx.beginPath();
ctx.fillStyle = EGG_COLOR;
if (!this.currentEgg) {
this.currentEgg = this.randomCoordinate();
this.setCoordinate(this.currentEgg, 'egg');
}
const { currentEgg } = this;
ctx.arc(
0 + RADIUS + currentEgg.x * PIXEL_X,
0 + RADIUS + currentEgg.y * PIXEL_Y,
RADIUS,
0,
Math.PI * 2,
true
);
ctx.fill();
ctx.closePath();
}
randomCoordinate() {
const arr = [];
eachNumber(ROW, (x) => {
eachNumber(COLUMN, (y) => {
const value = this.coordinateAll[x][y];
if (value === 'block') {
arr.push({ x, y });
}
});
});
// 说明无坐标可取
if (!arr.length) {
return null;
}
return arr[randomNumber(0, arr.length)];
}
setCoordinate({ x, y }, value) {
this.coordinateAll[x][y] = value;
}
getCoordinate({ x, y }) {
if (x < 0 || y < 0 || x > ROW - 1 || y > COLUMN - 1) {
return null;
}
return this.coordinateAll[x][y];
}
}
代码看起来还是蛮多的,不过之前已经介绍过实现的思路了,接下来一步步分析执行:
constructor
这里主要是创建 canvas
对象,在 this
上绑定一些必要的属性,例如坐标系
、egg 位置
和 sanke 位置
,这里 currentSanke
array
是因为蛇存在多节,用数组管理比较方便的。
init
用 init
来完成组装,第一步是创建坐标系,这里给所有的初始元素打上 block
,它一共三个值:'block' | 'egg' | 'sanke'
。
后面随机获取蛋的位置和蛇的位置,储存到 coordinateAll
中,最后调用 repaint
完成绘制
repaint
这一步就是调用 canvas
完成界面绘制,主要用到了两个 api
ctx.arc 和 ctx.fillRect,前者绘制弧形后者填充矩形,先 clearRect
后绘制是因为这个方法后续会被重复调用,后面的一些randomCoordinate
、setCoordinate
、getCoordinate
都是跟坐标方法有关,比较简单看一下即可。
到这里我们就可以得到静态的效果,后面就是让其动起来。
动起来
想让贪吃蛇动起来,我们需要做两步:
- 监听方向键,也就是动的方向(也充当启动游戏作用)
- 使用定时器不断的重绘蛇和鸡蛋的位置来造成视觉的移动
监听方向键很简单,我们直接在 body
上监听 keydown
即可,这个事件在键盘按下的时候触发
this.keydownFn = (e) => {
const code = e.code;
const result = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].indexOf(
code
);
if (result <= -1) {
return;
}
this.direction = code;
};
monitor() {
document.body.addEventListener('keydown', this.keydownFn);
};
move() {
if (this.timeId) {
clearInterval(this.timeId);
}
this.timeId = setInterval(() => {
const { direction, currentSanke, currentEgg } = this;
// 如果方向键不存在就返回
if (!direction) {
return;
}
let nextCoordinate;
// 蛇头始终是0
const { x, y } = currentSanke[0];
switch (direction) {
case 'ArrowDown':
case 'ArrowUp':
nextCoordinate = { x, y: direction === 'ArrowUp' ? y - 1 : y + 1 };
break;
case 'ArrowLeft':
case 'ArrowRight':
nextCoordinate = { y, x: direction === 'ArrowLeft' ? x - 1 : x + 1 };
break;
default:
break;
}
// nextCoordinate的作用是确认下一步是什么类型
const value = this.getCoordinate(nextCoordinate);
switch (value) {
case 'block':
this.setCoordinate(currentSanke.pop(), 'block');
currentSanke.unshift(nextCoordinate);
this.setCoordinate(nextCoordinate, 'sanke');
break;
case 'egg':
this.setCoordinate(currentEgg, 'sanke');
this.currentEgg = null;
currentSanke.unshift(nextCoordinate);
break;
case 'sanke':
case null:
// 游戏结束
break;
default:
break;
}
this.repaint();
}, INTERVAL);
};
之后在 init 中插入这这两个方法
init() {
// ...
this.monitor();
this.move();
}
到这一步,贪吃蛇也实现了动起来的要求,在 swtich
中判断 value
的类型来决定坐标值如何变化,之后继续执行 repaint
重绘界面
结束游戏
上面 move
对游戏结束只进行了注释,下面就把 case null:
下面语句替换成this.end
end() {
if (this.timeId) {
clearInterval(this.timeId);
}
this.timeId = null;
document.body.removeEventListener('keydown', this.keydownFn);
alert(`游戏结束,当前分数:${(this.currentSanke.length - 1) * 10}`);
};
最后
如果有什么错误请不吝指点,对你有帮助也可以 start
一下。