Freddy vs JSON:如何制作一款俯视角射击游戏
我将向你介绍我是如何在不使用任何额外库的情况下,用 JavaScript 创建一个简单的俯视角射击游戏的。但本文并非要完整复现游戏,而是旨在展示从零开始编写游戏需要采取哪些步骤。
本文转载自我的个人博客:https://koehr.tech
几年前(哎呀,都快十年了!我都这么老了吗?),Canvas API 被大多数浏览器广泛采用的时候,我就开始尝试使用它。我当时就被它深深吸引,立刻尝试把它应用到互动玩具和游戏中。
当然,我制作(现在也在制作)的游戏通常都不太复杂。这主要是因为我制作它们只是为了娱乐,并没有太多华丽的画面,甚至连音效都没有。真正让我着迷的是游戏的底层机制。否则,我完全可以直接使用那些现成的优秀游戏引擎。
为了分享这份乐趣,我为公司(顺便一提,我们正在招聘)的技术研讨会制作了一个小型俯视角射击游戏。成品可以在 GitHub 上找到。我给代码加了详细的注释,所以直接阅读应该会很有帮助。但如果你想了解我是如何一步步制作这个游戏的,那么这篇文章就是为你准备的。
游戏
为了让您对我的作品有个大致了解:
那个小灰盒子就是你的飞船。你可以用WASD键或方向键控制它,然后按空格键或回车键向敌人——也就是那些红色盒子——发射黄色小盒子。敌人也会反击。他们的瞄准不太准,但过一段时间后,屏幕上就会布满红色小盒子。如果被击中,你会受伤。每次受伤,你的体积都会缩小,直到完全消失。你的对手也会经历同样的情况。
前提条件
这篇文章并非着眼于游戏本身,而是探讨其底层机制以及一些实现技巧。我的目的是为有一定编程经验的读者提供一个入门途径,帮助他们理解更复杂的游戏开发。以下内容有助于全面理解:
游戏引擎基本机制
大多数游戏引擎(如果不是全部的话)都具有相同的基本组成部分:
- ,
state用于定义当前情况(例如主菜单、游戏运行中、游戏失败、游戏获胜等)。 - 用于存储所有对象及相关数据的地方。
- 该函数
main loop通常每秒运行 60 次,负责读取对象信息、绘制屏幕并更新对象数据。 - 一个
event handler将按键、鼠标移动和点击映射到数据更改的功能。
画布元素
Canvas 元素允许你直接在浏览器中处理基于像素的数据。它提供了一些绘制基本图形的函数。例如,绘制一个蓝色矩形很容易,但绘制三角形需要多个操作;要绘制圆形,你需要了解如何使用弧线。
正因为使用 Canvas API 绘制矩形是最简单快捷的操作,所以我在 Freddy vs JSON 中所有操作都使用了矩形。这样就避免了绘制更复杂图案或图形的繁琐工作,让我们能够专注于游戏机制本身。这意味着,在初始化画布之后,除了设置颜色之外,我们只使用了两个函数:
const ctx = canvas.getContext('2d') // this is the graphics context
ctx.fillStyle = '#123456' // use color #123456
ctx.fillText(text, x, y) // write 'text' at coords x, y
ctx.fillRect(x, y, width, height) // draw filled rectangle
第一步:一些 HTML 代码和一个初始化的 Canvas。
由于代码将在浏览器中运行,因此需要一些 HTML 代码。最简配置仅包含以下两行:
<canvas id="canvas" />
<script src="./app.js"></script>
这个方法可行,但当然,如果能添加一些样式就更好了。或许还可以加个标题?可以去GitHub 查看完整版本。
初始化 Canvas 也非常简单。app.js以下几行代码是必需的:
const canvas = document.getElementById('canvas')
// you can set height and width in HTML, too
canvas.width = 960
canvas.height = 540
const ctx = canvas.getContext('2d')
我随意选择了宽度和高度的数值,您可以根据自己的喜好进行更改。但请注意,数值越高,电脑的计算量自然就越大。
第二步:游戏模式/状态
为了避免产生混乱不堪的系统,通常会使用状态机。其核心思想是描述高层状态及其有效转换,并使用中央状态处理器来控制这些状态。
有一些库可以帮助我们创建状态机,但自己创建也并非难事。在我开发的游戏中,我使用了一个非常简单的状态机实现:可能的状态及其转换用类似枚举的对象来描述。以下是一些示例代码,用于说明这个概念。这段代码使用了一些较新的语言特性:符号和计算属性名称。
const STATE = {
start: Symbol('start'), // the welcome screen
game: Symbol('game'), // the actual game
pause: Symbol('pause'), // paused game
end: Symbol('end') // after losing the game
}
const STATE_TRANSITION = {
[STATE.start]: STATE.game, // Welcome screen => Game
[STATE.game]: STATE.pause, // Game => Pause
[STATE.pause]: STATE.game, // Pause => Game
[STATE.end]: STATE.start // End screen => Welcome screen
}
这虽然不是一个完整的状态机,但足以满足需求。为了简化起见,我在一个地方违背了状态机的原则:游戏运行状态与游戏结束状态之间没有转换。这意味着玩家死亡后,我必须直接跳转到结束画面,而不能使用状态处理器。但这大大简化了代码。现在,状态控制逻辑实际上只有一行:
newState = STATE_TRANSITION[currentState]
Freddy vs JSON 在点击事件处理程序中使用了这个功能。点击画布会将状态从欢迎界面切换到实际游戏,暂停和恢复游戏,并在失败后返回欢迎界面。所有这些操作都只需一行代码即可完成。新的状态会被赋值给一个变量,该变量会被中央更新循环所使用。稍后会详细介绍。
当然,状态的功能远不止于此。例如,可以实现武器或舰船升级。游戏可以过渡到更高的难度级别,并出现一些特殊的游戏状态,比如升级商店或关卡之间的过渡动画。你的想象力就是极限。当然,状态处理程序的代码行数也是限制因素。
第三步:数据处理
游戏通常需要处理大量信息。例如,玩家的位置和生命值、每个敌人的位置和生命值、当前飞行的每颗子弹的位置以及玩家迄今为止的命中次数。
JavaScript 提供了多种处理方式。当然,状态也可以是全局的。但我们都应该知道,全局变量是万恶之源。全局常量没问题,因为它们保持了可预测性。千万不要使用全局变量。如果你仍然不相信,请阅读Stack Exchange 上的这篇文章。
你可以不用全局变量,而是把所有内容都放在同一个作用域里。下面是一个简单的例子。以下代码示例使用了模板字面量,这是语言的一项新特性。点击此处了解更多关于模板字面量的信息。
function Game (canvas) { // the scope
const ctx = canvas.getContext('2d')
const playerMaxHealth = 10
let playerHealth = 10
function handleThings () {
ctx.fillText(`HP: ${playerHealth} / ${playerMaxHealth}`, 10, 10)
}
}
这样做的好处在于,你可以像使用全局变量一样轻松访问变量,而无需实际使用全局变量。当然,如果所有内容都只使用一个大的作用域,仍然存在一些潜在问题。不过,第一个游戏规模可能比较小,所以暂时不必过于担心这个问题。
另一种方法是使用类:
class Game {
constructor (canvas) {
this.ctx = canvas.getContext('2d')
this.playerMaxHealth = 10
this.playerHealth = 10
}
handleThings () {
const max = this.playerMaxHealth
const hp = this.playerHealth
ctx.fillText(`HP: ${hp} / ${max}`, 10, 10)
}
}
这看起来有点像样板代码,但类很适合封装常用功能。如果你的游戏规模越来越大,你想保持代码的简洁性,类就显得尤为重要。不过在 JavaScript 中,类只是语法糖。所有功能都可以通过函数和函数作用域来实现。所以,最终选择权在你。最后两个代码示例本质上是一样的。
既然我们已经决定了如何保存所有数据(Freddy vs JSON 使用类,所以我也在这里使用类),我们可以进一步组织数据结构……或者不组织。Freddy vs JSON 将所有数据扁平化保存。这意味着,例如,每个玩家属性都有自己的变量,而不是使用包含大量属性的玩家对象。后者可能更易读,所以你可能更倾向于这种方式。如今对象访问速度也很快,所以如果你写成 `[[[[[[[]]]]]` 而不是 `[[[[]]]`,可能不会有明显的差异。this.player.health但this.playerHealth如果你真的非常注重性能,你可能需要进一步研究这个问题。你可以先看看我的jsperf实验。
数据操作发生在更新循环或事件处理过程中。接下来的步骤将进一步解释这些主题。
第四步:主循环
如果像网站那样,基于事件的更改就足够了,那么就不需要单独的循环。用户点击某个地方,触发一个事件,更新某些内容,最终重新渲染页面的一部分。但在游戏中,有些事情无需用户直接交互就会发生。敌人会出现并向你射击,可能会有一些背景动画,播放音乐等等。为了实现这一切,游戏需要一个无限循环,不断调用一个函数来检查和更新所有状态。为了保证流畅运行,它应该以恒定的间隔调用这个函数——至少每秒30次,最好是每秒60次。
以下代码示例使用了另一个较新的语言特性,称为箭头函数。
定期运行函数的典型方法包括使用setInterval:
let someValue = 23
setInterval(() => {
someValue++
}, 16)
或者setTimeout
let someValue = 42
function update () {
someValue++
setTimeout(update, 16)
}
update()
第一个版本会每隔十六毫秒(即每秒六十二次半)无限循环运行该函数,而不管函数本身需要多少时间或是否已经完成。第二个版本则会在执行完可能耗时较长的任务后,设置一个定时器,使其在十六毫秒后再次启动。
第一个版本的问题尤其严重。如果单次运行时间超过 16 毫秒,它会在第一次运行完成之前再次运行,这或许会带来一些麻烦,但未必能得出任何有用的结果。第二个版本在这方面明显更好,因为它只在完成所有其他操作后才设置下一个超时时间。但仍然存在一个问题:无论函数需要运行多长时间,它都会额外等待 16 毫秒才能再次运行该函数。
为了缓解这个问题,该函数需要知道完成其任务所花费的时间,然后从等待时间中减去该值:
let lastRun
let someValue = 42
function update () {
someValue++
const duration = Date.now() - lastRun
const time = duration > 16 ? 0 : 16 - time
setTimeout(update, time)
lastRun = Date.now()
}
lastRun = Date.now()
update()
Date.now()返回当前时间(以毫秒为单位)。利用此信息,我们可以计算出自上次运行以来经过了多少时间。如果超过 16 毫秒,则立即开始更新,让计算机不堪重负(或者最好减慢执行速度,对计算机好一点),否则,等待必要的时间,以保持每秒约 60 次运行。
请注意,Date.now() 并非衡量性能的最佳方法。要了解更多关于性能和高分辨率时间测量的信息,请访问:https://developer.mozilla.org/en-US/docs/Web/API/Performance
酷。这样你也可以通过将间隔设置为 33 毫秒,将帧速率降至每秒 30 帧。但我们还是别这么做了。让我们像那些使用炫酷新浏览器的潮人们一样,使用requestAnimationFrame。
requestAnimationFrame它接受你的更新函数作为参数,并在下次重绘之前调用它。它还会提供上次调用的时间戳,这样你就无需再次请求更新,从而避免影响性能。让我们深入了解一下细节:
function update () {
/* do some heavy calculations */
requestAnimationFrame(update)
}
update()
这是最简单的版本。它会尽可能在下一次屏幕重绘之前运行更新函数。这意味着它通常每秒运行 60 次,但具体频率可能因运行该函数的计算机屏幕刷新率而异。如果你的函数运行时间超过了屏幕刷新间隔,它会跳过一些重绘,因为它在完成之前不会请求重绘。这样,它就能始终与屏幕刷新率保持一致。
执行大量操作的函数可能不需要频繁运行。每秒运行 30 次通常足以保证运行流畅,而且某些计算可能并非每次都需要。这就引出了我们之前提到的定时函数。在这个版本中,我们使用requestAnimationFrame调用函数时返回的时间戳:
let lastRun
function update (stamp) {
/* heavy work here */
lastRun = stamp
// maybe 30fps are enough so the code has 33ms to do its work
if (stamp - lastRun >= 33) {
requestAnimationFrame(update)
}
}
// makes sure the function gets a timestamp
requestAnimationFrame(update)
第五步:事件处理
人们通常希望掌控自己的行为。这就引出了游戏需要处理用户输入的问题。输入可以是鼠标移动、鼠标点击或按键。按键操作又分为按下和松开两个阶段。稍后我会在本节解释原因。
如果你的游戏是该页面上唯一运行的内容(而且它确实值得如此重视,不是吗?),那么输入事件可以直接绑定到 ` document<canvas>` 元素。否则,它们需要直接绑定到画布事件。后者对于按键事件来说会更复杂,因为按键事件最适合与实际的输入框配合使用。这意味着你需要在页面中插入一个输入框,并确保它保持焦点状态以便接收到事件。每次点击画布都会导致它失去焦点。为了避免这种情况,你可以使用以下技巧:
inputElement.onblur = () => inputElement.focus()
或者,你也可以把所有内容都放在一个单独的页面上,并将事件监听器绑定到该页面上document。这样会方便很多。
附注:可能有人会好奇我为什么不用 `addEventListener`。如果你觉得用它更安心,那就用吧。我这里不用它是为了简化代码,只要每个元素针对每种事件类型都只有一个事件监听器,就不会有问题。
鼠标移动
在 Freddy vs JSON 中,鼠标移动实际上并不常用,但如果不加以解释,这篇文章就不完整。所以,以下是具体操作方法:
canvas.onmousemove = mouseMoveEvent => {
doSomethingWithThat(mouseMoveEvent)
}
只要鼠标位于画布上方,每次轻微移动都会触发此事件处理程序。通常需要对该事件处理程序进行防抖处理,因为事件触发频率可能非常高。另一种方法是仅将其用于非常简单的功能,例如保存鼠标坐标。该信息可以在与事件触发无关的函数中使用,例如我们的更新函数:
class Game {
constructor (canvas) {
// don't forget to set canvas width and height,
// if you don't do it, it will set to rather
// small default values
this.ctx = canvas.getContext('2d')
this.mouseX = 0
this.mouseY = 0
// gets called at every little mouse movement
canvas.onmousemove = event => {
this.mouseX = event.offsetX
this.mouseY = event.offsetY
}
this.update()
}
// gets called at each repaint
update () {
requestAnimationFrame(() => this.update())
this.fillRect('green', this.mouseX, this.mouseY, 2, 2)
}
}
MouseEvent对象包含更多有用的信息。我建议您点击链接查看相关内容。
这样,鼠标悬停在画布上的任何位置,都会绘制出两个像素宽的方框。没错,一个十行代码就能搞定的绘图程序!Photoshop,我们来挑战你了!
鼠标点击
但我们还是回到现实吧。鼠标点击是另一种重要的交互方式:
canvas.onclick = mouseClickEvent => {
doSomethingWithThat(mouseClickEvent)
}
事件对象再次包含了各种有用的信息。它与鼠标移动获取的对象类型相同。这样一来,事情就简单多了,不是吗?
现在为了利用鼠标点击功能,让我们对之前的代码示例进行一些修改:
class Game {
constructor (canvas) {
// set canvas.width and canvas.height here
this.ctx = canvas.getContext('2d')
this.mouseX = 0
this.mouseY = 0
this.drawing = false
canvas.onmousemove = event => {
this.mouseX = event.offsetX
this.mouseY = event.offsetY
}
canvas.onmousedown = () => {
this.drawing = true
}
canvas.onmouseup = () => {
this.drawing = false
}
this.update()
}
update () {
requestAnimationFrame(() => this.update())
if (this.drawing) {
this.fillRect('green', this.mouseX, this.mouseY, 2, 2)
}
}
}
现在只需按住鼠标按钮即可绘制方框。哇,离 Photoshop 的易用性又近了一步!它已经能做到这么多,真是令人难以置信。看看这幅令人惊叹的艺术作品吧:
重要事件
最后一种重要的输入方式是键盘输入。好吧,其实它并非最后一种输入方式。其他输入方式还包括操纵杆或游戏手柄。但像我这样的老派玩家仍然更喜欢用键盘来操控他们的宇宙飞船。
输入处理理论上很简单,但实际上却并非如此。因此,本节不仅会解释关键事件的工作原理,还会讲解如何正确地处理它们。敬请期待事件处理、速度和加速度之间的关系,以及与帧率无关的计时方法……
最简单的按键事件处理方式如下:
document.onkeypress = keyPressEvent => {
doSomethingWithThat(keyPressEvent)
}
但keypress这种方法已被弃用,不应再使用。最好将其拆分keyPress为两个事件:` KeyDownand` 和 ` KeyUpand`,我稍后会解释原因。
现在想象一下,屏幕中央有一艘很酷的宇宙飞船,你想让它在用户按下d或键时向右飞行ArrowRight:
class Game {
constructor(canvas, width, height) {
// we'll need those values
this.width = canvas.width = width;
this.height = canvas.height = height;
this.ctx = canvas.getContext("2d");
this.shipSize = 10;
this.shipHalf = this.shipSize / 2.0; // you'll need that a lot
// position the ship in the center of the canvas
this.shipX = width / 2.0 - this.shipHalf;
this.shipY = height / 2.0 - this.shipHalf;
// event is a KeyboardEvent:
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent
document.onkeypress = event => {
const key = event.key;
if (key === "d" || key === "ArrowRight") {
this.shipX++;
}
};
this.update();
}
// convenience matters
rect(color, x, y, w, h) {
this.ctx.fillStyle = color;
this.ctx.fillRect(x, y, w, h);
}
update() {
// clean the canvas
this.rect("black", 0, 0, this.width, this.height);
// get everything we need to draw the ship
const size = this.shipSize;
const x = this.shipX - this.shipHalf;
const y = this.shipY - this.shipHalf;
// draw the ship
this.rect("green", x, y, size, size);
// redraw as fast as it makes sense
requestAnimationFrame(() => this.update());
}
}
好吧,这勉强能用,至少按按钮的时候是这样d。但是方向键好像失灵了,飞船的移动感觉有点卡顿。这似乎不太理想。
问题在于我们依赖于重复的按键事件。如果您按住一个键,该keypress事件每秒会重复几次,具体次数取决于您设置的按键重复频率。由于我们无法得知用户按键的重复频率,因此无法利用这种方式实现平滑的移动。当然,我们可以尝试测量重复频率,前提是用户按住按键的时间足够长。但我们应该尝试更智能的方法。
让我们回顾一下:我们握住钥匙,船就会移动。我们松开钥匙,船就会停止移动。这就是我们想要的。这两个事件竟然都……嗯……有关联,真是个巧合:
class Game {
constructor(canvas, width, height) {
// we'll need those values
this.width = canvas.width = width;
this.height = canvas.height = height;
this.ctx = canvas.getContext("2d");
this.shipSize = 10;
this.shipHalf = this.shipSize / 2.0; // you'll need that a lot
// position the ship in the center of the canvas
this.shipX = width / 2.0 - this.shipHalf;
this.shipY = height / 2.0 - this.shipHalf;
this.shipMoves = false;
// key is pressed down
document.onkeydown = event => {
const key = event.key;
switch (key) {
case "d":
case "ArrowRight":
this.shipMoves = "right";
break;
case "a":
case "ArrowLeft":
this.shipMoves = "left";
break;
case "w":
case "ArrowUp":
this.shipMoves = "up";
break;
case "s":
case "ArrowDown":
this.shipMoves = "down";
break;
}
};
document.onkeyup = () => {
this.shipMoves = false;
};
this.update();
}
// convenience matters
rect(color, x, y, w, h) {
this.ctx.fillStyle = color;
this.ctx.fillRect(x, y, w, h);
}
update() {
// move the ship
if (this.shipMoves) {
if (this.shipMoves === "right") this.shipX++;
else if (this.shipMoves === "left") this.shipX--;
else if (this.shipMoves === "up") this.shipY--;
else if (this.shipMoves === "down") this.shipY++;
}
// clean the canvas
this.rect("black", 0, 0, this.width, this.height);
// get everything we need to draw the ship
const size = this.shipSize;
const x = this.shipX - this.shipHalf;
const y = this.shipY - this.shipHalf;
// draw the ship
this.rect("green", x, y, size, size);
// redraw as fast as it makes sense
requestAnimationFrame(() => this.update());
}
}
我当时就想把所有方向都加上。现在,移动本身与按键事件解耦了。不再是在每个事件发生时直接改变坐标,而是给移动方向设置一个值,然后由主循环负责调整坐标。这很棒,因为我们不再需要关心按键重复率了。
但这里仍然存在一些问题。首先,飞船一次只能朝一个方向移动。它应该能够同时朝两个方向移动,例如向上和向左。其次,如果按键切换过快,飞船就会停止移动。这种情况可能会在飞船与敌人子弹激烈交战时发生。此外,飞船的移动速度与帧率挂钩。如果帧率下降或玩家电脑的屏幕刷新率发生变化,飞船的移动速度就会随之改变。最后,飞船的速度会突然从全速跳到零。为了更自然地操控飞船,它应该采用加速和减速的过渡方式。
工作量很大。让我们逐一解决这些问题:
双向移动很容易实现。我们只需要第二个变量。为了进一步简化,我们可以将这些变量设置为数字而不是标识字符串。原因如下:
class Game {
constructor(canvas, width, height) {
/* ... same as before ... */
this.shipMovesHorizontal = 0;
this.shipMovesVertical = 0;
// this time, the values are either positive or negative
// depending on the movement direction
document.onkeydown = event => {
const key = event.key;
switch (key) {
case "d":
case "ArrowRight":
this.shipMovesHorizontal = 1;
break;
case "a":
case "ArrowLeft":
this.shipMovesHorizontal = -1;
break;
case "w":
case "ArrowUp":
this.shipMovesVertical = -1;
break;
case "s":
case "ArrowDown":
this.shipMovesVertical = 1;
break;
}
};
// to make this work, we need to reset movement
// but this time depending on the keys
document.onkeyup = event => {
const key = event.key;
switch (key) {
case "d":
case "ArrowRight":
case "a":
case "ArrowLeft":
this.shipMovesHorizontal = 0;
break;
case "w":
case "ArrowUp":
case "s":
case "ArrowDown":
this.shipMovesVertical = 0;
break;
}
};
this.update();
}
/* more functions here */
update() {
// move the ship
this.shipX += this.shipMovesHorizontal;
this.shipY += this.shipMovesVertical;
/* drawing stuff */
}
}
这不仅能让飞船同时朝两个方向移动,也简化了所有操作。但仍然存在一个问题,那就是快速按键操作难以被准确识别。
从代码的角度来看,在那些紧张时刻实际发生的情况是正确的:如果按下相同方向(水平或垂直)的键,则设置移动方向;如果松开键,则将移动方向设置为零。但人类的操作并不总是那么精确。他们可能a在完全松开右箭头键(或右箭头)的前一瞬间按下左箭头键(或右箭头d)。这样,函数会在那一瞬间切换移动方向,但随后由于按键的松开而停止。
为了解决这个问题,keyup处理程序需要添加一些逻辑:
document.onkeyup = event => {
const key = event.key;
switch (key) {
case "d":
case "ArrowRight":
if (this.shipMovesHorizontal > 0) {
this.shipMovesHorizontal = 0;
}
break;
case "a":
case "ArrowLeft":
if (this.shipMovesHorizontal < 0) {
this.shipMovesHorizontal = 0;
}
break;
case "w":
case "ArrowUp":
if (this.shipMovesVertical < 0) {
this.shipMovesVertical = 0;
}
break;
case "s":
case "ArrowDown":
if (this.shipMovesVertical > 0) {
this.shipMovesVertical = 0;
}
break;
}
};
好多了,不是吗?无论我们做什么,飞船都朝着预期的方向飞行。是时候解决最后几个问题了。我们先从简单的开始:加速度。
目前,这艘船的速度是固定的。我们先来提高它的速度,因为我们想要看到一些动作,对吧?为此,我们先定义一下这艘船的最大速度:
this.shipSpeed = 5 // pixel per frame
并将其用作乘数:
update() {
// move the ship
this.shipX += this.shipMovesHorizontal * this.shipSpeed;
this.shipY += this.shipMovesVertical * this.shipSpeed;
/* drawing stuff */
}
现在,我们不再直接加速到全速,而是逐轴更新速度值:
constructor () {
/* ... */
this.shipSpeed = 5
this.shipVelocityHorizontal = 0
this.shipVelocityVertical = 0
/* ... */
}
/* ...more stuff... */
update () {
// accelerate the ship
const maxSpeed = this.shipSpeed;
// speed can be negative (left/up) or positive (right/down)
let currentAbsSpeedH = Math.abs(this.shipVelocityHorizontal);
let currentAbsSpeedV = Math.abs(this.shipVelocityVertical);
// increase ship speed until it reaches maximum
if (this.shipMovesHorizontal && currentAbsSpeedH < maxSpeed) {
this.shipVelocityHorizontal += this.shipMovesHorizontal * 0.2;
} else {
this.shipVelocityHorizontal = 0
}
if (this.shipMovesVertical && currentAbsSpeedV < maxSpeed) {
this.shipVelocityVertical += this.shipMovesVertical * 0.2;
} else {
this.shipVelocityVertical = 0
}
/* drawing stuff */
}
这段代码会缓慢地加速飞船直至达到全速,但它仍然会立即停止。为了使飞船减速,并确保飞船真正停止而不是由于舍入误差而随机漂浮,还需要添加一些代码。你可以在CodeSandbox 上的最终版本中找到所有代码。
现在最后一个问题也解决了:帧率相关的运动。目前,所有数值都经过调整,以保证在当前速度下流畅运行。假设帧率为每秒 60 帧。现在,可怜的电脑需要在后台安装更新,或者可能是 Chrome 浏览器出了问题。也可能是玩家的屏幕刷新率不同。结果就是帧率下降或上升。我们以帧率降至一半为例。每秒 30 帧对于几乎所有情况来说仍然非常流畅。电影也是每秒 30 帧,播放效果很好,对吧?然而,我们的飞船速度突然只有原来的一半,这种差异非常明显。
为了避免这种情况,运动必须基于实际时间。不再是每帧都给坐标加上一个固定值,而是加上一个与上次更新以来经过的时间相关的值。速度变化也需要这样做。因此,我们不再使用每秒 60 帧时大致任意的 5 像素值,而是以每毫秒像素为单位设置值,因为所有精度都以毫秒为单位。
5px*60/s = 300px/s = 0.3px/ms
这样,下一步就很容易了:计算自上次更新以来经过的毫秒数,并将其乘以最大速度和加速度值:
constructor () {
/* ... */
this.shipSpeed = 0.3 // pixels per millisecond
// how fast the ship accelerates
this.shipAcceleration = this.shipSpeed / 10.0
this.shipVelocityHorizontal = 0
this.shipVelocityVertical = 0
/* ... */
// this should always happen right before the first update call
// performance.now gives a high precision time value and is also
// used by requestAnimationFrame
this.lastDraw = performance.now()
requestAnimationFrame(stamp => this.update(stamp))
}
/* ...more stuff... */
// See the main loop section if "stamp" looks fishy to you.
update (stamp) {
// calculate how much time passed since last update
const timePassed = stamp - this.lastDraw
this.lastDraw = stamp
// accelerate the ship
const maxSpeed = this.shipSpeed * timePassed;
const accel = this.shipAcceleration * timePassed;
let currentAbsSpeedH = Math.abs(this.shipVelocityHorizontal);
let currentAbsSpeedV = Math.abs(this.shipVelocityVertical);
if (this.shipMovesHorizontal && currentAbsSpeedH < maxSpeed) {
const acceleration =
this.shipVelocityHorizontal += this.shipMovesHorizontal * accel;
} else {
this.shipVelocityHorizontal = 0
}
if (this.shipMovesVertical && currentAbsSpeedV < maxSpeed) {
this.shipVelocityVertical += this.shipMovesVertical * accel;
} else {
this.shipVelocityVertical = 0
}
/* drawing stuff */
}
如果一切都和之前一样,那就说明你做得完全正确。现在,无论帧速率如何,你发送的画面都会以每毫秒五个像素的速度移动。很遗憾,除了改变屏幕刷新率或覆盖现有数据之外,我没有找到更好的测试方法,requestAnimationFrame所以就把这部分内容从文章中省略了。
剧终
恭喜,你已经制作出了一艘可以正常行驶的船。这篇文章到此结束,但游戏开发领域当然还有很多东西值得学习。《Freddy vs JSON》添加了一些其他元素,但仅使用了本文中介绍的技术。欢迎查看它的源代码,并创建大量类似的游戏,或者完全不同的游戏。尽情发挥你的创造力,享受运用你刚刚学到的知识的乐趣吧!
文章来源:https://dev.to/koehr/freddy-vs-json-how-to-make-a-top-down-shooter-50lg

