原文:
annas-archive.org/md5/dc6184b37704cf034149c322f30f8917译者:飞龙
第三部分:测试和高级技巧
虽然你可能能够在没有测试或调试的情况下制作这个小游戏,但随着你自己游戏的开发,你需要证明游戏是可行的。没有编写一些测试和调试一些代码,你无法做到这一点。你还需要检查性能,所以我们也会涵盖这一点。如果你想让人们玩你的游戏,你需要一个 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 modulebrowser``,因为尽管我们正在导入一个测试模块,但我们还没有用任何代码填充它。在这种情况下,遵循编译器错误是一个好主意,它会指出在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 – 没有丢失帧!
这值得担心吗?可能吧——看到按钮加载是明显的,但扩展这一章来修复它并不值得。你知道如何修复它,也知道如何在性能标签页中找到问题,而这正是现在重要的。无论如何,我们还有另一个问题要回答:这款游戏占用了多少内存?
检查内存
当我编写这个游戏时,我经常让它全天在后台运行,结果我的浏览器开始占用我电脑的所有内存,变得非常不响应。我开始怀疑这个游戏有内存泄漏,所以我开始调查。你可能认为由于 Rust 的保证,在 Rust 中不可能有内存泄漏,这确实更难,但记住,我们的大部分代码都与 JavaScript 通信,我们并不一定有相同的保证。幸运的是,我们可以使用我们用来测试性能的相同工具来检查这一点。
点击左上角的无符号来清除性能数据。然后,开始另一个记录并播放一段时间。这次,不要试图立即死亡;让游戏播放一会儿。然后,停止记录并再次查看性能数据,这次确保你点击内存按钮。现在,你可以查看结果,它可能看起来像这样:
图 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 – 复制并粘贴我!
这些操作是工作流程的构建块。在 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 – 部署以测试
或者,你可以访问样本仓库,在那里你可以看到评论在 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 要快得多。它有像这样的截图:
https://bit.ly/3JkhbXw(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 – 一块石头
好吧,看起来并不怎么样,但重点是你可以通过使用 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 游戏开发中的生产力和效率有所帮助。
如果您能在亚马逊上留下对这本书的评论,分享您的想法,这将真正对我们(以及其他潜在读者!)有所帮助。
点击以下链接留下您的评论:
您的评论将帮助我们了解这本书中哪些内容做得好,以及哪些方面可以在未来版本中改进,所以这真的非常感谢。
祝好运,
https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/gm-dev-rs-wasm/img/Authorsign.png
https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/gm-dev-rs-wasm/img/Author_photo.jpg
订阅我们的在线数字图书馆,全面访问超过 7000 本书籍和视频,以及帮助您规划个人发展和提升职业生涯的行业领先工具。更多信息,请访问我们的网站。
第十二章:为什么订阅?
-
使用来自 4000 多名行业专业人士的实用电子书和视频,节省学习时间,多花时间编码
-
通过为您量身定制的技能计划提高您的学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于轻松访问关键信息
-
复制粘贴、打印和收藏内容
您知道 Packt 为每本书都提供电子书版本,并提供 PDF 和 ePub 文件吗?您可以在packt.com升级到电子书版本,并且作为印刷版书籍的顾客,您有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com。
在www.packt.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还会喜欢的其他书籍
如果您喜欢这本书,您可能对 Packt 出版的以下其他书籍也感兴趣:

被折叠的 条评论
为什么被折叠?



