通过游戏学习 Javascript
如果你在谷歌搜索“Javascript”,就会出现数十亿条搜索结果。可见它是多么的流行。几乎所有现代 Web 应用程序都使用 Javascript。作为一名 JS 开发者,在框架方面你拥有丰富的选择,无论是 React、Node、Vue 还是其他任何框架。在浩瀚的框架海洋中,我们常常会忘记我们的老朋友Vanilla JS,它是最纯粹的 Javascript 形式。
所以,我们一直在考虑做一个项目,以一种有趣而独特的方式,将 Vanilla JS 的基础知识融入其中。还有什么比只用简单易懂的 JS 来制作经典的贪吃蛇游戏更好的方法呢?那就让我们开始吧。
先决条件
这个项目没有任何先决条件,只要你有学习的意愿就可以。不过,掌握一点编程知识也总是好的,对吧?
项目
本文篇幅较长,因为我们将涵盖该项目的各个方面。因此,为了清晰易懂,我们将整个项目分为以下几个部分:
我们将要做什么
在深入代码之前,我们需要明确要构建的具体内容。我们需要构建一条蛇,它由头部和尾部组成,由许多段组成。我们还需要在屏幕上的随机位置生成一些食物,让蛇吃掉后变长。我们将跟踪玩家的得分,并添加暂停游戏的功能。
骷髅
为游戏创建一个单独的文件夹。在文件夹中创建两个文件,分别为 index.html 和 game.js。index.html 文件将包含常规的 HTML 样板代码以及一个非常特殊的元素——画布,我们的游戏将通过画布来呈现。
<!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 文件的标签style
内添加一个标签,如下所示:head
<style type="text/css">
*{
margin: 0;
padding: 0;
overflow: hidden;
box-sizing: border-box;
}
canvas{
background-color: #333;
}
</style>
为了覆盖浏览器元素的默认设置,我们为页面编写了自定义 CSS 样式,并将margin
和设置padding
为零。border -box属性会考虑添加到元素的边框,并将其调整到元素的边界内。overflow
将 属性设置为hidden
可禁用并隐藏浏览器上的滚动条。最后,我们设置了游戏画布的背景颜色。
初始化
现在我们来看一下 game.js 文件。首先,我们需要声明一些全局变量,以供整个游戏过程中引用。这些变量代表一些控制游戏行为的属性。我们将通过一个名为 的函数init
初始化这些属性。函数相当于通过执行一些语句来执行某项任务,这里的任务就是初始化变量。
首先在game.js文件中添加如下代码:
let width;
let height;
let tileSize;
let canvas;
let ctx;
// Initialization of the game objects.
function init() {
tileSize = 20;
// Dynamically controlling the size of 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 变量存储对 HTMLcanvas
元素的引用。ctx
是 context 的缩写canvas
,它指定了我们将使用的坐标系。在本例中,我们将使用二维坐标。
变量tileSize
是游戏的基本元素。它是屏幕上基本单位的尺寸。为了使蛇和食物完美对齐,我们将整个屏幕划分为网格,每个网格的尺寸对应于。这也是我们将 的和近似为的最接近倍数的tileSize
原因。width
height
canvas
tileSize
食物
我们需要一个指向蛇会吃的食物的引用。我们将其视为一个具有特定属性和行为的对象,与现实世界中的对象非常相似。为了实现这一点,我们将学习一些基本的面向对象编程 (OOP)。
我们将创建一个名为如下的类Food
:
// Treating the food as an object.
class Food {
// Initialization of object properties.
constructor(pos, color) {
this.x = pos.x;
this.y = pos.y;
this.color = color;
}
// Drawing the food on the 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 中的类由一个构造函数方法组成,该方法负责初始化基于它的对象的属性,以及一些定义其行为的成员函数。
这里我们使用了一个带参数的构造函数,为食物对象提供位置和颜色。位置对象pos
又具有属性x
和 ,y
用于指定其在 上的 X 和 Y 坐标canvas
。this关键字用于引用类的当前实例(或对象),即我们引用当前正在考虑的对象的属性。创建对象时,这些属性会更加清晰。
这里使用的成员函数是draw
,它负责将食物绘制到画布上。该draw
函数可以包含任何在 上绘制食物的代码,canvas
但为了简单起见,我们将用一个红色的正方形来表示食物,其位置为x
,y
宽度和高度为tileSize
。函数内部编写的所有代码都负责执行此操作,即在画布上绘制一个红色正方形。
最后,我们需要将一个food
对象添加到全局变量列表中,并在init
函数内部创建一个食物对象,如下所示:
全局变量:
// Other global variables.
let food;
init
功能:
// Initialization of the game objects.
function init() {
tileSize = 20;
// Dynamically controlling the size of 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
是什么。它是一个函数,返回画布上将要生成食物的随机位置。代码如下:
// Determining a random spawn location on the grid.
function spawnLocation() {
// Breaking the entire canvas into a grid of tiles.
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 {
// Initialization of object properties.
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;
}
// Drawing the snake on the canvas.
draw() {
// Drawing the head of the snake.
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();
// Drawing the tail of the snake.
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();
}
}
// Moving the snake by updating position.
move() {
// Movement of the tail.
for (var i = this.tail.length - 1; i > 0; i--) {
this.tail[i] = this.tail[i - 1];
}
// Updating the start of the tail to acquire the position of the head.
if (this.tail.length != 0)
this.tail[0] = { x: this.x, y: this.y };
// Movement of the head.
this.x += this.velX * tileSize;
this.y += this.velY * tileSize;
}
// Changing the direction of movement of the snake.
dir(dirX, dirY) {
this.velX = dirX;
this.velY = dirY;
}
// Determining whether the snake has eaten a piece of food.
eat() {
if (Math.abs(this.x - food.x) < tileSize && Math.abs(this.y - food.y) < tileSize) {
// Adding to the tail.
this.tail.push({});
return true;
}
return false;
}
// Checking if the snake has died.
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
首先,我们有一个参数化的构造函数,它初始化变量和中蛇头的 X 和 Y 坐标y
,蛇的颜色为color
,以及 X 和 Y 方向的速度,分别由velX
和指定velY
。我们还有一个tail
变量,它是一个对象列表,存储了对蛇尾各段的引用。蛇尾初始设置为两段,X 和 Y 坐标由其自身的x
和y
属性指定。
现在,我们将重点放在该类的不同成员方法上:
-
函数
draw
:该draw
函数与 中的函数类似Food
。它负责在画布上绘制蛇。同样,我们可以用任何东西来表示蛇,但为了简单起见,我们使用一个绿色的正方形,其尺寸与 相同,分别表示tileSize
蛇的头部和蛇尾的每一节。函数内部的代码正是这样做的,在画布上绘制一些绿色正方形。 -
功能
move
:蛇体运动的主要挑战在于尾部的正确运动。我们需要能够存储尾部不同部分的位置,以使蛇体遵循特定的路径。这可以通过将尾部某一部分的位置设置为与其前一部分相同的位置来实现。这样,蛇体的尾部就会沿着头部过去某个时间点折返的路径运动。蛇体的位置乘以速度velX
,velY
再乘以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");
游戏循环
在继续之前,我们需要定义一个负责运行游戏的函数。因此,我们定义如下:
// The actual game function.
function game() {
init();
}
在这个函数内部,我们调用了一个init
函数,该函数只负责全局变量的初始化。那么如何在画布上绘制对象并持续运行游戏呢?这就是游戏循环的作用所在。
游戏循环或者需要重复执行的逻辑,需要写在一个函数里面,也就是update
。该update
函数定义如下:
// Updating the position and redrawing of game objects.
function update() {
if (snake.die()) {
alert("GAME OVER!!!");
window.location.reload();
}
snake.border();
if (snake.eat()) {
food = new Food(spawnLocation(), "red");
}
// Clearing the canvas for redrawing.
ctx.clearRect(0, 0, width, height);
food.draw();
snake.draw();
snake.move();
}
该update
函数将负责每帧更新游戏逻辑,即绘制蛇、食物并移动蛇。它还会检查蛇是否已进食或死亡。如果蛇死亡,我们将按照逻辑所示重新加载游戏。
现在我们剩下的任务是update
每隔一段特定的时间间隔重复调用该函数。首先,我们需要先了解一下 FPS(每秒帧数)。广义上讲,它指的是游戏画面每秒渲染的次数。传统的贪吃蛇游戏的帧率较低,大约为 10 FPS,我们将以此为基准。
我们fps
在全局变量列表下定义一个名为的变量,并在init
函数中将其初始化为10。
然后我们更新函数内部的代码game
如下:
// The actual game function.
function game() {
init();
// The game loop.
interval = setInterval(update,1000/fps);
}
该setInterval
函数会在指定的毫秒数后定期调用某个函数。我们将此引用存储在名为 的变量中interval
。
clearInterval
最后,当蛇死亡时,我们需要通过调用如下函数来消除这个间隔:
if (snake.die()) {
alert("GAME OVER!!!");
clearInterval(interval);
window.location.reload();
}
因此,我们的游戏循环已准备就绪,可以开始运行了。
物流
现在我们已经准备好游戏循环,我们需要一个系统来计算玩家的分数并提供暂停游戏的功能。
我们将定义两个全局变量score
并在函数isPaused
内部初始化它们init
,如下所示:
score = 0;
isPaused = false;
然后,我们将定义两个函数,用于在画布上显示游戏的分数和状态,如下所示:
// Showing the score of the player.
function showScore() {
ctx.textAlign = "center";
ctx.font = "25px Arial";
ctx.fillStyle = "white";
ctx.fillText("SCORE: " + score, width - 120, 30);
}
// Showing if the game is paused.
function showPaused() {
ctx.textAlign = "center";
ctx.font = "35px Arial";
ctx.fillStyle = "white";
ctx.fillText("PAUSED", width / 2, height / 2);
}
我们将以下代码添加到函数的开头update
:
if(isPaused){
return;
}
并showScore
在末尾调用该函数,update
如下所示:
showScore();
update
在函数里面snake.eat
添加:
score += 10;
键盘控制
玩家需要能够与游戏互动。为此,我们需要在代码中添加事件监听器。这些监听器将具有回调函数,用于查找按键事件并执行代码来控制游戏,如下所示:
// Adding an event listener for key presses.
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
指定了蛇的移动方向。我们设计了以下约定:
向上和向下移动分别对应 Y 轴速度的 -1 和 1,向左和向右移动分别对应 X 轴速度的 -1 和 1。该evt.key
属性将按下的按键名称传递给监听器。因此,我们现在可以使用方向键控制蛇,并使用空格键暂停游戏。
完成
现在一切就绪,我们将在代码中添加最后一项功能。我们将在浏览器加载 HTML 文档后立即加载游戏。为此,我们将添加另一个事件监听器,用于检查文档是否已加载。代码如下:
// Loading the browser window.
window.addEventListener("load",function(){
game();
});
瞧!当我们在浏览器上启动 index.html 文件时,我们的游戏应该已经启动并运行了。
资源
更新后的仓库分支包含一些新增代码,旨在提升游戏的美观度、稳定性和流畅度。我们还添加了一些检查,以避免出现不可预见的错误。
您可以在这里玩游戏。
希望您觉得本文对您有所帮助。
欢迎访问我们的网站了解更多信息,并关注我们的平台:
另外,如果您有兴趣了解更多关于使用 JavaScript 进行游戏开发的知识,请不要忘记在下方点赞和评论。您可以自由地提出疑问并提出改进建议。
在那之前,
请保持安全,愿源头与你同在!