前言
上周末写了篇canvas实现贪吃蛇,今天再来个贪吃蛇!
技术选型
我们的贪吃蛇游戏将由Canvas渲染。下面是我们需要完成的步骤:
- 创建画布(Canvas)和游戏区域
- 绘制蛇和食物
- 监听键盘事件,以控制蛇的移动
- 判断输赢(当蛇吃到食物时,蛇会变长,游戏继续;当蛇碰到自身或边界时,游戏结束)
实现
第一步:创建画布和游戏区域
html
<!-- html只需提供一个canvas标签 -->
<canvas id="canvas"></canvas>
js
class Snake {
constructor(canvas, { width = 400, height = 400 } = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.width = width;
this.height = height;
// 格子宽度
this.gap = 10;
// 初始化
this.init();
}
init() {
const { canvas, width, height } = this;
canvas.width = width;
canvas.height = height;
// 画格子
this.drawGrid();
}
// 画格子
drawGrid() {
const { ctx, width, height, gap } = this;
// 青绿色背景
ctx.fillStyle = "#96c93d";
ctx.fillRect(0, 0, width, height);
// 白色线条
ctx.lineWidth = 0.5;
ctx.strokeStyle = "#fff";
for (let i = 0; i < width; i += gap) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, height);
ctx.stroke();
ctx.closePath();
}
for (let i = 0; i < height; i += gap) {
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(width, i);
ctx.stroke();
ctx.closePath();
}
}
}
此时效果如下
第二步:绘制蛇和食物
要先生成蛇和食物的坐标,默认蛇的位置是固定的,食物的位置是随机的,改写一下init方法
js
init() {
const { canvas, width, height } = this;
canvas.width = width;
canvas.height = height;
// 画格子
this.drawGrid();
// 初始化蛇的坐标,位置固定
this.snake = [
{ x: 10, y: 100 },
{ x: 20, y: 100 },
{ x: 30, y: 100 },
{ x: 40, y: 100 },
{ x: 50, y: 100 },
];
// 初始化食物
this.food = this.randomFood();
// 绘制蛇
this.drawSnake();
// 绘制食物
this.drawFood();
}
写个方法随机生成食物坐标,后面还要用。
js
randomFood() {
const { width, height } = this;
// 生成0-最大宽度之间的10倍的整数
const x = Math.floor((Math.random() * width) / 10) * 10;
const y = Math.floor((Math.random() * height) / 10) * 10;
return { x, y };
}
js
// 绘制蛇
drawSnake() {
const { ctx, snake, gap, width, height } = this;
snake.forEach((item, index) => {
// 蛇头部画个红色
// 身体画黑色
if (index === snake.length - 1) {
ctx.fillStyle = "red";
} else {
ctx.fillStyle = "black";
}
ctx.fillRect(item.x + 1, item.y + 1, gap - 1, gap - 1);
});
}
// 绘制食物
drawFood() {
const { ctx, food, gap } = this;
const { x, y } = food;
ctx.fillStyle = "#035c03";
ctx.fillRect(x, y, gap, gap);
}
此时效果如下
第三步:控制蛇的移动
每隔多长时间蛇前进一步,即,尾部减少一格,头部增加一格,增加的这一格的坐标应该怎么计算?这个要**「根据蛇当前的移动方向来计算」**。
实现核心的运动函数,在init中调用一下
js
init() {
// ... 前面的省略了
this.move()
}
js
move() {
const { width, height, ctx, snake, gap } = this;
// 每200ms更新一次蛇的坐标,
// 并重新渲染Canvas,🐍就动起来了
setInterval(() => {
// 更新蛇的坐标
// 获取增加的头部节点坐标
// 因为默认往右的,头部其实是在数组的最后一项
let { x, y } = snake.at(-1);
// 头部x轴增加一格,y轴不变
x += gap;
// 尾巴去掉一格
snake.shift();
// 头部新增一格
snake.push({ x, y });
// 清空画布
ctx.clearRect(0, 0, width, height);
// 重新画格子
this.drawGrid();
// 重新画蛇
this.drawSnake();
}, 200);
}
这里更新了蛇的坐标,重新调用ctx.clearRect
清空蛇尾,重新调用ctx.fillRect
画蛇头,这样应该会更好,就不用清空整个画布重新绘制了。先实现功能吧~
此时效果如下(这里gif帧率过低了,跟实际效果有区别)
但是只实现了往一个方向移动,并且可以看到蛇跑到游戏区域外了,下面来解决这个问题。
定义一个方向变量,并且绑定键盘事件来改变方向。
js
init() {
// ... 前面的省略了
// 方向,默认往右
this.direction = "right";
// 绑定操作
this.bindEvent();
}
bindEvent() {
// 键盘上的方向按键
const keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
document.addEventListener("keydown", (event) => {
// 规定只有按方向按键才起作用
if (!keys.includes(event.key)) return
// 不允许往反方向移动
if (event.key === "ArrowUp" && this.direction !== "bottom") {
this.direction = "top";
} else if (event.key === "ArrowDown" && this.direction !== "top") {
this.direction = "bottom";
} else if (event.key === "ArrowLeft" && this.direction !== "right") {
this.direction = "left";
} else if (event.key === "ArrowRight" && this.direction !== "left") {
this.direction = "right";
}
// 改变方向后要重新调用一下move函数
this.move()
});
}
move
函数也要改写一下,增加根据方向控制运动的代码:
js
move() {
const { width, height, ctx, snake } = this;
// 因为要多次调用,每次调用前要清空定时器
clearInterval(this.timer);
this.timer = setInterval(() => {
// 更新蛇的坐标
// 获取增加的节点坐标
const { x, y } = this.updateSnake()
// 尾巴去掉一格
snake.shift();
// 头部新增一格
snake.push({ x, y });
// 清空画布
ctx.clearRect(0, 0, width, height);
// 画格子
this.drawGrid();
// 重新画蛇
this.drawSnake();
}, 200);
}
js
updateSnake() {
const { snake, direction, gap } = this
let { x, y } = snake.at(-1);
switch (direction) {
case "right":
x += gap;
break;
case "left":
x -= gap;
break;
case "top":
y -= gap;
break;
case "bottom":
y += gap;
break;
default:
break;
}
return { x, y }
}
现在就可以用方向键控制拐弯了
第四步:判断输赢
- 蛇吃到食物的判断:蛇的头部坐标和食物坐标重合,即吃到了食物,在蛇的尾部增加一格
- 蛇撞到边界或自身的判断:蛇的头部和四周边缘坐标重合、或者和自己的身体某一部分坐标重合,即游戏结束
改写move
函数,判断是否吃到食物,重新绘制食物
js
move() {
const { width, height, ctx, snake } = this;
// 因为要多次调用,每次调用前要清空定时器
clearInterval(this.timer);
this.timer = setInterval(() => {
// 更新蛇的坐标
// 获取增加的节点坐标
const { x, y } = this.updateSnake()
// 吃的动作,把最新的头部坐标传进去
this.handleEat(x, y)
// 游戏结束
if (this.isHitWall({ x, y }) || this.isEatSelf({ x, y })) {
console.log('游戏结束')
clearInterval(this.timer);
return
}
// 尾巴去掉一格
snake.shift();
// 头部新增一格
snake.push({ x, y });
// 清空画布
ctx.clearRect(0, 0, width, height);
// 画格子
this.drawGrid();
// 重新画蛇
this.drawSnake();
// 重新绘制食物
this.drawFood();
}, 200);
}
// 吃
handleEat(x, y) {
const { snake, direction, gap, ctx } = this
if (x === this.food.x && y === this.food.y) {
// 吃到了食物
let { x, y } = snake[0];
switch (direction) {
case "right":
x -= gap;
break;
case "left":
x += gap;
break;
case "top":
y += gap;
break;
case "bottom":
y -= gap;
break;
default:
break;
}
// 尾部增加一节
snake.unshift({ x, y });
// 清空食物
ctx.clearRect(this.food.x, this.food.y, gap, gap);
// 重新生成食物坐标
this.food = this.randomFood();
}
}
判断是否撞到边界或自身
js
// 判断是否撞到边界
isHitWall(head) {
const { width, height } = this;
// x轴大于最大宽度或小于最小宽度
// y轴大于最大高度或小于最小高度
return head.x >= width || head.x < 0 || head.y >= height || head.y < 0
}
// 判断是否吃到了自己
isEatSelf(head) {
const { snake } = this
// 头部不可能和头部重合,只判断身体
const body = snake.slice(1);
// 头部和身体任何一个节点重合
return body.some(item => item.x === head.x && item.y === head.y);
}
到这里基本功能就完成了,还可以加入计分逻辑
、手动控制开始
、暂停游戏
、兼容移动端
等功能。