欢迎上车。今天我们将开始一场激动人心的冒险,在这里我们将开发属于我们自己的贪食蛇游戏。通过将其分解为一个个简短的步骤来学习如何解决问题。在这段旅程结束时,你会学到一些新东西,并且有信心能独立探索更多。
开始吧
首先新建一个文件“snake.html”,它将包含全部代码。
因为这是一个 HTML 文件,所以要做的第一件事就是申明 。请在 snake.html 文件中输入以下内容:
Snake Game
Welcome to Snake!
不错,接下来在你最喜欢的浏览器中打开 snake.html。你应该能够看到 Welcome to Snake!。
创建画布
为了完成游戏,我们需要用到 标签,并借助 JavaScript 绘制图像。
用以下代码来替换 snake.html 里的欢迎语。
id 用于标识画布且始终应该被指定。稍后我们会用它来访问画布。width 和 height 分别是画布的宽和高,同样也需要被指定。在本例中,画布尺寸为 300 px * 300 px。
snake.html 文件此时应该是这样的:
Snake Game
如果你刷新页面,你会发现页面一片空白。这是因为默认情况下,画布是空的并且没有背景。让我们来调整下。
为画布添加背景色和边框
为了看到画布,我们可以写一段 JavaScript 代码给它加一个边框。为此,我们需要在 后添加
标签,并在其中编写 JavaScript 代码。
如果你把 标签放在了
标签前面,那么你的 JavaScript 代码将不会生效,因为此时 HTML 还未加载完。
现在我们来写一些 JavaScript 代码,写在闭合的 标签之间。代码如下:
Snake Game
首先,使用前面指定的 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 文件应该是这样的。
Snake Game
刷新页面,你会发现画布中间有一条绿色的蛇。
让贪食蛇横向移动
接下来我们想要让蛇能动起来。
嗯,为了让蛇能够一步一步(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 代码如下:
Snake Game
刷新页面,蛇动了!
自己是一个五年的前端工程师
这里推荐一下我的前端学习=交流=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,用于显示分数。
0
最后更新 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 现在应该是这样的:
Snake Game
0
现在贪食蛇游戏已经可以玩了,不过,我们还是来看看最后一个问题。我保证,这绝对是最后一个。
潜藏的 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 的最终版本
注意我还在
标签之间添加了一些样式。这是为了让画布和分数显示在屏幕中间。
Snake Game
0
结尾
编程并不是一件很难学习,虽然最初未必敢于尝试,但这毫无疑问是非常有价值的。
前端开发进阶点击:加入