HTML5-和-JavaScript-高级游戏设计-全-
HTML5 和 JavaScript 高级游戏设计(全)
零、简介
你如何制作一个电子游戏?这本书会告诉你你需要知道的一切。您将学习如何使用低级开源技术,从零开始构建自己的开发工具,制作各种 2D 动作游戏。你所需要的就是这本书,一个文本编辑器,一个网络浏览器和任何一台旧电脑。没有库,没有框架,没有游戏引擎,没有黑盒神秘代码,没有昂贵的专有软件或许可证。你可以将你的游戏跨平台发布到桌面、移动设备和网络上。您很快就会看到,只需编写几百行代码,您就可以开发出任何您能想到的 2D 视频游戏。
听起来很有趣?这是最好的乐趣!以下是你将学到的东西:
- 新的 JavaScript 技巧和诀窍,包括 ES6 附带的一些技巧(第一章)。
- 如何在屏幕上绘制图形,并使用这些图形创建可重用的游戏组件(第二章和第四章)。
- 如何创建一个场景图(游戏对象的父子层次结构)最大限度的灵活创建游戏场景(第四章)。
- 如何高效地加载图像、声音、字体和其他游戏素材(第三章)并构建加载进度条(第十一章)。
- 如何用物理制作物体动画(第五章),如何用关键帧动画制作出细节丰富的游戏角色(第八章)。
- 关于碰撞检测你需要知道的一切(第七章),包括视频游戏设计师的“秘密黑艺术”:矢量数学(附录 A)。
- 保存和加载游戏数据,在全屏模式下运行游戏(第一章,将游戏缩放到任意窗口大小(第十一章)。)
- 分离轴定理(SAT)是什么,为什么对游戏有用?在附录 A 中找到答案!
- 如何创建按钮,为你的游戏添加鼠标、触摸和键盘交互性,学习如何为游戏对象创建拖放系统(第六章)。
- 如何给你的游戏添加特效:简单的视差滚动,粒子特效(第八章,和屏幕抖动(第十一章)。
- 如何播放和控制声音文件以及如何从纯代码动态生成合成音效(第九章)。
- 如何建立一个补间系统,让游戏物体沿着固定的曲线路径(第十章)。
- 如何将所有这些部件组合在一起,制作自己的游戏引擎,以便您能够以最快、最简单、最有趣的方式构建游戏(第十一章)。
- 在这个过程中,你会学到很多最现代的编码和游戏开发实践。
这本书是一个独立的,制作视频游戏的经典教育,也是作为一个游戏设计师,你需要知道的所有重要技术的概要。如果你独自一人在一个荒岛上,只有一台太阳能笔记本电脑、一堆椰子和这本书,你将拥有从《太空战争》中重现 2D 电子游戏全部历史所需的一切!敬皮皮鸟。
你需要知道的事情
为了充分利用这本书,你需要具备一定程度的编程素养。您应该能够浏览您最熟悉的任何编程语言的代码块,并对其工作原理有一个大致的了解。你不需要把自己当成一个“程序员”或者某种专家。你只需要能够带着一定程度的自信摸索出解决编程问题的方法。如果你喜欢编程,你会喜欢这本书。
这本书里的编程代码是用 JavaScript 写的。JavaScript 属于 C 语言家族(C、C++、C#、Objective-C、Java 和 AS3.0),所以如果你知道这些语言中的任何一种,你将能够轻松地使用 JavaScript。即使你以前从未使用过 JavaScript,第一章也会给你一个快速入门。
你以前没做过编程吗?然后从这本书的前身开始,用 HTML5 和 JavaScript 进行基础游戏设计。当你在学习如何制作游戏时,它会教你如何编程,并涵盖你开始使用这本书所需要知道的一切。
用 HTML5 和 JavaScript 看过基础游戏设计吗?如果是这样,你已经准备好了。如果你认为你可能忘记了一点,或者在开始前需要复习,放松!我用一种非常偷偷摸摸的方式写了这本书。它涵盖了所有的基础知识,从零开始,并融入了所有先进的,有趣的新东西。所以,如果你忘记了一些东西,比如如何制作和渲染一个精灵,不要担心:它都在这里,并且包含了许多聪明的新技巧。基础书是关于学习如何编程和如何以最可以理解的方式做事,而这本进阶书是关于以最好的和最令人兴奋的方式做事。我写这本书是为了成为一本关于制作游戏的全面的书,这本书将通过启发和开阔眼界的新方法向你重新介绍我们热爱的艺术形式。
如何阅读这本书
这是一本故事书。你可以在床上读,或者在沙滩上。就像你看《简爱》或《指环王》一样,从《??》第一章开始,一路看到底。每章都建立在前一章的技术和概念之上。但是,如果你现在就想知道具体的技术,就在任何地方开始吧——你应该还是可以的。
*唯一不同的部分是附录 A:“运动和碰撞检测的向量。”你可以随时阅读。附录 A 是对向量数学概念的深入探索,这些概念通常与书中所有其他章节相关,但也是可选的。如果你想在开始第一章之前了解所有关于向量的知识,请先阅读附录。但是如果你不是真的那么感兴趣,或者只是想留着以后参考,完全可以跳过。
源代码
这本书里的大部分内容其实都不是这本书里的。您可以在每章附带的源代码文件中找到它。您可以从本书的产品页面www.apress.com下载源代码,或者从本书的 GitHub 资源库下载:
https://github.com/kittykatattack/agd
是的,有很多源代码!事实上,这本书里的例子太多了,大多数印刷的例子只突出了最重要的部分。你是一个足够优秀的程序员,能够理解这些部分如何融入大局。但是如果您有任何疑问,请打开完整的工作源代码并查看一下。
这本书的所有代码都是纯 JavaScript 和 HTML 编写的。它不使用任何抽象层,如 CoffeeScript、TypeScript 或 JQuery。
写这本书的时候,HTML5 为游戏开发者提供的许多最好的特性仍处于试验阶段,需要“厂商前缀”才能在所有平台上正常工作。如果你可能需要使用它们,我会在正文中提及,但我会尽量避免它们出现在本书的代码中。(为了最大限度地兼容,我将它们保存在源代码中,但通常将它们整理在 JS polyfill 文件中。)希望你在一个快乐的未来阅读这本书,那时 HTML5 进化的残余已经消失。但在此之前,以下是目前最常用的供应商前缀:
|
小贩
|
CSS 前缀
|
JavaScript 前缀
|
| :-- | :-- | :-- |
| 微软浏览器名歌剧 Chrome,Safari | - ms-``-moz-``-o-``-webkit - | ms``moz``o``webkit |
提示编写跨平台的 CSS 可能会特别困难,因为不同的供应商在 CSS 属性的定义上往往存在差异。通过使用像 Sass 这样的好的预处理器,您可以让 CSS 再次变得有趣,并使用它的 Compass 扩展为您自动创建所有特定于供应商的代码。
此外,确保在 web 服务器上运行所有源代码。我们将在本书中用到的许多 JavaScript 和 HTML5 特性,比如 XHR,只能在网络服务器中使用。一个很好的跨平台方法是安装 Node,这是一个非常棒的纯 JavaScript 服务器。访问nodejs.org并按照说明将其安装到您的平台上。然后安装一个运行在它上面的很棒的小助手工具,叫做 http-server: ( github.com/nodeapps/http-server)。这是一个快速的迷你网络服务器,你只需要一行代码就可以运行。使用命令行 CD 到项目的根目录,并键入
http-server
然后打开任何网络浏览器
http://localhost:8080/
如果您的项目目录中有一个名为index.html的文件,您会看到它自动显示出来。如果没有,只需在项目根目录中添加任何其他 HTML 文件的名称。
如果你正在使用一个现代的代码编辑器,比如括号、Atom、Light Table 或者 Sublime Text,当你预览一个 HTML 文件的时候,这些工具中的很多都会自动为你启动一个 web 服务器。查看您正在使用的编辑器的文档。如果你在 Unix 上使用 Vim 或 Emacs 这样的文本编辑器,你可以使用watch或fswatch(对于 Mac OSX)和make在文件改变时运行任务,然后使用tincr (Chrome)或Auto Reload (Firefox)这样的浏览器扩展自动刷新浏览器。有数不清的配置,所以尝试一下,找到一个你觉得舒服的工作流程。*
一、向上提升
《使用 HTML5 和 JavaScript 的高级游戏设计》的第一章是一个快节奏、拥挤不堪、令人眼花缭乱的自助餐,包含了酷、有趣、新颖、实用、有时令人愉快的令人难以置信的技术,你可以马上开始在你的游戏项目中使用这些技术。要想让你的开发技能更上一层楼,你需要知道的一切都在本章中:
- 全新的 JavaScript 技巧,包括用 ES6 制作游戏所需的一切
- 使用配置对象
- JavaScript 的 getters 和 setters
- 承诺
- 使用类和组合从旧对象创建新对象
- 用模块组织您的项目
- 用 XHR 读写 JSON 文件
- 显示全屏游戏
- 分发带有 iFrames 和精简源代码的游戏
你可以把这一章看作是现代游戏开发者的训练营,里面有你开始用 HTML5 和 JavaScript 制作游戏所需要知道的所有基本技能。
您将能够立即将所有这些技术应用到您的游戏项目中,它们是本书其余章节所基于的核心技能。如果你读过 HTML5 和 JavaScript 的基础游戏设计,这有点像那本书的“秘密最后一章”。但是如果你没有读过那本书,并且你喜欢通过例子快速学习,这一章是对 JavaScript 和 HTML5 的闪电战式介绍,从头开始。
这一章与本书中的其他章节非常不同,因为它是一种快速入门参考指南。你需要读多少就读多少,顺序不限,然后只要你觉得准备好了就直接跳到第二章。
一些新的 JavaScript 技巧
这本书是使用最新版本的 JavaScript 编写的,称为 ES6 (ECMAScript 6)。如果您是 JavaScript 新手,您会发现这种语言很容易学习,因为它遵循大多数现代编程语言的范例。如果你已经知道 JavaScript,但你是在 2015 年之前学的,那么你会大吃一惊。ES6 几乎就像一种全新的语言。但好消息是,它是比你所知道的版本更好、更简单、更友好的 JavaScript 版本。最重要的是,它很容易学,所以当你读完这一章时,你就能流利地使用它了。
在这一章的第一部分,我将介绍你应该知道的关于语言如何工作的所有重要概念。您将了解 ES6 最重要的新特性,以及以前版本的 JavaScript ES5 的一些有用特性,这些您可能还没有意识到。
注意如果你想在 ES6 中编写代码,但仍能在旧平台上运行,你可以使用像 Babel 或 Traceur 这样的 transpiler ,它会在编译时进行转换,在这种情况下是从 ES6 到 ES5。我建议你这样做。使用 ES6 节省的精神开销远远弥补了传输代码所需要的额外工作。
变量:let、const和var??
使用关键字let创建一个变量:
let anyValue = 4;
let关键字赋予变量块范围 。这意味着变量在定义它的花括号之外是看不见的。这里有一个例子:
let say = "hello";
let outer = "An outer variable";
if (say === "hello") {
let say = "goodbye";
console.log(say);
//Displays: "goodbye"
console.log(outer);
//Displays: "An outer variable"
let inner = "An inner variable";
console.log(inner);
//Displays: "An inner variable"
}
console.log(say);
//Displays: "hello"
console.log(inner);
//Displays: ReferenceError: inner is not defined
在if语句外定义的变量可以在if语句内看到。但是在if语句中定义的任何变量在它之外是看不到的。这就是块作用域。if语句的花括号定义了变量可见的块。
在这个例子中,你可以看到有两个变量叫做say。因为它们是在不同的块中定义的,所以它们是不同的变量。改变if语句内的代码不会改变if语句外的代码。
您也可以使用var和const创建变量:
var:这给出了变量的作用范围??。这意味着变量在函数内部的任何地方都是可见的,甚至是在创建它的块之外。const:这创建了一个不能改变其值的变量。如果你试图改变它的值,你会得到一个错误。如果您的代码依赖于一个不会被意外更改的值,这将很有帮助。const变量也有块作用域。
然而,几乎在所有情况下,你都应该使用let。它防止你在代码中犯各种各样的小错误,这些小错误有时会导致紧张的调试会话。
变量是您最重要的 JavaScript 构建块,您使用它们和函数来构建程序。
功能
使用函数来运行和定义代码块、执行计算以及返回值。JavaScript 有两类函数:函数声明 和函数表达式 。
在 JavaScript 中,您可以这样定义一个函数声明:
function saySomething(value) {
console.log(value);
}
函数声明在编译时 加载。这意味着你的程序在其他代码运行之前就知道它们了。即使你在程序的末尾定义了一个函数,在程序开始运行之前,函数声明也会被预先加载。这使您可以在定义函数之前调用它,如下例所示:
saySomething("Hello from a function statement");
//Displays: "Hello from a function statement"
function saySomething(value) {
console.log(value);
}
JavaScript 还有函数表达式。你用一个粗箭头创建它们,就像这样:
let saySomething = (value) => {
console.log(value)
};
=>符号代表指向右边的箭头,像这样:
。它形象地表示“使用括号中的值在我指向的下一个代码块中做一些工作。”
使用let(或var)定义函数表达式的方式与定义变量的方式相同。每一个在它的右括号后面还需要一个分号。与函数声明不同,函数表达式必须在使用前定义,如下所示:
let saySomething = (value) => {
console.log(value)
};
saySomething("Hello from a function statement");
这是因为函数表达式是在运行时读取的。代码从上到下读取它们的顺序与读取其余代码的顺序相同。如果你试图在定义函数表达式之前调用它,你会得到一个错误。
如果你想写一个返回值的函数,使用一个return语句,如下所示:
let square = (x) => {
return x * x;
};
console.log(square(4));
//Displays: 16
为了方便起见,如果您的函数只是一行带有一个参数的代码,您可以省去花括号、参数括号和关键字return:
let square = x => x * x;
console.log(square(4));
//Displays: 16
这是一种简洁、紧凑、易读的函数编写方式。
注意箭头函数的一个很好的特性是,它们使函数内部的作用域与函数外部的作用域相同。这解决了困扰早期版本 JavaScript 的一个叫做绑定 的大问题。简而言之,你不得不解决的一整类怪癖不再是问题。(比如你不再需要用老var self = this;的招数了。)在一些极端的情况下,绑定仍然是一个问题,但是当我们遇到这些问题时,我会完整地解释这些问题。
您可以不使用粗箭头来编写函数表达式,如下所示:
let saySomething = function(value) {
console.log(value)
};
这与前面的例子工作方式相同,但有一个重要的区别:函数的作用域是该函数的局部,而不是周围的代码。这意味着“this”的值将是undefined。这里有一个例子可以说明这种差异:
let globalScope = () => console.log(this);
globalScope();
//Displays: Window...
let localScope = function(){
console.log(this);
};
localScope();
//Displays: undefined
这种差异很微妙,但很重要。在大多数情况下,我建议您使用粗箭头来创建函数表达式,因为函数内部的代码与函数外部的代码共享相同的范围通常会更方便。但是在一些罕见的情况下,将函数的作用域与周围的作用域隔离开来是很重要的,当我们遇到这些情况时,我会介绍这些情况。
注意如果你正在使用 ES5,你需要通过添加“use strict”作为函数的第一行来强制函数表达式使用局部范围。
var localScope = function(){
"use strict";
console.log(this);
};
use strict语句告诉 JavaScript 编译器使用 ES5 的严格模式,除了其他特性之外,它还将函数锁定在局部范围内。
现在你已经掌握了变量和函数的窍门,让我们看看如何使用数组来存储和操作它们。
对数组越来越着迷
作为游戏设计者,你最常做的事情之一就是循环遍历数组。下面的代码看起来熟悉吗?
let planets = ["jupiter", "venus", "saturn", "mars"];
for (let i = 0; i < planets.length; i++) {
planet = planets[i];
console.log(planet);
}
它在控制台中显示每个星球的名称:
jupiter
venus
saturn
mars
这是一段经典的代码,在 ES6 中得到了很好的更新。因为i循环索引计数器是使用let定义的,所以它对于for循环是局部的。这意味着您可以在任意多的其他循环中重用变量名i,并且它们的值不会冲突。
我们亲爱的老循环在 JavaScript 中仍然有它的位置,通常它仍然是这项工作的最佳工具。但是在 ES6(和 ES5)中,你有了一些方便的新方法来循环数组,这有助于使你的代码可读性更好。
注意 for循环往往会被像 V8 和 SpiderMonkey 这样的 JavaScript 引擎高度优化,因此对于每帧可能循环数千个对象的游戏来说,它们仍然是最安全的选择。在这本书里,我将强调什么时候使用一个新的循环方法是安全的,什么时候你应该用一个for循环来保证安全。如果你对你正在使用的平台上不同循环方法之间的相对性能差异感兴趣,请访问jsperf.com并运行一些测试用例。
使用forEach
ES5 引入了一个名为forEach的数组函数,专门用于遍历数组。它使得循环索引计数器的使用成为可选的。它还调用一个回调函数 来完成它的工作。(您将在前面看到回调函数是如何工作的。)这意味着如果需要,您可以重用相同的代码来转换其他数组。下面介绍如何使用forEach显示四颗行星的名称:
let planets = ["jupiter", "venus", "saturn", "mars"];
planets.forEach(displayElements);
function displayElements(element) {
console.log(element);
}
这样做的结果与我们的第一个例子完全相同:控制台按顺序显示每个行星的名称。
你可以看到forEach的参数是名为displayElements的函数:
planets.forEach(displayElements);
这意味着数组应该将它的四个元素发送给displayElements函数,一次一个。displayElements是所谓的回调函数。这是一个被另一个函数“回调”去做额外工作的函数。就好像forEach在说,“嘿!displayElements,快来帮我显示这些星球名称!”
displayElements函数有一个参数,代表它正在处理的当前元素:
function displayElements(element) {
console.log(element);
}
循环第一次运行时,element的值将是jupiter。第二次,将是venus,以此类推。
数组中有多少元素,循环就运行多少次。你不需要像i这样的循环计数器变量来跟踪它。
另一个优点是,如果您愿意,可以在另一个数组中重用displayElements函数。下面是如何使用它来显示第二个数组mountains的内容:
let planets = ["jupiter", "venus", "saturn", "mars"];
let mountains = ["everest", "k2", "kanchanjunga", "lhotse"];
planets.forEach(displayElements);
mountains.forEach(displayElements);
function displayElements(element) {
console.log(element);
}
这将显示所有的行星和山脉。我们只需要编写一次displayElements函数,它可以完美地用于两个数组。
索引计数器变量呢,我们亲爱的老朋友,i?我们可以通过在回调函数中使用第二个可选参数来访问它。以下是如何:
let planets = ["jupiter", "venus", "saturn", "mars"];
planets.forEach(displayElements);
function displayElements(element, i) {
console.log(i + ": " + element);
}
结果如下:
0: jupiter
1: venus
2: saturn
3: mars
另外,我们将在前面看到的其他数组方法也允许您在回调函数中使用第三个可选参数。它是对数组本身的引用。这里有一个例子:
function displayElements (element, i, array) {
console.log(array.toString());
}
这将显示调用数组,在本例中为:
jupiter, venus, saturn, mars
在大多数情况下,您可能只需要使用一次循环,而不需要将forEach方法与回调函数分开。在这种情况下,您可以将forEach与匿名回调函数一起使用,就像这样:
let planets = ["jupiter", "venus", "saturn", "mars"];
planets.forEach((planet) => {
console.log(planet);
});
之所以称之为匿名是因为完成这项工作的箭头函数没有名字。应用您刚刚学到的关于格式化 ES6 函数的知识,您可以非常简洁易读地编写整个内容,如下所示:
planets.forEach(planet => console.log(planet));
当你使用数组时,这个简单的格式将取代大多数的for循环。
使用for of循环
作为forEach的替代,你可以使用一个for of循环。它让您可以轻松地遍历数组的值,就像这样:
for (let planet of planets) {
console.log(planet);
}
//Displays: "jupiter", "venus", "saturn", "mars"
一个for of循环可以用来创建一个数组理解。这个特性允许您通过动态处理另一个数组来创建一个数组。以下是如何将一系列数字处理成另一个存储这些数字的平方的数组的示例:
let numbers = [1, 2, 3, 4, 5];
let squared = [for (x of numbers) x * x];
console.log(squared);
//Displays: [1, 4, 9, 16, 25]
请注意,您不需要使用循环的花括号。数组理解提供了一种方便简洁的语法来编写数学表达式。
遍历对象
JavaScript 有许多方法,使得遍历对象就像遍历数组一样简单。如果你想找到一个对象的所有属性,你可以使用一个叫做Object.keys()的方法。它以字符串形式返回所有对象属性的数组。(对象的“键”是它的属性名。)下面是使用方法:
Object.keys(anyObject);
这段代码表示一个包含所有anyObject的属性名的字符串数组。
注意 Object.keys将只返回一个对象的可枚举属性。可枚举属性就是你可以在对象中看到的任何普通属性。而这些通常是你唯一关心的。但是一个对象也可以有隐藏的属性,这叫做不可数。Object.keys不会显示它们,如果你需要知道如何使一个属性不可数,你将在本章的后面学习。如果您需要查看无法计数的属性,请使用Object.getOwnPropertyNames而不是Object.keys
这里有一个如何使用对象键的实际例子。想象你正在制作一个冒险游戏,使用房间里的物品。您的房间对象保持游戏中房间的状态,以及房间包含的内容:
let room = {
door: "open",
light: "on",
contents: ["carpet", "mouse", "katana"]
};
您可以看到room有三个属性。您可以使用以下语法,以字符串数组的形式查看这些属性:
console.log(Object.keys(room));
这显示了以下内容:
["door", "light", "contents"]
这很有趣,但还没用。Object.keys与forEach结合使用时变得非常有用:
Object.keys(room).forEach(key => {
let value = room[key];
console.log("key: " + key + ", value: " + value);
});
该代码将显示以下内容:
key: door, value: open
key: light, value: on
key: contents, value: carpet,mouse,katana
您可以通过key访问属性名,通过value访问属性值。现在,将你需要的任何游戏逻辑应用到房间、它的状态或它的内容将变得非常容易。
注意记住,如果你需要检查一个对象是否包含一个属性,使用hasOwnProperty(),就像这样:
if(room.hasOwnProperty("light")) { ... }
如果您愿意,您可以使用一个for of循环来做同样的事情:
let roomProperties = Object.keys(room);
for (let key of roomProperties) {
let value = room[key];
console.log("key: " + key + ", value: " + value);
}
输出与第一个示例完全相同。
但是有另一种方法可以做到这一点!我们可以使用一个很好的老式for in循环,它从版本 1 开始就是 JavaScript 的一部分。下面是如何使用它来生成与前面的示例相同的输出:
for(let key in room) {
let value = room[key];
console.log("key: " + key + ", value: " + value);
}
如果使用for in循环,有两件重要的事情需要注意。首先,它不会以任何特定的顺序遍历对象的键。所以如果订单对你很重要,要意识到你不能依赖它。第二,它将遍历同时属于对象和它的原型的关键点(参见本章后面的“制作对象”了解更多关于对象原型的信息)。)如果你需要保证只有对象自己的属性被访问,你必须在一个额外的if语句中用对象的hasOwnProperty方法来测试。
for(let key in room) {
if (room.hasOwnProperty(key) {
let value = room[key];
console.log("key: " + key + ", value: " + value);
}
}
这两个特性都不是问题,这取决于你要遍历的对象和你想要实现的目标。但如果你想稳妥一点的话,Object.keys和forEach的组合稍微靠谱一点。
仅循环一些数组元素
你可能只想循环遍历一个数组,直到找到你要找的一个元素。当你找到它时,循环应该停止进一步检查。你可以用一个叫做some的方法来做这件事。some方法将检查数组中的所有元素,直到回调函数返回true。true一回,就退出。
这里有一个数组列出了五种乐器。
let instruments = ["guitar", "piano", "tabla", "ocarina", "tabla"];
你看到tabla被列了两次吗?这将有助于说明some方法是如何工作的。现在让我们用这个方法来看看它是否能在这个数组中找到tabla:
instruments.some(find);
function find(instrument) {
if (instrument === "tabla") {
console.log("Tabla found!");
return true;
} else {
console.log("No tabla found...");
return false;
}
}
运行时,some循环遍历数组,当找到第一个 tabla 时退出。它退出是因为函数返回了true。下面是这段代码将在控制台中显示的内容:
No tabla found...
No tabla found...
Tabla found!
它一得到第一个true值就停止,不再继续检查。它找到第一个 tabla 元素,但在找到第二个元素之前退出。这与使用带有break语句的for循环达到了相同的效果。
您可以将some用于匿名回调,就像您可以使用forEach一样。另外,false条件是可选的。这意味着您可以通过以下方式使所有代码更加简洁:
let instruments = ["guitar", "piano", "tabla", "ocarina", "tabla"];
let found = instruments.some(instrument => {
if (instrument === "tabla") {
return true;
}
});
if (found) {
console.log("tabla found!");
}
或者,你可以写得更简洁:
let found = instruments.some(instrument => instrument === "tabla");
if (found) console.log("tabla found!");
如果您想知道数组中的任何元素是否通过了某个测试,那么some方法会非常有用。例如,您可以使用它来检查数组中的值是否大于 100:
let numbers = [11, 43, 9, 112, 64, 15];
let tooBig = numbers.some((number) => {
//Return true if a number is greater than 100
return number > 100;
});
console.log(tooBig);
该代码将在控制台中显示true。这是一个更紧凑的版本,可以做同样的事情:
let tooBig = numbers.some(number => number > 100);
因为循环只包含一个参数和一个语句,所以可以省去括号和花括号。另外,return是隐含的,所以你也可以省略它。
还有一个数组方法,叫做every,那是some的正逆;当函数返回false时,它退出循环。如果你认为放弃一个false值更符合你正在测试的逻辑,那么使用every而不是some。
注意记住,如果你曾经只需要检查一个数组是否包含一个元素,使用indexOf,这样:
if (instruments.indexOf("tabla") !== -1) {//Tabla found!}
或者你可以使用findIndex,这你将在下一节中了解。
查找数组元素
数组的some方法实际上是 ES5 的一个特性,所以它成为 JavaScript 的一部分已经有一段时间了。ES6 有一个叫做find的新数组方法,它只查找数组中的特定元素。它使用与some相同的语法,但是它返回元素的完整值,而不仅仅是true或false。下面是如何使用find:
let instruments = ["guitar", "piano", "tabla", "ocarina", "tabla"];
let found = instruments.find(x => x === "tabla");
console.log(found);
//Displays: tabla
就像some,find方法退出并在第一个匹配条件的元素上返回true。如果条件不能满足,find返回undefined。
如果你需要知道一个元素的数组索引号,使用findIndex:
let index = instruments.findIndex(x => x === "tabla");
console.log(index);
//Displays: 2
这是两个有用的新方法,可以让使用数组变得更加有趣。但是还有很多!
将旧数组映射到新数组
有时,根据另一个数组中的元素创建一个新数组是很有用的。JavaScript 让你用一个叫做map的方法来做到这一点。下面的例子展示了如何使用map来创建一个新的、基于现有数组的改进的单词数组。
let words = ["fun", "boring", "exciting"];
let betterWords = words.map(improveGrammar);
function improveGrammar(word) {
return word + "ish";
}
console.log(betterWords);
这将显示以下内容:
funish, boringish, excitingish
map方法使用words数组创建一个名为betterWords的新数组,如下所示:
["funish", "boringish", "excitingish"]
大幅度提高,我相信你可以看到!map方法使用与some方法相同的语法,因此您可以使用在上一节中看到的任何语法排列。这里有一个使用map的超紧凑方法,可以达到与前面代码相同的效果:
let betterWords = words.map(x => x + "ish");
平稳!
使用map方法并不是获得这个结果的唯一方法。您可以在数组理解中使用for of循环做同样的事情:
let betterWords = [for (word of words) word + "ish"];
结果完全一样。区别纯粹是风格上的,所以使用你喜欢的风格。
从数组中过滤元素
如果你想删除数组中的某些元素,使用filter方法。例如,您可能有一个数字列表,并希望将所有大于 100 的数字复制到一个新数组中。您可以使用filter方法来实现,如下所示:
let numbers = [11, 43, 9, 112, 64, 312, 92];
let bigNumbers = numbers.filter(findBigNumbers);
function findBigNumbers (number) {
return number > 100;
}
console.log(bigNumbers);
现在您将拥有一个名为bigNumbers的新数组,如下所示:
[112, 312]
下面是做同样事情的 array comprehension 版本:
bigNumbers = [for (number of numbers) if (number > 100) number];
filter方法还有另一个非常有用的用途——它可以很容易地从数组中删除元素。这类似于使用 array splice方法,但是它会产生更简单的代码。
这样做的技巧是将新的过滤后的数组赋回原始数组。下一个例子将使这一点更加清楚。以下是如何从数组中删除所有大于 100 的数字:
let numbers = [11, 43, 9, 112, 64, 312, 92];
numbers = numbers.filter(x => x < 100);
console.log(numbers);
下面是生成的numbers数组。所有大于 100 的数字都已删除:
11,43,9,64,92
您是否注意到过滤后的numbers数组是如何被复制回自身的?
numbers = numbers.filter(x => x < 100);
鬼鬼祟祟!
这是数组理解版本:
numbers = [for (number of numbers) if (number < 100) number];
与使用for循环和splice从数组中删除元素相比,使用这些技术有什么优势?您不必向后循环或手动将循环计数器变量减 1 来补偿删除的元素。但是不用担心;用老式的for循环和splice从数组中移除元素的技术仍然有它的位置,当你在第七章中学习碰撞检测时你会看到。
将数组元素缩减为单个值
reduce方法允许您获取数组中的所有元素,并将它们转换成一个值。例如,您可能在一个数组中有一些数字,并想知道它们的总数是多少。您可以这样做:
let numbers = [73, 19, 2, 144, 43, 7];
let total = numbers.reduce(addNumbersTogether);
function addNumbersTogether(a, b) {
return a + b;
}
console.log(total);
这个显示器
288
以下是精简版:
total = numbers.reduce((a, b) => a + b);
reduce方法的工作原理是遍历每个元素,并将其添加到右边的相邻元素中。在这个例子中,a是第一个元素,b是下一个元素。它对数组中的所有元素继续这样做,直到将所有元素都简化为一个值。
reduce有第二个可选参数:开始的初始值。在我们刚刚看到的例子中,如何从初始值 100 开始:
total = numbers.reduce((a, b) => a + b, 100);
total现在将是 388。
reduce有一些令人惊讶的用途。你有没有过这样的 2D 阵列,你希望你能把它展平成 1D 阵列?这很容易用reduce来完成。以下是如何:
let numbers2D = [[73, 19],[2, 144],[43, 7]];
let numbers1D = numbers2D.reduce(flattenArray);
function flattenArray(a, b) {
return a.concat(b);
}
console.log(numbers1D);
最终得到的numbers1D数组如下所示:
[73, 19, 2, 144, 43, 7]
reduce从右到左循环遍历数组。如果你需要从末尾开始遍历数组,使用reduce的姊妹方法reduceRight。
现在你已经有了很多使用数组元素的新方法。但是如果你在一个数组中有一些值,你想快速地分配给变量,该怎么办呢?
用析构函数从数组中生成变量
假设你有三个变量,想给它们初值。下面是简单的方法:
let age = 16,
height = 170,
grade = 10;
一个叫做析构的特性让你以不同的方式来做这件事。析构将数组的值解包,并自动将其复制到变量中。以下是如何:
let statistics = [16, 170, 10];
let [age, height, grade] = statistics;
现在有三个新变量:age的值是 16,height的值是 170,grade的值是 10。您可以使用模板字符串来显示这些,如下所示:
console.log(`Age: ${age} Height: ${height} Grade: ${grade}`);
这个显示器
Age: 16 Height: 170 Grade: 10
模板字符串是将文本和变量结合起来的一种方式。它取代了使用加号字符连接字符串的旧 JavaScript 方式。要创建一个模板字符串,用反勾字符 将字符串括起来,并在花括号中插入一个变量,变量前面有一个美元符号。下面是要使用的格式:
`This is a template string that displays a ${variableName}`
注意小心;反勾号字符不是单引号!您可能会发现它在键盘上与波浪号字符共享同一个键:~
模板字符串可以跨越多行,如果你愿意,也可以用表达式代替变量。
析构也可以用来非常有效地交换两个变量的值。以下是如何:
let x = 120,
y = 12;
//Swap the values:
[x, y] = [y, x];
console.log(`x: ${x} y: ${y}`);
//Displays: x: 12 y: 120
在以前版本的 JavaScript 中,如果没有第三个临时变量,你就无法做到这一点。
析构也适用于对象。下面是如何将对象的属性值捕获为单个变量。
let position = {x: 120, y: 12};
let {x, y} = position;
您现在有两个新变量,称为x和y。x的值是 120,y的值是 12。你可以用console.log来测试这个:
console.log(`X: ${x}`);
console.log(`Y: ${y}`);
这个显示器
X: 120
Y: 12
您可以看到,析构是从数组和对象中提取值的一种便捷方式。ES6 也有一些使用函数的便利特性。
函数参数
ES6 为您提供了三种新的灵活的函数参数赋值方式。您可以在参数中给函数默认的值,如下所示:
function display(name = "rose", color = "red") {
console.log(`Name: ${name}, Color: ${color}`);
}
display();
//Displays: Name: rose, Color: red
display("balloon");
//Displays: Name: balloon, Color: red
display("computer", "beige");
//Displays: Name: computer, Color: beige
或者,您可以向函数发送任意数量的参数,然后告诉函数将这些参数作为数组读取。只需在函数的一个参数前添加三个点...(称为扩展运算符)。该参数将包含数组形式的参数。用一个工作示例来理解这一点是最容易的:
function displayColors(...colorArray) {
for (let color of colorArray) console.log(`Color: ${color}`);
}
displayColors("red", "green", "blue")
这个显示器
Color: red
Color: green
Color: blue
你也可以颠倒这个过程。您可以使用数组作为参数,并告诉函数将数组值转换为单独的参数:
function displayNumbers(x, y, z) {
console.log(`X: ${x}, Y: ${y} Z: ${z}`);
}
displayNumbers(...[12, 30, 10]);
//Displays: X: 12, Y: 30 Z: 10
这三种将信息放入函数的方法给了你很大的灵活性,你可以用几十种不同的方法将它们结合起来,以摆脱一些棘手的问题。但是让我们来看看另一种更低技术含量的初始化函数的方法,这可能是你更喜欢的。
用配置对象初始化功能
在 JavaScript 中,可以用任何值初始化函数:数字、字符串、其他函数或对象文字。如果用单个对象文字初始化函数,可以使用对象的属性值来配置函数。这被称为配置对象,尽管它不是 JavaScript 的新特性,但它是一种广泛使用的初始化函数的方式,了解它很有用。
让我们暂时回到基本问题上来。下面是一个简单函数的例子,它显示三个参数:精灵的名称、它的 x 位置和它的 y 位置。
display("spaceship", 312, 112);
function display(name, x, y) {
console.log(`name: ${name}, x: ${x}, y: ${y}`);
}
下面是它显示的内容:
name: spaceship, x: 312, y: 112
没什么特别的!您可能已经编写了数百个这样函数。它工作得很好,但是有一个小问题。不是技术问题,是人为问题:可读性。当你在一个大项目中工作时,可能有几十个类似的函数,你可能会完全忘记这个函数应该做什么。
display("spaceship", 312, 112);
啊?什么是“宇宙飞船”,那两个数字指的是什么?光看函数调用是没办法分辨的。你必须找到函数定义来提醒自己那些参数指的是什么,以及你必须包含它们的顺序。
您可以使用一种非常有用的技术来解决这两个问题,这种技术叫做函数配置对象。没有向一个函数发送多个参数,而是只发送一个对象。这个对象可以包含初始化函数所需的任意多的属性。让我们用一个配置对象重写前面的代码,这样您就可以看到它是如何工作的。
在函数的参数中,提供一个对象:
displayClearly({name: "spaceship", x: 312, y: 112});
你看到物体周围的花括号了吗?对象有三个属性:name、x和y。这些属性的值是您希望发送给函数定义以完成其工作的值。这使得代码突然变得更加易读,因为这些值指的是什么很清楚。
然后,您需要创建一个接受单个对象作为参数的函数。本例中的参数名称是config,但是您可以给它取任何您喜欢的名称:
function displayClearly(config) {
console.log(`name: ${config.name}, x: ${config.x}, y: ${config.y}`);
}
现在,函数不再使用参数值,而是使用config对象的属性。结果与前面的例子完全相同,但是代码可读性更好。此外,您不再局限于以任何严格的顺序向函数提供三个参数。您可以使用任意多或任意少的属性。如果您遗漏了任何它可能依赖的值,您也可以设置该函数来分配默认值。
您将在本书中看到许多如何使用配置对象的例子。到目前为止,本章我们已经介绍了变量、数组和函数,所以现在让我们仔细看看 JavaScript 的对象文字。
Getters 和 setter
JavaScript 对象文字具有称为getter和setter的特性,当对象的属性值改变时,这些特性可以让您很好地控制会发生什么。一个简单的例子将告诉你它们是如何工作的。
假设您有一个 JavaScript cookie jar。你希望能够从罐子里取出饼干,并在罐子空了的时候把它装满。为此,您可以创建一个简单的 cookie jar 对象文字:
let jar = {
cookies: 10
};
现在,您可以使用以下代码来更改 jar 中 cookies 的数量:
jar.cookies = 8;
jar.cookies = 2;
但是有一个小问题。罐子不应该能够容纳超过十个或少于零个的饼干。在我们当前的模型中,没有什么可以阻止您用 100 个 cookie 填充它,或者分配一个负数的 cookie:
jar.cookies = 100;
jar.cookies = -50;
我们需要能够将 cookies 的范围保持在 0 到 10 之间。我们将分两步解决这个问题。
第一步是使用中介方法、调用和设置来改变 cookies 的数量。它们是这样工作的:
let jar = {
numberOfCookies: 10,
get cookies() {
return this.numberOfCookies;
},
set cookies(value) {
this.numberOfCookies = value;
}
};
注意注意,与普通的对象属性不同,对象方法不使用冒号(:)来赋值。
我们现在有了一个名为cookies的get方法和一个名为cookies的set方法。如果你想知道罐子里有多少饼干,使用get方法,就像这样:
jar.cookies;
这将给出numberOfCookies的值,即 10。
看起来您正在读取jar对象上一个名为cookies的属性,但是您没有。你实际上是在调用名为cookies的get方法。这是一个外观和行为都像属性的方法。
你可以像这样改变罐子里饼干的数量:
jar.cookies = 7;
同样,看起来你在改变一个属性值,但是,同样,你没有。你调用的set方法叫做cookies 。
到目前为止,这很有趣,但是这个例子似乎没什么用。该技术变得有用的地方在于,我们现在有了一种方便的方法来拦截读取或更改属性值的请求。这意味着我们可以在返回或更改值之前检查请求是否有效。
下一个例子将阐明这一点。请记住,我们的罐子容纳不下 10 块以上的饼干。我们也不应该被允许分配负数量的 cookies。现在我们已经设置了get和set方法,这些约束很容易实现。这里突出显示了新代码:
let jar = {
numberOfCookies: 10,
get cookies() {
return this.numberOfCookies;
},
set cookies(value) {
if (value >= 0 && value <= 10) {
this.numberOfCookies = value;
} else {
throw new Error("Please use a number between 0 and 10");
}
}
};
现在,如果您将cookies设置为 0 到 10 之间的任何数字,它都会正常工作:
jar.cookies = 3;
但是,如果您指定一个小于零或大于 10 的数字,就像这两个语句中的任何一个一样:
jar.cookies = 11;
jar.cookies = -2;
您将在控制台中看到以下错误:
Please use a number between 0 and 10
正如您在前面的代码中看到的,在将值分配给numberOfCookies之前,set方法会验证该值。
Getters 和 setters 是使用方法的一种非常方便的方式,这些方法的外观和行为就像普通属性一样。它们可以给你的代码带来强大的表现力。但是,请谨慎使用它们!在这个例子中,它们有一个集中的、有限的用途:它们只以一种可预测的方式影响一个属性numberOfCookies。但是没有什么可以阻止您创建一个 getter 或 setter,以奇特的方式同时改变一个对象的多个属性。如果jar.cookies不只是换了numberOfCookies,还更新了一个分数,换了一些雪碧颜色,还在网上给你订了披萨,会怎么样?如果您不小心使用 getters 和 setters,它们会产生复杂的效果,波及到您的代码,并导致不可预测的、可能很难跟踪的错误。
使用Object.defineProperty和防止改变属性
您可能已经注意到上一个例子中的一个小问题。我们可以通过使用 getter 和 setter 来限制 jar 中 cookies 的数量,但是如何阻止我们像这样简单地直接更改numberOfCookies?
jar.numberOfCookies = 100;
绝对没有!如果你正在编写一个复杂的游戏,在这个游戏中,给一个像这样的属性分配一个无效的数字可能会导致你的游戏冻结或崩溃,你可能需要确保这种情况不会发生。ES5 有一个叫做Object.defineProperty的方法,它防止属性值像这样被直接改变。
使用Object.defineProperty可以创建两种属性。第一个称为数据描述符。这只是一个普通的属性,你可以把它设置成可写或不可写。下面是如何使用它在我们的jar对象中创建一个名为cookies的不可写属性:
//1\. Create a jar object
let jar = {};
//2\. Use Object.defineProperty to create a property called
//cookies in the jar object. Set its value and other properties
//that determine how it can be viewed or changed
Object.defineProperty(jar, "cookies", {
value: 10,
writeable: false,
enumerable: true,
configurable: true
});
这将在jar对象中创建一个cookies属性。(您看到它是如何使用配置对象做到这一点的吗?)您可以这样访问它:
jar.cookies
它的value是 10。但是,因为它的writeable属性被设置为false,所以您不能更改它。如果您尝试更改它,您会得到一条错误消息:
jar.cookies = 120;
Uncaught TypeError: Cannot assign to read only property 'cookies' of #<Object>
jar.cookies还有另外两个属性:enumerable和configurable。如果enumerable是true,这意味着如果您使用Object.keys来获取 jar 属性的数组,那么cookies属性将会出现。如果configurable是true,说明你以后可以更改所有这些属性;他们不会被永久封锁。将configurable设置为true还可以让你从对象中删除属性,如果你认为有必要的话。writeable、enumerable和configurable属性都默认为false,除非您明确将其设置为true。
这个能力真的很强大。如果你的游戏依赖于一个不应该被改变、删除甚至访问的常量值,你现在有办法确保这一点。
Object.defineProperty允许您创建另一种属性,称为访问器描述符 。这是一个使用 getter 和 setter 来更改其值的属性。下面是如何在jar对象中创建一个cookies属性,其值可以更改:
//1\. Create a jar object
let jar = {};
//2\. Use Object.defineProperty to create a property called
//cookies in the jar object. Create a getter and setter so
//that its value can be changed
Object.defineProperty(jar, "cookies", {
get() {
return this.value;
},
set(newValue) {
this.value = newValue;
},
enumerable: true,
configurable: true
});
//3\. Give the new property an initial value
jar.cookies = 10;
现在,jar有了一个我们可以更改的cookies属性。这是一个简单的 getter 和 setter,可以读取和写入你给它的任何值。为了使它更有用,让我们将您可以赋予它的值限制在 0 到 10 之间。以下是如何:
Object.defineProperty(jar, "cookies", {
get() {
return this.value;
},
set(newValue) {
if (newValue >= 0 && newValue <= 10) {
this.value = newValue;
} else {
throw new Error("Please use a number between 0 and 10");
}
},
enumerable: true,
configurable: true
});
您现在不能分配小于 0 或大于 10 的数字。如果您尝试以下方法:
jar.cookies = 25;
您将收到以下错误消息:
Please use a number between 0 and 10
使用Object.defineProperty创建这样的 getter 和 setter 的好处在于,我们只有一个属性可以访问和更改:jar.cookies。试图改变jar.numberOfCookies是不可能绕过限制的——那个属性不存在。
您可以使用一个叫做Object.defineProperties的相关方法在一个对象上同时定义许多属性。这里有一个具有两个属性的jar对象:cookies和lid:
var jar = {};
Object.defineProperties(jar, {
"cookies": {
value: 10,
writable: true,
enumerable: true,
configurable: true
},
"lid": {
value: "closed",
writable: false,
enumerable: true,
configurable: true
}
});
只需根据您的需要对多个属性采用这种格式。
关于Object.defineProperty,还有两件事你应该知道:
- 您不能将数据描述符样式(第一个示例)与访问器描述符样式(第二个示例)混合使用。用
Object.defineProperty创建的属性必须是其中之一。 - 您可以随时使用
Object.defineProperty重新定义现有对象的属性。Object.defineProperty是 JavaScript 的一个相当高级的特性,您可能很少在日常游戏开发中使用。然而,如果你正在构建一个 JavaScript 框架或工具集,比如游戏引擎,它是必不可少的,你会在本书的许多例子中看到它的使用。
对象文字是 JavaScript 程序中最重要的构件之一。但是在我们继续深入之前,让我们后退一步,更深入地了解一下对象文字是如何工作的。
创建对象
尽管看起来很简单,JavaScript 对象隐藏着一些令人惊讶的复杂性。在这一部分,你将会学到一些新的有趣的东西,这些东西将会帮助你在游戏中更好地使用它们。我们将进行一次从对象到函数闭包的大旅行,并以对类的介绍结束。
但首先,让我们回到基础。关于物体,我们已经知道了什么?
在 JavaScript 中,你可以像下面这样创建一个对象:
let bird = {
legs: 2,
eyes: 2,
speak() {
console.log("Chirp!");
}
};
注意如果您使用过以前版本的 JavaScript,请注意 ES6 为您提供了一种简化的方式来编写对象方法。您不再需要冒号和function关键字。
然后使用点符号来访问对象的属性和方法,如下所示:
bird.eyes
这等于 2。
下面是让小鸟说话的方法:
bird.speak();
这将显示
Chirp!
如果您决定向 bird 添加一个新属性,您可以在任何时候使用以下语法来完成此操作:
bird.canFly = true;
这只鸟现在有了一个新的canFly属性,它被动态地添加到这个属性中。
您还可以使用数组文字符号来访问或更改对象的属性,如下所示:
bird["eyes"]
这个符号和使用bird.eyes是一样的,它给你相同的值:2。数组文字符号强调了这样一个事实,即对象实际上只是使用字符串而不是索引号来访问元素的数组。(这种类型的数组被称为关联。)可以存储在数组中的任何信息,也可以存储在对象中,方便的是可以用名称访问每一位信息。
如果您想通过使用函数或内部循环在对象上创建属性,请使用数组文字表示法。例如,这里有一个函数可以让您在名为wings的bird对象上添加一个新属性:
makeNewProperty(bird, "wings", 2);
function makeNewProperty(object, propertyName, value) {
object[propertyName] = value;
}
bird现在有一个值为 2 的wings属性,您可以像这样访问或更改它:
bird.wings
您将很快看到我们将如何使用这个特性作为构建复杂对象的基础。
如果您已经创建了一个喜欢的对象,您可以使用它来制作相似的相关对象。让我们看看下一步该怎么做。
从其他对象制作对象
创建一个通用对象作为模板,然后基于该模板创建更多相关对象,这通常非常有用。这使您可以将默认属性分配给模板,并且从该模板创建的所有对象都可以使用这些属性。在 JavaScript 中,有两种重要的方法可以将对象创建为模板:
- 组合:您可以通过组合其他对象的属性来创建新对象。
- 类:这些是返回新的定制对象的函数。
注意你也可以使用纯原型继承和Object.create创建新对象。我建议你不要使用这个功能,除非你真的很小心你在做什么。问题是,通常很难知道哪些属性属于对象,哪些属性属于其原型。关于使用Object.create 制作对象的详细工作示例和一些需要小心的陷阱,请参见本章的源文件。
先说作文吧!
构图
那么构图是如何工作的呢?只需将一个对象包装在函数中,并让函数返回该对象。放轻松!
这里有一个简单的例子。假设你想创造不同的动物,比如猫、鸟和老鼠。这些动物都有相似的特性:腿、眼睛和发声能力。所以从一个一般的动物对象作为模板开始,然后使用该模板作为基础来创建特定的动物是有意义的。
这里告诉你如何用构图来做到这一点。创建一个名为animal的函数。animal函数返回一个对象,该对象包含许多不同种类的动物可以使用的属性。
function animal() {
return {
legs: 4,
eyes: 2,
say: "Huh?",
speak () {
console.log(this.say);
}
};
}
你现在可以调用animal来制作特定的动物。下面是如何用它来做一只猫:
var cat = animal();
cat对象现在拥有了animal返回的对象的所有属性和方法。你可以让猫说话来测试这一点:
cat.speak();
这个显示器
Huh?
没错,但是猫一般不会这么说。让我们自定义cat的say属性,然后再次调用speak方法:
cat.say = "Meow!";
cat.speak();
以下是您将在控制台中看到的内容:
Meow!
有效!我们定制了say属性,但是我们使用了原始对象现有的speak方法:
speak() {
console.log(this.say);
}
如果你正在制作许多具有相似特征的物体,比如动物,这真的很有用。这意味着您不必为您创建的每个新对象一遍又一遍地创建相同的属性。您只需要创建一个模板对象,并使用其内置的默认属性。
例如,下面是制作一只鸟并定制它是多么容易。
let bird = animal();
bird.say = "Chirp!";
bird.speak();
这个显示器
Chirp!
bird拥有与原始模板对象相同的speak方法,所以它可以使用那个speak方法,而不必从头开始创建自己的方法。然而,这只鸟自己的say属性已经被定制。
通过使用一个函数返回一个对象,无论何时调用这个函数,我们都在创建一个全新的对象。我们还利用了一个强大的 JavaScript 特性,叫做函数闭包,它有各种各样有用的和意想不到的好处。在下一节中,您将确切地了解什么是闭包,以及它为什么有用。
理解关闭
让我们来看一个稍微复杂一点的例子,在这个例子中,我们将使用一系列随机单词让动物说话。这将展示如何在创建每个新对象之前运行一些初始化任务,并帮助您理解什么是函数闭包。
在下一个例子中,我们将让我们的动物对象说出一个随机的单词。选择随机数在游戏中是如此常见的任务,以至于使用一个助手函数来完成它非常方便。这里有一个名为random的有用函数,它将返回最小和最大范围内的随机整数:
function random(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
要使用它,只需用您希望它从中选择的最小和最大随机值来调用函数。下面是如何创建一个介于 1 和 10 之间的随机数:
random(1, 10)
现在让我们使用这个函数创建一个动物,当它的speak方法被调用时,它会随机说出一个单词:
function animal() {
//Declare the variables we'll use in this function
let newObject, words;
//A helper function to return a random integer within a minimum and maximum range
function random(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
//The animal's vocabulary
words = ["Food!", "Sleep!", "Video games!"];
//A `speak` function that chooses random words
function speak() {
let say = words[random(0, 2)];
console.log(say);
}
//Create a `newObject` and add the `speak` function to it
newObject= {};
newObject.speak = speak;
//Return the `newObject`
return newObject;
}
现在让我们用这段代码创建一个动物并让它说话:
let cat = animal();
cat.speak();
这随机显示“食物!”,“睡吧!”或者“电子游戏!”完美的人生!
这里有一些真正有趣的事情正在发生。cat对象只有一个属性:speak。这是因为当newObject对象被创建时,唯一附加到它的属性是speak函数:
newObject = {};
newObject.speak = speak;
return newObject;
但是每次调用speak方法时,它都会在animal函数中悄悄地使用另外两个东西:
words
random
cat对象不能访问words数组或random函数,也不能改变它们。它只知道自己的speak方法。speak 方法是 public ,其他所有隐藏变量和函数都是 private 。
代码以这种方式工作是因为 JavaScript 函数的特性叫做闭包。这意味着您可以使用函数来创建复杂的对象,并有选择地决定哪些功能应该是可公开访问的。
这种编写 JavaScript 代码的一般风格有时被称为模块模式 。你现在开始理解作文了吗?
配置对象
在创建对象时,您可能希望用自定义属性或值来初始化对象。可以通过用配置对象初始化animal函数来实现。然后,在返回新对象之前,将配置对象中的所有属性复制到新对象中。返回的对象将包含模板的所有默认属性,以及您在配置对象中设置的任何属性或值。
这就是我们的想法,但是用一个工作示例来理解就容易多了。假设我们想要制作一只猫,并将其say属性设置为“喵!”我们还想给它一个名为fur的新自定义属性。当您调用animal时,添加这些属性作为配置对象:
let cat = animal({
say: "Meow",
fur: "black"
});
然后,animal函数将config对象作为参数。它创建了newObject模板,然后将config对象的属性复制到其上:
function animal(config) {
//Create the `newObject` template
var newObject = {
legs: 4,
eyes: 2,
say: "Huh?",
speak() {
console.log(this.say);
}
};
//Copy the config object's properties onto the `newObject`.
//They will override the default properties
Object.assign(newObject, config);
//Return the `newObject`
return newObject;
}
config对象包含我们希望新对象拥有的所有属性名和值。创建了newObject之后,代码使用Object.assign将config对象的所有属性复制到newObject中:
Object.assign(newObject, config);
记住cat的config对象包含两个属性:fur和say. fur作为新属性添加到newObject中,say替换newObject自身的say属性。
如果我们让猫说话,现在会发生什么?
cat.speak();
控制台显示:
Meow!
“喵!”是我们在config对象中提供的自定义say属性。我们可以在Object.keys()的帮助下确认猫的所有属性,就像这样:
console.log(Object.keys(cat))
这个显示器
["legs", "eyes", "say", "speak", "fur"]
您可以看到它包括所有默认属性、定制的say属性和猫的唯一fur属性。
我们实际上所做的是将两个对象config和newObject组合成一个新的对象:cat。我们可以把这个想法更进一步。
混合和匹配对象
当你开始掌握像这样组成对象的窍门时,你很快就会意识到你可以通过组合其他对象的属性来创建新的对象。在下一个例子中,让我们使用Object.assign通过结合机器人和人类来创造一个电子人。你不是一直想这么做吗?
首先,让我们创建两个返回机器人和人类对象的函数:
function robot() {
return {
skill: "vaporizing death ray"
};
}
function human() {
return {
hobby: "bake cookies"
};
}
接下来,我们将创建一个cyborg函数,将这两个对象合并成一个新对象:
function cyborg() {
//Make a `newObject` and add the robot's and human's properties to it
let newObject = {};
Object.assign(newObject, robot());
Object.assign(newObject, human());
//Create a `speak` function
//`this` will refer to whatever object this function is attached to
function speak() {
console.log("I like to " + this.hobby + " using a " + this.skill);
}
//Attach the `speak` function to the `newObject`
//(Adding the function like this ensures that `this` points to `newObject`)
newObject.speak = speak;
//Return the `newObject`
return newObject;
}
现在让我们创造一个机器人,让它说话:
let zxlorb = cyborg();
zxlorb.speak();
它是这样说的:
I like to bake cookies using a vaporizing death ray
迷人!记得邀请我参加聚会。
你可以看到newObject包含了机器人的skill和人类的hobby。当它说话时,它同时使用这两个词:
function speak() {
console.log("I like to " + this.hobby + " using a " + this.skill);
}
标识符this将引用该函数所附加的任何对象。下一行将其连接到newObject:
newObject.speak = speak;
这意味着函数中的this.hobby和this.skill将被解释为newObject.hobby和newObject.skill。
这种从其他对象合成新对象的通用技术有时被称为混合模式。它有许多变种。
现在你已经知道使用一个函数来创建和返回一个对象,让我们来看一个最常见和最有用的方法。
类别
JavaScript 的一个特点是函数也是对象。为了清楚地理解这是如何工作的以及它为什么重要,让我们从一个返回对象的简单函数开始:
function animal() {
let newObject = {};
newObject.eyes = 2;
newObject.feet = 4;
return newObject;
}
let cat = animal();
console.log(cat.eyes);
//Displays: 2
这应该看起来很熟悉,因为它与我们在过去几页中用来创建对象的模式完全相同。
现在让我们改变这一点,使函数作为一个对象返回自己。为此,删除newObject,去掉return语句,并将对象属性附加到this:
function Animal() {
this.eyes = 2;
this.feet = 4;
}
this指“本功能对象”还要注意Animal中的 A 是大写的。这是一个命名约定,它告诉您函数将自身作为一个对象返回。
您现在可以使用new关键字来创建以Animal为原型的新对象。以下是如何:
let bird = new Animal();
console.log(bird.eyes);
//Displays: 2
bird.legs = 2;
console.log(bird.legs);
//Displays: 2
关键字new触发函数将自身作为对象返回。
如果您想给Animal一个方法,将该方法附加到一个名为prototype的特殊属性,如下所示:
Animal.prototype.speak = () => {
console.log("Huh?");
};
任何由Animal生成的对象现在都可以使用speak方法,语法如下:
bird.speak();
//Displays: "Huh?"
创建这样的对象非常有用,但也有点笨拙。为了使整个过程更加简化,您可以使用 JavaScript ES6 类。类只是一个将自身作为对象返回的函数,但是包含了一大堆额外的便利。理解一个类如何工作的最好方法是看它是如何工作的。这里有一个名为Animal的类,它只是做你认为它应该做的事情:
class Animal {
constructor() {
this.legs = 4;
this.eyes = 2;
this.say = "Huh?"
}
speak() {
console.log(this.say);
}
}
创建一个Animal类的新实例,如下所示:
let cat = new Animal();
你现在有了一个新的cat对象,它的工作方式就像本章前面所有的猫一样。您可以像处理任何其他对象一样使用、更改或添加方法和属性。
你可以看到Animal类有一个特殊的方法叫做constructor。每当你从类中创建新对象时,你放在constructor方法中的任何代码都会自动运行。您可以向它传递您的新对象初始化自己可能需要的任何自定义参数。下面是一个简化的Animal类,它允许您在创建新对象时自定义legs属性:
class Animal {
constructor(legs) {
this.legs = legs;
}
}
let bird = new Animal(2);
console.log(bird.legs);
//Displays: 2
T他bird将2作为该类的参数传递,该值由该类的constructor方法复制到legs属性中。
还可以用配置对象初始化类。这非常方便,因为您可以一次设置许多自定义属性。下面是如何使用配置对象来创建鼠标。首先,设置Animal类接受一个名为config的对象作为构造函数的参数:
class Animal {
constructor(config) {
//Set the default properties
this.legs = 4;
this.eyes = 2;
this.say = "Huh?"
//Use the `config` object's properties
Object.assign(this, config);
}
speak() {
console.log(this.say);
}
}
当构造函数运行时,它首先设置类的默认属性,然后使用Object.assign添加config对象的属性。如果config对象的属性与类的属性相同,那么将使用config属性值。如果config对象包含新的属性,它们将被添加到该类返回的对象中。
接下来,使用Animal类创建一个新对象,如下所示:
let mouse = new Animal({
say: "Squeak!",
tail: "curly"
});
请注意,该参数是一个具有两个属性的对象:say和tail。mouse对象正在定制say属性并添加一个名为tail的新属性。它不会改变eyes或legs,所以它们保持与Animal类中的默认属性相同。
为了确认这是您所期望的工作方式,让鼠标说点什么并显示它的属性:
mouse.speak();
//Displays: Squeak!
console.log(Object.keys(mouse));
//Displays: ["legs", "eyes", "say", "tail"]
多亏了类,你现在有了一个强大、简单、灵活的方法来创建新的对象。
注意记住,只要把一个类想象成一个返回对象的特殊函数。这意味着您还可以使用 mixin 模式使用一个类来组合对象,并使用闭包来隐藏您不希望程序的其他部分访问的内部变量。
遗产
类的一个方便的特性是它们使得建立一个继承模式变得容易。继承是一种建立具有一般属性的父类,然后使用该父类创建实现更多特定功能的子类的方法。如果您明智地使用继承,您可以用最少的代码获得复杂的结果。
要建立一个基本的继承链,首先创建一个通用类,它的属性和方法可以应用于许多不同类型的同类对象。这里有一个通用的Monster类,它有一些通用的类似怪物的属性:
class Monster {
constructor(hitPoints, scariness) {
this.name = "Monster";
this.hitPoints = hitPoints;
this.scariness = scariness;
}
speak() {
console.log(
`I'm a ${this.scariness}
scary ${this.name}
with ${this.hitPoints} hit points`
);
}
attack(skill) {
console.log(`The ${this.name} attacks with ${skill}!`);
}
}
这个类被称为父或超级类。你现在可以基于这个类通过扩展制造一个特定的怪物。这里有一个Dragon类,即extends中的Monster类:
class Dragon extends Monster {
constructor(hitPoints, scariness, weapon) {
//call the parent class's constructor with `super`
super(hitPoints, scariness);
this.name = "Dragon";
this.weapon = weapon;
}
breatheFire () {
//Call the parent class's `attack` method
super.attack(`flaming ${this.weapon}`);
}
}
这个Dragon类是Monster类的子。Dragon继承了Monster的所有属性,但也包括自己定制的属性。它可以用关键字super调用Monster类上的方法。现在让我们创建一个新的Dragon对象,并让它说话:
let fluffy = new Dragon(10, "somewhat", "furballs");
fluffy.speak();
这个显示器
I'm a somewhat scary Dragon with 10 hit points
你可以看到speak方法在Monster类中,但是它使用了Dragon类的属性。当我们让毛毛喷火时会发生什么?
fluffy.breatheFire();
这将显示:
The Dragon attacks with flaming furballs!
它使用fluffy的定制furball武器,以及Dragon的name和breathFire方法,然后将所有这些发送给Monster的attack方法。我故意把它弄得有点过于复杂,以帮助你了解所有这些零碎的东西是如何相互作用的。再次检查代码,看看你是否能弄清楚它们是如何组合在一起的!
继承模式有点像我们在讨论合成时看到的 mixin 模式的“邪恶孪生姐妹”。在继承和混合之间,你有一套很好的工具来从其他对象的碎片中创建各种对象。
注为什么遗传是邪恶的双胞胎?因为强大的力量伴随着巨大的危险。她会诱使你建立脆弱的依赖链,其中一个类从一个类继承,一个类继承,一个类继承,一个类继承...你明白了!不要这样做!这是一个仙女戒指,100 年后你会从里面出来,头发都没了。如果你有一个超过两层的继承链,后退一步,问问你自己,你是否有可能使用组合来达到你所需要的。路威龙向你展示了创造完全不必要的复杂性是多么的简单和有趣。使用组合构建对象通常会导致更稳定的系统。这是因为它鼓励你构建一个所有组件同时可见的系统。遗产就像一座纸牌搭建的房子,而作曲就像你卧室的地板:你所有的玩具都躺在那里。但是如果你能小心而熟练地使用继承,并且你理解可能的风险,那就去做吧。继承让你用很少的代码完成很多事情。
有了类,我们现在已经涵盖了编写 JavaScript 程序的所有最重要的组成部分。但是如何将小代码组织成大的应用程序呢?
模块
JavaScript 让你使用模块从不同部分构建复杂的游戏和应用。模块是自包含的代码,与全局范围和其他模块相隔离。如果您希望一个模块与另一个模块共享一个属性或方法,那么在您希望该模块共享的任何内容之前添加export关键字。其他模块可以import这些属性和方法,如果他们想使用它们的话。模块中未导出的任何内容都不能被任何其他模块看到或使用,这使得它实质上是该模块的私有内容。让我们看一些简单的例子来说明模块是如何工作的。
您需要知道的最重要的事情是,模块是任何带有.js扩展名的文件。没有比这更复杂的了。
注意如果你使用过 JavaScript 的早期版本,这可能会让你感到惊讶。您不再需要将您的模块代码包装在一个立即函数中以将其与全局范围隔离,并且您不需要任何特殊的语法来将文件定义为一个模块。任何普通的 JS 文件都会自动成为一个模块。真的就这么简单!
然而,你也可以在一个 HTML 文件中定义一个带有<script>标签的模块,其中type被设置为module:
<script type="module">
//This is the module
</script>
如果您想给模块命名,可以添加一个name属性:
<script type="module" name="nameOfTheModule">
如果需要,这允许您通过名称将模块导入到其他模块中。
要从 HTML 登录页面加载 JS 文件中的模块,请设置<script>标签的src属性,如下所示:
<script type="module" src="main.js">
注意 HTML5 还包括一个<module>标签来代替<script>标签。在发表时,<module>标签的规范还不是最终的,但是如果它对你可用,你应该使用它。
您还可以使用以下语法将代码块定义为 JS 文件中的模块:
module "theModuleName" {
//Write the module code here
}
只有当您希望在同一个文件中使用多个模块时,才需要这样做。然而,我建议你保持简单,每个文件只使用一个模块。
注意你会在本章源文件的modules文件夹中找到前面所有代码的工作示例。用它作为沙箱来练习玩模块。
模块基础知识
这里有一个模块的简单例子,叫做firstModule。它只是一个名为firstModule.js的普通 JS 文件,只有一行代码:
//firstModule.js
export let hello = "Hello from the firstModule!";
关键字export意味着hello变量可以被另一个模块使用。现在让我们在另一个叫做main的模块中使用这个hello变量。这个新模块是一个名为main.js的普通 JS 文件,和firstModule在同一个目录下。下面介绍如何将hello变量从firstModule转换成main。
//main.js
import {hello} from "firstModule";
console.log(hello);
这个显示器
Hello from the firstModule!
您可以看到,import关键字用于从另一个模块导入属性,使用以下语法:
import {exportedProperty} from "moduleName"
模块名与 JavaScript 文件名相同,但是没有 .js 扩展名(这暗示它是一个 JavaScript 文件。)您可以通过在名称中包含模块的文件路径来从不同的目录加载模块,如下所示:
import {exportedProperty} from "path/to/modules/moduleName"
JavaScript 的模块系统在导入和导出属性方面非常灵活,所以让我们来看看一些最有用的技术。
以不同的名称导入属性
如果您不喜欢从模块中导入的属性的名称,可以使用以下语法更改它:
import {oldName as newName} from "moduleName";
这允许您为导入的属性指定任何您喜欢的或方便的名称。它还允许您解决可能导出同名属性的两个模块之间的冲突。
例如,假设您有两个模块,firstModule和secondModule。两个模块都导出了一个同名的属性:hello。
//firstModule.js
export let hello = "Hello from the firstModule!";
//secondModule.js
export let hello = "Hello from the secondModule!";
当main模块导入这些时,它可以将secondModule的hello属性的名称改为anotherHello。
//main.js
import {hello} from "firstModule";
import {hello as anotherHello} from "secondModule";
console.log(hello);
console.log(anotherHello);
这个显示器
Hello from the firstModule!
Hello from the secondModule!
这些导入的属性是可变的,这意味着它们可以被改变(变异)。因此,您可以在导入后更改anotherHello的值,如下所示:
helloTwo = "Huh?"
模块导出和导入选项
如果您不想在每个要导出的属性前添加export,您可以批量导出您的所有属性,如下所示:
//thirdModule.js
export {color, shape};
let color = "red",
shape = "circle";
这很方便,因为它整理了您的代码,让您可以在一个地方保存所有的导出决策。把export语句放在哪里并不重要,所以我建议你把它放在模块的最前面,以便容易找到。(export语句在编译时被读取,就像function声明一样,所以它们将在模块中的其余代码运行之前被读取。)
注意您也可以更改导出的名称:
export {color as hue, shape as form};
以下是如何从一个模块导入多个属性:
//main.js
import {color, shape} from "thirdModule";
您也可以将整个模块作为对象导入。以下模块存储 x 、 y 和 z 位置值。它输出 x 和 y ,但不输出 z :
//fourthModule.js
export {x, y};
let x = 10,
y = 20,
z = 30;
然后,您可以通过将它定义为一个对象名为position的module来导入整个内容:
//main.js
module position from "fourthModule";
console.log(`x: ${position.x}, y: ${position.y}, z: ${position.z}`);
这个显示器
x: 10, y: 20, z: undefined
只有显式导出的属性才是可见的,这就是为什么 z 是“未定义的”这是有选择地将模块属性设置为 public 或 private 的好方法。
这个新的position对象是只读的(不可变的,所以你不能直接改变它的 x 和 y 属性。如果您想要更改它们,请将它们复制到当前模块本地的新变量中。
模块默认导出
如果您有一个只导出一个属性的模块,您可以将该属性作为default导出。如果您有一个大的类要用作模块,这种技术特别有用。这里有一个名为Animal.js的文件,它有一个创建动物的类:
//Animal.js
export default class {
constructor() {
this.legs = 4;
this.eyes = 2;
this.say = "Huh?";
}
speak() {
console.log(this.say);
}
}
请注意,您不必给default class起名字。下面是如何将其导入到main模块中:
//main.js
import Animal from "Animal";
var cat = new Animal();
cat.say = "Meow!";
cat.speak();
导入该类时,您可以给它起任何您喜欢的名称;类名不必与模块的文件名匹配。
重新导出模块
您是否将属性导入到一个模块中,并希望将其重新导出到另一个模块中?您可以使用以下语法重新导出模块的所有属性:
//main.js
export * from "fourthModule";
或者有选择地导出属性,如下所示:
export {x, y} from "fourthModule";
模块和代码架构
如您所见,您有多种方式来加载和使用模块。JavaScript 的模块系统没有规定使用它们的固定方法或最佳实践方式。它被设计得易于使用和思考。只要根据你个人的编码风格,想出你自己的使用模块的创造性系统就行了。在这本书里,你会看到很多可以用来组织游戏代码的策略的例子。
JavaScript 的模块系统也有一个 Loader API,它让您可以很好地控制模块应该如何加载。它允许您有条件地加载模块,预编译可能用 CoffeeScript 或 TypeScript 等其他语言编写的模块,并导入用 Browserify、RequireJS 或 AMD 等其他模块系统构建的代码。这已经超出了本书的范围,所以要了解更多细节,请访问wiki.ecmascript.org的模块加载器 API 规范。
然而,Loader API 的一个您希望经常使用的特性是System.baseURL属性。它设置了用于导入模块的目录,如下所示:
System.baseURL = "path/to/modules/";
baseURL可以是本地计算机系统上的任何地方,也可以是互联网上的 HTTP 地址。
如果您有一个只运行一次以执行某种初始化任务的模块,并且您不需要通过名称引用,您可以使用以下语法导入并运行它:
import "initializer";
确保该脚本在一个即时函数中,以便它一加载就自动运行,如下所示:
//initializer.js
(() => {
//This code will run as soon as the module loads
})();
注意如果需要将 AMD 模块、CommonJS 模块或者全局脚本加载到项目中,可以使用名为 SystemJS ( github.com/systemjs/systemjs)的通用模块加载器。)它会判断出您试图加载哪种类型的模块,并自动修复任何全局名称空间冲突。
使用外部数据
在下一节中,我们将学习加载和保存游戏数据的基础知识。这是一个可能变得非常复杂的领域,事实上,它本身就可以写满一整本书。在这里,我将向您展示快速入门所需的最重要的基本技巧,然后向您介绍一些令人兴奋的资源,您可以使用这些资源来进一步提高这些技巧。
用 JSON 和 XHR 加载数据
JSON (Java Script Object Notation)是一种跨平台、独立于语言的通用数据结构化格式。这是计算机系统之间交换数据最广泛使用的现代格式。它易于阅读,易于书写,而且,您很快就会看到,您已经知道如何使用它了。XHR (XMLHttpRequest)是一种用于将数据读入程序和从程序中读取数据,以及在服务器和客户端计算机之间发送数据的技术。在第一个例子中,您将学习如何使用 JSON 和 XHR 将数据文件读入游戏。
想象你正在创建一个文本冒险游戏。冒险游戏使用大量数据,因此您决定将所有数据保存在一个单独的文件中是有意义的,该文件将在主程序启动时加载到主程序中。这样你就可以通过改变易读的数据文件来轻松地更新你的游戏,而且你也不用去碰那些乱七八糟的源代码。你的冒险游戏有很多房间,都有自己的属性。每个房间都有一个 ID 号,一个描述,一个物品清单,一个出口清单,还有一个可以开或关的灯。让我们创建一个数据文件来描述这些房间。
首先,创建一个名为rooms.json的新文本文件。然后开始添加 JSON 格式的房间。这是你游戏的前两个房间:
{
"closet": {
"id": 0,
"description": "A dark coat closet.",
"light": {
"on": false
},
"contents": [],
"exits": ["east"]
},
"livingRoom": {
"id": 1,
"description": "A living room in an old, rambling house.",
"light": {
"on": true
},
"contents": ["fireplace", "sofa", "dagger"],
"exits": ["west", "north", "south"]
}
}
这是您的 JSON 数据文件。眼熟吗?确实如此!它本质上只是一个 JavaScript 对象文字。唯一的区别是属性名用引号括起来。值可以是字符串、数字、数组、布尔值(真/假)、嵌套对象或null。JSON 对象不能包含任何 JavaScript 逻辑;他们只能存储数据。这就是它的全部内容。如果你理解了 JavaScript 对象的基础,你就理解了 JSON。
注意如果一个 JSON 属性没有值,就把它设置为null。
尽管它看起来像一个 JavaScript 对象,但它还不是 JavaScript 对象。下一步是用 XHR 把它加载到你的程序中,然后把它转换成你可以在游戏中使用的真正的 JavaScript 对象。下面是如何做到这一点:
//Create an empty object to hold the JSON data
let rooms = {};
//Create a new xhr object
let xhr = new XMLHttpRequest();
//Use xhr to load the JSON file
xhr.open("GET", "rooms.json", true);
//Tell xhr that it's a text file
xhr.responseType = 'text';
//Create an `onload` callback function that
//will handle the file loading
xhr.onload = event => {
//Check to make sure the file has loaded properly.
//`200` means that the load was successful
if (xhr.status === 200) {
//Copy the JSON file into the `rooms` object
rooms = JSON.parse(xhr.responseText);
console.log("JSON data loaded");
//Now you can use this data to view the library contents
console.log(rooms.livingRoom.contents);
//Check whether the closet's light is on
if (rooms.closet.light.on === false) {
console.log("The closet light is off");
}
}
};
//Send the request to load the file
xhr.send();
现在您已经有了一个名为rooms的 JavaScript 对象,它包含来自 JSON 文件的所有数据,其行为就像任何其他普通对象一样。例如,您可以用下面的代码找出livingRoom的内容:
rooms.livingRoom.contents
结果是一个如下所示的数组:
["fireplace", "sofa", "dagger"]
现在,您可以像使用任何其他数组一样使用或修改它。如果你想知道壁橱灯是否关了,你可以用这样的语句来检查:
if (rooms.closet.light.on === false) {
console.log("The closet light is off");
}
使用这样的 JSON 文件是初始化游戏并在游戏数据和逻辑之间建立清晰分离的好方法。(你会在第二章中了解到这样做有多重要。)现在让我们看看所有这些新代码是如何工作的。
XHR 如何加载 JSON 文件
虽然 XHR 可能是一项复杂的技术,但你主要是以有限的方式使用它来将文件读入你的游戏,就像我们在这里所做的那样。在这个例子中,我们正在加载一个 JSON 文件,它只是一个纯文本。您可以使用同样的常用程序将任何其他类型的纯文本文件加载到游戏中。
注意 XHR 也可以用来读写二进制文件。二进制文件被亲切地称为blob(二进制大对象),是存储非文本数据的文件,比如图像和声音。
第一步是创建一个新的空 JavaScript 对象来存储传入的 JSON 文件,并创建一个新的 XHR 对象来帮助加载它:
let rooms = {};
let xhr = new XMLHttpRequest();
然后,xhr对象用open方法初始化一个加载文件的请求:
xhr.open("GET", "rooms.json", true);
"GET"是它将使用的 HTTP 传输系统,"rooms.json"是你想要加载的文件,true意味着我们想要使用一个回调函数来处理文件加载。使用回调处理程序很重要,因为这意味着我们的游戏可以在等待文件加载的同时继续执行其他任务。(这叫做异步文件加载,是 JavaScript 的武士超能力之一。)一旦文件被加载,回调处理程序就会运行,然后我们可以对该文件做一些有用的事情。
注意 JavaScript 异步加载文件,这意味着它们在程序的其余部分继续运行的同时加载。如果你需要以特定的顺序加载文件,ES6 的两个特性可以帮助你: promises 和 generators 。在这一章的后面,你会学到承诺。(尽管在本书中没有用到它们,但您会在本章的源文件中找到一个如何使用生成器的示例。)
**onload是文件加载时调用的事件。当status为 200 时,你知道文件已经成功加载到程序中:
xhr.onload = event => {
if (xhr.status === 200) {
rooms = JSON.parse(xhr.responseText);
}
};
加载的 JSON 文本文件存储在xhr对象的responseText属性中。JSON.parse将 JSON 数据转换成名为rooms的普通 JavaScript 对象。
您需要的最后一点代码是实际启动加载文件过程的send请求:
xhr.send();
这就是你开始在游戏中使用 JSON 或文本文件所需要知道的全部,但是在本书的其余部分,你将会看到更多关于如何使用 XHR 加载各种数据文件的例子。
注意XML 呢?如果您知道什么是 XML,并且想知道为什么在本书中几乎没有提到它,那是因为它已经作为一种通用数据格式被 JSON 取代了。JSON 是一种更简单、更好的数据格式,它对人类来说和对计算机来说一样容易读写。如果您想知道 XMLHttpRequest 与 XML 有什么关系,它没有。这是一项成熟的技术,当时 XML 非常流行,尽管您肯定可以用它来加载 XML 文件,但这已经不是它的主要用途了。如果您确实需要在游戏中使用 XML 数据,可以像加载 JSON 文件一样加载 XML 文件,但是要用 responseXML 替换 responseText。这将允许您访问 XML 树状结构中的数据。
我们将在本书中大量使用 XHR 来加载各种数据到我们的游戏中。
用localStorage 保存游戏数据
现在我们已经加载了一些数据,我们如何保存它呢?在本节中,您将学习如何使用localStorage存储和检索数据。对于不需要超过 5 MB 存储数据的单人游戏来说,这是一项伟大的技术。不要被它表面上的简单所迷惑——它可能是你玩多种游戏所需要的,它对基于网络的游戏和移动应用程序一样有效。
以下是如何将一些数据保存到localStorage:
let anyNumber = 34;
localStorage.setItem("data", anyNumber);
setItem在localStorage中保存一个名为data的属性,其值为34。这被称为键/值对,其存储方式如下:
"data" : "34"
setItem将所有值转换为字符串。看到那个"34"了吗?以前是数字,现在是字符串。这很重要——后面会有更多内容!
这个键/值对现在永久保存在浏览器或 HTML5 应用程序中。如果用户退出应用程序或关闭浏览器,当他们再次访问时,这些数据仍然会在那里,只要他们通过第一次访问时的同一个域访问这些数据。这意味着你可以通过重新加载这些数据来恢复游戏。
注意如果需要从本地存储中删除一些数据,使用localStorage.removeItem("keyName")。要一次性清除所有数据,使用localStorage.clear()。
既然它已经被保存了,下面是您检索它的方法:
let loadedData = localStorage.getItem("data");
loadedData现在有了值"34"。对,还是一串!所以用parseInt把它转换回一个数字,像这样:
loadedData = parseInt(loadedData);
loadedData现在又是一个正常数:34。
注意这个将对象存储为数据的一般过程叫做序列化 。从数据中重构一个对象叫做反序列化 。
这是如何设置和获取localStorage中的一项数据。但是很可能在你的大多数游戏中,你会有相当多的变量需要同时存储。例如,您可能希望存储玩家的姓名、分数和当前级别。处理这种情况的最好方法是将所有要保存的数据组织到一个对象中。这里有一个例子。
let gameData = {
playerName: "Rex",
levelCompleted: 5,
score: 84,
items: ["hat", "umbrella", "katana"]
};
您现在可以将整个gameData对象保存为一个值。
但是有一个条件!记住localStorage将所有值转换成字符串。因此,如果我们按原样存储这个对象,它会将所有的属性名称和值转换成一个大的数据字符串,这将使我们在重新加载它时很难使用它。幸运的是,我们可以通过将对象保存为 JSON 数据格式,然后在加载时将其转换回普通对象来解决这个问题。
要将一个对象转换成 JSON 格式,使用JSON.stringify:
let gameDataJSON = JSON.stringify(gameData);
然后使用localStorage保存对象的新 JSON 版本:
localStorage.setItem("gameData", gameDataJSON);
现在,当您检索这些数据时,使用JSON.parse将其转换回普通对象:
loadedData = localStorage.getItem("gameData");
let data = JSON.parse(loadedData);
loadedData现在是一个包含您保存的所有值的对象。例如,如果您想访问玩家的物品数组,可以使用以下语法:
data.items
这将为您提供以下数组:
["hat", "umbrella", "katana"]
是一个普通的对象,充满了保存的数据,你可以在游戏中随意使用。
注意为了简化这一点,可以考虑使用像 store.js 这样的包装器,它会为您处理所有这些细节。(github.com/marcuswestin/store.js)。为了获得更大的灵活性,可以看看 local feed . js 库。
加载和保存游戏数据的更多选项
如果你想共享游戏数据,在服务器上存储或处理它,让多个用户修改它,或者能够访问本地文件系统,你将需要使用一些其他技术。这里有一个选项的快速纲要,你应该逐渐开始探索。
- HTML5 文件 API。允许您加载、编辑、创建和保存文件,包括图像。在这个 API 稳定下来之前,可以考虑使用开源的 FileSaver.js 库。
- 如果您正在寻找更复杂的方法来构造本地存储数据,那么看看 HTML5 文件系统 API(令人困惑的是,它与文件 API 完全不同。)它允许您在本地存储中模拟一个文件系统。IndexedDB API 是另一种选择;它让您像传统数据库一样构造、读取和写入数据。如果您需要一个可以与服务器同步的跨平台数据库,可以考虑优秀的开源 PouchDB。这些选项可能都更适合数据密集型应用,而不是游戏,但你应该知道它们就在那里。
- 您可以使用 XHR 向 NodeJS web 服务器发送数据。然后,服务器可以将该数据文件写入文件系统,或者以许多其他方式处理它。NodeJS 是一项基础技术,您会发现它很有趣,也很容易学习。可以用纯 JavaScript 编写服务器脚本,所以不需要学习 PHP、Perl、Ruby 或者其他传统的服务器端语言。
- 嘿,现在是 21 世纪了!如果你想做一个实时多人游戏,你不再需要配置你自己的网络服务器或者写一行后端代码。有许多云服务和应用程序创建工具可以帮你做到这一点。让他们去吧!每天都有新的如雨后春笋般出现,但是,在撰写本文时,这些都是一些最成熟的:Parse、Firebase、Backendless、Hoodie、Meteor、Deployd、Nodejitsu、Amazon S3 和 remoteStorage。其中最好的能让你在几分钟内增加多人游戏的功能。有些,像 Hoodie 和 Meteor,是免费和开源的,但其他的,像 Parse 和 Firebase,是闭源和商业化的。所以你需要决定商业服务的便利性是否值得被他们的技术所束缚。考虑到从头开始编写一个多人游戏服务器所需要的工作量和专业知识,这很有可能。
看好这个空间!数据存储和共享是一个快速变化和不稳定的领域,因此请密切关注新兴技术和当前技术的新发展。
使用承诺
承诺是 JavaScript ES6 的新特性。他们让你按顺序排列任务。承诺并不能直接应用到游戏中,但是它们是一个非常好的功能,你肯定会想用,并且可能会找到一些创造性的用途。
让我们看看如何使用它们每秒显示一次消息。首先,创建一个使用setTimeout来计算毫秒数的wait函数。当setTimeout完成计数时,wait函数将返回一个Promise对象:
function wait(duration = 0) {
return new Promise(resolve => setTimeout(resolve, duration));
}
Promise有一个参数,是一个带参数的函数,叫做resolve,它实际上是一个内置在Promise对象中的特殊函数。每当任务完成,呼叫resolve告诉Promise任务完成。在本例中,当setTimeout完成计数时,将调用resolve:
setTimeout(resolve, duration);
您现在可以使用这个wait函数将一系列任务链接在一起。使用一个名为then的特殊函数将每个任务链接到下一个任务。以下是如何每秒显示一次新邮件:
wait(1000)
.then(() => console.log("One"))
.then(() => wait(1000))
.then(() => console.log("Two"))
.then(() => wait(1000))
.then(() => console.log("Three"));
等待 1000 毫秒后,代码依次显示“一”、“二”和“三”,其间有 1 秒钟的延迟。没有承诺,你需要建立一个复杂的if语句来达到同样的结果。你现在有了一个非常可读和合理的方法来排序一系列的任务。
为了帮助你更好地理解承诺是如何工作的,让我们从另一个角度来看它们。您可以像这样随意编写wait函数:
function wait(duration = 0) {
let timer = resolve => {
setTimeout(resolve, duration);
}
let promise = new Promise(timer);
return promise;
}
它做的事情和第一个版本一样,但是所有的组件都暴露得更清楚了。您可以看到,promise对象将timer函数设置为其参数:
let promise = new Promise(timer);
timer函数需要一个名为resolve的参数来满足承诺:
let timer = resolve => {//...
setTimeout然后在完成毫秒计数后调用resolve:
setTimeout(resolve, duration);
这实现了承诺,所以wait函数可以返回它:
return promise;
这是对 JavaScript 事件排序的一种稍微新的思考方式;但是,稍加练习,你会喜欢使用承诺。
注意关于使用承诺构建游戏的深刻方法,请参见http://greweb.me/2014/01/promisify-your-games/的 gatan renaudau 的文章“承诺你的游戏”。
在本例中,resolve正由setTimeout作为回调运行。但是当游戏中发生重要的事情时,你可以给resolve打电话,就像这样:
if (theAlienMothershipCrashed) {
resolve();
}
你也可以给resolve一个论证:
resolve("The task is finished");
现在,您可以在then函数中访问该参数的信息,语法如下:
task().then(result => console.log(result));
这将在控制台中显示“任务已完成”。
注意承诺还有第二个,可选参数叫做reject。其语法如下:
return new Promise((resolve, reject) => {
if (taskFails) {
reject(新错误(“哦不,任务失败!”));
}
});
如果想知道任务是否未能完成,使用reject。
现在,我们已经介绍了您需要了解的最基本的 JavaScript 特性,以便更好地利用这本书。但是在我们结束这一章之前,让我们来看看一些重要的技术,你可以用它们来包装和润色你完成的游戏。
全屏玩游戏
通过使用 HTML5 的全屏 API,你可以让基于浏览器的游戏充满整个屏幕。这是一种强大的效果,甚至可以让最简单的游戏感觉身临其境。你需要掌握一些小技巧,以确保你的全屏游戏在所有平台上看起来都一样,但是在下一节,我们将看看如何设置它,让你的游戏看起来更棒。
如果您设计的游戏的高度和宽度尺寸符合以下两种长宽比之一,您将获得最佳的全屏效果:
- 16:9.这是经典的宽屏比例,在各种移动设备、电视和电脑显示器上看起来都很棒。1136 × 640 和 2048 × 1152 之间的任何分辨率在现代硬件上都可以很好地工作。
- 4:3.这是经典的“旧电视”比率,它被许多设备广泛使用。现代硬件的分辨率范围在 1024 × 768 和 4096 × 3072 之间。
只有当用户点击游戏中的某个东西时,API 才会让你进入全屏模式,比如一个按钮或者一个 DOM 元素,比如画布。您可以通过在游戏中添加一个“播放”按钮来适应这种情况,该按钮既可以启动全屏模式,也可以开始游戏。你还应该确保你的游戏有一个按钮,让玩家退出全屏模式。在下面的例子中,我将向您展示如何让一个元素全屏显示,然后如何制作一个按钮,让您切换全屏模式。
注意在撰写本文时,全屏 API 仍处于试验阶段,所以如果你在阅读本文时它仍然不稳定,可以考虑使用 screenfull.js 这样的包装器来消除所有的障碍(github.com/sindresorhus/screenfull.js)。
使用全屏 API
在源文件中你会发现一个叫做fullScreen.html 的程序。它加载了一个图像,你可以点击它来全屏显示。有两张图片可以用来测试这种效果。一个是 1136 × 640 (16:9),另一个是 1024 × 768 (4:3)(你会在images文件夹中找到这些作为ratio_16x9.png和ratio_4x3.png)。图 1-1 显示了这些图像在被点击之前在浏览器中的样子,以及当它们进入全屏模式时在我的 16:10 笔记本电脑屏幕上的样子。它还展示了使用 CSS :full-screen伪选择器实现不同对齐所需的代码。

图 1-1 。全屏显示元素
最可靠的跨平台对齐全屏元素的方法是将它包装在一个容器<div>中,就像这样:
<div id="stage">
<img src="img/ratio_16x9.png">
</div>
然后在父标签上使用requestFullscreen使浏览器在被点击时进入全屏模式:
let stage = document.querySelector("#stage");
stage.addEventListener("mousedown", event => {
stage.requestFullscreen();
}, false);
您将需要一些 CSS 来确保包含的图像正确对齐。首先,清除任何默认边距或填充:
* {
margin: 0;
padding: 0;
}
然后给容器<div>一个高度、宽度和可选的背景颜色:
#stage {
width: 100%;
height: 100%;
background-color: black;
}
还要确保图像是一个块元素;否则它不会正确对齐:
img {
display: block;
}
现在,您可以使用:fullscreen伪选择器来定义图像进入全屏模式时的样子。因为全屏显示的实际上是包含的stage标签,所以在stage元素上使用:fullscreen,并将其包含的img标签作为目标,如下所示:
#stage:fullscreen img {
/* align the containing image tag here */
}
现在你需要决定如何对齐图像。下面是如何拉伸它的height和width来填充整个屏幕:
#stage:fullscreen img {
width: 100%;
height: 100%;
}
如果图像的纵横比与屏幕不完全匹配,这将会扭曲图像。为了防止这种情况,您可以将图像扩展到其最大高度或宽度,然后将其垂直或水平居中。(回头参考图 1-1 查看 CSS 的这些后续位对全屏图像对齐的影响。)
4:3 游戏水平居中时看起来不错:
#stage:fullscreen img {
height: 100%;
margin: 0 auto;
}
16:9 游戏垂直居中看起来不错。这段 CSS 创建了经典的信箱外观:
#stage:fullscreen img {
position: fixed;
width: 100%;
height: auto;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
}
要退出 web a 浏览器的全屏模式,用户需要按 Esc 键。他们会知道或记得这样做吗?你永远不应该这样假设,所以在你的游戏中添加一个按钮,让用户退出全屏模式。接下来您将学习如何做这件事。
创建全屏切换按钮
您可以创建一个切换按钮,只要点按它,就会启动和关闭全屏模式。图 1-2 显示了一个具有两种状态的全屏按钮的例子,这两种状态根据当前屏幕模式自动改变。您可以在源文件中找到完整的工作示例。

图 1-2 。全屏切换按钮的两种状态
这些状态将由两个图像表示:makeBig.png和makeSmall.png。
要制作一个按钮,在包含阶段的内部添加一个 ID 为按钮的<div>标签。
<div id="stage">
<img src="img/ratio_16x9.png">
<div id="button"></div>
</div>
添加一些 CSS 来设置它的大小和位置,并给它一个暗示“单击启动全屏”的背景图像
#button {
position: absolute;
right: 4%;
top: 6%;
width: 96px;
height: 96px;
background-image: url(img/makeBig.png);
cursor: pointer;
}
然后添加全屏状态的 CSS。如果需要,重新定位按钮,并将背景图像更改为其第二种状态:“单击退出全屏”:
#stage:fullscreen #button{
background-image: url(img/makeSmall.png);
right: 4%;
top: 10%;
}
向按钮添加一个 mousedown 监听器。然后检查当前是否有任何元素全屏显示。如果没有,让stage全屏显示。如果全屏模式当前处于活动状态,请退出。
let button = document.querySelector("#button");
button.addEventListener(
"mousedown",
event => {
//Is there any element that's currently full screen?
if (!document.fullscreenElement) {
//If not, make the stage full screen
stage.requestFullscreen();
} else {
//If there is, exit full screen mode
document.exitFullscreen();
}
},
false
);
这段代码通过检查document.fullscreenElement来工作。它可以保存全屏显示的一个元素的值。如果fullscreenElement是null,这意味着没有全屏显示,所以你可以继续启动它。但如果不是null,那么当前一定是在运行全屏模式。在这种情况下,使用document.exitFullscreen()退出模式。
注意与其让你的游戏占据用户的整个屏幕,不如让游戏缩放到浏览器窗口的最大可用尺寸来减少干扰。你将在第十一章中学习如何做这件事。
缩小你的 JS 源代码
你的大多数游戏可能最终需要大量的依赖 JavaScript 文件。在你部署你的游戏之前,你应该把所有这些文件合并成一个文件,然后缩小它。以下是如何:
- 串联你的代码:将你所有的 JS 文件复制到一个包含你所有代码的巨大文件中。您应该按照
<script>标签加载代码的顺序添加代码。 - 缩小你的代码:当你缩小你的代码时,它会被压缩和优化。空白、换行符和注释被删除,变量和函数名被缩短。一些迷你程序甚至会重写你的代码来注入微小的优化。精简的代码并不意味着人类可读或可编辑,但它更易于计算机阅读。您可以将简化的代码想象成 JavaScript 二进制文件或 blob 文件。它不仅加载速度更快,而且运行速度也可能更快。
现在,你的游戏只需要一个.min.js文件,而不是用一打相关的.js文件来启动你的游戏。
有许多小程序可供选择,包括:谷歌的 Closure 编译器、UglifyJS 和 YUI 压缩器等等。你会发现许多在线工具可以帮助你使用它们,保存时缩小是代码编辑器中的一个常见功能,比如 Sublime Text、Brackets、Atom 和 Light Table。Grunt、Gulp 和 Yeoman 等服务也将为您完成这项工作,您可以设置文件监视器,当文件发生变化时,它将自动为您连接和压缩文件。
下面是使用丑陋的 JS 连接和缩小代码的“穴居人风格”Unix 命令行方式:
-
从
github.com/mishoo/Uglify下载并安装 UglifyJS。您可以使用 git 的clone命令或者使用 node 的 npm 包管理器安装它。 -
导航到包含要缩小的 JavaScript 文件的目录。使用名为 cat 的内置 Unix 应用程序将所有独立的 JavaScript 文件合并成一个大文件(将它们连接起来)。然后使用 pipe 命令将连接的文件发送到要缩小的 UglifyJS:
cat file1.js file2.js file3.js file4.js | uglifyjs -o finishedFile.min.js
您现在有了一个名为finishedFile.min.js的新文件,它包含了其他三个文件中的所有代码,按照您列出的顺序排列。
工具为什么叫丑化?只要看一看它产生的精简代码就知道了!
使用 iFrames 在网上发布你的游戏
你可以使用一个 iFrame 在任何网页中无缝运行你的 HTML5 游戏。这是一个在网络上发布游戏的好方法,不需要用户访问或安装你的源代码。只需放一个 iFrame 就可以将你的游戏嵌入到任何现有的网页中。
下面是如何使用 iFrame:
- 首先确保你的游戏与 HTML 页面的左上角对齐。它不应该有任何填充、空白或边框。
- 在互联网的某个地方托管这个 HTML 页面。
- 在你想玩游戏的任何其他网页中使用一个
<iframe>标签。链接到你的游戏,给它和你原来的游戏完全一样的width和height。添加一个seamless属性,并将allowfullscreen设置为"true"。HTML 看起来像这样:
<iframe
src="sourcePage.html"
seamless
width="640"
height="480"
allowfullscreen="true"
></iframe>
属性从 iFrame 中移除边框和滚动条。将allowfullscreen设置为"true"意味着游戏的全屏模式仍然有效,即使它是通过 iFrame 运行的。这是相当惊人的,因为这意味着你可以在任何其他网站上分发和嵌入你的游戏,一旦玩家点击全屏按钮,他们就完全沉浸在你的游戏世界中。iFrames 是完全沙箱化的,这意味着你的 JS 脚本和 CSS 不会与运行在托管页面上的代码冲突,反之亦然。
这个 iFrame 现在已经成为你完成的游戏。一个标签,没有文件,没有依赖关系;它只是工作。通过电子邮件发送、分享、嵌入——大功告成!(你可以在本章源文件的 iFrame 文件夹中找到一个如何使用 iFrame 嵌入另一个页面的工作示例,包括使用全屏模式。)
将焦点设置到 iFrame
您在使用 iFrame 时可能会遇到的一个小问题是,在 iFrame 中的内容被点击之前,您的游戏可能没有焦点。焦点决定了浏览器窗口的哪一部分能够感知鼠标、触摸或键盘事件。这种行为是特定于浏览器的,但是为了安全起见,您应该假设您的 iFrame 游戏在用户点击或触摸它之前不会有焦点。处理这个问题的最好方法是要求用户用一个开始按钮来启动游戏,或者用一个全屏按钮来启动游戏。这迫使用户在游戏开始前点击或触摸内容,并且该动作将给予 iFrame 内容它所需要的焦点。
如果您想确保 iFrame 内容自动获得焦点,您可以使用一个小技巧。使用setTimeout在 300 毫秒的延迟后聚焦 iFrame 的contentWindow:
setTimeout(setFocus, 300);
function setFocus() {
let frame = document.querySelector("iframe");
frame.contentWindow.focus();
}
contentWindow是 iFrame 上的窗口对象,调用focus方法将浏览器的焦点设置到该内容。300 毫秒的时间足够让 iFrame 的 JS 脚本加载,这确保了 iFrame 在调用focus之前被正确实例化。
摘要
这一章是现代游戏开发者的训练营,为你提供从头开始用 HTML5 和 JavaScript 构建游戏所需的所有基本技能。我们已经涵盖了一切,从新的 JavaScript ES6 玩具和有趣的编码技巧到 JSON、XHR 和部署策略,我们甚至对对象、类和原型有了一些灵魂出窍的体验。这些都是你在接下来的章节中开始构建游戏时需要知道的小东西。**
二、画布绘制 API
画布绘制 API 是 HTML5 游戏设计师最好的朋友。它易于使用,功能强大,可用于所有平台,速度非常快。不仅如此,学习 Canvas Drawing API 还为您提供了一个很好的低级图形编程入门,您将能够将其应用于各种不同的游戏设计技术。作为学习 HTML5 游戏设计艺术的核心技术,这是最好的起点。
注什么是 API?它代表应用程序编程接口。它只是一个函数和对象的代码库,帮助您执行一组特定的任务,如绘制形状。
在这一章中,你将得到一个在画布上绘制线条、形状、图像和文本的快速速成课程,这样你就可以开始使用它们来为你的游戏制作组件。我们将看看制作和修改线条和形状的所有基本方法。
搭建画布
在你开始画画之前,你需要一个可以画画的表面。下面介绍如何使用 JavaScript 创建一个画布 HTML 元素和一个绘图上下文。
let canvas = document.createElement("canvas");
canvas.setAttribute("width", "256");
canvas.setAttribute("height", "256");
canvas.style.border = "1px dashed black";
document.body.appendChild(canvas);
let ctx = canvas.getContext("2d");
这段代码是做什么的?它在 HTML 文档的主体中创建一个<canvas> HTML 标记,如下所示:
<canvas id="canvas" width="256" height="256" style="border:1px dashed #000000;"></canvas>
您可以将 canvas 标签视为包含绘图表面的框架。这段代码创建的画布的宽度和高度是 256px,周围有一个 1 像素宽的虚线边框。
实际的绘制是在画布的 drawing context 上完成的。你可以把上下文想象成一种位于画布框架内部的可编程绘图表面。上下文在此代码中表示为 ctx :
let ctx = canvas.getContext("2d");
现在,您已经准备好开始绘制线条和形状了。
注意你会注意到本书中大多数图像的宽度和高度尺寸都是 2 的幂,比如 32、64、128 和 256。这是因为图形处理器在历史上处理 2 的幂大小的图像非常有效:这与二进制图形数据存储在计算机内存中的格式相同。你会发现,如果你把你的游戏图像保持在 2 的幂的大小,它们将整齐地适合大多数计算机和设备的屏幕。
画线
让我们从最简单的图形元素开始:一条线。下面是如何从画布的左上角(0,0)到其中点(128,128)画一条线。线条为黑色,3 像素宽:
//1\. Set the line style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
//2\. Draw the line
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(128, 128);
ctx.stroke();
图 2-1 显示了这段代码所创建的内容。

图 2-1 。在画布上画一条线
它是这样工作的。首先,我们设置线条样式选项。strokeStyle允许您设置线条的颜色,可以是任何 RGB、十六进制或预定义的 CSS 字符串颜色名称,如"black"。
ctx.strokeStyle = "black";
然后给它一个lineWidth,以像素为单位。下面是如何分配 3 个像素的线宽:
ctx.lineWidth = 3;
现在您已经设置了线条选项,您可以开始用beginPath方法绘制路径。这只是一种说“我们现在要开始划清界限了!”
ctx.beginPath();
用moveTo设置线的起始 x,y 位置。0,0 是画布的左上角。(左上角的 x 和 y 值都为零。)
ctx.moveTo(0, 0);
然后使用lineTo定义线条的终点。在这种情况下,它将在画布中间的 x,y 位置 128 处结束。(记住,我们的画布是 256×256 像素。)
ctx.lineTo(128, 128);
当你画完形状后,你可以选择使用closePath来自动连接路径中的最后一个点和第一个点。
ctx.closePath();
最后,我们需要使用stroke方法使这条线可见。这将应用我们之前设置的线条颜色和粗细选项,因此我们可以在画布上看到线条:
ctx.stroke();
这些都是开始使用绘图 API 需要知道的基础知识。接下来你会看到我们如何将线条连接在一起形成形状,并用颜色填充这些形状。
线帽
您可以很好地控制行尾的外观。lineCap属性有三个选项可以使用:"square"、"round"和"butt"。(注意,引号是字面意思。)您可以使用以下语法应用这些样式中的任何一种:
ctx.lineCap = "round";
(这行代码必须出现在我们调用ctx.stroke()方法之前。)
图 2-2 显示了这些风格的效果。你需要一条相当粗的线来看这些线条的不同。

图 2-2 。线帽样式
连接线条以创建形状
您可以将线条连接在一起形成形状,并用颜色填充这些形状。使用上下文的fillStyle属性定义要填充形状的颜色。下面的例子展示了如何将fillStyle设置为透明的灰色 RGBA 颜色:
ctx.fillStyle = "rgba(128, 128, 128, 0.5)";
color 是以 RGBA、十六进制或 HLSA 格式描述颜色的字符串。它也可以是 HTML/CSS 规范中的 140 个颜色词中的任何一个,比如“蓝色”或“红色”。
用线条画出形状的轮廓后,使用上下文的fill方法用fillStyle颜色填充形状:
ctx.fill();
下面是如何在画布中央画一个三角形,并赋予其透明的灰色填充颜色。图 2-3 显示了你将会看到的,以及用来创建它的moveTo和lineTo命令。

图 2-3 。画一个三角形
//Set the line and fill style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(128, 128, 128, 0.5)";
//Connect lines together to form a triangle in the center of the canvas
ctx.beginPath();
ctx.moveTo(128, 85);
ctx.lineTo(170, 170);
ctx.lineTo(85, 170);
ctx.lineTo(128, 85);
ctx.fill();
ctx.stroke();
绘制复杂形状
如果您的形状很复杂,您可以将其定义为点的 2D 数组,并使用循环将这些点连接在一起。这里有一个由 x 、 y 点坐标组成的 2D 数组,它形成了与上一个例子中相同的三角形:
let triangle = [
[128, 85],
[170, 170],
[85, 170]
];
接下来,定义一个循环遍历这些点并使用moveTo和lineTo连接它们的函数。我们可以保持代码尽可能简单,从最后一点开始,然后从那里顺时针连接这些点:
function drawPath(shape) {
//Start drawing from the last point
let lastPoint = shape.length - 1;
ctx.moveTo(
shape[lastPoint][0],
shape[lastPoint][1]
);
//Use a loop to plot each point
shape.forEach(point => {
ctx.lineTo(point[0], point[1]);
});
}
你现在可以使用这个drawPath函数来绘制形状,就像这样:
ctx.beginPath();
drawPath(triangle);
ctx.stroke();
ctx.fill();
您可以使用这种技术来制作具有任意数量点的复杂形状。
线条连接
您可以设置线条与其他线条的连接方式。使用lineJoin属性可以做到这一点。您可以使用下列选项中的任何一个来设置它:"round"、"mitre"或"bevel"(同样,引号是文字)。下面是要使用的格式:
ctx.lineJoin = "round";
图 2-4 显示了这些风格的效果。

图 2-4 。线条连接样式
画正方形和长方形
使用rect方法快速创建一个矩形。它具有以下格式:
rect(x, y, width, height)
下面是如何制作一个 x 位置为 50、 y 位置为 49、宽度为 70、高度为 90 的矩形:
ctx.rect(50, 40, 70, 90);
设置线条和填充样式选项,绘制如图 2-5 所示的矩形。

图 2-5 。画一个长方形
//Set the line and fill style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(128, 128, 128, 0.5)";
//Draw the rectangle
ctx.beginPath();
ctx.rect(50, 40, 70, 90);
ctx.stroke();
ctx.fill();
图 2-6 说明了如何使用尺寸和位置值来绘制矩形。

图 2-6 。x、y 位置以及矩形的宽度和高度
或者,您可以使用快捷方式strokeRect和fillRect方法绘制一个矩形。下面介绍如何使用它们来绘制如图 2-7 所示的矩形。
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(128, 128, 128, 0.5)";
ctx.strokeRect(110, 170, 100, 50);
ctx.fillRect(110, 170, 100, 50);

图 2-7 。使用strokeRect和fillRect绘制一个矩形
梯度
您可以创建两种类型的渐变:线性 或径向。
要创建线性渐变,使用createLinearGradient方法。它需要四个参数。前两个参数是画布上渐变开始点的 x 、 y 坐标。后两个参数是渐变终点的 x 、 y 坐标。
let gradient = ctx.createLinearGradient(startX, startY, endX, endY);
这在画布上定义了一条渐变应该遵循的线。
接下来,你需要添加色站 。这些是渐变将混合在一起以创建色调平滑过渡的颜色。addColorStop方法使用两个参数进行混合。第一个是渐变上颜色应该开始的位置。这可以是 0(开始位置)和 1(结束位置)之间的任何数字。第二个参数是颜色(这是一个 RGBA、十六进制或 HLSA 格式的字符串,或者是 140 个 HTML 颜色词中的一个)。
以下是如何在从白色过渡到黑色的渐变的起点和终点添加两个色标。
gradient.addColorStop(0, "white");
gradient.addColorStop(1, "black");
(如果您想在这两种颜色之间添加第三种颜色,您可以使用 0 到 1 之间的任何数字。值为 0.5 时,第三种颜色介于 0 和 1 之间。)
最后,将渐变应用到上下文的fillStyle以能够使用它来填充形状:
ctx.fillStyle = gradient;
这是一个填充正方形的渐变的例子。渐变从正方形的左上角开始,到它的右下角结束。图 2-8 显示了这段代码产生的结果。

图 2-8 。用线性渐变填充形状
//Set the line style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
//Create a linear gradient
let gradient = ctx.createLinearGradient(64, 64, 192, 192);
gradient.addColorStop(0, "white");
gradient.addColorStop(1, "black");
ctx.fillStyle = gradient;
//Draw the rectangle
ctx.beginPath();
ctx.rect(64, 64, 128, 128);
ctx.stroke();
ctx.fill();
在这个例子中,我使渐变比正方形稍微大一点,只是为了稍微柔化效果:
ctx.createLinearGradient(64, 64, 192, 192)
只有落在矩形内的前四分之三的渐变区域是可见的。
要创建径向渐变,使用createRadialGradient方法。它需要六个参数:前三个是渐变的起始圆的位置及其大小,后三个是渐变的结束圆的位置及其大小。
let gradient = ctx.createRadialGradient(x, y, startCircleSize, x, y, endCircleSize);
开始圆和结束圆通常具有相同的位置;只是尺寸会有所不同。
您可以添加色标并将渐变应用到画布fillStyle上,就像处理线性渐变一样。以下是用径向渐变填充正方形的方法:
let gradient = ctx.createRadialGradient(128, 128, 10, 128, 128, 96);
128 的渐变的 x 和 y 位置匹配画布中正方形的中心点。图 2-9 显示了结果。

图 2-9 。径向梯度
画圆和圆弧
使用arc方法画圆。以下是可以使用的参数:
arc(centerX, centerY, circleRadius, startAngle, endAngle, false)
中心 x , y 坐标是画布上确定圆中心点的点。circleRadius是一个以像素为单位的数字,它决定了圆的半径(其宽度的一半)。startAngle和endAngle是以弧度表示的数字,它们决定了圆的完整程度。对于一整圈,使用 0 的startAngle和 6.28 的endAngle(2 * Math.PI)。(startAngle的 0 位置在圆圈的 3 点钟位置。)最后一个参数false,表示应该从startAngle开始顺时针画圆。
以下是如何在画布中心绘制一个半径为 64 像素的完整圆:
ctx.arc(128, 128, 64, 0, 2*Math.PI, false)
图 2-10 显示了一个带有渐变填充的圆,你可以用下面的代码创建它。

图 2-10 。画一个带渐变的圆
//Set the line style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
//Create a radial gradient
let gradient = ctx.createRadialGradient(96, 96, 12, 128, 128, 96);
gradient.addColorStop(0, "white");
gradient.addColorStop(1, "black");
ctx.fillStyle = gradient;
//Draw the circle
ctx.beginPath();
ctx.arc(128, 128, 64, 0, 2*Math.PI, false);
ctx.stroke();
ctx.fill();
注意弧度是圆的度量单位,在数学上比度数更容易处理。1 弧度是当你把半径绕在圆的边缘时得到的度量。3.14 弧度等于半个圆,非常方便,等于π(3.14)。一个完整的圆是 6.28 弧度(π* 2)。一弧度约等于 57.3 度,如果您需要将度转换为弧度,或将弧度转换为度,请使用以下公式:
弧度=度(数学。
π/180)
度=弧度 (180 /数学。PI)
可以用同样的arc方法轻松画出一个圆弧(不完整的圆)。使用大于 0 的startAngle和小于 6.28 的endAngle(2 * Math.PI)即可。下面是一些绘制 3.14 到 5 弧度的圆弧的代码,如图图 2-11 所示。

图 2-11 。画一个弧线
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(128, 128, 64, 3.14, 5, false)
ctx.stroke();
如果你需要绘制曲线,画布绘制 API 有一些高级选项,我们接下来会看到。
画曲线
可以画的曲线有两种:二次曲线 和贝塞尔曲线。
要绘制二次曲线,使用quadraticCurveTo方法。下面的代码产生了你在图 2-12 中看到的曲线。

图 2-12 。二次曲线
ctx.moveTo(32, 128);
ctx.quadraticCurveTo(128, 20, 224, 128);
代码本身就令人困惑,但借助图表很容易理解。你需要做的第一件事是使用moveTo定义线条的起点,靠近画布的左中心边缘:
ctx.moveTo(32, 128);
然后使用quadraticCurveTo方法定义曲线。前两个参数定义了所谓的控制点 。你可以把控制点想象成一种无形的引力点,把线拉向它。在本例中,控制点靠近画布的中心顶部,在 128 的 x 位置和 20 的 y 位置,我已经在这里突出显示了:
ctx.quadraticCurveTo(128, 20, 224, 128);
最后两个参数是线条的终点,靠近画布的中右边缘:
ctx.quadraticCurveTo(128, 20, 224, 128);
你能在图 2-12 中看到这些点是如何一起创造曲线的吗?
贝塞尔曲线类似,但增加了第二个控制点:
bezierCurveTo(control1X, control1Y, control2X, control2Y, endX, endY);
再说一次,除非你看到一个清晰的例子,否则很难理解这是如何工作的。图 2-13 显示了一条贝塞尔曲线和用来创建它的四个点。下面是生成该曲线的代码:
ctx.moveTo(32, 128);
ctx.bezierCurveTo(32, 20, 224, 20, 224, 128);

图 2-13 。贝塞尔曲线
将代码与图表进行比较,并尝试制作一些自己的曲线,直到您对二次曲线和贝塞尔曲线的工作原理有所了解。如果您闭合这些线条,使它们在同一点开始和结束,您将生成一个可以用颜色或渐变填充的形状。
阴影
您可以使用shadowColor、shadowOffsetX、shadowOffsetY和shadowBlur属性为任何线条或形状添加阴影。图 2-14 显示一个带有浅灰色、略显模糊的圆形阴影。下面是生成它的代码:
ctx.shadowColor = "rgba(128, 128, 128, 0.9)";
ctx.shadowOffsetX = 10;
ctx.shadowOffsetY = 10;
ctx.shadowBlur = 10;

图 2-14 。添加阴影
shadowColor可以是任何 RGBA(如本例所示)、HSLA、十六进制或 CSS 颜色字符串名称。赋予阴影透明的 alpha 颜色(在本例中为 0.9)会使它们在覆盖另一个对象时看起来更真实。shadowOffsetX和shadowOffsetY决定阴影从形状偏移多少像素。shadowBlur是阴影应模糊的像素数,以产生漫射光效果。尝试这些值,直到你找到一个能产生你喜欢的效果的组合。像这样的投影适用于任何形状、线条或文本。
旋转
画布绘制 API 没有任何内置方法来旋转单个形状。相反,您必须旋转整个画布,将形状绘制到旋转后的状态,然后再次旋转整个画布。您还必须移动绘图上下文的坐标空间。它的 x , y 0,0 点通常是画布的左上角,您需要将其重新定位到形状的中心点。
起初,这似乎是画布绘制 API 的一个疯狂、糟糕的特性。实际上,这是最好的功能之一。正如你将在第四章中看到的,它允许你用最少的代码在形状之间创建非常有用的嵌套父子关系。但要理解发生了什么,首先确实需要一点概念上的飞跃。所以让我们来看看画布旋转是如何工作的。
下面的代码画了一个旋转后的正方形,如图图 2-15 所示。在代码清单之后,我将向您详细介绍它是如何工作的。

图 2-15 。旋转形状
//Set the line and fill style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(128, 128, 128, 0.5)";
//Save the current state of the drawing context before it's rotated
ctx.save();
//Shift the drawing context's 0,0 point from the canvas's top left
//corner to the center of the canvas. This will be the
//square's center point
ctx.translate(128, 128);
//Rotate the drawing context's coordinate system 0.5 radians
ctx.rotate(0.5);
//Draw the square from -64 x and -64 y. That will mean its center
//point will be at exactly 0, which is also the center of the
//context's coordinate system
ctx.beginPath();
ctx.rect(-64, -64, 128, 128);
ctx.stroke();
ctx.fill();
//Restore the drawing context to
//its original position and rotation
ctx.restore();
这就是所有这些是如何工作的。我们需要做的第一件事是保存绘图上下文的当前状态:
ctx.save();
这很重要,因为我们要移动和旋转整个上下文。save方法让我们记住它的原始状态,这样我们可以在画完旋转的正方形后恢复它。
接下来,translate方法移动上下文的坐标空间,使位置 0,0 位于画布上与我们将要绘制的正方形中心相同的点上。正方形的中心将有一个 128 的 x 位置和一个 128 的 y 位置,将其放置在画布的正中心。
ctx.translate(128, 128);
这意味着上下文的 0,0 位置不是在画布的左上角,而是向右移动了 128 像素,向下移动了 128 像素。这将是正方形的中心点。图 2-16 显示了坐标空间是如何移动的。如果我们不这样做,正方形看起来不会绕着它的中心旋转。相反,它会围绕画布的左上角旋转。图 2-16 显示了这段代码对上下文坐标位置的不可见但重要的影响。

图 2-16 。将上下文的坐标空间移动到画布的中心
接下来,将上下文的整个坐标空间顺时针旋转 0.5 弧度(28.6 度),如图图 2-17 所示。
ctx.rotate(0.5);

图 2-17 。旋转上下文
下一步是围绕上下文的中心点绘制矩形。这意味着你必须将矩形的宽度和高度各偏移一半。这个正方形的宽和高都是 128 像素,所以它的 x 、 y 位置都需要是–64。
ctx.beginPath();
ctx.rect(-64, -64, 128, 128);
ctx.stroke();
ctx.fill();
这是令人困惑的,所以看一看图 2-18 来弄清楚发生了什么。您可以看到,绘制矩形后,其中心点落在上下文的 0,0 点上。并且因为上下文被旋转了,所以正方形看起来也旋转了。

图 2-18 。绘制正方形,使其位于上下文旋转中心点的中心
最后,我们必须将上下文恢复到移动和旋转之前的状态:
ctx.restore();
这让我们可以在这一点之后添加更多的线条或形状,它们不会被旋转。图 2-19 显示了最终恢复后的状态。

图 2-19 。旋转后将画布的状态恢复到正常状态
即使上下文的位置和旋转已经恢复,正方形仍然保持在它被绘制的相同位置。
保存上下文的状态、移动它、旋转它、在它上面绘制形状,以及恢复它的整个过程发生在几分之一毫秒内。它真的很快,你永远不会看到它发生。但是,对于要旋转的每条线或形状,您必须一步一步地遵循相同的过程。像这样手动完成是很乏味的,但是在第四章中,你将学习如何用一个自定义的形状精灵和render函数来自动完成这个过程。
标度
canvas context 的scale方法让您可以轻松地沿着 x / y 轴缩放形状的宽度和高度:
ctx.scale(scaleX, scaleY)
0 到 1 之间的scaleX和scaleY值将在 0 到 100%的原始大小之间缩放形状。这意味着如果你设置scaleX和scaleY为 0.5,形状将被缩放到其大小的 50%。
ctx.scale(0.5, 0.5)
将这些值设置为 2 会将形状缩放到其原始大小的 200%:
ctx.scale(2, 2)
最后,scaleX和scaleY值为 1 会将形状设置为其原始比例。
图 2-20 显示了这些比例值对前面例子中旋转矩形的影响。

图 2-20 。相对于形状的大小缩放形状
就像旋转一样,实际上缩放的不是形状,而是整个画布背景。上下文的缩放量与当前正在绘制的线条、形状或图像的宽度和高度有关。因此,就像你处理旋转一样,你需要在一对save和restore方法之间插入scale方法,这样上下文将返回到它的原始比例,用于它需要绘制的下一个东西。虽然这看起来是一种笨拙的方式,但在第四章中,你会看到它是如何让我们用很少的代码轻松创建一个复杂的嵌套精灵层次的。
让事情变得透明
有两种方法可以使画布元素透明。第一种方法是使用 RGBA 或 HSLA 颜色,并将 alpha 值(参数中的最后一个数字)设置为小于 1 的数字,这在前面的示例中已经看到了。( Alpha 是透明度的图形设计术语。)第二种方法是使用画布上下文的globalAlpha属性。globalAlpha与rotate相似,都会影响整个画布。这意味着只对一个形状应用透明度,你需要将globalAlpha夹在save和restore之间,如下例所示:
ctx.save();
ctx.globalAlpha = 0.5;
//...Draw your line or shape...
ctx.restore();
globalAlpha取 0(完全透明)和 1(完全不透明)之间的一个数字。图 2-21 显示了一个用globalAlpha做成半透明的正方形和圆形。下图是执行此操作的代码。

图 2-21 。用globalAlpha使形状透明
//Set the fill style options
ctx.fillStyle = "black";
//Draw the rectangle
ctx.save();
ctx.beginPath();
ctx.globalAlpha = 0.6;
ctx.rect(32, 32, 128, 128);
ctx.fill();
ctx.restore();
//Draw the circle
ctx.save();
ctx.beginPath();
ctx.globalAlpha = 0.3;
ctx.arc(160, 160, 64, 0, Math.PI * 2, false)
ctx.fill();
ctx.restore();
使用混合模式
画布上下文有一个globalCompositeOperation属性,允许您将一个混合模式分配给画布。混合模式决定了两个相交形状或图像的颜色应该如何组合。有 16 种混合模式可供选择,它们与 Photoshop 等图像编辑软件中的相同混合模式具有相同的效果。根据您使用的混合模式和形状或图像的颜色,效果可以是从微妙的透明到生动的颜色反转。
以下是如何使用globalCompositeOperation将混合模式设置为multiply:
ctx.globalCompositeOperation = "multiply";
乘法是一种对比效果,它使用一个公式将重叠颜色的值相乘来生成一种新颜色。图 2-22 显示了蓝色圆圈重叠红色方块的效果。

图 2-22 。使用混合模式来组合重叠图像的颜色
下面是产生这种效果的代码:
//Set the blend mode
ctx.globalCompositeOperation = "multiply";
//Draw the rectangle
ctx.save();
ctx.fillStyle = "red";
ctx.beginPath();
ctx.rect(32, 32, 128, 128);
ctx.fill();
ctx.restore();
//Draw the circle
ctx.save();
ctx.fillStyle = "blue";
ctx.beginPath();
ctx.arc(160, 160, 64, 0, Math.PI*2, false)
ctx.fill();
ctx.restore();
以下是您可以使用的混合模式的完整列表,以及每种模式产生的效果:
- 无融合 :
"normal" - 对比 :
"soft-light","hard-light","overlay" - 点亮 :
"lighten","color-dodge","screen" - 变暗 :
"darken","color-burn","multiply" - 颜色反转 :
"difference","exclusion" - 复杂调配 :
"hue","saturation","color","luminosity"
欣赏这些效果的最佳方式是打开您最喜欢的图像编辑器,并观察这些混合模式对两个重叠图像的影响。使用画布绘制 API 的效果是一样的。关于这些混合模式如何工作的更多细节,W3C 令人惊讶的可读规范是一个很好的起点:dev.w3.org/fxtf/compositing-1。
合成效果
globalCompositeOperation方法也可以让你详细控制重叠的形状应该如何组合。有十二种波特-达夫运算 可以应用到形状上;它们涵盖了两种形状组合的所有可能方式。
注意波特-达夫操作是以托马斯·波特和汤姆·达夫的名字命名的,他们在为星球大战电影做视觉特效时开发了它们。
应用波特-达夫运算的方式与应用混合模式的方式相同:
ctx.globalCompositeOperation = "source-over";
图 2-23 说明了这些操作的效果,下表(表 2-1 )简要描述了每个操作的作用。

图 2-23 。使用复合操作来合并和遮罩重叠的形状
表 2-1 。画布合成效果
|
复合操作
|
它的作用
|
| --- | --- |
| "source-over" | 在第二个形状前面绘制第一个形状。 |
| "destination-over" | 在第一个形状前面绘制第二个形状。 |
| "source-in" | 仅在两个形状重叠的画布部分绘制第二个形状。 |
| "destination-in" | 仅在两个形状重叠的画布部分绘制第一个形状。 |
| "source-out" | 在不与第一个形状重叠的地方绘制第二个形状。 |
| "destination-out" | 在不与第二个形状重叠的地方绘制第一个形状。 |
| "source-atop" | 仅在第二个形状与第一个形状重叠的地方绘制第二个形状。 |
| "destination-atop" | 仅在第一个形状与第二个形状重叠的地方绘制第一个形状。 |
| "lighter" | 将重叠的形状颜色混合成较浅的颜色。 |
| "darker" | 将重叠的形状颜色混合成较暗的颜色。 |
| "xor" | 使重叠区域透明。 |
| "复制" | 仅绘制第二个形状。 |
到目前为止,在这一章中,我们只是处理了形状,但是你可以很容易地将所有这些技术应用到图像上。我们接下来会这么做。
用图像填充形状
您可以使用createPattern方法用图像填充形状。图 2-24 显示了一只猫的图像是如何被用来填充一个正方形的。

图 2-24 。用图像填充形状
这是通过用rect方法画一个正方形,然后使用图像pattern作为fillStyle来完成的。边框是可选的;如果不使用描边样式,图像周围就不会有边框。此外,如果您希望图像的左上角与形状的左上角匹配,您需要偏移画布以匹配形状的 x , y 位置。这和我们在前面的例子中使用的技巧是一样的,但是更简单一点,因为我们没有旋转任何东西。我将在前面解释这是如何工作的。下面是产生这种效果的代码:
//Load an image
let catImage = new Image();
catImage.addEventListener("load", loadHandler, false);
catImage.src = "img/cat.png";
//The loadHandler is called when the image has loaded
function loadHandler() {
//Set the line style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
//Draw the rectangle
ctx.beginPath();
ctx.rect(64, 64, 128, 128);
//Set the pattern to the image, and the fillStyle to the pattern
let pattern = ctx.createPattern(catImage, "no-repeat");
ctx.fillStyle = pattern;
//Offset the canvas to match the rectangle's x and y position,
//then start the image fill from that point
ctx.save();
ctx.translate(64, 64);
ctx.stroke();
ctx.fill();
ctx.restore();
}
加载图像时,代码在画布上绘制并定位矩形,如您在前面的示例中所见:
ctx.beginPath();
ctx.rect(64, 64, 128, 128);
然后,它使用createPattern方法将加载的图像转换成形状图像模式。该模式存储在名为pattern的变量中,然后分配给fillStyle:
let pattern = ctx.createPattern(catImage, "no-repeat");
ctx.fillStyle = pattern;
在本例中,模式被设置为"no-repeat"。如果您想要具有纹理图案效果的形状,您可以使用较小的图像,并使其在形状的整个区域重复。除了"no-repeat"之外,还有另外三个选项可以使用:"repeat"用图案的连续重复图像覆盖形状的表面,而"repeat-x"和"repeat-y"只是沿着一个轴重复图像。
在这个例子中,我希望猫图像的左上角与矩形的左上角精确对齐。为了实现这一点,我需要在设置fillStyle之前用translate方法偏移画布。偏移量匹配矩形的 x 、 y 位置(64 乘 64 像素):
ctx.save();
ctx.translate(64, 64);
ctx.stroke();
ctx.fill();
ctx.restore();
save和restore方法用于在用图像填充形状后将画布重置回其原始位置。如果不这样做,图像将从画布左上角的位置 0,0 绘制,而不是从形状左上角的位置 64,64 绘制。
绘制图像
如果您只想在画布上显示图像,上下文的drawImage方法是一种简单的方法。图像加载后,使用ctx.drawImage定义图像名称、其 x 位置和其 y 位置:
ctx.drawImage(imageObject, xPosition, yPosition);
下面是如何预加载一只猫的图像,并使用drawImage将其显示在画布中央。图 2-25 显示了结果。
//Load an image
let catImage = new Image();
catImage.addEventListener("load", loadHandler, false);
catImage.src = "img/cat.png";
//The loadHandler is called when the image has loaded
function loadHandler() {
ctx.drawImage(catImage, 64, 64);
}

图 2-25 。在画布上画一幅图像
像这样使用drawImage既快速又简单。但是对于大多数游戏项目来说,你会希望使用稍微灵活一点的方式来显示游戏图像,我们一会儿就会谈到这一点。首先,快速进入掩蔽。
遮蔽图像
一个面具就像一个窗框。蒙版下的任何图像将仅在蒙版区域内可见。超出蒙版区域的图像部分不会显示在框架之外。您可以使用clip方法将任何形状变成遮罩。
图 2-26 显示了我们的猫被一个圆形遮住的图像。

图 2-26 。使用clip方法将形状变成遮罩
要创建一个蒙版,画一个形状,然后使用clip方法,而不是stroke或fill。在clip之后绘制的图像或形状中的任何内容都将被遮罩。下面是生成图 2-26 中图像的代码:
//Draw the circle as a mask
ctx.beginPath();
ctx.arc(128, 128, 64, 0, Math.PI * 2, false);
ctx.clip();
//Draw the image
ctx.drawImage(catImage, 64, 64);
您可以像遮罩图像一样轻松地遮罩形状。
将图像传送到画布上
在画布上只显示图像的一部分非常有用。这一功能对于制作游戏来说非常重要,因为这意味着你可以将所有的游戏角色和对象存储在一个名为 tileset 或 sprite sheet 的图像文件中。然后,通过有选择地在画布上只显示和定位 tileset 中您需要的那些部分来构建您的游戏世界。这是一种真正快速且节省资源的渲染游戏图形的方式,称为 blitting 。
在下一个例子中,我们将使用一个包含许多游戏角色和对象的 tileset,并且只显示其中一个对象:一艘火箭船。图 2-27 显示了我们将要加载的 tileset,以及我们将要在画布上显示的单火箭船。

图 2-27 。将部分图像文件复制到画布上
代码使用了drawImage方法来完成这个任务。drawImage需要知道我们要显示的 tileset 部分的原点 x 和 y 位置,以及它的高度和宽度。然后,它需要知道目的地 x 、 y 、宽度和高度值,以便在画布上绘制图像。
//Load the tileset image
let tileset = new Image();
tileset.addEventListener("load", loadHandler, false);
tileset.src = "img/tileset.png";
//The loadHandler is called when the image has loaded
function loadHandler() {
ctx.drawImage(
tileset, //The image file
192, 128, //The source x and y position
64, 64, //The source height and width
96, 96, //The destination x and y position
64, 64 //The destination height and width
);
}
tileset.png图像文件为 384×384 像素。火箭船的图像向右 192 像素,向下 128 像素。这是它的 x 、 y 源位置。它的宽度和高度都是 64 像素,所以这是它的源宽度和高度值。火箭船被绘制到画布上的 x , y 位置为 96,使用相同的宽度和高度值(64)。这些是它的目标值。图 2-28 通过选择 tileset 的正确部分并将其显示在画布上,展示了这段代码是如何工作的。

图 2-28 。blitting 的工作原理
如果您不熟悉 blitting,可以在源文件的工作示例中使用这些数字,您会很快发现在画布上的任何位置以任何大小显示您想要的图像是多么容易。
注意“blit”一词来自“位块传输”,这是一个早期的计算机图形术语,指的是这种技术。
我们已经介绍了线条、形状和图像,现在让我们来看看画布拼图的最后一块:文本。
正文
画布绘制 API 几乎没有有用的属性和方法来帮助您绘制文本。下一个例子展示了如何使用它们来显示单词“Hello world!”用红色粗体字表示,位于画布中央。图 2-29 显示了以下代码产生的结果。

图 2-29 。在画布上显示文本
//Create a text string defines the content you want to display
let content = "Hello World!";
//Assign the font to the canvas context.
//The first value is the font size, followed by the names of the
//font families that should be tried:
//1st choice, fall-back font, and system font fall-back
ctx.font = "24px 'Rockwell Extra Bold', 'Futura', sans-serif";
//Set the font color to red
ctx.fillStyle = "red";
//Figure out the width and height of the text
let width = ctx.measureText(content).width,
height = ctx.measureText("M").width;
//Set the text's x/y registration point to its top left corner
ctx.textBaseline = "top";
//Use `fillText` to Draw the text in the center of the canvas
ctx.fillText(
content, //The text string
canvas.width / 2 - width / 2, //The x position
canvas.height / 2 - height / 2 //The y position
);
让我们来看看这是如何工作的。
画布上下文的font属性允许您使用以下格式定义字体大小和字体系列:
ctx.font = "24px 'Rockwell Extra Bold', 'Futura', sans-serif";
第一个字体系列名称 Rockwell Extra Bold 是应该使用的主要字体。如果由于某种原因它不可用,font 属性将返回到 Futura。如果 Futura 也不可用,将使用好的旧系统字体 sans-serif。
注意在这个例子中,我使用了一些可靠的网络安全字体,这些字体在所有现代网络浏览器上都可以找到。(你可以在cssfontstack.com找到网页安全字体列表。)在第三章中,你将学习如何使用 CSS @font-face 规则从文件中预加载自定义字体。
你可以使用measureText方法计算出文本的宽度,以像素为单位,如下所示:
width = ctx.measureText(content).width
在字体样式被分配后,你需要做这个,以便根据特定字体的字母大小和磅值正确测量文本。
measureText方法没有匹配的高度属性来告诉你文本的像素高度。相反,您可以使用这个非常可靠且广泛使用的方法来计算它:测量一个大写字母 M 的宽度:
height = ctx.measureText("M").width
令人惊讶的是,这与大多数字体的文本像素高度完全匹配。
您还需要定义一个文本基线,它告诉上下文文本的 x , y 0 点应该在哪里。要定义文本左上角的 x/y 点,将textBaseline设置为top:
ctx.textBaseline = "top";
将左上角设置为文本的 x 、 y 注册点,可以使文本的坐标与圆形、矩形和图像的坐标保持一致。
除了"top"之外,您可以分配给textBaseline的其他值有"hanging"、"middle"、"alphabetic"、"ideographic"和"bottom。这些选项比你可能需要的要多,但是图 2-30 说明了每个选项是如何影响文本对齐的。

图 2-30 。文本基线选项
fillText用于绘制特定 x/y 位置的字符串内容。如果您知道文本的宽度和高度,您可以使用fillText使文本在画布中居中,如下所示:
ctx.fillText(
content, //The text string
canvas.width / 2 - width / 2, //The x position
canvas.height / 2 - height / 2 //The y position
);
这些是你需要知道的在画布上显示文本的最重要的技术。
摘要
现在,您已经了解了使用画布绘制 API 绘制线条和形状的基本知识。画布绘制 API 是游戏设计师需要了解的最有用和最灵活的工具之一。它有一个简单的优雅,允许你用最少的代码创建大量的复杂性。现代 JavaScript 运行时系统(如 web 浏览器)使用 GPU 对画布绘制调用进行硬件加速,因此您会发现,即使与 WebGL 相比,画布绘制对于大多数 2D 动作游戏来说也足够快了。
绘图 API 是相当低级的,这意味着如果你想快速简单地在游戏中创建许多形状,你需要构建一些辅助函数和对象。但在我们学习如何做到这一点之前,让我们先来看看如何有效地加载图像,字体,数据文件和其他有用的素材到我们的游戏中。
三、使用游戏素材
一款游戏的素材是它使用的所有字体、声音、数据和图像文件。在这一章中,你将学习如何实现一个清晰的加载和管理素材的策略,以便在你的游戏代码中容易使用它们。您将学习如何创建一个assets对象来存储对所有游戏资源的引用,以及如何创建一个预加载器来加载资源并在一切就绪时初始化您的游戏。
游戏通常会使用大量的图像,如果你不知道如何处理这些图像,那么管理这些图像通常会是一件非常令人头疼的事情。但是不要害怕!游戏设计师有一个处理图像的秘密武器:纹理图谱。在本章的后半部分,你将会学到什么是纹理贴图集,以及如何用它来帮助你以一种有趣而有效的方式管理游戏图像。
素材对象
在这一章中,我们将构建一个名为assets的实用程序对象,它将成为游戏所有素材的中央仓库:图像、声音、字体、普通的 JSON 数据和代表纹理贴图集的 JSON 数据。在探索assets对象如何工作之前,让我们看看如何在你完成的程序中使用它。
对象有一个接受一个参数的方法:文件名字符串数组。在数组中列出所有要加载的文件名及其完整路径。当所有的素材都被加载后,load方法返回一个Promise,所以当一切就绪后,你可以调用一个setup函数来初始化你的游戏。下面是如何使用assets.load方法加载图像、字体和 JSON 文件,然后运行setup函数:
assets.load([
"img/cat.png",
"fonts/puzzler.otf",
"json/data.json"
]).then(() => setup());
function setup() {
//Initialize the game
}
只有在所有素材加载完毕后,setup功能才会运行。然后,您就可以在主游戏程序中的任何地方使用以下语法访问任何资源,如图像集:
let anyImage = assets["img/cat.png"];
您只需要构建这个素材加载器一次,然后就可以在任何游戏项目中使用它。它还被设计成易于定制,这样你就可以用它来加载你的游戏可能需要的任何类型的文件。
注意在本章中,你将学习如何配置素材加载器,以便它也可以加载声音文件。但是我们实际上不会编写加载声音的代码,直到第九章,在那里我们将在讨论 WebAudio API 时补充那些细节。
让我们来看看实现这一点的所有代码。
构建素材对象
起初,assets对象看起来很复杂,但是您很快就会看到,它只是遵循相同模式的单个组件的集合。这里是完整的代码清单,可以作为参考。现在不要担心去理解所有的事情;在前面的页面中,我将带您了解它是如何工作的,包括它如何解释 JSON 纹理图谱:
export let assets = {
//Properties to help track the assets being loaded
toLoad: 0,
loaded: 0,
//File extensions for different types of assets
imageExtensions: ["png", "jpg", "gif"],
fontExtensions: ["ttf", "otf", "ttc", "woff"],
jsonExtensions: ["json"],
audioExtensions: ["mp3", "ogg", "wav", "webm"],
//The `load` method creates and loads all the assets. Use it like this:
//`assets.load(["img/anyImage.png", "fonts/anyFont.otf"]);`
load(sources) {
//The `load` method will return a Promise when everything has loaded
return new Promise(resolve => {
//The `loadHandler` counts the number of assets loaded, compares
//it to the total number of assets that need to be loaded, and
//resolves the Promise when everything has loaded
let loadHandler = () => {
this.loaded += 1;
console.log(this.loaded);
//Check whether everything has loaded
if (this.toLoad === this.loaded) {
//Reset `toLoad` and `loaded` to `0` so you can use them
//to load more assets later if you need to
this.toLoad = 0;
this.loaded = 0;
console.log("Assets finished loading");
//Resolve the promise
resolve();
}
};
//Display a console message to confirm that the assets are
//being loaded
console.log("Loading assets...");
//Find the number of files that need to be loaded
this.toLoad = sources.length;
//Loop through all the source filenames and find out how
//they should be interpreted
sources.forEach(source => {
//Find the file extension of the asset
let extension = source.split(".").pop();
//Load images that have file extensions that match
//the imageExtensions array
if (this.imageExtensions.indexOf(extension) !== -1) {
this.loadImage(source, loadHandler);
}
//Load fonts
else if (this.fontExtensions.indexOf(extension) !== -1) {
this.loadFont(source, loadHandler);
}
//Load JSON files
else if (this.jsonExtensions.indexOf(extension) !== -1) {
this.loadJson(source, loadHandler);
}
//Load audio files
else if (this.audioExtensions.indexOf(extension) !== -1) {
this.loadSound(source, loadHandler);
//Display a message if a file type isn't recognized
else {
console.log("File type not recognized: " + source);
}
});
});
},
loadImage(source, loadHandler) {
//Create a new image and call the `loadHandler` when the image
//file has loaded
let image = new Image();
image.addEventListener("load", loadHandler, false);
//Assign the image as a property of the `assets` object so
//you can access it like this: `assets["path/imageName.png"]`
this[source] = image;
//Set the image's `src` property to start loading the image
image.src = source;
},
loadFont(source, loadHandler) {
//Use the font's filename as the `fontFamily` name
let fontFamily = source.split("/").pop().split(".")[0];
//Append an `@afont-face` style rule to the head of the HTML document
let newStyle = document.createElement("style");
let fontFace
= "@font-face {font-family: '" + fontFamily + "'; src: url('" + source + "');}";
newStyle.appendChild(document.createTextNode(fontFace));
document.head.appendChild(newStyle);
//Tell the `loadHandler` we're loading a font
loadHandler();
},
loadJson(source, loadHandler) {
//Create a new `xhr` object and an object to store the file
let xhr = new XMLHttpRequest();
//Use xhr to load the JSON file
xhr.open("GET", source, true);
//Tell xhr that it's a text file
xhr.responseType = "text";
//Create an `onload` callback function that
//will handle the file loading
xhr.onload = event => {
//Check to make sure the file has loaded properly
if (xhr.status === 200) {
//Convert the JSON data file into an ordinary object
let file = JSON.parse(xhr.responseText);
//Get the filename
file.name = source;
//Assign the file as a property of the assets object so
//you can access it like this: `assets["file.json"]`
this[file.name] = file;
//Texture atlas support:
//If the JSON file has a `frames` property then
//it's in Texture Packer format
if (file.frames) {
//Create the tileset frames
this.createTilesetFrames(file, source, loadHandler);
} else {
//Alert the load handler that the file has loaded
loadHandler();
}
}
};
//Send the request to load the file
xhr.send();
},
createTilesetFrames(file, source, loadHandler) {
//Get the tileset image's file path
let baseUrl = source.replace(/[^\/]*$/, "");
//Use the `baseUrl` and `image` name property from the JSON
//file's `meta` object to construct the full image source path
let imageSource = baseUrl + file.meta.image;
//The image's load handler
let imageLoadHandler = () => {
//Assign the image as a property of the `assets` object so
//you can access it like this:
//`assets["img/imageName.png"]`
this[imageSource] = image;
//Loop through all the frames
Object.keys(file.frames).forEach(frame => {
//The `frame` object contains all the size and position
//data for each sub-image.
//Add the frame data to the asset object so that you
//can access it later like this: `assets["frameName.png"]`
this[frame] = file.frames[frame];
//Get a reference to the source so that it will be easy for
//us to access it later
this[frame].source = image;
});
//Alert the load handler that the file has loaded
loadHandler();
};
//Load the tileset image
let image = new Image();
image.addEventListener("load", imageLoadHandler, false);
image.src = imageSource;
},
loadSound(source, loadHandler) {
console.log("loadSound called – see Chapter 10 for details");
}
};
您可以在本书源文件的“资源库/实用工具”文件夹中找到此代码。你可以像这样在游戏代码中导入并使用它作为 ES6 模块:
import {assets} from "../library/utilities";
现在让我们来看看这到底是如何工作的。
初始化加载过程
当你想加载一些文件到你的游戏中时,发送一个文件源路径数组到assets对象的load方法:
assets.load(["img/tileset.png", "fonts/puzzler.otf"]);
load方法首先使用数组的length来计算应该加载多少素材,并将结果复制到toLoad属性中。
load(sources) {
//...
this.toLoad = sources.length;
它现在知道您想要加载多少素材。
assets.load方法中的所有代码都包装在一个Promise中:
load(sources) {
return new Promise(resolve => {
//... all of the load function's code is here...
});
}
每次加载素材时,都会调用loadHandler 。它将1添加到loaded属性中。如果装入的素材数量与要装入的素材数量匹配,则承诺得到解决:
let loadHandler = () => {
this.loaded += 1;
console.log(this.loaded);
//Check whether everything has loaded
if (this.toLoad === this.loaded) {
//Reset `toLoad` and `loaded` to `0` so you can use them
//to load more assets later if you need to
this.toLoad = 0;
this.loaded = 0;
console.log("Assets finished loading");
//Resolve the promise
resolve();
}
};
但是在调用loadHandler之前,代码需要弄清楚您想要加载什么类型的素材。它首先遍历来自sources数组的每个文件源路径:
sources.forEach(source => {
let extension = source.split(".").pop();
forEach方法遍历每个源文件,并找到它的文件扩展名。它是怎么做到的?
首先,split将源字符串转换成一个新的数组。它通过在每个点(.)性格。因此,字符串中由点描绘的每一部分都将被转换为数组元素,并复制到新数组中:
source.split('.')
例如,假设我们的原始字符串如下所示:
"img/tileset.png"
split方法扫描字符串中的点。它将点左边的所有内容复制到一个数组元素中,将右边的所有内容复制到另一个数组元素中。这意味着源字符串现在位于如下所示的数组中:
["img/tileset", "png"]
我们已经成功了一半。我们只对文件扩展名"png"感兴趣。这是数组中的最后一个元素。我们怎样才能访问它?使用pop方法:
source.split('.').pop()
这一行“弹出”数组中的最后一个元素,非常方便的是文件扩展名。
当所有这些都完成后,名为extension的变量现在有了值"png"。但是代码如何知道png是一个图像文件呢?
assets对象有四个数组,存储每种文件类型的所有文件扩展名:
imageExtensions: ["png", "jpg", "gif"],
fontExtensions: ["ttf", "otf", "ttc", "woff"],
jsonExtensions: ["json"],
audioExtensions: ["mp3", "ogg", "wav", "webm"],
我们可以使用这些数组来计算出"png"是什么样的东西。使用数组的indexOf方法来帮助您做到这一点。
if (this.imageExtensions.indexOf(extension) !== -1) {
this.loadImage(source, loadHandler);
}
如果indexOf不能将"png"匹配到imageExtensions数组中的一个值,它将返回-1,表示“未找到”任何大于-1 的数字都意味着找到了匹配,因此"png"必须是一种图像类型。
注意或者,您可以使用 JavaScript ES6 的find方法来帮助您完成这项工作。
加载图像
如果extension指的是一幅图像,则loadImage函数运行:
loadImage(source, loadHandler) {
//Create a new image and call the `loadHandler` when the image
//file has loaded
let image = new Image();
image.addEventListener("load", loadHandler, false);
//Assign the image as a property of this `assets` object
this[source] = image;
//Set the image's `src` property to start loading the image
image.src = source;
},
loadImage做的第一件事是创建一个新的Image对象并设置图像的loadHandler:
let image = new Image();
image.addEventListener("load", loadHandler, false);
当图像完成加载后,它将调用assets对象上的loadHandler。(记住,loadHandler每运行一次,它就给loaded的值加 1。当所有素材加载完毕后,Promise被解析,所有加载完成。)
下一步是将这个Image对象存储在asset对象本身中,并且能够通过它的文件名和路径名来引用它。我们如何做到这一点?
这里有一点编程巫术,你会觉得非常有趣。记住,如果我们想在我们的主程序中访问一个图像,我们应该能够编写一些类似这样的代码:
assets["img/tileset.png"]
我们如何设置它?
在引用图像并与图像文件同名的assets对象上创建一个属性。以下是如何:
this[source] = image;
现在,您可以使用以下语法访问您加载的任何图像:
assets["img/rocket.png"]
assets["img/cat.png"]
assets["img/star.png"]
这种语法易于阅读和编写,它使我们不必为assets对象添加单独的搜索功能。
最后一步是通过设置图像的src属性开始加载图像:
image.src = source;
我们现在完成了图像,但是其他文件类型呢?
加载字体
字体造成了一个特殊的问题,因为与图像不同,没有内置的 HTML5 API 来强制加载字体。在 CSS @font-face规则的帮助下,你能做的最好的事情就是链接到你想要使用的字体文件。下面是方法:
@font-face {
font-family: "fontFamilyName";
src: url("fonts/fontFile.ttf");
}
但是这段代码实际上并没有加载字体;它只是告诉浏览器在哪里可以找到它。所有的浏览器只会在页面上使用字体时才下载,以前从来不会。这意味着任何玩你的游戏的人都可能会在字体加载前看到一个短暂的无样式文本闪烁。不幸的是,在撰写本文时,还没有新的 HTML5 规范来帮助解决这个问题。(但是如果你是在这本书出版后很久的某个快乐的未来时间读到这篇文章的,请仔细检查一下!HTML5 规范现在可能已经包含了这一点。)
如果你不认为这将是一个问题,那么不要担心预加载字体,只需使用@font-face。结案了。
但是如果你想要更多的控制,使用开源字体加载器。所有的预加载器都以同样的方式工作。他们创建一些不可见的 HTML 文本,对其进行样式化,并使用一些编程技巧来计算字体文件何时被加载。字体预加载器的一个好选择是开源项目 font.js ( github.com/Pomax/Font.js)。这是一个轻量级的、久经沙场的脚本,它允许您使用与加载图像相同的语法来加载字体。要使用 font.js,下载它并在游戏中加入一个script标签:
<script src="Font.js"></script>
然后,您可以像这样加载字体:
let anyFont = new Font();
anyFont.src = "fonts/fileName.ttf";
anyFont.onload = function () {
console.log("font loaded");
}
语法就像加载图像一样。如果这对你有用,那就去做吧!但是本着这本书的 DIY 精神,我们不会走那条路。相反,我们将使用一个小技巧来满足游戏的大部分字体加载需求,而不需要第三方脚本。
如果assets.load方法检测到我们试图加载一个带有字体扩展名("ttf"、"otf"、"ttc"或"woff")的文件,它会调用loadFont方法,该方法只是将一个@font-face规则写入并附加到 HTML 文档中:
loadFont(source, loadHandler) {
//Use the font's filename as the `fontFamily` name. This code captures
//the font file's name without the extension or file path
let fontFamily = source.split("/").pop().split(".")[0];
//Append an `@afont-face` style rule to the head of the HTML document
let newStyle = document.createElement("style");
let fontFace
= "@font-face {font-family: '" + fontFamily + "'; src: url('" + source + "');}";
newStyle.appendChild(document.createTextNode(fontFace));
document.head.appendChild(newStyle);
//Tell the loadHandler we're loading a font
loadHandler();
},
loadFont做的第一件事是在完整的源路径中找到字体的名称:
let fontFamily = source.split("/").pop().split(".")[0];
我知道,这些代码看起来像是精神病院墙上的涂鸦!但是,嘿,我们应该是成年人——我们可以接受的!它所做的只是提取最后一个斜杠(/)之后和最后一个点()之前的字母。)性格。例如,假设您的字体源路径如下所示:
"fonts/puzzler.otf"
fontFamily现在有了这个值:
"puzzler"
之前/之后的一切。不见了。
代码做的下一件事是将一个@font-face规则写入 HTML 页面的<head>部分:
let newStyle = document.createElement("style");
let fontFace
= "@font-face {font-family: '" + fontFamily + "'; src: url('" + source + "');}";
newStyle.appendChild(document.createTextNode(fontFace));
document.head.appendChild(newStyle);
如果您使用的是名为puzzler.otf 的字体,这些行将编写以下 HTML 和 CSS 代码:
<style>
@font-face {
font-family: 'puzzler';
src: url('fonts/puzzler.otf');
}
</style>
正如我前面提到的,这段代码实际上不会加载字体文件;它只是告诉浏览器,当 HTML 元素或画布请求字体时,在哪里查找字体。但是正如你将在下一章看到的,这对于游戏来说很少是个问题。因为游戏是在一个连续的循环中渲染的,所以你使用的任何自定义字体通常会在每一帧中被连续请求。这意味着它们应该在其他资源加载时被加载和渲染,在大多数情况下,您可能看不到未样式化的文本。
注意如果你不是在循环中请求自定义字体,字体文件几乎肯定不会加载。在这种情况下,请使用 font.js 或等效的字体预加载程序,让您的生活更轻松。
正如加载图像的代码一样,loadFont方法做的最后一件事是调用loadHandler :
loadHandler();
这表明该字体已经被加载,如果它是最后一个加载的素材,则解析load方法的承诺。
现在您已经知道如何加载图像和字体,让我们来看看如何加载 JSON 数据文件。
加载 JSON 文件
在第一章中,你学习了如何使用 XHR 加载和解析 JSON 文件。如果assets.load方法检测到您正试图加载一个 JSON 文件,它将使用大部分相同的代码来加载该文件。
loadJson(source, loadHandler) {
//Create a new XHR object
let xhr = new XMLHttpRequest();
xhr.open("GET", source, true);
xhr.responseType = "text";
xhr.onload = event => {
if (xhr.status === 200) {
let file = JSON.parse(xhr.responseText);
file.name = source;
this[file.name] = file;
//If the JSON file has a `frames` property then
//it's in Texture Packer format
if (file.frames) {
this.createTilesetFrames(file, source, loadHandler);
} else {
loadHandler();
}
}
};
//Send the request to load the file
xhr.send();
},
JSON 文件加载并解析成功后,代码在assets对象上添加对它的引用:
file.name = source;
this[file.name] = file;
这意味着我们可以在稍后的应用程序代码中获得对 JSON 对象的引用,如下所示:
assets["json/data.json"]
loadJson函数还做了一件额外的事情。它检查 JSON 文件是否有一个名为frames的属性。如果是,那么 JSON 文件一定是纹理图谱,函数调用createTilesetFrames方法:
if (file.frames) {
this.createTilesetFrames(file, source, loadHandler);
} else {
loadHandler();
}
什么是纹理图谱,createTilesetFrames是做什么的?来看看吧!
使用纹理图谱
如果你正在开发一个大型复杂的游戏,你会想要一个快速有效的方法来处理图像。一个纹理图谱可以帮你做到这一点。纹理贴图集实际上由两个密切相关的独立文件组成:
- 一个 PNG tileset image 文件,包含所有你想在游戏中使用的图像
- 描述 tileset 中这些子图像的大小和位置的 JSON 文件
要使用纹理贴图集,通常需要将 JSON 文件加载到游戏中,并使用它包含的数据为它定义的每个子图像自动创建单独的对象。每个对象都包含子图像的 x 、 y 、宽度、高度和名称,您可以使用这些信息将图像从 tileset 传送到画布。
使用纹理地图可以节省大量时间。您可以按任何顺序排列 tileset 的子图像,JSON 文件将为您跟踪它们的大小和位置。这真的很方便,因为这意味着子图像的大小和位置不会硬编码到你的游戏程序中。如果您对 tileset 进行了更改,比如添加图像、调整图像大小或删除图像,只需重新发布 JSON 文件,您的游戏就会使用更新后的数据来正确显示图像。如果你要制作比一个非常小的游戏更大的东西,你肯定会想要使用纹理地图。
tileset JSON 数据事实上的标准是由一个流行的软件工具 Texture Packer 输出的格式。即使不使用 Texture Packer,Shoebox 之类的类似工具也输出相同格式的 JSON 文件。下面我们来了解一下如何用它制作一个带有纹理打包器的纹理图谱,以及如何加载到游戏程序中。
注意 Texture Packer 的“Essential”许可是免费的,可以在www.codeandweb.com下载。
创建纹理图谱
打开纹理打包器,选择{JS}配置选项。将你的游戏图片拖到它的工作区。您也可以将它指向包含您的图像的任何文件夹。纹理打包器会自动将这些图像排列成一个单独的 tileset 图像,并给它们起一个与原始图像名称相匹配的名称。默认情况下,它会给它们一个 2 像素的填充。图 3-1 显示了由三个图像组成的 tileset。

图 3-1 。使用纹理打包器创建纹理图谱
完成后,确保数据格式设置为 JSON (Hash ),然后单击发布按钮。选择文件名和位置,并保存发布的文件。您将得到一个 PNG 文件和一个 JSON 文件。在这个例子中,我的文件名是animals.json和animals.png。为了让您的生活更轻松,只需将这两个文件都保存在项目的 images 文件夹中。(将 JSON 文件视为图像文件的额外元数据)。
JSON 文件描述了 tileset 中每个图像的名称、大小和位置。下面是描述 tileset 的完整 JSON 数据:
{"frames": {
"cat.png":
{
"frame": {"x":2,"y":2,"w":128,"h":128},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {"x":0,"y":0,"w":128,"h":128},
"sourceSize": {"w":128,"h":128},
"pivot": {"x":0.5,"y":0.5}
},
"hedgehog.png":
{
"frame": {"x":132,"y":2,"w":128,"h":128},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {"x":0,"y":0,"w":128,"h":128},
"sourceSize": {"w":128,"h":128},
"pivot": {"x":0.5,"y":0.5}
},
"tiger.png":
{
"frame": {"x":262,"y":2,"w":128,"h":128},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {"x":0,"y":0,"w":128,"h":128},
"sourceSize": {"w":128,"h":128},
"pivot": {"x":0.5,"y":0.5}
}},
"meta": {
"app": "http://www.codeandweb.com/texturepacker",
"version": "1.0",
"image": "animals.png",
"format": "RGBA8888",
"size": {"w":392,"h":132},
"scale": "1",
"smartupdate": "$TexturePacker:SmartUpdate:
a196e3e7dc7344bb1ddfbbb9ed914f90:
06a75246a1a4b65f2beacae47a5e81d7:
b00d48b51f56eb7c81e25100fcce2828$"
}
}
您可以看到该文件包含三个主要对象,名为"cat.png"、"hedgehog.png"和"tiger.png"。这些子图像中的每一个都被称为一个帧,它的第一组属性描述了子图像在 tileset 上的位置。还有一个名为“meta”的对象,它告诉您这些帧所属图像的名称和大小,以及其他一些信息。
注意使用像 Texture Packer 这样的软件来构建纹理地图的许多优点之一是,默认情况下,它会在每张图像周围添加 2 个像素的填充。这对于防止纹理出血的可能性很重要,这是一种当 tileset 上相邻图像的边缘出现在 sprite 旁边时发生的效果。发生这种情况是因为系统的渲染器(GPU 或 CPU)决定如何舍入分数像素值。应该向上还是向下取整?对于每个渲染器,这种选择是不同的。在 tileset 上的图像周围添加 1 或 2 像素的填充可以使所有图像显示一致。
加载纹理贴图集
现在我们已经得到了这些信息,我们如何将它加载到我们的游戏代码中呢?您会注意到 JSON 文件中的第一个属性叫做"frames":
{"frames": {
当assets.load方法加载任何 JSON 文件时,它检查文件的第一个属性。如果恰好是"frames",那么你知道你正在加载一个纹理打包格式的文件,你可以使用createTilesetFrames方法来解释它:
if (file.frames) {
this.createTilesetFrames(file, source, loadHandler);
} else {
loadHandler();
}
createTilesetFrames是做什么的?它遍历 JSON 文件中的所有frame对象,并将它们添加到assets中,以便您可以稍后在代码中访问它们。它还加载 tileset 图像文件:
createTilesetFrames(file, source, loadHandler) {
//Get the tileset image's file path
let baseUrl = source.replace(/[^\/]*$/, "");
//Use the `baseUrl` and `image` name property from the JSON
//file's `meta` object to construct the full image source path
let imageSource = baseUrl + file.meta.image;
//The image's load handler
let imageLoadHandler = () => {
//Assign the image as a property of the `assets` object so
//you can access it like this:
//`assets["img/imageName.png"]`
this[imageSource] = image;
//Loop through all the frames
Object.keys(file.frames).forEach(frame => {
//The `frame` object contains all the size and position
//data for each subimage.
//Add the frame data to the asset object so that you
//can access it later like this: `assets["frameName.png"]`
this[frame] = file.frames[frame];
//Get a reference to the source so that it will be easy for
//you to access it later
this[frame].source = image;
});
//Alert the load handler that the file has loaded
loadHandler();
};
//Load the tileset image
let image = new Image();
image.addEventListener("load", imageLoadHandler, false);
image.src = imageSource;
}
代码首先指出 tileset 图像的文件路径是什么。因为 JSON 文件和 PNG 文件存储在同一个文件夹中,所以它们都有相同的路径名。这意味着您可以通过提取source字符串中除图像名称之外的所有内容来找到图像的基本文件路径。createTilesetFrames的第一部分使用正则表达式和replace方法来查找文件名并用空字符串替换它:
let baseUrl = source.replace(/[^\/]*$/, "");
如果source字符串是"img/animals.json",那么baseUrl现在将具有值"img/"。
注意在source字符串语法中,[^\/]指任何不是斜杠的字符。跟在它后面的*匹配任意数量的字符,而$指的是字符串的结尾。这意味着正则表达式将匹配字符串末尾非斜杠的任何字符。要了解更多正则表达式,我最喜欢的资源是http:qntm.org/files/re/re.html的“55 分钟左右学会正则表达式”。
现在我们知道了 tileset 图像的文件路径,我们可以使用 JSON 文件中的meta.image属性来构建完整的图像源:
let imageSource = baseUrl + file.meta.image;
现在我们有了对图像源的引用,我们可以像加载任何其他图像一样加载它,并在完成后调用imageLoadHandler:
let image = new Image();
image.addEventListener("load", imageLoadHandler, false);
image.src = imageSource;
imageLoadHandler通过 JSON 文件中的每个帧对象循环,并将对它们的引用存储在assets对象中。:
let imageLoadHandler = () => {
this[imageSource] = image;
Object.keys(file.frames).forEach(frame => {
this[frame] = file.frames[frame];
this[frame].source = image;
});
loadHandler();
};
每个frame对象还在一个名为source的属性中获得对其所属 tileset 的引用。这将使我们在游戏代码中更容易将框架与其 tileset 相关联。当所有这些都完成后,代码调用assets.load方法的loadHandler来通知素材对象 JSON 文件及其关联的 PNG 文件已经加载。
在你的游戏代码中加载和使用纹理贴图
那么,这一切最终给我们带来了什么?
这意味着你可以用下面的语法将纹理贴图加载到你的游戏代码中:
assets.load([
"img/animals.json"
]).then(() => setup());
然后,您可以像这样访问 JSON 对象和 PNG 图像文件:
assets["img/animals.json"]
assets["img/animals.png"]
您可以像这样访问 JSON 文件中的每个单独的帧:
assets["cat.png"]
assets["tiger.png"]
assets["hedgehog.png"]
这些是包含每个子图像的大小和位置信息的帧对象。现在,您可以使用该信息从 tileset 图像中提取子图像,并将它们复制到画布上。
我们如何做到这一点?这就是下一章要讲的!
摘要
现在,您已经掌握了在游戏中加载和管理图像、字体和纹理贴图集所需的所有技能。我们已经创建了一个有用的assets对象,它以一种易于使用的格式存储您所有的游戏素材。您还了解了如何构建一个相当复杂的微型应用程序,它使用承诺来通知您的程序何时工作完成。
这只是一个起点;你可以根据游戏的具体需求以任何方式定制assets对象。如果您需要加载其他类型的文件,比如视频,只需将文件扩展名添加到extensions数组中,并编写您自己的定制加载函数来管理加载。在第九章的中,我们将再次访问assets对象,并学习如何定制它来加载与 WebAudio API 兼容的声音文件。
在第二章中,你学习了如何在画布上绘制和显示游戏的基本图形,在这一章中,你学习了如何加载外部文件。在下一章中,你将学习如何将这两种技能结合起来,并使用它们来构建可重用的游戏组件,称为精灵。
四、制作精灵和场景图
游戏设计师的基本构建模块是精灵。精灵是你在屏幕上移动、制作动画或与之互动的任何图像、形状或文本。在这一章中,你将学习如何从头开始制作精灵,然后,在接下来的章节中,你将学习如何移动他们,使他们互动,添加一些碰撞检测,并使用他们来构建一个游戏。
我们将要构建的精灵系统的一个重要特性是,你可以将精灵组合在一起,制作复合物体和游戏场景。每个精灵都有自己的局部坐标系,因此如果移动、缩放或旋转精灵,它包含的任何嵌套子精灵都将随之移动、缩放或旋转。这是一个叫做 场景图的特性:嵌套精灵的层次结构。正如你将看到的,这是一个很容易实现的特性,给你很大的灵活性来制作复杂的游戏显示系统。
到本章结束时,你将有一个简单而强大的方法来显示形状、图像、线条和文本,它们将成为制作游戏最重要的组成部分。
注意在这一章中,我们将构建一个精灵显示系统,它严格模仿经典的 Flash API,但是有一些新的变化。Flash API 是大多数现代 2D 精灵渲染系统的基础,包括 Starling、Sparrow、CreateJS 和 Pixi,所以如果你想知道这些 API 是如何在幕后发挥其魔力的,本章将向你展示。
精灵是什么?
在第二章中,你学习了如何使用画布绘制 API 制作基本的形状和线条。这个 API 被描述为一个低级 API 。这意味着您可以控制代码的非常小的细节,以您喜欢的任何方式将它定制到一个很好的程度。这很好,但缺点是创建非常简单的东西需要大量代码。例如,如果你想画一个矩形,旋转它,给它一点透明度,你必须写 13 行代码,像这样:
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(128, 128, 128, 1)";
ctx.save();
ctx.globalAlpha = 0.5;
ctx.translate(128, 128);
ctx.rotate(0.5);
ctx.beginPath();
ctx.rect(-64, -64, 128, 128);
ctx.closePath();
ctx.stroke();
ctx.fill();
ctx.restore();
花了 13 行费力的代码,就为了做一个简单的矩形?更糟糕的是,你必须为每一个矩形重复这 13 行代码。忘记构建这样的游戏吧!
有更好的办法!你可以通过使用一种叫做抽象的重要编程技巧来解决这个问题。抽象是一种隐藏代码中所有混乱细节的策略,这样你就可以只处理大的、重要的想法。因此,你可能只需要编写两行高级代码,而不必编写 13 行冗长的低级代码。你可以这样把这 13 行抽象成两行:
let box = rectangle();
render(canvas);
可读性更强,不是吗?你怎么能这样做?
注低级代码和高级代码有什么区别?低级代码往往是告诉计算机如何做某事的一系列指令。高级代码往往是描述做什么的指令列表。写一个好的游戏程序是关于在低级和高级代码之间保持一个健康的平衡。您需要理解并访问底层代码,以便在出错时进行修复。但是你想用尽可能多的高层次代码来完成你的创造性工作,这样你就不会被混乱的低层次细节所拖累。找出完美的高层次/低层次的平衡需要实践,而且每个项目都不一样。
第一项任务是仔细查看底层代码,并尝试找出是否可以将其组织到不同的工作中。下面是我们的矩形代码正在做的两个主要工作:
- 描述矩形:其高度、宽度、位置、旋转、透明度。
- 渲染矩形:在画布上显示矩形。
在我们当前的 13 行代码中,这两项工作都混杂在一个大混乱中。这就是程序员所说的意大利面代码。我们需要解开所有的面条,这样我们就可以将它们分类成合理的和可重用的组件。在大多数游戏项目中,你会有三根意大利面条需要解开:游戏信息,游戏逻辑,游戏渲染系统。
游戏开发者有一个很好的方法来保持线程分离,那就是制作叫做精灵的组件。在接下来的几节中,你将学习如何制作一个游戏精灵,并且在这个过程中,学习如何解开任何种类的代码。
抽象将是我们本章剩余部分的指导原则,你将看到我们如何用它来解决一些复杂的问题,以便我们可以快速开始制作游戏。
制作矩形精灵
让我们从一个非常小而简单的例子开始,这样你就可以大致了解一个基本的 sprite 系统是如何工作的。我们将构建一个只显示一个矩形的极简主义精灵。您将能够定义矩形的形状、大小、颜色和位置,并根据您的喜好复制任意多的副本。
children阵列
首先,创建一个数组来保存所有要创建的精灵:
let children = [];
每次你创建一个新的精灵,你会把它放入这个children数组。正如你将看到的,这将使你更容易有效地渲染精灵。
这个数组为什么叫children?把你的主游戏想象成一个大容器。每次你创建一个精灵,它都会存在于这个大容器中。容器是父容器,容器中的所有东西都是父容器的子容器。在后面的步骤中,你会看到我们将如何扩展这个概念,为我们所有的精灵建立一个方便的父子层次结构。
rectangle雪碧
下一个任务是编写一个函数,创建并返回一个抽象的矩形精灵。该函数应该接受所有你想在游戏代码中控制的精灵参数:大小、位置和颜色。您还应该能够设置它的 alpha、旋转、缩放和可见性。因为你可能想要移动矩形精灵,我们还将添加代表精灵速度的vx和vy属性。(你将在第五章中了解所有关于速度以及vx和vy如何工作的知识。)sprite 对象也应该有自己的内部render函数,描述画布绘制 API 应该如何绘制矩形。rectangle 函数要做的最后一件事是将 sprite 推入到children数组中,以便我们稍后可以访问它。下面是完成这一切的完整的rectangle函数:
let rectangle = function(
//Define the function's parameters with their default values
width = 32,
height = 32,
fillStyle = "gray",
strokeStyle = "none",
lineWidth = 0,
x = 0,
y = 0
) {
//Create an object called `o` (the lowercase letter "o")
//that is going to be returned by this
//function. Assign the function's arguments to it
let o = {width, height, fillStyle, strokeStyle, lineWidth, x, y};
//Add optional rotation, alpha, visible, and scale properties
o.rotation = 0;
o.alpha = 1;
o.visible = true;
o.scaleX = 1;
o.scaleY = 1;
//Add `vx` and `vy` (velocity) variables that will help us move the sprite
o.vx = 0;
o.vy = 0;
//Add a `render` method that explains how to draw the sprite
o.render = ctx => {
ctx.strokeStyle = o.strokeStyle;
ctx.lineWidth = o.lineWidth;
ctx.fillStyle = o.fillStyle;
ctx.beginPath();
ctx.rect(-o.width / 2, -o.height / 2, o.width, o.height);
if (o.strokeStyle !== "none") ctx.stroke();
ctx.fill();
};
//Push the sprite object into the `children` array
children.push(o);
//Return the object
return o;
};
大部分代码都是不言自明的,但是有一个新东西您可能以前没有见过。该函数使用 ES6 的 object 文字简写来方便地将函数参数分配给函数返回的对象:
let o = {width, height, fillStyle, strokeStyle, lineWidth, x, y};
在 ES6 中,如果对象的属性名与其值相同,则不需要指定值。所以前面的语句相当于这样写:
let o = {
width: width,
height: height,
fillStyle: fillStyle,
strokeStyle: strokeStyle,
lineWidth: lineWidth,
x: x,
y: y
};
该代码只是创建一个对象,该对象的属性的名称与函数的参数值相同。
还有一种使用Object.assign 编写代码的替代方法,这在许多情况下可能更好:
let o = {};
Object.assign(
o,
{width, height, fillStyle, strokeStyle, lineWidth, x, y}
);
使用Object.assign的优点是它在对象上创建了全新的属性和值,而不仅仅是指向现有对象的指针引用。
render功能
现在你有了一个创建精灵的函数,你需要一个全局render函数来显示它们。render函数的工作是遍历children数组中的所有对象,并使用每个精灵自己的内部render函数在画布上绘制形状。该函数仅在 sprite 可见的情况下绘制 sprite,并将画布的属性设置为与 sprite 的属性相匹配。代码如下:
function render(canvas, ctx) {
//Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
//Loop through each sprite object in the `children` array
children.forEach(sprite => {
displaySprite(sprite);
});
function displaySprite(sprite) {
//Display a sprite if it's visible
if (sprite.visible) {
//Save the canvas's present state
ctx.save();
//Shift the canvas to the sprite's position
ctx.translate(
sprite.x + sprite.width / 2,
sprite.y + sprite.height /2
);
//Set the sprite's `rotation`, `alpha` and `scale`
ctx.rotate(sprite.rotation);
ctx.globalAlpha = sprite.alpha;
ctx.scale(sprite.scaleX, sprite.scaleY);
//Use the sprite's own `render` method to draw the sprite
sprite.render(ctx);
//Restore the canvas to its previous state
ctx.restore();
}
}
}
我们现在已经准备好制作精灵了。开始吧!
制造精灵
这里有一些代码使用我们新的rectangle和render函数来制作和显示三个矩形精灵,每个精灵都有不同的属性值。图 4-1 显示了这段代码产生的结果。rectangle构造函数参数代表宽度、高度、填充颜色、笔画(轮廓)颜色、轮廓宽度、 x 位置、 y 位置。您还可以设置精灵的alpha、scaleX、scaleY、旋转和visible属性。

图 4-1 。三个矩形精灵
let blueBox = rectangle(64, 64, "blue", "none", 0, 32, 32);
blueBox.rotation = 0.2;
let redBox = rectangle(64, 64, "red", "black", 4, 160, 100);
redBox.alpha = 0.5;
redBox.scaleY = 2;
let greenBox = rectangle(64, 64, "yellowGreen", "black", 2, 50, 150);
greenBox.scaleX = 0.5;
greenBox.rotation = 0.8;
//Render the sprites
render(canvas, ctx);
您可以使用相同的格式来制作任意数量的矩形,并按照您喜欢的方式自定义它们的大小、位置和颜色。你可能会惊讶地发现,我们刚刚打开了用 HTML5 和 JavaScript 制作游戏的最重要的大门。即使你没有比制作这些基本的矩形精灵更进一步,你也能够使用本书剩余部分的技术开始制作游戏。但是,我们可以做得更好!
构建场景图
下一步是创建一个系统,你可以将一个精灵嵌套在另一个精灵中。嵌套的 sprite 是其父容器 sprite 的子。每当父 sprite 改变其比例、位置、旋转或 alpha 透明度时,子 sprite 应该与该改变相匹配。子精灵在父精灵中也有自己的局部坐标系。这个系统叫做场景图,它是制作游戏场景和用不同组件创建复杂精灵的基础。图 4-2 展示了一个基本的父子精灵关系。

图 4-2 。嵌套的父子精灵层次结构
创建场景图需要一点规划。首先,你的精灵需要这些新属性:
- children :一个数组,存储对 sprite 包含的所有子 sprite 的引用。
- parent :对这个 sprite 的父级的引用。
- gx 和 gx :精灵的全局 x 和 y 坐标,相对于画布。
- x 和 y :精灵的局部坐标,相对于它的父对象。
- 层:一个数字,表示精灵的深度层。您可以通过更改子画面的深度层,使子画面显示在其他子画面的上方或下方。
精灵还需要两种新方法来帮助你管理它们的父子关系:
- addChild :让你添加一个精灵作为父精灵的子精灵。
addChild只是将一个 sprite 推入父元素的children数组中。 - removeChild :让你从父精灵中移除一个子精灵。
此外,您需要一个根容器对象,作为游戏中所有顶级精灵的父对象:
- stage:
stage是位置为 0,0 的对象,与画布的宽度和高度相同。它有一个包含游戏中所有顶级精灵的children数组。当你渲染你的精灵时,你可以通过循环舞台的children数组来实现。
最后,您需要一个新的render函数,它遍历 stage 对象中的所有子精灵,然后递归遍历这些子精灵的所有子精灵。让我们来看看实现所有这些所需的新代码。
创建可嵌套的矩形精灵
有了所有这些新特性,我们的矩形精灵的代码如下所示。
let rectangle = function(
width = 32, height = 32,
fillStyle = "gray", strokeStyle = "none", lineWidth = 0,
x = 0, y = 0
) {
//Create an object called `o` that is going to be returned by this
//function. Assign the function's arguments to it
let o = {width, height, fillStyle, strokeStyle, lineWidth, x, y};
//Create a "private" `_layer` property. (Private properties are prefixed
//by an underscore character.)
o._layer = 0;
//The sprite's width and height
o.width = width;
o.height = height
//Add optional rotation, alpha, visible and scale properties
o.rotation = 0;
o.alpha = 1;
o.visible = true;
o.scaleX = 1;
o.scaleY = 1;
//Add `vx` and `vy` (velocity) variables that will help us move
//the sprite in later chapters
o.vx = 0;
o.vy = 0;
//Create a `children` array on the sprite that will contain all the
//child sprites
o.children = [];
//The sprite's `parent` property
o.parent = undefined;
//The `addChild` method lets you add sprites to this container
o.addChild = sprite => {
//Remove the sprite from its current parent, if it has one and
//the parent isn't already this object
if (sprite.parent) {
sprite.parent.removeChild(sprite);
}
//Make this object the sprite's parent and
//add it to this object's `children` array
sprite.parent = o;
o.children.push(sprite);
};
//The `removeChild` method lets you remove a sprite from its
//parent container
o.removeChild = sprite => {
if(sprite.parent === o) {
o.children.splice(o.children.indexOf(sprite), 1);
} else {
throw new Error(sprite + "is not a child of " + o);
}
};
//Add a `render` method that explains how to draw the sprite
o.render = ctx => {
ctx.strokeStyle = o.strokeStyle;
ctx.lineWidth = o.lineWidth;
ctx.fillStyle = o.fillStyle;
ctx.beginPath();
ctx.rect(-o.width / 2, -o.height / 2, o.width, o.height);
if (o.strokeStyle !== "none") ctx.stroke();
ctx.fill();
};
//Getters and setters for the sprite's internal properties
Object.defineProperties(o, {
//The sprite's global x and y position
gx: {
get() {
if (o.parent) {
//The sprite's global x position is a combination of
//its local x value and its parent's global x value
return o.x + o.parent.gx;
} else {
return o.x;
}
},
enumerable: true, configurable: true
},
gy: {
get() {
if (o.parent) {
return o.y + o.parent.gy;
} else {
return o.y;
}
},
enumerable: true, configurable: true
},
//The sprite's depth layer. Every sprite and group has its depth layer
//set to `0` (zero) when it's first created. If you want to force a
//sprite to appear above another sprite, set its `layer` to a
//higher number
layer: {
get() {
return o._layer;
},
set(value) {
o._layer = value;
if (o.parent) {
//Sort the sprite's parent's `children` array so that sprites with a
//higher `layer` value are moved to the end of the array
o.parent.children.sort((a, b) => a.layer - b.layer);
}
},
enumerable: true, configurable: true
}
});
//Add the object as a child of the stage
if (stage) stage.addChild(o);
//Return the object
return o;
};
这段代码的一个新特性是它使用了一个名为_layer的私有属性:
o._layer = 0;
按照惯例,私有属性总是以下划线字符为前缀。下划线表示您不应该直接在主游戏代码中更改该属性,而只能通过 getter/setter 来访问或更改它。这是因为对象在返回值之前可能需要验证一个值或进行一些计算。在我们的矩形精灵中,你可以看到layer getter/setter 作为私有_layer值的接口。(在前面几页中,你会了解到_layer是如何改变精灵的深度层的。)
矩形精灵还有一个 getter/setter,用于精灵的gx和gy属性。这些告诉你精灵的全局位置,相对于画布的左上角。精灵的全局位置就是它的局部位置加上它的父对象的全局位置。
舞台和画布
我们需要创建一个名为stage 的对象,作为所有精灵的根父对象。在这个例子中,stage只是一个简单的对象,它有一些重要的属性,我们需要显示它的子精灵。
let stage = {
x: 0,
y: 0,
gx: 0,
gy: 0,
alpha: 1,
width: canvas.width,
height: canvas.height,
parent: undefined,
//Give the stage `addChild` and `removeChild` methods
children: [],
addChild(sprite) {
this.children.push(sprite);
sprite.parent = this;
},
removeChild(sprite) {
this.children.splice(this.children.indexOf(sprite), 1);
}
};
创建 canvas 元素和绘制上下文是一项非常常见的任务,因此让一个可重用的函数来为您完成这项任务是很有价值的。下面是 makeCanvas函数,它创建 canvas 元素,将其添加到 HTML 文档中,并创建绘图上下文:
function makeCanvas(
width = 256, height = 256,
border = "1px dashed black",
backgroundColor = "white"
) {
//Make the canvas element and add it to the DOM
let canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.style.border = border;
canvas.style.backgroundColor = backgroundColor;
document.body.appendChild(canvas);
//Create the context as a property of the canvas
canvas.ctx = canvas.getContext("2d");
//Return the canvas
return canvas;
}
下面是如何使用makeCanvas创建一个 512 × 512 像素的新画布元素:
let canvas = makeCanvas(512, 512);
为了方便起见,makeCanvas创建绘图上下文作为canvas的属性,这样您就可以像这样访问它:
canvas.ctx
现在让我们看看如何在画布上渲染精灵。
新的render功能
render函数首先遍历stage对象的children数组中的所有精灵。如果一个精灵的visible属性是true,这个函数使用我们在本章前面使用的相同代码显示这个精灵。sprite 显示后,代码检查 sprite 是否有自己的子级。如果是这样,代码会将绘制上下文重新定位到父 sprite 的左上角,并通过递归调用displaySprite函数来绘制子 sprite。
function render(canvas) {
//Get a reference to the drawing context
let ctx = canvas.ctx;
//Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
//Loop through each sprite object in the stage's `children` array
stage.children.forEach(sprite => {
displaySprite(sprite);
});
function displaySprite(sprite) {
//Display a sprite if it's visible
if (sprite.visible) {
//Save the canvas's present state
ctx.save();
//Shift the canvas to the center of the sprite's position
ctx.translate(
sprite.x + (sprite.width / 2),
sprite.y + (sprite.height / 2)
);
//Set the sprite's `rotation`, `alpha`, and `scale`
ctx.rotate(sprite.rotation);
ctx.globalAlpha = sprite.alpha * sprite.parent.alpha;
ctx.scale(sprite.scaleX, sprite.scaleY);
//Use the sprite's own `render` method to draw the sprite
sprite.render(ctx);
//If the sprite contains child sprites in its
//`children` array, display them by recursively calling this very same
//`displaySprite` function again
if (sprite.children && sprite.children.length > 0) {
//Reset the context back to the parent sprite's top-left corner
ctx.translate(-sprite.width / 2, -sprite.height / 2);
//Loop through the parent sprite's children
sprite.children.forEach(child => {
//display the child
displaySprite(child);
});
}
//Restore the canvas to its previous state
ctx.restore();
}
}
}
这段代码的一个新特性是精灵的 alpha 透明度被设置为相对于其父级的 alpha:
ctx.globalAlpha = sprite.alpha * sprite.parent.alpha;
因此,如果子对象的 alpha 为 0.5,而其父对象的 alpha 也为 0.5,则子对象的 alpha 将为 0.25。这种技术使嵌套对象的透明度以你认为它应该自然的方式表现:如果你改变父对象的透明度,子对象的透明度将按比例调整。
让整个场景图工作的关键是在render函数末尾的这个if语句:
if (sprite.children && sprite.children.length > 0) {
ctx.translate(-sprite.width / 2, -sprite.height / 2);
sprite.children.forEach(child => {
displaySprite(child);
});
}
如果父级包含子级,则上下文将重新定位到父级的左上角。这让我们在 x 和 y 坐标绘制子精灵,这些坐标是父坐标空间的本地坐标。然后代码循环遍历每个子 sprite,并为每个子 sprite 调用displaySprite,运行完全相同的代码。如果这些子精灵中的任何一个有自己的孩子,那么displaySprite也会被调用。这个层次结构可以有你需要的深度,尽管精灵很少有嵌套子级超过三或四层的。
现在我们已经有了所有的新组件,让我们来看看如何使用它们。
筑巢精灵
图 4-3 展示了嵌套四层的四个矩形精灵。虚线显示了画布的顶部和左侧边界。

图 4-3 。嵌套的父子精灵层次结构
以下代码显示了如何使用您刚刚学习的新函数创建和显示这些矩形:
//Make the canvas
let canvas = makeCanvas(312, 312);
//Make the first parent sprite: the blueBox
let blueBox = rectangle(96, 96, "blue", "none", 0, 64, 54);
//Make the goldBox and add it as a child of the blueBox
let goldBox = rectangle(64, 64, "gold");
blueBox.addChild(goldBox);
//Assign the goldBox's local coordinates (relative to the blueBox)
goldBox.x = 24;
goldBox.y = 24;
//Add a grayBox to the goldBox
let grayBox = rectangle(48, 48, "gray");
goldBox.addChild(grayBox);
grayBox.x = 8;
grayBox.y = 8;
//Add a pinkBox to the grayBox
let pinkBox = rectangle(24, 24, "pink");
grayBox.addChild(pinkBox);
pinkBox.x = 8;
pinkBox.y = 8;
//Render the canvas
render(canvas);
局部和全局坐标
主父对象blueBox是stage对象的子对象。stage的大小和位置与画布相同,其 0,0 x / y 注册点位于左上角。这意味着当blueBox被创建时,它的 x 和 y 位置是指它离画布左上角的距离。其 x 位置为 64 °,其 y 位置为 54 °,此处突出显示:
let blueBox = rectangle(96, 96, "blue", "none", 0, 64, 54);
64 和 54 是它的局部坐标,相对于它的母体stage。但是因为blueBox位于 sprite 层次的顶部,所以这些局部坐标也与其全局坐标相同。
如果你把一个精灵作为孩子添加到blueBox中会发生什么?子节点的 x 和 y 位置相对于其父节点是。
let goldBox = rectangle(64, 64, "gold");
blueBox.addChild(goldBox);
goldBox.x = 24;
goldBox.y = 24;
goldBox从blueBox的左上角偏移 24 个像素;这些是它的本地坐标。你可以使用goldBox的gx和gy属性来找出它的全球坐标:
goldBox.gx;
goldBox.gy;
在这个例子中goldBox.gx是 88,而goldBox.gy是 78。
在大多数游戏场景中,你只需要使用精灵的局部坐标,但是如果你需要全局坐标,你现在知道如何访问它们了。
旋转
如果旋转父精灵,所有子精灵都会随之旋转。
blueBox.rotation = 0.8;
图 4-4 显示了这段代码的效果。子精灵的实际rotation值不会改变:它们的旋转值仍然为零。但是因为它们绑定到父对象的坐标系,所以它们保持与旋转轴对齐。

图 4-4 。如果旋转父对象,其子对象将与其旋转轴相匹配
您可以通过旋转内部grayBox进行测试:
grayBox.rotation = 0.3;
图 4-5 显示效果:除了blueBox的旋转之外,grayBox和其子pinkBox旋转了 0.3 弧度。

图 4-5 。子对象的旋转值相对于父对象
就像你和我固定在地球的自转上,没有意识到它以大约 1600 千米/小时的速度旋转一样,孩子们也不会意识到他们父母的自转值。
标度
缩放也是如此。改变一个家长的尺度,所有的孩子都会匹配尺度效果。
blueBox.scaleX = 1.5;
图 4-6 显示了这个代码对孩子的影响。它们都拉伸以匹配父对象。

图 4-6 。如果缩放父对象,其子对象也会被缩放
阿尔法透明度
透明度以类似的方式工作。如果将父对象的 alpha 设置为 0.5,并将子对象的 alpha 设置为相同的值 0.5,会发生什么情况?
blueBox.alpha = 0.5
grayBox.alpha = 0.5;
虽然在印刷品上很难看到,图 4-7 显示了这种效果是复合的。渲染时,grayBox和其子pinkBox的 alpha 值似乎为 0.25。这是一种自然的效果,也是你所期望的嵌套对象在现实世界中的透明表现。

图 4-7 。子对象的 alpha 透明度与其父对象相关
深度分层
精灵从下到上相互堆叠,按照它们在父元素的children数组中出现的顺序排列。例如,如果您创建了三个重叠的矩形,最后创建的矩形将堆叠在前面创建的矩形之上。
let redBox = rectangle(64, 64, "red", "black", 4, 220, 180);
let greenBox = rectangle(64, 64, "yellowGreen", "black", 4, 200, 200);
let violetBox = rectangle(64, 64, "violet", "black", 4, 180, 220);
图 4-8 显示了这段代码产生的结果。

图 4-8 。精灵的深度堆叠顺序由渲染顺序决定
render方法按顺序遍历children数组,因此数组末尾的精灵是最后一个被渲染的。这意味着你可以通过改变精灵在它所属的children数组中的位置来改变它的深度层。
我们新的矩形精灵有一个layer setter 属性来做这件事。如果你改变了layer的值,代码会根据该值对精灵的父节点的children数组进行排序。具有较高层值的精灵被排序到子数组的末尾,因此它们将最后显示。
layer: {
get() {
return o._layer;
},
set(value) {
o._layer = value;
if (o.parent) {
o.parent.children.sort((a, b) => a.layer - b.layer);
}
},
enumerable: true, configurable: true
}
数组的sort方法是如何工作的?它采用一个带有两个参数的自定义函数,a和b,其中a是正在排序的当前元素,b是它右边的邻居。sort方法遍历所有元素并比较它们的layer值。如果a.layer减去b.layer小于 0,那么b被排序到数组中更高的位置。如果结果大于零,则a被排序到更高的位置。如果结果正好是 0,那么任何一个元素的位置都不会改变。
所有矩形精灵的默认layer值都是 0。这意味着你可以给一个精灵一个大于 0 的数字,让它显示在另一个精灵的上面。在这个例子中,你可以将redBox的layer属性设置为 1,让它出现在其他精灵的上面。
redBox.layer = 1;
图 4-9 显示效果。

图 4-9 。改变精灵的深度层
现在,如果您想让greenBox出现在redBox的上方,您可以将greenBox的layer属性设置为 2。
注意小心!对数组进行排序在计算上非常昂贵。在游戏中,你应该避免这样做,除非绝对必要,并且永远不要在一个连续的循环中排序数组。
**这就是我们的场景图!这些都是你需要知道的为游戏制作嵌套精灵层次的基础。
游戏精灵
你现在知道了如何制作矩形,但是如果你想开始制作游戏,你还需要更多的精灵类型。以下是制作几乎任何种类的 2D 动作游戏所需的最重要的核心精灵类型:
- 圆
- 线条
- 文本
- 图像
- 群组(一种特殊的精灵,你会在本章后面学到,它只是用来将其他精灵组合在一起)
- 矩形
在本章的后半部分,我们将学习所有关于制作矩形精灵的概念,并用它们来创建新的精灵类型。我们将通过利用 ES6 的类继承系统的优势来做到这一点。
DisplayObject类
在前面的例子中,我们使用功能组合来创建矩形精灵。在这种技术中,sprite 是由一个函数创建的,该函数组成一个对象,然后将该对象返回给主程序。这是制作精灵或任何可重复使用的对象的一个非常好的方法。如果你喜欢这种风格的编码,继续做下去!
但是为了教你一些新东西,我将向你展示如何使用浅继承模式实现一个游戏精灵系统。这正是 ES6 的类系统所设计的那种编程任务。
首先,创建一个名为DisplayObject的基类,包含所有不同 sprite 类型共享的属性和方法。
class DisplayObject {
constructor(properties) {
//Initialize the sprite
}
commonMethod() {
}
}
接下来,创建一个特定的 sprite 类型,它扩展了DisplayObject并实现了自己独特的方法和属性:
class SpriteType extends DisplayObject {
constructor() {
//Call DisplayObject's constructor to initialize
//all the default properties
super();
//Initialize the sprite's specific properties
}
specificMethod() {
}
}
我们将创建七个 sprite 类型,它们扩展了DisplayObject : Circle、Rectangle、Line、Text、Sprite(用于图像)、Group(将 sprite 分组在一起),以及Stage(所有 sprite 的根父容器)。这种模式将使我们的代码保持紧凑,并给我们一个定义良好的结构。
如果DisplayObject将成为我们所有精灵的基类,它需要包含哪些属性和方法?至少,它需要有所有的属性和方法,我们给了最新版本的矩形精灵。这些包括像一个children数组、layer属性、addChild / removeChild方法等基本元素。但是因为我们将使用我们的精灵来制作各种各样的游戏,我们也将增加一些新的功能来帮助游戏开发过程尽可能的流畅和有趣。
首先让我们添加一些属性来帮助我们定位精灵并计算它们的大小:
pivotX、pivotY:定义精灵的轴点,精灵应该围绕这个轴点旋转。halfWidth、halfHeight:返回一半宽度和高度值的属性。centerX、centerY:定义精灵的中心位置。localBounds、globalBounds:这些属性中的每一个都返回一个对象,告诉你 x 、 y ,精灵的宽度和高度(使用局部或全局坐标)。在边界检查计算中,您可以使用它们作为快捷方式来获得精灵的位置和大小。circular:如果将circular属性设置为true,则在 sprite 上会创建diameter和radius属性。将其设置为false会删除diameter和radius属性。在接下来的章节中,你会看到这个特性是如何对碰撞检测有用的。
一些增强的视觉效果会很好:
blendMode:设置精灵的混合模式。shadow、shadowColor、shadowOffsetX、shadowOffsetY、shadowBlur:让你给精灵添加阴影的属性。
我们还将实现一些方便的“奢侈”属性和方法。它们不是必不可少的,但拥有它们很好:
position:一个 getter,返回 sprite 的位置,作为一个具有 x 和 y 属性的对象。setPosition。一个让你在一行代码中设置一个 sprite 的 x 和 y 值的方法,像这样:sprite.setPosition(120, 45);empty:如果精灵的children数组为空,则返回false的布尔属性。putCenter、putTop、putRight、putBottom和putLeft:让你相对于这个精灵定位任何精灵的方法。swapChildren:交换children数组中两个精灵位置的方法。使用它来交换两个子精灵的深度层。add和remove:快捷方式addChild和removeChild,让你用一行代码添加或删除许多子精灵,比如:sprite.remove(firstChild, secondChild, thirdChild)。
我们还将增加一些高级功能。我们不会在本章中使用它们,但是您将了解它们是如何工作的,以及在接下来的章节中我们将如何使用它们:
draggable:定义是否可以用指针(鼠标或触摸)拖动 sprite。interactive:让你使精灵互动,使它对指针事件变得敏感。frame, currentFrame, loop和playing:属性我们需要改变一个精灵的图像状态或动画。
所有这些方法和属性将被所有 sprite 类型继承,包括根stage对象。
编码DisplayObject类
这是实现了所有这些特性的完整的DisplayObject类。(你会在本章的源文件中的library/display中找到工作代码。)
class DisplayObject {
constructor() {
//The sprite's position and size
this.x = 0;
this.y = 0;
this.width = 0;
this.height = 0;
//Rotation, alpha, visible, and scale properties
this.rotation = 0;
this.alpha = 1;
this.visible = true;
this.scaleX = 1;
this.scaleY = 1;
//`pivotX` and `pivotY` let you set the sprite's axis of rotation
//(o.5 represents the sprite's center point)
this.pivotX = 0.5;
this.pivotY = 0.5;
//Add `vx` and `vy` (velocity) variables that will help you move the sprite
this.vx = 0;
this.vy = 0;
//A "private" `_layer` property
this._layer = 0;
//A `children` array on the sprite that will contain all the
//child sprites in this container
this.children = [];
//The sprite's `parent` property
this.parent = undefined;
//The sprite's `children` array
this.children = [];
//Optional drop shadow properties.
//Set `shadow` to `true` if you want the sprite to display a shadow
this.shadow = false;
this.shadowColor = "rgba(100, 100, 100, 0.5)";
this.shadowOffsetX = 3;
this.shadowOffsetY = 3;
this.shadowBlur = 3;
//Optional blend mode property
this.blendMode = undefined;
//Properties for advanced features:
//Image states and animation
this.frames = [];
this.loop = true;
this._currentFrame = 0;
this.playing = false;
//Can the sprite be dragged?
this._draggable = undefined;
//Is the sprite circular? If it is, it will be given a `radius`
//and `diameter`
this._circular = false;
//Is the sprite `interactive`? If it is, it can become clickable
//or touchable
this._interactive = false;
}
/* Essentials */
//Global position
get gx() {
if (this.parent) {
//The sprite's global x position is a combination of
//its local x value and its parent's global x value
return this.x + this.parent.gx;
} else {
return this.x;
}
}
get gy() {
if (this.parent) {
return this.y + this.parent.gy;
} else {
return this.y;
}
}
//Depth layer
get layer() {
return this._layer;
}
set layer(value) {
this._layer = value;
if (this.parent) {
this.parent.children.sort((a, b) => a.layer - b.layer);
}
}
//The `addChild` method lets you add sprites to this container
addChild(sprite) {
if (sprite.parent) {
sprite.parent.removeChild(sprite);
}
sprite.parent = this;
this.children.push(sprite);
}
removeChild(sprite) {
if(sprite.parent === this) {
this.children.splice(this.children.indexOf(sprite), 1);
} else {
throw new Error(sprite + "is not a child of " + this);
}
}
//Getters that return useful points on the sprite
get halfWidth() {
return this.width / 2;
}
get halfHeight() {
return this.height / 2;
}
get centerX() {
return this.x + this.halfWidth;
}
get centerY() {
return this.y + this.halfHeight;
}
/* Conveniences */
//A `position` getter. It returns an object with x and y properties
get position() {
return {x: this.x, y: this.y};
}
//A `setPosition` method to quickly set the sprite's x and y values
setPosition(x, y) {
this.x = x;
this.y = y;
}
//The `localBounds` and `globalBounds` methods return an object
//with `x`, `y`, `width`, and `height` properties that define
//the dimensions and position of the sprite. This is a convenience
//to help you set or test boundaries without having to know
//these numbers or request them specifically in your code.
get localBounds() {
return {
x: 0,
y: 0,
width: this.width,
height: this.height
};
}
get globalBounds() {
return {
x: this.gx,
y: this.gy,
width: this.gx + this.width,
height: this.gy + this.height
};
}
//`empty` is a convenience property that will return `true` or
//`false` depending on whether this sprite's `children`
//array is empty
get empty() {
if (this.children.length === 0) {
return true;
} else {
return false;
}
}
//The following "put" methods help you position
//another sprite in and around this sprite. You can position
//sprites relative to this sprite's center, top, right, bottom or
//left sides. The `xOffset` and `yOffset`
//arguments determine by how much the other sprite's position
//should be offset from this position.
//In all these methods, `b` is the second sprite that is being
//positioned relative to the first sprite (this one), `a`
//Center `b` inside `a`
putCenter(b, xOffset = 0, yOffset = 0) {
let a = this;
b.x = (a.x + a.halfWidth - b.halfWidth) + xOffset;
b.y = (a.y + a.halfHeight - b.halfHeight) + yOffset;
}
//Position `b` above `a`
putTop(b, xOffset = 0, yOffset = 0) {
let a = this;
b.x = (a.x + a.halfWidth - b.halfWidth) + xOffset;
b.y = (a.y - b.height) + yOffset;
}
//Position `b` to the right of `a`
putRight(b, xOffset = 0, yOffset = 0) {
let a = this;
b.x = (a.x + a.width) + xOffset;
b.y = (a.y + a.halfHeight - b.halfHeight) + yOffset;
}
//Position `b` below `a`
putBottom(b, xOffset = 0, yOffset = 0) {
let a = this;
b.x = (a.x + a.halfWidth - b.halfWidth) + xOffset;
b.y = (a.y + a.height) + yOffset;
}
//Position `b` to the left of `a`
putLeft(b, xOffset = 0, yOffset = 0) {
let a = this;
b.x = (a.x - b.width) + xOffset;
b.y = (a.y + a.halfHeight - b.halfHeight) + yOffset;
}
//Some extra conveniences for working with child sprites
//Swap the depth layer positions of two child sprites
swapChildren(child1, child2) {
let index1 = this.children.indexOf(child1),
index2 = this.children.indexOf(child2);
if (index1 !== -1 && index2 !== -1) {
//Swap the indexes
child1.childIndex = index2;
child2.childIndex = index1;
//Swap the array positions
this.children[index1] = child2;
this.children[index2] = child1;
} else {
throw new Error(`Both objects must be a child of the caller ${this}`);
}
}
//`add` and `remove` let you add and remove many sprites at the same time
add(...spritesToAdd) {
spritesToAdd.forEach(sprite => this.addChild(sprite));
}
remove(...spritesToRemove) {
spritesToRemove.forEach(sprite => this.removeChild(sprite));
}
/* Advanced features */
//If the sprite has more than one frame, return the
//value of `_currentFrame`
get currentFrame() {
return this._currentFrame;
}
//The `circular` property lets you define whether a sprite
//should be interpreted as a circular object. If you set
//`circular` to `true`, the sprite is given `radius` and `diameter`
//properties. If you set `circular` to `false`, the `radius`
//and `diameter` properties are deleted from the sprite
get circular() {
return this._circular;
}
set circular (value) {
//Give the sprite `diameter` and `radius` properties
//if `circular` is `true`
if (value === true && this._circular === false) {
Object.defineProperties(this, {
diameter: {
get () {
return this.width;
},
set (value) {
this.width = value;
this.height = value;
},
enumerable: true, configurable: true
},
radius: {
get() {
return this.halfWidth;
},
set(value) {
this.width = value * 2;
this.height = value * 2;
},
enumerable: true, configurable: true
}
});
//Set this sprite's `_circular` property to `true`
this._circular = true;
}
//Remove the sprite's `diameter` and `radius` properties
//if `circular` is `false`
if (value === false && this._circular === true) {
delete this.diameter;
delete this.radius;
this._circular = false;
}
}
//Is the sprite draggable by the pointer? If `draggable` is set
//to `true`, the sprite is added to a `draggableSprites`
//array. All the sprites in `draggableSprites` are updated each
//frame to check whether they're being dragged.
//(You’ll learn how to implement this in Chapter 6.)
get draggable() {
return this._draggable;
}
set draggable(value) {
if (value === true) {
draggableSprites.push(this);
this._draggable = true;
}
//If it's `false`, remove it from the `draggableSprites` array
if (value === false) {
draggableSprites.splice(draggableSprites.indexOf(this), 1);
}
}
//Is the sprite interactive? If `interactive` is set to `true`,
//the sprite is run through the `makeInteractive` function.
//`makeInteractive` makes the sprite sensitive to pointer
//actions. It also adds the sprite to the `buttons` array,
//which is updated each frame.
//(You’ll learn how to implement this in Chapter 6.)
get interactive() {
return this._interactive;
}
set interactive(value) {
if (value === true) {
//Add interactive properties to the sprite
//so that it can act like a button
makeInteractive(this);
//Add the sprite to the global `buttons` array so
//it can be updated each frame
buttons.push(this);
//Set this sprite’s private `_interactive` property to `true`
this._interactive = true;
}
if (value === false) {
//Remove the sprite's reference from the
//`buttons` array so that it's no longer affected
//by mouse and touch interactivity
buttons.splice(buttons.indexOf(this), 1);
this._interactive = false;
}
}
}
作为奖励,让我们也创建一个通用的remove函数,它将从任何父对象中删除任何精灵或精灵列表:
function remove(...spritesToRemove) {
spritesToRemove.forEach(sprite => {
sprite.parent.removeChild(sprite);
});
}
如果你需要从游戏中移除一个精灵,并且不知道或者不关心它的父精灵是什么,使用这个通用的remove函数。
全功能渲染功能
我们给精灵添加了一些新的特性——阴影、混合模式和旋转轴心点。让我们更新我们的render函数,以允许我们使用所有这些特性。我们还将添加一个进一步的优化:精灵只有在画布的可视区域内才会被绘制在画布上。
function render(canvas) {
//Get a reference to the context
let ctx = canvas.ctx;
//Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
//Loop through each sprite object in the stage's `children` array
stage.children.forEach(sprite => {
//Display a sprite
displaySprite(sprite);
});
function displaySprite(sprite) {
//Only display the sprite if it's visible
//and within the area of the canvas
if (
sprite.visible
&& sprite.gx < canvas.width + sprite.width
&& sprite.gx + sprite.width >= -sprite.width
&& sprite.gy < canvas.height + sprite.height
&& sprite.gy + sprite.height >= -sprite.height
) {
//Save the canvas's present state
ctx.save();
//Shift the canvas to the center of the sprite's position
ctx.translate(
sprite.x + (sprite.width * sprite.pivotX),
sprite.y + (sprite.height * sprite.pivotY)
);
//Set the sprite's `rotation`, `alpha` and `scale`
ctx.rotate(sprite.rotation);
ctx.globalAlpha = sprite.alpha * sprite.parent.alpha;
ctx.scale(sprite.scaleX, sprite.scaleY);
//Display the sprite's optional drop shadow
if(sprite.shadow) {
ctx.shadowColor = sprite.shadowColor;
ctx.shadowOffsetX = sprite.shadowOffsetX;
ctx.shadowOffsetY = sprite.shadowOffsetY;
ctx.shadowBlur = sprite.shadowBlur;
}
//Display the optional blend mode
if (sprite.blendMode) ctx.globalCompositeOperation = sprite.blendMode;
//Use the sprite's own `render` method to draw the sprite
if (sprite.render) sprite.render(ctx);
if (sprite.children && sprite.children.length > 0) {
//Reset the context back to the parent sprite's top-left corner,
//relative to the pivot point
ctx.translate(-sprite.width * sprite.pivotX , -sprite.height * sprite.pivotY);
//Loop through the parent sprite's children
sprite.children.forEach(child => {
//display the child
displaySprite(child);
});
}
//Restore the canvas to its previous state
ctx.restore();
}
}
}
现在我们有了一个基类和一个渲染器,让我们开始构建我们的游戏精灵。
舞台
stage 是所有精灵的根父容器,所以这是我们应该做的第一个新东西。stage只是一个不显示任何图形的精灵。这意味着您可以直接从DisplayObject创建stage,语法如下:
let stage = new DisplayObject();
然后只需给它与画布匹配的width和height值,就万事俱备了:
stage.width = canvas.width;
stage.height = canvas.height;
Rectangle类
Rectangle sprite 的代码将会非常熟悉,但是它实现了的一些巧妙的技巧,我将在代码清单之后解释。
class Rectangle extends DisplayObject {
constructor(
width = 32,
height = 32,
fillStyle = "gray",
strokeStyle = "none",
lineWidth = 0,
x = 0,
y = 0
){
//Call the DisplayObject's constructor
super();
//Assign the argument values to this sprite
Object.assign(
this, {width, height, fillStyle, strokeStyle, lineWidth, x, y}
);
//Add a `mask` property to enable optional masking
this.mask = false;
}
//The `render` method explains how to draw the sprite
render(ctx) {
ctx.strokeStyle = this.strokeStyle;
ctx.lineWidth = this.lineWidth;
ctx.fillStyle = this.fillStyle;
ctx.beginPath();
ctx.rect(
//Draw the sprite around its `pivotX` and `pivotY` point
-this.width * this.pivotX,
-this.height * this.pivotY,
this.width,
this.height
);
if (this.strokeStyle !== "none") ctx.stroke();
if (this.fillStyle !== "none") ctx.fill();
if (this.mask && this.mask === true) ctx.clip();
}
}
//A higher-level wrapper for the rectangle sprite
function rectangle(width, height, fillStyle, strokeStyle, lineWidth, x, y) {
//Create the sprite
let sprite = new Rectangle(width, height, fillStyle, strokeStyle, lineWidth, x, y);
//Add the sprite to the stage
stage.addChild(sprite);
//Return the sprite to the main program
return sprite;
}
这是Rectangle类的代码;现在让我们看看它实现的三个新技巧:遮罩、API 保险和围绕 sprite 的枢轴点旋转。
屏蔽
你在第二章中学习了如何使用形状来遮盖画布区域。Rectangle类引入了一个有用的新mask属性,让你可以选择使用任何矩形精灵作为遮罩。
this.mask = false;
它被初始化为false。如果您在游戏代码中的任何地方将mask属性设置为true,矩形将会屏蔽掉这个矩形精灵的所有子精灵。矩形的render函数中的这段代码是遮罩工作的基础:
if (this.mask && this.mask === true) ctx.clip();
正如您将在前面看到的,圆形精灵也有这个mask属性。
API 保险
看看Rectangle类代码的最后一部分。你可以看到一个名为rectangle的函数被用来创建和返回一个使用Rectangle类制作的精灵。为什么我增加了一个额外的不必要的步骤?为什么不直接用new Rectangle()创建精灵,而不用用另一个函数包装它呢?
这就是我所说的 API 保险。它是这样工作的:rectangle函数是Rectangle类构造函数的高级包装器。这个额外的包装器意味着您可以使用一致的 API 来创建 sprite,即使在将来某个时候您的底层代码发生了根本的变化。例如,如果你突然决定使用一个完全不同的类来制作矩形,你可以用新的构造函数替换旧的Rectangle构造函数,就像这样:
function rectangle(width, height, fillStyle, strokeStyle, lineWidth, x, y) {
let sprite = new BetterRectangle(width, height, fillStyle, strokeStyle, lineWidth, x, y);
stage.addChild(sprite);
return sprite;
}
你在游戏中用来创建矩形精灵的代码不会改变;rectangle函数只是将参数重定向到不同的类构造函数。
使用包装函数也意味着在创建 sprite 时可以运行有用的辅助任务,比如将 sprite 添加到stage中。这很重要,也是你需要为你创建的每一个精灵做的事情。但是为了保持模块化,这个任务可能不应该放在主 sprite 类中。(API 保险是我自己编的一个术语——不要在任何计算机科学教科书上找!)
围绕旋转轴旋转
如果你想旋转你的精灵偏离中心,使用一个轴点。您可以将轴心点视为一个可以插入精灵的大头针。当您旋转精灵时,它会围绕该大头针旋转。pivotX和pivotY属性取 0.1 到 0.99 之间的值,代表精灵的宽度或高度的百分比。(像这样介于 0 和 1 之间的百分比值通常被称为归一化值。)pivotX和pivotY初始化为 0.5 的值,这意味着精灵会围绕其中心旋转。让我们通过使用新代码创建一个矩形,将其 pivot 值设置为 0.25,并旋转它来测试这一点。
let box = rectangle(96, 96, "blue", "none", 0, 54, 64);
box.pivotX = 0.25;
box.pivotY = 0.25;
box.rotation = 0.8;
图 4-10 中的第三幅图像显示了结果。

图 4-10 。设置轴点
枢轴点不会改变精灵的 x 和 y 位置;这些保持固定在精灵的未旋转的左上角。
pivotX和pivotY是如何产生这种效果的?矩形的render功能围绕枢轴点绘制形状。
ctx.rect(
-this.width * this.pivotX,
-this.width * this.pivotY,
this.width,
this.height
);
你可以将pivotX和pivotY用于本章中所有新的精灵类型。
Circle类
Circle类遵循与Rectangle类相同的格式,但是它画了一个圆。像矩形一样,圆形也有一个mask属性,所以你可以使用它们来选择性地屏蔽任何其他精灵。
class Circle extends DisplayObject {
constructor(
diameter = 32,
fillStyle = "gray",
strokeStyle = "none",
lineWidth = 0,
x = 0,
y = 0
){
//Call the DisplayObject's constructor
super();
//Enable `radius` and `diameter` properties
this.circular = true;
//Assign the argument values to this sprite
Object.assign(
this, {diameter, fillStyle, strokeStyle, lineWidth, x, y}
);
//Add a `mask` property to enable optional masking
this.mask = false;
}
//The `render` method
render(ctx) {
ctx.strokeStyle = this.strokeStyle;
ctx.lineWidth = this.lineWidth;
ctx.fillStyle = this.fillStyle;
ctx.beginPath();
ctx.arc(
this.radius + (-this.diameter * this.pivotX),
this.radius + (-this.diameter * this.pivotY),
this.radius,
0, 2*Math.PI,
false
);
if (this.strokeStyle !== "none") ctx.stroke();
if (this.fillStyle !== "none") ctx.fill();
if (this.mask && this.mask === true) ctx.clip();
}
}
//A higher level wrapper for the circle sprite
export function circle(diameter, fillStyle, strokeStyle, lineWidth, x, y) {
let sprite = new Circle(diameter, fillStyle, strokeStyle, lineWidth, x, y);
stage.addChild(sprite);
return sprite;
}
下面是如何使用这段代码画一个青色(浅蓝色)填充红色轮廓的圆,如图图 4-11 所示。
let cyanCircle = circle(64, "cyan", "red", 4, 64, 280);

图 4-11 。使用Circle类来画一个圆
圆的 x 和 y 值指的是限定圆的一个假想框的左上角。
圆圈有diameter和radius属性,这是由Circle类构造函数中的这行代码创建的:
this.circular = true;
DisplayObject基类有一个名为circular的设置器,当它被设置为true时,它在任何 sprite 上创建diameter和radius属性。如果你有显示圆形图像的精灵,你可能会发现这个特性很有用,你想把它和圆形的碰撞检测功能一起使用,你会在第七章的中了解到这一点。
Line类
Line类创建一个帮助你画线的精灵:
class Line extends DisplayObject {
constructor(
strokeStyle = "none",
lineWidth = 0,
ax = 0,
ay = 0,
bx = 32,
by = 32
){
//Call the DisplayObject's constructor
super();
//Assign the argument values to this sprite
Object.assign(
this, {strokeStyle, lineWidth, ax, ay, bx, by}
);
//The `lineJoin` style.
//Options are "round", "mitre" and "bevel".
this.lineJoin = "round";
}
//The `render` method
render(ctx) {
ctx.strokeStyle = this.strokeStyle;
ctx.lineWidth = this.lineWidth;
ctx.lineJoin = this.lineJoin;
ctx.beginPath();
ctx.moveTo(this.ax, this.ay);
ctx.lineTo(this.bx, this.by);
if (this.strokeStyle !== "none") ctx.stroke();
}
}
//A higher-level wrapper for the line sprite
function line(strokeStyle, lineWidth, ax, ay, bx, by) {
let sprite = new Line(strokeStyle, lineWidth, ax, ay, bx, by);
stage.addChild(sprite);
return sprite;
}
要创建线条精灵,请设置其颜色和宽度,然后定义其起点和终点。值ax和ay定义直线的起点,bx和by定义直线的终点:
let blackLine = line(fillStyle, lineWidth, ax, ay, bx, by);
您可以随时改变ax、ay、bx,和by的值来改变线条的位置。
下面的代码产生了图 4-12 中的十字交叉线的图像。
let blackLine = line("black", 4, 200, 64, 264, 128);
let redLine = line("red", 4, 200, 128, 264, 64);
let greenLine = line("green", 4, 264, 96, 200, 96);
let blueLine = line("blue", 4, 232, 128, 232, 64);

图 4-12 。用Line类画一些线
Text类
Text类给你一个快速的方法给游戏添加一些动态文本:
class Text extends DisplayObject {
constructor(
content = "Hello!",
font = "12px sans-serif",
fillStyle = "red",
x = 0,
y = 0
){
//Call the DisplayObject's constructor
super();
//Assign the argument values to this sprite
Object.assign(
this, {content, font, fillStyle, x, y}
);
//Set the default text baseline to "top"
this.textBaseline = "top";
//Set `strokeText` to "none"
this.strokeText = "none";
}
//The `render` method describes how to draw the sprite
render(ctx) {
ctx.font = this.font;
ctx.strokeStyle = this.strokeStyle;
ctx.lineWidth = this.lineWidth;
ctx.fillStyle = this.fillStyle;
//Measure the width and height of the text
if (this.width === 0) this.width = ctx.measureText(this.content).width;
if (this.height === 0) this.height = ctx.measureText("M").width;
ctx.translate(
-this.width * this.pivotX,
-this.height * this.pivotY
);
ctx.textBaseline = this.textBaseline;
ctx.fillText(
this.content,
0,
0
);
if (this.strokeText !== "none") ctx.strokeText();
}
}
//A higher level wrapper
function text(content, font, fillStyle, x, y) {
let sprite = new Text(content, font, fillStyle, x, y);
stage.addChild(sprite);
return sprite;
}
下面是如何制作一个文本精灵来显示单词“Hello World!”:
let message = text("Hello World!", "24px Futura", "black", 330, 230);
第二个参数定义了文本应该使用的字体。你可以使用浏览器内置的任何标准字体,或者任何加载了@font-face CSS 规则或者我们在第三章中内置的assets对象的字体文件。
文本精灵有一个名为content的属性,可以用来改变文本显示的单词:
message.content = "Anything you like";
您可以在游戏过程中随时更改content,这对于更新动态文本非常有用,比如玩家的分数。
Group类
是一种特殊的精灵,它不显示自己的任何图形。相反,它用于将其他精灵分组在一起。你可以把它想象成一个小精灵的大容器。但是因为一个组和其他精灵有相同的属性,你可以用它作为复杂游戏角色、游戏场景或关卡的根父级。
组的高度和宽度是根据它包含的内容动态计算的。Group类实现了自定义的addChild和removeChild方法,每当一个精灵被添加到组中或者从组中移除时,这些方法都会重新计算组的大小。Group类的calculateSize方法循环遍历该组的每个子组,并将该组的width和height设置为其任何子组占据的最大宽度和高度。
class Group extends DisplayObject {
constructor(...spritesToGroup){
//Call the DisplayObject's constructor
super();
//Group all the sprites listed in the constructor arguments
spritesToGroup.forEach(sprite => this.addChild(sprite));
}
//Groups have custom `addChild` and `removeChild` methods that call
//a `calculateSize` method when any sprites are added or removed
//from the group
addChild(sprite) {
if (sprite.parent) {
sprite.parent.removeChild(sprite);
}
sprite.parent = this;
this.children.push(sprite);
//Figure out the new size of the group
this.calculateSize();
}
removeChild(sprite) {
if(sprite.parent === this) {
this.children.splice(this.children.indexOf(sprite), 1);
//Figure out the new size of the group
this.calculateSize();
} else {
throw new Error(`${sprite} is not a child of ${this}`);
}
}
calculateSize() {
//Calculate the width based on the size of the largest child
//that this sprite contains
if (this.children.length > 0) {
//Some temporary private variables to help track the new
//calculated width and height
this._newWidth = 0;
this._newHeight = 0;
//Find the width and height of the child sprites furthest
//from the top left corner of the group
this.children.forEach(child => {
//Find child sprites that combined x value and width
//that's greater than the current value of `_newWidth`
if (child.x + child.width > this._newWidth) {
//The new width is a combination of the child's
//x position and its width
this._newWidth = child.x + child.width;
}
if (child.y + child.height > this._newHeight) {
this._newHeight = child.y + child.height;
}
});
//Apply the `_newWidth` and `_newHeight` to this sprite's width
//and height
this.width = this._newWidth;
this.height = this._newHeight;
}
}
}
//A higher level wrapper for the group sprite
function group(...spritesToGroup) {
let sprite = new Group(...spritesToGroup);
stage.addChild(sprite);
return sprite;
}
要创建组,请在组的构造函数中列出要分组的精灵:
let squares = group(squareOne, squareTwo, squareThree);
或者,你可以创建一个空组,并用addChild或add将精灵分组在一起:
let squares = group();
squares.addChild(squareOne);
squares.add(squareTwo, squareThree);
团队在游戏中有很多用途,你会在接下来的章节中看到。
Sprite类
是一个强大的显示图像的类。它允许您显示单个图像文件中的图像、纹理贴图集帧或 tileset 中的子图像。它还允许您存储多个图像状态,并用图像数组初始化 sprite。为了帮助显示不同的图像状态,Sprite类还实现了一个名为gotoAndStop 的新方法。(我们将使用这些特性作为基础,在第七章的中制作交互按钮,并在第八章的中制作关键帧动画。)
这对于一个 sprite 类型来说是很大的工作量,所以Sprite类相当大。但是这段代码的最大部分只是计算出提供给它的是哪种图像信息。让我们看一下Sprite类的完整代码清单,然后我将带您了解每个特性是如何工作的。
class Sprite extends DisplayObject {
constructor(
source,
x = 0,
y = 0
){
//Call the DisplayObject's constructor
super();
//Assign the argument values to this sprite
Object.assign(this, {x, y});
//We need to figure out what the source is, and then use
//that source data to display the sprite image correctly
//Is the source a JavaScript Image object?
if(source instanceof Image) {
this.createFromImage(source);
}
//Is the source a tileset from a texture atlas?
//(It is if it has a `frame` property)
else if (source.frame) {
this.createFromAtlas(source);
}
//If the source contains an `image` subproperty, this must
//be a `frame` object that's defining the rectangular area of an inner subimage.
//Use that subimage to make the sprite. If it doesn't contain a
//`data` property, then it must be a single frame
else if (source.image && !source.data) {
this.createFromTileset(source);
}
//If the source contains an `image` subproperty
//and a `data` property, then it contains multiple frames
else if (source.image && source.data) {
this.createFromTilesetFrames(source);
}
//Is the source an array? If so, what kind of array?
else if (source instanceof Array) {
if (source[0] && source[0].source) {
//The source is an array of frames on a texture atlas tileset
this.createFromAtlasFrames(source);
}
//It must be an array of image objects
else if (source[0] instanceof Image){
this.createFromImages(source);
}
//throw an error if the sources in the array aren't recognized
else {
throw new Error(`The image sources in ${source} are not recognized`);
}
}
//Throw an error if the source is something we can't interpret
else {
throw new Error(`The image source ${source} is not recognized`);
}
}
createFromImage(source) {
//Throw an error if the source is not an Image object
if (!(source instanceof Image)) {
throw new Error(`${source} is not an image object`);
}
//Otherwise, create the sprite using an Image
else {
this.source = source;
this.sourceX = 0;
this.sourceY = 0;
this.width = source.width;
this.height = source.height;
this.sourceWidth = source.width;
this.sourceHeight = source.height;
}
}
createFromAtlas(source) {
this.tilesetFrame = source;
this.source = this.tilesetFrame.source;
this.sourceX = this.tilesetFrame.frame.x;
this.sourceY = this.tilesetFrame.frame.y;
this.width = this.tilesetFrame.frame.w;
this.height = this.tilesetFrame.frame.h;
this.sourceWidth = this.tilesetFrame.frame.w;
this.sourceHeight = this.tilesetFrame.frame.h;
}
createFromTileset(source) {
if (!(source.image instanceof Image)) {
throw new Error(`${source.image} is not an image object`);
} else {
this.source = source.image;
this.sourceX = source.x;
this.sourceY = source.y;
this.width = source.width;
this.height = source.height;
this.sourceWidth = source.width;
this.sourceHeight = source.height;
}
}
createFromTilesetFrames(source) {
if (!(source.image instanceof Image)) {
throw new Error(`${source.image} is not an image object`);
} else {
this.source = source.image;
this.frames = source.data;
//Set the sprite to the first frame
this.sourceX = this.frames[0][0];
this.sourceY = this.frames[0][1];
this.width = source.width;
this.height = source.height;
this.sourceWidth = source.width;
this.sourceHeight = source.height;
}
}
createFromAtlasFrames(source) {
this.frames = source;
this.source = source[0].source;
this.sourceX = source[0].frame.x;
this.sourceY = source[0].frame.y;
this.width = source[0].frame.w;
this.height = source[0].frame.h;
this.sourceWidth = source[0].frame.w;
this.sourceHeight = source[0].frame.h;
}
createFromImages(source) {
this.frames = source;
this.source = source[0];
this.sourceX = 0;
this.sourceY = 0;
this.width = source[0].width;
this.height = source[0].width;
this.sourceWidth = source[0].width;
this.sourceHeight = source[0].height;
}
//Add a `gotoAndStop` method to go to a specific frame
gotoAndStop(frameNumber) {
if (this.frames.length > 0 && frameNumber < this.frames.length) {
//a. Frames made from tileset subimages.
//If each frame is an array, then the frames were made from an
//ordinary Image object using the `frames` method
if (this.frames[0] instanceof Array) {
this.sourceX = this.frames[frameNumber][0];
this.sourceY = this.frames[frameNumber][1];
}
//b. Frames made from texture atlas frames.
//If each frame isn't an array, and it has a subobject called `frame`,
//then the frame must be a texture atlas ID name.
//In that case, get the source position from the atlas's `frame` object
else if (this.frames[frameNumber].frame) {
this.sourceX = this.frames[frameNumber].frame.x;
this.sourceY = this.frames[frameNumber].frame.y;
this.sourceWidth = this.frames[frameNumber].frame.w;
this.sourceHeight = this.frames[frameNumber].frame.h;
this.width = this.frames[frameNumber].frame.w;
this.height = this.frames[frameNumber].frame.h;
}
//c. Frames made from individual Image objects.
//If neither of the above is true, then each frame must be
//an individual Image object
else {
this.source = this.frames[frameNumber];
this.sourceX = 0;
this.sourceY = 0;
this.width = this.source.width;
this.height = this.source.height;
this.sourceWidth = this.source.width;
this.sourceHeight = this.source.height;
}
//Set the `_currentFrame` value to the chosen frame
this._currentFrame = frameNumber;
}
//Throw an error if this sprite doesn't contain any frames
else {
throw new Error(`Frame number ${frameNumber} does not exist`);
}
}
//The `render` method
render(ctx) {
ctx.drawImage(
this.source,
this.sourceX, this.sourceY,
this.sourceWidth, this.sourceHeight,
-this.width * this.pivotX,
-this.height * this.pivotY,
this.width, this.height
);
}
}
//A higher-level wrapper
function sprite(source, x, y) {
let sprite = new Sprite(source, x, y);
stage.addChild(sprite);
return sprite;
}
Sprite类是为最大的灵活性而设计的,这样你就可以显示各种来源的图像。让我们来看看您可以使用哪些图像源以及如何使用。
从单一图像制作精灵
要使用单个图像文件制作精灵,首先使用assets.load加载图像,你在第三章中学会了如何使用。
assets.load(["img/cat.png"]).then(() => setup());
然后提供对 image 对象的引用作为 sprite 函数的第一个参数。第二个和第三个参数是精灵的 x 和 y 位置:
function setup() {
let cat = sprite(assets["img/cat.png"], 64, 410);
}
这是从图像制作精灵的最基本的方法,但是你有更多的选择。
从纹理贴图帧制作精灵
使用纹理贴图帧就像使用单个图像文件一样简单。首先加载纹理图谱:
assets.load(["img/animals.json"]).then(() => setup());
然后提供 atlas 帧作为精灵的源:
let tiger = sprite(assets["tiger.png"], 192, 410);
(记住,"tiger.png"是纹理图集帧 ID,不是图像文件。)类Sprite知道这是一个纹理贴图集帧,因为当它检查source参数时,会发现一个名为frame的属性。那是纹理图谱的指纹。
这很方便,但是如果你想直接从一个单独的 tileset 图像中拼接一个子图像而不使用纹理贴图集呢?
从 Tileset 中创建子图像
假设您有一个单独的 tileset 图像,其中包含一个游戏角色的四个动画帧。图 4-13 显示了一个例子。

图 4-13 。具有四个角色动画帧的 tileset
您没有附带的 JSON 文件来告诉您这些帧的位置或大小;你只是想直接从图像中 blit 其中一个子图像。你怎么能这么做?
我们将使用一个名为frame的新函数来帮助你捕捉单独的 tileset 帧。
function frame(source, x, y, width, height) {
var o = {};
o.image = source;
o.x = x;
o.y = y;
o.width = width;
o.height = height;
return o;
};
要使用它,请提供您想要 blit 的 tileset 图像的名称。然后提供您想要使用的子图像的 x 、 y 、宽度和高度。 frame函数返回一个对象,您将能够使用该对象使用子图像创建精灵。下面是如何使用这个新的frame函数 blit 来自示例童话人物 tileset 的第一帧(示例 tileset 中的每一帧是 48 像素宽和 32 像素高):
let fairyFrame = frame(
assets["img/fairy.png"], //the tileset source image
0, 0, 48, 32 //The subimage's x, y, width and height
);
接下来,使用返回的fairyFrame对象初始化 sprite。
let fairy = sprite(fairyFrame, 164, 326);
这将创建一个显示 tileset 第一帧的 sprite,如图 4-14 所示。

图 4-14 。从切片集中复制子图像
fairyFrame是 sprite 的源参数。Sprite类检测到您正在对单个子图像进行块传输,因为源代码包含一个由frame函数创建的image属性。下面是来自Sprite类的代码片段,用于检查这一点:
else if (source.image && !source.data) {
//The source is a single subimage from a tileset
this.createFromTileset(source);
}
(下一节你就知道source.data是什么了。)
Sprite类调用它的createFromTileset方法,使用你在第二章中学到的相同技术,将子图像 blit 到画布上。
块传输多个 Tileset 帧
Sprite类的一个重要特性是它可以加载一个包含多个图像的 sprite。然后你可以使用sprite.gotoAndStop(frameNumber)来改变精灵显示的图像。这个特性将构成关键帧动画和按钮交互性的基础,你将在后面的章节中了解到。
但是如何将多个图像加载到一个 sprite 中呢?有几种不同的方法,但是让我们先来看看如何使用 tileset 图像。我们将使用一个名为frames(带“s”)的新函数,它允许您为想要使用的子图像指定一个由 x 和 y 位置组成的数组。
function frames(source, arrayOfPositions, width, height) {
var o = {};
o.image = source;
o.data = arrayOfPositions;
o.width = width;
o.height = height;
return o;
};
第二个参数让您提供 tileset 上子图像 x 和 y 位置的 2D 数组。该 2D 数组被复制到该函数返回的对象的一个名为data的属性中。下面是如何将它与我们的示例 tileset 一起使用,以指定您想要使用的前三个帧:
let fairyFrames = frames(
assets["img/fairy.png"], //The tileset image
[[0,0],[48,0],[96,0]], //The 2D array of x/y frame positions
48, 32 //The width and height of each frame
);
现在通过提供fairyFrames作为源来创建 sprite:
let fairy = sprite(fairyFrames, 224, 326);
仙女精灵现在有三个图像帧存储在一个名为frames的内部数组属性中。默认显示第一帧,但是你可以使用gotoAndStop让精灵显示另一帧。下面介绍如何让仙女显示第三帧,如图图 4-15 。
fairy.gotoAndStop(2);

图 4-15 。使用gotoAndStop改变精灵显示的帧
gotoAndStop方法通过将精灵的sourceX和sourceY值设置为由帧号指定的 x / y 位置 2D 数组来实现这一点。下面是来自Sprite类的代码:
if (this.frames[0] instanceof Array) {
this.sourceX = this.frames[frameNumber][0];
this.sourceY = this.frames[frameNumber][1];
}
您也可以应用类似的技术来加载多个纹理贴图集帧,如下所示。
使用多个纹理贴图集帧
假设您想为一个游戏制作一个具有三种图像状态的可点击按钮:向上、向上和向下。你在一个图像编辑器中创建每个状态,并使用这些图像制作一个纹理贴图集,如图图 4-16 所示。按钮框 ID 名称为up.png、over.png和down.png。

图 4-16 。使用纹理贴图集创建精灵图像状态
接下来,将纹理贴图集加载到游戏中:
assets.load([
"img/button.json"
]).then(() => setup());
您希望创建一个可以使用所有三种按钮图像状态的 sprite。首先,创建一个引用三个帧 ID 名称的数组:
let buttonFrames = [
assets["up.png"],
assets["over.png"],
assets["down.png"]
];
然后创建一个 sprite 并提供buttonFrames数组作为源:
let button = sprite(buttonFrames, 300, 280);
Sprite类将这些加载到 sprite 的frames数组中,并设置 sprite 显示第一帧。你现在可以使用gotoAndStop来有选择地显示这些帧中的任何一个。下面是如何显示"over.png"帧(帧数组中的第二个元素):
button.gotoAndStop(1);
gotoAndStop方法通过将精灵的sourceX和sourceY切换到纹理贴图集中正确的 x 和 y 帧值来实现这一点。下面是来自gotoAndStop方法的代码片段:
else if (this.frames[frameNumber].frame) {
this.sourceX = this.frames[frameNumber].frame.x;
this.sourceY = this.frames[frameNumber].frame.y;
}
你可以加载任意多帧的精灵,你将在第八章中学习如何使用gotoAndStop作为构建关键帧动画播放器的基本构件。
使用多个图像文件
为了获得最大的灵活性,Sprite类还允许您将单个图像文件加载到精灵中。假设您想要制作一个包含三种动物图像的精灵,每种动物都是单独的帧。首先,将你的图像文件加载到assets对象中:
assets.load([
"img/cat.png",
"img/tiger.png",
"img/hedgehog.png"
]).then(() => setup());
接下来,创建一个引用这些图像文件的数组:
let animalImages = [
assets["img/hedgehog.png"],
assets["img/tiger.png"],
assets["img/cat.png"]
];
然后使用图像数组初始化 sprite:
let animals = sprite(animalImages, 320, 410);
默认情况下,数组中的第一个图像刺猬将显示在 sprite 上。如果您希望 sprite 显示猫的图像,使用gotoAndStop显示第三个数组元素,如下所示:
animals.gotoAndStop(2);
看一下源代码中的gotoAndStop方法,你会发现只要将精灵的源切换到正确的图像就可以了。
制作你自己的精灵
现在你已经有了一个灵活而有用的制作精灵的系统,在接下来的章节中你会看到所有这些新代码将如何帮助我们以一种有趣而高效的方式制作游戏。这些精灵类型是你制作几乎所有你能想到的 2D 动作游戏所需要的。你会在本章源文件的library/display文件夹中找到所有这些新代码。如果你想使用这些新的类和函数开始制作你自己的精灵,按如下方式导入它们:
import {
makeCanvas, rectangle, circle, sprite,
line, group, text, stage, render, remove,
frame, frames
} from "../library/display";
另外,如果您需要预加载任何图像、字体或 JSON 文件,不要忘记从library/utilities导入assets对象:
import {assets} from "../library/utilities";
然后使用assets.load 加载您可能需要的任何文件,并调用setup函数:
assets.load([
"fonts/puzzler.otf",
"img/cat.png",
"img/animals.json",
"img/fairy.png",
"img/tiger.png",
"img/hedgehog.png",
"img/button.json"
]).then(() => setup());
接下来,使用setup函数创建画布,设置舞台,并创建你的精灵。调用render函数来显示它们。
function setup() {
//Create the canvas and stage
let canvas = makeCanvas(512, 512);
stage.width = canvas.width;
stage.height = canvas.height;
//..Use the code from this chapter to make sprites here...
//Then render them on the canvas:
render(canvas);
}
在本章的源文件中你会发现一个名为allTheSprites.html的文件,如图 4-17 中的所示,它展示了本章中的新代码。仔细看看代码如何生成您在画布上看到的图像,并尝试进行自己的更改和添加。

图 4-17 。你在本章学到的所有新技术场景图
摘要
在这一章中,你已经了解了精灵对于在屏幕上快速显示图像是多么的有用。您已经使用父/子嵌套创建了分层场景图,创建了显示矩形、圆形、线条、文本和图像的精灵,甚至创建了具有多个帧的精灵。您还深入了解了如何渲染小精灵,如何使用纹理贴图集制作小精灵,以及如何使用 ES6 类来构建有用的类继承系统,并且学习了代码抽象的原则。
现在我们知道了如何创建精灵,我们如何让他们移动呢?在下一章中,你将会学到所有关于脚本动画的知识,这是每个游戏设计者需要知道的在屏幕上移动精灵的基本技术。**
五、让东西动起来
快谢幕了!你的小精灵们都打扮好了,他们背好了台词,准时到达排练厅,耐心地站在舞台上等着你指挥他们。现在怎么办?你需要让它们生动起来!
游戏中有两种主要的精灵动画制作方式:
- 脚本动画:让精灵在屏幕上移动。
- 关键帧动画:改变精灵的外观。显示一系列略有不同的预渲染图像,就像绘制手绘卡通或动画书一样。
关键帧动画是关于动画精灵的外观,而脚本动画是关于动画其在屏幕上的 x,y 位置。在这一章中,你将学习如何使用脚本动画让精灵移动。在第八章中,你将学习如何制作关键帧动画来创造游戏角色行走的效果。
在这一章中,我们还将详细了解游戏循环:让精灵移动的循环功能。您将学习一些将游戏的更新逻辑与其渲染逻辑分离的策略,以实现尽可能平滑的精灵动画。
基本运动
要使用脚本动画制作精灵动画,你需要改变它在游戏循环中的 x,y 位置。游戏循环是一种每秒更新 60 次的功能,这样你就可以逐渐改变精灵的位置来创造运动的幻觉。所有的精灵动画和大部分游戏逻辑都发生在游戏循环中。用 JavaScript 和 HTML5 做游戏循环最好的方法就是用一个叫window.requestAnimationFrame的方法。这里有一些代码来说明如何使用这个方法让一个球从画布的边缘反弹出去。(图 5-1 说明了这段代码的作用。)
//Import code from the library
import {makeCanvas, circle, stage, render} from "../library/display";
//Create the canvas and stage
let canvas = makeCanvas(256, 256);
stage.width = canvas.width;
stage.height = canvas.height;
//Create a ball sprite
//`circle` arguments: diameter, fillStyle, strokeStyle, lineWidth, x, y
let ball = circle(32, "gray", "black", 2, 96, 128);
//Set the ball's velocity
ball.vx = 3;
ball.vy = 2;
//Start the game loop
gameLoop();
function gameLoop() {
requestAnimationFrame(gameLoop);
//Move the ball
ball.x += ball.vx;
ball.y += ball.vy;
//Bounce the ball off the canvas edges.
//Left and right
if(ball.x < 0
|| ball.x + ball.diameter > canvas.width) {
ball.vx = -ball.vx;
}
//Top and bottom
if(ball.y < 0
|| ball.y + ball.diameter > canvas.height) {
ball.vy = -ball.vy;
}
//Render the animation
render(canvas);
}

图 5-1 。以每秒 60 帧的速度在画布上拍球
注意这段代码使用了从library文件夹中的display模块导入的方法和对象。第四章展示了如何导入和使用这些工具。你会在本章的源文件中找到library文件夹。
速度是球运行的速度和方向。它由两个 sprite 属性表示:vx和vy 。
ball.vx = 3;
ball.vy = 2;
vx代表球的水平速度:向右或向左移动的速度。vy代表它的垂直速度:上下移动的速度有多快。
注vx和vy这两个属性实际上代表一个矢量。请参阅附录,了解什么是矢量以及如何在游戏中利用矢量的力量。
球移动是因为它的速度被加到了它在requestAnimationFrame循环中的当前位置:
gameLoop();
function gameLoop() {
requestAnimationFrame(gameLoop);
ball.x += ball.vx;
ball.y += ball.vy;
//...
}
对于每个新帧,球的 x 位置将改变 3 个像素,其 y 位置将改变 2 个像素。因为运动的变化很小,而且代码每秒钟更新 60 次,所以会产生运动的错觉。这是脚本动画的基础。
requestAnimationFrame方法是驱动连续循环代码的引擎。它的参数是应该在循环中调用的函数:
requestAnimationFrame(functionToLoop);
告诉浏览器应该以 16 毫秒(每秒 60 次)的间隔调用循环函数。每个循环更新都被称为一个动画帧,因为该效果模拟了一个运行的视频或连续画面。(视频和电影由一系列静止图像组成,称为帧,它们按顺序播放以创造运动的错觉。)显示这些帧的实际速率与显示器的屏幕刷新率同步,通常为 60Hz(但不总是这样;在本章快结束时,你会学到一些应对这些差异的策略。这种同步使循环高度优化,因为浏览器只会在最合适的时候调用循环代码,而不会忙于执行其他任务。
注意无论你是在循环函数的开头还是结尾调用requestAnimationFrame都没关系。那是因为requestAnimationFrame没有在代码中出现的地方调用循环函数;它只是允许浏览器随时调用循环函数。
requestAnimationFrame可以向循环函数传递一个可选的时间戳参数。时间戳告诉你自从requestAnimationFrame第一次开始循环以来已经过了多少毫秒。下面是访问时间戳的方法:
function gameLoop(timestamp) {
requestAnimationFrame(gameLoop);
console.log(`elapsed time: ${timestamp}`);
}
在这一章的结尾,你会学到如何使用这个时间戳来微调游戏循环。
注意你也可以用Date.now()来捕捉当前的 UTC 时间,单位是毫秒。UTC(协调世界时),也称为 Unix 时间,是一种标准的时间度量单位,它告诉您自 1970 年 1 月 1 日以来已经过去了多少毫秒。您可以通过捕获当前的 UTC 时间,将其与更早或更晚的时间进行比较,并使用差值来计算运行时间,从而将它用作通用时间戳。为了获得更高的精度,使用Performance.now(),它会给你一个精确到千分之一毫秒的时间。
在第一个例子中,球从画布的边缘反弹回来。它是怎么做到的?两个if语句检查球的边缘是否碰到画布的边缘。如果这是真的,球的速度是相反的。
//Left and right
if(ball.x < 0
|| ball.x + ball.diameter > canvas.width) {
ball.vx = -ball.vx;
}
//Top and bottom
if(ball.y < 0
|| ball.y + ball.diameter > canvas.height) {
ball.vy = -ball.vy;
}
在速度(ball.vx和ball.vy)前加一个负号会使球在碰到画布边缘时改变方向。
增加加速度和摩擦力
您可以通过添加物理属性,如加速度和摩擦力,使对象以更自然的方式移动。加速度使物体逐渐加速,摩擦力使其减速。图 5-2 显示了一个逐渐加速的球的例子。当它碰到画布的边缘时,会反弹回来并减速停止。

图 5-2 。用加速度加速,用摩擦力减速
为了实现这一点,向 sprite 添加一些新的acceleration和friction属性,并将其速度初始化为零:
let ball = circle(32, "gray", "black", 2, 64, 96);
//Set the ball's velocity to 0
ball.vx = 0;
ball.vy = 0;
//Acceleration and friction properties
ball.accelerationX = 0.2;
ball.accelerationY = -0.2;
ball.frictionX = 1;
ball.frictionY = 1;
加速度值 x 和 y 分别为 0.2 和–0.2,这是您希望球的速度在每一帧中增加的量。摩擦力 x 和 y 的值 1 是速度应该乘以的量,以使其减速。你不希望最初对球施加任何摩擦力,所以将值指定为 1 实质上意味着“没有摩擦力”(那是因为速度乘以 1 只是得到相同的速度值,没有任何变化。1 乘以 1 等于 1,对吗?)任何小于 1 的摩擦力值,比如 0.98,都会使物体逐渐减速。加速度和摩擦力可以不同地影响 x 和 y 轴,因此每个轴都有摩擦力和加速度属性。
下面是使用这些属性使球加速的游戏循环。球一碰到画布边缘,加速度就设置为零,摩擦力设置为 0.98,使球逐渐减速并停止。
//Start the game loop
gameLoop();
function gameLoop() {
requestAnimationFrame(gameLoop);
//Apply acceleration to the velocity
ball.vx += ball.accelerationX;
ball.vy += ball.accelerationY;
//Apply friction to the velocity
ball.vx *= ball.frictionX;
ball.vy *= ball.frictionY;
//Move the ball by applying the new calculated velocity
//to the ball's x and y position
ball.x += ball.vx;
ball.y += ball.vy;
//Bounce the ball off the canvas edges and slow it to a stop
//Left and right
if(ball.x < 0
|| ball.x + ball.diameter > canvas.width) {
//Turn on friction
ball.frictionX = 0.98;
ball.frictionY = 0.98;
//Turn off acceleration
ball.accelerationX = 0;
ball.accelerationY = 0;
//Bounce the ball on the x axis
ball.vx = -ball.vx;
}
//Top and bottom
if(ball.y < 0
|| ball.y + ball.diameter > canvas.height) {
//Turn on friction
ball.frictionX = 0.98;
ball.frictionY = 0.98;
//Turn off acceleration
ball.accelerationX = 0;
ball.accelerationY = 0;
//Bounce the ball on the y axis
ball.vy = -ball.vy;
}
//Render the animation
render(canvas);
}
你可以在前面的代码中看到加速度是加到球的速度上的:
ball.vx += ball.accelerationX;
ball.vy += ball.accelerationY;
摩擦力是乘以球的速度:
ball.vx *= ball.frictionX;
ball.vy *= ball.frictionY;
要使球移动,将其新速度添加到当前位置:
ball.x += ball.vx;
ball.y += ball.vy;
通过在游戏中的某个地方改变球的摩擦力和加速度值,例如当它撞到画布边缘时,该代码将正确地重新计算球的速度。在下一章,你将学习如何使用鼠标、键盘和触摸来改变精灵的加速度和摩擦力。
重力
重力是作用在物体上的持续向下的力。您可以通过将一个恒定的正值应用到精灵的垂直速度(vy)来将其添加到精灵,如下所示:
ball.vy += 0.3;
注意记住,画布的 y 位置随着你从画布的顶部移动到底部而增加。这意味着如果你想把一个物体拉下来,你必须给它的 y 位置加上一个值,而不是减去它。
如果你混合重力和弹跳效果,你可以创建一个非常真实的弹跳球模拟。从本章的源代码中运行gravity.html文件以获得一个工作示例。球以随机速度开始,在画布上反弹,并逐渐滚动到底部停止。图 5-3 说明了你将会看到的东西。

图 5-3 。添加一些重力来制作一个真实的弹跳球
不仅仅是重力有助于创造这种效果。为了增加一点真实性,球也有质量和摩擦力。当球击中画布的一个边缘时,其质量从速度中扣除,以模拟表面吸收一些冲击力。当球在地面上时,一些摩擦力作用于其在 x 轴上的速度。两种力量都以一种非常现实的方式让球慢下来。没有它们,球将继续在画布上无休止地反弹,而不会失去任何动量。让我们看看完成所有这些的代码。
ball sprite 增加了四个新属性 : gravity、mass、frictionX和frictionY。它还被初始化为随机的vx和vy速度,这样每次程序运行时球以不同的力和方向值移动:
let ball = circle(32, "gray", "black", 2, 96, 128);
//Random velocity
ball.vx = randomInt(5, 15);
ball.vy = randomInt(5, 15);
//Physics properties
ball.gravity = 0.3;
ball.frictionX = 1;
ball.frictionY = 0;
ball.mass = 1.3;
mass应该是大于 1 的任何数字。
可以看到 random vx和vy属性被初始化为 5 到 15 之间的随机数。我们的代码在你在第一章中学到的自定义randomInt函数的帮助下做到了这一点:
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
如果你在游戏中需要随机的浮点数,你可以使用相关的函数randomFloat :
function randomFloat(min, max) {
return min + Math.random() * (max - min);
}
下面是一个游戏循环,它使用这些属性使球下落并从画布边缘弹开:
//Start the game loop
gameLoop();
function gameLoop() {
requestAnimationFrame(gameLoop);
//Apply gravity to the vertical velocity
ball.vy += ball.gravity;
//Apply friction. `ball.frictionX` will be 0.96 if the ball is
//on the ground, and 1 if it's in the air
ball.vx *= ball.frictionX;
//Move the ball by applying the new calculated velocity
//to the ball's x and y position
ball.x += ball.vx;
ball.y += ball.vy;
//Bounce the ball off the canvas edges and slow it to a stop
//Left
if (ball.x < 0) {
ball.x = 0;
ball.vx = -ball.vx / ball.mass;
}
//Right
if (ball.x + ball.diameter > canvas.width) {
ball.x = canvas.width - ball.diameter;
ball.vx = -ball.vx / ball.mass;
}
//Top
if (ball.y < 0) {
ball.y = 0;
ball.vy = -ball.vy / ball.mass;
}
//Bottom
if(ball.y + ball.diameter > canvas.height) {
//Position the ball inside the canvas
ball.y = canvas.height - ball.diameter;
//Reverse its velocity to make it bounce, and dampen the effect with mass
ball.vy = -ball.vy / ball.mass;
//Add some friction if it's on the ground
ball.frictionX = 0.96;
} else {
//Remove friction if it's not on the ground
ball.frictionX = 1;
}
//Render the animation
render(canvas);
}
你可以看到重力是加到球的vy属性上的:
ball.vy += ball.gravity;
随着时间的推移,这逐渐将球拉向画布的底部。
注意在这个例子中,重力是球的一个属性,但是在游戏中,你可能想把它创建为一个影响所有精灵的全局值。
四个if语句检查球和画布边缘之间的碰撞。如果球越过画布边界,它会被移回,这样它就刚好在边界内。例如,如果球穿过画布的右侧,其 x 位置被设置为等于画布的宽度减去球直径的一半。
ball.x = canvas.width - ball.diameter;
这些检查确保球完全脱离画布边界,并且当它的速度改变时不会被它们粘住。
接下来,球的速度被逆转,使其反弹。该代码还将球的速度除以它的mass (1.3),以便在撞击中损失一点力。当球击中画布的右侧时会发生这种情况:
ball.vx = -ball.vx / ball.mass;
这将使球在每次撞击表面时逐渐减速。
检查球是否击中画布底部的if语句还做了一件事。它将球的摩擦力设置为 0.96,这样如果球在地面上,速度会更慢。
if(ball.y + ball.diameter > canvas.height) {
ball.y = canvas.height - ball.radius;
ball.vy = -ball.vy / ball.mass;
ball.frictionX = 0.96;
} else {
ball.frictionX = 1;
}
如果球不在地面上,else块将球的摩擦力设置回 1,这样它就可以在空中自由移动。
从这些例子中你可以看到,只需要一些简单的物理属性和一点逻辑,就可以很容易地让精灵以复杂而有趣的方式运行。我希望这个物理学的快速介绍会启发你开始在你自己的游戏中使用它。真的没有比这更复杂的了。
在一个区域内包含精灵
在一个区域中包含一个精灵,比如画布的边缘,是一个非常常见的游戏设计需求。因此,让我们创建一个名为contain的函数,您可以在任何游戏项目中使用它,就像这样:
let collision = contain(sprite, bounds, bounce, callbackFunction)
第一个参数是要包含的 sprite,第二个参数bounds是一个对象,它具有定义包含区域的 x 、 y 、width 和 height 属性。如果精灵从边界边缘反弹,将bounce(第三个参数)设置为true。您还可以提供一个可选的额外回调函数(第四个参数),如果 sprite 碰到任何边界边缘,该函数将运行。contain函数返回一个名为collision 的变量,它告诉你精灵是否碰到了边界的"top"、"left"、"bottom"或"right"边缘。下面是完整的contain功能:
export function contain (sprite, bounds, bounce = false, extra = undefined){
let x = bounds.x,
y = bounds.y,
width = bounds.width,
height = bounds.height;
//The `collision` object is used to store which
//side of the containing rectangle the sprite hits
let collision;
//Left
if (sprite.x < x) {
//Bounce the sprite if `bounce` is true
if (bounce) sprite.vx *= -1;
//If the sprite has `mass`, let the mass
//affect the sprite's velocity
if(sprite.mass) sprite.vx /= sprite.mass;
sprite.x = x;
collision = "left";
}
//Top
if (sprite.y < y) {
if (bounce) sprite.vy *= -1;
if(sprite.mass) sprite.vy /= sprite.mass;
sprite.y = y;
collision = "top";
}
//Right
if (sprite.x + sprite.width > width) {
if (bounce) sprite.vx *= -1;
if(sprite.mass) sprite.vx /= sprite.mass;
sprite.x = width - sprite.width;
collision = "right";
}
//Bottom
if (sprite.y + sprite.height > height) {
if (bounce) sprite.vy *= -1;
if(sprite.mass) sprite.vy /= sprite.mass;
sprite.y = height - sprite.height;
collision = "bottom";
}
//The `extra` function runs if there was a collision
//and `extra` has been defined
if (collision && extra) extra(collision);
//Return the `collision` object
return collision;
};
下面是如何使用这个contain函数来替换前一个例子中重力代码的原始版本中的四个if语句:
let collision = contain(ball, stage.localBounds, true);
如果球击中了舞台的任何边界,collision变量将具有值"top"、"right"、"bottom"或"left"。第二个参数显示了如何使用 sprite 的localBounds属性,你在第四章的中学到了这一点。localBounds是一个具有 x 、 y 、宽度和高度属性的对象,定义了一个矩形区域。您也可以通过提供一个自定义的bounds对象作为第二个参数来达到同样的效果,如下所示:
let collision = contain(
ball,
{x: 0, y: 0, width: canvas.width, height: canvas.height},
true
);
使用 stage 的localBounds属性是一种方便的快捷方式。
如果球碰到了舞台的边界,collision变量会告诉你碰撞发生在舞台的哪一边。在这个重力的例子中,如果球碰到画布的底部,你想让球慢下来。这意味着你可以检查collision是否有"bottom"的值,如果有,给球施加一些摩擦力:
if (collision === "bottom") {
ball.frictionX = 0.96;
} else {
ball.frictionX = 1;
}
contain函数的另一个特性是可以添加一个额外的可选回调函数作为第四个参数。以下是如何:
let collision = contain(
ball, stage.localBounds, true,
() => {
console.log("Hello from the extra function!");
}
);
每当球碰到舞台边界时,额外回调函数中的代码就会运行。这是一种将一些定制代码注入到contain函数中的便捷方式,无需修改函数本身。contain函数检查这个额外回调的存在,并在返回collision值之前运行它。下面是来自contain函数的代码:
if (collision && extra) extra(collision);
您可以看到,extra回调也可以访问collision值,所以如果需要的话,它可以使用这些信息。这是一个非常有用技巧,你会在本书中看到更多的例子。
仅供参考,这里是重力示例的整个gameLoop,其中四个if语句被contain函数替换为
function gameLoop() {
requestAnimationFrame(gameLoop);
//Move the ball
ball.vy += ball.gravity;
ball.vx *= ball.frictionX;
ball.x += ball.vx;
ball.y += ball.vy;
//Check for a collision between the ball and the stage boundaries
let collision = contain(ball, stage.localBounds, true);
if (collision === "bottom") {
//Slow the ball down if it hits the bottom
ball.frictionX = 0.96;
} else {
ball.frictionX = 1;
}
//Render the animation
render(canvas);
}
这比原来的代码少得多,您可以在任何其他项目中重用contain函数。
微调游戏循环
现在你知道了用代码让精灵移动的所有基础知识。很简单,不是吗?但是你还需要知道一些关于游戏循环的东西,这样你才能最大限度地控制精灵在屏幕上的移动。在本章的后半部分,你将学习如何设置游戏的帧率,以及如何微调游戏循环以获得最平滑的精灵动画。
第一步,也是最重要的一步,是将游戏逻辑和渲染逻辑分离开来。你已经完成了一半:所有的精灵渲染都发生在你在前一章学过的render函数中。你需要做的唯一新的事情就是将游戏逻辑保持在自己的函数update 中。当游戏循环运行时,它将首先更新游戏逻辑,然后渲染精灵:
gameLoop();
function gameLoop() {
requestAnimationFrame(gameLoop);
//update the game logic
update();
//Render the sprites
render(canvas);
}
function update() {
//All your game logic goes here
}
function render(canvas) {
//The same rendering code from Chapter 4
}
在前面的例子中,您将会看到这个额外的模块化变得多么重要。
设置帧速率
显示一系列动画帧的速度被称为帧率 ,以每秒帧数衡量,或 fps 。它是动画每秒钟更新或改变的次数。帧速率越高,动画越流畅,帧速率越低,动画越不流畅。任何动画的帧速率通常由一个名为fps的变量的值决定。如果你想让你的动画每秒播放 12 帧,你可以这样设置fps:
fps = 12
Fps 对于我们人类来说是一个容易理解的度量,但是 JavaScript 是以毫秒为单位,而不是以帧为单位。因此,如果我们想要使用fps值以固定的间隔播放帧,我们需要使用基于毫秒的帧速率。将fps除以 1000,计算出每帧之间应该间隔多少毫秒,如下所示:
frameRate = 1000 / fps
如果fps是 12,那么frameRate将是 83 毫秒。这是总的思路,但是怎么用它来控制我们游戏的帧率呢?
在大多数系统上,以 60Hz 的速度更新画布,这意味着你的游戏循环将以每秒 60 帧的速度运行。又快又流畅。requestAnimationFrame为您提供最流畅的动画,因为浏览器会在一次屏幕刷新中以固定的帧间隔绘制所有屏幕图形。它充分利用了宝贵的 CPU 时间。
每秒 60 帧的帧速率意味着你有大约 16 毫秒的空闲时间来运行你的游戏代码。实际上,两帧之间只有 13 毫秒,因为浏览器进程会到处消耗掉额外的几毫秒。为了安全起见,再多加几毫秒,假装你真的只有 10 毫秒。因此,这是 10 毫秒空闲 CPU 时间的预算,用于完成所有的游戏逻辑、物理、UI 处理和图形渲染。这并不多,这也是为什么游戏开发者倾向于痴迷于性能和优化。如果您在某处只能节省 1 毫秒的处理时间,那么这大约是您性能预算的 10%。如果你的游戏代码需要超过 10 毫秒来运行,帧速率将会下降,动画将会变成 janky 。我们都知道简洁性是什么:紧张、跳动和口吃的动画打破了游戏世界的沉浸感。
邱建缉捕队
简洁性经常是由 JavaScript 的垃圾收集器执行自动化内存管理任务造成的。不幸的是,您无法控制垃圾收集器何时运行,也无法控制它的工作效率。垃圾收集由 JavaScript 运行时环境(通常是 web 浏览器)管理,您只能希望它能很好地完成工作。
大多数 web 浏览器开发工具都有一个帧率图,告诉你游戏运行的速度。如果你在游戏运行的时候看图表,你可能会注意到一切看起来都很好,然后,不知从哪里,帧速率会有一个短暂的下降。这叫做尖峰,如图图 5-4 所示。尖刺是 jankiness 的常见原因。

图 5-4 。游戏设计者的克星:帧速率峰值!
当游戏或浏览器的处理量突然增加时,就会出现峰值。您可以通过在开发人员工具中打开浏览器的帧时间线查看器并查看帧图形来查看其原因。帧图告诉你游戏中的每一帧花了多少毫秒来处理。它还显示了游戏中每段代码在每一帧中运行的确切时间。如果你注意到任何不寻常的峰值,检查一下是什么引起的。通常,最大的峰值是由浏览器自动垃圾收集引起的,如图 5-5 所示。

图 5-5 。消耗 33 毫秒处理时间的垃圾收集事件
图 5-5 中的垃圾收集事件花费了 33 毫秒。因此,即使你的游戏以 60 帧/秒的速度流畅运行,垃圾收集事件也会使它突然下降到 20 帧/秒,导致短暂的抖动。那么,垃圾收集器实际上在做什么,用 33 毫秒来运行呢?
有时候很难说。但是最大的垃圾收集高峰通常是由浏览器清除图形渲染所需的临时数据引起的。最好的解决方案是尽可能使用 Canvas 或 WebGL 来渲染你的精灵,因为它们比 DOM 渲染更节省资源。如果你不认为你有一个渲染瓶颈,你有任何循环,递归函数,或排序算法可能会导致它吗?你正在使用一个进行大量密集计算的物理库吗?如果你已经做了你认为你能做的所有优化,而你的游戏仍然是 janky,考虑降低你游戏的帧速率。
注现代垃圾收集器学习如何在游戏运行时管理游戏内存。因此,在最初的几秒钟内,您可能会看到许多内存峰值,然后随着游戏的继续运行,这些峰值会越来越少。
设置游戏的帧速率
如果你将游戏的帧速率设置在 30 到 50 fps 之间,你将赢得一点不错的开销,而你的游戏看起来仍然运行流畅。30 fps 是你应该考虑的最低速度。
以下是设置帧速率的方法:
- 决定你的速率:12 fps,30 fps,40 fps,或者任何你想使用的速率。
- 算出帧时长。每帧之间应该间隔多少毫秒?这将是 1000 毫秒除以帧速率。例如,如果您的帧速率为 30 fps,则每帧的持续时间应为 33 毫秒。
- 仅当自上一帧以来经过的时间与帧持续时间匹配时,才更新该帧。
- 将下一帧的更新时间设置为当前时间加上帧持续时间。
下面是一些代码的例子,这些代码可以让游戏以每秒 30 帧的速度运行:
//Set the frame rate and find the frame duration in milliseconds
let fps = 30,
start = 0,
frameDuration = 1000 / fps;
//Start the game loop
gameLoop();
function gameLoop(timestamp) {
requestAnimationFrame(gameLoop);
if (timestamp >= start) {
//update the game logic
update();
//Render the sprites
render(canvas);
//Reset the frame start time
start = timestamp + frameDuration;
}
}
这段代码并不能保证你的游戏运行速度不会低于 30fps,只是保证不会超过 30 fps。代码将帧速率钳位在上限 30 fps。如果它运行得更慢,你可能在你的游戏代码中的某个地方有更大的问题,无论如何调整帧速率都不可能解决。这时就该深入代码,查找内存泄漏或进行一些真正激进的优化了。
像这样固定帧速率还有另一个优势:它让你的游戏在所有设备上以一致的速度运行。requestAnimationFrame方法与显示器的刷新率同步,通常是 60Hz——但并不总是如此。许多较新的显示器和设备屏幕有 120Hz 的刷新率,这使得requestAnimationFrame以双倍的速度运行:每秒 120 帧。通过设置帧率,可以保证你的游戏在任何平台上运行都不会太快。
固定更新时间,可变渲染时间
还有一个更进一步的方法来优化游戏循环。不是以相同的帧速率运行update和render函数,而是以不同的速率运行它们。例如,您可以以固定的每秒 30 帧的速度运行update游戏逻辑,并让render功能以每个用户系统能够运行的最大速度运行——60 fps、120 fps 或更高。为此,你的精灵需要两个位置:一个由游戏逻辑设置,另一个由渲染器设置。但是您还想消除由系统处理开销引起的帧速率波动,这样动画就不会抖动。在一些简单数学的帮助下,你可以使渲染的精灵的位置与游戏逻辑保持紧密同步,即使它们在不同的时间被调用,即使在帧速率上可能有小的停顿。这是一种叫做的技术,固定时间步长,可变渲染。在一个完美的世界里,它会给你最大的游戏逻辑计算空间。而且只要你的游戏逻辑帧率小于你的渲染帧率,你的精灵动画看起来就会很流畅,没有 jank。
它是如何工作的?假设您决定以 15 fps 的速度更新游戏逻辑,以 60 fps 的速度更新渲染逻辑。你运行一个普通的requestAnimationFrame循环,它以大约 60 fps 的速度运行。每一帧都调用render函数,但是直到最后一帧等于 15 fps 时才更新游戏逻辑。理想情况下,您最终会得到对update和render的调用,如下所示:
update
render
render
render
render
update
render
render
render
render
update
但是由于不可避免的现实世界的简洁性,一些渲染调用可能会间歇性地下降到 30 fps 或更低。这意味着对update和render的调用实际上可能是这样的:
update
render
render
update
render
update
render
render
render
update
你可以看到update仍然以固定的速率被调用,但是render是波动的。如果您在两次更新调用之间遇到滞后的render调用,会发生什么?
update
render
update
除非我们采用一种叫做插值的技术,它在两次更新调用之间平均出一个精灵的渲染位置,否则动画看起来仍然很滑稽。插值消除帧速率中的不一致,这样即使在低帧速率下你也可以有非常平滑的精灵动画。让我们来看看它是如何工作的。
编写代码
这是实现所有这些概念的新游戏循环。注意,代码调用了一个名为renderWithInterpolation 的新函数来呈现精灵。我将在前面解释这是如何工作的。
let fps = 30,
previous = 0,
frameDuration = 1000 / fps,
lag = 0;
function gameLoop(timestamp) {
requestAnimationFrame(gameLoop);
//Calculate the time that has elapsed since the last frame
if (!timestamp) timestamp = 0;
let elapsed = timestamp - previous;
//Optionally correct any unexpected huge gaps in the elapsed time
if (elapsed > 1000) elapsed = frameDuration;
//Add the elapsed time to the lag counter
lag += elapsed;
//Update the frame if the lag counter is greater than or
//equal to the frame duration
while (lag >= frameDuration) {
//Update the game logic
update();
//Reduce the lag counter by the frame duration
lag -= frameDuration;
}
//Calculate the lag offset. This tells us how far
//we are into the next frame
let lagOffset = lag / frameDuration;
//Render the sprites using the `lagOffset` to
//interpolate the sprites' positions
renderWithInterpolation(canvas, lagOffset);
//Capture the current time to be used as the previous
//time in the next frame
previous = timestamp;
}
gameLoop首先计算出自前一帧以来已经过去了多长时间:
let elapsed = timestamp - previous;
看一下gameLoop中的最后一行,您会看到previous是对timestamp?? 的当前值的引用:
previous = timestamp;
因此,当下一帧摆动时,previous仍将包含旧值。这就是为什么我们可以在循环开始时使用它来帮助计算自上一帧以来经过的时间。
作为额外的预防措施,代码首先检查elapsed时间是否不大于某个非常大的值,比如 1 秒。如果是这种情况,游戏代码可能已经崩溃,或者用户可能已经切换了浏览器标签:
if (elapsed > 1000) elapsed = frameDuration;
这只是将elapsed时间设置回合理时间的安全网。
名为lag 的变量用于计算帧之间经过的时间:
lag += elapsed;
当lag累积的量大于或等于帧速率时,它复位并调用update函数:
while (lag >= frameDuration) {
update();
lag -= frameDuration;
}
正是这个while循环充当了一种帧速率不一致的减震器。它会根据需要多次调用update函数,直到lag赶上当前的帧速率。
接下来,代码使用滞后量来计算更新帧速率和渲染帧速率之间的差异。该值保存在名为lagOffset 的变量中:
let lagOffset = lag / frameDuration;
lagOffset值给出了更新帧速率和渲染帧速率之间的比例差。它将是一个介于 0 和 1 之间的正常数字。(这个值通常被称为时间增量,或 dt。)这是我们需要的值,使用新的renderWithInterpolation函数来帮助计算出小精灵应该被渲染的精确位置。
renderWithInterpolation(canvas, lagOffset);
renderWithInterpolation如何在更新帧速率和渲染帧速率之间找到折衷的精灵位置?
插入文字
让我们想象游戏逻辑以每秒 15 帧的速度更新,精灵以每秒 60 帧的速度渲染。这意味着每个更新调用将有四个呈现调用。为了保持动画流畅,渲染器需要猜测在每次更新之间应该在哪些位置显示精灵,并在这些位置绘制精灵。这叫做插值。下面是计算它的基本公式:
renderedPosition = (currentPosition - previousPosition) * lagOffset + previousPosition;
公式的关键部分是精灵的速度是通过从它在当前帧中的位置减去它在上一帧中的位置来动态计算的。怎么知道小精灵之前的位置是什么?在对精灵做任何改变之前,你的第一步就是在每一帧捕捉精灵的当前位置。然后,您可以使用该捕捉位置作为精灵在下一帧中的前一个位置。下面是如何实施这一策略:
function update() {
sprite.previousX = sprite.x;
sprite.previousY = sprite.y;
//Next, change the sprite’s velocity and position
//as you normally would for the current frame
}
下面是如何在我们的弹跳球示例中实现这一点:
function update() {
//Capture the ball's previous positions
ball.previousX = ball.x;
ball.previousY = ball.y;
//Move the ball and bounce it off the stage’s edges
ball.vy += ball.gravity;
ball.vx *= ball.frictionX;
ball.x += ball.vx;
ball.y += ball.vy;
let collision = contain(ball, stage.localBounds, true);
if (collision === "bottom") {
ball.frictionX = 0.96;
} else {
ball.frictionX = 1;
}
}
当前帧完成更新后,ball.previousX和ball.previousY仍将包含前一帧的精灵位置值。
现在你可以使用renderWithInterpolation函数获取所有这些新数据,并用它来插值精灵的位置。renderWithInterpolation函数与你在前一章中学习使用的旧的render函数相同,除了计算精灵渲染位置的新代码。
function renderWithInterpolation(canvas, lagOffset) {
//...
//Interpolate the position
if (sprite.previousX) {
sprite.renderX = (sprite.x - sprite.previousX) * lagOffset + sprite.previousX;
} else {
sprite.renderX = sprite.x;
}
if (sprite.previousY) {
sprite.renderY = (sprite.y - sprite.previousY) * lagOffset + sprite.previousY;
} else {
sprite.renderY = sprite.y;
}
//Draw the sprite at its interpolated position
ctx.translate(
sprite.renderX + (sprite.width * sprite.pivotX),
sprite.renderY + (sprite.height * sprite.pivotY)
);
//...
}
(你会在本书源代码的library/display文件夹中找到完整的renderWithInterpolation函数。)
现在,如果你将游戏的 fps 设置为 15,精灵仍然会以 60 fps 渲染,给你平滑,无抖动的动画和最小的开销。
注意你可以用同样的方法插入其他的精灵属性,比如旋转和阿尔法。
插值多个精灵
如果你的游戏中有很多精灵,你需要在改变他们的当前位置之前捕捉他们所有的先前位置。一个简单的方法是在游戏循环中调用update之前运行一个capturePreviousPositions函数,就像这样:
while (lag >= frameDuration) {
capturePreviousPositions(stage);
update();
lag -= frameDuration;
}
capturePreviousPositions函数循环遍历所有精灵及其子精灵,并将它们之前的位置值设置为当前位置。
function capturePreviousPositions(stage) {
//Loop through all the children of the stage
stage.children.forEach(sprite => {
setPreviousPosition(sprite);
});
function setPreviousPosition(sprite) {
//Set the sprite’s `previousX` and `previousY`
sprite.previousX = sprite.x;
sprite.previousY = sprite.y;
//Loop through all the sprite's children
if (sprite.children && sprite.children.length > 0) {
sprite.children.forEach(child => {
//Recursively call `setPosition` on each sprite
setPreviousPosition(child);
});
}
}
}
代码的其余部分将是相同的。图 5-6 显示了一个 500 个球的例子,游戏逻辑以 15 fps 运行,渲染器以 60 fps 运行。你可以在本章的源文件中找到完整的源代码。

图 5-6 。插值允许在低帧速率下平滑动画
你应该这样做吗?
插值的取舍是什么?有几个。
因为插值的位置是基于精灵之前的位置,所以游戏世界的渲染视图总是稍微落后于游戏逻辑。如果逻辑以 30 fps 的速度运行,而渲染器以 60 fps 的速度运行,则时间差大约为 33 毫秒。这并不重要——玩家永远不会注意到它——但你应该意识到它正在发生。
另一个代价是您的代码变得更加复杂。如果你有一个需要大量处理开销的物理密集型游戏,这可能是值得的。但大多数游戏的最大瓶颈通常是渲染,而不是游戏逻辑计算。
还有一个副作用需要注意。如果有一个异常大的系统峰值,并且更新游戏逻辑比渲染精灵需要更长的时间,你会在动画中看到一个跳跃。动画将会停止,过一会儿你会看到精灵出现在动画没有中断的位置。在我们之前看到的不太复杂的游戏循环中,一个大的峰值只会导致动画变慢。这些影响并不一定更好或更坏;你只需要决定你更喜欢哪个。
最后,我应该提供一个关于过早优化的一般警告。我们看到的第一个,也是最基本的游戏循环很简单。这是在同一个循环中以最大帧速率调用update和render函数的地方:
gameLoop();
function gameLoop() {
requestAnimationFrame(gameLoop);
update();
render(canvas);
}
这可能就是你所需要的。浏览器厂商在不断调整、改变、改进和试验他们如何渲染图形,有时是以不可思议的方式。您需要小心,不要意外地编写一些与浏览器自己的渲染引擎相冲突的代码,最终导致性能比开始时更差。微调游戏循环和精灵渲染是一门艺术,而不是科学,所以保持开放的心态,尝试一切,并记住这一点:如果你的游戏看起来不错,运行良好,那么它就是好的。
摘要
在前一章你学习了如何创建精灵,在这一章你学习了如何让他们移动。您学习了如何通过使用requestAnimationFrame修改游戏循环中精灵的速度来更新其位置。您看到了通过应用加速度、摩擦力和重力来给精灵添加物理属性是多么容易,也看到了如何从边界墙上反弹精灵。我们仔细研究了使用可重用的contain函数模块化您的代码,您了解了一些优化游戏逻辑和渲染性能的详细策略。
现在我们可以制作精灵并移动它们,我们如何与它们互动呢?HTML5 和 JavaScript 具有使用鼠标、键盘和触摸来控制游戏的内置功能,你将在下一章了解这些功能是如何工作的。
六、交互性
欢迎来到最重要的游戏代码倒计时,你需要知道这些代码来增加你的精灵的互动性。现在你知道了如何让精灵移动,你将学习如何让他们与他们生活的游戏世界互动。
您还将学习如何为游戏创建两个最有用的交互对象:可点击的按钮和可拖动的精灵。你需要知道的关于给你的游戏世界增加丰富交互性的一切都在这一章里。
键盘、鼠标和触摸
添加键盘、鼠标或触摸交互的第一步是设置一个事件监听器。事件监听器是内置在浏览器中的一段代码,它“监听”玩家是否按下了键盘上的按键、触摸了屏幕或者移动或点击了鼠标。如果监听器检测到一个事件,它会调用一个事件处理程序,这只是一个在你的游戏中执行某种重要动作的函数。事件处理程序可能会让玩家的角色移动,计算鼠标的速度,或者接受一些输入。事件监听器和处理程序就像是人类世界和游戏世界之间的桥梁。在本章的第一部分,你将学习如何为键盘、触摸和鼠标事件添加事件监听器和处理程序,以及如何使用它们在游戏世界中创造有趣的事情。先说键盘交互性。
注意html 5 规范还包括一个游戏手柄 API,可以让你捕捉游戏控制器按钮的输入。查看dvcs.w3.org处的规格。它像键盘、鼠标和触摸事件一样易于使用。
捕捉键盘事件
如果您想知道玩家是否按下了键盘上的某个键,可以添加一个带有keydown事件的事件监听器。然后编写一个使用 ASCII 码keyCode的keyDownHandler来找出哪个键被按下了。以下是你如何发现一个玩家是否按下了空格键。(空格键的键码是 32,在网上快速搜索“ASCII 键码”会显示一个完整的列表。)
window.addEventListener("keydown", keydownHandler, false)
function keydownHandler(event) {
if(event.keyCode === 32) {
console.log("Space key pressed");
}
}
这段代码运行良好,但是对于大多数游戏来说,你还需要使用一个keyup事件来告诉你这个键是否已经被释放。检查这一点的一个好方法是创建一个属性为isDown和isUp 的key对象。根据按键的状态,将这些属性设置为true或false。这将允许您使用if语句检查击键,如下所示:
if (space.isDown) {
//do this!
}
if (space.isUp) {
//do this!
}
这里是space 键对象,它将让您编写刚刚显示的if语句:
let space = {
code: 32,
isDown: false,
isUp: true,
downHandler(event) {
if(event.keyCode === this.code) {
this.isDown = true;
this.isUp = false;
}
},
upHandler(event) {
if(event.keyCode === this.code) {
this.isUp = true;
this.isDown = false;
}
}
};
//Add the event listeners and bind them to the space object
window.addEventListener(
"keydown", space.downHandler.bind(space), false
);
window.addEventListener(
"keyup", space.upHandler.bind(space), false
);
注意注意如何使用bind方法将监听器连接到space.downHandler和space.upHandler方法。它确保在空间对象中引用“this”是指空间对象本身,而不是窗口对象。
这样做很好,但是如果我们还想为四个键盘箭头键(向上、向右、向下和向左)添加侦听器呢?我们不想把这 20 行重复的代码写五遍。我们可以做得更好!
让我们创建一个keyboard函数,该函数创建监听特定键盘事件的key对象。我们将能够像这样创建一个新的key对象:
let keyObject = keyboard(asciiKeyCodeNumber);
然后,我们可以将press和release方法分配给关键对象,如下所示:
keyObject.press = function() {
//key object pressed
};
keyObject.release = function() {
//key object released
};
我们的关键对象也将有isDown和isUp布尔属性,如果你需要的话可以检查它们。
下面是让我们实现的keyboard函数:
export function keyboard(keyCode) {
let key = {};
key.code = keyCode;
key.isDown = false;
key.isUp = true;
key.press = undefined;
key.release = undefined;
//The `downHandler`
key.downHandler = function(event) {
if (event.keyCode === key.code) {
if (key.isUp && key.press) key.press();
key.isDown = true;
key.isUp = false;
}
//Prevent the event's default behavior
//(such as browser window scrolling)
event.preventDefault();
};
//The `upHandler`
key.upHandler = function(event) {
if (event.keyCode === key.code) {
if (key.isDown && key.release) key.release();
key.isDown = false;
key.isUp = true;
}
event.preventDefault();
};
//Attach event listeners
window.addEventListener(
"keydown", key.downHandler.bind(key), false
);
window.addEventListener(
"keyup", key.upHandler.bind(key), false
);
//Return the `key` object
return key;
}
你会在本章源代码的library/interactive.js文件中找到这个完整的keyboard函数。
打开本章的源文件中的keyObject.html,查看这段代码的运行示例。按下并释放空格键,你会看到画布上显示“按下”和“释放”(图 6-1 )。

图 6-1 。使用文本精灵告诉你一个键是被按下还是被释放
这是通过使用游戏循环来显示文本精灵的字符串内容来实现的。这个精灵的内容是由一个space键对象的press和release方法设置的。代码还使用assets对象加载自定义字体,并在准备就绪时调用setup函数。下面是完成这一切的完整代码:
//Import code from the library
import {makeCanvas, text, stage, render} from "../library/display";
import {assets} from "../library/utilities";
import {keyboard} from "../library/interactive";
//Load a custom font
assets.load(["fonts/puzzler.otf"]).then(() => setup());
//Declare any variables shared between functions
let canvas;
function setup() {
//Make the canvas and initialize the stage
canvas = makeCanvas(256, 256);
stage.width = canvas.width;
stage.height = canvas.height;
//Make a text sprite
let message = text("Press space", "16px puzzler", "black", 16, 16);
//Make a space key object
let space = keyboard(32);
//Assign `press` and `release` methods
space.press = () => message.content = "pressed";
space.release = () => message.content = "released";
//Use a loop to display any changes to the text sprite's
//`content` property
gameLoop();
}
function gameLoop() {
requestAnimationFrame(gameLoop);
render(canvas);
}
现在,您已经有了一个通用系统,可以快速创建和监听键盘输入。在本章的后面,你会看到一个如何使用关键物体来控制一个互动游戏角色的例子。
捕获指针事件
既然你已经知道了如何增加键盘的交互性,让我们来看看如何创建交互式鼠标和触摸事件。鼠标和触摸的行为方式相似,所以把它们看作一个叫做“指针”的东西是很有用的在本节中,您将学习如何创建一个统一鼠标和触摸事件的通用pointer对象。然后你将学习如何使用新的pointer对象来增加游戏的交互性。
注在撰写本文时,一个名为指针事件的 HTML5 规范正在开发中。如果当你读到这篇文章时,它已经被广泛实现了,这意味着你不再需要分叉你的代码来适应鼠标和触摸;指针事件对两者都适用。而且,非常方便的是,指针事件 API 几乎与鼠标事件 API 完全相同,所以没有什么新东西需要学习。只需在任何鼠标事件代码中将“鼠标”替换为“指针”,就可以了。然后将指针敏感元素的 CSS touch-action 属性设置为“none ”,以禁用浏览器的默认平移和缩放操作。关注这个规范,如果可以的话就使用它(http://www.w3.org/TR/pointerevents/)。
要创建鼠标或触摸事件,请将一个事件侦听器附加到要使其对指针敏感的 HTML 元素,如画布。然后让监听器在事件发生时调用事件处理程序:
canvas.addEventListener("mousedown", downHandler, false);
function downHandler(event) {
console.log("Pointer pressed down");
}
然而,对于我们游戏开发者来说,有一个小问题。如何检测玩家是否点击了什么东西?点击或点击只是指针非常快速的上下移动。任何超过 200 毫秒的时间都可以被定义为点击或点击。您可以通过比较 down 和 up 事件之间的时间来判断这是否已经发生。如果少于 200 毫秒,你可以假设玩家点击或点击了。下面是解决这个问题的一般方法。
首先,当指针向下时,用Date.now()捕捉当前时间:
function downHandler(event) {
downTime = Date.now();
}
downTime值现在包含了指针被按下的精确时间,以毫秒为单位。当指针上升时,捕捉新的时间,并计算从downTime开始已经过去了多长时间。如果少于 200 毫秒,那么你知道有一个点击或点击。
function upHandler(event) {
elapsedTime = Math.abs(downTime - Date.now());
if (elapsedTime <= 200) {
console.log("Tap or click!");
}
}
为了帮助我们管理这一切,让我们创建一个pointer对象。除了点击或点击,指针还应该能够告诉我们它的 x 和 y 位置,以及它当前是向上还是向下。为了获得最大的灵活性,我们还将让用户定义可选的press、tap和release方法,这些方法可以在这些事件发生时运行一些自定义代码。此外,我们将赋予指针centerX、centerY和position属性,这样它的 API 就能很好地反映前一章中精灵的 API。正如你将在本书后面看到的,这是一种便利,它将使我们更容易使用带有碰撞检测功能的指针,你将在第七章中学习使用。
指针还会有一个前瞻性的属性叫做scale 。如果画布在浏览器窗口中被放大或缩小,属性scale将帮助我们调整指针的坐标。对于大多数游戏来说,默认比例值 1 就是你所需要的。但是如果你改变游戏的显示尺寸,你需要按比例修改指针的 x 和 y 坐标。(你会在第十一章中看到这有多有用。)
这里有一个makePointer函数,它创建并返回一个pointer对象,为我们完成所有这些工作。它如何工作的本质细节在注释中,我将在代码清单之后向您展示如何使用它。
export function makePointer(element, scale = 1) {
let pointer = {
element: element,
scale: scale,
//Private x and y properties
_x: 0,
_y: 0,
//The public x and y properties are divided by the scale. If the
//HTML element that the pointer is sensitive to (like the canvas)
//is scaled up or down, you can change the `scale` value to
//correct the pointer's position values
get x() {
return this._x / this.scale;
},
get y() {
return this._y / this.scale;
},
//Add `centerX` and `centerY` getters so that we
//can use the pointer's coordinates with easing
//and collision functions
get centerX() {
return this.x;
},
get centerY() {
return this.y;
},
//`position` returns an object with x and y properties that
//contain the pointer's position
get position() {
return {x: this.x, y: this.y};
},
//Booleans to track the pointer state
isDown: false,
isUp: true,
tapped: false,
//Properties to help measure the time between up and down states
downTime: 0,
elapsedTime: 0,
//Optional, user-definable `press`, `release`, and `tap` methods
press: undefined,
release: undefined,
tap: undefined,
//The pointer's mouse `moveHandler`
moveHandler(event) {
//Get the element that's firing the event
let element = event.target;
//Find the pointer’s x,y position (for mouse).
//Subtract the element's top and left offset from the browser window
this._x = (event.pageX - element.offsetLeft);
this._y = (event.pageY - element.offsetTop);
//Prevent the event's default behavior
event.preventDefault();
},
//The pointer's `touchmoveHandler`
touchmoveHandler(event) {
let element = event.target;
//Find the touch point's x,y position
this._x = (event.targetTouches[0].pageX - element.offsetLeft);
this._y = (event.targetTouches[0].pageY - element.offsetTop);
event.preventDefault();
},
//The pointer's `downHandler`
downHandler(event) {
//Set the down states
this.isDown = true;
this.isUp = false;
this.tapped = false;
//Capture the current time
this.downTime = Date.now();
//Call the `press` method if it's been assigned by the user
if (this.press) this.press();
event.preventDefault();
},
//The pointer's `touchstartHandler`
touchstartHandler(event) {
let element = event.target;
//Find the touch point's x,y position
this._x = event.targetTouches[0].pageX - element.offsetLeft;
this._y = event.targetTouches[0].pageY - element.offsetTop;
//Set the down states
this.isDown = true;
this.isUp = false;
this.tapped = false;
//Capture the current time
this.downTime = Date.now();
//Call the `press` method if it's been assigned by the user
if (this.press) this.press();
event.preventDefault();
},
//The pointer's `upHandler`
upHandler(event) {
//Figure out how much time the pointer has been down
this.elapsedTime = Math.abs(this.downTime - Date.now());
//If it's less than 200 milliseconds, it must be a tap or click
if (this.elapsedTime <= 200 && this.tapped === false) {
this.tapped = true;
//Call the `tap` method if it's been assigned
if (this.tap) this.tap();
}
this.isUp = true;
this.isDown = false;
//Call the `release` method if it's been assigned by the user
if (this.release) this.release();
event.preventDefault();
},
//The pointer's `touchendHandler`
touchendHandler(event) {
//Figure out how much time the pointer has been down
this.elapsedTime = Math.abs(this.downTime - Date.now());
//If it's less than 200 milliseconds, it must be a tap or click
if (this.elapsedTime <= 200 && this.tapped === false) {
this.tapped = true;
//Call the `tap` method if it's been assigned by the user
if (this.tap) this.tap();
}
this.isUp = true;
this.isDown = false;
//Call the `release` method if it's been assigned by the user
if (this.release) this.release();
event.preventDefault();
},
//Bind the events to the handlers’
//Mouse events
element.addEventListener(
"mousemove", pointer.moveHandler.bind(pointer), false
);
element.addEventListener(
"mousedown", pointer.downHandler.bind(pointer), false
);
//Add the `mouseup` event to the `window` to
//catch a mouse button release outside of the canvas area
window.addEventListener(
"mouseup", pointer.upHandler.bind(pointer), false
);
//Touch events
element.addEventListener(
"touchmove", pointer.touchmoveHandler.bind(pointer), false
);
element.addEventListener(
"touchstart", pointer.touchstartHandler.bind(pointer), false
);
//Add the `touchend` event to the `window` object to
//catch a mouse button release outside the canvas area
window.addEventListener(
"touchend", pointer.touchendHandler.bind(pointer), false
);
//Disable the default pan and zoom actions on the `canvas`
element.style.touchAction = "none";
//Return the pointer
return pointer;
}
你会在源代码的library/display文件夹中找到完整的makePointer函数。下面是如何使用这个函数来创建和初始化一个pointer对象:
pointer = makePointer(canvas);
打开并运行pointer.html程序,查看如何使用它的工作示例;样品运行如图 6-2 中的所示。当您在画布上移动、点击、点按、按下或释放指针时,HTML 文本会显示指针的状态。这是完整的程序,包括 HTML 代码,这样你就可以看到所有的部分是如何组合在一起的。

图 6-2 。鼠标和触摸的通用指针对象
<!doctype html>
<meta charset="utf-8">
<title>Pointer</title>
<p id="output"></p>
<script type="module">
//Import code from the library
import {makeCanvas, stage, render} from "../library/display";
import {assets} from "../library/utilities";
import {makePointer} from "../library/interactive";
//Make the canvas and initialize the stage
let canvas = makeCanvas(256, 256);
stage.width = canvas.width;
stage.height = canvas.height;
//Get a reference to the output <p> tag
let output = document.querySelector("p");
//Make the pointer
let pointer = makePointer(canvas);
//Add a custom `press` method
pointer.press = () => console.log("The pointer was pressed");
//Add a custom `release` method
pointer.release = () => console.log("The pointer was released");
//Add a custom `tap` method
pointer.tap = () => console.log("The pointer was tapped");
//Use a loop to display changes to the output text
gameLoop();
function gameLoop() {
requestAnimationFrame(gameLoop);
//Display the pointer properties in the
//HTML <p> tag called `output`
output.innerHTML
= `Pointer properties: <br>
pointer.x: ${pointer.x} <br>
pointer.y: ${pointer.y} <br>
pointer.isDown: ${pointer.isDown} <br>
pointer.isUp: ${pointer.isUp} <br>
pointer.tapped: ${pointer.tapped}`;
}
</script>
注意这个例子也展示了你需要编写的最少的 HTML5 代码,并且仍然有一个有效的 HTML 文档。短小精悍!<html>和<body>标签是可选的。您可能仍然需要一个<body>标签作为添加和删除 HTML 元素的钩子,但是如果您忽略它,HTML5 规范会认为它是隐含的。
注意可选的press、release和tap功能是如何定义的:
pointer.press = () => console.log("The pointer was pressed");
pointer.release = () => console.log("The pointer was released");
pointer.tap = () => console.log("The pointer was tapped");
这些都是方便的方法,允许您在发生任何这些操作时注入一些自定义代码。你将在前面的章节中看到如何使用它们。
我们现在有了键盘、鼠标和触摸交互功能——太酷了!现在我们知道如何与游戏世界互动,让我们开始做吧!
互动运动
让我们把你在前一章学到的关于如何移动精灵的知识和你在本章学到的关于交互性的知识结合起来。在下一节中,您将学习一些最有用的代码片段来让精灵移动。这些是视频游戏历史上的经典技术,你会发现它们在你的游戏中有无数的用途。您将在源代码的library/utilities文件夹中找到我们在本节中使用的所有自定义函数。
寻找精灵之间的距离
你的游戏经常需要计算精灵之间的像素数。这对于找出精灵是否碰撞,或者接近碰撞是有用的。如果你的精灵有centerX和centerY属性,你可以使用下面的函数计算出它们之间的距离(s1代表“精灵 1”,s2代表“精灵 2”):
function distance(s1, s2) {
let vx = s2.centerX - s1.centerX,
vy = s2.centerY - s1.centerY;
return Math.sqrt(vx * vx + vy * vy);
}
vx和vy值描述了从第一个子画面的中心到第二个子画面的中心的直线。(“v”代表“矢量”你可以把向量想象成任意两个 x,y 位置之间的一条线。关于矢量你需要知道的一切见附录)。Math.sqrt用于应用毕达哥拉斯定理,它告诉你这条线有多长,以像素为单位。
在本章的源文件中,你会发现一个名为distance.html的示例程序,它展示了这个函数的运行。有两个圆形精灵,用一条线将它们连接起来。当您移动指针时,一个文本精灵会告诉您两个圆圈之间的像素距离。图 6-3 说明了这一点。

图 6-3 。找出精灵之间的距离
让我们看看程序如何使用这个distance函数,并借此机会学习更多关于如何使用精灵的知识。下面是distance.html程序的完整 JavaScript 代码:
import {makeCanvas, text, circle, line, stage, render} from "../library/display";
import {assets, distance} from "../library/utilities";
import {makePointer} from "../library/interactive";
//Load a custom font
assets.load(["fonts/puzzler.otf"]).then(() => setup());
//Declare any variables shared between functions
let canvas, c1, c2, message, connection, pointer;
function setup() {
//Make the canvas and initialize the stage
canvas = makeCanvas(256, 256);
stage.width = canvas.width;
stage.height = canvas.height;
//Make a text sprite
message = text("", "12px puzzler", "black", 8, 8);
//Create a circle sprite offset by 32 pixels to the
//left and top of the stage
c1 = circle(32, "gray");
stage.putCenter(c1, -32, -32);
//Create a circle sprite offset by 32 pixels to the
//right and bottom of the stage
c2 = circle(32, "gray");
stage.putCenter(c2, 32, 32);
//Create a line between the centers of the circles
connection = line(
"black", 2, c1.centerX, c1.centerY, c2.centerX, c2.centerY
);
//Make the pointer
pointer = makePointer(canvas);
//Use a loop to update the sprites' positions
gameLoop();
}
function gameLoop() {
requestAnimationFrame(gameLoop);
//Keep the center of c2 aligned with the
//pointer's position
c2.x = pointer.x - c2.halfWidth;
c2.y = pointer.y - c2.halfHeight;
//Draw the connecting line between the circles
connection.ax = c1.centerX;
connection.ay = c1.centerY;
connection.bx = c2.centerX;
connection.by = c2.centerY;
//Use the imported `distance` function to figure
//out the distance between the circles
let distanceBetweenCircles = distance(c1, c2);
//Use the message text sprite to display the distance.
//Use `Math.floor` to truncate the decimal values
message.content = Math.floor(distanceBetweenCircles);
//Render the canvas
render(canvas);
}
在这个程序中有相当多的显示元素在一起工作:圆形、线条和文本精灵,还有舞台。游戏循环也在动态计算圆圈之间的距离,并在每一帧中不断重画连接线。
用put方法定位精灵
这也是你第一次看到神秘的“put”sprite 方法之一的运行(我们在第四章中为 sprite 添加了“put”方法)。所有显示对象(精灵和舞台)都有称为putCenter、putTop、putRight、putBottom和putLeft的方法,您可以使用这些方法来方便地对齐和定位精灵。在这个示例程序中,stage对象使用putCenter来定位舞台内部的圆:
c1 = circle(32, "gray");
stage.putCenter(c1, -32, -32);
c2 = circle(32, "gray");
stage.putCenter(c2, 32, 32);
第一个参数是应该居中的 sprite:c1或者c2。第二个和第三个参数定义了精灵应该在 x 和 y 轴上从中心偏移多少。这段代码将c1放在舞台的左上角,将c2放在右下角。你会发现你经常需要在游戏中做这种定位,而“put”方法让你不必编写大量繁琐的定位代码。
缓和
在前面的示例中,圆精确地跟随指针的位置。你可以使用一个叫做放松的标准公式,让圆圈移动得更优雅一点。“缓动”会使精灵在目标点上轻轻就位。这里有一个followEase函数,你可以用它让一个精灵跟随另一个精灵。
function followEase(follower, leader, speed) {
//Figure out the distance between the sprites
let vx = leader.centerX - follower.centerX,
vy = leader.centerY - follower.centerY,
distance = Math.sqrt(vx * vx + vy * vy);
//Move the follower if it's more than 1 pixel
//away from the leader
if (distance >= 1) {
follower.x += vx * speed;
follower.y += vy * speed;
}
}
该函数计算精灵之间的距离。如果它们之间的距离超过 1 个像素,代码会以一定的速度移动跟随器,该速度会随着接近引导器而成比例降低。介于 0.1 和 0.3 之间的速度值是一个很好的起点(较高的数字使精灵移动得更快)。跟随者将逐渐减速,直到越过领头者的位置。打开本章源文件中的easing.html文件,看看如何使用这个函数让精灵轻松地跟随鼠标。图 6-4 展示了你将会看到的东西。

图 6-4 。轻松移动精灵
程序代码与前面的例子非常相似。游戏循环中使用的followEase函数是这样的:
function gameLoop() {
requestAnimationFrame(gameLoop);
followEase(c1, pointer, 0.1);
render(canvas);
}
让一个精灵跟随另一个精灵是游戏的常见要求,所以让我们看看另一种方法。
匀速跟随
前几节中的缓动公式使精灵以可变速度移动,该速度与到其目的地的距离成比例。只要对公式稍加修改,你就可以使它以固定的恒定速度运动。这里有一个follow函数实现了这一点:
function followConstant(follower, leader, speed) {
//Figure out the distance between the sprites
let vx = leader.centerX - follower.centerX,
vy = leader.centerY - follower.centerY,
distance = Math.sqrt(vx * vx + vy * vy);
//Move the follower if it's more than 1 move
//away from the leader
if (distance >= speed) {
follower.x += (vx / distance) * speed;
follower.y += (vy / distance) * speed;
}
}
speed值应该是您希望跟随器移动的每帧像素数;在下面的示例代码中,每帧 3 个像素:
function gameLoop() {
requestAnimationFrame(gameLoop);
followConstant(c1, pointer, 3);
render(canvas);
}
这对于创造一个追逐玩家的敌人 AI 精灵来说是一个非常有用的功能。
向某物旋转
您可以使用以下函数找到两个精灵之间的旋转角度:
function angle(s1, s2) {
return Math.atan2(
s2.centerY - s1.centerY,
s2.centerX - s1.centerX
);
}
它返回以弧度为单位的旋转角度。您可以用下面的语句将它应用到 sprite 的rotation属性,使 sprite 向另一个 sprite 或指针旋转:
box.rotation = angle(box, pointer);
你可以在本章的源文件中的rotateTowards.html文件中看到一个这样的例子;输出如图 6-5 中的所示。框向指针旋转,一条 32 像素长的红线从框的中心向旋转方向延伸。

图 6-5 。向指针旋转精灵
只要有点想象力,你可能会意识到红线框实际上是一个主要的视频游戏组件:一个旋转炮塔。是怎么做出来的?这是一个很好的例子,展示了如何使用父/子层次结构构建一个简单的复合 sprite。turret(红线)是box的子节点。代码如下:
//Make a square and center it in the stage
box = rectangle(32, 32, "gray", "black", 2);
stage.putCenter(box);
//Make a turret by drawing a red, 4 pixel wide
//line that's 32 pixels long
turret = line("red", 4, 0, 0, 32, 0);
//Add the line as a child of the box and place its
//start point at the box's center
box.addChild(turret);
turret.x = 16;
turret.y = 16;
现在当游戏循环使用angle功能使box向pointer旋转时,turret会自动跟随box的旋转。
function gameLoop() {
requestAnimationFrame(gameLoop);
box.rotation = angle(box, pointer);
render(canvas);
}
这就是我们在第四章中所做的额外工作的回报。它让我们不必写一些复杂的数学来手动保持转台的旋转与盒子对齐。
围绕精灵旋转
使用下面的rotateSprite函数让一个精灵围绕另一个精灵旋转:
function rotateSprite(rotatingSprite, centerSprite, distance, angle) {
rotatingSprite.x
= centerSprite.centerX - rotatingSprite.parent.x
+ (distance * Math.cos(angle))
- rotatingSprite.halfWidth;
rotatingSprite.y
= centerSprite.centerY - rotatingSprite.parent.y
+ (distance * Math.sin(angle))
- rotatingSprite.halfWidth;
}
下面介绍如何用它让一个球绕着一个盒子旋转,如图图 6-6 所示。

图 6-6 。围绕另一个精灵旋转一个精灵
//Create a box and position it
box = rectangle(32, 32, "gray");
stage.putCenter(box, 32, -48);
//Create a circle sprite offset by 32 pixels to the
//left of the box
ball = circle(32, "gray");
box.putLeft(ball, -32);
//Add an `angle` property to the ball that we'll use to
//help make the ball rotate around the box
ball.angle = 0;
//Start the game loop
gameLoop();
function gameLoop() {
requestAnimationFrame(gameLoop);
//Update the ball’s rotation angle
ball.angle += 0.05;
//Use the ball’s `angle` value to make it rotate around the
//box at a distance of 48 pixels from the box’s center
rotateSprite(ball, box, 48, ball.angle);
}
围绕一个点旋转
有时候,能够围绕另一个点旋转空间中的一个点是很有用的。例如,你可以通过让一条线的两端围绕空间中不可见的点旋转来创造一种“摇摆线”的效果,如图 6-7 所示。像这样把不稳定的线条连接在一起,你可以创造出不稳定的形状。

图 6-7 。使点围绕其他点旋转。您可以使用一个名为rotatePoint的函数来帮助创建这种效果
export function rotatePoint(pointX, pointY, distanceX, distanceY angle) {
let point = {};
point.x = pointX + Math.cos(angle) * distanceX;
point.y = pointY + Math.sin(angle) * distanceY;
return point;
}
rotatePoint函数返回一个带有代表旋转轴的 x 和 y 值的point对象。distanceX和distanceY参数定义了从旋转中心到在空间中被描绘的假想圆的边缘的半径。如果distanceX和distanceY具有相同的值,该功能将描绘一个圆。如果给它们不同的值,该函数将跟踪一个椭圆。您可以使用rotatePoint返回的point对象使任何其他 x/y 点围绕该轴旋转。这里有一些代码使用rotatePoint来创建如图图 6-7 所示的摇摆线条效果。
movingLine = line("black", 4, 64, 160, 192, 208);
//We're going to make the line's start and end points
//rotate in space. The line will need two new angle properties
//to help us do this. Both are initialized to 0
movingLine.angleA = 0;
movingLine.angleB = 0;
//Start the game loop
gameLoop();
function gameLoop() {
requestAnimationFrame(gameLoop);
//Make the line's `ax` and `ay` points rotate clockwise around
//point 64, 160\. `rotatePoint` returns an
//object with `x` and `y` properties
//containing the point's new rotated position
movingLine.angleA += 0.02;
let rotatingA = rotatePoint(64, 160, 20, 20, movingLine.angleA);
movingLine.ax = rotatingA.x;
movingLine.ay = rotatingA.y;
//Make the line's `bx` and `by` point rotate counter-
//clockwise around point 192, 208
movingLine.angleB -= 0.03;
let rotatingB = rotatePoint(192, 208, 20, 20, movingLine.angleB);
movingLine.bx = rotatingB.x;
movingLine.by = rotatingB.y;
//Render the canvas
render(canvas);
}
效果就像曲轴转动一个看不见的轮子。看起来很有趣,甚至有点吓人,所以请确保查看本章源代码中的rotateAround.html文件中的这个代码的工作示例。
向旋转方向移动
如果你知道精灵的角度,你可以让它朝它所指的方向移动。运行moveTowards.html文件,你会发现一个可以使用箭头键移动的精灵的例子。向左和向右旋转精灵,向上使它向它所指的方向移动。释放向上箭头键,它会慢慢停下来。图 6-8 显示了您将看到的内容。

图 6-8 。使用箭头键旋转一个精灵,并向它所指的方向移动它
让我们再次发挥我们的想象力,把标有红线的移动箱想象成一辆“坦克”现在让我们重新思考一下我们在前面的例子中构建这个对象的方式。把盒子和炮塔放在一个叫做tank 的小组里可能是有意义的。我们可以使用我们在第四章的中创建的group函数来完成这项工作;方法如下:
//Make the box and turret
let box = rectangle(32, 32, "gray");
let turret = line("red", 4, 0, 0, 32, 0);
turret.x = 16;
turret.y = 16;
//Group them together as a compound sprite called `tank`
tank = group(box, turret);
stage.putCenter(tank);
tank组现在是box和turret精灵的父容器。这只是你可以用来制作复合精灵的另一种方法。tank组现在是你控制的主要精灵。给它添加一些属性来帮助它移动:
//Add some physics properties
tank.vx = 0;
tank.vy = 0;
tank.accelerationX = 0.2;
tank.accelerationY = 0.2;
tank.frictionX = 0.96;
tank.frictionY = 0.96;
//The speed at which the tank should rotate,
//initialized to 0
tank.rotationSpeed = 0;
//Whether or not the tank should move forward
tank.moveForward = false;
rotationSpeed决定坦克向左或向右旋转的速度,而moveForward只是一个布尔值,告诉我们游戏循环中的代码是否应该让坦克移动。这两个属性都由箭头键设置:右键和左键使坦克旋转,向上键使它向前移动。下面是使用我们在本章前面编写的keyboard函数对箭头键进行编程的代码:
//Make key objects
let leftArrow = keyboard(37),
rightArrow = keyboard(39),
upArrow = keyboard(38);
//Set the tank's `rotationSpeed` to -0.1 (to rotate left) if the
//left arrow key is being pressed
leftArrow.press = () => tank.rotationSpeed = -0.1;
//If the left arrow key is released and the right arrow
//key isn't being pressed down, set the `rotationSpeed` to 0
leftArrow.release = () => {
if (!rightArrow.isDown) tank.rotationSpeed = 0;
}
//Do the same for the right arrow key, but set
//the `rotationSpeed` to 0.1 (to rotate right)
rightArrow.press = () => tank.rotationSpeed = 0.1;
rightArrow.release = () => {
if (!leftArrow.isDown) tank.rotationSpeed = 0;
}
//Set `tank.moveForward` to `true` if the up arrow key is
//pressed, and set it to `false` if it's released
upArrow.press = () => tank.moveForward = true;
upArrow.release = () => tank.moveForward = false;
我们现在可以利用这些特性,以及我们在前一章中学到的关于加速度和摩擦力的知识,使坦克沿着它旋转的方向运动。下面是游戏循环中的代码:
function gameLoop() {
requestAnimationFrame(gameLoop);
//Use the `rotationSpeed` to set the tank's rotation
tank.rotation += tank.rotationSpeed;
//If `tank.moveForward` is `true`, use acceleration with a
//bit of basic trigonometry to make the tank move in the
//direction of its rotation
if (tank.moveForward) {
tank.vx += tank.accelerationX * Math.cos(tank.rotation);
tank.vy += tank.accelerationY * Math.sin(tank.rotation);
}
//If `tank.moveForward` is `false`, use
//friction to slow the tank down
else {
tank.vx *= tank.frictionX;
tank.vy *= tank.frictionY;
}
//Apply the tank's velocity to its position to make the tank move
tank.x += tank.vx;
tank.y += tank.vy;
//Display the tank's angle of rotation
message.content = tank.rotation;
//Render the canvas
render(canvas);
}
让坦克沿其旋转方向移动的秘密是这两行代码:
tank.vx += tank.accelerationX * Math.cos(tank.rotation);
tank.vy += tank.accelerationY * Math.sin(tank.rotation);
这只是一点基本的三角学,把坦克的加速度和它的旋转结合起来。当应用到坦克的 x,y 位置时,产生的vx和vy值将使坦克向正确的方向移动。
tank.x += tank.vx;
tank.y += tank.vy;
你可以使用这个基本系统作为许多种旋转视频游戏对象的起点,如宇宙飞船或汽车。在这个例子中,我们的坦克实际上更像一艘宇宙飞船,而不是一辆真正的坦克。这是因为当它向左或向右旋转时,它会继续向前漂移,而不是像有轮子的车辆那样随着旋转改变方向。我们将很快解决这个问题。首先,让我们赋予坦克发射子弹的能力。
发射子弹
只需多一点代码,你就可以让你的精灵发射子弹。运行bullets.html按空格键向坦克指向的方向发射子弹,如图图 6-9 所示。

图 6-9 。向四面八方发射子弹
这段代码的一个特性是当子弹击中画布边缘时会被移除,一个文本精灵会告诉你子弹击中了哪个边缘。
制作子弹的第一步是创建一个数组来存储你将要制作的新子弹精灵:
let bullets = [];
接下来,您需要一个shoot函数,它允许您使用一些参数创建项目符号:
shoot(
tank, //The shooter
tank.rotation, //The angle at which to shoot
32, //The bullet's offset from the center
7, //The bullet's speed (pixels per frame)
bullets, //The array used to store the bullets
//A function that returns the sprite that should
//be used to make each bullet
() => circle(8, "red")
);
shoot功能分配发射子弹所需的所有参数。最重要的是最后一条:
() => circle(8, "red")
这是一个函数,它创建并返回你想用作项目符号的精灵类型。在这种情况下,它是一个直径为 8 个像素的红色圆圈。你可以使用到目前为止在本书中学到的任何精灵创建函数,或者创建你自己的自定义函数。
下面是使用这些参数创建一个新的bullet精灵并将其添加到bullets数组的shoot函数定义。
function shoot(
shooter, angle, offsetFromCenter,
bulletSpeed, bulletArray, bulletSprite
) {
//Make a new sprite using the user-supplied `bulletSprite` function
let bullet = bulletSprite();
//Set the bullet's start point
bullet.x
= shooter.centerX - bullet.halfWidth
+ (offsetFromCenter * Math.cos(angle));
bullet.y
= shooter.centerY - bullet.halfHeight
+ (offsetFromCenter * Math.sin(angle));
//Set the bullet's velocity
bullet.vx = Math.cos(angle) * bulletSpeed;
bullet.vy = Math.sin(angle) * bulletSpeed;
//Push the bullet into the `bulletArray`
bulletArray.push(bullet);
}
你可以看到shoot函数正在使用射手精灵的旋转角度来计算子弹的起点和速度。shoot函数被设计成灵活和通用的,所以你可以在各种不同的游戏项目中使用它。
你的游戏如何让玩家发射子弹?在这个例子中,每按一次空格键,子弹就发射一次,而且只有一次。释放空格键重置子弹发射机制,以便您可以在下一次按下它时再次发射。这是一个使用我们在本章前面创建的keyboard函数来设置的简单机制。首先,创建一个space关键对象:
let space = keyboard(32);
然后给space键的press方法赋值,让它调用shoot函数发射子弹:
space.press = () => {
shoot(
tank, tank.rotation, 32, 7, bullets,
() => circle(8, "red")
);
};
既然我们能够发射子弹,我们需要在画布上移动它们。我们还需要检查它们的屏幕边界,这样如果它们碰到画布的边缘,我们就可以移除它们(实际上是根父对象stage)。这必须发生在游戏循环中:
function gameLoop() {
requestAnimationFrame(gameLoop);
//Move the bullets here...
}
移动项目符号并检查与stage边界冲突的代码也发生在循环内部。我们将使用一个filter循环,这样如果一颗子弹击中舞台的边缘,它将从bullets数组中移除。我们还将使用一个名为outsideBounds 的自定义函数,它将告诉我们子弹是否穿过了舞台的边界,以及子弹击中了边界的哪一侧。下面是完成这一切的filter循环:
bullets = bullets.filter(bullet => {
//Move the bullet
bullet.x += bullet.vx;
bullet.y += bullet.vy;
//Check for a collision with the stage boundary
let collision = outsideBounds(bullet, stage.localBounds);
//If there's a collision, display the side that the collision
//happened on, remove the bullet sprite, and filter it out of
//the `bullets` array
if(collision) {
//Display the boundary side that the bullet crossed
message.content = "The bullet hit the " + collision;
//The `remove` function will remove a sprite from its parent
//to make it disappear
remove(bullet);
//Remove the bullet from the `bullets` array
return false;
}
//If the bullet hasn't hit the edge of the stage,
//keep it in the `bullets` array
return true;
});
outsideBounds函数返回一个collision变量,其值为"top"、"right"、"bottom"或"left",这取决于子弹穿过边界的哪一侧。如果没有碰撞,它将返回undefined。outsideBounds与你在前一章学到的contain函数非常相似——只是简单得多。它检查 sprite 的整个形状是否已经越过了包含边界,并由您来决定如何处理这些信息。
function outsideBounds(sprite, bounds, extra = undefined){
let x = bounds.x,
y = bounds.y,
width = bounds.width,
height = bounds.height;
//The `collision` object is used to store which
//side of the containing rectangle the sprite hits
let collision;
//Left
if (sprite.x < x - sprite.width) {
collision = "left";
}
//Top
if (sprite.y < y - sprite.height) {
collision = "top";
}
//Right
if (sprite.x > width) {
collision = "right";
}
//Bottom
if (sprite.y > height) {
collision = "bottom";
}
//The `extra` function runs if there was a collision
//and `extra` has been defined
if (collision && extra) extra(collision);
//Return the `collision` object
return collision;
};
你会在本书源文件的library/display文件夹中找到shoot和outsideBounds函数。
移动水箱
在这个新的例子中,坦克的行为就像一个真正的轮式车辆。改变其旋转也会改变其向前运动的方向,如图图 6-10 所示。这不是一个很难达到的效果;我们只需要稍微重新思考如何计算和应用坦克的物理属性。

图 6-10 。储罐沿其旋转方向移动
首先,给坦克新的speed和friction值。我们将使用speed来帮助决定坦克应该跑多快,使用friction来帮助我们减速。(这个新的friction值取代了前一个例子中的frictionX和frictionY。)
tank.friction = 0.96;
tank.speed = 0;
以下是坦克新物理属性的所有初始值:
tank.vx = 0;
tank.vy = 0;
tank.accelerationX = 0.1;
tank.accelerationY = 0.1;
tank.rotationSpeed = 0;
tank.moveForward = false;
tank.friction = 0.96;
tank.speed = 0;
游戏循环使用speed和friction来计算坦克应该跑多快,让坦克移动。坦克的加速度是通过将其速度应用于其旋转来计算的:
//Use the `rotationSpeed` to set the tank's rotation
tank.rotation += tank.rotationSpeed;
//If `tank.moveForward` is `true`, increase the speed
if (tank.moveForward) {
tank.speed += 0.1;
}
//If `tank.moveForward` is `false`, use
//friction to slow the tank down
else {
tank.speed *= tank.friction;
}
//Use the `speed` value to figure out the acceleration in the
//direction of the tank’s rotation
tank.accelerationX = tank.speed * Math.cos(tank.rotation);
tank.accelerationY = tank.speed * Math.sin(tank.rotation);
//Apply the acceleration to the tank's velocity
tank.vx = tank.accelerationX;
tank.vy = tank.accelerationY;
//Apply the tank's velocity to its position to make the tank move
tank.x += tank.vx;
tank.y += tank.vy;
对代码的这一调整消除了第一个例子中飞船风格的漂移效应。你可以用它作为移动任何轮式车辆的基础。
交互式鼠标和触摸事件
到目前为止,在这一章中,你已经学会了如何通过给键盘键分配press和release方法来让精灵移动,以及如何让精灵跟随指针的位置。但是如果你想以一种更复杂的方式和精灵互动呢?你的游戏精灵可能需要对点击、触摸或拖动做出反应,你可能想要制作按钮来为你的游戏构建 UI。
在本章的后半部分,你将学习如何做到这一点。我们将建立一个通用的框架来制作各种各样的交互式精灵,然后定制它来制作按钮和拖放精灵。让我们来看看是如何做到的!
找出指针是否接触到一个精灵
最重要的第一步是我们需要某种方法来判断指针是否接触到了一个精灵。我们可以通过向名为hitTestSprite 的pointer对象添加一个方法来做到这一点。它的工作是检查指针的 x/y 位置是否在精灵的区域内。我们将把hitTestSprite添加到我们在本章开始时使用makePointer函数创建的同一个pointer对象中:
function makePointer(element, scale = 1) {
let pointer = {
//... the pointer's previous properties and methods...
hitTestSprite(sprite) {
//The new code goes here
}
};
//... the rest of the makePointer function...
return pointer;
}
hitTestSprite是做什么的?它将指针的位置与 sprite 定义的区域进行比较。如果指针在该区域内,该方法返回true;如果不是,它返回false。作为一个额外的特性,hitTestSprite对圆形精灵和矩形精灵都有效。(你会记得在第四章中我们所有的精灵都有一个叫做circular的布尔属性,你可以用它来找到精灵的大致形状。)
hitTestSprite(sprite) {
//The `hit` variable will become `true` if the pointer is
//touching the sprite and remain `false` if it isn't
let hit = false;
//Is the sprite rectangular?
if (!sprite.circular) {
//Yes, it is.
//Get the position of the sprite's edges using global
//coordinates
let left = sprite.gx,
right = sprite.gx + sprite.width,
top = sprite.gy,
bottom = sprite.gy + sprite.height;
//Find out if the pointer is intersecting the rectangle.
//`hit` will become `true` if the pointer is inside the
//sprite's area
hit
= this.x > left && this.x < right
&& this.y > top && this.y < bottom;
}
//Is the sprite circular?
else {
//Yes, it is.
//Find the distance between the pointer and the
//center of the circle
let vx = this.x - (sprite.gx + sprite.radius),
vy = this.y - (sprite.gy + sprite.radius),
distance = Math.sqrt(vx * vx + vy * vy);
//The pointer is intersecting the circle if the
//distance is less than the circle's radius
hit = distance < sprite.radius;
}
return hit;
}
在矩形精灵的情况下,代码检查指针的 x,y 位置是否在精灵的区域内。在圆形精灵的情况下,它检查指针中心和精灵中心之间的距离是否小于圆的半径。在这两种情况下,如果指针接触到子画面,代码将设置hit为真。指针的 x,y 坐标总是相对于画布的,这就是为什么代码使用精灵的全局gx和gy坐标。
在游戏代码中使用hitTestSprite,如下所示:
pointer.hitTestSprite(anySprite);
运行pointerCollision.html文件,交互演示hitTestSprite如何工作,如图图 6-11 所示。一个文本精灵会告诉你指针是否接触到了盒子或球精灵。

图 6-11 。找出指针是否接触到一个精灵
下面是来自游戏循环的代码,它使这个工作:
if(pointer.hitTestSprite(ball)) {
message.content = "Ball!"
} else if(pointer.hitTestSprite(box)) {
message.content = "Box!"
} else {
message.content = "No collision..."
}
这个例子实际上是对一个叫做碰撞检测的游戏设计主题的一个偷偷摸摸的介绍,你将在下一章了解到。但是,现在,我们如何使用hitTestPoint来制作交互式精灵呢?让我们通过学习如何制作最有用的交互精灵来找出答案:按钮。
小跟班
按钮是一个重要的用户界面组件,你肯定想在你的游戏中使用。你可以使用 HTML 和 CSS 很容易地创建它们,但是为基于画布的精灵渲染系统创建你自己的定制按钮,比如我们在本书中开发的那个,还有很多要说的。您将能够将按钮集成到您现有的场景图形和渲染器中,像操作任何其他游戏精灵一样操作它们,并保持您的代码库统一在 JavaScript 中,而不必跳过 HTML 和 CSS。使用我们在上一节中学到的指针交互性,并通过对 sprite 系统做一些小的添加,我们可以创建一个多功能的新按钮 sprite 对象。你可以把我们要做的按钮想象成“可点击/可触摸的精灵”,你可以在各种各样的游戏中使用它们。
关于按钮,你需要知道的最重要的事情是它们有状态和动作。状态定义按钮的外观,动作定义按钮的功能。
大多数按钮有三种状态:
- 向上:当指针没有碰到按钮时
- 在上:当指针在按钮上时
- 按下:当指针按下按钮时
图 6-12 显示了这三种按钮状态的例子。

图 6-12 。向上、向上和向下按钮状态
基于触摸的游戏只需要两种状态:向上和向下。
使用我们将要创建的按钮精灵,您将能够以字符串属性的形式访问这些状态,如下所示:
playButton.state
state属性可以有值"up"、"over"或"down",你可以在你的游戏逻辑中使用它们。
按钮也有动作:
- 按下:当指针按下按钮时
- 释放:当指针从按钮上释放时
- 结束:当指针移动到按钮区域时
- Out:当指针移出按钮区域时
- 点击:点击(或点击)按钮时
您可以将这些操作定义为用户可定义的方法,如下所示:
playButton.press = () => console.log("pressed");
playButton.release = () => console.log("released");
playButton.over = () => console.log("over");
playButton.out = () => console.log("out");
playButton.tap = () => console.log("tapped");
您还应该能够在一个字符串属性中访问按钮的“按下”和“释放”动作,如下所示:
playButton.action
明白了吗?很好!那么我们实际上是怎么做纽扣的呢?
创建按钮
首先,从定义三种按钮状态的三幅图像开始。你可以称他们为"up.png"、"over.png"和"down.png"。然后将这三幅图像添加到 tileset 中,或者作为纹理贴图集中的帧。图 6-13 显示了包含这三种状态的简单纹理图谱。

图 6-13 。将按钮图像状态添加到纹理贴图集
注意虽然有三种图像状态是标准的,但有时候按钮只有两种图像状态。这对于只有触摸的按钮来说尤其如此,因为它们没有“结束”状态。我们将在前面创建的按钮 sprite 将使用三个图像(如果它们可用的话),但是如果它只有两个图像,代码将假定它们指的是“向上”和“向下”状态。
接下来,将纹理地图加载到游戏程序中:
assets.load(["img/button.json"]).then(() => setup());
同样通过使用三个帧作为按钮的源参数来初始化一个新的button sprite:
let buttonFrames = [
assets["up.png"],
assets["over.png"],
assets["down.png"]
];
playButton = button(buttonFrames, 32, 96);
要查看这个按钮的运行,运行button.html文件,其输出显示在图 6-14 中。当您将指针移到按钮上时,光标会变成手形图标。游戏循环更新了一些显示按钮状态和动作的文本。
stateMessage.content = `State: ${playButton.state}`;
actionMessage.content = `Action: ${playButton.action}`;

图 6-14 。交互式按钮精灵
这是我们最终想要实现的,但是我们需要多写一点代码来使所有这些细节工作。让我们来看看我们到底需要做些什么来创建这些完全交互的按钮。
新的Button类
您可能在一个游戏中有许多按钮,并且,正如您将很快看到的,您需要在每一帧中更新它们。所以你首先需要一个buttons数组来存储游戏中的所有按钮:
export let buttons = [];
将这个buttons数组与stage对象和 sprite 类一起保存在你的display模块中。确保导出它,因为您需要将它导入到任何需要按钮的游戏的应用程序代码中。
接下来,做一个Button类。它所做的就是扩展Sprite类并将 sprite 的interactive属性设置为true。为按钮创建一个全新的类并不重要,但是您很快就会看到,这样做将帮助我们基于指针交互性自动显示图像状态。
class Button extends Sprite {
constructor(source, x = 0, y = 0) {
super(source, x, y);
this.interactive = true;
}
}
export function button(source, x, y) {
let sprite = new Button(source, x, y);
stage.addChild(sprite);
return sprite;
}
将interactive设置为true实际上是做什么的?
this.interactive = true;
你会记得,当我们在第四章的中制作精灵系统时,我们给了所有的精灵一个名为interactive的属性,你可以将它设置为true。下面是从DisplayObject类中摘录的代码(如果你需要的话,可以查看第四章中的代码)。
get interactive() {
return this._interactive;
}
set interactive(value) {
if (value === true) {
//Add interactive properties to the sprite
//so that it can act like a button
makeInteractive(this);
//Add the sprite to the global `buttons` array so
//it can be updated each frame
buttons.push(this);
this._interactive = true;
}
if (value === false) {
//Remove the sprite's reference from the
//`buttons` array so that it's no longer affected
//by mouse and touch interactivity
buttons.splice(buttons.indexOf(this), 1);
this._interactive = false;
}
}
将interactive设置为true会将精灵发送给一个名为makeInteractive的函数,并将其添加到buttons数组中。将其设置为false会将其从buttons数组中拼接出来。让我们看看这个makeInteractive函数是做什么的,以及它如何将任何精灵转换成一个可点击、可触摸的按钮。你会记得我在第四章告诉过你,“现在不要担心这些东西——我以后会解释的!”好吧,现在是时候了!
添加交互性
makeInteractive函数为 sprite 分配了一些新方法:press、release、over、tap和out。它还向 sprite 添加了一些属性,以便我们可以监视它的交互状态。这些方法将使任何精灵的行为像一个按钮。但是如果 sprite 实际上是Button类的一个实例,makeInteractive增加了一个额外的特性:它根据指针所做的事情将 sprite 的图像状态设置为"up"、"over"或"down"。
即使对我来说,这也是一段相当复杂的代码。这是因为它必须根据按钮的前一状态和指针正在做的事情来计算出按钮的当前状态。这碰巧是一项不可避免的微妙事业——有点像耍弄匕首。注释解释了它是如何工作的,但是真正理解其微妙之处的最好方法是阅读代码,同时观察button.html示例文件中的效果。编写这段代码只是一个渐进的试错过程,以及许许多多的测试。而我直到做完才完全明白我要解决的问题。这种探索和发现是编程如此有趣的原因之一!下面是来自library/display文件的完整的makeInteractive函数。
function makeInteractive(o) {
//The `press`, `release`, `over`, `out`, and `tap` methods. They're `undefined`
//for now, but they can be defined in the game program
o.press = o.press || undefined;
o.release = o.release || undefined;
o.over = o.over || undefined;
o.out = o.out || undefined;
o.tap = o.tap || undefined;
//The `state` property tells you the button's
//current state. Set its initial state to "up"
o.state = "up";
//The `action` property tells you whether it’s being pressed or
//released
o.action = "";
//The `pressed` and `hoverOver` Booleans are mainly for internal
//use in this code to help figure out the correct state.
//`pressed` is a Boolean that helps track whether
//the sprite has been pressed down
o.pressed = false;
//`hoverOver` is a Boolean that checks whether the pointer
//has hovered over the sprite
o.hoverOver = false;
//The `update` method will be called each frame
//inside the game loop
o.update = (pointer, canvas) => {
//Figure out if the pointer is touching the sprite
let hit = pointer.hitTestSprite(o);
//1\. Figure out the current state
if (pointer.isUp) {
//Up state
o.state = "up";
//Show the first image state frame, if this is a `Button` sprite
if (o instanceof Button) o.gotoAndStop(0);
}
//If the pointer is touching the sprite, figure out
//if the over or down state should be displayed
if (hit) {
//Over state
o.state = "over";
//Show the second image state frame if this sprite has
//3 frames and it's a `Button` sprite
if (o.frames && o.frames.length === 3 && o instanceof Button) {
o.gotoAndStop(1);
}
//Down state
if (pointer.isDown) {
o.state = "down";
//Show the third frame if this sprite is a `Button` sprite and it
//has only three frames, or show the second frame if it
//has only two frames
if(o instanceof Button) {
if (o.frames.length === 3) {
o.gotoAndStop(2);
} else {
o.gotoAndStop(1);
}
}
}
}
//Perform the correct interactive action
//a. Run the `press` method if the sprite state is "down" and
//the sprite hasn't already been pressed
if (o.state === "down") {
if (!o.pressed) {
if (o.press) o.press();
o.pressed = true;
o.action = "pressed";
}
}
//b. Run the `release` method if the sprite state is "over" and
//the sprite has been pressed
if (o.state === "over") {
if (o.pressed) {
if (o.release) o.release();
o.pressed = false;
o.action = "released";
//If the pointer was tapped and the user assigned a `tap`
//method, call the `tap` method
if (pointer.tapped && o.tap) o.tap();
}
//Run the `over` method if it has been assigned
if (!o.hoverOver) {
if (o.over) o.over();
o.hoverOver = true;
}
}
//c. Check whether the pointer has been released outside
//the sprite's area. If the button state is "up" and it has
//already been pressed, then run the `release` method
if (o.state === "up") {
if (o.pressed) {
if (o.release) o.release();
o.pressed = false;
o.action = "released";
}
//Run the `out` method if it has been assigned
if (o.hoverOver) {
if (o.out) o.out();
o.hoverOver = false;
}
}
};
}
前面代码中的一个重要特性是makeInteractive向 sprite 添加了一个名为update的方法:
o.update = (pointer, canvas) => { /*...*/ }
这个update方法实际上计算出了按钮的状态。唯一可行的方法是在每个动画帧的上调用它。这意味着游戏中的每个按钮都需要在游戏循环中调用它的update方法。幸运的是,每一个将interactive设置为true的精灵都会被推入buttons数组。这意味着你可以用这样的功能更新游戏中的所有按钮:
function gameLoop() {
requestAnimationFrame(gameLoop);
//Only run the code if there are buttons in the array
if (buttons.length > 0) {
//Set the mouse pointer to the default arrow icon
canvas.style.cursor = "auto";
//Loop through all the buttons
buttons.forEach(button => {
//Update the buttons
button.update(pointer, canvas);
//Figure out if the mouse arrow should be a hand icon
if (button.state === "over" || button.state === "down") {
//If the button (or interactive sprite) isn't the
//stage, change the cursor to a pointer.
//(This works because the `stage` object has a
//`parent` value of `undefined`)
if(button.parent !== undefined) {
//Display the mouse arrow as a hand
canvas.style.cursor = "pointer";
}
}
});
}
//Render the canvas
render(canvas);
}
代码在所有按钮中循环,并对所有按钮调用update方法。另外,这段代码还能判断出鼠标箭头是否应该显示一个手形图标。如果按钮状态是"over"或"down",并且交互子画面不是stage对象,则显示手图标。
把这一切放在一起
既然您已经知道了所有这些组件是如何工作的,那么让我们来看看button.html文件的完整 JavaScript 代码。您可以将此作为在自己的项目中使用按钮的起点。
//Import code from the library
import {
makeCanvas, button, buttons, frames,
text, stage, render
} from "../library/display";
import {assets} from "../library/utilities";
import {makePointer} from "../library/interactive";
//Load the button’s texture atlas and the custom font
assets.load([
"fonts/puzzler.otf",
"img/button.json"
]).then(() => setup());
//Declare any variables shared between functions
let canvas, playButton, stateMessage, actionMessage, pointer;
function setup() {
//Make the canvas and initialize the stage
canvas = makeCanvas(256, 256);
stage.width = canvas.width;
stage.height = canvas.height;
//Define the button's frames
let buttonFrames = [
assets["up.png"],
assets["over.png"],
assets["down.png"]
];
//Make the button sprite
playButton = button(buttonFrames, 32, 96);
//Define the button's actions
playButton.over = () => console.log("over");
playButton.out = () => console.log("out");
playButton.press = () => console.log("pressed");
playButton.release = () => console.log("released");
playButton.tap = () => console.log("tapped");
//Add some message text
stateMessage = text("State:", "12px puzzler", "black", 12, 12);
actionMessage = text("Action:", "12px puzzler", "black", 12, 32);
//Make the pointer
pointer = makePointer(canvas);
//Start the game loop
gameLoop();
}
function gameLoop() {
requestAnimationFrame(gameLoop);
//Update the buttons
if (buttons.length > 0) {
canvas.style.cursor = "auto";
buttons.forEach(button => {
button.update(pointer, canvas);
if (button.state === "over" || button.state === "down") {
if(button.parent !== undefined) {
canvas.style.cursor = "pointer";
}
}
});
}
//Display the button's state and action
stateMessage.content = `State: ${playButton.state}`;
actionMessage.content = `Action: ${playButton.action}`;
//Render the canvas
render(canvas);
}
我前面提到过,按钮只是一种交互式精灵,碰巧有三种定义的图像状态。这意味着你可以给任何 sprite 添加类似按钮的交互性。让我们找出方法。
制作互动精灵
您可以通过将任何 sprite 的interactive属性设置为true来使其行为像一个按钮:
anySprite.interactive = true;
这会将 sprite 添加到buttons数组中,并赋予它与任何其他按钮相同的方法属性。这意味着你可以分配press或release方法给精灵,并访问它的状态和动作属性。
注意你也可以让stage对象交互。如果您想知道玩家是否点击或按下了画布,这将非常有用。
运行interacteractiveSprites.html演示该功能,如图图 6-15 所示。如果你点击这个圆,它的填充和描边颜色会随机改变。

图 6-15 。点按以使圆显示随机颜色
下面是实现这种效果的代码:
//Make the pointer
pointer = makePointer(canvas);
//Create the sprite and put it in the center of the stage
ball = circle(96, "red", "blue", 8);
stage.putCenter(ball);
//Make the ball interactive
ball.interactive = true;
//Assign the ball's `press` method
ball.press = () => {
//An array of color names
var colors = ["Gold", "Lavender", "Crimson", "DarkSeaGreen"];
//Set the ball's `fillStyle` and `strokeStyle` to a random color
ball.fillStyle = colors[randomInt(0, 3)];
ball.strokeStyle = colors[randomInt(0, 3)];
};
如果你想制作一个可点击/点击的精灵来与指针交互,你现在有一个方法可以做到。
注意记住如果你设置一个 sprite 的交互属性为true,你还需要导入buttons数组,更新游戏循环中的所有按钮。
拖放
我们需要让我们的精灵完全交互的最后一件事是给他们拖放功能。在第四章的中,我们给精灵添加了一个draggable属性,你可以设置为true或者false:
anySprite.draggable = true;
这是我的另一个“以后我会告诉你的!”时刻。设置draggable到true的作用是让你用指针在画布上拖动精灵。图 6-16 显示了让你这样做的draggableSprites.html示例文件的输出。您可以在画布上拖动精灵,并将它们堆叠在一起。选定的精灵显示在栈的顶部,当鼠标箭头光标在可拖动的精灵上时会变成一个手形。

图 6-16 。拖拽小精灵
当你设置draggable为true时,实际上会发生什么,这是如何工作的?
首先,你需要一个名为draggableSprites的数组。您可以在library/display模块中找到它。
export let draggableSprites = [];
当设置为true时,DisplayObject类上的draggable属性将 sprite 推入draggableSprites,如果设置为false则将其拼接出来。下面是来自DisplayObject类的代码:
get draggable() {
return this._draggable;
}
set draggable(value) {
if (value === true) {
//Push the sprite into the `draggableSprites` array
draggableSprites.push(this);
this._draggable = true;
}
if (value === false) {
//Splice the sprite from the `draggableSprites` array
draggableSprites.splice(draggableSprites.indexOf(this), 1);
}
}
接下来,您需要一些新的属性和一个关于pointer对象的新方法来帮助您控制拖放行为。它们被添加到我们在本章前面创建的makePointer函数的pointer中:
function makePointer(element, scale = 1) {
let pointer = {
//... the pointer's existing properties and methods...
//New drag and drop properties:
dragSprite: null,
dragOffsetX: 0,
dragOffsetY: 0,
//New `updateDragAndDrop` method:
updateDragAndDrop(sprite) {
//The new code goes here
}
};
//... the rest of the `makePointer` function...
return pointer;
}
dragSprite对象是指针当前拖动的 sprite,dragOffsetX和dragOffsetY用于帮助移动dragSprite。updateDragAndDrop方法负责选择可拖动的精灵,在画布上拖动它们,并使选中的精灵堆叠在未选中的精灵之上。
updateDragAndDrop(draggableSprites) {
//Check whether the pointer is pressed down
if (this.isDown) {
//You need to capture the coordinates at which the pointer was
//pressed down and find out if it's touching a sprite
//Only run this code if the pointer isn't already dragging a sprite
if (this.dragSprite === null) {
//Loop through the `draggableSprites` in reverse so that
//you start searching at the top of the stack.
//This means the last array element
//will be the first one checked.
//(Sprites at the end of the array are displayed
//above sprites at the beginning of the array)
for (let i = draggableSprites.length - 1; i > -1; i--) {
let sprite = draggableSprites[i];
//Check for a collision with the pointer using `hitTestSprite`
if (this.hitTestSprite(sprite) && sprite.draggable) {
//Calculate the difference between the pointer's
//position and the sprite's position
this.dragOffsetX = this.x - sprite.gx;
this.dragOffsetY = this.y - sprite.gy;
//Set the sprite as the pointer's `dragSprite` property
this.dragSprite = sprite;
//The next two lines reorder the `sprites` array so that the
//selected sprite is displayed above all the others.
//First, splice the sprite out of its current position in
//its parent's `children` array
let children = sprite.parent.children;
children.splice(children.indexOf(sprite), 1);
//Next, push the `dragSprite` to the end
//of its `children` array so that it's
//displayed last, above all the other sprites
children.push(sprite);
//Reorganize the `draggableSprites` array in the same way
draggableSprites.splice(draggableSprites.indexOf(sprite), 1);
draggableSprites.push(sprite);
//Break the loop, because we only need to drag the topmost sprite
break;
}
}
}
//If the pointer is down and it has a `dragSprite`, make the
//sprite follow the pointer's position, with the calculated offset
else {
this.dragSprite.x = this.x - this.dragOffsetX;
this.dragSprite.y = this.y - this.dragOffsetY;
}
}
//If the pointer is up, drop the `dragSprite` by setting it to `null`
if (this.isUp) {
this.dragSprite = null;
}
//Change the mouse arrow pointer to a hand if it's over a
//draggable sprite
draggableSprites.some(sprite => {
if (this.hitTestSprite(sprite) && sprite.draggable) {
this.element.style.cursor = "pointer";
return true;
} else {
this.element.style.cursor = "auto";
return false;
}
});
}
要实现这一点,您需要在游戏循环内部调用指针的updateDragAndDrop方法。这使子画面和指针位置与帧速率保持同步。
function gameLoop() {
requestAnimationFrame(gameLoop);
pointer.updateDragAndDrop(draggableSprites);
render(canvas);
}
为了让你明白这一切是如何在适当的环境下工作的,这里是来自draggableSprites.html文件的所有 JavaScript 代码,它创建了三个可拖动的动物精灵,如图图 6-16 所示。
//Import code from the library, including the `draggableSprites` array
import {makeCanvas, stage, draggableSprites, sprite, render} from "../library/display";
import {assets} from "../library/utilities";
import {makePointer} from "../library/interactive";
//Load the texture atlas containing the animal sprite images
assets.load(["img/animals.json"]).then(() => setup());
//Declare any variables shared between functions
let canvas, cat, tiger, hedgehog, pointer;
function setup() {
//Make the canvas and initialize the stage
canvas = makeCanvas(256, 256);
stage.width = canvas.width;
stage.height = canvas.height;
//Make three sprites and set their `draggable` properties to `true`
cat = sprite(assets["cat.png"]);
stage.putCenter(cat, -32, -32);
cat.draggable = true;
tiger = sprite(assets["tiger.png"]);
stage.putCenter(tiger);
tiger.draggable = true;
hedgehog = sprite(assets["hedgehog.png"]);
stage.putCenter(hedgehog, 32, 32);
hedgehog.draggable = true;
//Make the pointer
pointer = makePointer(canvas);
//Start the game loop
gameLoop();
}
function gameLoop() {
requestAnimationFrame(gameLoop);
//Update the pointer's drag and drop system
pointer.updateDragAndDrop(draggableSprites);
//Render the canvas
render(canvas);
}
您可以将此代码用作向任何 sprite 添加拖放功能的基本模型。
摘要
游戏都是关于交互性的,在这一章中,你已经学习了为任何游戏增加交互性所需要知道的最重要的技术。您已经学习了计算距离的经典函数,使小精灵向其他小精灵旋转,使小精灵跟随其他小精灵或指针,并使小精灵沿其旋转方向移动。您还了解了如何编写和实现一个有用的keyboard函数来快速为游戏添加键盘交互性,以及如何创建一个通用的pointer对象,它既适用于鼠标,也适用于触摸。如果这还不够,您还发现了如何制作可点击和可触摸的按钮,以及如何在画布上拖放精灵。
但是在我们真正开始制作游戏之前,我们还需要完成一个重要的拼图。我们必须学会当精灵们撞到一起时该怎么做。这就是下一章要讲的:碰撞。
七、碰撞检测
两个精灵碰撞会发生什么?这就是本章的全部内容:碰撞检测。你将学习如何判断两个精灵是否在触碰,并让他们在触碰时做出有趣的反应。对于 2D 游戏,有四种基本的碰撞检测技术你需要知道,我们将在本章中一一介绍:
- 点与形状:当一个点与一个形状相交时
- 圆对圆:两个圆相交时
- 矩形 vs 矩形 :两个矩形相交时
- 圆形对矩形:矩形和圆形相交时
有了这四种技术,整个游戏设计的可能性就展现在你面前了。您将学习一些有用的、可重用的函数,这些函数将帮助您使用我们在本书中开发的 sprite 系统进行碰撞检测,以及如何将这些函数应用于各种实际的游戏原型。
你会在本章源文件的library/collision.js文件中找到我们将在本章中用到的所有碰撞函数。我们将在本章中使用以下碰撞函数:
hitTestPoint
hitTestCircle
hitTestRectangle
rectangleCollision
circleCollision
movingCircleCollision
hitTestCirclePoint
circlePointCollsion
hitTestCircleRectangle
circleRectangleCollision
hit
在使用这些函数之前,请确保将其导入到您的应用程序代码中。他们将与任何具有以下属性的精灵一起工作:
x, y, centerX, centerY, vx, vy, width, height,
halfWidth, halfHeight, radius, diameter
只要你的精灵拥有这些属性,碰撞功能就会起作用——即使你使用的是其他显示系统或游戏引擎,而不是我们在本书中构建的。让我们来看看如何使用这些碰撞功能来开始制作一些引人注目的游戏。
注意在这一章中,我不打算深究本章中碰撞函数如何工作的所有杂乱的细节。它们被设计成你可以将它们放入任何游戏项目中——让它们发挥它们的魔力并享受它。你是游戏设计师,不是数学家,所以放轻松!这一章是关于如何以有趣的方式使用这些碰撞函数。你会发现所有的碰撞函数都在源代码中做了细致的注释,所以如果你真的很好奇,可以看看。如果你想深入了解这些碰撞函数背后的数学原理,当我们在附录中检查向量时,你会学到你需要知道的一切。
点与形状
最基本的碰撞测试是检查一个点是否与一个形状相交。您可以使用一个名为hitTestPoint的函数来解决这个问题。hitTestPoint接受两个参数:一个具有 x 和 y 属性的点对象,以及一个 sprite。
hitTestPoint(
{x: 128, y: 128}, //An object with `x` and `y` properties
sprite //A sprite
)
如果点与精灵相交,hitTestPoint将返回true,如果不相交,false将返回。下面是如何使用它来检查指针和一个名为box的矩形精灵之间的冲突:
if (hitTestPoint(pointer.position, box) {
//The point is touching the box
}
(你会记得上一章中指针的position属性是一个包含一个 x,y 值的对象。)
hitTestPoint函数同样适用于矩形和圆形精灵。如果精灵有一个radius属性,hitTestPoint假设精灵是圆形的,并对圆形应用点碰撞检测算法。如果 sprite 没有一个radius属性,那么这个函数假设它是一个正方形。你会在pointVsShape.html文件中找到一个hitTestPoint的工作示例,如图图 7-1 所示。

图 7-1 。检查是否有一个点,比如指针,接触到了一个精灵
惊喜!hitTestPoint和你上一章学的指针的hitTestSprite方法几乎一模一样。唯一的区别是你可以对你定义的任何点使用hitTestPoint,而不仅仅是指针。下面是示例pointVsShape.html文件中游戏循环的代码,实现了图 7-1 : 所示的效果
if(hitTestPoint(pointer.position, ball)) {
message.content = "Ball!"
} else if(hitTestPoint(pointer.position, box)) {
message.content = "Box!"
} else {
message.content = "No collision..."
}
圆形 vs 圆形
如果你想检查两个圆形精灵之间的碰撞,使用hitTestCircle函数 :
hitTestCircle(sprite1, sprite2)
将它与任何具有radius属性的 sprite 一起使用。如果圆接触,它返回true,所以你可以用一个if语句来检查碰撞,语法如下:
if (hitTestCircle(sprite1, sprite2)) {
//The circles are touching
}
注意章节中所有的碰撞函数默认使用精灵的本地坐标。如果要强制函数使用全局坐标,将最后一个可选参数global设置为true。以下是如何:
hitTestCircle(sprite1, sprite2, true)
本章中的所有碰撞函数都有这个可选的最终global参数。
运行circleCollision.html文件,看看如何使用这个函数和我们在上一章创建的拖放指针系统,如图 7-2 所示。

图 7-2 。检查两个圆是否接触
下面是游戏循环中使用hitTestCircle来实现这种效果的代码。红圈称为c1(代表“圈 1”),蓝圈称为c2(代表“圈 2”)。
if(hitTestCircle(c1, c2)) {
message.content = "Collision!"
} else {
message.content = "No collision..."
}
这是创建一个拼图或拖放形状匹配游戏所需的基本系统。
反应循环碰撞
在上一个示例中,您可以检测到发生了碰撞,但是圆没有以任何方式对碰撞做出反应。在大多数动作游戏中,你会希望你的精灵阻止其他精灵的移动,或者在它们碰撞时相互弹开。有两个函数可以用来制作具有这种真实碰撞反应的精灵:circleCollision和movingCircleCollision。为什么有两个?因为运动的圆根据撞上的是静止的圆还是另一个运动的圆反应略有不同。在接下来的两个部分中,你将学习如何在一些非常实用的游戏原型中使用这两个函数。
运动圆和静止圆之间的碰撞
如果一个运动的圆碰到一个不运动的圆,你可以使用circleCollision功能创建一个碰撞反应:
circleCollision(circle1, circle2, true);
第一个参数是运动的球,第二个参数是不运动的球。第三个参数是一个可选的布尔值,它决定第一个圆是否应该从第二个圆弹回。(如果你忽略它,布尔值将默认为false,所以如果你想让圆圈反弹,将其设置为true。)
注意将可选的第四个参数设置为true使得函数使用精灵的全局坐标。如果您想要检查具有不同父容器的精灵之间的碰撞,这是很重要的。您将在前面的示例中看到这是如何有帮助的。
任何带有radius属性的 sprite 都可以用在这个函数中。如果精灵也有一个mass属性,circleCollision函数将使用它的值按比例减弱反弹效果。
运行pegs.html来看看你如何使用这个函数来制作一个球,让它在一个圆形的格子中弹跳。球从每个木栓上弹开,停在画布的底部。网格中每个木栓的大小和颜色是随机的,球的大小、质量和起始速度也是随机的,因此每次运行的效果都是不同的。图 7-3 显示了您将看到的内容。

图 7-3 。一个球落下来,穿过一排排钉子
钉子在名为pegs的数组中,球的碰撞反应在游戏循环中使用这段代码创建:
pegs.children.forEach(peg => {
circleCollision(ball, peg, true, true);
});
这就是创建反弹效果所需的全部内容。但是在这个程序中还有一些更有趣的东西,你可能会在你自己的游戏中发现它们的用处。让我们来看看钉子网格是如何制作的。
绘制网格
仔细观察,你会注意到每一个木桩都被布置在一个 5 列 5 行的隐形网格中,如图 7-4 所示。

图 7-4 。每个 peg 在一个不可见的网格中居中
您可以看到,每个 peg 都在一个宽度和高度为 48 像素的网格单元内居中。每个木栓都有一个介于 8 和 32 像素之间的随机直径,以及一个从颜色值数组中选择的随机颜色。网格从画布左侧偏移 8 像素,从顶部偏移 48 像素。网格是父组容器,每个圆形 peg 是该组的子组。这个网格是怎么做出来的?
在这样的网格形状中绘制精灵是一项非常常见的视频游戏设计任务——事实上,如此常见,以至于将这项工作分配给一个可重复使用的功能会有所帮助,它会自动为您完成这项工作。在library/display文件夹中,你会找到一个名为grid的函数来完成这个任务。下面是如何在示例文件中使用grid函数来制作pegs网格:
pegs = grid(
5, //The number of columns
4, //The number of rows
48, //The width of each cell
48, //The height of each cell
true, //Should the sprite be centered in the cell?
0, //The sprite's xOffset from the left of the cell
0, //The sprite's yOffset from the top of the cell
//A function that describes how to make each peg in the grid.
//A random diameter and color are selected for each one
() => {
let peg = circle(randomInt(8, 32));
let colors = [
"#FFABAB", "#FFDAAB", "#DDFFAB", "#ABE4FF", "#D9ABFF"
];
peg.fillStyle = colors[randomInt(0, 4)];
return peg;
},
//Run any optional extra code after each
//peg is made
() => console.log("extra!")
);
该函数返回一个名为pegs的group。网格中每个单元格内的所有精灵都是那个pegs组的孩子。因为它是一个组,所以你可以像操纵任何其他精灵一样操纵整个网格。这意味着你可以使用我们在第四章中创建的setPosition方法在画布中定位组,就像这样:
pegs.setPosition(8, 48);
测试球和钉子之间的碰撞只是循环通过pegs.children并为每个调用circleCollision函数。因为精灵在组内的位置是相对于组的本地坐标的,你需要将circleCollision的global标志设置为true。
pegs.children.forEach(peg => {
circleCollision(ball, peg, true, true);
});
如果你不使用全局坐标,所有的碰撞看起来都会偏离画布的左边和上边的网格的偏移量(8 个像素在 x 轴上,48 个像素在 y 轴上)。
下面是完整的grid函数,它创建所有的标桩,在网格上绘制它们,并将它们添加到组中:
export function grid(
columns = 0, rows = 0, cellWidth = 32, cellHeight = 32,
centerCell = false, xOffset = 0, yOffset = 0,
makeSprite = undefined,
extra = undefined
){
//Create an empty group called `container`. This `container`
//group is what the function returns to the main program.
//All the sprites in the grid cells will be added
//as children to this container
let container = group();
//The `create` method plots the grid
let createGrid = () => {
//Figure out the number of cells in the grid
let length = columns * rows;
//Create a sprite for each cell
for(let i = 0; i < length; i++) {
//Figure out the sprite's x/y placement in the grid
let x = (i % columns) * cellWidth,
y = Math.floor(i / columns) * cellHeight;
//Use the `makeSprite` function supplied in the constructor
//to make a sprite for the grid cell
let sprite = makeSprite();
//Add the sprite to the `container`
container.addChild(sprite);
//Should the sprite be centered in the cell?
//No, it shouldn't be centered
if (!centerCell) {
sprite.x = x + xOffset;
sprite.y = y + yOffset;
}
//Yes, it should be centered
else {
sprite.x
= x + (cellWidth / 2)
- sprite.halfWidth + xOffset;
sprite.y
= y + (cellHeight / 2)
- sprite.halfHeight + yOffset;
}
//Run any optional extra code. This calls the
//`extra` function supplied by the constructor
if (extra) extra(sprite);
}
};
//Run the `createGrid` method
createGrid();
//Return the `container` group back to the main program
return container;
}
这段代码有点神奇。该计算为网格中的每个单元格找到正确的 x,y 位置:
let x = (i % columns) * cellWidth,
y = Math.floor(i / columns) * cellHeight;
这让您可以使用一个for循环来绘制网格,而不必使用两个嵌套循环。这是一条便捷的捷径。(如果你想知道这到底是为什么,在本章后面的平台游戏例子中会有详细的解释。)
提示这个例子有一个有趣的扩展。如果你需要让一个球看起来在滚动,你可以用球的vx除以它的radius,然后把结果加到它的rotation,如下所示:ball.rotation += ball.vx / ball.radius;
在这个例子中,你看到了如何让一个移动的球与静止的球互动,但是如果所有的球都在移动,就像在台球或弹珠游戏中一样,那该怎么办呢?
运动圆之间的碰撞
您可以使用movingCircleCollision功能创建两个移动圆之间的碰撞反应。提供两个圆形精灵作为参数:
movingCircleCollision(circle1, circle2)
如果圆圈有一个mass属性,它将用于帮助计算出圆圈相互弹开的力。默认情况下,movingCircleCollision使精灵弹开。
这个函数的一个重要特征是,当两个运动的圆碰撞时,它们以一种使它们非常真实地弹开的方式将它们的速度传递给彼此。这开启了一个全新的游戏世界。你现在已经非常接近能够玩台球或弹珠游戏了。运行marbles.html得到这样一个游戏的工作原型,如图图 7-5 所示。在任何弹球上按住指针,拉动并释放指针,弹开弹球。开始拖移时,弹球和指针之间会出现一条黄线。这条线代表了一种弹力带或弹弓,它将弹球拉向你拉动的相反方向。线的长度决定了弹球移动的力。当你松开弹弓时,弹球会从画布的边缘和所有其他弹球上弹开,导致它们互相反弹。

图 7-5 。拉动并释放以使多个圆圈 在画布周围相互弹开
这里有一些非常有趣的东西,将你目前为止在书中学到的许多技术结合在一起。让我们一步一步来看看这个游戏原型是如何制作的。
制作弹珠
每个大理石是如何制作的?弹珠实际上是来自一个名为marbles.png的单幅拼贴图像的图像,如图 7-6 中的所示。每个单幅图块为 32 x 32 像素。

图 7-6 。大理石瓷砖
这个游戏只使用了前两行图像:六个彩色圆圈。在第四章的中,你学习了如何使用frames函数在 tileset 上捕捉多个图像。以下是如何使用它来捕捉对所有六个彩色圆圈的引用:
let marbleFrames = frames(
assets["img/marbles.png"], //The tileset image
[
[0,0],[32,0],[64,0], //A 2D array that defines the
[0,32],[32,32],[64,32] //x and y image positions
],
32, 32 //The width and height of each image
);
现在可以使用这些帧初始化精灵:
let marble = sprite(marbleFrames);
大理石精灵现在引用了所有六个图像帧,您可以使用gotoAndStop来显示其中的任何一个。下面是如何使用randomInt让大理石显示一个随机帧:
marble.gotoAndStop(randomInt(0, 5));
将弹球的circular属性 设置为true,使其具有碰撞功能所需的diameter和radius属性:
marble.circular = true;
如果你想给弹球一个随机的直径呢?创建一个大小数组,并随机分配一个给弹球的diameter属性 :
let sizes = [8, 12, 16, 20, 24, 28, 32];
marble.diameter = sizes[randomInt(0, 6)];
当然,你不只是制造一个弹珠——游戏原型有 25 个弹珠。所以在网格中初始化它们是有意义的。以下是游戏中的setup函数的所有代码,该函数使用grid函数创建所有 25 个大理石精灵。
marbles = grid(
//Set the grid's properties
5, 5, 64, 64,
true, 0, 0,
//A function that describes how to make each marble
() => {
let marbleFrames = frames(
assets["img/marbles.png"],
[
[0,0],[32,0],[64,0],
[0,32],[32,32],[64,32]
],
32, 32
);
//Initialize a marble with the frames
let marble = sprite(marbleFrames);
//Set the marble to a random frame
marble.gotoAndStop(randomInt(0, 5));
//Give it circular properties (`diameter` and `radius`)
marble.circular = true
//Give the marble a random diameter
let sizes = [8, 12, 16, 20, 24, 28, 32];
marble.diameter = sizes[randomInt(0, 6)];
//Give it a random initial velocity
marble.vx = randomInt(-10, 10);
marble.vy = randomInt(-10, 10);
//Assign the rest of the marble's physics properties
marble.frictionX = 0.99;
marble.frictionY = 0.99;
marble.mass = 0.75 + (marble.diameter / 32);
//Return the marble sprite so that it can
//be added to the grid cell
return marble;
}
);
您可以在这段代码中看到,弹珠也被赋予了随机的初始速度,从–10 到 10:
marble.vx = randomInt(-10, 10);
marble.vy = randomInt(-10, 10);
这意味着一旦他们的位置在游戏循环中更新,他们就会朝不同的方向飞走。代码还计算出每个弹球的mass :
marble.mass = 0.75 + (marble.diameter / 32);
质量较小的较轻弹珠会比质量较大的较重弹珠以更快的速度弹开。
制作弹弓
这个例子的一个关键特征是弹弓效应,当你把指针放在一个弹球上,拖动,然后放开它,你就会得到弹弓效应。弹球逆着你拉动的方向弹开,并反弹到它碰到的任何其他弹球上,产生一个复杂的碰撞链,如图图 7-7 所示。

图 7-7 。在弹球上按下、拖动并释放指针,产生弹弓效果
这个弹弓装置是很多游戏的重要功能,比如愤怒的小鸟,所以让我们快速看看它是怎么做的。
当你按下并拖动一个弹球时,你看到的被称为吊带 的黑线就是一个line精灵。它的visible属性在游戏刚开始的时候被设置为setup函数中的false,这样你就看不到它了:
sling = line("Yellow", 4);
sling.visible = false;
该游戏使用一个名为capturedMarble 的变量来跟踪指针选择了哪个弹球。游戏第一次开始时,它被初始化为null:
capturedMarble = null;
在每一帧中,游戏循环遍历所有的弹珠,并检查与pointer的碰撞。如果pointer是关闭的,并且一个弹球还没有被捕获,代码使用hitTestPoint来发现在pointer下面是否有一个弹球。如果有,代码会将弹球设置为capturedMarble,并将弹球的速度设置为零以阻止其移动:
marbles.children.forEach(marble => {
//Check for a collision with the pointer and marble
if (pointer.isDown && capturedMarble === null) {
if (hitTestPoint(pointer, marble)) {
//If there's a collision, capture the marble
capturedMarble = marble;
capturedMarble.vx = 0;
capturedMarble.vy = 0;
}
}
//... make the marbles move and set screen boundaries...
});
如果一个弹球被捕获,吊索变得可见,并从pointer 被拉到弹球的中心:
if (capturedMarble !== null) {
sling.visible = true;
sling.ax = capturedMarble.centerX;
sling.ay = capturedMarble.centerY;
sling.bx = pointer.x;
sling.by = pointer.y;
}
当pointer被释放时,吊索的长度被转换成速度,使弹球以成比例的速度向相反的方向射出。你拖得越远,弹球的速度就越快。就像橡皮筋一样。这实际上只是我们在前一章中用来发射子弹的代码的一个微小变化。
if (pointer.isUp) {
//Make the sling invisible when it is released
sling.visible = false;
if (capturedMarble !== null) {
//Find out how long the sling is
sling.length = distance(capturedMarble, pointer);
//Get the angle between the center of the marble and the pointer
sling.angle = angle(pointer, capturedMarble);
//Shoot the marble away from the pointer with a velocity
//proportional to the sling's length
let speed = 5;
capturedMarble.vx = Math.cos(sling.angle) * sling.length / speed;
capturedMarble.vy = Math.sin(sling.angle) * sling.length / speed;
//Release the captured marble
capturedMarble = null;
}
}
检查多重碰撞
在每一帧上,你需要检查每个弹球和其他弹球之间的碰撞。你需要确保没有一对弹珠会被检查一次以上的碰撞。实现这一点的关键是使用一个嵌套的 for 循环的,并使内部循环的计数器比外部循环大 1。下面是嵌套的for循环,它使用movingCircleCollision函数让弹珠相互弹开:
for (let i = 0; i < marbles.children.length; i++) {
//The first marble to use in the collision check
var c1 = marbles.children[i];
for (let j = i + 1; j < marbles.children.length; j++) {
//The second marble to use in the collision check
let c2 = marbles.children[j];
//Check for a collision and bounce the marbles apart if they collide
movingCircleCollision(c1, c2);
}
}
您可以看到内部循环的起始数字比外部循环大一:
let j = i + 1
这可以防止任何一对对象被多次检查碰撞。
注意library/collision文件包含一个名为multipleCircleCollision的便利方法,可以自动完成整个嵌套的for循环。您可以在游戏循环中使用它来检查一个数组中的所有精灵和同一数组中的所有其他精灵,而不会重复。像这样使用它:
multipleCircleCollision(marbles.children)
它会自动调用每一对精灵的movingCircleCollision来让它们互相弹开。
你现在知道了使用圆形精灵制作各种游戏所需的大部分重要技术。接下来,我们将看看如何处理矩形精灵之间的碰撞。
注意需要做圆和单点的碰撞检查吗?就把一个点想象成一个直径为 1 个像素,半径为 0.5 个像素的非常小的圆。然后使用那个非常小的圆和任何能和普通圆一起工作的碰撞函数。为了方便起见,library/collision模块包含了两个“圆对点”函数:hitTestCirclePoint测试碰撞,circlePointCollision将圆从点上弹开。第一个参数应该是 circle sprite,第二个参数应该是具有 x 和 y 属性的 point 对象。
矩形与矩形
要找出两个矩形精灵是否重叠,使用一个名为hitTestRectangle 的函数:
hitTestRectangle(rectangle1, rectangle2)
运行文件rectangleCollision.html获得一个简单的例子。用指针拖动方块,观察输出文本显示“Hit!”当它们碰撞时,如图图 7-8 所示。

图 7-8 。检查矩形之间的冲突
在简单的if语句中使用hitTestRectangle会改变输出文本:
if (hitTestRectangle(rectangle1, rectangle2)) {
output.text = "Hit!";
} else {
output.text = "No collision...";
}
像这样检查矩形之间的碰撞是目前为止游戏中最常见的碰撞检测。事实上,你可以用比hitTestRectangle更复杂的东西制作无数游戏。让我们来仔细看看如何使用它来制作一个简单的对象收集和敌人回避游戏,名为寻宝者 。
寻宝者
寻宝者(图 7-9 )是一个很好的例子,展示了一个最简单的完整游戏,你可以使用我们在本书中已经建立的工具来制作。(通过章节源文件中的treasureHunter.html来感受一下。)使用箭头键帮助探险家找到宝藏,并把它带到出口。六个斑点怪物在地牢墙壁之间上下移动,如果它们击中探险家,他就会变成半透明,右上角的生命值会缩小。如果所有生命值用完,画布上显示“你输了”;如果玩家角色带着宝藏到达出口,“你赢了!”已显示。虽然这是一个基本的原型,寻宝者包含了你在更大的游戏中会发现的大部分元素:纹理贴图、交互性、碰撞和多个游戏场景。让我们快速地看一下它是如何被组合在一起的,这样你就可以用它作为你自己的一个游戏的起点。

图 7-9 。找到宝藏,避开敌人,到达出口
制作游戏精灵
每个精灵最初都是一个单独的图像文件。我用纹理打包器把它们变成了纹理图谱(如图图 7-10 ,用assets.load导入纹理图谱。

图 7-10 。用单独的图像文件制作纹理贴图集
assets.load(["img/treasureHunter.json"]).then(() => setup());
玩家角色、出口门、宝箱和地下城背景图像都是来自纹理贴图帧 的精灵。
//The dungeon background image
dungeon = sprite(assets["dungeon.png"]);
//The exit door
exit = sprite(assets["door.png"]);
exit.x = 32;
//The player character sprite
player = sprite(assets["explorer.png"]);
stage.putCenter(player, -128);
//Create the treasure
treasure = sprite(assets["treasure.png"]);
//Position the treasure next to the right edge of the canvas
stage.putRight(treasure, -64);
所有精灵被组合在一个gameScene : 中
gameScene = group(dungeon, exit, player, treasure);
将它们放在一个组中会让我们很容易隐藏gameScene并在游戏结束时显示gameOverScene。
六个斑点怪物在一个循环中被创建。每个斑点被赋予一个随机的初始位置和速度。对于每个斑点,垂直速度交替乘以 1 或–1,这就是导致每个斑点向与其相邻斑点相反的方向移动的原因:
let numberOfEnemies = 6,
spacing = 48,
xOffset = 150,
speed = 2,
direction = 1;
//An array to store all the enemies
enemies = [];
//Make as many enemies as there are `numberOfEnemies`
for (let i = 0; i < numberOfEnemies; i++) {
//Each enemy is made from a blob texture atlas frame
let enemy = sprite(assets["blob.png"]);
//Space each enemy horizontally according to the `spacing` value.
//`xOffset` determines the point from the left of the screen
//at which the first enemy should be added
let x = spacing * i + xOffset;
//Give the enemy a random y position
let y = randomInt(0, canvas.height - enemy.height);
//Set the enemy's direction
enemy.x = x;
enemy.y = y;
//Set the enemy's vertical velocity. `direction` will be either `1` or
//`-1`. `1` means the enemy will move down and `-1` means the enemy will
//move up. Multiplying `direction` by `speed` determines the enemy's
//vertical direction
enemy.vy = speed * direction;
//Reverse the direction for the next enemy
direction *= -1;
//Push the enemy into the `enemies` array
enemies.push(enemy);
//Add the enemy to the `gameScene`
gameScene.addChild(enemy);
}
你会注意到,当玩家触摸其中一个敌人时,屏幕右上角的生命值条的宽度会减小。这款保健棒是怎么做出来的?它只是两个相同位置的矩形精灵:一个黑色矩形在后面,一个红色矩形在前面。它们被组合在一起,形成一个名为healthBar 的复合精灵。然后将healthBar 添加到gameScene??。
//Make the inner and outer bars
let outerBar = rectangle(128, 8, "black"),
innerBar = rectangle(128, 8, "red");
//Group the inner and outer bars
healthBar = group(outerBar, innerBar);
//Set the `innerBar` as a property of the `healthBar`
healthBar.inner = innerBar;
//Position the health bar
healthBar.x = canvas.width - 164;
healthBar.y = 4;
//Add the health bar to the `gameScene`
gameScene.addChild(healthBar);
您可以看到一个名为inner的属性被添加到了healthBar中。它只是引用了innerBar(红色矩形),以便以后方便访问:
healthBar.inner = innerBar;
你不必包括这个属性;但是为什么不呢!这意味着如果你想控制innerBar的宽度,你可以写一些类似这样的流畅代码:
healthBar.inner.width = 30;
这是非常整洁和可读的,所以我们将保持它!
当游戏结束时,一些文字显示“你赢了!”或者“你输了!”,视结果而定。我们通过使用一个文本精灵来创建这个文本,并将其添加到一个名为gameOverScene的组中。游戏开始时,gameOverScene的visible属性被设置为false,这样你就看不到这段文字了。下面是来自setup函数的代码,它创建了gameOverScene和message文本:
//Add some text for the game over message
message = text("Game Over!", "64px Futura", "black", 20, 20);
message.x = 120;
message.y = canvas.height / 2 - 64;
//Create a `gameOverScene` group and add the message sprite to it
gameOverScene = group(message);
//Make the `gameOverScene` invisible for now
gameOverScene.visible = false;
当游戏结束后,gameOverScene.visible将被设置为true以揭示结果。
移动和包含精灵
使用键盘控制播放器,这样做的代码与您在上一章中学习的键盘控制代码非常相似。键盘对象修改玩家的速度,并且该速度被添加到玩家在游戏循环中的位置。《寻宝者》的一个重要细节是,所有玩家和敌人的精灵都被包含在地下城的墙壁里,以一种与艺术品的 2.5D 视角相匹配的方式。该区域略小于整个画布区域,在图 7-11 中用绿色矩形表示。

图 7-11 。精灵被藏在地牢的墙壁里
借助我们在前一章中编写的自定义函数contain??,这很容易做到。contain函数的第二个参数是一个对象,它定义了应该包含精灵的矩形区域。在《寻宝者》中,这是一个从画布区域偏移并略小于画布区域的区域。
contain(
player,
{
x: 32, y: 16,
width: canvas.width - 32,
height: canvas.height - 32
}
);
游戏循环还会移动敌人,将他们控制在地下城内,并检查每个敌人是否与玩家发生冲突。如果一个敌人撞上了地牢的顶壁或底壁,它的方向就会反转。一个forEach循环在每一帧完成所有这些工作:
//Loop through all the enemies
enemies.forEach(enemy => {
//Move the enemy
enemy.x += enemy.vx;
enemy.y += enemy.vy;
//Check the enemy's screen boundaries
let enemyHitsEdges = contain(
enemy,
{
x: 32, y: 16,
width: canvas.width - 32,
height: canvas.height - 32
}
);
//If the enemy hits the top or bottom of the stage, it reverses
//its direction
if (enemyHitsEdges === "top" || enemyHitsEdges === "bottom") {
enemy.vy *= -1;
}
//Test for a collision. If any of the enemies are touching
//the player, set `playerHit` to `true`
if(hitTestRectangle(player, enemy)) {
playerHit = true;
}
});
最后一个if语句检查敌人和玩家之间的冲突——让我们仔细看看它是如何工作的。
检查碰撞
用于判断是否有敌人接触过玩家。如果hitTestRectangle 返回true,则表示发生了碰撞。然后,代码将名为playerHit的变量设置为true。
if(hitTestRectangle(player, enemy)) {
playerHit = true;
}
如果playerHit为true,游戏循环使玩家半透明,生命值条宽度减少 1 个像素:
if(playerHit) {
//Make the player semitransparent
player.alpha = 0.5;
//Reduce the width of the health bar's inner rectangle by 1 pixel
healthBar.inner.width -= 1;
} else {
//Make the player fully opaque (nontransparent) if it hasn't been hit
player.alpha = 1;
}
游戏循环还检查宝箱和玩家之间的碰撞。如果有击中,宝藏被设置到玩家的位置,稍微偏移,使它看起来像是玩家带着它(图 7-12 )。
if (hitTestRectangle(player, treasure)) {
treasure.x = player.x + 8;
treasure.y = player.y + 8;
}

图 7-12 。玩家角色可以拿起并携带宝箱
到达出口并结束游戏
游戏有两种结束方式:如果你把宝藏带到出口你就能赢,如果你耗尽了生命值你就输了。要赢得游戏,宝箱只需要触摸出口门。如果发生这种情况,包含所有精灵的gameScene将不可见,而显示消息文本的gameOverScene 会显示出来。下面是游戏循环中的if语句:
if (hitTestRectangle(treasure, exit)) {
gameScene.visible = false;
gameOverScene.visible = true;
message.content = "You won!";
}
要输掉游戏,生命值栏的宽度必须小于 0。如果是,那么gameOverScene以同样的方式显示。游戏循环使用这个if语句来检查:
if (healthBar.inner.width < 0) {
gameScene.visible = false;
gameOverScene.visible = true;
message.content = "You lost!";
}
这真的就是全部了!再做一点工作,你就可以把这个简单的原型变成一个完整的游戏——试试吧!
反应矩形碰撞
在前面的例子中,你可以检查两个矩形是否冲突,但是没有什么可以阻止它们重叠。有了一个叫做rectangleCollision 的新函数,我们可以更进一步,让矩形看起来像有实体一样;rectangleCollision将防止其前两个参数中的任何矩形子画面重叠:
rectangleCollision(rectangle1, rectangle2)
rectangleCollision也返回一个字符串,它的值可能是"left"、"right"、"top"或"bottom",告诉你第一个矩形的哪一侧接触了第二个矩形。您可以将返回值赋给一个变量,并在游戏中使用该信息。以下是如何:
let collision = rectangleCollision(rectangle1, rectangle2);
//On which side of the red square is the collision occurring?
switch (collision) {
case "left":
message.content = "Collision on left";
break;
case "right":
message.content = "Collision on right";
break;
case "top":
message.content = "Collision on top";
break;
case "bottom":
message.content = "Collision on bottom";
break;
default:
message.content = "No collision...";
}
collision的默认值为undefined。
这段代码防止矩形重叠,并在message文本精灵中显示碰撞边。运行reactiveRectangles.html作为工作示例,如图图 7-13 所示。使用指针将红色方块拖到蓝色方块中。无论你如何努力,方块将保持清晰的分离,永远不会重叠。输出文本显示碰撞发生在红色方块的哪一侧。

图 7-13 。方块不会重叠,输出文本会告诉您碰撞的一面
rectangleCollision函数有一个非常有用的副作用。参数中的第二个精灵有能力推开第一个精灵。你可以在例子中看到这种效果,用蓝色方块推动画布周围的红色方块,如图 7-14 所示。

图 7-14 。参数中的第二个精灵可以推动第一个精灵
如果你需要在游戏中加入推方块或滑动方块的功能,你可以这样做。
rectangleCollision函数有第三个可选的布尔参数bounce:
rectangleCollision(rectangle1, rectangle2, true)
如果bounce是true,当第一个精灵和第二个精灵碰撞时,它会使第一个精灵从第二个精灵身上弹开。其默认值为false。(与本章中所有其他碰撞函数一样,如果你想使用精灵的全局坐标,你应该将最后一个可选参数global设置为true。)
像这样精确的矩形碰撞反应是你武器库中最有用的游戏设计工具之一。为了向您展示它有多有用,我们将详细介绍一个实际的例子,它将为您提供许多灵感,让您可以立即着手大量的游戏项目。
制作平台游戏
平台游戏对于游戏设计者来说是一个伟大的技术和创意基准,因为它们给你一个机会来使用你的玩具盒中所有的游戏设计玩具。如果你能解决制作平台游戏需要克服的所有挑战,你会发现缩小规模并使用相同的技术制作许多其他类型的 2D 动作游戏很容易。
让我们利用本书中迄今为止已经创建的所有工具来构建一个极简平台游戏原型。运行platforms.html文件,如图 7-15 中的所示,尝试工作示例。用方向键左右移动红色方块,用空格键跳跃。你可以在岩石平台(黑色方块)或草地平台(绿色方块)上跳跃。草地平台总是在岩石平台之上。收集宝藏(黄色方块)以增加您的分数。游戏关卡地图是程序化生成的,所以每次玩都不一样。

图 7-15 。围绕一个程序化生成的平台游戏跑跳
这个游戏程序有两个主要部分。第一个控制平台跳跃的机制和碰撞检测的使用方式。第二部分是使用一些简单的规则随机创建游戏关卡的方式。我们先来看看游戏机制,然后看看关卡是如何创建的。在本节的最后,你将学习如何将这些简单的形状精灵替换为图像精灵,这样你就可以轻松地定制游戏的外观。
平台碰撞
游戏使用rectangleCollision功能来防止玩家角色(红色方块)从平台(黑色和绿色方块)上掉落。当玩家降落在一个平台上时,代码需要通过将玩家的速度设置为零来停止玩家。游戏还需要知道玩家何时站在“地上”这个游戏中的“地面”是任何平台的顶面。当玩家站在一个平台的顶部时,我们需要将一个名为isOnGround的变量设置为false,并通过从玩家的速度中减去重力来抵消重力的影响。
下面是游戏循环中完成这一切的代码。它遍历所有的平台,并使用rectangleCollision函数来找出玩家是否接触了任何平台。如果是,代码会防止玩家掉下去。代码还使用rectangleCollision'的返回值("left"、"right"、"top"或"bottom"来找出玩家触摸平台的哪一侧。
world.platforms.forEach(platform => {
//Use `rectangleCollision` to prevent the player and platforms
//from overlapping
let collision = rectangleCollision(player, platform);
//Use the `collision` variable to figure out what side of the player
//is hitting the platform
if (collision) {
if(collision === "bottom" && player.vy >= 0) {
//Tell the game that the player is on the ground if
//it's standing on top of a platform
player.isOnGround = true;
//Neutralize gravity by applying its
//exact opposite force to the character's vy
player.vy = -player.gravity;
}
else if(collision === "top" && player.vy <= 0) {
player.vy = 0;
}
else if(collision === "right" && player.vx >= 0) {
player.vx = 0;
}
else if(collision === "left" && player.vx <= 0) {
player.vx = 0;
}
//Set `isOnGround` to `false` if the bottom of the player
//isn't touching the platform
if(collision !== "bottom" && player.vy > 0) {
player.isOnGround = false;
}
}
});
玩家每捡起一个宝藏(黄色方块),宝藏就会从游戏中消失,分数增加一,如图图 7-16 所示。代码通过遍历每个平台精灵并使用hitTestRectangle 来检查碰撞来实现这一点。

图 7-16 。收集宝藏以增加分数
world.treasure = world.treasure.filter(box => {
//Check for a collision between the player and the treasure
if (hitTestRectangle(player, box)){
//Increase the score by 1
score += 1;
//Remove the treasure sprite
remove(box);
//Remove the treasure from the array
return false;
} else {
//Keep the treasure in the array
return true;
}
});
//Display the score
message.content = `score: ${score}`;
现在你知道了游戏中的碰撞检测是如何工作的,那么玩家是如何移动的呢?
让玩家移动和跳跃
这个平台游戏原型使用了你在前面章节中学到的所有物理力。它还增加了一个新功能:当你按下空格键时应用的jumpForce。jumpForce 是player精灵的属性:
player.jumpForce = -6.8;
它被设置为一个负数,让玩家向画布的顶部跳去。(记住,负 y 力使事情向上发展。)当玩家按下空格键,这个jumpForce就加到玩家的垂直速度上(vy)。找到使玩家的跳跃看起来自然的正确数字真的只是一个试错的问题。
只有当玩家站在平台上时,才允许他跳跃。幸运的是,我们为平台碰撞设置的isOnGround变量可以告诉我们这一点。此外,如果按下左右箭头键,玩家的移动应该不会受到摩擦的影响,这样它就可以在平台表面上平稳地移动。但是如果玩家在空中移动呢?一些风阻应该会使它慢一点,这样跳跃就更容易控制。这些都是微妙的细节,但最终的代码并不复杂。下面是来自setup函数的代码,它创建了玩家的键盘控件 。
leftArrow = keyboard(37);
rightArrow = keyboard(39);
space = keyboard(32);
//Left arrow key
leftArrow.press = () => {
if(rightArrow.isUp) {
player.accelerationX = -0.2;
}
};
leftArrow.release = () => {
if(rightArrow.isUp) {
player.accelerationX = 0;
}
};
//Right arrow key
rightArrow.press = () => {
if(leftArrow.isUp) {
player.accelerationX = 0.2;
}
};
rightArrow.release = () => {
if(leftArrow.isUp) {
player.accelerationX = 0;
}
};
//Space key (jump)
space.press = () => {
if(player.isOnGround) {
player.vy += player.jumpForce;
player.isOnGround = false;
player.frictionX = 1;
}
};
然后,游戏循环通过更新这些物理属性并将其应用于玩家的位置来使玩家移动:
//Regulate the amount of friction acting on the player
if (player.isOnGround) {
//Add some friction if the player is on the ground
player.frictionX = 0.92;
} else {
//Add less friction if it's in the air
player.frictionX = 0.97;
}
//Apply the acceleration
player.vx += player.accelerationX;
player.vy += player.accelerationY;
//Apply friction
player.vx *= player.frictionX;
//Apply gravity
player.vy += player.gravity;
//Move the player
player.x += player.vx;
player.y += player.vy;
这些都是创建大多数平台游戏需要知道的基本机制。但是实际的游戏世界是怎么创造出来的呢?
创造游戏世界
程序的setup功能创造了游戏世界。所有的关卡数据都存储在一个名为level的对象中,这个对象描述了游戏世界有多大。这个世界是由一个由瓷砖组成的网格构成的:16 块横向瓷砖和 16 块纵向瓷砖。每个拼贴宽 32 像素,高 32 像素,这意味着世界的像素大小为 512 乘 512。每块瓷砖的尺寸与我们用来创造世界的精灵的最大尺寸相匹配。
level = {
//The height and width of the level, in tiles
widthInTiles: 16,
heightInTiles: 16,
//The width and height of each tile, in pixels
tilewidth: 32,
tileheight: 32
};
在一个更复杂的游戏中,你可以在level对象中添加特定于游戏级别的其他类型的数据。这些属性可以存储特定项目的位置、使级别更容易或更难的值,或者应该创建的级别的大小和类型。如果你正在创建一个多级游戏,你可以在游戏中的每个级别使用不同的级别对象。然后你可以在一大堆游戏关卡中存储和访问它们,你可以随着游戏的进展动态地加载和创建它们。
这个等级数据然后被用来制作游戏世界:
world = makeWorld(level);
什么是world,makeWorld是如何工作的?world是一个由makeWorld返回的群组,包含游戏中所有的精灵。makeWorld功能基本上只是创建一个组,给它添加游戏精灵,然后把这个组返回给游戏程序。所有这些都发生在游戏循环开始运行之前的setup函数中。
makeWorld函数有很多工作要做,所以在我们看细节之前,让我们先来鸟瞰一下它做了什么。
function makeWorld(level) {
//create the `world` object
let world = group();
//Add some arrays to the world that will store the objects that we're
//going to create
world.map = [];
world.itemLocations = [];
world.platforms = [];
world.treasure = [];
//Initialize a reference to the player sprite
world.player = null;
//1\. Make the map
makeMap();
//2\. Terraform the map
terraformMap();
//3\. Add the items
addItems();
//4\. Make the sprites
makeSprites();
//The four functions that do all the work:
function makeMap() {/* Make the map */}
function terraformMap() {/* Add grass, rock, sky and clouds */}
function addItems() {/* Add the player and treasure to the map */}
function makeSprites() {/* Use the map data to make the actual game sprites */}
//Return the `world` group back to the main program
return world;
}
可以看到makeWorld有条不紊地依次调用了四个函数:makeMap、terraformMap、addItems、makeSprites。就像一条小流水线。这些函数中的每一个都做一点工作,然后将工作交给下一个函数继续。当最后一个makeSprites完成时,所有的精灵都被制作完成,并且world组返回到主游戏程序。这些都是按顺序发生的,所以让我们来看看每个函数是如何工作的。
制作地图
第一个函数makeMap ,用随机单元格填充地图数组。这些单元格只是人们熟悉的普通 JavaScript 对象:
cell = {};
每个单元格都有 x 和 y 属性,根据级别的宽度和高度表示它在网格上的位置。在这个例子中,它们代表 16×16 的单元网格。单元格有一个terrain属性,可以是"rock"或"sky"。每个细胞有 25%的几率是石头,75%的几率是天空,这是由一个叫做cellIsAlive的辅助函数决定的。这些单元格还有一个名为item的属性,我们将在后面的步骤中使用它来放置游戏物品:玩家和宝箱。
function makeMap() {
//The `cellIsAlive` helper function.
//Give each cell a 1 in 4 chance to live. If it's "alive", it will
//be rock, if it's "dead" it will be sky.
//`cellIsAlive` will be `undefined` unless the random number is 0
let cellIsAlive = () => randomInt(0, 3) === 0;
//First, figure out the number of cells in the grid
let numberOfCells = level.heightInTiles * level.widthInTiles;
//Next, create the cells in a loop
for (let i = 0; i < numberOfCells; i++) {
//Figure out the x and y position
let x = i % level.widthInTiles,
y = Math.floor(i / level.widthInTiles);
//Create the `cell` object
let cell = {
x: x,
y: y,
item: ""
};
//Decide whether the cell should be "rock" or "sky"
if (cellIsAlive()) {
cell.terrain = "rock";
} else {
cell.terrain = "sky";
}
//Push the cell into the world's `map` array
world.map.push(cell);
}
}
该函数运行后,map数组将包含 256 个cell对象,其中 25%的对象将随机将其地形设置为"rock",其余的设置为"sky"。它们的x和y属性也会告诉你它们在 16 乘 16 的网格上的位置。图 7-17 显示了一个随机地图的例子。(我们还没有为这些单元格创建精灵,所以图 7-17 只是展示了我们创建的数组数据。)

图 7-17 。创建岩石(黑色方块)和天空(蓝色方块)的随机贴图
这是一个好的开始,但是你会看到我们如何在接下来的步骤中改进这张地图。
将 1D 阵列用于 2D 地图
你会注意到,虽然我们正在创建一个 2D 网格单元,我们只使用一个平面,1D 阵列。这与我们在本章开始时编写的grid函数中使用的技术相同,所以让我们仔细看看它是如何工作的。
您可以通过将网格的宽度(16)乘以高度(16)来计算出 1D 数组应该有多长:
let numberOfCells = this.height * this.width;
这将得到 256 个数组元素:16 行和 16 列。
我们不需要使用 2D 数组的原因是每个单元格对象都有存储其在网格上的位置的x和y属性,但是我们不需要可以从 2D 数组中获得的行和列信息。相反,代码使用这个公式将数组的索引计数器i转换为x(列)和y(行)坐标:
x = i % this.width;
y = Math.floor(i / this.width);
x位置总是索引计数器除以网格宽度的余数:i % this.width。y位置总是索引计数器的值除以网格的宽度,余数被截断:Math.floor(i / this.width)。这是一个方便的神奇公式,放在你的后口袋里!
为什么不用 2D 阵?主要是风格问题。通过使用 1D 数组,我们可以消除内部嵌套的for循环。因为每个数组元素都包含一个对象,所以我们可以将网格位置直接存储在该对象上,而不必使用循环索引计数器来计算它。此外,我们可以用描述地图单元格所需的额外属性来包装cell对象。这使它成为地图信息的有效存储容器。当我们继续建造游戏关卡时,你将会看到这将会如何有帮助。
地形图
现在我们已经有了一个随机方格的网格,我们可以改进它,使它更适合平台游戏环境。To terraform 的意思是修改现有的环境——这就是我们接下来要做的。我决定做四件事来改进这张地图:
- 我想在游戏区域周围添加一个边框。
- 我想找到每一块上面有天空细胞的石头。这些岩石应该变成
"grass"细胞。(你可以在完成的原型中看到这些绿色的方块。)草细胞是玩家将能够在上面跳跃的所有平台。 - 我已经决定在每个草细胞上至少要有两个天空细胞。这将使游戏角色很容易自由跳跃,而不会撞到它的头。
- 原来,正上方的格子是放置玩家和宝箱的理想位置。我想把这些单元格都找出来,推到一个名为
itemLocations的数组里,在游戏刚开始的时候用它们来随机定位玩家和宝箱。
(你可以在图 7-18 中看到所有这些改进,以及查找这些单元格的代码。)

图 7-18 。添加要素以改善地图
为此,我们需要遍历map数组并分析每个单元格。我们需要知道它是什么类型的细胞(“岩石”或“草地”),它在网格上的 x/y 位置,以及它周围是什么类型的细胞。map是一个 1D 阵列,但是单元格代表一个 2D 网格。我们如何将单元格的x和y位置转换成正确的数组索引号?我们可以使用这个简单的助手函数,叫做getIndex:
let getIndex = (x, y) => x + (y * level.widthInTiles);
要使用它,在地图数组的方括号内调用getIndex。使用当前单元格的x和y值来定位要查找的单元格。下面是如何使用它来查找直接位于当前单元格左侧的单元格的数组索引:
cellTotheLeft = map[getIndex(cell.x - 1, cell.y)]
下面是如何找到当前单元格上方两个网格单元格的索引号:
cellTwoAbove = map[getIndex(cell.x, cell.y - 2)]
我们现在有一个简单的方法来导航 1D 阵列内的 2D 网格。
注意如果getIndex试图引用数组中小于 0 或大于数组长度的元素,它将返回undefined。如果您的代码有可能产生未定义的值,比如引用了地图边界之外的单元格,请确保为此添加额外的条件检查。
有了这个技巧,我们可以遍历地图数组中的所有单元,分析它们和它们的相邻单元,并使用这些信息来改进地图。下面是完成这一切的terraformMap函数。阅读注释以了解代码如何工作,并将代码与图 7-18 进行比较以了解它如何改变地图。
function terraformMap() {
//A `getIndex` helper function to convert the cell x and y position to an
//array index number
let getIndex = (x, y) => x + (y * level.widthInTiles);
world.map.forEach((cell, index, map) => {
//Some variables to help find the cells to the left, right, below
//and above the current cell
let cellToTheLeft = world.map[getIndex(cell.x - 1, cell.y)],
cellToTheRight = world.map[getIndex(cell.x + 1, cell.y)],
cellBelow = world.map[getIndex(cell.x, cell.y + 1)],
cellAbove = world.map[getIndex(cell.x, cell.y - 1)],
cellTwoAbove = world.map[getIndex(cell.x, cell.y - 2)];
//If the cell is on the border of the map, change its terrain to "border"
if (cell.x === 0 || cell.y === 0
|| cell.x === level.widthInTiles - 1
|| cell.y === level.heightInTiles - 1) {
cell.terrain = "border";
}
//If the cell isn't on the border, find out if we can
//grow some grass on it. Any rock with a sky cell above
//it should be made into grass. Here's how to figure this out:
else {
//1\. Is the cell a rock?
if (cell.terrain === "rock") {
//2\. Is there sky directly above it?
if (cellAbove && cellAbove.terrain === "sky") {
//3\. Yes there is, so change its name to "grass"
cell.terrain = "grass";
//4\. Make sure there are 2 sky cells above grass cells
//so that it's easy to jump to higher platforms
//without bumping your head. Change any rock cells that are
//2 above the current grass cell to "sky"
if (cellTwoAbove) {
if (cellTwoAbove.terrain === "rock"
|| cellTwoAbove.terrain === "grass") {
cellTwoAbove.terrain = "sky";
}
}
}
}
}
});
//We now have the finished map.
//Next, we're going to loop through the map one more time
//to find all the item location cells and push them into the
//`itemLocations` array. `itemLocations` is a list of cells that
//we'll use later to place the player and treasure on the map
world.map.forEach((cell, index, map) => {
//Is the cell a grass cell?
if (cell.terrain === "grass") {
//Yes, so find the cell directly above it and push it
//into the `itemLocations` array
let cellAbove = world.map[getIndex(cell.x, cell.y - 1)];
world.itemLocations.push(cellAbove);
}
});
}
我们的平台游戏环境现在已经完成,我们有一个名为itemLocations的数组,可以用来放置玩家和宝藏。
注意显然你可以对这张地图做更多的微调,比如确保没有封闭的空间,确保所有的平台都可以到达,找到陷阱和敌人的好地方。继续应用这些相同的原则来为你自己的游戏定制地图。这就像你认为的那样简单。要了解更多关于程序生成的游戏地图,使用细胞自动机对游戏关卡设计做一些研究。
添加游戏物品
将玩家和宝箱添加到游戏中只是从我们在上一步中填充的itemLocations数组中随机选择单元格。然后单元格的item属性被设置为我们希望它包含的任何项目。level对象的addItems方法为我们做到了这一点。
function addItems() {
//The `findStartLocation` helper function returns a random cell
let findStartLocation = () => {
//Randomly choose a start location from the `itemLocations` array
let randomIndex = randomInt(0, world.itemLocations.length - 1);
let location = world.itemLocations[randomIndex];
//Splice the cell from the array so we don't choose the
//same cell for another item
world.itemLocations.splice(randomIndex, 1);
return location;
};
//1\. Add the player
//Find a random cell from the `itemLocations` array
let cell = findStartLocation();
cell.item = "player";
//2\. Add 3 treasure boxes
for (let i = 0; i < 3; i++) {
cell = findStartLocation();
cell.item = "treasure";
}
}
地图现在完成了。最后一步是使用单元格信息来创建我们可以在画布上显示的精灵。
制造精灵
一个名为makeSprites 的函数使用地图数据来创建你在画布上看到的实际精灵。makeSprites函数首先遍历map数组,并使用单元格的属性创建边界、岩石、天空和草地单元格。单元格的 x 和 y 属性只需乘以关卡的cellWidth和cellHeight就可以在画布上的正确位置绘制精灵。边界、岩石和草地单元也被推入到platforms数组中,这样它们就可以用在我们前面看到的平台碰撞代码中。
在这些地形单元制作完成后,代码第二次循环通过map数组来添加玩家和宝藏物品。游戏道具精灵是以地形精灵的一半大小创建的,并位于它们的正上方。
项目精灵将被添加到sprites数组的末尾,这意味着当render函数显示它们时,它们将是最后渲染的精灵。这将使它们在地形精灵的前面重叠。
下面是完成这一切的完整的makeSprites函数:
function makeSprites() {
//Make the terrain
world.map.forEach(cell => {
let mapSprite = rectangle();
mapSprite.x = cell.x * level.tilewidth;
mapSprite.y = cell.y * level.tileheight;
mapSprite.width = level.tilewidth;
mapSprite.height = level.tileheight;
switch (cell.terrain) {
case "rock":
mapSprite.fillStyle = "black";
world.platforms.push(mapSprite);
break;
case "grass":
mapSprite.fillStyle = "green";
world.platforms.push(mapSprite);
break;
case "sky":
mapSprite.fillStyle = "cyan";
break;
case "border":
mapSprite.fillStyle = "blue";
world.platforms.push(mapSprite);
break;
}
});
//Make the game items. (Do this after the terrain so
//that the item sprites display above the terrain sprites)
world.map.forEach(cell => {
//Each game object will be half the size of the cell.
//They should be centered and positioned so that they align
//with the bottom of the cell
if(cell.item !== "") {
let mapSprite = rectangle();
mapSprite.x = cell.x * level.tilewidth + level.tilewidth / 4;
mapSprite.y = cell.y * level.tileheight + level.tilewidth / 2;
mapSprite.width = level.tilewidth / 2;
mapSprite.height = level.tileheight / 2;
switch (cell.item) {
case "player":
mapSprite.fillStyle = "red";
mapSprite.accelerationX = 0;
mapSprite.accelerationY = 0;
mapSprite.frictionX = 1;
mapSprite.frictionY = 1;
mapSprite.gravity = 0.3;
mapSprite.jumpForce = -6.8;
mapSprite.vx = 0;
mapSprite.vy = 0;
mapSprite.isOnGround = true;
world.player = mapSprite;
break;
case "treasure":
mapSprite.fillStyle = "gold";
//Push the treasure into the treasures array
world.treasure.push(mapSprite);
break;
}
}
});
}
这个方法运行后,world对象返回到主程序,游戏开始。
使用图像精灵
我们的平台游戏无非就是大量的数据。代码完全不知道精灵实际上是什么样子。这意味着您可以使用来自map数组的完全相同的数据,而不是制作简单的形状,从 tileset 创建图像精灵。您可以通过启用platforms.html示例文件中的makeImageSprites方法来查看这个例子。游戏以完全相同的方式运行和玩,但是精灵现在是真实的插图,而不是彩色的方块,如图图 7-19 所示。

图 7-19 。使用 tileset 完全自定义游戏的外观
根本不需要改变底层代码,只需使用不同的 tileset 就可以完全改变游戏的外观。看一下下面的makeImageSprites函数的细节,你会发现它是基于我们用纹理贴图集创建图像精灵的所有相同技术,你在这一章已经看过很多例子了。
function makeImageSprites() {
//Make the terrain
world.map.forEach((cell, index, map) => {
let mapSprite,
x = cell.x * level.tilewidth,
y = cell.y * level.tileheight;
switch (cell.terrain) {
case "rock":
mapSprite = sprite(assets["rock.png"]);
mapSprite.setPosition(x, y);
world.platforms.push(mapSprite);
break;
case "grass":
mapSprite = sprite(assets["grass.png"]);
mapSprite.setPosition(x, y);
world.platforms.push(mapSprite);
break;
case "sky":
//Add clouds every 6 cells and only on the top
//80% of the level
let sourceY = 0;
if (index % 6 === 0 && index < map.length * 0.8) {
mapSprite = sprite(assets["cloud.png"]);
} else {
mapSprite = sprite(assets["sky.png"]);
}
mapSprite.setPosition(x, y);
break;
case "border":
mapSprite = rectangle(level.tilewidth, level.tileheight, "black");
mapSprite.setPosition(x, y);
world.platforms.push(mapSprite);
break;
}
});
//Make the game items
world.map.forEach(cell => {
if (cell.item !== "") {
let mapSprite,
x = cell.x * level.tilewidth + level.tilewidth / 4,
y = cell.y * level.tileheight + level.tilewidth / 2,
width = level.tilewidth / 2,
height = level.tileheight / 2;
switch (cell.item) {
case "player":
mapSprite = sprite(assets["cat.png"]);
mapSprite.width = width;
mapSprite.height = height;
mapSprite.setPosition(x, y);
mapSprite.accelerationX = 0;
mapSprite.accelerationY = 0;
mapSprite.frictionX = 1;
mapSprite.frictionY = 1;
mapSprite.gravity = 0.3;
mapSprite.jumpForce = -6.8;
mapSprite.vx = 0;
mapSprite.vy = 0;
mapSprite.isOnGround = true;
world.player = mapSprite;
break;
case "treasure":
mapSprite = sprite(assets["star.png"]);
mapSprite.width = width;
mapSprite.height = height;
mapSprite.setPosition(x, y);
//Push the treasure into the `treasures` array
world.treasure.push(mapSprite);
break;
}
}
});
}
请注意这段代码是如何为每六个天空图块放置一张云的图像的,以及它是如何限制将云放置在地图的顶部 80%的:
if (index % 6 === 0 && index < map.length * 0.8) { //...
使用这种技术作为起点,给你自己的游戏环境增加一些变化。
提示你的游戏好玩吗?确定无疑的方法是使用简单的基本形状和颜色来构建你的游戏原型。事实上,在这个平台游戏示例中,我使用了与 1982 年的 Commodore 64 相同的字体和调色板,这是我学习制作游戏的第一台计算机之一。如果你的游戏用一堆方块和圆圈玩起来不好玩,那么世界上最好的图形也救不了它。
圆形与矩形
你在游戏中需要的最后一个重要的碰撞检查是找出一个圆形是否撞上了一个矩形。你可以使用一个名为hitTestCircleRectangle 的函数来帮你做到这一点。第一个参数是圆形 sprite,第二个参数是矩形 sprite:
let collision = hitTestCircleRectangle(ball, box);
如果它们在接触,返回值(collision)会告诉你圆碰到矩形的位置。它可以有值"topLeft"、"topMiddle"、"topRight"、"leftMiddle"、"rightMiddle"、"bottomLeft"、"bottomMiddle"或"bottomRight"。如果没有碰撞,它将是undefined。运行circleVsRectangle.html文件以获得一个交互式示例。拖动图形使它们接触,文本会告诉你碰撞发生在哪里。图 7-20 显示了您将看到的内容。

图 7-20 。检查圆和矩形之间的碰撞
下面是来自示例文件的游戏循环的代码,它检查冲突并显示结果:
let collision = hitTestCircleRectangle(ball, box);
if (collision) {
message.content = collision;
} else {
message.content = "No collision..."
}
您可以使用一个名为circleRectangleCollision 的配套功能让一个圆从一个正方形的边或角上反弹回来:
circleRectangleCollision(ball, box, true);
(将可选的第三个参数设置为true会使精灵弹开,将第四个参数设置为true会告诉函数使用精灵的全局坐标。)
运行本章源代码中的bricks.html文件,查看一个工作示例。这和本章前面的“钉子”例子是一样的,除了圆形的钉子被换成了长方形的砖块。(图 7-21 )。球从画布顶部落下,在落在地面上之前在砖块网格周围反弹。

图 7-21 。一个球在砖块网格中弹跳
下面是游戏循环中实现这一点的代码:
bricks.children.forEach(brick => {
circleRectangleCollision(ball, brick, true, true);
});
这就是你开始制作一些真正迷人的游戏所需要知道的一切!
万能命中功能
为了让您的生活更加轻松,library/collision文件包含了一个名为hit的通用碰撞函数。它会自动检测碰撞中使用的精灵种类,并为您选择合适的碰撞功能。这意味着你不需要记住本章中的碰撞函数,你只需要记住一个:hit。
最简单的形式是,你可以像这样使用hit:
hit(spriteOne, spriteTwo)
精灵可以是圆形或矩形。如果您希望它们对碰撞做出反应,以便它们不相交,请将第三个参数设置为true。如果想让它们分开,将第四个参数设置为true。将第五个参数设置为true会使函数使用精灵的全局坐标。
hit(spriteOne, spriteTwo, react, bounce, global)
如果要检查点对象与精灵的碰撞,请使用点作为第一个参数,如下所示:
hit({x: 145, y:65}, sprite)
hit函数还可以让你检查一个精灵和一个精灵数组之间的冲突。只需将数组作为第二个参数:
hit(ball, bricks.children, true, true, true);
你会看到hit自动循环数组中的所有精灵,并根据第一个精灵检查它们。这意味着你不必编写自己的for或forEach循环。
hit函数还返回一个collision对象,其返回值与您正在检查的精灵类型相匹配。例如,如果两个精灵都是矩形,您可以找到发生碰撞的一边,如下所示:
let collision = hit(rectangleOne, rectangleTwo, true);
message.text = `collision side: ${collision}`;
如果没有碰撞,collision将一直是undefined。
最后一个特性是,您可以使用可选的回调函数作为第五个参数。这允许您注入一些额外的代码,这些代码应该在冲突发生时运行。这对于检查单个精灵和精灵数组之间的冲突特别有用。如果有碰撞,回调将运行,您可以访问碰撞返回值和碰撞中涉及的 sprite。下面是我们在本章前面看到的平台游戏例子中如何使用这个特性,在玩家和平台之间进行碰撞检查:
let playerVsPlatforms = hit(
player, world.platforms, true, false, false,
(collision, platform) => {
//`collision` tells you the side on player that the collision occurred on.
//`platform` is the sprite from the `world.platforms` array
//that the player is colliding with
}
);
这是一种进行复杂碰撞检查的简洁方法,它提供了大量的信息和底层控制,但使您不必手动遍历数组中的所有精灵。
如果你想知道hit函数是如何工作的,请翻到附录,那里有详细的解释。本质上,它只是分析参数中提供的精灵种类,并将它们发送到正确的碰撞函数。
摘要
恭喜你,你刚刚从碰撞检测训练营毕业!本章涵盖了 2D 动作游戏中你需要知道的所有最重要的碰撞功能:矩形碰撞、圆形碰撞和点碰撞。事实上,你现在已经掌握了使用 HTML5 和 JavaScript 来重现视频游戏历史上大多数经典游戏的所有技能,而且现在很少有 2D 游戏不是你力所能及的。使用本章中的工作原型作为你自己游戏的起点,再加上一点想象力,你会惊讶于你能做什么。
但是您的工具包中缺少一个重要的工具:关键帧动画。在下一章中,你将学习一些先进的动画技术来帮助你制作富有表现力的动画游戏角色和特效。但是在你翻开新的一页之前,为什么不用你在这一章中学到的所有新技能制作一个游戏呢?你做完后我会在第八章见你!
八、丰富多汁
有了目前为止我们在书中建立的所有工具,你实际上可以开始制作真正的游戏了。但是到目前为止缺少的是游戏开发者所说的果汁:让游戏世界感觉有活力的浮华效果和动画。这一章是关于给你的游戏增加活力的三种重要方法:
- 关键帧动画:让你的游戏角色播放一系列预先渲染的动画帧,就像电影胶片一样。
- 粒子效果:使用大量的微小粒子创造爆炸或水流效果。
- 平铺精灵:这是一种快速简单的方法来添加无限滚动背景,尤其是创建视差深度效果。
通过仔细使用这些效果,你甚至可以将一个非常简单的游戏变成一个引人入胜的虚拟世界,让你的玩家无法抗拒。
关键帧动画
在第二章中,你学会了如何通过交互改变小精灵的 x 和 y 位置来移动它们。这是一种叫做脚本动画的动画技术:使用数学公式让事物移动。在这一章你将学习另一种动画技术,叫做关键帧动画 。关键帧动画显示一系列预渲染图像,使精灵看起来像是在执行某个操作。这些可以是改变精灵外观的任何动作,比如改变它的颜色,把它打碎,或者移动它的脚让它行走。关键帧动画是关于当精灵的状态改变时改变它的样子。如果您将脚本动画(更改精灵的位置)与关键帧动画(更改精灵的外观)相结合,您可以开始开发丰富而复杂的精灵交互性。
在本章的第一部分,我们将详细了解如何在精灵上播放和控制一组动画序列。但在此之前,让我们先来看看这个过程的第一步:如何改变一个精灵的状态。
改变状态
图 8-1 显示了一个名为states.png 的 tileset。它包含一个 elf 字符,显示为四种状态:上、左、下和右。每个状态由一个图像帧表示。

图 8-1 。具有四种字符状态的 tileset
想象一下,你正在创建一个游戏,这个精灵角色应该根据你按下的箭头键改变它面对的方向。你怎么能这么做?
第一步是创建一个帧数组:图像中的四个帧各一个。你可以用你在《??》第四章中学到的frames方法来做这件事。
let elfFrames = frames(
assets["img/states.png"], //The tileset image to use
[[0,0], [0,64], [0,128], [0, 192]], //Array of x/y positions of each frame
64, 64 //The width and height of each frame
);
elfFrames现在是一个数组,包含四个帧,匹配 elf 的每个图像状态。(你会在本书的源文件中的library/display中找到frames方法。)
现在使用elfFrames数组制作一个精灵:
elf = sprite(elfFrames);
接下来,定义四个状态属性 : up、left、down和right。它们被包裹在一个叫做states的物体里。给每一个赋予一个与它在数组中的帧的索引号相对应的值。
elf.states = {
up: 0,
left: 1,
down: 2,
right: 3
};
然后只需使用gotoAndStop来显示您想要显示的状态:
elf.gotoAndStop(elf.states.right);
那太容易了吗?嘿,享受简单吧!
这些只是静态的图像状态,但是在大多数游戏中你会想要做一些比这更复杂的事情。用一系列动画序列加载精灵,然后根据精灵在游戏中的表现有选择地播放这些序列,这不是很好吗?如果精灵在行走,播放它的行走动画;如果它在跳跃,播放它的跳跃动画。这是你希望大多数游戏精灵都具备的一个基本特性。让我们构建自己的动画状态播放器来实现这一点。
创建状态播放器
我们将分两个阶段构建我们的状态播放器。在第一阶段,我们将只使用它来显示一个静态图像状态,就像我们在上一节中所做的那样。这只是给你一个它是如何工作的基本概念。在下一节中,我们将修改它,使我们的精灵可以播放连续的帧序列。
在我们创建状态播放器之前,让我们先来看看当我们完成后你将如何使用它。如果你想显示 elf 的left状态,你可以使用一种叫做show的新方法:
elf.show(elf.states.left);
show方法只是根据我们在状态中定义的值调用 sprite 上的gotoAndStop。我们如何设置它?
我们将借助一个名为addStatePlayer的函数向 sprite 添加show方法。它的工作是创建show方法并将其添加到精灵中。它是这样做的:
function addStatePlayer(sprite) {
//The `show` function (to display static states)
function show(frameNumber) {
//Find the new state on the sprite
sprite.gotoAndStop(frameNumber);
}
//Add the `show` method to the sprite
sprite.show = show;
}
您可以看到该函数将 sprite 作为参数,创建了show方法,然后在最后一行中,将show方法添加到 sprite 中。
现在,您可以使用以下语句将状态播放器应用到 sprite:
addStatePlayer(elf);
elf对象现在有了自己的新方法,名为show:
elf.show(anyFrameNumber)
show方法只是gotoAndStop的包装器。这本身不是很有用,但是在本章的后面,我们将使用这个基本的addStatePlayer函数作为构建更复杂的东西的垫脚石。在此之前,让我们来看看如何在游戏中改变精灵的静态图像状态。
运行本章源文件中的statePlayer.html文件,获得一个工作示例。使用箭头键在画布上移动小精灵,如图 8-2 中的所示。

图 8-2 。使用箭头键使精灵移动和改变方向
下面是使这种状态改变策略起作用的代码:
function setup() {
//...Create the sprite...
//Create the keyboard objects
let leftArrow = keyboard(37),
upArrow = keyboard(38),
rightArrow = keyboard(39),
downArrow = keyboard(40);
//Assign key `press` methods
leftArrow.press = () => {
//Display the elf's new state and set its velocity
elf.show(elf.states.left);
elf.vx = -1;
elf.vy = 0;
};
upArrow.press = () => {
elf.show(elf.states.up);
elf.vy = -1;
elf.vx = 0;
};
rightArrow.press = () => {
elf.show(elf.states.right);
elf.vx = 1;
elf.vy = 0;
};
downArrow.press = () => {
elf.show(elf.states.down);
elf.vy = 1;
elf.vx = 0;
};
//Start the game loop
gameLoop();
}
function gameLoop() {
requestAnimationFrame(gameLoop);
//Move the elf
elf.x += elf.vx;
elf.y += elf.vy;
//Render the canvas
render(canvas);
}
能够以这种方式设置精灵状态真的很容易做到,并且在游戏中有广泛的应用。对于许多不需要复杂动画的游戏,像这样的简单状态机可能就是你所需要的。只是gotoAndStop!
但是,如果你想通过移动精灵的胳膊和腿来让它看起来像是在走路,那该怎么办呢?
播放帧
您可以按顺序播放一系列图像帧来创建动画动作,而不是只显示给定状态的一个图像。图 8-3 显示了我们的 elf 角色行走的九帧序列。

图 8-3 。动画序列
拥有可以用来控制动画的名为play和stop的方法不是很好吗?我们来看看如何为游戏搭建这样一个角色动画播放器。
在第六章中,你学习了如何设置每秒帧数和计算帧速率,如下所示:
fps = 12
frameRate = 1000 / this.fps
如果fps是 12,那么frameRate大约是 83 毫秒。
对于我们的角色动画播放器,我们将使用 JavaScript 的setInterval 定时器来控制连续帧显示的速度。使用setInterval的好处是让你使用一个独立于游戏帧速率的帧速率来制作角色动画。这也意味着你可以在同一个游戏中对不同种类的动画使用不同的帧率。您可以使用setInterval以设定的间隔推进动画帧。在这个例子中,我们将使用setInterval每 83 毫秒运行一次名为advanceFrame 的函数。以下是如何:
let timerInterval = setInterval(advanceFrame, frameRate);
advanceFrame的作用是显示动画序列中的下一帧:
function advanceFrame() {
sprite.gotoAndStop(sprite.currentFrame + 1);
}
这将每隔 83 毫秒显示 sprite 的frames数组中的下一幅图像。这就是事情的全部。
这些是关键帧动画的绝对基础。但是在实践中,你需要添加更多的功能来为游戏制作一个完全健壮和灵活的系统。
添加功能
要构建功能全面的动画帧播放器,您需要解决以下问题:
- 如何让动画在到达最后一帧时停止?或者说,你怎么能让它循环回到起点?
- 如何播放和循环特定范围的帧?例如,如果您的角色的完整动画是 36 帧,但您只想在 10 帧和 17 帧之间循环,如何才能做到这一点?
- 如果你想停止和重新开始一个动画,你必须清除当前的时间间隔,并重新设置动画开始。怎么做?
你的精灵也需要属性来帮助你控制他们的动画。在第四章中,我们给每个精灵的父类添加了一些属性来帮助我们完成这些任务:
this.frames = [];
this.loop = true;
this._currentFrame = 0;
get currentFrame() {
return this._currentFrame;
}
当时我告诉你,“现在不要担心这些财产;你以后会发现如何使用它们。”你一直很有耐心的等待,但是“以后”变成了“现在”!因此,让我们找出如何使所有这些工作。
循环播放动画
要制作动画循环,首先需要知道它有多少帧,以及当前正在播放哪一帧。使用一些变量来帮助您跟踪这一点。这些新变量将帮助您开始:
startFrame, endFrame, numberOfFrames, frameCounter
如果你要在一个帧范围内循环,你需要知道那些帧号是什么。例如,如果您想遍历 1 和 8 之间的所有帧,您可以使用这些startFrame和endFrame值:
startFrame = 1;
endFrame = 8;
然后使用这些值计算总帧数:
numberOfFrames = endFrame - startFrame;
numberOfFrames的值将是 7。因为我们从 0 开始编号帧,所以 7 号帧实际上将是序列中的第八帧。
图 8-4 显示了帧序列如何工作的例子。我们精灵动画的九帧从 0 到 8 编号。第一帧,0,只是显示了小精灵静止不动时的样子。第 1 帧到第 8 帧显示了小精灵行走时的样子。如果我们想让小精灵看起来像是在行走,我们必须排除第 0 帧,并且在连续的循环中只播放第 1 帧到第 8 帧。

图 8-4 。帧的子序列
很快您就会看到,我们将使用这些startFrame和endFrame值来播放这个循环的子帧序列。
我们还需要计算动画播放时已经过去的帧数;所以一个frameCounter变量可以帮助跟踪这个:
frameCounter = 0;
我们是否希望动画循环播放?我们可以使用已经内置到 sprites 中的布尔属性loop来确定这一点(它的默认值是true)。
这是我们新的advanceFrame函数,它实现了循环特性。如果loop是true,将从startFrame重新开始动画。如果loop是false,它会停在最后一帧。
function advanceFrame() {
//Advance the frame if `frameCounter` is less than the total frames
if (frameCounter < numberOfFrames) {
//Advance the frame
sprite.gotoAndStop(sprite.currentFrame + 1);
//Update the frame counter
frameCounter += 1;
//If we've reached the last frame and `loop`
//is `true`, then start from the first frame again
} else {
if (sprite.loop) {
sprite.gotoAndStop(startFrame);
frameCounter = 1;
}
}
}
现在我们有了一个简单的动画循环系统。
重置动画
在一个真正的游戏开发项目中,你不会只是运行一次动画,然后就忘记它;更有可能的是,您需要多次启动、停止和重启它。你可能还会有许多其他的动画可以同时播放。因此,能够跟踪动画当前是否正在播放是一个好主意,这样您就可以微调它的开始和停止条件。在的第四章中,我们在DisplayObject类中创建了一个名为playing的属性来帮助管理它。
this.playing = false;
现在,在您开始一个新的动画之前,您可以检查这个变量以确保动画还没有开始播放。如果不是,启动它,然后将playing设置为true:
if(!sprite.playing) {
timerInterval = setInterval(advanceFrame, frameRate);
sprite.playing = true;
}
如果您已经播放了一次动画,然后需要重新启动它,您必须将其重置回初始状态。您还需要清除timerInterval以便创建一个新的定时器。这里有一个reset函数完成所有这些事情。
function reset() {
if (timerInterval !== undefined && sprite.playing === true) {
sprite.playing = false;
frameCounter = 0;
startFrame = 0;
endFrame = 0;
numberOfFrames = 0;
clearInterval(timerInterval);
}
}
现在,您可以从头开始播放动画了。
这些都是你需要知道的为游戏建立一个健壮的精灵动画的重要概念。但是我们怎样才能把这一切付诸实践呢?
改进addStatePlayer功能
在本章的前面,我们构建了一个有趣的小函数,叫做addStatePlayer,当我们按下箭头键时,它可以改变精灵的图像状态。我们将通过给它一些新方法来改进它。
如果你想播放精灵的帧数组中的所有帧,使用play方法。
elf.play();
如果精灵的loop属性为true,这些帧将从头到尾播放并循环播放。如果你想让动画停止,使用stop方法:
elf.stop();
如果您只想播放特定范围的帧,请使用名为playSequence的方法。例如,如果您想播放第 10 帧到第 17 帧之间的所有帧,可以使用以下语句:
elf.playSequence([10, 17]);
如果 sprite 的loop属性为true,该序列将循环。
您可以通过以下方式设定动画的每秒帧数:
elf.fps = 12;
这是一个完整的新的addStatePlayer函数,带有解释每个部分如何工作的注释。(你会在library/display文件夹中找到工作代码。)本质上,所有这些新代码都与我们刚刚看到的基本动画代码相同。
function addStatePlayer(sprite) {
let frameCounter = 0,
numberOfFrames = 0,
startFrame = 0,
endFrame = 0,
timerInterval = undefined;
//The `show` function (to display static states)
function show(frameNumber) {
//Reset any possible previous animations
reset();
//Find the new state on the sprite
sprite.gotoAndStop(frameNumber);
}
//The `play` function plays all the sprite's frames
function play() {
playSequence([0, sprite.frames.length - 1]);
}
//The `stop` function stops the animation at the current frame
function stop() {
reset();
sprite.gotoAndStop(sprite.currentFrame);
}
//The `playSequence` function, to play a sequence of frames
function playSequence(sequenceArray) {
//Reset any possible previous animations
reset();
//Figure out how many frames there are in the range
startFrame = sequenceArray[0];
endFrame = sequenceArray[1];
numberOfFrames = endFrame - startFrame;
//Compensate for two edge cases:
//1\. If the `startFrame` happens to be `0`
if (startFrame === 0) {
numberOfFrames += 1;
frameCounter += 1;
}
//2\. If only a two-frame sequence was provided
if(numberOfFrames === 1){
numberOfFrames = 2;
frameCounter += 1;
};
//Calculate the frame rate. Set the default fps to 12
if (!sprite.fps) sprite.fps = 12;
let frameRate = 1000 / sprite.fps;
//Set the sprite to the starting frame
sprite.gotoAndStop(startFrame);
//If the state isn't already `playing`, start it
if(!sprite.playing) {
timerInterval = setInterval(advanceFrame.bind(this), frameRate);
sprite.playing = true;
}
}
//`advanceFrame` is called by `setInterval` to display the next frame
//in the sequence based on the `frameRate`. When the frame sequence
//reaches the end, it will either stop or loop
function advanceFrame() {
//Advance the frame if `frameCounter` is less than
//the state's total frames
if (frameCounter < numberOfFrames) {
//Advance the frame
sprite.gotoAndStop(sprite.currentFrame + 1);
//Update the frame counter
frameCounter += 1;
//If we've reached the last frame and `loop`
//is `true`, then start from the first frame again
} else {
if (sprite.loop) {
sprite.gotoAndStop(startFrame);
frameCounter = 1;
}
}
}
function reset() {
//Reset `sprite.playing` to `false`, set the `frameCounter` to 0,
//and clear the `timerInterval`
if (timerInterval !== undefined && sprite.playing === true) {
sprite.playing = false;
frameCounter = 0;
startFrame = 0;
endFrame = 0;
numberOfFrames = 0;
clearInterval(timerInterval);
}
}
//Add the `show`, `play`, `stop`, and `playSequence` methods to the sprite
sprite.show = show;
sprite.play = play;
sprite.stop = stop;
sprite.playSequence = playSequence;
}
我们还应该做一件事。这些动画方法非常有用,如果将它们自动添加到任何有多个图像帧的 sprite 中,会很有帮助。为此,我们需要修改库/显示模块中的子画面函数,该函数创建并返回每个子画面。让它为任何在其frames数组中有多个元素的 sprite 调用这个新的addStatePlayer函数:
export function sprite(source, x, y) {
let sprite = new Sprite(source, x, y);
if (sprite.frames.length > 0) addStatePlayer(sprite);
stage.addChild(sprite);
return sprite;
}
太好了,我们都准备好了!我们如何在一个实际的游戏项目中使用我们刚刚探索的所有技术?
打造行走精灵
运行animation.html文件,获得一个使用这些新技术制作行走精灵的交互式示例。使用箭头键让小精灵在森林景观中漫步。四个不同的行走周期动画匹配小精灵可以行走的四个方向。当释放按键时,精灵停止并面向它移动的方向。图 8-5 说明了你将会看到的东西。

图 8-5 。动画行走精灵
捕捉帧
elf 的动画基于包含所有帧的单一 tileset 图像,如图 8-6 所示。

图 8-6 。tileset 图像包含所有动画帧
在动画精灵之前,您需要一个包含所有这些帧的数组作为单独的图像。您知道您可以使用frames函数将一组帧位置值转换成一组图像。但是这个 tileset 中有 36 帧,跨越四行,所以您肯定不想手工输入这些位置值。让我们使用一个名为filmstrip 的新自定义函数,它为我们计算出每一帧的 x / y 位置,并返回所有动画帧:
export function filmstrip(image, frameWidth, frameHeight, spacing = 0){
//An array to store the x and y positions of each frame
let positions = [];
//Find out how many columns and rows there are in the image
let columns = image.width / frameWidth,
rows = image.height / frameHeight;
//Find the total number of frames
let numberOfFrames = columns * rows;
for(let i = 0; i < numberOfFrames; i++) {
//Find the correct row and column for each frame
//and figure out its x and y position
let x = (i % columns) * frameWidth,
y = Math.floor(i / columns) * frameHeight;
//Compensate for any optional spacing (padding) around the frames if
//there is any. This bit of code accumulates the spacing offsets from the
//left side of the tileset and adds them to the current tile's position
if (spacing && spacing > 0) {
x += spacing + (spacing * i % columns);
y += spacing + (spacing * Math.floor(i / columns));
}
//Add the x and y value of each frame to the `positions` array
positions.push([x, y]);
}
//Create and return the animation frames using the `frames` method
return frames(image, positions, frameWidth, frameHeight);
};
(您将在library/display文件中找到filmstrip函数。)
你现在可以使用这个filmstrip函数来创建一个精灵的frames数组。提供要使用的图像、每个框架的宽度和高度以及框架之间的任何可选间距作为参数:
let elfFrames = filmstrip(assets["img/walkcycle.png"], 64, 64);
然后使用elfFrames初始化精灵:
elf = sprite(elfFrames);
精灵现在已经加载了 36 帧,可以开始制作动画了。
定义 Elf 的状态
精灵总共有八种状态:四种站立状态和四种行走状态。图 8-7 显示了具有这八种状态的 tileset。插图中的黑线定义了各州的边界。

图 8-7 。tileset 包含 sprite 的八种状态
up、left、down和right状态是静态的,这意味着它们不包含任何动画帧。我们将使用show方法显示它们,就像我们在本章开始时所做的那样。walkUp、walkLeft、walkDown和walkRight状态是动画,我们将使用playSequence来显示它们。walkLeft和walkRight动画的第一帧也恰好是左右静态,这就是为什么在图 8-7 中有虚线将它们分开。
这里是精灵的状态,设置这一切:
elf.states = {
up: 0,
left: 9,
down: 18,
right: 27,
walkUp: [1, 8],
walkLeft: [10, 17],
walkDown: [19, 26],
walkRight: [28, 35]
};
接下来,设置 elf 的帧速率:
elf.fps = 12;
现在,您只需要根据哪些键是向上或向下的来判断要显示哪个状态。经过一点试验,您可能会得出类似如下的一些代码:
leftArrow.press = function() {
//Play the elf's `walkLeft` animation sequence
elf.playSequence(elf.states.walkLeft);
elf.vx = -1;
elf.vy = 0;
};
leftArrow.release = function() {
if (!rightArrow.isDown && elf.vy === 0) {
//Show the elf's `left` state
elf.show(elf.states.left);
elf.vx = 0;
}
};
其他三个键rightArrow、upArrow和downArrow都遵循相同的格式。
这就是关键帧动画的全部知识吗?差不多吧,是的!我们创建的状态播放器可以在各种不同的游戏中使用,您可以根据需要对其进行定制。将这些技术与你在第六章中学到的脚本动画结合起来,你将可以用无穷无尽的丰富而复杂的精灵来填充你的游戏。
提示你怎么能设计出像我们行走的精灵这样复杂的角色动画呢?你显然需要一些艺术能力和图形设计技能,但是有很多工具可以帮助你。这里有一些你可以尝试的软件:ShoeBox、Spine、Spriter、DragonBones、Animo Sprites、Piskel 和 Flash Professional(如果你使用的是 Flash Professional,将动画导出为 sprite 工作表)。
接下来,让我们来看看如何给你的游戏增加一点魔力。
粒子效果
你如何创造像火、烟、魔法和爆炸这样的效果?你制造了许多小精灵;几十个,几百个或者几千个。然后对这些精灵应用一些物理或重力约束,这样它们的行为就像你试图模拟的元素一样。你还需要给他们一些规则,关于他们应该如何出现和消失,以及他们应该形成什么样的模式。这些小精灵被称为粒子。你可以用它们来制作各种游戏特效。
只需几十行代码,你就可以编写一个通用的粒子效果引擎,这是大多数 2D 动作游戏所需要的。要查看运行中的粒子引擎,运行particleEffect.html 文件,如图图 8-8 所示。点击指针,一个小的星星爆炸在画布上爆发,从指针的位置放射出来。恒星被引力拉下,每一颗都有不同的速度、旋转、褪色和缩放率。

图 8-8 。粒子效果
这是由名为particleEffect的自定义函数创建的,您可以在library/display文件夹中找到它。下面是它的使用方法,包括例子中用来产生爆炸的所有参数。
particleEffect(
pointer.x, //The particle's starting x position
pointer.y, //The particle's starting y position
() => sprite(assets["img/star.png"]), //Particle function
20, //Number of particles
0.1, //Gravity
true, //Random spacing
0, 6.28, //Min/max angle
12, 24, //Min/max size
1, 2, //Min/max speed
0.005, 0.01, //Min/max scale speed
0.005, 0.01, //Min/max alpha speed
0.05, 0.1 //Min/max rotation speed
);
您可以看到,大多数参数描述了用于更改精灵速度、旋转、缩放或 alpha 的最小值和最大值之间的范围。您还可以指定应该创建的粒子数量,并添加可选的重力。
通过自定义第三个参数,你可以使用任何精灵来制作粒子。只需提供一个函数,返回你想为每个粒子使用的精灵类型:
() => sprite(assets["img/star.png"]),
如果你提供一个有多个帧的精灵,particleEffect函数会自动为每个粒子选择一个随机帧。
当粒子从原点向外辐射时,最小和最大角度值对于定义粒子的圆形扩散非常重要。对于完全圆形的爆炸效果,使用最小角度 0,最大角度 6.28。
0, 6.28
(这些值是弧度;等效度数为 0 度和 360 度。)0 从 3 点钟位置开始,直接指向右边。3.14 是 9 点的位置,6.28 带你绕回 0 再一次。
如果要将粒子范围限制在一个较窄的角度,只需提供描述该范围的最小值和最大值。这里有一些值,你可以用它们来限制披萨饼的角度,使饼皮指向左边。
2.4, 3.6
你可以像这样使用一个受约束的角度范围来创建一个粒子流,就像那些用来创建喷泉或火箭发动机火焰的粒子流。(在本章末尾的示例游戏中,您将会看到具体的操作方法。)随机间距值(第六个参数)决定了粒子在此范围内是应该均匀(false)还是随机(true)分布。
通过仔细选择粒子的精灵并微调每个参数,您可以使用这个通用的particl eEffect函数来模拟从液体到火焰的一切。让我们来看看particleEffect功能到底是如何工作的,以及如何在游戏中使用它。
构建particleEffect函数
游戏中的所有粒子都需要在每一帧更新它们的属性。在你开始制作粒子之前,你需要创建一个单独的particles数组来存储它们。
export let particles = [];
正如你将看到的,我们将通过在每一帧上循环这个数组来更新所有的粒子,这与我们在第六章中更新按钮使用的策略相同。
particleEffect函数获取你指定的所有参数,并使用它们来创建每个粒子。它为每个粒子计算出一个唯一的角度,并使用该角度值来指定粒子的速度以及所有其他属性。如果您提供的 sprite 函数返回一个具有多个图像帧的 sprite,则会为每个粒子选择一个新的随机帧。每个粒子也是用一个update方法创建的,这个方法描述了粒子的属性应该如何改变。这个update方法必须由游戏循环在每个粒子上调用,以使粒子移动、渐变、旋转和缩放。当该函数创建完每个粒子后,它将该粒子推入到particles数组中。
export function particleEffect(
x = 0,
y = 0,
spriteFunction = () => circle(10, "red"),
numberOfParticles = 10,
gravity = 0,
randomSpacing = true,
minAngle = 0, maxAngle = 6.28,
minSize = 4, maxSize = 16,
minSpeed = 0.1, maxSpeed = 1,
minScaleSpeed = 0.01, maxScaleSpeed = 0.05,
minAlphaSpeed = 0.02, maxAlphaSpeed = 0.02,
minRotationSpeed = 0.01, maxRotationSpeed = 0.03
) {
//`randomFloat` and `randomInt` helper functions
let randomFloat = (min, max) => min + Math.random() * (max - min),
randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
//An array to store the angles
let angles = [];
//A variable to store the current particle's angle
let angle;
//Figure out by how many radians each particle should be separated
let spacing = (maxAngle - minAngle) / (numberOfParticles - 1);
//Create an angle value for each particle and push that
//value into the `angles` array
for(let i = 0; i < numberOfParticles; i++) {
//If `randomSpacing` is `true`, give the particle any angle
//value between `minAngle` and `maxAngle`
if (randomSpacing) {
angle = randomFloat(minAngle, maxAngle);
angles.push(angle);
}
//If `randomSpacing` is `false`, space each particle evenly,
//starting with the `minAngle` and ending with the `maxAngle`
else {
if (angle === undefined) angle = minAngle;
angles.push(angle);
angle += spacing;
}
}
//Make a particle for each angle
angles.forEach(angle => makeParticle(angle));
//Make the particle
function makeParticle(angle) {
//Create the particle using the supplied sprite function
let particle = spriteFunction();
//Display a random frame if the particle has more than 1 frame
if (particle.frames.length > 0) {
particle.gotoAndStop(randomInt(0, particle.frames.length - 1));
}
//Set the x and y position
particle.x = x - particle.halfWidth;
particle.y = y - particle.halfHeight;
//Set a random width and height
let size = randomInt(minSize, maxSize);
particle.width = size;
particle.height = size;
//Set a random speed to change the scale, alpha and rotation
particle.scaleSpeed = randomFloat(minScaleSpeed, maxScaleSpeed);
particle.alphaSpeed = randomFloat(minAlphaSpeed, maxAlphaSpeed);
particle.rotationSpeed = randomFloat(minRotationSpeed, maxRotationSpeed);
//Set a random velocity at which the particle should move
let speed = randomFloat(minSpeed, maxSpeed);
particle.vx = speed * Math.cos(angle);
particle.vy = speed * Math.sin(angle);
//The particle's `update` method is called on each frame of the
//game loop
particle.update = () => {
//Add gravity
particle.vy += gravity;
//Move the particle
particle.x += particle.vx;
particle.y += particle.vy;
//Change the particle's `scale`
if (particle.scaleX - particle.scaleSpeed > 0) {
particle.scaleX -= particle.scaleSpeed;
}
if (particle.scaleY - particle.scaleSpeed > 0) {
particle.scaleY -= particle.scaleSpeed;
}
//Change the particle's rotation
particle.rotation += particle.rotationSpeed;
//Change the particle's `alpha`
particle.alpha -= particle.alphaSpeed;
//Remove the particle if its `alpha` reaches zero
if (particle.alpha <= 0) {
remove(particle);
particles.splice(particles.indexOf(particle), 1);
}
};
//Push the particle into the `particles` array.
//The `particles` array needs to be updated by the game loop each frame
particles.push(particle);
}
}
在particleEffect中需要注意的一个重要细节是,如果粒子的alpha值达到零,update方法会将粒子拼接到particles数组之外。它使用以下代码来实现这一点:
if (particle.alpha <= 0) {
remove(particle);
particles.splice(particles.indexOf(particle), 1);
}
(你在第四章的中学习了如何使用remove函数从父精灵中移除任何精灵。)任何时候你写这样的代码,其中一个对象负责自己的删除,你必须非常小心!这无疑是方便的,但总是要停下来问:是否有其他的依赖项需要被通知这个对象已经被移除了?如果有,而您忘记了它们,您可能正在为一些令人紧张的调试会议做准备。
如果物体在一个循环的上下文中把自己从一个数组中拼接出来,这是特别正确的,这就是我们的粒子将要做的。为了安全地做到这一点,在不将循环索引计数器减 1 的情况下,您需要反向循环数组中的所有元素。接下来您将学习如何做到这一点。
在游戏中使用particleEffect功能
要使用这个particleEffect函数,首先将它和particles数组导入到您的游戏程序中:
import {particles, particleEffect} from "../library/display";
然后遍历每一帧上的所有粒子,并为每个粒子调用update方法。您需要反向循环遍历粒子(从数组中的最后一个元素开始),这样,如果其中一个元素被拼接出来,就不会影响循环索引计数器。通过将计数器变量(i)初始化为数组的length,然后在每次迭代中递减它,可以使for循环反向运行。代码如下:
function gameLoop() {
requestAnimationFrame(gameLoop);
if (particles.length > 0) {
//Loop through the particles in reverse
for(let i = particles.length - 1; i >= 0; i--) {
let particle = particles[i];
particle.update();
}
}
render(canvas);
}
当粒子的alpha达到零时,粒子的update方法现在可以安全地移除粒子。
要启动particleEffect,只要您想让效果发生,就用任何自定义参数调用它。在本例中,这发生在调用指针的press方法时:
pointer.press = () => {
particleEffect(
//Assign the particle’s arguments...
);
};
当然,你可以在游戏中的任何时候调用particleEffect。在本章的最后你会看到更多的例子。
particleEffect功能对于创造单次粒子爆发非常有用。但是,如果你想在一个连续的流中产生粒子,就像你想模拟从水龙头流出的水滴或火箭发动机的火焰一样,那该怎么办呢?为此,你需要一个粒子发射器的帮助。
添加粒子发射器
粒子发射器只是一个简单的计时器,它以固定的时间间隔创建粒子。这意味着发射器不是只调用一次particleEffect函数,而是定期调用它。在下一节中,我们将构建一个emitter函数,你可以用它在任何你需要的时间间隔创建一个恒定的粒子流。以下是您可以使用它的方式:
let particleStream = emitter(
100, //The interval
() => particleEffect( //The `particleEffect` function
//Assign particle parameters...
)
);
emitter函数只是包装了我们在上一节中创建的particleEffect函数。它的第一个参数是一个以毫秒为单位的数字,它决定了粒子创建的频率。第二个参数是particleEffect函数,您可以随意定制。
发射器函数返回一个带有play和stop方法的对象,您可以使用它们来控制粒子流。你可以像我们在本章开始时创建的play和stop方法一样使用它们来控制精灵的动画。
particleStream.play();
particleStream.stop();
发射器对象还有一个playing属性,根据发射器的当前状态,该属性可以是true或false。下面是创建发射器对象并向其添加方法和属性的完整函数。(你会在library/display文件夹中找到这个工作代码。)
export function emitter(interval, particleFunction) {
let emitter = {},
timerInterval = undefined;
emitter.playing = false;
function play() {
if (!emitter.playing) {
particleFunction();
timerInterval = setInterval(emitParticle.bind(this), interval);
emitter.playing = true;
}
}
function stop() {
if (emitter.playing) {
clearInterval(timerInterval);
emitter.playing = false;
}
}
function emitParticle() {
particleFunction();
}
emitter.play = play;
emitter.stop = stop;
return emitter;
}
运行particleEmitter.html文件来查看这段代码的运行情况,如图 8-9 中的所示。按住鼠标左键以产生连续的粒子流。当你松开按钮时,水流将停止。

图 8-9 。粒子发射器产生连续的粒子流
下面是来自setup函数的代码,它创建了指针和粒子发射器。当指针被按下时,发射器的play方法被调用,当指针被释放时,发射器的stop方法被调用。
pointer = makePointer(canvas);
let particleStream = emitter(
100, //The timer interval
() => particleEffect( //The function
pointer.x, pointer.y, //x and y position
() => sprite(assets["img/star.png"]), //Particle sprite
10, //Number of particles
0.1, //Gravity
false, //Random spacing
3.14, 6.28, //Min/max angle
16, 32, //Min/max size
2, 5 //Min/max speed
)
);
pointer.press = () => {
particleStream.play();
};
pointer.release = () => {
particleStream.stop();
};
通过以这种方式一起使用particleEffect和emitter函数,您将能够创建游戏所需的大部分粒子爆炸和流效果。
平铺精灵
你将在本章学习的最后一个特效实际上是一种新的精灵:一种平铺精灵。它是一种特殊的矩形,具有重复的平铺背景图像图案。平铺子画面有两个新属性,tileX和tileY,可以控制平铺背景的位置。平铺背景无缝包裹,这样如果你在游戏循环中改变tileX和tileY的值,你就可以创建一个无限滚动的背景效果。
运行tilingSprite.html 文件来查看一个平铺精灵的例子,如图 8-10 中的所示。这是一个简单的矩形,将一个tile.png 图像设置为其重复背景。背景图案从左上向右下连续滚动。

图 8-10 。平铺子画面具有由单个平铺构成的连续重复的背景图案
平铺精灵是用一个叫做tilingSprite的新函数制作的。你可以把它想象成一个矩形精灵,一个图像被指定为它的填充:
box = tilingSprite(128, 128, assets["img/tile.png"]);
拼贴图像可以是图像文件或纹理贴图集帧。因为平铺精灵本质上只是一个普通的矩形精灵,所以你可以像在游戏中使用其他矩形精灵一样使用它。一个重要的区别是它有tileX和tileY属性,可以让你重新定位重复背景图案的原点。通过改变游戏循环中tileX和tileY的值,可以创造出无限的滚动效果:
box.tileY += 1;
box.tileX += 1;
平铺精灵是一个很好的例子,展示了如何在玩具箱中使用许多不同的技巧来创建一个复杂的复合精灵。那么它是如何工作的呢?
构建平铺精灵
平铺子画面本质上是一个矩形,它遮盖了由平铺图像构成的子画面网格。网格比填充矩形所需的最大拼贴数量大一行一列。这意味着在矩形的可视区域之外总是有一行和一列。如果背景图案向上、向下、向左或向右移动,隐藏行或列中的子画面将移动到网格的另一侧进行补偿。这产生了无缝滚动模式的错觉。但你真正做的是移动内部瓷砖精灵。图 8-11 显示了隐藏的行和列是如何被屏蔽和重新定位以匹配移位的图形的。

图 8-11 。当图案移动时,来自额外隐藏的行和列的精灵被重新定位,以创建无缝、无限滚动背景的幻觉
关于平铺精灵,要认识到的最重要的事情是,它只是一个矩形,掩盖了子精灵的网格。你可能记得在《??》第四章中,矩形精灵有一个可选的mask属性,默认为false。如果您将mask设置为 true,则在矩形的render方法中启用以下代码行:
if (this.mask && this.mask === true) ctx.clip();
这将导致任何矩形的子精灵被矩形掩盖。(您可以用同样的方式在圆形精灵中启用遮罩。)
在library/display文件夹中,你会发现tilingSprite函数,它设置所有这些并返回被屏蔽的网格。这其实并不复杂,但确实有很多工作要做。为了帮助理解它在做什么,您可以将其所有工作分解为以下几个主要步骤:
- 确定所提供的图块图像是来自图像文件还是纹理贴图集帧,然后捕获图像的宽度和高度值。
- 计算出有多少瓷砖可以放入矩形的尺寸中。
- 制作一个比精灵尺寸大一行一列的
grid对象。 - 创建一个矩形精灵,并添加网格作为其子元素。
- 将矩形的
mask属性设置为true。 - 给矩形添加
tileX和tileY属性。这些属性的设置器根据提供的偏移值按比例移动网格块的位置。 - 将矩形精灵返回到主程序。
下面是完成所有这些的tilingSprite函数。注释详细解释了每一位代码是如何工作的。
export function tilingSprite(width, height, source, x = 0, y = 0) {
//Figure out the tile's width and height
let tileWidth, tileHeight;
//If the source is a texture atlas frame, use its
//`frame.w` and `frame.h` properties
if(source.frame) {
tileWidth = source.frame.w;
tileHeight = source.frame.h;
}
//If it's an image, use the image's
//`width` and `height` properties
else {
tileWidth = source.width;
tileHeight = source.height;
}
//Figure out the rows and columns.
//The number of rows and columns should always be
//one greater than the total number of tiles
//that can fit into the rectangle. This give us one
//additional row and column that we can reposition
//to create the infinite scroll effect
let columns, rows;
//1\. Columns
//If the width of the rectangle is greater than the width of the tile,
//calculate the number of tile columns
if (width >= tileWidth) {
columns = Math.round(width / tileWidth) + 1;
}
//If the rectangle's width is less than the width of the
//tile, set the columns to 2, which is the minimum
else {
columns = 2;
}
//2\. Rows
//Calculate the tile rows in the same way
if (height >= tileHeight) {
rows = Math.round(height / tileHeight) + 1;
} else {
rows = 2;
}
//Create a grid of sprites that's just one sprite larger
//than the `totalWidth` and `totalHeight`
let tileGrid = grid(
columns, rows, tileWidth, tileHeight, false, 0, 0,
() => {
//Make a sprite from the supplied `source`
let tile = sprite(source);
return tile;
}
);
//Declare the grid's private properties that we'll use to
//help scroll the tiling background
tileGrid._tileX = 0;
tileGrid._tileY = 0;
//Create an empty rectangle sprite without a fill or stroke color.
//Set it to the supplied `width` and `height`
let container = rectangle(width, height, "none", "none");
container.x = x;
container.y = y;
//Set the rectangle's `mask` property to `true`. This switches on `ctx.clip()`
//In the rectangle sprite's `render` method
container.mask = true;
//Add the tile grid to the rectangle container
container.addChild(tileGrid);
//Define the `tileX` and `tileY` properties on the parent container
//so that you can scroll the tiling background
Object.defineProperties(container, {
tileX: {
get() {
return tileGrid._tileX;
},
set(value) {
//Loop through all of the grid's child sprites
tileGrid.children.forEach(child => {
//Figure out the difference between the new position
//and the previous position
let difference = value - tileGrid._tileX;
//Offset the child sprite by the difference
child.x += difference;
//If the x position of the sprite exceeds the total width
//of the visible columns, reposition it to just in front of the
//left edge of the container. This creates the wrapping
//effect
if (child.x > (columns - 1) * tileWidth) {
child.x = 0 - tileWidth + difference;
}
//Use the same procedure to wrap sprites that
//exceed the left boundary
if (child.x < 0 - tileWidth - difference) {
child.x = (columns - 1) * tileWidth;
}
});
//Set the private `_tileX` property to the new value
tileGrid._tileX = value;
},
enumerable: true, configurable: true
},
tileY: {
get() {
return tileGrid._tileY;
},
//Follow the same format to wrap sprites on the y axis
set(value) {
tileGrid.children.forEach(child => {
let difference = value - tileGrid._tileY;
child.y += difference;
if (child.y > (rows - 1) * tileHeight) child.y = 0 - tileHeight + difference;
if (child.y < 0 - tileHeight - difference) child.y = (rows - 1) * tileHeight;
});
tileGrid._tileY = value;
},
enumerable: true, configurable: true
}
});
//Return the rectangle container
return container;
}
最常见的视频游戏需求之一是无限滚动的背景,而平铺精灵的设计就是为了让您轻松实现这一点。接下来让我们来看看如何在一个游戏中使用它。
案例研究:Flappy 仙女
关键帧动画、粒子效果和平铺精灵有助于为游戏增添全新的趣味性和沉浸感。随着你的游戏设计技能和自信的增长,你可能会发现你制作的大多数游戏会用到至少一种或者所有这些效果。在本章的最后一节,我们将详细了解一款名为 Flappy Fairy 的游戏原型——向视频游戏史上最臭名昭著的游戏之一致敬。它使用了所有三种效果,并将为您将它们集成到您自己的游戏中提供一个良好的起点。
运行flappyFairy.html 文件玩游戏。点击屏幕让仙女飞起来,帮助她通过 15 根柱子的缝隙到达终点,如图图 8-12 所示。当她在迷宫中飞行时,一串五彩缤纷的仙尘跟随着她。如果她撞上其中一个绿色方块,她就会在一阵灰尘中爆炸。但是,如果她设法通过所有 15 根柱子之间越来越窄的缝隙,她会到达一个巨大的浮动“完成”标志。

图 8-12 。帮助 Flappy 仙女飞行通过迷宫的柱子到达终点
创建滚动背景
Flappy Fairy 是一款使用视差效果的侧滚游戏。视差是一种浅 3D 效果,通过使背景以比前景更慢的速度滚动来创造深度的幻觉。这使得背景看起来更远。
为了制作天空背景,我从一些云的无缝 512 × 512 图像开始。是游戏纹理图谱中的一帧,如图图 8-13 所示。

图 8-13 。纹理图谱中的天空帧图像
在setup函数中,我使用“sky.png”帧创建了一个名为sky的平铺精灵。
sky = tilingSprite(canvas.width, canvas.height, assets["sky.png"]);
然后,游戏循环将tileX位置每帧向左移动一点。
sky.tileX -= 1;
这就是它的全部——无限滚动!
创建支柱
游戏中有十五根柱子。每隔五根柱子,顶部和底部之间的间隙就变得更窄。前五根柱子的间距为四块,后五根柱子的间距为三块,最后五根柱子的间距为两块。随着 Flappy Fairy 飞得更远,这使得游戏越来越难。对于每个柱子来说,缺口的确切位置是随机的,每次玩游戏时都不一样。每个柱子的间距为 384 像素,但是图 8-14 显示了如果它们紧挨着的话会是什么样子。

图 8-14 。每根柱子顶部和底部之间的间隙逐渐变窄
你可以看到差距是如何从左边的四个空格逐渐缩小到右边的两个。
组成柱子的所有积木都在一个叫做blocks的group里。
blocks = group();
嵌套的for循环创建每个块,并将其添加到blocks容器中。外环运行 15 次;一次创建一个支柱。内循环运行八次;柱中的每个块一次。只有当块没有占据为间隙随机选择的范围时,才添加块。外环每运行五次,间隙的大小就缩小一。
//What should the initial size of the gap be between the pillars?
let gapSize = 4;
//How many pillars?
let numberOfPillars = 15;
for (let i = 0; i < numberOfPillars; i++) {
//Randomly place the gap somewhere inside the pillar
let startGapNumber = randomInt(0, 8 - gapSize);
//Reduce the `gapSize` by one after every fifth pillar. This is
//what makes gaps gradually become narrower
if (i > 0 && i % 5 === 0) gapSize -= 1;
//Create a block if it's not within the range of numbers
//occupied by the gap
for (let j = 0; j < 8; j++) {
if (j < startGapNumber || j > startGapNumber + gapSize - 1) {
let block = sprite(assets["greenBlock.png"]);
blocks.addChild(block);
//Space each pillar 384 pixels apart. The first pillar will be
//placed at an x position of 512
block.x = (i * 384) + 512;
block.y = j * 64;
}
}
//After the pillars have been created, add the finish image
//right at the end
if (i === numberOfPillars - 1) {
finish = sprite(assets["finish.png"]);
blocks.addChild(finish);
finish.x = (i * 384) + 896;
finish.y = 192;
}
}
代码的最后一部分给这个世界添加了一个大精灵,Flappy Fairy 将会看到她是否能够坚持到最后。
游戏循环每帧将一组方块向右移动 2 个像素,但仅在finish精灵不在屏幕上时:
if (finish.gx > 256) {
blocks.x -= 2;
}
当finish sprite 滚动到画布的中心时,blocks容器将停止移动。注意,代码使用了finish精灵的全局 x 位置(gx)来测试它是否在画布的区域内。因为全局坐标是相对于画布的,而不是相对于父容器的,所以对于那些你想在画布上找到一个嵌套的精灵的位置的情况,它们真的很有用。
让 Flappy 仙女飞起来
仙女角色是一个使用三个纹理贴图帧制作的动画精灵。每一帧都是仙女振翅动画中的一个图像。(图 8-15 说明了这三个纹理图谱框架。)
let fairyFrames = [
assets["0.png"],
assets["1.png"],
assets["2.png"]
];
fairy = sprite(fairyFrames);
fairy.fps = 24;
fairy.setPosition(232, 32);
fairy.vy = 0;
fairy.oldVy = 0;
仙女精灵有一个新的属性叫做oldVy,正如你将在前面看到的,它将帮助我们计算仙女的垂直速度。
为了让仙女移动,游戏循环对每一帧的垂直速度应用-0.05 来产生重力。
fairy.vy += -0.05;
fairy.y -= fairy.vy;
玩家可以通过点击或点击画布上的任何地方让她飞起来。每一次点击都会增加 Flappy 仙女的垂直速度 1.5,将她向上推。
pointer = makePointer(canvas);
pointer.tap = () => {
fairy.vy += 1.5;
};
散发仙尘
仙女拍动翅膀时会发出一股五彩缤纷的粒子流。粒子被限制在 2.4 到 3.6 弧度之间的角度,所以它们以一个锥形的楔形发射到仙女的左边,如图图 8-15 所示。粒子流随机发射粉色、黄色、绿色或紫色粒子,每个粒子都是纹理贴图集上的一个单独的帧。

图 8-15 。当仙女扇动翅膀时,会发射出一股五彩缤纷的粒子流
正如你在本章前面学到的,我们写的particleEffect函数将在一个精灵上随机显示一帧,如果这个精灵包含多个帧的话。为此,首先定义要使用的纹理贴图集帧的数组:
dustFrames = [
assets["pink.png"],
assets["yellow.png"],
assets["green.png"],
assets["violet.png"]
];
接下来,使用这些帧初始化提供给发射器的 sprite 函数:
dust = emitter(
300, //The interval
() => particleEffect( //The function
fairy.x + 8, //x position
fairy.y + fairy.halfHeight + 8, //y position
() => sprite(dustFrames), //Particle sprite
3, //Number of particles
0, //Gravity
true, //Random spacing
2.4, 3.6, //Min/max angle
12, 18, //Min/max size
1, 2, //Min/max speed
0.005, 0.01, //Min/max scale speed
0.005, 0.01, //Min/max alpha speed
0.05, 0.1 //Min/max rotation speed
)
);
你现在有了一个名为dust的粒子发射器。只需调用它的play函数,让它开始发射粒子:
dust.play();
微调仙女的动画
Flappy 仙女在往上走的时候,扇动翅膀,散发出神奇的仙尘。当她坠落时,灰尘停止了,她也停止了拍动翅膀。但是我们怎么知道她是向上飞还是向下飞呢?
我们必须找出当前帧和前一帧之间的速度差。如果她现在的速度大于之前的速度,她就会上升。如果它更小,并且之前的速度大于零,她就向下。代码将当前帧中仙女的vy值存储在一个名为oldVy的属性中。当oldVy在下一个帧中被访问时,它会告诉你仙女之前的 vy 值是多少。
//If she's going up, make her flap her wings and emit fairy dust
if (fairy.vy > fairy.oldVy) {
if(!fairy.playing) {
fairy.play();
if (fairy.visible && !dust.playing) dust.play();
}
}
//If she's going down, stop flapping her wings, show the first frame
//and stop the fairy dust
if (fairy.vy < 0 && fairy.oldVy > 0) {
if (fairy.playing) fairy.stop();
fairy.show(0);
if (dust.playing) dust.stop();
}
//Store the fairy's current vy so we can use it
//to find out if the fairy has changed direction
//in the next frame. (You have to do this as the last step)
fairy.oldVy = fairy.vy;
当下一帧来回摆动时,oldVy属性将用于计算帧之间的速度差。这是一个非常常用的技巧,每当你想比较两帧之间的速度精灵的差异时,你可以使用它。
与积木的碰撞
当 Flappy Fairy 撞上一个方块时,她消失在一团灰尘中,如图图 8-16 所示。这种行为是如何运作的?

图 8-16 。噗!她不见了!
游戏循环是在hitTestRectangle函数的帮助下完成的,你在前一章已经学会了使用这个函数。代码在blocks.children数组中循环,并测试每个块和仙女之间的冲突。如果hitTestRectangle返回true,循环退出,一个名为fairyVsBlock的碰撞物体变成true。
let fairyVsBlock = blocks.children.some(block => {
return hitTestRectangle(fairy, block, true);
});
提示你可以看到代码使用了some方法来遍历所有的块。使用some的好处是,一旦找到等于true的值,循环就会退出。
hitTestRectangle的第三个参数需要是true,以便使用精灵的全局坐标(gx和gy)来完成碰撞检测。那是因为fairy是舞台的孩子,但是每个积木都是blocks组的孩子。这意味着它们不共享同一个局部坐标空间。使用它们的全局坐标迫使hitTestRectangle使用精灵相对于画布的位置。
如果fairyVsBlock为true,且仙女当前可见,则运行碰撞代码。它让仙女隐形,制造粒子爆炸,延迟 3 秒后调用游戏的reset功能。
if (fairyVsBlock && fairy.visible) {
//Make the fairy invisible
fairy.visible = false;
//Create a fairy dust explosion
particleEffect(
fairy.centerX, fairy.centerY, //x and y position
() => sprite(dustFrames), //Particle sprite
20, //Number of particles
0, //Gravity
false, //Random spacing
0, 6.28, //Min/max angle
16, 32, //Min/max size
1, 3 //Min/max speed
);
//Stop the dust emitter that's trailing the fairy
dust.stop();
//Wait 3 seconds and then reset the game
wait(3000).then(() => reset());
}
reset功能只是将精灵和方块重新定位到它们的初始位置,并使精灵再次可见。
function reset() {
fairy.visible = true;
fairy.y = 32;
dust.play();
blocks.x = 0;
}
作为用some遍历每个块并测试与hitTestRectangle冲突的替代方法,你可以使用通用的hit函数。正如你在前一章的结尾所了解到的,hit是一个更高级的“奢侈”功能,它会自动为你做很多工作。如果你提供一个精灵数组作为第二个参数,hit会自动循环遍历它们,并检查它们是否与第一个参数中的精灵有冲突。以下是如何使用hit在精灵和积木之间执行相同的碰撞测试:
let fairyVsBlock = hit(
fairy, blocks.children, false, false, true,
() => {
if (fairy.visible) {
fairy.visible = false;
particleEffect(/*...particle arguments...*/);
dust.stop();
wait(3000).then(() => reset());
}
}
);
使用您喜欢的碰撞检测功能。
Flappy 仙女:完整代码
Flappy Fairy 使用了到目前为止你在这本书中学到的所有技术,它包含了一个完整游戏需要的大部分元素。你如何从头开始编写这样的游戏?如何开始或构建一个完整的、全功能的游戏并不总是显而易见的,所以这里是完整的 JavaScript 代码,供您参考。使用它作为开始你自己的新游戏的模型。
//Import code from the library
import {
makeCanvas, sprite, group, particles, particleEffect,
tilingSprite, emitter, stage, render
} from "../library/display";
import {assets, randomInt, contain, wait} from "../library/utilities";
import {makePointer} from "../library/interactive";
import {hit, hitTestRectangle} from "../library/collision";
//Load the assets
assets.load([
"img/flappyFairy.json"
]).then(() => setup());
//Declare any variables shared between functions
let pointer, canvas, fairy, sky, blocks,
finish, dust, dustFrames;
function setup() {
//Make the canvas and initialize the stage
canvas = makeCanvas(910, 512);
canvas.style.backgroundColor = "black";
stage.width = canvas.width;
stage.height = canvas.height;
//Make the sky background
sky = tilingSprite(canvas.width, canvas.height, assets["sky.png"]);
//Create a `group` for all the blocks
blocks = group();
//What should the initial size of the gap be between the pillars?
let gapSize = 4;
//How many pillars?
let numberOfPillars = 15;
//Loop 15 times to make 15 pillars
for (let i = 0; i < numberOfPillars; i++) {
//Randomly place the gap somewhere inside the pillar
let startGapNumber = randomInt(0, 8 - gapSize);
//Reduce the `gapSize` by one after every fifth pillar. This is
//what makes gaps gradually become narrower
if (i > 0 && i % 5 === 0) gapSize -= 1;
//Create a block if it's not within the range of numbers
//occupied by the gap
for (let j = 0; j < 8; j++) {
if (j < startGapNumber || j > startGapNumber + gapSize - 1) {
let block = sprite(assets["greenBlock.png"]);
blocks.addChild(block);
//Space each pillar 384 pixels apart. The first pillar will be
//placed at an x position of 512
block.x = (i * 384) + 512;
block.y = j * 64;
}
}
//After the pillars have been created, add the finish image
//right at the end
if (i === numberOfPillars - 1) {
finish = sprite(assets["finish.png"]);
blocks.addChild(finish);
finish.x = (i * 384) + 896;
finish.y = 192;
}
}
//Make the fairy
let fairyFrames = [
assets["0.png"],
assets["1.png"],
assets["2.png"]
];
fairy = sprite(fairyFrames);
fairy.fps = 24;
fairy.setPosition(232, 32);
fairy.vy = 0;
fairy.oldVy = 0;
//Create the frames array for the fairy dust images
//that trail the fairy
dustFrames = [
assets["pink.png"],
assets["yellow.png"],
assets["green.png"],
assets["violet.png"]
];
//Create the particle emitter
dust = emitter(
300, //The interval
() => particleEffect( //The function
fairy.x + 8, //x position
fairy.y + fairy.halfHeight + 8, //y position
() => sprite(dustFrames), //Particle sprite
3, //Number of particles
0, //Gravity
true, //Random spacing
2.4, 3.6, //Min/max angle
12, 18, //Min/max size
1, 2, //Min/max speed
0.005, 0.01, //Min/max scale speed
0.005, 0.01, //Min/max alpha speed
0.05, 0.1 //Min/max rotation speed
)
);
//Make the particle stream start playing when the game starts
dust.play();
//Make the pointer and increase the fairy's
//vertical velocity when it's tapped
pointer = makePointer(canvas);
pointer.tap = () => {
fairy.vy += 1.5;
};
//Start the game loop
gameLoop();
}
function gameLoop() {
requestAnimationFrame(gameLoop);
//Update all the particles in the game
if (particles.length > 0) {
for(let i = particles.length - 1; i >= 0; i--) {
let particle = particles[i];
particle.update();
}
}
//The `play` function contains all the game logic
play();
}
function play() {
//Make the sky background scroll by shifting the `tileX`
//of the `sky` tiling sprite
sky.tileX -= 1;
//Move the blocks 2 pixels to the left each frame.
//This will just happen while the finish image is off-screen.
//As soon as the finish image scrolls into view, the blocks
//container will stop moving
if (finish.gx > 256) {
blocks.x -= 2;
}
//Add gravity to the fairy
fairy.vy += -0.05;
fairy.y -= fairy.vy;
//Decide whether the fairy should flap her wings
//If she's going up, make her flap her wings and emit fairy dust
if (fairy.vy > fairy.oldVy) {
if(!fairy.playing) {
fairy.play();
if (fairy.visible && !dust.playing) dust.play();
}
}
//If she's going down, stop flapping her wings, show the first frame
//and stop the fairy dust
if (fairy.vy < 0 && fairy.oldVy > 0) {
if (fairy.playing) fairy.stop();
fairy.show(0);
if (dust.playing) dust.stop();
}
//Store the fairy's current vy so we can use it
//to find out if the fairy has changed direction
//in the next frame. (You have to do this as the last step)
fairy.oldVy = fairy.vy;
//Keep the fairy contained inside the stage and
//neutralize her velocity if she hits the top or bottom boundary
let fairyVsStage = contain(fairy, stage.localBounds);
if (fairyVsStage === "bottom" || fairyVsStage === "top") {
fairy.vy = 0;
}
//Loop through all the blocks and check for a collision between
//each block and the fairy. (`some` will quit the loop as soon as
//`hitTestRectangle` returns `true`.) Set `hitTestRectangle`s third argument
//to `true` to use the sprites' global coordinates
let fairyVsBlock = blocks.children.some(block => {
return hitTestRectangle(fairy, block, true);
});
//If there's a collision and the fairy is currently visible,
//create the explosion effect and reset the game after
//a three second delay
if (fairyVsBlock && fairy.visible) {
//Make the fairy invisible
fairy.visible = false;
//Create a fairy dust explosion
particleEffect(
fairy.centerX, fairy.centerY, //x and y position
() => sprite(dustFrames), //Particle sprite
20, //Number of particles
0, //Gravity
false, //Random spacing
0, 6.28, //Min/max angle
16, 32, //Min/max size
1, 3 //Min/max speed
);
//Stop the dust emitter that's trailing the fairy
dust.stop();
//Wait 3 seconds and then reset the game
wait(3000).then(() => reset());
}
//Alternatively, you can achieve the same collision effect
//using the higher-level universal `hit` function
//Render the canvas
render(canvas);
}
function reset() {
//Reset the game if the fairy hits a block
fairy.visible = true;
fairy.y = 32;
dust.play();
blocks.x = 0;
}
如果您在构建自己的游戏时,对在哪里或如何应用某项技术感到困惑或不确定,请回头看看这段源代码,并将其用作框架和指南。
摘要
你现在已经掌握了开始制作一些相当复杂和引人入胜的游戏的所有技能。关键帧动画、粒子效果和视差滚动等技术远非无足轻重的附加功能,而是为您的游戏增添了全新的沉浸感。他们可以把一个仅仅是有趣的游戏变成一个活生生的、会呼吸的另一个世界。
您还了解了如何利用代码抽象的力量,用不到 200 行代码制作一个非常复杂的游戏原型。通过创建可重用的对象和函数,并将所有代码隐藏在代码库中,你最终的游戏代码是轻量级的和可读的。现在,你已经为制作各种各样的游戏打下了坚实的基础,你也有了一个易于理解的架构。
在你翻过这一页之前,我要你做最后一件事。再玩一次 Flappy Fairy,打开电脑音量。你听到了什么?没错:没事!我们将在下一章解决这个问题。
九、网络音频 API 的声音
HTML5 有两个不同的声音播放系统:旧的 HTML5 音频元素和新的 Web 音频 API。你应该用哪一个?对于游戏,使用非常强大的网络音频 API。它加载声音可靠,让您同时播放多种声音,并给你精确的播放控制。此外,它还允许您应用效果,创建自己的合成声音,以及指定不同种类的输入和输出源。一旦你克服了这个小小的学习曲线,你将能够使用 Web Audio API 快速创建任何你能想象的声音系统。
在这一章中,你将会学到所有你需要知道的关于网络音频 API 的知识,来使用和创建你的游戏需要的所有音乐和声音效果。您将学习如何加载声音文件,访问它们,控制它们如何播放,以及添加特殊效果,如回声和混响。您还将学习如何用纯代码从头开始生成声音,以及如何构建您自己的游戏音效定制库。
不过,在我们深入研究新的 Web 音频 API 之前,让我们先了解一下最初的 HTML 音频元素。
HTML 音频元素
HTML5 音频元素是专门为在网站上播放音频文件而设计的,通常过于局限,不能很好地用于游戏。用 HTML5 音频元素加载和播放声音的 JavaScript 格式与加载图像的格式相同。创建一个新的Audio对象,监听一个canplaythrough事件,然后在声音加载完成后调用一个事件处理程序。你可以用一个play方法开始声音,并设置其他属性,如volume和loop。这里有一个典型的例子:
let sound = new Audio();
sound.addEventListener("canplaythrough", playSoundHandler, false);
sound.src = "sounds/music.wav";
function playSoundHandler(event) {
sound.play();
sound.volume = 0.5;
sound.loop = true;
}
很懂事,很熟悉,可惜,很有限。回放控制不精确,同时重叠的声音要么不起作用,要么表现古怪。尽管在撰写本文时,HTML5 Audio element 规范已经稳定了很多年,但是没有一个浏览器供应商已经完全实现了它,或者看起来可能很快就会实现。所以如果你在浏览器中运行这个例子,首先希望loop属性能够工作。如果是这样,您可能会听到音乐循环重复,但您几乎肯定会在它重复之前听到几毫秒的间隙。HTML5 音频元素对于一个声音设计要求不高的游戏来说已经足够了,但是对于其他任何东西来说,你需要更多的控制。
进入,网络音频 API!
了解网络音频 API
无论你如何努力地浏览 Web Audio API 规范,你都不会找到任何一个叫做play 的方法,或者任何一个叫做loop或者volume的属性。你必须自己建立这些。Web Audio API 给你的是一个装满音频组件的大玩具盒,你可以用它来创建你可能需要的任何音频系统。你的工作是把那个盒子倒在你的厨房桌子上,然后花一个阳光明媚的下午把这些组件连接起来,组合成任何你觉得有用的东西。Web Audio API 最大的优点是它是完全模块化的,所以你可以用任何你喜欢的方式连接不同的组件。您可以构建自己的模拟风格合成器或采样器、3D 全息音乐播放器、乐谱解释器、程序音乐生成器或声音可视化器。或者,你可以做我们在本章将要做的事情:为游戏创建可重复使用的音效和音乐播放器和生成器。
注意Web Audio API 足够复杂和深入,它本身就值得写一整本书。在这一章中,我不会触及 API 的每一个特性,只是那些你需要知道的来创建一个在游戏中使用声音的实用系统。您可以把这看作是对 Web Audio API 的广泛介绍,如果您想自己进一步探索它,这将为您打下坚实的基础。
以下是使用网络音频 API 加载和播放声音时需要遵循的基本步骤:
- 用 XHR 加载声音文件并解码。你最终得到一个叫做缓冲区的原始音频文件。
- 将缓冲器连接到音频效果节点。可以把每个节点想象成一个小小的音效盒,上面有可以调节的旋钮,也就是说,可以用代码设置参数和属性。你可能有一个控制音量的盒子,另一个控制左/右平移的盒子,还有一个控制混响的盒子。任何电吉他演奏者都可以把你的吉他想象成声音缓冲器,把节点想象成效果踏板。您可以按任意顺序将任意数量的节点连接在一起。
- 要听到声音,将效果链中的最后一个节点连接到目的地。目标通常是系统上的默认播放设备;它可以是扬声器、耳机、7.1 环绕声或电视。然而,目标也可以是新的声音文件。
- 开始播放声音。
图 9-1 显示了这个过程的样子。

图 9-1 。如何使用网络音频 API 播放声音
这在实际代码中更容易理解,所以接下来让我们看一个实际的例子。
加载和播放声音文件
让我们从最简单的例子开始。我们将加载一个声音文件,并通过按键盘上的数字 1 来播放它。评论一步一步地解释了这一切是如何运作的。
//1\. Create an audio context
let actx = new AudioContext();
//2\. Declare a variable to hold the sound we're going to load
let soundBuffer;
//3\. Load the sound.
//a. Use an XMLHttpRequest object to load the sound
let xhr = new XMLHttpRequest();
//b. Set properties for the file we want to load.
//Use GET and set the path to the sound file.
//`true` means that the file will load asynchronously and will create
//an event when the file has finished loading
xhr.open("GET", "sounds/test.wav", true);
//c. Set the `responseType`, which is the file format we're expecting to
//load. Sound files should be loaded as binary files, so the `responseType`
//needs to be `arraybuffer`
xhr.responseType = "arraybuffer";
//d. Load the sound into the program
xhr.send();
//e. Create a `loadHandler` that runs when the sound has been loaded
xhr.addEventListener("load", loadHandler, false);
function loadHandler(event) {
//f. Decode the audio file and store it in the `soundBuffer`
//variable. The `buffer` is the raw audio data
actx.decodeAudioData(
xhr.response,
buffer => {
//g. Copy the audio file into the `soundBuffer` variable
soundBuffer = buffer;
},
//Optionally throw an error if the audio can't be decoded
error => {
throw new Error("Audio could not be decoded: " + error);
}
);
}
//f. Play a sound when a key is pressed
window.addEventListener("keydown", keydownHandler, false);
function keydownHandler(event) {
switch (event.keyCode) {
case 49:
if (soundBuffer) {
//4\. Play the sound
//a. Create a new `soundNode` and tell it to use the
//sound that we loaded as its audio source
let soundNode = actx.createBufferSource();
soundNode.buffer = soundBuffer;
//b. Connect the sound to the destination.
//(There are no effects in this example.)
soundNode.connect(actx.destination);
//c. Finally, play the sound. Use the `start` method to
//play the sound “right now”, which is the audio context’s `currentTime`
soundNode.start(actx.currentTime);
}
break;
}
}
第一步是创建一个AudioContext 。这是您将创建和控制声音的编程空间:
var actx = new AudioContext();
这就像画布的背景,除了它是声音而不是图像。
接下来,创建一个名为soundBuffer 的变量,用于存储原始的二进制声音文件。
let soundBuffer;
使用 XHR 加载声音。responseType是arrayBuffer,它只是告诉 XHR 你正在加载一个二进制文件,而不是一个文本文件。
let xhr = new XMLHttpRequest();
xhr.open("GET", "sounds/test.wav", true);
xhr.responseType = "arraybuffer";
xhr.send();
xhr.addEventListener("load", loadHandler, false);
loadHandler 使用音频上下文的decodeAudioData方法将声音文件转换为原始音频数据。它将这些数据保存在soundBuffer中:
function loadHandler(event) {
actx.decodeAudioData(
xhr.response,
buffer => {
soundBuffer = buffer;
},
error => {
throw new Error("Audio could not be decoded: " + error);
}
);
}
decodeAudioData方法有一个可选的第三个参数,这是一个在解码音频出错时运行的函数。你应该总是加上这一点,因为如果音频由于某种原因没有正确解码,你肯定需要得到通知。如果您试图加载不兼容的音频格式,可能会出现解码错误。
最后一步是实际播放声音,在本例中是通过按键盘上的数字 1 来实现的。要播放声音,您至少需要运行以下四行代码:
let soundNode = actx.createBufferSource();
soundNode.buffer = soundBuffer;
soundNode.connect(actx.destination);
soundNode.start(actx.currentTime);
下面是这四行的工作原理。首先创建一个用于播放声音的soundNode 。它保存了对我们加载的缓冲区(原始音频数据)的引用。然后connect``soundNode到音频上下文的destination,在这种情况下是你的计算机的扬声器。最后,用start的方法来播放声音。参数actx.currentTime表示“现在播放声音”
soundNode.start(actx.currentTime);
start方法用于安排声音播放的时间,作为当前时间的偏移量。如果您向它提供音频上下文的currentTime值,这意味着“立即播放声音,没有任何延迟。”如果您希望声音开始前有 2 秒钟的延迟,可以使用以下语法:
start(actx.currentTime + 2)
请注意,Web Audio API 使用的时间单位是秒,而不是毫秒。
提示或者,也可以通过提供值为 0 的 start 方法,立即进行声音播放,这样: start(0) 。这是因为任何小于 currentTime 的值都会导致音频上下文立即播放声音。你可以使用你喜欢的任何一种风格。
这里有一件你需要知道的非常重要的事情:每次你想播放声音的时候都要运行这最后四行代码。对于一种声音来说,这似乎需要编写很多代码,但是我们很快就会解决这个问题。
您已经了解了如何加载和播放基本的声音,但是如果您想添加一些更高级的功能,该怎么办呢?
音量、声相和循环
要改变音量和平移,创建一个volumeNode和一个panNode 和一个:
let volumeNode = actx.createGain();
let panNode = actx.createStereoPanner()
你可以把它们想象成两个音效盒,你可以把它们连接在声音和扬声器之间。下面是如何将这些节点connect到soundNode和destination:
soundNode.connect(volumeNode);
volumneNode.connect(panNode);
panNode.connect(actx.destination);
你可以看到你正在创建一个连接链:soundNode
volumeNode
panNode
destination。最后一个效果节点,在这个例子中是panNode,应该总是连接到destination,这样你就可以听到声音。
注意如果需要断开一个节点,使用disconnect方法。例如,您可以使用以下语法断开平移节点:
panNode.disconnect()
这将断开平移节点与其所连接的任何设备的连接,在本例中是卷节点。但请记住,如果你这样做,你已经打破了原始声音和目的之间的连接链。这意味着当你开始播放声音时,你将听不到任何声音,除非你将音量节点直接连接到目的节点或链中的另一个连接节点。
现在它们已经连接好了,调整这些新节点的设置以达到你想要的效果。以下是将音量设置为 50%的方法:
volumeNode.gain.value = 0.5;
0 的gain.value 是没有声音,1 的值是全音量。(注意增益是音量的音频技术术语。更具体地说,它指的是音频信号被放大的程度。)
要设置左右扬声器平移,请将panNode.pan.value属性设置为–1 和 1 之间的一个数字。值为–1 会将声音导向左扬声器,值为 1 会将声音导向右扬声器。值为 0 会使两个扬声器的音量相等。例如,以下是如何将声相设定为在左扬声器中稍微突出一些:
panNode.pan.value = -0.2;
注意你也可以使用createPanner方法创建一个更高级的声相对象,它返回一个声相节点,让你使用 x 、 y 和 z 空间坐标在 3D 空间定位声音。它非常适合为 3D 游戏创建复杂的声音环境。有关更多信息,请参见位于http://webaudio.github.io/web-audio-api/的createPanner方法的网络音频规范。
您想在结束时重复播放声音吗?将soundNode的loop属性设置为true:
soundNode.loop = true;
声音将会在结束时重复播放。
提示在循环声音重复之前,你有没有听到短暂的延迟?如果是这样,请在任何音频编辑软件中打开您的声音文件。您可能会发现,在声音开始之前,该文件包含几毫秒的额外静默。这在 MP3 文件中很常见。还可以考虑删除 MP3 头或元数据,已知这会导致一些音频渲染引擎在重复声音之前打嗝一两毫秒。
现在让我们把所有这些新技术放在一起。如果你想播放一个通过音量和声相控制循环播放的声音,下面是你需要运行的完整代码:
let soundNode = actx.createBufferSource();
soundNode.buffer = soundBuffer;
//Create volume and pan nodes
let volumeNode = actx.createGain();
let panNode = actx.createStereoPanner();
//Connect the sound source to the pan node, the pan node to
//volume node, and the volume node to the destination
soundNode.connect(panNode);
panNode.connect(volumeNode);
volumeNode.connect(actx.destination);
//Set the volume
volumeNode.gain.value = 0.5;
//Set the pan fully to the left
panNode.pan.value = -1;
//Optionally loop the sound
soundNode.loop = true;
//Finally, play the sound
soundNode.start(actx.currentTime);
而且,就像我们的第一个例子一样,每次想要播放声音时,您都需要运行所有这些代码。
仅仅演奏一种声音似乎就要做很多工作,不是吗?但这是有原因的:这是因为 Web Audio API 不希望您每次播放声音时都编写所有这些代码。相反,它希望为您提供强大而灵活的细粒度工具,以便您可以从头开始构建任何类型的声音系统。它不是指定一个你应该用来播放声音的 API,而是给了你制作你自己的 API 所需要的构件。
因此,这正是我们接下来要做的:构建一个易于使用和可重用的声音对象,以在游戏中播放音效和音乐。
WEB 音频 API 节点
到目前为止,我们在这些例子中只使用了四个节点:音频源节点(加载的声音文件)、增益节点(音量)、声相器节点和目的节点(扬声器)。)但是 Web Audio API 有丰富的不同节点集合供您使用:
DelayNode:创建回声、相位和镶边效果。ConvolverNode:让你模拟一个声学环境,比如大教堂、音箱或者电话扬声器。AnalyserNode:获取关于你声音的数据,帮助你制作类似音乐可视化器或图形均衡器的东西。ChannelSplitterNode和ChannelMergerNode:让你捕捉左右立体声信号作为单声道输出,然后,如果你想,将它们重新混合成一个新的立体声信号。DynamicsCompressorNode:将非常安静或非常嘈杂的声音正常化到中等音量水平。- 帮助你建立低音、中音和高音均衡器。
WaveShaperNode:扭曲声音。OscillatorNode:生成合成音。制作自己的 Moog 合成器!ScriptProcessorNode:如果你需要你的声音做一些内置节点没有涵盖的事情,使用 JavaScript 创建你自己的自定义效果节点。
除了这些节点,Web Audio API 让你设置一个移动的AudioListener 。声音强度和方向将根据收听者在 3D 空间中的位置而变化。您还可以从麦克风或线路输入源采集声音,并将声音文件写入磁盘。有关详细信息,请在http://webaudio.github.io/web-audio-api/查看完整的、编写良好的、可读的网络音频 API 规范。
网络音频声音对象
你希望如何控制游戏中的声音?你应该能够加载声音并播放它们。如果你能暂停它们,重新启动它们,或者从一个特定的位置播放它们,那就太好了。你也应该能够控制音量和设置左,右扬声器平移。在一个完美的世界中,我们能够用简单的属性和方法来控制我们的声音,可能看起来像这样:
sound.load();
sound.play();
sound.pause();
sound.restart();
sound.volume = 0.8;
sound.pan = -0.5;
sound.playFrom(15);
多亏了网络音频 API,这个完美的世界才得以存在。嗯,差不多了...还没有。我们必须先建造它!
我们如何构建这样一个声音对象?我们的 dream API 布局给了你一些线索。声音对象需要名为load、play、pause、restart和playFrom的方法。并且它需要名为volume和pan的属性。我们希望能够为游戏中的所有声音制作尽可能多的声音对象。这意味着我们可以把每种声音想象成一种音频精灵。但是音频精灵将播放声音,而不是显示图像。这意味着,我们可以使用我们用于视觉精灵的相同模型,并对其进行调整,使其适用于声音。
在下一节中,我们将创建一个完成所有这些工作的Sound类。正如您将看到的,它只是您已经知道的不同模式的组合。它融合了我们刚刚学到的关于 Web Audio API 的知识和我们所知道的如何使用类来创建对象的知识。唯一真正新的东西是它用来暂停、重启和播放声音的系统。但是我们将在后面详细讨论,以及如何实现这个类来发出新的声音。这里是完整的Sound类。仔细通读一遍,你看完了我在另一边等你!
//Create the audio context
let actx = new AudioContext();
//The sound class
class Sound {
constructor(source, loadHandler) {
//Assign the `source` and `loadHandler` values to this object
this.source = source;
this.loadHandler = loadHandler;
//Set the default properties
this.actx = actx;
this.volumeNode = this.actx.createGain();
this.panNode = this.actx.createStereoPanner();
this.soundNode = null;
this.buffer = null;
this.loop = false;
this.playing = false;
//Values for the pan and volume getters/setters
this.panValue = 0;
this.volumeValue = 1;
//Values to help track and set the start and pause times
this.startTime = 0;
this.startOffset = 0;
//Load the sound
this.load();
}
//The sound object's methods
load() {
//Use xhr to load the sound file
let xhr = new XMLHttpRequest();
xhr.open("GET", this.source, true);
xhr.responseType = "arraybuffer";
xhr.addEventListener("load", () => {
//Decode the sound and store a reference to the buffer
this.actx.decodeAudioData(
xhr.response,
buffer => {
this.buffer = buffer;
this.hasLoaded = true;
//This next bit is optional, but important.
//If you have a load manager in your game, call it here so that
//the sound is registered as having loaded.
if (this.loadHandler) {
this.loadHandler();
}
},
//Throw an error if the sound can't be decoded
error => {
throw new Error("Audio could not be decoded: " + error);
}
);
});
//Send the request to load the file
xhr.send();
}
play() {
//Set the start time (it will be `0` when the first sound starts)
this.startTime = this.actx.currentTime;
//Create a sound node
this.soundNode = this.actx.createBufferSource();
//Set the sound node's buffer property to the loaded sound
this.soundNode.buffer = this.buffer;
//Connect the sound to the volume, connect the volume to the
//pan, and connect the pan to the destination
this.soundNode.connect(this.volumeNode);
this.volumeNode.connect(this.panNode);
this.panNode.connect(this.actx.destination);
//Will the sound loop? This can be `true` or `false`
this.soundNode.loop = this.loop;
//Finally, use the `start` method to play the sound.
//The start time will be either `0`,
//or a later time if the sound was paused
this.soundNode.start(
this.startTime,
this.startOffset % this.buffer.duration
);
//Set `playing` to `true` to help control the
//`pause` and `restart` methods
this.playing = true;
}
pause() {
//Pause the sound if it's playing, and calculate the
//`startOffset` to save the current position
if (this.playing) {
this.soundNode.stop(this.actx.currentTime);
this.startOffset += this.actx.currentTime - this.startTime;
this.playing = false;
}
}
restart() {
//Stop the sound if it's playing, reset the start and offset times,
//then call the `play` method again
if (this.playing) {
this.soundNode.stop(this.actx.currentTime);
}
this.startOffset = 0,
this.play();
}
playFrom(value) {
if (this.playing) {
this.soundNode.stop(this.actx.currentTime);
}
this.startOffset = value;
this.play();
}
//Volume and pan getters/setters
get volume() {
return this.volumeValue;
}
set volume(value) {
this.volumeNode.gain.value = value;
this.volumeValue = value;
}
get pan() {
return this.panNode.pan.value;
}
set pan(value) {
this.panNode.pan.value = value;
}
}
//Create a high-level wrapper to keep our general API style consistent and flexible
function makeSound(source, loadHandler) {
return new Sound(source, loadHandler);
}
若要使用该类创建 sound 对象,请使用声音的源路径和一个可选的加载处理程序对其进行初始化,该处理程序应在声音完成加载后运行。以下是创建新音乐声音的方法:
let music = makeSound("sounds/music.wav", setupMusic);
声音加载后,setup功能将立即运行。使用它来设置声音的任何属性。然后决定你想如何控制声音。下面是一些使用keyboard函数来监听按键的代码。它可以让你按下“a”键播放声音,“b”键暂停声音,“c”键重启声音,“d”键从 10 秒开始播放。
function setupMusic() {
//Make the music loop
music.loop = true;
//Set the pan
music.pan = -0.8;
//Set the volume
music.volume = 0.3;
//Capture keyboard key events
let a = keyboard(65),
b = keyboard(66),
c = keyboard(67),
d = keyboard(68);
//Use the key `press` methods to control the sound
//Play the music with the `a` key
a.press = () => {
if (!music.playing) music.play();
console.log("music playing");
};
//Pause the music with the `b` key
b.press = () => {
music.pause();
console.log("music paused");
};
//Restart the music with the `c` key
c.press = () => {
music.restart();
console.log("music restarted");
};
//Play the music from the 10 second mark
//with the `d` key
d.press = () => {
music.playFrom(10);
console.log("music start point changed");
};
}
去看(和听!)这段代码在运行中,运行章节的源文件中的soundObject.html文件,如图图 9-2 所示。

图 9-2 。使用一个声音类来加载和控制音乐
从给定时间开始暂停、重启和播放
sound 对象的一个重要特性是声音可以暂停、重新开始和从任何位置播放。AudioContext有一个名为currentTime 的属性,它告诉您从上下文创建的那一刻起的时间,以秒为单位。它就像时钟的秒针,永远在前进。有悖常理的是,它并没有告诉你声音播放的时间。这意味着如果你在 10 秒标记处暂停声音,等待 5 秒,然后从 0 开始重新开始声音,currentTime将是 15。currentTime只是一直向前滴答,直到声音物体被摧毁。
是的,很奇怪。这与视频或音频中的时间码是完全不同的概念。例如,在任何音频应用程序(如 Logic、Audacity 或 Ableton Live)中,如果您停止声音,时间码也会停止。如果您倒转声音,时间码会向后移动以匹配您想要移动到的时间段;如果您前进声音,时间码也会前进到相同的位置。网络音频 API 中的时间则不是这样:它只是不停地向前移动,不能暂停、前进或倒退。但是不要为此担心:这只是网络音频 API 的底层特性,从长远来看,它给了你更多的灵活性。但这也意味着你必须在此基础上构建自己的系统,以便在正确的时间点启动和播放声音。
为了帮助计算时间开始和停止点,使用初始化为零的startTime和startOffset变量:
this.startTime = 0;
this.startOffset = 0;
要暂停声音,首先使用stop方法停止声音。然后通过加上currentTime减去startTime来计算startOffset时间。
pause() {
if (this.playing) {
this.soundNode.stop(this.actx.currentTime);
this.startOffset += this.actx.currentTime - this.startTime;
this.playing = false;
}
},
再次播放声音时,捕捉新的startTime:
play() {
this.startTime = this.actx.currentTime;
//...
通过将start方法的第一个参数设置为currentTime来播放声音。意思是“现在播放声音”
//...
this.soundNode.start(
this.startTime, //1: "play right now" `this.startOffset % this.buffer.duration` **//2:** `"`**Play the correct section of the sound**`"`
`);`
`this.playing = true;`
`},`
第二个参数是要播放的声音文件部分。这是一个简单的计算,指定播放声音文件的哪一部分。该点是通过找到startOffset除以buffer.duration的余数来计算的。(buffer.duration`是载入声音的时间,以秒为单位。)这将使声音从暂停的地方开始播放。
深呼吸!这可能是在 Web 音频 API 中处理时间最复杂的部分,但是我们现在已经克服了它。多亏了这个小小的计算,我们有办法在时间上来回移动音频,并从暂停的地方恢复声音。
注意start方法还有第三个可选参数,即声音播放的持续时间,以秒为单位。例如,如果您有一个 10 秒长的声音,但您只想播放该声音的前 3 秒,则提供持续时间 3。如果你想让那部分声音循环,你必须将sounceNode的loopStart属性设置为 0(声音的开始)和loopEnd属性设置为 3(持续时间的结束时间)。)
restart方法以同样的方式工作。它将startOffset设置为currentTime,这将导致声音再次从头开始播放。
restart() {
if (this.playing) {
this.soundNode.stop(this.actx.currentTime);
}
this.startOffset = 0,
this.play();
},
第三个新特性是playFrom方法。这让您可以随时播放声音。以下是从 10 秒钟开始播放音乐的方法:
music.playFrom(10);
它几乎与restart方法相同,但是允许您指定开始播放的时间,以秒为单位。
playFrom(value) {
if (this.playing) {
this.soundNode.stop(this.actx.currentTime);
}
this.startOffset = value;
this.play();
},
现在我们有了一个简洁的、可重复使用的声音对象,可以添加到任何游戏中。
注意你会在library/sound.js文件中找到完整的Sound类和makeSound函数。
一个可靠的素材加载器
现在你知道了如何发出声音,你需要一些方法来有效地将声音文件加载到你的游戏程序中。幸运的是,我们已经在第三章中创建了一个通用素材加载器。只需做一些小的修改,我们就可以扩展它来帮助我们加载声音文件,就像它加载字体、图像和 JSON 数据文件一样容易。你会在library/utilities.js文件中找到assets对象——如果你需要快速回顾一下它是如何工作的,请翻回到第三章。
第一步是从library/sound模块导入makeSound方法。将这段代码添加到utilities模块的开头:
import {makeSound} from "../library/sound";
现在找到assets对象并添加一个名为audioExtensions 的属性,这是一个数组,列出了您可能需要加载的各种音频文件的所有文件扩展名:
audioExtensions: ["mp3", "ogg", "wav", "webm"],
然后,在加载每个源的循环中,检查是否有任何源具有这些音频文件扩展名之一。如果是,代码应该调用一个名为loadSound 的新方法:
sources.forEach(source => {
//...
else if (this.audioExtensions.indexOf(extension) !== -1) {
this.loadSound(source, loadHandler);
}
//...
});
loadSound方法使用makeSound创建声音对象并加载声音文件。然后,它将声音对象指定为assets对象的属性。它赋予声音对象一个与声音文件名称相匹配的名称。
loadSound(source, loadHandler) {
//Create a sound object and alert the `loadHandler`
//when the sound file has loaded
let sound = makeSound(source, loadHandler);
//Get the sound file name
sound.name = source;
//Assign the sound as a property of the assets object so
//we can access it this way: `assets["sounds/sound.mp3"]`
this[sound.name] = sound;
}
这意味着在声音加载后,您可以使用以下语法访问游戏文件中的声音对象:
assets["sounds/soundFileName.mp3"];
你如何在一个真实的游戏程序中使用它?首先,使用assets.load方法加载文件,完成后调用一个setup函数。以下是如何将两个声音文件加载到游戏中的方法:
assets.load([
"sounds/music.wav",
"sounds/shoot.wav"
]).then(() => setup());
当然,您也可以列出游戏可能需要的任何其他资源,如图像或 JSON 文件,并同时加载它们。
接下来,在setup函数中,只需使用assets对象来获取您想要使用的已加载声音的引用。然后你可以像使用本章前面例子中的任何其他声音对象一样使用它们。
function setup() {
//Get references to the loaded sound objects
let music = assets["sounds/music.wav"],
shoot = assets["sounds/shoot.wav"];
//Capture keyboard key events
let a = keyboard(65),
b = keyboard(66);
//Play the music with the `a` key
a.press = () => {
if (!music.playing) music.play();
};
//Play the shoot sound with the `b` key
b.press = () => {
shoot.play();
};
}
你现在已经有了一个统一一致的界面来加载和使用游戏中的所有资源。
添加效果
为游戏加载和播放声音文件固然很好,但这只是强大的网络音频 API 所能做到的一小部分。现在你已经知道了基础知识,让我们进一步探索,看看我们的好奇心能把我们带到哪里。也许对我们的声音做一些特殊的效果会很好?只需多做一点工作,我们就可以实现这三种效果:
- 播放速度:让声音以更快或更慢的速度播放。
- 回声:衰减的回声效果。
- 混响:模拟声学空间的声音,比如一个大房间或洞穴。
这些效果很容易在我们当前的系统上实现,并且会给你一个更高级的网络音频 API 特性的概述。(运行本章源代码中的specialEffects.html文件,查看该代码的运行示例。)
改变播放速率
让声音以更快或更慢的速度播放是一种有趣且快速的效果。声音缓冲源(我们前面例子中的soundNode对象)有一个名为playbackRate的属性,可以让你改变声音播放的快慢。其默认值为 1,即正常速度。您可以通过将playbackRate设置为 0.5 来使声音以一半的速度播放,或者通过将其设置为 2 来使声音以两倍的速度播放。改变playbackRate不会影响声音的音高(音高是声音的音符频率,即声音的高低)。下面是如何给Sound类添加回放速度特性,让你改变任何声音的速度。
首先,向Sound类的构造函数添加一个playBackrate属性:
this.playbackRate = 1;
将其设置为 1 意味着默认回放速率将是正常速度。
接下来,在调用start方法之前,将下面一行代码添加到Sound类的play方法中。它将声音缓冲源的playBackrate.value设置为Sound类自己的playBackrate值。
this.soundNode.playbackRate.value = this.playbackRate;
最后,在您的游戏程序代码中,使用makeSound方法设置您发出的任何声音的playbackRate属性。下面是如何让一个叫做music的声音以半速播放:
music.playbackRate = 0.5;
如果你想让它播放快一倍,把它设置为 2。现在你有一个简单的方法来控制任何声音的播放速度!
回声
回声是一种你可能想在游戏声音中使用的效果。Web Audio API 没有内置的自动添加回声的方法,但是创建自己的回声系统非常容易。要让它工作,你需要使用一个延迟节点 。
let delayNode = actx.createDelay();
延迟节点唯一做的事情是在播放声音之前延迟它。如果您想设置半秒钟的延迟,您可以如下操作:
delayNode.delayTime.value = 0.5;
但是简单地将声音延迟半秒并不足以产生回声效果。声音需要延迟,然后重复,每次重复都变得更微弱。为了实现这一点,你需要另一个节点,称为反馈 ,,它将使声音随着每次重复逐渐变得安静。feedbackNode只是一个增益节点,和我们用来设置音量的节点是同一类型的。
let feedbackNode = actx.createGain();
如果您希望每次重复时回声的音量降低大约 20 %,请将feedbackNode的值设置为 0.8。
feedbackNode.gain.value = 0.8;
(值为 1 表示音量不会降低,声音会一直重复。将其设置为大于 1 将逐渐增加回声的音量。)
但是我们还没完呢!为了完成所有这些工作,你需要将这些节点连接在一起。首先,通过将延迟发送到反馈来创建一个闭环,然后返回到延迟中。连接路径如下所示:
delay > feedback > delay
这个循环就是产生重复回声效果的原因。每次将延迟的声音发送到反馈时,其音量会降低 20%,因此声音会随着每次重复而逐渐消失。接下来,将延迟节点连接到您的主声音链。将其插入源节点和目标节点之间:
source > delay > destination
结果是,延迟节点从源获得输入,将其发送到反馈回路,然后将产生的回声发送到目的地,以便您可以听到它。
这在实际代码中是什么样子的?让我们稍微简化一下,现在忽略体积和平移节点。下面是创建基本回声效果所需的所有代码。
//Create the delay and feedback nodes
let delayNode = actx.createDelay(),
feedbackNode = actx.createGain();
//Set their values
delayNode.delayTime.value = 0.2;
feedbackNode.gain.value = 0.8;
//Create the delay feedback loop
delayNode.connect(feedbackNode);
feedbackNode.connect(delayNode);
//Connect the source to the destination to play the first
//instance of the sound at full volume
source.connect(actx.destination);
//Capture the source and send it to the delay loop
//to create the echo effect. Then connect the delay to the
//destination so that you can hear the echo
source.connect(delayNode);
delayNode.connect(actx.destination);
Web Audio API 非常有效地管理声音对象,因此没有内存泄漏的危险。API 运行时(浏览器)将负责销毁不再听得见的声音。这意味着你不需要编写代码来检查和删除音量为零的声音。
这将让你有一个良好的基本回声,但我们还可以做得更多。
更加自然的回声效果
我们当前的回声重复每一个声音,作为原始声音的完美复制,每次重复音量都有所下降。通过稍微改变每个重复声音的音调,您可以赋予回声效果一种更加有机、梦幻的品质。一种简单的方法是在混音中添加一个双二阶滤波器。一个双二阶滤波器只是过滤掉任何高于某个阈值的频率。下面是如何创建一个双二阶滤波器节点,并设置其频率值。
let filterNode = actx.createBiquadFilter();
filterNode.frequency.value = 1000;
给滤波器一个 1000 的频率值意味着它将剪切掉 1000 Hz 以上的任何频率。
注意默认情况下,双二阶滤波器是一个低通滤波器,这意味着它允许低于给定阈值的所有频率通过。通过设置过滤器的type属性,您可以更改其过滤行为。type属性可以设置为以下任意字符串值:"lowpass"、"highpass"、"bandpass"、"lowshelf"、"highshelf"、"peaking"、"notch"和"allpass"。
将filterNode添加到延迟回路,在反馈和延迟连接之间,如下所示:
delay > feedback > filter > delay
这是包含滤波器的新延迟环路代码:
delayNode.connect(feedbackNode);
feedbackNode.connect(filterNode);
filterNode.connect(delayNode);
你可以通过改变filterNode的频率值来实现各种各样的酷科幻效果——它非常适合幻想或太空游戏。
提示双二阶滤波器还有一个有趣的特性叫做detune,可以让你改变源声音的音高。将它设定为以音分(半音的百分比)为单位的值,以按该量更改音高。一个完整的八度音程(12 个半音)是 1200 美分。
向声音类添加回声功能
现在我们知道了如何创建回声效果,让我们更新我们的Sound类,这样我们就可以有选择地对任何声音应用回声。首先,在构造函数中创建我们需要的新节点:
this.delayNode = this.actx.createDelay();
this.feedbackNode = this.actx.createGain();
this.filterNode = this.actx.createBiquadFilter();
然后创建一些属性,我们可以使用它们来定制声音对象的效果:
this.echo = false;
this.delayValue = 0.3;
this.feedbackValue = 0.3;
this.filterValue = 0;
让我们也创建一个名为setEcho 的方法,它将让我们设置延迟时间、反馈时间和效果的可选过滤。
setEcho(delayValue = 0.3, feedbackValue = 0.3, filterValue = 0) {
this.delayValue = delayValue;
this.feedbackValue = feedbackValue;
this.filterValue = filterValue;
this.echo = true;
}
我们可以使用echo ( true或false)的值来打开或关闭回声效果。为此,让我们将 echo 代码添加到Sound类的 play 方法中。所有新代码都突出显示。
play() {
this.startTime = this.actx.currentTime;
this.soundNode = this.actx.createBufferSource();
this.soundNode.buffer = this.buffer;
//Create the main node chain
this.soundNode.connect(this.volumeNode);
this.volumeNode.connect(this.panNode);
this.panNode.connect(this.actx.destination);
//Add optional echo
if (this.echo) {
//Set the values
this.feedbackNode.gain.value = this.feedbackValue;
this.delayNode.delayTime.value = this.delayValue;
this.filterNode.frequency.value = this.filterValue;
//Create the delay loop, with optional filtering
this.delayNode.connect(this.feedbackNode);
if (this.filterValue > 0) {
this.feedbackNode.connect(this.filterNode);
this.filterNode.connect(this.delayNode);
} else {
this.feedbackNode.connect(this.delayNode);
}
//Capture the sound from the main node chain, send it to the
//delay loop, and send the final echo effect to the `panNode`, which
//will then route it to the destination
this.volumeNode.connect(this.delayNode);
this.delayNode.connect(this.panNode);
}
this.soundNode.loop = this.loop;
this.soundNode.playbackRate.value = this.playbackRate;
this.soundNode.start(
this.startTime,
this.startOffset % this.buffer.duration
);
this.playing = true;
}
现在要创建任何声音的回声效果,使用声音的setEcho方法。提供设置延迟时间、反馈时间所需的值,如果要使用双二阶滤波器,还可以选择提供要滤波的频率上限。
let bounce = assets["sounds/bounce.mp3"];
bounce.setEcho(0.2, 0.5, 1000);
如果您需要在某个时候关闭回声效果,只需将声音的echo属性设置为false:
bounce.echo = false;
通过改变这些值,你可以为你的游戏创造出各种各样的回声效果。
混响
混响是一种模拟声音空间的效果,如房间、大教堂或空间洞穴。这是我们将要创建的最复杂的效果,它将让你更深入地了解网络音频 API 的一些更高级的工作方式。在我们讨论具体细节之前,让我们暂时停止编码,尝试一些理论知识,这样你就可以为即将到来的事情做好充分的准备。
那么什么是缓冲呢?
在这一章中,我一直在反复使用“缓冲”这个词。我之前提到过它是“原始音频文件”,但它实际上不止于此。您可以将缓冲区视为存储二进制数据的数组。数据是代表声音的 1 和 0。每个声音片段被称为一个样本。萨姆样本相当于图像中的像素,所以我喜欢把样本想象成“音频像素”这意味着您可以将缓冲区视为一个数组,其中的每个元素代表组成每段声音的最小单元。
缓冲器还包含通道。您可以将每个通道视为一个单独的数组,包含自己的声音样本。如果您有一个带两个通道的缓冲器,第一个通道可能包含左扬声器的样本,第二个通道可能包含右扬声器的样本。您可以像这样想象缓冲区:
buffer = [
[l0, l1, l2], //channel one sample data for the left speaker
[r0, r1, r2] //channel two sample data for the right speaker
];
所以缓冲区有点像多维数组,每个通道代表一个子数组。数组中的每个索引位置称为一个样本- 帧 。该示例缓冲器包含三个样本帧:l0和r0都在样本帧 0 上;l2和r2都在样品架 2 上。占据相同样本帧的样本将在相同的时间点播放。它们就像一条录音带上的独立音轨。单个缓冲区可以包含多达 32 个声道的音频数据。
注单声道的声音,在两个扬声器中是一样的,只使用一个声道。杜比 5.1 环绕声使用 5 个声道。四声道音响使用 4。
您可以随时使用 Web Audio API 的createBuffer 方法创建一个空的声音缓冲区。它有三个参数:通道数、样本帧中缓冲区的长度和采样率。
let emptyBuffer = actx.createBuffer(numberOfChannels, length, sampleRate);
通常你只需要两个声道,一个用于左扬声器,一个用于右扬声器。length定义了缓冲器有多少样本帧。sampleRate 是每秒播放的样本帧数。采样速率会影响声音的分辨率,采样速率越高,音频质量就越高。采样率以赫兹(Hz)为单位,必须在 22050 到 96000 的范围内。Web Audio API 的默认采样率通常是 44.1kHz,但这取决于运行代码的设备。如果像刚才看到的例子那样初始化一个空的缓冲区,通道数据将用零填充,表示静音。
提示你可以通过将帧数除以采样率,以秒为单位计算出声音缓冲区有多长。
我把缓冲区作为一个“数组”来帮助你形象化它,但这并不是它的确切含义。是的,我又说谎了!它实际上是一种叫做ArrayBuffer 的“类数组”数据类型。一个ArrayBuffer 只是一个二进制数据的 JavaScript 存储容器。然而,在缓冲区的getChannelData方法的帮助下,您可以将一个ArrayBuffer转换成一个真实的、可用的数组。以下是如何使用它将左右扬声器通道数据转换为数组:
let left = buffer.getChannelData(0),
right = buffer.getChannelData(1);
left和right现在是用音频数据打包的普通数组。(0 代表左声道,1 代表右声道。)您可以像处理任何普通数组一样处理它们。它们实际上是一种特殊的高分辨率阵列,叫做Float32Array 。但是不要为此担心——只要把它们想象成普通的数组,它们对于存储和访问二进制数据特别有效。
注意 Float32 数组也用于 WebGL 图形渲染。
最棒的是,你可以通过改变数组中的通道数据来改变缓冲区的声音。这也意味着你可以从纯代码中程序化地创造声音。只需使用一种算法来产生您想要的声音数据,并将其推入通道数据数组。这就是我们接下来要做的。
模拟声音混响
现在让我们回到混响!创造可信混响的诀窍是将两种声音结合在一起。第一个声音是你的原声,没有混响。第二种是在您想要模拟的声学空间中的中性声音(白噪音)的特殊记录:例如,房间、洞穴或剧院。这些特殊的记录被称为脉冲响应记录。然后你用一种叫做卷积器的音频处理器将这两种声音混合在一起。卷积器获取您的原始声音,将其与脉冲响应录音进行比较,并将两种声音组合在一起。结果是逼真的混响,听起来像你试图模拟的空间。
但是你从哪里得到模拟混响的脉冲响应声音呢?有数千种专业录制的脉冲响应录音可供使用,这些录音模拟了从吉他放大器音箱到电话扬声器,再到历史悠久的大教堂的各种声音。你也可以创造你自己的:只需带一个录音机到一个废弃的发电厂、工厂或精神病院,用手枪在空中开几枪。最好在凌晨 3 点做这件事。你会得到一个很好的声学空间的录音,你可以用卷积器,并在这个过程中有很多乐趣。
或者,如果您对吸引警察的注意力有所顾虑,您可以通过几行代码生成可配置的脉冲响应。下面列出的impulseResponse函数就是这样做的。它创建一个有两个通道的空缓冲区,并用随机噪声填充每个通道。一个简单的公式就能让噪音呈指数衰减,就像声音从房间的墙壁反射回来时自然衰减一样。您可以设定混响时间和衰减量,以模拟各种空间。定义脉冲响应的是指数衰减(不是白噪声),因此也是你的声学空间的表观大小。短暂的衰减造成声音发生在小空间的错觉,较长的衰减模拟更大的空间。impulseResponse功能也有一个reverse参数,如果true出现,就会产生一种怪异的反混响效果。
function impulseResponse(duration = 2, decay = 2, reverse = false) {
//The length of the buffer
//(The AudioContext's default sample rate is 44100)
let length = actx.sampleRate * duration;
//Create an audio buffer (an empty sound container) to store the reverb effect
let impulse = actx.createBuffer(2, length, actx.sampleRate);
//Use `getChannelData` to initialize empty arrays to store sound data for
//the left and right channels
let left = impulse.getChannelData(0),
right = impulse.getChannelData(1);
//Loop through each sample-frame and fill the channel
//data with random noise
for (let i = 0; i < length; i++){
//Apply the reverse effect, if `reverse` is `true`
let n;
if (reverse) {
n = length - i;
} else {
n = i;
}
//Fill the left and right channels with random white noise that
//decays exponentially
left[i] = (Math.random() * 2 - 1) * Math.pow(1 - n / length, decay);
right[i] = (Math.random() * 2 - 1) * Math.pow(1 - n / length, decay);
}
//Return the `impulse`
return impulse;
}
impulseResponse函数返回一个缓冲区,它是我们想要应用到声音中的混响效果的模型。但是我们实际上如何使用它呢?
首先,创建一个卷积器节点。这是专门的音频处理器,将普通声音与脉冲响应混合在一起,以创建最终的混响效果。
let convolverNode = actx.createConvolver();
然后将脉冲响应设置为卷积器自己的buffer。
convolverNode.buffer = impulseResponse(2, 2, false);
最后,将卷积器节点连接到您的声音链。
soundNode.connect(convolverNode);
convolverNode.connect(destination);
当声音通过卷积器时,它会将脉冲响应混合到声音中,从而产生逼真的混响效果。
向声音类添加混响功能
现在让我们更新我们的Sound类来添加一个可重用的混响特性,我们可以在任何声音上启用它。首先,在构造函数中创建卷积器节点,以及一些帮助我们控制效果的属性。
this.convolverNode = this.actx.createConvolver();
this.reverb = false;
this.reverbImpulse = null;
接下来,创建一个setReverb方法,让我们可以轻松地将混响应用到任何声音中。
setReverb(duration = 2, decay = 2, reverse = false) {
this.reverbImpulse = impulseResponse(duration, decay, reverse);
this.reverb = true;
}
然后,在play方法中,在音量和声相节点之间连接卷积器,并将脉冲响应应用于卷积器的缓冲器。如果reverb设置为false,效果将被旁路。这里是Sound类的play方法的第一部分,突出显示了所有新代码。
play() {
this.startTime = this.actx.currentTime;
this.soundNode = this.actx.createBufferSource();
this.soundNode.buffer = this.buffer;
//Connect all the nodes
this.soundNode.connect(this.volumeNode);
//If there's no reverb, bypass the convolverNode
if (this.reverb === false) {
this.volumeNode.connect(this.panNode);
}
//If there is reverb, connect the `convolverNode` and apply
//the impulse response
else {
this.volumeNode.connect(this.convolverNode);
this.convolverNode.connect(this.panNode);
this.convolverNode.buffer = this.reverbImpulse;
}
this.panNode.connect(this.actx.destination);
//... the rest of the `play` method is the same
}
现在,您可以使用setReverb方法将定制的混响应用到任何声音,语法如下:
let music = assets["sounds/music.wav"];
music.setReverb(2, 5, false);
如果您稍后需要关闭混响,请将声音的reverb属性设置为false:
music.reverb = false;
尝试不同的持续时间和延迟设置,你将能够产生各种各样的效果。如果是万圣节,就把reverse参数设为true!
提示运行章节源文件中的specialEffects.html文件,体验所有这些新特性。请务必查看library/Sound模块中的完整的Sound类,以查看完整上下文中的所有代码。
合成声音
到目前为止,在这一章中,我们已经加载和控制的所有声音都是预先录制的音频文件。但是网络音频 API 也可以让你创造全新的声音,用一个多功能的振荡器节点。振荡器以你选择的任何音高产生音调。它也有一堆有用的属性,你可以设置来塑造这种基调。您可以将振荡器连接到 Web Audio API 的任何其他节点,如延迟节点或卷积器,为游戏创建几乎无限丰富的声音频谱。在本章的最后一节,我将首先向您介绍创建和使用振荡器的基础,然后我们将构建一个简单的SoundEffect类,您可以使用它作为构建各种不同游戏声音的基础。
用振荡器制作和播放声音非常容易。下面是你需要的最基本的代码:
//Create the audio context
let actx = new AudioContext();
//Create a new oscillator
let oscillator = actx.createOscillator();
//Connect it to the destination
oscillator.connect(actx.destination);
//Make it play
oscillator.start(actx.currentTime);
(使用stop方法停止振荡器。)
如果您运行这段代码,它会产生一个默认为 200 Hz 的音调(这是一个稍高的 g 音),您可以通过设置它的frequency.value属性来更改振荡器播放的音符。下面是如何让它播放中 A (440 Hz):
oscillator.frequency.value = 440;
振荡器还有一个detune属性 ,它是一个以分为单位的值,用来抵消频率。
您可以通过设置其type属性来更改振荡器音调所基于的基本波形模式,该属性是一个可以设置为"sine"、"triangle"、"square"或"sawtooth"的字符串。
oscillator.type = "sawtooth";
每种波形类型 都会产生越来越刺耳的音调。如果您想要一个真正平滑、类似钟声的音调,请使用"sine"。如果你仍然想要一个平滑的音调,但带有一点砂砾感,试试"triangle"。"square"开始发出有点刺耳的声音,其中"sawtooth"发出的声音最为刺耳。
注意这四种基本的波形类型可能就是你为游戏生成大多数声音所需要的全部。但是您可以借助createPeriodicWave和setPeriodicWave方法创建自己的定制波形。你可以将它们与称为傅立叶变换 的特殊数据阵列一起使用,来模拟各种各样的音调,比如不同的乐器。有关更多详细信息,请参见 Web Audio API 规范。
为了向您展示使用振荡器制作真正有用的东西是多么的简单,让我们把您的电脑变成一件乐器。
制作音乐
我们将创建一个迷你应用程序,让您使用键盘的数字键弹奏五个音符。我们将从一个叫做playNote 的可重用函数开始,它可以让你弹奏任何音符值。它会创建并播放一个音符,听起来就像你在摆弄过的电子键盘上按下任何一个键时所期待的一样。您可以设置音符的值(以赫兹为单位的频率)、波形类型及其decay。decay值决定音符从最大音量渐隐到静音的时长。playNote函数基本上只是我们刚才看到的代码的可重用包装器:它创建振荡器和音量节点,将它们连接到目的地,并使用振荡器的值来播放声音。(这段代码中唯一的新东西是用于淡出音符的技术——但我将在后面解释它是如何工作的。)
function playNote(frequency, decay = 1, type = "sine") {
//Create an oscillator and a gain node, and connect them
//together to the destination
let oscillator = actx.createOscillator(),
volume = actx.createGain();
oscillator.connect(volume);
volume.connect(actx.destination);
//Set the oscillator's wave form pattern
oscillator.type = type;
//Set the note value
oscillator.frequency.value = frequency;
//Fade the sound out
volume.gain.linearRampToValueAtTime(1, actx.currentTime);
volume.gain.linearRampToValueAtTime(0, actx.currentTime + decay);
//Make it play
oscillator.start(actx.currentTime)
}
您可以使用playNote以任何频率弹奏音符,如下所示:
playNote(440, 2, "square");
淡出效果
第二个参数decay决定音符淡出时可以听到多长时间。淡出效果 是使用这两行代码创建的:
volume.gain.linearRampToValueAtTime(1, actx.currentTime);
volume.gain.linearRampToValueAtTime(0, actx.currentTime + decay);
linearRampToValueAtTime是一个非常有用的内置函数,允许您随时更改任何节点值。在本例中,它将音量的值从最大音量(1)更改为静音(0 ),时间从currentTime开始,到decay设置的值结束。你可以看到你需要使用linearRampToValueAtTime两次来创建完整的淡出效果。第一次使用设置开始音量和开始时间。第二个设置其结束音量和结束时间。Web Audio API 的引擎会自动为您插入所有中间值,并为您提供完全平滑的音量过渡。
以下是可用于随时更改任何节点值的基本格式:
nodeProperty.linearRampToValueAtTime(startValue, startTime);
nodeProperty.linearRampToValueAtTime(endValue, endTime);
它适用于任何节点值,包括频率,因此您可以使用linearRampToValueAtTime为游戏创建各种不同的效果。
注 linearRampToValueAtTime线性改变一个值:以均匀、渐进的方式。如果您希望该值呈指数变化,请使用exponentialRampToValueAtTime。指数变化逐渐开始,然后迅速下降。自然界中的许多声音在数值上有指数变化。
播放音符
剩下唯一要做的事情就是将playNote函数与某种事件挂钩。下面是捕获 1 到 5 数字键的键盘事件的代码。然后,当按下:D 时,它会以正确的音符频率调用playNote函数,例如,G、A 或 c。(如果你很好奇,你只需要这五个音符来演奏一首古印度古典 raga,名为 megh ,意思是云。它们以任何组合放在一起听起来都不错。)
//Capture keyboard events for the number keys 1 to 5
let one = keyboard(49),
two = keyboard(50),
three = keyboard(51),
four = keyboard(52),
five = keyboard(53);
//Define the note values
let D = 293.66,
E = 329.63,
G = 392.00,
A = 440.00,
C = 523.25;
//D
one.press = () => {
playNote(D, 1);
};
//E
two.press = () => {
playNote(E, 1);
}
//G
three.press = () => {
playNote(G, 1);
}
//A
four.press = () => {
playNote(A, 1)
}
//C
five.press = () => {
playNote(C, 1);
}
提示在网上快速搜索会出现许多图表,向你展示如何将赫兹频率值转换成真实的音符值。
几乎没有任何麻烦,你已经把你的电脑键盘变成了一种乐器!你可以轻松地在游戏中加入音乐效果。在一个平台游戏中,当一个角色跳到积木上时,制作积木或者一个简单的随机音乐生成器怎么样?玩家一边探索游戏世界一边作曲的游戏怎么样?你现在离进入一个全新的基于音乐的游戏只有几步之遥,所以去吧!
而这还差不多!振荡器真的没有什么复杂的——当你开始以富有想象力的方式将它们与我们在本章中使用的其他节点连接起来时,真正的乐趣就开始了。通过一点点的实验,你很快就会意识到你已经有了一个完整的音乐和音效合成器,它几乎有无限的潜力来创造你可能需要的任何游戏声音。
真的吗?是的,让我们来看看怎么做!
产生声音效果
想象一下:一个单一的、可重复使用的函数,在不到 150 行代码中,可以生成你可能需要的任何游戏音效或音符,而不必下载任何声音文件。多亏了 Web Audio API,这片乐土才成为可能,这也正是我们下一步要做的:一个通用的音效生成器,可以产生你在游戏中可能需要的几乎任何声音。
我们将要构建的音效函数被称为soundEffect。它有 13 个低级参数,你可以设置它们来创造大量有用的音调。在我们研究这个函数如何发挥其魔力的所有细节之前,让我们先来看看如何以一种实用的方式使用它。这里有一个使用它的模型,包括每个参数的功能描述。
soundEffect(
frequencyValue, //The sound's frequency pitch in Hertz
attack, //The time, in seconds, to fade the sound in
decay, //The time, in seconds, to fade the sound out
type, //waveform type: "sine", "triangle", "square", or "sawtooth"
volumeValue, //The sound's maximum volume
panValue, //The speaker pan. left: -1, middle: 0, right: 1
wait, //The time, in seconds, to wait before playing the sound
pitchBendAmount, //A frequency amount, in Hz, to bend the sound's pitch down
reverse, //If `reverse` is true the pitch will bend up
randomValue, //A range, in Hz., within which to randomize the pitch
dissonance, //A value in Hz. Creates 2 additional dissonant frequencies
echo, //An array: [delayTime, feedbackTime, filterValue]
reverb //An array: [duration, decayRate, reverse?]
);
使用这个soundEffect函数的策略是修改所有这些参数,并为游戏创建你自己的自定义音效库。你可以把它想象成一个巨大的共鸣板,上面有 13 个彩色闪烁的转盘,你可以尽情玩耍。想象你是一个疯狂的科学家,而 13 是你的幸运数字!
要了解如何设置这些参数来创建您想要的声音,让我们尝试使用soundEffect来产生四种多功能的游戏声音:激光射击声音、跳跃声音、爆炸声音和音乐主题。(运行本章源文件中的soundEffects.html文件,获得该代码的工作示例,如图图 9-3 所示。)

图 9-3 。从纯代码中生成自定义声音效果
射击声
以下是如何使用soundEffect功能创建典型激光拍摄声音的示例:
function shootSound() {
soundEffect(
1046.5, //frequency
0, //attack
0.3, //decay
"sawtooth", //waveform
1, //Volume
-0.8, //pan
0, //wait before playing
1200, //pitch bend amount
false, //reverse bend
0, //random frequency range
25, //dissonance
[0.2, 0.2, 2000], //echo array: [delay, feedback, filter]
undefined //reverb array: [duration, decay, reverse?]
);
}
“锯齿”波形设置赋予声音一种刺骨的刺耳感。pitchBendAmount是 1200,这意味着声音的频率从头到尾下降了 1200 Hz。这听起来就像你看过的所有科幻电影中的每一束激光。dissonance值为 25 意味着在主频上下 25 Hz 的声音中增加了两个额外的泛音。这些额外的泛音增加了音调的复杂性。
因为soundEffect函数被包装在一个自定义的shootSound函数中,所以您可以随时在您的应用程序代码中播放该效果,如下所示:
shootSound();
它会立即播放。
跳跃的声音
让我们看另一个例子。这里有一个 jumpSound 函数产生一个典型的平台游戏——角色跳跃的声音。
function jumpSound() {
soundEffect(
523.25, //frequency
0.05, //attack
0.2, //decay
"sine", //waveform
3, //volume
0.8, //pan
0, //wait before playing
600, //pitch bend amount
true, //reverse
100, //random pitch range
0, //dissonance
undefined, //echo array: [delay, feedback, filter]
undefined //reverb array: [duration, decay, reverse?]
);
}
jumpSound的attack值为 0.05,这意味着声音会快速淡入。它太快了,你真的听不到,但它巧妙地柔化了声音的开始。reverse值为true,表示音高向上弯曲而不是向下。(这是有道理的,因为跳字是向上跳的。)的randomValue是 100。这意味着音高将在目标频率周围 100 Hz 的范围内随机变化,因此声音的音高每次都会略有不同。这增加了声音的趣味性,让游戏世界充满活力。
爆炸的声音
只需调整相同的参数,您就可以创建完全不同的explosionSound效果:
function explosionSound() {
soundEffect(
16, //frequency
0, //attack
1, //decay
"sawtooth", //waveform
1, //volume
0, //pan
0, //wait before playing
0, //pitch bend amount
false, //reverse
0, //random pitch range
50, //dissonance
undefined, //echo array: [delay, feedback, filter]
undefined //reverb array: [duration, decay, reverse?]
);
}
这会产生低频隆隆声。爆音的起点是将frequency值设置得极低:16 Hz。它还有一个粗糙的"sawtooth"波形。但是让它真正起作用的是 50 的dissonance值。这增加了两个泛音,高于和低于目标频率 50 Hz,它们相互干扰并干扰主声音。
音乐主题
但不仅仅是为了音效!您可以使用soundEffect功能创建音符,并以设定的间隔播放它们。这里有一个名为bonusSound的功能,它以升调顺序播放三个音符(D、A 和高音 D)。这是典型的音乐主题,当游戏角色获得一些奖励点数时,你可能会听到,比如捡星星或硬币。(听到这个声音,你可能会闪回 1985 年!)
function bonusSound() {
//D
soundEffect(587.33, 0, 0.2, "square", 1, 0, 0);
//A
soundEffect(880, 0, 0.2, "square", 1, 0, 0.1);
//High D
soundEffect(1174.66, 0, 0.3, "square", 1, 0, 0.2);
}
让它工作的关键是最后一个参数:值wait(在刚刚显示的代码中突出显示)。第一个声音的wait值为 0,这意味着声音将立即播放。第二个声音的wait值是 0.1,这意味着它将在延迟 100 毫秒后播放。最后一个声音的wait值为 0.2,会让它在 200 毫秒内播放。这意味着所有三个音符按顺序播放,它们之间有 100 毫秒的间隔。
只需多做一点工作,您就可以使用wait参数来构建一个简单的音乐音序器,并构建您自己的音乐音效迷你库来演奏音符。
完整的soundEffect功能
这里是完整的soundEffect函数,带有解释其工作原理的注释。正如你将看到的,这是你在这一章中学到的所有技术的混合。(你会在library/sound.js文件中找到这个soundEffect函数。)
function soundEffect(
frequencyValue,
attack = 0,
decay = 1,
type = "sine",
volumeValue = 1,
panValue = 0,
wait = 0,
pitchBendAmount = 0,
reverse = false,
randomValue = 0,
dissonance = 0,
echo = undefined,
reverb = undefined
) {
//Create oscillator, gain and pan nodes, and connect them
//together to the destination
let oscillator = actx.createOscillator(),
volume = actx.createGain(),
pan = actx.createStereoPanner();
oscillator.connect(volume);
volume.connect(pan);
pan.connect(actx.destination);
//Set the supplied values
volume.gain.value = volumeValue;
pan.pan.value = panValue;
oscillator.type = type;
//Optionally randomize the pitch. If the `randomValue` is greater
//than zero, a random pitch is selected that's within the range
//specified by `frequencyValue`. The random pitch will be either
//above or below the target frequency.
let frequency;
let randomInt = (min, max) => {
return Math.floor(Math.random() * (max - min+ 1)) + min;
}
if (randomValue > 0) {
frequency = randomInt(
frequencyValue - randomValue / 2,
frequencyValue + randomValue / 2
);
} else {
frequency = frequencyValue;
}
oscillator.frequency.value = frequency;
//Apply effects
if (attack > 0) fadeIn(volume);
if (decay > 0) fadeOut(volume);
if (pitchBendAmount > 0) pitchBend(oscillator);
if (echo) addEcho(volume);
if (reverb) addReverb(volume);
if (dissonance > 0) addDissonance();
//Play the sound
play(oscillator);
//The helper functions:
//Reverb
function addReverb(volumeNode) {
let convolver = actx.createConvolver();
convolver.buffer = impulseResponse(reverb[0], reverb[1], reverb[2]);
volumeNode.connect(convolver);
convolver.connect(pan);
}
//Echo
function addEcho(volumeNode) {
//Create the nodes
let feedback = actx.createGain(),
delay = actx.createDelay(),
filter = actx.createBiquadFilter();
//Set their values (delay time, feedback time, and filter frequency)
delay.delayTime.value = echo[0];
feedback.gain.value = echo[1];
if (echo[2]) filter.frequency.value = echo[2];
//Create the delay feedback loop, with
//optional filtering
delay.connect(feedback);
if (echo[2]) {
feedback.connect(filter);
filter.connect(delay);
} else {
feedback.connect(delay);
}
//Connect the delay loop to the oscillator's volume
//node, and then to the destination
volumeNode.connect(delay);
//Connect the delay loop to the main sound chain's
//pan node, so that the echo effect is directed to
//the correct speaker
delay.connect(pan);
}
//Fade in (the sound's "attack")
function fadeIn(volumeNode) {
//Set the volume to 0 so that you can fade in from silence
volumeNode.gain.value = 0;
volumeNode.gain.linearRampToValueAtTime(
0, actx.currentTime + wait
);
volumeNode.gain.linearRampToValueAtTime(
volumeValue, actx.currentTime + wait + attack
);
}
//Fade out (the sound’s "decay")
function fadeOut(volumeNode) {
volumeNode.gain.linearRampToValueAtTime(
volumeValue, actx.currentTime + attack + wait
);
volumeNode.gain.linearRampToValueAtTime(
0, actx.currentTime + wait + attack + decay
);
}
//Pitch bend.
//Uses `linearRampToValueAtTime` to bend the sound's frequency up or down
function pitchBend(oscillatorNode) {
//Get the frequency of the current oscillator
let frequency = oscillatorNode.frequency.value;
//If `reverse` is true, make the sound drop in pitch.
//(Useful for shooting sounds)
if (!reverse) {
oscillatorNode.frequency.linearRampToValueAtTime(
frequency,
actx.currentTime + wait
);
oscillatorNode.frequency.linearRampToValueAtTime(
frequency - pitchBendAmount,
actx.currentTime + wait + attack + decay
);
}
//If `reverse` is false, make the note rise in pitch.
//(Useful for jumping sounds)
else {
oscillatorNode.frequency.linearRampToValueAtTime(
frequency,
actx.currentTime + wait
);
oscillatorNode.frequency.linearRampToValueAtTime(
frequency + pitchBendAmount,
actx.currentTime + wait + attack + decay
);
}
}
//Dissonance
function addDissonance() {
//Create two more oscillators and gain nodes
let d1 = actx.createOscillator(),
d2 = actx.createOscillator(),
d1Volume = actx.createGain(),
d2Volume = actx.createGain();
//Set the volume to the `volumeValue`
d1Volume.gain.value = volumeValue;
d2Volume.gain.value = volumeValue;
//Connect the oscillators to the gain and destination nodes
d1.connect(d1Volume);
d1Volume.connect(actx.destination);
d2.connect(d2Volume);
d2Volume.connect(actx.destination);
//Set the waveform to "sawtooth" for a harsh effect
d1.type = "sawtooth";
d2.type = "sawtooth";
//Make the two oscillators play at frequencies above and
//below the main sound's frequency. Use whatever value was
//supplied by the `dissonance` argument
d1.frequency.value = frequency + dissonance;
d2.frequency.value = frequency - dissonance;
//Apply effects to the gain and oscillator
//nodes to match the effects on the main sound
if (attack > 0) {
fadeIn(d1Volume);
fadeIn(d2Volume);
}
if (decay > 0) {
fadeOut(d1Volume);
fadeOut(d2Volume);
}
if (pitchBendAmount > 0) {
pitchBend(d1);
pitchBend(d2);
}
if (echo) {
addEcho(d1Volume);
addEcho(d2Volume);
}
if (reverb) {
addReverb(d1Volume);
addReverb(d2Volume);
}
//Play the sounds
play(d1);
play(d2);
}
//The `play` function that starts the oscillators
function play(oscillatorNode) {
oscillatorNode.start(actx.currentTime + wait);
}
}
总结
您现在有了一套强大的新工具来为游戏添加声音。您已经学习了使用 Web Audio API 加载和播放声音的所有基础知识,并且有了一个有用的Sound类,您可以将它用于游戏中的所有音乐和声音效果。您还学习了如何使用assets对象加载和管理声音,以及如何使用通用的soundEffect函数从头开始生成声音。
但是你如何在游戏中使用声音呢?你将在第十一章中学习如何做。但是在你这么做之前,让我们看看你需要知道的一个更重要的工具来完成你的游戏设计工具包:补间。`
十、补间
补间是一种动画技术,你可以用它来让精灵以非常特殊的方式改变它的外观或位置。您可以使用补间使精灵沿着固定的路径或曲线移动,或者使精灵淡入、淡出、脉动或摆动。使用补间效果将会给你的游戏增加一个全新的互动维度和参与度,让它们以新的和令人兴奋的方式变得生动起来。
补间与你在本书中学习的其他移动精灵的方法有什么不同?在第五章中,你学习了如何使用速度和物理属性让精灵移动。这对于制作像弹跳球这样需要在每一帧对不断变化的游戏环境做出反应的东西来说是非常棒的。但有时你只是想告诉你的小精灵们“去那里,然后回来,永远重复这样的话。”补间就像给你的精灵一个可预测的、固定的、不变的运动脚本,它不受游戏物理特性的影响。就像一列在轨道上行驶的火车;它总是沿着相同的路线,在相同的时间停在每个车站。补间对于处理游戏动画中一些比较繁琐的方面特别有用,比如制作用户界面动画。它非常适合制作标题和按钮滑动或淡入淡出,也适合创建游戏场景之间的过渡。一般来说,当你想要实现一个快速运动特效的时候,你可以使用基于物理的运动来制作你的主精灵动画。
单词 tween 来自“在中间”动画师用这个词来描述动画对象在开始点和结束点之间的位置。如果您知道点 A 和点 B,并且您知道对象在这两点之间移动需要多长时间,您可以使用补间来计算所有这些中间点的位置。
提示这听起来耳熟吗?是的,它是!在第五章中,你学习了插值的概念——补间和插值是一回事。当你和你的编程朋友聊天时使用“插值”,和你的动画师朋友聊天时使用“补间”。
在这一章中,你将深入了解如何从头开始实现游戏的补间技术,包括:
- 用于补间任何 sprite 属性(或任何其他值)的低级公式和过程。
- 缓和:随着时间的推移,逐渐使精灵加速或减速到特定的目的地。
- 运动路径:使精灵沿直线或曲线路径移动。
您还将学习如何构建一些有用的可重用组件,让您可以轻松地将缓动效果应用到游戏中的任何精灵。
缓动和插值
缓动是一种补间效果,可创建从一种状态或位置到另一种状态或位置的平滑过渡。假设您有一个精灵,您想在 60 帧(一秒钟)的时间内从画布的左侧到右侧制作动画。它应该慢慢开始,逐渐加速,然后减速到停止。图 10-1 说明了你想要达到的效果。

图 10-1 。使用缓动来使精灵的位置变得平滑
如果你只知道精灵的startValue (32),它的endValue (400),和动画应该采取的totalTime (60 帧),你怎么能算出中间的位置呢?
诀窍是将精灵移动所花费的时间转换成一个 0 到 1 之间的数字。这被称为归一化时间。你可以用这个简单的公式算出来:
normalizedTime = currentTime / totalTime;
normalizedTime是一个神奇的数字。您可以使用它来创建一整套缓动功能,以产生各种不同的效果。你所需要做的就是获取normalizedTime值,将其放入一个专门的缓动函数中,并将结果应用回精灵的位置。在前面的页面中,您将了解到许多这样的缓解功能——但是让我们快速地用一个实际的例子来说明。
应用缓动功能
你需要知道的最有用的缓和函数叫做平滑步骤。如果你想让一个精灵以自然的方式加速和减速,smoothstep正在等待你的命令。就是这个:
smoothstep = x => x * x * (3 - 2 * x);
不要让它吓到你!这只是一个普通的函数,它接受一个参数x,对其应用一些数学运算,然后返回结果。你将要学习的所有缓动函数都将遵循相同的格式。
smoothstep函数中的数学是做什么的?它只是描绘了一条曲线。图 10-2 显示了这条曲线的样子。

图 10-2 。smoothstep功能描述了一条令人愉快的曲线
如果您将该曲线应用到normalizedTime,您可以控制时间的流动以匹配曲线的形状。下面是如何做到这一点:
curvedTime = smoothstep(normalizedTime);
有了这个声明,时间将开始变慢,在中途加速,然后在接近结束时又变慢。就像水呈现出倒入其中的容器的形状一样,时间呈现出你使用的任何缓和函数的形状。
当你有了曲线时间值,你可以用它来插值精灵的 x 位置,计算如下:
sprite.x = (endValue * curvedTime) + (startValue * (1 - curvedTime));
这个公式使用curvedTime来计算精灵在当前帧的位置。它将规范化(0 到 1)值扩展回 sprite 可以使用的实数。如果你不断循环更新curvedTime,精灵会加速和减速来匹配你应用的曲线。
提示这个基本的技巧会让你插值任意两个值;不仅仅是针对精灵属性!
行动缓和
我给你们看的这些代码都发生在一个循环中。它可以是任何类型的循环(比如for循环),但是因为我们正在制作游戏,所以我们将使用游戏循环。因此,让我们来看看你的代码需要什么样的从画布的左侧到右侧补间猫精灵,以产生如图 10-1 所示的效果。
首先,在游戏的setup函数中,创建你需要的变量:
totalFrames = 60;
frameCounter = 0;
startValue = cat.x;
endValue = 400;
smoothstep = x => x * x * (3 - 2 * x);
totalFrames值是动画的整个持续时间。frameCounter将用于计算经过的帧数,这样当动画到达totalFrames时你可以停止动画。startValue和endValue定义了动画的起点和终点。
gameLoop计算帧数并运行我们在第一部分中看到的代码:
function gameLoop() {
requestAnimationFrame(gameLoop);
//Run the animation while `frameCounter` is less than `totalFrames`
if (frameCounter < totalFrames) {
//Find the normalized time value
let normalizedTime = frameCounter / totalFrames;
//Apply the easing function
let curvedTime = smoothstep(normalizedTime);
//Interpolate the sprite's x position based on the curved time
cat.x = (endValue * curvedTime) + (startValue * (1 - curvedTime));
//Add 1 to the frame counter
frameCounter += 1;
}
//Render the canvas
render(canvas);
}
仅此而已!您可以使用相同的技术来补间任何 sprite 属性— alpha、width、height等等。如果你补间猫的scaleX和scaleY属性会发生什么?我们来看看。
首先,设置补间的开始和结束值。我们想让猫的体型从 1(正常大小)扩大到 2(两倍大小。)
startValue = 1;
endValue = 2;
然后用这两行新代码替换插值代码:
cat.scaleX = (endValue * curvedTime) + (startValue * (1 - curvedTime));
cat.scaleY = (endValue * curvedTime) + (startValue * (1 - curvedTime));
图 10-3 显示了发生的情况。这只猫在一秒钟内平稳地膨胀到两倍大。看起来像魔术,其实只是简单的数学!

图 10-3 。通过补间其比例来平滑地将精灵膨胀到两倍大小
经典缓动功能
您可以完全改变缓动效果的风格,只需添加不同的缓动功能。我们在第一个例子中使用了smoothstep,但是还有更多公式可供选择。让我们来做一个最棒的旅行,看看制作游戏最有用的一些放松功能。
线性的
巡演的第一站是最简单的公式:线性缓和。它所做的只是完全不变地返回normalizedTime。
let linear = x => x;
它什么也不做!它只是返回你输入的相同的值,没有改变。就好像你根本没用缓动功能一样;结果只是一条直线(图 10-4 )。

图 10-4 。线性放松只是一条直线
如果您使用线性缓动来补间精灵的位置,精灵将开始全速移动,然后突然停止。雪碧没有逐渐加速或减速。如果这听起来并不有趣,那是因为它并不有趣!我在这里包括了线性缓动,因为这是学习理解这些函数如何工作的第一步,但是我的建议是不要在真实的游戏中使用它。没有什么比线性宽松更大声地呼喊“学生游戏”了!
但幸运的是,线性放松有一些更有趣的兄弟姐妹:加速和减速。
加速度
通过将normalizedTime值(x)乘以自身,您可以将枯燥的线性缓动变为激动人心的加速:
let acceleration = x => x * x;
这是一个缓慢开始然后逐渐加速的缓和效果,如图图 10-5 中的图形所示。

图 10-5 。逐渐加速
当你把一个值乘以它自己,它被称为一个平方值。JavaScript 有一种方便的方法来帮助您使用Math.pow函数计算平方值(pow代表“的幂”)。Math.pow有两个参数:初始值,以及该值自身相乘的次数(指数)。
Math.pow(initialValue, exponent);
这意味着你也可以这样写acceleration函数:
let acceleration = x => Math.pow(x, 2);
如果你把相同的值再乘以一次,你会得到一个的立方值。下面是accelerationCubed函数:
let accelerationCubed = x => Math.pow(x, 3);
效果类似普通加速,但更极端,如图图 10-6 。

图 10-6 。立方加速度是一个更夸张的效果
减速
减速与加速相反:开始很快,然后逐渐减速直至停止。该公式正好与加速度公式相反:
let deceleration = x => 1 - Math.pow(1 - x, 2);
正如加速一样,减速也有一个立方版本,它夸大了效果:
let decelerationCubed = x => 1 - Math.pow(1 - x, 3);
图 10-7 是普通减速和立方减速的对比图。

图 10-7 。减速开始时很快,然后逐渐减速直至停止
平滑步骤
整场秀的明星是smoothstep公式。这是一个非常令人愉快、看起来自然的过渡,适用于任何类型的补间。除了你已经看到的标准公式,smoothstep也有平方和立方版本,它们将效果增强到额外的程度。这是所有三个smoothstep功能,你可以在图 10-8 中看到。
let smoothstep = x => x * x * (3 - 2 * x);
let smoothstepSquared = x => Math.pow((x * x * (3 - 2 * x)), 2);
let smoothstepCubed = x => Math.pow((x * x * (3 - 2 * x)), 3);

图 10-8 。Smoothstep 可产生均衡且自然的效果
如有疑问,请使用 smoothstepping!它可以将任何游戏的外观从“学生”变成“专业”
正弦
正弦曲线给你一个稍微圆一点的减速效果。对于一个温和的加速度,使用反正弦。
let sine = x => Math.sin(x * Math.PI / 2);
let inverseSine = x => 1 - Math.sin((1 - x) * Math.PI / 2);
图 10-9 显示了这些曲线的样子。

图 10-9 。使用正弦曲线进行平缓的加速和减速
这两个公式还有平方和立方版本,每一个都成比例地放大了曲线:
let sineSquared = x => Math.pow(Math.sin(x * Math.PI / 2), 2);
let sineCubed = x => Math.pow(Math.sin(x * Math.PI / 2), 2);
let inverseSineSquared = x => 1 - Math.pow(Math.sin((1 - x) * Math.PI / 2), 2);
let inverseSineCubed = x => 1 - Math.pow(Math.sin((1 - x) * Math.PI / 2), 3);
所有这些公式实际上只使用了正弦曲线的一半。如果你使用完整的曲线,你会得到一个和 smoothstep 几乎一样的形状。
let sineComplete = x => 0.5 - Math.cos(-x * Math.PI) * 0.5;
然而,这种方法比 smoothstep 公式的计算量大得多,所以通常不需要使用它。
齿条
到目前为止,我们看到的所有公式都只是在两点之间的一个值:0 和 1。但是有时引入两个超出这个范围的点是有用的。这使您可以创建一个补间,在值稳定之前添加一点反弹或抖动。你可以借助一条叫做样条的数学曲线来做到这一点。您可以将样条视为一条沿您定义的点弯曲的线。
有许多公式可以用来生成样条曲线,但是对于游戏来说,一个特别有效的公式是 Catmull-Rom 样条曲线。公式如下:
let spline = (t, a, b, c, d) => {
return 0.5 * (
(2 * b) +
(-a + c) * t +
(2 * a - 5 * b + 4 * c - d) * t * t +
(-a + 3 * b - 3 * c + d) * t * t * t
);
}
你和我都不需要知道为什么这个公式有效——我们只需要发一个大大的“谢谢!”敬 Catmull 和 Rom 为我们解决了这个问题。你真正需要知道的是,这个公式会产生四个你可以控制的点。自变量t是normalizedTime,a,b,c,d是样条的四个点。
下面是如何在我们当前的补间设置中使用样条线:
let curvedTime = spline(normalizedTime, 10, 0, 1, -10);
最后四个参数代表样条的四个点。中间的两个 0 和 1 表示基本补间范围:
10, 0, 1, -10
一般来说,不要将 0 和 1 更改为任何其他值,因为我们的补间系统使用的归一化时间值也在 0 和 1 之间。
您应该更改的数字是第一点和最后一点,即 10 和–10。
10, 0, 1, -10
那些是控制点。它们决定补间偏离 0 到 1 范围的程度。第一个数字 10 是补间开始时的散度,最后一个数字–10 是结束时的散度。给它们更高的值会使效果更戏剧化,给它们更低的值会使效果不那么戏剧化。
图 10-10 显示了该样条曲线绘制后的样子。

图 10-10 。使用样条线在 0 到 1 范围之外补间精灵
你可以在这张图上看到,曲线从 0 开始,然后移动到几乎-0.1。然后它向上弯曲到大约 1.1,然后稳定在 1。
当您补间一个精灵时,这有什么影响?图 10-11 展示了当你使用样条曲线来补间猫的 x 位置时会发生什么。这是一种弹性反弹效应。精灵向左摆动,向右反弹,稍微超出终点,然后到达目的地。这只发生在 x 轴上,所以猫沿着直线来回移动。

图 10-11 。对精灵的位置应用样条线以获得弹性反弹效果
你可以通过改变样条控制点的值来创建一系列不同的效果:第一个点值和最后一个点值。例如,如果将第一个点更改为 0,并将最后一个点保持在–10,则弹性反弹只会在猫运动结束时发生。
let curvedTime = spline(normalizedTime, 0, 0, 1, -10);
在你的日常游戏设计中,你很少需要在大多数补间中使用样条线。但对于某些特殊效果来说是必不可少的。在这一章的后面,你将学习如何使用样条线来产生戏剧性的果冻抖动效果。
加权平均值
如果你的目的值在每一帧不断变化,考虑使用weightedAverage功能。其效果与你在第六章中学到的缓解公式相同。
let weightedAverage = (p, d, w) => ((p * (w - 1)) + d) / w;
参数p是精灵属性值,d是目标值,w是添加到效果中的权重的数量。权重决定了放松的快慢。权重值在 5 到 50 之间是一个很好的起点;然后你可以调整这个数字来微调放松的感觉。
与本章中的其他补间函数不同,weightedAverage不要求您计算归一化时间或对其应用任何曲线函数。把它放在你游戏循环的任何地方。
function gameLoop() {
requestAnimationFrame(gameLoop);
cat.x = weightedAverage(cat.x, endValue, 30);
render(canvas);
}
是的,这只是简单宽松的另一种计算方式。
沿曲线运动
到目前为止,我们看到的曲线都有助于修改精灵的属性如何随时间变化。但是你也可以使用曲线来修改精灵在空间中移动的方式。贝塞尔曲线非常适合这样做。这是经典的三次贝塞尔公式:
function cubicBezier(t, a, b, c, d) {
var t2 = t * t;
var t3 = t2 * t;
return a
+ (-a * 3 + t * (3 * a - a * t)) * t
+ (3 * b + t * (-6 * b + b * 3 * t)) * t
+ (c * 3 - c * 3 * t) * t2 + d * t3;
}
这只是一种你可以设置的有四个点的样条曲线。自变量t是normalizedTime,a,b,c,d是样条的四个点。
你可以把贝塞尔曲线想象成一条在起点和终点a和d之间延伸的直线。点b和c是决定线弯曲程度的控制点。你可以把 b 和 c 想象成强力磁铁,它们拉着线使之弯曲。贝塞尔曲线的形状取决于 b 点和 c 点的位置。图 10-12 显示了b和c控制点如何扭曲在a和d之间运行的直线。

图 10-12 。贝塞尔曲线
有了我们设置好的补间系统,你可以很容易地让精灵沿着这条曲线移动。只需将cubicBezier函数的返回值应用到精灵的 x/y 位置。另外,您还可以选择对其应用任何缓动功能。以下是如何:
let curvedTime = smoothstep(normalizedTime);
cat.x = cubicBezier(curvedTime, startX, bX, cX, endX);
cat.y = cubicBezier(curvedTime, startY, bY, cY, endY);
如果您在我们在本章开始时编写的补间引擎中运行这段代码,猫将从起点到终点平滑地形成弧线。
这是达到这种效果的完整的gameLoop,图 10-13 说明了结果。
function gameLoop() {
requestAnimationFrame(gameLoop);
//Run the animation while `frameCounter` is less than `totalFrames`
if (frameCounter < totalFrames) {
//Find the normalized time value
let normalizedTime = frameCounter / totalFrames;
//Optionally apply an easing formula
let curvedTime = smoothstep(normalizedTime);
//Make the sprite follow a Bezier curve
cat.x = cubicBezier(curvedTime, 25, 100, 175, 225);
cat.y = cubicBezier(curvedTime, 250, 50, 0, 250);
//Add 1 to the frame counter
frameCounter += 1;
}
//Render the canvas
render(canvas);
}

图 10-13 。让精灵跟随曲线
如果您不想应用任何缓和,只需向cubicBezier函数提供原始的normalizedTime而不是curvedTime值作为第一个参数。
构建补间组件
你现在知道了补间技术是如何工作的,但是你如何在游戏中使用它呢?仅仅是在两点之间移动一个 sprite 就需要很多代码,如果您想要移动数百个 sprite 呢?您需要一个可重用的系统来创建和管理补间,所以这就是我们接下来要创建的。
您可以将补间视为介于粒子效果和关键帧动画之间的游戏组件。像粒子一样,它们需要被游戏循环更新,你可以在任何时候让几十个补间变得活跃或不活跃。像关键帧动画一样,补间动画也有持续时间,所以它们可以播放、暂停,也可能随时间反转或循环。在本节中,我们将构建一组用于构建补间的低级工具,以便您可以轻松地为游戏构建自己的自定义补间效果库。
注意你会在本书源文件的library/tween.js文件中找到这一节的所有工作代码。
tweens阵列
就像粒子和交互按钮一样,你的游戏需要一个数组来存储所有活跃的补间动画。简单点说,就叫tweens。
export let tweens = [];
然后,您的游戏循环将需要遍历数组中的补间,并对每个补间调用一个update方法:
if (tweens.length > 0) {
for(let i = tweens.length - 1; i >= 0; i--) {
let tween = tweens[i];
if (tween) tween.update();
}
}
这是我们在第八章中用于粒子引擎的同一系统。而且,就像粒子一样,代码以相反的顺序循环补间,以便我们可以随时从数组中轻松地删除补间,而不会弄乱循环索引计数器。
您很快就会看到这些补间对象是什么,以及update方法是做什么的。
轻松图书馆
在本章的第一部分,我向你展示了如何使用 16 个缓动功能,比如smoothstep、linear和spline。为了使它们易于使用,我们将把它们添加为一个名为ease的对象的属性。这将允许我们使用类似这样的代码来访问这些函数:
ease.smoothstep(normalizedTime);
或者,因为函数是ease对象的一个属性,我们可以选择通过它的字符串名调用函数:
ease"smoothstep";
这是一个巧妙的技巧,正如您将看到的,这将使我们不必在以后编写大量重复的代码。
存储缓和函数的ease对象只是一个普通的旧对象文字,将函数作为属性。这里有一个ease对象的简化版本,展示了前两个函数是如何作为属性添加的。
let ease = {
linear(x) {return x;},
smoothstep(x) {return x * x * (3 - 2 * x);},
//... the rest of the easing functions follow the same pattern...
};
对于剩余的 14 个函数,ease对象中的其余代码遵循完全相同的模型。我们现在有了一个方便的易于应用的放松函数库。
创建补间对象
下一步是构建一个叫做tweenProperty的灵活的底层函数,我们可以用它来补间任何 sprite 的任何属性。我称之为“低级”,因为我们将使用它作为构建块来创建更易于使用的、专门的补间效果。
在我向您展示代码之前,先告诉您如何使用tweenProperty函数:
tweenProperty(
sprite, //The sprite
"x", //The property you want to tween (a string)
100, //The start value
200, //the end value
60, //The tween duration, in frames
["smoothstep"], //An array that defines the easing type (a string)
true, //Yoyo? True or false
1000 //The delay, in milliseconds, before the tween yoyos
);
请注意,缓动类型在数组中作为字符串列出:
["smoothstep"]
除样条曲线外,所有缓动类型都将使用相同的格式。如果要使用样条曲线,请在数组中提供两个额外的值-起始幅值和结束幅值:
["spline" -10, 10]
这两个数字指的是可用于修改弹性的样条线控制点。您将在前面的代码中看到它们是如何使用的。
tweenProperty函数中的第七个参数是一个名为yoyo的布尔值。如果yoyo是true,补间动画将连续循环反转其动画,就像溜溜球一样。最后一个参数是一个以毫秒为单位的数字,它决定了在溜溜球重复之前动画应该暂停多长时间。
tweenProperty函数返回一个tween对象。
let tween = tweenProperty(/*...arguments...*/);
tween对象有play和pause方法,您可以使用它们来控制补间,还有一个名为playing的布尔属性,它告诉您补间当前是否正在播放:
tween.play();
tween.pause();
tween.playing
您可以选择使用补间完成时应该运行的任何代码来定义tween对象的onComplete方法:
tween.onComplete = () => {
//This code will run when the tween finishes
};
(如果你的补间是溜溜球,在每个溜溜球片段的结尾都会调用onComplete。)
tween对象还有一个update函数,它包含了应该在游戏循环内部运行的代码。当tweenProperty函数创建补间时,它将补间对象推入全局tweens数组。在我们之前看过的代码中,通过循环遍历tweens数组并对每个补间调用update方法,使补间对象具有动画效果。
完整的tweenProperty功能
下面是创建和返回补间对象的完整的tweenProperty函数。你会看到大部分代码是我们用来控制精灵关键帧动画的addStatePlayer函数和我们用来制作粒子的particleEffect函数的混合体。注释解释了大部分细节,但是还有一些新特性,我将在代码清单之后更深入地解释。
export function tweenProperty(
sprite, //The sprite object
property, //The property to tween (a string)
startValue, //Tween start value
endValue, //Tween end value
totalFrames, //Duration in frames
type = ["smoothstep"], //The easing type
yoyo = false, //Yoyo?
delayBeforeRepeat = 0 //Delay in milliseconds before repeating
) {
//Create the tween object
let o = {};
//If the tween is a spline, set the
//start and end magnitude values
if(type[0] === "spline" ){
o.startMagnitude = type[1];
o.endMagnitude = type[2];
}
//Use `o.start` to make a new tween using the current
//end point values
o.start = (startValue, endValue) => {
//Clone the start and end values so that any possible references to sprite
//properties are converted to ordinary numbers
o.startValue = JSON.parse(JSON.stringify(startValue));
o.endValue = JSON.parse(JSON.stringify(endValue));
o.playing = true;
o.totalFrames = totalFrames;
o.frameCounter = 0;
//Add the tween to the global `tweens` array. The `tweens` array is
//updated on each frame
tweens.push(o);
};
//Call `o.start` to start the tween
o.start(startValue, endValue);
//The `update` method will be called on each frame by the game loop.
//This is what makes the tween move
o.update = () => {
let time, curvedTime;
if (o.playing) {
//If the elapsed frames are less than the total frames,
//use the tweening formulas to move the sprite
if (o.frameCounter < o.totalFrames) {
//Find the normalized value
let normalizedTime = o.frameCounter / o.totalFrames;
//Select the correct easing function from the
//`ease` object’s library of easing functions
//If it's not a spline, use one of the ordinary easing functions
if (type[0] !== "spline") {
curvedTime = easetype;
}
//If it's a spline, use the `spline` function and apply the
//two additional `type` array values as the spline's start and
//end points
else {
curvedTime = ease.spline(normalizedTime, o.startMagnitude, 0, 1, o.endMagnitude);
}
//Interpolate the sprite's property based on the curve
sprite[property] = (o.endValue * curvedTime) + (o.startValue * (1 - curvedTime));
o.frameCounter += 1;
}
//When the tween has finished playing, run the end tasks
else {
o.end();
}
}
};
//The `end` method will be called when the tween is finished
o.end = () => {
//Set `playing` to `false`
o.playing = false;
//Call the tween's `onComplete` method, if it's been assigned
//by the user in the main program
if (o.onComplete) o.onComplete();
//Remove the tween from the `tweens` array
tweens.splice(tweens.indexOf(o), 1);
//If the tween's `yoyo` property is `true`, create a new tween
//using the same values, but use the current tween's `startValue`
//as the next tween's `endValue`
if (yoyo) {
wait(delayBeforeRepeat).then(() => {
o.start(o.endValue, o.startValue);
});
}
};
//Play and pause methods
o.play = () => o.playing = true;
o.pause = () => o.playing = false;
//Return the tween object
return o;
}
tweenProperty做的一件重要的事情是将startValue和endValue转换成字符串,然后再转换回数字,然后将它们分配给补间对象:
o.startValue = JSON.parse(JSON.stringify(startValue));
o.endValue = JSON.parse(JSON.stringify(endValue));
这确保了startValue和endValue是纯数字,而不是指向精灵属性的引用。在补间动画制作过程中,Sprite 属性值可能会发生变化,如果发生这种情况,可能会破坏补间动画。例如,假设您用cat.x初始化tweenProperty函数的初始值(第三个参数),如下面的代码所示:
let tween = tweenProperty(cat, "x", cat.x ... )
cat.x不是数字!只是一个指向猫身上x值的引用。如果在补间动画制作过程中cat.x发生变化(这是必然的),补间对象将读取猫的当前 x 位置,而不是它的开始位置。为了使补间正常工作,您只需要开始位置值。
使用JSON.parse和JSON.stringify方法是帮助解决这个问题的常用方法。JSON.stringify将任何值转换成字符串(这个过程叫做序列化)。然后JSON.parse将其转换回数字(这个过程叫做反序列化)。这个转换过程会删除所有引用,所以你只剩下一个纯数字。这是防止您意外使用补间开始值和结束值的保险策略。
注意制作一个不包含指向原始对象上的值的引用指针的对象的精确副本被称为克隆。当前版本的 JavaScript (ES6)没有克隆对象的专用功能,尽管将来的版本可能会有。
现在我们已经写了tweenProperty函数,我们如何使用它呢?这个函数的目的不是在你的游戏代码中直接使用它。相反,它是一个低级工具,你可以用它来为你的精灵构建更高级的、有用的补间函数。让我们看看下一步该怎么做。
阿尔法补间
现在我们有了一个在精灵上补间单个属性的便捷方法,有三个高级函数很容易让我们马上完成:fadeIn、fadeOut和pulse。
fadeIn
fadeIn函数允许你淡入一个 sprite,方法是将它的alpha属性补间为 1:
export function fadeIn(sprite, frames = 60) {
return tweenProperty(
sprite, "alpha", sprite.alpha, 1, frames, ["sine"]
);
}
在你的游戏代码中这样使用它:
let fadeInTween = fadeIn(anySprite);
fadeOut
相反的效果,fadeOut,将精灵的 alpha 补间为 0:
export function fadeOut(sprite, frames = 60) {
return tweenProperty(
sprite, "alpha", sprite.alpha, 0, frames, ["sine"]
);
}
请这样使用:
let fadeOutTween = fadeOut(anySprite);
这两种效果非常适合场景转换或使精灵出现或消失。
pulse
pulse功能使精灵在高低 alpha 值之间不断振荡。这是一个很好的效果,可以用来吸引人们对精灵的注意。
export function pulse(sprite, frames = 60, minAlpha = 0) {
return tweenProperty(
sprite, "alpha", sprite.alpha, minAlpha, frames, ["smoothstep"], true
);
}
第三个参数minAlpha是在补间回到原始值之前使用的最低 alpha 值。所以如果你只是想让精灵补间到 alpha 为 0.3,用下面的语句初始化pulse函数:
let pulseTween = pulse(anySprite, 120, 0.3);
如果将frames参数设置为较低的值,可以创建闪烁效果。
所有这些效果都在一个属性之间,即精灵的alpha。但是如果您想要创建一个需要多个属性补间的更复杂的效果呢?
补间多个属性
我们需要再构建一个底层组件!我们将构建一个名为makeTween的新函数,它将允许您通过组合任意多的单属性补间来创建复杂的效果。以下是您可以使用它的方式:
let complexTween = makeTween([
[/* A property you want to tween */],
[/* Another property you want to tween */],
[/* Yet another property you want to tween */]
]);
makeTween接受单个参数,该参数是包含要补间的属性子数组的数组。您放在子数组中的信息与您需要提供给我们之前创建的tweenProperty函数的信息是相同的。
下面是如何使用makeTween创建一个复杂的高级函数slide,它将把一个精灵的 x/y 位置补间到画布上的任何其他 x/y 位置。
export function slide(
sprite,
endX,
endY,
frames = 60,
type = ["smoothstep"],
yoyo = false,
delayBeforeRepeat = 0
) {
return makeTween([
//The x axis tween
[sprite, "x", sprite.x, endX, frames, type, yoyo, delayBeforeRepeat],
//The y axis tween
[sprite, "y", sprite.y, endY, frames, type, yoyo, delayBeforeRepeat]
]);
}
可以看到数组中的数据与初始化tweenProperty函数所需的参数完全相同。你可以这样在游戏代码中使用滑动功能:
let catSlide = slide(cat, 400, 32, 60, ["smoothstep"], true, 0);
这将使cat精灵从其当前位置平滑地来回滑动到 400/30 的 x/y 位置,超过 60 帧。
所有补间都有一个用户可定义的onComplete方法,该方法将在补间完成时运行。下面是当补间完成时,如何使用catSlide补间上的onComplete向控制台写入消息:
catSlide.onComplete = () => console.log("Cat slide finished!");
制作补间功能
makeTween函数将接受任意数量的补间数组作为参数,因此您可以使用它来构建一些真正复杂的效果。它本质上只是一个包装器,使用tweenProperty来创建每个补间,并将对每个补间对象的引用保存在它自己的内部数组中。它还为您提供了控制数组中所有补间动画的高级play和pause方法,并允许您分配一个onComplete方法,该方法将在数组中的所有补间动画完成后运行。
下面是完成所有这些的完整的makeTween函数。
function makeTween(tweensToAdd) {
//Create an object to manage the tweens
let o = {};
//Create an internal `tweens` array to store the new tweens
o.tweens = [];
//Make a new tween for each array
tweensToAdd.forEach(tweenPropertyArguments => {
//Use the tween property arguments to make a new tween
let newTween = tweenProperty(...tweenPropertyArguments);
//Push the new tween into this object's internal `tweens` array
o.tweens.push(newTween);
});
//Add a counter to keep track of the
//number of tweens that have completed their actions
let completionCounter = 0;
//`o.completed` will be called each time one of the tweens finishes
o.completed = () => {
//Add 1 to the `completionCounter`
completionCounter += 1;
//If all tweens have finished, call the user-defined `onComplete`
//method, if it's been assigned. Reset the `completionCounter`
if (completionCounter === o.tweens.length) {
if (o.onComplete) o.onComplete();
completionCounter = 0;
}
};
//Add `onComplete` methods to all tweens
o.tweens.forEach(tween => {
tween.onComplete = () => o.completed();
});
//Add pause and play methods to control all the tweens
o.pause = () => {
o.tweens.forEach(tween => {
tween.playing = false;
});
};
o.play = () => {
o.tweens.forEach(tween => {
tween.playing = true;
});
};
//Return the tween object
return o;
}
因为makeTween管理多个补间动画,它需要知道所有补间动画何时完成任务。代码使用一个名为completionCounter的计数器变量来跟踪这一点,并将其初始化为 0:
let completionCounter = 0;
创建补间动画后,makeTween循环遍历其数组中的所有补间动画,并向它们添加onComplete方法:
o.tweens.forEach(tween => {
tween.onComplete = () => o.completed();
});
当它们完成时,补间动画将调用一个名为completed的方法。completed方法给completionCounter加 1。如果completionCounter的值与内部tweens数组的长度相匹配,那么你就知道所有的补间都完成了。然后代码运行一个可选的、用户定义的onComplete方法,如果它存在的话。
o.completed = () => {
//Add 1 to the `completionCounter`
completionCounter += 1;
//If all tweens have finished, call the user-defined `onComplete`
//method, if it's been assigned in the main program.
//Then reset the `completionCounter`
if (completionCounter === o.tweens.length) {
if (o.onComplete) o.onComplete();
completionCounter = 0;
}
};
removeTween功能
既然我们有了制作多个补间动画的方法,我们还需要一个移除它们的方法。我们需要添加的最后一点是一个通用的removeTween函数:
export function removeTween(tweenObject) {
//Remove the tween if `tweenObject` doesn't have any nested
//tween objects
if(!tweenObject.tweens) {
tweenObject.pause();
tweens.splice(tweens.indexOf(tweenObject), 1);
//Otherwise, remove the nested tween objects
} else {
tweenObject.pause();
tweenObject.tweens.forEach(element => {
tweens.splice(tweens.indexOf(element), 1);
});
}
}
使用removeTween从游戏中删除任何补间,语法如下:
removeTween(tween);
最后,我们现在可以开始制作一些有趣的东西了!
轻松轻松!
我们现在已经有了创建一些简单易用的高级补间函数所需的所有工具,这些函数适用于任何精灵。下面是对各种游戏最有用的一些功能的快速总结。如果您需要更专业的东西,只需使用这些函数作为创建您自己的模型。
slide
如果你需要让精灵在任意两个 x/y 点之间移动,使用slide功能。
let slideTween = slide(
anySprite, //The sprite
400, //Destination x
32, //Destination y
60, //Duration in frames
["smoothstep"], //Easing type
true, //yoyo?
0 //Delay, in milliseconds, before repeating
);
你在前一节看到了slide函数的代码,而图 10-14 说明了它的功能。

图 10-14 。使用滑块让精灵平滑地移动到任何一点
breathe
通过在溜溜球循环中来回补间scaleX和scaleY属性,你可以让精灵看起来像在呼吸(图 10-15 )。

图 10-15 。对 scaleX 和 scaleY 属性进行补间以制作一个呼吸精灵
下面是做这件事的breathe函数。
export function breathe(
sprite, endScaleX, endScaleY,
frames, yoyo = true, delayBeforeRepeat = 0
) {
return makeTween([
//Create the scaleX tween
[
sprite, "scaleX", sprite.scaleX, endScaleX,
frames, ["smoothstepSquared"], yoyo, delayBeforeRepeat
],
//Create the scaleY tween
[
sprite, "scaleY", sprite.scaleY, endScaleY,
frames, ["smoothstepSquared"], yoyo, delayBeforeRepeat
]
]);
}
请注意,breathe使用了smoothstepSquared函数来获得更明显的效果:
在游戏代码中使用它让精灵呼吸,如下例所示:
let breathingTween = breathe(anySprite, 1.2, 1.2, 60);
scale
breathe功能在一个连续的溜溜球补间中放大和缩小精灵。但是如果您希望缩放效果只发生一次,请使用scale函数:
export function scale(sprite, endScaleX, endScaleY, frames = 60) {
return makeTween([
//Create the scaleX tween
[
sprite, "scaleX", sprite.scaleX, endScaleX,
frames, ["smoothstep"], false
],
//Create the scaleY tween
[
sprite, "scaleY", sprite.scaleY, endScaleY,
frames, ["smoothstep"], false
]
]);
}
它与breathe函数几乎完全相同,除了yoyo参数被设置为false。你可以使用scale平滑地放大或缩小精灵,方法如下:
let scaleUpTween = scale(anySprite, 2, 2);
let scaleDownTween = scale(anySprite, 0.2, 0.2);
strobe
通过快速旋转标尺并使用样条线,您可以创建一个迷幻效果strobe。
export function strobe(
sprite, scaleFactor = 1.3, startMagnitude = 10, endMagnitude = 20,
frames = 10, yoyo = true, delayBeforeRepeat = 0
) {
return makeTween([
//Create the scaleX tween
[
sprite, "scaleX", sprite.scaleX, scaleFactor, frames,
["spline", startMagnitude, endMagnitude],
yoyo, delayBeforeRepeat
],
//Create the scaleY tween
[
sprite, "scaleY", sprite.scaleY, scaleFactor, frames,
["spline", startMagnitude, endMagnitude],
yoyo, delayBeforeRepeat
]
]);
}
您可以在这段代码中看到“样条线”缓动类型是如何设置的,以及它的起始和结束幅度值:
["spline", startMagnitude, endMagnitude]
以下是制作精灵频闪灯的方法:
let strobeTween = strobe(anySprite, 1.3, 10, 20, 10);
这是一种闪烁的缩放效果,如果你让它持续太久,可能会让你头疼。(你会在本章的源文件中找到一个strobe函数的工作示例,以及这些效果的其余部分。)
T2wobble
最后但同样重要的是:wobble函数。想象一大盘你一生中见过的最不稳定的果冻布丁。然后,用手指戳它。这就是wobble函数的作用。它的工作原理是借助一条样条线在 x 和 y 轴上反向缩放精灵。精灵开始时非常不稳定,然后随着每次重复逐渐变得不那么不稳定,直到它恢复正常。图 10-16 说明了这个效果。

图 10-16 。让雪碧像果冻一样晃动
是这些补间函数中最复杂的,因为它在幕后做了更多的工作。它为 x 和 y 缩放补间添加了一个onComplete方法,这样每次重复时都会有一点点friction添加到抖动中。这就是它逐渐慢下来的原因。摩擦值在 0.96(不太不稳定)和 0.99(更不稳定)之间是一个很好的尝试范围。当补间的结束值低于 1 时,效果结束,补间被移除。
export function wobble(
sprite,
scaleFactorX = 1.2,
scaleFactorY = 1.2,
frames = 10,
xStartMagnitude = 10,
xEndMagnitude = 10,
yStartMagnitude = -10,
yEndMagnitude = -10,
friction = 0.98,
yoyo = true,
delayBeforeRepeat = 0
) {
let o = makeTween([
//Create the scaleX tween
[
sprite, "scaleX", sprite.scaleX, scaleFactorX, frames,
["spline", xStartMagnitude, xEndMagnitude],
yoyo, delayBeforeRepeat
],
//Create the scaleY tween
[
sprite, "scaleY", sprite.scaleY, scaleFactorY, frames,
["spline", yStartMagnitude, yEndMagnitude],
yoyo, delayBeforeRepeat
]
]);
//Add some friction to the `endValue` at the end of each tween
o.tweens.forEach(tween => {
tween.onComplete = () => {
//Add friction if the `endValue` is greater than 1
if (tween.endValue > 1) {
tween.endValue *= friction;
//Set the `endValue` to 1 when the effect is finished and
//remove the tween from the global `tweens` array
if (tween.endValue <= 1) {
tween.endValue = 1;
removeTween(tween);
}
}
};
});
return o;
}
以下是如何让精灵在游戏中摇摆的方法:
let wobbleTween = wobble(anySprite, 1.2, 1.2);
更改 x 和 y 比例因子(第二个和第三个参数)以获得更生动的效果。
我是在玩不同的补间值时偶然发现频闪和抖动效果的。你也可以这样做!使用makeTween合成多个补间动画,以意想不到的方式改变不同的 sprite 属性——您可能会对自己的成果感到惊讶!
使用航路点跟随运动路径
在上一节中,你学习了如何使用slide函数使一个精灵平滑地在其位置之间移动。但是如果你想让一个精灵沿着一条连接路径的路线走呢?你可以将一系列的slide函数连接在一起,让一个精灵在画布上行走。
为了完成这项工作,你需要连接一个由 x/y 点组成的阵列;每个点被称为一个航点。每次精灵到达一个航路点时,调用slide函数并将精灵移动到下一个点。例如,假设你想让一个精灵沿着矩形路径前进,如图图 10-17 所示。

图 10-17 。使用路径点使精灵跟随路径
您可以定义一组 2D 路点来描述路径,如下所示:
[
[32, 32], //First x/y point
[32, 128], //Next x/y point
[300, 128], //Next x/y point
[300, 32], //Next x/y point
[32, 32] //Last x/y point
],
因为这是一个封闭的路径,所以最后一个点与第一个点相同,但您也可以保持路径开放。
要做到这一点,您需要构建一个函数来读取这些路点,并使精灵在每个相邻点之间移动。当每个点之间的移动完成后,你需要让精灵在下两个点之间移动,直到它到达最后一个点。
walkPath
你可以使用一个名为walkPath的新函数来帮助你做到这一点。它的代码和我理论上描述的完全一样。在我们详细了解walkPath的工作原理之前,我们先来了解一下它的使用方法。下面是你需要用来让猫精灵沿着图 10-17 中的矩形路径前进的代码。
let catPath = walkPath(
cat, //The sprite
//An array of x/y waypoints to connect in sequence
[
[32, 32], //First x/y point
[32, 128], //Next x/y point
[300, 128], //Next x/y point
[300, 32], //Next x/y point
[32, 32] //Last x/y point
],
300, //Total duration in frames
["smoothstep"], //Easing type
true, //Should the path loop?
true, //Should the path yoyo?
1000 //Delay in milliseconds between segments
);
您可以看到第二个参数是一个 2D 数组,它列出了路径的路点。如果loop(第五个参数)是true,sprite 将在到达路径末尾时从路径的起点重新开始。如果yoyo(第六个参数)是true,精灵将在到达终点时逆向行走。(如果你设置yoyo为真,设置loop为false,精灵将从路径的起点到终点返回,不重复。)最后一个参数是 sprite 在路径的每一部分之间应该等待的延迟(以毫秒为单位)。
下面是完成这项工作的完整的walkPath函数。它使用makeTween在 2D 数组中的每个航路点之间创建一个补间。补间完成后,会在前一个数组的最后一个点和新数组的下一个点之间创建一个新的补间。当到达最后一个点时,路径可选地循环和溜溜球。
export function walkPath(
sprite, //The sprite
originalPathArray, //A 2D array of waypoints
totalFrames = 300, //The duration, in frames
type = ["smoothstep"], //The easing type
loop = false, //Should the animation loop?
yoyo = false, //Should the direction reverse?
delayBetweenSections = 0 //Delay, in milliseconds, between sections
) {
//Clone the path array so that any possible references to sprite
//properties are converted into ordinary numbers
let pathArray = JSON.parse(JSON.stringify(originalPathArray));
//Figure out the duration, in frames, of each path section by
//dividing the `totalFrames` by the length of the `pathArray`
let frames = totalFrames / pathArray.length;
//Set the current point to 0, which will be the first waypoint
let currentPoint = 0;
//Make the first path using the internal `makePath` function (below)
let tween = makePath(currentPoint);
//The `makePath` function creates a single tween between two points and
//then schedules the next path to be made after it
function makePath(currentPoint) {
//Use the `makeTween` function to tween the sprite's x and y position
let tween = makeTween([
//Create the x axis tween between the first x value in the
//current point and the x value in the following point
[
sprite,
"x",
pathArray[currentPoint][0],
pathArray[currentPoint + 1][0],
frames,
type
],
//Create the y axis tween in the same way
[
sprite,
"y",
pathArray[currentPoint][1],
pathArray[currentPoint + 1][1],
frames,
type
]
]);
//When the tween is complete, advance the `currentPoint` by 1.
//Add an optional delay between path segments, and then make the
//next connecting path
tween.onComplete = () => {
//Advance to the next point
currentPoint += 1;
//If the sprite hasn't reached the end of the
//path, tween the sprite to the next point
if (currentPoint < pathArray.length - 1) {
wait(delayBetweenSections).then(() => {
tween = makePath(currentPoint);
});
}
//If we've reached the end of the path, optionally
//loop and yoyo it
else {
//Reverse the path if `loop` is `true`
if (loop) {
//Reverse the array if `yoyo` is `true`. Use JavaScript’s built-in
//array `reverse` method to do this
if (yoyo) pathArray.reverse();
//Optionally wait before restarting
wait(delayBetweenSections).then(() => {
//Reset the `currentPoint` to 0 so that we can
//restart at the first point
currentPoint = 0;
//Set the sprite to the first point
sprite.x = pathArray[0][0];
sprite.y = pathArray[0][1];
//Make the first new path
tween = makePath(currentPoint);
//... and so it continues!
});
}
}
};
//Return the path tween to the main function
return tween;
}
//Pass the tween back to the main program
return tween;
}
通过调整makePath功能的参数,您可以实现多种多样的运动路径效果,这将使您很好地适应各种游戏。但是makePath只移动由直线段组成的路径。如果你想让一个精灵沿着一条弯曲的路径走呢?
walkCurve
你可以用一组贝塞尔曲线来描述一个精灵的路径,而不是用一组 x/y 的路点。图 10-18 显示了一个精灵沿着两条贝塞尔曲线组成的路径。第一条曲线使 sprite 向画布底部弯曲,第二条曲线使它向其起点弯曲。

图 10-18 。使用贝塞尔曲线使精灵遵循弯曲的运动路径
这是两条贝塞尔曲线的数组,描述了图 10-18 所示的运动路径:
[
//Curve 1
[[hedgehog.x, hedgehog.y],[75, 500],[200, 500],[300, 300]],
//Curve 2
[[300, 300],[250, 100],[100, 100],[hedgehog.x, hedgehog.y]]
]
下一步是创建一个名为walkCurve的函数,让精灵沿着贝塞尔曲线描述的路径前进。walkCurve功能与walkPath功能非常相似——唯一真正的区别是航路点数据被曲线数据取代。下面是你如何使用walkCurve函数让一个精灵跟随图 10-18 中的路径:
let hedgehogPath = walkCurve(
hedgehog, //The sprite
//An array of Bezier curve points that
//you want to connect in sequence
[
[[hedgehog.x, hedgehog.y],[75, 500],[200, 500],[300, 300]],
[[300, 300],[250, 100],[100, 100],[hedgehog.x, hedgehog.y]]
],
300, //Total duration, in frames
["smoothstep"], //Easing type
true, //Should the path loop?
true, //Should the path yoyo?
1000 //Delay in milliseconds between segments
);
下面是完整的walkCurve函数,带有描述其工作原理的注释。
export function walkCurve(
sprite, //The sprite
pathArray, //2D array of Bezier curves
totalFrames = 300, //The duration, in frames
type = ["smoothstep"], //The easing type
loop = false, //Should the animation loop?
yoyo = false, //Should the direction reverse?
delayBeforeContinue = 0 //Delay, in milliseconds, between sections
) {
//Divide the `totalFrames` into sections for each part of the path
let frames = totalFrames / pathArray.length;
//Set the current curve to 0, which will be the first one
let currentCurve = 0;
//Make the first path
let tween = makePath(currentCurve);
function makePath(currentCurve) {
//Use the custom `followCurve` function (described earlier
//in the chapter) to make a sprite follow a curve
let tween = followCurve(
sprite,
pathArray[currentCurve],
frames,
type
);
//When the tween is complete, advance the `currentCurve` by one.
//Add an optional delay between path segments, and then create the
//next path
tween.onComplete = () => {
currentCurve += 1;
if (currentCurve < pathArray.length) {
wait(delayBeforeContinue).then(() => {
tween = makePath(currentCurve);
});
}
//If we've reached the end of the path, optionally
//loop and reverse it
else {
if (loop) {
if (yoyo) {
//Reverse the order of the curves in the `pathArray`
pathArray.reverse();
//Reverse the order of the points in each curve
pathArray.forEach(curveArray => curveArray.reverse());
}
//After an optional delay, reset the sprite to the
//beginning of the path and create the next new path
wait(delayBeforeContinue).then(() => {
currentCurve = 0;
sprite.x = pathArray[0][0];
sprite.y = pathArray[0][1];
tween = makePath(currentCurve);
});
}
}
};
//Return the path tween to the main function
return tween;
}
//Pass the tween back to the main program
return tween;
}
就这样,我们结束了!补间,解决了!
摘要
你现在有了一套有用的工具,可以为各种游戏制作各种动画补间效果。您已经学习了所有经典的缓动功能是如何工作的,以及如何使用它们来制作精灵动画。您还构建了一个通用且可定制的补间引擎,可以用于任何游戏项目。结合您在前面章节中学习的脚本运动和关键帧动画技术,您现在有一个令人眼花缭乱的运动效果调色板可供选择。
本章最重要的是你知道如何使用高级补间函数,如fadeIn、fadeOut、pulse、slide、strobe、breathe、followCurve、walkPath和walkCurve。不要让那些补间函数如何工作的技术细节困扰你。如果您想尝试制作自己的自定义补间动画,您可以在以后更仔细地研究它们。
但是如何在游戏中使用这些补间函数呢?在下一章你会发现。
十一、制作你自己的游戏引擎
到目前为止,在这本书里,你已经学会了如何制作一系列灵活的低级工具,并使用它们来帮助构建游戏。但是在你用这些工具做了几个游戏之后,你会注意到一件事,那就是你最终会写很多重复的代码。这包括所有的代码来加载您的素材,导入模块,运行游戏循环,创建精灵,循环通过精灵来检查碰撞,移动精灵,以及无数其他不起眼,乏味,但必要的任务。所有这些都是游戏引擎的代码,它最终可能会占到你所编写的所有代码的一半。
游戏引擎代码与游戏逻辑代码无关。游戏逻辑是你为每个游戏编写的有趣的、富有想象力的代码。游戏引擎代码只是让游戏逻辑工作的重担。在你学习的过程中展示所有这些代码是很好的,因为理解游戏程序的所有部分是如何组合在一起的是很重要的。但是,如果你只是想清楚地实现你的想象,而不是那些杂乱的技术材料,你必须更进一步。这就是我们在本章要做的。我们将把游戏引擎代码从游戏逻辑代码中分离出来,这样你就可以自由地快速创建游戏,而不必趟过齐腰深的繁琐机制的泥淖。我们将关闭游戏引擎上的引擎盖,跳进一辆超级充电、制造游戏的跑车的驾驶座。
在这一章中,我将向你展示如何构建你自己的易于使用的游戏引擎,以及制作真正完美的游戏所需要知道的一些缺失的部分。您将学习如何:
- 创建和管理游戏状态。
- 自动缩放和居中游戏以填充浏览器窗口。
- 创建独立的游戏场景和关卡,并制作它们之间的动画过渡。
- 建立一个带有加载进度条的预加载屏幕。
- 创建一个有趣的新特效:屏幕抖动!
在本章的最后,你将学习如何运用所有这些新技能,并将它们与迄今为止你在书中学到的所有技术相结合,构建一个名为 Bloxyee 的突围式游戏(如图图 11-1 所示)。

图 11-1 。你将在这一章构建的角色游戏
使用状态来构建游戏
在前面的章节中,你学习了如何创建和使用一套工具来帮助你制作游戏。但是如何组织代码来构建一个游戏呢?解决方案是使用状态。状态是一个函数,包含游戏中每个重要部分的代码。例如,在一个基本游戏中,你可能有这四种状态:
- 加载:游戏素材加载时做什么。
- setup :素材加载后应该运行的代码。设置状态创建精灵并建立游戏关卡。
- 玩:你的主游戏逻辑。这是一个循环。
- end :玩家结束游戏时应该运行的代码。
图 11-2 展示了这个基本模型。

图 11-2 。四种基本游戏状态
这些状态中的每一个都只是一个普通的函数,包含普通的游戏代码:
function load() {/*What to do while loading assets*/}
function setup() {/*Create the sprites and game level*/}
function play() {/*The game code*/}
function end() {/*Code to run when the game finishes*/}
- 这些函数应该包含您想要为该状态运行的所有代码。但是你需要某种方法在游戏中的任何时候在这些状态之间切换。这里有一个简单的方法:
- 创建一个名为
state:的变量
let state;
它的工作是保存对当前游戏状态函数的引用。比如,想象一下,当你的游戏开始时,你想运行的第一个函数是load函数。将state的值设置为load:
let state = load;
现在state已经变成了load函数的中间指针。
然后你需要做的就是在游戏循环中调用state,就像这样:
function gameLoop() {
requestAnimationFrame(gameLoop);
state();
}
因为state有值load,所以load函数将循环运行。
这个系统的美妙之处在于,你可以在你的代码中随时将state的值改为另一个函数,然后那个函数就会在游戏循环中运行。你根本不用修改游戏循环代码。例如,如果您的代码已经加载完素材,并且您希望主游戏开始运行,只需重新定义state的值,使其指向play函数:
state = play;
现在,你在play函数中写的任何代码都将在gameLoop中运行。
循环将自动运行任何函数state所引用的内容。如果它引用了load函数,那么load函数中的所有代码都会运行;如果它引用了play,,那么play函数中的所有代码都会运行。把你的游戏状态函数(load、setup、play、end)想象成独立的模块,只要把它们分配给state变量,你就可以随时触发它们。
你可以根据需要在游戏中使用任意多个状态。如果您的状态包含大量代码,请将每个状态保存在单独的 JS 文件中。通过使用这种简单的技术,你可以创建非常大、非常复杂的游戏,但是因为你已经将游戏模块化为不同的状态,你的游戏代码总是很容易管理的。
游戏的模板
在我们进入下一步之前,让我们看看如何使用游戏状态来建立一个简单的模板来制作多种游戏。该模板从库中导入有用的代码,加载资源,并创建画布。它还设置和管理游戏状态,运行游戏循环,并呈现画布。
//Import any useful code from the library
import {
makeCanvas, sprite, group, particles, particleEffect,
tilingSprite, emitter, stage, render
} from "../library/display";
import {assets, randomInt, contain, wait} from "../library/utilities";
import {makePointer} from "../library/interactive";
import {hit, hitTestRectangle} from "../library/collision";
//Load the assets
assets.load([
"img/textureAtlas.json"
]).then(() => setup());
//Declare any variables shared between functions
let state, canvas;
function setup() {
//Make the canvas and initialize the stage
canvas = makeCanvas(512, 512);
canvas.style.backgroundColor = "black";
stage.width = canvas.width;
stage.height = canvas.height;
//...Create the game sprites and initialize variables...
//Set the game state to the `play` function
state = play;
//Start the game loop
gameLoop();
}
function gameLoop() {
requestAnimationFrame(gameLoop);
//Update all the particles in the game
if (particles.length > 0) {
for(let i = particles.length - 1; i >= 0; i--) {
let particle = particles[i];
particle.update();
}
}
//Run the current game state
state();
}
function play() {
//...Add game logic...
//Render the canvas
render(canvas);
}
你现在可以开始向这个模板添加定制代码来构建各种游戏,比如我们在第七章开发的 Flappy Fairy 游戏。
但是等等——有更好的方法!你可以通过创建一个可重用的游戏引擎来使这一切变得更加容易。
启动你的引擎!
上一节的游戏模板大约有 50 行代码。这还不算太糟,但是你需要为你制作的每个游戏重写相同的代码。而且,如果你想制作一个使用补间、声音、拖放或其他特殊功能的游戏,你也必须把这些代码放到模板中。你必须记住要包含许多样板代码,然后对你制作的每个游戏进行测试、调整和微调。
为什么不直接写一遍所有的样板代码,把它隐藏在某种可重用的函数中,在需要的时候调用它呢?这正是我们下一步要做的:制造一个游戏引擎。
在我向您展示使游戏引擎工作的代码之前,让我们先了解一下您将如何使用它。游戏引擎只是一个名为game的普通函数,你可以在library/engine.js文件夹中找到它。所以当你想开始制作一个新游戏时,用这样的语句将其导入你的代码:
import {game} from "../library/engine";
这是你需要导入的唯一的东西。稍后您将会看到,游戏引擎会在后台自动导入所有其他依赖项,因此您不必担心它们。
接下来,使用game函数创建一个名为g的新游戏对象:
let g = game(512, 256, setup, ["img/textureAtlas.json"]);
前两个参数是舞台的宽度和高度:512 乘 256。第三个参数setup,是游戏开始时应该运行的第一个函数。最后一个参数是一个数组,它列出了您想要加载的所有素材。您可以根据需要列出任意多的素材;以下是如何加载纹理贴图集、图像、字体和声音:
let g = game(512, 256, setup, [
"img/textureAtlas.json",
"img/cat.png",
"fonts/puzzler.otf",
"sounds/bounce.wav"
]);
一旦加载了这些素材,游戏引擎就会寻找并运行一个名为setup 的函数,这是第三个参数。(不必给那个函数起名叫“setup”;你可以叫它任何你喜欢的名字。)
您可以选择添加最后一个参数:加载素材时应该运行的函数的名称。例如,您可能有一个名为load的函数,它在加载素材时显示一个加载进度条。你可以告诉游戏引擎你想添加一个load函数,这样:
let g = game(512, 256, setup, ["img/textureAtlas.json"], load);
游戏引擎将在加载资源时寻找并运行一个名为load的函数。在本章的后面你会看到一个如何使用这个加载状态来创建一个加载进度条的实际例子。
接下来,通过调用游戏引擎的start方法来启动游戏引擎:
g.start();
这就像打开汽车的点火开关。start方法导致素材开始加载。
当素材已经加载时,引擎自动调用setup状态。setup函数只会运行一次,你可以用它来初始化你的游戏精灵。当你完成设置后,将游戏状态改为play。游戏引擎会自动循环运行play函数。
function setup() {
//The setup function runs only once
//Create your sprites like this:
let cat = g.sprite(g.assets["cat.png"]);
//When you're finished setting up, change the
//game state to `play`
g.state = play;
}
function play() {
//...the game logic, which runs in a loop...
}
如果需要随时暂停游戏循环,使用g.pause()方法。要恢复游戏循环,使用g.resume() 。
注意,你需要给所有游戏引擎代码加上前缀 g 。因此,要制作一个具有sprite功能的精灵,请使用g.sprite。要访问游戏素材,请使用g.assets。我们在本书中创建的所有函数、方法和实用程序都可以通过在它们的名称前加上前缀 g (它代表“游戏”,如果你还没有猜到的话!).
现在让我们来看看启动一个新游戏,加载一个纹理贴图集,创建一个画布,显示一个精灵,并使精灵移动所需编写的所有代码。
import {game} from "../library/engine";
let g = game(512, 256, setup, ["img/animals.json"]);
g.start();
let cat;
function setup() {
cat = g.sprite(g.assets["cat.png"]);
g.state = play;
}
function play() {
cat.x += 1;
}
很好很容易!这是你最基本的游戏引擎框架,你可以用它来制作任何种类的游戏。通过隐藏所有的技术细节,你已经整理好了你的代码,解放了你的思想去创造性地工作。
但是它是如何工作的呢?
制作游戏引擎
在上一节中,您已经了解到我们将要制作的游戏引擎将允许您通过调用game函数来创建一个新游戏;代码如下所示:
let g = game(512, 256, setup, ["img/animals.json"]);
game函数创建并返回一个名为Game的类的实例,它做以下事情:
- 从其他模块导入所有对象和方法,并将它们添加为自己的属性。
- 创建画布,初始化舞台,并创建指针。
- 设定游戏的比例。
- 加载素材并管理游戏状态。
- 运行游戏循环并更新任何补间动画、按钮、粒子和拖放精灵。(它还会更新任何“晃动的精灵”——稍后您会了解到这一点。)
- 渲染画布。
简而言之,它完成了所有你必须手工编码的烦人的管理任务。
Game类中的代码对您来说都不陌生。它只是你需要用来设置和运行任何游戏的纯技术代码。在前面的章节中,所有这些代码都与你制作的每个游戏的特定代码混合在一起。现在这段代码已经完全抽象了。
这里是完整的Game类,带有解释每个部分如何工作的注释。
//Import the modules
module utilities from "../library/utilities";
module display from "../library/display";
module collision from "../library/collision";
module interactive from "../library/interactive";
module sound from "../library/sound";
module tween from "../library/tween";
export class Game {
constructor(width = 256, height = 256, setup, assetsToLoad, load) {
//Copy all the imported library code into
//properties on this class
Object.assign(this, utilities);
Object.assign(this, display);
Object.assign(this, collision);
Object.assign(this, interactive);
Object.assign(this, sound);
Object.assign(this, tween);
//Make the canvas and initialize the stage
this.canvas = this.makeCanvas(width, height, "none");
this.canvas.style.backgroundColor = "white";
this.stage.width = this.canvas.width;
this.stage.height = this.canvas.height;
//Make the pointer
this.pointer = this.makePointer(this.canvas);
//The game's scale
this.scale = 1;
//Set the game `state`
this.state = undefined;
//Set the user-defined `load` and `setup` states
this.load = load;
this.setup = setup;
//Get a reference to the `assetsToLoad` array
this.assetsToLoad = assetsToLoad;
//A Boolean to let us pause the game
this.paused = false;
//The `setup` function is required, so throw an error if it's
//missing
if (!setup) {
throw new Error(
"Please supply the setup function in the constructor"
);
}
}
//The game loop
gameLoop() {
requestAnimationFrame(this.gameLoop.bind(this));
//Update all the buttons
if (this.buttons.length > 0) {
this.canvas.style.cursor = "auto";
this.buttons.forEach(button => {
button.update(this.pointer, this.canvas);
if (button.state === "over" || button.state === "down") {
if(button.parent !== undefined) {
this.canvas.style.cursor = "pointer";
}
}
});
}
//Update all the particles
if (this.particles.length > 0) {
for(let i = this.particles.length - 1; i >= 0; i--) {
let particle = this.particles[i];
particle.update();
}
}
//Update all the tweens
if (this.tweens.length > 0) {
for(let i = this.tweens.length - 1; i >= 0; i--) {
let tween = this.tweens[i];
if (tween) tween.update();
}
}
//Update all the shaking sprites
//(More about this later in the chapter!)
if (this.shakingSprites.length > 0) {
for(let i = this.shakingSprites.length - 1; i >= 0; i--) {
let shakingSprite = this.shakingSprites[i];
if (shakingSprite.updateShake) shakingSprite.updateShake();
}
}
//Update the pointer for drag-and-drop
if (this.draggableSprites.length > 0) {
this.pointer.updateDragAndDrop(this.draggableSprites);
}
//Run the current game `state` function if it's been defined and
//the game isn't `paused`
if(this.state && !this.paused) {
this.state();
}
//Render the canvas
this.render(this.canvas);
}
//The `start` method that gets the whole engine going. This needs to
//be called by the user from the game application code, right after
//the engine is instantiated
start() {
if (this.assetsToLoad) {
//Use the supplied file paths to load the assets, and then run
//the user-defined `setup` function
this.assets.load(this.assetsToLoad).then(() => {
//Clear the game `state` function for now to stop the loop
this.state = undefined;
//Call the `setup` function that was supplied by the user in
//the Game class’s constructor
this.setup();
});
//While the assets are loading, set the user-defined `load`
//function as the game state. That will make it run in a loop.
//You can use the `load` state to create a loading progress bar
if (this.load) {
this.state = this.load;
}
}
//If there aren't any assets to load,
//just run the user-defined `setup` function
else {
this.setup();
}
//Start the game loop
this.gameLoop();
}
//Pause and resume methods. These stop and start the
//game engine's game loop
pause() {
this.paused = true;
}
resume() {
this.paused = false;
}
}
为了使我们的 API 与本书中的其他代码保持一致,我们创建Game实例的接口是通过一个名为game的高级包装函数实现的:
export function game(
width = 256, height = 256, setup, assetsToLoad, load
) {
return new Game(width, height, setup, assetsToLoad, load);
}
现在,使用前面看到的代码创建一个新的游戏实例:
let g = game(512, 256, setup, ["img/textureAtlas.json"]);
g.start();
看看刚刚显示的代码中的Game类的start方法,看看它是如何让一切运转起来的。start方法在提供的数组中加载素材,然后调用用户定义的setup函数。(在加载素材时,它将可选地调用用户定义的load函数——但前提是已经提供了load函数。)在setup函数运行后,start方法调用gameLoop。gameLoop 更新所有游戏对象的集合,比如补间动画和按钮,然后调用当前游戏状态。Game类的pause和resume方法可以让你在需要的时候开始和停止游戏循环。
你现在可以使用这个框架开始制作任何新游戏。让我们找出方法。
使用游戏引擎
让我们看一个实际的例子来帮助你开始:从一个纹理贴图集中加载三个图像,并使它们可以拖动和放下。图 11-3 显示了这个简单的例子在浏览器窗口中运行的样子。你可以点击每个精灵并拖动它。

图 11-3 。使用游戏引擎制作简单的拖放精灵
下面是使用我们新的游戏引擎完成所有这些的代码。
//Create the engine
import {game} from "../library/engine";
let g = game(512, 256, setup, ["img/animals.json"]);
g.start();
//Give the canvas a black background
g.canvas.style.backgroundColor = "black";
//Declare any variables that should be used across functions
let cat, tiger, hedgehog;
function setup() {
//Make three sprites and set their `draggable`
//properties to `true`
cat = g.sprite(g.assets["cat.png"]);
g.stage.putCenter(cat, -32, -32);
cat.draggable = true;
tiger = g.sprite(g.assets["tiger.png"]);
g.stage.putCenter(tiger);
tiger.draggable = true;
hedgehog = g.sprite(g.assets["hedgehog.png"]);
g.stage.putCenter(hedgehog, 32, 32);
hedgehog.draggable = true;
//Optionally set the game state to `play`
g.state = play;
}
function play() {
//You don't actually need a `play` state in this example,
//but if you did, all this code would run in a loop
}
到目前为止,我们在这本书上的所有努力都开始有回报了!你可以看到我们的引擎在幕后做了大量繁琐的自动化工作,只用几行代码就给了我们一个可用的拖放界面。还记得我们在书的前面写的让拖放界面工作的代码吗?你再也不用看它或想它了!
提示记住,如果你需要访问我们在前面章节中写的方法中的任何自定义对象,它们都在那里,随时可供你使用——只需给它们加上前缀 g 。
缩放游戏以填充任何窗口大小
让我们给我们的游戏引擎添加一个高级特性:将任何游戏缩放到浏览器窗口最大宽度或高度的能力。你可以在图 11-3 中看到,画布与浏览器窗口的左上角对齐。如果画布能够自动缩放并自动对齐以填充最大可用空间,就像图 11-4 中的那样,那不是很好吗?

图 11-4 。缩放游戏以适合整个窗口
画布上的像素数量保持不变;它们只是根据需要填充的空间大小按比例拉伸或挤压。这是一个非常有用的功能,因为这意味着你可以设计一个单一的固定画布大小的游戏,然后根据你需要的屏幕大小来放大或缩小。像这样缩放画布通常比完全全屏运行游戏更可取,使用你在第一章中学到的全屏 API。这是因为你的游戏不会因为接管整个浏览器 UI 而有疏远玩家的风险,也不需要点击按钮来激活。它很自然地将任何浏览器窗口填充到最大尺寸。这并不难做到——让我们来看看怎么做吧!
提示通常来说,将你的游戏设计成你认为可能需要的最小分辨率,然后放大到更大的尺寸是一个好主意。这是因为更少的像素意味着更好的性能。当你放大时,你失去的是图形的清晰度,但现代硬件处理得如此优雅,那些模糊的像素很少被注意到。玩家肯定会注意到一个古怪、紧张的游戏,而不是你的图形上的柔和边缘。
再看一下Game类,您会注意到它有一个名为scale的属性,在构造函数中被初始化为 1:
this.scale = 1;
现在回想一下我们在第六章中写的makePointer函数。请记住,pointer有一个名为scale的属性,它可以在创建指针时初始化:
export function makePointer(element, scale = 1) {
let pointer = {
element: element,
scale: scale,
_x: 0,
_y: 0,
get x() {
return this._x / this.scale;
},
get y() {
return this._y / this.scale;
},
//... the rest of the `makePointer` function...
scale值用于帮助将浏览器的鼠标或触摸 x/y 位置转换到画布上的等效位置。这很重要,因为如果画布被缩放到更大或更小的尺寸,浏览器的 x/y 鼠标位置值将会错位。这意味着你不能点击按钮或拖动精灵。浏览器会认为这些交互元素与实际位置不同。指针代码通过调整鼠标或触摸 x/y 坐标到正确的刻度来解决这个问题。您可以通过以下方式将指针设定为不同的刻度值:
pointer.scale = anyScaleValue;
到目前为止,在本书中,我们还没有需要改变规模,但我们将在下一步这样做。
scaleToWindow功能
为了让画布自动缩放到最大窗口大小,我们将使用一个名为scaleToWindow 的新方法,这将是游戏对象的一个属性。所以在你创建并启动一个新的游戏引擎后就调用它,这样:
g.scaleToWindow("white");
它的单个参数决定了画布在其中浮动的 HTML 主体的背景颜色。
或者,您可能还想让画布在每次浏览器窗口的大小改变时重新调整自己的大小。如果是这种情况,调用window事件监听器中的scaleToWindow:
window.addEventListener("resize", event => {
g.scaleToWindow("white");
});
scaleToWindow是直接在Game类上的方法:
export class Game {
//...
scaleToWindow() {
//... the method code...
}
}
这很方便,因为这意味着代码可以直接访问画布和指针。下面是完整的scaleToWindow方法。(注意代码清单中的这个指的是Game类,该方法属于这个类。)
scaleToWindow(backgroundColor = "#2C3539") {
let scaleX, scaleY, scale, center;
//1\. Scale the canvas to the correct size
//Figure out the scale amount on each axis
scaleX = window.innerWidth / this.canvas.width;
scaleY = window.innerHeight / this.canvas.height;
//Scale the canvas based on whichever value is less: `scaleX` or `scaleY`
scale = Math.min(scaleX, scaleY);
this.canvas.style.transformOrigin = "0 0";
this.canvas.style.transform = "scale(" + scale + ")";
//2\. Center the canvas.
//Decide whether to center the canvas vertically or horizontally.
//Wide canvases should be centered vertically, and
//square or tall canvases should be centered horizontally
if (this.canvas.width > this.canvas.height) {
center = "vertically";
} else {
center = "horizontally";
}
//Center horizontally (for square or tall canvases)
if (center === "horizontally") {
let margin = (window.innerWidth - this.canvas.width * scaleY) / 2;
this.canvas.style.marginLeft = margin + "px";
this.canvas.style.marginRight = margin + "px";
}
//Center vertically (for wide canvases)
if (center === "vertically") {
let margin = (window.innerHeight - this.canvas.height * scaleX) / 2;
this.canvas.style.marginTop = margin + "px";
this.canvas.style.marginBottom = margin + "px";
}
//3\. Remove any padding from the canvas and set the canvas
//display style to "block"
this.canvas.style.paddingLeft = 0;
this.canvas.style.paddingRight = 0;
this.canvas.style.display = "block";
//4\. Set the color of the HTML body background
document.body.style.backgroundColor = backgroundColor;
//5\. Set the game engine ("this") and the pointer to the correct scale.
//This is important for correct hit testing between the pointer and sprites
this.pointer.scale = scale;
this.scale = scale;
}
这里有一些您以前没有见过的新东西,所以让我们来看一下这段代码是如何工作的。代码做的第一件事是计算出浏览器窗口比画布大多少倍:
scaleX = window.innerWidth / this.canvas.width;
scaleY = window.innerHeight / this.canvas.height;
然后,它使用这两个值中较小的一个来设置画布的比例:
scale = Math.min(scaleX, scaleY);
产生的scale值与 CSS transformOrigin和transform方法一起使用,以该数量缩放画布。
this.canvas.style.transformOrigin = "0 0";
this.canvas.style.transform = "scale(" + scale + ")";
(transformOrigin 方法将画布的 x/y 原点设置为其左上角:“0 0”)。)
如果画布的宽度大于高度,它应该垂直居中。如果画布的高度大于宽度,或者画布是方形的,它应该水平居中:
if (this.canvas.width > this.canvas.height) {
center = "vertically";
} else {
center = "horizontally";
}
要使画布居中,请找出缩放后的画布大小与浏览器窗口大小之间的差异。然后使用该值的一半来设置左右画布边距(如果居中是垂直的)或上下画布边距(如果居中是水平的)。
//Center horizontally (for square or tall canvases)
if (center === "horizontally") {
let margin = (window.innerWidth - this.canvas.width * scaleY) / 2;
this.canvas.style.marginLeft = margin + "px";
this.canvas.style.marginRight = margin + "px";
}
//Center vertically (for wide canvases)
if (center === "vertically") {
let margin = (window.innerHeight - this.canvas.height * scaleX) / 2;
this.canvas.style.marginTop = margin + "px";
this.canvas.style.marginBottom = margin + "px";
}
接下来,删除画布上任何可能的填充,将画布的显示样式设置为block,并将 HTML body元素的backgroundColor设置为函数参数中提供的任何颜色。
this.canvas.style.paddingLeft = 0;
this.canvas.style.paddingRight = 0;
this.canvas.style.display = "block";
document.body.style.backgroundColor = backgroundColor;
最后,将指针和游戏引擎的scale属性设置为与计算的scale相同的值。
this.pointer.scale = scale;
this.scale = scale;
这确保了浏览器的鼠标和触摸位置将正确地对应于缩放画布上的点。
您可能需要做的最后一件事就是从运行游戏的页面上的 HTML 元素中删除任何可能的默认填充。将这个 CSS 放到 HTML 文档中的<title>标签之后:
<style> * {margin: 0; padding: 0;} </style>
星号是代表“一切”的 CSS 代码该语句将所有 HTML 元素的边距和填充设置为 0。
现在我们准备好出发了!我们有一个很棒的小游戏引擎,充满了铃声和哨声。但是怎么用它来做一个真正的游戏呢?
案例研究:Bloxyee
在本章的源文件中,你会发现一个名为 Bloxyee 的游戏原型,它是使用我们新的游戏引擎构建的。这是本书中最完整的游戏原型,包括许多你想在专业标准游戏中使用的功能:加载栏,动画场景过渡,补间和粒子效果,以及一个简单的用户界面。玩几次 Bloxyee 来感受一下。图 11-5 说明了游戏的主要特点和流程,基于老款雅达利游戏 Breakout。

图 11-5 。Bloxyee:突破式游戏
当球碰到球拍时,球拍会像果冻一样晃动。当球击中其中一个方块时,方块消失在满天的星星中,玩家得一分。如果球碰到屏幕底部,屏幕就会晃动,玩家就会失去一分。只要按下播放按钮,音乐就开始播放,每当球碰到其中一个积木或球拍时,就会发出弹跳的声音。当所有的方块都被打破后,标题屏幕会滑回来,显示最终分数,并让您单击播放按钮重新播放。
从本书的其他例子来看,大多数游戏代码对你来说都很熟悉,但是有一些新的特性,比如屏幕抖动效果和加载进度条。让我们先来看看完整注释的代码清单,然后再仔细看看游戏的特殊功能细节。
注游戏为什么叫 Bloxyee?因为这是一个关于积木的游戏。在进行了大约 50 次网络搜索和一些疯狂的拼写实验后,“Bloxyee”是我能找到的唯一一个与已经存在的积木游戏不匹配的名字。事实上,历史上似乎从来没有人把这七个字母按照这样的顺序放在一起。然而,好景不长——在我进行网络搜索后不久,“bloxyee.com”就被某人神秘地注册了(或者……什么的!).
完整的代码清单
下面是完整的 Bloxyee 代码清单:
<!doctype html>
<meta charset="utf-8">
<title>Bloxyee</title>
<style> * {margin: 0; padding: 0;} </style>
<body>
<script type="module">
//Import the game engine
import {game} from "../../library/engine";
//Initialize the game engine and load all the assets
let g = game(
512, 512, setup,
[
"img/bloxyee.json",
"sounds/bounce.wav",
"sounds/music.wav",
"fonts/puzzler.otf"
],
load
);
//Start the engine
g.start();
//Scale and center the game
g.scaleToWindow();
//Optionally rescale the canvas if the browser window is changed
window.addEventListener("resize", event => {
g.scaleToWindow();
});
//Game variables
let paddle, ball, topBorder, blocks, blockFrames,
music, bounceSound, message, titleMessage,
//The size of the grid of blocks
gridWidth = 8,
gridHeight = 5,
cellWidth = 64,
cellHeight = 64,
//title sprites
title, playButton,
//Groups
titleScene, gameScene,
//Score
score = 0,
//The paddle wobble tween
paddleWobble;
function load() {
//Display the loading progress bar while the game
//assets load (you'll learn how asset loading works in later in this chapter)
g.progressBar.create(g.canvas, g.assets);
g.progressBar.update();
}
function setup() {
//Remove the progress bar
g.progressBar.remove();
//Sound and music
bounceSound = g.assets["sounds/bounce.wav"];
music = g.assets["sounds/music.wav"];
music.loop = true;
//Create the sprites
//1\. The `titleScene` sprites
//The `title`
title = g.sprite(g.assets["title.png"]);
//The play button
playButton = g.button([
g.assets["up.png"],
g.assets["over.png"],
g.assets["down.png"]
]);
//Set the `playButton's x property to 514 so that
//it's offscreen when the sprite is created
playButton.x = 514;
playButton.y = 350;
//Set the `titleMessage` x position to -200 so that it's offscreen
titleMessage = g.text("start game", "20px puzzler", "white", -200, 300);
//Make the `playButton` and `titleMessage` slide in from the
//edges of the screen using the `slide` function
g.slide(playButton, 250, 350, 30, ["decelerationCubed"]);
g.slide(titleMessage, 250, 300, 30, ["decelerationCubed"]);
//Create the `titleScene` group
titleScene = g.group(title, playButton, titleMessage);
//2\. The `gameScene` sprites
//The paddle
paddle = g.sprite(g.assets["paddle.png"]);
g.stage.putBottom(paddle, 0, -24);
//The ball
ball = g.sprite(g.assets["ball.png"]);
g.stage.putBottom(ball, 0, -128);
//Set the ball's initial velocity
ball.vx = 12;
ball.vy = 8;
//Add a black border along the top of the screen
topBorder = g.rectangle(512, 32, "black");
//Plot the blocks
//First create an array that stores references to all the
//block frames in the texture atlas
blockFrames = [
"blue.png",
"green.png",
"orange.png",
"red.png",
"violet.png"
];
//Use the `grid` function to randomly plot the
//blocks in a grid pattern
blocks = g.grid(
gridWidth, gridHeight, 64, 64,
false, 0, 0,
() => {
//Choose a random block from the tileset for each grid cell
let randomBlock = g.randomInt(0, 4);
return g.sprite(g.assets[blockFrames[randomBlock]]);
}
);
//Position the blocks 32 pixels below the top of the canvas
blocks.y = 32;
//A text sprite for the score
message = g.text("test", "20px puzzler", "white");
message.x = 8;
message.y = 8;
//Add the game sprites to the `gameScene` group
gameScene = g.group(paddle, ball, topBorder, blocks, message);
//Position the `gameScene` offscreen at -514 so that it's
//not visible when the game starts
gameScene.x = -514;
//Program the play button's `press` function to start the game.
//Start the music, set the `state` to `play`
//make `titleScene` slide out to the right and
// `gameScene` slide in from the left
playButton.press = () => {
if (!music.playing) music.play();
g.state = play;
g.slide(titleScene, 514, 0, 30, ["decelerationCubed"]);
g.slide(gameScene, 0, 0, 30, ["decelerationCubed"]);
};
}
//The `play` function contains all the game logic and runs in a loop
function play() {
//Move the paddle to the mouse position
paddle.x = g.pointer.x - paddle.halfWidth;
//Keep the paddle within the screen boundaries
g.contain(paddle, g.stage.localBounds);
//Move the ball using the `move` convenience function
g.move(ball);
//Bounce the ball off the screen edges. Use the `contain` method
//with a custom `bounds` object (the second argument) that defines
//the area in which the ball should bounce around.
//Play the `bounceSound` when the ball hits one of these edges,
//and reduce the score by 1 if it hits the ground
let ballHitsWall = g.contain(
ball,
{x: 0, y: 32, width: g.stage.width, height: g.stage.height},
true,
//what should happen when the ball hits the edges of the boundary?
(collision) => {
//Play the bounce sound
bounceSound.play();
//If the ball hits the bottom, perform these additional tasks:
if (collision === "bottom") {
//Subtract 1 from the score
score -= 1;
//Shake the screen (the `gameScene` sprite.)
//(You'll learn how the `shake` method works later in this chapter)
g.shake(gameScene, 0.05, true);
}
}
);
/*
Check for a collision between the ball and the paddle, and
bounce the ball off the paddle. Play the `bounceSound` when
the collision occurs.
You can use the universal `hit` collision function to do this.
`hit` arguments:
spriteA, spriteB, reactToCollision?, bounce?, useGlobalCoordinates?,
actionWhenCollisionOccurs
*/
let ballHitsPaddle = g.hit(
ball, paddle, true, true, true,
(collision) => {
//1\. Play the bounce sound
bounceSound.play();
//2\. Make the paddle wobble when the ball hits it.
//a. Remove any possible previous instances of the
//`paddleWobble` tween, and reset the paddle's scale
if (paddleWobble) {
paddle.scaleX = 1;
paddle.scaleY = 1;
g.removeTween(paddleWobble);
};
//b. Create the wobble tween
paddleWobble = g.wobble(
paddle, 1.3, 1.2, 5, 10, 10, -10, -10, 0.96
);
}
);
/*
Check for a collision between the ball and all
the blocks in the grid.
You can use the universal `hit` collision function to do this. If one
of the first two arguments is an array, the `hit` function will loop
through all the sprites in that array and check it for a collision
with the other sprite.
`hit` arguments:
spriteA, spriteB, reactToCollision?, bounce?, useGlobalCoordinates?
actionWhenCollisionOccurs
*/
let ballHitsBlock = g.hit(
ball, blocks.children, true, true, true,
(collision, block) => {
//Add 1 to the score, play the bounce sound
//and remove the block that was hit
score += 1;
bounceSound.play();
g.remove(block);
//Create the particle effect
//1\. Find the `globalCenterX` and `globalCenterY`
//position for the block that was hit
let globalCenterX = block.gx + block.halfWidth,
globalCenterY = block.gy + block.halfHeight;
//2\. Create the effect
g.particleEffect(
globalCenterX, globalCenterY, //x and y position
() => g.sprite(g.assets["star.png"]), //Particle function
20, //Number of particles
0.3, //Gravity
true, //Random spacing
0, 6.28, //Min/max angle
12, 24, //Min/max size
5, 10, //Min/max speed
0.005, 0.01, //Min/max scale speed
0.005, 0.01, //Min/max alpha speed
0.05, 0.1 //Min/max rotation speed
);
}
);
//Display the current score
message.content = `Score: ${score}`;
//Check for the end of the game
if (blocks.empty) {
//Pause the game, wait for 1 second, and then
//call the `end` function
g.pause();
g.wait(1000).then(() => end());
}
}
function end() {
//Display the `titleScene` and hide the `gameScene`
g.slide(titleScene, 0, 0, 30, ["decelerationCubed"]);
g.slide(gameScene, -514, 0, 30, ["decelerationCubed"]);
//Display the final score
titleMessage.content = `Score: ${score}`;
//Lower the music volume
music.volume = 0.3;
//Assign a new button `press` action to
//`restart` the game
playButton.press = () => {
restart();
};
}
function restart() {
//Remove any remaining blocks if there are any
g.remove(blocks);
//Plot a new grid of blocks
blocks = g.grid(
gridWidth, gridHeight, 64, 64,
false, 0, 0,
() => {
//Choose a random block from the
//`blockFrames` array for each grid cell
let randomBlock = g.randomInt(0, 4);
return g.sprite(g.assets[blockFrames[randomBlock]]);
}
);
//Add the blocks to the `gameScene` and position it
gameScene.addChild(blocks);
blocks.y = 32;
blocks.x = 0;
//Reset the ball and paddle positions
g.stage.putBottom(paddle, 0, -22);
g.stage.putBottom(ball, 0, -128);
//Reset the ball's velocity
ball.vx = 12;
ball.vy = 8;
//Reset the score
score = 0;
//Set the music volume to full
music.volume = 1;
//Hide the titleScene and reveal the gameScene
g.slide(titleScene, 514, 0, 30, ["decelerationCubed"]);
g.slide(gameScene, 0, 0, 30, ["decelerationCubed"]);
//Set the game state to `play` and `resume` the game
g.state = play;
g.resume();
}
</script>
</body>
现在让我们来看看这一切是如何工作的。
加载进度条
如果你的游戏中有很多声音或图像,加载它们可能需要几秒钟或更长时间,尤其是当你的游戏在网上运行时。为了让你的玩家知道,这样他们就不会认为他们的设备在这种情况下冻结了,显示某种“请等待加载”的信息是很重要的。显示一个加载栏是很常见的,它根据已经加载的游戏素材的百分比来缩放。当达到 100%时,游戏运行。
Bloxyee 在启动时显示的就是这样一个加载栏。在游戏加载的一两秒钟内,你会看到一个灰色背景上的蓝色条,随着游戏资源的加载,它的宽度会增加。它还显示已加载素材的百分比。当百分比达到 100%时,标题屏幕出现。图 11-6 显示了这个加载进度条的样子。

图 11-6 。一个正在加载的进度条
你会在library/display文件夹中找到这个progressBar 对象。这是一个由三个精灵组成的复合对象:一个灰色矩形作为背景,一个蓝色矩形作为前景,一个文本精灵显示百分比。progressBar有一个参照点canvas,这样它就可以在垂直和水平方向对中。它还有一个对assets对象的引用,这样它就知道当前已经加载了多少素材。progressBar还需要一个update方法,该方法在每一帧上被调用,因此它可以根据当前加载的素材数量动态调整大小。
这是完整的progressBar对象。
export let progressBar = {
maxWidth: 0,
height: 0,
backgroundColor: "gray",
foregroundColor: "cyan",
backBar: null,
frontBar: null,
percentage: null,
assets: null,
initialized: false,
//Use the `create` method to create the progress bar
create(canvas, assets) {
if (!this.initialized) {
//Store a reference to the `assets` object
this.assets = assets;
//Set the maximum width to half the width of the canvas
this.maxWidth = canvas.width / 2;
//Build the progress bar using two rectangle sprites and
//one text sprite:
//1\. Create the background bar's gray background
this.backBar = rectangle(this.maxWidth, 32, this.backgroundColor);
this.backBar.x = (canvas.width / 2) - (this.maxWidth / 2);
this.backBar.y = (canvas.height / 2) - 16;
//2\. Create the blue foreground bar. This is the element of the
//progress bar that will increase in width as assets load
this.frontBar = rectangle(this.maxWidth, 32, this.foregroundColor);
this.frontBar.x = (canvas.width / 2) - (this.maxWidth / 2);
this.frontBar.y = (canvas.height / 2) - 16;
//3\. A text sprite that will display the percentage
//of assets that have loaded
this.percentage = text("0%", "28px sans-serif", "black");
this.percentage.x = (canvas.width / 2) - (this.maxWidth / 2) + 12;
this.percentage.y = (canvas.height / 2) - 16;
//Flag the `progressBar` as having been initialized
this.initialized = true;
}
},
//Use the `update` method to update the width of the bar and
//percentage loaded each frame:
update() {
//Change the width of the blue `frontBar` to match the
//ratio of assets that have loaded. Adding `+1` to
//`assets.loaded` means that the loading bar will appear at 100%
//when the last asset is being loaded, which is reassuring for the
//player observing the load progress
let ratio = (this.assets.loaded + 1) / this.assets.toLoad;
this.frontBar.width = this.maxWidth * ratio;
//Display the percentage
this.percentage.content = `${Math.floor((ratio) * 100)} %`;
},
//Use the `remove` method to remove the progress bar when all the
//game assets have finished loading
remove() {
//Remove the progress bar using the universal sprite `remove` function
remove(this.frontBar);
remove(this.backBar);
remove(this.percentage);
}
};
我们通过调用它的create方法来创建进度条。它需要访问canvas和assets对象,所以create应该只在这些对象存在于游戏中之后才被调用。initialized属性用于确保create方法中的代码不会运行多次。
initialized: false,
create(canvas, assets) {
if (!this.initialized) {
//...
this.initialized = true;
}
}
一旦create方法中的代码完成运行,就将initialized设置为true。
您可以看到,create方法创建了矩形和文本精灵,并将它们放置在画布的中央。update方法用于改变蓝色frontBar和percentage的宽度。它通过计算当前加载的素材和素材总数之间的比率来实现这一点,然后使用该比率来缩放frontBar的宽度。
update() {
let ratio = (this.assets.loaded + 1) / this.assets.toLoad;
this.frontBar.width = this.maxWidth * ratio;
this.percentage.content = `${Math.floor((ratio) * 100)} %`;
},
如果这个update方法在一个游戏循环中被调用,这些精灵将显示当前每一帧载入的百分比:
function gameLoop {
progressBar.update();
}
你还需要一种方法在我们结束游戏后将progressBar的精灵从游戏中移除。精灵remove方法负责处理:
remove() {
remove(this.frontBar);
remove(this.backBar);
remove(this.percentage);
}
实现加载进度条
现在我们已经建立了progressBar,我们如何在像 Bloxyee 这样的游戏中使用它呢?
你会记得我们的游戏引擎的game函数有一个可选的最终参数,它决定了在加载资源时应该运行的函数。在 Bloxyee 中,这个函数被称为load,您可以在下面的代码中看到:
let g = game(
512, 512, setup,
[
"img/bloxyee.json",
"sounds/bounce.wav",
"sounds/music.wav",
"fonts/puzzler.otf"
],
load
);
g.start();
游戏引擎将在加载资源时循环调用load函数。下面是 Bloxyee 的load方法。你可以看到它首先创建了progressBar,然后更新它:
function load() {
g.progressBar.create(g.canvas, g.assets);
g.progressBar.update();
}
尽管在一个循环中被调用,create方法将只运行一次,因为第一次调用后progressBar.initialized将成为false。每一帧都会连续调用update函数,这就是增加蓝色frontBar的宽度并更新百分比数字的原因。因为progressBar对象是由library/display模块导出的,engine.js 导入显示模块中的所有东西,progressBar现在是游戏对象的一个属性:g。这就是为什么我们可以用g.progressBar来访问它。
当素材完成加载时会发生什么?游戏引擎运行setup状态。它做的第一件事是移除progressBar。
function setup() {
g.progressBar.remove();
//...create the game sprites...
然后像往常一样创建游戏精灵。
这个加载栏只是游戏加载时你可以向玩家展示的一种信息的一个例子。也许你想用一个纺车或者展示一些诗歌?我有意将这个例子模块化,以便您可以将任何类型的对象放入加载状态。只要按照同样的通用格式创建自己独特的加载对象,给我惊喜!您可能还想考虑将进度条的代码更深地集成到游戏引擎或素材加载器中,以进一步整理您的游戏代码。
游戏场景和过渡
这个游戏有两个场景:titleScene和gameScene。这些场景只是将相关的精灵聚集在一起的群组。
titleScene = g.group(title, playButton, titleMessage);
gameScene = g.group(paddle, ball, topBorder, blocks, message);
图 11-7 显示了这些场景的样子。

图 11-7 。布洛 xyee 的两个场景
当 Bloxyee 开始时,titleScene出现在画布上,但是gameScene被定位在屏幕外的左侧,在一个 x 的位置–514。
gameScene.x = -514;
当玩家点击播放按钮时,titleScene向右滑动,gameScene滑到画布上,如图图 11-8 所示。

图 11-8 。点击按钮从标题场景过渡到游戏场景 ??
这种效果是通过使用一个slide补间将场景移动到位来实现的。当按下playButton时发生。
playButton.press = () => {
if (!music.playing) music.play();
g.state = play;
g.slide(titleScene, 514, 0, 30, ["decelerationCubed"]);
g.slide(gameScene, 0, 0, 30, ["decelerationCubed"]);
};
屏幕外的titleScene被补间到 514 的 x 位置,屏幕上的gameScene被补间到 0 的 x 位置。
没有比这更复杂的了。你可以使用这个基本系统在游戏中创建尽可能多的独立场景或关卡。
划水和阻挡碰撞
Bloxyee 的play函数循环运行,处理所有游戏逻辑并检查冲突。第七章中的通用hit功能用于检查球拍和球之间的碰撞。如果hit功能检测到碰撞,它将球弹开,播放bounceSound并使球拍摆动,如图 11-9 中的所示。
let ballHitsPaddle = g.hit(
ball, paddle, true, true, true,
(collision) => {
//1\. Play the bounce sound
bounceSound.play();
//2\. Make the paddle wobble
//Remove any possible previous instances of the
//`paddleWobble` tween, and reset the paddle's scale
if (paddleWobble) {
paddle.scaleX = 1;
paddle.scaleY = 1;
g.removeTween(paddleWobble);
};
//Create the wobble tween
paddleWobble = g.wobble(
paddle, 1.3, 1.2, 5, 10, 10, -10, -10, 0.96
);
}
);

图 11-9 。当球击中球拍时,球拍会晃动
如果快速连续地多次按下挡板,碰撞代码可能会在第一个补间完成之前尝试创建一个新的paddleWobble 补间。如果是这样,桨的比例可能会变得扭曲,因为两个补间动画将同时作用于它。刚刚显示的代码通过在创建新的补间之前重置 paddle 的比例并移除任何当前的paddleWobble补间来防止这种情况发生。
如果球击中其中一个方块,该方块消失在星星的瀑布中,分数增加 1,播放弹跳声,球弹开,如图图 11-10 。下面是实现这一点的代码:

图 11-10 。当一个物体被击中时,星形粒子向四面八方飞去
let ballHitsBlock = g.hit(
ball, blocks.children, true, true, true,
(collision, block) => {
//Add 1 to the score, play the bounce sound
//and remove the block that was hit
score += 1;
bounceSound.play();
g.remove(block);
//Create the particle effect
//1\. Find the globalCenterX and globalCenterY
//position for the block that was hit
let globalCenterX = block.gx + block.halfWidth,
globalCenterY = block.gy + block.halfHeight;
//2\. Create the effect
g.particleEffect(
globalCenterX, globalCenterY, //x and y position
() => g.sprite(g.assets["star.png"]), //Particle function
20, //Number of particles
0.3, //Gravity
true, //Random spacing
0, 6.28, //Min/max angle
12, 24, //Min/max size
5, 10, //Min/max speed
0.005, 0.01, //Min/max scale speed
0.005, 0.01, //Min/max alpha speed
0.05, 0.1 //Min/max rotation speed
);
}
);
在放置粒子效果时,有一个重要的细节需要注意。恒星粒子爆炸从每个区块的中心 x/y 点开始。然而,所有的块都在一个名为blocks的组中。当模块组由setup功能创建时,其 y 位置被设置为 32:
blocks.y = 32;
这将块网格放置在画布顶部下方 32 像素处,如图图 11-11 所示。

图 11-11 。所有块都在一个组中,位于画布顶部下方 32 像素处
为什么这种定位很重要?记住,组有自己的局部坐标。因为blocks组从画布顶部偏移了 32 个像素,所以该组中每个 sprite 的本地 y 位置将比其 y 全局位置少 32 个像素。你知道你可以通过使用 sprite 的gx和gy属性来访问它的全局位置。然而,精灵没有任何内置属性给你它们的全局中心位置。如果你需要全球中心位置,你必须手动计算。下面是前面的代码是如何做到这一点的:
let globalCenterX = block.gx + block.halfWidth,
globalCenterY = block.gy + block.halfHeight;
现在,您可以使用globalCenterX和globalCenterY在块的中心设置粒子效果的起始点,代码如下:
g.particleEffect(globalCenterX, globalCenterY, //...
当你开始制作像 Bloxyee 这样相对复杂的游戏时,关注这些小细节是很重要的。
注意如果你发现自己制作的游戏经常需要你访问精灵的全局中心点,那就直接把全局中心属性添加到DisplayObject类中;你可以称他们为gcx和gcy。
晃动屏幕
Bloxyee 引入了一个新的特效:屏幕抖动。如果球拍未击中,球击中画布底部,屏幕会围绕其轴剧烈抖动,如图图 11-12 所示。

图 11-12 。如果球碰到画布的底部,屏幕就会晃动
这是一个强大的,发自内心的效果,让玩家沉浸在游戏中。
小心然而,屏幕抖动也是近年来电子游戏史上被过度使用的效果。请谨慎使用!
当然,并不是屏幕在晃动——只是gameScene组在围绕其中心点左右快速倾斜。这个效果是在一个名为shake的新功能的帮助下创建的:
shake(gameScene, 0.05, true);
shake函数的第一个参数是 sprite,第二个参数是以弧度表示的抖动幅度。第三个参数是一个布尔值,当true表示晃动应该围绕精灵的中心点成角度。
也可以在 x / y 平面上上下晃动,如图图 11-13 所示。

图 11-13 。让屏幕上下晃动
只需将第二个参数设置为一个以像素为单位的数字,它决定了精灵应该抖动的最大值。然后将第三个参数设置为false,禁用角度抖动。
shake(gameScene, 16, false);
你更喜欢哪种抖音风格完全由你决定。
shake 函数的工作方式类似于您在前面章节中学习使用的粒子效果和补间函数。一个游戏中所有晃动的小精灵都存储在一个名为shakingSprites 的数组中。
export let shakingSprites = [];
然后,该阵列中的所有精灵通过游戏循环在每一帧中更新他们的晃动:
if (shakingSprites.length > 0) {
for(let i = shakingSprites.length - 1; i >= 0; i--) {
let shakingSprite = shakingSprites[i];
if (shakingSprite.updateShake) shakingSprite.updateShake();
}
}
这是我们用来更新补间,粒子和按钮的相同技术。
shake函数的工作原理是给提供的 sprite 添加一个updateShake方法,并决定效果应该是有角度的还是上下的。如果效果是有角度的,子画面会快速交替向左和向右旋转。如果效果是向上和向下的,精灵会在提供的摇动幅度范围内快速移动一个随机量。在这两种情况下,每次震动的幅度都减少 10 %,因此震动逐渐稳定下来。这是完成所有这些工作的完整的shake函数。(你会在library/display文件里找到。)
export function shake(sprite, magnitude = 16, angular = false) {
//A counter to count the number of shakes
let counter = 1;
//The total number of shakes (there will be 1 shake per frame)
let numberOfShakes = 10;
//Capture the sprite's position and angle so you can
//restore them after the shaking has finished
let startX = sprite.x,
startY = sprite.y,
startAngle = sprite.rotation;
//Divide the magnitude into 10 units so that you can
//reduce the amount of shake by 10 percent each frame
let magnitudeUnit = magnitude / numberOfShakes;
//The `randomInt` helper function
let randomInt = (min, max) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
//Add the sprite to the `shakingSprites` array if it
//isn't already there
if(shakingSprites.indexOf(sprite) === -1) {
shakingSprites.push(sprite);
//Add an `updateShake` method to the sprite.
//The `updateShake` method will be called each frame
//in the game loop. The shake effect type can be either
//up and down (x/y shaking) or angular (rotational shaking)
sprite.updateShake = () => {
if(angular) {
angularShake();
} else {
upAndDownShake();
}
};
}
//The `upAndDownShake` function
function upAndDownShake() {
//Shake the sprite while the `counter` is less than
//the `numberOfShakes`
if (counter < numberOfShakes) {
//Reset the sprite's position at the start of each shake
sprite.x = startX;
sprite.y = startY;
//Reduce the magnitude
magnitude -= magnitudeUnit;
//Randomly change the sprite's position
sprite.x += randomInt(-magnitude, magnitude);
sprite.y += randomInt(-magnitude, magnitude);
//Add 1 to the counter
counter += 1;
}
//When the shaking is finished, restore the sprite to its original
//position and remove it from the `shakingSprites` array
if (counter >= numberOfShakes) {
sprite.x = startX;
sprite.y = startY;
shakingSprites.splice(shakingSprites.indexOf(sprite), 1);
}
}
//The `angularShake` function
//First set the initial tilt angle to the right (+1)
let tiltAngle = 1;
function angularShake() {
if (counter < numberOfShakes) {
//Reset the sprite's rotation
sprite.rotation = startAngle;
//Reduce the magnitude
magnitude -= magnitudeUnit;
//Rotate the sprite left or right, depending on the direction,
//by an amount in radians that matches the magnitude
sprite.rotation = magnitude * tiltAngle;
counter += 1;
//Reverse the tilt angle so that the sprite is tilted
//in the opposite direction for the next shake
tiltAngle *= -1;
}
//When the shaking is finished, reset the sprite's angle and
//remove it from the `shakingSprites` array
if (counter >= numberOfShakes) {
sprite.rotation = startAngle;
shakingSprites.splice(shakingSprites.indexOf(sprite), 1);
}
}
}
当球击中画布底部时,Bloxyee 使用shake函数。游戏代码使用我们定制的contain函数将球弹离画布边缘。您将回忆起contain函数返回一个碰撞对象,告诉您球击中了容器的哪一侧。如果它触到了“底部”,分数会被扣除一分,并且gameScene会晃动。下面是来自 Bloxyee 的play函数的代码:
let ballHitsWall = g.contain(
ball,
{x: 0, y: 32, width: g.stage.width, height: g.stage.height},
true,
//what should happen when the ball hits the edges of the boundary?
(collision) => {
//Play the bounce sound
bounceSound.play();
//If the ball hits the bottom, perform these additional tasks:
if (collision === "bottom") {
//Subtract 1 from the score
score -= 1;
//Shake the screen (the `gameScene` sprite)
g.shake(gameScene, 0.05, true);
}
}
);
这个shake功能不仅仅是为了创造屏幕抖动效果。因为你可以用它来摇动任何精灵,你可以用它来做一般的特效——例如,如果一个游戏物体被导弹击中。
结束游戏
当没有更多的块时,Bloxyee 结束。因为每个块都是父组blocks的子组,所以可以使用empty属性来检查blocks是否包含任何子组。你会回忆起第四章中的,我们在DisplayObject类上创建了empty属性,它被所有的 sprite 类型继承。如果一个精灵或组没有孩子,那么empty所做的就是返回true。下面是来自创建empty属性的DisplayObject 类的代码:
get empty() {
if (this.children.length === 0) {
return true;
} else {
return false;
}
}
它实际上只是一个方便的属性,帮助我们编写更可读的代码。
如果blocks.empty是true,你就知道没有更多的块了。当这种情况发生时,游戏暂停,延迟 1 秒后,调用end函数。下面是来自 Bloxyee 的play函数的代码,它检查游戏的结束:
if (blocks.empty) {
g.pause();
g.wait(1000).then(() => end());
}
pause方法告诉游戏引擎不要在游戏循环中运行state函数。这实质上冻结了屏幕上的所有动作,除了特殊效果,如粒子或补间。
end功能将gameScene滑向左侧,并将titleScene从右侧滑回视图。它显示乐谱并降低音乐音量。然后它重新编程playButton的press方法,这样按下播放按钮将调用restart函数来重启游戏。
function end() {
//Display the `titleScene` and hide the `gameScene`
g.slide(titleScene, 0, 0, 30, ["decelerationCubed"]);
g.slide(gameScene, -514, 0, 30, ["decelerationCubed"]);
//Display the final score
titleMessage.content = `Score: ${score}`;
//Lower the music volume
music.volume = 0.3;
//Assign a new button `press` action to
//`restart` the game
playButton.press = () => {
restart();
};
}
如果按下按钮,restart功能会重置游戏的所有初始条件,并重建方块网格。它会重置乐谱,将音乐调回到最大音量,并再次滑回gameScene。最后,restart函数做的最重要的事情是将游戏state设置回play,并调用游戏引擎的resume方法:
g.state = play;
g.resume();
resume函数告诉引擎在游戏循环中运行当前的state函数。这就是整个游戏重新开始的原因。
你可以像这样一遍又一遍地玩 Bloxyee,永远,如果你想的话。这只是一个简单的原型,让你开始,但为什么不花一点时间,把它变成一个真正的游戏?例如,您可以修改游戏以增加每个级别的难度,增加方块的数量,添加电源和要收集或避免的下落物体,并增加网格中的方块数量。
你的下一步
我们已经到了书的结尾!你从这里去哪里?在下面的附录中,你可以学到更多关于视频游戏设计的技巧,但是你现在已经掌握了开始制作游戏所需的所有技能。
Bloxyee 是一个基本的视频游戏原型。这意味着,如果你明白像 Bloxyee 这样的游戏是如何工作的,你几乎可以制作任何游戏。在 Bloxyee、Flappy Fairy、Treasure Hunter 和本书中学习的所有其他游戏原型和技术之间,您拥有制作整个 2D 动作游戏世界的工具和技能。所以去做吧!想想你想做什么样的游戏,决定这本书里的哪些原型和技术对你最有帮助,然后以它们为模型开始构建你的游戏。只要有一点想象力,你会对自己创造的东西感到惊讶。
但是,当然,这不是结束。离结束还远着呢!你可以学习更多的东西来加深和提高你的技能。这里有一些您可能想进一步探索的技术和特殊兴趣:
- WebGL 虽然 WebGL 被许多流行的渲染引擎用作底层渲染引擎,如 PixiJS、BabylonJS 和 ThreeJS,但花时间详细了解核心 API 的工作原理是值得的。开始学习 WebGL 的一个好地方是 Brian Danchilla 的【HTML 5 的入门 web GL(a press,2012)。
- 虽然这本书涵盖了你在 2D 动作游戏中需要了解的大部分物理知识,但如果你想做任何详细、精确的物理模拟,你还需要进一步钻研。关于这个主题的一本优秀的书是 Dev Ramtal 和 Adrian Dobre 的【JavaScript、游戏、动画和模拟的物理学(a press,2014)。
- 动画和运动图形 :你知道脚本动画的基础,以及如何做补间和创建运动路径。但是,如果你想做一些真正复杂的东西,比如一个完全交互的,关节式机械臂,该怎么办呢?这将有助于了解反向运动学。如果你想创建一个模拟的 3D 环境呢?一些 3D 矩阵数学会帮助你。你可以在 Billy Lamberta 和 Keith Peter 的书中了解到所有这些主题以及更多内容,基础 HTML5 动画与 JavaScript (Apress,2011)。太经典了!
- 碰撞检测 :本章后面的附录会给你展示如何为 2D 游戏做准确的碰撞检测,它几乎会涵盖你需要知道的一切。但是,如果你想把这些技能提高到一个新的水平,克里斯特埃里克森的经典著作《实时碰撞检测》(摩根考夫曼,2005 年)是一个好的起点。尽管代码示例是用 C++(与 JavaScript 属于同一语言家族)编写的,但矢量数学和算法是通用的,将概念翻译成 JavaScript 并不困难。实时碰撞检测还涵盖了您需要了解的 3D 游戏碰撞检测的所有内容,因此,当您准备好迈出这一步时,您将做好充分准备。
这本书是关于学习如何自己制作游戏,从头开始。如您所见,您不需要使用商业游戏引擎、框架、第三方库或昂贵的软件。你可以免费制作和发布游戏,只需要一个文本编辑器、一个浏览器和任何一台旧电脑。因为本书中的所有代码都使用了基础技术、普通 JavaScript 和开源标准化 API(如 Canvas 和 WebAudio API),所以您可以肯定您编写的代码将经得起未来几年或几十年的时间考验。您的代码库以及您投入学习的所有时间和精力都不会受到某个大公司的摆布或突发奇想,这些大公司可能会在一夜之间倒闭、出售或决定完全改变其 API。你所有的代码都属于你,并且永远属于你。你知道如何用几百行代码制作出优秀的游戏,你知道代码的每一个细节是如何工作的,以及如何改变和微调它以达到你需要的效果。恭喜你,你现在是一名视频游戏设计师了!
那么,你从这里去哪里?
这不是很明显吗?
开始制作一些伟大的游戏吧!
十二、附录 A:运动和碰撞检测的向量
你可能不知道,但在你的游戏中有一个看不见的世界,有看不见的力量在起作用,叫做矢量。它们使您的对象移动并检测碰撞,并帮助您创建真实世界物理对象的模拟。向量就像游戏宇宙中的原子和分子——一切都依赖于它们,但它们很难用肉眼看到。
在这个附录中,我们将揭开这个神秘领域的面纱,来检查这些视频游戏世界中最小但最重要的组件。借助一点简单的数学知识,矢量是解码游戏环境空间整体几何图形的关键。学习如何使用它们可以让你无限制地控制你的游戏。
如果可以随意控制重力、风、子弹轨迹、物理热力学定律,你会有什么感受?就像《黑客帝国》中的一个角色一样,你将在你自己的游戏世界中拥有那种力量。
什么是矢量?
矢量是直线。它们有起点和终点。图 A-1 展示了一个叫做 v1 的向量的例子。向量从 A 点开始,到 b 点结束。

图 A-1 。称为 v1(向量 1 的简写)的向量,有起点和终点
注意在这个附录中,我将使用一个简单的命名约定来描述向量。矢量名称将以 v 加矢量编号开头,如 v1 、 v2 和 v3 。这将有助于保持代码紧凑。
定义一条线的两点能告诉我们什么?正如你在图 A-1 中看到的,向量告诉你它从哪里开始,在哪里结束,以及它指向的方向。这是矢量的两个定义特征:
- 长度(通常用更专业的术语来指代)
*** 方向**
**这听起来像是在游戏中有用的信息吗?你已经在使用这些信息了。叫速度!
x 和 y 组件
每当精灵移动时,它都会创建一个向量。向量是由精灵的垂直和水平速度创建的,更好地称为我们亲爱的朋友, v x 和 v y ,如图图 A-2 所示。

图 A-2 。当游戏对象移动时,它们的 vx 和 vy 属性描述了一个向量
任何移动的精灵都有水平速度( vx )和垂直速度( vy )。当这些速度结合在一起时,精灵会向某个方向移动。如果精灵的 vx 为 5,而 vy 为–5,它将看起来向右上方倾斜移动。当这种情况发生时,精灵会在它之前的位置和它的新位置之间创建一个不可见的向量。
这种由物体运动产生的矢量被称为运动矢量 。所以你一直在创造和使用向量,而自己却不知道!
值 vx 被称为矢量的 x 分量。,而 vy 则是矢量的y*分量*。图 A-3 显示了 vx 和 vy 如何融入大画面。
**
图 A-3 。向量的 x 和 y 分量:vx 和 vy
你可以用 vx 和 vy 值来描述任何向量。实际上, vx 和 vy 值都是向量。你不需要更多的信息来有效地使用向量。
如果你知道向量的起点和终点,你可以用下面的简单公式计算出向量的 vx 和 vy 值:
vx = b.x – a.x;
vy = b.y – a.y;
从终点 x 和 y 点数中减去起点 x 和 y 点数即可。
如果你只知道向量的 vx 和 vy 属性以及向量的起点,你可以使用这些公式计算出终点:
b.x = a.x + vx;
b.y = a.y + vy;
看一下图 A-3 ,看看你是否能算出这些值是如何得到的。如果你一次只迈出一小步,这很容易。用简单的英语,它说:
新位置(B 点)与旧位置(A 点)相同,加上速度(vx 和 vy)。
有一种简写这个公式的方法。这看起来眼熟吗?
x += vx;
y += vy;
这就是我们在这本书里用来移动精灵的东西!
你看到这些概念有多容易理解了吗?这只是基本的数学。
需要记住的一件非常重要的事情是,如果你有 vx 和 vy 属性,你就有一个向量。
矢量幅度
每个矢量都有长度。在几何学中,向量的长度被称为其大小。知道矢量的大小是很重要的,这样你就可以计算出物体有多远或者它们移动有多快。如果一个飞船精灵的运动矢量大小为 3,那么你知道它的速度是多少。你也可以使用这些信息来预测或解决与另一个物体的碰撞,你很快就会知道。
那么我们怎样才能知道一个矢量的大小呢?它是 A 点和 b 点之间的距离。借助于游戏设计者可靠的老备用工具——勾股定理,你可以很容易地计算出这一点:
m = Math.sqrt(vx * vx + vy * vy);
我用变量名m来指代震级。图 A-4 说明了如何找到矢量的大小。

图 A-4 。用毕达哥拉斯定理求矢量的大小。数值已四舍五入
在一个游戏中,5.8 的星等可以告诉你宇宙飞船移动的速度。A 点是船的起始位置,B 点是它的终点位置,而星等是它的速度。在这个例子中,5.8 可能意味着飞船在一帧运动中(1/60thof second)移动了 5.8 个像素。)
或者你可以用星等来确定这艘船离敌人有多远。在这种情况下,A 点将是船,B 点将是敌人,数量级将代表他们之间的距离。
计算角度
知道矢量的角度通常很有用。一点简单的三角学就能帮你找到:
angle = Math.atan2(vy, vx);
注意对于Math.atan2方法,y属性是第一个参数,x属性是第二个参数。
这个公式给出一个弧度值。要计算角度的度数,将结果乘以 180 除以 PI (3.14),如下所示:
angle = Math.atan2(vy, vx) * 180 / Math.PI;
这也有有趣的另一面。如果你只有一个向量的角度和大小(长度),你怎么能找到它呢?再多学一点三角学也会有所帮助:
vx = m * Math.cos(angle);
vy = m * Math.sin(angle);
记住,你所需要的是计算任何向量的 vx 和 ??【vy 属性。有了这些结果,你就万事俱备了。有了 vx 和 vy ,你仍然有一个矢量,尽管它可能还没有一个具体的起点或终点。
这些公式是能够在矢量和角度之间切换的关键,正如你将在本附录中看到的,它们有无穷的效用。图 A-5 显示了它们的使用方法。

图 A-5 。找到一个向量的角度,并找到 vx 和 vy 值
矢量法线
向量隐藏了一个深刻的,黑暗的秘密。紧贴每个矢量底部的是两个不可见的、有点模糊的附加矢量,称为法线。
其中一条法线在向量的左边,另一条在向量的右边。它们与主向量完全垂直(成 90 度)。它们共同构成了矢量赖以存在的基础。图 A-6 展示了左右法线如何与主向量连接。法线定义了向量的“法线”方向。它们定义了矢量所站的地面,所以你总是知道在矢量的坐标系中哪个方向是“向上的”。

图 A-6 。左右法线垂直于主向量,有助于定义向量的坐标空间
左右法线也是向量。左法线是指向左边的向量,右法线指向右边。不管你想不想,每次你创建一个向量,你实际上是在创建三个向量:主向量和它的两个法线。法线与主向量的大小相同。它们有助于定义向量的坐标空间。
左法线由变量lx和ly表示。它们很容易计算:
lx = vy;
ly = -vx;
如你所见,这只是主向量的 vx 和 vy 属性的 90 度扭曲。右法线是这样找到的:
rx = -vy;
ry = vx;
一旦你找到了左右法线的lx、ly、rx和ry值,你就可以很容易地计算出其余的矢量信息。它们的起点(a)总是与主向量相同,所以你可以这样计算它们的终点(b):
leftNormal.b.x = v1.a.x + lx;
leftNormal.b.y = v1.a.y + ly;
rightNormal.b.x = v1.a.x + rx;
rightNormal.b.y = v1.a.y + ry;
现在你有了两个全新的向量,如果你需要的话。您可以将本附录中的任何其他矢量计算应用于这些新矢量。
直观地理解这一切要容易得多,所以图 A-7 显示了所有这些值是如何找到的。请将这些信息放在手边,因为您很快就会用到它们。
为什么能够计算矢量的法线很重要?你很快就会看到,它们对于研究如何将物体从其他物体上弹开是必不可少的。

图 A-7 。计算法线
标准化向量
有时你需要知道矢量指向的方向。一个物体要去哪里?更重要的是,你能利用这些信息将其他物体导向同一方向吗?
这就是标准化向量的技术变得重要的地方。标准化向量有明确的方向,但是它们的大小缩放为 1,这是向量的最小大小。如果你先把向量做得尽可能小,你就可以很容易地把它放大到任何大小,并保持完美的比例。
归一化向量由变量名dx和dy表示,它们是这样找到的:
dx = vx / m;
dy = vy / m;
你只需用矢量的大小来除 vx 和 ?? 和 vy。结果是一个长度为 1 的非常小的向量。在游戏中,它将是单个像素的大小。图 A-8 说明了这是如何工作的。

图 A-8 。标准化向量以帮助将其缩放到不同的大小
注意不要混淆正常化和向量法线。非常令人困惑(而且是令人困惑!),它们是两个完全分开的东西。法线是垂直于主向量的向量。归一化是一种用于缩放矢量的技术。
归一化向量也被称为单位向量 ,因为它们的大小是 1,这是向量可以是最小的可能的完整单位。您可以使用单位向量将向量缩放到任何大小,可以更大也可以更小。你有没有想过爱丽丝喝的那个瓶子里有什么神奇的成分?这是一个单位矢量!
归一化的向量没有任何起点和终点,所以你可以把它想象成只是挂在空间里,等着有人告诉它该做什么。但最重要的是,归一化得到的 dx 和 dy 值对于计算矢量的方向很有用。
提示dx和 dy 中的 d 是什么意思?它代表 delta,在数学中经常用来表示一个值发生了变化。它通常用来表示一个大的值已经减少到一个较小的值。按照惯例, dx 和 dy 用于表示归一化的 vx 和 vy 值。
在计算单位向量时,有一个小细节需要注意。如果幅度、 vx、或 vy 值有可能为零,则公式将返回NaN(不是数字。)为了避免这种情况,给dx和dy默认值 0,以防万一:
dx = vx / m || 0;
dy = vy / m || 0;
被零除在计算机编程中总是一件坏事——它会导致各种难以发现的错误。
加减向量
你可以把矢量想象成力。一个大小为 5 的向量是一个使你的飞船每帧移动 5 个像素的力。有时候在一个游戏中,你会有不止一个力作用在一个物体上。也许是重力把你的飞船往下拉,风把它推向右边。您可以创建重力和风的向量,然后将这些向量添加到船的运动向量中或从中减去,以找到船的新方向。
让我们以重力为例。假设你有一个宇宙飞船精灵,它的运动向量的大小为 5。然后想象在你的游戏中有一个重力矢量,大小为 2。如果重力作用在你的飞船上,你想从 5 中减去 2 来找到飞船新的运动矢量,它将是 3。
在大多数情况下,减去矢量的大小是没有用的,因为仅仅是大小并不能告诉你矢量的方向。相反,你需要在两个独立的计算中减去向量的 vx 和 vy 值。这非常容易做到。
举个例子,想象你有一艘宇宙飞船在一个平坦的行星表面上盘旋。y 轴上的重力将船往下拉。如果重力是 2,你可以这样描述重力的 vx 和 vy 属性:
gravityVx = 0;
gravityVy = 2;
记住:如果你有一个 vx 和一个 vy 值,你就有一个向量。它可能没有起点和终点,但它仍然是一个向量。在这种情况下,重力只作用在 y 轴上,所以不需要 vx 的值。
以下是如何将重力添加到船的运动矢量中:
ship.vy += gravityVy;
如果这艘船开始时的 vy 为-5,那么它现在的新值就是-3。这将在下一帧把船拉下来。图 A-9 和 A-10 说明了这种矢量加法是如何工作的。

图 A-9 。当你把一个重力矢量和船的运动矢量结合起来会发生什么?在这个例子中,船的 vy 值是–5,重力向量是+2

图 A-10 。当这两个矢量相加时,就会产生一个新的矢量,它将它们的力结合在一起。这将飞船拉向行星表面
当你以这种方式将向量相加时,结果是一个新的向量。这是重力向下的拉力和船向上的推力的结合。如你所见,数学非常简单,但它非常准确地描述了现实世界中发生的事情。
缩放矢量
在我们简单的重力例子中,船在 y 轴上被拉下。这在平台或弹球游戏中没有问题,因为“向下”是屏幕的底部。但是假设你的宇宙飞船正在环绕一颗行星呢?哪条路是向下的?看看图 A-11 ,看你能不能搞清楚。

图 A-11 。要把宇宙飞船拉向一个圆形星球,重力必须同时作用于 x 轴和 y 轴
看着这张图,我会想到两件事:
- 飞船需要知道星球的中心在哪里。
- 为了将飞船移向行星的中心,重力必须作用在 x 轴和 y 轴上。
让我们把目前为止我们所知道的关于向量的所有东西放在一起来解决这个问题。在本章的源文件中运行gravity.html得到一个利用重力绕行星运行的宇宙飞船的工作示例,如图图 A-12 所示。

图 A-12 。使用向量来模拟重力
使用键盘左右键旋转船只,使用向上箭头键使船只前进(你在第六章中学会了如何做)。这是在一个非常真实的重力模拟中把船拉向行星的代码。你可以看到它使用了你到目前为止学过的大部分向量数学:
//1\. Create a vector between the planet and the ship
let vx = planet.x - ship.x,
vy = planet.y - ship.y;
//2\. Find the vector's magnitude
let m = Math.sqrt(vx * vx + vy * vy);
//3\. Normalize the vector
let dx = vx / m,
dy = vy / m;
//4\. Create the gravity vector. Do this by scaling the vector
//between the planet and the ship to a tiny
//fraction of its original size
let gravityVx = dx * 0.05,
gravityVy = dy * 0.05;
//5\. Apply the new gravity vector to the ship's motion vector
ship.vx += gravityVx,
ship.vy += gravityVy;
//6\. Apply the ship's velocity to its position to make the ship move
ship.x += ship.vx;
ship.y += ship.vy;
让我们来看看这是如何工作的。
代码首先计算飞船和行星之间的矢量(vx和vy)。然后计算出大小(m)并计算出单位矢量(dx和dy)。)图 A-13 显示了如何从原始的飞船到行星矢量计算出dx和dy。

图 A-13 。在飞船和星球之间创建一个向量。找到它的dx和 dy 值,这告诉你矢量的方向
重力矢量通过将单位矢量(dx和dy)缩小到其原始尺寸的二十分之一来计算:
let gravityVx = dx * 0.05,
gravityVy = dy * 0.05;
这产生了一个非常小的矢量,但是在规模上与行星和船之间的原始矢量完全相等。它从飞船的中心直接指向星球的中心。
最后,将这个重力矢量应用于船的运动矢量:
ship.x += ship.vx;
ship.y += ship.vy;
飞船会很自然地被拉向星球的中心,不管它在星球的哪一边。图 A-14 显示了这一新的重力矢量是如何影响船的位置的。将其与图 A-13 进行比较,查看差异。

图 A-14 。给飞船的运动矢量增加重力会把它推向行星
真实重力
您可能已经注意到,我们的宇宙飞船示例有一个小问题。无论飞船离行星多远,重力都是一样的。在太空中,物体之间的引力随着它们的远离而减弱。此外,质量大的大物体比小物体有更大的引力。我们可以实现一个更真实的引力系统,只需要稍微调整一下。
首先,创建一些代表行星和飞船质量的值。您使用的值将取决于试错法,但以下值在此示例中效果很好:
ship.mass = 1;
planet.mass = 10;
然后使用以下公式计算重力矢量:
let gravityVx = dx * (planet.mass * ship.mass) / m,
gravityVy = dy * (planet.mass * ship.mass) / m;
如果您添加几个不同质量和初始速度的行星,您可以使用这种技术创建一个复杂的、基于重力的、具有现实物理的太空探索游戏。开始构建自己的宇宙吧!
点积
使用矢量时,一个有用的值是点积。点积告诉你两个矢量是指向同一个方向还是相反的方向。
想象你有两个向量v1和v2。您可以使用以下语法找到它们的点积:
dotProduct = v1.vx * v2.dx + v1.vy * v2.dy
如果点积为正,向量指向相同的方向。如果点积为负,则向量相互背离。这些看似无用的信息实际上可以给你一个强有力的描述,说明你的游戏对象彼此之间的关系,并且它形成了复杂的碰撞和边界检测的基础。
运行boundary.html文件,获得如何使用点积创建环境边界的工作示例。让飞船飞过对角线。如果船的中心在线的左侧,线的颜色是黄色。如果船在线的右边,线变成红色。文本子画面在屏幕顶部显示点积的当前值。您会注意到,一旦点积从正数变为负数,线条颜色就会改变。图 A-15 显示了你将会看到的。

图 A-15 。当宇宙飞船越过这条线时,点积从正变为负
在这个例子中,你可以看到的另一个重要特征是,点积还可以告诉你船的中心距离直线有多少像素。正如您将在本附录的后面看到的,这个数字将被证明对碰撞检测和反应非常有用。
下面是游戏循环中产生这种效果的代码:
//1\. Get a vector between the center of the ship and the start point of the line
let v1 = {};
v1.vx = boundary.ax - ship.centerX;
v1.vy = boundary.ay - ship.centerY;
//2\. Get the boundary line's vector and magnitude
let v2 = {};
v2.vx = boundary.bx - boundary.ax;
v2.vy = boundary.by - boundary.ay;
v2.m = Math.sqrt(v2.vx * v2.vx + v2.vy * v2.vy);
//3\. Figure out the line vector's left normal
v2.ln = {};
v2.ln.vx = v2.vy;
v2.ln.vy = -v2.vx;
//4\. Get the left normal's unit vector (dx and dy)
v2.ln.dx = v2.ln.vx / v2.m;
v2.ln.dy = v2.ln.vy / v2.m;
//5\. Get the dot product between v1 and v2's left normal
let dotProduct = v1.vx * v2.ln.dx + v1.vy * v2.ln.dy;
//If the dot product is positive, make the line yellow,
//if it's negative, make the line red
if (dotProduct > 0) {
boundary.strokeStyle = "yellow";
} else {
boundary.strokeStyle = "red";
}
//Display the value of the dot product
message.content = `Dot product: ${dotProduct}`;
代码做的第一件事是在船的中心点和线的起点之间创建一个名为v1的向量。然后在直线的起点和终点之间创建另一个向量,名为 v2 。图 A-16 展示了这两个向量。

图 A-16 。创建两个向量
然后代码计算 v2 的左法线,如图图 A-17 所示。

图 A-17 。这条线是左法线
然后,代码通过将左法线转换为单位向量来缩小它:
v2.ln.dx = v2.ln.vx / v2.m;
v2.ln.dy = v2.ln.vy / v2.m;
最后,代码找到v1和左法线的单位向量之间的点积:
let dotProduct = v1.vx * v2.ln.dx + v1.vy * v2.ln.dy;
这就是我们需要的神奇数字。如果点积为正,则船在线的左侧;如果是负的,船就在右边。
垂直点积
你可以使用的另一个有趣的值是垂直点积。它与普通的点积完全相同,除了在等式中不使用v1,而是使用v1的法线:垂直向量。(可以用左法线,也可以用右法线;这无关紧要)垂直点积有时被称为垂直点积或垂直点积。以下是找到它的方法。
perpProduct = v1.ln.vx * v2.dx + v1.ln.vy * v2.dy;
让我们看看如何用这个来计算两个向量的交点。运行intersection.html文件获得一个工作示例。将飞船对准这条线,朝它飞去。你会注意到一个白色的圆形目标出现在这条线上的点,正好是船与这条线相交的点。图 A-18 展示了你将会看到的东西。当你看到它的运行时,你会觉得有点恐怖,但这只是数学!

图 A-18 。圆形目标预测船将在哪里撞线
这是游戏循环中完成这一切的代码。
//1\. Get the ship's motion vector and left normal
let v1 = {};
v1.vx = ship.vx;
v1.vy = ship.vy;
v1.ln = {};
v1.ln.vx = v1.vy;
v1.ln.vy = -v1.vx;
//2.Figure out the motion vector's start and end points
v1.ax = ship.centerX;
v1.ay = ship.centerY;
v1.bx = v1.ax + v1.vx;
v1.by = v1.ay + v1.vy;
//3\. Get the boundary line's vector, magnitude, and unit vector
let v2 = {};
v2.vx = boundary.bx - boundary.ax;
v2.vy = boundary.by - boundary.ay;
v2.m = Math.sqrt(v2.vx * v2.vx + v2.vy * v2.vy);
v2.dx = v2.vx / v2.m;
v2.dy = v2.vy / v2.m;
//4\. Get a vector between v1 (the ship's motion vector)
//and the start point of the line. Get its left normal
let v3 = {};
v3.vx = boundary.ax - v1.ax;
v3.vy = boundary.ay - v1.ay;
v3.ln = {};
v3.ln.vx = v3.vy;
v3.ln.vy = -v3.vx;
//5\. Find the perpendicular dot product of v3 and v2
let perpProduct1 = v3.ln.vx * v2.dx + v3.ln.vy * v2.dy;
//6\. Find the perpendicular dot product of v1 and v2
let perpProduct2 = v1.ln.vx * v2.dx + v1.ln.vy * v2.dy;
//7\. Find the ratio between perpProduct1 and perpProduct2
let t = perpProduct1 / perpProduct2;
//8\. Find the intersection point on the boundary line
let intersectionX = v1.ax + v1.vx * t,
intersectionY = v1.ay + v1.vy * t;
//9\. Set the circular target sprite to the intersection point
//(You only want to do this if the intersection point falls
//between the starting and ending points of the line)
if (intersectionX > boundary.ax
&& intersectionX < boundary.by
&& intersectionY > boundary.ay
&& intersectionY < boundary.by
) {
target.x = intersectionX - target.halfWidth;
target.y = intersectionY - target.halfWidth;
}
代码计算三个向量:船的运动向量、边界线的向量以及船的运动向量和边界线之间的向量。图 A-19 显示了这些向量的位置。

图 A-19 。使用三个向量来帮助找到交点
然后,代码通过计算 v2 和其他两个向量之间的垂直点积来计算交点,然后获得它们之间的比率:
let perpProduct1 = v3.ln.vx * v2.dx + v3.ln.vy * v2.dy;
let perpProduct2 = v1.ln.vx * v2.dx + v1.ln.vy * v2.dy;
let t = perpProduct1 / perpProduct2;
(注意t代表切线——交点。)
然后使用该比率t来寻找线上交叉点的像素位置。该点通过将船的运动矢量乘以比率来找到:
let intersectionX = v1.ax + v1.vx * t,
intersectionY = v1.ay + v1.vy * t;
这段代码实际上是从船的中心到船将要穿过的那一点画了一条线。
intersectionX和intersectionY现在告诉你交点在画布上的像素位置值。您可以使用这些值在该点定位任何其他精灵。在这个例子中,一个名为target的圆形精灵被移动到那个位置。然而,你只需要移动target如果交点恰好落在直线上,而不是超出它的起点和终点。一个if语句在定位target精灵之前检查这一点。
if (intersectionX > boundary.ax
&& intersectionX < boundary.by
&& intersectionY > boundary.ay
&& intersectionY < boundary.by
) {
target.x = intersectionX - target.halfWidth;
target.y = intersectionY - target.halfWidth;
}
注意所有矢量最终都会相交,除非它们是平行的。如果两个向量的dx和dy值完全相同,则这两个向量平行。
现在你知道了如何使用向量来创建一个环境边界,让我们来看看如何防止精灵越过它。
与线碰撞
运行line Collision.html 文件,获取如何使用直线作为碰撞边界的工作示例。让飞船从左到右飞入这条线——你会发现,无论你如何努力,你都无法飞过这条线。该线为实心碰撞边界,如图 A-20 所示。

图 A-20 。你不能飞过这条线
这是游戏循环中实现这一点的所有代码——我将在列表后解释它到底做了什么。
//1\. Get the ship's motion vector and magnitude
let v1 = {};
v1.vx = ship.vx;
v1.vy = ship.vy;
v1.m = Math.sqrt(v1.vx * v1.vx + v1.vy * v1.vy);
//2\. Find the unit vector
v1.dx = v1.vx / v1.m || 0;
v1.dy = v1.vy / v1.m || 0;
//3\. Get the boundary line's vector, unit vector, left normal,
//and left normal unit vector
let v2 = {};
v2.ax = boundary.ax;
v2.ay = boundary.ay;
v2.bx = boundary.bx;
v2.by = boundary.by;
v2.vx = v2.bx - v2.ax;
v2.vy = v2.by - v2.ay;
v2.m = Math.sqrt(v2.vx * v2.vx + v2.vy * v2.vy);
v2.dx = v2.vx / v2.m || 0;
v2.dy = v2.vy / v2.m || 0;
v2.ln = {};
v2.ln.vx = v2.vy;
v2.ln.vy = -v2.vx;
v2.ln.dx = v2.ln.vx / v2.m || 0;
v2.ln.dy = v2.ln.vy / v2.m || 0;
//4\. Get a vector between the starting point of
//the ship's motion vector and the starting point of the line
let v3 = {};
v3.vx = v2.ax - ship.centerX;
v3.vy = v2.ay - ship.centerY;
//5\. You need two dot products.
//The first tells you whether the ship is
//between the starting and ending points of the line
let dp1 = v3.vx * v2.dx + v3.vy * v2.dy;
//The second dot product tells you if the ship has crossed the line
let dp2 = v3.vx * v2.ln.dx + v3.vy * v2.ln.dy;
//6\. Check to see if the ship is within the vector's scope
if(dp1 > -v2.m && dp1 < 0) {
//7\. Check if the ship's motion vector has crossed the line from right to left
if(dp2 <= 0) {
//8\. Find the collision vector
let collisionVx = v1.dx * Math.abs(dp2),
collisionVy = v1.dy * Math.abs(dp2);
//9\. Move the ship out of the collision
ship.x -= collisionVx;
ship.y -= collisionVy;
//10\. Set the ship's velocity to zero
ship.vx = 0;
ship.vy = 0;
}
}
代码首先创建三个向量:船的运动向量,一个表示直线的向量,以及在船的中心和直线的起点之间的第三个向量。然后它计算两个点积。第一个点积告诉你船是否在线的起点和终点之间。如果dp1大于v2的负值,并且小于0,那么你知道船在线的点之间,代码应该检查碰撞。
let dp1 = v3.vx * v2.dx + v3.vy * v2.dy;
if(dp1 > -v2.m && dp1 < 0) {
//The ship is within the start and end points of the line
}
第二个点积用于检查船是否从左向右过线。如果点积变成负值,你就知道这是真的:
let dp2 = v3.vx * v2.ln.dx + v3.vy * v2.ln.dy;
if(dp2 <= 0) {
//The ship has crossed the line from left to right
}
当船越线时,计算碰撞矢量 。碰撞向量告诉你船越过这条线多少像素:
let collisionVx = v1.dx * Math.abs(dp2),
collisionVy = v1.dy * Math.abs(dp2);
例如,如果船越过这条线 4 个像素,点积将是–4。如果你把这个数字乘以船的运动矢量的dx和dy,你最终会得到一个很小的矢量,这个矢量描述了船有多越线。这就是碰撞矢量。
您可以通过将船移回线上来解决冲突。你可以通过从船的位置减去碰撞矢量来实现。
ship.x -= collisionVx;
ship.y -= collisionVy;
这使船准确地处于直线上。

最后,将船的速度设置为 0:
ship.vx = 0;
ship.vy = 0;
这使得碰撞看起来更精确,因为在碰撞被解决后,防止船继续试图越过该线。
线的另一边
您刚才看到的示例代码只有在船从左向右穿过这条线时才有效。如果您想从右向左检查碰撞,颠倒v2的起点和终点:
v2.ax = boundary.bx;
v2.ay = boundary.by;
v2.bx = boundary.ax;
v2.by = boundary.ay;
你也知道如果dp2是正的,船在这条线的右边,如果是负的,船在左边。如果你的游戏需要,你可以使用这些信息来创建一个更复杂的线碰撞系统,让船绕着线飞行并在两边碰撞。
活力
在前面的例子中,宇宙飞船在撞线时完全静止了。但是在大多数游戏中,你可能希望船以一定的角度弹开。借助于一种叫做投影 的技术,我们可以很容易地添加一个反弹效果。
当您将一个向量叠加到另一个向量的坐标系上时,就会发生投影。想象你正站在人行道上。这是一个阳光明媚的日子,太阳在你身后把你的影子投射在水泥地上。你的影子是你在人行道上的投影。如果你把自己想象成一个矢量,把人行道想象成另一个矢量,那么你的影子就是第三个矢量。它有很多你的品质,但是符合人行道的坐标系。
一个投影就是这样:向量在另一个向量上的影子。图 A-21 显示了v1投射到v2上的两个例子。投影本身变成了一个新的矢量。

图 A-21 。投影是向量在其他向量上的阴影。在这个图中,粗黑线是 v1 到 v2 的投影
在这个例子中,将v1投影到v2上的数学非常简单。首先,求v1和v2的点积:
dotProduct = v1.vx * v2.dx + v1.vy * v2.dy
然后将点积乘以v2的dx和dy,得到投影向量:
projectionVx = dotProduct * v2.dx;
projectionVy = dotProduct * v2.dy;
仅此而已;你有你的投影向量!但是投影有什么用呢?
弹跳!当精灵碰到一条有角度的线时,它需要以正确的角度弹开。这就是投射技术拯救世界的地方。让我们来看看使物体弹跳的一般过程。
首先,我们需要将精灵的运动矢量与它碰撞的线的角度结合起来。第一步是将v1投影到v2和v2的法线上。图 A-22 对此进行了说明。(在这个例子中,投影向量被称为p1和p2。)

图 A-22 。将 v1 投射到 v2 和 v2 上正常
接下来,反转的p2向量。我们希望我们的碰撞对象反弹,因此反转该投影将创建一个与碰撞力直接相反的力。要反转一个向量,只需将它的vx和vy值乘以-1。然后将两个投影向量的vx和vy值相加。这给了你一个新的反弹向量 。图 A-23 对此进行了说明。

图 A-23 。反转 p2 并将投影加在一起,创建一个新的反弹向量
最后一步是将这个新的反弹向量应用到碰撞精灵的速度(v1)。不管线条的角度如何,精灵都会以正确的角度弹开。将图 A-23 中的反弹向量与图 A-24 中的反弹向量进行比较,您会发现这是完全相同的向量。它使精灵正确地反弹!

图 A-24 。将新的反弹向量指定给对象的速度
现在让我们把这些理论转化成代码。运行lineBounce.html文件来看看这是怎么回事。将飞船飞入线中,飞船会以正确的角度弹开,如图图 A-25 所示。

图 A-25 。任何角度都能完美弹跳
大部分代码与前面的示例相同。唯一增加的是内部嵌套的if语句中的以下几行。他们计算出投影,创建反弹向量,并将反弹向量添加到船的速度中:
//Find the dot product of v1 and v2
let dp3 = v1.vx * v2.dx + v1.vy * v2.dy;
//Find the projection of v1 onto v2
let p1Vx = dp3 * v2.dx,
p1Vy = dp3 * v2.dy;
//Find the dot product of v1 and v2's normal (v2.ln)
let dp4 = v1.vx * v2.ln.dx + v1.vy * v2.ln.dy;
//Find the projection of v1 onto v2's normal (v2.ln)
let p2Vx = dp4 * v2.ln.dx,
p2Vy = dp4 * v2.ln.dy;
//Reverse the projection on v2's normal by multiplying it by –1.
//This is what creates the bounce effect
p2Vx *= -1;
p2Vy *= -1;
//Add up the projected vectors' vx and vy values
//to create a new bounce vector
let bounceVx = p1Vx + p2Vx,
bounceVy = p1Vy + p2Vy;
//Finally, assign the bounce vector to the spaceship's velocity.
//Add an optional damping value
ship.vx = bounceVx * 0.8;
ship.vy = bounceVy * 0.8;
这使得船弹跳起来!
你可以在最后一行看到反弹向量被乘以 0.8。这减少了 20%的反弹力,模拟了船撞线时的能量损失。或者,您可以将反弹向量乘以另一个数字,以放大或减弱反弹效果。大于 1 的数字将使反弹力比原始碰撞力更强大,从而产生蹦床效果。0 表示根本没有反弹。
弹跳解决!
圆形碰撞
恭喜你,你刚刚从向量数学训练营毕业!现在让我们看看如何使用我们新的矢量技能来解决一些真正有趣的碰撞问题。首先:圈与圈之间的碰撞。圆形碰撞分为三类:
- 通用圆形碰撞检测。
- 运动圆和静止圆之间的反应碰撞。
- 运动圆之间的反应碰撞。
让我们首先考虑最容易解决的问题:检测任意两个圆之间的碰撞。
通用圆形碰撞检测
在本书中,我们使用了一个名为hitTestCircle的函数来确定两个圆形精灵是否接触:
hitTestCircle(sprite1, sprite2)
将它与任何具有radius属性的 sprite 一起使用。如果圆接触,它返回true。
下面是来自library/collision.js文件的hitTestCircle函数来完成这项工作。它首先在每个圆形精灵的中心点之间绘制一个矢量(c1和c2)。如果这个向量的大小小于两个圆的半径之和,那么你知道这两个圆接触了。
export function hitTestCircle(c1, c2, global = false) {
let vx, vy, magnitude, combinedRadii, hit;
//Calculate the vector between the circles' center points
if(global) {
//Use global coordinates
vx = (c2.gx + c2.radius) - (c1.gx + c1.radius);
vy = (c2.gy + c2.radius) - (c1.gy + c1.radius);
} else {
//Use local coordinates
vx = c2.centerX - c1.centerX;
vy = c2.centerY - c1.centerY;
}
//Find the distance between the circles by calculating
//the vector's magnitude
magnitude = Math.sqrt(vx * vx + vy * vy);
//Add together the circles' combined radii
combinedRadii = c1.radius + c2.radius;
//Set `hit` to `true` if the distance between the circles is
//less than their ` combinedRadii `
hit = magnitude < combinedRadii;
//`hit` will be either `true` or `false`
return hit;
};
图 A-26 展示了这是如何工作的。

图 A-26 。当两个圆之间的矢量小于它们的组合半径时,就会发生碰撞
下一步是通过在圆接触时将它们分开来产生碰撞反应。
运动圆和静止圆之间的反应碰撞
当两个圆相撞时,你如何将它们分开?根据碰撞中的一个或两个圆是否在移动,您使用的代码略有不同。我们先来看看如何处理运动圆(c1)和静止圆(c2)的碰撞。
首先,找出圆圈重叠的程度。从圆的组合半径中减去圆之间的距离矢量的大小,就可以算出这一点。
overlap = combinedRadii - magnitude;
图 A-27 说明了如何找到这个重叠值。

图 A-27 。找出重叠的数量
接下来,归一化距离向量以找到其dx和dy值:
dx = vx / magnitude;
dy = vy / magnitude;
将dx和dy值乘以overlap值,并从移动圆的x和y位置中减去这些新值。
c1.x -= overlap * dx;
c1.y -= overlap * dy;
这给了你一个非常清晰的圆圈之间的分离。图 A-28 说明了该代码是如何工作的。

图 A-28 。使用重叠值将圆移出碰撞
量子填充
这种数学在完美的实验室环境下工作良好。想想干净的房间,白色的实验服和本生灯。但不幸的是,视频游戏编程的世界要混乱得多。想想睡衣、披萨和宿醉。我们的编程世界充满了浮点舍入误差、速度峰值、复杂的碰撞环境、许多碰撞的对象在周围推挤,以及以不同顺序更新的精灵位置。在一些极端情况下,这种精确的数学并不总是转化为屏幕上的无缝碰撞。为了消除任何潜在的数学尖峰,我喜欢在碰撞圆周围添加一点我称之为的量子填充 。量子填充只是一个额外的、微小的值,它被添加到重叠值中,以便碰撞的精灵比纯数学认为它们应该分离的稍微多一点。
要实现量程填充,可以在overlap值上加一个任意的小数字。0.3 通常工作良好:
let quantumPadding = 0.3;
overlap += quantumPadding;
填充物在圆圈之间增加了微小的空间,以降低它们的表面张力,使它们更滑。值 0.3 是一个很好的起点,但是您可能需要根据您想要的确切行为稍微修改它。填充太少,碰撞的圆会有粘性;太多了,如果挤在一起,它们会开始抖动。
量子填充纯粹是我自己的发明。经过多年的实验,我开始添加它,以改善某些碰撞的美学外观。在现实世界中,原子粒子和表面从来没有真正接触过;所有粒子在发生物理接触之前,都被相互推开的量子力分开。量子填充模拟了这些力,它似乎对圆形碰撞很有效。
circleCollision功能
在本书中,我们一直在使用circleCollision函数来使一个移动的圆从一个静止的圆上反弹。下面是实现所有这些理论的完整的circleCollision函数。你会注意到,如果bounce参数是true,另一个名为bounceOffSurface的函数被调用。我将在下一节解释这是如何工作的。
export function circleCollision(c1, c2, bounce = false, global = false) {
let magnitude, combinedRadii, overlap,
vx, vy, dx, dy, s = {},
hit = false;
//Calculate the vector between the circles' center points
if(global) {
//Use global coordinates
vx = (c2.gx + c2.radius) - (c1.gx + c1.radius);
vy = (c2.gy + c2.radius) - (c1.gy + c1.radius);
} else {
//Use local coordinates
vx = c2.centerX - c1.centerX;
vy = c2.centerY - c1.centerY;
}
//Find the distance between the circles by calculating
//the vector's magnitude
magnitude = Math.sqrt(vx * vx + vy * vy);
//Add together the circles' combined half-widths
combinedRadii = c1.radius + c2.radius;
//Figure out if there's a collision
if (magnitude < combinedRadii) {
//Yes, a collision is happening
hit = true;
//Find the amount of overlap between the circles
overlap = combinedRadii - magnitude;
//Add some "quantum padding"
let quantumPadding = 0.3;
overlap += quantumPadding;
//Normalize the vector
//These numbers tell us the direction of the collision
dx = vx / magnitude;
dy = vy / magnitude;
//Move circle 1 out of the collision by multiplying
//the overlap with the normalized vector and subtract it from
//circle 1's position
c1.x -= overlap * dx;
c1.y -= overlap * dy;
//Bounce
if (bounce) {
//Create a collision vector object, `s`, to represent the bounce "surface".
//Find the bounce surface's x and y properties
//(This represents the normal of the distance vector between the circles)
s.x = vy;
s.y = -vx;
//Bounce c1 off the surface
//(I’ll explain this ahead)
bounceOffSurface(c1, s);
}
}
return hit;
}
弹跳圈
有一个技巧可以让圆圈以正确的角度弹开。假设圆碰到了由它们之间的距离向量的法线创建的虚拟墙。你所需要做的就是将c1的运动向量反弹到这面假想的墙上。这与我们在本附录前面看到的,解决让宇宙飞船反弹到直线上的方法是一样的。唯一的区别是,我们看不到圆圈反弹的线;它只是数学上的存在。图 A-29 显示了由距离矢量的法线创建的虚拟墙如果可见的话会是什么样子(图中的虚线)。

图 A-29 。将圆反弹到由距离向量的法线创建的虚拟墙壁(虚线)上
如果你能从一条线上弹开一个点,你就能从其他任何东西上弹开任何东西——这都是同一个问题。这意味着创建一个通用的反弹函数是有意义的,你可以使用点,线,圆和矩形。同样的反弹函数可以适用于所有这些元素。在library/collision.js文件中,你会找到一个名为bounceOffSurface 的函数来完成这个任务。下面是来自circleCollision函数的代码如何使用bounceOffSurface将圆弹开。
//Create a vector to represent the bounce surface
let s = {};
//Set the surface vector's x and y properties to the
//distance vector's left normal
s.x = vy;
s.y = -vx;
//Bounce c1 (the moving circle) off the surface
bounceOffSurface(c1, s);
这是创建反弹效果的完整的bounceOffSurface函数。你会看到代码使用了所有相同的矢量技术来制作一个物体反弹,就像我们在本附录前面看到的那样。如果碰撞中的精灵有一个mass属性,该值将用于减弱反弹效果。(在这段代码中,变量o代表“对象”。它可以是任何具有 x 、 y 、 vx 和 vy 属性的对象,比如精灵。)
function bounceOffSurface(o, s) {
let dp1, dp2,
p1 = {},
p2 = {},
bounce = {},
mass = o.mass || 1;
//1\. Calculate the collision surface's properties
//Find the surface vector's left normal
s.lx = s.y;
s.ly = -s.x;
//Find its magnitude
s.magnitude = Math.sqrt(s.x * s.x + s.y * s.y);
//Find its normalized values
s.dx = s.x / s.magnitude;
s.dy = s.y / s.magnitude;
//2\. Bounce the object (o) off the surface (s)
//Find the dot product between the object and the surface
dp1 = o.vx * s.dx + o.vy * s.dy;
//Project the object's velocity onto the collision surface
p1.vx = dp1 * s.dx;
p1.vy = dp1 * s.dy;
//Find the dot product of the object and the surface's left normal (s.lx and s.ly)
dp2 = o.vx * (s.lx / s.magnitude) + o.vy * (s.ly / s.magnitude);
//Project the object's velocity onto the surface's left normal
p2.vx = dp2 * (s.lx / s.magnitude);
p2.vy = dp2 * (s.ly / s.magnitude);
//Reverse the projection on the surface's left normal
p2.vx *= -1;
p2.vy *= -1;
//Add up the projections to create a new bounce vector
bounce.x = p1.vx + p2.vx;
bounce.y = p1.vy + p2.vy;
//Assign the bounce vector to the object's velocity
//with optional mass to dampen the effect
o.vx = bounce.x / mass;
o.vy = bounce.y / mass;
}
你可以使用bounceOffSurface从任何表面反弹任何精灵。只要识别或创建一个表面向量,然后找到一个具有 x 、 y 、 vx 和 vy 属性的精灵,你可以用它来反弹。
运动圆之间的反应碰撞
在像台球或弹珠这样的游戏中,你需要两个圆圈在碰撞中做出反应。当圆碰撞时,每个圆将其运动力传递给另一个圆。
当碰撞发生时,你需要分离圆,然后计算出它们新的反弹速度。这些是我们在前面的例子中看到的相同的概念。然而,当两个圈都在运动时,有一些重要的区别:
- 分离 :如果一个圆在运动,而另一个不动,在碰撞中很容易将它们分离。在本章的第一个例子中,我们通过简单地将运动圆定位在静止圆的边界上来实现这一点。但是当两个圈都在运动时,边界在哪里呢?你需要找到一个折中的位置:按比例分开。幸运的是,一个非常简单的公式可以帮助我们做到这一点。
- 反弹:当移动的圆圈碰撞时,它们新的反弹向量不仅取决于碰撞的角度,还取决于另一个圆圈撞击它的力度。这些圆需要相互传递它们的运动矢量。同样,这里没有什么大的惊喜,您已经拥有了解决方案所需的工具。
首先,计算出圆圈重叠的程度。
overlap = combinedRadii - magnitude;
接下来,创建一个碰撞向量。这将为我们提供分离圆所需的vx和vy值。这个矢量必须分成两半,这样我们就可以在两个圆之间共享它。一半去第一圈,另一半去第二圈。
dx = vx / magnitude;
dy = vy / magnitude;
vxHalf = Math.abs(dx * overlap / 2);
vyHalf = Math.abs(dy * overlap / 2);
vx和vy值需要是绝对值(没有加号或减号)。我们需要这些值是中性的,因为我们希望能够灵活地决定是将这个向量添加到每个圆的位置还是从每个圆的位置减去这个向量(您将在下一步中看到如何操作)。图 A-30 说明了这些计算是如何发现的。

图 A-30 。求碰撞向量,一分为二,求绝对
下一个问题是:第一个圆是在第二个圆的上面、下面、右边还是左边?我们需要知道这一点,以便我们可以正确地增加或减少每个圆的位置重叠矢量。跟踪它的最简单的方法是创建赋值为 1 或–1 的变量,这取决于圆之间的相对位置。下面是一些简洁的 JavaScript 语法,可以为您找到这个值:
(c1.x > c2.x) ? xSide = 1 : xSide = -1;
(c1.y > c2.y) ? ySide = 1 : ySide = -1;
我们需要把圆圈推开来解决碰撞。例如,在 x 轴上,我们需要向左推一个圆,向右推另一个圆。碰撞向量将是其中一个圆的正确方向,而不是另一个圆的正确方向。然而,我们知道圆圈需要移动的方向将会是彼此相反的两极。这意味着我们可以使用xSide和ySide变量(将是 1 或–1)来正确地反转其中一个向量:
//Move c1 out of the collision
c1.x = c1.x + (vxHalf * xSide);
c1.y = c1.y + (vyHalf * ySide);
//Move c2 out of the collision
c2.x = c2.x + (vxHalf * -xSide);
c2.y = c2.y + (vyHalf * -ySide);
圆的方向总是在变,所以我们永远不知道要反转哪个圆的重叠向量。幸运的是,xSide和ySide变量会自动为我们跟踪这一点。图 A-31 显示了如何找到圆圈的新位置。

图 A-31 。使用碰撞向量将圆移出碰撞
在你用这种方法把圆分开后,你可以用我们在其他例子中用过的同样的技术把它们弹开。
在这本书里,我使用了movingCircleCollision函数 来使移动的圆圈弹开。这里是实现所有这些技术的movingCircleCollision函数的完整代码,包括反弹代码。(在这个代码中,s代表两个圆之间的距离矢量。)
export function movingCircleCollision(c1, c2, global = false) {
let combinedRadii, overlap, xSide, ySide,
//`s` refers to the distance vector between the circles
s = {},
p1A = {}, p1B = {}, p2A = {}, p2B = {},
hit = false;
//Apply mass, if the circles have mass properties
c1.mass = c1.mass || 1;
c2.mass = c2.mass || 1;
//Calculate the vector between the circles’ center points
if(global) {
//Use global coordinates
s.vx = (c2.gx + c2.radius) - (c1.gx + c1.radius);
s.vy = (c2.gy + c2.radius) - (c1.gy + c1.radius);
} else {
//Use local coordinates
s.vx = c2.centerX - c1.centerX;
s.vy = c2.centerY - c1.centerY;
}
//Find the distance between the circles by calculating
//the vector's magnitude
s.magnitude = Math.sqrt(s.vx * s.vx + s.vy * s.vy);
//Add together the circles' combined half-widths
combinedRadii = c1.radius + c2.radius;
//Figure out if there's a collision
if (s.magnitude < combinedRadii) {
//Yes, a collision is happening
hit = true;
//Find the amount of overlap between the circles
overlap = combinedRadii - s.magnitude;
//Add some "quantum padding" to the overlap
overlap += 0.3;
//Normalize the vector.
//These numbers tell us the direction of the collision
s.dx = s.vx / s.magnitude;
s.dy = s.vy / s.magnitude;
//Find the collision vector.
//Divide it in half to share between the circles, and make it absolute
s.vxHalf = Math.abs(s.dx * overlap / 2);
s.vyHalf = Math.abs(s.dy * overlap / 2);
//Find the side on which the collision is occurring
(c1.x > c2.x) ? xSide = 1 : xSide = -1;
(c1.y > c2.y) ? ySide = 1 : ySide = -1;
//Move c1 out of the collision by multiplying
//the overlap with the normalized vector and adding it to
//the circles’ positions
c1.x = c1.x + (s.vxHalf * xSide);
c1.y = c1.y + (s.vyHalf * ySide);
//Move c2 out of the collision
c2.x = c2.x + (s.vxHalf * -xSide);
c2.y = c2.y + (s.vyHalf * -ySide);
//Now that the circles have been separated, you can bounce
//them apart. The code below does this in 4 major, numbered steps:
//1\. Calculate the collision surface's properties
//Find the surface vector's left normal
s.lx = s.vy;
s.ly = -s.vx;
//2\. Bounce c1 off the surface (s)
//Find the dot product between c1 and the surface
let dp1 = c1.vx * s.dx + c1.vy * s.dy;
//Project c1's velocity onto the collision surface
p1A.x = dp1 * s.dx;
p1A.y = dp1 * s.dy;
//Find the dot product of c1 and the surface's left normal (s.lx and s.ly)
let dp2 = c1.vx * (s.lx / s.magnitude) + c1.vy * (s.ly / s.magnitude);
//Project c1's velocity onto the surface's left normal
p1B.x = dp2 * (s.lx / s.magnitude);
p1B.y = dp2 * (s.ly / s.magnitude);
//3\. Bounce c2 off the surface (s)
//Find the dot product between c2 and the surface
let dp3 = c2.vx * s.dx + c2.vy * s.dy;
//Project c2's velocity onto the collision surface
p2A.x = dp3 * s.dx;
p2A.y = dp3 * s.dy;
//Find the dot product of c2 and the surface's left normal (s.lx and s.ly)
let dp4 = c2.vx * (s.lx / s.magnitude) + c2.vy * (s.ly / s.magnitude);
//Project c2's velocity onto the surface's left normal
p2B.x = dp4 * (s.lx / s.magnitude);
p2B.y = dp4 * (s.ly / s.magnitude);
//4\. Calculate the bounce vectors
//Bounce c1 using p1B and p2A
c1.bounce = {};
c1.bounce.x = p1B.x + p2A.x;
c1.bounce.y = p1B.y + p2A.y;
//Bounce c2 using p1A and p2B
c2.bounce = {};
c2.bounce.x = p1A.x + p2B.x;
c2.bounce.y = p1A.y + p2B.y;
//Add the bounce vector to the circles' velocity
//and add mass if the circle has a mass property
c1.vx = c1.bounce.x / c1.mass;
c1.vy = c1.bounce.y / c1.mass;
c2.vx = c2.bounce.x / c2.mass;
c2.vy = c2.bounce.y / c2.mass;
}
return hit;
}
这样,我们就完成了循环!
矩形碰撞
矩形碰撞可能是你在游戏中需要检测的最常见的碰撞类型。需要两种基本类型的检测:
- 通用矩形碰撞检测
- 计算静止和运动矩形之间的反应碰撞
这两种技术都依赖于一种广泛使用且可靠的多边形碰撞检测技术,称为分离轴定理(SAT) 。
分离轴定理
由传奇计算机科学家 Stefan Gottschalk 首次提出的分离轴定理被广泛认为是检查多边形是否碰撞的最有效方法。这也很容易理解:
- 如果有任何轴( x 或 y )上的任何两个物体不重叠,那么物体之间没有碰撞。在两个矩形的情况下,这意味着如果矩形在 x 轴上重叠,而不是在 y 轴上重叠,它们就没有碰撞。它们不相交的轴是分离轴,该定理由此得名。
- 如果物体在所有轴上都重叠(都是 x 和 y ,那么就发生了碰撞。
- 重叠最少的轴是发生碰撞的轴。
在任何游戏中,两个物体不碰撞的几率远远大于它们碰撞的几率。SAT 利用了这一事实。这是非常有效的,因为你可以通过测试一个轴立即发现两个对象是否重叠。如果在那个轴上没有重叠,你就有了你的答案:没有碰撞。这是一种快速逃避,并且您刚刚为自己节省了一些处理能力。您不需要费心测试任何其他轴。
在矩形的情况下,这意味着你可以减少一半的碰撞检查。对于复杂的多边形,如六边形,这意味着只需要三分之一的碰撞检查。
但是,如果你发现两个物体在一个轴上重叠,并不意味着物体实际上是在碰撞。意思是他们可能是,但是你还不知道。所以你接下来需要检查另一个轴来确定。在矩形的情况下,如果你发现在 x 和 y 轴上有重叠,那么矩形肯定是重叠的,你有一个碰撞。如果你发现任何轴没有重叠,那么就没有碰撞,你可以停止进一步的检查。
假设您需要检查舞台上两个方块之间的碰撞。在伪代码中,正方形的基本 SAT 算法如下所示:
if(the squares overlap on the x axis) {
//There might be a collision! Let's check:
if(the squares overlap on the y axis){
//The squares overlap on both axes, so there's definitely a collision
//The collision is occurring on the axis with the smallest amount of overlap
} else {
//There's no overlap on the y axis, so there's no collision
}
} else {
//There's no overlap on the x axis, so there's no collision
}
我说的“如果方块在 x 或 y 轴上重叠”是什么意思?
图 A-32 描绘了屏幕上的两个方块。我们可以看到它们显然没有相交。但是我们需要找到一种方法在编程代码中描述它们的状态。

图 A-32 。两个不相交的正方形。我们如何用代码来描述它呢?
为了检查正方形是否相交,我们不需要检查每个正方形的所有四条边与另一个正方形的所有四条边。我们只需要检查方块的边是否在两个轴上相交: x 和 y 。为此,我们需要使用投影。我们必须将每个方块投影到 x 轴和 y 轴上。你会记得,如果你站在一个形状的后面,用灯光照射,这个形状的投影就是它投射的影子。在这种情况下,每个方块需要投射两个阴影:一个阴影投射到 x 轴,另一个投射到 y 轴。图 A-33 对此进行了说明。

图 A-33 。将正方形投影到 x 和 y 轴上
因为矩形与载物台的 x 和 y 轴对齐,所以其高度和宽度等于其投影。这意味着我们不必做任何数学运算来计算投影值——我们可以直接使用矩形的高度和宽度。
注在技术碰撞检测文献中,边与 x 和 y 轴对齐的正方形或矩形称为轴对齐包围盒 ,或 AABBs 。换句话说,游戏世界是长方形的,方块也是长方形的。没有旋转。这是最简单的碰撞场景。游戏设计者通常会在 AABBs 中包装形状奇怪的非矩形物体,因为使用它们进行碰撞检测速度非常快。
如果方块在 x 轴上重叠会怎么样?让我们找出答案。
在图 A-34 中,可以看到顶部的方块已经向左移动。两个方块的投影现在在 x 轴上重叠。

图 A-34 。投影在 x 轴上重叠
当然,你可以看到即使它们在 x 轴上重叠,正方形仍然不相交——所以没有碰撞。现在让我们进一步推这个例子,看看当正方形在 y 轴上重叠时会发生什么,如图图 A-35 所示。

图 A-35 。正方形在两个轴上重叠,所以我们有一个碰撞。碰撞发生在重叠最少的 y 轴上
正方形现在明显相交。你还可以看出碰撞发生在 y 轴上,因为这是重叠量最小的轴。
下一步是计算出方块重叠的程度。我们需要一个在两个正方形中心之间的距离向量。距离矢量也需要投影到每个轴上,如图图 A-36 所示。

图 A-36 。绘制形状之间的距离矢量,并将其投影到 x 轴和 y 轴上
使用距离矢量的投影测量每个轴上正方形投影中心之间的距离,如图图 A-37 所示。

图 A-37 。测量形状投影之间的距离,找出它们是否重叠
将各形状的投影相加,然后除以二。如果投影距离向量的大小小于该值,则形状在该轴上重叠。
这在图片中比在阅读中更容易看到,所以好好看看图 A-38 。(我使用的约定是,以p开头的变量名指的是投影向量。)

图 A-38 。计算重叠部分
如果知道重叠,就可以解决碰撞;只需将其中一个方块移出碰撞区域相同的距离。
既然我们知道碰撞发生在哪个轴上,我们需要找到它发生在正方形的哪一边。这很容易通过检查距离向量的vx和vy是大于还是小于零来解决。
- 如果碰撞发生在 x 轴上,它发生在正方形的右边还是左边?找到距离向量的
vx。如果大于零,碰撞发生在右边。如果小于零,碰撞在左边。 - 如果碰撞发生在 y 轴上,它是发生在正方形的上边还是下边?找到距离向量的
vy。如果大于零,碰撞发生在顶部。如果小于零,碰撞在底部。
图 A-39 显示了如何找到碰撞侧。

图 A-39 。找到碰撞侧
这些是基于 SAT 的碰撞检测系统的基本原理。这一基本理论适用于所有形状,不管有多复杂。为了简单起见,我在这些例子中使用了正方形,但是您可以将相同的技术应用于旋转的矩形或任意边数的多边形。当你准备好开始做更复杂的碰撞检测,超出了我们在本书中所做的,这是理论,你可以使用你的碰撞系统的基础。
但是,正如您接下来将看到的,将这一理论应用于非旋转矩形是非常容易的。
注意使用基于卫星的碰撞系统有一个你需要知道的限制。如果物体移动得非常快,重叠最少的轴可能不是发生碰撞的轴。在这种情况下,使用不同的碰撞技术。从快速移动对象的中心向其运动矢量的方向绘制一个矢量。沿着矢量以固定的间隔测试点,看它们是否与任何形状相交。如果他们中的任何一个是,你有一个冲突,你可以决定如何解决这个冲突。(您将在前面的页面中了解如何测试点和形状之间的冲突)。
通用矩形碰撞测试
在本书中,我使用了一个名为hitTestRectangle 的函数来告诉你两个矩形精灵是否在碰撞。它简单地返回true或false,这取决于是否有碰撞。它实现了基本的 SAT 理论。但是因为非旋转矩形的宽度和高度等于它的 x 和 y 投影,我们可以跳过投影计算,只使用现有的宽度和高度值。下面是完整的hitTestRectangle功能:
export function hitTestRectangle(r1, r2, global = false) {
let hit, combinedHalfWidths, combinedHalfHeights, vx, vy;
//A variable to determine whether there's a collision
hit = false;
//Calculate the distance vector
if(global) {
vx = (r1.gx + r1.halfWidth) - (r2.gx + r2.halfWidth);
vy = (r1.gy + r1.halfHeight) - (r2.gy + r2.halfHeight);
} else {
vx = r1.centerX - r2.centerX;
vy = r1.centerY - r2.centerY;
}
//Figure out the combined half-widths and half-heights
combinedHalfWidths = r1.halfWidth + r2.halfWidth;
combinedHalfHeights = r1.halfHeight + r2.halfHeight;
//Check for a collision on the x axis
if (Math.abs(vx) < combinedHalfWidths) {
//A collision might be occurring. Check for a collision on the y axis
if (Math.abs(vy) < combinedHalfHeights) {
//There's definitely a collision happening
hit = true;
} else {
//There's no collision on the y axis
hit = false;
}
} else {
//There's no collision on the x axis
hit = false;
}
//`hit` will be either `true` or `false`
return hit;
}
hitTestRectangle函数不能解决冲突;它只返回true或false。为了让矩形对碰撞做出反应,让我们使用另一个函数,叫做rectangleCollision。
反应矩形碰撞
rectangleCollision函数测试碰撞,将碰撞的精灵推开,并选择性地从第二个精灵弹回第一个精灵。它还检查第一个矩形在哪一侧碰到第二个矩形,并返回一个字符串告诉你:顶部、底部、左侧或右侧。它实现了一个非常简单的反弹系统,但是如果你愿意,你可以选择使用bounceOffSurface函数来反弹矩形。下面是完整的rectangleCollision功能:
export function rectangleCollision(
r1, r2, bounce = false, global = true
) {
let collision, combinedHalfWidths, combinedHalfHeights,
overlapX, overlapY, vx, vy;
//Calculate the distance vector
if(global) {
vx = (r1.gx + r1.halfWidth) - (r2.gx + r2.halfWidth);
vy = (r1.gy + r1.halfHeight) - (r2.gy + r2.halfHeight);
} else {
vx = r1.centerX - r2.centerX;
vy = r1.centerY - r2.centerY;
}
//Figure out the combined half-widths and half-heights
combinedHalfWidths = r1.halfWidth + r2.halfWidth;
combinedHalfHeights = r1.halfHeight + r2.halfHeight;
//Check whether vx is less than the combined half widths
if (Math.abs(vx) < combinedHalfWidths) {
//A collision might be occurring!
//Check whether vy is less than the combined half heights
if (Math.abs(vy) < combinedHalfHeights) {
//A collision has occurred! This is good!
//Find out the size of the overlap on both the X and Y axes
overlapX = combinedHalfWidths - Math.abs(vx);
overlapY = combinedHalfHeights - Math.abs(vy);
//The collision has occurred on the axis with the
//*smallest* amount of overlap. Let's figure out which
//axis that is
if (overlapX >= overlapY) {
//The collision is happening on the X axis
//But on which side? vy can tell us
if (vy > 0) {
collision = "top";
//Move the rectangle out of the collision
r1.y = r1.y + overlapY;
} else {
collision = "bottom";
//Move the rectangle out of the collision
r1.y = r1.y - overlapY;
}
//Bounce
if (bounce) {
r1.vy *= -1;
/*Alternative
//Find the bounce surface's vx and vy properties
let s = {};
s.vx = r2.x - r2.x + r2.width;
s.vy = 0;
//Bounce r1 off the surface
bounceOffSurface(r1, s);
*/
}
} else {
//The collision is happening on the Y axis
//But on which side? vx can tell us
if (vx > 0) {
collision = "left";
//Move the rectangle out of the collision
r1.x = r1.x + overlapX;
} else {
collision = "right";
//Move the rectangle out of the collision
r1.x = r1.x - overlapX;
}
//Bounce
if (bounce) {
r1.vx *= -1;
/*Alternative
//Find the bounce surface's vx and vy properties
let s = {};
s.vx = 0;
s.vy = r2.y - r2.y + r2.height;
//Bounce r1 off the surface
bounceOffSurface(r1, s);
*/
}
}
} else {
//No collision
}
} else {
//No collision
}
//Return the collision string. It will be either "top", "right",
//"bottom", or "left" depending on which side of r1 is touching r2.
return collision;
}
您可以在这段代码中看到,反弹效果是通过简单地将第一个矩形的速度乘以–1 而创建的。
r1.vy *= -1;
r1.vx *= -1;
这就是我们需要做的。因为矩形是不旋转的,所以我们不需要像对圆形那样计算出任何复杂的投影或反弹向量。
圆形和矩形之间的碰撞
你现在知道如何编码圆和矩形之间的碰撞。但是你如何编码一个圆形和矩形之间的碰撞呢?你需要把它分成两部分:
- 当圆离正方形的边比离正方形的角更近时,就变成了矩形与矩形的碰撞问题。
- 当圆离任何一个角比离边都近时,就变成了圆对点的碰撞问题。您将在前面几页看到如何进行圆对点碰撞检查。
要知道什么时候使用哪种碰撞策略只是一个逻辑问题,并不难解决。你只需要找出圆圈占据了空间的哪个区域(称为沃罗诺区域 ),并对该区域应用正确的碰撞策略。图 A-40 展示了这些区域的位置以及对每个区域使用的策略。

图 A-40 。根据圆占据的区域选择碰撞策略
通过比较圆的中心位置和正方形的位置,加上它的半高和半宽,可以找出圆在哪个区域。
当圆处于矩形对矩形区域时,出于碰撞的目的,其字面上的变成了正方形,如图图 A-41 所示。它在屏幕上看起来可能像一个圆,但碰撞代码将其解释为一个正方形。

图 A-41 。当圆在矩形对矩形区域中时,碰撞代码将圆视为正方形
当圆在圆对点区域时,它完全忽略正方形,只检查与正方形最近的角点的碰撞,如图图 A-42 所示。

图 A-42 。该圆检查与角点的碰撞,并完全忽略正方形的边
一旦你知道了这一点,你所要做的就是写一些逻辑代码,计算出圆圈在哪个区域,并应用适当的碰撞策略。
在library/collision.js文件中,您将找到实现这一技术的hitTestCircleRectangle 函数。它首先做一个简单的条件检查来找出圆在哪里。如果这个圆在一个圆形区域,它调用hitTestRectangle函数。如果圆在圆对点区域,它调用一个名为hitTestCirclePoint的函数(一个新函数,稍后您将了解)。hitTestCircleRectangle返回一个值,告诉你圆在哪个区域。如果返回值是undefined,你就知道没有碰撞。下面是完整的hitTestCircleRectangle功能:
export function hitTestCircleRectangle(c1, r1, global = false) {
let region, collision, c1x, c1y, r1x, r1y;
//Use either global or local coordinates
if (global) {
c1x = c1.gx;
c1y = c1.gy
r1x = r1.gx;
r1y = r1.gy;
} else {
c1x = c1.x;
c1y = c1.y
r1x = r1.x;
r1y = r1.y;
}
//Is the circle above the rectangle's top edge?
if(c1y < r1y - r1.halfHeight) {
//If it is, we need to check whether it's in the
//top left, top center, or top right.
//(Increasing the size of the region by 2 pixels slightly weights
//the text in favor of a rectangle vs. rectangle collision test.
//This gives a more natural-looking result with corner collisions
//when physics calculations are added)
if(c1x < r1x - 1 - r1.halfWidth) {
region = "topLeft";
}
else if (c1x > r1x + 1 + r1.halfWidth) {
region = "topRight";
}
else {
region = "topMiddle";
}
}
//The circle isn't above the top edge, so it might be
//below the bottom edge
else if (c1y > r1y + r1.halfHeight) {
//If it is, we need to check whether it's in the bottom left,
//bottom center, or bottom right
if (c1x < r1x - 1 - r1.halfWidth) {
region = "bottomLeft";
}
else if (c1x > r1x + 1 + r1.halfWidth) {
region = "bottomRight";
}
else {
region = "bottomMiddle";
}
}
//The circle isn't above the top edge or below the bottom edge,
//so it must be on the left or right side
else {
if (c1x < r1x - r1.halfWidth) {
region = "leftMiddle";
}
else {
region = "rightMiddle";
}
}
//Is the circle touching the flat sides
//of the rectangle?
if (region === "topMiddle"
|| region === "bottomMiddle"
|| region === "leftMiddle"
|| region === "rightMiddle") {
//Yes, it is, so do a standard rectangle vs. rectangle collision test
collision = hitTestRectangle(c1, r1, global);
}
//The circle is touching one of the corners, so do a
//circle vs. point collision test
else {
let point = {};
switch (region) {
case "topLeft":
point.x = r1x;
point.y = r1y;
break;
case "topRight":
point.x = r1x + r1.width;
point.y = r1y;
break;
case "bottomLeft":
point.x = r1x;
point.y = r1y + r1.height;
break;
case "bottomRight":
point.x = r1x + r1.width;
point.y = r1y + r1.height;
}
//Check for a collision between the circle and the point
collision = hitTestCirclePoint(c1, point, global);
}
//Return the result of the collision.
//The return value will be `undefined` if there's no collision
if (collision) {
return region;
} else {
return collision;
}
}
您可以看到,如果圆在圆与点的区域中,代码会调用一个名为hitTestCirclePoint的函数:
collision = hitTestCirclePoint(c1, point, global);
但是如何检查圆和点之间的碰撞呢?很简单:一个点只是一个直径为一个像素的非常小的圆。所以你需要做的就是创建一个直径为 1,半径为 0.5 的point物体,用一个普通的圆对圆碰撞测试,看看它是不是碰到了一个圆。
你可以这样创建一个point对象:
let point = {}
point.x = anyXPosition;
point.y = anyYPosition;
然后通过hitTestCirclePoint函数运行,这个函数只是给这个点添加了一些属性,比如diameter和radius,然后针对提供的循环 sprite 调用并返回一个普通的hitTestCircle函数。如果它返回true,你就知道这个点与圆相交:
export function hitTestCirclePoint(c1, point, global = false) {
point.diameter = 1;
point.radius = 0.5;
point.centerX = point.x;
point.centerY = point.y;
point.gx = point.x;
point.gy = point.y;
return hitTestCircle(c1, point, global);
}
如果圆形和矩形发生冲突,所有这些函数都会返回true或false。但是如果你想把圆弹离矩形的边缘呢?
从矩形反弹出圆形
library/collision.js文件包含一个名为circleRectangleCollision、的函数,该函数使一个圆从一个矩形上弹回。代码与hitTestCircleRectangle相同,除了两行。如果它检测到碰撞,它会调用rectangleCollision或circlePointCollision函数,根据圆圈所在的区域将圆圈弹开。
collision = rectangleCollision(c1, r1, bounce, global);
collision = circlePointCollision(c1, point, bounce, global);
我们已经在本附录前面看到了rectangleCollision函数。除了在最后一行调用并返回一个circleCollision函数外,circlePointCollision函数与hitTestCirclePoint函数几乎相同:
export function circlePointCollision(c1, point, bounce = false, global = false) {
//...the code is the same as hitTestCircleRectangle...
return circleCollision(c1, point, bounce, global);
}
仅此而已!
单点和精灵之间的碰撞
library/collision.js文件包含另一个有用的函数,叫做hitTestPoint 。它的工作是根据单个点(比如鼠标指针)是否与矩形或圆形精灵相交来返回true或false。hitTestPoint有两个参数:一个点对象(带有 x 和 y 值)和一个 sprite。如果 sprite 是矩形的,代码将检查该点是否在矩形区域内。如果精灵是圆形的,它测量点和圆心之间的距离。如果这个距离小于圆的半径,它就知道这个点一定与圆相交。这是完成所有这些的完整的hitTestPoint函数。
export function hitTestPoint(point, sprite) {
let shape, left, right, top, bottom, vx, vy, magnitude, hit;
//Find out if the sprite is rectangular or circular depending
//on whether it has a `radius` property
if (sprite.radius) {
shape = "circle";
} else {
shape = "rectangle";
}
//Rectangle
if (shape === "rectangle") {
//Get the position of the sprite's edges
left = sprite.x;
right = sprite.x + sprite.width;
top = sprite.y;
bottom = sprite.y + sprite.height;
//Find out if the point is intersecting the rectangle
hit = point.x > left && point.x < right && point.y > top && point.y < bottom;
}
//Circle
if (shape === "circle") {
//Find the distance between the point and the
//center of the circle
vx = point.x - sprite.centerX,
vy = point.y - sprite.centerY,
magnitude = Math.sqrt(vx * vx + vy * vy);
//The point is intersecting the circle if the magnitude
//(distance) is less than the circle's radius
hit = magnitude < sprite.radius;
}
//`hit` will be either `true` or `false`
return hit;
}
这是一个有用的小工具,实现了本附录中的许多概念。
万能击功能
我们要看的最后一个碰撞函数是通用的hit函数。它会自动检测碰撞中使用的精灵种类,并为您选择合适的碰撞功能:
hit(spriteOne, spriteTwo, react, bounce, global, extra)
(最后一个参数extra,是一个可选的回调函数,如果发生冲突,您可以将它包含在您想要运行的代码中。)
如果要检查点对象与精灵的碰撞,请使用点作为第一个参数,如下所示:
hit({x: 145, y:65}, sprite)
hit函数还可以让你检查一个精灵和一个精灵数组之间的冲突。只需将数组作为第二个参数:
hit(ball, bricks.children, true, true, true);
自动循环数组中的所有精灵,并根据第一个精灵检查它们。这意味着您不必编写自己的循环代码。
hit函数还返回一个collision对象,其返回值与您正在检查的精灵类型相匹配。
hit函数很方便,因为这意味着对于你需要解决的每个游戏碰撞问题,你只需要使用一个函数。但它实际上只是你已经知道的低级碰撞函数的一个豪华包装,用一点逻辑来计算使用那些碰撞函数中的哪一个。如果你的游戏依赖于尽可能高的性能,就直接使用低级别的碰撞函数,这样你就不会招致轻微的处理债务。开销很小,但还是开销很大。
下面是完整的hit函数。代码并不漂亮——它主要只是一堆复杂的条件检查,以找出要实现哪个碰撞函数。我列出它只是作为参考,以防你需要自己编写一个类似的通用函数。一个特别方便的特性是,hit函数将自动循环遍历精灵数组,这将你从游戏代码中的filter、for或forEach循环中拯救出来——我将在代码清单之后更详细地解释这个特性是如何工作的。
export function hit(
a, b, react = false, bounce = false, global, extra = undefined
) {
let collision,
aIsASprite = a.parent !== undefined,
bIsASprite = b.parent !== undefined;
//Check to make sure one of the arguments isn't an array
if (aIsASprite && b instanceof Array
|| bIsASprite && a instanceof Array) {
//If it is, check for a collision between a sprite and an array
spriteVsArray();
} else {
//If one of the arguments isn't an array, find out what type of
//collision check to run
collision = findCollisionType(a, b);
if (collision && extra) extra(collision);
}
//Return the result of the collision.
//It will be `undefined` if there's no collision and `true` if
//there is a collision. `rectangleCollision` sets `collision` to
//"top", "bottom", "left" or "right" depending on which side the
//collision is occurring on
return collision;
function findCollisionType(a, b) {
//Are `a` and `b` both sprites?
//(We have to check again if this function was called from
//`spriteVsArray`)
let aIsASprite = a.parent !== undefined;
let bIsASprite = b.parent !== undefined;
if (aIsASprite && bIsASprite) {
//Yes, but what kind of sprites?
if(a.diameter && b.diameter) {
//They're circles
return circleVsCircle(a, b);
}
else if (a.diameter && !b.diameter) {
//The first one is a circle and the second is a rectangle
return circleVsRectangle(a, b);
}
else {
//They're rectangles
return rectangleVsRectangle(a, b);
}
}
//They're not both sprites, so what are they?
//Is `a` not a sprite and does it have x and y properties?
else if (bIsASprite && !(a.x === undefined) && !(a.y === undefined)) {
//Yes, so this is a point vs. sprite collision test
return hitTestPoint(a, b);
}
else {
//The user is trying to test some incompatible objects
throw new Error(
`I'm sorry, ${a} and ${b} cannot be used together in a collision test.'
`);
}
}
function spriteVsArray() {
//If `a` happens to be the array, flip it around so that it becomes `b`
if (a instanceof Array) {
let [a, b] = [b, a];
}
//Loop through the array in reverse
for (let i = b.length - 1; i >= 0; i--) {
let sprite = b[i];
collision = findCollisionType(a, sprite);
if (collision && extra) extra(collision, sprite);
}
}
function circleVsCircle(a, b) {
//If the circles shouldn't react to the collision,
//just test to see if they're touching
if(!react) {
return hitTestCircle(a, b);
}
//Yes, the circles should react to the collision
else {
//Are they both moving?
if (a.vx + a.vy !== 0 && b.vx + b.vy !== 0) {
//Yes, they are both moving
//(moving circle collisions always bounce apart so there's
//no need for the third, `bounce`, argument)
return movingCircleCollision(a, b, global);
}
else {
//No, they're not both moving
return circleCollision(a, b, bounce, global);
}
}
}
function rectangleVsRectangle(a, b) {
//If the rectangles shouldn't react to the collision, just
//test to see if they're touching
if(!react) {
return hitTestRectangle(a, b, global);
}
else {
return rectangleCollision(a, b, bounce, global);
}
}
function circleVsRectangle(a, b) {
//If the rectangles shouldn't react to the collision, just
//test to see if they're touching
if(!react) {
return hitTestCircleRectangle(a, b, global);
}
else {
return circleRectangleCollision(a, b, bounce, global);
}
}
}
hit函数如何遍历数组
让我们看看hit函数如何处理精灵数组。它首先检查第一个或第二个参数是否是数组。如果其中任何一个是,它调用spriteVsArray函数。
if (aIsASprite && b instanceof Array || bIsASprite && a instanceof Array) {
spriteVsArray();
}
执行遍历精灵数组的工作。
function spriteVsArray() {
//If `a` happens to be the array, flip it around so that it becomes `b`
if (a instanceof Array) {
let [a, b] = [b, a];
}
//Loop through the array in reverse
for (let i = b.length - 1; i >= 0; i--) {
let sprite = b[i];
collision = findCollisionType(a, sprite);
if (collision && extra) extra(collision, sprite);
}
}
spriteVsArray函数期望数组是第二个参数:b。如果不是——如果第一个参数a恰好是数组——代码会翻转这些值,使a变成b。它是在 JavaScript ES6 析构赋值的帮助下完成的:
let [a, b] = [b, a];
这是一个将b的值复制到a并将a的值复制到b的巧妙技巧。它使您不必使用第三个临时变量来帮助交换值。
接下来,代码反向遍历数组。(它反向循环,因此如果在循环过程中从数组中移除一个 sprite,这种移除不会在数组中创建一个洞。)它运行适当的碰撞函数,然后,如果有碰撞,运行您定义的extra回调函数:
if (collision && extra) extra(collision, sprite);
这里有一个例子,说明如何在游戏代码中定义这个extra回调函数。extra函数使用两个参数:冲突检查的返回值,以及冲突中涉及的数组中的 sprite。
let playerVsPlatforms = hit(
gameCharacter, world.platforms, true, false, false,
//The `extra` function
(collision, platform) => {
//`collision` tells you the side of the `gameCharacter` sprite
//on which the collision occurred.
//`platform` is the sprite from the `world.platforms` array
//that the player is colliding with
}
);
这是一种进行复杂碰撞检查的紧凑方式,它为您提供了大量信息和低级控制,但它也使您不必手动循环数组中的所有精灵。
摘要
这个附录是对向量数学世界的深入探究,它给了你完全控制你的游戏世界几何图形所需的所有工具。现在你知道了什么是向量,以及在各种游戏场景中使用它们所需的基本数学和概念。您不仅学习了一些使用矢量的有用函数,还学习了创建自己的自定义效果所需的所有低级技能。您还学习了如何应用向量数学技能来创建一些有用的碰撞函数,这些函数将使您很好地适应大多数类型的 2D 动作游戏,您还学习了分离轴定理的基础知识,如果需要,您可以使用它来构建自定义碰撞系统。****


浙公网安备 33010602011771号