欢迎上车。今天我们将开始一场激动人心的冒险,在这里我们将开发属于我们自己的贪食蛇游戏。通过将其分解为一个个简短的步骤来学习如何解决问题。在这段旅程结束时,你会学到一些新东西,并且有信心能独立探索更多。
开始吧
首先新建一个文件“snake.html”,它将包含全部代码。
因为这是一个 HTML 文件,所以要做的第一件事就是申明 <!DOCTYPE>。请在 snake.html 文件中输入以下内容:
<!DOCTYPE html>
<html>
<head>
<title>Snake Game</title>
</head>
<body>
Welcome to Snake!
</body>
</html>
不错,接下来在你最喜欢的浏览器中打开 snake.html。你应该能够看到 Welcome to Snake!。
创建画布
为了完成游戏,我们需要用到 <canvas>
标签,并借助 JavaScript 绘制图像。
用以下代码来替换 snake.html 里的欢迎语。
<canvas id="gameCanvas" width="300" height="300"><canvas>
id 用于标识画布且始终应该被指定。稍后我们会用它来访问画布。width 和 height 分别是画布的宽和高,同样也需要被指定。在本例中,画布尺寸为 300 px * 300 px。
snake.html 文件此时应该是这样的:
<!DOCTYPE html>
<html>
<head>
<title>Snake Game</title>
</head>
<body>
<canvas id="gameCanvas" width="300" height="300"></canvas>
</body>
</html>
如果你刷新页面,你会发现页面一片空白。这是因为默认情况下,画布是空的并且没有背景。让我们来调整下。
为画布添加背景色和边框
为了看到画布,我们可以写一段 JavaScript 代码给它加一个边框。为此,我们需要在 </canvas>
后添加 <script></script>
标签,并在其中编写 JavaScript 代码。
如果你把 <script>
标签放在了 <canvas>
标签前面,那么你的 JavaScript 代码将不会生效,因为此时 HTML 还未加载完。
现在我们来写一些 JavaScript 代码,写在闭合的 <script></script>
标签之间。代码如下:
<!DOCTYPE html>
<html>
<head>
<title>Snake Game</title>
</head>
<body>
<canvas id="gameCanvas" width="300" height="300"></canvas>
<script>
/** 常量 **/
const CANVAS_BORDER_COLOUR = 'black';
const CANVAS_BACKGROUND_COLOUR = "white";
// 获取 canvas 元素
var gameCanvas = document.getElementById("gameCanvas");
// 返回一个二维绘图上下文
var ctx = gameCanvas.getContext("2d");
// 选择画布的背景颜色
ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
// 选择画布的边框颜色
ctx.strokestyle = CANVAS_BORDER_COLOUR;
// 绘制一个“实心的”长方形来覆盖整个画布
ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
// 绘制画布的“边框”
ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
</script>
</body>
</html>
首先,使用前面指定的 id(gameCanvas)获取 canvas 元素。然后获取画布的“2d”上下文,这意味着我们将在 2D 空间绘制图像。
最后,我们画了一个 300 x 300 的白色矩形,边框为黑色。这个矩形从左上角(0,0)开始覆盖了整个画布。
如果你在浏览器中重新加载 snake.html,你会看到一个带黑色边框的白块!干得漂亮,现在我们已经有了一个画布,可以用来创建我们的贪食蛇游戏了!
用坐标来表示贪食蛇
为了让游戏能玩,我们需要知道蛇在画布中的位置。为此,我们用一系列坐标来表示蛇。因此,要在画布中间(150,150)画一条横向的蛇,我们可以这样写:
let snake = [
{x: 150, y: 150},
{x: 140, y: 150},
{x: 130, y: 150},
{x: 120, y: 150},
{x: 110, y: 150},
];
注意蛇所有部位的 y 坐标都是 150。每一部位的 x 坐标比(左边)前一个部位多 10 px。数组中的第一对坐标 {x: 150, y: 150} 表示蛇头,位于蛇的最右边。
别急,在下一步我们画蛇的时候,会对此有更清晰的认识。
开始画蛇
为了画蛇,我们可以写一个函数为蛇身上的每一个部位画一个矩形。
function drawSnakePart(snakePart) {
ctx.fillStyle = 'lightgreen';
ctx.strokestyle = 'darkgreen';
ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
}
接下来,我们用另一个函数在画布上把蛇展示出来。
function drawSnake() {
snake.forEach(drawSnakePart);
}
此时 snake.html 文件应该是这样的。
<!DOCTYPE html>
<html>
<head>
<title>Snake Game</title>
</head>
<body>
<canvas id="gameCanvas" width="300" height="300"></canvas>
<script>
/** 常量 **/
const CANVAS_BORDER_COLOUR = 'black';
const CANVAS_BACKGROUND_COLOUR = "white";
const SNAKE_COLOUR = 'lightgreen';
const SNAKE_BORDER_COLOUR = 'darkgreen';
let snake = [
{x: 150, y: 150},
{x: 140, y: 150},
{x: 130, y: 150},
{x: 120, y: 150},
{x: 110, y: 150}
]
// 获取 canvas 元素
var gameCanvas = document.getElementById("gameCanvas");
// 返回一个二维绘制上下文
var ctx = gameCanvas.getContext("2d");
// 选择画布的背景颜色
ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
// 选择画布的边框颜色
ctx.strokestyle = CANVAS_BORDER_COLOUR;
// 绘制一个“实心的”长方形来覆盖整个画布
ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
// 绘制画布的“边框”
ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
drawSnake();
/**
* 在画布上画蛇
*/
function drawSnake() {
// 循环遍历蛇的每一部分,并将其绘制到画布上
snake.forEach(drawSnakePart)
}
/**
* 在画布上画蛇的一个部分
* @param { object } snakePart —— 需要绘制的部位的所在坐标
*/
function drawSnakePart(snakePart) {
// 设置蛇身体的背景颜色
ctx.fillStyle = SNAKE_COLOUR;
// 设置蛇身的边框色
ctx.strokestyle = SNAKE_BORDER_COLOUR;
// 在蛇身坐标所在的位置,绘制“实心”的矩形以表示蛇
ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
// 绘制蛇身的边框
ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
}
</script>
</body>
</html>
刷新页面,你会发现画布中间有一条绿色的蛇。
让贪食蛇横向移动
接下来我们想要让蛇能动起来。
嗯,为了让蛇能够一步一步(10 px)移动到最右边,我们可以把蛇的每个部位的 x 坐标,每次增加 10 px(dx = +10 px)。同理,为了让蛇移动到最左边,每次把蛇的每个部位的 x 坐标减少 10 px(dx = -10)。
dx 是蛇的横向移动速度。
蛇向右移动了 10 px 后,坐标如下图所示:
[图片上传失败...(image-21b4ae-1546593847219)]
用 advanceSnake 函数来更新蛇的状态。
function advanceSnake() {
const head = {x: snake[0].x + dx, y: snake[0].y};
snake.unshift(head);
snake.pop();
}
首先,我们为蛇画了个新头。然后使用 unshift 方法将新头放在蛇的第一个部位,然后使用 pop 方法移除蛇的最后一部分。这样以后,如上图所示,所有蛇身上其他部位都移动到了对应位置。
让贪食蛇纵向移动
为了让蛇能够上下移动,我们不能直接将蛇的所有 y 坐标调整 10 px。那会让整条蛇上下移动。
相反,我们可以调整蛇头的 y 坐标。减少 10 px 蛇会向下移动,增加 10 px 蛇会向上移动。这样就能让蛇正确地移动了。
幸运的是,因为我们编写的 advanceSnake 函数很棒,所以这很容易做到。在 advanceSnake 函数中,更新 head 让 y 坐标随着 dy 变化。
const head = {x: snake[0].x + dx, y: snake[0].y + dy};
为了验证 advanceSnake 函数是否正确,我们可以临时性地在 drawSnake 函数前调用它。
// 向右走一步
advanceSnake()
// 水平速度改为 0
dx = 0;
// 垂直速度改为 10
dy = -10;
// 向上走一步
advanceSnake();
// 在画布上画蛇
drawSnake();
现在 snake.html 代码如下:
<!DOCTYPE html>
<html>
<head>
<title>Snake Game</title>
</head>
<body>
<canvas id="gameCanvas" width="300" height="300"></canvas>
<script>
/** 常量 **/
const CANVAS_BORDER_COLOUR = 'black';
const CANVAS_BACKGROUND_COLOUR = "white";
const SNAKE_COLOUR = 'lightgreen';
const SNAKE_BORDER_COLOUR = 'darkgreen';
let snake = [
{x: 150, y: 150},
{x: 140, y: 150},
{x: 130, y: 150},
{x: 120, y: 150},
{x: 110, y: 150}
]
// 横向移动速度
let dx = 10;
// 纵向移动速度
let dy = 0;
// 获取 canvas 元素
var gameCanvas = document.getElementById("gameCanvas");
// 返回一个二维绘制上下文
var ctx = gameCanvas.getContext("2d");
// 选择画布的背景颜色
ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
// 选择画布的边框颜色
ctx.strokestyle = CANVAS_BORDER_COLOUR;
// 绘制一个“实心的”长方形来覆盖整个画布
ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
// 绘制画布的“边框”
ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
// 向右走一步
advanceSnake()
// 水平速度改为 0
dx = 0;
// 垂直速度改为 10
dy = -10;
// 向上走一步
advanceSnake();
// 在画布上画蛇
drawSnake();
/**
* 根据蛇的水平移动速度改变蛇的 x 坐标,
* 根据蛇的垂直移动速度改变蛇的 y 坐标
*/
function advanceSnake() {
const head = {x: snake[0].x + dx, y: snake[0].y + dy};
snake.unshift(head);
snake.pop();
}
/**
* 在画布上画蛇
*/
function drawSnake() {
// 循环遍历蛇的每一部分,并将其绘制到画布上
snake.forEach(drawSnakePart)
}
/**
* 在画布上画蛇的一个部分
* @param { object } snakePart —— 需要绘制的部位的所在坐标
*/
function drawSnakePart(snakePart) {
// 设置蛇身体的背景颜色
ctx.fillStyle = SNAKE_COLOUR;
// 设置蛇身的边框色
ctx.strokestyle = SNAKE_BORDER_COLOUR;
// 在蛇身坐标所在的位置,绘制“实心”的矩形以表示蛇
ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
// 绘制蛇身的边框
ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
}
</script>
</body>
</html>
刷新页面,蛇动了!
自己是一个五年的前端工程师
这里推荐一下我的前端学习=交流=q==u=n=:731771211,里面都是学习前端的,从最基础的HTML+CSS+JS【炫酷特效,游戏,插件封装,设计模式】到移动端HTML5的项目实战的学习资料都有整理,送给每一位前端小伙伴,欢迎加入。
点击:加入
重构代码
在继续下一步之前,让我们重构一下现有代码,将绘制画布的代码封装在一个函数中。这有助于完成下一步。
代码重构是重构现有计算机代码的过程,同时不改变其外部行为。
function clearCanvas() {
ctx.fillStyle = "white";
ctx.strokeStyle = "black";
ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
}
让贪食蛇自动移动
好的,既然我们已经成功地重构了代码,那么接下来让贪食蛇能够自动移动吧。
之前为了测试 advanceSnake 函数是否正确,我们调用了它两次。一次为了让蛇向右移动,一次为了让它向上移动。
因此如果想要让蛇右移五次,那么我们需要连续调用 advanceSnake() 函数五次。
clearCanvas();
advanceSnake();
advanceSnake();
advanceSnake();
advanceSnake();
advanceSnake();
drawSnake();
但是,如上所示连续五次调用 advanceSnake 函数会让蛇一下子向前跳 50 px。
相反,我们想让蛇看起来像是一步一步地前进。
为此,我们用 setTimeout 在每次调用之间添加一个短暂的延时。我们还需要确保每次调用 advanceSnake 函数时都调用 drawSnake 函数。如果我们不这样做,我们将无法看到蛇移动的中间步骤。
setTimeout(function onTick() {
clearCanvas();
advanceSnake();
drawSnake();
}, 100);
setTimeout(function onTick() {
clearCanvas();
advanceSnake();
drawSnake();
}, 100);
...
drawSnake();
注意我们同样在每个 setTimeout 中调用了 clearCanvas() 函数。这是为了移除蛇先前所有的位置,以免留下痕迹。
尽管如此,上述代码仍然有问题。没有任何代码告诉程序它必须等待一个 setTimeout 完成后才能移动到下一个setTimeout。也就是说蛇仍然会在一个短暂延迟后向前跳 50 px。
要解决这个问题,我们必须将代码包装在函数中,每次只调用一个函数。
stepOne();
function stepOne() {
setTimeout(function onTick() {
clearCanvas();
advanceSnake();
drawSnake();
// 调用第二个函数
stepTwo();
}, 100)
}
function stepTwo() {
setTimeout(function onTick() {
clearCanvas();
advanceSnake();
drawSnake();
// 调用第三个函数
stepThree();
}, 100)
}
...
如何让贪食蛇一直前进?我们可以改为创建一个 main 函数并递归调用它,而不是创建无数个相互调用的函数。
function main() {
setTimeout(function onTick() {
clearCanvas();
advanceSnake();
drawSnake();
// 再次调用 main 函数
main();
}, 100)
}
贪食蛇现在会一直向右移动。虽然一旦它到达画布的尽头,将继续其无尽的旅程并进入未知的地方。我们将在适当的时候解决这个问题,耐心点年轻的朋友们
调整蛇的移动方向
我们的下一个任务是在按下任意方向键时更改蛇的移动方向。在 drawSnakePart 函数之后添加以下代码。
function changeDirection(event) {
const LEFT_KEY = 37;
const RIGHT_KEY = 39;
const UP_KEY = 38;
const DOWN_KEY = 40;
const keyPressed = event.keyCode;
const goingUp = dy === -10;
const goingDown = dy === 10;
const goingRight = dx === 10;
const goingLeft = dx === -10;
if (keyPressed === LEFT_KEY && !goingRight) {
dx = -10;
dy = 0;
}
if (keyPressed === UP_KEY && !goingDown) {
dx = 0;
dy = -10;
}
if (keyPressed === RIGHT_KEY && !goingLeft) {
dx = 10;
dy = 0;
}
if (keyPressed === DOWN_KEY && !goingDown) {
dx = 0;
dy = 10;
}
}
这没有什么特别的。我们检查按下的键是否与其中一个方向键匹配。如果匹配到,我们如上所述改变垂直和水平速度。
请注意,我们还要检查蛇是否往新预期方向的相反方向上移动。这是为了防止我们的蛇掉头,例如当蛇向左移动时按下右键。
要将 changeDirection 加到游戏代码中,可以在 document 上使用 addEventListener 来“监听”是否有键被按下。然后我们随着 keydown 事件调用 changeDirection 方法。接着在 main 函数之后添加以下代码。
document.addEventListener("keydown", changeDirection)
你现在应该可以使用四个方向键更改蛇的方向。
接下来让我们看看如何生成食物并让我们的蛇长大。
为蛇生成食物
为了生成蛇食,我们必须生成一组随机坐标。我们可以使用辅助函数 randomTen 来生成两个数字。一个用于 x 坐标,一个用于 y 坐标。
我们还必须确保食物不与蛇的位置重叠。如果重叠了,我们必须生成一个新的食物位置。
function randomTen(min, max) {
return Math.round((Math.random() * (max-min) + min) / 10) * 10;
}
function createFood() {
foodX = randomTen(0, gameCanvas.width - 10);
foodY = randomTen(0, gameCanvas.height - 10);
snake.forEach(function isFoodOnSnake(part) {
const foodIsOnSnake = part.x == foodX && part.y == foodY
if (foodIsOnSnake)
createFood();
});
}
然后我们需要写一个在画布上绘制食物的函数。
function drawFood() {
ctx.fillStyle = 'red';
ctx.strokestyle = 'darkred';
ctx.fillRect(foodX, foodY, 10, 10);
ctx.strokeRect(foodX, foodY, 10, 10);
}
最后我们可以在调用 main 之前调用 createFood。别忘了还要更新 main 函数以调用 drawFood 函数。
function main() {
setTimeout(function onTick() {
clearCanvas();
drawFood()
advanceSnake();
drawSnake();
main();
}, 100)
}
让蛇长大
让蛇长大很简单。更新 advanceSnake 函数,检查蛇头是否碰到了食物。如果碰到了,我们可以跳过移除蛇的最后部分这一操作,同时创建一个新的食物位置。
function advanceSnake() {
const head = {x: snake[0].x + dx, y: snake[0].y};
snake.unshift(head);
const didEatFood = snake[0].x === foodX && snake[0].y === foodY;
if (didEatFood) {
createFood();
} else {
snake.pop();
}
}
记录游戏分数
为了让游戏更有乐趣,我们还可以添加一个分数,当蛇吃食物时分数增加。
在声明了 snake 后,创建一个新的变量 score,并将其设为 0。
let score = 0;
接下来在画布前添加一个 id 为“score”的新 div,用于显示分数。
<div id="score">0</div>
<canvas id="gameCanvas" width="300" height="300"></canvas>
最后更新 advanceSnake, 当蛇吃食物时,增加并显示分数。
function advanceSnake() {
...
if (didEatFood) {
score += 10;
document.getElementById('score').innerHTML = score;
createFood();
} else {
...
}
}
胜利就在眼前了
结束游戏
还剩下最后一部分,那就是结束游戏。为此创建一个函数 didGameEnd,当游戏结束时返回 true,否则返回 false。
function didGameEnd() {
for (let i = 4; i < snake.length; i++) {
const didCollide = snake[i].x === snake[0].x &&
snake[i].y === snake[0].y
if (didCollide) return true
}
const hitLeftWall = snake[0].x < 0;
const hitRightWall = snake[0].x > gameCanvas.width - 10;
const hitToptWall = snake[0].y < 0;
const hitBottomWall = snake[0].y > gameCanvas.height - 10;
return hitLeftWall ||
hitRightWall ||
hitToptWall ||
hitBottomWall
}
首先,我们检查蛇的头部是否碰到蛇身上其他部分,如果碰到了那么返回 true。
请注意,我们从索引值 4 开始循环。这有两个原因:一个是如果索引为 0,didCollide 则会立即判断为 true,导致游戏结束。另一个是,蛇的前三个部分不可能相互接触。
接下来我们检查蛇是否在画布上撞墙了,如果是,那么返回 true,否则返回 false 。
现在,如果 didEndGame 返回 true,我们可以在主函数中提前返回,从而结束游戏。
function main() {
if (didGameEnd()) return;
...
}
snake.html 现在应该是这样的:
<!DOCTYPE html>
<html>
<head>
<title>Snake Game</title>
</head>
<body>
<div id="score">0</div>
<canvas id="gameCanvas" width="300" height="300"></canvas>
<script>
/** 常量 **/
const CANVAS_BORDER_COLOUR = 'black';
const CANVAS_BACKGROUND_COLOUR = "white";
const SNAKE_COLOUR = 'lightgreen';
const SNAKE_BORDER_COLOUR = 'darkgreen';
const FOOD_COLOUR = 'red';
const FOOD_BORDER_COLOUR = 'darkred';
let snake = [
{x: 150, y: 150},
{x: 140, y: 150},
{x: 130, y: 150},
{x: 120, y: 150},
{x: 110, y: 150}
]
// 玩家的分数
let score = 0;
// 横向移动速度
let dx = 10;
// 纵向移动速度
let dy = 0;
// 获取 canvas 元素
var gameCanvas = document.getElementById("gameCanvas");
// 返回一个二维绘制上下文
var ctx = gameCanvas.getContext("2d");
// 选择画布的背景颜色
ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
// 选择画布的边框颜色
ctx.strokestyle = CANVAS_BORDER_COLOUR;
// 绘制一个“实心的”长方形来覆盖整个画布
ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
// 绘制画布的“边框”
ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
// 开始游戏
main();
// 生成第一个食物位置
createFood();
// 按下任意一个键,都会调用 changeDirection
document.addEventListener("keydown", changeDirection);
function main() {
if (didGameEnd()) return;
setTimeout(function onTick() {
clearCanvas();
drawFood();
advanceSnake();
drawSnake();
// Call main again
main();
}, 100)
}
/**
* 设置画布的背景色为 CANVAS_BACKGROUND_COLOUR
* 并绘制画布的边框
*/
function clearCanvas() {
// 选择画布的背景颜色
ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
// 选择画布的边框颜色
ctx.strokestyle = CANVAS_BORDER_COLOUR;
// 绘制一个“实心的”长方形来覆盖整个画布
ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
// 绘制画布的“边框”
ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
}
/**
* 当蛇撞墙或者蛇头碰到蛇身的时候返回 true
*/
function didGameEnd() {
for (let i = 4; i < snake.length; i++) {
const didCollide = snake[i].x === snake[0].x && snake[i].y === snake[0].y
if (didCollide) return true
}
const hitLeftWall = snake[0].x < 0;
const hitRightWall = snake[0].x > gameCanvas.width - 10;
const hitToptWall = snake[0].y < 0;
const hitBottomWall = snake[0].y > gameCanvas.height - 10;
return hitLeftWall || hitRightWall || hitToptWall || hitBottomWall
}
/**
* 在画布上画食物
*/
function drawFood() {
ctx.fillStyle = FOOD_COLOUR;
ctx.strokestyle = FOOD_BORDER_COLOUR;
ctx.fillRect(foodX, foodY, 10, 10);
ctx.strokeRect(foodX, foodY, 10, 10);
}
/**
* 根据蛇的水平移动速度改变蛇的 x 坐标,
* 根据蛇的垂直移动速度改变蛇的 y 坐标
*/
function advanceSnake() {
// 绘制新的头部
const head = {x: snake[0].x + dx, y: snake[0].y + dy};
// 将新的头部放到蛇身体的第一个部位
snake.unshift(head);
const didEatFood = snake[0].x === foodX && snake[0].y === foodY;
if (didEatFood) {
// 增加分数
score += 10;
// 在屏幕上显示分数
document.getElementById('score').innerHTML = score;
// 生成新的食物
createFood();
} else {
// 移除蛇的最后一个部分
snake.pop();
}
}
/**
* 给定一个最大值和最小值,生成一个 10 的倍数的随机数
* @param { number } min —— 随机数的下限
* @param { number } max —— 随机数的上限
*/
function randomTen(min, max) {
return Math.round((Math.random() * (max-min) + min) / 10) * 10;
}
/**
* 随机生成食物坐标
*/
function createFood() {
// 随机生成食物的 x 坐标
foodX = randomTen(0, gameCanvas.width - 10);
// 随机生成食物的 y 坐标
foodY = randomTen(0, gameCanvas.height - 10);
// 如果新生成的食物与蛇当前位置重叠,重新为食物生成一个位置
snake.forEach(function isOnSnake(part) {
if (part.x == foodX && part.y == foodY) createFood();
});
}
/**
* 在画布上画蛇
*/
function drawSnake() {
// 循环遍历蛇的每一部分,并将其绘制到画布上
snake.forEach(drawSnakePart)
}
/**
* 在画布上画蛇的一个部分
* @param { object } snakePart —— 需要绘制的部位的所在坐标
*/
function drawSnakePart(snakePart) {
// 设置蛇身体的背景颜色
ctx.fillStyle = SNAKE_COLOUR;
// 设置蛇身的边框色
ctx.strokestyle = SNAKE_BORDER_COLOUR;
// 在蛇身坐标所在的位置,绘制“实心”的矩形以表示蛇
ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
// 绘制蛇身的边框
ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
}
/**
* 根据按下的键,改变蛇的水平移动速度和垂直移动速度
* 为了避免蛇反转,蛇的移动方向不能直接变成相反的方向,
* 比如说,当前方向是“向右”,那么下一个方向不能是“向左”
* @param { object } event —— 键盘事件
*/
function changeDirection(event) {
const LEFT_KEY = 37;
const RIGHT_KEY = 39;
const UP_KEY = 38;
const DOWN_KEY = 40;
const keyPressed = event.keyCode;
const goingUp = dy === -10;
const goingDown = dy === 10;
const goingRight = dx === 10;
const goingLeft = dx === -10;
if (keyPressed === LEFT_KEY && !goingRight) {
dx = -10;
dy = 0;
}
if (keyPressed === UP_KEY && !goingDown) {
dx = 0;
dy = -10;
}
if (keyPressed === RIGHT_KEY && !goingLeft) {
dx = 10;
dy = 0;
}
if (keyPressed === DOWN_KEY && !goingUp) {
dx = 0;
dy = 10;
}
}
</script>
</body>
</html>
现在贪食蛇游戏已经可以玩了,不过,我们还是来看看最后一个问题。我保证,这绝对是最后一个。
潜藏的 bugs
如果你玩了足够多次游戏,可能会注意到游戏有时会意外结束。这是一个很好的例子,展示了 bug 会潜入我们的程序并制造麻烦。
发现 bug 的时候,解决它的最好方法是首先找到可靠的方法来重现它。也就是说,找到导致意外行为的精确步骤。然后,需要了解它们导致意外行为的原因,最后寻求解决方案。
重现 bug
在我们的例子中,重现 bug 的步骤如下:
- [ ] 蛇正向左移动
- [ ] 玩家按下向下键
- [ ] 玩家立即按下向右键(在 100 ms 内)
- [ ] 游戏结束
分析导致 bug 的原因
让我们一步步分解 bug 产生的过程。
蛇正在向左移动
- [ ] 水平速度,dx 等于 -10
- [ ] 调用 main 函数
- [ ] 调用 advanceSnake 函数,蛇向左移动 10 px。
玩家按下向下键
调用 changeDirection
keyPressed === DOWN_KEY && !goingUp 的值为 true
dx 更改为 0
dy 更改为 +10
玩家立即按下向右键(在 100 ms 内)
- [ ] 调用 changeDirection
- [ ] keyPressed === RIGHT_KEY && !goingLeft 的值为 true
- [ ] dx 更改为 +10
- [ ] dy 更改为 0
游戏结束
- [ ] main 函数延时 100 ms 后被调用
- [ ] 调用 advanceSnake,蛇向右移动 10 px。
-
[ ] const didCollide = snake[i].x === snake[0].x && snake[i].y ===
snake[0].y 的值为true
- [ ] didGameEnd 返回 true
- [ ] main 函数提前返回
- [ ] 游戏结束
解决 bug
在分析了发生的事情之后,我们了解到游戏结束是因为蛇掉头了。
这是因为当玩家按下向下键时,dx 被设置为 0。因此 keyPressed === RIGHT_KEY && !goingLeft
值为 true,同时 dx 更改为 10。
重要的是要注意,在 100 ms 时间内,方向改变了。如果超过 100 ms,那么蛇首先会向下移而不会掉头。
为了解决这个 bug,必须确保只有在 main 和 advanceSnake 被调用之后,才可以改变蛇的方向。可以创建一个变量 changingDirection。当调用 changeDirection 时设置 changingDirection 为 true,并在调用 advanceSnake 时设置为 false。
在 changeDirection 函数中,如果 changingDirection 为 true,就提前返回。
function changeDirection(event) {
const LEFT_KEY = 37;
const RIGHT_KEY = 39;
const UP_KEY = 38;
const DOWN_KEY = 40;
if (changingDirection) return;
changingDirection = true;
...
}
function main() {
setTimeout(function onTick() {
changingDirection = false;
...
}, 100)
}
以下是 snake.html 的最终版本
注意我还在 <style></style>
标签之间添加了一些样式。这是为了让画布和分数显示在屏幕中间。
<!DOCTYPE html>
<html>
<head>
<title>Snake Game</title>
<link href="https://fonts.googleapis.com/css?family=Antic+Slab" rel="stylesheet">
</head>
<body>
<div id="score">0</div>
<canvas id="gameCanvas" width="300" height="300"></canvas>
<style>
#gameCanvas {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#score {
text-align: center;
font-size: 140px;
font-family: 'Antic Slab', serif;
}
</style>
</body>
<script>
const GAME_SPEED = 100;
const CANVAS_BORDER_COLOUR = 'black';
const CANVAS_BACKGROUND_COLOUR = "white";
const SNAKE_COLOUR = 'lightgreen';
const SNAKE_BORDER_COLOUR = 'darkgreen';
const FOOD_COLOUR = 'red';
const FOOD_BORDER_COLOUR = 'darkred';
let snake = [
{x: 150, y: 150},
{x: 140, y: 150},
{x: 130, y: 150},
{x: 120, y: 150},
{x: 110, y: 150}
]
// 玩家的分数
let score = 0;
// changingDirection 为 true 时表示蛇正在改变方向
let changingDirection = false;
// 食物的 x 坐标
let foodX;
// 食物的 y 坐标
let foodY;
// 横向移动速度
let dx = 10;
// 纵向移动速度
let dy = 0;
// 获取 canvas 元素
const gameCanvas = document.getElementById("gameCanvas");
// 返回一个二维绘制上下文
const ctx = gameCanvas.getContext("2d");
// 开始游戏
main();
// 生成第一个食物位置
createFood();
// 按下任意一个键,都会调用 changeDirection
document.addEventListener("keydown", changeDirection);
/**
* 游戏的主函数
* 递归调用以推进游戏
*/
function main() {
// 判定游戏结束时提前返回从而终止游戏
if (didGameEnd()) return;
setTimeout(function onTick() {
changingDirection = false;
clearCanvas();
drawFood();
advanceSnake();
drawSnake();
// 再次调用 main
main();
}, GAME_SPEED)
}
/**
* 设置画布的背景色为 CANVAS_BACKGROUND_COLOUR
* 并绘制画布的边框
*/
function clearCanvas() {
// 选择画布的背景颜色
ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
// 选择画布的边框颜色
ctx.strokestyle = CANVAS_BORDER_COLOUR;
// 绘制一个“实心的”长方形来覆盖整个画布
ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
// 绘制画布的“边框”
ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
}
/**
* 在画布上画食物
*/
function drawFood() {
ctx.fillStyle = FOOD_COLOUR;
ctx.strokestyle = FOOD_BORDER_COLOUR;
ctx.fillRect(foodX, foodY, 10, 10);
ctx.strokeRect(foodX, foodY, 10, 10);
}
/**
* 根据蛇的水平移动速度改变蛇的 x 坐标,
* 根据蛇的垂直移动速度改变蛇的 y 坐标
*/
function advanceSnake() {
// 绘制新的头部
const head = {x: snake[0].x + dx, y: snake[0].y + dy};
// 将新的头部放到蛇身体的第一个部位
snake.unshift(head);
const didEatFood = snake[0].x === foodX && snake[0].y === foodY;
if (didEatFood) {
// 增加分数
score += 10;
// 在屏幕上显示分数
document.getElementById('score').innerHTML = score;
// 生成新的食物
createFood();
} else {
// 移除蛇的最后一个部分
snake.pop();
}
}
/**
* 当蛇撞墙或者蛇头碰到蛇身的时候返回 true
*/
function didGameEnd() {
for (let i = 4; i < snake.length; i++) {
if (snake[i].x === snake[0].x && snake[i].y === snake[0].y) return true
}
const hitLeftWall = snake[0].x < 0;
const hitRightWall = snake[0].x > gameCanvas.width - 10;
const hitToptWall = snake[0].y < 0;
const hitBottomWall = snake[0].y > gameCanvas.height - 10;
return hitLeftWall || hitRightWall || hitToptWall || hitBottomWall
}
/**
* 给定一个最大值和最小值,生成一个 10 的倍数的随机数
* @param { number } min —— 随机数的下限
* @param { number } max —— 随机数的上限
*/
function randomTen(min, max) {
return Math.round((Math.random() * (max-min) + min) / 10) * 10;
}
/**
* 随机生成食物坐标
*/
function createFood() {
// 随机生成食物的 x 坐标
foodX = randomTen(0, gameCanvas.width - 10);
// 随机生成食物的 y 坐标
foodY = randomTen(0, gameCanvas.height - 10);
// 如果新生成的食物与蛇当前位置重叠,重新为食物生成一个位置
snake.forEach(function isFoodOnSnake(part) {
const foodIsoNsnake = part.x == foodX && part.y == foodY;
if (foodIsoNsnake) createFood();
});
}
/**
* 在画布上画蛇
*/
function drawSnake() {
// 循环遍历蛇的每一部分,并将其绘制到画布上
snake.forEach(drawSnakePart)
}
/**
* 在画布上画蛇的一个部分
* @param { object } snakePart —— 需要绘制的部位的所在坐标
*/
function drawSnakePart(snakePart) {
// 设置蛇身体的背景颜色
ctx.fillStyle = SNAKE_COLOUR;
// 设置蛇身的边框色
ctx.strokestyle = SNAKE_BORDER_COLOUR;
// 在蛇身坐标所在的位置,绘制“实心”的矩形以表示蛇
ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
// 绘制蛇身的边框
ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
}
/**
* 根据按下的键,改变蛇的水平移动速度和垂直移动速度
* 为了避免蛇反转,蛇的移动方向不能直接变成相反的方向,
* 比如说,当前方向是“向右”,那么下一个方向不能是“向左”
* @param { object } event —— 键盘事件
*/
function changeDirection(event) {
const LEFT_KEY = 37;
const RIGHT_KEY = 39;
const UP_KEY = 38;
const DOWN_KEY = 40;
/**
* 避免贪食蛇掉头
* 举个例子:
* 蛇正在向右移动。玩家按下向下键然后迅速地按下向左键。
* 此时蛇不会先向下移动一步,而会马上改变方向
*/
if (changingDirection) return;
changingDirection = true;
const keyPressed = event.keyCode;
const goingUp = dy === -10;
const goingDown = dy === 10;
const goingRight = dx === 10;
const goingLeft = dx === -10;
if (keyPressed === LEFT_KEY && !goingRight) {
dx = -10;
dy = 0;
}
if (keyPressed === UP_KEY && !goingDown) {
dx = 0;
dy = -10;
}
if (keyPressed === RIGHT_KEY && !goingLeft) {
dx = 10;
dy = 0;
}
if (keyPressed === DOWN_KEY && !goingUp) {
dx = 0;
dy = 10;
}
}
</script>
</html>
结尾
编程并不是一件很难学习,虽然最初未必敢于尝试,但这毫无疑问是非常有价值的。
前端开发进阶点击:加入
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。