WebAssembly-游戏开发实用指南(全)

WebAssembly 游戏开发实用指南(全)

原文:annas-archive.org/md5/2bc11e3fb2b816b3a221f95dafc6aa63

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

WebAssembly 是一项将在未来几年改变网络的技术。WebAssembly 承诺了一个世界,网络应用程序以接近本机速度运行。这是一个你可以用任何喜欢的语言为网络编写应用程序,并将其编译为本机平台以及网络的世界。对于 WebAssembly 来说,现在还处于早期阶段,但这项技术已经像火箭一样起飞。如果你对网络的未来和现在一样感兴趣,那就继续阅读吧!

我写这本书是为了反映我喜欢学习新技能的方式。我将带领你通过使用 WebAssembly 及其所有相关技术开发游戏。我是一名长期从事游戏和网络开发的人,我一直喜欢通过编写游戏来学习新的编程语言。在这本书中,我们将使用与 WebAssembly 紧密相关的网络和游戏开发工具涵盖许多主题。我们将学习如何使用各种编程语言和工具编写针对 WebAssembly 的游戏,包括 Emscripten、C/C++、WebGL、OpenGL、JavaScript、HTML5 和 CSS。作为一家专门从事网络游戏开发的独立游戏开发工作室的老板,我发现了解网络和游戏技术是至关重要的,我在这本书中充满了这些技术。你将学习一系列技能,重点是如何使用 WebAssembly 快速启动应用程序。如果你想学习如何使用 WebAssembly 开发游戏,或者想创建运行速度极快的基于网络的应用程序,这本书适合你。

这本书是为谁写的

这本书不是编程入门。它适用于至少掌握一种编程语言的人。了解一些网络技术,如 HTML,会有所帮助,但并非绝对必要。这本书包含了如何在 Windows 或 Ubuntu Linux 上安装所需工具的说明,如果两者中选择一个,我建议使用 Ubuntu,因为它的安装过程要简单得多。

这本书涵盖了什么

第一章,WebAssembly 和 Emscripten 简介,介绍了 WebAssembly,为什么网络需要它,以及为什么它比 JavaScript 快得多。我们将介绍 Emscripten,为什么我们需要它进行 WebAssembly 开发,以及如何安装它。我们还将讨论与 WebAssembly 相关的技术,如 asm.js、LLVM 和 WebAssembly Text。

第二章,HTML5 和 WebAssembly,讨论了 WebAssembly 模块如何使用 JavaScript“粘合代码”与 HTML 集成。我们将学习如何创建自己的 Emscripten HTML 外壳文件,以及如何在我们将用 C 编写的 WebAssembly 模块中进行调用和调用。最后,我们将学习如何编译和运行与我们的 WebAssembly 模块交互的 HTML 页面,以及如何使用 Emscripten 构建一个简单的 HTML5 Canvas 应用程序。

第三章,WebGL 简介,介绍了 WebGL 及支持它的新画布上下文。我们将学习着色器是什么,以及 WebGL 如何使用它们将几何图形渲染到画布上。我们将学习如何使用 WebGL 和 JavaScript 将精灵绘制到画布上。最后,我们将编写一个应用程序,集成了 WebAssembly、JavaScript 和 WebGL,显示一个精灵并在画布上移动。

第四章,在 WebAssembly 中使用 SDL 进行精灵动画,教你关于 SDL 库以及我们如何使用它来简化从 WebAssembly 到 WebGL 的调用。我们将学习如何使用 SDL 在 HTML5 画布上渲染、动画化和移动精灵。

第五章,“键盘输入”,介绍了如何从 JavaScript 中接收键盘输入并调用 WebAssembly 模块。我们还将学习如何在 WebAssembly 模块内使用 SDL 接受键盘输入,并使用输入来移动 HTML5 画布上的精灵。

第六章,“游戏对象和游戏循环”,探讨了一些基本的游戏设计。我们将学习游戏循环,以及 WebAssembly 中的游戏循环与其他游戏的不同之处。我们还将学习游戏对象以及如何在游戏内部创建对象池。我们将通过编写游戏的开头来结束本章,其中有两艘太空船在画布上移动并互相射击。

第七章,“碰撞检测”,将碰撞检测引入我们的游戏中。我们将探讨 2D 碰撞检测的类型,实现基本的碰撞检测系统,并学习一些使其工作的三角学知识。我们将修改我们的游戏,使得当抛射物相撞时太空船被摧毁。

第八章,“基本粒子系统”,介绍了粒子系统,并讨论了它们如何可以在视觉上改善我们的游戏。我们将讨论虚拟文件系统,并学习如何通过网页向其中添加文件。我们将简要介绍 SVG 和矢量图形,以及如何将它们用于数据可视化。我们还将进一步讨论三角学以及我们将如何在粒子系统中使用它。我们将构建一个新的 HTML5 WebAssembly 应用程序,帮助我们配置和测试稍后将添加到我们的游戏中的粒子系统。

第九章,“改进的粒子系统”,着手改进我们的粒子系统配置工具,添加了粒子缩放、旋转、动画和颜色过渡。我们将修改工具以允许粒子系统循环,并添加爆发效果。然后,我们将更新我们的游戏以支持粒子系统,并为我们的引擎排气和爆炸添加粒子系统效果。

第十章,“AI 和转向行为”,介绍了 AI 和游戏 AI 的概念,并讨论了它们之间的区别。我们将讨论有限状态机、自主代理和转向行为的 AI 概念,并在敌方 AI 中实现这些行为,使其能够避开障碍物并与玩家作战。

第十一章,“设计 2D 摄像头”,引入了 2D 摄像头设计的概念。我们将首先向我们的游戏添加一个渲染管理器,并创建一个锁定在玩家太空船上的摄像头,跟随它在扩展的游戏区域周围移动。然后,我们将添加投影焦点和摄像头吸引器的高级 2D 摄像头功能。

第十二章,“音效”,涵盖了在我们的游戏中使用 SDL 音频。我们将讨论从在线获取音效的位置,以及如何将这些声音包含在我们的 WebAssembly 模块中。然后,我们将向我们的游戏添加音效。

第十三章,“游戏物理”,介绍了计算机游戏中的物理概念。我们将在我们的游戏对象之间添加弹性碰撞。我们将在游戏的物理中添加牛顿第三定律,即当太空船发射抛射物时的后坐力。我们将在吸引太空船的星球上添加一个重力场。

第十四章,“UI 和鼠标输入”,讨论在我们的 WebAssembly 模块中添加要管理和呈现的用户界面。我们将收集要求并将其转化为我们游戏中的新屏幕。我们将添加一个新的按钮对象,并学习如何使用 SDL 从我们的 WebAssembly 模块内管理鼠标输入。

第十五章,“着色器和 2D 照明”,深入探讨了如何创建一个混合 OpenGL 和 SDL 的新应用程序。我们将创建一个新的着色器,加载并渲染多个纹理到一个四边形上。我们将学习法线贴图,以及如何使用法线贴图来在 2D 中近似冯氏光照模型,使用 OpenGL 在我们的 WebAssembly 模块中。

第十六章,“调试和优化”,介绍了调试和优化 WebAssembly 模块的基本方法。我们将从 WebAssembly 的调试宏和堆栈跟踪开始。我们将介绍源映射的概念,以及 Web 浏览器如何使用它们来调试 WebAssembly 模块。我们将学习使用优化标志来优化 WebAssembly 代码。我们将讨论使用分析器来优化我们的 WebAssembly 代码。

充分利用本书

您必须了解计算机编程的基础知识。

了解 HTML 和 CSS 等网络技术的基础知识将有所帮助。

下载示例代码文件

您可以从这里下载本书的代码包:github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly

我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781838644659_ColorImages.pdf

使用的约定

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packt.com

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为https://github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码字,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。例如:“我们将复制basic_particle_shell.html文件到一个新的外壳文件,我们将其称为advanced_particle_shell.html。”

代码块设置如下:

<label class="ccontainer"><span class="label">loop:</span>
<input type="checkbox" id="loop" checked="checked">
<span class="checkmark"></span>
</label>
<br/>

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

<label class="ccontainer"><span class="label">loop:</span>
<input type="checkbox" id="loop" checked="checked">
<span class="checkmark"></span>
</label>
<br/>

任何命令行输入或输出都以以下方式编写:

emrun --list_browsers

粗体:表示新术语,重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种形式出现在文本中。例如:“从管理面板中选择系统信息。”

警告或重要提示会以这种形式出现。

提示和技巧会出现在这样的形式中。

第一章:介绍 WebAssembly 和 Emscripten

欢迎来到令人兴奋的 WebAssembly 新世界!对于 WebAssembly 来说,现在还处于早期阶段,但这项技术目前正如火箭般腾飞,通过阅读本书,您有机会站在起步阶段。如果您对网络游戏开发感兴趣,或者您想尽可能多地了解这项新技术,以便在其成熟时为自己找到位置,那么您来对地方了。尽管 WebAssembly 还处于萌芽阶段,但所有主要的浏览器供应商都已经采用了它。现在是早期阶段,使用案例有限,但幸运的是,游戏开发是其中之一。因此,如果您想成为下一代网络应用程序开发派对的早期参与者,那就继续阅读吧,冒险家!

在本章中,我将向您介绍 WebAssembly、Emscripten 以及围绕 WebAssembly 的一些基础技术。我将教您 Emscripten 工具链的基础知识,以及如何使用 Emscripten 将 C++代码编译成 WebAssembly。我们将讨论 LLVM 是什么,以及它如何融入 Emscripten 工具链。我们将讨论 WebAssembly 的最小可行产品MVP),以及在其当前 MVP 形式下 WebAssembly 的最佳使用案例,以及即将到来的内容。我将介绍WebAssembly 文本.wat),以及我们如何使用它来理解 WebAssembly 字节码的设计,以及它与其他机器字节码的区别。我们还将简要讨论asm.js,以及它在 WebAssembly 设计中的历史意义。最后,我将向您展示如何在 Windows 和 Linux 上安装和运行 Emscripten。

在本章中,我们将涵盖以下主题:

  • 什么是 WebAssembly?

  • 我们为什么需要 WebAssembly?

  • 为什么 WebAssembly 比 JavaScript 更快?

  • WebAssembly 会取代 JavaScript 吗?

  • 什么是 asm.js?

  • 对 LLVM 的简要介绍

  • 对 WebAssembly 文本的简要介绍

  • 什么是 Emscripten,我们如何使用它?

什么是 WebAssembly?

WebAssembly 不是像 JavaScript 那样的高级编程语言,而是一种编译的二进制格式,所有主要浏览器目前都能够执行。WebAssembly 是一种机器字节码,不是设计用于直接在任何真实机器硬件上运行,而是在每个浏览器内置的 JavaScript 引擎中运行。在某些方面,它类似于旧的Java 虚拟机JVM);例如,它是一个平台无关的编译字节码。JavaScript 字节码的一个主要问题是需要下载和安装浏览器中的插件才能运行字节码。WebAssembly不仅旨在在浏览器中直接运行而无需插件,而且还旨在生成在 Web 浏览器内高效执行的紧凑二进制格式。规范的 MVP 版本利用了浏览器制造商设计他们的 JavaScript 即时JIT)编译器的现有工作。WebAssembly 目前是一项年轻的技术,许多改进计划中。然而,使用当前版本的 WebAssembly 的开发人员已经看到了相对于 JavaScript 的性能提升 10-800%。

MVP 是可以赋予产品的最小功能集,以使其吸引早期采用者。由于当前版本是 MVP,功能集很小。有关更多信息,请参阅这篇关于 WebAssembly“后 MVP 未来”的优秀文章:hacks.mozilla.org/2018/10/webassemblys-post-mvp-future/

我们为什么需要 WebAssembly?

JavaScript 已经存在很长时间了。它已经从一个允许在网页上添加花里胡哨的小脚本语言发展成一个庞大的 JIT 编译语言生态系统,可以用来编写完整的应用程序。如今,JavaScript 正在做许多在 1995 年由网景创建时可能从未想象过的事情。JavaScript 是一种解释语言,这意味着它必须在运行时进行解析、编译和优化。JavaScript 也是一种动态类型语言,这给优化器带来了麻烦。

Chrome V8 团队成员 Franziska Hinkelmann 在Web Rebels 2017会议上发表了一次精彩的演讲,她讨论了过去 20 年来对 JavaScript 所做的所有性能改进,以及他们在 JavaScript V8 引擎中尽可能挤出每一点性能时遇到的困难:youtu.be/ihANrJ1Po0w

WebAssembly 解决了 JavaScript 及其在浏览器中的悠久历史所带来的许多问题。因为 JavaScript 引擎已经是字节码格式,所以不需要运行解析器,这消除了应用程序执行中的一个重要瓶颈。这种设计还允许 JavaScript 引擎始终知道它正在处理的数据类型。字节码使优化变得更加容易。这种格式允许浏览器中的多个线程同时处理编译和优化代码的不同部分。

有关 Chrome V8 引擎解析代码时发生的详细解释,请参考JSConf EU 2017的这个视频,其中 Chrome V8 工具的 Marja Hölttä(负责人)详细介绍了您可能想要了解有关解析 JavaScript 的更多细节:www.youtube.com/watch?v=Fg7niTmNNLg&t=123s

WebAssembly 不是一种高级编程语言,而是一个带有虚拟机操作码的二进制文件。目前,它被认为处于 MVP 开发阶段。这项技术仍处于初期阶段,但即使现在,它也为许多用例提供了显著的性能和文件大小优势,例如游戏开发。由于 WebAssembly 目前的限制,我们只有两种选择用于其开发的语言 - C/C++或 Rust。WebAssembly 的长期计划是支持多种编程语言进行开发。如果我想以最低的抽象级别编写,我可以在Web Assembly TextWAT)中编写所有内容,但 WAT 是作为一种支持调试和测试的语言开发的,并不打算供开发人员用于编写应用程序。

为什么 WebAssembly 比 JavaScript 快?

正如我所提到的,WebAssembly 比 JavaScript 快 10-800%,这取决于应用程序。要理解原因,我需要谈一下当运行 JavaScript 代码时 JavaScript 引擎做了什么,以及当运行 WebAssembly 时它必须做什么。我将专门谈谈 V8(Chrome JavaScript 引擎),尽管据我所知,相同的一般过程也存在于 SpiderMonkey(Firefox)和 Chakra(IE 和 Edge)JavaScript 引擎中。

JavaScript 引擎的第一件事是将您的源代码解析成抽象语法树AST)。源代码根据应用程序内的逻辑被分成分支和叶子。此时,解释器开始处理您当前执行的语言。多年来,JavaScript 一直是一种解释语言,因此,如果您在 JavaScript 中运行相同的代码 100 次,JavaScript 引擎必须将该代码转换为机器代码 100 次。可以想象,这是极其低效的。

Chrome 浏览器在 2008 年引入了第一个 JavaScript JIT 编译器。JIT 编译器与提前编译AOT)编译器相对,它在运行代码时编译代码。一种分析器坐在那里观察 JavaScript 执行,寻找重复执行的代码。每当它看到代码执行几次时,就将该代码标记为 JIT 编译的“热”代码。然后编译器编译 JavaScript“存根”代码的字节码表示。这个字节码通常是中间表示IR),与特定于机器的汇编语言相去一步。解码存根将比下次通过解释器运行相同代码的速度快得多。

以下是运行 JavaScript 代码所需的步骤:

图 1.1:现代 JavaScript 引擎所需的步骤

在所有这些情况下,还有一个优化编译器正在观察分析器以寻找“热”代码分支。优化编译器然后将这些代码分支优化为 JIT 创建的字节码的高度优化的机器代码。此时,JavaScript 引擎已经创建了一些运行速度非常快的代码,但有一个问题(或者可能有几个)。

JavaScript 引擎必须对数据类型做出一些假设,以获得优化的机器代码。问题是,JavaScript 是一种动态类型语言。动态类型使程序员更容易学习如何编写 JavaScript 代码,但对于代码优化器来说却是一个糟糕的选择。我经常看到的例子是,当 JavaScript 看到表达式c = a + b时会发生什么(尽管我们几乎可以将此示例用于任何表达式)。

执行此操作的任何机器代码几乎都需要三个步骤:

  1. a值加载到一个寄存器中。

  2. b值添加到一个寄存器中。

  3. 然后将寄存器存储到c中。

以下伪代码摘自ECMAScript® 2018 语言规范的第 12.8.3 节,描述了 JavaScript 中使用加法运算符(+)时必须运行的代码:

1\. Let lref be the result of evaluating AdditiveExpression.
2\. Let lval be ? GetValue(lref).
3\. Let rref be the result of evaluating MultiplicativeExpression.
4\. Let rval be ? GetValue(rref).
5\. Let lprim be ? ToPrimitive(lval).
6\. Let rprim be ? ToPrimitive(rval).
7\. If Type(lprim) is String or Type(rprim) is String, then
   a. Let lstr be ? ToString(lprim).
   b. Let rstr be ? ToString(rprim).
   c. Return the string-concatenation of lstr and rstr.
8\. Let lnum be ? ToNumber(lprim).
9\. Let rnum be ? ToNumber(rprim).
10.Return the result of applying the addition operation to lnum and      
   rnum.

您可以在网上找到ECMAScript® 2018 语言规范,网址为www.ecma-international.org/ecma-262/9.0/index.html

这个伪代码并不是我们必须评估的全部内容。其中几个步骤是调用高级函数,而不是运行机器代码命令。例如,GetValue本身就有 11 个步骤,反过来又调用其他步骤。所有这些可能最终导致数百个机器操作码。这里发生的绝大部分是类型检查。在 JavaScript 中,当您执行a + b时,每个变量都可能是以下类型之一:

  • 整数

  • 浮点数

  • 字符串

  • 对象

  • 这些的任何组合

更糟糕的是,JavaScript 中的对象也是高度动态的。例如,也许您已经定义了一个名为Point的函数,并使用new运算符创建了两个具有该函数的对象:

function Point( x, y ) {
    this.x = x;
    this.y = y;
}

var p1 = new Point(1, 100);
var p2 = new Point( 10, 20 );

现在我们有两个共享相同类的点。假设我们添加了这行:

p2.z = 50;

这意味着这两个点将不再共享相同的类。实际上,p2已经成为一个全新的类,这对该对象在内存中的存在位置和可用的优化产生了影响。JavaScript 被设计为一种高度灵活的语言,但这一事实产生了许多特殊情况,而特殊情况使优化变得困难。

JavaScript 动态特性带来的另一个优化问题是,没有一种优化是最终的。所有围绕类型的优化都必须不断使用资源进行检查,以查看它们的类型假设是否仍然有效。此外,优化器必须保留非优化代码,以防这些假设被证明是错误的。优化器可能会确定最初做出的假设结果是不正确的。这会导致“退出”,优化器将丢弃其优化代码并取消优化,导致性能不一致。

最后,JavaScript 是一种具有垃圾收集GC)的语言,这使得 JavaScript 代码的作者在编写代码时可以承担更少的内存管理负担。尽管这对开发人员来说很方便,但它只是将内存管理的工作推迟到运行时的机器上。多年来,JavaScript 中的 GC 变得更加高效,但在运行 JavaScript 时,JavaScript 引擎仍然必须执行这项工作,而在运行 WebAssembly 时则不需要。

执行 WebAssembly 模块消除了运行 JavaScript 代码所需的许多步骤。WebAssembly 消除了解析,因为 AOT 编译器完成了该功能。解释器是不必要的。我们的 JIT 编译器正在进行近乎一对一的字节码到机器码的转换,这是非常快的。JavaScript 需要大部分优化是因为 WebAssembly 中不存在的动态类型。在 WebAssembly 编译之前,AOT 编译器可以进行与硬件无关的优化。JIT 优化器只需要执行 WebAssembly AOT 编译器无法执行的特定于硬件的优化。

以下是 JavaScript 引擎执行 WebAssembly 二进制文件的步骤:

图 1.2:执行 WebAssembly 所需的步骤

我想要提到的最后一件事不是当前 MVP 的特性,而是 WebAssembly 可能带来的未来。使现代 JavaScript 运行速度快的所有代码都占用内存。保留非优化代码的旧副本占用内存。解析器、解释器和垃圾收集器都占用内存。在我的桌面上,Chrome 经常占用大约 1GB 的内存。通过在我的网站www.classicsolitaire.com上运行一些测试,我可以看到启用 JavaScript 引擎时,Chrome 浏览器占用约 654MB 的内存。

这是一个任务管理器的截图:

图 1.3:启用 JavaScript 的 Chrome 任务管理器进程截图

关闭 JavaScript 后,Chrome 浏览器占用约 295MB。

这是一个任务管理器的截图:

图 1.4:没有 JavaScript 的 Chrome 任务管理器进程截图

因为这是我的网站之一,我知道该网站上只有几百千字节的 JavaScript 代码。对我来说,令人震惊的是,运行这么少量的 JavaScript 代码会使我的浏览器占用大约 350MB 的内存。目前,WebAssembly 在现有的 JavaScript 引擎上运行,并且仍然需要相当多的 JavaScript 粘合代码来使一切正常运行,但从长远来看,WebAssembly 不仅将允许我们加快 Web 上的执行速度,还将使我们能够以更小的内存占用来实现。

WebAssembly 会取代 JavaScript 吗?

简短的回答是不会很快。目前,WebAssembly 仍处于 MVP 阶段。在这个阶段,使用案例的数量仅限于 WebAssembly 与 JavaScript 和文档对象模型(DOM)之间的有限来回。WebAssembly 目前无法直接与 DOM 交互,Emscripten 使用 JavaScript“粘合代码”来实现该交互。这种交互可能很快会发生变化,可能在您阅读本文时就已经发生了,但在未来几年,WebAssembly 将需要额外的功能来增加可能的使用案例数量。

WebAssembly 并不是一个“功能完备”的平台。目前,它无法与任何需要 GC 的语言一起使用。这种情况将会改变,最终,几乎所有强类型语言都将以 WebAssembly 为目标。此外,WebAssembly 很快将与 JavaScript 紧密集成,允许诸如 React、Vue 和 Angular 等框架开始用 WebAssembly 替换大量的 JavaScript 代码,而不影响应用程序编程接口(API)。React 团队目前正在努力改进 React 的性能。

从长远来看,JavaScript 可能会编译成 WebAssembly。出于技术原因,这还有很长的路要走。JavaScript 不仅需要 GC(目前不支持),而且由于其动态特性,JavaScript 还需要运行时分析器来进行优化。因此,JavaScript 将产生非常糟糕的优化代码,或者需要进行重大修改以支持严格类型。更有可能的是,像 TypeScript 这样的语言将添加功能,使其能够编译成 WebAssembly。

在 GitHub 上开发的AssemblyScript项目正在开发一个 TypeScript 到 WebAssembly 的编译器。该项目创建 JavaScript 并使用 Binaryen 将该 JavaScript 编译成 WebAssembly。AssemblyScript 如何处理垃圾回收的问题尚不清楚。有关更多信息,请参阅github.com/AssemblyScript/assemblyscript

JavaScript 目前在网络上无处不在;有大量的库和框架是用 JavaScript 开发的。即使有一大批开发人员渴望用 C++或 Rust 重写整个网络,WebAssembly 也还没有准备好取代这些 JavaScript 库和框架。浏览器制造商已经付出了巨大的努力来使 JavaScript 运行(相对)快速,因此 JavaScript 可能仍然会成为网络的标准脚本语言。网络将始终需要一种脚本语言,无数开发人员已经努力使 JavaScript 成为这种脚本语言,因此 JavaScript 很可能永远不会消失。

然而,网络需要一种编译格式,WebAssembly 很可能会满足这种需求。目前,编译代码可能在网络上只是一个小众市场,但在其他地方却是标准。随着 WebAssembly 接近功能完备的状态,它将提供比 JavaScript 更多的选择和更好的性能,企业、框架和库将逐渐向其迁移。

什么是 asm.js?

早期实现在 Web 浏览器中使用 JavaScript 实现类似本机速度的尝试是 asm.js。尽管达到了这个目标,并且 asm.js 被所有主要浏览器供应商采用,但它从未被开发人员广泛采用。asm.js 的美妙之处在于它仍然可以在大多数浏览器中运行,即使在那些不对其进行优化的浏览器中也是如此。asm.js 的理念是,可以在 JavaScript 中使用类型化数组来模拟 C++内存堆。浏览器模拟 C++中的指针和内存分配,以及类型。设计良好的 JavaScript 引擎可以避免动态类型检查。使用 asm.js,浏览器制造商可以避开 JavaScript 动态特性带来的许多优化问题,只需假装这个版本的 JavaScript 不是动态类型的即可。Emscripten 作为 C++到 JavaScript 编译器,迅速采用了 asm.js 作为其编译的 JavaScript 子集,因为它在大多数浏览器中的性能得到了改善。由 asm.js 带来的性能改进引领了 WebAssembly 的发展。用于使 asm.js 性能良好的相同引擎修改可以用来引导 WebAssembly MVP。只需要添加一个字节码到字节码编译器,就可以将 WebAssembly 字节码直接转换为浏览器使用的 IR 字节码。

在撰写本文时,Emscripten 不能直接从 LLVM 编译到 WebAssembly。相反,它将编译为 asm.js,并使用一个名为 Binaryen 的工具将 Emscripten 的 asm.js 输出转换为 WebAssembly。

LLVM 简介

Emscripten 是我们将用来将 C++编译成 WebAssembly 的工具。在讨论 Emscripten 之前,我需要解释一下一个名为 LLVM 的技术以及它与 Emscripten 的关系。

首先,花点时间想想航空公司(跟着我)。航空公司希望将乘客从一个机场运送到另一个机场。但是要为每个机场到地球上的每个其他机场提供直达航班是具有挑战性的。这意味着航空公司必须提供大量的直达航班,比如从俄亥俄州的阿克伦到印度的孟买。让我们回到 20 世纪 90 年代,那是编译器世界的状态。如果你想要从 C++编译到 ARM,你需要一个能够将 C++编译到 ARM 的编译器。如果你需要从 Pascal 编译到 x86,你需要一个能够将 Pascal 编译到 x86 的编译器。这就像在任何两个城市之间只有直达航班一样:每种语言和硬件的组合都需要一个编译器。结果要么是你必须限制你为其编写编译器的语言数量,限制你可以支持的平台数量,或者更可能的是两者都有。

2003 年,伊利诺伊大学的一名学生克里斯·拉特纳想到了一个问题:“如果我们为编程语言创建一个轮毂和辐条模型会怎样?”他的想法导致了 LLVM 的诞生,LLVM 最初代表“低级虚拟机”。其理念是,不是为了任何可能的分发编译源代码,而是为了 LLVM。然后在中间语言和最终输出语言之间进行编译。理论上,这意味着如果你在下图的右侧开发了一个新的目标平台,你立即就能得到左侧所有语言:

图 1.5:LLVM 作为编程语言和硬件之间的轮毂。

要了解更多关于 LLVM 的信息,请访问 LLVM 项目主页llvm.org或阅读《LLVM Cookbook》,作者 Mayur Padney 和 Suyog Sarda,Packt Publishing:www.packtpub.com/application-development/llvm-cookbook

WebAssembly 文本简介

WebAssembly 二进制不是一种语言,而是类似于为 ARM 或 x86 构建的构建目标。然而,字节码的结构与其他特定硬件的构建目标不同。WebAssembly 字节码的设计者考虑了网络。目标是创建一种紧凑且可流式传输的字节码。另一个目标是用户应该能够对 WebAssembly 二进制执行“查看/源代码”以查看发生了什么。WebAssembly 文本是 WebAssembly 二进制的伴随代码,允许用户以人类可读的形式查看字节码指令,类似于汇编语言可以让您以机器可读的形式查看操作码。

对于习惯于为 ARM、x86 或 6502(如果您是老派的话)等硬件编写汇编的人来说,WebAssembly 文本可能一开始看起来很陌生。您可以使用 S 表达式编写 WebAssembly 文本,它具有括号密集的树结构。一些操作对于汇编语言来说也非常高级,例如 if/else 和 loop 操作码。如果您记得 WebAssembly 不是设计为直接在计算机硬件上运行,而是快速下载和转换为机器码,那么这就更有意义了。

处理 WebAssembly 文本时,刚开始会感到有些陌生的另一件事是缺少寄存器。WebAssembly 被设计为一种虚拟堆栈机,这是一种与您可能熟悉的 x86 和 ARM 等寄存器机不同的替代机器。堆栈机的优势在于生成的字节码比寄存器机小得多,这是选择堆栈机用于 WebAssembly 的一个很好的理由。堆栈机不是使用一系列寄存器来存储和操作数字,而是在堆栈上推送或弹出值(有时两者都有)。例如,在 WebAssembly 中调用i32.add会从堆栈中取出两个 32 位整数,将它们相加,然后将结果推送回堆栈。计算机硬件可以充分利用可用的寄存器来执行此操作。

Emscripten

现在我们知道了 LLVM 是什么,我们可以讨论 Emscripten。Emscripten 最初是开发为将 LLVM IR 编译成 JavaScript,但最近已更新为将 LLVM 编译成 WebAssembly。其想法是,一旦您使 LLVM 编译器工作,您就可以获得编译为 LLVM IR 的所有语言的好处。实际上,WebAssembly 规范仍处于早期阶段,不支持诸如 GC 之类的常见语言特性。因此,目前仅支持非 GC 语言,如 C/C++和 Rust。WebAssembly 仍处于其发展的早期 MVP 阶段,但很快将添加 GC 和其他常见语言特性。发生这种情况时,应该会有大量编程语言可以编译为 WebAssembly。

Emscripten 于 2012 年发布时,旨在成为 LLVM 到 JavaScript 的编译器。2013 年,添加了对 asm.js 的支持,这是 JavaScript 语言的更快、更易优化的子集。2015 年,Emscripten 开始添加对 LLVM 到 WebAssembly 的编译支持。Emscripten 还为 C++和 JavaScript 提供了软件开发工具包SDK),提供了比 WebAssembly MVP 本身提供的更好的 JavaScript 和 WebAssembly 交互工具。Emscripten 还集成了一个名为 Clang 的 C/C++到 LLVM 编译器,因此您可以将 C++编译成 WebAssembly。此外,Emscripten 将生成您启动项目所需的 HTML 和 JavaScript 粘合代码。

Emscripten 是一个非常动态的项目,工具链经常发生变化。要了解 Emscripten 的最新变化,请访问项目主页emscripten.org

在 Windows 上安装 Emscripten

我将保持本节简短,因为这些说明可能会发生变化。您可以在 Emscripten 网站上找到官方 Emscripten 下载和安装说明来补充这些说明:emscripten.org/docs/getting_started/downloads.html

我们需要从 GitHub 上的 emsdk 源文件下载并构建 Emscripten。首先,我们将介绍在 Windows 上的操作。

Python 2.7.12 或更高版本是必需的。如果您尚未安装高于 2.7.12 的 Python 版本,您需要从python.org获取 Windows 安装程序并首先安装:www.python.org/downloads/windows/

如果您已安装 Python,但仍然收到 Python 未找到的错误提示,可能需要将 Python 添加到 Windows 的 PATH 变量中。有关更多信息,请参考本教程:www.pythoncentral.io/add-python-to-path-python-is-not-recognized-as-an-internal-or-external-command/

如果您已经安装了 Git,则克隆存储库相对简单:

  1. 运行以下命令来克隆存储库:
git clone https://github.com/emscripten-core/emsdk.git
  1. 无论您在何处运行此命令,它都将创建一个emsdk目录。使用以下命令进入该目录:
cd emsdk

您可能尚未安装 Git,在这种情况下,以下步骤将帮助您迅速掌握:

  1. 在 Web 浏览器中转到以下 URL:github.com/emscripten-core/emsdk

  2. 您将在右侧看到一个绿色按钮,上面写着克隆或下载。下载 ZIP 文件:

  1. 将下载的文件解压缩到c:\emsdk目录。

  2. 通过在开始菜单中输入cmd并按Enter来打开 Windows 命令提示符。

  3. 然后,通过输入以下内容将目录更改为c:\emsdk\emsdk-master目录:

 cd \emsdk\emsdk-master

此时,无论您是否已安装 Git 都无关紧要。让我们继续向前:

  1. 从源代码安装emsdk,运行以下命令:
emsdk install latest
  1. 然后激活最新的emsdk
emsdk activate latest
  1. 最后,设置我们的路径和环境变量:
emsdk_env.bat

这最后一步需要在您的安装目录中的每次打开新的命令行窗口时重新运行。不幸的是,它不会永久设置 Windows 环境变量。希望这在未来会有所改变。

在 Ubuntu 上安装 Emscripten

如果您在 Ubuntu 上安装,您应该能够使用apt-get软件包管理器和 git 进行完整安装。让我们继续向前:

  1. Python 是必需的,因此如果您尚未安装 Python,请务必运行以下命令:
sudo apt-get install python
  1. 如果您尚未安装 Git,请运行以下命令:
sudo apt-get install git
  1. 现在您需要克隆emsdk的 Git 存储库:
git clone https://github.com/emscripten-core/emsdk.git
  1. 更改您的目录以进入emsdk目录:
cd emsdk
  1. 从这里,您需要安装最新版本的 SDK 工具,激活它,并设置您的环境变量:
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh
  1. 为了确保一切安装正确,运行以下命令:
emcc --version

使用 Emscripten

我们通过命令行运行 Emscripten;因此,您可以使用任何文本编辑器来编写 C/C++代码。我个人偏爱 Visual Studio Code,您可以在此处下载:code.visualstudio.com/download

Visual Studio Code 的一个美妙之处在于它具有内置的命令行终端,这样您就可以在不切换窗口的情况下编译代码。它还有一个出色的 C/C++扩展,您可以安装它。只需从扩展菜单中搜索 C/C++并安装 Microsoft C/C++ Intellisense 扩展。

无论您选择哪种文本编辑器或集成开发环境,您都需要一个简单的 C 代码片段来测试 emcc 编译器。

  1. 创建一个新的文本文件并将其命名为hello.c

  2. hello.c中输入以下代码:

#include <emscripten.h>
#include <stdlib.h>
#include <stdio.h>

int main() {
    printf("hello wasm\n");
}
  1. 现在我可以将hello.c文件编译成 WebAssembly,并生成一个hello.html文件:
emcc hello.c --emrun -o hello.html
  1. 如果您想要从emrun运行 HTML 页面,则需要--emrun标志。此标志会在 C 代码中添加代码,以捕获stdoutstderr和退出,没有它emrun将无法工作:
emrun --browser firefox hello.html

使用--browser标志运行emrun将选择您想要运行脚本的浏览器。emrun的行为在不同的浏览器之间似乎是不同的。Chrome 将在 C 程序退出时关闭窗口。这可能很烦人,因为我们只是想显示一个简单的打印消息。如果您有 Firefox,我建议使用--browser标志运行emrun

我不想暗示 Chrome 不能运行 WebAssembly。当 WebAssembly 模块退出时,Chrome 的行为确实有所不同。因为我试图尽可能简化我们的 WebAssembly 模块,所以当主函数完成时,它就会退出。这就是在 Chrome 中出现问题的原因。当我们学习游戏循环时,这些问题将会消失。

要查看可用的浏览器,请运行以下命令:

emrun --list_browsers

emrun应该在浏览器中打开一个 Emscripten 模板的 HTML 文件。

确保您的浏览器能够运行 WebAssembly。以下主要浏览器的版本应该能够运行 WebAssembly:

  • Edge 16

  • Firefox 52

  • Chrome 57

  • Safari 11

  • Opera 44

如果您熟悉设置自己的 Web 服务器,您可能希望考虑使用它而不是 emrun。在本书的前几章中使用 emrun 后,我又开始使用我的 Node.js Web 服务器。我发现随时运行基于 Node 的 Web 服务器更容易,而不是每次想要测试代码时都重新启动 emrun Web 服务器。如果您知道如何设置替代 Web 服务器(如 Node、Apache 和 IIS),您可以使用您喜欢的任何 Web 服务器。尽管 IIS 需要一些额外的配置来处理 WebAssembly MIME 类型。

其他安装资源

为 Emscripten 创建安装指南可能会有些问题。WebAssembly 技术经常发生变化,而 Emscripten 的安装过程在您阅读本文时可能已经不同。如果您遇到任何问题,我建议查阅 Emscripten 网站上的下载和安装说明:emscripten.org/docs/getting_started/downloads.html

您可能还想查阅 GitHub 上的 Emscripten 页面:github.com/emscripten-core/emsdk

Google Groups 有一个 Emscripten 讨论论坛,如果您在安装过程中遇到问题,可以在那里提问:groups.google.com/forum/?nomobile=true#!forum/emscripten-discuss

您也可以在 Twitter 上联系我(@battagline),我会尽力帮助您:twitter.com/battagline

摘要

在本章中,我们了解了 WebAssembly 是什么,以及为什么它将成为 Web 应用程序开发的未来。我们了解了为什么我们需要 WebAssembly,尽管我们已经有了像 JavaScript 这样强大的语言。我们了解了为什么 WebAssembly 比 JavaScript 快得多,以及它如何有可能增加其性能优势。我们还讨论了 WebAssembly 取代 JavaScript 成为 Web 应用程序开发的事实标准的可能性。

我们已经讨论了使用 Emscripten 和 LLVM 创建 WebAssembly 模块的实际方面。我们已经讨论了 WebAssembly 文本及其结构。我们还讨论了使用 Emscripten 编译我们的第一个 WebAssembly 模块,以及使用它创建运行该模块的 HTML 和 JavaScript 粘合代码。

在下一章中,我们将更详细地讨论如何使用 Emscripten 来创建我们的 WebAssembly 模块,以及用于驱动它的 HTML/CSS 和 JavaScript。

第二章:HTML5 和 WebAssembly

在本章中,我们将向您展示我们编写的用于目标 WebAssembly 的 C 代码如何与 HTML5、JavaScript 和 CSS 结合在一起,创建一个网页。我们将教您如何创建一个新的 HTML 外壳文件,供 Emscripten 在创建我们的 WebAssembly 应用程序时使用。我们将讨论Module对象以及 Emscripten 如何将其用作 JavaScript 和 WebAssembly 模块之间的接口。我们将向您展示如何在我们的 HTML 页面上从 JavaScript 中调用用 C 编写的 WebAssembly 函数。我们还将向您展示如何从我们的 C 代码中调用 JavaScript 函数。我们将讨论如何使用 CSS 来改善我们网页的外观。我们将向您介绍 HTML5 Canvas 元素,并展示如何可以从 JavaScript 中向画布显示图像。我们将简要讨论如何从我们的 WebAssembly 模块移动这些图像。本章将让您了解所有内容是如何协同工作的,并为我们为 WebAssembly 应用程序开发的其他功能奠定基础。

从本章开始,一直到本书的其余部分,您将需要从 GitHub 项目中获取图像和字体文件来编译示例。对于本章,您将需要从项目目录中获取/Chapter02/spaceship.png图像文件。请从以下网址下载项目:github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly

我强烈建议您在阅读本章的每个部分时进行实际操作。您可以使用您喜欢的代码编辑器和命令行进行跟随。尽管我们已经提供了直接下载代码的链接,但无法强调您通过实际跟随本章建议的编辑来学到多少。您将犯错误并从中学到很多。如果您决定跟随操作,另一个建议是:除非当前部分的编辑/步骤成功,否则不要继续进行下一部分。如果需要帮助,请在 Twitter 上联系我(@battagline)。

在本章中,我们将涵盖以下主题:

  • Emscripten 最小外壳文件

  • 创建新的 HTML 外壳和 C 文件

  • 定义我们的 CSS

  • HTML5 和游戏开发

  • 向 Emscripten 模板添加画布

Emscripten 最小外壳文件

我们使用 Emscripten 创建的第一个构建使用了默认的 HTML 外壳文件。如果您有一个网站,这可能不是您希望网页看起来的方式。您可能更喜欢使用 CSS 和 HTML5 来设计您的外观和感觉,以满足您的设计或业务需求。例如,我用于我的网站的模板通常在游戏画布的左右两侧包括广告。这就是这些网站的流量变现方式。您可能选择在您的网站上方添加一个标志。还有一个文本区域,Emscripten 从printf或其他标准 IO 调用中记录输出。您可以选择完全删除这个textarea元素,或者您可以保留它,但将其隐藏,因为它对以后的调试很有用。

要基于不是默认 Emscripten 外壳的新外壳文件构建 HTML 文件,我们必须使用--shell-file参数,将新的 HTML 模板文件传递给它,而不是 Emscripten 的默认文件。新的emcc命令将如下所示:

emcc hello.c --shell-file new_shell.html --emrun -o hello2.html

暂时不要执行这个命令。我们目前在项目目录中没有new_shell.html文件,因此在该文件存在之前运行该命令将导致错误消息。我们需要创建new_shell.html文件,并将其用作 HTML 外壳,而不是 Emscripten 的默认 HTML 外壳。这个外壳文件必须遵循特定的格式。为了构建它,我们必须从 Emscripten 的最小 HTML 外壳文件开始,您可以在 GitHub 上找到它:

github.com/emscripten-core/emscripten/blob/master/src/shell_minimal.html

我们将编写自己的 HTML 外壳,使用 shell_minimal.html 文件作为起点。最小外壳中的大部分内容都不是必需的,因此我们将对其进行一些重大编辑。我们将删除大部分代码以适应我们的目的。当您在文本编辑器中打开 shell_minimal.html 时,您会看到它以标准的 HTML 头部和 style 标签开头:

<style>
 .emscripten { padding-right: 0; margin-left: auto; margin-right: auto;    
               display: block; }
 textarea.emscripten { font-family: monospace; width: 80%; }
 div.emscripten { text-align: center; }
 div.emscripten_border { border: 1px solid black; }
 /* the canvas *must not* have any border or padding, or mouse coords 
    will be wrong */
 canvas.emscripten { border: 0px none; background-color: black; }
 .spinner {
            height: 50px;
            width: 50px;
            margin: 0px auto;
            -webkit-animation: rotation .8s linear infinite;
            -moz-animation: rotation .8s linear infinite;
            -o-animation: rotation .8s linear infinite;
            animation: rotation 0.8s linear infinite;
            border-left: 10px solid rgb(0,150,240);
            border-right: 10px solid rgb(0,150,240);
            border-bottom: 10px solid rgb(0,150,240);
            border-top: 10px solid rgb(100,0,200);
            border-radius: 100%;
            background-color: rgb(200,100,250);
          }
 @-webkit-keyframes rotation {
         from {-webkit-transform: rotate(0deg);}
         to {-webkit-transform: rotate(360deg);}
  }
 @-moz-keyframes rotation {
         from {-moz-transform: rotate(0deg);}
         to {-moz-transform: rotate(360deg);}
 }
 @-o-keyframes rotation {
         from {-o-transform: rotate(0deg);}
         to {-o-transform: rotate(360deg);}
 }
 @keyframes rotation {
         from {transform: rotate(0deg);}
         to {transform: rotate(360deg);}
 }
 </style>

这段代码是基于撰写时可用的 shell_minimal.html 版本。不预期对此文件进行任何更改。然而,WebAssembly 发展迅速。不幸的是,我们无法完全肯定在您阅读此文时,该文件是否会保持不变。如前所述,如果遇到问题,请随时在 Twitter 上联系我(@battagline)。

我们删除此样式标签,以便您可以按自己的喜好设置代码样式。如果您喜欢他们的旋转加载图像并希望保留它,这是必需的,但最好将所有这些都删除,并用链接标签从外部加载 CSS 文件替换它,如下所示:

<link href="shell.css" rel="stylesheet" type="text/css">

向下滚动一点,您会看到它们使用的加载指示器。我们最终将用我们自己的加载指示器替换它,但现在我们正在本地测试所有这些,我们的文件都很小,所以我们也会删除这些代码:

<figure style="overflow:visible;" id="spinner">
    <div class="spinner"></div>
    <center style="margin-top:0.5em"><strong>emscripten</strong></center>
</figure>
<div class="emscripten" id="status">Downloading...</div>
    <div class="emscripten">
        <progress value="0" max="100" id="progress" hidden=1></progress>
    </div>

之后是一个 HTML5 canvas 元素和与之相关的一些其他标签。我们最终需要重新添加一个 canvas 元素,但现在我们不会使用 canvas,因此代码的这部分也是不必要的:

<div class="emscripten">
    <input type="checkbox" id="resize">Resize canvas
    <input type="checkbox" id="pointerLock" checked>Lock/hide mouse 
     pointer&nbsp;&nbsp;&nbsp;
    <input type="button" value="Fullscreen" onclick=
    "Module.requestFullscreen(document.getElementById
    ('pointerLock').checked,
            document.getElementById('resize').checked)">
 </div>

canvas 之后,有一个 textarea 元素。这也是不必要的,但最好将其用作从我的 C 代码执行的任何 printf 命令的打印位置。外壳用两个 <hr/> 标签将其包围,用于格式化,因此我们也可以删除这些标签:

 <hr/>
 <textarea class="emscripten" id="output" rows="8"></textarea>
 <hr/>

接下来是我们的 JavaScript。它以三个变量开头,这些变量代表我们之前删除的 HTML 元素,因此我们也需要删除所有这些 JavaScript 变量:

var statusElement = document.getElementById('status');
var progressElement = document.getElementById('progress');
var spinnerElement = document.getElementById('spinner');

JavaScript 中的 Module 对象是 Emscripten 生成的 JavaScript 粘合 代码用来与我们的 WebAssembly 模块交互的接口。这是 shell HTML 文件中最重要的部分,了解它正在做什么是至关重要的。Module 对象以两个数组 preRunpostRun 开始。这些是在模块加载之前和之后运行的函数数组,分别。

var Module = {
 preRun: [],
 postRun: [],

出于演示目的,我们可以像这样向这些数组添加函数:

preRun: [function() {console.log("pre run 1")},
            function() {console.log("pre run 2")}],
postRun: [function() {console.log("post run 1")},
            function() {console.log("post run 2")}],

这将从我们在 Chapter1 中创建的 hello WASM 应用程序产生以下输出,WebAssembly 和 Emscripten 简介

pre run 2
pre run 1
status: Running...
Hello wasm
post run 2
post run 1

请注意,preRunpostRun 函数按照它们在数组中的顺序相反的顺序运行。我们可以使用 postRun 数组来调用一个函数,该函数将初始化我们的 WebAssembly 封装器,但是,出于演示目的,我们将在我们的 C main() 函数中调用 JavaScript 函数。

Module 对象内的下两个函数是 printprintErr 函数。print 函数用于将 printf 调用的输出打印到控制台和我们命名为 outputtextarea 中。您可以将此 output 更改为打印到任何 HTML 标记,但是,如果您的输出是原始 HTML,则必须运行几个已注释掉的文本替换调用。print 函数如下所示:

print: (function() {
    var element = document.getElementById('output');
    if (element) element.value = ''; // clear browser cache
    return function(text) {
        if (arguments.length > 1) text = 
        Array.prototype.slice.call(arguments).join(' ');
        // These replacements are necessary if you render to raw HTML
        //text = text.replace(/&/g, "&amp;");
        //text = text.replace(/</g, "&lt;");
        //text = text.replace(/>/g, "&gt;");
        //text = text.replace('\n', '<br>', 'g');
        console.log(text);
        if (element) {
            element.value += text + "\n";
            element.scrollTop = element.scrollHeight; // focus on 
            bottom
        }
    };
})(),

printErr 函数在粘合代码中运行,当我们的 WebAssembly 模块或粘合代码本身发生错误或警告时。printErr 的输出只在控制台中,尽管原则上,如果你想添加代码来写入 HTML 元素,你也可以这样做。这是 printErr 代码:

printErr: function(text) {
     if (arguments.length > 1) text = 
     Array.prototype.slice.call(arguments).join(' ');
     if (0) { // XXX disabled for safety typeof dump == 'function') {
       dump(text + '\n'); // fast, straight to the real console
     } else {
         console.error(text);
     }
 },

print函数之后,还有一个canvas函数。此函数设置为警告用户丢失了 WebGL 上下文。目前我们不需要该代码,因为我们已经删除了 HTML Canvas。当我们重新添加canvas元素时,我们将需要恢复此函数。更新它以处理丢失上下文事件,而不仅仅是警告用户也是有意义的。

canvas: (function() {
     var canvas = document.getElementById('canvas');
     // As a default initial behavior, pop up an alert when webgl 
        context is lost. To make your
     // application robust, you may want to override this behavior 
        before shipping!
     // See http://www.khronos.org/registry/webgl/specs/latest/1.0/#5.15.2
     canvas.addEventListener("webglcontextlost", function(e) { 
        alert('WebGL context lost. You will need to reload the page.'); 
        e.preventDefault(); }, false);
     return canvas;
 })(),

在您的网页可能丢失其 WebGL 上下文的几种不同情况下。上下文是您进入 GPU 的门户,您的应用程序对 GPU 的访问由浏览器和操作系统共同管理。让我们来到隐喻之地,在那里我们想象 GPU 是一辆公共汽车,Web 浏览器是公共汽车司机,使用其上下文的应用程序是一群吵闹的中学生。如果公共汽车司机(浏览器)觉得孩子们(应用程序)太吵闹,他可以停下公共汽车(GPU),让所有孩子下车(使应用程序失去上下文),然后让他们一个接一个地上车,如果他们答应表现好的话。

之后,最小外壳文件中有一些代码用于跟踪模块的状态和依赖关系。在这段代码中,我们可以删除对spinnerElementprogressElementstatusElement的引用。稍后,如果我们选择,可以用元素替换这些内容,以跟踪加载模块的状态,但目前不需要。以下是最小外壳中的状态和运行依赖监控代码:

setStatus: function(text) {
    if (!Module.setStatus.last) Module.setStatus.last = { time: 
        Date.now(), text: '' };
    if (text === Module.setStatus.last.text) return;
    var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
    var now = Date.now();

    // if this is a progress update, skip it if too soon
    if (m && now - Module.setStatus.last.time < 30) return; 
    Module.setStatus.last.time = now;
    Module.setStatus.last.text = text;
    if (m) {
        text = m[1];
    }
    console.log("status: " + text);
},
totalDependencies: 0,
monitorRunDependencies: function(left) {
  this.totalDependencies = Math.max(this.totalDependencies, left);
    Module.setStatus(left ? 'Preparing... (' + (this.totalDependencies-
                     left) + '/' + this.totalDependencies + ')' : 'All 
                     downloads complete.');
}
};
 Module.setStatus('Downloading...');

JavaScript 代码的最后一部分在最小外壳文件中确定了在浏览器错误发生时 JavaScript 将会做什么:

window.onerror = function() {
    Module.setStatus('Exception thrown, see JavaScript console');
    Module.setStatus = function(text) {
        if (text) Module.printErr('[post-exception status] ' + text);
    };

在我们的 JavaScript 之后,还有一行非常重要的代码:

{{{ SCRIPT }}}

此标记告诉 Emscripten 将 JavaScript 粘合代码的链接放在这里。以下是编译到最终 HTML 文件中的示例:

<script async type="text/javascript" src="img/shell-min.js"></script>

shell-min.js是由 Emscripten 构建的 JavaScript 粘合代码。在下一节中,我们将学习如何创建自己的 HTML 外壳文件。

创建新的 HTML 外壳和 C 文件

在这一部分中,我们将创建一个新的shell.c文件,其中公开了从 JavaScript 调用的几个函数。我们还将使用EM_ASM调用InitWrappers函数,该函数将在我们即将创建的新 HTML 外壳文件中定义。此函数将在 JavaScript 中创建包装器,可以调用 WebAssembly 模块中定义的函数。在创建新的 HTML 外壳文件之前,我们需要创建将由 HTML 外壳内的 JavaScript 包装器调用的 C 代码:

  1. 按照以下方式创建新的shell.c文件:
#include <emscripten.h>
#include <stdlib.h>
#include <stdio.h>

int main() {
    printf("Hello World\n");
    EM_ASM( InitWrappers() );
    printf("Initialization Complete\n");
}

void test() {
    printf("button test\n");
}

void int_test( int num ) {
    printf("int test=%d\n", num);
}

void float_test( float num ) {
    printf("float test=%f\n", num);
}

void string_test( char* str ) {
    printf("string test=%s\n", str);
}

当 WebAssembly 模块加载时,main函数将运行。此时,Module对象可以使用cwrap创建该函数的 JavaScript 版本,我们可以将其绑定到 HTML 元素的onclick事件上。在main函数内部,EM_ASM( InitWrappers() );代码调用了在 HTML 外壳文件中的 JavaScript 中定义的InitWrappers()函数。DOM 使用事件来调用接下来的四个函数。

我们初始化包装器的另一种方式是从Module对象的postRun: []数组中调用InitWrappers()函数。

我们将在 DOM 中将对test()函数的调用与按钮点击绑定。int_test函数将作为一个值从 DOM 中的输入字段传递,并通过使用printf语句将一个消息打印到控制台和textarea元素中,其中包括该整数。float_test函数将作为一个浮点数传递一个数字,并打印到控制台和textarea元素中。string_test函数将打印从 JavaScript 传入的字符串。

现在,我们将在 HTML 外壳文件中添加以下代码,并将其命名为new_shell.html。该代码基于 Emscripten 团队创建的Emscripten 最小外壳文件,并在前一节中进行了解释。我们将整个 HTML 页面分为四个部分呈现。

首先是 HTML 文件的开头和head元素:

<!doctype html>
<html lang="en-us">
<head>
    <meta charset="utf-8">
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>New Emscripten Shell</title>
    <link href="shell.css" rel="stylesheet" type="text/css">
</head>

接下来是body标签的开始。在此之后,我们有几个 HTML input元素以及textarea元素:

<body>
    <div class="input_box">&nbsp;</div>
    <div class="input_box">
        <button id="click_me" class="em_button">Click Me!</button>
    </div>
    <div class="input_box">
        <input type="number" id="int_num" max="9999" min="0" step="1" 
         value="1" class="em_input">
        <button id="int_button" class="em_button">Int Click!</button>
    </div>
    <div class="input_box">
        <input type="number" id="float_num" max="99" min="0" 
          step="0.01" value="0.0" class="em_input">
        <button id="float_button" class="em_button">Float Click!</button>
    </div>
    <div class="input_box">&nbsp;</div>
    <textarea class="em_textarea" id="output" rows="8"></textarea>
    <div id="string_box">
        <button id="string_button" class="em_button">String Click!</button>
        <input id="string_input">
    </div>

在我们的 HTML 之后,我们有script标签的开始,以及我们添加到默认 shell 文件中的一些 JavaScript 代码:


 <script type='text/javascript'>
    function InitWrappers() {
        var test = Module.cwrap('test', 'undefined');
        var int_test = Module.cwrap('int_test', 'undefined', ['int']);
        var float_test = Module.cwrap('float_test', 'undefined', 
                                       ['float']);
        var string_test = Module.cwrap('string_test', 'undefined', 
                                       ['string']);
        document.getElementById("int_button").onclick = function() {

        if( int_test != null ) {
            int_test(document.getElementById('int_num').value);
        }
    }

    document.getElementById("string_button").onclick = function() {
        if( string_test != null ) {
            string_test(document.getElementById('string_input').value);
        }
    }

    document.getElementById("float_button").onclick = function() {
        if( float_test != null ) {
            float_test(document.getElementById('float_num').value);
        }
    }

    document.getElementById("click_me").onclick = function() {
        if( test != null ) {
            test();
        }
    }
 }

function runbefore() {
    console.log("before module load");
}

function runafter() {
    console.log("after module load");
}

接下来是我们从默认 shell 文件中引入的Module对象。在Module对象之后,我们有script标签的结束,{{{ SCRIPT }}}标签,在编译时由 Emscripten 替换,以及我们文件中的结束标签:

var Module = {
    preRun: [runbefore],
    postRun: [runafter],
    print: (function() {
        var element = document.getElementById('output');
        if (element) element.value = ''; // clear browser cache
            return function(text) {
                if (arguments.length > 1) text = 
                   Array.prototype.slice.call(arguments).join(' ');
                /*
                // The printf statement in C is currently writing to a 
                   textarea. If we want to write
                // to an HTML tag, we would need to run these lines of 
                   codes to make our text HTML safe
                text = text.replace(/&/g, "&amp;");
                text = text.replace(/</g, "&lt;");
                text = text.replace(/>/g, "&gt;");
                text = text.replace('\n', '<br>', 'g');
                */
                console.log(text);
                if (element) {
                    element.value += text + "\n";
                    element.scrollTop = element.scrollHeight; 
                     // focus on bottom
                } 
            };
        })(),
        printErr: function(text) {
            if (arguments.length > 1) text = 
                Array.prototype.slice.call(arguments).join(' ');
            if (0) { // XXX disabled for safety typeof dump == 
                       'function') {
                dump(text + '\n'); // fast, straight to the real                     console
            } else {
                console.error(text);
            }
        },
        setStatus: function(text) {
            if (!Module.setStatus.last) Module.setStatus.last = { time: 
                Date.now(), text: '' };
            if (text === Module.setStatus.last.text) return;
            var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
            var now = Date.now();

            // if this is a progress update, skip it if too soon
            if (m && now - Module.setStatus.last.time < 30) return;
            Module.setStatus.last.time = now;
            Module.setStatus.last.text = text;

            if (m) {
                text = m[1];
            }
            console.log("status: " + text);
        },
        totalDependencies: 0,
        monitorRunDependencies: function(left) {
            this.totalDependencies = Math.max(this.totalDependencies,                                               
                                              left);
            Module.setStatus(left ? 'Preparing... (' + 
            (this.totalDependencies-left) + '/' +             
            this.totalDependencies + ')' : 'All downloads complete.');
        }
    };
    Module.setStatus('Downloading...');
    window.onerror = function() {
    Module.setStatus('Exception thrown, see JavaScript console');
    Module.setStatus = function(text) {
        if (text) Module.printErr('[post-exception status] ' + text);
    };
};
</script>
{{{ SCRIPT }}}
</body>
</html>

这前面的四个部分组成了一个名为new_shell.html的单个 shell 文件。您可以通过将最后四个部分输入到一个名为new_shell.html的文件中来创建此代码,或者您可以从我们的 GitHub 页面下载该文件github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly/blob/master/Chapter02/new_shell.html

现在我们已经大块地看完了整个new_shell.html文件,我们可以花一点时间来分解其中的重要部分,并以更细粒度的方式进行讨论。您会注意到我们删除了所有的 CSS 样式代码,并创建了一个新的shell.css文件,并在其中包含了以下行:

<link href="shell.css" rel="stylesheet" type="text/css">

接下来,我们重新设计了这个文件中的 HTML 代码,以创建与 WebAssembly 模块交互的元素。首先,我们将添加一个按钮,该按钮将调用 WebAssembly 模块内的test()函数:

<div class="input_box">
    <button id="click_me" class="em_button">Click Me!</button>
</div>

我们将在我们创建的shell.css文件中对按钮及其包含的div元素进行样式设置。我们需要定义将在稍后编写的 JavaScript 代码中由此button元素的onclick事件调用的函数。我们将在 HTML 中定义的两个输入/按钮对中做类似的事情,如下面的代码块所示:

<div class="input_box">
    <input type="number" id="int_num" max="9999" min="0" step="1" 
     value="1" class="em_input">
    <button id="int_button" class="em_button">Int Click!</button>
</div>
<div class="input_box">
    <input type="number" id="float_num" max="99" min="0" step="0.01" 
     value="0.0" class="em_input">
    <button id="float_button" class="em_button">Float Click!</button>
</div>

就像我们对第一个button元素所做的那样,我们将把接下来的两个按钮与将调用 WebAssembly 模块的函数联系起来。这些函数调用还将把input元素中定义的值传递到 WebAssembly 函数中。我们将textarea元素留作 WebAssembly 模块内的printf调用的输出。我们在 CSS 文件中对其进行了不同的样式设置,但我们将保持功能不变:

<textarea class="em_textarea" id="output" rows="8"></textarea>
<div id="string_box">
    <button id="string_button" class="em_button">String Click!</button>
    <input id="string_input">
</div>

textarea元素下面,我们添加了另一个button和一个string input元素。这个按钮将调用 WebAssembly 模块内的string_test函数,并将string_input元素中的值作为 C char*参数传递给它。

既然我们已经在 HTML 中定义了所有需要的元素,我们将逐步添加一些 JavaScript 代码,以将 JavaScript 和 WebAssembly 模块联系在一起。我们需要做的第一件事是定义InitWrappers函数。InitWrappers将从 C 代码的main函数内部调用:

function InitWrappers() {
    var test = Module.cwrap('test', 'undefined');
    var int_test = Module.cwrap('int_test', 'undefined', ['int']);
    var float_test = Module.cwrap('float_test', 'undefined', 
                                   ['float']);
    var string_test = Module.cwrap('string_test', 'undefined',
                                     ['string']);
    document.getElementById("int_button").onclick = function() {
        if( int_test != null ) {
            int_test(document.getElementById('int_num').value);
        }
    }

    document.getElementById("string_button").onclick = function() {
        if( string_test != null ) {
            string_test(document.getElementById('string_input').value);
        }
    }

    document.getElementById("float_button").onclick = function() {
        if( float_test != null ) {
            float_test(document.getElementById('float_num').value);
        }
    }

    document.getElementById("click_me").onclick = function() {
        if( test != null ) {
            test();
        }
    }
}

此函数使用Module.cwrap来创建围绕 WebAssembly 模块内导出函数的 JavaScript 函数包装器。我们传递给cwrap的第一个参数是我们要包装的 C 函数的名称。所有这些 JavaScript 函数都将返回undefined。JavaScript 没有像 C 那样的void类型,因此当我们在 JavaScript 中声明return类型时,我们需要使用undefined类型。如果函数要返回intfloat,我们需要在这里放置'number'值。传递给cwrap的最后一个参数是一个字符串数组,表示传递给 WebAssembly 模块的参数的 C 类型。

在我们定义了函数的 JavaScript 包装器之后,我们需要从按钮中调用它们。其中一个调用是对 WebAssembly 的int_test函数。以下是我们为int_button设置onclick事件的方式:

document.getElementById("int_button").onclick = function() {
    if( int_test != null ) {
        int_test(document.getElementById('int_num').value);
    }
}

我们要做的第一件事是检查int_test是否已定义。如果是这样,我们调用我们之前解释的int_test包装器,将int_num输入的值传递给它。然后我们对所有其他按钮做类似的事情。

接下来我们要做的是创建一个runbeforerunafter函数,将它们放在Module对象的preRunpostRun数组中:

function runbefore() {
    console.log("before module load");
}
function runafter() {
    console.log("after module load");
}
var Module = {
    preRun: [runbefore],
    postRun: [runafter],

这将导致在模块加载之前在控制台上打印“before module load”,并且在模块加载后打印“after module load”。这些函数不是必需的;它们旨在展示您如何在加载 WebAssembly 模块之前和之后运行代码。如果您不想从 WebAssembly 模块的main函数中调用InitWrappers函数,您可以将该函数放在postRun数组中。

JavaScript 代码的其余部分与 Emscripten 创建的shell_minimal.html文件中的内容类似。我们已删除了对于本演示多余的代码,例如与 HTML5canvas相关的代码,以及与spinnerElementprogressElementstatusElement相关的代码。这并不是说在 JavaScript 中留下这些代码有什么问题,但对于我们的演示来说并不是真正必要的,因此我们已将其删除以减少所需的最小代码。

定义 CSS

现在我们有了一些基本的 HTML,我们需要创建一个新的shell.css文件。没有任何 CSS 样式,我们的页面看起来非常糟糕。

没有样式的页面将类似于以下所示:

图 2.1:没有 CSS 样式的 Hello WebAssembly 应用程序

幸运的是,一点点 CSS 可以让我们的网页看起来很不错。以下是我们正在创建的新shell.css文件的样子:

body {
    margin-top: 20px;
}

.input_box {
    width: 20%;
    display: inline-block;
}
.em_button {
    width: 45%;
    height: 40px;
    background-color: orangered;
    color: white;
    border: 2px solid white;
    font-size: 20px;
    border-radius: 8px;
    transition-duration: 0.5s;
}

.em_button:hover {
    background-color: orange;
    color: white;
    border: 2px solid white;
}

.em_input {
    width: 45%;
    height: 20px;
    font-size: 20px;
    background-color: darkslategray;
    color: white;
    padding: 6px;
}

#output {
    background-color: darkslategray;
    color: white;
    font-size: 16px;
    padding: 10px;
    padding-right: 0;
    margin-left: auto;
    margin-right: auto;
    display: block;
    width: 60%;
}

#string_box {
    padding-top: 10px;
    margin-left: auto;
    margin-right: auto;
    display: block;
    width: 60%;
}

#string_input {
    font-size: 20px;
    background-color: darkslategray;
    color: white;
    padding: 6px;
    margin-left: 5px;
    width: 45%;
    float: right;
}

让我快速浏览一下我们需要做的样式化页面的步骤。这本书不是一本关于 CSS 的书,但简要地介绍一下这个主题也无妨。

  1. 我们要做的第一件事是在页面主体上放置 20 像素的小边距,以在浏览器工具栏和页面内容之间留出一点空间:
body {
    margin-top: 20px;
}
  1. 我们已创建了五个输入框,每个输入框占浏览器宽度的20%。左右两侧的框中都没有内容,因此内容占据了浏览器宽度的 60%。它们以内联块的形式显示,这样它们就可以在屏幕上水平排列。以下是使其发生的 CSS:
.input_box {
    width: 20%;
    display: inline-block;
}
  1. 然后我们有一些类来使用名为em_button的类来样式化我们的按钮:
.em_button {
    width: 45%;
    height: 40px;
    background-color: orangered;
    color: white;
    border: 0px;
    font-size: 20px;
    border-radius: 8px;
    transition-duration: 0.2s;
}

.em_button:hover {
    background-color: orange;
}

我们已将按钮宽度设置为占包含元素的45%。我们将按钮高度设置为 40 像素。我们已将按钮的颜色设置为orangered,文本颜色设置为白色。我们通过将边框宽度设置为 0 像素来移除边框。我们已将字体大小设置为 20 像素,并给它设置了 8 像素的边框半径,这样按钮就呈现出圆角外观。最后一行设置了用户悬停在按钮上时过渡到新颜色所需的时间。

在定义em_button类之后,我们定义了em_button:hover类,当用户悬停在按钮上时,它会改变按钮的颜色。

某些版本的 Safari 需要在em_button类定义内部包含一行-webkit-transition-duration: 0.2s;,才能实现悬停状态的过渡。没有这一行,在某些版本的 Safari 中,按钮会立即从orangered变为orange,而不是在 200 毫秒内过渡。

我们定义的下一个类是用于input元素的:

.em_input {
    width: 45%;
    height: 20px;
    font-size: 20px;
    background-color: darkslategray;
    color: white;
    padding: 6px;
}

我们在开头设置了它的高度宽度字体大小。我们将背景颜色设置为darkslategray,文本为白色。我们添加了6像素的填充,以便在input元素的字体和边缘之间有一小段空间。

在 CSS 元素名称前面的#样式化 ID 而不是类。ID 定义了特定的元素,而类(在 CSS 中以.开头)可以分配给 HTML 中的多个元素。CSS 的下一部分样式化了具有 ID 输出的textarea

#output {
    background-color: darkslategray;
    color: white;
    font-size: 16px;
    padding: 10px;
    margin-left: auto;
    margin-right: auto;
    display: block;
    width: 60%;
}

前两行设置了背景和文本颜色。我们将字体大小设置为16像素,并添加了10像素的填充。接下来的两行使用左右边距将textarea居中:

margin-left: auto;
margin-right: auto;

设置display: block;将此元素放在一行上。将宽度设置为60%使元素占据包含元素的60%,在这种情况下是浏览器的body标记。

最后,我们对string_boxstring_input元素进行了样式设置:

#string_box {
    padding-top: 10px;
    margin-left: auto;
    margin-right: auto;
    display: block;
    width: 60%;
}

#string_input {
    font-size: 20px;
    background-color: darkslategray;
    color: white;
    padding: 6px;
    margin-left: 5px;
    width: 45%;
    float: right;
}

string_box是包含字符串按钮和字符串输入元素的框。我们在框的顶部填充了一些空间,以在其上方的textareastring_box之间添加一些空间。margin-left: automargin-right: auto将框居中。然后,我们使用display:blockwidth: 60%使其占据浏览器的60%

对于string_input元素,我们设置了字体大小和颜色,并在其周围填充了 6 像素。我们设置了左边距为 5 像素,以在元素和其按钮之间留出一些空间。我们将其设置为占包含元素宽度的45%,而float: right样式将元素推到包含元素的右侧。

要构建我们的应用程序,我们需要运行emcc

 emcc shell.c -o shell-test.html --shell-file new_shell.html -s NO_EXIT_RUNTIME=1 -s EXPORTED_FUNCTIONS="['_test', '_string_test', '_int_test', '_float_test', '_main']" -s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap', 'ccall']"

EXPORTED_FUNCTIONS用于定义从 JavaScript 调用的所有函数。它们在前面加上_字符。EXTRA_EXPORTED_RUNTIME_METHODS用于使cwrapccall方法在我们的 shell 文件内部的 JavaScript 中可用。我们目前没有使用ccall,这是cwrap的替代方法,我们将来可能选择使用它。

重要的是要记住,您必须使用 Web 服务器或emrun来运行 WebAssembly 应用程序。如果您想使用emrun运行 WebAssembly 应用程序,您必须使用--emrun标志进行编译。Web 浏览器需要 Web 服务器来流式传输 WebAssembly 模块。如果您尝试直接从硬盘驱动器在浏览器中打开使用 WebAssembly 的 HTML 页面,那么 WebAssembly 模块将无法加载。

现在我们已经添加了一些 CSS 样式,我们的应用程序看起来好多了:

图 2.2:带有 CSS 样式的 Hello WebAssembly 应用程序

在下一节中,我们将讨论 HTML5 网络游戏开发。

HTML5 和游戏开发

大多数 HTML 渲染是通过 HTML 文档对象模型DOM)完成的。DOM 是一种称为保留模式的图形库。保留模式图形保留了一个称为场景图的树。这个场景图跟踪我们模型中的所有图形元素以及如何渲染它们。保留模式图形的好处是它们对开发人员来说很容易管理。图形库完成了所有繁重的工作,并为我们跟踪了对象以及它们的渲染位置。缺点是保留模式系统占用了更多的内存,并且为开发人员提供了更少的控制权。当我们编写 HTML5 游戏时,我们可以使用<IMG> HTML 元素在 DOM 中渲染图像,并使用 JavaScript 或 CSS 动画移动这些元素,直接在 DOM 中操作这些图像的位置。

然而,在大多数情况下,这会使游戏变得非常缓慢。每次我们在 DOM 中移动一个对象时,都会强制浏览器重新计算 DOM 中所有其他对象的位置。因此,通常情况下,通过在 DOM 中操作对象来制作网络游戏通常是行不通的。

即时模式与保留模式

即时模式经常被认为是保留模式的相反,但实际上,当我们为即时模式系统编写代码时,我们可能会在保留模式库的 API 之上构建一些功能。 即时模式迫使开发人员完成保留模式库所做的所有或大部分繁重工作。 我们作为开发人员被迫管理我们的场景图,并了解我们需要渲染的图形对象以及这些对象必须何时以何种方式渲染。 简而言之,这是更多的工作,但如果做得好,游戏将比使用 DOM 渲染更快地渲染。

你可能会问自己:我该如何使用这个 Immediate Mode?进入 HTML5 画布! 2004 年,苹果公司开发了画布元素作为苹果专有浏览器技术的即时模式显示标签。 画布将我们网页的一部分分隔出来,允许我们使用即时模式渲染到该区域。 这将使我们能够在不需要浏览器重新计算 DOM 中所有元素的位置的情况下,渲染到 DOM 的一部分(画布)。 这允许浏览器进一步优化画布的渲染,使用计算机的图形处理单元GPU)。

向 Emscripten 模板添加画布

在本章的较早部分,我们讨论了从 shell 模板调用 Emscripten WebAssembly 应用程序。 现在您知道如何使 JavaScript 和 WebAssembly 之间的交互工作,我们可以将canvas元素添加回模板,并开始使用 WebAssembly 模块操纵该canvas。 我们将创建一个新的.c文件,该文件将调用一个 JavaScript 函数,传递一个xy坐标。 JavaScript 函数将操纵太空船图像,将其移动到canvas周围。 我们还将创建一个名为canvas_shell.html的全新 shell 文件。

与我们为之前版本的 shell 所做的一样,我们将首先将此文件分成四个部分,以便从高层次讨论它。 然后我们将逐一讨论该文件的基本部分。

  1. HTML 文件的开头以开头的HTML标签和head元素开始:
<!doctype html>
<html lang="en-us">
<head>
    <meta charset="utf-8">
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Canvas Shell</title>
    <link href="canvas.css" rel="stylesheet" type="text/css">
</head>
  1. 在那之后,我们有了开头的body标签,并且删除了在此文件的早期版本中存在的许多 HTML 元素:
<body>
    <canvas id="canvas" width="800" height="600" oncontextmenu="event.preventDefault()"></canvas>
    <textarea class="em_textarea" id="output" rows="8"></textarea>
    <img src="img/spaceship.png" id="spaceship">
  1. 接下来是开头的script标签,一些全局 JavaScript 变量和一些我们添加的新函数:
    <script type='text/javascript'>
        var img = null;
        var canvas = null;
        var ctx = null;
        function ShipPosition( ship_x, ship_y ) {
            if( img == null ) {
                return;
            }
            ctx.fillStyle = "black";
            ctx.fillRect(0, 0, 800, 600);
            ctx.save();
            ctx.translate(ship_x, ship_y);
            ctx.drawImage(img, 0, 0, img.width, img.height);
            ctx.restore();
        }
        function ModuleLoaded() {
            img = document.getElementById('spaceship');
            canvas = document.getElementById('canvas');
            ctx = canvas.getContext("2d");
        }
  1. 在新的 JavaScript 函数之后,我们有Module对象的新定义:
        var Module = {
            preRun: [],
            postRun: [ModuleLoaded],
            print: (function() {
                var element = document.getElementById('output');
                if (element) element.value = ''; // clear browser cache
                return function(text) {
                    if (arguments.length > 1) text = 
                    Array.prototype.slice.call(arguments).join(' ');
                        // uncomment block below if you want to write 
                           to an html element
                        /*
                        text = text.replace(/&/g, "&amp;");
                        text = text.replace(/</g, "&lt;");
                        text = text.replace(/>/g, "&gt;");
                        text = text.replace('\n', '<br>', 'g');
                        */
                        console.log(text);
                        if (element) {
                            element.value += text + "\n";
                            element.scrollTop = element.scrollHeight; 
      // focus on bottom
                        }
                    };
                })(),
                printErr: function(text) {
                    if (arguments.length > 1) text = 
                       Array.prototype.slice.call(arguments).join(' ');
                    console.error(text);
                },
                canvas: (function() {
                    var canvas = document.getElementById('canvas');
                    canvas.addEventListener("webglcontextlost", 
                    function(e) { 
                        alert('WebGL context lost. You will need to 
                                reload the page.');
                        e.preventDefault(); }, 
                        false);
                    return canvas;
                })(),
                setStatus: function(text) {
                    if (!Module.setStatus.last) Module.setStatus.last = 
                    { time: Date.now(), text: '' };
                    if (text === Module.setStatus.last.text) return;
                    var m = text.match(/([^(]+)\((\d+
                    (\.\d+)?)\/(\d+)\)/);
                    var now = Date.now();

                    // if this is a progress update, skip it if too        
                       soon
                    if (m && now - Module.setStatus.last.time < 30) 
            return; 
                    Module.setStatus.last.time = now;
                    Module.setStatus.last.text = text;
                    if (m) {
                        text = m[1];
                    }
                    console.log("status: " + text);
                },
                totalDependencies: 0,
                monitorRunDependencies: function(left) {
                    this.totalDependencies = 
                    Math.max(this.totalDependencies, left);
                    Module.setStatus(left ? 'Preparing... (' + 
                    (this.totalDependencies-left) + 
                        '/' + this.totalDependencies + ')' : 'All 
                        downloads complete.');
                }
            };
            Module.setStatus('Downloading...');
            window.onerror = function() {
                Module.setStatus('Exception thrown, see JavaScript 
                                    console');
                Module.setStatus = function(text) {
                    if (text) Module.printErr('[post-exception status] 
                    ' + text);
                };
            };

最后几行关闭了我们的标签,并包括了{{{ SCRIPT }}} Emscripten 标签:

    </script>
{{{ SCRIPT }}}
</body>
</html>

这些前面的四个代码块定义了我们的新canvas_shell.html文件。 如果您想下载该文件,可以在 GitHub 上找到它,地址为:github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly/blob/master/Chapter02/canvas.html

现在我们已经从高层次查看了代码,我们可以更详细地查看源代码。 在 HTML 的head部分,我们正在更改我们链接的titleCSS文件的name。 这是 HTMLhead中的更改:

<title>Canvas Shell</title>
<link href="canvas.css" rel="stylesheet" type="text/css">

我们不需要之前<body>标签中的大多数元素。 我们需要一个canvas,这是我们从 Emscripten 提供的shell_minimal.html文件中删除的,但现在我们需要将其添加回去。 我们保留了最初在最小 shell 中的textarea,并添加了一个新的img标签,其中包含从embed.com网站上的 TypeScript 画布教程中获取的太空船图像,网址为www.embed.com/typescript-games/draw-image.html。 这是body元素中的新 HTML 标签:

<canvas id="canvas" width="800" height="600" oncontextmenu="event.preventDefault()"></canvas>
<textarea class="em_textarea" id="output" rows="8"></textarea>
<img src="img/spaceship.png" id="spaceship">

最后,我们需要更改 JavaScript 代码。我们要做的第一件事是在开头添加三个变量,用于保存对canvas元素、画布上下文和新的飞船img元素的引用:

var img = null;
var canvas = null;
var ctx = null;

接下来我们要添加到 JavaScript 中的是一个函数,用于将飞船图像渲染到给定的xy坐标的画布上:

function ShipPosition( ship_x, ship_y ) {
    if( img == null ) {
        return;
    } 
    ctx.fillStyle = "black";
    ctx.fillRect(0, 0, 800, 600); 
    ctx.save();
    ctx.translate(ship_x, ship_y);
    ctx.drawImage(img, 0, 0, img.width, img.height);
    ctx.restore();
}

该函数首先检查img变量是否为null以外的值。这将让我们知道模块是否已加载,因为img变量最初设置为 null。接下来我们要做的是使用ctx.fillStyle = black``清除画布的黑色,将上下文填充样式设置为颜色black,然后调用ctx.fillRect绘制填充整个画布的黑色矩形。接下来的四行保存了画布上下文,将上下文位置转换为飞船的xy坐标值,然后将飞船图像绘制到画布上。这四行中的最后一行执行上下文恢复,将我们的平移设置回到(0,0)的起始位置。

在定义了这个函数之后,WebAssembly 模块可以调用它。当模块加载时,我们需要设置一些初始化代码来初始化这三个变量。以下是该代码:

function ModuleLoaded() {
    img = document.getElementById('spaceship');
    canvas = document.getElementById('canvas');
    ctx = canvas.getContext("2d");
} 
var Module = {
    preRun: [],
    postRun: [ModuleLoaded],

ModuleLoaded函数使用getElementByIdimgcanvas分别设置为飞船和画布的 HTML 元素。然后我们将调用canvas.getContext(”2d”)来获取 2D 画布上下文,并将ctx变量设置为该上下文。所有这些都在Module对象完成加载时调用,因为我们将ModuleLoaded函数添加到postRun数组中。

我们还在最小的 shell 文件中添加了canvas函数,该函数在之前的教程中已经删除了。该代码监视画布上下文,并在上下文丢失时向用户发出警报。最终,我们希望这段代码能够解决问题,但目前知道发生了什么是很好的。以下是该代码:

canvas: (function() {
    var canvas = document.getElementById('canvas');
    // As a default initial behavior, pop up an alert when webgl 
       context is lost. To make your
    // application robust, you may want to override this behavior 
       before shipping!
    // See http://www.khronos.org/registry/webgl/specs/latest/1.0/#5.15.2
    canvas.addEventListener("webglcontextlost", function(e) { 
        alert('WebGL context lost. You will need to reload the page.'); 
        e.preventDefault(); }, false);
    return canvas;
})(),

为了配合这个新的 HTML shell 文件,我们创建了一个新的canvas.c文件,用于编译成 WebAssembly 模块。请注意,从长远来看,我们将在 JavaScript 中做的事情要少得多,而在 WebAssembly C/C++代码中要多得多。以下是新的canvas.c文件:

#include <emscripten.h>
#include <stdlib.h>
#include <stdio.h>

int ship_x = 0;
int ship_y = 0;

void MoveShip() {
    ship_x += 2;
    ship_y++;

    if( ship_x >= 800 ) {
        ship_x = -128;
    }

    if( ship_y >= 600 ) {
        ship_y = -128;
    }
    EM_ASM( ShipPosition($0, $1), ship_x, ship_y );
}

int main() {
    printf("Begin main\n");
    emscripten_set_main_loop(MoveShip, 0, 0);
    return 1;
}

首先,我们创建一个ship_xship_y变量来跟踪飞船的xy坐标。之后,我们创建一个MoveShip函数。每次调用该函数时,该函数将飞船的x位置增加2,飞船的y位置增加1。它还检查飞船的 x 坐标是否离开了画布的右侧,如果是,则将其移回左侧,如果飞船已经移出画布底部,则执行类似的操作。该函数的最后一步是调用我们的 JavaScriptShipPosition函数,传递飞船的xy坐标。这最后一步将在 HTML5 画布元素上以新坐标绘制我们的飞船。

在我们的main函数的新版本中,有以下一行:

emscripten_set_main_loop(MoveShip, 0, 0);

这行将作为第一个参数传递的函数转换为游戏循环。我们将在后面的章节中详细介绍emscripten_set_main_loop的工作原理,但目前只需知道这会导致每次渲染新帧时调用MoveShip函数。

最后,我们将创建一个新的canvas.css文件,其中包含body#output CSS 的代码,并添加一个新的#canvas CSS 类。以下是canvas.css文件的内容:

body {
    margin-top: 20px;
}

#output {
    background-color: darkslategray;
    color: white;
    font-size: 16px;
    padding: 10px;
    margin-left: auto;
    margin-right: auto;
    display: block;
    width: 60%;
}

#canvas {
    width: 800px;
    height: 600px;
    margin-left: auto;
    margin-right: auto;
    display: block;
}

一切完成后,我们将使用emcc编译新的canvas.html文件,以及canvas.wasmcanvas.js的粘合代码。以下是对emcc的调用示例:

emcc canvas.c -o canvas.html --shell-file canvas_shell.html

emcc之后,我们传入.c文件的名称canvas.c,这将用于编译我们的 WASM 模块。-o标志告诉我们的编译器下一个参数将是输出。使用扩展名为.html的输出文件告诉emcc编译 WASM、JavaScript 和 HTML 文件。接下来传入的标志是--shell-file,告诉emcc后面的参数是 HTML 外壳文件的名称,这将用于创建我们最终输出的 HTML 文件。

重要的是要记住,您必须使用 Web 服务器或emrun来运行 WebAssembly 应用程序。如果您想使用emrun运行 WebAssembly 应用程序,您必须使用--emrun标志进行编译。Web 浏览器需要一个 Web 服务器来流式传输 WebAssembly 模块。如果您尝试直接从硬盘驱动器在浏览器中打开使用 WebAssembly 的 HTML 页面,那么 WebAssembly 模块将无法加载。

以下是canvas.html的屏幕截图:

图 2.3:我们的第一个 WebAssembly HTML5 画布应用程序

摘要

在本章中,我们讨论了 Emscripten 最小外壳 HTML 文件,它的各个组件以及它们的工作原理。我们还写了关于文件的哪些部分可以不用,如果我们不使用我们的外壳来生成画布代码。您了解了Module对象,以及它是使用 JavaScript 粘合代码将我们的 HTML 中的 JavaScript 和我们的 WebAssembly 联系在一起的接口。然后,我们创建了一个包含我们导出的函数的新的 WebAssembly 模块,以允许 JavaScript 使用Module.cwrap来创建我们可以从 DOM 中调用的 JavaScript 函数,从而执行我们的 WebAssembly 函数。

我们创建了一个全新的 HTML 外壳文件,使用了 Emscripten 最小外壳的一些Module代码,但几乎完全重写了原始外壳的 HTML 和 CSS。然后,我们能够将新的 C 代码和 HTML 外壳文件编译成一个能够从 JavaScript 调用 WebAssembly 函数,并且能够从 WebAssembly 调用 JavaScript 函数的工作 WebAssembly 应用程序。

我们讨论了使用 HTML5 画布元素的好处,以及即时模式和保留模式图形之间的区别。我们还解释了为什么对于游戏和其他图形密集型任务来说,使用即时模式而不是保留模式是有意义的。

然后,我们创建了一个外壳文件来利用 HTML5 画布元素。我们添加了 JavaScript 代码来将图像绘制到画布上,并编写了使用 WebAssembly 在每帧修改画布上图像位置的 C 代码,从而在 HTML5 画布上创建出移动的太空飞船的外观。

在下一章中,我们将向您介绍 WebGL,它是什么,以及它如何改进 Web 上的图形渲染。

第三章:WebGL 简介

在苹果创建 Canvas 元素之后,Mozilla 基金会于 2006 年开始研究 Canvas 3D 原型,并在 2007 年实现了这个早期版本,最终成为 WebGL。2009 年,一个名为 Kronos Group 的财团成立了一个 WebGL 工作组。到 2011 年,该组织已经制定了基于 OpenGL ES 2.0 API 的 WebGL 1.0 版本。

正如我之前所说,WebGL 被视为与 HTML5 Canvas 元素一起使用的 3D 渲染 API。它的实现消除了传统 2D 画布 API 的一些渲染瓶颈,并几乎直接访问计算机的 GPU。因此,使用 WebGL 将 2D 图像渲染到 HTML5 画布通常比使用原始 2D 画布实现更快。然而,由于增加了三维渲染的复杂性,使用 WebGL 要复杂得多。因此,有几个库是建立在 WebGL 之上的。这允许用户使用 WebGL,但使用简化的 2D API。如果我们在传统的 JavaScript 中编写游戏,我们可能会使用像 Pixi.js 或 Cocos2d-x 这样的库来简化我们的代码,以便在 WebGL 上进行 2D 渲染。现在,WebAssembly 使用Simple DirectMedia LayerSDL)的实现,这是大多数开发人员用来编写游戏的库。这个 WebAssembly 版本的 SDL 是建立在 WebGL 之上的,并提供高端性能,但使用起来更容易。

使用 SDL 并不妨碍您直接从编译为 WebAssembly 的 C++代码中直接使用 WebGL。有时,我们可能对直接与 WebGL 进行交互感兴趣,因为我们感兴趣的功能在 SDL 内部并不直接可用。这些用例的一个例子是创建允许特殊 2D 光照效果的自定义着色器。

在本章中,您需要从 GitHub 项目中获取图像文件来运行示例。该应用程序需要项目目录中的/Chapter03/spaceship.png图像文件。请从以下网址下载项目:github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly

在本章中,我们将涵盖以下主题:

  • WebGL 和画布上下文

  • WebGL 着色器简介

  • WebGL 和 JavaScript

WebGL 和画布上下文

WebGL 是用于绘制 HTML5 元素的渲染上下文,是 2D 渲染上下文的替代品。通常,当有人提到画布时,他们指的是 2D 渲染上下文,通过调用getContext并传入字符串2d来访问。这两个上下文都是用于绘制到 HTML5 画布元素的方法。上下文是一种用于即时模式渲染的 API 类型。可以请求两种不同的 WebGL 上下文,两者都提供对不同版本的 WebGL API 的访问。这些上下文是webglwebgl2。在接下来的示例中,我将使用webgl上下文,并将使用 WebGL 1.0 API。还有一个很少使用的上下文,用于将位图渲染到画布上,我们可以通过传入bitmaprenderer作为字符串值来访问。

我想指出,术语画布有时用于指代 2D 画布上下文,有时用于指代即时模式渲染的 HTML5 画布元素。当我在本书中提到画布而没有提到 2D 上下文时,我指的是 HTML5 画布元素。

在下一节中,我将向您介绍着色器和 GLSL 着色器语言。

WebGL 着色器简介

当 OpenGL 或 WebGL 与 GPU 交互时,它们传递数据告诉 GPU 需要渲染的几何图形和纹理。此时,GPU 需要知道如何将这些纹理和与之相关的几何图形渲染成一个在计算机显示器上显示的单个 2D 图像。OpenGL 着色器语言GLSL)是一种用于指导 GPU 如何渲染 2D 图像的语言,它与 OpenGL 和 WebGL 一起使用。

从技术上讲,WebGL 使用 GLSL ES 着色器语言(有时称为 ELSL),它是 GLSL 语言的一个子集。GLSL ES 是与 OpenGL ES 一起使用的着色器语言,OpenGL ES 是 OpenGL 的一个移动友好子集(ES 代表嵌入式系统)。因为 WebGL 基于 OpenGL ES,它继承了 GLSL ES 着色器语言。请注意,每当我在 WebGL 或 WebAssembly 的上下文中提到 GLSL 时,我指的是 GLSL ES。

WebGL 渲染管道要求我们编写两种类型的着色器来将图像渲染到屏幕上。这些是顶点着色器,它以每个顶点为基础渲染几何图形,以及片段着色器,它渲染像素候选,称为片段。GLSL 看起来很像 C 语言,所以如果你在 C 或 C++中工作,代码会看起来有些熟悉。

这个 GLSL 着色器的介绍不会详细讨论。在后面的章节中,我将更详细地讨论 WebGL 着色器。现在,我只想介绍这个概念,并向你展示一个非常简单的 2D WebGL 着色器。在关于 2D 光照的章节中,我将更详细地讨论这个问题。这是一个用于渲染 2D WebGL 渲染引擎中四边形的简单顶点着色器的示例:

precision mediump float;

attribute vec4 a_position;
attribute vec2 a_texcoord;

uniform vec4 u_translate;

varying vec2 v_texcoord;

void main() {
   gl_Position = u_translate + a_position;
    v_texcoord = a_texcoord;
}

这个非常简单的着色器接收顶点的位置,并根据通过 WebGL 传递到着色器中的位置统一值移动它。这个着色器将在我们的几何图形中的每个顶点上运行。在 2D 游戏中,所有几何图形都将被渲染为四边形。以这种方式使用 WebGL 可以更好地利用计算机的 GPU。让我简要地讨论一下这个顶点着色器代码中发生了什么。

如果你是游戏开发的新手,顶点着色器和像素着色器的概念可能会感到有些陌生。它们并不像一开始看起来那么神秘。如果你想更好地理解着色器是什么,你可能想快速阅读一下维基百科的着色器文章(en.wikipedia.org/wiki/Shader)。如果你仍然感到迷茫,可以随时在 Twitter 上问我问题(@battagline)。

这个着色器的第一行设置了浮点精度:

precision mediump float;

计算机上的所有浮点运算都是对实数分数的近似。我们可以用 0.333 来低精度地近似 1/3,用 0.33333333 来高精度地近似。代码中的精度行表示 GPU 上浮点值的精度。我们可以使用三种可能的精度:highpmediumplowp。浮点精度越高,GPU 执行代码的速度就越慢,但所有计算值的精度就越高。一般来说,我将这个值保持在mediump,这对我来说效果很好。如果你有一个需要性能而不是精度的应用程序,你可以将其更改为lowp。如果你需要高精度,请确保你了解目标 GPU 的能力。并非所有 GPU 都支持highp

属性变量是与顶点数组一起传递到管道中的值。在我们的代码中,这些值包括与顶点相关的纹理坐标,以及与顶点相关的 2D 平移矩阵:

attribute vec4 a_position;
attribute vec2 a_texcoord;

uniform 变量类型是一种在所有顶点和片段中保持恒定的变量类型。在这个顶点着色器中,我们传入一个 uniform 向量u_translate。通常情况下,除非是为了相机,您不会希望将所有顶点平移相同的量,但因为我们只是编写一个用于绘制单个精灵的 WebGL 程序,所以使用uniform变量来进行translate将是可以的:

uniform vec4 u_translate;

varying变量(有时被称为插值器)是从顶点着色器传递到片段着色器的值,片段着色器中的每个片段都会得到该值的插值版本。在这段代码中,唯一的varying变量是顶点的纹理坐标:

varying vec2 v_texcoord;

在数学中,插值值是计算出的中间值。例如,如果我们在 0.2 和 1.2 之间进行插值,我们将得到一个值为 0.7。也就是说,0.2 的起始值,加上(1.2-0.2)/2 的平均值=0.5。所以,0.2+0.5=0.7。使用varying关键字从顶点着色器传递到片段着色器的值将根据片段相对于顶点的位置进行插值。

最后,在顶点着色器中执行的代码位于main函数内。该代码获取顶点的位置,并将其乘以平移矩阵以获得顶点的世界坐标,以便将其放入gl_Position中。然后,它将直接将传递到顶点着色器的纹理坐标设置为插值变量,以便将其传递到片段着色器中:

void main() {
    gl_Position = u_translate + a_position;
    v_texcoord = a_texcoord;
}

顶点着色器运行后,顶点着色器生成的所有片段都会通过片段着色器运行,片段着色器会为每个片段插值所有的varying变量。

这是一个片段着色器的简单示例:

precision mediump float;

varying vec2 v_texcoord;

uniform sampler2D u_texture;

void main() {
    gl_FragColor = texture2D(u_texture, v_texcoord);
}

就像在我们的顶点着色器中一样,我们首先将浮点精度设置为mediump。片段有一个uniform sample2D纹理,定义了用于在我们的游戏中生成 2D 精灵的纹理映射:

uniform sampler2D u_texture;

uniform有点像是传递到管道中并应用于着色器中使用它的每个顶点或每个片段的全局变量。main函数中执行的代码也很简单。它获取从v_texcoord变量中插值的纹理坐标,并从我们采样的纹理中检索颜色值,然后使用该值设置gl_FragColor片段的颜色:

void main() {
    gl_FragColor = texture2D(u_texture, v_texcoord);
}

直接在 JavaScript 中使用 WebGL 将一个简单的 2D 图像绘制到屏幕上需要更多的代码。在下一节中,我们将编写我能想到的最简单版本的 2D 精灵渲染 WebGL 应用程序,这恰好是我们在上一章中编写的 2D 画布应用程序的新版本。我认为值得看到两种方法在 HTML 画布上渲染 2D 图像之间的区别。了解更多关于 WebGL 的知识也将有助于我们理解当我们最终在 WebAssembly 中使用 SDL API 时发生了什么。在创建 WebGL JavaScript 应用程序时,我会尽量保持演示和代码的简单。

正如我之前提到的,本章的目的是让您亲身体验 WebGL。在本书的大部分内容中,我们不会直接处理 WebGL,而是使用更简单的 SDL API。如果您对编写自己的着色器不感兴趣,您可以将本章视为可选但有益的信息。

在下一节中,我们将学习如何使用 WebGL 绘制到画布上。

WebGL 和 JavaScript

正如我们在上一章中学到的,使用 2D 画布非常简单。要绘制图像,你只需要将上下文转换为要绘制图像的像素坐标,并调用drawImage上下文函数,传入图像、宽度和高度。如果你愿意,你甚至可以更简单地忘记转换,直接将 x 和 y 坐标传递到drawImage函数中。在 2D 画布中,你在使用图像,但在 WebGL 中,即使在编写 2D 游戏时,你总是在使用 3D 几何。在 WebGL 中,你需要将纹理渲染到几何体上。你需要使用顶点缓冲区和纹理坐标。我们之前编写的顶点着色器接收 3D 坐标数据和纹理坐标,并将这些值传递到片段着色器,后者将在几何体之间进行插值,并使用纹理采样函数来检索正确的纹理数据,以将像素渲染到画布上。

WebGL 坐标系统与 2D 画布

使用 WebGL,画布元素的中心是原点(0,0)。正 Y向上,而正 X向右。对于从未使用过 2D 图形的人来说,这更直观一些,因为它类似于我们在小学学到的坐标几何中的象限。在 2D 画布中,你总是在使用像素,并且画布上不会出现负数。

当你调用drawImage时,X 和 Y 坐标是图像的左上角绘制的位置。WebGL 有点不同。一切都使用几何,需要顶点着色器和像素着色器。我们将图像转换为纹理,然后将其拉伸到几何上,以便显示。这是 WebGL 坐标系统的样子:

如果你想在画布上的特定像素位置放置图像,你需要知道画布的宽度和高度。你的画布的中心点(0,0)左上角(-1, 1)右下角(1, -1)。因此,如果你想在 x=150,y=160 处放置图像,你需要使用以下方程来找到 WebGL 的 x 坐标:

 webgl_x = (pixel_x - canvas_width / 2) / (canvas_width / 2)

因此,对于pixel_x位置为 150,我们需要从 150 减去 400 得到-250。然后,我们需要将-250 除以 400,我们会得到-0.625。我们需要做类似的事情来获取 WebGL 的 y 坐标,但是轴的符号是相反的,所以我们需要做以下操作来获取pixel_x值,而不是我们之前做的:

((canvas_height / 2) - pixel_y) / (canvas_height / 2)

通过插入值,我们得到((600 / 2) - 160) / (600 / 2) 或 (300 - 160) / 300 = 0.47。

我跳过了很多关于 WebGL 的信息,以简化这个解释。WebGL 不是一个 2D 空间,即使在这个例子中我把它当作一个 2D 空间。因为它是一个 3D 空间,单位中画布的大小是基于一个称为裁剪空间的视图区域。如果你想了解更多,Mozilla 有一篇关于裁剪空间的优秀文章:developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_model_view_projection

顶点和 UV 数据

在我们看一大段可怕的 WebGL JavaScript 代码之前,我想简要讨论数据缓冲区以及我们将如何将几何和纹理坐标数据传递到着色器中。我们将在一个大缓冲区中传递 32 位浮点数据,该缓冲区将包含顶点的 X 和 Y 坐标的组合以及该顶点的 UV 纹理坐标。UV 映射是 GPU 将 2D 纹理坐标映射到 3D 几何的方法:

WebGL 和 OpenGL 通过为每个顶点分配 U 和 V 坐标来实现这一点。分配给顶点的 UV 坐标(0,0)意味着该顶点的颜色将基于纹理左上角的颜色。UV 坐标(1,1)意味着它将根据纹理右下角的颜色进行着色。当我们在 3D 对象的点之间进行插值时,我们还在纹理内部的不同 UV 坐标之间进行插值。这些 UV 坐标可以在我们的片段着色器中使用texture2D内置函数进行采样,通过传入纹理和当前 UV 坐标。

让我们来看看我们在这个 WebGL 应用程序中使用的顶点和纹理数据数组:

var vertex_texture_data = new Float32Array([
 //  X,     Y,     U,   V
     0.16,  0.213, 1.0, 1.0,
    -0.16,  0.213, 0.0, 1.0,
     0.16, -0.213, 1.0, 0.0,
    -0.16, -0.213, 0.0, 0.0,
    -0.16,  0.213, 0.0, 1.0,
     0.16, -0.213, 1.0, 0.0
 ]);

这些数据已经按行和列输入。尽管这是一组线性数据,但格式允许您看到我们将为每个顶点传递四个浮点值。数据上方有一条注释,显示每列代表什么。前两个数据值是几何图形的 X 和 Y 坐标。接下来的两个值是将纹理映射到几何图形的 X 和 Y 坐标的 U 和 V 坐标。这里有六行,尽管我们正在渲染一个矩形。我们需要六个点而不是四个的原因是,WebGL 通常使用三角形组成的几何图形。因此,我们需要重复两个顶点。

也许你会想,为什么是三角形?嗯,曾经有一段时间,计算机图形使用的几何图形并不是分解成三角形的。但是当你有一个四边形,而不是所有的点都共面(在同一个平面上)时就会出现问题。这与我去使用四条腿凳子的酒吧时遇到的问题是一样的。我很确定四条腿凳子的存在是某种秘密组织的阴谋,目的是让我失去平衡,但我岔开了话题。因为三个点定义一个平面,所以三角形根据定义总是共面的,就像一个三条腿的凳子永远不会摇摆一样。

2D 画布到 WebGL

让我们从Chapter02目录中复制出画布代码到Chapter03目录中。接下来,我们将把canvas_shell.html文件重命名为webgl_shell.html。我们将把canvas.css重命名为webgl.css。最后,我们将把canvas.c文件重命名为webgl.c。我们还需要确保复制spaceship.png文件。我们不会对webgl.css文件进行任何更改。我们将对webgl_shell.html文件进行最重要的更改。有很多代码需要添加,以完成从 2D 画布到 WebGL 的切换;几乎所有的代码都是额外的 JavaScript 代码。我们需要对webgl.c进行一些微小的调整,以使MoveShip函数中飞船的位置反映出带有原点在画布中心的 WebGL 坐标系统。

在我们开始之前,我想提一下,这个 WebGL 代码并不是为了投入生产。我们将要创建的游戏不会像我在这里演示的方式使用 WebGL。那不是最有效或可扩展的代码。我们所编写的代码将无法在没有重大更改的情况下一次渲染多个精灵。我之所以向你演示使用 WebGL 渲染 2D 图像的过程,是为了让你了解在使用类似 SDL 这样的库时发生了什么。如果你不在乎幕后的工作原理,那么跳过也没人会责怪你。就我个人而言,我总是更愿意多了解一点。

对 head 标签进行微小调整

在我们的head标签内,我们需要改变title,因为我们将canvas.css重命名为webgl.css,所以我们需要将我们的link标签指向新的样式表名称。以下是在 HTML 开头必须更改的唯一两个标签:

<title>WebGL Shell</title>
<link href="webgl.css" rel="stylesheet" type="text/css">

稍后在 HTML 中,我们将删除img标签,其中src设置为"spaceship.png"。这并不是必须的。在画布版本中,我们使用此标签将图像呈现到画布上。在这个 WebGL 版本中,我们将动态加载图像,因此没有必要保留它,但如果您忘记删除它,将不会以任何方式损害应用程序。

主要 JavaScript 更改

webgl_shell.html文件中 JavaScript 部分内的Module代码将保持不变,因此您无需担心在以下行之后修改任何内容:

var Module = {

但是,script标签中代码的前半部分将需要进行一些重大修改。您可能希望重新开始并删除整个模块。

WebGL 全局变量

我们要做的第一件事是创建许多 JavaScript 全局变量。如果此代码不仅仅是用于演示,使用这么多全局变量通常是不受欢迎的,被认为是不良实践。但就我们现在所做的事情而言,它有助于简化事情:

<script type='text/javascript'>
 var gl = null; // WebGLRenderingContext
 var program = null; // WebGLProgram
 var texture = null; // WebGLTexture
 var img = null; // HTMLImageElement
 var canvas = null;
 var image_width = 0;
 var image_height = 0;
 var vertex_texture_buffer = null; // WebGLBuffer
 var a_texcoord_location = null; // GLint
 var a_position_location = null; // GLint
 var u_translate_location = null; // WebGLUniformLocation
 var u_texture_location = null; // WebGLUniformLocation

第一个变量gl是渲染上下文的新版本。通常,如果您使用 2D 渲染上下文,您称之为ctx,如果您使用 WebGL 渲染上下文,您将其命名为gl。第二行定义了program变量。当我们编译顶点和片段着色器时,我们会得到一个编译后的版本,以WebGLProgram对象的形式存储在program变量中。texture变量将保存我们将从spaceship.png图像文件加载的WebGLTexture。这是我们在上一章中用于 2D 画布教程的图像。img变量将用于加载将用于加载纹理的spaceship.png图像文件。canvas变量将再次是对我们的 HTML 画布元素的引用,image_widthimage_height将在加载后保存spaceship.png图像的高度和宽度。

vertex_texture_buffer属性是一个缓冲区,将用于将顶点几何和纹理数据传输到 GPU,以便我们在上一节中编写的着色器可以使用它。a_texcoord_locationa_position_location变量将用于保存对顶点着色器中a_texcoorda_position属性变量的引用,最后,u_translate_locationu_texture_location用于引用着色器中的u_translateu_texture统一变量。

返回顶点和纹理数据

如果我告诉你我们还有一些变量要讨论,你会不会不高兴?好吧,下一个变量是我们之前讨论过的变量,但我会再次提到它,因为它很重要。vertex_texture_data数组是一个存储用于渲染的所有顶点几何和 UV 纹理坐标数据的数组:

var vertex_texture_data = new Float32Array([
     // x,  y,     u,   v
     0.16,  0.213, 1.0, 1.0,
    -0.16,  0.213, 0.0, 1.0,
     0.16, -0.213, 1.0, 0.0,
    -0.16, -0.213, 0.0, 0.0,
    -0.16,  0.213, 0.0, 1.0,
     0.16, -0.213, 1.0, 0.0
 ]);

我之前没有提到的一件事是,为什么xy值在 x 轴上的范围是-0.160.16,在 y 轴上的范围是-0.2130.213。因为我们正在渲染一张单独的图像,我们不需要动态地缩放几何图形以适应图像。我们正在使用的太空船图像是 128 x 128 像素。我们使用的画布大小是 800 x 600 像素。正如我们之前讨论的,无论我们为画布使用什么大小,WebGL 都会将两个轴都适应到-1 到+1 的范围内。这使得坐标(0, 0)成为画布元素的中心。这也意味着画布的宽度始终为 2,高度始终为 2,无论画布元素有多少像素宽或高。因此,如果我们想要计算出我们的几何图形有多宽,以使其与图像的宽度匹配,我们需要进行一些计算。首先,我们需要弄清楚 WebGL 剪辑空间宽度的一个单位对应于一个像素的宽度。WebGL 剪辑空间的宽度为 2.0,实际画布的宽度为 800 像素,因此在 WebGL 空间中一个像素的宽度为 2.0 / 800 = 0.0025。我们需要知道我们的图像在 WebGL 剪辑空间中有多宽,因此我们将 128 像素乘以 0.0025,得到 WebGL 剪辑空间宽度为 0.32。因为我们希望我们的几何图形的 x 值在中心为 0,我们的 x 几何范围从-0.16 到+0.16。

现在我们已经完成了宽度,让我们来解决高度。画布的高度为 600 像素,但在 WebGL 剪辑空间中,画布的高度始终为 2.0(-1.0 Y 到+1.0 Y)。因此,一个像素中有多少个 WebGL 单位?2.0 / 600 = 0.00333333…重复。显然,这是一个浮点精度无法匹配实际值的情况。我们将截掉一些尾随的 3,并希望精度足够。回到计算图像在 WebGL 剪辑空间中的高度,它高 128 像素,所以我们需要将 128 乘以 0.0033333…重复。结果是 0.4266666…重复,我们将截断为 0.426。因此,我们的 y 几何必须从-0.213+0.213

我正在尽力忽略 WebGL 剪辑空间的复杂性。这是一个 3D 体积,而不是像 2D 画布上下文那样简单的 2D 绘图区域。有关此主题的更多信息,请参阅 Mozilla 开发人员文档的剪辑空间部分:developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_model_view_projection#Clip_space

正如我之前所说的,当我们开发游戏时,SDL 会为我们处理很多事情,但是在将来,您可能希望在 WebAssembly 中使用 OpenGL。OpenGL ES 2.0 和 OpenGL ES 3.0 库已经移植到 WebAssembly,并且这些库或多或少地与 WebGL 具有直接的类比。WebGL 1.0 是 OpenGL ES 2.0 的修改版本,它是设计用于在移动硬件上运行的 OpenGL 的一个版本。WebGL 2.0 是 OpenGL ES 3.0 的修改版本。通过对 SDL 的调用理解 WebGL 正在做什么,可以使我们成为更好的游戏开发人员,即使 SDL 为我们做了很多繁重的工作。

缓冲区常量

我选择使用一个单独的Float32Array来保存此应用程序的所有顶点数据。这包括 X 和 Y 坐标数据,以及 U 和 V 纹理坐标数据。因此,当我们将这些数据加载到 GPU 的缓冲区中时,我们需要告诉 WebGL 如何将这些数据分开成不同的属性。我们将使用以下常量来告诉 WebGLFloat32Array中的数据是如何分解的:

const FLOAT32_BYTE_SIZE = 4; // size of a 32-bit float
const STRIDE = FLOAT32_BYTE_SIZE * 4; // there are 4 elements for every vertex. x, y, u, v
const XY_OFFSET = FLOAT32_BYTE_SIZE * 0;
const UV_OFFSET = FLOAT32_BYTE_SIZE * 2;

FLOAT32_BYTE_SIZE常量是Float32Array中每个变量的大小。STRIDE常量将用于告诉 WebGL 单个顶点数据使用了多少字节。我们在前面的代码中定义的四列代表xyuv。由于这些变量中的每一个使用了四个字节的数据,我们将变量的数量乘以每个变量使用的字节数来得到stride,或者单个顶点使用的字节数。XY_OFFSET常量是每个 stride 内的起始位置,我们将在那里找到xy坐标数据。为了保持一致,我将浮点字节大小乘以位置,但由于它是0,我们可以直接使用const XY_OFFSET = 0。现在,UV_OFFSET是从每个 stride 开始的偏移量,我们将在那里找到 UV 纹理坐标数据。由于它们在位置 2 和 3,偏移量是每个变量使用的字节数乘以2

定义着色器

我在前一节中详细介绍了着色器所做的一切。你可能想再次浏览一下那一节作为复习。代码的下一部分定义了多行 JavaScript 字符串中的顶点着色器代码和片段着色器代码。以下是顶点着色器代码:

var vertex_shader_code = `
    precision mediump float;
    attribute vec4 a_position;
    attribute vec2 a_texcoord;
    varying vec2 v_texcoord;
    uniform vec4 u_translate;

    void main() {
        gl_Position = u_translate + a_position;
        v_texcoord = a_texcoord;
    }
`;

片段着色器代码如下:

var fragment_shader_code = `
    precision mediump float;
    varying vec2 v_texcoord;
    uniform sampler2D u_texture;

    void main() {
        gl_FragColor = texture2D(u_texture, v_texcoord);
    }
`;

让我们来看看顶点着色器代码中的属性:

attribute vec4 a_position;
attribute vec2 a_texcoord;

这两个属性将从Float32Array中的数据中传递。在 WebGL 中的一个很棒的技巧是,如果你没有使用所有四个位置变量(xyzw),你可以传递你正在使用的两个(xy),GPU 将知道如何在其他两个位置使用适当的值。这些着色器将需要传递两个属性:

attribute vec4 a_position;
attribute vec2 a_texcoord;

我们将再次使用缓冲区和Float32Array来完成这个任务。我们还需要传递两个uniform变量。u_translate变量将被顶点着色器用于平移精灵的位置,u_texture是片段着色器将使用的纹理缓冲区。这些着色器几乎是尽可能简单的。许多教程都是从没有纹理开始,只是硬编码片段着色器的颜色输出,就像这样:

gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);

做出这个改变将导致片段着色器始终输出红色,所以请不要做这个改变。我能想到的唯一让这个教程更简单的事情是不加载纹理并渲染纯色,以及不允许几何体被移动。

ModuleLoaded函数

在旧的 2D 画布代码中,我们在ModuleLoaded函数之前定义了ShipPosition JavaScript 函数,但是我们已经将这两个函数互换了。我觉得在渲染部分之前解释 WebGL 初始化会更好。以下是ModuleLoaded函数的新版本:

function ModuleLoaded() {
    canvas = document.getElementById('canvas');
    gl = canvas.getContext("webgl", { alpha: false }) ||
                            canvas.getContext("experimental-webgl", { 
                            alpha: false });

    if (!gl) {
        console.log("No WebGL support!");
        return;
    }

    gl.blendFunc( gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA );
    gl.enable( gl.BLEND );

    var vertex_shader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource( vertex_shader, vertex_shader_code );
    gl.compileShader( vertex_shader );

    if( !gl.getShaderParameter(vertex_shader, gl.COMPILE_STATUS) ) {
        console.log('Failed to compile vertex shader' + 
                     gl.getShaderInfoLog(vertex_shader));
        gl.deleteShader(vertex_shader);
        return;
    }

    var fragment_shader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource( fragment_shader, fragment_shader_code );
    gl.compileShader( fragment_shader );

    if( !gl.getShaderParameter(fragment_shader, gl.COMPILE_STATUS) ) {
        console.log('Failed to compile fragment shader' + 
                     gl.getShaderInfoLog(fragment_shader));
        gl.deleteShader(fragment_shader);
        return;
    }

    program = gl.createProgram();

    gl.attachShader(program, vertex_shader);
    gl.attachShader(program, fragment_shader);
    gl.linkProgram(program);

    if( !gl.getProgramParameter(program, gl.LINK_STATUS) ) {
        console.log('Failed to link program');
        gl.deleteProgram(program);
        return;
    }

    gl.useProgram(program);

    u_texture_location = gl.getUniformLocation(program, "u_texture");
    u_translate_location = gl.getUniformLocation(program, 
    "u_translate");

    a_position_location = gl.getAttribLocation(program, "a_position");
    a_texcoord_location = gl.getAttribLocation(program, "a_texcoord");

    vertex_texture_buffer = gl.createBuffer();

    gl.bindBuffer(gl.ARRAY_BUFFER, vertex_texture_buffer);
    gl.bufferData(gl.ARRAY_BUFFER, vertex_texture_data, 
    gl.STATIC_DRAW);

    gl.enableVertexAttribArray(a_position_location);
    gl.vertexAttribPointer(a_position_location, 2, gl.FLOAT, false, 
    STRIDE, XY_OFFSET);

    gl.enableVertexAttribArray(a_texcoord_location);
    gl.vertexAttribPointer(a_texcoord_location, 2, gl.FLOAT, false, 
    STRIDE, UV_OFFSET);

    texture = gl.createTexture();

    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);

    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);

    img = new Image();
    img.addEventListener('load', function() {
        image_width = img.width;
        image_height = img.height;

        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,
        gl.UNSIGNED_BYTE, img );
    });
    img.src = "spaceship.png";

    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
}

前几行获取了canvas元素,并使用它来获取 WebGL 上下文。如果 JavaScript 未能获取 WebGL 上下文,我们会警告用户,让他们知道他们的浏览器不支持 WebGL:

canvas = document.getElementById('canvas');

gl = canvas.getContext("webgl", { alpha: false }) ||
                        canvas.getContext("experimental-webgl", { 
                        alpha: false });
if (!gl) {
    console.log("No WebGL support!");
    return;
}

接下来的两行打开了 alpha 混合:

gl.blendFunc( gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA );
gl.enable( gl.BLEND );

编译、加载和链接顶点和片段着色器是一项具有挑战性的工作。我不确定为什么 WebGL 库中没有一个函数可以一步完成所有这些工作。几乎每个为 2D 编写 webgl 的人都要做到这一点,他们要么将其放入一个单独的.js文件中,要么将其复制粘贴到每个项目的代码中。目前,你需要知道关于下面的代码批处理的是,它正在将我们之前编写的顶点和片段着色器编译成程序变量。从那时起,我们将使用程序变量与着色器进行交互。以下是代码:

var vertex_shader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource( vertex_shader, vertex_shader_code );
gl.compileShader( vertex_shader );

if( !gl.getShaderParameter(vertex_shader, gl.COMPILE_STATUS) ) {
    console.log('Failed to compile vertex shader' + 
    gl.getShaderInfoLog(vertex_shader));
    gl.deleteShader(vertex_shader);
    return;
}

var fragment_shader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource( fragment_shader, fragment_shader_code );
gl.compileShader( fragment_shader );

if( !gl.getShaderParameter(fragment_shader, gl.COMPILE_STATUS) ) {
    console.log('Failed to compile fragment shader' + 
    gl.getShaderInfoLog(fragment_shader));
    gl.deleteShader(fragment_shader);
    return;
}

program = gl.createProgram();
gl.attachShader(program, vertex_shader);
gl.attachShader(program, fragment_shader);
gl.linkProgram(program);

if( !gl.getProgramParameter(program, gl.LINK_STATUS) ) {
    console.log('Failed to link program');
    gl.deleteProgram(program);
    return;
}
gl.useProgram(program);

现在我们在program变量中有了WebGLProgram对象,我们可以使用该对象与我们的着色器进行交互。

  1. 我们要做的第一件事是获取我们着色器程序中的uniform变量的引用:
u_texture_location = gl.getUniformLocation(program, "u_texture");
u_translate_location = gl.getUniformLocation(program, "u_translate");
  1. 之后,我们将使用program对象来获取我们顶点着色器使用的属性变量的引用:
a_position_location = gl.getAttribLocation(program, "a_position");
a_texcoord_location = gl.getAttribLocation(program, "a_texcoord");
  1. 现在,是时候开始使用缓冲区了。您还记得我们创建了包含所有顶点数据的Float32Array吗?现在是使用缓冲区将该数据发送到 GPU 的时候了:
vertex_texture_buffer = gl.createBuffer();

gl.bindBuffer(gl.ARRAY_BUFFER, vertex_texture_buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertex_texture_data, 
              gl.STATIC_DRAW);

gl.enableVertexAttribArray(a_position_location);
gl.vertexAttribPointer(a_position_location, 2, gl.FLOAT, false, 
                        STRIDE, XY_OFFSET);

gl.enableVertexAttribArray(a_texcoord_location);
gl.vertexAttribPointer(a_texcoord_location, 2, gl.FLOAT, false, 
                        STRIDE, UV_OFFSET);

第一行创建了一个名为vertex_texture_buffer的新缓冲区。以gl.bindBuffer开头的行将vertex_texture_buffer绑定到ARRAY_BUFFER,然后bufferDatavertex_texture_data中的数据添加到ARRAY_BUFFER中。之后,我们需要使用之前在a_position_locationa_texcoord_location变量中创建的对a_positiona_texcoord的引用告诉 WebGL 在这个数组缓冲区中找到a_positiona_texcoord属性的数据。它首先调用enableVertexAttribArray来使用我们创建的位置变量启用该属性。接下来,vertexAttribPointer使用STRIDEXY_OFFSETUV_OFFSET告诉 WebGL 属性数据在缓冲区数据中的位置。

  1. 之后,我们将创建并绑定纹理缓冲区:
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
  1. 现在我们有了一个绑定的纹理缓冲区,我们可以在缩放时配置该缓冲区为镜像包裹和最近邻插值:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);

gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);

我们使用gl.NEAREST而不是gl.LINEAR,因为我希望游戏具有老式的像素化外观。在您的游戏中,您可能更喜欢不同的算法。

  1. 配置纹理缓冲区后,我们将下载spaceship.png图像并将该图像数据加载到纹理缓冲区中:
img = new Image();

img.addEventListener('load', function() {
    image_width = img.width;
    image_height = img.height;

    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,
                    gl.UNSIGNED_BYTE, img );
});

img.src = "spaceship.png";
  1. 我们要做的最后一件事是将视口设置为从(0,0)到画布的宽度和高度。视口告诉 WebGL 画布元素中的空间如何与我们的 WebGL 裁剪空间相关联:
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

ShipPosition 函数

如果这是生产质量的代码,我将在渲染函数中执行目前在初始化例程中执行的大部分工作。在画布上独立移动精灵将需要更新我们的数组缓冲区。我可能不会以我所做的方式定义几何形状,也就是手动计算大小。我目前没有对数组缓冲区或纹理缓冲区进行任何更改;我试图保持这段代码尽可能少,以便使用 WebGL 将精灵渲染到画布上。这是我拥有的内容:

function ShipPosition( ship_x, ship_y ) {
    if( image_width == 0 ) {
        return;
    }

    gl.uniform4fv(u_translate_location, [ship_x, ship_y, 0.0, 0.0]);
    gl.drawArrays(gl.TRIANGLES, 0, 6);
}

  1. 前几行检查图像下载是否已完成。如果没有,我们将退出该函数:
if( image_width == 0 ) {
    return;
}
  1. 接下来,我们告诉 WebGL 使用我们飞船坐标加载u_translate统一变量:
gl.uniform4fv(u_translate_location, [ship_x, ship_y, 0.0, 0.0]);
  1. 最后,我们指示 WebGL 使用数组缓冲区中的六个顶点绘制三角形:
gl.drawArrays(gl.TRIANGLES, 0, 6);

MoveShip 函数

我们需要回到 WebAssembly C 模块。webgl.c文件是canvas.c的复制版本,我们需要做的唯一更改是在MoveShip函数内部。这是MoveShip的新版本:

void MoveShip() {
    ship_x += 0.002;
    ship_y += 0.001;

    if( ship_x >= 1.16 ) {
        ship_x = -1.16;
    }

    if( ship_y >= 1.21 ) {
        ship_y = -1.21;
    }

    EM_ASM( ShipPosition($0, $1), ship_x, ship_y );
}

更改都是从像素空间转换为 WebGL 裁剪空间。在 2D 画布版本中,我们每帧将两个像素添加到飞船的x坐标和一个像素添加到飞船的y坐标。但是在 WebGL 中,将x坐标移动两个像素将使其移动整个屏幕的宽度。因此,我们必须将这些值修改为与 WebGL 坐标系统兼容的小单位:

ship_x += 0.002;
ship_y += 0.001;

0.002添加到x坐标会使飞船每帧移动画布宽度的 1/500。将y坐标移动0.001会使飞船在 y 轴上每帧移动屏幕高度的 1/1,000。你可能会注意到,在这个应用程序的 2D 画布版本中,飞船向右下方移动。这是因为在 2D 画布坐标系统中增加y坐标会使图像向下移动。在 WebGL 坐标系统中,飞船向上移动。我们唯一需要做的另一件事就是改变飞船包裹其xy坐标的坐标,以适应 WebGL 剪辑空间:

if( ship_x >= 1.16 ) {
    ship_x = -1.16;
}

if( ship_y >= 1.21 ) {
    ship_y = -1.21;
}

现在我们有了所有的源代码,继续运行emcc来编译我们的新webgl.html文件。

emcc webgl.c -o webgl.html --shell-file webgl_shell.html

一旦你编译了webgl.html,将其加载到 Web 浏览器中。它应该看起来像这样:

图 3.1:我们的 WebGL 应用程序的屏幕截图

重要的是要记住,应用程序必须从 Web 服务器上运行,或者使用emrun。如果你不从 Web 服务器上运行应用程序,或者使用emrun,当 JavaScript 粘合代码尝试下载 WASM 和数据文件时,你将会收到各种错误。你还应该知道,IIS 需要额外的配置才能为.wasm.data文件扩展名设置正确的 MIME 类型。

现在我们在 WebGL 中完成了所有这些工作,下一章中,我将谈论如果一开始就使用 SDL,所有这些工作将会更容易。

总结

在这一章中,我们讨论了 WebGL 以及它如何提高网络游戏的性能。我向你介绍了 GLSL 着色器的概念,并讨论了顶点着色器和片段着色器,这两种着色器之间的区别,以及它们如何用于将几何图形和图像渲染到 HTML5 画布上。

我们还使用 WebGL 重新创建了我们在 2D 画布上创建的移动飞船。我们讨论了如何使用顶点几何来将 2D 图像渲染到 3D 画布上。我们还讨论了基于像素的 2D 画布坐标系统和 3D WebGL 坐标系统之间的区别。

WebGL 是一个广泛的主题,因此单独一章只能给出一个非常粗略的介绍。WebGL 是一个 3D 渲染空间,在这一章中,我刻意忽略了这一点,将其视为 2D 空间。你可以在我们所做的基础上进行扩展,但为了提高应用程序的性能,我们将来使用 WebAssembly SDL API 与 WebGL 进行所有交互。如果你想了解更多关于 WebGL 的知识,Packt 有大量专门致力于 WebGL 的图书可供查阅search.packtpub.com/?query=webgl

在下一章中,我将教你 SDL 的基础知识,它是什么,以及它如何与 WebAssembly 一起工作。我们还将学习如何使用 SDL 将精灵渲染到 HTML5 画布上,对其进行动画处理,并在画布上移动它。

第四章:在 WebAssembly 中使用 SDL 进行精灵动画

在撰写本文时,Simple DirectMedia Layer(SDL)是唯一集成到 Emscripten 中供 WebAssembly 使用的 2D 渲染库。但是,即使更多的渲染库变得可用,SDL 也是一个得到广泛支持的渲染库,已经被移植到了大量平台,并且在可预见的未来仍将保持相关和有用,用于 WebAssembly 和 C++开发。使用 SDL 渲染到 WebGL 可以节省大量时间,因为我们不必自己编写 WebAssembly C++代码和 WebGL 之间的接口代码。庞大的社区还提供支持和文档。您可以在libsdl.org上找到更多 SDL 资源。

您需要在构建中包含几个图像才能使此项目工作。确保包括项目的 GitHub 中的/Chapter04/sprites//Chapter04/font/文件夹。如果您还没有下载 GitHub 项目,可以从以下网址在线获取:github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly

本章我们将涵盖以下主题:

  • 在 WebAssembly 中使用 SDL

  • 将精灵渲染到画布上

  • 动画精灵

  • 移动精灵

在 WebAssembly 中使用 SDL

到目前为止,我可以为 WebAssembly 模块和 JavaScript WebGL 库之间的交互自己开发系统。这将涉及使用函数表从 C++中调用 JavaScript WebGL 函数。幸运的是,Emscripten 团队已经完成了大部分工作。他们已经为我们创建了一个流行的 2D C++图形库的端口,可以实现这一点。SDL 是一个建立在大多数实现中的 OpenGL 之上的 2D 图形 API。有一个 Emscripten 端口,用于帮助我们在 WebGL 上渲染我们的 2D 图形。如果您想知道 Emscripten 集成了哪些其他库,请使用以下emcc命令:

emcc --show-ports

如果您运行此命令,您会注意到显示了几个不同的 SDL 库。这些包括 SDL2、SDL2_image、SDL2_gfx、SDL2_ttf 和 SDL2_net。SDL 是以模块化设计创建的,允许用户只包含他们需要的 SDL 部分,从而使核心 SDL 库保持较小。如果您的目标是创建一个下载大小受限的网络游戏,这将非常有帮助。

我们将首先通过创建一个简单的“Hello World”应用程序来熟悉 SDL,该应用程序将一些文本写入 HTML5 画布元素。为此,我们需要包含我们运行emcc --show-ports命令时列出的 Emscripten 库中的两个。我们需要通过在 Emscripten 编译时添加USE_SDL=2标志来添加核心 SDL 库,还需要通过添加USE_SDL_TTF=2标志来添加 SDL TrueType 字体库。

将在 HTML 画布中显示消息"HELLO SDL!".c源代码相对简单:

#include <SDL2/SDL.h>
#include <SDL2/SDL_ttf.h>
#include <emscripten.h>
#include <stdio.h>

#define MESSAGE "HELLO SDL!"
#define FONT_SIZE 16
#define FONT_FILE "font/Roboto-Black.ttf"

int main() {
    SDL_Window *window;
    SDL_Renderer *renderer;

    SDL_Rect dest = {.x = 160, .y = 100, .w = 0, .h = 0 };

    TTF_Font *font;
    SDL_Texture* texture;

    SDL_Init( SDL_INIT_VIDEO );
    TTF_Init();

    SDL_CreateWindowAndRenderer( 320, 200, 0, &window, &renderer );

    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );

    font = TTF_OpenFont( FONT_FILE, FONT_SIZE );

    SDL_Color font_color = {255, 255, 255, 255 }; // WHITE COLOR
    SDL_Surface *temp_surface = TTF_RenderText_Blended( font, 
                                                        MESSAGE, 
                                                       font_color );

    texture = SDL_CreateTextureFromSurface( renderer, temp_surface );

    SDL_FreeSurface( temp_surface );
    SDL_QueryTexture( texture,
                        NULL, NULL,
                        &dest.w, &dest.h ); // query the width and 
                                               height

    dest.x -= dest.w / 2;
    dest.y -= dest.h / 2;

    SDL_RenderCopy( renderer, texture, NULL, &dest );
    SDL_RenderPresent( renderer );

    return EXIT_SUCCESS;
}

让我来详细介绍一下这里发生了什么。代码的前四行是 SDL 头文件,以及 Emscripten 头文件:

#include <SDL2/SDL.h>
#include <SDL2/SDL_ttf.h>
#include <emscripten.h>
#include <stdio.h>

在此之后,有三个预处理器定义。如果我们想快速更改消息或字体大小,我们将修改这前两行。第三个定义不太清楚。我们有一个叫做FONT_FILE的东西,它是一个看起来像是文件系统位置的字符串。这有点奇怪,因为 WebAssembly 无法访问本地文件系统。为了让 WebAssembly 模块访问 fonts 目录中的 TrueType 字体文件,我们将在编译WASM文件时使用--preload-file标志。这将从字体目录的内容生成一个.data文件。Web 浏览器将此数据文件加载到虚拟文件系统中,WebAssembly 模块可以访问该文件。这意味着我们编写的 C 代码将可以像访问本地文件系统一样访问此文件:

#define MESSAGE "HELLO SDL!"
#define FONT_SIZE 16
#define FONT_FILE "font/Roboto-Black.ttf"

初始化 SDL

与 C/C++的其他目标一样,代码从main函数开始执行。我们将通过声明一些变量来启动我们的main函数:

int main() {
    SDL_Window *window;
    SDL_Renderer *renderer;

    SDL_Rect dest = {.x = 160, .y = 100, .w = 0, .h = 0 };
    TTF_Font *font;

    SDL_Texture *texture;

前两个变量是SDL_WindowSDL_Renderer对象。window对象将定义应用程序窗口,如果我们为 Windows、Mac 或 Linux 系统编写代码,我们将渲染到该窗口中。当我们构建 WebAssembly 时,我们的 HTML 中有一个画布,但 SDL 仍然需要一个window对象指针来进行初始化和清理。所有对 SDL 的调用都使用renderer对象将图像渲染到画布上。

SDL_Rect dest变量是一个表示我们将要渲染到画布上的目标的矩形。我们将渲染到 320x200 画布的中心,所以我们将从xy160100开始。我们还不知道我们将要渲染的文本的宽度和高度,所以在这一点上,我们将wh设置为0。我们稍后会重置这个值,所以理论上,我们可以将它设置为任何值。

TTF_Font *font变量是指向SDL_TTF库的font对象的指针。稍后,我们将使用该对象从虚拟文件系统加载字体,并将该字体渲染到SDL_Texture *texture指针变量。SDL_Texture变量由 SDL 用于将精灵渲染到画布上。

接下来的几行用于在 SDL 中进行一些初始化工作:

SDL_Init( SDL_INIT_VIDEO );
TTF_Init();

SDL_CreateWindowAndRenderer( 320, 200, 0, &window, &renderer );

SDL_Init函数使用单个标志调用,仅初始化视频子系统。顺便说一句,我不知道 SDL 的任何用例不需要视频子系统初始化。许多开发人员将 SDL 用作 OpenGL/WebGL 图形渲染系统;因此,除非您设计了一个仅音频的游戏,否则应始终传入SDL_INIT_VIDEO标志。如果您想初始化其他 SDL 子系统,您将使用布尔或|运算符传入这些子系统的标志,如下面的代码片段所示:

 SDL_Init( SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_HAPTIC );

如果我们使用上一行,SDL 也会初始化音频和触觉子系统,但我们现在不需要它们,所以我们不会进行更改。

TTF_Init();函数初始化我们的 TrueType 字体,SDL_CreateWindowAndRenderer向我们返回windowrenderer对象。我们传入320作为画布的宽度,200作为高度。第三个变量是window标志。我们传入0作为该参数,表示我们不需要任何window标志。因为我们正在使用 SDL Emscripten 端口,我们无法控制窗口,所以这些标志不适用。

清除 SDL 渲染器

初始化完成后,我们需要清除渲染器。我们可以用任何颜色清除我们的渲染器。为了做到这一点,我们将调用SDL_RenderDrawColor函数:

SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
SDL_RenderClear( renderer );

这将为渲染器设置绘图颜色为完全不透明的黑色。0, 0, 0是 RGB 颜色值,255是 alpha 不透明度。这些数字的范围都是从 0 到 255,其中 255 是颜色光谱上的全色。我们设置这样,这样当我们在下一行调用SDL_RenderClear函数时,它将用黑色清除渲染器。如果我们想要清除红色而不是黑色,我们需要修改以下调用方式:

SDL_SetRenderDrawColor( renderer, 255, 0, 0, 255 );

这不是我们想要的,所以我们不会做出这种改变。我只是想指出我们可以用任何颜色清除渲染器。

使用 WebAssembly 虚拟文件系统

接下来的几行将在虚拟文件系统中打开 TrueType 字体文件,并将其渲染到SDL_Texture,这可以用来渲染到画布:

font = TTF_OpenFont( FONT_FILE, FONT_SIZE );
SDL_Color font_color = {255, 255, 255, 255 }; // WHITE COLOR
SDL_Surface *temp_surface = TTF_RenderText_Blended( font, MESSAGE,
                                                    font_color );
texture = SDL_CreateTextureFromSurface( renderer, temp_surface );
SDL_FreeSurface( temp_surface ); 

在前面代码的第一行中,我们通过在程序顶部定义的 WebAssembly 虚拟文件系统中传递文件的位置来打开 TrueType 字体。我们还需要指定字体的点大小,这也在程序顶部定义为 16。接下来,我们创建一个SDL_Color变量,我们将用它来设置字体的颜色。这是一个 RGBA 颜色,我们将所有值设置为 255,这样它就是完全不透明的白色。做完这些之后,我们需要使用TTF_RenderText_Blended函数将文本渲染到一个表面上。我们传递了几行前打开的 TrueType 字体,MESSAGE,在程序顶部定义为"HELLO SDL!",以及定义为白色的字体颜色。然后,我们将从我们的表面创建一个纹理,并释放我们刚刚分配的表面内存。在使用表面指针创建纹理后,您应该立即释放表面指针的内存,因为一旦您有了纹理,表面就不再需要了。

将纹理渲染到 HTML5 画布

从虚拟文件系统加载字体,然后将该字体渲染到纹理后,我们需要将该纹理复制到渲染器对象的位置。在完成这些操作后,我们需要将渲染器的内容呈现到 HTML5 画布元素。

以下是将纹理渲染到画布的源代码:

SDL_QueryTexture( texture,
                    NULL, NULL,
                    &dest.w, &dest.h ); // query the width and height

dest.x -= dest.w / 2;
dest.y -= dest.h / 2;

SDL_RenderCopy( renderer, texture, NULL, &dest );
SDL_RenderPresent( renderer ); 

调用SDL_QueryTexture函数用于检索纹理的宽度和高度。我们需要使用这些值在目标矩形中,以便我们将纹理渲染到画布而不改变其尺寸。在那个调用之后,程序知道了纹理的宽度和高度,所以它可以使用这些值来修改目标矩形的xy变量,以便它可以将我们的文本居中在画布上。因为dest(目标)矩形的xy值指定了该矩形的左上角,我们需要减去矩形宽度的一半和矩形高度的一半,以确保它居中。然后SDL_RenderCopy函数将这个纹理渲染到我们的渲染缓冲区,SDL_RenderPresent将整个缓冲区移动到 HTML5 画布上。

到这一点,代码中剩下的就是return

return EXIT_SUCCESS;

EXIT_SUCCESS的值返回告诉我们的 JavaScript 粘合代码,当运行这个模块时一切都进行得很好。

清理 SDL。

您可能会注意到这段代码中缺少的内容,这在 Windows 或 Linux 版本的 SDL 应用程序中会有,那就是在程序结束时进行一些 SDL 清理的代码。例如,如果我们在 Windows 中退出应用程序,而没有进行清理工作,我们将退出而不清除 SDL 分配的一些内存。如果这不是一个 WebAssembly 模块,以下行将包含在函数的末尾:

SDL_Delay(5000);
SDL_DestroyWindow(window);
SDL_Quit();

因为我们还没有花时间制作游戏循环,我们希望通过调用SDL_Delay(5000)来延迟清理和退出程序五秒,5000是等待进行清理之前的毫秒数。我们要重申,因为我们正在编译为 WebAssembly,我们不希望清理我们的 SDL。这对不同的浏览器有不同的影响。

在 Firefox 中测试此代码时,使用延迟是不必要的,因为 Web 浏览器标签会在 WebAssembly 模块停止执行后保持打开。然而,Chrome 浏览器标签在 SDL 销毁window对象后会显示错误页面。

SDL_DestroyWindow函数会在 Windows 环境下销毁window对象。SDL_Quit函数终止 SDL 引擎,最后,return EXIT_SUCCESS;main函数成功退出。

编译 hello_sdl.html

最后,我们将使用 Emscripten 的emcc编译器编译和测试我们的 WebAssembly 模块:

emcc hello_sdl.c --emrun --preload-file font -s USE_SDL=2 -s USE_SDL_TTF=2 -o hello_sdl.html

重要的是要记住,您必须使用 Web 服务器或emrun来运行 WebAssembly 应用程序。如果您想使用emrun来运行 WebAssembly 应用程序,您必须使用--emrun标志进行编译。Web 浏览器需要 Web 服务器来流式传输 WebAssembly 模块。如果您尝试直接从硬盘驱动器在浏览器中打开使用 WebAssembly 的 HTML 页面,那么 WebAssembly 模块将无法加载。

在这次对emcc的调用中,我们使用了一些新的标志,并临时省略了--shell-file new_shell.html标志,该标志用于生成模板的定制版本。如果您想继续使用emrun来测试应用程序,您必须包括--emrun标志,以使用emrun命令运行。如果您使用 Node.js 等 Web 服务器来提供应用程序,则可以从现在开始省略--emrun标志。如果您喜欢使用emrun,请继续使用该标志进行编译。

我们已经添加了--preload-file字体标志,以便我们可以创建包含在hello_sdl.data文件中的虚拟文件系统。这个文件保存了我们的 TrueType 字体。应用程序使用了核心 SDL 库和额外的 SDL TrueType 字体模块,因此我们包含了以下标志-s USE_SDL=2 -s USE_SDL_TTF=2,以允许调用SDLSDL_ttf。如果您的编译顺利进行,当您在浏览器中打开新的hello_sdl.html文件时,它将会是这个样子:

图 4.1:Hello SDL!应用程序截图

在下一节中,我们将学习如何使用 SDL 将精灵渲染到 HTML5 画布上。

将精灵渲染到画布上

现在我们已经学会了如何使用 SDL 和 Emscripten 将文本渲染到 HTML 画布元素,我们可以迈出下一步,学习如何渲染精灵。用于将精灵渲染到画布的代码与我们用于渲染 TrueType 字体的代码非常相似。我们仍然使用虚拟文件系统来生成包含我们使用的精灵的数据文件,但是我们需要一个新的 SDL 库来实现这一点。我们不再需要SDL2_ttf来加载 TrueType 字体并将其渲染到纹理。相反,我们需要SDL2_image。稍后我们将向您展示如何更改我们对emcc的调用以包含这个新库。

首先,让我们来看一下新版本的 SDL 代码,它将图像渲染到我们的 HTML 画布元素上,而不是我们在上一节中渲染的文本:

#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>
#include <emscripten.h>
#include <stdio.h>
#define SPRITE_FILE "sprites/Franchise1.png"

int main() {
    SDL_Window *window;
    SDL_Renderer *renderer;
    SDL_Rect dest = {.x = 160, .y = 100, .w = 0, .h = 0 };
    SDL_Texture *texture;
    SDL_Init( SDL_INIT_VIDEO );
    SDL_CreateWindowAndRenderer( 320, 200, 0, &window, &renderer );
    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );
    SDL_Surface *temp_surface = IMG_Load( SPRITE_FILE );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return 0;
    }

    texture = SDL_CreateTextureFromSurface( renderer, temp_surface );

    SDL_FreeSurface( temp_surface );

    SDL_QueryTexture( texture,
                        NULL, NULL,
                        &dest.w, &dest.h ); // query the width and 
                        height

    dest.x -= dest.w / 2;
    dest.y -= dest.h / 2;

    SDL_RenderCopy( renderer, texture, NULL, &dest );
    SDL_RenderPresent( renderer );

 SDL_Delay(5000);
 SDL_DestroyWindow(window);
 SDL_Quit();
    return 1;
}

这段代码类似于我们在上一节HTML5 和 WebAssembly中编写的代码,用于HELLO SDL!应用程序。我们使用的是SDL2_image模块,而不是SDL2_ttf模块。因此,我们需要包含SDL2/SDL_image.h头文件。我们还需要从sprites目录加载一个精灵文件,并将其添加到 WebAssembly 虚拟文件系统中:

SDL_Surface *temp_surface = IMG_Load( SPRITE_FILE );

if( !temp_surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return 0;
}

在调用IMG_Load之后,我们添加了一个错误检查,以便在文件加载失败时让我们知道出了什么问题。除此之外,代码大部分都是相同的。如果成功,画布将显示我们的 16x16 像素的 Starship Franchise 图像:

图 4.2:Franchise1.png

在下一节中,我们将学习如何使用 SDL 在画布上制作动画精灵。

动画精灵

在本节中,我们将学习如何在 SDL 应用程序中制作一个快速而简单的动画。这不是我们在最终游戏中做动画的方式,但它会让您了解我们如何通过在 SDL 内部交换纹理来随时间创建动画。我将呈现分解为两部分的代码来动画精灵。第一部分包括我们的预处理宏、全局变量和show_animation函数:

#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>

#include <emscripten.h>
#include <stdio.h>

#define SPRITE_FILE "sprites/Franchise1.png"
#define EXP_FILE "sprites/FranchiseExplosion%d.png"
#define FRAME_COUNT 7

int current_frame = 0;
Uint32 last_time;
Uint32 current_time;
Uint32 ms_per_frame = 100; // animate at 10 fps

SDL_Window *window;
SDL_Renderer *renderer;
SDL_Rect dest = {.x = 160, .y = 100, .w = 0, .h = 0 };
SDL_Texture *sprite_texture;
SDL_Texture *temp_texture;
SDL_Texture* anim[FRAME_COUNT];

void show_animation() {
    current_time = SDL_GetTicks();
    int ms = current_time - last_time;

    if( ms < ms_per_frame) {
        return;
    }

    if( current_frame >= FRAME_COUNT ) {
        SDL_RenderClear( renderer );
        return;
    }

    last_time = current_time;
    SDL_RenderClear( renderer );

    temp_texture = anim[current_frame++];

    SDL_QueryTexture( temp_texture,
                        NULL, NULL,
                        &dest.w, &dest.h ); // query the width and       
                                               height

    dest.x = 160 - dest.w / 2;
    dest.y = 100 - dest.h / 2;

    SDL_RenderCopy( renderer, temp_texture, NULL, &dest );
    SDL_RenderPresent( renderer );
}

在定义了show_animation函数之后,我们需要定义模块的main函数:

int main() {
    char explosion_file_string[40];
    SDL_Init( SDL_INIT_VIDEO );
    SDL_CreateWindowAndRenderer( 320, 200, 0, &window, &renderer );

    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );

    SDL_Surface *temp_surface = IMG_Load( SPRITE_FILE );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return 0;
    }

    sprite_texture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    SDL_FreeSurface( temp_surface );

    for( int i = 1; i <= FRAME_COUNT; i++ ) {
        sprintf( explosion_file_string, EXP_FILE, i );
        SDL_Surface *temp_surface = IMG_Load( explosion_file_string );

        if( !temp_surface ) {
            printf("failed to load image: %s\n", IMG_GetError() );
            return 0;
        }

        temp_texture = SDL_CreateTextureFromSurface( renderer, 
        temp_surface );
        anim[i-1] = temp_texture;
        SDL_FreeSurface( temp_surface );
    }

    SDL_QueryTexture( sprite_texture,
                        NULL, NULL,
                        &dest.w, &dest.h ); // query the width and 
                                               height

    dest.x -= dest.w / 2;
    dest.y -= dest.h / 2;

    SDL_RenderCopy( renderer, sprite_texture, NULL, &dest );
    SDL_RenderPresent( renderer );

    last_time = SDL_GetTicks();
    emscripten_set_main_loop(show_animation, 0, 0);
    return 1;
}

这里有很多内容需要解释。有更高效的方法来做这个动画,但我们在这里所做的是基于我们已经完成的工作并进行扩展。在代码的早期版本中,我们将单个帧呈现到画布上,然后退出 WebAssembly 模块。如果您的目标是将静态内容呈现到画布并永远不更改它,那么这样做就足够了。但是,如果您正在编写游戏,则需要能够对精灵进行动画处理并在画布上移动它们。在这里,我们遇到了一个问题,如果我们将 C++代码编译为 WebAssembly 以外的任何目标,我们就不会遇到这个问题。游戏通常在循环中运行,并直接负责向屏幕渲染。WebAssembly 在 Web 浏览器的 JavaScript 引擎内运行。WebAssembly 模块本身无法更新我们的画布。Emscripten 使用 JavaScript 粘合代码间接从 SDL API 更新 HTML 画布。但是,如果 WebAssembly 在循环中运行,并使用该循环通过 SDL 来对我们的精灵进行动画处理,那么 WebAssembly 模块永远不会释放它所在的线程,并且 JavaScript 永远没有机会更新画布。因此,我们不能将游戏循环放在main函数中。相反,我们必须创建一个不同的函数,并使用 Emscripten 来设置 JavaScript 粘合代码,以便在每次浏览器渲染帧时调用该函数。我们将使用的函数如下:

emscripten_set_main_loop(show_animation, 0, 0);

我们将传递给emscripten_set_main_loop的第一个参数是show_animation。这是我们在代码顶部附近定义的一个函数的名称。稍后我会谈论show_animation函数的具体内容。现在,知道这是每次浏览器在画布上渲染新帧时调用的函数就足够了。

emscripten_set_main_loop的第二个参数是每秒帧数FPS)。如果要将游戏的 FPS 设置为固定速率,可以通过在此处将目标帧速率传递给函数来实现。如果传入0,这告诉emscripten_set_main_loop以尽可能高的帧速率运行。通常情况下,您希望游戏以尽可能高的帧速率运行,因此传入0通常是最好的做法。如果传入的值高于计算机能够渲染的速度,它将以其能够的速度渲染,因此此值仅对 FPS 设置了上限。

我们传递的第三个参数是simulate_infinite_loop。传入0等同于传递false值。如果此参数的值为true,它会强制模块在每帧通过main函数重新进入。我不确定这个用例是什么。我建议将其保持为0,并将游戏循环分离到另一个函数中,就像我们在这里做的那样。

在调用emscripten_set_main_loop之前,我们将设置一个 SDL 纹理表面指针的数组:

for( int i = 1; i <= FRAME_COUNT; i++ ) {
 sprintf( explosion_file_string, EXP_FILE, i );
    SDL_Surface *temp_surface = IMG_Load( explosion_file_string );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return 0;
    }

    temp_texture = SDL_CreateTextureFromSurface( renderer, temp_surface );
    anim[i-1] = temp_texture;
    SDL_FreeSurface( temp_surface );
}

这个循环将FranchiseExplosion1.pngFranchiseExplosion7.png加载到一个 SDL 纹理数组中,并将它们存储到一个名为anim的不同数组中。这是我们稍后将在show_animation函数中循环的数组。有更有效的方法可以使用精灵表,并通过修改目标矩形来实现这一点。我们将在后面的章节中讨论渲染动画精灵的这些技术。

在代码的顶部附近,我们定义了show_animation函数,每渲染一帧就调用一次:

void show_animation() {
    current_time = SDL_GetTicks();
    int ms = current_time - last_time;

    if( ms < ms_per_frame) {
        return;
    }

    if( current_frame >= FRAME_COUNT ) {
        SDL_RenderClear( renderer );
        return;
    }

    last_time = current_time;
    SDL_RenderClear( renderer );

    temp_texture = anim[current_frame++];

    SDL_QueryTexture( temp_texture,
                        NULL, NULL,
                        &dest.w, &dest.h ); // query the width and 
                                               height

    dest.x = 160 - dest.w / 2;
    dest.y = 100 - dest.h / 2;

    SDL_RenderCopy( renderer, temp_texture, NULL, &dest );
    SDL_RenderPresent( renderer );
}

这个函数的设计是等待一定的毫秒数,然后更新我们正在渲染的纹理。我创建了一个七帧动画,让星际特许经营号在一个小像素化的爆炸中爆炸。在这个循环中我们需要短暂等待的原因是,我们的刷新率可能是 60+ FPS,如果我们每次调用show_animation时都渲染一个新的动画帧,整个动画将在大约 1/10 秒内运行完毕。经典的街机游戏经常以比游戏帧率慢得多的速度翻转它们的动画序列。许多经典的任天堂娱乐系统NES)游戏使用两阶段动画,其中动画会在几百毫秒内交替精灵,尽管 NES 的帧率是 60 FPS。

这个函数的核心与我们之前创建的单纹理渲染类似。主要的区别是在改变动画帧之前我们等待固定的毫秒数,通过递增current_frame变量来遍历我们动画的所有七个阶段,这需要不到一秒的时间。

移动精灵

现在我们已经学会了如何以逐帧动画的方式为我们的精灵添加动画,我们将学习如何在画布上移动精灵。我希望保持我们的飞船动画,但我希望它不要在爆炸循环中运行。在我们的sprites文件夹中,我包含了一个简单的四阶段动画,可以使我们飞船的引擎闪烁。源代码非常长,所以我将分三部分介绍它:预处理和全局变量部分,show_animation函数和main函数。

这是我们的cpp文件开头定义的预处理指令和全局变量的代码:

#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>

#include <emscripten.h>
#include <stdio.h>

#define SPRITE_FILE "sprites/Franchise1.png"
#define EXP_FILE "sprites/Franchise%d.png"

#define FRAME_COUNT 4

int current_frame = 0;
Uint32 last_time;
Uint32 current_time;
Uint32 ms_per_frame = 100; // animate at 10 fps

SDL_Window *window;

SDL_Renderer *renderer;
SDL_Rect dest = {.x = 160, .y = 100, .w = 0, .h = 0 };
SDL_Texture *sprite_texture;
SDL_Texture *temp_texture;
SDL_Texture* anim[FRAME_COUNT];

在预处理指令和全局变量之后,我们的cpp文件包含了一个定义游戏循环的show_animation函数。以下是我们show_animation函数的代码:

void show_animation() {
    current_time = SDL_GetTicks();
    int ms = current_time - last_time;

    if( ms >= ms_per_frame) {
        ++current_frame;
        last_time = current_time;
    }

    if( current_frame >= FRAME_COUNT ) {
        current_frame = 0;
    }

    SDL_RenderClear( renderer );
    temp_texture = anim[current_frame];

    dest.y--;

    if( dest.y < -16 ) {
        dest.y = 200;
    }

    SDL_RenderCopy( renderer, temp_texture, NULL, &dest );
    SDL_RenderPresent( renderer );
}

我们的cpp文件的最后部分定义了main函数。这是我们的 WebAssembly 模块中的初始化代码:

int main() {
    char explosion_file_string[40];
    SDL_Init( SDL_INIT_VIDEO );
    SDL_CreateWindowAndRenderer( 320, 200, 0, &window, &renderer );
    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );
    SDL_Surface *temp_surface = IMG_Load( SPRITE_FILE );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return 0;
    }

    sprite_texture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );
    SDL_FreeSurface( temp_surface );

    for( int i = 1; i <= FRAME_COUNT; i++ ) {
        sprintf( explosion_file_string, EXP_FILE, i );
        SDL_Surface *temp_surface = IMG_Load( explosion_file_string );

        if( !temp_surface ) {
            printf("failed to load image: %s\n", IMG_GetError() );
            return 0;
        }

        temp_texture = SDL_CreateTextureFromSurface( renderer, 
        temp_surface );

        anim[i-1] = temp_texture;
        SDL_FreeSurface( temp_surface );
    }

    SDL_QueryTexture( sprite_texture,
                        NULL, NULL,
                        &dest.w, &dest.h ); // query the width and 
                                               height

    dest.x -= dest.w / 2;
    dest.y -= dest.h / 2;

    SDL_RenderCopy( renderer, sprite_texture, NULL, &dest );
    SDL_RenderPresent( renderer );

    last_time = SDL_GetTicks();
    emscripten_set_main_loop(show_animation, 0, 0);
    return 1;
}

这段代码类似于我们的sprite_animation代码。只有一些修改,大部分在show_animation函数中:

void show_animation() {
    current_time = SDL_GetTicks();

    int ms = current_time - last_time;

    if( ms >= ms_per_frame) {
        ++current_frame;
        last_time = current_time;
    }

    if( current_frame >= FRAME_COUNT ) {
        current_frame = 0;
    }

    SDL_RenderClear( renderer );
    temp_texture = anim[current_frame];

    dest.y--;

    if( dest.y < -16 ) {
        dest.y = 200;
    }

    SDL_RenderCopy( renderer, temp_texture, NULL, &dest );
    SDL_RenderPresent( renderer );
}

ms中的值超过ms_per_frame时,我们就会推进我们的帧,ms跟踪自上一帧更改以来的毫秒数,我们将ms_per_frame设置为100。因为飞船在移动,我们仍然需要在每一帧更新我们的画布以显示新的飞船位置。我们通过修改dest.y的值来实现这一点,这告诉 SDL 在 y 轴上渲染我们的飞船。我们每一帧都从dest.y变量中减去 1,以将飞船向上移动。我们还进行了一个检查,看看这个值是否变小到小于-16。因为精灵高度为 16 像素,当精灵完全移出屏幕顶部时,这种情况就会发生。如果是这种情况,我们需要通过将y值设置回200来将精灵移回游戏屏幕的底部。在实际游戏中,像这样直接将我们的移动与帧速率绑定在一起是一个坏主意,但是对于这个演示来说,这样做是可以的。

编译 sprite.html

现在我们可以使用emcc命令来编译我们的精灵 WebAssembly 应用程序。您需要从 GitHub 的Chapter02文件夹中获取sprites文件夹。在您下载了sprites文件夹并将其放在项目文件夹中之后,您可以使用以下命令编译应用程序:

emcc sprite_move.c --preload-file sprites -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -o sprite_move.html

重要的是要记住,应用程序必须从 Web 服务器上运行,或者使用emrun。如果您不从 Web 服务器上运行应用程序,或者使用emrun,当 JavaScript 粘合代码尝试下载 WASM 和数据文件时,您将收到各种错误。您还应该知道,为了设置.wasm.data文件扩展名的正确 MIME 类型,IIS 需要额外的配置。

我们仍然使用--preload-file标志,但是这次我们传递的是sprites文件夹,而不是fonts文件夹。我们将继续使用-s USE_SDL=2标志,并将添加-s USE_SDL_IMAGE=2标志,这将允许我们在 SDL 中使用图像,这是.bmp文件格式的替代品。

为了告诉SDL_IMAGE要使用哪种文件格式,我们使用以下-s SDL2_IMAGE_FORMATS=["png"]标志传递png格式:

图 4.3:sprite_move.html 的屏幕截图

总结

在本章中,我向您介绍了 SDL 及其可在 WebAssembly 中使用的模块库。我们了解了 WebAssembly 虚拟文件系统,以及 Emscripten 如何创建.data文件以便在 WebAssembly 虚拟文件系统中访问。我教会了您如何使用 SDL 将图像和字体渲染到 HTML 画布上。最后,我们学会了如何使用 SDL 在游戏中创建简单的动画。

在下一章中,我们将学习如何使用键盘输入来移动画布上的游戏对象。

第五章:键盘输入

现在我们有了精灵和动画,可以在画布上移动这些精灵,我们需要在游戏中添加一些交互。有几种方法可以获取游戏的键盘输入。一种方法是通过 JavaScript,根据输入调用 WebAssembly 模块中的不同函数。我们代码的第一部分将做到这一点。我们将在 WebAssembly 模块中添加一些函数,供我们在 JavaScript 包装器中使用。我们还将设置一些 JavaScript 键盘事件处理程序,这些处理程序将在触发键盘事件时调用我们的 WebAssembly 模块。

我们可以让 SDL 来为我们处理所有繁重的工作,从而将输入传递到我们的 WebAssembly 模块中。这涉及将 C 代码添加到我们的 WebAssembly 模块中,以捕获SDL_KEYDOWNSDL_KEYUP事件。然后,模块将查看事件的键码,以确定触发事件的键。使用任一方法编写我们的代码都有成本和收益。一般来说,让 SDL 管理我们的键盘输入会使我们失去在 JavaScript 中编写键盘输入管理器的灵活性,同时,我们也会获得更加直接的代码的好处。

您需要在构建中包含几个图像,以使该项目正常工作。确保您从项目的 GitHub 中包含/Chapter05/sprites/文件夹。如果您还没有下载 GitHub 项目,可以在以下网址在线获取:github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly

在本章中,我们将执行以下操作:

  • 学习如何使用 JavaScript 键盘事件调用我们的 WebAssembly 模块

  • 学习如何使用 SDL 事件来管理 WebAssembly 模块内的键盘输入

  • 通过使用键盘输入来移动画布上的飞船精灵来演示我们所学到的内容

JavaScript 键盘输入

我们将首先学习如何监听 JavaScript 键盘事件,并根据这些事件调用我们的 WebAssembly 模块。我们将重用我们为第二章编写的大部分代码,HTML5 和 WebAssembly,所以我们应该首先从Chapter02文件夹中获取该代码,并将其复制到我们的新Chapter05文件夹中。将Chapter02目录中的new_shell.html文件复制到Chapter05目录,然后将该文件重命名为jskey_shell.html。接下来,将Chapter02目录中的shell.c复制到Chapter05目录,并将该文件重命名为jskey.c。最后,将Chapter02目录中的shell.css文件复制到Chapter05目录,但不要重命名。这三个文件将为我们编写 JavaScript 键盘输入代码提供一个起点。

首先,让我们来看一下我们刚刚从shell.c创建的jskey.c文件。我们可以在文件的开头就把大部分代码删除掉。删除main函数结束后的所有代码。这意味着你将删除以下所有代码:

void test() {
    printf("button test\n");
}

void int_test( int num ) {
    printf("int test=%d\n", num);
}

void float_test( float num ) {
    printf("float test=%f\n", num);
}

void string_test( char* str ) {
    printf("string test=%s\n", str);
}

接下来,我们将修改main函数。我们不再希望在main函数内部使用EM_ASM来调用我们的 JavaScript 包装器初始化函数,因此从main函数中删除以下两行代码:

EM_ASM( InitWrappers() );
printf("Initialization Complete\n");

在我们的main函数中,唯一剩下的是一个printf语句。我们将更改该行以让我们知道main函数已运行。您可以更改此代码以说任何您喜欢的内容,或者完全删除printf语句。以下代码显示了我们main函数的内容:

int main() {
    printf("main has run\n");
}

现在我们已经修改了main函数,并删除了我们不再需要的所有函数,让我们添加一些在触发 JavaScriptkeyboard事件时调用的函数。当用户在键盘上按下箭头键时,我们将添加一个keypress事件的函数。以下代码将被这些keypress事件调用:

void press_up() {
    printf("PRESS UP\n");
}

void press_down() {
    printf("PRESS DOWN\n");
}

void press_left() {
    printf("PRESS LEFT\n");
}

void press_right() {
    printf("PRESS RIGHT\n");
}

我们还想知道用户何时释放按键。因此,我们将在 C 模块中添加四个release函数,如下所示:

void release_up() {
    printf("RELEASE UP\n");
}

void release_down() {
    printf("RELEASE DOWN\n");
}

void release_left() {
    printf("RELEASE LEFT\n");
}

void release_right() {
    printf("RELEASE RIGHT\n");
}

现在我们有了新的 C 文件,我们可以改变我们的 shell 文件。打开jskey_shell.html。我们不需要改变head标签中的任何内容,但在body内部,我们将删除许多我们将不再使用的 HTML 元素。继续删除除textarea元素之外的所有元素。我们希望保留textarea元素,以便我们可以看到模块内的printf语句的输出。我们需要在jskey_shell.html中删除以下 HTML,然后再删除textarea元素之后的div及其内容:

<div class="input_box">&nbsp;</div>
<div class="input_box">
    <button id="click_me" class="em_button">Click Me!</button>
</div>

<div class="input_box">
    <input type="number" id="int_num" max="9999" min="0" step="1" 
     value="1" class="em_input">
    <button id="int_button" class="em_button">Int Click!</button>
</div>

<div class="input_box">
    <input type="number" id="float_num" max="99" min="0" step="0.01" 
     value="0.0" class="em_input">
    <button id="float_button" class="em_button">Float Click!</button>
</div>

<div class="input_box">&nbsp;</div>

然后,在textarea元素之后,我们需要删除以下div及其内容:

<div id="string_box">
    <button id="string_button" class="em_button">String Click!</button>
    <input id="string_input">
</div>

之后,我们有包含所有 JavaScript 代码的script标签。我们需要在该script标签中添加一些全局变量。首先,让我们添加一些布尔变量,告诉我们玩家是否按下了我们的任何箭头键。将所有这些值初始化为false,如下例所示:

var left_key_press = false;
var right_key_press = false;
var up_key_press = false;
var down_key_press = false;

在我们的key_press标志之后,我们将有所有将用于保存调用我们 WebAssembly 模块内函数的wrapper函数的wrapper变量。我们将所有这些包装器初始化为null。稍后,我们只会在这些函数不为null时调用这些函数。以下代码显示了我们的包装器:

var left_press_wrapper = null;
var left_release_wrapper = null;

var right_press_wrapper = null;
var right_release_wrapper = null;

var up_press_wrapper = null;
var up_release_wrapper = null;

var down_press_wrapper = null;
var down_release_wrapper = null;

现在我们已经定义了所有的全局变量,我们需要添加在key_presskey_release事件上触发的函数。其中之一是keyPress函数。我们为这个函数编写的代码如下:

function keyPress() {
    event.preventDefault();
    if( event.repeat === true ) {
        return;
    }

    // PRESS UP ARROW
    if (event.keyCode === 38) {
        up_key_press = true;
        if( up_press_wrapper != null ) up_press_wrapper();
    }

    // PRESS LEFT ARROW
    if (event.keyCode === 37) {
        left_key_press = true;
        if( left_press_wrapper != null ) left_press_wrapper();
    }

    // PRESS RIGHT ARROW
    if (event.keyCode === 39) {
        right_key_press = true;
        if( right_press_wrapper != null ) right_press_wrapper();
    }

    // PRESS DOWN ARROW
    if (event.keyCode === 40) {
        down_key_press = true;
        if( down_press_wrapper != null ) down_press_wrapper();
    }
}

这个函数的第一行是event.preventDefault();。这一行阻止了网页浏览器在用户按下相应键时通常会做的事情。例如,如果你正在玩游戏,并按下下箭头键使你的飞船向下移动,你不希望网页也滚动向下。在keyPress函数的开头放置这个preventDefault调用将禁用所有按键的默认行为。在其他项目中,这可能不是你想要的。如果你只想在按下下箭头键时禁用默认行为,你会将该调用放在管理下箭头键按下的if块内。以下代码块检查事件是否为重复事件:

if( event.repeat === true ) {
    return;
}

如果你按住其中一个键是正确的。例如,如果你按住上箭头键,你最初会得到一个上箭头键按下事件,但是,经过一段时间后,你会开始得到一个重复的上箭头键事件。你可能已经注意到,如果你曾经按住一个单一的键,比如F键,你会在你的文字处理器中看到一个 f,但是,一秒左右后你会开始看到 fffffffffffff,你会继续看到 f 重复进入你的文字处理器,只要你按住F键。一般来说,这种行为在使用文字处理器时可能是有帮助的,但在玩游戏时是有害的。前面的if块使我们在接收到重复按键事件时退出函数。

我们函数中的接下来的几个if块检查各种 JavaScript 键码,并根据这些键码调用我们的 WebAssembly 模块。让我们快速看一下当玩家按下上箭头键时会发生什么:

// PRESS UP ARROW
if (event.keyCode === 38) {
    up_key_press = true;
    if( up_press_wrapper != null ) up_press_wrapper();
}

if语句正在检查事件的键码是否等于值38,这是上箭头的键码值。您可以在www.embed.com/typescript-games/html-keycodes.html找到 HTML5 键码的列表。如果触发事件是上箭头键按下,我们将up_key_press变量设置为true。如果我们的up_press_wrapper已初始化,我们将调用它,它将调用 WebAssembly 模块内的press_up函数。在检查上箭头键码的if块之后,我们将需要更多的if块来检查其他箭头键,如下例所示:

    // PRESS LEFT ARROW
    if (event.keyCode === 37) {
        left_key_press = true;
        if( left_press_wrapper != null ) left_press_wrapper();
    }

    // PRESS RIGHT ARROW
    if (event.keyCode === 39) {
        right_key_press = true;
        if( right_press_wrapper != null ) right_press_wrapper();
    }

    // PRESS DOWN ARROW
    if (event.keyCode === 40) {
        down_key_press = true;
        if( down_press_wrapper != null ) down_press_wrapper();
    }
}

keyUp函数之后,我们需要创建一个非常相似的函数:keyRelease。这个函数与keyUp几乎相同,只是它将调用 WebAssembly 模块中的按键释放函数。以下代码显示了keyRelease()函数的样子:

function keyRelease() {
    event.preventDefault();

    // PRESS UP ARROW
    if (event.keyCode === 38) {
        up_key_press = false;
        if( up_release_wrapper != null ) up_release_wrapper();
    }

    // PRESS LEFT ARROW
    if (event.keyCode === 37) {
        left_key_press = false;
        if( left_release_wrapper != null ) left_release_wrapper();
    }

    // PRESS RIGHT ARROW
    if (event.keyCode === 39) {
        right_key_press = false;
        if( right_release_wrapper != null ) right_release_wrapper();
    }

    // PRESS DOWN ARROW
    if (event.keyCode === 40) {
        down_key_press = false;
        if( down_release_wrapper != null ) down_release_wrapper();
    }
}

在定义了这些函数之后,我们需要使用以下两行 JavaScript 代码将它们作为事件监听器:

document.addEventListener('keydown', keyPress);
document.addEventListener('keyup', keyRelease);

接下来我们需要修改我们的InitWrappers函数来包装我们之前创建的函数。我们使用Module.cwrap函数来实现这一点。我们的InitWrappers函数的新版本如下:

function InitWrappers() {
    left_press_wrapper = Module.cwrap('press_left', 'undefined');
    right_press_wrapper = Module.cwrap('press_right', 'undefined');
    up_press_wrapper = Module.cwrap('press_up', 'undefined');
    down_press_wrapper = Module.cwrap('press_down', 'undefined');

    left_release_wrapper = Module.cwrap('release_left', 'undefined');
    right_release_wrapper = Module.cwrap('release_right', 'undefined');
    up_release_wrapper = Module.cwrap('release_up', 'undefined');
    down_release_wrapper = Module.cwrap('release_down', 'undefined');
}

我们有两个不再需要的函数可以删除。这些是runbeforerunafter函数。这些函数在第二章的 shell 中使用,用来演示preRunpostRun模块功能。它们只是在控制台中记录一行,所以请从jskey_shell.html文件中删除以下代码:

function runbefore() {
    console.log("before module load");
}

function runafter() {
    console.log("after module load");
}

现在我们已经删除了这些行,我们可以从模块的preRunpostRun数组中删除对这些函数的调用。因为我们之前已经从 WebAssembly 模块的main函数中删除了对EM_ASM( InitWrappers() );的调用,所以我们需要从模块的postRun数组中运行InitWrappers。以下代码显示了这些更改后Module对象定义的开头是什么样子的:

preRun: [],
postRun: [InitWrappers],

现在我们应该构建和测试我们的新 JavaScript 键盘处理程序。运行以下emcc命令:

emcc jskey.c -o jskey.html  -s NO_EXIT_RUNTIME=1 --shell-file jskey_shell.html -s EXPORTED_FUNCTIONS="['_main', '_press_up', '_press_down', '_press_left', '_press_right', '_release_up', '_release_down', '_release_left', '_release_right']" -s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap', 'ccall']"

您会注意到我们使用了-s EXPORT_FUNCTIONS标志来导出所有的按键按下和按键释放函数。因为我们没有使用默认的 shell,我们使用了--shell-file jskey_shell.html标志。-s NO_EXIT_RUNTIME=1标志防止浏览器在没有 emscripten 主循环时退出 WebAssembly 模块。我们还使用-s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap', 'ccall']"导出了cwrapccall

以下是应用程序的屏幕截图:

图 5.1:jskey.html 的屏幕截图

重要的是要记住,应用程序必须从 Web 服务器运行,或者使用emrun。如果您不从 Web 服务器运行应用程序,或者使用emrun,当 JavaScript 粘合代码尝试下载 WASM 和数据文件时,您将收到各种错误。您还应该知道,IIS 需要额外的配置才能为.wasm.data文件扩展名设置正确的 MIME 类型。

在下一节中,我们将使用 SDL 事件处理程序和默认的 WebAssembly shell 来捕获和处理键盘事件。

向 WebAssembly 添加 SDL 键盘输入

SDL 允许我们轮询键盘输入。每当用户按下键时,调用SDL_PollEvent( &event )将返回一个SDK_KEYDOWN SDL_Event。当释放键时,它将返回一个SDK_KEYUP事件。在这种情况下,我们可以查看这些值,以确定哪个键被按下或释放。我们可以使用这些信息来设置游戏中的标志,以便在何时移动我们的飞船以及移动的方向。稍后,我们可以添加检测空格键按下的代码,以发射飞船的武器。

现在,我们将回到使用默认的 Emscripten shell。在本节的其余部分,我们将能够在 WebAssembly C 代码中完成所有操作。我将带你创建一个新的keyboard.c文件,从头开始处理键盘事件并在默认 shell 中打印到textarea

首先创建一个新的keyboard.c文件,并在文件顶部添加以下#include指令:

#include <SDL2/SDL.h>
#include <emscripten.h>
#include <stdio.h>
#include <stdbool.h>

之后,我们需要添加我们的全局SDL对象。前两个,SDL_WindowSDL_Renderer,现在应该看起来很熟悉。第三个,SDL_Event,是新的。我们将使用SDL_PollEvent在代码后期填充这个事件对象:

SDL_Window *window;
SDL_Renderer *renderer;
SDL_Event event;

和这段代码的 JavaScript 版本一样,我们将使用全局变量来跟踪我们当前按下的箭头键。这些都将是布尔变量,如下面的代码所示:

bool left_key_press = false;
bool right_key_press = false;
bool up_key_press = false;
bool down_key_press = false;

我们要定义的第一个函数是input_loop,但在我们定义该函数之前,我们需要声明input_loop将调用的两个函数,如下所示:

void key_press();
void key_release();

这将允许我们在实际定义input_loop调用这些函数之前定义input_loop函数。input_loop函数将调用SDL_PollEvent来获取一个事件对象。然后我们可以查看事件的类型,如果是SDL_KEYDOWNSDL_KEYUP事件,我们可以调用适当的函数来处理这些事件,如下所示:

void input_loop() {
    if( SDL_PollEvent( &event ) ){
        if( event.type == SDL_KEYDOWN ){
            key_press();
        }
        else if( event.type == SDL_KEYUP ) {
            key_release();
        }
    }
}

我们将定义的第一个函数是key_press()函数。在这个函数内部,我们将在 switch 中查看键盘事件,并将值与不同的箭头键 SDLK 事件进行比较。如果键之前是弹起状态,它会打印出一个消息,让我们知道用户按下了哪个键。然后我们应该将keypress标志设置为true。下面的示例展示了key_press()函数的全部内容:

void key_press() {
    switch( event.key.keysym.sym ){
        case SDLK_LEFT:
            if( !left_key_press ) {
                printf("left arrow key press\n");
            }
            left_key_press = true;
            break;

        case SDLK_RIGHT:
            if( !right_key_press ) {
                printf("right arrow key press\n");
            }
            right_key_press = true;
            break;

        case SDLK_UP:
            if( !up_key_press ) {
                printf("up arrow key press\n");
            }
            up_key_press = true;
            break;

        case SDLK_DOWN:
            if( !down_key_press ) {
                printf("down arrow key press\n");
            }
            down_key_press = true;
            break;

        default:
            printf("unknown key press\n");
            break;
    }
}

key_press函数内的第一行是一个 switch 语句,switch(event.key.keysym.sym)。这些都是结构中的结构。在input_loop函数内,我们调用了SDL_PollEvent,传递了一个SDL_Event结构的引用。这个结构包含了可能返回给我们的任何事件的事件数据,以及一个告诉我们这是什么类型事件的类型。如果类型是SDL_KEYDOWNSDL_KEYUP,那意味着内部的key结构,它是一个SDL_KeyboardEvent类型的结构,被填充了。如果你想看SDL_Event结构的完整定义,你可以在 SDL 网站上找到它:wiki.libsdl.org/SDL_Event。在SDL_Event内部的 key 变量,你会注意到它是一个SDL_KeyboardEvent类型的结构。这个结构里有很多我们暂时不会用到的数据。它包括时间戳、这个键是否是重复按下的,或者这个键是被按下还是被释放;但是我们在 switch 语句中关注的是keysym变量,它是一个SDL_Keysym类型的结构。关于SDL_KeyboardEvent的更多信息,你可以在 SDL 网站上找到它的定义:wiki.libsdl.org/SDL_KeyboardEventSDL_KeyboardEvent结构中的keysym变量是你会在sym变量中找到SDL_Keycode的地方。这个键码是我们必须查看的,以确定玩家按下了哪个键。这就是为什么我们在switch( event.key.keysym.sym )周围构建了 switch 语句。SDL 键码的所有可能值的链接可以在这里找到:wiki.libsdl.org/SDL_Keycode

我们在 switch 语句中的所有 case 语句看起来非常相似:如果按下给定的 SDLK 键码,我们会检查上一个周期是否按下了该键,并且仅在其未按下时打印出该值。然后我们将keypress标志设置为true。以下示例显示了我们检测左箭头键按下的代码:

case SDLK_LEFT:
    if( !left_key_press ) {
        printf("left arrow key press\n");
    }
    left_key_press = true;
    break;

当事件类型为SDL_KEYUP时,我们的应用程序调用key_release函数。这与key_down函数非常相似。主要区别在于它是在查看用户是否按下按键,并且仅在状态变为未按下时打印消息。以下示例展示了该函数的全部内容:

void key_release() {
    switch( event.key.keysym.sym ){

        case SDLK_LEFT:
            if( left_key_press ) {
                printf("left arrow key release\n");
            }
            left_key_press = false;
            break;

        case SDLK_RIGHT:
            if( right_key_press ) {
                printf("right arrow key release\n");
            }
            right_key_press = false;
            break;

        case SDLK_UP:
            if( up_key_press ) {
                printf("up arrow key release\n");
            }
            up_key_press = false;
            break;

        case SDLK_DOWN:
            if( down_key_press ) {
                printf("down arrow key release\n");
            }
            down_key_press = false;
            break;

        default:
            printf("unknown key release\n");
            break;
    }
}

我们的最后一个函数是main函数的新版本,在加载我们的Module时调用。我们仍然需要使用emscripten_set_main_loop来防止我们的代码占用 JavaScript 引擎。我们创建了一个我们之前定义的input_loop。它使用 SDL 来轮询键盘事件。但是,在此之前,我们仍然需要进行 SDL 初始化。我们使用 Emscripten 默认 shell,因此调用SDL_CreateWindowAndRenderer将设置我们的canvas元素的宽度和高度。我们不会在input_loop中渲染canvas元素,但是我们仍希望在此处进行初始化,因为在下一节中,我们将调整此代码以将太空船图像渲染到画布上,并使用按键移动它。以下代码显示了我们的main函数的新版本将是什么样子:

int main() {
    SDL_Init( SDL_INIT_VIDEO );

    SDL_CreateWindowAndRenderer( 320, 200, 0, &window, &renderer );
    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );

    SDL_RenderClear( renderer );
    SDL_RenderPresent( renderer );

    emscripten_set_main_loop(input_loop, 0, 0);
    return 1;
}

现在我们已经将所有代码放入了keyboard.c文件中,我们可以使用以下emcc命令编译我们的keyboard.c文件:

emcc keyboard.c -o keyboard.html -s USE_SDL=2

当您在浏览器中运行keyboard.html时,您会注意到按下箭头键会导致消息打印到 Emscripten 默认 shell 的文本区域。

考虑以下屏幕截图:

图 5.2:keyboard.html 的屏幕截图

在接下来的部分,我们将学习如何使用键盘输入来移动精灵在画布上移动。

使用键盘输入移动精灵

现在我们知道如何获取键盘输入并在我们的 WebAssembly 模块中使用它,让我们想想如何将键盘输入用于在 HTML 画布上移动我们的太空船精灵。让我们从Chapter04目录中复制sprite_move.cChapter05目录中。这将给我们一个很好的起点。现在我们可以开始修改代码。我们需要在我们的.c文件开头添加一个#include。因为我们需要布尔变量,所以我们必须添加#include <stdbool.h>。现在我们的.c文件的新开头将如下所示:

#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>
#include <emscripten.h>
#include <stdio.h>
#include <stdbool.h>

之后,所有的#define指令将保持与sprite_move.c文件中的内容相同,如下面的代码所示:

#define SPRITE_FILE "sprites/Franchise1.png"
#define ANIM_FILE "sprites/Franchise%d.png"
#define FRAME_COUNT 4

sprite_move.c文件中有几个全局变量,我们将继续在keyboard_move.c中使用。不要删除这些变量中的任何一个;我们只会添加到它们中:

int current_frame = 0;

Uint32 last_time;
Uint32 current_time;
Uint32 ms_per_frame = 100; // animate at 10 fps

SDL_Window *window;
SDL_Renderer *renderer;
SDL_Rect dest = {.x = 160, .y = 100, .w = 0, .h = 0 };

SDL_Texture *sprite_texture;
SDL_Texture *temp_texture;
SDL_Texture* anim[FRAME_COUNT];

现在我们需要从keyboard.c文件中引入一些变量,这些变量在上一节中使用过。我们需要SDL_Event全局变量,以便我们有东西传递给我们对SDL_PollEvent的调用,并且我们需要我们的布尔键按下标志,如下所示:

SDL_Event event;

bool left_key_press = false;
bool right_key_press = false;
bool up_key_press = false;
bool down_key_press = false;

然后是函数声明,允许我们在定义input_loop函数之后定义key_presskey_release函数,如下例所示:

void key_press();
void key_release();

接下来,我们将从我们的keyboard.c文件中引入input_loop函数。这是我们用来调用SDL_PollEvent的函数,并根据返回的事件类型调用key_presskey_release。这个函数与我们在keyboard.c中的版本保持不变,如下例所示:

void input_loop() {
    if( SDL_PollEvent( &event ) ){
        if( event.type == SDL_KEYDOWN ){
            key_press();
        }
        else if( event.type == SDL_KEYUP ) {
            key_release();
        }
    }
}

key_presskey_release函数跟随input_loop函数,并且与keyboard.c版本保持不变。这些函数的主要目的是设置按键标志。printf语句现在是不必要的,但我们将它们留在那里。这对性能来说并不是一件好事,因为继续在我们的textarea中添加每次按键按下和释放的行最终会减慢我们的游戏速度,但是,此时,我觉得最好还是为了演示目的将这些语句留在那里:

void key_press() {
    switch( event.key.keysym.sym ){

        case SDLK_LEFT:
            if( !left_key_press ) {
                printf("left arrow key press\n");
            }
            left_key_press = true;
            break;

        case SDLK_RIGHT:
            if( !right_key_press ) {
                printf("right arrow key press\n");
            }
            right_key_press = true;
            break;

        case SDLK_UP:
            if( !up_key_press ) {
                printf("up arrow key press\n");
            }
            up_key_press = true;
            break;

        case SDLK_DOWN:
            if( !down_key_press ) {
                printf("down arrow key press\n");
            }
            down_key_press = true;
            break;

        default:
            printf("unknown key press\n");
            break;
    }
}

void key_release() {
    switch( event.key.keysym.sym ){

        case SDLK_LEFT:
            if( left_key_press ) {
                printf("left arrow key release\n");
            }
            left_key_press = false;
            break;

        case SDLK_RIGHT:
            if( right_key_press ) {
                printf("right arrow key release\n");
            }
            right_key_press = false;
            break;

        case SDLK_UP:
            if( up_key_press ) {
                printf("up arrow key release\n");
            }
            up_key_press = false;
            break;

        case SDLK_DOWN:
            if( down_key_press ) {
                printf("down arrow key release\n");
            }
            down_key_press = false;
            break;

        default:
            printf("unknown key release\n");
            break;
    }
}

keyboard_move.c文件中的下一个函数将是show_animation。这个函数需要与sprite_move.c中的版本有显著的改变,以便玩家可以控制飞船并在画布上移动它。在我们逐步讲解之前,以下示例展示了新函数的全部内容:

void show_animation() {
    input_loop();

    current_time = SDL_GetTicks();
    int ms = current_time - last_time;

    if( ms >= ms_per_frame) {
        ++current_frame;
        last_time = current_time;
    }

    if( current_frame >= FRAME_COUNT ) {
        current_frame = 0;
    }

    SDL_RenderClear( renderer );
    temp_texture = anim[current_frame];

    if( up_key_press ) {
        dest.y--;

        if( dest.y < -16 ) {
            dest.y = 200;
        }
    }

    if( down_key_press ) {
        dest.y++;

        if( dest.y > 200 ) {
            dest.y = -16;
        }
    }

    if( left_key_press ) {
        dest.x--;

        if( dest.x < -16 ) {
            dest.x = 320;
        }
    }

    if( right_key_press ) {
        dest.x++;

        if( dest.x > 320 ) {
            dest.x = -16;
        }
    }

    SDL_RenderCopy( renderer, temp_texture, NULL, &dest );
    SDL_RenderPresent( renderer );
}

我们将show_animation中的第一行添加到这个函数的新版本中。调用input_loop用于在每帧设置按键按下标志。在调用input_loop之后,有一大块代码,我们没有从sprite_move.c文件中更改,如下例所示:

current_time = SDL_GetTicks();
int ms = current_time - last_time;

if( ms >= ms_per_frame) {
    ++current_frame;
    last_time = current_time;
}

if( current_frame >= FRAME_COUNT ) {
    current_frame = 0;
}

SDL_RenderClear( renderer );
temp_texture = anim[current_frame];

这段代码调用SDL_GetTicks()来获取当前时间,然后从上一次当前帧更改的时间中减去当前时间,以获取自上次帧更改以来的毫秒数。如果自上次帧更改以来的毫秒数大于我们希望停留在任何给定帧上的毫秒数,我们需要推进当前帧。一旦我们弄清楚了是否推进了当前帧,我们需要确保当前帧不超过我们的帧数。如果超过了,我们需要将其重置为0。之后,我们需要清除我们的渲染器,并将我们使用的纹理设置为与当前帧对应的动画数组中的纹理。

sprite_move.c中,我们使用以下几行代码将飞船的y坐标每帧向上移动一个像素:

dest.y--;

if( dest.y < -16 ) {
    dest.y = 200;
}

在新的键盘应用程序中,我们只希望在玩家按下上箭头键时改变我们的y坐标。为此,我们必须将改变y坐标的代码放在一个检查up_key_press标志的if块中。以下是该代码的新版本:

if( up_key_press ) {
    dest.y--;

    if( dest.y < -16 ) {
        dest.y = 200;
    }
}

我们还需要添加代码,当玩家按下其他箭头键时移动飞船。根据玩家当前按下的键,以下代码将使飞船向下、向左或向右移动:

if( down_key_press ) {
    dest.y++;

    if( dest.y > 200 ) {
        dest.y = -16;
    }
}

if( left_key_press ) {
    dest.x--;

    if( dest.x < -16 ) {
        dest.x = 320;
    }
}

if( right_key_press ) {
    dest.x++;

    if( dest.x > 320 ) {
        dest.x = -16;
    }
}

最后,我们必须渲染纹理并呈现它,如下所示:

SDL_RenderCopy( renderer, temp_texture, NULL, &dest );
SDL_RenderPresent( renderer );

main函数不会从sprite_move.c中的版本改变,因为初始化没有改变。以下代码显示了keyboard_move.c中的main函数:

int main() {
    char explosion_file_string[40];

    SDL_Init( SDL_INIT_VIDEO );
    SDL_CreateWindowAndRenderer( 320, 200, 0, &window, &renderer );
    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );

    SDL_Surface *temp_surface = IMG_Load( SPRITE_FILE );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return 0;
    }

    sprite_texture = SDL_CreateTextureFromSurface( renderer, temp_surface );

    SDL_FreeSurface( temp_surface );

    for( int i = 1; i <= FRAME_COUNT; i++ ) {
        sprintf( explosion_file_string, ANIM_FILE, i );
        SDL_Surface *temp_surface = IMG_Load( explosion_file_string );

        if( !temp_surface ) {
            printf("failed to load image: %s\n", IMG_GetError() );
            return 0;
        }

        temp_texture = SDL_CreateTextureFromSurface( renderer, temp_surface );
        anim[i-1] = temp_texture;
        SDL_FreeSurface( temp_surface );
    }

    SDL_QueryTexture( sprite_texture,
                        NULL, NULL,
                        &dest.w, &dest.h ); // query the width and height

    dest.x -= dest.w / 2;
    dest.y -= dest.h / 2;

    SDL_RenderCopy( renderer, sprite_texture, NULL, &dest );
    SDL_RenderPresent( renderer );

    last_time = SDL_GetTicks();
    emscripten_set_main_loop(show_animation, 0, 0);
    return 1;
}

正如我之前所说,这段代码是我们在第四章中编写的最后一个应用程序的结合,使用 SDL 在 WebAssembly 中进行精灵动画,以及我们在将 SDL 键盘输入添加到 WebAssembly部分编写的代码,我们在那里从键盘接收输入并使用printf语句记录我们的按键。我们保留了input_loop函数,并在show_animation函数的开头添加了对它的调用。在show_animation内部,我们不再在每一帧移动飞船一像素,而是只有在按下上箭头键时才移动飞船。同样,当用户按下左箭头键时,我们向左移动飞船,当按下右箭头键时,我们向右移动飞船,当用户按下下箭头键时,我们向下移动飞船。

现在我们有了新的keyboard_move.c文件,让我们编译它并尝试一下我们的新移动飞船。运行以下emcc命令来编译代码:

emcc keyboard_move.c -o keyboard_move.html --preload-file sprites -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"]

我们需要添加--preload-file sprites标志,以指示我们希望在虚拟文件系统中包含 sprites 文件夹。我们还需要添加-s USE_SDL=2-s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"]标志,以允许我们从虚拟文件系统加载.png文件。一旦你编译了keyboard_move.html,将其加载到浏览器中,并使用箭头键在画布上移动飞船。请参阅以下截图:

图 5.3:keyboard_move.html 的键盘移动截图

摘要

在本章中,我们学习了如何获取用于 WebAssembly 的键盘输入。有两种主要方法。我们可以在 JavaScript 端接收键盘输入,并通过使用Module.cwrap制作的包装器与 WebAssembly 进行通信,或者直接通过Module.ccall调用 WebAssembly 函数。在 WebAssembly 中接受键盘输入的另一种方法是使用 SDL 键盘输入事件。当我们使用这种方法时,我们可以使用默认的 Emscripten shell。使用 SDL 事件的这种第二种方法将是本书其余部分中我们首选的方法。

在下一章中,我们将更多地了解游戏循环以及我们将如何在我们的游戏中使用它,以及一般的游戏。

第六章:游戏对象和游戏循环

在本章中,我们将开始构建游戏的框架。所有游戏都有游戏对象游戏循环。游戏循环存在于每个游戏中。一些工具,比如 Unity,尽最大努力抽象出游戏循环,以便开发人员不一定需要知道它的存在,但即使在这些情况下,它仍然存在。所有游戏都必须对操作系统或硬件的渲染能力进行一定的控制,并在游戏运行时向屏幕绘制图像。游戏的所有工作都在一个大循环中完成。游戏对象可以是面向对象编程OOP)语言(如 C++)中的类的实例,也可以是过程式语言(如 C)中的松散变量或结构的集合。在本章中,我们将学习如何设计游戏循环,并从 C++编译成WebAssembly中学习我们游戏对象的早期版本。

您需要在构建中包含几个图像才能使此项目工作。确保您包含了项目的 GitHub 存储库中的/Chapter06-game-object/sprites/文件夹。如果您还没有下载 GitHub 项目,可以在这里在线获取:github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly

在本章中,我们将涵盖以下主题:

  • 游戏循环

  • 对象池

  • 玩家游戏对象

  • 敌人游戏对象

  • 抛射物

理解游戏循环

游戏设计中的一个关键概念是游戏循环。在任何游戏中,代码必须一遍又一遍地运行,执行一系列任务,如输入、人工智能、物理和渲染。游戏循环可能看起来像这样:

while(loop_forever) {
    get_user_input();
    move_game_objects();
    collision_detection();
    render_game_objects();
    play_audio();
}

一个针对几乎任何平台的 SDL/C++游戏会有一个while循环,可能位于 C++代码的main函数中,只有当玩家退出游戏时才会退出。WebAssembly 与您的 Web 浏览器内部的 JavaScript 引擎共享运行时。JavaScript 引擎在单个线程上运行,Emscripten 使用 JavaScript 的glue code将您在 WebAssembly 中的 SDL 内部所做的工作渲染到 HTML 画布元素上。因此,我们需要使用一个特定于 Emscripten 的代码片段来实现我们的游戏循环:

emscripten_set_main_loop(game_loop, 0, 0);

在接下来的几章中,我们将向我们的游戏中添加一些这些函数:

  • 游戏对象管理

  • 游戏对象之间的碰撞检测

  • 粒子系统

  • 使用有限状态机FSM)的敌人飞船 AI

  • 用于跟踪玩家的游戏摄像机

  • 播放音频和音效

  • 游戏物理

  • 用户界面

这些将是从游戏循环中调用的函数。

编写基本游戏循环

在某种程度上,我们已经有了一个简单的游戏循环,尽管我们没有显式地创建一个名为game_loop的函数。我们将修改我们的代码,以便有一个更明确的游戏循环,将分离inputmoverender函数。此时,我们的main函数变成了一个初始化函数,最后使用 Emscripten 来设置游戏循环。这个新应用的代码比之前的应用要大。让我们首先以高层次的方式浏览代码,介绍每个部分。然后我们将详细介绍代码的每个部分。

我们从#include#define预处理宏开始编写代码:

#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>
#include <emscripten.h>
#include <stdio.h>
#include <stdbool.h>
#include <math.h>

#define SPRITE_FILE "sprites/Franchise.png"
#define PI 3.14159
#define TWO_PI 6.28318
#define MAX_VELOCITY 2.0

在预处理宏之后,我们有一些全局时间变量:

Uint32 last_time;
Uint32 last_frame_time;
Uint32 current_time;

然后我们将定义几个与 SDL 相关的全局变量:

SDL_Window *window;
SDL_Renderer *renderer;
SDL_Rect dest = {.x = 160, .y = 100, .w = 16, .h = 16 };
SDL_Texture *sprite_texture;
SDL_Event event;

在我们的 SDL 全局变量之后,我们有一个键盘标志块:

bool left_key_down = false;
bool right_key_down = false;
bool up_key_down = false;
bool down_key_down = false;

最后的全局变量跟踪玩家数据:

float player_x = 160.0;
float player_y = 100.0;
float player_rotation = PI;
float player_dx = 0.0;
float player_dy = 1.0;
float player_vx = 0.0;
float player_vy = 0.0;
float delta_time = 0.0;

现在我们已经定义了所有的全局变量,我们需要两个函数来使玩家的飞船向左和向右旋转:


void rotate_left() {
    player_rotation -= delta_time;
    if( player_rotation < 0.0 ) {
        player_rotation += TWO_PI;
    }
    player_dx = sin(player_rotation);
    player_dy = -cos(player_rotation);
}

void rotate_right() {
    player_rotation += delta_time;
    if( player_rotation >= TWO_PI ) {
        player_rotation -= TWO_PI;
    }
    player_dx = sin(player_rotation);
    player_dy = -cos(player_rotation);
}

然后我们有三个与玩家飞船相关的移动函数。我们使用它们来加速和减速我们的飞船,并限制我们飞船的速度:


void accelerate() {
    player_vx += player_dx * delta_time;
    player_vy += player_dy * delta_time;
}

void decelerate() {
    player_vx -= (player_dx * delta_time) / 2.0;
    player_vy -= (player_dy * delta_time) / 2.0;
}

void cap_velocity() {
    float vel = sqrt( player_vx * player_vx + player_vy * player_vy );
    if( vel > MAX_VELOCITY ) {
        player_vx /= vel;
        player_vy /= vel;
        player_vx *= MAX_VELOCITY;
        player_vy *= MAX_VELOCITY;
    }
}

move函数执行游戏对象的高级移动:


void move() {
    current_time = SDL_GetTicks();
    delta_time = (float)(current_time - last_time) / 1000.0;
    last_time = current_time;

    if( left_key_down ) {
        rotate_left();
    }
    if( right_key_down ) {
        rotate_right();
    }
    if( up_key_down ) {
        accelerate();
    }
    if( down_key_down ) {
        decelerate();
    }
    cap_velocity();

    player_x += player_vx;

    if( player_x > 320 ) {
        player_x = -16;
    }
    else if( player_x < -16 ) {
        player_x = 320;
    }

    player_y += player_vy;

    if( player_y > 200 ) {
        player_y = -16;
    }
    else if( player_y < -16 ) {
        player_y = 200;
    }
} 

input函数确定键盘输入状态并设置我们的全局键盘标志:


void input() {
    if( SDL_PollEvent( &event ) ){
        switch( event.type ){
            case SDL_KEYDOWN:
                switch( event.key.keysym.sym ){
                    case SDLK_LEFT:
                        left_key_down = true;
                        break;
                    case SDLK_RIGHT:
                        right_key_down = true;
                        break;
                    case SDLK_UP:
                        up_key_down = true;
                        break;
                    case SDLK_DOWN:
                        down_key_down = true;
                        break;
                    default:
                        break;
                }
                break;
            case SDL_KEYUP:
                switch( event.key.keysym.sym ){
                    case SDLK_LEFT:
                        left_key_down = false;
                        break;
                    case SDLK_RIGHT:
                        right_key_down = false;
                        break;
                    case SDLK_UP:
                        up_key_down = false;
                        break;
                    case SDLK_DOWN:
                        down_key_down = false;
                        break;
                    default:
                        break;
                }
                break;

            default:
                break;
        }
    }
}

render函数将玩家的精灵绘制到画布上:

void render() {
    SDL_RenderClear( renderer );

    dest.x = player_x;
    dest.y = player_y;

    float degrees = (player_rotation / PI) * 180.0;
    SDL_RenderCopyEx( renderer, sprite_texture,
                        NULL, &dest,
    degrees, NULL, SDL_FLIP_NONE );

    SDL_RenderPresent( renderer );
 }

game_loop函数在每一帧中运行我们所有的高级游戏对象:

void game_loop() {
    input();
    move();
    render();
}

与往常一样,main函数执行所有初始化:

int main() {
    char explosion_file_string[40];
    SDL_Init( SDL_INIT_VIDEO );
    SDL_CreateWindowAndRenderer( 320, 200, 0, &window, &renderer );
    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );
    SDL_Surface *temp_surface = IMG_Load( SPRITE_FILE );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return 0;
    }

    sprite_texture = SDL_CreateTextureFromSurface( renderer, 
                                                  temp_surface );
    SDL_FreeSurface( temp_surface );
    last_frame_time = last_time = SDL_GetTicks();

    emscripten_set_main_loop(game_loop, 0, 0);
    return 1;
}

在前面的代码中,您可能已经注意到我们添加了大量全局变量来定义特定于玩家的值:

float player_x = 160.0;
float player_y = 100.0;
float player_rotation = PI;
float player_dx = 0.0;
float player_dy = 1.0;
float player_vx = 0.0;
float player_vy = 0.0;

在“游戏对象”部分,我们将开始创建游戏对象并将这些值从全局定义移动到对象中,但是目前,将它们作为全局变量将起作用。我们正在添加移动玩家飞船的能力,这与经典街机游戏“Asteroids”类似。在我们游戏的最终版本中,我们将有两艘太空飞船进行决斗。为此,我们需要跟踪飞船的“x”和“y”坐标以及飞船的旋转;player_dxplayer_dy组成了我们太空飞船的归一化方向向量。

player_vxplayer_vy变量分别是玩家当前的xy速度。

我们不再让左右键在按住时移动飞船向左或向右,而是让这些键将飞船向左或向右转动。为此,我们的输入函数将调用rotate_leftrotate_right函数:

void rotate_left() {
    player_rotation -= delta_time;
    if( player_rotation < 0.0 ) {
        player_rotation += TWO_PI;
    }
    player_dx = sin(player_rotation);
    player_dy = -cos(player_rotation);
}

void rotate_right() {
    player_rotation += delta_time;
    if( player_rotation >= TWO_PI ) {
         player_rotation -= TWO_PI;
    }
    player_dx = sin(player_rotation);
    player_dy = -cos(player_rotation);
}

如果玩家正在向左转,我们会从玩家旋转中减去delta_time变量,这是自上一帧渲染以来的秒数。 player_rotation变量是玩家的弧度旋转,其中 180 度=π(3.14159…)。这意味着玩家可以通过按住左或右箭头约三秒钟来旋转 180 度。如果玩家的旋转低于 0 或玩家的旋转超过 2π(360 度),我们还必须纠正我们的旋转。如果您不熟悉弧度,它是一种替代的角度测量系统,其中一个圆中有 360 度。使用弧度,您可以考虑您需要绕单位圆的周长走多远才能到达该角度。半径为 1 的圆称为单位圆

单位圆在左侧:

单位圆和半径为 2 的圆

圆的直径公式是 2πr(在我们的代码中是2 * PI * radius)。因此,弧度中的 2π等同于 360 度。大多数游戏引擎和数学库在旋转精灵时使用弧度而不是度,但由于某种原因 SDL 在旋转精灵时使用度,因此我们需要在渲染游戏对象时将我们的弧度旋转回度(呸!)。

只是为了确保每个人都在跟着我,我们的代码中,PI宏保存了一个近似值,即定义为圆的直径与其周长的比值的π。 π的典型近似值为 3.14,尽管在我们的代码中,我们将π近似为 3.14159。

如果玩家按下键盘上的上下键,我们还需要加速或减速飞船。为此,我们将创建acceleratedecelerate函数,当玩家按住上下键时调用这些函数:

void accelerate() {
    player_vx += player_dx * delta_time;
    player_vy += player_dy * delta_time;
}

void decelerate() {
    player_vx -= (player_dx * delta_time) / 2.0;
    player_vy -= (player_dy * delta_time) / 2.0;
}

这两个函数都使用了使用我们的旋转函数中的sin-cos计算出的player_dxplayer_dy变量,并使用这些值来添加到存储在player_vxplayer_vy变量中的玩家的xy速度。我们将该值乘以delta_time,这将使我们的加速度设置为每秒 1 像素。我们的减速函数将该值除以 2,这将使我们的减速率设置为每秒 0.5 像素。

在定义了“加速”和“减速”函数之后,我们需要创建一个函数,将我们的飞船的xy速度限制为每秒 2.0 像素:

void cap_velocity() {
    float vel = sqrt( player_vx * player_vx + player_vy * player_vy );

    if( vel > MAX_VELOCITY ) {
        player_vx /= vel;
        player_vy /= vel;
        player_vx *= MAX_VELOCITY;
        player_vy *= MAX_VELOCITY;
     }
}

这不是定义这个函数的最有效方式,但这是最容易理解的方式。第一行确定了我们速度向量的大小。如果你不知道这意味着什么,让我更好地解释一下。我们有一个沿着x轴的速度。我们也有一个沿着y轴的速度。我们想要限制总速度。如果我们分别限制xy的速度,我们将能够通过对角线行进更快。为了计算我们的总速度,我们需要使用毕达哥拉斯定理(你还记得高中的三角学吗?)。如果你不记得了,当你有一个直角三角形时,要计算它的斜边,你需要取另外两条边的平方和的平方根(记得A² + B² = C²吗?):

[使用毕达哥拉斯定理来确定使用 x 和 y 速度的速度大小]

因此,为了计算我们的总速度,我们需要对x速度进行平方,对y速度进行平方,然后将它们加在一起,然后取平方根。在这一点上,我们将我们的速度与MAX_VELOCITY值进行比较,我们已经将其定义为2.0。如果当前速度大于这个最大速度,我们需要调整我们的xy速度,使其达到2的值。我们通过将xy速度都除以总速度,然后乘以MAX_VELOCITY来实现这一点。

最终我们需要编写一个move函数,它将移动所有游戏对象,但目前我们只会移动玩家的太空飞船:

void move() {
    current_time = SDL_GetTicks();
    delta_time = (float)(current_time - last_time) / 1000.0;
    last_time = current_time;

    if( left_key_down ) {
        rotate_left();
    }

    if( right_key_down ) {
        rotate_right();
    }

    if( up_key_down ) {
        accelerate();
    }

    if( down_key_down ) {
        decelerate();
    }

    cap_velocity();
    player_x += player_vx;

    if( player_x > 320 ) {
         player_x = -16;
     }
    else if( player_x < -16 ) {
        player_x = 320;
    }
    player_y += player_vy;

    if( player_y > 200 ) {
        player_y = -16;
    }
    else if( player_y < -16 ) {
        player_y = 200;
    }
}

我们需要做的第一件事是获取这一帧的当前时间,然后将其与我们之前的帧时间结合起来计算delta_timedelta_time变量是自上一帧时间以来的时间量(以秒为单位)。我们需要将许多移动和动画与这个值联系起来,以获得一个与任何给定计算机的帧速率无关的一致的游戏速度。之后,我们需要根据我们在input函数中设置的标志来旋转、加速或减速我们的太空飞船。然后我们限制我们的速度,并使用xy值来修改玩家太空飞船的xy坐标。

move函数中,我们使用了一系列标志,告诉我们当前是否按住了键盘上的特定键。为了设置这些标志,我们需要一个input函数,它使用SDL_PollEvent来查找键盘事件,并相应地设置标志:


void input() {
    if( SDL_PollEvent( &event ) ){
        switch( event.type ){
            case SDL_KEYDOWN:
                switch( event.key.keysym.sym ){
                    case SDLK_LEFT:
                        left_key_down = true;
                        break;
                    case SDLK_RIGHT:
                        right_key_down = true;
                        break;
                    case SDLK_UP:
                        up_key_down = true;
                        break;
                    case SDLK_DOWN:
                        down_key_down = true;
                        break;
                    default:
                        break;
                }
                break;
            case SDL_KEYUP:
                switch( event.key.keysym.sym ){
                    case SDLK_LEFT:
                        left_key_down = false;
                        break;
                    case SDLK_RIGHT:
                        right_key_down = false;
                        break;
                    case SDLK_UP:
                        up_key_down = false;
                        break;
                    case SDLK_DOWN:
                        down_key_down = false;
                        break;
                    default:
                        break;
                }
                break;
            default:
                break;
        }
    }
}

这个函数包括一些switch语句,用于查找箭头键的按下和释放。如果按下箭头键之一,我们将相应的标志设置为true;如果释放了一个键,我们将该标志设置为false

接下来,我们定义render函数。这个函数目前渲染了我们的太空飞船精灵,并最终会渲染所有精灵到 HTML 画布上:

void render() {
    SDL_RenderClear( renderer );
    dest.x = player_x;
    dest.y = player_y;
    float degrees = (player_rotation / PI) * 180.0;
    SDL_RenderCopyEx( renderer, sprite_texture,
                        NULL, &dest,
                        degrees, NULL, SDL_FLIP_NONE );
    SDL_RenderPresent( renderer );
}

这个函数清除 HTML 画布,将目的地xy值设置为player_xplayer_y,计算玩家的旋转角度,然后将该精灵渲染到画布上。我们用一个调用SDL_RenderCopyEx替换了之前的SDL_RenderCopy调用。这个新函数允许我们传入一个值,旋转我们的太空飞船的精灵。

在我们定义了render函数之后,我们有了新的game_loop函数:

void game_loop() {
    input();
    move();
    render();
}

这个函数将被emscripten_set_main_loop从我们的main函数中调用。这个函数在渲染的每一帧都会运行,并负责管理游戏中发生的所有活动。它目前调用我们在游戏代码中之前定义的inputmoverender函数,将来还会调用我们的 AI 代码、音效、物理代码等。

编译 gameloop.html

现在我们已经编写了我们的代码,可以继续编译我们的游戏循环应用程序。在运行此命令之前,我想重申,您需要从 GitHub(github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly)下载项目,因为您需要在/Chapter06-game-loop/sprites文件夹中找到 PNG 文件才能构建此项目。

一旦您正确设置了文件夹,使用以下命令编译应用程序:

emcc game_loop.c -o gameloop.html  --preload-file sprites -s NO_EXIT_RUNTIME=1 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap', 'ccall']" -s USE_SDL=2

使用 Web 服务器提供您编译的目录,或者使用 emrun 构建和运行它,加载到 Web 浏览器中时应该是这样的:

游戏循环的屏幕截图

重要的是要记住,必须使用 WebAssembly 应用程序使用 Web 服务器或emrun运行。如果您想使用emrun运行 WebAssembly 应用程序,必须使用--emrun标志进行编译。Web 浏览器需要一个 Web 服务器来流式传输 WebAssembly 模块。如果您尝试直接从硬盘驱动器在浏览器中打开使用 WebAssembly 的 HTML 页面,那么 WebAssembly 模块将无法加载。

应用程序编译完成后,您应该能够使用箭头键在画布上移动太空飞船。现在我们有了一个基本的游戏循环,在下一节中,我们将向我们的应用程序添加一些游戏对象,使其更像一个游戏。

游戏对象

到目前为止,我们的方法完全是过程化的,并且编码方式可以用 C 而不是 C++编写。开发人员长期以来一直在用 C 甚至汇编语言编写游戏,因此从代码管理的角度来看,面向对象的游戏设计并不是绝对必要的,但是从代码管理的角度来看,面向对象编程是设计和编写游戏的一种很好的方式。游戏对象可以帮助我们通过对象池管理我们分配的内存。此时,开始将程序分解成多个文件也是有意义的。我的方法是有一个单独的.hpp文件来定义所有的游戏对象,以及一个.cpp文件来定义每个对象。

玩家的太空飞船游戏对象

到目前为止,我们一直在全局变量中保存跟踪玩家飞船的所有值。从组织的角度来看,这并不理想。我们将创建的第一个游戏对象将是玩家的飞船对象。我们将从一个基本类开始,稍后再向我们的代码中添加更多面向对象的特性。

这是我们新的头文件game.hpp的代码:

#ifndef __GAME_H__
#define __GAME_H__#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>
#include <emscripten.h>
#include <stdio.h>
#include <stdbool.h>
#include <math.h>
#include <string>
#include <vector>

#define SPRITE_FILE "sprites/Franchise.png"
#define MAX_VELOCITY 2.0
#define PI 3.14159
#define TWO_PI 6.28318

extern Uint32 last_time;
extern Uint32 last_frame_time;
extern Uint32 current_time;
extern SDL_Window *window;
extern SDL_Renderer *renderer;
extern SDL_Rect dest;
extern SDL_Texture *sprite_texture;
extern SDL_Event event;
extern bool left_key_down;
extern bool right_key_down;
extern bool up_key_down;
extern bool down_key_down;
extern bool space_key_down;
extern float delta_time;
extern int diff_time;

class PlayerShip {
    public:
        float m_X;
        float m_Y;
        float m_Rotation;
        float m_DX;
        float m_DY;
        float m_VX;
        float m_VY;

        PlayerShip();
        void RotateLeft();
        void RotateRight();
        void Accelerate();
        void Decelerate();
        void CapVelocity();
        void Move();
        void Render();
};

extern PlayerShip player;
#endif

我们所有的 CPP 文件都将包括这个game.hpp头文件。这个文件的前几行是为了确保我们不会多次包含这个文件。然后我们定义了我们在旧的 C 文件中定义的所有全局变量:

extern Uint32 last_time;
extern Uint32 last_frame_time;
extern Uint32 current_time;
extern SDL_Window *window;
extern SDL_Renderer *renderer;
extern SDL_Rect dest;
extern SDL_Texture *sprite_texture;
extern SDL_Event event;
extern bool left_key_down;
extern bool right_key_down;
extern bool up_key_down;
extern bool down_key_down;
extern float delta_time;

在头文件中,我们不会在堆上分配空间。在全局变量定义之前使用extern关键字告诉编译器我们在一个.cpp文件中声明了全局变量。现在,我们仍然有很多全局变量。随着我们在本章对代码进行修改,我们将减少这些全局变量的数量。

如果这是生产代码,将所有这些值移到类中是有意义的,但是,目前,我们只创建了一个PlayerShip对象。我们还为PlayerShip定义了我们的类定义。开发人员通常在头文件中创建类定义。

在定义了所有全局变量之后,我们将需要我们的类定义。

这是我们的PlayerShip类的定义:

class PlayerShip {
    public:
        float m_X;
        float m_Y;
        float m_Rotation;
        float m_DX;
        float m_DY;
        float m_VX;
        float m_VY;

        PlayerShip();
        void RotateLeft();
        void RotateRight();
        void Accelerate();
        void Decelerate();
        void CapVelocity();
        void Move();
        void Render();
 };

extern PlayerShip player;

在本书中,我们将声明所有的属性为public。这意味着我们的代码可以从任何地方访问它们,而不仅仅是从这个函数内部。如果你正在与多个开发人员一起开发项目,这通常不被认为是一个好的做法。如果你不希望另一个开发人员直接修改一些只有类中的函数才能修改的特定属性,比如m_DXm_DY,那么阻止其他类能够直接修改一些属性是一个好主意。然而,出于演示目的,将我们类中的所有内容定义为public将简化我们的设计。

在定义了我们的属性之后,我们有一系列函数,一旦定义,就会与这个类相关联。第一个函数PlayerShip()与我们的类同名,这使它成为构造函数,也就是说,当我们的应用程序创建PlayerShip类型的对象时,默认情况下会调用该函数。如果我们希望,我们可以定义一个析构函数,当对象被销毁时运行,通过将其命名为~PlayerShip()。我们目前不需要该对象的析构函数,因此我们不会在这里定义它,这意味着我们将依赖 C++为这个类创建一个默认析构函数

我们在这个类中定义的所有其他函数对应于我们在游戏的先前 C 版本中创建的函数。将所有这些函数移动到一个类中可以更好地组织我们的代码。请注意,在我们的类定义之后,我们创建了另一个全局变量,一个名为playerPlayerShip。编译器在包含我们的game.hpp文件的所有.cpp文件中共享这个玩家对象。

对象池

我们已经定义了我们的第一个游戏对象,代表了我们玩家的太空飞船,但我们所能做的就是在游戏屏幕上飞行。我们需要允许玩家发射抛射物。如果每次玩家发射抛射物时都创建一个新的抛射物对象,我们很快就会填满 WASM 模块的内存。我们需要做的是创建所谓的对象池。对象池用于创建具有固定寿命的对象。我们的抛射物只需要存活足够长的时间,要么击中目标,要么在消失之前行进一定距离。如果我们创建一定数量的抛射物,略多于我们一次在屏幕上需要的数量,我们可以将这些对象保留在池中,处于活动或非活动状态。当我们需要发射新的抛射物时,我们扫描我们的对象池,找到一个非活动的对象,然后激活它并将其放置在发射点。这样,我们就不会不断地分配和释放内存来创建我们的抛射物。

让我们回到我们的game.hpp文件,在#endif宏之前添加一些类定义。

class Projectile {
    public:
        const char* c_SpriteFile = "sprites/Projectile.png";
        const int c_Width = 8;
        const int c_Height = 8;
        SDL_Texture *m_SpriteTexture;
        bool m_Active;
        const float c_Velocity = 6.0;
        const float c_AliveTime = 2000;
        float m_TTL;
        float m_X;
        float m_Y;
        float m_VX;
        float m_VY;

        Projectile();
        void Move();
        void Render();
        void Launch(float x, float y, float dx, float dy);
};

class ProjectilePool {
    public:
        std::vector<Projectile*> m_ProjectileList;
        ProjectilePool();
        ~ProjectilePool();
        void MoveProjectiles();
        void RenderProjectiles();
        Projectile* GetFreeProjectile();
};

extern ProjectilePool* projectile_pool; 

因此,我们已经在game.hpp文件中定义了所有的类。现在,我们有三个类:PlayerShipProjectileProjectilePool

PlayerShip类之前就存在,但我们正在为该类添加一些额外的功能,以允许我们发射抛射物。为了允许这种新功能,我们正在向我们的类定义中添加一些新的公共属性:

public:
    const char* c_SpriteFile = "sprites/Franchise.png";
    const Uint32 c_MinLaunchTime = 300;
    const int c_Width = 16;
    const int c_Height = 16;
    Uint32 m_LastLaunchTime;
    SDL_Texture *m_SpriteTexture;

我们将一些在#define宏中的值直接移到了类中。c_SpriteFile常量是我们将加载以渲染玩家太空飞船精灵的 PNG 文件的名称。c_MinLaunchTime常量是两次发射抛射物之间的最小时间间隔(以毫秒为单位)。我们还用c_Widthc_Height常量定义了精灵的宽度和高度。这样,我们可以为不同的对象类型设置不同的值。m_LastLaunchTime属性跟踪了最近的抛射物发射时间(以毫秒为单位)。精灵纹理,之前是一个全局变量,将移动到玩家飞船类的属性中。

在对PlayerShip类定义进行修改后,我们必须为两个新类添加类定义。这两个类中的第一个是Projectile类:

class Projectile {
    public:
        const char* c_SpriteFile = "sprites/Projectile.png";
        const int c_Width = 8;
        const int c_Height = 8;
        const float c_Velocity = 6.0;
        const float c_AliveTime = 2000;

        SDL_Texture *m_SpriteTexture;
        bool m_Active;
        float m_TTL;
        float m_X;
        float m_Y;
        float m_VX;
        float m_VY;

        Projectile();
        void Move();
        void Render();
        void Launch(float x, float y, float dx, float dy);
};

这个类代表了玩家将射出的 projectile 游戏对象,以及后来的敌人飞船。我们从几个常量开始,这些常量定义了我们在虚拟文件系统中放置精灵的位置,以及宽度和高度:

class Projectile {
    public:
        const char* c_SpriteFile = "sprites/Projectile.png";
        const int c_Width = 8;
        const int c_Height = 8;

接下来的属性是m_SpriteTexture,它是一个指向用于渲染我们的 projectiles 的 SDL 纹理的指针。我们需要一个变量来告诉我们的对象池这个游戏对象是活动的。我们称这个属性为m_Active。接下来,我们有一个常量,它定义了我们的 projectile 每秒移动的像素数,称为c_Velocity,以及一个常量,表示 projectile 在自毁之前会在毫秒内保持活动的时间,称为c_AliveTime

m_TTL变量是一个生存时间变量,跟踪着直到这个 projectile 将其m_Active变量更改为false并将自己回收到projectile 池中还有多少毫秒。m_Xm_Ym_VXm_VY变量用于跟踪我们的 projectile 的xy位置以及xy速度。

然后我们为我们的 projectile 类声明了四个函数:

Projectile();
void Move();
void Render();
void Launch(float x, float y, float dx, float dy);

Projectile函数是我们的类构造函数。如果我们的 projectile 当前处于活动状态,MoveRender将在每帧调用一次。Move函数将管理活动 projectile 的移动,Render将管理将 projectile 精灵绘制到我们的 HTML 画布元素上。Launch函数将从我们的PlayerShip类中调用,使我们的飞船朝着飞船的方向发射 projectile。

我们必须添加到我们的game.hpp文件中的最终类定义是ProjectilePool类:

class ProjectilePool {
    public:
        std::vector<Projectile*> m_ProjectileList;
        ProjectilePool();
        ~ProjectilePool();
        void MoveProjectiles();
        void RenderProjectiles();
        Projectile* GetFreeProjectile();
};

这个类管理一个包含在向量属性m_ProjectileList中的 10 个 projectiles 的。这个类的函数包括构造函数和析构函数,MoveProjectilesRenderProjectilsGetFreeProjectile

MoveProjectiles()函数循环遍历我们的 projectile 列表,调用任何活动 projectile 上的move函数。RenderProjectiles()函数循环遍历我们的 projectile 列表,并在画布上渲染任何活动的 projectile,GetFreeProjectile返回我们的池中第一个非活动的 projectile。

池化玩家的 projectiles

现在我们已经查看了ProjectileProjectilePool类的类定义,我们需要创建一个projectile.cpp文件和一个projectile_pool.cpp文件来存储这些类的函数代码。因为这是在第六章,游戏对象和游戏循环,我建议创建一个名为Chapter06的新文件夹来保存这些文件。这段代码将完成我们的 projectiles 的池化工作,在我们需要时请求一个非活动的 projectile,并移动和渲染我们的活动 projectiles。首先,让我们看看我们在projectile.cpp中的代码:

#include "game.hpp"

Projectile::Projectile() {
    m_Active = false;
    m_X = 0.0;
    m_Y = 0.0;
    m_VX = 0.0;
    m_VY = 0.0;

    SDL_Surface *temp_surface = IMG_Load( c_SpriteFile );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }

    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }

    SDL_FreeSurface( temp_surface );
}

void Projectile::Move() {
    m_X += m_VX;
    m_Y += m_VY;
    m_TTL -= diff_time;

    if( m_TTL <= 0 ) {
        m_Active = false;
        m_TTL = 0;
    }
}

void Projectile::Render() {
    dest.x = m_X;
    dest.y = m_Y;
    dest.w = c_Width;
    dest.h = c_Height;

    int return_val = SDL_RenderCopy( renderer, m_SpriteTexture,
                                     NULL, &dest );
    if( return_val != 0 ) {
        printf("SDL_Init failed: %s\n", SDL_GetError());
    }
}

void Projectile::Launch(float x, float y, float dx, float dy) {
    m_X = x;
    m_Y = y;
    m_VX = c_Velocity * dx;
    m_VY = c_Velocity * dy;
    m_TTL = c_AliveTime;
    m_Active = true;
}

这是处理移动、渲染和发射单个 projectile 的代码。这里声明的第一个函数是构造函数:

Projectile::Projectile() {
    m_Active = false;
    m_X = 0.0;
    m_Y = 0.0;
    m_VX = 0.0;
    m_VY = 0.0;

    SDL_Surface *temp_surface = IMG_Load( c_SpriteFile );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }

    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }
    SDL_FreeSurface( temp_surface );
}

这个构造函数的主要任务是将 projectile 设置为非活动状态,并创建一个 SDL 纹理,我们稍后将用它来渲染我们的精灵到画布元素上。在定义了构造函数之后,我们定义了我们的Move函数:

void Projectile::Move() {
    m_X += m_VX;
    m_Y += m_VY;
    m_TTL -= diff_time;
    if( m_TTL <= 0 ) {
        m_Active = false;
        m_TTL = 0;
    }
}

这个函数根据速度改变我们的 projectile 的xy位置,并减少我们的 projectile 的生存时间,如果它的生存时间小于或等于零,就将其设置为非活动状态并回收到 projectile 池中。我们定义的下一个函数是我们的Render函数:

void Projectile::Render() {
    dest.x = m_X;
    dest.y = m_Y;
    dest.w = c_Width;
    dest.h = c_Height;

    int return_val = SDL_RenderCopy( renderer, m_SpriteTexture,
                                    NULL, &dest );

    if( return_val != 0 ) {
        printf("SDL_Init failed: %s\n", SDL_GetError());
    }
}

这段代码与我们用来渲染飞船的代码类似,所以它应该对你来说看起来很熟悉。我们最后的 projectile 函数是Launch函数:

void Projectile::Launch(float x, float y, float dx, float dy) {
    m_X = x;
    m_Y = y;
    m_VX = c_Velocity * dx;
    m_VY = c_Velocity * dy;
    m_TTL = c_AliveTime;
    m_Active = true;
}

这个函数是在玩家在键盘上按下空格键时从PlayerShip类中调用的。PlayerShip对象将在dxdy参数中传入玩家飞船的xy坐标,以及飞船面对的方向。这些参数用于设置抛射物的xy坐标以及抛射物的xy速度。游戏将生存时间设置为默认的存活时间,然后将对象设置为活动状态。

现在我们已经完全定义了我们的Projectile类,让我们设置一个管理这些抛射物的ProjectilePool类。以下代码将在我们的projectile_pool.cpp文件中:

#include "game.hpp"

ProjectilePool::ProjectilePool() {
    for( int i = 0; i < 10; i++ ) {
        m_ProjectileList.push_back( new Projectile() );
    }
}

ProjectilePool::~ProjectilePool() {
    m_ProjectileList.clear();
}

void ProjectilePool::MoveProjectiles() {
    Projectile* projectile;
    std::vector<Projectile*>::iterator it;

    for( it = m_ProjectileList.begin(); it != m_ProjectileList.end(); it++ ) {
        projectile = *it;
        if( projectile->m_Active ) {
            projectile->Move();
        }
    }
}

void ProjectilePool::RenderProjectiles() {
    Projectile* projectile;
    std::vector<Projectile*>::iterator it;

    for( it = m_ProjectileList.begin(); it != m_ProjectileList.end(); it++ ) {
        projectile = *it;
        if( projectile->m_Active ) {
            projectile->Render();
         }
    }
}

Projectile* ProjectilePool::GetFreeProjectile() {
    Projectile* projectile;
    std::vector<Projectile*>::iterator it;

    for( it = m_ProjectileList.begin(); it != m_ProjectileList.end(); it++ ) {
        projectile = *it;
        if( projectile->m_Active == false ) {
            return projectile;
        }
    }
    return NULL;
}

前两个函数是构造函数和析构函数。这些函数在我们的列表内创建和销毁抛射物。接下来的函数是MoveProjectiles函数,它循环遍历我们的m_ProjectileList寻找活动的抛射物并移动它们。之后,我们有一个RenderProjectiles函数,它与我们的MoveProjectiles函数非常相似。这个函数循环遍历我们的列表,调用所有活动抛射物的Render函数。最后一个函数是GetFreeProjectile函数,它通过m_ProjectileList寻找第一个不活动的抛射物以返回它。每当我们想要发射一个抛射物时,我们需要调用这个函数来找到一个不活动的抛射物。

创建一个敌人

所以,现在我们有了一个射击的玩家飞船,我们可以开始添加一个敌人飞船。它将类似于PlayerShip类。稍后,我们将进入类继承,这样我们就不会得到一个复制并粘贴的相同代码版本,但现在我们将在我们的game.hpp文件中添加一个几乎与我们的PlayerShip类相同的新类定义:

enum FSM_STUB {
    SHOOT = 0,
    TURN_LEFT = 1,
    TURN_RIGHT = 2,
    ACCELERATE = 3,
    DECELERATE = 4
};

class EnemyShip {
    public:
        const char* c_SpriteFile = "sprites/BirdOfAnger.png";
        const Uint32 c_MinLaunchTime = 300;
        const int c_Width = 16;
        const int c_Height = 16;
        const int c_AIStateTime = 2000;

        Uint32 m_LastLaunchTime;
        SDL_Texture *m_SpriteTexture;

        FSM_STUB m_AIState;
        int m_AIStateTTL;

        float m_X;
        float m_Y;
        float m_Rotation;
        float m_DX;
        float m_DY;
        float m_VX;
        float m_VY;

        EnemyShip();
        void RotateLeft();
        void RotateRight();
        void Accelerate();
        void Decelerate();
        void CapVelocity();
        void Move();
        void Render();
        void AIStub();
};

您会注意到在EnemyShip类之前,我们定义了一个FSM_STUB枚举。枚举就像是您可以在 C 或 C++代码中定义的新数据类型。我们将在另一章中讨论人工智能有限状态机,但现在我们仍然希望我们的敌人飞船做一些事情,即使那些事情并不是很聪明。我们创建了一个FSM_STUB枚举来定义我们的敌人飞船目前可以做的事情。我们还在我们的EnemyShip类中创建了一个AIStub,它将作为未来 AI 逻辑的替身。整数属性m_AIStateTTL是一个倒计时计时器,用于 AI 状态的变化。还有一个名为c_AIStateTime的新常量,它的值为2000。这是我们的 AI 状态在随机更改之前将持续的毫秒数。

我们将创建一个enemy_ship.cpp文件,并向其中添加九个函数。第一个函数是我们的构造函数,在它之前是我们的game.hpp文件的#include

#include "game.hpp"
EnemyShip::EnemyShip() {
 m_X = 60.0;
    m_Y = 50.0;
    m_Rotation = PI;
    m_DX = 0.0;
    m_DY = 1.0;
    m_VX = 0.0;
    m_VY = 0.0;
    m_LastLaunchTime = current_time;

    SDL_Surface *temp_surface = IMG_Load( c_SpriteFile );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating enemy ship surface\n");
    }
    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating enemy ship texture\n");
    }
    SDL_FreeSurface( temp_surface );
}

之后,我们有RotateLeftRotateRight函数,用于转动太空飞船:

void EnemyShip::RotateLeft() {
    m_Rotation -= delta_time;

    if( m_Rotation < 0.0 ) {
        m_Rotation += TWO_PI;
    }
    m_DX = sin(m_Rotation);
    m_DY = -cos(m_Rotation);
}
void EnemyShip::RotateRight() {
    m_Rotation += delta_time;

    if( m_Rotation >= TWO_PI ) {
        m_Rotation -= TWO_PI;
    }
    m_DX = sin(m_Rotation);
    m_DY = -cos(m_Rotation);
}

函数AccelerateDecelerateCapVelocity都用于修改敌舰的速度。

void EnemyShip::Accelerate() {
    m_VX += m_DX * delta_time;
    m_VY += m_DY * delta_time;
}

void EnemyShip::Decelerate() {
    m_VX -= (m_DX * delta_time) / 2.0;
    m_VY -= (m_DY * delta_time) / 2.0;
}

void EnemyShip::CapVelocity() {
    float vel = sqrt( m_VX * m_VX + m_VY * m_VY );

    if( vel > MAX_VELOCITY ) {
        m_VX /= vel;
        m_VY /= vel;

        m_VX *= MAX_VELOCITY;
        m_VY *= MAX_VELOCITY;
    }
}

接下来我们添加到文件中的是Render函数:

void EnemyShip::Render() {
    dest.x = (int)m_X;
    dest.y = (int)m_Y;
    dest.w = c_Width;
    dest.h = c_Height;

    float degrees = (m_Rotation / PI) * 180.0;

    int return_code = SDL_RenderCopyEx( renderer, m_SpriteTexture,
                                        NULL, &dest,
                                        degrees, NULL, SDL_FLIP_NONE );

 if( return_code != 0 ) {
 printf("failed to render image: %s\n", IMG_GetError() );
 }
}

最后,我们添加了MoveAIStub函数:

void EnemyShip::Move() {
     AIStub();

 if( m_AIState == TURN_LEFT ) {
     RotateLeft();
 }

 if( m_AIState == TURN_RIGHT ) {
     RotateRight();
 }

 if( m_AIState == ACCELERATE ) {
     Accelerate();
 }

 if( m_AIState == DECELERATE ) {
     Decelerate();
 }

 CapVelocity();
 m_X += m_VX;

 if( m_X > 320 ) {
     m_X = -16;
 }
 else if( m_X < -16 ) {
     m_X = 320;
 }

 m_Y += m_VY;

 if( m_Y > 200 ) {
     m_Y = -16;
 }
 else if( m_Y < -16 ) {
     m_Y = 200;
 }

 if( m_AIState == SHOOT ) {
     Projectile* projectile;
     if( current_time - m_LastLaunchTime >= c_MinLaunchTime ) {
         m_LastLaunchTime = current_time;
         projectile = projectile_pool->GetFreeProjectile();

         if( projectile != NULL ) {
             projectile->Launch( m_X, m_Y, m_DX, m_DY );
             }
         }
     }
}

void EnemyShip::AIStub() {
     m_AIStateTTL -= diff_time;
     if( m_AIStateTTL <= 0 ) {
         // for now get a random AI state.
         m_AIState = (FSM_STUB)(rand() % 5);
         m_AIStateTTL = c_AIStateTime;
     }
}

这些函数都与我们的player_ship.cpp文件中定义的函数相同,除了Move函数。我们添加了一个新函数,AIStub。以下是AIStub函数中的代码:

void EnemyShip::AIStub() {
    m_AIStateTTL -= diff_time;

    if( m_AIStateTTL <= 0 ) {
        // for now get a random AI state.
        m_AIState = (FSM_STUB)(rand() % 5);
        m_AIStateTTL = c_AIStateTime;
    }
}

这个函数是暂时的。我们最终将为我们的敌人飞船定义一个真正的 AI。现在,这个函数使用m_AIStateTTL来倒计时固定数量的毫秒,直到达到或低于0。在这一点上,它会基于我们之前定义的枚举FSM_STUB中的一个值随机设置一个新的 AI 状态。我们还对我们为玩家飞船创建的Move()函数进行了一些修改:

void EnemyShip::Move() {
    AIStub();

    if( m_AIState == TURN_LEFT ) {
        RotateLeft();
    }
    if( m_AIState == TURN_RIGHT ) {
        RotateRight();
    }
    if( m_AIState == ACCELERATE ) {
        Accelerate();
    }
    if( m_AIState == DECELERATE ) {
        Decelerate();
    }
    CapVelocity();
     m_X += m_VX;

    if( m_X > 320 ) {
        m_X = -16;
    }
    else if( m_X < -16 ) {
        m_X = 320;
    }
    m_Y += m_VY;

    if( m_Y > 200 ) {
        m_Y = -16;
    }
    else if( m_Y < -16 ) {
        m_Y = 200;
    }

    if( m_AIState == SHOOT ) {
        Projectile* projectile;
        if( current_time - m_LastLaunchTime >= c_MinLaunchTime ) {
            m_LastLaunchTime = current_time;
            projectile = projectile_pool->GetFreeProjectile();

            if( projectile != NULL ) {
                projectile->Launch( m_X, m_Y, m_DX, m_DY );
            }
        }
    }
}

我已经从我们的PlayerShip::Move函数中取出了代码并对其进行了一些修改。在这个新函数的开头,我们添加了对AIStub函数的调用。这个函数是我们未来 AI 的替身。与我们为玩家飞船所做的一样,敌人飞船不会查看我们的键盘输入,而是会查看 AI 状态并选择左转、右转、加速、减速或射击。这不是真正的 AI,只是飞船做一些随机的事情,但它让我们能够想象一下当飞船具有真正的 AI 时它会是什么样子,并且它将允许我们稍后添加更多功能,比如碰撞检测。

编译 game_objects.html

现在我们已经构建了所有这些游戏对象,我们不再将所有内容放在一个文件中。我们需要包含几个 CPP 文件,并将它们全部编译成一个名为game_objects.html的输出文件。因为我们已经从 C 世界转移到了 C++世界,所以我们将使用 em++来指示我们正在编译的文件是 C++文件而不是 C 文件。这并不是严格必要的,因为当 Emscripten 接收到扩展名为.cpp的文件作为输入时,它会自动判断我们正在使用 C++进行编译。当我们传入-std=c++17标志时,我们还明确告诉编译器我们正在使用的 C++版本。请使用以下 em++命令编译game_objects.html文件:

em++ main.cpp enemy_ship.cpp player_ship.cpp projectile.cpp projectile_pool.cpp -std=c++17 --preload-file sprites -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -o game_objects.html

现在我们已经编译了game_objects.html文件,请使用 Web 服务器来提供文件并在浏览器中打开它,它应该看起来像这样:

game_objects.html 的屏幕截图

不要忘记,您必须使用 Web 服务器或emrun来运行 WebAssembly 应用程序。如果您想使用emrun运行 WebAssembly 应用程序,您必须使用--emrun标志进行编译。Web 浏览器需要 Web 服务器来流式传输 WebAssembly 模块。如果您尝试直接从硬盘驱动器在浏览器中打开使用 WebAssembly 的 HTML 页面,那么 WebAssembly 模块将无法加载。

您可以使用箭头键在画布上移动您的飞船,并使用空格键发射抛射物。敌船将在画布上随机移动并射击。

如果您在构建此应用程序或本书中的任何其他应用程序时遇到问题,请记住您可以在 Twitter 上联系我,twitter.com/battagline/,使用 Twitter 账号@battagline提问。我很乐意帮助。

摘要

在本章中,我们学习了如何创建一个基本的游戏框架。我们了解了游戏循环是什么,以及如何使用 Emscripten 为 WebAssembly 创建游戏循环。我们学习了游戏对象,并创建了用于定义玩家飞船、敌人飞船和抛射物的类。我们学习了对象池,以及如何使用对象池来回收内存中的对象,这样我们就不需要不断地在内存中创建和销毁新对象。我们利用这些知识为我们的抛射物创建了一个对象池。我们还为我们的敌人飞船创建了一个 AI 存根,使该对象具有随机行为,并创建了让我们的玩家和敌人相互射击的函数,同时我们的抛射物会无害地穿过飞船。

在下一章结束时,我们将添加碰撞检测;这将允许我们的抛射物摧毁它们击中的飞船,并添加一个动画序列,当飞船被抛射物击中时将显示飞船被摧毁的情景。

第七章:碰撞检测

目前,我们的太空飞船可以四处飞行并互相射击,但没有发生任何事情。

碰撞检测在绝大多数视频游戏中用于确定游戏对象是否相交。有许多方法可以检测不同游戏对象之间的碰撞。不同的方法在不同情况下可能效果更好。在计算时间和我们的碰撞检测的准确性之间也存在权衡。

您需要在构建中包含几个图像才能使此项目工作。确保您包含了项目的 GitHub 中的/Chapter07/sprites/文件夹。如果您还没有下载 GitHub 项目,可以在这里在线获取:github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly

在本章中,我们将讨论以下内容:

  • 碰撞检测

  • 碰撞器对象

  • 碰撞器的类型

  • 向我们的游戏对象添加碰撞器

2D 碰撞检测类型

我可以写一本关于我们可以使用的各种 2D 碰撞检测方法的书,更不用说 3D 碰撞检测的数量了。我已经在www.embed.com/typescript-games/basic-collision-detection.html上写了几篇 TypeScript 教程,介绍了如何使用不同的检测技术,包括基本的和复杂的,但在这本书中,我们将坚持使用一些更基本的碰撞技术的组合。

圆形碰撞检测

最基本的碰撞检测是圆形距离碰撞检测。如果我们把所有的碰撞器都看作是带有半径和位置的小圆圈,我们可以计算两个位置之间的距离,并查看该距离是否小于我们半径的总和。这种形式的碰撞检测速度很快,但精度有限。如果你看看我们游戏中的抛射物,这种方法效果还不错。另一方面,我们的太空飞船并不完全适合圆形。我们可以调整任何给定飞船的圆形碰撞器的半径,以获得略有不同的结果。当圆形碰撞检测有效时,它可以非常高效:

[圆形碰撞检测]

矩形碰撞检测

矩形碰撞检测是另一种快速的碰撞检测方法。在许多情况下,它可能比圆形碰撞检测更快。矩形碰撞器由* x y 坐标定义,这是我们矩形左上角的位置,以及宽度和高度。检测矩形碰撞非常简单。我们在 x 轴上寻找两个矩形之间的重叠。如果在 x 轴上有重叠,然后我们在 y *轴上寻找重叠。如果两个轴上都有重叠,就会发生碰撞。这种技术对许多老式视频游戏非常有效。在任天堂娱乐系统上发布的几款经典游戏使用了这种碰撞检测方法。在我们编写的游戏中,我们正在旋转我们的精灵,因此使用传统的非定向碰撞检测对我们来说没有用处。

三角函数的简短复习

在这一点上,我们的碰撞检测算法开始变得更加复杂。你可能还记得一些高中三角学课程的概念,但一些基本的三角学对于许多碰撞检测算法非常重要。即使我们之前讨论的圆形碰撞检测也依赖于毕达哥拉斯定理,所以,实际上,除非你在进行简单的非定向矩形碰撞检测,至少需要一点三角学。三角学是数学中三角形的研究。大多数游戏使用所谓的笛卡尔坐标系。如果你对这个词不熟悉,笛卡尔坐标系意味着我们有一个带有xy坐标的网格(对于 2D 游戏)。

笛卡尔这个词是由勒内·笛卡尔发明的——那个“我思故我在”的家伙,在数学上有很多伟大的想法,在哲学上有很多愚蠢的想法(机器中的幽灵…呸!)。

我们必须从高中的三角学课程中记住一些关键概念,它们都与直角三角形有关。直角三角形是一个内含 90 度角的三角形。当你使用笛卡尔坐标系时,这是一个方便的事情,因为你的xy轴恰好形成一个直角,所以任何两点之间不共享xy坐标的线都可以被视为直角三角形的斜边(长边)。我们还需要记住一些比率;它们如下:

  • 正弦 - Y / 斜边

  • 余弦 - X / 斜边

  • 正切 - Y / X

你还记得 SOHCAHTOA 吗?(发音为“Sock-Ah-Toe-Ah”)

这是为了提醒你记住三角比率的以下版本:

  • 正弦 - 对边 / 斜边

  • 余弦 - 邻边 / 斜边

  • 正切 - 对边 / 邻边

在这个公式中,三角形的对边y轴,三角形的邻边是x轴。如果你记得 SOHCAHTOA,你可能更容易记住这些比率。如果不记得,就重新翻开这本书或使用谷歌:

[SOHCAHTOA]

有些人被教导使用短语“Some Old Horse Came A-Hoppin' Through Our Alley.”我不确定这是否有帮助。我觉得比 SOHCAHTOA 更难记住,但这是一个观点问题。所以,如果想象一匹像兔子一样在某个城市的后巷里跳跃的马是你的菜,那么尽管使用那个。

你可能还记得在本书的前面,我们使用了船舶旋转的角度和sincos数学库函数来计算我们的船在x轴和y轴上的移动速度。这些函数返回给定角度的比率。

我们需要知道的另一个概念是两个单位向量之间的点积。单位向量是长度为 1 的向量。两个单位向量之间的点积就是这两个单位向量之间的角的余弦。点积越接近 1,两个向量之间的角度就越接近 0 度。如果点积接近 0,两个向量之间的角度接近 90 度,如果两个角之间的点积接近-1,两个向量之间的角度接近 180 度。不同向量之间的点积在碰撞检测和游戏物理中非常有用。参考以下图表:

[两个归一化向量的点积]

线碰撞检测

因此,我们需要做的第一件事是讨论线和线段之间的区别。我们使用两点来定义一条线。该线在点之后延伸到无穷大。线段在两点处终止,并且不会无限期地继续。不平行的两条线总会在某个地方相交。两条不平行的线段可能会相交,也可能不会相交。

在大多数情况下,在游戏中,我们都对两条线段是否相交感兴趣:

[线与线段]

确定一条线是否与线段相交相对容易。你所要做的就是看看线段的两个点是否在你的线的两侧。由于一条线是无限的,这意味着你的线段必须在某个地方与你的线相交。如果你想要找出两条线段是否相交,你可以分两个阶段来做。首先,找出线段 A 是否与无限线 B 相交。如果它们相交,那么找出线段 B 是否与无限线 A 相交。如果在这两种情况下都成立,那么线段相交。

那么,下一个问题是,我们如何在数学上知道两点是否在一条线的对立面?为了做到这一点,我们将使用先前讨论过的点积和称为向量法线的东西。向量法线只是你的向量的一个 90 度旋转版本。请参阅以下图表:

一个向量及该向量的法线

我们还需要一个向量,它的起点与同一点相同,但方向指向我们线段的第一个点。如果这两个向量的点积是一个正值,那么这意味着该点与归一化向量在同一侧。如果点积是一个负值,那么这意味着该点在与我们的法向量相反的一侧。如果线段相交,那么意味着一个点有一个正的点积,另一侧有一个负的点积。由于两个负数相乘和两个正数相乘都会得到一个正的结果,而一个负数和一个正数相乘会得到一个负的结果,将这两个点积相乘,看看结果值是否为负数。如果是,那么线段与线相交:

[确定两点是否在线的对立面]

复合碰撞器

复合碰撞器是指游戏对象使用多个碰撞器来确定是否发生了碰撞。我们将在我们的飞船上使用复合圆形碰撞器,以提高我们飞船碰撞检测的准确性,同时仍然提供使用圆形碰撞器的增加速度。我们将用三个圆覆盖我们的玩家飞船和敌人飞船。我们的抛射物是圆形的,因此对于它们使用圆形是非常自然的。没有理由限制复合碰撞器只使用一种形状的碰撞器。在内部,复合碰撞器可以混合圆形碰撞器和矩形碰撞器,或者任何其他你喜欢的类型。

下面的图表显示了一个由圆和两个矩形碰撞器组成的假想化复合碰撞器:

[由三个基本碰撞器组成的复合碰撞器]

在下一节中,我们将学习如何实现基本的圆形碰撞检测算法。

实现圆形碰撞检测

我们将从实现圆形碰撞检测开始,因为这是最快的碰撞检测方法。它也很适合我们的抛射物,这将是我们游戏中最常见的碰撞器。它对我们的飞船的效果不是很好,但是以后,我们可以通过实现一个复合碰撞器来改善这种情况,该碰撞器将为每艘飞船使用多个圆形碰撞器,而不仅仅是一个。因为我们只有两艘飞船,这将为我们的碰撞检测带来最佳的效果:圆形碰撞检测的速度,以及一些更好的碰撞检测方法的准确性。

让我们首先在game.hpp文件中添加一个Collider类定义,并创建一个新的collider.cpp文件,在其中我们可以定义Collider类使用的函数。以下是我们在game.hpp文件中新Collider类的样子:

class Collider {
    public:
        double m_X;
        double m_Y;
        double m_Radius;

        Collider(double radius);

        bool HitTest( Collider *collider );
};

这是我们放在collider.cpp文件中的代码:

#include "game.hpp"
Collider::Collider(double radius) {
    m_Radius = radius;
}

bool Collider::HitTest( Collider *collider ) {
    double dist_x = m_X - collider->m_X;
    double dist_y = m_Y - collider->m_Y;
    double radius = m_Radius + collider->m_Radius;

    if( dist_x * dist_x + dist_y * dist_y <= radius * radius ) {
        return true;
    }
    return false;
}

Collider类是一个相当简单的圆形碰撞器。正如我们之前讨论的,圆形碰撞器有一个xy坐标以及一个半径。HitTest函数进行了一个相当简单的距离测试,以确定两个圆是否足够接近以触碰彼此。我们通过平方两个碰撞器之间的x距离和y距离来实现这一点,这给了我们两点之间的距离的平方。我们可以取平方根来确定实际距离,但平方根是一个相对较慢的函数,而平方两个半径的和进行比较要快得多。

我们还需要简要讨论类继承。如果你回顾一下我们之前的代码,我们有一个PlayerShip类和一个EnemyShip类。这些类共享大部分属性。它们都有xy坐标,xy速度,以及许多其他相同的属性。许多函数使用相同的复制粘贴的代码。我们不想重复定义这些代码,让我们回过头来创建一个Ship类,它包含了PlayerShipEnemyShip类共有的所有特性。然后,我们可以重构我们的EnemyShipPlayerShip类,让它们继承自我们的Ship类。这是我们在game.hpp中添加的新Ship类定义:

class Ship: public Collider {
    public:
        Uint32 m_LastLaunchTime;
        const int c_Width = 16;
        const int c_Height = 16;
        SDL_Texture *m_SpriteTexture;
        Ship();
        float m_Rotation;
        float m_DX;
        float m_DY;
        float m_VX;
        float m_VY;

        void RotateLeft();
        void RotateRight();
        void Accelerate();
        void Decelerate();
        void CapVelocity();

        virtual void Move() = 0;
        void Render();
};

第一行Ship class: public Collider告诉我们Ship将继承Collider类的所有公共和受保护成员。我们这样做是因为我们希望能够执行碰撞检测。Collider类现在也定义了m_Xm_Y属性变量,用于跟踪对象的xy坐标。我们已经将EnemyShipPlayerShip类的共同部分移到了Ship类中。你会注意到我们有一个虚函数virtual void Move() = 0;。这一行告诉我们,所有继承自Ship的类都将有一个Move函数,但我们需要在这些类中定义Move,而不是直接在Ship类中定义。这使Ship成为一个抽象类,这意味着我们不能创建Ship的实例,而是它是其他类将继承的类。

类继承、抽象类和虚函数都是一种被称为面向对象编程OOP)的编程风格的一部分。C++是由 Bjarne Stroustrup 于 1979 年创建的,用于将 OOP 添加到 C 编程语言中。如果你对 OOP 不熟悉,有数百本书详细介绍了这个主题。我只能在本书中粗略地介绍它。

接下来,我们将修改game.hpp文件中的PlayerShipEnemyShip类,删除我们移动到父类Ship中的所有方法和属性。我们还将修改这些类,使它们继承自Ship。以下是类定义的新版本:

class PlayerShip: public Ship {
    public:
        const char* c_SpriteFile = "sprites/Franchise.png";
        const Uint32 c_MinLaunchTime = 300;
        PlayerShip();
        void Move();
};

class EnemyShip: public Ship {
    public:
        const char* c_SpriteFile = "sprites/BirdOfAnger.png";
        const Uint32 c_MinLaunchTime = 300;
        const int c_AIStateTime = 2000;
        FSM_STUB m_AIState;
        int m_AIStateTTL;

        EnemyShip();
        void AIStub();
        void Move();
};

现在,我们需要添加一个ship.cpp文件,并定义所有EnemyShipPlayerShip共有的方法。这些方法之前都在PlayerShipEnemyShip中,但现在我们可以将它们都放在一个地方。以下是ship.cpp文件的样子:

#include "game.hpp"

Ship::Ship() : Collider(8.0) {
    m_Rotation = PI;
    m_DX = 0.0;
    m_DY = 1.0;
    m_VX = 0.0;
    m_VY = 0.0;
    m_LastLaunchTime = current_time;
}

void Ship::RotateLeft() {
    m_Rotation -= delta_time;

    if( m_Rotation < 0.0 ) {
        m_Rotation += TWO_PI;
    }
    m_DX = sin(m_Rotation);
    m_DY = -cos(m_Rotation);
}

void Ship::RotateRight() {
    m_Rotation += delta_time;

    if( m_Rotation >= TWO_PI ) {
        m_Rotation -= TWO_PI;
    }
    m_DX = sin(m_Rotation);
    m_DY = -cos(m_Rotation);
}

void Ship::Accelerate() {
    m_VX += m_DX * delta_time;
    m_VY += m_DY * delta_time;
}

void Ship::Decelerate() {
    m_VX -= (m_DX * delta_time) / 2.0;
    m_VY -= (m_DY * delta_time) / 2.0;
}
void Ship::CapVelocity() {
    double vel = sqrt( m_VX * m_VX + m_VY * m_VY );

    if( vel > MAX_VELOCITY ) {
        m_VX /= vel;
        m_VY /= vel;

        m_VX *= MAX_VELOCITY;
        m_VY *= MAX_VELOCITY;
    }
}
void Ship::Render() {
    dest.x = (int)m_X;
    dest.y = (int)m_Y;
    dest.w = c_Width;
    dest.h = c_Height;

    double degrees = (m_Rotation / PI) * 180.0;

    int return_code = SDL_RenderCopyEx( renderer, m_SpriteTexture,
                                        NULL, &dest,
                                        degrees, NULL, SDL_FLIP_NONE );

    if( return_code != 0 ) {
        printf("failed to render image: %s\n", IMG_GetError() );
    }
}

这些类的版本之间唯一真正的区别是,在player_ship.cppenemy_ship.cpp文件中,每个函数定义前面不再是PlayerShip::EnemyShip::,而是Ship::

接下来,我们需要修改player_ship.cppenemy_ship.cpp,删除现在在ship.cpp文件中定义的所有函数。让我们看看enemy_ship.cpp文件分成两部分的样子。第一部分是我们的game.hpp文件的#includeEnemyShip构造函数:

#include "game.hpp"

EnemyShip::EnemyShip() {
    m_X = 60.0;
    m_Y = 50.0;
    m_Rotation = PI;
    m_DX = 0.0;
    m_DY = 1.0;
    m_VX = 0.0;
    m_VY = 0.0;
    m_LastLaunchTime = current_time;

    SDL_Surface *temp_surface = IMG_Load( c_SpriteFile );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating enemy ship surface\n");
    }
    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating enemy ship texture\n");
    }

    SDL_FreeSurface( temp_surface );
}

enemy_ship.cpp文件的第二部分中,我们有MoveAIStub函数:

void EnemyShip::Move() {
    AIStub();

    if( m_AIState == TURN_LEFT ) {
        RotateLeft();
    }

    if( m_AIState == TURN_RIGHT ) {
        RotateRight();
    }

    if( m_AIState == ACCELERATE ) {
        Accelerate();
    }

    if( m_AIState == DECELERATE ) {
        Decelerate();
    }

    CapVelocity();
    m_X += m_VX;

    if( m_X > 320 ) {
        m_X = -16;
    }
    else if( m_X < -16 ) {
        m_X = 320;
    }

    m_Y += m_VY;

    if( m_Y > 200 ) {
        m_Y = -16;
    }
    else if( m_Y < -16 ) {
        m_Y = 200;
    }

    if( m_AIState == SHOOT ) {
        Projectile* projectile;

        if( current_time - m_LastLaunchTime >= c_MinLaunchTime ) {
            m_LastLaunchTime = current_time;
            projectile = projectile_pool->GetFreeProjectile();

            if( projectile != NULL ) {
                projectile->Launch( m_X, m_Y, m_DX, m_DY );
            }
        }
    }
}

void EnemyShip::AIStub() {
    m_AIStateTTL -= diff_time;

    if( m_AIStateTTL <= 0 ) {
        // for now get a random AI state.
        m_AIState = (FSM_STUB)(rand() % 5);
        m_AIStateTTL = c_AIStateTime;
    }
}

现在我们已经看到了enemy_ship.cpp文件中的内容,让我们看看新的player_ship.cpp文件的样子:

#include "game.hpp"
PlayerShip::PlayerShip() {
    m_X = 160.0;
    m_Y = 100.0;
    SDL_Surface *temp_surface = IMG_Load( c_SpriteFile );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }

    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }

    SDL_FreeSurface( temp_surface );
}

void PlayerShip::Move() {
    current_time = SDL_GetTicks();
    diff_time = current_time - last_time;
    delta_time = (double)diff_time / 1000.0;
    last_time = current_time;

    if( left_key_down ) {
        RotateLeft();
    }

    if( right_key_down ) {
        RotateRight();
    }

    if( up_key_down ) {
        Accelerate();
    }

    if( down_key_down ) {
        Decelerate();
    }

    CapVelocity();
    m_X += m_VX;

    if( m_X > 320 ) {
        m_X = -16;
    }
    else if( m_X < -16 ) {
        m_X = 320;
    }

    m_Y += m_VY;

    if( m_Y > 200 ) {
        m_Y = -16;
    }
    else if( m_Y < -16 ) {
        m_Y = 200;
    }

    if( space_key_down ) {
        Projectile* projectile;

        if( current_time - m_LastLaunchTime >= c_MinLaunchTime ) {
            m_LastLaunchTime = current_time;
            projectile = projectile_pool->GetFreeProjectile();
            if( projectile != NULL ) {
                projectile->Launch( m_X, m_Y, m_DX, m_DY );
            }
        }
    }
}

接下来,让我们修改ProjectilePool类中的Move函数,以便每次移动Projectile时,它也测试是否击中了我们的飞船之一:

void ProjectilePool::MoveProjectiles() {
    Projectile* projectile;
    std::vector<Projectile*>::iterator it;
    for( it = m_ProjectileList.begin(); it != m_ProjectileList.end(); 
        it++ ) {
        projectile = *it;
        if( projectile->m_Active ) {
            projectile->Move();
            if( projectile->HitTest( player ) ) {
                printf("hit player\n");
            }
            if( projectile->HitTest( enemy ) ) {
                printf("hit enemy\n");
            }
        }
    }
}

现在,我们只会在玩家或敌人与抛射物发生碰撞时在控制台上打印。这将告诉我们我们的碰撞检测是否正确。在后面的部分中,我们将添加动画来摧毁我们的飞船当它们与抛射物碰撞时。

我们需要对Projectile类的Launch函数进行最后一个更改。当我们从飞船发射抛射物时,我们给抛射物一个基于飞船面向的方向的 x 和 y 位置和xy速度。我们需要根据这个方向移动抛射物的起点。这是为了防止抛射物通过移动出碰撞检测圈而击中发射它的飞船:

void Projectile::Launch(double x, double y, double dx, double dy) {
    m_X = x + dx * 9;
    m_Y = y + dy * 9;
    m_VX = velocity * dx;
    m_VY = velocity * dy;
    m_TTL = alive_time;
    m_Active = true;
}

在接下来的部分中,我们将检测当我们的飞船与抛射物碰撞时,并运行一个爆炸动画。

在碰撞时摧毁飞船

现在我们正在检测抛射物和飞船之间的碰撞,做一些比在控制台打印一行更有趣的事情会很好。当抛射物和飞船碰到东西时,能有一点爆炸动画会很好。当这些对象被摧毁时,我们可以为每个对象添加一个关联的动画。

我将介绍精灵表的概念,而不是像在之前的章节中那样为动画的每一帧加载多个精灵。我们将为每个飞船加载一个精灵表,其中不仅包括每个未受损版本,还包括我们在这些对象被摧毁时要播放的摧毁序列。

在这个示例中使用三个不同的精灵表只是为了方便。当您决定如何打包精灵表以供生产时,有几个考虑因素。您很可能希望根据需要打包精灵表。您可能会选择根据游戏的级别来打包精灵表。您还需要考虑到,出于性能原因,WebGL 需要大小为 2 的幂的精灵文件。这可能会影响您关于将哪些精灵打包到哪些精灵表中的决定。您还可以考虑购买一个像 Texture Packer 这样的工具,以便比手工更快地为您打包精灵。

我们已经创建了三个精灵表来替换我们之前使用的三个精灵。这些Sprites分别是FranchiseExp.png替换Franchise.pngBirdOfAngerExp.png替换BirdOfAnger.png,以及ProjectileExp.png替换Projectile.png。我们需要对Projectile类、Ship类、EnemyShip类、PlayerShip类以及ProjectilePool类,以及game_loop函数进行一些调整。

我们将从修改游戏循环开始,以跟踪游戏的时间数据。我们必须从player_ship.cpp文件中的PlayerShip::Move函数中删除一些代码。这段代码存在于第四章中,使用 SDL 在 WebAssembly 中进行精灵动画,我们讨论了通过对PlayerShip进行动画化来实现精灵动画的基础知识。我们必须从PlayerShip::Move的前几行中删除以下代码:

current_time = SDL_GetTicks();
diff_time = current_time - last_time;
delta_time = (double)diff_time / 1000.0;
last_time = current_time;

这段代码获取当前时间并计算我们用于速度调整和动画定时的所有时间相关信息。我们可能应该在几章前将这段代码移到游戏循环中,但迟做总比不做好。以下是main.cpp中新的game_loop函数的代码:

void game_loop() {
    current_time = SDL_GetTicks();
    diff_time = current_time - last_time;
    delta_time = (double)diff_time / 1000.0;
    last_time = current_time;
    input();
    move();
    render();
}

严格来说,我们不必进行这种更改,但将游戏时间代码放在游戏循环中更合理。现在我们已经改变了游戏循环,我们将修改Projectile类。以下是我们必须在game.hpp文件中进行的类定义更改:

class Projectile: public Collider {
    public:
        const char* c_SpriteFile = "sprites/ProjectileExp.png";
        const int c_Width = 16;
        const int c_Height = 16;
        const double velocity = 6.0;
        const double alive_time = 2000;
        SDL_Texture *m_SpriteTexture;
        SDL_Rect src = {.x = 0, .y = 0, .w = 16, .h = 16 };
        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;
        bool m_Active;

        float m_TTL;
        float m_VX;
        float m_VY;

        Projectile();
        void Move();
        void Render();
        void Launch(float x, float y, float dx, float dy);
};

我们需要修改c_SpriteFile变量,使其指向新的精灵表 PNG 文件,而不是单个精灵文件。我们需要增加宽度和高度。为了为爆炸腾出空间,我们将使精灵表中的所有帧都变为 16 x 16,而不是 8 x 8。我们还需要一个源矩形。当每个精灵使用整个文件时,我们可以将null传递给SDL_RenderCopy,函数将渲染精灵文件的全部内容。现在我们只想渲染一帧,所以我们需要一个从 0,0 开始并渲染宽度和高度为 16 的矩形。我们创建的精灵表是水平条形精灵表,这意味着每一帧都按顺序排列并水平放置。要渲染动画的不同帧,我们只需要修改源矩形中的.x值。我们添加的最后一个属性是公共部分的m_CurrentFrame属性。它跟踪我们当前所处动画的帧。当我们不渲染爆炸动画时,我们将保持当前帧为 0。

接下来,我们需要修改Projectile类上的几个函数。这些函数是projectile.cpp文件中的Projectile::Move函数和Projectile::Render函数。这是Projectile::Move函数的新版本:

void Projectile::Move() {
    if( m_CurrentFrame > 0 ) {
        m_NextFrameTime -= diff_time;
        if( m_NextFrameTime <= 0 ) {
            ++m_CurrentFrame;
            m_NextFrameTime = ms_per_frame;
            if( m_CurrentFrame >= 4 ) {
                m_Active = false;
                m_CurrentFrame = 0;
                return;
            }
        }
        return;
    }
    m_X += m_VX;
    m_Y += m_VY;
    m_TTL -= diff_time;
    if( m_TTL < 0 ) {
        m_Active = false;
        m_TTL = 0;
    }
}

Move函数的顶部部分都是新的。如果当前帧不是0,我们将运行动画直到结束,然后停用我们的抛射物,将其发送回抛射物池。我们通过减去自上次应用程序运行游戏循环以来的时间来实现这一点。这是存储在diff_time全局变量中的值。m_NextFrameTime属性变量存储了切换到我们系列中下一帧的毫秒数。一旦值低于 0,我们就会增加当前帧并将m_NextFrameTime重置为我们希望在动画的每一帧之间的毫秒数。现在我们已经增加了当前动画帧,我们可以检查它是否大于或等于此动画中最后一帧的帧数(在本例中为 4)。如果是,我们需要停用抛射物并将当前帧重置为 0。

现在,我们已经对Move()函数进行了所需的更改,接下来我们需要对Projectile::Render()函数进行以下更改:

void Projectile::Render() {
    dest.x = m_X + 8;
    dest.y = m_Y + 8;
    dest.w = c_Width;
    dest.h = c_Height;
    src.x = 16 * m_CurrentFrame;
    int return_val = SDL_RenderCopy( renderer, m_SpriteTexture,
                                    &src, &dest );
    if( return_val != 0 ) {
        printf("SDL_Init failed: %s\n", SDL_GetError());
    }
}

Render函数的第一个更改是在SDL_RenderCopy调用中添加了src矩形,并在该调用的上方立即设置了它的x值。我们的精灵表中的每一帧都是 16 像素宽,所以将x值设置为16 * m_CurrentFrame将从精灵表中选择一个不同的 16 x 16 精灵。该矩形的宽度和高度将始终为 16,y值将始终为 0,因为我们将精灵放入这个精灵表中作为水平条带。

现在我们要对game.hpp文件中的Ship类定义进行一些修改:

class Ship: public Collider {
    public:
        Uint32 m_LastLaunchTime;
        const int c_Width = 32;
        const int c_Height = 32;

        SDL_Texture *m_SpriteTexture;
        SDL_Rect src = {.x = 0, .y = 0, .w = 32, .h = 32 };
        bool m_Alive = true;
        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;

        float m_Rotation;
        float m_DX;
        float m_DY;
        float m_VX;
        float m_VY;

        void RotateLeft();
        void RotateRight();
        void Accelerate();
        void Decelerate();
        void CapVelocity();

        virtual void Move() = 0;
        Ship();
        void Render();
};

我们修改了宽度和高度常量以反映精灵表中新的精灵大小为 32 x 32 像素。我们还必须为Projectile类添加一个源矩形。在我们的公共属性部分,我们添加了一些变量来跟踪飞船的存活或死亡状态(m_Alive);游戏正在渲染的当前帧(m_CurrentFrame);以及直到渲染下一帧的毫秒数(m_NextFrameTime)。接下来,我们将对ship.cpp文件进行必要的修改。我们需要修改Ship::Render函数:

void Ship::Render() {
    if( m_Alive == false ) {
        return;
    }
    dest.x = (int)m_X;
    dest.y = (int)m_Y;
    dest.w = c_Width;
    dest.h = c_Height;

    src.x = 32 * m_CurrentFrame;
    float degrees = (m_Rotation / PI) * 180.0;
    int return_code = SDL_RenderCopyEx( renderer, m_SpriteTexture,
                                    &src, &dest,
                                    degrees, NULL, SDL_FLIP_NONE );
    if( return_code != 0 ) {
        printf("failed to render image: %s\n", IMG_GetError() );
    }
}

在函数顶部,我们添加了代码来检查飞船当前是否存活。如果不是,我们就不想渲染飞船,所以我们返回。稍后,我们将源矩形x值设置为当前帧的 32 倍,代码如下:src.x = 32 * m_CurrentFrame;。这样我们的渲染就会根据我们想要渲染的帧来渲染来自精灵表的不同的 32 x 32 像素块。最后,我们必须将src矩形传递给SDL_RenderCopyEx调用。

现在我们已经修改了Ship类,我们将改变EnemyShip类定义和PlayerShip类定义,以使用我们的精灵表 PNG 文件,而不是旧的单个精灵文件。以下是game.hpp文件中这两个类定义的修改:

class PlayerShip: public Ship {
    public:
        const char* c_SpriteFile = "sprites/FranchiseExp.png";
        const Uint32 c_MinLaunchTime = 300;
        PlayerShip();
        void Move();
};

class EnemyShip: public Ship {
    public:
        const char* c_SpriteFile = "sprites/BirdOfAngerExp.png";
        const Uint32 c_MinLaunchTime = 300;
        const int c_AIStateTime = 2000;

        FSM_STUB m_AIState;
        int m_AIStateTTL;

        EnemyShip();
        void AIStub();
        void Move();
};

对这些类定义的唯一更改是在每个类中的c_SpriteFile常量的值。PlayerShip类中的c_SpriteFile常量从"sprites/Franchise.png"修改为"sprites/FranchiseExp.png"EnemyShip中的c_SpriteFile常量从"sprites/BirdOfAnger.png"修改为"sprites/BirdOfAngerExp.png"。现在我们已经做出了这个改变,这些类将使用精灵表.png文件而不是原始精灵文件。

现在我们已经修改了这些类的定义,我们必须改变它们的Move函数。首先,我们将修改enemy_ship.cpp文件中的EnemyShip::Move函数:

void EnemyShip::Move() {
    if( m_Alive == false ) {
        return;
    }
    AIStub();

    if( m_AIState == TURN_LEFT ) {
        RotateLeft();
    }
    if( m_AIState == TURN_RIGHT ) {
        RotateRight();
    }
    if( m_AIState == ACCELERATE ) {
        Accelerate();
    }
    if( m_AIState == DECELERATE ) {
        Decelerate();
    }

    if( m_CurrentFrame > 0 ) {
        m_NextFrameTime -= diff_time;

        if( m_NextFrameTime <= 0 ) {
            m_NextFrameTime = ms_per_frame;
            if( ++m_CurrentFrame >= 8 ) {
                m_Alive = false;
                return;
            }
        }
    }
    CapVelocity();

    m_X += m_VX;

    if( m_X > 320 ) {
        m_X = -16;
    }
    else if( m_X < -16 ) {
        m_X = 320;
    }

    m_Y += m_VY;

    if( m_Y > 200 ) {
        m_Y = -16;
    }
    else if( m_Y < -16 ) {
        m_Y = 200;
    }

    if( m_AIState == SHOOT ) {
        Projectile* projectile;
        if( current_time - m_LastLaunchTime >= c_MinLaunchTime ) {
            m_LastLaunchTime = current_time;
            projectile = projectile_pool->GetFreeProjectile();

            if( projectile != NULL ) {
                projectile->Launch( m_X, m_Y, m_DX, m_DY );
            }
        }
    }
}

有两个地方需要更改代码。首先,如果敌方飞船不再存活,我们不想执行任何Move函数的工作,所以我们在函数开头添加了这个检查来返回,如果飞船不再存活:

if( m_Alive == false ) {
    return;
}

接下来,我们需要添加代码来检查是否需要运行死亡动画。如果当前帧大于 0,我们就会这样做。这一部分的代码与我们为抛射物运行死亡动画所做的类似。我们从下一帧时间(m_NextFrameTime)中减去帧之间的时间(diff_time),以确定是否需要增加帧数。当这个值低于 0 时,帧数准备好改变,通过增加m_CurrentFrame,然后我们通过将其设置为每帧之间的毫秒数(ms_per_frame)来重置m_NextFrameTime倒计时器。如果我们当前的帧达到了我们帧精灵表的末尾,(++m_CurrentFrame >= 8),那么我们将敌方飞船设置为不再存活,(m_Alive = false)。如下所示:

if( m_CurrentFrame > 0 ) {
    m_NextFrameTime -= diff_time;
    if( m_NextFrameTime <= 0 ) {
        m_NextFrameTime = ms_per_frame;
        if( ++m_CurrentFrame >= 8 ) {
            m_Alive = false;
            return;
        }
    }
}

现在,我们将对player_ship.cpp文件中的PlayerShip::Move函数进行相同的更改:

void PlayerShip::Move() {
    if( m_Alive == false ) {
        return;
    }
    if( left_key_down ) {
        RotateLeft();
    }
    if( right_key_down ) {
        RotateRight();
    }
    if( up_key_down ) {
        Accelerate();
    }
    if( down_key_down ) {
        Decelerate();
    }
    if( m_CurrentFrame > 0 ) {
        m_NextFrameTime -= diff_time;
        if( m_NextFrameTime <= 0 ) {
            m_NextFrameTime = ms_per_frame;
            if( ++m_CurrentFrame >= 8 ) {
                m_Alive = false;
                return;
            }
        }
    }
    CapVelocity();
    m_X += m_VX;

    if( m_X > 320 ) {
        m_X = -16;
    }
    else if( m_X < -16 ) {
        m_X = 320;
    }

    m_Y += m_VY;

    if( m_Y > 200 ) {
        m_Y = -16;
    }
    else if( m_Y < -16 ) {
        m_Y = 200;
    }

    if( space_key_down ) {
        Projectile* projectile;
        if( current_time - m_LastLaunchTime >= c_MinLaunchTime ) {
            m_LastLaunchTime = current_time;
            projectile = projectile_pool->GetFreeProjectile();
            if( projectile != NULL ) {
                projectile->Launch( m_X, m_Y, m_DX, m_DY );
            }
        }
    }
}

就像在我们的EnemyShip::Move函数中一样,我们添加了一个检查,看玩家是否还活着,代码如下:

if( m_Alive == false ) {
    return;
}

我们还添加了一些代码,如果当前帧大于 0,就运行死亡动画:

if( m_CurrentFrame > 0 ) {
    m_NextFrameTime -= diff_time;
    if( m_NextFrameTime <= 0 ) {
        m_NextFrameTime = ms_per_frame;
        if( ++m_CurrentFrame >= 8 ) {
            m_Alive = false;
            return;
        }
    }
}

我们需要做的最后一件事是修改我们之前添加到ProjectilePool::MoveProjectiles函数中的碰撞检测代码,以便在两者碰撞时运行飞船和抛射物的死亡动画。这是projectile_pool.cpp文件中ProjectilePool::MoveProjectiles的新版本:

void ProjectilePool::MoveProjectiles() {
    Projectile* projectile;
    std::vector<Projectile*>::iterator it;
    for( it = m_ProjectileList.begin(); it != m_ProjectileList.end(); it++ ) {
        projectile = *it;
        if( projectile->m_Active ) {
            projectile->Move();
            if( projectile->m_CurrentFrame == 0 &&
                player->m_CurrentFrame == 0 &&
                projectile->HitTest( player ) ) {

                player->m_CurrentFrame = 1;
                player->m_NextFrameTime = ms_per_frame;
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
            if( projectile->m_CurrentFrame == 0 &&
                enemy->m_CurrentFrame == 0 &&
                projectile->HitTest( enemy ) ) {

                enemy->m_CurrentFrame = 1;
                enemy->m_NextFrameTime = ms_per_frame;
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
        }
    }
}

在这段代码中,每次移动抛射物时,我们都会对该抛射物和玩家进行碰撞测试,以及对该抛射物和敌人进行碰撞测试。如果飞船或抛射物正在运行死亡动画(m_CurrentFrame == 0为 false),那么我们就不需要运行碰撞测试,因为飞船或抛射物已经被摧毁。如果碰撞测试返回 true,那么我们需要将抛射物和飞船的当前帧都设置为 1,开始摧毁动画。我们还需要将下一帧时间设置为直到帧变化的毫秒数。

现在我们已经添加了所有这些新代码,飞船和敌人飞船将运行一个爆炸动画,当被击中时摧毁飞船。抛射物也会爆炸而不是只是消失。圆形碰撞器速度很快,但不太精确。在实现复合圆形碰撞器部分,我们将学习需要进行的修改,以便在单个飞船上使用多个圆形碰撞器。这将使我们的碰撞看起来比简单的圆更准确。

内存中的指针

WebAssembly 的内存模型基于 asm.js 内存模型,后者使用一个大型的类型化ArrayBuffer来保存模块要操作的所有原始字节。JavaScript 调用WebAssembly.Memory设置模块的内存缓冲区为 64KB 的页面

页面是线性数据块,是操作系统或 WebAssembly 虚拟机分配的最小数据单元。有关内存页面的更多信息,请参阅维基百科页面:en.wikipedia.org/wiki/Page_%28computer_memory%29

WebAssembly 模块只能访问ArrayBuffer中的数据。这可以防止 WebAssembly 对浏览器沙盒之外的内存地址进行恶意攻击。由于这种设计,WebAssembly 的内存模型与 JavaScript 一样安全。

在下一节中,我们将在collider对象中使用 C++指针。如果您是 JavaScript 开发人员,您可能不熟悉指针。指针是一个保存内存位置而不是直接值的变量。让我们看一些代码:

int VAR1 = 1;
int* POINTER = &VAR1;

在这段代码中,我们创建了一个名为VAR1的变量,并给它赋了一个值 1。在第二行,我们使用int*创建了一个名为POINTER的指针。然后我们使用&字符将该指针初始化为VAR1的地址,这在 C++中被称为地址运算符。这个运算符给出了我们之前声明的VAR1的地址。如果我们想要改变VAR1,我们可以使用指针而不是直接改变,如下所示:

*POINTER = 2;
 printf("VAR1=%d\n", VAR1); // prints out "VAR1=2"

POINTER前面加上*告诉 C++设置内存地址中POINTER指向的值;在这种情况下使用的*被称为解引用运算符

如果您想了解有关 C++中指针及其工作原理的更多信息,以下文章详细介绍了这个主题:www.cplusplus.com/doc/tutorial/pointers/

在下一节中,我们将为太空飞船的碰撞检测实现复合圆形碰撞器。

实现复合圆形碰撞器

现在我们的碰撞检测已经起作用,我们的飞船和抛射物在碰撞时爆炸,让我们看看如何使我们的碰撞检测更好。我们选择圆形碰撞检测有两个原因:碰撞算法快速且简单。然而,我们可以通过简单地为每艘飞船添加更多的圆形来做得更好。这将使我们的碰撞检测时间增加n倍,其中n是我们每艘飞船上平均圆形的数量。这是因为我们只进行抛射物和飞船之间的碰撞检测。即使如此,我们也不希望在选择用于每艘飞船的圆形数量上过火。

对于玩家飞船,飞船的前部由基本圆形很好地覆盖。然而,通过在每一侧添加一个圆形,我们可以更好地覆盖玩家飞船的后部:

[我们的玩家飞船复合碰撞器]

敌方飞船则相反。飞船的后部被默认圆形很好地覆盖,但前部需要更好的覆盖,因此对于敌方飞船,我们将在前部添加一些额外的圆形:

[我们的敌方飞船复合碰撞器]

我们需要做的第一件事是改变Collider类,以包括来自我们碰撞器父级的信息。以下是我们game.hpp文件中Collider类定义的新版本:

class Collider {
    public:
        float* m_ParentRotation;
        float* m_ParentX;
        float* m_ParentY;
        float m_X;
        float m_Y;
        float m_Radius;

        bool CCHitTest( Collider* collider );
        void SetParentInformation( double* rotation, double* x, double* 
                                   y );
        Collider(double radius);
        bool HitTest( Collider *collider );
};

我们已经为我们的Collider类的父级属性添加了三个指针。这些指针将指向碰撞器的父级的xy坐标,以及Rotation,它可能是敌方飞船、玩家飞船或NULL。我们将在构造函数中将这些值初始化为NULL,如果该值为 null,我们将不修改碰撞器的行为。然而,如果这些值设置为其他值,我们将调用CCHitTest函数来确定是否有碰撞。这个碰撞测试的版本将在进行碰撞测试之前,调整碰撞器的位置,使其相对于其父级的位置和旋转。现在我们已经对碰撞器的定义进行了更改,我们将对collider.cpp文件中的函数进行更改,以支持新的复合碰撞器。

首先要做的是修改我们的构造函数,将新指针初始化为NULL

Collider::Collider(double radius) {
    m_ParentRotation = NULL;
    m_ParentX = NULL;
    m_ParentY = NULL;
    m_Radius = radius;
}

我们有一个新的函数要添加到我们的collider.cpp文件中,即CCHitTest函数,它将是我们的复合碰撞器碰撞测试。这个碰撞测试的版本将调整我们的碰撞器的xy坐标,使其相对于父级飞船的位置和旋转:

bool Collider::CCHitTest( Collider* collider ) {
    float sine = sin(*m_ParentRotation);
    float cosine = cos(*m_ParentRotation);
    float rx = m_X * cosine - m_Y * sine;
    float ry = m_X * sine + m_Y * cosine;
    float dist_x = (*m_ParentX + rx) - collider->m_X;
    float dist_y = (*m_ParentY + ry) - collider->m_Y;
    float radius = m_Radius + collider->m_Radius;

    if( dist_x * dist_x + dist_y * dist_y <= radius * radius ) {
        return true;
    }
    return false;
}

这个函数的第一件事是取父级旋转的正弦和余弦,并使用该旋转来得到变量rxryxy的旋转版本。然后我们调整旋转后的xy位置,通过父级的xy位置,然后计算两个碰撞器xy位置之间的距离。在我们添加了这个新的CCHitTest函数之后,我们需要修改HitTest函数,以便在设置了父级值时调用这个版本的碰撞测试。以下是HitTest的最新版本:

bool Collider::HitTest( Collider *collider ) {
    if( m_ParentRotation != NULL && m_ParentX != NULL && m_ParentY !=         NULL ) {
        return CCHitTest( collider );
    }

    float dist_x = m_X - collider->m_X;
    float dist_y = m_Y - collider->m_Y;
    float radius = m_Radius + collider->m_Radius;

    if( dist_x * dist_x + dist_y * dist_y <= radius * radius ) {
        return true;
    }
    return false;
}

我们创建了一个名为SetParentInformation的函数来设置所有这些值。以下是函数定义:

void Collider::SetParentInformation( float* rotation, float* x, float* y ) {
    m_ParentRotation = rotation;
    m_ParentX = x;
    m_ParentY = y;
}

为了利用这些新类型的碰撞器,我们需要在Ship类中添加一个新的碰撞器向量。以下是game.hpp文件中Ship的新类定义:

class Ship : public Collider {
    public:
        Uint32 m_LastLaunchTime;
        const int c_Width = 32;
        const int c_Height = 32;

        SDL_Texture *m_SpriteTexture;
        SDL_Rect src = {.x = 0, .y = 0, .w = 32, .h = 32 };
        std::vector<Collider*> m_Colliders;
        bool m_Alive = true;
        Uint32 m_CurrentFrame = 0;

        int m_NextFrameTime;
        float m_Rotation;
        float m_DX;
        float m_DY;
        float m_VX;
        float m_VY;

        void RotateLeft();
        void RotateRight();
        void Accelerate();
        void Decelerate();
        void CapVelocity();
        virtual void Move() = 0;
        Ship();
        void Render();
        bool CompoundHitTest( Collider* collider );
};

这个版本和Ship类的上一个版本之间有两个不同之处。第一个是添加了m_Colliders向量属性:

 std::vector<Collider*> m_Colliders;

第二个变化是在类底部添加的新的CompoundHitTest函数:

bool CompoundHitTest( Collider* collider );

对于我们的类的更改,我们需要在ship.cpp文件中添加一个新的函数:

bool Ship::CompoundHitTest( Collider* collider ) {
    Collider* col;
    std::vector<Collider*>::iterator it;
    for( it = m_Colliders.begin(); it != m_Colliders.end(); it++ ) {
        col = *it;
        if( col->HitTest(collider) ) {
            return true;
        }
    }
    return false;
}

这个CompoundHitTest函数是一个非常简单的函数,它循环遍历我们的额外碰撞器并对它们执行碰撞测试。这一行创建了一个碰撞器指针的向量。现在我们将修改EnemyShipPlayerShip构造函数,将一些碰撞器添加到这个向量中。首先,我们将在enemy_ship.cpp文件中的EnemyShip构造函数中添加一些新的行:

EnemyShip::EnemyShip() {
    m_X = 60.0;
    m_Y = 50.0;
    m_Rotation = PI;
    m_DX = 0.0;
    m_DY = 1.0;
    m_VX = 0.0;
    m_VY = 0.0;
    m_AIStateTTL = c_AIStateTime;
    m_Alive = true;
    m_LastLaunchTime = current_time;

    Collider* temp_collider = new Collider(2.0);
    temp_collider->SetParentInformation( &(this->m_Rotation),
                                         &(this->m_X), &(this->m_Y) );
    temp_collider->m_X = -6.0;
    temp_collider->m_Y = -6.0;
    m_Colliders.push_back( temp_collider );
    temp_collider = new Collider(2.0);
    temp_collider->SetParentInformation( &(this->m_Rotation),
                                         &(this->m_X), &(this->m_Y) );
    temp_collider->m_X = 6.0;
    temp_collider->m_Y = -6.0;
    m_Colliders.push_back( temp_collider );

    SDL_Surface *temp_surface = IMG_Load( c_SpriteFile );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating enemy ship surface\n");
    }
    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );
    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating enemy ship texture\n");
    }
    SDL_FreeSurface( temp_surface );
}

我们添加的代码创建了新的碰撞器,并将这些碰撞器的父级信息设置为指向该对象内部的xy坐标以及半径的地址。我们将这个碰撞器的m_Xm_Y值相对于该对象的位置进行设置,然后将新的碰撞器推入m_Colliders向量属性中。

Collider* temp_collider = new Collider(2.0);
temp_collider->SetParentInformation( &(this->m_Rotation),
                                     &(this->m_X), &(this->m_Y) );
temp_collider->m_X = -6.0;
temp_collider->m_Y = -6.0;
m_Colliders.push_back( temp_collider );
temp_collider = new Collider(2.0);
temp_collider->SetParentInformation( &(this->m_Rotation),
                                     &(this->m_X), &(this->m_Y) );
temp_collider->m_X = 6.0;
temp_collider->m_Y = -6.0;
m_Colliders.push_back( temp_collider );

现在我们将在player_ship.cpp文件中对PlayerShip构造函数做类似的事情:

PlayerShip::PlayerShip() {
    m_X = 160.0;
    m_Y = 100.0;
    SDL_Surface *temp_surface = IMG_Load( c_SpriteFile );

    Collider* temp_collider = new Collider(3.0);
    temp_collider->SetParentInformation( &(this->m_Rotation),
                                         &(this->m_X), &(this->m_Y) );
    temp_collider->m_X = -6.0;
    temp_collider->m_Y = 6.0;
    m_Colliders.push_back( temp_collider );
    temp_collider = new Collider(3.0);
    temp_collider->SetParentInformation( &(this->m_Rotation),
                                         &(this->m_X), &(this->m_Y) );
    temp_collider->m_X = 6.0;
    temp_collider->m_Y = 6.0;
    m_Colliders.push_back( temp_collider );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }
    SDL_FreeSurface( temp_surface );
}

现在,我们必须修改我们的抛射物池,以在我们的飞船中对这些新的复合碰撞器进行碰撞检测。以下是projectile_pool.cpp文件中MoveProjectiles函数的修改版本:

void ProjectilePool::MoveProjectiles() {
    Projectile* projectile;
    std::vector<Projectile*>::iterator it;

    for( it = m_ProjectileList.begin(); it != m_ProjectileList.end(); 
         it++ ) {
        projectile = *it;
        if( projectile->m_Active ) {
            projectile->Move();
            if( projectile->m_CurrentFrame == 0 &&
                player->m_CurrentFrame == 0 &&
                ( projectile->HitTest( player ) ||
                  player->CompoundHitTest( projectile ) ) ) {
                player->m_CurrentFrame = 1;
                player->m_NextFrameTime = ms_per_frame;
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
            if( projectile->m_CurrentFrame == 0 &&
                enemy->m_CurrentFrame == 0 &&
                ( projectile->HitTest( enemy ) ||
                  enemy->CompoundHitTest( projectile ) ) ) {
                enemy->m_CurrentFrame = 1;
                enemy->m_NextFrameTime = ms_per_frame;
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
        }
    }
}

因为我们在Ship类中继承了Collider,所以我们仍然会对玩家和敌人的飞船执行常规的碰撞测试。我们在Ship类中添加了对CompoundHitTest的调用,它循环遍历我们的m_Colliders向量,并对该向量中的每个碰撞器执行碰撞测试。

我们的复合碰撞器解决方案并不是通用的,大部分情况下,我们的碰撞检测也不是通用的。我们只检测飞船和抛射物之间的碰撞。我们目前没有执行任何飞船之间的碰撞检测。要实现通用的碰撞检测方法,我们需要实现空间分割。这将防止随着每个额外的碰撞器添加到我们的游戏中,碰撞检测数量呈指数级增长。

编译 collider.html

我们用来编译collider.html文件的命令与上一章中的编译命令类似。我们需要在命令行中添加一个新的collider.cpp文件,但除此之外应该是一样的。以下是编译collider.html所使用的命令:

em++ main.cpp collider.cpp ship.cpp enemy_ship.cpp player_ship.cpp projectile.cpp projectile_pool.cpp -std=c++17 --preload-file sprites -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -o collider.html

现在我们已经编译了collider.html,我们可以从我们选择的 Web 服务器中提供它,或者使用emrun运行它,并将其加载到 Web 浏览器中。这是它的样子:

[当被抛射物击中时,敌方飞船会爆炸]

请记住,您必须使用 Web 服务器或emrun来运行 WebAssembly 应用程序。如果您想使用emrun运行 WebAssembly 应用程序,您必须使用--emrun标志进行编译。Web 浏览器需要一个 Web 服务器来流式传输 WebAssembly 模块。如果您尝试直接从硬盘驱动器在浏览器中打开使用 WebAssembly 的 HTML 页面,那么 WebAssembly 模块将无法加载。

我没有像之前游戏截图那样截取整个浏览器,因为我想放大玩家飞船摧毁敌方飞船的画面。正如你所看到的,我们现在有了可以检测抛射物何时与飞船碰撞并在碰撞发生时摧毁飞船的碰撞器,从而运行爆炸动画。

总结

圆形碰撞体是我们现在需要的。它们快速高效,对于这样一个简单的游戏,你可能可以不用做更复杂的事情就能逃脱。我们添加了一个复合碰撞体来演示这个简单修改如何显著增加碰撞体的准确性。我们将在本书的后面添加更多的碰撞检测方法。在未来,我们将在游戏中添加小行星和一颗星星,并创建一个AI人工智能)代理来导航我们的游戏并攻击我们的玩家。这个代理最终需要知道它是否与玩家有视线,因此线碰撞检测将变得更加重要。我们的代理还希望快速扫描其附近的区域,以查看是否有任何必须避开的小行星。对于这个功能,我们将使用矩形碰撞。

2D 游戏有许多种碰撞检测技术,在本章中我们只是触及了皮毛。我们学会了如何实现一些基本的圆形碰撞体和复合碰撞体,并添加了代码来检测游戏中抛射物与玩家和敌人飞船之间的碰撞。这些类型的碰撞体快速而相对容易实现,但它们并非没有缺点。

你可能会注意到我们实现的简单碰撞体的一个缺点是,如果两个对象以足够高的相对速度相互穿过,它们可能会在不发生碰撞的情况下相互穿过。这是因为我们的对象在每一帧都有一个新的位置计算,并且它们不是连续地从 A 点移动到 B 点。如果需要一帧的时间从 A 点移动到 B 点,那么对象在两个点之间有效地传送。如果在这两个点之间有第二个对象,但当在 A 点或 B 点时我们没有与该对象发生碰撞,那么对象碰撞就会被忽略。这在我们的游戏中不应该是一个问题,因为我们将保持我们的最大对象速度相对较低。然而,在编写游戏时要记住这一点。

在下一章中,我们将构建一个工具来帮助我们配置粒子系统

第八章:基本粒子系统

粒子系统是一种图形技术,我们从发射器中发射大量精灵,并使这些精灵经历一个生命周期,在这个过程中它们以各种方式改变。我们在精灵的生命周期中加入了一些随机性,以创建各种有趣的效果,如爆炸、火花、雪、灰尘、火、发动机排气等。一些粒子效果可以与它们的环境互动。在我们的游戏中,我们将使用粒子效果来创建漂亮的发动机排气和飞船爆炸效果。

在本章中,您需要在构建中包含几个图像,以使该项目正常工作。确保您从项目的 GitHub 中包含/Chapter08/sprites/文件夹。如果您还没有下载 GitHub 项目,可以在这里在线获取:github.com/PacktPublishing/Hands-On-Game-Develop

这一章和下一章的开始起初会感觉像是一个离题。在接下来的两章中,我们将花费大量时间来处理游戏之外的内容。如果您对粒子系统感兴趣,我保证这将是值得的。当您创建一个粒子系统时,您会花费大量时间来调整它们,并玩耍以使它们看起来正确。在游戏中直接进行这样的操作将导致大量的编译和测试。我们需要的是一个工具,可以在将粒子系统添加到游戏之前配置和测试粒子系统。这一章和下一章的前半部分致力于构建这个工具。如果您对学习如何构建这个工具不感兴趣,您可以略过本章的文本,并从 GitHub 下载并编译该工具。如果您对学习 JavaScript、HTML 和 WebAssembly 如何在应用程序中交互感兴趣,本章和第九章 改进的粒子系统的前半部分是一个很好的教程,可以教您如何编写一个应用程序,而不仅仅是一个带有 WebAssembly 的游戏。

在本章中,我们将涵盖以下主题:

  • SVG 简介

  • 再次三角函数?

  • 添加 JavaScript

  • 简单的粒子发射器工具

  • Point 类

  • 粒子类

  • 发射器类

  • WebAssembly 接口函数

  • 编译和测试粒子发射器

添加到虚拟文件系统

这一部分将是一个简短的离题,因为我想花时间创建一个粒子系统设计工具,这将需要我们向 WebAssembly 虚拟文件系统添加文件。我们将添加一个类型为文件的输入元素,我们可以使用它将图像加载到虚拟文件系统中。我们需要检查正在加载的文件,以验证它是否是.png文件,如果是,我们将使用 WebAssembly 和 SDL 在画布上绘制和移动图像。

Emscripten 默认不会创建虚拟文件系统。因为我们需要使用一个最初没有任何内容的虚拟文件系统,我们需要向 em++传递以下标志,以强制 Emscripten 构建虚拟文件系统:-s FORCE_FILESYSTEM=1

我们将首先从第二章 HTML5 和 WebAssembly中复制canvas_shell.html,并使用它创建一个名为upload_shell.html的新 shell 文件。我们需要在处理文件加载的 JavaScript 中添加一些代码,并将该文件插入到 WebAssembly 虚拟文件系统中。我们还需要添加一个 HTML input元素,类型为file,直到Module对象加载完成才会显示。在下面的代码中,我们有新的 shell 文件:

<!doctype html><html lang="en-us">
<head><meta charset="utf-8"><meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Upload Shell</title>
    <link href="upload.css" rel="stylesheet" type="text/css">
</head>
<body>
    <canvas id="canvas" width="800" height="600" 
     oncontextmenu="event.preventDefault()"></canvas>
    <textarea class="em_textarea" id="output" rows="8"></textarea>
    <script type='text/javascript'>
        var canvas = null;
        var ctx = null;
        function ShowFileInput()         
            {document.getElementById("file_input_label")
            .style.display="block";}
        var Module = {
            preRun: [],
            postRun: [ShowFileInput],
            print: (function() {
                var element = document.getElementById('output');
                if (element) element.value = '';
                return function(text) {
                    if (arguments.length > 1)         
                    text=Array.prototype.slice.call(arguments).join('                     
                    ');
                    console.log(text);
                    if (element) {
                        element.value += text + "\n";
                        element.scrollTop = element.scrollHeight;
                } }; })(),
    printErr: function(text) {
        if (arguments.length > 1) 
        text=Array.prototype.slice.call(arguments).join(' ');
        if (0) { dump(text + '\n'); } 
        else { console.error(text); } },
    canvas: (function() {
        var canvas = document.getElementById('canvas');
        canvas.addEventListener("webglcontextlost", function(e) { 
        alert('WebGL context lost. You will need to reload the page.'); 
        e.preventDefault(); }, false);
        return canvas; })(),
    setStatus: function(text) {
        if (!Module.setStatus.last) Module.setStatus.last = { time: 
            Date.now(), text: '' };
        if (text === Module.setStatus.last.text) return;
        var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
        var now = Date.now();
        if (m && now - Module.setStatus.last.time < 30) return;
        Module.setStatus.last.time = now;
        Module.setStatus.last.text = text;
        if (m) { text = m[1]; }
        console.log("status: " + text);
    },
    totalDependencies: 0,
    monitorRunDependencies: function(left) {
        this.totalDependencies = Math.max(this.totalDependencies,left);
        Module.setStatus(left ? 'Preparing... (' + 
        (this.totalDependencies-left) + '/' +
         this.totalDependencies + ')' : 'All downloads complete.'); }
};
Module.setStatus('Downloading...');
window.onerror = function() {
    Module.setStatus('Exception thrown, see JavaScript console');
    Module.setStatus = function(text) { if (text) Module.printErr('[post-exception status] ' + text); };
};
function handleFiles(files) {
    var file_count = 0;
    for (var i = 0; i < files.length; i++) {
        if (files[i].type.match(/image.png/)) {
            var file = files[i];
            console.log("file name=" + file.name);
            var file_name = file.name;
            var fr = new FileReader();
            fr.onload = function (file) {
                var data = new Uint8Array(fr.result);
                Module.FS_createDataFile('/', file_name, data, true, 
                true, true);
                Module.ccall('add_image', 'undefined', ["string"], 
                [file_name]);
            };
            fr.readAsArrayBuffer(files[i]);
        }
    }
}
</script>
<input type="file" id="file_input" onchange="handleFiles(this.files)" />
<label for="file_input" id="file_input_label">Upload .png</label>
{{{ SCRIPT }}}
</body></html>

在头部,我们唯一做的更改是标题和样式表:

<title>Upload Shell</title>
<link href="upload.css" rel="stylesheet" type="text/css">

body标签中,我们不会对canvastextarea元素进行更改,但是 JavaScript 部分有显著的变化。我们将要做的第一件事是向 JavaScript 添加一个ShowFileInput函数,以显示file_input_label元素,该元素在我们的 CSS 中是隐藏的。您可以在以下代码片段中看到它:

function ShowFileInput() {
    document.getElementById("file_input_label").style.display = "block";
}

var Module = {
    preRun: [],
    postRun: [ShowFileInput],

请注意,我们已经在postRun数组中添加了对此函数的调用,以便在模块加载后运行。这是为了确保在Module对象加载之前没有人加载图像文件,而我们的页面可以处理它。除了将ShowFileInput添加到postRun数组中,Module对象保持不变。在Module对象代码之后,我们添加了一个handleFiles函数,当用户选择要加载的新文件时,文件输入元素将调用该函数。以下是该函数的代码:

function handleFiles(files) {
    var file_count = 0;
    for (var i = 0; i < files.length; i++) {
        if (files[i].type.match(/image.png/)) {
            var file = files[i];
            var file_name = file.name;
            var fr = new FileReader();

            fr.onload = function (file) {
                var data = new Uint8Array(fr.result);
                Module.FS_createDataFile('/', file_name, data, true, 
                true, true);
                Module.ccall('add_image', 'undefined', ["string"], 
                [file_name]);
            };
            fr.readAsArrayBuffer(files[i]);
        }
    }
}

您会注意到,该函数设计为通过循环遍历传入handleFilesfiles参数来处理多个文件。我们将首先检查图像文件类型是否为 PNG。在编译 WebAssembly 时,我们需要告诉它 SDL 将处理哪些图像文件类型。PNG 格式应该就足够了,但是在这里添加其他类型并不困难。

如果您不想专门检查 PNG 文件,可以省略匹配字符串的.png部分,然后在编译命令行参数中添加其他文件类型。如果文件是image/png类型,我们将文件名放入其变量file_name中,并创建一个FileReader对象。然后我们定义了FileReader加载文件时运行的函数:

fr.onload = function (file) {
    var data = new Uint8Array(fr.result);
    Module.FS_createDataFile('/', file_name, data, true, true, true);
    Module.ccall('add_image', 'undefined', ["string"], [file_name]);
};

该函数将数据作为 8 位无符号整数数组输入,然后将其传递到Module函数FS_createDataFile中。此函数的参数是一个字符串,表示我们文件的父目录'/',文件名file_name,我们从文件中读取的数据,以及canReadcanWritecanOwn,这些都应设置为true,因为我们希望我们的 WebAssembly 能够读取、写入和拥有此文件。然后,我们使用Module.ccall调用在我们的 WebAssembly 中定义的名为add_image的函数,该函数将获取文件名,以便我们的 WebAssembly 可以使用 SDL 将此图像呈现到 HTML 画布上。在定义告诉FileReader在加载文件时要执行的函数之后,我们必须指示FileReader继续读取已加载的文件作为ArrayBuffer

fr.readAsArrayBuffer(files[i]);

在 JavaScript 之后,我们添加了一个文件input元素和一个相应的标签,如下所示:

<input type="file" id="file_input" onchange="handleFiles(this.files)" />
<label for="file_input" id="file_input_label">Upload .png</label>

标签纯粹是为了样式。在 CSS 中,样式化输入文件元素并不是一件简单的事情。我们稍后会讨论如何做到这一点。在讨论 CSS 之前,我想先讨论一下我们将使用的 WebAssembly C 代码,以使用 SDL 加载和呈现此图像。以下代码将放在我们命名为upload.c的文件中:

#include <emscripten.h>
#include <stdlib.h>
#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>

SDL_Window *window;
SDL_Renderer *renderer;
char* fileName;
SDL_Texture *sprite_texture = NULL;
SDL_Rect dest = {.x = 160, .y = 100, .w = 16, .h = 16 };

int sprite_x = 0;
int sprite_y = 0;

void add_image(char* file_name) {
    SDL_Surface *temp_surface = IMG_Load( file_name );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    sprite_texture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );
    SDL_FreeSurface( temp_surface );
    SDL_QueryTexture( sprite_texture,
                        NULL, NULL,
                        &dest.w, &dest.h );
}

void show_animation() {
    if( sprite_texture == NULL ) {
        return;
    }

    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );

    sprite_x += 2;
    sprite_y++;

    if( sprite_x >= 800 ) {
        sprite_x = -dest.w;
    }

    if( sprite_y >= 600 ) {
        sprite_y = -dest.h;
    }
    dest.x = sprite_x;
    dest.y = sprite_y;

    SDL_RenderCopy( renderer, sprite_texture, NULL, &dest );
    SDL_RenderPresent( renderer );
}

int main() {
    printf("Enter Main\n");
    SDL_Init( SDL_INIT_VIDEO );

    int return_val = SDL_CreateWindowAndRenderer( 800, 600, 0, &window, 
    &renderer );

    if( return_val != 0 ) {
        printf("Error creating renderer %d: %s\n", return_val, 
        IMG_GetError() );
         return 0;
    }
    emscripten_set_main_loop(show_animation, 0, 0);
    printf("Exit Main\n");
    return 1;
}

在我们的新的upload.c文件中,我们定义了三个函数。第一个函数是add_image函数。此函数接受一个代表我们刚刚加载到 WebAssembly 虚拟文件系统中的文件的char*字符串。我们使用 SDL 将图像加载到表面中,然后使用该表面创建一个纹理,我们将使用它来呈现我们加载的图像。第二个函数是show_animation,我们用它来移动画布上的图像。第三个是main函数,它在模块加载时始终运行,因此我们用它来初始化我们的 SDL。

让我们快速看一下add_image函数:

void add_image(char* file_name) {
    SDL_Surface *temp_surface = IMG_Load( file_name );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    sprite_texture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );
    SDL_FreeSurface( temp_surface );
    SDL_QueryTexture( sprite_texture,
                        NULL, NULL,
                        &dest.w, &dest.h );
}

add_image函数中,我们首先使用传入的file_name参数将图像加载到SDL_Surface对象指针中,使用IMG_Load函数,该函数是SDL_image库的一部分:

SDL_Surface *temp_surface = IMG_Load( file_name );

如果加载失败,我们会打印错误消息并从函数中返回:

if( !temp_surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return;
}

如果没有失败,我们使用表面创建一个纹理,我们将能够在帧动画中渲染它。然后,我们释放表面,因为我们不再需要它:

sprite_texture = SDL_CreateTextureFromSurface( renderer, temp_surface );
SDL_FreeSurface( temp_surface );

我们最后要做的是使用SDL_QueryTexture函数获取图像的宽度和高度,并将这些值加载到dest矩形中:

SDL_QueryTexture( sprite_texture,
                  NULL, NULL,
                  &dest.w, &dest.h );

show_animation函数类似于我们过去编写的其他游戏循环。它应该在每一帧中运行,只要加载了精灵纹理,它应该清除画布,增加精灵的xy值,然后将精灵渲染到画布上:

void show_animation() {
    if( sprite_texture == NULL ) {
        return;
    }

    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );
    sprite_x += 2;
    sprite_y++;

    if( sprite_x >= 800 ) {
        sprite_x = -dest.w;
    }
    if( sprite_y >= 600 ) {
        sprite_y = -dest.h;
    }

    dest.x = sprite_x;
    dest.y = sprite_y;
    SDL_RenderCopy( renderer, sprite_texture, NULL, &dest );
    SDL_RenderPresent( renderer );
}

show_animation中我们要做的第一件事是检查sprite_texture是否仍然为NULL。如果是,用户尚未加载 PNG 文件,因此我们无法渲染任何内容:

if( sprite_texture == NULL ) {
    return;
}

接下来我们要做的是用黑色清除画布:

SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
SDL_RenderClear( renderer );

然后,我们将增加精灵的xy坐标,并使用这些值来设置dest(目标)矩形:

sprite_x += 2;
sprite_y++;
if( sprite_x >= 800 ) {
    sprite_x = -dest.w;
}
if( sprite_y >= 600 ) {
    sprite_y = -dest.h;
}
dest.x = sprite_x;
dest.y = sprite_y;

最后,我们将精灵渲染到后备缓冲区,然后将后备缓冲区移动到画布上:

SDL_RenderCopy( renderer, sprite_texture, NULL, &dest );
SDL_RenderPresent( renderer );

upload.c中的最后一个函数是main函数,当模块加载时调用。此函数用于初始化目的,如下所示:

int main() {
    printf("Enter Main\n");
    SDL_Init( SDL_INIT_VIDEO );
    int return_val = SDL_CreateWindowAndRenderer( 800, 600, 0, &window, 
    &renderer );

    if( return_val != 0 ) {
        printf("Error creating renderer %d: %s\n", return_val, 
        IMG_GetError() );
        return 0;
    }

    emscripten_set_main_loop(show_animation, 0, 0);
    printf("Exit Main\n");
    return 1;
}

它调用了一些 SDL 函数来初始化我们的 SDL 渲染器:

SDL_Init( SDL_INIT_VIDEO );
int return_val = SDL_CreateWindowAndRenderer( 800, 600, 0, &window, &renderer );

if( return_val != 0 ) {
    printf("Error creating renderer %d: %s\n", return_val, 
    IMG_GetError() );
    return 0;
}

然后,它设置show_animation函数在每次渲染帧时运行:

emscripten_set_main_loop(show_animation, 0, 0);

我们最后要做的是设置一个 CSS 文件,以正确显示我们外壳文件中的 HTML。以下是新的upload.css文件的内容:

body {
    margin-top: 20px;
}
#output {
    background-color: darkslategray;
    color: white;
    font-size: 16px;
    padding: 10px;
    margin-left: auto;
    margin-right: auto;
    display: block;
    width: 780px;
}
#canvas {
    width: 800px;
    height: 600px;
    margin-left: auto;
    margin-right: auto;
    display: block;
    background-color: black;
    margin-bottom: 20px;
}
[type="file"] {
    height: 0;
    overflow: hidden;
    width: 0;
    display: none;
}

[type="file"] + label {
    background: orangered;
    border-radius: 5px;
    color: white;
    display: none;
    font-size: 20px;
    font-family: Verdana, Geneva, Tahoma, sans-serif;
    text-align: center;
    margin-top: 10px;
    margin-bottom: 10px;
    margin-left: auto;
    margin-right: auto;
    width: 130px;
    padding: 10px 50px;
    transition: all 0.2s;
    vertical-align: middle;
}
[type="file"] + label:hover {
    background-color: orange;
}

前几个类,body#output#canvas,与以前的 CSS 文件中的那些类并没有太大不同,所以我们不需要详细介绍。在这些类之后是一个看起来有点不同的 CSS 类:

[type="file"] {
 height: 0;
 overflow: hidden;
 width: 0;
 display: none;
 }

这定义了具有file类型的input元素的外观。由于使用 CSS 直接为文件输入元素设置样式并不是非常直接。我们将使用display: none;属性隐藏元素,然后创建一个样式化的标签,如下所示:

[type="file"] + label {
    background: orangered;
    border-radius: 5px;
    color: white;
    display: none;
    font-size: 20px;
    font-family: Verdana, Geneva, Tahoma, sans-serif;
    text-align: center;
    margin-top: 10px;
    margin-bottom: 10px;
    margin-left: auto;
    margin-right: auto;
    width: 130px;
    padding: 10px 50px;
    transition: all 0.2s;
    vertical-align: middle;
}
[type="file"] + label:hover {
    background-color: orange;
}

因此,在 HTML 中,我们在输入文件元素之后立即添加了一个标签元素。您可能会注意到,我们的标签也将display设置为none。这样用户就无法在Module对象加载之前使用该元素上传 PNG 文件。如果您回顾一下我们 HTML 外壳文件中的 JavaScript,我们在postRun上调用了以下代码,以便在Module加载后使标签可见:

function ShowFileInput() {
    document.getElementById("file_input_label").style.display = 
    "block";
}

现在,我们应该有一个可以将图像加载到 WebAssembly 虚拟文件系统中的应用程序。在接下来的几节中,我们将扩展此应用程序以配置和测试一个简单的粒子发射器。

对 SVG 的简要介绍

SVG 代表可缩放矢量图形,是 HTML 画布中进行即时模式栅格图形渲染的一种替代方案。SVG 是一种基于 XML 的图形渲染语言,对于熟悉 HTML 的人来说应该至少有些熟悉。SVG 标记可以直接放在 HTML 中,并像任何其他 DOM 节点一样访问。因为我们正在编写一个用于配置粒子发射器数据的工具,我们将在我们的应用程序中添加 SVG 以进行数据可视化。

矢量与栅格图形

作为游戏开发者,您可能不熟悉矢量图形。当我们渲染计算机图形时,无论我们使用什么格式,它们都需要在游戏在计算机屏幕上显示之前被栅格化为像素网格。使用栅格图形意味着在像素级别处理我们的图像。另一方面,矢量图形涉及以不同的抽象级别处理图形,我们处理线条、点和曲线。最终,基于矢量的图形引擎仍然必须弄清楚它正在处理的线条、点和曲线如何转换为像素,但处理矢量图形并非没有好处。它们如下:

  • 矢量图形可以被清晰地缩放

  • 矢量图形允许更小的下载

  • 矢量图形可以在运行时轻松修改

在网络上使用矢量图形的一个甜蜜点是数据可视化。这本书不是关于 SVG 或数据可视化的,而且 SVG 目前还不够快,不能用于大多数应用程序的游戏渲染。然而,当你想要在网站上呈现数据时,它是一个有用的工具。我们将在我们的粒子发射器配置工具中添加一些 SVG 作为视觉辅助,帮助用户看到发射器配置为发射粒子的方向。因为我们将使用它作为视觉辅助,所以没有必要将其放在我们的应用程序中。

我们要做的第一件事是在我们的 HTML 中添加一些标签。我们需要一个 SVG 标签来设置一个可以用来绘制矢量圆形图形的区域。我们还需要一些输入值,允许我们输入两个角度,角度的值以度为单位。这两个输入字段将取最小和最大角度来发射粒子。当这个工作起来时,它将给我们的粒子发射一些方向。这是我们需要添加到body标签的 HTML 代码:

<svg id="pie" width="200" height="200" viewBox="-1 -1 2 2"></svg>
 <br/>
 <div style="margin-left: auto; margin-right: auto">
 <span class="label">min angle:</span>
 <input type="number" id="min_angle" max="359" min="-90" step="1" 
  value="-20" class="em_input"><br/>
 <span class="label">max angle:</span>
 <input type="number" id="max_angle" max="360" min="0" step="1" 
  value="20" class="em_input"><br/>
 </div>

我们在svg标签中将id设置为 pie。这将允许我们稍后用线和弧修改这个标签内的值。我们给它设置了高度和宽度为200像素。

viewbox设置为-1 -1 2 2。这表示我们的 SVG 绘图区域的左上坐标设置为坐标-1,-1。接下来的两个数字2 2是 SVG 绘图区域中的宽度和高度。这意味着我们的绘图空间将从左上角的坐标-1,-1到右下角的坐标1,1。这将使我们在需要计算角度时更容易处理正弦和余弦值。

三角学又来了?

天啊,是的,还有更多的三角学。我已经在第七章中介绍了基本的三角学,碰撞检测,但信不信由你,三角学在游戏开发中真的很有用。三角学碰巧对粒子系统非常有用,我们将使用 SVG 和一些三角函数来构建一个小的饼图,以便可视化我们的粒子发射器的方向。所以,让我们花点时间快速复习一下:

  • 正弦=对边/斜边(SOH)

  • 余弦=邻边/斜边(CAH)

  • 正切=对边/邻边(TOA)

还记得 SOHCAHTOA 这个词吗?

如果我们使用的是 2D 笛卡尔坐标系(剧透警告,我们是),在我们的情况中对边就是Y坐标,邻边就是X坐标。因此,在 2D 笛卡尔坐标系中,我们的比率如下:

  • 正弦=Y/圆半径

  • 余弦=X/圆半径

  • 正切=Y/X

如果你在 JavaScript 数学库中调用cos(余弦)或sin(正弦)等函数,通常传入的是以弧度为单位的角度。你将得到一个比值,如果你处理的是单位圆(半径为 1 的圆),那么这个比值就是余弦的X值和正弦的Y值。所以大多数时候,你只需要记住这个:

  • 如果你想要Y坐标,使用正弦

  • 如果你想要X坐标,使用余弦

我们之前用它来计算飞船的方向和速度。我们将在以后使用它来获得给定角度的粒子的方向和速度。而且,我们现在将使用它来找出如何绘制 SVG 图表,显示我们将发射粒子的角度。

我们正在使用两个不同的角度来获得一系列角度来发射粒子。因为我们希望我们的角度与 0 度的角度重叠,我们必须允许min_angle为负数。我们的最小角度可以从-90 度到 359 度,最大角度可以从 0 度到 360 度。

我更喜欢用度量角度而不是弧度。数学函数通常使用弧度,所以如果您更喜欢在界面中使用弧度,您可以省去转换的麻烦。弧度是基于单位圆的角度测量。单位圆的周长为。如果您用弧度测量角度,您是根据绕单位圆走多远来确定您的角度的。因此,如果您从单位圆的一侧走到另一侧,您需要走π的距离。因此π(以弧度表示)= 180 度。如果您想要一个圆的四分之一角度,您需要绕您的圆走π / 2的距离,所以π / 2 = 90 度。我仍然觉得 360 度的圆更直观,因为在学校时我们花了更多的时间学习度数。弧度只是提及而已。如果情况不是这样,我肯定会觉得用单位圆来测量我的角度更有意义。

360 度圆的概念只是直观的,因为当我们在学校时他们把它灌输给我们。我们之所以有这个圆的模型,只是因为我们从古巴比伦人那里继承了一个 60 进制的数学系统,这也是我们一分钟有 60 秒,一小时有 60 分钟的原因。

稍后,我们将使用 SVG 和一些三角函数来绘制一个小的饼图,代表粒子将从我们的粒子系统中发射的方向。我们需要这种方向性来创建我们的引擎排气粒子发射器:

图 8.1:我们的 SVG 饼图

在下一节中,我们将使用 JavaScript 实现我们的 SVG 饼图。

添加 JavaScript

现在我们已经讨论了一些绘制 SVG 图表所需的三角学知识,让我逐步介绍我们需要添加的 JavaScript 代码,使我们的代码能够运行:


<script>
    document.getElementById("min_angle").onchange = function() {
        var min_angle = Number(this.value);
        var max_angle = Number(document.getElementById         
                        ("max_angle").value);

        if( min_angle >= max_angle ) {
            max_angle = min_angle + 1;
            document.getElementById("max_angle").value = max_angle;
        }

        if( min_angle < this.min ) {
            min_angle = this.min;
            this.value = min_angle;
        }
        SetPie( min_angle / 180 * Math.PI, max_angle / 180 * Math.PI );
    }

    document.getElementById("max_angle").onchange = function() {
        var min_angle = Number(document.getElementById         
                        ("min_angle").value);
        var max_angle = Number(this.value);

        if( min_angle >= max_angle ) {
            min_angle = max_angle - 1;
            document.getElementById("min_angle").value = min_angle;
        }

        if( max_angle > this.max ) {
            max_angle = this.max;
            this.value = max_angle;
        }

        SetPie( min_angle / 180 * Math.PI, max_angle / 180 * Math.PI );
    }

    function SetPie( start_angle, end_angle ) {
        const svg = document.getElementById('pie');
        const start_x = Math.cos( start_angle );
        const start_y = Math.sin( start_angle );

        const end_x = Math.cos( end_angle );
        const end_y = Math.sin( end_angle );
        var arc_flag_1 = 0;
        var arc_flag_2 = 0;

        if( end_angle - start_angle <= 3.14) {
            arc_flag_1 = 0;
            arc_flag_2 = 1;
        }
        else {
            arc_flag_1 = 1;
            arc_flag_2 = 0;
        }

        const path_data_1 = 
            `M 0 0 L ${start_x} ${start_y} A 1 1 0 ${arc_flag_1} 1 
            ${end_x} ${end_y} L 0 0`;

        const path_1 = document.createElementNS         
        ('http://www.w3.org/2000/svg', 'path');
        path_1.setAttribute('d', path_data_1);
        path_1.setAttribute('fill', 'red');
        svg.appendChild(path_1);

        const path_data_2 = 
            `M 0 0 L ${end_x} ${end_y} A 1 1 0 ${arc_flag_2} 1 
             ${start_x} ${start_y} L 0 0`;

        const path_2 = 
        document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path_2.setAttribute('d', path_data_2);
        path_2.setAttribute('fill', 'blue');
        svg.appendChild(path_2);
    }

    SetPie( Number(document.getElementById("min_angle").value) / 180 *             
            Math.PI,
    Number(document.getElementById("max_angle").value) / 180 * Math.PI );
</script>

尽管这是代码中的最后一个函数,但我想首先解释SetPie函数,该函数用于设置显示用户输入的红色发射角范围的 SVG 饼图。很久以前,当我们设置 SVG 标签时,我们将viewport设置为从xy值为-11。这很好,因为使用Math.cosMath.sin将给我们单位圆XY坐标的值,单位圆的半径为1,所以这些值也将从-11运行。

我们使用document.getElementById('pie')从 DOM 中获取svg元素,以便根据角度值的变化对其进行修改。接下来,我们使用Math.cosMath.sin函数分别获取单位圆上的xy坐标。然后,我们使用end_angle来获取结束的xy坐标:

const end_x = Math.cos( end_angle );
const end_y = Math.sin( end_angle );

在 SVG 中,我们需要绘制两条路径。第一条路径将以红色绘制,表示粒子系统发射器发射粒子的角度。第二条路径将以蓝色绘制,表示我们不会发射粒子的发射圆的部分。当我们绘制 SVG 弧线时,我们给弧线两个点,并告诉它一个标志,如果我们需要绕圆走远的方式(钝角)或者走近的方式(锐角)。我们通过检查发射角是否小于π,并设置一个标志,将根据这个标志进入我们的 SVG 中:

if( end_angle - start_angle <= 3.14) {
    arc_flag_1 = 0;
    arc_flag_2 = 1;
}
else {
    arc_flag_1 = 1;
    arc_flag_2 = 0;
}

现在,我们需要定义路径数据并将其放入 SVG 路径对象中。以下代码设置了我们发射粒子的发射器部分的路径数据:

const path_data_1 = `M 0 0 L ${start_x} ${start_y} A 1 1 0 ${arc_flag_1} 1 ${end_x} ${end_y} L 0 0`;

const path_1 = document.createElementNS('http://www.w3.org/2000/svg',                                         
                                        'path');
path_1.setAttribute('d', path_data_1);
path_1.setAttribute('fill', 'red');
svg.appendChild(path_1);

一系列命令在 SVG 中定义路径数据。如果您查看path_data_1的定义,它以M 0 0开头,告诉 SVG 将光标移动到位置0, 0而不绘制。下一个命令是L ${start_x} ${start_y}。因为我们使用了字符串模板文字,${start_x}${start_y}会被start_xstart_y变量中的值替换。这个命令从我们在上一步移动到的当前位置(0,0)画一条线到坐标start_xstart_y。我们路径中的下一个命令是Arc命令,以A开头:A 1 1 0 ${arc_flag_1} 1 ${end_x} ${end_y}

前两个参数1 1是椭圆的xy半径。因为我们想要一个单位圆,这两个值都是1。之后的0是 SVG 在绘制椭圆时使用的X轴旋转。因为我们正在绘制一个圆,所以将其设置为0。之后的值是${arc_flag_1}。这用于设置大弧标志,告诉 SVG 我们是在绘制钝角弧(我们将值设置为 1)还是锐角弧(我们将值设置为 0)。之后的值是扫描标志。此标志确定我们是以顺时针(值为 1)还是逆时针(值为 0)方向绘制。我们总是希望以顺时针方向绘制,因此这个值将是 1。我们arc命令中的最后两个参数是${end_x} ${end_y}。这些值是我们弧的结束位置,我们之前通过获取结束角的余弦和正弦来确定这些值。完成弧后,我们通过使用L 0 0线命令画一条线回到0,0坐标来完成我们的形状。

在我们用红色绘制了发射角之后,我们通过从结束位置到起始位置绘制第二条路径,用蓝色覆盖了圆的其余部分。

在下一节中,我们将构建一个简单的粒子发射器配置工具。

简单的粒子发射器工具

现在我们已经创建了一个简单的 Web 应用程序,可以将 PNG 图像文件上传到 WebAssembly 虚拟文件系统,并且使用 SVG 图表显示粒子的发射方向,我们将添加一个简单的粒子系统配置工具。对于我们粒子系统配置工具的第一个版本,我们将保持可配置值的数量较少。稍后,我们将为我们的粒子系统工具添加更多功能,但目前这是我们将用来配置粒子发射器的参数列表:

  • 图像文件

  • 最小发射角度

  • 最大发射角度

  • 最大粒子

  • 粒子寿命(毫秒)

  • 粒子加速(或减速)

  • Alpha 淡出(粒子是否会随时间淡出?)

  • 发射速率(每秒发射的粒子数)

  • X 位置(发射器 x 坐标)

  • Y 位置(发射器 y 坐标)

  • 半径(离发射器位置有多远可以创建一个粒子?)

  • 最小起始速度

  • 最大起始速度

这将让我们创建一个非常基本的粒子发射器。我们将在下一节改进这个发射器,但我们需要从某个地方开始。我不打算讨论我们添加的任何 CSS 来增强这个工具的外观。我想要做的第一件事是覆盖将放入新外壳文件中的 HTML,我们称之为basic_particle_shell.html。我们需要添加一些 HTML input字段来接收我们之前讨论的所有可配置值。我们还需要一个按钮,在我们写入更改后更新发射器。

将以下代码添加到我们新外壳文件的<body>标签中:

<div class="container">
    <svg id="pie" width="200" height="200" viewBox="-1 -1 2 2"></svg>
    <br/>
    <div style="margin-left: auto; margin-right: auto">
        <span class="label">min angle:</span>
        <input type="number" id="min_angle" max="359" min="-90" 
         step="1" value="-20" class="em_input">
        <br/>
        <span class="label">max angle:</span>
        <input type="number" id="max_angle" max="360" min="0" step="1" 
         value="20" class="em_input">
        <br/>
    </div>
    <span class="label">max particles:</span>
    <input type="number" id="max_particles" max="10000" min="10" 
            step="10" value="100" class="em_input">    
    <br/>
    <span class="label">life time:</span>
    <input type="number" id="lifetime" max="10000" min="10"
            step="10" value="1000" class="em_input"><br/>
    <span class="label">acceleration:</span>

    <input type="number" id="acceleration" max="2.0" min="0.0"
                        step="0.1" value="1.0" class="em_input"><br/>
    <label class="ccontainer"><span class="label">alpha fade:</span>
        <input type="checkbox" checked="checked">
        <span class="checkmark"></span>
    </label>
    <br/>
    <span class="label">emission rate:</span>
    <input type="number" id="emission_rate" max="100" min="1" step="1" 
     value="20" class="em_input">
    <br/>

    <span class="label">x position:</span>
    <input type="number" id="x_pos" max="800" min="0" step="1" 
     value="400" class="em_input">
    <br/>
    <span class="label">y position:</span>
    <input type="number" id="y_pos" max="600" min="0" step="1" 
     value="300" class="em_input">
    <br/>
    <span class="label">radius:</span>
    <input type="number" id="radius" max="500" min="0" step="1" 
     value="20" class="em_input">
    <br/>

    <span class="label">min start vel:</span>
    <input type="number" id="min_starting_vel" max="9.9" min="0.0"
                        step="0.1" value="1.0" class="em_input"><br/>
    <span class="label">max start vel:</span>
    <input type="number" id="max_starting_vel" max="10.0" min="0.0"
                        step="0.1" value="2.0" class="em_input"><br/>

    <div class="input_box">
        <button id="update_btn" class="em_button" 
         onclick="UpdateClick()">Update Emitter</button>
    </div>
 </div>

CSS 文件将此容器样式设置为出现在网页的左侧。用户可以像以前一样将图像加载到虚拟文件系统中,但是这次所有这些输入字段中的值都用于创建一个粒子发射器。用户可以修改这些设置并单击“更新发射器”按钮以更新发射器使用的值。这将允许用户测试一些基本的发射器设置。

主函数中的代码需要添加以防止 SDL 事件处理程序拦截键盘事件并阻止这些输入元素内部的默认行为。我们稍后会介绍这段代码。

现在我已经向您展示了必须添加的 HTML 元素,以便我们能够配置一个粒子系统,让我们逐步了解一下能够将这些值传递到 WebAssembly 模块的 JavaScript 代码。以下是 JavaScript 代码的样子:

<script type='text/javascript'>
 var canvas = null;
 var ctx = null;
 var ready = false;
    var image_added = false;
    function ShowFileInput() {
        document.getElementById("file_input_label").style.display = 
        "block";
        ready = true;
    }
    function UpdateClick() {
        if( ready == false || image_added == false ) { return; }
        var max_particles = Number(document.getElementById         
                             ("max_particles").value);
        var min_angle = Number(document.getElementById         
                            ("min_angle").value) / 180 * Math.PI;
        var max_angle = Number(document.getElementById             
                              ("max_angle").value) / 180 * Math.PI
        var particle_lifetime = Number(document.getElementById         
                                    ("lifetime").value);
        var acceleration = Number(document.getElementById        
                               ("acceleration").value);
        var alpha_fade = Boolean(document.getElementById         
                               ("alpha_fade").checked);
        var emission_rate = Number(document.getElementById             
                                ("emission_rate").value);
        var x_pos = Number(document.getElementById("x_pos").value);
        var y_pos = Number(document.getElementById("y_pos").value);
        var radius = Number(document.getElementById("radius").value);
        var min_starting_velocity = Number(document.getElementById                                                                                                                                                         
                                    ("min_starting_vel").value);
        var max_starting_velocity = Number(document.getElementById                                                                                                                                                         
                                    ("max_starting_vel").value);
        Module.ccall('update_emitter', 'undefined',             
        ["number","number","number","number", "number","bool", 
        "number","number","number","number","number","number"],

        [max_particles,min_angle,max_angle,particle_lifetime,
         acceleration,alpha_fade,min_starting_velocity,
         max_starting_velocity,emission_rate,x_pos ,y_pos,radius]);
        }
        var Module = {
            preRun: [],
            postRun: [ShowFileInput],
            print: (function() {
                var element = document.getElementById('output');
                if (element) element.value = '';
                return function(text) {
                    if (arguments.length > 1) text =   
                    Array.prototype.slice.call(arguments).join(' ');
                    console.log(text);
                    if (element) {
                        element.value += text + "\n";
                        element.scrollTop = element.scrollHeight;
                    }
                }; })(),
        printErr: function(text) {
            if (arguments.length > 1) text = 
            Array.prototype.slice.call(arguments).join(' ');
            if (0) { dump(text + '\n'); } 
            else { console.error(text); }
        },
        canvas: (function() {
            var canvas = document.getElementById('canvas');
            canvas.addEventListener("webglcontextlost", function(e) {
                alert('WebGL context lost. You will need to reload the 
                       page.');
                e.preventDefault();},false);
            return canvas; })(),
        setStatus: function(text) {
            if (!Module.setStatus.last) Module.setStatus.last={ time: 
                Date.now(), text: '' };
            if (text === Module.setStatus.last.text) return;
            var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
            var now = Date.now();
            if (m && now - Module.setStatus.last.time < 30) return;
            Module.setStatus.last.time = now;
            Module.setStatus.last.text = text;
            if(m) { text = m[1]; }
            console.log("status: " + text); },
        totalDependencies: 0,
        monitorRunDependencies: function(left) {
            this.totalDependencies = Math.max(this.totalDependencies, 
                                              left);
            Module.setStatus(left?'Preparing... (' + 
                            (this.totalDependencies-left) +
                '/' + this.totalDependencies + ')' : 
                'All downloads complete.');
        } };
    Module.setStatus('Downloading...');
    window.onerror = function() {
        Module.setStatus('Exception thrown, see JavaScript console');
        Module.setStatus = function(text) {
            if (text) Module.printErr('[post-exception status] ' + 
                                        text);
        }; };
    function handleFiles(files) {
      var file_count = 0;
      for (var i = 0; i < files.length; i++) {
          if (files[i].type.match(/image.png/)) {
              var file = files[i];
              var file_name = file.name;
              var fr = new FileReader();
              fr.onload = function(file) {
                var data = new Uint8Array(fr.result);
                Module.FS_createDataFile('/', file_name, data, 
                                          true, true, true);
                var max_particles = Number(document.getElementById                                         
                                    ("max_particles").value);
                var min_angle = Number(document.getElementById                                       
                                ("min_angle").value) / 180 * 
                                Math.PI;
                var max_angle = Number(document.getElementById                                     
                                ("max_angle").value) / 180 * 
                                 Math.PI
                var particle_lifetime = Number(document.getElementById                                                
                                        ("lifetime").value);
                var acceleration = Number(document.getElementById 
                                    ("acceleration").value);
                var alpha_fade = Boolean(document.getElementById 
                                 ("alpha_fade").checked);
                var emission_rate = Number(document.getElementById 
                                    ("emission_rate").value);
                var x_pos = Number(document.getElementById 
                            ("x_pos").value);
                var y_pos = Number(document.getElementById 
                            ("y_pos").value);
                var radius = Number(document.getElementById                                          
                            ("radius").value);
                var min_starting_velocity = Number(document.getElementById
                                            ("min_starting_vel").value);
                var max_starting_velocity = Number(document.getElementById                                             
                                            ("max_starting_vel").value);
                Module.ccall('add_emitter','undefined', 
                ["string","number", "number", "number", "number", 
                 "number", "bool",  "number", "number","number", 
                 "number", "number", "number"],
                [file_name, max_particles, min_angle, max_angle, 
                particle_lifetime, acceleration, alpha_fade, 
                min_starting_velocity, max_starting_velocity, 
                emission_rate, x_pos, y_pos, radius]);
                image_added = true; };
              fr.readAsArrayBuffer(files[i]);
} } }
</script>

大部分Module代码未经修改,但我们添加了几个函数和一些新变量。我们添加了一个全局的ready变量,当初始化时设置为false。当Module加载时,此标志将设置为true。与前一节一样,ShowFileInput在使用postRun数组之后运行。我们已调整此代码以设置我们之前提到的ready标志:

function ShowFileInput() {
    document.getElementById("file_input_label").style.display = "block";
    ready = true;
}

在较早的部分,我们创建了一个handleFiles函数,将文件加载到我们的 WebAssembly 虚拟文件系统中。现在我们需要修改该函数,以调用一个名为add_emitter的函数,我们需要在我们的 C++代码中定义该函数。我们将调用此函数,传入我们在 HTML 输入元素中定义的所有值。以下是该函数的样子:

function handleFiles(files) {
    var file_count = 0;
    for (var i = 0; i < files.length; i++) {
        if (files[i].type.match(/image.png/)) {
            var file = files[i];
            var file_name = file.name;
            var fr = new FileReader();
            fr.onload = function (file) {
                var data = new Uint8Array(fr.result);
                Module.FS_createDataFile('/', file_name, data, true, 
                                          true, true);
                var max_particles = Number(document.getElementById( 
                                    "max_particles").value);
                var min_angle = Number(document.getElementById         
                                ("min_angle").value) / 180 * Math.PI;
                var max_angle = Number(document.getElementById         
                                ("max_angle").value) / 180 * Math.PI
                var particle_lifetime = Number(document.getElementById                                         
                                        ("lifetime").value);
                var acceleration = Number(document.getElementById 
                                   ("acceleration").value);
                var alpha_fade = Boolean(document.getElementById 
                                 ("alpha_fade").checked);
                var emission_rate = Number(document.getElementById 
                                    ("emission_rate").value);
                var x_pos = Number(document.getElementById 
                            ("x_pos").value);
                var y_pos = Number(document.getElementById    
                            ("y_pos").value);
                var radius = Number(document.getElementById 
                             ("radius").value);
              var min_starting_velocity = Number(document.getElementById 
                                         ("min_starting_vel").value);
              var max_starting_velocity = Number(document.getElementById                                                        
                                          ("max_starting_vel").value);
                Module.ccall('add_emitter', 'undefined', ["string", 
                "number", "number", "number",
                "number", "number", "bool",
                "number", "number",
                "number", "number", "number", "number"],
                [file_name, max_particles,
                min_angle, max_angle,
                particle_lifetime, acceleration, alpha_fade,                                                      
                min_starting_velocity, max_starting_velocity,
                emission_rate, x_pos, y_pos, radius]);
                image_added = true;
            };
            fr.readAsArrayBuffer(files[i]);
        }
    }
}

FileReader代码和从此函数的先前迭代中调用Module.FS_createDataFile仍然存在。除此之外,我们使用document.getElementById来获取 HTML 元素,并将这些元素的值存储到一组变量中:

var max_particles = Number(document.getElementById    
                    ("max_particles").value);
var min_angle = Number(document.getElementById("min_angle").value) / 
                180 * Math.PI;
var max_angle = Number(document.getElementById("max_angle").value) / 
                180 * Math.PI
var particle_lifetime = Number(document.getElementById     
                        ("lifetime").value);
var acceleration = Number(document.getElementById         
                   ("acceleration").value);
var alpha_fade = Boolean(document.getElementById 
                 ("alpha_fade").checked);
var emission_rate = Number(document.getElementById 
                    ("emission_rate").value);
var x_pos = Number(document.getElementById("x_pos").value);
var y_pos = Number(document.getElementById("y_pos").value);
var radius = Number(document.getElementById("radius").value);
var min_starting_velocity = Number(document.getElementById 
                            ("min_starting_vel").value);
var max_starting_velocity = Number(document.getElementById   
                            ("max_starting_vel").value);

许多这些值需要使用Number强制转换函数明确转换为数字。alpha_fade变量必须被强制转换为Boolean值。现在我们已经将所有这些值放入变量中,我们可以使用Module.ccall调用 C++函数add_emitter,传入所有这些值:

Module.ccall('add_emitter', 'undefined', ["string", "number", "number", 
             "number",
             "number", "number", "bool",
             "number", "number",
             "number", "number", "number", "number"],
             [file_name, max_particles, min_angle, max_angle,
             particle_lifetime, acceleration, alpha_fade,
             min_starting_velocity, max_starting_velocity,
             emission_rate, x_pos, y_pos, radius]);

在最后,我们将image_added标志设置为true。除非调用add_emitter已创建发射器,否则我们将不允许用户更新发射器。我们还添加了一个新函数UpdateClick,每当有人点击“更新发射器”按钮时,我们将调用该函数,假设他们已经创建了一个发射器。以下是该函数中的代码样子:

function UpdateClick() {
    if( ready == false || image_added == false ) {
        return;
    }
    var max_particles = Number(document.getElementById    
                        ("max_particles").value);
    var min_angle = Number(document.getElementById("min_angle").value) 
                    / 180 * Math.PI;
    var max_angle = Number(document.getElementById("max_angle").value) 
                    / 180 * Math.PI
    var particle_lifetime = Number(document.getElementById 
                            ("lifetime").value);
    var acceleration = Number(document.getElementById     
                       ("acceleration").value);
    var alpha_fade = Boolean(document.getElementById 
                     ("alpha_fade").checked);
    var emission_rate = Number(document.getElementById 
                        ("emission_rate").value);
    var x_pos = Number(document.getElementById("x_pos").value);
    var y_pos = Number(document.getElementById("y_pos").value);
    var radius = Number(document.getElementById("radius").value);
    var min_starting_velocity = Number(document.getElementById     
                                ("min_starting_vel").value);
    var max_starting_velocity = Number(document.getElementById 
                                ("max_starting_vel").value);

    Module.ccall('update_emitter', 'undefined', ["number", "number", 
                 "number",
                 "number", "number", "bool",
                 "number", "number",
                 "number", "number", "number", "number"],
                 [max_particles, min_angle, max_angle,
                 particle_lifetime, acceleration, alpha_fade,
                 min_starting_velocity, max_starting_velocity,
                 emission_rate, x_pos, y_pos, radius]);
}

我们要做的第一件事是确保Module对象已加载,并且我们已创建了发射器。如果这两者中的任何一个尚未发生,我们不希望运行此代码,因此我们必须返回:

if( ready == false || image_added == false ) {
    return;
}

此代码的其余部分与我们添加到handleFiles的代码类似。首先,我们获取所有 HTML 元素,并将它们中的值强制转换为适当的数据类型,以传递给我们对 C++函数的调用:

var max_particles = Number(document.getElementById             
                    ("max_particles").value);
var min_angle = Number(document.getElementById("min_angle").value) / 
                180 * Math.PI;
var max_angle = Number(document.getElementById("max_angle").value) / 
                180 * Math.PI
var particle_lifetime = Number(document.getElementById     
                        ("lifetime").value);
var acceleration = Number(document.getElementById         
                   ("acceleration").value); 
var alpha_fade = Boolean(document.getElementById 
                 ("alpha_fade").checked);
var emission_rate = Number(document.getElementById     
                    ("emission_rate").value);
var x_pos = Number(document.getElementById("x_pos").value);
var y_pos = Number(document.getElementById("y_pos").value);
var radius = Number(document.getElementById("radius").value);
var min_starting_velocity = Number(document.getElementById 
                            ("min_starting_vel").value);
var max_starting_velocity = Number(document.getElementById 
                            ("max_starting_vel").value);

从输入元素中获取所有值后,我们使用这些值调用update_emitter C++函数,传入这些值:

Module.ccall('update_emitter', 'undefined', ["number", "number", 
             "number",
             "number", "number", "bool",
             "number", "number",
             "number", "number", "number", "number"],
             [max_particles, min_angle, max_angle,
             particle_lifetime, acceleration, alpha_fade,
             min_starting_velocity, max_starting_velocity,
             emission_rate, x_pos, y_pos, radius]);

在下一节中,我们将实现一个Point类来跟踪游戏对象的位置。

Point 类

在以前的章节中,我们直接处理了类中的 2D XY坐标。我想添加一些处理我们的XY坐标的功能。为此,我们需要定义一个名为Point的新类。最终,Point将做的不仅仅是我们在这里使用它。但是现在,我想能够创建一个Point对象并能够通过角度Rotate该点。以下是我们添加到game.hpp文件中的Point类定义:

class Point {
    public:
        float x;
        float y;
        Point();
        Point( float X, float Y );
        Point operator=(const Point& p);
        void Rotate( float radians );
};

前几个函数和operator=都很简单。它们通过构造函数或使用诸如point_1 = point_2;这样的代码行来设置 x 和 y 属性。最后一个函数Rotate是我们创建这个类的整个原因。它的工作是将XY坐标围绕点0,0旋转。以下是完成这项工作的代码:

void Point::Rotate( float radians ) {
    float sine = sin(radians);
    float cosine = cos(radians);
    float rx = x * cosine - y * sine;
    float ry = x * sine + y * cosine;
    x = rx;
    y = ry;
}

这个Rotate函数最终将在整个游戏中使用。目前,我们将使用它来根据发射角度定义粒子的速度。

粒子类

Particle类是我们将用来表示粒子系统发射的单个粒子的类。Particles类将需要通过构造函数进行创建,并且稍后通过Update函数进行更新,用于修改粒子的定义属性。将会有一个Spawn函数用于激活Particle,一个Move函数用于移动粒子通过其生命周期最终使其停用,以及一个Render函数,用于执行绘制粒子到画布所需的 SDL 渲染任务。以下是我们在game.hpp文件中Particle类的样子:

class Particle {
    public:
        bool m_active;
        bool m_alpha_fade;
        SDL_Texture *m_sprite_texture;
        int m_ttl;
        Uint32 m_life_time;
        float m_acceleration;
        float m_alpha;
        Point m_position;
        Point m_velocity;
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 0, .h = 0 };
        Particle( SDL_Texture *sprite, Uint32 life_time, float 
        acceleration, bool alpha_fade, int width, int height );
        void Update( Uint32 life_time, float acceleration,
                    bool alpha_fade );
        void Spawn( float x, float y, float velocity_x, float 
        velocity_y, float alpha );
        void Move();
        void Render();
};

我们将在particle.cpp文件中定义与Particle类相关的函数。在该文件的顶部,我们已经定义了一个构造函数和一个Update函数。当用户在网页上点击更新发射器按钮时,我们将调用Update函数。这将更新所有粒子以使用它们的寿命、加速度和透明度衰减的新值。以下是这两个函数的代码样子:

Particle::Particle( SDL_Texture *sprite_texture, Uint32 life_time, 
                    float acceleration, bool alpha_fade, 
                    int width, int height ) {
    m_sprite_texture = sprite_texture;
    m_life_time = life_time;
    m_acceleration = acceleration;
    m_alpha_fade = alpha_fade;
    m_dest.w = width;
    m_dest.h = height;
    m_active = false;
}
void Particle::Update( Uint32 life_time, float acceleration, bool 
                       alpha_fade ) {
    m_life_time = life_time;
    m_acceleration = acceleration;
    m_alpha_fade = alpha_fade;
    m_active = false;
}

Spawn函数在Emitter需要发射粒子时被调用。Emitter检查其正在发射的粒子是否具有设置为false的活动标志。传递给Spawn的值,如XY坐标、速度xy值以及起始透明度值,都是由Emitter在发射新粒子时计算的。以下是代码的样子:

void Particle::Spawn( float x, float y, float velocity_x, 
                      float velocity_y, float alpha ) {
    m_position.x = x;
    m_dest.x = (int)m_position.x;
    m_position.y = y;
    m_dest.y = (int)m_position.y;
    m_velocity.x = velocity_x;
    m_velocity.y = velocity_y;
    m_alpha = alpha;
    m_active = true;
    m_ttl = m_life_time;
}

每个活动粒子的Move函数由发射器每帧调用一次,粒子在其中计算其新位置、透明度,并根据其存在时间确定其是否仍处于活动状态。以下是代码的样子:

void Particle::Move() { 
    float acc_adjusted = 1.0f;
    if( m_acceleration < 1.0f ) {
        acc_adjusted = 1.0f - m_acceleration;
        acc_adjusted *= delta_time;
        acc_adjusted = 1.0f - acc_adjusted;
    }
    else if( m_acceleration > 1.0f ) {
        acc_adjusted = m_acceleration - 1.0f;
        acc_adjusted *= delta_time;
        acc_adjusted += 1.0f;
    }
    m_velocity.x *= acc_adjusted;
    m_velocity.y *= acc_adjusted;
    m_position.x += m_velocity.x;
    m_position.y += m_velocity.y;
    m_dest.x = (int)m_position.x;
    m_dest.y = (int)m_position.y;

    if( m_alpha_fade == true ) {
        m_alpha = 255.0 * (float)m_ttl / (float)m_life_time;
        if( m_alpha < 0 ) {
            m_alpha = 0;
        }
    }
    else {
        m_alpha = 255.0;
    }
    m_ttl -= diff_time;
    if( m_ttl <= 0 ) {
        m_active = false;
    }
}

最后,Render函数调用 SDL 函数设置粒子的透明度值,然后将该粒子复制到渲染器:

void Particle::Render() {
    SDL_SetTextureAlphaMod(m_sprite_texture, (Uint8)m_alpha );
    SDL_RenderCopy( renderer, m_sprite_texture, NULL, &m_dest );
}

在下一节中,我们将讨论Emitter类以及我们需要使该类工作的代码。

发射器类

Emitter类管理一个粒子池,并且粒子用于渲染自身的加载的精灵纹理位于其中。我们的发射器只会是圆形的。可以定义具有许多不同形状的发射器,但对于我们的游戏,圆形的发射器就可以了。目前,我们的Emitter类将会非常基础。在后面的部分,我们将添加一些新功能,但现在我想创建一个非常基本的粒子系统。以下是在game.hpp文件中类定义的样子:

class Emitter {
    public:
        SDL_Texture *m_sprite_texture;
        std::vector<Particle*> m_particle_pool;
        int m_sprite_width;
        int m_sprite_height;
        Uint32 m_max_particles;
        Uint32 m_emission_rate;
        Uint32 m_emission_time_ms;
        int m_next_emission;
        float m_max_angle;
        float m_min_angle;
        float m_radius;
        float m_min_starting_velocity;
        float m_max_starting_velocity;
        Point m_position;

        Emitter(char* sprite_file, int max_particles, float min_angle, 
                float max_angle,
                Uint32 particle_lifetime, float acceleration, bool 
                alpha_fade,
                float min_starting_velocity, float 
                max_starting_velocity,
                Uint32 emission_rate, int x_pos, int y_pos, float 
                radius );
        void Update(int max_particles, float min_angle, float 
        max_angle,
                    Uint32 particle_lifetime, float acceleration, bool 
                    alpha_fade,
                    float min_starting_velocity, float 
                    max_starting_velocity,
                    Uint32 emission_rate, int x_pos, int y_pos, float 
                    radius );
        void Move();
        Particle* GetFreeParticle();
};

该类内部的属性与本章前面创建的 HTML 输入元素相对应。这些值在Emitter使用构造函数创建时设置,或者当用户点击更新按钮时设置,该按钮调用Update函数。Move函数将每帧调用一次,并且将移动并渲染粒子池中所有活动的粒子。它还将通过调用Spawn函数确定是否应该发射新粒子。

我们将在emitter.cpp文件中定义所有这些函数。以下是emitter.cpp文件中Emitter构造函数和Update函数的样子:

Emitter::Emitter(char* sprite_file, int max_particles, float min_angle, 
float max_angle, Uint32 particle_lifetime, float acceleration, bool alpha_fade, float min_starting_velocity, float max_starting_velocity,
Uint32 emission_rate, int x_pos, int y_pos, float radius ) {

    if( min_starting_velocity > max_starting_velocity ) {
        m_min_starting_velocity = max_starting_velocity;
        m_max_starting_velocity = min_starting_velocity;
    }
    else {
        m_min_starting_velocity = min_starting_velocity;
        m_max_starting_velocity = max_starting_velocity;
    }
    SDL_Surface *temp_surface = IMG_Load( sprite_file );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    m_sprite_texture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );
    SDL_FreeSurface( temp_surface );
    SDL_QueryTexture( m_sprite_texture,
                     NULL, NULL, &m_sprite_width, &m_sprite_height );
    m_max_particles = max_particles;

    for( int i = 0; i < m_max_particles; i++ ) {
        m_particle_pool.push_back(
            new Particle( m_sprite_texture, particle_lifetime, 
            acceleration, alpha_fade, m_sprite_width, m_sprite_height )
        );
    }
    m_max_angle = max_angle;
    m_min_angle = min_angle;
    m_radius = radius;
    m_position.x = (float)x_pos;
    m_position.y = (float)y_pos;
    m_emission_rate = emission_rate;
    m_emission_time_ms = 1000 / m_emission_rate;
    m_next_emission = 0;
}

void Emitter::Update(int max_particles, float min_angle, float 
                     max_angle, Uint32 particle_lifetime, float 
                     acceleration, bool alpha_fade,
                     float min_starting_velocity, float 
                     max_starting_velocity, Uint32 emission_rate, int 
                     x_pos, int y_pos, float radius ) {
    if( min_starting_velocity > max_starting_velocity ) {
        m_min_starting_velocity = max_starting_velocity;
        m_max_starting_velocity = min_starting_velocity;
    }
    else {
        m_min_starting_velocity = min_starting_velocity;
        m_max_starting_velocity = max_starting_velocity;
    }
    m_max_particles = max_particles;
    m_min_angle = min_angle;
    m_max_angle = max_angle;
    m_emission_rate = emission_rate;
    m_position.x = (float)x_pos;
    m_position.y = (float)y_pos;
    m_radius = radius;

    if( m_particle_pool.size() > m_max_particles ) {
        m_particle_pool.resize( m_max_particles );
    }
    else if( m_max_particles > m_particle_pool.size() ) {
        while( m_max_particles > m_particle_pool.size() ) {
            m_particle_pool.push_back(
                new Particle( m_sprite_texture, particle_lifetime, 
                acceleration, alpha_fade, m_sprite_width, 
                m_sprite_height )
            );
        }
    }

    Particle* particle;
    std::vector<Particle*>::iterator it;

    for( it = m_particle_pool.begin(); it != m_particle_pool.end(); 
         it++ ) {
        particle = *it;
        particle->Update( particle_lifetime, acceleration, alpha_fade );
    }
}

这两个函数都设置了Emitter类的属性,并根据传入这些函数的max_particles值设置了粒子池。GetFreeParticle函数被Move函数调用,以从当前未激活的粒子池中获取一个粒子。Move函数首先确定是否需要发射新的粒子,如果需要,就调用GetFreeParticle函数来获取一个未激活的粒子,然后使用Emitter的属性来设置生成粒子时要使用的值。它将循环遍历池中的所有粒子,如果粒子是活动的,它将Move然后Render该粒子:

Particle* Emitter::GetFreeParticle() {
    Particle* particle;
    std::vector<Particle*>::iterator it;
    for( it = m_particle_pool.begin(); it != m_particle_pool.end(); 
         it++ ) {
        particle = *it;
        if( particle->m_active == false ) {
            return particle;
        }
    }
    return NULL;
}

void Emitter::Move() {
    Particle* particle;
    std::vector<Particle*>::iterator it;
    static int count = 0;
    m_next_emission -= diff_time;
    if( m_next_emission <= 0 ) {
        m_next_emission = m_emission_time_ms;
        particle = GetFreeParticle();
        if( particle != NULL ) {
            float rand_vel = (rand() %
                (int)((m_max_starting_velocity - 
                       m_min_starting_velocity) * 1000)) / 1000.0f;
            Point spawn_point;
            spawn_point.x = (float)(rand() % (int)(m_radius * 1000)) / 
            1000.0;
            Point velocity_point;
            velocity_point.x = (float)(rand() %
                (int)((m_max_starting_velocity + rand_vel) * 1000)) / 
                 1000.0;
            int angle_int = (int)((m_max_angle - m_min_angle) * 
            1000.0);
            float add_angle = (float)(rand() % angle_int) /1000.0f;
            float angle = m_min_angle + add_angle;
            velocity_point.Rotate(angle);
            angle = (float)(rand() % 62832) / 10000.0;
            spawn_point.Rotate( angle );
            spawn_point.x += m_position.x;
            spawn_point.y += m_position.y;
            particle->Spawn(spawn_point.x, spawn_point.y, 
            velocity_point.x, velocity_point.y, 255.0f );
        }
    }
    for( it = m_particle_pool.begin(); it != m_particle_pool.end(); 
         it++ ) {
        particle = *it;
        if( particle->m_active ) {
            particle->Move();
            particle->Render();
        }
    }
}

我们将把这些类编译成我们的 WebAssembly 模块,但它们不会直接与我们之前定义的 JavaScript 进行交互。为此,我们需要在一个新文件中定义一些函数,我们将在下一节中讨论。

WebAssembly 接口函数

我们需要定义将与我们的 JavaScript 进行交互的函数。我们还需要定义一些将被我们的几个类使用的全局变量。以下是新的basic_particle.cpp文件中的代码:

#include "game.hpp"
#include <emscripten/bind.h>
SDL_Window *window;
SDL_Renderer *renderer;
char* fileName;
Emitter* emitter = NULL;
Uint32 last_time = 0;
Uint32 current_time = 0;
Uint32 diff_time = 0;
float delta_time = 0.0f;
extern "C"
    EMSCRIPTEN_KEEPALIVE
    void add_emitter(char* file_name, int max_particles, float 
    min_angle, float max_angle, Uint32 particle_lifetime, float 
    acceleration, bool alpha_fade, float min_starting_velocity, float 
    kmax_starting_velocity, Uint32 emission_rate, float x_pos, float 
    y_pos, float radius) {
        if( emitter != NULL ) {
            delete emitter;
        }
        emitter = new Emitter(file_name, max_particles, min_angle, 
                              max_angle, particle_lifetime, 
                              acceleration, alpha_fade,
                              min_starting_velocity, 
                              max_starting_velocity,
                              emission_rate, x_pos, y_pos, radius );
        }
extern "C"
    EMSCRIPTEN_KEEPALIVE
    void update_emitter(int max_particles, float min_angle, float   
    max_angle, Uint32 particle_lifetime, float acceleration, bool   
    alpha_fade, float min_starting_velocity, float 
    max_starting_velocity, Uint32 emission_rate, float x_pos, float 
    y_pos, float radius ) {
        if( emitter == NULL ) {
            return;
        }
        emitter->Update(max_particles, min_angle, max_angle,
                        particle_lifetime, acceleration, alpha_fade,
                        min_starting_velocity, max_starting_velocity,
                        emission_rate, x_pos, y_pos, radius );
    }
    void show_emission() {
        current_time = SDL_GetTicks();
        delta_time = (double)(current_time - last_time) / 1000.0;
        diff_time = current_time - last_time;
        last_time = current_time;
        if( emitter == NULL ) {
            return;
        }
        SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
        SDL_RenderClear( renderer );
        emitter->Move();
        SDL_RenderPresent( renderer );
    }
    int main() {
        printf("Enter Main\n");
        SDL_Init( SDL_INIT_VIDEO );
        int return_val = SDL_CreateWindowAndRenderer( 800, 600, 0, 
        &window, &renderer );
        SDL_EventState(SDL_TEXTINPUT, SDL_DISABLE);
        SDL_EventState(SDL_KEYDOWN, SDL_DISABLE);
        SDL_EventState(SDL_KEYUP, SDL_DISABLE);
        if( return_val != 0 ) {
            printf("Error creating renderer %d: %s\n", return_val, 
            IMG_GetError() );
            return 0;
        }
        last_time = SDL_GetTicks();
        emscripten_set_main_loop(show_emission, 0, 0);
        printf("Exit Main\n");
        return 1;
    }

前两个全局变量是SDL_WindowSDL_Renderer。我们需要这些作为全局对象(特别是渲染器),以便它们可以用来将我们的纹理渲染到画布上:

SDL_Window *window;
SDL_Renderer *renderer;

之后,我们有我们的发射器。现在,我们只支持单个发射器。在以后的版本中,我们将希望配置多个发射器。

Emitter* emitter = NULL;

其余的全局变量都与在毫秒(diff_time)和秒的分数(delta_time)之间跟踪时间有关。last_timecurrent_time变量主要用于计算这两个与时间相关的变量。以下是代码中这些定义的样子:

Uint32 last_time = 0;
Uint32 current_time = 0;
Uint32 diff_time = 0;
float delta_time = 0.0f;

在定义了全局变量之后,是时候定义将与我们的 JavaScript 进行交互的函数了。其中的第一个函数是add_emitter。这是一个简单的函数,它查看是否已定义了一个发射器,如果有,就删除它。然后,它使用从 JavaScript 传入此函数的值创建一个新的发射器,使用此时 HTML 输入元素中的值。以下是函数的样子:

extern "C"
    EMSCRIPTEN_KEEPALIVE
    void add_emitter(char* file_name, int max_particles, float 
    min_angle, float max_angle, Uint32 particle_lifetime, float   
    acceleration, bool alpha_fade, float min_starting_velocity, float 
    max_starting_velocity, Uint32 emission_rate, float x_pos, float 
    y_pos, float radius) {
        if( emitter != NULL ) {
            delete emitter;
        }
        emitter = new Emitter(file_name, max_particles, min_angle, 
        max_angle, particle_lifetime, acceleration, alpha_fade,
        min_starting_velocity, max_starting_velocity,
        emission_rate, x_pos, y_pos, radius );
    }

你可能已经注意到在add_emitter函数定义之前的这两行:

extern "C"
    EMSCRIPTEN_KEEPALIVE

我们需要这些行来防止名称混编死代码消除。如果你以前从未听说过这些术语,让我来解释一下。

C++名称混编

这些行中的第一行extern "C"告诉编译器这是一个 C 函数,并指示它不要在该函数上使用 C++ 名称混编。如果你不熟悉 C++名称混编,它的基本原理是:C++支持函数重载。换句话说,你可以有多个具有不同参数的相同名称的函数。C++将根据传递给该函数的参数调用正确的函数。由于这个功能,C++在编译时会对名称进行混编,在编译过程中为每个函数赋予不同的名称。因为我现在正在使用 C++,而不是使用 C,这些函数我希望从 JavaScript 中调用都会受到这个名称混编过程的影响。extern "C"指令告诉 C++编译器这些是 C 函数,并请不要混编名称,以便我可以从我的 JavaScript 中外部调用它们。

死代码消除

默认情况下,Emscripten 使用死代码消除来删除您在 C++代码内部没有调用的任何函数。在大多数情况下,这是一件好事。您不希望未使用的代码占据 WebAssembly 模块中的空间。当存在一个函数可供从 JavaScript 调用,但无法从 C++代码内部调用时,就会出现问题。Emscripten 编译器会看到没有任何东西调用此函数,并将其删除。EMSCRIPTEN_KEEPALIVE告诉 Emscripten 编译器不要删除此代码,因为您希望从外部源调用它。

更新发射器

add_emitter代码之后,为外部调用设置的下一个函数是update_emitter。此函数首先检查是否定义了发射器,如果是,则调用更新函数,该函数将更新发射器上的所有属性,以便与从 HTML 输入元素传入的值相匹配。代码如下:

extern "C"
    EMSCRIPTEN_KEEPALIVE
    void update_emitter(int max_particles, float min_angle, float   
    max_angle, Uint32 particle_lifetime, float acceleration, bool 
    alpha_fade, float min_starting_velocity, float 
    max_starting_velocity, Uint32 emission_rate, float x_pos, float 
    y_pos, float radius ) {
        if( emitter == NULL ) {
            return;
        }
        emitter->Update(max_particles, min_angle, max_angle,
                        particle_lifetime, acceleration, alpha_fade,
                        min_starting_velocity, max_starting_velocity,
                        emission_rate, x_pos, y_pos, radius );
    }

循环函数

下一个函数show_emission是如果此应用程序是游戏,则将成为我们的游戏循环的函数。此函数在每个渲染的帧上调用,并负责设置计时器值,准备我们的 SDL 进行渲染,并调用发射器的Move函数,该函数将移动和渲染粒子系统中的所有粒子:

void show_emission() {
    current_time = SDL_GetTicks();
    delta_time = (double)(current_time - last_time) / 1000.0;
    diff_time = current_time - last_time;
    last_time = current_time;

    if( emitter == NULL ) {
        return;
    }
    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );
    emitter->Move();
    SDL_RenderPresent( renderer );
}

前几行计算了delta_timediff_time全局变量,这些变量由粒子根据帧速率调整粒子的移动:

current_time = SDL_GetTicks();
delta_time = (double)(current_time - last_time) / 1000.0;
diff_time = current_time - last_time;
last_time = current_time;

如果发射器尚未设置,我们不希望渲染任何内容,因此我们返回:

if( emitter == NULL ) {
    return;
}

如果发射器存在,我们需要使用黑色清除渲染器:

SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
SDL_RenderClear( renderer );

之后,我们调用发射器的Move函数,该函数既移动所有粒子,又将精灵纹理复制到渲染器中的适当位置。然后,我们调用SDL_RenderPresent函数,将其渲染到 HTML 画布元素上:

emitter->Move();
SDL_RenderPresent( renderer );

初始化

最后一个函数是main函数,当加载 WebAssembly 模块时会自动调用:

int main() {
    SDL_Init( SDL_INIT_VIDEO );
    int return_val = SDL_CreateWindowAndRenderer( 800, 600, 0, &window, 
                                                  &renderer );
    if( return_val != 0 ) {
        printf("Error creating renderer %d: %s\n", return_val, 
                IMG_GetError() );
        return 0;
    }
    SDL_EventState(SDL_TEXTINPUT, SDL_DISABLE);
    SDL_EventState(SDL_KEYDOWN, SDL_DISABLE);
    SDL_EventState(SDL_KEYUP, SDL_DISABLE);
    last_time = SDL_GetTicks();
    emscripten_set_main_loop(show_emission, 0, 0);
    return 1;
}

前几行初始化了我们的 SDL:

SDL_Init( SDL_INIT_VIDEO );
int return_val = SDL_CreateWindowAndRenderer( 800, 600, 0, &window, 
                                              &renderer );

之后,接下来的几行用于禁用 SDL 文本输入和键盘事件。这些行防止 SDL 捕获我们需要在 HTML 元素内设置输入值的键盘输入。在大多数游戏中,我们不希望这些行存在,因为我们希望捕获这些事件,以便我们可以从我们的 WebAssembly 模块内管理游戏输入。但是,如果我们希望我们的应用程序正常工作,并且我们希望用户能够更改我们的 HTML 输入,我们必须在我们的代码中包含这些行:

SDL_EventState(SDL_TEXTINPUT, SDL_DISABLE);
SDL_EventState(SDL_KEYDOWN, SDL_DISABLE);
SDL_EventState(SDL_KEYUP, SDL_DISABLE);

下一行获取last_time全局变量的起始时钟值:

last_time = SDL_GetTicks();

在返回之前,此函数中的最后一行用于设置我们的循环函数。我们的循环函数将在每次渲染帧时调用:

emscripten_set_main_loop(show_emission, 0, 0);

在下一节中,我们将编译和测试发射器配置工具的早期版本。

编译和测试粒子发射器

哇,这是很多代码。好的,现在我们在粒子发射器配置工具中拥有了所有需要的东西,我们需要花时间编译和测试它。在测试此版本之后,我们可以使用相同的 em++调用来测试我们将在下一节开始构建的高级版本。

在命令行上运行此命令:

em++ emitter.cpp particle.cpp point.cpp basic_particle.cpp -o particle.html -std=c++17 --shell-file basic_particle_shell.html -s NO_EXIT_RUNTIME=1 -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s NO_EXIT_RUNTIME=1 -s EXPORTED_FUNCTIONS="['_add_emitter', '_update_emitter', '_main']" -s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap', 'ccall']" -s FORCE_FILESYSTEM=1

您的粒子发射器配置工具应如下所示:

图 8.2:粒子系统配置工具的屏幕截图

不要忘记,您必须使用 Web 服务器或emrun来运行 WebAssembly 应用程序。如果您想使用emrun运行 WebAssembly 应用程序,则必须使用--emrun标志进行编译。Web 浏览器需要 Web 服务器来流式传输 We1bAssembly 模块。如果您尝试直接从硬盘驱动器在浏览器中打开使用 WebAssembly 的 HTML 页面,那么该 WebAssembly 模块将无法加载。

使用这个界面上传一个.png图像文件,并在左侧的字段中玩一下。我们还没有足够的数值来制作一个出色的粒子发射器,但你可以通过目前的内容对基础知识有所了解。

总结

在本章中,我们学习了如何创建一个基本的粒子发射器配置工具。我们讨论了如何强制 Emscripten 在应用程序启动时没有加载文件时创建虚拟文件系统。我们学习了如何从用户的计算机加载图像到浏览器的虚拟文件系统,并添加了允许我们上传.png图像文件的功能。我们介绍了一些 SVG 的基础知识,讨论了矢量图形和光栅图形之间的区别,并学习了如何使用 SVG 来为我们的配置工具绘制饼状图。我们介绍了一些在本章中有用的基本三角学知识,在后面的章节中将变得更加有用。我们创建了一个新的 HTML 外壳文件,与我们的 WebAssembly 进行交互,帮助我们为游戏配置一个新的粒子系统。我们在一个 WebAssembly 模块中创建了PointParticleEmitter类,我们将最终在游戏中使用它们。最后,我们了解了 C++的名称修饰、死代码消除以及在编写 Emscripten 代码时必须避免它们的情况。

在下一章中,我们将改进我们的粒子发射器配置工具。在本章结束时,我们将使用它来配置游戏中的效果,如爆炸、太阳耀斑和飞船排气。这个工具可以用来尝试不同的效果,并在将该效果添加到游戏之前感受一下它们的外观。最后,我们将使用配置工具中使用的数值作为配置游戏中粒子效果的起点。

第九章:改进的粒子系统

我们在上一章中开发的粒子系统是一个很好的开始,但您可以使用它创建的效果相当单调。我们的粒子不会旋转或缩放,它们不会动画,它们在外观上相对一致。

在本章中,您需要在构建中包含几个图像,以使该项目正常工作。确保您包含了此项目的 GitHub 存储库中的/Chapter09/sprites/文件夹。如果您想要从 GitHub 构建粒子系统工具,该工具的源代码位于/Chapter09/advanced-particle-tool/文件夹中。如果您还没有下载 GitHub 项目,可以在此处在线获取:github.com/PacktPublishing/Hands-On-Game-Develop

如果我们希望从我们的粒子系统中获得最大的效果,我们需要为其添加更多功能。在本章中,我们将添加以下附加功能:

  • 粒子在其生命周期内的比例

  • 粒子旋转

  • 动画粒子

  • 随时间改变颜色

  • 支持粒子爆发

  • 支持循环和非循环发射器

修改我们的 HTML 外壳文件

我们需要做的第一件事是在 HTML 外壳文件中添加一些新的输入。我们将复制basic_particle_shell.html文件到一个新的外壳文件,我们将称之为advanced_particle_shell.html。我们将在原始容器和canvas元素之间的外壳文件的 HTML 部分中添加第二个容器类div元素和许多新的输入。以下是新容器元素的样子:

<div class="container">
<div class="empty_box">&nbsp;</div><br/>
<span class="label">min start scale:</span>
<input type="number" id="min_starting_scale" max="9.9" min="0.1" step="0.1" value="1.0" class="em_input"><br/>
<span class="label">max start scale:</span>
<input type="number" id="max_starting_scale" max="10.0" min="0.2" step="0.1" value="2.0" class="em_input"><br/>
<span class="label">min end scale:</span>
<input type="number" id="min_end_scale" max="9.9" min="0.1" step="0.1" value="1.0" class="em_input">
<br/>
<span class="label">max end scale:</span>
<input type="number" id="max_end_scale" max="10.0" min="0.2" step="0.1" value="2.0" class="em_input">
<br/>
<span class="label">start color:</span>
<input type="color" id="start_color" value="#ffffff" class="color_input"><br/>
<span class="label">end color:</span>
<input type="color" id="end_color" value="#ffffff" class="color_input"><br/>
<span class="label">burst time pct:</span>
<input type="number" id="burst_time" max="1.0" min="0.0" step="0.05" value="0.0" class="em_input">
<br/>
<span class="label">burst particles:</span>
<input type="number" id="burst_particles" max="100" min="0" step="1" value="0" class="em_input">
<br/>
<label class="ccontainer"><span class="label">loop:</span>
    <input type="checkbox" id="loop" checked="checked">
    <span class="checkmark"></span>
</label>
<br/>
<label class="ccontainer"><span class="label">align rotation:</span>
    <input type="checkbox" id="align_rotation" checked="checked">
    <span class="checkmark"></span>
</label>
<br/>
<span class="label">emit time ms:</span>
<input type="number" id="emit_time" max="10000" min="100" step="100" value="1000" class="em_input">
<br/>
<span class="label">animation frames:</span>
<input type="number" id="animation_frames" max="64" min="1" step="1" value="1" class="em_input">
<br/>
<div class="input_box">
<button id="update_btn" class="em_button" onclick="UpdateClick()">Update Emitter</button>
</div>
</div>

缩放值

缩放精灵意味着通过其原始大小的某个倍数修改该精灵的大小。例如,如果我们使用缩放值2.0来缩放一个 16 x 16 的精灵,那么该精灵将呈现为画布上的 32 x 32 图像。这个新容器以四个输入元素开始,以及它们的标签,告诉粒子系统如何在其生命周期内缩放粒子。min_starting_scalemax_starting_scale元素是粒子的起始范围缩放。如果您希望粒子始终以1.0的比例(与.png图像大小的 1 比 1 比例)开始,您应该在这两个字段中都放入1.0。实际的起始比例值将是在这些字段中放入的两个值之间随机选择的值。我们还没有在此界面中添加任何检查来验证max是否大于min,因此请确保maxmin值相同或大于min值,否则这将破坏发射器。接下来的两个input元素是min_end_scalemax_end_scale。与起始比例值一样,实际的结束比例将是在这些字段中放入的两个值之间随机选择的值。在粒子的生命周期中的任何给定点,它将具有一个比例,该比例是在该粒子生命周期开始时分配的比例值和结束时的比例值之间插值的值。因此,如果我从1.0的比例值开始,然后以3.0的比例值结束,当粒子的生命周期过了一半时,粒子的比例值将是2.0

以下是 HTML 文件中这些元素的样子:

<span class="label">min start scale:</span>
<input type="number" id="min_starting_scale" max="9.9" min="0.1" step="0.1" value="1.0" class="em_input"><br/>
<span class="label">max start scale:</span>
<input type="number" id="max_starting_scale" max="10.0" min="0.2" step="0.1" value="2.0" class="em_input"><br/>
<span class="label">min end scale:</span>
<input type="number" id="min_end_scale" max="9.9" min="0.1" step="0.1" value="1.0" class="em_input">
<br/>
<span class="label">max end scale:</span>
<input type="number" id="max_end_scale" max="10.0" min="0.2" step="0.1" value="2.0" class="em_input">
<br/>

颜色混合值

SDL 有一个名为SDL_SetTextureColorMod的函数,能够修改纹理的红色、绿色和蓝色通道。这个函数只能减少颜色通道值,所以在灰度图像上使用这些值效果最好。HTML 中的下两个输入是start_colorend_color。这些值将用于修改粒子在其生命周期内的颜色通道。每个颜色通道(红色、绿色和蓝色)在粒子的生命周期内进行插值。

以下是 HTML 文件中这些元素的样子:

<span class="label">start color:</span>
<input type="color" id="start_color" value="#ffffff" class="color_input"><br/>
<span class="label">end color:</span>
<input type="color" id="end_color" value="#ffffff" class="color_input"><br/>

粒子爆发

到目前为止,我们所使用的粒子系统都发射一致的粒子流。在粒子系统的生命周期内,我们可能希望在某个时间点突发发射一大批粒子。接下来的两个输入元素是burst_timeburst_particlesburst_time元素允许值从0.01.0。这个数字代表粒子发射器生命周期中爆发将发生的时间点。0.0表示爆发将在发射器生命周期的开始时发生,1.0表示在结束时发生,0.5表示在中间时发生。在burst_time元素之后是burst_particles元素。该元素包含爆发时发射的粒子数量。在调整此值为较大值之前,请确保将max_particles输入元素设置为可以容纳爆发的值。例如,如果你有一个每秒发射20个粒子的粒子发射器,并且你的最大粒子数也是20个粒子,那么添加任何大小的爆发都不会被注意到,因为粒子池中没有足够的非活动粒子供爆发使用。

以下是 HTML 文件中这些元素的外观:

<span class="label">burst time pct:</span>
<input type="number" id="burst_time" max="1.0" min="0.0" step="0.05" value="0.0" class="em_input">
<br/>
<span class="label">burst particles:</span>
<input type="number" id="burst_particles" max="100" min="0" step="1" value="0" class="em_input">
<br/>

循环发射器

一些发射器在固定时间内执行,然后在时间到期时停止。这种发射器的例子是爆炸。一旦爆炸效果完成,我们希望它结束。另一种类型的发射器可能会循环,它会继续执行,直到其他代码停止发射器。这种发射器的例子是我们飞船的引擎排气口。只要我们的飞船在加速,我们希望看到一串粒子从它的后面排放出来。HTML 中的下一个元素是一个循环复选框元素。如果点击,发射器将继续发射,即使其寿命已经结束。如果与此发射器相关联的是一次性爆发,那么每次发射器通过循环的那部分时,爆发都会发生。

以下是 HTML 中输入元素的外观:

<label class="ccontainer"><span class="label">loop:</span>
<input type="checkbox" id="loop" checked="checked">
<span class="checkmark"></span>
</label>
<br/>

对齐粒子旋转

旋转可以改善许多粒子效果。由于我们在项目中被迫选择要在粒子系统中使用的值,因此,坦率地说,我可以写一本关于粒子系统的整本书。与之前为粒子的比例所做的旋转值范围不同,我们将有一个单一的标志,允许用户选择粒子系统是否将其旋转与发射速度矢量对齐。我发现这是一个愉快的效果。用户将通过id="align_rotation"复选框做出这个决定。

以下是 HTML 代码的外观:

<label class="ccontainer"><span class="label">align rotation:</span>
 <input type="checkbox" id="align_rotation" checked="checked">
 <span class="checkmark"></span>
 </label>
 <br/>

发射时间

发射时间是我们的粒子发射器在停止运行之前运行的毫秒数,或者如果用户已经选中循环复选框,则循环。如果粒子系统循环,这个值只对具有突发的粒子系统才会有影响。这将导致每次粒子系统通过循环时都会发生爆发。

HTML 代码如下:

<span class="label">emit time ms:</span>
<input type="number" id="emit_time" max="10000" min="100" step="100" value="1000" class="em_input"><br/>

动画帧

如果我们想创建一个具有多帧动画的粒子,我们可以在这里添加帧数。此功能假定水平条形精灵表,并将加载的图像文件均匀分割在x轴上。当这个值为1时,没有动画,因为只有一个单独的帧。动画的帧时间将均匀分配到单个粒子的存活时间上。换句话说,如果你有一个十帧动画,粒子寿命为 1,000 毫秒,那么动画的每一帧将显示 100 毫秒(1,000/10)。

以下是 HTML 元素:

<span class="label">animation frames:</span>
<input type="number" id="animation_frames" max="64" min="1" step="1" value="1" class="em_input"><br/>

现在我们已经定义了 HTML,让我们来看看代码的 JavaScript 部分。

修改 JavaScript

我们正在创建的工具是在我们已经进行了几章工作的游戏之外运行的。因此,我们正在修改一个新的 HTML 外壳文件,并且我们将编写大量的 JavaScript 代码,以便将用户界面与稍后将放入游戏中的 WebAssembly 类集成起来。让我们花点时间来逐步了解我们需要添加到新的 HTML 外壳文件中的所有 JavaScript 函数。

JavaScript UpdateClick 函数

在我们修改了 HTML 之后,我们需要做的下一件事是修改UpdateClick() JavaScript 函数,以便它可以从 HTML 元素中获取新值,并将这些值传递给Module.ccall函数调用update_emitter

以下是完整的UpdateClick函数的新版本:

function UpdateClick() {
    if( ready == false || image_added == false ) {
        return;
    }
    var max_particles = Number(document.getElementById
                        ("max_particles").value);
    var min_angle = Number(document.getElementById
                    ("min_angle").value) / 180 * Math.PI;
    var max_angle = Number(document.getElementById
                    ("max_angle").value) / 180 * Math.PI
    var particle_lifetime = Number(document.getElementById
                            ("lifetime").value);
    var acceleration = Number(document.getElementById
                       ("acceleration").value);
    var alpha_fade = Boolean(document.getElementById
                     ("alpha_fade").checked);
    var emission_rate = Number(document.getElementById
                        ("emission_rate").value);
    var x_pos = Number(document.getElementById
                ("x_pos").value);
    var y_pos = Number(document.getElementById
                ("y_pos").value);
    var radius = Number(document.getElementById
                 ("radius").value);
    var min_starting_velocity = Number(document.getElementById
                                ("min_starting_vel").value);
    var max_starting_velocity = Number(document.getElementById
                                ("max_starting_vel").value);

    /* NEW INPUT PARAMETERS */
    var min_start_scale = Number(document.getElementById
                          ("min_starting_scale").value);
    var max_start_scale = Number(document.getElementById
                          ("max_starting_scale").value);
    var min_end_scale = Number(document.getElementById
                        ("min_end_scale").value);
    var max_end_scale = Number(document.getElementById
                        ("max_end_scale").value);
    var start_color_str = document.getElementById
                          ("start_color").value.substr(1, 7);
    var start_color = parseInt(start_color_str, 16);
    var end_color_str = document.getElementById
                        ("end_color").value.substr(1, 7);
    var end_color = parseInt(end_color_str, 16);
    var burst_time = Number(document.getElementById
                     ("burst_time").value);
    var burst_particles = Number(document.getElementById
                          ("burst_particles").value);
    var loop = Boolean(document.getElementById
               ("loop").checked);
    var align_rotation = Boolean(document.getElementById
                         ("align_rotation").checked);
    var emit_time = Number(document.getElementById
                    ("emit_time").value);
    var animation_frames = Number(document.getElementById
                           ("animation_frames").value);

    Module.ccall('update_emitter', 'undefined', ["number", "number", 
    "number", "number", "number", "bool", "number", "number",
    "number", "number", "number", "number",
    /* new parameters */
    "number", "number", "number", "number", "number", "number", 
    "number", "number", "bool", "bool", "number"],
    [max_particles, min_angle, max_angle, particle_lifetime, 
    acceleration, alpha_fade, min_starting_velocity, 
    max_starting_velocity, emission_rate, x_pos, y_pos, radius,
    /* new parameters */
    min_start_scale, max_start_scale, min_end_scale, max_end_scale,
    start_color, end_color, burst_time, burst_particles,    
    loop, align_rotation, emit_time, animation_frames]);
    }

正如您所看到的,我们在这个 JavaScript 函数中添加了新的本地变量,用于存储我们从新的 HTML 元素中获取的值。检索缩放值并将其强制转换为数字以传递给update_emitter现在应该看起来非常熟悉。以下是该代码:

var min_start_scale = Number(document.getElementById
                      ("min_starting_scale").value);
var max_start_scale = Number(document.getElementById
                      ("max_starting_scale").value);
var min_end_scale = Number(document.getElementById
                    ("min_end_scale").value);
var max_end_scale = Number(document.getElementById
                    ("max_end_scale").value);

强制转换颜色值

在 JavaScript 中,变量强制转换是将一个变量类型转换为另一个变量类型的过程。由于 JavaScript 是一种弱类型语言,强制转换与类型转换有所不同,后者类似于强类型语言(如 C 和 C++)中的变量强制转换。

将我们的颜色值强制转换为Integer值是一个两步过程。这些元素中的值是以*#*字符开头的字符串,后面跟着一个六位十六进制数。我们需要做的第一件事是删除该起始的#字符,因为它将阻止我们将该字符串解析为整数。我们可以通过简单的substr来实现这一点,以获取元素内部值的子字符串(字符串的一部分)。

以下是start_color的代码:

var start_color_str = document.getElementById
                      ("start_color").value.substr(1, 7);

我们知道字符串的长度始终为七个字符,但我们只想要最后的六个字符。现在,我们已经得到了起始颜色的十六进制表示,但它仍然是一个字符串变量。现在,我们需要将其强制转换为一个Integer值,并且我们必须告诉parseInt函数使用十六进制(base 16),因此我们将值16作为第二个参数传递给parseInt

var start_color = parseInt(start_color_str, 16);

现在,我们已经将start_color强制转换为整数,我们将对end_color执行相同的操作:

var end_color_str = document.getElementById
                    ("end_color").value.substr(1, 7);
var end_color = parseInt(end_color_str, 16);

额外的变量强制转换

start_colorend_color强制转换之后,我们必须执行的其余强制转换应该感觉很熟悉。我们将burst_timeburst_particlesemit_timeanimation_frames中的值强制转换为Number变量。我们将从loopalign_rotation中检查的值强制转换为布尔变量。

以下是强制转换代码的其余部分:

var burst_time = Number(document.getElementById
                 ("burst_time").value);
var burst_particles = Number(document.getElementById
                      ("burst_particles").value);
var loop = Boolean(document.getElementById
           ("loop").checked);
var align_rotation = Boolean(document.getElementById
                     ("align_rotation").checked);
var emit_time = Number(document.getElementById
                ("emit_time").value);
var animation_frames = Number(document.getElementById
                       ("animation_frames").value);

最后,我们需要将变量类型和新变量添加到我们的Module.ccall调用中,以便在我们的 WebAssembly 模块中调用update_emitter

Module.ccall('update_emitter', 'undefined', ["number", "number",                                       "number", "number", "number", "bool",
                                  "number", "number", "number",                                           "number", "number","number",
                                            /* new parameters */
                                             "number", "number",
                                             "number", "number",
                                             "number", "number",
                                             "number", "number",
                                             "bool", "bool", "number"],
                                            [max_particles, min_angle, 
                                             max_angle,
                                             particle_lifetime,         
                                             acceleration, alpha_fade,
                                             min_starting_velocity, 
                                             max_starting_velocity,
                                             emission_rate, x_pos, 
                                             y_pos, radius,
                                            /* new parameters */
                                             min_start_scale,   
                                             max_start_scale,
                                             min_end_scale, 
                                             max_end_scale,
                                             start_color, end_color,
                                             burst_time, 
                                             burst_particles,
                                             loop, align_rotation, 
                                             emit_time,
                                             animation_frames
                                         ]);

修改 handleFiles 函数

我们需要对 HTML 外壳文件进行的最后更改是修改handleFiles函数。这些修改实际上与UpdateClick函数的更改相同。当您逐步执行代码时,您将看到handleFiles中复制的相同强制转换,并且Module.ccalladd_emitter将使用相同的新参数类型和参数进行更新。以下是最新版本的handleFiles函数的代码:

function handleFiles(files) {
    var file_count = 0;
    for (var i = 0; i < files.length; i++) {
        if (files[i].type.match(/image.png/)) {
            var file = files[i]; 
            var file_name = file.name;
            var fr = new FileReader();
            fr.onload = function (file) {
                var data = new Uint8Array(fr.result);
                Module.FS_createDataFile('/', file_name, data, true, true, 
                true);
                var max_particles = Number(document.getElementById
                                    ("max_particles").value);
                var min_angle = Number(document.getElementById
                                ("min_angle").value) / 180 * Math.PI;
                var max_angle = Number(document.getElementById
                                ("max_angle").value) / 180 * Math.PI
                var particle_lifetime = Number(document.getElementById
                                        ("lifetime").value);
                var acceleration = Number(document.getElementById
                                   ("acceleration").value);
                var alpha_fade = Boolean(document.getElementById
                                 ("alpha_fade").checked);
                var emission_rate = Number(document.getElementById
                                    ("emission_rate").value);
                var x_pos = Number(document.getElementById
                                  ("x_pos").value);
                var y_pos = Number(document.getElementById
                                  ("y_pos").value);
                var radius = Number(document.getElementById
                                   ("radius").value);
                var min_starting_velocity = Number(document.getElementById
                                            ("min_starting_vel").value);
                var max_starting_velocity = Number(document.getElementById
                                            ("max_starting_vel").value);

                /* NEW INPUT PARAMETERS */
                var min_start_scale = Number(document.getElementById
                                      ("min_starting_scale").value);
                var max_start_scale = Number(document.getElementById
                                      ("max_starting_scale").value);
                var min_end_scale = Number(document.getElementById
                                    ("min_end_scale").value);
                var max_end_scale = Number(document.getElementById
                                    ("max_end_scale").value);
                var start_color_str = document.getElementById
                                     ("start_color").value.substr(1, 7);
                var start_color = parseInt(start_color_str, 16);
                var end_color_str = document.getElementById
                                    ("end_color").value.substr(1, 7);
                var end_color = parseInt(end_color_str, 16);
                var burst_time = Number(document.getElementById
                                 ("burst_time").value);
                var burst_particles = Number(document.getElementById
                                      ("burst_particles").value);
                var loop = Boolean(document.getElementById
                           ("loop").checked);
                var align_rotation = Boolean(document.getElementById 
                                     ("align_rotation").checked);
                var emit_time = Number(document.getElementById
                                ("emit_time").value);
                var animation_frames = Number(document.getElementById
                                       ("animation_frames").value);

                Module.ccall('add_emitter', 'undefined', 
                ["string","number", "number", "number", 
                "number","number","bool","number","number",
                "number", "number", "number","number", 
                /* new parameters */ 
                "number", "number", "number",
                "number", "number", "number", "number", 
                "number","bool", "bool", "number"],
                    file_name,max_particles,min_angle,max_angle,
                    particle_lifetime,acceleration,alpha_fade,
                    min_starting_velocity,max_starting_velocity,
                    emission_rate, x_pos,y_pos,radius,
                    /* new parameters */ 
                    min_start_scale,max_start_scale,min_end_scale, 
                    max_end_scale,start_color,end_color,
                    burst_time,burst_particles,loop,
                    align_rotation,emit_time,animation_frames ]);
                image_added = true;
            };
            fr.readAsArrayBuffer(files[i]); }}}

现在我们有了 JavaScript 代码,我们可以开始对 WebAssembly 模块进行修改。

修改 Particle 类

现在我们已经对 HTML 外壳文件进行了更改,我们需要对 WebAssembly 模块进行一些更改,以支持这些新参数。我们将从下往上逐步进行工作,从Particle类开始。这个类不仅对我们正在构建的设计粒子系统的工具有用,而且是我们完成后将能够引入我们的游戏中的几个类之一,这将使我们能够添加一些美丽的效果。

以下是game.hpp文件中粒子类定义的样子:

class Particle {
    public:
        bool m_active;
        bool m_alpha_fade;
        bool m_color_mod;
        bool m_align_rotation;
        float m_rotation;

        Uint8 m_start_red;
        Uint8 m_start_green;
        Uint8 m_start_blue;

        Uint8 m_end_red;
        Uint8 m_end_green;
        Uint8 m_end_blue;

        Uint8 m_current_red;
        Uint8 m_current_green;
        Uint8 m_current_blue;

        SDL_Texture *m_sprite_texture;
        int m_ttl;

        Uint32 m_life_time;
        Uint32 m_animation_frames;
        Uint32 m_current_frame;
        Uint32 m_next_frame_ms;

        float m_acceleration;
        float m_alpha;
        float m_width;
        float m_height;
        float m_start_scale;
        float m_end_scale;
        float m_current_scale;

        Point m_position;
        Point m_velocity;

        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 0, .h = 0 };
        SDL_Rect m_src = {.x = 0, .y = 0, .w = 0, .h = 0 };

        Particle( SDL_Texture *sprite, Uint32 life_time, float 
        acceleration,
                    bool alpha_fade, int width, int height, bool 
                    align_rotation,
                    Uint32 start_color,
                    Uint32 end_color,
                    Uint32 animation_frames );
        void Update( Uint32 life_time, float acceleration,
                     bool alpha_fade, bool align_rotation,
                     Uint32 start_color, Uint32 end_color,
                     Uint32 animation_frames );

        void Spawn( float x, float y, float velocity_x, float 
                velocity_y,
                    float start_scale, float end_scale, float rotation );

        void Move();
        void Render();
};

新属性

我们将逐步介绍已添加到Particle类定义中的新属性,并简要讨论每个新属性的作用。我们添加的第一个属性是bool m_color_mod。在我们的 HTML 中,我们没有为这个值添加复选框,所以您可能会想知道为什么这里有一个。原因是性能。如果用户不想要颜色修改,调用SDL_SetTextureColorMod就是浪费。如果我们将两个白色值传递给Particle对象,就不需要进行插值或调用修改值。我们可以每次检查开始和结束颜色,看它们的值是否为0xffffff,但我觉得添加这个标志会使检查更清晰。

对齐旋转属性

接下来跟随的m_align_rotation标志只是我们从复选框中传递的标志。如果这个值为true,粒子将自行旋转以指向它移动的方向。接下来是浮点变量m_rotation。这个属性变量保存粒子的角度,将根据粒子移动的方向进行旋转。以下是这些值在我们的代码中的样子:

bool m_align_rotation;
float m_rotation;

颜色属性

我之前提到的颜色 mod 标志使得对下一组值的检查变得更容易。我们在 HTML 中表示红色、绿色和蓝色值的十六进制颜色值需要作为整数传递,以便它可以被分解为三个 8 位通道。以下是代码中这些 8 位颜色变量的样子:

Uint8 m_start_red;
Uint8 m_start_green;
Uint8 m_start_blue;

Uint8 m_end_red;
Uint8 m_end_green;
Uint8 m_end_blue;

Uint8 m_current_red;
Uint8 m_current_green;
Uint8 m_current_blue;

您会注意到这些都是声明为Uint8的 8 位无符号整数变量。当 SDL 执行颜色修改时,它不会将 RGB 值作为单个变量传入;相反,它会将这些值分解为三个表示每个单独通道的 8 位变量。m_start_(color)变量和m_end_(color)变量将根据粒子寿命进行插值,得到m_current_(color)变量,这将作为通道传递给 SDL 进行颜色修改。因为我们将这些值作为单个颜色变量从 JavaScript 传递,所以Particle构造函数和Update函数需要执行位操作来设置这些单独的通道变量。

动画属性

接下来的一组新属性都与Particle中新的帧动画功能有关。以下是代码中的这些属性:

Uint32 m_animation_frames;
Uint32 m_current_frame;
Uint32 m_next_frame_ms;

第一个属性m_animation_frames是间接从 JavaScript 传递的值。它告诉Particle类在将纹理渲染到画布时有多少帧。第二个属性m_current_frameParticle类用于跟踪它当前应该渲染的帧。最后一个属性变量m_next_frame_ms告诉粒子在必须增加当前帧以显示序列中的下一帧之前还有多少毫秒。

大小和比例属性

接下来一批属性与粒子的大小和比例有关。在此代码的先前版本中,我们在m_dest矩形中处理了宽度和高度。这已不再实际,因为这个矩形的宽度和高度(wh)属性需要被修改以适应我们当前的比例。以下是这些新变量在代码中的样子:

float m_width;
float m_height;

float m_start_scale;
float m_end_scale;
float m_current_scale;

m_widthm_height属性现在是必需的,以跟踪粒子的原始宽度和高度,这些宽度和高度已经通过比例调整。

m_start_scalem_end_scale属性是在 JavaScript 中定义的maxmin值之间随机选择的值。

m_current_scale属性是在渲染粒子时用于计算m_dest.wm_dest.h值的当前比例。当前比例将是在m_start_scalem_end_scale属性之间插值的值。

源矩形属性

在先前版本的代码中,我们没有帧动画粒子。因此,我们不需要声明源矩形。如果要将整个纹理渲染到画布上,可以在调用SDL_RenderCopy时传入NULL,这就是我们所做的。现在我们有了帧动画,我们将传入纹理渲染到画布的位置和尺寸。因此,我们需要定义一个源矩形属性:

SDL_Rect m_src = {.x = 0, .y = 0, .w = 0, .h = 0 };

额外的构造函数参数

现在我们已经了解了所有新属性,我们将简要讨论我们的函数签名所需的更改。Particle类构造函数必须添加一些新参数,以支持我们的对齐旋转、颜色修改和帧动画功能。构造函数的新签名如下:

Particle( SDL_Texture *sprite, Uint32 life_time, float acceleration,
             bool alpha_fade, int width, int height, bool align_rotation,
             Uint32 start_color,
             Uint32 end_color,
             Uint32 animation_frames );

名为align_rotation的布尔值告诉构造函数将粒子的旋转与其移动方向对齐。start_colorend_color参数是颜色修改值,如果我们使用粒子系统的新颜色修改功能。最后一个参数animation_frames告诉粒子系统是否正在使用帧动画系统,如果是,它将使用多少帧。

Update函数的参数

Update函数签名的修改反映了我们需要对构造函数进行的更改。一共有四个新参数,用于影响对齐旋转、颜色修改系统和帧动画系统。

新的Update函数签名如下:

void Update( Uint32 life_time, float acceleration,
             bool alpha_fade, bool align_rotation,
             Uint32 start_color, Uint32 end_color,
             Uint32 m_animation_frames );

Spawn函数的参数

最后需要修改的函数签名是Spawn函数。需要新值来允许Emitter在生成单个粒子时设置比例和旋转值。float start_scalefloat end_scale参数用于设置生成粒子时的起始和结束比例乘数。添加的最后一个参数是float rotation,表示基于这个特定粒子的xy速度的角度。以下是函数的新版本:

void Spawn( float x, float y, float velocity_x, float velocity_y,
             float start_scale, float end_scale, float rotation );

对 particle.cpp 的更改

我们需要对Particle类进行的下一组更改都是对我们在particle.cpp文件中定义的函数进行的更改。跟踪对这些函数所做的更改是具有挑战性的,因此我将带领您了解我们讨论的每个函数中所发生的一切,而不是讨论这些更改。

粒子构造函数逻辑

新的Particle构造函数中的逻辑添加了大量代码,为我们的新功能设置了舞台。函数的最新版本如下:

Particle::Particle( SDL_Texture *sprite_texture, Uint32 life_time, 
                   float acceleration, bool alpha_fade, int width, 
                   int height, bool align_rotation,
                   Uint32 start_color, Uint32 end_color, 
                   Uint32 animation_frames ) {

    if( start_color != 0xffffff || end_color != 0xffffff ) {
        m_color_mod = true;
        m_start_red = (Uint8)(start_color >> 16);
        m_start_green = (Uint8)(start_color >> 8);
        m_start_blue = (Uint8)(start_color);

        m_end_red = (Uint8)(end_color >> 16);
        m_end_green = (Uint8)(end_color >> 8);
        m_end_blue = (Uint8)(end_color);

        m_current_red = m_start_red;
        m_current_green = m_start_green;
        m_current_blue = m_start_blue;
    }
    else {
        m_color_mod = false;

        m_start_red = (Uint8)255;
        m_start_green = (Uint8)255;
        m_start_blue = (Uint8)255;

        m_end_red = (Uint8)255;
        m_end_green = (Uint8)255;
        m_end_blue = (Uint8)255;

        m_current_red = m_start_red;
        m_current_green = m_start_green;
        m_current_blue = m_start_blue;
    }
    m_align_rotation = align_rotation;
    m_animation_frames = animation_frames;
    m_sprite_texture = sprite_texture;
    m_life_time = life_time;
    m_acceleration = acceleration;
    m_alpha_fade = alpha_fade;
    m_width = (float)width;
    m_height = (float)height;

    m_src.w = m_dest.w = (int)((float)width / (float)m_animation_frames);
    m_src.h = m_dest.h = height;

    m_next_frame_ms = m_life_time / m_animation_frames;
    m_current_frame = 0;
    m_active = false;
}

这段代码的第一大批用于设置粒子生命周期开始和结束时的 8 位颜色通道。如果起始颜色或结束颜色不是0xffffff(白色),我们将使用>>运算符(位移)设置起始和结束颜色通道。以下是设置起始通道的代码:

m_start_red = (Uint8)(start_color >> 16);
m_start_green = (Uint8)(start_color >> 8);
m_start_blue = (Uint8)(start_color);

如果您不熟悉右移位运算符>>,它会将左侧的整数向右移动右侧的位数。例如,一个二进制值为 15(0000 1111)向右移动两位将返回一个新值 3(0000 0011)。当我们向右移动时,向右移的任何位都会丢失,并且值为 0 的位会从左侧移入:

图 9.1:右位移的示例

如果我们有一个 RGB 整数,每个通道占用 1 字节或 8 位。因此,如果 R = 9,G = 8,B = 7,我们的十六进制整数值看起来像这样:ff090807. 如果我们想得到 R 值,我们需要移除这个 4 字节整数右侧的两个字节。每个字节是 8 位,所以我们会取出我们的 RGB 并使用>>运算符将其向右移动 16 位。然后我们会得到值09,我们可以用它来设置我们的 8 位红色通道。当我们处理绿色通道时,我们希望从右侧取出第二个字节,以便我们可以移除 8 位。现在,在我们的 4 字节整数中,我们会有 00000908. 因为我们将其移入一个 8 位整数,所有不在最右侧字节中的数据在赋值时都会丢失,所以我们最终得到绿色通道中的08。最后,蓝色通道的值已经在最右侧字节中。我们只需要将其转换为 8 位整数,因此我们会丢失不在蓝色通道中的所有数据。以下是 32 位颜色的图示:

图 9.2:32 位整数中的颜色位

我们必须对结束颜色通道执行相同的操作:

m_end_red = (Uint8)(end_color >> 16);
m_end_green = (Uint8)(end_color >> 8);
m_end_blue = (Uint8)(end_color);

最后我们要做的是将当前颜色通道设置为起始颜色通道。我们这样做是为了使用颜色的起始值创建我们的粒子。

如果起始颜色和结束颜色都是白色,我们希望将颜色修改标志设置为false,这样我们就不会尝试修改这个粒子的颜色。我们将所有颜色通道初始化为255。以下是执行此操作的代码:

else {
    m_color_mod = false;
    m_start_red = (Uint8)255;
    m_start_green = (Uint8)255;
    m_start_blue = (Uint8)255;

    m_end_red = (Uint8)255;
    m_end_green = (Uint8)255;
    m_end_blue = (Uint8)255;

    m_current_red = m_start_red;
    m_current_green = m_start_green;
    m_current_blue = m_start_blue;
}

在管理颜色修改的代码之后是一些初始化代码,它从构造函数中传入的参数设置了这个对象的属性变量:

m_align_rotation = align_rotation;
m_animation_frames = animation_frames;
m_sprite_texture = sprite_texture;
m_life_time = life_time;
m_acceleration = acceleration;
m_alpha_fade = alpha_fade;

m_width = (float)width;
m_height = (float)height;

然后,我们根据传入的高度和宽度以及粒子的动画帧数设置源矩形和目标矩形:

m_src.w = m_dest.w = (int)((float)width / (float)m_animation_frames);
m_src.h = m_dest.h = height;

代码的最后两行将当前帧初始化为0,并将我们的活动标志初始化为false。所有动画都从第0帧开始,直到生成新的粒子才会变为活动状态。

以下是代码的最后几行:

m_current_frame = 0;
m_active = false;

粒子更新逻辑

Particle类的Update函数在之前通过 PNG 文件上传创建的每个粒子上运行。这个函数更新了构造函数中设置的大部分值。唯一的例外是粒子的宽度和高度尺寸必须保持不变。这是因为构造函数根据上传的图像文件的尺寸设置了这些值。我不觉得有必要逐步介绍这个函数的每个部分,因为它与我们刚刚讨论过的构造函数非常相似。花点时间看一下代码,看看它有多相似:

void Particle::Update( Uint32 life_time, float acceleration, 
                       bool alpha_fade, bool align_rotation,
                       Uint32 start_color, Uint32 end_color, 
                       Uint32 animation_frames ) {
    if( start_color != 0xffffff || end_color != 0xffffff ) {
        m_color_mod = true;

        m_start_red = (Uint8)(start_color >> 16);
        m_start_green = (Uint8)(start_color >> 8);
        m_start_blue = (Uint8)(start_color);

        m_end_red = (Uint8)(end_color >> 16);
        m_end_green = (Uint8)(end_color >> 8);
        m_end_blue = (Uint8)(end_color);

        m_current_red = m_start_red;
        m_current_green = m_start_green;
        m_current_blue = m_start_blue;
    }
     else {
        m_color_mod = false;

        m_start_red = (Uint8)255;
        m_start_green = (Uint8)255;
        m_start_blue = (Uint8)255;

        m_end_red = (Uint8)255;
        m_end_green = (Uint8)255;
        m_end_blue = (Uint8)255;

        m_current_red = m_start_red;
        m_current_green = m_start_green;
        m_current_blue = m_start_blue;
    }

    m_align_rotation = align_rotation;
    m_life_time = life_time;
    m_acceleration = acceleration;
    m_alpha_fade = alpha_fade;
    m_active = false;

    m_current_frame = 0;
    m_animation_frames = animation_frames;
    m_next_frame_ms = m_life_time / m_animation_frames;;

    m_src.w = m_dest.w = (int)((float)m_width / (float)m_animation_frames);
    m_src.h = m_dest.h = m_height;
}

粒子生成函数

Particle类的Spawn函数由Emitter在需要发射新粒子时运行。当发射器达到下一个粒子发射时间时,它会在粒子池中寻找一个标记为非活动的粒子。如果找到一个粒子,它会调用该粒子的Spawn函数,激活粒子并设置其运行时的几个值。每次粒子被发射时,Emitter都会改变传递给Spawn的所有值。以下是这个函数的代码:

void Particle::Spawn( float x, float y,
                      float velocity_x, float velocity_y,
                      float start_scale, float end_scale,
                      float rotation ) {
     m_position.x = x;
     m_dest.x = (int)m_position.x;
     m_position.y = y;
     m_dest.y = (int)m_position.y;

    m_velocity.x = velocity_x;
    m_velocity.y = velocity_y;

    m_alpha = 255.0;
    m_active = true;
    m_ttl = m_life_time;
    m_rotation = rotation;

    m_current_red = m_start_red;
    m_current_green = m_start_green;
    m_current_blue = m_start_blue;

    m_current_scale = m_start_scale = start_scale;
    m_end_scale = end_scale;

    m_current_frame = 0;
    m_next_frame_ms = m_life_time / m_animation_frames;
}

这个函数中几乎所有的操作都是初始化,非常直接。前四行初始化了位置属性(m_position),以及与目标矩形相关的位置(m_dest)。然后设置了速度。Alpha 始终从255开始。粒子被激活,生存时间变量被激活,旋转被设置。颜色通道被重新初始化,比例被初始化,当前帧和下一帧的时间被设置。

粒子移动函数

Particle类的Move函数是一个不仅改变粒子渲染位置,还调整粒子生命周期开始和结束之间所有插值数值的函数。让我们逐步看一下代码:

void Particle::Move() {
    float time_pct = 1.0 - (float)m_ttl / (float)m_life_time;
    m_current_frame = (int)(time_pct * (float)m_animation_frames);
    float acc_adjusted = 1.0f;

    if( m_acceleration < 1.0f ) {
        acc_adjusted = 1.0f - m_acceleration;
        acc_adjusted *= delta_time;
        acc_adjusted = 1.0f - acc_adjusted;
    }
    else if( m_acceleration > 1.0f ) {
        acc_adjusted = m_acceleration - 1.0f;
        acc_adjusted *= delta_time;
        acc_adjusted += 1.0f;
    }
    m_velocity.x *= acc_adjusted;
    m_velocity.y *= acc_adjusted;

    m_position.x += m_velocity.x * delta_time;
    m_position.y += m_velocity.y * delta_time;

    m_dest.x = (int)m_position.x;
    m_dest.y = (int)m_position.y;

    if( m_alpha_fade == true ) {
         m_alpha = 255.0 * (1.0 - time_pct);
         if( m_alpha < 0 ) {
            m_alpha = 0;
        }
    }
    else {
        m_alpha = 255.0;
    }
    if( m_color_mod == true ) {
        m_current_red = m_start_red + (Uint8)(( m_end_red - m_start_red
        ) * 
        time_pct);
        m_current_green = m_start_green + (Uint8)(( m_end_green -
        m_start_green ) * 
        time_pct);
        m_current_blue = m_start_blue + (Uint8)(( m_end_blue -
        m_start_blue ) * 
        time_pct);
    }

    m_current_scale = m_start_scale + (m_end_scale - m_start_scale) * 
    time_pct;
    m_dest.w = (int)(m_src.w * m_current_scale);
    m_dest.h = (int)(m_src.h * m_current_scale);    
    m_ttl -= diff_time;

    if( m_ttl <= 0 ) {
        m_active = false;
    }
    else {
        m_src.x = (int)(m_src.w * m_current_frame);
    }
}

Move函数的第一行计算time_pct。这是一个浮点值,范围从0.01.0。当粒子刚刚生成时,该变量的值为0.0,当粒子准备停用时,该值为1.0。它给我们一个浮点值,指示我们在这个粒子的寿命中的位置:

float time_pct = 1.0 - (float)m_ttl / (float)m_life_time;

m_ttl属性是粒子在毫秒内的存活时间,m_life_time是粒子的总寿命。这个值对于在Move函数内进行所有插值计算非常有用。

以下一行根据time_pct中的值返回当前帧:

m_current_frame = (int)(time_pct * (float)m_animation_frames);

之后,几行代码根据加速度值调整粒子的 x 和 y 速度:

float acc_adjusted = 1.0f;

if( m_acceleration < 1.0f ) {
    acc_adjusted = 1.0f - m_acceleration;
    acc_adjusted *= delta_time;
    acc_adjusted = 1.0f - acc_adjusted;
}
else if( m_acceleration > 1.0f ) {
    acc_adjusted = m_acceleration - 1.0f;
    acc_adjusted *= delta_time;
    acc_adjusted += 1.0f;
}

m_velocity.x *= acc_adjusted;
m_velocity.y *= acc_adjusted;

我们需要将acc_adjusted变量设置为m_acceleration变量的修改版本,根据已经过去的秒数(delta_time)来调整。在改变m_velocity值后,我们需要使用这些速度值来修改粒子的位置:

m_position.x += m_velocity.x * delta_time;
m_position.y += m_velocity.y * delta_time;

m_dest.x = (int)m_position.x;
m_dest.y = (int)m_position.y;

如果m_alpha_fade变量为true,代码将修改 alpha 值,使其在time_pct值变为1.0时插值为0。如果m_alpha_fade标志未设置,alpha 值将设置为255(完全不透明)。以下是代码:

if( m_alpha_fade == true ) {
    m_alpha = 255.0 * (1.0 - time_pct);
    if( m_alpha < 0 ) {
        m_alpha = 0;
    }
}
else {
    m_alpha = 255.0;
}

如果m_color_mod标志为true,我们需要使用time_pct来在起始通道颜色值和结束通道颜色值之间进行插值,以找到当前通道颜色值:

if( m_color_mod == true ) {
    m_current_red = m_start_red + (Uint8)(( m_end_red - m_start_red ) *         
    time_pct);
    m_current_green = m_start_green + (Uint8)(( m_end_green -
    m_start_green ) * time_pct);
    m_current_blue = m_start_blue + (Uint8)(( m_end_blue - m_start_blue         
    ) * time_pct);
}

找到每个颜色通道的插值值后,我们需要使用time_pct来插值当前比例。然后,我们根据当前比例值和源矩形的尺寸设置目标宽度和目标高度:

m_current_scale = m_start_scale + (m_end_scale - m_start_scale) * time_pct;
m_dest.w = (int)(m_src.w * m_current_scale);
m_dest.h = (int)(m_src.h * m_current_scale);

我们将做的最后一件事是减少m_ttl变量(存活时间)的值,减去diff_time(自上一帧渲染以来的时间)。如果存活时间降至或低于0,我们将停用粒子,使其在粒子池中可用,并停止渲染。如果还有一些存活时间,我们将m_src.x(源矩形x值)设置为我们要渲染的帧的正确位置:

m_ttl -= diff_time;
if( m_ttl <= 0 ) {
    m_active = false;
}
else {
    m_src.x = (int)(m_src.w * m_current_frame);
}

粒子渲染函数

我们Particle类中的最后一个函数是Render函数。Emitter类为粒子池中的每个活动粒子调用此函数。该函数在粒子使用的精灵纹理上设置 alpha 和颜色通道值。然后,它检查m_align_rotation标志,看看纹理是否需要使用SDL_RenderCopySDL_RederCopyEx复制到后台缓冲区。这两个渲染调用之间的区别在于SDL_RenderCopyEx允许复制进行旋转或翻转。这两个函数都使用m_src矩形来确定要复制的纹理内的矩形。两者都使用m_dest矩形来确定后台缓冲区中的目的地,我们在那里复制我们的纹理数据:

void Particle::Render() {

    SDL_SetTextureAlphaMod(m_sprite_texture,
                            (Uint8)m_alpha );

    if( m_color_mod == true ) {
        SDL_SetTextureColorMod(m_sprite_texture,
        m_current_red,
        m_current_green,
        m_current_blue );
    }

    if( m_align_rotation == true ) {
        SDL_RenderCopyEx( renderer, m_sprite_texture, &m_src, &m_dest, 
                            m_rotation, NULL, SDL_FLIP_NONE );
    }
    else {
        SDL_RenderCopy( renderer, m_sprite_texture, &m_src, &m_dest );
    }
}

在接下来的部分,我们将讨论如何修改我们的Emitter类以适应我们的改进。

修改Emitter

正如我之前提到的,在讨论Emitter类时,它管理和发射粒子。在典型的粒子系统中,可能会有许多发射器。在我们的游戏中,最终会允许多个发射器,但在这个工具中,为了简单起见,我们将保持单个发射器。Emitter类中定义了四个函数,我们将更改其中的三个。唯一不需要更改的函数是GetFreeParticle函数。如果你不记得,GetFreeParticle循环遍历m_particle_pool(粒子池属性),寻找未标记为激活的粒子(particle->m_active == false)。如果找到,它返回该粒子。如果没有找到,它返回null

发射器构造函数

Emitter构造函数的代码将需要更改,以便我们可以设置支持新粒子系统功能所需的属性。以下是新Emitter构造函数的代码:

Emitter::Emitter(char* sprite_file, int max_particles, float min_angle, 
         float max_angle, Uint32 particle_lifetime, 
         float acceleration, bool alpha_fade,
         float min_starting_velocity, float max_starting_velocity,
         Uint32 emission_rate, int x_pos, int y_pos, float radius,
         float min_start_scale, float max_start_scale,
         float min_end_scale, float max_end_scale,
         Uint32 start_color, Uint32 end_color,
         float burst_time_pct, Uint32 burst_particles,
         bool loop, bool align_rotation, Uint32 emit_time_ms, 
         Uint32 animation_frames ) {
    m_start_color = start_color;
    m_end_color = end_color;
    m_active = true;
    if( min_starting_velocity > max_starting_velocity ) {
        m_min_starting_velocity = max_starting_velocity;
        m_max_starting_velocity = min_starting_velocity;
    }
    else {
        m_min_starting_velocity = min_starting_velocity;
        m_max_starting_velocity = max_starting_velocity;
    }
    SDL_Surface *temp_surface = IMG_Load( sprite_file );
    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    m_sprite_texture = SDL_CreateTextureFromSurface( renderer, temp_surface 
    );
    SDL_FreeSurface( temp_surface );
    SDL_QueryTexture( m_sprite_texture,
                        NULL, NULL,
                        &m_sprite_width, &m_sprite_height );
    m_max_particles = max_particles;
    for( int i = 0; i < m_max_particles; i++ ) {
        m_particle_pool.push_back(
            new Particle( m_sprite_texture, particle_lifetime, 

                          acceleration, alpha_fade, m_sprite_width, 
                          m_sprite_height, align_rotation,
                          m_start_color, m_end_color, 
                          animation_frames )
            );
    }
    m_max_angle = max_angle;
    m_min_angle = min_angle;
    m_radius = radius;
    m_position.x = (float)x_pos;
    m_position.y = (float)y_pos;
    m_emission_rate = emission_rate;
    m_emission_time_ms = 1000 / m_emission_rate;
    m_next_emission = 0;
    /* new values */
    m_min_start_scale = min_start_scale;
    m_max_start_scale = max_start_scale;
    m_min_end_scale = min_end_scale;
    m_max_end_scale = max_end_scale;

    m_loop = loop;
    m_align_rotation = align_rotation;
    m_emit_loop_ms = emit_time_ms;
    m_ttl = m_emit_loop_ms;
    m_animation_frames = animation_frames;
    m_burst_time_pct = burst_time_pct;
    m_burst_particles = burst_particles;
    m_has_burst = false;
}

这段代码已经改变了很多,我觉得有必要逐步讲解整个函数。前两行设置了color属性,然后通过将m_active设置为true来激活发射器。当发射器被创建或更新时,我们将此激活标志设置为true。如果是循环发射器,激活标志将一直保持激活状态。如果Emitter不循环,当它达到由emit_time_ms参数设置的发射时间结束时,发射器将停止发射。

接下来我们要做的是设置最小和最大起始速度。我们在Emitter中有一些代码,确保max_starting_velocity大于min_starting_velocity,但当我们将这段代码移到游戏中时,我们可能选择将值设置为任何有效的值。以下是代码:

if( min_starting_velocity > max_starting_velocity ) {
    m_min_starting_velocity = max_starting_velocity;
    m_max_starting_velocity = min_starting_velocity;
}
else {
    m_min_starting_velocity = min_starting_velocity;
    m_max_starting_velocity = max_starting_velocity;
}

设置速度后,使用sprite_file字符串创建一个 SDL 表面,这是我们加载到 WebAssembly 虚拟文件系统中的文件的位置。如果该文件不在虚拟文件系统中,我们会打印出错误消息并退出构造函数:

SDL_Surface *temp_surface = IMG_Load( sprite_file );

if( !temp_surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return;
}

在从图像文件创建表面后,我们使用该表面创建一个名为m_sprite_texture的 SDL 纹理,然后我们使用SDL_FreeSurface来销毁表面使用的内存,因为现在我们已经有了纹理,表面就不再需要了。然后,我们调用SDL_QueryTexture来检索精灵纹理的宽度和高度,并使用它们来设置Emitter的属性m_sprite_widthm_sprite_height。以下是代码:

m_sprite_texture = SDL_CreateTextureFromSurface( renderer, temp_surface );
SDL_FreeSurface( temp_surface );
SDL_QueryTexture( m_sprite_texture,
                  NULL, NULL,
                  &m_sprite_width, &m_sprite_height );

接下来我们需要设置m_max_particles属性,并使用该变量初始化粒子池。使用for循环将新粒子推送到std::vector变量m_particle_pool的末尾:

m_max_particles = max_particles;
for( int i = 0; i < m_max_particles; i++ ) {
    m_particle_pool.push_back(
        new Particle( m_sprite_texture, particle_lifetime, acceleration,
                        alpha_fade, m_sprite_width, m_sprite_height, 
                        align_rotation,
                        m_start_color, m_end_color, animation_frames )
    );
}

设置好粒子池后,我们使用参数设置了旧和新粒子系统值的发射器属性:

m_max_angle = max_angle;
m_min_angle = min_angle;
m_radius = radius;
m_position.x = (float)x_pos;
m_position.y = (float)y_pos;
m_emission_rate = emission_rate;
m_emission_time_ms = 1000 / m_emission_rate;
m_next_emission = 0;

/* new values */
m_min_start_scale = min_start_scale;
m_max_start_scale = max_start_scale;
m_min_end_scale = min_end_scale;
m_max_end_scale = max_end_scale;

m_loop = loop;
m_align_rotation = align_rotation;
m_emit_loop_ms = emit_time_ms;
m_ttl = m_emit_loop_ms;
m_animation_frames = animation_frames;
m_burst_time_pct = burst_time_pct;
m_burst_particles = burst_particles;
m_has_burst = false;

发射器更新逻辑

EmitterUpdate函数类似于构造函数,但在Emitter已经存在且需要更新时运行。此函数首先设置Emitter上的所有属性变量:

if( min_starting_velocity > max_starting_velocity ) {
    m_min_starting_velocity = max_starting_velocity;
    m_max_starting_velocity = min_starting_velocity;
}
else {
    m_min_starting_velocity = min_starting_velocity;
    m_max_starting_velocity = max_starting_velocity;
}
m_active = true;
m_has_burst = false;
m_max_particles = max_particles;
m_min_angle = min_angle;
m_max_angle = max_angle;
m_emission_rate = emission_rate;
m_emission_time_ms = 1000 / m_emission_rate;
m_position.x = (float)x_pos;
m_position.y = (float)y_pos;
m_radius = radius;
/* new values */
m_min_start_scale = min_start_scale;
m_max_start_scale = max_start_scale;
m_min_end_scale = min_end_scale;
m_max_end_scale = max_end_scale;
m_start_color = start_color;
m_end_color = end_color;
m_burst_time_pct = burst_time_pct;
m_burst_particles = burst_particles;
m_loop = loop;
m_align_rotation = align_rotation;
m_emit_loop_ms = emit_time_ms;
m_ttl = m_emit_loop_ms;
m_animation_frames = animation_frames;

设置属性变量后,我们可能需要增加或减少m_particle_pool向量(粒子池)的大小。如果我们的粒子池中的粒子数量大于新的最大粒子数量,我们可以通过简单的调整大小来缩小粒子池。如果粒子池太小,我们需要循环遍历创建新粒子并将这些粒子添加到池中的代码。我们一直这样做,直到池的大小与新的最大粒子数量匹配。以下是执行此操作的代码:

if( m_particle_pool.size() > m_max_particles ) {
    m_particle_pool.resize( m_max_particles );
}
else if( m_max_particles > m_particle_pool.size() ) {
    while( m_max_particles > m_particle_pool.size() ) {
        m_particle_pool.push_back(
            new Particle( m_sprite_texture, particle_lifetime, 
                            acceleration, alpha_fade, m_sprite_width, 
                            m_sprite_height, m_align_rotation,
                            m_start_color, m_end_color, 
                            m_animation_frames )
        );
    }
}

现在我们已经调整了粒子池的大小,我们需要循环遍历该池中的每个粒子,并对每个粒子运行Update函数,以确保每个粒子都使用新的属性值进行更新。以下是代码:

Particle* particle;
std::vector<Particle*>::iterator it;
for( it = m_particle_pool.begin(); it != m_particle_pool.end(); it++ ) {
    particle = *it;
    particle->Update( particle_lifetime, acceleration, alpha_fade, 
    m_align_rotation, m_start_color, m_end_color, m_animation_frames );
}

发射器移动函数

我们需要更新的最后一个发射器函数是Emitter::Move函数。这个函数确定本帧是否发射任何新粒子,以及如果是,有多少。它还使用随机化来选择这些粒子的许多起始值,这些值在我们的 HTML 中传递的范围内。在生成任何新粒子后,函数将循环遍历粒子池,移动和渲染当前处于活动状态的任何粒子。以下是这个函数的完整代码:

void Emitter::Move() {
    Particle* particle;
    std::vector<Particle*>::iterator it;
    if( m_active == true ) {
        m_next_emission -= diff_time;
        m_ttl -= diff_time;
        if( m_ttl <= 0 ) {
            if( m_loop ) {
                m_ttl = m_emit_loop_ms;
                m_has_burst = false;
            }
            else {
                m_active = false;
            }
        }
        if( m_burst_particles > 0 && m_has_burst == false ) {
            if( (float)m_ttl / (float)m_emit_loop_ms <= 1.0 - 
            m_burst_time_pct ) {
                m_has_burst = true;
                m_next_emission -= m_burst_particles * m_emission_time_ms;
            }
        }
        while( m_next_emission <= 0 ) {
            m_next_emission += m_emission_time_ms;
            particle = GetFreeParticle();
            if( particle != NULL ) {
                Point spawn_point;
                spawn_point.x = get_random_float( 0.0, m_radius );
                Point velocity_point;
                velocity_point.x = get_random_float( 
                m_min_starting_velocity, m_max_starting_velocity );
                float angle = get_random_float( m_min_angle, m_max_angle );
                float start_scale = get_random_float( m_min_start_scale, 
                m_max_start_scale );
                float end_scale = get_random_float( m_min_end_scale, 
                m_max_end_scale );
                spawn_point.x += m_position.x;
                spawn_point.y += m_position.y;
                particle->Spawn(spawn_point.x, spawn_point.y, 
                velocity_point.x, velocity_point.y,
                                start_scale, end_scale,
                                (int)(angle / 3.14159 * 180.0 + 360.0) 
                                % 360 );
            }
            else {
                m_next_emission = m_emission_time_ms;
            }
        }
    }
    for( it = m_particle_pool.begin(); it != m_particle_pool.end(); it++ ) {
        particle = *it;
        if( particle->m_active ) {
            particle->Move();
            particle->Render();
        }
    }
}

我们将这段代码分成两部分,以便更容易理解。Move函数的第一部分负责在必要时生成新粒子。第二部分负责移动和渲染任何已激活的粒子。只有当m_active(活动标志)为true时,才会运行这段代码的粒子生成部分。第二部分无论如何都会运行。当发射器停用时,我们不希望由发射器生成的所有粒子突然消失。相反,我们希望所有粒子继续移动和渲染,直到它们全部被停用。

我们现在将逐步解释代码中的每个部分:

if( m_active == true ) {
    m_next_emission -= diff_time;
    m_ttl -= diff_time;
    if( m_ttl <= 0 ) {
        if( m_loop ) {
            m_ttl = m_emit_loop_ms;
            m_has_burst = false;
        }
        else {
            m_active = false;
        }
    }

这段代码首先检查m_active属性变量,以确保发射器当前处于活动状态。如果不是,我们可以跳过生成新粒子的部分。接下来我们要做的是从m_next_emission属性中减去diff_time。当m_next_emission属性达到或低于0时,将生成另一个粒子。我们还从m_ttl中减去diff_time,这是存活时间属性。在从m_ttl中减去后,我们立即检查m_ttl中的值,看它是否小于或等于0。如果存活时间低于0,我们需要检查m_loop属性,看这是否是一个循环发射器。如果是循环发射器,我们重置存活时间变量,并将m_has_burst标志设置为false。如果这不是一个循环发射器,我们通过将m_active设置为false来停用发射器。

以下代码块涉及使用新的爆发功能发射粒子的部分:

if( m_burst_particles > 0 && m_has_burst == false ) {
    if( (float)m_ttl / (float)m_emit_loop_ms <= 1.0 - m_burst_time_pct ) {
        m_has_burst = true;
        m_next_emission -= m_burst_particles * m_emission_time_ms;
    }
}

爆发粒子功能是我们高级粒子系统的新功能。我们在这里使用了嵌套的if语句。我们本可以在第一个if的末尾加上&&,用一个if语句完成,但我想分开条件以便更容易理解。外部if语句首先检查m_burst_particles属性(爆发粒子的数量)是否大于0。如果是,那么这个发射器使用爆发系统,并需要在适当的爆发时间创建一波粒子。这个外部if语句中的下一个检查是检查爆发是否已经在这个发射器中运行过。由于我们设计了这个爆发系统的方式,每次发射循环只能有一个爆发。因此,如果m_has_burst属性为true,则不会运行爆发。

继续内部循环,我们需要检查是否已经超过了发射的爆发时间。m_burst_time_pct属性保存了一个值,介于0.01.0之间,表示粒子爆发发生的时间百分比。m_ttl变量保存了发射器的存活时间(以毫秒为单位)。如果我们将m_ttl除以m_emit_loop_ms(以毫秒为单位的发射时间),我们得到一个从1.00.0的发射时间倒计时,其中0.0表示发射完成。m_burst_time_pct变量则相反。值为0.6表示爆发发生在我们发射的 60%处。因为这个if语句的另一侧是一个倒计时,而爆发时间是递增的,我们需要从1.0中减去m_burst_time_pct来进行适当的比较。如果(float)m_ttl / (float)m_emit_loop_ms小于1.0 - m_burst_time_pct,那么我们准备好进行爆发。为了进行爆发,我们首先设置m_has_burst = true。这将防止在同一次发射中多次发生爆发。然后,我们从m_next_emission中减去爆发粒子的数量,乘以以毫秒为单位的发射时间。

接下来的几行代码进入一个while循环,只要下一个发射时间小于0,就会发射粒子。在这段代码的先前版本中,我们在这里有一个if语句而不是一个循环。这限制了我们的粒子系统每帧只能发射一个粒子。这对一些简单的没有爆发模式的粒子系统可能有效,但一旦添加了爆发,就需要能够在单帧中发射许多粒子。让我们来看看这个:

while( m_next_emission <= 0 ) {
    m_next_emission += m_emission_time_ms;
    particle = GetFreeParticle();
    if( particle != NULL ) {

while循环检查m_next_emission是否小于或等于0。紧接着的一行将m_emission_time_ms添加到下一个发射时间。这样做的效果是,如果我们从m_next_emission中减去了一个大数(就像我们在爆发中做的那样),这个循环将允许我们在Move函数的单次运行中发射多个粒子。这意味着我们可以在单帧中发射许多粒子。在添加到m_next_emission后,我们立即从粒子池中获取一个空闲粒子,通过调用GetFreeParticle。如果我们将最大粒子数设置得太小,GetFreeParticle可能会用尽我们可以使用的粒子并返回NULL。如果是这种情况,我们需要跳过所有发射新粒子的步骤,这就是为什么有if语句来检查NULL粒子的原因。

一旦我们知道我们可以生成一个粒子,我们需要在 HTML 文件中设置的范围内获取一堆随机值。C/C++的rand()函数返回一个随机整数。我们需要的大多数值都是浮点数。我们需要编写一个简单的名为get_random_float的函数。这个函数获取一个随机的浮点数,保留三位小数,落在传入的最小值和最大值之间。我们根据这个游戏的需求选择了三位小数的精度。如果以后有必要,这个函数可以修改为更高的精度。

这是为新生成的粒子获取随机值的代码:

Point spawn_point;
spawn_point.x = get_random_float( 0.0, m_radius );
Point velocity_point;
velocity_point.x = get_random_float( m_min_starting_velocity, m_max_starting_velocity );
float angle = get_random_float( m_min_angle, m_max_angle );
float start_scale = get_random_float( m_min_start_scale, m_max_start_scale );
float end_scale = get_random_float( m_min_end_scale, m_max_end_scale );

我们在这里得到的随机值是我们将生成粒子的发射器距离,粒子的速度,粒子的方向角以及起始和结束的比例值。因为我们希望从我们的发射器中心生成的粒子也具有相同的方向速度,所以我们只给spawn_pointvelocity_pointx值分配了一个随机数。我们将使用之前随机生成的相同角度来旋转这两个点。这是这些点的旋转代码:

velocity_point.Rotate(angle);
spawn_point.Rotate( angle );

我们使用相对于0,0原点的位置生成生成点。因为我们的发射器可能不在0,0,所以我们需要通过m_position点的值来调整生成点的位置。这是我们用来做到这一点的代码:

spawn_point.x += m_position.x;
spawn_point.y += m_position.y;

我们做的最后一件事是使用我们随机生成的值生成粒子:

particle->Spawn(spawn_point.x, spawn_point.y, velocity_point.x, 
                velocity_point.y,
                start_scale, end_scale,
                (int)(angle / 3.14159 * 180.0 + 360.0) % 360 );

现在函数已经完成了为当前帧生成粒子,函数需要循环遍历粒子池,寻找活跃的粒子进行移动和渲染:

for( it = m_particle_pool.begin(); it != m_particle_pool.end(); it++ ) {
    particle = *it;
    if( particle->m_active ) {
        particle->Move();
        particle->Render();
    }
}

在下一节中,我们将更新从 JavaScript 调用的 C++/WebAssembly 函数。

外部函数

我们正在编写的高级粒子系统具有两个可以从我们应用程序的 JavaScript 中调用的外部函数。这些函数add_emitterupdate_emitter用于在 WebAssembly 模块中插入或修改粒子系统。advanced_particle.cpp文件包含这些函数,以及main函数,当加载Module时调用,以及show_emission函数,每帧渲染调用一次。我们不需要修改mainshow_emission函数,因为我们在本章前面为基本粒子系统创建了这些函数。但是,我们需要将我们在 JavaScript 代码中放入的附加参数添加到add_emitterupdate_emitter中。此外,我们创建了一个名为get_random_float的实用函数,我们在生成粒子时使用它。因为这个文件包含了我们的其他所有 C 风格函数,我觉得advanced_particle.cpp是放置这个函数的最佳位置。

随机浮点数

让我们首先讨论新的get_random_float函数。以下是代码:

float get_random_float( float min, float max ) {
    int int_min = (int)(min * 1000);
    int int_max = (int)(max * 1000);
    if( int_min > int_max ) {
        int temp = int_max;
        int_max = int_min;
        int_min = temp;
    }
    int int_diff = int_max - int_min;
    int int_rand = (int_diff == 0) ? 0 : rand() % int_diff;
    int_rand += int_min;
    return (float)int_rand / 1000.0;
}

(取模运算符)用于使随机整数值在后使用的任何值之间的 0 和值之间。取模运算符是一个余数运算符。它返回除法运算的余数。例如,13%10将返回 3,23%10也将返回 3。对任何数字取%10将始终得到 0 到 9 之间的数字。取模与rand()结合使用很有用,因为它将导致在后的值之间的随机数。因此,rand()%10将导致在 0 和 9 之间的随机数。

get_random_float函数接受最小和最大浮点值,并在该范围内生成随机数。前两行获取这些浮点值,将它们乘以 1,000,并将它们转换为整数。因为rand()只能使用整数,所以我们需要模拟一个精度值。乘以 1,000 给我们三位小数的精度。例如,如果我们想在 1.125 和 1.725 之间生成一个随机数,那么这两个值都将乘以 1,000,我们将使用rand()在 1,125 和 1,175 之间生成一个随机值:

int int_min = (int)(min * 1000);
int int_max = (int)(max * 1000);

再次,rand()只生成随机整数,并且与rand()一起使用(取模运算符)将给出0后面的数字之间的数字。因此,我们想知道int_minint_max值之间的差异。如果我们从int_min中减去int_max,我们将得到这个差异的数字。如果调用代码意外地传递了一个比int_min的值更小的值,我们可能会被扔掉,因此我们需要一点代码来检查max是否小于min,如果是,我们需要交换这两个值。以下是if语句代码:

if( int_min > int_max ) {
    int temp = int_max;
    int_max = int_min;
    int_min = temp;
}

现在,我们可以继续获取两者之间的差异。

int int_diff = int_max - int_min;

在以下代码行中,我们获取 0 和int_diff值之间的随机值。我们使用?:(三元运算符)来确保在执行rand() % int_diff之前,int_diff不为 0。这样做的原因是%是一个除法余数运算符,所以,就像除以 0 一样,执行% 0会导致异常。如果我们的最小值和最大值之间没有差异,我们将返回最小值。因此,通过使用三元运算符,如果int_diff为 0,我们可以将int_rand设置为 0。以下是代码:

int int_rand = (int_diff == 0) ? 0 : rand() % int_diff;

然后,我们将int_min添加到int_rand,这样我们就得到了int_minint_max值之间的随机值:

int_rand += int_min;

我们需要做的最后一件事是将int_rand转换为float并除以1000.0。这将返回一个浮点值,介于传入函数的minmax浮点值之间。

return (float)int_rand / 1000.0;

添加一个发射器

add_emitter函数是一个通过函数,用于检查是否存在发射器,如果存在则删除它。然后创建一个新的Emitter对象,传入我们在 HTML 中设置并通过 JavaScript 传递的所有值。我们需要做的更改包括将新参数添加到add_emitter函数的签名中,并将这些新参数添加到Emitter构造函数的调用中。在函数签名和构造函数调用中,我们将添加一个/* new parameters */注释,显示旧参数的结束和新参数的开始位置。以下是新代码:

extern "C"
    EMSCRIPTEN_KEEPALIVE
    void add_emitter(char* file_name, int max_particles, float min_angle, 
         float max_angle,
         Uint32 particle_lifetime, float acceleration, bool alpha_fade,
         float min_starting_velocity, float max_starting_velocity,
         Uint32 emission_rate, float x_pos, float y_pos, float radius,
         /* new parameters */
         float min_start_scale, float max_start_scale,
         float min_end_scale, float max_end_scale,
         Uint32 start_color, Uint32 end_color,
         float burst_time_pct, Uint32 burst_particles,
         bool loop, bool align_rotation, Uint32 emit_time_ms,
         Uint32 animation_frames ) {
        if( emitter != NULL ) {
            delete emitter;
        }

        emitter = new Emitter(file_name, max_particles, min_angle, 
                  max_angle,
                  particle_lifetime, acceleration, alpha_fade,
                  min_starting_velocity, max_starting_velocity,
                  emission_rate, x_pos, y_pos, radius,
                  /* new parameters */
                  min_start_scale, max_start_scale,
                  min_end_scale, max_end_scale,
                  start_color, end_color,
                  burst_time_pct, burst_particles,
                  loop, align_rotation, emit_time_ms,
                  animation_frames
                  );
    }

更新发射器

我们对update_emitter函数所做的更改与add_emitter函数中所做的更改相似。add_emitterupdate_emitter之间的主要区别在于,如果没有现有的发射器,update_emitter将不会运行,而不是调用Emitter构造函数来创建新的Emitter,它调用现有发射器的Update函数。Update函数传入所有新值和大部分旧值(除了char* file_name)。就像我们对add_emitter函数所做的更改一样,我们在函数签名和发射器Update函数的调用中放置了一个/* new parameters */注释,以显示新参数已添加的位置。以下是代码:

extern "C"
    EMSCRIPTEN_KEEPALIVE
    void update_emitter(int max_particles, float min_angle, 
         float max_angle,
         Uint32 particle_lifetime, float acceleration, bool alpha_fade,
         float min_starting_velocity, float max_starting_velocity,
         Uint32 emission_rate, float x_pos, float y_pos, float radius,
         /* new parameters */
         float min_start_scale, float max_start_scale,
         float min_end_scale, float max_end_scale,
         Uint32 start_color, Uint32 end_color,
         float burst_time_pct, Uint32 burst_particles,
         bool loop, bool align_rotation, Uint32 emit_time_ms,
         Uint32 animation_frames ) {
         if( emitter == NULL ) {
                        return;
                    }
                    emitter->Update(max_particles, min_angle, max_angle,
                          particle_lifetime, acceleration, alpha_fade,
                          min_starting_velocity, max_starting_velocity,
                          emission_rate, x_pos, y_pos, radius,
                          /* new parameters */
                          min_start_scale, max_start_scale,
                          min_end_scale, max_end_scale,
                          start_color, end_color,
                          burst_time_pct, burst_particles,
                          loop, align_rotation, emit_time_ms,
                          animation_frames
                    );
                }

在下一节中,我们将配置我们的高级粒子系统工具来创建一个新的粒子发射器

配置粒子发射器

此时,您可能想知道我们何时会回到编写游戏。我们构建这个粒子发射器配置工具有几个原因。首先,在编译代码中配置粒子系统是很困难的。如果我们想测试发射器的配置,我们需要在每次测试时重新编译我们的值,或者我们需要编写一个数据加载程序,并在进行配置更改后重新运行游戏。创建一个允许我们测试不同发射器配置的工具可以加快(并且更有趣)粒子系统的创建。

HTML 外壳和 WebAssembly 模块交互

我创建粒子系统配置工具还有一个别有用心的动机。有可能你们中的一些人并不是为了学习游戏编程而阅读这本书。您可能购买这本书是为了以一种有趣的方式了解更多关于 WebAssembly。编写这个工具是一种有趣的方式来了解 WebAssembly 模块与驱动该模块的 HTML 和 JavaScript 之间的交互。

编译和运行新工具

现在我们已经拥有了所有我们想要的参数,是时候重新编译更新版本的配置工具并开始设计一些粒子系统了。

如果您是从 GitHub 项目构建的,您需要从/Chapter09/advanced-particle-tool/目录运行编译命令。

首先,在命令行上运行以下命令来编译新的配置工具:

em++ emitter.cpp particle.cpp point.cpp advanced_particle.cpp -o particle.html -std=c++17 --shell-file advanced_particle_shell.html -s NO_EXIT_RUNTIME=1 -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s NO_EXIT_RUNTIME=1 -s EXPORTED_FUNCTIONS="['_add_emitter', '_update_emitter', '_main']" -s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap', 'ccall']" -s FORCE_FILESYSTEM=1

emrun中打开网页或者在 Web 浏览器中打开(如果您正在运行 Web 服务器)。它会看起来像这样:

图 9.3:我们的粒子系统配置工具的屏幕截图

我们将从一个简单的排气发射器开始。对 HTML 值进行以下更改,然后点击“上传.png”按钮:

  • 最小角度:-10

  • 最大角度:10

  • 最大粒子数:500

  • 发射速率:50

  • 半径:0.5

  • 最小起始速度:100.0

  • 最大起始速度:150.0

  • 爆发时间:0.7

  • 爆发粒子数:40

  • 动画帧数:6

当您点击“上传.png”按钮后,转到图像目录中的ProjectileExpOrange.png文件并打开它。

这是配置工具与我们的排气粒子发射器的屏幕截图:

图 9.4:引擎排气配置

我鼓励您尝试不同的值,直到您得到满意的效果。每当您更改页面左侧的值时,您需要点击“更新发射器”按钮,以查看新值在网页右侧的粒子系统中的反映。

创建粒子发射器

现在我们有了一个排气粒子系统,我们将开始将粒子系统代码添加到游戏中,以添加一些漂亮的粒子效果。我想为玩家和敌人的飞船排气口添加一个粒子系统。我还想在我们的动画爆炸上添加一个粒子系统效果,使其更加突出。

我们要做的第一件事是将particle.cppemitter.cpp文件复制到主Chapter09目录中。之后,我们需要将这些类定义添加到game.hpp文件,以及get_random_float函数原型。

对 game.hpp 的更改

我们需要做的第一组更改是对game.hpp文件。我们需要添加一个Emitter类定义,一个Particle类定义,以及一个get_random_float的外部函数原型。我们还需要向Ship类添加一些新属性。这是我们必须添加的get_random_float原型的行:

extern float get_random_float( float min, float max );

添加 Particle 类定义

我们必须添加到game.hppParticle类的定义与我们的高级配置工具相同。因为它是相同的,我们不会逐一介绍类中的每个内容。如果您不记得,请随时返回到上一章作为参考。这是我们将添加到game.hppParticle类定义代码:

class Particle {
    public:
        bool m_active;
        bool m_alpha_fade;
        bool m_color_mod;
        bool m_align_rotation;

        Uint8 m_start_red;
        Uint8 m_start_green;
        Uint8 m_start_blue;

        Uint8 m_end_red;
        Uint8 m_end_green;
        Uint8 m_end_blue;

        Uint8 m_current_red;
        Uint8 m_current_green;
        Uint8 m_current_blue;

        SDL_Texture *m_sprite_texture;
        int m_ttl;

        Uint32 m_life_time;
        Uint32 m_animation_frames;
        Uint32 m_current_frame;

        Uint32 m_next_frame_ms;
        float m_rotation;
        float m_acceleration;
        float m_alpha;

        float m_width;
        float m_height;

        float m_start_scale;
        float m_end_scale;
        float m_current_scale;

        Point m_position;
        Point m_velocity;
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 0, .h = 0 };
        SDL_Rect m_src = {.x = 0, .y = 0, .w = 0, .h = 0 };

        Particle( SDL_Texture *sprite, Uint32 life_time, float 
                    acceleration,
                    bool alpha_fade, int width, int height, bool 
                    align_rotation,
                    Uint32 start_color,
                    Uint32 end_color,
                    Uint32 animation_frames );

        void Update( Uint32 life_time, float acceleration,
                    bool alpha_fade, bool align_rotation,
                    Uint32 start_color, Uint32 end_color,
                    Uint32 m_animation_frames );

        void Spawn( float x, float y, float velocity_x, float velocity_y,
                    float start_scale, float end_scale, float rotation );
        void Move();
        void Render();
};

Emitter 类定义

Emitter类有一些额外的属性,我们已经添加了这些属性,这些属性帮助Emitter相对于游戏对象定位。有一个Run函数,我们在粒子发射器配置工具中不需要,但在游戏代码中需要,这样我们就可以随时触发EmitterEmitterParticle内部的Update函数在游戏中并不是必需的,但我们将它们保留在那里,以免复杂化更改。Emscripten 死代码消除逻辑应该在编译游戏时删除该代码。这是我们需要添加到games.hppEmitter类定义的新代码:

class Emitter {
    public:
        bool m_loop;
        bool m_align_rotation;
        bool m_active;
        bool m_has_burst;

        SDL_Texture *m_sprite_texture;
        std::vector<Particle*> m_particle_pool;
        int m_sprite_width;
        int m_sprite_height;
        int m_ttl;

        // added ----------------------------
        int m_x_adjustment = 0;
        int m_y_adjustment = 0;
        // ----------------------------------

        Uint32 m_max_particles;
        Uint32 m_emission_rate;
        Uint32 m_emission_time_ms;

        Uint32 m_start_color;
        Uint32 m_end_color;

        Uint32 m_burst_particles;
        Uint32 m_emit_loop_ms;
        Uint32 m_animation_frames;

        int m_next_emission;

        float* m_parent_rotation;

        float m_max_angle;
        float m_min_angle;
        float m_radius;
        float m_min_starting_velocity;
        float m_max_starting_velocity;

        float m_min_start_scale;
        float m_max_start_scale;
        float m_min_end_scale;
        float m_max_end_scale;
        float m_min_start_rotation;
        float m_max_start_rotation;
        float m_burst_time_pct;

        // added ----------------------------
        float* m_parent_rotation_ptr;
        float* m_parent_x_ptr;
        float* m_parent_y_ptr;
        // -----------------------------------

        Point m_position;

        Emitter(char* sprite_file, int max_particles, float min_angle, 
              float max_angle,
              Uint32 particle_lifetime, float acceleration, 
              bool alpha_fade,
              float min_starting_velocity, float max_starting_velocity,
              Uint32 emission_rate, int x_pos, int y_pos, float radius,
              float min_start_scale, float max_start_scale,
              float min_end_scale, float max_end_scale,
              Uint32 start_color, Uint32 end_color,
              float burst_time_pct, Uint32 burst_particles,
              bool loop, bool align_rotation,
              Uint32 emit_time_ms, Uint32 animation_frames );

        void Update(int max_particles, float min_angle, float max_angle,
             Uint32 particle_lifetime, float acceleration, bool alpha_fade,
             float min_starting_velocity, float max_starting_velocity,
             Uint32 emission_rate, int x_pos, int y_pos, float radius,
             float min_start_scale, float max_start_scale,
             float min_end_scale, float max_end_scale,
             Uint32 start_color, Uint32 end_color,
             float burst_time_pct, Uint32 burst_particles,
             bool loop, bool align_rotation, Uint32 emit_time_ms,
             Uint32 animation_frames );

        void Move();
        Particle* GetFreeParticle();

        void Run(); // added
 };

我们添加到粒子系统配置工具的代码被标记为“添加”的注释所包围。让我逐一介绍每个新属性和新函数的作用。以下是前两个添加的属性:

int m_x_adjustment = 0;
int m_y_adjustment = 0;

这两个值是调整值,用于修改发射器产生粒子的位置。这些变量对于相对于发射器跟随的对象位置的粒子位置进行小的调整非常有用。以下是我们添加的三个属性:

float* m_parent_rotation_ptr;
float* m_parent_x_ptr;
float* m_parent_y_ptr;

这些是父对象的 x、y 和旋转属性的指针。例如,如果我们设置Emitter->m_parent_rotation_ptr = &m_Rotation,那么该指针将指向我们父对象的旋转,我们将能够在我们的Emitter中访问该值以调整旋转。对于m_parent_x_ptrm_parent_y_ptr也是如此。

最后,我们添加了一个Run函数:

void Run();

这个函数允许一个不循环的粒子发射器重新启动。我们将在Ship类中使用这个函数来使用我们添加的Explosion发射器。

emitter.cpp 的更改

现在我们已经了解了我们需要对game.hpp进行的更改,我们将逐个函数地了解我们将对emitter.cpp文件进行的所有更改。

构造函数的更改

构造函数需要做两个更改。首先,我们将在顶部添加一些初始化,将所有新指针初始化为NULL。我们不需要在每个发射器中使用这些指针,所以我们可以检查NULL来查看它们何时被使用。在更下面,我们将修改传递给构造函数的值,从角度改为弧度。函数如下所示:

Emitter::Emitter(char* sprite_file, int max_particles, float min_angle, 
                float max_angle,
                Uint32 particle_lifetime, float acceleration, bool 
                alpha_fade,
                float min_starting_velocity, float max_starting_velocity,
                Uint32 emission_rate, int x_pos, int y_pos, float radius,
                float min_start_scale, float max_start_scale,
                float min_end_scale, float max_end_scale,
                Uint32 start_color, Uint32 end_color,
                float burst_time_pct, Uint32 burst_particles,
                bool loop, bool align_rotation, Uint32 emit_time_ms, Uint32 
                animation_frames ) {
    // added -----------------------------
    m_parent_rotation_ptr = NULL;
    m_parent_x_ptr = NULL;
    m_parent_y_ptr = NULL;
    // -----------------------------------
    m_start_color = start_color;
    m_end_color = end_color;
    m_active = true;

    if( min_starting_velocity > max_starting_velocity ) {
        m_min_starting_velocity = max_starting_velocity;
        m_max_starting_velocity = min_starting_velocity;
    }
    else {
        m_min_starting_velocity = min_starting_velocity;
        m_max_starting_velocity = max_starting_velocity;
    }
    SDL_Surface *temp_surface = IMG_Load( sprite_file );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        printf("failed sprite file: %s\n", sprite_file );
        return;
    }
    m_sprite_texture = SDL_CreateTextureFromSurface( renderer, temp_surface 
    );
    SDL_FreeSurface( temp_surface );
    SDL_QueryTexture( m_sprite_texture,
                        NULL, NULL,
                        &m_sprite_width, &m_sprite_height );
                        m_max_particles = max_particles;

    for( int i = 0; i < m_max_particles; i++ ) {
        m_particle_pool.push_back(
            new Particle( m_sprite_texture, particle_lifetime, 
            acceleration,
                            alpha_fade, m_sprite_width, m_sprite_height, 
                            align_rotation,
                            m_start_color, m_end_color, animation_frames )
            );
    }

    // modified -----------------------------
    m_min_angle = (min_angle+90) / 180 * 3.14159;
    m_max_angle = (max_angle+90) / 180 * 3.14159;
    // --------------------------------------

    m_radius = radius;
    m_position.x = (float)x_pos;
    m_position.y = (float)y_pos;
    m_emission_rate = emission_rate;
    m_emission_time_ms = 1000 / m_emission_rate;
    m_next_emission = 0;
    m_min_start_scale = min_start_scale;
    m_max_start_scale = max_start_scale;
    m_min_end_scale = min_end_scale;
    m_max_end_scale = max_end_scale;

    m_loop = loop;
    m_align_rotation = align_rotation;
    m_emit_loop_ms = emit_time_ms;
    m_ttl = m_emit_loop_ms;

    m_animation_frames = animation_frames;
    m_burst_time_pct = burst_time_pct;
    m_burst_particles = burst_particles;
    m_has_burst = false;
}

首次更改是在函数的顶部,将我们的新指针属性设置为NULL

m_parent_rotation_ptr = NULL;
m_parent_x_ptr = NULL;
m_parent_y_ptr = NULL;

稍后,我们将检查这些指针是否为NULL,如果不是,我们将使用m_parent_rotation_ptr来调整该发射器的旋转角度。我们将使用m_parent_x_ptr来改变发射器的 x 坐标,我们将使用m_parent_y_ptr来调整该发射器的 y 坐标。之后,我们有修改传入的最小和最大角度从角度到弧度的代码:

m_min_angle = (min_angle+90) / 180 * 3.14159;
m_max_angle = (max_angle+90) / 180 * 3.14159;

我们需要这样做的真正原因是,我们正在将传递给发射器的值硬编码。如果我们创建了一个数据加载器,我们可以在数据加载时进行转换。但是,因为我们直接从粒子发射器配置工具中取出这些值,并将这些值硬编码到对新发射器的调用中,我们要么必须记住每次更改这些值时自己进行转换,要么必须在构造函数和Update函数中进行转换。

更新函数的更改

Update函数不太可能在我们的游戏中被调用。Emscripten 的死代码删除过程应该会将其消除。但是,我们还没有从Emitter类中删除它。如果你认为可能会调用它,你可能想要将m_min_anglem_max_angle的初始化改为从角度转换为弧度,就像我们在构造函数中所做的那样。

m_min_angle = (min_angle+90) / 180 * 3.14159;
m_max_angle = (max_angle+90) / 180 * 3.14159;

添加 Run 函数

在粒子系统配置工具中,我们不需要Run函数,因为调用Update函数将运行EmitterUpdate函数在我们的游戏中使用起来太过繁琐。它使用了大量的配置变量,当我们调用函数时可能无法访问。我们只想设置发射器为活动状态,重置生存时间和爆发标志。我们创建了一个小的Run函数来做我们需要的事情:

void Emitter::Run() {
    m_active = true;
    m_ttl = m_emit_loop_ms;
    m_has_burst = false;
}

m_active设置为 true 会使发射器处于活动状态,这样在调用Move函数时就可以生成新的粒子。将m_ttl重置为m_emit_loop_ms可以确保生存时间不会在下一次调用Move函数时自动关闭发射器。将m_has_burst = false确保了,如果在发射中必须发生粒子爆发,它将运行。

Move 函数的更改

Move函数的新版本需要能够根据父位置修改其位置,并根据父旋转旋转其定义的位置。它还需要能够使用m_x_adjustmentm_y_adjustment进行微小调整。以下是Move的新版本:

void Emitter::Move() {
 Particle* particle;
 std::vector<Particle*>::iterator it;
    if( m_active == true ) {
        m_next_emission -= diff_time;
        m_ttl -= diff_time;
        if( m_ttl <= 0 ) {
            if( m_loop ) {
                m_ttl = m_emit_loop_ms;
                m_has_burst = false;
            }
            else { m_active = false; }
        }
        if( m_burst_particles > 0 && m_has_burst == false ) {
            if( (float)m_ttl / (float)m_emit_loop_ms <= 1.0 - 
                m_burst_time_pct ) {
                m_has_burst = true;
                m_next_emission -= m_burst_particles * m_emission_time_ms;
            }
        }
        while( m_next_emission <= 0 ) {
            m_next_emission += m_emission_time_ms;
            particle = GetFreeParticle();
            if( particle != NULL ) {
                Point spawn_point, velocity_point, rotated_position;
                spawn_point.x = get_random_float( 0.0, m_radius );
                velocity_point.x = 
                get_random_float(m_min_starting_velocity, 
                m_max_starting_velocity);
                float angle = get_random_float( m_min_angle,m_max_angle );
                float start_scale = get_random_float(m_min_start_scale, 
                m_max_start_scale);
                float end_scale = get_random_float( m_min_end_scale,
                m_max_end_scale );
                if( m_parent_rotation_ptr != NULL ) {
                    angle += *m_parent_rotation_ptr;
                    rotated_position = m_position;
                    rotated_position.Rotate( *m_parent_rotation_ptr );
                }
                velocity_point.Rotate(angle);
                spawn_point.Rotate( angle );

                if( m_parent_rotation_ptr == NULL ) {
                    spawn_point.x += m_position.x;
                    spawn_point.y += m_position.y;
                    if( m_parent_x_ptr != NULL ) { spawn_point.x += 
                    *m_parent_x_ptr; }
                    if( m_parent_y_ptr != NULL ) { spawn_point.y += 
                    *m_parent_y_ptr; }
                }
                else {
                    spawn_point.x += rotated_position.x;
                    spawn_point.y += rotated_position.y;
                    if( m_parent_x_ptr != NULL ) { spawn_point.x += 
                    *m_parent_x_ptr; }
                    if( m_parent_y_ptr != NULL ) { spawn_point.y += 
                    *m_parent_y_ptr; }
                }
                spawn_point.x += m_x_adjustment;
                spawn_point.y += m_y_adjustment;
                particle->Spawn(spawn_point.x, 
                spawn_point.y,velocity_point.x, velocity_point.y,
                    start_scale, end_scale, (int)(angle / 3.14159 * 180.0 + 
                    360.0) % 360 );
            }
            else {
                m_next_emission = m_emission_time_ms;
            }
        }
    }
    for( it = m_particle_pool.begin(); it != m_particle_pool.end(); it++ ) 
    {
        particle = *it;
        if( particle->m_active ) {
            particle->Move();
            particle->Render();
        }
    }
}

大部分代码与早期版本相同。让我们来看看其中的不同之处。首先,如果有旋转的父对象,我们需要旋转整个粒子系统。我们将用于将排气粒子系统添加到飞船对象的这一功能。这个排气必须相对于飞船进行定位。为此,我们需要获取位置并进行旋转。我们还需要将父对象的旋转添加到现有的发射角度。以下是新代码:

Point rotated_position;

if( m_parent_rotation_ptr != NULL ) {
    angle += *m_parent_rotation_ptr;
    rotated_position = m_position;
    rotated_position.Rotate( *m_parent_rotation_ptr );
}

在顶部,我们创建了一个名为rotated_position的新Point对象。如果m_parent_rotation_ptr不为NULL,我们将该值添加到我们之前计算的发射角度上。我们将m_position的值复制到rotated_position中,并通过父对象的旋转对该位置进行旋转。稍后,我们将检查m_parent_rotation_ptr是否不为NULL,如果不是,我们将使用rotated_position相对于父对象的位置来计算发射器的位置。以下是一个检查m_parent_rotation_ptr == NULLif语句。如果它为空,该if块的第一部分将执行之前的操作。以下是代码:

if( m_parent_rotation_ptr == NULL ) {
    spawn_point.x += m_position.x;
    spawn_point.y += m_position.y;
}

因为if语句是在检查m_parent_rotation_ptr == NULL,我们不想使用粒子系统位置的旋转版本。该块默认使用未修改的m_position属性。如果m_parent_rotation_ptr不为NULL,我们将运行以下else块:

else {
    spawn_point.x += rotated_position.x;
    spawn_point.y += rotated_position.y;
}

这段代码使用了m_position的旋转版本。接下来,我们要查看m_parent_x_ptrm_parent_y_ptr是否不为NULL。如果不是,spawn_point将使用这些值添加父对象的位置。以下是这段代码:

if( m_parent_x_ptr != NULL ) {
    spawn_point.x += *m_parent_x_ptr;
}
if( m_parent_y_ptr != NULL ) {
    spawn_point.y += *m_parent_y_ptr;
}

我们将在Move函数中添加的最后一段代码是对生成点进行微调。有时,粒子系统在旋转之前需要进行一些微调,以使它们看起来更加完美。因此,我们添加以下内容:

spawn_point.x += m_x_adjustment;
spawn_point.y += m_y_adjustment;

m_x_adjustmentm_y_adjustment的值默认为0,因此如果要使用这些值,它们需要在创建发射器后的某个时候进行设置。

对 ship.cpp 的更改

接下来,我们要修改ship.cpp文件,以使用两个新的粒子发射器。我们需要一个用于飞船排气的粒子发射器,以及一个用于改善飞船爆炸效果的粒子发射器。我们需要对Ship类的构造函数、加速函数和渲染函数进行更改。

Ship 类的构造函数

Ship类的构造函数已更改了Ship类内的大部分函数。我们不仅初始化了新属性,还需要在发射器上设置父对象和调整值。以下是构造函数的新代码:

Ship::Ship() : Collider(8.0) {
    m_Rotation = PI;
    m_DX = 0.0;
    m_DY = 1.0;
    m_VX = 0.0;
    m_VY = 0.0;
    m_LastLaunchTime = current_time;
    m_Accelerating = false;
    m_Exhaust = new Emitter((char*)"/sprites/ProjectileExpOrange.png", 200,
                            -10, 10,
                            400, 1.0, true,
                            0.1, 0.1,
                            30, 0, 12, 0.5,
                            0.5, 1.0,
                            0.5, 1.0,
                            0xffffff, 0xffffff,
                            0.7, 10,
                            true, true,
                            1000, 6 );

    m_Exhaust->m_parent_rotation_ptr = &m_Rotation;
    m_Exhaust->m_parent_x_ptr = &m_X;
    m_Exhaust->m_parent_y_ptr = &m_Y;
    m_Exhaust->m_x_adjustment = 10;
    m_Exhaust->m_y_adjustment = 10;
    m_Exhaust->m_active = false;
    m_Explode = new Emitter((char*)"/sprites/Explode.png", 100,
                             0, 360,
                             1000, 0.3, false,
                             20.0, 40.0,
                             10, 0, 0, 5,
                             1.0, 2.0,
                             1.0, 2.0,
                             0xffffff, 0xffffff,
                             0.0, 10,
                             false, false,
                             800, 8 );
    m_Explode->m_parent_rotation_ptr = &m_Rotation;
    m_Explode->m_parent_x_ptr = &m_X;
    m_Explode->m_parent_y_ptr = &m_Y;
    m_Explode->m_active = false;
}

前几行与旧版本相同。新更改从将m_Accelerating初始化为false开始。之后,我们设置排气发射器,首先创建一个新的发射器,然后设置父对象值和调整值,最后将其设置为非活动状态:

m_Exhaust = new Emitter((char*)"/sprites/ProjectileExpOrange.png", 200,
                        -10, 10,
                        400, 1.0, true,
                        0.1, 0.1,
                        30, 0, 12, 0.5,
                        0.5, 1.0,
                        0.5, 1.0,
                        0xffffff, 0xffffff,
                        0.7, 10,
                        true, true,
                        1000, 6 );

 m_Exhaust->m_parent_rotation_ptr = &m_Rotation;
 m_Exhaust->m_parent_x_ptr = &m_X;
 m_Exhaust->m_parent_y_ptr = &m_Y;
 m_Exhaust->m_x_adjustment = 10;
 m_Exhaust->m_y_adjustment = 10;
 m_Exhaust->m_active = false;

所有传入Emitter函数的值都直接来自粒子系统配置工具。我们需要手动将它们添加到我们的函数调用中。如果我们在一个大型项目上工作,这种方法就不太可扩展。我们可能会让配置工具创建某种数据文件(例如 JSON 或 XML)。但出于迅速的目的,我们只是根据配置工具中的内容硬编码了这些值。不幸的是,这些值的顺序与工具中的顺序不同。您需要查看Emitter构造函数的签名,以确保将值放在正确的位置:

Emitter(char* sprite_file, int max_particles, float min_angle, float max_angle,
        Uint32 particle_lifetime, float acceleration, bool alpha_fade,
        float min_starting_velocity, float max_starting_velocity,
        Uint32 emission_rate, int x_pos, int y_pos, float radius,
        float min_start_scale, float max_start_scale,
        float min_end_scale, float max_end_scale,
        Uint32 start_color, Uint32 end_color,
        float burst_time_pct, Uint32 burst_particles,
        bool loop, bool align_rotation, Uint32 emit_time_ms, Uint32 
        animation_frames );

第一个参数sprite_file是虚拟文件系统中文件的位置。该文件不会自动包含在项目中。您需要确保它在正确的位置。我们将文件放在sprites目录中,并在运行 Emscripten 时使用以下标志:

 --preload-file sprites

创建Exhaust发射器后,我们使用以下代码创建Explosion发射器:

m_Explode = new Emitter((char*)"/sprites/Explode.png", 100,
                         0, 360,
                         1000, 0.3, false,
                         20.0, 40.0,
                         10, 0, 0, 5,
                         1.0, 2.0,
                         1.0, 2.0,
                         0xffffff, 0xffffff,
                         0.0, 10,
                         false, false,
                         800, 8 );

m_Explode->m_parent_rotation_ptr = &m_Rotation;
m_Explode->m_parent_x_ptr = &m_X;
m_Explode->m_parent_y_ptr = &m_Y;
m_Explode->m_active = false;

创建m_Explode发射器类似于m_Exhaust发射器,但我们根据在粒子发射器配置工具中创建的内容传递不同的值给发射器:

图 9.5:爆炸配置

m_Exhaust发射器一样,我们需要设置所有父指针变量并停用发射器。不像m_Exhaust,我们不需要使用m_x_adjustmentm_y_adjustment属性进行微调。

飞船类的加速函数

我们只想在飞船加速时运行排气发射器。为此,我们需要在飞船的Accelerate函数内设置一个标志。以下是加速函数的新版本:

void Ship::Accelerate() {
    m_Accelerating = true; // added line
    m_VX += m_DX * delta_time;
    m_VY += m_DY * delta_time;
}

唯一的改变是在开头添加了一行,将m_Accelerating设置为true。当我们渲染飞船时,我们可以检查这个标志,并根据其中的值启动或停止发射器。

飞船类的渲染函数

Ship类的最终更改在飞船的Render函数中。在这个函数内部,我们需要添加移动和渲染两个新的粒子系统的代码,以及在飞船加速时打开排气的代码,以及在飞船不加速时关闭排气的代码。以下是函数的新版本:

void Ship::Render() {
    if( m_Alive == false ) {
        return;
    }
    m_Exhaust->Move();
    m_Explode->Move();
    dest.x = (int)m_X;
    dest.y = (int)m_Y;
    dest.w = c_Width;
    dest.h = c_Height;
    src.x = 32 * m_CurrentFrame;
    float degrees = (m_Rotation / PI) * 180.0;
    int return_code = SDL_RenderCopyEx( renderer, m_SpriteTexture,
                                         &src, &dest,
                                         degrees, NULL, SDL_FLIP_NONE );
    if( return_code != 0 ) {
        printf("failed to render image: %s\n", IMG_GetError() );
    }

    if( m_Accelerating == false ) {
        m_Exhaust->m_active = false;
    }
    else {
        m_Exhaust->m_active = true;
    }
    m_Accelerating = false;
}

看一下添加的第一个代码块,靠近顶部:

m_Exhaust->Move();
m_Explode->Move();

在发射器上调用Move函数会移动和渲染粒子系统中的所有粒子。如果是发射器执行生成新粒子的时间,它也会生成新粒子。在函数的最后,有处理排气发射器的代码:

if( m_Accelerating == false ) {
    m_Exhaust->m_active = false;
}
else {
    m_Exhaust->m_active = true;
}
m_Accelerating = false;

这段代码检查m_Accelerating标志是否为false。如果是,我们就停用排气发射器。如果飞船正在加速,我们将m_active标志设置为true。我们不调用Run函数,因为我们每帧都在做这个操作,我们不希望每次循环都重新开始存活时间。最后一行将m_Accelerating设置为false。我们这样做是因为我们的代码中没有检测飞船停止加速的地方。如果飞船正在加速,该标志将在代码的这一点之前被设置为true。如果没有,它将保持为false

对 projectile_pool.cpp 的更改

我们不需要在ProjectilePool类内部做太多改动。实际上,我们只需要对一个函数进行两次更改。ProjectilePool类内的MoveProjectiles函数执行所有的项目和我们的两艘飞船之间的碰撞检测。如果一艘飞船被摧毁,我们就会在那艘飞船上运行m_Explode粒子发射器。这将需要在每艘飞船的碰撞检测条件内添加两行新代码。以下是MoveProjectiles函数的新版本:

void ProjectilePool::MoveProjectiles() {
    Projectile* projectile;
    std::vector<Projectile*>::iterator it;
    for( it = m_ProjectileList.begin(); it != m_ProjectileList.end(); it++ ) {
        projectile = *it;
        if( projectile->m_Active ) {
            projectile->Move();
            if( projectile->m_CurrentFrame == 0 &&
                player->m_CurrentFrame == 0 &&
                ( projectile->HitTest( player ) ||
                    player->CompoundHitTest( projectile ) ) ) {
                player->m_CurrentFrame = 1;
                player->m_NextFrameTime = ms_per_frame;
                player->m_Explode->Run(); // added
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
            if( projectile->m_CurrentFrame == 0 &&
                enemy->m_CurrentFrame == 0 &&
                ( projectile->HitTest( enemy ) ||
                    enemy->CompoundHitTest( projectile ) ) ) {
                enemy->m_CurrentFrame = 1;
                enemy->m_NextFrameTime = ms_per_frame;
                enemy->m_Explode->Run(); // added
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
        }
    }
}

我添加的两行代码是对player->m_Explode->Run();enemy->m_Explode->Run();的调用。这些行在玩家飞船或敌方飞船与其中一个抛射物发生碰撞并被摧毁时执行。

对 main.cpp 的更改

我们需要做的最后一个改变是在main.cpp文件中添加排气和爆炸粒子系统。这个改变需要添加一个函数get_random_float。我们之前讨论过这个函数。这是我们的粒子发射器获取随机浮点值的方法,这些值介于最小值和最大值之间。以下是代码:

float get_random_float( float min, float max ) {
    int int_min = (int)(min * 1000);
    int int_max = (int)(max * 1000);
    if( int_min > int_max ) {
        int temp = int_max;
        int_max = int_min;
        int_min = temp;
    }
    int int_diff = int_max - int_min;
    int int_rand = (int_diff == 0) ? 0 : rand() % int_diff;
    int_rand += int_min;
    return (float)int_rand / 1000.0;
}

编译新的 particle_system.html 文件

现在我们已经对文件进行了所有必要的更改,我们可以继续使用 Emscripten 来编译和测试游戏的新版本。

如果您是从 GitHub 项目构建此项目,您需要从/Chapter09/目录运行此编译命令。之前的编译是在/Chapter09/advanced-particle-tool/目录中完成的,因此请确保在运行此命令时您在正确的位置;否则,它将没有构建游戏所需的文件。

从命令行执行以下命令:

em++ collider.cpp emitter.cpp enemy_ship.cpp particle.cpp player_ship.cpp point.cpp projectile_pool.cpp projectile.cpp ship.cpp main.cpp -o particle_system.html --preload-file sprites -std=c++17 -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"]

进一步进行

我们将不会编写一个用于配置的数据导出工具。这一章已经太长了。当您创建粒子系统时,您可以花费几乎无限的时间来微调它们以满足您的喜好。粒子系统可以有大量的配置参数。您甚至可以使用贝塞尔曲线进行移动、旋转和缩放。一些高级粒子系统可以发射其他粒子。我们可以为粒子系统添加的复杂性没有限制,但是我在这本书中可以添加的页面数量是有限的,因此我鼓励您继续完善这个系统,直到您获得想要的结果。

总结

恭喜!您已经通过了一个非常漫长、信息丰富的章节。在过去的两章中,我们讨论了粒子系统是什么以及为什么要使用它们。我们学会了如何向 WebAssembly 虚拟文件系统添加文件以及如何访问它。我们学会了如何在 HTML 外壳文件和 WebAssembly 模块之间创建更高级的交互。然后,我们使用更多功能构建了一个更高级的粒子发射器配置工具。在工具中构建了一些漂亮的粒子系统后,我们将数据和代码用于构建了我们一直在构建的游戏中的两个新粒子发射器。

在下一章中,我们将讨论并构建敌方飞船的人工智能。

第十章:AI 和转向行为

我们一直在编写的游戏 loosly 基于计算机游戏Spacewar! 如果你不熟悉Spacewar!,它是有史以来第一款计算机游戏。它最初在麻省理工学院拥有的 PDP-1 上运行,并由麻省理工学院的一名名叫史蒂夫·拉塞尔的学生于 1962 年编写。那时,让计算机显示图形输出已经足够困难了。Spacewar!以及许多其他早期游戏系统,如Pong,都是设计供多人玩的。那是因为编程让计算机像人一样行为是非常困难的。尽管现代人工智能AI)算法比过去更智能,但这在某种程度上仍然是真的。

因为我们的游戏是单人玩家网络游戏,我们无法利用第二个人类智能来驱动我们的敌人飞船。在本章之前,我们使用了一个 AI 存根,允许我们的敌人飞船在游戏区域内随机移动和射击。这在这一点上可能对我们有用,但现在我们希望我们的玩家感到受到敌人飞船的威胁。它应该足够聪明,可以在一对一的战斗中与我们的玩家战斗和杀死他。

您需要在构建中包含几个图像才能使此项目工作。确保您从项目的 GitHub 中包含/Chapter10/sprites/文件夹。如果您还没有下载 GitHub 项目,您可以在网上获取它:github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly

在本章中,我们将做以下事情:

  • 引入 AI 和游戏 AI 的概念

  • 为了避开 AI(并增加画布大小),在游戏中添加障碍物

  • 为视线引入新的碰撞检测

  • 引入有限状态机FSM)的概念

  • 引入自主代理的概念

  • 引入转向行为的概念

  • 将力场添加到我们的游戏中

  • 使用 FSM 和转向行为创建 AI

  • 调整我们的 AI 以允许敌人飞船避开障碍物

什么是游戏 AI?

许多早期的视频游戏避免了 AI,因为当时的硬件条件下这是一个非常具有挑战性的问题。例如,Space InvadersGalagaGalaxian都有以特定非智能模式移动的外星人。早期的 Atari 游戏要么是双人游戏(Pong),要么是玩家与非智能环境互动(Breakout)。早期成功尝试具有 AI 的游戏之一是PAC-MANPAC-MAN中的每个幽灵都有不同的个性,并且在相同的情况下会有不同的行为。PAC-MAN还使用了简单的有限状态机FSM)。这是一种 AI 类型,在不同的环境情况下 AI 会有不同的行为。如果玩家在PAC-MAN中吃了一个能量豆,幽灵们会变成蓝色,并突然变得可以被吃掉,这是一种猎人成为猎物的命运逆转。虽然幽灵可以被吃掉,但对程序员来说让这些幽灵继续追捕PAC-MAN可能更容易。这会让幽灵看起来要么愚蠢要么自杀,这是我们在编写 AI 时想要避免的行为。

在 1950 年,数学和计算机天才艾伦·图灵提出了一个他称之为“模仿游戏”的 AI 基准,但后来它被称为图灵测试。他提出了一个游戏,让人类玩家通过基于文本的界面与人类和计算机互动。如果计算机能够说服人类他们正在与另一个人而不是计算机互动,那么该计算机应被认为是智能的。就我个人而言,我觉得我们很久以前就通过了这个门槛。但是当机器威胁到人类智能时,人类喜欢改变目标。

1964 年,麻省理工学院的约瑟夫·魏岑鲍姆编写了一个名为 ELIZA 的聊天机器人。ELIZA 假装是聊天系统另一端的心理医生。ELIZA 成功地愚弄了不少人,让他们相信她是一个真正的心理医生,这可能既是对心理治疗的评论,也是对人类智慧的评论。任何寻找聊天机器人的人都很容易就能发现 ELIZA 不是人类,但约瑟夫·魏岑鲍姆对愿意向 ELIZA 倾诉心事的人数感到非常不安。

Loebner 奖是一项年度图灵测试竞赛,一系列的 AI 专家评委至今还没有被聊天机器人愚弄。如今,许多程序经常愚弄人们,让他们认为自己是人类。我认为,需要一个人类专家来确定一个 AI 是否通过了图灵测试,这已经明显地改变了艾伦·图灵最初设定的目标。我相信,如果我们有一个被聊天机器人愚弄的非专家大样本,我们应该认为那个聊天机器人是智能的,但我岔开了话题。

提到图灵测试的目的是,游戏人工智能需要通过修改后的图灵测试。当你编写游戏人工智能时,你的目标是说服玩家,让他们相信他们不是在与一个彻底失败的游戏对手对战。所有的游戏人工智能都或多或少地很差劲。目前,我们将无法创建 IBM 的沃森(在《危险边缘》中击败肯·詹宁斯的人工智能)的游戏人工智能版本。就像电脑游戏中的一切,我们需要学会在系统的限制内工作。对于基于网络的游戏来说,这些限制可能是相当重要的。

记住,作弊是可以的,但不要被抓住。许多游戏人工智能都作弊。实时战略游戏可能能够看穿战争迷雾。AI 扑克玩家可能能够偷看玩家的牌。我们要作弊的一种方式是允许我们的敌方太空飞船在玩家不允许的方向加速。作弊的关键是确保行为或移动看起来不是不自然的。许多年前,我写了一个在线版本的纸牌游戏 Spades,可以在www.icardgames.com/spades.html上玩。玩家的伙伴人工智能被允许偷看每个人的牌。我经常收到的一个常见抱怨是,玩家的伙伴经常会打出玩家的高牌。这是因为 AI 不是看谁当前赢得了这一轮,而是看他后面的玩家是否能赢得这一轮,如果他不打出比玩家出的更高的牌。由于玩家没有意识到这种行为对他们有帮助,我经常收到玩家对伙伴打出高牌的沮丧抱怨。这是一个例子,玩家实际上因为 AI 的行为而表现更好,但却留下了 AI 在做愚蠢选择的印象。我的观点是,游戏人工智能都是关于印象的。记住 HBO 电视剧《西部世界》中 AI 主持人说的话,当其中一个角色问她是否真实时:“如果你分辨不出来,那真的重要吗?”

自主代理与自上而下的人工智能

1986 年,Craig Reynolds 创建了一个备受推崇的 AI 程序,名为“Boids”(鸟和机器人的组合)。这个程序创建了一种迷人的鸟群行为,小三角形在屏幕上移动,让观察者想起鸟群或鱼群的行为。当环境中有障碍时,boids 会分开以绕过障碍物,然后重新聚集。两个群体之间的碰撞通常会导致它们合并并继续前进。Boids 算法是 AI 的自主代理的实现。每个个体 boid 都根据一些简单的规则和其周围的环境做出决策。这导致了所谓的“新兴行为”,这种行为看起来好像是自上而下设计的,但实际上不是。讽刺的是,一个自上而下实施的 AI 经常看起来比让个体代理根据自己的环境做出决策要不那么智能。这有点像旧的苏联自上而下的指挥和控制经济,与个体根据其周围环境做出决策的资本主义经济相比。在游戏中,就像在经济学中一样,你也可以有一个混合系统,其中自上而下的 AI 可以向自主代理发送消息,给它们新的目标或指令。在我们编写的游戏中,我们有一个单独的敌人飞船,所以从自上而下管理 AI 还是通过自主代理管理并不重要,但因为你可能选择在将来扩展游戏以支持多个敌人及其 AI,我们的代理将自主管理自己。

什么是有限状态机?

有限状态机在游戏中非常常见。正如我之前提到的,PAC-MAN 是一个早期的游戏,其中的 AI 具有多个状态。鬼魂可以处于“追逐”或“逃跑”状态,这取决于 PAC-MAN 在屏幕上吃掉一个大点时全局条件的改变,这通常被称为“能量豆”。有限状态机中的特定状态可以是全局条件,或者在有限状态自动机的情况下,可以是游戏中的任何“自主代理”特定的状态。管理行为或状态转换可以简单地使用开关语句,也可以是更复杂的系统,当不同状态被触发时加载和卸载 AI 模块。一个状态可能会选择何时发生到不同状态的转换,或者状态转换可以由游戏自上而下地管理。

我们将为这个游戏编写的有限状态机非常基础。它将是一个简单的开关,根据当前状态执行不同的行为。敌船相对于玩家的位置以及它们之间是否有无障碍的视线将用于确定状态之间的转换。我们的有限状态机将有四种基本状态:

  1. 漫游

  2. 接近

  3. 攻击

  4. 逃跑

进入这些状态的条件如下:如果敌船没有到玩家船的无障碍路径,它将进入“漫游”状态,在游戏区域四处漫游,定期检查到玩家的视线路径。一旦有了到玩家的视线路径,敌船将进入“接近”状态,试图靠近玩家船以进行攻击。一旦玩家靠近,它进入“攻击”状态,向玩家船开火。如果玩家船离敌船太近,敌人将“逃跑”,试图增加与玩家船之间的距离。

引入转向行为

转向行为是一种基于力的导航方法,用于朝向或远离特定点,同时避开障碍物。它最初是在 1999 年的游戏开发者大会GDC)上由 Craig Reynolds(Boids的创造者)在一次演讲中讨论的,关于转向行为的原始论文可以在网上找到www.red3d.com/cwr/steer/gdc99/。与 A*或 Dijkstra 算法等寻路算法不同,转向行为是战术性的。它涉及一个目标位置和力,将自主代理引向其目标,同时将代理推开您希望它避开的障碍物。在我们的游戏中,敌人的飞船是我们将使用转向行为的自主代理。它将追逐玩家的飞船,同时避开包括小行星、抛射物和游戏区域中心的星星在内的障碍物。在接下来的几节中,我们将详细讨论几种转向行为。

寻找行为

寻找转向行为是一个力,指向期望的目标,并使代理朝着该目标的方向移动。这种行为试图以最大速度到达目标,并在最短时间内到达目标。寻找行为假设它正在寻找的位置是静态的,并且随时间不会改变。这张图表展示了寻找行为的外观:

寻找行为

逃避行为

逃避是一种与寻找行为相反的转向行为。这种行为会接受一个位置或游戏对象,并试图尽可能远离它。

逃避是被熊追赶时所表现的行为。你的唯一目标是尽可能远离你和熊当前位置之间的距离。所以下次被熊追赶时,停下来想一想,“哇,我的大脑目前正在实施一种名为逃避的自主代理转向行为。”或者你可以继续奔跑。选择权在你手中。看一下下一个图表:

一位艺术家描绘了一只熊正在吃读者

你可以通过否定寻找行为的方向来编程逃避行为。换句话说,如果寻找行为产生一个方向向量力为 1,1,逃避转向行为将产生一个方向向量力为-1,-1。这张图表描述了逃避行为:

逃避行为

到达行为

寻找转向行为的问题在于,直到代理达到目标位置,它都不会满意。另一个问题是,因为它试图以最大速度到达该位置,它几乎总是会超过目标位置,导致在期望目的地周围振荡。到达转向行为允许寻找行为在到达目标的到达范围时开始减速,从而优雅地结束。只要目标位置在期望范围内,到达行为就会减少朝向寻找位置的移动。以下图表描述了到达行为:

到达行为

追逐行为

我们在寻找行为的基础上构建了追逐行为。寻找行为试图到达一个静态点,而追逐行为假设目标在移动。因为我们的代理(敌船)希望追踪并摧毁通常在移动的玩家,我们将使用追逐转向行为。追逐行为查看目标的速度。它不是直接朝着目标的当前位置前进,而是试图找到一个拦截点,预测目标将在那里。寻找行为让我想起了一个儿童足球队。所有孩子都跑到球所在的地方,而不是球将要到达的地方。因此,足球场上的每个人都像一个大团体一样上下奔跑。总有一天,他们会长大,并将追逐转向行为纳入他们的足球战略中。

下一个图表描述了追逐行为:

追逐行为

逃避行为

逃避就像追逐一样,逃避转向行为试图确定你要避免的障碍物将在哪里,并尽可能远离那个点。换句话说,它采用了我们在追逐行为中找到的相同点,然后远离那个点。下一个图表描述了逃避行为:

逃避行为

避障

避障与逃避行为和逃避行为不同,因为障碍物可能潜在地阻碍我们的代理在寻找新位置时。逃避和逃避会导致我们试图尽可能远离对象的位置或我们正在逃离的位置,而避障更多地是关于避免与前往目标途中的障碍物碰撞。在我们的游戏中,需要避免的障碍物包括小行星、抛射物和游戏屏幕中心的星星。避障通常只涉及寻找避免最具威胁(最近)的障碍物。我们的代理有一个给定的前瞻距离,它朝着移动的方向查看。如果当前位置和移动方向上的最大前瞻之间的线与对象发生碰撞,避障要求我们调整我们的方向。我们避免的区域应该比障碍物的碰撞检测区域大,以给我们一个避免的缓冲区,特别是因为小行星和抛射物在游戏中是移动的。

下一个图表描述了避障:

避障

漫游行为

漫游是一种状态,在这种状态下,代理在游戏屏幕周围有些随机移动。导致敌方飞船的方向每一帧都随机旋转会导致非常不稳定的行为。相反,应该有一个随机的毫秒数(200-2,000),在这段时间内飞船保持当前方向。当飞船经过随机的毫秒数后,它应该随机选择向左转或向右转,但应该有一个偏向于选择上次相同方向的概率,每次在初始选择后选择相同方向的偏向概率会减少。这将使漫游行为具有更多的一致性,看起来不那么抖动。

看看漫游行为如何随机选择一个点并朝着它移动:

演示漫游行为

合并力量

我们之前讨论的读者使用逃避行为逃离熊的情况过于简化了。它假设你在一个大片开阔的地方逃离那只熊。如果你在树林里逃离熊,你需要避免撞到树,并尽可能远离那只熊。你必须无缝地融合这两种活动,否则会被那只熊吃掉。如果我们希望敌舰追逐或逃离玩家舰船,并同时避开障碍物,我们将需要结合转向力。最高优先级始终是避开障碍物。如果你在逃离那只熊时撞到了树,他最终还是会吃掉你。我们的转向行为将实现的一般策略是找到与玩家舰船的视线向量。由于我们的游戏级别在自身上环绕,我们有几次机会找到视线。如果那条视线比选择的距离长,我们将徘徊,直到我们的距离足够短,可以追逐玩家并向他射击。在我们徘徊时,我们将希望将任何徘徊力与帮助敌舰避免撞到小行星或恒星的力结合起来。一旦我们开始追逐,我们将希望继续避开障碍物。将有一个大的到达区域,我们的舰船将减速并朝着玩家方向开火。一旦玩家接近特定范围,我们的舰船将逃离。

修改 game.hpp

在我们深入新的代码之前,我想快速对game.hpp文件进行一些更改,以添加我们将在本章后面使用的一些功能。我想在game.hpp文件的顶部附近添加一些宏,让我们能够快速将角度从度转换为弧度,以及从弧度转换为度。我在使用 SDL 时经常这样做,因为 SDL 出于某种原因希望以度为单位旋转,而其他所有库都使用弧度。因此,让我们在game.hpp文件的顶部附近的某个地方添加以下两行代码:

#define DEG_TO_RAD(deg) ((float)deg/180.0)*3.14159
#define RAD_TO_DEG(rad) ((float)rad*180.0)/3.14159

我们将把画布的大小从 320 x 200 改为 800 x 600。为了以后更容易切换,让我们先定义一些宏,用于画布的宽度和高度,并将其放在game.hpp文件的顶部附近的某个地方:

#define CANVAS_WIDTH 800
#define CANVAS_HEIGHT 600

rand()函数,用于在 C 和 C++中获取随机数,只能用于返回整数。我将添加一个函数来获取落在最小和最大浮点值之间的随机数,因此我需要在我们的game.hpp文件中添加对该函数的外部引用:

extern float get_random_float( float min, float max );

我们的类也开始需要循环引用。FiniteStateMachine类将需要引用EnemyShip类,而EnemyShip类将需要引用FiniteStateMachine类。不幸的是,我们需要先定义其中一个类。过去,我们可以按特定顺序定义类以避免这个问题,但现在我们需要在任何类定义之前添加一组类声明。这将允许编译器知道类将在定义之前被定义。在game.hpp文件的顶部附近添加这个类声明块:

class Ship;
class Particle;
class Emitter;
class Collider;
class Asteroid;
class Star;
class PlayerShip;
class EnemyShip;
class Projectile;
class ProjectilePool;
class FiniteStateMachine;

我们将添加一个枚举来跟踪我们的 FSM 状态。正如我之前提到的,我们的 FSM 有四个状态:APPROACHATTACKFLEEWANDER。我们将在一个名为FSM_STATE的枚举中定义这些状态:

enum FSM_STATE {
    APPROACH = 0,
    ATTACK = 1,
    FLEE = 2,
    WANDER = 3
};

我们在game.hpp中定义的第一个类之一是Point类。这个类有xy属性,以及一些有用的函数,比如Rotate。我们需要大大扩展这个类的用途和功能。因此,称其为point已不再准确。我更愿意将这个类称为vector,因为我们将从现在开始使用它进行向量数学运算。我对这个名称唯一的问题是,它可能会让人困惑,因为我们在代码中使用std::vector来处理类似数组的数据。因此,我决定我们将把这个类称为Vector2D。我们将大大扩展这个类的功能,包括一个将向量归一化(即将其大小改为 1)的函数。我们需要两个函数来确定向量的大小和平方大小。我们需要一个函数来将向量投影到另一个向量上(以帮助我们进行视线碰撞检测)。我们需要能够找到两个向量的点积。我们还需要能够找到给定向量的旋转。除了这些新函数,我们还将重载我们的向量运算符,以便我们可以相加向量、相减向量,并用标量值相乘和相除向量。

继续删除Point类定义,并用新的Vector2D类定义替换该代码:

class Vector2D {
    public:
        float x;
        float y;

        Vector2D();
        Vector2D( float X, float Y );

        void Rotate( float radians );
        void Normalize();
        float MagSQ();
        float Magnitude();
        Vector2D Project( Vector2D &onto );
        float Dot(Vector2D &vec);
        float FindRotation();

        Vector2D operator=(const Vector2D &vec);
        Vector2D operator*(const float &scalar);
        void operator+=(const Vector2D &vec);
        void operator-=(const Vector2D &vec);
        void operator*=(const float &scalar);
        void operator/=(const float &scalar);
 };

我们的新碰撞检测还需要一个Range类。范围表示最小值和最大值之间的一系列值。我们可以将两个范围相加。我们可以找到两个范围之间的重叠部分。我们可以通过给定的标量值扩展一个范围,或者将一个值夹在给定范围内。以下是新的Range类定义的样子:

class Range {
    public:
        float min;
        float max;

        Range();
        Range( float min_val, float max_val );

        void operator+=(const Range& range);
        Range operator+(const Range& range);
        Range operator=(const Range& range);

        bool Overlap( Range &other );
        void Sort();
        void Extend( float ex );
        float Clamp( float value );
 };

如果您向下滚动到Collider类,我们将添加一些新函数和一些新属性。我想使用我们的Collider类来支持新的转向行为。因此,我们需要一些特定于转向的属性:

float m_SteeringRadius;
float m_SteeringRadiusSQ;

m_SteeringRadius是一个新属性,是m_Radius的倍数。为了转向目的,我们希望确保我们想要避免的对象的大小小于对象的碰撞区域。这为我们的转向行为创建了额外的边距,这将帮助我们避开这些对象。m_SteeringRadiusSQ属性是转向半径的平方。这将使我们不必一遍又一遍地对转向半径进行碰撞检查。

我们还需要添加以下函数的声明:

bool SteeringLineTest( Vector2D &p1, Vector2D &p2 );
bool SteeringRectTest( Vector2D &start_point, Vector2D &end_point );
void WrapPosition();

SteeringLineTestSteeringRecTest函数将与真实的线和矩形碰撞测试不同。转向矩形测试(SterringRectTest)将用于限制我们必须测试以进行对象避让的对象数量。我们只希望我们的 AI 担心在敌舰周围的一个 200 x 200 像素的框内的对象。如果我们有大量对象要测试,这将非常有用。为了使这个测试快速,我们将检查该框内的对象,就好像它们是点,并且不考虑对象的半径。SteeringLineTest函数将测试这个碰撞体的转向半径是否击中了测试中由两个点定义的线。

在我们的游戏中,我们还没有添加命中点系统。与小行星或抛射物的单次碰撞会导致即时死亡。这使得游戏变得非常短暂。为了增加游戏时间,我们将为我们的飞船添加护盾。这些护盾将使玩家或敌人在护盾激活期间无敌。在使用护盾时,它们将从绿色慢慢变成红色,并且在某个时刻它们将停止工作。这将取决于您在给定游戏中使用护盾的时间长度,以鼓励玩家仅在需要时使用护盾。以下是Shield类定义的样子:

class Shield : public Collider {
    public:
        bool m_Active;
        int m_ttl;
        int m_NextFrame;
        Uint32 m_CurrentFrame;
        Ship* m_Ship;
        SDL_Texture *m_SpriteTexture;

        SDL_Rect m_src = {.x = 0, .y = 0, .w = 32, .h = 32 };
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 32, .h = 32 };

        Shield( Ship* ship, const char* sprite_file );

        void Move();
        void Render();
        bool Activate();
        void Deactivate();
};

Shield类定义之后,我们需要为我们的Asteroid类添加一个类定义。与 Atari 游戏Asteroids不同,我们不能通过射击来摧毁这些小行星。它们被设计为障碍物,但我们(暂时)允许玩家在他们的护盾激活时撞毁小行星。它们将在游戏屏幕周围缓慢移动,并为玩家和敌方 AI 在游戏过程中提供障碍。以下是代码:

class Asteroid : public Collider {
    public:
        SDL_Texture *m_SpriteTexture;
        SDL_Rect m_src = {.x = 0, .y = 0, .w = 16, .h = 16 };
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 0, .h = 0 };

        bool m_Alive;
        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;
        float m_Rotation;

        Vector2D m_Direction;
        Vector2D m_Velocity;

        Emitter* m_Explode;
        Emitter* m_Chunks;

        Asteroid( float x, float y,
                  float velocity,
                  float rotation );

        void Move();
        void Render();
        void Explode();
};

我们还将在游戏区域中心添加一个大星星。这类似于游戏Spacewar!中心的黑洞,我们的游戏是基于它的。这颗星星最终将提供引力吸引力,使游戏变得更具挑战性。我们将通过粒子发射器对星星图像进行动画处理,并添加一些太阳耀斑:

class Star : public Collider {
    public:
        SDL_Texture *m_SpriteTexture;
        SDL_Rect m_src = {.x = 0, .y = 0, .w = 64, .h = 64 };
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 64, .h = 64 };

        std::vector<Emitter*> m_FlareList;
        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;

        Star();

        void Move();
        void Render();
};

现在我们可以对我们的Ship类进行一些修改。完成后,它将如下所示:

class Ship : public Collider {
    public:
        const float c_Acceleration = 10.0f;
        const float c_MaxVelocity = 50.0f;
        const int c_AliveTime = 2000;
        const Uint32 c_MinLaunchTime = 300;
        const int c_Width = 32;
        const int c_Height = 32;

        bool m_Accelerating = false;
        Uint32 m_LastLaunchTime;
        SDL_Texture *m_SpriteTexture;
        SDL_Rect src = {.x = 0, .y = 0, .w = 32, .h = 32 };

        Emitter* m_Explode;
        Emitter* m_Exhaust;
        Shield* m_Shield;
        std::vector<Collider*> m_Colliders;

        bool m_Alive = true;
        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;
        float m_Rotation;

        Vector2D m_Direction;
        Vector2D m_Velocity;

        void RotateLeft();
        void RotateRight();
        void Accelerate();
        void Decelerate();
        void CapVelocity();
        void Shoot();
        virtual void Move() = 0;
        Ship();
        void Render();
        bool CompoundHitTest( Collider* collider );
};

我们要做的第一件事是添加m_Shield属性,这是一个指向Shield对象的指针:

Shield* m_Shield;

之后,我们使用不同的变量来表示x方向和y方向,以及不同的变量来表示x速度和y速度,就像这样:

double m_DX;  // x-direction variable
double m_DY;  // y-direction variable
double m_VX;  // x-velocity variable
double m_VY;  // y-velocity variable

让我们删除所有这些代码,并用一些Vector2D对象来替换,表示方向向量和速度向量,就像这样:

Vector2D m_Direction;
Vector2D m_Velocity;

最后,为了防止我们的敌舰和玩家舰之间的代码重复,我们将添加一个Shoot()函数,从舰船发射抛射物:

void Shoot();

我们需要修改的下一个类是我们的EnemyShip类。我们需要添加一个包含我们Shield精灵文件名的字符串。我们还需要删除旧的AIStub()函数,并用指向我们 FSM 的指针替换它。以下是EnemyShip类的新版本:

class EnemyShip: public Ship {
    public:
        const char* c_SpriteFile = "/sprites/BirdOfAngerExp.png";
        const char* c_ShieldSpriteFile = "/sprites/shield-bird.png";
        const int c_AIStateTime = 2000;

        int m_AIStateTTL;
        FiniteStateMachine* m_FSM;

        EnemyShip();
        void Move();
};

我们将要添加的一个重要新类是FiniteStateMachine类。这个类将承担所有 AI 的繁重工作。以下是你必须添加到game.hpp的类定义:

class FiniteStateMachine {
    public:
        const float c_AttackDistSq = 40000.0;
        const float c_FleeDistSq = 2500.0;
        const int c_MinRandomTurnMS = 100;
        const int c_RandTurnMS = 3000;
        const int c_ShieldDist = 20;
        const int c_AvoidDist = 80;
        const int c_StarAvoidDistSQ = 20000;
        const int c_ObstacleAvoidForce = 150;
        const int c_StarAvoidForce = 120;

        FSM_STATE m_CurrentState;
        EnemyShip* m_Ship;
        bool m_HasLOS;
        bool m_LastTurnLeft;
        int m_SameTurnPct;
        int m_NextTurnMS;
        int m_CheckCycle;
        float m_DesiredRotation;
        float m_PlayerDistSQ;

        FiniteStateMachine(EnemyShip* ship);

        void SeekState(Vector2D &seek_point);
        void FleeState(Vector2D &flee_point);
        void WanderState();
        void AttackState();
        void AvoidForce();
        bool ShieldCheck();
        bool LOSCheck();
        Vector2D PredictPosition();
        float GetPlayerDistSq();
        void Move();
};

在这个类定义的顶部有九个常量:

 const float c_AttackDistSq = 40000.0;
 const float c_FleeDistSq = 2500.0;
 const int c_MinRandomTurnMS = 100;
 const int c_RandTurnMS = 3000;
 const int c_ShieldDist = 20;
 const int c_AvoidDist = 80;
 const int c_StarAvoidDistSQ = 20000;
 const int c_ObstacleAvoidForce = 150;
 const int c_StarAvoidForce = 120;

前两个常量c_AttackDistSqc_FleeDistSq是 FSM 用来确定它是否会转换为ATTACKFLEE状态的值;c_MinRandomTurnMSc_RandTurnMS都是WANDER状态使用的常量,用于确定 AI 何时决定下一次随机改变方向。c_ShieldDist常量是障碍物会导致 AI 打开护盾的距离。c_AvoidDist常量给出了 AI 进行校正调整以避免对象的范围。c_StarAvoidDistSQ函数是 AI 将进行航向调整以避免游戏区域中心的星星的距离。c_ObstacleAvoidForce常量是添加到对象速度的转向力,以帮助它避开障碍物,c_StarAvoidForce是用于避开星星的类似力。

在常量之后,我们有一块由 FSM 用于基于状态做出决策的属性:

 FSM_STATE m_CurrentState;
 EnemyShip* m_Ship;
 bool m_HasLOS;
 bool m_LastTurnLeft;
 int m_SameTurnPct;
 int m_NextTurnMS;
 int m_CheckCycle;
 float m_DesiredRotation;
 float m_PlayerDistSQ;

m_CurrentState属性保存了我们有限状态机的当前状态。m_Ship属性包含了指向飞船的指针。现在,这总是我们游戏中的单个敌人飞船,但在将来,您可能希望添加多个敌人飞船。m_HasLOS属性是一个布尔值,用于跟踪我们的飞船当前是否与玩家有无阻挡的视线。m_LastTurnLeft属性是一个布尔值,用于跟踪飞船在WANDER状态下上次转向的方向。m_SameTurnPct属性是飞船在WANDER状态下继续向同一方向转向的百分比几率。m_NextTurnMS属性是飞船在WANDER状态下继续进行方向改变之前的毫秒数。m_CheckCycle变量用于在不同的帧渲染周期中分解 AI 以执行不同的检查。如果您让 AI 在每次帧渲染之间做所有工作,可能会使系统陷入困境。通常最好将 AI 分解为多个部分,并且每次帧渲染只执行部分逻辑。m_DesiredRotation属性是 AI 的期望航向,最后,m_PlayerDistSQ是敌人飞船和玩家飞船之间的平方距离。

我们需要修改Projectile类,使用Vector2D来跟踪速度,而不是两个浮点变量m_VXm_VY。这是修改后的Projectile类的新版本:

class Projectile: public Collider {
    public:
        const char* c_SpriteFile = "sprites/ProjectileExp.png";
        const int c_Width = 16;
        const int c_Height = 16;
        const double velocity = 6.0;
        const double alive_time = 2000;

        SDL_Texture *m_SpriteTexture;
        SDL_Rect src = {.x = 0, .y = 0, .w = 16, .h = 16 };

        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;
        bool m_Active;
        float m_TTL;
        float m_VX;
        float m_VY;

        Projectile();
        void Move();
        void Render();
        void Launch(double x, double y, double dx, double dy);
};

game.hpp文件的末尾,我们应该添加一些新的小行星列表和将要放在游戏区域中心的星星的外部引用:

extern std::vector<Asteroid*> asteroid_list;
extern Star* star;

现在我们已经处理了对game.hpp文件的修改,让我们开始添加我们正在添加的障碍物。

向我们的游戏中添加障碍物

现在,我们的游戏中还没有任何 AI 可以操纵。我们需要添加一些障碍物,可以妨碍我们的敌人飞船。我们希望我们的敌人飞船尽力避开这些障碍物,同时试图接近并攻击我们玩家的太空船。我们将首先添加的是一个大星星,就在我们游戏区域的中间。我们可以为这颗星星制作动画,并为它的日冕添加一些漂亮的粒子效果。在上一节中,我们在game.hpp文件中创建了这颗星星的类定义,它看起来像这样:

class Star : public Collider {
    public:
        SDL_Texture *m_SpriteTexture;
        SDL_Rect m_src = {.x = 0, .y = 0, .w = 64, .h = 64 };
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 64, .h = 64 };

        std::vector<Emitter*> m_FlareList;

        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;

        Star();

        void Move();
        void Render();
};

我们需要创建一个名为star.cpp的新文件来配合这个类定义。在其中,我们应该定义我们的构造函数和MoveRender函数。与我们所有的 CPP 文件一样,我们首先包含game.hpp文件:

#include "game.hpp"

之后,我们有一些#define指令,用于定义我们将用来渲染星星和耀斑粒子系统的精灵文件:

#define STAR_SPRITE_FILE "/sprites/rotating-star.png"
#define FLARE_FILE (char*)"/sprites/flare.png"

构造函数相当长,但其中很多部分应该看起来很熟悉:

Star::Star() : Collider(32.0) {
    SDL_Surface *temp_surface = IMG_Load( STAR_SPRITE_FILE );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating enemy ship surface\n");
    }
    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating enemy ship texture\n");
    }
    SDL_FreeSurface( temp_surface );

    m_Radius = 36;

    m_Position.x = CANVAS_WIDTH / 2;
    m_Position.y = CANVAS_HEIGHT / 2;

    m_dest.x = m_Position.x - m_Radius / 2;
    m_dest.y = m_Position.y - m_Radius / 2;

    m_FlareList.push_back(new 
    Emitter(FLARE_FILE,100,160,220,1500,0.05,true,30,40, 1, 
    m_Position.x+8, m_Position.y+8, 10,0.1, 0.2,0.5, 1.0,0xffffff, 
    0xffffff, 0.1, 50,true, true, 4409, 1));

    m_FlareList.push_back(new 
    Emitter(FLARE_FILE,100,220,280,1500,0.05,true,30,40, 1, m_Position.x+8, 
    m_Position.y+8,10,0.1,0.2,0.5,1.0,0xffffff, 0xffffff, 0.0, 
    50,true,true,3571, 1));

    m_FlareList.push_back(new 
    Emitter(FLARE_FILE,100,280,360,1500,0.05,true,30,40, 1, 
    m_Position.x+8, m_Position.y+8, 10, 0.1, 0.2, 0.5, 1.0, 0xffffff, 
    0xffffff, 0.2, 50, true, true, 3989, 1));

    m_FlareList.push_back(new 
    Emitter(FLARE_FILE,100,0,60,1500,0.05,true,30,40, 1, m_Position.x+8, 
    m_Position.y+8, 10, 0.1, 0.2, 0.5, 1.0, 0xffffff, 0xffffff, 0.1, 50, 
    true, true, 3371, 1));

    m_FlareList.push_back(new 
    Emitter(FLARE_FILE,100,60,100,1500,0.05,true,30,40, 1, m_Position.x+8, 
    m_Position.y+8, 10, 0.1, 0.2, 0.5, 1.0, 0xffffff, 0xffffff, 0.3, 50, 
    true, true, 4637, 1));
}

这个构造函数首先通过继承Collider构造函数传递一个半径为32

Star::Star() : Collider(32.0) {

然后创建一个精灵纹理,用于渲染星星。这部分代码应该看起来很熟悉:

SDL_Surface *temp_surface = IMG_Load( STAR_SPRITE_FILE );

if( !temp_surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return;
}
else {
    printf("success creating enemy ship surface\n");
}
m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, temp_surface );
if( !m_SpriteTexture ) {
    printf("failed to create texture: %s\n", IMG_GetError() );
    return;
}
else {
    printf("success creating enemy ship texture\n");
}
SDL_FreeSurface( temp_surface );

设置好精灵纹理后,构造函数设置了一些属性,包括半径和位置:

m_Radius = 36;
m_Position.x = CANVAS_WIDTH / 2;
m_Position.y = CANVAS_HEIGHT / 2;
m_dest.x = m_Position.x - m_Radius / 2;
m_dest.y = m_Position.y - m_Radius / 2;

最后,它向m_FlareList向量添加了发射器。这些将是一些太阳耀斑粒子系统。我使用了粒子系统配置工具来得出我们正在创建的值。您可以根据需要调整这些值,但我觉得这些值创造了一个漂亮的耀斑效果:

m_FlareList.push_back(new Emitter(FLARE_FILE,100,160,220,1500,0.05,true,30,40, 1, m_Position.x+8, m_Position.y+8, 10,0.1, 0.2,0.5, 1.0,0xffffff, 0xffffff, 0.1, 50,true, true,4409, 1));

m_FlareList.push_back(new Emitter(FLARE_FILE,100,220,280,1500,0.05,true,30,40, 1, m_Position.x+8, m_Position.y+8,10,0.1,0.2,0.5,1.0,0xffffff, 0xffffff, 0.0, 50,true,true,3571, 1));

m_FlareList.push_back(new Emitter(FLARE_FILE,100,280,360,1500,0.05,true,30,40, 1, m_Position.x+8, m_Position.y+8, 10, 0.1, 0.2, 0.5, 1.0, 0xffffff, 0xffffff, 0.2, 50, true, true, 3989, 1));

m_FlareList.push_back(new Emitter(FLARE_FILE,100,0,60,1500,0.05,true,30,40, 1, m_Position.x+8, m_Position.y+8, 10, 0.1, 0.2, 0.5, 1.0, 0xffffff, 0xffffff, 0.1, 50, true, true, 3371, 1));

m_FlareList.push_back(new Emitter(FLARE_FILE,100,60,100,1500,0.05,true,30,40, 1, m_Position.x+8, m_Position.y+8, 10, 0.1, 0.2, 0.5, 1.0, 0xffffff, 0xffffff, 0.3, 50, true, true, 4637, 1));

星星的Move函数非常简单。它循环遍历星星动画序列的八个帧:

void Star::Move() {
    m_NextFrameTime -= diff_time;
    if( m_NextFrameTime <= 0 ) {
        ++m_CurrentFrame;
        m_NextFrameTime = ms_per_frame;
        if( m_CurrentFrame >= 8 ) {
            m_CurrentFrame = 0;
        }
    }
}

星星的Render函数稍微复杂一些,因为它需要循环遍历耀斑发射器,并在渲染星星精灵纹理之前移动它们:

void Star::Render() {
    Emitter* flare;
    std::vector<Emitter*>::iterator it;

    for( it = m_FlareList.begin(); it != m_FlareList.end(); it++ ) {
        flare = *it;
        flare->Move();
    }
    m_src.x = m_dest.w * m_CurrentFrame;

    SDL_RenderCopy( renderer, m_SpriteTexture,
                    &m_src, &m_dest );
}

接下来,我们需要定义asteroid.cpp文件。这将保存我们Asteroid类的函数定义。以下是games.hpp文件中Asteroid类的定义:

class Asteroid : public Collider {
    public:
        SDL_Texture *m_SpriteTexture;
        SDL_Rect m_src = {.x = 0, .y = 0, .w = 16, .h = 16 };
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 0, .h = 0 };

        bool m_Alive;
        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;
        float m_Rotation;
        Vector2D m_Direction;
        Vector2D m_Velocity;

        Emitter* m_Explode;
        Emitter* m_Chunks;

        Asteroid( float x, float y,
                  float velocity,
                  float rotation );

        void Move();
        void Render();
        void Explode();
};

在我们的asteroid.cpp文件中,我们需要定义Asteroid构造函数、Move函数、Render函数和Explode函数。在asteroid.cpp文件的顶部,我们需要#include game.hpp文件,并在虚拟文件系统中定义我们的小行星精灵文件的位置。以下是这些代码的前几行的样子:

#include "game.hpp"
#define ASTEROID_SPRITE_FILE (char*)"/sprites/asteroid.png"

我们将定义的第一个函数是我们的构造函数。以下是构造函数的完整函数:

Asteroid::Asteroid( float x, float y,
                    float velocity,
                    float rotation ): Collider(8.0) {
    SDL_Surface *temp_surface = IMG_Load( ADSTEROID_SPRITE_FILE );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating asteroid surface\n");
    }

    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, temp_surface );

    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating asteroid texture\n");
    }

    SDL_FreeSurface( temp_surface );

    m_Explode = new Emitter((char*)"/sprites/Explode.png",
         100, 0, 360,     // int max_particles, float min_angle, float 
         max_angle,
         1000, 0.3, false, // Uint32 particle_lifetime, float acceleration, 
         bool alpha_fade,
         20.0, 40.0,     // float min_starting_velocity, float 
         max_starting_velocity,
         10, 0, 0, 5,     // Uint32 emission_rate, int x_pos, int y_pos, 
         float radius,
         1.0, 2.0,         // float min_start_scale, float max_start_scale,
         1.0, 2.0,         // float min_end_scale, float max_end_scale,
         0xffffff, 0xffffff,
         0.01, 10,         // float burst_time_pct, Uint32 burst_particles,
         false, false,     // bool loop, bool align_rotation,
         800, 8 );         // Uint32 emit_time_ms, Uint32 animation_frames
    m_Explode->m_parent_rotation_ptr = &m_Rotation;
    m_Explode->m_parent_x_ptr = &(m_Position.x);
    m_Explode->m_parent_y_ptr = &(m_Position.y);
    m_Explode->m_active = false;

    m_Chunks = new Emitter((char*)"/sprites/small-asteroid.png",
         40, 0, 360, // int max_particles, float min_angle, float 
         max_angle,
         1000, 0.05, false, // Uint32 particle_lifetime, float 
         acceleration, 
         bool alpha_fade,
         80.0, 150.0, // float min_starting_velocity, float 
         max_starting_velocity,
         5, 0, 0, 10, // Uint32 emission_rate, int x_pos, int y_pos, 
         float radius,
         2.0, 2.0, // float min_start_scale, float max_start_scale,
         0.25, 0.5, // float min_end_scale, float max_end_scale,
         0xffffff, 0xffffff,
         0.1, 10, // float burst_time_pct, Uint32 burst_particles,
         false, true, // bool loop, bool align_rotation,
         1000, 8 ); // Uint32 emit_time_ms, Uint32 animation_frames

    m_Chunks->m_parent_rotation_ptr = &m_Rotation;
    m_Chunks->m_parent_x_ptr = &m_Position.x;
    m_Chunks->m_parent_y_ptr = &m_Position.    
    m_Chunks->m_active = false;

    m_Position.x = x;
    m_Position.y = y;

    Vector2D direction;
    direction.x = 1;
    direction.Rotate( rotation );

    m_Direction = direction;
    m_Velocity = m_Direction * velocity;

    m_dest.h = m_src.h = m_dest.w = m_src.w = 16;

    m_Rotation = rotation;
    m_Alive = true;
    m_CurrentFrame = 0;
    m_NextFrameTime = ms_per_frame;
}

构造函数的定义调用了Collider类中的父构造函数,传入了Collider的半径为8.0

Asteroid::Asteroid( float x, float y,
                    float velocity,
                    float rotation ): Collider(8.0) {

之后,构造函数使用 SDL 加载和初始化精灵纹理,这是一个我们现在应该都非常熟悉的过程:

SDL_Surface *temp_surface = IMG_Load( ADSTEROID_SPRITE_FILE );

if( !temp_surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return;
}
else {
    printf("success creating asteroid surface\n");
}

m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, temp_surface );

if( !m_SpriteTexture ) {
    printf("failed to create texture: %s\n", IMG_GetError() );
    return;
}
else {
    printf("success creating asteroid texture\n");
}

SDL_FreeSurface( temp_surface );

然后,我们定义了我们的爆炸发射器。如果我们的小行星被摧毁,这个发射器将被激活:

m_Explode = new Emitter((char*)"/sprites/Explode.png",
     100, 0, 360, // int max_particles, float min_angle, float max_angle,
     1000, 0.3, false, // Uint32 particle_lifetime, float acceleration, 
     bool alpha_fade,
     20.0, 40.0, // float min_starting_velocity, float 
     max_starting_velocity,
     10, 0, 0, 5, // Uint32 emission_rate, int x_pos, int y_pos, 
     float radius,
     1.0, 2.0, // float min_start_scale, float max_start_scale,
     1.0, 2.0, // float min_end_scale, float max_end_scale,
     0xffffff, 0xffffff,
     0.01, 10, // float burst_time_pct, Uint32 burst_particles,
     false, false, // bool loop, bool align_rotation,
     800, 8 ); // Uint32 emit_time_ms, Uint32 animation_frames

m_Explode->m_parent_rotation_ptr = &m_Rotation;
m_Explode->m_parent_x_ptr = &(m_Position.x);
m_Explode->m_parent_y_ptr = &(m_Position.y);
m_Explode->m_active = false;

之后,我们创建了第二个发射器,当我们的小行星被摧毁时会发射一些小块石头。这是为了补充m_Explosion发射器,并且它将在小行星爆炸时同时运行:

m_Chunks = new Emitter((char*)"/sprites/small-asteroid.png",
     40, 0, 360, // int max_particles, float min_angle, float max_angle,
     1000, 0.05, false, // Uint32 particle_lifetime, float acceleration, 
     bool alpha_fade,
     80.0, 150.0, // float min_starting_velocity, float 
     max_starting_velocity,
     5, 0, 0, 10, // Uint32 emission_rate, int x_pos, int y_pos, 
     float radius,
     2.0, 2.0, // float min_start_scale, float max_start_scale,
     0.25, 0.5, // float min_end_scale, float max_end_scale,
     0xffffff, 0xffffff,
     0.1, 10, // float burst_time_pct, Uint32 burst_particles,
     false, true, // bool loop, bool align_rotation,
     1000, 8 ); // Uint32 emit_time_ms, Uint32 animation_frames

m_Chunks->m_parent_rotation_ptr = &m_Rotation;
m_Chunks->m_parent_x_ptr = &m_Position.x;
m_Chunks->m_parent_y_ptr = &m_Position.y;
m_Chunks->m_active = false;

最后几行设置了我们小行星属性的起始值:

m_Position.x = x;
m_Position.y = y;

Vector2D direction;
direction.x = 1;
direction.Rotate( rotation );

m_Direction = direction;
m_Velocity = m_Direction * velocity;
m_dest.h = m_src.h = m_dest.w = m_src.w = 16;

m_Rotation = rotation;
m_Alive = true;
m_CurrentFrame = 0;
m_NextFrameTime = ms_per_frame;

我们将要定义的下一个函数是Move函数。以下是它的样子:

void Asteroid::Move() {
m_NextFrameTime -= diff_time;
if( m_NextFrameTime <= 0 ) {
    m_NextFrameTime = ms_per_frame;
    m_CurrentFrame++;
    if( m_CurrentFrame >= 8 ) {
        m_CurrentFrame = 0;
    }
}
m_Position += m_Velocity * delta_time;
WrapPosition();
}

处理m_NextFrameTimem_CurrentFrame的第一批代码只是根据经过的时间量交替切换精灵帧:

m_NextFrameTime -= diff_time;
if( m_NextFrameTime <= 0 ) {
    m_NextFrameTime = ms_per_frame;
    m_CurrentFrame++;

    if( m_CurrentFrame >= 8 ) {
        m_CurrentFrame = 0;
    }
}

之后,我们根据时间增量和当前速度更新位置:

m_Position += m_Velocity * delta_time;

最后,调用WrapPosition函数。这个函数会将我们的小行星移到屏幕的右侧,如果它偏离了屏幕的左侧,并且如果它偏离了底部,它会移到顶部。每当一个小行星朝着特定方向移出屏幕时,它的位置将被包裹到游戏区域的另一侧。

Move函数之后,我们定义了Asteroid Render函数。完整的函数如下所示:

void Asteroid::Render() {
    m_Explode->Move();
    m_Chunks->Move();
    if( m_Alive == false ) {
        return;
    }
    m_src.x = m_dest.w * m_CurrentFrame;
    m_dest.x = m_Position.x + m_Radius / 2;
    m_dest.y = m_Position.y + m_Radius / 2;
    SDL_RenderCopyEx( renderer, m_SpriteTexture,
                        &m_src, &m_dest,
                        RAD_TO_DEG(m_Rotation), NULL, SDL_FLIP_NONE );
}

前两行移动了爆炸发射器和碎片发射器。如果小行星还没有被摧毁,这些函数将不会执行任何操作。如果小行星已经被摧毁,这些函数将运行粒子发射器。这些发射器不会循环,所以当它们的发射时间结束时,它们将停止:

m_Explode->Move();
m_Chunks->Move();

之后,我们检查小行星是否存活,如果不是,我们退出这个函数。我们在移动我们的发射器之后执行这个操作的原因是,我们必须在小行星被摧毁后继续运行发射器:

if( m_Alive == false ) {
    return;
}

在这个函数中,我们做的最后一件事是渲染我们的小行星精灵纹理,这个过程现在应该看起来非常熟悉:

m_src.x = m_dest.w * m_CurrentFrame;
m_dest.x = m_Position.x + m_Radius / 2;
m_dest.y = m_Position.y + m_Radius / 2;
SDL_RenderCopyEx( renderer, m_SpriteTexture,
                  &m_src, &m_dest,
                  RAD_TO_DEG(m_Rotation), NULL, SDL_FLIP_NONE );

在我们的asteroid.cpp文件中的最后一个函数是Explode函数。这个函数将在小行星被摧毁时运行。该函数将运行我们设计的两个发射器,以创建爆炸效果。它还会将小行星的存活标志设置为false。以下是代码:

void Asteroid::Explode() {
    m_Explode->Run();
    m_Chunks->Run();
    m_Alive = false;
}

现在我们已经定义了我们的游戏障碍,让我们来看看为我们的太空飞船创建一些护盾需要做些什么。

添加力场

目前,在我们的游戏中,我们的飞船在一次碰撞中被摧毁。这最终导致游戏很快就结束了。当即将发生碰撞时,希望有一个力场来防止飞船的破坏。这也会给我们的人工智能一些其他可以做的事情。当护盾启用时,使用它的飞船周围将有一个小的力场动画。护盾使用有时间限制。这将防止玩家或人工智能在整个游戏中保持护盾。当护盾激活时,护盾的颜色将从绿色过渡到红色。颜色越接近红色,护盾的能量就越接近耗尽。每次护盾受到打击时,玩家或人工智能的护盾都会减少额外的时间。我们已经在game.hpp文件中创建了类定义。它看起来像这样:

class Shield : public Collider {
    public:
        bool m_Active;
        int m_ttl;
        int m_NextFrame;
        Uint32 m_CurrentFrame;
        Ship* m_Ship;
        SDL_Texture *m_SpriteTexture;

        SDL_Rect m_src = {.x = 0, .y = 0, .w = 32, .h = 32 };
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 32, .h = 32 };

        Shield( Ship* ship, const char* sprite_file );

        void Move();
        void Render();
        bool Activate();
        void Deactivate();
};

为了配合这个类定义,我们需要一个shield.cpp文件,在这里我们可以定义这个类使用的所有函数。我们将在shield.cpp文件中定义的第一个函数是Shield构造函数:

Shield::Shield( Ship* ship, const char* sprite_string ) : Collider(12.0) {
    m_Active = false;
    m_ttl = 25500;
    m_Ship = ship;
    m_CurrentFrame = 0;
    m_NextFrame = ms_per_frame;
    SDL_Surface *temp_surface = IMG_Load( sprite_string );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }

    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, 
   temp_surface );

    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }
    SDL_FreeSurface( temp_surface );
}

Shield构造函数将调用Collider构造函数,半径为12.0。这是比飞船半径更大的半径。如果护盾激活,我们希望击中这个Collider而不是飞船。在这个构造函数中的第一个代码块设置了这个类属性的起始值:

m_Active = false;
m_ttl = 25500;
m_Ship = ship;
m_CurrentFrame = 0;
m_NextFrame = ms_per_frame;

请注意,我们将m_ttl设置为25500。这是你可以在毫秒内使用护盾的时间。这相当于 25.5 秒。我希望它是 255 的倍数,这样绿色就会根据剩余时间从 255 过渡到 0。

相反,红色将根据剩余时间从 0 过渡到 255。之后,我们以标准方式创建护盾的精灵纹理:

SDL_Surface *temp_surface = IMG_Load( sprite_string );

if( !temp_surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return;
}

m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, temp_surface );

if( !m_SpriteTexture ) {
    printf("failed to create texture: %s\n", IMG_GetError() );
return;
}

SDL_FreeSurface( temp_surface );

构造函数之后,我们需要定义我们的Move函数:

void Shield::Move() {
    if( m_Active ) {
        m_NextFrame -= diff_time;
        m_ttl -= diff_time;

        if( m_NextFrame <= 0 ) {
            m_NextFrame = ms_per_frame;
            m_CurrentFrame++;

            if( m_CurrentFrame >= 6 ) {
                m_CurrentFrame = 0;
            }
        }
        if( m_ttl <= 0 ) {
            m_Active = false;
        }
    }
}

如果护盾未激活,此函数将不执行任何操作。如果激活了,根据自上一帧以来经过的毫秒数,将减少m_ttl参数。然后,如果已经过了适当数量的毫秒,我们会增加当前帧。如果护盾的剩余时间低于 0,护盾将被停用。

在我们定义了Move函数之后,我们将定义我们的Render函数:

void Shield::Render() {
    if( m_Active ) {
        int color_green = m_ttl / 100 + 1;
        int color_red = 255 - color_green;
        m_src.x = m_CurrentFrame * m_dest.w;
        m_dest.x = m_Ship->m_Position.x;
        m_dest.y = m_Ship->m_Position.y;

        SDL_SetTextureColorMod(m_SpriteTexture,
                             color_red,
                             color_green,
                             0 );

        SDL_RenderCopyEx( renderer, m_SpriteTexture,
                             &m_src, &m_dest,
                             RAD_TO_DEG(m_Ship->m_Rotation),
                             NULL, SDL_FLIP_NONE );
    }
}

Move函数一样,如果激活标志为 false,Render函数将不执行任何操作。我们使用以下公式根据剩余时间计算颜色:

int color_green = m_ttl / 100 + 1;
int color_red = 255 - color_green;

这将平滑地将我们的护盾颜色从绿色过渡到红色。我们使用SDL_SetTextureColorMod来设置精灵纹理的颜色:

SDL_SetTextureColorMod(m_SpriteTexture,
                     color_red,
                     color_green,
                     0 );

Shield::Render函数中的其他内容都很标准,现在应该看起来非常熟悉。

更多碰撞检测

让我们看看我们需要对Collider类进行的修改。正如我们之前讨论的,我们的人工智能将实现转向行为。这些转向行为将需要在我们的Collider类中添加一些新的属性和函数。新的Collider类将如下所示:

class Collider {
    public:
        float* m_ParentRotation;
        float* m_ParentX;
        float* m_ParentY;
        Vector2D m_TempPoint;

        bool CCHitTest( Collider* collider );

        Vector2D m_Position;
        float m_Radius;
        float m_SteeringRadius;
        float m_SteeringRadiusSQ;

        void SetParentInformation( float* rotation, float* x, float* y );
        Collider(float radius);
        bool HitTest( Collider *collider );
        bool SteeringLineTest( Vector2D &p1, Vector2D &p2 );
        bool SteeringRectTest( Vector2D &start_point, Vector2D &end_point 
        );
        void WrapPosition();
 };

我们有三个新函数,其中两个是用于转向的。其中一个函数WrapPosition()将用于将移出屏幕的对象包裹到游戏屏幕的另一侧。让我们打开collider.cpp来看一看。我们需要改变的第一件事是构造函数。新版本的构造函数如下所示:

Collider::Collider(float radius) {
    m_ParentRotation = NULL;
    m_ParentX = NULL;
    m_ParentY = NULL;

    m_Radius = radius;
    m_SteeringRadius = m_Radius * 1.5;
    m_SteeringRadiusSQ = m_SteeringRadius * m_SteeringRadius;
}

最后两行是唯一的修改。您会注意到我们将m_SteeringRadius属性设置为1.5倍的m_Radius值。这个额外的缓冲空间是为了防止我们的敌舰过于靠近小行星,特别是如果它们在移动。这个因素有效地使得转向行为更加警惕地避免与小行星的碰撞。1.5的倍数是相当任意地选择的,因为在我测试时效果很好。如果您希望您的 AI 对小行星碰撞不太关心,更有可能追逐玩家并置自己于危险之中,您可以减小这个值,也许到1.1之类的值。您也可以增加这个值,使 AI 更加警惕小行星。将值设置得太高将导致 AI 过于胆小。将它设置得太低将使它在几乎任何情况下都追逐玩家,仿佛是战斗的著名话语:“该死的鱼雷——全速前进!

接下来,我们需要将新函数SteeringLineText添加到collider.cpp中。这个新函数将在我们的敌舰和玩家之间连接的线之间进行圆线碰撞检测,并检测我们的飞船沿着这条路径可能撞到的所有小行星和抛射物。这是一种视线测试,用于确定从我们的位置到玩家是否有一条清晰的路径。与圆圆或矩形矩形碰撞检测相比,圆线碰撞检测有点复杂。我在embed.com上创建的解决方案中大量借鉴了以下地址:www.embed.com/typescript-games/multiple-type-collision-detection.html

圆线碰撞检测

确定圆和线是否相撞的第一步是最简单的:检查线的任一端点是否落在圆的半径内。这是通过使用毕达哥拉斯定理进行简单的距离检查来完成的。如果一个点与我们的圆的中心之间的距离小于半径,那么线就在圆内。这是一个点落在圆的半径内的图示:

线的 p2 点落在圆的半径内

如果任一点落在圆的半径内,我们知道线和圆相撞。如果没有点落在圆的半径内,我们还没有完成。然后我们需要做的是找到线上距离圆心最近的点。让我稍微偏离一下,来更加技术化。从技术上讲,所有的线都是无限的。当我们有两个点并在这些点之间画一条“线”时,它是一条线段。要找到线和我们的圆之间的最近点,我们需要谈论一些叫做向量投影的东西。

向量投影

向量投影有点复杂。如果你将给定的向量 b 投影到向量a上,你会得到向量a的标量倍数(我们将称这个标量倍数为c),你可以添加一个垂直于向量ca的向量来得到向量b

以下图示是将向量b投影到向量a的示例:

将向量 b 投影到向量 a 的示例

另一种看待这个问题的方式是,向量 b 在向量 a 上的投影给出了距离向量 b 的终点最近的点,该点位于由向量 a 的任意标量倍数定义的线段上。也许你会想知道这与检测圆和直线之间的碰撞有什么关系。如果我们假设向量 b 表示我们圆的中心点的位置,我们就可以找出我们直线上离圆的中心点最近的点。然后我们测试我们的投影找到的点与圆的中心之间是否发生碰撞。看看向量投影如何用于确定下图中直线上距离圆最近的点:

注意,将向量投影到我们的线上会给我们圆上最接近的点

你还需要考虑另一个潜在的问题。对向量 a 的投影可能会给你一个大于 1 的 c 值(标量倍数)。如果是这种情况,可能是我们的线段与圆发生了碰撞,超出了我们的结束点。因此,我们还需要进行一些范围检查,以查看我们是否超出了线段的末端:

将圆的向量投影到我们的线上会给我们超出线段范围的最接近点

现在我已经解释了向量投影是什么,让我们来看看代码:

bool Collider::SteeringLineTest( Vector2D &start, Vector2D &end ) {
    if( m_Active == false ) {
        return false;
    }
    Vector2D dist = start;
    dist -= m_Position;

    if( m_SteeringRadiusSQ > dist.MagSQ() ) {
        return true;
    }
    dist = end;
    dist -= m_Position;

    if( m_SteeringRadiusSQ > dist.MagSQ() ) {
        return true;
    }
    dist = end;
    dist -= start;

    Vector2D circle_vec = m_Position;
    circle_vec -= start;

    Vector2D near_point = circle_vec.Project( dist );
    near_point += start;

    Vector2D temp_vector = near_point;
    circle_vec += start;
    temp_vector -= circle_vec;

    Range x_range;
    x_range.min = start.x;
    x_range.max = end.x;
    x_range.Sort();
    Range y_range;
    y_range.min = start.y;
    y_range.max = end.y;
    y_range.Sort();

    if ((x_range.min <= near_point.x && near_point.x <= x_range.max &&
         y_range.min <= near_point.y && near_point.y <= y_range.max) == 
         false) {
        return false;
    }
    if( temp_vector.MagSQ() < m_SteeringRadiusSQ ) {
        return true;
    }
    return false;
}

正如我们之前讨论的,我们首先要做的是测试起点和终点到这个Collider对象位置的距离。如果距离的平方小于任一点的转向半径的平方,我们就知道该线段与我们的转向半径发生了碰撞:

if( m_Active == false ) {
    return false;
}

Vector2D dist = start;
dist -= m_Position;

if( m_SteeringRadiusSQ > dist.MagSQ() ) {
    return true;
}

dist = end;
dist -= m_Position;
if( m_SteeringRadiusSQ > dist.MagSQ() ) {
    return true;
}

如果两个点都不在圆内,我们将需要对投影进行测试。我们需要将线段转换为通过原点的向量。为此,我们需要从结束点减去起始点,并且我们还需要调整圆的位置相同的量:

dist = end;
dist -= start;

Vector2D circle_vec = m_Position;
circle_vec -= start;

Vector2D near_point = circle_vec.Project( dist );
near_point += start;

Vector2D temp_vector = near_point;
circle_vec += start;
temp_vector -= circle_vec;

我们需要确保距离碰撞器最近的点仍然在线段上。这可以通过对起始和结束的xy值进行简单的范围测试来完成。如果xy坐标都在我们的范围内,我们就知道该点必须位于线段上。如果不是,我们就知道该线段与圆不相交:

Range x_range;
x_range.min = start.x;
x_range.max = end.x;
x_range.Sort();

Range y_range;
y_range.min = start.y;
y_range.max = end.y;
y_range.Sort();

if ((x_range.min <= near_point.x && near_point.x <= x_range.max &&
     y_range.min <= near_point.y && near_point.y <= y_range.max) == false) {
    return false;
}

如果我们此时还没有返回false值,我们就知道碰撞器最近的点在我们的线段上。现在我们可以测试从该点到我们的碰撞器的距离,以查看它是否足够接近与我们的转向半径发生碰撞;如果是,我们返回true,如果不是,我们返回false

if( m_SteeringRadiusSQ > dist.MagSQ() ) {
    return true;
}
return false;

Vector2D 类

我之前提到,我们需要放弃旧的Point类,转而使用功能更多的东西。新的Vector2D类将为我们之前使用的Point类添加几个新的函数。让我们再次看看我们在game.hpp文件中的函数定义:

class Vector2D {
    public:
        float x;
        float y;

        Vector2D();
        Vector2D( float X, float Y );

        void Rotate( float radians );
        void Normalize();
        float MagSQ();
        float Magnitude();

        Vector2D Project( Vector2D &onto );
        float Dot(Vector2D &vec);
        float FindAngle();

        Vector2D operator=(const Vector2D &vec);
        Vector2D operator*(const float &scalar);
        void operator+=(const Vector2D &vec);
        void operator-=(const Vector2D &vec);
        void operator*=(const float &scalar);
        void operator/=(const float &scalar);
};

与点不同,向量有一个大小。因为计算速度更快,我们还将添加一个平方大小,MagSQ函数。向量可以被标准化,这意味着它们可以被修改为大小为 1。我们之前讨论过向量投影,并创建了一个Project函数来允许我们这样做。找到两个向量的点积在游戏中是一个非常有用的操作。两个标准化向量的点积是一个标量值,取决于这两个向量之间的角度,范围在 1 到-1 之间。如果向量指向相同的方向,则该值为 1,如果它们指向相反的方向,则为-1,如果两个向量互相垂直,则为 0。

两个归一化向量的点积等同于这两个归一化向量之间的角度的余弦。得到任意两个向量 ab 的点积,你会得到(a 的大小)b* 的大小)* 余弦(ab 之间的角度)。我们首先归一化这些向量的原因是为了将 ab 的大小设置为 1,这会导致我们的归一化点积返回 ab 之间的余弦角度。

我们还将添加一个 FindAngle 函数,它将告诉我们这个函数的方向角。我们将重载许多运算符,以便更容易地对向量进行操作。

让我们来看看 vector.cpp 的全部内容:

#include "game.hpp"

Vector2D::Vector2D( float X, float Y ) {
    x = X;
    y = Y;
}
Vector2D::Vector2D() {
    y = x = 0.0;
}
Vector2D Vector2D::operator=(const Vector2D& p) {
    x = p.x;
    y = p.y;
    return *this;
}
void Vector2D::operator+=(const Vector2D& p) {
    x += p.x;
    y += p.y;
}
void Vector2D::operator-=(const Vector2D& p) {
    x -= p.x;
    y -= p.y;
}
void Vector2D::operator*=(const float& scalar) {
    x *= scalar;
    y *= scalar;
}
void Vector2D::operator/=(const float& scalar) {
    x /= scalar;
    y /= scalar;
}
Vector2D Vector2D::operator*(const float& scalar) {
    Vector2D vec = *this;
    vec *= scalar;
    return vec;
}
void Vector2D::Rotate( float radians ) {
    float sine = sin(radians);
    float cosine = cos(radians);
    float rx = x * cosine - y * sine;
    float ry = x * sine + y * cosine;
    x = rx;
    y = ry;
}
void Vector2D::Normalize() {
    float mag = Magnitude();
    x /= mag;
    y /= mag;
}
Vector2D Vector2D::Project(Vector2D &onto) {
    Vector2D proj = *this;
    float proj_dot_onto = proj.Dot(onto);
    proj *= proj_dot_onto;
    return proj;
}
float Vector2D::Dot(Vector2D &vec) {
    Vector2D this_norm;
    this_norm = *this;
    this_norm.Normalize();
    Vector2D vec_norm;
    vec_norm = vec;
    vec_norm.Normalize();

    return this_norm.x * vec_norm.x + this_norm.y * vec_norm.y;
}
float Vector2D::FindAngle() {
    if( x == 0.0 && y == 0.0 ) {
        return 0.0;
    }
    Vector2D this_norm;
    this_norm = *this;
    this_norm.Normalize();
    return atan2( this_norm.y, this_norm.x ) + PI / 2;
}
float Vector2D::MagSQ() {
    return x * x + y * y;
}
float Vector2D::Magnitude() {
    return sqrt( MagSQ() );
}

前两个函数是构造函数,它们本质上与 Point 类中的构造函数相同:

Vector2D::Vector2D( float X, float Y ) {
    x = X;
    y = Y;
}
Vector2D::Vector2D() {
    y = x = 0.0;
}

之后,我们有了我们重载的运算符。这使我们能够轻松地对向量进行加法、减法、乘法和除法:

Vector2D Vector2D::operator=(const Vector2D& p) {
    x = p.x;
    y = p.y;
    return *this;
}
void Vector2D::operator+=(const Vector2D& p) {
    x += p.x;
    y += p.y;
}
void Vector2D::operator-=(const Vector2D& p) {
    x -= p.x;
    y -= p.y;
}
void Vector2D::operator*=(const float& scalar) {
    x *= scalar;
    y *= scalar;
}
void Vector2D::operator/=(const float& scalar) {
    x /= scalar;
    y /= scalar;
}
Vector2D Vector2D::operator*(const float& scalar) {
    Vector2D vec = *this;
    vec *= scalar;
    return vec;
}

Rotate 函数是 Point 类上存在的少数函数之一。它与 Point 类版本没有变化:

void Vector2D::Rotate( float radians ) {
    float sine = sin(radians);
    float cosine = cos(radians);
    float rx = x * cosine - y * sine;
    float ry = x * sine + y * cosine;
    x = rx;
    y = ry;
}

Normalize 函数将向量的大小更改为 1。它通过确定向量的大小并将 xy 值除以该大小来实现这一点:

void Vector2D::Normalize() {
    float mag = Magnitude();
    x /= mag;
    y /= mag;
}

Project 函数使用归一化角度的点积,并将标量值乘以向量来确定新的投影向量:

Vector2D Vector2D::Project(Vector2D &onto) {
    Vector2D proj = *this;
    float proj_dot_onto = proj.Dot(onto);
    proj *= proj_dot_onto;
    return proj;
}

我们的 Dot 乘积函数实际上是归一化向量的点积。这给了我们关于两个向量之间角度的信息。我们首先进行归一化,因为我们只在我们的向量投影中使用这个点积:

float Vector2D::Dot(Vector2D &vec) {
    Vector2D this_norm;
    this_norm = *this;
    this_norm.Normalize();

    Vector2D vec_norm;
    vec_norm = vec;
    vec_norm.Normalize();

    return this_norm.x * vec_norm.x + this_norm.y * vec_norm.y;
}

FindAngle 函数使用反正切来找到两个向量之间的弧度角度:

float Vector2D::FindAngle() {
    if( x == 0.0 && y == 0.0 ) {
        return 0.0;
    }
    Vector2D this_norm;
    this_norm = *this;
    this_norm.Normalize();
    return atan2( this_norm.y, this_norm.x ) + PI / 2;
}

最后两个函数获取向量的大小和平方大小:

float Vector2D::MagSQ() {
    return x * x + y * y;
}

float Vector2D::Magnitude() {
    return sqrt( MagSQ() );
}

编写有限状态机

现在我们在 ColliderVector2D 类中有了我们需要的工具,我们可以构建我们的 FSM。FiniteStateMachine 类将管理我们的 AI。我们的 FSM 将有四种状态:SEEKFLEEATTACKWANDER。它将实现导航行为,并在尝试穿越如小行星等障碍物时添加一个避免力。AI 还需要检查敌舰是否应该升高或降低护盾。让我们再次看看我们在 game.hpp 文件中定义的 FiniteStateMachine 类的定义:

class FiniteStateMachine {
    public:
        const float c_AttackDistSq = 40000.0;
        const float c_FleeDistSq = 2500.0;
        const int c_MinRandomTurnMS = 100;
        const int c_RandTurnMS = 3000;
        const int c_ShieldDist = 20;
        const int c_AvoidDist = 80;
        const int c_StarAvoidDistSQ = 20000;
        const int c_ObstacleAvoidForce = 150;
        const int c_StarAvoidForce = 120;

        FSM_STATE m_CurrentState;
        EnemyShip* m_Ship;

        bool m_HasLOS;
        bool m_LastTurnLeft;
        int m_SameTurnPct;
        int m_NextTurnMS;
        int m_CheckCycle;
        float m_DesiredRotation;
        float m_PlayerDistSQ;

        FiniteStateMachine(EnemyShip* ship);

        void SeekState(Vector2D &seek_point);
        void FleeState(Vector2D &flee_point);
        void WanderState();
        void AttackState();

        void AvoidForce();
        bool ShieldCheck();
        bool LOSCheck();

        Vector2D PredictPosition();

        float GetPlayerDistSq();
        void Move();
};

现在让我们花点时间来浏览我们在 finite_state_machine.cpp 文件中定义的所有函数。这个文件开头的构造函数并不复杂。它只做一些基本的初始化:

FiniteStateMachine::FiniteStateMachine(EnemyShip* ship) {
    m_Ship = ship;
    m_CurrentState = APPROACH;
    m_HasLOS = false;
    m_DesiredRotation = 0.0;
    m_CheckCycle = 0;
    m_PlayerDistSQ = 0;
}

在构造函数之后,我们定义了四个状态函数:SeekStateFleeStateWanderStateAttackState。这四种状态中的第一种会导致我们的敌舰在游戏区域中寻找特定的点。这一点要么在我们的 Move 函数中计算,要么在我们的 AttackState 函数内部计算。代码如下:

void FiniteStateMachine::SeekState(Vector2D &seek_point) {
    Vector2D direction = seek_point;
    direction -= m_Ship->m_Position;
    m_DesiredRotation = direction.FindAngle();
    float rotate_direction = m_Ship->m_Rotation - m_DesiredRotation;

    if( rotate_direction > PI ) {
        rotate_direction -= 2 * PI;
    }
    else if( rotate_direction < -PI ) {
        rotate_direction += 2 * PI;
    }

    if( rotate_direction < -0.05 ) {
        m_Ship->RotateRight();
        m_Ship->RotateRight();
    }
    else if( rotate_direction > 0.05 ) {
        m_Ship->RotateLeft();
        m_Ship->RotateLeft();
    }
    m_Ship->Accelerate();
    m_Ship->Accelerate();
    m_Ship->Accelerate();
    m_Ship->Accelerate();
}

函数的第一件事是确定飞船应该指向的角度,以寻找目的地点:

Vector2D direction = seek_point;
direction -= m_Ship->m_Position;
m_DesiredRotation = direction.FindAngle();
float rotate_direction = m_Ship->m_Rotation - m_DesiredRotation;

if( rotate_direction > PI ) {
    rotate_direction -= 2 * PI;
}
else if( rotate_direction < -PI ) {
    rotate_direction += 2 * PI;
}

根据我们计算的 rotate_direction 值,AI 决定将飞船向左或向右旋转:

if( rotate_direction < -0.05 ) {
    m_Ship->RotateRight();
    m_Ship->RotateRight();
}
else if( rotate_direction > 0.05 ) {
    m_Ship->RotateLeft();
    m_Ship->RotateLeft();
}

你可能想知道为什么有两次调用 RotateRight()RotateLeft()。嗯,这有点作弊。我希望敌方飞船的旋转和加速比玩家更快,所以我们调用 Rotate 函数两次,Accelerate 函数四次。你作弊的程度取决于个人喜好,以及你的作弊有多明显。一般来说,你希望你的 AI 具有挑战性,但不要太具有挑战性。一个明显作弊的 AI 会让玩家感到不快。最重要的是,如果你作弊,确保你不被抓到!

旋转后,我们通过四次调用 Accelerate() 函数结束函数。

m_Ship->Accelerate();
m_Ship->Accelerate();
m_Ship->Accelerate();
m_Ship->Accelerate();

在我们的SEEK状态之后,我们需要定义当我们处于FLEE状态时运行的函数。FLEE状态是SEEK状态的相反,即 AI 试图尽可能远离逃跑位置。在我们的FLEE状态版本中,我们作弊的少一些,但这可以根据个人口味进行更改:

void FiniteStateMachine::FleeState(Vector2D& flee_point) {
    Vector2D direction = flee_point;
    direction -= m_Ship->m_Position;
    m_DesiredRotation = direction.FindAngle();
    float rotate_direction = m_DesiredRotation - m_Ship->m_Rotation;
    rotate_direction -= PI;

    if( rotate_direction > 0 ) {
        m_Ship->RotateRight();
    }
    else {
        m_Ship->RotateLeft();
    }
    m_Ship->Accelerate();
    m_Ship->Accelerate();
}

WANDER状态是 AI 在游戏区域中徘徊的状态。如果敌方飞船没有清晰的视线到玩家飞船,就会运行这个状态。AI 会在游戏区域中四处徘徊,寻找到玩家的无阻挡路径。在WANDER状态下,飞船更有可能继续朝上次转向的方向转向,而不是选择新的方向。以下是代码:

void FiniteStateMachine::WanderState() {
    m_NextTurnMS -= delta_time;

    if( m_NextTurnMS <= 0 ) {
        bool same_turn = ( m_SameTurnPct >= rand() % 100 );
        m_NextTurnMS = c_MinRandomTurnMS + rand() % c_RandTurnMS;

        if( m_LastTurnLeft ) {
            if( same_turn ) {
                m_SameTurnPct -= 10;
                m_Ship->RotateLeft();
            }
            else {
                m_SameTurnPct = 80;
                m_Ship->RotateRight();
            }
        }
        else {
            if( same_turn ) {
                m_SameTurnPct -= 10;
                m_Ship->RotateRight();
            }
            else {
                m_SameTurnPct = 80;
                m_Ship->RotateLeft();
            }
        }
    }
    m_Ship->Accelerate();
}

Attack状态在向玩家射击时调用Seek状态:

void FiniteStateMachine::AttackState() {
    Vector2D prediction = PredictPosition();
    SeekState( prediction );
    m_Ship->Shoot();
}

为了知道我们寻找和攻击时要去哪里,我们可以直接将敌方飞船指向玩家当前的位置。如果我们能预测到我们到达那里时玩家飞船的位置会更好。我们有一个PredictPosition函数,它将使用玩家当前的速度来预测玩家的位置。以下是我们的PredictPosition函数:

Vector2D FiniteStateMachine::PredictPosition() {
    Vector2D dist = player->m_Position;
    dist -= m_Ship->m_Position;
    float mag = dist.Magnitude();
    Vector2D dir = player->m_Velocity;

    if( dir.MagSQ() > 0 ) {
        dir.Normalize();
    }
    dir *= (mag / 10);
    Vector2D prediction = player->m_Position;
    prediction += dir;
    return prediction;
}

这只是一个猜测,而且是不完美的。我们使用这个函数来预测我们将寻找和攻击的位置。如果我们正在寻找玩家,我们可能想要预测玩家将移动的距离,这将与敌方飞船和玩家飞船之间的当前距离大致相同。然而,更重要的是我们预测我们发射抛射物的位置。抛射物的移动速度比我们的飞船快得多,所以我们将敌方飞船和玩家飞船之间的距离除以 10 来进行预测。抛射物实际上并不是移动 10 倍快,但是,与我们 AI 选择的许多常数值一样,试错和外观优于实际数据。将多重值降低到 5 倍将使我们每次射击领先玩家飞船的距离加倍。将值设为 20 将使这个领先减半。当我测试 AI 时,值为 10 看起来对我来说是正确的,但你可以根据自己的口味调整这个数字。你甚至可以添加一个随机因素。

AvoidForce 函数

AvoidForce函数也有点作弊。转向行为使用避免力来防止自主代理与障碍物碰撞。如果避免力值设置得太高,敌方飞船看起来会像是被障碍物神奇地排斥。如果太低,它会直接撞上它们。我们的AvoidForce函数将寻找离我们的敌方飞船最近的障碍物,并增加敌方飞船的速度以绕过任何障碍物。以下是该函数的样子:

void FiniteStateMachine::AvoidForce() {
    Vector2D start_corner;
    Vector2D end_corner;
    Vector2D avoid_vec;
    Vector2D dist;

    float closest_square = 999999999999.0;
    float msq;
    Vector2D star_avoid;

    star_avoid.x = CANVAS_WIDTH / 2;
    star_avoid.y = CANVAS_HEIGHT / 2;
    star_avoid -= m_Ship->m_Position;

    msq = star_avoid.MagSQ();

    if( msq >= c_StarAvoidDistSQ ) {
        start_corner = m_Ship->m_Position;
        start_corner.x -= c_AvoidDist;
        start_corner.y -= c_AvoidDist;
        end_corner = m_Ship->m_Position;
        end_corner.x += c_AvoidDist;
        end_corner.y += c_AvoidDist;
        Asteroid* asteroid;
        std::vector<Asteroid*>::iterator it;
        int i = 0;

        for( it = asteroid_list.begin(); it != asteroid_list.end(); 
             it++ ) {
            asteroid = *it;
            if( asteroid->m_Active == true &&
                asteroid->SteeringRectTest( start_corner, end_corner ) ) {

                dist = asteroid->m_Position;
                dist -= m_Ship->m_Position;
                msq = dist.MagSQ();

                if( msq <= closest_square ) {
                    closest_square = msq;
                    avoid_vec = asteroid->m_Position;
                }
            }
        }

        // LOOP OVER PROJECTILES
        Projectile* projectile;
        std::vector<Projectile*>::iterator proj_it;

        for( proj_it = projectile_pool->m_ProjectileList.begin(); 
             proj_it != projectile_pool->m_ProjectileList.end(); 
             proj_it++ ) {

            projectile = *proj_it;

            if( projectile->m_Active == true &&
                projectile->SteeringRectTest( start_corner, end_corner ) 
                ) {

                dist = projectile->m_Position;
                dist -= m_Ship->m_Position;
                msq = dist.MagSQ();

                if( msq <= closest_square ) {
                    closest_square = msq;
                    avoid_vec = projectile->m_Position;
                }
            }
        }
        if( closest_square != 999999999999.0 ) {
            avoid_vec -= m_Ship->m_Position;
            avoid_vec.Normalize();
            float rot_to_obj = avoid_vec.FindAngle();

            if( std::abs( rot_to_obj - m_Ship->m_Rotation ) < 0.75 ) {
                if( rot_to_obj >= m_Ship->m_Rotation ) {
                    m_Ship->RotateLeft();
                }
                else {
                    m_Ship->RotateRight();
                }
            }
            m_Ship->m_Velocity -= avoid_vec * delta_time * 
            c_ObstacleAvoidForce;
        }
    }
    else {
        avoid_vec.x = CANVAS_WIDTH / 2;
        avoid_vec.y = CANVAS_HEIGHT / 2;
        avoid_vec -= m_Ship->m_Position;
        avoid_vec.Normalize();
        float rot_to_obj = avoid_vec.FindAngle();

        if( std::abs( rot_to_obj - m_Ship->m_Rotation ) < 0.75 ) {
            if( rot_to_obj >= m_Ship->m_Rotation ) {
                m_Ship->RotateLeft();
            }
            else {
                m_Ship->RotateRight();
            }
        }
        m_Ship->m_Velocity -= avoid_vec * delta_time * c_StarAvoidForce;
    }
}

这个函数中的第一个检查是我们离游戏区域中心的星星有多近。这颗星星是我们需要避免的最大的东西。即使我们的护盾打开,它也是唯一会摧毁我们的物体,所以 AI 需要特别确保它不会撞到星星。这个检查涉及找到游戏区域中心和敌方飞船之间的平方距离,并将该值与我们在类定义调用中设置的常数c_StarAvoidDistSQ进行比较:

if( msq >= c_StarAvoidDistSQ ) {

你可以调整c_StarAvoidDistSQ的值,以允许敌方飞船靠近或远离游戏屏幕的中心。如果我们的敌方飞船离可视游戏区域不太近,我们会查看飞船附近是否有障碍物:

if( msq >= c_StarAvoidDistSQ ) {
    start_corner = m_Ship->m_Position;
    start_corner.x -= c_AvoidDist;
    start_corner.y -= c_AvoidDist;

    end_corner = m_Ship->m_Position;
    end_corner.x += c_AvoidDist;
    end_corner.y += c_AvoidDist;

    Asteroid* asteroid;
    std::vector<Asteroid*>::iterator it;
    int i = 0;

    for( it = asteroid_list.begin(); it != asteroid_list.end(); it++ ) {
        asteroid = *it;
        if( asteroid->m_Active == true &&
            asteroid->SteeringRectTest( start_corner, end_corner ) ) {

            dist = asteroid->m_Position;
            dist -= m_Ship->m_Position;
            msq = dist.MagSQ();

            if( msq <= closest_square ) {
                closest_square = msq;
                avoid_vec = asteroid->m_Position;
            }
        }
    }
    // LOOP OVER PROJECTILES
    Projectile* projectile;
    std::vector<Projectile*>::iterator proj_it;

    for( proj_it = projectile_pool->m_ProjectileList.begin(); 
         proj_it != projectile_pool->m_ProjectileList.end(); proj_it++ 
         ) {

        projectile = *proj_it;

        if( projectile->m_Active == true &&
            projectile->SteeringRectTest( start_corner, end_corner 
            ) ) {
            dist = projectile->m_Position;
            dist -= m_Ship->m_Position;
            msq = dist.MagSQ();

            if( msq <= closest_square ) {
                closest_square = msq;
                avoid_vec = projectile->m_Position;
            }
        }
    }
    if( closest_square != 999999999999.0 ) {
        avoid_vec -= m_Ship->m_Position;
        avoid_vec.Normalize();
        float rot_to_obj = avoid_vec.FindAngle();
        if( std::abs( rot_to_obj - m_Ship->m_Rotation ) < 0.75 ) {
            if( rot_to_obj >= m_Ship->m_Rotation ) {
                m_Ship->RotateLeft();
            }
            else {
                m_Ship->RotateRight();
            }
        }
        m_Ship->m_Velocity -= avoid_vec * delta_time * 
        c_ObstacleAvoidForce;
    }
}

我们对游戏中的所有陨石和抛射物进行矩形测试。在if块的开始,我们设置了我们的矩形测试的角落:

start_corner = m_Ship->m_Position;
start_corner.x -= c_AvoidDist;
start_corner.y -= c_AvoidDist;

end_corner = m_Ship->m_Position;
end_corner.x += c_AvoidDist;
end_corner.y += c_AvoidDist;

c_AvoidDist常数在FiniteStateMachine类定义中设置,可以根据你的喜好进行更改。增加避让距离会使 AI 保持与所有抛射物的更大距离。如果设置这个值太高,你的 AI 会变得相当胆小。减小距离,AI 将容忍飞得更接近障碍物。如果太低,它会经常撞上它们。确定了用于我们的矩形测试的值后,我们循环遍历所有的小行星,寻找一个既活跃又在我们的矩形测试范围内的小行星:

Asteroid* asteroid;
std::vector<Asteroid*>::iterator it;
int i = 0;

for( it = asteroid_list.begin(); it != asteroid_list.end(); it++ ) 
{
    asteroid = *it;
    if( asteroid->m_Active == true &&
        asteroid->SteeringRectTest( start_corner, end_corner ) ) {

        dist = asteroid->m_Position;
        dist -= m_Ship->m_Position;
        msq = dist.MagSQ();

        if( msq <= closest_square ) {
             closest_square = msq;
             avoid_vec = asteroid->m_Position;
        }
    }
}

在添加避让力时,我们只避免最近的障碍物。你可以编写一个更复杂的版本,能够在我们的边界框内为几个对象添加避让力,但避免最近的障碍物效果还不错。在检查所有的小行星后,我们检查是否有一个抛射物是活跃的并且比最近的小行星更近:

    // LOOP OVER PROJECTILES
    Projectile* projectile;
    std::vector<Projectile*>::iterator proj_it;
    for( proj_it = projectile_pool->m_ProjectileList.begin(); 
         proj_it != projectile_pool->m_ProjectileList.end(); proj_it++ ) {
        projectile = *proj_it;
        if( projectile->m_Active == true &&
            projectile->SteeringRectTest( start_corner, end_corner ) ) {
            dist = projectile->m_Position;
            dist -= m_Ship->m_Position;
            msq = dist.MagSQ();

            if( msq <= closest_square ) {
                closest_square = msq;
                avoid_vec = projectile->m_Position;
            }
        }
    }

如果我们在我们的边界框中找到至少一个物体,我们希望旋转我们的飞船,使其自然地避开它,就像玩家一样,并且我们还添加一个避让力,这有点作弊。避让力根据我们在类定义中设置的常数c_ObstacleAvoidForce将我们的敌方飞船推离物体。这个值可以进行上下调整。一般来说,我喜欢保持这个值较高,冒着玩家可能意识到这是作弊的风险。你可以根据自己的喜好修改c_ObstacleAvoidForce的值:

if( closest_square != 999999999999.0 ) {
    avoid_vec -= m_Ship->m_Position;
    avoid_vec.Normalize();
    float rot_to_obj = avoid_vec.FindAngle();
    if( std::abs( rot_to_obj - m_Ship->m_Rotation ) < 0.75 ) {
        if( rot_to_obj >= m_Ship->m_Rotation ) {
            m_Ship->RotateLeft();
        }
        else {
            m_Ship->RotateRight();
        }
    }
    m_Ship->m_Velocity -= avoid_vec * delta_time * c_ObstacleAvoidForce;
}

如果敌舰离星球不太近,就会执行障碍物分支。如果物体离星球太近,代码就会跳到else块。这段代码创建了一个避让力,将飞船推离游戏区域中心。它有自己的避让力常数,我们在类定义中设置:

else {
    avoid_vec.x = CANVAS_WIDTH / 2;
    avoid_vec.y = CANVAS_HEIGHT / 2;
    avoid_vec -= m_Ship->m_Position;
    avoid_vec.Normalize();
    float rot_to_obj = avoid_vec.FindAngle();

    if( std::abs( rot_to_obj - m_Ship->m_Rotation ) < 0.75 ) {
        if( rot_to_obj >= m_Ship->m_Rotation ) {
            m_Ship->RotateLeft();
        }
        else {
            m_Ship->RotateRight();
        }
    }
    m_Ship->m_Velocity -= avoid_vec * delta_time * c_StarAvoidForce;
}

ShieldCheck函数类似于避让力函数,它检查一个边界矩形,看看我们的飞船附近是否有障碍物。然后确定飞船是否不太可能避免碰撞。无论我们的转向力有多好,有时我们都无法避免撞上小行星或抛射物。如果是这种情况,我们希望提高我们的护盾。我们不需要检查是否靠近星球,因为星球无论我们的护盾是否升起,都会杀死我们,所以在ShieldCheck函数中不需要担心这个问题:

bool FiniteStateMachine::ShieldCheck() {
    Vector2D start_corner;
    Vector2D end_corner;

    start_corner = m_Ship->m_Position;
    start_corner.x -= c_ShieldDist;
    start_corner.y -= c_ShieldDist;

    end_corner = m_Ship->m_Position;
    end_corner.x += c_ShieldDist;
    end_corner.y += c_ShieldDist;

    Asteroid* asteroid;
    std::vector<Asteroid*>::iterator it;
    int i = 0;

    for( it = asteroid_list.begin(); it != asteroid_list.end(); it++ ) {
        asteroid = *it;
        if( asteroid->m_Active &&
            asteroid->SteeringRectTest( start_corner, end_corner ) ) {
            return true;
        }
    }
    // LOOP OVER PROJECTILES
    Projectile* projectile;
    std::vector<Projectile*>::iterator proj_it;

    for( proj_it = projectile_pool->m_ProjectileList.begin(); 
         proj_it != projectile_pool->m_ProjectileList.end(); proj_it++ ) {
        projectile = *proj_it;
        if( projectile->m_Active &&
            projectile->SteeringRectTest( start_corner, end_corner ) ) {
            return true;
        }
    }
    return false;
}

就像避让力检查一样,我们用c_ShieldDist常数在我们的飞船周围设置一个边界矩形。这个值应该比避让力低。如果不是,我们将不必要地提高我们的护盾,而我们本来可以避开这个物体。就像我们 AI 中的其他一切一样,如果c_ShieldDist的值设置得太高,我们将在不需要的时候提高我们的护盾。我们的护盾使用次数有限,所以这会浪费我们本来可以在以后使用的护盾时间。如果我们将值设置得太低,我们会冒着在飞船加速向障碍物撞击之前没有机会升起护盾的风险。

LOSCheck函数是一个视线检查。这意味着它会查看敌舰和玩家舰之间是否可以画一条直线而不与任何障碍物相交。如果有清晰的视线,这个函数返回true。如果有障碍物挡住了视线,函数返回false

bool FiniteStateMachine::LOSCheck() { // LINE OF SIGHT CHECK
    // LOOP OVER ASTEROIDS
    Asteroid* asteroid;
    std::vector<Asteroid*>::iterator it;
    int i = 0;
    for( it = asteroid_list.begin(); it != asteroid_list.end(); it++ ) {
        asteroid = *it;
        if( asteroid->SteeringLineTest( m_Ship->m_Position, 
        player->m_Position ) ) {
            return false;
        }
    }

    // LOOP OVER PROJECTILES
    Projectile* projectile;
    std::vector<Projectile*>::iterator proj_it;
    for( proj_it = projectile_pool->m_ProjectileList.begin(); 
         proj_it != projectile_pool->m_ProjectileList.end(); proj_it++ ) {
        projectile = *proj_it;
        if( projectile->SteeringLineTest( m_Ship->m_Position, 
        player->m_Position ) ) {
            return false;
        }
    }
    return true;
}

我们经常需要检查的一件事是玩家到敌舰的距离。因为平方根是一个耗时的操作,我们通过检查平方距离来消除它。我们使用GetPlayerDistSq函数来获取敌舰和玩家舰之间的平方距离:

float FiniteStateMachine::GetPlayerDistSq() {
    float x_diff = m_Ship->m_Position.x - player->m_Position.x;
    float y_diff = m_Ship->m_Position.y - player->m_Position.y;
    return x_diff * x_diff + y_diff * y_diff;
}

FSM 的Move函数是我们每帧运行 AI 的函数。它执行一系列检查来确定 AI 应该处于什么状态,并执行该状态的函数。它还检查 AI 是否应该提高或降低飞船的盾牌。以下是完整的函数:

void FiniteStateMachine::Move() {
    m_CheckCycle++;
    if( m_CheckCycle == 0 ) {
        m_HasLOS = LOSCheck();
        if( !m_HasLOS ) {
            m_CurrentState = WANDER;
        }
        float player_dist_sq = 0.0f;
    }
    else if( m_CheckCycle == 1 ) {
        if( m_HasLOS ) {
            m_PlayerDistSQ = GetPlayerDistSq();
            if( m_PlayerDistSQ <= c_FleeDistSq ) {
                m_CurrentState = FLEE;
            }
            else if( m_PlayerDistSQ <= c_AttackDistSq ) {
                m_CurrentState = ATTACK;
            }
            else {
                m_CurrentState = APPROACH;
            }
        }
    }
    else {
        AvoidForce();
        m_CheckCycle = -1;
    }
    if( ShieldCheck() ) {
        m_Ship->m_Shield->Activate();
    }
    else {
        m_Ship->m_Shield->Deactivate();
    }
    if( m_CurrentState == APPROACH ) {
        Vector2D predict = PredictPosition();
        SeekState(predict);
    }
    else if( m_CurrentState == ATTACK ) {
        AttackState();
    }
    else if( m_CurrentState == FLEE ) {
        Vector2D predict = PredictPosition();
        FleeState(predict);
    }
    else if( m_CurrentState == WANDER ) {
        WanderState();
    }
}

我们使用m_CheckCycle属性来循环执行我们执行的不同状态检查,以减轻 CPU 的负担。对于这么简单的 AI 来说,这并不是真正必要的。在我们的游戏中只有一个代理执行这个 AI,但如果我们将来扩展到使用多个代理,我们可能会设置每个代理从不同的周期检查号开始,以分散我们的计算。现在,这个周期检查是为了演示目的而包含的:

m_CheckCycle++;

if( m_CheckCycle == 0 ) {
    m_HasLOS = LOSCheck();
    if( !m_HasLOS ) {
        m_CurrentState = WANDER;
    }
    float player_dist_sq = 0.0f;
}
else if( m_CheckCycle == 1 ) {
    if( m_HasLOS ) {
        m_PlayerDistSQ = GetPlayerDistSq();
        if( m_PlayerDistSQ <= c_FleeDistSq ) {
            m_CurrentState = FLEE;
        }
        else if( m_PlayerDistSQ <= c_AttackDistSq ) {
            m_CurrentState = ATTACK;
        }
        else {
            m_CurrentState = APPROACH;
        }
    }
}
else {
    AvoidForce();
    m_CheckCycle = -1;
}

如您所见,如果我们在周期 0 上,我们运行视线检查,如果我们没有视线,我们将当前状态设置为WANDER。在周期 1 上,我们查看上一帧是否有视线,如果有,我们根据敌船和玩家船之间的距离来确定我们是要接近、逃离还是攻击。在周期 2 上,我们添加任何避免力,并重置我们的检查周期属性。

然后我们每个周期执行一次盾牌检查。最初我是在每四个周期执行一次盾牌检查,但是当敌船被抛射物正面击中时,它经常被击中。因此,我改变了代码,使得每个周期执行一次盾牌检查。这就是你在游戏 AI 中最终需要做的手动调整。这需要大量的试验和错误:

if( ShieldCheck() ) {
    m_Ship->m_Shield->Activate();
}
else {
    m_Ship->m_Shield->Deactivate();
}

最后几个代码块只是一系列ifelse if语句,用于查看当前状态,并根据该状态调用适当的函数:

if( m_CurrentState == APPROACH ) {
    Vector2D predict = PredictPosition();
    SeekState(predict);
}
else if( m_CurrentState == ATTACK ) {
    AttackState();
}
else if( m_CurrentState == FLEE ) {
    Vector2D predict = PredictPosition();
    FleeState(predict);
}
else if( m_CurrentState == WANDER ) {
    WanderState();
}

编译 ai.html 文件

现在我们准备编译和测试我们的ai.html文件。这个游戏版本的屏幕截图看起来会和之前的版本有很大不同:

em++ asteroid.cpp collider.cpp emitter.cpp enemy_ship.cpp finite_state_machine.cpp main.cpp particle.cpp player_ship.cpp projectile_pool.cpp projectile.cpp range.cpp shield.cpp ship.cpp star.cpp vector.cpp -o ai.html --preload-file sprites -std=c++17 -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] 

游戏的新版本将拥有一个更大的画布,中间有小行星和一颗星星。敌人的飞船将寻找玩家并发动攻击。这是一个屏幕截图:

ai.html 的屏幕截图

请记住,您必须使用 Web 服务器或emrun来运行 WebAssembly 应用程序。如果您想使用emrun运行 WebAssembly 应用程序,您必须使用--emrun标志进行编译。Web 浏览器需要一个 Web 服务器来流式传输 WebAssembly 模块。如果您尝试直接从硬盘驱动器在浏览器中打开使用 WebAssembly 的 HTML 页面,那么 WebAssembly 模块将无法加载。

总结

在本章中,我们讨论了游戏 AI,它是什么,以及它与学术 AI 的不同之处。我们谈到了使用自主代理与自上而下的 AI,以及每种 AI 风格的好处,以及我们如何可以混合这两种风格。

我介绍了 FSM 的概念,并提到了在游戏中早期使用 FSM 的游戏,比如PAC-MAN,我们探讨了转向行为,以及我们将在游戏中用来引导代理的转向行为的种类。我们将小行星和一颗星星添加为游戏的障碍物,并增加了游戏区域的大小。我们添加了新形式的碰撞检测,以便我们的 AI 确定何时与玩家有视线。我们还添加了矩形碰撞检测,以确定是否有足够接近的障碍物供我们的 AI 使用避免力。我们将我们的Point类扩展为Vector2D类,并添加了新功能,包括投影、大小和点积计算。我们编写了一个 FSM,并用它来确定我们将使用的转向力,并在什么情况下使用。

在下一章中,我们将大大扩展我们的关卡大小,并添加一个摄像机,以便我们可以在游戏区域的更大版本中移动我们的飞船。

第十一章:设计 2D 相机

相机设计是新手游戏设计师经常忽视的事情之一。到目前为止,我们一直使用的是所谓的固定位置相机。屏幕上没有透视变化。在 20 世纪 70 年代,几乎所有早期的街机游戏都是这样设计的。我发现的最古老的带有相机的游戏是 Atari 的Lunar Lander,它于 1979 年 8 月发布。Lunar Lander是一个早期的基于矢量的游戏,当着陆器接近月球表面时,相机会放大,然后在着陆器接近表面时移动相机。

在 20 世纪 80 年代初,更多的游戏开始尝试一个比单个游戏屏幕更大的游戏世界的想法。Rally X是 Namco 于 1980 年发布的类似Pac-Man的迷宫游戏,其中迷宫比单个显示更大。Rally X使用了一个位置捕捉相机(有时称为锁定相机),无论如何都会将玩家的汽车保持在游戏屏幕的中心。这是你可以实现的最简单的 2D 滚动相机形式,许多新手游戏设计师会创建一个2D 位置捕捉相机然后就此结束,但是你可能希望在游戏中实现更复杂的相机,这是有原因的。

1981 年,Midway 发布了游戏Defender。这是一个横向卷轴射击游戏,允许玩家在任何方向移动他们的太空飞船。意识到玩家需要看到太空飞船面对的方向更多的关卡内容,Defender使用了第一个双向前置焦点相机。这个相机会移动视野区域,使得屏幕的三分之二在玩家太空飞船面对的方向前面,三分之一在后面。这更加关注了玩家当前面对的内容。相机不会在两个位置之间突然切换,那样会很令人不适。相反,当玩家改变方向时,相机位置会平稳过渡到新的位置(对于 1981 年来说相当酷)。

在 20 世纪 80 年代,许多新的相机设计开始被使用。Konami 开始在许多射击游戏中使用自动滚动相机,包括ScrambleGradius1942。1985 年,Atari 发布了Gauntlet,这是一个早期的多人游戏,允许四名玩家同时参与游戏。Gauntlet中的相机定位在所有玩家位置的平均值处。像Super Mario Bros.这样的平台游戏允许用户的位置推动相机向前移动。

你需要在构建中包含几个图像才能使这个项目工作。确保你从项目的 GitHub 中包含了/Chapter11/sprites/文件夹。如果你还没有下载 GitHub 项目,你可以在github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly上获取它。

如果你花时间寻找,会发现很多出色的 2D 相机示例。我们将专注(无意冒犯)于一些对我们的游戏有帮助的 2D 相机特性。

为我们的游戏创建相机

我们将分几个不同的阶段构建我们的摄像机。我们将从一个基本的锁定摄像机实现开始。这将为我们提供一个很好的起点,我们可以在此基础上添加新的摄像机功能。稍后,我们将修改这个摄像机,使其成为一个投影焦点摄像机。投影焦点摄像机会关注玩家飞船的速度,并调整摄像机,以便在玩家前方显示更多的游戏区域。这种技术基于这样的假设,即在这个游戏中,玩家通常更关注玩家飞船移动的方向上的游戏内容。对于我们摄像机的最终版本,我们将在我们的抛射物上添加摄像机吸引器。这种修改的想法是,当游戏中有射击时,摄像机应该吸引注意力到游戏的那个区域。

用于跟踪玩家移动的摄像机

我们摄像机的第一个实现将是一个锁定摄像机,它将锁定我们的玩家,并随着他们在关卡中移动而跟随。现在,我们的关卡和该关卡上的固定摄像机大小相同。我们不仅需要使我们的关卡更大,还需要修改我们的对象包裹,以使其与我们的摄像机配合。我们需要做的第一件事是修改我们的game.hpp文件以实现我们的锁定摄像机。我们将创建一个Camera类和一个RenderManager类,在其中移动所有我们特定于渲染的代码。我们还需要添加一些#define宏来定义我们关卡的高度和宽度,因为这将与我们已经定义的画布高度和宽度不同。我们还将向我们的Vector2D类添加一些额外的重载运算符。

投影焦点和摄像机吸引器

锁定摄像机并不是一件糟糕的事情,但更好的摄像机会显示玩家需要看到的更多内容。在我们的游戏中,玩家更有可能对他们移动方向前方的内容感兴趣。有时被称为投影焦点摄像机的摄像机会关注我们飞船当前移动的速度,并相应地调整我们的摄像机位置。

我们将采用的另一种摄像机技术称为摄像机吸引器。有时在游戏中,有一些感兴趣的对象可以用来吸引摄像机的焦点。这些对象会产生一种吸引力,会把我们的摄像机朝着那个方向拉动。我们摄像机的一个吸引力是敌人的飞船。另一个吸引力是抛射物。敌人的飞船代表潜在的行动,而抛射物代表对我们玩家的潜在威胁。在本节中,我们将结合投影焦点和摄像机吸引器来改善我们的摄像机定位。

我想要添加的最后一件事是一个指向敌人飞船的箭头。因为游戏区域现在比画布大,我们需要一个提示来帮助我们找到敌人。如果没有这个,我们可能会发现自己毫无目的地四处游荡,这并不好玩。我们还可以用小地图来实现这一点,但是因为只有一个敌人,我觉得箭头会更容易实现。让我们逐步了解我们需要添加的代码,以改善我们的摄像机并添加我们的定位箭头。

修改我们的代码

我们将需要为本章添加几个新的类。显然,如果我们想在游戏中有一个摄像头,我们将需要添加一个Camera类。在代码的先前版本中,渲染是通过直接调用 SDL 完成的。因为 SDL 没有摄像头作为 API 的一部分,我们将需要添加一个RenderManager类,作为我们渲染过程中的中间步骤。这个类将使用摄像机的位置来确定我们在画布上渲染游戏对象的位置。我们将扩大我们的游戏区域,使其为画布的四倍宽和四倍高。这会产生一个游戏问题,因为现在我们需要能够找到敌人飞船。为了解决这个问题,我们需要创建一个指向敌人飞船方向的定位器用户界面UI)元素。

修改 game.hpp 文件

让我们来看看我们将对game.hpp文件进行的更改。我们首先添加了一些#define宏:

#define LEVEL_WIDTH CANVAS_WIDTH*4
#define LEVEL_HEIGHT CANVAS_HEIGHT*4

这将定义我们的关卡的宽度和高度是画布宽度和高度的四倍。在我们的类列表的末尾,我们应该添加一个Camera类,一个Locator类和RenderManager类,如下所示:

class Ship;
class Particle;
class Emitter;
class Collider;
class Asteroid;
class Star;
class PlayerShip;
class EnemyShip;
class Projectile;
class ProjectilePool;
class FiniteStateMachine;
class Camera;
class RenderManager;
class Locator;

您会注意到最后三行声明了一个名为Camera的类,一个名为Locator的类,以及一个名为RenderManager的类将在代码中稍后定义。

Vector2D 类定义

我们将扩展我们的Vector2D类定义,为Vector2D类中的+-运算符添加operator+operator-重载。

如果您不熟悉运算符重载,这是允许类使用 C++运算符而不是函数的便捷方式。有一个很好的教程可以帮助您获取更多信息,可在www.tutorialspoint.com/cplusplus/cpp_overloading.htm找到。

以下是Vector2D类的新定义:

class Vector2D {
    public:
        float x;
        float y;

        Vector2D();
        Vector2D( float X, float Y );

        void Rotate( float radians );
        void Normalize();
        float MagSQ();
        float Magnitude();
        Vector2D Project( Vector2D &onto );
        float Dot(Vector2D &vec);
        float FindAngle();

        Vector2D operator=(const Vector2D &vec);
        Vector2D operator*(const float &scalar);
        void operator+=(const Vector2D &vec);
        void operator-=(const Vector2D &vec);
        void operator*=(const float &scalar);
        void operator/=(const float &scalar);
 Vector2D operator-(const Vector2D &vec);
 Vector2D operator+(const Vector2D &vec);
};

您会注意到定义的最后两行是新的:

Vector2D operator-(const Vector2D &vec);
Vector2D operator+(const Vector2D &vec);

Locator 类定义

Locator类是一个新的 UI 元素类,将指向玩家指向敌人飞船的箭头。当敌人飞船不出现在画布上时,我们需要一个 UI 元素来帮助玩家找到敌人飞船。以下是类定义的样子:

class Locator {
    public:
        bool m_Active = false;
        bool m_LastActive = false;
        SDL_Texture *m_SpriteTexture;
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 32, .h = 32 };
        Vector2D m_Position;
        int m_ColorFlux;
        float m_Rotation;

        Locator();
        void SetActive();
        void Move();
        void Render();
};

前两个属性是布尔标志,与定位器的活动状态有关。m_Active属性告诉我们定位器当前是否活动并应该被渲染。m_LastActive属性是一个布尔标志,告诉我们上一帧渲染时定位器是否活动。接下来的两行是精灵纹理和目标矩形,这将由渲染管理器用于渲染游戏对象:

        SDL_Texture *m_SpriteTexture;
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 32, .h = 32 };

之后,在m_Position属性中有一个xy位置值,m_ColorFlux中有一个表示 RGB 颜色值的整数,以及m_Rotation属性中的精灵旋转值。我们将使用m_ColorFlux属性使箭头的颜色在敌人靠近时更红,敌人远离时更白。

这个类定义的最后四行是类函数。有一个构造函数,一个将定位器状态设置为活动的函数,以及MoveRender函数:

        Locator();
        void SetActive();
        void Move();
        void Render();

Camera 类定义

现在我们需要添加新的Camera类定义。这个类将用于定义我们的viewport和摄像机的位置。Move函数将在每一帧中调用。最初,Move将锁定到我们玩家的位置并跟随其在关卡中移动。稍后,我们将改变这个功能以创建一个更动态的摄像机。Camera类将如下所示:

class Camera {
    public:
        Vector2D m_Position;
        float m_HalfWidth;
        float m_HalfHeight;

        Camera( float width, float height );
        void Move();
};

RenderManager 类定义

在这段时间里,我们一直在没有背景的情况下移动我们的关卡。在以前的章节中,我们的关卡恰好适合画布元素。然而,现在我们正在用相机在我们的关卡周围滚动。如果背景中没有任何东西在移动,很难判断你的飞船是否在移动。为了在我们的游戏中创造移动的幻觉,我们需要添加一个背景渲染器。除此之外,我们希望我们游戏中的所有渲染都是使用我们刚刚创建的相机作为偏移量来完成的。因此,我们不再希望我们的游戏对象直接调用SDL_RenderCopySDL_RenderCopyEx。相反,我们创建了一个RenderManager类,它将负责在我们的游戏内部执行渲染。我们有一个RenderBackground函数,它将渲染星空作为背景,并且我们创建了一个Render函数,它将使用相机作为偏移量来渲染我们的精灵纹理。这就是RenderManager类定义的样子:

class RenderManager {
    public:
        const int c_BackgroundWidth = 800;
        const int c_BackgroundHeight = 600;
        SDL_Texture *m_BackgroundTexture;
        SDL_Rect m_BackgroundDest = {.x = 0, .y = 0, .w = 
        c_BackgroundWidth, .h = c_BackgroundHeight };

        RenderManager();
        void RenderBackground();
        void Render( SDL_Texture *tex, SDL_Rect *src, SDL_Rect *dest, float 
        rad_rotation = 0.0, int alpha = 255, int red = 255, int green = 
        255, int blue = 255 );
};

game.hpp文件中我们需要做的最后一件事是创建CameraRenderManager类型的两个新对象指针的外部链接。这些将是我们在这个版本的游戏引擎中使用的相机和渲染管理器对象,并且是我们将在main.cpp文件中定义的变量的外部引用:

extern Camera* camera;
extern RenderManager* render_manager;
extern Locator* locator;

camera.cpp 文件

在我们的Camera类中我们定义了两个函数;一个是用于我们的camera对象的构造函数,另一个是Move函数,我们将用它来跟随我们的player对象。以下是我们在camera.cpp文件中的内容:

#include "game.hpp"
Camera::Camera( float width, float height ) {
    m_HalfWidth = width / 2;
    m_HalfHeight = height / 2;
}

void Camera::Move() {
    m_Position = player->m_Position;
    m_Position.x -= CANVAS_WIDTH / 2;
    m_Position.y -= CANVAS_HEIGHT / 2;
}

在这个实现中,Camera构造函数和Move函数非常简单。构造函数根据传入的宽度和高度设置相机的半宽和半高。Move函数将相机的位置设置为玩家的位置,然后将相机的位置移动画布宽度和画布高度的一半来使玩家居中。我们刚刚建立了一个起始相机,并将在本章后面添加更多功能。

render_manager.cpp 文件

我们将把我们在对象内部进行的所有呼叫渲染精灵的操作移动到RenderManager类中。我们需要这样做是因为我们将使用我们相机的位置来决定我们在画布上渲染精灵的位置。我们还需要一个函数来渲染我们的背景星空。我们render_manager.cpp文件的前几行将包括game.hpp文件,并定义我们背景图像的虚拟文件系统位置:

#include "game.hpp"
#define BACKGROUND_SPRITE_FILE (char*)"/sprites/starfield.png"

之后,我们将定义我们的构造函数。构造函数将用于将我们的starfield.png文件加载为一个SDL_Surface对象,然后使用该表面创建一个SDL_Texture对象,我们将使用它来渲染我们的背景:

RenderManager::RenderManager() {
    SDL_Surface *temp_surface = IMG_Load( BACKGROUND_SPRITE_FILE );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }

    m_BackgroundTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    if( !m_BackgroundTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }
    SDL_FreeSurface( temp_surface );
}

RenderBackground函数将需要在我们在main循环中定义的render()函数的开头被调用。因此,RenderBackground的前两行将有两个函数,我们将使用它们来清除之前在main.cpp中从render()函数调用的渲染器到黑色:

SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
SDL_RenderClear( renderer );

之后,我们将设置一个背景矩形作为我们的渲染目标。starfield.png的大小与我们的画布大小(800 x 600)相匹配,因此我们需要根据摄像头的位置渲染四次。因为这是一个重复的纹理,我们可以使用模运算符(%)在摄像头的位置上来确定我们想要如何偏移星空。举个例子,如果我们将摄像头定位在*x* = 100*y* = 200,我们希望将我们的星空背景的第一份拷贝渲染在-100-200。如果我们停在这里,我们会在画布的右侧有 100 像素的黑色空间,在画布的底部有 200 像素的黑色空间。因为我们希望在这些区域有一个背景,我们需要额外渲染三次我们的背景。如果我们在700-200处再次渲染我们的背景(在原始渲染的x值上添加画布宽度),我们现在在画布底部有一个 200 像素的黑色条。然后我们可以在-100400处渲染我们的星空(在原始渲染的y值上添加画布高度)。这样会在底角留下一个 100 x 200 像素的黑色。第四次渲染需要在原始渲染的xy值上添加画布宽度和画布高度来填补那个角落。这就是我们在RenderBackground函数中所做的,我们用它来根据摄像头的位置将重复的背景渲染到画布上:

void RenderManager::RenderBackground() {
    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );
    SDL_Rect background_rect = {.x = 0, .y=0, .w=CANVAS_WIDTH, 
                                .h=CANVAS_HEIGHT};
    int start_x = (int)(camera->m_Position.x) % CANVAS_WIDTH;
    int start_y = (int)(camera->m_Position.y) % CANVAS_HEIGHT;
    background_rect.x -= start_x;
    background_rect.y -= start_y;
    SDL_RenderCopy( renderer, m_BackgroundTexture, NULL, 
                    &background_rect );
    background_rect.x += CANVAS_WIDTH;
    SDL_RenderCopy( renderer, m_BackgroundTexture, NULL, 
                    &background_rect );
    background_rect.x -= CANVAS_WIDTH;
    background_rect.y += CANVAS_HEIGHT;
    SDL_RenderCopy( renderer, m_BackgroundTexture, NULL, 
                    &background_rect );
    background_rect.x += CANVAS_WIDTH;
    SDL_RenderCopy( renderer, m_BackgroundTexture, NULL, 
                    &background_rect );
 }

我们在render_manager.cpp中定义的最后一个函数是我们的Render函数。在定义完这个函数之后,我们需要找到我们之前在代码中调用SDL_RenderCopySDL_RenderCopyEx的每个地方,并将这些调用替换为对我们渲染管理器的Render函数的调用。这个函数不仅会根据我们摄像头的位置来渲染我们的精灵,还会用于设置颜色和 alpha 通道的修改。以下是Render函数的完整代码:


void RenderManager::Render( SDL_Texture *tex, SDL_Rect *src, SDL_Rect *dest, float rad_rotation,int alpha, int red, int green, int blue ) {

    SDL_Rect camera_dest = *dest;
    if( camera_dest.x <= CANVAS_WIDTH &&
        camera->m_Position.x >= LEVEL_WIDTH - CANVAS_WIDTH ) {
        camera_dest.x += (float)LEVEL_WIDTH;
    }
    else if( camera_dest.x >= LEVEL_WIDTH - CANVAS_WIDTH &&
             camera->m_Position.x <= CANVAS_WIDTH ) {
             camera_dest.x -= (float)LEVEL_WIDTH;
    }
    if( camera_dest.y <= CANVAS_HEIGHT &&
        camera->m_Position.y >= LEVEL_HEIGHT - CANVAS_HEIGHT ) {
        camera_dest.y += (float)LEVEL_HEIGHT;
    }
    else if( camera_dest.y >= LEVEL_HEIGHT - CANVAS_HEIGHT &&
             camera->m_Position.y <= CANVAS_HEIGHT ) {
             camera_dest.y -= (float)LEVEL_HEIGHT;
    }
    camera_dest.x -= (int)camera->m_Position.x;
    camera_dest.y -= (int)camera->m_Position.y;

    SDL_SetTextureAlphaMod(tex,
                           (Uint8)alpha );

    SDL_SetTextureColorMod(tex,
                            (Uint8)red,
                            (Uint8)green,
                            (Uint8)blue );

    if( rad_rotation != 0.0 ) {
        float degree_rotation = RAD_TO_DEG(rad_rotation);
        SDL_RenderCopyEx( renderer, tex, src, &camera_dest,
                          degree_rotation, NULL, SDL_FLIP_NONE );
    }
    else {
        SDL_RenderCopy( renderer, tex, src, &camera_dest );
    }
}

这个函数的第一步是创建一个新的SDL_Rect对象,我们将用它来修改传递给Render函数的dest变量中的值。因为我们有一个包裹xy坐标的级别,所以我们希望在级别的最左边渲染对象时,如果我们在级别的最右边,我们将希望将对象渲染到右边。同样,如果我们在级别的最左边,我们将希望将位于级别最右边的对象渲染到右边。这样可以使我们的飞船从级别的左侧环绕到级别的右侧,反之亦然。以下是调整摄像头位置以包裹级别左右对象的代码:

if( camera_dest.x <= CANVAS_WIDTH &&
    camera->m_Position.x >= LEVEL_WIDTH - CANVAS_WIDTH ) {
    camera_dest.x += (float)LEVEL_WIDTH;
}
else if( camera_dest.x >= LEVEL_WIDTH - CANVAS_WIDTH &&
         camera->m_Position.x <= CANVAS_WIDTH ) {
    camera_dest.x -= (float)LEVEL_WIDTH;
}

完成这些之后,我们将做类似的事情,以便在级别的顶部和底部包裹对象的位置:

if( camera_dest.y <= CANVAS_HEIGHT &&
    camera->m_Position.y >= LEVEL_HEIGHT - CANVAS_HEIGHT ) {
    camera_dest.y += (float)LEVEL_HEIGHT;
}
else if( camera_dest.y >= LEVEL_HEIGHT - CANVAS_HEIGHT &&
         camera->m_Position.y <= CANVAS_HEIGHT ) {
    camera_dest.y -= (float)LEVEL_HEIGHT;
}

接下来,我们需要从camera_destxy坐标中减去摄像头的位置,并设置我们的alphacolor修改的值:

camera_dest.x -= (int)camera->m_Position.x;
camera_dest.y -= (int)camera->m_Position.y;
SDL_SetTextureAlphaMod(tex,
                        (Uint8)alpha );

SDL_SetTextureColorMod(tex,
                       (Uint8)red,
                       (Uint8)green,
                       (Uint8)blue );

在函数的结尾,如果我们的精灵被旋转,我们将调用SDL_RenderCopyEx,如果没有旋转,我们将调用SDL_RenderCopy

if( rad_rotation != 0.0 ) {
    float degree_rotation = RAD_TO_DEG(rad_rotation);
    SDL_RenderCopyEx( renderer, tex, src, &camera_dest,
                      degree_rotation, NULL, SDL_FLIP_NONE );
}
else {
    SDL_RenderCopy( renderer, tex, src, &camera_dest );
}

修改 main.cpp

为了实现我们的摄像头,我们需要对main.cpp文件进行一些修改。我们需要为我们的摄像头、渲染管理器和定位器添加一些新的全局变量。我们需要修改我们的move函数,以包括移动我们的摄像头和定位器的调用。我们将修改我们的render函数来渲染我们的背景和定位器。最后,我们需要在我们的main函数中添加更多的初始化代码。

新的全局变量

我们需要在main.cpp文件的开头附近创建三个新的全局变量。我们将需要指向RenderManagerCameraLocator的对象指针。这是这些声明的样子:

Camera* camera;
RenderManager* render_manager;
Locator* locator;

修改 move 函数

我们需要修改我们的move函数来移动我们的摄像头和我们的定位器对象。我们需要在我们的move函数的结尾添加以下两行:

 camera->Move();
 locator->Move();

以下是move函数的全部内容:

void move() {
    player->Move();
    enemy->Move();
    projectile_pool->MoveProjectiles();
    Asteroid* asteroid;
    std::vector<Asteroid*>::iterator it;
    int i = 0;

    for( it = asteroid_list.begin(); it != asteroid_list.end(); it++ ) {
        asteroid = *it;
        if( asteroid->m_Active ) {
            asteroid->Move();
        }
    }
    star->Move();
    camera->Move();
    locator->Move();
}

修改渲染函数

我们将在render函数的开头添加一行新代码。这行代码将渲染背景星空,并根据摄像机位置移动它:

 render_manager->RenderBackground();

之后,我们需要在render函数的末尾添加一行代码。这行代码需要立即出现在SDL_RenderPresent调用之前,而SDL_RenderPresent调用仍然需要是该函数中的最后一行:

 locator->Render();

以下是render()函数的全部内容:

void render() {
 render_manager->RenderBackground();
    player->Render();
    enemy->Render();
    projectile_pool->RenderProjectiles();

    Asteroid* asteroid;
    std::vector<Asteroid*>::iterator it;
    for( it = asteroid_list.begin(); it != asteroid_list.end(); it++ ) {
        asteroid = *it;
        asteroid->Render();
    }
    star->Render();
 locator->Render();

    SDL_RenderPresent( renderer );
}

修改主函数

最后的修改将是在main函数中发生的初始化。我们需要为之前定义的camerarender_managerlocator指针创建新对象:

camera = new Camera(CANVAS_WIDTH, CANVAS_HEIGHT);
render_manager = new RenderManager();
locator = new Locator();

在我们的代码的先前版本中,我们有七个调用new Asteroid并使用asteroid_list.push_back将这七个新小行星推入我们的小行星列表中。现在我们需要创建比七个更多的小行星,所以我们将使用双重for循环来创建并分散我们的小行星遍布整个游戏区域。为此,我们首先需要删除所有那些早期的调用来创建和推入小行星:

asteroid_list.push_back( new Asteroid(
                            200, 50, 0.05, 
                            DEG_TO_RAD(10) ) );
asteroid_list.push_back( new Asteroid(
                            600, 150, 0.03, 
                            DEG_TO_RAD(350) ) );
asteroid_list.push_back( new Asteroid(
                            150, 500, 0.05, 
                            DEG_TO_RAD(260) ) );
asteroid_list.push_back( new Asteroid(
                            450, 350, 0.01, 
                            DEG_TO_RAD(295) ) );
asteroid_list.push_back( new Asteroid(
                            350, 300, 0.08, 
                            DEG_TO_RAD(245) ) );
asteroid_list.push_back( new Asteroid(
                            700, 300, 0.09, 
                            DEG_TO_RAD(280) ) );
asteroid_list.push_back( new Asteroid(
                            200, 450, 0.03, 
                            DEG_TO_RAD(40) ) );

一旦您删除了所有前面的代码,我们将添加以下代码来创建新的小行星,并在整个游戏区域中将它们半随机地分布:

int asteroid_x = 0;
int asteroid_y = 0;
int angle = 0;

// SCREEN 1
for( int i_y = 0; i_y < 8; i_y++ ) {
    asteroid_y += 100;
    asteroid_y += rand() % 400;
    asteroid_x = 0;

    for( int i_x = 0; i_x < 12; i_x++ ) {
        asteroid_x += 66;
        asteroid_x += rand() % 400;
        int y_save = asteroid_y;
        asteroid_y += rand() % 400 - 200;
        angle = rand() % 359;
        asteroid_list.push_back( new Asteroid(
                        asteroid_x, asteroid_y,
                        get_random_float(0.5, 1.0),
                        DEG_TO_RAD(angle) ) );
        asteroid_y = y_save;
    }
}

修改 asteroid.cpp

现在我们正在使用渲染管理器来渲染所有游戏对象,我们需要遍历各种游戏对象并修改它们以通过渲染管理器而不是直接渲染。我们将首先修改asteroid.cpp文件。在asteroid.cpp中,我们有Asteroid::Render()函数。在之前的章节中,这个函数会直接通过 SDL 渲染小行星精灵,使用SDL_RenderCopyEx调用。现在我们有了在main.cpp文件中定义的render_manager对象,我们将使用该渲染管理器间接地渲染我们的精灵。RenderManager::Render函数将使用摄像机来调整在画布上渲染精灵的位置。我们需要对Asteroid::Render()函数进行的第一个修改是删除以下行:

 SDL_RenderCopyEx( renderer, m_SpriteTexture, 
                   &m_src, &m_dest, 
                   RAD_TO_DEG(m_Rotation), NULL, SDL_FLIP_NONE );

删除对SDL_RenderCopyEX的调用后,我们需要在render_manager对象的Render函数中添加以下调用:

 render_manager->Render( m_SpriteTexture, &m_src, &m_dest, m_Rotation );

Asteroid::Render函数的新版本现在看起来像这样:

void Asteroid::Render() {
    m_Explode->Move();
    m_Chunks->Move();
    if( m_Active == false ) {
        return;
    }
    m_src.x = m_dest.w * m_CurrentFrame;
    m_dest.x = m_Position.x + m_Radius / 2;
    m_dest.y = m_Position.y + m_Radius / 2;
    render_manager->Render( m_SpriteTexture, &m_src, &m_dest, m_Rotation );
}

修改 collider.cpp

我们需要修改collider.cpp文件中的一个函数。WrapPosition函数的先前版本检查Collider对象是否移出画布的一侧,如果是,则该函数将移动碰撞器到相反的一侧。这模仿了经典的 Atari 街机游戏Asteroids的行为。在 Atari Asteroids中,如果一个小行星或玩家的太空船从屏幕的一侧移出,那个小行星(或太空船)将出现在游戏屏幕的对面。这是我们wrap代码的先前版本:

void Collider::WrapPosition() {
    if( m_Position.x > CANVAS_WIDTH + m_Radius ) {
        m_Position.x = -m_Radius;
    }
    else if( m_Position.x < -m_Radius ) {
        m_Position.x = CANVAS_WIDTH;
    }

    if( m_Position.y > CANVAS_HEIGHT + m_Radius ) {
        m_Position.y = -m_Radius;
    }
    else if( m_Position.y < -m_Radius ) {
        m_Position.y = CANVAS_HEIGHT;
    }
}

因为我们的游戏现在扩展到超出单个画布,所以我们不再希望在对象移出画布时进行包装。相反,我们希望在对象超出级别的边界时将其包装。这是WrapPosition函数的新版本:

void Collider::WrapPosition() {
    if( m_Position.x > LEVEL_WIDTH ) {
        m_Position.x -= LEVEL_WIDTH;
    }
    else if( m_Position.x < 0 ) {
        m_Position.x += LEVEL_WIDTH;
    }

    if( m_Position.y > LEVEL_HEIGHT ) {
        m_Position.y -= LEVEL_HEIGHT;
    }
    else if( m_Position.y < 0 ) {
        m_Position.y += LEVEL_HEIGHT;
    }
}

修改 enemy_ship.cpp

需要对enemy_ship.cpp文件进行一些小修改。EnemyShip构造函数现在将设置m_Position属性上的xy值。我们需要将位置设置为810800,因为级别现在比画布大小大得多。我们将在EnemyShip构造函数的最顶部设置m_Position属性。在更改后,构造函数的开头将如下所示:

EnemyShip::EnemyShip() {
    m_Position.x = 810.0;
    m_Position.y = 800.0;

修改 finite_state_machine.cpp

我们需要对finite_state_machine.cpp文件进行小的修改。在FiniteStateMachine::AvoidForce()函数内部,有几个引用画布尺寸的地方必须更改为引用级别尺寸,因为我们的级别尺寸和画布尺寸不同。以前,我们将star_avoid变量的xy属性设置为以下基于画布的值:

star_avoid.x = CANVAS_WIDTH / 2;
star_avoid.y = CANVAS_HEIGHT / 2;

这些行必须更改为引用LEVEL_WIDTHLEVEL_HEIGHT

star_avoid.x = LEVEL_WIDTH / 2;
star_avoid.y = LEVEL_HEIGHT / 2;

我们必须对avoid_vec变量做同样的事情。这是我们以前的内容:

avoid_vec.x = CANVAS_WIDTH / 2;
avoid_vec.y = CANVAS_HEIGHT / 2;

这也必须更改为引用LEVEL_WIDTHLEVEL_HEIGHT

avoid_vec.x = LEVEL_WIDTH / 2;
avoid_vec.y = LEVEL_HEIGHT / 2;

FiniteState::AvoidForce函数的新版本完整内容如下:

void FiniteStateMachine::AvoidForce() {
    Vector2D start_corner;
    Vector2D end_corner;
    Vector2D avoid_vec;
    Vector2D dist;
    float closest_square = 999999999999.0;
    float msq;
    Vector2D star_avoid;
 star_avoid.x = LEVEL_WIDTH / 2;
 star_avoid.y = LEVEL_HEIGHT / 2;
    star_avoid -= m_Ship->m_Position;
    msq = star_avoid.MagSQ();

    if( msq >= c_StarAvoidDistSQ ) {
        start_corner = m_Ship->m_Position;
        start_corner.x -= c_AvoidDist;
        start_corner.y -= c_AvoidDist;
        end_corner = m_Ship->m_Position;
        end_corner.x += c_AvoidDist;
        end_corner.y += c_AvoidDist;

        Asteroid* asteroid;
        std::vector<Asteroid*>::iterator it;

        int i = 0;
        for( it = asteroid_list.begin(); it != asteroid_list.end(); it++ ) {
            asteroid = *it;
            if( asteroid->m_Active == true &&
                asteroid->SteeringRectTest( start_corner, end_corner ) ) {
                dist = asteroid->m_Position;
                dist -= m_Ship->m_Position;
                msq = dist.MagSQ();

                if( msq <= closest_square ) {
                    closest_square = msq;
                    avoid_vec = asteroid->m_Position;
                }
            }
        }
        // LOOP OVER PROJECTILES
        Projectile* projectile;
        std::vector<Projectile*>::iterator proj_it;

        for( proj_it = projectile_pool->m_ProjectileList.begin(); 
             proj_it != projectile_pool->m_ProjectileList.end(); proj_it++ ) {
            projectile = *proj_it;
            if( projectile->m_Active == true &&
                projectile->SteeringRectTest( start_corner, end_corner ) ) {
                dist = projectile->m_Position;
                dist -= m_Ship->m_Position;
                msq = dist.MagSQ();

                if( msq <= closest_square ) {
                    closest_square = msq;
                    avoid_vec = projectile->m_Position;
                }
            }
        }
        if( closest_square != 999999999999.0 ) {
            avoid_vec -= m_Ship->m_Position;
            avoid_vec.Normalize();
            float rot_to_obj = avoid_vec.FindAngle();

            if( std::abs( rot_to_obj - m_Ship->m_Rotation ) < 0.75 ) {
                if( rot_to_obj >= m_Ship->m_Rotation ) {
                    m_Ship->RotateLeft();
                }
                else {
                    m_Ship->RotateRight();
                }
            }
            m_Ship->m_Velocity -= avoid_vec * delta_time * 
            c_ObstacleAvoidForce;
        }
    }
    else {
        avoid_vec.x = LEVEL_WIDTH / 2;
 avoid_vec.y = LEVEL_HEIGHT / 2;
        avoid_vec -= m_Ship->m_Position;
        avoid_vec.Normalize();
        float rot_to_obj = avoid_vec.FindAngle();
        if( std::abs( rot_to_obj - m_Ship->m_Rotation ) < 0.75 ) {
            if( rot_to_obj >= m_Ship->m_Rotation ) {
                m_Ship->RotateLeft();
            }
            else {
                m_Ship->RotateRight();
            }
        }
        m_Ship->m_Velocity -= avoid_vec * delta_time * c_StarAvoidForce; 
    }
}

修改 particle.cpp

我们需要修改particle.cpp文件中的Render函数,以便通过render_manager而不是直接通过调用 SDL 来渲染粒子。Particle::Render函数的旧版本如下:

void Particle::Render() {
    SDL_SetTextureAlphaMod(m_sprite_texture,
                            (Uint8)m_alpha );

    if( m_color_mod == true ) {
        SDL_SetTextureColorMod(m_sprite_texture,
                                m_current_red,
                                m_current_green,
                                m_current_blue );
    }

    if( m_align_rotation == true ) {
        SDL_RenderCopyEx( renderer, m_sprite_texture, &m_src, &m_dest, 
                            m_rotation, NULL, SDL_FLIP_NONE );
    }
    else {
        SDL_RenderCopy( renderer, m_sprite_texture, &m_src, &m_dest );
    }
}

新的Particle::Render函数将通过render_manager对象对Render函数进行一次调用:

void Particle::Render() {
 render_manager->Render( m_sprite_texture, &m_src, &m_dest, m_rotation,
 m_alpha, m_current_red, m_current_green, m_current_blue );
}

修改 player_ship.cpp

我们需要对player_ship.cpp文件进行一些小的修改。与我们对enemy_ship.cpp文件所做的更改一样,我们需要添加两行来设置m_Position属性中的xy值。

我们需要删除PlayerShip::PlayerShip()构造函数的前两行:

m_Position.x = CANVAS_WIDTH - 210.0;
m_Position.y = CANVAS_HEIGHT - 200.0;

这些是我们需要对PlayerShip::PlayerShip()构造函数进行的更改:

PlayerShip::PlayerShip() {
 m_Position.x = LEVEL_WIDTH - 810.0;
 m_Position.y = LEVEL_HEIGHT - 800.0;

修改 projectile.cpp

我们需要对projectile.cpp文件进行一些小的修改。与其他游戏对象一样,Render函数以前直接调用 SDL 函数来渲染游戏对象。我们需要通过render_manager对象进行调用,而不是直接调用 SDL。我们需要从Projectile::Render()函数中删除以下行:

int return_val = SDL_RenderCopy( renderer, m_SpriteTexture, 
                                 &src, &dest );
if( return_val != 0 ) {
    printf("SDL_Init failed: %s\n", SDL_GetError());
}

我们需要在render_manager对象上添加一个对Render函数的调用来替换这些行:

 render_manager->Render( m_SpriteTexture, &src, &dest );

Projectile::Render()函数的新版本将如下所示:

void Projectile::Render() {
    dest.x = m_Position.x + 8;
    dest.y = m_Position.y + 8;
    dest.w = c_Width;
    dest.h = c_Height;

    src.x = 16 * m_CurrentFrame;

 render_manager->Render( m_SpriteTexture, &src, &dest );
}

修改 shield.cpp

与许多其他游戏对象一样,Shield::Render()函数将需要修改,以便不再直接调用 SDL,而是调用render_manager对象的Render函数。在Shield::Render()函数内部,我们需要删除对 SDL 的以下调用:

SDL_SetTextureColorMod(m_SpriteTexture,
                        color_red,
                        color_green,
                        0 );

SDL_RenderCopyEx( renderer, m_SpriteTexture, 
                    &m_src, &m_dest, 
                    RAD_TO_DEG(m_Ship->m_Rotation), 
                    NULL, SDL_FLIP_NONE );

我们将用一个对Render的单一调用来替换这些行:

render_manager->Render( m_SpriteTexture, &m_src, &m_dest, m_Ship->m_Rotation,
                        255, color_red, color_green, 0 );

这是Shield::Render函数的新版本的完整内容:

void Shield::Render() {
    if( m_Active ) {
        int color_green = m_ttl / 100 + 1;
        int color_red = 255 - color_green;

        m_src.x = m_CurrentFrame * m_dest.w;

        m_dest.x = m_Ship->m_Position.x;
        m_dest.y = m_Ship->m_Position.y;
 render_manager->Render( m_SpriteTexture, &m_src, &m_dest, m_Ship->m_Rotation,
 255, color_red, color_green, 0 );
    }
}

修改 ship.cpp

修改我们游戏对象内的Render函数变得相当常规。与我们修改了Render函数的其他对象一样,我们需要删除所有直接调用 SDL 的部分。这是我们需要从Render函数中删除的代码:

float degrees = (m_Rotation / PI) * 180.0;
int return_code = SDL_RenderCopyEx( renderer, m_SpriteTexture, 
                                    &src, &dest, 
                                    degrees, NULL, SDL_FLIP_NONE );
if( return_code != 0 ) {
    printf("failed to render image: %s\n", IMG_GetError() );
}

删除这些行后,我们需要添加一行调用render_manager->Render函数:

 render_manager->Render( m_SpriteTexture, &src, &dest, m_Rotation );

修改 star.cpp

我们需要修改star.cpp文件内的两个函数。首先,我们需要修改Star::Star()构造函数中星星的位置。在上一章的Star构造函数版本中,我们将星星的位置设置为画布的中间。现在,它必须设置为级别的中间。以下是原始版本构造函数中的行:

m_Position.x = CANVAS_WIDTH / 2;
m_Position.y = CANVAS_HEIGHT / 2;

现在,我们将更改这些位置,使其相对于LEVEL_WIDTHLEVEL_HEIGHT而不是CANVAS_WIDTHCANVAS_HEIGHT

m_Position.x = LEVEL_WIDTH / 2;
m_Position.y = LEVEL_HEIGHT / 2;

在对Star::Star构造函数进行上述更改后,我们需要对Star::Render函数进行更改。我们需要删除对SDL_RenderCopy的调用,并将其替换为对render_manager对象上的Render函数的调用。这是以前版本的Render函数的样子:

void Star::Render() {
    Emitter* flare;
    std::vector<Emitter*>::iterator it;
    for( it = m_FlareList.begin(); it != m_FlareList.end(); it++ ) {
        flare = *it;
        flare->Move();
    }
    m_src.x = m_dest.w * m_CurrentFrame;
    SDL_RenderCopy( renderer, m_SpriteTexture, 
                    &m_src, &m_dest );
}

我们将修改为以下内容:

void Star::Render() {
    Emitter* flare;
    std::vector<Emitter*>::iterator it;
    for( it = m_FlareList.begin(); it != m_FlareList.end(); it++ ) {
        flare = *it;
        flare->Move();
    }
    m_src.x = m_dest.w * m_CurrentFrame;
    render_manager->Render( m_SpriteTexture, &m_src, &m_dest );
}

修改 vector.cpp

我们需要向我们的Vector2D类添加两个新的重载运算符。我们需要重载operator-operator+。这段代码非常简单。它将使用已经重载的operator-=operator+=来允许我们对彼此的向量进行加法和减法。以下是这些重载运算符的新代码:

Vector2D Vector2D::operator-(const Vector2D &vec) {
 Vector2D return_vec = *this;
 return_vec -= vec;
 return return_vec;
}

Vector2D Vector2D::operator+(const Vector2D &vec) {
 Vector2D return_vec = *this;
 return_vec += vec;
 return return_vec;
}

编译并使用锁定摄像头进行游戏

如果我们现在编译和测试我们所拥有的东西,我们应该能够在我们的关卡中移动并看到一个直接跟踪玩家位置的摄像头。我们应该有一个定位箭头,帮助我们找到敌人的太空船。以下是我们可以用来构建项目的 Emscripten 的命令行调用:

em++ asteroid.cpp camera.cpp collider.cpp emitter.cpp enemy_ship.cpp finite_state_machine.cpp locator.cpp main.cpp particle.cpp player_ship.cpp projectile_pool.cpp projectile.cpp range.cpp render_manager.cpp shield.cpp ship.cpp star.cpp vector.cpp -o index.html --preload-file sprites -std=c++17 -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] 

在 Windows 或 Linux 命令提示符上运行上述命令。运行后,从 Web 服务器提供index.html文件,并在 Chrome 或 Firefox 等浏览器中打开它。

更高级的摄像头

我们当前的摄像头是功能性的,但有点无聊。它专注于玩家,这样做还行,但可以显著改进。首先,正如Defender的设计者意识到的那样,将摄像头的焦点放在玩家移动的方向上更为重要,而不是直接对准玩家。为了实现这一点,我们将在我们的摄像头中添加投影焦点。它将查看玩家飞船的当前速度,并将摄像头向前移动到该速度的方向。然而,有时您可能仍希望摄像头的焦点在玩家后面。为了帮助解决这个问题,我们将添加一些摄像头吸引器。摄像头吸引器是吸引摄像头注意力的对象。如果敌人出现在玩家后面,将摄像头稍微移回以帮助保持敌人在屏幕上。如果敌人向你射击,将摄像头吸引到向你飞来的弹丸可能更为重要。

对 games.hpp 的更改

我们需要做的第一个更改是修改我们的games.hpp文件。让摄像头跟随我们的玩家很容易。摄像头没有任何抖动或突然移动,因为玩家的飞船不是那样移动的。如果我们要使用更高级的功能,比如吸引器和前置焦点,我们需要计算摄像头的期望位置,然后平稳过渡到该位置。为了支持这一点,我们需要在我们的Camera类中添加一个m_DesiredPosition属性。以下是我们必须添加的新行:

 Vector2D m_DesiredPosition;

这是我们在添加了期望位置属性后games.hpp文件中的Camera类的样子:

class Camera {
    public:
        Vector2D m_Position;
 Vector2D m_DesiredPosition;

        float m_HalfWidth;
        float m_HalfHeight;

        Camera( float width, float height );
        void Move();
};

对 camera.cpp 的更改

现在我们已经在类定义中添加了期望位置属性,我们需要更改我们的camera.cpp文件。我们需要修改构造函数,将摄像头的位置设置为玩家飞船的位置。以下是我们需要添加到构造函数的行:

m_Position = player->m_Position;
m_Position.x -= CANVAS_WIDTH / 2;
m_Position.y -= CANVAS_HEIGHT / 2;

在我们添加了这些行之后,构造函数如下:

Camera::Camera( float width, float height ) {
    m_HalfWidth = width / 2;
    m_HalfHeight = height / 2;

 m_Position = player->m_Position;
 m_Position.x -= CANVAS_WIDTH / 2;
 m_Position.y -= CANVAS_HEIGHT / 2;
}

我们的Camera::Move函数将完全不同。你可能要删除当前版本的Camera::Move中的所有代码行,因为它们都不再有用。我们的新期望位置属性将在Move函数的开头设置,就像之前设置位置一样。为此,请在您通过删除该函数中的所有内容创建的空版本的Camera::Move中添加以下行:

m_DesiredPosition = player->m_Position;
m_DesiredPosition.x -= CANVAS_WIDTH / 2;
m_DesiredPosition.y -= CANVAS_HEIGHT / 2;

如果玩家死亡,我们希望我们的摄像头停留在这个位置。玩家死亡后,我们不希望任何吸引器影响摄像头的位置。在玩家死亡后过度移动玩家摄像头看起来有点奇怪,因此添加以下代码行,检查玩家飞船是否活跃,如果不活跃,则将摄像头的位置移向期望位置,然后从Move函数返回:

if( player->m_Active == false ) {
    m_Position.x = m_Position.x + (m_DesiredPosition.x - m_Position.x) 
    * delta_time;
    m_Position.y = m_Position.y + (m_DesiredPosition.y - m_Position.y) 
    * delta_time;
    return;
}

我们将使游戏中的所有活动抛射物成为吸引器。如果敌人向我们射击,它对我们的飞船构成威胁,因此应该吸引摄像头的注意。如果我们射出抛射物,这也表明了我们的关注方向。我们将使用for循环来遍历游戏中的所有抛射物,如果该抛射物是活动的,我们将使用它的位置来移动摄像头的期望位置。以下是代码:

Projectile* projectile;
std::vector<Projectile*>::iterator it;
Vector2D attractor;
for( it = projectile_pool->m_ProjectileList.begin(); it != projectile_pool->m_ProjectileList.end(); it++ ) {
    projectile = *it;
    if( projectile->m_Active ) {
        attractor = projectile->m_Position;
        attractor -= player->m_Position;
        attractor.Normalize();
        attractor *= 5;
        m_DesiredPosition += attractor;
    }
}

在使用吸引器来移动摄像头的期望位置后,我们将根据玩家飞船的速度修改m_DesiredPosition变量,使用以下代码行:

m_DesiredPosition += player->m_Velocity * 2;

由于我们的关卡是环绕的,如果您从关卡的一侧退出,您会重新出现在另一侧,我们需要调整摄像头的期望位置以适应这一点。如果没有以下代码行,当玩家移出关卡边界并出现在另一侧时,摄像头会突然发生剧烈的转变:

if( abs(m_DesiredPosition.x - m_Position.x) > CANVAS_WIDTH ) {
    if( m_DesiredPosition.x > m_Position.x ) {
        m_Position.x += LEVEL_WIDTH;
    }
    else {
        m_Position.x -= LEVEL_WIDTH;
    }
}
if( abs(m_DesiredPosition.y - m_Position.y) > CANVAS_HEIGHT ) {
    if( m_DesiredPosition.y > m_Position.y ) {
        m_Position.y += LEVEL_HEIGHT;
    }
    else {
        m_Position.y -= LEVEL_HEIGHT;
    }
}

最后,我们将添加几行代码,使摄像头的当前位置平稳过渡到期望的位置。我们使用delta_time使这个过渡大约需要一秒钟。直接设置摄像头位置而不使用期望位置和过渡会导致新吸引器进入游戏时出现抖动。以下是过渡代码:

m_Position.x = m_Position.x + (m_DesiredPosition.x - m_Position.x) * 
delta_time;
m_Position.y = m_Position.y + (m_DesiredPosition.y - m_Position.y) * 
delta_time;

现在我们已经分别看到了Move函数的所有行,让我们来看一下函数的完成新版本:

void Camera::Move() {
    m_DesiredPosition = player->m_Position;
    m_DesiredPosition.x -= CANVAS_WIDTH / 2;
    m_DesiredPosition.y -= CANVAS_HEIGHT / 2;

    if( player->m_Active == false ) {
        m_Position.x = m_Position.x + (m_DesiredPosition.x - m_Position.x) 
        * delta_time;
        m_Position.y = m_Position.y + (m_DesiredPosition.y - m_Position.y) 
        * delta_time;
        return;
    }

    Projectile* projectile;
    std::vector<Projectile*>::iterator it;
    Vector2D attractor;

    for( it = projectile_pool->m_ProjectileList.begin(); 
        it != projectile_pool->m_ProjectileList.end(); it++ ) {
        projectile = *it;
            if( projectile->m_Active ) {
            attractor = projectile->m_Position;
            attractor -= player->m_Position;
            attractor.Normalize();
            attractor *= 5;
            m_DesiredPosition += attractor;
        }
    }
    m_DesiredPosition += player->m_Velocity * 2;

    if( abs(m_DesiredPosition.x - m_Position.x) > CANVAS_WIDTH ) {
        if( m_DesiredPosition.x > m_Position.x ) {
            m_Position.x += LEVEL_WIDTH;
        }
        else {
            m_Position.x -= LEVEL_WIDTH;
        }
    }

    if( abs(m_DesiredPosition.y - m_Position.y) > CANVAS_HEIGHT ) {
        if( m_DesiredPosition.y > m_Position.y ) {
            m_Position.y += LEVEL_HEIGHT;
        }
        else {
            m_Position.y -= LEVEL_HEIGHT;
        }
    }

    m_Position.x = m_Position.x + (m_DesiredPosition.x - m_Position.x) * 
    delta_time;
    m_Position.y = m_Position.y + (m_DesiredPosition.y - m_Position.y) * 
    delta_time;
}

编译并玩弄高级摄像头

当您构建了这个版本后,您会注意到摄像头会朝着您的飞船移动的方向前进。如果您开始射击,它会进一步向前移动。当敌方飞船靠近并向您射击时,摄像头也应该朝着这些抛射物的方向漂移。与以前一样,您可以通过在 Windows 或 Linux 命令提示符中输入以下行来编译和测试代码:

em++ asteroid.cpp camera.cpp collider.cpp emitter.cpp enemy_ship.cpp finite_state_machine.cpp locator.cpp main.cpp particle.cpp player_ship.cpp projectile_pool.cpp projectile.cpp range.cpp render_manager.cpp shield.cpp ship.cpp star.cpp vector.cpp -o camera.html --preload-file sprites -std=c++17 -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"]

现在我们已经有了我们应用程序的编译版本,我们应该运行它。新版本应该看起来像这样:

图 11.1:添加了分割屏幕的新摄像头版本

正如您所看到的,摄像头并没有将玩家的飞船置于中心。摄像头的焦点主要是根据玩家飞船的速度投影在前方,由于敌方飞船和抛射物的原因稍微向右上方拖动。

不要忘记,您必须使用 Web 服务器或emrun来运行 WebAssembly 应用程序。如果您想使用emrun运行 WebAssembly 应用程序,您必须使用--emrun标志进行编译。Web 浏览器需要一个 Web 服务器来流式传输 WebAssembly 模块。如果您尝试直接从硬盘驱动器上的浏览器打开使用 WebAssembly 的 HTML 页面,那么 WebAssembly 模块将无法加载。

总结

我们开始本章是通过了解视频游戏中摄像头的历史。我们讨论的第一个摄像头是最简单的摄像头类型,有时被称为锁定摄像头。这是一种精确跟踪玩家位置的摄像头。之后,我们了解了 2D 空间中锁定摄像头的替代方案,包括引导玩家的摄像头。我们谈到了投影焦点摄像头,以及它们如何预测玩家的移动并根据玩家移动的方向向前投影摄像头的位置。然后我们讨论了摄像头吸引器,以及它们如何吸引摄像头的焦点到感兴趣的对象。在讨论了摄像头类型之后,我们创建了一个摄像头对象,并设计它来实现投影焦点和摄像头吸引器。我们实现了一个渲染管理器,并修改了所有的游戏对象,使其通过RenderManager类进行渲染。然后我们创建了一个locator对象,以帮助我们在画布上找到敌方飞船。

在下一章中,我们将学习如何为我们的游戏添加音效。

第十二章:音效

网络上的音频当前处于一种混乱状态,而且已经有一段时间了。很长一段时间以来,根据您使用的浏览器的不同,加载 MP3 与 OGG 文件存在问题。最近,浏览器阻止自动播放声音以防止令人讨厌的音频垃圾的问题。Chrome 中的这一功能有时似乎会在我们的游戏中播放音频时出现问题。我注意到,如果 Chrome 最初没有播放音频,通常在重新加载页面后就会播放。我在 Firefox 上没有遇到这个问题。

您需要在构建中包含几个图像和音频文件才能使该项目正常工作。确保您从项目的 GitHub 中包含/Chapter12/sprites/文件夹以及/Chapter12/audio/文件夹。如果您还没有下载 GitHub 项目,可以在github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly上获取它。

Emscripten 对音频播放的支持并不如我所希望的那样好。在留言板上,Emscripten 的支持者很快就把音频的状态归咎于网络而不是 Emscripten 本身,这种评估有一定道理。Emscripten 的常见问题解答声称,Emscripten 支持使用 SDL1 音频、SDL2 音频和 OpenAL,但根据我的经验,我发现使用非常有限的 SDL2 音频提供了最佳的结果。我将尽量减少对 SDL2 音频的使用,使用音频队列而不是混合音效。您可能希望扩展或修改我在这里所做的工作。理论上,OpenAL 应该可以与 Emscripten 一起工作,尽管我在这方面并不太幸运。此外,您可能希望查看SDL_MixAudiowiki.libsdl.org/SDL_MixAudio)和SDL_AudioStreamwiki.libsdl.org/Tutorials/AudioStream)来改进游戏中的音频系统,但请注意,网络上的音频流和混音的性能和支持可能还没有准备好投入实际使用。

本章将涵盖以下主题:

  • 获取音效的地方

  • 使用 Emscripten 制作简单音频

  • 向我们的游戏添加声音

  • 编译和运行

获取音效的地方

有很多很棒的地方可以在线获取音乐和音效。我使用 SFXR(www.drpetter.se/project_sfxr.html)生成了本章中使用的音效,这是一个用于生成类似 NES 游戏中听到的老式 8 位音效的工具。这种类型的音效可能不符合您的口味。OpenGameArt.org 还有大量的音效(opengameart.org/art-search-advanced?keys=&field_art_type_tid%5B%5D=13&sort_by=count&sort_order=DESC)和音乐(opengameart.org/art-search-advanced?keys=&field_art_type_tid%5B%5D=12&sort_by=count&sort_order=DESC)的大量开放许可,因此在使用该网站上的任何音频或艺术之前,请确保您仔细阅读许可证。

使用 Emscripten 制作简单音频

在我们将音效添加到主游戏之前,我将向您展示如何在audio.c文件中制作音频播放器,以演示SDL 音频如何在 WebAssembly 应用程序中用于播放音效。该应用程序将使用五种我们将在游戏中使用的音效,并允许用户按数字键 1 到 5 来播放所有选择的音效。我将首先向您展示代码分为两个部分,然后我将向您解释每一部分的功能。以下是audio.c中的所有代码,除了main函数:

#include <SDL2/SDL.h>
#include <emscripten.h>
#include <stdio.h>
#include <stdbool.h>

#define ENEMY_LASER "/audio/enemy-laser.wav"
#define PLAYER_LASER "/audio/player-laser.wav"
#define LARGE_EXPLOSION "/audio/large-explosion.wav"
#define SMALL_EXPLOSION "/audio/small-explosion.wav"
#define HIT "/audio/hit.wav"

SDL_AudioDeviceID device_id;
SDL_Window *window;
SDL_Renderer *renderer;
SDL_Event event;

struct audio_clip {
    char file_name[100];
    SDL_AudioSpec spec;
    Uint32 len;
    Uint8 *buf;
} enemy_laser_snd, player_laser_snd, small_explosion_snd, large_explosion_snd, hit_snd;

void play_audio( struct audio_clip* clip ) {
    int success = SDL_QueueAudio(device_id, clip->buf, clip->len);
    if( success < 0 ) {
        printf("SDL_QueueAudio %s failed: %s\n", clip->file_name, 
        SDL_GetError());
    }
}

void init_audio( char* file_name, struct audio_clip* clip ) {
    strcpy( clip->file_name, file_name );

    if( SDL_LoadWAV(file_name, &(clip->spec), &(clip->buf), &(clip->len)) 
    == NULL ) {
        printf("Failed to load wave file: %s\n", SDL_GetError());
    }
}

void input_loop() {
    if( SDL_PollEvent( &event ) ){
        if( event.type == SDL_KEYUP ) {
            switch( event.key.keysym.sym ){
                case SDLK_1:
                    printf("one key release\n");
                    play_audio(&enemy_laser_snd);
                    break;
                case SDLK_2:
                    printf("two key release\n");
                    play_audio(&player_laser_snd);
                    break;
                case SDLK_3:
                    printf("three key release\n");
                    play_audio(&small_explosion_snd);
                    break;
                case SDLK_4:
                    printf("four key release\n");
                    play_audio(&large_explosion_snd);
                    break;
                case SDLK_5:
                    printf("five key release\n");
                    play_audio(&hit_snd);
                    break;
                default:
                    printf("unknown key release\n");
                    break;
            }
        }
    }
}

audio.c文件的末尾,我们有我们的main函数:

int main() {
    if((SDL_Init(SDL_INIT_VIDEO|SDL_INIT_AUDIO)==-1)) {
        printf("Could not initialize SDL: %s.\n", SDL_GetError());
        return 0;
    }

    SDL_CreateWindowAndRenderer( 320, 200, 0, &window, &renderer );

    init_audio( ENEMY_LASER, &enemy_laser_snd );
    init_audio( PLAYER_LASER, &player_laser_snd );
    init_audio( SMALL_EXPLOSION, &small_explosion_snd );
    init_audio( LARGE_EXPLOSION, &large_explosion_snd );
    init_audio( HIT, &hit_snd );

    device_id = SDL_OpenAudioDevice(NULL, 0, &(enemy_laser_snd.spec), 
                                    NULL, 0);

    if (device_id == 0) {
        printf("Failed to open audio: %s\n", SDL_GetError());
    }

    SDL_PauseAudioDevice(device_id, 0);

    emscripten_set_main_loop(input_loop, 0, 0);

    return 1;
}

现在你已经看到了整个audio.c文件,让我们来看看它的所有部分。在这个文件的顶部,我们有我们的#include#define宏:

#include <SDL2/SDL.h>
#include <emscripten.h>
#include <stdio.h>
#include <stdbool.h>

#define ENEMY_LASER "/audio/enemy-laser.wav"
#define PLAYER_LASER "/audio/player-laser.wav"
#define LARGE_EXPLOSION "/audio/large-explosion.wav"
#define SMALL_EXPLOSION "/audio/small-explosion.wav"
#define HIT "/audio/hit.wav"

之后,我们有我们的 SDL 特定的全局变量。我们需要一个SDL_AudioDeviceID用于我们的音频输出。SDL_WindowSDL_RendererSDL_Event在大多数早期章节中都被使用过,现在应该很熟悉了:

SDL_AudioDeviceID device_id;
SDL_Window *window;
SDL_Renderer *renderer;
SDL_Event event;

我们正在开发一个 C 程序,而不是 C++程序,所以我们将使用一个结构来保存我们的音频数据,而不是一个类。我们将创建一个名为audio_clip的 C 结构,它将保存我们应用程序中将要播放的音频的所有信息。这些信息包括一个包含文件名的字符串。它包含一个保存音频规格的SDL_AudioSpec对象。它还包含音频片段的长度和一个指向 8 位数据缓冲区的指针,该缓冲区保存了音频片段的波形数据。在定义了audio_clip结构之后,创建了五个该结构的实例,我们稍后将能够使用这些声音进行播放:

struct audio_clip {
    char file_name[100];
    SDL_AudioSpec spec;
    Uint32 len;
    Uint8 *buf;
} enemy_laser_snd, player_laser_snd, small_explosion_snd, large_explosion_snd, hit_snd;

在我们定义了audio_clip结构之后,我们需要创建一个函数来播放该结构中的音频。这个函数调用SDL_QueueAudio,传入全局device_id、波形缓冲区的指针和片段的长度。device_id是对音频设备(声卡)的引用。clip->buf变量是一个指向包含我们将要加载的.wav文件的波形数据的缓冲区的指针。clip->len变量包含片段播放的时间长度:

void play_audio( struct audio_clip* clip ) {
    int success = SDL_QueueAudio(device_id, clip->buf, clip->len);
    if( success < 0 ) {
        printf("SDL_QueueAudio %s failed: %s\n", clip->file_name, 
        SDL_GetError());
    }
}

我们需要的下一个函数是初始化我们的audio_clip,这样我们就可以将它传递到play_audio函数中。这个函数设置了我们的audio_clip的文件名,并加载了一个波形文件,设置了我们的audio_clip中的specbuflen值。如果调用SDL_LoadWAV失败,我们会打印出一个错误消息:

void init_audio( char* file_name, struct audio_clip* clip ) {
    strcpy( clip->file_name, file_name );

    if( SDL_LoadWAV(file_name, &(clip->spec), &(clip->buf), &(clip-
        >len)) 
    == NULL ) {
        printf("Failed to load wave file: %s\n", SDL_GetError());
    }
}

input_loop现在应该看起来很熟悉了。该函数调用SDL_PollEvent并使用它返回的事件来检查键盘按键的释放。它检查释放了哪个键。如果该键是从一到五的数字键之一,那么使用 switch 语句调用play_audio函数,传入特定的audio_clip。我们使用按键释放而不是按键按下的原因是为了防止用户按住键时的按键重复。我们可以很容易地防止这种情况,但我正在尽量保持这个应用程序的代码尽可能简短。这是input_loop的代码:

void input_loop() {
    if( SDL_PollEvent( &event ) ){
        if( event.type == SDL_KEYUP ) {
            switch( event.key.keysym.sym ){
                case SDLK_1:
                    printf("one key release\n");
                    play_audio(&enemy_laser_snd);
                    break;
                case SDLK_2:
                    printf("two key release\n");
                    play_audio(&player_laser_snd);
                    break;
                case SDLK_3:
                    printf("three key release\n");
                    play_audio(&small_explosion_snd);
                    break;
                case SDLK_4:
                    printf("four key release\n");
                    play_audio(&large_explosion_snd);
                    break;
                case SDLK_5:
                    printf("five key release\n");
                    play_audio(&hit_snd);
                    break;
                default:
                    printf("unknown key release\n");
                    break;
            }
        }
    }
}

和往常一样,main函数负责我们应用程序的所有初始化。除了我们在之前的应用程序中执行的初始化之外,我们还需要对我们的音频进行新的初始化。这就是main函数的新版本。

int main() {
    if((SDL_Init(SDL_INIT_VIDEO|SDL_INIT_AUDIO)==-1)) {
        printf("Could not initialize SDL: %s.\n", SDL_GetError());
        return 0;
    }
    SDL_CreateWindowAndRenderer( 320, 200, 0, &window, &renderer );
    init_audio( ENEMY_LASER, &enemy_laser_snd );
    init_audio( PLAYER_LASER, &player_laser_snd );
    init_audio( SMALL_EXPLOSION, &small_explosion_snd );
    init_audio( LARGE_EXPLOSION, &large_explosion_snd );
    init_audio( HIT, &hit_snd );

    device_id = SDL_OpenAudioDevice(NULL, 0, &(enemy_laser_snd.spec), NULL, 
    0);

    if (device_id == 0) {
        printf("Failed to open audio: %s\n", SDL_GetError());
    }
    SDL_PauseAudioDevice(device_id, 0);
    emscripten_set_main_loop(input_loop, 0, 0);
    return 1;
}

我们改变的第一件事是我们对SDL_Init的调用。我们需要添加一个标志,告诉 SDL 初始化音频子系统。我们通过在传入的参数中添加|SLD_INIT_AUDIO来实现这一点,这将对参数进行位操作,并使用SDL_INIT_AUDIO标志。在新版本的SDL_Init之后,我们将创建窗口和渲染器,这在这一点上我们已经做了很多次。

init_audio调用都是新的,并初始化了我们的audio_clip结构:

init_audio( ENEMY_LASER, &enemy_laser_snd );
init_audio( PLAYER_LASER, &player_laser_snd );
init_audio( SMALL_EXPLOSION, &small_explosion_snd );
init_audio( LARGE_EXPLOSION, &large_explosion_snd );
init_audio( HIT, &hit_snd );

接下来,我们需要调用SDL_OpenAudioDevice并检索设备 ID。打开音频设备需要一个默认规范,它通知音频设备您想要播放的声音剪辑的质量。确保选择一个声音文件,其质量水平是您想在游戏中播放的一个很好的例子。在我们的代码中,我们选择了enemy_laser_snd。我们还需要调用SDL_PauseAudioDevice。每当创建新的音频设备时,默认情况下会暂停。调用SDL_PauseAudioDevice并将0作为第二个参数传递进去会取消暂停我们刚刚创建的音频设备。起初我觉得有点困惑,但请记住,对SDL_PauseAudioDevice的后续调用实际上是取消暂停音频剪辑:

device_id = SDL_OpenAudioDevice(NULL, 0, &(enemy_laser_snd.spec), NULL, 0);

if (device_id == 0) {
    printf("Failed to open audio: %s\n", SDL_GetError());
}

SDL_PauseAudioDevice(device_id, 0);

在返回之前,我们将做的最后一件事是将我们的循环设置为我们之前创建的input_loop函数:

emscripten_set_main_loop(input_loop, 0, 0);

现在我们有了代码,我们应该编译和测试我们的audio.c文件:

emcc audio.c --preload-file audio -s USE_SDL=2 -o audio.html

我们需要预加载音频文件夹,以便在虚拟文件系统中访问.wav文件。然后,在 Web 浏览器中加载audio.html,使用 emrun 提供文件,或者使用其他替代 Web 服务器。当您在 Chrome 中加载应用程序时,可能会遇到一些小困难。Chrome 的新版本已添加了检查,以防止未经请求的音频播放,以防止一些令人讨厌的垃圾邮件。有时,这种检查过于敏感,这可能会阻止我们游戏中的音频运行。如果发生这种情况,请尝试在 Chrome 浏览器中重新加载页面。有时,这可以解决问题。另一种防止这种情况发生的方法是切换到 Firefox。

向我们的游戏添加声音

现在我们了解了如何在 Web 上让 SDL 音频工作,我们可以开始向我们的游戏添加音效。我们的游戏中不会使用混音器,因此一次只会播放一个音效。因此,我们需要将一些声音分类为优先音效。如果触发了优先音效,声音队列将被清除,并且该音效将运行。我们还希望防止我们的声音队列变得太长,因此如果其中有两个以上的项目,我们将清除我们的声音队列。不要害怕!当我们到达代码的那部分时,我会重复所有这些。

更新 game.hpp

我们需要改变的第一件事是我们的game.hpp文件。我们需要添加一个新的Audio类,以及其他新代码来支持我们游戏中的音频。在game.hpp文件的顶部附近,我们将添加一系列#define宏来定义我们声音效果.wav文件的位置:

#define ENEMY_LASER (char*)"/audio/enemy-laser.wav"
#define PLAYER_LASER (char*)"/audio/player-laser.wav"
#define LARGE_EXPLOSION (char*)"/audio/large-explosion.wav"
#define SMALL_EXPLOSION (char*)"/audio/small-explosion.wav"
#define HIT (char*)"/audio/hit.wav"

在我们的类声明列表的顶部,我们应该添加一个名为Audio的新类声明:

class Audio;
class Ship;
class Particle;
class Emitter;
class Collider;
class Asteroid;
class Star;
class PlayerShip;
class EnemyShip;
class Projectile;
class ProjectilePool;
class FiniteStateMachine;
class Camera;
class RenderManager;
class Locator;

然后,我们将定义新的Audio类,它将与我们在audio.c文件中使用的audio_clip结构非常相似。这个类将有一个文件名,一个规范,一个长度(以运行时间为单位)和一个缓冲区。它还将有一个优先标志,当设置时,将优先于我们音频队列中当前的所有其他内容。最后,我们将在这个类中有两个函数;一个构造函数,用于初始化声音,和一个Play函数,用于实际播放声音。这就是类定义的样子:

class Audio {
    public:
        char FileName[100];
        SDL_AudioSpec spec;
        Uint32 len;
        Uint8 *buf;
        bool priority = false;

        Audio( char* file_name, bool priority_value );
        void Play();
};

最后,我们需要定义一些外部与音频相关的全局变量。这些全局变量将是对将出现在我们的main.cpp文件中的变量的引用。其中大部分是Audio类的实例,将在我们的游戏中用于播放音频文件。最后一个变量是对我们的音频设备的引用:

extern Audio* enemy_laser_snd;
extern Audio* player_laser_snd;
extern Audio* small_explosion_snd;
extern Audio* large_explosion_snd;
extern Audio* hit_snd;
extern SDL_AudioDeviceID device_id;

更新 main.cpp

在我们的main.cpp文件中要做的第一件事是定义我们在game.hpp文件的末尾定义为外部变量的与音频相关的全局变量:

SDL_AudioDeviceID device_id;

Audio* enemy_laser_snd;
Audio* player_laser_snd;
Audio* small_explosion_snd;
Audio* large_explosion_snd;
Audio* hit_snd;

这些音效大多与我们游戏中发生碰撞时爆炸有关。因此,我们将在整个collisions函数中添加调用以播放这些音效。这是我们collisions函数的新版本:

void collisions() {
 Asteroid* asteroid;
 std::vector<Asteroid*>::iterator ita;
    if( player->m_CurrentFrame == 0 && player->CompoundHitTest( star ) ) {
        player->m_CurrentFrame = 1;
        player->m_NextFrameTime = ms_per_frame;
        player->m_Explode->Run(); // added
        large_explosion_snd->Play();
    }
    if( enemy->m_CurrentFrame == 0 && enemy->CompoundHitTest( star ) ) {
        enemy->m_CurrentFrame = 1;
        enemy->m_NextFrameTime = ms_per_frame;
        enemy->m_Explode->Run(); // added
        large_explosion_snd->Play();
    }
    Projectile* projectile;
    std::vector<Projectile*>::iterator it;
    for(it=projectile_pool->m_ProjectileList.begin(); 
        it!=projectile_pool->m_ProjectileList.end(); 
        it++){
        projectile = *it;
        if( projectile->m_CurrentFrame == 0 && projectile->m_Active ) {
            for( ita = asteroid_list.begin(); ita != 
                asteroid_list.end(); 
                 ita++ ) {
                asteroid = *ita;
                if( asteroid->m_Active ) {
                    if( asteroid->HitTest( projectile ) ) {
                        projectile->m_CurrentFrame = 1;
                        projectile->m_NextFrameTime = ms_per_frame;
                        small_explosion_snd->Play();
                    }
                }
            }
            if( projectile->HitTest( star ) ){
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
                small_explosion_snd->Play();
            }
            else if( player->m_CurrentFrame == 0 && ( projectile-
                     >HitTest( player ) ||
                      player->CompoundHitTest( projectile ) ) ) {
                if( player->m_Shield->m_Active == false ) {
                    player->m_CurrentFrame = 1;
                    player->m_NextFrameTime = ms_per_frame;
                    player->m_Explode->Run();
                    large_explosion_snd->Play();
                }
                else { hit_snd->Play(); }
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
            else if( enemy->m_CurrentFrame == 0 && ( projectile-
                     >HitTest( enemy ) ||
                      enemy->CompoundHitTest( projectile ) ) ) {
                if( enemy->m_Shield->m_Active == false ) {
                    enemy->m_CurrentFrame = 1;
                    enemy->m_NextFrameTime = ms_per_frame;
                    enemy->m_Explode->Run();
                    large_explosion_snd->Play();
                }
                else { hit_snd->Play(); }
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
        }
    }
    for( ita = asteroid_list.begin(); ita != asteroid_list.end(); 
         ita++ ) {
        asteroid = *ita;
        if( asteroid->m_Active ) {
            if( asteroid->HitTest( star ) ) {
                asteroid->Explode();
                small_explosion_snd->Play();
            }
        }
        else { continue; }
        if( player->m_CurrentFrame == 0 && asteroid->m_Active &&
            ( asteroid->HitTest( player ) || player->CompoundHitTest( 
            asteroid ) ) ) {
            if( player->m_Shield->m_Active == false ) {
                player->m_CurrentFrame = 1;
                player->m_NextFrameTime = ms_per_frame;
                player->m_Explode->Run();
                large_explosion_snd->Play();
            }
            else {
                asteroid->Explode();
                small_explosion_snd->Play();
            }
        }
        if( enemy->m_CurrentFrame == 0 && asteroid->m_Active &&
            ( asteroid->HitTest( enemy ) || enemy->CompoundHitTest( 
              asteroid ) ) ) {
            if( enemy->m_Shield->m_Active == false ) {
                enemy->m_CurrentFrame = 1;
                enemy->m_NextFrameTime = ms_per_frame;
                enemy->m_Explode->Run();
                large_explosion_snd->Play();
            }
            else {
                asteroid->Explode();
                small_explosion_snd->Play();
            }
        }
    }
}

现在声音将在几次爆炸和碰撞后播放;例如,在玩家爆炸后:

player->m_Explode->Run(); 
large_explosion_snd->Play();

当敌舰爆炸时也会播放声音:

enemy->m_Explode->Run();
large_explosion_snd->Play();

在一颗小行星爆炸后,我们也希望有同样的效果:

asteroid->Explode();
small_explosion_snd->Play();

如果敌人的护盾被击中,我们想播放hit声音:

if( enemy->m_Shield->m_Active == false ) {
    enemy->m_CurrentFrame = 1;
    enemy->m_NextFrameTime = ms_per_frame;
    enemy->m_Explode->Run();
    large_explosion_snd->Play();
}
else {
    hit_snd->Play();
}

同样,如果玩家的护盾被击中,我们还想播放hit声音:

if( player->m_Shield->m_Active == false ) {
    player->m_CurrentFrame = 1;
    player->m_NextFrameTime = ms_per_frame;

    player->m_Explode->Run();
    large_explosion_snd->Play();
}
else {
    hit_snd->Play();
}

最后,我们需要更改main函数来初始化我们的音频。以下是完整的main函数代码:

int main() {
    SDL_Init( SDL_INIT_VIDEO | SDL_INIT_AUDIO );
    int return_val = SDL_CreateWindowAndRenderer( CANVAS_WIDTH, 
    CANVAS_HEIGHT, 0, &window, &renderer );

    if( return_val != 0 ) {
        printf("Error creating renderer %d: %s\n", return_val, 
        IMG_GetError() );
        return 0;
    }

    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );
    last_frame_time = last_time = SDL_GetTicks();

    player = new PlayerShip();
    enemy = new EnemyShip();
    star = new Star();
    camera = new Camera(CANVAS_WIDTH, CANVAS_HEIGHT);
    render_manager = new RenderManager();
    locator = new Locator();
    enemy_laser_snd = new Audio(ENEMY_LASER, false);
 player_laser_snd = new Audio(PLAYER_LASER, false);
 small_explosion_snd = new Audio(SMALL_EXPLOSION, true);
 large_explosion_snd = new Audio(LARGE_EXPLOSION, true);
 hit_snd = new Audio(HIT, false);
 device_id = SDL_OpenAudioDevice(NULL, 0, &(enemy_laser_snd->spec), 
    NULL, 0);

 if (device_id == 0) {
 printf("Failed to open audio: %s\n", SDL_GetError());
 }
    int asteroid_x = 0;
    int asteroid_y = 0;
    int angle = 0;

    // SCREEN 1
    for( int i_y = 0; i_y < 8; i_y++ ) {
        asteroid_y += 100;
        asteroid_y += rand() % 400;
        asteroid_x = 0;
        for( int i_x = 0; i_x < 12; i_x++ ) {
            asteroid_x += 66;
            asteroid_x += rand() % 400;
            int y_save = asteroid_y;
            asteroid_y += rand() % 400 - 200;
            angle = rand() % 359;
            asteroid_list.push_back(
                new Asteroid( asteroid_x, asteroid_y,
                get_random_float(0.5, 1.0),
                DEG_TO_RAD(angle) ) );
            asteroid_y = y_save;
        }
    }
    projectile_pool = new ProjectilePool();
    emscripten_set_main_loop(game_loop, 0, 0);
    return 1;
}

我们需要对main函数进行的第一个更改是在SDL_Init调用中包括音频子系统的初始化:

SDL_Init( SDL_INIT_VIDEO | SDL_INIT_AUDIO );

我们需要做的另一个更改是添加新的Audio对象和调用SDL_OpenAudioDevice

enemy_laser_snd = new Audio(ENEMY_LASER, false);
player_laser_snd = new Audio(PLAYER_LASER, false);
small_explosion_snd = new Audio(SMALL_EXPLOSION, true);
large_explosion_snd = new Audio(LARGE_EXPLOSION, true);
hit_snd = new Audio(HIT, false);

device_id = SDL_OpenAudioDevice(NULL, 0, &(enemy_laser_snd->spec), 
NULL, 0);

if (device_id == 0) {
    printf("Failed to open audio: %s\n", SDL_GetError());
}

更新 ship.cpp

ship.cpp文件有一个小的更改。我们正在添加一个调用,当飞船发射抛射物时播放声音。这发生在Ship::Shoot()函数中。您会注意到在调用projectile->Launch之后发生对player_laser_snd->Play()的调用:

void Ship::Shoot() {
     Projectile* projectile;
     if( current_time - m_LastLaunchTime >= c_MinLaunchTime ) {
         m_LastLaunchTime = current_time;
         projectile = projectile_pool->GetFreeProjectile();
         if( projectile != NULL ) {
             projectile->Launch( m_Position, m_Direction );
             player_laser_snd->Play();
         }
     }
 }

新的 audio.cpp 文件

我们正在添加一个新的audio.cpp文件来实现Audio类的构造函数和Audio类的Play函数。以下是完整的audio.cpp文件:

#include "game.hpp"

Audio::Audio( char* file_name, bool priority_value ) {
    strcpy( FileName, file_name );
    priority = priority_value;

    if( SDL_LoadWAV(FileName, &spec, &buf, &len) == NULL ) {
        printf("Failed to load wave file: %s\n", SDL_GetError());
    }
}

void Audio::Play() {
    if( priority || SDL_GetQueuedAudioSize(device_id) > 2 ) {
        SDL_ClearQueuedAudio(device_id);
    }

    int success = SDL_QueueAudio(device_id, buf, len);
    if( success < 0 ) {
        printf("SDL_QueueAudio %s failed: %s\n", FileName, SDL_GetError());
    }
}

该文件中的第一个函数是Audio类的构造函数。此函数将FileName属性设置为传递的值,并设置priority值。它还从传递的文件名加载波形文件,并使用SDL_LoadWAV文件设置specbuflen属性。

Audio::Play()函数首先查看这是否是高优先级音频,或者音频队列的大小是否大于两个声音。如果是这种情况,我们会清空音频队列:

if( priority || SDL_GetQueuedAudioSize(device_id) > 2 ) {
    SDL_ClearQueuedAudio(device_id);
}

我们这样做是因为我们不想混合音频。我们正在按顺序播放音频。如果我们有一个优先级音频剪辑,我们希望清空队列,以便音频立即播放。如果队列太长,我们也希望这样做。然后我们将调用SDL_QueueAudio来排队播放此声音以尽快播放:

int success = SDL_QueueAudio(device_id, buf, len);
if( success < 0 ) {
 printf("SDL_QueueAudio %s failed: %s\n", FileName, SDL_GetError());
}

现在,我们应该准备编译和运行我们的代码。

编译和运行

现在我们已经对我们的代码进行了所有必要的更改,我们可以使用 Emscripten 编译和运行我们的新代码:

em++ asteroid.cpp audio.cpp camera.cpp collider.cpp emitter.cpp enemy_ship.cpp finite_state_machine.cpp locator.cpp main.cpp particle.cpp player_ship.cpp projectile_pool.cpp projectile.cpp range.cpp render_manager.cpp shield.cpp ship.cpp star.cpp vector.cpp -o sound_fx.html --preload-file audio --preload-file sprites -std=c++17 -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] 

没有添加新的标志来允许我们使用 SDL 音频库。但是,我们需要添加一个新的--preload-file audio标志,将新的audio目录加载到我们的虚拟文件系统中。一旦编译了游戏的新版本,您可以使用 emrun 来运行它(假设您在编译时包含了必要的 emrun 标志)。如果您愿意,您也可以选择一个不同的 Web 服务器来提供这些文件。

总结

我们已经讨论了网络上当前(混乱的)音频状态,并查看了 Emscripten 可用的音频库。我提到了一些可以获得免费音效的地方。我们使用 C 和 Emscripten 创建了一个简单的音频应用程序,允许我们播放一系列音频文件。然后我们为我们的游戏添加了音效,包括爆炸和激光声音。我们修改了main()函数中的初始化代码,以初始化 SDL 音频子系统。我们添加了一个新的Shoot函数,供我们的飞船在发射抛射物时使用。我们还创建了一个新的Audio类来帮助我们播放我们的音频文件。

在下一章中,我们将学习如何为我们的游戏添加一些物理效果。

第十三章:游戏物理学

我们的游戏中已经有一些物理学。我们的每艘飞船都有速度和加速度。它们也至少遵守了牛顿的一些定律并保持动量。所有这些早些时候都添加了,没有引起太多轰动。计算机游戏中的物理学可以追溯到最初的计算机游戏《太空战!》,这个游戏启发了我们目前正在编写的游戏。在《太空战!》的原始版本中,太空飞船保持了动量,就像我们现在在游戏中做的那样。黑洞通过引力吸引太空飞船到游戏区域的中心。在创造经典游戏《乒乓球》之前,诺兰·布什内尔创造了《太空战!》的街机克隆版,名为《计算机太空》。《计算机太空》不像《乒乓球》那样受欢迎,诺兰·布什内尔将游戏的商业失败归咎于牛顿定律和公众对基本物理学的理解不足等原因之一。

根据史蒂文·肯特的《视频游戏的终极历史:从乒乓球到宝可梦及其后》,“计算机太空遵守第一定律——动量守恒。(布什内尔可能指的是艾萨克·牛顿的第一定律——物体保持恒定速度,除非受到外力作用。)这对于不理解这一点的人来说确实很困难。”

  • 诺兰·布什内尔

物理学在游戏中很常见,但远非普遍。游戏所需的物理学类型高度依赖于游戏的类型。有一个名为“Bullet Physics”的 3D 物理库,但由于它是 3D 的,Bullet 对于我们在这个游戏中将使用的物理学来说是一个相当庞大的库。因此,我们将在游戏中集成一些简单的牛顿物理学,以增加一些额外的风味。我们的游戏中已经有牛顿第一定律的简单实现。当我们加速我们的太空飞船时,它会朝着同样的方向移动,直到我们通过使用向下箭头减速它,或者通过将飞船转向并加速到当前速度的相反方向来“翻转和燃烧”。

您需要在构建中包含几个图像和音频文件,以使此项目正常工作。确保您从项目的 GitHub 中包括/Chapter13/sprites/文件夹以及/Chapter13/audio/文件夹。如果您还没有下载 GitHub 项目,可以在github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly上获取它。

在本章中,我们将应用物理学的以下方面:

  • 小行星、抛射物和太空飞船之间的弹性碰撞。

  • 当我们的太空飞船射击时,应该有一个反冲(牛顿第三定律)。

  • 恒星的引力应该吸引玩家的太空飞船。

牛顿第三定律

牛顿第三定律通常陈述为,“对于每一个动作,都有一个相等和相反的反作用力”。这意味着当物体 A 对物体 B 施加力时,物体 B 会以同样的力反作用于物体 A。一个例子是从枪中发射子弹。当持枪的人发射子弹时,枪会以子弹离开枪的同样力量产生反冲。这可能听起来违反直觉,因为子弹可以杀死人,但是枪的反冲并不会杀死开枪的人。这是因为枪比子弹大得多,而牛顿第一定律规定了“F = ma”,即力等于质量乘以加速度。换句话说,如果枪比子弹大 50 倍,那么同样的力只会使其加速到 1/50 的速度。我们将修改我们的太空飞船,使其在射出抛射物时,根据太空飞船和抛射物的相对质量,以相反方向加速。这将给我们的飞船炮筒一个反冲。

添加重力

在我们为飞船的火炮添加后坐力之后,我还想在我们的游戏中为飞船添加一个引力效应,当它们在星球附近一定距离内时,会将飞船吸引向星球。引力随着两个物体之间距离的平方减小。这很方便,因为这意味着我们可以用MagSQ函数计算引力效应,这比Magnitude函数运行得快得多。出于个人偏好,我选择不在抛射物和小行星上添加引力效应。如果你选择这样做,添加这种效应并不难。

改进碰撞

我们将改进游戏中飞船与小行星和抛射物之间的碰撞。为了简化事情,我们将使用弹性碰撞。弹性碰撞是指保持所有动能的碰撞。实际上,碰撞总是会损失一些能量,转化为热量或摩擦,即使是接近弹性碰撞的碰撞,比如台球。然而,使我们的碰撞完全弹性化简化了数学。在游戏中,简单的数学通常意味着更快的算法。

有关弹性碰撞的更多信息,维基百科有一篇很好的文章(https://en.wikipedia.org/wiki/Elastic_collision),讨论了我们将用来实现弹性碰撞函数的数学。

修改代码

在这一部分,我们将对我们的游戏对象进行一些更改。我们需要在我们的“碰撞器”类中添加质量和弹性碰撞。我们的星星应该能够产生引力,并以与距离的平方成反比的力吸引玩家和敌人的飞船。我们需要修改我们的碰撞函数,以在我们的飞船、小行星和抛射物之间添加弹性碰撞。

更改 game.hpp 文件

为了将物理学引入我们的游戏,我们需要修改几个类定义并添加新的#define宏。让我们从更新我们的game.hpp文件开始。我们需要添加的第一件事是#define,以设置星球质量的常量值。我希望在我们的ElasticCollision函数中检查星球质量的大常量值。如果我们弹性碰撞中的任一对象的质量与STAR_MASS相同,我们不希望加速该对象。实际上,如果你把一块岩石扔进太阳,你会在你扔岩石的方向上微微加速太阳。相对于太阳来说,这个量是如此之小,以至于不可检测。我们将为星球的质量设定一个固定值,任何质量与该值相同的物体在游戏中被击中时都不会加速。为此,我们需要添加以下#define

#define STAR_MASS 9999999

在添加了#define之后,我们需要修改我们的Collider类,给它一个新的ElasticCollision函数。这个函数将接收第二个Collider对象,并使用这两个对象的速度和质量来确定它们的新速度。我们还需要添加一个名为m_Mass的质量属性。最后,我们需要将两个属性移到我们的Collider类中,这些属性以前在Collider的子类中。这些变量是 2Dm_Directionm_Velocity向量,因为我们的弹性碰撞函数将需要这些数据来计算新的速度。这是新版本的Collider类的样子:

class Collider {
    public:
        bool m_Active;
        float* m_ParentRotation;
        float* m_ParentX;
        float* m_ParentY;
        Vector2D m_TempPoint;

        bool CCHitTest( Collider* collider );

 void ElasticCollision( Collider* collider );
 float m_Mass;
 Vector2D m_Direction;
 Vector2D m_Velocity;
 Vector2D m_Position;

        float m_Radius;
        float m_SteeringRadius;
        float m_SteeringRadiusSQ;
        void SetParentInformation( float* rotation, float* x, float* y );

        Collider(float radius);
        bool HitTest( Collider *collider );
        bool SteeringLineTest( Vector2D &p1, Vector2D &p2 );
        bool SteeringRectTest( Vector2D &start_point, Vector2D 
                               &end_point );
        void WrapPosition();
};

我们添加的四行代码位于这个新版本的类的中心附近:

void ElasticCollision( Collider* collider );
float m_Mass;
Vector2D m_Direction;
Vector2D m_Velocity;

在将m_Directionm_Velocity添加到我们的Collider类之后,我们需要从三个子类中删除m_Velocity,这些子类在我们游戏的先前版本中有这些代码。我们需要从AsteroidShipProjectile类中删除这些属性。以下是我们需要删除的两行:

Vector2D m_Direction;
Vector2D m_Velocity;

在下面的代码片段中,我们有删除了那两行后的Asteroid类:

class Asteroid : public Collider {
    public:
        SDL_Texture *m_SpriteTexture;
        SDL_Rect m_src = {.x = 0, .y = 0, .w = 16, .h = 16 };
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 0, .h = 0 };

        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;
        float m_Rotation;

        Emitter* m_Explode;
        Emitter* m_Chunks;

        Asteroid( float x, float y,
                  float velocity,
                  float rotation );

        void Move();
        void Render();
        void Explode();
};

在删除了那两行后,Ship类将会是什么样子:

class Ship : public Collider {
    public:
        const float c_Acceleration = 10.0f;
        const float c_MaxVelocity = 100.0f;
        const int c_AliveTime = 2000;
        const Uint32 c_MinLaunchTime = 300;

        bool m_Accelerating = false;
        Uint32 m_LastLaunchTime;
        const int c_Width = 32;
        const int c_Height = 32;
        SDL_Texture *m_SpriteTexture;
        SDL_Rect src = {.x = 0, .y = 0, .w = 32, .h = 32 };

        Emitter* m_Explode;
        Emitter* m_Exhaust;
        Shield* m_Shield;
        std::vector<Collider*> m_Colliders;

        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;
        float m_Rotation;

        void RotateLeft();
        void RotateRight();
        void Accelerate();
        void Decelerate();
        void CapVelocity();
        void Shoot();
        virtual void Move() = 0;
        Ship();
        void Render();
        bool CompoundHitTest( Collider* collider );
};

最后,在删除了那两行后,Projectile类将会是什么样子:

class Projectile: public Collider {
    public:
        const char* c_SpriteFile = "sprites/ProjectileExp.png";
        const int c_Width = 16;
        const int c_Height = 16;
        SDL_Texture *m_SpriteTexture;
        SDL_Rect src = {.x = 0, .y = 0, .w = 16, .h = 16 };

        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;
        const float c_Velocity = 300.0;
        const float c_AliveTime = 2000;
        float m_TTL;

        Projectile();
        void Move();
        void Render();
        void Launch(Vector2D &position, Vector2D &direction);
};

我们必须改变的最后一个类是我们的Star类。Star类现在将能够通过引力吸引我们游戏中的飞船。为了做到这一点,我们将添加一个常量属性,定义引力作用的最大范围。实际上,重力是无限延伸的,但是对于我们的游戏,当星星不在屏幕上(或者至少离得很远)时,我们不希望重力影响我们的飞船。因此,我们将限制引力效应的距离为 500 像素。我们还将在我们的类中添加一个名为ShipGravity的新函数。我们将把一个Ship对象传递给这个函数,该函数将根据到Star对象的平方距离来修改飞船的速度。这是新版本的Star类定义将会是什么样子的:

class Star : public Collider {
    public:
        const float c_MaxGravityDistSQ = 250000.0; // 300 squared

        SDL_Texture *m_SpriteTexture;
        SDL_Rect m_src = {.x = 0, .y = 0, .w = 64, .h = 64 };
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 64, .h = 64 };

        std::vector<Emitter*> m_FlareList;

        Uint32 m_CurrentFrame = 0;
        int m_NextFrameTime;

        Star();

        void Move();
        void Render();

        void ShipGravity( Ship* s );
};

更改 collider.cpp

我们将要更改的下一个文件是collider.cpp文件,其中包含我们在Collider类定义中声明的函数。唯一的变化将是添加一个名为ElasticCollision的函数。该函数根据这些对象的质量和起始速度修改我们两个碰撞器的位置和速度。ElasticCollision函数看起来是这样的:

void Collider::ElasticCollision( Collider* collider ) {
    if( collider->m_Mass == STAR_MASS || m_Mass == STAR_MASS ) {
        return;
    }

    Vector2D separation_vec = collider->m_Position - m_Position;

    separation_vec.Normalize();
    separation_vec *= collider->m_Radius + m_Radius;

    collider->m_Position = m_Position + separation_vec;

    Vector2D old_v1 = m_Velocity;
    Vector2D old_v2 = collider->m_Velocity;

    m_Velocity = old_v1 * ((m_Mass - collider->m_Mass)/(m_Mass + 
    collider->m_Mass)) +
    old_v2 * ((2 * collider->m_Mass) / (m_Mass + collider->m_Mass));

    collider->m_Velocity = old_v1 * ((2 * collider->m_Mass)/(m_Mass + 
    collider->m_Mass)) +
    old_v2 * ((collider->m_Mass - m_Mass)/(m_Mass + collider->m_Mass));
}

函数的第一件事是检查两个碰撞器中是否有一个的质量是星星。如果有一个是星星,我们就不改变它们的速度。星星的速度不会改变,因为它太庞大而无法移动,而与星星碰撞的对象也不会改变其质量,因为它在碰撞中被摧毁:

if( collider->m_Mass == STAR_MASS || m_Mass == STAR_MASS ) {
    return;
}

在质量检查之后,我们需要调整碰撞器的位置,以使它们不重叠。重叠可能发生是因为我们的对象的位置每一帧都在变化,并不是连续的。因此,我们需要移动其中一个对象的位置,使其与另一个对象轻微接触。更准确的做法是修改两个对象的位置,每个对象修改的量是另一个对象的一半,但是方向不同。为简单起见,我们只会改变一个碰撞器的位置:

separation_vec.Normalize();
separation_vec *= collider->m_Radius + m_Radius;

collider->m_Position = m_Position + separation_vec;

之后,我们将使用这两个对象的质量和起始速度来修改这两个碰撞器对象的速度:

Vector2D old_v1 = m_Velocity;
Vector2D old_v2 = collider->m_Velocity;

m_Velocity = old_v1 * ((m_Mass - collider->m_Mass)/(m_Mass + collider->m_Mass)) +
old_v2 * ((2 * collider->m_Mass) / (m_Mass + collider->m_Mass));

collider->m_Velocity = old_v1 * ((2 * collider->m_Mass)/(m_Mass + collider->m_Mass)) +
old_v2 * ((collider->m_Mass - m_Mass)/(m_Mass + collider->m_Mass));

如果您想了解我们用来计算新速度的公式,可以查看维基百科关于弹性碰撞的文章en.wikipedia.org/wiki/Elastic_collision

对 star.cpp 的更改

在我们的star.cpp文件中,我们需要修改我们的Star类的构造函数,以及它的Move函数。我们还需要添加一个名为ShipGravity的新函数。我们将首先在我们的Star类构造函数的某处添加以下行:

m_Mass = STAR_MASS;

之后,我们需要定义我们的ShipGravity函数。以下代码定义了该函数:

void Star::ShipGravity( Ship* s ) {
    Vector2D dist_vec = m_Position - s->m_Position;
    float dist_sq = dist_vec.MagSQ();

    if( dist_sq < c_MaxGravityDistSQ ) {
        float force = (c_MaxGravityDistSQ / dist_sq) * delta_time;
        dist_vec.Normalize();
        dist_vec *= force;
        s->m_Velocity += dist_vec;
    }
}

第一行创建了一个dist_vec向量,它是表示星星位置和飞船位置之间距离的向量。第二行得到了星星和飞船之间的平方距离。之后,我们有一个if块,看起来是这样的:

if( dist_sq < c_MaxGravityDistSQ ) {
    float force = (c_MaxGravityDistSQ / dist_sq) * delta_time;
    dist_vec.Normalize();
    dist_vec *= force;
    s->m_Velocity += dist_vec;
}

这个if块正在检查与引力影响飞船的最大距离的平方距离,我们在c_MaxGravityDistSQ常量中定义了这个距离。因为引力随着星球和我们飞船之间的距离的平方减小,我们通过将最大引力距离除以 50 倍距离的平方来计算标量力。50 的值是相当任意选择的,是我在数字上摸索直到引力感觉合适的结果。如果您希望引力的力量不同,可以选择不同的值。您还可以通过更改我们在game.hpp中定义的c_MaxGravityDistSQ的值来修改最大引力距离。以下行用于将我们的标量力值转换为指向我们星球的矢量力值:

dist_vec.Normalize();
dist_vec *= force;

现在我们已经将dist_vec转换为一个指向我们星球的力向量,我们可以将该力向量添加到我们飞船的速度上,以在我们的飞船上创建引力效应:

s->m_Velocity += dist_vec;

我们需要做的最后一个更改是Move函数。我们需要添加两个对ShipGravity函数的调用;一个用于在玩家身上创建引力效应,另一个用于在敌方飞船上创建引力效应。以下是Move函数的新版本:

void Star::Move() {
    m_NextFrameTime -= diff_time;

    if( m_NextFrameTime <= 0 ) {
        ++m_CurrentFrame;
        m_NextFrameTime = ms_per_frame;
        if( m_CurrentFrame >= 8 ) {
            m_CurrentFrame = 0;
        }
    }

 ShipGravity( player );
 ShipGravity( enemy );
}

最后两行是新的。确保将这两行添加到Move函数中:

ShipGravity( player );
ShipGravity( enemy );

更改main.cpp文件

在更新我们的star.cpp文件之后,我们需要更改main.cpp文件以整合我们的弹性碰撞。我们需要对collisions()函数进行所有这些更改。以下是collisions的完整新版本:

void collisions() {
 Asteroid* asteroid;
 std::vector<Asteroid*>::iterator ita;
    if( player->m_CurrentFrame == 0 && player->CompoundHitTest( star ) ) {
        player->m_CurrentFrame = 1;
        player->m_NextFrameTime = ms_per_frame;
        player->m_Explode->Run();
        large_explosion_snd->Play();
    }
    if( enemy->m_CurrentFrame == 0 && enemy->CompoundHitTest( star ) ) {
        enemy->m_CurrentFrame = 1;
        enemy->m_NextFrameTime = ms_per_frame;
        enemy->m_Explode->Run();
        large_explosion_snd->Play();
    }
    Projectile* projectile;
    std::vector<Projectile*>::iterator it;
    for(it=projectile_pool->m_ProjectileList.begin(); 
    it!=projectile_pool->m_ProjectileList.end();
    it++) {
        projectile = *it;
        if( projectile->m_CurrentFrame == 0 && projectile->m_Active ) {
            for( ita = asteroid_list.begin(); ita != asteroid_list.end(); 
                 ita++ 
            ) {
                asteroid = *ita;
                if( asteroid->m_Active ) {
                    if( asteroid->HitTest( projectile ) ) {
 asteroid->ElasticCollision( projectile );
                        projectile->m_CurrentFrame = 1;
                        projectile->m_NextFrameTime = ms_per_frame;
                        small_explosion_snd->Play();
                    }
                }
            }
            if( projectile->HitTest( star ) ){
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
                small_explosion_snd->Play();
            }
            else if( player->m_CurrentFrame == 0 && ( projectile->HitTest( 
            player ) ||
                      player->CompoundHitTest( projectile ) ) ) {
                if( player->m_Shield->m_Active == false ) {
                    player->m_CurrentFrame = 1;
                    player->m_NextFrameTime = ms_per_frame;
                    player->m_Explode->Run();
                    large_explosion_snd->Play();
                }
                else {
                    hit_snd->Play();
 player->ElasticCollision( projectile );
                }
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
            else if( enemy->m_CurrentFrame == 0 && ( projectile-
            >HitTest( enemy ) || enemy->CompoundHitTest( projectile ) ) 
             ) {
                if( enemy->m_Shield->m_Active == false ) {
                    enemy->m_CurrentFrame = 1;
                    enemy->m_NextFrameTime = ms_per_frame;
                    enemy->m_Explode->Run();
                    large_explosion_snd->Play();
                }
                else {
                    enemy->ElasticCollision( projectile );
                    hit_snd->Play();
                }
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
        }
    }
    for( ita = asteroid_list.begin(); ita != asteroid_list.end(); ita++ ) {
        asteroid = *ita;
        if( asteroid->m_Active ) {
            if( asteroid->HitTest( star ) ) {
                asteroid->Explode();
                small_explosion_snd->Play();
            }
        }
        else { continue; }
        if( player->m_CurrentFrame == 0 && asteroid->m_Active &&
            ( asteroid->HitTest( player ) || player->CompoundHitTest( 
            asteroid ) ) ) {
            if( player->m_Shield->m_Active == false ) {
                player->m_CurrentFrame = 1;
                player->m_NextFrameTime = ms_per_frame;
                player->m_Explode->Run();
                large_explosion_snd->Play();
            }
            else {
 player->ElasticCollision( asteroid );
                small_explosion_snd->Play();
            }
        }
        if( enemy->m_CurrentFrame == 0 && asteroid->m_Active &&
            ( asteroid->HitTest( enemy ) || enemy->CompoundHitTest( 
            asteroid ) ) ) {
            if( enemy->m_Shield->m_Active == false ) {
                enemy->m_CurrentFrame = 1;
                enemy->m_NextFrameTime = ms_per_frame;
                enemy->m_Explode->Run();
                large_explosion_snd->Play();
            }
            else {
 enemy->ElasticCollision( asteroid );
                small_explosion_snd->Play();
            }
        }
    }
    Asteroid* asteroid_1;
    Asteroid* asteroid_2;
    std::vector<Asteroid*>::iterator ita_1;
    std::vector<Asteroid*>::iterator ita_2;
    for( ita_1 = asteroid_list.begin(); ita_1 != asteroid_list.end(); 
         ita_1++ ) {
        asteroid_1 = *ita_1;
        if( !asteroid_1->m_Active ) { continue; }
        for( ita_2 = ita_1+1; ita_2 != asteroid_list.end(); ita_2++ ) {
            asteroid_2 = *ita_2;
            if( !asteroid_2->m_Active ) { continue; }
            if( asteroid_1->HitTest( asteroid_2 ) ) {
 asteroid_1->ElasticCollision( asteroid_2 );
            }
        }
    }
}

在此函数的第一部分中,我们循环遍历抛射物并检查它们是否击中了小行星或飞船。如果抛射物在飞船启用护盾时击中了小行星或飞船,我们希望创建一个弹性碰撞。抛射物仍将被摧毁,但飞船或小行星的速度将根据碰撞进行修改。以下是projectile循环的代码:

for( it = projectile_pool->m_ProjectileList.begin(); it != projectile_pool->m_ProjectileList.end(); it++ ) {
    projectile = *it;
    if( projectile->m_CurrentFrame == 0 && projectile->m_Active ) {
        for( ita = asteroid_list.begin(); ita != asteroid_list.end(); 
        ita++ ) {
            asteroid = *ita;
            if( asteroid->m_Active ) {
                if( asteroid->HitTest( projectile ) ) {
 asteroid->ElasticCollision( projectile );
                    projectile->m_CurrentFrame = 1;
                    projectile->m_NextFrameTime = ms_per_frame;
                    small_explosion_snd->Play();
                }
            }
        }
        if( projectile->HitTest( star ) ){
            projectile->m_CurrentFrame = 1;
            projectile->m_NextFrameTime = ms_per_frame;
            small_explosion_snd->Play();
        }
        else if( player->m_CurrentFrame == 0 &&
                ( projectile->HitTest( player ) ||
                  player->CompoundHitTest( projectile ) ) ) {
            if( player->m_Shield->m_Active == false ) {
                player->m_CurrentFrame = 1;
                player->m_NextFrameTime = ms_per_frame;

                player->m_Explode->Run();
                large_explosion_snd->Play();
            }
            else {
                hit_snd->Play();
 player->ElasticCollision( projectile );
            }
            projectile->m_CurrentFrame = 1;
            projectile->m_NextFrameTime = ms_per_frame;
        }
        else if( enemy->m_CurrentFrame == 0 &&
                ( projectile->HitTest( enemy ) ||
                  enemy->CompoundHitTest( projectile ) ) ) {
            if( enemy->m_Shield->m_Active == false ) {
                enemy->m_CurrentFrame = 1;
                enemy->m_NextFrameTime = ms_per_frame;
                enemy->m_Explode->Run();
                large_explosion_snd->Play();
            }
            else {
 enemy->ElasticCollision( projectile );
                hit_snd->Play();
            }
            projectile->m_CurrentFrame = 1;
            projectile->m_NextFrameTime = ms_per_frame;
        }
    }
}

此循环执行的第一系列检查是针对每颗小行星。它寻找当前正在碰撞的活动小行星。如果这些条件为真,它首先调用ElasticCollision函数,传入抛射物:

for( ita = asteroid_list.begin(); ita != asteroid_list.end(); ita++ ) {
    asteroid = *ita;
    if( asteroid->m_Active ) {
        if( asteroid->HitTest( projectile ) ) {
 asteroid->ElasticCollision( projectile );
            projectile->m_CurrentFrame = 1;
            projectile->m_NextFrameTime = ms_per_frame;
            small_explosion_snd->Play();
        }
    }

这段代码与早期版本相同,但增加了对ElasticCollision的调用:

asteroid->ElasticCollision( projectile );

在我们循环遍历每个活动抛射物时,如果抛射物击中玩家飞船的护盾已经启用,我们将添加一个对ElasticCollision函数的调用:

else if( player->m_CurrentFrame == 0 &&
        ( projectile->HitTest( player ) ||
          player->CompoundHitTest( projectile ) ) ) {
    if( player->m_Shield->m_Active == false ) {
        player->m_CurrentFrame = 1;
        player->m_NextFrameTime = ms_per_frame;
        player->m_Explode->Run();
        large_explosion_snd->Play();
    }
    else {
        hit_snd->Play();
 player->ElasticCollision( projectile );
    }
    projectile->m_CurrentFrame = 1;
    projectile->m_NextFrameTime = ms_per_frame;
}

当敌方飞船在护盾启用时被抛射物击中时,我们也会做同样的处理:

    else if( enemy->m_CurrentFrame == 0 &&
            ( projectile->HitTest( enemy ) ||
              enemy->CompoundHitTest( projectile ) ) ) {
        if( enemy->m_Shield->m_Active == false ) {
            enemy->m_CurrentFrame = 1;
            enemy->m_NextFrameTime = ms_per_frame;
            enemy->m_Explode->Run();
            large_explosion_snd->Play();
        }
        else {
 enemy->ElasticCollision( projectile );
            hit_snd->Play();
        }
        projectile->m_CurrentFrame = 1;
        projectile->m_NextFrameTime = ms_per_frame;
    }
}

在循环遍历所有活动抛射物之后,collisions函数会循环遍历所有小行星,寻找小行星与飞船之间的碰撞。如果飞船没有启用护盾,飞船将被摧毁。我们不对代码的这部分进行任何修改。在我们的代码的早期版本中,如果飞船启用了护盾,我们会摧毁小行星。现在,我们将进行弹性碰撞,这将导致飞船和小行星相互弹开。这就是这个asteroid循环的样子:

for( ita = asteroid_list.begin(); ita != asteroid_list.end(); ita++ ) {
    asteroid = *ita;
    if( asteroid->m_Active ) {
        if( asteroid->HitTest( star ) ) {
            asteroid->Explode();
            small_explosion_snd->Play();
        }
    }
    else {
        continue;
    }

    if( player->m_CurrentFrame == 0 &&
        asteroid->m_Active &&
        ( asteroid->HitTest( player ) ||
          player->CompoundHitTest( asteroid ) ) ) {
        if( player->m_Shield->m_Active == false ) {
            player->m_CurrentFrame = 1;
            player->m_NextFrameTime = ms_per_frame;

            player->m_Explode->Run();
            large_explosion_snd->Play();
        }
        else {
 player->ElasticCollision( asteroid );
            small_explosion_snd->Play();
        }
    }
    if( enemy->m_CurrentFrame == 0 &&
        asteroid->m_Active &&
        ( asteroid->HitTest( enemy ) ||
          enemy->CompoundHitTest( asteroid ) ) ) {
        if( enemy->m_Shield->m_Active == false ) {
            enemy->m_CurrentFrame = 1;
            enemy->m_NextFrameTime = ms_per_frame;

            enemy->m_Explode->Run();
            large_explosion_snd->Play();
        }
        else {
            enemy->ElasticCollision( asteroid );
            small_explosion_snd->Play();
        }
    }
}

现在有两个对ElasticCollision的调用。一个是当玩家飞船与小行星碰撞且玩家飞船的护盾已经启用时。另一个是当敌方飞船与小行星碰撞且敌方飞船的护盾已经启用时。

我们必须对我们的collisions()函数进行的最后一个修改是添加一个新的双重asteroid循环,它将循环遍历我们所有的小行星,寻找它们之间的碰撞。这会产生一个有趣的效果,小行星会像台球一样弹开。如果检测到两个小行星之间的碰撞,我们调用ElasticCollision

Asteroid* asteroid_1;
Asteroid* asteroid_2;

std::vector<Asteroid*>::iterator ita_1;
std::vector<Asteroid*>::iterator ita_2;

for( ita_1 = asteroid_list.begin(); ita_1 != asteroid_list.end(); ita_1++ ) {
    asteroid_1 = *ita_1;
    if( !asteroid_1->m_Active ) {
        continue;
    }

    for( ita_2 = ita_1+1; ita_2 != asteroid_list.end(); ita_2++ ) {
        asteroid_2 = *ita_2;
        if( !asteroid_2->m_Active ) {
            continue;
        }

        if( asteroid_1->HitTest( asteroid_2 ) ) {
 asteroid_1->ElasticCollision( asteroid_2 );
        }
    }
}

对 asteroid.cpp 和 projectile.cpp 的更改

我们需要对asteroid.cppprojectile.cpp进行小的修改。我们为Collider类添加了一个名为m_Mass的新属性,因此所有从Collider派生的类都继承了这个属性。m_Mass属性被我们的ElasticCollision函数使用,以确定这些物体在弹性碰撞后将如何移动。飞船的质量与抛射物的质量之间的比率将用于计算飞船射击抛射物时发生的后坐力的大小。第一个修改是对Projectile类构造函数的修改。以下是该构造函数的新版本:

Projectile::Projectile(): Collider(4.0) {
    m_Active = false;

    SDL_Surface *temp_surface = IMG_Load( c_SpriteFile );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }

    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, temp_surface 
    );

    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }

    SDL_FreeSurface( temp_surface );

 m_Mass = 1.0;
}

唯一的修改是最后一行,我们将m_Mass设置为1.0

m_Mass = 1.0;

需要修改的下一个构造函数位于asteroid.cpp文件中。我们需要修改Asteroid类的构造函数。以下是Asteroid构造函数的新版本:

Asteroid::Asteroid( float x, float y, float velocity, float rotation ): Collider(8.0) {
    SDL_Surface *temp_surface = IMG_Load( ADSTEROID_SPRITE_FILE );
    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    else { printf("success creating asteroid surface\n"); }
    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, temp_surface 
    );
    if( !m_SpriteTexture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return;
    }
    else { printf("success creating asteroid texture\n"); }
    SDL_FreeSurface( temp_surface );
    m_Explode = new Emitter((char*)"/sprites/Explode.png", 100, 0, 360, 
    1000, 0.3, false, 20.0, 40.0, 10, 0, 0, 5, 1.0, 2.0, 1.0, 2.0,
    0xffffff, 0xffffff, 0.01, 10, false, false, 800, 8 ); 
    m_Explode->m_parent_rotation_ptr = &m_Rotation;
    m_Explode->m_parent_x_ptr = &(m_Position.x);
    m_Explode->m_parent_y_ptr = &(m_Position.y);
    m_Explode->m_Active = false;
    m_Chunks = new Emitter((char*)"/sprites/small-asteroid.png",40,0,360, 
    1000, 0.05, false, 80.0, 150.0, 5,0,0,10,2.0,2.0,0.25, 0.5, 0xffffff, 
    0xffffff, 0.1, 10, false, true, 1000, 8 ); 
    m_Chunks->m_parent_rotation_ptr = &m_Rotation;
    m_Chunks->m_parent_x_ptr = &m_Position.x;
    m_Chunks->m_parent_y_ptr = &m_Position.y;
    m_Chunks->m_Active = false;
    m_Position.x = x;
    m_Position.y = y;
    Vector2D direction;
    direction.x = 1;
    direction.Rotate( rotation );
    m_Direction = direction;
    m_Velocity = m_Direction * velocity;
    m_dest.h = m_src.h = m_dest.w = m_src.w = 16;
    m_Rotation = rotation;
    m_Active = true;
    m_CurrentFrame = 0;
    m_NextFrameTime = ms_per_frame;

    m_Mass = 100.0;
}

再次,我们要添加的唯一一行是最后一行,我们将m_Mass设置为100.0

m_Mass = 100.0;

对 ship.cpp 文件的更改

ship.cpp文件的第一个更改将是对Ship构造函数的更改。这是一个简单的更改,我们需要在构造函数的最后进行设置飞船的质量为50.0。以下是Ship类构造函数的新版本:

Ship::Ship() : Collider(8.0) {
    m_Rotation = PI;

    m_LastLaunchTime = current_time;

    m_Accelerating = false;

    m_Exhaust = new Emitter((char*)"/sprites/ProjectileExpOrange.png", 200,
                             -10, 10,
                             400, 1.0, true,
                             0.1, 0.1,
                             30, 0, 12, 0.5,
                             0.5, 1.0,
                             0.5, 1.0,
                             0xffffff, 0xffffff,
                             0.7, 10,
                             true, true,
                             1000, 6 );

    m_Exhaust->m_parent_rotation_ptr = &m_Rotation;
    m_Exhaust->m_parent_x_ptr = &(m_Position.x);
    m_Exhaust->m_parent_y_ptr = &(m_Position.y);
    m_Exhaust->m_x_adjustment = 10;
    m_Exhaust->m_y_adjustment = 10;
    m_Exhaust->m_Active = false;

    m_Explode = new Emitter((char*)"/sprites/Explode.png", 100,
                             0, 360,
                             1000, 0.3, false,
                             20.0, 40.0,
                             10, 0, 0, 5,
                             1.0, 2.0,
                             1.0, 2.0,
                             0xffffff, 0xffffff,
                             0.0, 10,
                             false, false,
                             800, 8 );

    m_Explode->m_parent_rotation_ptr = &m_Rotation;
    m_Explode->m_parent_x_ptr = &(m_Position.x);
    m_Explode->m_parent_y_ptr = &(m_Position.y);
    m_Explode->m_Active = false;

    m_Direction.y = 1.0;

    m_Active = true;
 m_Mass = 50.0;
}

唯一更改的是最后一行:

m_Mass = 50.0;

我们还需要改变Shoot函数以添加后坐力。将添加几行代码来修改飞船的速度,通过添加一个与飞船面对的方向相反的向量,并且其大小基于发射的抛射物的速度和相对质量。以下是新的Shoot函数:

void Ship::Shoot() {
    Projectile* projectile;
    if( current_time - m_LastLaunchTime >= c_MinLaunchTime ) {
        m_LastLaunchTime = current_time;
        projectile = projectile_pool->GetFreeProjectile();
        if( projectile != NULL ) {
            projectile->Launch( m_Position, m_Direction );
            player_laser_snd->Play();
            m_Velocity -= m_Direction * (projectile->c_Velocity * projectile->m_Mass / 
                                                                              m_Mass);
            CapVelocity();
        }
    }
}

这是我们要添加到函数中的两行代码:

m_Velocity -= m_Direction * (projectile->c_Velocity * projectile->m_Mass / m_Mass);
CapVelocity();

编译 physics.html 文件

现在我们已经添加了物理效果,是时候编译我们的代码了。我们可以使用以下em++命令构建physics.html文件:

em++ asteroid.cpp audio.cpp camera.cpp collider.cpp emitter.cpp enemy_ship.cpp finite_state_machine.cpp locator.cpp main.cpp particle.cpp player_ship.cpp projectile_pool.cpp projectile.cpp range.cpp render_manager.cpp shield.cpp ship.cpp star.cpp vector.cpp -o physics.html --preload-file audio --preload-file sprites -std=c++17 -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] 

以下的屏幕截图可能看起来与早期版本相似,但当你发射抛射物时,飞船将向后加速。如果你的护盾打开时与小行星碰撞,你将像台球一样弹开。离太阳太近,引力将开始吸引你的飞船:

图 13.1:physics.html 截图

总结

在本章中,我们讨论了计算机游戏中物理学的历史,以及这一历史可以追溯到第一个计算机游戏SpaceWar!。我们谈到了我们游戏中已经有的物理学,其中包括动量守恒。我们简要讨论了牛顿第三定律及其在游戏中的应用,然后通过使用第三定律在我们的游戏中添加了更多的牛顿物理学。我们为我们的星球添加了一个引力场,并使其以与两个物体之间距离的平方成反比的力吸引我们游戏中的飞船。最后,我们为我们的飞船、抛射物和小行星添加了弹性碰撞。

在下一章中,我们将为我们的游戏添加用户界面UI)。我们还将把游戏分成多个屏幕,并添加鼠标界面。

第十四章:UI 和鼠标输入

用户界面UI)定义了计算机程序与用户之间的交互。在我们的游戏中,到目前为止,我们的交互仅限于控制玩家飞船的键盘界面。当我们编写粒子系统配置应用程序时,我们使用 HTML 来定义更强大的用户界面,这使我们能够输入值来配置我们的粒子系统。从该用户界面,我们的代码必须间接地与 WebAssembly 代码进行交互。这是一种您可以继续在游戏中使用的技术,如果您想利用 HTML 来定义您的用户界面,但它有一些缺点。首先,我们可能希望用户界面元素覆盖我们游戏内容。通过 DOM 进行此类效果的效率不是很高。如果 UI 元素在游戏引擎内部呈现,游戏内的 UI 和对象之间的交互也更容易。此外,您可能正在开发 C/C++代码以用于平台以及 Web 发布。如果是这种情况,您可能不希望 HTML 在用户界面中扮演太大的角色。

在本章中,我们将在游戏中实现一些 UI 功能。我们需要实现一个Button类,这是最简单和最常见的 UI 元素之一。我们还需要实现一个单独的屏幕和游戏状态,以便我们可以有一个开始和结束游戏画面。

您需要在构建中包含几个图像和音频文件,以使此项目正常工作。确保您从此项目的 GitHub 存储库中包含/Chapter14/sprites//Chapter14/audio/文件夹。如果您还没有下载 GitHub 项目,可以在这里在线获取:github.com/PacktPublishing/Hands-On-Game-Development

在本章中,我们将涵盖以下主题:

  • UI 需求

  • 获取鼠标输入

  • 创建一个按钮

  • 开始游戏画面

  • 游戏结束画面

UI 需求

在实现 UI 时,我们需要做的第一件事是确定一些需求。我们的用户界面到底需要什么?其中的第一部分是决定我们游戏需要哪些游戏画面。这通常是游戏设计过程中早期就要做的事情,但因为我正在写一本关于 WebAssembly 的书,所以我把这一步留到了后面的章节。决定游戏需要哪些画面通常涉及故事板和一个过程,通过这个过程,您可以通过讨论(如果有多人在游戏上工作)或者思考用户将如何与您的网页以及网页上的游戏进行交互的方式来决定:

图 14.1:我们用户界面的故事板示例

您不必绘制故事板,但我发现在思考游戏 UI 所需的内容时很有用。当您需要将这些信息传达给另一名团队成员或艺术家时,它甚至更有用。在思考我们在这个游戏中需要什么之前的故事板时,我列出了以下需求清单:

  • 开场画面

  • 说明

  • 播放按钮

  • 游戏游玩画面

  • 得分文本

  • 游戏结束画面

  • 你赢了的消息

  • 你输了的消息

  • 再玩一次按钮

开场画面

我们的游戏需要一个开场画面,原因有几个。首先,我们不希望用户加载网页后立即开始游戏。用户加载网页并不立即开始玩游戏有很多原因。如果他们的连接速度慢,他们可能在游戏加载时离开电脑,可能不会注意到游戏加载完成的那一刻。如果他们通过点击链接来到这个页面,他们可能还没有准备好在游戏加载完成后立即开始玩。在将玩家投入游戏之前,让玩家确认他们已经准备好是一个很好的做法。开场画面还应包括一些基本游戏玩法的说明。街机游戏在街机柜上放置简单的说明,告诉玩家他们必须做什么才能玩游戏。众所周知,游戏 Pong 在柜子上印有说明避免错过球以获得高分。不幸的是,我们没有街机柜来打印我们的说明,所以使用开场游戏画面是下一个最好的选择。我们还需要一个按钮,让用户在点击时开始玩游戏,如下所示:

图 14.2:开场画面图像

游戏画面

游戏画面是我们一直拥有的画面。这是玩家在其中移动他们的太空飞船,试图摧毁敌人飞船的画面。我们可能不需要改变这个画面的工作方式,但我们需要根据游戏状态添加到这个画面的过渡。游戏需要在玩家点击按钮时从开场画面过渡到我们的游戏画面。如果任何一艘飞船被摧毁,玩家还需要从这个画面过渡到游戏结束画面。如下所示:

图 14.3:原始画面现在是游戏画面

游戏结束画面

如果其中一艘飞船被摧毁,游戏就结束了。如果玩家的飞船被摧毁,那么玩家就输了游戏。如果敌人的飞船被摧毁,那么玩家就赢了游戏。游戏结束画面告诉我们游戏结束了,并告诉我们玩家是赢了还是输了。它还需要提供一个按钮,让我们的玩家如果愿意的话可以再次玩游戏。游戏结束画面如下所示:

图 14.4:游戏结束画面

鼠标输入

在我们实现按钮之前,我们需要学习如何在 SDL 中使用鼠标输入。我们用来获取键盘输入的代码在我们的main.cpp文件中。在input函数内,您会找到对SDL_PollEvent的调用,然后是几个不同的 switch 语句。第一个 switch 语句检查event.type是否为SDL_KEYDOWN。第二个 switch 检查event.key.keysym.sym来查看我们按下了哪个键:

if( SDL_PollEvent( &event ) ){
    switch( event.type ){
        case SDL_KEYDOWN:
            switch( event.key.keysym.sym ){
                case SDLK_LEFT:
                    left_key_down = true;
                    break;
                case SDLK_RIGHT:
                    right_key_down = true;
                    break;
                case SDLK_UP:
                    up_key_down = true;
                    break;
                case SDLK_DOWN:
                    down_key_down = true;
                    break;
                case SDLK_f:
                    f_key_down = true;
                    break;
                case SDLK_SPACE:
                    space_key_down = true;
                    break;
                default:
                    break;
            }
            break;

当我们寻找鼠标输入时,我们需要使用相同的SDL_PollEvent函数来检索我们的鼠标事件。我们关心的三个鼠标事件是SDL_MOUSEMOTIONSDL_MOUSEBUTTONDOWNSDL_MOUSEBUTTONUP。一旦我们知道我们正在处理的鼠标事件的类型,我们就可以使用SDL_GetMouseState来找到鼠标事件发生时的xy坐标:

if(SDL_PollEvent( &event ) )
{
    switch (event.type)
    {
        case SDL_MOUSEMOTION:
        {
            int x_val = 0;
            int y_val = 0;
            SDL_GetMouseState( &x_val, &y_val );
            printf(”mouse move x=%d y=%d\n”, x_val, y_val);
        }
        case SDL_MOUSEBUTTONDOWN:
        {
            switch (event.button.button)
            {
                case SDL_BUTTON_LEFT:
                {
                    int x_val = 0;
                    int y_val = 0;
                    SDL_GetMouseState( &x_val, &y_val );
                    printf(”mouse down x=%d y=%d\n”, x_val, y_val);
                    break;
                }
                default:
                {
                    break;
                }
            }
            break;
        }
        case SDL_MOUSEBUTTONUP:
        {
            switch (event.button.button)
            {
                case SDL_BUTTON_LEFT:
                {
                    int x_val = 0;
                    int y_val = 0;
                    SDL_GetMouseState( &x_val, &y_val );
                    printf(”mouse up x=%d y=%d\n”, x_val, y_val);
                    break;
                }
                default:
                {
                    break;
                }
            }
            break;
        }

现在我们可以接收鼠标输入,让我们创建一个简单的用户界面按钮。

创建一个按钮

现在我们知道如何在 WebAssembly 中使用 SDL 捕获鼠标输入,我们可以利用这些知识创建一个可以被鼠标点击的按钮。我们需要做的第一件事是在game.hpp文件中创建一个UIButton类定义。我们的按钮将有多个与之关联的精灵纹理。按钮通常有悬停状态和点击状态,因此如果用户将鼠标悬停在按钮上或点击按钮,我们将希望显示我们精灵的另一个版本:

图 14.5:按钮状态

为了捕获这些事件,我们将需要函数来检测鼠标是否点击了我们的按钮或悬停在其上。以下是我们类定义的样子:

class UIButton {
    public:
        bool m_Hover;
        bool m_Click;
        bool m_Active;
        void (*m_Callback)();

        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 128, .h = 32 };
        SDL_Texture *m_SpriteTexture;
        SDL_Texture *m_ClickTexture;
        SDL_Texture *m_HoverTexture;

        UIButton( int x, int y,
        char* file_name, char* hover_file_name, char* click_file_name,
        void (*callback)() );

        void MouseClick(int x, int y);
        void MouseUp(int x, int y);
        void MouseMove( int x, int y );
        void KeyDown( SDL_Keycode key );
        void RenderUI();
};

前三个属性是按钮状态属性,告诉我们的渲染函数要绘制什么精灵,或者如果按钮处于非活动状态,则不要绘制任何内容。如果m_Hover属性为true,则会导致我们的渲染器绘制m_HoverTexture。如果m_Click属性为true,则会导致我们的渲染器绘制m_ClickTexture。最后,如果将m_Active设置为false,则会导致我们的渲染器不绘制任何内容。

以下一行是指向我们回调函数的函数指针:

void (*m_Callback)();

这个函数指针在我们的构造函数中设置,是我们在有人点击按钮时调用的函数。在函数指针之后,我们有我们的目标矩形,它将在构造函数运行后具有按钮图像文件的位置、宽度和高度:

SDL_Rect m_dest = {.x = 0, .y = 0, .w = 128, .h = 32 };

然后,我们有三个纹理。这些纹理用于根据我们之前讨论的状态标志在渲染时绘制图像:

SDL_Texture *m_SpriteTexture;
SDL_Texture *m_ClickTexture;
SDL_Texture *m_HoverTexture;

接下来,我们有构造函数。此函数获取我们按钮的xy屏幕坐标。之后,有三个字符串,它们是我们将用来加载纹理的三个 PNG 文件的位置。最后一个参数是回调函数的指针:

UIButton( int x, int y,
         char* file_name, char* hover_file_name, char* click_file_name,
         void (*callback)() );

然后,根据鼠标的当前状态,我们将需要在调用SDL_PollEvent之后调用三个函数:

void MouseClick(int x, int y);
void MouseUp(int x, int y);
void MouseMove( int x, int y );

KeyDown函数将在按下键时获取键码,如果键码与我们的热键匹配,我们希望将其用作使用鼠标点击按钮的替代方法:

void KeyDown( SDL_Keycode key );

RenderUI函数类似于我们为其他对象创建的Render函数。RenderUIRender之间的区别在于,当将精灵渲染到屏幕时,Render函数将考虑摄像机位置。RenderUI函数将始终在画布空间中进行渲染:

void RenderUI();

在下一节中,我们将创建用户界面状态信息以跟踪当前屏幕。

屏幕状态

在我们开始向游戏添加新屏幕之前,我们需要创建一些屏幕状态。我们将在main.cpp文件中管理这些状态的大部分内容。不同的屏幕状态将需要不同的输入,将运行不同的逻辑和不同的渲染函数。我们将在我们代码的最高级别管理所有这些,作为我们游戏循环调用的函数。我们将在game.hpp文件中作为枚举定义可能的状态列表:

enum SCREEN_STATE {
    START_SCREEN = 0,
    PLAY_SCREEN = 1,
    PLAY_TRANSITION = 2,
    GAME_OVER_SCREEN = 3,
    YOU_WIN_SCREEN = 4
};

您可能会注意到,即使只有三个不同的屏幕,我们总共有五种不同的屏幕状态。START_SCREENPLAY_SCREEN分别是开始屏幕和播放屏幕。PLAY_TRANSITION状态通过淡入游戏来在START_SCREENPLAY_SCREEN之间过渡屏幕,而不是突然切换到播放。我们将为游戏结束屏幕使用两种不同的状态。这些状态是GAME_OVER_SCREENYOU_WIN_SCREEN。这两种状态之间唯一的区别是游戏结束时显示的消息。

对 games.hpp 的更改

我们将需要对我们的game.hpp文件进行一些额外的更改。除了我们的UIButton类,我们还需要添加一个UISprite类定义文件。UISprite只是一个普通的在画布空间中绘制的图像。它除了作为 UI 元素呈现的精灵之外,不具有任何功能。定义如下:

class UISprite {
    public:
        bool m_Active;
        SDL_Texture *m_SpriteTexture;
        SDL_Rect m_dest = {.x = 0, .y = 0, .w = 128, .h = 32 };
        UISprite( int x, int y, char* file_name );
        void RenderUI();
};

与按钮类似,它具有一个由m_Active属性表示的活动状态。如果此值为 false,则精灵将不会渲染。它还具有精灵纹理和目标属性,告诉渲染器要绘制什么以及在哪里绘制它:

SDL_Texture *m_SpriteTexture;
SDL_Rect m_dest = {.x = 0, .y = 0, .w = 128, .h = 32 };

它有一个简单的构造函数,接受我们将在画布上呈现精灵的xy坐标,以及虚拟文件系统中图像的文件名,我们将从中加载精灵:

UISprite( int x, int y, char* file_name );

最后,它有一个名为RenderUI的渲染函数,将精灵呈现到画布上:

void RenderUI();

修改 RenderManager 类

RenderManager类将需要一个新属性和一个新函数。在我们游戏的先前版本中,我们可以呈现一种类型的背景,那就是我们的滚动星空。当我们呈现我们的开始屏幕时,我想使用一个包含一些游戏玩法说明的新自定义背景。

这是RenderManager类定义的新版本:

class RenderManager {
    public:
        const int c_BackgroundWidth = 800;
        const int c_BackgroundHeight = 600;
        SDL_Texture *m_BackgroundTexture;
        SDL_Rect m_BackgroundDest = {.x = 0, .y = 0, .w = 
        c_BackgroundWidth, .h = c_BackgroundHeight };
        SDL_Texture *m_StartBackgroundTexture;

        RenderManager();
        void RenderBackground();
        void RenderStartBackground(int alpha = 255);
        void Render( SDL_Texture *tex, SDL_Rect *src, SDL_Rect *dest, 
        float rad_rotation = 0.0,
                     int alpha = 255, int red = 255, int green = 255, 
                     int blue = 255 );
        void RenderUI( SDL_Texture *tex, SDL_Rect *src, SDL_Rect *dest, 
        float rad_rotation = 0.0,
                       int alpha = 255, int red = 255, int green = 255, 
                       int blue = 255 );
};

我们添加了一个新的SDL_Texture,我们将使用它在开始屏幕上呈现背景图像:

SDL_Texture *m_StartBackgroundTexture;

除了新属性之外,我们还添加了一个新函数,在开始屏幕激活时呈现该图像:

void RenderStartBackground(int alpha = 255);

传入此函数的 alpha 值将用于在PLAY_TRANSITION屏幕状态期间淡出开始屏幕。该过渡状态将在玩家点击“播放”按钮时开始,并持续约一秒钟。

新的外部变量

我们需要添加三个新的extern变量定义,这些变量将引用我们在main.cpp文件中声明的变量。其中两个变量是指向UISprite对象的指针,其中一个变量是指向UIButton的指针。以下是三个extern定义:

extern UISprite *you_win_sprite;
extern UISprite *game_over_sprite;
extern UIButton* play_btn;

我们在游戏结束屏幕上使用这两个UISprite指针。第一个you_win_sprite是玩家赢得游戏时将显示的精灵。第二个精灵game_over_sprite是玩家失败时将显示的精灵。最后一个变量play_btn是在开始屏幕上显示的播放按钮。

对 main.cpp 的更改

我们从游戏循环内管理新的屏幕状态。因此,我们将在main.cpp文件中进行大部分更改。我们需要将input函数分解为三个新函数,分别用于我们的游戏屏幕中的每一个。我们需要将我们的render函数分解为start_renderplay_render函数。我们不需要end_render函数,因为在显示结束屏幕时,我们将继续使用play_render函数。

我们还需要一个函数来显示开始屏幕和游戏屏幕之间的过渡。在游戏循环内,我们需要添加逻辑以根据当前屏幕执行不同的循环逻辑。

添加全局变量

我们需要对main.cpp文件进行的第一个更改是添加新的全局变量。我们将需要新的全局变量来表示我们的用户界面精灵和按钮。我们将需要一个新的全局变量来表示当前屏幕状态,状态之间的过渡时间,以及告诉我们玩家是否赢得了游戏的标志。以下是我们在main.cpp文件中需要的新全局变量:

UIButton* play_btn;
UIButton* play_again_btn;
UISprite *you_win_sprite;
UISprite *game_over_sprite;
SCREEN_STATE current_screen = START_SCREEN;
int transition_time = 0;
bool you_win = false;

前两个变量是UIButton对象指针。第一个是play_btn,这是用户将点击以开始玩游戏的开始屏幕按钮。第二个是play_again_btn,这是玩家可以点击以重新开始游戏的游戏结束屏幕上的按钮。在 UIButtons 之后,我们有两个UISprite对象:

UISprite *you_win_sprite;
UISprite *game_over_sprite;

这些是显示在游戏结束屏幕上的精灵。显示哪个精灵取决于玩家是否摧毁了敌舰还是相反。在这些精灵之后,我们有一个SCREEN_STATE变量,用于跟踪当前屏幕状态:

SCREEN_STATE current_screen = START_SCREEN;

transition_time变量用于跟踪开始屏幕和游戏屏幕之间过渡状态中剩余的时间量。you_win标志在游戏结束时设置,并用于跟踪谁赢得了游戏。

输入函数

我们游戏的先前版本有一个单一的input函数,它使用SDL_PollEvent来轮询按键。在这个版本中,我们希望为三个屏幕状态中的每一个都有一个输入函数。我们应该做的第一件事是将原始的input函数重命名为play_input。这将不再是一个通用的输入函数,它只会执行游戏屏幕的输入功能。现在我们已经重命名了原始的输入函数,让我们定义开始屏幕的输入函数并称之为start_input

void start_input() {
    if(SDL_PollEvent( &event ) )
    {
        switch (event.type)
        {
            case SDL_MOUSEMOTION:
            {
                int x_val = 0;
                int y_val = 0;
                SDL_GetMouseState( &x_val, &y_val );
                play_btn->MouseMove(x_val, y_val);
            }
            case SDL_MOUSEBUTTONDOWN:
            {
                switch (event.button.button)
                {
                    case SDL_BUTTON_LEFT:
                    {
                        int x_val = 0;
                        int y_val = 0;
                        SDL_GetMouseState( &x_val, &y_val );
                        play_btn->MouseClick(x_val, y_val);
                        break;
                    }
                    default:
                    {
                        break;
                    }
                }
                break;
            }
            case SDL_MOUSEBUTTONUP:
            {
                switch (event.button.button)
                {
                    case SDL_BUTTON_LEFT:
                    {
                        int x_val = 0;
                        int y_val = 0;
                        SDL_GetMouseState( &x_val, &y_val );
                        play_btn->MouseUp(x_val, y_val);
                        break;
                    }
                    default:
                    {
                        break;
                    }
                }
                break;
            }
            case SDL_KEYDOWN:
            {
                play_btn->KeyDown( event.key.keysym.sym );
            }
        }
    }
}

与我们的play_input函数一样,start_input函数将调用SDL_PollEvent。除了检查SDL_KEYDOWN来确定是否按下了键,我们还将检查三个鼠标事件:SDL_MOUSEMOTIONSDL_MOUSEBUTTONDOWNSDL_MOUSEBUTTONUP。在检查这些鼠标事件时,我们将根据我们检索到的SDL_GetMouseState值来调用play_btn函数。鼠标事件将触发以下代码:

case SDL_MOUSEMOTION:
{
    int x_val = 0;
    int y_val = 0;
    SDL_GetMouseState( &x_val, &y_val );
    play_btn->MouseMove(x_val, y_val);
}

如果event.typeSDL_MOUSEMOTION,我们创建x_valy_val整数变量,并使用SDL_GetMouseState来检索鼠标光标的xy坐标。然后我们调用play_btn->MouseMove(x_val, y_val)。这将鼠标 x 和 y 坐标传递给播放按钮,按钮使用这些值来确定按钮是否处于悬停状态。如果event.typeSDL_MOUSEBUTTONDOWN,我们会做类似的事情:

case SDL_MOUSEBUTTONDOWN:
{
    switch (event.button.button)
    {
        case SDL_BUTTON_LEFT:
        {
            int x_val = 0;
            int y_val = 0;

            SDL_GetMouseState( &x_val, &y_val );
            play_btn->MouseClick(x_val, y_val);
            break;
        }
        default:
        {
            break;
        }
    }
    break;
}

如果鼠标按钮被按下,我们会查看event.button.button来确定被点击的按钮是否是左鼠标按钮。如果是,我们将使用x_valy_valSDL_GetMouseState结合来找到鼠标光标的位置。我们使用这些值来调用play_btn->MouseClick(x_val, y_val)MouseClick函数将确定按钮点击是否落在按钮内,如果是,它将调用按钮的回调函数。

当事件是SDL_MOUSEBUTTONUP时执行的代码与SDL_MOUSEBUTTONDOWN非常相似,唯一的区别是它调用play_btn->MouseUp而不是play_btn->MouseClick

case SDL_MOUSEBUTTONUP:
{
    switch (event.button.button)
    {
        case SDL_BUTTON_LEFT:
        {
            int x_val = 0;
            int y_val = 0;

            SDL_GetMouseState( &x_val, &y_val );
            play_btn->MouseUp(x_val, y_val);
            break;
        }
        default:
        {
            break;
        }
    }
    break;
}

除了鼠标事件,我们还将把键盘事件传递给我们的按钮。这样做是为了我们可以创建一个热键来触发回调函数:

case SDL_KEYDOWN:
{
    play_btn->KeyDown( event.key.keysym.sym );
}

结束输入函数

start_input函数之后,我们将定义end_input函数。end_input函数与start_input函数非常相似。唯一的显著区别是play_btn对象被play_again_btn对象替换,它将有一个不同的回调和与之关联的 SDL 纹理:

void end_input() {
    if(SDL_PollEvent( &event ) )
    {
        switch(event.type)
        {
            case SDL_MOUSEMOTION:
            {
                int x_val = 0;
                int y_val = 0;
                SDL_GetMouseState( &x_val, &y_val );
                play_again_btn->MouseMove(x_val, y_val);
            }
            case SDL_MOUSEBUTTONDOWN:
            {
                switch(event.button.button)
                {
                    case SDL_BUTTON_LEFT:
                    {
                        int x_val = 0;
                        int y_val = 0;
                        SDL_GetMouseState( &x_val, &y_val );
                        play_again_btn->MouseClick(x_val, y_val);
                        break;
                    }
                    default:
                    {
                        break;
                    }
                }
                break;
            }
            case SDL_MOUSEBUTTONUP:
            {
                switch(event.button.button)
                {
                    case SDL_BUTTON_LEFT:
                    {
                        int x_val = 0;
                        int y_val = 0;
                        SDL_GetMouseState( &x_val, &y_val );
                        play_again_btn->MouseUp(x_val, y_val);
                        break;
                    }
                    default:
                    {
                        break;
                    }
                }
                break;
            }
            case SDL_KEYDOWN:
            {
                printf("SDL_KEYDOWN\n");
                play_again_btn->KeyDown( event.key.keysym.sym );
            }
        }
    }
}

渲染函数

在我们游戏的先前版本中,我们有一个单一的渲染函数。现在,我们必须为我们的开始屏幕和游戏屏幕分别设置渲染函数。现有的渲染器将成为我们新的游戏屏幕渲染器,因此我们必须将render函数重命名为play_render。我们还需要为我们的开始屏幕添加一个名为start_render的渲染函数。这个函数将渲染我们的新背景和play_btn。以下是start_render的代码:

void start_render() {
    render_manager->RenderStartBackground();
    play_btn->RenderUI();
}

碰撞函数

collisions()函数需要进行一些小的修改。当玩家飞船或敌人飞船被摧毁时,我们需要将当前屏幕更改为游戏结束屏幕。根据哪艘飞船被摧毁,我们将需要将其更改为胜利屏幕或失败屏幕。以下是我们碰撞函数的新版本:

void collisions() {
 Asteroid* asteroid;
 std::vector<Asteroid*>::iterator ita;
    if( player->m_CurrentFrame == 0 && player->CompoundHitTest( star ) ) {
        player->m_CurrentFrame = 1;
        player->m_NextFrameTime = ms_per_frame;
        player->m_Explode->Run();
        current_screen = GAME_OVER_SCREEN;
        large_explosion_snd->Play();
    }
    if( enemy->m_CurrentFrame == 0 && enemy->CompoundHitTest( star ) ) {
        enemy->m_CurrentFrame = 1;
        enemy->m_NextFrameTime = ms_per_frame;
        current_screen = YOU_WIN_SCREEN;
        enemy->m_Explode->Run();
        large_explosion_snd->Play();
    }
    Projectile* projectile;
    std::vector<Projectile*>::iterator it;
    for(it=projectile_pool->m_ProjectileList.begin(); 
    it!=projectile_pool->m_ProjectileList.end();it++){
        projectile = *it;
        if( projectile->m_CurrentFrame == 0 && projectile->m_Active ) {
            for( ita = asteroid_list.begin(); ita!=asteroid_list.end(); 
            ita++ ) {
                asteroid = *ita;
                if( asteroid->m_Active ) {
                    if( asteroid->HitTest( projectile ) ) {
                        asteroid->ElasticCollision( projectile );
                        projectile->m_CurrentFrame = 1;
                        projectile->m_NextFrameTime = ms_per_frame;
                        small_explosion_snd->Play();
                    }
                }
            }
            if( projectile->HitTest( star ) ){
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
                small_explosion_snd->Play();
            }
            else if( player->m_CurrentFrame == 0 &&
                ( projectile->HitTest( player ) || player->CompoundHitTest( 
                 projectile ) ) ) {
                if( player->m_Shield->m_Active == false ) {
                    player->m_CurrentFrame = 1;
                    player->m_NextFrameTime = ms_per_frame;
                    current_screen = GAME_OVER_SCREEN;
                    player->m_Explode->Run();
                    large_explosion_snd->Play();
                }
                else {
                    hit_snd->Play();
                    player->ElasticCollision( projectile );
                }
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
            else if( enemy->m_CurrentFrame == 0 &&
                ( projectile->HitTest( enemy ) || enemy->CompoundHitTest( 
                 projectile ) ) ) {
                if( enemy->m_Shield->m_Active == false ) {
                    enemy->m_CurrentFrame = 1;
                    enemy->m_NextFrameTime = ms_per_frame;
                    current_screen = YOU_WIN_SCREEN;
                    enemy->m_Explode->Run();
                    large_explosion_snd->Play();
                    enemy->m_Shield->m_ttl -= 1000;
                }
                else {
                    enemy->ElasticCollision( projectile );
                    hit_snd->Play();
                }
                projectile->m_CurrentFrame = 1;
                projectile->m_NextFrameTime = ms_per_frame;
            }
        }
    }
    for( ita = asteroid_list.begin(); ita != asteroid_list.end(); ita++ ) {
        asteroid = *ita;
        if( asteroid->m_Active ) {
            if( asteroid->HitTest( star ) ) {
                asteroid->Explode();
                small_explosion_snd->Play();
            }
        }
        else { continue; }
        if( player->m_CurrentFrame == 0 && asteroid->m_Active &&
          ( asteroid->HitTest( player ) || player->CompoundHitTest( 
           asteroid ) ) ) {
            if( player->m_Shield->m_Active == false ) {
                player->m_CurrentFrame = 1;
                player->m_NextFrameTime = ms_per_frame;

                player->m_Explode->Run();
                current_screen = GAME_OVER_SCREEN;
                large_explosion_snd->Play();
            }
            else {
                player->ElasticCollision( asteroid );
                small_explosion_snd->Play();
            }
        }
        if( enemy->m_CurrentFrame == 0 && asteroid->m_Active &&
          ( asteroid->HitTest( enemy ) || enemy->CompoundHitTest( asteroid 
           ) ) ) {
            if( enemy->m_Shield->m_Active == false ) {
                enemy->m_CurrentFrame = 1;
                enemy->m_NextFrameTime = ms_per_frame;

                enemy->m_Explode->Run();
                current_screen = YOU_WIN_SCREEN;
                large_explosion_snd->Play();
            }
            else {
                enemy->ElasticCollision( asteroid );
                small_explosion_snd->Play();
            }
        }
    }
    Asteroid* asteroid_1;
    Asteroid* asteroid_2;
    std::vector<Asteroid*>::iterator ita_1;
    std::vector<Asteroid*>::iterator ita_2;
    for( ita_1 = asteroid_list.begin(); ita_1 != asteroid_list.end(); 
    ita_1++ ) {
        asteroid_1 = *ita_1;
        if( !asteroid_1->m_Active ) { continue; }
        for( ita_2 = ita_1+1; ita_2 != asteroid_list.end(); ita_2++ ) {
            asteroid_2 = *ita_2;
            if( !asteroid_2->m_Active ) { continue; }
            if(asteroid_1->HitTest(asteroid_2)) { 
            asteroid_1->ElasticCollision( asteroid_2 ); }
        }
    }
}

您会注意到每次玩家被销毁时,都会调用player->m_Explode->Run()。现在我们会在这行代码后面调用current_screen = GAME_OVER_SCREEN,将屏幕设置为玩家失败画面。我们还可以通过向Ship类添加一个函数来完成此操作,该函数既运行爆炸动画又设置游戏画面,但我选择通过在main函数内部进行更改来修改更少的文件。如果我们将此项目用于除演示目的之外的其他用途,我可能会选择另一种方式。

我们对碰撞所做的其他更改类似。每当敌人被enemy->m_Explode->Run()函数销毁时,我们会跟着一行代码将当前画面设置为“你赢了”画面,就像这样:

current_screen = YOU_WIN_SCREEN;

过渡状态

从开始画面突然过渡到游戏画面可能有点令人不适。为了使过渡更加平滑,我们将创建一个名为draw_play_transition的过渡函数,它将使用 alpha 淡入淡出来将我们的画面从开始画面过渡到游戏画面。该函数如下所示:

void draw_play_transition() {
    transition_time -= diff_time;
    if( transition_time <= 0 ) {
        current_screen = PLAY_SCREEN;
        return;
    }
    render_manager->RenderStartBackground(transition_time/4);
}

此函数使用我们之前创建的transition_time全局变量,并减去自上一帧以来的毫秒数。它使用该值除以 4 作为 alpha 值,用于绘制开始画面背景,使其在过渡到游戏画面时淡出。当过渡时间降至 0 以下时,我们将当前画面设置为播放画面。过渡开始时,我们将transition_time设置为 1,020 毫秒,稍多于一秒。将该值除以 4 会得到一个从 255(完全不透明)到 0(完全透明)的值。

游戏循环

game_loop函数将需要修改以执行每个画面的不同逻辑。以下是游戏循环的新版本:

void game_loop() {
    current_time = SDL_GetTicks();
    diff_time = current_time - last_time;
    delta_time = diff_time / 1000.0;
    last_time = current_time;
    if( current_screen == START_SCREEN ) {
        start_input();
        start_render();
    }
    else if( current_screen == PLAY_SCREEN || current_screen == 
             PLAY_TRANSITION ) {
        play_input();
        move();
        collisions();
        play_render();
        if( current_screen == PLAY_TRANSITION ) {
            draw_play_transition();
        }
    }
    else if( current_screen == YOU_WIN_SCREEN || current_screen == 
             GAME_OVER_SCREEN ) {
        end_input();
        move();
        collisions();
        play_render();
        play_again_btn->RenderUI();
        if( current_screen == YOU_WIN_SCREEN ) {
            you_win_sprite->RenderUI();
        }
        else {
            game_over_sprite->RenderUI();
        }
    }
}

我们有新的分支逻辑,根据当前画面进行分支。第一个if块在当前画面是开始画面时运行start_inputstart_render函数:

if( current_screen == START_SCREEN ) {
    start_input();
    start_render();
}

游戏画面和游戏过渡与原始游戏循环逻辑相同,除了代码块末尾的PLAY_TRANSITION周围的if块。这通过调用我们之前定义的draw_play_transition()函数来绘制游戏过渡:

else if( current_screen == PLAY_SCREEN || current_screen == PLAY_TRANSITION ) {
    play_input();
    move();
    collisions();
    play_render();
    if( current_screen == PLAY_TRANSITION ) {
        draw_play_transition();
    }
}

函数中的最后一块代码是游戏结束画面。如果当前画面是YOU_WIN_SCREEN,它将渲染you_win_sprite,如果当前画面是GAME_OVER_SCREEN,它将渲染game_over_sprite

else if( current_screen == YOU_WIN_SCREEN || current_screen == 
         GAME_OVER_SCREEN ) {
    end_input();
    move();
    collisions();
    play_render();
    play_again_btn->RenderUI();
    if( current_screen == YOU_WIN_SCREEN ) {
        you_win_sprite->RenderUI();
    }
    else {
        game_over_sprite->RenderUI();
    }
}

播放和再玩一次回调

在对游戏循环进行更改后,我们需要为我们的按钮添加一些回调函数。其中之一是play_click函数。这是当玩家在开始画面上点击播放按钮时运行的回调。此函数将当前画面设置为播放过渡,并将过渡时间设置为 1,020 毫秒:

void play_click() {
    current_screen = PLAY_TRANSITION;
    transition_time = 1020;
}

之后,我们将定义play_again_click回调。当玩家在游戏结束画面上点击再玩一次按钮时,此函数将运行。因为这是一个网络游戏,我们将使用一个小技巧来简化这个逻辑。在几乎任何其他平台上编写的游戏中,您需要创建一些重新初始化逻辑,需要回到游戏中并重置所有内容的状态。我们将通过使用 JavaScript 简单地重新加载网页来作弊

void play_again_click() {
    EM_ASM(
        location.reload();
    );
}

这种作弊方法并不适用于所有游戏。重新加载某些游戏会导致无法接受的延迟。对于某些游戏,可能有太多的状态信息需要保留。但是,对于这个游戏,重新加载页面是一个快速简单的方法来完成任务。

主函数的更改

我们在应用程序中使用main函数来执行所有游戏初始化。这是我们需要添加一些代码来初始化游戏结束画面和新按钮所使用的精灵的地方。

在以下代码片段中,我们有我们的新精灵初始化行:

game_over_sprite = new UISprite( 400, 300, (char*)"/sprites/GameOver.png" );
game_over_sprite->m_Active = true;
you_win_sprite = new UISprite( 400, 300, (char*)"/sprites/YouWin.png" );
you_win_sprite->m_Active = true;

您可以看到,我们将game_over_sprite坐标和you_win_sprite坐标设置为400, 300。这将使这些精灵位于屏幕中央。我们设置两个精灵都处于活动状态,因为它们只会在游戏结束屏幕上呈现。在代码的后面,我们将调用我们的UIButton对象的构造函数:

play_btn = new UIButton(400, 500,
                     (char*)"/sprites/play_button.png",
                     (char*)"/sprites/play_button_hover.png",
                     (char*)"/sprites/play_button_click.png",
                     play_click );

play_again_btn = new UIButton(400, 500,
                     (char*)"/sprites/play_again_button.png",
                     (char*)"/sprites/play_again_button_hover.png",
                     (char*)"/sprites/play_again_button_click.png",
                     play_again_click );

这将两个按钮都放置在400, 500,在 x 轴上居中,但靠近游戏屏幕底部的 y 轴。回调设置为play_clickplay_again_click,我们之前定义过。以下是整个main函数的样子:

int main() {
    SDL_Init( SDL_INIT_VIDEO | SDL_INIT_AUDIO );
    int return_val = SDL_CreateWindowAndRenderer( CANVAS_WIDTH, 
    CANVAS_HEIGHT, 0, &window, &renderer );
    if( return_val != 0 ) {
        printf("Error creating renderer %d: %s\n", return_val, 
        IMG_GetError() );
        return 0;
    }
    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );
    game_over_sprite = new UISprite( 400, 300, 
    (char*)"/sprites/GameOver.png" );
    game_over_sprite->m_Active = true;
    you_win_sprite = new UISprite( 400, 300, 
    (char*)"/sprites/YouWin.png" );
    you_win_sprite->m_Active = true;
    last_frame_time = last_time = SDL_GetTicks();
    player = new PlayerShip();
    enemy = new EnemyShip();
    star = new Star();
    camera = new Camera(CANVAS_WIDTH, CANVAS_HEIGHT);
    render_manager = new RenderManager();
    locator = new Locator();
    enemy_laser_snd = new Audio(ENEMY_LASER, false);
    player_laser_snd = new Audio(PLAYER_LASER, false);
    small_explosion_snd = new Audio(SMALL_EXPLOSION, true);
    large_explosion_snd = new Audio(LARGE_EXPLOSION, true);
    hit_snd = new Audio(HIT, false);
    device_id = SDL_OpenAudioDevice(NULL, 0, &(enemy_laser_snd->spec), 
    NULL, 0);
    if (device_id == 0) {
        printf("Failed to open audio: %s\n", SDL_GetError());
    }
    SDL_PauseAudioDevice(device_id, 0);
    int asteroid_x = 0;
    int asteroid_y = 0;
    int angle = 0;
    // SCREEN 1
    for( int i_y = 0; i_y < 8; i_y++ ) {
        asteroid_y += 100;
        asteroid_y += rand() % 400;
        asteroid_x = 0;
        for( int i_x = 0; i_x < 12; i_x++ ) {
            asteroid_x += 66;
            asteroid_x += rand() % 400;
            int y_save = asteroid_y;
            asteroid_y += rand() % 400 - 200;
            angle = rand() % 359;
            asteroid_list.push_back(
            new Asteroid( asteroid_x, asteroid_y,
                          get_random_float(0.5, 1.0),
                          DEG_TO_RAD(angle) ) );
            asteroid_y = y_save;
        }
    }
    projectile_pool = new ProjectilePool();
    play_btn = new UIButton(400, 500,
                     (char*)"/sprites/play_button.png",
                     (char*)"/sprites/play_button_hover.png",
                     (char*)"/sprites/play_button_click.png",
                     play_click );
    play_again_btn = new UIButton(400, 500,
                     (char*)"/sprites/play_again_button.png",
                     (char*)"/sprites/play_again_button_hover.png",
                     (char*)"/sprites/play_again_button_click.png",
                     play_again_click );
    emscripten_set_main_loop(game_loop, 0, 0);
    return 1;
}

在下一节中,我们将在我们的ui_button.cpp文件中定义函数。

ui_button.cpp

UIButton对象有几个必须定义的函数。我们创建了一个新的ui_button.cpp文件,将保存所有这些新函数。我们需要定义一个构造函数,以及MouseMoveMouseClickMouseUpKeyDownRenderUI

首先,我们将包括我们的game.hpp文件:

#include "game.hpp"

现在,我们将定义我们的构造函数:

UIButton::UIButton( int x, int y, char* file_name, char* hover_file_name, char* click_file_name, void (*callback)() ) {
    m_Callback = callback;
    m_dest.x = x;
    m_dest.y = y;
    SDL_Surface *temp_surface = IMG_Load( file_name );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating ui button surface\n");
    }
    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );
    if( !m_SpriteTexture ) {
        return;
    }
    SDL_QueryTexture( m_SpriteTexture,
                        NULL, NULL,
                        &m_dest.w, &m_dest.h );
    SDL_FreeSurface( temp_surface );

     temp_surface = IMG_Load( click_file_name );
    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating ui button click surface\n");
    }
    m_ClickTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    if( !m_ClickTexture ) {
        return;
    }
    SDL_FreeSurface( temp_surface );

    temp_surface = IMG_Load( hover_file_name );
    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating ui button hover surface\n");
    }
    m_HoverTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    if( !m_HoverTexture ) {
        return;
    }
    SDL_FreeSurface( temp_surface );

    m_dest.x -= m_dest.w / 2;
    m_dest.y -= m_dest.h / 2;

    m_Hover = false;
    m_Click = false;
    m_Active = true;
}

构造函数从传入的参数设置回调函数开始:

m_Callback = callback;

然后,它从我们传递的参数设置了m_dest矩形的xy坐标:

m_dest.x = x;
m_dest.y = y;

之后,它将三个不同的图像文件加载到三个不同的纹理中,用于按钮、按钮的悬停状态和按钮的点击状态:

SDL_Surface *temp_surface = IMG_Load( file_name );

if( !temp_surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return;
}
else {
    printf("success creating ui button surface\n");
}
m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, temp_surface );

if( !m_SpriteTexture ) {
    return;
}
SDL_QueryTexture( m_SpriteTexture,
                  NULL, NULL,
                  &m_dest.w, &m_dest.h );
SDL_FreeSurface( temp_surface );

temp_surface = IMG_Load( click_file_name );

if( !temp_surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return;
}
else {
    printf("success creating ui button click surface\n");
}
m_ClickTexture = SDL_CreateTextureFromSurface( renderer, temp_surface );

if( !m_ClickTexture ) {
    return;
}
SDL_FreeSurface( temp_surface );

temp_surface = IMG_Load( hover_file_name );
if( !temp_surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return;
}
else {
    printf("success creating ui button hover surface\n");
}
m_HoverTexture = SDL_CreateTextureFromSurface( renderer, temp_surface );

if( !m_HoverTexture ) {
    return;
}
SDL_FreeSurface( temp_surface );

前面的代码应该看起来很熟悉,因为在这一点上,将图像文件加载到SDL_Texture对象中是我们经常做的事情。之后,我们使用之前查询的宽度和高度值来居中目标矩形:

m_dest.x -= m_dest.w / 2;
m_dest.y -= m_dest.h / 2;

然后,我们设置悬停、点击和活动状态标志:

m_Hover = false;
m_Click = false;
m_Active = true;

MouseMove 功能

我们需要一个函数来确定鼠标光标是否移动到我们的按钮上。我们从我们的输入函数中调用MouseMove函数,并传入当前鼠标光标的xy坐标。我们检查这些坐标是否与我们的m_dest矩形重叠。如果是,我们将悬停标志设置为true。如果不是,我们将悬停标志设置为false

void UIButton::MouseMove(int x, int y) {
    if( x >= m_dest.x && x <= m_dest.x + m_dest.w &&
        y >= m_dest.y && y <= m_dest.y + m_dest.h ) {
        m_Hover = true;
    }
    else {
        m_Hover = false;
    }
}

MouseClick 功能

MouseClick函数与MouseMove函数非常相似。当用户按下鼠标左键时,也会从我们的输入函数中调用。鼠标光标的xy坐标被传入,函数使用m_dest矩形来查看鼠标光标在点击时是否在按钮上。如果是,我们将单击标志设置为true。如果不是,我们将单击标志设置为false

void UIButton::MouseClick(int x, int y) {
    if( x >= m_dest.x && x <= m_dest.x + m_dest.w &&
        y >= m_dest.y && y <= m_dest.y + m_dest.h ) {
        m_Click = true;
    }
    else {
        m_Click = false;
    }
}

鼠标弹起功能

当释放鼠标左键时,我们调用此功能。无论鼠标光标坐标如何,我们都希望将单击标志设置为false。如果鼠标在释放按钮时位于按钮上,并且按钮被点击,我们需要调用回调函数:

void UIButton::MouseUp(int x, int y) {
    if( m_Click == true &&
        x >= m_dest.x && x <= m_dest.x + m_dest.w &&
        y >= m_dest.y && y <= m_dest.y + m_dest.h ) {
        if( m_Callback != NULL ) {
            m_Callback();
        }
    }
    m_Click = false;
}

KeyDown 功能

我本可以使按键按下功能更加灵活。最好将热键设置为对象中设置的值。这将支持屏幕上不止一个按钮。目前,如果有人按下Enter键,屏幕上的所有按钮都将被点击。这对我们的游戏不是问题,因为我们不会在屏幕上放置多个按钮,但是如果您想改进热键功能,这应该不难。因为该函数将其检查的键硬编码为SDLK_RETURN。以下是我们的函数版本:

void UIButton::KeyDown( SDL_Keycode key ) {
    if( key == SDLK_RETURN) {
        if( m_Callback != NULL ) {
            m_Callback();
        }
    }
}

RenderUI 功能

RenderUI函数检查按钮中的各种状态标志,并根据这些值呈现正确的精灵。如果m_Active标志为false,函数将不呈现任何内容。以下是函数:

void UIButton::RenderUI() {
    if( m_Active == false ) {
        return;
    }
    if( m_Click == true ) {
        render_manager->RenderUI( m_ClickTexture, NULL, &m_dest, 0.0,
                                    0xff, 0xff, 0xff, 0xff );
    }
    else if( m_Hover == true ) {
        render_manager->RenderUI( m_HoverTexture, NULL, &m_dest, 0.0,
                                    0xff, 0xff, 0xff, 0xff );
    }
    else {
        render_manager->RenderUI( m_SpriteTexture, NULL, &m_dest, 0.0,
                                    0xff, 0xff, 0xff, 0xff );
    }
}

在下一节中,我们将在我们的ui_sprite.cpp文件中定义函数。

ui_sprite.cpp

UISprite类非常简单。它只有两个函数:一个构造函数和一个渲染函数。与项目中的每个其他 CPP 文件一样,我们必须首先包含game.hpp文件:

#include "game.hpp"

定义构造函数

构造函数非常熟悉。它将m_dest矩形的xy值设置为传入构造函数的值。它使用我们传入的file_name变量从虚拟文件系统加载纹理。最后,它使用SDL_QueryTexture函数检索的宽度和高度值来居中m_dest矩形。以下是构造函数的代码:

UISprite::UISprite( int x, int y, char* file_name ) {
    m_dest.x = x;
    m_dest.y = y;
    SDL_Surface *temp_surface = IMG_Load( file_name );

    if( !temp_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return;
    }
    else {
        printf("success creating ui button surface\n");
    }

    m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, 
    temp_surface );

    if( !m_SpriteTexture ) {
        return;
    }
    SDL_QueryTexture( m_SpriteTexture,
                      NULL, NULL,
                      &m_dest.w, &m_dest.h );
    SDL_FreeSurface( temp_surface );
    m_dest.x -= m_dest.w / 2;
    m_dest.y -= m_dest.h / 2;
}

RenderUI 函数

我们精灵的RenderUI函数也很简单。它检查精灵是否处于活动状态,如果是,则调用渲染管理器的RenderUI函数。以下是代码:

void UISprite::RenderUI() {
    if( m_Active == false ) {
        return;
    }
    render_manager->RenderUI( m_SpriteTexture, NULL, &m_dest, 0.0,
                              0xff, 0xff, 0xff, 0xff );
}

编译 ui.html

现在我们已经为我们的游戏添加了用户界面,让我们编译它,从我们的 Web 服务器或 emrun 中提供它,并在 Web 浏览器中打开它。以下是我们需要编译ui.html文件的em++命令:

em++ asteroid.cpp audio.cpp camera.cpp collider.cpp emitter.cpp enemy_ship.cpp finite_state_machine.cpp locator.cpp main.cpp particle.cpp player_ship.cpp projectile_pool.cpp projectile.cpp range.cpp render_manager.cpp shield.cpp ship.cpp star.cpp ui_button.cpp ui_sprite.cpp vector.cpp -o ui.html --preload-file audio --preload-file sprites -std=c++17 -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] 

新版本将打开到我们的开始屏幕。如果您想玩游戏,现在需要点击播放按钮。这是一个截图:

图 14.6:开场画面

您会注意到开场画面上有关于如何玩游戏的说明。在面向动作的网络游戏中通常很好有一个开场画面,因为玩家加载页面时并不总是准备好玩。并非所有网络游戏都需要开场画面。我的网站classicsolitaire.com没有一个。这是因为纸牌是一种回合制游戏,玩家并不会立即投入行动。您的游戏的用户界面需求可能与我们为本书编写的游戏不同。因此,请绘制一个故事板,并花时间收集需求。您会为此感到高兴的。

摘要

在本章中,我们花了一些时间收集用户界面的要求。我们创建了一个故事板,帮助我们思考我们的游戏需要哪些屏幕以及它们可能的外观。我们讨论了开场画面的布局,以及为什么我们需要它。然后,我们将原本是整个游戏的屏幕分解为播放屏幕。然后,我们讨论了游戏结束屏幕的布局以及我们需要的 UI 元素,并学习了如何使用 SDL 检索鼠标输入。我们还创建了一个按钮类作为我们用户界面的一部分,以及一个用于我们屏幕状态的枚举,并讨论了这些状态之间的转换。然后,我们添加了一个精灵用户界面对象,然后修改了我们的渲染管理器,以便我们可以渲染开始屏幕的背景图像。最后,我们对代码进行了更改,以支持多个游戏屏幕。

在下一章中,我们将学习如何编写新的着色器并使用 WebAssembly 的 OpenGL API 实现它们。

第十五章:着色器和 2D 光照

我们已经在第三章中介绍了着色器,WebGL 简介。不幸的是,SDL 不允许用户在不深入库的源代码并在那里修改的情况下自定义其着色器。这种修改超出了本书的范围。

本书的范围。在使用 SDL 与 OpenGL 的组合是很常见的。SDL 可用于渲染游戏的用户界面,而 OpenGL 则渲染游戏对象。本章将与之前的许多章节有所不同,因为我们将不会直接在我们一直在编写的游戏中混合 SDL 和 OpenGL。更新游戏以支持 OpenGL 2D 渲染引擎将需要对游戏进行完全的重新设计。然而,我想为那些有兴趣创建更高级的 2D 渲染引擎的人提供一个章节,让他们尝试结合 OpenGL 和 SDL,并为该引擎编写着色器。

您需要在构建中包含几个图像才能使这个项目工作。确保您包含了这个项目的 GitHub 存储库中的/Chapter15/sprites/文件夹。如果您还没有下载 GitHub 项目,可以在这里在线获取:github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly

在本章中,我们将做以下事情:

  • 使用 SDL 和 OpenGL 为 WebAssembly 重新创建我们在第三章中制作的应用程序,WebGL 简介

  • 学习如何创建一个新的着色器,加载并渲染多个纹理到一个四边形

  • 了解法向图以及它们如何用于在 2D 游戏对象上创建深度的错觉

  • 学习如何在 OpenGL 和 WebAssembly 中使用法向图来近似 2D 中的冯氏光照模型

使用 OpenGL 和 WebAssembly

Emscripten 能够编译使用 OpenGL ES 2.0 或 OpenGL ES 3.0 的 C/C++代码,通过将这些调用映射到 WebGL 或 WebGL 2 调用来实现。因此,Emscripten 只支持与您使用的 WebGL 库内可用的 OpenGL ES 命令的子集。例如,如果您想使用 OpenGL ES 3.0,您需要在编译时通过向 Emscripten 编译器传递-s USE_WEBGL2=1参数来包含 WebGL 2。在本章中,我们将使用 OpenGL ES 2.0 与 SDL 结合使用着色器来渲染精灵,稍后我们将使用 SDL 来渲染代表应用程序中光源位置的图标。SDL 提供了许多 OpenGL 所没有的功能,如音频库、图像加载库以及鼠标和键盘输入库。在许多方面,SDL 更适合于渲染游戏的用户界面,因为它将对象渲染到屏幕坐标而不是 OpenGL 剪辑空间。在幕后,WebAssembly 版本的 SDL 也使用了 Emscripten 的 OpenGL ES 实现,依赖于 WebGL。因此,更好地了解 WebAssembly 的 OpenGL 实现可以帮助我们将游戏开发技能提升到更高的水平,即使我们在本书中开发的游戏中不会使用这些技能。

更多关于着色器的知识

我们在《HTML5 和 WebAssembly》的第二章中简要介绍了着色器的概念。着色器是现代 3D 图形渲染的关键部分。在计算机和视频游戏的早期,图形都是 2D 的,图形渲染的速度取决于系统能够将像素从一个数据缓冲区移动到另一个数据缓冲区的速度。这个过程称为blitting。在早期,一个重要的进步是任天堂在他们的任天堂娱乐系统中添加了一个图片处理单元PPU)。这是一个早期的硬件,旨在通过在不使用游戏系统 CPU 的情况下移动像素来加速图形处理。康柏 Amiga 也是这些早期 2D 图形协处理器的先驱,到了 20 世纪 90 年代中期,blitting 的硬件成为了计算机行业的标准。1996 年,像《奇兵》这样的游戏开始对消费者 3D 图形处理提出需求,早期的图形卡开始提供具有固定功能管线的 GPU。这允许应用程序加载几何数据并在该几何体上执行不可编程的纹理和光照功能。在 21 世纪初,Nvidia 推出了 GeForce 3。这是第一个支持可编程管线的 GPU。最终,这些可编程管线的 GPU 开始围绕统一着色器模型进行标准化,这允许程序员为支持该语言的所有图形卡编写 GLSL 等着色器语言。

GLSL ES 1.0 和 3.0

我们将使用的语言来编写我们的着色器是 GLSL 着色器语言的一个子集,称为 GLSL ES。这个着色器语言恰好适用于 WebGL,因此受到了被移植到 WebAssembly 的 OpenGL ES 版本的支持。我们编写的代码将在 GLSL ES 1.0 和 3.0 上运行,这是 WebAssembly 支持的 GLSL ES 的两个版本。

如果你想知道为什么不支持 GLSL ES 2.0,那是因为它根本不存在。OpenGL ES 1.0 使用了固定功能管线,因此没有与之相关的着色器语言。当 Khronos Group 创建了 OpenGL ES 2.0 时,他们创建了 GLSL ES 1.0 作为与之配套的着色器语言。当他们发布了 OpenGL ES 3.0 时,他们决定希望着色器语言的版本号与 API 的版本号相同。因此,所有新版本的 OpenGL ES 都将配备与之版本号相同的 GLSL 版本。

GLSL 是一种非常类似于 C 的语言。每个着色器都有一个main函数作为其入口点。GLSL ES 2.0 只支持两种着色器类型:顶点着色器片段着色器。这些着色器的执行是高度并行的。如果你习惯于单线程思维,你需要调整你的思维方式。着色器通常同时处理成千上万个顶点和像素。

我在《WebGL 入门》的第三章中简要讨论了顶点和片段的定义。顶点是空间中的一个点,一组顶点定义了我们的图形卡用来渲染屏幕的几何形状。片段是像素候选。通常需要多个片段来确定像素输出。

传递给顶点着色器的几何图形的每个顶点都由该着色器处理。然后使用varying 变量传递值给大量处理单个像素的线程,通过片段着色器。片段着色器接收一个值,该值在多个顶点着色器的输出之间进行插值。片段着色器的输出是一个片段,它是一个像素候选。并非所有片段都成为像素。有些片段被丢弃,这意味着它们根本不会渲染。其他片段被混合以形成完全不同的像素颜色。在第三章中,WebGL 简介中,我们为我们的 WebGL 应用程序创建了一个顶点着色器和一个片段着色器。让我们开始将该应用程序转换为一个 OpenGL/WebAssembly 应用程序。一旦我们有一个工作的应用程序,我们可以进一步讨论着色器和我们可以编写这些着色器的新方法,以改进我们的 2D WebAssembly 游戏。

WebGL 应用程序重现

现在我们将逐步介绍如何重写我们在第三章中制作的 WebGL 应用程序,使用 SDL 和 OpenGL。如果你不记得了,这是一个非常简单的应用程序,每帧都在我们的画布上绘制一艘飞船,并将其向左移动 2 个像素,向上移动一个像素。我们制作这个应用程序的原因是,这是我能想到的在 WebGL 中做的比绘制一个三角形更有趣的最简单的事情。出于同样的原因,这将是我们将使用 OpenGL 进行 WebAssembly 的第一件事情。现在,创建一个名为webgl-redux.c的新文件并打开它。现在,让我们开始添加一些代码。我们需要的第一部分代码是我们的#include命令,以引入我们这个应用程序所需的所有库:

#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>
#include <SDL_opengl.h>
#include <GLES2/gl2.h>
#include <stdlib.h>
#include <emscripten.h>

第一行包括标准的 SDL2 库。第二个库SDL_image.h是我们用来加载图像文件的库。这个文件的第三行包括SDL_opengl.h,这是一个允许我们混合 SDL 和 OpenGL 调用的库。包括GLES2/gl2.h让我们可以使用 OpenGL ES 2.0 的所有 OpenGL 命令。和往常一样,我们包括stdlib.h让我们可以使用printf命令,emscripten.h为我们提供了使用 Emscripten 编译器编译为 WebAssembly 目标所需的函数。

在我们的#include命令之后,我们有一系列#define宏,用于定义我们游戏所需的常量:

#define CANVAS_WIDTH 800
#define CANVAS_HEIGHT 600
#define FLOAT32_BYTE_SIZE 4
#define STRIDE FLOAT32_BYTE_SIZE*4

前两个定义了我们画布的宽度和高度。其余的#define调用用于设置我们在定义顶点缓冲区时将要使用的值。在这些#define宏之后,我们定义了我们着色器的代码。

着色器代码

接下来我将要展示的几个代码块将定义我们需要创建 2D 光照效果的着色器。以下是顶点着色器代码:

const GLchar* vertex_shader_code[] = {
    "precision mediump float; \n"
    "attribute vec4 a_position; \n"
    "attribute vec2 a_texcoord; \n"

    "uniform vec4 u_translate; \n"

    "varying vec2 v_texcoord; \n"

    "void main() { \n"
        "gl_Position = u_translate + a_position; \n"
        "v_texcoord = a_texcoord; \n"
    "} \n"
};

这是我们创建 WebGL 版本应用时使用的相同着色器代码。它在 C 中看起来有点不同,因为 JavaScript 可以使用多行字符串,使得代码更加清晰易读。与 WebGL 版本一样,我们使用精度调用将浮点精度设置为中等。我们设置属性来接收位置和 UV 纹理坐标数据作为向量。我们将使用顶点缓冲对象传递这些向量。我们定义一个 uniform 变量translate,它将是所有顶点使用的相同值,这通常不是我们在游戏中做的方式,但对于这个应用来说完全可以。最后,我们定义一个 varying v_texcoord变量。这个变量将代表我们从顶点着色器传递到片段着色器的纹理坐标值。这个顶点着色器中的main()函数非常简单。它将u_translate uniform 变量传递到顶点着色器中,将通过a_position传递的顶点属性位置添加到最终顶点位置,然后使用gl_Position变量设置。之后,通过将v_texcoord varying 变量设置为a_texcoord,我们将顶点的纹理坐标传递到片段着色器中。

在定义了我们的顶点着色器之后,我们创建了定义我们片段着色器的字符串。片段着色器接收到了v_texcoord的插值版本,这是从我们的顶点着色器传递出来的 varying 变量。你需要暂时戴上并行处理的帽子来理解这是如何工作的。当 GPU 处理我们的顶点着色器和片段着色器时,它不是一次处理一个,而是可能一次处理成千上万个顶点和片段。片段着色器也不是接收来自单个线程的输出,而是来自当前正在处理的多个顶点的混合值。

例如,如果你的顶点着色器有一个名为 X 的 varying 变量作为输出,并且你的片段着色器处于 X 为 0 和 X 为 10 的两个顶点之间的中间位置,那么进入片段的 varying 变量中的值将是 5。这是因为 5 是 0 和 10 两个顶点值之间的中间值。同样,如果片段在两个点之间的 30%位置,X 中的值将是 3。

以下是我们片段着色器代码的定义:

const GLchar* fragment_shader_code[] = {
    "precision mediump float; \n"
    "varying vec2 v_texcoord; \n"

    "uniform sampler2D u_texture; \n"

    "void main() { \n"
        "gl_FragColor = texture2D(u_texture, v_texcoord); \n"
    "} \n"
 };

与我们的顶点着色器一样,我们首先设置精度。之后,我们有一个 varying 变量,这是我们纹理坐标的插值值。这个值存储在v_texcoord中,并将用于将纹理映射到像素颜色上。最后一个变量是一个sampler2D类型的 uniform 变量。这是一个内存块,我们在其中加载了我们的纹理。这个片段着色器的主要功能是使用内置的texture2D函数,使用我们传递到片段着色器中的纹理坐标来获取纹理中的像素颜色。

OpenGL 全局变量

在定义了我们的着色器之后,我们需要在 C 中定义几个变量,用于与它们进行交互:

GLuint program = 0;
GLuint texture;

GLint a_texcoord_location = -1;
GLint a_position_location = -1;

GLint u_texture_location = -1;
GLint u_translate_location = -1;

GLuint vertex_texture_buffer;

OpenGL 使用引用变量与 GPU 进行交互。这些变量中的前两个是GLuint类型。GLuint是无符号整数,使用GLuint类型只是 OpenGL 类型的一种。看到GLuint而不是unsigned int是给阅读你的代码的人一个提示,表明你正在使用这个变量与 OpenGL 进行交互。程序变量最终将保存一个由你的着色器定义的程序的引用,而纹理变量将保存一个已加载到 GPU 中的纹理的引用。在对程序和纹理的引用之后,我们有两个变量,用于引用着色器程序属性。a_texcoord_location变量将是对a_texcoord着色器属性的引用,而a_position_location变量将是对a_position着色器属性值的引用。属性引用后面是两个统一变量引用。如果你想知道统一变量和属性变量之间的区别,统一变量对于所有顶点保持相同的值,而属性变量是特定于顶点的。最后,我们在vertex_texture_buffer变量中有一个对顶点纹理缓冲区的引用。

在定义这些值之后,我们需要定义我们的四边形。你可能还记得,我们的四边形由六个顶点组成。这是因为它由两个三角形组成。我在第三章中讨论了为什么我们以这种方式设置顶点数据,WebGL 入门。如果你觉得这很困惑,你可能需要回到那一章进行一些复习。以下是vertex_texture_data数组的定义:

float vertex_texture_data[] = {
    // x,   y,        u,   v
    0.167,  0.213,    1.0, 1.0,
   -0.167,  0.213,    0.0, 1.0,
    0.167, -0.213,    1.0, 0.0,
   -0.167, -0.213,    0.0, 0.0,
   -0.167,  0.213,    0.0, 1.0,
    0.167, -0.213,    1.0, 0.0
};

SDL 全局变量

我们仍然将使用 SDL 来初始化我们的 OpenGL 渲染画布。我们还将使用 SDL 从虚拟文件系统加载图像数据。因此,我们需要定义以下与 SDL 相关的全局变量:

SDL_Window *window;
SDL_Renderer *renderer;
SDL_Texture* sprite_texture;
SDL_Surface* sprite_surface;

之后,当我们使用 SDL 加载图像时,我们需要变量来保存我们的精灵宽度和高度值:

int sprite_width;
int sprite_height;

当我们将飞船绘制到画布上时,我们将需要该飞船的xy坐标,因此我们将创建一些全局变量来保存这些值:

float ship_x = 0.0;
float ship_y = 0.0;

最后,我们将创建一个游戏循环的函数原型。我想在定义主函数之后定义我们的游戏循环,因为我想先逐步进行初始化。以下是我们游戏循环的函数原型:

void game_loop();

主函数

现在,我们来到了我们的main函数。我们需要做一些初始化工作。我们不仅需要像创建游戏时那样初始化 SDL,还需要对 OpenGL 进行几个初始化步骤。以下是完整的main函数:

int main() {
 SDL_Init( SDL_INIT_VIDEO );
 SDL_CreateWindowAndRenderer( CANVAS_WIDTH, CANVAS_HEIGHT, 0, &window, &renderer );
    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );
    GLuint vertex_shader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource( vertex_shader,1,vertex_shader_code,0);
    glCompileShader(vertex_shader);
    GLint compile_success = 0;
    glGetShaderiv(vertex_shader, GL_COMPILE_STATUS, &compile_success);
    if(compile_success == GL_FALSE)
    {
        printf("failed to compile vertex shader\n");
        glDeleteShader(vertex_shader);
        return 0;
    }
    GLuint fragment_shader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource( fragment_shader,1,fragment_shader_code,0);
    glCompileShader(fragment_shader);
    glGetShaderiv(fragment_shader, GL_COMPILE_STATUS,&compile_success);
    if(compile_success == GL_FALSE)
    {
        printf("failed to compile fragment shader\n");
        glDeleteShader(fragment_shader);
        return 0;
    }
    program = glCreateProgram();
    glAttachShader( program,vertex_shader);
    glAttachShader( program,fragment_shader);
    glLinkProgram(program);
    GLint link_success = 0;
    glGetProgramiv(program, GL_LINK_STATUS, &link_success);
    if (link_success == GL_FALSE)
    {
        printf("failed to link program\n");
        glDeleteProgram(program);
        return 0;
    }
    glUseProgram(program);
    u_texture_location = glGetUniformLocation(program, "u_texture");
    u_translate_location = glGetUniformLocation(program,"u_translate");
    a_position_location = glGetAttribLocation(program, "a_position");
    a_texcoord_location = glGetAttribLocation(program, "a_texcoord");
    glGenBuffers(1, &vertex_texture_buffer);
    glBindBuffer( GL_ARRAY_BUFFER, vertex_texture_buffer );
    glBufferData(GL_ARRAY_BUFFER, 
    sizeof(vertex_texture_data),vertex_texture_data, GL_STATIC_DRAW);
    sprite_surface = IMG_Load( "/sprites/spaceship.png" );
    if( !sprite_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return 0;
    }
    sprite_texture = SDL_CreateTextureFromSurface( renderer, 
    sprite_surface );
    if( !sprite_texture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return 0;
    }
    SDL_QueryTexture( sprite_texture,NULL, NULL,&sprite_width, &sprite_height );
    glTexImage2D( GL_TEXTURE_2D,0,GL_RGBA,sprite_width,sprite_height,
                  0,GL_RGBA,GL_UNSIGNED_BYTE,sprite_surface );
    SDL_FreeSurface( sprite_surface );
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    glEnable(GL_BLEND);
    glEnableVertexAttribArray(a_position_location);
    glEnableVertexAttribArray(a_texcoord_location);
    glVertexAttribPointer(a_position_location,2,GL_FLOAT,GL_FALSE,4 * 
    sizeof(float),(void*)0 );
    glVertexAttribPointer(a_texcoord_location,2,GL_FLOAT,GL_FALSE,
                          4 * sizeof(float),(void*)(2 * sizeof(float)));
    emscripten_set_main_loop(game_loop, 0, 0);
}

让我把它分成一些更容易理解的部分。在我们的main函数中,我们需要做的第一件事是标准的 SDL 初始化工作。我们需要初始化视频模块,创建一个渲染器,并设置绘制和清除颜色。到现在为止,这段代码应该对你来说已经很熟悉了:

SDL_Init( SDL_INIT_VIDEO );
SDL_CreateWindowAndRenderer( CANVAS_WIDTH, CANVAS_HEIGHT, 0, &window, &renderer );
SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
SDL_RenderClear( renderer );

接下来,我们需要创建和编译我们的顶点着色器。这需要几个步骤。我们需要创建我们的着色器,将源代码加载到着色器中,编译着色器,然后检查编译时是否出现错误。基本上,这些步骤将你的代码编译,然后将编译后的代码加载到视频卡中以便以后执行。以下是编译顶点着色器所需执行的所有步骤:

GLuint vertex_shader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource( vertex_shader,
                1,
                vertex_shader_code,
                0);

glCompileShader(vertex_shader);

GLint compile_success = 0;1
glGetShaderiv(vertex_shader, GL_COMPILE_STATUS, &compile_success);
if(compile_success == GL_FALSE)
{
    printf("failed to compile vertex shader\n");
    glDeleteShader(vertex_shader);
    return 0;
}

在编译顶点着色器之后,我们需要编译片段着色器。这是相同的过程。我们首先调用glCreateShader来创建一个片段着色器。然后,我们使用glShaderSource加载我们的片段着色器源代码。之后,我们调用glCompileShader来编译我们的片段着色器。最后,我们调用glGetShaderiv来查看在尝试编译我们的片段着色器时是否发生了编译器错误:

GLuint fragment_shader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource( fragment_shader,
                1,
                fragment_shader_code,
                0);

glCompileShader(fragment_shader);
glGetShaderiv(fragment_shader, GL_COMPILE_STATUS, &compile_success);

if(compile_success == GL_FALSE)
{
    printf("failed to compile fragment shader\n");
    glDeleteShader(fragment_shader);
    return 0;
}

为了简单起见,当着色器编译失败时,我保持了错误消息的模糊性。它只告诉你哪个着色器编译失败了。在本章的后面,我将向你展示如何从着色器编译器中获取更详细的错误消息。

现在我们已经编译了我们的着色器,我们需要将我们的着色器链接到一个程序中,然后告诉 OpenGL 这是我们想要使用的程序。如果你正在使用 OpenGL 编写游戏,很有可能你会使用多个程序。例如,你可能希望在游戏中的某些对象上使用光照效果,而在其他对象上不使用。一些游戏对象可能需要旋转和缩放,而其他对象可能不需要。

正如你将在下一章中学到的那样,在 WebGL 中使用多个程序比在本机 OpenGL 应用程序中有更高的 CPU 负担。这与 Web 浏览器的安全检查有关。

对于这个应用程序,我们将使用一个单独的程序,并使用以下代码来附加我们的着色器并将它们链接到程序中:

program = glCreateProgram();
glAttachShader( program,
                vertex_shader);

glAttachShader( program,
                fragment_shader);

glLinkProgram(program);

GLint link_success = 0;

glGetProgramiv(program, GL_LINK_STATUS, &link_success);

if (link_success == GL_FALSE)
{
    printf("failed to link program\n");
    glDeleteProgram(program);
    return 0;
}
glUseProgram(program);

glCreateProgram函数创建一个新的程序并返回一个引用 ID。我们将把这个引用 ID 存储在我们的程序变量中。我们调用glAttachShader两次,将我们的顶点着色器和片元着色器附加到我们刚刚创建的程序上。然后我们调用glLinkProgram将程序着色器链接在一起。我们调用glGetProgramiv来验证程序成功链接。最后,我们调用glUseProgram告诉 OpenGL 这是我们想要使用的程序。

现在我们正在使用一个特定的程序,我们可以使用以下代码来检索该程序中属性和统一变量的引用:

u_texture_location = glGetUniformLocation(program, "u_texture");
u_translate_location = glGetUniformLocation(program, "u_translate");

a_position_location = glGetAttribLocation(program, "a_position");
a_texcoord_location = glGetAttribLocation(program, "a_texcoord");

第一行检索到u_texture统一变量的引用,第二行检索到u_translate统一变量的引用。我们可以稍后使用这些引用在我们的着色器中设置这些值。之后的两行用于检索到我们着色器中的a_position位置属性和a_texcoord纹理坐标属性的引用。像统一变量一样,我们稍后将使用这些引用来设置着色器中的值。

现在,我们需要创建并加载数据到一个顶点缓冲区。顶点缓冲区保存了我们将要渲染的每个顶点的属性数据。如果我们要渲染一个 3D 模型,我们需要用从外部检索到的模型数据加载它。幸运的是,对于我们来说,我们只需要渲染一些二维的四边形。四边形足够简单,我们之前能够在一个数组中定义它们。

在我们可以将数据加载到缓冲区之前,我们需要使用glGenBuffers来生成该缓冲区。然后我们需要使用glBindBuffer绑定缓冲区。绑定缓冲区只是告诉 OpenGL 你当前正在处理哪些缓冲区。以下是生成然后绑定我们的顶点缓冲区的代码:

glGenBuffers(1, &vertex_texture_buffer);
glBindBuffer( GL_ARRAY_BUFFER, vertex_texture_buffer );

现在我们已经选择了一个缓冲区,我们可以使用glBufferData来向缓冲区中放入数据。我们将传入我们之前定义的vertex_texture_data。它定义了我们四边形顶点的xy坐标以及这些顶点的 UV 映射数据。

glBufferData(GL_ARRAY_BUFFER, sizeof(vertex_texture_data),
                vertex_texture_data, GL_STATIC_DRAW);

在缓冲我们的数据之后,我们将使用 SDL 来加载一个精灵表面。然后,我们将从该表面创建一个纹理,我们可以用它来找到刚刚加载的图像的宽度和高度。之后,我们调用glTexImage2D从 SDL 表面创建一个 OpenGL 纹理。以下是代码:

sprite_surface = IMG_Load( "/sprites/spaceship.png" );

if( !sprite_surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return 0;
}

sprite_texture = SDL_CreateTextureFromSurface( renderer, sprite_surface );

if( !sprite_texture ) {
    printf("failed to create texture: %s\n", IMG_GetError() );
    return 0;
}

SDL_QueryTexture( sprite_texture,
                    NULL, NULL,
                    &sprite_width, &sprite_height );

glTexImage2D( GL_TEXTURE_2D,
                0,
                GL_RGBA,
                sprite_width,
                sprite_height,
                0,
                GL_RGBA,
                GL_UNSIGNED_BYTE,
                sprite_surface );

SDL_FreeSurface( sprite_surface );

大部分之前的代码应该看起来很熟悉。我们已经使用IMG_Load一段时间从虚拟文件系统中加载 SDL 表面。然后我们使用SDL_CreateTextureFromSurface创建了一个 SDL 纹理。一旦我们有了纹理,我们使用SDL_QueryTexture来找出图像的宽度和高度,并将这些值存储在sprite_widthsprite_height中。下一个函数调用是新的。GlTexImage2D函数用于创建一个新的 OpenGL 纹理图像。我们将sprite_surface作为我们的图像数据传入,这是我们几行前加载的图像数据。最后一行使用SDL_FreeSurface释放表面。

然后我们添加了两行代码在游戏中启用 alpha 混合:

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glEnable(GL_BLEND);

启用 alpha 混合后,我们有几行代码在着色器中设置属性:

glEnableVertexAttribArray(a_position_location);
glEnableVertexAttribArray(a_texcoord_location);

glVertexAttribPointer(
        a_position_location,     // set up the a_position attribute
        2,                       // how many attributes in the position
        GL_FLOAT,                // data type of float
        GL_FALSE,                // the data is not normalized
        4 * sizeof(float),       // stride (how many array items until 
                                 //the next position)
        (void*)0                 // starting point for attribute
);

glVertexAttribPointer(
        a_texcoord_location,         // set up the a_texcoord attribute
        2,                           // how many attributes in the 
                                     //texture coordinates
        GL_FLOAT,                    // data type of float
        GL_FALSE,                    // the data is not normalized
        4 * sizeof(float),           // stride (how many array items 
                                     //until the next position)
        (void*)(2 * sizeof(float))   // starting point for attribute
);

游戏循环的前两行启用了着色器中的a_positiona_texcoord属性。之后,我们调用了两次glVertexAttribPointer。这些调用用于告诉着色器每个特定属性分配的数据在顶点缓冲区中的位置。我们用 32 位浮点变量填充了顶点缓冲区。第一次调用glVertexAttribPointer设置了a_position属性分配的值的位置,使用了我们在a_position_location中创建的引用变量。然后我们传入了用于此属性的值的数量。在位置的情况下,我们传入了xy坐标,所以这个值是 2。我们传入了缓冲区数组的数据类型,即浮点数据类型。我们告诉函数我们不对数据进行归一化。stride值是倒数第二个参数。这是在此缓冲区中用于一个顶点的字节数。因为缓冲区中的每个顶点都使用了四个浮点值,所以我们传入了4 * sizeof(float)作为我们的 stride。最后,我们传入的最后一个值是字节偏移量,用于填充此属性的数据。对于a_position属性,这个值是0,因为位置位于开头。对于a_texcoord属性,这个值是2 * sizeof(float),因为在我们的a_texcoord数据之前使用了两个浮点值来填充a_position

main函数中的最后一行设置了游戏循环回调:

emscripten_set_main_loop(game_loop, 0, 0);

游戏循环

我们的游戏循环非常简单。在游戏循环中,我们将使用 OpenGL 清除画布,移动我们的飞船,并将我们的飞船渲染到画布上。以下是代码:

void game_loop() {
    glClearColor( 0, 0, 0, 1 );
    glClear( GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT );

    ship_x += 0.002;
    ship_y += 0.001;

    if( ship_x >= 1.16 ) {
        ship_x = -1.16;
    }

    if( ship_y >= 1.21 ) {
        ship_y = -1.21;
    }

    glUniform4f(u_translate_location,
                ship_x, ship_y, 0, 0 );

    glDrawArrays(GL_TRIANGLES, 0, 6);
}

游戏循环的前两行清除画布:

glClearColor( 0, 0, 0, 1 );
glClear( GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT );

之后,我们有几行代码更新飞船的xy坐标,然后在着色器中设置新的坐标:

ship_x += 0.002;
ship_y += 0.001;

if( ship_x >= 1.16 ) {
    ship_x = -1.16;
}

if( ship_y >= 1.21 ) {
    ship_y = -1.21;
}

glUniform4f(u_translate_location,
            ship_x, ship_y, 0, 0 );

最后,游戏循环使用glDrawArrays将我们的飞船绘制到画布上:

glDrawArrays(GL_TRIANGLES, 0, 6);

编译和运行我们的代码

您需要从 GitHub 项目中下载 sprites 文件夹,以便包含我们编译和运行此项目所需的图像文件。一旦您拥有这些图像并将我们刚刚编写的代码保存到webgl-redux.c文件中,我们就可以编译和测试这个新应用程序。如果成功,它应该看起来就像第三章中的WebGL 简介,WebGL 版本。运行以下emcc命令来编译应用程序:

emcc webgl-redux.c -o redux.html --preload-file sprites -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"]

如果应用程序成功运行,您应该会看到一艘飞船从左到右并上升到 HTML 画布上。以下是应用程序的工作版本的屏幕截图:

图 15.1:OpenGL 和 SDL 应用程序的屏幕截图

在下一节中,我们将学习如何在着色器中混合纹理。

混合纹理以产生发光效果

现在,我们将花一些时间学习如何将多个纹理加载到我们的程序中。我们将添加这两个纹理的颜色以创建脉动的光晕效果。为此,我们需要修改我们的片段着色器,以接收第二个纹理和一个时间统一变量。我们将把该变量传递给一个正弦波函数,该函数将用它来计算我们发光引擎的强度。我们需要添加一些代码来跟踪经过的时间,以及一些新的初始化代码来加载第二个纹理。我们可以通过将webgl-redux.c复制到一个名为glow.c的新文件来开始。现在我们有了新的glow.c文件,我们可以逐步了解我们需要做的更改,以实现我们发光引擎的效果。第一个代码更改是添加一个新的#define宏,用于定义的值。

我们将使用一个从0循环的值,并将其输入正弦波函数,以在我们的引擎光晕上创建脉动效果。以下是我们应该在glow.c文件开头附近添加的#define

#define TWOPI 6.2831853 // 2π

片段着色器更改

在添加了新的宏之后,我们需要对我们的片段着色器代码进行一些更改。我们的顶点着色器代码将保持不变,因为确定顶点位置的过程与应用程序先前版本中的过程没有任何不同。以下是片段着色器的更新版本:

const GLchar* fragment_shader_code[] = {
    "precision mediump float; \n"
    "varying vec2 v_texcoord; \n"

    "uniform float u_time; \n"
    "uniform sampler2D u_texture; \n"
    "uniform sampler2D u_glow; \n"

    "void main() { \n"
        "float cycle = (sin(u_time) + 1.0) / 2.0; \n"
        "vec4 tex = texture2D(u_texture, v_texcoord); \n"
        "vec4 glow = texture2D(u_glow, v_texcoord); \n"
        "glow.rgb *= glow.aaa; \n"
        "glow *= cycle; \n"
        "gl_FragColor = tex + glow; \n"
    "} \n"
};

我们添加了一个名为u_time的新统一变量,用于传递一个基于时间的变量,该变量将在0之间循环。我们还添加了第二个sampler2D统一变量,称为u_glow,它将保存我们的新光晕纹理。main函数的第一行根据u_time中的值计算出0.01.0之间的值。我们使用内置的texture2D函数从u_textureu_glow中检索采样值。这一次,我们不是直接将纹理的值存储到gl_FragColor中,而是将这两个值保存到名为texglowvec4变量中。我们将这两个值相加,为了避免所有地方都变得太亮,我们将glow样本颜色中的rgb(红绿蓝)值乘以 alpha 通道。之后,我们将glow颜色中的所有值乘以我们之前计算的cycle值。

cycle中的值将遵循一个正弦波,在0.01.0之间振荡。这将导致我们的glow值随时间上下循环。然后,我们通过将tex颜色添加到glow颜色来计算我们的片段颜色。然后,我们将输出值存储在gl_FragColor中。

OpenGL 全局变量更改

接下来,我们需要更新与 OpenGL 相关的变量,以便我们可以添加三个新的全局变量。我们需要一个名为glow_tex的新变量,我们将用它来存储对光晕纹理的引用。我们还需要两个新的引用变量,用于我们着色器中的两个新的统一变量,称为u_time_locationu_glow_location。一旦我们添加了这三行,新的 OpenGL 变量块将如下所示:

GLuint program = 0;
GLuint texture;
GLuint glow_tex;

GLint a_texcoord_location = -1;
GLint a_position_location = -1;
GLint u_texture_location = -1;
GLint u_glow_location = -1;
GLint u_time_location = -1;

GLint u_translate_location = -1;
GLuint vertex_texture_buffer;

其他全局变量更改

在我们的 OpenGL 全局变量之后,我们需要添加一个新的与时间相关的全局变量块。我们需要它们来使我们的着色器循环通过值来实现引擎光晕。这些与时间相关的变量应该看起来很熟悉。我们在开发的游戏中使用了类似于我们即将在游戏中使用的技术。以下是这些全局时间变量:

float time_cycle = 0;
float delta_time = 0.0;
int diff_time = 0;

Uint32 last_time;
Uint32 last_frame_time;
Uint32 current_time;

我们需要添加一个与 SDL 相关的全局表面变量,我们将用它来加载我们的光晕纹理。在main函数之前的全局变量块附近添加以下行:

SDL_Surface* glow_surface;

main函数的更改

我们将对我们在main函数中进行的初始化进行一些重大修改。让我先展示整个函数。然后,我们将逐一讲解所有的更改:

int main() {
    last_frame_time = last_time = SDL_GetTicks();

    SDL_Init( SDL_INIT_VIDEO );

    SDL_CreateWindowAndRenderer( CANVAS_WIDTH, CANVAS_HEIGHT, 0, 
    &window, &renderer );

    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );

    GLuint vertex_shader = glCreateShader(GL_VERTEX_SHADER);

    glShaderSource( vertex_shader,
                    1,
                    vertex_shader_code,
                    0);

    glCompileShader(vertex_shader);

    GLint compile_success = 0;
    glGetShaderiv(vertex_shader, GL_COMPILE_STATUS, &compile_success);

    if(compile_success == GL_FALSE)
    {
        printf("failed to compile vertex shader\n");
        glDeleteShader(vertex_shader);
        return 0;
    }

    GLuint fragment_shader = glCreateShader(GL_FRAGMENT_SHADER);

    glShaderSource( fragment_shader,
                    1,
                    fragment_shader_code,
                    0);

    glCompileShader(fragment_shader);
    glGetShaderiv(fragment_shader, GL_COMPILE_STATUS, 
    &compile_success);

    if(compile_success == GL_FALSE)
    {
        printf("failed to compile fragment shader\n");
        glDeleteShader(fragment_shader);
        return 0;
    }

    program = glCreateProgram();
    glAttachShader( program,
                    vertex_shader);

    glAttachShader( program,
                    fragment_shader);

    glLinkProgram(program);

    GLint link_success = 0;

    glGetProgramiv(program, GL_LINK_STATUS, &link_success);

    if (link_success == GL_FALSE)
    {
        printf("failed to link program\n");
        glDeleteProgram(program);
        return 0;
    }

    glUseProgram(program);

    u_glow_location = glGetUniformLocation(program, "u_glow");
    u_time_location = glGetUniformLocation(program, "u_time");

    u_texture_location = glGetUniformLocation(program, "u_texture");
    u_translate_location = glGetUniformLocation(program, 
    "u_translate");

    a_position_location = glGetAttribLocation(program, "a_position");
    a_texcoord_location = glGetAttribLocation(program, "a_texcoord");

    glGenBuffers(1, &vertex_texture_buffer);

glBindBuffer( GL_ARRAY_BUFFER, vertex_texture_buffer );
 glBufferData(GL_ARRAY_BUFFER, sizeof(vertex_texture_data),
 vertex_texture_data, GL_STATIC_DRAW);

sprite_surface = IMG_Load( "/sprites/spaceship.png" );

    if( !sprite_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return 0;
    }

    sprite_texture = SDL_CreateTextureFromSurface( renderer, 
    sprite_surface );

    if( !sprite_texture ) {
        printf("failed to create texture: %s\n", IMG_GetError() );
        return 0;
    }

    SDL_QueryTexture( sprite_texture,
                        NULL, NULL,
                        &sprite_width, &sprite_height );

    glTexImage2D( GL_TEXTURE_2D,
                    0,
                    GL_RGBA,
                    sprite_width,
                    sprite_height,
                    0,
                    GL_RGBA,
                    GL_UNSIGNED_BYTE,
                    sprite_surface );

    SDL_FreeSurface( sprite_surface );

    glGenTextures( 1,
                    &glow_tex);

    glActiveTexture(GL_TEXTURE1);
    glEnable(GL_TEXTURE_2D);
    glBindTexture(GL_TEXTURE_2D, glow_tex);

    glow_surface = IMG_Load( "/sprites/glow.png" );

    if( !glow_surface ) {
        printf("failed to load image: %s\n", IMG_GetError() );
        return 0;
    }

    glTexImage2D( GL_TEXTURE_2D,
                    0,
                    GL_RGBA,
                    sprite_width,
                    sprite_height,
                    0,
                    GL_RGBA,
                    GL_UNSIGNED_BYTE,
                    glow_surface );

    glGenerateMipmap(GL_TEXTURE_2D);

    SDL_FreeSurface( glow_surface );

    glUniform1i(u_texture_location, 0);
    glUniform1i(u_glow_location, 1);

    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    glEnable(GL_BLEND);

    glEnableVertexAttribArray(a_position_location);
    glEnableVertexAttribArray(a_texcoord_location);

    glVertexAttribPointer(
        a_position_location,     // set up the a_position attribute
        2,                       // how many attributes in the position
        GL_FLOAT,                // data type of float
        GL_FALSE,                // the data is not normalized
        4 * sizeof(float),       // stride (how many array items until 
                                 //the next position)
        (void*)0                 // starting point for attribute
    );

    glVertexAttribPointer(
        a_texcoord_location,       // set up the a_texcoord attribute
        2,                         // how many attributes in the 
                                   //texture coordinates
        GL_FLOAT,                  // data type of float
        GL_FALSE,                  // the data is not normalized
        4 * sizeof(float),         // stride (how many array items 
                                   //until the next position)
        (void*)(2 * sizeof(float)) // starting point for attribute
    );

    emscripten_set_main_loop(game_loop, 0, 0);
}

我们main函数中的第一行是新的。我们使用该行将last_frame_timelast_time设置为系统时间,我们使用SDL_GetTicks()来获取系统时间:

last_frame_time = last_time = SDL_GetTicks();

之后,直到我们到达检索统一位置的代码部分之前,我们将不进行任何更改。我们需要从我们的程序中检索另外两个统一位置,因此在我们调用glUseProgram之后,我们应该进行以下调用以获取u_glowu_time的统一位置:

u_glow_location = glGetUniformLocation(program, "u_glow");
u_time_location = glGetUniformLocation(program, "u_time");

在我们调用SDL_FreeSurface释放sprite_surface变量之后必须添加以下代码块。此代码块将生成一个新的纹理,激活它,绑定它,并将glow.png图像加载到该纹理中。然后释放 SDL 表面并为我们的纹理生成 mipmaps。最后,我们使用glUniform1i设置纹理的统一位置。以下是我们用来加载新纹理的代码:

glGenTextures( 1,
                &glow_tex);

glActiveTexture(GL_TEXTURE1);
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, glow_tex);

glow_surface = IMG_Load( "/sprites/glow.png" );

if( !glow_surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return 0;
}

glTexImage2D( GL_TEXTURE_2D,
                0,
                GL_RGBA,
                sprite_width,
                sprite_height,
                0,
                GL_RGBA,
                GL_UNSIGNED_BYTE,
                glow_surface );

SDL_FreeSurface( glow_surface );

glGenerateMipmap(GL_TEXTURE_2D);

glUniform1i(u_texture_location, 0);
glUniform1i(u_glow_location, 1);

如果您对 Mipmaps 不熟悉,您可能会想知道glGenerateMipmap(GL_TEXTURE_2D);这一行是做什么的。当您使用 OpenGL 缩放纹理时,这些纹理需要时间来生成。 Mipmaps 是一种通过在游戏初始化时执行一些二次幂缩放版本的图像来加速缩放的方法。这将减少在运行时缩放这些图像所需的时间。

更新 game_loop()

为了循环飞船引擎的发光效果,我们需要在我们的游戏循环中添加一些代码,该代码将从0.0循环到。然后,我们将这个值作为u_time统一变量传递到着色器中。我们需要将这个新的代码块添加到游戏循环函数的开头:

current_time = SDL_GetTicks();

diff_time = current_time - last_time;

delta_time = diff_time / 1000.0;
last_time = current_time;

time_cycle += delta_time * 4;

if( time_cycle >= TWOPI ) {
    time_cycle -= TWOPI;
}

glUniform1f( u_time_location, time_cycle );

第一行使用SDL_GetTicks()来检索当前时钟时间。然后我们从当前时间中减去上次时间以获得diff_time变量的值。这将告诉我们在此帧和上一帧之间生成的毫秒数。之后,我们计算delta_time,这将是此帧和上一帧之间的秒数。在我们计算出diff_timedelta_time之后,我们将last_time变量设置为current_time

我们这样做是为了在下次循环游戏时,我们将知道此帧的运行时间。所有这些行都在我们代码的先前版本中。现在,让我们获取time_cycle的值,然后将其传递到我们的片段着色器中的u_time统一变量中。首先,使用以下行将delta-time * 4添加到时间周期中:

time_cycle += delta_time * 4;

您可能想知道为什么我要将其乘以4。最初,我没有添加倍数,这意味着引擎的发光大约每 6 秒循环一次。这感觉循环时间太长。尝试不同的数字,4 的倍数对我来说感觉刚刚好,但如果您希望引擎的循环速度更快或更慢,您无需坚持使用这个特定的倍数。

因为我们使用正弦函数来循环我们的发光级别,所以当我们的时间周期达到TWOPI时,我们需要从我们的time_cycle变量中减去TWOPI

if( time_cycle >= TWOPI ) {
    time_cycle -= TWOPI;
}

现在我们已经计算出周期的值,我们使用u_time_location引用变量通过调用glUniform1f来设置该值:

glUniform1f( u_time_location, time_cycle );

编译和运行我们的代码

现在我们已经做出了所有需要的代码更改,我们可以继续编译和运行我们应用的新版本。通过运行以下emcc命令来编译glow.c文件:

emcc glow.c -o glow.html --preload-file sprites -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"]

如果构建成功,在 Web 浏览器中运行glow.html应该显示飞船移动的方式与之前相同。但是现在,引擎上会有一个发光效果。这种发光会上下循环,并在引擎达到最大发光时如下所示:

图 15.2:发光着色器应用的屏幕截图

在下一节中,我们将讨论 Phong 3D 光照模型。

3D 光照

我想简要讨论一下 3D 光照,因为我们将用 2D 光照效果来近似它。冯氏光照模型是计算机图形学中三维光照模型的标准。它是由 Bui Tuong Phong 于 1975 年在犹他大学创建的光照模型,但直到 20 世纪 90 年代末,台式电脑才足够快速地实现该模型在游戏中的应用。自那时起,这种光照模型已成为 3D 游戏开发的标准。它结合了环境光、漫反射光和镜面光来渲染几何图形。我们无法实现光照模型的正确版本,因为我们不是在写一个 3D 游戏。然而,我们可以通过使用 2D 精灵和法线贴图来近似该模型。

环境光

在现实世界中,有一定量的光会随机地反射到周围的表面上。这会产生均匀照亮一切的光线。如果没有环境光,一个物体在另一个物体的阴影下会完全变黑。环境光的数量根据环境而异。在游戏中,环境光的数量通常是根据游戏设计师试图实现的情绪和外观来决定的。对于 2D 游戏,环境光可能是我们唯一有效的光照。在 3D 游戏中,完全依赖环境光会产生看起来平坦的模型:

图 15.3:只有环境光的球

漫反射光

漫反射光是来自特定方向的光。如果你在现实世界中看一个三维物体,朝向光源的一面会比背对光源的一面看起来更亮。这给了 3D 环境中的物体一个真正的 3D 外观。在许多 2D 游戏中,漫反射光不是通过着色器创建的,而是由创建精灵的艺术家包含在精灵中。例如,在平台游戏中,艺术家可能会假设游戏对象上方有一个光源。艺术家会通过改变艺术作品中像素的颜色来设计游戏对象具有一种漫反射光。对于许多 2D 游戏来说,这样做完全没问题。然而,如果你想在游戏中加入一个火炬,让它在移动时改变游戏对象的外观,你需要设计能够完成这项工作的着色器:

图 15.4:有漫反射光的球

镜面光

一些物体是有光泽的,并且有反射区域,会产生明亮的高光。当光线照射到表面上时,会有一个基于光线照射表面的角度相对于表面法线的反射向量。镜面高光的强度取决于表面的反射性,以及相对于反射光角度的视角。游戏对象上的镜面高光可以使其看起来光滑或抛光。并非所有游戏对象都需要这种类型的光照,但它在你想要发光的物体上看起来很棒:

图 15.5:有镜面光的球

在下一节中,我们将讨论法线贴图及其在现代游戏中的应用。

法线贴图

法线贴图是一种在 3D 游戏中使用相对较低的多边形数量创建非常详细模型的方法。其思想是,游戏引擎可以使用一个法线贴图的低多边形模型,其中法线贴图中的每个像素都包含使用图像的红色、绿色和蓝色值的法线的 x、y 和 z 值,而不是创建一个具有大量多边形的表面。在着色器内部,我们可以像对其他纹理贴图进行采样一样采样法线贴图纹理。然而,我们可以使用法线数据来帮助我们计算精灵的光照效果。如果在我们的游戏中,我们希望我们的太空飞船始终相对于游戏区域中心的星星照亮,我们可以为我们的太空飞船创建一个法线贴图,并在游戏中心创建一个光源。我们现在将创建一个应用程序来演示 2D 照明中法线贴图的使用。

创建一个 2D 照明演示应用程序

我们可以通过创建一个名为lighting.c的新 C 文件来启动我们的照明应用程序。lighting.c开头的宏与我们在glow.c中使用的宏相同,但我们可以删除#define TWOPI宏,因为它不再需要。以下是我们将在lighting.c文件中使用的宏:

#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>
#include <SDL_opengl.h>

#include <GLES3/gl3.h>
#include <stdlib.h>
#include <emscripten.h>

#define CANVAS_WIDTH 800
#define CANVAS_HEIGHT 600
#define FLOAT32_BYTE_SIZE 4
#define STRIDE FLOAT32_BYTE_SIZE*4

此文件中的顶点着色器代码将与我们在glow.c文件中的顶点着色器代码非常相似。我们将做出的一个改变是删除u_translate统一变量。我们这样做是因为我们将把我们的阴影精灵图像居中,并允许用户在画布上移动光源。以下是顶点着色器的新版本:

const GLchar* vertex_shader_code[] = {
    "precision mediump float; \n"
    "attribute vec4 a_position; \n"
    "attribute vec2 a_texcoord; \n"
    "varying vec2 v_texcoord; \n"

    "void main() { \n"
        "gl_Position = a_position; \n"
        "v_texcoord = a_texcoord; \n"
    "} \n"
};

片段着色器更新

现在,我们需要创建我们的片段着色器的新版本。这个着色器将加载一个法线贴图以及原始加载的纹理。这个法线贴图将用于计算我们游戏对象的光照法线。这个着色器版本将使用 Phong 光照模型的 2D 形式,我们将计算我们正在渲染的精灵的环境、漫反射和法线光照。以下是我们新片段着色器的代码:

const GLchar* fragment_shader_code[] = {
    "precision mediump float; \n"

    "varying vec2 v_texcoord; \n"

    "uniform sampler2D u_texture; \n"
    "uniform sampler2D u_normal; \n"
    "uniform vec3 u_light_pos; \n"

    "const float ambient = 0.6; \n"
    "const float specular = 32.0; \n"
    "const vec3 view_pos = vec3(400, 300,-100); \n"
    "const vec4 light_color = vec4( 0.6, 0.6, 0.6, 0.0); \n"

    "void main() { \n"
        "vec4 tex = texture2D(u_texture, v_texcoord); \n"

        "vec4 ambient_frag = tex * ambient; \n"
        "ambient_frag.rgb *= light_color.rgb; \n"

        "vec3 norm = vec3(texture2D(u_normal, v_texcoord)); \n"
        "norm.xyz *= 2.0; \n"
        "norm.xyz -= 1.0; \n"

        "vec3 light_dir = normalize(gl_FragCoord.xyz - u_light_pos); \n"

        "vec3 view_dir = normalize(view_pos - gl_FragCoord.xyz); \n"
        "vec3 reflect_dir = reflect(light_dir, norm); \n"

        "float reflect_dot = max( dot(view_dir, reflect_dir), 0.0 ); \n"
        "float spec = pow(reflect_dot, specular); \n"
        "vec4 specular_frag = spec * light_color; \n"

        "float diffuse = max(dot(norm, light_dir), 0.0); \n"
        "vec4 diffuse_frag = vec4( diffuse*light_color.r, 
         diffuse*light_color.g, "
                                    "diffuse*light_color.b,  0.0);    \n"
        "gl_FragColor = ambient_frag + diffuse_frag + specular_frag; \n"
    "} \n"
};

让我们分解一下新版本片段着色器内部发生的事情。你会注意到的第一件事是,我们有两个sampler2D统一变量;第二个称为u_normal,用于对我们图像的法线贴图进行采样:

"uniform sampler2D u_texture; \n"
"uniform sampler2D u_normal; \n"

在我们的采样器之后,我们需要一个uniform vec3变量,它保存我们光源的位置。我们称之为u_light_pos

"uniform vec3 u_light_pos; \n"

在我们的新片段着色器中,我们将使用几个常量。我们将需要环境和镜面光照的因子,以及视图位置和光颜色。我们将在以下四行代码中定义这些常量:

"const float ambient = 0.6; \n"
"const float specular = 0.8; \n"
"const vec3 view_pos = vec3(400, 300,-100); \n"
"const vec4 light_color = vec4( 0.6, 0.6, 0.6, 0.0); \n"

在我们的main函数内,我们需要做的第一件事是获取环境片段颜色。确定环境颜色非常容易。你只需要将纹理颜色乘以环境因子,然后再乘以光颜色。以下是计算片段环境分量值的代码:

"vec4 tex = texture2D(u_texture, v_texcoord); \n"
"vec4 ambient_frag = tex * ambient; \n"

"ambient_frag.rgb *= light_color.rgb; \n"

计算完我们的环境颜色分量后,我们需要计算我们片段的法线,从我们传递到着色器的法线贴图纹理中。纹理使用红色表示法线的x值。绿色表示y值。最后,蓝色表示z值。颜色都是从0.01.0的浮点数,所以我们需要修改法线的xyz分量,使其从-1.0+1.0。以下是我们用来定义法线的代码:

"vec3 norm = vec3(texture2D(u_normal, v_texcoord)); \n"
"norm.xyz *= 2.0; \n"
"norm.xyz -= 1.0; \n"

为了将norm向量中的值从0.0转换为1.0-1.0+1.0,我们需要将法线向量中的值乘以 2,然后减去 1。计算法线值后,我们需要找到我们光源的方向:

"vec3 light_dir = normalize(gl_FragCoord.xyz - u_light_pos); \n"

我们使用 normalize GLSL 函数对值进行归一化,因为在这个应用程序中我们不会有任何光线衰减。如果你有一个带火炬的游戏,你可能希望基于与光源距离的平方的尖锐衰减。对于这个应用程序,我们假设光源具有无限范围。对于我们的镜面光照,我们需要计算我们的视图方向:

"vec3 view_dir = normalize(view_pos - gl_FragCoord.xyz); \n"

我们将view_pos向量设置为画布的中心,因此当我们的光源也在画布的中心时,我们的镜面光照应该最大。当您编译应用程序时,您将能够测试这一点。在计算视图方向之后,我们需要计算反射向量,这也将用于我们的镜面光照计算:

"vec3 reflect_dir = reflect(light_dir, norm); \n"

然后我们可以计算这两个向量的点积,并将它们提升到我们的镜面因子(之前定义为 32)的幂,以计算我们需要为这个片段的镜面光照的数量:

"float reflect_dot = max( dot(view_dir, reflect_dir), 0.0 ); \n"
"float spec = pow(reflect_dot, specular); \n"
"vec4 specular_frag = spec * light_color; \n"

之后,我们使用法线和光线方向的点积来计算片段的漫反射分量。我们将其与光颜色结合以获得我们的漫反射分量值:

"float diffuse = max(dot(norm, light_dir), 0.0); \n"
"vec4 diffuse_frag = vec4(diffuse*light_color.r, diffuse*light_color.g, diffuse*light_color.b, 0.0); \n"

最后,我们将所有这些值相加以找到我们的片段值:

"gl_FragColor = ambient_frag + diffuse_frag + specular_frag; \n"

OpenGL 全局变量

在定义了我们的片段着色器之后,我们需要定义一系列与 OpenGL 相关的全局变量。这些变量应该对你来说是熟悉的,因为它们来自这个应用程序的前两个版本。有一些新变量需要注意。我们将不再只有一个程序 ID。SDL 使用自己的程序,我们也需要一个该程序的 ID。我们将称这个变量为sdl_program。我们还需要新的纹理引用。此外,我们还需要新的引用传递给我们的着色器的统一变量。以下是我们的 OpenGL 全局变量代码的新版本:

GLuint program = 0;
GLint sdl_program = 0;
GLuint circle_tex, normal_tex, light_tex;
GLuint normal_map;

GLint a_texcoord_location = -1;
GLint a_position_location = -1;
GLint u_texture_location = -1;
GLint u_normal_location = -1;
GLint u_light_pos_location = -1;

GLint u_translate_location = -1;
GLuint vertex_texture_buffer;

float vertex_texture_data[] = {
    // x,    y,         u,   v
     0.167,  0.213,     1.0, 1.0,
    -0.167,  0.213,     0.0, 1.0,
     0.167, -0.213,     1.0, 0.0,
    -0.167, -0.213,     0.0, 0.0,
    -0.167,  0.213,     0.0, 1.0,
     0.167, -0.213,     1.0, 0.0
};

SDL 全局变量

一些 SDL 变量与我们在本章为此创建的先前应用程序中使用的变量相同。用于光照和法线的其他变量是这一部分的新内容。以下是我们在这个应用程序中需要的与 SDL 相关的全局变量:

SDL_Window *window;
SDL_Renderer *renderer;

SDL_Texture* light_texture;

SDL_Surface* surface;

int light_width;
int light_height;

int light_x = 600;
int light_y = 200;
int light_z = -300;

我们需要声明一个名为light_textureSDL_Texture变量,我们将使用它来保存我们光标图标的 SDL 纹理。我们将使用 SDL 来绘制我们的光标图标,而不是使用 OpenGL 来绘制它。我们将使用一个表面指针变量来加载所有的纹理,然后立即释放该表面。我们需要宽度和高度值来跟踪我们光标图标的宽度和高度。我们还需要值来跟踪我们光源的xyz坐标。

函数原型

因为我想把main函数的代码放在其他函数之前,我们需要一些函数原型。在这个应用程序中,我们将有一个游戏循环函数,一个通过 SDL 检索鼠标输入的函数,以及一个使用 SDL 绘制我们的光标图标的函数。以下是这些函数原型的样子:

void game_loop();
void input();
void draw_light_icon();

主函数

就像我们在本章中创建的其他应用程序一样,我们的main函数将需要初始化 SDL 和 OpenGL 变量。main函数的开头与我们的 glow 应用程序的开头相同。它初始化 SDL,然后编译和链接 OpenGL 着色器并创建一个新的 OpenGL 程序:

int main() {
    SDL_Init( SDL_INIT_VIDEO );
    SDL_CreateWindowAndRenderer( CANVAS_WIDTH, CANVAS_HEIGHT, 0, 
    &window, &renderer );
    SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
    SDL_RenderClear( renderer );

    GLuint vertex_shader = glCreateShader(GL_VERTEX_SHADER);

    glShaderSource( vertex_shader,
                    1,
                    vertex_shader_code,
                    0);

    glCompileShader(vertex_shader);

    GLint compile_success = 0;
    glGetShaderiv(vertex_shader, GL_COMPILE_STATUS, &compile_success);

    if(compile_success == GL_FALSE)
    {
        printf("failed to compile vertex shader\n");
        glDeleteShader(vertex_shader);
        return 0;
    }

    GLuint fragment_shader = glCreateShader(GL_FRAGMENT_SHADER);

    glShaderSource( fragment_shader,
                    1,
                    fragment_shader_code,
                    0);

    glCompileShader(fragment_shader);
    glGetShaderiv(fragment_shader, GL_COMPILE_STATUS, 
    &compile_success);

    if(compile_success == GL_FALSE)
    {
        printf("failed to compile fragment shader\n");

        GLint maxLength = 0;
        glGetShaderiv(fragment_shader, GL_INFO_LOG_LENGTH, &maxLength);

        GLchar* errorLog = malloc(maxLength);
        glGetShaderInfoLog(fragment_shader, maxLength, &maxLength, 
        &errorLog[0]);
        printf("error: %s\n", errorLog);

        glDeleteShader(fragment_shader);
        return 0;
    }

    program = glCreateProgram();
    glAttachShader( program,
                    vertex_shader);

    glAttachShader( program,
                    fragment_shader);

    glLinkProgram(program);

    GLint link_success = 0;

    glGetProgramiv(program, GL_LINK_STATUS, &link_success);

    if (link_success == GL_FALSE)
    {
        printf("failed to link program\n");
        glDeleteProgram(program);
        return 0;
    }

    glDeleteShader(vertex_shader);
    glDeleteShader(fragment_shader);
    glUseProgram(program);

在初始化 SDL 并创建 OpenGL 着色器程序之后,我们需要获取我们的 OpenGL 着色器程序的统一变量引用。其中两个引用是这个程序版本的新内容。u_normal_location变量将是对u_normal采样器统一变量的引用,u_light_pos_location变量将是对u_light_pos统一变量的引用。这是我们引用的新版本:

u_texture_location = glGetUniformLocation(program, "u_texture");
u_normal_location = glGetUniformLocation(program, "u_normal");
u_light_pos_location = glGetUniformLocation(program, "u_light_pos");
u_translate_location = glGetUniformLocation(program, "u_translate");

在获取了我们统一变量的引用之后,我们需要对我们的属性做同样的事情:

a_position_location = glGetAttribLocation(program, "a_position");
a_texcoord_location = glGetAttribLocation(program, "a_texcoord");

然后,我们需要生成顶点缓冲区,绑定它,并缓冲我们之前创建的数组中的数据。这应该是我们在glow.c文件中的相同代码:

glGenBuffers(1, &vertex_texture_buffer);

glBindBuffer( GL_ARRAY_BUFFER, vertex_texture_buffer );
glBufferData( GL_ARRAY_BUFFER, sizeof(vertex_texture_data),
              vertex_texture_data, GL_STATIC_DRAW);

接下来,我们需要设置所有的纹理。其中两个将使用 OpenGL 进行渲染,而另一个将使用 SDL 进行渲染。以下是这三个纹理的初始化代码:

glGenTextures( 1,
                &circle_tex);

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, circle_tex);

surface = IMG_Load( "/sprites/circle.png" );
if( !surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return 0;
}

glTexImage2D( GL_TEXTURE_2D,
                0,
                GL_RGBA,
                128, // sprite width
                128, // sprite height
                0,
                GL_RGBA,
                GL_UNSIGNED_BYTE,
                surface );

glUniform1i(u_texture_location, 1);
glGenerateMipmap(GL_TEXTURE_2D);

SDL_FreeSurface( surface );

glGenTextures( 1,
                &normal_tex);

glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, normal_tex);

surface = IMG_Load( "/sprites/ball-normal.png" );

if( !surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return 0;
}

glTexImage2D( GL_TEXTURE_2D,
                0,
                GL_RGBA,
                128, // sprite width
                128, // sprite height
                0,
                GL_RGBA,
                GL_UNSIGNED_BYTE,
                surface );

glUniform1i(u_normal_location, 1);
glGenerateMipmap(GL_TEXTURE_2D);

SDL_FreeSurface( surface );

surface = IMG_Load( "/sprites/light.png" );

if( !surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return 0;
}

light_texture = SDL_CreateTextureFromSurface( renderer, surface );

if( !light_texture ) {
    printf("failed to create light texture: %s\n", IMG_GetError() );
    return 0;
}

SDL_QueryTexture( light_texture,
                    NULL, NULL,
                    &light_width, &light_height );

SDL_FreeSurface( surface );

这是一个相当大的代码块,让我一步一步地解释。前三行生成、激活和绑定圆形纹理,以便我们可以开始更新它:

glGenTextures( 1,
                &circle_tex);

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, circle_tex);

现在我们已经准备好更新圆形纹理,我们可以使用 SDL 加载图像文件:

surface = IMG_Load( "/sprites/circle.png" );

if( !surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return 0;
}

接下来,我们需要将数据加载到我们绑定的纹理中:

glTexImage2D( GL_TEXTURE_2D,
                0,
                GL_RGBA,
                128, // sprite width
                128, // sprite height
                0,
                GL_RGBA,
                GL_UNSIGNED_BYTE,
                surface );

然后,我们可以激活该纹理,生成 mipmaps,并释放表面:

glUniform1i(u_texture_location, 1);
glGenerateMipmap(GL_TEXTURE_2D);

SDL_FreeSurface( surface );

在为我们的圆形纹理做完这些之后,我们需要为我们的法线贴图做同样一系列的步骤:

glGenTextures( 1,
                &normal_tex);

glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, normal_tex);
surface = IMG_Load( "/sprites/ball-normal.png" );

if( !surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return 0;
}

glTexImage2D( GL_TEXTURE_2D,
    0,
    GL_RGBA,
    128, // sprite width
    128, // sprite height
    0,
    GL_RGBA,
    GL_UNSIGNED_BYTE,
    surface );

glUniform1i(u_normal_location, 1);
glGenerateMipmap(GL_TEXTURE_2D);

SDL_FreeSurface( surface );

我们将以不同的方式处理最终的纹理,因为它只会使用 SDL 进行渲染。现在你应该对这个很熟悉了。我们需要从图像文件加载表面,从表面创建纹理,查询该纹理的大小,然后释放原始表面:

surface = IMG_Load( "/sprites/light.png" );

if( !surface ) {
    printf("failed to load image: %s\n", IMG_GetError() );
    return 0;
}

light_texture = SDL_CreateTextureFromSurface( renderer, surface );

if( !light_texture ) {
    printf("failed to create light texture: %s\n", IMG_GetError() );
    return 0;
}

SDL_QueryTexture( light_texture,
                    NULL, NULL,
                    &light_width, &light_height );

SDL_FreeSurface( surface );

现在我们已经创建了我们的纹理,我们应该设置我们的 alpha 混合:

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glEnable(GL_BLEND);

main函数的最后一行使用 Emscripten 调用游戏循环:

emscripten_set_main_loop(game_loop, 0, 0);

游戏循环函数

现在我们已经定义了main函数,我们需要定义我们的game_loop。因为game_loop函数同时使用 SDL 和 OpenGL 进行渲染,所以我们需要在每次循环之前设置顶点属性指针,然后在 OpenGL 中进行渲染。我们还需要在多个 OpenGL 程序之间切换,因为 SDL 使用的着色程序与我们用于 OpenGL 的着色程序不同。让我先向您展示整个函数,然后我们可以一步一步地解释它:

void game_loop() {
    input();

    glGetIntegerv(GL_CURRENT_PROGRAM,&sdl_program);
    glUseProgram(program);

    glClearColor( 0, 0, 0, 1 );
    glClear( GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT );

    glBindBuffer(GL_ARRAY_BUFFER, vertex_texture_buffer);
    glVertexAttribPointer(
        a_position_location,       // set up the a_position attribute
        2,                         // how many attributes in the 
                                   //position
        GL_FLOAT,                  // data type of float
        GL_FALSE,                  // the data is not normalized
        4 * sizeof(float),         // stride (how many array items 
                                   //until the next position)
        (void*)0                   // starting point for attribute
     );

    glEnableVertexAttribArray(a_texcoord_location);
    glBindBuffer(GL_ARRAY_BUFFER, vertex_texture_buffer);
    glVertexAttribPointer(
        a_texcoord_location,     // set up the a_texcoord attribute
        2,                       // how many attributes in the texture 
                                 //coordinates
        GL_FLOAT,                // data type of float
        GL_FALSE,                // the data is not normalized
        4 * sizeof(float),       // stride (how many array items until 
                                 //the next position)
        (void*)(2 * sizeof(float)) // starting point for attribute
    );

    glUniform3f( u_light_pos_location,
                (float)(light_x), (float)(600-light_y), (float)(light_z) );

    glDrawArrays(GL_TRIANGLES, 0, 6);

    glUseProgram(sdl_program);
    draw_light_icon();
}

游戏循环的第一行调用了input函数。这个函数将使用鼠标输入来设置光源位置。第二和第三行检索 SDL 着色程序并将其保存到sdl_program变量中。然后,它通过调用glUseProgram切换到自定义的 OpenGL 着色程序。以下是我们调用以保存当前程序并设置新程序的两行代码:

glGetIntegerv(GL_CURRENT_PROGRAM,&sdl_program);
glUseProgram(program);

之后,我们调用 OpenGL 来清除画布:

glClearColor( 0, 0, 0, 1 );
glClear( GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT );

接下来,我们需要设置我们的几何形状:

glBindBuffer(GL_ARRAY_BUFFER, vertex_texture_buffer);
glVertexAttribPointer(
            a_position_location,   // set up the a_position attribute
            2,                     // how many attributes in the 
                                   //position
            GL_FLOAT,              // data type of float
            GL_FALSE,              // the data is not normalized
            4 * sizeof(float),     // stride (how many array items 
                                   //until the next position)
            (void*)0               // starting point for attribute
);

glEnableVertexAttribArray(a_texcoord_location);
glBindBuffer(GL_ARRAY_BUFFER, vertex_texture_buffer);
glVertexAttribPointer(
    a_texcoord_location,          // set up the a_texcoord attribute
    2,                            // how many attributes in the texture 
                                  //coordinates
    GL_FLOAT,                     // data type of float
    GL_FALSE,                     // the data is not normalized
    4 * sizeof(float),            // stride (how many array items until 
                                  //the next position)
    (void*)(2 * sizeof(float))    // starting point for attribute
);

然后,我们使用glUniform3f调用将vec3 uniform u_light_pos变量设置为我们之前定义的light_xlight_ylight_z全局变量。这些光源位置可以通过鼠标移动。允许用户移动光源的代码将在我们编写input函数时定义。设置完光源位置的值后,我们可以使用 OpenGL 绘制我们的三角形:

glDrawArrays(GL_TRIANGLES, 0, 6);

最后,我们需要切换回我们的 SDL 程序并调用draw_light_icon函数,这将使用 SDL 绘制我们的光标图标:

glUseProgram(sdl_program);
draw_light_icon();

输入函数

现在我们已经定义了我们的游戏循环,我们需要编写一个函数来捕获鼠标输入。我希望能够点击我们的画布,让光标图标和光源移动到我刚刚点击的位置。我还希望能够按住鼠标按钮并拖动光标图标在画布上移动,以查看光源在画布上不同位置时阴影的效果。大部分代码看起来都很熟悉。我们使用SDL_PollEvent来检索事件,并查看左鼠标按钮是否按下,或用户是否移动了滚轮。如果用户转动了滚轮,light_z变量会改变,进而改变我们光源的z位置。我们使用static int mouse_down变量来跟踪用户是否按下了鼠标按钮。如果用户按下了鼠标按钮,我们将调用SDL_GetMouseState来检索light_xlight_y变量,这将修改我们光源的 x 和 y 位置。以下是输入函数的完整代码:

void input() {
    SDL_Event event;
    static int mouse_down = 0;

    if(SDL_PollEvent( &event ) )
    {
        if(event.type == SDL_MOUSEWHEEL )
        {
            if( event.wheel.y > 0 ) {
                light_z+= 100;
            }
            else {
                light_z-=100;
            }

            if( light_z > 10000 ) {
                light_z = 10000;
            }
            else if( light_z < -10000 ) {
                light_z = -10000;
            }
        }
        else if(event.type == SDL_MOUSEMOTION )
        {
            if( mouse_down == 1 ) {
                SDL_GetMouseState( &light_x, &light_y );
            }
        }
        else if(event.type == SDL_MOUSEBUTTONDOWN )
        {
            if(event.button.button == SDL_BUTTON_LEFT)
            {
                SDL_GetMouseState( &light_x, &light_y );
                mouse_down = 1;
            }
        }
        else if(event.type == SDL_MOUSEBUTTONUP )
        {
            if(event.button.button == SDL_BUTTON_LEFT)
            {
                mouse_down = 0;
            }
        }
    }
}

绘制光标图标函数

我们需要在lighting.c文件中定义的最后一个函数是draw_light_icon函数。该函数将使用 SDL 根据light_xlight_y变量的值来绘制我们的光源图标。我们创建一个名为destSDL_Rect变量,并设置该结构的xywh属性。然后,我们调用SDL_RenderCopy在适当的位置渲染我们的光源图标。以下是该函数的代码:

void draw_light_icon() {
    SDL_Rect dest;
    dest.x = light_x - light_width / 2 - 32;
    dest.y = light_y - light_height / 2;
    dest.w = light_width;
    dest.h = light_height;

    SDL_RenderCopy( renderer, light_texture, NULL, &dest );
}

编译和运行我们的照明应用

当我们编译和运行我们的照明应用时,我们应该能够在画布上单击并拖动我们的光源。我们有一个与法线贴图相关联的小圆圈。结合我们的着色和照明,它应该使得该圆圈看起来更像一个闪亮的按钮。在命令行上执行以下命令来编译lighting.html文件:

emcc lighting.c -o lighting.html --preload-file sprites -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"]

现在,您应该能够从 Web 服务器或 emrun 中提供lighting.html文件。如果一切顺利,应用程序应该如下所示:

图 15.6:2D 照明应用的屏幕截图

摘要

在本章中,我们在第三章 WebGL 简介中介绍了着色器的概念后,更深入地研究了着色器。当我们构建了一个 WebGL 应用时,了解 WebGL 是有帮助的。当您在使用 OpenGL for WebAssembly 时,因为每次从 WebAssembly 调用 OpenGL 时,内部都会调用相应的 WebGL 函数。我们首先使用 OpenGL ES 和 C++中的 SDL 重新构建了该 WebGL 应用,并将其编译为 WebAssembly。然后,我们学习了如何使用 OpenGL 和着色器以有趣的方式混合不同的纹理。我们利用这些知识创建了一个围绕飞船引擎的脉动发光效果。最后,我们讨论了 3D 照明和法线贴图,然后开发了一个 2D 照明模型,并创建了一个允许我们使用该照明模型照亮简单圆圈的应用程序。该应用程序通过允许我们在 2D 圆圈上移动光源并使用法线贴图来展示 2D 照明的可能性,法线贴图用于赋予该 2D 表面深度的外观。

在下一章中,我们将讨论调试我们的 WebAssembly 应用程序以及我们可以用于性能测试的工具。

第十六章:调试和优化

在这最后一章中,我们将讨论两个话题,这些话题将有助于您继续使用 Emscripten 创建游戏并构建 WebAssembly。我们将讨论调试和优化的话题。我们将在优化之前进行调试,因为构建代码以输出更多调试信息会阻止优化。我们将从一些基本的调试技术开始,比如打印堆栈跟踪和定义调试宏,我们可以通过更改编译标志来删除。然后,我们将转向一些更高级的调试技术,比如使用 Emscripten 标志进行编译,这允许我们在 Firefox 和 Chrome 中跟踪代码。我们还将讨论使用 Firefox 和 Chrome 开发者工具进行调试的一些区别。

您需要在构建中包含几个图像才能使此项目正常工作。确保您从该项目的 GitHub 存储库中包含/Chapter16/sprites/文件夹。如果您还没有下载 GitHub 项目,可以在这里在线获取:github.com/PacktPublishing/Hands-On-Game-Development-with-WebAssembly

在讨论调试结束后,我们将转向优化。我们将讨论您可以在 Emscripten 中使用的优化标志,以及使用性能分析器来确定您的游戏或应用可能存在性能问题的位置。我们将讨论优化代码以进行 WebAssembly 部署的一般技术。最后,我们将讨论与 Web 游戏和 WebAssembly 模块发出的 WebGL 调用相关的优化。

调试宏和堆栈跟踪

调试代码的一种方法是使用#define创建调试宏,我们可以通过将标志传递给 Emscripten 编译器来激活它。但是,如果我们不传递该标志,这将解析为空。宏很容易添加,我们可以创建一个调用,如果我们使用调试标志运行,它将打印一行,但如果我们不运行,它不会减慢性能。如果您不熟悉预处理器命令,它们是在代码编译时而不是在运行时评估的命令。例如,如果我使用了#ifdef PRINT_ME命令,只有在我们的源代码中定义了PRINT_ME宏时,才会将该行代码编译到我们的源代码中,或者如果我们在运行编译器时传递了-DPRINT_ME标志。假设我们在main函数中有以下代码块:

#ifdef PRINT_ME
    printf("PRINT_ME was defined\n");
#else
    printf("nothing was defined\n");
#endif

如果我们这样做了,我们将编译并运行该代码。Web 浏览器的控制台将打印以下内容:

"nothing was defined"

如果我们使用-DPRINT_ME标志进行编译,然后在命令行上运行代码,我们将看到以下内容被打印出来:

"PRINT_ME was defined"

如果您将代码反汇编为 WebAssembly 文本,那么您将看不到最初打印“未定义任何内容”的printf语句的任何迹象。在编译时,代码被移除。这使得预处理宏在创建我们希望在开发阶段包含的代码时非常有用。

如果您正在使用-D标志在代码中包含调试宏,请确保在编译发布时不要包含该标志,因为这将在您不想要它们的情况下继续包含所有调试宏。您可能需要考虑在为一般发布编译代码时,使用-DRELEASE标志来覆盖您的-DDEBUG标志。

将所有的printf调用限制在一个宏中是确保在发布应用时删除所有会减慢应用速度的printf调用的好方法。让我们通过以webgl-redux.c文件作为基准开始尝试一下。从我们在上一章中创建的代码中,将webgl-redux.c复制并粘贴到一个名为debug.cpp的文件中。我们将在这个文件的开头添加我们的调试宏。在包含emscripten.h的行之后,但在定义画布宽度的代码行之前,添加以下代码块:

#ifdef DEBUG
    void run_debug(const char* str) {
        EM_ASM (
            console.log(new Error().stack);
        );
        printf("%s\n", str);
    }

    #define DBG(str) run_debug(str)
#else
    #define DBG(str)
#endif

如果我们向编译器传递-DDEBUG标志,这段代码将只编译run_debug函数。用户不应直接运行run_debug函数,因为如果我们不使用-DDEBUG标志,它将不存在。相反,我们应该使用DBG宏函数。无论我们是否使用-DDEBUG标志,这个宏都存在。如果我们使用这个标志,该函数调用run_debug函数。如果我们不使用这个标志,对DBG的调用会神奇地消失。run_debug函数不仅使用printf打印字符串,还使用EM_ASM将堆栈跟踪转储到 JavaScript 控制台。堆栈跟踪记录当前在 JavaScript 堆栈上的每个函数。让我们添加一些最终会调用我们的DBG宏的函数调用。这些应该在main函数之前立即添加:

extern "C" {
    void inner_call_1() {
        DBG("check console log for stack trace");
    }
    void inner_call_2() {
        inner_call_1();
    }
    void inner_call_3() {
        inner_call_2();
    }
}

在我们的main函数内,我们应该添加对inner_call_3()的调用,如下所示:

int main() {
    inner_call_3();

现在,让我们使用以下命令编译我们的debug.cpp文件:

emcc debug.cpp -o index.html -DDEBUG --preload-file sprites -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"]

这将debug.cpp文件编译成一个index.html文件。如果我们从 Web 服务器提供该文件并在浏览器中打开它,我们将在 JavaScript 控制台中看到以下内容:

Error
 at Array.ASM_CONSTS (index.js:1901)
 at _emscripten_asm_const_i (index.js:1920)
 at :8080/wasm-function[737]:36
 at :8080/wasm-function[738]:11
 at :8080/wasm-function[739]:7
 at :8080/wasm-function[740]:7
 at :8080/wasm-function[741]:102
 at Object.Module._main (index.js:11708)
 at Object.callMain (index.js:12096)
 at doRun (index.js:12154)

(index):1237 check console log for stack trace

您会注意到我们有一个堆栈跟踪,后面是我们的消息,“检查控制台日志以获取堆栈跟踪”,这是我们传递给DBG宏的字符串。如果您仔细观察,您可能会注意到的一件事是,这个堆栈跟踪并不是很有用。堆栈跟踪中的大多数函数都标记为wasm-function,从调试的角度来看,这有点无用。这是因为我们在编译过程中丢失了函数名称。为了保留这些名称,我们需要在编译时向 Emscripten 传递-g4标志。-g标志后面跟着一个数字,告诉编译器在编译过程中保留多少调试信息,-g0表示最少的信息,-g4表示最多的信息。如果我们想要创建将我们的 WebAssembly 映射到创建它的 C/C++源代码的源映射,我们需要传入-g4命令,如果我们想知道堆栈跟踪调用的函数,我们也需要-g4。让我们尝试使用-g4标志重新编译。这是emcc命令的新版本:

emcc debug.cpp -o index.html -g4 -DDEBUG --preload-file sprites -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"]

现在,重新加载页面并检查控制台。在下面的片段中,我们有新的堆栈跟踪:

Error
 at Array.ASM_CONSTS (index.js:1901)
 at _emscripten_asm_const_i (index.js:1920)
 at __Z9run_debugPKc (:8080/wasm-function[737]:36)
 at _inner_call_1 (:8080/wasm-function[738]:11)
 at _inner_call_2 (:8080/wasm-function[739]:7)
 at _inner_call_3 (:8080/wasm-function[740]:7)
 at _main (:8080/wasm-function[741]:102)
 at Object.Module._main (index.js:11708)
 at Object.callMain (index.js:12096)
 at doRun (index.js:12154)
 (index):1237 check console log for stack trace

这样更容易阅读。您可以看到我们定义的所有内部调用函数,以及main函数。但run_debug发生了什么?它看起来像这样:

 __Z9run_debugPKc

这里发生的情况被称为 C++名称混编,在前几章中我们简要讨论过。因为 C++允许函数重载,编译器会对函数的名称进行混编,以便每个函数版本都有不同的名称。我们通过将它们放在标记为extern "C"的块中来防止这种情况发生在对inner_call_1inner_call_2inner_call_3的调用中。这告诉编译器不要混编这些函数的名称。这对于调试并不是绝对必要的,但我想演示如何将函数添加到这个块中可以更容易地在堆栈跟踪中识别我们的函数。如果我删除extern "C"块,那么相同的堆栈跟踪看起来是这样的:

Error
 at Array.ASM_CONSTS (index.js:1901)
 at _emscripten_asm_const_i (index.js:1920)
 at __Z9run_debugPKc (:8080/wasm-function[737]:36)
 at __Z12inner_call_1v (:8080/wasm-function[738]:11)
 at __Z12inner_call_2v (:8080/wasm-function[739]:7)
 at __Z12inner_call_3v (:8080/wasm-function[740]:7)
 at _main (:8080/wasm-function[741]:102)
 at Object.Module._main (index.js:11708)
 at Object.callMain (index.js:12096)
 at doRun (index.js:12154)
 (index):1237 check console log for stack trace

正如您所看到的,我们所有的内部调用函数都被搅乱了。在下一节中,我们将讨论源映射。

源映射

现在,让我们简要讨论源映射。在 Web 的早期,人们决定用户应该能够查看每个网页上的所有源代码。早期,这总是 HTML,但后来添加了 JavaScript,并成为用户可以查看以尝试理解给定网页工作原理的内容。今天,在大多数情况下,这是不可能的。今天的一些代码,如 TypeScript,是从另一种语言转译为 JavaScript。如果您正在编写 JavaScript,可以使用 Babel 将最新的 JavaScript 转换为在旧的 Web 浏览器上运行。Uglify 或 Minify 可用于删除空格并缩短变量名。如果您需要调试原始源代码,源映射是您可以使用的工具,将在浏览器中运行的 JavaScript 映射回原始源代码。

源映射是一个包含数据映射的 JSON 文件,用于将机器生成的 JavaScript 输出代码指回手写的 JavaScript 或另一种语言,如 TypeScript 或 CoffeeScript。应用程序可以通过两种方式告诉 Web 浏览器给定代码有一个源映射文件。我们可以在代码中包含一个带有sourceMappingURL指令的注释,或者我们可以在该文件的 HTTP 标头中包含一个SourceMap。如果我们使用sourceMappingURL注释方法,请在输出 JavaScript 文件的末尾添加以下行:

//# sourceMappingURL=http://localhost:8080/debug.wasm.map

这通常是在构建过程中以编程方式完成的。另一种方法是将以下行添加到 HTTP 标头中:

SourceMap: http://localhost:8080/debug.wasm.map

在下一节中,我们将讨论基于浏览器的 WebAssembly 调试工具。

浏览器调试

在 Web 浏览器中调试 WebAssembly 仍然相当粗糙。例如,在撰写本文时,仍然不可能直接使用调试器观察变量。在 Firefox 和 Chrome 中,您必须偶尔刷新浏览器才能看到 CPP 源文件。与调试 JavaScript 不同,WebAssembly 调试器感觉(讽刺地)很有 bug。在 Chrome 中,您经常不得不多次单击步进按钮才能推进代码行。在两个浏览器中,断点有时会失效。

我经常不得不删除然后重新添加断点才能使它们再次工作。WebAssembly 源映射和浏览器调试仍处于早期阶段,因此希望情况很快会有所改善。在这之前,尝试将浏览器中的调试与添加调试语句结合使用,正如我之前建议的那样。

为调试编译您的代码

正如我之前提到的,我们需要编译我们的应用程序以支持我们可以在 Firefox 和 Chrome 中进行浏览器调试的源映射。目前,唯一支持浏览器调试的浏览器是 Firefox、Chrome 和 Safari。在本书中,我只会涵盖 Firefox 和 Chrome。您可以使用以下emcc命令编译debug.cpp文件以供 WebAssembly 调试器使用:

emcc -g4 debug.cpp -o debug.html --source-map-base http://localhost:8080/ --preload-file sprites -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s MAIN_MODULE=1 -s WASM=1

第一个新标志是-g4,它指示编译器生成最多的调试数据,并为我们的 WebAssembly 创建源映射文件。接下来是--source-map-base http://localhost:8080/标志,它告诉编译器将sourceMappingURL$http://localhost:8080/debug.wasm.map字符串添加到debug.wasm文件的末尾。这允许浏览器找到与debug.wasm文件关联的源映射文件。最后两个新标志是-s MAIN_MODULE=1-s WASM=1。我不确定为什么需要这两个标志来使源映射工作。这两个标志都明确告诉编译器运行默认行为。但是,在撰写本文时,如果不包括这些标志,浏览器调试将无法工作。对我来说,这感觉像是一个错误,所以可能在您阅读本文时,emcc将不需要这最后两个标志。使用上述命令编译将允许您在 Chrome 和 Firefox 上使用 WebAssembly 调试器进行测试。如果您真的想在 Opera、Edge 或其他尚不支持 WebAssembly 调试的调试器上进行调试,您还有另一种选择。

使用 asm.js 作为调试的替代方法

出于某种原因,您可能认为使用 Edge 或 Opera 进行调试是必要的。如果您觉得必须在没有 WebAssembly 调试器的浏览器中进行调试,您可以将编译目标更改为 asm.js 作为替代方法。如果是这样,将-s WASM=1标志更改为-s WASM=0,然后就可以了。这将创建一个 JavaScript 文件而不是 WASM 文件,但是这两个文件(理论上)应该表现相同。

使用 Chrome 进行调试

Chrome 有一些很好的工具用于调试 JavaScript,但在调试 WebAssembly 方面仍然比较原始。构建应用程序后,将其在 Chrome 中打开,然后打开 Chrome 开发者工具:

图 16.1:使用菜单打开 Chrome 开发者工具的屏幕截图

您可以通过浏览器左上角的菜单打开它,就像前面的屏幕截图中所示,或者您可以通过键盘上的Ctrl + Shift + I组合键打开开发者工具。在 Chrome 中加载您的debug.html文件时,您需要在开发者窗口中点击“源”选项卡。如果您在“源”选项卡上,应该看起来像这样:

图 16.2:在 Chrome 开发者工具中使用源选项卡的屏幕截图

如果在“源”选项卡中看不到debug.cpp,可能需要点击顶部 URL 旁边的浏览器重新加载按钮来重新加载页面。正如我之前所说,界面感觉有点小问题,有时候 CPP 文件第一次加载不出来。希望在你阅读这篇文章时已经改变了。一旦选择了 CPP 文件,你应该能够在开发者工具窗口中间的代码窗口中看到我们的debug.cpp文件中的 C++代码。您可以通过单击代码行旁边的行号来在 C++代码中设置断点。然后,您可以使用“观察”变量上方的按钮逐步执行代码。尽管在撰写本文时观察变量不起作用,但您可能还是想尝试一下。WebAssembly 几乎每天都在改进,不断进行错误修复,所以在您阅读本文时,情况可能已经发生了变化。如果没有,您可以使用“本地”变量来了解一些值的变化。

您可以观察这些变量在您逐步执行源代码时被填充,经常可以确定哪些变量通过观察这些值的变化而更新。看一下下面的屏幕截图:

图 16.3:Chrome 浏览器中调试工具的屏幕截图

在撰写本文时,你需要点击“步过”按钮多次才能使 C++代码窗口中的行前进。在 Chrome 中,“步过”按钮每次点击会前进一个 WebAssembly 指令,而不是一个 C++指令。这可能在你阅读本文时已经改变,但如果你需要多次点击“步过”来前进代码,也不要感到惊讶。

使用 Firefox 进行调试

Firefox 与 Chrome 相比有一些优势和劣势。优势是,在 Firefox 中,你可以在 C++代码中每行点击一次“步过”按钮。劣势是,这使得跟踪响应你执行的行的本地变量更加困难。这些“本地”变量有点像寄存器,因此同一个变量可能会在几个寄存器中移动。如果你更关心跟踪代码流程而不是知道每个 WebAssembly 指令的值变化,那么 Firefox 在这方面要好得多。

要打开 Firefox 开发者工具,点击浏览器窗口右上角的菜单按钮,然后选择 Web 开发者:

图 16.4:Firefox 浏览器中的 Web 开发者工具

在 Web 开发者菜单中,点击调试器菜单项打开调试器窗口:

图 16.5:在 Firefox 中打开调试器的屏幕截图

不要通过菜单系统选择调试器,你可以使用快捷键Ctrl + Shift + C来打开检查器,然后从 Web 开发者窗口中选择调试器选项卡。当你在 Firefox 调试器中时,它看起来是这样的:

图 16.6:在 Firefox 浏览器中使用调试器的屏幕截图

目前,调试将需要结合使用调试宏和浏览器完全理解正在发生的情况。

Firefox 开发者版

我简要提一下 Firefox 开发者版。如果你喜欢将 Firefox 作为你的主要 WebAssembly 开发浏览器,你可能会考虑使用 Firefox 开发者版。开发者版比标准版的 Firefox 更快地推出更新的 Web 开发者工具。因为 WebAssembly 是如此新颖,改进开发体验的更新可能会比标准版提前几周甚至几个月出现在开发者版中。在撰写本文时,两个版本之间没有显著差异,但如果你有兴趣尝试,可以在以下网址找到:www.mozilla.org/en-US/firefox/developer/

为 WebAssembly 进行优化

优化你的 WebAssembly 代码部分取决于决策和实验。它是关于发现对你的特定游戏或应用有效的方法。例如,当设计 WebAssembly 时,决定让 WebAssembly 字节码在虚拟堆栈机上运行。WebAssembly 的设计者做出了这个选择,因为他们认为可以通过显著减小字节码下载大小来证明性能的小损失。每段代码都有瓶颈。在 OpenGL 应用程序中,瓶颈将是与 GPU 的接口。你的应用程序的瓶颈可能是内存,也可能是 CPU 限制。一般来说,优化代码是关于确定瓶颈在哪里,并决定你想要做出什么权衡来改进事情。如果你优化下载大小,你可能会失去一些运行时性能。如果你优化运行时性能,你可能需要增加内存占用。

优化标志

Emscripten 为我们提供了大量的标志,以优化不同的潜在瓶颈。所有的优化标志都会导致不同程度的较长编译时间,因此在开发周期的后期才应该使用这些标志。

优化性能

我们可以使用-O标志进行一般优化。-O0-O1-O2-O3提供了不同级别的编译时间和代码性能之间的权衡。-O0-O1标志提供了最小的优化。-O2标志提供了大部分来自-O3标志的优化,但编译时间明显更短。最后,-O3提供了最高级别的优化,但编译时间比任何其他标志都要长得多,因此最好在接近开发结束时开始使用它。除了-O标志,-s AGGRESSIVE_VARIABLE_ELIMINATION=1也可以用于增加性能,但可能会导致更大的字节码下载大小。

优化大小

在前面的部分中我没有提到另外两个-O标志。这些标志用于优化字节码下载大小,而不是纯粹地优化性能。-Os标志所花费的时间大约和-O3一样长,并提供尽可能多的性能优化,但是为了更小的下载大小而牺牲了一些-O3的优化。-Oz类似于-Os,但通过牺牲更多的性能优化来进一步优化更小的下载大小,从而导致更小的字节码。另一种优化大小的方法是包括-s ENVIRONMENT='web'标志。只有在编译网页时才应该使用这个标志。它会删除用于支持其他环境(如 Node.js)的任何源代码。

不安全的标志

除了我们到目前为止一直在使用的安全优化标志之外,Emscripten 还允许使用两个不安全标志来提高性能,但可能会破坏您的代码。这些标志是高风险/高回报的优化,只有在大部分测试完成之前才应该使用。使用--closure 1标志会运行 Closure JavaScript 编译器,它会对我们应用程序中的 JavaScript 进行非常激进的优化。但是,除非您已经熟悉使用闭包编译器以及该编译器可能对 JavaScript 产生的影响,否则不应该使用--closure 1标志。第二个不安全标志是--llvm-lto 1标志,它在 LLVM 编译步骤中启用链接时优化。这个过程可能会破坏您的代码,因此在使用这个标志时要非常小心。

分析

分析是确定源代码中存在的瓶颈的最佳方法。当您对 WebAssembly 模块进行分析时,我建议在编译时使用--profiling标志。您也可以不使用它进行分析,但是您调用的所有模块函数都将被标记为wasm-function,这可能会使您的生活比必要的更加困难。在使用--profile标志编译代码后,在 Chrome 中打开一个新的隐身窗口。

您可以通过按下CTRL + SHIFT + N键,或者通过浏览器右上角的菜单来执行此操作:

图 16.7:在 Chrome 浏览器中打开隐身窗口

在打开隐身窗口时,将阻止任何 Chrome 扩展在分析您的应用程序时运行。这将防止您不得不浏览这些扩展中的代码以找到您应用程序中的代码。打开隐身窗口后,按下Ctrl + Shift + I来检查页面。这将在浏览器窗口底部打开 Chrome 开发者工具。在 Chrome 开发者工具中,选择性能选项卡,如下面的截图所示:

图 16.8:Chrome 浏览器中的性能选项卡

现在,点击记录按钮,让它运行几秒钟。记录了五六秒钟后,点击停止按钮停止分析:

图 16.9:Chrome 浏览器中性能指标的录制屏幕截图

停止分析后,您将在性能窗口中看到数据。这称为摘要选项卡,并以饼图的形式显示应用程序在各种任务上花费的毫秒数。

正如您所看到的,我们的应用程序绝大部分时间都是空闲的:

图 16.10:Chrome 浏览器中的性能概述

摘要很有趣。它可以告诉您在非常高的层次上瓶颈在哪里,但要评估我们的 WebAssembly,我们需要查看调用树选项卡。点击调用树选项卡,您将看到以下窗口:

图 16.11:Chrome 浏览器中的调用树屏幕截图

因为我们的game_loop函数在每一帧都被调用,所以我们可以在Animation Frame Fired树中找到这个调用。向下钻取,寻找game_loop。当我们找到这个函数时,它被搞乱了,因为它是一个 C++函数。所以,我们看到的不是_game_loop,而是_Z9game_loopv,尽管你可能看到的搞乱的形式不同。如果你想要防止这种搞乱,你可以将这个函数包装在一个extern "C"块中。

您可以看到这个函数的执行总共占据了浏览器 CPU 时间的 3.2%。您还可以查看这个函数中的每个 OpenGL 调用。如果您看一下我们的游戏循环,超过一半的 CPU 时间都花在了_glClear上。对于这个应用程序来说,这不是问题,因为绝大多数的浏览器 CPU 时间都是空闲的。然而,如果我们的游戏循环函数占用了大部分 CPU 时间,我们就需要看看在这个函数中我们花费了多少时间。

try/catch 块的问题

在撰写本文时,已知 try/catch 块会导致 WebAssembly 模块的显著性能问题,因此只有在绝对必要时才使用它们。您可能希望在开发阶段使用它们,并在发布时将它们移除。一些-O优化标志将删除 try/catch 块,如果您打算在生产中使用它们,您需要注意这一点。如果您想在生产版本中使用 try/catch 块,您需要使用-s DISABLE_EXCEPTION_CATCHING=0标志进行编译。这将告诉编译器不要从优化版本的字节码中删除 try/catch 块。如果您想从未优化的开发代码中删除 try/catch 块,您可以使用-s DISABLE_EXCEPTION_CATCHING=1标志。

为 WebAssembly 优化 OpenGL

重要的是要记住,从 WebAssembly 调用 OpenGL 时都是通过函数表调用 WebGL 的。这很重要的部分原因是因为每当您使用 OpenGL ES 和 WebGL 不可用的 OpenGL 功能时,Emscripten 必须对这些功能进行一些非常慢的软件模拟。还要记住,WebGL 调用比本地平台上的 OpenGL 调用更昂贵,因为 WebGL 是受沙箱保护的,浏览器在调用 WebGL 时会执行各种安全检查。Emscripten 为您提供了几个标志,允许您模拟 WebGL 中不可用的 OpenGL 和 OpenGL ES 调用。然而出于性能原因,除非绝对必要,不要使用这些功能。

尽可能使用 WebGL 2.0

WebGL 2.0 比 WebGL 1.0 更快,但在撰写本文时,支持它的浏览器要少得多。将您的 WebGL 1.0 代码编译为 WebGL 2.0 将使您获得约 7%的性能提升。但是,在选择这样做之前,您可能希望参考caniuse.com/#search=webgl2来查看您的目标浏览器是否支持 WebGL 2.0。

最小化 OpenGL 调用次数

从 WebAssembly 调用 OpenGL 不像从本机编译的应用程序中进行相同调用那样快。从 WebAssembly 调用 OpenGL 相当于调用 WebGL 的模拟。WebGL 是为在 Web 浏览器中执行而构建的,并执行一些安全检查以验证我们没有要求 WebGL 执行任何恶意操作。这意味着在编写针对 WebAssembly 的 OpenGL 时,我们必须考虑到额外的开销。有些情况下,本机应用程序对 OpenGL 的两三次调用可能比将这些调用合并为单个 OpenGL 调用更快。然而,在 WebAssembly 中,将相同的代码压缩为单个 OpenGL 调用可能会更快。在优化 WebAssembly 时,尽量减少 OpenGL 调用,并使用分析器验证新代码是否更快。

Emscripten OpenGL 标志

几个 Emscripten 链接器标志可能会对性能产生重大影响。其中一些标志是为了简化代码移植到 WebAssembly,但可能会导致性能问题。其他标志在适当条件下可以提高性能。

-s FULL_ES2=1-s FULL_ES3=1链接器标志模拟整个 OpenGL ES 2.0/3.0 API。正如我之前提到的,默认情况下,WebAssembly 中的 OpenGL ES 2/3 实现仅支持与 WebGL 兼容的 OpenGL ES 2/3 的子集。这是因为 WebGL 在 WebAssembly 中进行渲染。您可能绝对需要 OpenGL ES 2/3 的某个默认不可用的功能。如果是这样,您可以使用-s FULL_ES2=1-s FULL_ES3=1标志在软件中模拟该功能。这将会影响性能,因此在决定使用时要考虑这一点。

-s LEGACY_GL_EMULATION=1标志用于模拟使用固定功能管线的旧版本 OpenGL。也不建议使用此标志,因为会导致性能不佳。这个标志是为那些希望将旧代码移植到 WebAssembly 的人准备的。

如果您想要使用 WebGL 2 来获得与之相关的性能提升,请使用-s USE_WEBGL2=1链接器标志。如果您有为 WebGL 1.0 编写的代码,但想要获得 WebGL 2.0 的性能提升,您可以尝试编译为 WebGL 2.0,以查看您是否使用了在 WebGL 2.0 中不向后兼容的任何代码。如果使用此标志无法编译,则可以尝试-s WEBGL2_BACKWARDS_COMPATIBILITY_EMULATION=1链接器标志,这将允许您编译您的 WebGL 1.0 代码,以便您可以在 WebGL 2.0 中使用它。

总结

在本章中,我们讨论了不同的策略,可以用来调试和优化我们的 WebAssembly 代码。我们讨论了编写 C 宏,它们可以让我们在从开发转入生产时轻松地删除对控制台的打印调用。我们谈到了源映射,它们是什么,以及它们如何帮助我们在浏览器中调试我们的 WebAssembly 代码。我们讨论了如何在 Chrome 和 Firefox 中使用调试器来逐步执行 WebAssembly 的源代码。最后,我们讨论了 WebAssembly 中的优化,Emscripten 中可用的编译器选项,以及我们如何改进 WebGL 性能。

这就是结尾

恭喜!您应该已经在开发自己的 WebAssembly 游戏或应用程序的路上了。我希望您喜欢学习如何使用 WebGL 来构建 Web 游戏。如果您有任何问题、评论或只是想打个招呼,您可以在以下平台找到我:

posted @ 2024-05-05 00:04  绝不原创的飞龙  阅读(18)  评论(0编辑  收藏  举报