如果你谷歌搜索 "Javascript" 方面,数以亿计的结果冒出来。这就是它的流行程度。几乎全部的 web 应用使用 Javascript。作为一个 JS 开发者,当遇到框架时有太多的选择,React,Node,Vue或者其他。在这个广阔的框架海洋中,我们常常变得忘记我们的好的老朋友,Vanilla JS,最纯粹的 Javascript 形式。
预备知识
这个项目没有预备知识只要你愿意学和做。但是,一点编程知识也不会坏,不是么?
项目
由于我们将覆盖项目的各个方面,此文章将是一篇长文章。因此,为了清晰和易于理解,整个项目被分成如下章节:
我们将要做的
在我们投入代码之前,我们需要规划究竟什么是我们将要制造的。我们需要制造一条蛇,它被头和尾代表,有很多片段组成。我们还需要在屏幕的随机位置产出某种食物,给蛇吃并且蛇会长长。我们将记录玩家的分数、还添加暂停游戏功能。
骨架
为此游戏创建一个独立的文件夹。在文件夹里创建两个文件,命名为 index.html 和 game.js。index.html 文件将包含常规 HMTL 样板代码并有一个很特别的元素,canvas,我们的游戏将苏醒的地方。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake Game</title>
</head>
<body>
<canvas id="game-area"></canvas>
<script type="text/javascript" src="game.js"></script>
</body>
</html>
HTML canvas 标签用于使用 Javascript 画图。它拥有内置的画简单图形比如弧形,矩形,直线的功能。它也能展示文字和图片。我们使用 script 标签添加对 game.js 文件的引用,该文件将规定游戏的逻辑。
在我们开始之前,我们需要在 HTML 文件的 head 标签内添加如下 style 标签:
<style type="text/css">
*{
margin: 0;
padding: 0;
overflow: hidden;
box-sizing: border-box;
}
canvas{
background-color: #333;
}
</style>
为了覆盖浏览器元素上的默认设置,我们为页面写了一个自定义的 CSS 样式并设置 margin 和 padding 为0。border-box 属性把考虑添加到元素的边 (border) 并将其适合于元素内。overflow 属性被设置成 hidden 以禁止和隐藏元素滚动条。最后,我们为游戏设置 canvas 背景色。
初始化
这里我们到 game.js 文件。首先,我们需要声明一些在整个游戏期间引用的 全局变量。这些变量代表将控制游戏行为的特定属性。我们将通过一个叫 init 的 函数 初始化这些属性。一个函数相当于通过运行一些语句执行一个特定任务,这里的任务时变量初始化。
最初添加下面的代码到 game.js 文件里:
let width;
let height;
let tileSize;
let canvas;
let ctx;
// 游戏对象初始化。
function init() {
tileSize = 20;
// 动态控制 canvas 大小。
width = tileSize * Math.floor(window.innerWidth / tileSize);
height = tileSize * Math.floor(window.innerHeight / tileSize);
canvas = document.getElementById("game-area");
canvas.width = width;
canvas.height = height;
ctx = canvas.getContext("2d");
}
变量 width 和 height 保存 canvas 的宽高。canvas 变量保存 HTML canvas 元素的引用。ctx 是 canvas 上下文的缩写,它确定我们工作的坐标系统。在我们的例子中,我们将使用 2D 坐标。
tileSize 变量是游戏中不可缺少的元素。它是屏幕上的基础单位尺寸。为了实现蛇和食物的完美对齐,我们将整个屏幕分成网格,每个尺寸对应 tileSize。这也是为什么我们让 canvas 的 width 和 height 最接近 tileSize 倍数的原因。
食物
我们需要一个将被蛇吃掉的食物的引用。我们将考虑它作为一个有特定属性和行为的对象,和现实世界的对象很相似。为了实现这个,我们将涉猎一些基本的 OOP(Object Oriented Programming, 面向对象编程)。
我们将创建一个叫 Food 的 类 如下:
// 视食物为对象
class Food {
// 对象属性初始化
constructor(pos, color) {
this.x = pos.x;
this.y = pos.y;
this.color = color;
}
// 在 canvas 上画食物
draw() {
ctx.beginPath();
ctx.rect(this.x, this.y, tileSize, tileSize);
ctx.fillStyle = this.color;
ctx.fill();
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.stroke();
ctx.closePath();
}
}
JS 中的类由 constructor 方法和一些成语函数组成,前者负责初始化基于它的对象的属性,后者定义行为。
这里我们使用参数化的构造函数提供带位置和颜色信息的食物对象。位置 pos 依次有属性 x 和 y 确定 canvas 上的 X 和 Y 坐标。this 关键字用来指向当前类的实例(或对象),也就是我们指向当前考虑的对象的属性。当我们创建对象时会更清晰。
这里被用到的成员函数是 draw,它负责在 canvas 上画食物。draw 函数能拥有任何在 canvas 上画食物的代码段但是为简单起见,我们用一个红色方块代表食物,它有位置 x 和 y 及宽高 tileSize。所有写在函数里的代码负责精确做到,在 canvas 上画一个红色方块。
最后,我们需要添加 food 对象到全局变量序列里并且在 init 函数里创建食物对象如下:
// 其他全局变量。
let food;
init 函数:
// 游戏中的对象初始化
function init() {
tileSize = 20;
// 动态控制 canvas 大小
width = tileSize * Math.floor(window.innerWidth / tileSize);
height = tileSize * Math.floor(window.innerHeight / tileSize);
canvas = document.getElementById("game-area");
canvas.width = width;
canvas.height = height;
ctx = canvas.getContext("2d");
food = new Food(spawnLocation(), "red");
}
你也许在想 spawnLocation 是什么。它是为食物产生而返回一个 canvas 上随机位置的函数。代码如下:
// 在网格中确定一个随机的产生位置。
function spawnLocation() {
// 将整个 canvas 分成块状网格
let rows = width / tileSize;
let cols = height / tileSize;
let xPos, yPos;
xPos = Math.floor(Math.random() * rows) * tileSize;
yPos = Math.floor(Math.random() * cols) * tileSize;
return { x: xPos, y: yPos };
}
蛇
蛇可能是游戏中最重要的方面。类似于 food 对象基于 Food 类,我们将创建名叫 Snake 的类,它包含蛇的属性和行为。
Snake 类如下:
class Snake {
// 对象属性初始化。
constructor(pos, color) {
this.x = pos.x;
this.y = pos.y;
this.tail = [{ x: pos.x - tileSize, y: pos.y }, { x: pos.x - tileSize * 2, y: pos.y }];
this.velX = 1;
this.velY = 0;
this.color = color;
}
// 在 canvas 上画蛇。
draw() {
// 画蛇头。
ctx.beginPath();
ctx.rect(this.x, this.y, tileSize, tileSize);
ctx.fillStyle = this.color;
ctx.fill();
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.stroke();
ctx.closePath();
// 画蛇尾。
for (var i = 0; i < this.tail.length; i++) {
ctx.beginPath();
ctx.rect(this.tail[i].x, this.tail[i].y, tileSize, tileSize);
ctx.fillStyle = this.color;
ctx.fill();
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.stroke();
ctx.closePath();
}
}
// 以更新位置方式移动蛇
move() {
// 蛇尾移动。
for (var i = this.tail.length - 1; i > 0; i--) {
this.tail[i] = this.tail[i - 1];
}
// 更新尾的第一个元素以或得头的位置
if (this.tail.length != 0)
this.tail[0] = { x: this.x, y: this.y };
// 蛇头移动
this.x += this.velX * tileSize;
this.y += this.velY * tileSize;
}
// 改变蛇的移动方向
dir(dirX, dirY) {
this.velX = dirX;
this.velY = dirY;
}
// 判断蛇是否吃到了食物
eat() {
if (Math.abs(this.x - food.x) < tileSize && Math.abs(this.y - food.y) < tileSize) {
// 添加到蛇尾
this.tail.push({});
return true;
}
return false;
}
// 检测蛇是否死了
die() {
for (var i = 0; i < this.tail.length; i++) {
if (Math.abs(this.x - this.tail[i].x) < tileSize && Math.abs(this.y - this.tail[i].y) < tileSize) {
return true;
}
}
return false;
}
border() {
if (this.x + tileSize > width && this.velX != -1 || this.x < 0 && this.velX != 1)
this.x = width - this.x;
else if (this.y + tileSize > height && this.velY != -1 || this.velY != 1 && this.y < 0)
this.y = height - this.y;
}
}
这个类包含大量代码,所以我们从方法一个一个来。
首先,我们有参数化构造函数,它以变量 x 和 y 初始化蛇头的 X 和 Y 坐标、以 color 初始化蛇的颜色,以及以 velX 和 velY 确定 X 和 Y 方向上的速度。我们还有 tail 变量,它是一个对象列表,保存关于蛇尾的片段。蛇尾被初始设置有两段,其 X 和 Y 坐标被它的 x 和 y 属性所确定。
现在,我们集中注意力到类的不同成员方法上:
-
draw函数:draw函数和Food中的类似。它负责在 canvas 上画蛇。同样,我们能用任何事物代表蛇,但是为了简单,我们使用如tileSize尺寸的绿色方块代表蛇头和每个蛇尾片段。函数里的代码做的实际是,画一些绿方块在 canvas 上。 -
move函数:蛇移动的主要挑战是蛇尾的适当运动。我们需要能保存不同蛇尾片段的位置,以让蛇沿着某个路径。这是通过给蛇尾片段赋值为前一个片段位置实现的。这样蛇尾沿着蛇头曾走过路径走下去。蛇的位置通过速度velX和velY乘tileSize增加,tileSize是网格基本单位。 -
dir函数:dir函数的目的是改变蛇头的运动方向。我们一会儿会讲到这个。 -
eat函数:eat函数负责检查蛇是否吃到了一块食物。这通过查找蛇头和食物的重叠实现。由于tileSize对应网格尺寸,我们可以检查头部和食物位置的差异是否对应tileSize,并相应地返回true或false。在此基础上,我们还在蛇尾上增加了一段,使其长度增加。 -
die函数:我们的蛇将会死亡仅当它咬它尾部某个部分。那是我们在这个函数里检查的东西,即如果头和尾的某部分重叠。因此,我们返回true或false作为回应。 -
border函数:border函数检查是否蛇在屏幕边界内。如果蛇从屏幕的一边消失了,那就太奇怪了。这里我们可以做以下两件事中的任何一件;我们既可以在那里结束游戏,也可以让蛇神奇地出现在屏幕的另一端,类似于经典的贪吃蛇游戏。我们选择了第二种方法,也就是函数内部的代码。
我们需要为蛇做最后一件事。我们将在全局变量序列下声明一个如下蛇对象:
let snake;
并在 init 函数内初始化如下:
snake = new Snake({ x: tileSize * Math.floor(width / (2 * tileSize)), y: tileSize * Math.floor(height / (2 * tileSize)) }, "#39ff14");
游戏循环
在更进一步之前,我们需要定义一个运行此游戏的函数。所以我们定义它如下:
// 实际的游戏函数。
function game() {
init();
}
在这个函数里,我们调用 init 函数,它仅处理全局变量初始化。如何在 canvas 上画对象和持续运行游戏呢?这就是游戏循环的由来。
游戏循环或者重复执行的逻辑被写下一个函数里,名字叫 update。此 update 函数定义如下:
// 更新位置和重绘游戏中的对象。
function update() {
if (snake.die()) {
alert("GAME OVER!!!");
window.location.reload();
}
snake.border();
if (snake.eat()) {
food = new Food(spawnLocation(), "red");
}
// 为重绘清屏 canvas。
ctx.clearRect(0, 0, width, height);
food.draw();
snake.draw();
snake.move();
}
此 update 函数将逐帧处理更新游戏逻辑,即画蛇,食物和移动蛇。它还检测蛇是否吃了食物或者它已死亡。如果蛇死了,我们将重新加载游戏,如逻辑所描述。
现在我们只剩下在某些特定时间间隔后重复调用 update 函数的任务。在任何其他事项前,我们需要讨论 FPS 或者帧每秒。宽松定义,它指的是游戏屏幕每秒渲染次数。传统的贪吃蛇游戏有一个低帧率,大约10 FPS,我们将沿用它。
我们定义一个变量叫 fps 在全局变量序列下并在 init 函数里初始化为 10。
然后我们更新 game 函数的代码如下:
// 实际的游戏函数。
function game() {
init();
// 游戏循环。
interval = setInterval(update,1000/fps);
}
setInterval 函数在特定毫秒数后周期性调用某个函数。我们把这个引用保存在叫 interval 的变量里。
最后,当蛇死亡,我们需要通过调用 clearInterval 函数清除这个 interval 如下:
if (snake.die()) {
alert("GAME OVER!!!");
clearInterval(interval);
window.location.reload();
}
这样,我们的游戏循环就准备好了。
后勤工作
现在我们准备好了游戏循环,我们需要有一个系统计算玩家得分和提供暂停游戏功能。
我们将定义两个全局变量 score 和 isPaused 并在 init 函数内初始化它们如下:
score = 0;
isPaused = false;
我们然后定义两个函数在 canvas 上展示分数和游戏状态如下:
// 展示玩家得分。
function showScore() {
ctx.textAlign = "center";
ctx.font = "25px Arial";
ctx.fillStyle = "white";
ctx.fillText("SCORE: " + score, width - 120, 30);
}
// 展示游戏是否暂停
function showPaused() {
ctx.textAlign = "center";
ctx.font = "35px Arial";
ctx.fillStyle = "white";
ctx.fillText("PAUSED", width / 2, height / 2);
}
我们添加下面的代码到 udpate 函数开头:
if(isPaused){
return;
}
并在 update 末尾调用 showScore 函数如下:
showScore();
在 update 函数内的 snake.eat 下添加:
score += 10;
键盘操控
玩家需要能够同游戏交互。为此,我们需要添加 事件监听 到代码里。这些监听将有 回调 函数,它们将查找按键和执行代码以控制游戏,如下:
// 为按键添加一个事件监听。
window.addEventListener("keydown", function (evt) {
if (evt.key === " ") {
evt.preventDefault();
isPaused = !isPaused;
showPaused();
}
else if (evt.key === "ArrowUp") {
evt.preventDefault();
if (snake.velY != 1 && snake.x >= 0 && snake.x <= width && snake.y >= 0 && snake.y <= height)
snake.dir(0, -1);
}
else if (evt.key === "ArrowDown") {
evt.preventDefault();
if (snake.velY != -1 && snake.x >= 0 && snake.x <= width && snake.y >= 0 && snake.y <= height)
snake.dir(0, 1);
}
else if (evt.key === "ArrowLeft") {
evt.preventDefault();
if (snake.velX != 1 && snake.x >= 0 && snake.x <= width && snake.y >= 0 && snake.y <= height)
snake.dir(-1, 0);
}
else if (evt.key === "ArrowRight") {
evt.preventDefault();
if (snake.velX != -1 && snake.x >= 0 && snake.x <= width && snake.y >= 0 && snake.y <= height)
snake.dir(1, 0);
}
});
上面的 dir 函数代码指定蛇的移动方向。我们制定如下约定;
向上和向下移动分别对应-1和1对于 Y 速度,向左和向右分别被-1和1代表对于 X 速度。evt.key 属性表示被按下的键名,对监听器。这样,我们现在能用方向键控制蛇和用空格键暂停游戏。
完结撒花
现在所有事就位,我们将添加最后的功能片段到我们代码。当 HTML 文档在浏览器完成加载时我们就加载游戏。为此,我们将添加另一个检查文档是否以及载入的事件监听。代码如下:
// 加载浏览器窗口
window.addEventListener("load",function(){
game();
});
看!我们的游戏应该开始运行,当我们在浏览器启动 index.html 文件的时候。
资源
一个使用 HTML 和纯 JS 实现的经典贪吃蛇翻版。
更新过的仓库分支包含一些附加内容到代码以使游戏更美观,健壮和顺滑。我们还加了一些检查以避免未知 bug。
你可以在 这里 玩此游戏。
我们希望你能发现深刻见解。
访问我们的 网站 了解更多关于我们的信息,也可以关注我们:
另外,别忘了在下面点赞和评论如果你对学习更多有关使用 Javascript 的游戏开发。你可以自由的提出疑问和改进建议。
那时,
保持安全,愿源头与你同在!