JS躲避行星
夏安 人气:01. 游戏概述
顾名思义,躲避小行星游戏的目标是非常明显的:当小行星向你冲来时,让火箭飞行和生存的时间尽可能长一些(如图91所示)。如果你碰上某颗小行星,游戏将结束,游戏的分数是通过火箭生存的时间来计算的。
躲避小行星游戏是一个“横向卷轴式”游戏,或者说它至少类似于这样的游戏,将会侧重于动态场景。
2. 核心功能
在创建游戏之前,首先需要构建一些基本框架。就创建躲避小行星游戏而言,这些框架就是基本的HTML、CSS以及JavaScript代码(作为将来要添加的高级代码的基础)。
2.1 构建 HTML 代码
在浏览器中创建游戏的优点在于可以使用一些构建网站的常用技术。也就是说,可以使用 HTML 语言来创建游戏的用户界面(UI)。现在的界面看上去不太美观,这是因为我们还没有使用 CSS 来设计用户界面的样式,但目前内容的原始结构是最重要的。
在你的计算机上为该游戏创建一个新目录,新建一个index.html
文件,在其中加入以下代码:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Asteroid avoidance</title> <link rel="stylesheet" href="style.css" rel="external nofollow" rel="external nofollow" > <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="./main.js"></script> </head> <body> <div id="game"> <div id="game-ui"> <div id="game-intro"> <h1>Asteroid avoidance</h1> <p>Click play and then press any key to start.</p> <p> <a id="game-play" class="button" href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" >Play</a> </p> </div> <div id="game-stats"> <p>Time: <span class="game-score"></span> </p> <p> <a class="game-reset" href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" >Reset</a> </p> </div> <div id="game-complete"> <h1>Game over!</h1> <p>You survived for <span class="game-score"></span> seconds. </p> <p><a class="game-reset buyyon" href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" >Play</a></p> </div> </div> <canvas id="game-canvas" width="800" height="600"> <!-- 在此处插入后备代码 --> </canvas> </div> </body> </html>
我不打算过多解释这些 HTML 代码,因为它们比较简单,但你只要知道这就是游戏所需的所有标记即可。
2.2 美化界面
创建一个名为 style.css
的新文件,并把它和 HTML 文件放在相同的目录下。在该 CSS 文件中插入以下代码:
* { margin: 0; padding: 0; } html, body { height: 100%; width: 100%; } canvas { display: block; } body { background-color: #000; color: #fff; font-family: Verdana, Arial, sans-serif; font-size: 18px; height: 100%; } h1 { font-size: 30px; } p { margin: 0 20px; } a { color: #fff; text-decoration: none; } a:hover { text-decoration: underline; } a.button { background-color: #185da8; border-radius: 5px; display: block; font-size: 30px; margin: 40px 0 0 270px; padding: 10px; width: 200px; } a.button:hover { background-color: #2488f5; color: #fff; text-decoration: none; } #game { height: 600px; left: 50%; margin: -300px 0 0 -400px; position: relative; top: 50%; width: 800px; } #game-canvas { background-color: #001022; } #game-ui { height: 600px; position: absolute; width: 800px; } #game-intro, #game-complete { background-color: rgba(0, 0, 0, .5); margin-top: 100px; padding: 40px 0; text-align: center; } #game-stats { font-size: 14px; margin: 20px 0; } #game-stats, .game-reset { margin: 20px 20px 0 0; position: absolute; right: 0; top: 0; }
2.3 编写 JavaScript 代码
在添加一些有趣的游戏逻辑之前,首先需要用JavaScript实现核心功能。创建一个名为 main.js
的新文件,并把它和 HTML 文件放在相同的目录下。在该 js 文件中插入以下代码:
$(document).ready(function () { const canvas = $('#game-canvas'); const context = canvas.get(0).getContext("2d"); // 画布尺寸 const canvasWidth = canvas.width(); const canvasHeight = canvas.height(); // 游戏设置 let playGame; // 游戏UI const ui = $("#game-ui"); const uiIntro = $("#game-intro"); const uiStats = $("#game-stats"); const uiComplete = $("#game-complete"); const uiPlay = $("#game-play"); const uiReset = $(".game-reset"); const uiScore = $(".game-score"); // 重至和启动游戏 function startGame() { // 重置游戏状态 uiScore.html("0"); uiStats.show(); // 初始游戏设置 playGame = false; // 开始动画糖环 animate(); } //初始化游戏环境 function init() { uiStats.hide(); uiComplete.hide(); uiPlay.click(function (e) { e.preventDefault(); uiIntro.hide(); startGame(); }); uiReset.click(function (e) { e.preventDefault(); uiComplete.hide(); startGame(); }); } // 动画循环,游戏的嫌味性就在这里 function animate() { // 清除 context.clearRect(0, 0, canvasWidth, canvasHeight); if (playGame) { setTimeout(animate, 33); } } init(); });
在你最喜欢的浏览器中运行该游戏,应该会看到一个更加美观的 UI。另外,你还可以单击 Play 按钮来显示游戏的主窗口,尽管它看上去也许还有些单调。
3. 创建游戏对象
躲避小行星游戏使用两个主要对象:小行星和玩家使用的火箭。我们将使用 JavaScript 类来创建这些对象。你也许会觉得奇怪,既然玩家只有一个,为什么还要通过一个类来定义它呢?简而言之,如果你以后需要在游戏中添加多个玩家,通过类创建玩家就会更容易一些。
3.1 创建小行星
通过类创建游戏对象意味着你可以在其他游戏中非常方便地重用和改变它们的用途。
第一步是声明主要变量,我们将使用这些变量来存储所有的小行星。同时,还需要声明另外一个变量,用于计算游戏中应该存在的小行星数目。在 JavaScript 代码顶部的 playGame
变量下面添加以下代码:
let asteroids; let numAsteroids;
稍后你将会为这些变量赋值,但现在我们只建立小行星类。在startGame
函数上面添加以下代码:
function Asteroid(x, y, radius, vX) { this.x = x; this.y = y; this.radius = radius; this.vX = vX; }
这里存在一个速度属性,这是因为小行星只需要从右向左运动,即只需要 x
速度。这里不需要 y
速度,所以就省略了。
在开始创建所有小行星之前,需要建立数组来存储这些小行星,并声明实际需要使用的小行星数目。在startGame
函数中的PlayGame
变量下面添加以下代码:
asteroids = new Array(); numAsteroids = 10;
你也许认为 10 个小行星是一个很小的数目,但是当这些小行星在屏幕上消失时,你将重复使用它们,所以在游戏中你实际看到的小行星数目可以有无穷多个。你可以把这里的小行星数目看做屏幕上某一时刻可能出现的小行星总数。
创建小行星的过程其实就是一个创建循环的过程,循环的次数就是你刚才声明的小行星数目。在你刚才赋值的numAsteroids
变量下面添加以下代码:
for (let i = 0; i < numAsteroids; i++) { const radius = 5 + (Math.random() * 10); const x = canvasWidth + radius + Math.floor(Math.random() * canvasWidth); const y = Math.floor(Math.random() * canvasHeight); const vx = -5 - (Math.random() * 5); asteroids.push(new Asteroid(x, y, radius, vX)); }
为了让每颗小行星的外观都与众不同,并且使游戏看上去更有趣一些,可以把小行星的半径设为一个介于 5 到 15 像素之间的随机数( 5 加上一个介于 0 到 10 之间的随机数)。虽然 x
速度的值介于 -5 到 -10 之间,但你也可以采用同样的方法来设置它( -5 减去一个 0 到 5 之间的数)。因为你需要让小行星按从右向左的方向运动,所以使用的是一个负的速度值,这说明 x
的位置将随着时间的推移而减小。
计算每颗小行星的 x
位置看上去有些复杂,但其实非常简单。在开始启动游戏的时候,如果让所有的小行星全部显示在屏幕上,就让人觉得有些太奇怪了。因此在游戏开始之前,最好把小行星放在屏幕的右侧,当游戏开始时才让它们按从右向左的顺序穿过屏幕。
为此,首先需要把 x
位置设为 canvas
元素的宽度,然后加上小行星的半径。这意味着如果你现在画出小行星,那么它应该位于屏幕的右侧。如果仅仅这样做,那么所有的小行星将会排成一行,所以下一步我们需要把 x
位置加上一个介于 0 到画布宽度之间的随机值。与 x
位置相比,y
位置简单一些,它只是一个介于 0 到画布高度之间的随机值。
这样可以产生一个与画布尺寸相同的方框,方框中随机分布着一些小行星。当游戏开始时,这些小行星将穿过可见的画布。
最后一步是把一个新的小行星推送到数组中,做好移动和绘制小行星的准备。
3.2 创建玩家使用的火箭
首先声明用于建立玩家的初始化变量。在 JavaScript 顶部的 numAsteroids
变量下面添加以下代码:
let player;
该变量将用于存储玩家对象的引用,但现在我们还没有定义玩家对象。在Asteroid
类下面添加以下代码:
function Player(x, y) { this.x = x; this.y = y; this.width = 24; this.height = 24; this.halfWidth = this.width / 2; this.halfHeight = this.height / 2; this.vX = 0; this.vY = 0; }
你应该熟悉以上代码的某些部分,例如位置和速度属性。其余属性用于描述玩家使用的火箭的尺寸,包括整个尺寸和一半的尺寸。绘制火箭和执行碰撞检测时,你需要使用这些尺寸。
最后一步是创建一个新的玩家对象。为此,在 startGame
函数中的numAsteroids
变量下面添加以下对象:
player = new Player(150, canvasHeight / 2);
通过以上代码,玩家的位置将垂直居中,并且距离画布左边界 150 像素。
现在还不能看到任何效果,稍后当你开始着手移动所有的游戏对象时,将会从视觉上看到这种效果。
4. 检测键盘输入
本游戏将使用键盘来控制游戏。更确切地说,你将使用方向键来四处移动玩家使用的火箭。如何才能实现这种控制呢?这比控制鼠标输入更难吗?不,其实非常简单。下面我来教你怎么做。
4.1 键值
在处理键盘输人时,首先需要知道哪一个按键被按下了。在 JavaScript 中,普通键盘上的每一个按键都分配了一个特定的键值(key code)。通过这些键值,可以唯一确定按下或释放了哪个键。稍后你将学习如何使用键值,现在我们首先需要了解每个按键所对应的数值。
例如,键 a
到 z
(无论在什么情况下)对应的键值分别是从 65 到 90 。箭头键对应的键值是从 37 到 40,其中左箭头的键值是 37、上箭头的键值是 38、右箭头的键值是 39、下箭头的键值是 40。空格键的键值是 32。
在躲避小行星游戏中,你需要重点关注的是箭头键,因此在 JavaScript 代码顶部的 player
变量下面添加以下代码:
const arrowUp = 38; const arrowRight = 39; const arrowDown = 40;
以上代码为每个箭头对应的键值分别分配了一个变量。这种方法称作枚举(enumeration),它是对值进行命名的过程。这主要是为后面的工作提供方便,因为通过这些名称你能很容易确定变量引用的是哪个箭头键。
请注意,为什么没有为左箭头声明一个变量呢?因为你不会手动地让玩家向后移动。相反,当玩家没有按任何按键时,就会表现为向后移动的状态。稍后你就会明白其中的道理。
4.2 键盘事件
在向游戏中添加键盘交互效果之前,首先需要确定玩家在何时按下或释放某个按键。为此,需要使用 keydown
和 keyup
事件监听器。
在 startGame
函数中的 animate
函数调用上面(在创建所有小行星的循环下面)添加以下代码:
$(window).keydown(e => { }); $(window).keyup(e => { });
按下某个按键时将触发第一个监听器,释放某个按键时将触发第二个监听器。非常简单。稍后我们将在这些事件监听器中添加一些有用的代码,但首先需要在重新设置游戏时删除这些监听器,这能防止玩家由于无意按下某个按键而启动游戏。在 uiReset.click
事件监听器中的 startGame
调用上面添加以下代码:
$(window).unbind('keyup'); $(window).unbind('keydown');
接下来,还需要添加一些在激活玩家后用到的属性。在 Player
类的末尾添加以下代码:
this.moveRight = false; this.moveUp = false; this.moveDown = false;
通过这些属性,你可以知道玩家的移动方向,这些属性值的设置取决于玩家按下了哪个按键。现在你是不是已经理解了其中的所有道理呢?
最后,需要向键盘事件监听器中添加一些逻辑。首先,在 keydown
事件监听器中添加以下代码:
const keyCode = e.keyCode; if (!playGame) { playGame = true; animate(); } if (keyCode == arrowRight) { player.moveRight = true; } else if (keyCode == arrowUp) { player.moveUp = true; } else if (keyCode == arrowDown) { player.moveDown = true; }
并在 keyup
事件监听器中添加以下代码:
const keyCode = e.keyCode; if (keyCode == arrowRight) { player.moveRight = false; } else if (keyCode == arrowUp) { player.moveUp = false; } else if (keyCode == arrowDown) { player.moveDown = false; }
以上代码的作用非常明显,但我还需要作一些说明。在两个监听器中,第一行的作用都是把按键的键值赋给一个变量。然后在一组检查语句中使用该键值来判断是否按下了某个箭头键,如果按下了箭头键,判断是哪个箭头键。这样,我们就可以启动(如果按下了该键)或禁用(如果释放了该键)玩家对象的对应属性。
例如,如果按下了向右的箭头键,那么玩家对象的 moveRight
属性将被设为 true
。如果释放了该方向键,则 moveRight
属性将被设为false
。
**注意:**如果玩家一直按住某个按键,那么将触发多个 keydown
事件。因此,代码要具备处理多个被触发的 keydown
事件的能力,这一,点非常重要。在每个 keydown
事件之后不一定总是一个 keyup
事件,另外还要注意的是,在 keydown
事件监听器中是如何通过一个条件语句来查看游戏当前是否正在进行的。如果玩家没有做好游戏准备,该语句将阻止游戏运行。只有玩家按下键盘上的某个键时,才会启动游戏。方法很简单,但却非常有效。
游戏中的键盘输入非常多,我们不可能一一列举。在下一节中,我们将通过这些输入来控制玩家沿着正确的方向运动。
5. 让对象运动起来
现在你已经做好了实现游戏对象动画的所有准备。当你实际看到游戏效果时,这一切会变得更有趣。
第一步是更新所有游戏对象的位置。我们从更新小行星对象的位置开始,在 animate
函数中
画布的 clearRect
方法下面添加以下代码:
const asteroidsLength = asteroids.length; for (let i = 0; i < asteroidsLength; i++) { const tmpAsteroid = asteroids[i]; tmpAsteroid.x += tmpAsteroid.vX; context.fillStyle = "rgb(255, 255, 255)"; context.beginPath(); context.arc(tmpAsteroid.x, tmpAsteroid.y, tmpAsteroid.radius, 0, Math.PI * 2, true); context.closePath(); context.fill(); }
这些代码非常简单。主要是遍历每一颗小行星,并根据速度来更新它的位置,然后在画布上绘制小行星。
刷新浏览器查看效果(记住按下某个按键启动游戏)。应该能够看到某颗小行星带穿越屏幕的场景。
注意它们是如何消失在屏幕左侧的。下一节将学习如何在横向滚动的屏幕上阻止它们的运动。
迄今为止,假设这些小行星都实现了我们的预期效果。接下来还需要更新并显示玩家!
在 animate
函数中刚才添加小行星代码的下面再添加以下代码:
player.vX = 0; player.vY = 0; if (player.moveRight) { player.vX = 3; } if (player.moveUp) { player.vY = -3; } if (player.moveDown) { player.vY = 3; } player.x += player.vX; player.y += player.vY;
以上代码将更新玩家的速度,并将速度设置为一个特定的值,该值由玩家移动的方向来确定。如果玩家需要向右移动,那么速度值为 x
轴上的 3像素。如果玩家需要向上移动,那么速度值为 y
轴上的 -3 像素。同样,如果玩家需要向下移动,那么速度值即为y轴上的3像素。这非常简单。另外,还需要注意如何在代码的开始处重置速度值。如果玩家没有按下任何按键,该语句将阻止玩家移动。
最后,还需要根据速度来更新玩家的 x
和 y
位置。现在你还看不到任何效果,但你已经做好了在屏幕上绘制火箭的所有准备工作。
在刚才添加的代码下面直接添加以下代码:
context.fillStyle = 'rgb(255, 0, 0)'; context.beginPath(); context.moveTo(player.x + player.halfWidth, player.y); context.lineTo(player.x - player.halfWidth, player.y - player.halfHeight); context.lineTo(player.x - player.halfWidth, player.y + player.halfHeight) context.closePath(); context.fill();
你知道以上代码的作用吗?很明显,你正在绘制一条填充路径,但你能告诉我绘制的路径是什么形状吗?是的,它只是一个三角形而已。
如果你仔细查看,会发现玩家对象的尺寸属性的作用。知道了玩家对象的宽度值和高度值的一半,就可以构建一个动态三角形,它能随着尺寸值的变化变大或缩小。方法很简单,但效果却很好。
在浏览器中查看游戏的效果,应该能够看到玩家使用的火箭。
试着按下箭头键。看到火箭移动了吗?现在的游戏效果已经非常棒了。
这里可以只使用运动逻辑,但游戏会显得有些单调。我们不妨在火箭上再添加一团闪动的火焰!在 Player
类的末尾添加以下代码:
this.flameLength = 20;
以上代码用于确定火焰的持续时间,稍后我们还需要添加更多代码。现在先在 animate
函数中绘制火箭的代码前面添加以下代码:
if (player.moveRight) { context.save(); context.translate(player.x - player.halfWidth, player.y); if (player.flameLength == 20) { player.flameLength = 15; } else { player.flameLength = 20; } context.fillStyle = "orange"; context.beginPath(); context.moveTo(0, -5); context.lineTo(-player.flameLength, 0); context.lineTo(0, 5); context.closePath(); context.fill(); context.restore(); }
条件语句用于确保只有当玩家向右运动时才绘制火焰,因为如果在其他时间也能看到火焰,看上去就不符合常理了。
我们使用画布的 translate
方法来绘制火焰,因为在后面调用 save
方法来保存画布的绘图状态时,translate
方法可以节约一些时间。现在已经存储了绘图上下文的原始状态,接下来就可以调用 translate
方法,并把 2D 绘图上下文的原点移到玩家使用的火箭的左侧。
现在已经移动了画布的原点,接下来的任务就非常简单了。只需要对存储在玩家对象的 flameLength
属性中的值执行循环(使火箭呈现闪烁效果),并把填充颜色改为橙色,然后从新的起点绘制一个长度与flameLength
属性相同的三角形。最后还需要调用 restore
方法,将原始绘图状态恢复到画布上。
刷新浏览器看看刚才的劳动成果。当按下向右的箭头键时,火箭上应该出现了一团闪烁的火焰。
接下来需要做好准备,我们将使游戏产生一种逼真的横向卷轴效果。
6. 假造横向卷轴效果
虽然这个游戏看上去好像是横向卷动的,但实际上你并没有穿越在游戏世界中。相反,你将循环利用所有在屏幕上消失的对象,并让它们重新显示在屏幕的另一侧。这样就会产生一种始终穿越在永无止境的游戏世界中的效果。听起来好像有些奇特,其实它只是一种横向卷动效果而已。
6.1 循环利用小行星
让游戏产生一种永无止境的穿越效果其实并不难。实际上非常简单!在animate
函数中刚才绘制每颗小行星的代码上面添加以下代码:
if (tmpAsteroid.x + tmpAsteroid.radius < 0) { tmpAsteroid.radius = 5 + (Math.random() * 10); tmpAsteroid.x = canvasWidth + tmpAsteroid.radius; tmpAsteroid.y = Math.floor(Math.random() * canvasHeight); tmpAsteroid.vX = -5 - (Math.random() * 5); }
这点代码就够了。这段代码的作用是检查小行星是否移动到画布的左边界之外,如果是,则重置该小行星,并将它重新移回到画布的右侧。你已经重新利用了该小行星,但它看上去却像是一颗全新的小行星。
6.2 添加边界
现在,玩家火箭可能会自由地在游戏中飞越,也可能会停止不动(试图飞越画布的右侧时)。为了解决这个问题,需要在适当的位置设置一些边界。在绘制火箭火焰的代码上面(正好在设置新的玩家位置的代码下面)添加以下代码:
if (player.x - player.halfWidth < 20) { player.x = 20 + player.halfWidth; } else if (player.x + player.halfWidth > canvasWidth - 20) { player.x = canvasWidth - 20 - player.halfWidth; } if (player.y - player.halfHeight < 20) { player.y = 20 + player.halfHeight; } else if (player.y + player.halfHeight > canvasHeight - 20) { player.y = canvasHeight - 20 - player.halfHeight; }
你也许能猜出以上代码的作用。它主要执行一些标准的边界检查。这些检查查看玩家是否位于画布边界 20 像素之内,如果是,则阻止它们沿着该方向进一步移动。我认为在画布的边界处预留 20 像素的空隙视觉效果更佳,但也可以把这个值再改小一点,以便玩家能够向右移动到画布的边缘处。
6.3 让玩家保持连续移动
目前,如果玩家没有按下任何按键,火箭将停止移动。当所有的小行星正在飘荡时,火箭突然停止移动不太符合常理。因此可以在游戏中添加一些额外的运动,当玩家不再向前移动时,可以让它们继续向后移动。
在 animate
函数中把改变玩家 vX
属性的代码段更换为以下代码:
if (player.moveRight) { player.vX = 3; } else { player.vX = -3; }
这段代码只是在条件语句中添加了一段额外代码,即当玩家不需要向右移动时,把玩家的 vX
属性设为-3。你总结一下就会发现,这与大部分游戏逻辑都是相同的。在浏览器中运行该游戏,现在的游戏看上去更逼真了!
7. 添加声音
这也许是游戏中最酷的一部分。在游戏中添加一些简单的声音非常有趣,游戏也会变得更加引人入胜。你也许觉得在游戏中添加音频是非常困难的,但使用 HTML5 音频来实现却是一件轻而易举的事!下面我们来看看。
首先需要在游戏的 HTML 代码中声明所有的 HTML5 音频元素。直接在index.html
文件中的 canvas
元素下面添加以下代码:
<audio id="game-sound-background" loop> <source src="sounds/background.ogg"> <source src="sounds/background.mp3"> </audio> <audio id="game-sound-thrust" loop> <source src="sounds/thrust.ogg"> <source src="sounds/thrust.mp3"> </audio> <audio id="game-sound-death"> <source src="sounds/death.ogg"> <source src="sounds/death.mp3"> </audio>
如果你掌握了 HTML5 音频部分的内容,那么应该对以上代码非常熟悉了。如果你还没有掌握该内容,也不用着急,因为它非常简单。这里声明了 3 个独立的 HTML5 audio
元素,并且为每个 audio
元素定义了一个唯一的 id
属性,后面将用到这些 id
属性。循环播放的声音还需要定义一个 loop
属性。
注意:并非所有的浏览器都支持 loop
属性。由于它是规范的部分,因此越来越多的浏览器将会全面支持该属性。如果需要采用一种变通的方案,可以在音频播放结束时添加一个事件监听器,并再次播放。
这 3 种声音都是背景音乐,火箭开始移动时使用推进器的声音,最后玩家死亡时使用深沉的轰鸣声。为了与大多数浏览器兼容,每种声音都需要两个版本的文件,因此也需要包含两个 source
元素:一个是 mp3 版本,另一个是 ogg 版本。
在 HTML 文件中只需要完成这些任务就可以了,接下来我们回 JavaScript文件中,并在 JavaScript文件顶部的 uiScore
变量下面添加以下代码:
const soundBackground = $("#game-sound-background").get(0); const soundThrust = $("#game-sound-thrust").get(0); const soundDeath = $("#game-sound-death").get(0);
这些变量使用 HTML 文件中声明的 id
属性来获取每个audio
元素,这与在游戏中获取 canvas
元素非常相似。接下来将使用这些变量访问HTML5 音频 API 并控制声音。
这些内容无需过多解释,紧接着我们转入 keydown
事件监听器中,在把playGame
设置为 true
的代码后面添加以下代码:
soundBackground.currentTime = 0; soundBackground.play();
现在,你已经在游戏中添加了HTML5音频,并且可以非常方便地控制它。很酷吧?以上代码的作用是访问与背景音乐相关的HTML5 audio
元素,并且可以通过HTML5音频 API 直接控制它。因此,通过更改 currentTime
属性,可以重置音频文件播放的起始位置,另外,通过调用 play
方法,可以播放该音频文件。真的很简单!
载入并运行游戏,现在当你开始移动火箭时,应该能听到一些美妙的背景音乐。
下一步是控制推进器的声音(当玩家移动火箭时)。我希望你已经猜到了如何去实现,其实这与实现背景音乐一样简单。
在 keydown
事件监听器中 player
对象的 moveRight
属性设置代码下面添加以下代码:
if (soundThrust.paused) { soundThrust.currentTime = 0; soundThrust.play(); }
第一行代码用于检查是否正在播放推进器声音,如果是,则禁止在游戏中再次播放它。这可以防止该声音在播放的过程中被中途切断,因为每秒钟可能会触发多次 keydown
事件,而你当然也不希望每次触发 keydown
事件时都再次播放推进器声音。
当玩家停止移动时,你也许不希望推进器声音继续播放,为此,在 keyup
事件监听器中 player
对象的 moveRight
属性设置代码下面添加以下代码:
soundThrust.pause();
就这么简单,音频 API 太方便了,通过它访问和操纵音频非常简单。
在继续下一步之前(下一节将添加死亡的声音),我们还需要考虑一个问题:如果玩家重置游戏,我们需要如何确保停止播放声音。为此,在 init
函数的 uiReset
,click
事件处理程序中的 startGame
调用上面添加以下代码:
soundThrust.pause(); soundBackground.pause();
当游戏重置时,以上两行代码可以确保停止播放推进器声音和背景音乐。因为死亡的声音不需要进行循环,并且你希望在游戏结束时才播放它,所以暂时不需要考虑死亡的声音。
8. 结束游戏
现在的游戏已经逐渐成型了。实际上,它就快完成了。接下来唯一要做的就是实现某种计分系统,并通过某种方法来结束游戏。首先解决计分系统问题,稍后介绍如何结束游戏。
8.1 计分系统
在游戏中,鉴于玩家试图生存尽可能长的时间,所以把存活时间作为计分标准显然是一个不错想法。不是吗?
我们需要通过某种方法来计算游戏从开始到现在所持续的时间。这正好是JavaScript 计时器的强项,但在构建计时器之前需要声明一些变量。在JavaScript 代码顶部的 player
变量下面添加以下代码:
let score; let scoreTimeout;
这些变量将用于存储分数(已经过去的秒数)和对计时器操作的引用,以便根据需要来开始或停止计时器。
另外,在游戏开始或重置时也需要重新设置分数。为此,在 startGame
函数顶部的 numAsteroids
变量下面添加以下代码:
score = 0;
为了便于管理得分计时器,我们创建一个名为 timer
的专用函数。在 animate
函数上面添加以下代码:
function timer() { if (playGame) { scoreTimeout = setTimeout(() => { uiScore.html(++score); timer(); }, 1000); } }
以上代码现在还不会起作用,但它会检查游戏是否开始,如果游戏已经开始,它就把计时器的时间间隔设置为 1 秒,并把该计时器赋给 scoreTimeout
变量。在计时器中,score
变量的值在增加,同时计分 UI 也在更新。然后,计时器自身将调用 timeout
函数来重复整个过程,这意味着游戏结束时计时器才会停止计时。
现在还没有调用 timer
函数,所以它还不会发挥作用。当游戏开始时,需要调用该函数,因此在 keydown
事件监听器中的 animate
函数调用下面添加以下代码:
timer();
只要玩家开始游戏,以上代码就会触发计时器。在浏览器中查看效果,在游戏界面的左上角可以看到分数在不断增加。
但遗憾的是,这里还存在一个问题——如果你重置游戏,分数有时候会显示为 1 秒钟。这是因为当你重置游戏时,分数计时器仍然在运行,但它实际在你重置游戏之后才运行(将重置分数由 0 更改为 1 )。为了解决这个问题,需要在重置游戏时先清除计时器。幸运的是,JavaScript 有特定的函数可以实现该操作。
在 init
函数的 uiReset.click
事件监听器的 startGame
调用上面添加以下代码:
clearTimeout(scoreTimeout);
顾名思义,以上代码的作用显而易见。通过这个独立的函数可以获取 scoreTime
变量中的分数计时器,并且阻止计时器的运行。再次运行游戏,你可以发现通过这行简单的 JavaScript 代码已经成功解决了上面遇到的问题。
8.2 杀死玩家
如果小行星无法伤害你,那么躲避小行星就没有任何意义了,因此我们需要添加一些功能来杀死玩家(当玩家碰到小行星时)。
在这里发现明显的问题了吗?在火箭是三角形的情况下,你能执行圆周碰撞检测吗?简单地说,你不能执行圆周碰撞检测,或者说至少没那么容易。但这里将忽略一些细节问题,也就是说,把玩家火箭的一小部分区域作为碰撞检测区域。在实际中如果你幸运一些,这种检测有助于躲避小行星。
为了简化代码,我认为这样做是值得的。毕竟,这只是一个供娱乐的小游戏而已,因此不需要追求绝对的真实。
因此,只需在 animate
函数中绘制每颗小行星的代码上面添加以下代码即可:
const dx = player.x - tmpAsteroid.x; const dy = player.y - tmpAsteroid.y; const distance = Math.sqrt((dx * dx) + (dy * dy)); if (distance < player.halfWidth + tmpAsteroid.radius) { soundThrust.pause(); soundDeath.currentTime = 0; soundDeath.play(); // 游戏结束 playGame = false; clearTimeout(scoreTimeout); uiStats.hide(); uiComplete.show() soundBackground.pause(); $(window).unbind("keyup"); $(window).unbind("keydown"); }
你应该能很快明白以上代码中的距离计算方法。你将通过它们来计算玩家火箭与当前循环中的小行星之间的像素距离。
下一步是判断火箭是否与小行星发生碰撞,可以通过查看上面计算的像素距离是否小于小行星半径加上火箭碰撞圆周的半径之和。这里使用的火箭碰撞圆周的半径是火箭宽度的一半,你也可以随意改变它。
如果火箭与小行星发生碰撞,就要杀死玩家。杀死玩家并结束游戏的过程非常简单,但我还需要逐行对它进行解释。
前三行代码停止播放推进器声音,并重置和播放死亡的声音。开始播放死亡的声音时,需要把 playGame
设置为 false
来结束整个游戏,并通过前面已经使用过的 clearTimeout
函数来停止计分计时器。
此时,所有的游戏逻辑都已经停止,因此可以隐藏统计界面,并显示游戏结束界面。
显示游戏结束界面时,需要停止播放背景音乐,并最终释放键盘事件处理程序,从而防止玩家由于无意按下某个按键而启动游戏。
9. 增加游戏难度
好的,其实我们要创建的游戏还没有完成。让我们在游戏中再添加一些功能,即增加游戏的难度,玩家要想存活更长的时间就变得更加困难了。
我们还是直入主题吧,在 timer
函数中的 uiScore.html
下面添加以下代码:
if (score % 5 === 0) { numAsteroids += 5; }
以上代码看上去是否像一个普通的条件语句?其实不是。注意其中的百分比符号。它是求模运算符。求模可以计算一个数是否能被另一个数完全整除,它将返回两个数相除所得的余数。
你可以通过求模计算来执行周期性的操作,例如,每隔 5 秒发生一次。因为你可以把模 5 运算运用于某个数,如果它返回的结果为 0,那么该数一定能够被 5 整除。
在本游戏中,我们使用模 5 运算来确保某个代码段每隔 5 秒钟执行一次。这个代码段的作用是,每隔 5 秒钟就向游戏中添加 5 颗小行星。实际上,这里并没有增加小行星,增加的只是小行星的数目。
添加小行星很简单,在 animate
函数中绘制玩家火箭的代码下面添加以下代码:
while (asteroids.length < numAsteroids) { const radius = 5 + (Math.random() * 10); const x = Math.floor(Math.random() * canvasWidth) + canvasWidth + radius; const y = Math.floor(Math.random() * canvasHeight); const vX = -5 - (Math.random() * 5); asteroids.push(new Asteroid(x, y, radius, vX)); }
以上代码检查每个循环中的小行星数目,如果数目没有达到要求,它将继续向游戏中添加新的小行星,直到小行星的数目达到要求为止。
再次在浏览器中启动游戏,你会发现,当存活的时间越来越长时,游戏中的小行星就会越来越多。现在,你已经真正完成了游戏。
10. 完整源码
下面给出该游戏的完整源码:
index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Asteroid avoidance</title> <link rel="stylesheet" href="style.css" rel="external nofollow" rel="external nofollow" > <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="./main.js"></script> </head> <body> <div id="game"> <div id="game-ui"> <div id="game-intro"> <h1>Asteroid avoidance</h1> <p>Click play and then press any key to start.</p> <p> <a id="game-play" class="button" href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" >Play</a> </p> </div> <div id="game-stats"> <p>Time:<span class="game-score"></span> </p> <p> <a class="game-reset" href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" >Reset</a> </p> </div> <div id="game-complete"> <h1>Game over!</h1> <p>You survived for <span class="game-score"></span> seconds. </p> <p><a class="game-reset button" href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" >Play</a></p> </div> </div> <canvas id="game-canvas" width="800" height="600"> <!-- 在此处插入后备代码 --> </canvas> <audio id="game-sound-background" loop> <source src="sounds/background.ogg"> <source src="sounds/background.mp3"> </audio> <audio id="game-sound-thrust" loop> <source src="sounds/thrust.ogg"> <source src="sounds/thrust.mp3"> </audio> <audio id="game-sound-death"> <source src="sounds/death.ogg"> <source src="sounds/death.mp3"> </audio> </div> </body> </html>
style.css
* { margin: 0; padding: 0; } html, body { height: 100%; width: 100%; } canvas { display: block; } body { background-color: #000; color: #fff; font-family: Verdana, Arial, sans-serif; font-size: 18px; height: 100%; } h1 { font-size: 30px; } p { margin: 0 20px; } a { color: #fff; text-decoration: none; } a:hover { text-decoration: underline; } a.button { background-color: #185da8; border-radius: 5px; display: block; font-size: 30px; margin: 40px 0 0 270px; padding: 10px; width: 200px; } a.button:hover { background-color: #2488f5; color: #fff; text-decoration: none; } #game { height: 600px; left: 50%; margin: -300px 0 0 -400px; position: relative; top: 50%; width: 800px; } #game-canvas { background-color: #001022; } #game-ui { height: 600px; position: absolute; width: 800px; } #game-intro, #game-complete { background-color: rgba(0, 0, 0, .5); margin-top: 100px; padding: 40px 0; text-align: center; } #game-stats { font-size: 14px; margin: 20px 0; } #game-stats, .game-reset { margin: 20px 20px 0 0; position: absolute; right: 0; top: 0; }
main.js
$(document).ready(function () { const canvas = $('#game-canvas'); const context = canvas.get(0).getContext("2d"); // 画布尺寸 const canvasWidth = canvas.width(); const canvasHeight = canvas.height(); // 游戏设置 let playGame; let asteroids; let numAsteroids; let player; let score; let scoreTimeout; const arrowUp = 38; const arrowRight = 39; const arrowDown = 40; // 游戏UI const ui = $("#game-ui"); const uiIntro = $("#game-intro"); const uiStats = $("#game-stats"); const uiComplete = $("#game-complete"); const uiPlay = $("#game-play"); const uiReset = $(".game-reset"); const uiScore = $(".game-score"); const soundBackground = $("#game-sound-background").get(0); const soundThrust = $("#game-sound-thrust").get(0); const soundDeath = $("#game-sound-death").get(0); function Asteroid(x, y, radius, vX) { this.x = x; this.y = y; this.radius = radius; this.vX = vX; } function Player(x, y) { this.x = x; this.y = y; this.width = 24; this.height = 24; this.halfWidth = this.width / 2; this.halfHeight = this.height / 2; this.vX = 0; this.vY = 0; this.moveRight = false; this.moveUp = false; this.moveDown = false; this.flameLength = 20; } // 重至和启动游戏 function startGame() { // 重置游戏状态 uiScore.html("0"); uiStats.show(); // 初始游戏设置 playGame = false; asteroids = new Array(); numAsteroids = 10; score = 0; player = new Player(150, canvasHeight / 2); for (let i = 0; i < numAsteroids; i++) { const radius = 5 + (Math.random() * 10); const x = canvasWidth + radius + Math.floor(Math.random() * canvasWidth); const y = Math.floor(Math.random() * canvasHeight); const vX = -5 - (Math.random() * 5); asteroids.push(new Asteroid(x, y, radius, vX)); } $(window).keydown(e => { const keyCode = e.keyCode; if (!playGame) { playGame = true; soundBackground.currentTime = 0; soundBackground.play(); animate(); timer(); } if (keyCode == arrowRight) { player.moveRight = true; if (soundThrust.paused) { soundThrust.currentTime = 0; soundThrust.play(); } } else if (keyCode == arrowUp) { player.moveUp = true; } else if (keyCode == arrowDown) { player.moveDown = true; } }); $(window).keyup(e => { const keyCode = e.keyCode; if (keyCode == arrowRight) { player.moveRight = false; soundThrust.pause(); } else if (keyCode == arrowUp) { player.moveUp = false; } else if (keyCode == arrowDown) { player.moveDown = false; } }); // 开始动画糖环 animate(); } //初始化游戏环境 function init() { uiStats.hide(); uiComplete.hide(); uiPlay.click(function (e) { e.preventDefault(); uiIntro.hide(); startGame(); }); uiReset.click(function (e) { e.preventDefault(); uiComplete.hide(); $(window).unbind('keyup'); $(window).unbind('keydown'); soundThrust.pause(); soundBackground.pause(); clearTimeout(scoreTimeout); startGame(); }); } function timer() { if (playGame) { scoreTimeout = setTimeout(() => { uiScore.html(++score); if (score % 5 === 0) { numAsteroids += 5; } timer(); }, 1000); } } // 动画循环,游戏的嫌味性就在这里 function animate() { // 清除 context.clearRect(0, 0, canvasWidth, canvasHeight); const asteroidsLength = asteroids.length; for (let i = 0; i < asteroidsLength; i++) { const tmpAsteroid = asteroids[i]; tmpAsteroid.x += tmpAsteroid.vX; if (tmpAsteroid.x + tmpAsteroid.radius < 0) { tmpAsteroid.radius = 5 + (Math.random() * 10); tmpAsteroid.x = canvasWidth + tmpAsteroid.radius; tmpAsteroid.y = Math.floor(Math.random() * canvasHeight); tmpAsteroid.vX = -5 - (Math.random() * 5); } const dx = player.x - tmpAsteroid.x; const dy = player.y - tmpAsteroid.y; const distance = Math.sqrt((dx * dx) + (dy * dy)); if (distance < player.halfWidth + tmpAsteroid.radius) { soundThrust.pause(); soundDeath.currentTime = 0; soundDeath.play(); // 游戏结束 playGame = false; clearTimeout(scoreTimeout); uiStats.hide(); uiComplete.show() soundBackground.pause(); $(window).unbind("keyup"); $(window).unbind("keydown"); } context.fillStyle = "rgb(255, 255, 255)"; context.beginPath(); context.arc(tmpAsteroid.x, tmpAsteroid.y, tmpAsteroid.radius, 0, Math.PI * 2, true); context.closePath(); context.fill(); } while (asteroids.length < numAsteroids) { const radius = 5 + (Math.random() * 10); const x = Math.floor(Math.random() * canvasWidth) + canvasWidth + radius; const y = Math.floor(Math.random() * canvasHeight); const vX = -5 - (Math.random() * 5); asteroids.push(new Asteroid(x, y, radius, vX)); } if (player.moveRight) { player.vX = 3; } else { player.vX = -3; } player.vY = 0; if (player.moveRight) { player.vX = 3; context.save(); context.translate(player.x - player.halfWidth, player.y); if (player.flameLength == 20) { player.flameLength = 15; } else { player.flameLength = 20; } context.fillStyle = "orange"; context.beginPath(); context.moveTo(0, -5); context.lineTo(-player.flameLength, 0); context.lineTo(0, 5); context.closePath(); context.fill(); context.restore(); } if (player.moveUp) { player.vY = -3; } if (player.moveDown) { player.vY = 3; } player.x += player.vX; player.y += player.vY; if (player.x - player.halfWidth < 20) { player.x = 20 + player.halfWidth; } else if (player.x + player.halfWidth > canvasWidth - 20) { player.x = canvasWidth - 20 - player.halfWidth; } if (player.y - player.halfHeight < 20) { player.y = 20 + player.halfHeight; } else if (player.y + player.halfHeight > canvasHeight - 20) { player.y = canvasHeight - 20 - player.halfHeight; } context.fillStyle = 'rgb(255, 0, 0)'; context.beginPath(); context.moveTo(player.x + player.halfWidth, player.y); context.lineTo(player.x - player.halfWidth, player.y - player.halfHeight); context.lineTo(player.x - player.halfWidth, player.y + player.halfHeight) context.closePath(); context.fill(); if (playGame) { setTimeout(animate, 33); } } init(); });
加载全部内容