Rust-和-Webassembly-游戏开发-全-
Rust 和 Webassembly 游戏开发(全)
原文:
annas-archive.org/md5/dc6184b37704cf034149c322f30f8917
译者:飞龙
前言
Rust 编程语言连续 6 年在 Stack Overflow 上保持“最受欢迎”的技术排名,而 JavaScript 则连续 9 年成为最常用的编程语言,因为它在所有网络浏览器上运行(bit.ly/3JBg4ms
)。现在,多亏了 WebAssembly(或 Wasm),您可以在无处不在的平台中使用您喜欢的语言。本书是一本易于遵循的参考书,帮助您开发自己的游戏,教您所有关于游戏开发以及如何从头开始创建无尽跑酷游戏的知识。您将从在浏览器窗口中绘制简单的图形开始,然后学习如何将主要角色移动到屏幕上。您还将创建游戏循环、渲染器等,所有这些都是在 Rust 中编写的。在屏幕上放置简单的形状后,您将通过添加精灵、声音和用户输入来增加挑战。随着您的进步,您将发现如何实现过程生成的世界,并添加音效和音乐。最后,您将学习如何保持您的 Rust 代码干净和有序,以便您可以继续实现新功能并将您的应用程序部署到网络上。到本书结束时,您将使用 Rust 编程语言构建了一个 2D 游戏,将其部署到网络上,并足以自信地开始构建自己的游戏。
本书面向的对象
这本游戏开发书籍是为对 Rust 感兴趣并希望创建和部署 2D 游戏到网络上的开发者而编写的。希望在没有 C++ 编程的情况下在 WebAssembly 平台上构建游戏的开发者,以及希望探索 WebAssembly 并与 JavaScript 网络开发者一起工作的开发者,也会发现这本书很有用。本书还将帮助 Rust 开发者通过使他们熟悉 WebAssembly 工具链,从服务器端迁移到客户端。假设您对 Rust 编程有一定的了解,但您不需要是专家。
本书涵盖的内容
第一章,你好,WebAssembly,为您搭建了第一个 WebAssembly 项目,解释了工具链,并在浏览器中运行了一个应用程序,绘制到我们将在这本书中使用的 HTML Canvas 上。
第二章,绘制精灵,通过向您展示如何将 .png
文件渲染到屏幕上来向您介绍我们的主要角色,红帽男孩。然后,我们将让红帽男孩通过动画和精灵表进行奔跑。
第三章,创建游戏循环,介绍了一个非常基本的游戏引擎,这样我们就可以以每秒 60 帧的速度让我们的角色在屏幕上四处移动。
第四章,使用状态机管理动画,描述了如何使用状态机和 Rust 类型状态模式让红帽男孩奔跑、滑动和跳跃。
第五章,碰撞检测,开始让游戏变得有趣,让红帽男孩撞到并跳过障碍物。我们将介绍轴对齐边界框,并对其进行调整以考虑透明度。
第六章,创建无尽跑酷游戏,将游戏从一个场景带到红帽男孩向右跑的场景,跳过程序生成的障碍物和平台,这些障碍物和平台可以持续到你玩不下去为止。
第七章,音效与音乐,展示了如何使用 Web Audio API 通过音效和吸引人的音乐让游戏获得真正的沉浸感。
第八章,添加用户界面,将 HTML 与画布集成以创建用户界面,重新构建游戏以使其适应。
第九章,测试、调试和性能,帮助我们为游戏编写一些自动化测试,并使用浏览器工具调查性能。
第十章,持续部署,将我们的游戏部署到网络上,让任何人都可以玩!
第十一章,更多资源和下一步是什么?,带我们了解如何为更大、更有雄心的游戏制定下一步计划。
要充分利用这本书
本书期望您对 Rust 有基本的了解,但不涵盖语法。它不期望您是专家;您不会编写任何宏或复杂的特性,所以即使是 Rust 速查表也足够了。本书的结构是一个教程,最好从头到尾完成。
本书中的代码使用 Rust 版本 1.57.0 进行了测试。大多数工具都将由rust-webpack-template
自动安装。
如果您正在使用这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly
。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
代码实战
本书的相关代码实战视频可在bit.ly/3uxXl4W
查看。
下载彩色图片
我们还提供了一个包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801070973_ColorImages.pdf
。
使用的约定
本书使用了多种文本约定。
文本中的代码
: 这表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“Cypress 通过cy.request()
方法执行其大部分 API 测试,该方法作为对被测试的 Web 服务器的GET
命令。”
代码块设置如下:
enum RedHatBoyState {
Jumping,
Running,
Sliding,
}
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
impl RedHatBoyContext {
pub fn update(mut self, frame_count: u8) ->
Self {
...
self.position.x += self.velocity.x;
self.position.y += self.velocity.y;
if self.position.y > FLOOR {
self.position.y = FLOOR;
}
任何命令行输入或输出都应如下编写:
the trait `From<SlidingEndState>` is not implemented for `RedHatBoyStateMachine`
粗体: 这表示新术语、重要单词或你在屏幕上看到的单词——例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从 GUI 的任何测试启动中,用户将能够点击添加新测试按钮。”
小贴士或重要提示
看起来像这样。
联系我们
我们读者的反馈总是受欢迎的。
一般反馈: 如果你对此书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书籍标题。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在此书中发现错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果你在网上以任何形式发现我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并在邮件主题中提及书籍标题。
如果你有兴趣成为作者: 如果你有一个你擅长的主题,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《使用 Rust 和 WebAssembly 进行游戏开发》,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
第一部分:Rust、WebAssembly 和游戏开发入门
在本部分,你将为本书的其余部分构建应用程序的框架。你将使用 Rust 创建你的第一个 WebAssembly 应用程序,并通过wasm-bindgen
与 JavaScript 交互。你还将开始绘制 Canvas,首先从粗糙的形状开始,然后是精灵(图像文件)甚至精灵图集。
在本部分,我们将涵盖以下章节:
-
第一章, 你好,WebAssembly
-
第二章, 绘制精灵
第一章:第一章: 欢迎来到 WebAssembly
让我们直奔主题——如果你拿着这本书,你可能已经知道你喜欢 Rust,并且认为 WebAssembly 是将你的 Rust 程序部署到网页上的绝佳方式。好消息——你是对的!Rust 和 WebAssembly 是程序员天堂中的完美搭配,尽管 WebAssembly 仍处于早期阶段,但游戏开发是 WebAssembly 的理想候选者。我很高兴能引导你使用 Stack Overflow 的“最受欢迎”的语言 Rust 来构建网页游戏。
本章全部关于为你提供游戏开发旅程的工具。在本章中,我们将涵盖以下主题:
-
什么是 WebAssembly?
-
创建 Rust 和 WebAssembly 项目的骨架
-
将 JavaScript 代码转换为 Rust 代码
-
使用 HTML5 Canvas 绘制到屏幕上
技术要求
要跟随项目骨架,你需要安装 rustup
来安装 Rust 工具链。这可以在 rustup.rs/
找到。虽然你可以使用 rustup
工具之外的方式安装 Rust 和其各种工具链,但这并不简单,我这里不会记录它。你还需要一个用于编写 Rust 代码的编辑器,虽然你可以使用几乎任何带有 rust-analyzer 的编辑器,如果你是 Rust 编写的初学者,我推荐 Visual Studio Code 和可在 bit.ly/3tAUyH2
找到的 Rust 扩展。它很容易设置,并且开箱即用。
最后,你需要一个网络浏览器,在本章中,你需要对终端和 Node.js 有一定的了解。如果你遇到困难,本章的代码可以在 github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_1
找到。整本书的最终代码在主分支 github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly
。
查看以下视频,了解代码的实际应用:bit.ly/3qMV44E
什么是 WebAssembly?
你选择了这本书(谢谢!)所以很可能会对 WebAssembly 有一些了解,但以防万一,让我们从 WebAssembly.org
捕获一个定义:
"WebAssembly(简称 Wasm)是一种基于栈的虚拟机的二进制指令格式。Wasm 被设计为编程语言的便携式编译目标,使得部署在客户端和服务器应用程序的网页上成为可能。"
换句话说,Wasm是一种二进制格式,我们可以将其编译为其他语言,以便在浏览器中运行。这与将语言如 TypeScript 转换为 JavaScript 进行源到源编译或转译不同,这些语言在 JavaScript 环境中运行时仍然是 JavaScript。这些语言最终仍然是在运行 JavaScript,而 Wasm 是字节码。这使得下载更小,并且在运行时去除了解析和编译步骤,这可以带来显著的性能提升。但说真的——你使用 Rust 和 Wasm 并不是为了性能提升,这些提升也并不保证。你使用它是因为你喜欢 Rust。
没关系!
Rust 拥有出色的类型系统、优秀的开发者工具和出色的社区。虽然 WebAssembly 最初是为 C 和 C++设计的,但 Rust 是 WebAssembly 的绝佳语言,原因有很多,这些原因也是你喜爱 Rust 的原因。现在,对于 Web 存在的大部分时间来说,编写在浏览器中运行的应用程序意味着编写 JavaScript,而且多年来,JavaScript 已经发展成为一个适合该目的的现代语言。我并不是在这里告诉你,如果你喜欢 JavaScript,你应该停止使用它,但如果你热爱 Rust,你绝对应该开始编译为 Wasm 并在浏览器中运行应用程序。
重要提示
本书专注于使用 Rust 和 Wasm 制作基于 Web 的游戏,但你绝对可以在 Node.js 等服务器端环境中运行 Wasm 应用程序。如果你对此感兴趣,你可以查看 Mike Rourke 的书籍《Learn WebAssembly》,可在bit.ly/2N89prp
找到,或者查看官方的wasm-bindgen
指南,可在bit.ly/39WC63G
找到。
重要提示
本书假设你对 Rust 有一定的了解,尽管你不需要成为专家。如果你在任何时候对 Rust 的概念感到困惑,我强烈建议你停下来查看"这本书",《Rust 编程语言》,可在doc.rust-lang.org/book/
免费获取。
因此,既然我已经说服你去做你本来就要做的事情,让我们来看看你需要的一些工具,以便用 Rust 编写 Web 游戏:
-
rustup
:如果你正在编写 Rust 代码,那么你很可能已经在使用rustup
了。如果不是,你应该使用它,因为它是安装 Rust 的标准方式。它允许轻松安装工具链、Rust 编译器,甚至可以启动 Rust 文档。你需要它来安装Wasm 工具链,你可以从之前的链接中安装它。本书中的代码已在 Rust 版本 1.57.0 上进行了测试。 -
Node.js: 我知道——我承诺我们会用 Rust 来编写!我们会的,但这仍然是一个 Web 应用程序,你将使用 Node.js 来运行应用程序。我建议安装当前的长期支持版本(写作时为16.13.0)。较老的 Node.js 版本可能无法与包创建工具按预期工作。如果你使用 Ubuntu Linux,在使用 Debian 发行版时要特别小心,因为它现在安装了一个非常旧的版本。如果有疑问,请使用管理多个版本的工具,如 Linux/Mac 的Node Version Manager(nvm)工具或 Windows 的相应 nvm-windows 工具,以确保你使用的是长期发布版本。我本人使用 asdf 工具(
asdf-vm.com/
)来管理多个版本,尽管我不太推荐那些之前没有使用过版本管理工具的人使用它。 -
webpack: 我们将使用 webpack 来打包我们的应用程序以供发布并运行开发服务器。大多数时候,你不必担心它,但它是存在的。
重要提示
当前模板使用 webpack 4。确保在查找文档时检查这一点。
-
wasm-pack
: 这是一个用于构建由 Rust 生成的 WebAssembly 代码的 Rust 工具。像 webpack 一样,大多数时候你不会知道它的存在,因为它由 webpack 管理,你的 Rust 应用程序将主要由 Rust 构建工具管理。 -
wasm-bindgen
: 这是你需要了解的一个 crate,以便编写由 Rust 生成的 WebAssembly 代码。WebAssembly 的一个限制是,你不能访问wasm-bindgen
创建的绑定以及调用 JavaScript 函数所需的样板代码,同时它还提供了创建反向绑定的工具,以便 JavaScript 代码可以回调到 Rust 代码。随着我们阅读本书,我们将详细介绍wasm-bindgen
的工作原理,但为了避免现在就陷入细节,你可以将其视为一个库,用于从 Rust 代码中调用 JavaScript。 -
web-sys
: 这是一个由许多预生成的绑定组成的 crate,使用wasm-bindgen
为 Web 创建。我们将使用web-sys
来调用浏览器 API,如 canvas 和requestAnimationFrame
。本书假设您至少对 Web 开发有一定的了解,但不需要在这个领域有专业知识,实际上,Rust 游戏开发的一个优点就是我们只需将浏览器视为一个平台库,在上面调用函数。web-sys
crate 意味着我们不必自己创建所有这些绑定。 -
Canvas
:HTML Canvas 是一个<canvas>
浏览器元素,例如标题或段落,但它允许你直接在其上绘制。这就是我们制作视频游戏的方法!有许多方法可以将内容绘制到画布上,包括WebGL
和WebGPU
,但我们将使用此项目的大部分内置 Canvas API。虽然这不是制作游戏的绝对最快方式,但对于学习目的来说足够快,并且避免了向我们的技术栈添加更多技术。
最后,在搜索 web-sys
、web-bindgen
或其他用于 WebAssembly 的 Rust 包时,你可能会遇到对 cargo-web
和 stdweb
的引用。虽然这两个项目对于 Rust 作为 WebAssembly 源的开发很重要,但它们自 2019 年以来都没有更新,可以安全忽略。现在我们已经知道了我们将使用的工具,让我们开始构建我们的第一个 Rust 项目。
一个 Rust 项目骨架
重要提示
这些说明是基于撰写时 rust-webpack-template
的状态。在阅读此内容时,它可能已经发生变化,所以请密切关注我们所做的更改。如果它们没有意义,请检查 wasm-pack
的文档,并使用你的最佳判断。
到目前为止,我将假设你已经安装了 rustup
和 Node.js。如果你还没有安装,请按照你平台的说明进行安装,然后按照以下步骤操作:
- 初始化项目
让我们先为你的应用程序创建一个项目骨架,它将是 Rust Wasm 小组的 Rust webpack 模板。它可以在 GitHub 上找到 github.com/rustwasm/rust-webpack-template
,但你不需要下载它。相反,使用 npm init
来创建它,如下所示:
mkdir walk-the-dog
cd walk-the-dog
npm init rust-webpack
你应该看到如下内容:
npx: installed 17 in 1.941s
🦀 Rust + 🕸 WebAssembly + Webpack = ❤
Installed dependencies ✅
恭喜!你已经创建了你的项目。
- 安装依赖项
你可以使用 npm
安装依赖项:
npm install
重要提示
如果你更喜欢使用 yarn
,你可以,除了 npm init
命令之外。我将在这本书中使用 npm
。
- 运行服务器
安装完成后,你现在可以使用 npm run start
运行开发服务器。你可能看到如下错误:
ℹ Installing wasm-pack
Error: Rust compilation.
at ChildProcess.<anonymous> (/walk-the-dog/node_modules/@wasm-tool/wasm-pack-plugin/plugin.js:221:16)
at ChildProcess.emit (events.js:315:20)
at maybeClose (internal/child_process.js:1048:16)
at Socket.<anonymous> (internal/child_process.js:439:11)
at Socket.emit (events.js:315:20)
at Pipe.<anonymous> (net.js:673:12)
如果发生这种情况,你需要手动安装 wasm-pack
。
- 安装 wasm-pack
在 Linux 和 macOS 系统上,wasm-pack
可以通过一个简单的 cURL 脚本安装:
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
Windows 用户有一个单独的安装程序,可以在 rustwasm.github.io
找到。
- 运行服务器 – 第二部分
现在 wasm-pack
已经安装,webpack 可以使用它,你应该能够运行应用程序:
npm run start
当你看到 「``wdm``」``: 编译成功。
时,你可以在 http://localhost:8080
浏览你的应用程序。好吧,是的,它是一个空白页面,但如果你打开开发者工具控制台,你应该会看到以下内容:
图 1.1 – Hello WebAssembly!
你已经将应用程序在浏览器中运行,但 Rust 生态系统更新速度比你所使用的模板能够跟上得更快。
- 更新 Rust 版本
最新的 Rust 版本,包含最新的 Rust 习惯用法和约定,是 2021 版。这在生成的 Cargo.toml
文件中的 package
部分进行了更改,如下所示:
# You must change these to your own details.
[package]
name = "rust-webpack-template"
description = "Walk the Dog - the game for the Rust Games with WebAssembly book"
version = "0.1.0"
authors = ["Eric Smith <paytonrules@gmail.com>"]
categories = ["wasm"]
readme = "README.md"
edition = "2021"
这里只更改了 edition
字段。
- 更新依赖
除非你恰好在模板更新时下载了它,否则生成的 Cargo.toml
文件中的依赖项不会是最新的和最好的。由于我们两个都不那么幸运,你将需要打开该文件并修改依赖项到以下内容。请注意,省略号只是用来标记文件中的空白,并不需要输入:
wasm-bindgen = "0.2.78"
...
[dependencies.web-sys]
version = "0.3.55"
...
[dev-dependencies]
wasm-bindgen-test = "0.3.28"
futures = "0.3.18"
js-sys = "0.3.55"
wasm-bindgen-futures = "0.4.28"
这些是我写这本书时使用的版本。如果你喜欢冒险,你可以去 crates.io
找到每个依赖项的最新版本,这正是我会做的,但我是个受苦的贪吃鬼。你可能比我聪明,所以你会使用这里指定的版本,以确保示例代码能正常工作。
- 更新 console_error_panic_hook
console_error_panic_hook
是在 WebAssembly 应用程序开发期间非常实用的 crate。它将 Rust 代码中的 panic 转发到控制台,以便你可以调试它们。当前的模板试图通过功能标志将其隐藏起来,但不幸的是,有一个错误,它不起作用。请记住仔细检查你生成的代码;如果它看起来不像我在这里复制的样子,那么错误可能已经被修复,但在此期间,请删除以下代码(仍然在 Cargo.toml
中)。
[target."cfg(debug_assertions)".dependencies]
console_error_panic_hook = "0.1.5"
然后将它添加到 [dependencies] 部分,在 wasm-bindgen 下方是一个好位置:
console_error_panic_hook = "0.1.7"
然后,我们将将其作为一个条件依赖项,这样你就不需要在发布构建时部署它,但到目前为止,这已经足够进步了。谁还愿意继续与 config
文件纠缠呢?我想在屏幕上绘制东西!
小贴士
虽然这个应用程序使用一个 npm init
模板来创建自身,但你也可以使用它的输出创建一个 cargo generate
模板,这样你就不必每次创建应用程序时都重做这些更改,只需创建一个 git
仓库即可。当然,如果你这么做,你将落后于 rust-webpack
模板的更新,所以这是一个权衡。如果你对使用 cargo generate
来创建自己的模板感兴趣,你可以在这里找到更多信息:bit.ly/3hCFWTs
。
绘制到画布上
要用 Rust 编写我们的游戏,我们需要在屏幕上绘制图形,为此,我们将使用 HTML Canvas 元素和 2D 上下文。Canvas 提供了一个直接在屏幕上绘制的 API,无需了解 WebGL 或使用外部工具。虽然这不是世界上速度最快的科技,但对于我们的小型游戏来说,它完全适用。让我们开始将我们的 Rust 应用从 "Hello World" 转换为能够绘制 Sierpiński 三角形 的应用程序。
重要提示
Sierpiński 三角形是一种通过绘制一个三角形,然后将该三角形细分为四个三角形,接着再将这些三角形细分为四个三角形,以此类推而创建的分形图像。听起来很复杂,但就像许多分形一样,它仅由几行数学公式构成:
- 添加画布
Canvas 是一个让我们可以自由绘制的 HTML 元素,使其成为游戏的理想选择。实际上,在撰写本文时,Adobe Flash 已经正式退役,如果你在网上看到一款游戏,无论是 2D 还是 3D,它都是在 canvas
元素中运行的。Canvas 可以用于游戏中的 WebGL 或 WebGPU,WebAssembly 也会与这些技术很好地配合,但它们超出了本书的范围。我们将使用内置的 Canvas 2D API 和其 2D 上下文。这意味着你不需要学习着色语言,我们能够非常快速地将图像显示在屏幕上。这也意味着,如果你需要的话,你可以在 Mozilla 开发者网络(MDN)Web 文档网站上找到优秀的文档:mzl.la/3tX5qPC
。
要在画布上绘制图形,我们需要将其添加到网页中。打开 static/index.html
文件,在 <body>
标签下添加 <canvas id="canvas" tabindex="0" height="600" width="600">您的浏览器不支持 canvas。</canvas>
。宽度和高度相当随意,但看起来现在很合适。"您的浏览器不支持 canvas。" 这条消息将在不支持 HTML Canvas 的浏览器上显示,但现在已经很少见了。
重要提示
确保不要删除 <script>
标签。它正在运行你在这个项目中构建的 JavaScript 和 WebAssembly!
- 清理错误
最后,我们可以开始编写一些 Rust 代码了!好吧,我们至少可以删除一些 Rust 代码。在 src/lib.rs
文件中,你会看到一个名为 main_js()
的函数,其代码如下:
// This provides better error messages in debug mode.
// It's disabled in release mode so it doesn't bloat
up the file size.
#[cfg(debug_assertions)]
console_error_panic_hook::set_once();
你可以继续删除注释和 [cfg(debug_annotations)]
注解。目前,我们将保留它在构建中运行,并在准备生产时使用功能标志将其移除。
重要提示
如果你编辑器中出现了错误,说 console::log_1(&JsValue::from_str("Hello world!"))
代码缺少一个不安全块,不要担心——这个错误是错误的。不幸的是,这是 rust-analyzer 中的一个错误,已经在这次问题中得到了解决:bit.ly/3BbQ39m
。你会在任何使用底层过程宏的东西上看到这个错误。如果你使用的编辑器支持实验性设置,你可能能够解决这个问题;检查 rust-analyzer.experimental.procAttrMacros
设置。如果有疑问,请检查 npm run start
的输出,因为这是编译器错误的更准确来源。
小贴士
如果你偏离这本书并决定部署,请转到 第十章,持续部署,学习如何在发布模式下通过功能标志隐藏该功能,这样你就不需要将不需要的代码部署到生产环境中。
删除这段代码将在应用程序启动时移除 warning: Found 'debug_assertions' in 'target.'cfg(...)'.dependencies'.
消息。此时,你可能已经注意到我没有告诉你更改后要重新启动服务器,这是因为 npm start
运行 webpack-dev-server
,它会自动检测更改,然后重新构建并刷新应用程序。除非你正在更改 webpack 配置,否则你不需要重新启动。
当前代码
到目前为止,我一直都在告诉你该做什么,而你一直在盲目地做,因为你像一位好读者一样跟随着。你非常勤奋,虽然有点过于信任,但现在该看看当前源代码,看看我们的 WebAssembly 库里到底有什么。首先,让我们从 use
指令开始。
use wasm_bindgen::prelude::*;
use web_sys::console;
第一个导入是 wasm_bindgen
的 prelude
。这会引入你很快就会看到的宏,以及一些对于编写 Web Rust 非常必要的类型。幸运的是,这并不多,而且不应该过多地污染命名空间。
重要提示
使用 *
语法并从给定的模块导入所有内容。如果模块有很多导出的名称,你现在在你的项目中也有这些相同的名称,并且在编码时并不明显。例如,如果 wasm_bindgen::prelude
中有一个名为 add
的函数,而你命名空间中也有一个名为 add
的函数,它们就会冲突。你可以通过在调用函数时使用显式命名空间来解决这个问题,但为什么要一开始就使用 *
呢?按照惯例,许多 Rust 包都有一个名为 prelude
的模块,可以通过 *
导入以方便使用;其他模块应该使用它们的完整名称导入。
另一个重要的是 web_sys::console
,它从 web_sys
中引入了 console
命名空间,而 web_sys
又模仿了 JavaScript 中的 console
命名空间。现在是详细讨论这两个模块功能的好时机。我之前已经说过,但可能需要重复一遍 —— wasm_bindgen
提供了绑定 JavaScript 函数的能力,这样你就可以在 WebAssembly 中调用它们,并且可以将你的 WebAssembly 函数暴露给 JavaScript。又是那种语言,我们通过编写 Rust 来试图避免的语言,但在这里我们无法避免,因为我们是在浏览器中工作的。
实际上,WebAssembly 的一项限制是它不能操作 DOM,这是一个比较委婉的说法,意思是它不能改变网页。它能做的是调用 JavaScript 中的函数,而这些函数再去做实际的工作。此外,JavaScript 对你的 WebAssembly 类型一无所知,所以任何传递给 JavaScript 对象的数据都会被打包到共享内存中,然后由 JavaScript 取出,以便将其转换为它理解的形式。这需要编写大量的重复代码,而 wasm-bindgen
crate 就是为了解决这个问题而存在的。稍后,我们将使用它来将我们自己的自定义绑定绑定到第三方 JavaScript 代码上,但已经内置在浏览器中的所有函数,比如 console.log
呢?这就是 web-sys
发挥作用的地方。它使用 wasm-bindgen
来绑定浏览器环境中的所有函数,这样你就不需要手动指定它们。可以把它想象成一个辅助 crate,它会说:“是的,我知道你需要所有这些函数,所以我为你创建了它们。”
因此,总结一下,wasm-bindgen
给你提供了在 WebAssembly 和 JavaScript 之间通信的能力,而 web-sys
包含了大量预先创建的绑定。如果你特别感兴趣于了解 WebAssembly 和 JavaScript 之间的调用是如何工作的,可以查看 Lin Clark 的这篇文章,它详细解释了这一点,并且配有图片:hacks.mozilla.org/2018/10/calls-between-javascript-and-webassembly-are-finally-fast-%F0%9F%8E%89/
。
小型分配器
在使用语句之后,你会看到一个关于 wee_alloc
功能的注释块,这是一个比默认 Rust 分配器使用更少内存的 WebAssembly 分配器。我们没有使用它,并且在 Cargo.toml
文件中已经禁用了它,所以你可以从源代码和 Cargo.toml
中删除它。
主函数
最后,我们来到了程序的主体部分:
#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
wasm_bindgen(start)
注解导出 main_js
以便它可以被 JavaScript 调用,而 start
参数标识它是程序的起点。如果你好奇,可以查看 pkg/index_bg.wasm.d.ts
来看看它生成了什么。你还会想注意返回值,Result
,其中错误类型可以是 JsValue
,它代表 JavaScript 拥有的对象,而不是 Rust。
在这一点上,你可能开始想知道你将如何跟踪 JavaScript 和 Rust 的区别,我建议你现在不要过于担心这个问题。有很多术语冒了出来,而且你不可能全部记住;只需让它在那里游荡,当它再次出现时,我会再次解释。JsValue
只是 Rust 代码中的一个代表 JavaScript 对象。
最后,让我们看看内容:
console_error_panic_hook::set_once();
// Your code goes here!
console::log_1(&JsValue::from_str("Hello world!"));
Ok(())
第一行设置了 panic 钩子,这意味着任何 panic 都会被重定向到网页浏览器的控制台。你将需要它来进行调试,最好将其放在程序的开始部分。我们这一行,我们的 Hello World,是 console::log_1(&JsValue::from_str("Hello world!"));
。这调用了 JavaScript 的 console.log
函数,但它使用的是 log_1
版本,因为 JavaScript 版本接受可变参数。这是在使用 web-sys
时会反复出现的事情,JavaScript 支持 varargs
而 Rust 不支持。因此,web-sys
模块中创建了多种变体来匹配替代方案。如果你期望的 JavaScript 函数不存在,那么查看 Rust 的 web-sys
文档(bit.ly/2NlRmOI
),看看是否有类似但为处理多个参数而构建的版本。
小贴士
一系列宏,用于解决一些常用函数(如 log
)的问题,但这将是读者的练习。
最后,函数返回 Ok(())
,这是 Rust 程序的典型做法。现在我们已经看到了生成的代码,让我们用我们自己的方式来分析它。
绘制三角形
我们已经花费了很多时间深入研究我们目前拥有的代码,仅仅向控制台输出 "Hello World" 就已经足够多了。为什么我们不玩点有趣的东西,实际上在画布上画点东西呢?
我们将要模仿以下 JavaScript 代码在 Rust 中实现:
canvas = window.document.getElementById("canvas")
context = canvas.getContext("2d")
context.moveTo(300, 0)
context.beginPath()
context.lineTo(0, 600)
context.lineTo(600, 600)
context.lineTo(300, 0)
context.closePath()
context.stroke()
context.fill()
这段代码获取我们在 index.html
中放置的画布元素,获取其 2D 上下文,然后绘制一个黑色三角形。在上下文中绘制形状的一种方法是通过绘制线路径,然后描边,在这个例子中,填充它。你实际上可以在大多数浏览器内置的网页开发者工具中使用浏览器查看这个。这个截图来自 Firefox:
图 1.2 – 一个简单的画布三角形
让我们在 Rust 程序中做同样的事情。你会看到它有点…不同。从顶部快速添加一个 use
语句开始:
use wasm_bindgen::JsCast;
然后,用以下内容替换现有的 main_js
函数:
console_error_panic_hook::set_once();
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let canvas = document
.get_element_by_id("canvas")
.unwrap()
.dyn_into::<web_sys::HtmlCanvasElement>()
.unwrap();
let context = canvas
.get_context("2d")
.unwrap()
.unwrap()
.dyn_into::<web_sys::CanvasRenderingContext2d>()
.unwrap();
context.move_to(300.0, 0.0); // top of triangle
context.begin_path();
context.line_to(0.0, 600.0); // bottom left of triangle
context.line_to(600.0, 600.0); // bottom right of triangle
context.line_to(300.0, 0.0); // back to top of triangle
context.close_path();
context.stroke();
context.fill();
Ok(())
有一些明显的差异,但乍一看,你可能只是觉得 Rust 代码比 JavaScript 代码要“嘈杂”得多,这是真的。你可能会倾向于认为它不够优雅或者不够干净,但我认为这取决于个人观点。JavaScript 是一种动态类型语言,这很明显。它忽略了 undefined
和 null
,如果任何值不存在,它可能会崩溃。它使用鸭子类型来调用上下文中的所有函数,这意味着如果函数存在,它就会简单地调用它;否则,它会抛出异常。
Rust 代码采取了一种非常不同的方法,这种方法更倾向于显式性和安全性,但代价是代码有额外的噪声。在 Rust 中,当你对结构体调用方法时,你必须更加显式,这就是为什么需要类型转换,你必须承认 null
或失败的 Result
类型,这就是为什么有所有的 unwraps。我多年来一直在使用动态语言,包括 JavaScript,我非常喜欢它们。我确实非常喜欢它们,比写 C++ 更好,我发现 C++ 过于冗长,实际上并没有真正提供一些安全优势,但我认为通过一些调整,我们可以使 Rust 代码几乎和 JavaScript 一样优雅,而不会忽略异常和结果。
不谈我的牢骚,如果你还在运行程序,你会注意到一个小的细节——Rust 代码无法编译!这让我想到,当我们从 JavaScript 代码翻译到 Rust 代码时,我们需要覆盖的第一个问题。
web-sys 和功能标志
web-sys
包大量使用功能标志来保持其大小。这意味着每次你想使用一个函数但该函数不存在时,你都需要检查它关联的功能标志,这可以在其文档中找到,并将其添加到 Cargo.toml
文件中。幸运的是,这有很好的文档记录,并且足够容易完成;我们甚至不需要重新启动服务器!
查看我们的错误,我们应该看到以下内容:
error[E0425]: cannot find function 'window' in crate 'web_sys'
--> src/lib.rs:18:27
|
18 | let window = web_sys::window().unwrap();
| ^^^^^^ not found in 'web_sys'
还有几个同类的错误,但这里我们看到的是 window
不在 web_sys
模块中。现在,如果你查看 web-sys
中 window
函数的文档,在 bit.ly/3ak3sAR
,你会看到,是的,它确实存在,但有一个消息:“此 API 需要以下 crate 功能被激活:Window”。
打开 cargo.toml
文件,查找 dependencies.web-sys
。你会看到它有一个 features
条目,其中只包含 ["console"]
;继续添加 "Window"
、"Document"
、"HtmlCanvasElement"
、"CanvasRenderingContext2d"
和 "Element"
到这个列表中。为了明确,你不需要所有这些功能标志只是为了 window
函数;这是我们使用到的所有函数。
你会注意到项目会自动重新构建,并且应该能够成功构建。如果你在浏览器中查看,你会看到你自己的黑色三角形!让我们扩展它,并了解我们是如何做到这一点的。
小贴士
当您期望在 web-sys
上存在的函数不存在时,请去检查文档中的功能标志。
DOM 交互
您会注意到,在获取上下文之后绘制三角形的函数与方法在 JavaScript 中看起来几乎相同——绘制线路径、描边和填充。与 DOM 交互的顶部代码看起来……不同。让我们分析一下这里发生了什么:
- 解包选项
获取 Window
只是在 web-sys
包中的一个函数,您在将 Window
功能添加到 Cargo.toml
时启用了它。然而,您会注意到它的末尾有 unwrap
:
let window = web_sys::window().unwrap();
在 JavaScript 中,window
可以是 null
或 undefined
,至少在理论上是这样,在 Rust 中,这被翻译成 Option<Window>
。您可以看到 unwrap
被应用于 window()
、document()
和 get_element_by_id()
的结果,因为它们都返回 Option<T>
。
dyn_into
dyn_into
究竟是什么意思?嗯,这个奇怪之处解释了 JavaScript 和 Rust 在类型处理方式上的差异。当我们使用 get_element_by_id
获取画布时,它返回 Option<Element>
,而 Element
没有任何与画布相关的函数。在 JavaScript 中,您可以使用动态类型假设元素有 get_context
方法,如果您错了,程序将抛出异常。这与 Rust 的原则相悖;实际上,这是一个开发者方便的地方,可能是另一个开发者潜在错误的隐藏之处,因此为了使用 Element
,我们必须调用 dyn_into
函数将其转换为 HtmlCanvasElement
。这个方法是通过 use wasm_bindgen::JsCast
声明引入作用域的。
重要提示
注意,HtmlCanvasElement
、Document
和 Element
都是在 web-sys
中需要添加的功能标志。
- 两次解包?
在调用 get_context("2d")
之后,我们实际上调用了两次 unwrap
;这不是一个打字错误。实际情况是 get_context
返回一个 Result<Option<Object>>
,所以我们两次解包它。这是一个游戏在失败时无法恢复的情况,所以 unwrap
是可以接受的,但如果您用 expect
替换它们,以便可以提供更清晰的错误信息,我并不会抱怨。
一个 Sierpiński 三角形
现在让我们来点真正的乐趣,绘制一个几级深的 Sierpiński 三角形。如果你愿意接受挑战,你可以在跟随这里提供的解决方案之前尝试自己编写代码。算法的工作方式是先绘制第一个三角形(你正在绘制的那个),然后绘制另外三个三角形,其中第一个三角形具有相同的顶点,但它的其他两个点位于原始三角形的每一边的中点。然后,在左下角绘制第二个三角形,其顶点位于左侧的中点,其右下角位于原始三角形底部的中点,其左下角位于原始三角形的左下角。最后,在原始三角形的右下角创建第三个三角形。这会在中间留下一个倒三角形形状的“洞”。这比解释起来更容易可视化,所以让我们看看一张图片?
图 1.3 – 一级 Sierpiński 三角形
每个编号的三角形都是已经绘制的。倒置的蓝色三角形是原始三角形留下的部分,因为我们没有覆盖它。
所以这是一个被细分成四个的三角形。现在,算法是递归的,它将每个三角形再次细分。所以,二级深,它看起来像这样:
![图 1.4 – 二级 Sierpiński 三角形
图 1.4 – 二级 Sierpiński 三角形
注意,它不会细分中心倒置的三角形,只会细分你创建的三个紫色三角形。实际上,所有点朝下的三角形只是“快乐的意外”,让形状看起来很酷。现在,你已经有足够的知识来绘制自己的 Sierpiński 三角形了,有一个例外——你应该在上下文中移除 fill
语句。否则,所有三角形都会被填充成黑色,你将无法看到它们。试试看吧。
绘制 Sierpiński 三角形
那么,你尝试了吗?不,我也不会;我想我们有很多共同之处。要开始创建 Sierpiński 三角形,让我们用三角形函数替换硬编码的三角形。这是 draw_triangle
的第一次尝试:
fn draw_triangle(context: &web_sys::CanvasRenderingContext2d, points: [(f64, f64); 3]) {
let [top, left, right] = points;
context.move_to(top.0, top.1);
context.begin_path();
context.line_to(left.0, left.1);
context.line_to(right.0, right.1);
context.line_to(top.0, top.1);
context.close_path();
context.stroke();
}
与我们最初开始的硬编码版本相比,有一些小的变化。函数接受对上下文的引用和一个包含三个点的列表。点本身由元组表示。我们还去掉了 fill
函数,所以我们只有一个空三角形。将内联的 draw_triangle
替换为函数调用,它应该看起来像这样:
let context = canvas
.get_context("2d")
.unwrap()
.unwrap()
.dyn_into::<web_sys::CanvasRenderingContext2d>()
.unwrap();
draw_triangle(&context, [(300.0, 0.0), (0.0, 600.0), (600.0, 600.0)]);
现在你正在绘制一个空三角形,你就可以开始绘制递归三角形了。与其从递归开始,不如先绘制三个更多的三角形。第一个将具有相同的顶点和两个侧面点:
draw_triangle(&context, [(300.0, 0.0), (150.00, 300.0), (450.0, 300.0)]);
注意,第三个元组在300.0
和600.0
之间有一个中点,而不是在0
和600.0
之间,因为三角形的顶点位于其他两个点的中点之间。还要注意,随着向下移动,y 坐标会变大,这与许多 3D 系统相反。现在,让我们添加左下角和右下角的三角形:
draw_triangle(&context, [(150.0, 300.0), (0.0, 600.0), (300.0, 600.0)]);
draw_triangle(&context, [(450.0, 300.0), (300.0, 600.0), (600.0, 600.0)]);
你的三角形应该看起来像这样:
图 1.5 – 你的三角形
到目前为止,你应该开始看到一种模式,我们可以开始将硬编码的三角形转换成算法。我们将创建一个名为sierpinski
的函数,它接受上下文、三角形尺寸和深度函数,这样我们就可以只绘制我们想要的三角形数量,而不是无限绘制并导致浏览器崩溃。然后,我们将那些调用该函数的函数移动到该函数中:
fn sierpinski(context: &web_sys::CanvasRenderingContext2d, points: [(f64, f64); 3], depth: u8) {
draw_triangle(&context, [(300.0, 0.0), (0.0, 600.0),
(600.0, 600.0)]);
draw_triangle(&context, [(300.0, 0.0), (150.00, 300.0),
(450.0, 300.0)]);
draw_triangle(&context, [(150.0, 300.0), (0.0, 600.0),
(300.0, 600.0)]);
draw_triangle(&context, [(450.0, 300.0), (300.0,
600.0), (600.0, 600.0)]);
}
此函数目前忽略除上下文之外的所有内容,但你可以将main_js
中的那四个draw_triangle
调用替换为对sierpinski
的调用:
sierpinski(&context, [(300.0, 0.0), (0.0, 600.0), (600.0, 600.0)], 2);
现在非常重要,你只发送深度为2
,这样图像在进展过程中将继续保持相同。将这个调用视为一个原型单元测试,确保我们在重构过程中行为没有改变。现在,在sierpinski
中,取第一个三角形并让它使用传入的点:
fn sierpinski(context: &web_sys::CanvasRenderingContext2d, points: [(f64, f64); 3], depth: u8) {
draw_triangle(&context, points);
...
然后,在绘制三角形之后,减少深度值一个单位,并查看它是否仍然大于0
。然后,绘制剩余的三角形:
...
let depth = depth - 1;
if depth > 0 {
draw_triangle(&context, [(300.0, 0.0), (150.00, 300.0),
(450.0, 300.0)]);
draw_triangle(&context, [(150.0, 300.0), (0.0, 600.0),
(300.0, 600.0)]);
draw_triangle(&context, [(450.0, 300.0), (300.0,
600.0), (600.0, 600.0)]);
}
现在,为了完成递归,你可以将所有的draw_triangle
调用替换为对sierpinski
的调用:
if depth > 0 {
sierpinski(
&context,
[(300.0, 0.0), (150.00, 300.0), (450.0, 300.0)],
depth,
);
sierpinski(
&context,
[(150.0, 300.0), (0.0, 600.0), (300.0, 600.0)],
depth,
);
sierpinski(
&context,
[(450.0, 300.0), (300.0, 600.0), (600.0, 600.0)],
depth,
);
}
到目前为止一切顺利——你应该仍然看到一个被细分成四个三角形的三角形。最后,我们可以实际上计算原始三角形上每条线的中点,并使用这些点来创建递归三角形,而不是硬编码它们:
let [top, left, right] = points;
if depth > 0 {
let left_middle = ((top.0 + left.0) / 2.0, (top.1 +
left.1) / 2.0);
let right_middle = ((top.0 + right.0) / 2.0, (top.1 +
right.1) / 2.0);
let bottom_middle = (top.0, right.1);
sierpinski(&context, [top, left_middle, right_middle],
depth);
sierpinski(&context, [left_middle, left,
bottom_middle], depth);
sierpinski(&context, [right_middle, bottom_middle,
right], depth);
}
计算线段中点的方法是取每端的x和y坐标,将它们相加,然后除以二。虽然前面的代码可以工作,但让我们通过编写一个新函数来使其更清晰,如下所示:
fn midpoint(point_1: (f64, f64), point_2: (f64, f64)) -> (f64, f64) {
((point_1.0 + point_2.0) / 2.0, (point_1.1 + point_2.1)
/ 2.0)
}
现在,我们可以在前面的函数中使用它,以增加清晰度:
if depth > 0 {
let left_middle = midpoint(top, left);
let right_middle = midpoint(top, right);
let bottom_middle = midpoint(left, right);
sierpinski(&context, [top, left_middle, right_middle],
depth);
sierpinski(&context, [left_middle, left,
bottom_middle], depth);
sierpinski(&context, [right_middle, bottom_middle,
right], depth);
}
如果你一直在跟随,你应该确保你仍然显示一个有四个内部三角形的三角形,以确保你没有犯任何错误。现在,进行重大揭秘——将原始Sierpinski
调用中的深度更改为5
:
sierpinski(&context, [(300.0, 0.0), (0.0, 600.0), (600.0, 600.0)], 5);
你应该看到一个像这样的三角形递归绘制:
![图 1.6 – 三角形的递归绘制
图 1.6 – 三角形的递归绘制
看起来不错!但那些我们在原始图中看到的颜色呢?它们使它变得更加有趣。
当库不兼容时
此图像的早期示例在每个递归层中用不同的随机颜色填充了三角形。因此,第一个三角形是一种颜色,第三个和第四个是另一种颜色,接下来的九个又是另一种颜色,以此类推。这使得图像更加有趣,并且提供了一个很好的例子,说明当库不完全兼容 WebAssembly 时应该怎么做。
要创建一个随机颜色,我们需要一个随机数生成器,而这不是标准库的一部分,而是在一个 crate 中找到的。你可以通过更改Cargo.toml
文件将其作为依赖项添加:
console_error_panic_hook = "0.1.7"
rand = "0.8.4"
当你这样做时,你会得到一个看起来像以下内容的编译器错误(尽管你的消息可能略有不同):
error: target is not supported, for more information see: https://docs.rs/getrandom/#unsupported-targets
--> /usr/local/cargo/registry/src/github.com-
1ecc6299db9ec823/getrandom-0.2.2/src/lib.rs:213:9
|
213 | / compile_error!("target is not supported, for more information see: \
214 | | https://docs.rs/getrandom/#unsupported-targets");
这是一个递归依赖项,在这种情况下是getrandom
,在 WebAssembly 目标上无法编译的情况。在这种情况下,这是一个非常有帮助的错误信息,如果你点击链接,你将在文档中找到解决方案。具体来说,你需要为getrandom
启用js
功能标志。回到你的Cargo.toml
文件,并添加以下内容:
getrandom = { version = "0.2.3", features = ["js"] }
这添加了带有js
功能的getrandom
依赖项,你的代码将开始重新编译。从这个例子中我们可以吸取的教训是,并非每个 Rust crate 都能在 WebAssembly 目标上编译,当这种情况发生时,你需要检查文档。
小贴士
当一个 crate 无法缓慢编译时,阅读错误信息并遵循指示。当你感到沮丧时,很容易忽略构建中断的原因。
随机颜色
现在我们已经让随机 crate 与我们的项目一起构建,让我们在绘制三角形时将其颜色改为随机颜色。为此,我们将在绘制三角形之前设置fillStyle
的颜色,并添加一个fill
命令。这通常是Context2D
API 的工作方式。你设置上下文的状态,然后使用该状态执行命令。这需要一点时间来适应,但你会习惯的。让我们将color
作为三个u8
元组的参数添加到draw_triangle
中:
fn draw_triangle(
context: &web_sys::CanvasRenderingContext2d,
points: [(f64, f64); 3],
color: (u8, u8, u8),
) {
重要提示
颜色在这里表示为三个组件,红色、绿色和蓝色,每个值可以从0
到255
。我们在这章中使用元组是因为我们可以快速取得进展,但如果这开始让你感到烦恼,你可以创建合适的struct
s。
现在,由于draw_triangle
需要一个颜色,我们的应用程序无法编译。让我们转到sierpinski
函数,并给它传递一个颜色。我们将把颜色发送到sierpinski
函数,而不是在那里生成它,这样我们就可以在每一层得到一个颜色。第一代将是一种纯色,然后第二代将都是一种颜色,然后第三代是第三种颜色,以此类推。所以让我们添加这个:
fn sierpinski(
context: &web_sys::CanvasRenderingContext2d,
points: [(f64, f64); 3],
color: (u8, u8, u8),
depth: u8,
) {
draw_triangle(&context, points, color);
let depth = depth - 1;
let [top, left, right] = points;
if depth > 0 {
let left_middle = midpoint(top, left);
let right_middle = midpoint(top, right);
let bottom_middle = midpoint(left, right);
sierpinski(&context, [top, left_middle,
right_middle], color, depth);
sierpinski(&context, [left_middle, left,
bottom_middle], color, depth);
sierpinski(&context, [right_middle, bottom_middle,
right], color, depth);
}
}
我将color
作为第三个参数而不是第四个,因为我认为这样看起来更好。请记住将颜色传递给其他调用。最后,为了我们可以编译,我们将向初始的sierpinski
调用发送一个颜色:
sierpinski(
&context,
[(300.0, 0.0), (0.0, 600.0), (600.0, 600.0)],
(0, 255, 0),
5,
);
由于这是一个 RGB 颜色,(0, 255, 0)
代表绿色。现在,我们已经使代码编译成功,但它没有任何作用,所以让我们从原始调用向下工作,再次进入sierpinski
函数。我们不仅传递颜色,还要创建一个新的元组,其中每个组件都有一个随机数。你需要在顶部的使用声明中添加use rand::prelude::*;
。然后,在sierpinski
函数中,在if depth > 0
检查之后,添加以下代码:
let mut rng = thread_rng();
let next_color = (
rng.gen_range(0..255),
rng.gen_range(0..255),
rng.gen_range(0..255),
);
...
sierpinski(
&context,
top, left_middle, right_middle],
next_color,
depth,
);
sierpinski(
&context,
[left_middle, left, bottom_middle],
next_color,
depth,
);
sierpinski(
&context,
[right_middle, bottom_middle, right],
next_color,
depth,
);
在深度检查内部,我们随机生成next_color
,然后将其传递给所有的递归sierpinski
调用。但当然,我们的输出仍然没有变化。我们从未更改draw_triangle
来改变颜色!这会有一点奇怪,因为context.fillStyle
属性在 JavaScript 中接受DOMString
,所以我们需要进行转换。在draw_triangle
的顶部添加两行:
let color_str = format!("rgb({}, {}, {})", color.0, color.1, color.2);
context.set_fill_style(&wasm_bindgen::JsValue::from_str(&color_str));
在第一行,我们将三个无符号整数的元组转换为字符串"rgb(255, 0, 255)"
,这是fillStyle
属性所期望的。在第二行,我们使用set_fill_style
来设置它,进行那种奇特的转换。关于这个函数,你需要理解两件事。第一是,通常,JavaScript 属性是公开的,你可以设置它们,但web-sys
生成getter
和setter
函数。第二是,这些生成的函数通常接受JsValue
对象,它们代表 JavaScript 拥有的对象。幸运的是,wasm_bindgen
为这些提供了工厂函数,所以我们可以轻松地创建它们并使用编译器作为我们的指南。
小贴士
无论何时从 JavaScript 代码转换为 Rust,请确保检查相应函数的文档,以查看所需的类型。将字符串传递给 JavaScript 并不像你想象的那么简单。
最后,我们实际上需要填充三角形才能看到那些颜色,所以在context.stroke()
之后,你需要恢复你之前删除的context.fill()
方法,然后就是大功告成了!
图 1.7 – 填充的三角形
你已经完成了,你现在可以开始创建真正的游戏了。
摘要
在本章中,我们做了很多。我们使用 Rust 编写了第一个 WebAssembly 应用程序,从"Hello World"过渡到使用 HTML Canvas 在浏览器中绘图。你添加了 crate,运行了开发服务器,并与 DOM 进行了交互。你学到了很多关于与浏览器交互的知识,包括以下内容:
-
使用
#[wasm_bindgen(start)]
创建主入口点 -
将 JavaScript 代码转换为 Rust 代码
-
处理编译为 JavaScript 的 crate
你也已经接触到了 HTML Canvas。坦白说,这有点像一场旋风,所以如果你觉得有些信息没有跟上,请不要担心,因为我们会再次涵盖这些主题——包括在下一章,我们将开始绘制精灵。
第二章:第二章:绘制精灵
现在我们已经有一个运行中的应用,并且开始在屏幕上绘制,我们可以开始制作真正看起来像游戏的东西。这意味着渲染精灵,这只是一个说法,意思是绘制图片。所以,在本章中,我们将通过进行一点游戏设计来定义这些图片是什么,然后我们将渲染一个静态精灵到屏幕上。由于静态图片是一个非常无聊的游戏,我们甚至会让精灵动起来。
在本章中,我们将做以下事情:
-
设计我们的游戏,遛狗。
-
将精灵渲染到画布上。
-
使用精灵表单一次性加载多个精灵。
-
通过精灵表单动画化一个角色。
到本章结束时,你将绘制角色而不是静态三角形,甚至它们会在屏幕上奔跑。
技术要求
除了第一章的技术要求Hello WebAssembly之外,你还需要下载位于github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/wiki/Assets
的资产。我们还会基于该章节的结果进行构建,所以不要丢弃代码。如果你因为无法被社会规则驯服而按顺序阅读这本书,那么你可以在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_1
找到上一章的源代码并从这里开始。如果你遇到难题,你可以在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_2
找到本章的完整源代码。
查看以下视频以查看代码的实际运行情况:bit.ly/3wOpCqy
快速游戏设计会议
在上一章中,我让你创建了一个名为“遛狗”的项目,你如此沉浸于创建 Rust 项目和我令人兴奋的散文中,以至于你甚至没有问为什么项目会叫这个名字。现在我们将深入研究这本书中我们要制作的这款游戏——遛狗。
遛狗是一个概念简单的无尽跑酷游戏。你扮演一个男孩,带着他的狗穿过森林,当你的狗被跑过的猫吓到并开始追逐它时,你开始追逐你的狗穿过森林,途中躲避障碍,直到你撞到其中一个并跌倒。这时,当然,狗会转身来看看你。
未经你猜测,这个游戏的灵感是在我遛狗时在冰上走时产生的。我使用了Miro(miro.com
)来制作原型,只是为了感受游戏将看起来是什么样子:
图 2.1 – 假设的遛狗屏幕
在你得到我是个伟大艺术家的想法之前,我使用的所有资源都是通过创意共享许可在网络上免费提供的。你可能注意到背景相对于角色来说有点模糊,这是因为我在将角色复制粘贴到 Miro 并拖动角落时几乎没有做任何努力来调整角色的比例。当我们将实际对象放入我们的游戏中时,我们需要做出比这更好的努力。
在这个阶段,诱惑就是可以说“我们完成了”然后开始编码。鉴于我们的游戏规模较小,我认为我们不需要完整的处理就可以开始编码,但我确实想确保我们澄清关于游戏的一些事情。
得分是通过测量我们的小红帽男孩(简称 RHB)跑了多远来计算的——和大多数无尽跑者游戏一样,如Canabalt(canabalt.com/
)或在没有互联网连接时启动 Google Chrome 时出现的恐龙霸王龙游戏。狗和猫可以轻松地导航所有障碍,只是给玩家提供如何捕捉狗的想法,也许通过走玩家无法跟随的路线来误导玩家。障碍将包括你可以撞到的岩石和箱子,以及你可以掉进去的水。RHB 有一个滑动动画,所以有时他需要从小悬崖下滑行,狗可以轻松地从下面跑过。这不足以成为一个完整的游戏,但足以给我们一个未来章节的功能清单。让我们告别我们可爱的小三角形,开始渲染我们可爱的小红帽男孩。
渲染 sprite
Sprite 是一个如此常见的术语,以至于在谈话中可以使用它而不必真正知道它的含义,但正确地定义它意味着正确地定义位图,而这又意味着正确地定义像素图。你知道术语 sprite 是在 20 世纪 70 年代由 Danny Hillis(bit.ly/3aZlJ72
)提出的吗?这太令人疲惫了。
虽然我觉得所有这些都很有趣,但你不是为此而买这本书的,所以为了我们的目的,sprite 是从文件中加载的 2D 图像。红帽男孩、他的狗和猫以及背景都将被定义为 sprite。让我们不要在定义上浪费时间,开始绘制一个。
加载图像
我们首先将资源解压缩,并将Idle (1).png
文件从resized/rhb
复制到项目中static
目录下。这样就可以从程序中访问它。随着我们构建程序,我们需要进一步的整理,但对于一个文件来说,这样是可以的。接下来,我们需要修改我们的代码。你可以暂时保留 Sierpiński 三角形,因为它和精灵放在一起看起来很可爱,但首先要做的是使用HTMLImage
元素来加载一张图片。目前,重要的是在调用 Sierpiński 三角形之前加载并绘制图片。它看起来是这样的:
#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
....
let image = web_sys::HtmlImageElement::new().unwrap();
image.set_src("Idle (1).png");
sierpinski(
&context,
[(300.0, 0.0), (0.0, 600.0), (600.0, 600.0)],
(0, 255, 0),
5,
);
Ok(())
}
你将再次遇到^^^^^^^^^^^^^^^ could not find
HtmlImageElementin
web_sys``错误。记住,web-sys
包大量使用了功能标志,所以你需要将HtmlImageElement
添加到Cargo.toml
中的功能标志列表中。在你添加之后,重建将需要更长的时间,但应用程序将再次构建。现在你已经加载了图像,我们可以绘制它。
画布坐标
在我们绘制之前,我们需要讨论一下line_to
和move_to
命令,这些命令可能当时没有意义,这就是为什么我们需要讨论坐标系的原因:
图 2.2 – 来源:Mozilla (http://mzl.la/30NLhxX)
我们的画布被划分为一个 600 x 600 的 2D 网格。为什么是 600 x 600?因为这是我们创建在 HTML 页面上的画布元素的宽度和高度,我们在第一章,Hello WebAssembly中创建了它。这个大小完全是任意的,随着我们的游戏发展,我们可能会改变它。网格的单位是像素,所以当我们将原始三角形的顶部移动到(300.0, 0.0)
时,我们将其向右移动了 300 像素(因为(0.0, 0.0)
位于画布的左上角。
绘制图像
在这个阶段绘制一个图像看起来并不复杂 – 我们将使用 JavaScript 中的drawImage
命令;只是我们会使用针对HtmlElement
的web-sys
版本。
小贴士
记住,JavaScript 函数经常使用函数重载,而 Rust 不支持,所以一个 JavaScript 函数在 Rust 中可能有多个相应的变体。
因此,让我们在加载图像的代码之后立即添加绘制命令,然后我们就完成了:
image.set_src("Idle (1).png");
context.draw_image_with_html_image_element(&image, 0.0, 0.0);
...
我们忽略了draw_image_with_html_image_element
命令中的Result
,但应该会绘制图像,但是,它…没有。结果是,你不能在设置图像元素的源之后立即绘制图像,因为图像还没有被加载。为了等待图像加载,我们将使用HtmlImageElement
的onload
回调,你可以在 Rust 中使用set_onload
来设置它。为了做到这一点,你需要了解一些在 WebAssembly 环境中使用 JavaScript 回调的知识。
JavaScript 回调
当你通过 Rust 中的set_onload
函数设置onload
回调时,你是在通过 WebAssembly 调用 JavaScript,通过web-sys
为你生成的函数。不幸的是,将以下 JavaScript 代码翻译成 Rust 变得复杂,因为 JavaScript 是垃圾回收的,而 Rust 使用手动内存管理,拥有著名的借用检查器。以下是一个例子:
image.onload = () => { alert("loaded"); }
这意味着要实际传递一个函数到 JavaScript,就像我们在这里想要做的那样,你必须使用一个复杂的签名,并且仔细考虑 Rust 的借用规则。这是一种在你正确理解之后才变得有意义的代码,但可能很难编写。让我们来分析一下我们需要做什么。
回到我们的源代码中,在创建HtmlImageElement
之后,我们可以尝试以直观的方式添加一个onload
回调:
let image = web_sys::HtmlImageElement::new().unwrap();
image.set_onload(|| {
web_sys::console::log_1(&JsValue::from_str("loaded"));
});
image.set_src("Idle (1).png");
...
直观可能是一种夸张,但这与我们迄今为止知道如何编写的代码相符。不幸的是,这不起作用,因为你会在编译器错误中看到类型不匹配的问题,如下所示:
error[E0308]: mismatched types
--> src/lib.rs:43:22
|
| image.set_onload(|| {
| ______________________^
| | web_sys::console.log_1("loaded");
| | });
| |_____^ expected enum `Option`, found closure
|
= note: expected enum `Option<&js_sys::Function>`
found closure `[closure@src/lib.rs:43:22: 45:6]`
正如错误信息所说,set_onload
不接收 Rust 闭包,而是接收Option<&js_sys::Function>
。不幸的是,错误信息没有告诉你如何修复它,而且不清楚如何创建js_sys::Function
对象。你可以做的是首先创建一个Closure
对象,首字母大写"C",然后尝试将其传递给set_onload
:
let image = web_sys::HtmlImageElement::new().unwrap();
let callback = Closure::once(|| {
web_sys::console::log_1(&JsValue::from_str("loaded"));
});
image.set_onload(callback);
Closure
是wasm-bindgen
结构体,用于将 Rust 闭包传递到 JavaScript。在这里,我们使用Closure
上的once
函数,因为我们知道onload
处理程序只被调用一次。然而,我们仍然不能直接将其发送到 JavaScript;通过image.set_onload(callback)
尝试这样做会导致以下错误:
error[E0308]: mismatched types
--> src/lib.rs:47:22
|
47 | image.set_onload(callback);
| ^^^^^^^^ expected enum `Option`,
found struct `wasm_bindgen::prelude::Closure`
记住set_onload
需要Option<&js_sys::Function>
,而到目前为止,我们只创建了Closure
。幸运的是,Closure
结构体提供了一种进行转换的方法,如下所示:
image.set_onload(Some(callback.as_ref().unchecked_ref()));
首先,我们在回调函数上调用as_ref
,它返回一个原始的JsValue
,然后我们调用unchecked_ref
,将其转换为&Function
对象。我们将它传递给Some
,因为在 JavaScript 中onload
可以是null
。太棒了!它编译成功了!现在的绘制代码如下:
let image = web_sys::HtmlImageElement::new().unwrap();
let callback = Closure::once(|| {
web_sys::console::log_1(&JsValue::from_str("loaded"));
});
image.set_onload(Some(callback.as_ref().unchecked_ref()));
image.set_src("Idle (1).png");
context.draw_image_with_html_image_element(&image, 0.0, 0.0);
...
如果你运行应用程序,它仍然没有显示我们的图片,但在浏览器控制台中记录了一个错误:
Uncaught Error: closure invoked recursively or destroyed already
闭包何时被销毁?这一切都在main
函数中,因此闭包在函数完成几行后销毁,此时callback
变量不再在作用域内。为了看到我们的日志消息,我们可以在设置on_load
函数之后在代码中添加一个额外的调用:
image.set_onload(Some(callback.as_ref().unchecked_ref()));
callback.forget();
在回调函数上调用forget()
将内存管理从 Rust 转移到 JavaScript,从而有效地创建了一个故意的内存泄漏。这不是我们经常想要做的事情,而且它在这里严格是为了帮助我们克服最新的错误,通过防止闭包被销毁。如果你编译这段代码并检查浏览器控制台,你会看到现在有“loaded”的消息。这很好,但是它仍然没有绘制我们的图片,因为我们实际上还没有等待图片加载完成。为此,我们需要一个异步函数。
提示
将 Rust 闭包转换为 JavaScript 闭包是那些 JavaScript 和 Rust 之间的抽象到处泄漏的案例之一,这可能会让人感到“不小心在孩子面前发誓”般的沮丧。所以,当你做错或感到困惑时,不要感到难过;这仅仅意味着你是一个凡人。
重要提示
在这本书中,我们将有更多发送闭包到 JavaScript 的示例,但你可能会发现自己想要交叉引用官方文档bit.ly/3kSyOSI
和bit.ly/3sXt1OW
。
异步 Rust
Rust 添加了 async
,让运行时知道可以将函数设置为异步执行。在该函数内部,你可以使用 await
调用来暂停当前线程/进程的执行,并允许应用程序的其他部分继续运行,直到等待的函数可以恢复。关键点是,虽然 await
暂停了当前执行上下文的执行,但它允许其他代码继续执行。这使得它非常适合那些永远不会停止执行游戏循环的游戏。与基于回调的代码相比,它的工作方式也更为简洁,因此我们将在这里(结合通道)使用它,以确保我们不会在图像加载完成之前尝试绘制图像。
如果你熟悉在传统 Rust 中使用 async
/.await
,那么你知道这些函数需要在运行时中执行,通常使用 tokio
或 async-std
等 crate。该运行时负责传递控制权并恢复它。以下是从 async-std
库仓库中的简单示例:
use async_std::task;
fn main() -> Result<(), surf::Error> {
task::block_on(async {
let url = "https://www.rust-lang.org";
let mut response = surf::get(url).send().await?;
let body = response.body_string().await?;
dbg!(url);
dbg!(response.status());
dbg!(response.version());
dbg!(response.header_names());
dbg!(response.header_values());
dbg!(body.len());
Ok(())
})
}
在这里,async
块被包裹在一个名为 task::block_on
的函数中,该函数在每个 await
调用中处理停止该块的执行,然后在 await
“醒来”进行未来处理时恢复执行。所有这些都需要生成线程或检查事件循环,这些代码你不需要编写,因为你从 async-std
中获取它们。
如果你熟悉其他原生支持 async/.await 语法的高级语言,例如 JavaScript,你可能会想知道为什么这个额外步骤是必要的。答案是,与 JavaScript 不同,Rust 语言中不存在 async
和 await
关键字,但如果没有额外的 crate,它们将无法工作,但这是我们为了获得额外功能所付出的代价。
这是坏消息,但好消息是——在 WebAssembly 中,我们不需要任何额外的运行时!我们的代码在浏览器中运行,因此可以使用浏览器的运行时;我们只需要使用一个 crate 在本地事件循环中生成 futures,而这个 crate 已经存在——wasm_bindgen_futures
。
生成 future
自然地,我们谈论的是“未来”,但作为一个期货的使用者,你通常不会直接创建Future
类型。你声明一个函数或闭包为async
,当调用一个async
函数时,其返回值将被包裹在Future
中。然后,调用者可以通过调用await
来等待那个Future
实例完成。这种方法的优点是,尽管在调用await
时程序实际上并没有停止,但从代码作者的视角来看,它看起来像是停止了。这使得代码看起来更加线性。实际上,程序的执行会继续;否则,它将变得无响应,但运行时会处理在Future
完成时从上次停止的地方恢复程序。
如果你忘记了,我们正在尝试将精灵绘制到画布上,为此,我们必须首先等待图像加载。为此,我们最终将使用期货,但我们需要先构建一些基础设施。我们将首先向HtmlImageElement
添加一个onload
回调,当图像加载时,它会调用一个oneshot
通道。一个oneshot
通道是一个接收者实现了Future
特质的通道,因此我们可以调用await
来等待它接收消息。如果我们设置onload
回调向那个通道发送消息,然后我们可以在接收者上调用await
,这样执行就会阻塞,直到图像加载完成。然后,我们实际上可以绘制图像,因为我们知道它已经加载了。为了使所有这些工作,我们需要将所有内容包裹在一个async
块中并启动返回的期货。这是await
语法的限制;它只能在async
函数或块内部工作。自然地,我们将从…Cargo.toml
文件开始实现。
我想从.TOML
文件开始并不自然,但我们需要将期货依赖项拉入我们的 WebAssembly 项目中。它们已经在测试中存在,所以我们将futures
和wasm-bindgen-futures
从dev-dependencies
移动到标准的dependencies
块中。你可以将它们放在getrandom
下面,如下面的代码所示:
getrandom = { version = "0.2.3", features = ["js"] }
futures = "0.3.17"
wasm-bindgen-futures = "0.4.28"
现在我们有了对 Rust 期货的访问权限,我们可以使用wasm_bindgen_futures::spawn_local
来启动一个本地期货并将所有用于绘制图像的代码放入其中。回到我们之前编写的用于加载HtmlImageElement
的代码,我们希望将所有这些代码包裹在一个对spawn_local
的调用中,如下面的代码所示:
wasm_bindgen_futures::spawn_local(async move {
let image = web_sys::HtmlImageElement::new().unwrap();
let callback = Closure::once(move || {
web_sys::console::log_1(&JsValue::from_str("loaded"));
});
image.set_onload(Some(callback.as_ref().unchecked_ref()));
callback.forget();
image.set_src("Idle (1).png");
context.draw_image_with_html_image_element
(&image, 0.0, 0.0);
sierpinski(
...
});
当你调用spawn_local
时,你需要将其作为一个标记为async,
的块传递,因为spawn_local
需要Future
。我们已经将这个块标记为move
,以便给块赋予我们在此块中引用的任何绑定所有权。稍后,我们还需要确保我们正确处理这个闭包的生命周期,它必须是'static
,但就目前而言,我们不必担心这一点,因为一切都在闭包中。这张图片仍然不会绘制,因为当Future
运行到完成时,它会生成,但我们的程序会退出。我们需要等待图像加载,为此,我们将使用oneshot
通道。
oneshot
通道的工作方式就像它的名字一样;你可以调用一次,此时它被消耗,不能再调用。这意味着当你将oneshot
通道移动到 Rust 闭包中时,闭包立即变为FnOnce
。实际上,如果你尝试将oneshot
移动到FnMut
或Fn
,你会得到编译器错误,副作用是当你试图找出问题所在时,你的头发会掉落。所以,不要这样做——这很痛苦。
相反,让我们在spawn_local
块内部创建通道,然后通过向通道发送消息来替换回调中的web_sys::console::log_1
调用。更改如下所示:
let (success_tx, success_rx) = futures::channel::oneshot::channel::<()>();
let image = web_sys::HtmlImageElement::new().unwrap();
let callback = Closure::once(move || {
success_tx.send(());
});
...
在第一行,我们创建了unit
类型的oneshot
通道,并将其发射器移动到回调中。我们移除了日志消息,并用对发射器上的send
的调用替换了它。现在,在我们尝试绘制图像之前,我们需要等待该消息被发送。让我们修改闭包下面的代码:
image.set_onload(Some(callback.as_ref().unchecked_ref()));
image.set_src("Idle (1).png");
success_rx.await;
context.draw_image_with_html_image_element(&image, 0.0, 0.0);
首先,我们移除了forget
调用,因为它不再必要,因为我们将在尝试绘制图像之前等待onload
函数被调用。这使得当作用域完成时删除闭包变得可以接受。然后,我们调用success_rx.await
以阻塞,直到加载完成。最后,我们将像之前一样绘制图像,它就会出现!
图 2.3 – 我是红帽男孩,三角形的国王
重要提示
在这里我们忽略了大量的结果,这是一个坏习惯。在下一章中,我们将开始构建我们的游戏,以便更好地分离关注点,并且在这个过程中,我们将移除它,转而使用显式的错误处理或调用expect
,如果我们真正想要停止执行。
你可能会想知道为什么我们在这里使用spawn_local
,而不是简单地使用标准的 Rust 通道并在其上调用recv
,原因在于recv
调用会阻塞主线程的执行,这在浏览器中是绝对不允许的。基于浏览器的代码必须允许浏览器继续其事件循环,而暂停它会导致浏览器本身变得无响应。你可以使用try_rcv
调用,因为它不会阻塞,但你必须在一个循环中检查它,以确保等待图像加载完成。这也会暂停浏览器,并可能引起那些令人烦恼的“浏览器无响应”错误。由于浏览器和视频游戏意外地都不能使应用程序变得无响应,我们将使用spawn_local
块和async
/await
语法。记住,虽然await
上下文会暂停本地执行,但程序本身实际上仍在运行,只是为了不断地轮询并查看Future
是否完成。
恭喜!在我承诺你会在一千个单词之后在屏幕上绘制图像之后,你做到了,但还有一件事我们必须关注。让我们对代码做一些小的修改:
image.set_onload(Some(callback.as_ref().unchecked_ref()));
image.set_src("rhg.png");
success_rx.await;
现在,如果你运行应用程序,屏幕上什么也没有绘制,包括三角形!这是因为我们等待一个成功的加载,而这个加载永远不会到来。我们需要处理错误情况,以便在加载失败的情况下继续,而不是挂起,即使我们只想停止错误。我们想要做的是在图像加载完成时发送一条消息(一个unit
)或向接收者发送另一条消息(错误),无论哪种方式。
你可能会认为当加载失败时,你可以将success_tx
改为接受unit
或错误代码。我们可以使用JsValue
作为错误,因为浏览器中的任何错误代码都将是这个类型。
重要提示
JsValue
是一种表示直接来自 JavaScript 的任何值的类型。在 Rust 代码中,我们将经常将这些类型转换为更具体的 Rust 类型。
那段代码看起来是这样的:
let (success_tx, success_rx) = futures::channel::oneshot::channel::<Result<(), JsValue>>();
let image = web_sys::HtmlImageElement::new().unwrap();
let callback = Closure::once(move || {
success_tx.send(Ok(()));
});
let error_callback = Closure::once(move |err| {
success_tx.send(Err(err));
});
image.set_onload(Some(callback.as_ref().unchecked_ref()));
image.set_onerror(Some(error_callback.as_ref().unchecked_ref()));
这将立即变成一个编译器错误:
70 | let error_callback = Closure::once(move |err| {
|
^^^^^^^^^^ value used here after move
71 | success_tx.send(Err(err));
success_tx
不能同时移动到两个闭包中。我们需要使用 Rust 构造来在线程之间共享通道,以便我们可以在两个回调中使用它。
重要提示
我们也可以在这里使用两个oneshot
通道和一个select
语句,但在编写本文时,这并没有在 WebAssembly 中很好地工作。
我们将创建通道,然后设置成功和错误发送者的引用计数版本。这意味着这两个发送者将发送到同一个接收者。这两个都需要被包裹在Mutex
中,就像这里所示,替换原始的oneshot
通道创建:
let (success_tx, success_rx) = futures::channel::oneshot::channel::<Result<(), JsValue>>();
let success_tx = Rc::new(Mutex::new(success_tx));
let error_tx = Rc::clone(&success_tx);
注意,我们将开始向通道发送 Result
,这样我们就可以在以后区分成功和失败。你需要确保导入 std::rc::Rc
和 std::sync::Mutex
。现在 success_tx
已经被更改为 Rc<Mutex<Sender>>
,你需要更新 success
回调以反映这一点。你将想要锁定对 Mutex
的访问并然后发送 success
消息。你的第一次尝试可能看起来像这样:
let image = web_sys::HtmlImageElement::new().unwrap();
let callback = Closure::once(move || {
success_tx
.lock()
.and_then(|oneshot| Ok(oneshot.send(Ok(()))));
});
...
这会锁定 Mutex
并向其 oneshot
发送 Ok(())
。这几乎是对的,但有一个问题导致编译器错误,如下所示:
error[E0507]: cannot move out of dereference of `std::sync::MutexGuard<'_, futures::futures_channel::oneshot::Sender<Result<(), wasm_bindgen::JsValue>>>`
--> src/lib.rs:38:40
|
38 | .and_then(|oneshot| Ok(oneshot.send(Ok(()))));
| ^^^^^^^^^^^^^^^^^^^^ move occurs because value has type `futures::futures_channel::oneshot::Sender<Result<(), wasm_bindgen::JsValue>>`, which does not implement the `Copy` trait
编译器错误信息很长,所以值得分解。正如错误所说,.and_then(|oneshot| Ok(oneshot.send(Ok(()))));
这一行需要将 oneshot
值移动到闭包中。这是因为 oneshot
没有实现拷贝。这很有道理;如果你可以拷贝 oneshot
,那么你可以多次使用它。好吧,所以 oneshot
必须移动到闭包中——那么问题是什么?移动并不坏,但错误信息说,“错误[E0507]:不能从 std::sync::MutexGuard
的解引用中移动”。Mutex
会获取你移动到其中的值的所有权,你不能只是移动它的值而留下“什么也没有”。因此,编译器阻止了这个操作。
这些错误既是 Rust 的一个伟大特性,也是 Rustacean 存在的痛苦之源。在这里,编译器正在阻止我们犯线程错误,这种错误在几乎所有其他语言中都很容易犯,但副作用是难以阅读的错误。Rust 团队一直在努力改进编译器信息的清晰度,但有些事情确实很难。当编译器让你感到困惑时,请慢慢仔细地阅读错误,你通常会弄清楚它试图告诉你什么。
那么,如何解决这个问题呢?你需要确保在仍然可以访问底层 Sender
的同时,永远不要从 Mutex
引用中移出。我们可以通过使用 Option<T>
类型来实现这一点,它实现了拷贝和 take
函数。这将允许我们在锁定 Mutex
内部用 None
替换 Sender
。然后,任何其他使用该 Mutex
引用的用户都将拥有 None
并能适当地使用它。
首先,修改 success_tx
的创建,使其接受 Option
,如下面的代码所示:
let (success_tx, success_rx) = futures::channel::oneshot::channel::<Result<(), JsValue>>();
let success_tx = Rc::new(Mutex::new(Some(success_tx)));
let error_tx = Rc::clone(&success_tx);
现在,在 success
回调中,我们需要修改代码以考虑传输器是可选的。这里我们将使用 take
来立即将 Some(transmitter)
替换为 None
,当它被使用时。这是 success
回调:
let callback = Closure::once(move || {
if let Some(success_tx) = success_tx.lock().ok()
.and_then(|mut opt| opt.take()) {
success_tx.send(Ok(()));
}
});
在这里,我们使用了if let
构造来从Mutex
和Option
中获取发送器。如果你从success_tx.lock()
的代码跟下来,你会看到我们调用ok
将lock()
的结果转换为Option
,使用and_then
函数对Option
的Some
版本进行操作,然后最终使用take
来获取Option
的值。在if
条件中,我们用Ok
结果调用发送器的send
函数,并且我们不再需要围绕send
调用的奇怪Ok
包装器。关键是Option
永远不会从Mutex
中移出;它被None
替换。由于在锁定期间没有人可以访问oneshot
结构,代码是线程安全的,而且因为我们使用了Option
,Mutex
总是包含某些东西——即使它是None
。
我们终于可以编写开始所有这些的error
回调了,它非常相似:
let error_callback = Closure::once(move |err| {
if let Some(error_tx) = error_tx.lock().ok()
.and_then(|mut opt| opt.take()) {
error_tx.send(Err(err));
}
});
...
那个error
回调需要使用set_onerror
调用设置。我们之前有那个,但以防你之前没有添加,它看起来如下:
image.set_onload(Some(callback.as_ref().unchecked_ref()));
image.set_onerror(Some(error_callback.as_ref().unchecked_ref()));
...
我将set_onerror
调用放在现有的set_onload
调用下面,以保持对称。我们不需要为错误添加第二个await
调用。因为error_tx
是success_tx
的克隆,并且我们受到保护,不会同时收到错误和成功,因为oneshot
只能触发一次。
现在,我们正确地处理了错误和成功的情况,并且没有收到编译器错误。如果你现在看看你的浏览器,你应该只看到三角形,因为我们不再卡在await
调用了。继续恢复image.set_src("Idle (1).png")
的调用,使其再次使用正确的文件,RHB 就会重新出现。
所以,就是这样——我们的游戏现在再次显示图像并处理错误。但是,如果你的游戏显示的图像是...多于一个呢?
精灵表
创建一个每个精灵都是其自身独立文件的游戏当然可能,但这意味着当游戏开始时,玩家需要等待每个文件单独加载。组织游戏精灵的一种常见方式是精灵表,它由两部分组成。第一部分是包含许多精灵的图像文件,就像这样:
图 2.4 – 精灵表顶部
第二部分是坐标和元数据的映射,它允许我们“裁剪”我们需要的每个图像,就像饼干切割器一样。例如,如果我们想要显示前面图中的第一个精灵(碰巧命名为Dead (7).png
),我们需要知道其位置和尺寸:
图 2.5 – 图表中一个精灵
我画了一个框,标记了你想要从图像中“裁剪”的帧,当你想要绘制Dead (7).png
时。当你想要绘制不同的文件,比如Slide (1).png
时,你可以在绘制时使用相同的图像但不同的帧。
为了知道每个精灵表格的帧和名称,我们需要加载一个单独的文件,该文件存储所有这些信息以及图像本身。在我们的情况下,我们将使用一个名为 TexturePacker 的工具(www.codeandweb.com/texturepacker
)为我生成的文件,该工具允许你导出一个看起来像这样的 JSON 文件:
{"frames": {
"Dead (1).png":
{
"frame": {"x":0,"y":0,"w":160,"h":136},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {"x":0,"y":0,"w":160,"h":136},
"sourceSize": {"w":160,"h":136}
}
...
TexturePacker 通过精灵名称生成了一个包含查找表的 JSON 文件。在这种情况下,名为 "Dead
(7).png
" 的精灵位于 (0,0
),宽度为 109 像素,高度为 67 像素,因此位于较大图像的左上角。要绘制图像,你最终将使用一个 drawImage
函数版本,它接受源坐标,即你在前面的代码中看到的尺寸,以及你想要在画布上定位绘制的目标坐标。
因此,为了渲染之前从精灵表格中渲染的相同的 Idle
(1).png
,我们需要做以下事情:
-
加载 JSON 文件。
-
将 JSON 文件解析为 Rust 结构体。
-
将图像加载到
HtmlImageElement
。 -
使用允许我们只绘制图像元素一部分的
drawImage
版本。
没有其他事情要做,让我们开始吧。
加载 JSON
在你之前下载的资产中,有一个名为 sprite_sheets
的目录,其中包含两个文件,rhb.json
和 rhb.png
。请将这两个文件都复制到 static
目录中,以便它们可以被我们的项目加载。现在,让我们回到 lib.rs
并开始编辑以加载我们的表格。
在这种情况下,我们将首先编写一个全新的函数来调用 fetch_json
。它将使用 window.fetch
调用来检索 JSON 文件,然后从响应体中提取 JSON。这需要两个异步调用,因此我们将整个内容编写为一个 async
函数。请将所有这些内容放在 main
之后:
async fn fetch_json(json_path: &str) -> Result<JsValue, JsValue> {
let window = web_sys::window().unwrap();
let resp_value = wasm_bindgen_futures::JsFuture::from(
window.fetch_with_str(json_path)).await?;
let resp: web_sys::Response = resp_value.dyn_into()?;
wasm_bindgen_futures::JsFuture::from(resp.json()?).await
}
目前还有一些事情甚至无法编译,我们将在逐行分析时修复它们。
首先,我们检索 window
。再一次,我们使用 unwrap
因为 window()
是一个 Option
;在下一章,我们将更好地处理我们的错误。第二条线是一个难题;我们将分部分来处理它:
let resp_value = wasm_bindgen_futures::JsFuture::from(
window.fetch_with_str(&"rhb.json")).await?;
第一部分是调用 wasm_bindgen_futures::JsFuture::from
,这有点误导。JsFuture
不是一个 JavaScript future,而是一个由 JavaScript promise 支持的 Rust future。我们想要一个 Rust future,这样我们最终可以在它上面调用 await
。我们使用以下内容调用 from
:
window.fetch_with_str(json_path)
这对应于 JavaScript 中的 window.fetch
函数,但就像许多其他 JavaScript 函数一样,fetch
是重载的,因此我们需要显式调用它为 with_str
。该函数返回 Promise
,我们立即通过之前讨论的 from
调用将其转换为 future。最后,我们调用 await?
,这将阻塞直到 fetch
返回。这是允许的,因为 fetch_json
函数是 async
。
你还在吗?如果你理解了这一点,你就已经解决了最困难的部分。接下来,我们将返回的resp_value
转换为Response
,因为fetch
调用解析为JsValue
。再一次,我们必须从 JavaScript 的动态类型转换为 Rust 的静态类型,而dyn_into()
函数就是做这件事的。
现在我们已经得到了一个响应(对应于浏览器中的Response
对象),我们可以调用它的json()
函数,对应于网络Response
对象的json()
函数。该函数也返回一个 promise,所以我们用JsFuture
包装它,并用await
调用阻塞它。
最后,这个函数返回Result<JsValue, JsValue>
,这意味着它是一个Result
,其Ok
或Err
情况都是动态 JavaScript 对象。这就是为什么我们可以在任何地方使用?
。
但当然,这仍然无法编译,因为我们再次缺少一个功能标志。确保你将Response
添加到web-sys
依赖项列表中,你应该再次变为绿色。嗯,除了那个说fetch_json
没有被调用的警告。
解析 JSON
回到main
函数中,我们将绘制顺序设置为红帽男孩、Sierpiński 三角形,然后是另一个红帽男孩。所以,在调用sierpinski
之后,让我们获取对应红帽男孩数据文件的"rhb.json
"文件:
context.draw_image_with_html_image_element(&image, 0.0, 0.0);
let json = fetch_json("rhb.json").await.unwrap();
这会获取 JSON,但不会将其解析成我们可以使用的结构。我们有几种 JSON 解析的选项,包括使用浏览器内置的功能,但这是本 Rust 书籍,所以让我们使用 Rust 库,Serde。
Serde 是 Rust 中更受欢迎的序列化库之一,它擅长将 JSON(以及许多其他格式)转换为 Rust 结构。在Cargo.toml
中添加必要的依赖项:
serde = {version = "1.0.131", features = ["derive"] }
我们需要的 crate 是serde
,它可以泛型处理序列化和反序列化(你之前在编辑器中将rhb.json
文件复制到static
目录中的文件。在顶部,你应该看到类似这样的内容:
{"frames": {
"Dead (1).png":
{
"frame": {"x":0,"y":0,"w":160,"h":136},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {"x":0,"y":0,"w":160,"h":136},
"sourceSize": {"w":160,"h":136}
}
...
这份 JSON 文档描述了一个帧的哈希,其中每个帧的键是图像的名称("Dead (1).png")
,下面的结构是该图像的属性。我们关心的属性是frame
。"Dead (1).png
"的图像位于(210, 493
),宽度为 71 像素,高度为 115 像素。回到代码中,我们可以解析我们之前获取的 JSON。
首先,我们需要设置serde
可以使用的数据结构。在lib.rs
的顶部,我们可以添加Deserialize
过程宏到作用域中:
use serde::Deserialize;
你还想要添加HashMap
从std::collections
:
use std::collections::HashMap;
现在,我们将逆向工作。你将有一个包含前面 JSON 中查找表的Sheet
类。你可以在lib.rs
文件中的任何地方放置这个结构体,只是不要在函数内部。我把它放在了顶部:
#[derive(Deserialize)]
struct Sheet {
frames: HashMap<String, Cell>,
}
[derive(Deserialize)]
宏意味着我们可以将Sheet
用作反序列化 JSON 的目标,HashMap
和String
会自动工作,但我们还没有定义Cell
。这将代表包含frame
的部分 JSON,这是我们关心的,因为它是目标精灵所在的位置。我们将在Sheet
之上添加所有需要的结构体:
#[derive(Deserialize)]
struct Rect {
x: u16,
y: u16,
w: u16,
h: u16,
}
#[derive(Deserialize)]
struct Cell {
frame: Rect,
}
太好了——我们有一系列结构可以保存我们需要绘制图像的数据映射,但我们还没有填充它们,但幸运的是,wasm-bindgen
通过serde-serialize
功能使这变得非常简单。要启用该功能,你需要再次更新Cargo.toml
,将基本的wasm-bindgen
依赖项替换为以下内容:
wasm-bindgen = { version = "0.2.78", features = ["serde-serialize"] }
之前你只有wasm-bindgen = "0.2.78"
,现在你需要添加serde-serialize
功能标志,所以你必须使用稍微复杂一点的语法。构建之后,你只需用一行代码into_serde
导入 JSON 数据,在你获取 JSON 之后:
let json = fetch_json("rhb.json")
.await
.expect("Could not fetch rhb.json");
let sheet: Sheet = json
.into_serde()
.expect("Could not convert rhb.json into a Sheet
structure");
我移除了unwrap
调用,并用expect
替换了它们,因为我希望在这些情况下有一个特定的消息。
提示
我们使用的几乎所有依赖都非常年轻,这本书不太可能跟上每一个怪癖。为了跟上,请坚持使用书中使用的版本,但为了你自己的未来项目,记住,当依赖似乎不起作用时,要检查文档中的功能标志、版本号或两者。
现在我们有了 sheet,我们就可以加载图片并在其中绘制精灵了。
用我们的“饼干切割器”绘制
回想一下,我们已经有四个步骤来从精灵图中绘制。我们已经完成了前两个:
-
加载 JSON 文件。
-
将 JSON 文件解析为 Rust 结构。
-
将图像加载到
HtmlImageElement
。 -
使用允许我们只绘制图像元素一部分的
drawImage
版本。
第 3 步是你之前已经做过的,就像所有优秀的程序员一样,当我们需要两次编写相同的代码时,我们会立即使用一个工具…
当然是复制粘贴!你以为我会说一个函数吗?我们会留到以后再说。
提示
更重要的是,为了使某物第二次工作而复制粘贴是完全可接受的;只是避免将其作为最终版本提交。
将从let (success_tx, success_rx)
到success_rx.await
的所有内容复制并粘贴到下面,在那里我们将rhb.json
转换为Sheet
:
let sheet: Sheet = json
.into_serde()
.expect("Could not convert rhb.json into a Sheet
structure");
let (success_tx, success_rx) = futures::channel::oneshot::channel::<()>();
...
image.set_src("Idle (1).png");
success_rx.await;
多亏了 Rust 的工作方式,你不需要重命名任何变量,因为每次你使用let
,你都会覆盖之前的变量版本并创建一个新的绑定。在粘贴的代码中,我们只需要做一个小改动——加载图像 sheet 而不是"Idle (1().png
":
image.set_src("rhb.png");
第 3 步现在已经完成;我们已加载包含许多精灵的大图像。最后,我们将绘制我们想要的精灵。让我们绘制"Run (1).png
"精灵,它虽然看起来相似,但将允许我们添加一些与之配合的动画。我们将使用带有源位置的drawImage
调用版本,这是我们之前讨论过的帧,以及目标位置,我们将在这里放置图像在画布上。为了确保我们看到新图像,让我们将其放置在中间附近。在最后一个await
调用之后添加此图像:
let sprite = sheet.frames.get("Run (1).png").expect("Cell not found");
context.draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
&image,
sprite.frame.x.into(),
sprite.frame.y.into(),
sprite.frame.w.into(),
sprite.frame.h.into(),
300.0,
300.0,
sprite.frame.w.into(),
sprite.frame.h.into(),
);
第一行,sheet.frames.get
通过名称检索精灵,如果名称错误,会抛出expect
异常。下一行是一个怪物,因为drawImage
在 JavaScript 中有九种参数版本,在 Rust 中通过调用draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh
来表示。这听起来很复杂,但它的意思是用源矩形(我们的帧)绘制图像到目标矩形,其中源矩形由四个位置和大小坐标表示,目标矩形也由四个坐标表示。源矩形是我们的帧,从我们之前加载的 JSON 文件中绘制。目标矩形从(300,300
)开始,将 RHB 放置在画布的大约中心,并使用相同的宽度和高度,因为我们不想改变图像的大小。最终结果是这个:
图 2.6 – 多个红帽男孩
原始 RHB 在左上角,使用自己的图像文件,而精灵图集中的第二个 RHB 大约在三角形的中心。你会注意到他的右手稍微缩回,因为这是他跑步动画的开始。
说到跑步动画,我们不妨看看它是如何运作的?
重要提示
以我们在这里的方式加载精灵图集和图像只是实现这种技术的方式之一。例如,另一种选择可能是将 JSON 和图像嵌入 Rust 可执行文件中,可能通过 Base64 编码它们,从而一次性完成所有数据加载。它们也可以通过 webpack 打包到目标应用程序中,并暴露给我们的 Rust 应用程序。所有这些不同的方式都伴随着它们自己的权衡,在我们的情况下,我们为了满足调用服务器的需求,牺牲了一些复杂性和初始加载时间。为你的游戏找到最佳解决方案。
添加动画
小精灵动画的工作原理就像翻页书或电影。快速展示一系列图像,其中每个图像都绘制得与前一个图像略有不同,从而产生运动的错觉。画布上的动画以类似的方式工作,其中精灵图集中的每一帧都像翻页书中的一幅画:
图 2.7 – 红帽男孩的跑步动画
要绘制正在跑步的红帽男孩,我们只需按顺序绘制图像,一次一个,并在绘制最后一个图像后循环。对于循环来说很简单,对吧?
当然,事情并不那么简单。首先,我们不能简单地使用无限循环,因为这将会阻止浏览器进行任何处理,导致浏览器标签页冻结。其次,我们必须确保在每一帧之间清除画布。否则,我们会看到所有图像合并在一起,因为一个图像会在另一个图像之上绘制。所以,每次我们绘制画布时,我们都需要先清除它,然后绘制所需的帧。
重要提示
如果你熟悉传统游戏开发中的双缓冲,并且担心我们在清除画布然后重新绘制时看到闪烁,不用担心。画布元素已经为你处理了这个。
幸运的是,你已经几乎知道了绘制动画 RHB 所需的所有知识。你需要将 Rust 闭包传递给一个函数,并从精灵表中绘制精灵。唯一你不了解的是如何清除画布,我们稍后会介绍,但我们必须首先说再见:
-
sierpinski
以及它所使用的所有代码,包括midpoint
和draw_triangle
函数。它们为我们服务得很好,将会被怀念。 -
删除闲置的 RHB: 我们可能需要费力地保留闲置的 RHB 精灵,但这将需要处理我们为创建精灵表而编写的重复代码。最好在 BOSS 发现之前删除所有这些复制粘贴的代码。
不,继续删除 spawn_local
闭包中直到我们加载 rhb.json
文件之前的所有内容。删除这些内容后,你的代码在 spawn_local
附近应该看起来像这样:
let context = canvas
.get_context("2d")
.unwrap()
.unwrap()
.dyn_into::<web_sys::CanvasRenderingContext2d>()
.unwrap();
wasm_bindgen_futures::spawn_local(async move {
let json = fetch_json("rhb.json")
.await
.expect("Could not fetch rhb.json");
...
因此,在生成本地未来之前,你最后要做的事情是获取 2d
上下文,而在生成未来之后的第一件事是加载 JSON。
现在,是时候将绘制改为回调函数了。
-
setInterval
函数,被称为set_interval_with_callback
。首先,我们需要设置回调本身,使用我们之前使用的Closure
结构体
。在success_rx.await
调用之后,添加以下内容:let interval_callback = Closure::wrap(Box::new(move || {}) as Box<dyn FnMut()>);
这设置了一个空的 Closure
,但与之前我们创建 Closure
的时候不同,我们这次使用 Closure::wrap
而不是 Closure::once
。为什么?因为这个闭包将会被多次调用。这也意味着我们需要使用 Box
并进行显式转换,as Box<dyn FnMut()>
,因为 wrap
函数需要 Box
,并且编译器没有足够的信息来推断类型。
现在我们有一个空的间隔回调,我们可以安排它被调用。在下一行,添加以下内容:
window.set_interval_with_callback_and_timeout_and_arguments_0(
interval_callback.as_ref().unchecked_ref(),
50,
);
添加这个将会启动每 50 毫秒调用我们的 interval_callback
的过程;然而,这样做将会导致错误。如果你通过控制台查看浏览器的错误日志,你会看到这个重复的:
Uncaught Error: closure invoked recursively or destroyed already
这听起来应该很熟悉,因为我们已经在本章中修复过一次了。修复方法是将我们传递给 setInterval
的闭包再次忘记,这样 Rust 就不会在我们离开这个 future 的作用域时销毁它。在 set_interval
调用后添加此行:
interval_callback.forget();
然后,返回并检查控制台以验证错误是否已消失。你可能需要刷新浏览器以确保不会出现陈旧的错误消息来混淆你。
现在你已经安排了一个定期的回调,让我们在回调中添加一行来清除屏幕:
let interval_callback = Closure::wrap(Box::new(move || {
context.clear_rect(0.0, 0.0, 600.0, 600.0);
}) as Box<dyn FnMut()>);
这将无法编译,因为在这个回调之外,我们仍在调用 draw_image
。由于我们将 context
移入这个 Closure
,我们触犯了借用检查器。为了解决这个问题,我们需要将绘图代码移入闭包,如下所示:
let interval_callback = Closure::wrap(Box::new(move || {
context.clear_rect(0.0, 0.0, 600.0, 600.0);
let sprite = sheet.frames.get("Run(1).png").expect
("Cell not found");
context.draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
&image,
sprite.frame.x.into(),
sprite.frame.y.into(),
sprite.frame.w.into(),
sprite.frame.h.into(),
300.0,
300.0,
sprite.frame.w.into(),
sprite.frame.h.into(),
);
}) as Box<dyn FnMut()>);
恭喜!你现在每 50 毫秒清除屏幕并重新绘制它。不幸的是,看起来什么都没有,因为你总是在绘制相同的图像。让我们改变代码,让它从 "Run (1).png
" 到 "Run (8).png
" 无限循环。
在闭包外部初始化一个帧计数器:
let mut frame = -1;
let interval_callback = Closure::wrap(Box::new(move || {
现在,在闭包内部,我们将帧计数器在 0 和 7 之间循环:
let interval_callback = Closure::wrap(Box::new(move || {
frame = (frame + 1) % 8;
为什么是 0 到 7,而不是到帧 8?因为我们将在下一行调整它,当我们构造 framename
时:
let interval_callback = Closure::wrap(Box::new(move || {
frame = (frame + 1) % 8;
let frame_name = format!("Run ({}).png", frame + 1);
最后,我们不再每次都获取 "Run (1).png
",而是从精灵图集中获取构造的精灵名称。只需将 sheet.get
调用改为使用 &frame_name
,并将 get
调用移至 clear_rect
调用之上:
let frame_name = format!("Run ({}).png", frame + 1);
let sprite = sheet.frames.get(&frame_name).expect("Cell not found");
现在看看,红帽男孩正在跑!
图 2.8 – 你在书中看不到跑步,相信我
摘要
在本章中,我们介绍了将精灵渲染到屏幕上的内容,包括精灵图集,但实际上我们涵盖的内容远不止于此。我们介绍了如何在 WebAssembly 应用中使用 futures 和 async
代码,如何解析 JSON,以及可能最令人困惑的是,如何通过 Closure
结构体将 Rust 闭包发送到 JavaScript。我们还回顾了在 WebAssembly 环境中使用 Rust 的一些怪癖,这些怪癖在 第一章 Hello WebAssembly 中有所提及。本章很有趣,但我们编写了一些混乱的代码。
在下一章中,我们将通过为我们的游戏设置一个简单的架构并编写一个合适的游戏循环来处理这个问题。以免你认为 第三章**,创建游戏循环 只是对代码进行重构,我们还将让我们的朋友红帽男孩在屏幕上移动。它将开始看起来像一款真正的游戏!
第二部分:编写你的无尽跑酷游戏
现在你已经将 Rust 和 WebAssembly 书籍的框架搭建完成,你将使用这个独特的工具链编写一个完整的游戏。"Walk the Dog"这个无尽跑酷游戏将需要用户输入、游戏循环、声音等功能。在本节结束时,你将拥有自己的版本可以反复游玩,并且拥有基于网络的用户界面。
在本部分,我们涵盖了以下章节:
-
第三章, 创建游戏循环
-
第四章, 使用状态机管理动画
-
第五章, 碰撞检测
-
第六章, 创建无尽跑酷游戏
-
第七章, 声音效果与音乐
-
第八章, 添加用户界面
第三章:第三章:创建游戏循环
在前两章中,我们专注于构建应用程序、设置环境和在屏幕上显示图形,而没有关注创建一个实际运行的游戏。这里没有交互性,也没有直接添加更多角色的简单方法,除非复制和粘贴更多代码。在本章中,这将改变,我们将添加游戏循环和键盘事件,但首先,我们需要重构代码,使其为新功能做好准备。准备好深入挖掘——这将是一个繁忙的章节。
我们将涵盖以下内容:
-
游戏的最小化架构
-
创建游戏循环
-
添加键盘输入
-
移动红帽男孩
到本章结束时,我们将拥有一个可以扩展新功能和处理输入的迷你游戏引擎。
技术要求
本章没有新的技术要求;我建议确保你的编辑器/IDE 设置对你来说很舒适。你将进行很多更改,你希望你的编辑器能帮助你。本章的源代码可在 github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_3
找到。
观看以下视频以查看代码的实际效果:bit.ly/3qP5NMa
最小化架构
几年前,在准备一场关于 HTML5 游戏开发的演讲时,我有一个顿悟。在我预定演讲的前一天,我已经写好了幻灯片并准备好了演讲稿,但我有一个小问题——我没有演示!我需要一个游戏的演示来结束我的演讲;实际上,我在幻灯片中提到了它,所以我必须制作它。如果你曾经面临过截止日期,你就知道接下来会发生什么。我关于干净代码和软件架构的所有想法都被抛到了一边,我一边破解一边砍伐,最终在 HTML5 中制作出了 《小行星》 的工作原型。你仍然可以在我的 GitHub 上找到它:github.com/paytonrules/Boberoids
,附带一个没有意义的名字。
根据几乎任何标准,代码都很糟糕。与 第一章 中的代码、《Hello WebAssembly》 和 第二章 中的 《绘制精灵》 一样,代码直线推进,没有模块、关注点分离或测试,这种代码从程序开始到结束都是暴力破解。但在那次演示的前一天凌晨 2 点左右,发生了一件有趣的事情——它竟然成功了!事实上,为了准备本章,我克隆了将近 10 年前的程序,运行 python -m http.server
,浏览到 http://localhost:8000
,然后,好吧,这里就是——一个基本上工作的 《小行星》 克隆:
图 3.1 – 带有公司标志的彗星
当然,这段代码也几乎不可能扩展或调试。没有封装,所有内容都在一个文件中;天哪,甚至没有合适的README
文件。从任何客观标准来看,这都是糟糕的软件。
它太糟糕了,以至于在制作这个演示文稿的同时,我开始同时进行一个名为"Eskimo"的开源项目(github.com/paytonrules/Eskimo
),该项目旨在成为一个优秀的游戏框架,拥有我那时所知的最佳面向对象设计,采用测试先行的方法,并内置了 CI 等特性。如果你查看提交日期,你可能会注意到我在这个项目上的最后提交是在你可以在前面的屏幕截图中看到的Asteroids克隆之后两年。如果你只是简单地做代码审查,代码实际上比上述游戏的代码要好得多。但它实际上并不工作。
我从未用Eskimo制作出一个可工作的游戏。像许多在我之前的开发者一样,我陷入了写框架而不是制作游戏的陷阱,花了很多时间“完善”我的框架,以至于我对我所声称制作的游戏失去了兴趣。这困扰了我很长时间,我一直在问自己这个问题,“为什么我在做所有错误的事情时完成了游戏,而在做正确的事情时失败了?”好的代码在现实生活中有什么真正的意义?
我不会让你保持悬念;为了这本书的目的,我们将把最小架构定义为一种使下一个功能更容易实现的架构。这意味着我们将做一些架构工作,但仅足够使未来的事情变得容易。我们将密切关注额外的复杂性和“镀金”。我们正在制作一个游戏,而不是一个引擎,我们希望完成这个游戏。
好的吗?糟糕的吗?我是那个有代码的人
最小架构听起来很简单,但可能很难,所以让我用一个反例来解释。
Eskimo 有一个Events
对象,它通过一个构造函数创建,该构造函数接受jquery
、document
、一个game
对象和一个canvas
。它有所有这些,因为我将依赖注入的原则推向了极致,并试图确保Events
对象不会直接依赖于这些事物中的任何一项。
问题是什么?这三个对象永远不会改变。你不会在任何游戏中替换jquery
、document
或canvas
,至少不是用Eskimo,而且由于这个原因,理解 Eskimo 代码需要很多理解。虽然代码在理论上更加灵活,并遵循依赖反转原则(bit.ly/3uh7fWU
),但它实际上使得添加未来的功能变得更加困难,因为我无法记住这些依赖项具体做了什么。我的错误是在没有理由的情况下,出于一种错误的“好代码”感,提前注入了这些依赖项。
我们将专注于我们的目标,即制作一个游戏,并不想陷入制作框架的困境。这意味着我们使程序演变成游戏的过程将在每次需要时引入一点灵活性。回到两个示例游戏,Asteroids 和 Eskimo,我们可以将它们视为在刚性尺度上。Asteroids 克隆非常刚性。它就像一根钢杆,如果你想改变它,你就不能。你只能打破它。与此同时,Eskimo 游戏框架具有无限的可塑性,以至于它实际上什么也做不了。它在自己身上坍塌成一块粘稠的物质。我们的游戏,也就是那个跑得这么远的红帽男孩,也是非常刚性的。添加第二个对象,比如狗,就需要在整个小型应用程序中更改大量代码,并可能引入缺陷。
因此,为了将我们的游戏添加更多功能,特别是交互性,我们需要引入一些灵活性。我们将加热我们的钢杆,使其可以弯曲,弯曲它,然后让它再次硬化。
分层架构
我们将首先引入一个小型分层架构。具体来说,我们将有三个层:
图 3.2 – 分层架构
这个架构的一条规则是,层只能使用其层或以下的东西。所以从底部开始,浏览器层将是一系列针对浏览器的特定小函数。例如,我们的 window
函数最终会在这里。同时,引擎层将是跨越我们游戏工作的工具,例如 GameLoop
结构。最后,游戏是包含我们实际游戏逻辑的层。最终,我们将花费大部分开发时间在这个层,尽管最初,我们将在 Engine
和 Browser
层花费大量时间,直到它们稳定下来。
为什么这样做?我们之前提到的规则是,任何架构的改变都必须使未来的改变更容易,所以让我们确定一下现在使改变困难的原因:
-
将所有内容都放在一个长函数中使代码难以跟踪。
-
提取所有
Browser
代码将使我们能够统一错误处理。
第一点反映的是我们的大脑只能容纳这么多信息。将所有代码放在一个地方意味着上下滚动试图找到东西的位置,并试图记住几乎所有的代码。将代码提取到各种具有名称的结构中,如模块、函数和结构体,让我们减少头脑中的信息量。这就是为什么合适的设计感觉编程起来很舒服。抽象过多,你就把跟踪程序所有细节的工作替换成了跟踪所有抽象的工作。我们将尽最大努力保持事物在最佳状态。
分层方法的原因之二特定于 Rust 和 wasm-bindgen
函数,它们都返回 JsValue
作为错误类型。虽然这在浏览器中工作得很好,但当与 Rust 程序的其他部分混合时则不太理想,因为 JsValue
没有实现大多数其他 Rust 错误实现的 std::Error::error
类型。这意味着你不能编写如下所示的函数:
async fn doesnt_compile() -> Result<(), Box<dyn std::error::Error>> {
let window = web_sys::window()?;
let json = fetch_json("rhb.json").await?;
...
}
之前的代码无法编译,因为虽然 ThreadPool::new
返回一个 Result<ThreadPool, Error>
,但 fetch_json
返回 Result<JsValue, JsValue>
,这些结果无法混合。在 browser
模块中,我们将使用 anyhow
crate 将 JsValues 映射到标准错误,同时我们也将用它来隐藏 API 的奇怪细节,创建一个适合我们目的的 API。让我们开始创建我们的 browser
模块。
创建浏览器模块
第一步是在 src
目录下创建一个名为 browser.rs
的文件,并在 lib.rs
的顶部使用 mod browser
引用它。虽然理论上我们可以将每个模块都放在 lib.rs
中,但我们不是怪物,我们会将它们拆分成自己的文件。到本章结束时,lib.rs
将非常小。我们添加到 browser
的第一个功能实际上将是一个宏,一个全新的宏,如下所示:
macro_rules! log {
( $( $t:tt )* ) => {
web_sys::console::log_1(&format!( $( $t )*
).into());
}
}
我很乐意宣称我是一个伟大的宏程序员,一次就写出了那个宏,但事实是那个小小的宏直接来自 Rust 和 WebAssembly 文档 (bit.ly/3abbdJ9
)。这是一个允许你使用类似于 format!
函数的语法通过 log!
在控制台中进行日志记录的宏。在 lib.rs
中,给 browser
模块声明添加一个注解,如下所示:
#[macro_use]
mod browser;
这使得 log!
在使用 browser
模块时可用。鉴于我们将进行很多更改,我们可能需要一些简单的调试。下一步将是添加 anyhow
crate,我们将使用它来统一 WebAssembly 和纯 Rust 代码的错误处理。依赖项添加到 Cargo.toml
中,作为 anyhow = "1.0.51"
。这个 crate 提供了一些我们将广泛使用的功能:
-
符合
std::error::Error
特质的anyhow::Error
类型 -
一个
anyhow!
宏,允许我们创建符合类型的错误消息,并使用字符串 -
一个
anyhow::Result<T>
类型,它是Result<T, anyhow::Error>
的快捷方式
现在请将 use anyhow::{anyhow, Result};
添加到使用声明顶部,这样我们就可以在创建新函数时使用它们了。
现在 browser
模块已经准备好了,让我们从 main
的顶部开始向下工作,提取函数。让我们从这里开始:
#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
console_error_panic_hook::set_once();
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
wasm_bindgen
宏必须保留在那里,并且它只与返回 Result<(), JsValue>
的函数兼容。这意味着虽然我们可以在整个程序中使用适当的 Rust 错误,但在最后,如果我们想从这个函数返回,我们需要将其转换回 JsValue
。幸运的是,一旦我们编写了游戏循环,这就不会成为问题。
重要提示
Wasm_bindgen
错误处理有点粗糙,Wasm 工作组已经注意到了这一点。为了参考,您可以在以下链接中查看缺陷:bit.ly/3d8x0D7
。
接下来是执行代码,有两个函数可以直接拖入 browser.rs
文件。我们将一步一步地进行重构。首先,让我们在浏览器模块中创建一个函数,如下所示:
pub fn window() -> Result<Window> {}
这段代码无法编译,因为它没有返回任何内容,而且它对 Window
类型一无所知。请继续在文件顶部导入这些内容。它应该看起来像这样:
use web_sys::Window;
小贴士
如果您还没有做,请将 Rust Analyzer 与您选择的编辑器配合使用。我使用 emacs,并使用键盘快捷键 c a
来导入模块。这对于这类工作来说是一个节省时间的好方法。从现在开始,我不会记录您在移动文件时需要的每个 use
声明;只需遵循编译器错误即可。
该函数也无法编译,因为您没有返回任何内容。您可以开始直接复制(不要剪切)lib.rs
中的 window()
调用:
pub fn window() -> Result<Window> {
web_sys::window().unwrap()
}
您在这里不需要用 let
绑定变量。由于那个 unwrap
,这段代码仍然无法编译。在这种情况下,web_sys::window
返回 Option<Window>
,unwrap
将提取 Window
对象,或者引发 panic。这些都不符合 Result<Window>
,我们需要做的是处理 window
以某种方式缺失的错误情况。
重要提示
当尝试混合使用 Option
和 Result
时,有两种观点——使用 ok
将 Result
转换为 Option
,或者使用 ok_or_else
将 Result
转换为 Option
。我更喜欢后者,因为虽然这意味着要写很多包含 "<X>
"(未找到)的错误消息,但另一种选择是失去有用的错误诊断。
为了使这个函数与 Result<Window>
返回类型兼容,记住,这是一个 Result<Window, anyhow::Error>
的缩写,我们将使用 anyhow!
宏。因此,为了将 Option
转换为 Result
并使这个函数编译,您可以这样做:
pub fn window() -> Result<Window> {
web_sys::window().ok_or_else(|| anyhow!("No Window Found"))
}
现在您已经有一个函数,browser::window()
,它将返回 Window
或适当的错误。
重要提示
Nightly Rust 目前有一个名为 NoneError
的错误,它有助于在 Option
和 Result
类型之间架起桥梁,但我们现在将坚持使用标准。
最后,我们可以将 lib
中的 web_sys::window()
调用替换为 lib
中的 browser::window()
调用:
let window = browser::window().expect("No Window Found");
let document = window.document().unwrap();
现在的window()
调用将使用expect
来确保如果没有窗口,程序会崩溃。稍后,你会看到我们可以使用?
运算符,但到目前为止,我们必须绕过main_js
返回Result<(), JsValue>
。如果这是唯一需要更改的地方,引入anyhow
就没有意义了。幸运的是,当我们用browser
模块中的新document
函数重复这个过程时,你可以看到优势。我们可以跳过那个过程的每个步骤,直接得到最终结果:
pub fn document() -> Result<Document> {
window()?.document().ok_or_else(|| anyhow!
("No Document Found"))
}
如果这段代码无法编译,别忘了在模块顶部的use
声明中添加Document
。随着我们做出这些更改,你需要将use
声明移动到browser
中,但你将能够从lib.rs
中删除它们。
现在,你实际上可以将lib.rs
中的两个window()
和document()
调用缩减为一个调用,如下所示:
pub fn main_js() -> Result<(), JsValue> {
console_error_panic_hook::set_once();
let document = browser::document().expect("No Document Found");
...
在lib.rs
中有一个地方我们使用了刚刚删除的 window 变量。在spawn_local
Closure
的底部附近,在创建interval_callback
之后,有一个调用window.set_interval_with_callback_and_timeout_and_arguments_0
,可以用browser::window().unwrap()
替换window
。它看起来如下所示:
let interval_callback = Closure::wrap(Box::new(move || {
...
}) as Box<dyn FnMut()>);
browser::window()
.unwrap()
.set_interval_with_callback_and_timeout_and_arguments_0(
interval_callback.as_ref().unchecked_ref(),
50,
);
interval_callback.forget();
我们接下来的函数将获取canvas
对象,但比前两个函数要复杂一些。我们对那个部分的unwrap
调用相当随意,所以我们需要做一些转换以获取更具体的错误。最终结果看起来像这样:
pub fn canvas() -> Result<HtmlCanvasElement> {
document()?
.get_element_by_id("canvas")
.ok_or_else(|| anyhow!
("No Canvas Element found with ID 'canvas'"))?
.dyn_into::<web_sys::HtmlCanvasElement>()
.map_err(|element| anyhow!("Error converting {:#?}
to HtmlCanvasElement", element))
}
在这里有几个值得特别注意的地方。首先,get_element_by_id
调用硬编码为'canvas'
ID。我们将继续保留这个设置,直到它引起问题为止,但我们将不会在需要之前将其设置为可配置的。接下来,我们使用了ok_or_else
将get_element_by_id
从Option
转换为Result
。最有趣的是对dyn_into
函数的调用。如前所述,几乎每个调用 JavaScript 的函数都会返回JsValue
类型,因为 JavaScript 是一种动态类型语言。我们知道get_element_by_id
返回的元素将返回HtmlCanvasElement
,至少如果我们已经检索到了正确的 JavaScript 节点,我们可以将其从JsValue
转换为正确的元素。这就是dyn_into
的作用——它将JsValue
转换为适当的 Rust 类型。为了使用dyn_into
,你必须导入wasm_bindgen::JsCast
,因为 rust-analyzer 无法自动导入。它可以导入web_sys::HtmlCanvasElement
。
我们将创建一个看起来非常相似的context
函数:
pub fn context() -> Result<CanvasRenderingContext2d> {
canvas()?
.get_context("2d")
.map_err(|js_value| anyhow!("Error getting 2d
context {:#?}", js_value))?
.ok_or_else(|| anyhow!("No 2d context found"))?
.dyn_into::<web_sys::CanvasRenderingContext2d>()
.map_err(|element| {
anyhow!( "Error converting {:#?} to
CanvasRenderingContext2d",
element
)
})
}
你在这里可能会看到一个奇怪的现象,那就是我们紧接着使用map_err
和ok_or
。这是因为get_context
返回Result<Option<Object>, JsValue>
,而旧代码通过两次调用unwrap
来“解决”这个问题。所以我们现在所做的就是将错误(JsValue
)映射到Error
,然后取内部的Option
并将None
情况映射到一个值上。
记住,如果你在跟随并遇到编译困难,请更新你的 use
声明。让我们稍微加快一点速度。我们可以为 spawn_local
添加一个函数:
pub fn spawn_local<F>(future: F)
where
F: Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(future);
}
小贴士
如果你正在编写这样的包装器并且不确定签名应该是什么,首先看看你正在包装的函数,并模仿其签名。
让我们也在 browser
中添加 JSON
获取:
pub async fn fetch_with_str(resource: &str) ->
Result<JsValue> {
JsFuture::from(window()?.fetch_with_str(resource))
.await
.map_err(|err| anyhow!("error fetching {:#?}",
err))
}
pub async fn fetch_json(json_path: &str) -> Result<JsValue> {
let resp_value = fetch_with_str(json_path).await?;
let resp: Response = resp_value
.dyn_into()
.map_err(|element| anyhow!("Error converting {:#?}
to Response", element))?;
JsFuture::from(
resp.json()
.map_err(|err| anyhow!("Could not get JSON from
response {:#?}", err))?,
)
.await
.map_err(|err| anyhow!("error fetching JSON {:#?}", err))
}
我将 fetch_json
扩展为两个函数,因为我认为 fetch_with_str
将会可重用,但这并不是严格必要的。fetch_json
函数几乎不属于 browser
模块。一方面,它专门调用 wasm_bindgen
API,将 JsValue
错误映射到标准的 Error
;另一方面,当我们决定从响应中获取 JSON
时,那里有一点点行为。最终,这有点像是一种判断。
写完所有这些函数后,你可以回到 lib.rs
模块并更新主函数以使用新的函数。正如你所见,它已经开始显著缩小,顶部应该看起来像以下这样,在适当的地方使用浏览器模块中的新函数:
#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
console_error_panic_hook::set_once();
let context = browser::context().expect("Could not get
browser context");
browser::spawn_local(async move {
let sheet: Sheet = browser::fetch_json("rhb.json")
.await
.expect("Could not fetch rhb.json")
.into_serde()
.expect("Could not convert rhb.json into a
Sheet structure");
let image =
web_sys::HtmlImageElement::new().unwrap();
...
你可以看到,我们移除了所有中间对 window
和 context
的调用,转而使用一个对 context
的调用。我们还刚刚使用 expect
调用 fetch_json
来抛出错误。最后,你会在 window.set_interval_with_callback_and_timeout_and_arguments_0
行看到一条编译错误。你可以通过将 window
替换为 browser::window().unwrap()
来修复它。unwrap
部分看起来很丑,但我们会继续重构,直到它也消失。它在上面的代码片段中没有重现,但你也可以从 lib.rs
中删除 fetch_json
函数;它不再被使用了。
这就带我们来到了下一个要提取的部分——加载图像。
加载图像
我敢打赌你认为你已经完成了图像的加载,不是吗?好吧,当我们将其转换为函数时,我们就会完成。让我们暂时再次看看原始实现:
let image = web_sys::HtmlImageElement::new().unwrap();
let (success_tx, success_rx) =
futures::channel::oneshot::channel::<Result<(),JsValue>>();
let success_tx = Rc::new(Mutex::new(Some(success_tx)));
let error_tx = Rc::clone(&success_tx);
let callback = Closure::once(Box::new(move || {
if let Some(success_tx) =
success_tx.lock().ok().and_then(|mut opt| opt.take())
{
success_tx.send(Ok(()));
}
}));
let error_callback = Closure::once(Box::new(move |err| {
if let Some(error_tx) =
error_tx.lock().ok().and_then(|mut opt| opt.take()) {
error_tx.send(Err(err));
}
}));
image.set_onload(Some(callback.as_ref().unchecked_ref()));
image.set_onload(Some(error_callback.as_ref().unchecked_ref()));
image.set_src("rhb.png");
success_rx.await;
乍一看,这似乎是 browser
模块中的一个函数,load_image
,但仔细思考后,这里有很多内容只是一个函数。例如,如果你选择,你可以创建一个图像元素而不用担心它是否会被加载,或者你可能愿意使用 set_src
而不考虑它是否已加载。不,let image = web_sys::HtmlImageElement::new().unwrap()
之后的所有内容实际上是引擎行为。这意味着是我们创建第二个模块 engine
的时候了!
engine
模块将包含我们将贯穿整个游戏使用的库和函数。我们是 engine
模块。实际上,为了分解这种行为,我们将遵循几个步骤:
-
创建一个
browser
函数,new_image
。 -
创建一个
browser
函数来创建 JS 闭包。 -
创建一个
engine
模块。 -
创建一个
engine
函数,load_image
。
让我们从对browser
的更改开始;我们将创建两个新函数来创建Closure
和图像:
pub fn new_image() -> Result<HtmlImageElement> {
HtmlImageElement::new().map_err(|err| anyhow!("Could
not create HtmlImageElement: {:#?}", err))
}
pub fn closure_once<F, A, R>(fn_once: F) ->
Closure<F::FnMut>
where
F: 'static + WasmClosureFnOnce<A, R>,
{
Closure::once(fn_once)
}
第一个函数只是HtmlImageElement
的一个包装器;没有太多可解释的。在未来,我们可能会决定我们想要自己的图像类型,但现在我们将坚持使用浏览器提供的类型。closure_once
函数因其类型签名而变得复杂。在这种情况下,我们只是模仿wasm_bindgen
中的Closure::once
函数的完全相同的类型签名。稍后,我们将为Closure
类型编写一些实用函数,以便更容易地使用它们,但在这个例子中,我们只创建一个直接的包装器。
重要提示
可以有力地论证,我们应该在这个模块中转换更多类型。具体来说,我们应该使用我们自己的类型来表示Closure
、HtmlImageElement
和其他浏览器提供的类型。这可能是一个更好的方法,但为了现在,我们将坚持使用提供的类型,以便学习材料并保持简单架构。像所有编程决策一样,这是一个权衡。
这涵盖了步骤 1和步骤 2,步骤 3很快——在源目录中创建一个名为engine.rs
的文件,并将mod engine
声明添加到lib.rs
中。现在对于步骤 4,我们一直害怕的那个。在engine.rs
中,添加以下内容:
pub async fn load_image(source: &str) -> Result<HtmlImageElement> {
let image = browser::new_image()?;
let (complete_tx, complete_rx) =
channel::<Result<()>>();
let success_tx =
Rc::new(Mutex::new(Some(complete_tx)));
let error_tx = Rc::clone(&success_tx);
let success_callback = browser::closure_once(move || {
if let Some(success_tx) =
success_tx.lock().ok().and_then(
|mut opt| opt.take()) {
success_tx.send(Ok(()));
}
});
let error_callback: Closure<dyn FnMut(JsValue)> =
browser::closure_once(move |err| {
if let Some(error_tx) =
error_tx.lock().ok().and_then(
|mut opt| opt.take()) {
error_tx.send(Err(anyhow!("Error Loading Image:
{:#?}", err)));
}
});
image.set_onload(Some(
success_callback.as_ref().unchecked_ref()));
image.set_onerror(Some(
error_callback.as_ref().unchecked_ref()));
image.set_src(source);
complete_rx.await??;
Ok(image)
}
我故意省略了use
语句,这样你就能习惯于添加它们,并思考你需要和正在使用哪些声明。然而,这段代码有两个陷阱,我想指出:
-
为了使
unchecked_ref
编译,你需要使用wasm_bindgen:JsCast
。 -
当你导入
channel
时,确保你选择futures::channel::oneshot::channel
。channel
有几种不同的实现,如果你不小心抓错了,这段代码将无法编译。
当你不确定时,看看lib.rs
并验证哪些依赖项在那里被使用,因为这是这段代码被提取的地方。
回到我们添加的代码,请注意我们一直在使用我们新的browser
函数,没有直接依赖于wasm-bindgen
函数。我们仍然依赖于wasm-bindgen
的Closure
和JSValue
类型,以及unchecked_ref
函数,但我们已经减少了直接平台依赖的数量。我们唯一的 JS 依赖是HtmlImageElement
。现在,看看函数的非常开始处,你会看到new_image
调用可以使用?
运算符在出现错误时提前返回,使用标准的 Rust 错误类型。这就是为什么我们在browser
函数中映射了那些错误的原因。
在方法的第一行之后,函数的其余部分与之前基本相同,将任何直接调用wasm-bindgen
函数替换为browser
中的相应调用。我们已经将通道改为发送anyhow::Result
,并在error_callback
中使用anyhow!
。这然后允许我们通过调用complete_rx.await??
和Ok(image)
来结束函数。这两个??
不是误印;complete_rx.await
返回Result<Result<(), anyhow::Error>, Canceled>
。由于anyhow::Error
和Canceled
都符合std::error::Error
,我们可以通过每次使用?
来处理这些错误。
我们在这个函数中仍然有两个警告,因为两个send
调用都返回了我们没有处理的Result
。我们不能只是使用?
,因为这些结果被Closure
类型所包裹,所以我们将推迟处理这些不太可能发生的错误,并在第九章中介绍错误记录,测试、调试和性能。
现在你已经完成了所有这些,你应该能够替换main
中的代码,调用我们新的函数:
let sheet: Sheet = json
.into_serde()
.expect("Could not convert rhb.json into a Sheet
structure");
let image = engine::load_image("rhb.png")
.await
.expect("Could not load rhb.png");
let mut frame = -1;
关于加载Sheet
的内容没有任何变化;那只是为了确保你把它放在正确的位置。之后,动画我们的小红帽男孩(RHB)的代码开始,但我们根本不会使用它。那将被我们的游戏循环所取代,我们现在开始介绍它。
创建游戏循环
这款游戏的核心,以及几乎所有游戏,都只是一个无限循环。你可以把它们简化成这样:
图 3.3 – 基本游戏循环
这意味着,从理论上讲,这些实现起来非常简单,如下所示:
while(!quit) {
handleInput()
updateGame()
drawGame()
}
核心来说,这就是我们要写的,但正如你可能猜到的,如果它那么简单,我就不会专门用一整章来介绍它了。不,我们在编写过程中将处理两个问题:
-
requestAnimationFrame
函数。 -
帧率和物理:我们之前编写的循环会以计算机能运行的最快速度运行。嗯,互联网上的每台计算机速度都一样吗?当然不是,所以我们需要确保我们能在循环中考虑到机器速度的差异,尽可能做到这一点。我们将使用所谓的固定步长游戏循环来实现。
重要提示
如果你愿意,你可以写一本关于游戏循环的书,但本节在很大程度上借鉴了
gameprogrammingpatterns.com/game-loop.html
和gafferongames.com/post/fix_your_timestep/
。
RequestAnimationFrame
我们将从requestAnimationFrame
函数开始,这是一个浏览器函数,它“请求”尽快绘制新帧。然后浏览器将这个帧绘制在处理鼠标点击、操作系统事件和猫咪视频等事物之间。您可能会认为这会非常慢,但实际上,通常情况下,它能够以每秒 60 帧的速度渲染,只要您的游戏能够跟上。问题是,与之前我们的setInterval
调用不同,这个函数需要在每个动画的末尾被调用。一个相当直接的 JavaScript 动画版本可能看起来像这样:
function animate(now) {
draw(now);
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
因此,requestAnimationFrame
使用animate
函数作为其参数被调用。浏览器随后在下一次帧上调用animate
函数,绘制并请求下一个帧。这看起来像是一个无限循环,但实际上,由于requestAnimationFrame
的调用,它并不会阻塞浏览器。这还接受一个参数now
,它是回调函数开始时的毫秒时间戳。我们将使用这个时间戳来调整我们的物理引擎,随着游戏循环的演变。实际上,在 Rust 中编写游戏循环有点奇怪,因为借用保证,所以让我们先写一个非常基本的循环:
您可以从为requestAnimationFrame
添加一个简单的包装器开始,如以下代码所示:
pub fn request_animation_frame(callback: &Function) ->
Result<i32> {
window()?
.request_animation_frame(callback)
.map_err(|err| anyhow!("Cannot request animation
frame {:#?}", err))
}
Function
类型是一个纯 JavaScript 类型,仅在js-sys
包中可用。虽然我们可以导入它,但如果可能的话,我宁愿不添加另一个 crate 依赖;然而,如果我们对函数签名和实现进行一些小的修改,实际上我们不必直接使用Function
类型:
pub fn request_animation_frame(callback: &Closure<
dyn FnMut(f64)>) -> Result<i32> {
window()?
.request_animation_frame(callback.as_ref().unchecked_ref())
.map_err(|err| anyhow!("Cannot request animation
frame {:#?}", err))
}
我们不使用&Function
,而是request_animation_frame
将接受&Closure<dyn FnMut(f64)>
作为其参数。然后,在调用web-sys
版本的request_animation_frame
时,它将调用callback.as_ref().unchecked_ref()
。这会将Closure
转换为Function
,而不需要显式依赖Function
类型,这在您创建这些函数的自己的版本时值得思考。web-sys
的制作者必须匹配每个潜在的使用场景,因此他们将创建尽可能广泛的接口。作为一个应用程序程序员,您不需要那个库中的大部分内容。因此,您可以将接口缩小到自己的使用场景,这样会使您的工作更加容易。实际上,为了使事情更加简洁,我们将将其转换为一种类型,其中有一个小的变化:
pub type LoopClosure = Closure<dyn FnMut(f64)>;
pub fn request_animation_frame(callback: &LoopClosure) ->
Result<i32> {
// ...
从我的小抱怨继续,您可能会认为现在可以编写一个简单的游戏循环,如下所示:
pub fn animate(perf: f64) {
browser::request_animation_frame(animate);
}
啊,如果只有,但请记住,我们需要传递一个 JavaScript Closure
,而不是 Rust fn
。使用我们之前使用的Closure::once
将不起作用,因为这个闭包将被多次调用,但幸运的是,有Closure::wrap
,它将做到这一点。我们将在browser
中创建一个针对request_animation_frame
函数的特定Closure
的函数,称为create_raf_closure
:
pub fn create_raf_closure(f: impl FnMut(f64) + 'static) ->
LoopClosure {
closure_wrap(Box::new(f))
}
传入的函数具有'static
生命周期。任何传递给这个函数的FnMut
都不能有任何非静态引用。这不是我个人的决定;这是我们将要调用的Closure::wrap
函数的要求。
小贴士
想了解更多关于静态生命周期的信息,请查看Rust by Example书籍,免费在此处提供:doc.rust-lang.org/rust-by-example/scope/lifetime/static_lifetime.html
。
说到Closure::wrap
,让我们将其包裹在closure_wrap
函数中,这样我们刚刚添加的代码就可以编译了,如下所示:
pub fn closure_wrap<T: WasmClosure + ?Sized>(data: Box<T>)
-> Closure<T> {
Closure::wrap(data)
}
这是一个包装函数,我们只是匹配被包装函数的相同签名——Closure::wrap
。因为Closure
上的wrap
函数创建了一个可以多次调用的Closure
,所以它需要被包裹在Box
中并存储在堆上。
小贴士
wasm-bindgen
的夜间构建提供了一个更方便的new
函数,它可以为你处理装箱。在这本书中,我们将坚持使用稳定构建,但你可以尝试使用夜间构建。
现在你已经了解了基本的游戏循环和如何调用request_animation_frame
,你可能认为,“我已经搞定了”,并创建如下游戏循环:
let animate = create_raf_closure(move |perf| {
request_animation_frame(animate);
});
request_animation_frame(animate);
这更接近了,但还不是最终结果。记得之前提到的,我们传递给create_raf_closure
的Closure
必须具有'static
生命周期,这意味着Closure
引用的所有内容都必须由闭包拥有。目前并不是这样。animate
变量属于当前作用域,并在该作用域完成时被销毁。当然,animate
本身就是Closure
,因为这是一个自引用数据结构。animate
变量是Closure
,但也在Closure
内部被引用。这是 Rust 的一个经典问题,因为borrow
检查器不允许这样做。
想象一下,如果情况不是这样会怎样——如果animate
可以在Closure
中被引用,但属于Closure
外部的作用域。当程序退出这个作用域时,它将被销毁,而Closure
将不再有效——这是一个Null
指针错误,并且会导致崩溃。这就是自引用数据结构的问题,因此我们需要找到一种绕过borrow
检查器的方法。
由于目前还没有地方放置这段代码,让我们再次尝试一个假设的循环:
let f = Rc<RefCell<Option<LoopClosure>>> =
Rc::new(RefCell::new(None));
let g = f.clone();
let animate = Some(create_raf_closure(move |perf: f64| {
request_animation_frame(f.borrow().as_ref().unwrap());
});
*g.borrow_mut() = animate;
request_animation_frame(g.borrow().as_ref().unwrap());
目前,我有点希望我在写 JavaScript,但让我们慢慢地通过这段代码。我们正在做的是创建两个指向内存中同一位置的引用,使用Rc
struct
,使我们能够同时将f
和g
指向同一事物,同时将f
移动到animate Closure
中。另一个技巧是它们都指向Option
,这样我们可以在f
完全定义之前将其移动到Closure
中。最后,当我们通过*g.borrow_mut() = animate
将Closure
赋值给g
时,我们f
因为它们指向同一位置。你明白了吗?不,我也不明白。让我们快速回顾一下类型,以重申我们做了什么。f
被设置为以下内容:
-
使用
Rc
创建一个引用计数指针 -
使用
RefCell
允许内部可变性 -
Option
允许我们将f
赋值为None
-
LoopClosure
用于持有与request_animation_frame
参数匹配的可变Closure
然后g
被设置为f
的一个克隆,这样它们就指向同一事物,f
被移动到animate
Closure
中。通过解引用*
运算符和borrow_mut
函数,g
被赋值给animate
。因为f
指向与g
同一位置,它也将包含animate Closure
。最后,我们可以通过借用它,将其转换为引用,并调用unwrap
来实际获取真正的Closure
来调用request_animation_frame
。是的,unwrap
又回来了;我们将在创建我们的真实函数时处理其中一个。最后,当g
离开作用域时,它可以被销毁,因为f
仍然在Closure
中,并将保留内存。
重要提示
再次,我很想为这段代码争取功劳,但事实是它大部分定义在wasm-bindgen
指南中,bit.ly/3v5FG3j
。
现在我们已经知道了我们的游戏循环的核心将是什么样子,我们该如何将它与一个游戏整合呢?
一个游戏特性
要编写我们的游戏循环,我们有几种选择。我们可以直接在循环中编写游戏,但这会与之前开始的样子非常相似。我们可以创建一个GameLoop
结构体,包含update
和draw
函数,这是一个显著的改进,但仍然将所有内容绑定到一个结构体中。我们将稍微超出这个范围,并从流行的游戏框架 XNA 或其现代版本 MonoGame 中汲取灵感。在 XNA 框架中,游戏开发者将实现一个Game
类型,包含update
和draw
方法。这比将所有代码都堆在一个地方稍微复杂一些,但比完整的实体-组件框架要简单得多。它应该适合我们的目的,因为它从小的开始,并且随着游戏的扩大应该允许扩展。XNA 之所以非常成功,是有原因的。
重要提示
你可以在www.monogame.net/
了解 XNA 的现代等价物,MonoGame。
我们将创建一个接受任何实现了Game
特质的start
函数。Game
特质将包含两个函数,update
和draw
。我们将通过游戏循环首先更新然后绘制场景。所有这些都将放入engine
模块;实际上,可以说这是我们整个“引擎”。让我们从简单的版本开始——首先,是特质:
pub trait Game {
fn update(&mut self);
fn draw(&self, context: &CanvasRenderingContext2d);
}
到目前为止一切顺利。注意draw
函数如何接受CanvasRenderingContext2d
作为参数。现在,对于循环的其余部分——你可以在Game
特质或load_image
之后添加这个,实际上这并不重要,只要它在engine
模块中即可:
pub struct GameLoop;
type SharedLoopClosure = Rc<RefCell<Option<LoopClosure>>>;
impl GameLoop {
pub async fn start(mut game: impl Game + 'static) ->
Result<()> {
let f: SharedLoopClosure =
Rc::new(RefCell::new(None));
let g = f.clone();
*g.borrow_mut() = Some(
browser::create_raf_closure(move |perf: f64| {
game.update();
game.draw(&browser::context().expect("Context
should exist"));
browser::request_animation_frame(
f.borrow().as_ref().unwrap());
}));
browser::request_animation_frame(
g.borrow()
.as_ref()
.ok_or_else(|| anyhow!("GameLoop: Loop is
None"))?,
)?;
Ok(())
}
}
这稍微大一点,但没有什么是你以前没见过的。我们将创建一个没有数据的GameLoop
结构体,并添加一个SharedLoopClosure
类型来简化f
和g
变量的类型。然后,我们将添加一个GameLoop
的实现,包含一个名为start
的方法,该方法接受Game
特质作为参数。请注意,特质是'static
,因为任何移动到“raf”闭包中的内容都必须是'static
。我们遵循之前使用的片段来设置我们的request_animation_frame
循环,关键变化在于内部,我们在更新后绘制,传递draw
函数的CanvasRenderingContext2d
。
这种简单游戏循环存在一个问题。通常,request_animation_frame
每秒运行 60 帧,但如果update
或draw
任一函数的执行时间超过 1/60 秒,它将减慢速度,使游戏运行得更慢。很久以前,我记得通过关闭桌面上的“Turbo”按钮来击败关卡,这使得以前不可能的挑战变得容易,因为游戏在较慢的速度下更容易玩。由于我们希望在处理器速度不同的情况下保持一致的游戏体验,我们将采取一个称为“固定”时间步的通用方法。
修复我们的时间步
你可能会注意到我们编写的update
函数没有接受perf
作为参数;实际上,它是未使用的。现在,想象一下尝试模拟一只狗在屏幕上奔跑,没有任何关于帧间时间间隔的知识。根据计算机和你的猜测,狗可能会悠闲地从左到右漫步,或者像子弹一样飞过去。我们可以做的是在每个更新时发送 delta 时间,这可以工作,但会很快变得复杂。相反,我们将假设每个 tick 的时间相同,即 1/60 秒,并在落后时多次调用update
以“赶上”。看起来是这样的:
图 3.4 – 固定步长游戏循环
这不是一个完美的解决方案;如果我们的游戏非常慢,它将陷入停滞,但应该足够满足我们的需求。这就是为什么我让我们创建了一个GameLoop
结构体——为了跟踪上一次更新的时间。我们将在GameLoop
结构体中添加两个字段:
const FRAME_SIZE: f32 = 1.0 / 60.0 * 1000.0;
pub struct GameLoop {
last_frame: f64,
accumulated_delta: f32,
}
这添加了一个表示帧长度的常量,转换为毫秒。我们将在last_frame
字段中跟踪上一帧何时被请求,并将累积一个 delta,总计自上次渲染以来的物理时间。这并不完全相同,正如你将在start
函数中实现该计数器时看到的那样。说到那个函数,我们将在该函数的开始处初始化一个可变的GameLoop
:
impl GameLoop {
pub async fn start(mut game: impl Game + 'static) ->
Result<()> {
let mut game_loop = GameLoop {
last_frame: browser::now()?,
accumulated_delta: 0.0,
};
...
这以适当的方式初始化了GameLoop
,使用now
作为上一帧的时间而不是0
,这样我们的循环就不会在第一次渲染之前执行数百万次更新。browser::now()
尚未实现,所以你需要将其添加到browser
模块中:
pub fn now() -> Result<f64> {
Ok(window()?
.performance()
.ok_or_else(|| anyhow!
("Performance object not found"))?
.now())
}
这只是一个围绕网络浏览器now
函数的包装器。如果你一直认真跟随,你可能会认识到这会导致编译错误。你需要将"Performance
"功能标志添加到web-sys
功能列表中,以引入该函数。
现在我们已经创建了一个游戏循环对象,在request_animation_frame
闭包内部,我们将添加我们的累加器:
*g.borrow_mut() = Some(browser::create_raf_closure(move
|perf: f64| {
game_loop.accumulated_delta += (
perf - game_loop.last_frame) as f32;
while game_loop.accumulated_delta > FRAME_SIZE {
game.update();
game_loop.accumulated_delta -= FRAME_SIZE;
}
game_loop.last_frame = perf;
game.draw(&browser::context().expect("Context should
exist"));
与上次相比,变化的是我们不再立即调用update
函数,而是计算perf
与request_animation_frame
函数开始执行回调函数的时间之间的差异。如果你记得,perf
是一个高精度时间戳。我们获取现在(在perf
中)和上一帧之间的差异,并将其添加到accumulated_delta
中。然后,我们将其与期望的FRAME_SIZE
(那是 1/60 秒)进行比较,如果有update
。然后我们从 delta 中减去帧大小。这一切的效果是什么?如果game.draw
执行时间过长,以至于我们无法在 1/60 秒内完成 1 帧,代码将运行额外的更新来赶上。
在这里举一个例子很有帮助。假设你在世界开始的时间0
开始玩游戏。当第一次执行request_animation_frame
回调时,它可能非常接近0
,可能低至1
毫秒,因为没有在第一帧上延迟。代码会将这个值加到accumulated_delta
上,然后与FRAME_SIZE
比较,发现积累的 delta 还不够,所以跳过update
。last_frame
值被存储(我们再次说它是1
),屏幕被绘制,然后调用request_animation_frame
。
第二次,perf
的值可能大约是第一帧的大小。我们将使用17
毫秒进行简单计算。所以perf
是17
;从它减去last_frame
,即1
,并将16
毫秒加到accumulated_delta
上。新的accumulated_delta
值是17
,所以游戏更新一次,accumulated_delta
减少到1
。游戏继续进行一次更新到一次绘制,直到出现问题。draw
调用需要40
毫秒!谁知道为什么——也许是一段意外的自动播放视频启动,消耗了资源。这无关紧要,因为accumulated_delta
激增到40
,这比2
帧大。现在,accumulated_delta
上的循环运行update
两次,丢弃一帧动画来补偿性能下降。这里要记住的重要事情是,它丢弃了一个draw但不是一个update,所以尽管玩家可能会看到一些视觉伪影,物理仍然可以正常工作。
重要提示
你可能会想知道额外的accumulated_delta
会发生什么,因为它不太可能是FRAME_SIZE
的精确倍数。更高级的游戏循环会将这个值传递给绘制,并使用它来在两个更新值之间进行插值。对于我们的游戏,我们不需要这个功能,只需将那个delta
滚动到下一帧。
重要提示
为什么使用f32
作为accumulated_delta
?你观察得真仔细!简短的版本是,因为我们可以。稍微长一点的版本是,我们只使用f64
,因为 JavaScript 使用 64 位的Number
类型来表示所有数字。如果我能,我会尽可能使用更小的值,以及整数,因为f64
的额外大小并不是真的必要,并且当它被重复使用时可能会对性能产生意外的拖累。
所以,这就是你的游戏循环——至少是它的“循环”部分。虽然现在它是可用的,但它不提供一种轻松加载我们资源的方法。虽然我们可以保持现状,并在开始游戏循环之前始终加载资源,但一个更干净的方法是将这个规则集成到游戏循环中。
加载资源
将我们的游戏循环扩展以处理加载资源,需要向我们的特质中添加一个函数,一个精确的async
函数。这将允许我们将目前被lib
中的spawn_local
包裹的所有异步代码放入一个返回包含Game
的Result
的函数中。你可以从向Game
特质添加该函数开始:
pub trait Game {
async fn initialize(&self) -> Result<Box<dyn Game>>;
fn update(&mut self);
fn draw(&self, context: &Renderer);
}
很不幸,这不能编译。async
特质函数还没有在稳定的 Rust 中实现,但幸运的是,我们可以使用一个 crate 来获取这个功能。将async-trait = "0.1.52"
添加到Cargo.toml
中,然后向特质添加以下属性宏:
#[async_trait(?Send)]
pub trait Game {
你还需要导入async_trait::async_trait
。async_trait
允许我们在特质中添加async
函数。我们可以使用它与?Send
特质一起,因为我们不需要我们的 futures 是线程安全的。现在,我们可以将其添加到游戏循环中:
impl GameLoop {
pub async fn start(game: impl Game + 'static) ->
Result<()> {
let mut game = game.initialize().await?;
....
就这样!游戏被异步初始化,第一行是Result
。请注意,传入的game
不再需要是可变的,因为我们没有在任何地方修改它。我们几乎准备好将我们的旧set_interval
集成到这个中,但我想在绘制周围做一点清理工作。
清洁绘图
目前,我们正在向绘制循环发送原始的CanvasRenderingContext2d
,带有所有那些尴尬的函数,例如draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh
。这可以工作,但看起来很糟糕,而且就像我们在browser
模块中所做的那样,我们可以使用一个包装器来将上下文的宽接口缩小到更小的一个,以适应我们的需求。我们将用我们自己的Renderer
对象替换传递CanvasRenderingContext2d
,该对象具有更易于使用的函数。
我们将首先在engine
中为我们的Renderer
创建一个结构:
pub struct Renderer {
context: CanvasRenderingContext2d,
}
这是一个简单的包装器,包含渲染上下文。现在,我们只需将两个实现方法添加到Renderer
结构中:
impl Renderer {
pub fn clear(&self, rect: &Rect) {
self.context.clear_rect(
rect.x.into(),
rect.y.into(),
rect.width.into(),
rect.height.into(),
);
}
pub fn draw_image(&self, image: &HtmlImageElement,
frame: &Rect, destination: &Rect) {
self.context
.draw_image_with_html_image_element_and_sw_and_sh_ and_dx_and_dy_and_dw_and_dh(
&image,
frame.x.into(),
frame.y.into(),
frame.width.into(),
frame.height.into(),
destination.x.into(),
destination.y.into(),
destination.width.into(),
destination.height.into(),
)
.expect("Drawing is throwing exceptions!
Unrecoverable error.");
}
}
这两个函数,clear
和draw_image
,都封装了context
函数,但使用更少的参数。我们不是传递四个参数和clear_rect
,而是传递clear
Rect
。我们不是传递那个极其长的函数名,而是传递draw_image
HtmlImageElement
和两个Rect
结构。目前,如果我们不能绘制,我们会使用expect
来 panic!我相信这应该返回Result
。
重要提示
到现在为止,这本书中有些代码你可能认为可以做得更好。试试看!我总是在跟随书籍时这样做,你没有理由不这样做。只是尽量记住你与书籍的不同之处。
当然,这两个函数都接受Rect
,但我们没有Rect
结构。让我们现在将Rect
添加到engine
中:
pub struct Rect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
现在,我们可以将绘制函数更改为接受Renderer
而不是CanvasRenderingContext2d
。因此,我们更新了特质:
#[async_trait(?Send)]
pub trait Game {
...
fn draw(&self, renderer: &Renderer);
}
然后,我们可以对循环进行修改。目前,我们在传递给create_raf_closure
的Closure
中创建context
。这个调用返回Result
,因此要访问context
,我们必须调用unwrap
或expect
。现在我们可以使用的更简洁的方法是在Closure
外部创建Renderer
,如下所示:
let mut game_loop = GameLoop {
last_frame: browser::now()?,
accumulated_delta: 0.0,
};
let renderer = Renderer {
context: browser::context()?,
};
...
*g.borrow_mut() = Some(browser::create_raf_closure(
move |perf: f64| {
...
game.draw(&renderer);
browser::request_animation_frame(f.borrow().as_ref(). unwrap());
}));
将这个移出request_animation_frame
闭包意味着我们不再需要使用expect
语法了——太好了!
将draw
修改为game.draw(&renderer)
的小改动将使我们的draw
函数更容易编写。我认为我们正在实现我们的目标,即通过改变代码使其更容易前进。让我们通过将我们的动画代码从lib
中取出并使用游戏循环来证明这一点。
集成游戏循环
我们已经编写了这个游戏循环,这很好,但现在是时候真正使用它了。记住,我们有 GameLoop
结构体,但它操作的是 Game
特质。因此,为了使用这个循环,我们需要实现这个特质。我们将在另一个模块 game
中实现它,我们将在 game.rs
中创建它,然后使用 lib.rs
中的 mod game
指令声明将其添加到库中。我们将从几个结构体开始:
use crate::engine::{Game, Renderer};
use anyhow::Result;
use async_trait::async_trait;
pub struct WalkTheDog;
#[async_trait(?Send)]
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
Ok(Box::new(WalkTheDog {}))
}
fn update(&mut self) {}
fn draw(&self, renderer: &Renderer) {}
}
确保添加 #[async_trait(?Send)]
注解,这允许你实现具有 async
函数的特质。只要你从 engine
中添加所需的 use
声明,就可以编译,因为 Game
按需实现了这个特质。它实际上什么都没做,但可以编译。initialize
函数可能看起来有点奇怪,因为我们正在取 self
并将其丢弃,以换取一个新的 WalkTheDog
结构体——而且是在堆上丢弃!我们这样做是为了在下一章中看到的一些更改,所以现在就请耐心等待。
现在,让我们将来自 lib.rs
的绘制代码移动到 draw
中,并在过程中更新它:
fn draw(&self, renderer: &Renderer) {
let frame_name = format!("Run ({}).png", self.frame +
1);
let sprite = self.sheet.frames.get(&frame_name).expect(
"Cell not found");
renderer.clear(Rect {
x: 0.0,
y: 0.0,
width: 600.0,
height: 600.0,
});
renderer.draw_image(
&self.image,
Rect {
x: sprite.frame.x.into(),
y: sprite.frame.y.into(),
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
},
Rect {
x: 300.0,
y: 300.0,
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
},
);
}
这只包含对 lib.rs
中代码的微小更改,尽管它肯定无法编译。对 context
的调用被替换为对 renderer
的调用,并且我们使用了新的 Rect
结构体。这无法编译,因为 self
没有包含 sheet
、frame
或 image
。我们需要将它们添加到 game
模块中,如下所示:
#[derive(Deserialize)]
struct SheetRect {
x: i16,
y: i16,
w: i16,
h: i16,
}
#[derive(Deserialize)]
struct Cell {
frame: SheetRect,
}
#[derive(Deserialize)]
pub struct Sheet {
frames: HashMap<String, Cell>,
}
pub struct WalkTheDog {
image: HtmlImageElement,
sheet: Sheet,
frame: u8,
}
在这里,我们将从 lib.rs
中序列化精灵图精灵的代码移动到 WalkTheDog
结构体中,并为 frame
、HtmlImageElement
和 Sheet
添加了字段。请注意,我们从 lib
中取出了 Rect
并将其重命名为 SheetRect
。这是从我们的精灵图中特定的矩形。在 game
中,我们也有一个 Rect
结构体。这是我们用作游戏域对象的矩形。这种重命名现在可能有些令人困惑,但它是为了区分这两个矩形,并且在我们继续前进时是有帮助的。
WalkTheDog
结构体具有使 draw
编译所需的字段,但它可能会让你对 initialize
提出疑问。具体来说,如果我们打算将我们的加载代码移动到 initialize
中,WalkTheDog
结构体是否真的总是有 HtmlImageElement
和 Sheet
?不,它不是。我们需要将这些字段转换为 Option
类型,并使 draw
函数考虑到它们:
pub struct WalkTheDog {
image: Option<HtmlImageElement>,
sheet: Option<Sheet>,
frame: u8,
}
我们可以使用 as_ref()
函数来借用 image
和 sheet
,然后使用 and_then
和 map
Option
函数干净地获取帧并绘制它:
fn draw(&self, renderer: &Renderer) {
let frame_name = format!("Run ({}).png", self.frame + 1);
let sprite = self
.sheet
.as_ref()
.and_then(|sheet| sheet.frames.get(&frame_name))
.expect("Cell not found");
renderer.clear(&Rect {
x: 0.0,
y: 0.0,
width: 600.0,
height: 600.0,
});
self.image.as_ref().map(|image| {
renderer.draw_image(
&image,
&Rect {
x: sprite.frame.x.into(),
y: sprite.frame.y.into(),
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
},
&Rect {
x: 300.0,
y: 300.0,
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
},
);
});
这很好——我们有一个什么也不绘制的游戏,但这没关系,因为我们的初始化代码仍然无法编译。让我们准备绘制,将 lib.rs
中的加载代码复制到游戏循环中的 initialize
函数。现在不要进行任何剪切和粘贴;我们将在最后清理 lib.rs
。Initialize
应该看起来像这样:
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
let sheet: Sheet = browser::fetch_json("rhb.json")
.await
.expect("Could not fetch rhb.json")
.into_serde()
.expect("Could not convert rhb.json into a
Sheet structure");
let image = engine::load_image("rhb.png")
.await
.expect("Could not load rhb.png");
Ok(Box::new(WalkTheDog {
image: Some(image),
sheet: Some(sheet),
frame: self.frame,
}))
}
...
那是一个很好的复制粘贴,但我们可以通过使用?
操作符使其更加简洁。这是改进后的版本:
async fn initialize(&self) -> Result<Box<dyn Game>> {
let sheet = browser::fetch_json(
"rhb.json").await?.into_serde()?;
let image =
Some(engine::load_image("rhb.png").await?);
Ok(Box::new(WalkTheDog {
image,
sheet,
frame: self.frame,
}))
}
看看那个函数有多小、多干净。我们只尝试了三次,但最终做到了。现在我们有了initialize
和draw
,我们可以编写update
函数。我们在lib.rs
中编写的版本使用了set_interval_with_callback_and_timeout_and_arguments_0
来动画化我们的红帽男孩,但那不再适用了。相反,update
函数需要跟踪经过的帧数,并在适当的时候前进。在原始代码中,我们每50
毫秒调用一次set_interval
回调。在这段新代码中,update
将每秒的 1/60,即16.7
毫秒被调用。因此,为了大约匹配动画,我们希望每三次更新就更新当前精灵帧;否则,我们的小 RHB 会跑得非常快。
如果你查看rhb.json
文件,你会看到Run
动画中有八个帧。如果我们想每 3 次更新前进一个精灵帧,这意味着完成动画需要 24 次更新。到那时,我们将想要回到开始处再次播放。所以,我们需要从update
函数中更新的帧计数计算精灵帧:
fn update(&mut self) {
if self.frame < 23 {
self.frame += 1;
} else {
self.frame = 0;
}
}
这与我们的当前draw
代码不兼容,因为它使用frame
来查找要渲染的精灵。当它查找不存在的Run (9).png
时,程序会崩溃。我们将更新draw
函数,从frame
获取精灵索引:
fn draw(&self, renderer: &Renderer) {
let current_sprite = (self.frame / 3) + 1;
let frame_name = format!("Run ({}).png",
current_sprite);
...
current_sprite
变量将从 1 循环到 8,然后再次循环。你信不过我?欢迎使用我们之前编写的log!
宏来检查我的工作;事实上,我鼓励你这么做。不是因为我很自大,而是因为总是对代码进行实验,而不是盲目地输入,总是好的。然后我们用那个数字来查找帧名。
完成这个任务后,我们现在有一个可以渲染到画布上的游戏循环,以及一个渲染我们跑步的 RHB 的游戏;我们只需要将其集成。我们将在engine
中的WalkTheDog
结构体定义下方添加一个普通的构造函数:
impl WalkTheDog {
pub fn new() -> Self {
WalkTheDog {
image: None,
sheet: None,
frame: 0,
}
}
}
没有什么特别之处——只是为了让创建游戏对象更容易。现在,对于你一直等待的时刻——整合所有这些更改的新主函数:
#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
console_error_panic_hook::set_once();
browser::spawn_local(async move {
let game = WalkTheDog::new();
GameLoop::start(game)
.await
.expect("Could not start game loop");
});
Ok(())
}
真的,就是这样——这就是全部。你创建一个本地 future,创建一个新的游戏,然后调用GameLoop::start(game).await
来启动它。你可以从lib.rs
中删除所有未使用的代码,例如额外的use
声明和当所有内容都在这里时我们定义的结构。看起来很棒!
我们为了达到这个目标修改了很多代码,但现在我们有一个带有正确循环的运行游戏。我们本可以在这里结束这一章,但如果代码实际上做了些新的事情,那会更好,不是吗?
添加键盘输入
大多数游戏都有某种形式的用户输入;否则,它们就不再是游戏了。在本节中,我们将开始监听键盘事件,并使用它们来控制我们的 RHB。这意味着将键盘输入添加到游戏循环中,并将其传递给update
函数。我们不会做的事情是进一步的重构。系统在这个阶段已经相当模块化,并且可以接受我们的新更改。
我们获取键盘事件的具体过程可能与你习惯的 Web 开发有所不同。在一个普通程序中,你会监听按键被按下——换句话说,按下然后释放——然后做一些事情,比如在按钮释放时更新屏幕。这与游戏不符,因为典型的玩家希望在按键按下时立即发生动作,并且希望它在按下期间持续。想象一下用箭头键在屏幕上移动。你期望在按下箭头键的瞬间开始移动,而不是在释放它之后。此外,传统的编程不考虑像同时按下“上”和“右”这样的情况。如果我们将这些作为两个独立的行为处理,我们将先向右移动,然后向上,然后再次向右,然后向上,就像我们在上楼梯一样。我们将监听每一个keyup
和keydown
事件,并将它们全部打包成一个keystate
,该状态存储了所有当前按下的键。然后我们将这个状态传递给update
函数,以便游戏可以确定如何处理所有当前按下的键。
重要提示
这种方法在游戏中很常见,但也带来一个缺点。如果你想只在按钮被按下时触发某些动作,比如开枪,你必须跟踪前一个更新是否有键被抬起,下一个更新是否有键被按下。因此,通过从事件驱动方法切换到全局键状态,我们失去了事件。幸运的是,这很容易重新创建。
要获取键盘事件,我们必须在canvas
上监听keydown
和keyup
事件。让我们在engine
中开始一个新的函数prepare_input()
:
fn prepare_input() {
let onkeydown = browser::closure_wrap(
Box::new(move |keycode: web_sys::KeyboardEvent| {})
as Box<dyn FnMut(web_sys::KeyboardEvent)>);
let onkeyup = browser::closure_wrap(Box::new(
move |keycode: web_sys::KeyboardEvent| {})
as Box<dyn FnMut(web_sys::KeyboardEvent)>);
browser::canvas()
.unwrap()
.set_onkeydown(Some(onkeydown.as_ref().unchecked_ref()));
browser::canvas()
.unwrap()
.set_onkeyup(Some(onkeyup.as_ref().unchecked_ref()));
onkeydown.forget();
onkeyup.forget();
}
小贴士
确保你在 HTML 文件中将canvas
元素设置一个tabIndex
属性;否则,它无法获得焦点并且无法处理键盘事件。
这就足够我们开始了。它应该看起来很熟悉,因为我们是以与load_image
和request_animation_frame
相同的方式设置Closure
对象的。我们必须确保在设置后对两个Closure
实例都调用forget
,这样它们就不会在设置后立即被释放,因为 Rust 应用程序中没有东西在持有它们。你还需要将KeyboardEvent
功能添加到web-sys
中,以便包含它。否则,这里没有你之前没见过的东西。它只是目前还没有做任何事情。
小贴士
与 Rust 中的大多数事物不同,如果你没有添加forget
调用,你不会在编译时遇到错误。你几乎会立即遇到 panic,而且并不总是伴随着有用的错误信息。如果你认为你已经设置了 JavaScript 的回调,并且正在遇到 panic,问问自己是否有什么东西在程序中保留了那个回调。如果没有,你可能忘记了添加forget
。
我们正在监听输入,所以现在我们需要跟踪所有这些输入。在这个函数中尝试将事件压缩到keystate
中很诱人,但这很麻烦,因为这个函数一次只处理一个keyup
或keydown
,并且对其他所有键一无所知。如果你想同时跟踪ArrowUp
和ArrowRight
被按下,你在这里无法做到。我们将做的是在游戏循环开始之前设置监听器一次,例如使用initialize
,然后处理每次更新中的所有新按键事件,更新我们的keystate
。这意味着需要将这些闭包的状态与传递给request_animation_frame
的闭包共享。是时候添加一个通道了。我们将在prepare_input
中创建一个unbounded
通道,这是一个如果你让它无限增长的话会无限增长的通道,然后返回它的接收器。我们将传递发送器到onkeyup
和onkeydown
,并将KeyboardEvent
发送到每个发送器。让我们看看这些变化:
fn prepare_input() -> Result<UnboundedReceiver<KeyPress>> {
let (keydown_sender, keyevent_receiver) = unbounded();
let keydown_sender = Rc::new(RefCell::new(keydown_sender));
let keyup_sender = Rc::clone(&keydown_sender);
let onkeydown = browser::closure_wrap(Box::new(move |keycode: web_sys::KeyboardEvent| {
keydown_sender
.borrow_mut()
.start_send(KeyPress::KeyDown(keycode));
}) as Box<dyn FnMut(web_sys::KeyboardEvent)>);
let onkeyup = browser::closure_wrap(Box::new(move |keycode: web_sys::KeyboardEvent| {
keyup_sender
.borrow_mut()
.start_send(KeyPress::KeyUp(keycode));
}) as Box<dyn FnMut(web_sys::KeyboardEvent)>);
browser::window()?.set_onkeydown(Some(onkeydown.as_ref().unchecked_ref()));
browser::window()?.set_onkeyup(Some(onkeyup.as_ref().unchecked_ref()));
onkeydown.forget();
onkeyup.forget();
Ok(keyevent_receiver)
}
函数现在返回Result<UnboundedReceiver<KeyPress>>
。UnboundedReceiver
和unbounded
都在futures::channel::mspc
模块中,并在文件顶部的use
声明中声明。我们使用unbounded
函数在第一行创建无界通道,然后创建keydown_sender
和keyup_sender
的引用计数版本,这样我们就可以在发送两个事件到同一个接收器的同时,将每个事件移动到它们各自的闭包中。请注意,unbounded
通道使用start_send
而不是send
。最后,我们将keyevent_receiver
作为Result
返回。你可能考虑有两个独立的通道,一个用于keyup
,一个用于keydown
,虽然我确信这是可以做到的,但我尝试了,发现这种方法更直接。
仔细观察,你可能会想知道KeyPress
是什么。实际上,你无法仅仅通过检查它来确定发生了什么类型的KeyboardEvent
。为了跟踪事件是keyup
还是keydown
,我们将这些事件包裹在一个我们将在engine.rs
中定义的枚举类型中:
enum KeyPress {
KeyUp(web_sys::KeyboardEvent),
KeyDown(web_sys::KeyboardEvent),
}
这种enum
方法意味着我们不需要管理两个通道。现在我们有一个函数可以监听并将所有我们的按键事件放入通道,我们需要编写第二个函数来从通道中获取所有这些事件并将它们减少到KeyState
。我们可以在engine
模块中这样做,如下所示:
fn process_input(state: &mut KeyState, keyevent_receiver: &mut UnboundedReceiver<KeyPress>) {
loop {
match keyevent_receiver.try_next() {
Ok(None) => break,
Err(_err) => break,
Ok(Some(evt)) => match evt {
KeyPress::KeyUp(evt) => state.set_released(&evt.code()),
KeyPress::KeyDown(evt) => state.set_pressed(&evt.code(), evt),
},
};
}
}
这个函数接受KeyState
和Receiver
,并通过从接收器中取出每个条目来更新state
,直到其为空。理论上,这看起来在接收器不断被填满的情况下可能会创建无限循环的可能性,但我无法通过正常手段(像疯子一样按键盘)做到这一点,如果有人决定编写一个脚本填充这个通道并破坏他们自己的游戏,那也是他们的自由。KeyState
必须作为mut
传递,这样我们就可以更新当前的状态,而不是在每次更新时从一个全新的状态开始。我们假设KeyState
已经存在而编写了这个函数,但我们也需要在engine
模块中创建它:
pub struct KeyState {
pressed_keys: HashMap<String, web_sys::KeyboardEvent>,
}
impl KeyState {
fn new() -> Self {
KeyState {
pressed_keys: HashMap::new(),
}
}
pub fn is_pressed(&self, code: &str) -> bool {
self.pressed_keys.contains_key(code)
}
fn set_pressed(&mut self, code: &str, event: web_sys::KeyboardEvent) {
self.pressed_keys.insert(code.into(), event);
}
fn set_released(&mut self, code: &str) {
self.pressed_keys.remove(code.into());
}
}
KeyState
结构体只是HashMap
的一个包装器,存储了KeyboardEvent.code
到其KeyboardEvent
的查找。如果code
不存在,则表示按键没有被按下。代码是键盘上物理按键的实际表示。你可以在 MDN Web Docs 上找到所有可用的KeyboardEvent
代码列表:mzl.la/3ar9krK
。
小贴士
当有疑问时,Mozilla 的 MDN Web Docs 是网上关于浏览器库的最佳资源。
我们已经创建了所需的库和结构来处理键盘输入,因此现在我们可以将其集成到我们的GameLoop
中。在我们开始循环之前,在start
函数中调用prepare_input
:
pub async fn start(mut game: impl Game + 'static) -> Result<()> {
let mut keyevent_receiver = prepare_input()?;
game.initialize().await?;
然后,我们将keyevent_receiver
移动到request_animation_frame
闭包中,并在每次更新时处理输入:
let mut keystate = KeyState::new();
*g.borrow_mut() = Some(browser::create_raf_closure(move |perf: f64| {
process_input(&mut keystate, &mut keyevent_receiver);
你可以看到,我们在request_animation_frame
闭包之前初始化了一个空的KeyState
,这样我们就可以从一个空的状态开始。现在每一帧都会调用我们的process_input
函数并生成一个新的KeyState
。这就是我们需要对我们游戏循环所做的所有更改,以跟踪KeyState
。唯一剩下的事情是将它传递给我们的Game
对象,以便可以使用它。一些游戏实现会将此存储为全局变量,但我们将只将其传递给Game
特质。我们将更新特质的update
函数以接受KeyState
:
pub trait Game {
...
fn update(&mut self, keystate: &KeyState);
...
现在,我们可以在每个循环中将KeyState
传递给update
函数:
while game_loop.accumulated_delta > frame_size {
game.update(&keystate);
game_loop.accumulated_delta -= frame_size;
}
最后,为了使我们的游戏能够编译,我们需要更新game
模块中的WalkTheDog::update
签名,以匹配:
#[async_trait(?Send)]
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
就这样!我们得到了一个处理键盘输入并将状态传递给我们的Game
的GameLoop
。我们花费了很多时间编写代码,使得我们可以编写游戏,但实际上我们还没有更新我们的游戏。我们可怜的小 RHB 仍然只在一个地方运行。他看起来很开心,但现在我们已经有了输入,我们为什么不移动他呢?
移动红帽男孩
移动游戏对象意味着跟踪位置而不是像你预期的那样硬编码它。我们将在engine
中创建一个Point
结构,它将为 RHB 保存一个x和y位置。在每次update
调用时,我们还将根据按下的哪个键为他计算一个速度。每个方向的大小都相同,所以如果同时按下ArrowLeft
和ArrowRight
,他将停止移动。在计算他的速度后,我们将使用这个数字更新他的位置。这应该足以让我们在屏幕上移动他。让我们先从向WalkTheDog
游戏结构体添加position
开始:
pub struct WalkTheDog {
image: Option<HtmlImageElement>,
sheet: Option<Sheet>,
frame: u8,
position: Point,
}
当然,Point
还不存在,所以我们将它在engine
中创建:
#[derive(Clone, Copy)]
pub struct Point {
pub x: i16,
pub y: i16,
}
注意,我们在这里使用整数,这样我们就不必在不需要时处理浮点数数学。虽然canvas
函数都接受f64
值,但这只是因为JavaScript
中只有一个数字类型,根据 MDN Web Docs (mzl.la/32PpIhL
),使用整数坐标的canvas
更快。你还需要更新WalkTheDog::new
函数以填写默认的position
。让我们现在使用0, 0
:
impl WalkTheDog {
pub fn new() -> Self {
WalkTheDog {
image: None,
sheet: None,
frame: 0,
position: Point { x: 0, y: 0 },
}
}
}
我承诺我会停止提醒你做这件事,但请确保你已经在文件顶部添加了crate::engine::Point
的use
声明。initialize
函数也需要更新以考虑position
。这实际上是我们为什么用Clone
和Copy
标记Point
的原因。这使得它能够复制到新的WalkTheDog
initialize
函数中,如下所示:
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
let json = browser::fetch_json("rhb.json").await?;
let sheet = json.into_serde()?;
let image =
Some(engine::load_image("rhb.png").await?);
Ok(Box::new(WalkTheDog {
image,
sheet,
position: self.position,
frame: self.frame,
}))
}
....
为了让position
有任何意义,我们需要更新draw
函数,使其真正被使用:
#[async_trait(?Send)]
impl Game for WalkTheDog {
...
fn draw(&self, renderer: &Renderer) {
....
self.image.as_ref().map(|image| {
renderer.draw_image(
&image,
&Rect {
x: sprite.frame.x.into(),
y: sprite.frame.y.into(),
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
},
&Rect {
x: self.position.into(),
y: self.position.into(),
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
},
);
});
}
}
确保你更新的是第二个Rect
而不是第一个。第一个Rect
是我们从精灵图中取出的切片。第二个是我们想要绘制它的位置。这将导致游戏有明显的改变,因为 RHB 现在在左上角。最后,我们将修改update
以根据在KeyState
中按下的哪个键来计算速度。我们将在更新当前帧之前添加这个,如下所示:
fn update(&mut self, keystate: &KeyState) {
let mut velocity = Point { x: 0, y: 0 };
if keystate.is_pressed("ArrowDown") {
velocity.y += 3;
}
if keystate.is_pressed("ArrowUp") {
velocity.y -= 3;
}
if keystate.is_pressed("ArrowRight") {
velocity.x += 3;
}
if keystate.is_pressed("ArrowLeft") {
velocity.x -= 3;
}
"ArrowDown
"和"ArrowUp
"等字符串等都在mzl.la/3ar9krK
中列出,尽管你也可以通过简单地记录按键时的代码来找出它们。你可以看到,如果按下"ArrowDown
",我们将增加y
,如果按下"ArrowUp
",我们将减少它,这是因为原点位于左上角,随着向下移动y
增加,而不是向上。注意,我们在这里没有使用if/else
。我们想要考虑每个按下的键,而不是在第一个按下的键上短路。接下来,我们根据速度调整位置:
if keystate.is_pressed("ArrowLeft") {
velocity.x -= 3;
}
self.position.x += velocity.x;
self.position.y += velocity.y;
回到浏览器,你现在可以使用箭头键来移动 RHB!如果他不动,确保你点击画布以将其聚焦。如果他仍然不动,而你确信你已经一切都正确,请在start
函数中添加一些log!
消息,并确保KeyState
正在创建,或者在update
函数中查看你是否真的得到了一个新的KeyState
。我们在这里覆盖了很多内容,如果你在跟随,很容易出错,但现在你有一个调试工具来找出问题。
小贴士
在某些浏览器上,当canvas
获得焦点时,它周围会有一条边框,点击后会出现。你可以通过添加样式outline: none
来移除它。
摘要
这是一章艰难、漫长且复杂的章节。我将引用亚伦·希莱加斯在其书中经常使用的一句话:“编程很难,你并不愚蠢。”有很多地方,一个小小的打字错误就能让你陷入困境,你可能不得不来回多次检查。这都是正常的——这是学习过程的一部分。我鼓励你在进入下一章之前,先尝试我们构建的框架,因为这是确保你理解所有代码的绝佳方式。
最后,我们取得了很大的成就。我们创建了一个游戏循环,它将以每秒 60 帧的速度在浏览器中运行,同时以固定步骤更新。我们设置了一个类似于 XNA 的游戏“引擎”,并将引擎关注点与游戏关注点分离。我们的浏览器界面被封装在一个模块中,这样我们就可以隐藏浏览器实现的一些细节。我们甚至处理了输入,使这个工作像真正的游戏引擎一样。我们在代码运行的同时完成了所有这些。
随着我们的前进,代码应该更容易处理,因为我们现在有明确的地方来放置东西。浏览器函数放在浏览器中,引擎函数放在引擎中,游戏放在游戏模块中,尽管你可能觉得这不是一个游戏,因为 RHB 不能跑、跳和滑动。
猜猜我们接下来要做什么?
第四章:第四章:使用状态机管理动画
在上一章中,我们创建了一个最小化的游戏引擎,允许我们移动主要角色并播放简单的动画,但它远非功能齐全。没有可以导航的世界,唯一播放的动画是跑步,红帽男孩(RHB)对任何物理都不做出反应。在这个时候,如果我们想重新命名我们的游戏,它将被称为红帽男孩和空旷的虚空。
虽然这可能是一个有趣的名字,但它不会让游戏变得有趣。最终,我们希望 RHB 在森林中追逐他的狗,那里有可以跳跃的平台和可以滑过的障碍,为了做到这一点,我们需要确保他可以滑动、跳跃和跑步。我们还需要确保他在做这些事情时看起来、表现和表现不同。
在本章中,我们将介绍一种常见的游戏开发模式来管理所有这些,即状态机,通过if
语句实现。
我们将涵盖以下主题:
-
介绍状态机
-
管理动画
-
为遛狗添加状态
-
空闲
,运行
,滑动
, 和跳跃
动画
到本章结束时,你将能够使用状态机在动画之间干净地切换,同时始终播放正确的动画。
技术要求
本章中没有新的 crate 或其他技术要求。本章的源代码可在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_4
找到。
查看以下视频以查看代码的实际应用:bit.ly/35sk3TC
介绍状态机
游戏、Web 应用程序,甚至是加密货币矿工,都必须管理系统的状态。毕竟,如果系统现在没有做正确的事情,如果没有当前状态,那么它不是在运行,对吗?状态也是分形的。在我们的游戏中,我们有playing
状态和game over
状态。一旦我们添加菜单项,我们将有更多的状态。同时,我们的 RHB 也有状态:他在跑步、滑动、跳跃、死亡和死亡。让我们说昏迷,这比黑暗要好一些。
重点是我们的游戏正在做很多事情,并且维护着一个包含许多子状态的大型游戏状态。当应用程序从一个状态移动到另一个状态时,系统的规则会改变。例如,当 RHB 在跑步时,空格键可能会让他跳跃,但当他正在跳跃时,按下空格键不会做任何事情。规则是你不能在已经跳跃时跳跃。你可以通过一个包含许多值或布尔值的大型结构来维持这种状态,例如jumping = true
,在 Rust 程序中,你可能像这样将其存储在枚举类型中:
enum RedHatBoyState {
Jumping,
Running,
Sliding,
}
这在小程序中效果相当不错,但对于更大的程序,有两件事你需要管理。第一,我已经暗示过了,可能存在关于状态之间转换的规则。也许你不能直接从 Jumping
转换到 Sliding
,但 enum
无法阻止这一点。第二是,除了每个状态的规则不同之外,经常在状态之间的 转换 发生事情,比如播放音效或更新分数;为此,你需要一个状态机。
定义状态机
关于状态机,最令人困惑的事情可能是命名,因为存在状态机、有限状态机、状态模式等等,所有这些通常被程序员交替使用。因此,为了清晰起见,让我们这样定义它们:
-
状态机:一个系统状态的模型,表示为一系列状态及其之间的转换
-
trait
对象,您可以在以下链接中找到:doc.rust-lang.org/book/ch17-03-oo-design-patterns.html
。它相当不错,但不是 Rust 的惯用语法,我们不会使用它。
状态机既帮助我们保持对系统在头脑中的心理模型,又防止我们在代码中犯愚蠢的错误,例如当 RHB 跳跃时播放跑步动画。当然,缺点是你需要理解状态机,所以让我们来解决这个问题。我们将使用 RHB 作为我们的例子。RHB 可以是 Running、Idle、Jumping、Sliding、Falling 或 KnockedOut。我们可以使用状态 转换表 来列出这些:
转换表目前只有三列,分别是起始状态、引起转换的事件以及转换到的状态。事件与转换的不同之处在于,事件是导致系统发生转换的原因,而转换是在状态变化期间发生的事情。
这是一个微妙的不同之处,有时它会被交替使用,因为名称通常会相同。让我们通过一个状态转换来澄清这一点。RHB 从 Idle
状态开始,在那里他站立并 run
:
当移动到 Running
状态时,我们在转换过程中实际上做了一些事情。具体来说,我们开始向右移动;在 x
方向上增加速度。你可以在表中命名这个转换:
虽然这是正确的,但通常我们不会费心命名转换和事件,因为它们变得冗余。虽然我们可以继续添加到这个表中,但我们也可以用几种类型的图来模拟状态机。我偏爱简单的圆圈和线条,其中圆圈是状态,线条是转换。
![图 4.1 – 状态机图
图 4.1 – 状态机图
这个图表是之前表格的详细版本,所有条目都已填写。它从Idle状态开始,通过Run事件过渡到Running状态。从那里,它可以有几个方向。如果玩家滑动,它可以进入Sliding状态;如果玩家跳跃,它可以进入Jumping状态。这两种状态最终都会在滑动或跳跃结束后返回到Running状态。Running、Sliding和Jumping都可以在撞到东西时过渡到Falling状态。
这确实导致图表中间有很多转换。最后,当下落结束时,Falling状态通过End事件过渡到KnockedOut状态。如果你熟悉这种类型的图表,你可能会指出,我本可以使用超状态来包含Running、Jumping和Sliding,并使用一个事件将所有这些转换到Falling。你是对的,但我们就不会在我们的实现中关注这一点了。
你可能会问,所有这些有什么好处?这真的符合我们在上一章中提到的最小架构吗?首先回答第二个问题,答案是,嗯……也许?我发现状态机帮助我把属于一起的代码放在一起,而不是像使用简单的enum
时那样在我的代码库中散布match
语句。这并不意味着我们不会使用那些match
语句;它们只是会集中在一个地方。
我还发现它符合我对代码工作方式的思维模型,并且它有助于防止错误,因为你根本无法执行一个无效的操作,因为这个操作在那个特定状态下不可用。坦白说,状态机无论我们是否对其进行建模都存在,如果我们能够在代码中对其进行建模,而不是让它意外出现,那么它会更干净。所以,这些都是好处,这就是为什么我认为它适合我们的最小架构。现在,是时候实现它了。
使用类型实现
面向对象(OO)状态模式通常作为策略模式的一种变体来实现,其中在运行时根据各种转换替换实现相同状态接口的不同对象。图表看起来大致如下:
图 4.2 – 状态模式
在模式的面向对象(OO)版本中,enum
,我们可以用它以比传统对象更清晰的方式枚举状态。第二个是泛型类型,我们将用它来将每个状态建模为类型状态。
重要提示
我最初编写的原始状态机实现主要基于 Ana Hobden(又名 Hoverbear)这篇优秀的文章,她在hoverbear.org/blog/rust-state-machine-pattern/
上发表了这篇文章。虽然这本书不再使用该模式,但我鼓励你阅读它以获取另一种方法。
类型状态模式
类型状态是将对象的状态嵌入其类型中的高级名称。它的工作方式是,你有一个具有一个泛型参数的泛型结构,该参数表示状态。然后,每个状态都将有可以返回新状态的方法。因此,与它们在图 4.2中所示的情况一样,每个状态都有自己的方法来返回新状态。图 4.2中的状态可能看起来像这样:
图 4.3 – 类型状态模式
在这个图中,State<GenericStateOne>
有一个next
方法,它消耗self
并返回State<GenericStateTwo>
。同时,State<GenericStateTwo>
只有一个update
方法,它接受一个可变借用self
。这意味着如果你尝试在State<GenericStateTwo>
上调用next
,编译器会捕获你。在传统的 OO 模式中,所有状态都必须处理所有相同的方法,因为它们共享一个接口,所以这种防御是不可能的。通常,这意味着实现你实际上不关心的方法,然后返回一个错误状态或Self
,并在运行时进行调试。
此外,我们可以使用mod
关键字和 Rust 的隐私规则来确保无法创建任何无效状态。我们可以通过保持State
的内部私有,使其无法直接构造,来确保无法从GenericStateOne
移动到GenericStateTwo
而不调用next
方法。这被称为使非法状态不可表示,这是一种确保你不会在程序中犯错的绝佳方法。
重要提示
我追踪到“使非法状态不可表示”的表述是来自 Yaron Minsky (blog.janestreet.com/effective-ml-revisited/
);然而,这种做法和表述很可能比那还要早。
类型状态可能会让人感到害怕,因为它们既是一个新概念,也是新术语,所以如果你感到有点困惑,请不要担心。
小贴士
Rust 中关于类型状态有很多很好的信息。有一个来自 Strange Loop 的 Will Crichton 的精彩演讲(https://youtu.be/bnnacleqg6k?t=2015),以及docs.rust-embedded.org/book/static-guarantees/typestate-programming.htm
和cliffle.com/blog/rust-typestate/
上的博客。
如果你想要暂时忘记所有关于泛型和类型理论的知识,它们可以总结如下:
-
对象的每个状态都由一个单独的结构体表示。
-
你只能通过该结构体上的方法从一个状态推进到另一个状态。
-
你可以使用隐私规则保证只能创建有效的状态。
剩下的只是细节。
最后,我们需要一个 enum
来 持有 我们的类型状态。每个状态都是泛型的,所以继续我们之前的例子,任何将与我们的状态机交互的结构体都需要持有 要么 State<GenericStateOne>
或 State<GenericStateTwo>
。为了做到这一点,我们可能需要将包含的结构体也做成泛型的,然后每次状态改变时都创建包含结构体的新版本,或者将泛型对象包裹在一个 enum
中。
我们将使用 enum
,因为它阻止了类型状态的泛型性质在整个程序中传播,使得类型状态成为一个实现细节。我们将编写 Rust 非常擅长的那种状态机。让我们开始吧。
管理动画
我们将创建状态机来管理不同的动画。具体来说,当 RHB 不在移动时,它是 Idle
状态,但当它在移动时,它是 Running
状态。当他跳跃时,他是 Jumping
状态。你明白这个意思。
这些不同的 RHB 状态对应于使用状态机管理的不同动画。我们首先创建带有状态机的 RHB,然后将其集成到我们的当前应用程序中。我们将从代表 RHB 的结构体开始,让编译器错误驱动进一步的开发。这有时被称为 编译器驱动开发,尽管它不是一个正式的方法,如 测试驱动开发。它可以在具有强大类型系统和优秀编译器错误的语言中工作得非常好,如 Rust。让我们从如何表示 RHB 开始。
RedHatBoy
结构体将包含状态机、精灵表和图像,因为最终它将自行绘制:
struct RedHatBoy {
state_machine: RedHatBoyStateMachine,
sprite_sheet: Sheet,
image: HtmlImageElement,
}
重要提示
所有这些代码都属于 game
模块。这意味着你可以将它放在 game.rs
文件中,或者如果你愿意,可以将其放在一个单独的文件中,并使用 mod
关键字将其引入 game
模块。我会把这个决定留给你。
当然,这不会工作,因为你还没有创建状态机。你确实有来自 第三章 的 Sheet
结构体,创建游戏循环。让我们创建 RedHatBoyStateMachine
:
#[derive(Copy, Clone)]
enum RedHatBoyStateMachine {
Idle(RedHatBoyState<Idle>),
Running(RedHatBoyState<Running>),
}
看到我们之前讨论的 enum
,可能仍然不清楚为什么我们要使用它,因为我们将会创建所有这些类型状态结构。还不存在的 RedHatBoyState
是一个包含另一个类型的泛型类型,其中这些类型代表各种状态。那么,为什么还需要冗余的 enum
?因为我们希望能够在不使用堆或动态分派的情况下轻松地在状态之间切换。让我们想象我们以以下方式定义了 RedHatBoy
结构体:
struct RedHatBoy {
state: RedHatBoyState<Idle>,
sprite_sheet: Sheet,
}
现在状态被固定到一个状态。当然,我们可以用以下方式定义:
struct RedHatBoy<T> {
state: RedHatBoyState<T>,
sprite_sheet: Sheet,
}
但是当然,现在RedHatBoy
也必须是一个泛型类型。你可以使用Box<dyn State>
而不使用enum
来使这可行,但这不是很方便,并且需要在每个状态上实现相同的方法,所以我们将坚持使用enum
。我必须承认我不喜欢类型中的这种重复,比如*Idle*(RedHatBoyState<*Idle*>)
,但我们会看到,随着状态机的实现,enum
包装器变得极其有用。确保enum
也是Copy,Clone
,原因你很快就会看到。
重要提示
如果你对这个感兴趣,《Rust 编程语言》有一章描述了如何以传统的面向对象方式实现状态模式。有趣的是,他们最终放弃了它,转而使用enum
。你可以在这里找到:bit.ly/3hBsVd4
。
当然,这段代码仍然无法编译,因为我们还没有创建那些状态,也没有创建RedHatBoyState
x
。这就是我所说的编译器驱动开发。我们可以从创建RedHatBoyState
开始:
mod red_hat_boy_states {
use crate::engine::Point;
#[derive(Copy, Clone)]
pub struct RedHatBoyState<S> {
context: RedHatBoyContext,
_state: S,
}
#[derive(Copy, Clone)]
pub struct RedHatBoyContext {
frame: u8,
position: Point,
velocity: Point,
}
}
所有与单个状态相关的代码都将放入其自己的模块red_hat_boy_states
中,这样我们就可以只公开game
模块其余部分所需的方法。这将使得意外创建一个状态而不使用提供的方法变得不可能,因此,不可能意外地执行无效的转换。从RedHatBoyState<Idle>
转换到RedHatBoyState<Running>
的唯一方法将通过RedHatBoyState<Idle>
上的方法来实现。重要的是RedHatBoyState
和RedHatBoyContext
都是公开的,但它们的成员是私有的,这样我们就可以按预期使用它们。
在新模块中,RedHatBoyState
是一个简单的泛型类型,它包含_state
,这个字段永远不会被读取,因此使用了下划线,以及RedHatBoyContext
。现在,RedHatBoyContext
是一个包含所有状态共有数据的结构。在这种情况下,那就是正在渲染的帧、位置和速度。我们需要它以便状态转换可以修改 RHB 的状态。将所有这些放入red_hat_boy_states
模块意味着我们没有改变编译器错误信息。我们需要将那个模块导入到game
模块中,使用use self::red_hat_boy_states::*;
,你可以在game
模块的任何地方添加它。这让我们前进了一步,但如果我们查看下面的编译器输出,我们还没有完成:
error[E0412]: cannot find type 'Idle' in this scope
--> src/game.rs:19:25
|
19 | Idle(RedHatBoyState<Idle>),
| ^^^^ not found in
this scope
对于Running(RedHatBoyState<Running>)
也有相应的enum
变体。Idle
和Running
这两个状态都不存在。我们可以在red_hat_boy_states
模块内部轻松创建这两个状态,注意这两个也必须是Clone
:
#[derive(Copy, Clone)]
struct Idle;
#[derive(Copy, Clone)]
struct Running;
状态之间的转换
恭喜!您为 RHB 创建了两个状态。但这...什么也没做。还有一些东西缺失。首先,我们不能从Idle
状态转换到Running
状态,而且当它们不在转换过程中时,这些状态实际上并不做任何事情。让我们现在处理一个转换。我们将在RedHatBoyState<Idle>
上添加一个方法,从Idle
状态转换到Running
状态:
mod red_hat_boy_states {
....
impl RedHatBoyState<Idle> {
pub fn run(self) -> RedHatBoyState<Running> {
RedHatBoyState {
context: self.context,
_state: Running {},
}
}
}
这是从Idle
状态到Running
状态的转换,而run
方法就是魔法发生的地方。这只是一个函数,它接受一个RedHatBoy<Idle>
状态并将其转换为RedHatBoy<Running>
状态,目前它不会改变任何RedHatBoyContext
数据。那么,你可能想知道,这有什么魔法?
这意味着要从Idle
状态转换到Running
状态,你可以使用run
,但也意味着你不能从Running
状态转换回Idle
状态,这是有道理的,因为游戏不允许这种行为。该函数还接受mut self
,这意味着当它被调用时,它会消耗当前状态。这意味着如果你想在转换到Running
状态后保留Idle
状态,你必须克隆它,如果你这样做,你很可能真的想这么做。
你也不能直接创建Running
状态,因为它的数据成员是私有的,这意味着你不能不小心创建那个状态。你也不能创建Idle
状态,这是一个问题,因为它是起始状态。我们稍后会解决这个问题,但首先,让我们深入了解我们将如何通过状态机与状态交互。
管理状态机
初始时,我们可能会倾向于通过在RedHatBoyStateMachine
enum
上添加方法来实现我们的状态机,如下所示:
#[derive(Copy, Clone)]
enum RedHatBoyStateMachine {
Idle(RedHatBoyState<Idle>),
Running(RedHatBoyState<Running>),
}
impl RedHatBoyStateMachine {
fn run(self) -> Self {
match self {
RedHatBoyStateMachine::Idle(state) =>
RedHatBoyStateMachine::Running(state.run()),
_ => self,
}
}
}
这并不糟糕,但这意味着我们的状态机上的每个方法都可能需要匹配RedHatBoyStateMachine
enum
的当前变体。然后,它将根据转换或self
返回新的变体,当转换当前无效时。换句话说,虽然如果我们对Running
状态调用run
,编译器会报错,但如果我们对当前变体为Running
的RedHatBoyStateMachine
调用run
,编译器不会报错。这种错误,即错误地在不正确的状态上调用run
,正是我们试图通过类型状态避免的。我们费尽心思编写这些类型状态,只是为了在每个RedHatBoyStateMachine
enum
的方法上立即放弃其中的一个好处。
不幸的是,我们无法完全摆脱这个问题,因为我们正在使用enum
来包含我们的状态。我们无法像使用泛型结构那样在enum
的变体上实现方法,如果我们打算用enum
包装状态,我们就必须匹配变体。我们可以做的是通过减少在状态中操作的方法数量来减少这种错误的可能性。具体来说,我们不会在enum
上调用run
,而是创建一个接受Event
的transition
函数。这看起来像以下代码:
#[derive(Copy, Clone)]
enum RedHatBoyStateMachine {
Idle(RedHatBoyState<Idle>),
Running(RedHatBoyState<Running>),
}
pub enum Event {
Run,
}
impl RedHatBoyStateMachine {
fn transition(self, event: Event) -> Self {
match (self, event) {
(RedHatBoyStateMachine::Idle(state),
Event::Run) => {
RedHatBoyStateMachine::Running(state.run())
}
_ => self,
}
}
}
我们用另一个enum
解决了enum
引起的问题!这非常Rusty。在这种情况下,我们创建了一个名为Event
的enum
来表示可能发生在我们机器上的每一个事件,并用名为transition
的方法替换了名为run
的方法。
因此,我们不会有很多小的方法,如 run、jump 等,我们将有一个名为transition
的方法和许多Event
变体。这如何改进事情?因为当我们想要添加转换时,我们只需要更新一个match
语句,而不是可能添加多个小的match
语句。记住,这个函数接受mut self
,这意味着调用transition
将消耗self
并返回一个新的RedHatBoyStateMachine
,就像run
方法在RedHatBoyState<Idle>
上做的那样。
使用 Into 编写整洁的代码
我们实际上可以使用From
特质来改进这个方法的易用性。如果你不熟悉,From
特质是 Rust 的一个特性,它允许我们定义如何从一个类型转换为另一个类型。在你的类型上实现From
特质也会实现Into
特质,这将提供一个into
方法,使得类型之间的转换变得容易。
我们知道,如果我们有RedHatBoyState<Running>
,它将转换为RedHatBoyStateMachine::Running
变体,如果我们通过实现From
特质来编写转换,我们将能够用into
调用替换包装。这虽然说了很多话,但代码却很少,所以以下就是From
特质的实现样子:
impl From<RedHatBoyState<Running>> for RedHatBoyStateMachine {
fn from(state: RedHatBoyState<Running>) -> Self {
RedHatBoyStateMachine::Running(state)
}
}
这可以放在RedHatBoyStateMachine
实现下方。它定义了如何从RedHatBoy<Running>
转换为RedHatBoyStateMachine
,并且这与我们在transition
方法中编写的少量代码相同。因为我们现在有了这个,我们可以使那个方法更加简洁,如下所示:
impl RedHatBoyStateMachine {
fn transition(self, event: Event) -> Self {
match (self, event) {
(RedHatBoyStateMachine::Idle(state),
Event::Run) => state.run().into(),
_ => self,
}
}
...
将RedHatBoyStateMachine::Idle::Running(state.run)
这样的调用替换为into
不仅更美观、更简洁;它还意味着如果run
改变为返回不同的状态,只要有一个从状态到RedHatBoyStateMachine
enum
的From
特质被编写,transition
方法就可以保持不变。这是一个使我们的代码更加灵活的小改动。
有点奇怪的是,我们称之为状态机的RedHatBoyStateMachine
enum
是因为我们通常不会将枚举类型与行为相关联,但这个方法就是为什么我们称之为机器。我们使用enum
来持有各种泛型状态,我们使用向enum
添加方法的能力来使其使用起来更加方便。各种状态知道如何从一个状态转换到另一个状态,机器知道何时进行转换。
集成状态机
现在我们已经构建了一个状态机,尽管它只有两个状态,但我们实际上需要用它来做些事情。回想一下我们当前的游戏,让 RHB 在一个无意义的虚空中奔跑。我们将想要改变它,使得 RHB 从左角开始,当用户按下 右箭头键 时开始奔跑。换句话说,他们将从一个 Idle
状态过渡到 Running
状态。当这种情况发生时,我们还想确保显示适当的动画。
我们将从将 RedHatBoy
放入 WalkTheDog
游戏开始:
pub struct WalkTheDog {
image: Option<HtmlImageElement>,
sheet: Option<Sheet>,
frame: u8,
position: Point,
rhb: Option<RedHatBoy>,
}
...
impl WalkTheDog {
pub fn new() -> Self {
WalkTheDog {
image: None,
sheet: None,
frame: 0,
position: Point { x: 0, y: 0 },
rhb: None,
}
}
}
由于 RedHatBoy
包含一个精灵图集,所以现在 RHB
必须是一个 Option
类型。由于精灵图集直到在 initialize
中加载图像后才可用,我们必须将 rhb
设置为 Option
类型。我们希望在 initialize
函数中初始化机器,为此,我们将为 Idle
状态创建一个方便的 new
方法:
mod red_hat_boy_states {
use crate::engine::Point;
const FLOOR: i16 = 475;
...
impl RedHatBoyState<Idle> {
pub fn new() -> Self {
RedHatBoyState {
context: RedHatBoyContext {
frame: 0,
position: Point { x: 0, y: FLOOR },
velocity: Point { x: 0, y: 0 },
},
_state: Idle {},
}
}
...
由于 Idle
是初始状态,所以它将是唯一获得 new
函数的状态,正如之前提到的。我们还引入了一个名为 FLOOR
的常量,它标记了屏幕的底部,当 RHB 跳跃时,他将落在那里。
我在这里将其展示为好像它定义在 red_hat_boy_states
模块的顶部。现在,在 Game
的 initialize
方法中,我们仍然有一个编译错误,因为我们还没有在游戏中设置 RedHatBoy
。我们可以在加载精灵图集之后立即这样做,并且我们会保留两个精灵图集副本;这不是因为我们想要两个副本,而是因为我们将在成功用新代码替换旧代码后删除所有旧代码。你可以在这里看到这些更改:
#[async_trait(?Send)]
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
let sheet: Option<Sheet> = browser::fetch_json(
"rhb.json").await?.into_serde()?;
let image = Some(engine::load_image(
"rhb.png").await?);
Ok(Box::new(WalkTheDog {
image: image.clone(),
sheet: sheet.clone(),
frame: self.frame,
position: self.position,
rhb: Some(RedHatBoy::new(
sheet.clone().ok_or_else(|| anyhow!
("No Sheet Present"))?,
image.clone().ok_or_else(|| anyhow!
("No Image Present"))?,
)),
}))
}
...
由于 Rust 的借用规则,我们在这里不得不修改大量的代码。我们的意图是 clone
sheet
和 image
,并将这些传递给 RedHatBoy::new
方法。然而,如果我们这样做,我们还需要在为 WalkTheDogStruct
上的 image
和 sheet
字段设置值时克隆 image
和 sheet
。为什么?因为 image: image
这一行是一个移动操作,之后无法访问。这就是移动后的借用错误。因此,我们克隆 image
和 sheet
,并将克隆的实例移动到 WalkTheDog
中。然后,在创建 RedHatBoy
时,我们再次克隆它们。
对于 sheet
也是如此。我们还需要在最初分配 sheet
时明确指出 sheet
的类型,因为编译器已经无法推断出类型了。幸运的是,这是一个中间步骤;我们正在解决编译错误,并最终将这段代码缩减到我们实际需要的样子。我们目前还不能这样做,因为我们用两个编译错误替换了一个!
之前,当我们创建 WalkTheDog
时,rhb
字段没有被填充,所以这没有编译通过。为了将 rhb
字段设置为一个值,我们假设存在一个 RedHatBoy::new
方法,但实际上它不存在,所以这也没有编译通过。我们还传递了即将存在的构造函数克隆的 sheet
和 image
。由于 Sheet
类型目前不支持 clone
,这也导致了编译错误。我们需要修复这两个编译错误才能继续前进。
在我们继续之前,我想指出我们如何在每个clone
调用中使用ok_or_else
构造,然后是?
运算符。RedHatBoy
不需要持有Option<Sheet>
或Option<HtmlImageElement>
,所以它的构造函数将接受Sheet
和HtmlImageElement
。调用ok_or_else
将Option
转换为Result
,如果值不存在,?
将返回initialize
方法中的Error
。这防止了代码的其余部分需要不断验证Option
类型的存在,因此代码将更加简洁。Option
类型很棒,但任何时候你都可以用实际值替换处理Option
类型。
两个编译器错误中,最容易修复的是sheet
没有实现clone
。在 Rust 社区中,很多人在所有公共类型上都派生了Clone
,虽然在这本书中我不会遵循这种做法,但将Clone
添加到Sheet
及其引用的类型中并没有任何理由,如下所示。记住,Sheet
位于engine
模块中:
#[derive(Deserialize, Clone)]
pub struct SheetRect {
pub x: i16,
pub y: i16,
pub w: i16,
pub h: i16,
}
#[derive(Deserialize, Clone)]
pub struct Cell {
pub frame: SheetRect,
}
#[derive(Deserialize, Clone)]
pub struct Sheet {
pub frames: HashMap<String, Cell>,
}
现在,我们只剩下一个编译器错误,RedHatBoy
没有new
函数,所以让我们为RedHatBoy
结构体创建一个impl
块,并定义它,如下所示:
impl RedHatBoy {
fn new(sheet: Sheet, image: HtmlImageElement) -> Self {
RedHatBoy {
state_machine: RedHatBoyStateMachine::Idle(
RedHatBoyState::new()),
sprite_sheet: sheet,
image,
}
}
}
这创建了一个处于Idle
状态的RedHatBoy
新实例。我们还在initialize
函数中加载了sprite_sheet
和image
,并将它们传递给这个构造函数。恭喜!我们的代码编译成功了!
绘制 RedHatBoy
不幸的是,这仍然没有做什么。RedHatBoy
从未被绘制!我们想要的接口是调用self.rhb.draw()
并看到 RHB 绘制空闲动画。我们还想在按下右箭头时调用run
函数,看到 RHB 奔跑。
让我们从在RedHatBoy
上实现draw
开始。我们将创建一个模拟WalkTheDog
中绘制函数的绘制函数,只使用RedHatBoyState
中共享的RedHatBoyContext
。以下代码是作为impl RedHatBoy
块的一部分编写的:
impl RedHatBoy {
...
fn draw(&self, renderer: &Renderer) {
let frame_name = format!(
"{} ({}).png",
self.state_machine.frame_name(),
(self.state_machine.context().frame / 3) + 1
);
let sprite = self
.sprite_sheet
.frames
.get(&frame_name)
.expect("Cell not found");
renderer.draw_image(
&self.image,
&Rect {
x: sprite.frame.x.into(),
y: sprite.frame.y.into(),
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
},
&Rect {
x: self.state_machine.context()
.position.x.into(),
y: self.state_machine.context()
.position.y.into(),
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
},
);
}
}
这几乎与我们的 RHB(RedHatBoy)正在运行的draw
函数中已有的代码完全相同。我们不需要总是使用还不存在的frame_name
函数。
我们还从context()
获取position
和frame
,另一个还不存在的函数。同样,我们将让编译器引导我们创建这两个函数;编译器驱动开发再次发挥作用!RedHatBoyStateMachine
enum
需要提供一个返回RedHatBoyContext
和frame_name
的方法。我们可以添加以下实现:
impl RedHatBoyStateMachine {
...
fn frame_name(&self) ->&str {
match self {
RedHatBoyStateMachine::Idle(state) =>
state.frame_name(),
RedHatBoyStateMachine::Running(state) =>
state.frame_name(),
}
}
fn context(&self) ->&RedHatBoyContext {
match self {
RedHatBoyStateMachine::Idle(state)
=>&state.context(),
RedHatBoyStateMachine::Running(state)
=>&state.context(),
}
}
}
我承认我不喜欢这两种方法,曾考虑创建一个特质,让各种状态实现作为替代方案。经过一番思考,我决定这更简单,因为如果你不匹配每个enum
变体,Rust 编译器将会失败,所以我愿意接受这些重复的case语句。
frame_name
和 context
方法都委托给当前活动的 state
来获取所需的数据。在 frame_name
的情况下,这将是一个返回给定状态在 rhb.json
中动画名称的方法,正如在每个状态上定义的那样。context
方法尤其奇怪,因为我们总是为每个状态返回相同的字段,并且总是这样,因为该数据在所有状态之间共享。这需要一种泛型实现,我们将在稍后编写。一个练习是使用宏简化这些函数,但在这里我们不会这样做。
重要提示
你可能已经注意到,这一行 self.state_machine.context().position.x
违反了self
应该只与 state_machine
(它的朋友)通信,但相反,它通过 context
与 position
通信。这种方式将 RedHatBoy
与 RedHatBoyContext
的内部结构耦合在一起,这可以通过在 state machine
上添加 position_x
和 position_y
的获取器来避免,这些获取器将委托给 context
,而 context
又会委托给 position
。Demeter 法则是设置值时的一个很好的指导原则,你应该几乎总是遵循它来处理可变数据,但在这个案例中,数据是不可变的。我们无法通过这个获取器更改上下文,违反 Demeter 法则的缺点并不那么相关。我不认为有必要创建更多的委托函数只是为了避免违反一个任意指南,但如果它成为一个问题,我们总是可以更改它。有关此信息的更多信息,请参阅 wiki.c2.com/?LawOfDemeter
。
再次遵循编译器,我们将 RedHatBoy
上的 draw
方法中的错误移动到了 RedHatBoyStateMachine
中,因为没有任何状态有 frame_name
或 context
方法。在这两个方法中,frame_name
更直接,所以我们将首先实现它。它是一个获取 rhb.json
文件中帧名称的获取器,并且对于每个状态都是不同的,所以我们将把这个方法放在每个状态上,如下所示:
mod red_hat_boy_states {
use crate::engine::Point;
const FLOOR: i16 = 475;
const IDLE_FRAME_NAME: &str = "Idle";
const RUN_FRAME_NAME: &str = "Run";
impl RedHatBoyState<Idle> {
...
pub fn frame_name(&self) -> &str {
IDLE_FRAME_NAME
}
}
...
impl RedHatBoyState<Running> {
pub fn frame_name(&self) -> &str {
RUN_FRAME_NAME
}
}
}
我们添加了两个常量,IDLE_FRAME_NAME
和 RUN_FRAME_NAME
,分别对应于我们的精灵图集 Idle
和 Run
部分的帧名称。然后我们在 RedHatBoyState<Idle>
上创建了一个新的方法 frame_name
,以及一个全新的实现 RedHatBoyState<Running>
,它也包含一个 frame_name
方法。
值得考虑的是,我们是否可以使用特质对象(bit.ly/3JSyoI9
)而不是我们的 enum
来表示 RedHatBoyStateMachine
,这可能是可行的。我已经尝试过它,但没有找到令人满意的解决方案,但我鼓励你试一试。如果你自己尝试代码,你会从这本书中学到更多。
现在我们已经处理了 frame_name
方法,我们想要添加一个 context
方法。这个方法将为每个状态做同样的事情,返回上下文,并且我们可以为它们都写一个通用的,就像这里展示的:
mod red_hat_boy_states {
....
#[derive(Copy, Clone)]
pub struct RedHatBoyState<S> {
context: RedHatBoyContext,
_state: S,
}
impl<S> RedHatBoyState<S> {
pub fn context(&self) -> &RedHatBoyContext {
&self.context
}
}
...
这是 Rust 的一项相当酷的特性。由于我们有一个泛型结构体,我们可以在泛型类型上写方法,它将适用于所有类型。最后,还有一个编译器错误,在 draw
函数中引用上下文中的帧或位置字段。这些字段是私有的,但只要 RedHatBoyContext
是一个不可变类型,我们就可以将它们公开,如下所示:
mod red_hat_boy_states {
...
#[derive(Copy, Clone)]
pub struct RedHatBoyContext {
pub frame: u8,
pub position: Point,
pub velocity: Point,
}
...
最后,我们需要在 WalkTheDog#draw
函数中调用这个方法。你可以在这个,诚然有些尴尬的一行中添加它,就在 draw
函数的末尾:
fn draw(&self, renderer: &Renderer) {
...
self.rhb.as_ref().unwrap().draw(renderer);
如果你已经成功跟上了,你应该会看到以下屏幕:
![图 4.4 – RHBs
图 4.4 – RHBs
在顶部,我们有我们旧的、永不停歇的 RHB,而在底部,我们的新 RHB 正在静止不动。新版本功能更少;我们退步了,但为什么?这为我们接下来要做的事情做好了准备,就是移动它并改变动画。说到动画,RHB 的“空闲”版本目前什么都没做,因为frame
从未改变。当 RHB 处于空闲状态时,它会缓慢呼吸地站立,所以让我们开始吧,好吗?
更新 RHB
我们的 RedHatBoy
结构体将有一个 update
函数,它将转而委托给状态机上的 update
函数。这是一个新方法,因为每个状态都需要更新,以便推进动画。我们将从 WalkTheDog
的 update
中调用 RedHatBoy
的 update
。这有很多更新,但实际上只是委托:
#[async_trait(?Send)]
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
....
if self.frame < 23 {
self.frame += 1;
} else {
self.frame = 0;
}
self.rhb.as_mut().unwrap().update();
}
}
impl RedHatBoy {
...
fn update(&mut self) {
self.state_machine = self.state_machine.update();
}
}
impl RedHatBoyStateMachine {
...
fn update(self) -> Self {
match self {
RedHatBoyStateMachine::Idle(mut state) => {
if state.context.frame < 29 {
state.context.frame += 1;
} else {
state.context.frame = 0;
}
RedHatBoyStateMachine::Idle(state)
}
RedHatBoyStateMachine::Running(_) => self,
}
}
}
在 WalkTheDog
的 update
函数中,我们只在 update
函数的末尾添加了一行新代码:
self.rhb.as_mut().unwrap().update();
这很酷,因为 rhb
是 Option
,我们稍后会修复这个问题。我们向 RedHatBoy
struct
的 update
方法添加了另一个小功能,它只是通过状态机的 update
函数更新 state_machine
。这一行,以及其他类似的行,是为什么状态机需要是 Copy
的原因。如果不是,那么因为 update
通过 mut self
参数消耗 self
,你就必须使用类似 Option
的东西将 self
移入 update
,然后再重置它。通过使一切成为 Copy
,你将获得一个更加方便的 update
函数。
最后,行为的主体在RedHatBoyStateMachine#update
函数中。在这里,我们匹配self
并在可变的state
参数上更新当前帧,然后返回一个新的Idle
状态,并带有更新后的帧的移动context
。不幸的是,这段代码无法编译;context
不是一个公共数据成员,所以你不能分配它。现在,我们将继续使context
公共,但你应该感到烦恼。记得我之前提到的 Demeter 法则。获取不可变数据值是一回事,而设置可变值则是另一回事。这种耦合可能会导致未来的真正问题。我们现在不会修复它,所以请继续使context
公共,但我们将非常密切地关注这段代码。
到目前为止,如果你查看WalkTheDog
的update
和RedHatBoyStateMachine
的update
,你会看到相似之处。一个是更新左上角的奔跑 RHB,另一个是更新左下角的空闲 RHB。现在是时候开始合并这两个对象了。让我们继续这样做。
添加奔跑状态
关于状态,有一点需要记住的是,无论你是否实现状态机,它们都存在。虽然我们还没有在RedHatBoyState<Running>
中实现任何内容,但Running
状态目前存在于WalkTheDog
中;RHB 现在正四处奔跑在虚空中!我们只需要将细节移动到我们的状态机中,这样我们作为程序员就可以真正看到状态以及它们作为一个连贯单元所做的事情。此外,我们还将停止有一个在屏幕左下角孤独奔跑的悲伤男孩。
我们可以通过修改RedHatBoyStateMachine
中的update
函数来快速实现这一点,使其与Idle
中的版本匹配,只是奔跑动画的帧数不同。如下所示:
impl RedHatBoyStateMachine {
...
fn update(self) -> Self {
match self {
...
RedHatBoyStateMachine::Running(mut state) => {
if state.context.frame < 23 {
state.context.frame += 1;
} else {
state.context.frame = 0;
}
RedHatBoyStateMachine::Running(state)
}
}
}
}
现在,状态机在理论上能够绘制奔跑动画,但我们还没有编写任何代码来触发这种转换。另外缺少的东西可能更加微妙。Running
动画有23
帧,而Idle
动画有29
帧。如果我们要在24
帧时从Idle
转换到Running
,游戏就会崩溃。
最后,我想我们都可以同意,这里存在的这种重复可以改进。这两个函数之间唯一的区别是帧数。所以,我们有一些事情要做:
- 重构重复的代码。
更新context.frame
的代码存在一个名为update
函数在context
上重复操作的代码异味。为什么不将这个函数移动到RedHatBoyContext
中呢?如下所示:
const IDLE_FRAMES: u8 = 29;
const RUNNING_FRAMES: u8 = 23;
...
impl RedHatBoyStateMachine {
fn update(self) -> Self {
match self {
RedHatBoyStateMachine::Idle(mut state) => {
state.context =
state.context.update(IDLE_FRAMES);
RedHatBoyStateMachine::Idle(state)
}
RedHatBoyStateMachine::Running(mut state)
=> {
state.context = state.context.update(
RUNNING_FRAMES);
RedHatBoyStateMachine::Running(state)
}
}
}
}
mod red_hat_boy_states {
...
impl RedHatBoyContext {
pub fn update(mut self, frame_count: u8) ->
Self {
if self.frame < frame_count {
self.frame += 1;
} else {
self.frame = 0;
}
self
}
}
RedHatBoyContext
现在有一个update
函数,该函数增加帧数,当总帧数达到时循环回0
。注意它如何以与我们转换相同的方式工作,消耗self
,并返回一个新的RedHatBoyContext
,尽管实际上整个时间都是同一个instance
。这给了我们与我们在其他地方使用的相同类型的功能性接口。总帧数随着每个状态的变化而变化,所以我们将其作为参数传递,使用常量以提高清晰度。
- 修复 Demeter 法则违反。
看看每个match
语句的两边,它们几乎是相同的,都在以我们不喜欢的早期方式修改context
。现在是解决这个问题的好时机,我们可以通过再次将RedHatBoyState<S>
的字段设为私有,并在各自的RedHatBoy
状态实现中创建新方法来实现,如下所示:
mod red_hat_boy_states {
...
const IDLE_FRAMES: u8 = 29;
const RUNNING_FRAMES: u8 = 23;
....
impl RedHatBoyState<Idle> {
....
pub fn update(&mut self) {
self.context = self.context.update(
IDLE_FRAMES);
}
}
impl RedHatBoyState<Running> {
...
pub fn update(&mut self) {
self.context = self.context.update(
RUNNING_FRAMES);
}
}
}
好了!这样更好。context
不再是不恰当的公共的,每个单独的状态都处理自己的更新。它们之间的唯一区别是它们使用的常量,将它们与实现本身捆绑在一起是很合适的。说到这里,确保将RUNNING_FRAMES
和IDLE_FRAMES
常量移动到red_hat_boy_states
模块中。
我们需要修改RedHatBoyStateMachine
上的update
方法,以便在每个状态上调用这个新方法:
impl RedHatBoyStateMachine {
....
fn update(self) -> Self {
match self {
RedHatBoyStateMachine::Idle(mut state) =>
{
state.update();
RedHatBoyStateMachine::Idle(state)
}
RedHatBoyStateMachine::Running(mut state)
=> {
state.update();
RedHatBoyStateMachine::Running(state)
}
}
}
}
在update
中,每个臂现在都会更新状态,然后返回状态。这里有一些可疑的重复;我们稍后会再次查看。
- 在每次
update
时移动 RHB。
如果 RHB 要在运行状态下运行,它需要尊重速度。换句话说,更新动画化帧,但不移动,所以让我们将其添加到RedHatBoyContext
的update
方法中:
fn update(mut self, frame_count: u8) -> Self {
...
self.position.x += self.velocity.x;
self.position.y += self.velocity.y;
self
}
当然,RHB 现在不会移动,因为我们没有改变速度。这很快就会到来。
- 确保在状态之间转换时帧计数重置为
0
。
在我们的状态机中,游戏对象可能发生两种类型的更改。有一种是在状态没有变化时发生的更改。这就是update
所做的工作,目前这些都是在RedHatBoyStateMachine
中编写的。还有在转换时发生的更改,这些是在定义为类型类方法的转换函数中发生的。
我们已经通过run
方法从Idle
状态转换到Running
状态,并且我们可以在转换时确保重置帧率。这是一个你可以在这里看到的小改动:
impl RedHatBoyContext {
...
fn reset_frame(mut self) -> Self {
self.frame = 0;
self
}
}
impl RedHatBoyState<Idle> {
....
pub fn run(self) -> RedHatBoyState<Running> {
RedHatBoyState {
context: self.context.reset_frame(),
_state: Running {},
}
}
}
RedHatBoyContext
增加了一个名为reset_frame
的函数,该函数将它的帧计数重置为0
并返回自身。通过返回自身,我们可以将调用链在一起,这很快就会派上用场。run
方法也演变为在RedHatBoyContext
上调用reset_frame()
并使用新的context
版本在新的RedHatBoyState
结构体中。
- 在转换时开始运行。
现在我们已经通过在转换时重新启动动画来防止崩溃,让我们开始在转换时向前跑步。这将非常短:
mod red_hat_boy_states {
....
const RUNNING_SPEED: i16 = 3;
...
impl RedHatBoyContext {
...
fn run_right(mut self) -> Self {
self.velocity.x += RUNNING_SPEED;
self
}
}
impl RedHatBoyState<Idle> {
pub fn run(self) -> RedHatBoyState<Running> {
RedHatBoyState {
context: self.context.reset_frame()
.run_right(),
_state: Running {},
}
}
}
我们在RedHatBoyContext
上又增加了一个名为run_right
的方法,它只是简单地将前进速度添加到速度中。同时,我们在转换中链式调用了run_right
(看!)方法。别忘了将RUNNING_SPEED
常量添加到模块中。
- 在右箭头上开始跑步。
最后,我们实际上需要在按下ArrowRight
按钮时调用这个事件。在这个时候,我们可以跟随在WalkTheDog
实现中我们正在做这件事的地方:
impl Game for WalkTheDog {
...
if keystate.is_pressed("ArrowRight") {
velocity.x += 3;
self.rhb.as_mut().unwrap().run_right();
}
}
impl RedHatBoy {
...
fn run_right(&mut self) {
self.state = self.state.transition(
Event::Run);
}
}
这将现在开始我们的 RHB 跑步,以至于他会直接跑出屏幕!
图 4.5 – 这可能是个问题
在这一点上,我们可以重新建立月球漫步,将 RHB 带回屏幕上,但这并不真正服务于游戏的目的。你可以创建一个事件,每次更新时重置水平速度,就像当前代码所做的那样,或者你可以跟踪当按键抬起时移除一些速度。第二个感觉更好,但将迫使我们编写一些事件,并可能从Running
状态转换到Idle
状态。不,我们将采取第三种方法:忽略它并刷新!在我们的实际游戏中,我们不需要向后移动,也不需要停止,所以我们不会这样做。让我们不要花更多的时间编写代码,我们最终会删除它。说到这一点。
- 删除原始代码。
现在新的改进版 RHB 正在移动,是时候移除WalkTheDog
中对工作表、元素、框架等的所有引用,基本上是任何不是RedHatBoy
struct
的东西:
pub struct WalkTheDog {
rhb: Option<RedHatBoy>,
}
而不是用无休止的删除来让你感到无聊,我简单地说你可以删除所有不是rhb
的字段,并跟随编译器错误来删除其余的代码。当你完成时,WalkTheDog
会变得非常短,就像它应该的那样。至于箭头键,你只需要担心ArrowRight
键,以及向右移动。
提示
正如我说的,我们不会在这里恢复向上、向下或向后的移动,但你当然可以考虑通过扩展状态机来恢复向后行走的功能。这样做将帮助你内化这里的教训,并节省你不断刷新的麻烦。
所以,现在 RHB 可以在屏幕上跑步,但这并不有趣。让我们添加滑动。
转换到滑动
从跑步到滑动的转换将涉及添加一个新的滑动状态,这样我们就能看到滑动动作,同时检查滑动何时完成并转换回跑步状态。这意味着滑动将在update
函数上有自己的变体。我们可以从在下箭头上添加滑动开始,将其处理得就像跑步一样。我们将快速完成这个过程,因为大部分都是熟悉的。让我们首先在WalkTheDog
的update
方法中添加滑动:
impl Game for WalkTheDog {
fn update(&mut self, keystate: &KeyState) {
...
if keystate.is_pressed("ArrowDown") {
self.rhb.as_mut().unwrap().slide();
}
}
}
是时候跟随编译器了。RedHatBoy 没有滑动方法,所以让我们添加它,如下所示:
impl RedHatBoy {
...
fn slide(&mut self) {
self.state_machine = self.state_machine.transition(
Event::Slide);
}
}
通过 Event::Slide
事件进行转换不存在。根本就没有 Event::Slide
,所以让我们添加这些:
enum Event {
....
Slide,
}
impl RedHatBoyStateMachine {
fn transition(self, event: Event) -> Self {
match (self, event) {
...
(RedHatBoyStateMachine::Running(state),
Event::Slide) => state.slide().into(),
_ => self,
}
}
...
上一段代码块中没有新内容。当 RHB 是 Running
状态时,它可以通过 Event::Slide
事件和 slide
方法转换到 Sliding
状态,而 slide
方法在 RedHatBoyState<Running>
类型状态上不存在。这与其他从 Idle
到 Running
的转换非常相似。
为了继续编译器,我们需要向 RedHatBoyState<Running>
类型状态添加一个 slide
方法,如下所示:
mod red_hat_boy_states {
...
impl RedHatBoyState<Running> {
...
pub fn slide(self) -> RedHatBoyState<Sliding> {
RedHatBoyState {
context: self.context.reset_frame(),
_state: Sliding {},
}
}
}
RedHatBoyState<Running>
上的 slide
方法将状态转换为 RedHatBoyState<Sliding>
,仅在 context
上调用 reset_frame
确保滑动动画从帧 0
开始播放。我们还在 slide
方法上调用 into
,这意味着我们需要为 RedHatBoyState<Sliding>
创建一个变体,并为其创建一个 From
实现,如下所示:
enum RedHatBoyStateMachine {
...
Sliding(RedHatBoyState<Sliding>),
}
impl From<RedHatBoyState<Sliding>> for RedHatBoyStateMachine {
fn from(state: RedHatBoyState<Sliding>) -> Self {
RedHatBoyStateMachine::Sliding(state)
}
}
在这一点上,你会在 RedHatBoyStateMachine
的 frame_name
、context
和 update
方法的错误上看到错误,因为它们对应的 match
调用没有为新的 Sliding
变体添加情况。我们可以通过向这些 match
语句添加情况来修复这个问题,这将模仿其他情况:
impl RedHatBoyStateMachine {
...
fn frame_name(&self) -> &str {
match self {
...
RedHatBoyStateMachine::Sliding(state) =>
state.frame_name(),
}
}
fn context(&self) ->&RedHatBoyContext{
match self {
...
RedHatBoyStateMachine::Sliding(state)
=> &state.context(),
}
}
fn update(self) -> Self {
match self {
RedHatBoyStateMachine::Sliding(mut state) => {
state.update();
RedHatBoyStateMachine::Sliding(state)
}
}
}
}
再次,我们用另一个编译器错误替换了一个。没有 Sliding
状态,它也没有我们假设它应有的方法。我们可以通过填充它,添加一些常数来修复这个问题:
mod red_hat_boy_states {
const SLIDING_FRAMES: u8 = 14;
const SLIDING_FRAME_NAME: &str = "Slide";
...
#[derive(Copy, Clone)]
struct Sliding;
impl RedHatBoyState<Sliding> {
pub fn frame_name(&self) -> &str {
SLIDING_FRAME_NAME
}
pub fn update(&mut self) {
self.context = self.context.update(
SLIDING_FRAMES);
}
}
}
如果你查看这段代码,你会发现它与我们的现有运行代码非常相似。如果你一直跟着做,你会看到 RHB 开始在地面上滑动,直到他滑过屏幕的右边缘:
![图 4.6 – 安全
图 4.6 – 安全
阻止 RHB 滑动与之前我们所做的方法略有不同。我们需要做的是确定滑动动画何时完成,然后立即过渡回运行状态,无需任何用户输入。我们将从检查代表我们的机器的 enum
的 update
方法中动画是否完成开始,然后从滑动状态创建一个新的过渡。我们可以通过修改 RedHatBoyStateMachine
的 update
方法,在滑动分支更新后进行检查,如下所示:
fn update(self) -> Self {
match self {
...
RedHatBoyStateMachine::Sliding(mut state) => {
state.update(SLIDING_FRAMES);
if state.context().frame >= SLIDING_FRAMES {
RedHatBoyStateMachine::Running(
state.stand())
} else {
RedHatBoyStateMachine::Sliding(state)
}
}
}
}
这还不能编译,因为 stand
还未定义,并且因为 SLIDING_FRAMES
在 red_hat_boy_states
模块中。你可能认为我们可以将 SLIDING_FRAMES
公开并定义一个 stand
方法,或者我们可以将 SLIDING_FRAMES
移动到 game
模块。这两种方法都可以工作,但我认为现在是时候更全面地查看我们的 update
方法了。
match
语句的每一臂都会更新当前状态,然后返回一个新的状态。在Running
和Idle
的情况下,总是相同的状态,但在Sliding
的情况下,有时会是Running
状态。结果是update
是一个转换,只是有时会转换回起始状态。在状态图中,它看起来像这样:
图 4.7 – 从滑动到运行
如果我们对此要严格一些,我们可以说,当Updating
状态接收到Update事件时,它可以转换回Sliding或Running。这是一个状态至少在概念上存在,但我们实际上不必在我们的代码中创建它。
Sliding
状态上的update
实际上最好建模为一个转换,因为它是一个最终返回状态的函数。想到这一点,这正是update
方法中的其他臂所做的事情!是的,它们永远不会转换到另一个状态,但每个分支都会调用update
然后返回一个状态。所以,在我们将Sliding
添加到update
方法之前,让我们重构以使update
对其他两个状态都是转换。
由于我们正在使用编译器驱动开发,我们将更改update
方法,使其看起来像update
已经是一个转换:
pub enum Event {
...
Update,
}
impl RedHatBoyStateMachine {
fn transition(self, event: Event) -> Self {
match (self, event) {
(RedHatBoyStateMachine::Idle(state),
Event::Run) => state.run().into(),
(RedHatBoyStateMachine::Running(state),
Event::Slide) => state.slide().into(),
(RedHatBoyStateMachine::Idle(state),
Event::Update) => state.update().into(),
(RedHatBoyStateMachine::Running(state),
Event::Update) => state.update().into(),
_ => self,
}
}
...
fn update(self) -> Self {
self.transition(Event::Update)
}
}
通过这些更改,我们将Update
转换为Event
,并在transition
方法中为match
添加了两个额外的臂。这两个臂的工作方式与其他转换相同:它们在类型状态上调用一个方法,然后将状态转换为带有From
特质的RedHatBoyStateMachine
枚举。你现在得到的编译器错误可能有点奇怪;它看起来像这样:
error[E0277]: the trait bound 'RedHatBoyStateMachine: From<()>' is not satisfied
--> src/game.rs:155:83
|
155 | (RedHatBoyStateMachine::Idle(state), Event::Update) => state.update().into(),
| ^^^^ the trait 'From<()>' is not implemented for 'RedHatBoyStateMachine'
你可能预计错误会提到update
方法没有返回任何内容,但请记住,所有 Rust 函数都会返回一些内容;它们只是在没有返回其他内容时返回Unit
。所以这个错误在告诉你没有方法可以从()
或Unit
转换为RedHatBoyStateMachine
类型的值。这不是我们想要修复的;我们想要使两个状态上的update
调用都返回新状态。那些更改是下一个:
mod red_hat_boy_states {
impl RedHatBoyState<Idle> {
...
pub fn update(mut self) -> Self {
self.context = self.context.update(
IDLE_FRAMES);
self
}
}
...
impl RedHatBoyState<Running> {
...
pub fn update(mut self) -> Self {
self.context = self.context.update(RUNNING_FRAMES);
self
}
}
...
这些更改很小但很重要。RedHatBoyState<Idle>
和RedHatBoyState<Running>
的update
方法现在都返回Self
,因为即使状态没有改变,这些仍然是返回新状态的类型状态方法。它们现在也接受mut self
而不是&mut self
。如果你可变借用它,就不能返回self
,所以这个方法停止编译。更重要的是,这意味着这些方法不会进行不必要的复制。它们在调用时获取self
的所有权,然后返回它。所以,如果你担心由于额外的复制而导致的优化问题,你不必担心。
现在,我们只剩下一个编译器错误,我们之前已经见过:
the trait 'From<red_hat_boy_states::RedHatBoyState<red_hat_boy_states::Idle>>' is not implemented for 'RedHatBoyStateMachine'
我们没有实现从 Idle
状态转换回 RedHatBoyStateMachine enum
的转换。这与其他我们编写的类似,实现了 From<RedHatBoyState<Idle>>
,如下所示:
impl From<RedHatBoyState<Idle>> for RedHatBoyStateMachine {
fn from(state: RedHatBoyState<Idle>) -> Self {
RedHatBoyStateMachine::Idle(state)
}
}
记住,这些 From
特质的实现并不在 red_hat_boy_states
模块中。red_hat_boy_states
模块了解各个状态,但不知道 RedHatBoyStateMachine
。这不是它的职责。
现在我们已经重构了代码,我们的小 RHB 不再滑动。相反,他有点坐下来,因为 Sliding
状态没有处理 Update
事件。现在让我们修复这个问题。
在滑动和返回之间转换
我们使用类型状态模式为我们的各个状态的一部分原因是为了在出错时得到编译器错误。例如,如果我们处于 Running
状态时调用 run
,它甚至无法编译,因为没有这样的方法。有一个地方这个规则不适用,那就是 RedHatBoyStateMachine
enum
上的 transition
方法。如果你用一个 RedHatBoyStateMachine
变体和一个没有匹配的 Event
变体对调用 transition
,它将返回 Self
。
正是因为这个原因,我们的 RHB 才会坐着。他过渡到 Sliding
状态,然后停止更新,永远停留在同一个状态。我们将通过添加对 Update
事件的匹配来解决这个问题,然后,正如你所猜到的,跟随编译器来实现滑动动画。
这是从添加到转换方法开始的,如下所示:
impl RedHatBoyStateMachine {
fn transition(self, event: Event) -> Self {
match (self, event) {
...
(RedHatBoyStateMachine::Sliding(state),
Event::Update) => state.update().into(),
_ => self,
}
}
这个匹配就像其他匹配一样;我们在 Sliding
和 Update
上进行匹配并调用 update
。就像之前一样,我们会得到一个错误:
the trait 'From<()>' is not implemented for 'RedHatBoyStateMachine'
Sliding
状态仍然有一个更新方法,该方法不会返回一个状态。这在我们当前的设置中是不可行的,但并不像在其他两个状态中那样简单,只需让 update
方法返回 Self
。
记住,从 Sliding
状态的 update
方法可以返回两种可能的状态:Sliding
和 Running
。这如何与我们的当前设置相匹配?我们需要做的是让 update
返回一个 SlidingEndState
enum
,它可以要么是 Sliding
,要么是 Running
,然后我们将实现一个 From
特质,将这个转换成 RedHatBoyStateMachine
的适当变体。这很难解释,所以让我们看看它是如何工作的。我们可以修改 RedHatBoyState<Sliding>
上的 update
方法,使其像本节开头所提出的:
mod red_hat_boy_states {
...
impl RedHatBoyState<Sliding> {
...
pub fn update(mut self) -> SlidingEndState {
self.context = self.context.update(
SLIDING_FRAMES);
if self.context.frame >= SLIDING_FRAMES {
SlidingEndState::Complete(self.stand())
} else {
SlidingEndState::Sliding(self)
}
}
}
}
我们已经将原本考虑放入 RedHatBoyStateMachine
的 update
方法中的代码移动到了 RedHatBoyState<Sliding>
的 update
方法中。从概念上讲,这是有道理的;状态应该知道自己的行为。在每次更新时,我们更新 context
,然后检查动画是否完成,使用 if self.context.frame >= SLIDING_FRAMES
。如果动画完成,我们将返回这个新 enum
的一个变体,而这个变体目前还不存在:SlidingState
。SlidingState
变体可以是 Complete
或 Sliding
。
重要提示
确实有点奇怪,这里的update
方法没有返回另一个状态,可能意味着我们并没有使用一个纯状态类型方法。一个替代方案可能是从update
返回下一个Event
,并将其发送回RedHatBoyStateMachine
上的transition
方法的调用。这种实现最终看起来非常奇怪,因为状态返回的Events
只被RedHatBoyStateMachine
使用,在red_hat_boy_states
模块中其他地方没有引用。不管update
返回的奇怪值让你有多不舒服,我都鼓励你尝试其他方法。也许你的方法比我的更好!
再次跟随编译器,我们有两个明显的问题:没有stand
方法,也没有SlidingEndState
enum
。我们可以在我们刚刚编写的代码旁边处理这两个问题,如下所示:
impl RedHatBoyState<Sliding> {
...
pub fn stand(self) -> RedHatBoyState<Running> {
RedHatBoyState {
context: self.context.reset_frame(),
_state: Running,
}
}
}
pub enum SlidingEndState {
Complete(RedHatBoyState<Running>),
Sliding(RedHatBoyState<Sliding>),
}
转换到Running
的唯一副作用是我们再次在context
上调用reset_frame
。记住,这必须在每次转换时都做,否则程序可能会尝试用frame
来动画化新状态,这是无效的,会导致崩溃。所以,我们将在每次转换时将帧重置回0
。
这又让我们遇到了一个需要修复的编译器错误。这次,它是这样的:
the trait 'From<SlidingEndState>' is not implemented for 'RedHatBoyStateMachine'
仔细注意那个源特质。它不是来自任何一个状态,而是来自中间的SlidingEndState
。我们将像之前一样解决这个问题,使用From
特质,但我们需要使用match
语句从enum
中提取它:
impl From<SlidingEndState> for RedHatBoyStateMachine {
fn from(end_state: SlidingEndState) -> Self {
match end_state {
SlidingEndState::Complete(running_state) =>
running_state.into(),
SlidingEndState::Sliding(sliding_state) =>
sliding_state.into(),
}
}
}
在这里,我们通过end_state
进行匹配,从enum
中获取实际的State
,然后再次调用该状态的into
方法以到达RedHatBoyStateMachine
。虽然有点模板化,但这样做使得转换更容易。
现在我们有了它!现在运行游戏,你会看到 RHB 短暂地滑行然后又弹回到跑步状态。现在我们已经添加了三个动画,是时候处理WalkTheDog
实现中的这些丑陋的线条了:self.rhb.as_mut().unwrap().slide()
。
我们将rhb
视为Option
类型,并不是因为它真的会变成None
,而是因为我们还没有在WalkTheDog``struct
初始化之前拥有它。一旦WalkTheDog
初始化,rhb
就再也不会是None
了,因为系统的状态已经改变。幸运的是,我们现在有一个工具来处理这个问题,那就是我们熟悉的状态机!
我看到的每一件小事
WalkTheDog
可以处于两种状态,Loading
或Loaded
,在初始化之后。幸运的是,我们在编写我们的GameLoop
时已经考虑到了这一点。记住GameLoop
从initialize
返回Result<Game>
;我们目前总是返回Ok(WalkTheDog)
。如果我们让WalkTheDog
成为一个枚举并返回我们游戏的不同状态会怎样?这意味着WalkTheDog
将是一个状态机,有两个状态,而initialize
将成为转换!这正是我们要做的。将WalkTheDog
修改为不再是struct
而是枚举,如下所示:
pub enum WalkTheDog {
Loading,
Loaded(RedHatBoy),
}
这太棒了;现在一切都坏了!哎呀!我们需要调整WalkTheDog
的实现来考虑两种变体。首先,我们将更改WalkTheDog
上的initialize
函数:
#[async_trait(?Send)]
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
match self {
WalkTheDog::Loading => {
let json = browser::fetch_json(
"rhb.json").await?;
let rhb = RedHatBoy::new(
json.into_serde::<Sheet>()?,
engine::load_image("rhb.png").await?,
);
Ok(Box::new(WalkTheDog::Loaded(rhb)))
}
WalkTheDog::Loaded(_) => Err(anyhow!
("Error: Game is already initialized!")),
}
}
...
记得在第三章中,创建游戏循环,我们让这个函数返回Game
?这就是原因!为了确保initialize
只调用一次,initialize
必须在它的变体上匹配self
,如果我们调用initialize
两次,我们将通过anyhow!
返回一个错误。否则,Loading
分支内部的所有内容都与之前相同,只是我们返回WalkTheDog::Loaded
而不是WalkTheDog
。这确实会导致编译器警告,在 Rust 的未来版本中这将成为一个错误,因为RedHatBoy
不是公开的,但它被公开类型暴露。为了消除这个警告,你需要将RedHatBoy
公开,这是可以的;继续这样做。我们还需要更改new
构造函数,以反映新的类型,如下所示:
impl WalkTheDog {
pub fn new() -> Self {
WalkTheDog::Loading
}
}
WalkTheDog
枚举在初始化后开始于Loading
,这里没有什么特别之处。现在update
和draw
函数都需要反映变化的状态;你可以在这里看到这些变化:
#[async_trait(?Send)]
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(rhb) = self {
if keystate.is_pressed("ArrowRight") {
rhb.run_right();
}
if keystate.is_pressed("ArrowDown") {
rhb.slide();
}
rhb.update();
}
}
fn draw(&self, renderer: &Renderer) {
...
if let WalkTheDog::Loaded(rhb) = self {
rhb.draw(renderer);
}
}
}
你可以争论这并不是对Option
类型的真正改变,因为我们每次操作rhb
时仍然需要检查Game
的状态,这是真的,但我认为这更清楚地揭示了系统的意图。这也带来了好处,可以消除as_ref
、as_mut
代码,这些代码通常很令人困惑。现在我们已经清理了那段代码,让我们给 RHB 添加一个额外的动画。让我们看看这个男孩跳起来吧!
转换到跳跃
再次逐个检查跳跃的每个变化是多余的。相反,我可以推荐你进行以下更改:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(rhb) = self {
...
if keystate.is_pressed("Space") {
rhb.jump();
}
}
}
}
impl RedHatBoy {
...
fn jump(&mut self) {
self.state_machine = self.state_machine.transition(
Event::Jump);
}
}
你应该能够跟踪编译器错误,从rhb.json
中直接查找所需的常量值。帧数是动画中Jump
中的图像数量乘以3
,然后减去1
,动画的名称是Jump
。确保你在Jumping
的转换方法中处理update
事件。
做完所有这些,你会看到 RHB 在地面上打滑,做一种舞蹈般的动作:
图 4.8 – 那不是跳跃
小贴士
如果你卡住了,这些答案可以在github.com/PacktPublishing/Rust-Game-Development-with-WebAssembly/tree/chapter_4/
找到。然而,我强烈建议在查看答案之前先尝试解决这个问题。看看我们为前三个过渡做了什么,并尝试理解我们做了什么。即使你卡住了,这里练习所花费的时间也是宝贵的。
如果你已经正确实现了代码以过渡到跳跃状态,我们的 RHB 将永远播放他的跳跃动画,同时在地面滑行。我们之前在滑行状态中见过这种情况,所以现在是时候弄清楚跳跃有什么不同了。当然,我们知道跳跃有什么不同——你会向上跳!嗯,至少有一点。
我们需要做三件事。首先,当 RHB 跳跃时,我们给他赋予垂直速度;其次,我们需要添加重力,这样 RHB 在跳跃时才会真正落下。最后,我们着陆时需要过渡到奔跑状态,使用我们永远耐用的状态机:
- 在
Jump
上向上跳。
休息一下,思考一下,这应该放在哪里?应该放在update
函数中,还是jump
事件中,或者可能在enum
实现中?不,这是一个过渡变化,因为它发生在jump
事件上,它属于Running
类型类的jump
方法。你应该已经有了从奔跑到跳跃的过渡,所以让我们更新这个函数以添加垂直速度:
mod red_hat_boy_states {
...
const JUMP_SPEED: i16 = -25;
...
impl RedHatBoyState<Running> {
...
pub fn jump(self) -> RedHatBoyState<Jumping> {
RedHatBoyState {
context: self.context.set_vertical_
velocity(JUMP_SPEED).reset_frame(),
_state: Jumping {},
}
}
...
impl RedHatBoyContext {
...
fn set_vertical_velocity(mut self, y: i16) ->
Self {
self.velocity.y = y;
self
}
记住在我们二维坐标系中,y
在顶部是0
,所以我们需要一个负速度才能向上跳。它还会重置帧,使跳跃动画从帧0
开始。RedHatBoyContext
中的实现使用了接受mut self
并返回一个新的RedHatBoyContext
的相同模式。现在,如果你让应用刷新,RHB 将像超人一样起飞!
- 添加重力。
为了使跳跃更自然,我们将在每次更新时应用重力。我们将无论状态如何都这样做,因为稍后我们需要让 RHB 从平台和悬崖上掉下来,我们不想每次都要不断选择何时应用重力。这将在RedHatBoyContext
的update
函数中实现,就在顶部:
mod red_hat_boy_states {
...
const GRAVITY: i16 = 1;
impl RedHatBoyContext {
fn update(mut self, frame_count: u8) -> Self {
self.velocity.y += GRAVITY;
如果你现在刷新页面,你会遇到一个一闪而过的问题,你可能会看到一个空白的屏幕。屏幕其实并不空白;RHB 直接穿过了地面!
图 4.9 – 告诉我的家人我爱他们
我们需要用我们的第一个案例来处理碰撞解决。
- 着陆地面。
这是对下一章的一点点剧透,但碰撞检测分为两个步骤。第一步是检测,找到物体碰撞的地方,第二步是解决,处理碰撞。由于 RHB 的空旷空间中没有东西可以碰撞,我们可以在同一个update
函数中简单地检查他的新位置是否超过了地板,并将位置更新回地板。记住,你是在更新到新位置之后做这个操作的:
impl RedHatBoyContext {
pub fn update(mut self, frame_count: u8) ->
Self {
...
self.position.x += self.velocity.x;
self.position.y += self.velocity.y;
if self.position.y > FLOOR {
self.position.y = FLOOR;
}
这可能感觉有些多余,但我们无法知道重力是否将 RHB 拉过了地面,除非我们实际计算他最终的位置,而且我们没有绘制中间状态,所以性能成本最小。这个更改防止了 RHB 穿过地面,并产生了一个漂亮的跳跃弧线,但他会一直执行跳跃动画。我们需要将状态从Jumping
变回Running
,并且我们需要在RedHatBoyStateMachine
中做出这个决定,因为它是一个基于条件的状态改变,就像从Sliding
过渡到Running
的那个一样。
这是状态机的更改,就像我们为Sliding
所做的更改一样,如下所示:
impl RedHatBoyState<Jumping> {
...
pub fn update(mut self) -> JumpingEndState {
self.context = self.context.update(
JUMPING_FRAMES);
if self.context.position.y >= FLOOR {
JumpingEndState::Complete(self.land())
} else {
JumpingEndState::Jumping(self)
}
}
}
所以,如果位置在地板上,我们需要通过stand
方法过渡到Running
状态,但我们不能!我们从未编写过从Sliding
到Running
的转换,只有相反的转换。我们也从未编写过JumpingEndState
枚举,或者通过From
转换出去的方法。所以,现在你应该会看到关于所有这些的几个编译器错误,第一个如下所示:
error[E0599]: no method named 'land' found for struct 'red_hat_boy_states::RedHatBoyState' in the current scope
--> src/game.rs:413:48
|
258 | pub struct RedHatBoyState<S> {
| ---------------------------- method 'land' not found for this
有编译器错误,但没有land
方法。所以,去写它。我是认真的:自己去写。我不会在这里重现它。你可以继续跟随我们之前编写的代码和方法来实现它们。你可以做到的;我相信你。当你这样做的时候,你将会有一个从Idle
到Running
,然后到Jumping
,再回到Running
的干净动画。然后,你会离开屏幕,因为我们还没有一个完整的场景,但我们正在朝着这个目标前进!
重要提示
如果遇到难题,你总是可以检查在github.com/PacktPublishing/Rust-Game-Development-with-WebAssembly/tree/chapter_4/
仓库中该章节的源代码。
摘要
本章涵盖了一个主题,但它是游戏开发中最重要的话题之一。状态机在游戏中无处不在,我们在实现一个小的状态机来管理WalkTheDog
enum
的Loaded
和Loading
状态时已经看到了这一点。它们是实现必须与玩家行为相对应的动画状态的一种特别好的方式,而 Rust 有很好的方法来实现这种模式。我们使用了两种:简单的用于WalkTheDog
,以及更复杂的RedHatBoyStateMachine
,它使用了类型状态模式。类型状态模式是 Rust 中常用的一种模式,无论是在游戏开发内部还是外部,你都可以期待在许多 Rust 项目中看到它。
我们还多次使用编译器来驱动开发。这是一种极其有用的技术,你可以从你希望代码看起来像什么开始,并使用编译器的错误信息来帮助你完成其余的实现。代码就像是一个数字画,你使用高级代码来画线,编译器的错误信息告诉你如何填充它们。Rust 有非常好的编译器错误信息,并且随着每个版本的发布而变得越来越好,密切关注它们将为你带来巨大的回报。
现在我们已经让 RHB 能够跑和跳了,那么他跑和跳在什么东西上呢?我们将在下一章把他放入一个场景,并让他跳到上面。
第五章:第五章:碰撞检测
为了让我们的游戏更有趣,我们的“小红帽男孩”(RHB)需要奔跑、跳跃和滑行。幸运的是,我们已经实现了所有这些功能,但他还需要有东西可以跳上,有东西可以滑下,还有东西可以撞到。为了让这个游戏更有趣,我们需要添加碰撞检测,这是游戏设计中最有乐趣且最复杂的部分之一。
碰撞检测始于数学,检测两个形状是否相交,但会引出各种有趣的问题。在本章中,我们将处理其中的一些问题,例如,我们如何处理精灵的透明度?我们如何确保玩家从上方着陆在平台上,但如果他们在下面则会撞到平台?对于形状不是简单盒子的精灵怎么办?这将是一次非常有趣的体验!
在本章中,我们将涵盖以下主题:
-
创建真实场景
-
与轴对齐的边界框
-
从精灵图中获取边界框
-
撞到石头上
-
在平台上着陆和掉落
到本章结束时,你将拥有一个真正的游戏,尽管它可能是一个简短的游戏。你将拥有构建自己场景的技能,拥有良好的碰撞检测,并且知道如何将碰撞事件与你的程序集成。如果你想的话,可以向场景中添加自己的新对象,并与之碰撞或跳离,甚至掉出世界。让我们开始吧!
技术要求
你需要从github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/wiki/Assets
下载本章的最新资源。
你可以在此章节的源代码:github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_5
。
下载中没有新的资源,所以如果你之前已经下载过,就无需再次下载。
观看以下视频,看看代码的实际效果:bit.ly/36BJYJd
创建真实场景
目前,RHB 可以随意移动,在一个空旷的虚空中,就像《黑客帝国》中的那样。这是进步;所有那些动画都是真正的劳动成果,但它不是一个游戏。是时候让 RHB 进入一个环境——一个背景,平台,也许还有一些可以跳过的东西。让我们从背景开始。
添加背景
目前,我们的游戏只能从精灵图中渲染图像,我们可以用它作为背景,但对于一张图片来说这是过度设计。相反,我们将添加一个新的struct
,它可以从.png
文件中绘制简单的图像。然后,我们将将其添加到WalkTheDog
中的draw
和initialize
函数中:
- 创建一个
Image
结构体。
我们可以自下而上地处理这些更改,先在引擎中添加代码,然后将其集成到游戏中。我们的 Image
struct
将会使用我们在 第二章,绘制精灵 中编写的很多相同代码,但设置会更简单,因为我们不会使用表格。所有这些代码都应该放入 engine
模块中。
从一个持有 HtmlImageElement
的 struct
开始:
pub struct Image {
element: HtmlImageElement,
position: Point,
}
impl Image {
pub fn new(element: HtmlImageElement, position:
Point) -> Self {
Self { element, position }
}
}
这里没有你以前在其他形式中没有看到过的内容。Image struct
包含图像元素,假设是通过 load_image
函数加载的,以及它在场景中的位置。Image
还需要一个绘制函数,但在 Renderer
中没有简单的方法可以绘制整个图像。这需要一个新的方法,如下所示:
impl Renderer {
...
pub fn draw_entire_image(&self, image:
&HtmlImageElement, position: &Point)
self.context
.draw_image_with_html_image_element(image,
position.x.into(), position.y.into())
.expect("Drawing is throwing exceptions!
Unrecoverable error.");
}
}
这个函数与我们之前写的 draw_image
函数非常相似,但它使用的是更简单的 JavaScript drawImage
函数版本,该版本只接受一个图像和一个位置。要使用这种方法,你需要知道你正在绘制的图像有多大。如果它太大或太小,它将显示得与源图像一样大或一样小。
-
现在你已经为
Renderer
添加了一个方法,请继续更新Image
实现以使用它来绘制图像:impl HtmlImageElement { ... pub fn draw(&self, renderer: &Renderer) { renderer.draw_entire_image (&self.element,&self.position) } }
现在你已经可以绘制图像了,让我们来加载它。
- 加载图像。
背景图像可以在下载的资产中找到,在 original/freetileset/png/BG/BG.png
中,可以将其复制到 static
目录。然后,它可以被加载并用于创建一个 Image
struct
。这将在 game
模块的 WalkTheDog
的 initialize
函数中完成,如下所示:
impl Game for WalkTheDog {
async fn initialize(&mut self) -> Result<Box<dyn
Game>> {
match self {
WalkTheDog::Loading => {
let sheet = browser::fetch_json
("rhb.json").await?.into_serde()?;
let background = engine::
load_image("BG.png").await?;
....
在前面的代码片段中,只有高亮显示的最后一行是新的,它从文件中加载背景。我们的 WalkTheDog
enum
只包含 RedHatBoy
,所以我们将不得不稍微重构一下代码。虽然我们可以在 WalkTheDog::Loaded
状态中持有 RedHatBoy
和 Background
的元组,但这会变得非常烦人,而且会很快。
-
要做到这一点,将
enum
改成如下所示:pub enum WalkTheDog { Loading, Loaded(Walk), }
我们将用 WalkTheDog
来代表我们的游戏,但我决定让 RHB 带狗去"Walk"。在一个通用框架中,我可能会称这个为"
Walk`应该可以工作。
-
Walk
结构将需要包含 RHB 和背景,所以请继续添加:pub struct Walk { boy: RedHatBoy, background: Image, }
确保你已经从 engine
模块导入了 Image
。现在,你可以沿着 game
模块向下工作,并跟随编译器错误。在 WalkTheDog
的 initialize
函数中,你应该看到一个错误,即 "expected struct `Walk`, found struct `RedHatBoy`
"。
-
通过创建带有我们已加载的背景的
Walk
并将其设置在返回的WalkTheDog::Loaded
中来修复这个问题。这看起来如下所示:impl Game for WalkTheDog { async fn initialize(&mut self) -> Result<Box<dyn Game>> { ... Ok(Box::new(WalkTheDog::Loaded(Walk { boy: rhb, background: Image::new(background, Point { x: 0, y: 0 }), }))) } ... }
这将创建一个带有男孩和位于左上角的background
的Walk
,但你仍然会在WalkTheDog
的update
方法中看到几个编译错误,因为所有这些假设WalkTheDog::Loaded
包含RedHatBoy
。每个都可以以完全相同的方式更改。第一个看起来像这样:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
if keystate.is_pressed("ArrowRight") {
walk.boy.run_right();
}
...
if let WalkTheDog::Loaded
这一行没有变化,除了现在变量名是walk
而不是rhb
。然后,我们在boy
上调用run_right
,但通过walk
结构。你可以争论我们应该在Walk
上添加方法而不是委托给boy
,但我们现在先不这么做。毕竟,walk.run_right()
并没有真正意义。在修复update
中的所有类似编译错误后,你还可以像这样修复draw
中的类似错误:
impl Game for WalkTheDog {
...
fn draw(&self, renderer: &Renderer) {
if let WalkTheDog::Loaded(walk) = self {
walk.boy.draw(renderer);
}
...
做完所有这些,你现在将再次绘制……嗯,你将再次绘制 RHB。
-
接下来,继续绘制我们游戏的背景。绘制背景只是使用我们新的绘制函数的问题,所以让我们在
walk.boy.draw
函数调用之前添加它,如下所示:impl Game for WalkTheDog { ... fn draw(&self, renderer: &Renderer) { if let WalkTheDog::Loaded(walk) = self { walk.background.draw(renderer); walk.boy.draw(renderer); } ...
做完这些后,你应该看到 RHB 站在背景前面,就像这样:
图 5.1 – 站在森林中
看起来,你可能会想,为什么 RHB 的x
坐标是0
时却这么靠右?请记住这个想法,因为我们很快就会处理它。首先,让我们使用我们的精灵图集从第二章,绘制精灵,将一个平台放到屏幕上。
添加障碍物
有 RHB 站在背景前面真是太好了,看起来也很棒,但场景仍然有点空。如果场景中还有其他东西会怎样?一些宏伟的、创新的、超越生命的东西。嗯,艺术预算很低,所以为什么不放一块石头呢?
我们新的Image
类意味着我们不需要很多代码,而且你之前都见过。要添加障碍物,请按照以下步骤操作:
-
首先,从
assets
中的original/freetileset/png/Object/Stone.png
复制Stone.png
到static
目录。现在,你可以像添加Background
一样将其添加到Walk
中,如下所示:struct Walk { boy: RedHatBoy, background: Image, stone: Image, }
这将再次开始引起编译错误,因为Walk
是在没有石头的情况下创建的。
-
在
initialize
中,像加载背景一样加载石头,如下所示:impl Game for WalkTheDog { async fn initialize(&mut self) -> Result<Box<dyn Game>> { ... match self { WalkTheDog::Loading => { ... let background = engine::load_image ("BG.png").await?; let stone = engine:: load_image("Stone.png").await?; ...
-
然后,你需要拿起我们刚刚加载的石头,并将其添加到
Walk
中。我们将通过取FLOOR
值(600
)并减去石头图像的高度(恰好是54
像素)来确保石头在地面上。如果我们把石头放在y
位置546
,它应该正好坐在地面上。以下是创建Walk
的更新:impl Game for WalkTheDog { async fn initialize(&mut self) -> Result<Box<dyn Game>> { .... Ok(Box::new(WalkTheDog::Loaded(Walk { boy: rhb, background: Image::new(background, Point { x: 0, y: 0 }), stone: Image::new(stone, Point { x: 150, y: 546 }), })))
-
石头距离右边
150
像素,所以它将在 RHB 前面。最后,使用draw
方法绘制石头。这个添加如下:impl Game for WalkTheDog { ... fn draw(&self, renderer: &Renderer) { if let WalkTheDog::Loaded(walk) = self { walk.background.draw(renderer); walk.boy.draw(renderer); walk.stone.draw(renderer); }
代码更改很小,只是用与boy
和background
相同的draw
调用绘制石头。这样做,你将看到 RHB 走向石头:
图 5.2 – 注意那块石头!
现在,如果 RHB 走进那块石头,他会安全地躲在它后面,就像这样:
图 5.3 – 世界上最简单的游戏
这没什么意思。虽然我们已经学会了如何将新对象添加到游戏中,并为更互动的体验绘制它们,但游戏还没有任何挑战。我们想让男孩撞到石头上并倒下,结束游戏。为此,我们需要了解一些关于边界框和碰撞检测的知识,所以让我们在下一节中学习这些内容。
轴对齐边界框
在我们的游戏中检查两个对象是否发生碰撞,理论上可以通过检查每个对象中的每个像素,看它们是否共享一个位置来实现。这种逻辑不仅编写起来非常复杂,而且在计算上也非常昂贵。我们需要以每秒60
帧的速度运行,不能浪费我们宝贵的处理能力去追求那种完美——至少如果我们想让游戏有趣的话。幸运的是,我们可以使用一种简化方法,它足够接近以欺骗我们愚蠢的眼睛,就像我们无法分辨动画实际上只是一系列静态图像一样。这种简化方法被称为边界框。
边界框只是一个矩形,我们将用它来进行碰撞,而不是检查精灵上的每个像素。你可以想象每个精灵周围都有一个盒子,看起来像这样:
图 5.4 – 边界框
这些盒子实际上并没有绘制出来;它们只存在于游戏的内存中,除非你想调试它们。当你使用盒子时,你只需要检查盒子的值——顶部(y),左边(x),右边(x + 宽度),和底部(y + 高度)。这使比较变得更快。让我们更详细地谈谈如何检测两个盒子是否相交。
注意
术语“轴对齐”听起来很复杂,但实际上它只是意味着盒子没有旋转。Y将向上和向下,X从左到右,并且始终与游戏的坐标系对齐。
碰撞
为了检测两个框是否碰撞或重叠,它们将存在于我们从本书开始就一直在使用的同一 2D 坐标空间中。它们可能不可见,但它们就在那里,坐在石头所在的位置或与 RHB 一起运行。它们需要一个 x 和 y 的位置,就像精灵已经有了,还需要一个宽度和高度。当我们检查两个框是否碰撞时,我们在 x 和 y 轴上检查。让我们首先看看你如何判断两个框在 x 轴上是否相交。给定有两个框,如果框 1 的左侧(或 x 位置)小于框 2 的右侧,但框 1 的右侧大于框 2 的左侧,那么框 1 就与框 2 相交。这更容易通过视觉来解释:
图 5.5 – 碰撞
前面的图显示了三组可能发生碰撞的两个框,在一个 x 随着向右移动而增加的空间中,就像我们的画布一样。前两个比较没有发生碰撞,但第三个发生了。
看一下第一个比较,其中框 1 在框 2 的左侧,两者之间有一个间隙。正如你所见,框 1 的左侧明显在框 2 的右侧左侧,如箭头所示。这满足了碰撞的第一个条件——框 1 的左侧必须小于框 2 的右侧。然而,框 1 的右侧在框 2 的左侧左侧,这违反了我们的第二个条件。为了碰撞,框 1 的右侧必须大于(在右侧)框 2 的左侧,因此这两个框没有发生碰撞。
在第二个比较中,框 1 被移动到框 2 的右侧,再次没有重叠。框 1 的右侧现在在框 2 的左侧右侧,因此它们满足了碰撞的第二个条件,但框 1 的左侧现在也在框 2 的右侧右侧,所以框子没有满足第一个条件,仍然没有发生碰撞。
最后,在第三个比较中,框 1 的左侧再次在框 2 的右侧右侧,但框 1 的左侧在框 2 的右侧左侧。这两个框发生了碰撞。框 1 和框 2 有重叠的 x 值,所以它们发生了碰撞。
如果图片不是你的风格,查看实际数字也可以帮助你了解这个算法是如何工作的。假设框 1 和框 2 都是 10 x 10,我们可以形成一个表格,如下所示:
在这张表的每一行中——也就是说,每一组坐标的示例集——框 2 处于相同的位置。这里实际上有四个示例。在第一行中,框 1 完全位于框 2 的左侧。在第二行中,框 1 的右边缘撞到了框 2 的左边缘,因此它们发生了碰撞。在第三行中,它们发生碰撞是因为框 1 的左边缘撞到了框 2 的右边缘。最后,在第四行中,框 1 并不完全位于框 2 的右侧。这些值具有与图像相同的属性;第一个框的左边缘或右边缘位于第二个框的左边缘和右边缘之间。这个长篇的解释导致以下简短的伪代码:
if (box_one.x < box_two.right) &&
(box_one.right > box_two.x) {
log!("Collision!");
}
这满足了我在一开始提到的两个条件,但关于垂直轴(y)呢?它的工作方式类似,只是我们不是使用左右两侧,而是分别使用顶部和底部值。框 1 的顶部必须位于框 2 的底部之上,这意味着小于框 2 的底部。框 1 的底部必须位于框 2 的顶部之下。如果这两个条件都成立,则框发生碰撞。记住,在我们的坐标系中,y 值随着屏幕向下移动而增加:
图 5.6 – 垂直碰撞
让我们花点时间来处理这三个比较,就像我们之前做的那样。对于第一个比较,框 1 的顶部在框 2 的底部之上,但框 1 的底部也在框 2 的顶部之上,所以它们没有重叠。
在第二种情况下,框 1 完全位于框 2 下方,没有发生碰撞。框 1 的底部在框 2 的顶部下方,这是碰撞必须满足的条件,但框 1 的顶部也在框 2 的底部下方,因此我们的第一条垂直碰撞规则不成立。
在第三个比较中,框 1 的顶部在框 2 的底部之上,框 1 的底部在框 2 的顶部之下,所以我们有碰撞。这意味着我们可以扩展我们的伪代码,使其看起来像以下这样:
if (box_one.x < box_two.right) &&
(box_one.right > box_two.x) &&
(box_one.y < box_two.bottom) &&
(box_one.bottom > box_two.y) {
log!("Collision!");
}
这四个条件必须同时满足才能发生碰撞。既然我们已经知道了碰撞,现在我们可以将约束框应用到 RHB 和石头上,以便它们可以发生碰撞。不幸的是,一种天真方法会导致非常困难的碰撞和几乎不可能的游戏。这个问题可以用一个词来概括——透明度。
透明度
在 图 5.7 中,我为 RHB 和石头画了红色的约束框:
图 5.7 – 约束框
这些约束框是通过使用加载后的整个精灵的大小创建的,使用了 HTMLImageElement
的宽度和高度属性。正如你所见,框的大小远大于它们对应的精灵,特别是 RHB 的那个。这是因为精灵具有透明度,我们不想将其包含在我们的约束框中。目前,框发生了碰撞,RHB 会在接触到石头之前就被撞倒。这不是我们想要的!
这是边界框碰撞的主要调试技术的一个例子——绘制边界框以便可以看到哪里出了问题。在这种情况下,RHB 的框太大了。它应该是包含整个图像所需的最小尺寸,而这个错误揭示的是我们在第二章,绘制精灵中使用的精灵表包含了很多透明度。在 RHB 能够正确与石头碰撞之前,我们需要修复这个问题,所以让我们开始剪辑精灵表。
剪辑精灵表
为了让 RHB 撞到石头上,我们得处理透明度问题。让我们看一下 RHB 所来源的原始.png
文件。图像的一部分如图图 5.8所示,如下:
图 5.8 – 精灵表
这是空闲动画的两个帧,黑色线条显示了图像的边界。正如你所见,这些图像中有很多额外的空间,所以使用与图像相同大小的边界框是不行的。这就是你在图 5.7中看到的边界框问题。我们有两个选择来解决这个问题。最简单的方法,尽管有点烦人,就是在我们图形编辑器中打开精灵表,找出每个精灵的实际边界框像素。然后,我们将这些信息存储在代码或单独的文件中,并使用这些边界框。这会加快开发速度,但意味着需要加载比必要的更大的图像,并且渲染大量的透明度而没有理由。避免编写一些代码而造成的性能损失是很大的,但如果我们处于游戏马拉松中并且需要匆忙完成游戏,我们可能会这样做。
我们将要使用一个剪辑后的精灵表,其中已经移除了透明度。这意味着需要写一点代码来确保精灵仍然对齐,但仅仅因为图形文件更小而节省的内存就足够补偿了。
我们剪辑后的精灵表将看起来如下(这是一个片段):
图 5.9 – 剪辑后的纸张
注意,虽然空白区域被剪辑了,但并没有全部移除。这是因为整个表中的每个矩形大小都是相同的。看看 RHB 被击出的版本在水平方向上占据了整个矩形,而空闲的 RHB 在垂直方向上占据了它。这意味着我们将在边界框中考虑到一些透明度,但幸运的是,我们的精灵表 JSON 也将包含这些数据。我们还需要确保精灵正确对齐,以便动画不会在屏幕上跳动。幸运的是,JSON 也提供了这些数据。
注意
这里使用的所有精灵图集都是使用一个名为TexturePacker的工具生成的。这包括与图形一起的 JSON 文件。虽然你可以制作自己的纹理图,但你为什么要这么做呢?TexturePacker(包括免费和付费版本)可以在以下网址找到:bit.ly/3hvZtDQ
。TexturePacker 内置了裁剪精灵图集和导出我们需要的用于在游戏中使用的数据的工具。
裁剪版本的精灵图集数据文件将包含比我们在第二章,“绘制精灵”中使用的更多一些信息。以下是新 JSON 文件中前两个空闲精灵的示例:
"Idle (1).png":
{
"frame": {"x":117,"y":122,"w":71,"h":115},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {"x":58,"y":8,"w":71,"h":115},
"sourceSize": {"w":160,"h":136}
},
"Idle (2).png":
{
"frame": {"x":234,"y":122,"w":71,"h":115},
"rotated": false,
"trimmed": true,
"spriteSourceSize": {"x":58,"y":8,"w":71,"h":115},
"sourceSize": {"w":160,"h":136}
},
两个框架都包含了我们之前用来裁剪精灵的frame
数据,但它们还包含了一个spriteSourceSize
字段。该字段包含了精灵不透明部分的边界框。换句话说,前两个空闲帧的精灵从左侧的57
个透明像素和顶部的8
个像素开始。这些信息对于对齐裁剪后的精灵至关重要,因为它们都从0,0
开始。如果没有使用这些信息,动画就会在页面上到处跳跃,看起来非常糟糕。幸运的是,通过将精灵的位置与spriteSourceSize
的x和y坐标相加,就可以纠正这个问题。这将导致精灵看起来没有在正确的位置上——也就是说,当我们把精灵定位在0
时,它会在右侧显示58
个像素,但只要我们在进行碰撞检测时也考虑到spriteSourceSize
,那就没关系了。一旦我们考虑了spriteSourceSize
,我们的边界框就会紧贴精灵图集,透明度最小化:
图 5.10 – 正确的边界框
注意
如果你想要为调试绘制自己的边界框,我建议你这么做,你可以在Renderer
中添加一个draw_rect
函数并在上下文中绘制矩形。代码可以在第五章,“碰撞检测”的源代码中找到,网址为github.com/PacktPublishing/Rust-Game-Development-with-WebAssembly/tree/chapter_5/
。
使用这些新的、修正后的边界框,RHB 和石头不会发生碰撞,最终可以安全地跳过石头。在下一节中,我们将开始添加新的裁剪精灵图集。
添加裁剪图集
在 assets
文件夹的 sprite_sheets
目录中,你可以找到名为 rhb_trimmed.png
和 rhb_trimmed.json
的新精灵图版本。将这些文件复制到 static
目录,但请确保将文件重命名为 rhb.png
和 rhb.json
。如果服务器尚未运行,请启动它,你应该会看到 RHB 在屏幕上弹跳,因为图集中的精灵不再正确对齐。他也会稍微悬停在地面之上:
图 5.11 – 摇晃的 RHB
我们的首要任务是修复他的动画,使其不再如此生硬。这就是为什么我们之前花了那么多时间讨论 spriteSourceSize
—— 为了修复他的动画。首先,我们将添加该字段到 Cell
中,你可能记得它位于 engine
模块中,如下代码片段所示:
#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Cell {
pub frame: SheetRect,
pub sprite_source_size: SheetRect,
}
这些更改包括添加了 #[serde(rename_all)]
指令和一个 sprite_source_size
字段。虽然 spriteSourceSize
是 JSON 中的名称,但这是 Rust,在 Rust 中,我们使用蛇形命名法来命名变量,这就是为什么我们使用 serde(rename_all)
指令。rename_all = "camelCase"
可能看起来有些反直觉,因为我们实际上是将名称更改为蛇形命名法,但这是因为该指令指的是序列化,而不是反序列化。如果我们要将此结构写入 JSON 文件,我们希望将任何变量重命名为 camelCase,这意味着在反序列化时,我们需要做相反的操作。多亏了我们之前的工作,sprite_source_size
将从新的 JSON 文件中加载,所以接下来,我们需要调整绘制,以便动画再次对齐。
在 game
模块和 RedHatBoy
实现中,我们将稍微修改 draw
函数以考虑裁剪。它看起来如下所示:
impl RedHatBoy {
...
fn draw(&self, renderer: &Renderer) {
...
renderer.draw_image(
&self.image,
&Rect {
x: sprite.frame.x.into(),
y: sprite.frame.y.into(),
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
},
&Rect {
x: (self.state_machine.context().position.x
+ sprite.sprite_source_size.x as i16)
.into(),
y: (self.state_machine.context().position.y
+ sprite.sprite_source_size.y as i16)
.into(),
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
},
);
}
我已经为了上下文复制了整个 draw_image
调用,但只有两行发生了变化。请记住,draw_image
调用需要两个矩形——源矩形,它保持不变,和目标矩形,这是我们更改的部分。x 和 y 坐标都通过 sprite_source_size
及其相应的坐标进行调整。将值转换为 i16
可能会让你感到紧张,因为这可能导致数学错误,如果精灵图中的 x 或 y 位置超过 215,但那将是一个非常奇怪的图。最后,into
调用作用于计算结果,将 i16
转回 f32
以供 Rect
结构体使用。在做出这些更改后,你应该会看到动画正确播放,RHB 应该回到他最初的位置,即在石头旁边:
![图 5.12 – 良好的边界框]
图 5.12 – 良好的边界框
如果你使用draw_rect
绘制边界框,确保它使用与图像相同的边界框。注意边界框不再重叠。尽管如此,它们仍然非常接近,RHB 稍微悬停在地面之上。所以,让我们稍微调整他的起点。在red_hat_boy_states
模块的顶部,我们将更改一个常量并添加一个新的,如下所示:
const FLOOR: i16 = 479;
const STARTING_POINT: i16 = -20;
之前,FLOOR
是475
,但让我们将 RHB 向下推几个像素。我们还将给 RHB 一个负的x位置,以便在他和石头之间留出一点空间。记住,RHB 会调整回右边以考虑动画,所以他实际上不会被画到屏幕外。接下来,我们将修改RedHatBoyState<Idle>
实现,特别是new
函数,以移动 RHB 的起点。这个变化在这里显示:
impl RedHatBoyState<Idle> {
fn new() -> Self {
RedHatBoyState {
context: RedHatBoyContext {
frame: 0,
position: Point {
x: STARTING_POINT,
y: FLOOR,
},
velocity: Point { x: 0, y: 0 },
},
_state: Idle {},
}
}
}
再次,我包括了整个impl
以提供上下文,但唯一的变化是 RHB 的RedHatBoyContext
的初始位置,使用了新的常量。这样做,RHB 就会站在一小段跑道上,这样他就可以跳过石头,就像这样:
图 5.13 – 跑步起步
我们图像中的边界框是正确的,但我们还没有实际使用它们。这就是为什么如果你按右箭头,RHB 仍然会开始奔跑并直接穿过石头后面。是时候给石头和 RHB 合适的轴对齐边界框了,而不仅仅是画出它们,然后使用它们将 RHB 撞倒。多么有趣啊!
与障碍物碰撞
要有碰撞,我们实际上必须将我们看到的 RHB 和石头上的边界框放在一起。然后,在WalkTheDog
的update
函数中,我们需要检测这个碰撞,当发生碰撞时,我们将 RHB 移动到Falling
和KnockedOut
状态,这对应于精灵图中的Dead
动画。大部分代码,尤其是状态机,都是非常熟悉的,所以我会避免重复的部分,并突出显示差异。我会提醒你新状态中需要更改的内容,你总是可以检查最终的代码在github.com/PacktPublishing/Rust-Game-Development-with-WebAssembly/tree/chapter_5/
。
让我们从最简单的边界框开始,即石头的边界框。
石头的边界框
石头是最简单的边界框,因为我们只需使用HTMLImageElement
的大小。但这并不总是如此。如果你看围绕石头画有边界框的图片,你会注意到它比石头的实际尺寸要大,尤其是在角落处。目前这已经足够好了,但随着我们继续前进,我们需要记住这一点。
要向engine
模块中的Image
实现添加边界框,我们希望在创建Image
时,在其new
函数中计算边界框,如下所示:
pub struct Image {
element: HtmlImageElement,
position: Point,
bounding_box: Rect,
}
impl Image {
pub fn new(element: HtmlImageElement, position: Point) -> Self {
let bounding_box = Rect {
x: position.x.into(),
y: position.y.into(),
width: element.width() as f32,
height: element.height() as f32,
};
Self {
element,
position,
bounding_box,
}
}
....
}
在这里,我们向Image
struct
中添加了bounding_box
,并在new
函数中使用其HTMLImageElement
后端的width
和height
来构建它。值得注意的是,我们必须将element.width()
和element.height()
调用转换为f32
。这应该是安全的,但如果以后我们绘制一个非常大的图像,那么它可能成为一个问题。还值得注意的是,通过在new
函数中创建边界框,我们确保了每次更新position
时,我们也需要更新bounding_box
。我们可以通过每次计算bounding_box
来解决这个问题,这是一个不错的解决方案,但这确实意味着可能会损失性能。在这种情况下,我们将保持struct
中的position
和bounding_box
都是私有的,以确保它们不会不同步。Image
对象目前还没有移动。
由于bounding_box
是私有的,我们需要提供一个访问器,所以现在就来做这件事:
impl Image {
...
pub fn bounding_box(&self) ->&Rect {
&self.bounding_box
}
}
这样就处理了石头;现在,让我们给 RHB 一个边界框。
为 RedHatBoy 定义一个边界框
由于RedHatBoy
上的边界框比精灵表更复杂,所以边界框的处理也稍微复杂一些。它需要与表的位置对齐,并且需要根据动画进行调整。因此,我们无法像处理Image
那样存储一个与对象绑定的bounding_box
。相反,我们将根据其当前状态和精灵表来计算其边界框。代码实际上看起来非常类似于draw
,如下所示:
impl RedHatBoy {
...
fn bounding_box(&self) ->Rect {
let frame_name = format!(
"{} ({}).png",
self.state_machine.frame_name(),
(self.state_machine.context().frame / 3) + 1
);
let sprite = self
.sprite_sheet
.frames
.get(&frame_name)
.expect("Cell not found");
Rect {
x: (self.state_machine.context().position.x +
sprite.sprite_source_size.x as i16).into(),
y: (self.state_machine.context().position.y +
sprite.sprite_source_size.y as i16).into(),
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
}
}
...
}
为了计算bounding_box
,我们首先从状态名称和当前帧创建frame_name
,就像我们在draw
中做的那样,然后使用我们在更新draw
函数时所做的相同计算从这些值计算Rect
。实际上,这是一个清理那些两段代码中重复代码的好时机,使用重构。让我们从RedHatBoy
实现中提取函数来获取帧和精灵名称:
impl RedHatBoy {
...
fn frame_name(&self) -> String {
format!(
"{} ({}).png",
self.state_machine.frame_name(),
(self.state_machine.context().frame / 3) + 1
)
}
fn current_sprite(&self) -> Option<&Cell> {
self.sprite_sheet.frames.get(&self.frame_name())
}
...
}
对于current_sprite
,你需要确保你导入了engine::Cell
。现在,我们可以替换bounding_box
实现中的重复代码,如下所示:
impl RedHatBoy {
…
fn bounding_box(&self) ->Rect {
let sprite = self.current_sprite().expect("Cell not
found");
Rect {
x: (self.state_machine.context().position.x +
sprite.sprite_source_size.x as i16).into(),
y: (self.state_machine.context().position.y +
sprite.sprite_source_size.y as i16).into(),
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
}
}
...
}
进一步来说,我们可以通过从bounding_box
中移除重复代码来缩小draw
函数,使其更加精简:
impl RedHatBoy {
...
fn draw(&self, renderer: &Renderer) {
let sprite = self.current_sprite().expect("Cell not
found");
renderer.draw_image(
&self.image,
&Rect {
x: sprite.frame.x.into(),
y: sprite.frame.y.into(),
width: sprite.frame.w.into(),
height: sprite.frame.h.into(),
},
&self.bounding_box(),
);
}
...
}
这使得实现更加小巧、干净,但需要注意我们每帧都在查找current_sprite
两次。我们现在不会努力修复它,因为我们没有看到任何问题,但以后我们可能想要缓存这个值。
现在我们有了两个边界框,我们实际上可以看到 RHB 是否与石头发生碰撞。
在碰撞时崩溃
为了在碰撞时崩溃,我们需要使用之前提到的伪代码来检查两个矩形是否相交,但这次使用的是真实代码。我们将添加这段代码到Rect
中,如果你还记得,它是engine
模块的一部分。这段代码是Rect
结构体的实现,如下所示:
impl Rect {
pub fn intersects(&self, rect: &Rect) -> bool {
self.x < (rect.x + rect.width)
&& self.x + self.width > rect.x
&& self.y < (rect.y + rect.height)
&& self.y + self.height > rect.y
}
}
这段代码复现了之前的伪代码,检查是否存在任何重叠,如果存在则返回true
。每次你看到rect.x + rect.width
,那就是右边,而rect.y + height
是底部。我个人更喜欢在每个条件下将相同的矩形放在这个函数的左侧,因为我发现这样更容易阅读和思考。我们将在WalkTheDog
的update
函数中使用这段代码。这段代码很小,但它将引发一系列连锁反应。碰撞代码如下:
impl WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
walk.boy.update();
if walk
.boy
.bounding_box()
.intersects(walk.stone.bounding_box())
{
walk.boy.knock_out();
}
}
}
}
碰撞检查将在调用boy
上的update
之后立即发生。我们检查男孩的边界框是否与石头相交,使用我们全新的intersects
函数,如果相交,我们在 RHB 上使用knock_out
。可怜的 RHB;幸运的是,你总是可以刷新。
knock_out
函数还不存在;创建它将意味着更新我们的状态机。KnockOut
事件将导致转换到Falling
状态,然后当Falling
动画完成后,将转换到KnockedOut
状态。我们在等什么?让我们敲出 RHB!
KnockOut 事件
正如我们在第四章中提到的,使用状态机管理动画,我们将向RedHatBoyStateMachine
添加新状态,并“跟随编译器”以了解在哪里填写必要的代码。Rust 的类型系统在这方面做得很好,它使这类工作变得容易,并在过程中提供有用的错误信息,所以我只会突出显示独特的段落。记住,你总是可以使用github.com/PacktPublishing/Rust-Game-Development-with-WebAssembly
上的源代码提前查看,尽管我强烈建议你首先尝试自己编写实现。
你可以在game
模块中通过向Event
枚举添加KnockOut
事件,并在RedHatBoy
上添加knock_out
方法来开始,就像其他状态机转换一样,如下所示:
pub enum Event {
Run,
Jump,
Slide,
KnockOut,
Update,
}
...
impl RedHatBoy {
...
fn knock_out(&mut self) {
self.state_machine =
self.state_machine.transition(Event::KnockOut);
}
...
这只会将编译器错误移动到RedHatBoyStateMachine
,因为匹配语句是不完整的,所以你需要向RedHatBoyStateMachine
添加一个KnockOut
事件,它将从Running
状态转换到Falling
状态。这个转换是这样的:
impl RedHatBoyStateMachine {
fn transition(self, event: Event) -> Self {
match (self, event) {
...
(RedHatBoyStateMachine::Running(state),
Event::KnockOut) => state.knock_out
().into(),
(RedHatBoyStateMachine::Jumping(state),
Event::KnockOut) =>
state.knock_out().into(),
(RedHatBoyStateMachine::Sliding(state),
Event::KnockOut) =>
state.knock_out().into(),
_ => self,
}
}
...
你可能会想知道为什么我们还有从Jumping
和Sliding
到Falling
的转换;这是因为如果我们不这样做,那么用户可以简单地按住空格键连续跳跃,或者适时滑动,他们就会直接穿过石头。所以,我们需要确保这三种状态都会转换到Falling
,这样游戏才不会有任何错误。
当然,还有很多东西缺失。Falling
状态还不存在,既不是RedHatBoyStateMachine
enum
的成员,也不是一个结构体。Sliding
、Jumping
或Running
的类型状态没有knock_out
方法,而且没有实现将Falling
转换为RedHatBoyStateMachine::Falling
的From
特质。你需要像之前一样添加这两个,并填补其余的编译器错误。你会发现你需要两个新的常量,一个是跌倒动画中的帧数,另一个是精灵图中跌倒动画的名称。你可以查看rhb.json
来找出这些值,或者查看以下列表:
const FALLING_FRAMES: u8 = 29; // 10 'Dead' frames in the sheet, * 3 - 1.
const FALLING_FRAME_NAME: &str = "Dead";
如果你已经做了所有必要的样板更改,你最终会创建一个从Running
状态到Falling
状态的转换,如下面的代码所示:
impl RedHatBoyState<Running> {
pub fn knock_out(self) -> RedHatBoyState<Falling> {
RedHatBoyState {
context: self.context,
_state: Falling {},
}
}
...
注意,你目前只是在转换状态,并没有对RedHatBoyContext
进行任何更改。这就是为什么事情变得奇怪,因为当 RHB 与石头碰撞时,他会跌倒…并且一直滑动和跌倒:
图 5.14 – 跌倒时滑动?
转换正确地进入了Dead
动画,但它并没有停止 RHB 的前进动作。让我们将转换改为停止RedHatBoy
:
impl RedHatBoyState<Running> {
pub fn knock_out(self) -> RedHatBoyState<Falling> {
RedHatBoyState {
context: self.context.reset_frame().stop(),
_state: Falling {},
}
}
...
现在,当我们设置新状态时,我们调用reset_frame()
将帧设置为0
,就像我们更改动画时总是做的那样,并调用新的stop
函数来停止角色的前进动作。当然,这个函数还没有编写。它附加到RedHatBoyContext
实现中,将velocity.x
设置为0
:
impl RedHatBoyContext {
fn stop(mut self) -> Self {
self.velocity.x = 0;
self
}
}
...
当从Sliding
状态转换到Falling
状态,以及从Jumping
状态转换到Falling
状态时,你也想进行相同的转换,以便转换匹配。这将停止角色的前进动作,但不会停止死亡动画反复播放。这是因为我们从未从Falling
状态转换到KnockedOut
状态,而KnockedOut
状态本身还不存在。幸运的是,我们之前已经编写过类似的代码。记得在第四章,使用状态机管理动画中,当滑动动画完成时,我们从一个Sliding
动画转换回Running
动画。这段代码位于RedHatBoyState<Sliding>
的update
函数中,这里重新呈现:
impl RedHatBoyState<Sliding> {
...
pub fn update(mut self) -> SlidingEndState {
self.update_context(SLIDING_FRAMES);
if self.context.frame >= SLIDING_FRAMES {
SlidingEndState::Running(self.stand())
} else {
SlidingEndState::Sliding(self)
}
}
}
在这段代码中,我们检查每次更新,通过if state_machine.context.frame>= SLIDING_FRAMES
来查看Sliding
动画是否完成。如果是,我们返回Running
状态而不是Sliding
状态。为了达到这个程度,你之前已经必须向RedHatBoyState<Falling>
添加了一个update
方法,可能是一个通用的默认方法来播放动画。现在,你需要模仿这种行为并转换到新的KnockedOut
状态。具体来说,你需要做以下几步:
-
创建一个
KnockedOut
状态。 -
从
Falling
状态创建一个转换到KnockedOut
状态。 -
在
update
动作中检查Falling
动画是否完成,如果是,则切换到KnockedOut
状态,而不是停留在Falling
状态。 -
在
RedHatBoyState<Falling>
中创建一个enum
来处理update
方法的两种结束状态,以及相应的From
特质,以便将其转换为RedHatBoyStateMachine
适当的enum
变体。
这里唯一的新事物是RedHatBoyState<KnockedOut>
将不需要update
方法,因为在KnockedOut
状态下,RHB 什么都不做。我们不会逐行通过那段代码,相反,我强烈建议你自己尝试。如果你卡住了,你可以查看github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_5
中的代码。完成之后,它应该看起来像这样:
图 5.15 – 正在打盹
同时,我会假设你已经做到了,因为你很棒,所以我们将继续到跳到平台上的动作。
跳到平台上
现在 RHB 撞到石头上,我们需要找到一种方法来越过它。玩游戏并尝试跳过岩石;你会注意到这真的很困难。时机必须恰到好处,让人联想到经典游戏Pitfall中的 Atari 2600 蝎子。在本章的后面部分,我们将通过缩小边界框并增加 RHB 的水平速度来调整这一点,但首先,我们将在石头上方放置一个平台,RHB 可以跳上去以避开岩石。除了用新的精灵图在屏幕上放置平台并给它一个边界框外,我们还需要处理一种新的碰撞类型。具体来说,我们需要处理来自平台上面的碰撞,这样我们才能着陆。
添加平台
我们将首先添加来自新精灵图的平台。这个精灵图实际上包含了我们将在接下来的章节中构建地图的元素,但现在我们只用它来制作一个平台。精灵图看起来是这样的:
图 5.16 – 我们的平台
图片被分割成没有轮廓但形状排列方式可见的正方形,称为瓦片。这些正方形是我们将混合匹配以制作 RHB 跳过和滑过的各种障碍物的精灵。瓦片也紧密排列在一起,所以我们不需要担心任何偏移。目前,我们只需要右下角的平台,它将浮在石头上方:
图 5.17 – 一个平台
这个设置方便地将精灵按顺序排列,所以它将很容易在精灵图中访问。你现在可以看到那些标记三个精灵的虚线。让我们将其放入我们的游戏中。在assets
目录的sprite_sheets
中,你会找到两个文件,tiles.json
和tiles.png
。这是瓦片的图,我们将在启动时加载它。为了有一个可以加载它的东西,我们首先在game
模块中创建一个Platform
结构体:
struct Platform {
sheet: Sheet,
image: HtmlImageElement,
position: Point,
}
impl Platform {
fn new(sheet: Sheet, image: HtmlImageElement, position: Point) -> Self {
Platform {
sheet,
image,
position,
}
}
}
到目前为止,这只是为了加载预期的数据。在这个阶段,你可能注意到sheet
和image
被反复配对,这意味着它们是重构为新的结构,如SpriteSheet
的良好候选者。我们现在不会这么做,因为我们不想过早地进行重构并得到一个糟糕的抽象,但我们会留心如果它再次出现时的重复。
平台需要两样东西。它需要被绘制,并且需要一个边界框,这样我们才能在上面着陆。为了绘制这个框,我们需要一起绘制构成该平台的底部三个瓦片。查看tiles.json
,由于框架名称都是像14.png
这样的数字,所以请相信我,瓦片是13.png
、14.png
和15.png
。
注意
使用像 TexturePacker 这样的工具可以显著更容易地确定需要查看哪些瓦片。如果你没有这个工具可用,你可以只从图中绘制每个图像,同时显示它们的名称,然后修改 JSON 文件中的名称以使其更易读。
现在让我们深入到Platform
的draw
函数中,它里面有一个小技巧,如下所示:
impl Platform {
...
fn draw(&self, renderer: &Renderer) {
let platform = self
.sheet
.frames
.get("13.png")
.expect("13.png does not exist");
renderer.draw_image(
&self.image,
&Rect {
x: platform.frame.x.into(),
y: platform.frame.y.into(),
width: (platform.frame.w * 3).into(),
height: platform.frame.h.into(),
},
&Rect {
x: self.position.x.into(),
y: self.position.y.into(),
width: (platform.frame.w * 3).into(),
height: platform.frame.h.into(),
},
);
}
小技巧是我们知道这三个瓦片在图中恰好相邻,所以我们不会从图中取出所有三个精灵,而是只获取第一个精灵宽度的三倍。这恰好包括了其他两个瓦片。别忘了第二个Rect
是目的地,因此应该使用position
字段。这个第二个矩形也对应于平台的边界框,所以让我们创建平台的边界框函数并在这里使用它。这些更改如下所示:
impl Platform {
...
fn bounding_box(&self) ->Rect {
let platform = self
.sheet
.frames
.get("13.png")
.expect("13.png does not exist");
Rect {
x: self.position.x.into(),
y: self.position.y.into(),
width: (platform.frame.w * 3).into(),
height: platform.frame.h.into(),
}
}
fn draw(&self, renderer: &Renderer) {
...
renderer.draw_image(
&self.image,
&Rect {
x: platform.frame.x.into(),
y: platform.frame.y.into(),
width: (platform.frame.w * 3).into(),
height: platform.frame.h.into(),
},
&self.bounding_box(),
);
}
这段代码与其他代码有相同的问题,我们在每次绘制时都搜索框架,并且我们在这里做了两次。我们还在每次bounding_box
调用时构造Rect
,而我们之前明确避免过。为什么改变?因为我知道未来,我们很快就会改变构建这个结构的方式,所以在这里节省一个或两个周期不值得担心。相信我。
既然我们已经制作了一个理论上可以绘制的平台,让我们实际绘制它。首先,我们将它添加到Walk
结构体中,如下所示:
struct Walk {
boy: RedHatBoy,
background: Image,
stone: Image,
platform: Platform,
}
当然,这不会编译,因为当我们创建Walk
时,我们没有平台。我们需要更新WalkTheDog
中的initialize
函数以包含新的Platform
,如下所示:
impl Game for WalkTheDog {
async fn initialize(&mut self) -> Result<Box<dyn Game>> {
match self {
WalkTheDog::Loading => {
...
let stone = engine::
load_image("Stone.png").await?;
let platform_sheet = browser::
fetch_json("tiles.json").await?;
let platform = Platform::new(
platform_sheet.into_serde::<Sheet>()?,
engine::load_image("tiles.png").await?,
Point { x: 200, y: 400 },
);
...
Ok(Box::new(WalkTheDog::Loaded(Walk {
boy: rhb,
background: Image::new(background,
Point { x: 0, y: 0 }),
stone: Image::new(stone, Point { x:
150, y: 546 }),
platform,
})))
...
这里只有几个小的改动,我已经用高亮标记了。然后我们获取tiles.json
,并使用它和tiles.png
创建一个新的Platform
。最后,我们用platform
创建Walk
。绘制平台是一个简单的改动,添加到WalkTheDog
的draw
函数中,如下所示:
fndraw(&self, renderer: &Renderer) {
...
if let WalkTheDog::Loaded(walk) = self {
walk.background.draw(renderer);
walk.boy.draw(renderer);
walk.stone.draw(renderer);
walk.platform.draw(renderer);
}
}
如果你正确地完成了这个任务,你应该会看到以下内容:
图 5.18 – 一次逃脱!
但是,尽管平台有一个边界框,你现在还没有使用它,所以我们需要将它添加到WalkTheDog
的update
函数中。当与平台碰撞时,你希望从Jumping
状态转换回Running
状态。这个转换已经写好了 – 我们在着陆地板时做这个转换 – 所以你只需要添加一个检查和一个可以执行转换的事件。
我们还需要确保 RHB(红帽男孩)留在平台上。目前,重力会直接将他拉穿平台,无论是否有碰撞或者玩家处于Running
(跑步)状态。这个解决方案稍微复杂一些。一个简单的解决方案,我知道因为我就是写的,是在玩家站在平台上时停止应用重力。这直到它不起作用,当 RHB 从平台上跑开并停留在空中时,会产生一个Wile E. Coyote效果。假设他能够向下看,他会举一个牌子,然后重重地摔到地上。
相反,我们继续在每一帧上应用重力,并检查 RHB 是否仍在着陆平台上。如果他还在,我们就立即调整他回到平台的顶部。这意味着 RHB 会反复“着陆”,直到他到达平台的尽头,然后掉落。幸运的是,这对用户是不可见的,因为我们每次更新都会计算 RHB 的新位置,这导致他向右移动,直到他掉落边缘,就像他应该做的那样。
让我们从向update
函数添加检查开始,这样 RHB 就能站在平台上:
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
walk.boy.update();
if walk
.boy
.bounding_box()
.intersects(&walk.platform.bounding_box())
{
walk.boy.land();
}
if walk
.boy
.bounding_box()
.intersects(walk.stone.bounding_box())
{
walk.boy.knock_out()
}
...
}
}
我也重现了男孩与石头相交的检查,这样你可以看到我们在检查石头之前检查了边界框。哪个检查先进行并不重要,但我更喜欢最后检查那些可以杀死玩家的东西。这样我们就不至于在玩家真正想要站在平台上时杀死他们。就像我们在RedHatBoy
上创建knock_out
方法一样,land
方法和它对应的Event
还不存在。你现在可以创建它们两个,并跟随编译器,直到你需要在状态机中编写转换,如下所示:
impl RedHatBoyStateMachine {
fn transition(self, event: Event) -> Self {
match (self, event) {
(RedHatBoyStateMachine::Jumping(state), Event::Land) => {
state.land().into()
}
...
记住,我们已经编写了从 Jumping
到 Running
的转换方法,所以你不需要再写它,但如我之前提到的,这并不足以使 RHB 落在平台上。转换会发生,但 RHB 会直接穿过平台并撞到地上。这很不酷。为了使 RHB 保持平台上的状态,我们需要将其 y 位置设置为边界框的顶部。这意味着需要将 Land
事件修改为存储平台边界框的 y 位置。
小贴士
由于我们使用了 enum
来表示事件,我们可以通过将其作为我们使用的变体的一部分来传递任何所需的数据。Rust 的 enum
是 Rust 的一个伟大特性。
在与平台的每一次交汇中,我们将通过 Land
事件进行转换。这意味着 Update
事件会因为重力将玩家向下拉一点,但随后 Land
事件会将他们推回到原位。由于我们没有绘制中间状态,所以看起来会很好。这个系统并不完美,但我们并不是在编写物理引擎。现在让我们来做这件事;我们将首先修改 land
函数为 land_on
,并传入一个 y 位置:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
...
if walk
.boy
.bounding_box()
.intersects(&walk.platform.bounding_box())
{
walk.boy.land_on(walk.platform.bounding_box().y);
}
}
...
}
现在,land_on
而不是 land
会获取平台 bounding_box
的 y 位置。如果你只跟随编译器错误,你最终需要修改 Land
事件以保存位置并修改 Jumping
类型状态上的 land
方法。它可能看起来像这样:
impl RedHatBoyState<Jumping> {
...
pub fn land_on(mut self, position: f32) ->
RedHatBoyState<Running> {
self.context.position.y = position as i16;
RedHatBoyState {
context: self.context.reset_frame(),
_state: Running,
}
}
作为初步尝试,这似乎是可行的。遗憾的是 self
必须是可变的,但转换将 RHB 的位置恢复到平台顶部。问题是 RHB 的 y 位置实际上代表了他的左上角。这意味着如果你按照这个思路继续下去,你会得到类似这样的结果:
图 5.19 – 这看起来不正确
幸运的是,RedHatBoy
知道自己的高度,因此我们可以在设置 y 位置时调整高度。我们需要在 Land
事件中将 self.bounding_box.height()
作为参数包含在内,然后在转换过程中考虑它,如下所示:
impl RedHatBoyState<Jumping>{
...
fn land_on(mut self, position: f32, height: f32) {
let position = (position - height) as i16;
RedHatBoyState {
context: self.context.reset_frame(),
_state: Running,
}
}
}
这似乎可行,但它还有一个问题。边界框实际上在动画过程中会改变大小,基于动画的当前帧,因为剪裁精灵会略微缩小和扩大。由于我们在每一帧检查碰撞,当 RHB 在平台上时,我们会反复调用 Land
。如果我们不断地根据当前帧的高度改变着陆位置,行走看起来会非常“弹跳”。尽管边界框略有变化,但如果我们为这个计算使用一个恒定的玩家高度值,看起来会更好。
小贴士
游戏开发通常有很多试错。当数学上正确的解决方案表现不佳或看起来不正确时,请记住,游戏的感觉比数学准确性更重要。
我们已经有了玩家高度调整;我们只是将其创建为FLOOR
常量。在game
模块中,你会看到FLOOR
常量被设置为479
。那么,这意味着我们可以使用游戏的高度(600
),减去FLOOR
来得到玩家的身高。我们可以使用这个信息来创建两个新的常量。第一个,HEIGHT
,可以在游戏模块中定义为const HEIGHT: i16 = 600
,并用于我们硬编码的600
值的地方。第二个,PLAYER_HEIGHT
,可以在red_hat_boy_states
模块中定义,如下所示:
mod red_hat_boy_states {
use super::HEIGHT;
...
const FLOOR: i16 = 479;
const PLAYER_HEIGHT: i16 = HEIGHT - FLOOR;
PLAYER_HEIGHT
属于red_hat_boy_states
模块,因为它只会在那里使用,但为了计算它,我们需要将game::HEIGHT
导入到red_hat_boy_states
模块中。我们通过高亮的use
语句来完成这个操作。现在我们有了调整 RHB 着陆时的正确值,我们可以在land_on
方法和RedHatBoyContext
中考虑到它:
impl RedHatBoyContext {
...
fn set_on(mut self, position: i16) -> Self {
let position = position - PLAYER_HEIGHT;
self.position.y = position;
self
}
}
...
impl RedHatBoyState<Jumping> {
pub fn land_on(self, position: f32) -> RedHatBoyState<Running> {
RedHatBoyState {
context: self.context.reset_frame()
.set_on(position as i16),
_state: Running,
}
}
...
我们已经将 RHB 位置的调整移动到了RedHatBoyContext
中的set_on
方法。set_on
方法始终调整玩家的身高,这就是为什么它被命名为set_on
而不是set_position_y
。它还返回self
,这样我们就不需要mut self
anymore,与RedHatBoyContext
上的其他操作相匹配。
将land
方法更改为land_on
方法也要求你修改在RedHatBoyState<Jumping>
的update
方法中对其的调用。毕竟,现在没有land
方法了。记住,在调用set_on
时,我们必须考虑到高度,如下所示:
impl RedHatBoyState<Jumping> {
pub fn update(mut self) -> JumpingEndState {
self.update_context(JUMPING_FRAMES);
if self.context.position.y >= FLOOR {
JumpingEndState::Landing(self.land_on
(HEIGHT.into()))
} else {
JumpingEndState::Jumping(self)
}
}
在这里,我们正在检查 RHB 是否已经超过了FLOOR
,并将其推回到HEIGHT
。记住,当我们调用land_on
时,我们发送的是 RHB 脚的位置,而不是他的头部。你可以争论update
方法不应该检查是否触地,而WalkTheDog
中的高级update
方法应该检查与地面的碰撞,并在适当的时候使用Land
事件。我认为我可能会同意,但我们已经对这个章节做了足够多的改动,所以现在我们将保持现状。
这调整了 RHB 着陆时的位置。他在跳跃结束时将被定位在平台或地面上。现在,我们需要确保Land
事件在 RHB 着陆后防止他穿过平台。Land
事件将在平台上的Running
发生时发生,但它没有被处理,所以你会直接穿过,因为重力开始起作用。我们需要为每个在平台上有效的状态创建一个Land
转换,其中状态保持不变,但y
位置被强制回到平台的顶部。
注意
如果我可以从Big Nerd Ranch系列书籍中借用一句话,编程很难,你并不愚蠢。这些变化可能看起来像是完全成形出现的,因为我是一个超级专家,但在许多情况下,这些只是通过大量的尝试和错误、重新阅读旧书和运气才得以实现。所以,如果你没有立刻想到这个解决方案或者事情变得有些混乱,不要担心。再次尝试代码,放慢速度,享受乐趣。我们正在制作一个游戏!
幸运的是,解释为什么我们需要这段代码比实际编写它更困难。我们将在transition
方法中处理Running
状态的Land
事件:
impl RedHatBoyStateMachine {
fn transition(self, event: Event) -> Self {
match (self, event) {
...
(RedHatBoyStateMachine::Running(state), Event::
Land(position)) => {
state.land_on(position).into()
}
然后,我们将向RedHatBoyState<Running>
状态类型添加一个land_on
方法,如下所示:
...
impl RedHatBoyState<Running> {
...
pub fn land_on(self, position: f32) ->
RedHatBoyState<Running> {
RedHatBoyState {
context: self.context.set_on(position as
i16),
_state: Running {},
}
}
}
对于Running
状态中的每个Land
事件,你调整位置并保持在Running
状态。有了这个,你应该能看到 RHB 跳到平台上:
图 5.20 – 在平台上奔跑
在平台上奔跑开始工作,但如果你尝试跑到平台边缘之外,你会找到一个奇怪的 bug。RHB 会从底部掉下去!
图 5.21 – 我的上帝!我是怎么到这里的?
结果表明,我们在处理重力方面存在一个相当狡猾的 bug,我们可以将其称为“终端速度”bug,我们可以在下一步解决这个问题。
终端速度
如果你记录 RHB 在update
方法中跳跃到平台上并走过它时的y
方向velocity
,它看起来像这样:
图 5.22 – 永恒的重力!
如果你还记得,我们在每次更新时都会将重力增加1
,直到玩家再次跳跃。这意味着最终,重力变得如此之大,以至于在更新时玩家被完全拉到平台下方,并且实际上不再与平台相交。我们的平台目前位于400
。当玩家落在平台上时,他位于279
,即平台的Y 轴减去玩家的身高。在第一帧,我们通过重力将其向下拉1
,检查是否与平台相交(确实相交),然后着陆。在下一帧,我们将其向下拉2
,下一帧拉3
,以此类推。最终,我们实际上将其完全拉到平台下方,他不再与平台相交,然后——他突然在平台下方。我们需要通过给重力一个终端速度来修复这个问题。
在现实世界中,终端速度是由于物体周围空气的阻力而下降时所能达到的最快速度(更多信息请参阅go.nasa.gov/3roAWGL
)。我们不会计算 RHB 的真实终端速度,因为他的世界中没有空气,但我们可以使用非常科学的挑选一个数字并看看它是否可行的方法。我们将 RHB 的最大正y速度设置为20
,并将他的更新锁定在这个值。这将在RedHatBoyContext
的update
方法中实现,我们已经在那里修改了y以适应重力。相应的代码如下:
mod red_hat_boy_states {
...
const TERMINAL_VELOCITY: i16 = 20;
...
impl RedHatBoyContext {
pub fn update(mut self, frame_count: u8) -> Self {
if self.velocity.y < TERMINAL_VELOCITY {
self.velocity.y += GRAVITY;
}
...
将速度锁定在20
解决了我们穿过平台的问题,现在 RHB 应该像预期的那样从平台上掉落。然而,如果你尝试滑动(按下箭头键),你会看到 RHB 直接穿过平台。这是因为Sliding
状态不会响应Land
事件。你可以用与修复Running
相同的方式修复这个问题,这是一个练习题。试试看,记住如果你卡住了,最终源代码可在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_5
找到。一个提示——当你保持同一状态时,你不需要调用reset_frame
!
这几乎就是全部内容了,但还有两件事需要注意——撞到平台底部和边界框的透明度。
来自下方的碰撞
目前,如果 RHB 与平台发生碰撞,他会出现在顶部,这对于着陆来说很好,但如果他从平台下方来,那就不是那么好了。如果你现在取消注释与石头的碰撞并向前直行,你会发现你突然弹跳到平台上!为什么?因为 RHB 的头部实际上撞到了平台的底部,这次碰撞触发了land_on
事件。他不是撞头倒下,而是传送到了平台上!
我们需要在这里进行特殊的碰撞检测。RHB 只有从平台上方落下时才能落在平台上;否则,游戏就结束了。幸运的是,这可以在update
函数中通过检查碰撞方式的两处小改动来处理。当RedHatBoy
在平台上方时与平台发生碰撞意味着着陆;否则,就像撞到石头一样,你会被击倒。你还需要是下降状态;否则,你会在跳跃上升的同时粘在平台上,产生奇怪的效果。让我们看看这个改动:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
walk.boy.update();
if walk
.boy
.bounding_box()
.intersects(&walk.platform.bounding_box())
{
if walk.boy.velocity_y() > 0 && walk.boy.pos_y() < walk.platform.position.y {
walk.boy.land_on(walk.platform.bounding_box().y);
} else {
walk.boy.knock_out();
}
}
...
}
}
}
修改是为了检查 RedHatBoy
在 y 方向上的速度是否大于 0
,因此 RHB 正在向下移动。我们还检查 y 位置是否小于平台 y 位置的上端。这意味着男孩在平台上方,所以他正在着陆;否则,男孩撞到了平台上,我们将他击倒。pos_y
和 velocity_y
函数还不存在,所以我们将它们添加到 RedHatBoy
中,如下所示:
impl RedHatBoy {
...
fn pos_y(&self) -> i16 {
self.state_machine.context().position.y
}
fn velocity_y(&self) -> i16 {
self.state_machine.context().velocity.y
}
...
}
获取 RedHatBoy
的 y 值有点棘手,因为它们实际上在 RedHatBoyContext
上,但我们能够在这里完成它,并使用 getter 来方便地包装它们。
信息
为了这本书,这里的代码相当明确,但你可以通过为 RedHatBoy
的 falling
提取一个方法来使其更具表现力。我们现在就保持原样,但你在自己的代码中可能需要考虑一些更具表现力的名称。
就这样,RHB 终于可以跑了,跳过石头,落在平台上,也可以从平台上掉下来。然而,你可能已经注意到碰撞非常粗糙。他很容易撞到平台的底部,因为图像的透明部分发生了碰撞。他也可以从平台的边缘走过,这同样是因为图像的透明部分:
图 5.23 – 信不信由你,我正在空中行走
让我们花点时间调整我们的边界框来处理透明度。
边界框中的透明度
我们边界框的问题在于我们使用图像尺寸作为边界框。这意味着我们的角色周围会有很多额外的空间用于边界框。在这张屏幕截图中,我使用了本章早些时候的 draw_rect
方法来显示场景中所有三个对象的边界框:
图 5.24 – 到处都是边界框
平台在边界框中有很多空白区域,尤其是在左下角和右下角。RHB 的帽子角落附近也有空白区域。当我们关闭石头的碰撞检查并尝试在平台下行走时,RHB 在实际碰到它之前就已经“碰撞”到了平台的左下角。
RHB 脚周围的空白区域也是一个问题;它们是造成空中着陆效果的原因。他的边界框的远右边缘与平台相交,所以他会在真正到达正确位置之前就着陆。如果你看到他离开平台的边缘,你会看到当他离开时也有同样的问题。他在空中走了几步之后才开始下落。
我们将从处理 RHB 的边界框开始,使着陆和从平台上掉下来的动作看起来更逼真。
修复游戏以适应
我们可以使用一些算法来使边界框更好地匹配图像中的实际像素,但最终,它们都不是必要的。花点时间玩大多数平台游戏,你会发现碰撞并不完美,99% 的时间,这已经足够好了。在花点时间通过玩游戏进行“研究”后,我确定如果我们仅仅让边界框只宽到脚那么宽,他将发展出更加逼真的着陆。这有点反直觉。如果我们缩小边界框,他的手臂和帽子会超出边界框的边缘;我们会错过碰撞!这有关系吗?
图 5.25 – 一个窄边界框
答案是,“也许。”边界框和碰撞检测不仅仅是数学问题,它们也是游戏设计问题。当我在玩游戏时,感觉让边界框刚好包裹住脚是正确的。也许当你玩的时候,当你落在平台上或者手没有碰撞到时,会感觉太难,所以改变边界框吧!这不是一成不变的。
经过实验,我发现我还想缩短边界框,这样 RHB 就不会被他的帽子擦过而击倒。为了模仿这一点,我们可以先重命名 bounding_box
为 destination_box
,因为这代表精灵渲染到的位置。它需要位于游戏中的 RedHatBoy
位置,但具有源图像的宽度和高度;否则,图像将看起来被压扁。然后,我们可以重新实现 RedHatBoy
的边界框,如下所示:
impl RedHatBoy {
...
fn bounding_box(&self) -> Rect {
const X_OFFSET: f32 = 18.0;
const Y_OFFSET: f32 = 14.0;
const WIDTH_OFFSET: f32 = 28.0;
let mut bounding_box = self.destination_box();
bounding_box.x += X_OFFSET;
bounding_box.width -= WIDTH_OFFSET;
bounding_box.y += Y_OFFSET;
bounding_box.height -= Y_OFFSET;
bounding_box
}
我们从图像的原始尺寸 destination_box
开始,简单地通过一些偏移量缩小它。我通过使用高科技的选数和看数系统选择了这些数字。这给了我一个看起来自然的边界框,在跳跃和从悬崖上掉下来时不会太小,以至于 RHB 从来不会碰到任何东西。
如果你全局查找并替换了 bounding_box
并将其更改为 destination_box
,那么碰撞检测将是不正确的。我们需要使用 bounding_box
来检查碰撞,并使用 destination_box
来绘制。绘制应该已经完成;你需要进入 WalkTheDog
中的 update
方法,并确保每个 intersects
调用都在 bounding_box
上,而不是 destination_box
。
使用新的 bounding_box
方法和正确绘制的图像,你得到的 RHB 的边界框看起来像这样:
图 5.26 – 一个紧贴的边界框
你可以看到它比图像小得多,这使得游戏看起来更好,玩起来也更宽容。他着陆和从平台上掉下来的准确性更高,没有悬浮效果。你也可能发现现在跳过石头更容易,因为 RHB 的透明部分不会撞到岩石。
这留下了平台边缘周围的空白区域。我们可以缩小它的边界框,但这样会导致玩家在落在边缘时从平台顶部掉落。平台底部比顶部窄,这是一个问题,因为我们撞到的是底部,而落在顶部。我们真正想要做的是将平台分成多个边界框。
细分边界框
将边界框细分就像它的名字一样——我们将要取当前用于平台的那个边界框,并将其分成几个。这将显著减少框中额外的空间量,并改善我们的碰撞检测。你可能认为我们会使用复杂的算法或工具来确定使用哪些框,我们会——那是我们的眼睛。
具体来说,我们将查看平台,看到空白区域,然后尝试几个边界框分割的版本,直到我们找到一个我们喜欢的解决方案。我们可以通过使Platform
能够有多个边界框来开始这个过程。我们将再次将bounding_box
重命名为destination_box
,然后创建一个新的方法来从原始框构建一个bounding_boxes
向量,如下所示:
impl Platform {
fn bounding_boxes(&self) -> Vec<Rect> {
const X_OFFSET: f32 = 60.0;
const END_HEIGHT: f32 = 54.0;
let destination_box = self.destination_box();
let bounding_box_one = Rect {
x: destination_box.x,
y: destination_box.y,
width: X_OFFSET,
height: END_HEIGHT,
};
let bounding_box_two = Rect {
x: destination_box.x + X_OFFSET,
y: destination_box.y,
width: destination_box.width - (X_OFFSET *
2.0),
height: destination_box.height,
};
let bounding_box_three = Rect {
x: destination_box.x + destination_box.width –
X_OFFSET,
y: destination_box.y,
width: X_OFFSET,
height: END_HEIGHT,
};
vec![bounding_box_one, bounding_box_two,
bounding_box_three]
}
在这种方法中,我们创建三个矩形,每个都旨在与平台匹配,从平台的起始框开始。这是两个边缘上的小矩形和一个中间的大矩形。当我们画出这些框时,它看起来像这样:
![Figure 5.27 – Platform bounding boxes
图 5.27 – 平台边界框
这样碰撞的空白区域就少多了。你可能想知道X_OFFSET
和END_HEIGHT
的值是从哪里来的,而事实是我只是画了框,直到我满意为止。这并不花哨;只是足够好。
现在我们使用边界框的向量而不是一个,我们需要更改WalkTheDog
的update
方法中的逻辑,以确保 RHB 可以与任何框发生碰撞,并使代码编译。这段代码如下所示:
#[async_trait(?Send)]
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
...
for bounding_box in &walk.platform.bounding_boxes() {
if walk.boy.bounding_box()
.intersects(bounding_box) {
if walk.boy.velocity_y() > 0 &&
walk.boy.pos_y() <
walk.platform.position.y {
walk.boy.land_on(bounding_box.y);
} else {
walk.boy.knock_out();
}
}
}
...
}
}
这里的变化是循环遍历所有边界框,并检查任何框上的碰撞。这里只有三个框,所以我们不必每次都检查这三个框。如果电脑不能数到三,你可能需要一个新电脑。
如果你再次暂时注释掉与石头的碰撞,你会看到你几乎可以走在平台下面:
![Figure 5.28 – Just short enough
Figure 5.28 – Just short enough
在这一点上,你可能想知道这应该算作一次碰撞吗。毕竟,他的帽子确实刮到了平台的底部。玩家可能很难判断他们是否能从平台下通过。我们能找到这个问题的解决方案吗?
通过常量进行游戏设计
在我们浏览这一节时,我们为诸如FLOOR
和PLAYER_HEIGHT
之类的值引入了越来越多的常数。大多数时候,我们因为魔法数字会导致代码重复和错误,所以将它们视为“坏”的。这是真的,但对我们使用的多数数字来说,我们没有重复。不,在这种情况下,我们可以使用常数来阐明数字的含义,并用于游戏设计。然后我们可以利用游戏设计来隐藏一些小瑕疵,比如我们的平台高度几乎刚刚超过玩家。
我们在最初创建平台时,将其位置设置为Point { x: 200, y: 400 }
。这些是魔法数字——对此表示歉意。我们实际上知道400
的y值将平台放置在一个相当令人困惑的位置。如果y是370
,那么你需要从下面穿过它,如果是420
,你需要从上面越过它。我们可以为这个创建两个常数并设置位置。这个变化在这里显示:
const LOW_PLATFORM: i16 = 420;
const HIGH_PLATFORM: i16 = 375;
#[async_trait(?Send)]
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
match self {
WalkTheDog::Loading => {
...
let platform = Platform::new(
platform_sheet.into_serde::<Sheet>()?,
engine::load_image("tiles.png").await?,
Point {
x: 370,
y: LOW_PLATFORM,
},
);
图 5.29 – 你不可能成功的!
你可能会注意到在这个截图中的平台稍微向右偏。我想能够跳过石头然后跳到平台上。用我们最初构建的方式来做这是不可能的,所以我将平台向右移动。我创建了一个名为FIRST_PLATFORM
的常数,用于平台的x位置,将其设置为370
,然后设置Platform
的x位置为那个值。
我发现实际上用用户的速度和重力组合跳过石头几乎是不可能的。即使缩小了 RHB 的碰撞框,他跳得高但并不远。幸运的是,这可以通过常数轻松调整——只需将RUNNING_SPEED
从3
增加到4
,他就移动得足够快,使得跳过石头变得容易。
在我们设计无尽跑酷游戏时,我们会发现可以通过游戏设计来隐藏碰撞中的任何缺陷。你将不断需要调整玩家的速度、边界框高度和障碍物位置等值。你将游戏编码到常数中的越多,这就会变得越容易。
一个快速挑战
当我们编写代码使 RHB 从平台下方跳起时被击倒,我们引入了这里可以看到的 bug:
图 5.30 – 他是怎么到那里的?
发生的情况是,当 RHB 撞到平台的底部时,他会进入Falling
状态,但他不会改变他的速度,所以他继续跳跃。然后,在KnockedOut
状态下,重力不再作用于 RHB。你的挑战是修复这个缺陷。你需要修改状态以反映这些变化。在你检查github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_5
之前试一试。这些更改很小,并且都在现有代码中。
摘要
在本章中,我们通过让 RHB 跑进障碍物和跳上平台,使WalkTheDog
更接近一个游戏。我们用对齐的边界框完成了所有这些工作,在一个看起来像真实游戏的场景中,有背景,而不是一个空旷的虚无。我们还处理了一些处理修剪精灵图的怪癖,正确处理边界框,并利用我们在第四章中构建的状态机来处理新的动画和管理RedHatBoy
的状态。
我们还学习了碰撞不仅仅是围绕一个图像画一个框。是的,这是相交框背后的数学,但也是检查玩家是否着陆或撞到平台。我们用矩形调试了碰撞框,并使用这些矩形制作了一个更好的匹配框。我们甚至将一个图像细分为多个碰撞框!
本章内容丰富,我们做了很多工作,我鼓励你在认为合适的情况下调整和修改代码。让 RHB 跳得更高或更低,或者让他移动得更慢。尝试缩小边界框,以便更容易跳过石头或将石头放在平台上。发挥你的想象力!
总的来说,我们已经将游戏设置得可以成为一个无限跑酷游戏,有随机生成的地形和令人信服的从左到右的滚动效果。我们将在下一章中开发这个功能。
第六章:第六章:创建无限跑酷游戏
红帽男孩(RHB)可以跑步,跳上平台,甚至撞到岩石并摔倒。但一旦他开始向右跑,他就从屏幕上消失,再也没有出现过。这并不复杂,如果你等待足够长的时间,游戏甚至会因为缓冲区溢出错误而崩溃。在本章中,我们将通过在 RHB 跑步时生成新的场景来使我们的游戏真正实现无限,这些场景包含新的障碍和挑战。它们甚至包含随机性,一切始于 RHB 保持在原地!这是一个真正的技巧。
在本章中,我们将涵盖以下主题:
-
滚动背景
-
优化以实现无限跑酷
-
创建动态关卡
到本章结束时,你将拥有一个功能齐全的无限跑酷游戏,并且能够为 RHB 创建可以跳过和滑过的障碍物。
技术要求
对于本章,你需要所有在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/wiki/Assets
的资源。就像在前几章中一样,你可以在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_6
找到源代码。
查看以下视频,了解代码的实际应用:bit.ly/35pES1T
滚动背景
要用无限背景让 RHB 从左到右跑,我们有两种选择,如下:
-
根据图案或数学公式程序化生成背景。
-
使用 Hanna-Barbera 技术。
虽然第一种选择可能看起来更有趣或更动态,但 Hanna-Barbera 技术要简单得多,这就是我们将用于 Walk the Dog 的技术。Hanna-Barbera 技术是什么?首先,它可能根本不叫这个名字,但这就是我给它取的名字。Hanna-Barbera 是一家动画工作室,从 20 世纪 50 年代到 90 年代制作了一系列非常受欢迎的卡通,包括汤姆和杰瑞、弗林斯通一家、神秘岛、约吉熊等等。如果你是那个年代的孩子,你会醒来看到“周六早晨卡通”,这些卡通由 Hanna-Barbera 的作品主导。虽然该工作室因其深受喜爱的角色而闻名,但他们也因削减成本而闻名。他们制作了大量的卡通,并需要最大化他们快速且低成本地制作卡通的方式。
Hanna-Barbera 卡通最常见的特点之一是重复的背景。例如,在《YOgI 熊》的一集结束时,Ranger Smith 会开始追逐 YOgI 熊穿越 Jellystone 公园。然而,如果你仔细观察,Jellystone 公园似乎有一系列相同的树木在重复(见bit.ly/3BNuNXZ
以获取示例)。这种节省金钱的技术将非常适合我们的无尽跑酷游戏。我们将使用我们现在使用的相同背景元素,并在 RHB 向右跑时将其向左移动。紧接着,它将变成相同背景的副本,从而创建两个具有相同源图像的Image
元素。一旦第一张图像完全移出屏幕,我们将将其移动到第二张图像的右侧。这两个背景将循环,创造出背景永远在移动的错觉:
![Figure 6.1 – Sliding the canvas over the background
图 6.1 – 在背景上滑动画布
这种技术依赖于三个因素。第一个是背景必须是无缝的,这样两个图像之间就没有可见的缝隙。幸运的是,我们的背景就是为了这个目的而构建的,并且它将工作得很好。第二个是画布窗口需要比背景小,这样整个背景就永远不会显示在屏幕上。如果我们这样做,那么第一个背景就可以完全移出屏幕到左边,然后被移动到第二个背景的右边,而这一切都不会有任何明显的缝隙或撕裂。这是因为这一切都发生在窗口边界之外。我倾向于将其想象成戏剧中的幕后,然后迅速跳到幕布的右侧。
最后,我们必须使用另一个错觉并冻结主要角色。不是将角色从屏幕左边移动到右边,而是将物体从右向左移动,几乎就像在跑步机上一样。从视觉上看,这看起来就像角色在跑步一样,并且它有一个优点,即修复了一个错误,如果玩家持续向右跑,他们的x位置最终会溢出(变得比我们用来保存它的 i16 更大),从而导致游戏崩溃。我们将不得不通过改变x速度来调整我们的思维,但一旦你习惯了它,你会发现它工作得相当容易。让我们开始制作我们的滚动背景。
注意
对于这种技术的另一个示例,请访问bit.ly/3BPNBGc
,它解释了这种技术在持续向上移动的游戏中的应用,例如 Doodle Jump。
修复 RHB 的 x 坐标
我们可以随意滚动背景,但如果我们继续同时将 RHB 向右移动,效果将是让他以双倍速度奔跑。相反,我们希望 RHB 原地奔跑,而岩石和平台则向他移动,就像它们在传送带上一样。在本节结束时,我们将看到 RHB 向右跑进一个空白的白色空间,而一切都在他身边经过,就像他正在跑过世界的尽头。
让我们从game::red_hat_boy_states
模块开始,并在RedHatBoyContext
的update
方法中不更新x
:
impl RedHatBoyContext {
fn update(mut self, frame_count: u8) -> Self {
...
// DELETE THIS LINE! self.position.x +=
self.velocity.x
self.position.y += self.velocity.y;
...
通过这个改变,RHB 将原地运行,周围没有任何东西移动。我们保持velocity
不变,因为这个值将被代码库的其余部分使用。为了方便使用,我们将添加一些方法。首先,让我们向RedHatBoy
实现添加一个访问器,如下所示:
impl RedHatBoy {
...
fn walking_speed(&self) -> i16 {
self.state_machine.context().velocity.x
}
此函数的工作方式与我们的其他几个RedHatBoy
访问器类似,这使得获取context
值更容易。接下来,让我们添加一个新的实现——Walk
结构体的Walk
:
impl Walk {
fn velocity(&self) -> i16 {
-self.boy.walking_speed()
}
}
Walk
实现仅在WalkTheDog
枚举处于Loaded
状态时可用,并且它翻转boy
的walking_speed
。当boy
向右移动时,这意味着其他所有东西都在向左移动。现在,在WalkTheDog
的update
函数中,我们可以使用该值将其他所有东西向左移动。在更新walk.boy
之后,我们可以更新stone
和platform
的位置,使它们与以下代码匹配:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
walk.boy.update();
walk.platform.position.x += walk.velocity();
walk.stone.move_horizontally(walk.velocity());
...
你应该得到一个编译器错误,因为stone
没有move_horizontally
函数。Stone
是Image
类型,可以在engine
模块中找到,而Image
上的position
是私有的。我们将保持这种方式,并在此处将move_horizontally
添加到Image
实现中,如下所示:
impl Image {
...
pub fn move_horizontally(&mut self, distance: i16) {
self.bounding_box.x += distance as f32;
self.position.x += distance;
}
}
这段代码可能有两件事会让你感到烦恼。第一点是我们在Platform
上直接操作position
,但使用了Image
上的方法。这种不一致性是一个异味,告诉我们我们的代码中可能有问题——在这种情况下,stone
和platform
有两个不同的接口来修改它们的位置,尽管代码已经被复制。现在,我们将保持原样,但这是一个关于我们可能想要稍后做出的更改的提示。另一点是我们在更新bounding_box
和position
值时使用了相同的内容。这是一个我们将留到下一节(在Rect Point
上放置position
)的重构,尽管如果你有雄心壮志,你现在就可以做。
注意
代码异味是一个编程术语,由 Kent Beck 提出,并由 Martin Fowler 在其书籍《重构》中普及。如果你因为编程而获得报酬,无论是游戏还是其他,你应该看看这本书。
现在,你应该看到 RHB 原地奔跑,岩石和平台在他下面移动:
图 6.2 – 岩石去哪了?
提示
如果更改似乎没有显示出来,不要忘记重新启动服务器。我删除代码时就是这样做的,出于某种原因。
我们可以通过匹配WalkTheDogupdate
函数中的stone
和platform
移动来开始移动背景。这个更改看起来如下所示:
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
walk.platform.position.x += walk.velocity();
walk.stone.move_horizontally(walk.velocity());
walk.background.move_horizontally(walk.velocity());
...
这个小小的更改意味着 RHB 现在可以走出世界的边缘:
图 6.3 – 看看,这空旷的虚无!
然而,我们不想这样,所以让我们学习如何使用两个平铺背景来模拟一个无限的背景。
无限背景
要得到无限背景,我们需要两个背景图像而不是一个。我们将首先将background
存储为数组,而不是在Walk
中只存储一个Image
,如下所示:
struct Walk {
boy: RedHatBoy,
backgrounds: [Image; 2],
stone: Image,
platform: Platform,
}
这将导致几个编译器错误,因为backgrounds
不存在;即使它存在,代码也期望它是一个Imagearray
。幸运的是,错误在很大程度上是有意义的,我们可以找出需要做什么。再次移动到Game
实现的initialize
中,让我们在初始化Walk
时设置一个backgrounds
数组,而不是只有一个,如下所示:
impl Game for WalkTheDog {
async fn initialize(&mut self) -> Result<Box<dyn Game>> {
match self {
WalkTheDog::Loading => {
...
let background_width = background.width()
as i16;
Ok(Box::new(WalkTheDog::Loaded(Walk {
boy: rhb,
backgrounds: [
Image::new(background.clone(),
Point { x: 0, y: 0 }),
Image::new(
background,
Point {
x: background_width,
y: 0,
},
),
],
stone: Image::new(stone, Point { x:
150, y: 546 }),
platform,
})))
...
与我们之前的变化相比,这里发生了一些额外的事情,所以让我们更详细地看看这段代码。我们首先获取background
的width
属性。这是我们加载HtmlImageElement
时创建的临时变量,而不是我们一直在使用的附加到Walk
的background
属性。我们这样做是为了防止在Walk
初始化期间出现借用后移动错误。然后,我们让Walk
接受一个Image
对象的数组,确保在第一次创建时克隆background
属性。最后,我们确保将第二个Image
定位在background_width
处,以便它与第一个背景对齐,位于屏幕之外。
然而,我们还没有完成编译器错误的问题。这是因为背景正在更新和绘制。我们将做出最简单的更改,以便我们可以重新开始编译和运行。首先,将我们在update
函数中刚刚编写的move_horizontally
代码替换为以下代码,该代码遍历所有背景并将它们移动:
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
walk.platform.position.x += walk.velocity();
walk.stone.move_horizontally(walk.velocity());
let velocity = walk.velocity();
walk.backgrounds.iter_mut().for_each(|background| {
background.move_horizontally(velocity);
});
确保使用iter_mut
,以便background
是可变的。注意,您需要将walk.velocity()
绑定到一个临时变量上;否则,您将得到一个编译器错误,提示cannot borrow '*walk' as immutable because it is also borrowed as mutable
。现在,您可以更新draw
函数来绘制所有背景:
fn draw(&self, renderer: &Renderer) {
...
if let WalkTheDog::Loaded(walk) = self {
walk.backgrounds.iter().for_each(|background| {
background.draw(renderer);
});
...
在这里,我们再次遍历backgrounds
并绘制它们,依赖于画布只显示屏幕上的背景。如果你在运行此代码时玩游戏,你会看到 RHB 跑得更远,但不会无限跑。这是因为我们没有循环背景。如果你运行游戏足够长时间,你会看到游戏也会因为缓冲区溢出错误而崩溃,但我们在下一节中会修复这个问题。首先,我们需要让背景循环。我们可以通过将update
函数中的循环替换为显式解构数组的代码来实现这一点,如下所示:
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
walk.platform.position.x += walk.velocity();
walk.stone.move_horizontally(walk.velocity());
let velocity = walk.velocity();
let [first_background, second_background] = &mut
walk.backgrounds;
first_background.move_horizontally(velocity);
second_background.move_horizontally(velocity);
if first_background.right() < 0 {
first_background.set_x(
second_background.right());
}
if second_background.right() < 0 {
second_background.set_x(
first_background.right());
}
...
在这里,我们首先用let [first_background, second_background] = &mut walk.backgrounds;
替换for
循环,以便访问两个背景。然后,我们将它们都向左移动,就像我们在循环中做的那样,并检查图像的右侧是否为负。这意味着图像已经离屏幕,因此我们可以继续将其移动到另一个背景的右侧。如果你输入这段代码,它将无法编译,因为set_x
和right
在Image
结构体上不存在。再次打开engine
模块,这样我们就可以将它们添加到Image
中,如下所示:
impl Image {
...
pub fn move_horizontally(&mut self, distance: i16) {
self.set_x(self.position.x + distance);
}
pub fn set_x(&mut self, x: i16) {
self.bounding_box.x = x as f32;
self.position.x = x;
}
pub fn right(&self) -> i16 {
(self.bounding_box.x + self.bounding_box.width) as
i16
}
}
在这里,我们添加了一个set_x
函数,它更新position
和bounding_box
,就像我们之前做的那样,并且我们让move_horizontally
调用它以避免重复。我们还添加了一个right
函数,它根据当前位置计算bounding_box
的右侧。有了这个,RHB 现在可以永远向右跑!嗯,直到缓冲区溢出并崩溃。幸运的是,我们将在下一节中处理这个问题。
重构以实现无限运行
到现在为止,你已经正确地注意到了一个模式。每次我们添加一个新功能,我们首先重构旧代码,使其更容易添加。这在大多数形式的软件开发中通常是一个好的实践,我们现在也将遵循相同的模式。在创建无限背景时,我们识别出了一些代码异味,所以现在让我们清理这些,从处理所有这些类型转换开始。
f32 与 i16
我们不得不多次将值从i16
转换为f32
然后再转换回来。这不是一个安全的操作;f32
的最大值比i16
的最大值大几个数量级,因此我们的程序有可能在大的f32
上崩溃。HtmlImageElement
使用u32
类型,所以所有为了让编译器闭嘴的类型转换都是不正确的。我们在这里有两个选择:
-
将我们的数据类型(如
Rect
和Point
)与HtmlImageElement
匹配。 -
将
Rect
和任何其他域对象设置为我们的首选、较小的类型,并在需要时转换为较大的类型。
我想我们到目前为止一直在使用第二个选择——即随机抛掷以使编译器编译——但这并不是理想的选择。虽然第一个选择很有吸引力,因为我们不会有任何类型转换,但我更喜欢Rect
和Point
尽可能小,因此我们将它们设置为使用i16
作为它们的值。这对于我们所有的游戏对象来说已经足够大了,而且较小的尺寸可能对性能有益。
注意
WebAssembly 规范没有i32
类型,所以在这里i32
同样有效。它也没有无符号类型,所以可能值得分析哪种类型最快。就我们的目的而言,我们将选择最小的合理大小——i16
。正如我的一位教授曾经说过,“我们用 16 位到达了月球!”
要开始这种方法,将engine::Rect
中的所有字段从f32
更改为i16
。然后,跟随编译器错误。首先让它编译,根据需要将i16
转换为f32
。在编译并再次运行之后,寻找我们可以从i16
转换为f32
的地方,并在可能的情况下将其删除。这将包括查看Event
枚举中的Land
事件,它包含一个f32
,并将其更改为i16
。最后,寻找所有将值转换为i16
的地方,并看看它是否仍然必要。这最终会在很多地方,但应该不会太痛苦;最终,应该只剩下几个必要的转换。这样做时要慢而仔细,以免在处理错误时陷入困境。
一个更有用的矩形
Rect
实现只包含intersects
方法,但它可以使用两个非常有用的方法:right
和bottom
。如果你看看我们刚刚在Image
上写的那个方法,你会看到它非常适合right
函数。让我们继续添加到Rect
中:
impl Rect {
pub fn intersects(&self, rect: &Rect) -> bool {
self.x < rect.right()
&& self.right() > rect.x
&& self.y < rect.bottom()
&& self.bottom() > rect.y
}
pub fn right(&self) -> i16 {
self.x + self.width
}
pub fn bottom(&self) -> i16 {
self.y + self.height
}
添加right
和bottom
方法将防止这种添加逻辑散布到游戏逻辑中。我们还重构了intersects
以使用这些新方法。现在,让我们回到我们刚刚编写的Image
代码,并更新它以使用新的right
方法,如下所示:
impl Image {
...
pub fn right(&self) -> i16 {
self.bounding_box.right()
}
}
当我们在Image
中时,让我们处理position
和bounding_box
的重复。
设置矩形的坐标
包含边界框Rect
和位置Point
的图像是由于我们的代码演变而发生的意外。所以,问题是,我们想要保留哪一个?我们可以始终保留图像的bounding_box
,这意味着每次我们绘制时都需要构造一个Point
,因为我们需要它来调用draw_entire_element
。我们还可以创建一个只包含width
和height
的Dimens
结构,并在需要时在更新中构造一个Rect
。虽然我怀疑创建这些对象的成本将不会引起注意,但它在每一帧上都令人烦恼。
我们将要做的是给 Rect
一个 position
字段——毕竟,这就是 Rect
的 x
和 y
坐标。这是一个看似微小的变化,但具有深远的影响,因为我们不断地用 x
和 y
初始化 Rect
。幸运的是,我们可以使用编译器来简化这个过程。我们首先将 Rect
改为持有 position
字段,而不是 x
和 y
:
pub struct Rect {
pub position: Point,
pub width: i16,
pub height: i16,
}
添加 position
将会在各个地方引起编译错误,这是预料之中的。我们知道我们经常需要同时访问 x
和 y
值并使用 x
和 y
创建一个 Rect
,因此为了便于操作,我们将为 Rect
添加两个 factory
方法,如下所示:
impl Rect {
pub fn new(position: Point, width: i16, height: i16) ->
Self {
Rect {
position,
width,
height,
}
}
pub fn new_from_x_y(x: i16, y: i16, width: i16, height:
i16) -> Self {
Rect::new(Point { x, y }, width, height)
}
...
现在,当我们修复 Rect
的所有地方时,我们将停止直接创建 Rect
,而是使用新的构造方法。我们还将添加 x
和 y
的获取器,因为我们经常访问它们,如下所示:
impl Rect {
...
pub fn x(&self) -> i16 {
self.position.x
}
pub fn y(&self) -> i16 {
self.position.y
}
这为你提供了修复编译错误所需的大部分工具。我不会全部重新展示,因为错误有很多,而且重复性很高。有两个例子你可以使用,以处理除一个错误之外的所有错误。第一个是将所有对 .x
或 .y
的引用替换为对方法的引用。
这就是在 Rect
的 intersects
方法中这样做的方式:
impl Rect {
...
pub fn intersects(&self, rect: &Rect) -> bool {
self.x() < self.right()
&& self.right() > rect.x()
&& self.y() < rect.bottom()
&& self.bottom() > rect.y()
}
如你所见,它与之前相同,只是将 x
和 y
替换为 x()
和 y()
。除了在访问 x
或 y
时看到错误外,你还会在创建 Rect
时看到错误,因为 position
字段未指定。你将想要直接使用构造方法之一来创建 Rect
,如下所示在 Image
的实现中:
impl Image {
pub fn new(element: HtmlImageElement, position: Point)
-> Self {
let bounding_box = Rect::new(position,
element.width() as i16, element.height() as i16);
...
解决那些将在 engine
和 game
模块中出现的编译错误,将只留下一个剩余的失败。这可以在 Image
的 set_x
方法中找到。这是因为我们需要设置 bounding_box.x
的值。而不是使用 position.x
,这会编译但如果我们再次更改 Rect
的内部结构,则会暴露我们于错误之中,我们将向 Rect
实现中添加一个设置器,如下所示:
impl Rect {
...
pub fn set_x(&mut self, x: i16) {
self.position.x = x
}
}
现在,在 Image
中,我们可以通过使用 set_x
来修复最后的编译错误,如下所示:
impl Image {
...
pub fn set_x(&mut self, x: i16) {
self.bounding_box.set_x(x);
self.position.x = x;
}
注意
你可能已经注意到,当代码使用设置器而不是直接使用公共变量时,代码是不一致的。一般来说,我的经验法则是像 Rect
这样的简单结构不需要设置器和获取器,特别是如果我们保持它们不可变的话。然而,如果内部结构发生变化,就像这里发生的那样,那么是时候添加一个抽象来隐藏内部结构了。从 x
和 y
到位置的这一变化,最终证明了设置器的必要性。
在这个阶段,你应该看到 RHB 向右跑并再次跳上跳下平台。确保每次成功编译时都检查这种行为,因为当你进行大量的小改动时,很容易出错。
现在我们已经为Rect
准备了存储position
,我们可以从Image
中移除该数据的重复。我们将从从Image
结构体中移除position
开始,如下所示:
pub struct Image {
element: HtmlImageElement,
bounding_box: Rect,
}
现在,让我们跟随编译器,从Image
实现中移除所有对position
的引用。幸运的是,现在没有对position
的引用存在于Image
实现之外,所以我们可以通过进行一些快速更改来完成这项工作。这些更改如下所示。注意,我们之前在哪里使用position
,现在我们使用bounding_box.position
或bounding_box.x()
:
impl Image {
pub fn new(element: HtmlImageElement, position: Point)
-> Self {
let bounding_box = Rect::new(position,
element.width() as i16, element.height() as i16);
Self {
element,
bounding_box,
}
}
pub fn draw(&self, renderer: &Renderer) {
renderer.draw_entire_image(&self.element,
&self.bounding_box.position)
}
pub fn bounding_box(&self) ->&Rect {
&self.bounding_box
}
pub fn move_horizontally(&mut self, distance: i16) {
self.set_x(self.bounding_box.x() + distance);
}
pub fn set_x(&mut self, x: i16) {
self.bounding_box.set_x(x);
}
...
现在我们已经从Image
中移除了重复,我们准备好将一个关卡中的所有障碍物放入一个共享的trait
中,这样我们就可以在一个列表中使用它们。这样做将允许我们修复当缓冲区因无限运行而溢出时发生的错误,并为动态添加许多共享段准备代码。让我们开始吧!
障碍物特质
目前,在Walk
结构体中,石头和平台是独立的对象。如果我们想给游戏添加更多障碍物,我们必须向这个结构体添加更多字段。如果我们想要有一个无限生成的跳过和滑过的物品列表,这就会成为一个问题。我们真正想要做的是保持一个Obstacles
列表,遍历每一个,并检查当RedHatBoy
与之相交时应该做什么。我们为什么要这样做呢?让我们看看:
-
它将消除消除 RHB 时的重复,并消除我们为了继续当前模式而必须创建的未来重复。
-
我们希望将每个
Obstacle
视为相同,这样我们就可以在飞行中创建障碍物。 -
我们将能够移除任何已经离开屏幕的障碍物。
我们将首先在game
模块中创建一个名为check_intersection
的新Obstacle
特质,以及两个已经在Platform
上存在的特质:
pub trait Obstacle {
fn check_intersection(&self, boy: &mut RedHatBoy);
fn draw(&self, renderer: &Renderer);
fn move_horizontally(&mut self, x: i16);
}
为什么有三个方法?stone
和platform
都将实现Obstacle
,我们需要遍历它们,绘制它们,并移动它们。所以,这就是为什么特质包含move_horizontally
和draw
。新的方法check_intersection
存在,因为platform
允许你着陆在上面,而stone
则不行。所以,我们需要一个可以处理不同类型Obstacle
交叉的不同抽象。现在我们已经创建了我们的trait
,我们可以在Platform
结构体上实现它。我们可以从将draw
从Platform
实现中提取出来并创建一个move_horizontally
方法开始,如下所示:
impl Obstacle for Platform {
fn draw(&self, renderer: &Renderer) {
...
}
fn move_horizontally(&mut self, x: i16) {
self.position.x += x;
}
}
我在这里省略了draw
的实现,因为这个方法没有变化。同时,move_horizontally
模仿了之前在update
中识别出的代码。
最后,让我们添加check_intersection
函数,该函数目前存在于WalkTheDog
的update
方法中:
for bounding_box in &walk.platform.bounding_boxes() {
if walk.boy.bounding_box().intersects(bounding_box) {
if walk.boy.velocity_y() > 0 && walk.boy.pos_y() <
walk.platform.position.y {
walk.boy.land_on(bounding_box.y);
} else {
walk.boy.knock_out();
}
}
}
为Platform
实现的版本应该非常相似,没有对walk
的引用,如下所示:
impl Obstacle for Platform {
...
fn check_intersection(&self, boy: &mut RedHatBoy) {
if let Some(box_to_land_on) = self
.bounding_boxes()
.iter()
.find(|&bounding_box| boy.bounding_box()
.intersects(bounding_box))
{
if boy.velocity_y() > 0 && boy.pos_y() <
self.position.y {
boy.land_on(box_to_land_on.y());
} else {
boy.knock_out();
}
}
}
}
这段代码大体相同,但有一个相当重要的优化:而不是遍历Platform
中的每个边界框,这段代码使用find
来获取第一个相交的边界框。如果有(if let Some(box_to_land_on)
),那么我们处理碰撞。这防止了在找到碰撞后的重复检查。其余的代码稍微短一些,没有对walk
的引用,这是很棒的。现在,我们需要将Walk
中的Platform
替换为对堆上的它的引用,如下所示:
struct Walk {
boy: RedHatBoy,
backgrounds: [Image; 2],
stone: Image,
platform: Box<dyn Obstacle>,
}
备注
我们在这里确实有一个替代方案,那就是使用一个包含每种障碍物类型的枚举,就像我们在状态机中做的那样。使用dyn
关键字进行动态分发的权衡是,一个查找表被存储在内存中。这个好处是我们写更少的样板代码,而且代码不需要每次添加障碍物时都更新。在这种情况下,我认为trait
和枚举对状态机一样好,但这是值得记住的。
这将导致两个编译错误,我们可以通过进行小的修改来修复它们。在WalkTheDog
的initialize
方法中,我们在创建Walk
时没有正确设置platform
,所以让我们进行一个小改动,如下所示:
impl Game for WalkTheDog {
async fn initialize(&mut self) -> Result<Box<dyn Game>> {
match self {
WalkTheDog::Loading => {
...
Ok(Box::new(WalkTheDog::Loaded(Walk {
...
platform: Box::new(platform),
})))
}
...
这只是一个单行的改动,涉及将platform
替换为platform: Box::new(platform)
。另一个修复是你会记得的一个问题——当stone
使用名为move_horizontally
的方法时,直接在x
上设置位置。这就是为什么我们在Platform
结构体上的Obstacle
特质中创建了那个方法。这个改动可以在WalkTheDog
的update
函数中找到,如下所示:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
let velocity = walk.velocity();
walk.platform.move_horizontally(velocity);
walk.stone.move_horizontally(velocity);
platform
和stone
都有move_horizontally
函数,这是一个迹象表明这些接口可以被合并,我们将在稍后做到这一点。最后,我们必须用对这个函数的调用替换我们移动到check_intersection
中的代码。在update
函数的稍低处,你将想要更新以下代码:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
if second_background.right() < 0 {
second_background.set_x(
first_background.right());
}
walk.platform.check_intersection(&mut
walk.boy);
if walk
.boy
.bounding_box()
.intersects(walk.stone.bounding_box())
{
walk.boy.knock_out()
}
check_intersection
的调用在检查你是否撞到石头之前,在背景更新之后。你可能注意到检查与石头碰撞的代码有所不同,从某种意义上说,当与boy
碰撞时,它总是被击倒,但它在概念上也是相同的,因为你再次检查与障碍物的碰撞,然后做些什么。这就是为什么我们需要将当前是Image
类型的stone
转换为Obstacle
类型。但应该是什么类型呢?
障碍物与平台
我们需要另一种类型的Obstacle
,它不能着陆,而目前stone
是一个Image
。向Image
添加功能并不合适,因为Obstacle trait
是一个game
概念,而Image
是engine
的一部分。相反,我们将创建一种总是导致用户与之碰撞的Obstacle
类型,称为Barrier
,并将stone
转换为那种类型。这是一块非常危险的石头。
我们将首先创建一个Barrier
结构体,并使用占位符实现Obstacle
特质,如下所示:
pub struct Barrier {
image: Image,
}
impl Obstacle for Barrier {
fn check_intersection(&self, boy: &mut RedHatBoy) {
todo!()
}
fn draw(&self, renderer: &Renderer) {
todo!()
}
fn move_horizontally(&mut self, x: i16) {
todo!()
}
}
小贴士
我在使用add-missing-members
操作时,用rust-analyzer
生成了这个骨架。在我的编辑器(emacs)中,这只需要输入c v
。在 Visual Studio Code 中,只需点击灯泡并选择todo!
宏,如果没有实现就调用此代码,将抛出一个运行时异常,这是为了向编译器传达临时代码的存在。
注意
目前,所有的Barrier
对象都必须是Image
,而Platform
使用精灵表。你可能想为所有东西使用精灵表,甚至为所有东西使用一个精灵表,这也是可以的——甚至更好。我们将保持现状,因为我们已经对这个应用程序进行了足够的重新设计。
在我们填充所有那些todo!
块之前,让我们添加一个典型的new
方法来创建Barrier
对象:
impl Barrier {
pub fn new(image: Image) -> Self {
Barrier { image }
}
}
现在,我们可以填充函数。draw
和move_horizontally
函数可以委托给Image
,如下所示:
impl Obstacle for Barrier {
...
fn draw(&self, renderer: &Renderer) {
self.image.draw(renderer);
}
fn move_horizontally(&mut self, x: i16) {
self.image.move_horizontally(x);
}
}
最终的函数check_intersection
将略有不同。与Platform
不同,Platform
是男孩可以着陆的地方,而Barrier
总是导致碰撞。这个代码已经在WalkTheDog
的update
方法中存在,因为我们就是用它来处理stone
的。让我们在这里模仿那个实现:
impl Obstacle for Barrier {
...
fn check_intersection(&self, boy: &mut RedHatBoy) {
if boy.bounding_box().intersects(
self.image.bounding_box()) {
boy.knock_out()
}
}
}
Barrier
目前还没有被使用。因此,我们可以从将stone
从Image
改为Barrier
开始。然而,我们将做得更多。我们将在Walk
中创建一个包含所有Obstacle
类型的列表。这将使我们能够减少Walk
中的特定代码量,并且可以更简单地动态生成新的障碍物。记住,这是我们重构的原因。让我们创建我们的列表并将其添加到Walk
结构体中,如下所示:
struct Walk {
boy: RedHatBoy,
backgrounds: [Image; 2],
obstacles: Vec<Box<dyn Obstacle>>,
}
注意,我们已经从Walk
中移除了platform
和stone
,我们需要更新其余的实现,并将对stone
和platform
的直接引用替换为对Obstacle
向量的引用。这并不意味着我们永远不会再次提到platform
和stone
;我们仍然需要加载图像和精灵表,但我们只会提到一次。再次提醒,我们将查看编译器错误信息,这些信息大量抱怨WalkTheDog
中的initialize
、update
和draw
方法。让我们首先从修改initialize
函数开始,如下所示:
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
...
Ok(Box::new(WalkTheDog::Loaded(Walk {
...
obstacles: vec![
Box::new(Barrier::new(Image::new(
stone, Point { x: 150, y: 546 }))),
Box::new(platform),
],
})))
...
我们只更改了Walk
结构的构建,将stone
和platform
的引用替换为初始化obstacles
向量。向量中的第一个项目现在是一个Barrier
,但这只是我们之前创建的stone
对象被新的Barrier
结构包装起来。第二个是之前创建的platform
对象。所有内容都必须在Box
中,这样我们才能使用Obstacle
特质。接下来我们将做的几个更改必须在update
方法中完成。我们将稍微调整一下代码,首先更新boy
,然后是我们的背景,最后是我们的obstacles
,如下所示:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
if second_background.right() < 0 {
second_background.set_x(
first_background.right());
}
walk.obstacles.iter_mut().for_each(|obstacle| {
obstacle.move_horizontally(velocity);
obstacle.check_intersection(&mut walk.boy);
});
}
}
...
在update
中不应有直接引用stone
或platform
。现在,检查障碍物移动以及它们是否相交的代码应该只有四行长,并且位于update
方法的底部——这还是慷慨地计算了闭合花括号。确保你使用iter_mut
方法,因为我们正在循环中修改obstacle
。我们可以判断我们的设计方向是否正确的一种方式是,我们正在编写的代码更少,但与更多事物协同工作。最后,我们需要绘制所有的obstacles
,这可以通过更新draw
方法来实现,如下所示:
impl Game for WalkTheDog {
...
fn draw(&self, renderer: &Renderer) {
...
if let WalkTheDog::Loaded(walk) = self {
...
walk.obstacles.iter().for_each(|obstacle| {
obstacle.draw(renderer);
});
}
}
}
在这种情况下,我们可以使用for_each
和普通的iter()
。正如你可能猜到的,当我们想要在屏幕上添加更多障碍物时,我们只需将它们添加到obstacles
列表中。到目前为止,代码应该再次工作;RHB 应该跳过平台和石头,然后撞到它们。现在,我们只需要处理如果让 RHB 继续运行时发生的崩溃。我们将在下一部分处理这个问题。
随着障碍物从屏幕上消失时移除它们
如果你让 RHB 向右跑足够长的时间,你会看到一个类似这样的崩溃信息:
panicked at 'attempt to add with overflow', src/engine.rs:289:20
Stack:
上述代码来自浏览器的日志。在这里,图像向左移动越来越远,直到最终达到有符号 16 位整数的最大长度。这是因为在障碍物从屏幕上消失时,我们从未从障碍物 Vec 中移除障碍物,我们应该这样做。让我们在update
函数中添加一行代码,就在我们移动和与障碍物碰撞之前,如下所示:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
walk.obstacles.retain(|obstacle|
obstacle.right() > 0);
walk.obstacles.iter_mut().for_each(|obstacle| {
obstacle.move_horizontally(velocity);
obstacle.check_intersection(&mut walk.boy);
});
...
retain
函数将保留任何与传入的谓词匹配的obstacles
。在这种情况下,如果障碍物的最右端点在屏幕的左侧边缘的右侧,这将发生。这意味着我们正在两次遍历障碍物列表。如果我们使用 Rust 的 nightly 构建,我们可以使用drain_filter
函数来避免这种情况,但我们的obstacles
列表永远不会长到成为问题。为了使这段代码编译,你需要在Obstacle
特质中添加一个额外的方法——right
方法,用于Obstacle
的最右端点。这可以在以下代码中看到:
trait Obstacle {
...
fn right(&self) -> i16;
}
这种方法需要添加到Obstacle
的Platform
和Barrier
实现中。Barrier
可以直接委托到它持有的图像,而Platform
稍微复杂一些,因为它有多个盒子。我们希望使用最后一个边界框的右边,如下所示:
impl Obstacle for Platform {
...
fn right(&self) -> i16 {
self.bounding_boxes()
.last()
.unwrap_or(&Rect::default())
.right()
}
}
这段代码使用last
获取最后一个边界框并解包它,因为last
返回一个Option
。我们不希望返回一个Result
然后强迫每个人使用Result
,所以我们使用unwrap_or(&Rect::default())
在Platform
没有边界框时返回一个空的Rect
。一个空的边界框实际上等同于没有边界框。然后,我们使用right
获取最后一个Rect
的最右侧值。
Rect
还没有默认实现,所以我们需要在engine
中的Rect
和Point
结构上添加#[derive(Default)]
注解。该注解通过使用该struct
中每个字段的默认值来自动实现Default
特质。Point
需要这个注解,因为它位于Rect
结构中,所以为了让宏对Rect
起作用,它也必须对Point
起作用。幸运的是,添加这个注解并没有真正的害处。
这样,你可以让 RHB 运行尽可能长时间,而不会发生缓冲区溢出。现在,我们需要给 RHB 提供许多可以跳上的平台。我们将从共享精灵图集开始。让我们深入研究这个重构的最后部分。
分享精灵图集
每个Platform
都有一个对Image
和Sheet
的引用,我们随意称之为“精灵图集”。当我们开始生成更多的Platform
对象时,我们希望共享图集的引用。因此,现在是时候在我们的engine
中添加一个SpriteSheetstruct
以实现这一点。让我们打开engine
模块并添加这个新概念。
创建精灵图集
我们将首先在engine
模块中创建一个struct
,它同时包含HtmlImageElement
和Sheet
:
pub struct SpriteSheet {
sheet: Sheet,
image: HtmlImageElement,
}
现在,让我们创建一个实现,它将封装我们在Platform
中使用的图集的常见行为:
impl SpriteSheet {
pub fn new(sheet: Sheet, image: HtmlImageElement) ->
Self {
SpriteSheet { sheet, image }
}
pub fn cell(&self, name: &str) -> Option<&Cell> {
self.sheet.frames.get(name)
}
pub fn draw(&self, renderer: &Renderer, source: &Rect,
destination: &Rect) {
renderer.draw_image(&self.image, source, destination);
}
}
我最初考虑让draw
函数接受我们正在绘制的cell
属性的名称,但现在,我们的Platform
一次绘制多个cell
,我们希望保持这种功能。让我们将Platform
中的HtmlImageElement
和Sheet
替换为SpriteSheet
字段,如下所示:
pub struct Platform {
sheet: SpriteSheet,
position: Point,
}
不要忘记从engine
模块导入SpriteSheet
。现在,你可以跟随编译器简化Platform
,通过移除对Sheet
和HtmlImageElement
的引用,只使用SpriteSheet
。特别是,你需要更改new
函数,使其接受一个SpriteSheet
而不是两个参数。以下代码显示了如何在WalkTheDog
的initialize
方法中初始化这一点:
impl Game for WalkTheDog {
async fn initialize(&mut self) -> Result<Box<dyn Game>>
{
match self {
WalkTheDog::Loading => {
...
let platform = Platform::new(
SpriteSheet::new(
platform_sheet.into_serde::
<Sheet>()?,
engine::load_image(
"tiles.png").await?,
),
Point { x: 200, y: 400 },
);
...
Platform
的其余部分可以修改以适应新的接口。注意,你不再需要说 frames
,可以直接调用 sheet.cell
。draw
方法现在将委托给 self.sheet.draw
并传递 renderer
而不是 Image
。这个结构很小,如果我们不想要在多个 Platform
对象之间共享相同的 SpriteSheet
,那么这就不值得努力。但我们确实想要共享一个 SpriteSheet
,而不是在各个地方复制内存。因此,我们需要使其能够共享。
共享精灵表
要在多个 Platform
之间共享 SpriteSheet
,我们需要将其存储在一个所有平台都可以指向的地方,并指定一个作为 SpriteSheet
的所有者。我们可以给 SpriteSheet
一个 static
生命周期,并使其全局,但这意味着它必须是一个 Option
,因为它在 initialize
被使用之前不可用。相反,我们将 SpriteSheet
的引用计数版本存储在 Walk
结构体中。这是一个权衡,因为我们将使用引用计数而不是所有权来跟踪何时应该删除 SpriteSheet
,但作为交换,我们只会在内存中复制指针而不是整个 SpriteSheet
。
让我们将 obstacle_sheet
添加到 Walk
结构体中,如下所示:
struct Walk {
obstacle_sheet: Rc<SpriteSheet>,
...
}
你需要确保将 use std::rc::Rc
添加到 game
模块的顶部。我们还需要确保 Platform
可以接受一个引用计数的 SpriteSheet
而不是接受 SpriteSheet
的所有权,如下所示:
pub struct Platform {
sheet: Rc<SpriteSheet>,
...
}
impl Platform {
pub fn new(sheet: Rc<SpriteSheet>, position: Point) ->
Self {
Platform { sheet, position }
}
...
在这里,我们将 SpriteSheet
替换为 Rc<SpriteSheet>
。这让我们只剩下最后一个需要做的修改——我们必须初始化 Walk
结构体,并设置 obstacle_sheet
和平台,如下所示:
#[async_trait(?Send)]
impl Game for WalkTheDog {
async fn initialize(&mut self) -> Result<Box<dyn Game>>
{
match self {
WalkTheDog::Loading => {
...
let tiles = browser::fetch_json(
"tiles.json").await?;
let sprite_sheet =
Rc::new(SpriteSheet::new(
tiles.into_serde::<Sheet>()?,
engine::load_image("tiles.png").await?,
));
let platform = Platform::new(
sprite_sheet.clone(),
Point {
x: FIRST_PLATFORM,
y: LOW_PLATFORM,
},
);
...
Ok(Box::new(WalkTheDog::Loaded(Walk {
...
obstacles: vec![
Box::new(Barrier::new(Image::new(
stone, Point { x: 150, y: 546 }))),
Box::new(platform),
],
obstacle_sheet: sprite_sheet,
})))
在 initialize
中有两个部分发生了变化。首先,在调用 fetch_json
获取 tiles.json
之后,我们使用它通过 Rc::new
创建一个名为 sprite_sheet
的引用计数 SpriteSheet
。请注意,我们将 let platform_sheet
替换为 let tiles
,因为这个名字更好——它毕竟是在加载 tiles.json
。然后,当我们使用 Platform::new
创建 platform
时,我们传递给它创建的精灵 _sheet
的副本。之前,这是直接完成的,但我们在一分钟后还需要 sprite_sheet
。
然后,当我们创建 Walk
结构体时,我们需要将创建的表格传递给 obstacle_sheet
字段。这不需要克隆,因为 Walk
是 sprite_sheet
的最终所有者,所以 sprite_sheet
可以移动到其中。这将增加引用计数,并且不会克隆整个 SpriteSheet
。我们将需要在创建每个 Platform
时克隆 obstacle_sheet
,以确保正确计数引用,但不用担心这个问题——编译器会强制我们这样做。
因此,我们现在准备重新评估我们的Platform
对象是如何工作的。目前,它只能创建一个Platform
,但没有任何理由它不能创建许多玩家可以站立的物体。当我们生成关卡时,我们会需要这个功能。我们将在下一步实现它。
许多不同的平台
当前的Platform
结构体假设它使用的是精灵图中相同的三个单元格,包括计算边界框。因此,为了允许使用多种类型的平台,我们需要传递从图中要渲染的单元格,并且我们需要为每个潜在的Platform
传递自定义的边界框。例如,假设你想要将提供的瓷砖集(tiles.json
)排列成一个小悬崖:
![图 6.4 – 下面小心!
图 6.4 – 下面小心!
这将需要传递11
、2
和3
个平台瓷砖。这些瓷砖不是水平排列或整齐排列的,并且边界框与我们的其他平台不匹配。当我们创建这个平台时,我们需要在tiles.json
中查找瓷砖尺寸,并手动从提供的尺寸中计算出边界框。这意味着我们需要改变Platform
的工作方式,使其不那么具体。
让我们从更改Platform
结构体开始,使其能够持有边界框和精灵列表,如下所示:
pub struct Platform {
sheet: Rc<SpriteSheet>,
bounding_boxes: Vec<Rect>,
sprites: Vec<Cell>,
position: Point,
}
当我们在改变Platform
以使其不那么具体的同时,我们还将引入一个优化:Platform
将持有精灵单元格而不是每次绘制时都查找它们。这里有两个优化,因为我们还在存储Platform
的边界框而不是每次创建时都计算它们。
这个改变将几乎破坏Platform
实现的每一部分,最显著的是new
构造函数,它需要接受一个精灵名称列表和边界框列表,然后将精灵名称转换为单元格,如下所示:
impl Platform {
pub fn new(
sheet: Rc<SpriteSheet>,
position: Point,
sprite_names: &[&str],
bounding_boxes: &[Rect],
) -> Self {
let sprites = sprite_names
.iter()
.filter_map(|sprite_name|
sheet.cell(sprite_name).cloned())
.collect();
...
这不是new
方法的全部内容,只是开始。我们首先更改了签名,使其接受四个参数。sheet
和position
已经存在,但new
方法现在接受一个精灵名称列表作为字符串切片数组的引用。你可以取一个Vec
的String
对象,但使用字符串切片的引用要方便得多,因为它更容易调用。Clippy 也会反对代码接受一个Vec<String>
,我们将在第九章中介绍,测试、调试和性能。
在构造函数中,我们首先使用迭代器通过filter_map
调用查找精灵表中的每个Cell
。我们使用filter_map
而不是map
,因为sheet.cell
可以返回None
,所以我们需要跳过任何无效的精灵名称。filter_map
结合了filter
和map
,可以自动拒绝任何值为None
的选项,但如果存在,则映射内部值。Option
上的cloned
方法将返回一个Option<T>
,对于任何Option<&T>
,它都会克隆内部值。我们使用这个方法来获取内部Cell
的所有权。让我们继续我们的构造函数:
...
let bounding_boxes = bounding_boxes
.iter()
.map(|bounding_box| {
Rect::new_from_x_y(
bounding_box.x() + position.x,
bounding_box.y() + position.y,
bounding_box.width,
bounding_box.height,
)
})
.collect();
Platform {
sheet,
position,
sprites,
bounding_boxes,
}
}
我们继续进行,通过接收传入的边界框,这些边界框是&[Rect]
类型,并将它们转换为Vec<Rect>
,以便由Platform
结构体拥有。然而,我们不是简单地调用collect
或to_owned
,而是对每个Rect
进行调整,使其position
与Platform
的实际position
相匹配。因此,bounding_boxes
需要相对于其图像传入,其中图像从(0,0)
开始。想象一下你正在绘制的图像位于左上角。然后,在这些图像周围“绘制”边界框,跳过任何相对于左上角的不透明区域。然后,将所有内容移动到游戏中的正确位置。这就是我在指定边界框时防止混淆所使用的心理模型。
注意
Rust 有一些相当不错的工具用于函数式编程风格,如filter
和map
。了解它们是值得的。
提示
对于构造函数来说,有四个参数已经很多了,所以你可能需要考虑用Builder
模式替换这段代码。我们没有在这里这样做,因为这会分散我们对当前主题的关注,但它是一个值得改进的代码。例如,你可以查看这里的非官方Rust 设计模式书籍:https://bit.ly/3GKxMld
。
你还需要更改获取bounding_boxes
的函数,这将变得很小:
impl Platform {
...
fn bounding_boxes(&self) -> &Vec<Rect> {
&self.bounding_boxes
}
}
好吧,这要容易得多!确保你返回Vec
的引用而不是Vec
实例。我们在这里不需要进行任何更多的计算;Platform
正在接收其边界框。对于Platform
的其余实现来说,不会那么简单,因为我们需要修改move_horizontally
和draw
以考虑这些更改。需要修改move_horizontally
的更改如下所示:
impl Obstacle for Platform {
...
fn move_horizontally(&mut self, x: i16) {
self.position.x += x;
self.bounding_boxes.iter_mut()
.for_each(|bounding_box| {
bounding_box.set_x(bounding_box.position.x +
x);
});
}
原始代码只移动位置,因为bounding_boxes
是在需要时计算的。现在bounding_boxes
存储在Platform
上,每次我们移动Platform
时都需要进行调整。否则,你会在一个地方看到Platform
的图像,在另一个地方看到边界框,并且会出现非常奇怪的错误。问我怎么知道。
最后,让我们更新 draw
函数以适应新的结构。原始实现假设它是三个单元格宽,并且每次绘制时都会查找每个单元格,而新的实现将遍历每个单元格并单独绘制它。它还需要考虑每个单元格的宽度。所以,如果单元格宽度为 50
像素,那么第一个单元格将位于 0
,第二个位于 50
,以此类推:
impl Obstacle for Platform {
...
fn draw(&self, renderer: &Renderer) {
let mut x = 0;
self.sprites.iter().for_each(|sprite| {
self.sheet.draw(
renderer,
&Rect::new_from_x_y(
sprite.frame.x,
sprite.frame.y,
sprite.frame.w,
sprite.frame.h,
),
// Just use position and the standard
widths in the tileset
&Rect::new_from_x_y(
self.position.x + x,
self.position.y,
sprite.frame.w,
sprite.frame.h,
),
);
x += sprite.frame.w;
});
}
这不是世界上我最喜欢的代码,但它能完成任务。它首先创建一个局部、临时的 x
,用于计算每个 Cell
相对于 position
的偏移量。然后,它遍历精灵,绘制每一个,同时调整它们的 position
和 x
。注意,在目标 Rect
中,我们通过 self.position.x + x
来推进 x
位置。这确保每个 cell
都被绘制在之前的一个右边。最后,我们根据 cell
的宽度计算下一个 x
位置。这个 draw
的实现没有使用 destination_box
方法,这意味着没有人使用它,你可以安全地删除它。
注意
此代码假设 width
是可变的,而 height
是固定的,并且精灵从左向右移动。在这里,一个两层平台需要用两个平台来构建。
Platform
现在应该可以使用我们可以构造的任何精灵列表来工作。现在,我们只需要在 WalkTheDog::initialize
中正确初始化 Platform
,如下所示:
impl Game for WalkTheDog {
async fn initialize(&mut self) -> Result<Box<dyn Game>>
{
match self {
WalkTheDog::Loading => {
...
let platform = Platform::new(
sprite_sheet.clone(),
Point {
x: FIRST_PLATFORM,
y: LOW_PLATFORM,
},
&["13.png", "14.png", "15.png"],
&[
Rect::new_from_x_y(0, 0, 60, 54),
Rect::new_from_x_y(60, 0, 384 - (60
* 2), 93),
Rect::new_from_x_y(384 - 60, 0, 60,
54),
],
);
...
有了这些,Platform
已经通过两个额外的参数创建——瓷砖列表和边界框列表——构成了我们一直以来的平台。注意,我们现在可以传递一个简单的字符串数组作为精灵的名称。这是因为我们接受 &[&str]
类型作为参数,而不是 Vec<String>
。你可能想知道我从哪里得到了三个边界框矩形。毕竟,之前我们在 bounding_boxes
方法中使用偏移量来计算它们。我只是查看了 tiles.json
并做了数学计算,考虑到了我们之前使用的偏移量。这些测量值与我们在计算边界框时使用的相同。你可能还想知道为什么这些不使用常量,尤其是在我赞扬了在 第五章 碰撞检测 中使用常量的优点之后。那是因为我们将在下一节创建它们。
到这一点,你应该回到了起点——RHB 正在等待跳过一块石头。现在,我们准备创建一系列动态段。在下一个部分的结尾,你将拥有制作无尽跑酷游戏所需的结构。
创建动态关卡
我们长时间观察的初始屏幕,其中 RHB 从一个石头跳到一个平台上,我们将称之为“段”。这不是一个技术术语,而是一个为了生成它们而创造的观念。当 RHB 向右移动(即,当所有障碍物向左移动时),我们将生成新的段到屏幕右侧,我们将创建这些段,以便我们可以控制生成的内容以及它们如何组合。可以这样想:如果我们随机生成障碍物,那么我们的平台看起来会杂乱无章,并且会以无法击败的方式排列,如下所示:
图 6.5 – 一个真正的随机关卡
相反,我们将创建一个段,第一个段看起来就像我们的一个平台和一个岩石,并通过存储在Walk
中的“时间线”值将它们连接起来。这个时间线将代表x
轴上最后一个段的右侧。当这个值接近屏幕边缘时,我们将生成另一个新的段并将时间线移回。采用这种方法,RHB 可以运行我们喜欢的时间,我们将拥有关卡设计师的自由。我们将能够创建既容易又难以导航的段,尽管我们需要确保它们都能相互锁合并能被击败。这是最有意思的部分!
创建一个段
我们将首先创建一个介绍屏幕并将其作为一个段来创建。让我们通过创建一个名为segments.rs
的新文件来实现这一点,并确保将mod segments
添加到lib.rs
文件中。这个模块不是出于典型的软件设计原因而创建的;通常,是因为game.rs
变得相当长,这些段更接近于关卡而不是真正的代码。
注意
记住game.rs
可以通过使用包含mod.rs
文件的目录分解成一个模块,使用单独的文件。我们在这里不这样做,因为我觉得当文件数量较多时,解释新代码的去向变得更加困难——至少在书籍形式中是这样。如果你对此感到舒适,那么请随意将其分解成更小的块。
每个段将是一个返回障碍物列表的函数。让我们在segments.rs
中创建一个公共函数,它返回游戏初始化时相同的列表:
pub fn stone_and_platform(
stone: HtmlImageElement,
sprite_sheet: Rc<SpriteSheet>,
offset_x: i16,
) -> Vec<Box<dyn Obstacle>> {
const INITIAL_STONE_OFFSET: i16 = 150;
vec![
Box::new(Barrier::new(Image::new(
stone,
Point {
x: offset_x + INITIAL_STONE_OFFSET,
y: STONE_ON_GROUND,
},
))),
Box::new(create_floating_platform(
sprite_sheet,
Point {
x: offset_x + FIRST_PLATFORM,
y: LOW_PLATFORM,
},
)),
]
}
看,这是常数!我们希望段模块尽可能看起来是数据驱动的,所以我们将在这个文件中使用常数。这段代码无法编译,因为create_floating_platform
函数还不存在,但它执行的功能与WalkTheDog
的initialize
方法中的对应代码相同。唯一的区别是它使用了不存在的create_floating_platform
函数,以及一些也不存在的常数。
该函数本身从stone
中获取HtmlImageElement
,从Rc<SpriteSheet>
创建Barrier
和Platform
,同时还需要一个offset_x
值。这是因为虽然第一个Barrier
和Platform
可能分别位于150
和200
,但在未来,我们希望它们与时间轴的距离是这么多像素。它返回一个障碍物向量,我们可以在WalkTheDog
的initialize
方法以及任何其他生成段的地方使用。
信息
你可能已经注意到我们为SpriteSheet
使用了Rc
,但只是接管了HtmlImageElement
的所有权,当它被调用时可能需要克隆。这是一个很好的发现!你可能希望考虑将HtmlImageElement
也改为Rc
。HtmlImageElement
足够小,如果我们克隆它,可能就足够了,但在第九章,测试、调试和性能中可能值得调查。
让我们继续创建缺少的函数——即create_floating_platform
:
fn create_floating_platform(sprite_sheet: Rc<SpriteSheet>, position: Point) -> Platform {
Platform::new(
sprite_sheet,
position,
&FLOATING_PLATFORM_SPRITES,
&FLOATING_PLATFORM_BOUNDING_BOXES,
)
}
这是一个相当小的函数,因为它只是委托给Platform
构造函数并传递重要信息。正如你所见,有两个新的常量与stone_and_platform
中的其他常量一起使用。我告诉你常量会回来的!
小贴士
如果你想在声明FLOATING_PLATFORM_BOUNDING_BOXES
时使用Rect::new_from_x_y
,你需要将其和Rect::new
声明为pub const fn
。
segments
模块的其余部分由常量和use
语句组成。你可以从我们之前使用的代码中推断出所有常量的值,或者直接查看github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/blob/chapter_6/src/segments.rs
。在这里重现那段代码将相当于填充。通过将所有值放入常量中,代码看起来越来越数据驱动,函数只是返回我们为每个段想要的数据。
小贴士
使用serde
可以将这些段序列化为 JSON,然后从 JSON 文件中读取它们,而不是将级别写入 Rust 代码。这是一个你可以尝试的实验;我更喜欢 Rust 代码版本。
一旦填写了常量和use
语句,你就可以在WalkTheDog
的initialize
方法中使用新的stone_and_platform
函数。是的,又是那个。让我们用这个新函数替换硬编码的障碍物列表:
#[async_trait(?Send)]
impl Game for WalkTheDog {
async fn initialize(&mut self) -> Result<Box<dyn Game>>
{
match self {
WalkTheDog::Loading => {
...
Ok(Box::new(WalkTheDog::Loaded(Walk {
...
obstacles: stone_and_platform(stone,
sprite_sheet.clone(), 0),
obstacle_sheet: sprite_sheet,
})))
确保你从segments
中导入stone_and_platform
!现在我们已经有一个创建初始场景的函数,我们可以添加时间轴并反复生成场景。让我们开始吧。
小贴士
你可能已经注意到,这会在segments
和game
之间产生一个循环依赖。你说得对。为了解决这个问题,将segments
所依赖的任何在game
中的东西放入另一个模块,这个模块同时被game
和segments
依赖。这已经被留给你作为一个练习。
添加时间线
我们需要在段落的宽度上初始化时间线。我们可以通过找到障碍物列表中的最右端点来计算这个值,我们将使用之前用过的那些酷炫的功能结构。这将是一个独立的函数,我们可以将其保留在game
模块中,如下所示:
fn rightmost(obstacle_list: &Vec<Box<dyn Obstacle>>) -> i16 {
obstacle_list
.iter()
.map(|obstacle| obstacle.right())
.max_by(|x, y| x.cmp(&y))
.unwrap_or(0)
}
这个函数遍历一个vec
的Obstacle
,获取它的right
值。然后,它使用max_by
函数来确定右边的最大值。最后,它使用unwrap_or
,因为虽然max_by
在技术上可以返回None
,但如果它在这里这样做,那么我们就完全搞砸了,我们最好把所有图形都推到屏幕的最左边。现在我们有了这个函数,我们可以在Walk
结构体中添加一个timeline
值,如下所示:
struct Walk {
...
stone: HtmlImageElement,
timeline: i16,
}
我们还添加了对HtmlImageElement
的引用,因为我们稍后会需要它。现在,我们将使用stone
和timeline
初始化Walk
——是的,我们又回到了那个函数中——我们将不得不稍微调整代码以处理借用检查器:
impl Game for WalkTheDog {
async fn initialize(&mut self) -> Result<Box<dyn Game>>
{
match self {
WalkTheDog::Loading => {
...
let starting_obstacles = stone_and_platform
(stone.clone(), sprite_sheet.clone(), 0);
let timeline = rightmost(
&starting_obstacles);
Ok(Box::new(WalkTheDog::Loaded(Walk {
...
obstacles: starting_obstacles,
obstacle_sheet: sprite_sheet,
stone,
timeline,
})))
}
在这里,我们在初始化Walk
之前将starting_obstacles
和timeline
绑定,因为我们已经移动了obstacles
,所以无法再获取timeline
。注意,当我们把stone
传递给stone_and_platform
时,我们现在克隆了stone
。从现在开始,我们需要这样做,因为每个Barrier
障碍物都拥有一个Image
,最终是它的HtmlImageElement
。最后,我们将stone
和timeline
传递给Walk
结构体。现在我们有了timeline field
,我们可以通过将生成的障碍物的最右边向左移动来更新它,并在必要时生成更多的障碍物。我们的Canvas
仍然是600
像素宽,所以如果我们发现在1000
点之后的最右边没有障碍物,我们就需要生成更多的障碍物。
这些更改属于WalkTheDog
的update
方法,在更新逻辑的末尾:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
walk.obstacles.iter_mut().for_each(|obstacle| {
obstacle.move_horizontally(velocity);
obstacle.check_intersection(&mut walk.boy);
});
if walk.timeline < TIMELINE_MINIMUM {
let mut next_obstacles =
stone_and_platform(
walk.stone.clone(),
walk.obstacle_sheet.clone(),
walk.timeline + OBSTACLE_BUFFER,
);
walk.timeline = rightmost(&next_obstacles);
walk.obstacles.append(&mut next_obstacles);
} else {
walk.timeline += velocity;
}
}
在移动障碍物之后,我们检查walk.timeline
是否小于TIMELINE_MINIMUM
,它在模块顶部被设置为1000
。如果是,我们在walk.timeline + OBSTACLE_BUFFER
处创建另一个stone_and_platform
段,OBSTACLE_BUFFER
是一个常量,被设置为20
。为什么是20
?我们需要一点缓冲区来确保段之间不会完全重叠,20
看起来是合适的。你可以使用更大的数字或者根本不用。然后,我们将walk.timeline
更新为新障碍物的最右边,并将这些障碍物添加到列表中,准备绘制。
如果walk.timeline
超过了TIMELINE_MINIMUM
,我们只需将其减少到 RHB 的行走速度,直到下一次更新。添加此代码后,你应该会看到以下类似的内容:
图 6.6 – 当一个平台结束时,另一个平台在召唤
没错 – 你有一个无尽跑酷游戏!那么,我们怎么只读到这本书的一半呢?嗯,我们的跑酷者有点无聊,因为它只是反复出现同样的两个物体。我们不妨用多个段落增加一些随机性和创意,怎么样?
创建段落
创建随机段落意味着使用随机库在每次需要时选择不同的段落。让我们首先将之前编写的代码提取到一个函数中,如下所示:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
if walk.timeline < TIMELINE_MINIMUM {
walk.generate_next_segment()
} else {
walk.timeline += velocity;
}
}
...
}
}
impl Walk {
...
fn generate_next_segment(&mut self) {
let mut next_obstacles = stone_and_platform(
self.stone.clone(),
self.obstacle_sheet.clone(),
self.timeline + OBSTACLE_BUFFER,
);
self.timeline = rightmost(&next_obstacles);
self.obstacles.append(&mut next_obstacles);
}
}
信息
WalkTheDog
有一个严重的WalkTheDog
病例,并且进入了Walk
。
现在,Walk
可以生成下一个段落,我们将使用来自第一章,“Hello WebAssembly”,的random
crate 来选择下一个段落。当然,我们只有一个段落,所以这不会意味着太多。它看起来是这样的:
impl Walk {
...
fn generate_next_segment(&mut self) {
let mut rng = thread_rng();
let next_segment = rng.gen_range(0..1);
let mut next_obstacles = match next_segment {
0 => stone_and_platform(
self.stone.clone(),
self.obstacle_sheet.clone(),
self.timeline + OBSTACLE_BUFFER,
),
_ =>vec![],
};
self.timeline = rightmost(&next_obstacles);
self.obstacles.append(&mut next_obstacles);
}
}
不要忘记在文件顶部添加use rand::prelude::*;
。这将在0
和,嗯,0
之间生成一个随机数。然后,它匹配那个值并生成所选段落,在这种情况下,将始终是stone_and_platform
。这里有一个默认情况,但这只是为了让编译器安静下来 – 它不会发生。我将创建第二个名为platform_and_stone
的段落,它与第一个相同,除了它翻转了stone
和platform
的位置,然后使用我们之前创建的HIGH_PLATFORM
常量将平台提高。现在,generate_next_segment
函数看起来是这样的:
impl Walk {
...
fn generate_next_segment(&mut self) {
let mut rng = thread_rng();
let next_segment = rng.gen_range(0..2);
let mut next_obstacles = match next_segment {
0 => stone_and_platform(
self.stone.clone(),
self.obstacle_sheet.clone(),
self.timeline + OBSTACLE_BUFFER,
),
1 => platform_and_stone(
self.stone.clone(),
self.obstacle_sheet.clone(),
self.timeline + OBSTACLE_BUFFER,
),
_ =>vec![],
};
self.timeline = rightmost(&next_obstacles);
self.obstacles.append(&mut next_obstacles);
}
}
在这里,你可以看到我得到了两个段落,它们都以相同的方式被调用。确保gen_range
现在生成一个从0
到2
的数字。运行此代码后,我可以看到一个新的段落:
图 6.7 – 谁动了那块石头?
如果你尝试复制/粘贴前面的代码,它将不会工作,因为你没有platform_and_stone
。这没有包括在这里,因为你已经拥有了创建你自己的段落所需的所有知识。你可以从复制/粘贴stone_and_platform
并调整其值开始。然后,你可以尝试使用精灵图集创建平台。记住,你不仅限于我们精灵图集中的三个图像。整个图集看起来是这样的:
图 6.8 – 精灵图集
你可以用这个来制作更大的平台、阶梯,甚至是悬崖。试着制作几种不同的形状。试着通过跳过我们一直在使用的平台中的中间瓷砖来制作更小的平台。RHB 可以滑动;你能为他制作一个可以滑下的东西吗?
对于一个真正的挑战,看看水精灵。目前,RHB 不能穿过地面,因为我们使用了FLOOR
变量,但如果我们不使用它呢?RHB 会淹死吗?从悬崖上掉下来吗?是时候成为一个游戏设计师了!
摘要
是时候坦白了。如果你像我一样,是一个程序员,这意味着你可能坐在一个房间里,背后堆满了像这样的一堆书。在那堆书中,你可能只打开了一半,而且你可能只从头到尾读了一本或两本。不包括《哈利·波特》在内。
好消息!到目前为止,你已经制作了一个无尽跑酷游戏。它没有声音,碰撞框相当大(你尝试过从平台下穿过吗?),而且没有菜单系统,但到目前为止,你已经有一个游戏了。你可以通过在游戏中探索来让它更有趣,你也可以使用这个基础来制作更大或完全不同的无尽跑酷游戏。如果你在这个时候停止跟随,我不会怪你,因为你已经学到了很多。
但如果你决定留下来阅读下一章,我们将增加一个对任何游戏都必需的沉浸式体验要求——声音。难道你不想听听 RHB 的声音吗?
第七章:第七章:音效和音乐
抽空想想游戏俄罗斯方块。如果你像我一样,你可能已经哼起了它的主题曲《Korobeiniki》,因为这首歌与游戏本身如此同义。除了音乐的吸引力之外,音效对于创造沉浸式体验至关重要。我们玩游戏不仅仅是通过键盘或摇杆的触摸和我们的眼睛;我们听到马里奥跳跃或索尼克抓住一个圈。虽然我们的游戏可能是可玩的,但没有一些声音它就不是一个游戏。要在我们的游戏中播放声音,我们需要学习如何使用浏览器的 Web Audio API 来播放短的和长的声音。
在本章中,我们将涵盖以下主题:
-
将 Web Audio API 添加到引擎中
-
播放音效
-
播放长音乐
到本章结束时,你不仅会看到 RHB 跑、跳和躲避障碍,在我们为游戏添加音效和音乐后,你还将能够听到他的声音。让我们开始吧!
技术要求
技术要求与前面的章节基本相同。你需要从 github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/wiki/Assets
的资产下载中的 sound
目录获取 sound
资产。
所有声音均来自开源声音集合,并已获得许可。更多信息请参阅 sounds/credits.txt
文件。本章的代码可在 github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_7
找到。
查看以下视频以查看代码的实际应用:bit.ly/3JUdA2R
将 Web Audio API 添加到引擎中
在本节中,我们将使用浏览器的 Web Audio API 为我们的游戏添加声音。该 API 功能非常全面,允许混合音频源和特殊效果,但我们将仅使用它来播放背景音乐和声音。实际上,Web Audio API 是一本自己的书,如果你感兴趣,可以在 webaudioapi.com/book/
找到。虽然添加诸如空间化音频到我们的游戏会很有趣,但我们将专注于添加一些音乐和音效。我鼓励你在制作自己的更复杂游戏时进行实验。
一旦我们了解了 Web Audio API 的概览,我们将创建一个模块来在 Rust 中播放声音,以与加载我们的图片相同的方式加载声音,最后将那个声音添加到引擎中。
Web Audio API 是一种相对较新的技术,旨在取代旧的音频技术,如 QuickTime 和 Flash,同时比使用音频元素提供更灵活的解决方案。它被所有主要浏览器支持,只有旧版本的 Internet Explorer 可能存在问题。鉴于 Internet Explorer 的最后一次发布是在 2013 年,Windows 使用 Edge 浏览器代替,你的游戏可能不需要牺牲那个市场。
与 Canvas 相比,Web Audio API 可能一开始看起来很熟悉。与 Canvas 一样,你创建一个上下文,然后它提供了一个用于播放声音的 API。在那个点上,相似之处就结束了。因为 Web Audio API 具有我之前提到的所有功能,所以很难弄清楚如何进行基本的播放声音操作。与 Canvas 不同,没有drawImage
等价物称为playSound
或类似的东西。相反,你必须获取声音数据,创建AudioBufferSourceNode
,将其连接到目的地,然后最后启动它。这可以实现一些非常令人印象深刻的效果(例如在webaudiodemos.appspot.com/
上找到的),但对于我们的游戏来说,我们将编写一次性的代码,然后把它忘掉。在 JavaScript 中,加载和准备声音以供播放的代码如下:
const audioContext = new AudioContext();
let sound = await fetch("SFX_Jump_23.mp3");
let soundBuffer = await sound.arrayBuffer();
let decodedArray = await audioContext.decodeAudioData(soundBuffer);
它首先创建一个新的AudioContext
,这是内置在浏览器引擎中的,然后从服务器获取声音文件。fetch
调用最终返回一个响应,我们需要对其进行解码。我们首先获取它的arrayBuffer
,这会消耗它,然后我们使用我们最初创建的audioContext
将缓冲区解码成可以播放的声音。注意,一切都是异步的,这将在我们将 JavaScript 承诺映射到 Rust 未来时给我们带来一点麻烦。之前的代码对于任何声音资源只应该执行一次,因为加载和解码文件可能需要很长时间。
以下代码将播放声音:
let trackSource = audioContext.createBufferSource();
trackSource.buffer = decodedArray;
trackSource.connect(audioContext.destination);
trackSource.start();
哎呀,这不太直观,但我们只能这样。幸运的是,我们可以用几个简单的函数把它包装起来,这样我们就能记住它,然后把它忘掉。它通过createBufferSource
创建我们需要的AudioBufferSourceNode
,将其分配给我们在上一节解码成音频数据的数组,连接到audioContext
,最后用start
播放声音。重要的是要知道,你不能对trackSource
两次调用start
,但幸运的是,缓冲源创建非常快,不需要我们缓存它。
太好了!我们知道在 JavaScript 中播放声音的八行代码,但我们如何将其放入我们的引擎中?
在 Rust 中播放声音
我们将创建一个sound
模块,它与我们的browser
模块非常相似,一系列只是将权利委托给底层 JavaScript 的函数。这将是一个自下而上的方法,我们将创建我们的实用函数,然后创建使用它们的最终函数。我们将首先关注play_sound
函数所需的各个部分。
注意
记住,你希望这些函数非常小——这是 Rust 和 JavaScript 之间的薄层——但也要改变接口以更好地匹配你想要做的事情。所以,最终,我们不会谈论缓冲源和上下文,而是会调用我们最初希望存在的play_sound
函数。
我们将首先在名为sound.rs
的文件中创建模块,这个文件位于src
目录中我们其他模块旁边。别忘了在src/lib.rs
中添加对其的引用,如下所示:
#[macro_use]
mod browser;
mod engine;
mod game;
mod segments;
mod sound;
这是我总是忘记的部分。我们的第一个函数将以一种Rusty的方式创建AudioContext
,而不是我们已经看到的 JavaScript 方式,如下所示:
use anyhow::{anyhow, Result};
use web_sys::AudioContext;
pub fn create_audio_context() -> Result<AudioContext> {
AudioContext::new().map_err(|err| anyhow!
("Could not create audio context: {:#?}", err))
}
如往常一样,Rust 版本的代码比 JavaScript 版本更冗长。这是我们为 Rust 的优点所付出的代价。这些代码并没有什么特别新颖的地方;我们正在将new AudioContext
映射到AudioContext::new
,并将JsResult
错误映射到可能返回的anyhow
结果,使其更符合 Rust 的风格。然而,这段代码无法编译;花点时间思考一下原因。这是臭名昭著的Cargo.toml
中web-sys
的功能标志,我们还没有添加AudioContext
,所以现在就添加它:
[dependencies.web-sys]
version = "0.3.55"
features = ["console",
"Window",
"Document",
"HtmlCanvasElement",
"CanvasRenderingContext2d",
"Element",
"HtmlImageElement",
"Response",
"Performance",
"KeyboardEvent",
"AudioContext"
]
注意
AudioContext
绑定的文档可以在bit.ly/3tv5PsD
找到。记住,你可以搜索web-sys
文档中的任何 JavaScript 对象,以找到其对应的 Rust 库。
小贴士
根据你选择的编辑器,你可能需要在向项目添加新文件(如sound.rs
)和/或将功能标志添加到Cargo.toml
文件时重新启动rust-analyzer
,以获得正确的编译错误和代码操作。
现在我们已经设置了sound
模块,创建了创建AudioContext
的函数,并且回顾了向web-sys
依赖项添加新功能的过程,我们可以继续添加一些代码来播放声音。让我们介绍所有需要添加到Cargo.toml
中的剩余功能标志:
[dependencies.web-sys]
version = "0.3.55"
features = ["console",
"Window",
"Document",
"HtmlCanvasElement",
"CanvasRenderingContext2d",
"Element",
"HtmlImageElement",
"Response",
"Performance",
"KeyboardEvent",
"AudioContext",
"AudioBuffer",
"AudioBufferSourceNode",
"AudioDestinationNode",
]
这三个功能,AudioBuffer
、AudioBufferSourceNode
和AudioDestinationNode
,对应于原始 JavaScript 代码中的相同对象。例如,let trackSource = audioContext.createBufferSource();
函数返回AudioBufferSourceNode
。web-sys
的作者选择将大量音频功能隐藏在单个标志下,因此我们需要逐个命名它们。
小贴士
记得在无法使用web-sys
功能时检查功能标志。它总是在文档中列出,并带有如下注释:“此 API 需要以下 crate 功能被激活:AudioContext
。”
现在我们已经准备好了功能,我们可以添加剩余的代码,而不必担心那些错误。回到sound
模块,代码将看起来像这样:
use anyhow::{anyhow, Result};
use web_sys::{AudioBuffer, AudioBufferSourceNode, AudioContext, AudioDestinationNode, AudioNode};
...
fn create_buffer_source(ctx: &AudioContext) -> Result<AudioBufferSourceNode> {
ctx.create_buffer_source()
.map_err(|err| anyhow!("Error creating buffer
source {:#?}", err))
}
fn connect_with_audio_node(
buffer_source: &AudioBufferSourceNode,
destination: &AudioDestinationNode,
) -> Result<AudioNode> {
buffer_source
.connect_with_audio_node(&destination)
.map_err(|err| anyhow!("Error connecting audio
source to destination {:#?}", err))
}
在这本书中,我们通常一次通过一个函数来查看代码,但对于这两个函数来说,这不是必要的。这些函数分别对应于audioContext.createBufferSource
和trackSource.connect(audioContext.destination)
的调用。我们将代码从 JavaScript 的面向对象风格转换为稍微更过程化的格式,函数接受参数,部分原因是为了能够通过anyhow!
宏将JsValue
类型中的错误映射到适当的 Rust Error
类型。
现在我们已经有了三个函数,我们需要播放一个声音。我们可以继续编写紧随其后的播放函数,如下所示:
pub fn play_sound(ctx: &AudioContext, buffer: &AudioBuffer) -> Result<()> {
let track_source = create_buffer_source(ctx)?;
track_source.set_buffer(Some(&buffer));
connect_with_audio_node(&track_source,
&ctx.destination())?;
track_source
.start()
.map_err(|err| anyhow!
("Could not start sound!{:#?}", err))
}
play_sound
函数接受AudioContext
和AudioBuffer
作为参数,然后返回start
调用的结果,将JsValue
映射到Error
。我们还没有在任何地方创建AudioBuffer
,所以不必担心你不知道如何创建,因为我们将在需要的时候解决这个难题。这里有一个与原始 JavaScript 播放声音非常相似的功能,但增加了 Rust 带来的错误处理,包括使用?
操作符使其更容易阅读,以及在track_source.set_buffer(Some(&buffer));
行中围绕None
进行的一点点额外工作,因为我们需要在Some
中包装AudioBuffer
的引用,因为track_source
有一个可选的缓冲区。在 JavaScript 中,这是null
或undefined
,但在 Rust 中,我们需要使用Option
类型。否则,JavaScript 和 Rust 版本在播放声音时做的是同样的事情:
-
从
AudioContext
创建AudioBufferSource
。 -
在源上设置
AudioBuffer
。 -
将
AudioBufferSource
连接到AudioContext
的目标。 -
调用
start
来播放声音。
这看起来很多,但实际上非常快,所以缓存AudioBufferSource
没有太大的用处,特别是因为你只能调用一次start
。现在我们可以播放声音了,是时候加载声音资源并解码它,以便我们有AudioBuffer
来播放。让我们现在就做吧。
加载声音
要从服务器加载声音,我们需要将以下代码翻译成 Rust,这些代码你已经见过:
let sound = await fetch("SFX_Jump_23.mp3");
let soundBuffer = await sound.arrayBuffer();
let decodedArray = await audioContext.decodeAudioData(soundBuffer);
获取资源是我们已经在我们的browser
模块中可以做到的事情,但我们没有一种方便的方法来获取它的arrayBuffer
,所以我们需要添加这个功能。我们还需要创建一个 Rust 版本的decodeAudioData
。让我们从需要添加到browser
中的更改开始,这些是现有方法的修改。我们希望将旧的fetch_json
函数拆分,如下所示:
pub async fn fetch_json(json_path: &str) -> Result<JsValue> {
let resp_value = fetch_with_str(json_path).await?;
let resp: Response = resp_value
.dyn_into()
.map_err(|element| anyhow!("Error converting {:#?}
to Response", element))?;
JsFuture::from(
resp.json()
.map_err(|err| anyhow!("Could not get JSON from
response {:#?}", err))?,
)
.await
.map_err(|err| anyhow!("error fetching json {:#?}", err
))
}
我们需要将其拆分为两个函数,第一个函数首先获取 Result<Response>
,第二个函数将其转换为 JSON:
pub async fn fetch_response(resource: &str) -> Result<Response> {
fetch_with_str(resource)
.await?
.dyn_into()
.map_err(|err| anyhow!("error converting fetch to
Response {:#?}", err))
}
pub async fn fetch_json(json_path: &str) -> Result<JsValue> {
let resp = fetch_response(json_path).await?;
JsFuture::from(
resp.json()
.map_err(|err| anyhow!("Could not get JSON from
response {:#?}", err))?,
)
.await
.map_err(|err| anyhow!("error fetching JSON {:#?}", err
))
}
这是一个典型的“第二个人为抽象付费”的案例,我们在 第二章 “绘制精灵”中编写的代码,用于加载 JSON,但现在我们需要一个可以处理多种响应的 fetch
版本,特别是那些可以作为 ArrayBuffer
访问的声音文件。这段代码将需要 fetch_response
,但会将其转换为不同的对象。现在,让我们编写这段代码,就在 fetch_json
下方:
pub async fn fetch_array_buffer(resource: &str) -> Result<ArrayBuffer> {
let array_buffer = fetch_response(resource)
.await?
.array_buffer()
.map_err(|err| anyhow!("Error loading array buffer
{:#?}", err))?;
JsFuture::from(array_buffer)
.await
.map_err(|err| anyhow!("Error converting array
buffer into a future {:#?}", err))?
.dyn_into()
.map_err(|err| anyhow!("Error converting raw
JSValue to ArrayBuffer {:#?}", err))
}
就像 fetch_json
一样,它首先通过传入的资源调用 fetch_response
。然后,它调用响应上的 array_buffer()
函数,这将返回一个解析为 ArrayBuffer
的承诺。然后,我们像往常一样将承诺转换为 JsFuture
,以便使用 await
语法。最后,我们调用 dyn_into
将所有 Promise
类型返回的 JsValue
转换为 ArrayBuffer
。我跳过了它,但在每个步骤中,我们都使用 map_err
将 JsValue
错误转换为 Error
类型。
ArrayBuffer
类型是 JavaScript 的一种类型,目前我们的代码还无法使用。它是一个核心 JavaScript 类型,定义在 ECMAScript 标准 中,为了直接使用它,我们需要添加 js-sys
包。这有些令人惊讶,因为我们已经引入了 wasm-bindgen
和 web-sys
,这两个包都依赖于 JavaScript,那么为什么我们还需要为 ArrayBuffer
引入另一个包呢?这与各种包的排列方式有关。web-sys
包包含了所有的 Web API,而 js-sys
只限于 ECMAScript 标准中的代码。到目前为止,我们除了 web-sys
暴露的内容外,还没有使用过核心 JavaScript 中的任何东西,但现在随着 ArrayBuffer
的出现,这种情况发生了变化。
为了使此代码能够编译,您需要在 Cargo.toml
中的依赖项列表中添加 js-sys = "0.3.55"
。它已经在 dev-dependencies
中,所以您只需将其从那里移动即可。您还需要添加一个 use
js_sys::ArrayBuffer
声明来导入 ArrayBuffer
结构体。
小贴士
在本书出版后,各种库可能会以小的方式发生变化。如果您在使用这些依赖项时遇到任何困难,请检查 github.com/rustwasm/wasm-bindgen
的文档。
现在我们可以获取声音文件并将其作为 ArrayBuffer
获取,我们就可以编写我们自己的 await audioContext.decodeAudioData(soundBuffer)
版本了。到现在为止,你可能已经注意到我们正在遵循相同的模式来包装每个 JavaScript 函数:
-
将任何返回承诺的函数,如
decode_audio_data
,转换为JsFuture
,以便您可以在异步 Rust 代码中使用它。 -
将
JsValue
中的任何错误映射到您自己的错误类型中;在这种情况下,我们使用anyhow::Result
,但你可能需要更具体的错误。 -
使用
?
操作符来传播错误。 -
检查特性标记,尤其是在使用
web_sys
并且你确信一个库存在时。
在此基础上,我们再添加一个步骤。
- 使用
dyn_into
函数将JsValue
类型转换为更具体的类型。
按照相同的模式,Rust 版本的 decodeAudioData
放在 sound
模块中,如下所示:
pub async fn decode_audio_data(
ctx: &AudioContext,
array_buffer: &ArrayBuffer,
) -> Result<AudioBuffer> {
JsFuture::from(
ctx.decode_audio_data(&array_buffer)
.map_err(|err| anyhow!("Could not decode audio from array buffer {:#?}", err))?,
)
.await
.map_err(|err| anyhow!("Could not convert promise to
future {:#?}", err))?
.dyn_into()
.map_err(|err| anyhow!("Could not cast into AudioBuffer
{:#?}", err))
}
你需要确保添加对 js_sys::ArrayBuffer
和 wasm_bindgen_futures::JsFuture
以及 wasm_bindgen::JsCast
的 use
声明,以便将 dyn_into
函数引入作用域。再次提醒,我们不是直接在 AudioContext
上调用方法,在这个例子中是 decodeAudioData
,而是创建了一个封装调用的函数。该函数将 AudioContext
的引用作为第一个参数,并将 ArrayBuffer
类型作为第二个参数。这使得我们可以将错误映射和结果转换封装到函数中。
这个函数随后将 ctx.decode_audio_data
作为参数传递,并传递 ArrayBuffer
,但如果它只是这样做,我们实际上并不需要它。然后它将 ctx.decode_audio_data
中的任何错误映射到 Error
上,使用 anyhow!
;实际上,正如你所看到的,它将在处理过程中的每个步骤都这样做,并与 ?
操作符配对以传播错误。它从 decode_audio_data
中获取一个承诺,并从中创建 JsFuture
,然后立即调用 await
以等待完成,对应于 JavaScript 中的 await
调用。在处理将承诺转换为 JsFuture
的任何错误后,我们使用 dyn_into
函数将其转换为 AudioBuffer
,最终处理任何相关的错误。
这个函数是所有封装函数中最复杂的,所以让我们回顾一下从一行 JavaScript 转换为九行 Rust 时所采取的步骤:
- 将任何返回承诺的函数转换为
JsFuture
,以便你可以在异步 Rust 代码中使用它。
在这种情况下,decode_audio_data
返回了一个承诺,我们使用 JsFuture::from
将其转换为 JsFuture
,然后立即调用 await
。
- 将
JsValue
中的任何错误映射到你的自定义错误类型;在这种情况下,我们使用anyhow::Result
,但你可能需要更具体的错误。
我们这样做三次,因为每个调用似乎都返回一个 JsValue
版本的结果,我们在错误信息中添加了说明性语言。
- 使用
dyn_into
函数将JsValue
类型转换为更具体的类型。
我们这样做是为了将 decode_audio_data
的最终结果从 JsValue
转换为 AudioBuffer
,并且 Rust 的编译器可以从函数的返回值推断出适当的数据类型。
- 不要忘记使用
?
操作符来传播错误;注意这个函数是如何两次做到这一点的。
我们使用了 ?
操作符两次,使函数更容易阅读。
- 检查特性标记,尤其是在使用
web_sys
并且你确信一个库存在时。
AudioBuffer
是特性标记的,但我们又在开始时将其添加了回来。
这个过程比实际操作要复杂一些。大部分情况下,你可以遵循编译器并使用像rust-analyzer
这样的工具来自动添加use
声明。
现在我们已经拥有了所有这些实用工具,我们需要播放一个声音。是时候将这个功能添加到engine
模块中,以便我们的游戏可以使用它。
将音频添加到引擎中
我们在sound
模块中刚刚创建的函数可以直接通过委托函数由引擎使用,但我们不想让游戏担心AudioContext
、AudioBuffer
和类似的东西。就像Renderer
一样,我们将创建一个Audio
结构体来封装该实现的细节。我们还将创建一个Sound
结构体,将AudioBuffer
转换为对整个系统更友好的类型。这些结构体将会非常小,如下所示:
#[derive(Clone)]
pub struct Audio {
context: AudioContext,
}
#[derive(Clone)]
pub struct Sound {
buffer: AudioBuffer,
}
这些结构体被添加到engine
模块的底部,但它们实际上可以放在文件的任何地方。别忘了导入AudioContext
和AudioBuffer
!如果你发现自己随着engine
和game
的增大而感到困惑,你可以通过创建一个mod.rs
文件和目录将其拆分成多个文件,但为了跟上进度,所有内容都需要最终放在engine
模块中。我不会这样做,因为虽然这样做可以使代码更容易导航,但它会使解释和跟进变得更加困难。稍后将其拆分成更小的块是一个很好的练习,以确保你理解我们正在编写的代码。
现在我们有一个表示Audio
并持有AudioContext
的结构体,以及一个相应的Sound
结构体,它持有AudioBuffer
,我们可以向Audio
添加impl
,使用我们之前编写的函数来播放声音。现在,我们将向Audio
结构体添加impl
来播放和加载声音。让我们从加载实现开始,这可能是最难的,如下所示:
impl Audio {
pub fn new() -> Result<Self> {
Ok(Audio {
context: sound::create_audio_context()?,
})
}
pub async fn load_sound(&self, filename: &str) -> Result<Sound> {
let array_buffer =
browser::fetch_array_buffer(filename).await?;
let audio_buffer =
sound::decode_audio_data(&self.context,
&array_buffer).await?;
Ok(Sound {
buffer: audio_buffer,
})
}
}
这个impl
将从两个方法开始,一个是熟悉的new
方法,它使用AudioContext
创建一个Audio
结构体。请注意,在这种情况下new
返回一个结果,因为create_audio_context
可能会失败。然后,我们有load_sound
方法,它也返回一个结果,这次是Sound
类型,只有三行。这是我们正确组织sound
和browser
模块中的函数的一个迹象,因为我们只需简单地调用我们的fetch_array_buffer
和decode_audio_data
函数来获取AudioBuffer
,然后将其包装在一个Sound
结构体中。我们返回一个结果并通过?
传播错误。如果加载声音很简单,那么在这个Audio
实现的方法中播放它就很容易:
impl Audio {
...
pub fn play_sound(&self, sound: &Sound) -> Result<()> {
sound::play_sound(&self.context, &sound.buffer)
}
}
对于play_sound
,我们实际上只是委托,传递Audio
持有的AudioContext
和从传入的声音中获取的AudioBuffer
。
我们已经编写了一个模块来在 API 中播放声音,添加了加载声音到浏览器,并最终创建了游戏引擎的音频部分。这足以在引擎中播放音效;现在我们需要将其添加到我们的游戏中,这将会变得复杂。
播放音效
将音效添加到我们的游戏是一个挑战,原因有几个:
- 效果必须只发生一次:
我们将为跳跃(boing!)添加音效,并确保它只发生一次。幸运的是,我们已经有了一个解决方案,那就是我们的状态机!我们可以使用RedHatBoyContext
在发生某些事情时播放声音,如下所示(现在不要添加它):
impl RedHatBoyContext {
...
fn play_jump_sound(audio: &Audio) {
audio.play_sound(self.sound)
}
}
这直接引出了我们的第二个挑战。
- 在过渡时播放音频:
我们希望在过渡时刻播放声音,但大多数过渡不会播放声音。记住我们的状态机使用transition
从一个事件过渡到另一个事件,虽然我们可以在那里传递音频,但它只会被该方法中一小部分代码使用。这是一个代码问题,所以我们不会这样做。RedHatBoyContext
将必须拥有音频和声音。这不是理想的,我们更希望系统中只有一个音频,但这对我们的状态机来说不可行。这导致了我们的第三个问题。
AudioContext
和AudioBuffer
不是Copy
:
为了在RedHatBoy
实现中使用self.state = self.state.jump();
这样的语法,并让每个状态过渡消耗RedHatBoyContext
,我们需要RedHatBoyContext
是Copy
。不幸的是,AudioContext
和AudioBuffer
不是Copy
,这意味着Audio
和Sound
不能是Copy
,因此,如果RedHatBoyContext
要持有音频和声音,它也不能是一个副本。这很糟糕,但我们可以通过重构RedHatBoyContext
和RedHatBoy
以使用所需的clone
函数来修复它。
让RedHatBoyContext
拥有音频意味着系统中可能存在多个Audio
对象,其中另一个将播放音乐。这是多余的,但大多数情况下是无害的,所以我们选择这个解决方案。它使我们能够继续开发,最终,这个解决方案效果很好。如果有疑问,选择现成的解决方案。
注意
你可能会想知道为什么我们不在RedHatBoyContext
中存储Audio
的引用。最终,在我们的引擎中Game
是静态的,因此,如果将Audio
引用存储在RedHatBoyContext
上,它必须保证与Game
一样长命。
有其他选项,包括使用服务定位器模式(bit.ly/3A4th2f
)或将音频作为参数传递到update
函数中,但它们都需要更长的时间才能达到我们的最终目标——播放声音,这是本章的真实目标。
在我们能够将音效添加到游戏中之前,我们需要重构代码以包含一个Audio
元素。然后我们将播放音效。
重构 RedHatBoyContext 和 RedHatBoy
在我们真正这样做之前,我们将准备RedHatBoyContext
和RedHatBoy
来存储音频和一首歌,因为这会使添加声音更容易。让我们首先将RedHatBoyContext
设置为clone
,如下所示:
#[derive(Clone)]
struct RedHatBoyContext {
frame: u8,
position: Point,
velocity: Point,
}
我们所做的一切就是从derive
声明中移除了Copy
特质。这将在RedHatBoyStateMachine
和RedHatBoyState<S>
上引起编译错误,这两个结构都派生了Copy
,因此你还需要从这些结构中移除那个声明。一旦你这样做,你将看到一大堆类似这样的错误:
nerror[E0507]: cannot move out of `self.state` which is behind a mutable reference
--> src/game.rs:134:22
|
134 | self.state_machine = self.state_machine.run();
| ^^^^^^^^^^ move occurs because `self.state` has type `RedHatBoyStateMachine`, which does not implement the `Copy` trait
如预期的那样,调用self.state.<method>
,其中方法接受self
,所有调用都未能编译,因为RedHatBoyStateMachine
不再实现Copy
。解决方案,我们将在每一行遇到这个编译错误时这样做,就是在我们需要进行更改时显式地克隆状态。以下是带有错误的run_right
函数:
impl RedHatBoy {
...
fn run_right(&mut self) {
self.state_machine = self.state_machine. transition(Event::Run);
}
然后,这是修复后的结果:
impl RedHatBoy {
...
fn run_right(&mut self) {
self.state_machine = self.state_machine
clone().transition(Event::Run);
}
可能最令人牙痒痒的例子是在transition
方法中,我们将因为match
语句而得到一个移动,如下所示:
impl RedHatBoyStateMachine {
fn transition(self, event: Event) -> Self {
match (self, event) {
...
_ => self,
}
}
这个部分的问题在于self
被移动到了match
语句中,并且在默认情况下无法返回。试图使用match
和self
来解决这个问题会导致所有的类型状态方法,例如land_on
和knock_out
,失败,因为它们需要消耗self
。最干净的修复方法如下所示:
impl RedHatBoyStateMachine {
fn transition(self, event: Event) -> Self {
match (self.clone(), event) {
...
_ => self,
}
}
我承认这很糟糕,但我们能够继续进步。
小贴士
我知道你在想什么——性能!我们在每个转换时都在克隆!你完全正确,但你知道性能会受到负面影响吗?性能的第一条规则是先测量,在我们测量这个之前,我们实际上不知道这个代码的最终版本是否是问题。我花了很多时间试图避免这个clone
调用,因为担心性能问题,结果发现这并没有多大影响。先让它工作,然后再让它变得更快。
一旦你修复那个错误几次,你就可以为RedHatBoyContext
添加音频和声音了,但我们会播放什么声音呢?
添加音效
使用 Web Audio API,我们可以播放任何由audio
HTML 元素支持的音频格式,包括所有常见的 WAV、MP3、MP4 和 Ogg 格式。此外,2017 年,MP3 许可证到期,所以如果你对此担心,不要担心;你可以无忧无虑地使用 MP3 文件作为声音。
由于 Web Audio API 与许多音频格式兼容,因此只要它是在适当的许可下发布的,你可以使用来自互联网任何地方的声音。我们将用于跳跃的声音效果可在 opengameart.org/content/8-bit-jump-1
找到,并且它是在 Creative Commons 公共领域 许可下发布的,因此我们可以放心使用。你不需要下载那个捆绑包并浏览它,尽管你可以这样做,但跳跃声音已经捆绑在这本书的资产中,位于 github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/wiki/Assets
的 sounds
目录下。我们想要的特定文件是 SFX_Jump_23.mp3
。你需要将这个文件复制到你的 Rust 项目的 static
目录中,以便它可以在你的游戏中使用。
现在,RedHatBoyContext
已经准备好容纳 Audio
结构体,并且 SFX_Jump_23.mp3
文件可供加载,我们可以开始添加代码。从添加 Audio
和 Sound
到 RedHatBoyContext
,如下所示:
#[derive(Clone)]
pub struct RedHatBoyContext {
pub frame: u8,
pub position: Point,
pub velocity: Point,
audio: Audio,
jump_sound: Sound,
}
记得将 Audio
和 Sound
的 use
声明添加到 red_hat_boy_states
模块中。代码将无法编译,因为 RedHatBoyContext
在没有 audio
或 jump_sound
的情况下被初始化,所以我们需要添加它。RedHatBoyContext
在 RedHatBoyState<Idle>
实现的 new
方法中被初始化,所以我们将该方法更改为接受 Audio
和 Sound
对象,并将它们传递给 RedHatBoyContext
,如下所示:
impl RedHatBoyState<Idle> {
fn new(audio: Audio, jump_sound: Sound) -> Self {
RedHatBoyState {
game_object: RedHatBoyContext {
frame: 0,
position: Point {
x: STARTING_POINT,
y: FLOOR,
},
velocity: Point { x: 0, y: 0 },
audio,
jump_sound,
},
_state: Idle {},
}
}
}
我们可以在这里创建一个 Audio
对象,但这样 new
方法就需要返回 Result<Self>
,我认为这不合适。这将移动编译器错误,因为我们调用 RedHatBoyState<Idle>::new
的地方现在是不正确的。那就是在 RedHatBoy::new
中,它现在也可以接受 Audio
和 Sound
对象并将它们传递下去。
这将带我们来到 Game
实现中臭名昭著的 initialize
函数,因为它在没有任何 Audio
或 Sound
的情况下调用 RedHatBoy::new
,因此无法编译。这是一个加载文件的合适位置,因为它既是 async
的,又返回一个结果。我们将在 initialize
中创建一个 Audio
对象,加载我们想要的音效,并将其传递给 RedHatBoy::new
函数,如下所示:
#[async_trait(?Send)]
impl Game for WalkTheDog {
async fn initialize(&mut self) -> Result<Box<dyn Game>> {
match self {
WalkTheDog::Loading => {
...
let audio = Audio::new()?;
let sound = audio.load_sound
("SFX_Jump_23.mp3").await?;
let rhb = RedHatBoy::new(
sheet,
engine::load_image("rhb.png").await?,
audio,
sound,
);
...
}
这将使应用程序再次编译,但我们没有对 audio
或 sound
做任何事情。记住,所有这些工作都是因为我们想确保在跳跃时声音只播放 一次,而确保这一点的方法是将声音播放放在从 Running
到 Jumping
的转换中。转换是通过 RedHatBoyContext
上的各种 From
实现通过方法完成的。让我们在 RedHatBoyContext
上编写一个名为 play_jump_sound
的小函数,如下所示:
impl RedHatBoyContext {
...
fn play_jump_sound(self) -> Self {
if let Err(err) = self.audio.play_sound
(&self.jump_sound) {
log!("Error playing jump sound {:#?}", err);
}
self
}
}
这个函数的编写方式与这个实现中其他过渡副作用函数略有不同,因为play_sound
返回一个结果,但为了与其他过渡方法保持一致,play_jump_sound
实际上不应该这样做。幸运的是,未能播放声音虽然令人烦恼,但不会致命,所以如果声音无法播放,我们将记录错误并继续。现在代码可以编译,但我们需要将play_jump_sound
的调用添加到过渡中。在RedHatBoyState<Running>
上查找jump
,并将该过渡修改为调用play_jump_sound
,如下所示:
impl RedHatBoyState<Running> {
...
pub fn jump(self) -> RedHatBoyState<Jumping> {
RedHatBoyState {
context: self
.context
.reset_frame()
.set_vertical_velocity(JUMP_SPEED)
.play_jump_sound(),
_state: Jumping {},
}
}
当这个程序编译完成后,运行游戏,你就会看到,也会听到 RHB 跳上平台。
图 7.1 – 你能听到吗?
小贴士
如果你像我认识的许多开发者一样,现在有 20 多个浏览器标签页打开,你可能想关闭它们。这可能会减慢浏览器的声音播放,并使声音时间不准确。
现在你已经播放了一个音效,考虑添加更多,例如,当 RHB 撞到障碍物,或者平稳着陆,或者滑动时。选择权在你!在你对音效玩得有点乐趣之后,让我们添加一些背景音乐。
播放长音乐
你可能会认为播放音乐意味着检测声音是否播放完成并重新开始。这可能对于浏览器的实现来说是正确的,但幸运的是,你不必这样做。Web Audio API 已经在AudioBufferSourceNode
的循环上设置了一个标志,可以在声音被明确停止之前循环播放声音。这将使播放背景音频变得相当简单。我们可以在sound
模块的play_sound
函数中添加一个标志到loop
参数,如下所示:
fn create_track_source(ctx: &AudioContext, buffer: &AudioBuffer) -> Result<AudioBufferSourceNode> {
let track_source = create_buffer_source(ctx)?;
track_source.set_buffer(Some(&buffer));
connect_with_audio_node(&track_source,
&ctx.destination())?;
Ok(track_source)
}
pub enum LOOPING {
NO,
YES,
}
pub fn play_sound(ctx: &AudioContext, buffer: &AudioBuffer, looping: LOOPING) -> Result<()> {
let track_source = create_track_source(ctx, buffer)?;
if matches!(looping, LOOPING::YES) {
track_source.set_loop(true);
}
track_source
.start()
.map_err(|err| anyhow!("Could not start sound!
{:#?}", err))
}
这是从create_track_source
函数开始的,实际上是对play_sound
函数的重构。它提取了它的前三行,并将它们提取到一个单独的函数中以提高可读性。之后,我们创建了一个LOOPING
枚举,并使用它来检查我们是否应该在track_source
上调用set_loop
。你可能想知道为什么我们不直接传递bool
作为第三个参数,答案是,阅读这里显示的第一行代码将比第二行代码容易得多:
play_sound(ctx, buffer, LOOPING::YES)
play_sound(ctx, buffer, true)
六个月后,当我不知道那个布尔值是做什么的时候,我不得不去查找,而使用枚举的版本则很明显。通过添加这个标志,我们的程序停止编译,因为引擎中的Audio
仍然使用两个参数调用play_sound
。我们可以快速修复这个问题,如下所示:
impl Audio {
...
pub fn play_sound(&self, sound: &Sound) -> Result<()> {
sound::play_sound(&self.context, &sound.buffer,
sound::LOOPING::NO)
}
我们还将添加一种播放背景音乐的新方法,即通过开启循环播放来播放声音:
impl Audio {
...
pub fn play_looping_sound(&self, sound: &Sound) ->
Result<()> {
sound::play_sound(&self.context, &sound.buffer,
sound::LOOPING::YES)
}
}
我喜欢引擎比sound
模块的灵活性逐渐减少。sound
和browser
模块是浏览器功能的包装器;引擎提供了帮助您制作游戏的工具。现在,引擎提供了一种播放背景音乐的方式,我们实际上可以将它添加到游戏中。在资源中,sounds
目录中有一个第二个文件,background_song.mp3
,您可以将其复制到本项目的static
目录中。一旦完成,我们就可以在Game::initialize
函数中加载并播放背景音乐:
#[async_trait(?Send)]
impl Game for WalkTheDog {
async fn initialize(&mut self) -> Result<Box<dyn Game>> {
match self {
WalkTheDog::Loading => {
...
let audio = Audio::new()?;
let sound = audio.load_sound
("SFX_Jump_23.mp3").await?;
let background_music = audio.load_sound
("background_song.mp3").await?;
audio.play_looping_sound
(&background_music)?;
let rhb = RedHatBoy::new(
sheet,
engine::load_image("rhb.png").await?,
audio,
sound,
);
...
小贴士
查看 https://gamesounds.xyz/获取您游戏的无版权声音。
在这里,我们加载第二首歌曲background_song.mp3
,并使用play_looping_sound
立即播放。在大多数浏览器上,您可能需要点击画布以将其聚焦,才会听到音乐,所以如果听不到任何声音,请检查一下。需要注意的是,尽管那个声音即将超出作用域,浏览器仍然会愉快地继续播放它。我们已经将歌曲传递给浏览器,现在由它负责。当音频移动到RedHatBoy
中时,RedHatBoy
的创建没有发生变化,它最终将负责播放游戏的声音效果。
小贴士
在开发过程中,你可能想静音浏览器,因为每次浏览器刷新时,歌曲都会重新开始播放。
就这样!一个带有音乐和声音效果的完整游戏!现在要添加 UI,这样我们就可以点击它上的新游戏了。
摘要
在本章中,您使用 Web Audio API 为您的游戏添加了声音,并对 API 本身进行了概述。Web Audio API 非常广泛,具有众多功能,我鼓励您去探索它。您的第一个挑战是使用gain
属性来改变音乐的音量,目前音量相当大。Web Audio API 还支持立体声环绕声和程序生成音乐等功能。尽情享受并尝试一下吧!
您还为游戏添加了一个新模块,并进一步扩展了游戏引擎以支持它。我们甚至涵盖了重构,并做出了一些权衡,以确保游戏可以在不要求耗时理想设计的情况下完成。我鼓励您花些时间给游戏添加更多声音效果;您现在有技能让 RHB 在着陆或撞到岩石时发出砰的声音。说到撞到岩石,你可能已经厌倦了每次都不得不点击刷新,所以在下一章中,我们将添加一个小型 UI,并带有一个精彩的新游戏按钮。
第八章:第八章:添加 UI
可能看起来我们已经为视频游戏开发了一切所需,从某种程度上说,我们确实如此,除了每次小红帽男孩(RHB)撞到岩石时需要刷新页面的那个烦恼。一个真正的游戏有“新游戏”或“最高分”的按钮,在本章中,我们将添加这个 UI。这样做可能看起来微不足道,但你可能从网络开发中熟悉的基于事件的 UI 与我们的游戏循环并不匹配。为了添加一个简单的按钮,我们需要对我们的应用程序进行重大更改,甚至需要写一点 HTML。
在本章中,你将执行以下操作:
-
设计一个新的游戏按钮
-
在游戏结束时显示按钮
-
开始新游戏
在本章结束时,你将拥有一个更完整功能的 UI 框架和使其工作的技能。
技术要求
你需要一些额外的资源,这次来自github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/wiki/Assets
中的assets
下载的ui
目录。字体是来自www.kenney.nl
的 Kenny Future Narrow。按钮来自www.gameart2d.com/
。两者都是 CC0 许可。和之前一样,本章的最终代码可在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_8
的相应分支上找到。
查看以下视频,了解代码的实际应用:bit.ly/3DrEeNO
设计一个新的游戏按钮
当 RHB 撞到岩石上时,他会倒下,嗯,让我们说他要小睡一会儿。不幸的是,到那时,玩家必须刷新页面才能开始新游戏。在大多数游戏中,我们会看到一系列用于新游戏和最高分的按钮。目前,我们只将添加一个新游戏按钮,它会从头开始重新开始。这看起来可能是一个简单的任务,但实际上,我们还有很多事情要做。
首先,我们需要决定我们想要如何实现按钮。我们实际上有两个选择。我们可以在引擎中创建一个按钮,这将是一个渲染到画布上的精灵,就像其他所有东西一样,或者我们可以使用一个 HTML 按钮并将其定位在画布上。第一个选项看起来会正确,并且不需要任何传统的网络编程,但它也将要求我们检测鼠标点击和处理按钮点击动画。换句话说,我们可能需要实现一个按钮。这比我们想要实现来让游戏工作要多,所以我们打算使用传统的 HTML 按钮并使其看起来像是一个游戏元素。
因此,我们将编写一些 HTML 和 CSS,这样我们就可以让按钮看起来像是游戏引擎的一部分。然后,我们将使用 Rust 将按钮添加到屏幕上并处理点击事件。这将是难点部分。
准备用户界面
从概念上讲,我们的用户界面将像 FPS 中的 HUD 或按钮叠加在游戏本身前面时的工作方式。想象一下,在游戏上方有一个完全透明的玻璃板,按钮就像是一张贴在玻璃上的贴纸。这意味着,在网页的上下文中,我们需要一个与 canvas 大小和位置相同的 div。
小贴士
这不是一本关于 HTML 或 CSS 的书,所以我不会花太多时间来介绍它,除了我们一直在使用的 canvas。如果你不是网页开发的专家,不用担心——快速浏览learnxinyminutes.com/docs/html/
就能了解足够多的内容。我们也会在这一节中使用一点 CSS,你可以在learnxinyminutes.com/docs/css/
找到类似的速查表。
我们可以快速更新index.html
以包含所需的 div,如下所示:
<body>
<div id="ui" style="position: absolute"></div>
<canvas id="canvas" tabindex="0" height="600" width="600">
Your browser does not support the Canvas.
</canvas>
<script src="img/index.js"></script>
</body>
</html>
注意,ui
div 是position: absolute
,这样它就不会“推”下面的canvas
元素。你可以通过将一个标准的 HTML 按钮放入div
元素中来看如何实现,如下所示:
<div id="ui" style="position: absolute">
<button>New Game</button>
</div>
这将产生一个看起来如下所示的屏幕:
图 8.1 – 新游戏按钮!
如果你完全水平缩小屏幕,它可能不会响应得很好,但游戏在那个情况下是无法工作的,所以应该没问题。现在我们有了按钮,我们需要让它看起来像是一个游戏元素,为此我们需要添加样式。请创建一个名为styles.css
的文件在static
目录下,并在index.html
中添加对其的链接,如下所示:
<html>
<head>
<meta charset="UTF-8">
<title>My Rust + Webpack project!</title>
<link rel="stylesheet" href="styles.css" type="text/css"
media="screen">
</head>
当然,一个指向空文件的链接对我们来说并没有什么帮助。为了证明链接是有效的,请修改index.html
文件,移除ui
div 上的内联样式,使其看起来像<div id="ui">
。这将导致按钮将 canvas 元素向下推,你的游戏可能会稍微偏离:
图 8.2 – 新游戏在顶部
现在,在 CSS 文件中,你将为该 div 添加一个样式。这个样式不是内联的并不是很重要,除了它能方便地检查我们的 CSS 文件是否被加载。在 CSS 文件中,插入以下内容:
#ui {
position: absolute;
}
这是一个 CSS 选择器,用于任何具有ui
ID 的元素,并将它们的定位设置为absolute
。如果你的 CSS 文件正在加载,那么新游戏按钮应该再次位于画布的顶部。稍后,我们将在游戏代码中程序化地添加该按钮,但现在我们只想让它显示出来,看起来正确。我们希望给它一个看起来像视频游戏的字体,以及背景。让我们从字体开始。在你的资源中,你会看到一个名为ui
的目录,其中包含一个名为kenney_future_narrow-webfont.woff2
的文件。WOFF代表Web 开放字体格式,这是一种在所有现代浏览器中都能工作的字体格式。
注意
无论何时你不确定一个功能是否与浏览器兼容,有时即使你确定,也要检查caniuse.com/
以进行双重确认。对于 WOFF 文件,你可以在这里看到结果:caniuse.com/?search=woff
。
将kenney_future_narrow-webfont.woff2
复制到应用程序的static
目录中,以便它被构建过程选中。然后,你需要指定 CSS 中的@font-face
,以便元素可以在此渲染,如下所示:
@font-face {
font-family: 'Ken Future';
src: url('kenney_future_narrow-webfont.woff2');
}
我们在这里所做的就是加载一个名为Ken Future
的简单名称的新字体,以便我们可以在其他样式中引用它,并通过指定的 URL 加载它。现在,我们可以使用这个额外的 CSS 将所有按钮更改为使用该字体:
button {
font-family: 'Ken Future';
}
现在,你应该能看到按钮以更像是游戏的字体渲染,如下所示:
图 8.3 – 使用 Kenney Future 字体的新游戏
由于那个传统的网络背景,按钮仍然看起来非常像 HTML 按钮。为了使其看起来更像游戏按钮,我们将使用背景和 CSS 精灵来创建一个具有圆角和悬停颜色的漂亮按钮。
CSS 精灵
作为游戏开发者,你已经知道什么是精灵;你没有忘记第二章,绘制精灵,对吧?在CSS 精灵的情况下,这个术语有点名不副实,因为它实际上并不是指精灵,而是指精灵图。
从概念上讲,CSS 精灵的工作方式与使用 canvas 渲染它们相同。你从更大的精灵中切出一块,并只渲染那一部分。我们将只使用 CSS 而不是 Rust 来完成整个操作。由于我们使用 CSS,我们可以改变鼠标悬停在按钮上和点击按钮时的背景。这将使按钮看起来正确,我们不需要编写 Rust 代码就能达到相同的效果。点击按钮是浏览器非常擅长的事情,所以我们将利用它。
我们将使用下载资源中ui
目录下的Button.svg
文件,因此你可以将该文件复制到游戏项目的static
目录中。SVG 文件实际上包含了一个完整的按钮库,看起来如下所示:
图 8.4 – Button.svg 的顶部
我们希望裁剪出宽的蓝色、绿色和黄色按钮,作为按钮在不同状态下的背景。我们将首先使用 CSS 中的background
属性将按钮的背景设置为 SVG 文件。您将按照以下方式更新样式:
button {
font-family: 'Ken Future';
background: -72px -60px url('Button.svg');
}
在background
中的像素值-72px
和-60px
表示将背景向左移动72
像素,向上移动60
像素以与空白蓝色按钮对齐。您可以在矢量图形编辑器中获取这些值,例如url
值指定要加载的文件。进行这些更改后,您会看到按钮的背景变成了新的背景……嗯,差不多吧。
图 8.5 – 按钮及裁剪后的背景
如您所见,背景被裁剪了,所以您只能看到一半,按钮本身仍然保留了一些默认 HTML 按钮的效果。我们可以通过添加更多的 CSS 来去除边框并将按钮大小调整为与背景匹配,如下所示:
button {
font-family: 'Ken Future';
background: -72px -60px url('Button.svg');
border: none;
width: 82px;
height: 33px;
}
width
和height
值是从*Inkscape~中提取的,这将使按钮与源中的按钮背景大小相同。与之前使用的精灵图一样,我们需要从原始源中裁剪出一个部分,所以在这种情况下,有一个从(72, 60)
开始的矩形,宽度和高度为82x33
。进行这些更改后,按钮现在看起来像是一个游戏按钮而不是一个网页按钮。
![图 8.6 – 新的游戏按钮
图 8.6 – 新的游戏按钮
仍然还有一些问题。按钮现在在视觉上没有与用户交互,所以点击时它看起来就像一张图片。我们可以通过 CSS 伪类#active
和#hover
来解决这个问题。
注意
一些浏览器,特别是 Firefox,会将新游戏渲染在一行上而不是两行。
注意
更多关于伪类的信息,请查看 Mozilla 文档:developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes
。
在每个伪类中,我们将更改背景属性以与另一个背景对齐。同样,数字是从 Inkscape 中提取的,一旦添加到其中,就会进行一些调整以确保它们对齐。首先,我们可以处理hover
样式,即鼠标悬停在图像上时。
这会产生一个看起来像这样的悬停按钮:
button:hover {
background: -158px -60px url('Button.svg');
}
![图 8.7 – 悬停
图 8.7 – 悬停
然后,我们将添加active
样式,这是鼠标点击时的样子:
button:active {
background: -244px -60px url('Button.svg');
}
这会产生一个点击后的按钮,看起来像这样:
图 8.8 – 激活状态
最后的问题是,我们的按钮真的很小,对于一个游戏来说,并且位于左上角。使用传统的 CSS 方法(宽度)和高度来放大按钮是有问题的,就像我们在这里改变宽度值时看到的那样:
图 8.9 – 那不是一个按钮
改变宽度和高度将意味着改变我们从精灵图中取出的“切片”,所以我们不希望这样。我们将使用的是 CSS 的 translate
属性,配合 scale
函数,看起来是这样的:
button {
font-family: 'Ken Future';
background: -72px -60px url('Button.svg');
border: none;
width: 82px;
height: 33px;
transform: scale(1.8);
}
这给了我们一个大小合适、背景正确的按钮,但它不在正确的位置。
图 8.10 – 按钮的左侧被切掉
现在按钮变大了,看起来像是一个游戏按钮,我们只需要将其放置在正确的位置。你可以通过添加 translate
到 transform
属性来实现这一点,其中 translate
是 move
的一个花哨说法。你可以如下看到:
button {
font-family: 'Ken Future';
background: -72px -60px url('Button.svg');
border: none;
width: 82px;
height: 33px;
transform: scale(1.8) translate(150px, 100px);
}
这将使新游戏按钮大致位于屏幕中央。
图 8.11 – 新游戏按钮!
注意
在 div 中居中按钮需要比我在这本书中想要覆盖的 CSS 知识更多。由于我们正在手动定位事物,我们可以先“过得去”。如果你对 Web 开发更熟悉,请随意使其真正完美居中。如果你对使用 Flexbox 实现完美居中感兴趣,请查看这里:webdesign.tutsplus.com/tutorials/how-to-create-perfectly-centered-text-with-flexbox--cms-27989
。
新的游戏按钮现在显示出来了,但它没有任何作用,因为我们的代码没有对 onclick
做任何处理。它只是一个悬浮的按钮,用它的无效性来挑衅我们。请继续从 index.html
中移除 button
元素,但保留具有 ui
ID 的 div
。相反,我们将使用 Rust 在需要时动态添加和移除按钮,并实际处理点击事件。为此,我们需要对我们的 browser
和 engine
模块做一些扩展,让我们深入研究。
使用 Rust 显示按钮
我们已经编写了 HTML 代码来显示按钮,看起来相当不错,但实际上我们需要能够根据命令显示和隐藏它。这意味着我们需要与浏览器交互并使用 browser
模块。我们有一段时间没有这样做过了,所以让我们回顾一下如何将我们传统上会写的 JavaScript 代码转换为我们将要使用的 web-sys
的 Rust 代码。首先,我们需要代码来将按钮插入到 ui
div 中。有好多方法可以做到这一点;我们将使用 insertAdjacentHTML
,这样我们就可以直接从代码中将字符串发送到屏幕上。在 JavaScript 中,它看起来是这样的:
let ui = document.getElementById("ui");
ui.insertAdjacentHTML("afterbegin", "<button>New Game</button>");
注意
您可以在developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentHTML
找到这个函数的文档。当涉及到查找浏览器 API 时,Mozilla 开发者网络(MDN)是你的朋友。
我们在第二章“绘制精灵”和第三章“创建游戏循环”中花费了大量时间将此类代码转换为 Rust,但让我们刷新一下记忆,安抚一下那些按顺序阅读书籍的怪物。任何 JavaScript 函数或方法都可能在web-sys
包中找到,其名称已从 PascalCase 转换为 snake_case,并且大多数函数返回Option
。通常,你可以尝试一下,它就会工作。让我们在browser
中创建一个新的函数,看看是否如此,如下所示:
pub fn draw_ui(html: &str) -> Result<()> {
document()
.and_then(|doc| {
doc.get_element_by_id("ui")
.ok_or_else(|| anyhow!("UI element not found"))
})
.and_then(|ui| {
ui.insert_adjacent_html("afterbegin", html)
.map_err(|err| anyhow!("Could not insert
html {:#?}", err))
})
}
这个draw_ui
函数假设存在一个具有ui
ID 的 div,就像canvas
函数假设有一个canvas
ID 一样。这意味着它并不非常通用,但我们现在不需要更复杂的解决方案。如果我们以后需要,我们会编写更多的函数。一如既往,我们不希望因为“完美”代码的想法而走得太远,因为我们还有游戏要完成。
再次强调,Rust 版本的代码要长得多,使用and_then
和映射错误来确保我们处理错误情况,而不是像 JavaScript 那样崩溃或停止程序。这是代码在 Rust 中在美学上不那么吸引人,但在我看来更好的一个案例,因为它突出了错误的可能原因。我们马上需要的另一个函数是用来隐藏ui
元素,它在 JavaScript 中的样子如下:
let ui = document.getElementById("ui");
let firstChild = ui.firstChild;
ui.removeChild(firstChild);
这个函数获取ui
div 的第一个子元素,并使用removeChild
方法将其移除。为了彻底,我们应该遍历所有的ui
子元素并确保它们都被移除,但我们在这里没有这么做,因为我们已经知道只有一个。我们还移除了子元素(而不仅仅是设置它们的可见性为隐藏),这样它们就不会影响布局,并且移除了任何事件监听器。再次强调,你将需要将 JavaScript 转换为 Rust。在这种情况下,firstChild
变为first_child()
方法,removeChild
变为remove_child
,如下所示:
pub fn hide_ui() -> Result<()> {
let ui = document().and_then(|doc| {
doc.get_element_by_id("ui")
.ok_or_else(|| anyhow!("UI element not found"))
})?;
if let Some(child) = ui.first_child() {
ui.remove_child(&child)
.map(|_removed_child| ())
.map_err(|err| anyhow!("Failed to remove child
{:#?}", err))
} else {
Ok(())
}
}
这个函数与 draw_ui
有点不同,部分原因是因为 first_child
缺失不是一个错误;它只是意味着你在一个空的 UI 上调用了 hide_ui
,而我们不希望它出错。这就是为什么我们使用 if let
构造,并在它不存在时显式地返回 Ok(())
。ui
div 已经是空的,所以这没问题。此外,还有那个奇怪的调用 map(|_removed_child| ())
,我们之所以调用它是因为 remove_child
返回正在被移除的 Element
。我们在这里不关心它,所以我们再次显式地将它映射到我们的单元值。最后,当然,我们使用 anyhow!
处理错误。
这个函数揭示了一些重复,所以让我们在最终版本中继续重构它,如下所示:
pub fn draw_ui(html: &str) -> Result<()> {
find_ui()?
.insert_adjacent_html("afterbegin", html)
.map_err(|err| anyhow!("Could not insert html
{:#?}", err))
}
pub fn hide_ui() -> Result<()> {
let ui = find_ui()?;
if let Some(child) = ui.first_child() {
ui.remove_child(&child)
.map(|_removed_child| ())
.map_err(|err| anyhow!("Failed to remove child
{:#?}", err))
} else {
Ok(())
}
}
fn find_ui() -> Result<Element> {
document().and_then(|doc| {
doc.get_element_by_id("ui")
.ok_or_else(|| anyhow!("UI element not found"))
})
}
在这里,我们将两个重复的 document().and_then
调用替换为对 find_ui
的调用,这是一个私有函数,它确保我们在找不到 UI 时总是得到相同的错误。这简化了一小部分代码,并使得在 draw_ui
中使用 try
操作符成为可能。find_ui
函数返回 Element
,所以你需要确保导入 web_sys::Element
。
我们已经在 browser
中设置了绘制按钮所需的工具。要程序化地显示我们的按钮,我们只需调用 browser::draw_ui("<button>New Game</button>")
。这很好,但我们实际上还不能处理按钮点击事件。我们有两种选择。第一种是创建一个带有 onclick
处理程序的按钮,例如 browser::draw_ui("<button onclick='myfunc'>New Game</button>")
。这将需要将我们的 Rust 包中的函数暴露给浏览器。它还需要某种类型的全局变量,该函数可以操作它。如果 myfunc
要操作游戏状态,那么它需要访问游戏状态。我们可以在事件队列中使用某种方法,这是一个可行的方案,但不是我们将要做的。
我们将要做的相反是,通过 web-sys
库在 Rust 代码中设置 onclick
变量,将其设置为写入通道的闭包。其他代码可以监听这个通道,看看是否发生了点击事件。这段代码将与我们在 第三章 中编写的代码非常相似,即 创建游戏循环,用于处理键盘输入。我们将在 engine
模块中从一个函数开始,该函数接受 HtmlElement
并返回 UnboundedReceiver
,如下所示:
pub fn add_click_handler(elem: HtmlElement) -> UnboundedReceiver<()> {
let (click_sender, click_receiver) = unbounded();
click_receiver
}
不要忘记使用 use web_sys::HtmlElement
将 HtmlElement
带入作用域。这并不会做太多,而且看起来似乎与点击事件无关,而且我们也不明显地需要 UnboundedReceiver
。当我们给按钮添加点击处理程序时,我们不想移动任何关于游戏的元素到闭包中。在这里使用通道让我们能够封装点击处理并使其与对点击事件的响应分离。让我们继续创建 on_click
处理程序,如下所示:
pub fn add_click_handler(elem: HtmlElement) ->
UnboundedReceiver<()> {
let (mut click_sender, click_receiver) = unbounded();
let on_click = browser::closure_wrap(Box::new(move || {
click_sender.start_send(());
}) as Box<dyn FnMut()>);
click_receiver
}
我们所做的更改是将click_sender
变为可变的,然后将其移动到新创建的闭包on_click
中。你可能还记得前面章节中的closure_wrap
,它需要接收一个堆分配的闭包,换句话说是一个Box
,在这个例子中,它将传递一个我们未使用的mouse
事件,这样我们就可以安全地跳过它。将类型转换为Box<dyn FnMut()>
是必要的,以平息编译器并允许这个函数转换为WasmClosure
。在这个内部,我们调用发送者的start_send
函数并传递一个单位。由于我们没有使用任何其他参数,我们只需让接收者检查任何事件即可。
最后,我们需要将这个闭包分配给elem
上的on_click
方法,以便按钮实际上可以处理它,如下所示:
pub fn add_click_handler(elem: HtmlElement) -> UnboundedReceiver<()> {
let (mut click_sender, click_receiver) = unbounded();
let on_click = browser::closure_wrap(Box::new(move || {
click_sender.start_send(());
}) as Box<dyn FnMut()>);
elem.set_onclick(Some(on_click.as_ref().unchecked_ref()));
on_click.forget();
click_receiver
}
我们添加了对elem.set_onclick
的调用,这对应于 JavaScript 中的elem.onclick =
。注意我们如何将set_onclick
传递一个Some
变体,因为onclick
本身在 JavaScript 中可以是null
或undefined
,因此,在 Rust 中可以是None
,它是一个Option
类型。然后我们传递on_click.as_ref().unchecked_ref()
,这是我们多次使用来将Closure
转换为web-sys
可以使用函数的模式。
最后,我们还要确保忘记on_click
处理程序。如果没有这个处理程序,当我们实际创建这个回调时,程序将会崩溃,因为on_click
尚未正确地传递给 JavaScript。我们这样做了几次,所以在这里我不会过多地强调这一点。现在我们已经编写了所有代码,我们需要显示一个按钮并处理对其的响应,并且我们需要将其集成到我们的游戏中。让我们弄清楚如何显示这个按钮。
游戏结束时显示按钮
我们可以在Game
的update
方法中通过检查每一帧游戏是否结束以及按钮是否存在来显示和隐藏按钮,确保我们只显示或隐藏一次,这可能会工作,但我认为你可以感觉到如果这样做,面条代码开始形成。一般来说,最好在update
中避免过多的条件逻辑,因为它会变得混乱并允许逻辑错误。相反,我们可以将每个看起来像if (state_is_true)
的条件检查视为系统的两种不同状态。所以,如果新游戏按钮被显示,那就是一种游戏状态,如果它没有被显示,那就是另一种游戏状态。你知道这意味着什么——是时候使用状态机了。
状态机回顾
在第四章 使用状态机管理动画中,我们将 RHB 转换为状态机,以便在事件上轻松且更重要的是正确地更改动画。例如,当我们想让 RHB 跳跃时,我们通过类型状态方法从Running
变为Jumping
,只改变一次状态,只改变一次速度并播放一次声音。这段代码在此处重现以供清晰理解:
impl RedHatBoyState<Running> {
...
pub fn jump(self) -> RedHatBoyState<Jumping> {
RedHatBoyState {
context: self
.context
.reset_frame()
.set_vertical_velocity(JUMP_SPEED)
.play_jump_sound(),
_state: Jumping {},
}
}
类型状态工作得很好,但如果不需要那种功能,它们也会很嘈杂。这就是为什么在同一个章节中,我们选择将我们的游戏本身建模为一个简单的enum
,如下所示:
pub enum WalkTheDog {
Loading,
Loaded(Walk),
}
这将发生显著变化,因为我们现在有一个需要状态机的难题。当 RHB 被击倒时,游戏结束,新的游戏按钮应该出现。这是一个在状态改变时需要发生一次的副作用,这正是我们状态机的完美用例。不幸的是,将代码重构为状态机将需要相当数量的代码,因为我们的当前实现状态机的方法虽然优雅,但有点嘈杂。此外,实际上这里有两个状态机在工作,一开始并不明显。第一个是我们一开始看到的,从Loading
到Loaded
的状态机,你可以将其视为没有Walk
和有Walk
的情况。第二个是Walk
本身的状态机,它从Ready
移动到Walking
再到GameOver
。你可以这样可视化它:
图 8.12 – 嵌套状态机
如你所见,我们这里有两个状态机,一个是从Loading
到Loaded
,另一个代表三个游戏状态Ready
、Walking
和GameOver
。还有一个未画出的第三个状态机,著名的RedHatBoyStateMachine
,它管理着RedHatBoy
的动画。图中的一些状态模仿了RedHatBoyStateMachine
中的状态,其中Idle
是Ready
,Walking
是Running
,因此有将RedHatBoyStateMachine
移动到WalkTheDogStateMachine
的诱惑。这可能可行,但请记住,Walk
没有“跳跃”状态,所以这样做的话,你需要开始检查布尔值,模型开始崩溃。最好接受这种相似性,因为游戏很大程度上依赖于 RHB 的行为,但将RedHatBoyStateMachine
视为具有更细粒度的状态。真正起作用的是将Loading
和Loaded
转换为Option
。具体来说,我们将我们的游戏建模如下:
struct WalkTheDogGame {
machine: Option<WalkTheDogStateMachine>
}
这段代码目前还没有打算在任何地方编写;它只是在这里为了清晰起见。在这里使用Option
有一个很大的优势,这与我们的update
函数的工作方式有关。为了清晰起见,我将在下面重现我们游戏循环的一部分:
let mut keystate = KeyState::new();
*g.borrow_mut() = Some(browser::create_raf_closure(move |perf: f64| {
process_input(&mut keystate, &mut keyevent_receiver);
game_loop.accumulated_delta += (perf –
game_loop.last_frame) as f32;
while game_loop.accumulated_delta > FRAME_SIZE {
game.update(&keystate);
game_loop.accumulated_delta -= FRAME_SIZE;
}
这里关键的部分是game.update
行,它对game
对象执行可变借用,而不是将其移动到update
中。这是因为一旦game
被FnMut
拥有,它就不能被移动出来。尝试这样做会导致以下编译器错误:
error[E0507]: cannot move out of `*game`, as `game` is a captured variable in an `FnMut` closure
这样的可变借用很棘手,因为它们可能会使你在调用栈中向下导航时更难导航借用检查器。在这种情况下,如果我们尝试以与RedHatBoyStateMachine
相同的方式实现另一个状态机,这会成为一个问题。在我们的状态机实现中,每个typestate
方法消耗机器并返回一个新的。现在,让我们想象我们正在将整个游戏建模为enum
,如下所示:
enumWalkTheDogGame {
Loading,
Loaded(Walk),
Walking(Walk),
GameOver(Walk)
}
为了使update
中的可变借用工作,我们不得不在每次状态改变时克隆整个游戏,因为from
函数无法获取其所有权。换句话说,我们game.update
函数中的闭包是将game
“借出”给update
函数。这不能反过来“给予”from
函数——它并不拥有它!这样做需要克隆整个游戏,这可能在每一帧都发生!
将游戏建模为持有可选的WalkTheDogStateMachine
有两个优点:
-
我们可以在
Option
上调用take
来获取状态机的所有权。 -
类型反映了状态机在游戏加载后才可用。
注意
自然,有许多方式可以建模我们的游戏类型,其中一些可能比我们在这里选择的更好。然而,在你开始尝试制作这个类型的“更简单”版本之前,让我提醒你,我已经尝试了这种解决方案的几种不同变体,并最终发现使用
Option
是最直接的选择。其他几种实现要么以复杂的借用结束,要么是不必要的克隆。要小心,但也要勇敢。你可能会找到比我更好的方法!
在深入实际实现之前,这个实现相当长,我们先了解一下我们要实现的设计。
图 8.13 – 之前
这很简单,但它并没有做到我们需要的所有事情。现在,让我们重新设计状态机。
图 8.14 – 之后
是的,这需要更多的代码,甚至没有反映实现的细节,或者我们编写的From
特质,这些特质使得在enum
值和结构体之间转换变得容易。编写一些处理状态机模板代码的宏超出了本书的范围,但这不是一个坏主意。你可能会想知道为什么每个状态都持有自己的Walk
实例,因为每个状态都有它,这是因为我们将在转换时改变Walk
,而单个状态不容易访问父WalkTheDogState
容器数据。然而,在可能的情况下,我们将将公共数据从Walk
移动到WalkTheDogState
。
提示
这一节有很多代码,片段通常一次只显示几行,这样就不会太多难以处理。然而,当你跟随时,你可能希望重新组织代码以便更容易找到。例如,我更喜欢在game
模块中自上而下地工作,顶部是常量,然后是“最大的”struct
,在这个例子中是WalkTheDog
,然后是它所依赖的任何代码,这样调用栈就会沿着页面向下流动。这就是github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_8
是如何组织的。你也可以开始将其拆分成更多的文件。我不会这样做,以便更容易以书籍的形式解释。
重新设计为状态机
在真正的重构中,我们会在每次更改后确保游戏处于运行状态,但我们的更改将导致级联编译错误,这意味着我们将会有一段时间无法工作,所以这个更改并不是真正的重构,而更像是一次重新设计。当你进行这种更改时,你应该尽可能快地进入编译状态,并尽可能长时间地保持在该状态,但尽管我在写这一章时做到了这一点,我并不会让你经历所有中间步骤。我们将继续前进,就好像我们事先就知道我们的设计将会成功一样,因为我们这次就是这样做的,但不要在家里尝试。如果你是 Git 的常规用户,现在是一个创建分支的绝佳时机,以防万一。
我们将首先替换game
模块中的这段代码:
pub enum WalkTheDog {
Loading,
Loaded(Walk),
}
我们将用以下内容替换它:
pub struct WalkTheDog {
machine: Option<WalkTheDogStateMachine>,
}
这将在各个地方引发编译错误。这是我们将采取捷径的章节,在实现状态机的同时暂时让编译器出错,只是为了确保这一章不会有一千页那么长。所以,如果你长时间与出错的代码库工作感到不舒服,那很好——只需相信我非常聪明,第一次就全部搞对了。假装一下——一切都会好起来的。
由于我们具有心灵感应能力,确切地知道这种设计将会如何发展,我们可以继续前进,知道最终一切都会顺利地结合在一起,不会出现错误。这次更改正是我们之前讨论过的内容——enum WalkTheDog
变成了一个包含其machine
实例的结构体,该实例是一个optional
字段。目前,WalkTheDogStateMachine
不存在,所以我们将添加它,如下所示:
enum WalkTheDogStateMachine {
Ready(WalkTheDogState<Ready>),
Walking(WalkTheDogState<Walking>),
GameOver(WalkTheDogState<GameOver>),
}
当我们在 Rust 中实现状态机时,我们需要enum
作为状态的容器,这样WalkTheDog
就不需要是一个泛型struct
。我们已经将编译错误向下移动,因为没有WalkTheDogState
和定义的状态。让我们接下来这样做:
struct WalkTheDogState<T> {
_state: T,
walk: Walk,
}
struct Ready;
struct Walking;
struct GameOver;
目前,各种状态 Ready
、Walking
和 GameOver
都不存储任何数据。随着我们的进行,这会有所改变,但所有状态都有 Walk
,这样它们就可以保存在共同的 WalkTheDogState
结构体中。现在我们已经创建了状态机,我们需要看看旧版本的 WalkTheDog
是在哪里被使用的。第一个是在 WalkTheDog
的小型 impl
块中,在旧代码中我们创建了 enum
,如下所示:
impl WalkTheDog {
pub fn new() -> Self {
WalkTheDog::Loading {}
}
}
这将不起作用,并且无法编译,所以让我们用空的 WalkTheDog
实例来替换它,如下所示:
impl WalkTheDog {
pub fn new() -> Self {
WalkTheDog { machine: None }
}
}
这个更改用 machine
设置为 None
的旧 enum
替换掉了。你现在可以将 None
视为 Loading
状态,当机器存在时,你就是 Loaded
状态。说到加载,下一个逻辑上需要更改的地方是在 WalkTheDog
的 Game
实现中。查看我们多次遇到的 initialize
函数,你将在这里看到一个编译器错误:
#[async_trait(?Send)]
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
match self {
WalkTheDog::Loading => {
match self
这一行将不再起作用,因为 self
不是 enum
。我们需要的做法是匹配 machine
,如果它是 None
,那么加载新的机器;如果它存在,那么就使用 Err
,就像我们现在做的那样,因为 initialize
被以某种方式调用了两次。我们可以从替换 match
语句的两个部分开始,所以匹配应该从如下开始:
#[async_trait(?Send)]
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
match self.machine {
None => {
仔细观察,我们会发现我们现在匹配 self.machine
,并且匹配 None
。在我们深入 None
匹配分支之前,让我们快速更改 WalkTheDog::Loaded(_)
的匹配,如下所示:
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
match self.machine {
...
Some(_) => Err(anyhow!("Error: Game is already
initialized!")),
这只是将 WalkTheDog::Loaded
改为 Some
,使用相同的错误信息。
提示
为了获得更清晰的错误信息,你可以在 WalkTheDog
结构体上使用 #[derive(Debug)]
。这样做会产生级联效应,因为它所依赖的每一项也必须使用 #[derive(Debug)]
,所以我们在这里不会这样做,但这是一个好主意,尤其是如果你在这里遇到问题时。
现在匹配语句的两个部分都正确地匹配了 Option
类型,我们需要修改 initialization
以返回正确的类型。在 None
分支的底部,你将想要创建一个类似于以下所示的状态机,在返回值之前:
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
match self.machine {
None => {
...
let timeline =
rightmost(&starting_obstacles);
let machine = WalkTheDogStateMachine
::Ready(WalkTheDogState {
_state: Ready,
walk: Walk {
boy: rhb,
backgrounds: [
Image::new(background.clone(),
Point { x: 0, y: 0 }),
Image::new(
background,
Point {
x: background_width,
y: 0,
},
),
],
obstacles: starting_obstacles,
obstacle_sheet: sprite_sheet,
stone,
timeline,
},
});
...
这与之前的代码非常相似;Walk
的构建没有改变,但它被所有状态机的噪音所掩盖。我们将 machine
变量绑定到 WalkTheDogStateMachine::Ready
,使用初始化的 WalkTheDogState
实例,这反过来又将其内部 _state
值设置为 Ready
,并且状态具有 Walk
。这很嘈杂,在我们将这个文件重新编译后,我们将进行真正的重构,使这一行更干净一些,但现在我们先把它放在一边。
现在,我们之前让 initialize
返回一个新的 Result<Box<dyn Game>>
,所以我们需要返回一个新的 Game
实例。所以,在添加 machine
之后,添加以下内容:
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
match self.machine {
None => {
...
Ok(Box::new(WalkTheDog {
machine: Some(machine),
}))
注意
由于 initialize
接收 self
并实际上没有使用它,所以它在 Game
特质中是否应该存在是有争议的。创建一个单独的特质,例如 Initializer
,将需要大量的修改,这是一项留给读者的练习。
这确保了 initialize
返回一个机器处于正确状态的游戏。我们还有两个更大的特质方法,update
和 draw
,需要处理,而 update
填满了编译错误,所以让我们接下来处理它。
将更新扩展到状态机
update
函数充满了编译错误,是游戏行为的核心,并且还有一个额外的挑战。直观地,你可能认为你可以像这样修改函数的开始部分:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let Some(machine) = self.machine {
...
if let Some(machine) = self.machine
这行代码最终会因以下错误而无法编译:
error[E0507]: cannot move out of `self.machine.0` which is behind a mutable reference
-->src/game.rs:676:32
|
676 | if let Some(machine) = self.machine {
| ------- ^^^^^^^^^^^^ help: consider borrowing here: `&self.machine`
现在,你可能尝试,就像我一样,通过将行更改为 if let Some(machine) = &mut self.machine
来修复这个问题。这会一直有效,直到你尝试在 WalkTheDogState
上实现一个转换。因为你有一个借用的机器,当你后来在状态上匹配时,你也会有一个借用的状态,就像以下示例所示:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState)
if let Some(machine) = &mut self.machine {
match machine {
WalkTheDogStateMachine::Ready(state) => {
在这里,state
值是借用的,与大多数其他情况不同,其中匹配分支会接管值,并且这不是立即显而易见的。如果我们将从 Ready
转换到 Walking
,它就会变得明显。为了编写 state._state.run_right()
并到达 Walking
,你的转换需要像下面这样才能编译:
impl WalkTheDogState<Ready> {
fn start_running(&mut self) -> WalkTheDogState<Walking> {
self.run_right();
WalkTheDogState {
_state: Walking,
walk: self.walk,
}
}
}
注意,我们正在从 &mut WalkTheDogState<Ready>>
转换到 WalkTheDogState<Walking>
,这是一个奇怪的转换,也是一个错误的提示。你看不见的是,这段代码无法编译。使用 walk
返回新的 WalkTheDogState
是我们不能做的操作,因为 state
是借用的。start_running
方法不拥有 state
,因此它不能接管 state.walk
,因此不能返回新的实例。这个问题的解决方案是在每次转换时克隆整个 Walk
,但这样做效率低下。相反,我们可以在 Game
实现中一直向上接管 machine
的所有权,通过名为 take
的合适函数。我们不会在机器上使用可变借用,而是像下面这样调用 take
:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let Some(machine) = self.machine.take() {
这段代码与之前相同,但这次我们在 Option<WalkTheDogStateMachine>
上调用了 take
方法。这会将 self
中的状态机替换为 None
,并将现有的 machine
绑定到 if let Some(machine)
中的变量。现在,在这个作用域内,我们对 machine
拥有完全的控制权,可以在最终调用 self
中状态机的 replace
方法将其移回函数末尾之前做任何我们想做的事情。这有点尴尬,但它可以绕过可变借用的问题。它还引入了一个潜在的错误,即当控制退出 update
函数时,machine
可能仍然被设置为 None
,这可能会意外地停止游戏。为了防止这种情况发生,在我们继续更新这个函数之前,我们将在 if let
语句之外添加 assert
,如下所示:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let Some(machine) = self.machine.take() {
...
}
assert!(self.machine.is_some());
不幸的是,这是一个运行时错误,而不是编译时错误,但它会立即让我们知道我们是否在下一部分中出错。这个 assert
可能有点过度,因为我们将在 if let
块内部显著减少代码量;事实上,它将只有一行。首先,我们将向我们的状态机添加一个对不存在函数 update
的调用,如下所示:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let Some(machine) = self.machine.take() {
self.machine.replace(machine.update(keystate));
if keystate.is_pressed("ArrowRight") {
在 if let Some(machine)
之后立即添加 self.machine.replace(machine.update(keystate))
这一行。if let
块中 replace
以下的全部代码将变成实现状态中的各种 update
函数的一部分,所以你想要做的是要么将这段代码复制粘贴到你可以获取它的某个地方,要么简单地注释掉它。接下来,我们将在 WalkTheDogStateMachine
上创建一个 impl
,使用这个新的 update
方法,它将返回新的状态。这个空的版本看起来像这样:
impl WalkTheDogStateMachine {
fn update(self, keystate: &KeyState) -> Self {
}
}
现在,你可以从 Game
中的 update
方法调用它,它看起来像这样:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let Some(machine) = self.machine.take() {
self.machine.replace(machine.update(keystate));
}
assert!(self.machine.is_some());
}
WalkTheDogStateMachine
中的 update
方法有点空,我们可能需要在里面放一些代码。我们可以在更新中调用 match self
,然后在这个 update
函数中为每个状态编写行为,例如调用 state._state.walk.boy.run_right()
,这会起作用,但看起来很糟糕。相反,我们将匹配 self
并将任务委托给单个 state
类型。这将导致一个相当冗余的 match
语句,如下所示:
impl WalkTheDogStateMachine {
fn update(self, keystate: &KeyState) -> Self {
match self {
WalkTheDogStateMachine::Ready(state)
=>state.update(keystate).into(),
WalkTheDogStateMachine::Walking(state)
=>state.update(keystate).into(),
WalkTheDogStateMachine::GameOver(state)
=>state.update().into(),
}
}
}
我们之前在 RedHatBoyStateMachine
中看到了这个模式的变体,在那里我们必须匹配 enum
的每个变体才能将任务委托给状态,而且不幸的是,没有很好的解决办法。幸运的是,它很小。这个 match
语句将无法编译,因为没有任何 typestates
类型有 update
方法。事实上,对于 typestates
没有任何实现。让我们通过为所有三个创建占位符实现来继续我们的委托,如下所示:
impl WalkTheDogState<Ready> {
fn update(self, keystate: &KeyState) ->
WalkTheDogState<Ready> {
self
}
}
impl WalkTheDogState<Walking> {
fn update(self, keystate: &KeyState) ->
WalkTheDogState<Walking> {
self
}
}
impl WalkTheDogState<GameOver> {
fn update(self) -> WalkTheDogState<GameOver> {
self
}
}
值得我们回顾一下类型状态的工作方式。类型状态是一种泛型状态的结构。所以WalkTheDogState<T>
是结构,我们通过向WalkTheDogState<T>
的实现中添加方法来实现状态之间的转换,其中T
是具体状态之一。所有这些占位符都只是返回self
,所以update
目前还没有做任何事情。仔细观察你会发现,GameOver
不需要KeyState
,因为它不需要它。
WalkTheDogStateMachine
上的update
方法试图使用into
将每个类型状态转换回enum
,但我们还没有编写这些。回忆一下第四章,使用状态机管理动画,再次,我们需要实现From
以将各种状态转换回enum
类型。这些在这里实现:
impl From<WalkTheDogState<Ready>> for WalkTheDogStateMachine {
fn from(state: WalkTheDogState<Ready>) -> Self {
WalkTheDogStateMachine::Ready(state)
}
}
impl From<WalkTheDogState<Walking>> for WalkTheDogStateMachine {
fn from(state: WalkTheDogState<Walking>) -> Self {
WalkTheDogStateMachine::Walking(state)
}
}
impl From<WalkTheDogState<GameOver>> for WalkTheDogStateMachine {
fn from(state: WalkTheDogState<GameOver>) -> Self {
WalkTheDogStateMachine::GameOver(state)
}
}
这只是为了开始而做的样板代码,但它展示了这些是如何工作的。WalkTheDogStateMachine
上的update
方法使用match
获取每个变体的state
值。然后,在各个类型状态上调用update
方法。每个update
方法都返回它转换到的状态,尽管现在它们都返回self
。最后,回到WalkTheDogStateMachine
的update
方法中,我们调用into
将类型状态转换回enum
变体。
注意
你可能还记得,对于RedHatBoyStateMachine
,我们使用了转换函数和Event
enum
来推进状态机。新的WalkTheDogStateMachine
枚举有更少的事件,因此不需要额外的复杂性。
是时候考虑每个状态实际上应该做什么了。以前,这些状态中的每一个都被推到了Game
update
方法中——例如,以下旧代码:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
if keystate.is_pressed("ArrowRight") {
walk.boy.run_right();
}
if keystate.is_pressed("Space") {
walk.boy.jump();
}
if keystate.is_pressed("ArrowDown") {
walk.boy.slide();
}
在旧系统中,如果游戏是Loaded
状态,那么按下ArrowRight
键时,男孩可以run_right
,按下Space
键时可以跳跃。这工作得很好,但值得注意的是以下内容:
-
如果 RHB 已经在跑步,
run_right
函数将不起作用。 -
如果 RHB 没有在跑步,
jump
和slide
函数将不起作用。
我们在RedHatBoyStateMachine
中处理得相当好,并将继续这样做,但这也揭示了,一旦 RHB 开始向右移动,我们并不真的关心玩家是否推动了WalkTheDogStateMachine
。当游戏处于Ready
状态时,我们会检查用户是否按下了ArrowRight
键并转换状态。否则,我们就会保持在同一状态。
我们可以修改WalkTheDogState<Ready>
以反映这一新现实。函数的第一个更改将是进行此检查,如下所示:
impl WalkTheDogState<Ready> {
fn update(self, keystate: &KeyState) -> ReadyEndState {
if keystate.is_pressed("ArrowRight") {
ReadyEndState::Complete(self.start_running())
} else {
ReadyEndState::Continue(self)
}
}
}
有一种类型和一种方法不存在,因此这段代码目前无法编译。start_running
的转换还不存在,尽管我们讨论了编写类似的内容。我们也没有ReadyEndState
类型。让我们首先解决第二个问题。
我们之前已经使用过这种模式来处理任何可以返回多个状态的typestate
方法,例如Jumping
或Sliding
上的update
方法。我们创建了一个新的enum
,它可以表示任一返回状态。在WalkTheDogState<Ready>
的update
方法的情况下,游戏在更新结束时可以是Ready
(ReadyEndState::Continue
)或者完成并转换到Walking
(ReadyEndState::Complete
)。
让我们先实现From
特质,将ReadyEndState
转换为WalkTheDogStateMachine
:
enum ReadyEndState {
Complete(WalkTheDogState<Walking>),
Continue(WalkTheDogState<Ready>),
}
impl From<ReadyEndState> for WalkTheDogStateMachine {
fn from(state: ReadyEndState) -> Self {
match state {
ReadyEndState::Complete(walking) =>
walking.into(),
ReadyEndState::Continue(ready) => ready.into(),
}
}
}
这是一些你之前已经见过的通用模板。对于ReadyEndState
,我们有两种状态,因为WalkTheDogState<Ready>
的update
方法可以结束在两种状态中。为了从ReadyEndState
转换到WalkTheDogStateMachine
,我们创建了一个From
特质,并在ReadyEndState
的两个变体上匹配,并从中提取它们的字段。这两个都是类型状态,分别是WalkTheDogState<Ready>
和WalkTheDogState<Walking>
,因此我们使用它们的into
方法将它们转换为WalkTheDogStateMachine
类型。这些特质之前已经编写好了。
对self.start_running
的调用仍然不会工作,因为我们还没有编写它!当玩家调用一个以转换命名的typestate
方法时会发生什么,看起来像这样:
impl WalkTheDogState<Ready> {
...
fn start_running(mut self) -> WalkTheDogState<Walking> {
self.run_right();
WalkTheDogState {
_state: Walking,
walk: self.walk,
}
}
让我们回顾一下这些内容。每个状态
转换都作为各种类型状态上的一个方法来编写——在这个例子中,是WalkTheDogState<Ready>
,其中源状态是self
,返回值是目标状态。在这里,我们通过编写一个名为start_running
的方法从Ready
转换到Walking
。
实际实现并没有做太多。我们首先调用self.run_right
,这个方法还不存在,所以我们必须编写它。在发送 RHB 开始跑之后,我们通过返回一个带有_state
为Walking
的新WalkTheDogState
实例来进入Walking
状态。仔细看看start_running
函数的签名,你会发现它接受mut state
。这意味着获取对self
的独占所有权,我们可以这样做,因为我们拥有状态中的一切。这也是我们最初创建Option<WalkTheDogStateMachine>
的原因之一!然而,为什么在这里我们取mut state
而不是state
并不明显,部分原因是因为run_right
方法还不存在。当我们添加我们的新委托方法时,这应该会变得清晰,所以让我们立即用以下代码来做这件事:
impl WalkTheDogState<Ready> {
...
fn run_right(&mut self) {
self.walk.boy.run_right();
}
}
在WalkTheDogState<Ready>
上的这个函数通过其walk
字段调用boy
的run_right
方法。boy
上的run_right
方法需要一个可变借用,这就是为什么我们在之前的委托上要求一个可变借用。这也是为什么我们之前在start_running()
方法中需要mut state
的原因。你不能从最初不可变的事物上获取可变借用。
为了保持代码整洁,我们现在比之前做了更多的委托。这使得我们的方法更小,更容易理解,但代价是行为将分散在多个地方。我认为最终,这将使我们的代码更容易思考,因为我们不必在任何时候考虑太多代码,所以这种权衡是值得的。我们必须小心,不要在我们将代码拆分成块并分散到各个地方时丢失任何原始代码。
重新实现 draw
现在,我们已经从原始的update
方法中移除了所有编译错误,部分是通过移除其功能的一个大块,我们可以继续更新Walking
状态以确保它正常工作,但我相信这已经有一段时间没有从游戏中得到任何有意义的反馈了。毕竟,在这个时候,游戏无法编译也无法绘制。我们如何知道任何东西在正常工作?让我们花点时间更新Game
的draw
方法,这样我们就可以真正编译代码并查看它的工作情况。
draw
方法将首先借鉴update
方法,并用对WalkTheDogStateMachine
的委托来替换其当前实现,如下所示:
impl Game for WalkTheDog {
...
fn draw(&self, renderer: &Renderer) {
renderer.clear(&Rect::new(Point { x: 0, y: 0 },
600, 600));
if let Some(machine) = &self.machine {
machine.draw(renderer);
}
}
}
与我们对update
所做的更改相比,有两点略有不同。第一点是,我们只借用self.machine
,因为我们不需要可变访问。我们还在draw
的顶部清除了屏幕。这发生在每个状态变化时,所以没有理由不这么做。此外,如果我们犯了任何错误,这将帮助我们调试,因为屏幕会变白。
让我们通过向WalkTheDogStateMachine
添加一个draw
方法来继续委托,该方法可以从每个案例中提取状态以进行绘制,如下所示:
impl WalkTheDogStateMachine {
...
fn draw(&self, renderer: &Renderer) {
match self {
WalkTheDogStateMachine::Ready(state) =>
state.draw(renderer),
WalkTheDogStateMachine::Walking(state) =>
state.draw(renderer),
WalkTheDogStateMachine::GameOver(state) =>
state.draw(renderer),
}
}
}
这几乎与我们在之前写的update
方法完全相同,只是使用了借用的self
而不是消耗self
。其余的都是对各种状态的委托。与update
不同,每个状态都以完全相同的方式进行绘制,因此我们可以用一个方法来填充这些,如下所示:
impl<T> WalkTheDogState<T> {
fn draw(&self, renderer: &Renderer) {
self.walk.draw(renderer);
}
}
任何状态都会将draw
委托给Walk
,因为绘制实际上并不基于状态而改变。我们终于可以继续并重新实现draw
方法,这次是在Walk
上,如下所示:
impl Walk {
fn draw(&self, renderer: &Renderer) {
self.backgrounds.iter().for_each(|background| {
background.draw(renderer);
});
self.boy.draw(renderer);
self.obstacles.iter().for_each(|obstacle| {
obstacle.draw(renderer);
});
}
...
}
这段代码并不新鲜,但如果你忘记了它,我也不怪你。这是我们旧的draw
代码,来自第六章,创建无限跑酷游戏,只是将walk
变量替换为self
。其余的都是相同的。
在这一点上,你会注意到一些令人兴奋的事情——代码再次编译成功!但如果你仔细观察游戏,你会看到它有点静态。
图 8.15 – 站得非常稳……
红帽男孩已经停止了动画!他不会做他那个小小的空闲动画,因为我们不再像以前那样调用update
;快到回去修复update
方法的时候了。
重构初始化
在我们继续恢复功能之前,你可能记得我说过WalkTheDogStateMachine
的创建是“被所有状态机噪音所掩盖”。具体来说,它看起来像这样:
let machine = WalkTheDogStateMachine
::Ready(WalkTheDogState {
_state: Ready,
walk: Walk {
创建WalkTheDogStateMachine
需要创建它的Ready
变体,并传递一个WalkTheDog
状态,其_state
变量设置为Ready
。除了嘈杂之外,它还要求你记住状态机的正确初始状态。这正是构造函数的作用!
让我们为WalkTheDogState<Ready>
创建一个构造函数,如下所示:
impl WalkTheDogState<Ready> {
fn new(walk: Walk) -> WalkTheDogState<Ready> {
WalkTheDogState {
_state: Ready,
walk,
}
}
...
这使得创建一个新的WalkTheDogState<Ready>
类型状态变得更容易;接受Walk
,它需要是有效的。让我们也使创建整个机器更容易,使用更小的构造函数:
impl WalkTheDogStateMachine {
fn new(walk: Walk) -> Self {
WalkTheDogStateMachine
::Ready(WalkTheDogState::new(walk))
}
...
这个构造函数使用正确的状态创建整个机器,并将Walk
传递给它。现在我们已经创建了这些辅助方法,我们可以对原始的初始化方法进行更改,通过使用WalkTheDogStateMachine
构造函数使其更容易阅读:
impl Game for WalkTheDog {
async fn initialize(&self) -> Result<Box<dyn Game>> {
match self.machine {
None => {
...
let machine = WalkTheDogStateMachine
::new(Walk {
boy: rhb,
...
这是一个小小的改动,但它既使阅读变得更轻松,也使操作更安全。在Ready
状态下创建WalkTheDogStateMachine
是容易做到的,而在错误的状态下创建它则不是。
现在我们已经完成了这个小插曲,我们可以回到按计划完成更新方法。
完成更新
这段在Game
中的原始 update
函数揭示了我们的当前代码中缺少的内容:
impl Game for WalkTheDog {
...
fn update(&mut self, keystate: &KeyState) {
if let WalkTheDog::Loaded(walk) = self {
...
if keystate.is_pressed("ArrowDown") {
walk.boy.slide();
}
walk.boy.update();
...
在所有按钮按下检查之后,我们正在更新boy
。让我们继续在我们的WalkTheDogState<Ready>
实现中的新版本的update
函数中添加这个操作,如下所示:
impl WalkTheDogState<Ready> {
fn update(mut self, keystate: &KeyState) ->
ReadyEndState {
self.walk.boy.update();
if keystate.is_pressed("ArrowRight") {
ReadyEndState::Complete(self.start_running())
} else {
ReadyEndState::Continue(self)
}
}
...
这里有两个改动,所以别忘了现在将update
改为接受mut self
而不是self
。它在函数签名中隐藏着。此外,我们还添加了对self.walk.boy.update()
的调用,以再次开始更新男孩。
做完这件事,你会看到 RHB 再次空闲,准备开始追逐他的隐形狗。但如果你按下了右箭头,RHB 会冻结,在他的跑步动画的第一帧。这不是我们想要的,而且有趣的是,控制台日志中没有错误,因为没有抛出异常。只是Walking
状态在其update
函数中没有做任何事情。我们可以通过将之前注释/复制/删除的一些代码放回游戏的Walking
状态中,来恢复这段代码,如下所示:
impl WalkTheDogState<Walking> {
...
fn update(mut self, keystate: &KeyState) ->
WalkTheDogState<Walking> {
if keystate.is_pressed("Space") {
self.walk.boy.jump();
}
...
if self.walk.timeline < TIMELINE_MINIMUM {
self.walk.generate_next_segment();
} else {
self.walk.timeline += walking_speed;
}
self
}
}
在WalkTheDogState<Walking>
中,我们已经修改了update
方法,使其接受一个mut self
,然后恢复了大部分旧的Game
update
代码。由于篇幅原因,我没有展示整个方法,只是复制了代码片段的开始和结束部分,中间部分省略了;你可以安全地复制粘贴所有原始代码。我们做了一些修改,以使代码适应新的位置。原始代码中读取walk.boy
的地方现在读取self.walk.boy
。我还趁机将velocity
重命名为walking_speed
,以明确它指的是 RHB 行走速度。我们做的最后一个更改是移除了if keystate.is_pressed("ArrowRight")
代码,因为现在没有必要检查这个按键了。最后,我们返回self
,因为目前还没有从WalkTheDogState<Walking>
中过渡出去的方法。如果你全部正确地完成这些,你会发现你的代码可以编译并运行!实际上,截至此刻,所有行为都已恢复,包括我们必须刷新才能开始新游戏的问题。那么,我们现在就添加一个新的游戏按钮,怎么样?
开始新游戏
如果你记得我们最初计划的行为,如果你不记得,我也不怪你,我们想在 RHB 崩溃并倒下时在屏幕上绘制一个新的游戏按钮。然后,当它被点击时,我们希望开始一个新的游戏。为了实现这一点,我们需要做以下几步:
-
检查
RedHatBoyStateMachine
是否处于KnockedOut
状态,如果是,则从Walking
状态过渡到GameOver
状态。 -
在这个过渡过程中,绘制新的游戏按钮。
-
添加一个
onclick
处理程序,以便当按钮被点击时,我们使用一个新的Walk
实例过渡回Ready
状态。 -
在过渡到
Ready
状态时,隐藏按钮并重新开始游戏。
我们之前编写的所有代码都是为了使这个更改更容易。让我们看看我们是否正确地做到了这一点:
- 从
Walking
状态过渡到GameOver
状态。
要从Walking
状态过渡到GameOver
状态,我们需要在WalkTheDogState<Walking>
的update
方法中返回GameOver
状态,但我们应该在什么时候做这件事呢?我们需要查看男孩是否被击倒,然后进行相应的更改。我们目前还没有这个功能,所以我们需要创建它,并且让我们像本章一直做的那样自顶向下工作。首先,我们将修改WalkTheDogState<Walking>
的update
方法,以检查一个不存在的函数:
impl WalkTheDogState<Walking> {
...
fn update(mut self, keystate: &KeyState) ->
WalkingEndState {
...
if self.walk.timeline < TIMELINE_MINIMUM {
self.walk.generate_next_segment()
} else {
self.walk.timeline += walking_speed;
}
if self.walk.knocked_out() {
WalkingEndState::Complete(self.end_game())
} else {
WalkingEndState::Continue(self)
}
}
}
现在,我们不再总是返回Walking
状态,而是返回WalkingEndState
,这个状态目前还不存在,但将模仿我们在WalkTheDogState<Ready>
上的update
方法中使用的模式。当当前状态是knocked_out
时,我们将返回包含WalkTheDogState<GameOver>
类型实例的Complete
变体。这将是从end_game
转换返回的状态,这个转换也还没有写。否则,我们将返回Continue
,其字段为当前的WalkTheDogState<Walking>
状态。这是两个还不存在的函数,knocked_out
和end_game
,以及一个全新的类型。你可以通过遵循与ReadyEndState
相同的模式来创建WalkingEndState
类型及其相应的From
特质,将其转换为WalkTheDogStateMachine
。我不会在这里重现那段代码。我们将从那里开始,通过使knocked_out
工作来继续前进,这将从Walk
委托给RedHatBoyStateMachine
,中间有一些委托:
impl Walk {
fn knocked_out(&self) -> bool {
self.boy.knocked_out()
}
...
}
impl RedHatBoy {
...
fn knocked_out(&self) -> bool {
self.state_machine.knocked_out()
}
...
}
impl RedHatBoyStateMachine {
...
fn knocked_out(&self) -> bool {
matches!(self, RedHatBoyStateMachine
::KnockedOut(_))
}
}
我们可以将WalkTheDogState
传递给RedHatBoyStateMachine
以获取新状态,并遵循面向对象指导原则“告诉,不要询问”,但有时你只想检查一个布尔值。在这里,我们询问Walking
状态,它询问RedHatBoy
,最终询问RedHatBoyStateMachine
是否被击倒。RedHatBoyStateMachine
使用方便的matches!
宏来检查self
是否与enum
变体匹配,并返回它们是否匹配。现在我们可以检查红帽男孩是否被击倒,我们只有一个编译错误——“在WalkTheDogState
结构中找不到名为end_game
的方法”。
是时候实现end_game
转换方法了,它将代表我们的转换。我们可以从实现一个什么也不做,只是将walk
从Walking
转换为GameOver
的转换开始,如下所示:
impl WalkTheDogState<Walking> {
fn end_game(self) -> WalkTheDogState<GameOver> {
WalkTheDogState {
_state: GameOver,
walk: self.walk,
}
}
...
这使我们回到了编译状态,意味着当 RHB 崩溃并被击倒时,游戏处于GameOver
状态。然而,它什么也不做,所以现在是第二步——绘制新的游戏按钮。
- 绘制新的游戏按钮。
在很多页之前,我说:“为了以编程方式显示我们的按钮,我们只需调用browser::draw_ui("<button>New Game</button>")
。”但我们在什么时候调用它?嗯,我们现在调用它,就在创建新状态之前:
impl WalkTheDogState<Walking> {
fn end_game(self) -> WalkTheDogState<GameOver> {
browser::draw_ui("<button>New Game</button>");
WalkTheDogState {
_state: GameOver,
walk: self.walk,
}
}
...
如果你在这条代码行中添加这一行,你将看到我们很久以前在 RHB 撞到岩石时编写的新的游戏按钮。这一行有一个警告,因为我们没有处理draw_ui
的结果,我们暂时忽略它。
- 为按钮添加
onclick
处理程序。
为了将点击处理程序添加到按钮,我们需要获取我们刚刚绘制的元素的引用。我们没有这个引用,因为insert_adjacent_html
函数不提供它,所以我们需要找到我们刚刚添加到屏幕上的按钮,以便我们可以将其事件处理程序附加到它上。我们之前在document
上使用了get_element_by_id
两次,所以可能是时候在browser
模块中编写一个包装函数,如下所示:
pub fn find_html_element_by_id(id: &str) -> Result<HtmlElement> {
document()
.and_then(|doc| {
doc.get_element_by_id(id)
.ok_or_else(|| anyhow!("Element with
id {} not found", id))
})
.and_then(|element| {
element
.dyn_into::<HtmlElement>()
.map_err(|err| anyhow!("Could not cast
into HtmlElement {:#?}", err))
})
}
我们对这个函数中查找元素的方式做了一些轻微的调整。通常,我们想要HtmlElement
而不是通用的Element
类型,所以在这个函数中,我们提前添加了一个调用dyn_into
来执行转换。因此,这个函数首先获取文档,然后获取元素,最后将其转换为HtmlElement
类型,同时使用anyhow!
来规范化错误。
现在我们有了查找元素的方法,我们可以回到game
中的转换,找到新添加的新游戏按钮,然后向它添加一个点击处理程序,如下面的代码所示:
impl WalkTheDogState<Walking> {
fn end_game(self) -> WalkTheDogState<GameOver> {
let receiver = browser::draw_ui("<button id='new_game'>New Game</button>")
.and_then(|_unit| browser::
find_html_element_by_id("new_game"))
.map(|element| engine::
add_click_handler(element))
.unwrap();
WalkTheDogState {
_state: GameOver,
walk: self.walk,
}
}
我们在这里重现了整个转换特性,但有三个变化。第一个变化是我们向新游戏按钮添加了id
;自然地,它是new_game
。然后,我们在and_then
块中找到文档中的元素,并使用map
函数将这个元素传递给最近创建的add_click_handler
函数。现在,我们遇到了一个小问题。我们需要receiver
在点击事件发生时获取点击消息,但add_click_handler
函数返回带有UnboundedReceiver
的Result
。挑战在于end_game
函数不返回Result
。在第九章,测试、调试和性能中,我们将探讨如何调试这类条件,但现在,我们只能咬紧牙关并添加unwrap
。
现在我们有了receiver
,它会在玩家点击GameOver
状态的update
函数以及我们接收到事件转换到Ready
状态时接收消息。这意味着我们需要将receiver
添加到GameOver
结构体中,如下所示:
struct GameOver {
new_game_event: UnboundedReceiver<()>,
}
这将提示你添加futures::channel::mpsc::UnboundedReceiver
的use
声明。现在GameOver
结构体有了这个字段,我们需要在转换中传递它,如下所示:
impl WalkTheDogState<Walking> {
fn end_game(self) -> WalkTheDogState<GameOver> {
let receiver = browser::draw_ui("<button
id='new_game'>New Game</button>")
.and_then(|_unit| browser::
find_html_element_by_id("new_game"))
.map(|element| engine::
add_click_handler(element))
.unwrap();
WalkTheDogState {
_state: GameOver {
new_game_event: receiver,
},
walk: self.walk,
}
}
这是此方法的最终更改,只是将字段添加到GameOver
中。有趣的是,这是我们第一次向任何状态结构体添加字段,但随着你扩展这个游戏,你很可能会做更多类似的事情。各种状态都有它们独有的数据,它们属于state
结构体。
是时候回到WalkTheDogState<GameOver>
的实现及其update
方法了,它目前只是返回GameOver
状态,使游戏永远处于该状态。相反,我们希望检查新游戏事件是否发生(因为按钮被点击),然后返回Ready
状态以重新开始。这里复制的这段小代码如下:
impl WalkTheDogState<GameOver> {
fn update(mut self) -> GameOverEndState {
if self._state.new_game_pressed() {
GameOverEndState::Complete(self.new_game())
} else {
GameOverEndState::Continue(self)
}
}
}
impl GameOver {
fn new_game_pressed(&mut self) -> bool {
matches!(self.new_game_event.try_next(),
Ok(Some(())))
}
}
在WalkTheDogState<GameOver>
的实现中,我们检查状态以查看是否按下了新游戏按钮,如果是,则返回GameOverEndState::Complete
变体;否则,返回GameOverEndState::Continue
变体。这是我们已经在每个其他更新方法中使用的相同模式,你可以继续复制GameOverEndState
枚举及其相应的From
特质,以将类型转换为WalkTheDogStateMachine
枚举。这段代码在此处未复制,但请记住,如果你遇到困难,可以在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_8
找到示例代码。
在GameOver
的实现中,我们有检查new_game_event
(对应玩家的点击)是否发生的详细信息。调用try_next
将立即返回Result
,不阻塞,或者如果通道仍然打开,则返回Ok
,无论其中是否有内容。请记住,我们以每秒 60 帧的速度运行,不能使用阻塞调用。最后,我们使用方便的 matches!宏来检查是否成功向通道发送了unit
的消息或Ok(Some(()))
。如果事件存在,则返回true
。
这段代码无法编译,因为我们没有从GameOver
到Ready
的转换代码,这是我们将在下一步中编写的。
- 在新游戏上重新开始游戏。
重新开始游戏意味着在new_game
转换上执行两件事。第一是隐藏按钮或“UI”,第二是从头开始重新创建Walk
。第一件事实际上更容易,所以我们将从这里开始:
impl WalkTheDogState<GameOver> {
...
fn new_game(self) -> WalkTheDogState<Ready> {
browser::hide_ui();
WalkTheDogState {
_state: Ready,
walk: self.walk,
}
}
}
这是另一个转换,这次是从GameOver
到Ready
,副作用是隐藏 UI。然后它移动到一个新的状态,具有我们结束时的相同行走,这并不是我们想要的。
图 8.16 – 我点击了新游戏 – 小伙子,快跑!
按钮被隐藏了,但 RHB 仍然被打败了。从GameOver
到Ready
的转换意味着从旧的一个创建一个新的Walk
实例,因此游戏重新开始。这有点挑战性,因为我们不再能够访问我们最初创建Walk
和RedHatBoy
时使用的各种图像和精灵表。我们将通过Walk
实现的构造函数从现有的一个克隆它们。我们不会调用这个clone
,因为这个词意味着一个完全相同的副本,而实际上这是一个重置。你可以在下面看到实现:
impl Walk {
fn reset(walk: Self) -> Self {
let starting_obstacles =
stone_and_platform(walk.stone.clone(),
walk.obstacle_sheet.clone(), 0);
let timeline = rightmost(&starting_obstacles);
Walk {
boy: walk.boy,
backgrounds: walk.backgrounds,
obstacles: starting_obstacles,
obstacle_sheet: walk.obstacle_sheet,
stone: walk.stone,
timeline,
}
}
...
}
reset
函数消耗Walk
并返回一个新的实例。它以与initialize
中创建相同的方式重新创建starting_obstacles
,然后重新计算timeline
。然后,它构建一个新的Walk
,将Walk
中的所有值(除了starting_obstacles
和timeline
)移动到新实例中。不过,这个函数还不够正确,因为它会重置Walk
,但会留下boy
处于其KnockedOut
状态。我们需要为boy
提供一个类似的reset
函数,如下所示:
impl RedHatBoy {
...
fn reset(boy: Self) -> Self {
RedHatBoy::new(
boy.sprite_sheet,
boy.image,
boy.state_machine.context().audio.clone(),
boy.state_machine.context().jump_sound.clone(),
)
}
在RedHatBoy
上写reset
要比在Walk
上容易得多,因为我们很久以前就为RedHatBoy
创建了一个构造函数,new
。我们也应该为Walk
做同样的事情,但这个重构取决于你。记住,为了编译通过,RedHatBoyContext
上的audio
和jump_sound
字段需要是公开的。
现在我们有了RedHatBoy
的reset
函数,我们可以在Walk
的reset
函数中使用它,如下所示:
impl Walk {
...
fn reset(walk: Self) -> Self {
...
Walk {
boy: RedHatBoy::reset(walk.boy),
...
}
}
}
我们还需要在从GameOver
到Ready
的原生转换中调用这个函数,如下所示:
impl WalkTheDogState<GameOver> {
...
fn new_game(self) -> WalkTheDogState<Ready> {
browser::hide_ui();
WalkTheDogState {
_state: Ready,
walk: Walk::reset(self.walk),
}
}
}
如果你做了所有这些,你会发现当你点击新游戏按钮时,游戏会重置,玩家会回到起点。你应该能够按下右箭头键并再次开始行走。你应该能这样做,但它不起作用,因为我们还没有考虑到 UI 的一个特性——焦点。
- 注意焦点!
结果,当我们点击新游戏按钮使游戏准备好再次播放时,还有一件事要做。当游戏开始时,我们设置了画布以获得焦点,以便它能够接收键盘输入。我们使用原始 HTML 中的tabIndex
字段做到了这一点。当玩家点击新游戏时,他们会将焦点转移到按钮上,然后隐藏按钮,这意味着没有任何东西会接收到我们正在监听的键盘事件。你可以通过点击新游戏然后点击按钮消失后的画布来看到这个效果。如果你点击画布,它会重新获得焦点,你就可以再次玩游戏了。
我们可以在browser
模块的hide_ui
函数中自动将焦点转回画布。是否应该在这里讨论这个问题是有争议的,因为可能存在你想要隐藏 UI 但不重置焦点的情况,但我们的游戏没有这种情况,所以我认为我们是安全的。这个更改如下所示:
pub fn hide_ui() -> Result<()> {
let ui = find_ui()?;
if let Some(child) = ui.first_child() {
ui.remove_child(&child)
.map(|_removed_child| ())
.map_err(|err| anyhow!("Failed to remove
child {:#?}", err))
.and_then(|_unit| {
canvas()?
.focus()
.map_err(|err| anyhow!("Could not
set focus to canvas!
{:#?}", err))
})
} else {
Ok(())
}
}
在对移除子项进行第一次map_err
调用之后,我们添加了第二个and_then
调用,它从早期的map
调用中获取unit
,立即忽略它,然后请求canvas
上的focus
。focus
调用的错误不会返回anyhow!
类型,所以编译器会报错,我们通过map_err
调用解决这个问题。focus
函数是我们通过web-sys
调用的 JavaScript 函数,它在 MDN 上有文档(mzl.la/30YGOMm
)。
通过这个更改,你可以点击新游戏并开始另一轮尝试。我们做到了!
预加载
你可能会注意到按钮在屏幕上显示时是可见地加载的——也就是说,图像和字体还没有下载到浏览器中,所以它不会立即出现。这是网络浏览器的标准行为。为了确保你不需要等待整个页面的图像、字体和其他资源加载完毕才能看到页面,浏览器会懒加载资源。这是如此常见,以至于你可能没有注意到当页面加载时Button.svg
和kenney_future_narrow-webfont.woff2
资源立即加载,以便当按钮出现时,它是瞬间的。打开index.html
文件,并按照这里所示进行更改:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My Rust + Webpack project!</title>
<link rel="stylesheet" href="styles.css" type="text/css"
media=
"screen">
<link rel="preload" as="image" href="Button.svg">
<link rel="preload" as="font" href=
"kenney_future_narrow-webfont.woff2">
</head>
带有preload
属性的link
标签将在渲染页面之前预加载资源。你通常希望尽量减少这种行为,因为你不希望用户在空白屏幕上等待很长时间,如果你要制作一个非常大的游戏,包含许多资源,你可能需要在代码中使用更灵活的解决方案,并添加一个加载屏幕。我们的游戏目前很小,所以这完全适用。随着这个变化,新的游戏按钮不仅出现,而且响应迅速。
摘要
你可以从两个角度来看本章的结尾。第一个可能是说,“就为了一个按钮?”你确实有这个疑问。毕竟,我们的用户界面(UI)只是一个新的游戏按钮,虽然这是事实,但我们实际上覆盖了很多内容。我们通过web-sys
将 DOM 集成到我们的应用中,并相应地调整了我们的游戏以处理它。通过利用 DOM,我们能够利用浏览器来实现点击和悬停等行为,而无需检测鼠标在画布中的位置并创建可点击区域。现在,你可以使用 CSS Grid 和 Flexbox 等工具创建更复杂的 UI,所以如果你熟悉网络开发,你在这整本书中都在做,那么你将能够为你的游戏制作高质量的 UI。如果你在寻找一个起点,尝试给这个游戏添加一个分数。你可以在更新时增加分数,并在游戏结束菜单中显示它,或者在游戏右下角显示,或者两者都要!我期待看到它。
就这样,我们将从新功能开发转移到确保我们的当前功能正常且运行快速。现在是时候开始进行一些测试和调试了,所以我们将在下一章深入探讨这一点。
第三部分:测试和高级技巧
虽然你可能能够在没有测试或调试的情况下制作这个小游戏,但随着你自己游戏的开发,你需要证明游戏是可行的。没有编写一些测试和调试一些代码,你无法做到这一点。你还需要检查性能,所以我们也会涵盖这一点。如果你想让人们玩你的游戏,你需要一个 CI/CD 流水线,这就是为什么我们会构建一个。最后,我们将探讨你可以扩展游戏的方法,包括与第三方 JavaScript 的互操作性。
在这部分,我们将涵盖以下章节:
-
第九章, 测试、调试和性能
-
第十章, 持续部署
-
第十一章, 更多资源与未来展望?
第九章:第九章: 测试、调试和性能
在这本书中,我们使用两个工具来测试我们的逻辑——也就是说,编译器和我们的眼睛。如果游戏无法编译,它就是有问题的,如果红帽男孩(RHB)看起来不对,它也是有问题的——这很简单。幸运的是,编译器提供了很多工具来确保我们不会犯错误。但是,让我们说实话——这还不够。
开发一个游戏可能是一个漫长的过程,尤其是如果你是一个爱好者。当你在一周内只有 4 个小时可以用来工作的时候,你不能把所有的时间都花在同一个错误上。为了确保我们的游戏能够正常工作,我们需要对其进行测试,找出错误,并确保它不会太慢。这正是我们在这里要做的。
本章将涵盖以下主题:
-
创建自动化测试
-
调试游戏
-
使用浏览器测量性能
完成本章后,你将能够修复我们迄今为止编写的错误,并确保它们不再发生。
技术要求
在本章中,我们将使用 Chrome 开发者工具来调试代码并监控性能。其他浏览器也配备了强大的开发者工具,但为了本章的截图和说明,我们将使用 Chrome。
本章的源代码可在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_9
找到。
查看以下视频,了解代码的实际应用:bit.ly/3NKppLk
创建自动化测试
在一个理想的世界里,每个系统都应该有大量的测试,包括自动和手动测试,由开发人员和 QA 团队完成。以下是一些测试你的游戏是否正确工作的方法:
-
使用类型来防止程序员错误
-
自己玩游戏
-
执行自动化单元测试
-
执行自动化集成测试
到目前为止,我们只使用了前两种,这在现实世界的代码中是一个不幸的常见做法。这可能适合个人或爱好项目,但它对于生产应用来说不够健壮,尤其是那些由团队编写的应用。
几乎任何应用程序都可以从自动的、程序员编写的单元测试中受益,随着程序变得越来越大,它也开始从集成测试中受益。这两种测试类型之间的区别并没有一个一致的定义,因为你通常在看到它们时才会知道,但幸运的是,我们可以使用 Rust 的定义。Rust 和 Cargo 提供了两种测试类型:
-
通过
cargo test
进行单元测试 -
通过
wasm-pack test
进行集成测试
单元测试通常以程序员为中心。它们在方法或函数级别编写,具有最少的依赖。你可能会有一个针对if/else
语句每个分支的测试,而在循环的情况下,你可能会有针对列表为 0、1 或多个条目的测试。这些测试很小、很快,应该在几秒钟或更短的时间内运行。这是我首选的测试形式。
集成测试通常会从更高的层次查看应用程序。在我们的代码中,集成测试会自动化浏览器,并基于事件(如鼠标点击)在整个程序中工作。这些测试编写起来耗时更长,更难维护,并且经常因为神秘的原因而失败。那么,为什么还要编写它们呢?单元测试通常不会测试应用程序的各个部分,或者它们可能只在小范围内这样做。这可能导致一种情况,即单元测试全部通过,但游戏却无法运行。由于集成测试的缺点,大多数系统将比单元测试少,但它们需要它们来获得其好处。
在 Rust 中,单元测试是与模块并行的,并通过cargo test
运行。在我们的设置中,它们将作为 Rust 可执行程序的一部分运行,直接在机器上运行。集成测试存储在tests
目录中,并且只能访问你的 crate 公开的事物。它们在浏览器中运行——可能是一个无头浏览器——通过wasm-pack test
。单元测试可以直接测试内部方法,而集成测试必须像真实程序一样使用你的 crate。
小贴士
汉姆·沃克(Ham Vocke)有一篇非常详细的关于测试金字塔的文章,它描述了一种组织系统中所有测试的方法:martinfowler.com/articles/practical-test-pyramid.html
。
测试驱动开发
我有一个坦白要说。我通常以测试驱动的方式编写所有代码,即先写一个测试,然后在开发过程的每个步骤中让它失败。如果我们在这本书的开发过程中遵循这个过程,我们可能会有一大堆测试——可能超过 100 个。此外,测试驱动开发(TDD)对设计施加了很大的压力,这往往会导致更松散耦合的代码。那么,为什么我们没有这样做呢?
好吧,TDD 有其缺点,其中最大的可能是我们会生成大量的测试代码。我们在这本书中已经写了很多代码,所以想象一下还要跟着测试一起做——你可以看到为什么我觉得最好省略掉我通常写的测试。毕竟,这本书的标题不是《测试驱动的 Rust》。然而,仅仅因为我们没有先写测试,并不意味着我们不希望确保我们的代码能正常工作。这就是为什么在许多情况下,我们使用类型系统作为防止错误的第一道防线,例如使用类型状态模式进行状态转换。类型系统是使用 Rust 而不是 JavaScript 进行这款游戏的优点之一。
这并不是说自动化测试不能为我们程序提供价值。Rust 生态系统高度重视测试,以至于测试框架被内置到 Cargo 中,并且为任何 Rust 程序自动设置。通过单元测试,我们可以测试诸如碰撞检测或我们著名的状态机等算法。我们可以确保游戏仍然按照我们的预期运行,尽管我们无法测试游戏是否有趣或漂亮。为此,你可能需要一直玩游戏直到你讨厌它,但游戏如果基础功能正常,会更有趣。我们可以使用测试以及类型来确保代码按预期工作,这样我们就可以将注意力转向游戏是否有趣。为此,我们需要设置测试运行器,然后编写一些在浏览器外和浏览器内运行的测试。
注意
如果你对 TDD 感兴趣,Kent Beck 的书《通过示例进行测试驱动开发》(Test-Driven Development By Example)仍然是一个极好的资源。对于使用 TypeScript 和 React 的基于 Web 的方法,你可以看看一本叫做《自己动手制作电子表格》(Build Your Own Spreadsheet)的优秀书籍。
入门
如我们之前提到的,Rust 内置了运行测试的能力——既包括单元测试和集成测试。不幸的是,我们很久以前在第一章,“Hello WebAssembly”,所使用的模板在撰写本文时仍然存在过时的设置。如果尚未修复,在命令提示符下运行 cargo test
将无法编译,更不用说运行测试了。幸运的是,错误并不多。只是有一个过时的 async
代码用于浏览器测试,而我们不会在自动生成的测试中使用。这些测试位于 tests
目录下的 app.rs
文件中。在 Cargo 项目中,传统上集成测试会放在这里。我们将很快通过使用单元测试来更改这个设置,但首先,让我们通过删除错误的 async_test
设置测试来确保它能编译。在 app.rs
中,你可以删除那个函数以及其上的 #[wasm_bindgen_test(async)]
宏,这样你的 app.rs
文件看起来就像这样:
use futures::prelude::*;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;
use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure};
wasm_bindgen_test_configure!(run_in_browser);
// This runs a unit test in native Rust, so it can only use Rust APIs.
#[test]
fn rust_test() {
assert_eq!(1, 1);
}
// This runs a unit test in the browser, so it can use browser APIs.
#[wasm_bindgen_test]
fn web_test() {
assert_eq!(1, 1);
}
注意
在这本书出版后,模板将被修复,并且很可能会编译。我将假设这一点,无论你如何更改代码,以便它与此处的内容相匹配。
一些 use
声明不再需要了,但它们将很短,所以你可以保留它们并忽略警告。现在,app.rs
包含了两个测试 - 一个将在 JavaScript 环境中运行,例如浏览器,另一个将作为原生 Rust 测试运行。这两个都只是示例,其中 1
仍然等于 1
。要运行原生 Rust 测试,你可以运行 cargo test
,就像你可能习惯的那样。这将运行带有 test
宏注解的 rust_test
函数。你可以通过 wasm-pack test --headless --chrome
命令运行基于浏览器的测试,这些测试带有 wasm_bindgen_test
宏。这将使用 Chrome 浏览器在无头环境中运行网络测试。你也可以使用 --firefox
、--safari
和 --node
,如果你愿意,但你必须指定你将在哪个 JavaScript 环境中运行它们。请注意,--node
不会工作,因为它没有浏览器。
我们将开始使用 #[test]
宏编写测试,这个宏在原生环境中运行 Rust 代码,就像编写一个标准的 Rust 程序一样。要测试的最简单的事情是一个纯函数,所以让我们试试。
纯函数
#[test]
注解并使用 cargo test
运行。
当前设置在 test/app.rs
文件中运行我们唯一的 Rust 测试,这使得在 Cargo 看来,它是一个集成测试。我不喜欢这样,更愿意使用 Rust 习惯在执行代码的文件中编写单元测试。在这个第一个例子中,我们将测试 Rect
上的 intersects
函数,这是一个复杂到足以出错的自纯函数。我们将把这个测试添加到 engine.rs
文件的底部,因为 Rect
就是在那里定义的,然后我们将使用 cargo test
运行它。让我们在这个模块的底部添加一个测试,用于 Rect
上的 intersect
方法,如下所示:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_rects_that_intersect_on_the_left() {
let rect1 = Rect {
position: Point { x: 10, y: 10 },
height: 100,
width: 100,
};
let rect2 = Rect {
position: Point { x: 0, y: 10 },
height: 100,
width: 100,
};
assert_eq!(rect2.intersects(&rect1), true);
}
}
大部分内容在 Rust 书籍中有记录,见 bit.ly/3bNBH3H
,但复习一下永远不会有害。我们首先使用 #[cfg(test)]
属性宏告诉 Cargo 不要编译和运行此代码,除非我们在运行测试。然后,我们使用 mod
关键字创建一个 tests
模块,以将我们的测试与代码的其他部分隔离开。之后,我们使用 use super::*
导入 engine
代码。然后,我们通过编写一个函数 two_rects_that_intersect_on_the_left
并用 #[test]
宏注解它来编写我们的测试,这样测试运行器就可以找到它。其余的都是一个相当标准的测试。它创建了两个矩形,第二个矩形与第一个矩形重叠,然后确保 intersects
函数返回 true
。你可以使用 cargo test
运行这个测试,你将看到以下输出:
Finished test [unoptimized + debuginfo] target(s) in 1.48s
Running target/debug/deps/rust_webpack_template-5805000a6d5d52b4
running 1 test
test engine::tests::two_rects_that_intersect_on_the_left ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running target/debug/deps/app-ec65f178e238b04b
running 1 test
test rust_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
你将看到两组结果。第一个结果是关于我们的新测试two_rects_that_intersect_on_the_left
,它将通过。然后,你会看到rust_test
运行,它也将通过。rust_test
测试位于tests\app.rs
中,它是与项目骨架一起创建的。因为它位于tests
目录中,所以它作为一个集成测试运行——这是 Cargo 的标准。单元测试和集成测试之间的区别在于,集成测试作为一个单独的 crate 运行,并使用生产代码作为单独的库。这意味着它们以与你的 crate 用户相同的方式使用代码,但它们不能调用内部或私有函数。当你运行单元测试时,更容易获得完整的覆盖率,但有一个前提是它们可能不太现实。我们的代码不是作为 crate 使用的,所以我们不会使用很多集成测试。
现在我们已经为我们的代码编写了第一个单元测试,我们可以为这个intersects
方法编写更多测试,包括以下情况发生时:
-
当矩形在顶部或底部重叠时
-
当矩形在右侧重叠时
-
当矩形不重叠时——也就是说,当函数返回 false 时
我们应该在intersects
函数的每个分支都有一个测试。我们将这些测试留给你作为练习,因为重复它们将是多余的。随着我们的代码库增长,如果大部分代码可以像这样轻松地进行测试,那将是理想的,但不幸的是,对于这个游戏,很多代码都与浏览器交互,因此我们将有两种不同的测试方式。第一种方式是用存根替换浏览器,这样我们就不需要运行基于浏览器的测试。我们将在下一节中这样做。
隐藏浏览器模块
在第三章“创建游戏循环”中,我们将浏览器功能分离到了一个browser
模块中。我们可以利用这个接口注入测试版本的浏览器功能,使其作为原生 Rust 代码运行,并允许我们编写测试。
注意
术语接口来自迈克尔·费瑟斯的书籍《与遗留代码有效协作》(amzn.to/3kas1Fa
)。这本书是用 C++和 Java 编写的,但仍然是你可以找到的关于遗留代码的最佳书籍。接口是一个可以插入测试行为以替换真实行为的地方,而启用点是代码中允许这种情况发生的地方。
接口是我们可以在不更改该处代码的情况下改变程序行为的地方。看看game
模块中的以下代码:
impl WalkTheDogState<GameOver> {
...
fn new_game(self) -> WalkTheDogState<Ready> {
browser::hide_ui();
WalkTheDogState {
_state: Ready,
walk: Walk::reset(self.walk),
}
}
}
我们想测试当游戏从 GameOver
状态转换为 Ready
状态时,UI 是否被隐藏。我们可以通过集成测试来完成这项工作,检查在这次转换之后包含 UI 的 div
属性是否为空。我们可能想这样做,但这样的测试通常编写和维护起来要困难一些。当游戏增长时,这一点尤其正确。另一种方法,我们将在这里使用,是用不与浏览器交互的 browser
模块的版本来替换它。接口是 hide_ui
,这是一种我们可以替换而不实际更改代码的行为,而启用点是 use
声明,这是我们引入 browser
模块的地方。
我们可以通过条件编译启用使用 browser
模块的测试版本。与 #[cfg(test)]
宏仅在测试模式下编译时包含 test
模块的方式相同,我们可以使用 cfg
指令导入 browser
模块的不同版本,如下所示:
#[cfg(test)]
mod test_browser;
#[cfg(test)]
use test_browser as browser;
#[cfg(not(test))]
use crate::browser;
上述代码可以在 game
模块的顶部找到,我们之前在这里导入 crate::browser
。在这里,我们可以使用 mod
关键字从 src/game/test_browser.rs
文件中引入 test_browser
模块的 内容,但仅在我们运行测试构建时。然后,我们可以使用 test_browser as browser
使函数通过 browser::
调用来可用 – 同样,仅在测试构建中 – 就像我们调用生产代码中的 browser
一样。最后,我们可以添加 #[cfg(not(test))]
注解到 use crate::browser
以防止将真实的 browser
代码导入到测试中。
注意
我第一次在 Klausi 的博客上看到这项技术,bit.ly/3ENxhWQ
,但在 Rust 代码中相当常见。
如果你这样做并运行 cargo test
,你会看到很多错误,例如 cannot find function
fetch_jsonin module
browser``,因为尽管我们正在导入一个测试模块,但我们还没有用任何代码填充它。在这种情况下,遵循编译器错误是一个好主意,它会指出在
src/game/test_browser.rs中还没有文件。它还会列出在
game模块中使用但未在
test_browser.rs文件中定义的函数。为了解决这个问题,你可以创建
test_browser.rs` 文件,并引入所需的最小内容以重新开始编译,如下所示:
use anyhow::{anyhow, Result};
use wasm_bindgen::JsValue;
use web_sys::HtmlElement;
pub fn draw_ui(html: &str) -> Result<()> {
Ok(())
}
pub fn hide_ui() -> Result<()> {
Ok(())
}
pub fn find_html_element_by_id(id: &str) -> Result<HtmlElement> {
Err(anyhow!("Not implemented yet!"))
}
pub async fn fetch_json(json_path: &str) -> Result<JsValue> {
Err(anyhow!("Not implemented yet!"))
}
如您所见,game
模块中只使用了四个在browser
中定义过的函数,而我们只填入了足够编译的代码。为了进行测试,我们需要放置一些具有某种跟踪状态的模拟实现。您可能还会注意到,由于在运行 Rust 原生测试时它们无法工作,所以这段代码中同时使用了JsValue
和HtmlElement
。它们需要一个浏览器运行时,因此为了继续沿着这条路径前进,我们最终需要为HtmlElement
和JsValue
创建测试版本,或者为它们创建包装类型,这可能在engine
模块中完成。不过,现在我们先保留这些不变,尝试使用标准的 Rust 测试框架编写我们的第一个测试。我们将通过设置游戏在GameOver
状态并过渡到Running
状态来测试我之前提到的状态机变化,然后检查 UI 是否被隐藏。该测试的*开始部分如下所示:
#[cfg(test)]
mod tests {
use super::*;
use futures::channel::mpsc::unbounded;
use std::collections::HashMap;
use web_sys::{AudioBuffer, AudioBufferOptions};
fn test_transition_from_game_over_to_new_game() {
let (_, receiver) = unbounded();
let image = HtmlImageElement::new().unwrap();
let audio = Audio::new().unwrap();
let options = AudioBufferOptions::new(1, 3000.0);
let sound = Sound {
buffer: AudioBuffer::new(&options).unwrap(),
};
let rhb = RedHatBoy::new(
Sheet {
frames: HashMap::new(),
},
image.clone(),
audio,
sound,
);
let sprite_sheet = SpriteSheet::new(
Sheet {
frames: HashMap::new(),
},
image.clone(),
);
let walk = Walk {
boy: rhb,
backgrounds: [
Image::new(image.clone(), Point { x: 0, y:
0 }),
Image::new(image.clone(), Point { x: 0, y:
0 }),
],
obstacles: vec![],
obstacle_sheet: Rc::new(sprite_sheet),
stone: image.clone(),
timeline: 0,
};
let state = WalkTheDogState {
_state: GameOver {
new_game_event: receiver,
},
walk: walk,
};
}
}
哎呀——测试这么多的代码只是为了测试几行 Rust,而且这甚至不是一个完整的测试。它只是设置游戏到我们需要它处于的状态,在我们过渡到Ready
状态之前。这揭示了关于我们设计的大量信息,特别是它可能是我所说的天真。构建对象非常困难,尽管game
、engine
和browser
模块是分开的,但它们仍然相当紧密地耦合在一起。它确实可以工作,但只是在解决我们面前的问题。这是完全可以接受的——我们有一个具体的目标,那就是构建一个小型的无限跑酷游戏,我们做到了,但这同时也意味着,如果我们想要开始扩展我们的游戏引擎,使其更加灵活,我们就需要做出进一步的改变。我倾向于将软件设计看作是雕刻,而不是构建。你从一个大块的代码开始,然后逐渐雕刻,直到它看起来像你想要的样子,而不是遵循蓝图来建造完美的房子。
这个测试揭示了我们设计的一些方面如下:
-
创建新的
Walk
结构并不容易。 -
game
模块与web-sys
和wasm-bindgen
的耦合程度远超我们的想象。
我们有意选择不在项目早期尝试创建完美的抽象。这也是我们最初没有以测试驱动的方式编写代码的原因之一。TDD 会强烈推动进一步抽象和分层,这可能会隐藏我们试图学习的游戏代码。例如,我们可能不会使用HtmlImageElement
或AudioBuffer
,而是围绕这些对象编写包装器或抽象(我们已经有了一个Image
结构体),这在中等或长期内可能更有利于项目的发展,但在短期内可能更难理解。
这是一种冗长的说法,意思是由于我们没有考虑到这一点,现在很难为这段代码编写独立的单元测试。如果你能运行这个测试,你会看到以下内容:
thread 'game::tests::test_transition_from_game_over_to_new_game' panicked at 'cannot call wasm-bindgen imported functions on non-wasm targets', /Users/eric/.cargo/registry/src/github.com-1ecc6299db9ec823/web-sys-0.3.52/src/features/gen_HtmlImageElement.rs:4:1
结果表明,尽管我们用test_browser
替换了生产中的browser
,但我们仍在尝试调用浏览器代码。我已经指出了HtmlElement
和JsValue
,但这个测试还包括AudioBuffer
和AudioBufferOptions
。按照现在的样子,如果没有启用更多功能标志并对engine
进行更改,这段代码是无法编译的。它仍然与浏览器耦合得太紧密了。
尝试在测试框架中使用此代码的行为展示了耦合的力量,通常将遗留代码放入框架中,以识别这些依赖问题并解决它们是非常有用的。不幸的是,这是一个耗时过程,我们不会在本节中继续使用它,尽管它可能会在我的博客paytonrules.com上某个时候出现。相反,我们将通过在浏览器中运行的测试来测试此代码。
浏览器测试
在本章的开头,我提到过有单元测试和浏览器测试。区别在于,虽然浏览器测试可能与单元测试测试相同的行为,但它们在无头浏览器中自动化了所需的行为。这使得测试更加真实,但同时也更慢,更容易因为不稳定的原因而失败。我更喜欢我的系统有一个大量的单元测试和较少的更集成的测试,以确保一切连接正确无误,但我们并不总能得到我们想要的结果。
相反,我们将通过跳过针对遗留代码的依赖破坏技术,并编写一个在浏览器中运行的测试来获取我们所需的东西——行为的验证。我们将移除添加了test_browser
模块的代码,以及test_browser
文件本身。我们将保留之前编写的测试,并对其进行两项更改,如下所示:
-
将
AudioBufferOptions
添加到Cargo.toml
中web-sys
功能列表中。 -
在
engine
模块中,将Sound
结构体上的buffer
字段设置为公共的,这样我们就可以在这个测试中直接创建Sound
。
这两项更改将使代码能够编译,但还不会使其在测试中运行。为此,我们需要进行一些更改。首先,我们需要将#[test]
宏更改为#[wasm_bindgen_test]
。然后,我们需要向我们的test
模块添加两个语句,如下所示:
#[cfg(test)]
mod tests {
use super::*;
use futures::channel::mpsc::unbounded;
use std::collections::HashMap;
use web_sys::{AudioBuffer, AudioBufferOptions};
use wasm_bindgen_test::wasm_bindgen_test;
wasm_bindgen_test::wasm_bindgen_test_configure!
(run_in_browser);
#[wasm_bindgen_test]
fn test_transition_from_game_over_to_new_game() {
...
首先要添加的是use wasm_bindgen_test::wasm_bindgen_test
,以便宏存在。第二是wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
。这个指令告诉测试运行器在浏览器中运行,以便代码可以与 DOM 交互,类似于应用程序的方式。这个测试不会在cargo test
中运行,所以你需要使用wasm-pack test --headless –chrome
命令。这将使用 Chrome 浏览器的无头版本运行 Web 测试。当你运行它们时,你应该看到以下输出:
running 1 test
test rust_webpack_template::game::tests::test_transition_from_game_over_to_new_game … ok
现在,我们有一个正在运行且通过测试,但唯一的问题是我们没有任何断言。我们已经编写了一个“安排”步骤,但还没有检查结果。这个测试的目的是确保当状态转换发生时 UI 被隐藏,因此我们需要更新测试来检查这一点。我们可以通过添加动作和断言步骤来实现,如下所示:
#[wasm_bindgen_test]
fn test_transition_from_game_over_to_new_game() {
...
let document = browser::document().unwrap();
document
.body()
.unwrap()
.insert_adjacent_html("afterbegin", "<div
id='ui'></div>")
.unwrap();
browser::draw_ui("<p>This is the UI</p>").unwrap();
let state = WalkTheDogState {
_state: GameOver {
new_game_event: receiver,
},
walk: walk,
};
state.new_game();
let ui =
browser::find_html_element_by_id("ui").unwrap();
assert_eq!(ui.child_element_count(), 0);
}
在这里,我们通过将div
属性和ui
ID 插入到文档中来开始测试——毕竟,这是在游戏的index.html
中。然后,browser::draw_ui
将 UI 绘制到浏览器中,即使浏览器是无头运行的,所以我们看不到。我们继续在GameOver
状态中创建WalkTheDogState
;在下一行,我们通过state.new_game()
方法将其转换为Ready
状态。最后,我们通过查找div
属性并检查其child_element_count
来检查 UI 是否被清除。如果它是0
,代码就是正确的,这个测试将会通过。如果你运行这个测试,你会看到这个测试确实通过了,所以你可能想要注释掉let next_state: WalkTheDogState<Ready> = state.
这一行,然后再次运行以确保在转换发生时测试失败。
这仍然是一个非常长的测试,但至少它在工作。可以通过在各个模块中创建一些工厂方法来清理测试,这样就可以更容易地创建结构体。你会注意到测试中充满了unwrap
调用。这是因为,在测试中,我希望如果事情不是预期的,它们立即崩溃。不幸的是,基于浏览器的测试使用wasm_bindgen_test
宏并不允许你像标准 Rust 测试那样返回Result
以提高可读性。这是你应该尝试使你的测试以原生 Rust 测试方式运行的原因之一。
异步测试
测试 Web 应用程序的最大挑战之一,无论是 Wasm 还是传统的 JavaScript 应用程序,都是在async
块或函数中发生的代码。想象一下在一个async
测试中调用一个函数,然后立即尝试验证它是否工作。根据定义,你不能这样做,因为它是异步运行的,可能还没有完成。幸运的是,wasm_bindgen_test
通过使测试函数本身是async
的来非常容易地处理这个问题。
让我们看看一个更简单的例子,并尝试为browser
模块中的load_json
函数编写一个测试:
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
wasm_bindgen_test::wasm_bindgen_test_configure!
(run_in_browser);
#[wasm_bindgen_test]
async fn test_error_loading_json() {
let json = fetch_json("not_there.json").await;
assert_eq!(json.is_err(), true);
}
}
这可以在browser
模块中找到。在这里,我们首先使用样板代码来设置一个tests
模块,导入browser
和wasm_bindgen_test
,并配置测试在浏览器中运行。测试本身只有两行。尝试加载一个不存在的JSON
文件并报告错误。这个测试的关键区别在于它是async
的,这允许我们在测试中使用await
并在不添加任何“等待”逻辑的情况下编写断言。这很好,但有几件事情需要记住:
-
如果
fetch_json
可能会挂起,这个测试也会挂起。 -
这个测试将尝试加载一个文件。理想情况下,我们不想在单元测试中这样做。
这个测试将会运行并通过。我们可以用这种方式测试所有的browser
函数,接受browser
模块的测试将需要使用文件系统。如果我在专业环境中接手这个系统,我可能会这样做。你可以非常努力地模拟这些测试中的实际浏览器,但这样做会消除其防止缺陷的能力。毕竟,如果你从browser
模块中移除浏览器,你怎么知道代码是正确的?
如果我被分配了这段代码并要求维护它,我可能会采用以下策略:
-
咒骂那个没有编写测试就写代码的家伙的名字(就是我!)。
-
在需要更改代码时编写测试。如果它没有变化,就别麻烦了。继续使用浏览器自动化,就像我们之前做的那样。
-
随着时间的推移,将更多依赖于
wasm-bindgen
和web-sys
的代码移入browser
模块,以便engine
和game
可以模拟它。 -
尽可能多地编写尽可能多的 Rust 原生测试,然后尽可能地将基于浏览器的单元测试本地化。
关于集成测试,我怀疑我会在 Cargo 的意义上编写任何集成测试。对于 Cargo 库,所有的集成测试都是写在tests
目录中,并作为一个单独的包编译。当你编写一个将被其他人使用的库时,这是一个很好的主意,但我们正在编写一个应用程序,并没有提供 API。我会编写的集成测试是任何使用真实浏览器的测试,但这些测试在意义上是集成在浏览器中的,而不是作为 Rust 集成测试运行。
然而,我们不能仅仅依靠添加测试来确保我们的代码工作。有时,我们只是必须调试它。让我们深入探讨这一点。
游戏调试
要调试一个传统的程序,无论是 Java、C#还是 C++,我们必须设置断点并逐步执行代码。在 JavaScript 中,我们可以输入单词debugger
来设置断点,但尽管 WebAssembly 在浏览器中运行,但它不是 JavaScript。那么,我们如何调试它?
关于使用 WebAssembly 进行调试的信息很多,但如何调试 WebAssembly 呢?根据官方的 Rust WebAssembly 文档,很简单——你不能!
不幸的是,WebAssembly 的调试故事仍然不成熟。在大多数 Unix 系统中,DWARF 用于编码调试器需要提供源代码级别的程序检查所需的信息。在 Windows 上有一个替代格式,它以类似的方式编码了相似的信息。目前,WebAssembly 还没有等效的格式。因此,当前的调试器提供的功能有限,我们最终只能通过编译器输出的原始 WebAssembly 指令来逐步执行,而不是我们编写的 Rust 源代码。
– rustwasm.github.io/docs/book/reference/debugging.html
所以,这就是全部内容——没有调试,章节结束。这很简单。
但事情并非如此简单。当然,你可以调试你的应用程序——你只是不能使用浏览器开发者工具逐步执行调试器中的 Rust 代码。这项技术还没有准备好。但这并不意味着我们不进行调试;它只是意味着我们将采取更传统的调试方法。
之前,我提到当我编写代码时,我通常会编写很多测试。我也通常不太常用调试器。如果我们把代码分成小块,这些小块可以很容易地通过测试来执行,那么调试器就很少需要了。话虽如此,我们没有在这个项目中这样做,所以我们需要一种调试现有代码的方法。我们将从记录开始,然后获取堆栈跟踪,最后使用 linters 来防止在发生之前出现错误。
注意
事实并非像 Rust Wasm 网站所陈述的那样简单明了。在撰写本文时,Chrome 开发者工具已经添加了对wasm-bindgen
的支持。你可以在这里看到这个问题的进展:github.com/rustwasm/wasm-bindgen/issues/2389
。到你阅读这本书的时候,Rust Wasm 以及 Chrome 以外的浏览器的调试工具可能已经现代化了,但到目前为止,我们必须使用更传统的工具,如println!
和日志。
日志、错误与恐慌
如果你一直在跟随并在这个过程中感到困惑,那么你很可能使用了我们在第三章“创建游戏循环”中编写的log!
宏来查看发生了什么。如果你一直在这样做,恭喜你!你一直在用与我最初编写代码时相同的方式进行调试。打印行调试在许多语言中仍然是标准做法,并且几乎是唯一保证在任何地方都能工作的调试形式。如果你没有这样做,那么它看起来是这样的:
impl WalkTheDogStateMachine {
fn update(self, keystate: &KeyState) -> Self {
log!("Keystate is {:#?}", keystate);
match self {
WalkTheDogStateMachine::Ready(state) =>
state.update(keystate),
WalkTheDogStateMachine::Walking(state) =>
state.update(keystate),
WalkTheDogStateMachine::GameOver(state) =>
state.update(),
}
}
在前面的例子中,我们通过update
函数在每一刻记录KeyState
。这不是一个很好的日志,因为它会每秒显示 60 次空的KeyState
,但对于我们的目的来说已经足够好了。然而,这个日志有一个缺陷:KeyState
没有实现Debug
特质。你可以通过在KeyState
结构体上添加derive(Debug)
注解来添加它,如下所示:
#[derive(Debug)]
pub struct KeyState {
pressed_keys: HashMap<String, web_sys::KeyboardEvent>,
}
当您添加这个功能时,控制台将记录所有您的键状态变化,这在您的键盘输入损坏时将非常有用:
图 9.1 – 记录 KeyState
通常情况下,任何 pub struct
都应该 use
#[derive(Debug)]
,但这不是默认选项,因为它可能会使大型项目的编译时间变长。当不确定时,请继续使用 #[derive(Debug)]
并记录信息。现在,可能 log!
对您来说不够明显,您希望文本明亮、明显且为红色。为此,您需要使用 JavaScript 中的 console.error
并编写一个类似于 log
宏的宏,我们已经在 browser
模块中有了这个宏。这个宏看起来是这样的:
macro_rules! error {
( $( $t:tt )* ) => {
web_sys::console::error_1(&format!( $( $t )*
).into());
}
}
这与 log
宏相同,但使用 console
对象上的 error
函数。error
函数有两个优点。第一个是它是红色的,另一个优点是它还会显示堆栈跟踪。以下是一个在 Chrome 中玩家被击倒时调用 error
的示例:
图 9.2 – 错误日志
这不是世界上最易读的堆栈跟踪,但看过 console::error_1
函数的几行后,您可以看到这个日志是从 WalkTheDogState<Walking>::end_game
调用的。这个日志实际上是针对真正的错误,而不是仅仅的信息记录,并且这个堆栈跟踪可能不会在所有浏览器中清晰地显示。您还希望谨慎地保留这个日志在生产代码中,因为您可能不希望向好奇的玩家暴露这么多信息。我们将确保它不在生产部署中,这将在 第十章 持续部署 中创建。
最后,如果您想确保程序在发生错误时停止,我们将继续使用 panic!
宏。一些错误是可以恢复的,但许多不是,我们不希望我们的程序在损坏的状态下艰难前行。在 第一章 Hello WebAssembly 中,我们包含了 console-error-panic-hook
crate,以便如果程序崩溃,我们会得到一个堆栈跟踪。让我们将调用 error
! 替换为调用 panic
! 并看看区别:
图 9.3 – 潜意识日志
在这里,您可以看到它看起来略有不同,但信息基本上是相同的。在最顶部有一个地方写着 src/game.rs:829
,这告诉您 panic
被调用的确切位置。通常,如果您需要在生产代码中包含错误,您可能会更倾向于使用 panic
而不是 error
,因为这种错误应该是罕见的,并且应该快速失败。error
函数在调试期间更有用,所以您最终会移除那些。
我们有时会忽略另一种错误,那就是编译器和代码检查器给出的警告和错误。我们可以在运行程序之前使用 Rust 生态系统中的工具来检测错误。现在让我们来看看这个。
检查和 Clippy
Rust 编译器之所以出色,其中一个特点就是它内置了一个检查器,除了它已经提供的警告和错误之外。如果你不熟悉,检查器是一种静态代码分析工具,它通常会发现样式错误,以及编译器可能无法发现的潜在逻辑错误。这个术语来自衣服上的 lint,所以你可以把使用检查器想象成在你的代码上刷 lint 刷子。我们已经从编译器那里收到了一些警告,我们已经忽略了一段时间,其中大多数看起来像这样:
warning: unused `std::result::Result` that must be used
--> src/game.rs:241:9
|
241 | browser::hide_ui();
| ^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: this `Result` may be an `Err` variant, which should be handled
这些都是可能发生错误的情况,但我们可能不希望它发生时崩溃,所以恐慌或调用 unwrap
不是选项。传播 Result
类型是一个选项,但我不认为我们想在存在小的浏览器问题时阻止从一个状态移动到另一个状态。因此,我们将使用 error
情况在这里记录。你可以在 https://bit.ly/3q1936N
的示例源代码中看到它。让我们修改代码,以便记录任何错误:
impl WalkTheDogState<GameOver> {
...
fn new_game(self) -> WalkTheDogState<Ready> {
if let Err(err) = browser::hide_ui() {
error!("Error hiding the browser {:#?}", err);
}
WalkTheDogState {
_state: Ready,
walk: Walk::reset(self.walk),
}
}
}
在这里,我们将 browser::hide_ui()
行更改为 if let Err(err) = browser::hide_ui()
并在发生错误时进行记录。我们可以通过强制 hide_ui
在一段时间内返回错误来查看错误日志将是什么样子:
图 9.4 – 一个假错误
在书籍形式中,堆栈跟踪被截断,但你可以看到我们得到了一个错误日志,其中包含 Error hiding the browser
和 This is the error in the hide_ui function
,这是我强制放入 hide_ui
的错误消息。堆栈跟踪还显示了 game::Ready
,这会显示如果你有无限的空间来显示整个消息,你将正在过渡到 Ready
状态。
应该处理所有生成的警告。大多数警告都是同一种类型,即 Result
类型,其中 Err
变体被忽略。这些可以通过处理 Err
情况并记录或调用 panic
(如果游戏确实应该在此时崩溃)来删除。大部分情况下,我使用了 if let
模式,但如果 request_animation_frame
失败,我就使用 unwrap
。我不认为如果那样失败,游戏能工作。
我们还忽略了一个警告,但我们应该解决它,如下所示:
warning: associated function is never used: `draw_rect`
--> src/engine.rs:106:12
|
106 | pub fn draw_rect(&self, bounding_box: &Rect) {
| ^^^^^^^^^
|
= note: `#[warn(dead_code)]` on by default
这个警告有点独特,因为我们使用了这个函数来调试。你可能不想在你的游戏中绘制矩形,但正如我们在 第五章 碰撞检测 中所做的那样,调试碰撞框是至关重要的,所以我们会希望它可用。为了保留它,让我们用 allow
关键字来注释它,如下所示:
impl Renderer {
...
#[allow(dead_code)]
pub fn draw_rect(&self, bounding_box: &Rect) {
这应该会留下无编译错误的编译结果,但我们还可以使用一个额外的工具来查看我们的代码是否可以改进。如果你在 Rust 生态系统中度过了很多时间,那么你可能已经听说过 Cargo.toml
文件,但针对当前系统本身。安装很简单,你可能已经安装过并忘记了,但如果你还没有,只需要一个 shell 命令:
rustup component add clippy
一旦你安装了 Clippy,你可以运行 cargo clippy
并看到我们编写坏 Rust 代码的其他所有方式。
注意
当代码很棒时,我写了它,而你跟随着。当它很糟糕时,我们一起完成。我不会制定规则。
当我运行 cargo clippy
时,我得到 17
个警告,但你的数字可能不同,取决于你何时运行它。我不会逐个解释,但让我们突出一个错误:
warning: writing `&Vec<_>` instead of `&[_]` involves one more reference and cannot be used with non-Vec-based slices.
--> src/game.rs:945:29
|
945 | fn rightmost(obstacle_list: &Vec<Box<dyn Obstacle>>) -> i16 {
| ^^^^^^^^^^^^^^^^^^^^^^^ help: change this to: `&[Box<dyn Obstacle>]`
|
= note: `#[warn(clippy::ptr_arg)]` on by default
= help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#ptr_arg
game
模块中的 rightmost
函数可以被修改以使用更少的引用并变得更加灵活。这里的 help
非常好,因为它告诉我如何修复它。所以,让我们改变 rightmost
函数签名,使其看起来如下:
fn rightmost(obstacle_list: &[Box<dyn Obstacle>]) -> i16 {
这并不能修复任何错误,但它确实移除了一个 Clippy 警告,并使方法更加灵活。
Clippy 经常会通知你更好的惯用用法。我想突出一个 Clippy 警告,看起来像这样:
warning: match expression looks like `matches!` macro
--> src/game.rs:533:9
|
533 | / match self {
534 | | RedHatBoyStateMachine::KnockedOut(_) => true,
535 | | _ => false,
536 | | }
| |_________^ help: try this: `matches!(self, RedHatBoyStateMachine::KnockedOut(_))`
|
= help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#match_like_matches_macro
我在代码的早期版本中遇到了这个错误很多次。在运行 Clippy 之前,我不知道存在 matches!
宏,但它所做的正是处理你需要检查 enum
是否是特定情况的精确情况。这就是为什么代码现在使用 Clippy 建议的,即在 impl
RedHatBoyStateMachine
中:
impl RedHatBoyStateMachine {
...
fn knocked_out(&self) -> bool {
matches!(self, RedHatBoyStateMachine::KnockedOut(_))
}
小贴士
许多编辑器使它非常容易将 Clippy 作为语法检查的一部分启用,这样你就不需要显式地运行它。如果你能启用它,你应该这样做。
许多其他错误都是关于过度使用 clone
和在不必要的时候使用 into
。我强烈建议你通读代码并修复这些问题,花点时间理解为什么它们被标记出来。在 第十章 持续部署 中,我们将把 Clippy 添加到我们的构建过程中,这样我们就不必继续忍受这些错误。
到目前为止,代码已经经过(一点)测试,我们已经处理了我们能想到的所有编译错误和警告。可以说游戏是可行的,但它足够快吗?接下来要检查的是它的性能。所以,我们现在就来做这件事。
使用浏览器测量性能
调试性能的第一步是回答这个问题,你是否有性能问题? 太多的开发者,尤其是游戏开发者,过早地担心性能,并引入了复杂的代码来获得并不存在的性能提升。
例如,你知道为什么这么多代码使用 i16
和 f16
吗?好吧,当我几年前回到学校时,我在 C++中参加了一门游戏优化课程,我们的最终项目需要优化一个粒子系统。最大的性能提升是将 32 位整数转换为 16 位整数。正如我的教授所说,“我们在 16 位上登上了月球!”所以,当我编写这段代码时,我内化了这个教训,并将变量设置为 16 位,除非它们被发送到 JavaScript,在那里一切都是 32 位的。好吧,让我直接引用 WebAssembly 规范(可在webassembly.github.io/spec/core/syntax/types.html
找到):
数字类型用于分类数值。
i32 和 i64 类型分别用于分类 32 位和 64 位整数。整数本身不是有符号或无符号的;它们的解释由单个操作决定。
f32 和 f64 类型分别用于分类 32 位和 64 位的浮点数据。它们对应于由 IEEE 754-2019 标准(第 3.3 节)定义的相应二进制浮点表示,也称为单精度和双精度。
结果表明,WebAssembly 不支持 16 位数值,所以所有针对 i16
的优化都是无意义的。它没有造成任何伤害,也没有必要回去更改它,但它强化了优化的第一条规则:先测量。考虑到这一点,让我们调查两种不同的方法来衡量我们游戏的表现。
帧率计数器
我们的游戏表现不佳有两种方式:使用过多的内存和降低帧率。其中第二种对于像这样小型游戏来说更为重要,因此我们首先需要关注帧率。如果帧率持续落后,我们的游戏循环将尽可能处理它,但游戏看起来会抖动,响应也会不佳。所以,我们需要知道当前的帧率,而最好的方法是在屏幕上显示它。
我们将首先添加一个函数 draw_text
,该函数将在屏幕上绘制任意文本。这是调试文本,所以类似于 draw_rect
函数,我们需要禁用显示代码未使用的警告。写文本是 engine
模块中 Renderer
的一个功能,如下所示:
impl Renderer {
...
#[allow(dead_code)]
pub fn draw_text(&self, text: &str, location: &Point) -> Result<()> {
self.context.set_font("16pt serif");
self.context
.fill_text(text, location.x.into(),
location.y.into())
.map_err(|err| anyhow!("Error filling text
{:#?}", err))?;
Ok(())
}
}
我们在这里硬编码了字体,因为这只是用于调试目的,所以不值得定制。现在,我们需要将帧率计算器添加到游戏循环中,这个游戏循环位于engine
模块的GameLoop
的start
方法中。你可以通过回顾第三章,创建一个游戏循环来刷新你对它是如何工作的记忆。帧率可以通过取最后两个帧之间的差值,除以 1,000,将毫秒转换为秒,并计算其倒数(即 1 除以该数值)来计算。这很简单,但它会导致屏幕上的帧率波动得很厉害,并且不会显示非常有用的信息。我们可以做的是每秒更新一次帧率,这样我们就可以在屏幕上得到一个相当稳定的性能指标。
让我们将这段代码添加到engine
模块中。我们将从一个独立的函数开始,这个函数将在start
方法中每秒计算一次帧率,如下所示:
unsafe fn draw_frame_rate(renderer: &Renderer, frame_time: f64) {
static mut FRAMES_COUNTED: i32 = 0;
static mut TOTAL_FRAME_TIME: f64 = 0.0;
static mut FRAME_RATE: i32 = 0;
FRAMES_COUNTED += 1;
TOTAL_FRAME_TIME += frame_time;
if TOTAL_FRAME_TIME > 1000.0 {
FRAME_RATE = FRAMES_COUNTED;
TOTAL_FRAME_TIME = 0.0;
FRAMES_COUNTED = 0;
}
if let Err(err) = renderer.draw_text(
&format!("Frame Rate {}", FRAME_RATE),
&Point { x: 400, y: 100 },
) {
error!("Could not draw text {:#?}", err);
}
}
哦不——这是一个unsafe
函数!这是本书中的第一个,也可能是最后一个。我们在这里使用unsafe
函数是因为有static mut
变量——即FRAMES_COUNTED
、TOTAL_FRAME_TIME
和FRAME_RATE
——在多线程环境中并不安全。我们知道这个函数不会以多线程的方式被调用,我们也知道如果它被调用,它只会显示一个奇怪的帧率值。这并不是我一般推荐的做法,但在这个情况下,我们不想让GameLoop
或engine
模块被这些值污染,或者将它们放入线程安全的类型中。毕竟,我们不想因为一大堆Mutex
锁调用而让我们的帧率计算器运行得太慢。所以,我们将接受这个调试函数是unsafe
的,颤抖一会儿,然后继续。
函数首先设置初始的FRAMES_COUNTED
、TOTAL_FRAME_TIME
和FRAME_RATE
值。在每次调用draw_frame_rate
时,我们更新TOTAL_FRAME_TIME
和FRAMES_COUNTED
的数量。当TOTAL_FRAME_TIME
超过1000
时,这意味着已经过去了 1 秒,因为TOTAL_FRAME_TIME
是以毫秒为单位的。我们可以将FRAME_RATE
设置为FRAMES_COUNTED
的数量,因为那正是我们刚刚创建的draw_text
函数。这个函数将在每一帧上最后被调用,这是很重要的,因为如果不是这样,我们就会在帧率上直接绘制游戏。如果我们不在每一帧上绘制帧率,我们也不会看到它,除了在屏幕上短暂的闪烁,这几乎不适合调试。
现在,让我们在start
函数中添加对GameLoop
的调用,如下所示:
impl GameLoop {
pub async fn start(game: impl Game + 'static) -> Result<()> {
...
*g.borrow_mut() = Some(browser::create_raf_closure
(move |perf: f64| {
process_input(&mut keystate, &mut
keyevent_receiver);
let frame_time = perf - game_loop.last_frame;
game_loop.accumulated_delta += frame_time as
f32;
while game_loop.accumulated_delta > FRAME_SIZE {
game.update(&keystate);
game_loop.accumulated_delta -= FRAME_SIZE;
}
game_loop.last_frame = perf;
game.draw(&renderer);
if cfg!(debug_assertions) {
unsafe {
draw_frame_rate(&renderer, frame_time);
}
}
...
game_loop.accumlated_delta
这一行有轻微的变化,将帧长度的计算拉入一个临时变量frame_time
。然后,在绘制之后,我们通过检查if cfg!(debug_assertions)
来确定是否处于调试/开发模式。这将确保这不会出现在部署的代码中。如果我们处于调试模式,我们将在unsafe
块内调用draw_frame_rate
。我们发送该函数renderer
和frame_time
,我们刚刚将其拉入临时变量。添加此代码可以在屏幕上提供清晰的帧率测量:
图 9.5 – 显示帧率
在我的机器上,帧率稳定在60
,偶尔会有不稳定的短暂波动。这很好,除非你正在编写关于调试性能问题的章节。那么,你可能会有问题。
幸运的是,在早期草稿中,有一次帧率下降,那是在 RHB 撞到岩石的时候。当index.html
。换句话说,我们必须删除index.html
中高亮显示的代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My Rust + Webpack project!</title>
<link rel="stylesheet" href="styles.css" type="text/css"
media=
"screen">
<link rel="preload" as="image" href="Button.svg">
<link rel="preload" as="font" href=
"kenney_future_narrow-webfont.woff2">
</head>
如果你删除了预加载的资源,你应该会看到帧率短暂下降。显示帧率是确保你作为开发者立即看到性能问题的绝佳方式。如果帧率下降,那么你就遇到了问题,就像我们没有预加载资源时遇到的情况一样。有时,我们需要的不仅仅是帧率计数器。所以,让我们保留预加载代码被删除的状态,并在浏览器调试器中查看性能问题。
浏览器调试器
每个现代浏览器都有开发者工具,但我会在这个部分使用 Chrome,因为它是开发者中最受欢迎的。一般来说,它们看起来都很相似。为了获取性能信息,我必须启动游戏并在 Chrome 中打开开发者工具。然后,我必须右键单击并点击检查,尽管有其他很多打开工具的方法。从那里,我必须点击性能选项卡并开始录制。然后,我必须将 RHB 撞到岩石并停止录制。由于我知道我有一个特定的性能下降点,我想尽快到达那里,以隐藏调试器中其他代码的任何噪音。完成这些后,我会看到一个图表,就像这样:
图 9.6 – 性能选项卡
这有很多噪音,但你可以看到图表发生了变化。在帧行有一个粉红色的块,表明那里发生了某些事情。我可以使用我的光标选择看起来像山一样的部分,并将其拖动以放大。现在,我会看到以下屏幕:
图 9.7 – 丢失的帧
在这里,你可以看到一帧是115.8 毫秒。我打开了帧部分(看看帧旁边的灰色箭头是如何指向下方的),以查看这些帧上绘制了什么——我们可怜的击倒 RHB。115.8 毫秒的帧太长了,如果你将鼠标悬停在其上,它会显示丢失的帧。在帧部分下方,是主部分,它显示了应用程序正在做什么。我在这里突出显示了重新计算样式,根据工具提示窗口,它显示为33.55 毫秒,这个窗口在我将鼠标悬停在其上时出现。
index.html
文件,这应该会加快布局的重新计算。如果你这样做并重新测量性能,你会看到类似这样的:
![图 9.8 – 没有丢失帧!
图 9.8 – 没有丢失帧!
这值得担心吗?可能吧——看到按钮加载是明显的,但扩展这一章来修复它并不值得。你知道如何修复它,也知道如何在性能标签页中找到问题,而这正是现在重要的。无论如何,我们还有另一个问题要回答:这款游戏占用了多少内存?
检查内存
当我编写这个游戏时,我经常让它全天在后台运行,结果我的浏览器开始占用我电脑的所有内存,变得非常不响应。我开始怀疑这个游戏有内存泄漏,所以我开始调查。你可能认为由于 Rust 的保证,在 Rust 中不可能有内存泄漏,这确实更难,但记住,我们的大部分代码都与 JavaScript 通信,我们并不一定有相同的保证。幸运的是,我们可以使用我们用来测试性能的相同工具来检查这一点。
点击左上角的无符号来清除性能数据。然后,开始另一个记录并播放一段时间。这次,不要试图立即死亡;让游戏播放一会儿。然后,停止记录并再次查看性能数据,这次确保你点击内存按钮。现在,你可以查看结果,它可能看起来像这样:
![图 9.9 – 内存分析
图 9.9 – 内存分析
你能看到屏幕底部的蓝色波浪吗,它显示了右下角的堆?这表明我们的内存增长,然后定期回收。这可能不是我们想要的理想状态,但我们现在并不试图控制到那种程度。Chrome 和大多数浏览器都在单独的线程中运行它们的垃圾收集器,这样就不会像你想象的那么影响性能。进行实验并在应用程序中创建一个内存预算,并保持所有分配都在那个预算内,但这超出了本书的范围。幸运的是,内存被回收了,看起来游戏并没有无控制地增长。
经过进一步调查,发现我浏览器的问题是由我们公司的缺陷跟踪器引起的,它使用的内存比这个小游戏多得多!如果你遇到性能问题,请确保考虑其他标签页、浏览器扩展程序以及可能减慢你电脑速度的其他任何东西。
摘要
这一章与之前的不同,因为从许多方面来看,我们的游戏已经完成了!但当然,它并不完美,这就是为什么我们花了一些时间来研究我们可以如何调查缺陷并使代码库更加健壮。
我们深入研究了自动化测试,为我们的转换编写了单元测试,并编写了在浏览器中运行的集成测试。我们现在对任何未预见的错误和代码崩溃时的堆栈跟踪都有记录,这两者都是调试困难错误的必要诊断工具。然后,我们使用了代码检查器和 Clippy 来清理我们的代码,并移除编译器无法捕获的微妙问题。最后,我们调查了浏览器中的性能问题,发现我们没有遇到任何问题!
在下一章中,我们将把那些测试集成到 CI/CD 设置中,甚至将它们部署到生产环境中。我们在等什么?让我们把这个东西发布出去!
第十章:第十章:持续部署
传统的游戏发布方式是创建一个主副本的构建版本,并将其发送到制造工厂。这在游戏行业内外经常被称为黄金版,如果你正在制作一个将被发送到游戏机并在商店销售的 AAA 游戏,情况也是如此。这个过程既耗时又极其昂贵;幸运的是,我们不必这样做!《Walk the Dog》是一款基于网页的游戏,我们需要将其发布到网站上。由于我们是在部署到网络上,我们可以使用所有最佳的网络实践,包括持续部署,这意味着我们可以直接从源代码控制中部署任何我们想要的构建版本。
在本章中,我们将涵盖以下主题:
-
创建持续集成/持续交付(CI/CD)管道
-
部署测试和生产构建
当本章完成时,你将能够将你的游戏发布到网络上!否则你怎么能变得富有和出名呢?
技术要求
除了 GitHub 账户外,你还需要一个 Netlify 账户。这两个账户都有显著的免费层,所以如果成本成为问题,那么恭喜!你的游戏起飞了!你还需要熟悉 Git。你不需要成为专家,但你需要能够创建仓库并将它们推送到 GitHub。如果你对 Git 一无所知,那么 GitHub 的入门指南是一个不错的起点:docs.github.com/en/get-started
。本章的示例代码可在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_10
找到。
查看以下视频,看看代码的实际应用:bit.ly/3DsfDsA
创建一个 CI/CD 管道
当你在本地运行npm run build
时,发布构建版本会被放入dist
目录中。理论上,你可以将那个目录复制到某个服务器上以部署你的应用程序。只要服务器知道wasm
MIME
类型,这就会起作用,但手动复制到目录是一种非常过时的软件部署方式。如今,我们在服务器上自动化构建和部署,包括已经提交到源代码控制中的额外代码。这比传统方式复杂得多,那么为什么它更好呢?
以这种方式自动化构建的实践通常被称为 CD,其定义相当广泛。请看以下来自continuousdelivery.com
的引用:
持续交付是将所有类型的更改(包括新功能、配置更改、错误修复和实验)安全、快速且可持续地部署到生产环境或用户手中的能力。
你可能会读到这里并认为,从你的机器的 dist
目录复制到服务器上确实是那样,但事实并非如此。在手动部署时可能会出现一些问题。我们在这里列出了一些:
-
文档可能错误或缺失,意味着只有一个人知道如何部署。
-
部署的代码可能与源控制中的代码不同。
-
部署可能仅基于本地配置,例如个人机器上存在的
rustc
版本。
有许多更多原因说明为什么你不应该在本地简单地运行 npm run build
然后复制粘贴到服务器上。但当一个团队规模较小时,说“我稍后再处理”是非常诱人的。而不是听从那个小声音,让我们尝试思考部署的哪些特性是安全且快速的,正如定义所说。我们可以从一些前面的要点相反开始。如果这些是手动部署不满足 CD 的原因,那么一个符合资格的过程将能够做到以下几点:
-
自动化流程,以便团队中的每个人都可以重复执行。
-
总是从源控制中部署。
-
在源控制中声明配置,以确保它永远不会错误。
正确的 CD 流程远不止前面列表中的内容。事实上,“完美”的 CD 往往更像是一个要达到的目标,而不是一个你达到的最终状态。由于我们是一个人的乐队,我们不会从 持续交付 书籍(amzn.to/32bf9bt
)中的每一个要点开始,但我们会创建一个 main
分支,并将其部署到生产站点。为此,我们将使用两种技术:GitHub Actions 和 Netlify。
注意
CI 指的是频繁地将代码合并到主分支(在 Git 术语中为 main
)并运行所有测试,以确保代码仍然工作。CI/CD 是将集成和交付实践结合起来的缩写,尽管它有点冗余,因为 CD 已经包含了 CI。
GitHub Actions 是来自 GitHub 的相对较新的技术。它在将分支推送到 GitHub 时用于运行任务。它非常适合运行 CI/CD,因为它直接构建在我们已经使用的源控制系统中,并且有一个相当不错的免费层。如果你决定使用不同的工具,例如 Travis CI 或 GitLab CI/CD,你可以使用这个实现来指导你如何使用那些其他工具。到目前为止,相似之处多于不同之处。
在 GitHub Actions 上运行 CI 后,我们将部署到 Netlify。你可能想知道,如果我们声称的目标是减少新技术数量,为什么我们还要使用 Netlify,那是因为,虽然我们可以直接部署到 GitHub Pages,但那不会支持创建测试构建。在我看来,良好的 CD 流程的一个重要部分是能够创建可以实验和尝试的生产类似构建。Netlify 将提供这种功能。如果你的团队已经超过一个人,你将能够在代码审查 PR 的过程中尝试游戏。此外,Netlify 默认支持 Wasm,这很方便。
注意
在 GitHub 的术语中,PR 是你希望合并到 main
分支的分支。你创建一个 PR 并请求审查。这个分支在允许合并到 main
分支之前可以运行其他检查。其他工具,如 GitLab,将这些称为 合并请求(MRs)。我倾向于坚持使用 PR 这个术语,因为我已经习惯了。
我们的流水线将会相当简单。在每次推送到 PR 分支时,我们将检出代码,构建并运行测试,然后将代码推送到 Netlify。如果构建是分支构建,你将获得一个临时 URL 来测试该构建。如果推送的是 main
,那么它将部署一个发布构建。在未来,你可能希望在生产部署方面有更多的严谨性,例如用发布说明标记发布,但这应该足以让我们开始。
第一步是确保构建机器使用与我们本地相同的 Rust 版本。rustup
工具允许你安装多个 Rust 编译器的多个版本以及多个工具链,你需要确保团队中的每个人都以及 CI 都使用相同的 Rust 版本。幸运的是,rustup
提供了多种实现方式。我们将使用 toolchain
文件,这是一个指定当前项目工具链的文件。除了确保任何构建此软件包的机器都将使用相同的 Rust 版本外,它还记录了用于开发的 Rust 版本。每个 Rust 项目都应该有一个这样的文件。
注意
在撰写本章时,我发现我在第一稿的 第一章 中犯了一个错误,Hello WebAssembly。我没有记录正在使用的 Rust 版本,也没有确保安装了 wasm32-unknown-unknown
工具链。这些正是当你尝试设置 CI 构建时出现的错误类型,因为你已经忘记了所有那些早期假设,这也是为什么拥有 CI 构建很重要的原因之一。遗憾的是,你总是会忘记文档,但构建机器不会说谎。这就是为什么我经常在项目开始时设置 CI 的原因。
toolchain
文件命名为 rust-toolchain.toml
,并保存在软件包的根目录中。我们可以创建一个如下所示的文件:
[toolchain]
channel = "1.57.0"
targets = ["wasm32-unknown-unknown"]
上述工具链说明我们将使用 Rust 编译器的1.57.0
版本和wasm32-unknown-unknown
目标,这样我们就可以确保能够编译成 WebAssembly。现在我们已经确保了使用的 Rust 版本,我们就可以开始在 GitHub Actions 中设置 CI/CD 流水线。你可以尝试使用新版本,但这里使用的是经过测试的1.57.0
。
GitHub Actions
就像许多其他 CI/CD 工具一样,GitHub Actions 是由你的源仓库中的配置文件定义的。当你创建第一个配置文件,在 Actions 中称为工作流程时,GitHub 会将其识别,然后启动一个运行器。你可以在 GitHub 仓库的操作标签页中查看输出。以下截图显示了我在编写本章时该标签页的样子:
图 10.1 – 绿色构建
这是一个在 GitHub 上运行的示例工作流程,其中我已更新部署版本以使用 Node.js 的长周期版本。你必须去操作标签页查看工作流程的结果,这有点遗憾。我想营销赢了。听到工作流程和流水线这样的术语也有些令人困惑。工作流程是 GitHub Actions 的一个特定术语,指的是通过我们接下来要构建的配置在其基础设施上运行的一系列步骤。流水线是 CD 术语,指的是部署软件所需的一系列步骤。所以,如果我在 GitHub Actions 上运行并使用他们的术语,我可以有一个由一个或多个工作流程组成的流水线。这个流水线将由一个工作流程组成,所以你可以互换使用它们。
要开始构建我们的流水线,我们需要确保我们有一个用于“遛狗”的 GitHub 仓库。你可能已经有了,如果没有,你有两个选择:
-
从现有的代码创建一个新的仓库。
-
分叉示例代码。
你可以选择其中之一,但如果你一直写的代码在某处仓库中不存在,那就太遗憾了。如果你决定从我的仓库中分叉,那么请确保你从第九章**,测试、调试和性能的示例代码github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_9
进行分叉。否则,所有的工作都将为你完成。在任何情况下,从现在开始,我将假设你的代码已经在 GitHub 上的某个仓库中。
提示
如果在任何时候你感到困惑,你可以查阅 GitHub Actions 文档docs.github.com/en/actions/learn-github-actions/understanding-github-actions
。我们将尽量保持工作流程简单,所以你不需要成为 Actions 专家。
我们可以用一种 GitHub Actions 的“Hello World”来开始设置工作流程。这个工作流程将简单地检查代码,在推送后几乎立即变绿。创建一个名为 .github/workflows/build.yml
的文件,并向其中添加以下 YAML:
on: [push]
name: build
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
YAML(另一种标记语言)是许多 CI/CD 管道的标记语言。如果你以前从未见过它,请注意它对空白字符敏感。这意味着,有时如果你从文件复制粘贴到另一个文件,或者从一本书复制到代码中,它可能不是语法正确的。在这里,我每个制表符使用两个空格,这是标准格式,YAML 不允许使用制表符。
小贴士
YAML 主要是可以自我解释的,而且它也不是本章的重要收获。所以,如果你对某些 YAML 语法感到困惑,可能不值得担心。但以防万一,有一个相当不错的 YAML 技巧表可以在 quickref.me/yaml
找到。
在大多数情况下,你可以将 YAML 读取为键值对的列表。这个工作流程从 on
键开始,它将在每次 push
事件上运行这个工作流程。它是一个数组,所以你可以为多个事件设置工作流程,但我们不会这么做。下一个键 name
给工作流程一个名字。然后,我们添加 jobs
键,它将只有一个工作。在我们的例子中,它是 build
。我们指定我们的工作在 ubuntu-latest
上运行,使用 runs-on
键。然后,最后,我们定义它的步骤列表。这个工作目前只有一个步骤,uses: actions/checkout@v2
,这值得深入解释。每个步骤可以是 shell 命令,或者——你猜对了——一个 动作。你可以创建自己的动作,但大多数动作都是由 GitHub 社区创建的;它们可以在 GitHub Actions 市场找到。
你可能能够猜到 actions/checkout@v2
检查代码,而且你是对的。但你可能想知道它是从哪里来的,以及你应该如何知道它。这就是 Actions 市场发挥作用的地方,可以在 github.com/marketplace?type=actions
找到:
图 10.2 – Actions 市场 place
你的工作流程由一系列按顺序运行的步骤组成,其中大部分可以在 GitHub 市场找到。不要让“市场”这个名字欺骗了你;动作不需要花钱。它们是开源项目,免费如啤酒。让我们深入了解我们将要使用的第一个动作 (github.com/marketplace/actions/checkout
):
图 10.3 – Checkout
检出操作几乎可以在每个工作流程中找到,因为它在没有首先检出代码的情况下很难做任何事情。如果你浏览这个页面,你会看到关于该操作的完整功能文档,以及一个写着使用最新版本的大绿色按钮。如果你点击那个按钮,会呈现一个小片段,展示如何将操作集成到你的工作流程中:
![图 10.4 – 复制并粘贴我!
图 10.4 – 复制并粘贴我!
这些操作是工作流程的构建块。在 GitHub Actions 中设置 CI/CD 管道意味着在市场上搜索,将操作添加到你的工作流程中,并阅读文档。这比过去我使用的 Bash 脚本混乱要容易得多,尽管不必担心,你仍然可以调用可靠的 Bash 脚本。
注意
我想强调,这并不是要表明 GitHub Actions 优于其他任何 CI/CD 解决方案。如今,有这么多优秀的工具可以用于这项工作,很难推荐一个工具而不是另一个。多年来,我相当多地使用了 Travis CI 和 GitLab CI/CD,它们也非常出色。话虽如此,GitHub Actions 也非常出色。
如果你提交这个更改并将其推送到你的仓库内的一个分支(现在不要使用main
),你可以检查操作选项卡以查看工作流程成功运行,如下面的截图所示:
图 10.5 – 检出代码
我们已经检出了代码,现在我们需要在 GitHub runner上构建它。runner 只是一个机器的别称。要在本地机器上构建 Rust,你需要安装rustup
程序,并带有已安装的编译器和工具链。我们可以运行一系列 shell 脚本;然而,我们将查看市场上是否有任何 Rust 操作。我不会让你悬而未决——在actions-rs.github.io/
可以找到一个完整的 Rust 相关操作库。这是一个很棒的集合,这将使我们的构建更容易。我们将添加以下步骤:
-
安装工具链 (
actions-rs.github.io/#toolchain
)。 -
安装 wasm-pack (
actions-rs.github.io/#install
)。 -
运行 Clippy (
actions-rs.github.io/#clippy-check
)。
上述链接将带你去每个操作的官方文档,所有这些文档都是由 Nikita Kuznetsov (svartalf.info/
)创建和维护的。由于每个操作都在 YAML 中指定,它可以使用它喜欢的任何键。潜在地,这意味着有很多标志和配置需要记录,但我们将坚持使用简单的标志。
那么,我们还在等什么呢?让我们添加安装工具链所需的步骤,如下所示:
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.57.0
target: wasm32-unknown-unknown
override: true
components: clippy
我在示例中保留了 checkout 步骤以供参考,但我们添加的代码以 - uses: actions-rs/toolchain@v1
开始。-
字符很重要——这是 YAML 语法中序列条目的表示。所以,步骤 1 是第一行 - uses: actions/checkout@v2
。步骤 2 以 uses: actions-rs/toolchain@v1
开始,这是我们使用的动作的名称。请注意,下一行 with:
前面没有减号。这是因为它是同一步骤的一部分,这是一个具有 uses:
和 hash:
键的 YAML 哈希。这些字段必须对齐,因为 YAML 对空白字符敏感。如果你对 YAML 仍然感到困惑,我建议你不要想得太多;它实际上只是一个简单的纯文本标记格式,它以它看起来的方式工作。
依次,with
键被设置为另一个包含 toolchain
、target
、override
和 components
键的映射。它们设置了 toolchain
(1.57.0)和 target
(wasm32-unknown-unknown)的值,并确保安装了 clippy
组件。最后,override: true
标志确保这个版本的 Rust 是在这个目录中的版本。
通过这个步骤,你已经添加了所需的工具链。然而,如果你在工作流程中尝试运行构建,它仍然会失败,因为你还没有在构建机器上安装 wasm-pack
。你可以按以下方式添加这个步骤:
- uses: actions-rs/install@v0.1
with:
crate: wasm-pack
version: 0.9.1
use-tool-cache: true
你可能已经开始看到模式了。一个新的步骤以 -
字符开始,并 使用
一个动作。在这种情况下,它是 actions-rs/install@v0.1
。它的参数是 wasm-pack
包,版本 0.9.1。然而,我们还指定了重要的 use-tool-cache
,这将确保如果该版本的 wasm-pack
可以使用预构建的二进制文件,它将这样做。这可以节省你几分钟的构建时间,所以尽可能使用它。
因此,我们准备构建 WebAssembly,但在我们开始担心构建 Wasm 之前,还有一件事要做,那就是运行 Clippy。当我们它在 第九章 中运行时,测试、调试和性能,我们手动运行了一次,但将这种代码检查集成到构建中以便及早捕获这类错误是很重要的。通常,我甚至在我的独立项目中也会安装这种检查,因为我忘记在本地运行它。我们可以像这样添加这个步骤:
- name: Annotate commit with clippy warnings
uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
在这个例子中,我保留了 name
字段,它直接来自 actions-rs.github.io/#clippy-check
文档。这是因为当它运行时,这个名字将出现在 GitHub Actions UI 上,我可能会忘记 clippy-check
是什么。它需要的唯一参数是 token
字段,设置为魔法 ${{ secrets.GITHUB_TOKEN }}
字段。该字段将展开为你的实际 GitHub API 令牌,这是 GitHub Actions 在每次工作流程运行时自动生成的。这个令牌是必要的,因为此操作实际上可以注释提交中 Clippy 生成的任何警告,因此它需要能够写入存储库。以下截图显示了这样一个例子,我故意引入了一个 Clippy 错误:
图 10.6 – GitHub Actions 中的 Clippy 错误
这个错误也出现在提交本身中:
图 10.7 – 提交中的 Clippy 错误
这个功能很棒,但除非你正在写一本书,否则不要引入 Clippy 错误来炫耀;否则,这并不安全。现在我们已经检查了 Rust 代码中的惯用错误,是时候构建和运行测试了。由于这是一个 Wasm 项目,为此步骤,我们需要 Node.js。
Node.js 和 webpack
actions-rs
家族的操作是为 Rust 代码设计的,因此在 actions
的末尾添加了 -rs
。因此,我们需要在其他地方查找来安装 Node.js。幸运的是,安装 Node.js 是如此普遍,以至于它是 GitHub 提供的默认操作之一。我们可以添加另一个步骤来设置 Node.js,如下所示:
- uses: actions/setup-node@v2
with:
node-version: '16.13.0'
GitHub 提供的任何操作都可以在 actions
存储库中找到,这个操作叫做 setup-node
。在这种情况下,我们只需要一个参数,node-version
,我已经将其设置为 setup-node
步骤。它们看起来如下:
- run: npm install
- run: npm test
- run: npm run build
注意,这些步骤中没有一个有 uses
键——它们只是调用 run
,这会按照在 shell 中编写的命令运行。由于已经安装了 Node.js,你可以安全地假设 npm
也是可用的,并在你的工作流程中作为三个额外的步骤安装、测试和运行构建。这是一个提交你的工作流程并尝试它的好时机。
小贴士
在提交和推送代码之前,运行它通过一个 YAML 语法验证器可能会有所帮助。这不能保证它在 GitHub Actions 中有效,但至少可以确保它是有效的 YAML 语法,并防止在缩进中推送简单的错误而浪费时间。onlineyamltools.com/validate-yaml
是一个简单的在线示例,Visual Studio Code 在 marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml
提供了一个插件。
这个构建可能会在-run: npm test
时失败,以下错误被突出显示:
Error: Must specify at least one of `--node`, `--chrome`, `--firefox`, or `--safari`
在第九章,“测试、调试和性能”中,我们使用wasm-pack test --headless --chrome
命令运行了基于浏览器的测试。构建脚本运行npm test
,这对应于在第一章,“Hello WebAssembly”中为我们创建的package.json
文件中的测试脚本。如果这个文件名听起来不熟悉,那是因为我们还没有花时间在上面。打开它,你会看到测试条目,它应该看起来像这样:
{
...
"scripts": {
"build": "rimraf dist pkg && webpack",
"start": "rimraf dist pkg && webpack-dev-server --open
-d --host 0.0.0.0",
"test": "cargo test && wasm-pack test --headless"
},
...
}
在前面高亮的代码中,你可以看到它运行了cargo test
然后是wasm-pack test --headless
,但没有指定浏览器。这就是我们的构建错误!你可以通过将--chrome
添加到传递给wasm-pack test
的参数列表中,并将其推送到 GitHub 来修复它。
注意
有可能这个代码在项目骨架的新版本中已经被修复,所以你没有看到这个错误。如果是这样,你已经完成了——恭喜!了解npm test
背后正在运行的任务仍然是有用的。
到目前为止,你应该有一个大约需要 4 分钟的构建,这比我想的小项目要长一点,但我们把优化构建留给 DevOps 团队。你已经完成了本节 CI 步骤,现在可以继续 CD 部分。
部署测试和生产构建
对于部署,我们将使用 Netlify,这是一家专注于main
的云计算公司,它将执行生产构建。在这里,生产被定义得比较宽松,因为我们不会深入讨论诸如为您的应用获取自定义域名或监控错误等任务,但这是将公开可用的应用版本。
为了从 GitHub 部署到 Netlify,我们不得不做一些配置,以便 GitHub 可以访问您的 Netlify 账户,并且我们有一个可以推送的网站。因此,我们将使用 Netlify CLI 来设置一个网站并为其 GitHub 推送做准备。我们不会使用 Netlify 提供的内置 Netlify-GitHub 连接,因为它除非你是管理员,否则不适用于存储库。在这种情况下,如果你使用其他 Git 提供商,这也更适用,因为 Netlify CLI 可以与它们中的任何一个一起工作。
注意
有一个论点可以提出,我们在这里没有练习 CD,因为我们不会在我们的机器上完全配置像 Ansible 或 Terraform 这样的工具。Netlify 配置是不可丢弃的,所以它不是 CD 或 DevOps。这是真的,但这本书不是关于如何在代码中配置 Netlify 的,所以我们不会在这里关心这个问题。我们不得不在某处划一条线。
第一步是安装 CLI 本身,这可以通过在根目录下运行npm install netlify-cli --save
来完成。这将本地安装netlify-cli
,位于此项目的node_modules
目录中,因此它不会污染你的本地环境。--save
标志会自动将netlify-cli
添加到package.json
中依赖项列表。
小贴士
如果你运行 Netlify CLI 时遇到问题,请确保你使用的是 Node.js 的16.13.0
版本或更高版本。早期版本存在一些问题。
安装 Netlify CLI 后,你需要调用npm exec netlify login
来登录你的 Netlify 账户。在撰写本文时,npm exec
是确保你使用本地netlify
命令的方式,但你也可以使用npx
或直接调用node_modules\.bin
中的副本。这可能会在未来再次改变,所以最好在 Google 上搜索一下。重要的是,除非你知道自己在做什么,否则你可能不想安装全局版本的netlify
命令。
当你调用npm exec netlify login
时,它将通过网页浏览器引导你完成登录过程。然后,你将想要调用npm exec netlify init -- --manual
。中间添加--
很重要,这样--manual
就会传递给netlify
命令,而不是npm exec
。你将想要选择rust-games-webassembly
。你的构建命令是npm run build
,要部署的目录是dist
。你可以接受默认设置,直到说明说给这个 Netlify SSH 公钥访问你的仓库。然后,你将想要复制提供的密钥并将其添加到 GitHub 下的你的仓库的设置 | 部署密钥页面,如下面的截图所示:
图 10.8 – 部署密钥
你可以接受默认设置,但不要配置提供的webhook
设置。虽然你可以这样做,但我想要确保只有当构建通过时才推送测试构建,所以我们将这添加到 GitHub Actions 中。这也将更多的行为保留在源代码控制中。这是因为我们将在工作流程步骤中明确地推送到 Netlify,而通过 GitHub GUI 进行配置意味着可能会有更多我们可能会忘记的设置。
当命令完成时,你应该会看到一个显示成功!Netlify CI/CD 已配置的消息。它会告诉你,当你向这些分支推送时,它们将自动部署。由于我们没有设置 webhook,这是不正确的,还有更多的事情要做。
注意
当然,自本书出版以来,CLI 可能已经更改了其界面。重要的是你想要在 Netlify 中创建网站,并且不想要设置 webhook,因为我们将使用 GitHub Actions。如果选项已更改,你可以查看官方 Netlify 文档,网址为 docs.netlify.com/cli/get-started/
。
要将部署到 Netlify 的步骤添加到工作流程中,我们需要在工作流程中添加一个步骤。该步骤如下:
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@v1.2
with:
publish-dir: './dist'
production-branch: main
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-message: "Deploy from GitHub Actions"
enable-pull-request-comment: true
enable-commit-comment: true
overwrites-pull-request-comment: true
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
timeout-minutes: 1
我们使用的是 nwtgck/actions-netlify@v1.2
动作,因为它有一个酷炫的功能,就是可以在执行部署的提交上评论。还有其他使用 Netlify 的动作,如果你愿意,你可以在安装 CLI 后使用 runs
命令。有很多选项,所有这些都应该被视为设置此工作流程的一种示例,而不是实际设置它的方式。
前几个标志有些是自我解释的。构建目录是 dist
,所以这就是我们将要发布的。生产分支是 main
,我们还需要再次使用 github-token
,以便操作可以注释提交。接下来的三个标志将启用一个 PR 注释,告诉你应用部署到了哪里。将相同的注释放在评论上,然后如果你部署同一个分支多次,覆盖 pull-request-comment
。我们已经将这些全部设置为 true。
这两个 env
字段可能是最令人困惑的,因为它们指定了一个你还没有的 NETLIFY_AUTH_TOKEN
令牌和 NETLIFY_SITE_ID
网站 ID。网站 ID 是两者中更容易找到的,你可以通过图形用户界面或命令行界面来获取它。要从命令行界面获取它,请在命令提示符中运行 npm exec netlify status
。你应该会得到一个看起来像这样的输出:
$ npm exec netlify status
──────────────────────┐
Current Netlify User │
──────────────────────┘
Name: You
Email: you@gmail.com
Teams:
Your team: Collaborator
────────────────────┐
Netlify Site Info │
────────────────────┘
Current site: site-name
Admin URL: https://app.netlify.com/sites/site-name
Site URL: https://site-name.netlify.app
Site Id: SITE_ID
最后一行显示了你的 NETLIFY_SITE_ID
网站 ID。然后你可以将那个网站 ID 添加到 GitHub 仓库的 Secrets
部分中,它位于 NETLIFY_SITE_ID
:
图 10.9 – 在 GitHub 中设置网站 ID
此外,你还需要一个个人访问令牌来访问部署。在 Netlify UI 中找到它有点棘手,但它确实在用户设置下,你可以通过点击屏幕右上角的用户图标来找到它:
图 10.10 – 用户设置
然后,选择应用程序,而不是安全,你将看到个人访问令牌部分,如下面的截图所示:
图 10.11 – 个人访问令牌
你可以看到 Netlify Deploy
或类似的内容。复制该令牌并将其添加到 GitHub 的秘密中,这次命名为 NETLIFY_AUTH_TOKEN
:
图 10.12 – 显示两个密钥
一旦你添加了这两个键,你就可以将更改提交到工作流程,将它们推上去,你将收到来自 GitHub Actions 机器人的电子邮件,告诉你你的应用程序已被部署到一个测试 URL。它也被注释到了提交中,你可以在下面的屏幕截图中看到:
![图 10.13 – 部署以测试
图 10.13 – 部署以测试
或者,你可以访问样本仓库,在那里你可以看到评论在 bit.ly/3DR1dS5
。提交信息中的部署链接将不再工作,因为它是一个测试 URL,但它曾经是有效的。这让我们还有另一件事要测试。到目前为止,我们一直在向一个分支推送——至少,如果你注意到了,你应该会这样——但如果我们将部署到 main
分支,我们将得到一个生产部署。你可以以任何你喜欢的方式将你的代码推送到 main
,本地合并,然后推送或创建一个 PR。无论如何,你只需要将一个分支推送到 main
,你应该会得到一个生产部署。
我知道我做到了——你可以在 rust-games-webassembly.netlify.app/
上玩“Walk the Dog”。我们发布了!
摘要
我提到我们发布了吗?在本章中,我们为“Walk the Dog”游戏构建了一个小型但功能齐全的 CI/CD 管道。我们学习了如何创建 GitHub Actions 工作流程,并参观了如何在市场上找到操作。此外,我们开始在 Netlify 上创建测试和生产部署。我们甚至在完成后会收到电子邮件!你可以扩展这个流程来做诸如仅在 PR 上进行测试构建或添加集成测试之类的事情,你可以将其用作其他系统上不同 CI/CD 管道的模型。本章虽然简短,但至关重要,因为游戏实际上必须发布。
当然,虽然游戏可能已经发布,但它永远不会完成。在下一章中,我们将讨论一些你可以承担的挑战,以使你的“Walk the Dog”版本优于书本版本。我迫不及待地想看看你会做什么!
第十一章:第十一章:更多资源和下一步是什么?
如果你已经通读了这本书的每一部分,阅读并编写了代码,那真是太棒了!我相信没有更好的学习方法,现在你有一个可以运行的游戏。此外,你可能花了大量时间在犯错时调试,在想要娱乐时调整,对那些没有解释得很好的陌生部分感到困惑。然而,你可能还在想你是否真的学到了什么,或者你只是复制/粘贴了我所写的内容而没有理解。不用担心——这是正常的,这就是为什么我们要进行一点回顾。
在本章中,我们将涵盖以下内容:
-
一个具有挑战性的回顾
-
更多资源
本章完成后,你将验证你所学的知识,我希望能在网上看到你的游戏!
技术要求
本章包含少量代码,可在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_11
找到。
游戏的最终版本也可在github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly
找到,游戏的部署生产版本在rust-games-webassembly.netlify.app/
。
要完成这个挑战,你需要github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/wiki/Assets
中资产的最新版本。
查看以下视频,了解代码的实际应用:bit.ly/3JVabRg
一个具有挑战性的回顾
在书中审查代码是一个奇怪的概念;毕竟,你可以直接翻到前面的章节来回顾你学到的知识,那么现在为什么要重复呢?同时,我教过很多课程,如果有一件事是一致的,那就是有时候聪明的学生静静地坐着,倾听,点头,然后离开教室,对你说的话一无所知。唯一获得理解的方法是将我们迄今为止所练习的知识应用于构建。幸运的是,我们正好有这个工具。
狗发生了什么事?
在第二章,绘制精灵中,我们进行了一次快速的游戏设计会议,描述了我们的小红帽男孩(RHB)是如何追逐他的狗,而那只狗被一只猫吓到了。然而,在接下来的九个章节中,狗的身影却从未出现。简单来说,添加狗和猫所需的知识点并不多,而且如果添加它们可能会显得多余。添加它们将是一个很好的方式来巩固你所学的知识,也许还能在学习过程中学到一些新技巧。为了添加狗,需要以下几个步骤,这里故意以高层次概述:
- 将狗精灵图集放入游戏:你需要将精灵图集放入游戏,这个图集位于
sprite_sheets
文件夹中的assets
目录,名为dog_sheet
。这是狗在奔跑动画中的样子,准备好被放置到游戏中。查看第二章,绘制精灵,以提醒自己这是如何工作的。
添加狗struct
:游戏中需要有一个狗struct
作为众多游戏对象之一。它看起来会与RedHatBoy
对象相似,正如你可能猜到的,这意味着你可能需要使用状态机,正如我们在第四章,使用状态机管理动画中提到的。你会用状态机做什么?确保狗在游戏开始时向右走,当 RHB 发生碰撞时,它会转身跑回 RHB。你需要为向右跑和向左跑设置状态。狗也应该在开始时保持静止,确保在 RHB 追逐之后的一段时间内才开始奔跑。
- 扩展
WalkTheDogStateMachine
:为了让狗保持静止,以及让 RHP 忽略用户指令,你需要将WalkTheDogStateMachine
扩展到Ready
状态之外。我们已经在第八章,添加用户界面中涵盖了所有这些内容。
当然,这是一种添加狗的简单方法,但作为一个视频游戏,你的想象力是唯一的限制。可能最简单的事情就是让狗跑出屏幕,然后在 RHB 倒下后跑回来。你也可以让狗保持在屏幕上,并像玩家尝试的那样安全地导航平台。这将意味着需要更多的更改。
-
向无尽跑酷游戏添加提示:在第六章,创建无尽跑酷中,我们根据玩家的位置和随机值创建了游戏的部分。每个部分也可以为狗添加“提示”,这样狗就知道何时跳跃以绕过各种障碍。
-
确保狗会吠叫:作为一个狗的主人,我知道它们的一个特点——它们不是安静的。我们的狗应该发出声音,比如吠叫,使用我们在第七章中提到的相同技术,声音效果和音乐。你还可以添加一些跑步声音效果,以及当用户未能通过平台或撞到岩石时的碰撞声。
-
记分:这个游戏实际上并没有记分,但它可以。它使用基于时间的模型,玩家存活时间越长,得分就越高,每次玩家在平台上完成跳跃或滑行穿过箱子时,都会增加奖励。有大量的选择。你将在我们最初在第三章中实现的
Game
对象中保持这个分数,创建游戏循环,并使用我们在第八章中使用的相同技术显示它,添加用户界面。 -
使用滑行:除了我们迄今为止使用的那些小岛和岩石之外,瓦片精灵图集还有更多的图形。我们还有一个滑行动画,但我们没有足够短的东西可以滑行。使用第六章中提到的技术,创建无限跑酷游戏,设置一个玩家可以滑行的段落。
这是个陈词滥调,但限制真的是你的想象力。多年前,我教了一个 HTML5 游戏开发的研讨会,我给学生提供了一个Asteroids克隆版作为起点。其中一个人下周就带着一个类似马里奥的平台游戏回来了!
小贴士
记住,这本书的每一章都可以从仓库的 Git 标签github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly
中访问。此外,主分支包含整个游戏,包括我完成这些挑战时的解决方案。如果你这本书买得早,你甚至可以看到我现场工作在www.twitch.tv/paytonrules。
更多资源
在完成这个游戏并完成我刚才提到的挑战之后,也许你想要在下一款游戏中做得更大。我希望你能这样做。你可以添加粒子效果、爆炸或在线记分系统。你还可以将这个框架作为一款完全原创游戏的起点。你也可以决定使用这个游戏作为介绍,并使用一个完全不同的框架开始一个全新的游戏。本节旨在向你展示,如果你想要继续制作游戏,特别是使用 Rust 和 WebAssembly,你现在有哪些选项可用。
使用 JavaScript 库
整个游戏都是使用 Rust 作为我们的首选语言编写的,有效地摒弃了整个 JavaScript 生态系统。这是一个有意的选择,但并非唯一的选择。我们也可以从现有的 JavaScript 框架调用 Rust Wasm 库,或者可以使用wasm-bindgen
从 Rust 代码中调用 JavaScript 库或框架。第一个方法更实用,是向现有 JavaScript 项目引入 Rust 的绝佳方式。第二个方法更有趣,所以自然地,我们将简要地看看这个例子,使用 PixiJS 编写的示例。
PixiJS
PixiJS (pixijs.com/
) 是一个流行的、高效的 JavaScript 框架,用于在 JavaScript 中制作游戏和可视化。它有一个基于 Canvas 和 WebGL 的渲染器,是获得高性能 2D 图形而不必自己编写 WebGL 着色器的好方法。它支持众多酷炫的功能,比我们游戏中使用 Canvas 要快得多。它有像这样的截图:
(img/Figure_11.01_B17151.jpg)
图 11.1 – 一个纹理网格 (https://bit.ly/3JkhbXw)
它也比我们的引擎复杂得多,这也是本书没有使用它的一个原因,但它在你的下一个游戏中尝试是非常棒的。要从 Rust 代码中使用 JavaScript 库,你需要使用wasm-bindgen
库导入函数,如下所示:
#[derive(Serialize, Deserialize)]
struct Options {
width: f32,
height: f32,
}
#[wasm_bindgen]
extern "C" {
type Application;
type Container;
#[wasm_bindgen(method, js_name = "addChild")]
fn add_child(this: &Container, child: &Sprite);
#[wasm_bindgen(constructor, js_namespace = PIXI)]
fn new(dimens: &JsValue) -> Application;
#[wasm_bindgen(method, getter)]
fn view(this: &Application) -> HtmlCanvasElement;
#[wasm_bindgen(method, getter)]
fn stage(this: &Application) -> Container;
type Sprite;
#[wasm_bindgen(static_method_of = Sprite, js_namespace
= PIXI)]
fn from(name: &JsValue) -> Sprite;
}
// This is like the `main` function, except for JavaScript.
#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
let app = Application::new(
&JsValue::from_serde(&Options {
width: 640.0,
height: 360.0,
})
.unwrap(),
);
let body =
browser::document().unwrap().body().unwrap();
body.append_child(&app.view()).unwrap();
let sprite =
Sprite::from(&JsValue::from_str("Stone.png"));
app.stage().add_child(&sprite);
console_error_panic_hook::set_once();
Ok(())
}
我已经隐藏了use
声明,但这是从我们的游戏中使用 PixiJS 渲染静态屏幕的lib.rs
版本。现在还没有什么乐趣,但足以展示如何使用wasm_bindgen
宏和extern "C" struct
将任何 JavaScript 函数导入到你的 Rust 库中,你可能想使用。这允许你在 Rust 程序中使用任意 JavaScript 代码,只需要一点粘合代码来连接各个部分。实际上,这正是web_sys
的工作方式,我们一直在各处使用它。
为了使用所有这些 Pixi 代码,你需要添加对pixi.js
JavaScript 库的引用,而快速且简单的方法是将以下内容添加到index.html
中:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My Rust + Webpack project!</title>
<link rel="stylesheet" href="styles.css" type="text/css"
media= "screen">
<link rel="preload" as="image" href="Button.svg">
<link rel="preload" as="font" href=
"kenney_future_narrow-webfont.woff2">
<script src="img/pixi.js">
</script>
</head>
...
在一个专业的部署环境中,你可能想使用 WebPack 将 JavaScript 与你的源代码捆绑在一起,但现在这就可以了。我也已经从 HTML 中移除了我们的 canvas 元素,因为 Pixi 提供了自己的。
在 Rust 代码中,我能够从 pixi.js
中导入 PIXI.Application
、PIXI.Container
和 PIXI.Sprite
类型,并且我还引入了与它们相关的一些函数。这使得我可以在 main_js
中使用它们,就像使用原生的 Rust 代码一样。这里的示例并不专业,到处都在使用 unwrap
,但它成功地创建了一个 PixiJS 应用程序,并从我们游戏中已有的文件中创建了一个 Sprite
。然后,它将其添加到 stage
中,这是 PixiJS 的一个概念,你可以将其视为画布。这段代码导致了一个看起来像这样的屏幕:
![图 11.2 – 一块石头
图 11.2 – 一块石头
好吧,看起来并不怎么样,但重点是你可以通过使用 wasm-bindgen
声明你需要的类型,在 Rust 项目中使用 PixiJS。我们在这里不会涵盖所有内容,但 wasm-bindgen
的文档在 rustwasm.github.io/wasm-bindgen/reference/attributes/index.html
上非常详尽。
更重要的是,也许你不喜欢 PixiJS,而想使用 PhaserJS;同样的原则适用!你可以使用任何 JavaScript 程序员可用的优秀框架进行游戏开发,例如 Three.JS 和 Babylon3D,只要你能在你的 WebAssembly 项目中包含它们。但如果你根本不想使用 JavaScript,但仍想在网络上运行呢?
Macroquad
Macroquad (macroquad.rs/
) 是用 Rust 编写的许多游戏开发库之一。作者将其称为“游戏库”,这意味着它并不像整个框架那样功能全面,但它比仅仅编写 HTML Canvas 元素的功能要多,就像我们在游戏中做的那样。它支持开箱即用的 WebAssembly,无需编写任何 JavaScript。以下是一个 Macroquad 中代码的示例:
use macroquad::prelude::*;
#[macroquad::main("BasicShapes")]
async fn main() {
loop {
clear_background(RED);
draw_line(40.0, 40.0, 100.0, 200.0, 15.0, BLUE);
draw_rectangle(screen_width() / 2.0 - 60.0, 100.0,
120.0, 60.0, GREEN);
draw_circle(screen_width() - 30.0, screen_height()
- 30.0, 15.0, YELLOW);
draw_text("HELLO", 20.0, 20.0, 20.0, DARKGRAY);
next_frame().await
}
}
这个非常简单的示例只需通过指定目标 cargo build --target wasm32-unknown-unknown
就可以在网络上编译和运行——没有 JavaScript,没问题。Macroquad 很好,但它并不是一个完整的引擎。那么,如果你想要那种体验呢?
Bevy
另一个具有更多功能的选项是 Bevy (bevyengine.org/
),自从其最初发布以来就非常受欢迎,并支持 WebAssembly。它的“Hello World”与 Macroquad 版本非常不同,如下所示:
use bevy::prelude::*;
fn main() {
App::new().add_system(hello_world_system).run();
}
fn hello_world_system() {
println!("hello world");
}
这个系统最独特的地方是 add_system
函数,它允许你向 Bevy 引擎添加“系统”。Bevy 使用现代的实体组件系统进行开发,旨在帮助您构建程序结构以及提高性能。它正以极快的速度迅速流行起来,并且发展速度超过了其文档的更新速度。目前,如果你想要学习如何使用 Bevy 进行 2D 和 3D 游戏开发,你最好的选择是加入这里的社区:bevyengine.org/community/
。如果你这样做,你会得到回报,因为 Bevy 是一个非常先进的引擎,但它没有像 Unity3D 或 Unreal 那样的编辑器。如果你在寻找这样的编辑器,幸运的是,你有一个非常好的选择。
Godot
我第一次在 Rust 中进行游戏开发是使用 Godot 游戏引擎 (godotengine.org
)。Godot 是一个真正免费且开源的引擎,受到业余爱好者和专业游戏开发者的喜爱。它自带内置语言 GDScript,无需额外安装,但也能够通过 GDNative 包装器使用 Rust。GDNative 最初是为了允许使用 C 和 C++ 而设计的,它与 Rust 的配合非常出色。它拥有自己繁荣的社区,你可以在以下链接下载它:godot-rust.github.io
。
使用 Godot 将意味着获得一个功能齐全的 2D 和 3D 引擎,其性能可以与 Unity3D 的最佳表现相媲美。在你阅读这本书的整个过程中,你可能一直想看到这样一个合适的商业游戏引擎:
图 11.3 – Godot 游戏引擎
如果是这样,Godot 就是你的选择。要查看用 Rust 编写的示例 Godot 程序,你可以查看我在 github.com/paytonrules/Aircombat
上写的程序。
概述
网站 arewegameyet.rs
提出了问题,“Rust 是否准备好进行游戏开发?”,并以“几乎准备好了”作为回答。尊重地说,因为这是一个非常酷的网站,我不同意。我们拥有 JavaScript 开发者在几年前就拥有的所有工具,以及优秀的类型系统和 Wasm 的所有优势。我们拥有的工具比游戏开发历史上大多数开发者拥有的工具都要多,虽然我们可能还没有 Unity 或 Unreal,但我们拥有构建我们自己的所有东西。所以,出去那里,创建你自己的游戏,扩展引擎,享受乐趣!我希望听到你比这个更好的游戏。如果你需要帮助,想要展示你的游戏,或者只是想和志同道合的人一起消磨时光,你可以在 Rustacean Station Discord 上找到我 discord.gg/cHc3Gyc
。你总是可以在 Twitter 上找到我作为 @paytonrules
,我非常期待收到你的来信。
嗨!
我是 Eric Smith,使用 Rust 和 WebAssembly 进行游戏开发的作者,我真心希望您喜欢阅读这本书,并发现它对提高您在 Rust 游戏开发中的生产力和效率有所帮助。
如果您能在亚马逊上留下对这本书的评论,分享您的想法,这将真正对我们(以及其他潜在读者!)有所帮助。
点击以下链接留下您的评论:
您的评论将帮助我们了解这本书中哪些内容做得好,以及哪些方面可以在未来版本中改进,所以这真的非常感谢。
祝好运,
订阅我们的在线数字图书馆,全面访问超过 7000 本书籍和视频,以及帮助您规划个人发展和提升职业生涯的行业领先工具。更多信息,请访问我们的网站。
第十二章:为什么订阅?
-
使用来自 4000 多名行业专业人士的实用电子书和视频,节省学习时间,多花时间编码
-
通过为您量身定制的技能计划提高您的学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于轻松访问关键信息
-
复制粘贴、打印和收藏内容
您知道 Packt 为每本书都提供电子书版本,并提供 PDF 和 ePub 文件吗?您可以在packt.com升级到电子书版本,并且作为印刷版书籍的顾客,您有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com。
在www.packt.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还会喜欢的其他书籍
如果您喜欢这本书,您可能对 Packt 出版的以下其他书籍也感兴趣:
![包含图形用户界面的图片
为 Rust 程序员设计的创意项目
Carlo Milanesi
ISBN: 978-1-78934-622-0
访问 TOML、JSON 和 XML 文件以及 SQLite、PostgreSQL 和 Redis 数据库。
-
使用 JSON 有效载荷开发 RESTful 网络服务。
-
使用 HTML 模板和 JavaScript 创建一个网络应用程序,或者使用 WebAssembly 创建一个前端网络应用程序或网络游戏。
-
构建桌面 2D 游戏。
![图形用户界面,应用程序,PowerPoint
Rust 网络编程
Maxwell Flitton
ISBN: 978-1-80056-081-9
-
在 Rocket、Actix Web 和 Warp 中用 Rust 构建可扩展的网络应用程序。
-
使用 PostgreSQL 为您的网络应用程序应用数据持久性。
-
为您的网络应用程序构建登录、JWT 和配置模块。
-
从 Actix Web 服务器中提供 HTML、CSS 和 JavaScript。
-
在 Postman 和 Newman 中构建单元测试和功能 API 测试。
Packt 正在寻找像你这样的作者。
如果你感兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。