介绍
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。

每年,r/roguelikedev 的优秀伙伴们都会举办一系列的 Tutorial Tuesday,鼓励新程序员加入 roguelike 开发者的行列。大多数语言都会被代表,而今年(2019 年)我决定利用这个机会学习 Rust。我并不想使用默认的引擎 libtcod,所以我创建了自己的引擎 RLTK。我最初的作品并不出色,但我从中学到了很多——如果你好奇的话,可以在这里找到它 here。
该系列始终引导人们学习一系列优秀的教程,使用 Python 和libtcod。你可以在这里找到它。本教程的第一部分反映了本教程的结构——并试图带你从零(如何打开控制台并说 Hello Rust)到英雄(装备物品在多层地牢中与敌人战斗)。我希望继续扩展这个系列。
我也真的想使用实体组件系统。Rust 有一个很棒的叫做 Specs,所以我选择了它。我之前在其他游戏中使用过基于 ECS 的设置,所以对我来说使用它感觉很自然。这也是 subreddit 上持续混乱的原因,所以希望这个教程能够阐明它的好处以及为什么你可能想使用它。
我写这篇文章非常开心——希望能继续写下去。如果你有任何问题、改进建议或希望我添加的内容,请随时联系我(我在 Twitter 上的用户名是@herberticus)。另外,对于所有的 Patreon 广告,我感到抱歉——希望有人会觉得这足够有用,愿意请我喝一两杯咖啡。:-)
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
为 Web 构建(WASM)
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
Web Assembly 是一个很酷的系统,可以让你运行从非基于网络的语言编译的代码,并在浏览器中运行它们。它有一些限制:
- 你是沙盒化的,所以无法访问用户计算机上的文件。
- 线程在 WASM 中的工作方式不同,因此没有帮助的情况下,正常的多线程代码可能无法工作。
- 你的渲染后端将是 OpenGL,至少在 WebGL 完成之前是这样。
- 我还没有编写从网络访问文件的代码,所以你必须嵌入你的资源。教程章节通过各种
rltk::embedded_resource!调用来实现这一点。至少,你需要使用include_bytes!或类似的方法将资源存储在可执行文件中。(或者你可以帮我编写一个文件读取器!)
WASM 是用于在您的浏览器中运行可玩章节演示的工具。
为网络构建
制作游戏 WASM 版本的过程比我想要的要复杂一些,但它是可行的。我通常会将其放入批处理文件(或 shell 脚本)中以自动化该过程。
你需要的工具
首先,Rust 需要安装“目标”来处理编译为 WebAssembly(WASM)。目标名称是 wasm32-unknown-unknown。假设你使用 rustup 设置了 Rust,可以通过输入以下命令来安装它:
rustup target add wasm32-unknown-unknown
你还需要一个名为 wasm-bindgen 的工具。这是一个非常令人印象深刻的工具,可以扫描你的 WebAssembly 并构建使代码在 Web 上运行所需的各个部分。我使用命令行版本(有办法将其集成到你的系统中 - 希望这将成为未来章节的主题)。你可以通过以下方式安装该工具:
cargo install wasm-bindgen-cli
注意:当你更新 Rust 工具链时,需要重新安装 wasm-bindgen。
第一步:为 WASM 编译程序
我建议为 WASM 执行release构建。调试版本可能会非常大,没有人愿意在下载一个巨大的程序时等待。导航到项目的根目录,然后输入:
cargo build --release --target wasm32-unknown-unknown
第一次这样做时,会需要一段时间。它需要重新编译你用于 WebAssembly 的所有库!这会在target/wasm32-unknown-unknown/release/文件夹中创建文件。会有几个包含构建信息和类似的文件夹,以及重要的文件:yourproject.d(调试信息)和yourproject.wasm——实际的 WASM 目标文件。(将yourproject替换为你的项目名称)
第二步:确定文件存放位置
为了简单起见,我将使用一个名为 wasm 的目标文件夹。你可以使用任何你喜欢的名称,但需要在接下来的说明中更改名称。在你的根项目文件夹中创建该文件夹。例如,mkdir wasm。
第三步:组装网页文件
现在你需要使用 wasm-bindgen 来构建与浏览器集成的所需 Web 基础设施。
wasm-bindgen target\wasm32-unknown-unknown\release\yourproject.wasm --out-dir wasm --no-modules --no-typescript
如果你查看 wasm 文件夹,你会看到两个文件:
yourproject.js- 项目的 JavaScript 绑定yourproject_bg.wasm- 包含 JavaScript 文件所需绑定的wasm输出版本
我通常将这些文件重命名为 myblob.js 和 myblob_bg.wasm。你不必这样做,但这让我每次都能使用相同的模板 HTML。
第四步:创建一些样板 HTML
在你的 wasm 文件夹中,你需要创建一个 HTML 页面来托管/启动你的应用程序。我每次都使用相同的样板代码:
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
</head>
<body>
<canvas id="canvas" width="640" height="480"></canvas>
<script src="./myblob.js"></script>
<script>
window.addEventListener("load", async () => {
await wasm_bindgen("./myblob_bg.wasm");
});
</script>
</body>
</html>
第五步:托管它!
你不能从本地文件源运行 WASM(可能是出于安全原因)。你需要将其放入一个 Web 服务器,并从那里运行。如果你有网络托管,将你的wasm文件夹复制到你想要的位置。然后你可以在浏览器中打开 Web 服务器的 URL,你的游戏就会运行。
如果你没有网络托管,你需要安装一个本地网络服务器,并从那里提供服务。
第一章:你好,Rust
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。

本教程主要关于学习制作 roguelike 游戏(以及扩展到其他游戏),但它也应该能帮助你熟悉 Rust 和 RLTK——我们将使用的Roguelike 工具包来提供输入/输出。即使你不想使用 Rust,我也希望你能从结构、想法和一般游戏开发建议中受益。
为什么选择 Rust?
Rust 首次出现在 2010 年,但直到最近才达到“稳定”状态——也就是说,你现在编写的代码在语言变化时不太可能停止工作。开发仍在进行中,语言的全新部分(如异步系统)仍在出现/稳定中。本教程将避开开发的最前沿——它应该是稳定的。
Rust 被设计为一种“更好的系统语言”——即像 C++ 一样低级,但机会更少会让你自己陷入困境,专注于避免使 C++ 开发变得困难的许多“陷阱”,并且对内存和线程安全有极大的关注:它的设计使得编写一个破坏其内存或遭受竞态条件的程序变得非常困难(不是不可能,但你必须努力尝试!)。它正在迅速获得关注,从 Mozilla 到 Microsoft 都表现出兴趣——并且有越来越多的工具用它编写。
Rust 也被设计为比 C++ 拥有更好的生态系统。Cargo 提供了一个完整的包管理器(在 C++ 领域中,vcpkg、conan 等也提供了类似的功能,但 Cargo 集成得更好),一个完整的构建系统(类似于 cmake、make、meson 等,但标准化了)。它不像 C 或 C++ 那样在许多平台上运行,但这个列表在不断增长。
我尝试了 Rust(在朋友的强烈推荐下),发现虽然它并没有取代 C++在我日常工具箱中的位置——但有时它确实帮助我快速完成项目。它的语法需要一些时间适应,但它确实能很好地融入现有基础设施。
学习 Rust
如果你使用过其他编程语言,那么有很多帮助可用!
- Rust 编程语言书籍 提供了对语言的优秀自顶向下介绍。
- 通过示例学习 Rust 更接近我喜欢的学习方式(我已经有很多语言的经验),提供了大多数你可能遇到的主题的常见用法示例。
- 24 天学习 Rust 提供了一个稍微以 Web 为重点的 24 天课程来学习 Rust。
- 面向 JavaScript 开发者的 Rust 所有权模型 如果你来自 JS 或其他非常高级的语言,应该会有所帮助。
如果你发现你需要的东西不在那里,很可能有人已经写了一个crate(在其他语言中称为“包”,但 cargo 处理的是 crate...)来帮助你。一旦你有了一个工作环境,你可以输入cargo search <my term>来查找有用的 crate。你也可以前往crates.io查看 Cargo 中提供的所有 crate 的完整列表,包括文档和示例。
如果你是完全的编程新手,那么有一个坏消息:Rust 是一种相对年轻的语言,所以目前还没有很多“从零开始学习 Rust 编程”的资料——还没有。你可能会发现从一种更高层次的语言开始,然后再“向下”(更接近底层)学习 Rust 会更容易。然而,如果你决定尝试,上面的教程/指南应该能帮你入门。
获取 Rust
在大多数平台上,rustup 足以让你获得一个可用的 Rust 工具链。在 Windows 上,这是一个简单的下载——完成后你会得到一个可用的 Rust 环境。在类 Unix 系统(如 Linux 和 OS X)上,它提供了一些命令行指令来安装环境。
安装完成后,通过在命令行中输入 cargo --version 来验证它是否正常工作。你应该会看到类似 cargo 1.36.0 (c4fcfb725 2019-05-15) 的内容(版本会随时间变化)。
熟悉开发环境
你想为你的开发工作创建一个目录/文件夹(我个人使用users/herbert/dev/rust - 但这只是个人选择。它真的可以是你喜欢的任何地方!)。你还需要一个文本编辑器。我是Visual Studio Code的粉丝,但你可以使用任何你习惯的编辑器。如果你使用 Visual Studio Code,我推荐以下扩展:
更好的 TOML: 使阅读 toml 文件变得愉快;Rust 经常使用它们C/C++: 使用 C++调试系统调试 Rust 代码Rust (rls): 不是最快的,但提供全面的语法高亮和实时错误检查。
一旦你选择了你的环境,打开一个编辑器并导航到你的新文件夹(在 VS Code 中,文件 -> 打开文件夹 并选择该文件夹)。
创建一个项目
现在你已经在你选择的文件夹中,你想在那里打开一个终端/控制台窗口。在 VS Code 中,这是终端 -> 新建终端。否则,像往常一样打开命令行并cd到你的文件夹。
Rust 有一个内置的包管理器叫做 cargo。Cargo 可以为你创建项目模板!所以,要创建你的新项目,输入 cargo init hellorust。片刻之后,你的项目中会出现一个名为 hellorust 的新文件夹。它将包含以下文件和目录:
src\main.rs
Cargo.toml
.gitignore
这些是:
.gitignore在你使用 git 时非常方便 - 它可以防止你不小心将不需要的文件放入 git 仓库。如果你不使用 git,可以忽略它。src\main.rs是一个简单的 Rust "hello world" 程序源码。Cargo.toml定义了你的项目以及如何构建它。
快速 Rust 介绍 - Hello World 的剖析
自动生成的 main.rs 文件如下所示:
fn main() { println!("Hello, world!"); }
如果你使用过其他编程语言,这看起来应该有些熟悉——但语法/关键字可能不同。Rust 最初是 ML 和 C 的混合体,旨在创建一种灵活的“系统”语言(意味着:你可以为你的 CPU 编写裸机代码,而不需要像 Java 或 C# 那样的虚拟机)。在此过程中,它继承了这两种语言的许多语法。我发现使用它的第一周语法看起来糟糕,之后就变得相当自然。就像人类语言一样,你的大脑需要一段时间才能适应语法和布局。
那么这一切意味着什么?
fn是 Rust 的关键字,用于表示 函数。在 JavaScript 或 Java 中,这会写成function main()。在 C 中,它会写成void main()(尽管在 C 中main应该返回一个int)。在 C# 中,它会是static void Main(...)。main是函数的 名称。在这种情况下,名称是一个特殊情况:操作系统需要知道在将程序加载到内存时首先运行哪个函数 - 而 Rust 会额外工作以将main标记为第一个函数。通常,如果您希望程序执行任何操作,您 需要 一个main函数,除非您正在制作一个 库(供其他程序使用的函数集合)。()是函数的 参数 或 形参。在这种情况下,没有参数 - 所以我们只使用空的括号。{表示 块 的开始。在这种情况下,块是函数的 主体。在{和}之间的所有内容都是函数的内容:执行的指令。块还表示 作用域 - 因此您在函数内部声明的任何内容都仅限于该函数。 换句话说,如果你在一个名为cheese的函数中创建一个变量 - 它将不会在名为mouse的函数中可见(反之亦然)。有一些方法可以解决这个问题,我们将在构建游戏的过程中介绍它们。println!是一个 宏。你可以通过名称后面的!来识别 Rust 宏。你可以在这里学习所有关于宏的知识;现在,你只需要知道它们是 特殊 函数,在编译期间会被解析成 其他代码。打印到屏幕上可能会非常复杂 - 你可能想说不仅仅是 "hello world" - 而println!宏涵盖了 很多 格式化情况。(如果你熟悉 C++,它相当于std::fmt。大多数语言都有自己的字符串格式化系统,因为程序员往往需要输出大量文本!)- 最后的
}关闭了在4中开始的块。
继续输入 cargo run。经过一些编译后,如果一切正常,你将在终端上看到 "Hello World"。
有用的 cargo 命令
Cargo 是一个非常棒的工具!你可以在《学习 Rust 书籍》中了解一些关于它的内容 从《学习 Rust 书籍》,如果你感兴趣,可以从《Cargo 书籍》中了解 所有 关于它的内容 《Cargo 书籍》。
在 Rust 工作中,你会经常与 cargo 打交道。如果你用 cargo init 初始化你的程序,你的程序就是一个 cargo crate。编译、测试、运行、更新 - Cargo 可以帮助你完成所有这些工作。它甚至默认为你设置 git。
您可能会发现以下 cargo 功能很方便:
cargo init创建一个新项目。这就是你用来创建 hello world 程序的方法。如果你真的不想使用git,可以输入cargo init --vcs none (项目名称)。cargo build下载项目的所有依赖并编译它们,然后编译你的程序。它实际上不会运行你的程序——但这是一个快速查找编译错误的好方法。cargo update将获取你在cargo.toml文件中列出的 crates 的新版本(见下文)。cargo clean可以用来删除项目的所有中间工作文件,释放大量磁盘空间。它们会在你下次运行/构建项目时自动下载和重新编译。偶尔,cargo clean可以在某些功能不正常时提供帮助——特别是 IDE 集成。cargo verify-project会告诉你你的 Cargo 设置是否正确。cargo install可以通过 Cargo 安装程序。这对于安装你需要的工具很有帮助。
Cargo 还支持扩展——即插件,使其功能更加强大。以下是一些你可能会发现特别有用的扩展:
- Cargo 可以将所有源代码重新格式化为标准的 Rust 代码,就像 Rust 手册中的那样。您需要输入
rustup component add rustfmt一次 来安装该工具。完成后,您可以随时输入cargo fmt来格式化代码。 - 如果您想使用
mdbook格式 - 用于 这本书!- Cargo 也可以提供帮助。只需一次,您需要运行cargo install mdbook将工具添加到您的系统中。之后,mdbook build将构建一个图书项目,mdbook init将创建一个新项目,而mdbook serve将为您提供一个本地网络服务器来查看您的工作!您可以在 他们的文档页面 上了解有关mdbook的所有信息。 - Cargo 还可以与一个名为
Clippy的“代码检查工具”集成。Clippy 有点挑剔(就像他的 Microsoft Office 同名一样!)。只需一次,运行rustup component add clippy。现在,您可以随时输入cargo clippy来查看代码中可能存在的问题建议!
创建一个新项目
让我们修改新创建的“hello world”项目,以使用RLTK——Roguelike 工具包。
设置 Cargo.toml
自动生成的 Cargo 文件将如下所示:
[package]
name = "helloworld"
version = "0.1.0"
authors = ["如果它知道的话,你的名字"]
edition = "2018"
# 在 https://doc.rust-lang.org/cargo/reference/manifest.html 查看更多键及其定义
[dependencies]
继续并确保您的名字是正确的!接下来,我们将要求 Cargo 使用 RLTK - Roguelike 工具包库。Rust 使这变得非常容易。调整 dependencies 部分,使其看起来像这样:
[dependencies]
rltk = { version = "0.8.0" }
我们告诉它包名为rltk,并且可以在 Cargo 中找到——所以我们只需要给它一个版本。你可以运行cargo search rltk随时查看最新版本,或者访问crate 网页。
偶尔运行 cargo update 是个好主意 - 这将更新程序使用的库。
你好,Rust - RLTK 风格!
继续并替换 src\main.rs 的内容为:
use rltk::{Rltk, GameState}; struct State {} impl GameState for State { fn tick(&mut self, ctx : &mut Rltk) { ctx.cls(); ctx.print(1, 1, "Hello Rust World"); } } fn main() -> rltk::BError { use rltk::RltkBuilder; let context = RltkBuilder::simple80x50() .with_title("Roguelike Tutorial") .build()?; let gs = State{ }; rltk::main_loop(context, gs) }
现在创建一个名为 resources 的新文件夹。RLTK 需要一些文件才能运行,我们将它们放在这里。下载 resources.zip,并将其解压到此文件夹中。注意要有 resources/backing.fs(等等)而不是 resources/resources/backing.fs。
保存,然后返回终端。输入 cargo run,你将会看到一个显示 Hello Rust 的控制台窗口。

如果你是 Rust 的新手,你可能想知道Hello Rust代码到底做了什么,以及为什么它在那里——所以我们花点时间来了解一下。
- 第一行相当于 C++的
#include或 C#的using。它只是告诉编译器我们将需要来自命名空间rltk的Rltk和GameState类型。以前你需要在这里添加一个额外的extern crate行,但最新版本的 Rust 现在可以为你解决这个问题。 - 使用
struct State{},我们正在创建一个新的结构。结构类似于 Pascal 中的记录,或许多其他语言中的类:你可以在其中存储一堆数据,还可以将“方法”(函数)附加到它们上。在这种情况下,我们实际上不需要任何数据——我们只需要一个地方来附加代码。如果你想了解更多关于结构的信息,这是 Rust Book 中关于该主题的章节 impl GameState for State相当冗长!我们告诉 Rust 我们的State结构实现了GameState特性。特性类似于其他语言中的接口或基类:它们为你设置了一个结构,你可以在自己的代码中实现,然后可以与提供它们的库进行交互——而无需该库知道你的代码的任何其他信息。 在这种情况下,GameState是由 RLTK 提供的一个 trait。RLTK 要求你有一个 - 它使用它来在每一帧调用你的程序。你可以在 Rust 书中的这一章 了解 trait。fn tick(&mut self, ctx : &mut Rltk)是一个 *函数 * 定义。我们在 trait 实现的作用域内,所以我们是为 trait 实现这个函数 - 因此它必须匹配 trait 所需的类型。函数是 Rust 的基本构建块,我推荐 Rust 书中的这一章。- 在这种情况下,
fn tick意味着“创建一个名为 tick 的函数”(它被称为“tick”是因为它在每一帧渲染时“滴答”;在游戏编程中,通常将每次迭代称为 tick)。 - 它没有以
-> type结尾,所以它相当于 C 中的void函数 - 它在被调用后不返回任何数据。参数也可以受益于一点解释。 &mut self表示“此函数需要访问父结构体,并且可能会更改它”(mut是“mutable”的缩写,表示它可以更改结构体内的变量——“状态”)。你也可以在结构体中有只包含&self的函数——这意味着我们可以查看结构体的内容,但不能更改它。如果你完全省略&self,该函数根本无法查看结构体——但可以像调用命名空间一样调用(你经常会在调用new函数时看到这种情况——它们为你创建结构体的新副本)。ctx: &mut Rltk表示“传入一个名为ctx的变量”(ctx是“context”的缩写)。冒号表示我们正在指定它必须是哪种类型的变量。&表示“传递一个引用”——这是一个指向现有变量副本的指针。变量不会被复制,你正在处理传入的版本;如果你进行更改,你将更改原始变量。《Rust 编程语言》对此解释得比我好。mut再次表明这是一个“可变”引用:允许你对上下文进行更改。- 最后
Rltk是你接收的变量的类型。在这种情况下,它是一个在RLTK库中定义的struct,提供了各种可以对屏幕执行的操作。
- 在这种情况下,
ctx.cls();表示“调用变量ctx提供的cls函数。cls是“清除屏幕”的常见缩写——我们告诉我们的上下文它应该清除虚拟终端。除非你特别不想这样做,否则在每一帧的开始这样做是个好主意。ctx.print(1, 1, "Hello Rust World");要求上下文在位置 (1,1) 处打印“Hello Rust World”。- 现在我们到了
fn main()。每个程序都有一个main函数:它告诉操作系统从哪里开始运行程序。 -
是一个从#![allow(unused)] fn main() { use rltk::RltkBuilder; let context = RltkBuilder::simple80x50() .with_title("Roguelike Tutorial") .build()?; }struct内部调用函数的例子——其中该结构不接受“self”函数。在其他语言中,这被称为构造函数。我们正在调用函数simple80x50(这是 RLTK 提供的一个构建器,用于创建一个 80 个字符宽、50 个字符高的终端。窗口标题是“Roguelike 教程”。 let gs = State{ };是一个变量赋值的例子(参见The Rust Book)。我们正在创建一个名为gs的新变量(代表“游戏状态”),并将其设置为我们上面定义的State结构的副本。rltk::main_loop(context, gs)调用rltk命名空间中的一个名为main_loop的函数。它需要我们之前创建的context和GameState- 所以我们传递这些参数。RLTK 试图简化运行 GUI/游戏应用程序的复杂性,并提供这个包装器。该函数现在接管程序的控制,并且每次程序“滴答”时(即完成一个周期并进入下一个周期)都会调用你的tick函数(见上文)。这可以每秒发生 60 次或更多次!
希望这些解释有些作用!
使用教程
你可能想在不全部输入的情况下玩转教程代码!好消息是它已经上传到 GitHub 供你查阅。你需要安装git(RustUp 应该已经帮你解决了这个问题)。选择你想存放教程的位置,并打开一个终端:
cd <路径到教程> git clone https://github.com/thebracket/rustrogueliketutorial .
过了一会儿,这将下载完整的教程(包括本书的源代码!)。它的布局如下(这并不完整!):
───book
├───chapter-01-hellorust
├───chapter-02-helloecs
├───chapter-03-walkmap
├───chapter-04-newmap
├───chapter-05-fov
├───resources
├───src
这里有什么?
book文件夹包含本书的源代码。除非你想纠正我的拼写,否则可以忽略它!- 每个章节的示例代码包含在
chapter-xy-name文件夹中;例如,chapter-01-hellorust。 src文件夹包含一个简单的脚本,提醒你在运行任何内容之前切换到章节文件夹。resources包含你为此示例下载的 ZIP 文件的内容。所有章节文件夹都预配置为使用此内容。Cargo.toml设置为包含所有教程作为“工作区条目”——它们共享依赖项,因此不会每次使用时都重新下载所有内容,占用整个驱动器。
要运行一个示例,打开您的终端并:
cd <你存放教程的位置> cd chapter-01-hellorust
cargo run
如果您使用的是 Visual Studio Code,您可以使用 文件 -> 打开文件夹 来打开您检出的整个目录。使用内置终端,您可以简单地 cd 到每个示例并 cargo run 它。
访问教程源代码
你可以在https://github.com/thebracket/rustrogueliketutorial获取所有教程的源代码。
更新教程
我经常更新这个教程——添加章节、修复问题等。你将定期想要打开教程目录,并输入 git pull。这会告诉 git(源代码管理器)前往 Github 仓库并查找新的内容。然后它会下载所有更改的内容,你再次拥有最新的教程。
更新您的项目
你可能会发现 rltk_rs 或其他包已经更新,而你希望获得最新版本。从你的项目文件夹中,你可以输入 cargo update 来更新所有内容。你可以输入 cargo update --dryrun 来查看它将要更新的内容,而不会更改任何内容(人们经常更新他们的 crates,所以这可能是一个很长的列表!)。
更新 Rust 本身
我不推荐在 Visual Studio Code 或其他 IDE 内部运行这个,但如果你想确保你拥有最新版本的 Rust(以及相关工具),你可以输入 rustup self update。这将更新 Rust 更新工具(我知道这听起来有点递归)。然后你可以输入 rustup update 并安装所有工具的最新版本。
获取帮助
有多种方式可以获得帮助:
- 如果有任何问题、改进建议或希望我添加的内容,请随时联系我(我在 Twitter 上的用户名是
@herberticus)。 - /r/rust上的热心人士在 Rust 语言问题上非常有帮助。
- /r/roguelikedev上的热心人士在 Roguelike 问题上非常有帮助。他们的 Discord 也很活跃。 运行本章的示例与 Web Assembly,在您的浏览器中(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
第一章:你好,Rust
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。

本教程主要关于学习制作 roguelike 游戏(以及扩展到其他游戏),但它也应该能帮助你熟悉 Rust 和 RLTK——我们将使用的Roguelike 工具包来提供输入/输出。即使你不想使用 Rust,我也希望你能从结构、想法和一般游戏开发建议中受益。
为什么选择 Rust?
Rust 首次出现在 2010 年,但直到最近才达到“稳定”状态——也就是说,你现在编写的代码在语言变化时不太可能停止工作。开发仍在进行中,语言的全新部分(如异步系统)仍在出现/稳定中。本教程将避开开发的最前沿——它应该是稳定的。
Rust 被设计为一种“更好的系统语言”——即像 C++ 一样低级,但机会更少会让你自己陷入困境,专注于避免使 C++ 开发变得困难的许多“陷阱”,并且对内存和线程安全有极大的关注:它的设计使得编写一个破坏其内存或遭受竞态条件的程序变得非常困难(不是不可能,但你必须努力尝试!)。它正在迅速获得关注,从 Mozilla 到 Microsoft 都表现出兴趣——并且有越来越多的工具用它编写。
Rust 也被设计为比 C++ 拥有更好的生态系统。Cargo 提供了一个完整的包管理器(在 C++ 领域中,vcpkg、conan 等也提供了类似的功能,但 Cargo 集成得更好),一个完整的构建系统(类似于 cmake、make、meson 等,但标准化了)。它不像 C 或 C++ 那样在许多平台上运行,但这个列表在不断增长。
我尝试了 Rust(在朋友的强烈推荐下),发现虽然它并没有取代 C++在我日常工具箱中的位置——但有时它确实帮助我快速完成项目。它的语法需要一些时间适应,但它确实能很好地融入现有基础设施。
学习 Rust
如果你使用过其他编程语言,那么有很多帮助可用!
- Rust 编程语言书籍 提供了对语言的优秀自顶向下介绍。
- 通过示例学习 Rust 更接近我喜欢的学习方式(我已经有很多语言的经验),提供了大多数你可能遇到的主题的常见用法示例。
- 24 天学习 Rust 提供了一个稍微以 Web 为重点的 24 天课程来学习 Rust。
- 面向 JavaScript 开发者的 Rust 所有权模型 如果你来自 JS 或其他非常高级的语言,应该会有所帮助。
如果你发现你需要的东西不在那里,很可能有人已经写了一个crate(在其他语言中称为“包”,但 cargo 处理的是 crate...)来帮助你。一旦你有了一个工作环境,你可以输入cargo search <my term>来查找有用的 crate。你也可以前往crates.io查看 Cargo 中提供的所有 crate 的完整列表,包括文档和示例。
如果你是完全的编程新手,那么有一个坏消息:Rust 是一种相对年轻的语言,所以目前还没有很多“从零开始学习 Rust 编程”的资料——还没有。你可能会发现从一种更高层次的语言开始,然后再“向下”(更接近底层)学习 Rust 会更容易。然而,如果你决定尝试,上面的教程/指南应该能帮你入门。
获取 Rust
在大多数平台上,rustup 足以让你获得一个可用的 Rust 工具链。在 Windows 上,这是一个简单的下载——完成后你会得到一个可用的 Rust 环境。在类 Unix 系统(如 Linux 和 OS X)上,它提供了一些命令行指令来安装环境。
安装完成后,通过在命令行中输入 cargo --version 来验证它是否正常工作。你应该会看到类似 cargo 1.36.0 (c4fcfb725 2019-05-15) 的内容(版本会随时间变化)。
熟悉开发环境
你想为你的开发工作创建一个目录/文件夹(我个人使用users/herbert/dev/rust - 但这只是个人选择。它真的可以是你喜欢的任何地方!)。你还需要一个文本编辑器。我是Visual Studio Code的粉丝,但你可以使用任何你习惯的编辑器。如果你使用 Visual Studio Code,我推荐以下扩展:
更好的 TOML: 使阅读 toml 文件变得愉快;Rust 经常使用它们C/C++: 使用 C++调试系统调试 Rust 代码Rust (rls): 不是最快的,但提供全面的语法高亮和实时错误检查。
一旦你选择了你的环境,打开一个编辑器并导航到你的新文件夹(在 VS Code 中,文件 -> 打开文件夹 并选择该文件夹)。
创建一个项目
现在你已经在你选择的文件夹中,你想在那里打开一个终端/控制台窗口。在 VS Code 中,这是终端 -> 新建终端。否则,像往常一样打开命令行并cd到你的文件夹。
Rust 有一个内置的包管理器叫做 cargo。Cargo 可以为你创建项目模板!所以,要创建你的新项目,输入 cargo init hellorust。片刻之后,你的项目中会出现一个名为 hellorust 的新文件夹。它将包含以下文件和目录:
src\main.rs
Cargo.toml
.gitignore
这些是:
.gitignore在你使用 git 时非常方便 - 它可以防止你不小心将不需要的文件放入 git 仓库。如果你不使用 git,可以忽略它。src\main.rs是一个简单的 Rust "hello world" 程序源码。Cargo.toml定义了你的项目以及如何构建它。
快速 Rust 介绍 - Hello World 的剖析
自动生成的 main.rs 文件如下所示:
fn main() { println!("Hello, world!"); }
如果你使用过其他编程语言,这看起来应该有些熟悉——但语法/关键字可能不同。Rust 最初是 ML 和 C 的混合体,旨在创建一种灵活的“系统”语言(意味着:你可以为你的 CPU 编写裸机代码,而不需要像 Java 或 C# 那样的虚拟机)。在此过程中,它继承了这两种语言的许多语法。我发现使用它的第一周语法看起来糟糕,之后就变得相当自然。就像人类语言一样,你的大脑需要一段时间才能适应语法和布局。
那么这一切意味着什么?
fn是 Rust 的关键字,用于表示 函数。在 JavaScript 或 Java 中,这会写成function main()。在 C 中,它会写成void main()(尽管在 C 中main应该返回一个int)。在 C# 中,它会是static void Main(...)。main是函数的 名称。在这种情况下,名称是一个特殊情况:操作系统需要知道在将程序加载到内存时首先运行哪个函数 - 而 Rust 会额外工作以将main标记为第一个函数。通常,如果您希望程序执行任何操作,您 需要 一个main函数,除非您正在制作一个 库(供其他程序使用的函数集合)。()是函数的 参数 或 形参。在这种情况下,没有参数 - 所以我们只使用空的括号。{表示 块 的开始。在这种情况下,块是函数的 主体。在{和}之间的所有内容都是函数的内容:执行的指令。块还表示 作用域 - 因此您在函数内部声明的任何内容都仅限于该函数。 换句话说,如果你在一个名为cheese的函数中创建一个变量 - 它将不会在名为mouse的函数中可见(反之亦然)。有一些方法可以解决这个问题,我们将在构建游戏的过程中介绍它们。println!是一个 宏。你可以通过名称后面的!来识别 Rust 宏。你可以在这里学习所有关于宏的知识;现在,你只需要知道它们是 特殊 函数,在编译期间会被解析成 其他代码。打印到屏幕上可能会非常复杂 - 你可能想说不仅仅是 "hello world" - 而println!宏涵盖了 很多 格式化情况。(如果你熟悉 C++,它相当于std::fmt。大多数语言都有自己的字符串格式化系统,因为程序员往往需要输出大量文本!)- 最后的
}关闭了在4中开始的块。
继续输入 cargo run。经过一些编译后,如果一切正常,你将在终端上看到 "Hello World"。
有用的 cargo 命令
Cargo 是一个非常棒的工具!你可以在《学习 Rust 书籍》中了解一些关于它的内容 从《学习 Rust 书籍》,如果你感兴趣,可以从《Cargo 书籍》中了解 所有 关于它的内容 《Cargo 书籍》。
在 Rust 工作中,你会经常与 cargo 打交道。如果你用 cargo init 初始化你的程序,你的程序就是一个 cargo crate。编译、测试、运行、更新 - Cargo 可以帮助你完成所有这些工作。它甚至默认为你设置 git。
您可能会发现以下 cargo 功能很方便:
cargo init创建一个新项目。这就是你用来创建 hello world 程序的方法。如果你真的不想使用git,可以输入cargo init --vcs none (项目名称)。cargo build下载项目的所有依赖并编译它们,然后编译你的程序。它实际上不会运行你的程序——但这是一个快速查找编译错误的好方法。cargo update将获取你在cargo.toml文件中列出的 crates 的新版本(见下文)。cargo clean可以用来删除项目的所有中间工作文件,释放大量磁盘空间。它们会在你下次运行/构建项目时自动下载和重新编译。偶尔,cargo clean可以在某些功能不正常时提供帮助——特别是 IDE 集成。cargo verify-project会告诉你你的 Cargo 设置是否正确。cargo install可以通过 Cargo 安装程序。这对于安装你需要的工具很有帮助。
Cargo 还支持扩展——即插件,使其功能更加强大。以下是一些你可能会发现特别有用的扩展:
- Cargo 可以将所有源代码重新格式化为标准的 Rust 代码,就像 Rust 手册中的那样。您需要输入
rustup component add rustfmt一次 来安装该工具。完成后,您可以随时输入cargo fmt来格式化代码。 - 如果您想使用
mdbook格式 - 用于 这本书!- Cargo 也可以提供帮助。只需一次,您需要运行cargo install mdbook将工具添加到您的系统中。之后,mdbook build将构建一个图书项目,mdbook init将创建一个新项目,而mdbook serve将为您提供一个本地网络服务器来查看您的工作!您可以在 他们的文档页面 上了解有关mdbook的所有信息。 - Cargo 还可以与一个名为
Clippy的“代码检查工具”集成。Clippy 有点挑剔(就像他的 Microsoft Office 同名一样!)。只需一次,运行rustup component add clippy。现在,您可以随时输入cargo clippy来查看代码中可能存在的问题建议!
创建一个新项目
让我们修改新创建的“hello world”项目,以使用RLTK——Roguelike 工具包。
设置 Cargo.toml
自动生成的 Cargo 文件将如下所示:
[package]
name = "helloworld"
version = "0.1.0"
authors = ["如果它知道的话,你的名字"]
edition = "2018"
# 在 https://doc.rust-lang.org/cargo/reference/manifest.html 查看更多键及其定义
[dependencies]
继续并确保您的名字是正确的!接下来,我们将要求 Cargo 使用 RLTK - Roguelike 工具包库。Rust 使这变得非常容易。调整 dependencies 部分,使其看起来像这样:
[dependencies]
rltk = { version = "0.8.0" }
我们告诉它包名为rltk,并且可以在 Cargo 中找到——所以我们只需要给它一个版本。你可以运行cargo search rltk随时查看最新版本,或者访问crate 网页。
偶尔运行 cargo update 是个好主意 - 这将更新程序使用的库。
你好,Rust - RLTK 风格!
继续并替换 src\main.rs 的内容为:
use rltk::{Rltk, GameState}; struct State {} impl GameState for State { fn tick(&mut self, ctx : &mut Rltk) { ctx.cls(); ctx.print(1, 1, "Hello Rust World"); } } fn main() -> rltk::BError { use rltk::RltkBuilder; let context = RltkBuilder::simple80x50() .with_title("Roguelike Tutorial") .build()?; let gs = State{ }; rltk::main_loop(context, gs) }
现在创建一个名为 resources 的新文件夹。RLTK 需要一些文件才能运行,我们将它们放在这里。下载 resources.zip,并将其解压到此文件夹中。注意要有 resources/backing.fs(等等)而不是 resources/resources/backing.fs。
保存,然后返回终端。输入 cargo run,你将会看到一个显示 Hello Rust 的控制台窗口。

如果你是 Rust 的新手,你可能想知道Hello Rust代码到底做了什么,以及为什么它在那里——所以我们花点时间来了解一下。
- 第一行相当于 C++的
#include或 C#的using。它只是告诉编译器我们将需要来自命名空间rltk的Rltk和GameState类型。以前你需要在这里添加一个额外的extern crate行,但最新版本的 Rust 现在可以为你解决这个问题。 - 使用
struct State{},我们正在创建一个新的结构。结构类似于 Pascal 中的记录,或许多其他语言中的类:你可以在其中存储一堆数据,还可以将“方法”(函数)附加到它们上。在这种情况下,我们实际上不需要任何数据——我们只需要一个地方来附加代码。如果你想了解更多关于结构的信息,这是 Rust Book 中关于该主题的章节 impl GameState for State相当冗长!我们告诉 Rust 我们的State结构实现了GameState特性。特性类似于其他语言中的接口或基类:它们为你设置了一个结构,你可以在自己的代码中实现,然后可以与提供它们的库进行交互——而无需该库知道你的代码的任何其他信息。 在这种情况下,GameState是由 RLTK 提供的一个 trait。RLTK 要求你有一个 - 它使用它来在每一帧调用你的程序。你可以在 Rust 书中的这一章 了解 trait。fn tick(&mut self, ctx : &mut Rltk)是一个 *函数 * 定义。我们在 trait 实现的作用域内,所以我们是为 trait 实现这个函数 - 因此它必须匹配 trait 所需的类型。函数是 Rust 的基本构建块,我推荐 Rust 书中的这一章。- 在这种情况下,
fn tick意味着“创建一个名为 tick 的函数”(它被称为“tick”是因为它在每一帧渲染时“滴答”;在游戏编程中,通常将每次迭代称为 tick)。 - 它没有以
-> type结尾,所以它相当于 C 中的void函数 - 它在被调用后不返回任何数据。参数也可以受益于一点解释。 &mut self表示“此函数需要访问父结构体,并且可能会更改它”(mut是“mutable”的缩写,表示它可以更改结构体内的变量——“状态”)。你也可以在结构体中有只包含&self的函数——这意味着我们可以查看结构体的内容,但不能更改它。如果你完全省略&self,该函数根本无法查看结构体——但可以像调用命名空间一样调用(你经常会在调用new函数时看到这种情况——它们为你创建结构体的新副本)。ctx: &mut Rltk表示“传入一个名为ctx的变量”(ctx是“context”的缩写)。冒号表示我们正在指定它必须是哪种类型的变量。&表示“传递一个引用”——这是一个指向现有变量副本的指针。变量不会被复制,你正在处理传入的版本;如果你进行更改,你将更改原始变量。《Rust 编程语言》对此解释得比我好。mut再次表明这是一个“可变”引用:允许你对上下文进行更改。- 最后
Rltk是你接收的变量的类型。在这种情况下,它是一个在RLTK库中定义的struct,提供了各种可以对屏幕执行的操作。
- 在这种情况下,
ctx.cls();表示“调用变量ctx提供的cls函数。cls是“清除屏幕”的常见缩写——我们告诉我们的上下文它应该清除虚拟终端。除非你特别不想这样做,否则在每一帧的开始这样做是个好主意。ctx.print(1, 1, "Hello Rust World");要求上下文在位置 (1,1) 处打印“Hello Rust World”。- 现在我们到了
fn main()。每个程序都有一个main函数:它告诉操作系统从哪里开始运行程序。 -
是一个从#![allow(unused)] fn main() { use rltk::RltkBuilder; let context = RltkBuilder::simple80x50() .with_title("Roguelike Tutorial") .build()?; }struct内部调用函数的例子——其中该结构不接受“self”函数。在其他语言中,这被称为构造函数。我们正在调用函数simple80x50(这是 RLTK 提供的一个构建器,用于创建一个 80 个字符宽、50 个字符高的终端。窗口标题是“Roguelike 教程”。 let gs = State{ };是一个变量赋值的例子(参见The Rust Book)。我们正在创建一个名为gs的新变量(代表“游戏状态”),并将其设置为我们上面定义的State结构的副本。rltk::main_loop(context, gs)调用rltk命名空间中的一个名为main_loop的函数。它需要我们之前创建的context和GameState- 所以我们传递这些参数。RLTK 试图简化运行 GUI/游戏应用程序的复杂性,并提供这个包装器。该函数现在接管程序的控制,并且每次程序“滴答”时(即完成一个周期并进入下一个周期)都会调用你的tick函数(见上文)。这可以每秒发生 60 次或更多次!
希望这些解释有些作用!
使用教程
你可能想在不全部输入的情况下玩转教程代码!好消息是它已经上传到 GitHub 供你查阅。你需要安装git(RustUp 应该已经帮你解决了这个问题)。选择你想存放教程的位置,并打开一个终端:
cd <路径到教程> git clone https://github.com/thebracket/rustrogueliketutorial .
过了一会儿,这将下载完整的教程(包括本书的源代码!)。它的布局如下(这并不完整!):
───book
├───chapter-01-hellorust
├───chapter-02-helloecs
├───chapter-03-walkmap
├───chapter-04-newmap
├───chapter-05-fov
├───resources
├───src
这里有什么?
book文件夹包含本书的源代码。除非你想纠正我的拼写,否则可以忽略它!- 每个章节的示例代码包含在
chapter-xy-name文件夹中;例如,chapter-01-hellorust。 src文件夹包含一个简单的脚本,提醒你在运行任何内容之前切换到章节文件夹。resources包含你为此示例下载的 ZIP 文件的内容。所有章节文件夹都预配置为使用此内容。Cargo.toml设置为包含所有教程作为“工作区条目”——它们共享依赖项,因此不会每次使用时都重新下载所有内容,占用整个驱动器。
要运行一个示例,打开您的终端并:
cd <你存放教程的位置> cd chapter-01-hellorust
cargo run
如果您使用的是 Visual Studio Code,您可以使用 文件 -> 打开文件夹 来打开您检出的整个目录。使用内置终端,您可以简单地 cd 到每个示例并 cargo run 它。
访问教程源代码
你可以在https://github.com/thebracket/rustrogueliketutorial获取所有教程的源代码。
更新教程
我经常更新这个教程——添加章节、修复问题等。你将定期想要打开教程目录,并输入 git pull。这会告诉 git(源代码管理器)前往 Github 仓库并查找新的内容。然后它会下载所有更改的内容,你再次拥有最新的教程。
更新您的项目
你可能会发现 rltk_rs 或其他包已经更新,而你希望获得最新版本。从你的项目文件夹中,你可以输入 cargo update 来更新所有内容。你可以输入 cargo update --dryrun 来查看它将要更新的内容,而不会更改任何内容(人们经常更新他们的 crates,所以这可能是一个很长的列表!)。
更新 Rust 本身
我不推荐在 Visual Studio Code 或其他 IDE 内部运行这个,但如果你想确保你拥有最新版本的 Rust(以及相关工具),你可以输入 rustup self update。这将更新 Rust 更新工具(我知道这听起来有点递归)。然后你可以输入 rustup update 并安装所有工具的最新版本。
获取帮助
有多种方式可以获得帮助:
- 如果有任何问题、改进建议或希望我添加的内容,请随时联系我(我在 Twitter 上的用户名是
@herberticus)。 - /r/rust上的热心人士在 Rust 语言问题上非常有帮助。
- /r/roguelikedev上的热心人士在 Roguelike 问题上非常有帮助。他们的 Discord 也很活跃。 运行本章的示例与 Web Assembly,在您的浏览器中(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
第二章 - 实体和组件
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
本章将介绍一个实体组件系统(ECS)的全部内容,这将成为本教程其余部分的基础。Rust 有一个非常好的 ECS,称为 Specs - 本教程将向您展示如何使用它,并尝试演示一些早期使用它的好处。
关于实体和组件
如果你以前做过游戏,你可能已经习惯了面向对象的设计(这在原始的 Python libtcod教程中非常常见,这个教程也启发了我们)。面向对象(OOP)设计并没有什么真正的错误——但游戏开发者已经逐渐远离了它,主要是因为当你开始扩展游戏时,它可能会变得非常混乱,超出了你最初的设计想法。
你可能已经见过这样的“类层次结构”,例如这个简化的:
BaseEntity
Monster
MeleeMob
OrcWarrior
ArcherMob
OrcArcher
你可能会有比这更复杂的东西,但它作为一个例子是有效的。BaseEntity 将包含代码/数据,以便在地图上显示为一个实体,Monster 表示它是一个坏人,MeleeMob 将包含寻找近战目标、接近并杀死它们的逻辑。同样,ArcherMob 将尝试保持最佳距离并使用远程武器从安全距离射击。这种分类的问题在于它可能具有限制性,而且在你意识到之前——你已经开始为更复杂的组合编写单独的类。例如,如果我们设计一个既能近战又能射箭的兽人——如果你完成了 与绿皮交朋友 任务,它可能会变得友好?你可能会将所有这些逻辑组合到一个特殊情况类中。这行得通——而且很多游戏都是这样发布的——但如果有一种更简单的方法呢?
基于实体组件的设计试图消除层次结构,转而实现一组描述你想要的“组件”。一个“实体”是一个东西——任何东西,真的。一个兽人,一只狼,一瓶药水,一个虚幻的硬盘格式化幽灵——任何你想要的。它也非常简单:几乎只是一个识别号码。魔力来自于实体能够拥有你想要添加的任意数量的组件。组件只是数据,按你想要赋予实体的任何属性分组。
例如,你可以使用以下组件构建相同的一组生物:Position、Renderable、Hostile、MeleeAI、RangedAI和某种战斗统计组件(用于描述它们的武器、生命值等)。一个兽人战士需要一个位置,这样你知道它们在哪里,一个可渲染组件,这样你知道如何绘制它们。它是敌对的,所以你标记为敌对。给它一个近战 AI 和一组游戏统计数据,你就有了让它接近玩家并试图攻击玩家所需的一切。一个弓箭手可能是同样的东西,但用远程 AI 替换近战 AI。一个混合型可以保留所有组件,但如果需要自定义行为,可以同时拥有两种 AI 或额外的 AI。如果你的兽人变得友好,你可以移除敌对组件,并添加一个友好组件。
换句话说:组件就像你的继承树,但不是通过继承特性,而是通过添加组件直到它实现你想要的功能来组合它们。这通常被称为“组合”。
ECS 中的“S”代表“系统”。系统是一段代码,从实体/组件列表中收集数据并对其进行处理。它实际上与继承模型非常相似,但在某些方面它是“反向”的。例如,在 OOP 系统中,绘制通常是:对于每个 BaseEntity,调用该实体的 Draw 命令。在 ECS 系统中,它将是获取所有具有位置和可渲染组件的实体,并使用该数据绘制它们。
对于小型游戏,ECS 常常让人觉得它只是在你的代码中增加了一些额外的输入。确实如此。你在一开始就承担了额外的工作,以便以后的生活更轻松。
这有很多需要消化的内容,所以我们来看一个简单的例子,说明 ECS 如何让你的生活更轻松一些。 了解 ECS 只是处理组合的一种方式很重要。还有很多其他的方法,实际上并没有正确的答案。稍微搜索一下,你可以找到很多不同的方法来处理 ECS。有很多面向对象的方法。有很多“自由函数”的方法。它们都有优点,并且可以为你所用。在这本书中,我采用了实体-组件的方法,但还有很多其他的方法可以达到目的。随着经验的积累,你会发现一种适合你的方法!我的建议是:如果有人告诉你某种方法是“正确的”,忽略他们——编程是创造出有效的东西的艺术,而不是追求纯粹的探索!
在项目中包含规格
首先,我们要告诉 Cargo 我们将使用 Specs。打开你的 Cargo.toml 文件,并将 dependencies 部分修改为如下内容:
[dependencies]
rltk = { version = "0.8.0" }
specs = "0.16.1"
specs-derive = "0.4.1"
这很简单:我们告诉 Rust 我们仍然想使用 RLTK,并且我们还请求了 specs(版本号在撰写时是最新的;你可以通过输入 cargo search specs 来检查新的版本)。我们还添加了 specs-derive - 它提供了一些辅助代码来减少你必须编写的样板代码量。
在 main.rs 顶部我们添加了几行代码:
#![allow(unused)] fn main() { use rltk::{GameState, Rltk, RGB, VirtualKeyCode}; use specs::prelude::*; use std::cmp::{max, min}; use specs_derive::Component; }
use rltk:: 是简写;你可以每次想要控制台时都输入 rltk::Console;这告诉 Rust 我们只想输入 Console。同样,use specs::prelude::* 这一行是为了我们不必不断地输入 specs::prelude::World 而只需输入 World。
旧的 Rust 需要一个看起来很吓人的
macro_use调用。你不再需要那个了:你可以直接使用宏。
我们需要从 Specs 的 derive 组件中获取派生:所以我们添加 use specs_derive::Component;。
定义一个位置组件
我们将构建一个小演示,使用 ECS 将字符放在屏幕上并移动它们。一个基本的部分是定义一个position,以便实体知道它们的位置。我们将保持简单:位置只是屏幕上的一个 X 和 Y 坐标。
因此,我们定义一个struct(这些类似于 C 中的结构体,Pascal 中的记录等——一组数据存储在一起。请参阅《Rust 编程语言》关于结构的章节):
#![allow(unused)] fn main() { struct Position { x: i32, y: i32, } }
非常简单!一个Position组件有一个 x 和 y 坐标,作为 32 位整数。我们的Position结构被称为POD——即“普通旧数据”的缩写。也就是说,它只是数据,没有任何逻辑。这是“纯”ECS(实体组件系统)组件的常见主题:它们只是数据,没有关联的逻辑。逻辑将在其他地方实现。使用这种模型的有两个原因:它将所有“做某事”的代码放在“系统”中(即跨组件和实体运行的代码),以及性能——将所有位置放在内存中且没有重定向是非常快的。
现在,你可以使用 Position,但几乎没有办法帮助存储它们或分配给任何人——所以我们需要告诉 Specs 这是一个组件。Specs 为此提供了 很多 选项,但我们希望保持简单。没有 specs-derive 帮助的时候这看起来像这样:
#![allow(unused)] fn main() { struct Position { x: i32, y: i32, } impl Component for Position { type Storage = VecStorage<Self>; } }
当你的游戏完成时,你可能会有很多组件——所以需要大量的输入。不仅如此,还需要一遍又一遍地输入相同的内容——这可能会变得混乱。幸运的是,specs-derive 提供了一种更简单的方法。你可以用以下代码替换之前的代码:
#![allow(unused)] fn main() { #[derive(Component)] struct Position { x: i32, y: i32, } }
这是做什么的?#[derive(x)] 是一个宏,它表示“从我的基本数据中,请派生出所需的样板代码以实现x”;在这种情况下,x 是一个 Component。宏会为你生成额外的代码,这样你就不必为每个组件手动输入这些代码。它使得使用组件变得简单方便!之前的 #[macro_use] use specs_derive::Component; 就是在利用这一点;派生宏 是一种特殊的宏,它会代表你为一个结构实现额外的功能——节省大量的输入。
定义一个可渲染组件
将角色显示在屏幕上的第二部分是*我们应该绘制什么角色,以及用什么颜色?*为了处理这个问题,我们将创建第二个组件——Renderable。它将包含前景色、背景色和字形(例如@)以进行渲染。因此,我们将创建第二个组件结构:
#![allow(unused)] fn main() { #[derive(Component)] struct Renderable { glyph: rltk::FontCharType, fg: RGB, bg: RGB, } }
RGB 来自 RLTK,代表一种颜色。这就是为什么我们有 use rltk::{... RGB} 语句——否则,我们每次都要输入 rltk::RGB——节省按键。再次强调,这是一个 普通旧数据 结构,我们使用 derive 宏来添加组件存储信息,而无需全部输入。
世界和注册
所以现在我们有两个组件类型,但没有地方放置它们并不是很有用!规格要求你在启动时注册你的组件。你用什么注册它?一个World!
一个 World 是一个 ECS,由 Rust 的 Specs crate 提供。如果你愿意,可以有多个,但我们暂时不会讨论这个。我们将扩展我们的 State 结构,以便有一个地方存储世界:
#![allow(unused)] fn main() { struct State { ecs: World } }
现在在 main 中,当我们创建世界时 - 我们会将其放入一个 ECS 中:
#![allow(unused)] fn main() { let mut gs = State { ecs: World::new() }; }
注意 World::new() 是另一个 构造函数 - 它是 World 类型中的一个方法,但没有引用 self。因此,它不能用于现有的 World 对象 - 它只能创建新的对象。这是 Rust 中随处可见的模式,因此熟悉它是很有必要的。《Rust 编程语言》有一节专门讨论这个主题。
接下来要做的是告诉 ECS 我们创建的组件。我们在创建世界后立即执行此操作:
#![allow(unused)] fn main() { gs.ecs.register::<Position>(); gs.ecs.register::<Renderable>(); }
这会告诉我们的World查看我们提供给它的类型,并进行一些内部魔法来为每个类型创建存储系统。Specs 使这变得容易;只要它实现了Component,你就可以将任何你喜欢的东西作为组件放入!
创建实体
现在我们有了一个World,它知道如何存储Position和Renderable组件。仅仅让这些组件存在并不能帮助我们,除了提供结构上的指示。为了使用它们,它们需要附加到游戏中的某个东西上。在 ECS 世界中,这个东西被称为实体。实体非常简单;它们只不过是一个识别号码,告诉 ECS 一个实体存在。它们可以附加任何组合的组件。在这种情况下,我们将创建一个知道自己在屏幕上的位置,并且知道如何在屏幕上表示的实体。
我们可以创建一个同时具有 Renderable 和 Position 组件的实体,如下所示:
#![allow(unused)] fn main() { gs.ecs .create_entity() .with(Position { x: 40, y: 25 }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), }) .build(); }
这做了什么,是它告诉我们的World(在gs中的ecs - 我们的游戏状态)我们想要一个新的实体。该实体应该有一个位置(我们选择了控制台的中间),并且我们希望它可以用黑色背景上的黄色@符号渲染。这非常简单;我们甚至没有存储实体(如果我们愿意,我们可以存储) - 我们只是告诉世界它在那里!
注意,我们使用了一个有趣的布局:许多函数不以;结束语句,而是使用许多.调用另一个函数。这被称为构建器模式,在 Rust 中非常常见。以这种方式组合函数称为方法链(方法是结构体内部的函数)。它之所以有效,是因为每个函数都返回自身的副本——因此每个函数依次运行,将自己作为链中下一个方法的持有者传递。因此,在这个例子中,我们从create_entity调用开始——它返回一个新的、空的实体。在该实体上,我们调用with——它将一个组件附加到它上面。这反过来返回部分构建的实体——因此我们可以再次调用with来添加Renderable组件。最后,.build()接受组装好的实体并完成困难的部分——实际上将所有不同的部分组合到 ECS 的正确部分中。
你可以很容易地添加更多的实体,如果你愿意的话。我们就这样做吧:
#![allow(unused)] fn main() { for i in 0..10 { gs.ecs .create_entity() .with(Position { x: i * 7, y: 20 }) .with(Renderable { glyph: rltk::to_cp437('☺'), fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), }) .build(); } }
这是我们第一次在教程中调用 for 循环!如果你使用过其他编程语言,这个概念会很熟悉:将 i 设置为从 0 到 9 的每个值来运行循环。等等 - 你说 9?Rust 的范围是 独占 的 - 它们不包括范围中的最后一个数字!这是为了熟悉像 C 这样的语言,它们通常会写 for (i=0; i<10; ++i)。如果你实际上 想要 一直到最后一个数字(即 0 到 10),你会写一个相当神秘的 for i in 0..=10。《Rust 编程语言》提供了一个很好的入门,帮助理解 Rust 中的控制流。
你会注意到我们将它们放在不同的位置(每 7 个字符,10 次),并且我们将@改为☺——一个笑脸(to_cp437是 RLTK 提供的一个辅助工具,允许你输入/粘贴 Unicode 并获得旧 DOS/CP437 字符集的等效成员。你可以将to_cp437('☺')替换为1以达到相同的效果)。你可以在这里找到可用的字形。
迭代实体 - 一个通用的渲染系统
所以我们现在有 11 个实体,具有不同的渲染特性和位置。用这些数据做点什么是个好主意!在我们的tick函数中,我们将调用绘制“Hello Rust”替换为以下内容:
#![allow(unused)] fn main() { let positions = self.ecs.read_storage::<Position>(); let renderables = self.ecs.read_storage::<Renderable>(); for (pos, render) in (&positions, &renderables).join() { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph); } }
这是做什么的?let positions = self.ecs.read_storage::<Position>(); 向 ECS 请求读取它用于存储Position组件的容器的访问权限。同样,我们请求读取Renderable存储的访问权限。只有在同时拥有这两个组件时,绘制角色才有意义——你需要一个Position来知道绘制的位置,以及Renderable来知道绘制的内容!你可以在The Specs Book中了解更多关于这些存储的信息。重要的是read_storage——我们请求对用于存储每种类型组件的结构进行只读访问。
幸运的是,Specs 支持我们使用:
#![allow(unused)] fn main() { for (pos, render) in (&positions, &renderables).join() { }
这行代码表示join位置和可渲染对象;类似于数据库连接,它只返回同时具有两者的实体。然后使用 Rust 的“解构”将每个结果(每个具有这两个组件的实体对应一个结果)放置。因此,对于for循环的每次迭代 - 你都会得到属于同一实体的两个组件。这足以绘制它!
join 函数返回一个迭代器。《Rust 编程语言》有一章节详细介绍了迭代器。在 C++ 中,迭代器提供了一个 begin、next 和 end 函数 - 你可以使用它们在集合中的元素之间移动。Rust 扩展了相同的概念,只是更加强大:只要你愿意,几乎任何东西都可以变成迭代器。迭代器与 for 循环配合得非常好 - 你可以将任何迭代器作为 for x in iterator 循环的目标。我们之前讨论的 0..10 实际上是一个范围 - 并为 Rust 提供了一个迭代器来导航。
这里另一个有趣的事情是括号。在 Rust 中,当你用括号包裹变量时,你正在创建一个元组。这些只是变量的集合,组合在一起——但不需要为此情况专门创建一个结构体。你可以通过数字访问(mytuple.0、mytuple.1等)单独访问它们,以获取每个字段,或者你可以解构它们。(one, two) = (1, 2) 将变量 one 设置为 1,将变量 two 设置为 2。这就是我们在这里所做的:join 迭代器返回包含 Position 和 Renderable 组件的元组作为 .0 和 .1。由于输入这些内容既丑陋又不清晰,我们将它们解构为命名的变量 pos 和 render。这在一开始可能会令人困惑,所以如果你有困难,我推荐阅读 Rust By Example 的元组部分。
#![allow(unused)] fn main() { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph); }
我们为每个同时具有 Position 和 Renderable 组件的实体运行此操作。join 方法保证传递给我们的这两个组件属于同一个实体。任何只有一个组件而没有另一个组件的实体都不会包含在我们返回的数据中。
ctx 是 RLTK 的实例,当 tick 运行时传递给我们。它提供了一个名为 set 的函数,该函数将单个终端字符设置为您选择的字形/颜色。因此,我们将来自 pos(该实体的 Position 组件)的数据和来自 render(该实体的 Renderable 组件)的颜色/字形传递给它。
有了这个设置,任何同时具有Position和Renderable的实体都会被渲染到屏幕上!你可以添加任意多个,它们都会被渲染。移除其中一个组件或另一个组件,它们就不会被渲染(例如,如果一个物品被捡起,你可能会移除它的Position组件——并添加另一个指示它在你背包中的组件;更多内容将在后续教程中介绍)
渲染 - 完整代码
如果你正确地输入了所有内容,你的 main.rs 现在看起来像这样:
use rltk::{GameState, Rltk, RGB}; use specs::prelude::*; use std::cmp::{max, min}; use specs_derive::Component; #[derive(Component)] struct Position { x: i32, y: i32, } #[derive(Component)] struct Renderable { glyph: rltk::FontCharType, fg: RGB, bg: RGB, } struct State { ecs: World } impl GameState for State { fn tick(&mut self, ctx : &mut Rltk) { ctx.cls(); let positions = self.ecs.read_storage::<Position>(); let renderables = self.ecs.read_storage::<Renderable>(); for (pos, render) in (&positions, &renderables).join() { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph); } } } fn main() -> rltk::BError { use rltk::RltkBuilder; let context = RltkBuilder::simple80x50() .with_title("Roguelike Tutorial") .build()?; let mut gs = State { ecs: World::new() }; gs.ecs.register::<Position>(); gs.ecs.register::<Renderable>(); gs.ecs .create_entity() .with(Position { x: 40, y: 25 }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), }) .build(); for i in 0..10 { gs.ecs .create_entity() .with(Position { x: i * 7, y: 20 }) .with(Renderable { glyph: rltk::to_cp437('☺'), fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), }) .build(); } rltk::main_loop(context, gs) }
运行它(使用 cargo run)将得到以下内容:

一个示例系统 - 随机移动
这个示例展示了如何让一个 ECS 渲染一组不同的实体。继续尝试创建实体吧——你可以用这个做很多事情!不幸的是,这相当无聊——什么都没有动!让我们稍微改进一下,让它看起来像一个射击场。
首先,我们将创建一个名为 LeftMover 的新组件。拥有此组件的实体表明它们非常喜欢向左移动。该组件定义非常简单;像这样没有数据的组件称为“标记组件”。我们将它与其他组件定义放在一起:
#![allow(unused)] fn main() { #[derive(Component)] struct LeftMover {} }
现在我们需要告诉 ECS 使用这种类型。在我们的其他register调用中,我们添加:
#![allow(unused)] fn main() { gs.ecs.register::<LeftMover>(); }
现在,让我们只让红色的笑脸向左移动。所以它们的定义变为:
#![allow(unused)] fn main() { for i in 0..10 { gs.ecs .create_entity() .with(Position { x: i * 7, y: 20 }) .with(Renderable { glyph: rltk::to_cp437('☺'), fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), }) .with(LeftMover{}) .build(); } }
注意我们添加了一行:.with(LeftMover{}) - 这就是为这些实体添加一个组件所需的全部内容(而不是黄色的 @)。
现在要实际让他们移动。我们将定义我们的第一个系统。系统是将实体/组件逻辑放在一起并独立运行的方法。有很多复杂的灵活性可用,但我们将保持简单。以下是我们 LeftWalker 系统所需的一切:
#![allow(unused)] fn main() { struct LeftWalker {} impl<'a> System<'a> for LeftWalker { type SystemData = (ReadStorage<'a, LeftMover>, WriteStorage<'a, Position>); fn run(&mut self, (lefty, mut pos) : Self::SystemData) { for (_lefty,pos) in (&lefty, &mut pos).join() { pos.x -= 1; if pos.x < 0 { pos.x = 79; } } } } }
这并不像我希望的那样好/简单,但当你理解它时,它是有意义的。让我们一点一点地来看:
struct LeftWalker {}只是定义了一个空结构体——用于附加逻辑的地方。impl<'a> System<'a> for LeftWalker表示我们正在为LeftWalker结构体实现 Specs 的System特性。'a是生命周期说明符:系统表示它使用的组件必须存在足够长的时间以供系统运行。目前,不必过于担心这一点。如果你感兴趣,Rust 书籍可以澄清一些内容。type SystemData正在定义一个类型以告诉 Specs 系统需要什么。在这种情况下,需要对LeftMover组件的读取访问权限,以及对Position组件的写入访问权限(因为它更新它们)。你可以在这里混合搭配你需要的内容,我们将在后面的章节中看到。fn run是实际的特性实现,由impl System要求。它接受自身和我们定义的SystemData。- for 循环是系统的简写,用于在渲染系统中进行的相同迭代:它将对每个同时具有
LeftMover和Position的实体运行一次。 注意,我们在LeftMover变量名前加了一个下划线:我们实际上从不使用它,只是要求实体有一个。下划线告诉 Rust“我们知道我们不使用它,这不是一个错误!”并且在我们每次编译时停止警告我们。 - 循环的主体非常简单:我们从位置组件中减去一,如果它小于零,我们就回到屏幕的右侧。
注意,这与我们编写渲染代码的方式非常相似——但不是调用到ECS 中,而是 ECS 系统调用到我们的函数/系统中。使用哪种方式可能是一个艰难的判断。如果你的系统只是需要从 ECS 获取数据,那么将它放在系统中是正确的。如果它还需要访问程序的其他部分,那么可能最好在外部实现——调用进去。
既然我们已经编写了我们的系统,我们需要能够使用它。我们将在我们的State中添加一个run_systems函数:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut lw = LeftWalker{}; lw.run_now(&self.ecs); self.ecs.maintain(); } } }
这相对简单:
impl State表示我们希望为State实现功能。fn run_systems(&mut self)表示我们正在定义一个函数,并且它需要可变(即允许更改)访问self;这意味着它可以使用self.关键字访问其State实例中的数据。let mut lw = LeftWalker{}创建一个新的(可更改的)LeftWalker系统实例。lw.run_now(&self.ecs)告诉系统运行,并告诉它如何找到 ECS。self.ecs.maintain()告诉 Specs 如果有任何系统排队更改,它们现在应该应用到世界中。
最后,我们实际上想要运行我们的系统。在 tick 函数中,我们添加:
#![allow(unused)] fn main() { self.run_systems(); }
好处是这会运行我们注册到调度器中的所有系统;因此,随着我们添加更多系统,我们不必担心调用它们(甚至不必担心按正确的顺序调用它们)。有时你仍然需要比调度器更多的访问权限;我们的渲染器不是一个系统,因为它需要来自 RLTK 的Context(我们将在未来的章节中改进这一点)。
你的代码现在看起来像这样:
use rltk::{GameState, Rltk, RGB}; use specs::prelude::*; use std::cmp::{max, min}; use specs_derive::Component; #[derive(Component)] struct Position { x: i32, y: i32, } #[derive(Component)] struct Renderable { glyph: rltk::FontCharType, fg: RGB, bg: RGB, } #[derive(Component)] struct LeftMover {} struct State { ecs: World, } impl GameState for State { fn tick(&mut self, ctx : &mut Rltk) { ctx.cls(); self.run_systems(); let positions = self.ecs.read_storage::<Position>(); let renderables = self.ecs.read_storage::<Renderable>(); for (pos, render) in (&positions, &renderables).join() { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph); } } } struct LeftWalker {} impl<'a> System<'a> for LeftWalker { type SystemData = (ReadStorage<'a, LeftMover>, WriteStorage<'a, Position>); fn run(&mut self, (lefty, mut pos) : Self::SystemData) { for (_lefty,pos) in (&lefty, &mut pos).join() { pos.x -= 1; if pos.x < 0 { pos.x = 79; } } } } impl State { fn run_systems(&mut self) { let mut lw = LeftWalker{}; lw.run_now(&self.ecs); self.ecs.maintain(); } } fn main() -> rltk::BError { use rltk::RltkBuilder; let context = RltkBuilder::simple80x50() .with_title("Roguelike Tutorial") .build()?; let mut gs = State { ecs: World::new() }; gs.ecs.register::<Position>(); gs.ecs.register::<Renderable>(); gs.ecs.register::<LeftMover>(); gs.ecs .create_entity() .with(Position { x: 40, y: 25 }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), }) .build(); for i in 0..10 { gs.ecs .create_entity() .with(Position { x: i * 7, y: 20 }) .with(Renderable { glyph: rltk::to_cp437('☺'), fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), }) .with(LeftMover{}) .build(); } rltk::main_loop(context, gs) }
如果你运行它(使用 cargo run),红色的笑脸会向左移动,而 @ 则在一旁观看。

移动玩家
最后,让我们使用键盘控制来移动@。为了知道哪个实体是玩家,我们将创建一个新的标签组件:
#![allow(unused)] fn main() { #[derive(Component, Debug)] struct Player {} }
我们将添加到注册中:
#![allow(unused)] fn main() { gs.ecs.register::<Player>(); }
并将其添加到玩家的实体中:
#![allow(unused)] fn main() { gs.ecs .create_entity() .with(Position { x: 40, y: 25 }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), }) .with(Player{}) .build(); }
现在我们实现一个新功能,try_move_player:
#![allow(unused)] fn main() { fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) { let mut positions = ecs.write_storage::<Position>(); let mut players = ecs.write_storage::<Player>(); for (_player, pos) in (&mut players, &mut positions).join() { pos.x = min(79 , max(0, pos.x + delta_x)); pos.y = min(49, max(0, pos.y + delta_y)); } } }
借鉴我们之前的经验,我们可以看到这获得了对 Player 和 Position 的写访问权限。然后它将两者连接起来,确保它只对同时具有这两种组件类型的实体起作用——在这种情况下,只有玩家。然后它将 delta_x 加到 x 上,将 delta_y 加到 y 上——并进行一些检查,以确保你没有试图离开屏幕。
我们将添加第二个函数来读取 RLTK 提供的键盘信息:
#![allow(unused)] fn main() { fn player_input(gs: &mut State, ctx: &mut Rltk) { // Player movement match ctx.key { None => {} // Nothing happened Some(key) => match key { VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs), VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs), VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs), VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs), _ => {} }, } } }
这里有很多我们以前没见过的功能!context正在提供关于一个键的信息——但用户可能按也可能不按!Rust 为此提供了一个特性,称为 Option 类型。Option 类型有两种可能的值:None(没有数据),或 Some(x)——表示这里有数据,包含在里面。
上下文提供了一个key变量。它是一个枚举——即一个可以从一组预定义值(在这种情况下,键盘上的键)中保存值的变量。Rust 枚举非常强大,实际上也可以保存值——但我们暂时不会使用这个功能。
所以要从 Option 中取出数据,我们需要 解包 它。有一个叫做 unwrap 的函数——但如果你在没有数据时调用它,你的程序会崩溃!因此我们将使用 Rust 的 match 命令来查看内部。匹配是 Rust 的一大优势,我强烈推荐 Rust 书籍中关于它的章节,或者如果你更喜欢通过示例学习,可以查看 Rust by Example 部分。
所以我们调用 match ctx.key - 而 Rust 期望我们提供一个可能匹配的列表。在 ctx.key 的情况下,只有两种可能的值:Some 或 None。None => {} 这一行表示“匹配 ctx.key 没有数据的情况” - 并运行一个空块。Some(key) 是另一种选择;有一些数据 - 我们会要求 Rust 将其作为名为 key 的变量提供给我们(你可以随意命名它)。
然后我们再次匹配,这次是根据键。我们为每个想要处理的情况写了一行代码:VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs)表示如果key等于VirtualKeyCode::Left(VirtualKeyCode是枚举类型的名称),我们应该调用try_move_player函数并传入(-1, 0)。我们对所有四个方向重复这一操作。_ => {}看起来有点奇怪;_表示其他任何东西。所以我们告诉 Rust,任何其他键码都可以在这里忽略。Rust 非常严谨:如果你没有指定每一种可能的枚举,它会给出编译错误!通过包含默认情况,我们不需要输入每一种可能的按键。
此函数获取当前游戏状态和上下文,查看上下文中的key变量,并在按下相关移动键时调用适当的移动命令。最后,我们将其添加到tick中:
#![allow(unused)] fn main() { player_input(self, ctx); }
如果你运行你的程序(使用 cargo run),你现在有一个键盘控制的 @ 符号,而笑脸则向左移动!

第二章的最终代码
这个完整示例的源代码可以在chapter-02-helloecs中找到,可以直接运行。它看起来像这样:
use rltk::{GameState, Rltk, RGB, VirtualKeyCode}; use specs::prelude::*; use std::cmp::{max, min}; use specs_derive::Component; #[derive(Component)] struct Position { x: i32, y: i32, } #[derive(Component)] struct Renderable { glyph: rltk::FontCharType, fg: RGB, bg: RGB, } #[derive(Component)] struct LeftMover {} #[derive(Component, Debug)] struct Player {} struct State { ecs: World } fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) { let mut positions = ecs.write_storage::<Position>(); let mut players = ecs.write_storage::<Player>(); for (_player, pos) in (&mut players, &mut positions).join() { pos.x = min(79 , max(0, pos.x + delta_x)); pos.y = min(49, max(0, pos.y + delta_y)); } } fn player_input(gs: &mut State, ctx: &mut Rltk) { // Player movement match ctx.key { None => {} // Nothing happened Some(key) => match key { VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs), VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs), VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs), VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs), _ => {} }, } } impl GameState for State { fn tick(&mut self, ctx : &mut Rltk) { ctx.cls(); player_input(self, ctx); self.run_systems(); let positions = self.ecs.read_storage::<Position>(); let renderables = self.ecs.read_storage::<Renderable>(); for (pos, render) in (&positions, &renderables).join() { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph); } } } struct LeftWalker {} impl<'a> System<'a> for LeftWalker { type SystemData = (ReadStorage<'a, LeftMover>, WriteStorage<'a, Position>); fn run(&mut self, (lefty, mut pos) : Self::SystemData) { for (_lefty,pos) in (&lefty, &mut pos).join() { pos.x -= 1; if pos.x < 0 { pos.x = 79; } } } } impl State { fn run_systems(&mut self) { let mut lw = LeftWalker{}; lw.run_now(&self.ecs); self.ecs.maintain(); } } fn main() -> rltk::BError { use rltk::RltkBuilder; let context = RltkBuilder::simple80x50() .with_title("Roguelike Tutorial") .build()?; let mut gs = State { ecs: World::new() }; gs.ecs.register::<Position>(); gs.ecs.register::<Renderable>(); gs.ecs.register::<LeftMover>(); gs.ecs.register::<Player>(); gs.ecs .create_entity() .with(Position { x: 40, y: 25 }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), }) .with(Player{}) .build(); for i in 0..10 { gs.ecs .create_entity() .with(Position { x: i * 7, y: 20 }) .with(Renderable { glyph: rltk::to_cp437('☺'), fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), }) .with(LeftMover{}) .build(); } rltk::main_loop(context, gs) }
这一章有很多内容需要消化,但它提供了一个非常坚实的基础来构建。很棒的是:你现在比许多有抱负的开发者走得更远!你已经在屏幕上有实体,并且可以用键盘移动。
本章的源代码可以在此处找到这里
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
第三章 - 地图
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
本教程的其余部分将致力于制作一个 Roguelike 游戏。Rogue 于 1980 年出现,是一款文本模式的地牢探索游戏。它催生了一个完整的“roguelike”类型:程序生成的地图、在多个层次上猎取目标以及“永久死亡”(死亡后重新开始)。这个定义是许多在线争论的源头;我宁愿避免这种情况!
没有地图可探索的 Roguelike 有点无意义,所以在本章中,我们将组合一个基本地图,绘制它,并让你的玩家四处走动一下。我们从第 2 章的代码开始,但去掉了红色的笑脸(及其向左的倾向)。
定义地图瓦片
我们将从两种瓷砖类型开始:墙壁和地板。我们可以用一个enum来表示(要了解更多关于枚举的信息,《Rust 编程语言》 有一大节关于它们的内容):
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] enum TileType { Wall, Floor } }
请注意,我们已经包含了一些派生特性(这次是内置于 Rust 本身的派生宏):Copy 和 Clone。Clone 为类型添加了一个 .clone() 方法,允许以编程方式进行复制。Copy 将默认行为从赋值时移动对象改为复制对象——因此 tile1 = tile2 会使两个值都有效,而不是处于“移动后”状态。
PartialEq 允许我们使用 == 来检查两个瓦片类型是否匹配。如果我们没有派生这些特性,if tile_type == TileType::Wall 将无法编译!
构建一个简单的地图
现在我们将创建一个返回瓦片(tile)向量的函数,表示一个简单的地图。我们将使用一个大小为整个地图的向量,这意味着我们需要一种方法来确定给定 x/y 位置的数组索引。因此,首先,我们创建一个新函数xy_idx:
#![allow(unused)] fn main() { pub fn xy_idx(x: i32, y: i32) -> usize { (y as usize * 80) + x as usize } }
这很简单:它将 y 位置乘以地图宽度(80),并加上 x。这保证了每个位置一个瓦片,并且高效地在内存中映射以供从左到右阅读。
我们在这里使用了一个 Rust 函数的简写形式。注意,该函数返回一个usize(相当于 C/C++中的size_t——无论平台使用的基本大小类型是什么)——并且函数体末尾缺少一个;?任何以缺少分号的语句结尾的函数都将该行视为return语句。所以它与输入return (y as usize * 80) + x as usize相同。这来自于 Rust 作者的另一个最喜欢的语言ML——它使用了相同的简写形式。这种风格被认为是“Rustacean”(规范的 Rust;我总是想象一个带有可爱小爪子和壳的 Rust 怪物),所以我们为教程采用了这种风格。
然后我们编写一个构造函数来创建地图:
#![allow(unused)] fn main() { fn new_map() -> Vec<TileType> { let mut map = vec![TileType::Floor; 80*50]; // Make the boundaries walls // 将边界设置为墙壁 for x in 0..80 { map[xy_idx(x, 0)] = TileType::Wall; map[xy_idx(x, 49)] = TileType::Wall; } for y in 0..50 { map[xy_idx(0, y)] = TileType::Wall; map[xy_idx(79, y)] = TileType::Wall; } // Now we'll randomly splat a bunch of walls. It won't be pretty, but it's a decent illustration. // First, obtain the thread-local RNG: // 现在我们将随机散布一堆墙壁。它不会很漂亮,但这是一个不错的演示。 // 首先,获取线程本地的 RNG: let mut rng = rltk::RandomNumberGenerator::new(); for _i in 0..400 { let x = rng.roll_dice(1, 79); let y = rng.roll_dice(1, 49); let idx = xy_idx(x, y); if idx != xy_idx(40, 25) { map[idx] = TileType::Wall; } } map } }
这里有一些我们以前没有遇到过的语法,所以我们来分解一下:
fn new_map() -> Vec<TileType>指定了一个名为new_map的函数。它不接受任何参数,因此可以从任何地方调用。- 它返回一个
Vec。Vec是 Rust 中的向量(如果你熟悉 C++,它几乎与 C++ 的std::vector完全相同)。向量类似于数组(参见 这个 Rust by Example 章节),可以让你将一堆数据放入列表中并访问每个元素。与数组不同,Vec没有大小限制——并且大小可以在程序运行时改变。因此,你可以push(添加)新项目,并随着进度remove(移除)它们。Rust by Example 有一个关于向量的很棒的章节;了解它们是个好主意——它们被广泛使用。 let mut map = vec![TileType::Floor; 80*50];是一个看起来令人困惑的语句!让我们分解一下:let mut map表示“创建一个新变量”(let),“让我可以更改它”(mut)并将其命名为“map”。vec!是一个宏,另一个内置于 Rust 标准库中的宏。 感叹号是 Rust 表示“这是一个过程宏”的方式(与之前看到的派生宏相反)。过程宏像函数一样运行——它们定义了一个过程,只是大大减少了你的打字量。vec!宏在其参数中使用方括号。- 第一个参数是新向量中每个元素的值。在这种情况下,我们将创建的每个条目设置为
Floor(来自TileType枚举)。 - 第二个参数是我们应该创建多少个瓦片。它们都将设置为我们上面设置的值。在这种情况下,我们的地图是 80x50 个瓦片(4,000 个瓦片——但我们会让编译器为我们做数学运算!)。所以我们需要创建 4,000 个瓦片。
- 你可以将
vec!调用替换为for _i in 0..4000 { map.push(TileType::Floor); }。事实上,宏基本上就是这样为你做的——但让宏来做肯定打字量更少!
for x in 0..80 {是一个for 循环(见这里),就像我们在前面的例子中使用的那样。在这种情况下,我们正在迭代x从 0 到 79。 ```map[xy_idx(x, 0)] = TileType::Wall;首先调用我们上面定义的xy_idx函数来获取x, 0的向量索引。然后它索引向量,告诉它将该位置的向量条目设置为墙。我们再次对x,49进行同样的操作。- 我们做同样的事情,但循环
y从 0..49 - 并在我们的地图上设置垂直墙。 let mut rng = rltk::RandomNumberGenerator::new();调用RLTK中的RandomNumberGenerator类型的new函数,并将其分配给一个名为rng的变量。我们要求 RLTK 给我们一个新的骰子滚筒。for _i in 0..400 {与其他for循环相同,但注意_在i之前。我们实际上并不关心i的值 - 我们只是希望循环运行 400 次。如果你有一个未使用的变量,Rust 会给你一个警告;添加下划线前缀告诉 Rust 没关系,我们故意这样做的。let x = rng.roll_dice(1, 79);调用我们在第 7 步中获取的rng,并要求它提供一个从 1 到 79 的随机数。RLTK 不使用独占范围,因为它试图镜像旧的 D&D 骰子惯例,如1d20或类似。 在这种情况下,我们应该庆幸计算机不在乎发明一个 79 面的骰子的几何难度!我们还得到了一个介于 1 到 49 之间的y值。我们已经掷了想象的骰子,并在地图上找到了一个随机位置。- 我们将变量
idx(“index”的缩写)设置为我们掷出的坐标的向量索引(通过我们之前定义的xy_idx)。 if idx != xy_idx(40, 25) {检查idx是否不是正中间(我们将从那里开始,所以我们不想在墙内开始!)。- 如果不是中间,我们将随机掷出的位置设置为墙。
很简单:它在地图的外边缘放置墙壁,然后在不是玩家起点的任何地方添加 400 个随机墙壁。
让地图对世界可见
Specs 包含“资源”的概念 —— 整个 ECS 可以使用的共享数据。因此,在我们的main函数中,我们将一个随机生成的地图添加到世界中:
#![allow(unused)] fn main() { gs.ecs.insert(new_map()); }
地图现在可以从 ECS 可见的任何地方获取!现在在你的代码中,你可以使用相当繁琐的let map = self.ecs.get_mut::<Vec<TileType>>();来访问地图;它以更简单的方式提供给系统。实际上有几种方法可以获取地图的值,包括ecs.get、ecs.fetch。get_mut获取地图的“可变”(你可以更改它)引用——包装在一个可选的(以防地图不存在)。fetch跳过Option类型,直接给你一个地图。你可以在 Specs 书中了解更多信息。
绘制地图
现在我们有了一个可用的地图,我们应该把它显示在屏幕上!新的draw_map函数的完整代码如下:
#![allow(unused)] fn main() { fn draw_map(map: &[TileType], ctx : &mut Rltk) { let mut y = 0; let mut x = 0; for tile in map.iter() { // Render a tile depending upon the tile type // 根据瓦片类型渲染瓦片 match tile { TileType::Floor => { ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.')); } TileType::Wall => { ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#')); } } // Move the coordinates // 移动坐标 x += 1; if x > 79 { x = 0; y += 1; } } } }
大部分是直接的,并且使用了我们已经涉及的概念。在声明中,我们将地图作为&[TileType]而不是&Vec<TileType>传递;这允许我们选择传递地图的“切片”(部分)。我们暂时不会这样做,但这可能在以后有用。这也是一种更“地道”(即:惯用的 Rust)的做法,而且代码检查工具(clippy)会对此发出警告。如果你对切片感兴趣,《Rust 编程语言》可以教你。
否则,它利用我们存储地图的方式——行在一起,一个接一个。因此,它遍历整个地图结构,每块瓷砖的x位置加 1。如果达到地图宽度,它将x归零并将y加 1。这样我们不会重复读取整个数组——这可能会变慢。实际渲染非常简单:我们匹配瓷砖类型,并为墙壁/地板绘制一个点或一个井号。
我们还应该调用该函数!在我们的 tick 函数中,添加:
#![allow(unused)] fn main() { let map = self.ecs.fetch::<Vec<TileType>>(); draw_map(&map, ctx); }
fetch 调用是新的(我们在上面提到过)。fetch 要求你保证你知道你请求的资源确实存在——如果不存在,它会崩溃。它并不完全返回一个引用——它是一个 shred 类型,大多数情况下表现得像一个引用,但偶尔需要一点强制才能成为一个引用。我们会在需要的时候担心这个问题,但请你自己注意!
使墙壁变实
所以现在如果你运行程序(cargo run),你会看到一个绿色和灰色的地图,上面有一个黄色的@可以四处移动。不幸的是,你会很快发现玩家可以穿过墙壁!幸运的是,这很容易纠正。
为了实现这一点,我们修改了 try_move_player 以读取地图并检查目的地是否开放:
#![allow(unused)] fn main() { fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) { let mut positions = ecs.write_storage::<Position>(); let mut players = ecs.write_storage::<Player>(); let map = ecs.fetch::<Vec<TileType>>(); for (_player, pos) in (&mut players, &mut positions).join() { let destination_idx = xy_idx(pos.x + delta_x, pos.y + delta_y); if map[destination_idx] != TileType::Wall { pos.x = min(79 , max(0, pos.x + delta_x)); pos.y = min(49, max(0, pos.y + delta_y)); } } } }
let map = ... 是新的部分,它与主循环使用fetch的方式相同(这是将其存储在 ECS 中的优势 - 你可以在任何地方访问它,而不需要试图强制 Rust 允许你使用全局变量!)。我们用let destination_idx = xy_idx(pos.x + delta_x, pos.y + delta_y); 计算玩家目的地的单元格索引 - 如果不是墙,我们正常移动。
现在运行程序(cargo run),你会在地图上有一个玩家,并且可以移动,被墙壁正确阻挡。

完整的程序现在看起来像这样:
use rltk::{GameState, Rltk, RGB, VirtualKeyCode}; use specs::prelude::*; use std::cmp::{max, min}; use specs_derive::*; #[derive(Component)] struct Position { x: i32, y: i32, } #[derive(Component)] struct Renderable { glyph: rltk::FontCharType, fg: RGB, bg: RGB, } #[derive(Component, Debug)] struct Player {} #[derive(PartialEq, Copy, Clone)] enum TileType { Wall, Floor } struct State { ecs: World } pub fn xy_idx(x: i32, y: i32) -> usize { (y as usize * 80) + x as usize } fn new_map() -> Vec<TileType> { let mut map = vec![TileType::Floor; 80*50]; // Make the boundaries walls // 将边界设置为墙壁 for x in 0..80 { map[xy_idx(x, 0)] = TileType::Wall; map[xy_idx(x, 49)] = TileType::Wall; } for y in 0..50 { map[xy_idx(0, y)] = TileType::Wall; map[xy_idx(79, y)] = TileType::Wall; } // Now we'll randomly splat a bunch of walls. It won't be pretty, but it's a decent illustration. // First, obtain the thread-local RNG: // 现在我们将随机散布一堆墙壁。它不会很漂亮,但这是一个不错的演示。 // 首先,获取线程本地的 RNG: let mut rng = rltk::RandomNumberGenerator::new(); for _i in 0..400 { let x = rng.roll_dice(1, 79); let y = rng.roll_dice(1, 49); let idx = xy_idx(x, y); if idx != xy_idx(40, 25) { map[idx] = TileType::Wall; } } map } fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) { let mut positions = ecs.write_storage::<Position>(); let mut players = ecs.write_storage::<Player>(); let map = ecs.fetch::<Vec<TileType>>(); for (_player, pos) in (&mut players, &mut positions).join() { let destination_idx = xy_idx(pos.x + delta_x, pos.y + delta_y); if map[destination_idx] != TileType::Wall { pos.x = min(79 , max(0, pos.x + delta_x)); pos.y = min(49, max(0, pos.y + delta_y)); } } } fn player_input(gs: &mut State, ctx: &mut Rltk) { // Player movement // 玩家移动 match ctx.key { None => {} // Nothing happened // 没有事件发生 Some(key) => match key { VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs), VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs), VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs), VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs), _ => {} }, } } fn draw_map(map: &[TileType], ctx : &mut Rltk) { let mut y = 0; let mut x = 0; for tile in map.iter() { // Render a tile depending upon the tile type // 根据瓦片类型渲染瓦片 match tile { TileType::Floor => { ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.')); } TileType::Wall => { ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#')); } } // Move the coordinates // 移动坐标 x += 1; if x > 79 { x = 0; y += 1; } } } impl GameState for State { fn tick(&mut self, ctx : &mut Rltk) { ctx.cls(); player_input(self, ctx); self.run_systems(); let map = self.ecs.fetch::<Vec<TileType>>(); draw_map(&map, ctx); let positions = self.ecs.read_storage::<Position>(); let renderables = self.ecs.read_storage::<Renderable>(); for (pos, render) in (&positions, &renderables).join() { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph); } } } impl State { fn run_systems(&mut self) { self.ecs.maintain(); } } fn main() -> rltk::BError { use rltk::RltkBuilder; let context = RltkBuilder::simple80x50() .with_title("Roguelike Tutorial") .build()?; let mut gs = State { ecs: World::new() }; gs.ecs.register::<Position>(); gs.ecs.register::<Renderable>(); gs.ecs.register::<Player>(); gs.ecs.insert(new_map()); gs.ecs .create_entity() .with(Position { x: 40, y: 25 }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), }) .with(Player{}) .build(); rltk::main_loop(context, gs) }
本章的源代码可以在此处找到这里
在浏览器中使用 WebAssembly 运行本章示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
第四章 - 一个更有趣的地图
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
在本章中,我们将制作一个更有趣的地图。它将是基于房间的,看起来有点像早期的许多 roguelike 游戏,如 Moria,但复杂度较低。它还将为放置怪物提供一个很好的起点!
清理
我们将从清理代码开始,并使用单独的文件。随着项目的复杂性/大小增加,最好将它们保持为一组干净的文件/模块,这样我们可以快速找到所需的内容(有时还可以提高编译时间)。
如果你查看本章的源代码,你会看到我们已经将许多功能拆分到单独的文件中。当你在 Rust 中创建一个新文件时,它会自动成为一个模块。然后,你必须告诉 Rust 使用这些模块,因此main.rs增加了一些mod map和类似的语句,接着是pub use map::*。这表示“导入模块 map,然后使用并使其公共内容对其他模块可用”。
我们也把一堆struct变成了pub struct,并为它们的成员添加了pub。如果你不这样做,那么结构体将仅保留在该模块内部——你无法在代码的其他部分使用它。这类似于在类定义中添加public: C++行,并在头文件中导出类型。Rust 使其更加简洁,无需写两次!
制作更有趣的地图
我们将首先将 new_map(现在在 map.rs 中)重命名为 new_map_test。我们将停止使用它,但暂时保留它——这是一个测试我们地图代码的好方法!我们还将使用 Rust 的文档标签来发布这个函数的功能,以防我们以后忘记:
#![allow(unused)] fn main() { /// Makes a map with solid boundaries and 400 randomly placed walls. No guarantees that it won't /// look awful. pub fn new_map_test() -> Vec<TileType> { ... } }
在标准的 Rust 中,如果你在函数前加上以 /// 开头的注释,它就会变成一个函数注释。当你将鼠标悬停在函数头时,你的 IDE 会显示你的注释文本,你可以使用 Cargo 的文档功能 为你正在编写的系统制作漂亮的文档页面。如果你计划分享代码或与他人合作,这非常有用——但拥有它也是很好的!
所以现在,本着原始 libtcod 教程的精神,我们将开始制作地图。我们的目标是在随机位置放置房间,并通过走廊将它们连接起来。
制作几个矩形房间
我们将从一个新函数开始:
#![allow(unused)] fn main() { pub fn new_map_rooms_and_corridors() -> Vec<TileType> { let mut map = vec![TileType::Wall; 80*50]; map } }
这创建了一个坚固的 80x50 地图,所有瓷砖上都有墙壁——你无法移动!我们保持了函数签名,因此在main.rs中更改我们想要使用的地图只需将gs.ecs.insert(new_map_test());改为gs.ecs.insert(new_map_rooms_and_corridors());。我们再次使用vec!宏来简化我们的生活——有关其工作原理的讨论,请参见上一章。
由于该算法大量使用矩形,并且有一个 Rect 类型 - 我们将在 rect.rs 中创建一个。我们将包含一些在本章后面有用的实用函数:
#![allow(unused)] fn main() { pub struct Rect { pub x1 : i32, pub x2 : i32, pub y1 : i32, pub y2 : i32 } impl Rect { pub fn new(x:i32, y: i32, w:i32, h:i32) -> Rect { Rect{x1:x, y1:y, x2:x+w, y2:y+h} } // Returns true if this overlaps with other pub fn intersect(&self, other:&Rect) -> bool { self.x1 <= other.x2 && self.x2 >= other.x1 && self.y1 <= other.y2 && self.y2 >= other.y1 } pub fn center(&self) -> (i32, i32) { ((self.x1 + self.x2)/2, (self.y1 + self.y2)/2) } } }
这里没有什么真正新的东西,但让我们分解一下:
- 我们定义了一个名为
Rect的struct。我们添加了pub标签以使其 公开 - 它可以在该模块外部使用(通过将其放入新文件中,我们自动创建了一个代码模块;这是 Rust 内置的代码分隔方式)。在main.rs中,我们可以添加pub mod Rect来说明“我们使用Rect,并且因为我们前面加了pub,任何人都可以从我们这里获取Rect作为super::rect::Rect。这样输入不太方便,所以第二行use rect::Rect将其简化为super::Rect。 - 我们创建了一个新的 构造函数,名为
new。它使用返回简写并根据传入的x、y、width和height返回一个矩形。 - 我们定义了一个 成员 方法,
intersect。它有一个&self,这意味着它可以查看附加到的Rect- 但不能修改它(它是一个“纯”函数)。它返回一个布尔值:如果两个矩形重叠则返回true,否则返回false。 - 我们定义了
center,也是一个纯成员方法。它简单地返回矩形中心的坐标,作为一个x和y的 元组,在val.0和val.1中。
我们还将创建一个新函数来将房间应用到地图上:
#![allow(unused)] fn main() { fn apply_room_to_map(room : &Rect, map: &mut [TileType]) { for y in room.y1 +1 ..= room.y2 { for x in room.x1 + 1 ..= room.x2 { map[xy_idx(x, y)] = TileType::Floor; } } } }
注意我们使用的是 for y in room.y1 +1 ..= room.y2 - 这是一个包含范围。我们希望一直到达 y2 的值,而不是 y2-1!否则,这相对简单:使用两个 for 循环来访问房间矩形内的每个瓦片,并将该瓦片设置为 Floor。
使用这两段代码,我们可以在任何位置创建一个新的矩形,使用 Rect::new(x, y, width, height)。我们可以将其作为地板添加到地图中,使用 apply_room_to_map(rect, map)。这足以添加几个测试房间。我们的地图函数现在看起来像这样:
#![allow(unused)] fn main() { pub fn new_map_rooms_and_corridors() -> Vec<TileType> { let mut map = vec![TileType::Wall; 80*50]; let room1 = Rect::new(20, 15, 10, 15); let room2 = Rect::new(35, 15, 10, 15); apply_room_to_map(&room1, &mut map); apply_room_to_map(&room2, &mut map); map } }
如果你运行你的项目,你会看到我们现在有两个房间——没有连接在一起。
制作走廊
两个不连通的房间没什么意思,所以我们在它们之间加一条走廊。我们需要一些比较函数,所以我们必须告诉 Rust 导入它们(在 map.rs 的顶部):use std::cmp::{max, min};。min 和 max 如其名:它们返回两个值中的最小值或最大值。你可以使用 if 语句来实现同样的功能,但有些计算机会将其优化为一个简单的(快速)调用;我们让 Rust 来处理这个问题!
然后我们创建两个函数,用于水平和垂直隧道:
#![allow(unused)] fn main() { fn apply_horizontal_tunnel(map: &mut [TileType], x1:i32, x2:i32, y:i32) { for x in min(x1,x2) ..= max(x1,x2) { let idx = xy_idx(x, y); if idx > 0 && idx < 80*50 { map[idx as usize] = TileType::Floor; } } } fn apply_vertical_tunnel(map: &mut [TileType], y1:i32, y2:i32, x:i32) { for y in min(y1,y2) ..= max(y1,y2) { let idx = xy_idx(x, y); if idx > 0 && idx < 80*50 { map[idx as usize] = TileType::Floor; } } } }
然后我们在地图生成函数中添加一个调用,apply_horizontal_tunnel(&mut map, 25, 40, 23);,瞧!我们有了两个房间之间的隧道!如果你运行(cargo run)项目,你可以在两个房间之间行走——而不是撞墙。所以我们的之前的代码仍然有效,但现在看起来更像一个 Roguelike 游戏。
制作一个简单的地牢
现在我们可以用它来制作一个随机的地牢。我们将修改我们的函数如下:
#![allow(unused)] fn main() { pub fn new_map_rooms_and_corridors() -> Vec<TileType> { let mut map = vec![TileType::Wall; 80*50]; let mut rooms : Vec<Rect> = Vec::new(); const MAX_ROOMS : i32 = 30; const MIN_SIZE : i32 = 6; const MAX_SIZE : i32 = 10; let mut rng = RandomNumberGenerator::new(); for _ in 0..MAX_ROOMS { let w = rng.range(MIN_SIZE, MAX_SIZE); let h = rng.range(MIN_SIZE, MAX_SIZE); let x = rng.roll_dice(1, 80 - w - 1) - 1; let y = rng.roll_dice(1, 50 - h - 1) - 1; let new_room = Rect::new(x, y, w, h); let mut ok = true; for other_room in rooms.iter() { if new_room.intersect(other_room) { ok = false } } if ok { apply_room_to_map(&new_room, &mut map); rooms.push(new_room); } } map } }
这里有很多变化:
- 我们为最大房间数量、最小和最大房间尺寸添加了
const常量。这是我们第一次遇到const:它只是说“在开始时设置这个值,并且它永远不会改变”。这是在 Rust 中拥有全局变量的唯一简单方法;由于它们永远不会改变,它们通常甚至不存在,并且会被编译到使用它们的函数中。如果它们确实存在,因为它们不能改变,所以当多个线程访问它们时没有问题。通常设置一个命名的常量比使用“魔法数字”更清晰——也就是说,一个硬编码的值,没有真正的线索表明为什么选择这个值。 - 我们从 RLTK 获取一个
RandomNumberGenerator(这要求我们在map.rs顶部的use语句中添加内容) * 我们随机构建宽度和高度。 - 然后我们随机放置房间,使得
x和y大于 0 且小于最大地图尺寸减一。 - 我们遍历现有房间,如果新房间与已放置的房间重叠,则拒绝它。
- 如果可以,我们将其应用到房间。
- 我们将房间保存在一个向量中,尽管我们还没有使用它。
在此阶段运行项目(cargo run)将为您提供一系列随机房间,房间之间没有走廊。
将房间连接在一起
我们现在需要将房间连接起来,用走廊。我们将把这个添加到地图生成器的if ok部分:
#![allow(unused)] fn main() { if ok { apply_room_to_map(&new_room, &mut map); if !rooms.is_empty() { let (new_x, new_y) = new_room.center(); let (prev_x, prev_y) = rooms[rooms.len()-1].center(); if rng.range(0,2) == 1 { apply_horizontal_tunnel(&mut map, prev_x, new_x, prev_y); apply_vertical_tunnel(&mut map, prev_y, new_y, new_x); } else { apply_vertical_tunnel(&mut map, prev_y, new_y, prev_x); apply_horizontal_tunnel(&mut map, prev_x, new_x, new_y); } } rooms.push(new_room); } }
- 这是做什么的?它首先检查
rooms列表是否为空。如果是,那么没有之前的房间可以加入 - 所以我们忽略它。 - 它获取房间的中心,并将其存储为
new_x和new_y。 - 它获取向量中前一个房间的中心,并将其存储为
prev_x和prev_y。 - 它掷骰子,一半的时间它会先画一个水平的再画一个垂直的隧道 - 另一半时间,则相反。
现在试试 cargo run。它真的开始看起来像一个肉鸽游戏了!
放置玩家
目前,玩家总是从地图中心开始——使用新的生成器,这可能不是一个有效的起点!我们可以简单地将玩家移动到第一个房间的中心,但我们的生成器可能需要知道所有房间的位置——这样我们可以在里面放置物品——而不仅仅是玩家的位置。因此,我们将修改我们的new_map_rooms_and_corridors函数,使其也返回房间列表。所以我们更改方法签名:pub fn new_map_rooms_and_corridors() -> (Vec<Rect>, Vec<TileType>) {,并将返回语句改为(rooms, map)
我们的 main.rs 文件也需要调整,以接受新的格式。我们将 main.rs 中的 main 函数改为:
fn main() -> rltk::BError { use rltk::RltkBuilder; let context = RltkBuilder::simple80x50() .with_title("Roguelike Tutorial") .build()?; let mut gs = State { ecs: World::new() }; gs.ecs.register::<Position>(); gs.ecs.register::<Renderable>(); gs.ecs.register::<Player>(); let (rooms, map) = new_map_rooms_and_corridors(); gs.ecs.insert(map); let (player_x, player_y) = rooms[0].center(); gs.ecs .create_entity() .with(Position { x: player_x, y: player_y }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), }) .with(Player{}) .build(); rltk::main_loop(context, gs) }
这基本上是相同的,但我们从new_map_rooms_and_corridors接收到了房间列表和地图。然后我们将玩家放置在第一个房间的中心。
总结 - 并支持数字键盘和 Vi 键
现在你有一个看起来像 Rogue 类的地图,将玩家放在第一个房间,并让你使用光标键进行探索。并非每个键盘都有容易访问的光标键(有些笔记本电脑需要有趣的组合键)。许多玩家喜欢使用数字键盘进行操控,但并非每个键盘都有数字键盘——所以我们还支持文本编辑器 vi 的方向键。这使得硬核 UNIX 用户和普通玩家都感到满意。
我们暂时不会担心对角线移动。在 player.rs 中,我们将 player_input 改为如下所示:
#![allow(unused)] fn main() { pub fn player_input(gs: &mut State, ctx: &mut Rltk) { // Player movement match ctx.key { None => {} // Nothing happened Some(key) => match key { VirtualKeyCode::Left | VirtualKeyCode::Numpad4 | VirtualKeyCode::H => try_move_player(-1, 0, &mut gs.ecs), VirtualKeyCode::Right | VirtualKeyCode::Numpad6 | VirtualKeyCode::L => try_move_player(1, 0, &mut gs.ecs), VirtualKeyCode::Up | VirtualKeyCode::Numpad8 | VirtualKeyCode::K => try_move_player(0, -1, &mut gs.ecs), VirtualKeyCode::Down | VirtualKeyCode::Numpad2 | VirtualKeyCode::J => try_move_player(0, 1, &mut gs.ecs), _ => {} }, } } }
当你运行你的项目时,你应该会得到类似这样的结果:

本章的源代码可以在此处找到这里
在浏览器中使用 WebAssembly 运行本章示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
第五章 - 视野
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
我们有一张绘制精美的地图,但它显示了整个地牢!这降低了探索的实用性——如果我们已经知道所有东西的位置,为什么还要费心探索呢?本章将添加“视野范围”,并调整渲染以显示我们已经发现的地图部分。它还将把地图重构为其自己的结构,而不是仅仅是一个瓦片向量。 本章从第 4 章的代码开始。
地图重构
我们将把与地图相关的功能和数据放在一起,以保持清晰,因为我们正在制作一个越来越复杂的游戏。大部分工作是创建一个新的Map结构,并将我们的辅助函数移到其实现中。
#![allow(unused)] fn main() { use rltk::{ RGB, Rltk, RandomNumberGenerator }; use super::{Rect}; use std::cmp::{max, min}; #[derive(PartialEq, Copy, Clone)] pub enum TileType { Wall, Floor } pub struct Map { pub tiles : Vec<TileType>, pub rooms : Vec<Rect>, pub width : i32, pub height : i32 } impl Map { pub fn xy_idx(&self, x: i32, y: i32) -> usize { (y as usize * self.width as usize) + x as usize } fn apply_room_to_map(&mut self, room : &Rect) { for y in room.y1 +1 ..= room.y2 { for x in room.x1 + 1 ..= room.x2 { let idx = self.xy_idx(x, y); self.tiles[idx] = TileType::Floor; } } } fn apply_horizontal_tunnel(&mut self, x1:i32, x2:i32, y:i32) { for x in min(x1,x2) ..= max(x1,x2) { let idx = self.xy_idx(x, y); if idx > 0 && idx < self.width as usize * self.height as usize { self.tiles[idx as usize] = TileType::Floor; } } } fn apply_vertical_tunnel(&mut self, y1:i32, y2:i32, x:i32) { for y in min(y1,y2) ..= max(y1,y2) { let idx = self.xy_idx(x, y); if idx > 0 && idx < self.width as usize * self.height as usize { self.tiles[idx as usize] = TileType::Floor; } } } /// Makes a new map using the algorithm from http://rogueliketutorials.com/tutorials/tcod/part-3/ /// This gives a handful of random rooms and corridors joining them together. pub fn new_map_rooms_and_corridors() -> Map { let mut map = Map{ tiles : vec![TileType::Wall; 80*50], rooms : Vec::new(), width : 80, height: 50 }; const MAX_ROOMS : i32 = 30; const MIN_SIZE : i32 = 6; const MAX_SIZE : i32 = 10; let mut rng = RandomNumberGenerator::new(); for i in 0..MAX_ROOMS { let w = rng.range(MIN_SIZE, MAX_SIZE); let h = rng.range(MIN_SIZE, MAX_SIZE); let x = rng.roll_dice(1, map.width - w - 1) - 1; let y = rng.roll_dice(1, map.height - h - 1) - 1; let new_room = Rect::new(x, y, w, h); let mut ok = true; for other_room in map.rooms.iter() { if new_room.intersect(other_room) { ok = false } } if ok { map.apply_room_to_map(&new_room); if !map.rooms.is_empty() { let (new_x, new_y) = new_room.center(); let (prev_x, prev_y) = map.rooms[map.rooms.len()-1].center(); if rng.range(0,2) == 1 { map.apply_horizontal_tunnel(prev_x, new_x, prev_y); map.apply_vertical_tunnel(prev_y, new_y, new_x); } else { map.apply_vertical_tunnel(prev_y, new_y, prev_x); map.apply_horizontal_tunnel(prev_x, new_x, new_y); } } map.rooms.push(new_room); } } map } } }
在main和player中也有变化——详见示例源代码。这使我们的代码整洁了不少——我们可以传递一个Map,而不是一个向量。如果我们想教Map做更多的事情——我们有一个地方可以这样做。
视野组件
不仅仅是玩家有有限的视野!最终,我们希望怪物也能考虑它们能看到什么。因此,由于这是可重用的代码,我们将创建一个Viewshed组件。(我喜欢“viewshed”这个词;它来自制图领域——字面意思是“从这里我能看到什么?”——完美地描述了我们的问题)。我们将为每个拥有Viewshed的实体提供它们能看到的一组瓦片索引。在components.rs中我们添加:
#![allow(unused)] fn main() { #[derive(Component)] pub struct Viewshed { pub visible_tiles : Vec<rltk::Point>, pub range : i32 } }
在 main.rs 中,我们告诉系统关于新组件的信息:
#![allow(unused)] fn main() { gs.ecs.register::<Viewshed>(); }
最后,在 main.rs 中,我们也会给 Player 一个 Viewshed 组件:
#![allow(unused)] fn main() { gs.ecs .create_entity() .with(Position { x: player_x, y: player_y }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), }) .with(Player{}) .with(Viewshed{ visible_tiles : Vec::new(), range : 8 }) .build(); }
玩家现在变得相当复杂了——这很好,它展示了 ECS 的用途!
一个新系统:通用视域
我们将首先定义一个系统来为我们处理这个问题。我们希望它是通用的,因此它适用于任何可以从知道自己能看到什么中受益的东西。我们创建一个新文件,visibility_system.rs:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Position}; pub struct VisibilitySystem {} impl<'a> System<'a> for VisibilitySystem { type SystemData = ( WriteStorage<'a, Viewshed>, WriteStorage<'a, Position>); fn run(&mut self, (mut viewshed, pos) : Self::SystemData) { for (viewshed,pos) in (&mut viewshed, &pos).join() { } } } }
现在我们需要在main.rs中调整run_systems以实际调用系统:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); self.ecs.maintain(); } } }
我们还需要告诉 main.rs 使用新模块:
#![allow(unused)] fn main() { mod visibility_system; use visibility_system::VisibilitySystem; }
这实际上还没有做任何事情,但我们已经在调度器中添加了一个系统,一旦我们完善代码以实际绘制可见性,它将适用于具有Viewshed 和Position组件的每个实体。
向 RLTK 请求视域:特征实现
RLTK 的设计初衷是不关心你如何选择布局地图:我希望它对任何人都有用,并不是每个人都按照这个教程的方式来做地图。为了在我们的地图实现和 RLTK 之间架起桥梁,它为我们提供了一些 特性 来支持。在这个例子中,我们需要 BaseMap 和 Algorithm2D。别担心,它们实现起来很简单。
在我们的 map.rs 文件中,我们添加如下内容:
#![allow(unused)] fn main() { impl Algorithm2D for Map { fn dimensions(&self) -> Point { Point::new(self.width, self.height) } } }
RLTK 能够从 dimensions 函数中推断出许多其他特性:点索引(及其倒数)、边界检查和类似的功能。我们使用已经使用的维度,self.width 和 self.height。
我们还需要支持BaseMap。我们目前不需要全部功能,所以我们将让它使用默认值。在map.rs中:
#![allow(unused)] fn main() { impl BaseMap for Map { fn is_opaque(&self, idx:usize) -> bool { self.tiles[idx as usize] == TileType::Wall } } }
is_opaque 简单地返回 true 如果该瓷砖是墙,否则返回 false。如果/当我们添加更多类型的瓷砖时,这将需要扩展,但现在可以工作。我们暂时将特性的其余部分保留为默认值(因此不需要输入其他内容)。
向 RLTK 请求视域:系统
所以回到 visibility_system.rs,我们现在有了从 RLTK 请求视野所需的内容。我们将 visibility_system.rs 文件扩展为如下所示:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Position, Map}; use rltk::{field_of_view, Point}; pub struct VisibilitySystem {} impl<'a> System<'a> for VisibilitySystem { type SystemData = ( ReadExpect<'a, Map>, WriteStorage<'a, Viewshed>, WriteStorage<'a, Position>); fn run(&mut self, data : Self::SystemData) { let (map, mut viewshed, pos) = data; for (viewshed,pos) in (&mut viewshed, &pos).join() { viewshed.visible_tiles.clear(); viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map); viewshed.visible_tiles.retain(|p| p.x >= 0 && p.x < map.width && p.y >= 0 && p.y < map.height ); } } } }
这里有很多内容,而视野范围实际上是最简单的部分:
- 我们添加了一个
ReadExpect<'a, Map>- 这意味着系统应该传递我们的Map以供使用。我们使用了ReadExpect,因为如果没有地图是一个失败。 - 在循环中,我们首先清除可见瓦片列表。
- 然后我们调用 RLTK 的
field_of_view函数,提供起点(实体的位置,从pos获取),范围(从视野范围获取),以及一个稍微复杂的“解引用,然后获取引用”来从 ECS 中解包Map。 - 最后我们使用向量的
retain方法删除任何不符合我们指定条件的条目。这是一个 lambda 或 closure - 它遍历向量,传递p作为参数。如果 p 在地图边界内,我们保留它。这防止其他函数尝试访问工作地图区域外的瓦片。
这将现在每帧运行(这是过度杀伤,稍后会详细介绍)——并存储一个可见瓦片列表。
渲染可见性 - 糟糕!
作为第一次尝试,我们将修改我们的 draw_map 函数以检索地图和玩家的视野。它只会绘制视野内的瓦片:
#![allow(unused)] fn main() { pub fn draw_map(ecs: &World, ctx : &mut Rltk) { let mut viewsheds = ecs.write_storage::<Viewshed>(); let mut players = ecs.write_storage::<Player>(); let map = ecs.fetch::<Map>(); for (_player, viewshed) in (&mut players, &mut viewsheds).join() { let mut y = 0; let mut x = 0; for tile in map.tiles.iter() { // Render a tile depending upon the tile type // 根据瓦片类型渲染瓦片 let pt = Point::new(x,y); if viewshed.visible_tiles.contains(&pt) { match tile { TileType::Floor => { ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.')); } TileType::Wall => { ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#')); } } } // Move the coordinates // 移动坐标 x += 1; if x > 79 { x = 0; y += 1; } } } } }
如果你现在运行示例(cargo run),它会显示玩家所能看到的内容。没有记忆,性能非常糟糕——但它是存在的,而且大致正确。
很明显,我们走在正确的轨道上,但我们需要更有效的方法来做事情。如果玩家也能记住他们看到的地图,那就太好了。
扩展地图以包含已揭示的瓦片
为了模拟地图记忆,我们将扩展我们的Map类以包含一个revealed_tiles结构。它只是地图上每个瓦片的一个bool值——如果为真,那么我们就知道那里有什么。我们的Map定义现在看起来像这样:
#![allow(unused)] fn main() { #[derive(Default)] pub struct Map { pub tiles : Vec<TileType>, pub rooms : Vec<Rect>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool> } }
我们还需要扩展填充地图的函数以包含新类型。在 new_rooms_and_corridors 中,我们将地图创建扩展为:
#![allow(unused)] fn main() { let mut map = Map{ tiles : vec![TileType::Wall; 80*50], rooms : Vec::new(), width : 80, height: 50, revealed_tiles : vec![false; 80*50] }; }
这为每个图块添加了一个 false 值。
我们将 draw_map 改为查看这个值,而不是每次迭代组件。现在函数看起来像这样:
#![allow(unused)] fn main() { pub fn draw_map(ecs: &World, ctx : &mut Rltk) { let map = ecs.fetch::<Map>(); let mut y = 0; let mut x = 0; for (idx,tile) in map.tiles.iter().enumerate() { // Render a tile depending upon the tile type // 根据瓦片类型渲染瓦片 if map.revealed_tiles[idx] { match tile { TileType::Floor => { ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.')); } TileType::Wall => { ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#')); } } } // Move the coordinates // 移动坐标 x += 1; if x > 79 { x = 0; y += 1; } } } }
这将呈现一个黑色屏幕,因为我们从未设置任何要显示的瓦片!所以现在我们扩展VisibilitySystem以知道如何标记瓦片为已显示。为此,它必须检查实体是否是玩家——如果是,它更新地图的显示状态:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Position, Map, Player}; use rltk::{field_of_view, Point}; pub struct VisibilitySystem {} impl<'a> System<'a> for VisibilitySystem { type SystemData = ( WriteExpect<'a, Map>, Entities<'a>, WriteStorage<'a, Viewshed>, WriteStorage<'a, Position>, ReadStorage<'a, Player>); fn run(&mut self, data : Self::SystemData) { let (mut map, entities, mut viewshed, pos, player) = data; for (ent,viewshed,pos) in (&entities, &mut viewshed, &pos).join() { viewshed.visible_tiles.clear(); viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map); viewshed.visible_tiles.retain(|p| p.x >= 0 && p.x < map.width && p.y >= 0 && p.y < map.height ); // If this is the player, reveal what they can see // 如果这是玩家,揭示他们能看到的东西 let p : Option<&Player> = player.get(ent); if let Some(p) = p { for vis in viewshed.visible_tiles.iter() { let idx = map.xy_idx(vis.x, vis.y); map.revealed_tiles[idx] = true; } } } } } }
这里的主要变化是我们正在获取实体列表以及组件,并获得对玩家存储的只读访问权限。我们将这些添加到要迭代的列表中,并添加一个 let p : Option<&Player> = player.get(ent); 来检查这是否是玩家。相当隐晦的 if let Some(p) = p 仅在存在 Player 组件时运行。然后我们计算索引,并标记为已揭示。
如果你现在运行(cargo run)项目,它比之前的版本快得多,并且记得你曾经去过的地方。
进一步加速 - 在我们需要时重新计算可见性
它仍然没有达到应有的效率!让我们只在需要时更新视域。让我们在 Viewshed 组件中添加一个 dirty 标志:
#![allow(unused)] fn main() { #[derive(Component)] pub struct Viewshed { pub visible_tiles : Vec<rltk::Point>, pub range : i32, pub dirty : bool } }
我们还将更新main.rs中的初始化,以表明视野确实是不干净的:.with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })。
我们的系统可以扩展以检查dirty标志是否为真,并且只有在为真时才重新计算 - 完成后将dirty标志设置为假。现在我们需要在玩家移动时设置标志 - 因为他们能看到的内容已经改变了!我们在player.rs中更新try_move_player:
#![allow(unused)] fn main() { pub fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) { let mut positions = ecs.write_storage::<Position>(); let mut players = ecs.write_storage::<Player>(); let mut viewsheds = ecs.write_storage::<Viewshed>(); let map = ecs.fetch::<Map>(); for (_player, pos, viewshed) in (&mut players, &mut positions, &mut viewsheds).join() { let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y); if map.tiles[destination_idx] != TileType::Wall { pos.x = min(79 , max(0, pos.x + delta_x)); pos.y = min(49, max(0, pos.y + delta_y)); viewshed.dirty = true; } } } }
这应该现在很熟悉了:我们已经添加了viewsheds以获取写存储,并将其包含在我们正在迭代的组件类型列表中。然后在移动后调用一次将标志设置为true。
游戏现在再次运行得非常快,如果你输入 cargo run。
淡化我们记得但看不见的东西
再增加一个扩展:我们希望渲染那些我们知道存在但目前无法看到的部分地图。因此,我们将当前可见的瓦片列表添加到Map中:
#![allow(unused)] fn main() { #[derive(Default)] pub struct Map { pub tiles : Vec<TileType>, pub rooms : Vec<Rect>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool> } }
我们的创建方法也需要知道如何添加所有假值,就像之前一样:visible_tiles : vec![false; 80*50]。接下来,在我们的 VisibilitySystem 中,我们在开始迭代之前清除可见瓦片列表,并在找到它们时标记当前可见的瓦片。因此,更新视域时运行的代码如下所示:
#![allow(unused)] fn main() { if viewshed.dirty { viewshed.dirty = false; viewshed.visible_tiles.clear(); viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map); viewshed.visible_tiles.retain(|p| p.x >= 0 && p.x < map.width && p.y >= 0 && p.y < map.height ); // If this is the player, reveal what they can see // 如果这是玩家,揭示他们能看到的东西 let _p : Option<&Player> = player.get(ent); if let Some(_p) = _p { for t in map.visible_tiles.iter_mut() { *t = false }; for vis in viewshed.visible_tiles.iter() { let idx = map.xy_idx(vis.x, vis.y); map.revealed_tiles[idx] = true; map.visible_tiles[idx] = true; } } } }
现在我们调整 draw_map 函数,以不同方式处理已揭示但当前不可见的瓦片。新的 draw_map 函数如下所示:
#![allow(unused)] fn main() { pub fn draw_map(ecs: &World, ctx : &mut Rltk) { let map = ecs.fetch::<Map>(); let mut y = 0; let mut x = 0; for (idx,tile) in map.tiles.iter().enumerate() { // Render a tile depending upon the tile type // 根据瓦片类型渲染瓦片 if map.revealed_tiles[idx] { let glyph; let mut fg; match tile { TileType::Floor => { glyph = rltk::to_cp437('.'); fg = RGB::from_f32(0.0, 0.5, 0.5); } TileType::Wall => { glyph = rltk::to_cp437('#'); fg = RGB::from_f32(0., 1.0, 0.); } } if !map.visible_tiles[idx] { fg = fg.to_greyscale() } ctx.set(x, y, fg, RGB::from_f32(0., 0., 0.), glyph); } // Move the coordinates // 移动坐标 x += 1; if x > 79 { x = 0; y += 1; } } } }
如果你运行你的项目,你现在会看到浅青色的地板和绿色的墙壁——当它们移出视线时会变成灰色。性能应该很好!恭喜——你现在有一个很好的、可用的视野系统。

本章的源代码可以在此处找到这里
在浏览器中使用 Web Assembly 运行本章示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
第六章 - 怪物
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
一个没有怪物的类 Rogue 游戏非常不寻常,所以我们来添加一些吧!好消息是我们已经为此做了一些工作:我们可以渲染它们,并且可以计算它们能看到什么。我们将基于上一章的源代码,并引入一些无害的怪物。
在每个房间的中心渲染一个怪物
我们可以简单地为每个怪物添加一个Renderable组件(我们还会添加一个Viewshed,因为我们稍后会用到它)。在我们的main函数(在main.rs中),添加以下内容:
#![allow(unused)] fn main() { for room in map.rooms.iter().skip(1) { let (x,y) = room.center(); gs.ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('g'), fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), }) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .build(); } gs.ecs.insert(map); }
注意使用 skip(1) 忽略第一个房间 - 我们不希望玩家一开始就被怪物压在上面!运行这个(使用 cargo run)会产生类似这样的结果:

这是一个很好的开始!然而,即使我们看不到怪物,我们也在渲染它们。我们可能只想渲染那些我们能看到的。我们可以通过修改我们的渲染循环来实现这一点:
#![allow(unused)] fn main() { let positions = self.ecs.read_storage::<Position>(); let renderables = self.ecs.read_storage::<Renderable>(); let map = self.ecs.fetch::<Map>(); for (pos, render) in (&positions, &renderables).join() { let idx = map.xy_idx(pos.x, pos.y); if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) } } }
我们从 ECS 获取地图,并使用它来获取索引 - 并检查图块是否可见。如果是 - 我们渲染可渲染对象。不需要为玩家设置特殊情况 - 因为他们通常可以看到自己!结果非常好:

增加一些怪物种类
只有一种怪物类型相当无聊,所以我们会对怪物生成器进行修改,使其能够生成哥布林和兽人。
这是生成器代码:
#![allow(unused)] fn main() { let mut rng = rltk::RandomNumberGenerator::new(); for room in map.rooms.iter().skip(1) { let (x,y) = room.center(); let glyph : rltk::FontCharType; let roll = rng.roll_dice(1, 2); match roll { 1 => { glyph = rltk::to_cp437('g') } _ => { glyph = rltk::to_cp437('o') } } gs.ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: glyph, fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), }) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .build(); } }
显然,当我们开始加入战斗时,我们会希望有更多的多样性——但这是一个好的开始。运行程序(cargo run),你会看到大约 50/50 的兽人和哥布林的分布。
让怪物思考
现在开始让怪物思考!目前,除了思考它们孤独的存在之外,它们实际上不会做太多事情。我们应该首先添加一个标签组件,以表明一个实体是怪物。在 components.rs 中,我们添加一个简单的结构体:
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct Monster {} }
当然,我们需要在 main.rs 中注册它:gs.ecs.register::<Monster>();。我们还应该修改我们的生成代码以将其应用于怪物:
#![allow(unused)] fn main() { gs.ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: glyph, fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), }) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Monster{}) .build(); }
现在我们为一个怪物思维系统创建一个文件。我们将创建一个新文件,monster_ai_system.rs。我们将给它一些基本不存在的智能:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Position, Map, Monster}; use rltk::{field_of_view, Point, console}; pub struct MonsterAI {} impl<'a> System<'a> for MonsterAI { type SystemData = ( ReadStorage<'a, Viewshed>, ReadStorage<'a, Position>, ReadStorage<'a, Monster>); fn run(&mut self, data : Self::SystemData) { let (viewshed, pos, monster) = data; for (viewshed,pos,_monster) in (&viewshed, &pos, &monster).join() { console::log("Monster considers their own existence"); } } } }
注意,我们从 rltk 导入 console - 并使用 console::log 打印。这是 RLTK 提供的一个辅助工具,可以检测你是否在编译为常规程序或 Web Assembly;如果你使用的是常规程序,它会调用 println! 并输出到控制台。如果你在 WASM 中,它会输出到 浏览器 控制台。
我们还将在main.rs中扩展系统运行器以调用它:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut mob = MonsterAI{}; mob.run_now(&self.ecs); self.ecs.maintain(); } } }
如果你现在 cargo run 你的项目,它会非常慢——你的控制台会充满“怪物思考它们的存在”。AI 正在运行——但它每刻都在运行!
回合制游戏,在基于时间的世界中
为了防止这种情况——并制作一个回合制游戏——我们向游戏状态引入了一个新概念。游戏要么是“运行中”,要么是“等待输入”——所以我们创建一个enum来处理这种情况:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { Paused, Running } }
注意derive宏!派生是一种让 Rust(和 crates)为你的结构添加代码的方式,以减少样板代码的输入。在这种情况下,enum需要一些额外的功能。PartialEq允许你比较RunState与其他RunState变量,以确定它们是否相同(或不同)。Copy将其标记为“复制”类型——它可以安全地在内存中复制(这意味着它没有会在此过程中混乱的指针)。Clone悄悄地为其添加了一个.clone()函数,允许你通过这种方式进行内存复制。
接下来,我们需要将其添加到State结构中:
#![allow(unused)] fn main() { pub struct State { pub ecs: World, pub runstate : RunState } }
反过来,我们需要修改我们的状态创建器以包含 runstate: RunState::Running:
#![allow(unused)] fn main() { let mut gs = State { ecs: World::new(), runstate : RunState::Running }; }
现在,我们将 tick 函数改为仅在游戏未暂停时运行模拟,否则请求用户输入:
#![allow(unused)] fn main() { if self.runstate == RunState::Running { self.run_systems(); self.runstate = RunState::Paused; } else { self.runstate = player_input(self, ctx); } }
如你所见,player_input 现在返回一个状态。这是它的新代码:
#![allow(unused)] fn main() { pub fn player_input(gs: &mut State, ctx: &mut Rltk) -> RunState { // Player movement match ctx.key { None => { return RunState::Paused } // Nothing happened Some(key) => match key { VirtualKeyCode::Left | VirtualKeyCode::Numpad4 | VirtualKeyCode::H => try_move_player(-1, 0, &mut gs.ecs), VirtualKeyCode::Right | VirtualKeyCode::Numpad6 | VirtualKeyCode::L => try_move_player(1, 0, &mut gs.ecs), VirtualKeyCode::Up | VirtualKeyCode::Numpad8 | VirtualKeyCode::K => try_move_player(0, -1, &mut gs.ecs), VirtualKeyCode::Down | VirtualKeyCode::Numpad2 | VirtualKeyCode::J => try_move_player(0, 1, &mut gs.ecs), _ => { return RunState::Paused } }, } RunState::Running } }
如果你现在运行 cargo run,游戏速度会恢复 - 而怪物只会在你移动时思考下一步该做什么。这是一个基本的回合制 tick 循环!
安静的怪物,直到它们看到你
你可以让怪物在任何东西移动时都思考(当你进入更深层次的模拟时,你可能会这样做),但现在让我们让它们安静一点——如果它们能看到玩家,就让它们做出反应。
系统很可能会经常想知道玩家的位置——所以让我们将其作为一个资源添加。在 main.rs 中,一行代码将其添加进去(我不建议对非玩家实体这样做;资源是有限的——但玩家是我们反复使用的):
#![allow(unused)] fn main() { gs.ecs.insert(Point::new(玩家_x, 玩家_y)); }
在 player.rs 中,try_move_player(),更新玩家移动时的资源:
#![allow(unused)] fn main() { let mut ppos = ecs.write_resource::<Point>(); ppos.x = pos.x; ppos.y = pos.y; }
我们可以在我们的 monster_ai_system 中使用它。以下是一个工作版本:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Monster}; use rltk::{Point, console}; pub struct MonsterAI {} impl<'a> System<'a> for MonsterAI { type SystemData = ( ReadExpect<'a, Point>, ReadStorage<'a, Viewshed>, ReadStorage<'a, Monster>); fn run(&mut self, data : Self::SystemData) { let (player_pos, viewshed, monster) = data; for (viewshed,_monster) in (&viewshed, &monster).join() { if viewshed.visible_tiles.contains(&*player_pos) { console::log(format!("Monster shouts insults")); } } } } }
如果你 cargo run 这个,你将能够四处移动——当怪物能看到你时,你的控制台会不时显示“怪物大喊侮辱”。
区分我们的怪物
怪物应该有名字,这样我们就知道是谁在对我们大喊大叫!所以我们创建一个新的组件,Name。在 components.rs 中,我们添加:
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct Name { pub name : String } }
我们也会在 main.rs 中注册它,你现在应该已经很熟悉了!我们还会添加一些命令来给我们的怪物和玩家添加名字。所以我们的怪物生成器看起来像这样:
#![allow(unused)] fn main() { for (i,room) in map.rooms.iter().skip(1).enumerate() { let (x,y) = room.center(); let glyph : rltk::FontCharType; let name : String; let roll = rng.roll_dice(1, 2); match roll { 1 => { glyph = rltk::to_cp437('g'); name = "Goblin".to_string(); } _ => { glyph = rltk::to_cp437('o'); name = "Orc".to_string(); } } gs.ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: glyph, fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), }) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Monster{}) .with(Name{ name: format!("{} #{}", &name, i) }) .build(); } }
现在我们调整 monster_ai_system 以包含怪物的名字。新的 AI 如下所示:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Monster, Name}; use rltk::{Point}; pub struct MonsterAI {} impl<'a> System<'a> for MonsterAI { type SystemData = ( ReadExpect<'a, Point>, ReadStorage<'a, Viewshed>, ReadStorage<'a, Monster>, ReadStorage<'a, Name>); fn run(&mut self, data : Self::SystemData) { let (player_pos, viewshed, monster, name) = data; for (viewshed,_monster,name) in (&viewshed, &monster, &name).join() { if viewshed.visible_tiles.contains(&*player_pos) { console::log(&format!("{} shouts insults", name.name)); } } } } }
我们也需要给玩家一个名字;我们在 AI 的加入中明确包含了名字,所以我们最好确保玩家有一个名字!否则,AI 将完全忽略玩家。在main.rs中,我们将在Player创建时包含一个名字:
#![allow(unused)] fn main() { gs.ecs .create_entity() .with(Position { x: player_x, y: player_y }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), }) .with(Player{}) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Name{name: "Player".to_string() }) .build(); }
如果你运行项目,你现在会看到类似 哥布林 #9 大喊侮辱 的内容 - 这样你就能知道谁在喊叫。

第六章就这样结束了;我们添加了各种满口脏话的怪物来侮辱你脆弱的自尊!在本章中,我们开始看到了使用实体组件系统的一些好处:添加新渲染的怪物,带有一些变化,并开始为事物存储名称,都非常容易。我们之前编写的视野代码只需稍作修改即可让怪物可见——而我们新的怪物 AI 能够利用我们已经构建的内容,相当高效地对玩家说坏话。
本章的源代码可以在此处找到这里
在浏览器中使用 WebAssembly 运行本章示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
造成伤害(并承受一些!)
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
现在我们有了怪物,我们希望它们不仅仅是站在控制台上对你大喊大叫!本章将使它们追逐你,并引入一些基本的游戏统计数据,让你能够杀出重围。
追逐玩家
我们需要做的第一件事是为我们的Map类完成实现BaseMap。特别是,我们需要支持get_available_exits——这是用于路径查找的。
在我们的Map实现中,我们需要一个辅助函数:
#![allow(unused)] fn main() { fn is_exit_valid(&self, x:i32, y:i32) -> bool { if x < 1 || x > self.width-1 || y < 1 || y > self.height-1 { return false; } let idx = self.xy_idx(x, y); self.tiles[idx as usize] != TileType::Wall } }
这需要一个索引,并计算它是否可以进入。
然后我们使用这个辅助工具来实现特征:
#![allow(unused)] fn main() { fn get_available_exits(&self, idx:usize) -> rltk::SmallVec<[(usize, f32); 10]> { let mut exits = rltk::SmallVec::new(); let x = idx as i32 % self.width; let y = idx as i32 / self.width; let w = self.width as usize; // Cardinal directions if self.is_exit_valid(x-1, y) { exits.push((idx-1, 1.0)) }; if self.is_exit_valid(x+1, y) { exits.push((idx+1, 1.0)) }; if self.is_exit_valid(x, y-1) { exits.push((idx-w, 1.0)) }; if self.is_exit_valid(x, y+1) { exits.push((idx+w, 1.0)) }; exits } }
提供没有距离启发式的出口会导致一些糟糕的行为(并且在 RLTK 的未来版本中会导致崩溃)。因此,也要为你的地图实现这一点:
#![allow(unused)] fn main() { impl BaseMap for Map { ... fn get_pathing_distance(&self, idx1:usize, idx2:usize) -> f32 { let w = self.width as usize; let p1 = Point::new(idx1 % w, idx1 / w); let p2 = Point::new(idx2 % w, idx2 / w); rltk::DistanceAlg::Pythagoras.distance2d(p1, p2) } }
相当直接:我们评估每个可能的出口,如果可以通行,则将其添加到 exits 向量中。接下来,我们修改 monster_ai_system 中的主循环:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Monster, Name, Map, Position}; use rltk::{Point, console}; pub struct MonsterAI {} impl<'a> System<'a> for MonsterAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, Point>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Monster>, ReadStorage<'a, Name>, WriteStorage<'a, Position>); fn run(&mut self, data : Self::SystemData) { let (mut map, player_pos, mut viewshed, monster, name, mut position) = data; for (mut viewshed,_monster,name,mut pos) in (&mut viewshed, &monster, &name, &mut position).join() { if viewshed.visible_tiles.contains(&*player_pos) { console::log(&format!("{} shouts insults", name.name)); let path = rltk::a_star_search( map.xy_idx(pos.x, pos.y) as i32, map.xy_idx(player_pos.x, player_pos.y) as i32, &mut *map ); if path.success && path.steps.len()>1 { pos.x = path.steps[1] as i32 % map.width; pos.y = path.steps[1] as i32 / map.width; viewshed.dirty = true; } } } } } }
我们更改了一些内容以允许写访问,请求访问地图。我们还添加了一个 #[allow...] 来告诉代码检查工具我们确实打算在一个类型中使用这么多内容!核心是 a_star_search 调用;RLTK 包含一个高性能的 A* 实现,所以我们要求它从怪物的位置到玩家的路径。然后我们检查路径是否成功,并且有超过 2 步(第 0 步总是当前位置)。如果是,那么我们将怪物移动到该点,并将其视野设置为脏状态。
如果你运行项目,怪物现在会追逐玩家——如果失去视线就会停止。我们没有阻止怪物互相站立——或者你——我们也没有让它们做任何其他事情,只是对着你的控制台大喊——但这是一个好的开始。追逐机制并不难实现!
阻止访问
我们不希望怪物互相踩踏,也不希望它们在寻找玩家时陷入交通堵塞;我们宁愿它们愿意尝试包抄玩家!我们将通过跟踪地图上哪些部分被阻挡来辅助这一点。
首先,我们将向我们的 Map 添加另一个布尔向量:
#![allow(unused)] fn main() { #[derive(Default)] pub struct Map { pub tiles : Vec<TileType>, pub rooms : Vec<Rect>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool>, pub blocked : Vec<bool> } }
我们也会初始化它,就像其他向量一样:
#![allow(unused)] fn main() { let mut map = Map{ tiles : vec![TileType::Wall; 80*50], rooms : Vec::new(), width : 80, height: 50, revealed_tiles : vec![false; 80*50], visible_tiles : vec![false; 80*50], blocked : vec![false; 80*50] }; }
让我们引入一个新功能来填充一个瓦片是否被阻挡。在Map实现中:
#![allow(unused)] fn main() { pub fn populate_blocked(&mut self) { for (i,tile) in self.tiles.iter_mut().enumerate() { self.blocked[i] = *tile == TileType::Wall; } } }
这个功能非常简单:如果是墙壁,则将瓦片的 blocked 设置为 true,否则设置为 false(当我们添加更多瓦片类型时会扩展它)。在处理 Map 时,让我们调整 is_exit_valid 以使用这些数据:
#![allow(unused)] fn main() { fn is_exit_valid(&self, x:i32, y:i32) -> bool { if x < 1 || x > self.width-1 || y < 1 || y > self.height-1 { return false; } let idx = self.xy_idx(x, y); !self.blocked[idx] } }
这很简单:它检查 x 和 y 是否在地图范围内,如果出口在地图外则返回 false(这种 边界检查 是值得做的,它可以防止你的程序因为试图读取有效内存区域外的内容而崩溃)。然后它检查瓦片数组中指定坐标的 索引,并返回 blocked 的 反值(! 在大多数语言中与 not 相同 - 所以可以读作“在 idx 处未被阻挡”)。
现在我们将创建一个新的组件,BlocksTile。你现在应该知道怎么做;在 Components.rs 中:
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct BlocksTile {} }
然后在 main.rs 中注册它:gs.ecs.register::<BlocksTile>();
我们应该将 BlocksTile 应用于 NPC - 因此我们的 NPC 创建代码变为:
#![allow(unused)] fn main() { gs.ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph, fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), }) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Monster{}) .with(Name{ name: format!("{} #{}", &name, i) }) .with(BlocksTile{}) .build(); }
最后,我们需要填充阻止列表。我们可能会在以后扩展这个系统,所以我们选择一个很好的通用名称 map_indexing_system.rs:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Map, Position, BlocksTile}; pub struct MapIndexingSystem {} impl<'a> System<'a> for MapIndexingSystem { type SystemData = ( WriteExpect<'a, Map>, ReadStorage<'a, Position>, ReadStorage<'a, BlocksTile>); fn run(&mut self, data : Self::SystemData) { let (mut map, position, blockers) = data; map.populate_blocked(); for (position, _blocks) in (&position, &blockers).join() { let idx = map.xy_idx(position.x, position.y); map.blocked[idx] = true; } } } }
这告诉地图从地形设置阻挡,然后遍历所有带有 BlocksTile 组件的实体,并将它们应用到阻挡列表中。我们需要在 main.rs 中使用 run_systems 注册它。
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut mob = MonsterAI{}; mob.run_now(&self.ecs); let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); self.ecs.maintain(); } } }
如果你现在运行 cargo run,怪物不再重叠在一起——但它们确实会出现在玩家上方。我们应该修复这个问题。我们可以让怪物只在靠近玩家时才大叫。在 monster_ai_system.rs 中,在可见性测试上方添加以下内容:
#![allow(unused)] fn main() { let distance = rltk::DistanceAlg::Pythagoras.distance2d(Point::new(pos.x, pos.y), *player_pos); if distance < 1.5 { // Attack goes here console::log(&format!("{} shouts insults", name.name)); return; } }
最后,我们希望阻止玩家走过怪物。在 player.rs 中,我们将查找墙壁的 if 语句替换为:
#![allow(unused)] fn main() { if !map.blocked[destination_idx] { }
由于我们已经将墙壁加入到阻止列表中,这应该暂时解决了这个问题。cargo run显示怪物现在会阻挡玩家。它们阻挡得完美无缺——所以一个想要挡在你面前的怪物是一个无法通过的障碍!
允许对角移动
能够绕过怪物会很不错——而对角线移动是 Roguelike 游戏的主要特点。所以让我们继续支持它。在 map.rs 的 get_available_exits 函数中,我们添加它们:
#![allow(unused)] fn main() { fn get_available_exits(&self, idx:usize) -> rltk::SmallVec<[(usize, f32); 10]> { let mut exits = rltk::SmallVec::new(); let x = idx as i32 % self.width; let y = idx as i32 / self.width; let w = self.width as usize; // Cardinal directions if self.is_exit_valid(x-1, y) { exits.push((idx-1, 1.0)) }; if self.is_exit_valid(x+1, y) { exits.push((idx+1, 1.0)) }; if self.is_exit_valid(x, y-1) { exits.push((idx-w, 1.0)) }; if self.is_exit_valid(x, y+1) { exits.push((idx+w, 1.0)) }; // Diagonals if self.is_exit_valid(x-1, y-1) { exits.push(((idx-w)-1, 1.45)); } if self.is_exit_valid(x+1, y-1) { exits.push(((idx-w)+1, 1.45)); } if self.is_exit_valid(x-1, y+1) { exits.push(((idx+w)-1, 1.45)); } if self.is_exit_valid(x+1, y+1) { exits.push(((idx+w)+1, 1.45)); } exits } }
我们还修改了 player.rs 输入代码:
#![allow(unused)] fn main() { pub fn player_input(gs: &mut State, ctx: &mut Rltk) -> RunState { // Player movement match ctx.key { None => { return RunState::Paused } // Nothing happened Some(key) => match key { VirtualKeyCode::Left | VirtualKeyCode::Numpad4 | VirtualKeyCode::H => try_move_player(-1, 0, &mut gs.ecs), VirtualKeyCode::Right | VirtualKeyCode::Numpad6 | VirtualKeyCode::L => try_move_player(1, 0, &mut gs.ecs), VirtualKeyCode::Up | VirtualKeyCode::Numpad8 | VirtualKeyCode::K => try_move_player(0, -1, &mut gs.ecs), VirtualKeyCode::Down | VirtualKeyCode::Numpad2 | VirtualKeyCode::J => try_move_player(0, 1, &mut gs.ecs), // Diagonals VirtualKeyCode::Numpad9 | VirtualKeyCode::Y => try_move_player(1, -1, &mut gs.ecs), VirtualKeyCode::Numpad7 | VirtualKeyCode::U => try_move_player(-1, -1, &mut gs.ecs), VirtualKeyCode::Numpad3 | VirtualKeyCode::N => try_move_player(1, 1, &mut gs.ecs), VirtualKeyCode::Numpad1 | VirtualKeyCode::B => try_move_player(-1, 1, &mut gs.ecs), _ => { return RunState::Paused } }, } RunState::Running } }
你现在可以斜向躲避怪物 - 它们也可以斜向移动/攻击。
给怪物和玩家一些战斗属性
你可能已经猜到了,添加属性到实体的方法是通过另一个组件!在 components.rs 中,我们添加 CombatStats。以下是一个简单的定义:
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct CombatStats { pub max_hp : i32, pub hp : i32, pub defense : i32, pub power : i32 } }
像往常一样,别忘了在 main.rs 中注册它!
我们将给Player 30 点生命值,2 点防御和 5 点力量:
#![allow(unused)] fn main() { .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 }) }
同样,我们会给怪物一组较弱的属性(我们稍后再考虑怪物的差异化):
#![allow(unused)] fn main() { .with(CombatStats{ max_hp: 16, hp: 16, defense: 1, power: 4 }) }
索引什么在哪里
在地图上旅行时——无论是作为玩家还是怪物——了解一个格子里的内容非常方便。你可以将其与可见性系统结合,以便对可见的事物做出明智的选择,你可以用它来查看是否试图进入敌人的空间(并攻击他们),等等。一种方法是迭代Position组件并查看我们是否击中了任何东西;对于少量实体来说,这已经足够快了。我们将采取不同的方法,并让map_indexing_system帮助我们。我们将首先在地图中添加一个字段:
#![allow(unused)] fn main() { #[derive(Default)] pub struct Map { pub tiles : Vec<TileType>, pub rooms : Vec<Rect>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool>, pub blocked : Vec<bool>, pub tile_content : Vec<Vec<Entity>> } }
并且我们将为新地图代码添加一个基本初始化器:
#![allow(unused)] fn main() { tile_content : vec![Vec::new(); 80*50] }
当我们在 map 中时,我们还需要一个函数:
#![allow(unused)] fn main() { pub fn clear_content_index(&mut self) { for content in self.tile_content.iter_mut() { content.clear(); } } }
这也很简单:它迭代(访问)tile_content 列表中的每个向量,可变地(iter_mut 获取一个可变迭代器)。然后它告诉每个向量清除自身——移除所有内容(它实际上并不保证会释放内存;向量可以保留空的部分以备更多数据。这实际上是一件好事,因为获取新内存是程序可以做的最慢的事情之一——所以它有助于保持运行速度)。
然后我们将升级索引系统,按图块索引所有实体:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Map, Position, BlocksTile}; pub struct MapIndexingSystem {} impl<'a> System<'a> for MapIndexingSystem { type SystemData = ( WriteExpect<'a, Map>, ReadStorage<'a, Position>, ReadStorage<'a, BlocksTile>, Entities<'a>,); fn run(&mut self, data : Self::SystemData) { let (mut map, position, blockers, entities) = data; map.populate_blocked(); map.clear_content_index(); for (entity, position) in (&entities, &position).join() { let idx = map.xy_idx(position.x, position.y); // If they block, update the blocking list let _p : Option<&BlocksTile> = blockers.get(entity); if let Some(_p) = _p { map.blocked[idx] = true; } // Push the entity to the appropriate index slot. It's a Copy // type, so we don't need to clone it (we want to avoid moving it out of the ECS!) map.tile_content[idx].push(entity); } } } }
让玩家进行攻击
大多数 Roguelike 游戏角色花费大量时间攻击事物,所以让我们实现这一点!碰撞攻击(走进目标)是实现这一点的典型方式。我们希望扩展player.rs中的try_move_player,以检查我们试图进入的瓷砖是否包含目标。
我们将为CombatStats添加一个读取器到数据存储列表中,并快速插入一个敌人检测器:
#![allow(unused)] fn main() { let combat_stats = ecs.read_storage::<CombatStats>(); let map = ecs.fetch::<Map>(); for (_player, pos, viewshed) in (&mut players, &mut positions, &mut viewsheds).join() { let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y); for potential_target in map.tile_content[destination_idx].iter() { let target = combat_stats.get(*potential_target); match target { None => {} Some(t) => { // Attack it console::log(&format!("From Hell's Heart, I stab thee!")); return; // So we don't move after attacking } } } }
如果你 cargo run 这个,你会看到你可以走到一个生物旁边并试图移动到它上面。从地狱的心脏,我刺向你! 出现在控制台上。所以检测是有效的,攻击也在正确的位置。
玩家攻击和杀死事物
我们将以 ECS 的方式进行,所以有一些样板代码。在components.rs中,我们添加一个表示攻击意图的组件:
#![allow(unused)] fn main() { #[derive(Component, Debug, ConvertSaveload, Clone)] pub struct WantsToMelee { pub target : Entity } }
我们也希望跟踪传入的伤害。在一个回合中,你可能会从多个来源受到伤害,而 Specs 非常不喜欢你在实体上尝试使用多个相同类型的组件。这里有两种可能的方法:将伤害本身作为一个实体(并跟踪受害者),或者将伤害作为一个向量。后者似乎更容易实现;因此我们将创建一个SufferDamage组件来跟踪伤害,并附加/实现一个方法以便于使用:
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct SufferDamage { pub amount : Vec<i32> } impl SufferDamage { pub fn new_damage(store: &mut WriteStorage<SufferDamage>, victim: Entity, amount: i32) { if let Some(suffering) = store.get_mut(victim) { suffering.amount.push(amount); } else { let dmg = SufferDamage { amount : vec![amount] }; store.insert(victim, dmg).expect("Unable to insert damage"); } } } }
(别忘了在main.rs中注册它们!)。我们修改玩家的移动命令以创建一个表示攻击意图的组件(将wants_to_melee附加到攻击者上):
#![allow(unused)] fn main() { let entities = ecs.entities(); let mut wants_to_melee = ecs.write_storage::<WantsToMelee>(); for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() { if pos.x + delta_x < 1 || pos.x + delta_x > map.width-1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height-1 { return; } let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y); for potential_target in map.tile_content[destination_idx].iter() { let target = combat_stats.get(*potential_target); if let Some(_target) = target { wants_to_melee.insert(entity, WantsToMelee{ target: *potential_target }).expect("Add target failed"); return; } } } }
我们需要一个近战战斗系统来处理近战。这使用了我们创建的新伤害系统,以确保在一个回合中可以应用多个伤害来源:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{CombatStats, WantsToMelee, Name, SufferDamage}; pub struct MeleeCombatSystem {} impl<'a> System<'a> for MeleeCombatSystem { type SystemData = ( Entities<'a>, WriteStorage<'a, WantsToMelee>, ReadStorage<'a, Name>, ReadStorage<'a, CombatStats>, WriteStorage<'a, SufferDamage> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut wants_melee, names, combat_stats, mut inflict_damage) = data; for (_entity, wants_melee, name, stats) in (&entities, &wants_melee, &names, &combat_stats).join() { if stats.hp > 0 { let target_stats = combat_stats.get(wants_melee.target).unwrap(); if target_stats.hp > 0 { let target_name = names.get(wants_melee.target).unwrap(); let damage = i32::max(0, stats.power - target_stats.defense); if damage == 0 { console::log(&format!("{} is unable to hurt {}", &name.name, &target_name.name)); } else { console::log(&format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage)); SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage); } } } } wants_melee.clear(); } } }
我们需要一个damage_system来应用伤害(我们将其分开,因为伤害可能来自多种来源!)。我们使用迭代器来求和伤害,确保所有伤害都被应用:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{CombatStats, SufferDamage}; pub struct DamageSystem {} impl<'a> System<'a> for DamageSystem { type SystemData = ( WriteStorage<'a, CombatStats>, WriteStorage<'a, SufferDamage> ); fn run(&mut self, data : Self::SystemData) { let (mut stats, mut damage) = data; for (mut stats, damage) in (&mut stats, &damage).join() { stats.hp -= damage.amount.iter().sum::<i32>(); } damage.clear(); } } }
我们还将添加一个方法来清理死亡的实体:
#![allow(unused)] fn main() { pub fn delete_the_dead(ecs : &mut World) { let mut dead : Vec<Entity> = Vec::new(); // Using a scope to make the borrow checker happy { let combat_stats = ecs.read_storage::<CombatStats>(); let entities = ecs.entities(); for (entity, stats) in (&entities, &combat_stats).join() { if stats.hp < 1 { dead.push(entity); } } } for victim in dead { ecs.delete_entity(victim).expect("Unable to delete"); } } }
这是在我们运行系统后从 tick 命令调用的:damage_system::delete_the_dead(&mut self.ecs);。
如果你现在 cargo run,你可以在地图上四处跑动并击打物体——它们死后会消失!
让怪物击中你
由于我们已经编写了处理攻击和伤害的系统,因此使用相同的代码来处理怪物相对容易——只需添加一个WantsToMelee组件,它们就可以攻击/杀死玩家。
我们将首先将玩家实体变成一个游戏资源,这样它可以很容易地被引用。像玩家的位置一样,这是我们可能需要在各处使用的东西——而且由于实体 ID 是稳定的,我们可以依赖它的存在。在main.rs中,我们将玩家的create_entity改为返回实体对象:
#![allow(unused)] fn main() { let player_entity = gs.ecs .create_entity() .with(Position { x: player_x, y: player_y }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), }) .with(Player{}) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Name{name: "Player".to_string() }) .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 }) .build(); }
然后我们将其插入到世界中:
#![allow(unused)] fn main() { gs.ecs.insert(player_entity); }
现在我们修改 monster_ai_system。这里有一些清理工作,并且“投掷侮辱”代码被完全替换为一个单一组件插入:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Monster, Map, Position, WantsToMelee, RunState}; use rltk::{Point}; pub struct MonsterAI {} impl<'a> System<'a> for MonsterAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, Point>, ReadExpect<'a, Entity>, ReadExpect<'a, RunState>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Monster>, WriteStorage<'a, Position>, WriteStorage<'a, WantsToMelee>); fn run(&mut self, data : Self::SystemData) { let (mut map, player_pos, player_entity, runstate, entities, mut viewshed, monster, mut position, mut wants_to_melee) = data; for (entity, mut viewshed,_monster,mut pos) in (&entities, &mut viewshed, &monster, &mut position).join() { let distance = rltk::DistanceAlg::Pythagoras.distance2d(Point::new(pos.x, pos.y), *player_pos); if distance < 1.5 { wants_to_melee.insert(entity, WantsToMelee{ target: *player_entity }).expect("Unable to insert attack"); } else if viewshed.visible_tiles.contains(&*player_pos) { // Path to the player let path = rltk::a_star_search( map.xy_idx(pos.x, pos.y), map.xy_idx(player_pos.x, player_pos.y), &mut *map ); if path.success && path.steps.len()>1 { let mut idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = false; pos.x = path.steps[1] as i32 % map.width; pos.y = path.steps[1] as i32 / map.width; idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = true; viewshed.dirty = true; } } } } } }
如果你现在运行 cargo run,你可以杀死怪物——它们也可以攻击你。如果一个怪物杀死了你——游戏会崩溃!它会崩溃,因为 delete_the_dead 删除了玩家。这显然不是我们想要的结果。以下是一个不会崩溃的 delete_the_dead 版本:
#![allow(unused)] fn main() { pub fn delete_the_dead(ecs : &mut World) { let mut dead : Vec<Entity> = Vec::new(); // Using a scope to make the borrow checker happy { let combat_stats = ecs.read_storage::<CombatStats>(); let players = ecs.read_storage::<Player>(); let entities = ecs.entities(); for (entity, stats) in (&entities, &combat_stats).join() { if stats.hp < 1 { let player = players.get(entity); match player { None => dead.push(entity), Some(_) => console::log("You are dead") } } } } for victim in dead { ecs.delete_entity(victim).expect("Unable to delete"); } } }
我们将在后面的章节中讨论结束游戏的问题。
扩展转向系统
如果你仔细观察,你会发现即使敌人已经受到致命伤害,他们仍然可以反击。虽然这与某些莎士比亚戏剧相符(他们真的应该发表演讲),但这不是 rogue 类游戏鼓励的战术玩法。问题在于我们的游戏状态只有“运行”和“暂停”——甚至在玩家行动时我们也没有运行系统。此外,系统不知道我们处于哪个阶段——所以它们无法考虑到这一点。
让我们用更能描述每个阶段的名称替换 RunState:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn } }
如果你在使用带有 RLS 的 Visual Studio Code,你的项目一半可能会变成红色。没关系,我们会一步一步地重构。我们将从主GameState中完全移除RunState:
#![allow(unused)] fn main() { pub struct State { pub ecs: World } }
这使得更多的红色出现!我们这样做,是因为我们要将 RunState 变成一个资源。所以在 main.rs 中我们插入其他资源的地方,我们添加:
#![allow(unused)] fn main() { gs.ecs.insert(RunState::PreRun); }
现在开始重构 Tick。我们的新 tick 函数如下:
#![allow(unused)] fn main() { fn tick(&mut self, ctx : &mut Rltk) { ctx.cls(); let mut newrunstate; { let runstate = self.ecs.fetch::<RunState>(); newrunstate = *runstate; } match newrunstate { RunState::PreRun => { self.run_systems(); newrunstate = RunState::AwaitingInput; } RunState::AwaitingInput => { newrunstate = player_input(self, ctx); } RunState::PlayerTurn => { self.run_systems(); newrunstate = RunState::MonsterTurn; } RunState::MonsterTurn => { self.run_systems(); newrunstate = RunState::AwaitingInput; } } { let mut runwriter = self.ecs.write_resource::<RunState>(); *runwriter = newrunstate; } damage_system::delete_the_dead(&mut self.ecs); draw_map(&self.ecs, ctx); let positions = self.ecs.read_storage::<Position>(); let renderables = self.ecs.read_storage::<Renderable>(); let map = self.ecs.fetch::<Map>(); for (pos, render) in (&positions, &renderables).join() { let idx = map.xy_idx(pos.x, pos.y); if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) } } } } }
注意我们现在有一个状态机在进行,有一个“预运行”阶段来启动游戏!这非常清晰,而且很明显发生了什么。为了使借用检查器满意,我们使用了一些作用域魔法:如果你在一个作用域内声明和使用一个变量,它在作用域退出时会被丢弃(你也可以手动丢弃事物,但我认为这样看起来更清晰)。
在 player.rs 中,我们只需将所有 Paused 替换为 AwaitingInput,并将 Running 替换为 PlayerTurn。
最后,我们修改 monster_ai_system 仅在状态为 MonsterTurn 时运行(代码片段):
#![allow(unused)] fn main() { impl<'a> System<'a> for MonsterAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, Point>, ReadExpect<'a, Entity>, ReadExpect<'a, RunState>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Monster>, WriteStorage<'a, Position>, WriteStorage<'a, WantsToMelee>); fn run(&mut self, data : Self::SystemData) { let (mut map, player_pos, player_entity, runstate, entities, mut viewshed, monster, mut position, mut wants_to_melee) = data; if *runstate != RunState::MonsterTurn { return; } }
不要忘记确保所有系统现在都在 run_systems(在 main.rs 中):
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut mob = MonsterAI{}; mob.run_now(&self.ecs); let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut melee = MeleeCombatSystem{}; melee.run_now(&self.ecs); let mut damage = DamageSystem{}; damage.run_now(&self.ecs); self.ecs.maintain(); } } }
如果你 cargo run 这个项目,它现在会按预期运行:玩家移动,他/她杀死的生物在还击之前就会死亡。
总结
那是相当精彩的一章!我们加入了位置索引、伤害和击杀功能。好消息是这是最难的部分;你现在有了一个简单的地牢闯关游戏!虽然不是特别有趣,而且你肯定会死(因为没有治疗)——但基本功能已经齐备。
本章的源代码可以在此处找到这里
在浏览器中使用 WebAssembly 运行本章示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
用户界面
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
在本章中,我们将为游戏添加用户界面。
缩小地图
我们将从 map.rs 开始,并添加一些常量:MAPWIDTH、MAPHEIGHT 和 MAPCOUNT:
#![allow(unused)] fn main() { const MAPWIDTH: usize = 80; const MAPHEIGHT: usize = 50; const MAPCOUNT: usize = MAPHEIGHT * MAPWIDTH; }
然后我们将逐一更改对 80*50 的引用为MAPCOUNT,并对地图大小的引用使用常量。完成后并运行时,我们将MAPHEIGHT更改为 43 - 以便在屏幕底部留出用户界面面板的空间。
一些最小的图形用户界面元素
我们将创建一个新文件 gui.rs 来存放我们的代码。我们将从一个非常简单的开始:
#![allow(unused)] fn main() { use rltk::{ RGB, Rltk, Console }; use specs::prelude::*; pub fn draw_ui(ecs: &World, ctx : &mut Rltk) { ctx.draw_box(0, 43, 79, 6, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); } }
我们在main.rs顶部的导入块中添加一个mod gui,并在tick的末尾调用它:
#![allow(unused)] fn main() { gui::draw_ui(&self.ecs, ctx); }
如果我们现在运行 cargo run,我们会看到地图已经缩小了——并且我们有一个白色框代替了面板。

添加生命条
这会帮助玩家了解他们还剩下多少生命值。幸运的是,RLTK 提供了方便的辅助工具。我们需要从 ECS 中获取玩家的生命值并进行渲染。这很简单,你现在应该已经很熟悉了。代码如下:
#![allow(unused)] fn main() { use rltk::{ RGB, Rltk, Console }; use specs::prelude::*; use super::{CombatStats, Player}; pub fn draw_ui(ecs: &World, ctx : &mut Rltk) { ctx.draw_box(0, 43, 79, 6, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); let combat_stats = ecs.read_storage::<CombatStats>(); let players = ecs.read_storage::<Player>(); for (_player, stats) in (&players, &combat_stats).join() { let health = format!(" HP: {} / {} ", stats.hp, stats.max_hp); ctx.print_color(12, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &health); ctx.draw_bar_horizontal(28, 43, 51, stats.hp, stats.max_hp, RGB::named(rltk::RED), RGB::named(rltk::BLACK)); } } }
添加消息日志
游戏日志作为资源是有意义的:任何想要告诉你一些信息的系统都可以使用它,而且几乎没有限制什么可能想要告诉你一些信息。我们将从建模日志本身开始。创建一个新文件,gamelog.rs。我们将从非常简单的方式开始:
#![allow(unused)] fn main() { pub struct GameLog { pub entries : Vec<String> } }
在 main.rs 中,我们添加一行 mod gamelog;,并将其作为资源插入,使用 gs.ecs.insert(gamelog::GameLog{ entries : vec!["Welcome to Rusty Roguelike".to_string()] });。我们在开始时插入一条日志记录,使用 vec! 宏来构造向量。这样我们就有东西可以显示了——所以我们将在 gui.rs 中开始编写日志显示代码。在我们的 GUI 绘制函数中,我们只需添加:
#![allow(unused)] fn main() { let log = ecs.fetch::<GameLog>(); let mut y = 44; for s in log.entries.iter().rev() { if y < 49 { ctx.print(2, y, s); } y += 1; } }
如果你现在cargo run这个项目,你会看到类似这样的内容:

记录攻击
在我们的 melee_combat_system 中,我们从 super 导入中添加 gamelog::GameLog,为日志添加读/写访问器(WriteExpect<'a, GameLog>,),并扩展解构以包含它:let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage) = data;。然后只需将 print! 宏替换为插入游戏日志即可。以下是结果代码:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{CombatStats, WantsToMelee, Name, SufferDamage, gamelog::GameLog}; pub struct MeleeCombatSystem {} impl<'a> System<'a> for MeleeCombatSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToMelee>, ReadStorage<'a, Name>, ReadStorage<'a, CombatStats>, WriteStorage<'a, SufferDamage> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage) = data; for (_entity, wants_melee, name, stats) in (&entities, &wants_melee, &names, &combat_stats).join() { if stats.hp > 0 { let target_stats = combat_stats.get(wants_melee.target).unwrap(); if target_stats.hp > 0 { let target_name = names.get(wants_melee.target).unwrap(); let damage = i32::max(0, stats.power - target_stats.defense); if damage == 0 { log.entries.push(format!("{} is unable to hurt {}", &name.name, &target_name.name)); } else { log.entries.push(format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage)); SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage); } } } } wants_melee.clear(); } } }
现在如果你运行游戏并玩一会儿(cargo run,玩法由你决定!),你会在日志中看到战斗消息:

通知死亡
我们可以用delete_the_dead来通知死亡。以下是完成的代码:
#![allow(unused)] fn main() { pub fn delete_the_dead(ecs : &mut World) { let mut dead : Vec<Entity> = Vec::new(); // Using a scope to make the borrow checker happy { let combat_stats = ecs.read_storage::<CombatStats>(); let players = ecs.read_storage::<Player>(); let names = ecs.read_storage::<Name>(); let entities = ecs.entities(); let mut log = ecs.write_resource::<GameLog>(); for (entity, stats) in (&entities, &combat_stats).join() { if stats.hp < 1 { let player = players.get(entity); match player { None => { let victim_name = names.get(entity); if let Some(victim_name) = victim_name { log.entries.push(format!("{} is dead", &victim_name.name)); } dead.push(entity) } Some(_) => console::log("You are dead") } } } } for victim in dead { ecs.delete_entity(victim).expect("Unable to delete"); } } }
鼠标支持和工具提示
让我们从如何从 RLTK 获取鼠标信息开始。这非常简单;在你的draw_ui函数的底部添加以下内容:
#![allow(unused)] fn main() { // Draw mouse cursor let mouse_pos = ctx.mouse_pos(); ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::MAGENTA)); }
这将鼠标指向的单元格的背景设置为品红色。如您所见,鼠标信息作为上下文的一部分从 RLTK 到达。
现在我们将介绍一个新功能,draw_tooltips,并在draw_ui的末尾调用它。新功能如下所示:
#![allow(unused)] fn main() { fn draw_tooltips(ecs: &World, ctx : &mut Rltk) { let map = ecs.fetch::<Map>(); let names = ecs.read_storage::<Name>(); let positions = ecs.read_storage::<Position>(); let mouse_pos = ctx.mouse_pos(); if mouse_pos.0 >= map.width || mouse_pos.1 >= map.height { return; } let mut tooltip : Vec<String> = Vec::new(); for (name, position) in (&names, &positions).join() { let idx = map.xy_idx(position.x, position.y); if position.x == mouse_pos.0 && position.y == mouse_pos.1 && map.visible_tiles[idx] { tooltip.push(name.name.to_string()); } } if !tooltip.is_empty() { let mut width :i32 = 0; for s in tooltip.iter() { if width < s.len() as i32 { width = s.len() as i32; } } width += 3; if mouse_pos.0 > 40 { let arrow_pos = Point::new(mouse_pos.0 - 2, mouse_pos.1); let left_x = mouse_pos.0 - width; let mut y = mouse_pos.1; for s in tooltip.iter() { ctx.print_color(left_x, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), s); let padding = (width - s.len() as i32)-1; for i in 0..padding { ctx.print_color(arrow_pos.x - i, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &" ".to_string()); } y += 1; } ctx.print_color(arrow_pos.x, arrow_pos.y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &"->".to_string()); } else { let arrow_pos = Point::new(mouse_pos.0 + 1, mouse_pos.1); let left_x = mouse_pos.0 +3; let mut y = mouse_pos.1; for s in tooltip.iter() { ctx.print_color(left_x + 1, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), s); let padding = (width - s.len() as i32)-1; for i in 0..padding { ctx.print_color(arrow_pos.x + 1 + i, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &" ".to_string()); } y += 1; } ctx.print_color(arrow_pos.x, arrow_pos.y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &"<-".to_string()); } } } }
它首先获取我们需要用于工具提示的组件的读取权限:名称和位置。它还获取地图本身的读取权限。然后我们检查鼠标光标是否实际在地图上,如果不在,则退出——没有必要为永远不会有任何东西的地方绘制工具提示!
余下的部分说“如果我们有任何工具提示,查看鼠标位置”——如果在左边,我们会将工具提示放在右边,否则放在左边。
如果你现在 cargo run 你的项目,它看起来像这样:

可选的后处理,以获得真正的复古感觉
由于我们在讨论外观和感觉,让我们考虑启用一个 RLTK 功能:后处理以产生扫描线和屏幕烧伤,以获得真正的复古感觉。你是否想使用这个完全取决于你!在main.rs中,初始设置只是将第一个init命令替换为:
#![allow(unused)] fn main() { use rltk::RltkBuilder; let mut context = RltkBuilder::simple80x50() .with_title("Roguelike Tutorial") .build()?; context.with_post_scanlines(true); }
如果你选择这样做,游戏看起来有点像经典的Qud 的洞穴:

总结
现在我们有了一个图形用户界面,看起来相当不错!
本章的源代码可以在此处找到这里
运行本章的示例与 Web Assembly,在您的浏览器中(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
物品和库存
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
到目前为止,我们有地图、怪物和砸东西!没有捡拾物品的“谋杀霍博”体验,就不是完整的 roguelike 游戏。本章将添加一些基本物品到游戏中,以及拾取、使用和丢弃它们所需的用户界面元素。
思考编写项目
面向对象和实体组件系统之间的一个主要区别是,你不再考虑某个东西位于继承树上,而是考虑它如何从组件组合而成。理想情况下,你已经有一些组件可以随时使用!
那么... 一个物品由什么组成?思考一下,可以说一个物品具有以下属性:
- 它有一个
Renderable- 一种绘制它的方式。 - 如果它在地上等待拾取 - 它有一个
Position。 - 如果它不在地上 - 比如在背包里,它需要一种方式来表明它被存储了。我们将从
InPack开始。 - 它是一个
item,这意味着它可以被拾取。所以它需要某种Item组件。 - 如果它可以被使用,它需要某种方式来表明它可以被使用 - 以及如何使用它。
一致性随机
计算机实际上非常不擅长生成随机数。计算机本质上是确定性的——所以(不涉及加密内容)当你请求一个“随机”数时,你实际上得到的是一个“序列中非常难以预测的下一个数”。这个序列由一个种子控制——使用相同的种子,你总是得到相同的掷骰结果!
由于我们有越来越多的使用随机性的东西,让我们继续将 RNG(随机数生成器)作为一个资源。
在 main.rs 中,我们添加:
#![allow(unused)] fn main() { gs.ecs.insert(rltk::RandomNumberGenerator::new()); }
我们现在可以随时访问 RNG,而不必传递一个。由于我们没有创建新的 RNG,我们可以用种子启动它(我们会使用seeded而不是new,并提供一个种子)。我们稍后再担心这个问题;现在,它只是会让我们的代码更简洁!
改进的生成
每个房间一个怪物,总是在中间,使得游戏相当无聊。我们还需要支持生成物品以及怪物!
为此,我们将创建一个新文件 spawner.rs:
#![allow(unused)] fn main() { use rltk::{ RGB, RandomNumberGenerator }; use specs::prelude::*; use super::{CombatStats, Player, Renderable, Name, Position, Viewshed, Monster, BlocksTile}; /// Spawns the player and returns his/her entity object. pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity { ecs .create_entity() .with(Position { x: player_x, y: player_y }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), }) .with(Player{}) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Name{name: "Player".to_string() }) .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 }) .build() } /// Spawns a random monster at a given location pub fn random_monster(ecs: &mut World, x: i32, y: i32) { let roll :i32; { let mut rng = ecs.write_resource::<RandomNumberGenerator>(); roll = rng.roll_dice(1, 2); } match roll { 1 => { orc(ecs, x, y) } _ => { goblin(ecs, x, y) } } } fn orc(ecs: &mut World, x: i32, y: i32) { monster(ecs, x, y, rltk::to_cp437('o'), "Orc"); } fn goblin(ecs: &mut World, x: i32, y: i32) { monster(ecs, x, y, rltk::to_cp437('g'), "Goblin"); } fn monster<S : ToString>(ecs: &mut World, x: i32, y: i32, glyph : rltk::FontCharType, name : S) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph, fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), }) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Monster{}) .with(Name{ name : name.to_string() }) .with(BlocksTile{}) .with(CombatStats{ max_hp: 16, hp: 16, defense: 1, power: 4 }) .build(); } }
如您所见,我们将 main.rs 中的现有代码包装在不同模块的函数中。我们不一定非要这样做,但这有助于保持整洁。由于我们将扩展生成功能,因此将它们分开是很好的。现在我们修改 main.rs 以使用它:
#![allow(unused)] fn main() { let player_entity = spawner::player(&mut gs.ecs, player_x, player_y); gs.ecs.insert(rltk::RandomNumberGenerator::new()); for room in map.rooms.iter().skip(1) { let (x,y) = room.center(); spawner::random_monster(&mut gs.ecs, x, y); } }
那确实更整洁了!cargo run 会给你我们在上一章结束时所拥有的东西。
生成所有事物
我们将扩展函数以在每个房间生成多个怪物,0 是一个选项。首先,我们将上一章中引入的 Map 常量改为公共的,以便在 spawner.rs 中使用:
#![allow(unused)] fn main() { pub const MAPWIDTH : usize = 80; pub const MAPHEIGHT : usize = 43; pub const MAPCOUNT : usize = MAPHEIGHT * MAPWIDTH; }
我们希望控制我们生成的物品数量,包括怪物和物品。我们希望怪物比物品多,以避免出现“蒙蒂霍尔”地牢!另外,在 spawner.rs 中,我们将添加这些常量(它们可以放在任何地方,放在其他常量旁边是有意义的):
#![allow(unused)] fn main() { const MAX_MONSTERS : i32 = 4; const MAX_ITEMS : i32 = 2; }
仍在 spawner.rs 中,我们创建了一个新函数 - spawn_room,该函数使用这些常量:
#![allow(unused)] fn main() { /// Fills a room with stuff! pub fn spawn_room(ecs: &mut World, room : &Rect) { let mut monster_spawn_points : Vec<usize> = Vec::new(); // Scope to keep the borrow checker happy { let mut rng = ecs.write_resource::<RandomNumberGenerator>(); let num_monsters = rng.roll_dice(1, MAX_MONSTERS + 2) - 3; for _i in 0 .. num_monsters { let mut added = false; while !added { let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 - room.x1))) as usize; let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 - room.y1))) as usize; let idx = (y * MAPWIDTH) + x; if !monster_spawn_points.contains(&idx) { monster_spawn_points.push(idx); added = true; } } } } // Actually spawn the monsters for idx in monster_spawn_points.iter() { let x = *idx % MAPWIDTH; let y = *idx / MAPWIDTH; random_monster(ecs, x as i32, y as i32); } } }
这获取了 RNG 和地图,并为应该生成多少怪物掷骰子。然后它会不断尝试添加未被占用的随机位置,直到生成足够数量的怪物。每个怪物随后在确定的位置生成。借用检查器对我们可以可变访问rng,然后传递 ECS 本身的想法并不满意:因此我们引入一个作用域以使其满意(在我们完成操作后自动放弃对 RNG 的访问)。
在 main.rs 中,我们将怪物生成器替换为:
#![allow(unused)] fn main() { for room in map.rooms.iter().skip(1) { spawner::spawn_room(&mut gs.ecs, room); } }
如果你现在cargo run这个项目,每个房间会有 0 到 4 个怪物。可能会有些棘手!

健康药水实体
我们将通过在游戏中添加生命药水来提高一点生存机会!我们将首先添加一些组件来帮助定义药水。在 components.rs 中:
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct Item {} #[derive(Component, Debug)] pub struct Potion { pub heal_amount : i32 } }
我们当然需要在 main.rs 中注册这些:
#![allow(unused)] fn main() { gs.ecs.register::<Item>(); gs.ecs.register::<Potion>(); }
在 spawner.rs 中,我们将添加一个新函数:health_potion:
#![allow(unused)] fn main() { fn health_potion(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('¡'), fg: RGB::named(rltk::MAGENTA), bg: RGB::named(rltk::BLACK), }) .with(Name{ name : "Health Potion".to_string() }) .with(Item{}) .with(Potion{ heal_amount: 8 }) .build(); } }
这很简单:我们创建一个实体,包含一个位置、一个可渲染对象(我们选择了 ¡,因为它看起来有点像药水,而且我最喜欢的游戏《矮人要塞》也使用它)、一个名称、一个 Item 组件和一个 Potion 组件,该组件指定它可以治愈 8 点伤害。
现在我们可以修改生成器代码,使其有机会生成 0 到 2 个物品:
#![allow(unused)] fn main() { pub fn spawn_room(ecs: &mut World, room : &Rect) { let mut monster_spawn_points : Vec<usize> = Vec::new(); let mut item_spawn_points : Vec<usize> = Vec::new(); // Scope to keep the borrow checker happy { let mut rng = ecs.write_resource::<RandomNumberGenerator>(); let num_monsters = rng.roll_dice(1, MAX_MONSTERS + 2) - 3; let num_items = rng.roll_dice(1, MAX_ITEMS + 2) - 3; for _i in 0 .. num_monsters { let mut added = false; while !added { let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 - room.x1))) as usize; let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 - room.y1))) as usize; let idx = (y * MAPWIDTH) + x; if !monster_spawn_points.contains(&idx) { monster_spawn_points.push(idx); added = true; } } } for _i in 0 .. num_items { let mut added = false; while !added { let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 - room.x1))) as usize; let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 - room.y1))) as usize; let idx = (y * MAPWIDTH) + x; if !item_spawn_points.contains(&idx) { item_spawn_points.push(idx); added = true; } } } } // Actually spawn the monsters for idx in monster_spawn_points.iter() { let x = *idx % MAPWIDTH; let y = *idx / MAPWIDTH; random_monster(ecs, x as i32, y as i32); } // Actually spawn the potions for idx in item_spawn_points.iter() { let x = *idx % MAPWIDTH; let y = *idx / MAPWIDTH; health_potion(ecs, x as i32, y as i32); } } }
如果你现在cargo run这个项目,房间里有时会包含生命药水。工具提示和渲染“只需工作”——因为它们有使用所需组件。

拾取物品
拥有药水是一个很好的开始,但如果能捡起它们就更好了!我们将在 components.rs 中创建一个新组件(并在 main.rs 中注册它!),以表示物品在某个人的背包中:
#![allow(unused)] fn main() { #[derive(Component, Debug, Clone)] pub struct InBackpack { pub owner : Entity } }
我们还希望使物品收集通用化——也就是说,任何实体都可以捡起物品。让它只适用于玩家会非常简单,但后来我们可能会决定怪物也可以捡起战利品(引入全新的战术元素——诱饵!)。因此,我们还会在components.rs中创建一个表示意图的组件(并在main.rs中注册它):
#![allow(unused)] fn main() { #[derive(Component, Debug, Clone)] pub struct WantsToPickupItem { pub collected_by : Entity, pub item : Entity } }
接下来,我们将组装一个处理WantsToPickupItem通知的系统。我们将创建一个新文件,inventory_system.rs:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{WantsToPickupItem, Name, InBackpack, Position, gamelog::GameLog}; pub struct ItemCollectionSystem {} impl<'a> System<'a> for ItemCollectionSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToPickupItem>, WriteStorage<'a, Position>, ReadStorage<'a, Name>, WriteStorage<'a, InBackpack> ); fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, mut wants_pickup, mut positions, names, mut backpack) = data; for pickup in wants_pickup.join() { positions.remove(pickup.item); backpack.insert(pickup.item, InBackpack{ owner: pickup.collected_by }).expect("Unable to insert backpack entry"); if pickup.collected_by == *player_entity { gamelog.entries.push(format!("You pick up the {}.", names.get(pickup.item).unwrap().name)); } } wants_pickup.clear(); } } }
这会迭代请求以拾取物品,移除它们的位置组件,并添加一个分配给收集者的InBackpack组件。不要忘记将其添加到main.rs中的系统列表中:
#![allow(unused)] fn main() { let mut pickup = ItemCollectionSystem{}; pickup.run_now(&self.ecs); }
下一步是添加一个输入命令来拾取物品。g 是常用的键,所以我们就用这个(随时可以更改!)。在 player.rs 中,在不断增长的输入 match 语句中,我们添加:
#![allow(unused)] fn main() { VirtualKeyCode::G => get_item(&mut gs.ecs), }
你可能已经猜到了,下一步是实现 get_item:
#![allow(unused)] fn main() { fn get_item(ecs: &mut World) { let player_pos = ecs.fetch::<Point>(); let player_entity = ecs.fetch::<Entity>(); let entities = ecs.entities(); let items = ecs.read_storage::<Item>(); let positions = ecs.read_storage::<Position>(); let mut gamelog = ecs.fetch_mut::<GameLog>(); let mut target_item : Option<Entity> = None; for (item_entity, _item, position) in (&entities, &items, &positions).join() { if position.x == player_pos.x && position.y == player_pos.y { target_item = Some(item_entity); } } match target_item { None => gamelog.entries.push("There is nothing here to pick up.".to_string()), Some(item) => { let mut pickup = ecs.write_storage::<WantsToPickupItem>(); pickup.insert(*player_entity, WantsToPickupItem{ collected_by: *player_entity, item }).expect("Unable to insert want to pickup"); } } } }
这从 ECS 获取一堆引用/访问器,并迭代所有具有位置的项。如果它与玩家的位置匹配,则设置target_item。然后,如果target_item为空,我们告诉玩家没有东西可以捡起。如果不是,它为我们刚刚添加的系统添加一个拾取请求。
如果你现在运行项目,你可以在任何地方按 g 键,它会告诉你没有什么可以获取。如果你站在一瓶药水上,当你按 g 键时它会消失!它在我们的背包里——但我们除了日志条目外没有任何方法知道这一点。
列出您的库存
能够查看您的库存列表是个好主意!这将是一个游戏模式——也就是说,游戏循环可以进入的另一种状态。因此,首先,我们将在main.rs中扩展RunMode以包含它:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory } }
i 键是库存的一个流行选择(b 也很流行!),所以在 player.rs 中,我们将在玩家输入代码中添加以下内容:
#![allow(unused)] fn main() { VirtualKeyCode::I => return RunState::ShowInventory, }
在 main.rs 的 tick 函数中,我们将添加另一个匹配:
#![allow(unused)] fn main() { RunState::ShowInventory => { if gui::show_inventory(self, ctx) == gui::ItemMenuResult::Cancel { newrunstate = RunState::AwaitingInput; } } }
这自然引出了实现show_inventory!在gui.rs中,我们添加:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum ItemMenuResult { Cancel, NoResponse, Selected } pub fn show_inventory(gs : &mut State, ctx : &mut Rltk) -> ItemMenuResult { let player_entity = gs.ecs.fetch::<Entity>(); let names = gs.ecs.read_storage::<Name>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity ); let count = inventory.count(); let mut y = (25 - (count / 2)) as i32; ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Inventory"); ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); let mut j = 0; for (_pack, name) in (&backpack, &names).join().filter(|item| item.0.owner == *player_entity ) { ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as rltk::FontCharType); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, &name.name.to_string()); y += 1; j += 1; } match ctx.key { None => ItemMenuResult::NoResponse, Some(key) => { match key { VirtualKeyCode::Escape => { ItemMenuResult::Cancel } _ => ItemMenuResult::NoResponse } } } } }
这首先使用 Rust 迭代器的 filter 功能来计算背包中的所有物品。然后绘制一个适当大小的框,并用标题和说明装饰它。接下来,迭代所有匹配的物品并以菜单格式呈现它们。最后,等待键盘输入——如果你按下 ESCAPE,则表示是时候关闭菜单了。
如果你现在 cargo run 你的项目,你可以看到你收集的物品:

使用物品
现在我们可以显示我们的库存了,让我们实际选择一个物品并使用它。我们将扩展菜单以返回一个物品实体和一个结果:
#![allow(unused)] fn main() { pub fn show_inventory(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) { let player_entity = gs.ecs.fetch::<Entity>(); let names = gs.ecs.read_storage::<Name>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let entities = gs.ecs.entities(); let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity ); let count = inventory.count(); let mut y = (25 - (count / 2)) as i32; ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Inventory"); ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); let mut equippable : Vec<Entity> = Vec::new(); let mut j = 0; for (entity, _pack, name) in (&entities, &backpack, &names).join().filter(|item| item.1.owner == *player_entity ) { ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as rltk::FontCharType); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, &name.name.to_string()); equippable.push(entity); y += 1; j += 1; } match ctx.key { None => (ItemMenuResult::NoResponse, None), Some(key) => { match key { VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) } _ => { let selection = rltk::letter_to_option(key); if selection > -1 && selection < count as i32 { return (ItemMenuResult::Selected, Some(equippable[selection as usize])); } (ItemMenuResult::NoResponse, None) } } } } } }
我们在 main.rs 中调用 show_inventory 现在是无效的,所以我们来修复它:
#![allow(unused)] fn main() { RunState::ShowInventory => { let result = gui::show_inventory(self, ctx); match result.0 { gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput, gui::ItemMenuResult::NoResponse => {} gui::ItemMenuResult::Selected => { let item_entity = result.1.unwrap(); let names = self.ecs.read_storage::<Name>(); let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>(); gamelog.entries.push(format!("You try to use {}, but it isn't written yet", names.get(item_entity).unwrap().name)); newrunstate = RunState::AwaitingInput; } } } }
如果你现在尝试使用库存中的物品,你会得到一条日志记录,表明你尝试使用它,但我们还没有编写那部分代码。这是一个开始!
再次,我们希望编写通用代码——以便最终怪物可以使用药水。我们将暂时作弊,假设所有物品都是药水,并创建一个药水系统;我们稍后会将其变成更有用的东西。因此,我们将首先在 components.rs 中创建一个“意图”组件(并在 main.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct WantsToDrinkPotion { pub potion : Entity } }
将以下内容添加到 inventory_system.rs:
#![allow(unused)] fn main() { pub struct PotionUseSystem {} impl<'a> System<'a> for PotionUseSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, Entities<'a>, WriteStorage<'a, WantsToDrinkPotion>, ReadStorage<'a, Name>, ReadStorage<'a, Potion>, WriteStorage<'a, CombatStats> ); fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, entities, mut wants_drink, names, potions, mut combat_stats) = data; for (entity, drink, stats) in (&entities, &wants_drink, &mut combat_stats).join() { let potion = potions.get(drink.potion); match potion { None => {} Some(potion) => { stats.hp = i32::min(stats.max_hp, stats.hp + potion.heal_amount); if entity == *player_entity { gamelog.entries.push(format!("You drink the {}, healing {} hp.", names.get(drink.potion).unwrap().name, potion.heal_amount)); } entities.delete(drink.potion).expect("Delete failed"); } } } wants_drink.clear(); } } }
并在系统列表中注册以运行:
#![allow(unused)] fn main() { let mut potions = PotionUseSystem{}; potions.run_now(&self.ecs); }
像我们看过的其他系统一样,这个迭代所有的WantsToDrinkPotion意图对象。然后根据Potion组件中设置的量治愈饮用者,并删除药水。由于所有的放置信息都附加在药水本身上,因此无需四处检查以确保它从适当的背包中移除:实体不复存在,并带走其组件。
使用 cargo run 测试这个会带来一个惊喜:使用后药水没有被删除!这是因为 ECS 只是将实体标记为 dead - 它在系统中不会删除它们(以免打乱迭代器和线程)。因此,每次调用 dispatch 后,我们需要添加一个对 maintain 的调用。在 main.ecs 中:
#![allow(unused)] fn main() { RunState::PreRun => { self.run_systems(); self.ecs.maintain(); newrunstate = RunState::AwaitingInput; } ... RunState::PlayerTurn => { self.run_systems(); self.ecs.maintain(); newrunstate = RunState::MonsterTurn; } RunState::MonsterTurn => { self.run_systems(); self.ecs.maintain(); newrunstate = RunState::AwaitingInput; } }
最后,如果选择了物品,我们需要更改 RunState::ShowInventory 的处理,创建一个 WantsToDrinkPotion 意图:
#![allow(unused)] fn main() { RunState::ShowInventory => { let result = gui::show_inventory(self, ctx); match result.0 { gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput, gui::ItemMenuResult::NoResponse => {} gui::ItemMenuResult::Selected => { let item_entity = result.1.unwrap(); let mut intent = self.ecs.write_storage::<WantsToDrinkPotion>(); intent.insert(*self.ecs.fetch::<Entity>(), WantsToDrinkPotion{ potion: item_entity }).expect("Unable to insert intent"); newrunstate = RunState::PlayerTurn; } } } }
现在,如果你 cargo run 这个项目,你可以拾取并饮用生命药水:

丢弃物品
你可能希望能够从你的库存中丢弃物品,特别是以后可以用作诱饵的时候。我们将遵循类似的模式来处理这一部分——创建一个意图组件,一个选择它的菜单,以及一个执行丢弃的系统。
所以我们创建一个组件(在 components.rs 中),并在 main.rs 中注册它:
#![allow(unused)] fn main() { #[derive(Component, Debug, ConvertSaveload, Clone)] pub struct WantsToDropItem { pub item: Entity } }
我们在 inventory_system.rs 中添加另一个系统:
#![allow(unused)] fn main() { pub struct ItemDropSystem {} impl<'a> System<'a> for ItemDropSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, Entities<'a>, WriteStorage<'a, WantsToDropItem>, ReadStorage<'a, Name>, WriteStorage<'a, Position>, WriteStorage<'a, InBackpack> ); fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, entities, mut wants_drop, names, mut positions, mut backpack) = data; for (entity, to_drop) in (&entities, &wants_drop).join() { let mut dropper_pos : Position = Position{x:0, y:0}; { let dropped_pos = positions.get(entity).unwrap(); dropper_pos.x = dropped_pos.x; dropper_pos.y = dropped_pos.y; } positions.insert(to_drop.item, Position{ x : dropper_pos.x, y : dropper_pos.y }).expect("Unable to insert position"); backpack.remove(to_drop.item); if entity == *player_entity { gamelog.entries.push(format!("You drop the {}.", names.get(to_drop.item).unwrap().name)); } } wants_drop.clear(); } } }
在 main.rs 中的调度构建器中注册它:
#![allow(unused)] fn main() { let mut drop_items = ItemDropSystem{}; drop_items.run_now(&self.ecs); }
我们将在 main.rs 中添加一个新的 RunState:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem } }
现在在 player.rs 中,我们将 d 用于 丢弃 添加到命令列表中:
#![allow(unused)] fn main() { VirtualKeyCode::D => return RunState::ShowDropItem, }
在 gui.rs 中,我们需要另一个菜单——这次是用于丢弃物品的:
#![allow(unused)] fn main() { pub fn drop_item_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) { let player_entity = gs.ecs.fetch::<Entity>(); let names = gs.ecs.read_storage::<Name>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let entities = gs.ecs.entities(); let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity ); let count = inventory.count(); let mut y = (25 - (count / 2)) as i32; ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Drop Which Item?"); ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); let mut equippable : Vec<Entity> = Vec::new(); let mut j = 0; for (entity, _pack, name) in (&entities, &backpack, &names).join().filter(|item| item.1.owner == *player_entity ) { ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as rltk::FontCharType); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, &name.name.to_string()); equippable.push(entity); y += 1; j += 1; } match ctx.key { None => (ItemMenuResult::NoResponse, None), Some(key) => { match key { VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) } _ => { let selection = rltk::letter_to_option(key); if selection > -1 && selection < count as i32 { return (ItemMenuResult::Selected, Some(equippable[selection as usize])); } (ItemMenuResult::NoResponse, None) } } } } } }
我们也需要扩展main.rs中的状态处理程序以使用它:
#![allow(unused)] fn main() { RunState::ShowDropItem => { let result = gui::drop_item_menu(self, ctx); match result.0 { gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput, gui::ItemMenuResult::NoResponse => {} gui::ItemMenuResult::Selected => { let item_entity = result.1.unwrap(); let mut intent = self.ecs.write_storage::<WantsToDropItem>(); intent.insert(*self.ecs.fetch::<Entity>(), WantsToDropItem{ item: item_entity }).expect("Unable to insert intent"); newrunstate = RunState::PlayerTurn; } } } }
如果你运行项目,现在可以按 d 来丢弃物品!这里有一个在受到围攻时不太明智地丢弃药水的截图:

渲染顺序
你可能已经注意到,当你走过一瓶药水时,它会覆盖在你上方——完全移除了你玩家的环境!我们将通过在Renderables中添加一个render_order字段来修复这个问题:
#![allow(unused)] fn main() { #[derive(Component)] pub struct Renderable { pub glyph: rltk::FontCharType, pub fg: RGB, pub bg: RGB, pub render_order : i32 } }
您的 IDE 可能现在正在突出显示许多没有这些信息的Renderable组件的错误。我们将在各个地方添加它:玩家是0(首先渲染),怪物是1(第二),物品是2(最后)。例如,在Player生成器中,Renderable现在看起来像这样:
#![allow(unused)] fn main() { .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), render_order: 0 }) }
要使这个做某些事情,我们转到main.rs中的项目渲染代码,并对迭代器进行排序。我们参考了规格书来了解如何做到这一点!基本上,我们获取Position和Renderable组件的联合集,并将它们收集到一个向量中。然后我们对向量进行排序,并迭代它以按适当顺序渲染。在main.rs中,用以下代码替换之前的实体渲染代码:
#![allow(unused)] fn main() { let mut data = (&positions, &renderables).join().collect::<Vec<_>>(); data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) ); for (pos, render) in data.iter() { let idx = map.xy_idx(pos.x, pos.y); if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) } } }
总结
本章展示了使用 ECS 的强大功能:拾取、使用和丢弃实体相对简单——一旦玩家可以做到,其他任何东西也可以(如果你将其添加到它们的 AI 中)。我们还展示了如何排序 ECS 获取,以保持合理的渲染顺序。
本章的源代码可以在此处找到这里
在浏览器中使用 Web Assembly 运行本章示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
远程卷轴和瞄准
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
在上一章中,我们添加了物品和库存——以及一种物品类型,即生命药水。现在我们将添加第二种物品类型:魔法飞弹卷轴,它可以让你远程攻击实体。
使用组件来描述物品的功能
在上一章中,我们基本上编写了代码以确保所有物品都是治疗药水。这使事情开始运转,但不是很灵活。因此,我们将从将物品分解为更多组件类型开始。我们将从一个简单的标志组件Consumable开始:
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct Consumable {} }
拥有此物品表示使用它会销毁它(使用时消耗)。因此,我们将 PotionUseSystem(我们将其重命名为 ItemUseSystem!)中总是调用的 entities.delete(useitem.item).expect("Delete failed"); 替换为:
#![allow(unused)] fn main() { let consumable = consumables.get(useitem.item); match consumable { None => {} Some(_) => { entities.delete(useitem.item).expect("Delete failed"); } } }
这很简单:检查组件是否有Consumable标签,如果有就销毁它。同样,我们可以用ProvidesHealing替换Potion部分,以表明这是药水实际的作用。在components.rs中:
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct ProvidesHealing { pub heal_amount : i32 } }
在我们的 ItemUseSystem 中:
#![allow(unused)] fn main() { let item_heals = healing.get(useitem.item); match item_heals { None => {} Some(healer) => { stats.hp = i32::min(stats.max_hp, stats.hp + healer.heal_amount); if entity == *player_entity { gamelog.entries.push(format!("You drink the {}, healing {} hp.", names.get(useitem.item).unwrap().name, healer.heal_amount)); } } } }
综合起来,我们创建药水的代码(在 spawner.rs 中)如下所示:
#![allow(unused)] fn main() { fn health_potion(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('¡'), fg: RGB::named(rltk::MAGENTA), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Health Potion".to_string() }) .with(Item{}) .with(Consumable{}) .with(ProvidesHealing{ heal_amount: 8 }) .build(); } }
所以我们描述了它的位置、外观、名称,表示它是一个物品,使用时会被消耗,并提供 8 点治疗。这描述得很详细——未来的物品可以混合/匹配。随着我们添加组件,物品系统将变得越来越灵活。
描述远程魔法飞弹卷轴
我们需要添加一些更多的组件!在 components.rs(并在 main.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct Ranged { pub range : i32 } #[derive(Component, Debug)] pub struct InflictsDamage { pub damage : i32 } }
这反过来让我们可以在 spawner.rs 中编写一个 magic_missile_scroll 函数,该函数有效地描述了卷轴:
#![allow(unused)] fn main() { fn magic_missile_scroll(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437(')'), fg: RGB::named(rltk::CYAN), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Magic Missile Scroll".to_string() }) .with(Item{}) .with(Consumable{}) .with(Ranged{ range: 6 }) .with(InflictsDamage{ damage: 8 }) .build(); } }
这清楚地列出了它的特性:它有一个位置,一个外观,一个名字,它是一个使用后会被销毁的物品,它的范围是 6 个格子,并造成 8 点伤害。这就是我喜欢组件的地方:过了一段时间,听起来更像是在描述一个设备的蓝图,而不是写很多行代码!
我们将继续将它们添加到生成列表中:
#![allow(unused)] fn main() { fn random_item(ecs: &mut World, x: i32, y: i32) { let roll :i32; { let mut rng = ecs.write_resource::<RandomNumberGenerator>(); roll = rng.roll_dice(1, 2); } match roll { 1 => { health_potion(ecs, x, y) } _ => { magic_missile_scroll(ecs, x, y) } } } }
在物品生成代码中,将调用 health_potion 替换为调用 random_item。
如果你现在运行程序(使用 cargo run),你会发现周围有卷轴和药水。组件系统已经提供了相当多的功能:
- 你可以在地图上看到它们(感谢
Renderable和Position) - 你可以捡起它们并放下它们(感谢
Item) - 你可以在你的库存中列出它们
- 你可以对它们调用
use,它们会被销毁:但什么也不会发生。

为物品实现范围伤害
我们希望魔法飞弹能够被瞄准:你激活它,然后必须选择一个受害者。这将是一种新的输入模式,所以我们再次在 main.rs 中扩展 RunState:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity} } }
我们将在 main.rs 中扩展 ShowInventory 的处理程序,以处理远程物品并引发模式切换:
#![allow(unused)] fn main() { RunState::ShowInventory => { let result = gui::show_inventory(self, ctx); match result.0 { gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput, gui::ItemMenuResult::NoResponse => {} gui::ItemMenuResult::Selected => { let item_entity = result.1.unwrap(); let is_ranged = self.ecs.read_storage::<Ranged>(); let is_item_ranged = is_ranged.get(item_entity); if let Some(is_item_ranged) = is_item_ranged { newrunstate = RunState::ShowTargeting{ range: is_item_ranged.range, item: item_entity }; } else { let mut intent = self.ecs.write_storage::<WantsToUseItem>(); intent.insert(*self.ecs.fetch::<Entity>(), WantsToUseItem{ item: item_entity, target: None }).expect("Unable to insert intent"); newrunstate = RunState::PlayerTurn; } } } } }
所以在 main.rs 中,当我们匹配适当的游戏模式时,我们可以暂时插入:
#![allow(unused)] fn main() { RunState::ShowTargeting{range, item} => { let target = gui::ranged_target(self, ctx, range); } }
这自然会导致实际编写 gui::ranged_target。这看起来很复杂,但实际上非常简单:
#![allow(unused)] fn main() { pub fn ranged_target(gs : &mut State, ctx : &mut Rltk, range : i32) -> (ItemMenuResult, Option<Point>) { let player_entity = gs.ecs.fetch::<Entity>(); let player_pos = gs.ecs.fetch::<Point>(); let viewsheds = gs.ecs.read_storage::<Viewshed>(); ctx.print_color(5, 0, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Select Target:"); // Highlight available target cells let mut available_cells = Vec::new(); let visible = viewsheds.get(*player_entity); if let Some(visible) = visible { // We have a viewshed for idx in visible.visible_tiles.iter() { let distance = rltk::DistanceAlg::Pythagoras.distance2d(*player_pos, *idx); if distance <= range as f32 { ctx.set_bg(idx.x, idx.y, RGB::named(rltk::BLUE)); available_cells.push(idx); } } } else { return (ItemMenuResult::Cancel, None); } // Draw mouse cursor let mouse_pos = ctx.mouse_pos(); let mut valid_target = false; for idx in available_cells.iter() { if idx.x == mouse_pos.0 && idx.y == mouse_pos.1 { valid_target = true; } } if valid_target { ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::CYAN)); if ctx.left_click { return (ItemMenuResult::Selected, Some(Point::new(mouse_pos.0, mouse_pos.1))); } } else { ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::RED)); if ctx.left_click { return (ItemMenuResult::Cancel, None); } } (ItemMenuResult::NoResponse, None) } }
所以我们首先获取玩家的位置和视野,并迭代他们可以看到的单元格。我们检查单元格的范围与物品的范围,如果在范围内,我们将单元格高亮为蓝色。我们还维护一个可能目标单元格的列表。然后,我们获取鼠标位置;如果指向有效目标,我们将其高亮为青色——否则使用红色。如果你点击一个有效单元格,它会返回你瞄准的目标信息——否则,它会取消。
现在我们将 ShowTargeting 代码扩展以处理这种情况:
#![allow(unused)] fn main() { RunState::ShowTargeting{range, item} => { let result = gui::ranged_target(self, ctx, range); match result.0 { gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput, gui::ItemMenuResult::NoResponse => {} gui::ItemMenuResult::Selected => { let mut intent = self.ecs.write_storage::<WantsToUseItem>(); intent.insert(*self.ecs.fetch::<Entity>(), WantsToUseItem{ item, target: result.1 }).expect("Unable to insert intent"); newrunstate = RunState::PlayerTurn; } } } }
这是什么target?我在components.rs中的WantsToUseItem添加了另一个字段:
#![allow(unused)] fn main() { #[derive(Component, Debug, ConvertSaveload, Clone)] pub struct WantsToUseItem { pub item : Entity, pub target : Option<rltk::Point> } }
所以现在当你收到一个 WantsToUseItem 时,你可以知道 用户 是拥有实体,物品 是 item 字段,并且它瞄准的是 target - 如果有的话(瞄准对治疗药水来说没有太大意义!)。
所以现在我们可以为我们的 ItemUseSystem 添加另一个条件:
#![allow(unused)] fn main() { // If it inflicts damage, apply it to the target cell let item_damages = inflict_damage.get(useitem.item); match item_damages { None => {} Some(damage) => { let target_point = useitem.target.unwrap(); let idx = map.xy_idx(target_point.x, target_point.y); used_item = false; for mob in map.tile_content[idx].iter() { SufferDamage::new_damage(&mut suffer_damage, *mob, damage.damage); if entity == *player_entity { let mob_name = names.get(*mob).unwrap(); let item_name = names.get(useitem.item).unwrap(); gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage)); } used_item = true; } } } }
这检查物品上是否有造成伤害组件——如果有,则对目标单元格内的所有人施加伤害。
如果你运行游戏,你现在可以用你的魔法飞弹卷轴攻击实体!
介绍效果范围
我们将添加另一种滚动类型 - 火球。这是一个老牌经典,并引入了 AoE - 区域效果 - 伤害。我们将首先添加一个组件来表明我们的意图:
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct AreaOfEffect { pub radius : i32 } }
我们将扩展spawner.rs中的random_item函数,使其成为一个选项:
#![allow(unused)] fn main() { fn random_item(ecs: &mut World, x: i32, y: i32) { let roll :i32; { let mut rng = ecs.write_resource::<RandomNumberGenerator>(); roll = rng.roll_dice(1, 3); } match roll { 1 => { health_potion(ecs, x, y) } 2 => { fireball_scroll(ecs, x, y) } _ => { magic_missile_scroll(ecs, x, y) } } } }
所以现在我们可以编写一个fireball_scroll函数来实际生成它们。这与其他物品非常相似:
#![allow(unused)] fn main() { fn fireball_scroll(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437(')'), fg: RGB::named(rltk::ORANGE), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Fireball Scroll".to_string() }) .with(Item{}) .with(Consumable{}) .with(Ranged{ range: 6 }) .with(InflictsDamage{ damage: 20 }) .with(AreaOfEffect{ radius: 3 }) .build(); } }
请注意,基本上是一样的——但我们正在添加一个AreaOfEffect组件,以表明这是我们想要的。如果你现在运行cargo run,你会在游戏中看到火球卷轴——它们会对单个实体造成伤害。显然,我们必须解决这个问题!
在我们的 UseItemSystem 中,我们将构建一个新部分来确定效果的目标列表:
#![allow(unused)] fn main() { // Targeting let mut targets : Vec<Entity> = Vec::new(); match useitem.target { None => { targets.push( *player_entity ); } Some(target) => { let area_effect = aoe.get(useitem.item); match area_effect { None => { // Single target in tile let idx = map.xy_idx(target.x, target.y); for mob in map.tile_content[idx].iter() { targets.push(*mob); } } Some(area_effect) => { // AoE let mut blast_tiles = rltk::field_of_view(target, area_effect.radius, &*map); blast_tiles.retain(|p| p.x > 0 && p.x < map.width-1 && p.y > 0 && p.y < map.height-1 ); for tile_idx in blast_tiles.iter() { let idx = map.xy_idx(tile_idx.x, tile_idx.y); for mob in map.tile_content[idx].iter() { targets.push(*mob); } } } } } } }
这表示“如果没有目标,则将其应用于玩家”。如果有目标,检查它是否是范围效果事件;如果是 - 从该点绘制适当半径的视野,并添加目标区域中的每个实体。如果不是,我们只需获取目标图块中的实体。
现在我们需要使效果代码通用化。我们不想假设效果是独立的;以后我们可能会决定用卷轴攻击某物会产生各种效果!所以对于治疗,它看起来像这样:
#![allow(unused)] fn main() { // If it heals, apply the healing let item_heals = healing.get(useitem.item); match item_heals { None => {} Some(healer) => { for target in targets.iter() { let stats = combat_stats.get_mut(*target); if let Some(stats) = stats { stats.hp = i32::min(stats.max_hp, stats.hp + healer.heal_amount); if entity == *player_entity { gamelog.entries.push(format!("You use the {}, healing {} hp.", names.get(useitem.item).unwrap().name, healer.heal_amount)); } } } } } }
伤害代码实际上已经简化,因为我们已经计算了目标:
#![allow(unused)] fn main() { // If it inflicts damage, apply it to the target cell let item_damages = inflict_damage.get(useitem.item); match item_damages { None => {} Some(damage) => { used_item = false; for mob in targets.iter() { SufferDamage::new_damage(&mut suffer_damage, *mob, damage.damage); if entity == *player_entity { let mob_name = names.get(*mob).unwrap(); let item_name = names.get(useitem.item).unwrap(); gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage)); } used_item = true; } } } }
如果你现在 cargo run 这个项目,你可以使用魔法飞弹卷轴、火球卷轴和治疗药水。
混乱卷轴
让我们添加另一个物品 - 混乱卷轴。这些将对范围内的单个实体生效,并使它们在几个回合内陷入混乱 - 在此期间它们将什么也不做。我们将从描述我们希望在物品生成代码中实现的内容开始:
#![allow(unused)] fn main() { fn confusion_scroll(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437(')'), fg: RGB::named(rltk::PINK), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Confusion Scroll".to_string() }) .with(Item{}) .with(Consumable{}) .with(Ranged{ range: 6 }) .with(Confusion{ turns: 4 }) .build(); } }
我们也会将其添加到选项中:
#![allow(unused)] fn main() { fn random_item(ecs: &mut World, x: i32, y: i32) { let roll :i32; { let mut rng = ecs.write_resource::<RandomNumberGenerator>(); roll = rng.roll_dice(1, 4); } match roll { 1 => { health_potion(ecs, x, y) } 2 => { fireball_scroll(ecs, x, y) } 3 => { confusion_scroll(ecs, x, y) } _ => { magic_missile_scroll(ecs, x, y) } } } }
我们将添加一个新组件(并注册它!):
#![allow(unused)] fn main() { #[derive(Component, Debug)] pub struct Confusion { pub turns : i32 } }
这足以使它们出现、可触发并导致目标选择发生——但使用时不会发生任何事情。我们将添加将混乱传递给 ItemUseSystem 的能力:
#![allow(unused)] fn main() { // Can it pass along confusion? Note the use of scopes to escape from the borrow checker! let mut add_confusion = Vec::new(); { let causes_confusion = confused.get(useitem.item); match causes_confusion { None => {} Some(confusion) => { used_item = false; for mob in targets.iter() { add_confusion.push((*mob, confusion.turns )); if entity == *player_entity { let mob_name = names.get(*mob).unwrap(); let item_name = names.get(useitem.item).unwrap(); gamelog.entries.push(format!("You use {} on {}, confusing them.", item_name.name, mob_name.name)); } } } } } for mob in add_confusion.iter() { confused.insert(mob.0, Confusion{ turns: mob.1 }).expect("Unable to insert status"); } }
好的!现在我们可以将 Confused 状态添加到任何东西上。我们应该更新 monster_ai_system 以使用它。将循环替换为:
#![allow(unused)] fn main() { for (entity, mut viewshed,_monster,mut pos) in (&entities, &mut viewshed, &monster, &mut position).join() { let mut can_act = true; let is_confused = confused.get_mut(entity); if let Some(i_am_confused) = is_confused { i_am_confused.turns -= 1; if i_am_confused.turns < 1 { confused.remove(entity); } can_act = false; } if can_act { let distance = rltk::DistanceAlg::Pythagoras.distance2d(Point::new(pos.x, pos.y), *player_pos); if distance < 1.5 { wants_to_melee.insert(entity, WantsToMelee{ target: *player_entity }).expect("Unable to insert attack"); } else if viewshed.visible_tiles.contains(&*player_pos) { // Path to the player let path = rltk::a_star_search( map.xy_idx(pos.x, pos.y), map.xy_idx(player_pos.x, player_pos.y), &mut *map ); if path.success && path.steps.len()>1 { let mut idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = false; pos.x = path.steps[1] as i32 % map.width; pos.y = path.steps[1] as i32 / map.width; idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = true; viewshed.dirty = true; } } } } }
如果看到一个Confused组件,它会减少计时器。如果计时器达到 0,它会移除它。然后返回,使怪物跳过它的回合。
本章的源代码可以在此处找到这里
运行本章的示例与 Web Assembly,在您的浏览器中(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
版权 (C) 2024, myedgetech.com.
加载和保存游戏
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在前几个章节中,我们专注于让游戏变得可玩(即使不是非常有趣)。 你可以四处奔跑,杀死怪物,并使用各种物品。 这是一个好的开始! 大多数游戏都允许你停止游玩,并在稍后返回以继续。 幸运的是,Rust(和相关的库)使这变得相对容易。
主菜单
如果你要恢复游戏,你需要一个可以进行此操作的地方! 主菜单还让你有选择放弃上次保存、可能查看制作人员名单,并大致告诉世界你的游戏就在这里 - 并且是由你编写的。 拥有一个主菜单是很重要的,所以我们来创建一个。
处于菜单中是一种状态 - 因此我们将其添加到不断扩展的 RunState 枚举中。 我们希望将菜单状态包含在其中,因此定义最终看起来像这样:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection } } }
在 gui.rs 中,我们添加几个枚举类型来处理主菜单选项:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum MainMenuSelection { NewGame, LoadGame, Quit } #[derive(PartialEq, Copy, Clone)] pub enum MainMenuResult { NoSelection{ selected : MainMenuSelection }, Selected{ selected: MainMenuSelection } } }
你的 GUI 现在可能在告诉你 main.rs 有错误! 它是对的 - 我们需要处理新的 RunState 选项。 我们需要稍微调整一下,以确保在菜单中时我们也不会渲染 GUI 和地图。 所以我们重新安排 tick:
#![allow(unused)] fn main() { fn tick(&mut self, ctx : &mut Rltk) { let mut newrunstate; { let runstate = self.ecs.fetch::<RunState>(); newrunstate = *runstate; } ctx.cls(); match newrunstate { RunState::MainMenu{..} => {} _ => { draw_map(&self.ecs, ctx); { let positions = self.ecs.read_storage::<Position>(); let renderables = self.ecs.read_storage::<Renderable>(); let map = self.ecs.fetch::<Map>(); let mut data = (&positions, &renderables).join().collect::<Vec<_>>(); data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) ); for (pos, render) in data.iter() { let idx = map.xy_idx(pos.x, pos.y); if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) } } gui::draw_ui(&self.ecs, ctx); } } } ... }
我们还将在 RunState 的大型 match 语句中处理 MainMenu 状态:
#![allow(unused)] fn main() { RunState::MainMenu{ .. } => { let result = gui::main_menu(self, ctx); match result { gui::MainMenuResult::NoSelection{ selected } => newrunstate = RunState::MainMenu{ menu_selection: selected }, gui::MainMenuResult::Selected{ selected } => { match selected { gui::MainMenuSelection::NewGame => newrunstate = RunState::PreRun, gui::MainMenuSelection::LoadGame => newrunstate = RunState::PreRun, gui::MainMenuSelection::Quit => { ::std::process::exit(0); } } } } } }
我们基本上是在用新的菜单选择更新状态,如果选择了某些内容,我们会更改游戏状态。 对于 Quit,我们只是终止进程。 目前,我们将加载/开始游戏设置为执行相同的操作:进入 PreRun 状态以设置游戏。
最后要做的是编写菜单本身。 在 menu.rs 中:
pub fn main_menu(gs : &mut State, ctx : &mut Rltk) -> MainMenuResult { let runstate = gs.ecs.fetch::<RunState>(); ctx.print_color_centered(15, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Rust Roguelike Tutorial"); if let RunState::MainMenu{ menu_selection : selection } = *runstate { if selection == MainMenuSelection::NewGame { ctx.print_color_centered(24, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Begin New Game"); } else { ctx.print_color_centered(24, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Begin New Game"); } if selection == MainMenuSelection::LoadGame { ctx.print_color_centered(25, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Load Game"); } else { ctx.print_color_centered(25, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Load Game"); } if selection == MainMenuSelection::Quit { ctx.print_color_centered(26, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Quit"); } else { ctx.print_color_centered(26, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Quit"); } match ctx.key { None => return MainMenuResult::NoSelection{ selected: selection }, Some(key) => { match key { VirtualKeyCode::Escape => { return MainMenuResult::NoSelection{ selected: MainMenuSelection::Quit } } VirtualKeyCode::Up => { let newselection; match selection { MainMenuSelection::NewGame => newselection = MainMenuSelection::Quit, MainMenuSelection::LoadGame => newselection = MainMenuSelection::NewGame, MainMenuSelection::Quit => newselection = MainMenuSelection::LoadGame } return MainMenuResult::NoSelection{ selected: newselection } } VirtualKeyCode::Down => { let newselection; match selection { MainMenuSelection::NewGame => newselection = MainMenuSelection::LoadGame, MainMenuSelection::LoadGame => newselection = MainMenuSelection::Quit, MainMenuSelection::Quit => newselection = MainMenuSelection::NewGame } return MainMenuResult::NoSelection{ selected: newselection } } VirtualKeyCode::Return => return MainMenuResult::Selected{ selected : selection }, _ => return MainMenuResult::NoSelection{ selected: selection } } } } } MainMenuResult::NoSelection { selected: MainMenuSelection::NewGame } }
这有点拗口,但它显示了菜单选项,并允许你使用向上/向下键和回车键来选择它们。 它非常小心地不修改状态本身,以保持清晰。
包含 Serde
Serde 几乎是 Rust 中序列化的黄金标准。 它使很多事情变得更容易! 所以第一步是包含它。 在你的项目的 Cargo.toml 文件中,我们将扩展 dependencies 部分以包含它:
[dependencies]
rltk = { version = "0.8.0", features = ["serde"] }
specs = { version = "0.16.1", features = ["serde"] }
specs-derive = "0.4.1"
serde= { version = "1.0.93", features = ["derive"] }
serde_json = "1.0.39"
现在可能值得调用 cargo run - 这将需要一段时间,下载新的依赖项(及其所有依赖项)并为你构建它们。 它应该将它们保留在本地,这样你就不必每次构建都等待这么久。
添加 "SaveGame" 状态
我们将再次扩展 RunState 以支持游戏保存:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame } }
在 tick 中,我们现在添加虚拟代码:
#![allow(unused)] fn main() { RunState::SaveGame => { newrunstate = RunState::MainMenu{ menu_selection : gui::MainMenuSelection::LoadGame }; } }
在 player.rs 中,我们将添加另一个键盘处理程序 - escape 键:
#![allow(unused)] fn main() { // 保存并退出 VirtualKeyCode::Escape => return RunState::SaveGame, }
如果你现在 cargo run,你可以开始一个游戏并按下 escape 键退出到菜单。
开始保存游戏
现在脚手架已经到位,是时候实际保存一些东西了! 让我们从简单的开始,感受一下 Serde。 在 tick 函数中,我们扩展保存系统,将地图的 JSON 表示形式转储到控制台:
#![allow(unused)] fn main() { RunState::SaveGame => { let data = serde_json::to_string(&*self.ecs.fetch::<Map>()).unwrap(); println!("{}", data); newrunstate = RunState::MainMenu{ menu_selection : gui::MainMenuSelection::LoadGame }; } }
我们还需要在 main.rs 的顶部添加 extern crate serde;。
这将无法编译,因为我们需要告诉 Map 序列化自身! 幸运的是,serde 提供了一些助手来简化此操作。 在 map.rs 的顶部,我们添加 use serde::{Serialize, Deserialize};。 然后我们装饰地图以派生序列化和反序列化代码:
#![allow(unused)] fn main() { #[derive(Default, Serialize, Deserialize, Clone)] pub struct Map { pub tiles : Vec<TileType>, pub rooms : Vec<Rect>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool>, pub blocked : Vec<bool>, #[serde(skip_serializing)] #[serde(skip_deserializing)] pub tile_content : Vec<Vec<Entity>> } }
请注意,我们已使用指令装饰了 tile_content,使其不进行序列化/反序列化。 这可以防止我们需要存储实体,并且由于此数据在每一帧都会重建 - 因此无关紧要。 游戏仍然无法编译; 我们需要向 TileType 和 Rect 添加类似的装饰器:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Floor } }
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone, Serialize, Deserialize)] pub struct Rect { pub x1 : i32, pub x2 : i32, pub y1 : i32, pub y2 : i32 } }
如果你现在 cargo run 项目,当你按下 escape 键时,它会将大量的 JSON 数据转储到控制台。 那就是游戏地图!
保存实体状态
现在我们已经了解了 serde 的用处,我们应该开始将其用于游戏本身。 这比人们预期的要困难,因为 specs 处理 Entity 结构的方式:它们的 ID 编号纯粹是合成的,无法保证你下次会得到相同的编号! 此外,你可能不想保存所有内容 - 因此 specs 引入了标记的概念来帮助解决这个问题。 最终结果比实际需要的要复杂一点,但它提供了一个非常强大的序列化系统。
引入标记 (Markers)
首先,在 main.rs 中,我们将告诉 Rust 我们想要使用标记功能:
#![allow(unused)] fn main() { use specs::saveload::{SimpleMarker, SimpleMarkerAllocator}; }
在 components.rs 中,我们将添加一个标记类型:
#![allow(unused)] fn main() { pub struct SerializeMe; }
回到 main.rs 中,我们将 SerializeMe 添加到我们注册的事物列表中:
#![allow(unused)] fn main() { gs.ecs.register::<SimpleMarker<SerializeMe>>(); }
我们还将条目添加到 ECS 资源中,该条目用于确定下一个身份:
#![allow(unused)] fn main() { gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new()); }
最后,在 spawners.rs 中,我们告诉每个实体构建器包含标记。 这是 Player 的完整条目:
#![allow(unused)] fn main() { pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity { ecs .create_entity() .with(Position { x: player_x, y: player_y }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), render_order: 0 }) .with(Player{}) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Name{name: "Player".to_string() }) .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 }) .marked::<SimpleMarker<SerializeMe>>() .build() } }
新的一行 (.marked::<SimpleMarker<SerializeMe>>()) 需要在所有 spawners 文件中重复。 值得查看本章的源代码; 为了避免制作一个充满源代码的庞大章节,我省略了重复的细节。
ConvertSaveload 派生宏
Entity 类本身(由 Specs 提供)不能直接序列化; 它实际上是对一个名为 "slot map" 的特殊结构中的身份的引用(基本上是一种非常有效的方式来存储数据并保持位置稳定,直到你删除它,但在可用时重新使用空间)。 因此,为了保存和加载 Entity 类,有必要将这些合成身份转换为唯一的 ID 编号。 幸运的是,Specs 提供了一个名为 ConvertSaveload 的 derive 宏来实现此目的。 它适用于大多数组件,但并非所有组件!
序列化不包含 Entity 但确实包含数据的类型非常容易:使用 #[derive(Component, ConvertSaveload, Clone)] 标记它。 因此,我们遍历 components.rs 中的所有简单组件类型; 例如,这是 Position:
#![allow(unused)] fn main() { #[derive(Component, ConvertSaveload, Clone)] pub struct Position { pub x: i32, pub y: i32, } }
所以这表示:
- 该结构是
Component。 如果你愿意,你可以用编写代码指定 Specs 存储来替换它,但是宏要容易得多! ConvertSaveload实际上添加了Serialize和Deserialize,但是为它遇到的任何Entity类添加了额外的转换。Clone表示“此结构可以从内存中的一个点复制到另一个点。” 这对于 Serde 的内部工作是必需的,并且还允许你将.clone()附加到对组件的任何引用的末尾 - 并获得另一个完美的副本。 在大多数情况下,clone非常快(有时编译器可以使其完全不执行任何操作!)
当你有一个没有数据的组件时,ConvertSaveload 宏不起作用! 幸运的是,这些不需要任何额外的转换 - 因此你可以回退到默认的 Serde 语法。 这是一个非数据(“标签”)类:
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct Player {} }
实际保存一些东西
加载和保存的代码变得很大,因此我们已将其移至 saveload_system.rs。 然后在 main.rs 中包含 mod saveload_system;,并将 SaveGame 状态替换为:
#![allow(unused)] fn main() { RunState::SaveGame => { saveload_system::save_game(&mut self.ecs); newrunstate = RunState::MainMenu{ menu_selection : gui::MainMenuSelection::LoadGame }; } }
那么... 开始实现 save_game。 Serde 和 Specs 可以很好地协同工作,但是桥梁仍然定义得相当粗糙。 我一直遇到诸如如果我有超过 16 种组件类型,它就无法编译的问题! 为了解决这个问题,我构建了一个宏。 我建议在您准备好学习 Rust(令人印象深刻)的宏系统之前,只需复制该宏即可。
#![allow(unused)] fn main() { macro_rules! serialize_individually { ($ecs:expr, $ser:expr, $data:expr, $( $type:ty),*) => { $( SerializeComponents::<NoError, SimpleMarker<SerializeMe>>::serialize( &( $ecs.read_storage::<$type>(), ), &$data.0, &$data.1, &mut $ser, ) .unwrap(); )* }; } }
它的简短版本是,它将你的 ECS 作为第一个参数,并将一个元组与你的实体存储和“标记”存储在其中(你稍后会看到)。 之后的每个参数都是一个 类型 - 列出存储在你的 ECS 中的类型。 这些是重复规则,因此它为每种类型发出一个 SerializeComponent::serialize 调用。 它不如一次完成所有操作有效,但它可以工作 - 并且当你超过 16 种类型时不会崩溃! 然后 save_game 函数看起来像这样:
#![allow(unused)] fn main() { pub fn save_game(ecs : &mut World) { // 创建助手 let mapcopy = ecs.get_mut::<super::map::Map>().unwrap().clone(); let savehelper = ecs .create_entity() .with(SerializationHelper{ map : mapcopy }) .marked::<SimpleMarker<SerializeMe>>() .build(); // 实际序列化 { let data = ( ecs.entities(), ecs.read_storage::<SimpleMarker<SerializeMe>>() ); let writer = File::create("./savegame.json").unwrap(); let mut serializer = serde_json::Serializer::new(writer); serialize_individually!(ecs, serializer, data, Position, Renderable, Player, Viewshed, Monster, Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem, WantsToDropItem, SerializationHelper ); } // 清理 ecs.delete_entity(savehelper).expect("Cleanup crash"); } }
那么,这里发生了什么?
- 我们首先创建一个新的组件类型 -
SerializationHelper,它存储地图的副本(看,我们正在使用上面的地图内容!)。 然后它创建一个新实体,并为其提供新组件 - 以及地图的副本(clone命令创建深层副本)。 这是必需的,这样我们就不需要单独序列化地图。 - 我们进入一个代码块以避免借用检查器问题。
- 我们将
data设置为一个元组,其中包含Entity存储和SimpleMarker的ReadStorage。 这些将由保存宏使用。 - 我们在当前目录中打开一个名为
savegame.json的File。 - 我们从 Serde 获取 JSON 序列化器。
- 我们使用所有类型调用
serialize_individually宏。 - 我们删除我们创建的临时助手实体。
如果你 cargo run 并开始一个游戏,然后保存它 - 你会发现一个 savegame.json 文件已经出现 - 其中包含你的游戏状态。 耶!
恢复游戏状态
现在我们有了游戏数据,是时候加载它了!
是否有已保存的游戏?
首先,我们需要知道是否有已保存的游戏要加载。 在 saveload_system.rs 中,我们添加以下函数:
#![allow(unused)] fn main() { pub fn does_save_exist() -> bool { Path::new("./savegame.json").exists() } }
然后在 gui.rs 中,我们扩展 main_menu 函数以检查文件是否存在 - 如果不存在则不提供加载选项:
pub fn main_menu(gs : &mut State, ctx : &mut Rltk) -> MainMenuResult { let save_exists = super::saveload_system::does_save_exist(); let runstate = gs.ecs.fetch::<RunState>(); ctx.print_color_centered(15, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Rust Roguelike Tutorial"); if let RunState::MainMenu{ menu_selection : selection } = *runstate { if selection == MainMenuSelection::NewGame { ctx.print_color_centered(24, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Begin New Game"); } else { ctx.print_color_centered(24, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Begin New Game"); } if save_exists { if selection == MainMenuSelection::LoadGame { ctx.print_color_centered(25, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Load Game"); } else { ctx.print_color_centered(25, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Load Game"); } } if selection == MainMenuSelection::Quit { ctx.print_color_centered(26, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Quit"); } else { ctx.print_color_centered(26, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Quit"); } match ctx.key { None => return MainMenuResult::NoSelection{ selected: selection }, Some(key) => { match key { VirtualKeyCode::Escape => { return MainMenuResult::NoSelection{ selected: MainMenuSelection::Quit } } VirtualKeyCode::Up => { let mut newselection; match selection { MainMenuSelection::NewGame => newselection = MainMenuSelection::Quit, MainMenuSelection::LoadGame => newselection = MainMenuSelection::NewGame, MainMenuSelection::Quit => newselection = MainMenuSelection::LoadGame } if newselection == MainMenuSelection::LoadGame && !save_exists { newselection = MainMenuSelection::NewGame; } return MainMenuResult::NoSelection{ selected: newselection } } VirtualKeyCode::Down => { let mut newselection; match selection { MainMenuSelection::NewGame => newselection = MainMenuSelection::LoadGame, MainMenuSelection::LoadGame => newselection = MainMenuSelection::Quit, MainMenuSelection::Quit => newselection = MainMenuSelection::NewGame } if newselection == MainMenuSelection::LoadGame && !save_exists { newselection = MainMenuSelection::Quit; } return MainMenuResult::NoSelection{ selected: newselection } } VirtualKeyCode::Return => return MainMenuResult::Selected{ selected : selection }, _ => return MainMenuResult::NoSelection{ selected: selection } } } } } MainMenuResult::NoSelection { selected: MainMenuSelection::NewGame } }
最后,我们将修改 main.rs 中的调用代码以调用游戏加载:
#![allow(unused)] fn main() { RunState::MainMenu{ .. } => { let result = gui::main_menu(self, ctx); match result { gui::MainMenuResult::NoSelection{ selected } => newrunstate = RunState::MainMenu{ menu_selection: selected }, gui::MainMenuResult::Selected{ selected } => { match selected { gui::MainMenuSelection::NewGame => newrunstate = RunState::PreRun, gui::MainMenuSelection::LoadGame => { saveload_system::load_game(&mut self.ecs); newrunstate = RunState::AwaitingInput; } gui::MainMenuSelection::Quit => { ::std::process::exit(0); } } } } } }
实际加载游戏
在 saveload_system.rs 中,我们将需要另一个宏! 这与 serialize_individually 宏非常相似 - 但反转了过程,并包含了一些细微的更改:
#![allow(unused)] fn main() { macro_rules! deserialize_individually { ($ecs:expr, $de:expr, $data:expr, $( $type:ty),*) => { $( DeserializeComponents::<NoError, _>::deserialize( &mut ( &mut $ecs.write_storage::<$type>(), ), &mut $data.0, // entities &mut $data.1, // marker &mut $data.2, // allocater &mut $de, ) .unwrap(); )* }; } }
这是从一个新函数 load_game 调用的:
#![allow(unused)] fn main() { pub fn load_game(ecs: &mut World) { { // 删除所有内容 let mut to_delete = Vec::new(); for e in ecs.entities().join() { to_delete.push(e); } for del in to_delete.iter() { ecs.delete_entity(*del).expect("Deletion failed"); } } let data = fs::read_to_string("./savegame.json").unwrap(); let mut de = serde_json::Deserializer::from_str(&data); { let mut d = (&mut ecs.entities(), &mut ecs.write_storage::<SimpleMarker<SerializeMe>>(), &mut ecs.write_resource::<SimpleMarkerAllocator<SerializeMe>>()); deserialize_individually!(ecs, de, d, Position, Renderable, Player, Viewshed, Monster, Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem, WantsToDropItem, SerializationHelper ); } let mut deleteme : Option<Entity> = None; { let entities = ecs.entities(); let helper = ecs.read_storage::<SerializationHelper>(); let player = ecs.read_storage::<Player>(); let position = ecs.read_storage::<Position>(); for (e,h) in (&entities, &helper).join() { let mut worldmap = ecs.write_resource::<super::map::Map>(); *worldmap = h.map.clone(); worldmap.tile_content = vec![Vec::new(); super::map::MAPCOUNT]; deleteme = Some(e); } for (e,_p,pos) in (&entities, &player, &position).join() { let mut ppos = ecs.write_resource::<rltk::Point>(); *ppos = rltk::Point::new(pos.x, pos.y); let mut player_resource = ecs.write_resource::<Entity>(); *player_resource = e; } } ecs.delete_entity(deleteme.unwrap()).expect("Unable to delete helper"); } }
这真是有点拗口,所以让我们逐步了解它:
- 在一个代码块内(为了让借用检查器保持愉快),我们迭代游戏中的所有实体。 我们将它们添加到向量中,然后迭代该向量 - 删除实体。 这是一个两步过程,以避免在第一遍中使迭代器无效。
- 我们打开
savegame.json文件,并附加一个 JSON 反序列化器。 - 然后我们为宏构建元组,这需要对实体存储进行可变访问,对标记存储进行写入访问,以及一个分配器(来自 Specs)。
- 现在我们将其传递给我们刚制作的宏,该宏依次为每种类型调用反序列化器。 由于我们以相同的顺序保存,它将拾取所有内容。
- 现在我们进入另一个代码块,以避免与之前的代码和实体删除发生借用冲突。
- 我们首先迭代所有具有
SerializationHelper类型的实体。 如果我们找到它,我们将访问存储地图的资源 - 并替换它。 由于我们没有序列化tile_content,因此我们将其替换为空向量集。 - 然后我们通过迭代具有
Player类型和Position类型的实体来找到玩家。 我们存储玩家实体及其位置的世界资源。 - 最后,我们删除助手实体 - 这样如果我们再次保存游戏,就不会有重复项。
如果你现在 cargo run,你可以加载你保存的游戏了!
只是添加永久死亡!
如果我们让你在重新加载后保留你的保存游戏,那就不算是真正的 roguelike 了! 因此,我们将在 saveload_system 中添加另一个函数:
#![allow(unused)] fn main() { pub fn delete_save() { if Path::new("./savegame.json").exists() { std::fs::remove_file("./savegame.json").expect("Unable to delete file"); } } }
我们将在 main.rs 中添加一个调用,以便在加载游戏后删除保存:
#![allow(unused)] fn main() { gui::MainMenuSelection::LoadGame => { saveload_system::load_game(&mut self.ecs); newrunstate = RunState::AwaitingInput; saveload_system::delete_save(); } }
Web Assembly
示例按原样编译并在 web assembly (wasm32) 平台上运行:但是一旦你尝试保存游戏,它就会崩溃。 不幸的是(嗯,如果你喜欢你的计算机不被你访问的每个网站攻击,那这其实是幸运的!),wasm 是沙盒化的 - 并且不具备在本地保存文件的能力。
支持通过 LocalStorage(浏览器/JavaScript 功能)进行保存计划在 RLTK 的未来版本中实现。 在此期间,我们将添加一些包装器以避免崩溃 - 并且只是在 wasm32 上实际上不保存游戏。
Rust 提供了条件编译(如果你熟悉 C,它很像你在大型跨平台库中找到的 #define 混乱情况)。 在 saveload_system.rs 中,我们将修改 save_game 以仅在非 web assembly 平台上编译:
#![allow(unused)] fn main() { #[cfg(not(target_arch = "wasm32"))] pub fn save_game(ecs : &mut World) { }
# 标签看起来有点吓人,但如果你展开它,它就很有意义。 #[cfg()] 表示“仅当当前配置与括号的内容匹配时才编译。 not() 反转检查的结果,因此当我们检查 target_arch = "wasm32")(我们是否正在为 wasm32 编译)时,结果被反转。 最终结果是,该函数仅在你不为 wasm32 构建时才编译。
这完全没问题,但是有对该函数的调用 - 因此在 wasm 上的编译将失败。 我们将添加一个 桩函数 来代替它:
#![allow(unused)] fn main() { #[cfg(target_arch = "wasm32")] pub fn save_game(_ecs : &mut World) { } }
#[cfg(target_arch = "wasm32")] 前缀表示“仅为 web assembly 编译此代码”。 我们保持了函数签名相同,但在 _ecs 之前添加了 _ - 告诉编译器我们不打算使用该变量。 然后我们保持函数为空。
结果? 你可以为 wasm32 编译,save_game 函数只是根本不做任何事情。 其余结构仍然存在,因此游戏正确返回到主菜单 - 但没有恢复功能。
(为什么检查文件是否存在有效? Rust 非常聪明,可以说“没有文件系统,所以文件不可能存在”。 谢谢,Rust!)
总结
这是一个很长的章节,内容相当繁重。 好消息是我们现在有了一个框架,可以随时加载和保存游戏。 添加组件增加了一些步骤:我们必须在 main 中注册它们,标记它们为 Serialize, Deserialize,并记住将它们添加到 saveload_system.rs 中的组件类型列表中。 这可能会更容易 - 但这是一个非常坚实的基础。
本章的源代码可以在这里找到
在你的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson.
深入地下城
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
我们现在拥有了地下城探索游戏的所有基础要素,但只有一个关卡是一个很大的限制! 本章将介绍深度,每个更深的层级都会生成一个新的地下城。 我们将跟踪玩家的深度,并鼓励更深入的探索。 玩家可能会遇到什么问题呢?
指示和存储深度
我们将首先将当前深度添加到地图中。 在 map.rs 中,我们调整 Map 结构以包含深度的整数:
#![allow(unused)] fn main() { #[derive(Default, Serialize, Deserialize, Clone)] pub struct Map { pub tiles : Vec<TileType>, pub rooms : Vec<Rect>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool>, pub blocked : Vec<bool>, pub depth : i32, #[serde(skip_serializing)] #[serde(skip_deserializing)] pub tile_content : Vec<Vec<Entity>> } }
i32 是一种基本类型,由序列化库 Serde 自动处理。 因此,在此处添加它会自动将其添加到我们的游戏保存/加载机制中。 我们的地图创建代码还需要指示我们位于地图的第 1 层。 我们希望能够将地图生成器用于其他层级,因此我们还添加一个参数。 更新后的函数如下所示:
#![allow(unused)] fn main() { pub fn new_map_rooms_and_corridors(new_depth : i32) -> Map { let mut map = Map{ tiles : vec![TileType::Wall; MAPCOUNT], rooms : Vec::new(), width : MAPWIDTH as i32, height: MAPHEIGHT as i32, revealed_tiles : vec![false; MAPCOUNT], visible_tiles : vec![false; MAPCOUNT], blocked : vec![false; MAPCOUNT], tile_content : vec![Vec::new(); MAPCOUNT], depth: new_depth }; ... }
我们从 main.rs 中的设置代码调用此函数,因此我们也需要修改对地下城构建器的调用:
#![allow(unused)] fn main() { let map : Map = Map::new_map_rooms_and_corridors(1); }
就这样! 我们的地图现在知道了深度。 你需要删除你拥有的任何 savegame.json 文件,因为我们已经更改了格式 - 加载将会失败。
向玩家显示地图深度
我们将修改玩家的抬头显示器 (heads-up-display) 以指示当前的地图深度。 在 gui.rs 中,在 draw_ui 函数内部,我们添加以下内容:
#![allow(unused)] fn main() { let map = ecs.fetch::<Map>(); let depth = format!("Depth: {}", map.depth); ctx.print_color(2, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &depth); }
如果你现在 cargo run 项目,你将看到我们正在向你显示当前的深度:

添加下楼楼梯
在 map.rs 中,我们有一个枚举 - TileType - 列出了可用的地块类型。 我们想要添加一个新的:下楼楼梯。 像这样修改枚举:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Floor, DownStairs } }
我们还希望能够渲染楼梯。 map.rs 包含 draw_map,添加地块类型是一项相对简单的任务:
#![allow(unused)] fn main() { match tile { TileType::Floor => { glyph = rltk::to_cp437('.'); fg = RGB::from_f32(0.0, 0.5, 0.5); } TileType::Wall => { glyph = rltk::to_cp437('#'); fg = RGB::from_f32(0., 1.0, 0.); } TileType::DownStairs => { glyph = rltk::to_cp437('>'); fg = RGB::from_f32(0., 1.0, 1.0); } } }
最后,我们应该放置下楼楼梯。 我们将上楼楼梯放置在地图生成的第一个房间的中心 - 因此我们将楼梯放置在最后一个房间的中心! 回到 map.rs 中的 new_map_rooms_and_corridors,我们像这样修改它:
#![allow(unused)] fn main() { pub fn new_map_rooms_and_corridors(new_depth : i32) -> Map { let mut map = Map{ tiles : vec![TileType::Wall; MAPCOUNT], rooms : Vec::new(), width : MAPWIDTH as i32, height: MAPHEIGHT as i32, revealed_tiles : vec![false; MAPCOUNT], visible_tiles : vec![false; MAPCOUNT], blocked : vec![false; MAPCOUNT], tile_content : vec![Vec::new(); MAPCOUNT], depth: new_depth }; const MAX_ROOMS : i32 = 30; const MIN_SIZE : i32 = 6; const MAX_SIZE : i32 = 10; let mut rng = RandomNumberGenerator::new(); for i in 0..MAX_ROOMS { let w = rng.range(MIN_SIZE, MAX_SIZE); let h = rng.range(MIN_SIZE, MAX_SIZE); let x = rng.roll_dice(1, map.width - w - 1) - 1; let y = rng.roll_dice(1, map.height - h - 1) - 1; let new_room = Rect::new(x, y, w, h); let mut ok = true; for other_room in map.rooms.iter() { if new_room.intersect(other_room) { ok = false } } if ok { map.apply_room_to_map(&new_room); if !map.rooms.is_empty() { let (new_x, new_y) = new_room.center(); let (prev_x, prev_y) = map.rooms[map.rooms.len()-1].center(); if rng.range(0,2) == 1 { map.apply_horizontal_tunnel(prev_x, new_x, prev_y); map.apply_vertical_tunnel(prev_y, new_y, new_x); } else { map.apply_vertical_tunnel(prev_y, new_y, prev_x); map.apply_horizontal_tunnel(prev_x, new_x, new_y); } } map.rooms.push(new_room); } } let stairs_position = map.rooms[map.rooms.len()-1].center(); let stairs_idx = map.xy_idx(stairs_position.0, stairs_position.1); map.tiles[stairs_idx] = TileType::DownStairs; map } }
如果你现在 cargo run 项目,并四处走动一下 - 你可以找到一组下楼楼梯! 它们目前没有任何作用,但它们在地图上。

实际进入下一层
在 player.rs 中,我们有一个大的 match 语句来处理用户输入。 让我们将进入下一层绑定到句点键 (在美式键盘上,那是 >,不带 shift 键)。 将此添加到 match:
#![allow(unused)] fn main() { // 层级变更 VirtualKeyCode::Period => { if try_next_level(&mut gs.ecs) { return RunState::NextLevel; } } }
当然,现在我们需要实现 try_next_level:
#![allow(unused)] fn main() { pub fn try_next_level(ecs: &mut World) -> bool { let player_pos = ecs.fetch::<Point>(); let map = ecs.fetch::<Map>(); let player_idx = map.xy_idx(player_pos.x, player_pos.y); if map.tiles[player_idx] == TileType::DownStairs { true } else { let mut gamelog = ecs.fetch_mut::<GameLog>(); gamelog.entries.push("这里没有下去的路。".to_string()); false } } }
眼尖的程序员会注意到我们返回了一个新的 RunState - NextLevel。 由于它还不存在,我们将打开 main.rs 并实现它:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel } }
你的 IDE 现在会抱怨我们实际上没有实现新的 RunState! 因此,我们进入 main.rs 中不断增长的状态处理程序并添加:
#![allow(unused)] fn main() { RunState::NextLevel => { self.goto_next_level(); newrunstate = RunState::PreRun; } }
我们将为 State 添加一个新的 impl 部分,以便我们可以将方法附加到它。 我们首先要创建一个辅助方法:
#![allow(unused)] fn main() { impl State { fn entities_to_remove_on_level_change(&mut self) -> Vec<Entity> { let entities = self.ecs.entities(); let player = self.ecs.read_storage::<Player>(); let backpack = self.ecs.read_storage::<InBackpack>(); let player_entity = self.ecs.fetch::<Entity>(); let mut to_delete : Vec<Entity> = Vec::new(); for entity in entities.join() { let mut should_delete = true; // 不要删除玩家 let p = player.get(entity); if let Some(_p) = p { should_delete = false; } // 不要删除玩家的装备 let bp = backpack.get(entity); if let Some(bp) = bp { if bp.owner == *player_entity { should_delete = false; } } if should_delete { to_delete.push(entity); } } to_delete } } }
当我们进入下一层时,我们想要删除所有实体 - 除了玩家和玩家拥有的任何装备。 这个辅助函数查询 ECS 以获取要删除的实体列表。 它有点冗长,但相对简单:我们创建一个向量,然后迭代所有实体。 如果实体是玩家,我们将其标记为 should_delete=false。 如果它在背包中(具有 InBackpack 组件),我们检查所有者是否是玩家 - 如果是,我们就不删除它。
有了这个,我们开始创建 goto_next_level 函数,也在 State 实现内部:
#![allow(unused)] fn main() { fn goto_next_level(&mut self) { // 删除不是玩家或其装备的实体 let to_delete = self.entities_to_remove_on_level_change(); for target in to_delete { self.ecs.delete_entity(target).expect("无法删除实体"); } // 构建新地图并放置玩家 let worldmap; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); let current_depth = worldmap_resource.depth; *worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1); worldmap = worldmap_resource.clone(); } // 生成坏人 for room in worldmap.rooms.iter().skip(1) { spawner::spawn_room(&mut self.ecs, room); } // 放置玩家并更新资源 let (player_x, player_y) = worldmap.rooms[0].center(); let mut player_position = self.ecs.write_resource::<Point>(); *player_position = Point::new(player_x, player_y); let mut position_components = self.ecs.write_storage::<Position>(); let player_entity = self.ecs.fetch::<Entity>(); let player_pos_comp = position_components.get_mut(*player_entity); if let Some(player_pos_comp) = player_pos_comp { player_pos_comp.x = player_x; player_pos_comp.y = player_y; } // 将玩家的视野标记为脏 (dirty) let mut viewshed_components = self.ecs.write_storage::<Viewshed>(); let vs = viewshed_components.get_mut(*player_entity); if let Some(vs) = vs { vs.dirty = true; } // 通知玩家并给他们一些治疗 let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>(); gamelog.entries.push("你下到下一层,并花点时间治疗。".to_string()); let mut player_health_store = self.ecs.write_storage::<CombatStats>(); let player_health = player_health_store.get_mut(*player_entity); if let Some(player_health) = player_health { player_health.hp = i32::max(player_health.hp, player_health.max_hp / 2); } } }
这是一个很长的函数,但它完成了我们需要的一切。 让我们逐步分解它:
- 我们使用刚刚编写的辅助函数来获取要删除的实体列表,并要求 ECS 处理它们。
- 我们创建一个
worldmap变量,并进入一个新的作用域。 否则,我们会遇到 ECS 的不可变与可变借用的问题。 - 在此作用域中,我们获得对当前
Map资源的写引用。 我们获取当前层级,并将地图替换为新地图 - 新深度为current_depth + 1。 然后,我们将它的 clone 存储在外部变量中并退出作用域(避免任何借用/生命周期问题)。 - 现在我们使用与初始设置中相同的代码,在每个房间中生成坏人和物品。
- 现在我们获取第一个房间的位置,并更新玩家的资源,将其位置设置为第一个房间的中心。 我们还获取玩家的
Position组件并更新它。 - 我们获取玩家的
Viewshed组件,因为它现在已经过时了,因为整个地图都围绕他/她发生了变化! 我们将其标记为 dirty - 并让各种系统处理其余部分。 - 我们给玩家一个日志条目,表明他们已下到下一层。
- 我们获取玩家的生命值组件,如果他们的生命值低于 50% - 则将其提升到一半。
如果你现在 cargo run 项目,你可以四处奔跑并下降层级。 你的深度指示器会增加 - 告诉你你做对了!

总结
本章比前几章稍微容易一些! 你现在可以下降到一个实际上无限的地下城(它实际上受 32 位整数大小的限制,但祝你好运通过那么多层级)。 我们已经了解了 ECS 如何提供帮助,以及我们的序列化工作如何轻松扩展以包含像这样的新功能,随着我们添加到项目中。
本章的源代码可以在这里找到
在你的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson.
难度
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
目前,你可以通过多个地下城层级 - 但它们都具有相同的生成物 (spawn)。 随着你的前进,难度没有提升,也没有简单的模式可以帮助你度过初期。 本章旨在改变这一点。
添加等待按键
大多数 Roguelike 游戏的一个重要的战术要素是能够跳过回合 - 让怪物向你靠近(而不是让你先受到攻击!)。 作为将游戏转变为更具战术挑战性的一部分,让我们快速实现回合跳过。 在 player.rs 中(以及其余的输入),我们将数字键盘 5 和空格键添加为跳过:
#![allow(unused)] fn main() { // 跳过回合 VirtualKeyCode::Numpad5 => return RunState::PlayerTurn, VirtualKeyCode::Space => return RunState::PlayerTurn, }
这为游戏增加了一个不错的战术维度:你可以引诱敌人向你靠近,并从战术位置中获益。 Roguelike 游戏中另一个常见的功能是,如果没有附近的敌人,等待可以提供一些治疗。 我们只为玩家实现这一点,因为怪物突然治疗会让人感到不安! 因此,我们将其更改为:
#![allow(unused)] fn main() { // 跳过回合 VirtualKeyCode::Numpad5 => return skip_turn(&mut gs.ecs), VirtualKeyCode::Space => return skip_turn(&mut gs.ecs), }
现在我们实现 skip_turn:
#![allow(unused)] fn main() { fn skip_turn(ecs: &mut World) -> RunState { let player_entity = ecs.fetch::<Entity>(); let viewshed_components = ecs.read_storage::<Viewshed>(); let monsters = ecs.read_storage::<Monster>(); let worldmap_resource = ecs.fetch::<Map>(); let mut can_heal = true; let viewshed = viewshed_components.get(*player_entity).unwrap(); for tile in viewshed.visible_tiles.iter() { let idx = worldmap_resource.xy_idx(tile.x, tile.y); for entity_id in worldmap_resource.tile_content[idx].iter() { let mob = monsters.get(*entity_id); match mob { None => {} Some(_) => { can_heal = false; } } } } if can_heal { let mut health_components = ecs.write_storage::<CombatStats>(); let player_hp = health_components.get_mut(*player_entity).unwrap(); player_hp.hp = i32::min(player_hp.hp + 1, player_hp.max_hp); } RunState::PlayerTurn } }
这段代码查找了各种实体,然后使用 tile_content 系统迭代玩家的视野范围 (viewshed)。 它检查玩家可以看到的怪物; 如果没有怪物出现,它会治疗玩家 1 点生命值。 这鼓励了动脑筋的玩法 - 并且可以在稍后通过包含饥饿时钟来平衡。 这也使游戏变得非常容易 - 但我们正在解决这个问题!
随着深入地下城难度增加:生成表 (spawn table)
到目前为止,我们一直在使用一个简单的生成系统:它随机选择一些怪物和物品,然后以相等的权重选择每个。 这不太像“正常”游戏,正常游戏倾向于使某些东西稀有 - 而某些东西常见。 我们将创建一个通用的 random_table 系统,用于生成系统。 创建一个新文件 random_table.rs 并将以下内容放入其中:
#![allow(unused)] fn main() { use rltk::RandomNumberGenerator; pub struct RandomEntry { name : String, weight : i32 } impl RandomEntry { pub fn new<S:ToString>(name: S, weight: i32) -> RandomEntry { RandomEntry{ name: name.to_string(), weight } } } #[derive(Default)] pub struct RandomTable { entries : Vec<RandomEntry>, total_weight : i32 } impl RandomTable { pub fn new() -> RandomTable { RandomTable{ entries: Vec::new(), total_weight: 0 } } pub fn add<S:ToString>(mut self, name : S, weight: i32) -> RandomTable { self.total_weight += weight; self.entries.push(RandomEntry::new(name.to_string(), weight)); self } pub fn roll(&self, rng : &mut RandomNumberGenerator) -> String { if self.total_weight == 0 { return "None".to_string(); } let mut roll = rng.roll_dice(1, self.total_weight)-1; let mut index : usize = 0; while roll > 0 { if roll < self.entries[index].weight { return self.entries[index].name.clone(); } roll -= self.entries[index].weight; index += 1; } "None".to_string() } } }
因此,这创建了一个新类型 random_table。 它为其添加了一个 new 方法,以方便创建一个新的 random_table。 它还创建了一个 vector 或条目 (entry),每个条目都有一个权重和一个名称(传递字符串不是很有效,但可以使示例代码清晰!)。 它还实现了一个 add 函数,该函数允许你传入一个新的名称和权重,并更新结构的 total_weight。 最后,roll 从 0 .. total_weight - 1 进行一次掷骰子 (dice roll),并遍历条目。 如果掷骰子的结果低于权重,则返回它 - 否则,它会从掷骰子的结果中减去权重并测试下一个条目。 这为表中的任何给定项目提供了等于条目相对权重的机会。 其中有一些额外的工作来帮助将方法链接在一起,以实现链式函数调用的 Rust 风格外观。 我们将在 spawner.rs 中使用它来创建一个新函数 room_table:
#![allow(unused)] fn main() { fn room_table() -> RandomTable { RandomTable::new() .add("Goblin", 10) .add("Orc", 1) .add("Health Potion", 7) .add("Fireball Scroll", 2) .add("Confusion Scroll", 2) .add("Magic Missile Scroll", 4) } }
这包含了我们到目前为止添加的所有物品和怪物,并附加了权重。 我对这些权重不是很在意; 我们稍后会调整它们! 这确实意味着调用 room_table().roll(rng) 将返回一个随机房间条目。
现在我们简化一下。 删除 spawner.rs 中的 NUM_MONSTERS、random_monster 和 random_item 函数。 然后我们将房间生成代码替换为:
#![allow(unused)] fn main() { #[allow(clippy::map_entry)] pub fn spawn_room(ecs: &mut World, room : &Rect) { let spawn_table = room_table(); let mut spawn_points : HashMap<usize, String> = HashMap::new(); // Scope to keep the borrow checker happy { let mut rng = ecs.write_resource::<RandomNumberGenerator>(); let num_spawns = rng.roll_dice(1, MAX_MONSTERS + 3) - 3; for _i in 0 .. num_spawns { let mut added = false; let mut tries = 0; while !added && tries < 20 { let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 - room.x1))) as usize; let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 - room.y1))) as usize; let idx = (y * MAPWIDTH) + x; if !spawn_points.contains_key(&idx) { spawn_points.insert(idx, spawn_table.roll(&mut rng)); added = true; } else { tries += 1; } } } } // Actually spawn the monsters for spawn in spawn_points.iter() { let x = (*spawn.0 % MAPWIDTH) as i32; let y = (*spawn.0 / MAPWIDTH) as i32; match spawn.1.as_ref() { "Goblin" => goblin(ecs, x, y), "Orc" => orc(ecs, x, y), "Health Potion" => health_potion(ecs, x, y), "Fireball Scroll" => fireball_scroll(ecs, x, y), "Confusion Scroll" => confusion_scroll(ecs, x, y), "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y), _ => {} } } } }
让我们逐步了解一下:
- 第一行告诉 Rust linter,我们确实喜欢检查
HashMap的成员资格,然后插入其中 - 我们还设置了一个标志,但这与它的建议不太相符。 - 我们获取全局随机数生成器,并将生成数量设置为 1d7-3(范围为 -2 到 4)。
- 对于上面 0 的每个生成数量,我们选择房间中的一个随机点。 我们不断选择随机点,直到找到一个空点(或者我们超过 20 次尝试,在这种情况下我们放弃)。 找到一个点后,我们将其添加到
spawn列表,其中包含位置和从我们的随机表中掷骰子的结果。 - 然后我们迭代生成列表,匹配掷骰子的结果并生成怪物和物品。
这绝对比以前的方法更简洁,现在你不太可能遇到兽人 - 而更有可能遇到地精和治疗药水。
快速的 cargo run 向你展示了改进的生成多样性。
随着深入地下城增加生成率
这给出了一个更好的分布,但没有解决后期关卡与早期关卡难度相同的问题。 一种快速而粗糙的方法是在你深入时生成更多实体。 这仍然不能解决问题,但这只是一个开始! 我们将首先修改 spawn_room 的函数签名以接受地图深度:
#![allow(unused)] fn main() { pub fn spawn_room(ecs: &mut World, room : &Rect, map_depth: i32) { }
然后我们将更改生成的实体数量以使用它:
#![allow(unused)] fn main() { let num_spawns = rng.roll_dice(1, MAX_MONSTERS + 3) + (map_depth - 1) - 3; }
我们将必须更改 main.rs 中的几个调用以传入深度:
#![allow(unused)] fn main() { for room in map.rooms.iter().skip(1) { spawner::spawn_room(&mut gs.ecs, room, 1); } }
#![allow(unused)] fn main() { // 构建新地图并放置玩家 let worldmap; let current_depth; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); current_depth = worldmap_resource.depth; *worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1); worldmap = worldmap_resource.clone(); } // 生成坏人 for room in worldmap.rooms.iter().skip(1) { spawner::spawn_room(&mut self.ecs, room, current_depth+1); } }
如果你现在 cargo run,第一层会非常安静。 随着你的深入,难度会略有提升,直到你拥有真正的怪物大军!
按深度增加权重
让我们修改 room_table 函数以包含地图深度:
#![allow(unused)] fn main() { fn room_table(map_depth: i32) -> RandomTable { RandomTable::new() .add("Goblin", 10) .add("Orc", 1 + map_depth) .add("Health Potion", 7) .add("Fireball Scroll", 2 + map_depth) .add("Confusion Scroll", 2 + map_depth) .add("Magic Missile Scroll", 4) } }
我们还在 spawn_room 中更改了对它的调用以使用它:
#![allow(unused)] fn main() { let spawn_table = room_table(map_depth); }
在 cargo build 之后,瞧 - 你有了一个随着你的深入而增加找到兽人、火球和混乱卷轴的概率。 地精、治疗药水和魔法飞弹卷轴的总权重保持不变 - 但由于其他权重发生了变化,它们的总可能性降低了。
总结
你现在拥有了一个随着你的深入而难度增加的地下城! 在下一章中,我们还将研究如何通过装备让你的角色获得一些成长,以平衡局面。
本章的源代码可以在这里找到
在你的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson.
为玩家装备物品
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
现在我们有了一个难度不断增加的地下城,是时候开始为玩家提供一些提高他们能力的方法了! 在本章中,我们将从最基本的人类任务开始:装备武器和盾牌。
添加一些可以穿戴/挥舞的物品
我们已经有了很多物品系统的基础,所以我们将基于前几章的基础继续构建。 仅使用我们已经拥有的组件,我们可以在 spawners.rs 中从以下内容开始:
#![allow(unused)] fn main() { fn dagger(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('/'), fg: RGB::named(rltk::CYAN), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Dagger".to_string() }) .with(Item{}) .marked::<SimpleMarker<SerializeMe>>() .build(); } fn shield(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('('), fg: RGB::named(rltk::CYAN), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Shield".to_string() }) .with(Item{}) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
在这两种情况下,我们都在创建一个新实体。 我们给它一个 Position,因为它必须从地图上的某个位置开始。 我们分配一个 Renderable,设置为适当的 CP437/ASCII 字形。 我们给它们一个名称,并将它们标记为物品。 我们可以像这样将它们添加到生成表 (spawn table) 中:
#![allow(unused)] fn main() { fn room_table(map_depth: i32) -> RandomTable { RandomTable::new() .add("Goblin", 10) .add("Orc", 1 + map_depth) .add("Health Potion", 7) .add("Fireball Scroll", 2 + map_depth) .add("Confusion Scroll", 2 + map_depth) .add("Magic Missile Scroll", 4) .add("Dagger", 3) .add("Shield", 3) } }
我们也可以很容易地将它们包含在实际生成它们的系统中:
#![allow(unused)] fn main() { // 实际生成怪物 for spawn in spawn_points.iter() { let x = (*spawn.0 % MAPWIDTH) as i32; let y = (*spawn.0 / MAPWIDTH) as i32; match spawn.1.as_ref() { "Goblin" => goblin(ecs, x, y), "Orc" => orc(ecs, x, y), "Health Potion" => health_potion(ecs, x, y), "Fireball Scroll" => fireball_scroll(ecs, x, y), "Confusion Scroll" => confusion_scroll(ecs, x, y), "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y), "Dagger" => dagger(ecs, x, y), "Shield" => shield(ecs, x, y), _ => {} } } }
如果你现在 cargo run 项目,你可以四处奔跑并最终找到匕首或盾牌。 在你测试时,你可以考虑将生成频率从 3 提高到一个非常大的数字! 由于我们添加了 Item 标签,你可以在找到这些物品时捡起和放下它们。

装备物品
如果你不能使用匕首和盾牌,它们就不是很有用! 所以让我们让它们可装备。
Equippable 组件
我们需要一种方法来指示物品可以被装备。 你可能已经猜到了,但我们添加了一个新组件! 在 components.rs 中,我们添加:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone, Serialize, Deserialize)] pub enum EquipmentSlot { Melee, Shield } #[derive(Component, Serialize, Deserialize, Clone)] pub struct Equippable { pub slot : EquipmentSlot } }
既然我们有了序列化支持(来自第 11 章),我们也必须记住在几个地方注册它。 在 main.rs 中,我们将其添加到已注册组件的列表中:
#![allow(unused)] fn main() { gs.ecs.register::<Equippable>(); }
在 saveload_system.rs 中,我们将其添加到两组组件列表中:
#![allow(unused)] fn main() { serialize_individually!(ecs, serializer, data, Position, Renderable, Player, Viewshed, Monster, Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem, WantsToDropItem, SerializationHelper, Equippable ); }
#![allow(unused)] fn main() { deserialize_individually!(ecs, de, d, Position, Renderable, Player, Viewshed, Monster, Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem, WantsToDropItem, SerializationHelper, Equippable ); }
最后,我们应该将 Equippable 组件添加到 spawners.rs 中的 dagger 和 shield 函数中:
#![allow(unused)] fn main() { fn dagger(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('/'), fg: RGB::named(rltk::CYAN), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Dagger".to_string() }) .with(Item{}) .with(Equippable{ slot: EquipmentSlot::Melee }) .marked::<SimpleMarker<SerializeMe>>() .build(); } fn shield(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('('), fg: RGB::named(rltk::CYAN), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Shield".to_string() }) .with(Item{}) .with(Equippable{ slot: EquipmentSlot::Shield }) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
使物品可装备
一般来说,背包里放着盾牌没有太大帮助(先不考虑明显的“你是怎么把它塞进去的?”问题 - 像许多游戏一样,我们将忽略这个问题!) - 所以你必须能够选择一个来装备。 我们将首先制作另一个组件 Equipped。 它的工作方式与 InBackpack 类似 - 它指示实体正在持有它。 与 InBackpack 不同,它将指示正在使用的槽位。 这是 components.rs 中的基本 Equipped 组件:
#![allow(unused)] fn main() { #[derive(Component, ConvertSaveload, Clone)] pub struct Equipped { pub owner : Entity, pub slot : EquipmentSlot } }
与之前一样,我们需要在 main.rs 中注册它,并将其包含在 saveload_system.rs 中的序列化和反序列化列表中。
实际装备物品
现在我们想让装备物品成为可能。 这样做将自动卸下同一槽位中的任何物品。 我们将通过我们已经用于使用物品的相同接口来完成此操作,这样我们就不会到处都有不同的菜单。 打开 inventory_system.rs,我们将编辑 ItemUseSystem。 我们将首先扩展我们正在引用的系统列表:
#![allow(unused)] fn main() { impl<'a> System<'a> for ItemUseSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, ReadExpect<'a, Map>, Entities<'a>, WriteStorage<'a, WantsToUseItem>, ReadStorage<'a, Name>, ReadStorage<'a, Consumable>, ReadStorage<'a, ProvidesHealing>, ReadStorage<'a, InflictsDamage>, WriteStorage<'a, CombatStats>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, AreaOfEffect>, WriteStorage<'a, Confusion>, ReadStorage<'a, Equippable>, WriteStorage<'a, Equipped>, WriteStorage<'a, InBackpack> ); fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, map, entities, mut wants_use, names, consumables, healing, inflict_damage, mut combat_stats, mut suffer_damage, aoe, mut confused, equippable, mut equipped, mut backpack) = data; }
现在,在获取目标之后,添加以下代码块:
#![allow(unused)] fn main() { // 如果物品是可装备的,那么我们想要装备它 - 并卸下该槽位中的任何其他物品 let item_equippable = equippable.get(useitem.item); match item_equippable { None => {} Some(can_equip) => { let target_slot = can_equip.slot; let target = targets[0]; // 移除目标在物品槽位中已有的任何物品 let mut to_unequip : Vec<Entity> = Vec::new(); for (item_entity, already_equipped, name) in (&entities, &equipped, &names).join() { if already_equipped.owner == target && already_equipped.slot == target_slot { to_unequip.push(item_entity); if target == *player_entity { gamelog.entries.push(format!("You unequip {}.", name.name)); } } } for item in to_unequip.iter() { equipped.remove(*item); backpack.insert(*item, InBackpack{ owner: target }).expect("Unable to insert backpack entry"); } // 挥舞物品 equipped.insert(useitem.item, Equipped{ owner: target, slot: target_slot }).expect("Unable to insert equipped component"); backpack.remove(useitem.item); if target == *player_entity { gamelog.entries.push(format!("You equip {}.", names.get(useitem.item).unwrap().name)); } } } }
这首先匹配以查看我们是否可以装备该物品。 如果可以,它会查找该物品的目标槽位,并查看该槽位中是否已存在物品。 如果有,则将其移动到背包中。 最后,它将 Equipped 组件添加到物品实体,其中包含所有者(现在是玩家)和适当的槽位。
最后,你可能还记得,当玩家移动到下一层时,我们会删除很多实体。 我们希望将玩家 Equipped 的物品作为保留 ECS 中物品的理由。 在 main.rs 中,我们修改 entities_to_remove_on_level_change 如下:
#![allow(unused)] fn main() { fn entities_to_remove_on_level_change(&mut self) -> Vec<Entity> { let entities = self.ecs.entities(); let player = self.ecs.read_storage::<Player>(); let backpack = self.ecs.read_storage::<InBackpack>(); let player_entity = self.ecs.fetch::<Entity>(); let equipped = self.ecs.read_storage::<Equipped>(); let mut to_delete : Vec<Entity> = Vec::new(); for entity in entities.join() { let mut should_delete = true; // 不要删除玩家 let p = player.get(entity); if let Some(_p) = p { should_delete = false; } // 不要删除玩家的装备 let bp = backpack.get(entity); if let Some(bp) = bp { if bp.owner == *player_entity { should_delete = false; } } let eq = equipped.get(entity); if let Some(eq) = eq { if eq.owner == *player_entity { should_delete = false; } } if should_delete { to_delete.push(entity); } } to_delete } }
如果你现在 cargo run 项目,你可以四处奔跑捡起新物品 - 并且可以装备它们。 它们目前还没有任何作用 - 但至少你可以换入和换出它们。 游戏日志将显示装备和卸下装备。

授予战斗奖励
从逻辑上讲,盾牌应该提供一些针对传入伤害的保护 - 而被匕首刺伤应该比被拳头击中更疼! 为了方便这一点,我们将添加更多组件(现在应该是一首熟悉的歌了)。 在 components.rs 中:
#![allow(unused)] fn main() { #[derive(Component, ConvertSaveload, Clone)] pub struct MeleePowerBonus { pub power : i32 } #[derive(Component, ConvertSaveload, Clone)] pub struct DefenseBonus { pub defense : i32 } }
我们还需要记住在 main.rs 和 saveload_system.rs 中注册它们。 然后我们可以修改 spawner.rs 中的代码,将这些组件添加到正确的物品中:
#![allow(unused)] fn main() { fn dagger(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('/'), fg: RGB::named(rltk::CYAN), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Dagger".to_string() }) .with(Item{}) .with(Equippable{ slot: EquipmentSlot::Melee }) .with(MeleePowerBonus{ power: 2 }) .marked::<SimpleMarker<SerializeMe>>() .build(); } fn shield(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('('), fg: RGB::named(rltk::CYAN), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Shield".to_string() }) .with(Item{}) .with(Equippable{ slot: EquipmentSlot::Shield }) .with(DefenseBonus{ defense: 1 }) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
请注意我们是如何将组件添加到每个物品的? 现在我们需要修改 melee_combat_system 以应用这些奖励。 我们通过向我们的系统添加一些额外的 ECS 查询来做到这一点:
#![allow(unused)] fn main() { impl<'a> System<'a> for MeleeCombatSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToMelee>, ReadStorage<'a, Name>, ReadStorage<'a, CombatStats>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, MeleePowerBonus>, ReadStorage<'a, DefenseBonus>, ReadStorage<'a, Equipped> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage, melee_power_bonuses, defense_bonuses, equipped) = data; for (entity, wants_melee, name, stats) in (&entities, &wants_melee, &names, &combat_stats).join() { if stats.hp > 0 { let mut offensive_bonus = 0; for (_item_entity, power_bonus, equipped_by) in (&entities, &melee_power_bonuses, &equipped).join() { if equipped_by.owner == entity { offensive_bonus += power_bonus.power; } } let target_stats = combat_stats.get(wants_melee.target).unwrap(); if target_stats.hp > 0 { let target_name = names.get(wants_melee.target).unwrap(); let mut defensive_bonus = 0; for (_item_entity, defense_bonus, equipped_by) in (&entities, &defense_bonuses, &equipped).join() { if equipped_by.owner == wants_melee.target { defensive_bonus += defense_bonus.defense; } } let damage = i32::max(0, (stats.power + offensive_bonus) - (target_stats.defense + defensive_bonus)); }
这是一大段代码,所以让我们过一遍:
- 我们已将
MeleePowerBonus、DefenseBonus和Equipped读取器添加到系统中。 - 一旦我们确定攻击者还活着,我们将
offensive_bonus设置为 0。 - 我们迭代所有具有
MeleePowerBonus和Equipped条目的实体。 如果它们是由攻击者装备的,我们将它们的攻击奖励添加到offensive_bonus。 - 一旦我们确定防御者还活着,我们将
defensive_bonus设置为 0。 - 我们迭代所有具有
DefenseBonus和Equipped条目的实体。 如果它们是由目标装备的,我们将它们的防御添加到defense_bonus。 - 当我们计算伤害时,我们将攻击奖励添加到攻击方 - 并将防御奖励添加到防御方。
如果你现在 cargo run,你会发现使用匕首会让你攻击更猛烈 - 而使用盾牌会让你受到的伤害更少。
卸下物品
现在你可以装备物品,并通过交换来移除它们,你可能想要停止持有物品并将其放回背包。 在像这样简单的游戏中,这并不是绝对必要的 - 但这是一个很好的未来选择。 我们将 R 键绑定到 remove 物品,因为该键是可用的。 在 player.rs 中,将此添加到输入代码:
#![allow(unused)] fn main() { VirtualKeyCode::R => return RunState::ShowRemoveItem, }
现在我们在 main.rs 的 RunState 中添加 ShowRemoveItem:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, ShowRemoveItem, GameOver } }
我们在 tick 中为其添加一个处理程序:
#![allow(unused)] fn main() { RunState::ShowRemoveItem => { let result = gui::remove_item_menu(self, ctx); match result.0 { gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput, gui::ItemMenuResult::NoResponse => {} gui::ItemMenuResult::Selected => { let item_entity = result.1.unwrap(); let mut intent = self.ecs.write_storage::<WantsToRemoveItem>(); intent.insert(*self.ecs.fetch::<Entity>(), WantsToRemoveItem{ item: item_entity }).expect("Unable to insert intent"); newrunstate = RunState::PlayerTurn; } } } }
我们将在 components.rs 中实现一个新组件(有关序列化处理程序,请参阅源代码;它是想要丢弃物品的处理程序的剪切粘贴,并更改了名称):
#![allow(unused)] fn main() { #[derive(Component, Debug, ConvertSaveload, Clone)] pub struct WantsToRemoveItem { pub item : Entity } }
与往常一样,它必须在 main.rs 和 saveload_system.rs 中注册。
现在在 gui.rs 中,我们将实现 remove_item_menu。 它几乎与物品丢弃菜单完全相同,但更改了查询内容和标题(在某个时候将这些变成更通用的函数将是一个好主意!):
#![allow(unused)] fn main() { pub fn remove_item_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) { let player_entity = gs.ecs.fetch::<Entity>(); let names = gs.ecs.read_storage::<Name>(); let backpack = gs.ecs.read_storage::<Equipped>(); let entities = gs.ecs.entities(); let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity ); let count = inventory.count(); let mut y = (25 - (count / 2)) as i32; ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Remove Which Item?"); ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); let mut equippable : Vec<Entity> = Vec::new(); let mut j = 0; for (entity, _pack, name) in (&entities, &backpack, &names).join().filter(|item| item.1.owner == *player_entity ) { ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as rltk::FontCharType); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, &name.name.to_string()); equippable.push(entity); y += 1; j += 1; } match ctx.key { None => (ItemMenuResult::NoResponse, None), Some(key) => { match key { VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) } _ => { let selection = rltk::letter_to_option(key); if selection > -1 && selection < count as i32 { return (ItemMenuResult::Selected, Some(equippable[selection as usize])); } (ItemMenuResult::NoResponse, None) } } } } } }
接下来,我们应该扩展 inventory_system.rs 以支持移除物品。 幸运的是,这是一个非常简单的系统:
#![allow(unused)] fn main() { pub struct ItemRemoveSystem {} impl<'a> System<'a> for ItemRemoveSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteStorage<'a, WantsToRemoveItem>, WriteStorage<'a, Equipped>, WriteStorage<'a, InBackpack> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut wants_remove, mut equipped, mut backpack) = data; for (entity, to_remove) in (&entities, &wants_remove).join() { equipped.remove(to_remove.item); backpack.insert(to_remove.item, InBackpack{ owner: entity }).expect("Unable to insert backpack"); } wants_remove.clear(); } } }
最后,我们将其添加到 main.rs 中的系统中:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut mob = MonsterAI{}; mob.run_now(&self.ecs); let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut melee = MeleeCombatSystem{}; melee.run_now(&self.ecs); let mut damage = DamageSystem{}; damage.run_now(&self.ecs); let mut pickup = ItemCollectionSystem{}; pickup.run_now(&self.ecs); let mut itemuse = ItemUseSystem{}; itemuse.run_now(&self.ecs); let mut drop_items = ItemDropSystem{}; drop_items.run_now(&self.ecs); let mut item_remove = ItemRemoveSystem{}; item_remove.run_now(&self.ecs); self.ecs.maintain(); } } }
现在,如果你 cargo run,你可以捡起匕首或盾牌并装备它。 然后你可以按 R 来移除它。
稍后添加更强大的装备
让我们在 spawner.rs 中添加更多物品:
#![allow(unused)] fn main() { fn longsword(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('/'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Longsword".to_string() }) .with(Item{}) .with(Equippable{ slot: EquipmentSlot::Melee }) .with(MeleePowerBonus{ power: 4 }) .marked::<SimpleMarker<SerializeMe>>() .build(); } fn tower_shield(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('('), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Tower Shield".to_string() }) .with(Item{}) .with(Equippable{ slot: EquipmentSlot::Shield }) .with(DefenseBonus{ defense: 3 }) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
我们将在 random_table.rs 中添加一个快速修复,以忽略生成几率 (spawn chance) 为 0 或更低的条目:
#![allow(unused)] fn main() { pub fn add<S:ToString>(mut self, name : S, weight: i32) -> RandomTable { if weight > 0 { self.total_weight += weight; self.entries.push(RandomEntry::new(name.to_string(), weight)); } self } }
回到 spawner.rs,我们将它们添加到战利品表 (loot table) 中 - 并在地下城后期有一定几率出现:
#![allow(unused)] fn main() { fn room_table(map_depth: i32) -> RandomTable { RandomTable::new() .add("Goblin", 10) .add("Orc", 1 + map_depth) .add("Health Potion", 7) .add("Fireball Scroll", 2 + map_depth) .add("Confusion Scroll", 2 + map_depth) .add("Magic Missile Scroll", 4) .add("Dagger", 3) .add("Shield", 3) .add("Longsword", map_depth - 1) .add("Tower Shield", map_depth - 1) } }
#![allow(unused)] fn main() { match spawn.1.as_ref() { "Goblin" => goblin(ecs, x, y), "Orc" => orc(ecs, x, y), "Health Potion" => health_potion(ecs, x, y), "Fireball Scroll" => fireball_scroll(ecs, x, y), "Confusion Scroll" => confusion_scroll(ecs, x, y), "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y), "Dagger" => dagger(ecs, x, y), "Shield" => shield(ecs, x, y), "Longsword" => longsword(ecs, x, y), "Tower Shield" => tower_shield(ecs, x, y), _ => {} } }
现在,随着你进一步深入,你可以找到更好的武器和盾牌!
游戏结束画面
我们快要完成基本教程了,所以让我们在您死亡时做一些事情 - 而不是锁定在控制台循环中。 在文件 damage_system.rs 中,我们将编辑 delete_the_dead 中 player 的 match 语句:
#![allow(unused)] fn main() { match player { None => { let victim_name = names.get(entity); if let Some(victim_name) = victim_name { log.entries.push(format!("{} is dead", &victim_name.name)); } dead.push(entity) } Some(_) => { let mut runstate = ecs.write_resource::<RunState>(); *runstate = RunState::GameOver; } } }
当然,我们现在必须去 main.rs 并添加新状态:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, ShowRemoveItem, GameOver } }
我们将其添加到状态实现中,也在 main.rs 中:
#![allow(unused)] fn main() { RunState::GameOver => { let result = gui::game_over(ctx); match result { gui::GameOverResult::NoSelection => {} gui::GameOverResult::QuitToMenu => { self.game_over_cleanup(); newrunstate = RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame }; } } } }
这相对简单:我们调用 game_over 来渲染菜单,当您退出时,我们删除 ECS 中的所有内容。 最后,在 gui.rs 中,我们将实现 game_over:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum GameOverResult { NoSelection, QuitToMenu } pub fn game_over(ctx : &mut Rltk) -> GameOverResult { ctx.print_color_centered(15, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Your journey has ended!"); ctx.print_color_centered(17, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "One day, we'll tell you all about how you did."); ctx.print_color_centered(18, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "That day, sadly, is not in this chapter.."); ctx.print_color_centered(20, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Press any key to return to the menu."); match ctx.key { None => GameOverResult::NoSelection, Some(_) => GameOverResult::QuitToMenu } } }
最后,我们将处理 game_over_cleanup:
#![allow(unused)] fn main() { fn game_over_cleanup(&mut self) { // 删除所有内容 let mut to_delete = Vec::new(); for e in self.ecs.entities().join() { to_delete.push(e); } for del in to_delete.iter() { self.ecs.delete_entity(*del).expect("Deletion failed"); } // 构建新地图并放置玩家 let worldmap; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); *worldmap_resource = Map::new_map_rooms_and_corridors(1); worldmap = worldmap_resource.clone(); } // 生成坏人 for room in worldmap.rooms.iter().skip(1) { spawner::spawn_room(&mut self.ecs, room, 1); } // 放置玩家并更新资源 let (player_x, player_y) = worldmap.rooms[0].center(); let player_entity = spawner::player(&mut self.ecs, player_x, player_y); let mut player_position = self.ecs.write_resource::<Point>(); *player_position = Point::new(player_x, player_y); let mut position_components = self.ecs.write_storage::<Position>(); let mut player_entity_writer = self.ecs.write_resource::<Entity>(); *player_entity_writer = player_entity; let player_pos_comp = position_components.get_mut(player_entity); if let Some(player_pos_comp) = player_pos_comp { player_pos_comp.x = player_x; player_pos_comp.y = player_y; } // 将玩家的视野标记为脏 (dirty) let mut viewshed_components = self.ecs.write_storage::<Viewshed>(); let vs = viewshed_components.get_mut(player_entity); if let Some(vs) = vs { vs.dirty = true; } } }
从我们加载游戏时的序列化工作中,这应该看起来很熟悉。 它非常相似,但它生成了一个新玩家。
现在,如果你 cargo run,并且死亡 - 你将收到一条消息,告知你游戏结束,并将你送回菜单。

总结
这是本教程第一部分的结尾。 它相对紧密地遵循了 Python 教程,并将您从“hello rust”带到一个相当有趣的 Roguelike 游戏。 我希望你喜欢它! 请继续关注,我希望很快添加第二部分。
本章的源代码可以在这里找到
在你的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson.
Section 2 - 拓展目标
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 您可以随意使用。我希望您喜欢本教程,并能制作出精彩的游戏!
如果您喜欢本教程并希望我继续创作,请考虑在 我的 Patreon 上支持我。
我一直很享受编写本教程的过程,并且有人在使用它(谢谢!),所以我决定继续添加内容。第二节与其说是一个结构化的教程,不如说是一个内容的大杂烩。当我们作为一个社区尝试构建一个出色的 Roguelike 游戏时,我会继续添加内容。
如果您有任何问题,改进的想法或希望我添加的内容,请随时与我联系(我在 Twitter 上的用户名是 @herberticus)。另外,也为所有关于 Patreon 的内容感到抱歉 - 希望有人会觉得这足够有用,愿意请我喝一两杯咖啡。:-)
版权所有 (C) 2019, Herbert Wolverson。
更棒的墙壁
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
到目前为止,我们对地图使用了非常传统的渲染风格。井号 (#) 代表墙壁,句点 (.) 代表地板。 看起来很不错,但是像《矮人要塞》(Dwarf Fortress) 这样的游戏在使用 codepage 437 的线条绘制字符来使地牢的墙壁看起来平滑方面做得非常出色。 这个简短的章节将展示如何使用 bitmask 来计算合适的墙壁并适当地渲染它们。 和往常一样,我们将从第 1 节末尾的先前代码开始。
计算邻居以构建我们的位掩码 (bitset)
我们在 map.rs 中有一个像样的地图渲染系统,特别是函数 draw_map。 如果您找到按类型匹配 tile 的部分,我们可以从扩展 Wall 选择开始:
#![allow(unused)] fn main() { TileType::Wall => { glyph = wall_glyph(&*map, x, y); fg = RGB::from_f32(0., 1.0, 0.); } }
这需要 wall_glyph 函数,所以让我们编写它:
#![allow(unused)] fn main() { fn wall_glyph(map : &Map, x: i32, y:i32) -> rltk::FontCharType { if x < 1 || x > map.width-2 || y < 1 || y > map.height-2 as i32 { return 35; } let mut mask : u8 = 0; if is_revealed_and_wall(map, x, y - 1) { mask +=1; } if is_revealed_and_wall(map, x, y + 1) { mask +=2; } if is_revealed_and_wall(map, x - 1, y) { mask +=4; } if is_revealed_and_wall(map, x + 1, y) { mask +=8; } match mask { 0 => { 9 } // 柱子,因为我们看不到邻居 1 => { 186 } // 仅北边有墙 2 => { 186 } // 仅南边有墙 3 => { 186 } // 南北都有墙 4 => { 205 } // 仅西边有墙 5 => { 188 } // 北边和西边有墙 6 => { 187 } // 南边和西边有墙 7 => { 185 } // 北边、南边和西边有墙 8 => { 205 } // 仅东边有墙 9 => { 200 } // 北边和东边有墙 10 => { 201 } // 南边和东边有墙 11 => { 204 } // 北边、南边和东边有墙 12 => { 205 } // 东西都有墙 13 => { 202 } // 东、西和南边有墙 14 => { 203 } // 东、西和北边有墙 15 => { 206 } // ╬ 四面都有墙 _ => { 35 } // 我们遗漏了一种情况? } } }
让我们逐步了解这个函数:
- 如果我们位于地图边界,我们不希望冒险超出边界 - 所以我们返回一个
#符号 (ASCII 35)。 - 现在我们创建一个 8 位无符号整数作为我们的
bitmask。 我们有兴趣设置单个位,并且只需要四个位 - 所以 8 位数字是完美的。 - 接下来,我们检查 4 个方向中的每一个方向,并添加到掩码 (mask)。 我们添加的数字对应于二进制的前四个位 - 即 1,2,4,8。 这意味着我们的最终数字将存储我们是否拥有四个可能的邻居中的每一个。 例如,值 3 表示我们在南北方向都有邻居。
- 然后我们匹配生成的掩码位,并从 codepage 437 字符集 返回适当的线条绘制字符。
这个函数反过来又调用了 is_revealed_and_wall,所以我们也来编写它! 它非常简单:
#![allow(unused)] fn main() { fn is_revealed_and_wall(map: &Map, x: i32, y: i32) -> bool { let idx = map.xy_idx(x, y); map.tiles[idx] == TileType::Wall && map.revealed_tiles[idx] } }
它只是检查一个瓦片 (tile) 是否被显示 (revealed) 并且它是否是墙壁。 如果两者都为真,则返回 true - 否则返回 false。
如果您现在 cargo run 运行项目,您将获得一组看起来更漂亮的墙壁:

本章的源代码可以在这里找到
在您的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权 (Copyright) (C) 2019, Herbert Wolverson。
血迹
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
我们的角色过着“杀人流浪汉 (murder-hobo)”的生活,随意掠夺和杀戮 - 因此,原始的地牢开始变得像停尸房 (charnel house) 也就很合理了。 这也为我们进入未来的章节搭建了桥梁,在未来的章节中,我们将开始向游戏中添加一些粒子和视觉效果(以 ASCII/CP437 格式)。
存储血液
瓦片 (Tile) 要么有血迹,要么没有,因此将它们作为“集合 (set)”附加到地图是有意义的。 因此,在 map.rs 的顶部,我们将包含一个新的存储类型 - HashSet:
#![allow(unused)] fn main() { use std::collections::HashSet; }
在地图定义中,我们将包含一个 usize(用于表示瓦片索引)类型的 HashSet 用于血液:
#![allow(unused)] fn main() { #[derive(Default, Serialize, Deserialize, Clone)] pub struct Map { pub tiles : Vec<TileType>, pub rooms : Vec<Rect>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool>, pub blocked : Vec<bool>, pub depth : i32, pub bloodstains : HashSet<usize>, #[serde(skip_serializing)] #[serde(skip_deserializing)] pub tile_content : Vec<Vec<Entity>> } }
在新地图生成器中,我们将初始化它:
#![allow(unused)] fn main() { let mut map = Map{ tiles : vec![TileType::Wall; MAPCOUNT], rooms : Vec::new(), width : MAPWIDTH as i32, height: MAPHEIGHT as i32, revealed_tiles : vec![false; MAPCOUNT], visible_tiles : vec![false; MAPCOUNT], blocked : vec![false; MAPCOUNT], tile_content : vec![Vec::new(); MAPCOUNT], depth: new_depth, bloodstains: HashSet::new() }; }
渲染血液
我们将通过将瓦片背景更改为深红色来指示血迹。 我们不想让效果太“引人注目”,也不想隐藏瓦片内容 - 因此这应该足够了。 我们也不会显示视觉范围之外的血液,以保持低调。 在 map.rs 中,渲染部分现在看起来像这样:
#![allow(unused)] fn main() { if map.revealed_tiles[idx] { let glyph; let mut fg; let mut bg = RGB::from_f32(0., 0., 0.); match tile { TileType::Floor => { glyph = rltk::to_cp437('.'); fg = RGB::from_f32(0.0, 0.5, 0.5); } TileType::Wall => { glyph = wall_glyph(&*map, x, y); fg = RGB::from_f32(0., 1.0, 0.); } TileType::DownStairs => { glyph = rltk::to_cp437('>'); fg = RGB::from_f32(0., 1.0, 1.0); } } if map.bloodstains.contains(&idx) { bg = RGB::from_f32(0.75, 0., 0.); } if !map.visible_tiles[idx] { fg = fg.to_greyscale(); bg = RGB::from_f32(0., 0., 0.); // 不显示视觉范围外的污渍 } ctx.set(x, y, fg, bg, glyph); } }
为血腥之神献血 (Blood for the blood god)
现在我们需要向场景中添加血液! 每当有人在瓦片中受到伤害时,我们都会将该瓦片标记为血腥的。 我们将调整 damage_system.rs 中的 DamageSystem 以设置血迹:
#![allow(unused)] fn main() { impl<'a> System<'a> for DamageSystem { type SystemData = ( WriteStorage<'a, CombatStats>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, Position>, WriteExpect<'a, Map>, Entities<'a> ); fn run(&mut self, data : Self::SystemData) { let (mut stats, mut damage, positions, mut map, entities) = data; for (entity, mut stats, damage) in (&entities, &mut stats, &damage).join() { stats.hp -= damage.amount.iter().sum::<i32>(); let pos = positions.get(entity); if let Some(pos) = pos { let idx = map.xy_idx(pos.x, pos.y); map.bloodstains.insert(idx); } } damage.clear(); } } }
如果您 cargo run 运行您的项目,地图将开始显示战斗的迹象!

本章的源代码可以在这里找到
在您的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权 (Copyright) (C) 2019, Herbert Wolverson。
ASCII 字符粒子特效
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
对于你的操作没有真正的视觉反馈 - 你击中了什么东西,它要么消失,要么不消失。 血迹可以很好地表示一个地点 先前 发生了什么 - 但如果能对你的行为给出某种即时反应就更好了。 这些反应需要快速、非阻塞(这样你就不必等待动画完成才能继续玩游戏),并且不宜过于突兀。 粒子非常适合这种情况,所以我们将实现一个简单的 ASCII/CP437 粒子系统。
粒子组件
和往常一样,我们将从思考粒子 是 什么开始。 通常,它有一个位置、一些要渲染的东西和一个生命周期(以便它消失)。 我们已经完成了其中三项中的两项,所以让我们继续创建 ParticleLifetime 组件。 在 components.rs 中:
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct ParticleLifetime { pub lifetime_ms : f32 } }
我们必须在所有通常的地方注册它:main.rs 和 saveload_system.rs (两次)。
将粒子代码分组在一起
我们将创建一个新文件 particle_system.rs。 它不会是一个常规的 system,因为我们需要访问 RLTK Context 对象 - 但它必须为其他 system 提供服务。
首先要支持的是让粒子在生命周期结束后消失。 因此,我们在 particle_system.rs 中从以下内容开始:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{ Rltk, ParticleLifetime}; pub fn cull_dead_particles(ecs : &mut World, ctx : &Rltk) { let mut dead_particles : Vec<Entity> = Vec::new(); { // 让粒子老化消亡 let mut particles = ecs.write_storage::<ParticleLifetime>(); let entities = ecs.entities(); for (entity, mut particle) in (&entities, &mut particles).join() { particle.lifetime_ms -= ctx.frame_time_ms; if particle.lifetime_ms < 0.0 { dead_particles.push(entity); } } } for dead in dead_particles.iter() { ecs.delete_entity(*dead).expect("Particle will not die"); } } }
然后我们修改 main.rs 中的渲染循环来调用它:
#![allow(unused)] fn main() { ctx.cls(); particle_system::cull_dead_particles(&mut self.ecs, ctx); }
通过服务生成粒子
让我们扩展 particle_system.rs 以提供一个构建器系统:你获得一个 ParticleBuilder 并向其添加请求,然后一起批量创建你的粒子。 我们将把粒子系统作为 resource 提供 - 这样它就可以在任何地方使用。 这避免了在每个系统中添加太多侵入性代码,并允许我们将实际的粒子生成作为一个单一的(快速)批处理来处理。
我们基本的 ParticleBuilder 看起来像这样。 我们还没有做任何实际 添加 粒子的事情,但这提供了请求器服务:
#![allow(unused)] fn main() { struct ParticleRequest { x: i32, y: i32, fg: RGB, bg: RGB, glyph: rltk::FontCharType, lifetime: f32 } pub struct ParticleBuilder { requests : Vec<ParticleRequest> } impl ParticleBuilder { #[allow(clippy::new_without_default)] pub fn new() -> ParticleBuilder { ParticleBuilder{ requests : Vec::new() } } pub fn request(&mut self, x:i32, y:i32, fg: RGB, bg:RGB, glyph: rltk::FontCharType, lifetime: f32) { self.requests.push( ParticleRequest{ x, y, fg, bg, glyph, lifetime } ); } } }
在 main.rs 中,我们将它变成一个 resource:
#![allow(unused)] fn main() { gs.ecs.insert(particle_system::ParticleBuilder::new()); }
现在,我们将返回 particle_system.rs 并构建一个实际的 system 来生成粒子。 该 system 看起来像这样:
#![allow(unused)] fn main() { pub struct ParticleSpawnSystem {} impl<'a> System<'a> for ParticleSpawnSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteStorage<'a, Position>, WriteStorage<'a, Renderable>, WriteStorage<'a, ParticleLifetime>, WriteExpect<'a, ParticleBuilder> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut positions, mut renderables, mut particles, mut particle_builder) = data; for new_particle in particle_builder.requests.iter() { let p = entities.create(); positions.insert(p, Position{ x: new_particle.x, y: new_particle.y }).expect("Unable to inser position"); renderables.insert(p, Renderable{ fg: new_particle.fg, bg: new_particle.bg, glyph: new_particle.glyph, render_order: 0 }).expect("Unable to insert renderable"); particles.insert(p, ParticleLifetime{ lifetime_ms: new_particle.lifetime }).expect("Unable to insert lifetime"); } particle_builder.requests.clear(); } } }
这是一个非常简单的服务:它迭代请求,并为每个粒子创建一个 entity,其中包含来自请求的组件参数。 然后它清除构建器列表。 最后一步是在 main.rs 中将其添加到 system 调度中:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut mob = MonsterAI{}; mob.run_now(&self.ecs); let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut melee = MeleeCombatSystem{}; melee.run_now(&self.ecs); let mut damage = DamageSystem{}; damage.run_now(&self.ecs); let mut pickup = ItemCollectionSystem{}; pickup.run_now(&self.ecs); let mut itemuse = ItemUseSystem{}; itemuse.run_now(&self.ecs); let mut drop_items = ItemDropSystem{}; drop_items.run_now(&self.ecs); let mut item_remove = ItemRemoveSystem{}; item_remove.run_now(&self.ecs); let mut particles = particle_system::ParticleSpawnSystem{}; particles.run_now(&self.ecs); self.ecs.maintain(); } } }
我们使其依赖于可能的粒子生成器。 我们需要小心一点,避免意外地使其与任何可能向其添加粒子的东西并发执行。
实际为战斗生成一些粒子
让我们从在有人攻击时生成粒子开始。 打开 melee_combat_system.rs,我们将 ParticleBuilder 添加到 system 请求的 resource 列表中。 首先,是 include:
#![allow(unused)] fn main() { use super::{CombatStats, WantsToMelee, Name, SufferDamage, gamelog::GameLog, MeleePowerBonus, DefenseBonus, Equipped, particle_system::ParticleBuilder, Position}; }
然后,一个 WriteExpect,以便能够写入 resource:
#![allow(unused)] fn main() { type SystemData = ( Entities<'a>, WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToMelee>, ReadStorage<'a, Name>, ReadStorage<'a, CombatStats>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, MeleePowerBonus>, ReadStorage<'a, DefenseBonus>, ReadStorage<'a, Equipped>, WriteExpect<'a, ParticleBuilder>, ReadStorage<'a, Position> ); }
以及 run 方法本身扩展的 resource 列表:
#![allow(unused)] fn main() { let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage, melee_power_bonuses, defense_bonuses, equipped, mut particle_builder, positions) = data; }
最后,我们将添加请求:
#![allow(unused)] fn main() { let pos = positions.get(wants_melee.target); if let Some(pos) = pos { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0); } let damage = i32::max(0, (stats.power + offensive_bonus) - (target_stats.defense + defensive_bonus)); }
如果你现在 cargo run,你将看到相对微妙的粒子反馈,以显示发生了近战。 这绝对有助于游戏的 感觉,并且足够不突兀,以至于我们不会使我们的其他 system 过于混乱。

为物品使用添加特效
为物品使用添加类似的效果会很棒,所以让我们这样做! 在 inventory_system.rs 中,我们将扩展 ItemUseSystem 的介绍以包含 ParticleBuilder:
#![allow(unused)] fn main() { impl<'a> System<'a> for ItemUseSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, ReadExpect<'a, Map>, Entities<'a>, WriteStorage<'a, WantsToUseItem>, ReadStorage<'a, Name>, ReadStorage<'a, Consumable>, ReadStorage<'a, ProvidesHealing>, ReadStorage<'a, InflictsDamage>, WriteStorage<'a, CombatStats>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, AreaOfEffect>, WriteStorage<'a, Confusion>, ReadStorage<'a, Equippable>, WriteStorage<'a, Equipped>, WriteStorage<'a, InBackpack>, WriteExpect<'a, ParticleBuilder>, ReadStorage<'a, Position> ); #[allow(clippy::cognitive_complexity)] fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, map, entities, mut wants_use, names, consumables, healing, inflict_damage, mut combat_stats, mut suffer_damage, aoe, mut confused, equippable, mut equipped, mut backpack, mut particle_builder, positions) = data; }
我们将从在你喝下治疗药水时显示一颗心形开始。 在 healing 部分:
#![allow(unused)] fn main() { stats.hp = i32::min(stats.max_hp, stats.hp + healer.heal_amount); if entity == *player_entity { gamelog.entries.push(format!("You use the {}, healing {} hp.", names.get(useitem.item).unwrap().name, healer.heal_amount)); } used_item = true; let pos = positions.get(*target); if let Some(pos) = pos { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::GREEN), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('♥'), 200.0); } }
我们可以对 confusion 使用类似的效果 - 只是使用品红色问号。 在 confusion 部分:
#![allow(unused)] fn main() { gamelog.entries.push(format!("You use {} on {}, confusing them.", item_name.name, mob_name.name)); let pos = positions.get(*mob); if let Some(pos) = pos { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::MAGENTA), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('?'), 200.0); } }
我们还应该使用粒子来指示造成了伤害。 在 system 的 damage 部分:
#![allow(unused)] fn main() { gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage)); let pos = positions.get(*mob); if let Some(pos) = pos { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::RED), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0); } }
最后,如果效果击中整个区域(例如,火球),最好指示该区域是什么。 在 system 的 targeting 部分,添加:
#![allow(unused)] fn main() { for mob in map.tile_content[idx].iter() { targets.push(*mob); } particle_builder.request(tile_idx.x, tile_idx.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('░'), 200.0); }
这并不太难,不是吗? 如果你现在 cargo run 你的项目,你将看到各种视觉效果触发。

为因 confusion 错过回合添加指示器
最后,当怪物回合时,如果它们因 confusion 而跳过回合,我们将重复 confusion 效果。 这应该减少对它们为何站在原地发呆的困惑。 在 monster_ai_system.rs 中,我们首先修改 system 标头以请求适当的 helper:
#![allow(unused)] fn main() { impl<'a> System<'a> for MonsterAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, Point>, ReadExpect<'a, Entity>, ReadExpect<'a, RunState>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Monster>, WriteStorage<'a, Position>, WriteStorage<'a, WantsToMelee>, WriteStorage<'a, Confusion>, WriteExpect<'a, ParticleBuilder>); fn run(&mut self, data : Self::SystemData) { let (mut map, player_pos, player_entity, runstate, entities, mut viewshed, monster, mut position, mut wants_to_melee, mut confused, mut particle_builder) = data; }
然后我们在 confusion 测试的末尾添加一个请求:
#![allow(unused)] fn main() { can_act = false; particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::MAGENTA), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('?'), 200.0); }
我们不需要担心在这里获取 Position 组件,因为我们已经在循环中获取了它。 如果你现在 cargo run 你的项目,并找到一个 confusion 卷轴 - 你将获得视觉反馈,了解为什么 goblin 不再追逐你了:

总结
目前视觉效果就到此为止。 我们为游戏赋予了更强的代入感,并为操作提供了反馈。 这是一个很大的改进,并且在很大程度上使 ASCII 界面现代化!
本章的源代码可以在这里找到
在你的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
添加饥饿时钟和食物
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续创作,请考虑支持我的 Patreon。
饥饿时钟是许多 Roguelike 游戏中备受争议的功能。 如果您花费所有时间寻找食物,它们真的会困扰玩家,但它们也会推动您前进 - 因此您不能无所事事而不去探索更多。 尤其,休息以恢复生命值变成了一个更具风险/回报的系统。 本章将为玩家实现一个基本的饥饿时钟。
添加饥饿时钟组件
我们将为玩家添加一个饥饿时钟,因此第一步是创建一个组件来表示它。 在 components.rs 中:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Copy, Clone, PartialEq)] pub enum HungerState { WellFed, Normal, Hungry, Starving } #[derive(Component, Serialize, Deserialize, Clone)] pub struct HungerClock { pub state : HungerState, pub duration : i32 } }
与所有组件一样,它需要在 main.rs 和 saveload_system.rs 中注册。 在 spawners.rs 中,我们将扩展 player 函数,为玩家添加饥饿时钟:
#![allow(unused)] fn main() { pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity { ecs .create_entity() .with(Position { x: player_x, y: player_y }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), render_order: 0 }) .with(Player{}) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Name{name: "Player".to_string() }) .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 }) .with(HungerClock{ state: HungerState::WellFed, duration: 20 }) .marked::<SimpleMarker<SerializeMe>>() .build() } }
现在已经有了一个饥饿时钟组件,但它还没有任何作用!
添加饥饿系统
我们将创建一个新文件 hunger_system.rs 并实现一个饥饿时钟系统。 这非常简单直接:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{HungerClock, RunState, HungerState, SufferDamage, gamelog::GameLog}; pub struct HungerSystem {} impl<'a> System<'a> for HungerSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteStorage<'a, HungerClock>, ReadExpect<'a, Entity>, // The player ReadExpect<'a, RunState>, WriteStorage<'a, SufferDamage>, WriteExpect<'a, GameLog> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut hunger_clock, player_entity, runstate, mut inflict_damage, mut log) = data; for (entity, mut clock) in (&entities, &mut hunger_clock).join() { let mut proceed = false; match *runstate { RunState::PlayerTurn => { if entity == *player_entity { proceed = true; } } RunState::MonsterTurn => { if entity != *player_entity { proceed = true; } } _ => proceed = false } if proceed { clock.duration -= 1; if clock.duration < 1 { match clock.state { HungerState::WellFed => { clock.state = HungerState::Normal; clock.duration = 200; if entity == *player_entity { log.entries.push("You are no longer well fed.".to_string()); } } HungerState::Normal => { clock.state = HungerState::Hungry; clock.duration = 200; if entity == *player_entity { log.entries.push("You are hungry.".to_string()); } } HungerState::Hungry => { clock.state = HungerState::Starving; clock.duration = 200; if entity == *player_entity { log.entries.push("You are starving!".to_string()); } } HungerState::Starving => { // Inflict damage from hunger if entity == *player_entity { log.entries.push("Your hunger pangs are getting painful! You suffer 1 hp damage.".to_string()); } SufferDamage::new_damage(&mut inflict_damage, entity, 1); } } } } } } } }
它的工作原理是迭代所有拥有 HungerClock 的实体。 如果它们是玩家,则仅在 PlayerTurn 状态下生效; 同样,如果它们是怪物,则仅在它们的 turn 中发生(以防我们以后想要饥饿的怪物!)。 当前状态的持续时间在每次运行时都会减少。 如果它达到 0,它会向下移动一个状态 - 或者如果您处于饥饿状态,则会对您造成伤害。
现在我们需要将其添加到 main.rs 中运行的系统列表中:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut mob = MonsterAI{}; mob.run_now(&self.ecs); let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut melee = MeleeCombatSystem{}; melee.run_now(&self.ecs); let mut damage = DamageSystem{}; damage.run_now(&self.ecs); let mut pickup = ItemCollectionSystem{}; pickup.run_now(&self.ecs); let mut itemuse = ItemUseSystem{}; itemuse.run_now(&self.ecs); let mut drop_items = ItemDropSystem{}; drop_items.run_now(&self.ecs); let mut item_remove = ItemRemoveSystem{}; item_remove.run_now(&self.ecs); let mut hunger = hunger_system::HungerSystem{}; hunger.run_now(&self.ecs); let mut particles = particle_system::ParticleSpawnSystem{}; particles.run_now(&self.ecs); self.ecs.maintain(); } } }
如果您现在 cargo run,并点击等待很多次 - 您就会饿死。

显示状态
如果能知道您的饥饿状态就太好了! 我们将修改 gui.rs 中的 draw_ui 来显示它:
#![allow(unused)] fn main() { pub fn draw_ui(ecs: &World, ctx : &mut Rltk) { ctx.draw_box(0, 43, 79, 6, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); let combat_stats = ecs.read_storage::<CombatStats>(); let players = ecs.read_storage::<Player>(); let hunger = ecs.read_storage::<HungerClock>(); for (_player, stats, hc) in (&players, &combat_stats, &hunger).join() { let health = format!(" HP: {} / {} ", stats.hp, stats.max_hp); ctx.print_color(12, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &health); ctx.draw_bar_horizontal(28, 43, 51, stats.hp, stats.max_hp, RGB::named(rltk::RED), RGB::named(rltk::BLACK)); match hc.state { HungerState::WellFed => ctx.print_color(71, 42, RGB::named(rltk::GREEN), RGB::named(rltk::BLACK), "Well Fed"), HungerState::Normal => {} HungerState::Hungry => ctx.print_color(71, 42, RGB::named(rltk::ORANGE), RGB::named(rltk::BLACK), "Hungry"), HungerState::Starving => ctx.print_color(71, 42, RGB::named(rltk::RED), RGB::named(rltk::BLACK), "Starving"), } } ... }
如果您 cargo run 您的项目,这将提供一个非常令人愉悦的显示:

添加食物
饿死固然不错,但是如果玩家在 620 回合后总是开始死亡(并且在此之前会遭受后果!620 听起来可能很多,但是在一个关卡上使用几百步是很常见的,而且我们并没有试图使食物成为主要的游戏焦点),玩家会感到沮丧。 我们将引入一个新的物品 Rations(口粮)。 我们已经拥有了大部分所需的组件,但是我们需要一个新的组件来指示物品 ProvidesFood(提供食物)。 在 components.rs 中:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct ProvidesFood {} }
与往常一样,我们需要在 main.rs 和 saveload_system.rs 中注册它。
现在,在 spawner.rs 中,我们将创建一个新函数来制作口粮:
#![allow(unused)] fn main() { fn rations(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('%'), fg: RGB::named(rltk::GREEN), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Rations".to_string() }) .with(Item{}) .with(ProvidesFood{}) .with(Consumable{}) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
我们还将它添加到生成表(非常常见):
#![allow(unused)] fn main() { fn room_table(map_depth: i32) -> RandomTable { RandomTable::new() .add("Goblin", 10) .add("Orc", 1 + map_depth) .add("Health Potion", 7) .add("Fireball Scroll", 2 + map_depth) .add("Confusion Scroll", 2 + map_depth) .add("Magic Missile Scroll", 4) .add("Dagger", 3) .add("Shield", 3) .add("Longsword", map_depth - 1) .add("Tower Shield", map_depth - 1) .add("Rations", 10) } }
并添加到生成代码:
#![allow(unused)] fn main() { match spawn.1.as_ref() { "Goblin" => goblin(ecs, x, y), "Orc" => orc(ecs, x, y), "Health Potion" => health_potion(ecs, x, y), "Fireball Scroll" => fireball_scroll(ecs, x, y), "Confusion Scroll" => confusion_scroll(ecs, x, y), "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y), "Dagger" => dagger(ecs, x, y), "Shield" => shield(ecs, x, y), "Longsword" => longsword(ecs, x, y), "Tower Shield" => tower_shield(ecs, x, y), "Rations" => rations(ecs, x, y), _ => {} } }
如果您现在 cargo run,您将遇到可以捡起和丢弃的口粮。 但是,您不能吃它们! 我们将把该功能添加到 inventory_system.rs 中。 这是相关的部分(完整版本请参见教程源代码):
#![allow(unused)] fn main() { // It it is edible, eat it! // 如果它是可食用的,就吃掉它! let item_edible = provides_food.get(useitem.item); match item_edible { None => {} Some(_) => { used_item = true; let target = targets[0]; let hc = hunger_clocks.get_mut(target); if let Some(hc) = hc { hc.state = HungerState::WellFed; hc.duration = 20; gamelog.entries.push(format!("You eat the {}.", names.get(useitem.item).unwrap().name)); } } } }
如果您现在 cargo run,您可以四处奔跑 - 找到口粮,并吃掉它们以重置饥饿时钟!

为吃饱喝足添加奖励
如果 Well Fed(吃饱喝足)状态能做些什么就太好了! 当您吃饱时,我们将给您一个临时的 +1 力量奖励。 这鼓励玩家进食 - 即使他们不必这样做(偷偷地使在较低级别生存更加困难,因为食物变得不那么丰富)。 在 melee_combat_system.rs 中,我们添加:
#![allow(unused)] fn main() { let hc = hunger_clock.get(entity); if let Some(hc) = hc { if hc.state == HungerState::WellFed { offensive_bonus += 1; } } }
就是这样! 您会因为吃饱口粮而获得 +1 力量奖励。
防止在饥饿或饥饿状态下治疗
作为食物的另一个好处,我们将阻止您在饥饿或饥饿状态下等待治疗(这也平衡了我们之前添加的治疗系统)。 在 player.rs 中,我们修改 skip_turn:
#![allow(unused)] fn main() { let hunger_clocks = ecs.read_storage::<HungerClock>(); let hc = hunger_clocks.get(*player_entity); if let Some(hc) = hc { match hc.state { HungerState::Hungry => can_heal = false, HungerState::Starving => can_heal = false, _ => {} } } if can_heal { }
总结
我们现在有了一个可用的饥饿时钟系统。 您可能想要调整持续时间以适合您的口味(或者如果它不是您的菜,则完全跳过它) - 但它是该类型的支柱,因此最好将其包含在教程中。
本章的源代码可以在这里找到
在您的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
魔法地图
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在 Roguelike 游戏中,一个非常常见的物品是魔法地图卷轴。 你阅读它,地下城就会被揭示出来。 更精美的 Roguelike 游戏为此提供了漂亮的图形效果。 在本章中,我们将从使其工作开始 - 然后使其变得漂亮!
添加魔法地图组件
我们拥有了所需的一切,除了一个指示物品是魔法地图卷轴(或任何其他物品,真的)的指示器。 因此,在 components.rs 中,我们将为其添加一个组件:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct MagicMapper {} }
与往常一样,我们需要在 main.rs 和 saveload_system.rs 中注册它。 我们将前往 spawners.rs 并为其创建一个新函数,并将其添加到战利品表中:
#![allow(unused)] fn main() { fn magic_mapping_scroll(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437(')'), fg: RGB::named(rltk::CYAN3), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Scroll of Magic Mapping".to_string() }) .with(Item{}) .with(MagicMapper{}) .with(Consumable{}) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
以及战利品表:
#![allow(unused)] fn main() { fn room_table(map_depth: i32) -> RandomTable { RandomTable::new() .add("Goblin", 10) .add("Orc", 1 + map_depth) .add("Health Potion", 7) .add("Fireball Scroll", 2 + map_depth) .add("Confusion Scroll", 2 + map_depth) .add("Magic Missile Scroll", 4) .add("Dagger", 3) .add("Shield", 3) .add("Longsword", map_depth - 1) .add("Tower Shield", map_depth - 1) .add("Rations", 10) .add("Magic Mapping Scroll", 400) } }
请注意,我们赋予了它 400 的权重 - 绝对是荒谬的。 我们稍后会修复它,现在我们真的想生成卷轴以便我们可以测试它! 最后,我们将其添加到实际的生成函数中:
#![allow(unused)] fn main() { match spawn.1.as_ref() { "Goblin" => goblin(ecs, x, y), "Orc" => orc(ecs, x, y), "Health Potion" => health_potion(ecs, x, y), "Fireball Scroll" => fireball_scroll(ecs, x, y), "Confusion Scroll" => confusion_scroll(ecs, x, y), "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y), "Dagger" => dagger(ecs, x, y), "Shield" => shield(ecs, x, y), "Longsword" => longsword(ecs, x, y), "Tower Shield" => tower_shield(ecs, x, y), "Rations" => rations(ecs, x, y), "Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y), _ => {} } }
如果您现在运行 cargo run,您可能会发现可以拾取的卷轴 - 但它们不会做任何事情。
映射关卡 - 简单版本
我们将修改 inventory_system.rs 以检测您是否刚刚使用了魔法地图卷轴,并显示整个地图:
#![allow(unused)] fn main() { // 如果是魔法地图卷轴... let is_mapper = magic_mapper.get(useitem.item); match is_mapper { None => {} Some(_) => { used_item = true; for r in map.revealed_tiles.iter_mut() { *r = true; } gamelog.entries.push("The map is revealed to you!".to_string()); } } }
还有一些框架更改(请参阅源代码); 我们已经做过很多次了,我认为不需要再次在这里重复。 如果您现在 cargo run 该项目,找到一个卷轴(它们无处不在)并使用它 - 地图会立即显示:

让它更漂亮
虽然那里展示的代码是有效的,但它在视觉上并不吸引人。 在游戏中加入一些花絮,让用户时不时地对 ASCII 终端的美丽感到惊喜是件好事! 我们将再次修改 inventory_system.rs:
#![allow(unused)] fn main() { // 如果是魔法地图卷轴... let is_mapper = magic_mapper.get(useitem.item); match is_mapper { None => {} Some(_) => { used_item = true; gamelog.entries.push("The map is revealed to you!".to_string()); *runstate = RunState::MagicMapReveal{ row : 0}; } } }
请注意,我们没有修改地图,而是将游戏状态更改为映射模式。 我们实际上还不支持这样做,所以让我们进入 main.rs 中的状态映射器并修改 PlayerTurn 以处理它:
#![allow(unused)] fn main() { RunState::PlayerTurn => { self.systems.dispatch(&self.ecs); self.ecs.maintain(); match *self.ecs.fetch::<RunState>() { RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 }, _ => newrunstate = RunState::MonsterTurn } } }
当我们在这里时,让我们将状态添加到 RunState:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, ShowRemoveItem, GameOver, MagicMapReveal { row : i32 } } }
我们还在 tick 循环中为新状态添加了一些逻辑:
#![allow(unused)] fn main() { RunState::MagicMapReveal{row} => { let mut map = self.ecs.fetch_mut::<Map>(); for x in 0..MAPWIDTH { let idx = map.xy_idx(x as i32,row); map.revealed_tiles[idx] = true; } if row as usize == MAPHEIGHT-1 { newrunstate = RunState::MonsterTurn; } else { newrunstate = RunState::MagicMapReveal{ row: row+1 }; } } }
这非常简单:它显示当前行上的瓦片,然后如果我们没有到达地图底部 - 它会增加行数。 如果我们到达了底部,它会返回到我们之前的位置 - MonsterTurn。 如果您现在 cargo run,找到魔法地图卷轴并使用它,地图会很好地淡入:

记住要降低生成优先级!
在 spawners.rs 中,我们目前正在到处生成魔法地图卷轴。 这可能不是我们想要的! 编辑生成表以使其具有更低的优先级:
#![allow(unused)] fn main() { fn room_table(map_depth: i32) -> RandomTable { RandomTable::new() .add("Goblin", 10) .add("Orc", 1 + map_depth) .add("Health Potion", 7) .add("Fireball Scroll", 2 + map_depth) .add("Confusion Scroll", 2 + map_depth) .add("Magic Missile Scroll", 4) .add("Dagger", 3) .add("Shield", 3) .add("Longsword", map_depth - 1) .add("Tower Shield", map_depth - 1) .add("Rations", 10) .add("Magic Mapping Scroll", 2) } }
总结
这是一个相对快速的章节,但我们现在拥有了 Roguelike 类型的另一个主要元素:魔法地图。
本章的源代码可以在这里找到
在您的浏览器中使用 Web Assembly 运行本章的示例 (需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson.
REX Paint 主菜单
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢本教程并希望我继续写作,请考虑支持我的 Patreon。
我们的主菜单非常无聊,不利于吸引玩家!本章将为它增添趣味。
REX Paint
Grid Sage Games(Reddit 上令人惊叹的 u/Kyzrati)提供了一个很棒的工具,用于 Codepage 437 图像编辑,名为 REX Paint。RLTK 内置了对使用此编辑器输出的支持。正如他们在 BBC 的老牌儿童节目 Blue Peter 中常说的那样 - 这是我之前做好的一个。

我稍微作弊了一下;我找到了一张 CC0 图像,在 GIMP 中将其大小调整为 80x50,并使用我多年前编写的工具 将 PNG 转换为 REX Paint 文件。不过,我喜欢这个结果。您可以在 resources 文件夹中找到 REX Paint 文件。
加载 REX 资源
我们将引入一个新文件 rex_assets.rs 来存储我们的 REX 精灵图。该文件看起来像这样:
#![allow(unused)] fn main() { use rltk::{rex::XpFile}; rltk::embedded_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp"); pub struct RexAssets { pub menu : XpFile } impl RexAssets { #[allow(clippy::new_without_default)] pub fn new() -> RexAssets { rltk::link_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp"); RexAssets{ menu : XpFile::from_resource("../../resources/SmallDungeon_80x50.xp").unwrap() } } } }
非常简单 - 它定义了一个结构体,并在调用 new 时将地下城图形加载到其中。我们还将它作为资源插入到 Specs 中,以便我们可以在任何地方访问我们的精灵图。这里有一些新概念:
- 我们正在使用
rltk::embedded_resource!将文件包含在我们的二进制文件中。这避免了必须将二进制文件与您的可执行文件一起发布(并使wasm环境中的生活更轻松)。 #[allow(clippy::new_without_default)]告诉 linter 不要再告诉我编写默认实现,当我们不需要它时!rltk::link_resource!是嵌入式资源的后半部分;前半部分将其存储在内存中,后半部分告诉 RLTK 在哪里找到它。menu : XpFile::from_resource("../../resources/SmallDungeon_80x50.xp").unwrap()从内存中加载 Rex paint 文件。
在 main.rs 中:
#![allow(unused)] fn main() { gs.ecs.insert(rex_assets::RexAssets::new()); }
现在我们打开 gui.rs 并找到 main_menu 函数。在开始打印菜单内容之前,我们将添加两行代码:
#![allow(unused)] fn main() { let assets = gs.ecs.fetch::<RexAssets>(); ctx.render_xp_sprite(&assets.menu, 0, 0); }
结果(cargo run 查看)是一个不错的菜单的开始!

改善菜单外观 - 添加边框和文本框
为了使其看起来更漂亮,我们将处理间距 - 并为菜单和文本添加一个框。将当前标题渲染代码替换为:
#![allow(unused)] fn main() { ctx.draw_box_double(24, 18, 31, 10, RGB::named(rltk::WHEAT), RGB::named(rltk::BLACK)); ctx.print_color_centered(20, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Rust Roguelike Tutorial"); ctx.print_color_centered(21, RGB::named(rltk::CYAN), RGB::named(rltk::BLACK), "by Herbert Wolverson"); ctx.print_color_centered(22, RGB::named(rltk::GRAY), RGB::named(rltk::BLACK), "Use Up/Down Arrows and Enter"); }
如果您现在 cargo run,您的菜单看起来像这样:

这好多了!
修复间距
您会注意到,如果您没有要加载的已保存游戏,菜单项之间会有一个恼人的间隙。这是一个简单的修复方法,通过在渲染菜单时跟踪我们使用的 y 位置。这是新的菜单渲染代码:
#![allow(unused)] fn main() { let mut y = 24; if let RunState::MainMenu{ menu_selection : selection } = *runstate { if selection == MainMenuSelection::NewGame { ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Begin New Game"); } else { ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Begin New Game"); } y += 1; if save_exists { if selection == MainMenuSelection::LoadGame { ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Load Game"); } else { ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Load Game"); } y += 1; } if selection == MainMenuSelection::Quit { ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Quit"); } else { ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Quit"); } ... }
如果您现在 cargo run,它看起来更好了:

本章的源代码可以在这里找到
使用 web assembly 在您的浏览器中运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
好的,请看下面翻译后的文档:
简易陷阱
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
大多数 Roguelike 游戏,就像它们的前身《龙与地下城》(D&D) 一样,在地牢中都设有陷阱。走在看起来无辜的走廊上,哎哟 - 一支箭飞出来击中你。 本章将实现一些简单的陷阱,然后研究它们带来的游戏影响。
什么是陷阱?
大多数陷阱都遵循以下模式:你可能会看到陷阱(也可能看不到!),你无论如何都进入了该图块,陷阱触发,然后发生一些事情(伤害、传送等)。 因此,陷阱可以逻辑地分为三个部分:
- 外观(我们已经支持了),可能会被发现,也可能不会被发现(我们尚未实现)。
- 触发器 - 如果你进入陷阱的图块,就会发生一些事情。
- 效果 - 我们已经在魔法物品中接触过了。
让我们逐步完成这些组件的实现。
渲染一个基本的捕熊陷阱
许多 Roguelike 游戏使用 ^ 作为陷阱的符号,所以我们也将这样做。 我们拥有渲染基本对象所需的所有组件,因此我们将创建一个新的生成函数(在 spawners.rs 中)。 它几乎是将字形放在地图上的最简实现:
#![allow(unused)] fn main() { fn bear_trap(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('^'), fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Bear Trap".to_string() }) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
我们还将它添加到可以生成的事物列表中:
#![allow(unused)] fn main() { fn room_table(map_depth: i32) -> RandomTable { RandomTable::new() .add("Goblin", 10) .add("Orc", 1 + map_depth) .add("Health Potion", 7) .add("Fireball Scroll", 2 + map_depth) .add("Confusion Scroll", 2 + map_depth) .add("Magic Missile Scroll", 4) .add("Dagger", 3) .add("Shield", 3) .add("Longsword", map_depth - 1) .add("Tower Shield", map_depth - 1) .add("Rations", 10) .add("Magic Mapping Scroll", 2) .add("Bear Trap", 2) } }
#![allow(unused)] fn main() { match spawn.1.as_ref() { "Goblin" => goblin(ecs, x, y), "Orc" => orc(ecs, x, y), "Health Potion" => health_potion(ecs, x, y), "Fireball Scroll" => fireball_scroll(ecs, x, y), "Confusion Scroll" => confusion_scroll(ecs, x, y), "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y), "Dagger" => dagger(ecs, x, y), "Shield" => shield(ecs, x, y), "Longsword" => longsword(ecs, x, y), "Tower Shield" => tower_shield(ecs, x, y), "Rations" => rations(ecs, x, y), "Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y), "Bear Trap" => bear_trap(ecs, x, y), _ => {} } }
如果你现在 cargo run 运行项目,偶尔你会遇到一个红色的 ^ - 并且鼠标悬停时它会被标记为 “Bear Trap”(捕熊陷阱)。 不是很令人兴奋,但这是一个好的开始! 请注意,为了测试,我们将生成频率从 2 提高到 100 - 大量陷阱,使调试更容易。 记住稍后降低它!
但你并不总是能发现陷阱!
如果你总是知道陷阱在等着你,那就太容易了! 所以我们希望默认情况下使陷阱是隐藏的,并想出一种在靠近陷阱时有时可以定位陷阱的方法。 就像 ECS 驱动世界中的大多数事物一样,分析文本可以很好地提示您需要哪些组件。 在这种情况下,我们需要进入 components.rs 并创建一个新组件 - Hidden:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Hidden {} }
像往常一样,我们需要在 main.rs 和 saveload_system.rs 中注册它。 我们还将该属性赋予新的捕熊陷阱:
#![allow(unused)] fn main() { fn bear_trap(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('^'), fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Bear Trap".to_string() }) .with(Hidden{}) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
现在,我们想要修改对象渲染器,使其不显示隐藏的事物。 Specs Book 为如何从连接 (join) 中排除组件提供了很好的线索,所以我们这样做(在 main.rs 中):
#![allow(unused)] fn main() { let mut data = (&positions, &renderables, !&hidden).join().collect::<Vec<_>>(); data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) ); for (pos, render, _hidden) in data.iter() { let idx = map.xy_idx(pos.x, pos.y); if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) } } }
请注意,我们在连接中添加了一个 ! (“非” 符号)- 我们表示如果我们要渲染实体,则实体不能具有 Hidden 组件。
如果你现在 cargo run 运行项目,捕熊陷阱将不再可见。 但是,它们会显示在工具提示中(也许这样也很好,我们知道它们在那里!)。 我们也将它们从工具提示中排除。 在 gui.rs 中,我们修改 draw_tooltips 函数:
#![allow(unused)] fn main() { fn draw_tooltips(ecs: &World, ctx : &mut Rltk) { let map = ecs.fetch::<Map>(); let names = ecs.read_storage::<Name>(); let positions = ecs.read_storage::<Position>(); let hidden = ecs.read_storage::<Hidden>(); let mouse_pos = ctx.mouse_pos(); if mouse_pos.0 >= map.width || mouse_pos.1 >= map.height { return; } let mut tooltip : Vec<String> = Vec::new(); for (name, position, _hidden) in (&names, &positions, !&hidden).join() { let idx = map.xy_idx(position.x, position.y); if position.x == mouse_pos.0 && position.y == mouse_pos.1 && map.visible_tiles[idx] { tooltip.push(name.name.to_string()); } } ... }
现在,如果你 cargo run 运行项目,你将不知道陷阱的存在。 因为它们还没有做任何事情 - 它们可能根本不存在!
添加进入触发器
当实体走到陷阱上时,陷阱应该被触发。 因此,在 components.rs 中,我们将创建一个 EntryTrigger(像往常一样,我们也会在 main.rs 和 saveload_system.rs 中注册它):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct EntryTrigger {} }
我们将给捕熊陷阱一个触发器(在 spawner.rs 中):
#![allow(unused)] fn main() { fn bear_trap(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('^'), fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Bear Trap".to_string() }) .with(Hidden{}) .with(EntryTrigger{}) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
我们还需要让陷阱在实体进入时触发其触发器。 我们将添加另一个组件 EntityMoved 来指示实体在本回合已移动。 在 components.rs 中(并记住在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct EntityMoved {} }
现在,我们搜索代码库,在每次实体移动时添加一个 EntityMoved 组件。 在 player.rs 中,我们在 try_move_player 函数中处理玩家移动。 在顶部,我们将获得对相关组件存储的写入访问权:
#![allow(unused)] fn main() { let mut entity_moved = ecs.write_storage::<EntityMoved>(); }
然后,当我们确定玩家确实移动了时 - 我们将插入 EntityMoved 组件:
#![allow(unused)] fn main() { entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); }
另一个具有移动功能的位置是怪物 AI。 因此,在 monster_ai_system.rs 中,我们执行类似的操作。 我们为 EntityMoved 组件添加一个 WriteResource,并在怪物移动后插入一个。 AI 的源代码有点长,所以我建议您直接查看源文件 (here) 以了解此部分。
最后,我们需要一个系统来使触发器实际做一些事情。 我们将创建一个新文件 trigger_system.rs:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{EntityMoved, Position, EntryTrigger, Hidden, Map, Name, gamelog::GameLog}; pub struct TriggerSystem {} impl<'a> System<'a> for TriggerSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Map>, WriteStorage<'a, EntityMoved>, ReadStorage<'a, Position>, ReadStorage<'a, EntryTrigger>, WriteStorage<'a, Hidden>, ReadStorage<'a, Name>, Entities<'a>, WriteExpect<'a, GameLog>); fn run(&mut self, data : Self::SystemData) { let (map, mut entity_moved, position, entry_trigger, mut hidden, names, entities, mut log) = data; // 迭代移动的实体及其最终位置 for (entity, mut _entity_moved, pos) in (&entities, &mut entity_moved, &position).join() { let idx = map.xy_idx(pos.x, pos.y); for entity_id in map.tile_content[idx].iter() { if entity != *entity_id { // 不要费心检查自己是否是陷阱! let maybe_trigger = entry_trigger.get(*entity_id); match maybe_trigger { None => {}, Some(_trigger) => { // 我们触发了它 let name = names.get(*entity_id); if let Some(name) = name { log.entries.push(format!("{} triggers!", &name.name)); } hidden.remove(*entity_id); // 陷阱不再隐藏 } } } } } // 移除所有实体移动标记 entity_moved.clear(); } } }
如果您已经完成了前面的章节,这相对简单:
- 我们迭代所有具有
Position和EntityMoved组件的实体。 - 我们获取它们位置的地图索引。
- 我们迭代
tile_content索引以查看新图块中的内容。 - 我们查看那里是否有陷阱。
- 如果有,我们获取其名称并通过日志通知玩家陷阱已激活。
- 我们从陷阱中移除
hidden组件,因为我们现在知道它在那里。
我们还必须进入 main.rs 并插入代码以运行系统。 它在怪物 AI 之后运行,因为怪物可以移动 - 但我们可能会输出伤害,因此该系统需要稍后运行:
#![allow(unused)] fn main() { ... let mut mob = MonsterAI{}; mob.run_now(&self.ecs); let mut triggers = trigger_system::TriggerSystem{}; triggers.run_now(&self.ecs); ... }
造成伤害的陷阱
到此为止,我们已经走了很长一段路:陷阱可以散布在关卡周围,并在你进入其目标图块时触发。 如果陷阱做一些事情,那将会有所帮助! 我们实际上有相当多的组件类型来描述效果。 在 spawner.rs 中,我们将扩展捕熊陷阱以包含一些伤害:
#![allow(unused)] fn main() { fn bear_trap(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('^'), fg: RGB::named(rltk::RED), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Bear Trap".to_string() }) .with(Hidden{}) .with(EntryTrigger{}) .with(InflictsDamage{ damage: 6 }) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
我们还将扩展 trigger_system 以应用伤害:
#![allow(unused)] fn main() { // 如果陷阱是造成伤害的,则执行它 let damage = inflicts_damage.get(*entity_id); if let Some(damage) = damage { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0); SufferDamage::new_damage(&mut inflict_damage, entity, damage.damage); } }
如果你现在 cargo run 运行项目,你可以四处移动 - 并且走进陷阱会伤害你。 如果怪物走进陷阱,它也会伤害它们! 它甚至播放攻击的粒子效果。
捕熊陷阱只能触发一次
有些陷阱,例如捕熊陷阱(想想带有尖刺的弹簧),实际上只能触发一次。 这似乎是我们触发器系统建模的一个有用属性,因此我们将添加一个新组件(到 components.rs,main.rs 和 saveload_system.rs):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct SingleActivation {} }
我们还将它添加到 spawner.rs 中的 Bear Trap 函数中:
#![allow(unused)] fn main() { .with(SingleActivation{}) }
现在我们修改 trigger_system 以应用它。 请注意,我们在循环遍历实体之后删除它们,以避免混淆迭代器。
#![allow(unused)] fn main() { use specs::prelude::*; use super::{EntityMoved, Position, EntryTrigger, Hidden, Map, Name, gamelog::GameLog, InflictsDamage, particle_system::ParticleBuilder, SufferDamage, SingleActivation}; pub struct TriggerSystem {} impl<'a> System<'a> for TriggerSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Map>, WriteStorage<'a, EntityMoved>, ReadStorage<'a, Position>, ReadStorage<'a, EntryTrigger>, WriteStorage<'a, Hidden>, ReadStorage<'a, Name>, Entities<'a>, WriteExpect<'a, GameLog>, ReadStorage<'a, InflictsDamage>, WriteExpect<'a, ParticleBuilder>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, SingleActivation>); fn run(&mut self, data : Self::SystemData) { let (map, mut entity_moved, position, entry_trigger, mut hidden, names, entities, mut log, inflicts_damage, mut particle_builder, mut inflict_damage, single_activation) = data; // 迭代移动的实体及其最终位置 let mut remove_entities : Vec<Entity> = Vec::new(); for (entity, mut _entity_moved, pos) in (&entities, &mut entity_moved, &position).join() { let idx = map.xy_idx(pos.x, pos.y); for entity_id in map.tile_content[idx].iter() { if entity != *entity_id { // 不要费心检查自己是否是陷阱! let maybe_trigger = entry_trigger.get(*entity_id); match maybe_trigger { None => {}, Some(_trigger) => { // 我们触发了它 let name = names.get(*entity_id); if let Some(name) = name { log.entries.push(format!("{} triggers!", &name.name)); } hidden.remove(*entity_id); // 陷阱不再隐藏 // 如果陷阱是造成伤害的,则执行它 let damage = inflicts_damage.get(*entity_id); if let Some(damage) = damage { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0); SufferDamage::new_damage(&mut inflict_damage, entity, damage.damage); } // 如果是单次激活,则需要将其移除 let sa = single_activation.get(*entity_id); if let Some(_sa) = sa { remove_entities.push(*entity_id); } } } } } } // 移除任何单次激活陷阱 for trap in remove_entities.iter() { entities.delete(*trap).expect("Unable to delete trap"); } // 移除所有实体移动标记 entity_moved.clear(); } } }
如果你现在 cargo run 运行项目(我建议 cargo run --release - 它变得越来越慢!),你可能会被捕熊陷阱击中 - 受到一些伤害,然后陷阱消失。
发现陷阱
我们现在有了一个相当实用的陷阱系统,但是随机地无缘无故地受到伤害是很烦人的 - 因为你无法知道陷阱在那里。 这也很不公平,因为没有办法防范它。 我们将实现发现陷阱的机会。 在未来的某个时候,这可能会与属性或技能相关联 - 但就目前而言,我们将使用掷骰子。 这比要求每个人始终携带 10 英尺长的杆子要好一些(就像早期的 D&D 游戏!)。
由于 visibility_system 已经处理了显示图块,为什么不让它也可能显示隐藏的东西呢? 这是 visibility_system.rs 的代码:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Position, Map, Player, Hidden, gamelog::GameLog}; use rltk::{field_of_view, Point}; pub struct VisibilitySystem {} impl<'a> System<'a> for VisibilitySystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Position>, ReadStorage<'a, Player>, WriteStorage<'a, Hidden>, WriteExpect<'a, rltk::RandomNumberGenerator>, WriteExpect<'a, GameLog>, ReadStorage<'a, Name>,); fn run(&mut self, data : Self::SystemData) { let (mut map, entities, mut viewshed, pos, player, mut hidden, mut rng, mut log, names) = data; for (ent,viewshed,pos) in (&entities, &mut viewshed, &pos).join() { if viewshed.dirty { viewshed.dirty = false; viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map); viewshed.visible_tiles.retain(|p| p.x >= 0 && p.x < map.width && p.y >= 0 && p.y < map.height ); // 如果这是玩家,则显示他们可以看到的东西 let _p : Option<&Player> = player.get(ent); if let Some(_p) = _p { for t in map.visible_tiles.iter_mut() { *t = false }; for vis in viewshed.visible_tiles.iter() { let idx = map.xy_idx(vis.x, vis.y); map.revealed_tiles[idx] = true; map.visible_tiles[idx] = true; // 有机会显示隐藏的事物 for e in map.tile_content[idx].iter() { let maybe_hidden = hidden.get(*e); if let Some(_maybe_hidden) = maybe_hidden { if rng.roll_dice(1,24)==1 { let name = names.get(*e); if let Some(name) = name { log.entries.push(format!("You spotted a {}.", &name.name)); } hidden.remove(*e); } } } } } } } } } }
那么为什么发现陷阱的几率是 1/24 呢? 我尝试了一下,直到感觉差不多合适为止。 1/6(我的首选)太好了。 由于您的视野在您移动时会更新,因此您在四处移动时有很高的机会发现陷阱。 就像游戏设计中的许多事情一样:有时你只需要玩玩,直到感觉合适为止!
如果你现在 cargo run 运行项目,你可以四处走动 - 有时会发现陷阱。 怪物不会显示陷阱,除非它们掉入陷阱。
本章的源代码可以在 这里 找到
在您的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson.
第三节 - 程序化生成地图
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢本教程并希望我继续写作,请考虑支持 我的 Patreon。
这最初是第二节的一部分,但我意识到这是一个大型、开放的主题。 诸如 Dungeon Crawl Stone Soup、Cogmind、Caves of Qud 等大型 Roguelike 游戏都拥有各种各样的地图。 第三节完全是关于地图构建的,并将涵盖许多可用于程序化构建有趣地图的算法。
版权所有 (C) 2019, Herbert Wolverson。
重构:通用地图接口
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出精彩的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
到目前为止,我们实际上只有一个地图设计。每次都不同(除非您使用重复的随机种子),这是一个很好的开始 - 但程序化生成的世界留下了更多可能性。在接下来的几个章节中,我们将开始构建几种不同的地图类型。
重构构建器 - 定义接口
到目前为止,我们所有的地图生成代码都位于 map.rs 文件中。对于单一风格来说,这很好,但是如果我们想要有很多风格呢?现在是创建合适的构建器系统的绝佳时机!如果您查看 main.rs 中的地图生成代码,我们会发现一个已定义的接口的雏形:
- 我们调用
Map::new_map_rooms_and_corridors,它构建了一组房间。 - 我们将其传递给
spawner::spawn_room以填充每个房间。 - 然后我们将玩家放置在第一个房间中。
为了更好地组织我们的代码,我们将创建一个模块。Rust 允许您创建一个目录,其中包含一个名为 mod.rs 的文件 - 该目录现在就是一个模块。模块通过 mod 和 pub mod 暴露,并提供了一种将代码部分放在一起的方法。mod.rs 文件提供了一个接口 - 也就是说,模块提供的内容以及如何与之交互的列表。模块中的其他文件可以做任何他们想做的事情,安全地与代码的其余部分隔离。
因此,我们将在 src 之外创建一个名为 map_builders 的目录。在该目录中,我们将创建一个名为 mod.rs 的空文件。我们正在尝试定义一个接口,所以我们将从一个骨架开始。在 mod.rs 中:
#![allow(unused)] fn main() { use super::Map; trait MapBuilder { fn build(new_depth: i32) -> Map; } }
trait 的使用是新的!trait 就像其他语言中的接口:您是在说任何其他类型都可以实现该 trait,然后可以将其视为该类型的变量。Rust by Example 有一个关于 trait 的精彩章节,The Rust Book 也是如此。我们声明的是,任何东西都可以声明自己是 MapBuilder - 这包括承诺他们将提供一个 build 函数,该函数接受一个 ECS World 对象,并返回一个地图。
打开 map.rs,并添加一个新函数 - 恰如其分地命名为 new:
#![allow(unused)] fn main() { /// 生成一个空地图,完全由实心墙组成 pub fn new(new_depth : i32) -> Map { Map{ tiles : vec![TileType::Wall; MAPCOUNT], rooms : Vec::new(), width : MAPWIDTH as i32, height: MAPHEIGHT as i32, revealed_tiles : vec![false; MAPCOUNT], visible_tiles : vec![false; MAPCOUNT], blocked : vec![false; MAPCOUNT], tile_content : vec![Vec::new(); MAPCOUNT], depth: new_depth, bloodstains: HashSet::new() } } }
我们需要这个用于其他地图生成器,并且 Map 知道如何返回一个新的作为构造函数 - 而不必封装地图布局的所有逻辑,这是有意义的。我们的想法是,任何 Map 基本上都以相同的方式工作,而与我们决定如何填充它无关。
现在我们将创建一个新文件,也在 map_builders 目录中。我们将它命名为 simple_map.rs - 它将是我们放置现有地图生成系统的地方。我们也会在这里放置一个骨架:
#![allow(unused)] fn main() { use super::MapBuilder; use super::Map; use specs::prelude::*; pub struct SimpleMapBuilder {} impl MapBuilder for SimpleMapBuilder { fn build(new_depth: i32) -> Map { Map::new(new_depth) } } }
这只是返回一个不可用的、实心的地图。我们稍后会充实细节 - 让我们先让接口工作起来。
现在,回到 map_builders/mod.rs,我们添加一个公共函数。目前,它只是调用 SimpleMapBuilder 中的构建器:
#![allow(unused)] fn main() { pub fn build_random_map(new_depth: i32) -> Map { SimpleMapBuilder::build(new_depth) } }
最后,我们将告诉 main.rs 实际包含该模块:
#![allow(unused)] fn main() { pub mod map_builders; }
好的,做了相当多的工作,但实际上没有做任何事情 - 但我们获得了一个干净的接口,提供地图创建(通过一个函数),并设置了一个 trait 以要求我们的地图构建器以类似的方式工作。这是一个好的开始。
充实简单地图构建器
现在我们开始将功能从 map.rs 移到我们的 SimpleMapBuilder 中。我们将首先向 map_builders 添加另一个文件 - common.rs。这将保存以前是地图一部分的函数,现在在构建时常用。
该文件看起来像这样:
#![allow(unused)] fn main() { use super::{Map, Rect, TileType}; use std::cmp::{max, min}; pub fn apply_room_to_map(map : &mut Map, room : &Rect) { for y in room.y1 +1 ..= room.y2 { for x in room.x1 + 1 ..= room.x2 { let idx = map.xy_idx(x, y); map.tiles[idx] = TileType::Floor; } } } pub fn apply_horizontal_tunnel(map : &mut Map, x1:i32, x2:i32, y:i32) { for x in min(x1,x2) ..= max(x1,x2) { let idx = map.xy_idx(x, y); if idx > 0 && idx < map.width as usize * map.height as usize { map.tiles[idx as usize] = TileType::Floor; } } } pub fn apply_vertical_tunnel(map : &mut Map, y1:i32, y2:i32, x:i32) { for y in min(y1,y2) ..= max(y1,y2) { let idx = map.xy_idx(x, y); if idx > 0 && idx < map.width as usize * map.height as usize { map.tiles[idx as usize] = TileType::Floor; } } } }
这些与 map.rs 中的函数完全相同,但 map 作为可变引用传递(因此您正在处理原始地图,而不是新地图),并且所有 self 的痕迹都消失了。这些是自由函数 - 也就是说,它们是从任何地方可用的函数,不与类型绑定。pub fn 表示它们在模块内是公共的 - 除非我们向模块本身添加 pub use,否则它们不会传递到主程序。这有助于保持代码的组织性。
现在我们有了这些助手函数,我们可以开始移植地图构建器本身了。在 simple_map.rs 中,我们首先充实 build 函数:
#![allow(unused)] fn main() { impl MapBuilder for SimpleMapBuilder { fn build(new_depth: i32) -> Map { let mut map = Map::new(new_depth); SimpleMapBuilder::rooms_and_corridors(&mut map); map } } }
我们正在调用一个新函数 rooms_and_corridors。让我们构建它:
#![allow(unused)] fn main() { impl SimpleMapBuilder { fn rooms_and_corridors(map : &mut Map) { const MAX_ROOMS : i32 = 30; const MIN_SIZE : i32 = 6; const MAX_SIZE : i32 = 10; let mut rng = RandomNumberGenerator::new(); for i in 0..MAX_ROOMS { let w = rng.range(MIN_SIZE, MAX_SIZE); let h = rng.range(MIN_SIZE, MAX_SIZE); let x = rng.roll_dice(1, map.width - w - 1) - 1; let y = rng.roll_dice(1, map.height - h - 1) - 1; let new_room = Rect::new(x, y, w, h); let mut ok = true; for other_room in map.rooms.iter() { if new_room.intersect(other_room) { ok = false } } if ok { apply_room_to_map(map, &new_room); if !map.rooms.is_empty() { let (new_x, new_y) = new_room.center(); let (prev_x, prev_y) = map.rooms[map.rooms.len()-1].center(); if rng.range(0,2) == 1 { apply_horizontal_tunnel(map, prev_x, new_x, prev_y); apply_vertical_tunnel(map, prev_y, new_y, new_x); } else { apply_vertical_tunnel(map, prev_y, new_y, prev_x); apply_horizontal_tunnel(map, prev_x, new_x, new_y); } } map.rooms.push(new_room); } } let stairs_position = map.rooms[map.rooms.len()-1].center(); let stairs_idx = map.xy_idx(stairs_position.0, stairs_position.1); map.tiles[stairs_idx] = TileType::DownStairs; } } }
您会注意到,这是作为附加到 SimpleMapBuilder 结构的方法构建的。它不是 trait 的一部分,所以我们不能在那里定义它 - 但我们希望将其与其他构建器分开,其他构建器可能有自己的函数。代码本身应该看起来非常熟悉:它与 map.rs 中的生成器相同,但 map 作为一个变量,而不是在函数内部生成。
这只是生成的第一部分,但这是一个好的开始!现在转到 map.rs,并删除整个 new_map_rooms_and_corridors 函数。还要删除我们在 common.rs 中复制的那些。map.rs 文件现在看起来更简洁了,没有任何对地图构建策略的引用!当然,您的编译器/IDE 可能会告诉您,我们破坏了一堆东西。没关系 - 这是“重构”的正常部分 - 更改代码以使其更易于使用的过程。
main.rs 中有三行代码现在被编译器标记。
- 我们可以将
*worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1);替换为*worldmap_resource = map_builders::build_random_map(current_depth + 1);。 *worldmap_resource = Map::new_map_rooms_and_corridors(1);可以变为*worldmap_resource = map_builders::build_random_map(1);。let map : Map = Map::new_map_rooms_and_corridors(1);转换为let map : Map = map_builders::build_random_map(1);。
如果您现在 cargo run,您会注意到:游戏完全相同!很好:我们已成功地将功能从 Map 重构到 map_builders 中。
放置玩家
如果您查看 main.rs,几乎每次我们构建地图时 - 我们都会寻找第一个房间,并用它来放置玩家。我们很可能不想在未来的地图中使用相同的策略,因此我们应该指示在构建地图时玩家要去哪里。让我们扩展 map_builders/mod.rs 中的接口,使其也返回一个位置:
#![allow(unused)] fn main() { trait MapBuilder { fn build(new_depth: i32) -> (Map, Position); } pub fn build_random_map(new_depth: i32) -> (Map, Position) { SimpleMapBuilder::build(new_depth) } }
请注意,我们使用元组一次返回两个值。我们之前已经讨论过这些,但这很好地说明了为什么它们很有用!我们现在需要进入 simple_map 以使 build 函数实际返回正确的数据。simple_map.rs 中 build 的定义现在看起来像这样:
#![allow(unused)] fn main() { fn build(new_depth: i32) -> (Map, Position) { let mut map = Map::new(new_depth); let playerpos = SimpleMapBuilder::rooms_and_corridors(&mut map); (map, playerpos) } }
我们将更新 rooms_and_corridors 的签名:
#![allow(unused)] fn main() { fn rooms_and_corridors(map : &mut Map) -> Position { }
我们将在最后一行添加返回房间 0 的中心:
#![allow(unused)] fn main() { let start_pos = map.rooms[0].center(); Position{ x: start_pos.0, y: start_pos.1 } }
这当然破坏了我们在 main.rs 中更新的代码。我们可以快速处理它!第一个错误可以使用以下代码处理:
#![allow(unused)] fn main() { // 构建新地图并放置玩家 let worldmap; let current_depth; let player_start; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); current_depth = worldmap_resource.depth; let (newmap, start) = map_builders::build_random_map(current_depth + 1); *worldmap_resource = newmap; player_start = start; worldmap = worldmap_resource.clone(); } // 生成坏人 for room in worldmap.rooms.iter().skip(1) { spawner::spawn_room(&mut self.ecs, room, current_depth+1); } // 放置玩家并更新资源 let (player_x, player_y) = (player_start.x, player_start.y); }
请注意,我们如何使用解构从构建器中检索地图和起始位置。然后我们将它们放在适当的位置。由于 Rust 中的赋值是移动操作,因此这非常高效 - 并且编译器可以为我们摆脱临时赋值。
我们在第二个错误(大约在第 369 行)再次执行相同的操作。它几乎是完全相同的代码,因此如果您遇到困难,请随时查看本章的源代码。
最后,最后一个错误可以简单地替换为:
#![allow(unused)] fn main() { let (map, player_start) = map_builders::build_random_map(1); let (player_x, player_y) = (player_start.x, player_start.y); }
好的,让我们 cargo run 那个小家伙!如果一切顺利,那么... 没有任何变化。但是,我们取得了重大进展:我们的地图构建策略现在决定了玩家在关卡中的起点,而不是地图本身。
清理房间生成
在某些地图设计中,我们很可能不会有房间的概念,因此我们也希望将生成移动为地图构建器的函数。我们将在 map_builders/mod.rs 中的接口中添加一个通用生成器:
#![allow(unused)] fn main() { trait MapBuilder { fn build(new_depth: i32) -> (Map, Position); fn spawn(map : &Map, ecs : &mut World, new_depth: i32); } }
足够简单:它需要 ECS(因为我们正在添加实体)和地图。我们还将添加一个公共函数 spawn,以提供一个外部接口来布置怪物:
#![allow(unused)] fn main() { pub fn spawn(map : &mut Map, ecs : &mut World, new_depth: i32) { SimpleMapBuilder::spawn(map, ecs, new_depth); } }
现在我们打开 simple_map.rs 并实际实现 spawn。幸运的是,这非常简单:
#![allow(unused)] fn main() { fn spawn(map : &mut Map, ecs : &mut World) { for room in map.rooms.iter().skip(1) { spawner::spawn_room(ecs, room, 1); } } }
现在,我们可以进入 main.rs 并找到每次我们循环调用 spawn_room 并将其替换为对 map_builders::spawn 的调用。
再一次,cargo run 应该给您与我们在 22 章中看到的相同的游戏!
维护构建器状态
如果您仔细查看我们目前的内容,会发现一个问题:构建器无法知道第二次调用构建器(生成事物)应该使用什么。这是因为我们的函数是无状态的 - 我们实际上并没有创建一个构建器并给它一种记住任何东西的方法。由于我们希望支持各种各样的构建器,因此我们应该纠正这一点。
这引入了一个新的 Rust 概念:动态分发。The Rust Book 如果您熟悉这个概念,那么其中有一个关于此的很好的章节。如果您以前使用过面向对象编程语言,那么您也会遇到过这种情况。基本思想是您有一个“基本对象”,它指定一个接口 - 并且多个对象实现来自该接口的函数。然后,您可以在运行时(程序运行时,而不是编译时)将任何实现该接口的对象放入由接口键入的变量中 - 并且当您调用接口中的方法时,实现会从实际类型运行。这很好,因为您的底层程序不必了解实际的实现 - 只需了解如何与接口对话。这有助于保持程序的整洁。
动态分发确实会带来成本,这就是实体组件系统(以及一般的 Rust)倾向于不将其用于性能关键代码的原因。实际上有两个成本:
- 由于您事先不知道对象的类型,因此必须通过指针分配它。Rust 通过提供
Box系统(稍后会详细介绍)使其变得容易,但有一个成本:代码不是简单地跳转到预定义的内存片段(您的 CPU/内存通常可以提前轻松地弄清楚并确保缓存已准备就绪),而是必须跟随指针 - 然后运行在指针末尾找到的内容。这就是为什么一些 C++ 程序员将->(解引用运算符)称为“缓存未命中运算符”。仅仅通过装箱,您的代码就会稍微变慢。 - 由于多种类型可以实现方法,因此计算机需要知道要运行哪一种。它通过
vtable来实现这一点 - 也就是说,方法实现的“虚拟表”。因此,每次调用都必须检查表,找出要运行的方法,然后从那里运行。那是另一个缓存未命中,并且 CPU 需要更多时间来弄清楚该做什么。
在这种情况下,我们只是在生成地图 - 并且很少调用构建器。这使得速度减慢是可以接受的,因为它真的很小并且不经常运行。如果可以避免,您将不希望在主循环中执行此操作!
所以 - 实现。我们将首先将我们的 trait 更改为 public,并让方法接受 &mut self - 这意味着“此方法是 trait 的成员,并且应该在调用时接收对 self - 附加对象的访问权限”。代码看起来像这样:
#![allow(unused)] fn main() { pub trait MapBuilder { fn build_map(&mut self, new_depth: i32) -> (Map, Position); fn spawn_entities(&mut self, map : &Map, ecs : &mut World, new_depth: i32); } }
请注意,我还花时间使名称更具描述性!现在我们用一个工厂函数替换我们的自由函数调用:它创建一个 MapBuilder 并返回它。在有更多地图实现之前,这个名字有点谎言 - 它声称是随机的,但是当只有一个选择时,不难猜测它会选择哪一个(只需询问苏联选举系统!):
#![allow(unused)] fn main() { pub fn random_builder() -> Box<dyn MapBuilder> { // 请注意,在我们有第二种地图类型之前,这甚至不是稍微随机的 Box::new(SimpleMapBuilder{}) } }
请注意,它不返回 MapBuilder - 而是返回 Box<dyn MapBuilder>!这相当复杂(在 Rust 的早期版本中,dyn 是可选的)。Box 是一种包装在指针中的类型,其大小在编译时可能未知。它与 C++ MapBuilder * 相同 - 它指向 MapBuilder 而不是实际是一个。dyn 是一个标志,表示“这应该使用动态分发”;代码在没有它的情况下也能工作(它将被推断出来),但标记您正在这里做一些复杂/昂贵的事情是一种好的做法。
该函数仅返回 Box::new(SimpleMapBuilder{})。这实际上是两个调用,现在:我们用 Box::new(...) 创建一个 box,并将一个空的 SimpleMapBuilder 放入 box 中。
在 main.rs 中,我们再次必须更改对地图构建器的所有三个调用。我们现在需要使用以下模式:
- 从工厂获取 boxed
MapBuilder对象。 - 将
build_map作为方法调用 - 也就是说,附加到对象的函数。 - 也将
spawn_entities作为方法调用。
来自 goto_next_level 的实现现在读取如下:
#![allow(unused)] fn main() { // 构建新地图并放置玩家 let mut builder = map_builders::random_builder(current_depth + 1); let worldmap; let current_depth; let player_start; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); current_depth = worldmap_resource.depth; let (newmap, start) = builder.build_map(current_depth + 1); *worldmap_resource = newmap; player_start = start; worldmap = worldmap_resource.clone(); } // 生成坏人 builder.spawn_entities(&worldmap, &mut self.ecs, current_depth+1); }
它并没有什么不同,但现在我们保留了构建器对象 - 因此对构建器的后续调用将应用于相同的实现(有时称为“具体对象” - 实际物理存在的对象)。
如果我们添加 5 个以上的地图构建器,main.rs 中的代码将不会在意!我们可以将它们添加到工厂中,程序的其余部分愉快地不知道地图构建器的工作原理。这是一个很好的例子,说明动态分发如何有用:您有一个明确定义的接口,程序的其余部分不需要理解内部工作原理。
向 SimpleMapBuilder 添加构造函数
我们目前正在将 SimpleMapBuilder 作为空对象创建。如果它需要跟踪一些数据怎么办?以防我们需要它,让我们向其添加一个简单的构造函数,并使用它来代替空白对象。在 simple_map.rs 中,修改 struct 实现如下:
#![allow(unused)] fn main() { impl SimpleMapBuilder { pub fn new(new_depth : i32) -> SimpleMapBuilder { SimpleMapBuilder{} } ... }
现在这只是返回一个空对象。在 mod.rs 中,更改 random_map_builder 函数以使用它:
#![allow(unused)] fn main() { pub fn random_builder(new_depth : i32) -> Box<dyn MapBuilder> { // 请注意,在我们有第二种地图类型之前,这甚至不是稍微随机的 Box::new(SimpleMapBuilder::new(new_depth)) } }
这并没有给我们带来任何好处,但更简洁一些 - 当您编写更多地图时,它们可能会在其构造函数中做一些事情!
清理 trait - 简单、明显的步骤和单一返回类型
既然我们已经走了这么远,让我们扩展 trait 以在一个函数中获取玩家的位置,在另一个函数中获取地图,并分别构建/生成。使用小函数往往使代码更易于阅读,这本身就是一个有价值的目标。在 mod.rs 中,我们按如下方式更改接口:
#![allow(unused)] fn main() { pub trait MapBuilder { fn build_map(&mut self); fn spawn_entities(&mut self, ecs : &mut World); fn get_map(&mut self) -> Map; fn get_starting_position(&mut self) -> Position; } }
这里有几件事需要注意:
build_map不再返回任何内容。我们将其用作构建地图状态的函数。spawn_entities不再要求 Map 参数。由于所有地图构建器必须实现地图才能有意义,因此我们将假定地图构建器有一个地图。get_map返回一个地图。同样,我们假设构建器实现保留一个。get_starting_position也假设构建器会保留一个。
显然,我们的 SimpleMapBuilder 现在需要修改为以这种方式工作。我们将首先修改 struct 以包含所需的变量。这是地图构建器的状态 - 并且由于我们正在进行动态面向对象的代码,因此状态仍然附加到对象。这是来自 simple_map.rs 的代码:
#![allow(unused)] fn main() { pub struct SimpleMapBuilder { map : Map, starting_position : Position, depth: i32 } }
接下来,我们将实现 getter 函数。这些非常简单:它们只是返回结构状态中的变量:
#![allow(unused)] fn main() { impl MapBuilder for SimpleMapBuilder { fn get_map(&self) -> Map { self.map.clone() } fn get_starting_position(&self) -> Position { self.starting_position.clone() } ... }
我们还将更新构造函数以创建状态:
#![allow(unused)] fn main() { pub fn new(new_depth : i32) -> SimpleMapBuilder { SimpleMapBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth } } }
这也简化了 build_map 和 spawn_entities:
#![allow(unused)] fn main() { fn build_map(&mut self) { SimpleMapBuilder::rooms_and_corridors(); } fn spawn_entities(&mut self, ecs : &mut World) { for room in self.map.rooms.iter().skip(1) { spawner::spawn_room(ecs, room, self.depth); } } }
最后,我们需要修改 rooms_and_corridors 以使用此接口:
#![allow(unused)] fn main() { fn rooms_and_corridors(&mut self) { const MAX_ROOMS : i32 = 30; const MIN_SIZE : i32 = 6; const MAX_SIZE : i32 = 10; let mut rng = RandomNumberGenerator::new(); for i in 0..MAX_ROOMS { let w = rng.range(MIN_SIZE, MAX_SIZE); let h = rng.range(MIN_SIZE, MAX_SIZE); let x = rng.roll_dice(1, self.map.width - w - 1) - 1; let y = rng.roll_dice(1, self.map.height - h - 1) - 1; let new_room = Rect::new(x, y, w, h); let mut ok = true; for other_room in self.map.rooms.iter() { if new_room.intersect(other_room) { ok = false } } if ok { apply_room_to_map(&mut self.map, &new_room); if !self.map.rooms.is_empty() { let (new_x, new_y) = new_room.center(); let (prev_x, prev_y) = self.map.rooms[self.map.rooms.len()-1].center(); if rng.range(0,2) == 1 { apply_horizontal_tunnel(&mut self.map, prev_x, new_x, prev_y); apply_vertical_tunnel(&mut self.map, prev_y, new_y, new_x); } else { apply_vertical_tunnel(&mut self.map, prev_y, new_y, prev_x); apply_horizontal_tunnel(&mut self.map, prev_x, new_x, new_y); } } self.map.rooms.push(new_room); } } let stairs_position = self.map.rooms[self.map.rooms.len()-1].center(); let stairs_idx = self.map.xy_idx(stairs_position.0, stairs_position.1); self.map.tiles[stairs_idx] = TileType::DownStairs; let start_pos = self.map.rooms[0].center(); self.starting_position = Position{ x: start_pos.0, y: start_pos.1 }; } }
这与我们之前的非常相似,但现在使用 self.map 来引用其自己的地图副本,并将玩家位置存储在 self.starting_position 中。
对 main.rs 中新代码的调用再次更改。来自 goto_next_level 的调用现在看起来像这样:
#![allow(unused)] fn main() { let mut builder; let worldmap; let current_depth; let player_start; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); current_depth = worldmap_resource.depth; builder = map_builders::random_builder(current_depth + 1); builder.build_map(); *worldmap_resource = builder.get_map(); player_start = builder.get_starting_position(); worldmap = worldmap_resource.clone(); } // 生成坏人 builder.spawn_entities(&mut self.ecs); }
我们基本上为其他调用重复这些更改(请参阅源代码)。我们现在有一个非常舒适的地图构建器接口:它暴露了足够多的内容使其易于使用,而没有暴露其用于实际构建地图的魔法的细节!
如果您现在 cargo run 该项目:再一次,没有任何可见的变化 - 它仍然像以前一样工作。当您进行重构时,这是一件好事!
那么为什么地图仍然有房间?
房间实际上在游戏中并没有做太多事情:它们是我们构建地图的方式的产物。以后的地图构建器很可能实际上并不关心房间,至少不是在“这是一个矩形,我们称之为房间”的意义上。让我们尝试将这种抽象移出地图,也移出生成器。
第一步,在 map.rs 中,我们完全删除 rooms 结构:
#![allow(unused)] fn main() { #[derive(Default, Serialize, Deserialize, Clone)] pub struct Map { pub tiles : Vec<TileType>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool>, pub blocked : Vec<bool>, pub depth : i32, pub bloodstains : HashSet<usize>, #[serde(skip_serializing)] #[serde(skip_deserializing)] pub tile_content : Vec<Vec<Entity>> } }
我们也从 new 函数中删除它。看看您的 IDE,您会注意到您只破坏了 simple_map.rs 中的代码!我们没有在其他任何地方使用 rooms - 这很明显地表明它们不属于我们在整个主程序中传递的地图中。
我们可以通过将 rooms 放入构建器而不是地图来修复 simple_map。我们将其放入结构中:
#![allow(unused)] fn main() { pub struct SimpleMapBuilder { map : Map, starting_position : Position, depth: i32, rooms: Vec<Rect> } }
这需要我们修复构造函数:
#![allow(unused)] fn main() { pub fn new(new_depth : i32) -> SimpleMapBuilder { SimpleMapBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, rooms: Vec::new() } } }
生成函数变为:
#![allow(unused)] fn main() { fn spawn_entities(&mut self, ecs : &mut World) { for room in self.rooms.iter().skip(1) { spawner::spawn_room(ecs, room, self.depth); } } }
我们在 rooms_and_corridors 中将每个 map.rooms 实例替换为 self.rooms:
#![allow(unused)] fn main() { fn rooms_and_corridors(&mut self) { const MAX_ROOMS : i32 = 30; const MIN_SIZE : i32 = 6; const MAX_SIZE : i32 = 10; let mut rng = RandomNumberGenerator::new(); for i in 0..MAX_ROOMS { let w = rng.range(MIN_SIZE, MAX_SIZE); let h = rng.range(MIN_SIZE, MAX_SIZE); let x = rng.roll_dice(1, self.map.width - w - 1) - 1; let y = rng.roll_dice(1, self.map.height - h - 1) - 1; let new_room = Rect::new(x, y, w, h); let mut ok = true; for other_room in self.rooms.iter() { if new_room.intersect(other_room) { ok = false } } if ok { apply_room_to_map(&mut self.map, &new_room); if !self.rooms.is_empty() { let (new_x, new_y) = new_room.center(); let (prev_x, prev_y) = self.rooms[self.rooms.len()-1].center(); if rng.range(0,2) == 1 { apply_horizontal_tunnel(&mut self.map, prev_x, new_x, prev_y); apply_vertical_tunnel(&mut self.map, prev_y, new_y, new_x); } else { apply_vertical_tunnel(&mut self.map, prev_y, new_y, prev_x); apply_horizontal_tunnel(&mut self.map, prev_x, new_x, new_y); } } self.rooms.push(new_room); } } let stairs_position = self.rooms[self.rooms.len()-1].center(); let stairs_idx = self.map.xy_idx(stairs_position.0, stairs_position.1); self.map.tiles[stairs_idx] = TileType::DownStairs; let start_pos = self.rooms[0].center(); self.starting_position = Position{ x: start_pos.0, y: start_pos.1 }; } }
再一次,cargo run 该项目:应该没有任何变化。
总结
这是一个有趣的章节,因为目标是以与之前完全相同的代码结束 - 但地图构建器已清理到其自己的模块中,与代码的其余部分完全隔离。这为我们提供了一个很好的起点,可以开始构建新的地图构建器,而无需更改游戏本身。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)。没有太多意义,因为重构旨在不更改可见结果!
版权所有 (C) 2019, Herbert Wolverson。
地图构建测试工具
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。我希望您会喜欢本教程,并制作出伟大的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持 我的 Patreon。
当我们深入研究生成新的和有趣的地图时,提供一种查看算法正在做什么的方法将很有帮助。本章将构建一个测试工具来实现此目的,并扩展上一章中的 SimpleMapBuilder 以支持它。这将是一项相对较大的任务,我们将在过程中学习一些新技术!
清理地图创建 - 不要重复自己
在 main.rs 中,我们基本上有三次相同的代码。当程序启动时,我们将一个地图插入到世界中。当我们更改关卡或完成游戏时 - 我们也做同样的事情。最后两个具有不同的语义(因为我们正在更新世界而不是第一次插入) - 但它基本上是冗余的重复。
我们将首先更改第一个,以插入占位符值,而不是我们打算使用的实际值。这样,World 就有了数据的槽位 - 只是目前还不是很有用。这是一个带有注释掉的旧代码的版本:
#![allow(unused)] fn main() { gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new()); gs.ecs.insert(Map::new(1)); gs.ecs.insert(Point::new(0, 0)); gs.ecs.insert(rltk::RandomNumberGenerator::new()); /*let mut builder = map_builders::random_builder(1); builder.build_map(); let player_start = builder.get_starting_position(); let map = builder.get_map(); let (player_x, player_y) = (player_start.x, player_start.y); builder.spawn_entities(&mut gs.ecs); gs.ecs.insert(map); gs.ecs.insert(Point::new(player_x, player_y));*/ // 注释掉的旧代码 let player_entity = spawner::player(&mut gs.ecs, 0, 0); gs.ecs.insert(player_entity); }
因此,我们没有构建地图,而是将一个占位符放入 World 资源中。这显然对于实际开始游戏不是很有用,所以我们还需要一个函数来执行实际的构建并更新资源。并非完全巧合,该函数与我们当前更新地图的其他两个地方相同!换句话说,我们也可以将它们合并到这个函数中。因此,在 State 的实现中,我们添加:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32) { let mut builder = map_builders::random_builder(new_depth); builder.build_map(); let player_start; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); *worldmap_resource = builder.get_map(); player_start = builder.get_starting_position(); } // 生成坏人 builder.spawn_entities(&mut self.ecs); // 放置玩家并更新资源 let (player_x, player_y) = (player_start.x, player_start.y); let mut player_position = self.ecs.write_resource::<Point>(); *player_position = Point::new(player_x, player_y); let mut position_components = self.ecs.write_storage::<Position>(); let player_entity = self.ecs.fetch::<Entity>(); let player_pos_comp = position_components.get_mut(*player_entity); if let Some(player_pos_comp) = player_pos_comp { player_pos_comp.x = player_x; player_pos_comp.y = player_y; } // 标记玩家的视野为脏 let mut viewshed_components = self.ecs.write_storage::<Viewshed>(); let vs = viewshed_components.get_mut(*player_entity); if let Some(vs) = vs { vs.dirty = true; } } }
现在我们可以删除注释掉的代码,并大大简化我们的第一次调用:
#![allow(unused)] fn main() { gs.ecs.insert(Map::new(1)); gs.ecs.insert(Point::new(0, 0)); gs.ecs.insert(rltk::RandomNumberGenerator::new()); let player_entity = spawner::player(&mut gs.ecs, 0, 0); gs.ecs.insert(player_entity); gs.ecs.insert(RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame }); gs.ecs.insert(gamelog::GameLog{ entries : vec!["Welcome to Rusty Roguelike".to_string()] }); gs.ecs.insert(particle_system::ParticleBuilder::new()); gs.ecs.insert(rex_assets::RexAssets::new()); gs.generate_world_map(1); }
我们还可以转到代码中调用我们刚刚添加到 generate_world_map 的相同代码的各个部分,并通过使用新函数来大大简化它们。我们可以用以下代码替换 goto_next_level:
#![allow(unused)] fn main() { fn goto_next_level(&mut self) { // 删除不是玩家或其装备的实体 let to_delete = self.entities_to_remove_on_level_change(); for target in to_delete { self.ecs.delete_entity(target).expect("Unable to delete entity"); } // 构建新地图并放置玩家 let current_depth; { let worldmap_resource = self.ecs.fetch::<Map>(); current_depth = worldmap_resource.depth; } self.generate_world_map(current_depth + 1); // 通知玩家并给予他们一些生命值 let player_entity = self.ecs.fetch::<Entity>(); let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>(); gamelog.entries.push("You descend to the next level, and take a moment to heal.".to_string()); let mut player_health_store = self.ecs.write_storage::<CombatStats>(); let player_health = player_health_store.get_mut(*player_entity); if let Some(player_health) = player_health { player_health.hp = i32::max(player_health.hp, player_health.max_hp / 2); } } }
同样,我们可以清理 game_over_cleanup:
#![allow(unused)] fn main() { fn game_over_cleanup(&mut self) { // 删除所有内容 let mut to_delete = Vec::new(); for e in self.ecs.entities().join() { to_delete.push(e); } for del in to_delete.iter() { self.ecs.delete_entity(*del).expect("Deletion failed"); } // 生成一个新的玩家 { let player_entity = spawner::player(&mut self.ecs, 0, 0); let mut player_entity_writer = self.ecs.write_resource::<Entity>(); *player_entity_writer = player_entity; } // 构建新地图并放置玩家 self.generate_world_map(1); } }
这样就可以了 - cargo run 给出了我们已经玩了一段时间的相同游戏,并且我们删减了很多代码。使事情更小的重构真是太棒了!
制作生成器
有时,组合两种范例出奇地困难:
- RLTK(以及底层 GUI 环境)的图形“tick”特性鼓励您快速地一蹴而就地完成所有操作。
- 在生成地图时实际可视化进度鼓励您以许多阶段作为“状态机”运行,并在过程中产生地图结果。
我的第一个想法是使用协程,特别是 Generators。它们确实非常适合这种类型的事情:您可以编写在函数中同步(按顺序)运行的代码,并在计算继续时“yield”值。我甚至深入到获得了可工作的实现 - 但它需要 nightly 支持(不稳定的,未完成的 Rust)并且与 web assembly 不能很好地配合。所以我放弃了它。这里有一个教训:有时工具并不完全为你真正想要的东西做好准备!
相反,我决定采用更传统的方式。地图可以在生成时拍摄“快照”,并且可以在可视化工具中逐帧播放大量的快照。这不如协程那么好,但它有效且稳定。这些是理想的特性!
首先,我们应该确保可视化地图生成是完全可选的。当您将游戏发布给玩家时,您可能不希望在他们开始游戏时向他们展示整个地图 - 但当您处理地图算法时,这非常有价值。所以在 main.rs 的顶部,我们添加一个常量:
#![allow(unused)] fn main() { const SHOW_MAPGEN_VISUALIZER : bool = true; }
常量就是这样:一旦程序启动就无法更改的变量。Rust 使只读常量非常容易,并且编译器通常会完全优化掉它们,因为值是预先知道的。在这种情况下,我们声明一个名为 SHOW_MAPGEN_VISUALIZER 的 bool 值为 true。我们的想法是,当我们不想显示地图生成进度时,可以将其设置为 false。
有了这个前提,是时候为我们的地图构建器接口添加快照支持了。在 map_builders/mod.rs 中,我们稍微扩展了接口:
#![allow(unused)] fn main() { pub trait MapBuilder { fn build_map(&mut self); fn spawn_entities(&mut self, ecs : &mut World); fn get_map(&self) -> Map; fn get_starting_position(&self) -> Position; fn get_snapshot_history(&self) -> Vec<Map>; fn take_snapshot(&mut self); } }
请注意新条目:get_snapshot_history 和 take_snapshot。前者将用于向生成器请求其地图帧的历史记录;后者告诉生成器支持拍摄快照(并由他们决定如何操作)。
现在是提及 Rust 和 C++(以及其他提供面向对象编程支持的语言)之间一个主要区别的好时机。Rust 特征不支持向特征签名添加变量。因此,即使 history : Vec<Map> 恰好是您在所有实现中用于存储快照的内容,您也不能将其包含在特征中。我真的不知道为什么会这样,但这可以解决 - 只是与 OOP 规范略有不同。
在 simple_map.rs 内部,我们需要为我们的 SimpleMapBuilder 实现这些方法。我们首先向我们的 struct 添加支持变量:
#![allow(unused)] fn main() { pub struct SimpleMapBuilder { map : Map, starting_position : Position, depth: i32, rooms: Vec<Rect>, history: Vec<Map> } }
请注意,我们已将 history: Vec<Map> 添加到结构中。它的意思正如其字面意思:Map 结构的向量(可调整大小的数组)。我们的想法是,我们将为地图生成的每个“帧”不断地将地图的副本添加到其中。
接下来是特征实现:
#![allow(unused)] fn main() { fn get_snapshot_history(&self) -> Vec<Map> { self.history.clone() } }
这非常简单:我们将历史向量的副本返回给调用者。我们还需要:
#![allow(unused)] fn main() { fn take_snapshot(&mut self) { if SHOW_MAPGEN_VISUALIZER { let mut snapshot = self.map.clone(); for v in snapshot.revealed_tiles.iter_mut() { *v = true; } self.history.push(snapshot); } } }
我们首先检查是否正在使用快照功能(如果没有使用,则没有必要浪费内存!)。如果正在使用,我们获取当前地图的副本,迭代每个 revealed_tiles 单元格并将其设置为 true(以便地图渲染将显示所有内容,包括无法访问的墙壁),并将其添加到历史记录列表中。
我们现在可以在地图生成的任何时候调用 self.take_snapshot(),它会被添加为地图生成器的帧。在 simple_map.rs 中,我们在添加房间或走廊后添加几个调用:
#![allow(unused)] fn main() { ... if ok { apply_room_to_map(&mut self.map, &new_room); self.take_snapshot(); if !self.rooms.is_empty() { let (new_x, new_y) = new_room.center(); let (prev_x, prev_y) = self.rooms[self.rooms.len()-1].center(); if rng.range(0,2) == 1 { apply_horizontal_tunnel(&mut self.map, prev_x, new_x, prev_y); apply_vertical_tunnel(&mut self.map, prev_y, new_y, new_x); } else { apply_vertical_tunnel(&mut self.map, prev_y, new_y, prev_x); apply_horizontal_tunnel(&mut self.map, prev_x, new_x, new_y); } } self.rooms.push(new_room); self.take_snapshot(); } ... }
渲染可视化工具
可视化地图开发是另一种游戏状态,所以我们将其添加到 main.rs 中的 RunState 枚举中:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, ShowRemoveItem, GameOver, MagicMapReveal { row : i32 }, MapGeneration } }
可视化实际上需要几个变量,但我遇到了一个问题:其中一个变量真的应该是我们在可视化之后要转换到的下一个状态。我们可能正在从三个来源之一(新游戏、游戏结束、下一关)构建新地图 - 并且它们在生成后具有不同的状态。不幸的是,你不能将第二个 RunState 放入第一个 RunState 中 - Rust 会给你循环错误,并且它不会编译。您可以使用 Box<RunState> - 但这不适用于从 Copy 派生的 RunState!我为此奋斗了一段时间,并决定添加到 State 中:
#![allow(unused)] fn main() { pub struct State { pub ecs: World, mapgen_next_state : Option<RunState>, mapgen_history : Vec<Map>, mapgen_index : usize, mapgen_timer : f32 } }
我们添加了:
mapgen_next_state- 游戏接下来应该去哪里。mapgen_history- 要播放的地图历史帧的副本。mapgen_index- 我们在回放过程中历史记录的进度。mapgen_timer- 用于回放期间的帧定时。
由于我们修改了 State,我们还必须修改 State 对象的创建:
#![allow(unused)] fn main() { let mut gs = State { ecs: World::new(), mapgen_next_state : Some(RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame }), mapgen_index : 0, mapgen_history: Vec::new(), mapgen_timer: 0.0 }; }
我们使下一个状态与我们一直在使用的起始状态相同:因此游戏将渲染地图创建,然后转到菜单。我们可以将我们的初始状态更改为 MapGeneration:
#![allow(unused)] fn main() { gs.ecs.insert(RunState::MapGeneration{} ); }
现在我们需要实现渲染器。在我们的 tick 函数中,我们添加以下状态:
#![allow(unused)] fn main() { match newrunstate { RunState::MapGeneration => { if !SHOW_MAPGEN_VISUALIZER { newrunstate = self.mapgen_next_state.unwrap(); } ctx.cls(); draw_map(&self.mapgen_history[self.mapgen_index], ctx); self.mapgen_timer += ctx.frame_time_ms; if self.mapgen_timer > 300.0 { self.mapgen_timer = 0.0; self.mapgen_index += 1; if self.mapgen_index >= self.mapgen_history.len() { newrunstate = self.mapgen_next_state.unwrap(); } } } ... }
这相对简单:
- 如果未启用可视化工具,则立即转换到下一个状态。
- 清空屏幕。
- 调用
draw_map,使用我们状态中的地图历史记录 - 在当前帧。 - 将帧持续时间添加到
mapgen_timer,如果它大于 300ms:- 将计时器设置回 0。
- 增加帧计数器。
- 如果帧计数器已到达历史记录的末尾,则转换到下一个游戏状态。
眼尖的读者会注意到这里有一个细微的变化。draw_map 以前不接受 map - 它会从 ECS 中获取!在 map.rs 中,draw_map 的开头更改为:
#![allow(unused)] fn main() { pub fn draw_map(map : &Map, ctx : &mut Rltk) { }
我们在 tick 中的常规 draw_map 调用也更改为:
#![allow(unused)] fn main() { draw_map(&self.ecs.fetch::<Map>(), ctx); }
这是一个微小的更改,它允许我们渲染我们需要的任何 Map 结构!
最后,我们需要实际为可视化工具提供一些数据来渲染。我们调整 generate_world_map 以重置各种 mapgen_ 变量,清除历史记录,并在运行后检索快照历史记录:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32) { self.mapgen_index = 0; self.mapgen_timer = 0.0; self.mapgen_history.clear(); let mut builder = map_builders::random_builder(new_depth); builder.build_map(); self.mapgen_history = builder.get_snapshot_history(); let player_start; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); *worldmap_resource = builder.get_map(); player_start = builder.get_starting_position(); } }
如果您现在 cargo run 该项目,您可以在开始游戏之前观看简单的地图生成器构建您的关卡。

总结
这完成了构建测试工具 - 您可以观看地图生成,这应该使生成地图(接下来几章的主题)更加直观。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
BSP 房间地牢
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您喜欢本教程,并制作出伟大的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持 我的 Patreon。
一种流行的地图生成方法是使用“二叉空间分割”(binary space partition, BSP)将您的地图细分为大小不一的矩形,然后将生成的房间连接成走廊。 你可以用这种方法走很远的路:Nethack 广泛使用它,Dungeon Crawl: Stone Soup 有时使用它,而我的项目 - One Knight in the Dungeon - 在下水道关卡中使用它。 本章将使用上一章的可视化工具来引导您使用这项技术。
实现一个新的地图 - 细分 BSP,样板代码
我们将从在 map_builders 中创建一个新文件开始 - bsp_dungeon.rs。 我们首先创建基本的 BspDungeonBuilder 结构体:
#![allow(unused)] fn main() { pub struct BspDungeonBuilder { map : Map, starting_position : Position, depth: i32, rooms: Vec<Rect>, history: Vec<Map>, rects: Vec<Rect> } }
这基本上与 SimpleMapBuilder 中的结构体相同 - 并且我们保留了 rooms 向量,因为此方法也使用了房间的概念。 我们添加了一个 rects 向量:该算法大量使用它,因此在整个实现过程中使其可用很有帮助。 我们很快就会明白为什么需要它。
现在我们为该类型实现 MapBuilder 特征:
#![allow(unused)] fn main() { impl MapBuilder for BspDungeonBuilder { fn get_map(&self) -> Map { self.map.clone() } fn get_starting_position(&self) -> Position { self.starting_position.clone() } fn get_snapshot_history(&self) -> Vec<Map> { self.history.clone() } fn build_map(&mut self) { // 我们应该在这里做一些事情 } fn spawn_entities(&mut self, ecs : &mut World) { for room in self.rooms.iter().skip(1) { spawner::spawn_room(ecs, room, self.depth); } } fn take_snapshot(&mut self) { if SHOW_MAPGEN_VISUALIZER { let mut snapshot = self.map.clone(); for v in snapshot.revealed_tiles.iter_mut() { *v = true; } self.history.push(snapshot); } } } }
这也几乎与 SimpleMapBuilder 相同,但 build_map 有一条注释提醒我们编写一些代码。 如果您现在运行生成器,您将得到一个实心的墙块 - 并且没有任何内容。
我们还需要为 BspMapBuilder 实现一个 构造器。 同样,它基本上与 SimpleMapBuilder 相同:
#![allow(unused)] fn main() { impl BspDungeonBuilder { pub fn new(new_depth : i32) -> BspDungeonBuilder { BspDungeonBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, rooms: Vec::new(), history: Vec::new(), rects: Vec::new() } } } }
最后,我们将打开 map_builders/mod.rs 并更改 random_builder 函数,使其始终返回我们的新地图类型:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { // 请注意,在我们有第二种地图类型之前,这甚至不是轻微的随机 Box::new(BspDungeonBuilder::new(new_depth)) } }
再次说明,这根本不是随机的 - 但开发一个始终运行的功能要容易得多,而不是不断尝试直到它选择了我们想要调试的功能!
构建地图创建器
我们稍后会担心更换地图类型。 现在开始制作地图! 请注意,此实现是从我的 C++ 游戏 One Knight in the Dungeon 移植过来的。 我们将从房间生成开始。 在我们的 impl BspMapBuilder 中,我们添加一个新函数:
#![allow(unused)] fn main() { fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); self.rects.clear(); self.rects.push( Rect::new(2, 2, self.map.width-5, self.map.height-5) ); // 从一个单一的地图大小的矩形开始 let first_room = self.rects[0]; self.add_subrects(first_room); // 划分第一个房间 // 最多 240 次,我们获取一个随机矩形并划分它。 如果有可能在那里挤出一个 // 房间,我们放置它并将其添加到房间列表。 let mut n_rooms = 0; while n_rooms < 240 { let rect = self.get_random_rect(&mut rng); let candidate = self.get_random_sub_rect(rect, &mut rng); if self.is_possible(candidate) { apply_room_to_map(&mut self.map, &candidate); self.rooms.push(candidate); self.add_subrects(rect); self.take_snapshot(); } n_rooms += 1; } let start = self.rooms[0].center(); self.starting_position = Position{ x: start.0, y: start.1 }; } }
那么这到底做了什么?
- 我们清除了作为构建器一部分创建的
rects结构。 这将用于存储从整个地图派生的矩形。 - 我们创建了“第一个房间” - 这实际上是整个地图。 我们修剪了一点,为地图的侧面添加了一些填充。
- 我们调用
add_subrects,将矩形列表和第一个房间传递给它。 我们将在稍后实现它,但它的作用是:它将矩形划分为四个象限,并将每个象限添加到矩形列表。 - 现在我们设置一个房间计数器,这样我们就不会无限循环。
- 当该计数器小于 240 时(一个相对任意的限制,可以产生有趣的结果):
- 我们调用
get_random_rect以从矩形列表中检索一个随机矩形。 - 我们使用此矩形作为外部边界调用
get_random_sub_rect。 它在父矩形内的某个位置创建一个大小为 3 到 10 个图块(在每个轴上)的随机房间。 - 我们询问
is_possible候选房间是否可以绘制到地图上; 每个图块都必须在地图边界内,并且还不是房间。 如果 是 可能的:- 我们在地图上标记它。
- 我们将其添加到房间列表。
- 我们调用
add_subrects来细分我们刚刚使用的矩形(不是候选房间!)。
- 我们调用
这里有很多支持函数在起作用,所以让我们逐一介绍它们。
#![allow(unused)] fn main() { fn add_subrects(&mut self, rect : Rect) { let width = i32::abs(rect.x1 - rect.x2); let height = i32::abs(rect.y1 - rect.y2); let half_width = i32::max(width / 2, 1); let half_height = i32::max(height / 2, 1); self.rects.push(Rect::new( rect.x1, rect.y1, half_width, half_height )); self.rects.push(Rect::new( rect.x1, rect.y1 + half_height, half_width, half_height )); self.rects.push(Rect::new( rect.x1 + half_width, rect.y1, half_width, half_height )); self.rects.push(Rect::new( rect.x1 + half_width, rect.y1 + half_height, half_width, half_height )); } }
函数 add_subrects 是 BSP(二叉空间分割)方法的关键:它接受一个矩形,并将宽度和高度平分。 然后它创建四个新的矩形,每个象限对应一个。 这些被添加到 rects 列表中。 图形化表示:
############### ###############
# # # 1 + 2 #
# # # + #
# 0 # -> #+++++++++++++#
# # # 3 + 4 #
# # # + #
############### ###############
接下来是 get_random_rect:
#![allow(unused)] fn main() { fn get_random_rect(&mut self, rng : &mut RandomNumberGenerator) -> Rect { if self.rects.len() == 1 { return self.rects[0]; } let idx = (rng.roll_dice(1, self.rects.len() as i32)-1) as usize; self.rects[idx] } }
这是一个简单的函数。 如果 rects 列表中只有一个矩形,它将返回第一个矩形。 否则,它会掷一个骰子 1d(rects 列表的大小),并返回在随机索引处找到的矩形。
接下来是 get_random_sub_rect:
#![allow(unused)] fn main() { fn get_random_sub_rect(&self, rect : Rect, rng : &mut RandomNumberGenerator) -> Rect { let mut result = rect; let rect_width = i32::abs(rect.x1 - rect.x2); let rect_height = i32::abs(rect.y1 - rect.y2); let w = i32::max(3, rng.roll_dice(1, i32::min(rect_width, 10))-1) + 1; let h = i32::max(3, rng.roll_dice(1, i32::min(rect_height, 10))-1) + 1; result.x1 += rng.roll_dice(1, 6)-1; result.y1 += rng.roll_dice(1, 6)-1; result.x2 = result.x1 + w; result.y2 = result.y1 + h; result } }
因此,它以一个矩形作为参数,并制作一个可变副本作为结果使用。 它计算矩形的宽度和高度,然后在该矩形内生成一个随机宽度和高度 - 但每个维度都不小于 3 个图块且不大于 10 个图块。 您可以调整这些数字以更改您想要的房间大小。 然后它稍微移动矩形,以提供一些随机放置(否则,它将始终靠在子矩形的侧面)。 最后,它返回结果。 图形化表示:
############### ########
# # # 1 #
# # # #
# 0 # -> ########
# #
# #
###############
最后,是 is_possible 函数:
#![allow(unused)] fn main() { fn is_possible(&self, rect : Rect) -> bool { let mut expanded = rect; expanded.x1 -= 2; expanded.x2 += 2; expanded.y1 -= 2; expanded.y2 += 2; let mut can_build = true; for y in expanded.y1 ..= expanded.y2 { for x in expanded.x1 ..= expanded.x2 { if x > self.map.width-2 { can_build = false; } if y > self.map.height-2 { can_build = false; } if x < 1 { can_build = false; } if y < 1 { can_build = false; } if can_build { let idx = self.map.xy_idx(x, y); if self.map.tiles[idx] != TileType::Wall { can_build = false; } } } } can_build } }
这稍微复杂一些,但当您分解它时,它就变得有意义了:
- 以一个矩形作为目标,表示我们正在查看的房间。
- 创建一个名为
expanded的矩形的可变副本。 然后我们在每个方向上将矩形向外扩展 2 个图块,以防止房间重叠。 - 我们迭代矩形中的每个
x和y坐标:- 如果
x或y超出地图边界,我们将can_build标记为false- 这将不起作用。 - 如果我们仍然可以构建它,我们会查看现有地图 - 如果它不是实心墙,那么我们已经与现有房间重叠,并标记为我们无法构建。
- 如果
- 我们返回
can_build的结果。
因此,现在我们已经实现了所有这些,整体算法就更加明显了:
- 我们从覆盖整个地图的单个矩形开始。
- 我们对其进行细分,因此现在我们的地图有 5 个矩形 - 每个象限一个,整个地图一个。
- 我们使用一个计数器来确保我们不会永远循环(我们将拒绝很多房间)。 当我们仍然可以添加房间时,我们:
- 从矩形列表中获取一个随机矩形。 最初,这将是象限之一 - 或整个地图。 当我们添加细分时,此列表将继续增长。
- 我们在这个矩形内生成一个随机子矩形。
- 我们查看这是否是一个可能的房间。 如果是,我们:
- 将房间应用到地图(构建它)。
- 将其添加到房间列表。
- 将新的矩形细分为象限,并将这些象限添加到我们的矩形列表。
- 存储一个快照以用于可视化工具。
这往往会给出很好的房间分布,并且保证它们不会重叠。 非常像 Nethack!
如果您现在 cargo run,您将身处一个没有出口的房间。 您将可以在可视化工具中观看房间在地图周围出现。 这是一个好的开始。

添加走廊
现在,我们按左侧坐标对房间进行排序。 您不必这样做,但这有助于使连接的房间对齐。
#![allow(unused)] fn main() { self.rooms.sort_by(|a,b| a.x1.cmp(&b.x1) ); }
sort_by 接受一个闭包 - 也就是一个内联函数(在其他语言中称为“lambda”或“匿名函数”)作为参数。 如果您愿意,您可以指定另一个完整的函数,或者在 Rect 上实现特征使其可排序 - 但这已经足够容易了。 它通过比较每个矩形的 x1 值进行排序。
现在我们将添加一些走廊:
#![allow(unused)] fn main() { // 现在我们需要走廊 for i in 0..self.rooms.len()-1 { let room = self.rooms[i]; let next_room = self.rooms[i+1]; let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1); let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1); let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1); let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1); self.draw_corridor(start_x, start_y, end_x, end_y); self.take_snapshot(); } }
这将迭代房间列表,忽略最后一个房间。 它获取当前房间和列表中的下一个房间,并计算每个房间内的随机位置(start_x/start_y 和 end_x/end_y)。 然后,它使用这些坐标调用神秘的 draw_corridor 函数。 draw_corridor 添加一条从起点到终点的线,仅使用南北或东西方向(它可以给出 90 度弯曲)。 它不会像 Bresenham 算法那样给您一条交错的、难以导航的完美直线。 我们还会拍摄快照。
draw_corridor 函数非常简单:
#![allow(unused)] fn main() { fn draw_corridor(&mut self, x1:i32, y1:i32, x2:i32, y2:i32) { let mut x = x1; let mut y = y1; while x != x2 || y != y2 { if x < x2 { x += 1; } else if x > x2 { x -= 1; } else if y < y2 { y += 1; } else if y > y2 { y -= 1; } let idx = self.map.xy_idx(x, y); self.map.tiles[idx] = TileType::Floor; } } }
它接受一个起点和终点,并创建等于起始位置的可变 x 和 y 变量。 然后它一直循环,直到 x 和 y 与直线的终点匹配。 对于每次迭代,如果 x 小于结束 x - 它向左移动。 如果 x 大于结束 x - 它向右移动。 y 也是如此,但方向是向上和向下。 这给出了带有单个拐角的直走廊。
别忘了楼梯(我差点忘了!)
最后,我们需要完成并创建出口:
#![allow(unused)] fn main() { // 别忘了楼梯 let stairs = self.rooms[self.rooms.len()-1].center(); let stairs_idx = self.map.xy_idx(stairs.0, stairs.1); self.map.tiles[stairs_idx] = TileType::DownStairs; }
我们将出口放置在最后一个房间中,以确保可怜的玩家有路可走。
如果您现在 cargo run,您将看到类似这样的内容:
.
随机化每个地牢关卡
我们希望有时使用其中一种算法,而不是总是使用 BSP 下水道算法。 在 map_builders/mod.rs 中,替换 build 函数:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 2); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) } } }
现在,当您玩游戏时,遇到哪种类型的地图就像掷硬币一样。 这些类型的 spawn 函数是相同的 - 因此在下一章之前,我们不会担心地图构建器状态。
总结
您已将地图构建重构为一个新模块,并构建了一个基于简单的 BSP(二叉空间分割)的地图。 游戏会随机选择地图类型,并且您拥有更多种类。 下一章将进一步重构地图生成,并介绍另一种技术。
本章的源代码可以在这里找到
在您的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
BSP 室内设计
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您能享受本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在上一章中,我们使用二叉空间分割 (BSP) 构建了一个带有房间的地牢。BSP 非常灵活,可以帮助您解决许多问题;在本例中,我们将修改 BSP 来设计一个室内地牢 - 完全在一个矩形结构内部(例如,一座城堡),除了内部墙壁外,没有浪费任何空间。
本章的代码是从 One Knight in the Dungeon 的监狱关卡转换而来的。
脚手架
我们将从创建一个新文件 map_builders/bsp_interior.rs 开始,并放入我们在上一章中使用的相同初始样板代码:
#![allow(unused)] fn main() { use super::{MapBuilder, Map, Rect, apply_room_to_map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER}; use rltk::RandomNumberGenerator; use specs::prelude::*; pub struct BspInteriorBuilder { map : Map, starting_position : Position, depth: i32, rooms: Vec<Rect>, history: Vec<Map>, rects: Vec<Rect> } impl MapBuilder for BspInteriorBuilder { fn get_map(&self) -> Map { self.map.clone() } fn get_starting_position(&self) -> Position { self.starting_position.clone() } fn get_snapshot_history(&self) -> Vec<Map> { self.history.clone() } fn build_map(&mut self) { // 我们应该在这里做些什么 } fn spawn_entities(&mut self, ecs : &mut World) { for room in self.rooms.iter().skip(1) { spawner::spawn_room(ecs, room, self.depth); } } fn take_snapshot(&mut self) { if SHOW_MAPGEN_VISUALIZER { let mut snapshot = self.map.clone(); for v in snapshot.revealed_tiles.iter_mut() { *v = true; } self.history.push(snapshot); } } } impl BspInteriorBuilder { pub fn new(new_depth : i32) -> BspInteriorBuilder { BspInteriorBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, rooms: Vec::new(), history: Vec::new(), rects: Vec::new() } } } }
我们还将更改 map_builders/mod.rs 中的随机生成器函数,再次“欺骗”用户,始终“随机”选择新的算法:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { /*let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 2); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) }*/ Box::new(BspInteriorBuilder::new(new_depth)) } }
细分为房间
由于舍入问题,我们无法实现完美的细分,但我们可以非常接近。当然,对于游戏来说已经足够好了!我们组合了一个 build 函数,它与上一章中的函数非常相似:
#![allow(unused)] fn main() { fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); self.rects.clear(); self.rects.push( Rect::new(1, 1, self.map.width-2, self.map.height-2) ); // 从一个地图大小的矩形开始 let first_room = self.rects[0]; self.add_subrects(first_room, &mut rng); // 分割第一个房间 let rooms = self.rects.clone(); for r in rooms.iter() { let room = *r; //room.x2 -= 1; //room.y2 -= 1; self.rooms.push(room); for y in room.y1 .. room.y2 { for x in room.x1 .. room.x2 { let idx = self.map.xy_idx(x, y); if idx > 0 && idx < ((self.map.width * self.map.height)-1) as usize { self.map.tiles[idx] = TileType::Floor; } } } self.take_snapshot(); } let start = self.rooms[0].center(); self.starting_position = Position{ x: start.0, y: start.1 }; } }
让我们看看这段代码做了什么:
- 我们创建一个新的随机数生成器。
- 我们清空
rects列表,并添加一个覆盖我们打算使用的整个地图的矩形。 - 我们在这个矩形上调用一个神奇的函数
add_subrects。稍后会详细介绍。 - 我们复制房间列表,以避免借用问题。
- 对于每个房间,我们将其添加到房间列表 - 并从地图上雕刻出来。我们还拍摄快照。
- 我们将玩家的起始位置设置在第一个房间。
add_subrects 函数在这种情况下完成了所有繁重的工作:
#![allow(unused)] fn main() { fn add_subrects(&mut self, rect : Rect, rng : &mut RandomNumberGenerator) { // 从列表中移除最后一个矩形 if !self.rects.is_empty() { self.rects.remove(self.rects.len() - 1); } // 计算边界 let width = rect.x2 - rect.x1; let height = rect.y2 - rect.y1; let half_width = width / 2; let half_height = height / 2; let split = rng.roll_dice(1, 4); if split <= 2 { // 水平分割 let h1 = Rect::new( rect.x1, rect.y1, half_width-1, height ); self.rects.push( h1 ); if half_width > MIN_ROOM_SIZE { self.add_subrects(h1, rng); } let h2 = Rect::new( rect.x1 + half_width, rect.y1, half_width, height ); self.rects.push( h2 ); if half_width > MIN_ROOM_SIZE { self.add_subrects(h2, rng); } } else { // 垂直分割 let v1 = Rect::new( rect.x1, rect.y1, width, half_height-1 ); self.rects.push(v1); if half_height > MIN_ROOM_SIZE { self.add_subrects(v1, rng); } let v2 = Rect::new( rect.x1, rect.y1 + half_height, width, half_height ); self.rects.push(v2); if half_height > MIN_ROOM_SIZE { self.add_subrects(v2, rng); } } } }
让我们看一下这个函数的作用:
- 如果
rects列表不为空,我们从列表中删除最后一个项目。这具有删除我们添加的最后一个矩形的效果 - 因此当我们开始时,我们正在删除覆盖整个地图的矩形。稍后,我们删除一个矩形是因为我们要分割它。这样,我们就不会有重叠。 - 我们计算矩形的宽度和高度,以及宽度和高度的一半。
- 我们掷骰子。水平或垂直分割的概率为 50%。
- 如果我们进行水平分割:
- 我们创建
h1- 一个新的矩形。它覆盖了父矩形的左半部分。 - 我们将
h1添加到rects列表。 - 如果
half_width大于MIN_ROOM_SIZE,我们再次递归调用add_subrects,并将h1作为目标矩形。 - 我们创建
h2- 一个新的矩形,覆盖父矩形的右侧。 - 我们将
h2添加到rects列表。 - 如果
half_width大于MIN_ROOM_SIZE,我们再次递归调用add_subrects,并将h2作为目标矩形。
- 我们创建
- 如果我们进行垂直分割,则与 (4) 相同 - 但使用顶部和底部矩形。
从概念上讲,这从一个矩形开始:
#################################
# #
# #
# #
# #
# #
# #
# #
# #
# #
#################################
水平分割会产生以下结果:
#################################
# # #
# # #
# # #
# # #
# # #
# # #
# # #
# # #
# # #
#################################
下一个分割可能是垂直的:
#################################
# # #
# # #
# # #
# # #
################ #
# # #
# # #
# # #
# # #
#################################
这将重复进行,直到我们有很多小房间为止。
您可以立即 cargo run 运行代码,以查看房间的出现。

添加一些门道
拥有房间固然很好,但是如果没有连接它们的门,那就不会是一个非常有趣的体验!幸运的是,上一章中的完全相同的代码 也适用于此处。
#![allow(unused)] fn main() { // 现在我们需要走廊 for i in 0..self.rooms.len()-1 { let room = self.rooms[i]; let next_room = self.rooms[i+1]; let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1); let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1); let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1); let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1); self.draw_corridor(start_x, start_y, end_x, end_y); self.take_snapshot(); } }
这反过来又调用了未更改的 draw_corridor 函数:
#![allow(unused)] fn main() { fn draw_corridor(&mut self, x1:i32, y1:i32, x2:i32, y2:i32) { let mut x = x1; let mut y = y1; while x != x2 || y != y2 { if x < x2 { x += 1; } else if x > x2 { x -= 1; } else if y < y2 { y += 1; } else if y > y2 { y -= 1; } let idx = self.map.xy_idx(x, y); self.map.tiles[idx] = TileType::Floor; } } }
别忘了楼梯(我差点又忘了!)
最后,我们需要完成并创建出口:
#![allow(unused)] fn main() { // 别忘了楼梯 let stairs = self.rooms[self.rooms.len()-1].center(); let stairs_idx = self.map.xy_idx(stairs.0, stairs.1); self.map.tiles[stairs_idx] = TileType::DownStairs; }
我们将出口放置在最后一个房间中,以确保可怜的玩家有路可走。
如果您现在 cargo run 运行,您将看到类似这样的内容:
.
再次恢复随机性
最后,我们回到 map_builders/mod.rs 并编辑我们的 random_builder,以便再次为每个关卡提供一个随机地牢:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 3); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) } } }
总结
这种类型的地牢可以代表室内,可能是宇宙飞船、城堡甚至房屋的内部。您可以根据自己的需要调整尺寸、门的位置和分割的偏向 - 但您将获得一张地图,该地图使游戏可以使用大部分可用空间。可能值得节约使用这些关卡(或将它们融入到其他关卡中) - 即使它们是随机的,它们也可能缺乏多样性。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
细胞自动机地图
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
有时候,您需要从矩形房间中解脱出来。您可能想要一个漂亮的、看起来自然的洞穴;一条蜿蜒的森林小径,或是一个阴森的采石场。《地牢骑士》(One Knight in the Dungeon)使用了细胞自动机来实现这个目的,其灵感来源于这篇优秀的文章。本章将帮助您创建看起来自然的地图。
脚手架
再一次,我们将从之前的教程中提取大量代码,并将其重新用于新的生成器。创建一个新文件 map_builders/cellular_automata.rs,并将以下内容放入其中:
#![allow(unused)] fn main() { use super::{MapBuilder, Map, Rect, apply_room_to_map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER}; use rltk::RandomNumberGenerator; use specs::prelude::*; const MIN_ROOM_SIZE : i32 = 8; pub struct CellularAutomataBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map> } impl MapBuilder for CellularAutomataBuilder { fn get_map(&self) -> Map { self.map.clone() } fn get_starting_position(&self) -> Position { self.starting_position.clone() } fn get_snapshot_history(&self) -> Vec<Map> { self.history.clone() } fn build_map(&mut self) { //self.build(); - 我们应该编写这个 } fn spawn_entities(&mut self, ecs : &mut World) { // 我们也需要重写这个。 } fn take_snapshot(&mut self) { if SHOW_MAPGEN_VISUALIZER { let mut snapshot = self.map.clone(); for v in snapshot.revealed_tiles.iter_mut() { *v = true; } self.history.push(snapshot); } } } impl CellularAutomataBuilder { pub fn new(new_depth : i32) -> CellularAutomataBuilder { CellularAutomataBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), } } } }
再一次,我们将使名称 random_builder 成为一个谎言,并且只返回我们正在处理的那个:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { /*let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 3); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) }*/ Box::new(CellularAutomataBuilder::new(new_depth)) } }
组合基本的地图
第一步是使地图完全混乱,大约 55% 的地块是实心的。您可以调整这个数字以获得不同的效果,但我非常喜欢这个结果。这是 build 函数:
#![allow(unused)] fn main() { fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); // 首先,我们完全随机化地图,将其中的 55% 设置为地板。 for y in 1..self.map.height-1 { for x in 1..self.map.width-1 { let roll = rng.roll_dice(1, 100); let idx = self.map.xy_idx(x, y); if roll > 55 { self.map.tiles[idx] = TileType::Floor } else { self.map.tiles[idx] = TileType::Wall } } } self.take_snapshot(); } }
这制造了一个乱七八糟的、无法使用的关卡。墙壁和地板到处都是,没有任何规律可循 - 而且完全无法游玩。这没关系,因为细胞自动机旨在从噪声中生成关卡。它的工作原理是迭代每个单元格,计算邻居的数量,并根据密度将墙壁变成地板或墙壁。这是一个可工作的生成器:
#![allow(unused)] fn main() { fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); // 首先,我们完全随机化地图,将其中的 55% 设置为地板。 for y in 1..self.map.height-1 { for x in 1..self.map.width-1 { let roll = rng.roll_dice(1, 100); let idx = self.map.xy_idx(x, y); if roll > 55 { self.map.tiles[idx] = TileType::Floor } else { self.map.tiles[idx] = TileType::Wall } } } self.take_snapshot(); // 现在我们迭代地应用细胞自动机规则 for _i in 0..15 { let mut newtiles = self.map.tiles.clone(); for y in 1..self.map.height-1 { for x in 1..self.map.width-1 { let idx = self.map.xy_idx(x, y); let mut neighbors = 0; if self.map.tiles[idx - 1] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx + 1] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx - self.map.width as usize] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx + self.map.width as usize] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx - (self.map.width as usize - 1)] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx - (self.map.width as usize + 1)] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx + (self.map.width as usize - 1)] == TileType::Wall { neighbors += 1; } if self.map.tiles[idx + (self.map.width as usize + 1)] == TileType::Wall { neighbors += 1; } if neighbors > 4 || neighbors == 0 { newtiles[idx] = TileType::Wall; } else { newtiles[idx] = TileType::Floor; } } } self.map.tiles = newtiles.clone(); self.take_snapshot(); } } }
这实际上非常简单:
- 我们如上所述随机化地图。
- 我们从 0 数到 9,进行 10 次算法迭代。
- 对于每次迭代:
- 我们复制地图地块,将其放入
newtiles中。我们这样做是为了不写入我们正在计数的地块,这会产生非常奇怪的地图。 - 我们迭代地图上的每个单元格,并计算与该地块相邻的墙壁地块数量。
- 如果有超过 4 个或 0 个相邻的墙壁 - 那么该地块(在
newtiles中)变成墙壁。否则,它变成地板。 - 我们将
newtiles复制回map。 - 我们拍摄快照。
- 我们复制地图地块,将其放入
这是一个非常简单的算法 - 但产生了非常漂亮的结果。这是它的实际效果:
.
选择一个起始点
为玩家选择一个起始点比之前的章节要稍微困难一些。我们没有房间列表可以查询!相反,我们将从中间开始,然后向左移动,直到我们找到一些开放空间。这段代码非常简单:
#![allow(unused)] fn main() { // 找到一个起始点;从中间开始向左走,直到找到一个开放的地块 self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 }; let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); while self.map.tiles[start_idx] != TileType::Floor { self.starting_position.x -= 1; start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); } }
放置出口 - 并剔除无法到达的区域
我们希望出口离玩家相当远。我们也不想保留玩家绝对无法到达的区域。幸运的是,找到出口的过程和找到孤立区域的过程非常相似。我们可以使用 Dijkstra 地图。如果您还没有读过,我建议您阅读 Dijkstra 地图的不可思议的力量。幸运的是,RLTK 为您实现了一个非常快速的 Dijkstra 版本,因此您不必与该算法作斗争。这是代码:
#![allow(unused)] fn main() { // 找到我们可以从起始点到达的所有地块 let map_starts : Vec<usize> = vec![start_idx]; let dijkstra_map = rltk::DijkstraMap::new(self.map.width, self.map.height, &map_starts , &self.map, 200.0); let mut exit_tile = (0, 0.0f32); for (i, tile) in self.map.tiles.iter_mut().enumerate() { if *tile == TileType::Floor { let distance_to_start = dijkstra_map.map[i]; // 我们无法到达这个地块 - 所以我们把它变成墙壁 if distance_to_start == std::f32::MAX { *tile = TileType::Wall; } else { // 如果它比我们当前的出口候选者更远,则移动出口 if distance_to_start > exit_tile.1 { exit_tile.0 = i; exit_tile.1 = distance_to_start; } } } } self.take_snapshot(); self.map.tiles[exit_tile.0] = TileType::DownStairs; self.take_snapshot(); }
这是一段密集的代码,它做了很多事情,让我们逐步了解它:
- 我们创建一个名为
map_starts的向量,并为其提供一个单一值:玩家开始所在的地块索引。Dijkstra 地图可以有多个起点(距离为 0),因此即使只有一个选择,这也必须是一个向量。 - 我们要求 RLTK 为我们制作一个 Dijkstra 地图。它的尺寸与主地图匹配,使用起点,具有对地图本身的读取访问权限,并且我们将停止计数 200 步(以防失控的安全功能!)
- 我们将
exit_tiletuple设置为0和0.0。第一个零是出口的地块索引,第二个零是到出口的距离。 - 我们迭代地图地块,使用 Rust 的超棒的枚举功能。通过在范围迭代的末尾添加
.enumerate(),它将单元格索引作为元组中的第一个参数添加。然后我们解构以获得地块和索引。 - 如果地块是地板,
- 我们从 Dijkstra 地图获得到起点的距离。
- 如果距离是
f32的最大值(Dijkstra 地图用于“无法到达”的标记),那么它根本不需要是地板 - 没人能到达那里。所以我们把它变成墙壁。 - 如果距离大于
exit_tile元组中的距离,我们将新距离和新地块索引都存储起来。 - 一旦我们访问了每个地块,我们就会拍摄快照以显示移除的区域。
- 我们将
exit_tile处的地块(最远的可到达的地块)设置为向下楼梯。
如果您 cargo run,您实际上现在拥有了一个相当可玩的游戏地图!只是有一个问题:地图上没有其他实体。
填充我们的洞穴:将生成系统从房间中解放出来。
如果我们感到懒惰,我们可以简单地迭代地图 - 找到开放空间并随机生成一些东西。但这真的不是很有趣。怪物成群结队地出现,留出一些“死亡空间”让您喘口气(并恢复一些生命值)更有意义。
第一步,我们将重新审视我们如何生成实体。现在,几乎所有不是玩家的东西都是通过 spawner.rs 提供的 spawn_room 函数进入世界的。到目前为止,它已经很好地为我们服务,但我们希望更加灵活;我们可能想在走廊中生成,我们可能想在不适合矩形的半开放区域中生成,等等。此外,查看 spawn_room 会发现它在一个函数中做了几件事 - 这不是最好的设计。最终目标是保持 spawn_room 接口可用 - 这样我们仍然可以使用它,但也提供更详细的选项。
我们要做的第一件事是将实际生成分离出来:
#![allow(unused)] fn main() { /// 在(tuple.0)的位置生成一个命名的实体(tuple.1 中的名称) fn spawn_entity(ecs: &mut World, spawn : &(&usize, &String)) { let x = (*spawn.0 % MAPWIDTH) as i32; let y = (*spawn.0 / MAPWIDTH) as i32; match spawn.1.as_ref() { "Goblin" => goblin(ecs, x, y), "Orc" => orc(ecs, x, y), "Health Potion" => health_potion(ecs, x, y), "Fireball Scroll" => fireball_scroll(ecs, x, y), "Confusion Scroll" => confusion_scroll(ecs, x, y), "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y), "Dagger" => dagger(ecs, x, y), "Shield" => shield(ecs, x, y), "Longsword" => longsword(ecs, x, y), "Tower Shield" => tower_shield(ecs, x, y), "Rations" => rations(ecs, x, y), "Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y), "Bear Trap" => bear_trap(ecs, x, y), _ => {} } } }
现在我们可以用以下内容替换 spawn_room 中的最后一个 for 循环:
#![allow(unused)] fn main() { // 实际生成怪物 for spawn in spawn_points.iter() { spawn_entity(ecs, &spawn); } }
现在,我们将用一个简化的版本替换 spawn_room,该版本调用我们的理论函数:
#![allow(unused)] fn main() { pub fn spawn_room(ecs: &mut World, room : &Rect, map_depth: i32) { let mut possible_targets : Vec<usize> = Vec::new(); { // 借用作用域 - 保持对地图的访问分离 let map = ecs.fetch::<Map>(); for y in room.y1 + 1 .. room.y2 { for x in room.x1 + 1 .. room.x2 { let idx = map.xy_idx(x, y); if map.tiles[idx] == TileType::Floor { possible_targets.push(idx); } } } } spawn_region(ecs, &possible_targets, map_depth); } }
此函数保持与先前调用相同的接口/签名 - 因此我们的旧代码仍然可以工作。它不是实际生成任何东西,而是构建一个房间中所有地块的向量(检查它们是否是地板 - 我们之前没有做过;墙壁中的怪物不再可能!)。然后它调用一个新函数 spawn_region,该函数接受类似的签名 - 但需要一个可用于生成事物的可用地块列表。这是新函数:
#![allow(unused)] fn main() { pub fn spawn_region(ecs: &mut World, area : &[usize], map_depth: i32) { let spawn_table = room_table(map_depth); let mut spawn_points : HashMap<usize, String> = HashMap::new(); let mut areas : Vec<usize> = Vec::from(area); // 作用域以保持借用检查器满意 { let mut rng = ecs.write_resource::<RandomNumberGenerator>(); let num_spawns = i32::min(areas.len() as i32, rng.roll_dice(1, MAX_MONSTERS + 3) + (map_depth - 1) - 3); if num_spawns == 0 { return; } for _i in 0 .. num_spawns { let array_index = if areas.len() == 1 { 0usize } else { (rng.roll_dice(1, areas.len() as i32)-1) as usize }; let map_idx = areas[array_index]; spawn_points.insert(map_idx, spawn_table.roll(&mut rng)); areas.remove(array_index); } } // 实际生成怪物 for spawn in spawn_points.iter() { spawn_entity(ecs, &spawn); } } }
这与之前的生成代码类似,但并不完全相同(尽管结果基本上是相同的!)。我们将仔细研究它,以确保我们理解我们正在做什么:
- 我们获得当前地图深度的生成表。
- 我们设置一个名为
spawn_points的HashMap,列出我们已决定生成的所有内容的成对数据(地图索引和名称标签)。 - 我们创建一个新的
Vector区域,从传入的切片复制而来。(切片是数组或向量的“视图”)。我们正在创建一个新的,这样我们就不会修改父区域列表。调用者可能想将该数据用于其他用途,并且避免在未经请求的情况下更改人们的数据是件好事。在没有警告的情况下更改数据称为“副作用”,通常最好避免它们(除非您实际上想要它们)。 - 我们创建一个新的作用域,因为 Rust 不喜欢我们使用 ECS 来获取随机数生成器,然后在稍后使用它来生成实体。作用域使 Rust 在作用域结束后立即“忘记”我们的第一次借用。
- 我们从 ECS 获取一个随机数生成器。
- 我们计算要生成的实体的数量。这与我们之前使用的随机函数相同,但我们添加了一个
i32::min调用:我们想要可用地块数量或随机计算中的较小者。这样,我们永远不会尝试生成超过我们有空间的实体。 - 如果要生成的数量为零,我们退出函数(这里无事可做!)。
- 对于从零到生成数量的重复(减 1 - 我们没有使用包含范围):
- 我们从区域中选择一个
array_index。如果只有一个条目,我们使用它。否则,我们掷骰子(从 1 到条目数,减一是因为数组是基于零的)。 map_idx(地图地块数组中的位置)是位于数组的array_index索引处的值。所以我们获得它。- 我们将一个生成项插入
spawn_points地图中,列出索引和生成表上的随机掷骰。 - 我们从
areas中删除我们刚刚使用的条目 - 这样,我们不会意外地再次选择它。请注意,我们没有检查数组是否为空:在上面的步骤 6 中,我们保证我们生成的实体不会超过我们有空间的数量,因此(至少在理论上)那个特定的错误不会发生!
- 我们从区域中选择一个
测试这个的最佳方法是取消注释 random_builder 代码(并注释掉 CellularAutomataBuilder 条目),然后试用一下。它应该像以前一样正常运行。一旦您测试过它,请返回到始终生成我们正在处理的地图类型。
在我们的地图中分组放置 - 进入 Voronoi!
Voronoi 图 是非常有用的数学工具。给定一组点,它构建了一个围绕每个点的区域图(这些点可以是随机的,也可能意味着某些东西;这就是数学的魅力,这取决于您!) - 没有空白空间。我们希望为我们的地图做类似的事情:将地图细分为随机区域并在这些区域内部生成。幸运的是,RLTK 提供了一种噪声来帮助实现这一点:细胞噪声。
首先,什么是噪声。在这种情况下,“噪声”不是指您在凌晨 2 点不小心从庭院扬声器中播放出来的响亮重金属音乐,同时想知道您在新房子里找到的立体声接收器是做什么的(真实故事...);它指的是随机数据 - 就像您没有调谐到频道时旧模拟电视上的噪声一样(好吧,我在这里暴露我的年龄了)。像大多数随机事物一样,有很多方法可以使其不是真正随机的,并将其分组为有用的模式。噪声库提供了许多类型的噪声。Perlin/Simplex 噪声 可以很好地近似景观。白噪声看起来像有人随机地将油漆扔到一张纸上。细胞噪声在网格上随机放置点,然后在它们周围绘制 Voronoi 图。我们对后者感兴趣。
这是一种有点复杂的方法,因此我们将逐步进行。首先,让我们向 CellularAutomataBuilder 结构添加一个结构来存储生成的区域:
#![allow(unused)] fn main() { pub struct CellularAutomataBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, noise_areas : HashMap<i32, Vec<usize>> } }
在 new 中,我们还必须初始化它:
#![allow(unused)] fn main() { impl CellularAutomataBuilder { pub fn new(new_depth : i32) -> CellularAutomataBuilder { CellularAutomataBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new() } } ... }
这里的想法是我们有一个 HashMap(其他语言中的字典),以区域的 ID 号为键。该区域由地块 ID 号的 vector 组成。理想情况下,我们将生成 20-30 个不同的区域,所有区域都有空间来生成实体。
这是 build 代码的下一部分:
#![allow(unused)] fn main() { // 现在我们构建一个噪声地图,用于稍后生成实体 let mut noise = rltk::FastNoise::seeded(rng.roll_dice(1, 65536) as u64); noise.set_noise_type(rltk::NoiseType::Cellular); noise.set_frequency(0.08); noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan); for y in 1 .. self.map.height-1 { for x in 1 .. self.map.width-1 { let idx = self.map.xy_idx(x, y); if self.map.tiles[idx] == TileType::Floor { let cell_value_f = noise.get_noise(x as f32, y as f32) * 10240.0; let cell_value = cell_value_f as i32; if self.noise_areas.contains_key(&cell_value) { self.noise_areas.get_mut(&cell_value).unwrap().push(idx); } else { self.noise_areas.insert(cell_value, vec![idx]); } } } } }
由于这相当复杂,让我们逐步了解它:
- 我们从 RLTK 的 Auburns 优秀
FastNoise库的端口创建一个新的FastNoise对象。 - 我们指定我们想要细胞噪声。在这种情况下,这与 Voronoi 噪声相同。
- 我们指定频率为
0.08。这个数字是通过尝试不同的值找到的! - 我们指定
Manhattan距离函数。有三种可以选择,它们给出不同的形状。曼哈顿倾向于偏爱细长的形状,我喜欢将其用于此目的。尝试所有三种,看看您喜欢哪种。 - 我们迭代整个地图:
- 我们获取地块的
idx,在地图的tiles向量中。 - 我们检查以确保它是地板 - 如果不是,则跳过。
- 我们查询
FastNoise坐标的噪声值(将它们转换为f32浮点数,因为该库喜欢浮点数)。我们将结果乘以10240.0,因为默认值是非常小的数字 - 这使其达到合理的范围。 - 我们将结果转换为整数。
- 如果
noise_areas地图包含我们刚刚生成的区域编号,我们将地块索引添加到向量中。 - 如果
noise_areas地图不包含我们刚刚生成的区域编号,我们创建一个新的地块索引向量,其中包含地图索引号。
- 我们获取地块的
这相当一致地生成 20 到 30 个区域,并且它们仅包含有效的地板地块。因此,最后剩下的工作是实际生成一些实体。我们更新我们的 spawn_entities 函数:
#![allow(unused)] fn main() { fn spawn_entities(&mut self, ecs : &mut World) { for area in self.noise_areas.iter() { spawner::spawn_region(ecs, area.1, self.depth); } } }
这非常简单:它迭代每个区域,并使用该区域的可用地图地块向量调用新的 spawn_region。
游戏现在在这些新地图上非常可玩:
.
恢复随机性
再一次,我们应该恢复地图构建的随机性。在 map_builders/mod.rs 中:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 4); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) } } }
总结
我们制作了一个非常不错的地图生成器,并修复了我们对房间的依赖。细胞自动机是一种非常灵活的算法,可以用于各种看起来自然的地图。通过对规则进行一些调整,您可以制作出各种各样的地图。
本章的源代码可以在这里找到
使用 WebAssembly 在您的浏览器中运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
醉汉漫步地图
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
有没有想过,如果一只掘地虫 (Umber Hulk)(或其他隧道生物)真的喝醉了,并且开始了一场地下城雕刻狂欢,会发生什么? 醉汉漫步算法回答了这个问题 - 或者更准确地说,如果一大群怪物喝了太多酒会发生什么。 听起来很疯狂,但这确实是制作自然地下城的好方法。
初始脚手架
和往常一样,我们将从之前地图教程中的脚手架开始。 我们已经做过很多次了,现在应该轻车熟路了! 在 map_builders/drunkard.rs 中,构建一个新的 DrunkardsWalkBuilder 类。 我们将保留来自细胞自动机的基于区域的放置方式 - 但删除地图构建代码。 这是脚手架代码:
#![allow(unused)] fn main() { use super::{MapBuilder, Map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER}; use rltk::RandomNumberGenerator; use specs::prelude::*; use std::collections::HashMap; pub struct DrunkardsWalkBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, noise_areas : HashMap<i32, Vec<usize>> } impl MapBuilder for DrunkardsWalkBuilder { fn get_map(&self) -> Map { self.map.clone() } fn get_starting_position(&self) -> Position { self.starting_position.clone() } fn get_snapshot_history(&self) -> Vec<Map> { self.history.clone() } fn build_map(&mut self) { self.build(); } fn spawn_entities(&mut self, ecs : &mut World) { for area in self.noise_areas.iter() { spawner::spawn_region(ecs, area.1, self.depth); } } fn take_snapshot(&mut self) { if SHOW_MAPGEN_VISUALIZER { let mut snapshot = self.map.clone(); for v in snapshot.revealed_tiles.iter_mut() { *v = true; } self.history.push(snapshot); } } } impl DrunkardsWalkBuilder { pub fn new(new_depth : i32) -> DrunkardsWalkBuilder { DrunkardsWalkBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new() } } #[allow(clippy::map_entry)] fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); // 设置中心起始点 self.starting_position = Position{ x: self.map.width / 2, y: self.map.height / 2 }; let start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); // 查找从起始点可以到达的所有图块 let map_starts : Vec<usize> = vec![start_idx]; let dijkstra_map = rltk::DijkstraMap::new(self.map.width, self.map.height, &map_starts , &self.map, 200.0); let mut exit_tile = (0, 0.0f32); for (i, tile) in self.map.tiles.iter_mut().enumerate() { if *tile == TileType::Floor { let distance_to_start = dijkstra_map.map[i]; // 我们无法到达此图块 - 因此我们将其设为墙壁 if distance_to_start == std::f32::MAX { *tile = TileType::Wall; } else { // 如果它比我们当前的出口候选更远,则移动出口 if distance_to_start > exit_tile.1 { exit_tile.0 = i; exit_tile.1 = distance_to_start; } } } } self.take_snapshot(); // 放置楼梯 self.map.tiles[exit_tile.0] = TileType::DownStairs; self.take_snapshot(); // 现在我们构建一个噪声地图,用于稍后生成实体 let mut noise = rltk::FastNoise::seeded(rng.roll_dice(1, 65536) as u64); noise.set_noise_type(rltk::NoiseType::Cellular); noise.set_frequency(0.08); noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan); for y in 1 .. self.map.height-1 { for x in 1 .. self.map.width-1 { let idx = self.map.xy_idx(x, y); if self.map.tiles[idx] == TileType::Floor { let cell_value_f = noise.get_noise(x as f32, y as f32) * 10240.0; let cell_value = cell_value_f as i32; if self.noise_areas.contains_key(&cell_value) { self.noise_areas.get_mut(&cell_value).unwrap().push(idx); } else { self.noise_areas.insert(cell_value, vec![idx]); } } } } } } }
我们保留了来自细胞自动机章节的大部分工作,因为它在这里也能帮助我们。 我们还进入 map_builders/mod.rs 并再次强制“随机”系统选择我们的新代码:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { /*let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 4); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) }*/ Box::new(DrunkardsWalkBuilder::new(new_depth)) } }
不要重复自己 (DRY 原则)
由于我们正在重用来自细胞自动机的完全相同的代码,我们应该将通用代码放入 map_builders/common.rs 中。 这样可以节省打字时间,并节省编译器重复重新制作相同代码的时间(从而减小程序的大小)。 因此,在 common.rs 中,我们将通用代码重构为一些函数。 在 common.rs 中,我们创建一个新函数 - remove_unreachable_areas_returning_most_distant:
#![allow(unused)] fn main() { /// 搜索地图,删除无法到达的区域并返回最远的图块。 pub fn remove_unreachable_areas_returning_most_distant(map : &mut Map, start_idx : usize) -> usize { map.populate_blocked(); let map_starts : Vec<usize> = vec![start_idx]; let dijkstra_map = rltk::DijkstraMap::new(map.width as usize, map.height as usize, &map_starts , map, 200.0); let mut exit_tile = (0, 0.0f32); for (i, tile) in map.tiles.iter_mut().enumerate() { if *tile == TileType::Floor { let distance_to_start = dijkstra_map.map[i]; // 我们无法到达此图块 - 因此我们将其设为墙壁 if distance_to_start == std::f32::MAX { *tile = TileType::Wall; } else { // 如果它比我们当前的出口候选更远,则移动出口 if distance_to_start > exit_tile.1 { exit_tile.0 = i; exit_tile.1 = distance_to_start; } } } } exit_tile.0 } }
我们将创建第二个函数 generate_voronoi_spawn_regions:
#![allow(unused)] fn main() { /// 生成区域的 Voronoi/细胞噪声地图,并将其划分为生成区域。 #[allow(clippy::map_entry)] pub fn generate_voronoi_spawn_regions(map: &Map, rng : &mut rltk::RandomNumberGenerator) -> HashMap<i32, Vec<usize>> { let mut noise_areas : HashMap<i32, Vec<usize>> = HashMap::new(); let mut noise = rltk::FastNoise::seeded(rng.roll_dice(1, 65536) as u64); noise.set_noise_type(rltk::NoiseType::Cellular); noise.set_frequency(0.08); noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan); for y in 1 .. map.height-1 { for x in 1 .. map.width-1 { let idx = map.xy_idx(x, y); if map.tiles[idx] == TileType::Floor { let cell_value_f = noise.get_noise(x as f32, y as f32) * 10240.0; let cell_value = cell_value_f as i32; if noise_areas.contains_key(&cell_value) { noise_areas.get_mut(&cell_value).unwrap().push(idx); } else { noise_areas.insert(cell_value, vec![idx]); } } } } noise_areas } }
将这些函数插入到我们的 build 函数中,可以大大减少样板代码部分:
#![allow(unused)] fn main() { // 查找从起始点可以到达的所有图块 let exit_tile = remove_unreachable_areas_returning_most_distant(&mut self.map, start_idx); self.take_snapshot(); // 放置楼梯 self.map.tiles[exit_tile] = TileType::DownStairs; self.take_snapshot(); // 现在我们构建一个噪声地图,用于稍后生成实体 self.noise_areas = generate_voronoi_spawn_regions(&self.map, &mut rng); }
在示例中,我回到了 cellular_automata 部分并做了同样的操作。
这基本上是我们之前拥有的相同代码(因此,此处不再解释),但包装在一个函数中(并接受可变地图引用 - 因此它会更改您给它的地图,并将起始点作为参数)。
醉汉漫步
该算法背后的基本思想很简单:
- 选择一个中心起始点,并将其转换为地板。
- 我们计算地图上有多少地板空间,并迭代直到我们将一定百分比(在示例中使用 50%)的地图转换为地板。
- 在起始点生成一个醉汉。 醉汉具有“生命周期”和“位置”。
- 当醉汉还活着时:
- 减少醉汉的生命周期(我喜欢认为他们昏过去睡着了)。
- 掷一个四面骰子。
- 如果我们掷出 1,则将醉汉向北移动。
- 如果我们掷出 2,则将醉汉向南移动。
- 如果我们掷出 3,则将醉汉向东移动。
- 如果我们掷出 4,则将醉汉向西移动。
- 醉汉落脚的图块变成地板。
这就是全部:我们不断生成醉汉,直到我们有足够的地图覆盖率。 这是一个实现:
#![allow(unused)] fn main() { // 设置中心起始点 self.starting_position = Position{ x: self.map.width / 2, y: self.map.height / 2 }; let start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); self.map.tiles[start_idx] = TileType::Floor; let total_tiles = self.map.width * self.map.height; let desired_floor_tiles = (total_tiles / 2) as usize; let mut floor_tile_count = self.map.tiles.iter().filter(|a| **a == TileType::Floor).count(); let mut digger_count = 0; let mut active_digger_count = 0; while floor_tile_count < desired_floor_tiles { let mut did_something = false; let mut drunk_x = self.starting_position.x; let mut drunk_y = self.starting_position.y; let mut drunk_life = 400; while drunk_life > 0 { let drunk_idx = self.map.xy_idx(drunk_x, drunk_y); if self.map.tiles[drunk_idx] == TileType::Wall { did_something = true; } self.map.tiles[drunk_idx] = TileType::DownStairs; let stagger_direction = rng.roll_dice(1, 4); match stagger_direction { 1 => { if drunk_x > 2 { drunk_x -= 1; } } 2 => { if drunk_x < self.map.width-2 { drunk_x += 1; } } 3 => { if drunk_y > 2 { drunk_y -=1; } } _ => { if drunk_y < self.map.height-2 { drunk_y += 1; } } } drunk_life -= 1; } if did_something { self.take_snapshot(); active_digger_count += 1; } digger_count += 1; for t in self.map.tiles.iter_mut() { if *t == TileType::DownStairs { *t = TileType::Floor; } } floor_tile_count = self.map.tiles.iter().filter(|a| **a == TileType::Floor).count(); } rltk::console::log(format!("{} dwarves gave up their sobriety, of whom {} actually found a wall.", digger_count, active_digger_count)); }
这个实现扩展了很多东西,并且可以更短 - 但为了清晰起见,我们将其保留得很大且很明显。 我们还将很多东西变成了可以成为常量的变量 - 它更容易阅读,并且旨在易于“玩弄”值。 它还在控制台中打印状态更新,显示发生了什么。
如果您现在 cargo run,您将获得一张非常漂亮的开放地图:
.
管理挖掘者的酗酒问题
有很多方法可以调整“醉汉漫步”算法以生成不同的地图类型。 由于这些方法可以产生完全不同的地图,因此让我们自定义算法的接口,以提供几种不同的运行方式。 我们将首先创建一个 struct 来保存参数集:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum DrunkSpawnMode { StartingPoint, Random } pub struct DrunkardSettings { pub spawn_mode : DrunkSpawnMode } }
现在我们将修改 new 和结构本身以接受它:
#![allow(unused)] fn main() { pub struct DrunkardsWalkBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, noise_areas : HashMap<i32, Vec<usize>>, settings : DrunkardSettings } ... impl DrunkardsWalkBuilder { pub fn new(new_depth : i32, settings: DrunkardSettings) -> DrunkardsWalkBuilder { DrunkardsWalkBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), settings } } ... }
我们还将修改“随机”构建器以接受设置:
#![allow(unused)] fn main() { Box::new(DrunkardsWalkBuilder::new(new_depth, DrunkardSettings{ spawn_mode: DrunkSpawnMode::StartingPoint })) }
现在我们有了一个调整挖掘者醉酒程度的机制!
改变醉酒漫步者的起始点
我们在上一节中通过创建 DrunkSpawnMode 暗示了这一点 - 我们将看看如果我们更改醉酒挖掘者(在第一个之后)的生成方式会发生什么。 将 random_builder 更改为 DrunkSpawnMode::Random,然后修改 build(在 drunkard.rs 中)以使用它:
#![allow(unused)] fn main() { ... while floor_tile_count < desired_floor_tiles { let mut did_something = false; let mut drunk_x; let mut drunk_y; match self.settings.spawn_mode { DrunkSpawnMode::StartingPoint => { drunk_x = self.starting_position.x; drunk_y = self.starting_position.y; } DrunkSpawnMode::Random => { if digger_count == 0 { drunk_x = self.starting_position.x; drunk_y = self.starting_position.y; } else { drunk_x = rng.roll_dice(1, self.map.width - 3) + 1; drunk_y = rng.roll_dice(1, self.map.height - 3) + 1; } } } let mut drunk_life = 400; ... }
这是一个相对容易的更改:如果我们在“随机”模式下,则第一个挖掘者的起始位置是地图的中心(以确保我们在楼梯周围有一些空间),然后对于每个后续迭代,起始位置是随机地图位置。 它生成这样的地图:
.
这是一个更加分散的地图。 更少的大型中心区域,更像是一个广阔的洞穴。 一个方便的变体!
修改醉汉昏倒所需的时间
另一个可以调整的参数是醉汉保持清醒的时间。 这会严重改变生成地图的特性。 我们将其添加到设置中:
#![allow(unused)] fn main() { pub struct DrunkardSettings { pub spawn_mode : DrunkSpawnMode, pub drunken_lifetime : i32 } }
我们将告诉 random_builder 函数使用较短的生命周期:
#![allow(unused)] fn main() { Box::new(DrunkardsWalkBuilder::new(new_depth, DrunkardSettings{ spawn_mode: DrunkSpawnMode::Random, drunken_lifetime: 100 })) }
我们将修改 build 代码以实际使用它:
#![allow(unused)] fn main() { let mut drunk_life = self.settings.drunken_lifetime; }
这是一个简单的更改 - 并且极大地改变了生成地图的性质。 每个挖掘者只能走之前挖掘者距离的四分之一(更烈的啤酒!),因此他们倾向于挖掘的地图区域更少。 这导致更多的迭代,并且由于它们是随机开始的,因此您倾向于看到形成更多明显的地图区域 - 并希望它们连接起来(如果它们没有连接起来,它们将在最后被剔除)。
使用 100 生命周期的 cargo run,随机放置的醉汉会产生类似这样的结果:
.
更改期望的填充百分比
最后,我们将调整我们希望用地板覆盖地图多少百分比。 数字越低,您生成的墙壁越多(开放区域越少)。 我们将再次修改 DrunkardSettings:
#![allow(unused)] fn main() { pub struct DrunkardSettings { pub spawn_mode : DrunkSpawnMode, pub drunken_lifetime : i32, pub floor_percent: f32 } }
我们还更改了构建器中的一行代码:
#![allow(unused)] fn main() { let desired_floor_tiles = (self.settings.floor_percent * total_tiles as f32) as usize; }
我们之前将 desired_floor_tiles 设置为 total_tiles / 2 - 这在新系统中将表示为 0.5。 让我们尝试在 random_builder 中将其更改为 0.4:
#![allow(unused)] fn main() { Box::new(DrunkardsWalkBuilder::new(new_depth, DrunkardSettings{ spawn_mode: DrunkSpawnMode::Random, drunken_lifetime: 200, floor_percent: 0.4 })) }
如果您现在 cargo run,您将看到我们形成的开放区域甚至更少:
.
构建一些预设构造器
现在我们有了这些参数可以玩,让我们制作更多构造器,以消除 mod.rs 中的调用者了解算法细节的需求:
#![allow(unused)] fn main() { pub fn new(new_depth : i32, settings: DrunkardSettings) -> DrunkardsWalkBuilder { DrunkardsWalkBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), settings } } pub fn open_area(new_depth : i32) -> DrunkardsWalkBuilder { DrunkardsWalkBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), settings : DrunkardSettings{ spawn_mode: DrunkSpawnMode::StartingPoint, drunken_lifetime: 400, floor_percent: 0.5 } } } pub fn open_halls(new_depth : i32) -> DrunkardsWalkBuilder { DrunkardsWalkBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), settings : DrunkardSettings{ spawn_mode: DrunkSpawnMode::Random, drunken_lifetime: 400, floor_percent: 0.5 } } } pub fn winding_passages(new_depth : i32) -> DrunkardsWalkBuilder { DrunkardsWalkBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), settings : DrunkardSettings{ spawn_mode: DrunkSpawnMode::Random, drunken_lifetime: 100, floor_percent: 0.4 } } } }
现在我们可以修改我们的 random_builder 函数,使其再次随机 - 并提供三种不同的地图类型:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 7); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), 4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)), 5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)), 6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) } } }
总结
我们完成了醉酒地图构建(我从没想过会打出这些词……)! 这是一种非常灵活的算法,可用于制作许多不同的地图类型。 它也与其他算法很好地结合,我们将在以后的章节中看到。
本章的源代码可以在这里找到
在您的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
迷宫/地宫生成
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
地牢爬行游戏的一个主要内容是古老的迷宫,通常以弥诺陶洛斯为特色。《地牢爬行:石汤 (Dungeon Crawl: Stone Soup)》 有一个字面意义上的弥诺陶洛斯迷宫,《托姆 4 (Tome 4)》 有沙虫迷宫,《独 Knight (One Knight)》 有精灵树篱迷宫。这些关卡可能会让玩家感到恼火,应该谨慎使用:很多玩家并不真正喜欢为了找到出口而进行的乏味探索。本章将向您展示如何制作迷宫!
脚手架 (Scaffolding)
和之前一样,我们将使用上一章作为脚手架 (scaffolding) - 并将我们的 “随机” 构建器设置为使用新的设计。在 map_builders/maze.rs 中,放置以下代码:
#![allow(unused)] fn main() { use super::{MapBuilder, Map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER, remove_unreachable_areas_returning_most_distant, generate_voronoi_spawn_regions}; use rltk::RandomNumberGenerator; use specs::prelude::*; use std::collections::HashMap; pub struct MazeBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, noise_areas : HashMap<i32, Vec<usize>> } impl MapBuilder for MazeBuilder { fn get_map(&self) -> Map { self.map.clone() } fn get_starting_position(&self) -> Position { self.starting_position.clone() } fn get_snapshot_history(&self) -> Vec<Map> { self.history.clone() } fn build_map(&mut self) { self.build(); } fn spawn_entities(&mut self, ecs : &mut World) { for area in self.noise_areas.iter() { spawner::spawn_region(ecs, area.1, self.depth); } } fn take_snapshot(&mut self) { if SHOW_MAPGEN_VISUALIZER { let mut snapshot = self.map.clone(); for v in snapshot.revealed_tiles.iter_mut() { *v = true; } self.history.push(snapshot); } } } impl MazeBuilder { pub fn new(new_depth : i32) -> MazeBuilder { MazeBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new() } } #[allow(clippy::map_entry)] fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); // 找到一个起始点;从中间开始向左走,直到找到一个空地块 // Find a starting point; start at the middle and walk left until we find an open tile self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 }; let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); while self.map.tiles[start_idx] != TileType::Floor { self.starting_position.x -= 1; start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); } self.take_snapshot(); // 找到所有我们可以从起点到达的地块 // Find all tiles we can reach from the starting point let exit_tile = remove_unreachable_areas_returning_most_distant(&mut self.map, start_idx); self.take_snapshot(); // 放置楼梯 // Place the stairs self.map.tiles[exit_tile] = TileType::DownStairs; self.take_snapshot(); // 现在我们构建一个噪声图,以便稍后在生成实体时使用 // Now we build a noise map for use in spawning entities later self.noise_areas = generate_voronoi_spawn_regions(&self.map, &mut rng); } } }
在 random_builder (map_builders/mod.rs) 中:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { /*let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 7); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), 4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)), 5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)), 6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) }*/ Box::new(MazeBuilder::new(new_depth)) } }
实际构建迷宫
有很多优秀的迷宫构建算法,所有这些算法都保证给你一个完美可解的迷宫。在《独 Knight in the Dungeon》中,我的迷宫构建代码是基于一个相对标准的实现 - Cyucelen 的 mazeGenerator。这是一个有趣的算法,因为 - 像许多迷宫算法一样 - 它假设墙壁是地块网格的一部分,而不是拥有单独的墙壁实体。这不适用于我们正在使用的地块地图类型,因此我们在实际地图一半的分辨率下生成网格,并根据网格中的墙壁邻接信息生成墙壁。
该算法最初是带有到处都是指针的 C++ 代码,并且花了一些时间移植。算法中最基本的结构:Cell。单元格 (Cells) 是地图上的地块:
#![allow(unused)] fn main() { const TOP : usize = 0; const RIGHT : usize = 1; const BOTTOM : usize = 2; const LEFT : usize = 3; #[derive(Copy, Clone)] struct Cell { row: i32, column: i32, walls: [bool; 4], visited: bool, } }
我们定义了四个常量:TOP、RIGHT、BOTTOM 和 LEFT,并将它们分配给数字 0..3。当算法想要引用方向时,我们会使用这些常量。查看 Cell,它相对简单:
row和column定义了单元格 (cell) 在地图上的位置。walls是一个array,为我们定义的每个方向都有一个bool。Rust 数组(静态的,你不能像vector那样调整它们的大小)使用语法[TYPE ; NUMBER_OF_ELEMENTS]定义。大多数时候我们只使用 vectors,因为我们喜欢动态大小调整;在这种情况下,元素的数量是预先知道的,因此使用低开销类型是有意义的。visited- 一个布尔值,指示我们是否之前查看过该单元格 (cell)。
Cell 还定义了一些方法。第一个是它的构造函数:
#![allow(unused)] fn main() { impl Cell { fn new(row: i32, column: i32) -> Cell { Cell{ row, column, walls: [true, true, true, true], visited: false } } ... }
这是一个简单的构造函数:它创建一个在每个方向都有墙壁,且之前未访问过的单元格 (cell)。Cells 还定义了一个名为 remove_walls 的函数:
#![allow(unused)] fn main() { fn remove_walls(&mut self, next : &mut Cell) { let x = self.column - next.column; let y = self.row - next.row; if x == 1 { self.walls[LEFT] = false; next.walls[RIGHT] = false; } else if x == -1 { self.walls[RIGHT] = false; next.walls[LEFT] = false; } else if y == 1 { self.walls[TOP] = false; next.walls[BOTTOM] = false; } else if y == -1 { self.walls[BOTTOM] = false; next.walls[TOP] = false; } } }
哦,哦,这里有一些新东西:
- 我们将
x设置为 我们 的column值,减去下一个单元格 (cell) 的column值。 - 我们对
y做同样的事情 - 但使用row值。 - 如果
x等于 1,那么next的列必须大于我们的列值。换句话说,next单元格 (cell) 位于我们当前位置的右侧。所以我们移除了右侧的墙壁。 - 同样,如果
x是-1,那么我们一定是向左移动 - 所以我们移除了左侧的墙壁。 - 再次,如果
y是1,我们一定是向上移动。所以我们移除了上方的墙壁。 - 最后,如果
y是-1,我们一定是向下移动 - 所以我们移除了下方的墙壁。
呼!Cell 完成了。现在实际使用它。在我们的迷宫算法中,Cell 是 Grid 的一部分。这是 Grid 的基本定义:
#![allow(unused)] fn main() { struct Grid<'a> { width: i32, height: i32, cells: Vec<Cell>, backtrace: Vec<usize>, current: usize, rng : &'a mut RandomNumberGenerator } }
关于 Grid 的一些注释:
<'a>是一个 生命周期 (lifetime) 说明符。我们必须指定一个,以便 Rust 的借用检查器 (borrow checker) 可以确保Grid在我们删除RandomNumberGenerator之前不会过期。因为我们将 可变引用 (mutable reference) 传递给调用者的 RNG,所以 Rust 需要这个来确保 RNG 在我们完成使用之前不会消失。这种类型的错误经常影响 C/C++ 用户,因此 Rust 使其非常难以出错。不幸的是,使其难以出错的代价是一些丑陋的语法!- 我们有定义迷宫大小的
width和height。 - Cells 只是我们之前定义的
Cell类型的Vector。 backtrace由算法用于递归回溯 (back-tracking),以确保每个单元格 (cell) 都已被处理。它只是单元格 (cell) 索引的vector-cellsvector 的索引。current由算法使用,以告知我们当前正在处理哪个Cell。rng是导致丑陋的生命周期 (lifetime) 存在的原因;我们想使用在build函数中构建的随机数生成器,所以我们在这里存储对它的引用。由于获取随机数会更改变量的内容,因此我们必须存储可变引用 (mutable reference)。真正丑陋的&'a mut表明它是一个引用,具有生命周期 (lifetime)'a(如上定义)并且是可变的/可更改的。
Grid 实现了相当多的方法。首先是构造函数:
#![allow(unused)] fn main() { impl<'a> Grid<'a> { fn new(width: i32, height:i32, rng: &mut RandomNumberGenerator) -> Grid { let mut grid = Grid{ width, height, cells: Vec::new(), backtrace: Vec::new(), current: 0, rng }; for row in 0..height { for column in 0..width { grid.cells.push(Cell::new(row, column)); } } grid } ... }
请注意,我们再次不得不为生命周期 (lifetime) 使用一些丑陋的语法!构造函数本身非常简单:它创建一个新的 Grid 结构,其中包含指定的 width 和 height,一个新的单元格 (cells) vector,一个新的(空的)backtrace vector,将 current 设置为 0 并存储随机数生成器引用。然后它迭代网格的行和列,将新的 Cell 结构推送到 cells vector,并按其位置编号。
Grid 还实现了 calculate_index:
#![allow(unused)] fn main() { fn calculate_index(&self, row: i32, column: i32) -> i32 { if row < 0 || column < 0 || column > self.width-1 || row > self.height-1 { -1 } else { column + (row * self.width) } } }
这与我们 map 的 xy_idx 函数非常相似:它接受行和列坐标,并返回可以在其中找到单元格 (cell) 的数组索引。它还进行一些边界检查 (bounds checking),如果坐标无效,则返回 -1。接下来,我们提供 get_available_neighbors:
#![allow(unused)] fn main() { fn get_available_neighbors(&self) -> Vec<usize> { let mut neighbors : Vec<usize> = Vec::new(); let current_row = self.cells[self.current].row; let current_column = self.cells[self.current].column; let neighbor_indices : [i32; 4] = [ self.calculate_index(current_row -1, current_column), self.calculate_index(current_row, current_column + 1), self.calculate_index(current_row + 1, current_column), self.calculate_index(current_row, current_column - 1) ]; for i in neighbor_indices.iter() { if *i != -1 && !self.cells[*i as usize].visited { neighbors.push(*i as usize); } } neighbors } }
此函数提供从 current 单元格 (cell) 可用的出口。它的工作原理是获取当前单元格 (cell) 的 row 和 column 坐标,然后将对 calculate_index 的调用放入一个数组(对应于我们用 Cell 定义的方向)。最后,它迭代该数组,如果值有效(大于 -1),并且我们之前没有去过那里(visited 检查),则将其推送到 neighbors 列表。然后它返回 neighbors。对任何单元格 (cell) 地址的调用都将返回一个 vector,其中列出了我们可以到达的所有相邻单元格 (cells)(忽略墙壁)。我们首先在 find_next_cell 中使用它:
#![allow(unused)] fn main() { fn find_next_cell(&mut self) -> Option<usize> { let neighbors = self.get_available_neighbors(); if !neighbors.is_empty() { if neighbors.len() == 1 { return Some(neighbors[0]); } else { return Some(neighbors[(self.rng.roll_dice(1, neighbors.len() as i32)-1) as usize]); } } None } }
此函数很有趣,因为它返回一个 Option。当前单元格 (cell) 可能无路可走 - 在这种情况下,它返回 None。否则,它返回 Some 以及下一个目的地的数组索引。它的工作原理是:
- 获取当前单元格 (cell) 的邻居列表。
- 如果有邻居:
- 如果只有一个邻居,则返回它。
- 如果有多个邻居,则随机选择一个并返回它。
- 如果没有邻居,则返回
None。
我们在 generate_maze 中使用它:
#![allow(unused)] fn main() { fn generate_maze(&mut self, generator : &mut MazeBuilder) { loop { self.cells[self.current].visited = true; let next = self.find_next_cell(); match next { Some(next) => { self.cells[next].visited = true; self.backtrace.push(self.current); // __lower_part__ __higher_part_ // / \ / \ // --------cell1------ | cell2----------- let (lower_part, higher_part) = self.cells.split_at_mut(std::cmp::max(self.current, next)); let cell1 = &mut lower_part[std::cmp::min(self.current, next)]; let cell2 = &mut higher_part[0]; cell1.remove_walls(cell2); self.current = next; } None => { if !self.backtrace.is_empty() { self.current = self.backtrace[0]; self.backtrace.remove(0); } else { break; } } } self.copy_to_map(&mut generator.map); generator.take_snapshot(); } } }
所以现在我们进入了实际的算法!让我们逐步了解它是如何工作的:
- 我们从一个
loop开始。我们以前没有用过这个(你可以在 这里 阅读有关它们的信息)。基本上,一个loop永远运行 - 直到它遇到break语句。 - 我们将
current单元格 (cell) 中的visited值设置为true。 - 我们将当前单元格 (cell) 添加到
backtrace列表的开头。 - 我们调用
find_next_cell并将其索引设置在变量next中。如果这是我们第一次运行,我们将从起始单元格 (cell) 获得一个随机方向。否则,我们将从我们正在访问的current单元格 (cell) 获得一个出口。 - 如果
next有一个值,那么:- 将 cells 分割为两个可变引用 (mutable references)。我们将需要对同一个切片进行两个可变引用 (mutable references),Rust 通常不允许这样做,但是我们可以将我们的切片分割为两个不重叠的部分。这是一个常见的用例,Rust 提供了安全的函数来完全做到 这一点。
- 从第一部分获取索引较低的单元格 (cell) 的可变引用 (mutable reference),从第二部分的开头获取第二个单元格 (cell) 的可变引用 (mutable reference)。
- 我们在 cell1 单元格 (cell) 上调用
remove_walls,引用 cell2 单元格 (cell)。
- 如果
next没有值(它等于None),我们:- 如果
backtrace不为空,我们将current设置为backtrace列表中的第一个值。 - 如果
backtrace为空,我们就完成了 - 所以我们break跳出循环。
- 如果
- 最后,我们调用
copy_to_map- 它将迷宫复制到地图(稍后会详细介绍),并为迭代地图生成渲染器拍摄快照 (snapshot)。
那么为什么它会起作用呢?
- 前几次迭代将获得一个未访问的邻居,在迷宫中开辟一条清晰的路径。沿途的每一步,我们访问过的单元格 (cell) 都会被添加到
backtrace中。这实际上是在迷宫中醉酒行走,但确保我们无法返回到单元格 (cell)。 - 当我们到达一个没有邻居的点(我们到达迷宫的尽头)时,算法会将
current更改为我们backtrace列表中的第一个条目。然后它将从那里随机行走,填充更多单元格 (cells)。 - 如果那个点无处可去,它会回溯
backtrace列表。 - 这种情况会重复发生,直到每个单元格 (cell) 都被访问过,这意味着
backtrace和neighbors都为空。我们就完成了!
理解这一点的最好方法是观看它的实际操作:
.
最后,还有 copy_to_map 函数:
#![allow(unused)] fn main() { fn copy_to_map(&self, map : &mut Map) { // 清空地图 // Clear the map for i in map.tiles.iter_mut() { *i = TileType::Wall; } for cell in self.cells.iter() { let x = cell.column + 1; let y = cell.row + 1; let idx = map.xy_idx(x * 2, y * 2); map.tiles[idx] = TileType::Floor; if !cell.walls[TOP] { map.tiles[idx - map.width as usize] = TileType::Floor } if !cell.walls[RIGHT] { map.tiles[idx + 1] = TileType::Floor } if !cell.walls[BOTTOM] { map.tiles[idx + map.width as usize] = TileType::Floor } if !cell.walls[LEFT] { map.tiles[idx - 1] = TileType::Floor } } } }
这就是 Grid/Cell 和我们的地图格式之间的不匹配得到解决的地方:迷宫结构中的每个 Cell 都可以在四个主要方向中的任何一个方向上都有墙壁。我们的地图不是那样工作的:墙壁不是地块的一部分,它们是地块。所以我们将 Grid 的大小加倍,并在没有墙壁的地方雕刻地板。让我们逐步了解这个函数:
- 我们将地图中的所有单元格 (cells) 设置为实心墙。
- 对于网格中的每个单元格 (cell),我们执行以下操作:
- 将
x计算为单元格 (cell) 的column值,加一。 - 将
y计算为单元格 (cell) 的row值,加一。 - 将
idx设置为 两倍x和y值的map.xy_idx:因此展开每个单元格 (cell)。 - 我们将
idx处的地图地块设置为地板。 - 如果我们引用的
Cell没有TOP墙壁,我们将idx地块上方的地图地块设置为地板。 - 我们对其他方向重复该操作。
- 将
加速生成器
通过在每次迭代时进行快照 (snapshot),我们浪费了大量时间 - 我们正在构建一个巨大的快照 (snapshot) 地图列表。这对于学习算法来说很棒,但在玩游戏时只是花费的时间太长了。我们将修改我们的 generate_maze 函数来计算迭代次数,并且仅每 10 次记录一次:
#![allow(unused)] fn main() { fn generate_maze(&mut self, generator : &mut MazeBuilder) { let mut i = 0; loop { self.cells[self.current].visited = true; let next = self.find_next_cell(); match next { Some(next) => { self.cells[next].visited = true; self.backtrace.push(self.current); unsafe { let next_cell : *mut Cell = &mut self.cells[next]; let current_cell = &mut self.cells[self.current]; current_cell.remove_walls(next_cell); } self.current = next; } None => { if !self.backtrace.is_empty() { self.current = self.backtrace[0]; self.backtrace.remove(0); } else { break; } } } if i % 50 == 0 { self.copy_to_map(&mut generator.map); generator.take_snapshot(); } i += 1; } } }
这使生成器的速度提高到一个合理的水平,您仍然可以观看迷宫的形成。
找到出口
幸运的是,我们当前的算法将从 Cell (1,1) 开始 - 这对应于地图位置 (2,2)。所以在 build 中,我们可以轻松地指定一个起点:
#![allow(unused)] fn main() { self.starting_position = Position{ x: 2, y : 2 }; let start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); self.take_snapshot(); }
然后我们可以使用我们在最后两个示例中使用的相同代码来找到出口:
#![allow(unused)] fn main() { // 找到所有我们可以从起点到达的地块 // Find all tiles we can reach from the starting point let exit_tile = remove_unreachable_areas_returning_most_distant(&mut self.map, start_idx); self.take_snapshot(); // 放置楼梯 // Place the stairs self.map.tiles[exit_tile] = TileType::DownStairs; self.take_snapshot(); // 现在我们构建一个噪声图,以便稍后在生成实体时使用 // Now we build a noise map for use in spawning entities later self.noise_areas = generate_voronoi_spawn_regions(&self.map, &mut rng); }
这也是对库的 Dijkstra 地图代码的出色测试。它可以非常快速地解决迷宫!
恢复随机性
再一次,我们应该恢复 random_builder 的随机性:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 8); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), 4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)), 5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)), 6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)), 7 => Box::new(MazeBuilder::new(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) } } }
总结 (Wrap-Up)
在本章中,我们构建了一个迷宫。这是一个保证可解的迷宫,因此不存在无法通关的关卡的风险。您仍然必须谨慎使用这种类型的地图:它们可以制作出色的单次地图,并且真的会惹恼玩家!
本章的源代码可以在这里找到
在您的浏览器中使用 Web Assembly 运行本章的示例(需要 WebGL2)
版权 (Copyright) (C) 2019, Herbert Wolverson.
扩散限制聚集
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
扩散限制聚集 (Diffusion-Limited Aggregation, DLA) 是对受限的醉汉漫步 (drunken walk) 的一种更花哨的称呼。它可以生成看起来更自然的地图,更侧重于中心区域和从中延伸出来的“臂状”结构。 通过一些技巧,它可以看起来非常外星化 - 或者非常真实。 请参阅 Rogue Basin 上关于扩散限制聚集的优秀文章。
脚手架
我们将创建一个新文件 map_builders/dla.rs,并将之前项目中的脚手架代码放入其中。 我们将构建器命名为 DLABuilder。 我们还将保留 Voronoi 生成代码,它在此应用中可以正常工作。 我们将直接进入正题,而不是重复之前章节中的脚手架代码块。 如果您遇到困难,可以查看本章的源代码 这里。
算法调整旋钮 (Algorithm Tuning Knobs)
在上一章中,我们介绍了向构建器添加参数的想法。 我们将再次为 DLA 执行相同的操作 - 有一些算法变体可以产生不同的地图样式。 我们将引入以下枚举:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum DLAAlgorithm { WalkInwards, WalkOutwards, CentralAttractor } #[derive(PartialEq, Copy, Clone)] pub enum DLASymmetry { None, Horizontal, Vertical, Both } }
我们的构建器将再包含一个,笔刷大小 (brush size):
#![allow(unused)] fn main() { pub struct DLABuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, noise_areas : HashMap<i32, Vec<usize>>, algorithm : DLAAlgorithm, brush_size: i32, symmetry: DLASymmetry, floor_percent: f32 } }
如果您已经阅读过其他章节,那么现在这应该是不言自明的:
- 我们支持三种算法,
WalkInwards、WalkOutwards、CentralAttractor。 我们将在稍后详细介绍这些算法。 - 我们添加了
symmetry,它可以是None、Horizontal、Vertical或Both。 对称性可以用于使用此算法生成一些精美的结果,我们将在本文后面介绍。 - 我们还添加了
brush_size,它指定我们一次在地图上“绘制”多少个地板瓦片 (floor tiles)。 我们将在本章末尾介绍这一点。 - 我们包含了来自醉汉漫步章节的
floor_percent。
我们的 new 函数需要包含这些参数:
#![allow(unused)] fn main() { pub fn new(new_depth : i32) -> DLABuilder { DLABuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), algorithm: DLAAlgorithm::WalkInwards, brush_size: 1, symmetry: DLASymmetry::None, floor_percent: 0.25 } } }
一旦我们掌握了算法及其变体,我们将制作一些类型构造器 (type constructors)!
向内行走 (Walking Inwards)
扩散限制聚集 (Diffusion-Limited Aggregation) 最基本的形式如下:
- 在您的中心起始点周围挖掘一个“种子 (seed)”区域。
- 当地板瓦片 (floor tiles) 的数量少于您期望的总数时:
- 为您的挖掘者随机选择一个起始点。
- 使用“醉汉漫步 (drunkard's walk)”算法随机移动。
- 如果挖掘者撞击到地板瓦片 (floor tile),则他们之前所在的瓦片也会变成地板,并且挖掘者停止。
非常简单,并且不太难实现:
#![allow(unused)] fn main() { fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); // 雕刻一个起始种子 (Carve a starting seed) self.starting_position = Position{ x: self.map.width/2, y : self.map.height/2 }; let start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); self.take_snapshot(); self.map.tiles[start_idx] = TileType::Floor; self.map.tiles[start_idx-1] = TileType::Floor; self.map.tiles[start_idx+1] = TileType::Floor; self.map.tiles[start_idx-self.map.width as usize] = TileType::Floor; self.map.tiles[start_idx+self.map.width as usize] = TileType::Floor; // 随机行走者 (Random walker) let total_tiles = self.map.width * self.map.height; let desired_floor_tiles = (self.floor_percent * total_tiles as f32) as usize; let mut floor_tile_count = self.map.tiles.iter().filter(|a| **a == TileType::Floor).count(); while floor_tile_count < desired_floor_tiles { match self.algorithm { DLAAlgorithm::WalkInwards => { let mut digger_x = rng.roll_dice(1, self.map.width - 3) + 1; let mut digger_y = rng.roll_dice(1, self.map.height - 3) + 1; let mut prev_x = digger_x; let mut prev_y = digger_y; let mut digger_idx = self.map.xy_idx(digger_x, digger_y); while self.map.tiles[digger_idx] == TileType::Wall { prev_x = digger_x; prev_y = digger_y; let stagger_direction = rng.roll_dice(1, 4); match stagger_direction { 1 => { if digger_x > 2 { digger_x -= 1; } } 2 => { if digger_x < self.map.width-2 { digger_x += 1; } } 3 => { if digger_y > 2 { digger_y -=1; } } _ => { if digger_y < self.map.height-2 { digger_y += 1; } } } digger_idx = self.map.xy_idx(digger_x, digger_y); } self.paint(prev_x, prev_y); } _ => {} ... }
这里唯一的新内容是对 paint 的调用。 我们稍后将对其进行扩展(以处理笔刷大小 (brush sizes)),但这是一个临时实现:
#![allow(unused)] fn main() { fn paint(&mut self, x: i32, y: i32) { let digger_idx = self.map.xy_idx(x, y); self.map.tiles[digger_idx] = TileType::Floor; } }
如果您 cargo run 运行此代码,您将获得一个非常酷的地牢:
.
向外行走 (Walking outwards)
此算法的第二个变体颠倒了部分过程:
- 在您的中心起始点周围挖掘一个“种子 (seed)”区域。
- 当地板瓦片 (floor tiles) 的数量少于您期望的总数时:
- 将挖掘者设置为中心起始位置。
- 使用“醉汉漫步 (drunkard's walk)”算法随机移动。
- 如果挖掘者撞击到墙壁瓦片 (wall tile),则该瓦片变为地板 - 并且挖掘者停止。
因此,我们勇敢的挖掘者不是向内行进,而是向外行进。 实现这一点非常简单,并且可以添加到 build 中算法的 match 序列中:
#![allow(unused)] fn main() { ... DLAAlgorithm::WalkOutwards => { let mut digger_x = self.starting_position.x; let mut digger_y = self.starting_position.y; let mut digger_idx = self.map.xy_idx(digger_x, digger_y); while self.map.tiles[digger_idx] == TileType::Floor { let stagger_direction = rng.roll_dice(1, 4); match stagger_direction { 1 => { if digger_x > 2 { digger_x -= 1; } } 2 => { if digger_x < self.map.width-2 { digger_x += 1; } } 3 => { if digger_y > 2 { digger_y -=1; } } _ => { if digger_y < self.map.height-2 { digger_y += 1; } } } digger_idx = self.map.xy_idx(digger_x, digger_y); } self.paint(digger_x, digger_y); } _ => {} }
这段代码中没有任何新概念,如果您理解了醉汉漫步 (Drunkard's Walk) - 它应该是相当不言自明的。 如果您调整构造器以使用它,并调用 cargo run 运行它,它看起来会很不错:
.
中心吸引子 (Central Attractor)
此变体再次非常相似,但略有不同。 您的粒子不是随机移动,而是从随机点向中心路径移动:
- 在您的中心起始点周围挖掘一个“种子 (seed)”区域。
- 当地板瓦片 (floor tiles) 的数量少于您期望的总数时:
- 为您的挖掘者随机选择一个起始点。
- 绘制一条到地图中心的线,并保留它。
- 遍历该线。 如果挖掘者撞击到地板瓦片 (floor tile),则他们之前所在的瓦片也会变成地板,并且挖掘者停止。
同样,这相对容易实现:
#![allow(unused)] fn main() { ... DLAAlgorithm::CentralAttractor => { let mut digger_x = rng.roll_dice(1, self.map.width - 3) + 1; let mut digger_y = rng.roll_dice(1, self.map.height - 3) + 1; let mut prev_x = digger_x; let mut prev_y = digger_y; let mut digger_idx = self.map.xy_idx(digger_x, digger_y); let mut path = rltk::line2d( rltk::LineAlg::Bresenham, rltk::Point::new( digger_x, digger_y ), rltk::Point::new( self.starting_position.x, self.starting_position.y ) ); while self.map.tiles[digger_idx] == TileType::Wall && !path.is_empty() { prev_x = digger_x; prev_y = digger_y; digger_x = path[0].x; digger_y = path[0].y; path.remove(0); digger_idx = self.map.xy_idx(digger_x, digger_y); } self.paint(prev_x, prev_y); } }
如果您调整构造器以使用此算法,并 cargo run 运行该项目,您将获得一个更集中于中心点的地图:
.
实现对称性 (Implementing Symmetry)
Tyger Tyger, burning bright,
In the forests of the night;
What immortal hand or eye,
Could frame thy fearful symmetry?
(威廉·布莱克,《老虎》) (William Blake, The Tyger)
对称性可以将随机地图转变为看起来像是设计出来的东西 - 但非常外星化。 它通常看起来很像昆虫或让人想起《太空侵略者 (Space Invaders)》的敌人。 这可以制作一些看起来很有趣的关卡!
让我们修改 paint 函数以处理对称性:
#![allow(unused)] fn main() { fn paint(&mut self, x: i32, y:i32) { match self.symmetry { DLASymmetry::None => self.apply_paint(x, y), DLASymmetry::Horizontal => { let center_x = self.map.width / 2; if x == center_x { self.apply_paint(x, y); } else { let dist_x = i32::abs(center_x - x); self.apply_paint(center_x + dist_x, y); self.apply_paint(center_x - dist_x, y); } } DLASymmetry::Vertical => { let center_y = self.map.height / 2; if y == center_y { self.apply_paint(x, y); } else { let dist_y = i32::abs(center_y - y); self.apply_paint(x, center_y + dist_y); self.apply_paint(x, center_y - dist_y); } } DLASymmetry::Both => { let center_x = self.map.width / 2; let center_y = self.map.height / 2; if x == center_x && y == center_y { self.apply_paint(x, y); } else { let dist_x = i32::abs(center_x - x); self.apply_paint(center_x + dist_x, y); self.apply_paint(center_x - dist_x, y); let dist_y = i32::abs(center_y - y); self.apply_paint(x, center_y + dist_y); self.apply_paint(x, center_y - dist_y); } } } }
为了清晰起见,这个函数比实际需要的要长。 这是它的工作原理:
- 我们
match当前的对称性设置。 - 如果是
None,我们只需使用目标瓦片调用apply_paint。 - 如果是
Horizontal:- 我们检查是否在瓦片上 - 如果是,则只应用一次绘制。
- 否则,获取到中心的水平距离。
- 在
center_x - distance和center_x + distance处绘制,以在x轴上对称绘制。
- 如果是
Vertical:- 我们检查是否在瓦片上 - 如果是,则只应用一次绘制(这有助于处理奇数个瓦片,从而减少舍入问题)。
- 否则,获取到中心的垂直距离。
- 在
center_y - distance和center_y + distance处绘制。
- 如果是
Both- 则执行两个步骤。
您会注意到我们正在调用 apply_paint 而不是实际绘制。 这是因为我们还实现了 brush_size:
#![allow(unused)] fn main() { fn apply_paint(&mut self, x: i32, y: i32) { match self.brush_size { 1 => { let digger_idx = self.map.xy_idx(x, y); self.map.tiles[digger_idx] = TileType::Floor; } _ => { let half_brush_size = self.brush_size / 2; for brush_y in y-half_brush_size .. y+half_brush_size { for brush_x in x-half_brush_size .. x+half_brush_size { if brush_x > 1 && brush_x < self.map.width-1 && brush_y > 1 && brush_y < self.map.height-1 { let idx = self.map.xy_idx(brush_x, brush_y); self.map.tiles[idx] = TileType::Floor; } } } } } } }
这很简单:
- 如果笔刷大小 (brush size) 为 1,我们只需绘制一个地板瓦片 (floor tile)。
- 否则,我们循环遍历笔刷大小 (brush size) - 并进行绘制,执行边界检查以确保我们没有在地图外绘制。
在您的构造器中,使用 CentralAttractor 算法 - 并使用 Horizontal 启用对称性。 如果您现在 cargo run 运行,您将获得一张与脾气暴躁的昆虫非常相似的地图:
.
玩转笔刷大小 (Playing with Brush Sizes)
使用更大的笔刷 (brush) 可确保您不会获得太多 1x1 区域(这些区域可能难以导航),并使地图看起来更具规划性。 既然我们已经实现了笔刷大小 (brush size),请像这样修改您的构造器:
#![allow(unused)] fn main() { pub fn new(new_depth : i32) -> DLABuilder { DLABuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), algorithm: DLAAlgorithm::WalkInwards, brush_size: 2, symmetry: DLASymmetry::None, floor_percent: 0.25 } } }
通过这个简单的更改,我们的地图看起来更加开阔:
.
提供一些构造器 (Providing a few constructors)
与其使用算法细节污染 random_builder 函数,不如为我们在本章中使用的每个主要算法制作构造器 (constructors):
#![allow(unused)] fn main() { pub fn walk_inwards(new_depth : i32) -> DLABuilder { DLABuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), algorithm: DLAAlgorithm::WalkInwards, brush_size: 1, symmetry: DLASymmetry::None, floor_percent: 0.25 } } pub fn walk_outwards(new_depth : i32) -> DLABuilder { DLABuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), algorithm: DLAAlgorithm::WalkOutwards, brush_size: 2, symmetry: DLASymmetry::None, floor_percent: 0.25 } } pub fn central_attractor(new_depth : i32) -> DLABuilder { DLABuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), algorithm: DLAAlgorithm::CentralAttractor, brush_size: 2, symmetry: DLASymmetry::None, floor_percent: 0.25 } } pub fn insectoid(new_depth : i32) -> DLABuilder { DLABuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), algorithm: DLAAlgorithm::CentralAttractor, brush_size: 2, symmetry: DLASymmetry::Horizontal, floor_percent: 0.25 } } }
再次随机化地图构建器 (Randomizing the map builder, once again)
现在我们可以修改 map_builders/mod.rs 中的 random_builder 以再次真正随机化 - 并提供更多类型的地图!
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 12); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), 4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)), 5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)), 6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)), 7 => Box::new(MazeBuilder::new(new_depth)), 8 => Box::new(DLABuilder::walk_inwards(new_depth)), 9 => Box::new(DLABuilder::walk_outwards(new_depth)), 10 => Box::new(DLABuilder::central_attractor(new_depth)), 11 => Box::new(DLABuilder::insectoid(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) } } }
总结 (Wrap-up)
本章介绍了另一种非常灵活的地图构建器 (map builder),供您使用。 非常适合制作感觉像是从岩石中雕刻出来的地图(或从森林中砍伐、从小行星中开采等),这是为您的游戏引入多样性的另一种绝佳方式。
本章的源代码可以在 这里 找到
在您的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
添加对称和笔刷大小作为库函数
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在上一章关于扩散限制聚集(Diffusion-Limited Aggregation)的内容中,我们为地图构建引入了两个新概念:对称性(symmetry) 和 笔刷大小(brush size)。这些概念很容易应用于其他算法,因此我们将花一些时间将它们移入库函数(在 map_builders/common.rs 中),使它们更通用,并演示它们如何改变醉汉步法(Drunkard's Walk)。
构建库版本
我们将从将 DLASymmetry 枚举从 dla.rs 中移到 common.rs 中开始。我们还将更改其名称,因为它不再绑定到特定的算法:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum Symmetry { None, Horizontal, Vertical, Both } }
在 common.rs 的末尾,我们可以添加以下内容:
#![allow(unused)] fn main() { pub fn paint(map: &mut Map, mode: Symmetry, brush_size: i32, x: i32, y:i32) { match mode { Symmetry::None => apply_paint(map, brush_size, x, y), Symmetry::Horizontal => { let center_x = map.width / 2; if x == center_x { apply_paint(map, brush_size, x, y); } else { let dist_x = i32::abs(center_x - x); apply_paint(map, brush_size, center_x + dist_x, y); apply_paint(map, brush_size, center_x - dist_x, y); } } Symmetry::Vertical => { let center_y = map.height / 2; if y == center_y { apply_paint(map, brush_size, x, y); } else { let dist_y = i32::abs(center_y - y); apply_paint(map, brush_size, x, center_y + dist_y); apply_paint(map, brush_size, x, center_y - dist_y); } } Symmetry::Both => { let center_x = map.width / 2; let center_y = map.height / 2; if x == center_x && y == center_y { apply_paint(map, brush_size, x, y); } else { let dist_x = i32::abs(center_x - x); apply_paint(map, brush_size, center_x + dist_x, y); apply_paint(map, brush_size, center_x - dist_x, y); let dist_y = i32::abs(center_y - y); apply_paint(map, brush_size, x, center_y + dist_y); apply_paint(map, brush_size, x, center_y - dist_y); } } } } fn apply_paint(map: &mut Map, brush_size: i32, x: i32, y: i32) { match brush_size { 1 => { let digger_idx = map.xy_idx(x, y); map.tiles[digger_idx] = TileType::Floor; } _ => { let half_brush_size = brush_size / 2; for brush_y in y-half_brush_size .. y+half_brush_size { for brush_x in x-half_brush_size .. x+half_brush_size { if brush_x > 1 && brush_x < map.width-1 && brush_y > 1 && brush_y < map.height-1 { let idx = map.xy_idx(brush_x, brush_y); map.tiles[idx] = TileType::Floor; } } } } } } }
这应该不会让人感到惊讶:这与我们在 dla.rs 中拥有的代码完全相同 - 只是移除了 &mut self,而是接受参数。
修改 dla.rs 以使用它
修改 dla.rs 以使用它相对简单。将所有 DLASymmetry 引用替换为 Symmetry。将所有对 self.paint(x, y) 的调用替换为 paint(&mut self.map, self.symmetry, self.brush_size, x, y);。您可以查看源代码以查看更改 - 无需在此处重复所有更改。 确保也在顶部的包含函数列表中包含 paint 和 Symmetry。
像许多重构一样,结果好坏的检验标准是,如果您 cargo run 您的代码 - 没有任何变化!我们不会再费心用截图来展示它和上次一样!
修改醉汉步法(Drunkard's Walk)以使用它
我们将首先修改 DrunkardSettings 结构体以接受这两个新特性:
#![allow(unused)] fn main() { pub struct DrunkardSettings { pub spawn_mode : DrunkSpawnMode, pub drunken_lifetime : i32, pub floor_percent: f32, pub brush_size: i32, pub symmetry: Symmetry } }
编译器会抱怨我们没有在构造函数中设置这些值,因此我们将添加一些默认值:
#![allow(unused)] fn main() { pub fn open_area(new_depth : i32) -> DrunkardsWalkBuilder { DrunkardsWalkBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), settings : DrunkardSettings{ spawn_mode: DrunkSpawnMode::StartingPoint, drunken_lifetime: 400, floor_percent: 0.5, brush_size: 1, symmetry: Symmetry::None } } } }
我们需要对其他构造函数进行类似的更改 - 只是将 brush_size 和 symmetry 添加到每个 DrunkardSettings 构建器中。
我们还需要替换以下行:
#![allow(unused)] fn main() { self.map.tiles[drunk_idx] = TileType::DownStairs; }
替换为:
#![allow(unused)] fn main() { paint(&mut self.map, self.settings.symmetry, self.settings.brush_size, drunk_x, drunk_y); self.map.tiles[drunk_idx] = TileType::DownStairs; }
双重绘制保留了添加 > 符号以显示步行者路径的功能,同时保留了绘制函数的过度绘制。
制作更宽阔的通道醉汉步法(drunk)
为了测试这一点,我们将在 drunkard.rs 中添加一个新的构造函数:
#![allow(unused)] fn main() { pub fn fat_passages(new_depth : i32) -> DrunkardsWalkBuilder { DrunkardsWalkBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), settings : DrunkardSettings{ spawn_mode: DrunkSpawnMode::Random, drunken_lifetime: 100, floor_percent: 0.4, brush_size: 2, symmetry: Symmetry::None } } } }
我们还将快速修改 map_builders/mod.rs 中的 random_builder 以展示这个:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { /*let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 12); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), 4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)), 5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)), 6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)), 7 => Box::new(MazeBuilder::new(new_depth)), 8 => Box::new(DLABuilder::walk_inwards(new_depth)), 9 => Box::new(DLABuilder::walk_outwards(new_depth)), 10 => Box::new(DLABuilder::central_attractor(new_depth)), 11 => Box::new(DLABuilder::insectoid(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) }*/ Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)) } }
这显示了地图生成中的即时变化:
。
请注意,“更胖”的挖掘区域如何提供更开放的大厅。它的运行时间也缩短了一半,因为我们更快地耗尽了所需的地面数量。
添加对称性(Symmetry)
与 DLA 类似,对称的醉汉步法(drunkards)可以制作出看起来有趣的地图。我们将再添加一个构造函数:
#![allow(unused)] fn main() { pub fn fearful_symmetry(new_depth : i32) -> DrunkardsWalkBuilder { DrunkardsWalkBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), settings : DrunkardSettings{ spawn_mode: DrunkSpawnMode::Random, drunken_lifetime: 100, floor_percent: 0.4, brush_size: 1, symmetry: Symmetry::Both } } } }
我们还修改了 random_builder 函数以使用它:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { /*let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 12); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), 4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)), 5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)), 6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)), 7 => Box::new(MazeBuilder::new(new_depth)), 8 => Box::new(DLABuilder::walk_inwards(new_depth)), 9 => Box::new(DLABuilder::walk_outwards(new_depth)), 10 => Box::new(DLABuilder::central_attractor(new_depth)), 11 => Box::new(DLABuilder::insectoid(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) }*/ Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)) } }
cargo run 将渲染出类似这样的结果:
。
请注意对称性(symmetry)是如何应用的(非常快 - 我们现在正在快速生成地面瓷砖!) - 然后剔除无法到达的区域,去除了地图的一部分。这是一个非常不错的地图!
再次恢复随机性
再一次,我们将新的算法添加到 map_builders/mod.rs 中的 random_builder 函数中:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 14); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), 4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)), 5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)), 6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)), 7 => Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)), 8 => Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)), 9 => Box::new(MazeBuilder::new(new_depth)), 10 => Box::new(DLABuilder::walk_inwards(new_depth)), 11 => Box::new(DLABuilder::walk_outwards(new_depth)), 12 => Box::new(DLABuilder::central_attractor(new_depth)), 13 => Box::new(DLABuilder::insectoid(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) } } }
现在我们已经有 14 种算法了!我们拥有越来越多样化的游戏!
总结
本章演示了游戏程序员的一个非常有用的工具:找到一个方便的算法,使其通用化,并在代码的其他部分中使用它。 准确地猜测您预先需要什么是很罕见的(并且对于“您不需要它”有很多话要说 - 在您确实需要它们时才实现功能),因此能够快速重构我们的代码以进行重用是我们武器库中的宝贵武器。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
Voronoi 蜂巢/单元格地图
关于本教程
本教程是免费和开源的,所有代码都使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
我们之前在生成点放置中接触过 Voronoi 图。在本节中,我们将使用它们来制作地图。该算法基本上将地图细分为多个区域,并在它们之间放置墙壁。结果有点像蜂巢。您可以调整距离/邻接算法来调整结果。
脚手架
我们将像之前的章节一样制作脚手架,在 voronoi.rs 中创建 VoronoiBuilder 结构。我们还将调整 random_builder 函数,使其目前只返回 VoronoiBuilder。
构建 Voronoi 图
在之前的用法中,我们略过了如何实际制作 Voronoi 图 - 并依赖于 rltk 库中的 FastNoise 库。这当然很好,但它并没有真正向我们展示它是 如何 工作的 - 并且提供了非常有限的调整机会。所以 - 我们将自己制作一个。
制作 Voronoi 噪声的第一步是填充一组“种子”。这些是在地图上随机选择(但不是重复)的点。我们将使种子的数量成为一个变量,以便稍后可以对其进行调整。这是代码:
#![allow(unused)] fn main() { let n_seeds = 64; let mut voronoi_seeds : Vec<(usize, rltk::Point)> = Vec::new(); while voronoi_seeds.len() < n_seeds { let vx = rng.roll_dice(1, self.map.width-1); let vy = rng.roll_dice(1, self.map.height-1); let vidx = self.map.xy_idx(vx, vy); let candidate = (vidx, rltk::Point::new(vx, vy)); if !voronoi_seeds.contains(&candidate) { voronoi_seeds.push(candidate); } } }
这创建了一个 vector,每个条目包含一个 tuple。在该 tuple 中,我们存储了地图位置的索引,以及包含 x 和 y 坐标的 Point (如果我们愿意,可以跳过保存这些并从索引计算,但我认为这样更清晰)。然后我们随机确定一个位置,检查以确保我们尚未滚动到该位置,并将其添加。我们重复此过程,直到获得所需数量的种子。64 相当多,但会产生相对密集的蜂巢状结构。
下一步是确定每个单元格的 Voronoi 成员资格:
#![allow(unused)] fn main() { let mut voronoi_distance = vec![(0, 0.0f32) ; n_seeds]; let mut voronoi_membership : Vec<i32> = vec![0 ; self.map.width as usize * self.map.height as usize]; for (i, vid) in voronoi_membership.iter_mut().enumerate() { let x = i as i32 % self.map.width; let y = i as i32 / self.map.width; for (seed, pos) in voronoi_seeds.iter().enumerate() { let distance = rltk::DistanceAlg::PythagorasSquared.distance2d( rltk::Point::new(x, y), pos.1 ); voronoi_distance[seed] = (seed, distance); } voronoi_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); *vid = voronoi_distance[0].0 as i32; } }
在这段代码块中,我们:
- 创建一个新的
vector,名为voronoi_distance。它包含usize和f32(浮点数) 的元组,并预先创建了n_seeds个条目。我们可以为每次迭代都创建它,但是重用同一个会快得多。我们将其创建为零。 - 我们创建一个新的
voronoi_membership向量,其中包含地图上每个瓦片的一个条目。我们将它们全部设置为 0。我们将使用它来存储瓦片属于哪个 Voronoi 单元格。 - 对于
voronoi_membership中的每个瓦片,我们获得一个枚举器(索引号)和值。我们可变地拥有它,因此我们可以进行更改。- 我们从枚举器 (
i) 计算瓦片的x和y位置。 - 对于
voronoi_seeds结构中的每个条目,我们获得索引(通过enumerate())和位置元组。- 我们使用
PythagorasSquared算法计算从种子到当前瓦片的距离。 - 我们将
voronoi_distance[seed]设置为种子索引和距离。
- 我们使用
- 我们按距离对
voronoi_distance向量进行排序,因此最近的种子将是第一个条目。 - 我们将瓦片的
vid(Voronoi ID) 设置为voronoi_distance列表中的第一个条目。
- 我们从枚举器 (
您可以用更简单的英语总结一下:每个瓦片都被赋予了 Voronoi 组的成员资格,该组的种子在物理上离它最近。
接下来,我们使用它来绘制地图:
#![allow(unused)] fn main() { for y in 1..self.map.height-1 { for x in 1..self.map.width-1 { let mut neighbors = 0; let my_idx = self.map.xy_idx(x, y); let my_seed = voronoi_membership[my_idx]; if voronoi_membership[self.map.xy_idx(x-1, y)] != my_seed { neighbors += 1; } if voronoi_membership[self.map.xy_idx(x+1, y)] != my_seed { neighbors += 1; } if voronoi_membership[self.map.xy_idx(x, y-1)] != my_seed { neighbors += 1; } if voronoi_membership[self.map.xy_idx(x, y+1)] != my_seed { neighbors += 1; } if neighbors < 2 { self.map.tiles[my_idx] = TileType::Floor; } } self.take_snapshot(); } }
在这段代码中,我们访问除了最外边缘之外的每个瓦片。我们计算有多少相邻的瓦片属于 不同的 Voronoi 组。如果答案是 0,则它完全在该组中:因此我们可以放置地板。如果答案是 1,则它仅与 1 个其他组接壤 - 因此我们也可以放置地板(以确保我们可以在地图上行走)。否则,我们将瓦片保留为墙壁。
然后,我们运行与之前使用过的相同的剔除和放置代码。如果您现在 cargo run 该项目,您将看到一个令人愉悦的结构:
.
调整蜂巢
有两个明显的变量可以暴露给构建器:种子的数量,以及要使用的距离算法。我们将更新结构签名以包含这些:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum DistanceAlgorithm { Pythagoras, Manhattan, Chebyshev } pub struct VoronoiCellBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, noise_areas : HashMap<i32, Vec<usize>>, n_seeds: usize, distance_algorithm: DistanceAlgorithm } }
然后我们将更新 Voronoi 代码以使用它们:
#![allow(unused)] fn main() { fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); // Make a Voronoi diagram. We'll do this the hard way to learn about the technique! // 制作 Voronoi 图。我们将用比较复杂的方式来做,以了解这项技术! let mut voronoi_seeds : Vec<(usize, rltk::Point)> = Vec::new(); while voronoi_seeds.len() < self.n_seeds { let vx = rng.roll_dice(1, self.map.width-1); let vy = rng.roll_dice(1, self.map.height-1); let vidx = self.map.xy_idx(vx, vy); let candidate = (vidx, rltk::Point::new(vx, vy)); if !voronoi_seeds.contains(&candidate) { voronoi_seeds.push(candidate); } } let mut voronoi_distance = vec![(0, 0.0f32) ; self.n_seeds]; let mut voronoi_membership : Vec<i32> = vec![0 ; self.map.width as usize * self.map.height as usize]; for (i, vid) in voronoi_membership.iter_mut().enumerate() { let x = i as i32 % self.map.width; let y = i as i32 / self.map.width; for (seed, pos) in voronoi_seeds.iter().enumerate() { let distance; match self.distance_algorithm { DistanceAlgorithm::Pythagoras => { distance = rltk::DistanceAlg::PythagorasSquared.distance2d( rltk::Point::new(x, y), pos.1 ); } DistanceAlgorithm::Manhattan => { distance = rltk::DistanceAlg::Manhattan.distance2d( rltk::Point::new(x, y), pos.1 ); } DistanceAlgorithm::Chebyshev => { distance = rltk::DistanceAlg::Chebyshev.distance2d( rltk::Point::new(x, y), pos.1 ); } } voronoi_distance[seed] = (seed, distance); } voronoi_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); *vid = voronoi_distance[0].0 as i32; } for y in 1..self.map.height-1 { for x in 1..self.map.width-1 { let mut neighbors = 0; let my_idx = self.map.xy_idx(x, y); let my_seed = voronoi_membership[my_idx]; if voronoi_membership[self.map.xy_idx(x-1, y)] != my_seed { neighbors += 1; } if voronoi_membership[self.map.xy_idx(x+1, y)] != my_seed { neighbors += 1; } if voronoi_membership[self.map.xy_idx(x, y-1)] != my_seed { neighbors += 1; } if voronoi_membership[self.map.xy_idx(x, y+1)] != my_seed { neighbors += 1; } if neighbors < 2 { self.map.tiles[my_idx] = TileType::Floor; } } self.take_snapshot(); } ... }
作为一个测试,让我们更改构造函数以使用 Manhattan 距离。结果将如下所示:
.
请注意线条是如何更直,更少有机感的。这就是 Manhattan 距离的作用:它像曼哈顿出租车司机一样计算距离 - 行数加列数,而不是直线距离。
恢复随机性
因此,我们将为每种噪声类型放入几个构造函数:
#![allow(unused)] fn main() { pub fn pythagoras(new_depth : i32) -> VoronoiCellBuilder { VoronoiCellBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec<Map>, noise_areas : HashMap::new(), n_seeds: 64, distance_algorithm: DistanceAlgorithm::Pythagoras } } pub fn manhattan(new_depth : i32) -> VoronoiCellBuilder { VoronoiCellBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec<Map>, noise_areas : HashMap::new(), n_seeds: 64, distance_algorithm: DistanceAlgorithm::Manhattan } } }
然后我们将恢复 random_builder,使其再次随机:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 16); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), 4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)), 5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)), 6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)), 7 => Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)), 8 => Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)), 9 => Box::new(MazeBuilder::new(new_depth)), 10 => Box::new(DLABuilder::walk_inwards(new_depth)), 11 => Box::new(DLABuilder::walk_outwards(new_depth)), 12 => Box::new(DLABuilder::central_attractor(new_depth)), 13 => Box::new(DLABuilder::insectoid(new_depth)), 14 => Box::new(VoronoiCellBuilder::pythagoras(new_depth)), 15 => Box::new(VoronoiCellBuilder::manhattan(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) } } }
总结
这又是我们掌握的一种算法!我们现在真的有足够的能力编写一个相当不错的 roguelike 游戏了,但还有更多内容!
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson。
波函数坍缩
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持 我的 Patreon。
几年前,波函数坍缩 (WFC) 在程序化生成领域引起了轰动。 它看似神奇,可以输入图像 - 并生成类似的图像。 演示表明它可以生成外观精美的游戏关卡,而令人惊叹的《Qud 的洞穴》开始使用它来生成有趣的关卡。 规范的演示 - 以及最初的 C# 算法和各种解释链接/移植版本 - 可以在这里找到。
在本章中,我们将从头开始实现波函数坍缩 - 并将其应用于制作有趣的 Roguelike 关卡。 请注意,有一个包含原始算法的 crate 可用(wfc,以及 wfc-image); 在测试中它看起来相当不错,但我在使其与 WebAssembly 协同工作时遇到了问题。 我也不认为仅仅说“导入这个”就能真正教会算法。 这是一个较长的章节,但到最后您应该对该算法感到满意。
WFC 真正做了什么?
波函数坍缩与我们到目前为止使用的地图生成算法不同,因为它实际上并不制作地图。 它接收源数据(我们将使用其他地图!),扫描它们,并构建一个以完全来自源数据的元素为特征的新地图。 它分几个阶段运行:
- 它读取传入的数据。 在最初的实现中,这是一个 PNG 文件。 在我们的实现中,这是一个像我们之前使用过的
Map结构; 我们还将实现一个 REX Paint 读取器来加载地图。 - 它将源图像划分为“瓦片”(tiles),并且可以选择通过沿一个或两个轴镜像读取的瓦片来制作更多瓦片。
- 它加载或构建一个“约束”图。 这是一组规则,指定哪些瓦片可以彼此相邻。 在图像中,这可以从瓦片邻接关系中推导出来。 在 Roguelike 地图中,出口的连通性是一个很好的指标。 对于基于瓦片的游戏,您可以仔细构建一个布局,说明什么可以放在哪里。
- 然后,它将输出图像划分为瓦片大小的块,并将它们全部设置为“空”。 第一个放置的瓦片将非常随机,然后它选择区域并检查已经已知的瓦片数据 - 放置与已存在瓦片兼容的瓦片。 最终,它放置了所有瓦片 - 您就得到了一张地图/图像!
“波函数坍缩”这个名称指的是量子物理学的思想,即粒子在您观察它之前可能实际上没有状态。 在算法中,瓦片在您选择一个进行检查之前,实际上并不会合并成存在。 因此,与量子物理学有轻微的相似之处。 然而,实际上 - 这个名字是营销的胜利。 该算法被称为 求解器 - 给定一组约束,它迭代可能的解决方案,直到约束被解决。 这不是一个新概念 - Prolog 是一种完全基于这个思想的编程语言,它于 1972 年首次问世。 所以在某种程度上,它比我还老!
入门:Rust 对复杂模块的支持
我们之前的所有算法都足够小,可以放入一个源代码文件中,而无需太多翻页来查找相关的代码片段。 波函数坍缩足够复杂,值得分解成多个文件 - 与 map_builders 模块分解为 module 的方式非常相似 - WFC 将被划分为它自己的 module。 该模块仍然存在于 map_builders 内部 - 所以在某种程度上它实际上是一个子模块。
Rust 使分解任何模块为多个文件变得非常容易:您在父模块内部创建一个目录,并在其中放置一个名为 mod.rs 的文件。 然后您可以在文件夹中放置更多文件,只要您启用它们(使用 mod myfile)并使用内容(使用 use myfile::MyElement),它的工作方式就像单个文件一样。
因此,要开始,在您的 map_builders 目录中 - 创建一个名为 waveform_collapse 的新目录。 在其中添加一个文件 mod.rs。 您应该有一个如下所示的源代码树:
\ src
\ map_builders
\ waveform_collapse
+ mod.rs
bsp_dungeon.rs
(等等)
main.rs
(等等)
我们将使用类似于之前章节的骨架实现来填充 mod.rs:
#![allow(unused)] fn main() { use super::{MapBuilder, Map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER, generate_voronoi_spawn_regions, remove_unreachable_areas_returning_most_distant}; use rltk::RandomNumberGenerator; use specs::prelude::*; pub struct WaveformCollapseBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, noise_areas : HashMap<i32, Vec<usize>> } impl MapBuilder for WaveformCollapseBuilder { fn get_map(&self) -> Map { self.map.clone() } fn get_starting_position(&self) -> Position { self.starting_position.clone() } fn get_snapshot_history(&self) -> Vec<Map> { self.history.clone() } fn build_map(&mut self) { self.build(); } fn spawn_entities(&mut self, ecs : &mut World) { for area in self.noise_areas.iter() { spawner::spawn_region(ecs, area.1, self.depth); } } fn take_snapshot(&mut self) { if SHOW_MAPGEN_VISUALIZER { let mut snapshot = self.map.clone(); for v in snapshot.revealed_tiles.iter_mut() { *v = true; } self.history.push(snapshot); } } } impl WaveformCollapseBuilder { pub fn new(new_depth : i32) -> WaveformCollapseBuilder { WaveformCollapseBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new() } } fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); // TODO: 构建器代码放在这里 // 找到一个起点; 从中间开始向左走,直到找到一个开放的瓦片 self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 }; /*let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); while self.map.tiles[start_idx] != TileType::Floor { self.starting_position.x -= 1; start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); }*/ self.take_snapshot(); // 找到所有我们可以从起点到达的瓦片 let exit_tile = remove_unreachable_areas_returning_most_distant(&mut self.map, start_idx); self.take_snapshot(); // 放置楼梯 self.map.tiles[exit_tile] = TileType::DownStairs; self.take_snapshot(); // 现在我们构建一个噪声地图,供以后在生成实体时使用 self.noise_areas = generate_voronoi_spawn_regions(&self.map, &mut rng); } } }
我们还将修改 map_builders/mod.rs 的 random_builder 函数,使其始终返回我们当前正在开发的算法:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { /* let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 16); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), 3 => Box::new(CellularAutomataBuilder::new(new_depth)), 4 => Box::new(DrunkardsWalkBuilder::open_area(new_depth)), 5 => Box::new(DrunkardsWalkBuilder::open_halls(new_depth)), 6 => Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)), 7 => Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)), 8 => Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)), 9 => Box::new(MazeBuilder::new(new_depth)), 10 => Box::new(DLABuilder::walk_inwards(new_depth)), 11 => Box::new(DLABuilder::walk_outwards(new_depth)), 12 => Box::new(DLABuilder::central_attractor(new_depth)), 13 => Box::new(DLABuilder::insectoid(new_depth)), 14 => Box::new(VoronoiCellBuilder::pythagoras(new_depth)), 15 => Box::new(VoronoiCellBuilder::manhattan(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) }*/ Box::new(WaveformCollapseBuilder::new(new_depth)) } }
如果您 cargo run 它,这将给您一张空白地图(全是墙壁) - 但这是一个好的起点。
加载源图像 - REX Paint
您可能还记得在第 2 节 中,我们加载了一个 REX Paint 文件用作主菜单屏幕。 我们将在这里做类似的事情,但我们将把它变成一个可玩的地图。 这是一张刻意奇怪的地图,以帮助说明您可以使用此算法做什么。 这是 REX Paint 中的原始图像:
。
我尝试包含一些有趣的形状、一张愚蠢的脸以及大量的走廊和不同大小的房间。 这是第二个 REX Paint 文件,旨在更像旧棋盘游戏 The Sorcerer's Cave,该算法让我想起了它 - 具有 1 个出口、2 个出口、3 个出口和 4 个出口的瓦片。 让这些瓦片更漂亮很容易,但为了演示目的,我们将保持简单。
。
这些文件位于 resources 目录中,分别为 wfc-demo1.xp 和 wfc-demo2.xp。 我喜欢 REX Paint 的一件事:文件非常小(分别为 102k 和 112k)。 为了使访问它们更容易 - 并避免在发布完成的游戏时必须随可执行文件一起发布它们,我们将它们嵌入到我们的游戏中。 我们之前为主菜单做过这件事。 修改 rex_assets.xp 以包含新文件:
#![allow(unused)] fn main() { use rltk::{rex::XpFile}; rltk::embedded_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp"); rltk::embedded_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp"); rltk::embedded_resource!(WFC_DEMO_IMAGE2, "../../resources/wfc-demo2.xp"); pub struct RexAssets { pub menu : XpFile } impl RexAssets { #[allow(clippy::new_without_default)] pub fn new() -> RexAssets { rltk::link_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp"); rltk::link_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp"); rltk::link_resource!(WFC_DEMO_IMAGE2, "../../resources/wfc-demo2.xp"); RexAssets{ menu : XpFile::from_resource("../../resources/SmallDungeon_80x50.xp").unwrap() } } } }
最后,我们应该加载地图本身! 在 waveform_collapse 目录中,创建一个新文件:image_loader.rs:
#![allow(unused)] fn main() { use rltk::rex::XpFile; use super::{Map, TileType}; /// 加载 RexPaint 文件,并将其转换为我们的地图格式 pub fn load_rex_map(new_depth: i32, xp_file : &XpFile) -> Map { let mut map : Map = Map::new(new_depth); for layer in &xp_file.layers { for y in 0..layer.height { for x in 0..layer.width { let cell = layer.get(x, y).unwrap(); if x < map.width as usize && y < map.height as usize { let idx = map.xy_idx(x as i32, y as i32); match cell.ch { 32 => map.tiles[idx] = TileType::Floor, // # 35 => map.tiles[idx] = TileType::Wall, // # _ => {} } } } } } map } }
这非常简单,如果您还记得主菜单图形教程,它应该是不言自明的。 此函数:
- 接受
new_depth(因为地图需要它)和对XpFile的引用 - REX Paint 地图的参数。 它将由构造函数完全设置为实体,到处都是墙壁。 - 它使用
new_depth参数创建一个新地图。 - 对于 REX Paint 文件中的每个图层(此时应该只有一个):
- 对于该图层上的每个
y和x:- 加载该坐标的瓦片信息。
- 确保我们在地图边界内(以防尺寸不匹配)。
- 计算单元格的
tiles索引。 - 匹配单元格字形; 如果是
#(35),我们放置一堵墙,如果是空格 (32),我们放置一个地板。
- 对于该图层上的每个
现在我们可以修改我们的 build 函数(在 mod.rs 中)来加载地图:
#![allow(unused)] fn main() { fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); self.map = load_rex_map(self.depth, &rltk::rex::XpFile::from_resource("../../resources/wfc-demo1.xp").unwrap()); self.take_snapshot(); // 找到一个起点; 从中间开始向左走,直到找到一个开放的瓦片 self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 }; ... }
在顶部,我们必须告诉它使用新的 image_loader 文件:
#![allow(unused)] fn main() { mod image_loader; use image_loader::*; }
请注意,我们没有在这些前面加上 pub:我们正在使用它们,但没有在模块外部公开它们。 这有助于我们保持代码的简洁,并缩短我们的编译时间!
就其本身而言,这很酷 - 我们现在可以加载任何 REX Paint 设计的关卡并进行游戏! 如果您现在 cargo run,您会发现您可以玩新地图:
。
我们将在后面的章节中利用这一点来制作地窖、预制件和预先设计的关卡 - 但现在,我们只将其用作波函数坍缩实现的后续步骤的源数据。
将我们的地图分割成瓦片
我们之前讨论过 WFC 的工作原理是将原始图像分割成块/瓦片,并可选择地在不同方向上翻转它们。 它这样做是构建约束的第一部分 - 地图如何布局。 所以现在我们需要开始分割我们的图像。
我们将从选择一个瓦片大小开始(我们将其称为 chunk_size)。 我们现在将其设为一个常量(稍后它将变为可调整的),并从大小 7 开始 - 因为这是我们的第二个 REX 演示文件中的瓦片大小。 我们还将调用一个稍后我们将编写的函数:
#![allow(unused)] fn main() { fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); const CHUNK_SIZE :i32 = 7; self.map = load_rex_map(self.depth, &rltk::rex::XpFile::from_resource("../../resources/wfc-demo2.xp").unwrap()); self.take_snapshot(); let patterns = build_patterns(&self.map, CHUNK_SIZE, true, true); ... }
由于我们正在处理约束,我们将在我们的 map_builders/waveform_collapse 目录中创建一个新文件 - constraints.rs。 我们将创建一个名为 build_patterns 的函数:
#![allow(unused)] fn main() { use super::{TileType, Map}; use std::collections::HashSet; pub fn build_patterns(map : &Map, chunk_size: i32, include_flipping: bool, dedupe: bool) -> Vec<Vec<TileType>> { let chunks_x = map.width / chunk_size; let chunks_y = map.height / chunk_size; let mut patterns = Vec::new(); for cy in 0..chunks_y { for cx in 0..chunks_x { // 正常方向 let mut pattern : Vec<TileType> = Vec::new(); let start_x = cx * chunk_size; let end_x = (cx+1) * chunk_size; let start_y = cy * chunk_size; let end_y = (cy+1) * chunk_size; for y in start_y .. end_y { for x in start_x .. end_x { let idx = map.xy_idx(x, y); pattern.push(map.tiles[idx]); } } patterns.push(pattern); if include_flipping { // 水平翻转 pattern = Vec::new(); for y in start_y .. end_y { for x in start_x .. end_x { let idx = map.xy_idx(end_x - (x+1), y); pattern.push(map.tiles[idx]); } } patterns.push(pattern); // 垂直翻转 pattern = Vec::new(); for y in start_y .. end_y { for x in start_x .. end_x { let idx = map.xy_idx(x, end_y - (y+1)); pattern.push(map.tiles[idx]); } } patterns.push(pattern); // 同时翻转 pattern = Vec::new(); for y in start_y .. end_y { for x in start_x .. end_x { let idx = map.xy_idx(end_x - (x+1), end_y - (y+1)); pattern.push(map.tiles[idx]); } } patterns.push(pattern); } } } // 去重 if dedupe { rltk::console::log(format!("去重前,有 {} 个模式", patterns.len())); let set: HashSet<Vec<TileType>> = patterns.drain(..).collect(); // 去重 patterns.extend(set.into_iter()); rltk::console::log(format!("有 {} 个模式", patterns.len())); } patterns } }
这是一个相当冗长的函数,所以让我们逐步了解它:
- 在顶部,我们从项目中的其他位置导入了一些项目:
Map、TileType和内置集合HashMap。 - 我们声明了
build_patterns函数,其参数为对源地图的引用、要使用的chunk_size(瓦片大小)以及include_flipping和dedupe的标志(bool变量)。 这些指示我们在读取源地图时希望使用的功能。 我们返回一个vector,其中包含一系列不同的TileType的vector。 外部容器保存每个模式。 内部 vector 保存构成模式本身的TileType。 - 我们确定每个方向有多少块,并将其存储在
chunks_x和chunks_y中。 - 我们创建一个名为
patterns的新vector。 这将保存函数的结果; 我们没有声明它的类型,因为 Rust 足够聪明,可以看到我们将在函数末尾返回它 - 并且可以为我们计算出它的类型。 - 我们迭代变量
cy中的每个垂直块:- 我们迭代变量
cx中的每个水平块:- 我们创建一个新的
vector来保存此模式。 - 我们计算
start_x、end_x、start_y和end_y以保存此块的四个角坐标 - 在原始地图上。 - 我们以
y/x顺序迭代模式(以匹配我们的地图格式),读取块内每个地图瓦片的TileType,并将其添加到模式中。 - 我们将模式推送到
patterns结果 vector。 - 如果
include_flipping设置为true(因为我们想要翻转我们的瓦片,制作更多瓦片!):- 以不同的顺序重复迭代
y/x,给出另外 3 个瓦片。 每个都添加到patterns结果 vector。
- 以不同的顺序重复迭代
- 我们创建一个新的
- 我们迭代变量
- 如果设置了
dedupe,那么我们正在“去重”模式缓冲区。 基本上,删除任何出现多次的模式。 如果地图有很多浪费的空间,而您不想制作同样稀疏的结果地图,这会很有用。 我们通过将模式添加到HashMap(只能存储每个条目的一个)中,然后再将其读出来来去重。
为了使此代码编译,我们必须使 TileType 知道如何将其自身转换为 hash。 HashMap 使用“哈希”(基本上是包含值的校验和)来确定条目是否唯一,并帮助找到它。 在 map.rs 中,我们可以简单地向 TileType 枚举添加一个派生属性:
#![allow(unused)] fn main() { #[derive(PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Floor, DownStairs } }
此代码应为您获取源文件中的每个 7x7 瓦片 - 但如果能够证明它有效,那就太好了! 正如里根的演讲稿撰写人曾经写道的那样,信任 - 但要验证。 在 constraints.rs 中,我们将添加另一个函数:render_pattern_to_map:
#![allow(unused)] fn main() { fn render_pattern_to_map(map : &mut Map, pattern: &Vec<TileType>, chunk_size: i32, start_x : i32, start_y: i32) { let mut i = 0usize; for tile_y in 0..chunk_size { for tile_x in 0..chunk_size { let map_idx = map.xy_idx(start_x + tile_x, start_y + tile_y); map.tiles[map_idx] = pattern[i]; map.visible_tiles[map_idx] = true; i += 1; } } } }
这非常简单:迭代模式,并复制到地图上的某个位置 - 由 start_x 和 start_y 坐标偏移。 请注意,我们还将瓦片标记为 visible - 这将使渲染器以彩色显示我们的瓦片。
现在我们只需要显示我们的瓦片作为 snapshot 系统的一部分。 在 waveform_collapse/mod.rs 中,在 WaveformCollapseBuilder 的实现中添加一个新函数(在 build 下面)。 这是一个成员函数,因为它需要访问 take_snapshot 命令:
#![allow(unused)] fn main() { fn render_tile_gallery(&mut self, patterns: &Vec<Vec<TileType>>, chunk_size: i32) { self.map = Map::new(0); let mut counter = 0; let mut x = 1; let mut y = 1; while counter < patterns.len() { render_pattern_to_map(&mut self.map, &patterns[counter], chunk_size, x, y); x += chunk_size + 1; if x + chunk_size > self.map.width { // 移动到下一行 x = 1; y += chunk_size + 1; if y + chunk_size > self.map.height { // 移动到下一页 self.take_snapshot(); self.map = Map::new(0); x = 1; y = 1; } } counter += 1; } self.take_snapshot(); } }
现在,我们需要调用它。 在 build 中:
#![allow(unused)] fn main() { let patterns = build_patterns(&self.map, CHUNK_SIZE, true, true); self.render_tile_gallery(&patterns, CHUNK_SIZE); }
此外,注释掉一些代码,以免因无法找到起点而崩溃:
#![allow(unused)] fn main() { let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); /*while self.map.tiles[start_idx] != TileType::Floor { self.starting_position.x -= 1; start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); }*/ }
如果您现在 cargo run,它将显示地图示例 2 中的瓦片模式:
。
请注意,翻转如何为我们提供了每个瓦片的多个变体。 如果我们将图像加载代码更改为加载 wfc-demo1(通过将加载器更改为 self.map = load_rex_map(self.depth, &rltk::rex::XpFile::from_resource("../../resources/wfc-demo1.xp").unwrap());),我们将获得手绘地图的块:
。
构建约束矩阵
现在我们需要开始告诉算法如何将瓦片彼此相邻放置。 我们可以采用简单的“原始图像上与它相邻的是什么?”算法,但这会忽略 Roguelike 地图中的一个关键因素:连通性。 与整体美学相比,我们更关注从点 A 到点 B 的能力! 所以我们需要编写一个约束构建器,它考虑到连通性。
我们将首先扩展 mod.rs 中的 builder 以调用一个假设的函数,我们将在稍后实现它:
#![allow(unused)] fn main() { let patterns = build_patterns(&self.map, CHUNK_SIZE, true, true); self.render_tile_gallery(&patterns, CHUNK_SIZE); let constraints = patterns_to_constraints(patterns, CHUNK_SIZE); }
这为我们提供了一个新方法 patterns_to_constraints 的签名,以添加到 constraints.rs。 我们还需要一个新的类型和一个辅助函数。 我们将在其他地方使用这些,因此我们将在 waveform_collapse 文件夹中添加一个新文件 - common.rs。
#![allow(unused)] fn main() { use super::TileType; #[derive(PartialEq, Eq, Hash, Clone)] pub struct MapChunk { pub pattern : Vec<TileType>, pub exits: [Vec<bool>; 4], pub has_exits: bool, pub compatible_with: [Vec<usize>; 4] } pub fn tile_idx_in_chunk(chunk_size: i32, x:i32, y:i32) -> usize { ((y * chunk_size) + x) as usize } }
我们将 MapChunk 定义为一个结构体,包含实际模式、出口结构(稍后详细介绍)、一个 bool 值来说明我们是否有任何出口,以及一个名为 compatible_with 的结构(稍后也会详细介绍)。 我们还定义了 tile_idx_in_chunk - 它就像 map.xy_idx - 但限制为小瓦片类型。
现在我们将在 constraints.rs 中编写 patterns_to_constraints:
#![allow(unused)] fn main() { pub fn patterns_to_constraints(patterns: Vec<Vec<TileType>>, chunk_size : i32) -> Vec<MapChunk> { // 移动到新的约束对象中 let mut constraints : Vec<MapChunk> = Vec::new(); for p in patterns { let mut new_chunk = MapChunk{ pattern: p, exits: [ Vec::new(), Vec::new(), Vec::new(), Vec::new() ], has_exits : true, compatible_with: [ Vec::new(), Vec::new(), Vec::new(), Vec::new() ] }; for exit in new_chunk.exits.iter_mut() { for _i in 0..chunk_size { exit.push(false); } } let mut n_exits = 0; for x in 0..chunk_size { // 检查北向出口 let north_idx = tile_idx_in_chunk(chunk_size, x, 0); if new_chunk.pattern[north_idx] == TileType::Floor { new_chunk.exits[0][x as usize] = true; n_exits += 1; } // 检查南向出口 let south_idx = tile_idx_in_chunk(chunk_size, x, chunk_size-1); if new_chunk.pattern[south_idx] == TileType::Floor { new_chunk.exits[1][x as usize] = true; n_exits += 1; } // 检查西向出口 let west_idx = tile_idx_in_chunk(chunk_size, 0, x); if new_chunk.pattern[west_idx] == TileType::Floor { new_chunk.exits[2][x as usize] = true; n_exits += 1; } // 检查东向出口 let east_idx = tile_idx_in_chunk(chunk_size, chunk_size-1, x); if new_chunk.pattern[east_idx] == TileType::Floor { new_chunk.exits[3][x as usize] = true; n_exits += 1; } } if n_exits == 0 { new_chunk.has_exits = false; } constraints.push(new_chunk); } // 构建兼容性矩阵 let ch = constraints.clone(); for c in constraints.iter_mut() { for (j,potential) in ch.iter().enumerate() { // 如果根本没有出口,则兼容 if !c.has_exits || !potential.has_exits { for compat in c.compatible_with.iter_mut() { compat.push(j); } } else { // 按方向评估兼容性 for (direction, exit_list) in c.exits.iter_mut().enumerate() { let opposite = match direction { 0 => 1, // 我们的北,他们的南 1 => 0, // 我们的南,他们的北 2 => 3, // 我们的西,他们的东 _ => 2 // 我们的东,他们的西 }; let mut it_fits = false; let mut has_any = false; for (slot, can_enter) in exit_list.iter().enumerate() { if *can_enter { has_any = true; if potential.exits[opposite][slot] { it_fits = true; } } } if it_fits { c.compatible_with[direction].push(j); } if !has_any { // 这边没有出口,我们不在乎那里放什么 for compat in c.compatible_with.iter_mut() { compat.push(j); } } } } } } constraints } }
这是一个非常大的函数,但显然被分解为多个部分。 让我们花时间逐步了解它实际做了什么:
- 它接受第一个参数
patterns作为Vec<Vec<TileType>>- 我们用来构建模式的类型。 第二个参数chunk_size与我们之前使用的相同。 它返回新MapChunk类型的vector。MapChunk是一个模式,但添加了额外的出口和兼容性信息。 因此,我们承诺,给定一组模式图形,我们将添加所有导航信息并以一组块的形式返回模式。 - 它创建一个名为
constraints的MapChunk类型的Vec。 这是我们的结果 - 我们将在其中添加内容,并在最后将其返回给调用者。 - 现在我们迭代
patterns中的每个模式,称之为p(以节省输入)。 对于每个模式:- 我们创建一个新的
MapChunk。pattern字段获取我们模式的副本。exits是一个数组(固定大小的集合;在本例中大小为 4)的 vector,因此我们向其中插入 4 个空 vector。compatible_with也是一个 vector 数组,因此我们将它们设置为新的 - 空 - vector。 我们将has_exits设置为true- 我们稍后会设置它。 - 我们从 0 迭代到
chunk_size,并在新地图块的每个exits字段中添加false。exits结构表示每个可能的方向(北、南、西、东)一个条目 - 因此它需要每个块大小一个条目来表示该方向的每个可能的出口瓦片。 我们稍后将检查实际的连通性 - 现在,我们只是想要每个方向的占位符。 - 我们将
n_exits设置为 0,并使其为可变的 - 以便我们稍后可以添加到其中。 我们将在整个过程中计算出口总数。 - 我们迭代
x从 0 到chunk_size,对于每个x值:- 我们检查北向出口。 这些出口始终位于块内的
(x, 0)位置 - 因此我们计算要检查的瓦片索引为tile_idx_in_chunk(chunk_size, x, 0)。 如果该瓦片是地板,我们将在n_exits中加一,并将new_chunk.exits[0][x]设置为true。 - 我们对南向出口执行相同的操作。 这些出口始终位于
(x, chunk_size-1)位置,因此我们计算块索引为tile_idx_in_chunk(chunk_size, x, chunk_size-1)。 如果该瓦片是地板,我们将在n_exits中加一,并将new_chunks.exits[1][x]设置为true。 - 我们再次对西向出口执行相同的操作,西向出口位于
(0,x)位置。 - 我们再次对东向出口执行相同的操作,东向出口位于
(chunk_size-1,0)位置。
- 我们检查北向出口。 这些出口始终位于块内的
- 如果
n_exits为 0,我们将new_chunk.has_exits设置为 0 - 没有进出此块的方式! - 我们将
new_chunk推送到constraints结果 vector。
- 我们创建一个新的
- 现在是构建兼容性矩阵的时候了! 这里的想法是通过匹配相邻边缘的出口来匹配哪些瓦片可以放置到哪些其他瓦片。
- 为了避免借用检查器问题,我们使用
let ch = constraints.clone();获取现有约束的副本。 Rust 不太喜欢同时从同一个vector读取和写入 - 因此这避免了我们必须进行舞蹈以保持其分离。 - 对于结果 vector
constraints中的每个constraint,命名为c,我们:- 将
ch中的每个约束迭代为potential,这是约束 vector 的副本。 我们添加一个枚举器j来告诉我们它的索引方式。- 如果
c(我们正在编辑的约束)和potential(我们正在检查的约束)都没有出口,那么我们使其与所有内容兼容。 我们这样做是为了增加成功解决地图并仍然包含这些瓦片的机会(否则,它们将永远不会被选中)。 为了增加与所有内容的兼容性,我们将j添加到所有四个方向的compatibile_with结构中。 因此,c可以与potential在任何方向上相邻放置。 - 否则,我们迭代
c上的所有四个出口方向:- 我们将
opposite设置为我们正在评估的方向的倒数; 因此,北变为南,东变为西,等等。 - 我们设置两个可变变量
it_fits和has_any- 并将它们都设置为false。 我们将在后续步骤中使用这些变量。it_fits表示c的出口瓦片和potential的入口瓦片之间存在一个或多个匹配的出口。has_any表示c在此方向上是否有任何出口。 我们区分这两者是因为如果该方向上没有出口,我们不在乎邻居是什么 - 我们无法影响它。 如果有出口,那么我们只希望与您实际可以访问的瓦片兼容。 - 我们迭代
c的 exits,同时保留一个slot(我们正在评估的瓦片编号)和exit瓦片的值 (can_enter)。 您会记得,如果它们是地板,我们将这些设置为true- 否则设置为false- 因此我们正在迭代可能的出口。- 如果
can_enter为true,那么我们将has_any设置为 true - 它在该方向上有一个出口。 - 我们检查
potential_exits.exits[opposite][slot]- 即另一个瓦片上的匹配出口,方向与我们前进的方向相反。 如果存在匹配,那么您可以从瓦片c到瓦片potential在我们当前的方向上! 这使我们可以将it_fits设置为 true。
- 如果
- 如果
it_fits为true,则瓦片之间存在兼容性:我们将j添加到c的compatible_withvector 中,用于当前方向。 - 如果
has_any为false,那么我们不在乎此方向上的邻接关系 - 因此我们将j添加到所有方向的兼容性矩阵中,就像我们对没有出口的瓦片所做的那样。
- 我们将
- 如果
- 将
- 最后,我们返回我们的
constraints结果 vector。
这是一个相当复杂的算法,因此我们真的不想相信我做对了。 我们将通过调整我们的瓦片画廊代码来显示出口来验证出口检测。 在 build 中,调整渲染顺序以及我们传递给 render_tile_gallery 的内容:
#![allow(unused)] fn main() { let patterns = build_patterns(&self.map, CHUNK_SIZE, true, true); let constraints = patterns_to_constraints(patterns, CHUNK_SIZE); self.render_tile_gallery(&constraints, CHUNK_SIZE); }
我们还需要修改 render_tile_gallery:
#![allow(unused)] fn main() { fn render_tile_gallery(&mut self, constraints: &Vec<MapChunk>, chunk_size: i32) { self.map = Map::new(0); let mut counter = 0; let mut x = 1; let mut y = 1; while counter < constraints.len() { render_pattern_to_map(&mut self.map, &constraints[counter], chunk_size, x, y); x += chunk_size + 1; if x + chunk_size > self.map.width { // 移动到下一行 x = 1; y += chunk_size + 1; if y + chunk_size > self.map.height { // 移动到下一页 self.take_snapshot(); self.map = Map::new(0); x = 1; y = 1; } } counter += 1; } self.take_snapshot(); } }
这要求我们也修改我们的 render_pattern_to_map 函数:
#![allow(unused)] fn main() { pub fn render_pattern_to_map(map : &mut Map, chunk: &MapChunk, chunk_size: i32, start_x : i32, start_y: i32) { let mut i = 0usize; for tile_y in 0..chunk_size { for tile_x in 0..chunk_size { let map_idx = map.xy_idx(start_x + tile_x, start_y + tile_y); map.tiles[map_idx] = chunk.pattern[i]; map.visible_tiles[map_idx] = true; i += 1; } } for (x,northbound) in chunk.exits[0].iter().enumerate() { if *northbound { let map_idx = map.xy_idx(start_x + x as i32, start_y); map.tiles[map_idx] = TileType::DownStairs; } } for (x,southbound) in chunk.exits[1].iter().enumerate() { if *southbound { let map_idx = map.xy_idx(start_x + x as i32, start_y + chunk_size -1); map.tiles[map_idx] = TileType::DownStairs; } } for (x,westbound) in chunk.exits[2].iter().enumerate() { if *westbound { let map_idx = map.xy_idx(start_x, start_y + x as i32); map.tiles[map_idx] = TileType::DownStairs; } } for (x,eastbound) in chunk.exits[3].iter().enumerate() { if *eastbound { let map_idx = map.xy_idx(start_x + chunk_size - 1, start_y + x as i32); map.tiles[map_idx] = TileType::DownStairs; } } } }
现在我们已经运行了演示框架,我们可以 cargo run 项目 - 并看到 wfc-demo2.xp 中的瓦片正确突出显示了出口:
。
wfc-demo1.xp 出口也突出显示:
。
太棒了! 我们的出口查找器工作正常。
构建求解器
您还记得过去在长途旅行中可以买到的旧逻辑问题书吗? “弗雷德是律师,玛丽是医生,吉姆失业了。 弗雷德不能坐在失业者旁边,因为他很势利。 玛丽喜欢所有人。 你应该如何安排他们的座位?” 这是一个约束问题的示例,求解器旨在帮助解决这类问题。 构建我们的地图没有什么不同 - 我们正在读取约束矩阵(我们在上面构建的矩阵)以确定我们可以在任何给定区域放置哪些瓦片。 因为它是 Roguelike 游戏,并且我们希望每次都有不同的东西,所以我们想要注入一些随机性 - 并每次都获得不同但有效的地图。
让我们扩展我们的 build 函数以调用一个假设的求解器:
#![allow(unused)] fn main() { let patterns = build_patterns(&self.map, CHUNK_SIZE, true, true); let constraints = patterns_to_constraints(patterns, CHUNK_SIZE); self.render_tile_gallery(&constraints, CHUNK_SIZE); self.map = Map::new(self.depth); loop { let mut solver = Solver::new(constraints.clone(), CHUNK_SIZE, &self.map); while !solver.iteration(&mut self.map, &mut rng) { self.take_snapshot(); } self.take_snapshot(); if solver.possible { break; } // 如果它遇到了不可能的条件,请重试 } }
我们制作了一张全新的实体地图(因为我们一直在使用它来渲染瓦片演示,并且不想用演示画廊污染最终地图!)。 然后我们 loop(Rust 循环,除非某些东西调用 break,否则永远运行)。 在该循环中,我们为 constraints 矩阵的副本创建一个求解器(我们复制它是为了以防我们必须重复执行;否则,我们将不得不 move 它进入并再次 move 它出来)。 我们重复调用求解器的 iteration 函数,每次都拍摄快照 - 直到它报告完成为止。 如果 solver 放弃并说不可能,我们重试。
我们将首先在我们的 waveform_collapse 目录中添加 solver.rs。 求解器需要保持自己的状态:也就是说,当它迭代时,它需要知道它已经走了多远。 我们将通过将 Solver 转换为结构体来支持这一点:
#![allow(unused)] fn main() { pub struct Solver { constraints: Vec<MapChunk>, chunk_size : i32, chunks : Vec<Option<usize>>, chunks_x : usize, chunks_y : usize, remaining : Vec<(usize, i32)>, // (索引,# 邻居) pub possible: bool } }
它存储了我们一直在构建的 constraints、我们正在使用的 chunk_size、我们正在解析的 chunks(稍后详细介绍)、它可以容纳在目标地图上的块数(chunks_x 和 chunks_y)、一个 remaining 向量(稍后也会详细介绍),以及一个 possible 指示器,用于指示它是否放弃。
chunks 是 Option<usize> 的 vector。 usize 值是块的索引。 它是一个选项,因为我们可能还没有填写它 - 因此它可能是 None 或 Some(usize)。 这很好地表示了问题的“量子波函数坍缩”性质 - 它要么存在,要么不存在,在我们看到它之前我们不知道!
remaining 是所有块的 vector,带有它们的索引。 这是一个 tuple - 我们将块索引存储在第一个条目中,并将现有邻居的数量存储在第二个条目中。 我们将使用它来帮助决定接下来要填充哪个块,并在添加一个块后将其从 remaining 列表中删除。
我们还需要为 Solver 实现方法。 new 是一个基本的构造函数:
#![allow(unused)] fn main() { impl Solver { pub fn new(constraints: Vec<MapChunk>, chunk_size: i32, map : &Map) -> Solver { let chunks_x = (map.width / chunk_size) as usize; let chunks_y = (map.height / chunk_size) as usize; let mut remaining : Vec<(usize, i32)> = Vec::new(); for i in 0..(chunks_x*chunks_y) { remaining.push((i, 0)); } Solver { constraints, chunk_size, chunks: vec![None; chunks_x * chunks_y], chunks_x, chunks_y, remaining, possible: true } } ... }
它计算大小(对于 chunks_x 和 chunks_y),用每个瓦片和没有邻居的值填充 remaining,并用 None 值填充 chunks。 这为我们的求解运行做好了准备! 我们还需要一个名为 chunk_idx 的辅助函数:
#![allow(unused)] fn main() { fn chunk_idx(&self, x:usize, y:usize) -> usize { ((y * self.chunks_x) + x) as usize } }
这很像 map 中的 xy_idx,或 common 中的 tile_idx_in_chunk - 但受到我们可以容纳在地图上的块数的限制。 我们还将依赖 count_neighbors:
#![allow(unused)] fn main() { fn count_neighbors(&self, chunk_x:usize, chunk_y:usize) -> i32 { let mut neighbors = 0; if chunk_x > 0 { let left_idx = self.chunk_idx(chunk_x-1, chunk_y); match self.chunks[left_idx] { None => {} Some(_) => { neighbors += 1; } } } if chunk_x < self.chunks_x-1 { let right_idx = self.chunk_idx(chunk_x+1, chunk_y); match self.chunks[right_idx] { None => {} Some(_) => { neighbors += 1; } } } if chunk_y > 0 { let up_idx = self.chunk_idx(chunk_x, chunk_y-1); match self.chunks[up_idx] { None => {} Some(_) => { neighbors += 1; } } } if chunk_y < self.chunks_y-1 { let down_idx = self.chunk_idx(chunk_x, chunk_y+1); match self.chunks[down_idx] { None => {} Some(_) => { neighbors += 1; } } } neighbors } }
这个函数可以小很多,但我把它留下来是为了清楚地说明每一步。 它查看一个块,并确定它是否在北、南、东和西方向上有一个已创建(未设置为 None)的块。
最后,我们得到了 iteration 函数 - 它完成了繁重的工作:
#![allow(unused)] fn main() { pub fn iteration(&mut self, map: &mut Map, rng : &mut super::RandomNumberGenerator) -> bool { if self.remaining.is_empty() { return true; } // 填充剩余列表的邻居计数 let mut remain_copy = self.remaining.clone(); let mut neighbors_exist = false; for r in remain_copy.iter_mut() { let idx = r.0; let chunk_x = idx % self.chunks_x; let chunk_y = idx / self.chunks_x; let neighbor_count = self.count_neighbors(chunk_x, chunk_y); if neighbor_count > 0 { neighbors_exist = true; } *r = (r.0, neighbor_count); } remain_copy.sort_by(|a,b| b.1.cmp(&a.1)); self.remaining = remain_copy; // 选择一个我们尚未处理的随机块并获取其索引,从剩余列表中删除 let remaining_index = if !neighbors_exist { (rng.roll_dice(1, self.remaining.len() as i32)-1) as usize } else { 0usize }; let chunk_index = self.remaining[remaining_index].0; self.remaining.remove(remaining_index); let chunk_x = chunk_index % self.chunks_x; let chunk_y = chunk_index / self.chunks_x; let mut neighbors = 0; let mut options : Vec<Vec<usize>> = Vec::new(); if chunk_x > 0 { let left_idx = self.chunk_idx(chunk_x-1, chunk_y); match self.chunks[left_idx] { None => {} Some(nt) => { neighbors += 1; options.push(self.constraints[nt].compatible_with[3].clone()); } } } if chunk_x < self.chunks_x-1 { let right_idx = self.chunk_idx(chunk_x+1, chunk_y); match self.chunks[right_idx] { None => {} Some(nt) => { neighbors += 1; options.push(self.constraints[nt].compatible_with[2].clone()); } } } if chunk_y > 0 { let up_idx = self.chunk_idx(chunk_x, chunk_y-1); match self.chunks[up_idx] { None => {} Some(nt) => { neighbors += 1; options.push(self.constraints[nt].compatible_with[1].clone()); } } } if chunk_y < self.chunks_y-1 { let down_idx = self.chunk_idx(chunk_x, chunk_y+1); match self.chunks[down_idx] { None => {} Some(nt) => { neighbors += 1; options.push(self.constraints[nt].compatible_with[0].clone()); } } } if neighbors == 0 { // 附近没有任何东西,所以我们可以拥有任何东西! let new_chunk_idx = (rng.roll_dice(1, self.constraints.len() as i32)-1) as usize; self.chunks[chunk_index] = Some(new_chunk_idx); let left_x = chunk_x as i32 * self.chunk_size as i32; let right_x = (chunk_x as i32+1) * self.chunk_size as i32; let top_y = chunk_y as i32 * self.chunk_size as i32; let bottom_y = (chunk_y as i32+1) * self.chunk_size as i32; let mut i : usize = 0; for y in top_y .. bottom_y { for x in left_x .. right_x { let mapidx = map.xy_idx(x, y); let tile = self.constraints[new_chunk_idx].pattern[i]; map.tiles[mapidx] = tile; i += 1; } } } else { // 附近有邻居,所以我们尝试与它们兼容 let mut options_to_check : HashSet<usize> = HashSet::new(); for o in options.iter() { for i in o.iter() { options_to_check.insert(*i); } } let mut possible_options : Vec<usize> = Vec::new(); for new_chunk_idx in options_to_check.iter() { let mut possible = true; for o in options.iter() { if !o.contains(new_chunk_idx) { possible = false; } } if possible { possible_options.push(*new_chunk_idx); } } if possible_options.is_empty() { rltk::console::log("哦不! 这不可能!"); self.possible = false; return true; } else { let new_chunk_idx = if possible_options.len() == 1 { 0 } else { rng.roll_dice(1, possible_options.len() as i32)-1 }; self.chunks[chunk_index] = Some(new_chunk_idx as usize); let left_x = chunk_x as i32 * self.chunk_size as i32; let right_x = (chunk_x as i32+1) * self.chunk_size as i32; let top_y = chunk_y as i32 * self.chunk_size as i32; let bottom_y = (chunk_y as i32+1) * self.chunk_size as i32; let mut i : usize = 0; for y in top_y .. bottom_y { for x in left_x .. right_x { let mapidx = map.xy_idx(x, y); let tile = self.constraints[new_chunk_idx as usize].pattern[i]; map.tiles[mapidx] = tile; i += 1; } } } } false } }
这是另一个非常大的函数,但同样是因为我试图使其易于阅读。 让我们逐步了解算法:
- 如果
remaining中没有任何剩余内容,我们返回表明我们已完成地图。possible为真,因为我们实际上完成了问题。 - 我们获取
remaining的clone以避免借用检查器问题。 - 我们迭代
remaining的副本,对于每个剩余的块:- 我们从块索引确定它的
x和y位置。 - 我们调用
count_neighbors以确定有多少(如果有)相邻块已被解析。 - 如果找到任何邻居,我们将
neighbors_exist设置为 true - 告诉算法它至少运行了一次。 - 我们更新
remaining列表的副本,以包含与之前相同的索引和新的邻居计数。
- 我们从块索引确定它的
- 我们按邻居数量降序对
remaining的副本进行排序 - 因此邻居最多的块排在第一位。 - 我们将
remaining的克隆副本复制回我们的实际remaining列表。 - 我们想要创建一个新变量
remaining_index- 以指示我们要处理哪个块,以及它在remainingvector 中的位置。 如果我们还没有制作任何瓦片,我们会随机选择起点。 否则,我们选择remaining列表中的第一个条目 - 这将是邻居最多的条目。 - 我们从所选索引处的
remaining list获取chunk_idx,并从列表中删除该块。 - 现在我们计算
chunk_x和chunk_y以告诉我们它在新地图上的位置。 - 我们将可变变量
neighbors设置为 0; 我们将再次计算邻居。 - 我们创建一个名为
Options的可变变量。 它的类型相当奇怪Vec<Vec<usize>>- 它是 vector 的 vector,每个 vector 都包含一个数组索引 (usize)。 我们将在此处存储每个方向的兼容选项 - 因此我们需要用于方向的外部 vector 和用于选项的内部 vector。 这些索引constraintsvector。 - 如果它不是地图上最左侧的块,则它可能在西侧有一个块 - 因此我们计算该块的索引。 如果西侧存在块(不是
None),那么我们将它的东向compatible_with列表添加到我们的Optionsvector 中。 我们递增neighbors以指示我们找到了一个邻居。 - 我们对东侧重复操作 - 如果它不是地图上最右侧的块。 我们递增
neighbors以指示我们找到了一个邻居。 - 我们对南侧重复操作 - 如果它不是地图上最底部的块。 我们递增
neighbors以指示我们找到了一个邻居。 - 我们对北侧重复操作 - 如果它不是地图上最顶部的块。 我们递增
neighbors以指示我们找到了一个邻居。 - 如果没有邻居,我们:
- 从
constraints中找到一个随机瓦片。 - 计算出我们将瓦片放置在
left_x、right_x、top_y和bottom_y中的边界。 - 将选定的瓦片复制到地图。
- 从
- 如果有邻居,我们:
- 将每个方向的所有选项插入到
HashSet中。 我们之前使用HashSet来去重我们的瓦片,这就是我们在这里所做的事情:我们删除了所有重复的选项,因此我们不会重复评估它们。 - 我们创建一个名为
possible_options的新 vector。 对于HashSet中的每个选项:- 将一个名为
possible的可变变量设置为true。 - 检查每个方向的
options,如果它与其邻居的偏好兼容 - 将其添加到possible_options。
- 将一个名为
- 如果
possible_options为空 - 那么我们就碰壁了,无法添加更多瓦片。 我们在父结构中将possible设置为 false 并退出! - 否则,我们从
possible_options中选择一个随机条目并将其绘制到地图上。
- 将每个方向的所有选项插入到
因此,虽然它是一个长函数,但它并不是一个真正复杂的函数。 它在每次迭代中查找可能的组合,并尝试应用它们 - 如果找不到则放弃并返回失败。
调用者已经在拍摄每次迭代的快照,因此如果我们使用我们的 wfc-test1.xp 文件 cargo run 项目,我们会得到如下结果:
。
不是最好的地图,但你可以观看求解器缓慢地运行 - 一次放置一个瓦片。 现在让我们使用 wfc-test2.xmp 尝试一下,这是一组专为平铺设计的瓦片:
。
这有点有趣 - 它像拼图一样布局,最终得到一张地图! 该地图的连接性不如人们希望的那样好,没有出口的边缘导致更小的游戏区域(在最后被剔除)。 这仍然是一个好的开始!
减小块大小
在这种情况下,我们可以通过将我们的 CHUNK_SIZE 常量减小到 3 来显着改善生成的地图。 使用测试地图 1 运行它会产生如下结果:
。
这是一张更有趣的地图! 您也可以使用 wfc-test2.xp 尝试一下:
。
再一次,这是一张有趣且可玩的地图! 问题是我们有如此小的块大小,以至于邻接关系真的没有那么多有趣的选项 - 3x3 网格真的限制了您可以在地图上拥有的可变性! 因此,我们将使用 5 的块大小尝试 wfc-test1.xp:
。
更像样了! 它与我们可能尝试以另一种方式生成的地图没有太大的不同。
利用读取其他地图类型的能力
与其加载我们的 .xp 文件之一,不如输入 CellularAutomata 运行的结果,并使用它作为具有大 (8) 块的种子。 使用我们拥有的结构,这非常容易! 在我们的 build 函数中:
#![allow(unused)] fn main() { const CHUNK_SIZE :i32 = 8; let mut ca = super::CellularAutomataBuilder::new(0); ca.build_map(); self.map = ca.get_map(); for t in self.map.tiles.iter_mut() { if *t == TileType::DownStairs { *t = TileType::Floor; } } }
请注意,我们正在删除下楼梯 - 细胞自动机生成器将放置一个,我们不希望到处都是楼梯! 这给出了非常令人愉悦的结果:
。
改进邻接关系 - 并增加拒绝的风险!
我们已经拥有的已经是一个非常可行的解决方案 - 您可以使用它制作出色的地图,尤其是在您使用其他生成器作为种子时。 在曲折的拼图地图上,它没有生成我们想要的邻接关系。 通过使匹配器更具体,存在一些失败的小风险,但无论如何让我们尝试一下。 在我们构建兼容性矩阵的代码中,找到注释 `这
预制关卡和关卡片段
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
尽管本质上是伪随机的(即随机的 - 但以一种构成有趣、有凝聚力的游戏的方式受到约束),许多 roguelike 游戏都包含一些手工制作的内容。通常,这些内容可以分为几个类别:
- 手工制作的关卡 - 整个关卡都是预先制作的,内容是静态的。这些通常非常谨慎地使用,用于对故事至关重要的大型场景战役。
- 手工制作的关卡片段 - 关卡的某些部分是随机创建的,但很大一部分是预先制作的。例如,堡垒可能是一个“场景”,但通往堡垒的地下城是随机的。《地下城爬行:石汤 (Dungeon Crawl Stone Soup)》广泛使用了这些片段 - 您有时会遇到您认出的区域,因为它们是预制的 - 但它们周围的地下城显然是随机的。《Cogmind》在洞穴的某些部分使用了这些片段(我将避免剧透)。《Qud 的洞穴 (Caves of Qud)》有一些场景关卡,似乎是围绕许多预制部件构建的。有些系统将这种机制称为“vaults”——但这个名称也可能适用于第三类。
- 手工制作的房间(在某些情况下也称为 Vaults)。关卡在很大程度上是随机的,但有时一个房间适合一个 vault - 所以您在那里放置一个。
第一类是特殊的,应该谨慎使用(否则,您的玩家只会学习一种最佳策略并一路过关斩将 - 并且可能会因为缺乏变化而感到无聊)。其他类别受益于提供大量 vault(因此有大量内容可以散布,这意味着游戏每次玩起来感觉不会太相似)或稀有 - 因此您只是偶尔看到它们(原因相同)。
一些清理工作
在 波函数坍缩章节 中,我们加载了一个预制关卡 - 没有任何实体(这些实体稍后添加)。将地图加载器隐藏在 WFC 内部并不是很好 - 因为这不是它的主要目的 - 所以我们将从删除它开始:
我们将首先删除文件 map_builders/waveform_collapse/image_loader.rs。我们稍后将构建一个更好的加载器。
现在我们编辑 map_builders/waveform_collapse 中的 mod.rs 的开头:
#![allow(unused)] fn main() { use super::{MapBuilder, Map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER, generate_voronoi_spawn_regions, remove_unreachable_areas_returning_most_distant}; use rltk::RandomNumberGenerator; use specs::prelude::*; use std::collections::HashMap; mod common; use common::*; mod constraints; use constraints::*; mod solver; use solver::*; /// 提供使用波函数坍缩算法的地图构建器。 pub struct WaveformCollapseBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, noise_areas : HashMap<i32, Vec<usize>>, derive_from : Option<Box<dyn MapBuilder>> } ... impl WaveformCollapseBuilder { /// 波函数坍缩的通用构造函数。 /// # 参数 /// * new_depth - 新的地图深度 /// * derive_from - None,或者一个 Box<dyn MapBuilder>,作为 `random_builder` 的输出 pub fn new(new_depth : i32, derive_from : Option<Box<dyn MapBuilder>>) -> WaveformCollapseBuilder { WaveformCollapseBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history: Vec::new(), noise_areas : HashMap::new(), derive_from } } /// 从预先存在的地图构建器派生地图。 /// # 参数 /// * new_depth - 新的地图深度 /// * derive_from - None,或者一个 Box<dyn MapBuilder>,作为 `random_builder` 的输出 pub fn derived_map(new_depth: i32, builder: Box<dyn MapBuilder>) -> WaveformCollapseBuilder { WaveformCollapseBuilder::new(new_depth, Some(builder)) } ... }
我们已经删除了所有对 image_loader 的引用,删除了测试地图构造函数,并删除了丑陋的模式枚举。WFC 现在完全名副其实,仅此而已。最后,我们将修改 random_builder 以不再使用测试地图:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 16); let mut result : Box<dyn MapBuilder>; match builder { 1 => { result = Box::new(BspDungeonBuilder::new(new_depth)); } 2 => { result = Box::new(BspInteriorBuilder::new(new_depth)); } 3 => { result = Box::new(CellularAutomataBuilder::new(new_depth)); } 4 => { result = Box::new(DrunkardsWalkBuilder::open_area(new_depth)); } 5 => { result = Box::new(DrunkardsWalkBuilder::open_halls(new_depth)); } 6 => { result = Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)); } 7 => { result = Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)); } 8 => { result = Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)); } 9 => { result = Box::new(MazeBuilder::new(new_depth)); } 10 => { result = Box::new(DLABuilder::walk_inwards(new_depth)); } 11 => { result = Box::new(DLABuilder::walk_outwards(new_depth)); } 12 => { result = Box::new(DLABuilder::central_attractor(new_depth)); } 13 => { result = Box::new(DLABuilder::insectoid(new_depth)); } 14 => { result = Box::new(VoronoiCellBuilder::pythagoras(new_depth)); } 15 => { result = Box::new(VoronoiCellBuilder::manhattan(new_depth)); } _ => { result = Box::new(SimpleMapBuilder::new(new_depth)); } } if rng.roll_dice(1, 3)==1 { result = Box::new(WaveformCollapseBuilder::derived_map(new_depth, result)); } result } }
骨架构建器
我们将从一个非常基本的骨架开始,类似于之前使用的那些。我们将在 map_builders 中创建一个新文件 prefab_builder.rs:
#![allow(unused)] fn main() { use super::{MapBuilder, Map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER, remove_unreachable_areas_returning_most_distant}; use rltk::RandomNumberGenerator; use specs::prelude::*; pub struct PrefabBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, } impl MapBuilder for PrefabBuilder { fn get_map(&self) -> Map { self.map.clone() } fn get_starting_position(&self) -> Position { self.starting_position.clone() } fn get_snapshot_history(&self) -> Vec<Map> { self.history.clone() } fn build_map(&mut self) { self.build(); } fn spawn_entities(&mut self, ecs : &mut World) { } fn take_snapshot(&mut self) { if SHOW_MAPGEN_VISUALIZER { let mut snapshot = self.map.clone(); for v in snapshot.revealed_tiles.iter_mut() { *v = true; } self.history.push(snapshot); } } } impl PrefabBuilder { pub fn new(new_depth : i32) -> PrefabBuilder { PrefabBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history : Vec::new() } } fn build(&mut self) { } } }
预制构建器模式 1 - 手工制作关卡
我们将为预制构建器支持多种模式,所以让我们在一开始就将其加入。在 prefab_builder.rs 中:
#![allow(unused)] fn main() { #[derive(PartialEq, Clone)] #[allow(dead_code)] pub enum PrefabMode { RexLevel{ template : &'static str } } pub struct PrefabBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, mode: PrefabMode } }
这是新的 - 带有变量的 enum? 这是可行的,因为在底层,Rust 枚举实际上是 联合体 (unions)。它们可以容纳您想要放入的任何内容,并且类型大小被调整为容纳最大的选项。在紧凑的代码中最好谨慎使用,但对于配置之类的事情,这是一种非常干净的方式来传入数据。我们还应该更新构造函数以创建新类型:
#![allow(unused)] fn main() { impl PrefabBuilder { #[allow(dead_code)] pub fn new(new_depth : i32) -> PrefabBuilder { PrefabBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history : Vec::new(), mode : PrefabMode::RexLevel{ template : "../../resources/wfc-demo1.xp" } } } ... }
将地图模板路径包含在模式中使其更易于阅读,即使它稍微复杂一些。我们没有用我们可能使用的所有选项的变量填充 PrefabBuilder - 我们将它们分开保存。这通常是一个好的做法 - 它使阅读您代码的人更容易清楚地了解正在发生的事情。
现在我们将重新实现我们之前从 image_loader.rs 中删除的地图读取器 - 只是我们将它添加为 PrefabBuilder 的成员函数,并使用封闭类特性,而不是传入和传出 Map 和 new_depth:
#![allow(unused)] fn main() { #[allow(dead_code)] fn load_rex_map(&mut self, path: &str) { let xp_file = rltk::rex::XpFile::from_resource(path).unwrap(); for layer in &xp_file.layers { for y in 0..layer.height { for x in 0..layer.width { let cell = layer.get(x, y).unwrap(); if x < self.map.width as usize && y < self.map.height as usize { let idx = self.map.xy_idx(x as i32, y as i32); match (cell.ch as u8) as char { ' ' => self.map.tiles[idx] = TileType::Floor, // 空格 '#' => self.map.tiles[idx] = TileType::Wall, // # _ => {} } } } } } } }
这非常直接,或多或少是波函数坍缩章节中形式的直接移植。现在让我们开始制作我们的 build 函数:
#![allow(unused)] fn main() { fn build(&mut self) { match self.mode { PrefabMode::RexLevel{template} => self.load_rex_map(&template) } // 找到一个起始点;从中间开始向左走,直到找到一个开放的瓦片 self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 }; let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); while self.map.tiles[start_idx] != TileType::Floor { self.starting_position.x -= 1; start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); } self.take_snapshot(); } }
请注意,我们已经复制了寻找起始点代码;我们将在某个时候改进它,但目前它确保您可以玩您的关卡。我们没有生成任何东西 - 所以您将独自一人在关卡中。这里也稍微不同地使用了 match - 我们正在使用枚举中的变量。代码 PrefabMode::RexLevel{template} 表示“匹配 RexLevel,但使用 template 的任何值 - 并在匹配范围内通过名称 template 使该值可用”。如果您不想访问它,可以使用 _ 来匹配任何值。Rust 的模式匹配系统 确实令人印象深刻 - 您可以使用它做很多事情!
让我们修改我们的 random_builder 函数以始终调用这种类型的地图(这样我们就不必一遍又一遍地测试,希望得到我们想要的那一个!)。在 map_builders/mod.rs 中:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { /* let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 16); let mut result : Box<dyn MapBuilder>; match builder { 1 => { result = Box::new(BspDungeonBuilder::new(new_depth)); } 2 => { result = Box::new(BspInteriorBuilder::new(new_depth)); } 3 => { result = Box::new(CellularAutomataBuilder::new(new_depth)); } 4 => { result = Box::new(DrunkardsWalkBuilder::open_area(new_depth)); } 5 => { result = Box::new(DrunkardsWalkBuilder::open_halls(new_depth)); } 6 => { result = Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)); } 7 => { result = Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)); } 8 => { result = Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)); } 9 => { result = Box::new(MazeBuilder::new(new_depth)); } 10 => { result = Box::new(DLABuilder::walk_inwards(new_depth)); } 11 => { result = Box::new(DLABuilder::walk_outwards(new_depth)); } 12 => { result = Box::new(DLABuilder::central_attractor(new_depth)); } 13 => { result = Box::new(DLABuilder::insectoid(new_depth)); } 14 => { result = Box::new(VoronoiCellBuilder::pythagoras(new_depth)); } 15 => { result = Box::new(VoronoiCellBuilder::manhattan(new_depth)); } _ => { result = Box::new(SimpleMapBuilder::new(new_depth)); } } if rng.roll_dice(1, 3)==1 { result = Box::new(WaveformCollapseBuilder::derived_map(new_depth, result)); } result*/ Box::new(PrefabBuilder::new(new_depth)) } }
如果您现在 cargo run 您的项目,您可以在(原本荒凉的)演示地图中四处奔跑:
.
使用预制实体填充测试地图
让我们假设我们的测试地图是某种超级终极游戏地图。我们将复制一份并将其命名为 wfc-populated.xp。然后我们将在其周围喷溅一堆怪物和物品字形:
.
颜色编码是完全可选的,但我为了清晰起见将其放入。您会看到我们有一个 @ 来指示玩家起始位置,一个 > 来指示出口,以及一堆 g 地精,o 兽人,! 药水,% 军粮和 ^ 陷阱。地图还不错,真的。
我们将 wfc-populated.xp 添加到我们的 resources 文件夹中,并扩展 rex_assets.rs 以加载它:
#![allow(unused)] fn main() { use rltk::{rex::XpFile}; rltk::embedded_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp"); rltk::embedded_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp"); rltk::embedded_resource!(WFC_POPULATED, "../../resources/wfc-populated.xp"); pub struct RexAssets { pub menu : XpFile } impl RexAssets { #[allow(clippy::new_without_default)] pub fn new() -> RexAssets { rltk::link_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp"); rltk::link_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp"); rltk::link_resource!(WFC_POPULATED, "../../resources/wfc-populated.xp"); RexAssets{ menu : XpFile::from_resource("../../resources/SmallDungeon_80x50.xp").unwrap() } } } }
我们还希望能够列出地图所需的生成物。查看 spawner.rs,我们有一个已建立的 tuple 格式,用于传递生成物 - 所以我们将在结构体中使用它:
#![allow(unused)] fn main() { #[allow(dead_code)] pub struct PrefabBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, mode: PrefabMode, spawns: Vec<(usize, String)> } }
现在我们将修改我们的构造函数以使用新地图,并初始化 spawns:
#![allow(unused)] fn main() { impl PrefabBuilder { #[allow(dead_code)] pub fn new(new_depth : i32) -> PrefabBuilder { PrefabBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history : Vec::new(), mode : PrefabMode::RexLevel{ template : "../../resources/wfc-populated.xp" }, spawns: Vec::new() } } ... }
为了使用 spawner.rs 中接受这种类型数据的函数,我们需要使其成为 public 的。所以我们打开文件,并在函数签名中添加单词 pub:
#![allow(unused)] fn main() { /// 在 (tuple.0) 中的位置生成一个命名的实体(tuple.1 中的名称) pub fn spawn_entity(ecs: &mut World, spawn : &(&usize, &String)) { ... }
然后我们将修改 PrefabBuilder 的 spawn_entities 函数以使用此数据:
#![allow(unused)] fn main() { fn spawn_entities(&mut self, ecs : &mut World) { for entity in self.spawns.iter() { spawner::spawn_entity(ecs, &(&entity.0, &entity.1)); } } }
我们使用引用做了一些舞蹈,只是为了使用之前的函数签名(并且不必更改它,这将更改许多其他代码)。到目前为止,一切都很好 - 它读取 spawn 列表,并请求将列表中的所有内容放置到地图上。现在是向列表中添加一些内容的好时机!我们将要修改我们的 load_rex_map 以处理新数据:
#![allow(unused)] fn main() { #[allow(dead_code)] fn load_rex_map(&mut self, path: &str) { let xp_file = rltk::rex::XpFile::from_resource(path).unwrap(); for layer in &xp_file.layers { for y in 0..layer.height { for x in 0..layer.width { let cell = layer.get(x, y).unwrap(); if x < self.map.width as usize && y < self.map.height as usize { let idx = self.map.xy_idx(x as i32, y as i32); // 我们正在做一些令人讨厌的类型转换,以便更容易在匹配中键入诸如 '#' 之类的东西 match (cell.ch as u8) as char { ' ' => self.map.tiles[idx] = TileType::Floor, '#' => self.map.tiles[idx] = TileType::Wall, '@' => { self.map.tiles[idx] = TileType::Floor; self.starting_position = Position{ x:x as i32, y:y as i32 }; } '>' => self.map.tiles[idx] = TileType::DownStairs, 'g' => { self.map.tiles[idx] = TileType::Floor; self.spawns.push((idx, "Goblin".to_string())); } 'o' => { self.map.tiles[idx] = TileType::Floor; self.spawns.push((idx, "Orc".to_string())); } '^' => { self.map.tiles[idx] = TileType::Floor; self.spawns.push((idx, "Bear Trap".to_string())); } '%' => { self.map.tiles[idx] = TileType::Floor; self.spawns.push((idx, "Rations".to_string())); } '!' => { self.map.tiles[idx] = TileType::Floor; self.spawns.push((idx, "Health Potion".to_string())); } _ => { rltk::console::log(format!("加载地图时未知字形: {}", (cell.ch as u8) as char)); } } } } } } } }
这可以识别额外的字形,并且如果我们加载了我们忘记处理的字形,则会在控制台中打印警告。请注意,对于实体,我们将瓦片设置为 Floor,然后 添加实体类型。这是因为我们不能在同一瓦片上叠加两个字形 - 但有理由认为实体是站立在地板上的。
最后,我们需要修改我们的 build 函数,使其不移动出口和玩家。我们只需将回退代码包装在一个 if 语句中,以检测我们是否设置了 starting_position(我们将要求如果您设置了起始位置,您也设置出口):
#![allow(unused)] fn main() { fn build(&mut self) { match self.mode { PrefabMode::RexLevel{template} => self.load_rex_map(&template) } self.take_snapshot(); // 找到一个起始点;从中间开始向左走,直到找到一个开放的瓦片 if self.starting_position.x == 0 { self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 }; let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); while self.map.tiles[start_idx] != TileType::Floor { self.starting_position.x -= 1; start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y); } self.take_snapshot(); // 找到我们可以从起点到达的所有瓦片 let exit_tile = remove_unreachable_areas_returning_most_distant(&mut self.map, start_idx); self.take_snapshot(); // 放置楼梯 self.map.tiles[exit_tile] = TileType::DownStairs; self.take_snapshot(); } } }
如果您现在 cargo run 该项目,您将从指定的位置开始 - 并且实体会在您周围生成。
.
无 Rex 预制件
您可能不喜欢 Rex Paint(别担心,我不会告诉 Kyzrati!),也许您在不支持它的平台上 - 或者也许您只是不想依赖外部工具。我们将扩展我们的读取器以也支持地图的字符串输出。当我们处理小房间预制件/vaults 时,这将非常方便。
我作弊了一下,在 Rex 中打开了 wfc-populated.xp 文件,然后键入 ctrl-t 以 TXT 格式保存。这给了我一个不错的 Notepad 友好的地图文件:
.
我也意识到 prefab_builder 将会超出单个文件的大小!幸运的是,Rust 使将模块变成多文件怪物变得非常容易。在 map_builders 中,我创建了一个名为 prefab_builder 的新目录。然后我将 prefab_builder.rs 移动到其中,并将其重命名为 mod.rs。游戏编译并运行的方式与以前完全相同。
在您的 prefab_builder 文件夹中创建一个新文件,并将其命名为 prefab_levels.rs。我们将粘贴地图定义,并稍微装饰一下:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub struct PrefabLevel { pub template : &'static str, pub width : usize, pub height: usize } pub const WFC_POPULATED : PrefabLevel = PrefabLevel{ template : LEVEL_MAP, width: 80, height: 43 }; const LEVEL_MAP : &str = " ############################################################################### ######################################################## ######### @ ###### ######### #### ################### ####### #### g # ############### ##### #### # # ####### #### ############# ### #### ######### # # ####### ######### #### ##### ### #### ######### ###### ####### o ######### #### ## ##### ### # #### ######### ### ## o ### #### ######### ### #### ####### ## ##### ### #### ######### ### #### ####### # ### ## ##### ### #### ######### ### #### ####### ####### ##### o ### ## ## ### #### ####### ################ ### ## ## ### o ###### ########### # ############ ### ## ## ### ###### ########### ### ### ## % ###### ########### # ### ! ## ### ## ## ### ###### ## ####### ## ### ## ## ### ## ### ##### # ######################## ##### ## ## ### ## ### ##### # # ###################### ##### ### ## ####### ###### ##### ### #### o ########### ###### ##### ### ## ####### ###### #### ## #### # ######### ###### ###### ## ####### ###### #### ## #### ############ ##### ###### g ## ####### ###### #### ## % ########### o o #### # # ## ### #### ## #### # ####### ## ## #### g # ###### ####### #### ###### ! ! ### # # ##### ##### #### # ###### ### ###### #### ##### # ########## ### ###### #### ! ### ###### # ########## o##o ### # ## #### ### ####### ## # ###### ### g ## ## #### ######## ### o ####### ^########^ #### # ## g # ###### ######## ##### ####### ^ ^ #### ###### ##g#### ###### ######## ################ ##### ###### ## ########## ########## ######## ################# ###### # #### ######### ########## % ######## ################### ######## ## # ### ### ######## ########## ######## #################### ########## # # ## ##### ###### ######### ######## ########### ####### # g# # ## ##### ############### ### ########### ####### #### # ## ##### #### ############## ######## g g ########### #### # ^ # ### ###^#### ############# ######## ##### #### # g# # #### ###### ### ######## ##### g #### ! ####^^ # #!%^## ### ## ########## ######## gg g # > # #!%^ ### ### ############### ######## ##### g #### # g# # %^## ^ ### ############### ######## ##### ################## ############################################################################### "; }
所以我们首先定义一个新的 struct 类型:PrefabLevel。它包含一个地图模板、宽度和高度。然后我们创建一个常量 WFC_POPULATED,并在其中创建一个始终可用的关卡定义。最后,我们将我们的 Notepad 文件粘贴到一个新的常量中,目前称为 MY_LEVEL。这是一个大字符串,将像任何其他字符串一样存储。
让我们修改 mode 以也允许这种类型:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] #[allow(dead_code)] pub enum PrefabMode { RexLevel{ template : &'static str }, Constant{ level : prefab_levels::PrefabLevel } } }
我们将修改我们的 build 函数以也处理这种 match 模式:
#![allow(unused)] fn main() { fn build(&mut self) { match self.mode { PrefabMode::RexLevel{template} => self.load_rex_map(&template), PrefabMode::Constant{level} => self.load_ascii_map(&level) } self.take_snapshot(); ... }
并修改我们的构造函数以使用它:
#![allow(unused)] fn main() { impl PrefabBuilder { #[allow(dead_code)] pub fn new(new_depth : i32) -> PrefabBuilder { PrefabBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history : Vec::new(), mode : PrefabMode::Constant{level : prefab_levels::WFC_POPULATED}, spawns: Vec::new() } } }
现在我们需要创建一个可以处理它的加载器。我们将修改我们的 load_rex_map 以与其共享一些代码,这样我们就不会重复键入所有内容 - 并制作我们的新 load_ascii_map 函数:
#![allow(unused)] fn main() { fn char_to_map(&mut self, ch : char, idx: usize) { match ch { ' ' => self.map.tiles[idx] = TileType::Floor, '#' => self.map.tiles[idx] = TileType::Wall, '@' => { let x = idx as i32 % self.map.width; let y = idx as i32 / self.map.width; self.map.tiles[idx] = TileType::Floor; self.starting_position = Position{ x:x as i32, y:y as i32 }; } '>' => self.map.tiles[idx] = TileType::DownStairs, 'g' => { self.map.tiles[idx] = TileType::Floor; self.spawns.push((idx, "Goblin".to_string())); } 'o' => { self.map.tiles[idx] = TileType::Floor; self.spawns.push((idx, "Orc".to_string())); } '^' => { self.map.tiles[idx] = TileType::Floor; self.spawns.push((idx, "Bear Trap".to_string())); } '%' => { self.map.tiles[idx] = TileType::Floor; self.spawns.push((idx, "Rations".to_string())); } '!' => { self.map.tiles[idx] = TileType::Floor; self.spawns.push((idx, "Health Potion".to_string())); } _ => { rltk::console::log(format!("加载地图时未知字形: {}", (ch as u8) as char)); } } } #[allow(dead_code)] fn load_rex_map(&mut self, path: &str) { let xp_file = rltk::rex::XpFile::from_resource(path).unwrap(); for layer in &xp_file.layers { for y in 0..layer.height { for x in 0..layer.width { let cell = layer.get(x, y).unwrap(); if x < self.map.width as usize && y < self.map.height as usize { let idx = self.map.xy_idx(x as i32, y as i32); // 我们正在做一些令人讨厌的类型转换,以便更容易在匹配中键入诸如 '#' 之类的东西 self.char_to_map(cell.ch as u8 as char, idx); } } } } } #[allow(dead_code)] fn load_ascii_map(&mut self, level: &prefab_levels::PrefabLevel) { // 首先转换为一个向量,删除换行符 let mut string_vec : Vec<char> = level.template.chars().filter(|a| *a != '\r' && *a !='\n').collect(); for c in string_vec.iter_mut() { if *c as u8 == 160u8 { *c = ' '; } } let mut i = 0; for ty in 0..level.height { for tx in 0..level.width { if tx < self.map.width as usize && ty < self.map.height as usize { let idx = self.map.xy_idx(tx as i32, ty as i32); self.char_to_map(string_vec[i], idx); } i += 1; } } } }
首先要注意的是 load_rex_map 中的巨大 match 现在是一个函数 - char_to_map。由于我们多次使用该功能,因此这是一个好的做法:现在如果搞砸了,我们只需要修复一次!否则,load_rex_map 几乎相同。我们的新函数是 load_ascii_map。它从一些丑陋的代码开始,这些代码需要解释:
let mut string_vec : Vec<char> = level.template.chars().filter(|a| *a != '\r' && *a !='\n').collect();是一种常见的 Rust 模式,但根本不是不言自明的。它以从左到右的顺序将方法链接在一起。所以它实际上是一大堆粘合在一起的指令:let mut string_vec : Vec<char>只是说“创建一个名为string_vec的变量,或Vec<char>类型,并允许我编辑它。level.template是我们的关卡模板所在的字符串。.chars()将字符串转换为迭代器 - 与我们之前键入myvector.iter()时相同。.filter(|a| *a != '\r' && *a !='\n')很有趣。过滤器接受一个 lambda 函数,并保留任何返回true的条目。所以在这种情况下,我们正在剥离\r和\n- 两个换行符。我们将保留其他所有内容。.collect()表示“获取我之前的所有内容的結果,并将它们放入一个向量中。”
- 然后,我们可变地迭代字符串向量,并将字符
160转换为空格。我真的不知道为什么文本将空格读取为字符 160 而不是 32,但我们将接受它并将其转换。 - 然后我们从
0到指定的高度迭代y。- 然后我们从
0到指定的宽度迭代x。- 如果
x和y值在我们正在创建的地图范围内,我们计算地图瓦片的idx- 并调用我们的char_to_map函数来转换它。
- 如果
- 然后我们从
如果您现在 cargo run,您将看到与以前完全相同的内容 - 但是我们不是加载 Rex Paint 文件,而是从 prefab_levels.rs 中的常量 ASCII 加载它。
构建关卡片段
你勇敢的冒险家从蜿蜒的隧道中出现,遇到了一个古老的地下防御工事的墙壁! 这是伟大的 D&D 故事的素材,也是《地下城爬行:石汤》等游戏中偶尔发生的事情。很可能实际发生的是你勇敢的冒险家从程序生成的地图中出现,并找到了一个关卡片段预制件!
我们将扩展我们的地图系统以明确支持这一点:一个常规构建器创建一个地图,然后一个分段预制件用您令人兴奋的预制内容替换地图的一部分。我们将首先创建一个新文件(在 map_builders/prefab_builder 中),名为 prefab_sections.rs,并放置我们想要的描述:
#![allow(unused)] fn main() { #[allow(dead_code)] #[derive(PartialEq, Copy, Clone)] pub enum HorizontalPlacement { Left, Center, Right } #[allow(dead_code)] #[derive(PartialEq, Copy, Clone)] pub enum VerticalPlacement { Top, Center, Bottom } #[allow(dead_code)] #[derive(PartialEq, Copy, Clone)] pub struct PrefabSection { pub template : &'static str, pub width : usize, pub height: usize, pub placement : (HorizontalPlacement, VerticalPlacement) } #[allow(dead_code)] pub const UNDERGROUND_FORT : PrefabSection = PrefabSection{ template : RIGHT_FORT, width: 15, height: 43, placement: ( HorizontalPlacement::Right, VerticalPlacement::Top ) }; #[allow(dead_code)] const RIGHT_FORT : &str = " ###### # ####### g # ####### # ## ### # # ## ^ ^ ## # # # # ## ### # # g # # # ## ### # # # ## ^ ^ ## # # # ## ### # ####### g # ####### # ###### "; }
所以我们有 RIGHT_FORT 作为一个字符串,描述了我们可能遇到的防御工事。我们构建了一个结构体 PrefabSection,其中包括放置提示,以及我们的实际堡垒 (UNDERGROUND_FORT) 的常量,指定我们希望在地图的右侧,顶部(垂直位置在这个例子中并不重要,因为它与地图的完整大小相同)。
关卡片段与我们之前制作的构建器不同,因为它们采用已完成的地图 - 并替换其中的一部分。我们在波函数坍缩中做了一些类似的事情,所以我们将采用类似的模式。我们将首先修改我们的 PrefabBuilder 以了解新型的地图装饰:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] #[allow(dead_code)] pub enum PrefabMode { RexLevel{ template : &'static str }, Constant{ level : prefab_levels::PrefabLevel }, Sectional{ section : prefab_sections::PrefabSection } } #[allow(dead_code)] pub struct PrefabBuilder { map : Map, starting_position : Position, depth: i32, history: Vec<Map>, mode: PrefabMode, spawns: Vec<(usize, String)>, previous_builder : Option<Box<dyn MapBuilder>> } }
尽管我很想将 previous_builder 放入枚举中,但我一直遇到生命周期问题。也许有一种方法可以做到这一点(并且一些好心的读者会帮助我?),但目前我已将其放入 PrefabBuilder 中。但是,请求的地图片段在参数中。我们还更新了构造函数以使用这种类型的地图:
#![allow(unused)] fn main() { impl PrefabBuilder { #[allow(dead_code)] pub fn new(new_depth : i32, previous_builder : Option<Box<dyn MapBuilder>>) -> PrefabBuilder { PrefabBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history : Vec::new(), mode : PrefabMode::Sectional{ section: prefab_sections::UNDERGROUND_FORT }, spawns: Vec::new(), previous_builder } } ... }
在 map_builders/mod.rs 的 random_builder 中,我们将修改构建器以首先运行细胞自动机地图,然后应用分段:
#![allow(unused)] fn main() { Box::new( PrefabBuilder::new( new_depth, Some( Box::new( CellularAutomataBuilder::new(new_depth) ) ) ) ) }
这可能是一行代码,但由于括号数量众多,我将其分开了。
接下来,我们更新我们的 match 语句(在 build() 中)以实际调用构建器:
#![allow(unused)] fn main() { fn build(&mut self) { match self.mode { PrefabMode::RexLevel{template} => self.load_rex_map(&template), PrefabMode::Constant{level} => self.load_ascii_map(&level), PrefabMode::Sectional{section} => self.apply_sectional(§ion) } self.take_snapshot(); ... }
现在,我们将编写 apply_sectional:
#![allow(unused)] fn main() { pub fn apply_sectional(&mut self, section : &prefab_sections::PrefabSection) { // 构建地图 let prev_builder = self.previous_builder.as_mut().unwrap(); prev_builder.build_map(); self.starting_position = prev_builder.get_starting_position(); self.map = prev_builder.get_map().clone(); self.take_snapshot(); use prefab_sections::*; let string_vec = PrefabBuilder::read_ascii_to_vec(section.template); // 放置新片段 let chunk_x; match section.placement.0 { HorizontalPlacement::Left => chunk_x = 0, HorizontalPlacement::Center => chunk_x = (self.map.width / 2) - (section.width as i32 / 2), HorizontalPlacement::Right => chunk_x = (self.map.width-1) - section.width as i32 } let chunk_y; match section.placement.1 { VerticalPlacement::Top => chunk_y = 0, VerticalPlacement::Center => chunk_y = (self.map.height / 2) - (section.height as i32 / 2), VerticalPlacement::Bottom => chunk_y = (self.map.height-1) - section.height as i32 } println!("{},{}", chunk_x, chunk_y); let mut i = 0; for ty in 0..section.height { for tx in 0..section.width { if tx < self.map.width as usize && ty < self.map.height as usize { let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y); self.char_to_map(string_vec[i], idx); } i += 1; } } self.take_snapshot(); } }
这与我们编写的其他代码非常相似,但无论如何让我们逐步了解它:
let prev_builder = self.previous_builder.as_mut().unwrap();非常拗口。之前的构建器是一个Option- 但如果我们调用此代码,它必须有一个值。所以我们想unwrap它(如果没有任何值,它会 panic 并崩溃),但我们不能!如果我们只是调用previous_builder.unwrap,借用检查器会抱怨 - 所以我们必须在那里注入一个as_mut(),Option为此目的提供了它。- 我们在之前的构建器上调用
build_map,以构建基础地图。 - 我们将起始位置从之前的构建器复制到我们的新构建器。
- 我们将地图从之前的构建器复制到我们自己(新的构建器)。
- 我们调用
read_ascii_to_vec,它与关卡示例中的字符串到向量代码相同;实际上,我们已经更新了关卡示例以也使用它,在源代码中。 - 我们创建两个变量
chunk_x和chunk_y并查询片段的放置偏好以确定将新块放在哪里。 - 我们像之前迭代关卡一样迭代片段 - 但将
chunk_x添加到tx,将chunk_y添加到ty以偏移关卡内的片段。
如果您现在 cargo run 该示例,您将看到一张用洞穴建造的地图 - 以及右侧的防御工事。
.
您可能还会注意到,除了预制区域之外,根本没有任何实体!
向片段添加实体
生成和确定生成点在逻辑上是分开的,以帮助保持地图生成代码的清洁。不同的地图可以有自己的实体放置策略,因此没有一种直接的方法可以简单地吸取先前算法中的数据并添加到其中。应该有,并且它应该启用过滤以及稍后“元地图构建器”(例如 WFC 或此构建器)的各种调整。我们在预制件中放置实体的代码中偶然发现了一个关于良好接口的线索:生成系统已经支持 (position, type string) 的 tuples。我们将使用它作为新设置的基础。
我们将首先打开 map_builders/mod.rs 并编辑 MapBuilder trait:
#![allow(unused)] fn main() { pub trait MapBuilder { fn build_map(&mut self); fn get_map(&self) -> Map; fn get_starting_position(&self) -> Position; fn get_snapshot_history(&self) -> Vec<Map>; fn take_snapshot(&mut self); fn get_spawn_list(&self) -> &Vec<(usize, String)>; fn spawn_entities(&mut self, ecs : &mut World) { for entity in self.get_spawn_list().iter() { spawner::spawn_entity(ecs, &(&entity.0, &entity.1)); } } } }
恭喜,您一半的源代码在您的 IDE 中都变成了红色。这就是更改基本接口的危险 - 您最终会在所有地方实现它。此外,spawn_entities 的设置已更改 - 现在有一个默认实现。trait 的实现者如果愿意,可以覆盖它 - 否则他们实际上不需要再编写它了。由于一切应该通过 get_spawn_list 函数可用,因此 trait 拥有提供该实现所需的一切。
我们将回到 simple_map 并更新它以遵守新的 trait 规则。我们将扩展 SimpleMapBuiler 结构以包含生成列表:
#![allow(unused)] fn main() { pub struct SimpleMapBuilder { map : Map, starting_position : Position, depth: i32, rooms: Vec<Rect>, history: Vec<Map>, spawn_list: Vec<(usize, String)> } }
get_spawn_list 的实现很简单:
#![allow(unused)] fn main() { fn get_spawn_list(&self) -> &Vec<(usize, String)> { &self.spawn_list } }
现在到了有趣的部分。以前,我们直到调用 spawn_entities 才考虑生成。让我们提醒自己它做了什么(已经有一段时间了!):
#![allow(unused)] fn main() { fn spawn_entities(&mut self, ecs : &mut World) { for room in self.rooms.iter().skip(1) { spawner::spawn_room(ecs, room, self.depth); } } }
它迭代所有房间,并在房间内生成实体。我们经常使用这种模式,所以现在是时候访问 spawner.rs 中的 spawn_room 了。我们将修改它以生成到 spawn_list 中,而不是直接在地图上生成。所以我们打开 spawner.rs,并修改 spawn_room 和 spawn_region(由于它们是交织在一起的,我们将一起修复它们):
#![allow(unused)] fn main() { /// 用东西填充房间! pub fn spawn_room(map: &Map, rng: &mut RandomNumberGenerator, room : &Rect, map_depth: i32, spawn_list : &mut Vec<(usize, String)>) { let mut possible_targets : Vec<usize> = Vec::new(); { // 借用范围 - 保持对地图的访问分开 for y in room.y1 + 1 .. room.y2 { for x in room.x1 + 1 .. room.x2 { let idx = map.xy_idx(x, y); if map.tiles[idx] == TileType::Floor { possible_targets.push(idx); } } } } spawn_region(map, rng, &possible_targets, map_depth, spawn_list); } /// 用东西填充区域! pub fn spawn_region(map: &Map, rng: &mut RandomNumberGenerator, area : &[usize], map_depth: i32, spawn_list : &mut Vec<(usize, String)>) { let spawn_table = room_table(map_depth); let mut spawn_points : HashMap<usize, String> = HashMap::new(); let mut areas : Vec<usize> = Vec::from(area); // 范围以使借用检查器满意 { let num_spawns = i32::min(areas.len() as i32, rng.roll_dice(1, MAX_MONSTERS + 3) + (map_depth - 1) - 3); if num_spawns == 0 { return; } for _i in 0 .. num_spawns { let array_index = if areas.len() == 1 { 0usize } else { (rng.roll_dice(1, areas.len() as i32)-1) as usize }; let map_idx = areas[array_index]; spawn_points.insert(map_idx, spawn_table.roll(rng)); areas.remove(array_index); } } // 实际生成怪物 for spawn in spawn_points.iter() { spawn_list.push((*spawn.0, spawn.1.to_string())); } } }
您会注意到最大的变化是在每个函数中获取对 spawn_list 的可变引用,而不是实际生成实体 - 我们通过在末尾将生成信息推送到 spawn_list 向量中来延迟操作。我们没有传入 ECS,而是传入 Map 和 RandomNumberGenerator。
回到 simple_map.rs,我们将生成代码移动到 build 的末尾:
#![allow(unused)] fn main() { ... self.starting_position = Position{ x: start_pos.0, y: start_pos.1 }; // 生成一些实体 for room in self.rooms.iter().skip(1) { spawner::spawn_room(&self.map, &mut rng, room, self.depth, &mut self.spawn_list); } }
我们现在可以删除 SimpleMapBuilder 的 spawn_entities 实现 - 默认实现将正常工作。
同样的更改可以应用于所有依赖房间生成的构建器;为简洁起见,我不会在此处详细说明所有内容 - 您可以在源代码中找到它们。使用 Voronoi 图的各种构建器也同样易于更新。例如,细胞自动机。将 spawn_list 添加到构建器结构,并在构造函数中添加 spawn_list : Vec::new()。将怪物生成从 spawn_entities 移动到 build 的末尾并删除该函数。从其他实现中复制 get_spawn_list。我们稍微更改了区域生成代码,所以这是来自 cellular_automata.rs 的实现:
#![allow(unused)] fn main() { // 现在我们构建一个噪声地图,用于稍后生成实体 self.noise_areas = generate_voronoi_spawn_regions(&self.map, &mut rng); // 生成实体 for area in self.noise_areas.iter() { spawner::spawn_region(&self.map, &mut rng, area.1, self.depth, &mut self.spawn_list); } }
再次,在其他 Voronoi 生成算法上重复此操作。如果您想看一下,我已经在源代码中为您完成了这项工作。
如果重构很无聊,请跳转到这里!
所以 - 既然我们已经重构了我们的生成系统,我们如何在我们的 PrefabBuilder 中使用它?我们可以在我们的 apply_sectional 函数中添加一行,并从之前的地图中获取所有实体。您可以简单地复制它,但这可能不是您想要的;您需要过滤掉新预制件内部的实体,既要为新的实体腾出空间,又要确保生成是有意义的。我们还需要稍微重新排列一下,以使借用检查器满意。这是现在的函数:
#![allow(unused)] fn main() { pub fn apply_sectional(&mut self, section : &prefab_sections::PrefabSection) { use prefab_sections::*; let string_vec = PrefabBuilder::read_ascii_to_vec(section.template); // 放置新片段 let chunk_x; match section.placement.0 { HorizontalPlacement::Left => chunk_x = 0, HorizontalPlacement::Center => chunk_x = (self.map.width / 2) - (section.width as i32 / 2), HorizontalPlacement::Right => chunk_x = (self.map.width-1) - section.width as i32 } let chunk_y; match section.placement.1 { VerticalPlacement::Top => chunk_y = 0, VerticalPlacement::Center => chunk_y = (self.map.height / 2) - (section.height as i32 / 2), VerticalPlacement::Bottom => chunk_y = (self.map.height-1) - section.height as i32 } // 构建地图 let prev_builder = self.previous_builder.as_mut().unwrap(); prev_builder.build_map(); self.starting_position = prev_builder.get_starting_position(); self.map = prev_builder.get_map().clone(); for e in prev_builder.get_spawn_list().iter() { let idx = e.0; let x = idx as i32 % self.map.width; let y = idx as i32 / self.map.width; if x < chunk_x || x > (chunk_x + section.width as i32) || y < chunk_y || y > (chunk_y + section.height as i32) { self.spawn_list.push( (idx, e.1.to_string()) ) } } self.take_snapshot(); let mut i = 0; for ty in 0..section.height { for tx in 0..section.width { if tx > 0 && tx < self.map.width as usize -1 && ty < self.map.height as usize -1 && ty > 0 { let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y); self.char_to_map(string_vec[i], idx); } i += 1; } } self.take_snapshot(); } }
如果您现在 cargo run,您将在地图的两个部分都面对敌人。
.
总结
在本章中,我们涵盖了很多内容:
- 我们可以加载 Rex Paint 关卡,完成手工放置的实体并进行游戏。
- 我们可以在游戏中定义 ASCII 预制地图,并进行游戏(无需使用 Rex Paint)。
- 我们可以加载关卡片段,并将它们应用于关卡。
- 我们可以调整构建器链中先前关卡的生成物。
...
本章的源代码可以在这里找到
在您的浏览器中使用 Web 程序集运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
地牢密室
关于本教程
本教程是免费开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢本教程并希望我继续创作,请考虑支持我的 Patreon。
上一章内容过长,因此分成了两部分。 在上一章中,我们学习了如何加载预制地图和地图区块,修改了生成系统,以便 元构建器 可以影响先前构建器的生成模式,并演示了将整个地图块集成到关卡中。 在本章中,我们将深入探讨 房间密室 - 可以将自身集成到您的关卡中的预制内容。 因此,您可以手工制作一些房间,并使其无缝地融入您现有的地图中。
设计房间:完全不是陷阱
Roguelike 游戏开发者的生活一部分是程序员,一部分是室内设计师(以一种古怪的侏儒疯狂科学家的风格)。 我们已经设计了整个关卡和关卡区块,所以设计房间并不是一个巨大的飞跃。 让我们继续构建一些预先设计的房间。
我们将在 map_builders/prefab_builders 中创建一个名为 prefab_rooms.rs 的新文件。 我们将在其中插入一个相对标志性的地图特征:
#![allow(unused)] fn main() { #[allow(dead_code)] #[derive(PartialEq, Copy, Clone)] pub struct PrefabRoom { pub template : &'static str, pub width : usize, pub height: usize, pub first_depth: i32, pub last_depth: i32 } #[allow(dead_code)] pub const TOTALLY_NOT_A_TRAP : PrefabRoom = PrefabRoom{ template : TOTALLY_NOT_A_TRAP_MAP, width: 5, height: 5, first_depth: 0, last_depth: 100 }; #[allow(dead_code)] const TOTALLY_NOT_A_TRAP_MAP : &str = " ^^^ ^!^ ^^^ "; }
如果您查看 ASCII 码,您会看到一个经典的地图设计:一个生命药水完全被陷阱包围。 由于陷阱默认是隐藏的,我们指望玩家会想“嗯,这看起来一点也不可疑”! 注意到内容周围都有空格 - 周围有一圈 1 格的 沟槽。 这确保了放置密室的任何 5x5 房间仍然是可通行的。 我们还引入了 first_depth 和 last_depth - 这些是密室 可能 应用的关卡; 为了便于介绍,我们选择 0..100 - 这应该是每个关卡,除非你是 真的 非常敬业的测试玩家!
放置“完全不是陷阱”房间
我们将首先在 PrefabBuiler 系统中添加另一种 模式:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] #[allow(dead_code)] pub enum PrefabMode { RexLevel{ template : &'static str }, Constant{ level : prefab_levels::PrefabLevel }, Sectional{ section : prefab_sections::PrefabSection }, RoomVaults } }
我们 暂时 不会添加任何参数 - 在本章末尾,我们将把它集成到一个更广泛的密室放置系统中。 我们将更新我们的构造函数以使用这种类型的放置:
#![allow(unused)] fn main() { impl PrefabBuilder { #[allow(dead_code)] pub fn new(new_depth : i32, previous_builder : Option<Box<dyn MapBuilder>>) -> PrefabBuilder { PrefabBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history : Vec::new(), mode : PrefabMode::RoomVaults, previous_builder, spawn_list : Vec::new() } } ... }
我们将教导 build 中的 match 函数使用它:
#![allow(unused)] fn main() { fn build(&mut self) { match self.mode { PrefabMode::RexLevel{template} => self.load_rex_map(&template), PrefabMode::Constant{level} => self.load_ascii_map(&level), PrefabMode::Sectional{section} => self.apply_sectional(§ion), PrefabMode::RoomVaults => self.apply_room_vaults() } self.take_snapshot(); ... }
接下来合乎逻辑的步骤是编写 apply_room_vaults。 我们的目标是扫描传入的地图(来自不同的构建器,甚至是这个构建器的先前迭代!),找到可以放置密室的合适位置,并将其添加到地图中。 我们还希望删除密室区域中任何已生成的生物 - 以便密室保持手工制作,并且不受随机生成的影响。
我们将重用我们在 apply_sectional 中的“创建先前迭代”代码 - 让我们将其重写为更通用的形式:
#![allow(unused)] fn main() { fn apply_previous_iteration<F>(&mut self, mut filter: F) where F : FnMut(i32, i32, &(usize, String)) -> bool { // 构建地图 let prev_builder = self.previous_builder.as_mut().unwrap(); prev_builder.build_map(); self.starting_position = prev_builder.get_starting_position(); self.map = prev_builder.get_map().clone(); for e in prev_builder.get_spawn_list().iter() { let idx = e.0; let x = idx as i32 % self.map.width; let y = idx as i32 / self.map.width; if filter(x, y, e) { self.spawn_list.push( (idx, e.1.to_string()) ) } } self.take_snapshot(); } }
这里有很多新的 Rust 代码! 让我们逐步分析一下:
- 您会注意到我们为函数添加了一个 模板 类型。
fn apply_previous_iteration<F>。 这指定了我们在编写函数时并不知道F到底是什么。 - 第二个参数 (
mut filter: F) 也是F类型。 因此,我们告诉函数签名接受模板类型作为参数。 - 在左大括号之前,我们添加了一个
where子句。 这种子句类型 可用于 限制 泛型类型接受的内容。 在本例中,我们说F必须 是FnMut。FnMut是一个 函数指针,它允许更改状态(可变的;如果是不可变的,则为Fn)。 然后我们指定函数的参数及其返回类型。 在函数内部,我们现在可以将filter视为函数 - 即使我们实际上没有编写一个函数。 我们要求该函数接受两个i32(整数)和一个(usize, String)的tuple。 后者应该看起来很熟悉 - 它是我们的生成列表格式。 前两个是生成的x和y坐标 - 我们传递它是为了避免调用者每次都进行数学运算。 - 然后我们运行我们在上一章中编写的
prev_builder代码 - 它构建地图并获取地图数据本身,以及来自先前算法的spawn_list。 - 然后我们遍历生成列表,并计算每个实体的 x/y 坐标和地图索引。 我们使用此信息调用
filter,如果它返回true,我们将其添加到 我们自己的spawn_list中。 - 最后,我们拍摄地图的快照,以便您可以查看正在运行的步骤。
这听起来非常复杂,但它所做的大部分工作是允许我们替换 apply_sectional 中的以下代码:
#![allow(unused)] fn main() { // 构建地图 let prev_builder = self.previous_builder.as_mut().unwrap(); prev_builder.build_map(); self.starting_position = prev_builder.get_starting_position(); self.map = prev_builder.get_map().clone(); for e in prev_builder.get_spawn_list().iter() { let idx = e.0; let x = idx as i32 % self.map.width; let y = idx as i32 / self.map.width; if x < chunk_x || x > (chunk_x + section.width as i32) || y < chunk_y || y > (chunk_y + section.height as i32) { self.spawn_list.push( (idx, e.1.to_string()) ) } } self.take_snapshot(); }
我们可以使用更通用的调用来替换它:
#![allow(unused)] fn main() { // 构建地图 self.apply_previous_iteration(|x,y,e| { x < chunk_x || x > (chunk_x + section.width as i32) || y < chunk_y || y > (chunk_y + section.height as i32) }); }
这很有趣:我们正在将一个 闭包 - 一个 lambda 函数传递给 filter。 它从先前地图的 spawn_list 中为每个实体接收 x、y 和 e。 在这种情况下,我们正在根据 chunk_x、chunk_y、section.width 和 section.height 进行检查,以查看实体是否在我们区块内部。 您可能已经注意到我们没有在 lambda 函数中的任何位置声明这些; 我们依赖于 捕获 - 您可以调用 lambda 并引用 在其作用域内 的其他变量 - 并且它可以像引用自己的变量一样引用它们。 这是一个 非常 强大的功能,您可以在此处了解它。
房间密室
让我们开始构建 apply_room_vaults。 我们将逐步进行,逐步完成。 我们将从函数签名开始:
#![allow(unused)] fn main() { fn apply_room_vaults(&mut self) { use prefab_rooms::*; let mut rng = RandomNumberGenerator::new(); }
足够简单:除了构建器的可变成员资格之外,没有其他参数。 它将引用 prefab_rooms 中的类型,因此与其每次都键入它,不如使用函数内 using statement 将名称导入本地命名空间以节省您的手指。 我们还需要一个随机数生成器,所以我们像以前一样创建一个。 接下来:
#![allow(unused)] fn main() { // 应用先前的构建器,并保留它生成的所有实体(目前) self.apply_previous_iteration(|_x,_y,_e| true); }
我们使用刚刚编写的代码来应用先前的地图。 这次我们传入的 filter 始终返回 true:暂时保留所有实体。 接下来:
#![allow(unused)] fn main() { // 请注意,这是一个占位符,稍后将移出此函数 let master_vault_list = vec![TOTALLY_NOT_A_TRAP]; // 将密室列表过滤为适用于当前深度的密室 let possible_vaults : Vec<&PrefabRoom> = master_vault_list .iter() .filter(|v| { self.depth >= v.first_depth && self.depth <= v.last_depth }) .collect(); if possible_vaults.is_empty() { return; } // 如果没有要构建的内容,则退出 let vault_index = if possible_vaults.len() == 1 { 0 } else { (rng.roll_dice(1, possible_vaults.len() as i32)-1) as usize }; let vault = possible_vaults[vault_index]; }
我们创建了一个包含所有可能的密室类型的向量 - 目前只有一个,但是当我们有更多时,它们会放在这里。 这并不是很理想,但我们将在以后的章节中考虑将其设为全局资源。 然后,我们通过获取 master_vault_list 并 过滤 它以仅包含那些 first_depth 和 last_depth 与请求的地牢深度一致的密室来创建 possible_vaults 列表。 iter().filter(...).collect() 模式之前已经描述过,它是一种非常强大的方式,可以快速提取您需要的向量内容。 如果没有可能的密室,我们 return 退出函数 - 这里没什么可做的! 最后,我们使用我们之前使用过的另一种模式:我们通过选择 possible_vaults 向量的随机成员来选择要创建的密室。
接下来:
#![allow(unused)] fn main() { // 我们将创建一个列表,列出密室可能适合的所有位置 let mut vault_positions : Vec<Position> = Vec::new(); let mut idx = 0usize; loop { let x = (idx % self.map.width as usize) as i32; let y = (idx / self.map.width as usize) as i32; // 检查我们是否会溢出地图 if x > 1 && (x+vault.width as i32) < self.map.width-2 && y > 1 && (y+vault.height as i32) < self.map.height-2 { let mut possible = true; for ty in 0..vault.height as i32 { for tx in 0..vault.width as i32 { let idx = self.map.xy_idx(tx + x, ty + y); if self.map.tiles[idx] != TileType::Floor { possible = false; } } } if possible { vault_positions.push(Position{ x,y }); break; } } idx += 1; if idx >= self.map.tiles.len()-1 { break; } } }
本节中有很多代码(用于确定密室可能适合的所有位置)。 让我们逐步分析一下:
- 我们创建了一个新的
Position向量。 这将包含我们 可以 生成密室的所有可能位置。 - 我们将
idx设置为0- 我们计划遍历整个地图。 - 我们启动一个
loop- Rust 的循环类型,除非您调用break,否则不会退出。- 我们计算
x和y以了解我们在地图上的位置。 - 我们进行溢出检查;
x需要大于 1,并且x+1需要小于地图宽度。 我们对y和地图高度进行相同的检查。 如果我们在边界内:- 我们将
possible设置为 true。 - 我们迭代地图上
(x .. x+vault width), (y .. y + vault height)范围内的每个图块 - 如果任何图块不是地板,我们将possible设置为false。 - 如果 可以 在此处放置密室,我们将该位置添加到步骤 1 中的
vault_positions向量中。
- 我们将
- 我们将
idx递增 1。 - 如果我们用完了地图,我们将跳出循环。
- 我们计算
换句话说,我们快速扫描 整个地图,查找我们可以放置密室的所有位置 - 并创建一个可能的放置列表。 然后我们:
#![allow(unused)] fn main() { if !vault_positions.is_empty() { let pos_idx = if vault_positions.len()==1 { 0 } else { (rng.roll_dice(1, vault_positions.len() as i32)-1) as usize }; let pos = &vault_positions[pos_idx]; let chunk_x = pos.x; let chunk_y = pos.y; let string_vec = PrefabBuilder::read_ascii_to_vec(vault.template); let mut i = 0; for ty in 0..vault.height { for tx in 0..vault.width { let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y); self.char_to_map(string_vec[i], idx); i += 1; } } self.take_snapshot(); } }
因此,如果 有 密室的任何有效位置,我们将:
- 选择
vault_positions向量中的随机条目 - 这就是我们将放置密室的位置。 - 使用
read_ascii_to_vec读取 ASCII 码,就像我们在预制件和区块中所做的那样。 - 迭代密室数据并使用
char_to_map放置它 - 就像我们之前所做的那样。
将它们放在一起,您将得到以下函数:
#![allow(unused)] fn main() { fn apply_room_vaults(&mut self) { use prefab_rooms::*; let mut rng = RandomNumberGenerator::new(); // 应用先前的构建器,并保留它生成的所有实体(目前) self.apply_previous_iteration(|_x,_y,_e| true); // 请注意,这是一个占位符,稍后将移出此函数 let master_vault_list = vec![TOTALLY_NOT_A_TRAP]; // 将密室列表过滤为适用于当前深度的密室 let possible_vaults : Vec<&PrefabRoom> = master_vault_list .iter() .filter(|v| { self.depth >= v.first_depth && self.depth <= v.last_depth }) .collect(); if possible_vaults.is_empty() { return; } // 如果没有要构建的内容,则退出 let vault_index = if possible_vaults.len() == 1 { 0 } else { (rng.roll_dice(1, possible_vaults.len() as i32)-1) as usize }; let vault = possible_vaults[vault_index]; // 我们将创建一个列表,列出密室可能适合的所有位置 let mut vault_positions : Vec<Position> = Vec::new(); let mut idx = 0usize; loop { let x = (idx % self.map.width as usize) as i32; let y = (idx / self.map.width as usize) as i32; // 检查我们是否会溢出地图 if x > 1 && (x+vault.width as i32) < self.map.width-2 && y > 1 && (y+vault.height as i32) < self.map.height-2 { let mut possible = true; for ty in 0..vault.height as i32 { for tx in 0..vault.width as i32 { let idx = self.map.xy_idx(tx + x, ty + y); if self.map.tiles[idx] != TileType::Floor { possible = false; } } } if possible { vault_positions.push(Position{ x,y }); break; } } idx += 1; if idx >= self.map.tiles.len()-1 { break; } } if !vault_positions.is_empty() { let pos_idx = if vault_positions.len()==1 { 0 } else { (rng.roll_dice(1, vault_positions.len() as i32)-1) as usize }; let pos = &vault_positions[pos_idx]; let chunk_x = pos.x; let chunk_y = pos.y; let string_vec = PrefabBuilder::read_ascii_to_vec(vault.template); let mut i = 0; for ty in 0..vault.height { for tx in 0..vault.width { let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y); self.char_to_map(string_vec[i], idx); i += 1; } } self.take_snapshot(); } } }
方形密室更可能适合矩形房间,因此我们将跳到 map_builders/mod.rs 并稍微调整 random_builder 以对基础地图使用原始的简单地图算法:
#![allow(unused)] fn main() { Box::new( PrefabBuilder::new( new_depth, Some( Box::new( SimpleMapBuilder::new(new_depth) ) ) ) ) }
如果您现在 cargo run,密室可能会放置在您的地图上。 这是我运行并找到它的截图:
。
过滤实体
我们可能不希望保留先前地图迭代中位于我们新密室内的实体。 您可能会巧妙地放置一个陷阱,然后在上面生成一个地精!(虽然很有趣,但这可能不是您所想的)。 因此,我们将扩展 apply_room_vaults 以在放置密室时进行一些过滤。 我们希望在生成新内容 之前 进行过滤,然后再使用房间生成更多内容。 输入 retain 功能:
#![allow(unused)] fn main() { ... let chunk_y = pos.y; let width = self.map.width; // borrow checker 真的不喜欢 let height = self.map.height; // 当我们在 `retain` 内部访问 `self` 时 self.spawn_list.retain(|e| { let idx = e.0 as i32; let x = idx % width; let y = idx / height; x < chunk_x || x > chunk_x + vault.width as i32 || y < chunk_y || y > chunk_y + vault.height as i32 }); ... }
在向量上调用 retain 会遍历每个条目,并调用传递的闭包/lambda 函数。 如果它返回 true,则该元素将被 保留(保留) - 否则将被删除。 因此,在这里我们捕获 width 和 height(以避免借用 self),然后计算每个条目的位置。 如果它在新密室之外 - 我们保留它。
我想要不止一个密室!
只有一个密室非常乏味 - 虽然在证明功能有效性方面是一个良好的开端。 在 prefab_rooms.rs 中,我们将继续编写更多密室。 这些并非旨在成为关卡设计的开创性示例,但它们说明了该过程。 我们将添加更多房间预制件:
#![allow(unused)] fn main() { #[allow(dead_code)] #[derive(PartialEq, Copy, Clone)] pub struct PrefabRoom { pub template : &'static str, pub width : usize, pub height: usize, pub first_depth: i32, pub last_depth: i32 } #[allow(dead_code)] pub const TOTALLY_NOT_A_TRAP : PrefabRoom = PrefabRoom{ template : TOTALLY_NOT_A_TRAP_MAP, width: 5, height: 5, first_depth: 0, last_depth: 100 }; #[allow(dead_code)] const TOTALLY_NOT_A_TRAP_MAP : &str = " ^^^ ^!^ ^^^ "; #[allow(dead_code)] pub const SILLY_SMILE : PrefabRoom = PrefabRoom{ template : SILLY_SMILE_MAP, width: 6, height: 6, first_depth: 0, last_depth: 100 }; #[allow(dead_code)] const SILLY_SMILE_MAP : &str = " ^ ^ # ### "; #[allow(dead_code)] pub const CHECKERBOARD : PrefabRoom = PrefabRoom{ template : CHECKERBOARD_MAP, width: 6, height: 6, first_depth: 0, last_depth: 100 }; #[allow(dead_code)] const CHECKERBOARD_MAP : &str = " ^# g#%# #!# ^# # "; }
我们添加了 CHECKERBOARD(一个墙壁和空间网格,其中包含陷阱、一个地精和战利品),以及 SILLY_SMILE,它看起来只是一个愚蠢的墙壁特征。 现在打开 map_builders/prefab_builder/mod.rs 中的 apply_room_vaults 并将它们添加到主向量中:
#![allow(unused)] fn main() { // 请注意,这是一个占位符,稍后将移出此函数 let master_vault_list = vec![TOTALLY_NOT_A_TRAP, CHECKERBOARD, SILLY_SMILE]; }
如果您现在 cargo run,您很可能会遇到这三个密室之一。 每次您深入一层,您都可能会遇到这三个密室之一。 我的测试几乎立即遇到了棋盘格:
。
这是一个好的开始,并且在您下降时为地图增添了一些风格 - 但当您说您想要不止一个密室时,这可能与您所要求的并不完全一致! 一个关卡上不止一个密室 怎么样? 回到 apply_room_vaults! 很容易想出要生成的密室数量:
#![allow(unused)] fn main() { let n_vaults = i32::min(rng.roll_dice(1, 3), possible_vaults.len() as i32); }
这将 n_vaults 设置为骰子滚动 (1d3) 和可能的密室数量的 最小值 - 因此它永远不会超过选项的数量,但可以稍微变化。 将创建函数包装在 for 循环中也很容易:
#![allow(unused)] fn main() { if possible_vaults.is_empty() { return; } // 如果没有要构建的内容,则退出 let n_vaults = i32::min(rng.roll_dice(1, 3), possible_vaults.len() as i32); for _i in 0..n_vaults { let vault_index = if possible_vaults.len() == 1 { 0 } else { (rng.roll_dice(1, possible_vaults.len() as i32)-1) as usize }; let vault = possible_vaults[vault_index]; ... self.take_snapshot(); possible_vaults.remove(vault_index); } } }
请注意,在循环的 末尾,我们正在从 possible_vaults 中删除我们添加的密室。 我们必须更改声明才能做到这一点:let mut possible_vaults : Vec<&PrefabRoom> = ... - 我们添加 mut 以允许我们更改向量。 这样,我们就不会一直添加 相同 的密室 - 它们只生成一次。
现在是更困难的部分:确保我们的新密室不与先前生成的密室重叠。 我们将创建一个新的 HashSet,其中包含我们已消耗的图块:
#![allow(unused)] fn main() { let mut used_tiles : HashSet<usize> = HashSet::new(); }
哈希集合的优点是提供了一种快速判断它们是否包含值的方法,因此它们非常适合我们的需求。 当我们添加图块时,我们将图块 idx 插入到集合中:
#![allow(unused)] fn main() { for ty in 0..vault.height { for tx in 0..vault.width { let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y); self.char_to_map(string_vec[i], idx); used_tiles.insert(idx); i += 1; } } }
最后,在我们的可能性检查中,我们想要针对 used_tiles 进行检查,以确保我们没有重叠:
#![allow(unused)] fn main() { let idx = self.map.xy_idx(tx + x, ty + y); if self.map.tiles[idx] != TileType::Floor { possible = false; } if used_tiles.contains(&idx) { possible = false; } }
现在,如果您 cargo run 您的项目,您可能会遇到多个密室。 这是一个我们遇到两个密室的案例:
。
我不 总是 想要密室!
如果您在每个关卡都提供所有密室,那么游戏将比您可能想要的更可预测(除非您制作 很多 密室!)。 我们将修改 apply_room_vaults,使其仅在有时有密室,并且随着您深入地牢,概率会增加:
#![allow(unused)] fn main() { // 应用先前的构建器,并保留它生成的所有实体(目前) self.apply_previous_iteration(|_x,_y,_e| true); // 我们想要密室吗? let vault_roll = rng.roll_dice(1, 6) + self.depth; if vault_roll < 4 { return; } }
这非常简单:我们掷一个六面骰子并加上当前深度。 如果我们掷出的点数小于 4,我们将退出并仅提供先前生成的地图。 如果您现在 cargo run 您的项目,您有时会遇到密室 - 有时您不会。
完成:提供除 new 之外的其他构造函数
我们应该提供一些更友好的方式来构建我们的 PrefabBuilder,以便在我们构建构建器链时清楚地了解我们在做什么。 将以下构造函数添加到 prefab_builder/mod.rs:
#![allow(unused)] fn main() { #[allow(dead_code)] pub fn rex_level(new_depth : i32, template : &'static str) -> PrefabBuilder { PrefabBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history : Vec::new(), mode : PrefabMode::RexLevel{ template }, previous_builder : None, spawn_list : Vec::new() } } #[allow(dead_code)] pub fn constant(new_depth : i32, level : prefab_levels::PrefabLevel) -> PrefabBuilder { PrefabBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history : Vec::new(), mode : PrefabMode::Constant{ level }, previous_builder : None, spawn_list : Vec::new() } } #[allow(dead_code)] pub fn sectional(new_depth : i32, section : prefab_sections::PrefabSection, previous_builder : Box<dyn MapBuilder>) -> PrefabBuilder { PrefabBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history : Vec::new(), mode : PrefabMode::Sectional{ section }, previous_builder : Some(previous_builder), spawn_list : Vec::new() } } #[allow(dead_code)] pub fn vaults(new_depth : i32, previous_builder : Box<dyn MapBuilder>) -> PrefabBuilder { PrefabBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, history : Vec::new(), mode : PrefabMode::RoomVaults, previous_builder : Some(previous_builder), spawn_list : Vec::new() } } }
我们现在有了一个用于创建元构建器的不错的接口!
无处不在的海龟(或元构建器)
最近的几章都创建了 元构建器 - 它们实际上不是 构建器,因为它们不会创建全新的地图,而是修改另一种算法的结果。 这里真正有趣的是,您可以将它们链接在一起以达到您想要的结果。 例如,让我们通过从细胞自动机地图开始,通过波函数坍缩传递它,可能添加城堡墙,然后搜索密室来制作地图!
此语法的当前形式非常丑陋(这将是未来章节的主题)。 在 map_builders/mod.rs 中:
#![allow(unused)] fn main() { Box::new( PrefabBuilder::vaults( new_depth, Box::new(PrefabBuilder::sectional( new_depth, prefab_builder::prefab_sections::UNDERGROUND_FORT, Box::new(WaveformCollapseBuilder::derived_map( new_depth, Box::new(CellularAutomataBuilder::new(new_depth)) )) )) ) ) }
同样在 map_builders/prefab_builder/mod.rs 中,确保您公开共享地图模块:
#![allow(unused)] fn main() { pub mod prefab_levels; pub mod prefab_sections; pub mod prefab_rooms; }
如果您 cargo run 这个,您将观看它循环遍历分层构建:
。
恢复随机性
既然我们已经完成了关于预制、分层地图构建的为期两章的马拉松,现在是时候恢复 random_builder 函数以再次提供随机性了。 这是来自 map_builders/mod.rs 的新函数:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 17); let mut result : Box<dyn MapBuilder>; match builder { 1 => { result = Box::new(BspDungeonBuilder::new(new_depth)); } 2 => { result = Box::new(BspInteriorBuilder::new(new_depth)); } 3 => { result = Box::new(CellularAutomataBuilder::new(new_depth)); } 4 => { result = Box::new(DrunkardsWalkBuilder::open_area(new_depth)); } 5 => { result = Box::new(DrunkardsWalkBuilder::open_halls(new_depth)); } 6 => { result = Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)); } 7 => { result = Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)); } 8 => { result = Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)); } 9 => { result = Box::new(MazeBuilder::new(new_depth)); } 10 => { result = Box::new(DLABuilder::walk_inwards(new_depth)); } 11 => { result = Box::new(DLABuilder::walk_outwards(new_depth)); } 12 => { result = Box::new(DLABuilder::central_attractor(new_depth)); } 13 => { result = Box::new(DLABuilder::insectoid(new_depth)); } 14 => { result = Box::new(VoronoiCellBuilder::pythagoras(new_depth)); } 15 => { result = Box::new(VoronoiCellBuilder::manhattan(new_depth)); } 16 => { result = Box::new(PrefabBuilder::constant(new_depth, prefab_builder::prefab_levels::WFC_POPULATED)) }, _ => { result = Box::new(SimpleMapBuilder::new(new_depth)); } } if rng.roll_dice(1, 3)==1 { result = Box::new(WaveformCollapseBuilder::derived_map(new_depth, result)); } if rng.roll_dice(1, 20)==1 { result = Box::new(PrefabBuilder::sectional(new_depth, prefab_builder::prefab_sections::UNDERGROUND_FORT ,result)); } result = Box::new(PrefabBuilder::vaults(new_depth, result)); result } }
我们现在充分利用了我们图层系统的可组合性! 我们的随机构建器现在:
- 在第一层中,我们滚动
1d17并选择地图类型; 我们已将预制关卡作为选项之一包含在内。 - 接下来,我们滚动
1d3- 并在 1 上,我们在 该 构建器上运行WaveformCollapse算法。 - 我们滚动
1d20,并在 1 上 - 我们应用PrefabBuilder区块,并添加我们的堡垒。 这样,您只会偶尔遇到它。 - 我们针对
PrefabBuilder的房间密室系统(本章的重点!)运行我们提出的任何构建器,以将预制房间添加到混合中。
总结
在本章中,我们获得了预制房间的能力,并在它们适合我们的关卡设计时包含它们。 我们还探索了将算法组合在一起的能力,从而提供了更多层次的随机性。
...
本章的源代码可以在此处找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
分层/构建器链
关于本教程
本教程是免费和开源的,所有代码都使用 MIT 许可证 - 所以你可以随意使用它。我希望你喜欢这个教程,并制作出伟大的游戏!
如果你喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在过去的几章中,我们介绍了程序化生成中一个重要的概念:链式构建器。我们很高兴地构建地图,调用波函数坍缩来改变地图,调用我们的 PrefabBuilder 再次改变它,等等。本章将稍微形式化这个过程,扩展它,并为你留下一个框架,让你能够清晰地通过链式连接概念来构建新地图。
基于构建器的接口
构建器链式调用是程序化生成地图的一种非常深刻的方法,它为我们提供了一个机会来清理我们目前构建的很多代码。我们想要一个类似于我们使用 Specs 构建实体的接口:一个构建器,我们可以在其上不断链式调用构建器,并将其作为“执行器”返回 - 准备好构建地图。我们还希望阻止构建器做超过一件事 - 它们应该只做一件事,并把它做好(这是一个好的设计原则;它使调试更容易,并减少重复)。
构建器主要有两种类型:只生成地图的(并且只运行一次才有意义),以及修改现有地图的。我们将分别将它们命名为 InitialMapBuilder 和 MetaMapBuilder。
这给了我们一个想要采用的语法概念:
- 我们的 Builder 应该有:
- 一个初始构建器 (Initial Builder)。
- n 个元构建器 (Meta Builder),按顺序运行。
那么,构建器应该有一个接受第一个地图的 start_with 方法,以及用于链式连接构建器的额外的 with 方法,这是有道理的。构建器应该存储在一个容器中,该容器保留它们被添加的顺序 - 向量是显而易见的选择。
不再让单个构建器负责设置它们的前置组件也是有道理的;理想情况下,除了 它 所做的事情之外,构建器不应该 必须 知道任何关于过程的事情。因此,我们需要抽象这个过程,并支持快照(以便您可以查看程序化生成过程)。
共享地图状态 - BuilderMap
与其让每个构建器定义它们自己的共享数据副本,不如将共享数据放在一个地方 - 并在需要时在链中传递它,这将更有意义。因此,我们将首先在 map_builders/mod.rs 中定义一些新的结构和接口。首先,我们将创建 BuilderMap:
#![allow(unused)] fn main() { pub struct BuilderMap { pub spawn_list : Vec<(usize, String)>, pub map : Map, pub starting_position : Option<Position>, pub rooms: Option<Vec<Rect>>, pub history : Vec<Map> } }
你会注意到,这包含了我们一直在构建到每个地图构建器中的所有数据 - 仅此而已。它是有意通用的 - 我们将把它传递给构建器,并让他们处理它。请注意,所有字段都是 公共的 - 这是因为我们正在传递它,并且很有可能任何接触它的东西都需要访问它的全部或部分内容。
BuilderMap 还需要方便进行快照的任务,以便调试器查看我们处理算法时的地图。我们将在 BuilderMap 中放入一个函数 - 用于处理快照开发:
#![allow(unused)] fn main() { impl BuilderMap { fn take_snapshot(&mut self) { if SHOW_MAPGEN_VISUALIZER { let mut snapshot = self.map.clone(); for v in snapshot.revealed_tiles.iter_mut() { *v = true; } self.history.push(snapshot); } } } }
这与我们一直在混入构建器的 take_snapshot 代码 相同。由于我们正在使用地图构建知识的中央存储库,我们可以提升它以应用于 所有 我们的构建器。
BuilderChain - 管理地图创建的主构建器
以前,我们传递了 MapBuilder 类,每个类都能够构建之前的地图。由于我们已经得出结论,这是一个糟糕的想法,并定义了我们 想要 的语法,我们将创建一个替代品。BuilderChain 是一个 主 构建器 - 它控制整个构建过程。为此,我们将添加 BuilderChain 类型:
#![allow(unused)] fn main() { pub struct BuilderChain { starter: Option<Box<dyn InitialMapBuilder>>, builders: Vec<Box<dyn MetaMapBuilder>>, pub build_data : BuilderMap } }
这是一个更复杂的结构,所以让我们过一遍:
starter是一个Option,所以我们知道是否存在一个。没有第一步(不引用其他地图的地图)将是一个错误条件,因此我们将跟踪它。我们正在引用一个新的 trait,InitialMapBuilder;我们稍后会介绍它。builders是MetaMapBuilders的向量,另一个新的 trait(同样 - 我们稍后会介绍它)。这些是操作先前地图结果的构建器。build_data是一个公共变量(任何人都可以读/写它),包含我们刚刚创建的BuilderMap。
我们将实现一些函数来支持它。首先,一个 构造函数:
#![allow(unused)] fn main() { impl BuilderChain { pub fn new(new_depth : i32) -> BuilderChain { BuilderChain{ starter: None, builders: Vec::new(), build_data : BuilderMap { spawn_list: Vec::new(), map: Map::new(new_depth), starting_position: None, rooms: None, history : Vec::new() } } } ... }
这非常简单:它创建了一个新的 BuilderChain,所有内容都使用默认值。现在,让我们允许我们的用户向链中添加 起始地图。(起始地图是不需要先前地图作为输入的第一步,并生成可用的地图结构,我们可以对其进行修改):
#![allow(unused)] fn main() { ... pub fn start_with(&mut self, starter : Box<dyn InitialMapBuilder>) { match self.starter { None => self.starter = Some(starter), Some(_) => panic!("You can only have one starting builder.") }; } ... }
这里有一个新概念:panic!。如果用户尝试添加第二个起始构建器,我们将崩溃 - 因为这没有任何意义。你只是简单地覆盖你之前的步骤,这是一个巨大的时间浪费!我们还将允许用户添加元构建器:
#![allow(unused)] fn main() { ... pub fn with(&mut self, metabuilder : Box<dyn MetaMapBuilder>) { self.builders.push(metabuilder); } ... }
这非常简单:我们只需将元构建器添加到构建器向量中。由于向量保持您添加到它们的顺序,因此您的操作将保持适当的排序。最后,我们将实现一个实际构建地图的函数:
#![allow(unused)] fn main() { pub fn build_map(&mut self, rng : &mut rltk::RandomNumberGenerator) { match &mut self.starter { None => panic!("Cannot run a map builder chain without a starting build system"), Some(starter) => { // 构建起始地图 starter.build_map(rng, &mut self.build_data); } } // 依次构建额外的层 for metabuilder in self.builders.iter_mut() { metabuilder.build_map(rng, &mut self.build_data); } } }
让我们在这里逐步了解一下:
- 我们
match了我们的起始地图。如果没有起始地图,我们会 panic - 并崩溃程序,并显示一条消息,提示您 必须 设置一个起始构建器。 - 我们在起始地图上调用
build_map。 - 对于每个元构建器,我们在其上调用
build_map- 按照指定的顺序。
这不是一个糟糕的语法!它应该使我们能够将构建器链接在一起,并为构建复杂的分层地图提供所需的概览。
新的 Traits - InitialMapBuilder 和 MetaMapBuilder
让我们看一下我们定义的两个 trait 接口,InitialMapBuilder 和 MetaMapBuilder。我们将它们设为单独的类型,以强制用户只选择 一个 起始构建器,而不是尝试将任何起始构建器放入修改层列表中。它们的实现是相同的:
#![allow(unused)] fn main() { pub trait InitialMapBuilder { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap); } pub trait MetaMapBuilder { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap); } }
build_map 接受一个随机数生成器(这样我们就不会到处创建新的生成器了!),以及我们正在处理的 BuilderMap 的可变引用。因此,我们不是让每个构建器可选地调用前一个构建器,而是在处理状态时传递状态。
生成函数
我们还需要实现我们的生成系统:
#![allow(unused)] fn main() { pub fn spawn_entities(&mut self, ecs : &mut World) { for entity in self.build_data.spawn_list.iter() { spawner::spawn_entity(ecs, &(&entity.0, &entity.1)); } } }
这几乎与我们之前在 MapBuilder 中的生成器代码相同,但我们是从 build_data 结构中的 spawn_list 中生成的。否则,它是相同的。
随机构建器 - 第一步
最后,我们将修改 random_builder 以使用我们的 SimpleMapBuilder 和一些新类型来分解创建步骤:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain { let mut builder = BuilderChain::new(new_depth); builder.start_with(SimpleMapBuilder::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStartingPosition::new()); builder.with(RoomBasedStairs::new()); builder } }
请注意,我们现在正在使用 RandomNumberGenerator 参数。这是因为我们想使用全局 RNG,而不是一直创建新的 RNG。这样,如果调用者设置了“种子” - 它将应用于世界生成。这计划成为未来章节的主题。我们现在还返回 BuilderChain 而不是 boxed trait - 我们将 messy boxing/dynamic dispatch 隐藏在实现内部,因此调用者不必担心它。这里还有两个新类型:RoomBasedSpawner 和 RoomBasedStartingPosition - 以及 SimpleMapBuilder 的更改后的构造函数(它不再接受深度参数)。我们稍后会介绍这一点 - 但首先,让我们处理由于新接口而导致的主程序更改。
看起来不错的界面 - 但你破坏了东西!
我们现在拥有了我们想要的 接口 - 系统如何与世界交互的良好地图。不幸的是,世界仍然期望我们之前的设置 - 所以我们需要修复它。在 main.rs 中,我们需要更新我们的 generate_world_map 函数以使用新接口:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32) { self.mapgen_index = 0; self.mapgen_timer = 0.0; self.mapgen_history.clear(); let mut rng = self.ecs.write_resource::<rltk::RandomNumberGenerator>(); let mut builder = map_builders::random_builder(new_depth, &mut rng); builder.build_map(&mut rng); std::mem::drop(rng); self.mapgen_history = builder.build_data.history.clone(); let player_start; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); *worldmap_resource = builder.build_data.map.clone(); player_start = builder.build_data.starting_position.as_mut().unwrap().clone(); } // 生成坏人 builder.spawn_entities(&mut self.ecs); }
- 我们重置
mapgen_index、mapgen_timer和mapgen_history,以便进度查看器从头开始运行。 - 我们从 ECS
World获取 RNG。 - 我们使用新接口创建一个新的
random_builder,并传递随机数生成器。 - 我们告诉它从链中构建新地图,也利用 RNG。
- 我们在 RNG 上调用
std::mem::drop。这停止了对它的“借用” - 因此我们也不再借用self。这防止了代码的后续阶段出现借用检查器错误。 - 我们将地图构建器历史记录 克隆 到我们自己的世界历史记录副本中。我们复制它,这样我们就不会破坏构建器。
- 我们将
player_start设置为构建器确定的起始位置的 克隆。请注意,我们正在调用unwrap- 因此起始位置的Option必须 在此时有一个值,否则我们将崩溃。这是故意的:我们宁愿崩溃,知道我们忘记设置起始点,也不愿程序在未知/混乱的状态下运行。 - 我们调用
spawn_entities来填充地图。
修改 SimpleMapBuilder
我们可以大大简化 SimpleMapBuilder(使其名副其实!)。这是新代码:
#![allow(unused)] fn main() { use super::{InitialMapBuilder, BuilderMap, Rect, apply_room_to_map, apply_horizontal_tunnel, apply_vertical_tunnel }; use rltk::RandomNumberGenerator; pub struct SimpleMapBuilder {} impl InitialMapBuilder for SimpleMapBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.rooms_and_corridors(rng, build_data); } } impl SimpleMapBuilder { #[allow(dead_code)] pub fn new() -> Box<SimpleMapBuilder> { Box::new(SimpleMapBuilder{}) } fn rooms_and_corridors(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { const MAX_ROOMS : i32 = 30; const MIN_SIZE : i32 = 6; const MAX_SIZE : i32 = 10; let mut rooms : Vec<Rect> = Vec::new(); for i in 0..MAX_ROOMS { let w = rng.range(MIN_SIZE, MAX_SIZE); let h = rng.range(MIN_SIZE, MAX_SIZE); let x = rng.roll_dice(1, build_data.map.width - w - 1) - 1; let y = rng.roll_dice(1, build_data.map.height - h - 1) - 1; let new_room = Rect::new(x, y, w, h); let mut ok = true; for other_room in rooms.iter() { if new_room.intersect(other_room) { ok = false } } if ok { apply_room_to_map(&mut build_data.map, &new_room); build_data.take_snapshot(); if !rooms.is_empty() { let (new_x, new_y) = new_room.center(); let (prev_x, prev_y) = rooms[i as usize -1].center(); if rng.range(0,2) == 1 { apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, prev_y); apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, new_x); } else { apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, prev_x); apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, new_y); } } rooms.push(new_room); build_data.take_snapshot(); } } build_data.rooms = Some(rooms); } } }
这基本上与旧的 SimpleMapBuilder 相同,但有一些更改:
- 请注意,我们只应用了
InitialMapBuildertrait -MapBuilder不复存在了。 - 我们也没有设置起始位置或生成实体 - 这些现在是链中其他构建器的职权范围。我们基本上将其提炼为仅房间构建算法。
- 我们将
build_data.rooms设置为Some(rooms)。并非所有算法都支持房间 - 因此我们的 trait 将Option设置为None,直到我们填充它为止。由于SimpleMapBuilder完全是关于房间的 - 我们填充它。
基于房间的生成
在 map_builders 目录中创建一个新文件 room_based_spawner.rs。我们将在这里应用旧 SimpleMapBuilder 中的 仅 房间填充系统:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, spawner}; use rltk::RandomNumberGenerator; pub struct RoomBasedSpawner {} impl MetaMapBuilder for RoomBasedSpawner { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl RoomBasedSpawner { #[allow(dead_code)] pub fn new() -> Box<RoomBasedSpawner> { Box::new(RoomBasedSpawner{}) } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { if let Some(rooms) = &build_data.rooms { for room in rooms.iter().skip(1) { spawner::spawn_room(&build_data.map, rng, room, build_data.map.depth, &mut build_data.spawn_list); } } else { panic!("Room Based Spawning only works after rooms have been created"); } } } }
在这个子模块中,我们正在实现 MetaMapBuilder:这个构建器要求您已经有一个地图。在 build 中,我们从 SimpleMapBuilder 中复制了旧的基于房间的生成代码,并对其进行了修改以在构建器的 rooms 结构上运行。为此,我们使用 if let 来获取 Option 的内部值;如果没有,那么我们 panic!,程序退出,并声明基于房间的生成仅在您 有 房间的情况下才有效。
我们将功能减少到仅一个任务:如果有房间,我们在其中生成怪物。
基于房间的起始位置
这与基于房间的生成非常相似,但将玩家放置在第一个房间中 - 就像以前在 SimpleMapBuilder 中一样。在 map_builders 中创建一个新文件 room_based_starting_position:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, Position}; use rltk::RandomNumberGenerator; pub struct RoomBasedStartingPosition {} impl MetaMapBuilder for RoomBasedStartingPosition { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl RoomBasedStartingPosition { #[allow(dead_code)] pub fn new() -> Box<RoomBasedStartingPosition> { Box::new(RoomBasedStartingPosition{}) } fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { if let Some(rooms) = &build_data.rooms { let start_pos = rooms[0].center(); build_data.starting_position = Some(Position{ x: start_pos.0, y: start_pos.1 }); } else { panic!("Room Based Staring Position only works after rooms have been created"); } } } }
基于房间的楼梯
这也非常类似于我们在 SimpleMapBuilder 中生成出口楼梯的方式。创建一个新文件 room_based_stairs.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, TileType}; use rltk::RandomNumberGenerator; pub struct RoomBasedStairs {} impl MetaMapBuilder for RoomBasedStairs { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl RoomBasedStairs { #[allow(dead_code)] pub fn new() -> Box<RoomBasedStairs> { Box::new(RoomBasedStairs{}) } fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { if let Some(rooms) = &build_data.rooms { let stairs_position = rooms[rooms.len()-1].center(); let stairs_idx = build_data.map.xy_idx(stairs_position.0, stairs_position.1); build_data.map.tiles[stairs_idx] = TileType::DownStairs; build_data.take_snapshot(); } else { panic!("Room Based Stairs only works after rooms have been created"); } } } }
将它们放在一起,用新框架制作一个简单的地图
让我们再次看一下 random_builder:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(SimpleMapBuilder::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStartingPosition::new()); builder.with(RoomBasedStairs::new()); builder }
既然我们已经完成了所有步骤,这应该是有意义的:
- 我们 从 使用
SimpleMapBuilder生成器生成的地图 开始。 - 我们使用 元构建器
RoomBasedSpawner修改 地图,以在房间中生成实体。 - 我们再次使用 元构建器
RoomBasedStartingPosition修改 地图,以便从第一个房间开始。 - 再次,我们使用 元构建器
RoomBasedStairs修改 地图,以在最后一个房间中放置向下楼梯。
如果你现在 cargo run 项目,你将看到很多关于未使用代码的警告 - 但游戏应该可以玩,只有我们第一节中的简单地图。您可能想知道 为什么 我们付出了这么多努力来保持事物相同;希望随着我们清理更多构建器,这一点会变得清晰!
清理 BSP 地牢构建器
再次,我们可以认真清理地图构建器!这是新版本的 bsp_dungeon.rs:
#![allow(unused)] fn main() { use super::{InitialMapBuilder, BuilderMap, Map, Rect, apply_room_to_map, TileType, draw_corridor}; use rltk::RandomNumberGenerator; pub struct BspDungeonBuilder { rects: Vec<Rect>, } impl InitialMapBuilder for BspDungeonBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl BspDungeonBuilder { #[allow(dead_code)] pub fn new() -> Box<BspDungeonBuilder> { Box::new(BspDungeonBuilder{ rects: Vec::new(), }) } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let mut rooms : Vec<Rect> = Vec::new(); self.rects.clear(); self.rects.push( Rect::new(2, 2, build_data.map.width-5, build_data.map.height-5) ); // 从单个地图大小的矩形开始 let first_room = self.rects[0]; self.add_subrects(first_room); // 划分第一个房间 // 最多 240 次,我们获得一个随机矩形并划分它。如果有可能在那里挤出一个房间, // 我们放置它并将其添加到房间列表。 let mut n_rooms = 0; while n_rooms < 240 { let rect = self.get_random_rect(rng); let candidate = self.get_random_sub_rect(rect, rng); if self.is_possible(candidate, &build_data.map) { apply_room_to_map(&mut build_data.map, &candidate); rooms.push(candidate); self.add_subrects(rect); build_data.take_snapshot(); } n_rooms += 1; } // 现在我们对房间进行排序 rooms.sort_by(|a,b| a.x1.cmp(&b.x1) ); // 现在我们需要走廊 for i in 0..rooms.len()-1 { let room = rooms[i]; let next_room = rooms[i+1]; let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1); let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1); let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1); let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1); draw_corridor(&mut build_data.map, start_x, start_y, end_x, end_y); build_data.take_snapshot(); } build_data.rooms = Some(rooms); } fn add_subrects(&mut self, rect : Rect) { let width = i32::abs(rect.x1 - rect.x2); let height = i32::abs(rect.y1 - rect.y2); let half_width = i32::max(width / 2, 1); let half_height = i32::max(height / 2, 1); self.rects.push(Rect::new( rect.x1, rect.y1, half_width, half_height )); self.rects.push(Rect::new( rect.x1, rect.y1 + half_height, half_width, half_height )); self.rects.push(Rect::new( rect.x1 + half_width, rect.y1, half_width, half_height )); self.rects.push(Rect::new( rect.x1 + half_width, rect.y1 + half_height, half_width, half_height )); } fn get_random_rect(&mut self, rng : &mut RandomNumberGenerator) -> Rect { if self.rects.len() == 1 { return self.rects[0]; } let idx = (rng.roll_dice(1, self.rects.len() as i32)-1) as usize; self.rects[idx] } fn get_random_sub_rect(&self, rect : Rect, rng : &mut RandomNumberGenerator) -> Rect { let mut result = rect; let rect_width = i32::abs(rect.x1 - rect.x2); let rect_height = i32::abs(rect.y1 - rect.y2); let w = i32::max(3, rng.roll_dice(1, i32::min(rect_width, 10))-1) + 1; let h = i32::max(3, rng.roll_dice(1, i32::min(rect_height, 10))-1) + 1; result.x1 += rng.roll_dice(1, 6)-1; result.y1 += rng.roll_dice(1, 6)-1; result.x2 = result.x1 + w; result.y2 = result.y1 + h; result } fn is_possible(&self, rect : Rect, map : &Map) -> bool { let mut expanded = rect; expanded.x1 -= 2; expanded.x2 += 2; expanded.y1 -= 2; expanded.y2 += 2; let mut can_build = true; for y in expanded.y1 ..= expanded.y2 { for x in expanded.x1 ..= expanded.x2 { if x > map.width-2 { can_build = false; } if y > map.height-2 { can_build = false; } if x < 1 { can_build = false; } if y < 1 { can_build = false; } if can_build { let idx = map.xy_idx(x, y); if map.tiles[idx] != TileType::Wall { can_build = false; } } } } can_build } } }
就像 SimpleMapBuilder 一样,我们已经剥离了所有非房间构建代码,使其成为更简洁的代码。我们正在引用构建器的 build_data 结构,而不是制作我们自己的所有内容的副本 - 代码的 核心 部分在很大程度上是相同的。
现在您可以修改 random_builder 以制作此地图类型:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(BspDungeonBuilder::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStartingPosition::new()); builder.with(RoomBasedStairs::new()); builder }
如果你现在 cargo run,你将获得一个基于 BspDungeonBuilder 的地牢。看看你是如何重用生成器、起始位置和楼梯代码的?这绝对比旧版本有所改进 - 如果您更改一个,它现在可以帮助多个构建器!
再次针对 BSP 内部
再次,我们可以极大地清理构建器 - 这次是 BspInteriorBuilder。这是 bsp_interior.rs 的代码:
#![allow(unused)] fn main() { use super::{InitialMapBuilder, BuilderMap, Rect, TileType, draw_corridor}; use rltk::RandomNumberGenerator; const MIN_ROOM_SIZE : i32 = 8; pub struct BspInteriorBuilder { rects: Vec<Rect> } impl InitialMapBuilder for BspInteriorBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl BspInteriorBuilder { #[allow(dead_code)] pub fn new() -> Box<BspInteriorBuilder> { Box::new(BspInteriorBuilder{ rects: Vec::new() }) } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let mut rooms : Vec<Rect> = Vec::new(); self.rects.clear(); self.rects.push( Rect::new(1, 1, build_data.map.width-2, build_data.map.height-2) ); // 从单个地图大小的矩形开始 let first_room = self.rects[0]; self.add_subrects(first_room, rng); // 划分第一个房间 let rooms_copy = self.rects.clone(); for r in rooms_copy.iter() { let room = *r; //room.x2 -= 1; //room.y2 -= 1; rooms.push(room); for y in room.y1 .. room.y2 { for x in room.x1 .. room.x2 { let idx = build_data.map.xy_idx(x, y); if idx > 0 && idx < ((build_data.map.width * build_data.map.height)-1) as usize { build_data.map.tiles[idx] = TileType::Floor; } } } build_data.take_snapshot(); } // 现在我们需要走廊 for i in 0..rooms.len()-1 { let room = rooms[i]; let next_room = rooms[i+1]; let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1); let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1); let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1); let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1); draw_corridor(&mut build_data.map, start_x, start_y, end_x, end_y); build_data.take_snapshot(); } build_data.rooms = Some(rooms); } fn add_subrects(&mut self, rect : Rect, rng : &mut RandomNumberGenerator) { // 从列表中删除最后一个矩形 if !self.rects.is_empty() { self.rects.remove(self.rects.len() - 1); } // 计算边界 let width = rect.x2 - rect.x1; let height = rect.y2 - rect.y1; let half_width = width / 2; let half_height = height / 2; let split = rng.roll_dice(1, 4); if split <= 2 { // 水平分割 let h1 = Rect::new( rect.x1, rect.y1, half_width-1, height ); self.rects.push( h1 ); if half_width > MIN_ROOM_SIZE { self.add_subrects(h1, rng); } let h2 = Rect::new( rect.x1 + half_width, rect.y1, half_width, height ); self.rects.push( h2 ); if half_width > MIN_ROOM_SIZE { self.add_subrects(h2, rng); } } else { // 垂直分割 let v1 = Rect::new( rect.x1, rect.y1, width, half_height-1 ); self.rects.push(v1); if half_height > MIN_ROOM_SIZE { self.add_subrects(v1, rng); } let v2 = Rect::new( rect.x1, rect.y1 + half_height, width, half_height ); self.rects.push(v2); if half_height > MIN_ROOM_SIZE { self.add_subrects(v2, rng); } } } } }
您可以通过修改 random_builder 来测试它:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(BspInteriorBuilder::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStartingPosition::new()); builder.with(RoomBasedStairs::new()); builder }
cargo run 现在将带您进入一个内部构建器。
细胞自动机
您现在应该理解这里的基本思想了 - 我们正在将构建器分解为小块,并为地图类型实现适当的 traits。查看细胞自动机地图,您会发现我们做事的方式略有不同:
- 我们像往常一样制作地图。这显然属于
CellularAutomataBuilder。 - 我们搜索靠近中间的起始点。这看起来应该是一个单独的步骤。
- 我们搜索地图中无法到达的区域并剔除它们。这看起来也是一个单独的步骤。
- 我们将出口放置在远离起始位置的地方。这也是一个不同的算法步骤。
好消息是,其中最后三个步骤在许多其他构建器中使用 - 因此实现它们将使我们能够重用代码,而不会不断重复自己。坏消息是,如果我们使用现有的基于房间的步骤运行我们的细胞自动机构建器,它将崩溃 - 我们没有 房间!
因此,我们将从构建基本的地图构建器开始。像其他构建器一样,这主要只是重新排列代码以适应新的 trait 方案。这是新的 cellular_automata.rs 文件:
#![allow(unused)] fn main() { use super::{InitialMapBuilder, BuilderMap, TileType}; use rltk::RandomNumberGenerator; pub struct CellularAutomataBuilder {} impl InitialMapBuilder for CellularAutomataBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl CellularAutomataBuilder { #[allow(dead_code)] pub fn new() -> Box<CellularAutomataBuilder> { Box::new(CellularAutomataBuilder{}) } #[allow(clippy::map_entry)] fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { // 首先,我们完全随机化地图,将其 55% 设置为地板。 for y in 1..build_data.map.height-1 { for x in 1..build_data.map.width-1 { let roll = rng.roll_dice(1, 100); let idx = build_data.map.xy_idx(x, y); if roll > 55 { build_data.map.tiles[idx] = TileType::Floor } else { build_data.map.tiles[idx] = TileType::Wall } } } build_data.take_snapshot(); // 现在我们迭代地应用细胞自动机规则 for _i in 0..15 { let mut newtiles = build_data.map.tiles.clone(); for y in 1..build_data.map.height-1 { for x in 1..build_data.map.width-1 { let idx = build_data.map.xy_idx(x, y); let mut neighbors = 0; if build_data.map.tiles[idx - 1] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx + 1] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx - build_data.map.width as usize] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx + build_data.map.width as usize] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx - (build_data.map.width as usize - 1)] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx - (build_data.map.width as usize + 1)] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx + (build_data.map.width as usize - 1)] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx + (build_data.map.width as usize + 1)] == TileType::Wall { neighbors += 1; } if neighbors > 4 || neighbors == 0 { newtiles[idx] = TileType::Wall; } else { newtiles[idx] = TileType::Floor; } } } build_data.map.tiles = newtiles.clone(); build_data.take_snapshot(); } } } }
非房间起始点
我们完全有可能实际上并不 想 从地图中间开始。这样做提供了很多机会(并有助于确保连通性),但也许您宁愿玩家跋涉穿过很多地图,而减少选择错误方向的机会。如果玩家到达地图的一端并从另一端离开,也许您的故事更有意义。让我们实现一个起始位置系统,该系统采用 首选的 起始点,并选择最近的有效瓦片。创建 area_starting_points.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, Position, TileType}; use rltk::RandomNumberGenerator; #[allow(dead_code)] pub enum XStart { LEFT, CENTER, RIGHT } #[allow(dead_code)] pub enum YStart { TOP, CENTER, BOTTOM } pub struct AreaStartingPosition { x : XStart, y : YStart } impl MetaMapBuilder for AreaStartingPosition { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl AreaStartingPosition { #[allow(dead_code)] pub fn new(x : XStart, y : YStart) -> Box<AreaStartingPosition> { Box::new(AreaStartingPosition{ x, y }) } fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let seed_x; let seed_y; match self.x { XStart::LEFT => seed_x = 1, XStart::CENTER => seed_x = build_data.map.width / 2, XStart::RIGHT => seed_x = build_data.map.width - 2 } match self.y { YStart::TOP => seed_y = 1, YStart::CENTER => seed_y = build_data.map.height / 2, YStart::BOTTOM => seed_y = build_data.map.height - 2 } let mut available_floors : Vec<(usize, f32)> = Vec::new(); for (idx, tiletype) in build_data.map.tiles.iter().enumerate() { if *tiletype == TileType::Floor { available_floors.push( ( idx, rltk::DistanceAlg::PythagorasSquared.distance2d( rltk::Point::new(idx as i32 % build_data.map.width, idx as i32 / build_data.map.width), rltk::Point::new(seed_x, seed_y) ) ) ); } } if available_floors.is_empty() { panic!("No valid floors to start on"); } available_floors.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); let start_x = available_floors[0].0 as i32 % build_data.map.width; let start_y = available_floors[0].0 as i32 / build_data.map.width; build_data.starting_position = Some(Position{x : start_x, y: start_y}); } } }
我们已经介绍了足够的样板代码,不需要再次介绍了 - 所以让我们逐步了解 build 函数:
- 我们接受几个
enum类型:X 轴和 Y 轴上的首选位置。 - 因此,我们将
seed_x和seed_y设置为最接近指定位置的点。 - 我们遍历整个地图,将地板瓦片添加到
available_floors- 并计算到首选起始点的距离。 - 我们对可用瓦片列表进行排序,以便距离较小的瓦片排在前面。
- 我们选择列表中的第一个。
请注意,如果没有地板,我们也会 panic!。
这里最棒的部分是,这将适用于 任何 地图类型 - 它搜索可以站立的地板,并尝试找到最近的起始点。
剔除无法到达的区域
我们之前在剔除无法从起始点到达的区域方面取得了不错的成功。因此,让我们将其形式化为自己的元构建器。创建 cull_unreachable.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, TileType}; use rltk::RandomNumberGenerator; pub struct CullUnreachable {} impl MetaMapBuilder for CullUnreachable { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl CullUnreachable { #[allow(dead_code)] pub fn new() -> Box<CullUnreachable> { Box::new(CullUnreachable{}) } fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let starting_pos = build_data.starting_position.as_ref().unwrap().clone(); let start_idx = build_data.map.xy_idx( starting_pos.x, starting_pos.y ); build_data.map.populate_blocked(); let map_starts : Vec<usize> = vec![start_idx]; let dijkstra_map = rltk::DijkstraMap::new(build_data.map.width as usize, build_data.map.height as usize, &map_starts , &build_data.map, 1000.0); for (i, tile) in build_data.map.tiles.iter_mut().enumerate() { if *tile == TileType::Floor { let distance_to_start = dijkstra_map.map[i]; // 我们无法到达此瓦片 - 因此我们将其设为墙壁 if distance_to_start == std::f32::MAX { *tile = TileType::Wall; } } } } } }
您会注意到这几乎与 common.rs 中的 remove_unreachable_areas_returning_most_distant 相同,但没有返回 Dijkstra 地图。这就是意图:我们删除玩家无法到达的区域,并且 只 做这件事。
基于 Voronoi 的生成
我们还需要复制基于 Voronoi 的生成的功能。创建 voronoi_spawning.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, TileType, spawner}; use rltk::RandomNumberGenerator; use std::collections::HashMap; pub struct VoronoiSpawning {} impl MetaMapBuilder for VoronoiSpawning { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl VoronoiSpawning { #[allow(dead_code)] pub fn new() -> Box<VoronoiSpawning> { Box::new(VoronoiSpawning{}) } #[allow(clippy::map_entry)] fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let mut noise_areas : HashMap<i32, Vec<usize>> = HashMap::new(); let mut noise = rltk::FastNoise::seeded(rng.roll_dice(1, 65536) as u64); noise.set_noise_type(rltk::NoiseType::Cellular); noise.set_frequency(0.08); noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan); for y in 1 .. build_data.map.height-1 { for x in 1 .. build_data.map.width-1 { let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] == TileType::Floor { let cell_value_f = noise.get_noise(x as f32, y as f32) * 10240.0; let cell_value = cell_value_f as i32; if noise_areas.contains_key(&cell_value) { noise_areas.get_mut(&cell_value).unwrap().push(idx); } else { noise_areas.insert(cell_value, vec![idx]); } } } } // 生成实体 for area in noise_areas.iter() { spawner::spawn_region(&build_data.map, rng, area.1, build_data.map.depth, &mut build_data.spawn_list); } } } }
这几乎与我们在各种构建器中调用的 common.rs 中的代码相同,只是进行了修改以在构建器链/构建器地图框架内工作。
生成一个遥远的出口
另一个常用的代码片段生成了关卡的 Dijkstra 地图,从玩家的入口点开始 - 并使用该地图将出口放置在离玩家最远的位置。这在 common.rs 中,我们经常调用它。我们将把这个变成地图构建步骤;创建 map_builders/distant_exit.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, TileType}; use rltk::RandomNumberGenerator; pub struct DistantExit {} impl MetaMapBuilder for DistantExit { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl DistantExit { #[allow(dead_code)] pub fn new() -> Box<DistantExit> { Box::new(DistantExit{}) } fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let starting_pos = build_data.starting_position.as_ref().unwrap().clone(); let start_idx = build_data.map.xy_idx( starting_pos.x, starting_pos.y ); build_data.map.populate_blocked(); let map_starts : Vec<usize> = vec![start_idx]; let dijkstra_map = rltk::DijkstraMap::new(build_data.map.width as usize, build_data.map.height as usize, &map_starts , &build_data.map, 1000.0); let mut exit_tile = (0, 0.0f32); for (i, tile) in build_data.map.tiles.iter_mut().enumerate() { if *tile == TileType::Floor { let distance_to_start = dijkstra_map.map[i]; if distance_to_start != std::f32::MAX { // 如果它比我们当前的出口候选更远,则移动出口 if distance_to_start > exit_tile.1 { exit_tile.0 = i; exit_tile.1 = distance_to_start; } } } } // 放置楼梯 let stairs_idx = exit_tile.0; build_data.map.tiles[stairs_idx] = TileType::DownStairs; build_data.take_snapshot(); } } }
同样,这是我们之前使用过的相同代码 - 只是进行了调整以匹配新接口,因此我们不会详细介绍。
测试细胞自动机
我们终于把所有部件都放在一起了,让我们测试一下。在 random_builder 中,我们将使用新的构建器链:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(CellularAutomataBuilder::new()); builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); builder.with(CullUnreachable::new()); builder.with(VoronoiSpawning::new()); builder.with(DistantExit::new()); builder }
如果您现在 cargo run,您将可以在细胞自动机生成的地图中玩游戏。
更新醉汉漫步
您应该对我们现在正在做的事情有一个很好的了解,因此我们将略过对 drunkard.rs 的更改:
#![allow(unused)] fn main() { use super::{InitialMapBuilder, BuilderMap, TileType, Position, paint, Symmetry}; use rltk::RandomNumberGenerator; #[derive(PartialEq, Copy, Clone)] #[allow(dead_code)] pub enum DrunkSpawnMode { StartingPoint, Random } pub struct DrunkardSettings { pub spawn_mode : DrunkSpawnMode, pub drunken_lifetime : i32, pub floor_percent: f32, pub brush_size: i32, pub symmetry: Symmetry } pub struct DrunkardsWalkBuilder { settings : DrunkardSettings } impl InitialMapBuilder for DrunkardsWalkBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl DrunkardsWalkBuilder { #[allow(dead_code)] pub fn new(settings: DrunkardSettings) -> DrunkardsWalkBuilder { DrunkardsWalkBuilder{ settings } } #[allow(dead_code)] pub fn open_area() -> Box<DrunkardsWalkBuilder> { Box::new(DrunkardsWalkBuilder{ settings : DrunkardSettings{ spawn_mode: DrunkSpawnMode::StartingPoint, drunken_lifetime: 400, floor_percent: 0.5, brush_size: 1, symmetry: Symmetry::None } }) } #[allow(dead_code)] pub fn open_halls() -> Box<DrunkardsWalkBuilder> { Box::new(DrunkardsWalkBuilder{ settings : DrunkardSettings{ spawn_mode: DrunkSpawnMode::Random, drunken_lifetime: 400, floor_percent: 0.5, brush_size: 1, symmetry: Symmetry::None }, }) } #[allow(dead_code)] pub fn winding_passages() -> Box<DrunkardsWalkBuilder> { Box::new(DrunkardsWalkBuilder{ settings : DrunkardSettings{ spawn_mode: DrunkSpawnMode::Random, drunken_lifetime: 100, floor_percent: 0.4, brush_size: 1, symmetry: Symmetry::None }, }) } #[allow(dead_code)] pub fn fat_passages() -> Box<DrunkardsWalkBuilder> { Box::new(DrunkardsWalkBuilder{ settings : DrunkardSettings{ spawn_mode: DrunkSpawnMode::Random, drunken_lifetime: 100, floor_percent: 0.4, brush_size: 2, symmetry: Symmetry::None }, }) } #[allow(dead_code)] pub fn fearful_symmetry() -> Box<DrunkardsWalkBuilder> { Box::new(DrunkardsWalkBuilder{ settings : DrunkardSettings{ spawn_mode: DrunkSpawnMode::Random, drunken_lifetime: 100, floor_percent: 0.4, brush_size: 1, symmetry: Symmetry::Both }, }) } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { // 设置中心起始点 let starting_position = Position{ x: build_data.map.width / 2, y: build_data.map.height / 2 }; let start_idx = build_data.map.xy_idx(starting_position.x, starting_position.y); build_data.map.tiles[start_idx] = TileType::Floor; let total_tiles = build_data.map.width * build_data.map.height; let desired_floor_tiles = (self.settings.floor_percent * total_tiles as f32) as usize; let mut floor_tile_count = build_data.map.tiles.iter().filter(|a| **a == TileType::Floor).count(); let mut digger_count = 0; while floor_tile_count < desired_floor_tiles { let mut did_something = false; let mut drunk_x; let mut drunk_y; match self.settings.spawn_mode { DrunkSpawnMode::StartingPoint => { drunk_x = starting_position.x; drunk_y = starting_position.y; } DrunkSpawnMode::Random => { if digger_count == 0 { drunk_x = starting_position.x; drunk_y = starting_position.y; } else { drunk_x = rng.roll_dice(1, build_data.map.width - 3) + 1; drunk_y = rng.roll_dice(1, build_data.map.height - 3) + 1; } } } let mut drunk_life = self.settings.drunken_lifetime; while drunk_life > 0 { let drunk_idx = build_data.map.xy_idx(drunk_x, drunk_y); if build_data.map.tiles[drunk_idx] == TileType::Wall { did_something = true; } paint(&mut build_data.map, self.settings.symmetry, self.settings.brush_size, drunk_x, drunk_y); build_data.map.tiles[drunk_idx] = TileType::DownStairs; let stagger_direction = rng.roll_dice(1, 4); match stagger_direction { 1 => { if drunk_x > 2 { drunk_x -= 1; } } 2 => { if drunk_x < build_data.map.width-2 { drunk_x += 1; } } 3 => { if drunk_y > 2 { drunk_y -=1; } } _ => { if drunk_y < build_data.map.height-2 { drunk_y += 1; } } } drunk_life -= 1; } if did_something { build_data.take_snapshot(); } digger_count += 1; for t in build_data.map.tiles.iter_mut() { if *t == TileType::DownStairs { *t = TileType::Floor; } } floor_tile_count = build_data.map.tiles.iter().filter(|a| **a == TileType::Floor).count(); } } } }
再次,您可以通过调整 random_builder 来测试它:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(DrunkardsWalkBuilder::fearful_symmetry()); builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); builder.with(CullUnreachable::new()); builder.with(VoronoiSpawning::new()); builder.with(DistantExit::new()); builder }
您可以 cargo run 并查看它的运行情况。
更新扩散限制聚集
这与之前的类似,因此我们将再次仅提供 dla.rs 的代码:
#![allow(unused)] fn main() { use super::{InitialMapBuilder, BuilderMap, TileType, Position, Symmetry, paint}; use rltk::RandomNumberGenerator; #[derive(PartialEq, Copy, Clone)] #[allow(dead_code)] pub enum DLAAlgorithm { WalkInwards, WalkOutwards, CentralAttractor } pub struct DLABuilder { algorithm : DLAAlgorithm, brush_size: i32, symmetry: Symmetry, floor_percent: f32, } impl InitialMapBuilder for DLABuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl DLABuilder { #[allow(dead_code)] pub fn new() -> Box<DLABuilder> { Box::new(DLABuilder{ algorithm: DLAAlgorithm::WalkInwards, brush_size: 2, symmetry: Symmetry::None, floor_percent: 0.25, }) } #[allow(dead_code)] pub fn walk_inwards() -> Box<DLABuilder> { Box::new(DLABuilder{ algorithm: DLAAlgorithm::WalkInwards, brush_size: 1, symmetry: Symmetry::None, floor_percent: 0.25, }) } #[allow(dead_code)] pub fn walk_outwards() -> Box<DLABuilder> { Box::new(DLABuilder{ algorithm: DLAAlgorithm::WalkOutwards, brush_size: 2, symmetry: Symmetry::None, floor_percent: 0.25, }) } #[allow(dead_code)] pub fn central_attractor() -> Box<DLABuilder> { Box::new(DLABuilder{ algorithm: DLAAlgorithm::CentralAttractor, brush_size: 2, symmetry: Symmetry::None, floor_percent: 0.25, }) } #[allow(dead_code)] pub fn insectoid() -> Box<DLABuilder> { Box::new(DLABuilder{ algorithm: DLAAlgorithm::CentralAttractor, brush_size: 2, symmetry: Symmetry::Horizontal, floor_percent: 0.25, }) } #[allow(clippy::map_entry)] fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { // 雕刻起始种子 let starting_position = Position{ x: build_data.map.width/2, y : build_data.map.height/2 }; let start_idx = build_data.map.xy_idx(starting_position.x, starting_position.y); build_data.take_snapshot(); build_data.map.tiles[start_idx] = TileType::Floor; build_data.map.tiles[start_idx-1] = TileType::Floor; build_data.map.tiles[start_idx+1] = TileType::Floor; build_data.map.tiles[start_idx-build_data.map.width as usize] = TileType::Floor; build_data.map.tiles[start_idx+build_data.map.width as usize] = TileType::Floor; // 随机游走者 let total_tiles = build_data.map.width * build_data.map.height; let desired_floor_tiles = (self.floor_percent * total_tiles as f32) as usize; let mut floor_tile_count = build_data.map.tiles.iter().filter(|a| **a == TileType::Floor).count(); while floor_tile_count < desired_floor_tiles { match self.algorithm { DLAAlgorithm::WalkInwards => { let mut digger_x = rng.roll_dice(1, build_data.map.width - 3) + 1; let mut digger_y = rng.roll_dice(1, build_data.map.height - 3) + 1; let mut prev_x = digger_x; let mut prev_y = digger_y; let mut digger_idx = build_data.map.xy_idx(digger_x, digger_y); while build_data.map.tiles[digger_idx] == TileType::Wall { prev_x = digger_x; prev_y = digger_y; let stagger_direction = rng.roll_dice(1, 4); match stagger_direction { 1 => { if digger_x > 2 { digger_x -= 1; } } 2 => { if digger_x < build_data.map.width-2 { digger_x += 1; } } 3 => { if digger_y > 2 { digger_y -=1; } } _ => { if digger_y < build_data.map.height-2 { digger_y += 1; } } } digger_idx = build_data.map.xy_idx(digger_x, digger_y); } paint(&mut build_data.map, self.symmetry, self.brush_size, prev_x, prev_y); } DLAAlgorithm::WalkOutwards => { let mut digger_x = starting_position.x; let mut digger_y = starting_position.y; let mut digger_idx = build_data.map.xy_idx(digger_x, digger_y); while build_data.map.tiles[digger_idx] == TileType::Floor { let stagger_direction = rng.roll_dice(1, 4); match stagger_direction { 1 => { if digger_x > 2 { digger_x -= 1; } } 2 => { if digger_x < build_data.map.width-2 { digger_x += 1; } } 3 => { if digger_y > 2 { digger_y -=1; } } _ => { if digger_y < build_data.map.height-2 { digger_y += 1; } } } digger_idx = build_data.map.xy_idx(digger_x, digger_y); } paint(&mut build_data.map, self.symmetry, self.brush_size, digger_x, digger_y); } DLAAlgorithm::CentralAttractor => { let mut digger_x = rng.roll_dice(1, build_data.map.width - 3) + 1; let mut digger_y = rng.roll_dice(1, build_data.map.height - 3) + 1; let mut prev_x = digger_x; let mut prev_y = digger_y; let mut digger_idx = build_data.map.xy_idx(digger_x, digger_y); let mut path = rltk::line2d( rltk::LineAlg::Bresenham, rltk::Point::new( digger_x, digger_y ), rltk::Point::new( starting_position.x, starting_position.y ) ); while build_data.map.tiles[digger_idx] == TileType::Wall && !path.is_empty() { prev_x = digger_x; prev_y = digger_y; digger_x = path[0].x; digger_y = path[0].y; path.remove(0); digger_idx = build_data.map.xy_idx(digger_x, digger_y); } paint(&mut build_data.map, self.symmetry, self.brush_size, prev_x, prev_y); } } build_data.take_snapshot(); floor_tile_count = build_data.map.tiles.iter().filter(|a| **a == TileType::Floor).count(); } } } }
更新迷宫构建器
再次,这是 maze.rs 的代码:
#![allow(unused)] fn main() { use super::{Map, InitialMapBuilder, BuilderMap, TileType}; use rltk::RandomNumberGenerator; pub struct MazeBuilder {} impl InitialMapBuilder for MazeBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl MazeBuilder { #[allow(dead_code)] pub fn new() -> Box<MazeBuilder> { Box::new(MazeBuilder{}) } #[allow(clippy::map_entry)] fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { // 迷宫生成 let mut maze = Grid::new((build_data.map.width / 2)-2, (build_data.map.height / 2)-2, rng); maze.generate_maze(build_data); } } /* 迷宫代码根据 MIT 许可取自 https://github.com/cyucelen/mazeGenerator/ */ const TOP : usize = 0; const RIGHT : usize = 1; const BOTTOM : usize = 2; const LEFT : usize = 3; #[derive(Copy, Clone)] struct Cell { row: i32, column: i32, walls: [bool; 4], visited: bool, } impl Cell { fn new(row: i32, column: i32) -> Cell { Cell{ row, column, walls: [true, true, true, true], visited: false } } fn remove_walls(&mut self, next : &mut Cell) { let x = self.column - next.column; let y = self.row - next.row; if x == 1 { self.walls[LEFT] = false; next.walls[RIGHT] = false; } else if x == -1 { self.walls[RIGHT] = false; next.walls[LEFT] = false; } else if y == 1 { self.walls[TOP] = false; next.walls[BOTTOM] = false; } else if y == -1 { self.walls[BOTTOM] = false; next.walls[TOP] = false; } } } struct Grid<'a> { width: i32, height: i32, cells: Vec<Cell>, backtrace: Vec<usize>, current: usize, rng : &'a mut RandomNumberGenerator } impl<'a> Grid<'a> { fn new(width: i32, height:i32, rng: &mut RandomNumberGenerator) -> Grid { let mut grid = Grid{ width, height, cells: Vec::new(), backtrace: Vec::new(), current: 0, rng }; for row in 0..height { for column in 0..width { grid.cells.push(Cell::new(row, column)); } } grid } fn calculate_index(&self, row: i32, column: i32) -> i32 { if row < 0 || column < 0 || column > self.width-1 || row > self.height-1 { -1 } else { column + (row * self.width) } } fn get_available_neighbors(&self) -> Vec<usize> { let mut neighbors : Vec<usize> = Vec::new(); let current_row = self.cells[self.current].row; let current_column = self.cells[self.current].column; let neighbor_indices : [i32; 4] = [ self.calculate_index(current_row -1, current_column), self.calculate_index(current_row, current_column + 1), self.calculate_index(current_row + 1, current_column), self.calculate_index(current_row, current_column - 1) ]; for i in neighbor_indices.iter() { if *i != -1 && !self.cells[*i as usize].visited { neighbors.push(*i as usize); } } neighbors } fn find_next_cell(&mut self) -> Option<usize> { let neighbors = self.get_available_neighbors(); if !neighbors.is_empty() { if neighbors.len() == 1 { return Some(neighbors[0]); } else { return Some(neighbors[(self.rng.roll_dice(1, neighbors.len() as i32)-1) as usize]); } } None } fn generate_maze(&mut self, build_data : &mut BuilderMap) { let mut i = 0; loop { self.cells[self.current].visited = true; let next = self.find_next_cell(); match next { Some(next) => { self.cells[next].visited = true; self.backtrace.push(self.current); // __lower_part__ __higher_part_ // / \ / \ // --------cell1------ | cell2----------- let (lower_part, higher_part) = self.cells.split_at_mut(std::cmp::max(self.current, next)); let cell1 = &mut lower_part[std::cmp::min(self.current, next)]; let cell2 = &mut higher_part[0]; cell1.remove_walls(cell2); self.current = next; } None => { if !self.backtrace.is_empty() { self.current = self.backtrace[0]; self.backtrace.remove(0); } else { break; } } } if i % 50 == 0 { self.copy_to_map(&mut build_data.map); build_data.take_snapshot(); } i += 1; } } fn copy_to_map(&self, map : &mut Map) { // 清空地图 for i in map.tiles.iter_mut() { *i = TileType::Wall; } for cell in self.cells.iter() { let x = cell.column + 1; let y = cell.row + 1; let idx = map.xy_idx(x * 2, y * 2); map.tiles[idx] = TileType::Floor; if !cell.walls[TOP] { map.tiles[idx - map.width as usize] = TileType::Floor } if !cell.walls[RIGHT] { map.tiles[idx + 1] = TileType::Floor } if !cell.walls[BOTTOM] { map.tiles[idx + map.width as usize] = TileType::Floor } if !cell.walls[LEFT] { map.tiles[idx - 1] = TileType::Floor } } } } }
更新 Voronoi 地图
这是 Voronoi 构建器(在 voronoi.rs 中)的更新代码:
#![allow(unused)] fn main() { use super::{InitialMapBuilder, BuilderMap, TileType}; use rltk::RandomNumberGenerator; #[derive(PartialEq, Copy, Clone)] #[allow(dead_code)] pub enum DistanceAlgorithm { Pythagoras, Manhattan, Chebyshev } pub struct VoronoiCellBuilder { n_seeds: usize, distance_algorithm: DistanceAlgorithm } impl InitialMapBuilder for VoronoiCellBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl VoronoiCellBuilder { #[allow(dead_code)] pub fn new() -> Box<VoronoiCellBuilder> { Box::new(VoronoiCellBuilder{ n_seeds: 64, distance_algorithm: DistanceAlgorithm::Pythagoras, }) } #[allow(dead_code)] pub fn pythagoras() -> Box<VoronoiCellBuilder> { Box::new(VoronoiCellBuilder{ n_seeds: 64, distance_algorithm: DistanceAlgorithm::Pythagoras, }) } #[allow(dead_code)] pub fn manhattan() -> Box<VoronoiCellBuilder> { Box::new(VoronoiCellBuilder{ n_seeds: 64, distance_algorithm: DistanceAlgorithm::Manhattan, }) } #[allow(clippy::map_entry)] fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { // 制作 Voronoi 图。我们将以困难的方式做到这一点,以了解这项技术! let mut voronoi_seeds : Vec<(usize, rltk::Point)> = Vec::new(); while voronoi_seeds.len() < self.n_seeds { let vx = rng.roll_dice(1, build_data.map.width-1); let vy = rng.roll_dice(1, build_data.map.height-1); let vidx = build_data.map.xy_idx(vx, vy); let candidate = (vidx, rltk::Point::new(vx, vy)); if !voronoi_seeds.contains(&candidate) { voronoi_seeds.push(candidate); } } let mut voronoi_distance = vec![(0, 0.0f32) ; self.n_seeds]; let mut voronoi_membership : Vec<i32> = vec![0 ; build_data.map.width as usize * build_data.map.height as usize]; for (i, vid) in voronoi_membership.iter_mut().enumerate() { let x = i as i32 % build_data.map.width; let y = i as i32 / build_data.map.width; for (seed, pos) in voronoi_seeds.iter().enumerate() { let distance; match self.distance_algorithm { DistanceAlgorithm::Pythagoras => { distance = rltk::DistanceAlg::PythagorasSquared.distance2d( rltk::Point::new(x, y), pos.1 ); } DistanceAlgorithm::Manhattan => { distance = rltk::DistanceAlg::Manhattan.distance2d( rltk::Point::new(x, y), pos.1 ); } DistanceAlgorithm::Chebyshev => { distance = rltk::DistanceAlg::Chebyshev.distance2d( rltk::Point::new(x, y), pos.1 ); } } voronoi_distance[seed] = (seed, distance); } voronoi_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); *vid = voronoi_distance[0].0 as i32; } for y in 1..build_data.map.height-1 { for x in 1..build_data.map.width-1 { let mut neighbors = 0; let my_idx = build_data.map.xy_idx(x, y); let my_seed = voronoi_membership[my_idx]; if voronoi_membership[build_data.map.xy_idx(x-1, y)] != my_seed { neighbors += 1; } if voronoi_membership[build_data.map.xy_idx(x+1, y)] != my_seed { neighbors += 1; } if voronoi_membership[build_data.map.xy_idx(x, y-1)] != my_seed { neighbors += 1; } if voronoi_membership[build_data.map.xy_idx(x, y+1)] != my_seed { neighbors += 1; } if neighbors < 2 { build_data.map.tiles[my_idx] = TileType::Floor; } } build_data.take_snapshot(); } } } }
更新波函数坍缩
波函数坍缩是一个略有不同的端口,因为它已经有了“前一个构建器”的概念。现在这个概念已经消失了(链式调用是自动的),因此需要更新更多内容。波函数坍缩是一个元构建器,因此它实现了该 trait,而不是初始地图构建器。总的来说,这些更改使它 简单 得多!所有更改都在 waveform_collapse/mod.rs 中进行:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, Map, TileType}; use rltk::RandomNumberGenerator; mod common; use common::*; mod constraints; use constraints::*; mod solver; use solver::*; /// 提供一个使用波函数坍缩算法的地图构建器。 pub struct WaveformCollapseBuilder {} impl MetaMapBuilder for WaveformCollapseBuilder { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl WaveformCollapseBuilder { /// 波函数坍缩的构造函数。 #[allow(dead_code)] pub fn new() -> Box<WaveformCollapseBuilder> { Box::new(WaveformCollapseBuilder{}) } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { const CHUNK_SIZE :i32 = 8; build_data.take_snapshot(); let patterns = build_patterns(&build_data.map, CHUNK_SIZE, true, true); let constraints = patterns_to_constraints(patterns, CHUNK_SIZE); self.render_tile_gallery(&constraints, CHUNK_SIZE, build_data); build_data.map = Map::new(build_data.map.depth); loop { let mut solver = Solver::new(constraints.clone(), CHUNK_SIZE, &build_data.map); while !solver.iteration(&mut build_data.map, rng) { build_data.take_snapshot(); } build_data.take_snapshot(); if solver.possible { break; } // 如果它遇到了不可能的条件,请重试 } build_data.spawn_list.clear(); } fn render_tile_gallery(&mut self, constraints: &[MapChunk], chunk_size: i32, build_data : &mut BuilderMap) { build_data.map = Map::new(0); let mut counter = 0; let mut x = 1; let mut y = 1; while counter < constraints.len() { render_pattern_to_map(&mut build_data.map, &constraints[counter], chunk_size, x, y); x += chunk_size + 1; if x + chunk_size > build_data.map.width { // 移动到下一行 x = 1; y += chunk_size + 1; if y + chunk_size > build_data.map.height { // 移动到下一页 build_data.take_snapshot(); build_data.map = Map::new(0); x = 1; y = 1; } } counter += 1; } build_data.take_snapshot(); } } }
您可以使用以下代码进行测试:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(VoronoiCellBuilder::pythagoras()); builder.with(WaveformCollapseBuilder::new()); builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); builder.with(CullUnreachable::new()); builder.with(VoronoiSpawning::new()); builder.with(DistantExit::new()); builder }
更新预制件构建器
这是一个有趣的东西。PrefabBuilder 既是 InitialMapBuilder 又是 MetaMapBuilder - 两者之间共享代码。幸运的是,这些 traits 是相同的 - 因此我们可以同时实现它们,并从每个 trait 调用到主 build 函数中!Rust 足够智能,可以根据我们存储的 trait 找出我们正在调用哪个 trait - 因此 PrefabBuilder 可以放置在初始或元地图构建器中。
所有更改都在 prefab_builder/mod.rs 中进行:
#![allow(unused)] fn main() { use super::{InitialMapBuilder, MetaMapBuilder, BuilderMap, TileType, Position}; use rltk::RandomNumberGenerator; pub mod prefab_levels; pub mod prefab_sections; pub mod prefab_rooms; use std::collections::HashSet; #[derive(PartialEq, Copy, Clone)] #[allow(dead_code)] pub enum PrefabMode { RexLevel{ template : &'static str }, Constant{ level : prefab_levels::PrefabLevel }, Sectional{ section : prefab_sections::PrefabSection }, RoomVaults } #[allow(dead_code)] pub struct PrefabBuilder { mode: PrefabMode } impl MetaMapBuilder for PrefabBuilder { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl InitialMapBuilder for PrefabBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl PrefabBuilder { #[allow(dead_code)] pub fn new() -> Box<PrefabBuilder> { Box::new(PrefabBuilder{ mode : PrefabMode::RoomVaults, }) } #[allow(dead_code)] pub fn rex_level(template : &'static str) -> Box<PrefabBuilder> { Box::new(PrefabBuilder{ mode : PrefabMode::RexLevel{ template }, }) } #[allow(dead_code)] pub fn constant(level : prefab_levels::PrefabLevel) -> Box<PrefabBuilder> { Box::new(PrefabBuilder{ mode : PrefabMode::Constant{ level }, }) } #[allow(dead_code)] pub fn sectional(section : prefab_sections::PrefabSection) -> Box<PrefabBuilder> { Box::new(PrefabBuilder{ mode : PrefabMode::Sectional{ section }, }) } #[allow(dead_code)] pub fn vaults() -> Box<PrefabBuilder> { Box::new(PrefabBuilder{ mode : PrefabMode::RoomVaults, }) } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { match self.mode { PrefabMode::RexLevel{template} => self.load_rex_map(&template, build_data), PrefabMode::Constant{level} => self.load_ascii_map(&level, build_data), PrefabMode::Sectional{section} => self.apply_sectional(§ion, rng, build_data), PrefabMode::RoomVaults => self.apply_room_vaults(rng, build_data) } build_data.take_snapshot(); } fn char_to_map(&mut self, ch : char, idx: usize, build_data : &mut BuilderMap) { match ch { ' ' => build_data.map.tiles[idx] = TileType::Floor, '#' => build_data.map.tiles[idx] = TileType::Wall, '@' => { let x = idx as i32 % build_data.map.width; let y = idx as i32 / build_data.map.width; build_data.map.tiles[idx] = TileType::Floor; build_data.starting_position = Some(Position{ x:x as i32, y:y as i32 }); } '>' => build_data.map.tiles[idx] = TileType::DownStairs, 'g' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Goblin".to_string())); } 'o' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Orc".to_string())); } '^' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Bear Trap".to_string())); } '%' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Rations".to_string())); } '!' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Health Potion".to_string())); } _ => { rltk::console::log(format!("Unknown glyph loading map: {}", (ch as u8) as char)); } } } #[allow(dead_code)] fn load_rex_map(&mut self, path: &str, build_data : &mut BuilderMap) { let xp_file = rltk::rex::XpFile::from_resource(path).unwrap(); for layer in &xp_file.layers { for y in 0..layer.height { for x in 0..layer.width { let cell = layer.get(x, y).unwrap(); if x < build_data.map.width as usize && y < build_data.map.height as usize { let idx = build_data.map.xy_idx(x as i32, y as i32); // 我们正在做一些令人讨厌的类型转换,以便更容易在 match 中键入诸如 '#' 之类的东西 self.char_to_map(cell.ch as u8 as char, idx, build_data); } } } } } fn read_ascii_to_vec(template : &str) -> Vec<char> { let mut string_vec : Vec<char> = template.chars().filter(|a| *a != '\r' && *a !='\n').collect(); for c in string_vec.iter_mut() { if *c as u8 == 160u8 { *c = ' '; } } string_vec } #[allow(dead_code)] fn load_ascii_map(&mut self, level: &prefab_levels::PrefabLevel, build_data : &mut BuilderMap) { let string_vec = PrefabBuilder::read_ascii_to_vec(level.template); let mut i = 0; for ty in 0..level.height { for tx in 0..level.width { if tx < build_data.map.width as usize && ty < build_data.map.height as usize { let idx = build_data.map.xy_idx(tx as i32, ty as i32); if i < string_vec.len() { self.char_to_map(string_vec[i], idx, build_data); } } i += 1; } } } fn apply_previous_iteration<F>(&mut self, mut filter: F, _rng: &mut RandomNumberGenerator, build_data : &mut BuilderMap) where F : FnMut(i32, i32) -> bool { let width = build_data.map.width; build_data.spawn_list.retain(|(idx, _name)| { let x = *idx as i32 % width; let y = *idx as i32 / width; filter(x, y) }); build_data.take_snapshot(); } #[allow(dead_code)] fn apply_sectional(&mut self, section : &prefab_sections::PrefabSection, rng: &mut RandomNumberGenerator, build_data : &mut BuilderMap) { use prefab_sections::*; let string_vec = PrefabBuilder::read_ascii_to_vec(section.template); // 放置新 section let chunk_x; match section.placement.0 { HorizontalPlacement::Left => chunk_x = 0, HorizontalPlacement::Center => chunk_x = (build_data.map.width / 2) - (section.width as i32 / 2), HorizontalPlacement::Right => chunk_x = (build_data.map.width-1) - section.width as i32 } let chunk_y; match section.placement.1 { VerticalPlacement::Top => chunk_y = 0, VerticalPlacement::Center => chunk_y = (build_data.map.height / 2) - (section.height as i32 / 2), VerticalPlacement::Bottom => chunk_y = (build_data.map.height-1) - section.height as i32 } // 构建地图 self.apply_previous_iteration(|x,y| { x < chunk_x || x > (chunk_x + section.width as i32) || y < chunk_y || y > (chunk_y + section.height as i32) }, rng, build_data); let mut i = 0; for ty in 0..section.height { for tx in 0..section.width { if tx > 0 && tx < build_data.map.width as usize -1 && ty < build_data.map.height as usize -1 && ty > 0 { let idx = build_data.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y); if i < string_vec.len() { self.char_to_map(string_vec[i], idx, build_data); } } i += 1; } } build_data.take_snapshot(); } fn apply_room_vaults(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { use prefab_rooms::*; // 应用之前的构建器,并保留它生成的所有实体(目前) self.apply_previous_iteration(|_x,_y| true, rng, build_data); // 我们想要 vault 吗? let vault_roll = rng.roll_dice(1, 6) + build_data.map.depth; if vault_roll < 4 { return; } // 请注意,这是一个占位符,将被移出此函数 let master_vault_list = vec![TOTALLY_NOT_A_TRAP, CHECKERBOARD, SILLY_SMILE]; // 将 vault 列表过滤到适用于当前深度的 vault let mut possible_vaults : Vec<&PrefabRoom> = master_vault_list .iter() .filter(|v| { build_data.map.depth >= v.first_depth && build_data.map.depth <= v.last_depth }) .collect(); if possible_vaults.is_empty() { return; } // 如果没有什么可构建的,则退出 let n_vaults = i32::min(rng.roll_dice(1, 3), possible_vaults.len() as i32); let mut used_tiles : HashSet<usize> = HashSet::new(); for _i in 0..n_vaults { let vault_index = if possible_vaults.len() == 1 { 0 } else { (rng.roll_dice(1, possible_vaults.len() as i32)-1) as usize }; let vault = possible_vaults[vault_index]; // 我们将创建一个 vault 可能适合的位置列表 let mut vault_positions : Vec<Position> = Vec::new(); let mut idx = 0usize; loop { let x = (idx % build_data.map.width as usize) as i32; let y = (idx / build_data.map.width as usize) as i32; // 检查我们是否不会溢出地图 if x > 1 && (x+vault.width as i32) < build_data.map.width-2 && y > 1 && (y+vault.height as i32) < build_data.map.height-2 { let mut possible = true; for ty in 0..vault.height as i32 { for tx in 0..vault.width as i32 { let idx = build_data.map.xy_idx(tx + x, ty + y); if build_data.map.tiles[idx] != TileType::Floor { possible = false; } if used_tiles.contains(&idx) { possible = false; } } } if possible { vault_positions.push(Position{ x,y }); break; } } idx += 1; if idx >= build_data.map.tiles.len()-1 { break; } } if !vault_positions.is_empty() { let pos_idx = if vault_positions.len()==1 { 0 } else { (rng.roll_dice(1, vault_positions.len() as i32)-1) as usize }; let pos = &vault_positions[pos_idx]; let chunk_x = pos.x; let chunk_y = pos.y; let width = build_data.map.width; // 当我们在 `retain` 内部访问 `self` 时,借用检查器真的不喜欢这样 let height = build_data.map.height; // build_data.spawn_list.retain(|e| { let idx = e.0 as i32; let x = idx % width; let y = idx / height; x < chunk_x || x > chunk_x + vault.width as i32 || y < chunk_y || y > chunk_y + vault.height as i32 }); let string_vec = PrefabBuilder::read_ascii_to_vec(vault.template); let mut i = 0; for ty in 0..vault.height { for tx in 0..vault.width { let idx = build_data.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y); if i < string_vec.len() { self.char_to_map(string_vec[i], idx, build_data); } used_tiles.insert(idx); i += 1; } } build_data.take_snapshot(); possible_vaults.remove(vault_index); } } } } }
您可以使用 random_builder 中的以下代码测试我们最近的更改(在 map_builders/mod.rs 中):
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(VoronoiCellBuilder::pythagoras()); builder.with(WaveformCollapseBuilder::new()); builder.with(PrefabBuilder::vaults()); builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); builder.with(CullUnreachable::new()); builder.with(VoronoiSpawning::new()); builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT)); builder.with(DistantExit::new()); builder }
这演示了我们方法的强大之处 - 我们正在从小构建块中组合大量功能。在这个例子中,我们正在:
- 从 使用
VoronoiBuilder在Pythagoras模式下生成的地图 开始。 - 使用
WaveformCollapseBuilder运行 修改 地图,这将像拼图游戏一样重新排列地图。 - 通过
PrefabBuilder(在 Vaults 模式下)修改 地图,放置 vaults。 - 使用
AreaStartingPositions修改 地图,指示我们希望在地图中间附近开始。 - 修改 地图以剔除无法到达的区域。
- 修改 地图以使用 Voronoi 生成方法生成实体。
- 修改 地图以添加地下堡垒,再次使用
PrefabBuilder。 - 修改 地图以在最远的位置添加出口楼梯。
删除 MapBuilder Trait 和 common 中的位
现在我们已经有了构建器机制,我们可以删除一些旧代码了。从 common.rs 中,我们可以删除 remove_unreachable_areas_returning_most_distant 和 generate_voronoi_spawn_regions;我们已经用构建器步骤替换了它们。
我们还可以打开 map_builders/mod.rs 并删除 MapBuilder trait 及其实现:我们现在已经完全替换了它。
随机化
像往常一样,我们希望回到地图生成是随机的状态。我们将把这个过程分解为两个步骤。我们将创建一个新函数 random_initial_builder,该函数掷骰子并选择 起始 构建器。它还返回一个 bool,指示我们是否选择了提供房间数据的算法。基本函数应该看起来很熟悉,但我们已经摆脱了所有 Box::new 调用 - 构造函数现在为我们创建 boxes:
#![allow(unused)] fn main() { fn random_initial_builder(rng: &mut rltk::RandomNumberGenerator) -> (Box<dyn InitialMapBuilder>, bool) { let builder = rng.roll_dice(1, 17); let result : (Box<dyn InitialMapBuilder>, bool); match builder { 1 => result = (BspDungeonBuilder::new(), true), 2 => result = (BspInteriorBuilder::new(), true), 3 => result = (CellularAutomataBuilder::new(), false), 4 => result = (DrunkardsWalkBuilder::open_area(), false), 5 => result = (DrunkardsWalkBuilder::open_halls(), false), 6 => result = (DrunkardsWalkBuilder::winding_passages(), false), 7 => result = (DrunkardsWalkBuilder::fat_passages(), false), 8 => result = (DrunkardsWalkBuilder::fearful_symmetry(), false), 9 => result = (MazeBuilder::new(), false), 10 => result = (DLABuilder::walk_inwards(), false), 11 => result = (DLABuilder::walk_outwards(), false), 12 => result = (DLABuilder::central_attractor(), false), 13 => result = (DLABuilder::insectoid(), false), 14 => result = (VoronoiCellBuilder::pythagoras(), false), 15 => result = (VoronoiCellBuilder::manhattan(), false), 16 => result = (PrefabBuilder::constant(prefab_builder::prefab_levels::WFC_POPULATED), false), _ => result = (SimpleMapBuilder::new(), true) } result } }
这是一个非常简单的函数 - 我们掷骰子,匹配结果表并返回我们选择的构建器和房间信息。现在我们将修改我们的 random_builder 函数以使用它:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain { let mut builder = BuilderChain::new(new_depth); let (random_starter, has_rooms) = random_initial_builder(rng); builder.start_with(random_starter); if has_rooms { builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStairs::new()); builder.with(RoomBasedStartingPosition::new()); } else { builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); builder.with(CullUnreachable::new()); builder.with(VoronoiSpawning::new()); builder.with(DistantExit::new()); } if rng.roll_dice(1, 3)==1 { builder.with(WaveformCollapseBuilder::new()); } if rng.roll_dice(1, 20)==1 { builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT)); } builder.with(PrefabBuilder::vaults()); builder } }
这应该看起来很熟悉。此函数:
- 使用我们刚刚创建的函数选择一个随机房间。
- 如果构建器提供房间数据,我们将链式调用
RoomBasedSpawner、RoomBasedStairs和RoomBasedStartingPositiosn- 房间数据所需的三个重要步骤。 - 如果构建器 不 提供房间信息,我们将链式调用
AreaStartingPosition、CullUnreachable、VoronoiSpawning和DistantExit- 我们过去在每个构建器内部应用的默认值。 - 我们掷一个 3 面骰子;如果结果是 1 - 我们应用
WaveformCollapseBuilder来重新排列地图。 - 我们掷一个 20 面骰子;如果结果是 1 - 我们应用我们的地下堡垒预制件。
- 我们将 vault 创建应用于最终地图,从而有机会出现预制房间。
总结
这是一个 巨大的 章节,但我们完成了很多工作:
- 我们现在有一个一致的构建器接口,用于将任意数量的元地图修饰符链接到我们的构建链。这应该使我们能够构建我们想要的地图。
- 每个构建器现在都 只做一项任务 - 因此如果您需要修复/调试它们,那么去哪里就更加明显了。
- 构建器不再负责制作其他构建器 - 因此我们已经剔除了一大堆代码,并将 bug 潜入的机会转移到只有一个(简单的)控制流中。
这为下一章奠定了基础,下一章将研究更多使用过滤器来修改地图的方法。
...
本章的源代码可以在这里找到
在您的浏览器中使用 Web 程序集运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
图层的乐趣
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
既然我们有了一个漂亮、干净的图层系统,我们将借此机会稍微玩一下它。 本章收集了一些你可以用图层做的有趣的事情,并将介绍一些新的图层类型。 它的目的是激发你编写更多代码的兴趣:天空才是真正的极限!
将现有算法作为元构建器
让我们首先调整一些现有的算法,使其可以用作过滤器。
应用细胞自动机作为元构建器
当我们编写细胞自动机系统时,我们的目标是构建一个通用的洞穴生成器。 该算法的功能远不止于此——每次迭代基本上都是在前一次迭代上运行的“元构建器”。 一个简单的调整使其也能够成为一个只运行一次迭代的元构建器。
我们将首先将单次迭代的代码移动到它自己的函数中:
#![allow(unused)] fn main() { fn apply_iteration(&mut self, build_data : &mut BuilderMap) { let mut newtiles = build_data.map.tiles.clone(); for y in 1..build_data.map.height-1 { for x in 1..build_data.map.width-1 { let idx = build_data.map.xy_idx(x, y); let mut neighbors = 0; if build_data.map.tiles[idx - 1] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx + 1] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx - build_data.map.width as usize] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx + build_data.map.width as usize] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx - (build_data.map.width as usize - 1)] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx - (build_data.map.width as usize + 1)] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx + (build_data.map.width as usize - 1)] == TileType::Wall { neighbors += 1; } if build_data.map.tiles[idx + (build_data.map.width as usize + 1)] == TileType::Wall { neighbors += 1; } if neighbors > 4 || neighbors == 0 { newtiles[idx] = TileType::Wall; } else { newtiles[idx] = TileType::Floor; } } } build_data.map.tiles = newtiles.clone(); build_data.take_snapshot(); } }
build 函数可以很容易地修改为在每次迭代时调用它:
#![allow(unused)] fn main() { // 现在我们迭代地应用细胞自动机规则 for _i in 0..15 { self.apply_iteration(build_data); } }
最后,我们将为组合添加 MetaMapBuilder 的实现:
#![allow(unused)] fn main() { impl MetaMapBuilder for CellularAutomataBuilder { #[allow(dead_code)] fn build_map(&mut self, _rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.apply_iteration(build_data); } } }
看看我们是如何调用单个迭代,而不是替换整个地图? 这展示了我们如何将细胞自动机规则应用于地图 - 并相当大地改变结果特征。
现在让我们修改 map_builders/mod.rs 的 random_builder,强制它使用这个作为例子:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain { let mut builder = BuilderChain::new(new_depth); builder.start_with(VoronoiCellBuilder::pythagoras()); builder.with(CellularAutomataBuilder::new()); builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); builder.with(CullUnreachable::new()); builder.with(VoronoiSpawning::new()); builder.with(DistantExit::new()); builder } }
如果你现在 cargo run 运行项目,你将看到类似这样的内容:
。
用醉酒的矮人侵蚀方正的地图
醉汉走路算法也可以产生很好的后处理效果,只需进行非常小的修改。 在 drunkard.rs 中,只需添加以下内容:
#![allow(unused)] fn main() { impl MetaMapBuilder for DrunkardsWalkBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } }
你可以再次修改 random_builder 来测试它:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(SimpleMapBuilder::new()); builder.with(DrunkardsWalkBuilder::winding_passages()); builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); builder.with(CullUnreachable::new()); builder.with(VoronoiSpawning::new()); builder.with(DistantExit::new()); builder }
如果你 cargo run 运行项目,你将看到类似这样的内容:
。
请注意,最初的方正设计现在看起来更自然一些,因为醉酒的矮人已经开凿出地图的各个部分!
使用扩散限制聚集攻击你的方正地图
DLA 也可以被修改为侵蚀现有的方正地图。 只需将 MetaBuilder 特征添加到 dla.rs:
#![allow(unused)] fn main() { impl MetaMapBuilder for DLABuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } }
我们还将添加一个新的模式 heavy_erosion ——它与“向内行走”相同,但需要更大的地板空间百分比:
#![allow(unused)] fn main() { #[allow(dead_code)] pub fn heavy_erosion() -> Box<DLABuilder> { Box::new(DLABuilder{ algorithm: DLAAlgorithm::WalkInwards, brush_size: 2, symmetry: Symmetry::None, floor_percent: 0.35, }) } }
并修改你的 random_builder 测试工具:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(SimpleMapBuilder::new()); builder.with(DLABuilder::heavy_erosion()); builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); builder.with(CullUnreachable::new()); builder.with(VoronoiSpawning::new()); builder.with(DistantExit::new()); builder }
如果你 cargo run 运行项目,你将看到类似这样的内容:
。
一些新的元构建器
编写新的地图过滤器也有很大的空间。 在本节中,我们将探讨一些更有趣的过滤器。 几乎任何你可能在像 Photoshop (或 GIMP!) 这样的程序中用作图像过滤器的东西都可以为此目的进行调整。 给定过滤器的用处仍然是一个开放/有趣的问题!
侵蚀房间
Nethack 风格的方正房间非常适合早期的 D&D 类型游戏,但人们经常说它们在视觉上并没有那么令人愉悦或有趣。 一种保持基本房间风格,但获得更自然的视觉效果的方法是在每个房间内部运行醉汉走路算法。 我喜欢称之为“炸毁房间”——因为它看起来有点像你在每个房间里引爆了炸药。 在 map_builders/ 中,创建一个新文件 room_exploder.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, TileType, paint, Symmetry, Rect}; use rltk::RandomNumberGenerator; pub struct RoomExploder {} impl MetaMapBuilder for RoomExploder { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl RoomExploder { #[allow(dead_code)] pub fn new() -> Box<RoomExploder> { Box::new(RoomExploder{}) } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let rooms : Vec<Rect>; if let Some(rooms_builder) = &build_data.rooms { rooms = rooms_builder.clone(); } else { panic!("Room Explosions require a builder with room structures"); } for room in rooms.iter() { let start = room.center(); let n_diggers = rng.roll_dice(1, 20)-5; if n_diggers > 0 { for _i in 0..n_diggers { let mut drunk_x = start.0; let mut drunk_y = start.1; let mut drunk_life = 20; let mut did_something = false; while drunk_life > 0 { let drunk_idx = build_data.map.xy_idx(drunk_x, drunk_y); if build_data.map.tiles[drunk_idx] == TileType::Wall { did_something = true; } paint(&mut build_data.map, Symmetry::None, 1, drunk_x, drunk_y); build_data.map.tiles[drunk_idx] = TileType::DownStairs; let stagger_direction = rng.roll_dice(1, 4); match stagger_direction { 1 => { if drunk_x > 2 { drunk_x -= 1; } } 2 => { if drunk_x < build_data.map.width-2 { drunk_x += 1; } } 3 => { if drunk_y > 2 { drunk_y -=1; } } _ => { if drunk_y < build_data.map.height-2 { drunk_y += 1; } } } drunk_life -= 1; } if did_something { build_data.take_snapshot(); } for t in build_data.map.tiles.iter_mut() { if *t == TileType::DownStairs { *t = TileType::Floor; } } } } } } } }
这段代码中没有什么太令人惊讶的地方:它从父构建数据中获取 rooms 列表,然后迭代每个房间。 然后从每个房间的中心运行随机数量(可以为零)的醉汉,寿命很短,雕刻出每个房间的边缘。 你可以使用以下 random_builder 代码进行测试:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(BspDungeonBuilder::new()); builder.with(RoomExploder::new()); builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); builder.with(CullUnreachable::new()); builder.with(VoronoiSpawning::new()); builder.with(DistantExit::new()); builder }
。
圆角房间角
另一种使方正地图看起来不那么矩形的快速简便方法是将角稍微平滑一下。 将 room_corner_rounding.rs 添加到 map_builders/:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, TileType, Rect}; use rltk::RandomNumberGenerator; pub struct RoomCornerRounder {} impl MetaMapBuilder for RoomCornerRounder { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl RoomCornerRounder { #[allow(dead_code)] pub fn new() -> Box<RoomCornerRounder> { Box::new(RoomCornerRounder{}) } fn fill_if_corner(&mut self, x: i32, y: i32, build_data : &mut BuilderMap) { let w = build_data.map.width; let h = build_data.map.height; let idx = build_data.map.xy_idx(x, y); let mut neighbor_walls = 0; if x > 0 && build_data.map.tiles[idx-1] == TileType::Wall { neighbor_walls += 1; } if y > 0 && build_data.map.tiles[idx-w as usize] == TileType::Wall { neighbor_walls += 1; } if x < w-2 && build_data.map.tiles[idx+1] == TileType::Wall { neighbor_walls += 1; } if y < h-2 && build_data.map.tiles[idx+w as usize] == TileType::Wall { neighbor_walls += 1; } if neighbor_walls == 2 { build_data.map.tiles[idx] = TileType::Wall; } } fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let rooms : Vec<Rect>; if let Some(rooms_builder) = &build_data.rooms { rooms = rooms_builder.clone(); } else { panic!("Room Rounding require a builder with room structures"); } for room in rooms.iter() { self.fill_if_corner(room.x1+1, room.y1+1, build_data); self.fill_if_corner(room.x2, room.y1+1, build_data); self.fill_if_corner(room.x1+1, room.y2, build_data); self.fill_if_corner(room.x2, room.y2, build_data); build_data.take_snapshot(); } } } }
样板代码(重复代码)现在应该看起来很熟悉了,所以我们将专注于 build 中的算法:
- 我们获取房间列表,如果没有房间,则
panic!。 - 对于房间的 4 个角中的每一个,我们调用一个新函数
fill_if_corner。 fill_if_corner计算每个相邻的瓦片,以查看它是否是墙壁。 如果正好有 2 面墙,那么这个瓦片就有资格成为角——所以我们填充一面墙。
你可以使用以下 random_builder 代码尝试一下:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(BspDungeonBuilder::new()); builder.with(RoomCornerRounder::new()); builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); builder.with(CullUnreachable::new()); builder.with(VoronoiSpawning::new()); builder.with(DistantExit::new()); builder }
结果(如果你 cargo run 运行)应该类似于这样:
。
解耦房间和走廊
在 BSP 房间放置和“简单地图”房间放置之间存在相当多的共享代码 - 但走廊决策制定方式不同。 如果我们解耦各个阶段会怎样——让房间算法决定房间的去向,另一个算法绘制它们(可能会改变它们的绘制方式),第三个算法放置走廊? 我们改进的框架只需稍微调整一下算法即可支持这一点。
这是删除了走廊代码的 simple_map.rs:
#![allow(unused)] fn main() { use super::{InitialMapBuilder, BuilderMap, Rect, apply_room_to_map, apply_horizontal_tunnel, apply_vertical_tunnel }; use rltk::RandomNumberGenerator; pub struct SimpleMapBuilder {} impl InitialMapBuilder for SimpleMapBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build_rooms(rng, build_data); } } impl SimpleMapBuilder { #[allow(dead_code)] pub fn new() -> Box<SimpleMapBuilder> { Box::new(SimpleMapBuilder{}) } fn build_rooms(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { const MAX_ROOMS : i32 = 30; const MIN_SIZE : i32 = 6; const MAX_SIZE : i32 = 10; let mut rooms : Vec<Rect> = Vec::new(); for i in 0..MAX_ROOMS { let w = rng.range(MIN_SIZE, MAX_SIZE); let h = rng.range(MIN_SIZE, MAX_SIZE); let x = rng.roll_dice(1, build_data.map.width - w - 1) - 1; let y = rng.roll_dice(1, build_data.map.height - h - 1) - 1; let new_room = Rect::new(x, y, w, h); let mut ok = true; for other_room in rooms.iter() { if new_room.intersect(other_room) { ok = false } } if ok { apply_room_to_map(&mut build_data.map, &new_room); build_data.take_snapshot(); rooms.push(new_room); build_data.take_snapshot(); } } build_data.rooms = Some(rooms); } } }
除了将 rooms_and_corridors 重命名为 build_rooms 之外,唯一的更改是删除了放置走廊的掷骰子。
让我们创建一个新文件 map_builders/rooms_corridors_dogleg.rs。 这是我们放置走廊的地方。 现在,我们将使用刚刚从 SimpleMapBuilder 中删除的相同算法:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, Rect, apply_horizontal_tunnel, apply_vertical_tunnel }; use rltk::RandomNumberGenerator; pub struct DoglegCorridors {} impl MetaMapBuilder for DoglegCorridors { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.corridors(rng, build_data); } } impl DoglegCorridors { #[allow(dead_code)] pub fn new() -> Box<DoglegCorridors> { Box::new(DoglegCorridors{}) } fn corridors(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let rooms : Vec<Rect>; if let Some(rooms_builder) = &build_data.rooms { rooms = rooms_builder.clone(); } else { panic!("Dogleg Corridors require a builder with room structures"); } for (i,room) in rooms.iter().enumerate() { if i > 0 { let (new_x, new_y) = room.center(); let (prev_x, prev_y) = rooms[i as usize -1].center(); if rng.range(0,2) == 1 { apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, prev_y); apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, new_x); } else { apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, prev_x); apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, new_y); } build_data.take_snapshot(); } } } } }
再说一遍——这是我们刚刚删除的代码,但它自己被放置到一个新的构建器中。 所以真的没有什么新鲜的。 我们可以调整 random_builder 来测试这段代码:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(SimpleMapBuilder::new()); builder.with(DoglegCorridors::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStairs::new()); builder.with(RoomBasedStartingPosition::new()); builder }
用 cargo run 测试它应该会显示房间被构建,然后是走廊:
。
与 BSP 地牢再次相同
对我们的 BSPDungeonBuilder 执行相同的操作很容易。 在 bsp_dungeon.rs 中,我们也删除了走廊代码。 为了简洁起见,我们只包含 build 函数:
#![allow(unused)] fn main() { fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let mut rooms : Vec<Rect> = Vec::new(); self.rects.clear(); self.rects.push( Rect::new(2, 2, build_data.map.width-5, build_data.map.height-5) ); // 从单个地图大小的矩形开始 let first_room = self.rects[0]; self.add_subrects(first_room); // 分割第一个房间 // 最多 240 次,我们得到一个随机矩形并将其分割。 如果有可能在那里挤出一个 // 房间,我们放置它并将其添加到房间列表中。 let mut n_rooms = 0; while n_rooms < 240 { let rect = self.get_random_rect(rng); let candidate = self.get_random_sub_rect(rect, rng); if self.is_possible(candidate, &build_data.map) { apply_room_to_map(&mut build_data.map, &candidate); rooms.push(candidate); self.add_subrects(rect); build_data.take_snapshot(); } n_rooms += 1; } build_data.rooms = Some(rooms); } }
我们还将把我们的 BSP 走廊代码移动到一个新的构建器中,不带房间排序(我们将在下一个标题中讨论排序!)。 创建新文件 map_builders/rooms_corridors_bsp.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, Rect, draw_corridor }; use rltk::RandomNumberGenerator; pub struct BspCorridors {} impl MetaMapBuilder for BspCorridors { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.corridors(rng, build_data); } } impl BspCorridors { #[allow(dead_code)] pub fn new() -> Box<BspCorridors> { Box::new(BspCorridors{}) } fn corridors(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let rooms : Vec<Rect>; if let Some(rooms_builder) = &build_data.rooms { rooms = rooms_builder.clone(); } else { panic!("BSP Corridors require a builder with room structures"); } for i in 0..rooms.len()-1 { let room = rooms[i]; let next_room = rooms[i+1]; let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1); let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1); let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1); let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1); draw_corridor(&mut build_data.map, start_x, start_y, end_x, end_y); build_data.take_snapshot(); } } } }
同样,这 是 来自 BspDungeonBuilder 的走廊代码 - 只是适合其自身的构建器阶段。 你可以通过再次修改 random_builder 来证明它有效:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(BspDungeonBuilder::new()); builder.with(BspCorridors::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStairs::new()); builder.with(RoomBasedStartingPosition::new()); builder }
如果你 cargo run 运行它,你将看到类似这样的内容:
。
这 看起来 像是有效的 - 但如果你仔细注意,你就会明白为什么我们在原始算法中对房间进行排序:房间/走廊之间有很多重叠,并且走廊没有趋向于最短路径。 这是故意的 - 我们需要制作一个 RoomSorter 构建器,为我们提供更多地图构建选项。 让我们创建 map_builders/room_sorter.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap }; use rltk::RandomNumberGenerator; pub struct RoomSorter {} impl MetaMapBuilder for RoomSorter { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.sorter(rng, build_data); } } impl RoomSorter { #[allow(dead_code)] pub fn new() -> Box<RoomSorter> { Box::new(RoomSorter{}) } fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) ); } } }
这与我们之前使用的排序 完全 相同,我们可以通过将其插入到我们的构建器序列中来测试它:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(BspDungeonBuilder::new()); builder.with(RoomSorter::new()); builder.with(BspCorridors::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStairs::new()); builder.with(RoomBasedStartingPosition::new()); builder }
如果你 cargo run 运行它,你将看到类似这样的内容:
。
这样更好 - 我们已经恢复了 BSP 地牢构建器的外观和感觉!
更多房间排序选项
只有当我们打算提出一些不同的房间排序方式时,将排序器分解为自己的步骤才真正有用! 我们目前按最左边的条目排序 - 给出一个逐渐向东移动,但到处跳跃的地图。
让我们添加一个 enum 来为我们提供更多排序选项:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap }; use rltk::RandomNumberGenerator; pub enum RoomSort { LEFTMOST } pub struct RoomSorter { sort_by : RoomSort } impl MetaMapBuilder for RoomSorter { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.sorter(rng, build_data); } } impl RoomSorter { #[allow(dead_code)] pub fn new(sort_by : RoomSort) -> Box<RoomSorter> { Box::new(RoomSorter{ sort_by }) } fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { match self.sort_by { RoomSort::LEFTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) ) } } } }
很简单:我们将希望使用的排序算法存储在结构中,并在需要执行时 match 它。
让我们添加 RIGHTMOST ——它将简单地反转排序:
#![allow(unused)] fn main() { pub enum RoomSort { LEFTMOST, RIGHTMOST } ... fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { match self.sort_by { RoomSort::LEFTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) ), RoomSort::RIGHTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.x2.cmp(&a.x2) ) } } }
这太简单了,简直就像作弊! 让我们也添加 TOPMOST 和 BOTTOMMOST,以完成这种类型的排序:
#![allow(unused)] fn main() { #[allow(dead_code)] pub enum RoomSort { LEFTMOST, RIGHTMOST, TOPMOST, BOTTOMMOST } ... fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { match self.sort_by { RoomSort::LEFTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) ), RoomSort::RIGHTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.x2.cmp(&a.x2) ), RoomSort::TOPMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.y1.cmp(&b.y1) ), RoomSort::BOTTOMMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.y2.cmp(&a.y2) ) } } }
这是 BOTTOMMOST 的实际效果:
。
看看这如何在不真正改变结构的情况下改变地图的特性? 微小的调整能做到什么真是令人惊叹!
我们将添加另一个排序,CENTRAL。 这次,我们按到地图中心的距离排序:
#![allow(unused)] fn main() { #[allow(dead_code)] pub enum RoomSort { LEFTMOST, RIGHTMOST, TOPMOST, BOTTOMMOST, CENTRAL } ... fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { match self.sort_by { RoomSort::LEFTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) ), RoomSort::RIGHTMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.x2.cmp(&a.x2) ), RoomSort::TOPMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.y1.cmp(&b.y1) ), RoomSort::BOTTOMMOST => build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.y2.cmp(&a.y2) ), RoomSort::CENTRAL => { let map_center = rltk::Point::new( build_data.map.width / 2, build_data.map.height / 2 ); let center_sort = |a : &Rect, b : &Rect| { let a_center = a.center(); let a_center_pt = rltk::Point::new(a_center.0, a_center.1); let b_center = b.center(); let b_center_pt = rltk::Point::new(b_center.0, b_center.1); let distance_a = rltk::DistanceAlg::Pythagoras.distance2d(a_center_pt, map_center); let distance_b = rltk::DistanceAlg::Pythagoras.distance2d(b_center_pt, map_center); distance_a.partial_cmp(&distance_b).unwrap() }; build_data.rooms.as_mut().unwrap().sort_by(center_sort); } } } }
你可以修改你的 random_builder 函数来使用它:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(BspDungeonBuilder::new()); builder.with(RoomSorter::new(RoomSort::CENTRAL)); builder.with(BspCorridors::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStairs::new()); builder.with(RoomBasedStartingPosition::new()); builder }
cargo run 将给你类似这样的内容:
。
请注意,现在所有道路都通向中间 - 形成一个非常连通的地图!
清理我们的随机构建器
既然我们即将结束本节(尚未结束!),让我们花时间真正利用我们到目前为止构建的内容。 我们将完全重组我们选择随机构建模式的方式。
现在,基于房间的生成不像以前那样令人尴尬地可预测了。 因此,让我们创建一个函数来公开我们到目前为止构建的所有房间种类:
#![allow(unused)] fn main() { fn random_room_builder(rng: &mut rltk::RandomNumberGenerator, builder : &mut BuilderChain) { let build_roll = rng.roll_dice(1, 3); match build_roll { 1 => builder.start_with(SimpleMapBuilder::new()), 2 => builder.start_with(BspDungeonBuilder::new()), _ => builder.start_with(BspInteriorBuilder::new()) } // BSP Interior 仍然会在墙壁上打洞 if build_roll != 3 { // 按 5 种可用算法之一排序 let sort_roll = rng.roll_dice(1, 5); match sort_roll { 1 => builder.with(RoomSorter::new(RoomSort::LEFTMOST)), 2 => builder.with(RoomSorter::new(RoomSort::RIGHTMOST)), 3 => builder.with(RoomSorter::new(RoomSort::TOPMOST)), 4 => builder.with(RoomSorter::new(RoomSort::BOTTOMMOST)), _ => builder.with(RoomSorter::new(RoomSort::CENTRAL)), } let corridor_roll = rng.roll_dice(1, 2); match corridor_roll { 1 => builder.with(DoglegCorridors::new()), _ => builder.with(BspCorridors::new()) } let modifier_roll = rng.roll_dice(1, 6); match modifier_roll { 1 => builder.with(RoomExploder::new()), 2 => builder.with(RoomCornerRounder::new()), _ => {} } } let start_roll = rng.roll_dice(1, 2); match start_roll { 1 => builder.with(RoomBasedStartingPosition::new()), _ => { let (start_x, start_y) = random_start_position(rng); builder.with(AreaStartingPosition::new(start_x, start_y)); } } let exit_roll = rng.roll_dice(1, 2); match exit_roll { 1 => builder.with(RoomBasedStairs::new()), _ => builder.with(DistantExit::new()) } let spawn_roll = rng.roll_dice(1, 2); match spawn_roll { 1 => builder.with(RoomBasedSpawner::new()), _ => builder.with(VoronoiSpawning::new()) } } }
这是一个很大的函数,所以我们将逐步介绍它。 它非常简单,只是真的散开并且充满了分支:
- 我们掷 1d3,并从 BSP Interior、Simple 和 BSP Dungeon 地图构建器中选择。
- 如果我们没有选择 BSP Interior(它自己做了很多事情),我们:
- 随机选择一个房间排序算法。
- 随机选择我们现在拥有的两个走廊算法之一。
- 随机选择(或忽略)房间炸毁器或圆角器。
- 我们在基于房间的起始位置和基于区域的起始位置之间随机选择。 对于后者,调用
random_start_position在 3 个 X 轴和 3 个 Y 轴起始位置之间进行选择以支持。 - 我们在基于房间的楼梯放置和“离起点最远”的出口之间随机选择。
- 我们在 Voronoi 区域生成和基于房间的生成之间随机选择。
所以这个函数完全是关于掷骰子和制作地图! 即使忽略了每个起始构建器可能产生的数千种布局,它也有很多组合。 有:
2 <带选项的起始房间> * 5 <排序 > * 2 <走廊 > * 3 <修改器 > = 60 个基本房间选项。
+1 用于 BSP 内部地牢 = 61 个房间选项。
*2 <起始位置选项 > = 122 个房间选项。
*2 <出口位置 > = 244 个房间选项。
*2 <生成选项 > = 488 个房间选项!
所以这个函数提供了 488 种可能的构建器组合!。
现在我们将为非房间生成器创建一个函数:
#![allow(unused)] fn main() { fn random_shape_builder(rng: &mut rltk::RandomNumberGenerator, builder : &mut BuilderChain) { let builder_roll = rng.roll_dice(1, 16); match builder_roll { 1 => builder.start_with(CellularAutomataBuilder::new()), 2 => builder.start_with(DrunkardsWalkBuilder::open_area()), 3 => builder.start_with(DrunkardsWalkBuilder::open_halls()), 4 => builder.start_with(DrunkardsWalkBuilder::winding_passages()), 5 => builder.start_with(DrunkardsWalkBuilder::fat_passages()), 6 => builder.start_with(DrunkardsWalkBuilder::fearful_symmetry()), 7 => builder.start_with(MazeBuilder::new()), 8 => builder.start_with(DLABuilder::walk_inwards()), 9 => builder.start_with(DLABuilder::walk_outwards()), 10 => builder.start_with(DLABuilder::central_attractor()), 11 => builder.start_with(DLABuilder::insectoid()), 12 => builder.start_with(VoronoiCellBuilder::pythagoras()), 13 => builder.start_with(VoronoiCellBuilder::manhattan()), _ => builder.start_with(PrefabBuilder::constant(prefab_builder::prefab_levels::WFC_POPULATED)), } // 将起点设置为中心并剔除 builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); builder.with(CullUnreachable::new()); // 现在将起点设置为随机起始区域 let (start_x, start_y) = random_start_position(rng); builder.with(AreaStartingPosition::new(start_x, start_y)); // 设置出口并生成怪物 builder.with(VoronoiSpawning::new()); builder.with(DistantExit::new()); } }
这与我们之前所做的类似,但有一个转折:我们现在将玩家放置在中心,剔除无法到达的区域,然后将玩家放置在随机位置。 生成地图的中间很可能非常连通 - 因此这消除了死角空间,并最大限度地减少了从“孤立”部分开始并将地图剔除到仅剩几个瓦片的可能性。
这也提供了很多组合,但没有那么多。
14 个基本房间选项
*1 生成选项
*1 出口选项
*6 起始选项
= 84 个选项。
所以这个函数提供了 84 种房间构建器组合。
最后,我们将所有内容整合到 random_builder 中:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain { let mut builder = BuilderChain::new(new_depth); let type_roll = rng.roll_dice(1, 2); match type_roll { 1 => random_room_builder(rng, &mut builder), _ => random_shape_builder(rng, &mut builder) } if rng.roll_dice(1, 3)==1 { builder.with(WaveformCollapseBuilder::new()); } if rng.roll_dice(1, 20)==1 { builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT)); } builder.with(PrefabBuilder::vaults()); builder } }
这相对简单明了。 我们随机选择一个房间或一个形状构建器,如上定义。 有 1/3 的几率我们会然后在它上面运行 Wave Function Collapse,以及 1/20 的几率我们会向它添加一个 sectional。 最后,我们尝试生成我们可能想要使用的任何 vault。
那么我们的总组合爆炸看起来如何? 在这一点上还不错:
488 种可能的房间构建器 +
84 种可能的形状构建器 =
572 种构建器组合。
我们可能会运行 Wave Function Collapse,再给出 2 个选项:
*2 = 1,144
我们可能会添加一个 sectional:
*2 = 2,288
所以我们现在有 2,288 种可能的构建器组合,仅来自最近的几章。 将其与随机种子结合起来,玩家在一次运行中看到完全相同的地图组合的可能性越来越小。
...
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)
版权 (C) 2019, Herbert Wolverson。
改进的房间构建
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在上一章中,我们抽象出了房间的布局 - 但保持了房间的实际位置不变:它们始终是矩形,尽管可以通过房间炸裂和圆角来缓解这种情况。本章将增加使用不同形状房间的能力。
矩形房间构建器
首先,我们将创建一个构建器,它接受一组房间作为输入,并将这些房间作为地图上的矩形输出 - 与之前的版本完全一样。我们还将修改 SimpleMapBuilder 和 BspDungeonBuilder 以避免重复功能。
我们将创建一个新文件 map_builders/room_draw.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, TileType, Rect}; use rltk::RandomNumberGenerator; pub struct RoomDrawer {} impl MetaMapBuilder for RoomDrawer { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl RoomDrawer { #[allow(dead_code)] pub fn new() -> Box<RoomDrawer> { Box::new(RoomDrawer{}) } fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let rooms : Vec<Rect>; if let Some(rooms_builder) = &build_data.rooms { rooms = rooms_builder.clone(); } else { panic!("Room Rounding require a builder with room structures"); } for room in rooms.iter() { for y in room.y1 +1 ..= room.y2 { for x in room.x1 + 1 ..= room.x2 { let idx = build_data.map.xy_idx(x, y); if idx > 0 && idx < ((build_data.map.width * build_data.map.height)-1) as usize { build_data.map.tiles[idx] = TileType::Floor; } } } build_data.take_snapshot(); } } } }
这是在 common.rs 的 apply_room_to_map 中找到的相同绘制功能 - 包装在我们前几章中使用的相同的元构建器 (meta-builder) 功能中。这里没有什么太令人惊讶的!
在 bsp_dungeon.rs 中,只需删除引用 apply_room_to_map 的行。您也可以删除 take_snapshot - 因为我们尚未将任何内容应用到地图:
#![allow(unused)] fn main() { if self.is_possible(candidate, &build_data.map, &rooms) { rooms.push(candidate); self.add_subrects(rect); } }
我们还需要更新 is_possible 以检查房间列表,而不是读取实时地图(我们尚未向其中写入任何内容):
#![allow(unused)] fn main() { fn is_possible(&self, rect : Rect, build_data : &BuilderMap, rooms: &Vec<Rect>) -> bool { let mut expanded = rect; expanded.x1 -= 2; expanded.x2 += 2; expanded.y1 -= 2; expanded.y2 += 2; let mut can_build = true; for r in rooms.iter() { if r.intersect(&rect) { can_build = false; } } for y in expanded.y1 ..= expanded.y2 { for x in expanded.x1 ..= expanded.x2 { if x > build_data.map.width-2 { can_build = false; } if y > build_data.map.height-2 { can_build = false; } if x < 1 { can_build = false; } if y < 1 { can_build = false; } if can_build { let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] != TileType::Wall { can_build = false; } } } } can_build } }
同样地,在 simple_map.rs 中 - 只需删除 apply_room_to_map 和 take_snapshot 调用:
#![allow(unused)] fn main() { if ok { rooms.push(new_room); } }
common.rs 中没有任何地方再使用 apply_room_to_map 了 - 所以我们也可以删除它!
最后,修改 map_builders/mod.rs 中的 random_builder 来测试我们的代码:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain { /*let mut builder = BuilderChain::new(new_depth); let type_roll = rng.roll_dice(1, 2); match type_roll { 1 => random_room_builder(rng, &mut builder), _ => random_shape_builder(rng, &mut builder) } if rng.roll_dice(1, 3)==1 { builder.with(WaveformCollapseBuilder::new()); } if rng.roll_dice(1, 20)==1 { builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT)); } builder.with(PrefabBuilder::vaults()); builder*/ let mut builder = BuilderChain::new(new_depth); builder.start_with(SimpleMapBuilder::new()); builder.with(RoomDrawer::new()); builder.with(RoomSorter::new(RoomSort::LEFTMOST)); builder.with(BspCorridors::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStairs::new()); builder.with(RoomBasedStartingPosition::new()); builder } }
如果您 cargo run 项目,您将看到我们的简单地图构建器运行 - 就像以前一样。
圆形房间
简单地将绘制代码移出算法可以使事情更清晰,但并没有为我们带来任何新的东西。因此,我们将研究为房间添加一些形状选项。我们将首先将绘制代码移出主循环并放入其自己的函数中。修改 room_draw.rs 如下:
#![allow(unused)] fn main() { fn rectangle(&mut self, build_data : &mut BuilderMap, room : &Rect) { for y in room.y1 +1 ..= room.y2 { for x in room.x1 + 1 ..= room.x2 { let idx = build_data.map.xy_idx(x, y); if idx > 0 && idx < ((build_data.map.width * build_data.map.height)-1) as usize { build_data.map.tiles[idx] = TileType::Floor; } } } } fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let rooms : Vec<Rect>; if let Some(rooms_builder) = &build_data.rooms { rooms = rooms_builder.clone(); } else { panic!("Room Drawing require a builder with room structures"); } for room in rooms.iter() { self.rectangle(build_data, room); build_data.take_snapshot(); } } }
再一次,如果您想测试它 - cargo run 将给您与上次类似的结果。 让我们添加第二个房间形状 - 圆形房间:
#![allow(unused)] fn main() { fn circle(&mut self, build_data : &mut BuilderMap, room : &Rect) { let radius = i32::min(room.x2 - room.x1, room.y2 - room.y1) as f32 / 2.0; let center = room.center(); let center_pt = rltk::Point::new(center.0, center.1); for y in room.y1 ..= room.y2 { for x in room.x1 ..= room.x2 { let idx = build_data.map.xy_idx(x, y); let distance = rltk::DistanceAlg::Pythagoras.distance2d(center_pt, rltk::Point::new(x, y)); if idx > 0 && idx < ((build_data.map.width * build_data.map.height)-1) as usize && distance <= radius { build_data.map.tiles[idx] = TileType::Floor; } } } } }
现在将您对 rectangle 的调用替换为 circle,输入 cargo run 并享受新的房间类型:
.
随机选择形状
如果圆形房间成为一种偶尔出现的特性就好了。因此,我们将修改我们的 build 函数,使大约四分之一的房间是圆形的:
#![allow(unused)] fn main() { fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let rooms : Vec<Rect>; if let Some(rooms_builder) = &build_data.rooms { rooms = rooms_builder.clone(); } else { panic!("Room Drawing require a builder with room structures"); } for room in rooms.iter() { let room_type = rng.roll_dice(1,4); match room_type { 1 => self.circle(build_data, room), _ => self.rectangle(build_data, room) } build_data.take_snapshot(); } } }
如果您现在 cargo run 项目,您将看到类似这样的内容:
.
恢复随机性
在 map_builders/mod.rs 中,取消注释代码并删除测试工具:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain { let mut builder = BuilderChain::new(new_depth); let type_roll = rng.roll_dice(1, 2); match type_roll { 1 => random_room_builder(rng, &mut builder), _ => random_shape_builder(rng, &mut builder) } if rng.roll_dice(1, 3)==1 { builder.with(WaveformCollapseBuilder::new()); } if rng.roll_dice(1, 20)==1 { builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT)); } builder.with(PrefabBuilder::vaults()); builder } }
在 random_room_builder 中,我们添加房间绘制:
#![allow(unused)] fn main() { ... let sort_roll = rng.roll_dice(1, 5); match sort_roll { 1 => builder.with(RoomSorter::new(RoomSort::LEFTMOST)), 2 => builder.with(RoomSorter::new(RoomSort::RIGHTMOST)), 3 => builder.with(RoomSorter::new(RoomSort::TOPMOST)), 4 => builder.with(RoomSorter::new(RoomSort::BOTTOMMOST)), _ => builder.with(RoomSorter::new(RoomSort::CENTRAL)), } builder.with(RoomDrawer::new()); let corridor_roll = rng.roll_dice(1, 2); match corridor_roll { 1 => builder.with(DoglegCorridors::new()), _ => builder.with(BspCorridors::new()) } ... }
您现在可以获得完整的随机房间创建 - 但偶尔会有圆形房间而不是矩形房间。这为组合增加了一些多样性。
...
本章的源代码可以在这里找到
使用 WebAssembly 在您的浏览器中运行本章的示例 (需要 WebGL2)
版权 (C) 2019, Herbert Wolverson。
改进的走廊
关于本教程
本教程是免费和开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
到目前为止,我们的走廊生成方式相当原始,存在重叠现象 - 除非您使用 Voronoi 生成,否则走廊里什么都没有。本章将尝试提供更多生成策略(反过来提供更多地图多样性),并允许走廊包含实体。
新的走廊策略:最近邻
使地图感觉更自然的一种方法是在近邻之间构建走廊。这减少(但不能消除)重叠,并且看起来更像是某人可能实际 建造 的东西。我们将创建一个新文件 map_builders/rooms_corridors_nearest.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, Rect, draw_corridor }; use rltk::RandomNumberGenerator; use std::collections::HashSet; pub struct NearestCorridors {} impl MetaMapBuilder for NearestCorridors { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.corridors(rng, build_data); } } impl NearestCorridors { #[allow(dead_code)] pub fn new() -> Box<NearestCorridors> { Box::new(NearestCorridors{}) } fn corridors(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let rooms : Vec<Rect>; if let Some(rooms_builder) = &build_data.rooms { rooms = rooms_builder.clone(); } else { panic!("Nearest Corridors require a builder with room structures"); } let mut connected : HashSet<usize> = HashSet::new(); for (i,room) in rooms.iter().enumerate() { let mut room_distance : Vec<(usize, f32)> = Vec::new(); let room_center = room.center(); let room_center_pt = rltk::Point::new(room_center.0, room_center.1); for (j,other_room) in rooms.iter().enumerate() { if i != j && !connected.contains(&j) { let other_center = other_room.center(); let other_center_pt = rltk::Point::new(other_center.0, other_center.1); let distance = rltk::DistanceAlg::Pythagoras.distance2d( room_center_pt, other_center_pt ); room_distance.push((j, distance)); } } if !room_distance.is_empty() { room_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap() ); let dest_center = rooms[room_distance[0].0].center(); draw_corridor( &mut build_data.map, room_center.0, room_center.1, dest_center.0, dest_center.1 ); connected.insert(i); build_data.take_snapshot(); } } } } }
这里有一些您现在应该熟悉的样板代码,所以让我们过一遍 corridors 函数:
- 我们首先获取
rooms列表,如果没有则panic!。 - 我们创建一个名为
connected的新的HashSet。当房间获得出口时,我们会将房间添加到其中,以避免重复连接到同一个房间。 - 对于每个房间,我们检索一个名为
i的“枚举”(向量中的索引号)和room:- 我们创建一个名为
room_distance的新向量。它存储包含正在考虑的房间的索引和浮点数的元组,该浮点数将存储其到当前房间的距离。 - 我们计算房间的中心,并将其存储在 RLTK 的
Point中(为了与距离算法兼容)。 - 对于每个房间,我们检索一个名为
j的枚举(习惯上对计数器使用i和j,大概可以追溯到变量名较长很昂贵的日子!),以及other_room。- 如果
i和j相等,我们正在查看通往/来自同一房间的走廊。我们不想这样做,所以我们跳过它! - 同样,如果
other_room的索引 (j) 在我们的connected集合中,那么我们也不想评估它 - 所以我们跳过它。 - 我们计算从外部房间 (
room/i) 到我们正在评估的房间 (other_room/j) 的距离。 - 我们将距离和
j索引推送到room_distance中。
- 如果
- 如果
room_distance的列表为空,我们向前跳过。否则: - 我们使用
sort_by对room_distance向量进行排序,最短距离的排在最前面。 - 然后我们使用
draw_corridor函数从当前room的中心绘制到最近的房间(room_distance中索引为0的房间)的走廊。
- 我们创建一个名为
最后,我们将修改 map_builders/mod.rs 中的 random_builder 以使用此算法:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain { /* let mut builder = BuilderChain::new(new_depth); let type_roll = rng.roll_dice(1, 2); match type_roll { 1 => random_room_builder(rng, &mut builder), _ => random_shape_builder(rng, &mut builder) } if rng.roll_dice(1, 3)==1 { builder.with(WaveformCollapseBuilder::new()); } if rng.roll_dice(1, 20)==1 { builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT)); } builder.with(PrefabBuilder::vaults()); builder*/ let mut builder = BuilderChain::new(new_depth); builder.start_with(SimpleMapBuilder::new()); builder.with(RoomDrawer::new()); builder.with(RoomSorter::new(RoomSort::LEFTMOST)); builder.with(NearestCorridors::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStairs::new()); builder.with(RoomBasedStartingPosition::new()); builder } }
这提供了连接良好的地图,走廊距离合理地短。如果您 cargo run 该项目,您应该看到类似这样的内容:
.
走廊重叠 仍然 可能发生,但现在已经不太可能了。
带有 Bresenham 线段的走廊
我们可以将走廊绘制为直线,而不是绕过角落呈折线形。这对玩家来说导航起来有点麻烦(需要导航更多角落),但可以产生令人愉悦的效果。我们将创建一个新文件 map_builders/rooms_corridors_lines.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, Rect, TileType }; use rltk::RandomNumberGenerator; use std::collections::HashSet; pub struct StraightLineCorridors {} impl MetaMapBuilder for StraightLineCorridors { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.corridors(rng, build_data); } } impl StraightLineCorridors { #[allow(dead_code)] pub fn new() -> Box<StraightLineCorridors> { Box::new(StraightLineCorridors{}) } fn corridors(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let rooms : Vec<Rect>; if let Some(rooms_builder) = &build_data.rooms { rooms = rooms_builder.clone(); } else { panic!("Straight Line Corridors require a builder with room structures"); } let mut connected : HashSet<usize> = HashSet::new(); for (i,room) in rooms.iter().enumerate() { let mut room_distance : Vec<(usize, f32)> = Vec::new(); let room_center = room.center(); let room_center_pt = rltk::Point::new(room_center.0, room_center.1); for (j,other_room) in rooms.iter().enumerate() { if i != j && !connected.contains(&j) { let other_center = other_room.center(); let other_center_pt = rltk::Point::new(other_center.0, other_center.1); let distance = rltk::DistanceAlg::Pythagoras.distance2d( room_center_pt, other_center_pt ); room_distance.push((j, distance)); } } if !room_distance.is_empty() { room_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap() ); let dest_center = rooms[room_distance[0].0].center(); let line = rltk::line2d( rltk::LineAlg::Bresenham, room_center_pt, rltk::Point::new(dest_center.0, dest_center.1) ); for cell in line.iter() { let idx = build_data.map.xy_idx(cell.x, cell.y); build_data.map.tiles[idx] = TileType::Floor; } connected.insert(i); build_data.take_snapshot(); } } } } }
这与前一个几乎相同,但是我们没有调用 draw_corridor,而是使用 RLTK 的线条函数来绘制从源房间和目标房间中心点的直线。然后,我们将沿着该线段的每个瓦片标记为地板。如果您修改您的 random_builder 以使用此方法:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(SimpleMapBuilder::new()); builder.with(RoomDrawer::new()); builder.with(RoomSorter::new(RoomSort::LEFTMOST)); builder.with(StraightLineCorridors::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStairs::new()); builder.with(RoomBasedStartingPosition::new()); builder }
然后 cargo run 您的项目,您将看到类似这样的内容:
.
存储走廊位置
我们将来可能想对我们的走廊位置做些什么,所以让我们存储它们。在 map_builders/mod.rs 中,让我们添加一个容器来存储我们的走廊位置。我们将使其成为一个 Option,以便保持与不使用此概念的地图类型的兼容性:
#![allow(unused)] fn main() { pub struct BuilderMap { pub spawn_list : Vec<(usize, String)>, pub map : Map, pub starting_position : Option<Position>, pub rooms: Option<Vec<Rect>>, pub corridors: Option<Vec<Vec<usize>>>, pub history : Vec<Map> } }
我们还需要调整构造函数以确保不会忘记 corridors:
#![allow(unused)] fn main() { impl BuilderChain { pub fn new(new_depth : i32) -> BuilderChain { BuilderChain{ starter: None, builders: Vec::new(), build_data : BuilderMap { spawn_list: Vec::new(), map: Map::new(new_depth), starting_position: None, rooms: None, corridors: None, history : Vec::new() } } } ... }
现在在 common.rs 中,让我们修改我们的走廊函数以返回走廊放置信息:
#![allow(unused)] fn main() { pub fn apply_horizontal_tunnel(map : &mut Map, x1:i32, x2:i32, y:i32) -> Vec<usize> { let mut corridor = Vec::new(); for x in min(x1,x2) ..= max(x1,x2) { let idx = map.xy_idx(x, y); if idx > 0 && idx < map.width as usize * map.height as usize && map.tiles[idx as usize] != TileType::Floor { map.tiles[idx as usize] = TileType::Floor; corridor.push(idx as usize); } } corridor } pub fn apply_vertical_tunnel(map : &mut Map, y1:i32, y2:i32, x:i32) -> Vec<usize> { let mut corridor = Vec::new(); for y in min(y1,y2) ..= max(y1,y2) { let idx = map.xy_idx(x, y); if idx > 0 && idx < map.width as usize * map.height as usize && map.tiles[idx as usize] != TileType::Floor { corridor.push(idx); map.tiles[idx as usize] = TileType::Floor; } } corridor } pub fn draw_corridor(map: &mut Map, x1:i32, y1:i32, x2:i32, y2:i32) -> Vec<usize> { let mut corridor = Vec::new(); let mut x = x1; let mut y = y1; while x != x2 || y != y2 { if x < x2 { x += 1; } else if x > x2 { x -= 1; } else if y < y2 { y += 1; } else if y > y2 { y -= 1; } let idx = map.xy_idx(x, y); if map.tiles[idx] != TileType::Floor { corridor.push(idx); map.tiles[idx] = TileType::Floor; } } corridor } }
请注意,它们基本上没有改变,但现在返回一个瓦片索引向量 - 并且仅在被修改的瓦片是地板时才添加到其中? 这将为我们提供走廊每一段的定义。现在我们需要修改走廊绘制算法以存储此信息。在 rooms_corridors_bsp.rs 中,修改 corridors 函数以执行此操作:
#![allow(unused)] fn main() { ... let mut corridors : Vec<Vec<usize>> = Vec::new(); for i in 0..rooms.len()-1 { let room = rooms[i]; let next_room = rooms[i+1]; let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1); let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1); let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1); let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1); let corridor = draw_corridor(&mut build_data.map, start_x, start_y, end_x, end_y); corridors.push(corridor); build_data.take_snapshot(); } build_data.corridors = Some(corridors); ... }
我们在 rooms_corridors_dogleg.rs 中再次执行相同的操作:
#![allow(unused)] fn main() { ... let mut corridors : Vec<Vec<usize>> = Vec::new(); for (i,room) in rooms.iter().enumerate() { if i > 0 { let (new_x, new_y) = room.center(); let (prev_x, prev_y) = rooms[i as usize -1].center(); if rng.range(0,2) == 1 { let mut c1 = apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, prev_y); let mut c2 = apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, new_x); c1.append(&mut c2); corridors.push(c1); } else { let mut c1 = apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, prev_x); let mut c2 = apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, new_y); c1.append(&mut c2); corridors.push(c1); } build_data.take_snapshot(); } } build_data.corridors = Some(corridors); ... }
您会注意到我们将走廊的第二段附加到第一段,因此我们将其视为一个长走廊,而不是两条走廊。我们需要对我们新创建的 rooms_corridors_lines.rs 应用相同的更改:
#![allow(unused)] fn main() { fn corridors(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let rooms : Vec<Rect>; if let Some(rooms_builder) = &build_data.rooms { rooms = rooms_builder.clone(); } else { panic!("Straight Line Corridors require a builder with room structures"); } let mut connected : HashSet<usize> = HashSet::new(); let mut corridors : Vec<Vec<usize>> = Vec::new(); for (i,room) in rooms.iter().enumerate() { let mut room_distance : Vec<(usize, f32)> = Vec::new(); let room_center = room.center(); let room_center_pt = rltk::Point::new(room_center.0, room_center.1); for (j,other_room) in rooms.iter().enumerate() { if i != j && !connected.contains(&j) { let other_center = other_room.center(); let other_center_pt = rltk::Point::new(other_center.0, other_center.1); let distance = rltk::DistanceAlg::Pythagoras.distance2d( room_center_pt, other_center_pt ); room_distance.push((j, distance)); } } if !room_distance.is_empty() { room_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap() ); let dest_center = rooms[room_distance[0].0].center(); let line = rltk::line2d( rltk::LineAlg::Bresenham, room_center_pt, rltk::Point::new(dest_center.0, dest_center.1) ); let mut corridor = Vec::new(); for cell in line.iter() { let idx = build_data.map.xy_idx(cell.x, cell.y); if build_data.map.tiles[idx] != TileType::Floor { build_data.map.tiles[idx] = TileType::Floor; corridor.push(idx); } } corridors.push(corridor); connected.insert(i); build_data.take_snapshot(); } } build_data.corridors = Some(corridors); } }
我们也会在 rooms_corridors_nearest.rs 中做同样的事情:
#![allow(unused)] fn main() { fn corridors(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let rooms : Vec<Rect>; if let Some(rooms_builder) = &build_data.rooms { rooms = rooms_builder.clone(); } else { panic!("Nearest Corridors require a builder with room structures"); } let mut connected : HashSet<usize> = HashSet::new(); let mut corridors : Vec<Vec<usize>> = Vec::new(); for (i,room) in rooms.iter().enumerate() { let mut room_distance : Vec<(usize, f32)> = Vec::new(); let room_center = room.center(); let room_center_pt = rltk::Point::new(room_center.0, room_center.1); for (j,other_room) in rooms.iter().enumerate() { if i != j && !connected.contains(&j) { let other_center = other_room.center(); let other_center_pt = rltk::Point::new(other_center.0, other_center.1); let distance = rltk::DistanceAlg::Pythagoras.distance2d( room_center_pt, other_center_pt ); room_distance.push((j, distance)); } } if !room_distance.is_empty() { room_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap() ); let dest_center = rooms[room_distance[0].0].center(); let corridor = draw_corridor( &mut build_data.map, room_center.0, room_center.1, dest_center.0, dest_center.1 ); connected.insert(i); build_data.take_snapshot(); corridors.push(corridor); } } build_data.corridors = Some(corridors); } }
好的,我们有了走廊数据 - 然后呢?
一个明显的用途是在走廊内生成实体。我们将创建新的 room_corridor_spawner.rs 来做到这一点:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, spawner}; use rltk::RandomNumberGenerator; pub struct CorridorSpawner {} impl MetaMapBuilder for CorridorSpawner { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl CorridorSpawner { #[allow(dead_code)] pub fn new() -> Box<CorridorSpawner> { Box::new(CorridorSpawner{}) } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { if let Some(corridors) = &build_data.corridors { for c in corridors.iter() { let depth = build_data.map.depth; spawner::spawn_region(&build_data.map, rng, &c, depth, &mut build_data.spawn_list); } } else { panic!("Corridor Based Spawning only works after corridors have been created"); } } } }
这是基于 room_based_spawner.rs 的 - 复制/粘贴并更改了名称!然后,rooms 的 if let 被替换为 corridors,并且不是每个房间都生成 - 我们将走廊传递给 spawn_region。实体现在在走廊中生成。
您可以通过将生成器添加到您的 random_builder 来测试这一点:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(SimpleMapBuilder::new()); builder.with(RoomDrawer::new()); builder.with(RoomSorter::new(RoomSort::LEFTMOST)); builder.with(StraightLineCorridors::new()); builder.with(RoomBasedSpawner::new()); builder.with(CorridorSpawner::new()); builder.with(RoomBasedStairs::new()); builder.with(RoomBasedStartingPosition::new()); builder }
一旦您开始玩游戏,您现在可以在走廊内找到实体:
.
恢复随机性
再一次,这是一个小节的结尾 - 所以我们将再次使 random_builder 随机化,但利用我们的新内容!
首先取消注释 random_builder 中的代码,并删除测试工具:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain { let mut builder = BuilderChain::new(new_depth); let type_roll = rng.roll_dice(1, 2); match type_roll { 1 => random_room_builder(rng, &mut builder), _ => random_shape_builder(rng, &mut builder) } if rng.roll_dice(1, 3)==1 { builder.with(WaveformCollapseBuilder::new()); } if rng.roll_dice(1, 20)==1 { builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT)); } builder.with(PrefabBuilder::vaults()); builder } }
由于我们在这里所做的一切都是基于 房间 的,我们还将修改 random_room_builder 以包含它。我们将扩展与走廊相关的部分:
#![allow(unused)] fn main() { let corridor_roll = rng.roll_dice(1, 4); match corridor_roll { 1 => builder.with(DoglegCorridors::new()), 2 => builder.with(NearestCorridors::new()), 3 => builder.with(StraightLineCorridors::new()), _ => builder.with(BspCorridors::new()) } let cspawn_roll = rng.roll_dice(1, 2); if cspawn_roll == 1 { builder.with(CorridorSpawner::new()); } }
所以我们添加了直线走廊和最近邻走廊的相等机会,并且 50% 的时间它将在走廊中生成实体。
...
本章的源代码可以在这里找到
使用 WebAssembly 在您的浏览器中运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
门
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
门和角落,那是他们抓住你的地方。 如果我们要让米勒(来自《太空无垠》- 可能是目前我最喜欢的科幻小说系列)的警告成真 - 在游戏中有门将是个好主意。 门是地下城探索的必备元素! 我们等待了这么久才实现它们,是为了确保我们有放置它们的合适位置。
门也是实体
我们将从简单的装饰性门开始,这些门根本不做任何事情。 这将使我们能够适当地放置它们,然后我们可以实现一些与门相关的功能。 距离我们上次添加实体类型已经有一段时间了; 幸运的是,我们现有的 components 中拥有装饰性门所需的一切。 打开 spawner.rs,并重新熟悉它! 然后我们将添加一个门生成器函数:
#![allow(unused)] fn main() { fn door(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('+'), fg: RGB::named(rltk::CHOCOLATE), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Door".to_string() }) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
所以我们仅用于装饰的门非常简单:它有一个字形(glyph)(在许多 roguelike 游戏中,+ 是传统的),是棕色的,并且具有 Name 和 Position。 这就是让它们出现在地图上所需的全部! 我们还将修改 spawn_entity 以了解在给定要生成的 Door 时该怎么做:
#![allow(unused)] fn main() { match spawn.1.as_ref() { "Goblin" => goblin(ecs, x, y), "Orc" => orc(ecs, x, y), "Health Potion" => health_potion(ecs, x, y), "Fireball Scroll" => fireball_scroll(ecs, x, y), "Confusion Scroll" => confusion_scroll(ecs, x, y), "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y), "Dagger" => dagger(ecs, x, y), "Shield" => shield(ecs, x, y), "Longsword" => longsword(ecs, x, y), "Tower Shield" => tower_shield(ecs, x, y), "Rations" => rations(ecs, x, y), "Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y), "Bear Trap" => bear_trap(ecs, x, y), "Door" => door(ecs, x, y), _ => {} } }
我们不会将门添加到生成表(spawn tables)中; 让它们随机出现在房间里是没有意义的!
放置门
我们将创建一个新的 builder(毕竟我们仍然在地图部分!)它可以放置门。 因此,在 map_builders 中,创建一个新文件:door_placement.rs:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap }; use rltk::RandomNumberGenerator; pub struct DoorPlacement {} impl MetaMapBuilder for DoorPlacement { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.doors(rng, build_data); } } impl DoorPlacement { #[allow(dead_code)] pub fn new() -> Box<DoorPlacement> { Box::new(DoorPlacement{ }) } fn doors(&mut self, _rng : &mut RandomNumberGenerator, _build_data : &mut BuilderMap) { } } }
这是一个元 builder 的空骨架。 让我们首先处理最简单的情况:当我们有走廊数据时,它提供了一个门可能适合的位置的蓝图。 我们将从一个新函数 door_possible 开始:
#![allow(unused)] fn main() { fn door_possible(&self, build_data : &mut BuilderMap, idx : usize) -> bool { let x = idx % build_data.map.width as usize; let y = idx / build_data.map.width as usize; // 检查东西方向门的可能性 if build_data.map.tiles[idx] == TileType::Floor && (x > 1 && build_data.map.tiles[idx-1] == TileType::Floor) && (x < build_data.map.width-2 && build_data.map.tiles[idx+1] == TileType::Floor) && (y > 1 && build_data.map.tiles[idx - build_data.map.width as usize] == TileType::Wall) && (y < build_data.map.height-2 && build_data.map.tiles[idx + build_data.map.width as usize] == TileType::Wall) { return true; } // 检查南北方向门的可能性 if build_data.map.tiles[idx] == TileType::Floor && (x > 1 && build_data.map.tiles[idx-1] == TileType::Wall) && (x < build_data.map.width-2 && build_data.map.tiles[idx+1] == TileType::Wall) && (y > 1 && build_data.map.tiles[idx - build_data.map.width as usize] == TileType::Floor) && (y < build_data.map.height-2 && build_data.map.tiles[idx + build_data.map.width as usize] == TileType::Floor) { return true; } false } }
实际上只有两种门有意义的位置:东西方向开放且南北方向被阻挡,反之亦然。 我们不希望门出现在开放区域。 因此,此函数检查这些条件,如果门是可能的,则返回 true,否则返回 false。 现在我们扩展 doors 函数以扫描走廊并在其开头放置门:
#![allow(unused)] fn main() { fn doors(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { if let Some(halls_original) = &build_data.corridors { let halls = halls_original.clone(); // 为了避免嵌套借用 for hall in halls.iter() { if hall.len() > 2 { // 我们对微小的走廊不感兴趣 if self.door_possible(build_data, hall[0]) { build_data.spawn_list.push((hall[0], "Door".to_string())); } } } } } }
我们首先检查是否有走廊信息可以使用。 如果有,我们复制一份(为了让借用检查器高兴 - 否则我们将对 halls 进行两次借用)并迭代它。 每个条目都是一个走廊 - 构成该走廊的瓦片(tile)向量。 我们只对长度超过 2 个条目的走廊感兴趣 - 以避免连接门的非常短的走廊。 因此,如果它足够长 - 我们检查在走廊索引 0 处放置门是否合理; 如果合理,我们将其添加到生成列表(spawn list)中。
我们将快速再次修改 random_builder 以创建一个可能生成门的情况:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(SimpleMapBuilder::new()); builder.with(RoomDrawer::new()); builder.with(RoomSorter::new(RoomSort::LEFTMOST)); builder.with(StraightLineCorridors::new()); builder.with(RoomBasedSpawner::new()); builder.with(CorridorSpawner::new()); builder.with(RoomBasedStairs::new()); builder.with(RoomBasedStartingPosition::new()); builder.with(DoorPlacement::new()); builder }
我们 cargo run 运行项目,瞧 - 门出现了:
.
其他设计怎么样?
当然可以逐瓦片扫描其他地图,看看是否有门出现的可能性。 让我们这样做:
#![allow(unused)] fn main() { if let Some(halls_original) = &build_data.corridors { let halls = halls_original.clone(); // 为了避免嵌套借用 for hall in halls.iter() { if hall.len() > 2 { // 我们对微小的走廊不感兴趣 if self.door_possible(build_data, hall[0]) { build_data.spawn_list.push((hall[0], "Door".to_string())); } } } } else { // 没有走廊 - 扫描可能的位置 let tiles = build_data.map.tiles.clone(); for (i, tile) in tiles.iter().enumerate() { if *tile == TileType::Floor && self.door_possible(build_data, i) { build_data.spawn_list.push((i, "Door".to_string())); } } } } }
修改你的 random_builder 以使用没有走廊的地图:
#![allow(unused)] fn main() { let mut builder = BuilderChain::new(new_depth); builder.start_with(BspInteriorBuilder::new()); builder.with(DoorPlacement::new()); builder.with(RoomBasedSpawner::new()); builder.with(RoomBasedStairs::new()); builder.with(RoomBasedStartingPosition::new()); builder }
你可以 cargo run 运行项目并看到门:
.
效果相当好!
恢复我们的随机函数
我们将 random_builder 恢复到原来的样子,但有一个更改:我们将添加一个门生成器作为最后一步:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain { let mut builder = BuilderChain::new(new_depth); let type_roll = rng.roll_dice(1, 2); match type_roll { 1 => random_room_builder(rng, &mut builder), _ => random_shape_builder(rng, &mut builder) } if rng.roll_dice(1, 3)==1 { builder.with(WaveformCollapseBuilder::new()); } if rng.roll_dice(1, 20)==1 { builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT)); } builder.with(DoorPlacement::new()); builder.with(PrefabBuilder::vaults()); builder } }
请注意,我们在添加 vault 之前 添加了它; 这是故意的 - vault 有机会生成并删除任何会干扰它的门。
让门发挥作用
门有几个属性:当关闭时,它们会阻挡移动和视野。 它们可以被打开(可以选择性地需要解锁,但我们现在不打算这样做),此时你可以很好地看穿它们。
让我们从“勾勒出”(suggesting!)一些新的组件开始。 在 spawner.rs 中:
#![allow(unused)] fn main() { fn door(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('+'), fg: RGB::named(rltk::CHOCOLATE), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Door".to_string() }) .with(BlocksTile{}) .with(BlocksVisibility{}) .with(Door{open: false}) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
这里有两种新的组件类型!
BlocksVisibility将实现其名称所示的功能 - 阻止你(和怪物)看穿它。 将其作为组件而不是特殊情况处理是很好的,因为现在你可以使任何东西阻挡视野。 一个非常大的宝箱,一个巨人,甚至是一堵移动的墙 - 能够阻止看穿它们是有意义的。Door- 表示它是一扇门,并且需要自己的处理方式。
打开 components.rs,我们将创建这些新组件:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct BlocksVisibility {} #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Door { pub open: bool } }
与所有组件一样,不要忘记在 main 和 saveload_system.rs 中注册它们。
扩展视野系统以处理阻挡您视线的实体
由于视野(field of view)由 RLTK 处理,而 RLTK 依赖于 Map trait - 我们需要扩展我们的地图类以处理这个概念。 添加一个新字段:
#![allow(unused)] fn main() { #[derive(Default, Serialize, Deserialize, Clone)] pub struct Map { pub tiles : Vec<TileType>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool>, pub blocked : Vec<bool>, pub depth : i32, pub bloodstains : HashSet<usize>, pub view_blocked : HashSet<usize>, #[serde(skip_serializing)] #[serde(skip_deserializing)] pub tile_content : Vec<Vec<Entity>> } }
并更新构造函数,以免忘记:
#![allow(unused)] fn main() { pub fn new(new_depth : i32) -> Map { Map{ tiles : vec![TileType::Wall; MAPCOUNT], width : MAPWIDTH as i32, height: MAPHEIGHT as i32, revealed_tiles : vec![false; MAPCOUNT], visible_tiles : vec![false; MAPCOUNT], blocked : vec![false; MAPCOUNT], tile_content : vec![Vec::new(); MAPCOUNT], depth: new_depth, bloodstains: HashSet::new(), view_blocked : HashSet::new() } } }
现在我们将更新 is_opaque 函数(视野(field-of-view)使用),以包含对其的检查:
#![allow(unused)] fn main() { fn is_opaque(&self, idx:i32) -> bool { let idx_u = idx as usize; self.tiles[idx_u] == TileType::Wall || self.view_blocked.contains(&idx_u) } }
我们还需要访问 visibility_system.rs 以填充此数据。 我们需要扩展系统的数据以检索更多内容:
#![allow(unused)] fn main() { type SystemData = ( WriteExpect<'a, Map>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Position>, ReadStorage<'a, Player>, WriteStorage<'a, Hidden>, WriteExpect<'a, rltk::RandomNumberGenerator>, WriteExpect<'a, GameLog>, ReadStorage<'a, Name>, ReadStorage<'a, BlocksVisibility>); fn run(&mut self, data : Self::SystemData) { let (mut map, entities, mut viewshed, pos, player, mut hidden, mut rng, mut log, names, blocks_visibility) = data; ... }
紧随其后,我们将循环遍历所有阻挡视野的实体,并在 view_blocked HashSet 中设置它们的索引:
#![allow(unused)] fn main() { map.view_blocked.clear(); for (block_pos, _block) in (&pos, &blocks_visibility).join() { let idx = map.xy_idx(block_pos.x, block_pos.y); map.view_blocked.insert(idx); } }
如果你现在 cargo run 运行项目,你会看到门现在阻挡了视线:
.
处理门
撞到一扇关着的门应该打开它,然后你就可以自由通过(我们可以添加 open 和 close 命令 - 也许我们稍后会这样做 - 但现在让我们保持简单)。 打开 player.rs,我们将向 try_move_player 添加功能:
#![allow(unused)] fn main() { ... let mut doors = ecs.write_storage::<Door>(); let mut blocks_visibility = ecs.write_storage::<BlocksVisibility>(); let mut blocks_movement = ecs.write_storage::<BlocksTile>(); let mut renderables = ecs.write_storage::<Renderable>(); for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() { if pos.x + delta_x < 1 || pos.x + delta_x > map.width-1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height-1 { return RunState::AwaitingInput; } let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y); for potential_target in map.tile_content[destination_idx].iter() { let target = combat_stats.get(*potential_target); if let Some(_target) = target { wants_to_melee.insert(entity, WantsToMelee{ target: *potential_target }).expect("Add target failed"); return; } let door = doors.get_mut(*potential_target); if let Some(door) = door { door.open = true; blocks_visibility.remove(*potential_target); blocks_movement.remove(*potential_target); let glyph = renderables.get_mut(*potential_target).unwrap(); glyph.glyph = rltk::to_cp437('/'); viewshed.dirty = true; } } ... }
让我们逐步了解它:
- 我们获得对
Door、BlocksVisibility、BlocksTile和Renderable存储的写入访问权限。 - 我们迭代移动瓦片中的潜在目标,像以前一样处理近战。
- 我们还检查潜在目标是否是一扇门。 如果是,则:
- 将门的
open变量设置为true。 - 删除
BlocksVisibility条目 - 现在你可以看穿它了(怪物也可以!)。 - 删除
BlocksTile条目 - 现在你可以穿过它了(所有人都可以!)。 - 更新字形(glyph)以显示打开的门口。
- 我们将视野(viewshed)标记为脏的(dirty),以便现在显示你可以通过门看到的东西。
- 将门的
如果你现在 cargo run 运行项目,你将获得所需的功能:
.
门太多了!
在非走廊地图上,在测试门放置时存在一个小问题:到处都是门。 让我们降低门放置的频率。 我们只需添加一点随机性:
#![allow(unused)] fn main() { fn doors(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { if let Some(halls_original) = &build_data.corridors { let halls = halls_original.clone(); // 为了避免嵌套借用 for hall in halls.iter() { if hall.len() > 2 { // 我们对微小的走廊不感兴趣 if self.door_possible(build_data, hall[0]) { build_data.spawn_list.push((hall[0], "Door".to_string())); } } } } else { // 没有走廊 - 扫描可能的位置 let tiles = build_data.map.tiles.clone(); for (i, tile) in tiles.iter().enumerate() { if *tile == TileType::Floor && self.door_possible(build_data, i) && rng.roll_dice(1,3)==1 { build_data.spawn_list.push((i, "Door".to_string())); } } } } }
这给出了任何可能的门放置产生门的 1/3 的机会。 从玩游戏的角度来看,这感觉差不多是对的。 它可能不适合你 - 所以你可以更改它! 你甚至可能想把它做成一个参数。
门在其他实体之上
有时,门会生成在另一个实体之上。 这很少见,但可能会发生。 让我们防止这个问题发生。 我们可以通过快速扫描 door_possible 中的生成列表(spawn list)来解决这个问题:
#![allow(unused)] fn main() { fn door_possible(&self, build_data : &mut BuilderMap, idx : usize) -> bool { let mut blocked = false; for spawn in build_data.spawn_list.iter() { if spawn.0 == idx { blocked = true; } } if blocked { return false; } ... }
如果速度成为一个问题,这将很容易加速(创建一个已占用瓦片的快速 HashSet,并查询它而不是整个列表) - 但我们实际上没有任何性能问题,并且地图构建在主循环之外运行(所以它是每个级别一次,而不是每帧) - 所以你很可能不需要它。
附录:修复 WFC
在我们的 random_builder 中,我们犯了一个错误! 波函数坍缩(Wave Function Collapse)改变了地图的性质,应该调整生成点(spawn)、入口点和出口点。 这是正确的代码:
#![allow(unused)] fn main() { if rng.roll_dice(1, 3)==1 { builder.with(WaveformCollapseBuilder::new()); // 现在将起点设置为随机起始区域 let (start_x, start_y) = random_start_position(rng); builder.with(AreaStartingPosition::new(start_x, start_y)); // 设置出口并生成怪物 builder.with(VoronoiSpawning::new()); builder.with(DistantExit::new()); } }
总结
门的教程就到这里了! 未来肯定还有改进的空间 - 但该功能已经可以工作了。 你可以靠近一扇门,它会阻挡移动和视线(因此房间的居住者不会打扰你)。 打开它,你就可以看穿 - 居住者也可以看到你。 现在它打开了,你可以通过它。 这非常接近门的定义了!
...
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
将地图尺寸与终端尺寸解耦
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
到目前为止,我们已经将地图尺寸牢牢地绑定到终端分辨率。您有一个 80x50 的屏幕,并使用几行来显示用户界面 - 所以我们制作的所有东西都是 80 个瓦片宽和 43 个瓦片高。正如您在之前的章节中看到的,您可以使用 3,440 个瓦片做 很多 事情 - 但有时您想要更多(有时您想要更少)。您可能还想要一个大的、开放的世界设定 - 但我们现在还不打算涉及那里!本章将首先将相机与地图解耦,然后使地图尺寸和屏幕尺寸能够不同。用户界面大小调整这个难题将留待未来开发。
引入相机
游戏中常见的抽象概念是将您正在查看的内容(地图和实体)与您如何查看它(相机)分离开来。相机通常跟随您勇敢的冒险家在地图上移动,从他们的角度向您展示世界。在 3D 游戏中,相机可能非常复杂;在自上而下的 Roguelike 游戏中(从上方查看地图),它通常将视图中心对准玩家的 @。
可以预见的是,我们将从创建一个新文件开始:camera.rs。为了启用它,在 main.rs 的顶部附近添加 pub mod camera(与其他模块访问方式相同)。
我们将从创建一个函数 render_camera 开始,并进行一些我们需要的计算:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Map,TileType,Position,Renderable,Hidden}; use rltk::{Point, Rltk, RGB}; const SHOW_BOUNDARIES : bool = true; pub fn render_camera(ecs: &World, ctx : &mut Rltk) { let map = ecs.fetch::<Map>(); let player_pos = ecs.fetch::<Point>(); let (x_chars, y_chars) = ctx.get_char_size(); let center_x = (x_chars / 2) as i32; let center_y = (y_chars / 2) as i32; let min_x = player_pos.x - center_x; let max_x = min_x + x_chars as i32; let min_y = player_pos.y - center_y; let max_y = min_.y + y_chars as i32; ... }
我已将其分解为几个步骤,以清楚地说明正在发生的事情:
- 我们创建一个常量
SHOW_BOUNDARIES。如果为 true,我们将为超出边界的瓦片渲染一个标记,以便我们知道地图的边缘在哪里。大多数时候,这将是false(不需要玩家获得该信息),但它对于调试非常方便。 - 我们首先从 ECS World 中检索地图。
- 然后,我们从 ECS World 中检索玩家的位置。
- 我们向 RLTK 请求当前的控制台尺寸,以字符空间为单位(因此对于 8x8 字体,为
80x50)。 - 我们计算控制台的中心。
- 我们将
min_x设置为最左边的瓦片,相对于玩家。所以玩家的x位置,减去控制台的中心。这将使x轴以玩家为中心。 - 我们将
max_x设置为min_x加上控制台宽度 - 再次,确保玩家居中。 - 我们对
min_y和max_y执行相同的操作。
所以我们已经确定了相机在世界空间中的位置 - 即地图本身的坐标。我们还确定了使用我们的相机视图,这应该是渲染区域的中心。
现在我们将渲染实际的地图:
#![allow(unused)] fn main() { let map_width = map.width-1; let map_height = map.height-1; let mut y = 0; for ty in min_y .. max_y { let mut x = 0; for tx in min_x .. max_x { if tx > 0 && tx < map_width && ty > 0 && ty < map_height { let idx = map.xy_idx(tx, ty); if map.revealed_tiles[idx] { let (glyph, fg, bg) = get_tile_glyph(idx, &*map); ctx.set(x, y, fg, bg, glyph); } } else if SHOW_BOUNDARIES { ctx.set(x, y, RGB::named(rltk::GRAY), RGB::named(rltk::BLACK), rltk::to_cp437('·')); } x += 1; } y += 1; } }
这与我们旧的 draw_map 代码类似,但稍微复杂一些。让我们逐步了解它:
- 我们将
y设置为 0;我们使用x和y来表示实际的屏幕坐标。 - 我们从
min_y到max_y循环ty。我们使用tx和ty表示地图坐标 - 或“瓦片空间”坐标(因此为t)。- 我们将
x设置为零,因为我们要在屏幕上开始新的一行。 - 我们在变量
tx中从min_x到max_x循环 - 因此我们在tx中覆盖了可见的瓦片空间。- 我们进行裁剪检查。我们检查
tx和ty是否真的在地图边界内。玩家很可能会访问地图的边缘,您不希望因为他们可以看到不在地图区域内的瓦片而崩溃! - 我们计算
tx/ty位置的idx(索引),告诉我们屏幕上的这个位置在地图上的哪里。 - 如果它是已显示的,我们为这个索引调用神秘的
get_tile_glyph函数(稍后会详细介绍),并将结果设置在屏幕上。 - 如果瓦片超出地图范围且
SHOW_BOUNDARIES为true- 我们绘制一个点。 - 无论是否裁剪,我们都将
x加 1 - 我们正在移动到下一列。
- 我们进行裁剪检查。我们检查
- 我们将
y加一,因为我们现在正在向下移动屏幕。
- 我们将
- 我们渲染了一张地图!
这实际上非常简单 - 我们渲染的实际上是一个窗口,它查看地图的一部分,而不是整个地图 - 并将窗口中心对准玩家。
接下来,我们需要渲染我们的实体:
#![allow(unused)] fn main() { let positions = ecs.read_storage::<Position>(); let renderables = ecs.read_storage::<Renderable>(); let hidden = ecs.read_storage::<Hidden>(); let map = ecs.fetch::<Map>(); let mut data = (&positions, &renderables, !&hidden).join().collect::<Vec<_>>(); data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) ); for (pos, render, _hidden) in data.iter() { let idx = map.xy_idx(pos.x, pos.y); if map.visible_tiles[idx] { let entity_screen_x = pos.x - min_x; let entity_screen_y = pos.y - min_y; if entity_screen_x > 0 && entity_screen_x < map_width && entity_screen_y > 0 && entity_screen_y < map_height { ctx.set(entity_screen_x, entity_screen_y, render.fg, render.bg, render.glyph); } } } }
如果这看起来很眼熟,那是因为它与曾经存在于 main.rs 中的渲染代码相同。有两个主要的区别:我们从 x 和 y 坐标中减去 min_x 和 min_y,以使实体与我们的相机视图对齐。我们还对坐标执行裁剪 - 我们不会尝试渲染任何不在屏幕上的东西。
我们之前提到了 get_tile_glyph,所以这里是它:
#![allow(unused)] fn main() { fn get_tile_glyph(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { let glyph; let mut fg; let mut bg = RGB::from_f32(0., 0., 0.); match map.tiles[idx] { TileType::Floor => { glyph = rltk::to_cp437('.'); fg = RGB::from_f32(0.0, 0.5, 0.5); } TileType::Wall => { let x = idx as i32 % map.width; let y = idx as i32 / map.width; glyph = wall_glyph(&*map, x, y); fg = RGB::from_f32(0., 1.0, 0.); } TileType::DownStairs => { glyph = rltk::to_cp437('>'); fg = RGB::from_f32(0., 1.0, 1.0); } } if map.bloodstains.contains(&idx) { bg = RGB::from_f32(0.75, 0., 0.); } if !map.visible_tiles[idx] { fg = fg.to_greyscale(); bg = RGB::from_f32(0., 0., 0.); // 不显示视野范围外的血迹 } (glyph, fg, bg) } }
这与我们很久以前编写的 draw_map 中的代码非常相似,但它不是绘制到地图,而是返回字形、前景色和背景色。它仍然处理血迹,灰化您看不到的区域,并为漂亮的墙壁调用 wall_glyph。我们只是从 map.rs 中复制了 wall_glyph:
#![allow(unused)] fn main() { fn wall_glyph(map : &Map, x: i32, y:i32) -> rltk::FontCharType { if x < 1 || x > map.width-2 || y < 1 || y > map.height-2 as i32 { return 35; } let mut mask : u8 = 0; if is_revealed_and_wall(map, x, y - 1) { mask +=1; } if is_revealed_and_wall(map, x, y + 1) { mask +=2; } if is_revealed_and_wall(map, x - 1, y) { mask +=4; } if is_revealed_and_wall(map, x + 1, y) { mask +=8; } match mask { 0 => { 9 } // 柱子,因为我们看不到邻居 1 => { 186 } // 仅北面有墙 2 => { 186 } // 仅南面有墙 3 => { 186 } // 北面和南面都有墙 4 => { 205 } // 仅西面有墙 5 => { 188 } // 北面和西面都有墙 6 => { 187 } // 南面和西面都有墙 7 => { 185 } // 北面、南面和西面都有墙 8 => { 205 } // 仅东面有墙 9 => { 200 } // 北面和东面都有墙 10 => { 201 } // 南面和东面都有墙 11 => { 204 } // 北面、南面和东面都有墙 12 => { 205 } // 东面和西面都有墙 13 => { 202 } // 东面、西面和南面都有墙 14 => { 203 } // 东面、西面和北面都有墙 15 => { 206 } // ╬ 四面都有墙 _ => { 35 } // 我们遗漏了一个? } } fn is_revealed_and_wall(map: &Map, x: i32, y: i32) -> bool { let idx = map.xy_idx(x, y); map.tiles[idx] == TileType::Wall && map.revealed_tiles[idx] } }
最后,在 main.rs 中找到以下代码:
#![allow(unused)] fn main() { ... RunState::GameOver{..} => {} _ => { draw_map(&self.ecs.fetch::<Map>(), ctx); let positions = self.ecs.read_storage::<Position>(); let renderables = self.ecs.read_storage::<Renderable>(); let hidden = self.ecs.read_storage::<Hidden>(); let map = self.ecs.fetch::<Map>(); let mut data = (&positions, &renderables, !&hidden).join().collect::<Vec<_>>(); data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) ); for (pos, render, _hidden) in data.iter() { let idx = map.xy_idx(pos.x, pos.y); if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) } } gui::draw_ui(&self.ecs, ctx); } ... }
我们现在可以用一段更短的代码替换它:
#![allow(unused)] fn main() { RunState::GameOver{..} => {} _ => { camera::render_camera(&self.ecs, ctx); gui::draw_ui(&self.ecs, ctx); } }
如果您现在 cargo run 项目,您将看到我们仍然可以玩 - 并且相机以玩家为中心:
。
糟糕 - 我们没有移动工具提示或目标!
如果您玩一会儿,您可能会注意到工具提示不起作用(它们仍然绑定到地图坐标)。我们应该修复它!首先,屏幕边界显然是我们不仅在绘制代码中需要的东西,所以让我们把它分解成 camera.rs 中的一个单独的函数:
#![allow(unused)] fn main() { pub fn get_screen_bounds(ecs: &World, ctx : &mut Rltk) -> (i32, i32, i32, i32) { let player_pos = ecs.fetch::<Point>(); let (x_chars, y_chars) = ctx.get_char_size(); let center_x = (x_chars / 2) as i32; let center_y = (y_chars / 2) as i32; let min_x = player_pos.x - center_x; let max_x = min_x + x_chars as i32; let min_y = player_pos.y - center_y; let max_y = min_y + y_chars as i32; (min_x, max_x, min_y, max_y) } pub fn render_camera(ecs: &World, ctx : &mut Rltk) { let map = ecs.fetch::<Map>(); let (min_x, max_x, min_y, max_y) = get_screen_bounds(ecs, ctx); }
它是 render_camera 中的相同代码 - 只是移动到一个函数中。我们还扩展了 render_camera 以使用该函数,而不是重复我们自己。现在我们可以进入 gui.rs 并轻松编辑 draw_tooltips 以使用相机位置:
#![allow(unused)] fn main() { fn draw_tooltips(ecs: &World, ctx : &mut Rltk) { let (min_x, _max_x, min_y, _max_y) = camera::get_screen_bounds(ecs, ctx); let map = ecs.fetch::<Map>(); let names = ecs.read_storage::<Name>(); let positions = ecs.read_storage::<Position>(); let hidden = ecs.read_storage::<Hidden>(); let mouse_pos = ctx.mouse_pos(); let mut mouse_map_pos = mouse_pos; mouse_map_pos.0 += min_x; mouse_map_pos.1 += min_y; if mouse_map_pos.0 >= map.width-1 || mouse_map_pos.1 >= map.height-1 || mouse_map_pos.0 < 1 || mouse_map_pos.1 < 1 { return; } if !map.visible_tiles[map.xy_idx(mouse_map_pos.0, mouse_map_pos.1)] { return; } let mut tooltip : Vec<String> = Vec::new(); for (name, position, _hidden) in (&names, &positions, !&hidden).join() { if position.x == mouse_map_pos.0 && position.y == mouse_map_pos.1 { tooltip.push(name.name.to_string()); } } ... }
所以我们的更改是:
- 在开始时,我们使用
camera::get_screen_bounds检索屏幕边界。我们不打算使用max变量,所以我们在它们前面加上下划线,让 Rust 知道我们有意忽略它们。 - 在获取
mouse_pos后,我们创建一个新的mouse_map_pos变量。它等于mouse_pos,但我们添加了min_x和min_y值 - 将其偏移以匹配可见坐标。 - 我们扩展了裁剪以检查所有方向,因此当您查看实际地图之外的区域时,工具提示不会使游戏崩溃,因为视口位于地图的极端末端。
- 我们对
position的比较现在与mouse_map_pos而不是mouse_pos进行比较。 - 就这样 - 其余的可以保持不变。
如果您现在 cargo run,工具提示将起作用:
。
修复目标
如果您玩一会儿,您还会注意到,如果您尝试使用火球或类似效果 - 目标系统完全失灵了。它仍然引用屏幕/地图位置,因为它们曾经直接链接。所以您看到了可用的瓦片,但它们的位置完全错误!我们也应该修复它。
在 gui.rs 中,我们将编辑函数 ranged_target:
#![allow(unused)] fn main() { pub fn ranged_target(gs : &mut State, ctx : &mut Rltk, range : i32) -> (ItemMenuResult, Option<Point>) { let (min_x, max_x, min_y, max_y) = camera::get_screen_bounds(&gs.ecs, ctx); let player_entity = gs.ecs.fetch::<Entity>(); let player_pos = gs.ecs.fetch::<Point>(); let viewsheds = gs.ecs.read_storage::<Viewshed>(); ctx.print_color(5, 0, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Select Target:"); // 高亮显示可用的目标单元格 let mut available_cells = Vec::new(); let visible = viewsheds.get(*player_entity); if let Some(visible) = visible { // 我们有一个视野 for idx in visible.visible_tiles.iter() { let distance = rltk::DistanceAlg::Pythagoras.distance2d(*player_pos, *idx); if distance <= range as f32 { let screen_x = idx.x - min_x; let screen_y = idx.y - min_y; if screen_x > 1 && screen_x < (max_x - min_x)-1 && screen_y > 1 && screen_y < (max_y - min_y)-1 { ctx.set_bg(screen_x, screen_y, RGB::named(rltk::BLUE)); available_cells.push(idx); } } } } else { return (ItemMenuResult::Cancel, None); } // 绘制鼠标光标 let mouse_pos = ctx.mouse_pos(); let mut mouse_map_pos = mouse_pos; mouse_map_pos.0 += min_x; mouse_map_pos.1 += min_y; let mut valid_target = false; for idx in available_cells.iter() { if idx.x == mouse_map_pos.0 && idx.y == mouse_map_pos.1 { valid_target = true; } } if valid_target { ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::CYAN)); if ctx.left_click { return (ItemMenuResult::Selected, Some(Point::new(mouse_map_pos.0, mouse_map_pos.1))); } } else { ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::RED)); if ctx.left_click { return (ItemMenuResult::Cancel, None); } } (ItemMenuResult::NoResponse, None) } }
这基本上是我们以前拥有的,但做了一些更改:
- 我们在开始时获取边界,再次使用
camera::get_screen_bounds。 - 在我们的可见目标瓦片部分中,我们通过获取地图索引并添加我们的
min_x和min_y值来计算screen_x和screen_y。然后,我们检查它是否在屏幕上,然后在这些位置绘制目标高亮。 - 在计算
mouse_pos后,我们使用相同的mouse_map_pos计算。 - 然后,我们在检查目标是否在鼠标下方或被选中时引用
mouse_map_pos。
如果您现在 cargo run,目标将起作用:
。
可变的地图尺寸
现在我们的地图没有直接链接到我们的屏幕,我们可以拥有任何我们想要的尺寸的地图!温馨提示:如果您使用巨大的地图,您的玩家将需要很长时间才能探索完所有地图 - 并且越来越难以确保所有地图都足够有趣,值得访问。
一个简单的开始
让我们从最简单的例子开始:全局更改地图的大小。转到 map.rs,找到常量 MAPWIDTH、MAPHEIGHT 和 MAPCOUNT。让我们将它们更改为方形地图:
#![allow(unused)] fn main() { pub const MAPWIDTH : usize = 64; pub const MAPHEIGHT : usize = 64; pub const MAPCOUNT : usize = MAPHEIGHT * MAPWIDTH; }
如果您 cargo run 该项目,它应该可以工作 - 我们在整个程序中都很好地使用了 map.width/map.height 或这些常量。这些算法运行,并尝试为您制作一张地图。这是我们的玩家在 64x64 地图上漫游 - 请注意地图的侧面是如何显示为超出边界的:
。
更难:删除常量
现在从 map.rs 中删除这三个常量,并观察您的 IDE 将世界涂成红色。在我们开始修复问题之前,我们将添加更多的红色:
#![allow(unused)] fn main() { /// 生成一个空地图,完全由实心墙壁组成 pub fn new(new_depth : i32, width: i32, height: i32) -> Map { Map{ tiles : vec![TileType::Wall; MAPCOUNT], width, height, revealed_tiles : vec![false; MAPCOUNT], visible_tiles : vec![false; MAPCOUNT], blocked : vec![false; MAPCOUNT], tile_content : vec![Vec::new(); MAPCOUNT], depth: new_depth, bloodstains: HashSet::new(), view_blocked : HashSet::new() } } }
现在创建地图需要您指定大小和深度。我们可以通过再次更改构造函数以在创建各种向量时使用指定的大小,来开始修复一些错误:
#![allow(unused)] fn main() { pub fn new(new_depth : i32, width: i32, height: i32) -> Map { let map_tile_count = (width*height) as usize; Map{ tiles : vec![TileType::Wall; map_tile_count], width, height, revealed_tiles : vec![false; map_tile_count], visible_tiles : vec![false; map_tile_count], blocked : vec![false; map_tile_count], tile_content : vec![Vec::new(); map_tile_count], depth: new_depth, bloodstains: HashSet::new(), view_blocked : HashSet::new() } } }
map.rs 在 draw_map 中也有一个错误。幸运的是,这是一个简单的修复:
#![allow(unused)] fn main() { ... // 移动坐标 x += 1; if x > (map.width * map.height) as i32-1 { x = 0; y += 1; } ... }
spawner.rs 也是一个同样容易修复的问题。从开头的 use 导入列表中删除 map::MAPWIDTH,并找到 spawn_entity 函数。我们可以直接从 ECS 获取地图宽度:
#![allow(unused)] fn main() { pub fn spawn_entity(ecs: &mut World, spawn : &(&usize, &String)) { let map = ecs.fetch::<Map>(); let width = map.width as usize; let x = (*spawn.0 % width) as i32; let y = (*spawn.0 / width) as i32; std::mem::drop(map); ... }
saveload_system.rs 中的问题也很容易修复。在第 102 行左右,您可以将 MAPCOUNT 替换为 (worldmap.width * worldmap.height) as usize:
#![allow(unused)] fn main() { ... let mut deleteme : Option<Entity> = None; { let entities = ecs.entities(); let helper = ecs.read_storage::<SerializationHelper>(); let player = ecs.read_storage::<Player>(); let position = ecs.read_storage::<Position>(); for (e,h) in (&entities, &helper).join() { let mut worldmap = ecs.write_resource::<super::map::Map>(); *worldmap = h.map.clone(); worldmap.tile_content = vec![Vec::new(); (worldmap.height * worldmap.width) as usize]; deleteme = Some(e); } ... }
main.rs 也需要一些帮助。在 tick 中,MagicMapReveal 代码是一个简单的修复:
#![allow(unused)] fn main() { RunState::MagicMapReveal{row} => { let mut map = self.ecs.fetch_mut::<Map>(); for x in 0..map.width { let idx = map.xy_idx(x as i32,row); map.revealed_tiles[idx] = true; } if row == map.height-1 { newrunstate = RunState::MonsterTurn; } else { newrunstate = RunState::MagicMapReveal{ row: row+1 }; } } }
在第 451 行附近,我们也在用 map::new(1) 创建地图。我们想在这里引入地图尺寸,所以我们使用 map::new(1, 64, 64)(尺寸并不重要,因为我们无论如何都会用来自构建器的地图替换它)。
打开 player.rs,您会发现我们犯了一个真正的编程罪过。我们硬编码了 79 和 49 作为玩家移动的地图边界!让我们修复它:
#![allow(unused)] fn main() { if !map.blocked[destination_idx] { pos.x = min(map.width-1 , max(0, pos.x + delta_x)); pos.y = min(map.height-1, max(0, pos.y + delta_y)); }
最后,展开我们的 map_builders 文件夹会显示一些错误。我们将在修复它们之前再引入几个错误!在 map_builders/mod.rs 中,我们将存储请求的地图大小:
#![allow(unused)] fn main() { pub struct BuilderMap { pub spawn_list : Vec<(usize, String)>, pub map : Map, pub starting_position : Option<Position>, pub rooms: Option<Vec<Rect>>, pub corridors: Option<Vec<Vec<usize>>>, pub history : Vec<Map>, pub width: i32, pub height: i32 } }
然后我们将更新构造函数以使用它:
#![allow(unused)] fn main() { impl BuilderChain { pub fn new(new_depth : i32, width: i32, height: i32) -> BuilderChain { BuilderChain{ starter: None, builders: Vec::new(), build_data : BuilderMap { spawn_list: Vec::new(), map: Map::new(new_depth, width, height), starting_position: None, rooms: None, corridors: None, history : Vec::new(), width, height } } } }
我们还需要调整 random_builder 的签名以接受地图大小:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut builder = BuilderChain::new(new_depth, width, height); ... }
我们还将访问 map_builders/waveform_collapse/mod.rs 并进行一些修复。基本上,我们对 Map::new 的所有引用都需要包括新的大小。
最后,回到 main.rs,在第 370 行左右,您会找到我们对 random_builder 的调用。我们需要向其中添加宽度和高度;现在,我们将使用 64x64:
#![allow(unused)] fn main() { let mut builder = map_builders::random_builder(new_depth, &mut rng, 64, 64); }
就是这样!如果您现在 cargo run 该项目,您可以漫游 64x64 的地图:
。
如果您将该行更改为不同的尺寸,则可以漫游巨大的地图:
#![allow(unused)] fn main() { let mut builder = map_builders::random_builder(new_depth, &mut rng, 128, 128); }
瞧 - 您正在漫游一张巨大的地图!巨大地图的明显缺点,以及滚动一个大部分开放区域的缺点是,有时它可能真的很难生存:
。
重新访问 draw_map 以进行渐进式地图渲染。
如果您保留巨大的地图,打开 main.rs 并将 const SHOW_MAPGEN_VISUALIZER : bool = false; 设置为 true - 恭喜您,您刚刚使游戏崩溃了!这是因为我们从未调整我们用于验证地图创建的 draw_map 函数来处理原始尺寸以外的任何尺寸的地图。哎呀。这确实提出了一个问题:在 ASCII 终端上,我们不能简单地渲染整个地图并将其缩小以适应。因此,我们将满足于渲染地图的一部分。
我们将在 camera.rs 中添加一个新函数:
#![allow(unused)] fn main() { pub fn render_debug_map(map : &Map, ctx : &mut Rltk) { let player_pos = Point::new(map.width / 2, map.height / 2); let (x_chars, y_chars) = ctx.get_char_size(); let center_x = (x_chars / 2) as i32; let center_y = (y_chars / 2) as i32; let min_x = player_pos.x - center_x; let max_x = min_x + x_chars as i32; let min_y = player_pos.y - center_y; let max_y = min_y + y_chars as i32; let map_width = map.width-1; let map_height = map.height-1; let mut y = 0; for ty in min_y .. max_y { let mut x = 0; for tx in min_x .. max_x { if tx > 0 && tx < map_width && ty > 0 && ty < map_height { let idx = map.xy_idx(tx, ty); if map.revealed_tiles[idx] { let (glyph, fg, bg) = get_tile_glyph(idx, &*map); ctx.set(x, y, fg, bg, glyph); } } else if SHOW_BOUNDARIES { ctx.set(x, y, RGB::named(rltk::GRAY), RGB::named(rltk::BLACK), rltk::to_cp437('·')); } x += 1; } y += 1; } } }
这很像我们常规的地图绘制,但我们将相机锁定在地图的中间 - 并且不渲染实体。
在 main.rs 中,将对 draw_map 的调用替换为:
#![allow(unused)] fn main() { if self.mapgen_index < self.mapgen_history.len() { camera::render_debug_map(&self.mapgen_history[self.mapgen_index], ctx); } }
现在您可以进入 map.rs 并完全删除 draw_map、wall_glyph 和 is_revealed_and_wall。
总结
我们将在 main.rs 中将地图尺寸设置回合理的尺寸:
#![allow(unused)] fn main() { let mut builder = map_builders::random_builder(new_depth, &mut rng, 80, 50); }
并且 - 我们完成了!在本章中,我们使拥有任何您喜欢的地图尺寸成为可能。我们最终恢复为“正常”尺寸 - 但我们将在未来发现此功能非常有用。我们可以放大或缩小地图 - 系统一点也不介意。
...
本章的源代码可以在这里找到
使用 WebAssembly 在您的浏览器中运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
Section 3 - 总结
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出伟大的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
至此,第 3 节 - 地图构建就结束了! 在本节中,我们涵盖了大量内容,学习了许多地图构建技术。 我希望这能启发您去探索自己感兴趣的组合,并制作有趣的游戏! 程序化生成地图是制作 roguelike 游戏的一个重要组成部分,因此它也是本教程如此大篇幅介绍的内容。
第 4 节将介绍如何真正制作游戏。
...
版权 (C) 2019, Herbert Wolverson。
让我们来制作一个游戏!
关于本教程
本教程是免费和开源的,所有代码都使用 MIT 许可证 - 所以你可以随意使用它。我希望你喜欢这个教程,并制作出伟大的游戏!
如果你喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
到目前为止,本教程分为三个部分:
- 制作一个骨架游戏,展示如何制作一个非常简约的 roguelike 游戏。
- 向游戏中添加一些必要的类型特征,使其更有趣。
- 构建大量的地图,这是制作有趣的 roguelike 游戏中非常重要的一部分。
现在我们将开始一个系列文章,从我们的框架中实际制作出一个有凝聚力的游戏。它不会很大,也不太可能挑战“史上最佳 Roguelike!”的地位 - 但它将探索将技术演示转变为一个有凝聚力的游戏的过程中会遇到的试验和磨难。
柏林诠释 (The Berlin Interpretation)
这个游戏将紧密贴近该类型,几乎没有突破性的创新。所以我们将拥有一个幻想背景、地下城探险和有限的进程。 如果你熟悉柏林诠释(一种尝试在游戏世界中使用该名称来编纂什么才算作 roguelike 的尝试!),我们将努力紧密坚持重要的方面:
高价值目标
- 随机环境生成 (Random Environment Generation) 是必不可少的,我们已经介绍了很多有趣的方法来做到这一点!
- 永久死亡 (Permadeath) 定义了该类型,所以我们将采用它。我们可能会偷偷加入游戏保存/加载,并研究如果这是你想要的,如何处理非永久死亡 - 但我们将坚持这一原则,及其含义,即你应该能够在不死的情况下通关 roguelike 游戏。
- 回合制 (Turn-based) - 我们肯定会坚持回合制设置,但会引入不同的速度和先攻权 (initiative)。
- 网格化 (Grid-based) - 我们肯定会坚持网格化系统。
- 非模态 (Non-modal) - 我们可能会打破这一条,通过设置一些系统将你从常规的“所有内容都在一个屏幕上”的游戏系统带走。
- 复杂性 (Complexity) - 我们将努力实现复杂性,但尽量保持游戏的可玩性,而不是成为硕士论文的课题!
- 资源管理 (Resource management) - 我们已经通过饥饿时钟和消耗品获得了一些,但我们肯定希望保留它作为一种定义特征。
- 砍杀 (Hack'n'slash) - 绝对的!
- 探索与发现 (Exploration and discovery) - 当然!
低价值目标
- 单人角色 (Single player character) - 在本节中我们不太可能引入队伍,但我们可能会引入友好的 NPC。
- 怪物与玩家相似 (Monsters are similar to players) - ECS 有助于实现这一点,因为我们以与 NPC 相同的方式模拟玩家。我们将坚持基本原则。
- 战术挑战 (Tactical challenge) - 始终值得努力的目标;没有挑战的游戏有什么意义?
- ASCII 显示 (ASCII Display) - 我们将坚持使用这个,但可能会在稍后找到时间引入图形 tiles。
- 地下城 (Dungeons) - 当然!它们不一定必须是房间和走廊,但我们已经努力拥有良好的房间和走廊!
- 数字 (Numbers) - 这一点更具争议性;不是每个人都想在每次揍地精时看到一大堆数学计算。我们将尝试取得一些平衡 - 因此会有大量的数字,大部分是可见的,但它们对于玩游戏来说不是必不可少的。
因此,看起来很有可能在这些约束下,我们将制作一个 真正的 roguelike 游戏 - 一个几乎符合所有要求的游戏!
设定 (Setting)
我们已经决定了幻想-伪中世纪 (fantasy-faux-medieval) 的设定,但这并不意味着它必须 完全 像 D&D 或托尔金!我们将尝试在我们的设定中引入一些有趣和独特的元素。
叙事 (Narrative)
在下一章中,我们将致力于在设计文档 (design document) 中概述我们的总体目标。这将必然包括一些叙事,尽管 roguelike 游戏并非以深刻的故事而闻名!
...
版权 (Copyright) (C) 2019, Herbert Wolverson.
设计文档
关于本教程
本教程是免费和开源的,所有代码都使用 MIT 许可证 - 所以你可以随意使用它。 我希望你喜欢本教程,并制作出色的游戏!
如果你喜欢这个教程并希望我继续写作,请考虑支持 我的 Patreon。
如果你计划完成一个游戏,提前设定你的目标非常重要! 传统上,这采取了设计文档的形式 - 一份概述游戏的总文档,以及详细说明你想完成什么的小节。 在这种情况下,它也构成了编写本节的骨架。 有成千上万的 在线参考资料关于编写游戏设计文档。 格式其实并不重要,只要它能作为开发的指导,并为你提供可以宣布“完成了!”的标准。
因为这是一个教程,我们现在将把游戏设计文档作为一个骨架,并在我们进行时充实它。 这在我的写作方面留下了一些灵活性! 因此,在本节接近完成之前,请将其视为活文档——一个永恒的进行中的工作,随着我们的进展而扩展。 这真的不是编写设计文档的方式,但我拥有大多数团队没有的两项奢侈品:没有时间限制,也没有需要指导的团队成员!
锈化的 Roguelike
《锈化的 Roguelike》是一款 2D 传统 Roguelike 游戏,旨在捕捉自 1980 年《Rogue》发布以来该类型发展而来的精髓。 回合制、基于图块,并以冒险家深入地下城以找回亚拉护身符(又一个丢失的护身符)为中心。 冒险家在无数程序生成的关卡中战斗以找回护身符,然后必须战斗返回城镇才能赢得游戏。
角色
玩家控制一个主要角色,英雄主角,他/她/它在地下城中战斗。 人类 NPC 的范围从商店老板到幻想 RPG 的常见角色,如强盗、土匪、巫师等等。 游戏中的其他角色将主要是幻想 RPG 的常见角色:精灵、矮人、侏儒、半身人、兽人、地精、巨魔、食人魔、龙等等。
(所有 NPC 的描述应在此处)
一个延伸目标是让 NPC 属于不同的派系,并允许聪明的玩家“刷派系声望”并调整忠诚度。
理想情况下,NPC AI 应该比石头更智能。
故事
这不是一个故事驱动型的游戏(Roguelike 游戏的故事通常比传统 RPG 短,因为你会经常死亡和重新开始,并且通常不会花很多时间阅读故事/ lore)。
在遥远的黑暗时代,巫师国王们制作了亚拉护身符,以束缚深渊的恶魔 - 并结束他们的恐怖统治。 随之而来的是黄金时代,善良的种族蓬勃发展。 现在黑暗时代再次降临这片土地,恶魔蠢蠢欲动,黑暗势力再次蹂躏这片土地。 亚拉护身符可能是善良人民最后的希望。 在酒吧度过漫长一夜后,你意识到也许你的命运是找回它,恢复这片土地的安宁。 带着轻微的宿醉,你出发前往家乡城镇下方的地下城——确信你可以成为那个拨乱反正的人。
主题
我们的目标是传统的 D&D 风格的地下城乱斗,包含陷阱、怪物、偶尔的谜题和“可重玩性”。 游戏每次都应该有所不同。 优先采用轻松愉快的方式,并自由地撒上幽默(该类型的另一个主要特征)。 相比于严格关注现实主义,更倾向于“大杂烩式”的方法——这是一个教程项目,在这种情况下,拥有许多主题(从中学习)比拥有一个单一的、有凝聚力的主题更好。
故事进程
没有横向成长 - 你不会保留之前游戏过程中的任何增益。 因此,你总是以新角色的身份在同一个地方开始,并且只获得单次游戏过程的增益。 你可以在地下城中向上和向下移动,返回城镇出售物品和货物。 关卡进度会一直保留,直到你找到亚拉护身符——此时,整个宇宙真的都在与你作对,直到你返回家园。
作为入门指南,请考虑以下进程。 随着我们游戏的开发,它将不断演变并变得更加随机。
- 游戏在城镇中开始。 在城镇中,只有极少的敌人(扒手、暴徒)。 你在一个待命名的酒吧(酒馆)中开始游戏,只配备了微薄的钱包、最少的初始装备、一杯啤酒、一根干香肠、一个背包和宿醉。 城镇允许你拜访各种商人。
- 你深入城镇旁边的洞穴,并在天然石灰岩洞穴中战斗。
- 石灰岩洞穴让位于一个被摧毁的矮人要塞,现在被邪恶的野兽——和一条黑龙(谢谢 Sveller 先生!)占据。
- 矮人要塞下方是一个广阔的蘑菇森林。
- 蘑菇森林让位于一座黑暗精灵城市。
- 深处包含一个通往深渊的传送门的城堡。
- 深渊是一场对抗高等级恶魔怪物的艰难战斗。 在这里你可以找到亚拉护身符。
- 你一路战斗返回城镇。
旅行应该通过类似《暗黑破坏神》中的城镇传送门卷轴来促进。
游戏玩法
游戏玩法应该是非常传统的基于回合的地下城探索,但重点是使机制易于使用。 在基础层面上,这就是“谋杀流浪汉体验”:你开始时一无所有,靠你找到的东西为生,杀死(或躲避)你遇到的怪物,并拿走他们的东西! 这应该撒上该类型的常见要素:物品鉴定、有趣的魔法物品、属性和大量修改它们的方法,以及多种“有效”的玩法和通关游戏的方式。 游戏应该有难度,但并非不可能。 不允许任何需要快速反应的东西!
在真正的游戏设计文档中,我们会在这里仔细描述每个元素。 为了本教程的目的,我们将在编写更多内容时添加到列表中。
目标
- 总体目标: 最终目标是找回亚拉护身符 - 并返回城镇(一旦你拥有它,城镇传送门法术将停止工作)。
- 短期目标: 击败每个关卡中的敌人。
- 导航地下城的每个关卡,避开陷阱并到达出口。
- 获得大量酷炫的战利品。
- 赢得你的分数以获得炫耀的资本。
用户技能
- 导航不同的地下城。
- 战术战斗,学习 AI 行为和地形,最大限度地提高生存机会。
- 物品鉴定应该不仅仅是“鉴定法术”——应该有一些提示/系统,用户可以使用这些提示/系统来更好地理解概率。
- 属性管理 - 装备以提高你在不同威胁下的生存机会。
- 长期和短期资源管理。
- 理想情况下,我们希望有足够的深度来激发“Build”讨论。
游戏机制
我们将采用经过尝试和测试的“有点像 D&D”的机制,许多游戏都在使用这种机制(并根据开放游戏许可获得许可),但又没有束缚于类似 D&D 的游戏。 随着教程的开发,我们将对此进行扩展。
物品和强化道具
游戏应包含各种各样的物品。 广义上讲,物品分为:
- 可穿戴物品(盔甲、衣服等)
- 特殊可穿戴物品(护身符、戒指等)
- 防御物品(盾牌和类似物品)
- 近战武器
- 远程武器
- 消耗品(药水、卷轴、任何使用后消耗的物品)
- 充能物品(除非重新充能,否则只能使用
x次的物品) - 可出售/可拆卸的战利品/垃圾。
- 食物。
其他注意事项:
- 最终,物品应该有重量,库存管理成为一种技能。 在此之前,它可以相当宽松/容易。
- 魔法物品不应立即显示它们的作用,除非是魔法物品。
- 物品应该从至少在某种程度上合理的掉落列表中抽取。
- “道具”是一种特殊形式的物品,它不会移动,但可以与之互动。
进程和挑战
- 当你击败敌人时,你会获得经验值并可以升级。 这可以提高你的整体能力,并让你能够以更好的方式击败更多敌人!
- 随着你向下深入,关卡的难度应该会增加。 “超出等级”的敌人是可能存在的,但非常罕见 - 为了保持公平。
- 尽量避免随意地杀死玩家,让他们没有希望规避。
- 一旦亚拉护身符被认领,当你一路战斗返回城镇时,所有关卡的难度都会增加。 某些特权(如城镇传送门)不再起作用。
- 运行之间没有进程 - 它是完全独立的。
失败
失败也很有趣! 事实上,传统 Roguelike 的吸引力很大一部分在于你只有一条生命 - 当你屈服于伤口/陷阱/变成香蕉时,游戏就“结束”了。 游戏将具有 永久死亡 - 一旦你死了,你的游戏过程就结束了,你将重新开始。
作为一个延伸目标,我们可能会引入一些方法来减轻/缓和这种情况。
美术风格
我们的目标是精美的 ASCII,并可能引入图块。
音乐和音效
没有! 一旦图块完成,拥有音乐和音效会很好,但为现代 RPG 完全配音远远超出了我的资源。
技术描述
该游戏将使用 rltk_rs 作为后端,用 Rust 编写。 它将支持 Rust 可以编译和链接到 OpenGL 的所有平台,包括用于基于浏览器的游戏的 Web Assembly。
营销和资金
这是一个免费教程,所以预算大约为 0 美元。 如果有人想捐赠给 我的 Patreon,我可以保证永恒的感激之情、一个以你名字命名的怪物,以及其他不多东西!
本地化
我不擅长语言,所以就用英语吧。
其他想法
任何有好主意的人都应该发给我。 :-)
总结
所以我们有了:一个非常骨感的设计文档,其中有很多漏洞。 编写其中一个文档是个好主意,尤其是在制作时间受限的游戏(例如“7 天 Roguelike 挑战”)时。 随着更多功能的实现,本章的质量将不断提高。 目前,它旨在作为基线。
...
版权所有 (C) 2019, Herbert Wolverson。
数据驱动设计:Raw Files
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出伟大的游戏!
如果您喜欢这个教程并希望我继续创作,请考虑支持我的 Patreon。
如果您玩过 Dwarf Fortress,其(底层)一个显著的特点就是 raw file 系统。游戏中大量的内容都在 raws 中详细说明,您可以完全“mod”(修改)游戏,使其变成其他的东西。其他游戏,例如 Tome 4,更进一步,为 所有内容 定义了脚本引擎文件 - 您可以随心所欲地自定义游戏。一旦实现,raws 将您的游戏变成更像一个 引擎 - 显示/管理与 raw files 中编写的内容的交互。这并不是说引擎很简单:它必须支持 raw files 中指定的所有内容!
这被称为 数据驱动设计:您的游戏更多地是由描述它的数据定义的,而不是实际的引擎机制。它有以下几个优点:
- 它使得进行更改非常容易;您不必每次想更改哥布林,或者制作一个新的变种(例如“胆小的哥布林”)时都去挖掘
spawner.rs。相反,您编辑raws以包含您的新怪物,将其添加到生成、战利品和阵营表格中,然后这个怪物就出现在您的游戏中了!(除非 “胆小” 实际上需要新的支持代码 - 在这种情况下,您也需要编写它)。 - 数据驱动设计与实体组件系统 (ECS) 美妙地结合在一起。
raws充当一个 模板,您可以通过组合组件来构建实体,直到它与您的raw描述相匹配。 - 数据驱动设计使人们可以轻松更改您创建的游戏。对于像这样的教程来说,这一点非常重要:我更希望您从本教程中走出来后能够制作自己的游戏,而不仅仅是重新制作这个游戏!
WebAssembly 的一个缺点
WebAssembly 不容易从您的计算机读取文件。这就是为什么我们开始使用 嵌入 系统来处理资源;否则您必须制作一堆钩子,通过 JavaScript 调用来读取游戏数据,以下载资源,将其作为数据数组获取,并将数组传递到 WebAssembly 模块中。可能还有比嵌入所有内容更好的方法,但在我找到一个好的方法(并且也能在原生代码中工作)之前,我们将坚持使用嵌入。
这消除数据驱动设计的一个优势:您仍然需要重新编译游戏。因此我们将使嵌入成为可选的;如果我们 可以 从磁盘读取文件,我们将这样做。在实践中,这意味着当您发布游戏时,您必须包含可执行文件 和 raw files - 或者将它们嵌入到最终构建中。
确定 Raw files 的格式
在一些项目中,我使用脚本语言 Lua 来处理这类事情。Lua 是一种很棒的语言,并且拥有可执行的配置出奇地有用(配置可以包含函数和助手函数来构建自身)。但这对于本项目来说有点过度了。我们已经在游戏的保存/加载中支持 JSON,因此我们也将使用它来处理 Raws。
查看当前游戏中的 spawner.rs 应该会给我们一些关于在这些文件中放入什么内容的线索。感谢我们对组件的使用,已经有很多共享功能可以构建。例如,治疗药水 的定义如下所示:
#![allow(unused)] fn main() { fn health_potion(ecs: &mut World, x: i32, y: i32) { ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ glyph: rltk::to_cp437('¡'), fg: RGB::named(rltk::MAGENTA), bg: RGB::named(rltk::BLACK), render_order: 2 }) .with(Name{ name : "Health Potion".to_string() }) .with(Item{}) .with(Consumable{}) .with(ProvidesHealing{ heal_amount: 8 }) .marked::<SimpleMarker<SerializeMe>>() .build(); } }
在 JSON 中,我们可能会选择像这样的表示形式(只是一个例子):
{
"name" : "Healing Potion",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000"
},
"consumable" : {
"effects" : { "provides_healing" : "8" }
}
}
创建 raw files
您的包应按如下方式布局:
| 根文件夹
\ - src (您的源文件)
在根级别,我们将创建一个名为 raws 的新目录/文件夹。因此您的目录树应如下所示:
| 根文件夹
\ - src (您的源文件)
\ - raws
在此目录中,创建一个新文件:spawns.json。我们将暂时将所有定义放在一个文件中;这稍后会更改,但我们希望获得对我们的数据驱动野心的引导支持。在此文件中,我们将放入一些我们当前在 spawner.rs 中支持的实体的定义。我们将从几个物品开始:
{
{
"items" : [
{
"name" : "Health Potion",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "provides_healing" : "8" }
}
},
{
"name" : "Magic Missile Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20"
}
}
}
]
}
如果您不熟悉 JSON 格式,它基本上是数据的 JavaScript 转储:
- 我们用
{和}包裹文件以表示我们要加载的 对象。这将最终成为我们的Raws对象。 - 然后我们有一个名为
Items的 数组 - 它将保存我们的物品。 - 每个
Item都有一个name- 这直接映射到Name组件。 - 物品可能有一个
renderable结构,列出字形、前景色和背景色。 - 这些物品是
consumable(消耗品),我们在一个 “键/值映射” 中列出它们的效果 - 基本上是一个HashMap,就像我们以前使用过的那样,在其他语言中是一个Dictionary。
最终我们将向 spawns 列表添加更多内容,但让我们首先让这些内容起作用。
嵌入 Raw Files
在您的项目 src 目录中,创建一个新目录:src/raws。我们可以合理地预期这个模块会变得很大,因此我们将从一开始就支持将其分解成更小的部分。为了符合 Rust 构建模块的要求,在新文件夹中创建一个名为 mod.rs 的新文件:
#![allow(unused)] fn main() { rltk::embedded_resource!(RAW_FILE, "../../raws/spawns.json"); pub fn load_raws() { rltk::link_resource!(RAW_FILE, "../../raws/spawns.json"); } }
并在 main.rs 的顶部将其添加到我们使用的模块列表中:
#![allow(unused)] fn main() { pub mod raws; }
在我们的初始化中,在组件初始化之后,在您开始添加到 World 之前,添加对 load_raws 的调用:
#![allow(unused)] fn main() { ... gs.ecs.register::<Door>(); gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new()); raws::load_raws(); gs.ecs.insert(Map::new(1, 64, 64)); ... }
spawns.json 文件现在将被嵌入到您的可执行文件中,这要归功于 RLTK 的嵌入系统。
解析 raw files
这是困难的部分:我们需要一种 读取 我们创建的 JSON 文件的方法,并将其转换为我们可以在 Rust 中使用的格式。回到 mod.rs,我们可以扩展该函数以将嵌入的数据加载为字符串:
#![allow(unused)] fn main() { // 将原始数据检索为 u8(8 位无符号字符)数组 let raw_data = rltk::embedding::EMBED .lock() .unwrap() .get_resource("../../raws/spawns.json".to_string()) .unwrap(); let raw_string = std::str::from_utf8(&raw_data).expect("Unable to convert to a valid UTF-8 string."); }
如果无法找到资源,或者无法将其解析为常规字符串,这将导致 panic(崩溃)(Rust 喜欢 UTF-8 Unicode 编码,因此我们将使用它。它允许我们包含扩展字形,我们可以通过 RLTK 的 to_cp437 函数解析它们 - 因此效果很好!)。
现在我们需要实际 解析 JSON 为一些可用的东西。就像我们的 saveload.rs 系统一样,我们可以使用 Serde 来做到这一点。现在,我们只将结果转储到控制台,以便我们可以看到它 确实 做了一些事情:
#![allow(unused)] fn main() { let decoder : Raws = serde_json::from_str(&raw_string).expect("Unable to parse JSON"); rltk::console::log(format!("{:?}", decoder)); }
(看到了神秘的 {:?} 吗?这是一种打印关于结构的 调试 信息的方式)。这将编译失败,因为我们实际上还没有实现 Raws - 它正在寻找的类型。
为了清晰起见,我们将实际处理数据的类放在它们自己的文件 raws/item_structs.rs 中。这是该文件:
#![allow(unused)] fn main() { use serde::{Deserialize}; use std::collections::HashMap; #[derive(Deserialize, Debug)] pub struct Raws { pub items : Vec<Item> } #[derive(Deserialize, Debug)] pub struct Item { pub name : String, pub renderable : Option<Renderable>, pub consumable : Option<Consumable> } #[derive(Deserialize, Debug)] pub struct Renderable { pub glyph: String, pub fg : String, pub bg : String, pub order: i32 } #[derive(Deserialize, Debug)] pub struct Consumable { pub effects : HashMap<String, String> } }
在文件顶部,请确保包含 use serde::{Deserialize}; 和 use std::collections::HashMap; 以包含我们需要的类型。另请注意,我们在派生类型列表中包含了 Debug。这允许 Rust 打印结构的调试副本,以便我们可以看到代码做了什么。另请注意,很多东西都是 Option。这样,如果一个物品 没有 该条目,解析也能工作。稍后读取它们会稍微复杂一些,但我们可以忍受!
如果您现在 cargo run 项目,请忽略游戏窗口 - 观看控制台。您将看到以下内容:
Raws { items: [Item { name: "Healing Potion", renderable: Some(Renderable { glyph: "!", fg: "#FF00FF", bg: "#000000" }), consumable: Some(Consumable { effects: {"provides_healing": "8"} }) }, Item { name: "Magic Missile Scroll", renderable: Some(Renderable { glyph: ")", fg: "#00FFFF", bg: "#000000"
}), consumable: Some(Consumable { effects: {"damage": "20", "ranged": "6"} }) }] }
这 超级 丑陋且格式糟糕,但您可以看到它包含我们输入的数据!
存储和索引我们的 raw item 数据
拥有这些(主要是文本)数据很棒,但在它可以直接关联到生成实体之前,它并没有真正帮助我们。我们也在加载数据后立即丢弃了数据!
我们想要创建一个结构来保存我们所有的 raw 数据,并提供有用的服务,例如完全根据 raws 中的数据生成对象。我们将创建一个新文件 raws/rawmaster.rs:
#![allow(unused)] fn main() { use std::collections::HashMap; use specs::prelude::*; use crate::components::*; use super::{Raws}; pub struct RawMaster { raws : Raws, item_index : HashMap<String, usize> } impl RawMaster { pub fn empty() -> RawMaster { RawMaster { raws : Raws{ items: Vec::new() }, item_index : HashMap::new() } } pub fn load(&mut self, raws : Raws) { self.raws = raws; self.item_index = HashMap::new(); for (i,item) in self.raws.items.iter().enumerate() { self.item_index.insert(item.name.clone(), i); } } } }
这非常直接,并且完全在我们目前学到的 Rust 知识范围内:我们创建了一个名为 RawMaster 的结构,它获得 Raws 数据的私有副本和一个 HashMap,用于存储物品名称及其在 Raws.items 中的索引。empty 构造函数的作用正是如此:它创建了 RawMaster 结构的一个完全空的版本。load 接受反序列化的 Raws 结构,存储它,并按名称和在 items 数组中的位置索引物品。
从任何地方访问 Raw 数据
现在是 Rust 如果不使全局变量难以使用就好了的时刻之一;我们想要完全一个 RawMaster 数据的副本,并且我们希望能够从任何地方 读取 它。您 可以 通过一堆 unsafe 代码来实现这一点,但我们将成为优秀的 “Rustaceans” 并使用一种流行的方法:lazy_static。此功能不是语言本身的一部分,因此我们需要向 cargo.toml 添加一个 crate。将以下行添加到文件中的 [dependencies] 中:
lazy_static = "1.4.0"
现在我们做一点舞蹈,以使全局变量可以从任何地方安全地访问。在 main.rs 的导入部分末尾,添加:
#![allow(unused)] fn main() { #[macro_use] extern crate lazy_static; }
这与我们为其他宏所做的事情类似:它告诉 Rust 我们想从 crate lazy_static 导入宏。在 mod.rs 中,声明以下内容:
#![allow(unused)] fn main() { mod rawmaster; pub use rawmaster::*; use std::sync::Mutex; }
还有:
#![allow(unused)] fn main() { lazy_static! { pub static ref RAWS : Mutex<RawMaster> = Mutex::new(RawMaster::empty()); } }
lazy_static! 宏为我们做了很多繁重的工作,以使其安全。有趣的部分是我们仍然必须使用 Mutex。互斥锁是一种构造,可确保一次只有一个线程可以写入结构。您通过调用 lock 来访问互斥锁 - 它现在是您的,直到锁超出范围。因此,在我们的 load_raws 函数中,我们需要填充它:
#![allow(unused)] fn main() { // 将原始数据检索为 u8(8 位无符号字符)数组 let raw_data = rltk::embedding::EMBED .lock() .get_resource("../../raws/spawns.json".to_string()) .unwrap(); let raw_string = std::str::from_utf8(&raw_data).expect("Unable to convert to a valid UTF-8 string."); let decoder : Raws = serde_json::from_str(&raw_string).expect("Unable to parse JSON"); RAWS.lock().unwrap().load(decoder); }
您会注意到 RLTK 的 embedding 系统本身也在悄悄地使用 lazy_static - 这就是 lock 和 unwrap 代码的用途:它管理 Mutex。因此,对于我们的 RAWS 全局变量,我们 lock 它(检索一个作用域锁),unwrap 该锁(以允许我们访问内容),并调用我们之前编写的 load 函数。相当拗口,但现在我们可以安全地共享 RAWS 数据,而无需担心线程问题。一旦加载,我们可能永远不会再写入它 - 并且当您没有大量线程运行时,用于读取的互斥锁几乎是瞬间完成的。
从 RAWS 生成物品
在 rawmaster.rs 中,我们将创建一个新函数:
#![allow(unused)] fn main() { pub fn spawn_named_item(raws: &RawMaster, new_entity : EntityBuilder, key : &str, pos : SpawnType) -> Option<Entity> { if raws.item_index.contains_key(key) { let item_template = &raws.raws.items[raws.item_index[key]]; let mut eb = new_entity; // 在指定位置生成 match pos { SpawnType::AtPosition{x,y} => { eb = eb.with(Position{ x, y }); } } // Renderable if let Some(renderable) = &item_template.renderable { eb = eb.with(crate::components::Renderable{ glyph: rltk::to_cp437(renderable.glyph.chars().next().unwrap()), fg : rltk::RGB::from_hex(&renderable.fg).expect("Invalid RGB"), bg : rltk::RGB::from_hex(&renderable.bg).expect("Invalid RGB"), render_order : renderable.order }); } eb = eb.with(Name{ name : item_template.name.clone() }); eb = eb.with(crate::components::Item{}); if let Some(consumable) = &item_template.consumable { eb = eb.with(crate::components::Consumable{}); for effect in consumable.effects.iter() { let effect_name = effect.0.as_str(); match effect_name { "provides_healing" => { eb = eb.with(ProvidesHealing{ heal_amount: effect.1.parse::<i32>().unwrap() }) } "ranged" => { eb = eb.with(Ranged{ range: effect.1.parse::<i32>().unwrap() }) }, "damage" => { eb = eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }) } _ => { rltk::console::log(format!("Warning: consumable effect {} not implemented.", effect_name)); } } } } return Some(eb.build()); } None } }
这是一个很长的函数,但它实际上非常直接 - 并且使用了我们之前多次遇到的模式。它执行以下操作:
- 它查找我们传递的
key是否存在于item_index中。如果不存在,则返回None- 它什么也没做。 - 如果
key确实存在,则它将Name组件添加到实体 - 使用 raw 文件中的名称。 - 如果
Renderable存在于物品定义中,它将创建一个类型为Renderable的组件。 - 如果
Consumable存在于物品定义中,它会创建一个新的消耗品。它迭代effect字典中的所有键/值对,根据需要添加效果组件。
现在您可以打开 spawner.rs 并修改 spawn_entity:
#![allow(unused)] fn main() { pub fn spawn_entity(ecs: &mut World, spawn : &(&usize, &String)) { let map = ecs.fetch::<Map>(); let width = map.width as usize; let x = (*spawn.0 % width) as i32; let y = (*spawn.0 / width) as i32; std::mem::drop(map); let item_result = spawn_named_item(&RAWS.lock().unwrap(), ecs.create_entity(), &spawn.1, SpawnType::AtPosition{ x, y}); if item_result.is_some() { return; } match spawn.1.as_ref() { "Goblin" => goblin(ecs, x, y), "Orc" => orc(ecs, x, y), "Fireball Scroll" => fireball_scroll(ecs, x, y), "Confusion Scroll" => confusion_scroll(ecs, x, y), "Dagger" => dagger(ecs, x, y), "Shield" => shield(ecs, x, y), "Longsword" => longsword(ecs, x, y), "Tower Shield" => tower_shield(ecs, x, y), "Rations" => rations(ecs, x, y), "Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y), "Bear Trap" => bear_trap(ecs, x, y), "Door" => door(ecs, x, y), _ => {} } } }
请注意,我们已经删除了添加到 spawns.json 中的物品。我们也可以删除关联的函数。当我们完成时,spawner.rs 将非常小!所以这里的魔力在于它调用 spawn_named_item,使用相当丑陋的 &RAWS.lock().unwrap() 来安全访问我们的 RAWS 全局变量。如果它匹配了一个键,它将返回 Some(Entity) - 否则,我们得到 None。因此,我们检查 item_result.is_some(),如果成功从数据中生成了某些东西,则返回。否则,我们使用新代码。
您还需要将 raws::* 添加到从 super 导入的物品列表中。
如果您现在 cargo run,游戏将像以前一样运行 - 包括治疗药水和魔法飞弹卷轴。
添加其余的消耗品
我们将继续并将其余的消耗品添加到 spawns.json 中:
...
{
"name" : "Fireball Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#FFA500",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20",
"area_of_effect" : "3"
}
}
},
{
"name" : "Confusion Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20",
"confusion" : "4"
}
}
},
{
"name" : "Magic Mapping Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#AAAAFF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"magic_mapping" : ""
}
}
},
{
"name" : "Rations",
"renderable": {
"glyph" : "%",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"food" : ""
}
}
}
]
}
我们将它们的效果放入 rawmaster.rs 的 spawn_named_item 函数中:
#![allow(unused)] fn main() { if let Some(consumable) = &item_template.consumable { eb = eb.with(crate::components::Consumable{}); for effect in consumable.effects.iter() { let effect_name = effect.0.as_str(); match effect_name { "provides_healing" => { eb = eb.with(ProvidesHealing{ heal_amount: effect.1.parse::<i32>().unwrap() }) } "ranged" => { eb = eb.with(Ranged{ range: effect.1.parse::<i32>().unwrap() }) }, "damage" => { eb = eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }) } "area_of_effect" => { eb = eb.with(AreaOfEffect{ radius: effect.1.parse::<i32>().unwrap() }) } "confusion" => { eb = eb.with(Confusion{ turns: effect.1.parse::<i32>().unwrap() }) } "magic_mapping" => { eb = eb.with(MagicMapper{}) } "food" => { eb = eb.with(ProvidesFood{}) } _ => { rltk::console::log(format!("Warning: consumable effect {} not implemented.", effect_name)); } } } } }
您现在可以从 spawner.rs 中删除火球术、魔法地图和混乱卷轴了!运行游戏,您就可以访问这些物品了。希望这开始说明了将数据文件链接到组件创建的强大功能。
添加剩余的物品
我们将在 spawns.json 中再添加几个 JSON 条目,以涵盖我们剩余的各种其他物品:
{
"name" : "Dagger",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"power_bonus" : 2
}
},
{
"name" : "Longsword",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAFF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"power_bonus" : 4
}
},
{
"name" : "Shield",
"renderable": {
"glyph" : "[",
"fg" : "#00AAFF",
"bg" : "#000000",
"order" : 2
},
"shield" : {
"defense_bonus" : 1
}
},
{
"name" : "Tower Shield",
"renderable": {
"glyph" : "[",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"shield" : {
"defense_bonus" : 3
}
}
这里有两个新字段!shield 和 weapon。我们需要扩展我们的 item_structs.rs 以处理它们:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Item { pub name : String, pub renderable : Option<Renderable>, pub consumable : Option<Consumable>, pub weapon : Option<Weapon>, pub shield : Option<Shield> } ... #[derive(Deserialize, Debug)] pub struct Weapon { pub range: String, pub power_bonus: i32 } #[derive(Deserialize, Debug)] pub struct Shield { pub defense_bonus: i32 } }
我们还需要教我们的 spawn_named_item 函数(在 rawmaster.rs 中)使用这些数据:
#![allow(unused)] fn main() { if let Some(weapon) = &item_template.weapon { eb = eb.with(Equippable{ slot: EquipmentSlot::Melee }); eb = eb.with(MeleePowerBonus{ power : weapon.power_bonus }); } if let Some(shield) = &item_template.shield { eb = eb.with(Equippable{ slot: EquipmentSlot::Shield }); eb = eb.with(DefenseBonus{ defense: shield.defense_bonus }); } }
您现在也可以从 spawner.rs 中删除这些物品,它们仍然会在游戏中生成 - 和以前一样。
现在是怪物了!
我们将在 spawns.json 中添加一个新的数组来处理怪物。我们称之为 “mobs” - 这是许多游戏中 “movable object”(可移动对象)的俚语,但它已经演变成在常用语中意味着四处移动并与您战斗的东西:
"mobs" : [
{
"name" : "Orc",
"renderable": {
"glyph" : "o",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 8
},
{
"name" : "Goblin",
"renderable": {
"glyph" : "g",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 8,
"hp" : 8,
"defense" : 1,
"power" : 3
},
"vision_range" : 8
}
]
您会注意到我们正在修复之前的一个小问题:兽人和哥布林的属性不再相同!否则,这应该是有意义的:我们在 spawner.rs 中设置的属性改为在 JSON 文件中设置。我们需要创建一个新文件 raws/mob_structs.rs:
#![allow(unused)] fn main() { use serde::{Deserialize}; use super::{Renderable}; #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub stats : MobStats, pub vision_range : i32 } #[derive(Deserialize, Debug)] pub struct MobStats { pub max_hp : i32, pub hp : i32, pub power : i32, pub defense : i32 } }
我们还将修改 Raws(目前在 item_structs.rs 中)。我们将把它移动到 mod.rs,因为它与其他模块共享,并对其进行编辑:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Raws { pub items : Vec<Item>, pub mobs : Vec<Mob> } }
我们还需要修改 rawmaster.rs 以向构造函数添加一个空的 mobs 列表:
#![allow(unused)] fn main() { impl RawMaster { pub fn empty() -> RawMaster { RawMaster { raws : Raws{ items: Vec::new(), mobs: Vec::new() }, item_index : HashMap::new() } } ... }
我们还将修改 RawMaster 以索引我们的 mobs:
#![allow(unused)] fn main() { pub struct RawMaster { raws : Raws, item_index : HashMap<String, usize>, mob_index : HashMap<String, usize> } impl RawMaster { pub fn empty() -> RawMaster { RawMaster { raws : Raws{ items: Vec::new(), mobs: Vec::new() }, item_index : HashMap::new(), mob_index : HashMap::new() } } pub fn load(&mut self, raws : Raws) { self.raws = raws; self.item_index = HashMap::new(); for (i,item) in self.raws.items.iter().enumerate() { self.item_index.insert(item.name.clone(), i); } for (i,mob) in self.raws.mobs.iter().enumerate() { self.mob_index.insert(mob.name.clone(), i); } } } }
我们将要构建一个 spawn_named_mob 函数,但首先让我们创建一些助手函数,以便我们与 spawn_named_item 共享功能 - 避免重复自己。第一个非常直接:
#![allow(unused)] fn main() { fn spawn_position(pos : SpawnType, new_entity : EntityBuilder) -> EntityBuilder { let mut eb = new_entity; // 在指定位置生成 match pos { SpawnType::AtPosition{x,y} => { eb = eb.with(Position{ x, y }); } } eb } }
当我们添加更多 SpawnType 条目时,此函数必然会扩展以包含它们 - 因此它是一个函数 非常棒。我们可以用对这个函数的单个调用替换 spawn_named_item 中的相同代码:
#![allow(unused)] fn main() { // 在指定位置生成 eb = spawn_position(pos, eb); }
让我们也分离出 Renderable 数据的处理。这更困难;我在让 Rust 的生命周期检查器与实际将其添加到 EntityBuilder 的系统一起工作时遇到了 可怕的 时间。我最终确定了一个返回组件以供调用者添加的函数:
#![allow(unused)] fn main() { fn get_renderable_component(renderable : &super::item_structs::Renderable) -> crate::components::Renderable { crate::components::Renderable{ glyph: rltk::to_cp437(renderable.glyph.chars().next().unwrap()), fg : rltk::RGB::from_hex(&renderable.fg).expect("Invalid RGB"), bg : rltk::RGB::from_hex(&renderable.bg).expect("Invalid RGB"), render_order : renderable.order } } }
这仍然清理了 spawn_named_item 中的调用:
#![allow(unused)] fn main() { // Renderable if let Some(renderable) = &item_template.renderable { eb = eb.with(get_renderable_component(renderable)); } }
好的 - 有了这些,我们可以继续制作 spawn_named_mob:
#![allow(unused)] fn main() { pub fn spawn_named_mob(raws: &RawMaster, new_entity : EntityBuilder, key : &str, pos : SpawnType) -> Option<Entity> { if raws.mob_index.contains_key(key) { let mob_template = &raws.raws.mobs[raws.mob_index[key]]; let mut eb = new_entity; // 在指定位置生成 eb = spawn_position(pos, eb); // Renderable if let Some(renderable) = &mob_template.renderable { eb = eb.with(get_renderable_component(renderable)); } eb = eb.with(Name{ name : mob_template.name.clone() }); eb = eb.with(Monster{}); if mob_template.blocks_tile { eb = eb.with(BlocksTile{}); } eb = eb.with(CombatStats{ max_hp : mob_template.stats.max_hp, hp : mob_template.stats.hp, power : mob_template.stats.power, defense : mob_template.stats.defense }); eb = eb.with(Viewshed{ visible_tiles : Vec::new(), range: mob_template.vision_range, dirty: true }); return Some(eb.build()); } None } }
这个函数中真的没有什么我们还没有介绍过的:我们只是应用一个 renderable、位置、名称,使用与之前相同的代码 - 然后检查 blocks_tile 以查看是否应该添加 BlocksTile 组件,并将属性复制到 CombatStats 组件中。我们还使用 vision_range 范围设置了一个 Viewshed 组件。
在我们再次更新 spawner.rs 之前,让我们引入一个主生成方法 - spawn_named_entity。这背后的原因是生成系统实际上不知道(或不关心)实体是物品、mob 还是其他任何东西。与其在其中推送大量的 if 检查,不如提供一个单一的接口:
#![allow(unused)] fn main() { pub fn spawn_named_entity(raws: &RawMaster, new_entity : EntityBuilder, key : &str, pos : SpawnType) -> Option<Entity> { if raws.item_index.contains_key(key) { return spawn_named_item(raws, new_entity, key, pos); } else if raws.mob_index.contains_key(key) { return spawn_named_mob(raws, new_entity, key, pos); } None } }
所以在 spawner.rs 中,我们现在可以使用通用生成器:
#![allow(unused)] fn main() { let spawn_result = spawn_named_entity(&RAWS.lock().unwrap(), ecs.create_entity(), &spawn.1, SpawnType::AtPosition{ x, y}); if spawn_result.is_some() { return; } }
我们也可以继续删除对兽人、哥布林和怪物的引用!我们快完成了 - 您现在可以获得数据驱动的怪物了。
门和陷阱
还有两个剩余的硬编码实体。这些一直被单独留下,因为它们与其他类型真的不一样:它们是我所说的 “props”(物件) - 关卡特征。您无法捡起它们,但它们是关卡不可或缺的一部分。因此,在 spawns.json 中,我们将继续定义一些 props:
"props" : [
{
"name" : "Bear Trap",
"renderable": {
"glyph" : "^",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 2
},
"hidden" : true,
"entry_trigger" : {
"effects" : {
"damage" : "6",
"single_activation" : "1"
}
}
},
{
"name" : "Door",
"renderable": {
"glyph" : "+",
"fg" : "#805A46",
"bg" : "#000000",
"order" : 2
},
"hidden" : false,
"blocks_tile" : true,
"blocks_visibility" : true,
"door_open" : true
}
]
props 的问题在于它们可能非常多样化,因此我们在定义中最终会得到很多 可选的 东西。我宁愿在 Rust 端而不是 JSON 端进行复杂的定义,以减少当我们有很多 props 时的大量输入。因此,我们最终在 JSON 中创建了一些相当富有表现力的东西,并做了大量工作使其在 Rust 中起作用!我们将创建一个新文件 prop_structs.rs 并将我们的序列化类放入其中:
#![allow(unused)] fn main() { use serde::{Deserialize}; use super::{Renderable}; use std::collections::HashMap; #[derive(Deserialize, Debug)] pub struct Prop { pub name : String, pub renderable : Option<Renderable>, pub hidden : Option<bool>, pub blocks_tile : Option<bool>, pub blocks_visibility : Option<bool>, pub door_open : Option<bool>, pub entry_trigger : Option<EntryTrigger> } #[derive(Deserialize, Debug)] pub struct EntryTrigger { pub effects : HashMap<String, String> } }
我们必须告诉 raws/mod.rs 使用它:
#![allow(unused)] fn main() { mod prop_structs; use prop_structs::*; }
我们还需要扩展 Raws 以保存它们:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Raws { pub items : Vec<Item>, pub mobs : Vec<Mob>, pub props : Vec<Prop> } }
这将我们带入 rawmaster.rs,我们需要扩展构造函数和读取器以包含新类型:
#![allow(unused)] fn main() { pub struct RawMaster { raws : Raws, item_index : HashMap<String, usize>, mob_index : HashMap<String, usize>, prop_index : HashMap<String, usize> } impl RawMaster { pub fn empty() -> RawMaster { RawMaster { raws : Raws{ items: Vec::new(), mobs: Vec::new(), props: Vec::new() }, item_index : HashMap::new(), mob_index : HashMap::new(), prop_index : HashMap::new() } } pub fn load(&mut self, raws : Raws) { self.raws = raws; self.item_index = HashMap::new(); for (i,item) in self.raws.items.iter().enumerate() { self.item_index.insert(item.name.clone(), i); } for (i,mob) in self.raws.mobs.iter().enumerate() { self.mob_index.insert(mob.name.clone(), i); } for (i,prop) in self.raws.props.iter().enumerate() { self.prop_index.insert(prop.name.clone(), i); } } } }
我们还创建了一个新函数 spawn_named_prop:
#![allow(unused)] fn main() { pub fn spawn_named_prop(raws: &RawMaster, new_entity : EntityBuilder, key : &str, pos : SpawnType) -> Option<Entity> { if raws.prop_index.contains_key(key) { let prop_template = &raws.raws.props[raws.prop_index[key]]; let mut eb = new_entity; // 在指定位置生成 eb = spawn_position(pos, eb); // Renderable if let Some(renderable) = &prop_template.renderable { eb = eb.with(get_renderable_component(renderable)); } eb = eb.with(Name{ name : prop_template.name.clone() }); if let Some(hidden) = prop_template.hidden { if hidden { eb = eb.with(Hidden{}) }; } if let Some(blocks_tile) = prop_template.blocks_tile { if blocks_tile { eb = eb.with(BlocksTile{}) }; } if let Some(blocks_visibility) = prop_template.blocks_visibility { if blocks_visibility { eb = eb.with(BlocksVisibility{}) }; } if let Some(door_open) = prop_template.door_open { eb = eb.with(Door{ open: door_open }); } if let Some(entry_trigger) = &prop_template.entry_trigger { eb = eb.with(EntryTrigger{}); for effect in entry_trigger.effects.iter() { match effect.0.as_str() { "damage" => { eb = eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }) } "single_activation" => { eb = eb.with(SingleActivation{}) } _ => {} } } } return Some(eb.build()); } None } }
我们将略过内容,因为这基本上与我们之前所做的相同。我们需要扩展 spawn_named_entity 以包含 props:
#![allow(unused)] fn main() { pub fn spawn_named_entity(raws: &RawMaster, new_entity : EntityBuilder, key : &str, pos : SpawnType) -> Option<Entity> { if raws.item_index.contains_key(key) { return spawn_named_item(raws, new_entity, key, pos); } else if raws.mob_index.contains_key(key) { return spawn_named_mob(raws, new_entity, key, pos); } else if raws.prop_index.contains_key(key) { return spawn_named_prop(raws, new_entity, key, pos); } None } }
最后,我们可以进入 spawner.rs 并删除门和熊陷阱函数。我们可以完成清理 spawn_entity 函数。我们还将添加一个警告,以防您尝试生成一些我们不知道的东西:
#![allow(unused)] fn main() { /// 在 (tuple.0) 位置生成一个命名实体(tuple.1 中的名称) pub fn spawn_entity(ecs: &mut World, spawn : &(&usize, &String)) { let map = ecs.fetch::<Map>(); let width = map.width as usize; let x = (*spawn.0 % width) as i32; let y = (*spawn.0 / width) as i32; std::mem::drop(map); let spawn_result = spawn_named_entity(&RAWS.lock().unwrap(), ecs.create_entity(), &spawn.1, SpawnType::AtPosition{ x, y}); if spawn_result.is_some() { return; } rltk::console::log(format!("WARNING: We don't know how to spawn [{}]!", spawn.1)); } }
如果您现在 cargo run,您将看到门和陷阱像以前一样工作。
总结
本章使我们能够轻松更改装饰我们关卡的物品、mobs 和 props。我们尚未涉及 添加更多(或调整生成表) - 那将是下一章的内容。您现在可以快速更改游戏的特性;想要哥布林变得更弱吗?降低他们的属性!想要它们比兽人有更好的视力吗?调整它们的视野范围!这就是数据驱动方法的主要好处:您可以快速进行更改,而无需深入研究源代码。引擎 负责 模拟世界 - 而 数据 负责 描述世界。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
数据驱动的生成表
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您能喜欢本教程,并制作出伟大的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持 我的 Patreon。
在上一章节中,我们将生成机制改为数据驱动:您在 JSON 数据文件中定义怪物、物品和道具 - 生成函数变成了一个解析器,它根据您的定义构建组件。这使您在数据驱动世界的道路上前进了一半。
如果您查看不断缩小的 spawner.rs 文件,我们会发现一个硬编码的表用于处理生成:
#![allow(unused)] fn main() { fn room_table(map_depth: i32) -> RandomTable { RandomTable::new() .add("Goblin", 10) .add("Orc", 1 + map_depth) .add("Health Potion", 7) .add("Fireball Scroll", 2 + map_depth) .add("Confusion Scroll", 2 + map_depth) .add("Magic Missile Scroll", 4) .add("Dagger", 3) .add("Shield", 3) .add("Longsword", map_depth - 1) .add("Tower Shield", map_depth - 1) .add("Rations", 10) .add("Magic Mapping Scroll", 2) .add("Bear Trap", 5) } }
它在之前的章节中为我们提供了很好的服务,但遗憾的是,现在是时候让它退休了。我们希望能够在 JSON 数据中指定生成表 - 这样,我们可以向数据文件和生成列表添加新的实体,并且它们会出现在游戏中,而无需额外的 Rust 编码(除非它们需要新功能,在这种情况下,就需要扩展引擎)。
基于 JSON 的生成表
这是一个我设想的生成表示例:
"spawn_table" : [
{ "name" : "Goblin", "weight" : 10, "min_depth" : 0, "max_depth" : 100 }
],
所以 spawn_table 是一个数组,每个条目包含可以生成的东西。我们存储了可生成物的 name (名称)。我们给它一个 weight (权重),这与我们当前 RandomTable 结构中的相同字段相对应。我们添加了 min_depth (最小深度) 和 max_depth (最大深度) - 因此此生成行将仅应用于地下城的指定深度范围。
这看起来不错,所以让我们把我们所有的实体都放进去:
"spawn_table" : [
{ "name" : "Goblin", "weight" : 10, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Orc", "weight" : 1, "min_depth" : 0, "max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Health Potion", "weight" : 7, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Fireball Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Confusion Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Magic Missile Scroll", "weight" : 4, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Dagger", "weight" : 3, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Shield", "weight" : 3, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Longsword", "weight" : 1, "min_depth" : 1, "max_depth" : 100 },
{ "name" : "Tower Shield", "weight" : 1, "min_depth" : 1, "max_depth" : 100 },
{ "name" : "Rations", "weight" : 10, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Magic Mapping Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Bear Trap", "weight" : 5, "min_depth" : 0, "max_depth" : 100 }
],
请注意,我们添加了 add_map_depth_to_weight,以便我们可以指示事物在游戏后期变得更有可能出现。这使我们能够保持可变权重的能力。我们还将 longsword (长剑) 和 tower shield (塔盾) 设置为仅在第一层之后出现。
这非常全面(涵盖了我们目前拥有的所有内容,并增加了一些功能),所以让我们在 raws 中创建一个新文件 spawn_table_structs,并定义读取这些数据所需的类:
#![allow(unused)] fn main() { use serde::{Deserialize}; use super::{Renderable}; #[derive(Deserialize, Debug)] pub struct SpawnTableEntry { pub name : String, pub weight : i32, pub min_depth: i32, pub max_depth: i32, pub add_map_depth_to_weight : Option<bool> } }
打开 raws/mod.rs,我们会将其添加到 Raws 结构中:
#![allow(unused)] fn main() { mod spawn_table_structs; use spawn_table_structs::*; ... #[derive(Deserialize, Debug)] pub struct Raws { pub items : Vec<Item>, pub mobs : Vec<Mob>, pub props : Vec<Prop>, pub spawn_table : Vec<SpawnTableEntry> } }
我们还需要将其添加到 rawmaster.rs 中的构造函数中:
#![allow(unused)] fn main() { pub fn empty() -> RawMaster { RawMaster { raws : Raws{ items: Vec::new(), mobs: Vec::new(), props: Vec::new(), spawn_table: Vec::new() }, item_index : HashMap::new(), mob_index : HashMap::new(), prop_index : HashMap::new(), } } }
现在值得快速运行 cargo run,以确保生成表加载时没有错误。它现在还不会 做 任何事情,但总是很高兴知道数据加载正确。
使用新的生成表
在 rawmaster.rs 中,我们将添加一个新函数,用于从我们的 JSON 数据构建随机生成表:
#![allow(unused)] fn main() { pub fn get_spawn_table_for_depth(raws: &RawMaster, depth: i32) -> RandomTable { use super::SpawnTableEntry; let available_options : Vec<&SpawnTableEntry> = raws.raws.spawn_table .iter() .filter(|a| depth >= a.min_depth && depth <= a.max_depth) .collect(); let mut rt = RandomTable::new(); for e in available_options.iter() { let mut weight = e.weight; if e.add_map_depth_to_weight.is_some() { weight += depth; } rt = rt.add(e.name.clone(), weight); } rt } }
这个函数非常简单:
- 我们获取
raws.raws.spawn_table- 这是主生成表列表。 - 我们使用
iter()获取一个迭代器。 - 我们使用
filter仅包含在请求的地图深度范围内的项目。 - 我们
collect()将其收集到一个SpawnTableEntry行的引用向量中。 - 我们迭代所有收集到的可用选项:
- 我们获取权重。
- 如果条目具有“增加地图深度到权重”的要求,我们将该深度添加到该条目的权重。
- 我们将其添加到我们的
RandomTable中。
非常直接!我们可以打开 spawner.rs 并修改我们的 RoomTable 函数以使用它:
#![allow(unused)] fn main() { fn room_table(map_depth: i32) -> RandomTable { get_spawn_table_for_depth(&RAWS.lock().unwrap(), map_depth) } }
哇,这是一个简短的函数!但它完成了工作。如果您现在 cargo run,您将像以前一样玩游戏。
添加一些健全性检查
我们现在已经具备了添加实体而无需接触我们的 Rust 代码的能力!在我们探索这一点之前,让我们看看向系统添加一些“健全性检查”以帮助避免错误。我们只需更改 rawmaster.rs 中的 load 函数:
#![allow(unused)] fn main() { pub fn load(&mut self, raws : Raws) { self.raws = raws; self.item_index = HashMap::new(); let mut used_names : HashSet<String> = HashSet::new(); for (i,item) in self.raws.items.iter().enumerate() { if used_names.contains(&item.name) { rltk::console::log(format!("WARNING - duplicate item name in raws [{}]", item.name)); } self.item_index.insert(item.name.clone(), i); used_names.insert(item.name.clone()); } for (i,mob) in self.raws.mobs.iter().enumerate() { if used_names.contains(&mob.name) { rltk::console::log(format!("WARNING - duplicate mob name in raws [{}]", mob.name)); } self.mob_index.insert(mob.name.clone(), i); used_names.insert(mob.name.clone()); } for (i,prop) in self.raws.props.iter().enumerate() { if used_names.contains(&prop.name) { rltk::console::log(format!("WARNING - duplicate prop name in raws [{}]", prop.name)); } self.prop_index.insert(prop.name.clone(), i); used_names.insert(prop.name.clone()); } for spawn in self.raws.spawn_table.iter() { if !used_names.contains(&spawn.name) { rltk::console::log(format!("WARNING - Spawn tables references unspecified entity {}", spawn.name)); } } } }
我们在这里做什么?我们创建一个 used_names 作为 HashSet。每当我们加载某些东西时,我们都会将其添加到集合中。如果它已经存在?那么我们已经制作了重复项,并且会发生不好的事情 - 所以我们警告用户。然后我们迭代生成表,如果我们引用了尚未定义的实体名称 - 我们再次警告用户。
这些类型的数据输入错误很常见,并且实际上不会使程序崩溃。这种健全性检查确保我们至少在继续认为一切都很好之前收到警告。如果您有偏执狂(在编程时,这实际上是一个很好的特质;有很多人想抓住您!),您可以将 println! 替换为 panic! 并崩溃,而不是仅仅提醒用户。如果您喜欢经常 cargo run 以查看您的进展,您可能不想这样做!
从数据驱动架构中受益
让我们快速向游戏中添加一种新武器和一个新怪物。我们可以做到这一点,而无需接触 Rust 代码,只需重新编译(嵌入更改的文件)即可。在 spawns.json 中,让我们在武器列表中添加一个 Battleaxe (战斧):
{
"name" : "Battleaxe",
"renderable": {
"glyph" : "¶",
"fg" : "#FF55FF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"power_bonus" : 5
}
},
我们也会将其添加到生成表中:
{ "name" : "Battleaxe", "weight" : 1, "min_depth" : 2, "max_depth" : 100 }
让我们也添加一个卑微的 kobold (狗头人)。它基本上是一个更弱的 goblin (哥布林)。我们喜欢狗头人,让我们多要点!
{
"name" : "Kobold",
"renderable": {
"glyph" : "k",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 4,
"hp" : 4,
"defense" : 0,
"power" : 2
},
"vision_range" : 4
}
所以我们也会将这个小生物添加到生成列表中:
{ "name" : "Kobold", "weight" : 15, "min_depth" : 0, "max_depth" : 3 }
请注意,我们让他们 非常 常见 - 并在 3 级之后停止用它们骚扰玩家。
如果您现在 cargo run 该项目,您将在游戏中找到新实体:
.
总结
生成表就到此为止了!在过去的两个章节中,您获得了相当大的力量 - 请明智地使用它。您现在可以添加各种实体,而无需编写一行 Rust 代码,并且可以轻松地开始将游戏塑造成您想要的样子。在下一章中,我们将开始这样做。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
制作起始城镇
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
城镇的用途是什么?
回到 设计文档 我们决定:游戏开始于城镇。在城镇中,只有极少的敌人(扒手、暴徒)。你将在一个尚未命名的酒馆(tavern)中开始游戏,身上只有少量的钱袋,最少的初始装备,一杯啤酒,一根干香肠,一个背包和宿醉。城镇允许你访问各种供应商。
从开发的角度来看,这告诉我们几件事:
- 城镇具有故事层面,因为你在那里开始游戏,它奠定了故事的基础 - 给出了一个起点,一个命运(在这种情况下是醉酒后拯救世界的承诺)。因此,城镇暗示了一个特定的舒适的起点,暗示了一些交流来帮助你理解为什么你要踏上冒险者的生活,等等。
- 城镇有供应商。 在这一点上,这可能没有意义,因为我们还没有价值/货币系统 - 但我们知道我们需要一个地方来安置他们。
- 城镇有一个酒馆/客栈/酒吧 - 这是一个起始位置,但它显然足够重要,需要做一些事情!
- 在设计文档的其他地方,我们提到你可以城镇传送门返回到定居点。 这再次暗示了某种舒适/安全感,也暗示了这样做是有用的 - 因此城镇提供的服务需要在整个游戏中保持其效用。
- 最后,城镇是获胜条件:一旦你拿到了亚拉护身符 - 返回城镇就可以拯救世界。 这意味着城镇应该有一些神圣的结构,你必须将护身符归还给它。
- 城镇是新玩家将遇到的第一件事 - 因此它必须看起来生机勃勃且有点流畅,否则玩家只会关闭窗口并尝试其他游戏。 它也可能作为一些教程的地点。
这种讨论对于游戏设计至关重要; 你不希望仅仅因为你能做到就实现某些东西(在大多数情况下;大型开放世界游戏在这方面有所放松)。城镇有目的,而这个目的指导着它的设计。
那么城镇中必须包含什么?
因此,讨论让我们确定城镇必须包含:
- 一个或多个商人。 我们尚未实现商品的销售,但他们需要一个经营场所。
- 一些友善/中立的 NPC 来增添色彩。
- 一座神庙。
- 一家酒馆。
- 城镇传送门到达的地点。
- 一条通往冒险起点的道路。
我们还可以稍微思考一下是什么构成了城镇:
- 通常有一条交通路线(陆路或海路),否则城镇将不会繁荣。
- 通常,会有一个市场(周围的村庄使用城镇进行商业活动)。
- 几乎可以肯定的是,要么有一条河流,要么有一个深层的天然水源。
- 城镇通常有权威人物,至少以警卫或守望者的形式出现。
- 城镇通常也有阴暗面。
我们希望如何生成我们的城镇?
我们可以选择预制城镇。 这样做的好处是,城镇可以进行调整,直到恰到好处,并且运行流畅。 缺点是,在最初几次游戏流程(“runs”)之后,离开城镇变成了一个纯粹的机械步骤; 看看《卡瓦拉的洞穴》(Caves of Qud)中的约帕(Joppa)——它几乎变成了一个“拿走箱子里的东西,和这些人谈话,然后出发”的速度障碍,才进入一个令人惊叹的游戏。
所以——我们想要一个程序生成的城镇,但我们希望保持它的功能性——并使其美观。 要求不多!
制作一些新的瓦片类型
从上面来看,听起来我们需要一些新的瓦片。 城镇中想到的瓦片类型是道路、草地、水(深水和浅水)、桥梁、木地板和建筑墙壁。 我们可以肯定一件事:随着我们的进展,我们将添加大量新的瓦片类型,所以我们最好花时间预先使其成为无缝体验!
如果不小心,map.rs 可能会变得非常复杂,所以让我们把它变成一个独立的模块,带有一个目录。 我们首先创建一个目录 map/。 然后我们将 map.rs 移动到其中,并将其重命名为 mod.rs。 现在,我们将 TileType 从 mod.rs 中取出,并将其放入一个新文件 - tiletype.rs:
#![allow(unused)] fn main() { use serde::{Serialize, Deserialize}; #[derive(PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Floor, DownStairs } }
在 mod.rs 中,我们将接受该模块并共享它公开的公共类型:
#![allow(unused)] fn main() { mod tiletype; pub use tiletype::TileType; }
这目前并没有给我们带来太多好处……但现在我们可以开始支持各种瓦片类型了。 随着我们添加功能,您有望明白为什么使用单独的文件可以更容易地找到相关代码:
#![allow(unused)] fn main() { #[derive(PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Floor, DownStairs, Road, Grass, ShallowWater, DeepWater, WoodFloor, Bridge } }
这只是整个情况的一部分,因为现在我们需要处理一堆繁琐的工作:你是否可以进入这种类型的瓦片,它们是否会阻挡视野,它们是否具有不同的寻路成本等等。 我们还在地图生成器中做了很多“如果是地板就生成”的代码; 如果你可以有多种地板类型,也许那不是一个好主意? 无论如何,当前的 map.rs 提供了一些我们需要的东西,以便满足 RLTK 的 BaseMap 特征。
我们将编写一些函数来帮助满足此要求,同时将我们的瓦片功能保持在一个地方:
#![allow(unused)] fn main() { pub fn tile_walkable(tt : TileType) -> bool { match tt { TileType::Floor | TileType::DownStairs | TileType::Road | TileType::Grass | TileType::ShallowWater | TileType::WoodFloor | TileType::Bridge => true, _ => false } } pub fn tile_opaque(tt : TileType) -> bool { match tt { TileType::Wall => true, _ => false } } }
现在我们将回到 mod.rs,并导入这些 - 并使它们对任何想要使用它们的人公开:
#![allow(unused)] fn main() { mod tiletype; pub use tiletype::{TileType, tile_walkable, tile_opaque}; }
我们还需要更新我们的一些函数以使用此功能。 我们使用 blocked 系统确定了很多寻路,所以我们需要更新 populate_blocked 以使用我们刚刚创建的函数来处理各种类型:
#![allow(unused)] fn main() { pub fn populate_blocked(&mut self) { for (i,tile) in self.tiles.iter_mut().enumerate() { self.blocked[i] = !tile_walkable(*tile); } } }
我们还需要更新我们的视野确定代码:
#![allow(unused)] fn main() { impl BaseMap for Map { fn is_opaque(&self, idx:i32) -> bool { let idx_u = idx as usize; if idx_u > 0 && idx_u < self.tiles.len() { tile_opaque(self.tiles[idx_u]) || self.view_blocked.contains(&idx_u) } else { true } } ... }
最后,让我们看看 get_available_exits。 这使用 blocked 系统来确定出口是否可能,但到目前为止,我们已经硬编码了我们所有的成本。 当只有地板和墙壁可供选择时,这毕竟是一个非常容易的选择! 一旦我们开始提供选择,我们可能希望鼓励某些行为。 如果人们更喜欢在道路上而不是草地上行走,并且绝对更现实的是,除非他们需要,否则他们会避免站在浅水中,那肯定看起来更真实。 所以我们将构建一个成本函数(在 tiletype.rs 中):
#![allow(unused)] fn main() { pub fn tile_cost(tt : TileType) -> f32 { match tt { TileType::Road => 0.8, TileType::Grass => 1.1, TileType::ShallowWater => 1.2, _ => 1.0 } } }
然后我们更新我们的 get_available_exits 以使用它:
#![allow(unused)] fn main() { fn get_available_exits(&self, idx:i32) -> Vec<(i32, f32)> { let mut exits : Vec<(i32, f32)> = Vec::new(); let x = idx % self.width; let y = idx / self.width; let tt = self.tiles[idx as usize]; // Cardinal directions // 基数方向 if self.is_exit_valid(x-1, y) { exits.push((idx-1, tile_cost(tt))) }; if self.is_exit_valid(x+1, y) { exits.push((idx+1, tile_cost(tt))) }; if self.is_exit_valid(x, y-1) { exits.push((idx-self.width, tile_cost(tt))) }; if self.is_exit_valid(x, y+1) { exits.push((idx+self.width, tile_cost(tt))) }; // Diagonals // 对角线方向 if self.is_exit_valid(x-1, y-1) { exits.push(((idx-self.width)-1, tile_cost(tt) * 1.45)); } if self.is_exit_valid(x+1, y-1) { exits.push(((idx-self.width)+1, tile_cost(tt) * 1.45)); } if self.is_exit_valid(x-1, y+1) { exits.push(((idx+self.width)-1, tile_cost(tt) * 1.45)); } if self.is_exit_valid(x+1, y+1) { exits.push(((idx+self.width)+1, tile_cost(tt) * 1.45)); } exits } }
我们将所有成本 1.0 替换为对 tile_cost 函数的调用,并将对角线成本乘以 1.45,以鼓励更自然的移动。
修复我们的相机
我们还需要能够渲染这些瓦片类型,所以我们打开 camera.rs 并将它们添加到 get_tile_glyph 中的 match 语句中:
#![allow(unused)] fn main() { fn get_tile_glyph(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { let glyph; let mut fg; let mut bg = RGB::from_f32(0., 0., 0.); match map.tiles[idx] { TileType::Floor => { glyph = rltk::to_cp437('.'); fg = RGB::from_f32(0.0, 0.5, 0.5); } TileType::WoodFloor => { glyph = rltk::to_cp437('.'); fg = RGB::named(rltk::CHOCOLATE); } TileType::Wall => { let x = idx as i32 % map.width; let y = idx as i32 / map.width; glyph = wall_glyph(&*map, x, y); fg = RGB::from_f32(0., 1.0, 0.); } TileType::DownStairs => { glyph = rltk::to_cp437('>'); fg = RGB::from_f32(0., 1.0, 1.0); } TileType::Bridge => { glyph = rltk::to_cp437('.'); fg = RGB::named(rltk::CHOCOLATE); } TileType::Road => { glyph = rltk::to_cp437('~'); fg = RGB::named(rltk::GRAY); } TileType::Grass => { glyph = rltk::to_cp437('"'); fg = RGB::named(rltk::GREEN); } TileType::ShallowWater => { glyph = rltk::to_cp437('≈'); fg = RGB::named(rltk::CYAN); } TileType::DeepWater => { glyph = rltk::to_cp437('≈'); fg = RGB::named(rltk::NAVY_BLUE); } } if map.bloodstains.contains(&idx) { bg = RGB::from_f32(0.75, 0., 0.); } if !map.visible_tiles[idx] { fg = fg.to_greyscale(); bg = RGB::from_f32(0., 0., 0.); // Don't show stains out of visual range // 不显示视野范围外的血迹 } (glyph, fg, bg) } }
开始构建我们的城镇
我们希望停止随机生成地图,而是开始对我们制作的内容进行一些预测。 所以当你开始深度 1 时,你总是得到一个城镇。 在 map_builders/mod.rs 中,我们将创建一个新函数。 现在,它只会回退到随机:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { random_builder(new_depth, rng, width, height) } }
跳转到 main.rs 并更改构建器函数调用以使用我们的新函数:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32) { self.mapgen_index = 0; self.mapgen_timer = 0.0; self.mapgen_history.clear(); let mut rng = self.ecs.write_resource::<rltk::RandomNumberGenerator>(); let mut builder = map_builders::level_builder(new_depth, &mut rng, 80, 50); ... }
现在,我们将开始充实我们的 level_builder; 我们希望深度 1 生成城镇地图 - 否则,我们暂时坚持使用随机。 我们还希望通过 match 语句清楚地了解我们如何路由每个级别的程序生成:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, rng, width, height), _ => random_builder(new_depth, rng, width, height) } } }
在 mod.rs 文件的顶部,添加:
#![allow(unused)] fn main() { mod town; use town::town_builder; }
在一个新文件 map_builders/town.rs 中,我们将开始我们的函数:
#![allow(unused)] fn main() { use super::BuilderChain; pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height); chain.start_with(TownBuilder::new()); let (start_x, start_y) = super::random_start_position(rng); chain.with(AreaStartingPosition::new(start_x, start_y)); chain.with(DistantExit::new()); chain } }
AreaStartingPosition 和 DistantExit 是临时的,用于获得有效的起点/终点。 重点是对 TownBuilder 的调用。 我们还没有编写它,所以我们将逐步完成,直到我们拥有一个我们喜欢的城镇!
这是一个空的骨架开始:
#![allow(unused)] fn main() { pub struct TownBuilder {} impl InitialMapBuilder for TownBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build_rooms(rng, build_data); } } impl TownBuilder { pub fn new() -> Box<TownBuilder> { Box::new(TownBuilder{}) } pub fn build_rooms(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { } } }
让我们制作一个渔村
让我们从在该区域添加草地、水和码头开始。 我们将首先编写骨架:
#![allow(unused)] fn main() { pub fn build_rooms(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.grass_layer(build_data); self.water_and_piers(rng, build_data); // Make visible for screenshot // 为截图设置为可见 for t in build_data.map.visible_tiles.iter_mut() { *t = true; } build_data.take_snapshot(); } }
函数 grass_layer 非常简单:我们将所有内容替换为草地:
#![allow(unused)] fn main() { fn grass_layer(&mut self, build_data : &mut BuilderMap) { // We'll start with a nice layer of grass // 我们将从一个漂亮的草地层开始 for t in build_data.map.tiles.iter_mut() { *t = TileType::Grass; } build_data.take_snapshot(); } }
添加水更有趣。 我们不希望每次都一样,但我们希望保持相同的基本结构。 这是代码:
#![allow(unused)] fn main() { fn water_and_piers(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { let mut n = (rng.roll_dice(1, 65535) as f32) / 65535f32; let mut water_width : Vec<i32> = Vec::new(); for y in 0..build_data.height { let n_water = (f32::sin(n) * 10.0) as i32 + 14 + rng.roll_dice(1, 6); water_width.push(n_water); n += 0.1; for x in 0..n_water { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::DeepWater; } for x in n_water .. n_water+3 { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::ShallowWater; } } build_data.take_snapshot(); // Add piers // 添加码头 for _i in 0..rng.roll_dice(1, 4)+6 { let y = rng.roll_dice(1, build_data.height)-1; for x in 2 + rng.roll_dice(1, 6) .. water_width[y as usize] + 4 { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::WoodFloor; } } build_data.take_snapshot(); } }
这里发生了很多事情,所以我们将逐步介绍:
- 我们通过掷一个 65,535 面的骰子(如果存在这样的骰子不是很好吗?)并将结果除以最大数,使
n等于一个介于0.0和1.0之间的随机浮点数。 - 我们创建一个名为
water_width的新向量。 我们将在其中存储每行水瓦片的数量,因为我们在生成它们。 - 对于地图上的每一行
y:- 我们创建
n_water。 这是存在的瓦片数量。 我们首先取n的sin(正弦)(我们随机化了它以给出随机梯度)。 正弦波很棒,它们给出了一个很好的可预测曲线,你可以沿着它们读取任何位置来确定曲线的位置。 由于sin给出的数字从 -1 到 1,我们乘以 10 以给出 -10 到 +10。 然后我们加上 14,保证 4 到 24 个水瓦片。 为了使其看起来不规则,我们还添加了一点随机性。 - 我们将它
push到water_width向量中,存储起来以供以后使用。 - 我们将
0.1添加到n,沿着正弦波前进。 - 然后我们从 0 迭代到
n_water(作为x),并将DeepWater瓦片写入每个水瓦片的位置。 - 我们从
n_water到n_water+3添加一些边缘的浅水。
- 我们创建
- 我们拍摄快照,以便您可以观看地图的进程。
- 我们从 0 迭代到 1d4+6,生成 10 到 14 个码头。
- 我们随机选择
y。 - 我们查找该
y值的水的放置位置,并从 2+1d6 到water_width[y]+4开始绘制木地板 - 给出一个码头,该码头延伸到水中一段距离,并方正地结束在陆地上。
- 我们随机选择
如果您 cargo run,您现在将看到像这样的地图:

添加城镇围墙、碎石路和道路
现在我们有了一些地形,我们应该为城镇添加一些初始轮廓。 使用另一个函数调用扩展 build 函数:
#![allow(unused)] fn main() { let (mut available_building_tiles, wall_gap_y) = self.town_walls(rng, build_data); }
该函数如下所示:
#![allow(unused)] fn main() { fn town_walls(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) -> (HashSet<usize>, i32) { let mut available_building_tiles : HashSet<usize> = HashSet::new(); let wall_gap_y = rng.roll_dice(1, build_data.height - 9) + 5; for y in 1 .. build_data.height-2 { if !(y > wall_gap_y-4 && y < wall_gap_y+4) { let idx = build_data.map.xy_idx(30, y); build_data.map.tiles[idx] = TileType::Wall; build_data.map.tiles[idx-1] = TileType::Floor; let idx_right = build_data.map.xy_idx(build_data.width - 2, y); build_data.map.tiles[idx_right] = TileType::Wall; for x in 31 .. build_data.width-2 { let gravel_idx = build_data.map.xy_idx(x, y); build_data.map.tiles[gravel_idx] = TileType::Gravel; if y > 2 && y < build_data.height-1 { available_building_tiles.insert(gravel_idx); } } } else { for x in 30 .. build_data.width { let road_idx = build_data.map.xy_idx(x, y); build_data.map.tiles[road_idx] = TileType::Road; } } } build_data.take_snapshot(); for x in 30 .. build_data.width-1 { let idx_top = build_data.map.xy_idx(x, 1); build_data.map.tiles[idx_top] = TileType::Wall; let idx_bot = build_data.map.xy_idx(x, build_data.height-2); build_data.map.tiles[idx_bot] = TileType::Wall; } build_data.take_snapshot(); (available_building_tiles, wall_gap_y) } }
同样,让我们逐步了解它是如何工作的:
- 我们创建一个名为
available_building_tiles的新HashSet。 我们将返回它,以便其他函数以后可以使用它。 - 我们将
wall_gap_y设置为地图上的一个随机y位置,介于 6 和map.height - 8之间。 我们将使用它来确定穿过城镇的道路的位置,以及城墙上的大门。 - 我们在地图上迭代
y轴,跳过最顶部和最底部的瓦片。- 如果
y在“墙壁间隙”之外(以wall_gap_y为中心的 8 个瓦片):- 我们在位置
30,y绘制一个墙瓦,在29,y绘制一条道路。 这在海岸线后方给出了一个墙壁,并在其前方留出了明显的间隙(显然他们有草坪管理人员!) - 我们还在地图的最东端绘制了一堵墙。
- 我们用砾石填充中间区域。
- 对于获得砾石的瓦片,我们将其添加到
available_building_tiles集合中。
- 我们在位置
- 如果它在间隙中,我们绘制一条道路。
- 如果
- 最后,我们用墙壁填充 30 和
width-2之间的行1和height-2。
如果您现在 cargo run,您将拥有城镇的轮廓:

添加一些建筑物
一个没有建筑物的城镇既相当无意义又相当不寻常! 所以让我们添加一些。 我们将向构建器函数添加另一个调用,这次传递我们创建的 available_building_tiles 结构:
#![allow(unused)] fn main() { let mut buildings = self.buildings(rng, build_data, &mut available_building_tiles); }
建筑物代码的核心如下所示:
#![allow(unused)] fn main() { fn buildings(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap, available_building_tiles : &mut HashSet<usize>) -> Vec<(i32, i32, i32, i32)> { let mut buildings : Vec<(i32, i32, i32, i32)> = Vec::new(); let mut n_buildings = 0; while n_buildings < 12 { let bx = rng.roll_dice(1, build_data.map.width - 32) + 30; let by = rng.roll_dice(1, build_data.height)-2; let bw = rng.roll_dice(1, 8)+4; let bh = rng.roll_dice(1, 8)+4; let mut possible = true; for y in by .. by+bh { for x in bx .. bx+bw { if x < 0 || x > build_data.width-1 || y < 0 || y > build_data.height-1 { possible = false; } else { let idx = build_data.map.xy_idx(x, y); if !available_building_tiles.contains(&idx) { possible = false; } } } } if possible { n_buildings += 1; buildings.push((bx, by, bw, bh)); for y in by .. by+bh { for x in bx .. bx+bw { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::WoodFloor; available_building_tiles.remove(&idx); available_building_tiles.remove(&(idx+1)); available_building_tiles.remove(&(idx+build_data.width as usize)); available_building_tiles.remove(&(idx-1)); available_building_tiles.remove(&(idx-build_data.width as usize)); } } build_data.take_snapshot(); } } // Outline buildings // 建筑物轮廓 let mut mapclone = build_data.map.clone(); for y in 2..build_data.height-2 { for x in 32..build_data.width-2 { let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] == TileType::WoodFloor { let mut neighbors = 0; if build_data.map.tiles[idx - 1] != TileType::WoodFloor { neighbors +=1; } if build_data.map.tiles[idx + 1] != TileType::WoodFloor { neighbors +=1; } if build_data.map.tiles[idx-build_data.width as usize] != TileType::WoodFloor { neighbors +=1; } if build_data.map.tiles[idx+build_data.width as usize] != TileType::WoodFloor { neighbors +=1; } if neighbors > 0 { mapclone.tiles[idx] = TileType::Wall; } } } } build_data.map = mapclone; build_data.take_snapshot(); buildings } }
再次,让我们逐步了解此算法:
- 我们创建一个元组向量,每个元组包含 4 个整数。 这些是建筑物的
x和y坐标,以及它在每个维度上的大小。 - 我们创建一个变量
n_buildings来存储我们放置了多少建筑物,并循环直到我们有 12 个。 对于每个建筑物:- 我们为建筑物选择一个随机的
x和y位置,以及一个随机的width和height。 - 我们将
possible设置为true- 然后循环遍历候选建筑物位置中的每个瓦片。 如果它不在available_building_tiles集合中,我们将possible设置为false。 - 如果
possible仍然为真,我们再次循环遍历每个瓦片 - 设置为WoodenFloor。 然后,我们从available_building_tiles列表中删除该瓦片以及所有四个周围的瓦片 - 确保建筑物之间有间隙。 我们还递增n_buildings,并将建筑物添加到已完成建筑物的列表中。
- 我们为建筑物选择一个随机的
- 现在我们有 12 个建筑物,我们复制一份地图。
- 我们循环遍历地图“城镇”部分中的每个瓦片。
- 对于每个瓦片,我们计算不是
WoodenFloor的相邻瓦片数量(在所有四个方向上)。 - 如果相邻瓦片计数大于零,那么我们可以在此处放置墙壁(因为它必须是建筑物的边缘)。 我们写入地图的副本 - 以免影响对后续瓦片的检查(否则,您将看到建筑物被墙壁替换)。
- 对于每个瓦片,我们计算不是
- 我们将副本放回我们的地图中。
- 我们返回已放置建筑物的列表。
如果您现在 cargo run,您将看到我们有建筑物了!

添加一些门
建筑物很棒,但是没有门。 所以你永远无法进入或离开它们。 我们应该修复这个问题。 使用另一个调用扩展构建器函数:
#![allow(unused)] fn main() { let doors = self.add_doors(rng, build_data, &mut buildings, wall_gap_y); }
add_doors 函数如下所示:
#![allow(unused)] fn main() { fn add_doors(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap, buildings: &mut Vec<(i32, i32, i32, i32)>, wall_gap_y : i32) -> Vec<usize> { let mut doors = Vec::new(); for building in buildings.iter() { let door_x = building.0 + 1 + rng.roll_dice(1, building.2 - 3); let cy = building.1 + (building.3 / 2); let idx = if cy > wall_gap_y { // Door on the north wall // 北墙上的门 build_data.map.xy_idx(door_x, building.1) } else { build_data.map.xy_idx(door_x, building.1 + building.3 - 1) }; build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Door".to_string())); doors.push(idx); } build_data.take_snapshot(); doors } }
此函数非常简单,但我们将逐步介绍它:
- 我们创建一个新的门位置向量; 我们稍后需要它。
- 对于建筑物列表中的每个建筑物:
- 将
door_x设置为建筑物水平侧面的一个随机点,不包括角。 - 计算
cy为建筑物的中心。 - 如果
cy > wall_gap_y(还记得那个吗?道路在哪里!),我们将门 的y坐标放置在北侧 - 即building.1。 否则,我们将其放置在南侧 -building.1 + building.3 - 1(y位置加上高度,减一)。 - 我们将门瓦片设置为
Floor。 - 我们将一个
Door添加到生成列表中。 - 我们将门添加到 doors 向量中。
- 将
- 我们返回 doors 向量。
如果您现在 cargo run,您将看到每栋建筑物都出现门:

通往门的路径
用一些通往城镇中各个门的路径来装饰砾石路会很好。 这是有道理的——即使是步行往返建筑物造成的磨损也会侵蚀出一条路径。 因此,我们向构建器函数添加另一个调用:
#![allow(unused)] fn main() { self.add_paths(build_data, &doors); }
add_paths 函数有点长,但非常简单:
#![allow(unused)] fn main() { fn add_paths(&mut self, build_data : &mut BuilderMap, doors : &[usize]) { let mut roads = Vec::new(); for y in 0..build_data.height { for x in 0..build_data.width { let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] == TileType::Road { roads.push(idx); } } } build_data.map.populate_blocked(); for door_idx in doors.iter() { let mut nearest_roads : Vec<(usize, f32)> = Vec::new(); let door_pt = rltk::Point::new( *door_idx as i32 % build_data.map.width as i32, *door_idx as i32 / build_data.map.width as i32 ); for r in roads.iter() { nearest_roads.push(( *r, rltk::DistanceAlg::PythagorasSquared.distance2d( door_pt, rltk::Point::new( *r as i32 % build_data.map.width, *r as i32 / build_data.map.width ) ) )); } nearest_roads.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); let destination = nearest_roads[0].0; let path = rltk::a_star_search(*door_idx, destination, &mut build_data.map); if path.success { for step in path.steps.iter() { let idx = *step as usize; build_data.map.tiles[idx] = TileType::Road; roads.push(idx); } } build_data.take_snapshot(); } } }
让我们逐步了解:
- 我们首先创建一个
roads向量,存储地图上每个道路瓦片的地图索引。 我们通过快速扫描地图并将匹配的瓦片添加到我们的列表中来收集此信息。 - 然后我们遍历我们放置的所有门:
- 我们创建另一个向量 (
nearest_roads),其中包含索引和一个浮点数。 - 我们添加每条道路,以及它的索引以及到门的计算距离。
- 我们按距离对
nearest_roads向量进行排序,确保元素0将是最接近的道路位置。 请注意,我们正在为每扇门执行此操作:如果最近的道路是我们添加到另一扇门的道路,它将选择该道路。 - 我们调用 RLTK 的 a 星寻路算法来查找从门到最近道路的路线。
- 我们迭代路径,在路线上的每个位置写入道路瓦片。 我们还将其添加到
roads向量中,因此它将影响未来的路径。
- 我们创建另一个向量 (
如果您现在 cargo run,您将看到一个非常不错的城镇起点:

起始位置和出口
我们并不真正想要完全随机的起始位置,也不想要在此地图上故意远离的出口。 因此,我们将编辑我们的 TownBuilder 构造函数以删除提供此功能的其他元构建器:
#![allow(unused)] fn main() { pub fn town_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height); chain.start_with(TownBuilder::new()); chain } }
现在我们必须修改我们的 build 函数以提供这些功能。 放置出口很容易 - 我们希望它在东边,在道路上:
#![allow(unused)] fn main() { let exit_idx = build_data.map.xy_idx(build_data.width-5, wall_gap_y); build_data.map.tiles[exit_idx] = TileType::DownStairs; }
放置入口更加困难。 我们希望玩家在酒馆开始他们的旅程 - 但我们还没有决定哪栋建筑物是酒馆! 我们将使酒馆成为地图上最大的建筑物。 毕竟,它对游戏最重要! 以下代码将按大小对建筑物进行排序(在 building_size 向量中,第一个元组元素是建筑物的索引,第二个是它的“正方形瓦片面积”):
#![allow(unused)] fn main() { let mut building_size : Vec<(usize, i32)> = Vec::new(); for (i,building) in buildings.iter().enumerate() { building_size.push(( i, building.2 * building.3 )); } building_size.sort_by(|a,b| b.1.cmp(&a.1)); }
请注意,我们按降序排序(通过执行 b.cmp(&a) 而不是反过来) - 因此最大的建筑物是建筑物 0。
现在我们可以设置玩家的起始位置:
#![allow(unused)] fn main() { // Start in the pub // 在酒馆开始 let the_pub = &buildings[building_size[0].0]; build_data.starting_position = Some(Position{ x : the_pub.0 + (the_pub.2 / 2), y : the_pub.1 + (the_pub.3 / 2) }); }
如果您现在 cargo run,您将在酒馆开始 - 并且能够在一个空旷的城镇中导航到出口:

总结
本章介绍了如何使用我们对地图生成的了解来制作一个有针对性的程序生成项目——一个渔村。 西边有一条河流,一条道路,城镇围墙,建筑物和小路。 对于一个起点来说,它看起来一点也不差!
它完全没有 NPC、道具和任何可做的事情。 我们将在下一章纠正这一点。
本章的源代码可以在这里找到
在您的浏览器中使用 Web Assembly 运行本章的示例 (需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
填充起始城镇
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您能喜欢本教程,并制作出伟大的游戏!
如果您喜欢本教程并希望我继续写作,请考虑支持我的 Patreon。
在上一章中,我们构建了城镇的布局。 在本章中,我们将用 NPC 和道具填充它。 我们将引入一些新的 AI 类型来处理友善或中立的 NPC,并开始放置商人、镇民和其他居民,使城镇充满生机。 我们还将开始放置家具和物品,以使这个地方感觉不那么荒凉。
识别建筑物
我们不是在制作一个真实的、全尺寸的城镇。 那里可能有数百座建筑物,玩家很快就会因为试图找到出口而感到厌烦。 相反 - 我们有 12 座建筑物。 查看我们的设计文档,其中两座很重要:
- 酒馆 (Pub)。
- 神庙 (Temple)。
剩下 10 个其他位置实际上并不重要,但我们暗示它们将包括供应商。 集思广益一些供应商,拥有以下内容是有意义的:
- 铁匠铺 (Blacksmith)(满足您的武器/盔甲需求)。
- 服装店 (Clothier)(用于服装、皮革和类似物品)。
- 炼金术士 (Alchemist)(用于药水、魔法物品和物品鉴定)。
所以我们还剩下 5 个位置要填充! 让我们把其中三个变成有居民的普通住宅 (homes),一个变成你的房子 (your house) - 配上一个唠叨的母亲,一个变成有啮齿动物问题的废弃房屋 (abandoned house)。 啮齿动物问题是幻想游戏的常见元素,当我们到达那一步时,它可能会成为一个很好的教程。
您会记得我们按大小对建筑物进行了排序,并确定最大的是酒馆。 让我们扩展一下,为每栋建筑物添加标签。 在 map_builders/town.rs 中,查看 build 函数,我们将扩展建筑物排序器。 首先,让我们为我们的建筑物类型创建一个 enum:
#![allow(unused)] fn main() { enum BuildingTag { Pub, Temple, Blacksmith, Clothier, Alchemist, PlayerHouse, Hovel, Abandoned, Unassigned } }
接下来,我们将把我们的建筑物排序代码移到它自己的函数中(作为 TownBuilder 的一部分):
#![allow(unused)] fn main() { fn sort_buildings(&mut self, buildings: &[(i32, i32, i32, i32)]) -> Vec<(usize, i32, BuildingTag)> { let mut building_size : Vec<(usize, i32, BuildingTag)> = Vec::new(); for (i,building) in buildings.iter().enumerate() { building_size.push(( i, building.2 * building.3, BuildingTag::Unassigned )); } building_size.sort_by(|a,b| b.1.cmp(&a.1)); building_size[0].2 = BuildingTag::Pub; building_size[1].2 = BuildingTag::Temple; building_size[2].2 = BuildingTag::Blacksmith; building_size[3].2 = BuildingTag::Clothier; building_size[4].2 = BuildingTag::Alchemist; building_size[5].2 = BuildingTag::PlayerHouse; for b in building_size.iter_mut().skip(6) { b.2 = BuildingTag::Hovel; } let last_index = building_size.len()-1; building_size[last_index].2 = BuildingTag::Abandoned; building_size } }
这是我们之前的代码,添加了 BuildingTag 条目。 一旦我们按大小排序,我们就分配各种建筑物类型 - 最后一个始终是废弃的房屋。 这将确保我们拥有所有建筑物类型,并且它们按降序排列。
在 build 函数中,用对该函数的调用替换您的排序代码 - 以及对 building_factory 的调用,我们稍后会编写它:
#![allow(unused)] fn main() { let building_size = self.sort_buildings(&buildings); self.building_factory(rng, build_data, &buildings, &building_size); }
现在我们将构建一个骨架工厂:
#![allow(unused)] fn main() { fn building_factory(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap, buildings: &[(i32, i32, i32, i32)], building_index : &[(usize, i32, BuildingTag)]) { for (i,building) in buildings.iter().enumerate() { let build_type = &building_index[i].2; match build_type { _ => {} } } } }
酒馆 (The Pub)
那么,当你在早上醒来,宿醉且惊讶地发现自己已经答应拯救世界时,你期望在酒馆里找到什么? 脑海中浮现出一些想法:
- 其他宿醉的顾客 (patrons),可能睡着了。
- 一个尽可能可疑的“丢失”货物销售员 (salesperson)。
- 一个酒保 (Barkeep),他可能希望你回家。
- 桌子、椅子、桶 (barrels)。
我们将扩展我们的工厂函数,添加一个 match 行来构建酒馆:
#![allow(unused)] fn main() { fn building_factory(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap, buildings: &[(i32, i32, i32, i32)], building_index : &[(usize, i32, BuildingTag)]) { for (i,building) in buildings.iter().enumerate() { let build_type = &building_index[i].2; match build_type { BuildingTag::Pub => self.build_pub(&building, build_data, rng), _ => {} } } } }
我们将开始编写新函数 build_pub:
#![allow(unused)] fn main() { fn build_pub(&mut self, building: &(i32, i32, i32, i32), build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator) { // 放置玩家 (Place the player) build_data.starting_position = Some(Position{ x : building.0 + (building.2 / 2), y : building.1 + (building.3 / 2) }); let player_idx = build_data.map.xy_idx(building.0 + (building.2 / 2), building.1 + (building.3 / 2)); // 放置其他物品 (Place other items) let mut to_place : Vec<&str> = vec!["Barkeep", "Shady Salesman", "Patron", "Patron", "Keg", "Table", "Chair", "Table", "Chair"]; for y in building.1 .. building.1 + building.3 { for x in building.0 .. building.0 + building.2 { let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] == TileType::WoodFloor && idx != player_idx && rng.roll_dice(1, 3)==1 && !to_place.is_empty() { let entity_tag = to_place[0]; to_place.remove(0); build_data.spawn_list.push((idx, entity_tag.to_string())); } } } } }
让我们逐步了解一下:
- 该函数接受我们的建筑物数据、地图信息和随机数生成器作为参数。
- 由于我们总是让玩家在酒馆中开始,所以我们在这里执行此操作。 我们可以从
build函数中删除它。 - 我们存储
player_idx- 我们不想在玩家之上生成任何东西。 - 我们创建
to_place- 我们想要在酒吧中的字符串标签列表。 我们稍后会担心编写这些内容。 - 我们在整个建筑物中迭代
x和y。- 我们计算建筑物瓦片的地图索引。
- 如果建筑物瓦片是木地板 (WoodFloor),地图索引不是玩家地图索引,并且 1d3 掷骰结果为 1,我们:
- 从
to_place列表中取出第一个标签,并从列表中删除它(除非我们放入两次,否则没有重复项)。 - 使用当前瓦片标签将该标签添加到地图的
spawn_list中。
- 从
这非常简单,并且部分内容绝对足够通用,可以帮助处理未来的建筑物。 如果您现在运行项目,您将看到如下错误消息:WARNING: We don't know how to spawn [Barkeep]!。 这是因为我们还没有编写它们。 我们需要 spawns.json 来包含我们尝试生成的所有标签。
制作非敌对 NPC (Making non-hostile NPCs)
让我们为我们的酒保 (Barkeep) 在 spawns.json 中添加一个条目。 我们将引入一个新元素 - ai:
"mobs" : [
{
"name" : "Barkeep",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
为了支持 AI 元素,我们需要打开 raws/mob_structs.rs 并编辑 Mob:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub stats : MobStats, pub vision_range : i32, pub ai : String } }
我们还需要将 "ai" : "melee" 添加到每个其他 mob 中。 现在打开 raws/rawmaster.rs,我们将编辑 spawn_named_mob 以支持它。 将行 eb = eb.with(Monster{}); 替换为:
#![allow(unused)] fn main() { match mob_template.ai.as_ref() { "melee" => eb = eb.with(Monster{}), "bystander" => eb = eb.with(Bystander{}), _ => {} } }
Bystander 是一个新的组件 - 所以我们需要打开 components.rs 并添加它:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Bystander {} }
然后不要忘记在 main.rs 和 saveload_system.rs 中注册它!
如果您现在 cargo run,您应该会看到一个微笑的酒保。 他身穿紫色(来自 JSON 的 RGB #EE82EE)显得容光焕发。 为什么要用紫色? 我们最终会将供应商变成紫色(供应商将在以后的章节中介绍):

他不会对你做出反应或做任何事情,但他就在那里。 我们将在本章稍后添加一些行为。 现在,既然我们支持无辜的旁观者(专业提示:复制现有条目并编辑它;比全部重新输入容易得多),让我们继续在 spawns.json 中添加一些其他实体:
{
"name" : "Shady Salesman",
"renderable": {
"glyph" : "h",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Patron",
"renderable": {
"glyph" : "☺",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
如果您现在 cargo run,酒吧会变得更热闹一些:

添加道具 (Adding props)
一个有人的酒馆,但没有任何东西让他们喝酒、坐着或吃东西,这是一个非常简陋的酒馆。 我想我们可以争辩说这是一个真正的烂地方,预算不够,但是当你开始添加其他建筑物时,这种说法就变得苍白无力了。 所以我们将在 spawns.json 中添加一些道具:
{
"name" : "Keg",
"renderable": {
"glyph" : "φ",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Table",
"renderable": {
"glyph" : "╦",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Chair",
"renderable": {
"glyph" : "└",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
}
如果您现在 cargo run,您会看到一些惰性道具散落在酒馆中:

这并不令人惊叹,但已经感觉更有生气了!
制作神庙 (Making the temple)
就生成代码而言,神庙将与酒馆类似。 事实上,非常相似,以至于我们将从 build_pub 函数中分离出生成实体的部分,并从中创建一个通用函数。 这是新函数:
#![allow(unused)] fn main() { fn random_building_spawn( &mut self, building: &(i32, i32, i32, i32), build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator, to_place : &mut Vec<&str>, player_idx : usize) { for y in building.1 .. building.1 + building.3 { for x in building.0 .. building.0 + building.2 { let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] == TileType::WoodFloor && idx != player_idx && rng.roll_dice(1, 3)==1 && !to_place.is_empty() { let entity_tag = to_place[0]; to_place.remove(0); build_data.spawn_list.push((idx, entity_tag.to_string())); } } } } }
我们将 build_pub 中对该代码的调用替换为:
#![allow(unused)] fn main() { // 放置其他物品 (Place other items) let mut to_place : Vec<&str> = vec!["Barkeep", "Shady Salesman", "Patron", "Patron", "Keg", "Table", "Chair", "Table", "Chair"]; self.random_building_spawn(building, build_data, rng, &mut to_place, player_idx); }
有了这个,让我们思考一下你在神庙里可能会发现什么:
- 牧师 (Priests)
- 教区居民 (Parishioners)
- 椅子 (Chairs)
- 蜡烛 (Candles)
现在我们将扩展我们的工厂以包括神庙:
#![allow(unused)] fn main() { match build_type { BuildingTag::Pub => self.build_pub(&building, build_data, rng), BuildingTag::Temple => self.build_temple(&building, build_data, rng), _ => {} } }
我们的 build_temple 函数可以非常简单:
#![allow(unused)] fn main() { fn build_temple(&mut self, building: &(i32, i32, i32, i32), build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator) { // 放置物品 (Place items) let mut to_place : Vec<&str> = vec!["Priest", "Parishioner", "Parishioner", "Chair", "Chair", "Candle", "Candle"]; self.random_building_spawn(building, build_data, rng, &mut to_place, 0); } }
因此,有了这些 - 我们仍然必须将牧师、教区居民和蜡烛添加到 spawns.json 列表中。 牧师和教区居民在 mobs 部分中,并且基本上与酒保相同:
{
"name" : "Priest",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Parishioner",
"renderable": {
"glyph" : "☺",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
同样,至少现在 - 蜡烛只是另一种道具:
{
"name" : "Candle",
"renderable": {
"glyph" : "Ä",
"fg" : "#FFA500",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
}
如果您现在 cargo run,您可以四处奔跑并找到一座神庙:

构建其他建筑物 (Build other buildings)
我们现在已经完成了大部分艰苦的工作,所以我们只是在填补空白。 让我们扩展构建器中的 match,以包含除废弃房屋之外的各种类型:
#![allow(unused)] fn main() { let build_type = &building_index[i].2; match build_type { BuildingTag::Pub => self.build_pub(&building, build_data, rng), BuildingTag::Temple => self.build_temple(&building, build_data, rng), BuildingTag::Blacksmith => self.build_smith(&building, build_data, rng), BuildingTag::Clothier => self.build_clothier(&building, build_data, rng), BuildingTag::Alchemist => self.build_alchemist(&building, build_data, rng), BuildingTag::PlayerHouse => self.build_my_house(&building, build_data, rng), BuildingTag::Hovel => self.build_hovel(&building, build_data, rng), _ => {} } }
我们将这些组合在一起,因为它们基本上是相同的函数! 这是它们每个函数的主体:
#![allow(unused)] fn main() { fn build_smith(&mut self, building: &(i32, i32, i32, i32), build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator) { // 放置物品 (Place items) let mut to_place : Vec<&str> = vec!["Blacksmith", "Anvil", "Water Trough", "Weapon Rack", "Armor Stand"]; self.random_building_spawn(building, build_data, rng, &mut to_place, 0); } fn build_clothier(&mut self, building: &(i32, i32, i32, i32), build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator) { // 放置物品 (Place items) let mut to_place : Vec<&str> = vec!["Clothier", "Cabinet", "Table", "Loom", "Hide Rack"]; self.random_building_spawn(building, build_data, rng, &mut to_place, 0); } fn build_alchemist(&mut self, building: &(i32, i32, i32, i32), build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator) { // 放置物品 (Place items) let mut to_place : Vec<&str> = vec!["Alchemist", "Chemistry Set", "Dead Thing", "Chair", "Table"]; self.random_building_spawn(building, build_data, rng, &mut to_place, 0); } fn build_my_house(&mut self, building: &(i32, i32, i32, i32), build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator) { // 放置物品 (Place items) let mut to_place : Vec<&str> = vec!["Mom", "Bed", "Cabinet", "Chair", "Table"]; self.random_building_spawn(building, build_data, rng, &mut to_place, 0); } fn build_hovel(&mut self, building: &(i32, i32, i32, i32), build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator) { // 放置物品 (Place items) let mut to_place : Vec<&str> = vec!["Peasant", "Bed", "Chair", "Table"]; self.random_building_spawn(building, build_data, rng, &mut to_place, 0); } }
正如您所看到的 - 这些基本上是将生成列表传递给建筑物生成器,而不是做任何太花哨的事情。 我们在这里创建了很多新实体! 我试图想出你可能在每个位置找到的东西:
- 铁匠铺 (smith) 当然有一个铁匠 (Blacksmith)。 他喜欢围绕着铁砧 (Anvils)、水槽 (Water Troughs)、武器架 (Weapon Racks) 和盔甲架 (Armor Stands)。
- 服装店 (clothier) 有一个服装商 (Clothier),一个柜子 (Cabinet)、一张桌子 (Table)、一台织布机 (Loom) 和一个兽皮架 (Hide Rack)。
- 炼金术士 (alchemist) 有一个炼金术士 (Alchemist),一套化学装置 (Chemistry Set),一个死物 (Dead Thing)(为什么不呢?),一把椅子 (Chair) 和一张桌子 (Table)。
- 我的房子 (My House) 以妈妈 (Mom)(角色的母亲!)为特色,一张床 (bed)、一个柜子 (cabinet)、一把椅子 (chair) 和一张桌子 (table)。
- 小屋 (Hovels) 以农民 (Peasant)、一张床 (bed)、一把椅子 (chair) 和一张桌子 (table) 为特色。
因此,我们需要在 spawns.json 中支持这些:
{
"name" : "Blacksmith",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Clothier",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Alchemist",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Mom",
"renderable": {
"glyph" : "☺",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Peasant",
"renderable": {
"glyph" : "☺",
"fg" : "#999999",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
在道具部分:
{
"name" : "Anvil",
"renderable": {
"glyph" : "╔",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Water Trough",
"renderable": {
"glyph" : "•",
"fg" : "#5555FF",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Weapon Rack",
"renderable": {
"glyph" : "π",
"fg" : "#FFD700",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Armor Stand",
"renderable": {
"glyph" : "⌠",
"fg" : "#FFFFFF",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Chemistry Set",
"renderable": {
"glyph" : "δ",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Dead Thing",
"renderable": {
"glyph" : "☻",
"fg" : "#AA0000",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Cabinet",
"renderable": {
"glyph" : "∩",
"fg" : "#805A46",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Bed",
"renderable": {
"glyph" : "8",
"fg" : "#805A46",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Loom",
"renderable": {
"glyph" : "≡",
"fg" : "#805A46",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Hide Rack",
"renderable": {
"glyph" : "π",
"fg" : "#805A46",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
}
如果您现在 cargo run,您可以四处奔跑并找到人口稠密的房间:

希望您也发现了错误:玩家击败了他/她的妈妈(和炼金术士)! 我们真的不想鼓励这种类型的行为! 因此,在下一节中,我们将研究一些中立的 AI 和玩家与 NPC 的移动行为。
中立 AI/移动 (Neutral AI/Movement)
我们当前的“旁观者 (bystander)”处理存在两个问题:旁观者只是像木头一样站在那里(甚至阻碍你的移动!),并且没有办法在不屠杀他们的情况下绕过他们。 我希望我们的英雄不会通过谋杀他们的母亲来开始他/她的冒险 - 所以让我们纠正这种情况!
交换位置 (Trading Places)
目前,当您“撞”到包含任何具有战斗属性的瓦片时 - 您会发起攻击。 这在 player.rs 的 try_move_player 函数中提供:
#![allow(unused)] fn main() { let target = combat_stats.get(*potential_target); if let Some(_target) = target { wants_to_melee.insert(entity, WantsToMelee{ target: *potential_target }).expect("Add target failed"); return; } }
我们需要扩展此功能,不仅要攻击,还要在撞到 NPC 时与他们交换位置。 这样,他们不会阻止你的移动 - 但你也不能谋杀你的母亲! 因此,首先,我们需要访问 Bystanders 组件存储,并创建一个向量,我们将在其中存储我们移动 NPC 的意图(我们不能只是在循环中访问它们;不幸的是,借用检查器会报错):
#![allow(unused)] fn main() { let bystanders = ecs.read_storage::<Bystander>(); let mut swap_entities : Vec<(Entity, i32, i32)> = Vec::new(); }
所以在 swap_entities 中,我们存储了要移动的实体及其 x/y 目标坐标。 现在我们调整主循环以检查目标是否是旁观者,将他们添加到交换列表,并在他们是旁观者的情况下仍然移动。 我们还使攻击取决于他们不是旁观者:
#![allow(unused)] fn main() { let bystander = bystanders.get(*potential_target); if bystander.is_some() { // 注意,我们想要移动旁观者 (Note that we want to move the bystander) swap_entities.push((*potential_target, pos.x, pos.y)); // 移动玩家 (Move the player) pos.x = min(map.width-1 , max(0, pos.x + delta_x)); pos.y = min(map.height-1, max(0, pos.y + delta_y)); entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); viewshed.dirty = true; let mut ppos = ecs.write_resource::<Point>(); ppos.x = pos.x; ppos.y = pos.y; } else { let target = combat_stats.get(*potential_target); if let Some(_target) = target { wants_to_melee.insert(entity, WantsToMelee{ target: *potential_target }).expect("Add target failed"); return; } } }
最后,在该函数的最后,我们迭代 swap_entities 并应用移动:
#![allow(unused)] fn main() { for m in swap_entities.iter() { let their_pos = positions.get_mut(m.0); if let Some(their_pos) = their_pos { their_pos.x = m.1; their_pos.y = m.2; } } }
如果您现在 cargo run,您将无法再谋杀所有 NPC; 撞到他们会交换你的位置:

废弃的房屋 (The Abandoned House)
最后(对于本章而言),我们需要填充废弃的房屋。 我们决定它将包含大量的啮齿动物问题,因为对于低级冒险者来说,异常大的啮齿动物是一个重要的问题! 我们将在我们的建筑物工厂匹配器中添加另一个匹配行:
#![allow(unused)] fn main() { BuildingTag::Abandoned => self.build_abandoned_house(&building, build_data, rng), }
这是用啮齿动物填充房屋大约一半的函数:
#![allow(unused)] fn main() { fn build_abandoned_house(&mut self, building: &(i32, i32, i32, i32), build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator) { for y in building.1 .. building.1 + building.3 { for x in building.0 .. building.0 + building.2 { let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] == TileType::WoodFloor && idx != 0 && rng.roll_dice(1, 2)==1 { build_data.spawn_list.push((idx, "Rat".to_string())); } } } } }
最后,我们需要将 Rat 添加到 spawns.json 中的 mob 列表中:
{
"name" : "Rat",
"renderable": {
"glyph" : "r",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 2,
"hp" : 2,
"defense" : 1,
"power" : 3
},
"vision_range" : 8,
"ai" : "melee"
},
如果您现在 cargo run,并四处寻找废弃的房屋 - 您会发现它充满了敌对的老鼠:

总结 (Wrap-Up)
在本章中,我们向城镇添加了一堆道具和旁观者 - 以及一间满是愤怒老鼠的房子。 这让它感觉更有生气了。 它绝不是已经完成,但它已经开始感觉像是一个幻想游戏的开场场景。 在下一章中,我们将进行一些 AI 调整,使其感觉更生动 - 并添加一些不方便地在建筑物内闲逛的旁观者。
本章的源代码可以在这里找到
使用 web assembly 在您的浏览器中运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
让 NPC 栩栩如生
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出伟大的游戏!
如果您喜欢本教程并希望我继续创作,请考虑支持我的 Patreon。
我本想建议用黑暗的咒语和蜡烛来赋予 NPC 生命,但实际上 - 这更多的是代码。我们不希望我们的旁观者再像石头一样傻站着了。他们不必表现得特别明智,但如果他们至少能四处走动一下(除了商人,否则会很烦人 - “铁匠去哪儿了?”)并告诉你他们的一天,那就太好了。
新组件 - 区分商人和旁观者
首先,我们将创建一个新组件 - Vendor(商人)。在 components.rs 中,添加以下组件类型:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Vendor {} }
不要忘记在 main.rs 和 saveload_system.rs 中注册它!
现在我们将调整我们的原始文件(spawns.json);所有带有 "ai" : "bystander" 的商人需要更改为 "ai" : "vendor"。因此,我们将为我们的酒保、炼金术士、布商、铁匠和可疑商人进行更改。
接下来,我们调整 raws/rawmaster.rs 的 spawn_named_mob 函数,使其也生成商人:
#![allow(unused)] fn main() { match mob_template.ai.as_ref() { "melee" => eb = eb.with(Monster{}), "bystander" => eb = eb.with(Bystander{}), "vendor" => eb = eb.with(Vendor{}), _ => {} } }
最后,我们将调整 player.rs 中的 try_move_player 函数,使其也不会攻击商人:
#![allow(unused)] fn main() { ... let vendors = ecs.read_storage::<Vendor>(); let mut swap_entities : Vec<(Entity, i32, i32)> = Vec::new(); for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() { if pos.x + delta_x < 1 || pos.x + delta_x > map.width-1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height-1 { return RunState::AwaitingInput; } let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y); for potential_target in map.tile_content[destination_idx].iter() { let bystander = bystanders.get(*potential_target); let vendor = vendors.get(*potential_target); if bystander.is_some() || vendor.is_some() { ... }
用于移动旁观者的系统
我们希望旁观者在城镇里闲逛。为了保持一致性,我们不会让他们开门(这样当您进入酒吧时,您可以期望看到顾客 - 而且他们不会跑到外面去和老鼠战斗!)。创建一个新文件 bystander_ai_system.rs 并将以下代码粘贴到其中:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Bystander, Map, Position, RunState, EntityMoved}; pub struct BystanderAI {} impl<'a> System<'a> for BystanderAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, RunState>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Bystander>, WriteStorage<'a, Position>, WriteStorage<'a, EntityMoved>, WriteExpect<'a, rltk::RandomNumberGenerator>); fn run(&mut self, data : Self::SystemData) { let (mut map, runstate, entities, mut viewshed, bystander, mut position, mut entity_moved, mut rng) = data; if *runstate != RunState::MonsterTurn { return; } for (entity, mut viewshed, _bystander, mut pos) in (&entities, &mut viewshed, &bystander, &mut position).join() { // 尝试随机移动 // Try to move randomly let mut x = pos.x; let mut y = pos.y; let move_roll = rng.roll_dice(1, 5); match move_roll { 1 => x -= 1, 2 => x += 1, 3 => y -= 1, 4 => y += 1, _ => {} } if x > 0 && x < map.width-1 && y > 0 && y < map.height-1 { let dest_idx = map.xy_idx(x, y); if !map.blocked[dest_idx] { let idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = false; pos.x = x; pos.y = y; entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); // 无法插入标记 map.blocked[dest_idx] = true; viewshed.dirty = true; } } } } } }
如果您还记得我们之前制作的系统,第一部分是样板代码,告诉 ECS 我们想要访问哪些资源。我们检查当前是否是怪物的回合(实际上,在这个设置中 NPC 就是怪物);如果不是,我们就跳出。然后我们掷骰子来决定一个随机方向,看看我们是否可以朝那个方向走 - 如果可以,就移动。这非常简单!
在 main.rs 中,我们需要告诉它使用新的模块:
#![allow(unused)] fn main() { pub mod bystander_ai_system; }
我们还需要将该系统添加到我们要运行的系统列表中:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut mob = MonsterAI{}; mob.run_now(&self.ecs); let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut bystander = bystander_ai_system::BystanderAI{}; bystander.run_now(&self.ecs); let mut triggers = trigger_system::TriggerSystem{}; triggers.run_now(&self.ecs); let mut melee = MeleeCombatSystem{}; melee.run_now(&self.ecs); let mut damage = DamageSystem{}; damage.run_now(&self.ecs); let mut pickup = ItemCollectionSystem{}; pickup.run_now(&self.ecs); let mut itemuse = ItemUseSystem{}; itemuse.run_now(&self.ecs); let mut drop_items = ItemDropSystem{}; drop_items.run_now(&self.ecs); let mut item_remove = ItemRemoveSystem{}; item_remove.run_now(&self.ecs); let mut hunger = hunger_system::HungerSystem{}; hunger.run_now(&self.ecs); let mut particles = particle_system::ParticleSpawnSystem{}; particles.run_now(&self.ecs); self.ecs.maintain(); } } }
如果您现在 cargo run 运行项目,您可以看到 NPC 们在随机地笨拙地走动。让他们移动在很大程度上避免了城镇感觉像雕像之城!

会说话的 NPC
为了进一步让事物栩栩如生,让我们允许 NPC 在发现您时“俏皮话”。在 spawns.json 中,让我们为 Patron(酒吧顾客)添加一些俏皮话:
{
"name" : "Patron",
"renderable": {
"glyph" : "☺",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander",
"quips" : [ "安静点,现在还太早!", "哦,天哪,我喝太多了。", "还在拯救世界,是吗?" ]
},
我们需要修改 raws/mob_structs.rs 以处理加载这些数据:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub stats : MobStats, pub vision_range : i32, pub ai : String, pub quips : Option<Vec<String>> } }
我们还需要创建一个组件来保存可用的俏皮话。在 components.rs 中:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Quips { pub available : Vec<String> } }
不要忘记在 main.rs 和 saveload_system.rs 中注册它!
我们需要更新 rawmaster.rs 的 spawn_named_mob 函数,使其能够添加此组件:
#![allow(unused)] fn main() { if let Some(quips) = &mob_template.quips { eb = eb.with(Quips{ available: quips.clone() }); } }
最后,我们将添加当 NPC 发现您时,将这些俏皮话输入到游戏日志中的功能。在 bystander_ai_system.rs 中。首先,扩展系统的可用数据集,如下所示:
#![allow(unused)] fn main() { ... WriteExpect<'a, rltk::RandomNumberGenerator>, ReadExpect<'a, Point>, WriteExpect<'a, GameLog>, WriteStorage<'a, Quips>, ReadStorage<'a, Name>); fn run(&mut self, data : Self::SystemData) { let (mut map, runstate, entities, mut viewshed, bystander, mut position, mut entity_moved, mut rng, player_pos, mut gamelog, mut quips, names) = data; ... }
您可能还记得:它获得了对 Point 资源的只读访问权限,该资源存储了玩家的位置,对 GameLog 的写入访问权限,以及对 Quips 和 Name 组件存储的访问权限。现在,我们将俏皮话添加到函数体中:
#![allow(unused)] fn main() { ... for (entity, mut viewshed,_bystander,mut pos) in (&entities, &mut viewshed, &bystander, &mut position).join() { // 可能会说俏皮话 // Possibly quip let quip = quips.get_mut(entity); if let Some(quip) = quip { if !quip.available.is_empty() && viewshed.visible_tiles.contains(&player_pos) && rng.roll_dice(1,6)==1 { let name = names.get(entity); let quip_index = if quip.available.len() == 1 { 0 } else { (rng.roll_dice(1, quip.available.len() as i32)-1) as usize }; gamelog.entries.push( format!("{} says \"{}\"", name.unwrap().name, quip.available[quip_index]) ); quip.available.remove(quip_index); } } // 尝试随机移动 // Try to move randomly ... }
我们可以逐步了解它的工作原理:
- 它从
quips存储中请求一个组件。这将是一个Option- 要么是None(没什么可说的),要么是Some- 包含俏皮话。 - 如果 确实 有一些俏皮话...
- 如果可用俏皮话列表不为空,视野包含玩家的图块,并且 1d6 掷骰结果为 1...
- 我们查找实体的名称,
- 从
quip的available列表中随机选择一个条目。 - 将字符串记录为
Name说Quip。 - 从该实体的可用俏皮话列表中删除该俏皮话 - 他们不会一直重复自己。
如果您现在运行游戏,您会发现顾客愿意评论普遍的生活:

我们会发现这可以在游戏的其他部分中使用,例如让守卫喊警报,或者让地精说一些适当的“地精式”的话。为了简洁起见,我们不会在此处列出游戏中所有的俏皮话。查看源代码 以查看我们添加了什么。
这种“花絮”在让世界感觉生动方面大有帮助,即使它并没有真正对游戏玩法产生有意义的增加。由于城镇是玩家看到的第一个区域,因此最好有一些花絮。
户外 NPC
到目前为止,城镇中的所有 NPC 都方便地位于建筑物内部。即使在糟糕的天气里(我们没有糟糕的天气!),这也不是很现实的;因此我们应该考虑生成一些户外 NPC。
打开 map_builders/town.rs,我们将创建两个新函数;这是在主 build 函数中对它们的调用:
#![allow(unused)] fn main() { self.spawn_dockers(build_data, rng); self.spawn_townsfolk(build_data, rng, &mut available_building_tiles); }
spawn_dockers 函数查找桥梁图块,并在其上放置各种人:
#![allow(unused)] fn main() { fn spawn_dockers(&mut self, build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator) { for (idx, tt) in build_data.map.tiles.iter().enumerate() { if *tt == TileType::Bridge && rng.roll_dice(1, 6)==1 { let roll = rng.roll_dice(1, 3); match roll { 1 => build_data.spawn_list.push((idx, "Dock Worker".to_string())), 2 => build_data.spawn_list.push((idx, "Wannabe Pirate".to_string())), _ => build_data.spawn_list.push((idx, "Fisher".to_string())), } } } } }
这很简单:对于地图上的每个图块,检索其索引和类型。如果它是桥梁,并且 1d6 掷骰结果为 1 - 生成某人。我们随机在码头工人、想成为海盗的人和渔民之间选择。
spawn_townsfolk 也非常简单:
#![allow(unused)] fn main() { fn spawn_townsfolk(&mut self, build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator, available_building_tiles : &mut HashSet<usize>) { for idx in available_building_tiles.iter() { if rng.roll_dice(1, 10)==1 { let roll = rng.roll_dice(1, 4); match roll { 1 => build_data.spawn_list.push((*idx, "Peasant".to_string())), 2 => build_data.spawn_list.push((*idx, "Drunk".to_string())), 3 => build_data.spawn_list.push((*idx, "Dock Worker".to_string())), _ => build_data.spawn_list.push((*idx, "Fisher".to_string())), } } } } }
这迭代所有剩余的 availble_building_tiles;这些图块是我们知道不会在建筑物内部的图块,因为我们在放置建筑物时移除了它们!因此,保证每个点都在户外,并且在城镇中。对于每个图块,我们掷 1d10 - 如果结果为 1,我们生成一个农民、醉汉、码头工人或渔民。
最后,我们将这些人添加到我们的 spawns.json 文件中:
{
"name" : "Dock Worker",
"renderable": {
"glyph" : "☺",
"fg" : "#999999",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander",
"quips" : [ "今天天气真好,是吧?", "天气不错", "你好" ]
},
{
"name" : "Fisher",
"renderable": {
"glyph" : "☺",
"fg" : "#999999",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander",
"quips" : [ "今天鱼儿咬钩了!", "我抓到了一些东西,但不是鱼!", "看起来要下雨了" ]
},
{
"name" : "Wannabe Pirate",
"renderable": {
"glyph" : "☺",
"fg" : "#aa9999",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander",
"quips" : [ "啊", "格罗格酒!", "酒!" ]
},
{
"name" : "Drunk",
"renderable": {
"glyph" : "☺",
"fg" : "#aa9999",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander",
"quips" : [ "嗝", "需要... 更多... 酒!", "能施舍点铜币吗?" ]
},
如果您现在 cargo run 运行,您将看到一个充满生机的城镇:

总结
本章真正地让我们的城镇栩栩如生。总是有改进的空间,但这对于起始地图来说已经足够好了!下一章将改变方向,开始为游戏添加属性。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
游戏属性
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证——因此您可以随意使用。 我希望您会喜欢本教程,并制作出伟大的游戏!
如果您喜欢本教程并希望我继续创作,请考虑支持我的 Patreon。
到目前为止,我们只有非常原始的属性:力量和防御。这并没有提供太多的变化空间,也不符合 Roguelike 游戏中让玩家沉浸在数字中的理想(好吧,这有点夸张了)。在设计文档中,我们提到希望采用类似 D&D 的游戏属性方法。这为各种玩法提供了大量空间,允许带有各种奖励(和惩罚)的物品,并且对于大多数可能玩这类游戏的玩家来说应该会感到熟悉。这也需要一些 UI 工作,但我们会将大部分工作推迟到下一章。
基础 6 属性 - 浓缩为 4 种
任何玩过 D&D 的人都知道,角色——以及后来的版本中的所有人——都拥有六个属性:
- 力量 (Strength),决定了你能携带多少东西、你击打东西的力度以及你一般的身体能力。
- 敏捷 (Dexterity),决定了你躲避事物的速度、你杂技般跳跃的能力,以及像开锁和瞄准弓箭之类的技能。
- 体质 (Constitution),决定了你的身体健康程度,调整你的生命值总量并帮助抵抗疾病。
- 智力 (Intelligence),代表你的聪明程度,帮助你施法、阅读事物。
- 感知 (Wisdom),代表你拥有的常识,以及与神灵的有益互动。
- 魅力 (Charisma),代表你与他人互动的能力。
对于我们正在制作的游戏来说,这有点过头了。智力和感知不需要分开(感知最终会成为每个人为了在其他地方获得点数而抛弃的“废属性”!),而魅力实际上只在与供应商互动时有用,因为我们在游戏中没有进行大量的社交互动。因此,我们将为这款游戏选择一组浓缩的属性:
- 力量 (Might),决定你击中目标的一般能力。
- 体魄 (Fitness),你的一般健康状况。
- 迅捷 (Quickness),你的一般敏捷替代属性。
- 智力 (Intelligence),实际上结合了 D&D 术语中的智力和感知。
这在其他游戏中也是相当常见的组合。让我们打开 components.rs 并创建一个新的组件来保存它们:
#![allow(unused)] fn main() { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Attribute { pub base : i32, pub modifiers : i32, pub bonus : i32 } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Attributes { pub might : Attribute, pub fitness : Attribute, pub quickness : Attribute, pub intelligence : Attribute } }
所以我们为属性创建了一个结构体,并存储了三个值:
- 基础 (base) 值,这是完全未修改的值。
- 修正值 (modifiers),表示属性的任何有效奖励或惩罚(并且必须不时重新计算)。
- 奖励值 (bonus),它从最终的修正值派生而来——并且在大多数情况下,是我们实际要使用的值。
不要忘记在 main.rs 和 saveload_system.rs 中注册 Attributes。 Attribute 实际上不是一个组件——它只是被一个组件使用——所以你不必注册它。
给玩家一些属性
现在,我们应该给玩家一些属性。我们将从简单的开始,并为每个属性赋予 11 的值(我们使用 D&D 风格的 3-18,3d6 生成的属性)。在 spawner.rs 中,修改 player 函数如下:
#![allow(unused)] fn main() { pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity { ecs .create_entity() .with(Position { x: player_x, y: player_y }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), render_order: 0 }) .with(Player{}) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Name{name: "Player".to_string() }) .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 }) .with(HungerClock{ state: HungerState::WellFed, duration: 20 }) .with(Attributes{ might: Attribute{ base: 11, modifiers: 0, bonus: 0 }, fitness: Attribute{ base: 11, modifiers: 0, bonus: 0 }, quickness: Attribute{ base: 11, modifiers: 0, bonus: 0 }, intelligence: Attribute{ base: 11, modifiers: 0, bonus: 0 }, }) .marked::<SimpleMarker<SerializeMe>>() .build() } }
NPC 的属性
我们可能不想为 spawns.json 中的每个 NPC 都写出每个属性,但我们希望能够在需要时这样做。以普通的 NPC,比如 Barkeep(酒保)为例。我们可以使用以下语法来表示他在所有方面都具有“正常”属性,但比普通农民更聪明:
{
"name" : "Barkeep",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "vendor",
"attributes" : {
"intelligence" : 13
}
},
这种模式很强大,因为我们可以忽略基本上是背景装饰的人物的细节——但可以为重要的怪物填写我们想要的尽可能多的细节!如果您没有指定属性,它将默认为一个中间值。
让我们扩展 raws/mob_structs.rs 中的结构体以支持这种灵活的格式:
#![allow(unused)] fn main() { use serde::{Deserialize}; use super::{Renderable}; #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub stats : MobStats, pub vision_range : i32, pub ai : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes } #[derive(Deserialize, Debug)] pub struct MobStats { pub max_hp : i32, pub hp : i32, pub power : i32, pub defense : i32 } #[derive(Deserialize, Debug)] pub struct MobAttributes { pub might : Option<i32>, pub fitness : Option<i32>, pub quickness : Option<i32>, pub intelligence : Option<i32> } }
请注意,我们将 attributes 设置为必需——因此您必须拥有一个属性才能加载 JSON。然后我们将所有属性值设置为可选;如果您没有指定它们,我们将使用一个不错的、正常的值。
现在让我们打开 raws/rasmaster.rs 并修改 spawn_named_mob 以生成此数据:
#![allow(unused)] fn main() { let mut attr = Attributes{ might: Attribute{ base: 11, modifiers: 0, bonus: 0 }, fitness: Attribute{ base: 11, modifiers: 0, bonus: 0 }, quickness: Attribute{ base: 11, modifiers: 0, bonus: 0 }, intelligence: Attribute{ base: 11, modifiers: 0, bonus: 0 }, }; if let Some(might) = mob_template.attributes.might { attr.might = Attribute{ base: might, modifiers: 0, bonus: 0 }; } if let Some(fitness) = mob_template.attributes.fitness { attr.fitness = Attribute{ base: fitness, modifiers: 0, bonus: 0 }; } if let Some(quickness) = mob_template.attributes.quickness { attr.quickness = Attribute{ base: quickness, modifiers: 0, bonus: 0 }; } if let Some(intelligence) = mob_template.attributes.intelligence { attr.intelligence = Attribute{ base: intelligence, modifiers: 0, bonus: 0 }; } eb = eb.with(attr); }
这将检查 JSON 中是否存在每个属性,并将其分配给 mob(怪物)。
属性奖励值
到目前为止,一切都很好——但是 bonus(奖励值)字段呢?每个值的奖励值都为 0 是不对的!我们将需要进行大量的游戏系统计算——所以我们将在主项目中创建一个新文件 gamesystem.rs:
#![allow(unused)] fn main() { pub fn attr_bonus(value: i32) -> i32 { (value-10)/2 // 参见:https://roll20.net/compendium/dnd5e/Ability%20Scores#content } }
这使用了标准的 D&D 规则来确定属性奖励值:减去 10 再除以 2。所以我们的 11 将给出 0 的奖励值——它是平均水平。我们的酒保将获得 1 点智力检定奖励。
在 main.rs 的顶部,添加 mod gamesystem 和 pub use gamesystem::* 以使这个模块在任何地方都可用。
现在修改 spawner.rs 中的玩家生成代码以使用它:
#![allow(unused)] fn main() { use crate::attr_bonus; ... .with(Attributes{ might: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, fitness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, quickness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, intelligence: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, }) ... }
在 rawmaster.rs 中做同样的事情:
#![allow(unused)] fn main() { use crate::attr_bonus; // 在顶部! ... let mut attr = Attributes{ might: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, fitness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, quickness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, intelligence: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, }; if let Some(might) = mob_template.attributes.might { attr.might = Attribute{ base: might, modifiers: 0, bonus: attr_bonus(might) }; } if let Some(fitness) = mob_template.attributes.fitness { attr.fitness = Attribute{ base: fitness, modifiers: 0, bonus: attr_bonus(fitness) }; } if let Some(quickness) = mob_template.attributes.quickness { attr.quickness = Attribute{ base: quickness, modifiers: 0, bonus: attr_bonus(quickness) }; } if let Some(intelligence) = mob_template.attributes.intelligence { attr.intelligence = Attribute{ base: intelligence, modifiers: 0, bonus: attr_bonus(intelligence) }; } eb = eb.with(attr); ... }
在编译/运行游戏之前,在 spawns.json 中为每个 mob 添加一个空白的 attributes 条目,以避免错误。这是一个例子:
{
"name" : "Shady Salesman",
"renderable": {
"glyph" : "h",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "vendor",
"attributes" : {}
},
技能
在我们开始使用属性之前,我们应该考虑与属性密切相关的另一个元素:技能。对于这个游戏,我们不想搞得太复杂,弄出数百个技能;我们永远无法完成本教程!相反,让我们使用一些非常基础的技能:近战、防御和魔法。我们以后总是可以添加更多技能(但删除它们可能会引起用户的一片嘲笑!)。
在 components.rs 中,让我们创建一个技能持有组件:
#![allow(unused)] fn main() { #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)] pub enum Skill { Melee, Defense, Magic } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Skills { pub skills : HashMap<Skill, i32> } }
因此,如果我们要添加技能,将来我们需要将它们添加到 enum 中——但我们的基本 skills 结构可以容纳我们想出的任何技能。不要忘记将 Skills(而不是 Skill)添加到 main.rs 和 saveload_system.rs 中进行注册!
打开 spawners.rs,让我们给 player 在所有技能中都赋予 1 的技能等级:
#![allow(unused)] fn main() { pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity { let mut skills = Skills{ skills: HashMap::new() }; skills.skills.insert(Skill::Melee, 1); skills.skills.insert(Skill::Defense, 1); skills.skills.insert(Skill::Magic, 1); ecs .create_entity() .with(Position { x: player_x, y: player_y }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), render_order: 0 }) .with(Player{}) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Name{name: "Player".to_string() }) .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 }) .with(HungerClock{ state: HungerState::WellFed, duration: 20 }) .with(Attributes{ might: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, fitness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, quickness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, intelligence: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, }) .with(skills) .marked::<SimpleMarker<SerializeMe>>() .build() } }
对于 mob,我们也将假定每个技能的技能等级为 1,除非另有说明。在 raws/mob_structs.rs 中,更新 Mob:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub stats : MobStats, pub vision_range : i32, pub ai : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>> } }
这允许我们在许多情况下完全省略它(没有非默认技能),这将避免在我们忘记给他们技能时给 mob 带来惩罚! mob 可以覆盖技能,如果我们愿意的话。它们必须与 HashMap 结构对齐。让我们在 spawns.json 中给我们的酒保一个技能奖励(它不会做任何事情,但它可以作为一个例子):
{
"name" : "Barkeep",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "vendor",
"attributes" : {
"intelligence" : 13
},
"skills" : {
"Melee" : 2
}
},
让我们修改我们的 raws/rawmaster.rs 的 spawn_named_mob 函数来使用这些数据:
#![allow(unused)] fn main() { let mut skills = Skills{ skills: HashMap::new() }; skills.skills.insert(Skill::Melee, 1); skills.skills.insert(Skill::Defense, 1); skills.skills.insert(Skill::Magic, 1); if let Some(mobskills) = &mob_template.skills { for sk in mobskills.iter() { match sk.0.as_str() { "Melee" => { skills.skills.insert(Skill::Melee, *sk.1); } "Defense" => { skills.skills.insert(Skill::Defense, *sk.1); } "Magic" => { skills.skills.insert(Skill::Magic, *sk.1); } _ => { rltk::console::log(format!("Unknown skill referenced: [{}]", sk.0)); } } } } eb = eb.with(skills); }
将等级、经验和生命值设为组件,添加法力值
在 components.rs 中,继续添加另一个组件(然后在 main.rs 和 saveload_system.rs 中注册它):
#![allow(unused)] fn main() { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Pool { pub max: i32, pub current: i32 } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Pools { pub hit_points : Pool, pub mana : Pool, pub xp : i32, pub level : i32 } }
这里有很多内容:
- 我们创建了一个新类型来表示可消耗的资源,
Pool(池)。池具有最大值和当前值。这表示受伤或耗尽魔法力量;最大值不变,但当前值会波动。 - 我们使用
Pool来存储hit_points(生命值)和mana(法力值)。 - 我们还存储
xp,表示“经验值 (Experience Points)”。 - 我们存储
level(等级),表示你(或 NPC)的等级。
我们应该为这些定义一些默认值,并确定你的属性如何影响它们。在 gamesystem.rs 中,我们将使用以下函数:
#![allow(unused)] fn main() { pub fn attr_bonus(value: i32) -> i32 { (value-10)/2 // 参见:https://roll20.net/compendium/dnd5e/Ability%20Scores#content } pub fn player_hp_per_level(fitness: i32) -> i32 { 10 + attr_bonus(fitness) } pub fn player_hp_at_level(fitness:i32, level:i32) -> i32 { player_hp_per_level(fitness) * level } pub fn npc_hp(fitness: i32, level: i32) -> i32 { let mut total = 1; for _i in 0..level { total += i32::max(1, 8 + attr_bonus(fitness)); } total } pub fn mana_per_level(intelligence: i32) -> i32 { i32::max(1, 4 + attr_bonus(intelligence)) } pub fn mana_at_level(intelligence: i32, level: i32) -> i32 { mana_per_level(intelligence) * level } pub fn skill_bonus(skill : Skill, skills: &Skills) -> i32 { if skills.skills.contains_key(&skill) { skills.skills[&skill] } else { -4 } } }
如果您一直在关注,这些应该非常容易理解:玩家每级获得 10 点生命值,并由他们的体魄属性修正。NPC 每级获得 8 点,也由体魄修正——每级至少 1 点(对于糟糕的掷骰结果)。
因此,在 spawner.rs 中,我们可以修改 player 函数来为初始角色填充这些池:
#![allow(unused)] fn main() { .with(Pools{ hit_points : Pool{ current: player_hp_at_level(11, 1), max: player_hp_at_level(11, 1) }, mana: Pool{ current: mana_at_level(11, 1), max: mana_at_level(11, 1) }, xp: 0, level: 1 }) }
同样,我们需要让 NPC 能够拥有池。至少,我们必须在他们的定义中添加一个 level 属性——但让我们使其可选,如果省略则默认为 1(这样您就不需要修改每个普通的 NPC!)。我们还将使 hp 和 mana 字段可选——这样您就可以使用随机默认值,或者为重要的怪物覆盖它们。这是调整后的 raws/mob_structs.rs:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub vision_range : i32, pub ai : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32> } }
我们还应该修改 spawn_named_mob(来自 raws/rawmaster.rs)以包含此内容:
#![allow(unused)] fn main() { let mut mob_fitness = 11; let mut mob_int = 11; let mut attr = Attributes{ might: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, fitness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, quickness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, intelligence: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, }; if let Some(might) = mob_template.attributes.might { attr.might = Attribute{ base: might, modifiers: 0, bonus: attr_bonus(might) }; } if let Some(fitness) = mob_template.attributes.fitness { attr.fitness = Attribute{ base: fitness, modifiers: 0, bonus: attr_bonus(fitness) }; mob_fitness = fitness; } if let Some(quickness) = mob_template.attributes.quickness { attr.quickness = Attribute{ base: quickness, modifiers: 0, bonus: attr_bonus(quickness) }; } if let Some(intelligence) = mob_template.attributes.intelligence { attr.intelligence = Attribute{ base: intelligence, modifiers: 0, bonus: attr_bonus(intelligence) }; mob_int = intelligence; } eb = eb.with(attr); let mob_level = if mob_template.level.is_some() { mob_template.level.unwrap() } else { 1 }; let mob_hp = npc_hp(mob_fitness, mob_level); let mob_mana = mana_at_level(mob_int, mob_level); let pools = Pools{ level: mob_level, xp: 0, hit_points : Pool{ current: mob_hp, max: mob_hp }, mana: Pool{current: mob_mana, max: mob_mana} }; eb = eb.with(pools); }
我们在构建 NPC 时捕获了相关的属性,并调用了新函数来帮助构建 NPC 的池。
是时候破坏东西了:删除旧的属性!
在 components.rs 中,删除 CombatStats。您还需要在 main.rs 和 saveload_system.rs 中删除它。注意您的 IDE 将整个城镇都涂成红色——我们已经用了很多次了!由于我们正在制定一个新的类似 D&D 的系统,所以必须这样做……这也让我们有机会查看我们实际使用它的地方,并做出一些明智的决定。
如果您不想直接遵循所有这些更改,或者感到困惑(我们所有人都会发生这种情况!),本章的源代码 包含可用的版本。
以下是更简单的更改:
- 在
mob_structs.rs中,您可以删除MobStats及其在Mob中的引用。 - 在
rawmaster.rs中,删除将CombatStats分配给 NPC 的代码。 - 在
spawns.json中,您可以删除所有属性块。 - 在
damage_system.rs中,将所有对CombatStats的引用替换为Pools,并将所有对stats.hp的引用替换为stats.hit_points.current或stats.hit_points.max(对于max_hp)。 - 在
inventory_system.rs中,将所有对CombatStats的引用替换为Pools,并将引用max_hp和hp的行替换为stats.hit_points = i32::min(stats.hit_points.max, stats.hit_points.current + healer.heal_amount);
以及不太容易的更改:
在 player.rs 中,将 CombatStats 替换为 Pools——它将起到相同的作用。此外,找到 can_heal 部分并将其替换为:
#![allow(unused)] fn main() { if can_heal { let mut health_components = ecs.write_storage::<Pools>(); let pools = health_components.get_mut(*player_entity).unwrap(); pools.hit_points.current = i32::min(pools.hit_points.current + 1, pools.hit_points.max); } }
在 main.rs(第 345 行)中,我们引用了玩家的生命值——他们已经改变了地下城等级,我们给他们恢复了一些生命值。让我们不要那么好心,完全删除它。现在不友好的代码看起来像这样:
#![allow(unused)] fn main() { let player_entity = self.ecs.fetch::<Entity>(); let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>(); gamelog.entries.push("You descend to the next level.".to_string()); }
gui.rs 是一个简单的修复。将 CombatStats 的导入替换为 Pools;这是相关的部分:
#![allow(unused)] fn main() { ... use super::{Pools, Player, gamelog::GameLog, Map, Name, Position, State, InBackpack, Viewshed, RunState, Equipped, HungerClock, HungerState, rex_assets::RexAssets, Hidden, camera }; pub fn draw_ui(ecs: &World, ctx : &mut Rltk) { ctx.draw_box(0, 43, 79, 6, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); let combat_stats = ecs.read_storage::<Pools>(); let players = ecs.read_storage::<Player>(); let hunger = ecs.read_storage::<HungerClock>(); for (_player, stats, hc) in (&players, &combat_stats, &hunger).join() { let health = format!(" HP: {} / {} ", stats.hp, stats.max_hp); ctx.print_color(12, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &health); ctx.draw_bar_horizontal(28, 43, 51, stats.hit_points.current, stats.hit_points.max, RGB::named(rltk::RED), RGB::named(rltk::BLACK)); ... }
更新近战战斗系统。
我们只剩下一个“红色文件”(有错误的文件),在 melee_combat_system.rs 中,但它们现在与依赖于旧系统的核心游戏系统有关。我们希望使其更像 D20 (D&D) 游戏,因此无论如何都应该替换它们。
这意味着是时候讨论我们想要的战斗系统了。让我们采用非常类似 D&D 的(但又不完全是)设置:
- 我们查看攻击者正在使用什么武器。我们需要确定它是基于力量 (Might) 还是迅捷 (Quickness)。如果您是徒手,我们将使用力量 (Might)。
- 攻击者掷出 1d20(一个 20 面骰子)。
- 如果掷出的点数是自然值、未修改的 20,则总是命中。
- 自然值 1 总是未命中。
- 攻击者根据武器添加力量或迅捷的属性奖励值。
- 攻击者添加技能奖励值,等于在近战 (Melee) 技能上花费的点数。
- 攻击者添加武器本身赋予的任何奖励(以防它是魔法武器)。
- 攻击者添加任何情境或状态奖励,这些奖励尚未实现,但最好记住。
- 如果总攻击掷骰值等于或大于目标的护甲等级 (armor class),则目标被击中并将受到伤害。
护甲等级由以下因素决定:
- 从基础数字 10 开始。
- 添加防御 (Defense) 技能。
- 添加装备的护甲(尚未实现!和盾牌)的护甲奖励值。
然后根据近战武器确定伤害:
- 武器将指定骰子类型和奖励(例如
1d6+1);如果没有装备武器,则徒手战斗造成1d4伤害。 - 添加攻击者的力量 (Might) 奖励值。
- 添加攻击者的近战 (Melee) 奖励值。
现在我们已经定义了它应该如何工作,我们可以开始实现它了。在我们改进一些装备之前,它将是不完整的——但至少我们可以让它编译。
这是一个替换 melee_combat_system.rs,它执行了我们描述的操作:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Attributes, Skills, WantsToMelee, Name, SufferDamage, gamelog::GameLog, particle_system::ParticleBuilder, Position, HungerClock, HungerState, Pools, skill_bonus, Skill}; pub struct MeleeCombatSystem {} impl<'a> System<'a> for MeleeCombatSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToMelee>, ReadStorage<'a, Name>, ReadStorage<'a, Attributes>, ReadStorage<'a, Skills>, WriteStorage<'a, SufferDamage>, WriteExpect<'a, ParticleBuilder>, ReadStorage<'a, Position>, ReadStorage<'a, HungerClock>, ReadStorage<'a, Pools>, WriteExpect<'a, rltk::RandomNumberGenerator> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut log, mut wants_melee, names, attributes, skills, mut inflict_damage, mut particle_builder, positions, hunger_clock, pools, mut rng) = data; for (entity, wants_melee, name, attacker_attributes, attacker_skills, attacker_pools) in (&entities, &wants_melee, &names, &attributes, &skills, &pools).join() { // 攻击者和防御者都活着吗? 只有当他们都活着时才攻击 let target_pools = pools.get(wants_melee.target).unwrap(); let target_attributes = attributes.get(wants_melee.target).unwrap(); let target_skills = skills.get(wants_melee.target).unwrap(); if attacker_pools.hit_points.current > 0 && target_pools.hit_points.current > 0 { let target_name = names.get(wants_melee.target).unwrap(); let natural_roll = rng.roll_dice(1, 20); let attribute_hit_bonus = attacker_attributes.might.bonus; let skill_hit_bonus = skill_bonus(Skill::Melee, &*attacker_skills); let weapon_hit_bonus = 0; // TODO: 一旦武器支持这个 let mut status_hit_bonus = 0; if let Some(hc) = hunger_clock.get(entity) { // 吃饱喝足状态给予 +1 if hc.state == HungerState::WellFed { status_hit_bonus += 1; } } let modified_hit_roll = natural_roll + attribute_hit_bonus + skill_hit_bonus + weapon_hit_bonus + status_hit_bonus; let base_armor_class = 10; let armor_quickness_bonus = target_attributes.quickness.bonus; let armor_skill_bonus = skill_bonus(Skill::Defense, &*target_skills); let armor_item_bonus = 0; // TODO: 一旦护甲支持这个 let armor_class = base_armor_class + armor_quickness_bonus + armor_skill_bonus + armor_item_bonus; if natural_roll != 1 && (natural_roll == 20 || modified_hit_roll > armor_class) { // 目标被击中! 在我们支持武器之前,我们使用 1d4 let base_damage = rng.roll_dice(1, 4); let attr_damage_bonus = attacker_attributes.might.bonus; let skill_damage_bonus = skill_bonus(Skill::Melee, &*attacker_skills); let weapon_damage_bonus = 0; let damage = i32::max(0, base_damage + attr_damage_bonus + skill_hit_bonus + skill_damage_bonus + weapon_damage_bonus); SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage); log.entries.push(format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage)); if let Some(pos) = positions.get(wants_melee.target) { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0); } } else if natural_roll == 1 { // 自然值 1 未命中 log.entries.push(format!("{} considers attacking {}, but misjudges the timing.", name.name, target_name.name)); if let Some(pos) = positions.get(wants_melee.target) { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::BLUE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0); } } else { // 未命中 log.entries.push(format!("{} attacks {}, but can't connect.", name.name, target_name.name)); if let Some(pos) = positions.get(wants_melee.target) { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::CYAN), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0); } } } } wants_melee.clear(); } } }
这是一大段代码,但它与我们创建的轮廓非常相似。让我们逐步了解一下:
- 我们仔细定义了我们需要访问的许多 ECS 资源。
- 我们迭代所有想要近战、并且具有名称、技能、属性和池的实体。
- 我们获取目标的技能和池。
- 我们检查攻击者和目标是否都活着,如果他们没有活着就跳过。
- 我们获取目标的名称,我们将在日志记录中需要它。
- 我们通过掷出 1d20 获得
natural_roll(自然掷骰值)。 - 我们计算属性命中奖励值,引用攻击者的力量 (Might) 奖励值。
- 我们计算技能命中奖励值,引用攻击者的近战 (Melee) 技能。
- 我们将
weapon_hit_bonus(武器命中奖励值)设置为 0,因为我们尚未实现它。 - 我们查看攻击者是否处于吃饱喝足状态,如果是,则给予他们 +1 情境奖励。
- 我们现在可以通过添加步骤 6 到 10 来计算
modified_hit_roll(修正后的命中掷骰值)。 - 我们将
base_armor_class(基础护甲等级)设置为 10。 - 我们从目标获取迅捷奖励值,并将其设置为
armor_quickness_bonus(护甲迅捷奖励值)。 - 我们从目标获取防御 (Defense) 技能,并将其设置为
armor_skill_bonus(护甲技能奖励值)。 - 我们设置
armor_item_bonus(护甲物品奖励值),因为我们尚未实现它。 - 我们通过添加步骤 12 到 15 来计算
armor_class(护甲等级)。 - 如果
natural_roll(自然掷骰值)不等于 1,并且是 20 或modified_hit_roll(修正后的命中掷骰值)大于或等于armor_class(护甲等级)——那么攻击者就命中了:- 由于我们尚未正确支持战斗物品,因此我们掷
1d4作为基础伤害。 - 我们添加攻击者的力量 (Might) 奖励值。
- 我们添加攻击者的近战 (Melee) 技能。
- 我们向伤害系统发送
inflict_damage(造成伤害)消息,记录攻击,并播放橙色粒子效果。
- 由于我们尚未正确支持战斗物品,因此我们掷
- 如果
natural_roll(自然掷骰值)为 1,我们会提到这是一个壮观的失误并显示蓝色粒子效果。 - 否则,这是一个普通的失误——播放青色粒子效果并记录失误。
最后,让我们打开 spawns.json 并使老鼠非常弱。否则,在没有装备的情况下,当您找到它们时,您会被直接杀死:
{
"name" : "Rat",
"renderable": {
"glyph" : "r",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"ai" : "melee",
"attributes" : {
"Might" : 3,
"Fitness" : 3
},
"skills" : {
"Melee" : -1,
"Defense" : -1
}
},
您现在可以 cargo run,慢跑到下楼梯处(它会在右边)或找到废弃的房屋,并进行战斗!由于尚未实现物品,因此内容仍然会相当缺乏——但基础知识已经存在,您可以亲眼看到“d20”系统的运作。战斗不再那么确定性,并且可能具有一些真正的紧张感,而不是“国际象棋般的紧张感”。

总结
我们现在已经实现了游戏属性和一个简单的类似 D&D 的近战系统。还有更多的事情要做,我们将在下一章中进入下一个阶段——装备。
本章的源代码可以在这里找到
使用 WebAssembly 在您的浏览器中运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
装备
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出伟大的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在上一章中,我们转到了 d20 风格(类似 D&D)的战斗系统和属性系统。它功能完善,但并没有真正提供任何通过装备来提升角色的机会。寻找酷炫的物品,并煞费苦心地最大化您的效率是 roguelike 游戏的基础 - 提供了大量的深度,以及一种感觉,即虽然游戏是随机的,但您可以极大地影响它以获得您想要的结果。
解析骰子字符串
我们将发现能够读取包含 D&D 骰子规格(例如 20d6+4)的字符串并将其转换为计算机友好的数字非常有帮助。我们在读取原始文件时会经常使用它,所以我们会把它放在那里 - 但要公开它,以防我们在其他地方需要它。
解析像这样的文本片段是正则表达式的完美工作。Rust 通过 crate 支持正则表达式,所以我们必须打开 cargo.toml 并将 regex = "1.3.6" 添加到 [dependencies] 部分。实际上 教 正则表达式本身就可以写一本书;这是一个非常复杂(且强大)的系统,并且往往看起来像猫在您的键盘上走过一样。这是一个解析 1d20+4 类型字符串的正则表达式:
(\d+)d(\d+)([\+\-]\d+)?
这到底是什么 意思 呢?
- 括号
(..)中包含的每个部分都是一个匹配组。您正在告诉正则表达式,括号中的任何内容对您都很重要,并且可以被捕获以供读取。 \d是正则表达式的说法,表示“我希望这里有一个数字”。- 添加
+表示“这里可能不止一个数字,继续读取直到遇到其他内容”。 - 因此,第一个
(\d+)表示“捕获字符串开头的全部数字”。 - 组外的
d是字面量d字符。因此,我们用字母d将第一组数字与后续部分分隔开。现在我们已经到了1d。 - 下一个
(\d+)工作方式相同 - 继续读取数字,并将它们捕获到第二个组中。所以现在我们已经读取到1d20,并且在组中捕获了1和20。 - 最后一个组有点令人困惑。
[..]表示“期望这些字符中的任何一个”。反斜杠 (\) 转义 了后续字符,意思是“+ 或 - 在正则表达式语言中可能意味着某些东西;在这种情况下,请将其视为符号”。所以[\+\-]表示“期望这里有一个加号或减号”。然后我们读取那里的数字。 - 所以现在我们已经将
1d20+4分解为1、20和4。
完全有可能我们会传递没有 +4 的骰子类型,例如 1d20。在这种情况下,正则表达式将匹配 1 和 20 - 但最后一个组将为空。
这是我们函数的 Rust 代码:
#![allow(unused)] fn main() { pub fn parse_dice_string(dice : &str) -> (i32, i32, i32) { lazy_static! { static ref DICE_RE : Regex = Regex::new(r"(\d+)d(\d+)([\+\-]\d+)?").unwrap(); } let mut n_dice = 1; let mut die_type = 4; let mut die_bonus = 0; for cap in DICE_RE.captures_iter(dice) { if let Some(group) = cap.get(1) { n_dice = group.as_str().parse::<i32>().expect("Not a digit"); // 不是数字 } if let Some(group) = cap.get(2) { die_type = group.as_str().parse::<i32>().expect("Not a digit"); // 不是数字 } if let Some(group) = cap.get(3) { die_bonus = group.as_str().parse::<i32>().expect("Not a digit"); // 不是数字 } } (n_dice, die_type, die_bonus) } }
嗯,这简直是一团糟。让我们逐步分析并尝试稍微解读一下。
- 正则表达式在 Rust Regex 库首次解析时被编译成它们自己的内部格式。我们不想每次尝试读取骰子字符串时都这样做,所以我们采纳了 Rust Cookbook 的建议,并将读取表达式的操作烘焙到
lazy_static!中(就像我们用于全局变量一样)。这样,它只会被解析一次,并且正则表达式在我们需要时就可以使用了。 - 我们将一些可变变量设置为骰子表达式的不同部分;骰子数量、它们的类型(面数)和奖励(如果是惩罚则为负数)。我们为它们提供了一些默认值,以防我们在读取字符串(或其部分)时遇到问题。
- 现在我们使用 regex 库的
captures_iter功能;我们将我们正在查看的字符串传递给它,它返回所有捕获的迭代器(复杂的正则表达式可能有许多捕获)。在我们的例子中,这返回一个捕获集,其中可能包含我们上面讨论的所有组。 - 现在,任何组都可能不存在。所以我们对每个捕获组都执行
if let。如果它确实存在,我们使用as_str检索字符串并将其解析为整数 - 并将其分配给骰子读取器的正确部分。 - 我们将所有部分作为元组返回。
定义近战武器
目前,没有必要更改消耗品 - 系统运行良好。我们将专注于可装备物品:您可以挥舞、穿戴或以其他方式从中受益的物品。我们之前对“匕首”的定义如下所示:
{
"name" : "Dagger",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"power_bonus" : 2
}
},
power_bonus 现在已经过时了;武器不再那样工作了。相反,我们希望能够为它们定义类似 D&D 的属性。这是一个现代化的匕首:
{
"name" : "Dagger",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "Quickness",
"base_damage" : "1d4",
"hit_bonus" : 0
}
},
为了支持这一点,在 raws/item_structs.rs 中,我们更改 Weapon 结构体:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Weapon { pub range: String, pub attribute: String, pub base_damage: String, pub hit_bonus: i32 } }
现在打开 components.rs,我们将更改 MeleePowerBonus(并从 main.rs 和 saveload_system.rs 中重命名它)。我们将用 MeleeWeapon 替换它,它捕获了这些方面,但以更机器友好的格式(这样我们就不会一直解析字符串):
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone, Serialize, Deserialize)] pub enum WeaponAttribute { Might, Quickness } #[derive(Component, Serialize, Deserialize, Clone)] pub struct MeleeWeapon { pub attribute : WeaponAttribute, pub damage_n_dice : i32, pub damage_die_type : i32, pub damage_bonus : i32, pub hit_bonus : i32 } }
我们将 attribute 浓缩成一个 enum(读取速度更快),并将 1d4+0 分解为以下含义:(1)damage_n_dice,(4)damage_die_type,加上 damage_bonus。
我们还需要更改 raws/rawmaster.rs 中的 spawn_named_item:
#![allow(unused)] fn main() { if let Some(weapon) = &item_template.weapon { eb = eb.with(Equippable{ slot: EquipmentSlot::Melee }); let (n_dice, die_type, bonus) = parse_dice_string(&weapon.base_damage); let mut wpn = MeleeWeapon{ attribute : WeaponAttribute::Might, damage_n_dice : n_dice, damage_die_type : die_type, damage_bonus : bonus, hit_bonus : weapon.hit_bonus }; match weapon.attribute.as_str() { "Quickness" => wpn.attribute = WeaponAttribute::Quickness, _ => wpn.attribute = WeaponAttribute::Might } eb = eb.with(wpn); } }
这应该足以读取我们更友好的武器格式,并使它们在游戏中使用。
从武器开始
如果您回到设计文档,我们声明您从一些最少的装备开始。我们将让您从您父亲的生锈的长剑开始。让我们将其添加到 spawns.json 文件中:
{
"name" : "Rusty Longsword",
"renderable": {
"glyph" : "/",
"fg" : "#BB77BB",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "Might",
"base_damage" : "1d8-1",
"hit_bonus" : -1
}
},
我们稍微调暗了颜色(毕竟它是生锈的),并为剑添加了 -1 的惩罚(以说明其状况)。现在,我们希望玩家从它开始。目前在 spawners.rs 中,我们的 player 函数创建了玩家 - 仅此而已。我们希望他/她从一些初始装备开始。目前,我们只允许在地板上生成物品;那不行(您必须记住在开始时捡起它!) - 所以我们将扩展我们的原始文件生成系统来处理它(这就是为什么我们在 mod/rawmaster.rs 中有 SpawnType 枚举 - 即使它只有一个条目!)。让我们将 Equipped 和 Carried 添加到该枚举中:
#![allow(unused)] fn main() { pub enum SpawnType { AtPosition { x: i32, y: i32 }, Equipped { by: Entity }, Carried { by: Entity } } }
我们将需要一个函数来确定可装备物品应该放入哪个槽位。这将起到作用:
#![allow(unused)] fn main() { fn find_slot_for_equippable_item(tag : &str, raws: &RawMaster) -> EquipmentSlot { if !raws.item_index.contains_key(tag) { panic!("Trying to equip an unknown item: {}", tag); // 尝试装备未知物品 } let item_index = raws.item_index[tag]; let item = &raws.raws.items[item_index]; if let Some(_wpn) = &item.weapon { return EquipmentSlot::Melee; } else if let Some(wearable) = &item.wearable { return string_to_slot(&wearable.slot); } panic!("Trying to equip {}, but it has no slot tag.", tag); // 尝试装备 {}, 但它没有槽位标签。 } }
请注意,我们显式调用 panic! 来处理可能导致非常奇怪/意外的游戏行为的情况。现在您没有借口不小心处理您的原始文件条目了!它非常简单:它在索引中查找项目名称,并使用它来查找项目。如果是武器,它会从中派生槽位(目前始终为 Melee)。如果是可穿戴物品,它会使用我们的 string_to_slot 函数从中计算出来。
我们还需要更新 spawn_position 函数来处理这个问题:
#![allow(unused)] fn main() { fn spawn_position<'a>(pos : SpawnType, new_entity : EntityBuilder<'a>, tag : &str, raws: &RawMaster) -> EntityBuilder<'a> { let eb = new_entity; // Spawn in the specified location match pos { SpawnType::AtPosition{x,y} => eb.with(Position{ x, y }), SpawnType::Carried{by} => eb.with(InBackpack{ owner: by }), SpawnType::Equipped{by} => { let slot = find_slot_for_equippable_item(tag, raws); eb.with(Equipped{ owner: by, slot }) } } } }
这里有一些值得注意的事情:
- 我们必须更改方法签名,因此您将必须修复对它的调用。它现在需要访问
raws文件,以及您正在生成的项目的名称标签。 - 因为我们传递的是引用,而
EntityBuilder实际上包含对 ECS 的引用,所以我们必须添加一些生命周期修饰,以告诉 Rust 返回的EntityBuilder不依赖于标签或原始文件作为有效引用存在。所以我们命名一个生命周期a- 并将其附加到函数名称(spawn_position<'a>声明'a是它使用的生命周期)。然后我们将<'a>添加到共享该生命周期的类型。这足以提示以避免吓到生命周期检查器。 - 我们从
match返回;AtPosition和Carried很简单;Equipped使用了我们刚刚编写的标签查找器。
我们将不得不更改三行来使用新的函数签名。它们是相同的;找到 eb = spawn_position(pos, eb); 并替换为 eb = spawn_position(pos, eb, key, raws);。
不幸的是,我在实现此功能时遇到了另一个问题。我们一直在将新实体 (ecs.create_entity()) 传递到我们的 spawn_named_x 函数中。不幸的是,这将成为一个问题:我们开始需要实体生成来触发其他实体生成(例如,生成带有装备的 NPC - 下面 - 或生成带有内容的箱子)。让我们现在修复它,这样我们就不会在以后遇到问题。
我们将更改 spawn_named_item 的函数签名,并将 eb 的第一次使用更改为实际创建实体:
#![allow(unused)] fn main() { pub fn spawn_named_item(raws: &RawMaster, ecs : &mut World, key : &str, pos : SpawnType) -> Option<Entity> { if raws.item_index.contains_key(key) { let item_template = &raws.raws.items[raws.item_index[key]]; let mut eb = ecs.create_entity().marked::<SimpleMarker<SerializeMe>>(); ... }
我们将对 spawn_named_mob 执行相同的操作:
#![allow(unused)] fn main() { pub fn spawn_named_mob(raws: &RawMaster, ecs : &mut World, key : &str, pos : SpawnType) -> Option<Entity> { if raws.mob_index.contains_key(key) { let mob_template = &raws.raws.mobs[raws.mob_index[key]]; let mut eb = ecs.create_entity().marked::<SimpleMarker<SerializeMe>>(); ... }
再次对 spawn_named_prop 执行相同的操作:
#![allow(unused)] fn main() { pub fn spawn_named_prop(raws: &RawMaster, ecs : &mut World, key : &str, pos : SpawnType) -> Option<Entity> { if raws.prop_index.contains_key(key) { let prop_template = &raws.raws.props[raws.prop_index[key]]; let mut eb = ecs.create_entity().marked::<SimpleMarker<SerializeMe>>(); ... }
然后这要求我们更改 spawn_named_entity 的签名以及它传递的内容:
#![allow(unused)] fn main() { pub fn spawn_named_entity(raws: &RawMaster, ecs : &mut World, key : &str, pos : SpawnType) -> Option<Entity> { if raws.item_index.contains_key(key) { return spawn_named_item(raws, ecs, key, pos); } else if raws.mob_index.contains_key(key) { return spawn_named_mob(raws, ecs, key, pos); } else if raws.prop_index.contains_key(key) { return spawn_named_prop(raws, ecs, key, pos); } None } }
在 spawner.rs 中,我们需要更改 spawn_named_entity 的调用签名:
#![allow(unused)] fn main() { let spawn_result = spawn_named_entity(&RAWS.lock().unwrap(), ecs, &spawn.1, SpawnType::AtPosition{ x, y}); if spawn_result.is_some() { return; } }
如果您想知道为什么我们不能同时传递 ECS 和新实体,那是因为 Rust 的借用检查器。新实体实际上保留了对其父 ECS 的引用(因此当您调用 build 时,它们知道应该将它们插入哪个世界)。因此,如果您尝试发送 &mut World 和一个新实体 - 您会收到错误,因为您对同一对象有两个“借用”。这样做可能是安全的,但 Rust 无法证明这一点 - 所以它会警告您。这实际上防止了在 C/C++ 世界中经常发现的一整类错误,所以虽然这很痛苦 - 但这是为了我们自己好。
所以现在我们可以更新 spawners.rs 中的 player 函数,使其从生锈的长剑开始:
#![allow(unused)] fn main() { pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity { let mut skills = Skills{ skills: HashMap::new() }; skills.skills.insert(Skill::Melee, 1); skills.skills.insert(Skill::Defense, 1); skills.skills.insert(Skill::Magic, 1); let player = ecs .create_entity() .with(Position { x: player_x, y: player_y }) .with(Renderable { glyph: rltk::to_cp437('@'), fg: RGB::named(rltk::YELLOW), bg: RGB::named(rltk::BLACK), render_order: 0 }) .with(Player{}) .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .with(Name{name: "Player".to_string() }) .with(HungerClock{ state: HungerState::WellFed, duration: 20 }) .with(Attributes{ might: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, fitness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, quickness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, intelligence: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) }, }) .with(skills) .with(Pools{ hit_points : Pool{ current: player_hp_at_level(11, 1), max: player_hp_at_level(11, 1) }, mana: Pool{ current: mana_at_level(11, 1), max: mana_at_level(11, 1) }, xp: 0, level: 1 }) .marked::<SimpleMarker<SerializeMe>>() .build(); // Starting equipment spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Rusty Longsword", SpawnType::Equipped{by : player}); player } }
这里的主要变化是我们将新实体放入名为 player 的变量中,然后再返回它。然后我们将其用作参数,用于谁持有我们通过 spawn_named_entity 生成的“生锈的长剑”。
如果您现在 cargo run,您将从生锈的长剑开始。它不会 工作,但您拥有它:

让生锈的长剑造成一些伤害
我们在 melee_combat_system.rs 中留下了一些占位符。现在,是时候填补武器空白了。打开文件,我们首先添加一些我们需要的更多类型:
#![allow(unused)] fn main() { impl<'a> System<'a> for MeleeCombatSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToMelee>, ReadStorage<'a, Name>, ReadStorage<'a, Attributes>, ReadStorage<'a, Skills>, WriteStorage<'a, SufferDamage>, WriteExpect<'a, ParticleBuilder>, ReadStorage<'a, Position>, ReadStorage<'a, HungerClock>, ReadStorage<'a, Pools>, WriteExpect<'a, rltk::RandomNumberGenerator>, ReadStorage<'a, Equipped>, ReadStorage<'a, MeleeWeapon> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut log, mut wants_melee, names, attributes, skills, mut inflict_damage, mut particle_builder, positions, hunger_clock, pools, mut rng, equipped_items, meleeweapons) = data; ... }
然后我们将添加一些代码来放入默认武器信息,然后在攻击者装备了某些东西时搜索替换:
#![allow(unused)] fn main() { let mut weapon_info = MeleeWeapon{ attribute : WeaponAttribute::Might, hit_bonus : 0, damage_n_dice : 1, damage_die_type : 4, damage_bonus : 0 }; for (wielded,melee) in (&equipped_items, &meleeweapons).join() { if wielded.owner == entity && wielded.slot == EquipmentSlot::Melee { weapon_info = melee.clone(); } } }
这使得替换代码中依赖于武器的部分非常容易:
#![allow(unused)] fn main() { let natural_roll = rng.roll_dice(1, 20); let attribute_hit_bonus = if weapon_info.attribute == WeaponAttribute::Might { attacker_attributes.might.bonus } else { attacker_attributes.quickness.bonus}; let skill_hit_bonus = skill_bonus(Skill::Melee, &*attacker_skills); let weapon_hit_bonus = weapon_info.hit_bonus; }
我们还可以换入武器的伤害信息:
#![allow(unused)] fn main() { let base_damage = rng.roll_dice(weapon_info.damage_n_dice, weapon_info.damage_die_type); let attr_damage_bonus = attacker_attributes.might.bonus; let skill_damage_bonus = skill_bonus(Skill::Melee, &*attacker_skills); let weapon_damage_bonus = weapon_info.damage_bonus; }
现在,如果您 cargo run 该项目,您可以使用您的剑将老鼠砍成碎片!

哦 - 嗯,那进展得不太顺利!我们造成了大量的伤害,但老鼠很快就压倒了我们 - 即使使用 1d4 的默认伤害和 力量 惩罚!正如您在录音中看到的那样,我试图撤退,打算治疗 - 并注意到我饿了(找到房子花了太长时间!)并且无法治疗。幸运的是,我们拥有添加一些食物到玩家背包所需的一切。设计文档声明您应该从一杯啤酒和一根干香肠开始。让我们将它们放入 spawns.json 中:
{
"name" : "Dried Sausage",
"renderable": {
"glyph" : "%",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"food" : ""
}
}
},
{
"name" : "Beer",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "provides_healing" : "4" }
}
},
香肠是“口粮”的复制品 - 可以治愈饥饿。啤酒是一种超弱的治疗药水,但这足以击败啮齿动物的威胁!
让我们修改 spawner.rs 中的 player 以也将这些包含在玩家的背包中:
#![allow(unused)] fn main() { spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Dried Sausage", SpawnType::Carried{by : player} ); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Beer", SpawnType::Carried{by : player}); }
现在玩家开始时拥有治疗选项和抗饥饿设备(通常称为食物)。
但是等等 - 我们是裸体的,只有香肠和一些啤酒?我不认为这是那种游戏?
游戏中没有人穿着任何东西。对于老鼠来说可能没问题,但我们并没有在这里为人类设想一个非常自由的社会。更重要的是,如果我们没有任何东西可以穿,我们也没有任何护甲等级奖励!
在 components.rs 中,我们将用 Wearable 替换 DefenseBonus - 并将其充实一些。(不要忘记更改 main.rs 和 saveload_system.rs 中的组件):
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct Wearable { pub armor_class : f32 } }
这是一个非常简单的更改。让我们更新 raws/item_structs.rs 中的原始文件读取器,以反映我们想要的内容:
{
"name" : "Shield",
"renderable": {
"glyph" : "[",
"fg" : "#00AAFF",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Shield",
"armor_class" : 1.0
}
},
我们可能还想支持更多装备槽位!在 components.rs 中,我们应该更新 EquipmentSlot 以处理更多可能的位置:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone, Serialize, Deserialize)] pub enum EquipmentSlot { Melee, Shield, Head, Torso, Legs, Feet, Hands } }
我们无疑会在稍后添加更多,但这涵盖了基础知识。我们需要更新 raws/item_structs.rs 以反映更改:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Item { pub name : String, pub renderable : Option<Renderable>, pub consumable : Option<Consumable>, pub weapon : Option<Weapon>, pub wearable : Option<Wearable> } ... #[derive(Deserialize, Debug)] pub struct Wearable { pub armor_class: f32, pub slot : String } }
我们将需要多次从字符串(在 JSON 中)转换为 EquipmentSlot,因此我们将在 raws/rawmaster.rs 中添加一个函数来执行此操作:
#![allow(unused)] fn main() { pub fn string_to_slot(slot : &str) -> EquipmentSlot { match slot { "Shield" => EquipmentSlot::Shield, "Head" => EquipmentSlot::Head, "Torso" => EquipmentSlot::Torso, "Legs" => EquipmentSlot::Legs, "Feet" => EquipmentSlot::Feet, "Hands" => EquipmentSlot::Hands, "Melee" => EquipmentSlot::Melee, _ => { rltk::console::log(format!("Warning: unknown equipment slot type [{}]", slot)); EquipmentSlot::Melee } // 警告:未知的装备槽位类型 } } }
我们将要扩展 raws/rawmaster.rs 中的 spawn_named_item 代码以处理扩展的选项:
#![allow(unused)] fn main() { if let Some(wearable) = &item_template.wearable { let slot = string_to_slot(&wearable.slot); eb = eb.with(Equippable{ slot }); eb = eb.with(Wearable{ slot, armor_class: wearable.armor_class }); } }
让我们在 spawns.json 中制作更多物品,让玩家可以穿戴:
{
"name" : "Stained Tunic",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Torso",
"armor_class" : 0.1
}
},
{
"name" : "Torn Trousers",
"renderable": {
"glyph" : "[",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Legs",
"armor_class" : 0.1
}
},
{
"name" : "Old Boots",
"renderable": {
"glyph" : "[",
"fg" : "#FF9999",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Legs",
"armor_class" : 0.1
}
}
现在我们将打开 spawner.rs 并将这些物品添加到玩家:
#![allow(unused)] fn main() { spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Rusty Longsword", SpawnType::Equipped{by : player}); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Dried Sausage", SpawnType::Carried{by : player} ); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Beer", SpawnType::Carried{by : player}); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Stained Tunic", SpawnType::Equipped{by : player}); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Torn Trousers", SpawnType::Equipped{by : player}); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Old Boots", SpawnType::Equipped{by : player}); }
如果您现在运行游戏,检查您的背包并移除物品将显示您已正确开始 - 穿着您那件染色的衬衫、破烂的裤子、旧靴子、啤酒、香肠和一把生锈的剑。
我们还有一件关于可穿戴物品的事情要做;melee_system.rs 需要知道如何计算护甲等级。幸运的是,这非常容易:
#![allow(unused)] fn main() { let mut armor_item_bonus_f = 0.0; for (wielded,armor) in (&equipped_items, &wearables).join() { if wielded.owner == wants_melee.target { armor_item_bonus_f += armor.armor_class; } } let base_armor_class = 10; let armor_quickness_bonus = target_attributes.quickness.bonus; let armor_skill_bonus = skill_bonus(Skill::Defense, &*target_skills); let armor_item_bonus = armor_item_bonus_f as i32; }
我们遍历装备的盔甲,并将防御者佩戴的每件物品的奖励加在一起。然后我们将数字截断为整数。
那么我们为什么 要 使用浮点数呢?经典的 D&D 将护甲值分配给整套护甲。因此,在第 5 版中,皮甲的 AC 为 11(加上敏捷)。在我们的游戏中,您可以单独穿戴皮甲的各个部件 - 因此我们给它们一个相当于整套所需 AC 一部分 的值。然后我们将它们加在一起,以处理零散的盔甲(例如,您找到了一件漂亮的胸甲,但只有皮革护腿)。
好的,所以我穿着衣服 - 为什么其他人不穿呢?
我们已经实现了足够的衣服和武器,我们可以开始将它们交给 NPC。由于酒保是我们最喜欢的测试对象,让我们用我们 希望 生成物品的方式来装饰他的条目:
{
"name" : "Barkeep",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 4,
"ai" : "vendor",
"attributes" : {
"intelligence" : 13
},
"skills" : {
"Melee" : 2
},
"equipped" : [ "Cudgel", "Cloth Tunic", "Cloth Pants", "Slippers" ]
},
很简单:我们添加了一个名为 equipped 的数组,并列出了我们希望酒保穿戴的所有物品。当然,我们现在必须 编写 这些物品。
{
"name" : "Cudgel",
"renderable": {
"glyph" : "/",
"fg" : "#A52A2A",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "Quickness",
"base_damage" : "1d4",
"hit_bonus" : 0
}
},
{
"name" : "Cloth Tunic",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Torso",
"armor_class" : 0.1
}
},
{
"name" : "Cloth Pants",
"renderable": {
"glyph" : "[",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Legs",
"armor_class" : 0.1
}
},
{
"name" : "Slippers",
"renderable": {
"glyph" : "[",
"fg" : "#FF9999",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Legs",
"armor_class" : 0.1
}
}
那里没有什么 新 的东西,只是数据录入。我们需要修改我们的 raws/mob_structs.rs 文件,以适应为 NPC 提供装备:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub vision_range : i32, pub ai : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32>, pub equipped : Option<Vec<String>> } }
再次,很简单 - 我们可选地为 mob 提供一个字符串列表(表示物品名称标签)。所以我们必须修改 raws/rawmaster.rs 中的 spawn_named_mob 来处理这个问题。我们将 Some(eb.build()) 替换为:
#![allow(unused)] fn main() { let new_mob = eb.build(); // Are they wielding anyting? if let Some(wielding) = &mob_template.equipped { for tag in wielding.iter() { spawn_named_entity(raws, ecs, tag, SpawnType::Equipped{ by: new_mob }); } } return Some(new_mob); }
这创建了新的 mob,并将实体存储为 new_mob。然后它查看 mob 模板中是否有 equipped 字段;如果有,它会遍历它,生成每个物品作为 mob 的装备。
如果您现在 cargo run,您会发现您穿着衣服,挥舞着生锈的剑,并且拥有您的啤酒和香肠。

装饰您的 NPC
我不会在这里粘贴完整的 spawns.json 文件,但如果您 查看源代码,您会发现我为我们的基本 NPC 添加了服装。目前,它们都与酒保相同 - 希望我们将来会记得调整这些!
那么自然攻击和防御呢?
所以 NPC 们穿着得体并配备了装备,这给了他们战斗属性和护甲等级。但是我们的老鼠呢?老鼠通常不穿可爱的小老鼠套装和携带武器(如果您有这样做
用户界面
关于本教程
本教程是免费且开源的,所有代码都使用 MIT 许可证 - 因此您可以随意使用它。我希望您能喜欢本教程,并制作出伟大的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
与城镇一起,玩家首先看到的就是你的用户界面(User Interface, UI)。我们已经用一个相当不错的界面凑合了一段时间,但它还不够出色。理想情况下,用户界面应该使新玩家容易上手,同时仍然为老玩家提供足够的深度。它应该支持键盘和鼠标输入(我知道许多资深的 Roguelike 玩家讨厌鼠标;但许多新玩家喜欢它),并提供关于符号汤实际含义的反馈。符号是表示世界的好方法,但在你的大脑将 g 与 goblin(哥布林)联系起来并想象出可怕的小家伙时,会有一个学习曲线。
Cogmind 是 ASCII (以及简单的 tile(图块)) 用户界面的灵感来源。如果您还没有玩过,我衷心建议您去看看。此外,在与 Red Blob Games 的创建者的一次对话中,他对良好 UI 的重要性给出了一些非常有见地的评论:预先构建 UI 可以帮助你意识到你是否可以向玩家展示你正在制作的东西,并且可以为你的构建提供非常好的“感觉”。因此,一旦你完成了最初的原型设计(prototyping),构建用户界面就可以作为其余工作的指南。他是一位非常聪明的人,所以我们将采纳他的建议!
原型设计用户界面
我喜欢在 Rex Paint 中草绘 UI。这是我最初为本教程游戏想到的:

就 ASCII 用户界面而言,这算是一个不错的开始。一些相关的注意事项:
- 我们已将终端(terminal)扩展到
80x60,这对于这些游戏来说是一个非常常见的分辨率(Cogmind 默认使用它)。 - 我们缩小了屏幕上用于地图(map)的区域,以便在屏幕上同时显示更多相关信息;它实际上是
50x48。 - 底部面板是日志(log),我给它着色并添加了一些愚蠢的虚假文本,只是为了清楚地表明它的用途。我们肯定希望改进我们的日志记录体验,以帮助玩家沉浸其中。
- 右上方显示了一些重要信息:你的生命值(health)和魔法值(mana),都以数字和条形图显示。在下面,我们显示了你的属性(attributes) - 并突出显示了以某种方式得到提升的属性(我们没有说明是如何提升的!)。
- 下一个面板列出了你已装备的物品栏(equipped inventory)。
- 在下面,我们展示了你的消耗品(consumables) - 完整地显示了激活它们的热键(hot-key)(shift + 数字)。
- 在下面,我们展示了一个示例咒语(spell) - 尚未实现,但这个想法仍然存在。
- 在右侧面板的底部,我们列出了状态效果(status effects)。设计文档说你开始时会宿醉(hangover),所以我们列出了它(即使它还没有编写出来)。你也会以*饱腹(well fed)*状态开始,所以我们也会显示它。
更改控制台大小
在 main.rs 中,我们的 main 函数首先要做的是引导 RLTK。我们在这里指定分辨率和窗口标题。因此,我们将更新它以匹配我们想要的内容:
#![allow(unused)] fn main() { use rltk::RltkBuilder; let mut context = RltkBuilder::simple(80, 60) .unwrap() .with_title("Roguelike Tutorial") .build()?; }
如果你现在 cargo run,你会看到一个更大的控制台 - 并且没有任何东西利用额外的空间!

我们稍后会担心修复主菜单。让我们首先使游戏看起来像原型草图。
限制渲染的地图
原型中的地图从 1,1 开始,一直延伸到 48,44。因此打开 camera.rs,我们将更改边界。我们将使用我们期望的视口(viewport),而不是使用屏幕边界:
#![allow(unused)] fn main() { pub fn get_screen_bounds(ecs: &World, _ctx: &mut Rltk) -> (i32, i32, i32, i32) { let player_pos = ecs.fetch::<Point>(); //let (x_chars, y_chars) = ctx.get_char_size(); let (x_chars, y_chars) = (48, 44); // 设置地图视口大小为 48x44 (Set map viewport size to 48x44) let center_x = (x_chars / 2) as i32; let center_y = (y_chars / 2) as i32; let min_x = player_pos.x - center_x; let max_x = min_x + x_chars as i32; let min_y = player_pos.y - center_y; let max_y = min_y + y_chars as i32; (min_x, max_x, min_y, max_y) } }
我们没有读取屏幕尺寸并缩放它,而是将地图限制为所需的视口。我们保留了 ctx 参数,即使我们没有使用它,这样就不会破坏所有其他使用它的地方。
地图视口现在被很好地限制了:

绘制框
我们将进入 gui.rs(特别是 draw_ui),并开始放置构成用户界面的基本框(box)。我们还将注释掉我们尚未使用的部分。RLTK 的 box 函数效果很好,但它会填充框。这不是我们这里需要的,所以在 gui.rs 的顶部我添加了一个新函数:
#![allow(unused)] fn main() { pub fn draw_hollow_box( console: &mut Rltk, sx: i32, sy: i32, width: i32, height: i32, fg: RGB, bg: RGB, ) { use rltk::to_cp437; console.set(sx, sy, fg, bg, to_cp437('┌')); console.set(sx + width, sy, fg, bg, to_cp437('┐')); console.set(sx, sy + height, fg, bg, to_cp437('└')); console.set(sx + width, sy + height, fg, bg, to_cp437('┘')); for x in sx + 1..sx + width { console.set(x, sy, fg, bg, to_cp437('─')); console.set(x, sy + height, fg, bg, to_cp437('─')); } for y in sy + 1..sy + height { console.set(sx, y, fg, bg, to_cp437('│')); console.set(sx + width, y, fg, bg, to_cp437('│')); } } }
这实际上是从 RLTK 复制过来的,但去除了填充(fill)。
接下来,我们开始处理 draw_ui:
#![allow(unused)] fn main() { pub fn draw_ui(ecs: &World, ctx : &mut Rltk) { use rltk::to_cp437; let box_gray : RGB = RGB::from_hex("#999999").expect("Oops"); let black = RGB::named(rltk::BLACK); draw_hollow_box(ctx, 0, 0, 79, 59, box_gray, black); // Overall box (整体框) draw_hollow_box(ctx, 0, 0, 49, 45, box_gray, black); // Map box (地图框) draw_hollow_box(ctx, 0, 45, 79, 14, box_gray, black); // Log box (日志框) draw_hollow_box(ctx, 49, 0, 30, 8, box_gray, black); // Top-right panel (右上角面板) } }
这为我们提供了一个裁剪后的地图,以及原型图形中的基本框轮廓:

现在我们添加一些框连接符(box connectors)进去,使其看起来更平滑:
#![allow(unused)] fn main() { ctx.set(0, 45, box_gray, black, to_cp437('├')); ctx.set(49, 8, box_gray, black, to_cp437('├')); ctx.set(49, 0, box_gray, black, to_cp437('┬')); ctx.set(49, 45, box_gray, black, to_cp437('┴')); ctx.set(79, 8, box_gray, black, to_cp437('┤')); ctx.set(79, 45, box_gray, black, to_cp437('┤')); }

添加地图名称
在顶部显示地图名称看起来确实不错 - 但地图目前没有名称!让我们纠正一下。打开 map/mod.rs 并修改 Map 结构:
#![allow(unused)] fn main() { #[derive(Default, Serialize, Deserialize, Clone)] pub struct Map { pub tiles : Vec<TileType>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool>, pub blocked : Vec<bool>, pub depth : i32, pub bloodstains : HashSet<usize>, pub view_blocked : HashSet<usize>, pub name : String, // 地图名称 (Map name) #[serde(skip_serializing)] #[serde(skip_deserializing)] pub tile_content : Vec<Vec<Entity>> } }
我们还将修改构造器(constructor),使用我们在其他地方使用的 to_string 模式,以便你可以发送任何类似字符串的内容:
#![allow(unused)] fn main() { /// Generates an empty map, consisting entirely of solid walls (生成一个空地图,完全由坚固的墙壁组成) pub fn new<S : ToString>(new_depth : i32, width: i32, height: i32, name: S) -> Map { let map_tile_count = (width*height) as usize; Map{ tiles : vec![TileType::Wall; map_tile_count], width, height, revealed_tiles : vec![false; map_tile_count], visible_tiles : vec![false; map_tile_count], blocked : vec![false; map_tile_count], tile_content : vec![Vec::new(); map_tile_count], depth: new_depth, bloodstains: HashSet::new(), view_blocked : HashSet::new(), name : name.to_string() } } }
在 map_builders/waveform_collapse/mod.rs 中(第 39、62 和 78 行)更新对 Map::new 的调用,使其读取 build_data.map = Map::new(build_data.map.depth, build_data.width, build_data.height, &build_data.map.name);。
在 map_builders/mod.rs 中更新 BuilderChain 构造器:
#![allow(unused)] fn main() { impl BuilderChain { pub fn new<S : ToString>(new_depth : i32, width: i32, height: i32, name : S) -> BuilderChain { BuilderChain{ starter: None, builders: Vec::new(), build_data : BuilderMap { spawn_list: Vec::new(), map: Map::new(new_depth, width, height, name), starting_position: None, rooms: None, corridors: None, history : Vec::new(), width, height } } } ... }
此外,第 268 行更改为:let mut builder = BuilderChain::new(new_depth, width, height, "New Map");。
main.rs 第 465 行更改为:gs.ecs.insert(Map::new(1, 64, 64, "New Map"));。
最后,在 map_builders/town.rs 中更改构造器以命名我们的城镇。我建议你选择一个不是我公司的名字!
#![allow(unused)] fn main() { pub fn town_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "The Town of Bracketon"); chain.start_with(TownBuilder::new()); chain } }
唷!完成所有这些之后,让我们在 gui.rs 中绘制地图名称:
#![allow(unused)] fn main() { // Draw the town name (绘制城镇名称) let map = ecs.fetch::<Map>(); let name_length = map.name.len() + 2; let x_pos = (22 - (name_length / 2)) as i32; ctx.set(x_pos, 0, box_gray, black, to_cp437('┤')); ctx.set(x_pos + name_length as i32, 0, box_gray, black, to_cp437('├')); ctx.print_color(x_pos+1, 0, white, black, &map.name); std::mem::drop(map); }
因此,我们从 ECS World 中获取地图,计算名称的长度(加上 2 个用于包装字符)。然后我们计算出居中的位置(在地图窗格上方;所以是 22,窗格宽度的一半,减去名称长度的一半)。然后我们绘制端盖和名称。你可以 cargo run 来查看改进:

显示生命值、魔法值和属性
我们可以修改现有的生命值和魔法值代码。以下代码可以工作:
#![allow(unused)] fn main() { // Draw stats (绘制属性) let player_entity = ecs.fetch::<Entity>(); let pools = ecs.read_storage::<Pools>(); let player_pools = pools.get(*player_entity).unwrap(); let health = format!("Health: {}/{}", player_pools.hit_points.current, player_pools.hit_points.max); let mana = format!("Mana: {}/{}", player_pools.mana.current, player_pools.mana.max); ctx.print_color(50, 1, white, black, &health); ctx.print_color(50, 2, white, black, &mana); ctx.draw_bar_horizontal(64, 1, 14, player_pools.hit_points.current, player_pools.hit_points.max, RGB::named(rltk::RED), RGB::named(rltk::BLACK)); ctx.draw_bar_horizontal(64, 2, 14, player_pools.mana.current, player_pools.mana.max, RGB::named(rltk::BLUE), RGB::named(rltk::BLACK)); }
在下面,我们想要显示属性。由于我们以相同的方式格式化每个属性,让我们引入一个函数:
#![allow(unused)] fn main() { fn draw_attribute(name : &str, attribute : &Attribute, y : i32, ctx: &mut Rltk) { let black = RGB::named(rltk::BLACK); let attr_gray : RGB = RGB::from_hex("#CCCCCC").expect("Oops"); ctx.print_color(50, y, attr_gray, black, name); let color : RGB = if attribute.modifiers < 0 { RGB::from_f32(1.0, 0.0, 0.0) } // 负面修改器为红色 (Negative modifier is red) else if attribute.modifiers == 0 { RGB::named(rltk::WHITE) } // 无修改器为白色 (No modifier is white) else { RGB::from_f32(0.0, 1.0, 0.0) }; // 正面修改器为绿色 (Positive modifier is green) ctx.print_color(67, y, color, black, &format!("{}", attribute.base + attribute.modifiers)); ctx.print_color(73, y, color, black, &format!("{}", attribute.bonus)); if attribute.bonus > 0 { ctx.set(72, y, color, black, rltk::to_cp437('+')); } } }
因此,此属性以较浅的灰色在 50,y 处打印名称。然后我们根据修改器确定颜色;如果没有修改器,我们使用白色。如果它们是负面的(negative),我们使用红色。如果它们是正面的(positive),我们使用绿色。这样我们就可以在 67,y 处打印值 + 修改器(总计)。我们将在 73,y 处打印 bonus。如果 bonus 为正数,我们将添加一个 + 符号。
现在我们可以从我们的 draw_ui 函数中调用它:
#![allow(unused)] fn main() { // Attributes (属性) let attributes = ecs.read_storage::<Attributes>(); let attr = attributes.get(*player_entity).unwrap(); draw_attribute("Might:", &attr.might, 4, ctx); draw_attribute("Quickness:", &attr.quickness, 5, ctx); draw_attribute("Fitness:", &attr.fitness, 6, ctx); draw_attribute("Intelligence:", &attr.intelligence, 7, ctx); }
现在 cargo run,你会看到我们肯定在取得进展:

添加已装备物品
原型 UI 的一个不错的功能是它显示了我们已装备的装备。这实际上非常容易,所以让我们来做吧!我们迭代 Equipped 物品,如果它们的 owner 等于玩家,我们显示它们的 Name:
#![allow(unused)] fn main() { // Equipped (已装备) let mut y = 9; let equipped = ecs.read_storage::<Equipped>(); let name = ecs.read_storage::<Name>(); for (equipped_by, item_name) in (&equipped, &name).join() { if equipped_by.owner == *player_entity { ctx.print_color(50, y, white, black, &item_name.name); y += 1; } } }
添加消耗品
这也很容易:
#![allow(unused)] fn main() { // Consumables (消耗品) y += 1; let green = RGB::from_f32(0.0, 1.0, 0.0); let yellow = RGB::named(rltk::YELLOW); let consumables = ecs.read_storage::<Consumable>(); let backpack = ecs.read_storage::<InBackpack>(); let mut index = 1; for (carried_by, _consumable, item_name) in (&backpack, &consumables, &name).join() { if carried_by.owner == *player_entity && index < 10 { ctx.print_color(50, y, yellow, black, &format!("↑{}", index)); ctx.print_color(53, y, green, black, &item_name.name); y += 1; index += 1; } } }
我们将 y 加 1,以强制它下降一行。然后将 index 设置为 1(不是零,因为我们的目标是键盘上的按键!)。然后我们 join backpack、consumables 和 name。对于每个物品,我们检查 owner 是否是玩家,并且 index 仍然小于 10。如果是,我们以 ↑1 Dried Sausage 的格式打印名称 - 其中 1 是 index。将索引加一,递增 y,我们就完成了。
现在 cargo run,你会看到我们肯定越来越接近了:
我们稍后会担心使消耗品快捷键工作。让我们先完成 UI!

状态效果
我们将稍微略过这一点,因为我们目前只有一个状态效果。这是先前代码的直接移植,因此无需过多解释:
#![allow(unused)] fn main() { // Status (状态) let hunger = ecs.read_storage::<HungerClock>(); let hc = hunger.get(*player_entity).unwrap(); match hc.state { HungerState::WellFed => ctx.print_color(50, 44, RGB::named(rltk::GREEN), RGB::named(rltk::BLACK), "Well Fed"), // 饱腹 HungerState::Normal => {} HungerState::Hungry => ctx.print_color(50, 44, RGB::named(rltk::ORANGE), RGB::named(rltk::BLACK), "Hungry"), // 饥饿 HungerState::Starving => ctx.print_color(50, 44, RGB::named(rltk::RED), RGB::named(rltk::BLACK), "Starving"), // 饥荒 } }
显示日志
同样,这几乎是直接复制:
#![allow(unused)] fn main() { // Draw the log (绘制日志) let log = ecs.fetch::<GameLog>(); let mut y = 46; for s in log.entries.iter().rev() { if y < 59 { ctx.print(2, y, s); } y += 1; } }
同样,使其具有漂亮的颜色是未来的主题。
工具提示
我们将恢复对绘制工具提示(tool-tips)的调用:
#![allow(unused)] fn main() { draw_tooltips(ecs, ctx); }
在 draw_tooltips 内部,我们首先必须补偿地图现在偏离屏幕的情况。我们只需将 1 添加到 mouse_map_pos:
#![allow(unused)] fn main() { mouse_map_pos.0 += min_x - 1; mouse_map_pos.1 += min_y - 1; }
闪亮的新工具提示!
这使我们的旧工具提示系统正常工作 - 但原型显示了一个漂亮的新显示!因此,我们需要创建一种方法来制作这些漂亮的工具提示并排列它们。由于工具提示可以被认为是独立的实体,我们将创建一个对象来定义它们:
#![allow(unused)] fn main() { struct Tooltip { lines : Vec<String> } impl Tooltip { fn new() -> Tooltip { Tooltip { lines : Vec::new() } } fn add<S:ToString>(&mut self, line : S) { self.lines.push(line.to_string()); } fn width(&self) -> i32 { let mut max = 0; for s in self.lines.iter() { if s.len() > max { max = s.len(); } } max as i32 + 2i32 } fn height(&self) -> i32 { self.lines.len() as i32 + 2i32 } fn render(&self, ctx : &mut Rltk, x : i32, y : i32) { let box_gray : RGB = RGB::from_hex("#999999").expect("Oops"); let light_gray : RGB = RGB::from_hex("#DDDDDD").expect("Oops"); let white = RGB::named(rltk::WHITE); let black = RGB::named(rltk::BLACK); ctx.draw_box(x, y, self.width()-1, self.height()-1, white, box_gray); for (i,s) in self.lines.iter().enumerate() { let col = if i == 0 { white } else { light_gray }; ctx.print_color(x+1, y+i as i32+1, col, black, &s); } } } }
这里的想法是考虑构成工具提示的要素:
- 工具提示最重要的部分是文本:因此我们有一个向量(
String类型)来表示每一行。 - 我们需要一种制作新工具提示的方法,因此我们定义了一个构造函数 -
new函数。这将创建一个空的工具提示。 - 我们需要一种添加行的方法,因此我们定义了一个
add函数。 - 我们需要知道提示有多宽,以便我们可以确定将其放在屏幕上的哪个位置。因此我们有一个
width函数。它遍历工具提示的每一行,找到最长的宽度(我们尚不支持换行),并使用它 - 并加上2,以考虑边框。 - 我们还需要知道工具提示的高度 - 因此
height函数是行数加上2,以考虑边框。 - 我们需要能够将工具提示渲染到控制台。我们首先使用 RLTK 的
draw_box功能绘制一个框并填充它(这里不需要空心框!),然后依次添加每一行。
所以现在我们实际上需要使用一些工具提示。我们将很大程度上替换 draw_tooltips 函数。我们将首先获得访问我们需要的 ECS 各个部分:
#![allow(unused)] fn main() { fn draw_tooltips(ecs: &World, ctx : &mut Rltk) { use rltk::to_cp437; let (min_x, _max_x, min_y, _max_y) = camera::get_screen_bounds(ecs, ctx); let map = ecs.fetch::<Map>(); let names = ecs.read_storage::<Name>(); let positions = ecs.read_storage::<Position>(); let hidden = ecs.read_storage::<Hidden>(); let attributes = ecs.read_storage::<Attributes>(); let pools = ecs.read_storage::<Pools>(); let entities = ecs.entities(); ... }
这没有什么我们以前没有做过的,所以无需过多解释。你只是在向 ECS 请求 read_storage 以访问组件存储(component stores),并使用 fetch 获取地图。接下来,我们查询控制台中的鼠标位置,并将其转换为地图上的位置:
#![allow(unused)] fn main() { ... let mouse_pos = ctx.mouse_pos(); let mut mouse_map_pos = mouse_pos; mouse_map_pos.0 += min_x - 1; mouse_map_pos.1 += min_y - 1; if mouse_map_pos.0 >= map.width-1 || mouse_map_pos.1 >= map.height-1 || mouse_map_pos.0 < 1 || mouse_map_pos.1 < 1 { return; } ... }
请注意,如果鼠标光标在地图之外,我们是如何调用 return 的。我们还添加了 min_x 和 min_y 以找到地图空间中的屏幕位置,并减去 1 以考虑地图的边框。mouse_map_pos 现在包含鼠标光标在地图上的位置。接下来,我们看看这里有什么,以及它是否需要工具提示:
#![allow(unused)] fn main() { ... let mut tip_boxes : Vec<Tooltip> = Vec::new(); for (entity, name, position, _hidden) in (&entities, &names, &positions, !&hidden).join() { if position.x == mouse_map_pos.0 && position.y == mouse_map_pos.1 { ... }
因此,我们正在创建一个新的向量 tip_boxes。这将包含我们决定需要的任何工具提示。然后我们使用 Specs 的 join 函数来搜索具有所有名称、位置并且没有 hidden 组件的实体(这就是感叹号的作用)。左侧列表是变量,它将保存每个匹配实体的组件;右侧列表是我们正在搜索的实体存储。然后我们检查实体的 Position 组件的位置是否与我们创建的 mouse_map_pos 结构相同。
现在我们已经确定实体在我们正在检查的 tile(图块)上,我们开始构建工具提示内容:
#![allow(unused)] fn main() { let mut tip = Tooltip::new(); tip.add(name.name.to_string()); }
这将创建一个新的工具提示对象,并将实体的名称添加为第一行。
#![allow(unused)] fn main() { // Comment on attributes (属性注释) let attr = attributes.get(entity); if let Some(attr) = attr { let mut s = "".to_string(); if attr.might.bonus < 0 { s += "Weak. " }; // 虚弱 if attr.might.bonus > 0 { s += "Strong. " }; // 强壮 if attr.quickness.bonus < 0 { s += "Clumsy. " }; // 笨拙 if attr.quickness.bonus > 0 { s += "Agile. " }; // 敏捷 if attr.fitness.bonus < 0 { s += "Unheathy. " }; // 不健康 if attr.fitness.bonus > 0 { s += "Healthy." }; // 健康 if attr.intelligence.bonus < 0 { s += "Unintelligent. "}; // 不聪明 if attr.intelligence.bonus > 0 { s += "Smart. "}; // 聪明 if s.is_empty() { s = "Quite Average".to_string(); // 非常普通 } tip.add(s); } }
现在我们看看实体是否具有属性。如果它有,我们查看每个属性的 bonus,并为其添加一个描述性词语;因此,低力量是“虚弱” - 高力量是“强壮”(依此类推,对于每个属性)。如果没有修改器,我们使用“非常普通” - 并添加描述行。
#![allow(unused)] fn main() { // Comment on pools (属性池注释) let stat = pools.get(entity); if let Some(stat) = stat { tip.add(format!("Level: {}", stat.level)); // 等级 } }
我们还检查实体是否具有 Pools 组件,如果它们有 - 我们将它们的等级添加到工具提示中。最后,我们将工具提示添加到 tip_boxes 向量并退出循环:
#![allow(unused)] fn main() { tip_boxes.push(tip); } } }
如果没有工具提示,那么我们现在不妨退出该函数:
#![allow(unused)] fn main() { if tip_boxes.is_empty() { return; } }
所以如果我们已经走到这一步,就有工具提示!我们将使用类似于我们之前使用的代码来确定是将提示放在目标的左侧还是右侧(哪一侧有更多空间):
#![allow(unused)] fn main() { let box_gray : RGB = RGB::from_hex("#999999").expect("Oops"); let white = RGB::named(rltk::WHITE); let arrow; let arrow_x; let arrow_y = mouse_pos.1; if mouse_pos.0 < 40 { // Render to the left (渲染到左侧) arrow = to_cp437('→'); arrow_x = mouse_pos.0 - 1; } else { // Render to the right (渲染到右侧) arrow = to_cp437('←'); arrow_x = mouse_pos.0 + 1; } ctx.set(arrow_x, arrow_y, white, box_gray, arrow); }
看看我们是如何设置 arrow_x 和 arrow_y 的?如果鼠标在屏幕的左半部分,我们将其放置在目标左侧的一个 tile(图块)处。如果它在屏幕的右半部分,我们将提示放置在右侧。我们还注意到要绘制哪个字符。接下来,我们将计算所有工具提示的总高度:
#![allow(unused)] fn main() { let mut total_height = 0; for tt in tip_boxes.iter() { total_height += tt.height(); } }
这只是所有提示的 height() 函数的总和。现在我们向上移动所有框以使堆栈居中:
#![allow(unused)] fn main() { let mut y = mouse_pos.1 - (total_height / 2); while y + (total_height/2) > 50 { y -= 1; } }
最后,我们实际绘制这些框:
#![allow(unused)] fn main() { for tt in tip_boxes.iter() { let x = if mouse_pos.0 < 40 { mouse_pos.0 - (1 + tt.width()) } else { mouse_pos.0 + (1 + tt.width()) }; tt.render(ctx, x, y); y += tt.height(); } }
如果你现在 cargo run 并将鼠标悬停在角色上方,你将看到类似这样的内容:

这看起来很像我们的原型了!
启用消耗品快捷键
由于我们添加了一种使用 UI 中消耗品的漂亮新方法,我们应该让它们做一些事情!我们在 player.rs 中处理所有玩家输入,所以让我们去那里 - 并查看适当命名的 player_input 函数。我们将在开头添加一个部分,以查看是否按住 shift(这通常是向上箭头的指示),如果 shift 和数字键被按下,则调用一个新函数:
#![allow(unused)] fn main() { pub fn player_input(gs: &mut State, ctx: &mut Rltk) -> RunState { // Hotkeys (快捷键) if ctx.shift && ctx.key.is_some() { let key : Option<i32> = match ctx.key.unwrap() { VirtualKeyCode::Key1 => Some(1), VirtualKeyCode::Key2 => Some(2), VirtualKeyCode::Key3 => Some(3), VirtualKeyCode::Key4 => Some(4), VirtualKeyCode::Key5 => Some(5), VirtualKeyCode::Key6 => Some(6), VirtualKeyCode::Key7 => Some(7), VirtualKeyCode::Key8 => Some(8), VirtualKeyCode::Key9 => Some(9), _ => None }; if let Some(key) = key { return use_consumable_hotkey(gs, key-1); } } ... }
这应该很容易理解:如果 shift 被按下并且一个键被按下(因此 ctx.key.is_some() 返回 true),那么我们 match 数字键码并将 key 设置为匹配的数字(如果调用了其他键,则将其保留为 None)。之后,如果有 Some 键被按下,我们调用 use_consumable_hotkey;如果它返回 true,我们返回新的运行状态 RunState::PlayerTurn 以指示我们做了一些事情。否则,我们让输入像往常一样运行。这留下了编写新函数 use_consumable_hotkey 的工作:
#![allow(unused)] fn main() { fn use_consumable_hotkey(gs: &mut State, key: i32) -> RunState { use super::{Consumable, InBackpack, WantsToUseItem}; let consumables = gs.ecs.read_storage::<Consumable>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let player_entity = gs.ecs.fetch::<Entity>(); let entities = gs.ecs.entities(); let mut carried_consumables = Vec::new(); for (entity, carried_by, _consumable) in (&entities, &backpack, &consumables).join() { if carried_by.owner == *player_entity { carried_consumables.push(entity); } } if (key as usize) < carried_consumables.len() { use crate::components::Ranged; if let Some(ranged) = gs.ecs.read_storage::<Ranged>().get(carried_consumables[key as usize]) { return RunState::ShowTargeting{ range: ranged.range, item: carried_consumables[key as usize] }; } let mut intent = gs.ecs.write_storage::<WantsToUseItem>(); intent.insert( *player_entity, WantsToUseItem{ item: carried_consumables[key as usize], target: None } ).expect("Unable to insert intent"); return RunState::PlayerTurn; } RunState::PlayerTurn } }
让我们逐步了解一下:
- 我们添加了一些
use语句来引用组件。如果你愿意,你也可以将它们放在文件的顶部,但由于我们只是在函数中使用它们,我们就在这里这样做。 - 我们从 ECS 获取一些东西的访问权限;我们已经经常这样做,你现在应该掌握了!
- 我们迭代携带的消耗品,完全像我们渲染 GUI 一样 - 但没有名称。我们将这些存储在一个
carried_consumables向量中,存储物品的 entity。 - 我们检查请求的按键是否落在向量的范围内;如果不是,我们忽略按键并返回 false。
- 我们检查以查看物品是否需要远程目标(ranged targeting);如果需要,我们返回
ShowTargeting运行状态,就像我们通过菜单使用它一样。 - 如果是,那么我们插入一个
WantsToUseItem组件,就像我们在一段时间前的物品栏处理程序中所做的那样。它属于player_entity- 玩家正在使用物品。要使用的item是来自carried_consumables列表的Entity。 - 我们返回
PlayerTurn,使游戏进入PlayerTurn运行状态。
其余的将自动发生:我们已经编写了 WantsToUseItem 的处理程序,这只是提供了另一种指示玩家想要做什么的方法。
所以现在我们为玩家提供了一种不错的、简单的方法来快速使用物品!
总结
在本章中,我们构建了一个非常不错的 GUI。它还没有像它可以达到的那样流畅 - 我们将在接下来的章节中对其进行添加,但它提供了一个良好的工作基础。本章说明了一个构建 GUI 的良好过程:绘制原型,然后逐步实现它。
这是迭代过程:

本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
走进森林!
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出很棒的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
我们用了一些章节来改进基础游戏、它的界面和起始城镇。这很有趣,而且老实说,我们可以持续改进很多章节 - 但在开发过程中看到一些真正的进展是个好主意。 否则,你往往会失去动力! 因此,在本章中,我们将为游戏添加下一个关卡,填充它,并探讨主题的概念来区分关卡。
走进森林!
我们的设计文档表明,我们将从城镇前往石灰岩洞穴。 这是一个好的开始,但从一个地方到另一个地方之间没有任何过渡是很不可能的; 否则,所有人都会去那里! 所以我们将在 Bracketon 镇旁边添加一片森林,其中有一个通往主要冒险的洞穴入口。 一条道路穿过森林,这是其他人通常去的地方(那些不打算拯救世界的人,而这才是大多数人!)。
让我们首先移动 Bracketon 的出口,使其覆盖整个东侧。 在 town.rs 中,找到放置出口的行(大约在第 36 行),并替换为:
#![allow(unused)] fn main() { for y in wall_gap_y-3 .. wall_gap_y + 4 { let exit_idx = build_data.map.xy_idx(build_data.width-2, y); build_data.map.tiles[exit_idx] = TileType::DownStairs; } }
这将用出口瓷砖填充通往城镇外的整条道路:

这有一个主要优点:它真的很难被错过!
构建森林
现在我们想开始第二个关卡。 在 map_builders/mod.rs 中,我们有函数 level_builder; 让我们在其中为第二个关卡添加一个新的调用:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, rng, width, height), 2 => forest_builder(new_depth, rng, width, height), _ => random_builder(new_depth, rng, width, height) } } }
为了实现这一点,我们将创建一个新文件 - map_builders/forest.rs 并给它一些占位符内容(就像我们为城镇所做的那样):
#![allow(unused)] fn main() { use super::{BuilderChain, CellularAutomataBuilder, XStart, YStart, AreaStartingPosition, CullUnreachable, VoronoiSpawning, DistantExit}; pub fn forest_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Into the Woods"); chain.start_with(CellularAutomataBuilder::new()); chain.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); // Setup an exit and spawn mobs // 设置一个出口并生成怪物 chain.with(VoronoiSpawning::new()); chain.with(DistantExit::new()); chain } }
另外,不要忘记将 mod forest; use forest::forest_builder 添加到你的 map_builders/mod.rs 文件中! 如果你运行这个,你会看到我们有一个基本的细胞自动机地牢:

这真的不是我们想要的……还是说是呢? 它在形状上确实有点像森林 - 但到处都用墙壁图形渲染它并不能给人你身处森林的印象。
主题
你可以制作所有新的瓷砖,并让森林生成器吐出它们 - 但这会复制大量代码,仅仅是为了改变外观。 更好的方法是支持主题。 因此,城镇使用一种外观,森林使用另一种外观 - 但它们共享基本功能,例如墙壁阻挡移动。 现在我们揭示了为什么我们将 map 变成多文件模块:我们将构建一个主题引擎! 创建一个新文件,map/themes.rs,我们将放入一个默认函数和我们现有的瓷砖选择代码(来自 camera.rs):
#![allow(unused)] fn main() { use super::{Map, TileType}; use rltk::RGB; pub fn tile_glyph(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { let (glyph, mut fg, mut bg) = match map.depth { 2 => get_forest_glyph(idx, map), _ => get_tile_glyph_default(idx, map) }; if map.bloodstains.contains(&idx) { bg = RGB::from_f32(0.75, 0., 0.); } if !map.visible_tiles[idx] { fg = fg.to_greyscale(); bg = RGB::from_f32(0., 0., 0.); // Don't show stains out of visual range // 不显示视觉范围外的污渍 } (glyph, fg, bg) } fn get_tile_glyph_default(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { let glyph; let fg; let bg = RGB::from_f32(0., 0., 0.); match map.tiles[idx] { TileType::Floor => { glyph = rltk::to_cp437('.'); fg = RGB::from_f32(0.0, 0.5, 0.5); } TileType::WoodFloor => { glyph = rltk::to_cp437('░'); fg = RGB::named(rltk::CHOCOLATE); } TileType::Wall => { let x = idx as i32 % map.width; let y = idx as i32 / map.width; glyph = wall_glyph(&*map, x, y); fg = RGB::from_f32(0., 1.0, 0.); } TileType::DownStairs => { glyph = rltk::to_cp437('>'); fg = RGB::from_f32(0., 1.0, 1.0); } TileType::Bridge => { glyph = rltk::to_cp437('.'); fg = RGB::named(rltk::CHOCOLATE); } TileType::Road => { glyph = rltk::to_cp437('≡'); fg = RGB::named(rltk::GRAY); } TileType::Grass => { glyph = rltk::to_cp437('"'); fg = RGB::named(rltk::GREEN); } TileType::ShallowWater => { glyph = rltk::to_cp437('~'); fg = RGB::named(rltk::CYAN); } TileType::DeepWater => { glyph = rltk::to_cp437('~'); fg = RGB::named(rltk::BLUE); } TileType::Gravel => { glyph = rltk::to_cp437(';'); fg = RGB::from_f32(0.5, 0.5, 0.5); } } (glyph, fg, bg) } fn wall_glyph(map : &Map, x: i32, y:i32) -> rltk::FontCharType { if x < 1 || x > map.width-2 || y < 1 || y > map.height-2 as i32 { return 35; } let mut mask : u8 = 0; if is_revealed_and_wall(map, x, y - 1) { mask +=1; } if is_revealed_and_wall(map, x, y + 1) { mask +=2; } if is_revealed_and_wall(map, x - 1, y) { mask +=4; } if is_revealed_and_wall(map, x + 1, y) { mask +=8; } match mask { 0 => { 9 } // Pillar because we can't see neighbors // 因为我们看不到邻居,所以是柱子 1 => { 186 } // Wall only to the north // 仅北侧有墙 2 => { 186 } // Wall only to the south // 仅南侧有墙 3 => { 186 } // Wall to the north and south // 南北两侧都有墙 4 => { 205 } // Wall only to the west // 仅西侧有墙 5 => { 188 } // Wall to the north and west // 西北两侧都有墙 6 => { 187 } // Wall to the south and west // 西南两侧都有墙 7 => { 185 } // Wall to the north, south and west // 西北南三侧都有墙 8 => { 205 } // Wall only to the east // 仅东侧有墙 9 => { 200 } // Wall to the north and east // 东北两侧都有墙 10 => { 201 } // Wall to the south and east // 东南两侧都有墙 11 => { 204 } // Wall to the north, south and east // 东北南三侧都有墙 12 => { 205 } // Wall to the east and west // 东西两侧都有墙 13 => { 202 } // Wall to the east, west, and south // 东西南三侧都有墙 14 => { 203 } // Wall to the east, west, and north // 东西北三侧都有墙 15 => { 206 } // ╬ Wall on all sides // ╬ 四面都有墙 _ => { 35 } // We missed one? // 我们遗漏了一个? } } fn is_revealed_and_wall(map: &Map, x: i32, y: i32) -> bool { let idx = map.xy_idx(x, y); map.tiles[idx] == TileType::Wall && map.revealed_tiles[idx] } }
在 map/mod.rs 中添加 mod themes; pub use themes::* 以将其添加到你的项目中。
现在我们将修改 camera.rs,删除这些函数,并导入地图主题。 删除 get_tile_glyph、wall_glyph 和 is_revealed_and_wall。 在顶部,添加 use crate::map::tile_glyph 并更改两个渲染函数以使用它:
#![allow(unused)] fn main() { let (glyph, fg, bg) = tile_glyph(idx, &*map); }
这有两个很好的效果:你的相机现在仅仅是一个相机,并且你能够更改每个关卡的主题!
构建森林主题
在 themes.rs 中,让我们扩展 tile_glyph 函数,以便为关卡 2 分支到单独的森林主题:
#![allow(unused)] fn main() { pub fn tile_glyph(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { match map.depth { 2 => get_forest_glyph(idx, map), _ => get_tile_glyph_default(idx, map) } } }
现在,当然,我们必须编写 get_forest_glyph:
#![allow(unused)] fn main() { fn get_forest_glyph(idx:usize, map: &Map) -> (rltk::FontCharType, RGB, RGB) { let glyph; let fg; let bg = RGB::from_f32(0., 0., 0.); match map.tiles[idx] { TileType::Wall => { glyph = rltk::to_cp437('♣'); fg = RGB::from_f32(0.0, 0.6, 0.0); } TileType::Bridge => { glyph = rltk::to_cp437('.'); fg = RGB::named(rltk::CHOCOLATE); } TileType::Road => { glyph = rltk::to_cp437('≡'); fg = RGB::named(rltk::YELLOW); } TileType::Grass => { glyph = rltk::to_cp437('"'); fg = RGB::named(rltk::GREEN); } TileType::ShallowWater => { glyph = rltk::to_cp437('~'); fg = RGB::named(rltk::CYAN); } TileType::DeepWater => { glyph = rltk::to_cp437('~'); fg = RGB::named(rltk::BLUE); } TileType::Gravel => { glyph = rltk::to_cp437(';'); fg = RGB::from_f32(0.5, 0.5, 0.5); } TileType::DownStairs => { glyph = rltk::to_cp437('>'); fg = RGB::from_f32(0., 1.0, 1.0); } _ => { glyph = rltk::to_cp437('"'); fg = RGB::from_f32(0.0, 0.6, 0.0); } } (glyph, fg, bg) } }
现在 cargo run,你会看到视觉上的变化产生了巨大的差异 - 它现在看起来像一个森林!

沿着黄砖路走
我们指定了一条道路穿过关卡,但我们没有为此构建器! 让我们制作一个并将其添加到构建器链中。 首先,我们将修改构建器链 - 摆脱 DistantExit 部分并添加一个新的 YellowBrickRoad 阶段:
#![allow(unused)] fn main() { pub fn forest_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Into the Woods"); chain.start_with(CellularAutomataBuilder::new()); chain.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); chain.with(VoronoiSpawning::new()); chain.with(YellowBrickRoad::new()); chain } }
然后我们将实现 YellowBrickRoad:
#![allow(unused)] fn main() { pub struct YellowBrickRoad {} impl MetaMapBuilder for YellowBrickRoad { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl YellowBrickRoad { #[allow(dead_code)] pub fn new() -> Box<YellowBrickRoad> { Box::new(YellowBrickRoad{}) } fn find_exit(&self, build_data : &mut BuilderMap, seed_x : i32, seed_y: i32) -> (i32, i32) { let mut available_floors : Vec<(usize, f32)> = Vec::new(); for (idx, tiletype) in build_data.map.tiles.iter().enumerate() { if map::tile_walkable(*tiletype) { available_floors.push( ( idx, rltk::DistanceAlg::PythagorasSquared.distance2d( rltk::Point::new(idx as i32 % build_data.map.width, idx as i32 / build_data.map.width), rltk::Point::new(seed_x, seed_y) ) ) ); } } if available_floors.is_empty() { panic!("No valid floors to start on"); } available_floors.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); let end_x = available_floors[0].0 as i32 % build_data.map.width; let end_y = available_floors[0].0 as i32 / build_data.map.width; (end_x, end_y) } fn paint_road(&self, build_data : &mut BuilderMap, x: i32, y: i32) { if x < 1 || x > build_data.map.width-2 || y < 1 || y > build_data.map.height-2 { return; } let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] != TileType::DownStairs { build_data.map.tiles[idx] = TileType::Road; } } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let starting_pos = build_data.starting_position.as_ref().unwrap().clone(); let start_idx = build_data.map.xy_idx(starting_pos.x, starting_pos.y); let (end_x, end_y) = self.find_exit(build_data, build_data.map.width - 2, build_data.map.height / 2); let end_idx = build_data.map.xy_idx(end_x, end_y); build_data.map.tiles[end_idx] = TileType::DownStairs; build_data.map.populate_blocked(); let path = rltk::a_star_search(start_idx, end_idx, &mut build_data.map); //if !path.success { // panic!("No valid path for the road"); //} for idx in path.steps.iter() { let x = *idx as i32 % build_data.map.width; let y = *idx as i32 / build_data.map.width; self.paint_road(build_data, x, y); self.paint_road(build_data, x-1, y); self.paint_road(build_data, x+1, y); self.paint_road(build_data, x, y-1); self.paint_road(build_data, x, y+1); } build_data.take_snapshot(); } } }
这个构建器结合了我们已经实现的一些概念:
find_exit就像AreaStartingPoint构建器一样,但它会找到一个靠近提供的“种子”位置的区域并返回它。 我们将给它一个中东部的种子点,并将结果用作道路的目的地,因为我们从西部开始。paint_road检查瓷砖是否在地图边界内,如果它不是下楼梯 - 将其绘制为道路。build调用a_star_search以找到从西到东的有效路径。 然后,它沿着路径绘制一条 3x3 的道路。
结果是一个森林,其中有一条通往东方的黄色道路。 当然,实际上还没有出口(而且你很可能被狗头人、地精和兽人谋杀!)

添加一个出口 - 和一些面包屑
现在我们将出口隐藏在地图的东北部 - 或东南部,我们将随机选择! 隐藏它提供了一种探索元素,但是不给用户关于位置的线索(尤其是在道路本质上是转移视线的情况下)是让你的玩家感到沮丧的好方法! 我们知道目的地是石灰岩洞穴,而石灰岩洞穴通常是由于水而形成的 - 因此有理由认为洞穴内/周围应该有水源。 我们将在地图上添加一条小溪! 将以下内容添加到你的 build 函数中:
#![allow(unused)] fn main() { // Place exit // 放置出口 let exit_dir = rng.roll_dice(1, 2); let (seed_x, seed_y, stream_startx, stream_starty) = if exit_dir == 1 { (build_data.map.width-1, 1, 0, build_data.height-1) } else { (build_data.map.width-1, build_data.height-1, 1, build_data.height-1) }; let (stairs_x, stairs_y) = self.find_exit(build_data, seed_x, seed_y); let stairs_idx = build_data.map.xy_idx(stairs_x, stairs_y); build_data.take_snapshot(); let (stream_x, stream_y) = self.find_exit(build_data, stream_startx, stream_starty); let stream_idx = build_data.map.xy_idx(stream_x, stream_y) as usize; let stream = rltk::a_star_search(stairs_idx, stream_idx, &mut build_data.map); for tile in stream.steps.iter() { if build_data.map.tiles[*tile as usize] == TileType::Floor { build_data.map.tiles[*tile as usize] = TileType::ShallowWater; } } build_data.map.tiles[stairs_idx] = TileType::DownStairs; build_data.take_snapshot(); }
这会随机选择一个出口位置(从东北和东南),然后在相反方向添加一条小溪。 再次,我们使用路径查找来放置小溪 - 这样我们就不会过多地干扰整体布局。 然后我们放置出口楼梯。
但是 - 我一直被兽人谋杀!
我们让默认生成发生,而没有考虑为我们的关卡更新怪物! 我们的玩家可能等级非常低,特别是考虑到我们直到下一章才会实现升级。 咳咳。 无论如何,我们应该引入一些对初学者友好的生成,并调整我们其他敌人的生成位置。 再次查看 spawns.json,我们将直接进入顶部的生成表。 我们将首先调整我们目前不想看到的物品的 min_depth 条目:
"spawn_table" : [
{ "name" : "Goblin", "weight" : 10, "min_depth" : 3, "max_depth" : 100 },
{ "name" : "Orc", "weight" : 1, "min_depth" : 3, "max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Health Potion", "weight" : 7, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Fireball Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Confusion Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Magic Missile Scroll", "weight" : 4, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Dagger", "weight" : 3, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Shield", "weight" : 3, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Longsword", "weight" : 1, "min_depth" : 3, "max_depth" : 100 },
{ "name" : "Tower Shield", "weight" : 1, "min_depth" : 3, "max_depth" : 100 },
{ "name" : "Rations", "weight" : 10, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Magic Mapping Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Bear Trap", "weight" : 5, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Battleaxe", "weight" : 1, "min_depth" : 2, "max_depth" : 100 },
{ "name" : "Kobold", "weight" : 15, "min_depth" : 3, "max_depth" : 3 }
],
看看怪物在深度 3 之前是如何不出现的? 如果你现在 cargo run,你将拥有一个“Monty Haul”(这是一个关于获得免费物品的旧电视节目;它变成了 D&D 术语,意思是“太容易了,宝藏太多了”)的森林 - 到处都是免费物品,而且看不到任何风险。 我们希望玩家找到一些有用的物品,但我们也希望有一些风险! 如果你每次都只是赢,那就没什么游戏性了!

添加一些林地野兽
你期望在对初学者友好的树林中找到什么? 可能是老鼠、狼、狐狸、各种可食用但无害的野生动物(例如鹿)以及一些旅行者。 你甚至可能会遇到熊,但在这个级别会非常可怕! 我们已经有了老鼠,所以让我们只将它们添加到生成表中:
{ "name" : "Rat", "weight" : 15, "min_depth" : 2, "max_depth" : 3 }
我们可以通过复制/粘贴老鼠并稍作编辑来添加狼:
{
"name" : "Mangy Wolf",
"renderable": {
"glyph" : "w",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"ai" : "melee",
"attributes" : {
"Might" : 3,
"Fitness" : 3
},
"skills" : {
"Melee" : -1,
"Defense" : -1
},
"natural" : {
"armor_class" : 12,
"attacks" : [
{ "name" : "bite", "hit_bonus" : 0, "damage" : "1d6" }
]
}
},
我们希望它们比老鼠少见,所以也把它们放入生成表中 - 但权重较低:
{ "name" : "Mangy Wolf", "weight" : 13, "min_depth" : 2, "max_depth" : 3 }
我们也可以制作一只讨厌的狐狸。 同样,它在暗地里非常像老鼠!
{
"name" : "Fox",
"renderable": {
"glyph" : "f",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"ai" : "melee",
"attributes" : {
"Might" : 3,
"Fitness" : 3
},
"skills" : {
"Melee" : -1,
"Defense" : -1
},
"natural" : {
"armor_class" : 11,
"attacks" : [
{ "name" : "bite", "hit_bonus" : 0, "damage" : "1d4" }
]
}
},
也将狐狸添加到生成表中:
{ "name" : "Fox", "weight" : 15, "min_depth" : 2, "max_depth" : 3 }
仍然太难了 - 让我们给玩家更多生命值!
好的,所以我们仍然经常被谋杀。 让我们给可怜的玩家更多生命值! 打开 gamesystem.rs 并编辑 player_hp_at_level 以增加 10 点生命值:
#![allow(unused)] fn main() { pub fn player_hp_at_level(fitness:i32, level:i32) -> i32 { 10 + (player_hp_per_level(fitness) * level) } }
在真正的游戏中,你会发现自己会大量调整这些东西,直到你获得正确的平衡感!
添加一些无害的野兽
并非典型森林中的所有事物都试图杀死你(除非你住在澳大利亚,我听说)。 让我们从制作一只鹿并给它 bystander AI 开始,这样它就不会伤害任何人:
{
"name" : "Deer",
"renderable": {
"glyph" : "d",
"fg" : "#FFFF00",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"ai" : "bystander",
"attributes" : {
"Might" : 3,
"Fitness" : 3
},
"skills" : {
"Melee" : -1,
"Defense" : -1
},
"natural" : {
"armor_class" : 11,
"attacks" : [
{ "name" : "bite", "hit_bonus" : 0, "damage" : "1d4" }
]
}
},
并将其添加到生成表中:
{ "name" : "Deer", "weight" : 14, "min_depth" : 2, "max_depth" : 3 }
如果你现在 cargo run,你会在森林中遇到大量的生命 - 鹿会随机漫游,什么也不做。

但是鹿肉很好吃!
让鹿使用 bystander 系统的缺点是它们会愚蠢地漫游,而且你 - 和狼 - 都不能吃它们。 在更大的层面上,你也无法吃掉狼(并不是说它们会好吃)。 你也不能出售它们的皮毛,或者以其他方式从它们的屠杀中获利!
似乎这里实际上有三个问题:
- 当我们杀死东西时,它们应该(有时)掉落战利品供我们使用。
- 鹿需要它们自己的 AI。
- 狼需要想要吃鹿,这可能需要它们也有自己的 AI。
掉落战利品
一个好的开始是,当我们杀死一个实体时,它有机会掉落它携带的任何东西。 打开 damage_system.rs,我们将在 delete_the_dead 中添加一个阶段(在我们确定谁死了之后,在我们删除它们之前):
#![allow(unused)] fn main() { // Drop everything held by dead people // 掉落死者持有的所有物品 { let mut to_drop : Vec<(Entity, Position)> = Vec::new(); let entities = ecs.entities(); let mut equipped = ecs.write_storage::<Equipped>(); let mut carried = ecs.write_storage::<InBackpack>(); let mut positions = ecs.write_storage::<Position>(); for victim in dead.iter() { for (entity, equipped) in (&entities, &equipped).join() { if equipped.owner == *victim { // Drop their stuff // 掉落他们的物品 let pos = positions.get(*victim); if let Some(pos) = pos { to_drop.push((entity, pos.clone())); } } } for (entity, backpack) in (&entities, &carried).join() { if backpack.owner == *victim { // Drop their stuff // 掉落他们的物品 let pos = positions.get(*victim); if let Some(pos) = pos { to_drop.push((entity, pos.clone())); } } } } for drop in to_drop.iter() { equipped.remove(drop.0); carried.remove(drop.0); positions.insert(drop.0, drop.1.clone()).expect("Unable to insert position"); } } }
因此,这段代码在 Equipped 和 InBackpack 组件存储中搜索死亡的实体,并将实体的位置和物品列在一个向量中。 然后它迭代该向量,删除该物品的任何 InBackpack 和 Equipped 标签 - 并添加地面上的位置。 这样做的最终结果是,当有人死亡时 - 他们的东西会掉到地板上。 这是一个好的开始,尽管装备精良的实体可能会留下很多东西。 我们稍后会担心这个问题。
因此,有了这段代码,你可以生成你希望实体掉落的所有物品,作为他们随身携带的东西。 从概念上讲,这有点奇怪(我想鹿确实会携带肉……) - 但它会起作用。 但是,我们可能不希望每只鹿都掉落相同的东西。 进入:战利品表!
战利品表
可以稍微控制一下哪些物品在哪里掉落是很好的。 游戏中有“狼掉落任何东西”(甚至盔甲!)和更现实的“狼掉落皮毛和肉”之间的区别。 战利品表让你自己做出这个决定。
我们将首先打开 spawns.json 并构建一个原型,了解我们希望战利品表结构的样子。 我们将尝试使其与生成表相似 - 这样我们就可以利用相同的 RandomTable 基础设施。 这是我想出的:
"loot_tables" : [
{ "name" : "Animal",
"drops" : [
{ "name" : "Hide", "weight" : 10 },
{ "name" : "Meat", "weight" : 10 }
]
}
],
这比生成表稍微复杂一些,因为我们想要有多个战利品表。 因此,分解它:
- 我们有一个外部容器
loot_tables- 它包含许多表。 - 表格有一个
name(用于标识它)和一组drops- 激活战利品表时可以“掉落”的物品。 drops中的每个条目都包含一个name(与物品列表中的物品匹配)和一个weight- 就像随机生成的权重一样。
所以真的,它是单个数组中的多个 - 命名的 - 表格。 现在我们必须读取它; 我们将打开 raws 目录并创建一个新文件:raws/loot_structs.rs。 这旨在匹配战利品表结构的内容:
#![allow(unused)] fn main() { use serde::{Deserialize}; #[derive(Deserialize, Debug)] pub struct LootTable { pub name : String, pub drops : Vec<LootDrop> } #[derive(Deserialize, Debug)] pub struct LootDrop { pub name : String, pub weight : i32 } }
这与 JSON 版本几乎相同,只是采用 Rust 编写。 再次,我们描述了我们尝试读取的结构,并让序列化库 Serde 处理两者之间的转换。 然后我们打开 raws/mod.rs 并添加:
#![allow(unused)] fn main() { mod loot_structs; use loot_structs::*; }
在顶部,并扩展 Raws 结构以包含战利品表:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Raws { pub items : Vec<Item>, pub mobs : Vec<Mob>, pub props : Vec<Prop>, pub spawn_table : Vec<SpawnTableEntry>, pub loot_tables : Vec<LootTable> } }
我们也需要将其添加到 rawmaster.rs 中的构造函数中:
#![allow(unused)] fn main() { impl RawMaster { pub fn empty() -> RawMaster { RawMaster { raws : Raws{ items: Vec::new(), mobs: Vec::new(), props: Vec::new(), spawn_table: Vec::new(), loot_tables: Vec::new() }, item_index : HashMap::new(), mob_index : HashMap::new(), prop_index : HashMap::new(), } } ... }
这足以读取战利品表 - 但我们实际上需要使用它们! 我们将首先在 RawMaster 中添加另一个索引(在 raws/rawmaster.rs 中):
#![allow(unused)] fn main() { pub struct RawMaster { raws : Raws, item_index : HashMap<String, usize>, mob_index : HashMap<String, usize>, prop_index : HashMap<String, usize>, loot_index : HashMap<String, usize> } }
我们还必须将 loot_index : HashMap::new() 添加到 RawMaster::new 函数中,并在 load 函数中添加读取器:
#![allow(unused)] fn main() { for (i,loot) in self.raws.loot_tables.iter().enumerate() { self.loot_index.insert(loot.name.clone(), i); } }
接下来,我们需要为怪物提供拥有战利品表条目的选项。 因此,我们打开 mob_structs.rs 并将其添加到 Mob 结构中:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub vision_range : i32, pub ai : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32>, pub equipped : Option<Vec<String>>, pub natural : Option<MobNatural>, pub loot_table : Option<String> } }
我们还需要添加一个新的组件,因此在 components.rs 中(并在 saveload_system.rs 和 main.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct LootTable { pub table : String } }
然后我们将回到 rawmaster.rs 并查看 spawn_named_mob 函数。 如果怪物支持,我们需要添加附加 LootTable 组件的功能:
#![allow(unused)] fn main() { if let Some(loot) = &mob_template.loot_table { eb = eb.with(LootTable{table: loot.clone()}); } }
我们提到了两个新项目,因此我们需要将它们添加到 spawns.json 的 items 部分中:
{
"name" : "Meat",
"renderable": {
"glyph" : "%",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"food" : ""
}
}
},
{
"name" : "Hide",
"renderable": {
"glyph" : "ß",
"fg" : "#A52A2A",
"bg" : "#000000",
"order" : 2
}
},
你会注意到,在这一点上,皮革完全没用; 我们将在后面的章节中担心这个问题。 现在,让我们修改 mangy wolf 和 deer 以拥有战利品表。 就像添加一行一样简单:
"loot_table" : "Animal"
现在一切都已就绪 - 当生物死亡时,我们实际上需要生成一些战利品! 我们需要一种滚动战利品的方法,因此在 rawmaster.rs 中,我们引入了一个新函数:
#![allow(unused)] fn main() { pub fn get_item_drop(raws: &RawMaster, rng : &mut rltk::RandomNumberGenerator, table: &str) -> Option<String> { if raws.loot_index.contains_key(table) { let mut rt = RandomTable::new(); let available_options = &raws.raws.loot_tables[raws.loot_index[table]]; for item in available_options.drops.iter() { rt = rt.add(item.name.clone(), item.weight); } return Some(rt.roll(rng)); } None } }
这非常简单:我们查看指定名称的表是否存在,如果不存在,则返回 None。 如果它确实存在,我们从原始文件信息中创建一个名称和权重的表 - 并滚动以确定随机加权的结果,然后我们返回该结果。 现在,我们将它附加到 damage_system.rs 中的 delete_the_dead:
#![allow(unused)] fn main() { // Drop everything held by dead people // 掉落死者持有的所有物品 let mut to_spawn : Vec<(String, Position)> = Vec::new(); { // To avoid keeping hold of borrowed entries, use a scope // 为了避免持有借用的条目,请使用作用域 let mut to_drop : Vec<(Entity, Position)> = Vec::new(); let entities = ecs.entities(); let mut equipped = ecs.write_storage::<Equipped>(); let mut carried = ecs.write_storage::<InBackpack>(); let mut positions = ecs.write_storage::<Position>(); let loot_tables = ecs.read_storage::<LootTable>(); let mut rng = ecs.write_resource::<rltk::RandomNumberGenerator>(); for victim in dead.iter() { let pos = positions.get(*victim); for (entity, equipped) in (&entities, &equipped).join() { if equipped.owner == *victim { // Drop their stuff // 掉落他们的物品 if let Some(pos) = pos { to_drop.push((entity, pos.clone())); } } } for (entity, backpack) in (&entities, &carried).join() { if backpack.owner == *victim { // Drop their stuff // 掉落他们的物品 if let Some(pos) = pos { to_drop.push((entity, pos.clone())); } } } if let Some(table) = loot_tables.get(*victim) { let drop_finder = crate::raws::get_item_drop( &crate::raws::RAWS.lock().unwrap(), &mut rng, &table.table ); if let Some(tag) = drop_finder { if let Some(pos) = pos { to_spawn.push((tag, pos.clone())); } } } } for drop in to_drop.iter() { equipped.remove(drop.0); carried.remove(drop.0); positions.insert(drop.0, drop.1.clone()).expect("Unable to insert position"); } } { for drop in to_spawn.iter() { crate::raws::spawn_named_item( &crate::raws::RAWS.lock().unwrap(), ecs, &drop.0, crate::raws::SpawnType::AtPosition{x : drop.1.x, y: drop.1.y} ); } } }
这有点混乱。 我们首先创建一个 to_spawn 向量,其中包含位置和名称。 然后,在我们完成将物品移出背包和装备后,我们查看是否有战利品表。 如果有,并且有一个位置 - 我们将两者都添加到 to_spawn 列表中。 完成后,我们迭代 to_spawn 列表,并为我们找到的每个结果调用 spawn_named_item。 像这样分散的原因是借用检查器:当我们在查看掉落物品时,我们保留对 entities 的持有,但是 spawn_named_item 希望临时(在其运行时)拥有世界! 因此,我们必须等到完成之后才能交出所有权。
如果你现在 cargo run,你可以杀死狼和鹿 - 它们会掉落肉和皮革。 这是一个很好的改进 - 你可以主动狩猎动物以确保你有东西吃!

一些强盗 - 他们会掉落物品!
让我们添加一些强盗,并给他们一些最少的装备。 这为玩家提供了一个在进入下一关之前掠夺一些更好装备的机会,以及森林中更多的多样性。 这是 NPC 定义:
{
"name" : "Bandit",
"renderable": {
"glyph" : "☻",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 4,
"ai" : "melee",
"quips" : [ "Stand and deliver!", "Alright, hand it over" ],
"attributes" : {},
"equipped" : [ "Shortsword", "Shield", "Leather Armor", "Leather Boots" ]
},
像这样将它们添加到生成表中:
{ "name" : "Bandit", "weight" : 9, "min_depth" : 2, "max_depth" : 3 }
我们还必须定义短剑、皮甲和皮靴,因为它们是新的! 这现在应该是旧闻了:
{
"name" : "Shortsword",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAFF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "Might",
"base_damage" : "1d6",
"hit_bonus" : 0
}
},
{
"name" : "Leather Armor",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Torso",
"armor_class" : 1.0
}
},
{
"name" : "Leather Boots",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Feet",
"armor_class" : 0.2
}
}
如果你现在 cargo run,你就有望找到一个强盗 - 杀死他们会掉落他们的战利品!

受惊的鹿和饥饿的狼
我们在本章中做得相当不错! 我们有一个全新的关卡可以玩,新的怪物,新的物品,战利品表和 NPC 在死亡时掉落他们拥有的物品。 仍然有一件事困扰着我:你不能杀死鹿,狼也不能。 期望狼与小鹿斑比闲逛而不通过吃掉它来破坏电影真的不现实,而且鹿不会从玩家和狼那里逃跑也很令人惊讶。
打开 components.rs,我们将引入两个新组件:Carnivore 和 Herbivore(我们不会忘记在 main.rs 和 saveload_system.rs 中注册它们):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Carnivore {} #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Herbivore {} }
我们还将修改 raws/rawmaster.rs 中的 spawn_named_mob,以便让我们生成食肉动物和食草动物作为 AI 类:
#![allow(unused)] fn main() { match mob_template.ai.as_ref() { "melee" => eb = eb.with(Monster{}), "bystander" => eb = eb.with(Bystander{}), "vendor" => eb = eb.with(Vendor{}), "carnivore" => eb = eb.with(Carnivore{}), "herbivore" => eb = eb.with(Herbivore{}), _ => {} } }
现在我们将创建一个新的系统来处理它们的 AI,将其放入文件:animal_ai_system.rs:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Herbivore, Carnivore, Item, Map, Position, WantsToMelee, RunState, Confusion, particle_system::ParticleBuilder, EntityMoved}; use rltk::{Point}; pub struct AnimalAI {} impl<'a> System<'a> for AnimalAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, Entity>, ReadExpect<'a, RunState>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Herbivore>, ReadStorage<'a, Carnivore>, ReadStorage<'a, Item>, WriteStorage<'a, WantsToMelee>, WriteStorage<'a, EntityMoved>, WriteStorage<'a, Position> ); fn run(&mut self, data : Self::SystemData) { let (mut map, player_entity, runstate, entities, mut viewshed, herbivore, carnivore, item, mut wants_to_melee, mut entity_moved, mut position) = data; if *runstate != RunState::MonsterTurn { return; } // Herbivores run away a lot // 食草动物经常逃跑 for (entity, mut viewshed, _herbivore, mut pos) in (&entities, &mut viewshed, &herbivore, &mut position).join() { let mut run_away_from : Vec<usize> = Vec::new(); for other_tile in viewshed.visible_tiles.iter() { let view_idx = map.xy_idx(other_tile.x, other_tile.y); for other_entity in map.tile_content[view_idx].iter() { // They don't run away from items // 它们不会从物品中逃跑 if item.get(*other_entity).is_none() { run_away_from.push(view_idx); } } } if !run_away_from.is_empty() { let my_idx = map.xy_idx(pos.x, pos.y); map.populate_blocked(); let flee_map = rltk::DijkstraMap::new(map.width as usize, map.height as usize, &run_away_from, &*map, 100.0); let flee_target = rltk::DijkstraMap::find_highest_exit(&flee_map, my_idx, &*map); if let Some(flee_target) = flee_target { if !map.blocked[flee_target as usize] { map.blocked[my_idx] = false; map.blocked[flee_target as usize] = true; viewshed.dirty = true; pos.x = flee_target as i32 % map.width; pos.y = flee_target as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); } } } } // Carnivores just want to eat everything // 食肉动物只想吃掉一切 for (entity, mut viewshed, _carnivore, mut pos) in (&entities, &mut viewshed, &carnivore, &mut position).join() { let mut run_towards : Vec<usize> = Vec::new(); let mut attacked = false; for other_tile in viewshed.visible_tiles.iter() { let view_idx = map.xy_idx(other_tile.x, other_tile.y); for other_entity in map.tile_content[view_idx].iter() { if herbivore.get(*other_entity).is_some() || *other_entity == *player_entity { let distance = rltk::DistanceAlg::Pythagoras.distance2d( Point::new(pos.x, pos.y), *other_tile ); if distance < 1.5 { wants_to_melee.insert(entity, WantsToMelee{ target: *other_entity }).expect("Unable to insert attack"); attacked = true; } else { run_towards.push(view_idx); } } } } if !run_towards.is_empty() && !attacked { let my_idx = map.xy_idx(pos.x, pos.y); map.populate_blocked(); let chase_map = rltk::DijkstraMap::new(map.width as usize, map.height as usize, &run_towards, &*map, 100.0); let chase_target = rltk::DijkstraMap::find_lowest_exit(&chase_map, my_idx, &*map); if let Some(chase_target) = chase_target { if !map.blocked[chase_target as usize] { map.blocked[my_idx] = false; map.blocked[chase_target as usize] = true; viewshed.dirty = true; pos.x = chase_target as i32 % map.width; pos.y = chase_target as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); } } } } } } }
(我们还需要将其添加到 main.rs 中的 run_systems)。 我们已经制作了一些系统,因此我们将跳过其中的一些内容。 重要的部分是涵盖食草动物和食肉动物的循环。 它们基本上是相同的 - 但逻辑颠倒了。 让我们来看看食草动物:
- 我们循环遍历具有
Herbivore组件以及位置和视野的实体。 - 我们浏览食草动物的视野,查看它们可以看到的每个瓷砖。
- 我们迭代可见瓷砖的
tile_content,如果它不是物品(我们不需要鹿从口粮中逃跑!),我们将其添加到flee_from列表中。 - 我们使用
flee_from构建一个 Dijkstra 地图,并选择最高可能的出口:这意味着它们想要尽可能远离其他实体! - 如果它没有被阻挡,我们会移动它们。
这有一个很好的效果,鹿会发现你,并试图保持远离。 它们也会对地图上的其他人这样做。 如果你能抓住它们,你就可以杀死它们并吃掉它们 - 但它们会尽力逃脱。
食肉动物循环非常相似:
- 我们循环遍历具有
Carnivore组件以及位置和视野的实体。 - 我们浏览食肉动物的视野,查看它们可以看到的东西。
- 我们迭代
tile_content以查看那里有什么; 如果它是食草动物或玩家,它们会将其添加到run_towards列表中。 它们还会检查距离:如果它们相邻,它们会发起近战。 - 我们使用
run_towards构建 Dijkstra 地图,并使用find_lowest_exit向最接近的目标移动。
这使得地图生机勃勃:鹿在逃跑,狼在试图吃掉它们。 如果狼在追你,你也许可以用鹿来分散它的注意力并逃脱!
总结
这是一个很大的章节,但我们为游戏添加了一个完整的关卡! 它有一个地图、一个主题、战利品表、可掉落物品、新的 NPC/怪物、两个新的 AI 类别,并演示了 Dijkstra 地图如何制作出逼真但简单的 AI。 唷!
在下一章中,我们将改变齿轮,看看如何添加一些玩家进度。
本章的源代码可以在这里找到
在你的浏览器中使用 Web 程序集运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
经验与升级
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
到目前为止,我们已经深入地下城,并且实际上只通过找到更好的装备来提升了自己。 属性更好的剑、盾牌和盔甲 - 使我们有机会在更强大的敌人面前生存下来。 这很好,但这通常只是 Roguelike 和 RPG 中常见等式的一半; 击败敌人通常会奖励 经验值 - 这些经验值可以用来提升你的角色。
我们正在制作的游戏类型暗示了一些指导原则:
- 由于永久死亡的设定,你可以预料到会 死很多次。 因此,管理你的角色进度需要 简单 - 这样你就不会花费大量时间在上面,结果却不得不重新再来一次。
- 垂直进度 是一件好事:随着你深入地下城,你会变得更强大(这允许我们制作更强大的怪物)。 水平 进度在很大程度上破坏了永久死亡的意义; 如果你在游戏之间保留收益,那么 Roguelike 游戏中“每次游戏都是独一无二的”这一方面就会受到损害,你可以预料到 Reddit 上的
/r/roguelikes的伙伴们会抱怨!
获取经验值
当你击败某些东西时,你应该获得经验值(XP)。 我们现在采用一个简单的升级方案:每次你击败某些东西时,你都会获得 100 XP * 敌人等级。 这对于杀死强大的敌人来说收益更大 - 而对于猎杀那些你已经超越等级的生物来说,收益则相对较小。 此外,我们决定你需要 1,000 XP * 当前等级 才能升到下一级。
我们的 Pools 组件中已经有了 level 和 xp (你几乎会认为我们正在计划这一章!)。 让我们从修改我们的 GUI 来显示等级进度开始。 打开 gui.rs,我们将以下内容添加到 draw_ui:
#![allow(unused)] fn main() { format!("Level: {}", player_pools.level); ctx.print_color(50, 3, white, black, &xp); let xp_level_start = (player_pools.level-1) * 1000; ctx.draw_bar_horizontal(64, 3, 14, player_pools.xp - xp_level_start, 1000, RGB::named(rltk::GOLD), RGB::named(rltk::BLACK)); }
这会在屏幕上为我们当前的等级进度添加一个金色的进度条,并显示我们当前的等级:

现在我们应该支持实际 获得 经验值。 我们应该从跟踪伤害 来自哪里 开始。 打开 components.rs,我们将在 SufferDamage 中添加一个字段:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct SufferDamage { pub amount : Vec<(i32, bool)>, } impl SufferDamage { pub fn new_damage(store: &mut WriteStorage<SufferDamage>, victim: Entity, amount: i32, from_player: bool) { if let Some(suffering) = store.get_mut(victim) { suffering.amount.push((amount, from_player)); } else { let dmg = SufferDamage { amount : vec![(amount, from_player)] }; store.insert(victim, dmg).expect("Unable to insert damage"); } } } }
我们添加了 from_player。 如果伤害来自玩家 - 那么我们会将其标记为来自玩家。 我们实际上并不关心其他实体升级,所以目前这足以区分。 现在,当某些地方创建 SufferDamage 组件时,会出现一些编译器错误; 在大多数情况下,你可以通过在创建时添加 from_player: false 来修复它们。 对于 hunger_system.rs、trigger_system.rs 来说是这样。 inventory_system.rs 需要使用 from_player : true - 因为现在只有玩家可以使用物品。 melee_combat_system.rs 需要做更多的工作,以确保你不会从其他生物互相残杀中获得 XP(谢谢,狼群!)。
首先,我们需要将玩家实体添加到系统请求访问的资源列表中:
#![allow(unused)] fn main() { ... ReadStorage<'a, NaturalAttackDefense>, ReadExpect<'a, Entity> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut log, mut wants_melee, names, attributes, skills, mut inflict_damage, mut particle_builder, positions, hunger_clock, pools, mut rng, equipped_items, meleeweapons, wearables, natural, player_entity) = data; ... }
然后,我们将 from_player 设置为取决于攻击实体是否与玩家匹配(一直到第 105 行):
#![allow(unused)] fn main() { SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage, from_player: entity == *player_entity); }
这样就解决了知道伤害 来自哪里 的问题。 现在我们可以修改 damage_system.rs 来实际奖励 XP。 这是更新后的系统:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Pools, SufferDamage, Player, Name, gamelog::GameLog, RunState, Position, Map, InBackpack, Equipped, LootTable, Attributes}; use crate::gamesystem::{player_hp_at_level, mana_at_level}; pub struct DamageSystem {} impl<'a> System<'a> for DamageSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, Pools>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, Position>, WriteExpect<'a, Map>, Entities<'a>, ReadExpect<'a, Entity>, ReadStorage<'a, Attributes> ); fn run(&mut self, data : Self::SystemData) { let (mut stats, mut damage, positions, mut map, entities, player, attributes) = data; let mut xp_gain = 0; for (entity, mut stats, damage) in (&entities, &mut stats, &damage).join() { for dmg in damage.amount.iter() { stats.hit_points.current -= dmg.0; let pos = positions.get(entity); if let Some(pos) = pos { let idx = map.xy_idx(pos.x, pos.y); map.bloodstains.insert(idx); } if stats.hit_points.current < 1 && dmg.1 { xp_gain += stats.level * 100; } } } if xp_gain != 0 { let mut player_stats = stats.get_mut(*player).unwrap(); let player_attributes = attributes.get(*player).unwrap(); player_stats.xp += xp_gain; if player_stats.xp >= player_stats.level * 1000 { // We've gone up a level! // 我们升级了! player_stats.level += 1; player_stats.hit_points.max = player_hp_at_level( player_attributes.fitness.base + player_attributes.fitness.modifiers, player_stats.level ); player_stats.hit_points.current = player_stats.hit_points.max; player_stats.mana.max = mana_at_level( player_attributes.intelligence.base + player_attributes.intelligence.modifiers, player_stats.level ); player_stats.mana.current = player_stats.mana.max; } } damage.clear(); } } }
因此,当我们处理伤害时,如果伤害是 来自 玩家并且杀死了目标 - 我们会将经验值添加到变量 xp_gain 中。 在我们完成击杀之后,我们会检查 xp_gain 是否为非零; 如果是,我们会获取有关玩家的信息并授予他们 XP。 如果他们升级了,我们会重新计算他们的生命值和法力值。
你现在可以 cargo run,如果你杀死 10 个野兽,你将升到 2 级!

让升级更具戏剧性
升级是一件大事 - 你升级了,治愈了自己,并准备好在一个全新的层次上面对世界! 我们应该 让它看起来像一件大事! 我们应该做的第一件事是在游戏日志中宣布升级。 在我们之前的升级代码中,我们可以添加:
#![allow(unused)] fn main() { WriteExpect<'a, GameLog> ); fn run(&mut self, data : Self::SystemData) { let (mut stats, mut damage, positions, mut map, entities, player, attributes, mut log) = data; ... log.entries.push(format!("Congratulations, you are now level {}", player_stats.level)); }
现在至少我们 告诉 了玩家,而不是仅仅希望他们注意到。 这仍然不算是一个盛大的庆祝,所以让我们添加一些粒子效果!
我们首先添加两个更多的数据访问器:
#![allow(unused)] fn main() { WriteExpect<'a, ParticleBuilder>, ReadExpect<'a, Position> ); fn run(&mut self, data : Self::SystemData) { let (mut stats, mut damage, positions, mut map, entities, player, attributes, mut log, mut particles, player_pos) = data; }
我们将在玩家上方添加一道金光!
#![allow(unused)] fn main() { for i in 0..10 { if player_pos.y - i > 1 { particles.request( player_pos.x, player_pos.y - i, rltk::RGB::named(rltk::GOLD), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('░'), 200.0 ); } } }

技能呢?
我们现在并没有真正 使用 技能,除了给玩家很多属性 +1 之外。 所以在我们开始使用它们之前,我们将保持这部分空白。
总结
所以现在你可以升级了! 欢呼!
本章的源代码可以在这里找到
使用 web assembly 在您的浏览器中运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
回溯
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续创作,请考虑支持我的 Patreon。
设计文档中提到了使用 城镇传送门 (Town Portal) 返回城镇,这意味着 回溯 (backtracking) 是可能的——也就是说,可以返回到之前的关卡。 这在诸如 Dungeon Crawl: Stone Soup 之类的游戏中是很常见的功能(在这些游戏中,标准操作是将物品留在“储藏点 (stash)” 中,希望怪物不会找到它们)。
如果我们要支持在关卡之间来回移动(通过入口/出口对,或者通过传送/传送门等机制),我们需要调整我们处理关卡以及在关卡之间转换的方式。
主地牢地图 (A Master Dungeon Map)
我们将从创建一个结构开始,用于存储 所有 我们的地图——MasterDungeonMap。创建一个新文件 map/dungeon.rs,我们将开始将其组合在一起:
#![allow(unused)] fn main() { use std::collections::HashMap; use serde::{Serialize, Deserialize}; use super::{Map}; #[derive(Default, Serialize, Deserialize, Clone)] pub struct MasterDungeonMap { maps : HashMap<i32, Map> } impl MasterDungeonMap { pub fn new() -> MasterDungeonMap { MasterDungeonMap{ maps: HashMap::new() } } pub fn store_map(&mut self, map : &Map) { self.maps.insert(map.depth, map.clone()); } pub fn get_map(&self, depth : i32) -> Option<Map> { if self.maps.contains_key(&depth) { let mut result = self.maps[&depth].clone(); result.tile_content = vec![Vec::new(); (result.width * result.height) as usize]; Some(result) } else { None } } } }
这很容易理解:结构本身只有一个私有(没有 pub)字段——maps。它是一个 HashMap ——一个字典——由 Map 结构组成,并以地图深度为索引。我们提供了一个构造函数 new 以方便创建类,以及用于 store_map (保存地图)和 get_map (检索地图,返回 Option,其中 None 表示我们没有该地图)的函数。我们还添加了 Serde 装饰器,使该结构可序列化——这样你就可以保存游戏了。我们还重新创建了 tile_content 字段,因为我们不序列化它。
在 map/mod.rs 中,你需要添加一行:pub mod dungeon;。这告诉模块向外界公开 dungeon。
添加向后出口 (Adding backwards exits)
让我们向世界添加向上楼梯。在 map/tiletype.rs 中,我们添加新的类型:
#![allow(unused)] fn main() { #[derive(PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Floor, DownStairs, Road, Grass, ShallowWater, DeepWater, WoodFloor, Bridge, Gravel, UpStairs } }
然后在 themes.rs 中,我们添加一些缺失的模式来渲染它(在每个主题中):
#![allow(unused)] fn main() { TileType::UpStairs => { glyph = rltk::to_cp437('<'); fg = RGB::from_f32(0., 1.0, 1.0); } }
在我们创建新地图时存储它们 (Storing New Maps As We Make Them)
目前,每当玩家进入新关卡时,我们都会在 main.rs 中调用 generate_world_map 来从头开始创建一个新地图。相反,我们希望将整个地牢地图作为全局资源——并在我们创建新地图时引用它,如果可能,使用 现有的 地图。将它放在 main.rs 中也很混乱,所以我们将借此机会将其重构到我们的地图系统中。
我们可以首先向 ECS World 添加一个 MasterDungeonMap 资源。在你的 main 函数中,在 ecs.insert 调用的顶部,添加一行以将 MasterDungeonMap 插入到 World 中(我包含了它后面的那行代码,以便你可以看到它放在哪里):
#![allow(unused)] fn main() { gs.ecs.insert(map::MasterDungeonMap::new()); gs.ecs.insert(Map::new(1, 64, 64, "New Map")); }
我们希望在每次开始新游戏时重置 MasterDungeonMap,因此我们将相同的代码行添加到 game_over_cleanup 中:
#![allow(unused)] fn main() { fn game_over_cleanup(&mut self) { // 删除所有实体 (Delete everything) let mut to_delete = Vec::new(); for e in self.ecs.entities().join() { to_delete.push(e); } for del in to_delete.iter() { self.ecs.delete_entity(*del).expect("Deletion failed"); } // 生成一个新的玩家 (Spawn a new player) { let player_entity = spawner::player(&mut self.ecs, 0, 0); let mut player_entity_writer = self.ecs.write_resource::<Entity>(); *player_entity_writer = player_entity; } // 替换世界地图 (Replace the world maps) self.ecs.insert(map::MasterDungeonMap::new()); // 构建一个新的地图并放置玩家 (Build a new map and place the player) self.generate_world_map(1, 0); } }
现在我们将 generate_world_map 简化为基本功能:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32) { self.mapgen_index = 0; self.mapgen_timer = 0.0; self.mapgen_history.clear(); let map_building_info = map::level_transition(&mut self.ecs, new_depth); if let Some(history) = map_building_info { self.mapgen_history = history; } } }
此函数重置了构建器信息(这很好,因为它正在处理它自己的职责——但不是其他的),并询问一个新函数 map::level_transition 是否有历史信息。 如果有,它将其存储为地图构建历史; 否则,它将历史记录留空。
在 map/dungeon.rs 中,我们将构建它调用的外部函数(并记住将其添加到 map/mod.rs 中的 pub use 部分!):
#![allow(unused)] fn main() { pub fn level_transition(ecs : &mut World, new_depth: i32) -> Option<Vec<Map>> { // 获取主地牢地图 (Obtain the master dungeon map) let dungeon_master = ecs.read_resource::<MasterDungeonMap>(); // 我们已经有地图了吗? (Do we already have a map?) if dungeon_master.get_map(new_depth).is_some() { std::mem::drop(dungeon_master); transition_to_existing_map(ecs, new_depth); None } else { std::mem::drop(dungeon_master); Some(transition_to_new_map(ecs, new_depth)) } } }
此函数从 ECS World 获取主地图,并调用 get_map。 如果存在地图,则调用 transition_to_existing_map。 如果不存在,则调用 transition_to_new_map。 请注意 std::mem::drop 调用:从 World 获取 dungeon_master 会持有对它的“借用 (borrow) ”; 我们需要在将 ECS 传递给其他函数之前停止借用(drop 它),以避免多重引用问题。
新函数 transition_to_new_map 是来自旧 generate_world_map 函数的代码,经过修改后不再依赖于 self。它在末尾有一个新部分:
#![allow(unused)] fn main() { fn transition_to_new_map(ecs : &mut World, new_depth: i32) -> Vec<Map> { let mut rng = ecs.write_resource::<rltk::RandomNumberGenerator>(); let mut builder = level_builder(new_depth, &mut rng, 80, 50); builder.build_map(&mut rng); if new_depth > 1 { if let Some(pos) = &builder.build_data.starting_position { let up_idx = builder.build_data.map.xy_idx(pos.x, pos.y); builder.build_data.map.tiles[up_idx] = TileType::UpStairs; } } let mapgen_history = builder.build_data.history.clone(); let player_start; { let mut worldmap_resource = ecs.write_resource::<Map>(); *worldmap_resource = builder.build_data.map.clone(); player_start = builder.build_data.starting_position.as_mut().unwrap().clone(); } // 生成坏人 (Spawn bad guys) std::mem::drop(rng); builder.spawn_entities(ecs); // 放置玩家并更新资源 (Place the player and update resources) let (player_x, player_y) = (player_start.x, player_start.y); let mut player_position = ecs.write_resource::<Point>(); *player_position = Point::new(player_x, player_y); let mut position_components = ecs.write_storage::<Position>(); let player_entity = ecs.fetch::<Entity>(); let player_pos_comp = position_components.get_mut(*player_entity); if let Some(player_pos_comp) = player_pos_comp { player_pos_comp.x = player_x; player_pos_comp.y = player_y; } // 标记玩家的视野为脏 (Mark the player's visibility as dirty) let mut viewshed_components = ecs.write_storage::<Viewshed>(); let vs = viewshed_components.get_mut(*player_entity); if let Some(vs) = vs { vs.dirty = true; } // 存储新生成的地图 (Store the newly minted map) let mut dungeon_master = ecs.write_resource::<MasterDungeonMap>(); dungeon_master.store_map(&builder.build_data.map); mapgen_history } }
最后,它返回构建历史记录。在此之前,它获取对新的 MasterDungeonMap 系统的访问权限,并将新地图添加到存储的地图列表中。我们还在起始位置添加了一个“向上”楼梯。
检索我们之前访问过的地图 (Retrieving maps we've visited before)
现在我们需要处理加载之前的地图!是时候充实 transition_to_existing_map 了:
#![allow(unused)] fn main() { fn transition_to_existing_map(ecs: &mut World, new_depth: i32) { let dungeon_master = ecs.read_resource::<MasterDungeonMap>(); let map = dungeon_master.get_map(new_depth).unwrap(); let mut worldmap_resource = ecs.write_resource::<Map>(); let player_entity = ecs.fetch::<Entity>(); // 找到向下楼梯并放置玩家 (Find the down stairs and place the player) let w = map.width; for (idx, tt) in map.tiles.iter().enumerate() { if *tt == TileType::DownStairs { let mut player_position = ecs.write_resource::<Point>(); *player_position = Point::new(idx as i32 % w, idx as i32 / w); let mut position_components = ecs.write_storage::<Position>(); let player_pos_comp = position_components.get_mut(*player_entity); if let Some(player_pos_comp) = player_pos_comp { player_pos_comp.x = idx as i32 % w; player_pos_comp.y = idx as i32 / w; } } } *worldmap_resource = map; // 标记玩家的视野为脏 (Mark the player's visibility as dirty) let mut viewshed_components = ecs.write_storage::<Viewshed>(); let vs = viewshed_components.get_mut(*player_entity); if let Some(vs) = vs { vs.dirty = true; } } }
这非常简单:我们从地牢主地图列表中获取地图,并将其存储为 World 中的当前地图。我们扫描地图以查找向下楼梯,并将玩家放在上面。我们还标记玩家的视野为脏,以便在新地图中重新计算。
上一级关卡的输入 (Input for previous level)
现在我们需要处理实际的转换。由于我们使用 RunState::NextLevel 处理下楼梯,我们将添加一个状态来处理返回上一层:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, PreviousLevel, ShowRemoveItem, GameOver, MagicMapReveal { row : i32 }, MapGeneration } }
我们还需要在我们的状态匹配函数中处理它。我们基本上会复制“下一关 (next level)”选项:
#![allow(unused)] fn main() { RunState::PreviousLevel => { self.goto_previous_level(); self.mapgen_next_state = Some(RunState::PreRun); newrunstate = RunState::MapGeneration; } }
我们将复制/粘贴 goto_next_level() 和 goto_previous_level() 并更改一些数字:
#![allow(unused)] fn main() { fn goto_previous_level(&mut self) { // 删除不在玩家或其装备中的实体 (Delete entities that aren't the player or his/her equipment) let to_delete = self.entities_to_remove_on_level_change(); for target in to_delete { self.ecs.delete_entity(target).expect("Unable to delete entity"); } // 构建一个新的地图并放置玩家 (Build a new map and place the player) let current_depth; { let worldmap_resource = self.ecs.fetch::<Map>(); current_depth = worldmap_resource.depth; } self.generate_world_map(current_depth - 1); // 通知玩家并给他们一些生命值 (Notify the player and give them some health) let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>(); gamelog.entries.push("你回到了上一层关卡。 (You ascend to the previous level.)".to_string()); } }
接下来,在 player.rs (我们处理输入的地方)——我们需要处理接收“向上走 (go up)”指令。 同样,我们基本上会复制“向下走 (go down)”:
#![allow(unused)] fn main() { VirtualKeyCode::Comma => { if try_previous_level(&mut gs.ecs) { return RunState::PreviousLevel; } } }
反过来,这要求我们复制 try_next_level 并创建 try_previous_level:
#![allow(unused)] fn main() { pub fn try_previous_level(ecs: &mut World) -> bool { let player_pos = ecs.fetch::<Point>(); let map = ecs.fetch::<Map>(); let player_idx = map.xy_idx(player_pos.x, player_pos.y); if map.tiles[player_idx] == TileType::UpStairs { true } else { let mut gamelog = ecs.fetch_mut::<GameLog>(); gamelog.entries.push("这里没有上去的路。 (There is no way up from here.)".to_string()); false } } }
如果你现在 cargo run,你就可以在地图之间转换了。但是,当你返回时——这里却是一座鬼城。关卡里 没有 其他任何人。 阴森恐怖,你妈妈的失踪应该让你沮丧!
实体冻结和解冻 (Entity freezing and unfreezing)
如果你回想一下本教程的第一部分,我们花了一些时间确保在更改关卡时 删除 除了玩家之外的所有内容。 这很有道理:你永远不会回来了,所以为什么要浪费内存来保存它们呢? 现在我们能够来回移动了,我们需要跟踪事物的所在位置——这样我们才能再次找到它们。 我们还可以借此机会稍微清理一下我们的转换——所有这些函数都很混乱!
考虑一下我们想要做什么,我们的目标是存储实体 在另一个关卡 上的位置。 因此我们需要存储关卡,以及他们的 x/y 位置。 让我们创建一个新的组件。 在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct OtherLevelPosition { pub x: i32, pub y: i32, pub depth: i32 } }
实际上,我们可以创建一个相对简单的函数来调整我们的实体状态。 在 map/dungeon.rs 中,我们将创建一个新函数:
#![allow(unused)] fn main() { pub fn freeze_level_entities(ecs: &mut World) { // 获取 ECS 访问权限 (Obtain ECS access) let entities = ecs.entities(); let mut positions = ecs.write_storage::<Position>(); let mut other_level_positions = ecs.write_storage::<OtherLevelPosition>(); let player_entity = ecs.fetch::<Entity>(); let map_depth = ecs.fetch::<Map>().depth; // 查找位置并创建 OtherLevelPosition (Find positions and make OtherLevelPosition) let mut pos_to_delete : Vec<Entity> = Vec::new(); for (entity, pos) in (&entities, &positions).join() { if entity != *player_entity { other_level_positions.insert(entity, OtherLevelPosition{ x: pos.x, y: pos.y, depth: map_depth }).expect("Insert fail"); pos_to_delete.push(entity); } } // 移除位置 (Remove positions) for p in pos_to_delete.iter() { positions.remove(*p); } } }
这是另一个相对简单的函数:我们获取对各种存储的访问权限,然后迭代所有具有位置的实体。 我们检查它是否不是玩家(因为他们的处理方式不同); 如果他们 不是 ——我们为他们添加一个 OtherLevelPosition,并在 pos_to_delete 向量中标记它们。 然后我们迭代该向量,并从我们标记的每个人中删除 Position 组件。
让他们恢复生机(解冻)也很容易:
#![allow(unused)] fn main() { pub fn thaw_level_entities(ecs: &mut World) { // 获取 ECS 访问权限 (Obtain ECS access) let entities = ecs.entities(); let mut positions = ecs.write_storage::<Position>(); let mut other_level_positions = ecs.write_storage::<OtherLevelPosition>(); let player_entity = ecs.fetch::<Entity>(); let map_depth = ecs.fetch::<Map>().depth; // 查找 OtherLevelPosition (Find OtherLevelPosition) let mut pos_to_delete : Vec<Entity> = Vec::new(); for (entity, pos) in (&entities, &other_level_positions).join() { if entity != *player_entity && pos.depth == map_depth { positions.insert(entity, Position{ x: pos.x, y: pos.y }).expect("Insert fail"); pos_to_delete.push(entity); } } // 移除位置 (Remove positions) for p in pos_to_delete.iter() { other_level_positions.remove(*p); } } }
这基本上是相同的函数,但逻辑相反! 我们 添加 Position 组件,并 删除 OtherLevelPosition 组件。
在 main.rs 中,我们有一堆 goto_next_level 和 goto_previous_level 函数的混乱代码。 让我们用一个通用的函数替换它们,该函数可以理解我们要往哪个方向走:
#![allow(unused)] fn main() { fn goto_level(&mut self, offset: i32) { freeze_level_entities(&mut self.ecs); // 构建一个新的地图并放置玩家 (Build a new map and place the player) let current_depth = self.ecs.fetch::<Map>().depth; self.generate_world_map(current_depth + offset, offset); // 通知玩家 (Notify the player) let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>(); gamelog.entries.push("你改变了关卡。 (You change level.)".to_string()); } }
这要简单得多——我们调用新的 freeze_level_entities 函数,获取当前深度,并使用新深度调用 generate_world_map。 这是什么? 我们还在传递 offset。我们需要知道你往哪个方向走,否则你可以通过先返回再前进的方式完成整个关卡——并被传送到“向下 (down)”的楼梯! 因此,我们将修改 generate_world_map 以接受此参数:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32, offset: i32) { self.mapgen_index = 0; self.mapgen_timer = 0.0; self.mapgen_history.clear(); let map_building_info = map::level_transition(&mut self.ecs, new_depth, offset); if let Some(history) = map_building_info { self.mapgen_history = history; } else { map::thaw_level_entities(&mut self.ecs); } } }
请注意,我们基本上是在调用相同的代码,但也向 level_transition 传递了 offset (稍后会详细介绍)。 如果我们没有创建新地图,我们还会调用 thaw。 这样,新地图会获得新实体——旧地图会获得旧实体。
你需要修复对 generate_world_map 的各种调用。 如果你正在创建新关卡,则可以传递 0 作为偏移量。 你还需要修复更改关卡的两个 match 条目:
#![allow(unused)] fn main() { RunState::NextLevel => { self.goto_level(1); self.mapgen_next_state = Some(RunState::PreRun); newrunstate = RunState::MapGeneration; } RunState::PreviousLevel => { self.goto_level(-1); self.mapgen_next_state = Some(RunState::PreRun); newrunstate = RunState::MapGeneration; } }
最后,我们需要打开 dungeon.rs 并对关卡转换系统进行简单的更改,以处理偏移量:
#![allow(unused)] fn main() { pub fn level_transition(ecs : &mut World, new_depth: i32, offset: i32) -> Option<Vec<Map>> { // 获取主地牢地图 (Obtain the master dungeon map) let dungeon_master = ecs.read_resource::<MasterDungeonMap>(); // 我们已经有地图了吗? (Do we already have a map?) if dungeon_master.get_map(new_depth).is_some() { std::mem::drop(dungeon_master); transition_to_existing_map(ecs, new_depth, offset); None } else { std::mem::drop(dungeon_master); Some(transition_to_new_map(ecs, new_depth)) } } }
这里唯一的区别是我们将偏移量传递给 transition_to_existing_map。 这是更新后的函数:
#![allow(unused)] fn main() { fn transition_to_existing_map(ecs: &mut World, new_depth: i32, offset: i32) { let dungeon_master = ecs.read_resource::<MasterDungeonMap>(); let map = dungeon_master.get_map(new_depth).unwrap(); let mut worldmap_resource = ecs.write_resource::<Map>(); let player_entity = ecs.fetch::<Entity>(); // 找到向下楼梯并放置玩家 (Find the down stairs and place the player) let w = map.width; let stair_type = if offset < 0 { TileType::DownStairs } else { TileType::UpStairs }; for (idx, tt) in map.tiles.iter().enumerate() { if *tt == stair_type { ... }
我们更新了签名,并使用它来确定玩家的放置位置。 如果偏移量小于 0,我们想要一个向下楼梯——否则我们想要一个向上楼梯。
你现在可以 cargo run 了,并在关卡之间来回跳转,尽情享受——每个关卡上的实体都将停留在你离开它们的地方!
保存/加载游戏 (Saving/Loading the game)
现在我们需要将地牢主地图包含在我们的保存游戏中; 否则,重新加载将保留当前地图并生成一大堆新地图——实体放置无效! 我们需要扩展我们的序列化系统以保存整个地牢地图,而不仅仅是当前的地图。
我们将从 components.rs 开始; 你可能还记得,我们必须创建一个特殊的 SerializationHelper 来帮助我们将地图保存为游戏的一部分。 它看起来像这样:
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct SerializationHelper { pub map : super::map::Map } }
我们需要 第二个,来存储 MasterDungeonMap。 它看起来像这样:
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct DMSerializationHelper { pub map : super::map::MasterDungeonMap } }
在 main.rs 中,我们必须像其他组件一样注册它:
#![allow(unused)] fn main() { gs.ecs.register::<DMSerializationHelper>(); }
在 saveload.rs 中,我们需要将其包含在组件类型的大列表中。 同样在 saveload.rs 中,我们需要使用之前使用过的相同技巧将其添加到 ECS World,保存它,然后删除它:
#![allow(unused)] fn main() { pub fn save_game(ecs : &mut World) { // 创建 helper (Create helper) let mapcopy = ecs.get_mut::<super::map::Map>().unwrap().clone(); let dungeon_master = ecs.get_mut::<super::map::MasterDungeonMap>().unwrap().clone(); let savehelper = ecs .create_entity() .with(SerializationHelper{ map : mapcopy }) .marked::<SimpleMarker<SerializeMe>>() .build(); let savehelper2 = ecs .create_entity() .with(DMSerializationHelper{ map : dungeon_master }) .marked::<SimpleMarker<SerializeMe>>() .build(); // 实际序列化 (Actually serialize) { let data = ( ecs.entities(), ecs.read_storage::<SimpleMarker<SerializeMe>>() ); let writer = File::create("./savegame.json").unwrap(); let mut serializer = serde_json::Serializer::new(writer); serialize_individually!(ecs, serializer, data, Position, Renderable, Player, Viewshed, Monster, Name, BlocksTile, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem, WantsToDropItem, SerializationHelper, Equippable, Equipped, MeleeWeapon, Wearable, WantsToRemoveItem, ParticleLifetime, HungerClock, ProvidesFood, MagicMapper, Hidden, EntryTrigger, EntityMoved, SingleActivation, BlocksVisibility, Door, Bystander, Vendor, Quips, Attributes, Skills, Pools, NaturalAttackDefense, LootTable, Carnivore, Herbivore, OtherLevelPosition, DMSerializationHelper ); } // 清理 (Clean up) ecs.delete_entity(savehelper).expect("Crash on cleanup"); ecs.delete_entity(savehelper2).expect("Crash on cleanup"); } }
请注意,我们正在创建 第二个 临时实体——savehelper2。 这确保了数据与所有其他数据一起保存。 我们在最后一行将其删除。 我们还需要调整我们的加载器:
#![allow(unused)] fn main() { pub fn load_game(ecs: &mut World) { { // 删除所有实体 (Delete everything) let mut to_delete = Vec::new(); for e in ecs.entities().join() { to_delete.push(e); } for del in to_delete.iter() { ecs.delete_entity(*del).expect("Deletion failed"); } } let data = fs::read_to_string("./savegame.json").unwrap(); let mut de = serde_json::Deserializer::from_str(&data); { let mut d = (&mut ecs.entities(), &mut ecs.write_storage::<SimpleMarker<SerializeMe>>(), &mut ecs.write_resource::<SimpleMarkerAllocator<SerializeMe>>()); deserialize_individually!(ecs, de, d, Position, Renderable, Player, Viewshed, Monster, Name, BlocksTile, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem, WantsToDropItem, SerializationHelper, Equippable, Equipped, MeleeWeapon, Wearable, WantsToRemoveItem, ParticleLifetime, HungerClock, ProvidesFood, MagicMapper, Hidden, EntryTrigger, EntityMoved, SingleActivation, BlocksVisibility, Door, Bystander, Vendor, Quips, Attributes, Skills, Pools, NaturalAttackDefense, LootTable, Carnivore, Herbivore, OtherLevelPosition, DMSerializationHelper ); } let mut deleteme : Option<Entity> = None; let mut deleteme2 : Option<Entity> = None; { let entities = ecs.entities(); let helper = ecs.read_storage::<SerializationHelper>(); let helper2 = ecs.read_storage::<DMSerializationHelper>(); let player = ecs.read_storage::<Player>(); let position = ecs.read_storage::<Position>(); for (e,h) in (&entities, &helper).join() { let mut worldmap = ecs.write_resource::<super::map::Map>(); *worldmap = h.map.clone(); worldmap.tile_content = vec![Vec::new(); (worldmap.height * worldmap.width) as usize]; deleteme = Some(e); } for (e,h) in (&entities, &helper2).join() { let mut dungeonmaster = ecs.write_resource::<super::map::MasterDungeonMap>(); *dungeonmaster = h.map.clone(); deleteme2 = Some(e); } for (e,_p,pos) in (&entities, &player, &position).join() { let mut ppos = ecs.write_resource::<rltk::Point>(); *ppos = rltk::Point::new(pos.x, pos.y); let mut player_resource = ecs.write_resource::<Entity>(); *player_resource = e; } } ecs.delete_entity(deleteme.unwrap()).expect("Unable to delete helper"); ecs.delete_entity(deleteme2.unwrap()).expect("Unable to delete helper"); } }
因此,在这个代码中,我们添加了遍历 MasterDungeonMap helper 的代码,并将其作为资源添加到 World 中——然后删除实体。 这与我们对 Map 所做的操作相同——但适用于 MasterDungeonMap。
如果你现在 cargo run,你可以转换关卡,保存游戏,然后再进行转换。 序列化工作正常!
更无缝的转换 (More seamless transition)
要求玩家输入仅使用一次的按键(用于向上/向下楼梯)来与楼梯交互不是很符合人体工程学。 不仅如此,使用国际键盘有时很难捕捉到正确的键码! 如果走进楼梯就可以带你到达楼梯的目的地,那肯定会更流畅。 同时,我们可以修复一些困扰我一段时间的问题:尝试移动失败会浪费一个回合,而你却在盲目地撞墙!
由于 player.rs 是我们处理输入的地方,让我们打开它。 我们将更改 try_move_player 以返回一个 RunState:
#![allow(unused)] fn main() { pub fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) -> RunState { let mut positions = ecs.write_storage::<Position>(); let players = ecs.read_storage::<Player>(); let mut viewsheds = ecs.write_storage::<Viewshed>(); let entities = ecs.entities(); let combat_stats = ecs.read_storage::<Attributes>(); let map = ecs.fetch::<Map>(); let mut wants_to_melee = ecs.write_storage::<WantsToMelee>(); let mut entity_moved = ecs.write_storage::<EntityMoved>(); let mut doors = ecs.write_storage::<Door>(); let mut blocks_visibility = ecs.write_storage::<BlocksVisibility>(); let mut blocks_movement = ecs.write_storage::<BlocksTile>(); let mut renderables = ecs.write_storage::<Renderable>(); let bystanders = ecs.read_storage::<Bystander>(); let vendors = ecs.read_storage::<Vendor>(); let mut result = RunState::AwaitingInput; let mut swap_entities : Vec<(Entity, i32, i32)> = Vec::new(); for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() { if pos.x + delta_x < 1 || pos.x + delta_x > map.width-1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height-1 { return RunState::AwaitingInput; } let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y); for potential_target in map.tile_content[destination_idx].iter() { let bystander = bystanders.get(*potential_target); let vendor = vendors.get(*potential_target); if bystander.is_some() || vendor.is_some() { // 注意,我们想移动旁观者 (Note that we want to move the bystander) swap_entities.push((*potential_target, pos.x, pos.y)); // 移动玩家 (Move the player) pos.x = min(map.width-1 , max(0, pos.x + delta_x)); pos.y = min(map.height-1, max(0, pos.y + delta_y)); entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); viewshed.dirty = true; let mut ppos = ecs.write_resource::<Point>(); ppos.x = pos.x; ppos.y = pos.y; result = RunState::PlayerTurn; } else { let target = combat_stats.get(*potential_target); if let Some(_target) = target { wants_to_melee.insert(entity, WantsToMelee{ target: *potential_target }).expect("Add target failed"); return RunState::PlayerTurn; } } let door = doors.get_mut(*potential_target); if let Some(door) = door { door.open = true; blocks_visibility.remove(*potential_target); blocks_movement.remove(*potential_target); let glyph = renderables.get_mut(*potential_target).unwrap(); glyph.glyph = rltk::to_cp437('/'); viewshed.dirty = true; } } if !map.blocked[destination_idx] { pos.x = min(map.width-1 , max(0, pos.x + delta_x)); pos.y = min(map.height-1, max(0, pos.y + delta_y)); entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); viewshed.dirty = true; let mut ppos = ecs.write_resource::<Point>(); ppos.x = pos.x; ppos.y = pos.y; result = RunState::PlayerTurn; } } for m in swap_entities.iter() { let their_pos = positions.get_mut(m.0); if let Some(their_pos) = their_pos { their_pos.x = m.1; their_pos.y = m.2; } } result } }
这与之前的函数基本相同,但我们确保从中返回一个 RunState。 如果玩家确实移动了,我们返回 RunState::PlayerTurn。 如果移动无效,我们返回 RunState::AwaitingInput ——表示我们仍在等待有效的指令。
在玩家键盘处理程序中,我们需要将每次调用 try_move_player... 替换为 return try_move_player...:
#![allow(unused)] fn main() { ... match ctx.key { None => { return RunState::AwaitingInput } // 没有发生任何事情 (Nothing happened) Some(key) => match key { VirtualKeyCode::Left | VirtualKeyCode::Numpad4 | VirtualKeyCode::H => return try_move_player(-1, 0, &mut gs.ecs), VirtualKeyCode::Right | VirtualKeyCode::Numpad6 | VirtualKeyCode::L => return try_move_player(1, 0, &mut gs.ecs), VirtualKeyCode::Up | VirtualKeyCode::Numpad8 | VirtualKeyCode::K => return try_move_player(0, -1, &mut gs.ecs), VirtualKeyCode::Down | VirtualKeyCode::Numpad2 | VirtualKeyCode::J => return try_move_player(0, 1, &mut gs.ecs), // 对角线 (Diagonals) VirtualKeyCode::Numpad9 | VirtualKeyCode::U => return try_move_player(1, -1, &mut gs.ecs), VirtualKeyCode::Numpad7 | VirtualKeyCode::Y => return try_move_player(-1, -1, &mut gs.ecs), VirtualKeyCode::Numpad3 | VirtualKeyCode::N => return try_move_player(1, 1, &mut gs.ecs), VirtualKeyCode::Numpad1 | VirtualKeyCode::B => return try_move_player(-1, 1, &mut gs.ecs), // 跳过回合 (Skip Turn) VirtualKeyCode::Numpad5 | VirtualKeyCode::Space => return skip_turn(&mut gs.ecs), ... }
如果你现在 cargo run,你会注意到你不再浪费回合撞墙了。
既然我们已经完成了这项工作,我们就可以很好地修改 try_move_player,使其能够在玩家进入楼梯时返回关卡转换指令。 让我们在移动后添加一个楼梯检查,并在适用时返回楼梯转换:
#![allow(unused)] fn main() { if !map.blocked[destination_idx] { pos.x = min(map.width-1 , max(0, pos.x + delta_x)); pos.y = min(map.height-1, max(0, pos.y + delta_y)); entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); viewshed.dirty = true; let mut ppos = ecs.write_resource::<Point>(); ppos.x = pos.x; ppos.y = pos.y; result = RunState::PlayerTurn; match map.tiles[destination_idx] { TileType::DownStairs => result = RunState::NextLevel, TileType::UpStairs => result = RunState::PreviousLevel, _ => {} } } }
现在你可以通过跑到出口来更改关卡了。

关于楼梯舞步 (A Word on Stair dancing)
许多 Roguelike 游戏中都会遇到一个问题,那就是“楼梯舞步 (stair dancing)”。 你看到一个可怕的怪物,然后你退到楼梯上。 治疗一下,下来打怪物一下。 再跳回楼上,再治疗一下。 由于怪物被“冻结”在后面的关卡中,它不会追你上楼梯(除非在处理此问题的游戏中,例如 Dungeon Crawl Stone Soup!)。 这对于整体游戏来说可能是不希望看到的,但我们现在还不打算修复它。 计划在未来的章节中使 NPC AI 在总体上更加智能(并引入更多战术选项),因此我们将把这个问题留到以后解决。
总结 (Wrap Up)
这是另一个大型章节,但我们实现了一些非常有用的功能:关卡是持久的,你可以穿越世界,享受着当你返回时,你留在树林中的剑仍然会在那里的知识。 这在使游戏更可信、更广阔方面大有帮助(并且它开始感觉更“开放世界”,即使它不是!)。 我们将在以后的章节中添加城镇传送门,届时城镇将成为一个更有用的访问地点。
接下来——为了确保你不会感到无聊!——我们将添加下一个关卡,石灰岩洞穴。
...
本章的源代码可以在 这里 找到
在你的浏览器中使用 Web Assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
石灰岩洞穴
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持 我的 Patreon。
设计文档 讨论了第一个真正的地下城关卡是一个石灰岩洞穴网络。石灰岩洞穴在现实生活中非常令人惊叹;约克郡的 Gaping Gill 是我小时候最喜欢去的地方之一(您可能在*《巨蟒与圣杯》*中见过它 - Vorpal 兔子从它的入口处出现!)。涓涓细流,经过数个世纪的作用,可以雕刻出 惊人 的洞穴。洞穴主要由浅灰色岩石构成,这些岩石磨损后变得光滑且具有反射性 - 产生令人惊叹的照明效果!
作弊以帮助关卡设计
在设计新关卡时,快速简便地到达那里会很有帮助!因此,我们将引入作弊模式,让您快速导航到地下城以查看您的创作。 这将非常像我们创建的其他 UI 元素(例如库存管理),所以我们需要做的第一件事是打开 main.rs 并添加一个新的 RunState 来显示作弊菜单:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, ... ShowCheatMenu } }
然后,将以下内容添加到您的大型游戏状态 match 语句中:
#![allow(unused)] fn main() { RunState::ShowCheatMenu => { let result = gui::show_cheat_mode(self, ctx); match result { gui::CheatMenuResult::Cancel => newrunstate = RunState::AwaitingInput, gui::CheatMenuResult::NoResponse => {} gui::CheatMenuResult::TeleportToExit => { self.goto_level(1); self.mapgen_next_state = Some(RunState::PreRun); newrunstate = RunState::MapGeneration; } } } }
这会请求 show_cheat_mode 返回一个响应,并使用“下一关”代码(与玩家激活楼梯时相同)在用户选择 Teleport 时前进。我们尚未编写该函数和枚举,因此我们打开 gui.rs 并添加它:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum CheatMenuResult { NoResponse, Cancel, TeleportToExit } pub fn show_cheat_mode(_gs : &mut State, ctx : &mut Rltk) -> CheatMenuResult { let count = 2; let y = (25 - (count / 2)) as i32; ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Cheating!"); ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), rltk::to_cp437('T')); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, "Teleport to exit"); match ctx.key { None => CheatMenuResult::NoResponse, Some(key) => { match key { VirtualKeyCode::T => CheatMenuResult::TeleportToExit, VirtualKeyCode::Escape => CheatMenuResult::Cancel, _ => CheatMenuResult::NoResponse } } } } }
这应该看起来很熟悉:它显示一个作弊菜单,并提供字母 T 表示“传送至出口”。
最后,我们需要在 player.rs 中添加一个输入:
#![allow(unused)] fn main() { // 保存并退出 VirtualKeyCode::Escape => return RunState::SaveGame, // 作弊! VirtualKeyCode::Backslash => return RunState::ShowCheatMenu, }
就这样! 如果您现在 cargo run,您可以按 \ (反斜杠) 和 T - 并直接传送到下一关。 这将使我们的关卡设计变得容易得多!

雕刻洞穴
我们将对石灰岩洞穴进行另一次自定义设计,因此打开 map_builders/mod.rs 并找到 level_builder(它应该在文件的末尾):
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, rng, width, height), 2 => forest_builder(new_depth, rng, width, height), 3 => limestone_cavern_builder(new_depth, rng, width, height), _ => random_builder(new_depth, rng, width, height) } } }
还在顶部添加这个:
#![allow(unused)] fn main() { mod limestone_cavern; use limestone_cavern::limestone_cavern_builder; }
我们添加了 limestone_cavern_builder - 让我们继续创建它!创建一个新文件 map_builders/limestone_cavern.rs 并添加以下内容:
#![allow(unused)] fn main() { use super::{BuilderChain, DrunkardsWalkBuilder, XStart, YStart, AreaStartingPosition, CullUnreachable, VoronoiSpawning, MetaMapBuilder, BuilderMap, TileType, DistantExit}; use rltk::RandomNumberGenerator; use crate::map; pub fn limestone_cavern_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Limestone Caverns"); chain.start_with(DrunkardsWalkBuilder::winding_passages()); chain.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); chain.with(VoronoiSpawning::new()); chain.with(DistantExit::new()); chain } }
这非常简单:我们使用醉汉漫步算法以“蜿蜒通道”模式构建地图。然后我们将起点设置为中心,并剔除不可达区域。接下来,我们将入口放置在左侧中心,使用 Voronoi 算法生成,并将出口放置在远处。
这为您提供了一个可玩地图!怪物选择不是很好,但它可以工作。 这是我们一直在使用的灵活地图构建系统的一个很好的例子。
洞穴主题化
洞穴布局是一个良好的开端,但它看起来还不像石灰岩洞穴。 打开 map/themes.rs,我们将纠正这一点!我们将首先修改 get_tile_glyph 以了解此关卡:
#![allow(unused)] fn main() { pub fn tile_glyph(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { let (glyph, mut fg, mut bg) = match map.depth { 3 => get_limestone_cavern_glyph(idx, map), 2 => get_forest_glyph(idx, map), _ => get_tile_glyph_default(idx, map) }; }
现在我们需要编写 get_limestone_cavern_glyph。 我们希望它看起来像石灰岩洞穴。 这是我想出的(也许更有艺术天赋的人可以帮忙!):
#![allow(unused)] fn main() { fn get_limestone_cavern_glyph(idx:usize, map: &Map) -> (rltk::FontCharType, RGB, RGB) { let glyph; let fg; let bg = RGB::from_f32(0., 0., 0.); match map.tiles[idx] { TileType::Wall => { glyph = rltk::to_cp437('▒'); fg = RGB::from_f32(0.7, 0.7, 0.7); } TileType::Bridge => { glyph = rltk::to_cp437('.'); fg = RGB::named(rltk::CHOCOLATE); } TileType::Road => { glyph = rltk::to_cp437('≡'); fg = RGB::named(rltk::YELLOW); } TileType::Grass => { glyph = rltk::to_cp437('"'); fg = RGB::named(rltk::GREEN); } TileType::ShallowWater => { glyph = rltk::to_cp437('░'); fg = RGB::named(rltk::CYAN); } TileType::DeepWater => { glyph = rltk::to_cp437('▓'); fg = RGB::named(rltk::BLUE); } TileType::Gravel => { glyph = rltk::to_cp437(';'); fg = RGB::from_f32(0.5, 0.5, 0.5); } TileType::DownStairs => { glyph = rltk::to_cp437('>'); fg = RGB::from_f32(0., 1.0, 1.0); } TileType::UpStairs => { glyph = rltk::to_cp437('<'); fg = RGB::from_f32(0., 1.0, 1.0); } _ => { glyph = rltk::to_cp437('░'); fg = RGB::from_f32(0.4, 0.4, 0.4); } } (glyph, fg, bg) } }
开局不错!环境看起来很像洞穴(而不是凿成的石头),颜色是令人愉悦的中性浅灰色,但又不会刺眼。 它使其他实体非常突出:

仅仅添加水和砾石
我们可以通过添加一些水(像这样的洞穴网络没有水是不寻常的),并将一些地板瓷砖变成砾石来进一步改善地图 - 以显示地图上的巨石。我们还可以添加一些钟乳石和石笋(由滴水缓慢沉积钙质经过数个世纪形成的巨大石柱)以增加风味。 因此,我们将首先在构建器中添加一个新层(作为最后一步):
#![allow(unused)] fn main() { chain.with(CaveDecorator::new()); }
然后我们需要编写它:
#![allow(unused)] fn main() { pub struct CaveDecorator {} impl MetaMapBuilder for CaveDecorator { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl CaveDecorator { #[allow(dead_code)] pub fn new() -> Box<CaveDecorator> { Box::new(CaveDecorator{}) } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let old_map = build_data.map.clone(); for (idx,tt) in build_data.map.tiles.iter_mut().enumerate() { // 砾石生成 if *tt == TileType::Floor && rng.roll_dice(1, 6)==1 { *tt = TileType::Gravel; } else if *tt == TileType::Floor && rng.roll_dice(1, 10)==1 { // 生成可通行的水池 *tt = TileType::ShallowWater; } else if *tt == TileType::Wall { // 生成深水池和钟乳石 let mut neighbors = 0; let x = idx as i32 % old_map.width; let y = idx as i32 / old_map.width; if x > 0 && old_map.tiles[idx-1] == TileType::Wall { neighbors += 1; } if x < old_map.width - 2 && old_map.tiles[idx+1] == TileType::Wall { neighbors += 1; } if y > 0 && old_map.tiles[idx-old_map.width as usize] == TileType::Wall { neighbors += 1; } if y < old_map.height - 2 && old_map.tiles[idx+old_map.width as usize] == TileType::Wall { neighbors += 1; } if neighbors == 2 { *tt = TileType::DeepWater; } else if neighbors == 1 { let roll = rng.roll_dice(1, 4); match roll { 1 => *tt = TileType::Stalactite, 2 => *tt = TileType::Stalagmite, _ => {} } } } } build_data.take_snapshot(); } } }
其工作原理如下:
- 我们遍历地图的所有瓷砖类型和地图索引。 这是一个可变迭代器 - 我们希望能够更改瓷砖。
- 如果瓷砖是
Floor,我们有 1/6 的几率将其变成砾石。 - 如果我们没有这样做,我们有 1/10 的几率将其变成浅水池(仍然可以通行)。
- 如果它是墙壁,我们会计算有多少其他墙壁环绕着它。
- 如果有 2 个邻居,我们将瓷砖替换为
DeepWater- 漂亮的深色水,玩家无法通行。 - 如果有 1 个邻居,我们掷一个 4 面骰子。 如果掷出 1,我们将其变成钟乳石; 如果掷出 2,我们将其变成石笋。 否则,我们什么都不做。
这确实需要我们打开 map/tiletype.rs 并引入新的瓷砖类型:
#![allow(unused)] fn main() { #[derive(PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Stalactite, Stalagmite, Floor, DownStairs, Road, Grass, ShallowWater, DeepWater, WoodFloor, Bridge, Gravel, UpStairs } }
我们使新的瓷砖类型阻挡视野:
#![allow(unused)] fn main() { pub fn tile_opaque(tt : TileType) -> bool { match tt { TileType::Wall | TileType::Stalactite | TileType::Stalagmite => true, _ => false } } }
我们还将它们添加到 map/themes.rs 中的新石灰岩主题和默认主题中:
#![allow(unused)] fn main() { TileType::Stalactite => { glyph = rltk::to_cp437('╨'); fg = RGB::from_f32(0.5, 0.5, 0.5); } TileType::Stalagmite => { glyph = rltk::to_cp437('╥'); fg = RGB::from_f32(0.5, 0.5, 0.5); } }
这给出了一个非常令人愉悦的,相当自然(和潮湿)的洞穴:

填充洞穴
洞穴本身就非常可玩,但它们与我们描述的 NPC 在类型方面不太匹配。洞穴里有一些森林怪物和鹿,这不太合理! 让我们首先打开 spawns.rs 并更改一些生物出现的深度以避免这种情况:
"spawn_table" : [
{ "name" : "Goblin", "weight" : 10, "min_depth" : 3, "max_depth" : 100 },
{ "name" : "Orc", "weight" : 1, "min_depth" : 3, "max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Health Potion", "weight" : 7, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Fireball Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Confusion Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Magic Missile Scroll", "weight" : 4, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Dagger", "weight" : 3, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Shield", "weight" : 3, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Longsword", "weight" : 1, "min_depth" : 3, "max_depth" : 100 },
{ "name" : "Tower Shield", "weight" : 1, "min_depth" : 3, "max_depth" : 100 },
{ "name" : "Rations", "weight" : 10, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Magic Mapping Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Bear Trap", "weight" : 5, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Battleaxe", "weight" : 1, "min_depth" : 2, "max_depth" : 100 },
{ "name" : "Kobold", "weight" : 15, "min_depth" : 3, "max_depth" : 5 },
{ "name" : "Rat", "weight" : 15, "min_depth" : 2, "max_depth" : 2 },
{ "name" : "Mangy Wolf", "weight" : 13, "min_depth" : 2, "max_depth" : 2 },
{ "name" : "Deer", "weight" : 14, "min_depth" : 2, "max_depth" : 2 },
{ "name" : "Bandit", "weight" : 9, "min_depth" : 2, "max_depth" : 3 }
],
我们将土匪留在了洞穴中,因为他们可能会在那里寻求庇护 - 但不再有狼、鹿或异常大的啮齿动物(反正我们现在可能已经厌倦了它们!)。在洞穴里还会发现什么? d20 系统遭遇表 提出了一些建议:
恐狼,火甲虫,人类骷髅,巨型蜈蚣,蜘蛛群,人类僵尸,扼杀怪,骷髅勇士,地精,食尸鬼,巨型蜘蛛,鸡蛇兽,凝胶状立方怪,锈蚀怪,阴影,幽灵,翼龙,暗潜怪,穴居人,熊地精,瓦格伊,
灰色软泥怪,拟像怪和食人魔(我的天哪)
真是个长长的清单! 考虑到地下城的 这一层,其中一些是有道理的:蜘蛛肯定会喜欢阴暗的地方。“翼龙”基本上是邪恶的蝙蝠,所以我们应该添加蝙蝠。我们有地精和狗头人以及偶尔的兽人。我们已经决定暂时厌倦老鼠了! 我是凝胶状立方怪的忠实粉丝,所以我也很想把它们放进去! 由于难度原因,许多其他怪物最好留到以后的关卡。
所以让我们将它们添加到生成表中:
{ "name" : "Bat", "weight" : 15, "min_depth" : 3, "max_depth" : 3 },
{ "name" : "Large Spider", "weight" : 3, "min_depth" : 3, "max_depth" : 3 },
{ "name" : "Gelatinous Cube", "weight" : 3, "min_depth" : 3, "max_depth" : 3 }
我们将蝙蝠设为非常常见,大型蜘蛛和凝胶状立方怪非常稀有。 让我们继续将它们添加到 spawns.json 的 mobs 部分:
{
"name" : "Bat",
"renderable": {
"glyph" : "b",
"fg" : "#995555",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 6,
"ai" : "herbivore",
"attributes" : {
"Might" : 3,
"Fitness" : 3
},
"skills" : {
"Melee" : -1,
"Defense" : -1
},
"natural" : {
"armor_class" : 11,
"attacks" : [
{ "name" : "bite", "hit_bonus" : 0, "damage" : "1d4" }
]
}
},
{
"name" : "Large Spider",
"level" : 2,
"attributes" : {},
"renderable": {
"glyph" : "s",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 6,
"ai" : "carnivore",
"natural" : {
"armor_class" : 12,
"attacks" : [
{ "name" : "bite", "hit_bonus" : 1, "damage" : "1d12" }
]
}
},
{
"name" : "Gelatinous Cube",
"level" : 2,
"attributes" : {},
"renderable": {
"glyph" : "▄",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 4,
"ai" : "carnivore",
"natural" : {
"armor_class" : 12,
"attacks" : [
{ "name" : "engulf", "hit_bonus" : 0, "damage" : "1d8" }
]
}
}
所以蝙蝠是无害的食草动物,它们大部分时间都在逃离你。 蜘蛛和立方体会猎杀其他人并吃掉它们。 我们还将它们设为 2 级 - 因此它们值得更多经验,并且更难杀死。 玩家很可能已经准备好迎接这个挑战。 所以我们可以 cargo run 并试一试!

还不错! 它可玩,正确的怪物出现,总体而言体验还不错。
光照!
使石灰岩洞穴如此令人惊叹的事情之一是光照; 您可以使用头盔火炬的光线穿过大理石向外窥视,投下阴影并赋予一切怪异的外观。 我们可以轻松地为游戏添加装饰性照明(它可能会在某个时候进入潜行系统!)
让我们首先创建一个新的组件 LightSource。 在 components.rs 中:
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct LightSource { pub color : RGB, pub range: i32 } }
与往常一样,在 main.rs 和 saveload_system.rs 中注册您的新组件! 光源定义了两个值:color(光的颜色)和 range - 这将控制其强度/衰减。 我们还需要将光照信息添加到地图中。 在 map/mod.rs 中:
#![allow(unused)] fn main() { #[derive(Default, Serialize, Deserialize, Clone)] pub struct Map { pub tiles : Vec<TileType>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool>, pub blocked : Vec<bool>, pub depth : i32, pub bloodstains : HashSet<usize>, pub view_blocked : HashSet<usize>, pub name : String, pub outdoors : bool, pub light : Vec<rltk::RGB>, #[serde(skip_serializing)] #[serde(skip_deserializing)] pub tile_content : Vec<Vec<Entity>> } }
这里有两个新值:outdoors,表示“有自然光,不要应用照明”,以及 light - 这是一个 RGB 颜色向量,指示每个瓷砖上的光照水平。 您还需要更新 new 构造函数以包含这些内容:
#![allow(unused)] fn main() { pub fn new<S : ToString>(new_depth : i32, width: i32, height: i32, name: S) -> Map { let map_tile_count = (width*height) as usize; Map{ tiles : vec![TileType::Wall; map_tile_count], width, height, revealed_tiles : vec![false; map_tile_count], visible_tiles : vec![false; map_tile_count], blocked : vec![false; map_tile_count], tile_content : vec![Vec::new(); map_tile_count], depth: new_depth, bloodstains: HashSet::new(), view_blocked : HashSet::new(), name : name.to_string(), outdoors : true, light: vec![rltk::RGB::from_f32(0.0, 0.0, 0.0); map_tile_count] } } }
请注意,我们将 outdoors 设置为默认模式 - 因此照明不会突然应用于所有地图(可能会搞砸我们已经完成的工作;很难解释为什么你早上醒来天空是黑色的 - 嗯,这可能是另一个游戏的故事钩子!)。 我们还将照明初始化为全黑,每个瓷砖一种颜色。
现在,我们将调整 map/themes.rs 以处理照明。 我们故意不使实体变暗(这样您仍然可以发现它们),只是地图瓷砖:
#![allow(unused)] fn main() { pub fn tile_glyph(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { let (glyph, mut fg, mut bg) = match map.depth { 3 => get_limestone_cavern_glyph(idx, map), 2 => get_forest_glyph(idx, map), _ => get_tile_glyph_default(idx, map) }; if map.bloodstains.contains(&idx) { bg = RGB::from_f32(0.75, 0., 0.); } if !map.visible_tiles[idx] { fg = fg.to_greyscale(); bg = RGB::from_f32(0., 0., 0.); // 不显示视野范围外的血迹 } else if !map.outdoors { fg = fg * map.light[idx]; bg = bg * map.light[idx]; } (glyph, fg, bg) } }
这非常简单:如果我们看不到瓷砖,我们仍然会使用灰度。 如果我们可以看到瓷砖,并且 outdoors 是 false - 那么我们将颜色乘以光强度。
接下来,让我们给玩家一个光源。 现在,我们将始终给他/她/它一个略带黄色的火炬。 在 spawner.rs 中,将此添加到为玩家构建的组件列表中:
#![allow(unused)] fn main() { .with(LightSource{ color: rltk::RGB::from_f32(1.0, 1.0, 0.5), range: 8 }) }
我们还将更新我们的 map_builders/limestone_caverns.rs 以在洞穴中使用照明。 在自定义构建器的末尾,将 take_snapshot 更改为:
#![allow(unused)] fn main() { build_data.take_snapshot(); build_data.map.outdoors = false; }
最后,我们需要一个 系统 来实际计算照明。 创建一个新文件 lighting_system.rs:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Viewshed, Position, Map, LightSource}; use rltk::RGB; pub struct LightingSystem {} impl<'a> System<'a> for LightingSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadStorage<'a, Viewshed>, ReadStorage<'a, Position>, ReadStorage<'a, LightSource>); fn run(&mut self, data : Self::SystemData) { let (mut map, viewshed, positions, lighting) = data; if map.outdoors { return; } let black = RGB::from_f32(0.0, 0.0, 0.0); for l in map.light.iter_mut() { *l = black; } for (viewshed, pos, light) in (&viewshed, &positions, &lighting).join() { let light_point = rltk::Point::new(pos.x, pos.y); let range_f = light.range as f32; for t in viewshed.visible_tiles.iter() { if t.x > 0 && t.x < map.width && t.y > 0 && t.y < map.height { let idx = map.xy_idx(t.x, t.y); let distance = rltk::DistanceAlg::Pythagoras.distance2d(light_point, *t); let intensity = (range_f - distance) / range_f; map.light[idx] = map.light[idx] + (light.color * intensity); } } } } } }
这是一个非常简单的系统! 如果地图在户外,它只是简单地返回。 否则:
- 它将整个地图照明设置为黑暗。
- 它迭代所有具有位置、视野和光源的实体。
- 对于这些实体中的每一个,它都会迭代所有可见的瓷砖。
- 它计算可见瓷砖到光源的距离,并将其反转 - 因此距离光源越远就越暗。 然后将其除以光的范围,以将其缩放到 0..1 范围。
- 此照明量被添加到瓷砖的照明中。
最后,我们将系统添加到 main.rs 的 run_systems 函数中(作为要运行的最后一个系统):
#![allow(unused)] fn main() { let mut lighting = lighting_system::LightingSystem{}; lighting.run_now(&self.ecs); }
如果您现在 cargo run,您将拥有一个功能齐全的照明系统!

剩下的就是让 NPC 也拥有光照。 在 raws/mob_structs.rs 中,添加一个新类:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct MobLight { pub range : i32, pub color : String } }
并将其添加到主要的 mob 结构中:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub vision_range : i32, pub ai : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32>, pub equipped : Option<Vec<String>>, pub natural : Option<MobNatural>, pub loot_table : Option<String>, pub light : Option<MobLight> } }
现在我们可以修改 raws/rawmaster.rs 中的 spawn_named_mob 以支持它:
#![allow(unused)] fn main() { if let Some(light) = &mob_template.light { eb = eb.with(LightSource{ range: light.range, color : rltk::RGB::from_hex(&light.color).expect("Bad color") }); } }
让我们修改凝胶状立方怪使其发光。 在 spawns.json 中:
{
"name" : "Gelatinous Cube",
"level" : 2,
"attributes" : {},
"renderable": {
"glyph" : "▄",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 4,
"ai" : "carnivore",
"natural" : {
"armor_class" : 12,
"attacks" : [
{ "name" : "engulf", "hit_bonus" : 0, "damage" : "1d8" }
]
},
"light" : {
"range" : 4,
"color" : "#550000"
}
}
我们还将给土匪一个火炬:
{
"name" : "Bandit",
"renderable": {
"glyph" : "☻",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 6,
"ai" : "melee",
"quips" : [ "Stand and deliver!", "Alright, hand it over" ],
"attributes" : {},
"equipped" : [ "Dagger", "Shield", "Leather Armor", "Leather Boots" ],
"light" : {
"range" : 6,
"color" : "#FFFF55"
}
},
现在,当您 cargo run 并在洞穴中漫游时 - 您将看到从这些实体发出的光。 这是一个拿着火炬的土匪:

总结
在本章中,我们添加了一个全新的关卡和主题 - 并点亮了洞穴! 进展还不错。 游戏真的开始整合在一起了。
...
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
AI 清理和状态效果
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在设计文档中,我们指出我们希望 AI 比普通的石头更智能。在完成各个章节的过程中,我们也添加了很多与 AI 相关的系统,并且(您可能已经注意到)诸如持续应用混乱效果(以及偶尔出现无法击中刚刚移动的怪物的问题)之类的事情已经悄悄地溜走了!
随着我们为怪物增加复杂性,最好把所有这些都理顺,使其更容易支持新功能,并一致地处理诸如移动之类的常见问题。
链式系统
与其尝试在一个系统中为 NPC 完成所有事情,我们可以将这个过程分解为几个步骤。这样做会增加一些输入量,但其优点是每个步骤都是独立的、清晰的,并且只做一件事 - 这使得调试容易得多。这类似于我们处理 WantsToMelee 的方式 - 我们指示一个意图,然后在自己的步骤中处理它 - 这使我们可以将目标选择和实际战斗分开。
让我们看看这些步骤,看看它们是如何分解的:
- 我们确定轮到 NPC 行动了。
- 我们检查状态效果 - 例如混乱,以确定他们是否真的可以行动。
- 该 AI 类型对应的 AI 模块扫描周围环境,并确定他们是想移动、攻击还是什么都不做。
- 发生移动,这会更新各种全局状态。
- 发生战斗,这可能会杀死怪物或使其在未来无法行动。
模块化 AI
我们已经有了相当多的 AI 系统,而这只是增加了更多。因此,让我们将 AI 移到一个模块中。创建一个新文件夹 src/ai - 这将是新的 AI 模块。创建一个 mod.rs 文件,并将以下内容放入其中:
#![allow(unused)] fn main() { mod animal_ai_system; mod bystander_ai_system; mod monster_ai_system; pub use animal_ai_system::AnimalAI; pub use bystander_ai_system::BystanderAI; pub use monster_ai_system::MonsterAI; }
这告诉它使用其他 AI 模块,并在 ai 命名空间中共享它们。现在将 animal_ai_system、bystander_ai_system 和 monster_ai_system 从您的 src 目录移动到 src\ai。在 main.rs 的序言中(您放置所有 mod 和 use 语句的地方),删除这些系统的 mod 和 use 语句。将它们替换为单行 mod ai;。最后,您可以清理 run_systems 以通过 ai 命名空间引用这些系统:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut mob = ai::MonsterAI{}; mob.run_now(&self.ecs); let mut animal = ai::AnimalAI{}; animal.run_now(&self.ecs); let mut bystander = ai::BystanderAI{}; bystander.run_now(&self.ecs); ... }
在您的 ai/X_system 文件中,您有读取 use super::{...} 的行。将 super 替换为 crate,以表明您想使用来自父 crate 的组件(和其他类型)。
如果您现在 cargo run,您会得到与之前完全相同的游戏 - 您的重构成功了!
确定轮到谁了 - 先攻权/能量消耗
到目前为止,我们以严格但不够灵活的方式处理了我们的回合:玩家先行动,然后所有 NPC 再行动。来回循环,永远如此。这效果相当好,但它不允许太多变化:您不能让某个实体比其他实体更快,所有动作都花费相同的时间,并且诸如加速和减速法术之类的东西将无法实现。
许多 roguelike 游戏使用先攻权或先攻权消耗的变体来确定轮到谁了,所以我们将采用类似的方法。我们不想过于随机,这样您就不会突然看到事物加速和减速,但我们也希望更加灵活。我们也希望它稍微有点随机性,这样默认情况下所有 NPC 都不会同时行动 - 基本上就是我们已经拥有的情况。减慢重甲/武器使用者的速度,并让轻型装备的使用者更快(匕首使用者可以比双手巨剑使用者更频繁地攻击!)也会很好。
在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册),让我们创建一个新的 Initiative 组件:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Initiative { pub current : i32 } }
我们希望玩家从一个先攻权分数开始(我们将使用 0,这样他们总是先开始)。在 spawners.rs 中,我们只需将其添加到 player 函数中,作为玩家的另一个组件:
#![allow(unused)] fn main() { .with(Initiative{current: 0}) }
我们还希望所有 NPC 都从一个先攻权分数开始。因此,在 raws/rawmaster.rs 中,我们将其添加到 spawn_named_mob 函数中,作为另一个始终存在的组件。我们将给怪物一个 2 的初始先攻权 - 因此在第一回合中,它们将在玩家之后立即处理(稍后我们将担心后续回合)。
#![allow(unused)] fn main() { // 先攻权为 2 eb = eb.with(Initiative{current: 2}); }
这添加了组件,但目前它根本没有做任何事情。我们将从在 components.rs 中创建另一个新组件开始(并在 main.rs 和 saveload_system.rs 中注册),称为 MyTurn:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct MyTurn {} }
MyTurn 组件背后的想法是,如果您拥有该组件,那么就轮到您行动了 - 并且您应该被包括在 AI/回合控制中(如果玩家拥有 MyTurn,那么我们等待输入)。如果您没有它,那么您就不能行动。我们也可以将其用作过滤器:因此,诸如状态效果之类的东西可以检查是否轮到您了,并且您是否受到状态的影响,它们可能会确定您必须跳过您的回合。
现在我们应该制作一个新的 - 简单的 - 系统来处理先攻权掷骰。创建一个新文件 ai/initiative_system.rs:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{Initiative, Position, MyTurn, Attributes, RunState}; pub struct InitiativeSystem {} impl<'a> System<'a> for InitiativeSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, Initiative>, ReadStorage<'a, Position>, WriteStorage<'a, MyTurn>, Entities<'a>, WriteExpect<'a, rltk::RandomNumberGenerator>, ReadStorage<'a, Attributes>, WriteExpect<'a, RunState>, ReadExpect<'a, Entity>); fn run(&mut self, data : Self::SystemData) { let (mut initiatives, positions, mut turns, entities, mut rng, attributes, mut runstate, player) = data; if *runstate != RunState::Ticking { return; } // 我们稍后会添加 Ticking;如果您想在此时测试,请使用 MonsterTurn // 清除我们错误留下的任何剩余 MyTurn turns.clear(); // 掷先攻权 for (entity, initiative, _pos) in (&entities, &mut initiatives, &positions).join() { initiative.current -= 1; if initiative.current < 1 { // 轮到我了! turns.insert(entity, MyTurn{}).expect("无法插入回合"); // 重新掷骰 initiative.current = 6 + rng.roll_dice(1, 6); // 给予敏捷奖励 if let Some(attr) = attributes.get(entity) { initiative.current -= attr.quickness.bonus; } // TODO: 稍后将在此处添加更多先攻权授予的增益/惩罚 // 如果是玩家,我们希望进入 AwaitingInput 状态 if entity == *player { *runstate = RunState::AwaitingInput; } } } } } }
这非常简单:
- 我们首先清除所有剩余的
MyTurn组件,以防我们忘记删除一个(这样实体就不会乱跑)。 - 我们迭代所有具有
Initiative组件的实体(表明它们可以行动)和Position组件的实体(我们不使用它,但表明它们在当前地图层上并且可以行动)。 - 我们从实体的当前先攻权中减去 1。
- 如果当前先攻权为 0(或更小,以防我们搞砸了!),我们向它们应用
MyTurn组件。然后我们重新掷骰它们的当前先攻权;我们现在使用6 + 1d6 + 敏捷奖励。请注意,我们留下了一个注释,表明我们稍后会使它更复杂! - 如果现在轮到玩家了,我们将全局
RunState更改为AwaitingInput- 是时候处理玩家的指令了。
我们也在检查是否轮到怪物了;我们实际上会改变这一点 - 但如果我们在测试它,我不希望系统一遍又一遍地旋转掷骰先攻权!
现在我们需要进入 mod.rs 并添加 mod initiative_system.rs; pub use initiative_system::InitiativeSystem; 这对行,以将其暴露给程序的其余部分。然后我们打开 main.rs 并将其添加到 run_systems 中:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut initiative = ai::InitiativeSystem{}; initiative.run_now(&self.ecs); ... }
我们在各种 AI 函数运行之前添加了它,但在我们获得地图索引和视野之后 - 因此它们具有最新的数据可以使用。
调整游戏循环以使用先攻权
打开 main.rs,我们将编辑 RunState 以摆脱 PlayerTurn 和 MonsterTurn 条目 - 将它们替换为 Ticking。这将破坏很多代码 - 但没关系,我们实际上是在简化和获得功能,这在大多数标准下都是双赢!这是新的 RunState:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, Ticking, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, PreviousLevel, ShowRemoveItem, GameOver, MagicMapReveal { row : i32 }, MapGeneration, ShowCheatMenu } }
在我们的主循环的 match 函数中,我们可以完全删除 MonsterTurn 条目,并将 PlayerTurn 调整为更通用的 Ticking 状态:
#![allow(unused)] fn main() { RunState::Ticking => { self.run_systems(); self.ecs.maintain(); match *self.ecs.fetch::<RunState>() { RunState::AwaitingInput => newrunstate = RunState::AwaitingInput, RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 }, _ => newrunstate = RunState::Ticking } } }
您还需要在 main.rs 中搜索 PlayerTurn 和 MonsterTurn;当许多状态完成时,它们会返回到其中一个状态。它们现在想要返回到 Ticking。
同样,在 player.rs 中,有很多地方我们返回 RunState::PlayerTurn - 您需要将所有这些更改为 Ticking。
我们将修改饥饿时钟,使其仅在您的回合中计时。这实际上变得更简单了;我们只需加入 MyTurn 即可删除整个“proceed”系统:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{HungerClock, RunState, HungerState, SufferDamage, gamelog::GameLog, MyTurn}; pub struct HungerSystem {} impl<'a> System<'a> for HungerSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteStorage<'a, HungerClock>, ReadExpect<'a, Entity>, // 玩家 ReadExpect<'a, RunState>, WriteStorage<'a, SufferDamage>, WriteExpect<'a, GameLog>, ReadStorage<'a, MyTurn> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut hunger_clock, player_entity, runstate, mut inflict_damage, mut log, turns) = data; for (entity, mut clock, _myturn) in (&entities, &mut hunger_clock, &turns).join() { clock.duration -= 1; if clock.duration < 1 { match clock.state { HungerState::WellFed => { clock.state = HungerState::Normal; clock.duration = 200; if entity == *player_entity { log.entries.push("您不再吃得饱饱的了。".to_string()); } } HungerState::Normal => { clock.state = HungerState::Hungry; clock.duration = 200; if entity == *player_entity { log.entries.push("您饿了。".to_string()); } } HungerState::Hungry => { clock.state = HungerState::Starving; clock.duration = 200; if entity == *player_entity { log.entries.push("您饿得要命了!".to_string()); } } HungerState::Starving => { // 饥饿造成的伤害 if entity == *player_entity { log.entries.push("您的饥饿感变得痛苦起来!您受到 1 点生命值伤害。".to_string()); } SufferDamage::new_damage(&mut inflict_damage, entity, 1, false); } } } } } } }
这将使 ai 中的文件出现错误。我们将进行最少的更改,以使其现在可以运行。删除检查游戏状态的行,并为 MyTurn 添加读取存储。将回合添加到 join 中,以便实体仅在其回合时才行动。所以在 ai/animal_ai_system.rs 中:
#![allow(unused)] fn main() { impl<'a> System<'a> for AnimalAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, Entity>, ReadExpect<'a, RunState>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Herbivore>, ReadStorage<'a, Carnivore>, ReadStorage<'a, Item>, WriteStorage<'a, WantsToMelee>, WriteStorage<'a, EntityMoved>, WriteStorage<'a, Position>, ReadStorage<'a, MyTurn> ); fn run(&mut self, data : Self::SystemData) { let (mut map, player_entity, runstate, entities, mut viewshed, herbivore, carnivore, item, mut wants_to_melee, mut entity_moved, mut position, turns) = data; ... for (entity, mut viewshed, _herbivore, mut pos, _turn) in (&entities, &mut viewshed, &herbivore, &mut position, &turns).join() { ... for (entity, mut viewshed, _carnivore, mut pos, _turn) in (&entities, &mut viewshed, &carnivore, &mut position, &turns).join() { }
同样,在 bystander_ai_system.rs 中:
#![allow(unused)] fn main() { impl<'a> System<'a> for BystanderAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, RunState>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Bystander>, WriteStorage<'a, Position>, WriteStorage<'a, EntityMoved>, WriteExpect<'a, rltk::RandomNumberGenerator>, ReadExpect<'a, Point>, WriteExpect<'a, GameLog>, WriteStorage<'a, Quips>, ReadStorage<'a, Name>, ReadStorage<'a, MyTurn>); fn run(&mut self, data : Self::SystemData) { let (mut map, runstate, entities, mut viewshed, bystander, mut position, mut entity_moved, mut rng, player_pos, mut gamelog, mut quips, names, turns) = data; for (entity, mut viewshed,_bystander,mut pos, _turn) in (&entities, &mut viewshed, &bystander, &mut position, &turns).join() { ... }
再次在 monster_ai_system.rs 中:
#![allow(unused)] fn main() { impl<'a> System<'a> for MonsterAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, Point>, ReadExpect<'a, Entity>, ReadExpect<'a, RunState>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Monster>, WriteStorage<'a, Position>, WriteStorage<'a, WantsToMelee>, WriteStorage<'a, Confusion>, WriteExpect<'a, ParticleBuilder>, WriteStorage<'a, EntityMoved>, ReadStorage<'a, MyTurn>); fn run(&mut self, data : Self::SystemData) { let (mut map, player_pos, player_entity, runstate, entities, mut viewshed, monster, mut position, mut wants_to_melee, mut confused, mut particle_builder, mut entity_moved, turns) = data; for (entity, mut viewshed,_monster,mut pos, _turn) in (&entities, &mut viewshed, &monster, &mut position, &turns).join() { }
这样就解决了编译错误!现在 cargo run 游戏。它像以前一样运行,只是稍微慢了一点。一旦我们有了基本的功能,我们将担心性能 - 所以这是一个很大的进步,我们有了一个先攻权系统!
处理状态效果
现在,我们在 monster_ai_system 中检查混乱 - 实际上在旁观者、商人和动物中忘记了它。与其到处复制/粘贴代码,不如利用这个机会创建一个系统来处理状态效果回合跳过,并清理其他系统以从中受益。创建一个新文件 ai/turn_status.rs:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Confusion, RunState}; pub struct TurnStatusSystem {} impl<'a> System<'a> for TurnStatusSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, WriteStorage<'a, Confusion>, Entities<'a>, ReadExpect<'a, RunState>); fn run(&mut self, data : Self::SystemData) { let (mut turns, mut confusion, entities, runstate) = data; if *runstate != RunState::Ticking { return; } let mut not_my_turn : Vec<Entity> = Vec::new(); let mut not_confused : Vec<Entity> = Vec::new(); for (entity, _turn, confused) in (&entities, &mut turns, &mut confusion).join() { confused.turns -= 1; if confused.turns < 1 { not_confused.push(entity); } else { not_my_turn.push(entity); } } for e in not_my_turn { turns.remove(e); } for e in not_confused { confusion.remove(e); } } } }
这非常简单:它迭代每个处于混乱状态的人,并减少他们的回合计数器。如果他们仍然处于混乱状态,则移除 MyTurn。如果他们已经恢复,则移除 Confusion。您需要在 ai/mod.rs 中为其添加 mod 和 pub use 语句,并将其添加到 main.rs 中的 run_systems 函数中:
#![allow(unused)] fn main() { let mut initiative = ai::InitiativeSystem{}; initiative.run_now(&self.ecs); let mut turnstatus = ai::TurnStatusSystem{}; turnstatus.run_now(&self.ecs); }
这展示了我们正在使用的新模式:系统只做一件事,并且可以移除 MyTurn 以防止将来执行。您还可以进入 monster_ai_system 并删除与混乱相关的所有内容。
爱说话的 NPC
还记得当我们添加土匪时,我们给他们一些评论来增加趣味性吗?您可能已经注意到他们实际上并没有说话!那是因为我们在旁观者 AI 中处理了说话 - 而不是作为一个普遍的概念。让我们将说话移到它自己的系统中。创建一个新文件 ai/quipping.rs:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{gamelog::GameLog, Quips, Name, MyTurn, Viewshed}; pub struct QuipSystem {} impl<'a> System<'a> for QuipSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, GameLog>, WriteStorage<'a, Quips>, ReadStorage<'a, Name>, ReadStorage<'a, MyTurn>, ReadExpect<'a, rltk::Point>, ReadStorage<'a, Viewshed>, WriteExpect<'a, rltk::RandomNumberGenerator>); fn run(&mut self, data : Self::SystemData) { let (mut gamelog, mut quips, names, turns, player_pos, viewsheds, mut rng) = data; for (quip, name, viewshed, _turn) in (&mut quips, &names, &viewsheds, &turns).join() { if !quip.available.is_empty() && viewshed.visible_tiles.contains(&player_pos) && rng.roll_dice(1,6)==1 { let quip_index = if quip.available.len() == 1 { 0 } else { (rng.roll_dice(1, quip.available.len() as i32)-1) as usize }; gamelog.entries.push( format!("{} 说 \"{}\"", name.name, quip.available[quip_index]) ); quip.available.remove(quip_index); } } } } }
这基本上是 bystander_ai_system 中的说话代码,所以我们真的不需要过多地讨论它。您确实想将其添加到 main.rs 中的 run_systems 中,以便它可以运行(并在 ai/mod.rs 中添加 mod 和 pub use 语句):
#![allow(unused)] fn main() { turnstatus.run_now(&self.ecs); let mut quipper = ai::QuipSystem{}; quipper.run_now(&self.ecs); }
还要进入 bystander_ai_system.rs 并删除所有说话代码!它缩短了很多,如果您现在 cargo run,土匪就可以侮辱您了。实际上,现在可以给任何 NPC 添加俏皮话 - 并且会愉快地对您说些什么。再一次,我们使系统更小并且获得了功能。又一次胜利!
让 AI 看起来像在思考
目前,我们为每种类型的 AI 都设置了一个单独的系统 - 结果导致一些代码重复。我们还有一些非常不切实际的事情正在发生:怪物保持完全静止,直到它们能看到您,并且一旦您绕过一个角落,它们就会完全忘记您。村民像喝醉了的酒鬼一样随机移动,即使他们是清醒的。狼会追捕鹿 - 但同样,仅当它们可见时才追捕。您可以通过给 NPC 目标 - 并让目标持续不止一个回合,从而显着提高表面上的 AI 智能(它仍然很笨!)。然后,您可以将基于类型的决策更改为基于目标的决策;帮助 NPC 实现他们生活中想要的任何东西。
让我们花一点时间来考虑一下我们的 NPC 在生活中真正想要什么:
- 鹿和其他食草动物真正想要吃草,不被打扰,并逃离可能杀死它们的东西(实际上是所有东西;在食物链中不是一个好地方)。
- 怪物想要守卫地下城,杀死玩家,并在其他方面过着平静的生活。
- 狼(和其他食肉动物)想要吃玩家和食草动物。
- 凝胶状立方体实际上并不以思考而闻名!
- 村民真正想要过他们的日常生活,偶尔对路过的玩家说些什么。
- 商人想待在他们的商店里,并在未来的章节更新中向您出售商品!
这并没有真正考虑到短暂的目标;受伤的怪物可能想要逃离战斗,怪物可能想要考虑拿起碰巧就在它们旁边的发光的末日长剑,等等。但这仍然是一个好的开始。
我们实际上可以将很多这些归结为“状态机”。您以前见过这些:RunState 使整个游戏成为一种状态,并且每个 UI 框都返回当前状态。在这种情况下,我们将让 NPC 拥有一个状态 - 代表他们现在尝试做什么,以及他们是否已经实现它。我们应该能够用 json 原始文件中的标签来描述 AI 的目标,并实现更小的子系统,以使 AI 的行为在某种程度上可信。
确定 AI 对其他实体的感觉
AI 面临的许多决策都围绕着:那是什么,以及我对它们的感觉如何?如果它们是敌人,我应该攻击还是逃跑(取决于我的性格)。如果我对它们感到中立,那么我真的不在乎它们的存在。如果我喜欢它们,我甚至可能想靠近它们!输入每个实体对每个其他实体的感觉将是一项巨大的数据输入工作 - 每次您添加一个实体时,您都需要去将它们添加到每个其他实体(并记住如果您删除它们/想要更改它们,则在所有地方删除/编辑它们)。这不是一个好主意!
像许多游戏一样,我们可以通过一个简单的阵营系统来解决这个问题。NPC(和玩家)是阵营的成员。阵营对其他阵营(包括默认阵营)有感觉。然后我们可以进行简单的阵营查找,以了解 NPC 对潜在目标的感觉。我们还可以在用户界面中包含阵营信息,以帮助玩家了解正在发生的事情。
我们将从 spawns.json 中的阵营表开始。这是第一个草案:
"faction_table" : [
{ "name" : "Player", "responses": { }},
{ "name" : "Mindless", "responses": { "Default" : "attack" } },
{ "name" : "Townsfolk", "responses" : { "Default" : "ignore" } },
{ "name" : "Bandits", "responses" : { "Default" : "attack" } },
{ "name" : "Cave Goblins", "responses" : { "Default" : "attack" } },
{ "name" : "Carnivores", "responses" : { "Default" : "attack" } },
{ "name" : "Herbivores", "responses" : { "Default" : "flee" } }
],
我们还需要为每个 NPC 添加一个条目,例如:"faction" : "Bandit"。
为了使它工作,我们需要创建一个新的组件来存储阵营成员资格。与往常一样,它需要在 main.rs 和 saveload_system.rs 中注册,并在 components.rs 中定义:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Faction { pub name : String } }
让我们从打开 spawner.rs 并修改 player 函数开始,始终将玩家添加到“Player”阵营:
#![allow(unused)] fn main() { .with(Faction{name : "Player".to_string() }) }
现在我们需要在加载其余原始数据时加载阵营表。我们将创建一个新文件 raws/faction_structs.rs 来保存此信息。我们的目标是镜像我们为 JSON 设计的内容:
#![allow(unused)] fn main() { use serde::{Deserialize}; use std::collections::HashMap; #[derive(Deserialize, Debug)] pub struct FactionInfo { pub name : String, pub responses : HashMap<String, String> } }
反过来,我们将其添加到 raws/mod.rs 中的 Raws 结构中:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Raws { pub items : Vec<Item>, pub mobs : Vec<Mob>, pub props : Vec<Prop>, pub spawn_table : Vec<SpawnTableEntry>, pub loot_tables : Vec<LootTable>, pub faction_table : Vec<FactionInfo> } }
我们还需要将其添加到 raws/rawmaster.rs 中的原始构造函数中:
#![allow(unused)] fn main() { impl RawMaster { pub fn empty() -> RawMaster { RawMaster { raws : Raws{ items: Vec::new(), mobs: Vec::new(), props: Vec::new(), spawn_table: Vec::new(), loot_tables: Vec::new(), faction_table : Vec::new(), }, item_index : HashMap::new(), mob_index : HashMap::new(), prop_index : HashMap::new(), loot_index : HashMap::new() } } }
我们还需要在 Raws 中添加一些索引。我们需要一种比字符串更好的方式来表示反应,所以让我们先在 faction_structs.rs 中添加一个枚举:
#![allow(unused)] fn main() { #[derive(PartialEq, Eq, Hash, Copy, Clone)] pub enum Reaction { Ignore, Attack, Flee } }
现在我们为 RawMaster 添加反应索引:
#![allow(unused)] fn main() { pub struct RawMaster { raws : Raws, item_index : HashMap<String, usize>, mob_index : HashMap<String, usize>, prop_index : HashMap<String, usize>, loot_index : HashMap<String, usize>, faction_index : HashMap<String, HashMap<String, Reaction>> } }
也将其作为 faction_index : HashMap::new() 添加到 RawMaster 构造函数中。最后,我们将设置索引 - 打开 load 函数并在末尾添加以下内容:
#![allow(unused)] fn main() { for faction in self.raws.faction_table.iter() { let mut reactions : HashMap<String, Reaction> = HashMap::new(); for other in faction.responses.iter() { reactions.insert( other.0.clone(), match other.1.as_str() { "ignore" => Reaction::Ignore, "flee" => Reaction::Flee, _ => Reaction::Attack } ); } self.faction_index.insert(faction.name.clone(), reactions); } }
这会迭代所有阵营,然后迭代它们对其他阵营的反应 - 构建一个关于它们如何响应每个阵营的 HashMap。然后将这些存储在 faction_index 表中。
这样就加载了原始阵营信息,我们仍然必须将其转化为游戏中易于使用的东西。我们还应该在 mob_structs.rs 中添加一个阵营选项:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub vision_range : i32, pub ai : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32>, pub equipped : Option<Vec<String>>, pub natural : Option<MobNatural>, pub loot_table : Option<String>, pub light : Option<MobLight>, pub faction : Option<String> } }
并在 spawn_named_mob 中添加组件。如果没有,我们将自动将“mindless”应用于怪物:
#![allow(unused)] fn main() { if let Some(faction) = &mob_template.faction { eb = eb.with(Faction{ name: faction.clone() }); } else { eb = eb.with(Faction{ name : "Mindless".to_string() }) } }
现在在 rawmaster.rs 中,我们将添加另一个函数:查询阵营表以获得关于阵营的反应:
#![allow(unused)] fn main() { pub fn faction_reaction(my_faction : &str, their_faction : &str, raws : &RawMaster) -> Reaction { if raws.faction_index.contains_key(my_faction) { let mf = &raws.faction_index[my_faction]; if mf.contains_key(their_faction) { return mf[their_faction]; } else if mf.contains_key("Default") { return mf["Default"]; } else { return Reaction::Ignore; } } Reaction::Ignore } }
因此,给定 my_faction 的名称和另一个实体的阵营(their_faction),我们可以查询阵营表并返回一个反应。如果没有反应,我们默认使用 Ignore(这不应该发生,因为我们默认使用 Mindless)。
通用 AI 任务:处理相邻实体
几乎每个 AI 都需要知道如何处理相邻实体。它可能是敌人(攻击或逃离),可能是可以忽略的人等等 - 但它需要被处理。与其在每个 AI 模块中单独处理它,不如构建一个通用系统来处理它。让我们创建一个新文件 ai/adjacent_ai_system.rs(并在 ai/mod.rs 中像其他文件一样为它添加 mod 和 pub use 条目):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Faction, Position, Map, raws::Reaction, WantsToMelee}; pub struct AdjacentAI {} impl<'a> System<'a> for AdjacentAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, ReadStorage<'a, Faction>, ReadStorage<'a, Position>, ReadExpect<'a, Map>, WriteStorage<'a, WantsToMelee>, Entities<'a>, ReadExpect<'a, Entity> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, factions, positions, map, mut want_melee, entities, player) = data; let mut turn_done : Vec<Entity> = Vec::new(); for (entity, _turn, my_faction, pos) in (&entities, &turns, &factions, &positions).join() { if entity != *player { let mut reactions : Vec<(Entity, Reaction)> = Vec::new(); let idx = map.xy_idx(pos.x, pos.y); let w = map.width; let h = map.height; // 为每个方向的邻居添加可能的反应 if pos.x > 0 { evaluate(idx-1, &map, &factions, &my_faction.name, &mut reactions); } if pos.x < w-1 { evaluate(idx+1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y > 0 { evaluate(idx-w as usize, &map, &factions, &my_faction.name, &mut reactions); } if pos.y < h-1 { evaluate(idx+w as usize, &map, &factions, &my_faction.name, &mut reactions); } if pos.y > 0 && pos.x > 0 { evaluate((idx-w as usize)-1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y > 0 && pos.x < w-1 { evaluate((idx-w as usize)+1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y < h-1 && pos.x > 0 { evaluate((idx+w as usize)-1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y < h-1 && pos.x < w-1 { evaluate((idx+w as usize)+1, &map, &factions, &my_faction.name, &mut reactions); } let mut done = false; for reaction in reactions.iter() { if let Reaction::Attack = reaction.1 { want_melee.insert(entity, WantsToMelee{ target: reaction.0 }).expect("插入近战错误"); done = true; } } if done { turn_done.push(entity); } } } // 删除已完成实体的回合标记 for done in turn_done.iter() { turns.remove(*done); } } } fn evaluate(idx : usize, map : &Map, factions : &ReadStorage<Faction>, my_faction : &str, reactions : &mut Vec<(Entity, Reaction)>) { for other_entity in map.tile_content[idx].iter() { if let Some(faction) = factions.get(*other_entity) { reactions.push(( *other_entity, crate::raws::faction_reaction(my_faction, &faction.name, &crate::raws::RAWS.lock().unwrap()) )); } } } }
这个系统的工作方式如下:
- 我们查询所有具有阵营、位置和回合的实体,并通过检查具有玩家实体资源的实体来确保我们没有修改玩家的行为。
- 我们查询地图上所有相邻的瓦片,记录对相邻实体的反应。
- 我们迭代生成的反应,如果是
Attack反应 - 我们取消它们的回合并启动WantsToMelee结果。
要实际使用这个系统,请将其添加到 main.rs 中 run_systems 中的 MonsterAI 之前:
#![allow(unused)] fn main() { let mut adjacent = ai::AdjacentAI{}; adjacent.run_now(&self.ecs); }
如果您现在 cargo run 游戏,骚乱爆发了!每个人都属于“mindless”阵营,因此对其他人怀有敌意!这实际上是一个很棒的演示,展示了我们的引擎的性能;尽管战斗从四面八方进行,但它运行得非常好:

恢复城镇的和平
这也完全不是我们对和平起始城镇的设想。它可能适用于僵尸末日,但这最好留给 Cataclysm: Dark Days Ahead(顺便说一句,这是一个很棒的游戏)!幸运的是,我们可以通过向所有城镇 NPC 添加 "faction" : "Townsfolk" 行来恢复城镇的和平。这是酒保的例子;您需要对所有城镇居民执行相同的操作:
{
"name" : "Barkeep",
"renderable": {
"glyph" : "☻",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 4,
"ai" : "vendor",
"attributes" : {
"intelligence" : 13
},
"skills" : {
"Melee" : 2
},
"equipped" : [ "Cudgel", "Cloth Tunic", "Cloth Pants", "Slippers" ],
"faction" : "Townsfolk"
},
一旦您将这些放入,您就可以 cargo run - 并在我们的时代获得和平!好吧,几乎:如果您观看战斗日志,老鼠会互相猛烈攻击。同样,这不太符合我们的意图。打开 spawns.json,让我们为老鼠添加一个阵营 - 并让它们互相忽略。我们还将互相忽略添加到其他几个阵营中 - 这样土匪就不会无缘无故地互相残杀:
"faction_table" : [
{ "name" : "Player", "responses": { }},
{ "name" : "Mindless", "responses": { "Default" : "attack" } },
{ "name" : "Townsfolk", "responses" : { "Default" : "ignore" } },
{ "name" : "Bandits", "responses" : { "Default" : "attack", "Bandits" : "ignore" } },
{ "name" : "Cave Goblins", "responses" : { "Default" : "attack", "Cave Goblins" : "ignore" } },
{ "name" : "Carnivores", "responses" : { "Default" : "attack", "Carnivores" : "ignore" } },
{ "name" : "Herbivores", "responses" : { "Default" : "flee", "Herbivores" : "ignore" } },
{ "name" : "Hungry Rodents", "responses": { "Default" : "attack", "Hungry Rodents" : "ignore" }}
],
此外,将 Rat 添加到 Hungry Rodents 阵营:
{
"name" : "Rat",
"renderable": {
"glyph" : "r",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"ai" : "melee",
"attributes" : {
"Might" : 3,
"Fitness" : 3
},
"skills" : {
"Melee" : -1,
"Defense" : -1
},
"natural" : {
"armor_class" : 11,
"attacks" : [
{ "name" : "bite", "hit_bonus" : 0, "damage" : "1d4" }
]
},
"faction" : "Hungry Rodents"
},
现在 cargo run,您会看到老鼠不再互相攻击了。
响应更远的实体
响应您旁边的人是迈出的伟大第一步,并且实际上有助于处理时间(因为相邻的敌人是在没有代价高昂的整个视野搜索的情况下处理的) - 但是如果没有相邻的敌人,AI 需要寻找更远的敌人。如果发现一个需要反应的敌人,我们需要一些组件来指示意图。在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct WantsToApproach { pub idx : i32 } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct WantsToFlee { pub indices : Vec<usize> } }
这些旨在表明 AI 想要做什么:接近一个瓦片(敌人),或逃离一系列敌方瓦片。
我们将创建另一个新系统 ai/visible_ai_system.rs(并将其添加到 ai/mod.rs 中的 mod 和 pub use 中):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Faction, Position, Map, raws::Reaction, Viewshed, WantsToFlee, WantsToApproach}; pub struct VisibleAI {} impl<'a> System<'a> for VisibleAI { #[allow(clippy::type_complexity)] type SystemData = ( ReadStorage<'a, MyTurn>, ReadStorage<'a, Faction>, ReadStorage<'a, Position>, ReadExpect<'a, Map>, WriteStorage<'a, WantsToApproach>, WriteStorage<'a, WantsToFlee>, Entities<'a>, ReadExpect<'a, Entity>, ReadStorage<'a, Viewshed> ); fn run(&mut self, data : Self::SystemData) { let (turns, factions, positions, map, mut want_approach, mut want_flee, entities, player, viewsheds) = data; for (entity, _turn, my_faction, pos, viewshed) in (&entities, &turns, &factions, &positions, &viewsheds).join() { if entity != *player { let my_idx = map.xy_idx(pos.x, pos.y); let mut reactions : Vec<(usize, Reaction)> = Vec::new(); let mut flee : Vec<usize> = Vec::new(); for visible_tile in viewshed.visible_tiles.iter() { let idx = map.xy_idx(visible_tile.x, visible_tile.y); if my_idx != idx { evaluate(idx, &map, &factions, &my_faction.name, &mut reactions); } } let mut done = false; for reaction in reactions.iter() { match reaction.1 { Reaction::Attack => { want_approach.insert(entity, WantsToApproach{ idx: reaction.0 as i32 }).expect("无法插入"); done = true; } Reaction::Flee => { flee.push(reaction.0); } _ => {} } } if !done && !flee.is_empty() { want_flee.insert(entity, WantsToFlee{ indices : flee }).expect("无法插入"); } } } } } fn evaluate(idx : usize, map : &Map, factions : &ReadStorage<Faction>, my_faction : &str, reactions : &mut Vec<(usize, Reaction)>) { for other_entity in map.tile_content[idx].iter() { if let Some(faction) = factions.get(*other_entity) { reactions.push(( idx, crate::raws::faction_reaction(my_faction, &faction.name, &crate::raws::RAWS.lock().unwrap()) )); } } } }
请记住,如果我们已经在处理相邻的敌人 - 这根本不会运行 - 因此无需担心分配近战。它也不会做任何事情 - 它会触发其他系统/服务的意图。所以我们不必担心结束回合。它只是扫描每个可见的瓦片,并评估对瓦片内容的可用反应。如果它看到它想要攻击的东西,它会设置一个 WantsToApproach 组件。如果它看到它应该逃离的东西,它会填充一个 WantsToFlee 结构。
您还需要将其添加到 main.rs 中的 run_systems 中,也在邻接检查之后:
#![allow(unused)] fn main() { let mut visible = ai::VisibleAI{}; visible.run_now(&self.ecs); }
接近
现在我们标记了接近瓦片的愿望(无论出于何种原因;目前是因为居住者应该受到殴打),我们可以编写一个非常简单的系统来处理这个问题。创建一个新文件 ai/approach_ai_system.rs(并在 ai/mod.rs 中 mod/pub use 它):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, WantsToApproach, Position, Map, Viewshed, EntityMoved}; pub struct ApproachAI {} impl<'a> System<'a> for ApproachAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, WriteStorage<'a, WantsToApproach>, WriteStorage<'a, Position>, WriteExpect<'a, Map>, WriteStorage<'a, Viewshed>, WriteStorage<'a, EntityMoved>, Entities<'a> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, mut want_approach, mut positions, mut map, mut viewsheds, mut entity_moved, entities) = data; let mut turn_done : Vec<Entity> = Vec::new(); for (entity, mut pos, approach, mut viewshed, _myturn) in (&entities, &mut positions, &want_approach, &mut viewsheds, &turns).join() { turn_done.push(entity); let path = rltk::a_star_search( map.xy_idx(pos.x, pos.y) as i32, map.xy_idx(approach.idx % map.width, approach.idx / map.width) as i32, &mut *map ); if path.success && path.steps.len()>1 { let mut idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = false; pos.x = path.steps[1] as i32 % map.width; pos.y = path.steps[1] as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("无法插入标记"); idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = true; viewshed.dirty = true; } } want_approach.clear(); // 删除已完成实体的回合标记 for done in turn_done.iter() { turns.remove(*done); } } } }
这基本上与 MonsterAI 中的接近代码相同,但它适用于所有接近请求 - 适用于任何目标。它还在完成后移除 MyTurn,并移除所有接近请求。将其添加到 main.rs 中的 run_systems 中,在远程 AI 处理程序之后:
#![allow(unused)] fn main() { let mut approach = ai::ApproachAI{}; approach.run_now(&self.ecs); }
逃跑
我们还需要实现一个逃跑系统,主要基于我们动物 AI 中的逃跑代码。创建一个新文件 flee_ai_system.rs(并记住 ai/mod.rs 中的 mod 和 pub use):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, WantsToFlee, Position, Map, Viewshed, EntityMoved}; pub struct FleeAI {} impl<'a> System<'a> for FleeAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, WriteStorage<'a, WantsToFlee>, WriteStorage<'a, Position>, WriteExpect<'a, Map>, WriteStorage<'a, Viewshed>, WriteStorage<'a, EntityMoved>, Entities<'a> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, mut want_flee, mut positions, mut map, mut viewsheds, mut entity_moved, entities) = data; let mut turn_done : Vec<Entity> = Vec::new(); for (entity, mut pos, flee, mut viewshed, _myturn) in (&entities, &mut positions, &want_flee, &mut viewsheds, &turns).join() { turn_done.push(entity); let my_idx = map.xy_idx(pos.x, pos.y); map.populate_blocked(); let flee_map = rltk::DijkstraMap::new(map.width as usize, map.height as usize, &flee.indices, &*map, 100.0); let flee_target = rltk::DijkstraMap::find_highest_exit(&flee_map, my_idx, &*map); if let Some(flee_target) = flee_target { if !map.blocked[flee_target as usize] { map.blocked[my_idx] = false; map.blocked[flee_target as usize] = true; viewshed.dirty = true; pos.x = flee_target as i32 % map.width; pos.y = flee_target as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("无法插入标记"); } } } want_flee.clear(); // 删除已完成实体的回合标记 for done in turn_done.iter() { turns.remove(*done); } } } }
我们还需要在 run_systems(main.rs)中注册它,在接近系统之后:
#![allow(unused)] fn main() { let mut flee = ai::FleeAI{}; flee.run_now(&self.ecs); }
为了增加效果,让我们让 Townsfolk 逃离潜在的敌对实体。在 spawns.json 中:
{ "name" : "Townsfolk", "responses" : { "Default" : "flee", "Player" : "ignore", "Townsfolk" : "ignore" } },
如果您现在 cargo run 并玩游戏,怪物将接近并攻击 - 而懦夫将逃离敌对势力。
清理
我们现在正在执行 MonsterAI 执行的最小 AI 以及我们的通用系统中大部分食肉动物和食草动物的处理,并且赋予了城镇居民比以前更高的智能!如果您查看 MonsterAI - 没有任何剩余的东西不是已经执行的!因此,我们可以删除 ai/monster_ai_system.rs,并将其从 run_systems(在 main.rs 中)中完全删除!删除后,您应该 cargo run 以查看游戏是否未更改 - 它应该是!
同样,ai/animal_ai_system.rs 的逃跑和接近现在是多余的。您实际上也可以删除此系统!
最好确保现在所有 NPC 都有一个阵营(除了实际上是无意识的凝胶状立方体)。您可以查看 spawns.json 的源代码以查看更改:这很明显,现在一切都有一个阵营。
剩余的 AI:旁观者
因此,剩余的独特 AI 模块是旁观者,他们只做一件事:随机移动。这实际上是一种非常适合鹿的行为(而不仅仅是站在那里)。如果城镇居民表现出稍微更多的智能,那也会很好。
让我们考虑一下我们现在的 AI 是如何工作的:
- 先攻权 决定是否轮到 NPC 行动。
- 状态 可以根据正在经历的效果来取消行动。
- 邻接关系 决定对附近实体的即时反应。
- 视野 决定对稍微不太近的实体的反应。
- 每个 AI 系统 决定实体现在做什么。
我们可以用更通用的“移动选项”集来替换每个 AI 系统。如果其他系统都没有导致 NPC 行动,这些选项将控制 NPC 的行为。现在让我们考虑一下我们希望城镇居民和其他人如何移动:
- 商人留在他们的商店里。
- 顾客应留在他们光顾的商店中。
- 醉汉应该随机蹒跚而行。鹿可能也应该随机移动,这很合理。
- 普通城镇居民应该在建筑物之间移动,表现得好像他们有计划一样。
- 警卫可以巡逻(我们没有任何警卫,但它们是有意义的)。对于其他怪物类型来说,巡逻而不是保持静态也可能很好。也许土匪应该在森林中漫游以寻找受害者。
- 敌对势力应在超出视觉范围之外追逐其目标,但有一定的逃脱机会。
制作移动模式组件
让我们创建一个新组件(在 components.rs 中,并在 main.rs 和 saveload_system.rs 中注册)来捕获移动模式。我们将从简单的开始:静态(不去任何地方)和随机(像傻瓜一样游荡!)。请注意,您不需要注册枚举 - 只需注册组件:
#![allow(unused)] fn main() { #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)] pub enum Movement { Static, Random } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct MoveMode { pub mode : Movement } }
现在我们将打开 raws/mob_structs.rs 并对其进行编辑以捕获移动模式 - 并且不再提供 AI 标签(因为这将使我们能够完全摆脱它们):
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub vision_range : i32, pub movement : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32>, pub equipped : Option<Vec<String>>, pub natural : Option<MobNatural>, pub loot_table : Option<String>, pub light : Option<MobLight>, pub faction : Option<String> } }
(我们将 ai 重命名为 movement)。这破坏了 rawmaster 的一部分;打开 spawn_named_mob 函数并将 AI 标签选择替换为:
#![allow(unused)] fn main() { match mob_template.movement.as_ref() { "random" => eb = eb.with(MoveMode{ mode: Movement::Random }), _ => eb = eb.with(MoveMode{ mode: Movement::Static }) } }
现在,我们需要一个新的系统来处理“默认”移动(即,我们已经尝试了其他一切)。创建一个新文件 ai/default_move_system.rs(不要忘记在 ai/mod.rs 中 mod 和 pub use 它!):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, MoveMode, Movement, Position, Map, Viewshed, EntityMoved}; pub struct DefaultMoveAI {} impl<'a> System<'a> for DefaultMoveAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, ReadStorage<'a, MoveMode>, WriteStorage<'a, Position>, WriteExpect<'a, Map>, WriteStorage<'a, Viewshed>, WriteStorage<'a, EntityMoved>, WriteExpect<'a, rltk::RandomNumberGenerator>, Entities<'a> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, move_mode, mut positions, mut map, mut viewsheds, mut entity_moved, mut rng, entities) = data; let mut turn_done : Vec<Entity> = Vec::new(); for (entity, mut pos, mode, mut viewshed, _myturn) in (&entities, &mut positions, &move_mode, &mut viewsheds, &turns).join() { turn_done.push(entity); match mode.mode { Movement::Static => {}, Movement::Random => { let mut x = pos.x; let mut y = pos.y; let move_roll = rng.roll_dice(1, 5); match move_roll { 1 => x -= 1, 2 => x += 1, 3 => y -= 1, 4 => y += 1, _ => {} } if x > 0 && x < map.width-1 && y > 0 && y < map.height-1 { let dest_idx = map.xy_idx(x, y); if !map.blocked[dest_idx] { let idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = false; pos.x = x; pos.y = y; entity_moved.insert(entity, EntityMoved{}).expect("无法插入标记"); map.blocked[dest_idx] = true; viewshed.dirty = true; } } } } } // 删除已完成实体的回合标记 for done in turn_done.iter() { turns.remove(*done); } } } }
现在打开 main.rs,找到 run_systems 并将对 BystanderAI 的调用替换为 DefaultMoveAI:
#![allow(unused)] fn main() { let mut defaultmove = ai::DefaultMoveAI{}; defaultmove.run_now(&self.ecs); }
最后,我们需要打开 spawns.json 并将 mobs 中所有对 ai= 的引用替换为 movement=。为除顾客、食草动物和醉汉以外的所有人选择 static。
如果您现在 cargo run,您会看到每个人都站在那里 - 除了随机的人,他们漫无目的地游荡。
本节的最后一件事:继续删除 bystander_ai_system.rs 文件以及对它的所有引用。我们不再需要它了!
添加基于航点的移动
我们提到我们希望城镇居民四处走动,但不是随机走动。打开 components.rs,并为 Movement 添加一种模式:
#![allow(unused)] fn main() { #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)] pub enum Movement { Static, Random, RandomWaypoint{ path : Option<Vec<usize>> } } }
请注意,我们正在使用 Rust 的功能,即 enum 在其他语言中实际上是 union,以添加用于随机移动的可选 path。这表示 AI 尝试去的地方 - 如果没有当前目标,则为 None(要么是因为他们刚开始,要么是因为他们到达那里了)。我们希望不要每回合都运行昂贵的 A-Star 搜索,因此我们将存储路径 - 并继续遵循它,直到它无效。
现在在 rawmaster.rs 中,我们将其添加到移动模式列表中:
#![allow(unused)] fn main() { match mob_template.movement.as_ref() { "random" => eb = eb.with(MoveMode{ mode: Movement::Random }), "random_waypoint" => eb = eb.with(MoveMode{ mode: Movement::RandomWaypoint{ path: None } }), _ => eb = eb.with(MoveMode{ mode: Movement::Static }) } }
在 default_move_system.rs 中,我们可以添加实际的移动逻辑:
#![allow(unused)] fn main() { Movement::RandomWaypoint{path} => { if let Some(path) = path { // 我们有一个目标 - 去那里 let mut idx = map.xy_idx(pos.x, pos.y); if path.len()>1 { if !map.blocked[path[1] as usize] { map.blocked[idx] = false; pos.x = path[1] % map.width; pos.y = path[1] / map.width; entity_moved.insert(entity, EntityMoved{}).expect("无法插入标记"); idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = true; viewshed.dirty = true; path.remove(0); // 删除路径中的第一步 } // 否则我们等待一个回合,看看路径是否畅通 } else { mode.mode = Movement::RandomWaypoint{ path : None }; } } else { let target_x = rng.roll_dice(1, map.width-2); let target_y = rng.roll_dice(1, map.height-2); let idx = map.xy_idx(target_x, target_y); if tile_walkable(map.tiles[idx]) { let path = rltk::a_star_search( map.xy_idx(pos.x, pos.y) as i32, map.xy_idx(target_x, target_y) as i32, &mut *map ); if path.success && path.steps.len()>1 { mode.mode = Movement::RandomWaypoint{ path: Some(path.steps) }; } } } }
这有点复杂,所以让我们逐步了解一下:
- 我们匹配
RandomWaypoint并捕获path作为一个变量(以便在枚举内部访问它)。 - 如果路径存在:
- 如果它有多个条目。
- 如果下一步没有被阻塞。
- 通过遵循路径来实际执行移动。
- 从路径中删除第一个条目,这样我们就可以继续遵循它。
- 等待一个回合,路径可能会畅通
- 如果下一步没有被阻塞。
- 放弃并设置无路径。
- 如果它有多个条目。
- 如果路径不存在:
- 选择一个随机位置。
- 如果随机位置是可步行的,则路径到该位置。
- 如果路径成功,则将其存储为 AI 的
path。 - 否则,离开时没有路径 - 知道我们将在下一回合回来尝试另一个路径。
如果您现在 cargo run(并在 spawns.json 中将某些 AI 类型设置为 random_waypoint),您将看到村民现在的行为就像他们有计划一样 - 他们沿着路径移动。由于 A-Star 尊重我们的移动成本,他们甚至会自动优先选择路径和道路!现在看起来更真实了。
追逐目标
我们的另一个既定目标是,一旦 AI 开始追逐目标,它就不应该仅仅因为失去了视线而放弃。另一方面,它也不应该对地图有全知的视图,并完美地跟踪其目标!它也需要不是默认操作 - 但如果它是一个选项,则应在默认操作之前发生。
我们可以通过创建一个新组件来实现这一点(在 components.rs 中,记住在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, ConvertSaveload, Clone)] pub struct Chasing { pub target : Entity } }
不幸的是,我们正在存储一个 Entity - 因此我们需要一些额外的样板代码来使序列化系统满意:
rust
现在我们可以修改我们的 visible_ai_system.rs 文件,以便在它想要追逐目标时添加 Chasing 组件。有很多小的更改,所以我包含了整个文件:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Faction, Position, Map, raws::Reaction, Viewshed, WantsToFlee, WantsToApproach, Chasing}; pub struct VisibleAI {} impl<'a> System<'a> for VisibleAI { #[allow(clippy::type_complexity)] type SystemData = ( ReadStorage<'a, MyTurn>, ReadStorage<'a, Faction>, ReadStorage<'a, Position>, ReadExpect<'a, Map>, WriteStorage<'a, WantsToApproach>, WriteStorage<'a, WantsToFlee>, Entities<'a>, ReadExpect<'a, Entity>, ReadStorage<'a, Viewshed>, WriteStorage<'a, Chasing> ); fn run(&mut self, data : Self::SystemData) { let (turns, factions, positions, map, mut want_approach, mut want_flee, entities, player, viewsheds, mut chasing) = data; for (entity, _turn, my_faction, pos, viewshed) in (&entities, &turns, &factions, &positions, &viewsheds).join() { if entity != *player { let my_idx = map.xy_idx(pos.x, pos.y); let mut reactions : Vec<(usize, Reaction, Entity)> = Vec::new(); let mut flee : Vec<usize> = Vec::new(); for visible_tile in viewshed.visible_tiles.iter() { let idx = map.xy_idx(visible_tile.x, visible_tile.y); if my_idx != idx { evaluate(idx, &map, &factions, &my_faction.name, &mut reactions); } } let mut done = false; for reaction in reactions.iter() { match reaction.1 { Reaction::Attack => { want_approach.insert(entity, WantsToApproach{ idx: reaction.0 as i32 }).expect("无法插入"); chasing.insert(entity, Chasing{ target: reaction.2}).expect("无法插入"); done = true; } Reaction::Flee => { flee.push(reaction.0); } _ => {} } } if !done && !flee.is_empty() { want_flee.insert(entity, WantsToFlee{ indices : flee }).expect("无法插入"); } } } } } fn evaluate(idx : usize, map : &Map, factions : &ReadStorage<Faction>, my_faction : &str, reactions : &mut Vec<(usize, Reaction, Entity)>) { for other_entity in map.tile_content[idx].iter() { if let Some(faction) = factions.get(*other_entity) { reactions.push(( idx, crate::raws::faction_reaction(my_faction, &faction.name, &crate::raws::RAWS.lock().unwrap()), *other_entity )); } } } }
这是一个好的开始:当追击 NPC 时,我们将自动开始追逐它们。现在,让我们创建一个新系统来处理追逐;创建 ai/chase_ai_system.rs(并在 ai/mod.rs 中 mod,pub use):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Chasing, Position, Map, Viewshed, EntityMoved}; use std::collections::HashMap; pub struct ChaseAI {} impl<'a> System<'a> for ChaseAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, WriteStorage<'a, Chasing>, WriteStorage<'a, Position>, WriteExpect<'a, Map>, WriteStorage<'a, Viewshed>, WriteStorage<'a, EntityMoved>, Entities<'a> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, mut chasing, mut positions, mut map, mut viewsheds, mut entity_moved, entities) = data; let mut targets : HashMap<Entity, (i32, i32)> = HashMap::new(); let mut end_chase : Vec<Entity> = Vec::new(); for (entity, _turn, chasing) in (&entities, &turns, &chasing).join() { let target_pos = positions.get(chasing.target); if let Some(target_pos) = target_pos { targets.insert(entity, (target_pos.x, target_pos.y)); } else { end_chase.push(entity); } } for done in end_chase.iter() { chasing.remove(*done); } end_chase.clear(); let mut turn_done : Vec<Entity> = Vec::new(); for (entity, mut pos, _chase, mut viewshed, _myturn) in (&entities, &mut positions, &chasing, &mut viewsheds, &turns).join() { turn_done.push(entity); let target_pos = targets[&entity]; let path = rltk::a_star_search( map.xy_idx(pos.x, pos.y) as i32, map.xy_idx(target_pos.0, target_pos.1) as i32, &mut *map ); if path.success && path.steps.len()>1 && path.steps.len()<15 { let mut idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = false; pos.x = path.steps[1] as i32 % map.width; pos.y = path.steps[1] as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("无法插入标记"); idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = true; viewshed.dirty = true; turn_done.push(entity); } else { end_chase.push(entity); } } for done in end_chase.iter() { chasing.remove(*done); } for done in turn_done.iter() { turns.remove(*done); } } } }
这个系统最终比我希望的要复杂,因为借用检查器真的不希望我两次访问 Position 存储。所以我们最终得到了以下结果:
- 我们迭代所有具有
Chasing组件以及回合的实体。我们查看他们的目标是否有效,如果有效 - 我们将其存储在一个临时的 HashMap 中。这避免了需要两次查看Position内部。如果它无效,我们移除该组件。 - 我们迭代每个仍在追逐的人,并路径到他们的目标。如果路径成功,则遵循该路径。如果路径不成功,我们移除追逐组件。
- 我们从
MyTurn列表中删除每个执行回合的人。
将其添加到默认移动系统之前的 run_systems 中:
#![allow(unused)] fn main() { let mut approach = ai::ApproachAI{}; approach.run_now(&self.ecs); }
删除每个 AI 标签
我们不再使用 Bystander、Monster、Carnivore、Herbivore 和 Vendor 标签!打开 components.rs 并删除它们。您还需要删除它们在 main.rs 和 saveload_system.rs 中的注册。一旦它们消失,您仍然会在 player.rs 中看到错误;为什么?我们过去使用这些标签来确定我们是否应该攻击或与 NPC 交换位置。我们可以很容易地替换 try_move_player 中失败的代码。首先,从您的 using 语句中删除对这些组件的引用。然后替换这两行:
#![allow(unused)] fn main() { let bystanders = ecs.read_storage::<Bystander>(); let vendors = ecs.read_storage::<Vendor>(); }
替换为:
#![allow(unused)] fn main() { let factions = ecs.read_storage::<Faction>(); }
然后我们将标签检查替换为:
#![allow(unused)] fn main() { for potential_target in map.tile_content[destination_idx].iter() { let mut hostile = true; if combat_stats.get(*potential_target).is_some() { if let Some(faction) = factions.get(*potential_target) { let reaction = crate::raws::faction_reaction( &faction.name, "Player", &crate::raws::RAWS.lock().unwrap() ); if reaction != Reaction::Attack { hostile = false; } } } if !hostile { // 请注意,我们想要移动旁观者 }
请注意,我们正在使用我们之前制作的阵营系统!player.rs 中还有一个修复 - 决定我们是否可以因为附近的怪物而治疗。这基本上是相同的更改 - 我们检查实体是否是敌对的,如果是,则禁止治疗(因为您感到紧张/不安!):
#![allow(unused)] fn main() { fn skip_turn(ecs: &mut World) -> RunState { let player_entity = ecs.fetch::<Entity>(); let viewshed_components = ecs.read_storage::<Viewshed>(); let factions = ecs.read_storage::<Faction>(); let worldmap_resource = ecs.fetch::<Map>(); let mut can_heal = true; let viewshed = viewshed_components.get(*player_entity).unwrap(); for tile in viewshed.visible_tiles.iter() { let idx = worldmap_resource.xy_idx(tile.x, tile.y); for entity_id in worldmap_resource.tile_content[idx].iter() { let faction = factions.get(*entity_id); match faction { None => {} Some(faction) => { let reaction = crate::raws::faction_reaction( &faction.name, "Player", &crate::raws::RAWS.lock().unwrap() ); if reaction == Reaction::Attack { can_heal = false; } } } } } ... }
距离剔除 AI
我们目前在远离玩家的事件上花费了大量 CPU 周期。性能仍然可以,但这在两个方面都不是最优的:
- 如果阵营在我们远离时正在战斗,我们可能只是四处奔波寻找死人。最好是到达时发现正在发生的事情,而不是仅仅发现余波。
- 我们不想浪费我们宝贵的 CPU 周期!
让我们打开 initiative_system.rs 并对其进行修改以检查与玩家的距离,如果他们距离很远,则不进行回合:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{Initiative, Position, MyTurn, Attributes, RunState}; pub struct InitiativeSystem {} impl<'a> System<'a> for InitiativeSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, Initiative>, ReadStorage<'a, Position>, WriteStorage<'a, MyTurn>, Entities<'a>, WriteExpect<'a, rltk::RandomNumberGenerator>, ReadStorage<'a, Attributes>, WriteExpect<'a, RunState>, ReadExpect<'a, Entity>, ReadExpect<'a, rltk::Point>); fn run(&mut self, data : Self::SystemData) { let (mut initiatives, positions, mut turns, entities, mut rng, attributes, mut runstate, player, player_pos) = data; if *runstate != RunState::Ticking { return; } // 清除我们错误留下的任何剩余 MyTurn turns.clear(); // 掷先攻权 for (entity, initiative, pos) in (&entities, &mut initiatives, &positions).join() { initiative.current -= 1; if initiative.current < 1 { let mut myturn = true; // 重新掷骰 initiative.current = 6 + rng.roll_dice(1, 6); // 给予敏捷奖励 if let Some(attr) = attributes.get(entity) { initiative.current -= attr.quickness.bonus; } // TODO: 稍后将在此处添加更多先攻权授予的增益/惩罚 // 如果是玩家,我们希望进入 AwaitingInput 状态 if entity == *player { *runstate = RunState::AwaitingInput; } else { let distance = rltk::DistanceAlg::Pythagoras.distance2d(*player_pos, rltk::Point::new(pos.x, pos.y)); if distance > 20.0 { myturn = false; } } // 轮到我了! if myturn { turns.insert(entity, MyTurn{}).expect("无法插入回合"); } } } } } }
修复性能
您可能已经注意到,在本章的学习过程中,性能有所下降。我们添加了很多功能,因此系统似乎是罪魁祸首 - 但事实并非如此!我们的系统实际上以非常好的速度运行(每个系统做一件事的一个优势:您的 CPU 缓存非常高兴!)。如果您想证明这一点,请执行调试构建,启动分析器(我在 Windows 上使用 Very Sleepy)并将其附加到游戏中!
罪魁祸首实际上是先攻权。不再是每个实体都在同一刻移动,因此通过主循环花费更多周期才能到达玩家的回合。这是一个小减速,但很明显。幸运的是,您可以通过快速更改 main.rs 中的主循环来修复它:
#![allow(unused)] fn main() { RunState::Ticking => { while newrunstate == RunState::Ticking { self.run_systems(); self.ecs.maintain(); match *self.ecs.fetch::<RunState>() { RunState::AwaitingInput => newrunstate = RunState::AwaitingInput, RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 }, _ => newrunstate = RunState::Ticking } } } }
这将运行所有先攻权周期,直到轮到玩家。它将游戏恢复到全速运行。
总结
这是一个漫长的章节,对此我深感抱歉 - 但这是一个非常富有成效的章节!AI 不再只是站在那里或完全随机地漫游,而是分层运作 - 首先决定相邻的目标,然后是可见的目标,然后再是默认操作。它甚至可以追捕您。这在很大程度上使 AI 感觉更智能。
如果您现在 cargo run,您可以享受一个更加丰富的世界!
...
本章的源代码可以在这里找到
在您的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
空间地图
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
您可能已经注意到本章的文件名是 "57A"。在第 57 章的 AI 更改之后,空间索引系统出现了一些问题。与其在一个已经过长的章节中加入一个本身就很不错的主题,我决定最好插入一个章节。在本章中,我们将修改 map_indexing_system 和相关数据。我们有几个目标:
- 实体存储的位置和 "blocked" 系统应该易于在回合中更新。
- 我们希望消除实体共享空间的情况。
- 我们希望修复在实体被击杀后无法进入瓦片的问题。
- 我们希望保持良好的性能。
这是一个相当高的标准!
构建空间索引 API
与其分散地图的 tile_content、blocked 列表、定期更新的系统以及对这些数据结构的调用,不如将其移动到一个统一的 API 之后,这样会 干净 得多。然后我们可以访问 API,功能更改会自动随着改进而被引入。这样,我们只需要记住调用 API - 而不是记住它是如何工作的。
我们将从创建一个模块开始。创建一个 src\spatial 目录,并在其中放入一个空的 mod.rs 文件。然后我们将 "桩出" 我们的空间后端,添加一些内容:
#![allow(unused)] fn main() { use std::sync::Mutex; use specs::prelude::*; struct SpatialMap { blocked : Vec<bool>, tile_content : Vec<Vec<Entity>> } impl SpatialMap { fn new() -> Self { Self { blocked: Vec::new(), tile_content: Vec::new() } } } lazy_static! { static ref SPATIAL_MAP : Mutex<SpatialMap> = Mutex::new(SpatialMap::new()); } }
SpatialMap 结构体包含我们存储在 Map 中的空间信息。它刻意地不是 public 的:我们希望停止直接共享数据,而是使用 API。然后我们创建一个 lazy_static:一个受互斥锁保护的全局变量,并使用它来存储空间信息。以这种方式存储它允许我们访问它,而不会给 Specs 的资源系统带来负担 - 并且更容易从系统内部和外部提供访问。由于我们正在使用互斥锁保护空间地图,我们还可以从线程安全中受益;这会将资源从 Specs 的线程计划中移除。这使得程序作为一个整体更容易使用线程调度器。
地图 API 替换
当地图更改时,我们需要一种方法来调整空间地图的大小。在 spatial/mod.rs 中:
#![allow(unused)] fn main() { pub fn set_size(map_tile_count: usize) { let mut lock = SPATIAL_MAP.lock().unwrap(); lock.blocked = vec![false; map_tile_count]; lock.tile_content = vec![Vec::new(); map_tile_count]; } }
这有点低效,因为它会重新分配 - 但我们不经常这样做,所以应该没问题。我们还需要一种清除空间内容的方法:
#![allow(unused)] fn main() { pub fn clear() { let mut lock = SPATIAL_MAP.lock().unwrap(); lock.blocked.clear(); for content in lock.tile_content.iter_mut() { content.clear(); } } }
我们需要一个类似于地图当前 populate_blocked 的函数(它构建一个 被地形 阻挡的瓦片列表):
#![allow(unused)] fn main() { pub fn populate_blocked_from_map(map: &Map) { let mut lock = SPATIAL_MAP.lock().unwrap(); for (i,tile) in map.tiles.iter().enumerate() { lock.blocked[i] = !tile_walkable(*tile); } } }
更新地图
更新处理空间映射的两个地图函数以使用新的 API。在 map/mod.rs 中:
#![allow(unused)] fn main() { pub fn populate_blocked(&mut self) { crate::spatial::populate_blocked_from_map(self); } pub fn clear_content_index(&mut self) { crate::spatial::clear(); } }
填充空间索引
我们已经有了 map_indexing_system.rs,它处理空间地图的初始(每帧,所以它不会太不同步)填充。由于我们正在更改存储数据的方式,我们也需要更改系统。索引系统对地图的空间数据执行两个功能:它将瓦片设置为 blocked,并添加索引实体。我们已经创建了它需要的 clear 和 populate_blocked_from_map 函数。将 MapIndexingSystem 的 run 函数的主体替换为:
#![allow(unused)] fn main() { use super::{Map, Position, BlocksTile, spatial}; ... fn run(&mut self, data : Self::SystemData) { let (mut map, position, blockers, entities) = data; spatial::clear(); spatial::populate_blocked_from_map(&*map); for (entity, position) in (&entities, &position).join() { let idx = map.xy_idx(position.x, position.y); // 如果它们阻挡,更新阻挡列表 let _p : Option<&BlocksTile> = blockers.get(entity); if let Some(_p) = _p { spatial::set_blocked(idx); } // 将实体推送到适当的索引槽。它是一个 Copy // 类型,所以我们不需要克隆它(我们想要避免将其移出 ECS!) spatial::index_entity(entity, idx); } } }
在 spatial/mod.rs 中,添加 index_entity 函数:
#![allow(unused)] fn main() { pub fn index_entity(entity: Entity, idx: usize) { let mut lock = SPATIAL_MAP.lock().unwrap(); lock.tile_content[idx].push(entity); } }
地图的构造函数还需要告诉空间系统调整自身大小。将以下内容添加到构造函数:
#![allow(unused)] fn main() { pub fn new<S : ToString>(new_depth : i32, width: i32, height: i32, name: S) -> Map { let map_tile_count = (width*height) as usize; crate::spatial::set_size(map_tile_count); ... }
从地图中移除旧的空间数据
是时候破坏一些东西了!这将导致整个源代码库出现问题。从地图中移除 blocked 和 tile_content。新的 Map 定义如下:
#![allow(unused)] fn main() { #[derive(Default, Serialize, Deserialize, Clone)] pub struct Map { pub tiles : Vec<TileType>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool>, pub depth : i32, pub bloodstains : HashSet<usize>, pub view_blocked : HashSet<usize>, pub name : String, pub outdoors : bool, pub light : Vec<rltk::RGB>, } }
您还需要从构造函数中删除这些条目:
#![allow(unused)] fn main() { pub fn new<S : ToString>(new_depth : i32, width: i32, height: i32, name: S) -> Map { let map_tile_count = (width*height) as usize; crate::spatial::set_size(map_tile_count); Map{ tiles : vec![TileType::Wall; map_tile_count], width, height, revealed_tiles : vec![false; map_tile_count], visible_tiles : vec![false; map_tile_count], depth: new_depth, bloodstains: HashSet::new(), view_blocked : HashSet::new(), name : name.to_string(), outdoors : true, light: vec![rltk::RGB::from_f32(0.0, 0.0, 0.0); map_tile_count] } } }
Map 中的 is_exit_valid 函数会崩溃,因为它访问了 blocked。在 spatial/mod.rs 中,我们将创建一个新函数来提供此功能:
#![allow(unused)] fn main() { pub fn is_blocked(idx: usize) -> bool { SPATIAL_MAP.lock().unwrap().blocked[idx] } }
这允许我们修复地图的 is_exit_valid 函数:
#![allow(unused)] fn main() { fn is_exit_valid(&self, x:i32, y:i32) -> bool { if x < 1 || x > self.width-1 || y < 1 || y > self.height-1 { return false; } let idx = self.xy_idx(x, y); !crate::spatial::is_blocked(idx) } }
修复 map/dungeon.rs
map/dungeon.rs 中的 get_map 函数创建了一个新的(未使用的)tile_content 条目。我们不再需要它了,所以我们将删除它。新函数是:
#![allow(unused)] fn main() { pub fn get_map(&self, depth : i32) -> Option<Map> { if self.maps.contains_key(&depth) { let mut result = self.maps[&depth].clone(); Some(result) } else { None } } }
修复 AI
查看 AI 函数,我们经常直接查询 tile_content。由于我们现在正在尝试使用 API,所以我们不能这样做!最常见的用例是迭代表示瓦片的向量。我们希望避免返回锁,然后确保它被释放所导致的混乱 - 这从 API 中泄漏了太多实现细节。相反,我们将提供一种使用闭包迭代瓦片内容的方法。将以下内容添加到 spatial/mod.rs:
#![allow(unused)] fn main() { pub fn for_each_tile_content<F>(idx: usize, f: F) where F : Fn(Entity) { let lock = SPATIAL_MAP.lock().unwrap(); for entity in lock.tile_content[idx].iter() { f(*entity); } } }
f 变量是一个泛型参数,使用 where 来指定它必须是一个可变函数,它接受一个 Entity 作为参数。这为我们提供了类似于迭代器上的 for_each 的接口:您可以在瓦片中的每个实体上运行一个函数,依靠闭包捕获来让您在调用它时处理本地状态。
打开 src/ai/adjacent_ai_system.rs。 evaluate 函数因我们的更改而损坏。使用新的 API,修复它非常简单:
#![allow(unused)] fn main() { fn evaluate(idx : usize, map : &Map, factions : &ReadStorage<Faction>, my_faction : &str, reactions : &mut Vec<(Entity, Reaction)>) { crate::spatial::for_each_tile_content(idx, |other_entity| { if let Some(faction) = factions.get(other_entity) { reactions.push(( other_entity, crate::raws::faction_reaction(my_faction, &faction.name, &crate::raws::RAWS.lock().unwrap()) )); } }); } }
我喜欢这个 API - 它与旧的设置非常相似,但包装得很干净!
Approach API:一些糟糕的代码!
如果您想知道为什么我定义了 API,然后又更改了它:这是为了让您了解香肠是如何制作的。像这样的 API 构建始终是一个迭代过程,看到事物如何演变是件好事。
查看 src/ai/approach_ai_system.rs。代码非常糟糕:我们在实体移动时手动更改 blocked。更糟糕的是,我们可能没有做对!它只是取消设置 blocked;如果由于某种原因瓦片仍然被阻挡,结果将是不正确的。这行不通;我们需要一种 干净 的方法来移动实体,并保留 blocked 状态。
每次移动事物时都为所有内容添加 BlocksTile 检查将会很慢,并且会用更多的引用来污染我们已经很大的 Specs 查找。相反,我们将更改我们存储实体的方式。我们还将更改我们存储 blocked 的方式。在 spatial/mod.rs 中:
#![allow(unused)] fn main() { struct SpatialMap { blocked : Vec<(bool, bool)>, tile_content : Vec<Vec<(Entity, bool)>> } }
blocked 向量现在包含两个 bool 的元组。第一个是 "地图是否阻挡它?",第二个是 "它是否被实体阻挡?"。这要求我们更改一些其他函数。我们还将 删除 set_blocked 函数,并使其从 populate_blocked_from_map 和 index_entity 函数中自动执行。自动是好的:减少了搬起石头砸自己脚的机会!
#![allow(unused)] fn main() { pub fn set_size(map_tile_count: usize) { let mut lock = SPATIAL_MAP.lock().unwrap(); lock.blocked = vec![(false, false); map_tile_count]; lock.tile_content = vec![Vec::new(); map_tile_count]; } pub fn clear() { let mut lock = SPATIAL_MAP.lock().unwrap(); lock.blocked.iter_mut().for_each(|b| { b.0 = false; b.1 = false; }); for content in lock.tile_content.iter_mut() { content.clear(); } } pub fn populate_blocked_from_map(map: &Map) { let mut lock = SPATIAL_MAP.lock().unwrap(); for (i,tile) in map.tiles.iter().enumerate() { lock.blocked[i].0 = !tile_walkable(*tile); } } pub fn index_entity(entity: Entity, idx: usize, blocks_tile: bool) { let mut lock = SPATIAL_MAP.lock().unwrap(); lock.tile_content[idx].push((entity, blocks_tile)); if blocks_tile { lock.blocked[idx].1 = true; } } pub fn is_blocked(idx: usize) -> bool { let lock = SPATIAL_MAP.lock().unwrap(); lock.blocked[idx].0 || lock.blocked[idx].1 } pub fn for_each_tile_content<F>(idx: usize, mut f: F) where F : FnMut(Entity) { let lock = SPATIAL_MAP.lock().unwrap(); for entity in lock.tile_content[idx].iter() { f(entity.0); } } }
这要求我们再次调整 map_indexing_system。好消息是它变得越来越短:
#![allow(unused)] fn main() { fn run(&mut self, data : Self::SystemData) { let (mut map, position, blockers, entities) = data; spatial::clear(); spatial::populate_blocked_from_map(&*map); for (entity, position) in (&entities, &position).join() { let idx = map.xy_idx(position.x, position.y); spatial::index_entity(entity, idx, blockers.get(entity).is_some()); } } }
完成这些之后,让我们回到 approach_ai_system。查看代码,我们怀着最好的意图 试图 根据实体的移动来更新 blocked。我们天真地从源瓦片中清除了 blocked,并在目标瓦片中设置了它。我们多次使用这种模式,所以让我们创建一个 API 函数(在 spatial/mod.rs 中),它可以真正一致地工作:
#![allow(unused)] fn main() { pub fn move_entity(entity: Entity, moving_from: usize, moving_to: usize) { let mut lock = SPATIAL_MAP.lock().unwrap(); let mut entity_blocks = false; lock.tile_content[moving_from].retain(|(e, blocks) | { if *e == entity { entity_blocks = *blocks; false } else { true } }); lock.tile_content[moving_to].push((entity, entity_blocks)); // 重新计算两个瓦片的 blocks let mut from_blocked = false; let mut to_blocked = false; lock.tile_content[moving_from].iter().for_each(|(_,blocks)| if *blocks { from_blocked = true; } ); lock.tile_content[moving_to].iter().for_each(|(_,blocks)| if *blocks { to_blocked = true; } ); lock.blocked[moving_from].1 = from_blocked; lock.blocked[moving_to].1 = to_blocked; } }
这允许我们用更简洁的代码来修复 ai/approach_ai_system.rs:
#![allow(unused)] fn main() { if path.success && path.steps.len()>1 { let idx = map.xy_idx(pos.x, pos.y); pos.x = path.steps[1] as i32 % map.width; pos.y = path.steps[1] as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); let new_idx = map.xy_idx(pos.x, pos.y); crate::spatial::move_entity(entity, idx, new_idx); viewshed.dirty = true; } }
文件 ai/chase_ai_system.rs 存在相同的问题。修复方法几乎相同:
#![allow(unused)] fn main() { if path.success && path.steps.len()>1 && path.steps.len()<15 { let idx = map.xy_idx(pos.x, pos.y); pos.x = path.steps[1] as i32 % map.width; pos.y = path.steps[1] as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); let new_idx = map.xy_idx(pos.x, pos.y); viewshed.dirty = true; crate::spatial::move_entity(entity, idx, new_idx); turn_done.push(entity); } else { end_chase.push(entity); } }
修复 ai/default_move_system.rs
这个文件有点复杂。第一个损坏的部分既查询又更新了 blocked 索引。将其更改为:
#![allow(unused)] fn main() { if x > 0 && x < map.width-1 && y > 0 && y < map.height-1 { let dest_idx = map.xy_idx(x, y); if !crate::spatial::is_blocked(dest_idx) { let idx = map.xy_idx(pos.x, pos.y); pos.x = x; pos.y = y; entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); crate::spatial::move_entity(entity, idx, dest_idx); viewshed.dirty = true; } } }
RandomWaypoint 选项的更改非常相似:
#![allow(unused)] fn main() { if path.len()>1 { if !crate::spatial::is_blocked(path[1] as usize) { pos.x = path[1] as i32 % map.width; pos.y = path[1] as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); let new_idx = map.xy_idx(pos.x, pos.y); crate::spatial::move_entity(entity, idx, new_idx); viewshed.dirty = true; path.remove(0); // 移除路径中的第一步 } // 否则我们等待一个回合,看看路径是否畅通 } else { mode.mode = Movement::RandomWaypoint{ path : None }; } }
修复 ai/flee_ai_system.rs
这与默认移动更改非常相似:
#![allow(unused)] fn main() { if let Some(flee_target) = flee_target { if !crate::spatial::is_blocked(flee_target as usize) { crate::spatial::move_entity(entity, my_idx, flee_target); viewshed.dirty = true; pos.x = flee_target as i32 % map.width; pos.y = flee_target as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); } } }
修复 ai/visible_ai_system.rs
AI 的可见性系统使用了一个 evaluate 函数,就像相邻 AI 设置中的那个函数一样。它可以更改为使用闭包:
#![allow(unused)] fn main() { fn evaluate(idx : usize, map : &Map, factions : &ReadStorage<Faction>, my_faction : &str, reactions : &mut Vec<(usize, Reaction, Entity)>) { crate::spatial::for_each_tile_content(idx, |other_entity| { if let Some(faction) = factions.get(other_entity) { reactions.push(( idx, crate::raws::faction_reaction(my_faction, &faction.name, &crate::raws::RAWS.lock().unwrap()), other_entity )); } }); } }
各种 Inventory 系统
在 inventory_system.rs 中,ItemUseSystem 执行空间查找。这是另一个可以用闭包系统替换的:
更改:
#![allow(unused)] fn main() { for mob in map.tile_content[idx].iter() { targets.push(*mob); } }
为:
#![allow(unused)] fn main() { crate::spatial::for_each_tile_content(idx, |mob| targets.push(mob) ); }
再往下,还有另一个。
#![allow(unused)] fn main() { for mob in map.tile_content[idx].iter() { targets.push(*mob); } }
变为:
#![allow(unused)] fn main() { crate::spatial::for_each_tile_content(idx, |mob| targets.push(mob)); }
修复 player.rs
函数 try_move_player 对空间索引系统进行了非常大的查询。它有时也会在计算过程中返回,而我们的 API 目前不支持这一点。我们将在 spatial/mod.rs 文件中添加一个新函数来启用此功能:
#![allow(unused)] fn main() { pub fn for_each_tile_content_with_gamemode<F>(idx: usize, mut f: F) -> RunState where F : FnMut(Entity)->Option<RunState> { let lock = SPATIAL_MAP.lock().unwrap(); for entity in lock.tile_content[idx].iter() { if let Some(rs) = f(entity.0) { return rs; } } RunState::AwaitingInput } }
此函数像另一个函数一样运行,但接受来自闭包的可选游戏模式。如果游戏模式是 Some(x),则它返回 x。如果它在最后没有收到任何模式,则返回 AwaitingInput。
用新的 API 替换它主要是在于使用新函数,并在闭包内执行索引检查。这是新函数:
#![allow(unused)] fn main() { pub fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) -> RunState { let mut positions = ecs.write_storage::<Position>(); let players = ecs.read_storage::<Player>(); let mut viewsheds = ecs.write_storage::<Viewshed>(); let entities = ecs.entities(); let combat_stats = ecs.read_storage::<Attributes>(); let map = ecs.fetch::<Map>(); let mut wants_to_melee = ecs.write_storage::<WantsToMelee>(); let mut entity_moved = ecs.write_storage::<EntityMoved>(); let mut doors = ecs.write_storage::<Door>(); let mut blocks_visibility = ecs.write_storage::<BlocksVisibility>(); let mut blocks_movement = ecs.write_storage::<BlocksTile>(); let mut renderables = ecs.write_storage::<Renderable>(); let factions = ecs.read_storage::<Faction>(); let mut result = RunState::AwaitingInput; let mut swap_entities : Vec<(Entity, i32, i32)> = Vec::new(); for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() { if pos.x + delta_x < 1 || pos.x + delta_x > map.width-1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height-1 { return RunState::AwaitingInput; } let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y); result = crate::spatial::for_each_tile_content_with_gamemode(destination_idx, |potential_target| { let mut hostile = true; if combat_stats.get(potential_target).is_some() { if let Some(faction) = factions.get(potential_target) { let reaction = crate::raws::faction_reaction( &faction.name, "Player", &crate::raws::RAWS.lock().unwrap() ); if reaction != Reaction::Attack { hostile = false; } } } if !hostile { // 注意,我们想要移动旁观者 swap_entities.push((potential_target, pos.x, pos.y)); // 移动玩家 pos.x = min(map.width-1 , max(0, pos.x + delta_x)); pos.y = min(map.height-1, max(0, pos.y + delta_y)); entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); viewshed.dirty = true; let mut ppos = ecs.write_resource::<Point>(); ppos.x = pos.x; ppos.y = pos.y; return Some(RunState::Ticking); } else { let target = combat_stats.get(potential_target); if let Some(_target) = target { wants_to_melee.insert(entity, WantsToMelee{ target: potential_target }).expect("Add target failed"); return Some(RunState::Ticking); } } let door = doors.get_mut(potential_target); if let Some(door) = door { door.open = true; blocks_visibility.remove(potential_target); blocks_movement.remove(potential_target); let glyph = renderables.get_mut(potential_target).unwrap(); glyph.glyph = rltk::to_cp437('/'); viewshed.dirty = true; return Some(RunState::Ticking); } None }); if !crate::spatial::is_blocked(destination_idx) { let old_idx = map.xy_idx(pos.x, pos.y); pos.x = min(map.width-1 , max(0, pos.x + delta_x)); pos.y = min(map.height-1, max(0, pos.y + delta_y)); let new_idx = map.xy_idx(pos.x, pos.y); entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker"); crate::spatial::move_entity(entity, old_idx, new_idx); viewshed.dirty = true; let mut ppos = ecs.write_resource::<Point>(); ppos.x = pos.x; ppos.y = pos.y; result = RunState::Ticking; match map.tiles[destination_idx] { TileType::DownStairs => result = RunState::NextLevel, TileType::UpStairs => result = RunState::PreviousLevel, _ => {} } } } for m in swap_entities.iter() { let their_pos = positions.get_mut(m.0); if let Some(their_pos) = their_pos { let old_idx = map.xy_idx(their_pos.x, their_pos.y); their_pos.x = m.1; their_pos.y = m.2; let new_idx = map.xy_idx(their_pos.x, their_pos.y); crate::spatial::move_entity(m.0, old_idx, new_idx); result = RunState::Ticking; } } result } }
注意 TODO:在我们完成之前,我们将需要查看它。我们正在移动实体 - 而不是更新空间地图。
skip_turn 也需要用新的基于闭包的设置替换 tile_content 的直接迭代:
#![allow(unused)] fn main() { crate::spatial::for_each_tile_content(idx, |entity_id| { let faction = factions.get(entity_id); match faction { None => {} Some(faction) => { let reaction = crate::raws::faction_reaction( &faction.name, "Player", &crate::raws::RAWS.lock().unwrap() ); if reaction == Reaction::Attack { can_heal = false; } } } }); }
修复 Trigger 系统
trigger_system.rs 也需要一些改进。这只是另一个直接的 for 循环替换为新的闭包:
#![allow(unused)] fn main() { crate::spatial::for_each_tile_content(idx, |entity_id| { if entity != entity_id { // 不要费心检查自己是否是陷阱! let maybe_trigger = entry_trigger.get(entity_id); match maybe_trigger { None => {}, Some(_trigger) => { // 我们触发了它 let name = names.get(entity_id); if let Some(name) = name { log.entries.push(format!("{} 触发了!", &name.name)); } hidden.remove(entity_id); // 陷阱不再隐藏 // 如果陷阱是造成伤害的,那就造成伤害 let damage = inflicts_damage.get(entity_id); if let Some(damage) = damage { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0); SufferDamage::new_damage(&mut inflict_damage, entity, damage.damage, false); } // 如果它是单次激活,则需要移除它 let sa = single_activation.get(entity_id); if let Some(_sa) = sa { remove_entities.push(entity_id); } } } } }); }
Visibility 系统中更多相同的内容
visibility_system.rs 需要非常相似的修复。 for e in map.tile_content[idx].iter() { 和相关的 body 变为:
#![allow(unused)] fn main() { crate::spatial::for_each_tile_content(idx, |e| { let maybe_hidden = hidden.get(e); if let Some(_maybe_hidden) = maybe_hidden { if rng.roll_dice(1,24)==1 { let name = names.get(e); if let Some(name) = name { log.entries.push(format!("你发现了一个 {}。", &name.name)); } hidden.remove(e); } } }); }
保存和加载
saveload_system.rs 文件也需要一些调整。替换:
#![allow(unused)] fn main() { worldmap.tile_content = vec![Vec::new(); (worldmap.height * worldmap.width) as usize]; }
为:
#![allow(unused)] fn main() { crate::spatial::set_size((worldmap.height * worldmap.width) as usize); }
如果您 cargo build,它现在可以编译了!这是一个进步。现在 cargo run 运行项目,看看效果如何。游戏以不错的速度运行,并且可以玩。仍然有一些问题 - 我们将依次解决这些问题。
清理死者
我们将从 "死者仍然阻挡瓦片" 的问题开始。出现此问题的原因是实体在调用 delete_the_dead 之前不会消失,并且整个地图会重新索引。这可能不会及时发生,无法帮助移动到目标瓦片。在我们的空间 API 中添加一个新函数(在 spatial/mod.rs 中):
#![allow(unused)] fn main() { pub fn remove_entity(entity: Entity, idx: usize) { let mut lock = SPATIAL_MAP.lock().unwrap(); lock.tile_content[idx].retain(|(e, _)| *e != entity ); let mut from_blocked = false; lock.tile_content[idx].iter().for_each(|(_,blocks)| if *blocks { from_blocked = true; } ); lock.blocked[idx].1 = from_blocked; } }
然后修改 damage_system 以处理移除死亡时的实体:
#![allow(unused)] fn main() { if stats.hit_points.current < 1 && dmg.1 { xp_gain += stats.level * 100; if let Some(pos) = pos { let idx = map.xy_idx(pos.x, pos.y); crate::spatial::remove_entity(entity, idx); } } }
听起来不错 - 但运行它表明我们 仍然 存在问题。一些大量的调试表明,map_indexing_system 在事件之间运行,并恢复了不正确的数据。我们不希望死者出现在我们的索引地图上,所以我们编辑索引系统以进行检查。修复后的索引系统如下所示:我们添加了对死者的检查。
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Map, Position, BlocksTile, Pools, spatial}; pub struct MapIndexingSystem {} impl<'a> System<'a> for MapIndexingSystem { type SystemData = ( ReadExpect<'a, Map>, ReadStorage<'a, Position>, ReadStorage<'a, BlocksTile>, ReadStorage<'a, Pools>, Entities<'a>,); fn run(&mut self, data : Self::SystemData) { let (map, position, blockers, pools, entities) = data; spatial::clear(); spatial::populate_blocked_from_map(&*map); for (entity, position) in (&entities, &position).join() { let mut alive = true; if let Some(pools) = pools.get(entity) { if pools.hit_points.current < 1 { alive = false; } } if alive { let idx = map.xy_idx(position.x, position.y); spatial::index_entity(entity, idx, blockers.get(entity).is_some()); } } } } }
您现在可以移动到最近去世的人占据的空间。
处理实体交换
还记得我们在玩家处理程序中标记的 TODO 吗?当我们想要交换实体位置时。让我们弄清楚这一点。这是一个更新目的地的版本:
#![allow(unused)] fn main() { for m in swap_entities.iter() { let their_pos = positions.get_mut(m.0); if let Some(their_pos) = their_pos { let old_idx = map.xy_idx(their_pos.x, their_pos.y); their_pos.x = m.1; their_pos.y = m.2; let new_idx = map.xy_idx(their_pos.x, their_pos.y); crate::spatial::move_entity(m.0, old_idx, new_idx); result = RunState::Ticking; } } }
总结
它仍然不是绝对完美,但它 好 得多了。我玩了一段时间,在发布模式下它非常流畅。无法进入瓦片的问题已经消失,命中检测正在工作。同样重要的是,我们清理了一些 hacky 代码。
注意:本章处于 alpha 阶段。我仍在将这些修复应用于后续章节,并在完成后更新此章节。
...
本章的源代码可以在 这里 找到
在您的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
物品属性
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在上一章中,我们讨论了使用先攻值来使重型盔甲具有移动成本,并使某些武器比其他武器更快。 设计文档还提到了商人。 最后,哪个 RPG/roguelike 游戏在物品栏管理方面,没有烦人的“你超重了”消息(以及随之而来的速度惩罚)是不完整的呢? 这些功能都指向一个方向:额外的物品统计数据,并将它们整合到游戏系统中。
定义物品信息
我们已经有了一个名为 Item 的组件; 所有物品都已经拥有它,因此它似乎是添加此信息的理想场所! 打开 components.rs,我们将编辑 Item 结构以包含先攻惩罚、负重和商人所需的信息:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Item { pub initiative_penalty : f32, pub weight_lbs : f32, pub base_value : f32 } }
所以我们定义了一个 initiative_penalty(先攻惩罚) - 当装备(或在武器的情况下使用)时,它将被添加到你的先攻掷骰中以减慢你的速度; weight_lbs(重量-磅) - 定义物品的重量,以磅为单位; 以及 base_value(基础价值) - 物品以金币为单位的基础价格(十进制,因此我们也可以允许银币)。
我们需要一种输入此信息的方法,因此我们打开 raws/item_structs.rs 并编辑 Item 结构:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Item { pub name : String, pub renderable : Option<Renderable>, pub consumable : Option<Consumable>, pub weapon : Option<Weapon>, pub wearable : Option<Wearable>, pub initiative_penalty : Option<f32>, pub weight_lbs : Option<f32>, pub base_value : Option<f32> } }
请注意,我们将这些设为可选的 - 如果您没有在 spawns.json 文件中定义它们,它们将默认为零。 最后,我们需要修复 raws/rawmaster.rs 的 spawn_named_item 函数以加载这些值。 将添加 Item 的行替换为:
#![allow(unused)] fn main() { eb = eb.with(crate::components::Item{ initiative_penalty : item_template.initiative_penalty.unwrap_or(0.0), weight_lbs : item_template.weight_lbs.unwrap_or(0.0), base_value : item_template.base_value.unwrap_or(0.0) }); }
这里利用了 Option 的 unwrap_or 函数 - 它要么返回包装的值(如果存在),要么返回 0.0。 方便的功能,可以节省打字!
这些值在您进入 spawns.json 并开始添加它们之前不会存在。 我一直在从 roll20 compendium 中获取重量和价值的值,并凭空捏造先攻惩罚的数字。 我已将它们输入到 源代码 中,而不是在这里重复所有内容。 这是一个例子:
{
"name" : "Longsword",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAFF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "Might",
"base_damage" : "1d8",
"hit_bonus" : 0
},
"weight_lbs" : 3.0,
"base_value" : 15.0,
"initiative_penalty" : 2
},
计算负重和先攻惩罚
一个简单的方法是循环遍历每个实体,并在每回合中计算它们的总重量和先攻惩罚。 这样做的问题是,它可能相当慢; 很多 实体都有装备(他们中的大多数!),而我们实际上只需要在某些东西发生变化时重新计算它。 我们使用与可见性相同的方法,通过标记它为“dirty”(脏的/需要更新的)。 因此,让我们首先扩展 Pools 以包含两个字段来表示总数。 在 components.rs 中:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Pools { pub hit_points : Pool, pub mana : Pool, pub xp : i32, pub level : i32, pub total_weight : f32, pub total_initiative_penalty : f32 } }
您需要打开 spawner.rs 并将这些字段添加到 Player 的初始 Pools 设置中(我们将使用零并依靠计算它):
#![allow(unused)] fn main() { .with(Pools{ hit_points : Pool{ current: player_hp_at_level(11, 1), max: player_hp_at_level(11, 1) }, mana: Pool{ current: mana_at_level(11, 1), max: mana_at_level(11, 1) }, xp: 0, level: 1, total_weight : 0.0, total_initiative_penalty : 0.0 }) }
同样,在 rawmaster.rs 中,spawn_named_mob 需要在其 Pools 初始化中获得这些字段:
#![allow(unused)] fn main() { let pools = Pools{ level: mob_level, xp: 0, hit_points : Pool{ current: mob_hp, max: mob_hp }, mana: Pool{current: mob_mana, max: mob_mana}, total_weight : 0.0, total_initiative_penalty : 0.0 }; eb = eb.with(pools); }
现在,我们需要一种方法来向游戏指示装备已更改。 这可能由于各种原因而发生,因此我们希望尽可能通用! 打开 components.rs,并创建一个新的“tag”(标签)组件(然后在 main.rs 和 saveload_system.rs 中注册它):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct EquipmentChanged {} }
打开 spawner.rs,我们将开始玩家的生命时应用此标签:
#![allow(unused)] fn main() { .with(EquipmentChanged{}) }
同样,在 rawmaster.rs 的 spawn_named_mob 中,我们将做同样的事情:
#![allow(unused)] fn main() { eb = eb.with(EquipmentChanged{}); }
现在,我们将创建一个新的系统来计算这个。 创建一个新文件 ai/encumbrance_system.rs(并在 ai/mod.rs 中包含 mod 和 pub use 语句):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{EquipmentChanged, Item, InBackpack, Equipped, Pools, Attributes, gamelog::GameLog}; use std::collections::HashMap; pub struct EncumbranceSystem {} impl<'a> System<'a> for EncumbranceSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, EquipmentChanged>, Entities<'a>, ReadStorage<'a, Item>, ReadStorage<'a, InBackpack>, ReadStorage<'a, Equipped>, WriteStorage<'a, Pools>, ReadStorage<'a, Attributes>, ReadExpect<'a, Entity>, WriteExpect<'a, GameLog> ); fn run(&mut self, data : Self::SystemData) { let (mut equip_dirty, entities, items, backpacks, wielded, mut pools, attributes, player, mut gamelog) = data; if equip_dirty.is_empty() { return; } // 构建需要更新的地图 let mut to_update : HashMap<Entity, (f32, f32)> = HashMap::new(); // (重量, 先攻值) for (entity, _dirty) in (&entities, &equip_dirty).join() { to_update.insert(entity, (0.0, 0.0)); } // 移除所有 dirty 声明 equip_dirty.clear(); // 统计装备物品 for (item, equipped) in (&items, &wielded).join() { if to_update.contains_key(&equipped.owner) { let totals = to_update.get_mut(&equipped.owner).unwrap(); totals.0 += item.weight_lbs; totals.1 += item.initiative_penalty; } } // 统计携带物品 for (item, carried) in (&items, &backpacks).join() { if to_update.contains_key(&carried.owner) { let totals = to_update.get_mut(&carried.owner).unwrap(); totals.0 += item.weight_lbs; totals.1 += item.initiative_penalty; } } // 将数据应用到 Pools for (entity, (weight, initiative)) in to_update.iter() { if let Some(pool) = pools.get_mut(*entity) { pool.total_weight = *weight; pool.total_initiative_penalty = *initiative; if let Some(attr) = attributes.get(*entity) { let carry_capacity_lbs = (attr.might.base + attr.might.modifiers) * 15; if pool.total_weight as i32 > carry_capacity_lbs { // Overburdened (超重) pool.total_initiative_penalty += 4.0; if *entity == *player { gamelog.entries.push("You are overburdened, and suffering an initiative penalty.".to_string()); } } } } } } } }
让我们逐步了解它的作用:
- 如果我们不在
Ticking运行状态,我们将返回(在等待输入时无需保持循环!)。 - 如果没有任何
EquipmentChanged条目,我们将返回(如果没有什么可做的,则无需进行额外的工作)。 - 我们循环遍历所有带有
EquipmentChanged条目的实体,并将它们存储在to_updateHashMap 中,以及重量和先攻值的零值。 - 我们移除所有
EquipmentChanged标签。 - 我们循环遍历所有装备的物品。 如果它们的所有者在
to_update列表中,我们将每个物品的重量和惩罚添加到该实体在to_update映射中的总数。 - 我们循环遍历所有携带的物品并执行相同的操作。
- 我们迭代
to_update列表,使用解构使其易于使用友好的名称访问字段。- 对于每个更新的实体,我们尝试获取它们的
Pools组件(如果无法获取则跳过)。 - 我们将 pool 的
total_weight和total_initiative_penalty设置为我们构建的总数。 - 我们查看实体是否具有
Might属性; 如果他们有,我们将总携带能力计算为每点力量 15 磅(就像 D&D!)。 - 如果他们超过了他们的携带能力,我们会对他们处以额外的 4 点先攻惩罚(哎哟)。 如果是玩家,我们在日志文件中宣布他们的超重状态。
- 对于每个更新的实体,我们尝试获取它们的
我们还需要在 run_systems(在 main.rs 中)中调用系统。 将其放在调用先攻系统之前:
#![allow(unused)] fn main() { let mut encumbrance = ai::EncumbranceSystem{}; encumbrance.run_now(&self.ecs); }
如果您现在 cargo run,它将计算每个人的负重 - 一次,且仅一次! 我们在更改后没有添加 EquipmentChanged 标签。 我们需要更新 inventory_system.rs,以便拾取、掉落和使用物品(可能会销毁它们)触发更新。
拾取物品的系统是一个非常简单的更改:
#![allow(unused)] fn main() { impl<'a> System<'a> for ItemCollectionSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToPickupItem>, WriteStorage<'a, Position>, ReadStorage<'a, Name>, WriteStorage<'a, InBackpack>, WriteStorage<'a, EquipmentChanged> ); fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, mut wants_pickup, mut positions, names, mut backpack, mut dirty) = data; for pickup in wants_pickup.join() { positions.remove(pickup.item); backpack.insert(pickup.item, InBackpack{ owner: pickup.collected_by }).expect("Unable to insert backpack entry"); dirty.insert(pickup.collected_by, EquipmentChanged{}).expect("Unable to insert"); if pickup.collected_by == *player_entity { gamelog.entries.push(format!("You pick up the {}.", names.get(pickup.item).unwrap().name)); } } wants_pickup.clear(); } } }
对于使用物品,我们做了几乎相同的事情:
#![allow(unused)] fn main() { impl<'a> System<'a> for ItemUseSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, WriteExpect<'a, Map>, Entities<'a>, WriteStorage<'a, WantsToUseItem>, ReadStorage<'a, Name>, ReadStorage<'a, Consumable>, ReadStorage<'a, ProvidesHealing>, ReadStorage<'a, InflictsDamage>, WriteStorage<'a, Pools>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, AreaOfEffect>, WriteStorage<'a, Confusion>, ReadStorage<'a, Equippable>, WriteStorage<'a, Equipped>, WriteStorage<'a, InBackpack>, WriteExpect<'a, ParticleBuilder>, ReadStorage<'a, Position>, ReadStorage<'a, ProvidesFood>, WriteStorage<'a, HungerClock>, ReadStorage<'a, MagicMapper>, WriteExpect<'a, RunState>, WriteStorage<'a, EquipmentChanged> ); #[allow(clippy::cognitive_complexity)] fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, map, entities, mut wants_use, names, consumables, healing, inflict_damage, mut combat_stats, mut suffer_damage, aoe, mut confused, equippable, mut equipped, mut backpack, mut particle_builder, positions, provides_food, mut hunger_clocks, magic_mapper, mut runstate, mut dirty) = data; for (entity, useitem) in (&entities, &wants_use).join() { dirty.insert(entity, EquipmentChanged{}); ... }
对于掉落物品:
#![allow(unused)] fn main() { impl<'a> System<'a> for ItemDropSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, Entities<'a>, WriteStorage<'a, WantsToDropItem>, ReadStorage<'a, Name>, WriteStorage<'a, Position>, WriteStorage<'a, InBackpack>, WriteStorage<'a, EquipmentChanged> ); fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, entities, mut wants_drop, names, mut positions, mut backpack, mut dirty) = data; for (entity, to_drop) in (&entities, &wants_drop).join() { let mut dropper_pos : Position = Position{x:0, y:0}; { let dropped_pos = positions.get(entity).unwrap(); dropper_pos.x = dropped_pos.x; dropper_pos.y = dropped_pos.y; } positions.insert(to_drop.item, Position{ x : dropper_pos.x, y : dropper_pos.y }).expect("Unable to insert position"); backpack.remove(to_drop.item); dirty.insert(entity, EquipmentChanged{}).expect("Unable to insert"); if entity == *player_entity { gamelog.entries.push(format!("You drop the {}.", names.get(to_drop.item).unwrap().name)); } } wants_drop.clear(); } } }
如果您 cargo run,您可以在调试器中看到修改器正在生效。
向玩家展示正在发生的事情
然而 - 您的玩家不太可能运行调试器! 我们应该让玩家看到他们行为的效果,以便他们可以相应地计划。 我们将修改 gui.rs 中的用户界面(函数 draw_ui)以实际显示玩家正在发生的事情。
首先,我们将装备物品列表(以及其下方的热键)向下移动四行(示例源代码的第 99 行):
#![allow(unused)] fn main() { // Equipped (已装备) let mut y = 13; }
为什么要移动四行? 这样我们就可以有一些空白,一行用于显示先攻值,一行用于显示重量,以及未来一行用于显示金钱,当我们实现金钱系统的时候! 让我们实际打印信息。 在 // Equipped 注释之前:
#![allow(unused)] fn main() { // Initiative and weight (先攻值和重量) ctx.print_color(50, 9, white, black, &format!("{:.0} lbs ({} lbs max)", player_pools.total_weight, (attr.might.base + attr.might.modifiers) * 15 ) ); ctx.print_color(50,10, white, black, &format!("Initiative Penalty: {:.0}", player_pools.total_initiative_penalty)); }
请注意,format! 宏对占位符使用了 {:.0}; 这告诉 Rust 格式化为零位小数(它是一个浮点数)。 如果您现在 cargo run,您将看到我们正在显示我们的总数。 如果您掉落物品,总数会发生变化:

实际更新先攻值
我们遗漏了一个相当重要的步骤:实际使用先攻惩罚! 打开 ai/initiative_system.rs,我们将纠正这个问题。 还记得我们留在那里的 TODO 语句吗? 现在我们有东西可以放在那里了! 首先,我们将 Pools 添加到可用的读取资源中:
#![allow(unused)] fn main() { impl<'a> System<'a> for InitiativeSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, Initiative>, ReadStorage<'a, Position>, WriteStorage<'a, MyTurn>, Entities<'a>, WriteExpect<'a, rltk::RandomNumberGenerator>, ReadStorage<'a, Attributes>, WriteExpect<'a, RunState>, ReadExpect<'a, Entity>, ReadExpect<'a, rltk::Point>, ReadStorage<'a, Pools>); fn run(&mut self, data : Self::SystemData) { let (mut initiatives, positions, mut turns, entities, mut rng, attributes, mut runstate, player, player_pos, pools) = data; ... }
然后,我们将当前的先攻惩罚总值添加到先攻值中:
#![allow(unused)] fn main() { // Apply pool penalty (应用 pool 惩罚) if let Some(pools) = pools.get(entity) { initiative.current += f32::floor(pools.total_initiative_penalty) as i32; } // TODO: More initiative granting boosts/penalties will go here later (更多先攻值增益/惩罚将稍后在此处添加) }
好的 - 先攻惩罚生效了! 您可以玩一会儿游戏,看看这些值如何影响游戏玩法。 您已经使更大/更具破坏性的武器会产生速度惩罚(以及更重的盔甲),因此现在实体装备越多 - 它们移动得越慢。 这为游戏应用了一些平衡性; 快速的匕首使用者相对于较慢的、穿着盔甲的长剑使用者可以获得更多的打击次数。 装备选择不再仅仅是获得最大的奖励 - 它还会影响速度/重量。 换句话说,这是一个平衡行为 - 为玩家提供多种方式来优化“他们的 build”(如果您让人们发布关于您游戏的“build”,请庆祝:这意味着他们真的在享受它!)。
关于现金的一切
我们已经向物品添加了 base_value 字段,但没有对其做任何事情。 实际上,我们根本没有货币的概念。 让我们使用简化的“金币”系统; 金币是主要数字(小数点前),银币是小数部分(10 银币兑换 1 金币)。 我们将不担心更小的硬币。
在许多方面,货币是一个 pool - 就像生命值和类似的东西一样。 你花费它,获得它,最好将其作为抽象数字处理,而不是试图追踪每枚硬币(尽管使用 ECS 完全有可能做到这一点!)。 因此,我们将进一步扩展 Pools 组件以指定金币:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Pools { pub hit_points : Pool, pub mana : Pool, pub xp : i32, pub level : i32, pub total_weight : f32, pub total_initiative_penalty : f32, pub gold : f32 } }
将其应用于 pools 意味着玩家和所有 NPC 都可能拥有金币! 打开 spawner.rs,并修改 player 函数以使贫困的英雄从一开始就身无分文:
#![allow(unused)] fn main() { .with(Pools{ hit_points : Pool{ current: player_hp_at_level(11, 1), max: player_hp_at_level(11, 1) }, mana: Pool{ current: mana_at_level(11, 1), max: mana_at_level(11, 1) }, xp: 0, level: 1, total_weight : 0.0, total_initiative_penalty : 0.0, gold : 0.0 }) }
NPC 也应该携带金币,这样你就可以在他们被杀死时将他们从重商主义思想的负担中解放出来! 打开 raws/mob_structs.rs,我们将向 NPC 定义添加一个“gold”(金币)字段:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub vision_range : i32, pub movement : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32>, pub equipped : Option<Vec<String>>, pub natural : Option<MobNatural>, pub loot_table : Option<String>, pub light : Option<MobLight>, pub faction : Option<String>, pub gold : Option<String> } }
我们将 gold 设置为 Option - 因此它不必存在(毕竟,为什么老鼠要携带现金?)。 我们也将其设置为 String - 因此它可以是骰子投掷结果,而不是特定数字。 让土匪总是掉落一个金币是很无聊的! 现在我们需要修改 rawmaster.rs 的 spawn_named_mob 函数,以实际将金币应用于 NPC:
#![allow(unused)] fn main() { let pools = Pools{ level: mob_level, xp: 0, hit_points : Pool{ current: mob_hp, max: mob_hp }, mana: Pool{current: mob_mana, max: mob_mana}, total_weight : 0.0, total_initiative_penalty : 0.0, gold : if let Some(gold) = &mob_template.gold { let mut rng = rltk::RandomNumberGenerator::new(); let (n, d, b) = parse_dice_string(&gold); (rng.roll_dice(n, d) + b) as f32 } else { 0.0 } }; }
因此,我们告诉生成器:如果没有指定金币,则使用零。 否则,解析骰子字符串并掷骰子 - 并使用该数量的金币。
接下来,当玩家杀死某人时 - 我们应该掠夺他们的现金。 你几乎总是想捡起钱,所以没有真正的必要把它掉下来并让玩家记住去收集它。 在 damage_system.rs 中,首先在 xp_gain 旁边添加一个新的可变变量:
#![allow(unused)] fn main() { let mut xp_gain = 0; let mut gold_gain = 0.0f32; }
然后在我们添加 XP 的位置旁边:
#![allow(unused)] fn main() { if stats.hit_points.current < 1 && damage.from_player { xp_gain += stats.level * 100; gold_gain += stats.gold; } }
然后在我们更新玩家的 XP 时,我们也更新他们的金币:
#![allow(unused)] fn main() { if xp_gain != 0 || gold_gain != 0.0 { let mut player_stats = stats.get_mut(*player).unwrap(); let player_attributes = attributes.get(*player).unwrap(); player_stats.xp += xp_gain; player_stats.gold += gold_gain; }
接下来,我们应该向玩家展示他们有多少金币。 再次打开 gui.rs,并在我们放入重量和先攻值的位置旁边再添加一行:
#![allow(unused)] fn main() { ctx.print_color(50,11, rltk::RGB::named(rltk::GOLD), black, &format!("Gold: {:.1}", player_pools.gold)); }
最后,我们应该给土匪一些金币。 在 spawns.json 中,打开 Bandit 条目并应用金币:
{
"name" : "Bandit",
"renderable": {
"glyph" : "☻",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 6,
"movement" : "random_waypoint",
"quips" : [ "Stand and deliver!", "Alright, hand it over" ],
"attributes" : {},
"equipped" : [ "Dagger", "Shield", "Leather Armor", "Leather Boots" ],
"light" : {
"range" : 6,
"color" : "#FFFF55"
},
"faction" : "Bandits",
"gold" : "1d6"
},
(在 源代码 中,我也给地精、兽人和其他人形生物添加了金币。 你也应该这样做!)
如果您现在 cargo run,您将能够通过杀死敌人来获得金币(您还可以看到我在杀死土匪后给自己装备了物品,先攻值和重量会正确更新):

与商人交易
获得金币(并释放你的物品栏)的另一种好方法是将其出售给商人。 我们希望保持界面简单,因此我们希望走进商人会触发商人界面。 让我们修改 Barkeep 条目,以包含他 a) 是商人,b) 销售食物的注释。
{
"name" : "Barkeep",
"renderable": {
"glyph" : "☻",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 4,
"movement" : "static",
"attributes" : {
"intelligence" : 13
},
"skills" : {
"Melee" : 2
},
"equipped" : [ "Cudgel", "Cloth Tunic", "Cloth Pants", "Slippers" ],
"faction" : "Townsfolk",
"gold" : "2d6",
"vendor" : [ "food" ]
},
我们需要更新 raws/mob_structs.rs 以支持商人标签作为选项,其中将包含类别字符串列表:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub vision_range : i32, pub movement : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32>, pub equipped : Option<Vec<String>>, pub natural : Option<MobNatural>, pub loot_table : Option<String>, pub light : Option<MobLight>, pub faction : Option<String>, pub gold : Option<String>, pub vendor : Option<Vec<String>> } }
我们还需要创建一个 Vendor 组件。 您可能还记得我们之前有一个,但它与 AI 绑定 - 这次,它实际上旨在处理买卖。 将其添加到 components.rs(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Vendor { pub categories : Vec<String> } }
对 rawmaster.rs 的 spawn_named_mob 进行快速更改,使此组件附加到商人:
#![allow(unused)] fn main() { if let Some(vendor) = &mob_template.vendor { eb = eb.with(Vendor{ categories : vendor.clone() }); } }
让我们打开 main.rs 并向 RunState 添加一个新状态:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum VendorMode { Buy, Sell } #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, Ticking, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, PreviousLevel, ShowRemoveItem, GameOver, MagicMapReveal { row : i32 }, MapGeneration, ShowCheatMenu, ShowVendor { vendor: Entity, mode : VendorMode } } }
现在我们需要更新 player.rs 的 try_move_player 函数,以便在我们走进商人时触发商人模式:
#![allow(unused)] fn main() { ... let vendors = ecs.read_storage::<Vendor>(); let mut result = RunState::AwaitingInput; let mut swap_entities : Vec<(Entity, i32, i32)> = Vec::new(); for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() { if pos.x + delta_x < 1 || pos.x + delta_x > map.width-1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height-1 { return RunState::AwaitingInput; } let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y); result = crate::spatial::for_each_tile_content_with_gamemode(destination_idx, |potential_target| { if let Some(_vendor) = vendors.get(potential_target) { return Some(RunState::ShowVendor{ vendor: potential_target, mode : VendorMode::Sell }); } ... }
我们还需要一种方法来确定商人有哪些商品出售。 在 raws/item_structs.rs 中,我们将向物品定义添加一个新的可选字段:商人类别:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Item { pub name : String, pub renderable : Option<Renderable>, pub consumable : Option<Consumable>, pub weapon : Option<Weapon>, pub wearable : Option<Wearable>, pub initiative_penalty : Option<f32>, pub weight_lbs : Option<f32>, pub base_value : Option<f32>, pub vendor_category : Option<String> } }
进入 spawns.json,并将商人类别标签添加到 Rations:
{
"name" : "Rations",
"renderable": {
"glyph" : "%",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"food" : ""
}
},
"weight_lbs" : 2.0,
"base_value" : 0.5,
"vendor_category" : "food"
},
现在我们可以将此函数添加到 raws/rawmaster.rs,以检索类别中出售的物品:
#![allow(unused)] fn main() { pub fn get_vendor_items(categories: &[String], raws : &RawMaster) -> Vec<(String, f32)> { let mut result : Vec<(String, f32)> = Vec::new(); for item in raws.raws.items.iter() { if let Some(cat) = &item.vendor_category { if categories.contains(cat) && item.base_value.is_some() { result.push(( item.name.clone(), item.base_value.unwrap() )); } } } result } }
我们将前往 gui.rs 并创建一个新函数 show_vendor_menu,以及两个辅助函数和一个枚举! 让我们从枚举开始:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum VendorResult { NoResponse, Cancel, Sell, BuyMode, SellMode, Buy } }
这表示玩家在与商人交谈时可能做出的选择:无响应、取消对话、买卖物品以及在买卖模式之间切换。
显示待售物品的函数与用于掉落物品的 UI 非常相似(它是修改后的副本):
#![allow(unused)] fn main() { fn vendor_sell_menu(gs : &mut State, ctx : &mut Rltk, _vendor : Entity, _mode : VendorMode) -> (VendorResult, Option<Entity>, Option<String>, Option<f32>) { let player_entity = gs.ecs.fetch::<Entity>(); let names = gs.ecs.read_storage::<Name>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let items = gs.ecs.read_storage::<Item>(); let entities = gs.ecs.entities(); let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity ); let count = inventory.count(); let mut y = (25 - (count / 2)) as i32; ctx.draw_box(15, y-2, 51, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Sell Which Item? (space to switch to buy mode)"); // 卖哪个物品?(空格键切换到购买模式) ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); // ESCAPE 取消 let mut equippable : Vec<Entity> = Vec::new(); let mut j = 0; for (entity, _pack, name, item) in (&entities, &backpack, &names, &items).join().filter(|item| item.1.owner == *player_entity ) { ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as rltk::FontCharType); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, &name.name.to_string()); ctx.print(50, y, &format!("{:.1} gp", item.base_value * 0.8)); equippable.push(entity); y += 1; j += 1; } match ctx.key { None => (VendorResult::NoResponse, None, None, None), Some(key) => { match key { VirtualKeyCode::Space => { (VendorResult::BuyMode, None, None, None) } VirtualKeyCode::Escape => { (VendorResult::Cancel, None, None, None) } _ => { let selection = rltk::letter_to_option(key); if selection > -1 && selection < count as i32 { return (VendorResult::Sell, Some(equippable[selection as usize]), None, None); } (VendorResult::NoResponse, None, None, None) } } } } } }
购买也很相似,但是我们没有查询背包,而是使用我们之前编写的 get_vendor_items 函数来获取要出售的物品列表:
#![allow(unused)] fn main() { fn vendor_buy_menu(gs : &mut State, ctx : &mut Rltk, vendor : Entity, _mode : VendorMode) -> (VendorResult, Option<Entity>, Option<String>, Option<f32>) { use crate::raws::*; let vendors = gs.ecs.read_storage::<Vendor>(); let inventory = crate::raws::get_vendor_items(&vendors.get(vendor).unwrap().categories, &RAWS.lock().unwrap()); let count = inventory.len(); let mut y = (25 - (count / 2)) as i32; ctx.draw_box(15, y-2, 51, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Buy Which Item? (space to switch to sell mode)"); // 买哪个物品?(空格键切换到出售模式) ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); // ESCAPE 取消 for (j,sale) in inventory.iter().enumerate() { ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as rltk::FontCharType); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, &sale.0); ctx.print(50, y, &format!("{:.1} gp", sale.1 * 1.2)); y += 1; } match ctx.key { None => (VendorResult::NoResponse, None, None, None), Some(key) => { match key { VirtualKeyCode::Space => { (VendorResult::SellMode, None, None, None) } VirtualKeyCode::Escape => { (VendorResult::Cancel, None, None, None) } _ => { let selection = rltk::letter_to_option(key); if selection > -1 && selection < count as i32 { return (VendorResult::Buy, None, Some(inventory[selection as usize].0.clone()), Some(inventory[selection as usize].1)); } (VendorResult::NoResponse, None, None, None) } } } } } }
最后,我们提供一个公共函数,它只是定向到相关模式:
#![allow(unused)] fn main() { pub fn show_vendor_menu(gs : &mut State, ctx : &mut Rltk, vendor : Entity, mode : VendorMode) -> (VendorResult, Option<Entity>, Option<String>, Option<f32>) { match mode { VendorMode::Buy => vendor_buy_menu(gs, ctx, vendor, mode), VendorMode::Sell => vendor_sell_menu(gs, ctx, vendor, mode) } } }
回到 main.rs,我们需要将交易添加到游戏的整体状态机中:
#![allow(unused)] fn main() { RunState::ShowVendor{vendor, mode} => { let result = gui::show_vendor_menu(self, ctx, vendor, mode); match result.0 { gui::VendorResult::Cancel => newrunstate = RunState::AwaitingInput, gui::VendorResult::NoResponse => {} gui::VendorResult::Sell => { let price = self.ecs.read_storage::<Item>().get(result.1.unwrap()).unwrap().base_value * 0.8; self.ecs.write_storage::<Pools>().get_mut(*self.ecs.fetch::<Entity>()).unwrap().gold += price; self.ecs.delete_entity(result.1.unwrap()).expect("Unable to delete"); } gui::VendorResult::Buy => { let tag = result.2.unwrap(); let price = result.3.unwrap(); let mut pools = self.ecs.write_storage::<Pools>(); let player_pools = pools.get_mut(*self.ecs.fetch::<Entity>()).unwrap(); if player_pools.gold >= price { player_pools.gold -= price; std::mem::drop(pools); let player_entity = *self.ecs.fetch::<Entity>(); crate::raws::spawn_named_item(&RAWS.lock().unwrap(), &mut self.ecs, &tag, SpawnType::Carried{ by: player_entity }); } } gui::VendorResult::BuyMode => newrunstate = RunState::ShowVendor{ vendor, mode: VendorMode::Buy }, gui::VendorResult::SellMode => newrunstate = RunState::ShowVendor{ vendor, mode: VendorMode::Sell } } } }
您现在可以从商人处买卖商品了! UI 可能需要一些改进(在未来的章节中!),但功能已经具备。 现在您有理由捡起无用的战利品和现金了!

最后,遍历 spawns.json 以将物品添加到商人类别是一个好主意 - 并将商人设置为销售这些类别。 您已经看到了 Rations 作为示例 - 现在是时候在物品上尽情发挥了! 在源代码 中,我已经填写了我认为合理的默认值。
总结
游戏现在有了金钱、买卖系统! 这为回到城镇并捡起原本无用的物品提供了一个很好的理由。 游戏现在还具有物品重量和负重,以及使用较小武器的好处。 这为更深入的游戏奠定了基础。
...
本章的源代码可以在这里找到
在您的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
更深邃的洞穴
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持 我的 Patreon。
我们已经有了第一层石灰岩洞穴,看起来相当不错。我们从设计文档中了解到,洞穴会通向一个矮人要塞,但看起来我们可以再享受一下我们的洞穴渲染器。让我们构建一个更深邃的洞穴关卡,重点是一个兽人和地精营地,以及外围的野生怪物。
更多的作弊!
现在是添加更多作弊功能的好时机,以便更轻松地处理后面的关卡。
按需治疗
当你死亡的时候感觉很糟糕,特别是当你的本意只是想看看你的新关卡设计!所以我们将添加一个新的作弊选项:治疗。打开 gui.rs,并编辑 cheat_menu 和相关的 result 类型:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum CheatMenuResult { NoResponse, Cancel, TeleportToExit, Heal } pub fn show_cheat_mode(_gs : &mut State, ctx : &mut Rltk) -> CheatMenuResult { let count = 2; let mut y = (25 - (count / 2)) as i32; ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Cheating!"); ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), rltk::to_cp437('T')); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, "Teleport to next level"); // 传送至下一关 y += 1; ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), rltk::to_cp437('H')); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, "Heal all wounds"); // 治疗所有伤口 match ctx.key { None => CheatMenuResult::NoResponse, Some(key) => { match key { VirtualKeyCode::T => CheatMenuResult::TeleportToExit, VirtualKeyCode::H => CheatMenuResult::Heal, VirtualKeyCode::Escape => CheatMenuResult::Cancel, _ => CheatMenuResult::NoResponse } } } } }
然后访问 main.rs,并在作弊处理器中添加对治疗的支持:
#![allow(unused)] fn main() { gui::CheatMenuResult::Heal => { let player = self.ecs.fetch::<Entity>(); let mut pools = self.ecs.write_storage::<Pools>(); let mut player_pools = pools.get_mut(*player).unwrap(); player_pools.hit_points.current = player_pools.hit_points.max; newrunstate = RunState::AwaitingInput; } }
完成这些后,你只需按两次键就可以在需要时获得免费治疗!这应该使探索我们后面的关卡变得更容易:

显示全部和上帝模式
另一个方便的功能是显示地图,特别是当您只想验证地图构建时。完全关闭死亡也是确保地图都在您认为应该在的位置的好方法!所以首先,我们将添加另外两个菜单项及其处理程序:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum CheatMenuResult { NoResponse, Cancel, TeleportToExit, Heal, Reveal, GodMode } pub fn show_cheat_mode(_gs : &mut State, ctx : &mut Rltk) -> CheatMenuResult { let count = 4; let mut y = (25 - (count / 2)) as i32; ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Cheating!"); ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), rltk::to_cp437('T')); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, "Teleport to next level"); // 传送至下一关 y += 1; ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), rltk::to_cp437('H')); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, "Heal all wounds"); // 治疗所有伤口 y += 1; ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), rltk::to_cp437('R')); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, "Reveal the map"); // 显示地图 y += 1; ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), rltk::to_cp437('G')); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print(21, y, "God Mode (No Death)"); // 上帝模式(无死亡) match ctx.key { None => CheatMenuResult::NoResponse, Some(key) => { match key { VirtualKeyCode::T => CheatMenuResult::TeleportToExit, VirtualKeyCode::H => CheatMenuResult::Heal, VirtualKeyCode::R => CheatMenuResult::Reveal, VirtualKeyCode::G => CheatMenuResult::GodMode, VirtualKeyCode::Escape => CheatMenuResult::Cancel, _ => CheatMenuResult::NoResponse } } } } }
现在我们需要在 main.rs 中处理这些:
#![allow(unused)] fn main() { gui::CheatMenuResult::Reveal => { let mut map = self.ecs.fetch_mut::<Map>(); for v in map.revealed_tiles.iter_mut() { *v = true; } newrunstate = RunState::AwaitingInput; } gui::CheatMenuResult::GodMode => { let player = self.ecs.fetch::<Entity>(); let mut pools = self.ecs.write_storage::<Pools>(); let mut player_pools = pools.get_mut(*player).unwrap(); player_pools.god_mode = true; newrunstate = RunState::AwaitingInput; } }
Reveal 非常简单:将地图上的每个瓦片都设置为已显示。上帝模式是在 Pools 组件中设置一个尚不存在的变量,所以打开 components.rs,我们将添加它:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Pools { pub hit_points : Pool, pub mana : Pool, pub xp : i32, pub level : i32, pub total_weight : f32, pub total_initiative_penalty : f32, pub gold : f32, pub god_mode : bool } }
我们需要在创建 Pools 对象的 spawner.rs 和 raws/rawmaster.rs 函数中将 god_mode 设置为 false。最后,对 damage_system.rs 进行快速调整,以关闭神祇的伤害:
#![allow(unused)] fn main() { ... for (entity, mut stats, damage) in (&entities, &mut stats, &damage).join() { if !stats.god_mode { stats.hit_points.current -= damage.amount.iter().sum::<i32>(); } ... }
现在您可以随时显示地图,并关闭承受伤害的能力:

这使得处理后期内容 容易得多,而无需一遍又一遍地玩(不过,最好时不时地玩一下,并找出错误)。
深邃洞穴基本布局
深邃洞穴仍然应该看起来很自然,但也应该有一个中心区域,地精类生物可以在其中扎营。我们在上一章中研究过的扩散限制聚集 (Diffusion-Limited Aggregation) 算法,特别是“中心吸引子 (central attractor)”模式,为基本布局提供了我们想要的几乎所有东西:

在 map_builders/mod.rs 中,我们将首先为第 4 级创建一个新条目:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, rng, width, height), 2 => forest_builder(new_depth, rng, width, height), 3 => limestone_cavern_builder(new_depth, rng, width, height), 4 => limestone_deep_cavern_builder(new_depth, rng, width, height), _ => random_builder(new_depth, rng, width, height) } } }
在 map/themes.rs 中,我们将告诉这个关卡也使用石灰石主题:
#![allow(unused)] fn main() { pub fn tile_glyph(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { let (glyph, mut fg, mut bg) = match map.depth { 4 => get_limestone_cavern_glyph(idx, map), 3 => get_limestone_cavern_glyph(idx, map), 2 => get_forest_glyph(idx, map), _ => get_tile_glyph_default(idx, map) }; }
然后在 map_builders/limestone_cavern.rs 中,我们可以添加新函数。这是一个好的开始:
#![allow(unused)] fn main() { pub fn limestone_deep_cavern_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Deep Limestone Caverns"); chain.start_with(DLABuilder::central_attractor()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::TOP)); chain.with(VoronoiSpawning::new()); chain.with(DistantExit::new()); chain.with(CaveDecorator::new()); chain } }
这实际上为我们提供了一个相当可玩性的关卡;我们可以停在这里而不感到羞愧(尽管我们显然需要添加更多怪物)。但是我们还没有完成!我们希望地图中心有一个兽人营地。这听起来像是预制件 (prefab) 的工作!打开 map_builders/prefab_builder/prefab_sections.rs,我们将添加一个新的 sectional:
#![allow(unused)] fn main() { #[allow(dead_code)] pub const ORC_CAMP : PrefabSection = PrefabSection{ template : ORC_CAMP_TXT, width: 12, height: 12, placement: ( HorizontalPlacement::Center, VerticalPlacement::Center ) }; #[allow(dead_code)] const ORC_CAMP_TXT : &str = " ≈≈≈≈o≈≈≈≈≈ ≈☼ ☼≈ ≈ g ≈ ≈ ≈ ≈ g ≈ o O o ≈ ≈ ≈ g ≈ ≈ g ≈ ≈☼ ☼≈ ≈≈≈≈o≈≈≈≈≈ "; }
这里有一些新的字形,所以我们还需要打开 map_builders/prefab_builder/mod.rs,找到 char_to_map 函数并将它们添加进去。波浪线表示水(提供一个受保护的护城河),太阳符号表示篝火。大写字母 O 是兽人首领。所以我们将它们添加到 match 函数中:
#![allow(unused)] fn main() { '≈' => build_data.map.tiles[idx] = TileType::DeepWater, 'O' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Orc Leader".to_string())); } '☼' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Watch Fire".to_string())); } }
然后我们修改构建链(在 limestone_deep_cavern_builder 中)以包含此内容:
#![allow(unused)] fn main() { pub fn limestone_deep_cavern_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Deep Limestone Caverns"); chain.start_with(DLABuilder::central_attractor()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::TOP)); chain.with(VoronoiSpawning::new()); chain.with(DistantExit::new()); chain.with(CaveDecorator::new()); chain.with(PrefabBuilder::sectional(super::prefab_builder::prefab_sections::ORC_CAMP)); chain } }
我们还需要添加缺少的实体。“Watch Fire”和“Orc Leader”是新的。所以我们打开 spawns.json 并将它们添加进去。Watch Fire 是一个 prop:
{
"name" : "Watch Fire",
"renderable": {
"glyph" : "☼",
"fg" : "#FFFF55",
"bg" : "#000000",
"order" : 2
},
"hidden" : false,
"light" : {
"range" : 6,
"color" : "#FFFF55"
},
"entry_trigger" : {
"effects" : {
"damage" : "6"
}
}
}
light 条目是新的!我们以前没有让 prop 生成光照(但这很有道理;黑暗的篝火会很奇怪)。它也会在进入时造成伤害,这也很合理 - 走进火堆对你的健康不利。支持光照需要进行一些快速更改。打开 raws/prop_structs.rs,我们将为 props 添加 light 条目的选项:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Prop { pub name : String, pub renderable : Option<Renderable>, pub hidden : Option<bool>, pub blocks_tile : Option<bool>, pub blocks_visibility : Option<bool>, pub door_open : Option<bool>, pub entry_trigger : Option<EntryTrigger>, pub light : Option<super::mob_structs::MobLight>, } }
我们重用了 mobs 中的 MobLight,因为它是一样的东西。现在打开 raws/raw_master.rs,我们将编辑 spawn_named_prop 以包含此选项:
#![allow(unused)] fn main() { if let Some(light) = &prop_template.light { eb = eb.with(LightSource{ range: light.range, color : rltk::RGB::from_hex(&light.color).expect("Bad color") }); eb = eb.with(Viewshed{ range: light.range, dirty: true, visible_tiles: Vec::new() }); } }
如果您还记得,我们的光照代码使用可见性图来确定它可以照亮的位置 - 因此 prop 需要一个 viewshed。没关系,我们的 ECS 会支持我们并处理它(并且在第一次绘制后,它将永远不会重新计算 - 因为 prop 不会移动)。
最后,我们的 Orc Leader 进入 spawns.json 的 "mobs" 部分:
{
"name" : "Orc Leader",
"renderable": {
"glyph" : "O",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "static",
"attributes" : {},
"faction" : "Cave Goblins",
"gold" : "3d8",
"equipped" : [ "Battleaxe", "Tower Shield", "Leather Armor", "Leather Boots" ],
"level" : 2
},
他应该是一个挑战,但如果你赢了,你可以从他那里获得不错的现金和好的武器/盔甲。
如果您现在 cargo run,您将看到要塞已就位(我在图形中使用的是上帝模式):

所以 prefab 在那里 - 但存在一个真正的问题:玩家完全被兽人和地精淹没了!虽然这可能是现实的,但它让玩家几乎没有机会在到达这个关卡后生存下来。即使是巧妙的玩法,在相对开放的地图中,这种类型的猛攻也可能很快被证明是致命的。所以现在,我们将调整 spawns.json 中的 spawn table:
"spawn_table" : [
{ "name" : "Goblin", "weight" : 10, "min_depth" : 3, "max_depth" : 4 },
{ "name" : "Orc", "weight" : 1, "min_depth" : 4, "max_depth" : 100 },
{ "name" : "Health Potion", "weight" : 7, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Fireball Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Confusion Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Magic Missile Scroll", "weight" : 4, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Dagger", "weight" : 3, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Shield", "weight" : 3, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Longsword", "weight" : 1, "min_depth" : 3, "max_depth" : 100 },
{ "name" : "Tower Shield", "weight" : 1, "min_depth" : 3, "max_depth" : 100 },
{ "name" : "Rations", "weight" : 10, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Magic Mapping Scroll", "weight" : 2, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Bear Trap", "weight" : 5, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Battleaxe", "weight" : 1, "min_depth" : 2, "max_depth" : 100 },
{ "name" : "Kobold", "weight" : 15, "min_depth" : 3, "max_depth" : 3 },
{ "name" : "Rat", "weight" : 15, "min_depth" : 2, "max_depth" : 2 },
{ "name" : "Mangy Wolf", "weight" : 13, "min_depth" : 2, "max_depth" : 2 },
{ "name" : "Deer", "weight" : 14, "min_depth" : 2, "max_depth" : 2 },
{ "name" : "Bandit", "weight" : 9, "min_depth" : 2, "max_depth" : 3 },
{ "name" : "Bat", "weight" : 15, "min_depth" : 3, "max_depth" : 3 },
{ "name" : "Large Spider", "weight" : 3, "min_depth" : 3, "max_depth" : 3 },
{ "name" : "Gelatinous Cube", "weight" : 3, "min_depth" : 3, "max_depth" : 3 }
],
我们已经从 Orcs 中删除了 add_map_depth_to_weight,所以它们不会 到处都是,并将其他生物限制为不出现在此关卡中。由于我们知道我们正在中间添加一个完整的要塞,这很有意义:你现在更有可能获得有用的掉落物,并且有更多的开放空间。
还有一个视觉问题。深蓝色的深水不错,但在灰度模式下基本上是不可见的 - 如果你的显示器亮度没有调高,也很难看到。让我们在其中添加一点绿色,使其更可见。在 map/themes.rs 中(get_limestone_cavern_glyph 函数):
#![allow(unused)] fn main() { TileType::DeepWater => { glyph = rltk::to_cp437('▓'); fg = RGB::from_f32(0.2, 0.2, 1.0); } }
这样就好多了:

更多生成物
让我们花一点时间为关卡引入更好的盔甲和武器,并使其有可能生成。玩家开始面临真正的挑战,因此他们需要一些可能的改进!我们将首先将锁子甲添加到 spawns.json 中:
{
"name" : "Chainmail Armor",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Torso",
"armor_class" : 2.0
},
"weight_lbs" : 20.0,
"base_value" : 50.0,
"initiative_penalty" : 1.0,
"vendor_category" : "armor"
},
{
"name" : "Chain Coif",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Head",
"armor_class" : 1.0
},
"weight_lbs" : 5.0,
"base_value" : 20.0,
"initiative_penalty" : 0.5,
"vendor_category" : "armor"
},
通过包含 vendor_category,这些物品已可供出售 - 因此如果您的玩家获得足够的现金,他们可以购买它们(如果他们花时间回家!)。 让我们也让它们偶尔从第 4 级开始掉落。在 spawns.json 的 spawn_table 中:
{ "name" : "Leather Armor", "weight" : 1, "min_depth" : 2, "max_depth" : 100 },
{ "name" : "Leather Boots", "weight" : 1, "min_depth" : 2, "max_depth" : 100 },
{ "name" : "Chainmail Armor", "weight" : 1, "min_depth" : 4, "max_depth" : 100 },
{ "name" : "Chain Coif", "weight" : 1, "min_depth" : 4, "max_depth" : 100 },
我们也允许皮革盔甲作为宝藏掉落出现。 这应该有助于降低难度!
总结
又完成了一个关卡(更多的改进是可能的;它们 总是 可能的),游戏正在成型!你现在可以开辟森林之路,砍杀石灰岩洞穴关卡,并在有兽人要塞的深邃洞穴中挥砍。这开始听起来像是一场冒险!
...
本章的源代码可以在 这里 找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
过渡:从洞穴到矮人要塞
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出伟大的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
设计文档中提到,洞穴将会过渡到一个精心雕琢的矮人要塞——现在被邪恶的野兽和一条龙占据着。如果下一层突然变成了一个四四方方的矮人堡垒,那将会非常突兀——所以这一层将完全关于过渡。
让我们从主题开始。我们希望将地图分割为石灰岩洞穴的外观和地牢的外观——因此我们在 themes.rs 的 tile_glyph 函数中添加一个新的条目来实现这一点:
#![allow(unused)] fn main() { pub fn tile_glyph(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { let (glyph, mut fg, mut bg) = match map.depth { 5 => { let x = idx as i32 % map.width; if x < map.width/2 { get_limestone_cavern_glyph(idx, map) } else { get_tile_glyph_default(idx, map) } } ... }
现在我们打开 map_builders/mod.rs 并调用一个新的构建函数:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, rng, width, height), 2 => forest_builder(new_depth, rng, width, height), 3 => limestone_cavern_builder(new_depth, rng, width, height), 4 => limestone_deep_cavern_builder(new_depth, rng, width, height), 5 => limestone_transition_builder(new_depth, rng, width, height), _ => random_builder(new_depth, rng, width, height) } } }
打开 limestone_cavern.rs,我们将创建一个新函数:
#![allow(unused)] fn main() { pub fn limestone_transition_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Dwarf Fort - Upper Reaches"); chain.start_with(CellularAutomataBuilder::new()); chain.with(WaveformCollapseBuilder::new()); chain.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); chain.with(VoronoiSpawning::new()); chain.with(CaveDecorator::new()); chain } }
这非常简单:它创建了一个细胞自动机地图,然后用波形坍缩对其进行卷积;我们在之前的章节中已经介绍过这些,所以它们应该很熟悉。它实现了我们想要一半的效果:一个开放、自然的地牢外观。但是我们需要更多的工作来生成矮人部分!让我们添加更多步骤:
#![allow(unused)] fn main() { pub fn limestone_transition_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Dwarf Fort - Upper Reaches"); chain.start_with(CellularAutomataBuilder::new()); chain.with(WaveformCollapseBuilder::new()); chain.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); chain.with(VoronoiSpawning::new()); chain.with(CaveDecorator::new()); chain.with(CaveTransition::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); chain.with(DistantExit::new()); chain } }
所以现在我们经历了相同的地图生成过程,调用一个尚未编写的 CaveTransition 构建器,并重置起点和终点。那么 CaveTransition 中包含了什么呢?
#![allow(unused)] fn main() { pub struct CaveTransition {} impl MetaMapBuilder for CaveTransition { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl CaveTransition { #[allow(dead_code)] pub fn new() -> Box<CaveTransition> { Box::new(CaveTransition{}) } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { build_data.map.depth = 5; build_data.take_snapshot(); // 构建一个基于 BSP 的地下城 let mut builder = BuilderChain::new(5, build_data.width, build_data.height, "New Map"); builder.start_with(BspDungeonBuilder::new()); builder.with(RoomDrawer::new()); builder.with(RoomSorter::new(RoomSort::RIGHTMOST)); builder.with(NearestCorridors::new()); builder.with(RoomExploder::new()); builder.with(RoomBasedSpawner::new()); builder.build_map(rng); // 将历史记录添加到我们的历史记录中 for h in builder.build_data.history.iter() { build_data.history.push(h.clone()); } build_data.take_snapshot(); // 将 BSP 地图的右半部分复制到我们的地图中 for x in build_data.map.width / 2 .. build_data.map.width { for y in 0 .. build_data.map.height { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = builder.build_data.map.tiles[idx]; } } build_data.take_snapshot(); // 保留地图左半部分的 Voronoi 生成数据 let w = build_data.map.width; build_data.spawn_list.retain(|s| { let x = s.0 as i32 / w; x < w / 2 }); // 保留地图右半部分的房间生成数据 for s in builder.build_data.spawn_list.iter() { let x = s.0 as i32 / w; if x > w / 2 { build_data.spawn_list.push(s.clone()); } } } } }
所以这里有制作构建器的所有常用样板代码,然后我们进入 build 函数。让我们逐步了解它:
- 我们首先重置关卡的深度。波函数坍缩中存在一个错误,这使得它成为必要(它将在本章的修订版中修复)。
- 然后我们创建一个新的构建器!它被设置为生成一个非常普通的基于 BSP 的地牢,具有短而直接的走廊,然后侵蚀房间。
- 我们运行构建器,并将它的历史记录复制到我们的历史记录的末尾——这样我们也可以看到它采取的步骤。
- 我们将 BSP 地图的整个右半部分复制到我们实际构建的地图上。
- 我们从当前地图中删除地图右半部分的所有生成点。
- 如果 BSP 地图的所有生成点都在地图的右半部分,则将它们复制到当前地图。
所有这些的结果是什么?一个分裂的地牢!

我们依赖于两个半部分之间没有任何连接的几率非常低。为了确保万无一失,我们还添加一个不可达剔除循环并移除波形坍缩——它使得地图太有可能没有出口:
#![allow(unused)] fn main() { pub fn limestone_transition_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Dwarf Fort - Upper Reaches"); chain.start_with(CellularAutomataBuilder::new()); chain.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); chain.with(VoronoiSpawning::new()); chain.with(CaveDecorator::new()); chain.with(CaveTransition::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaEndingPosition::new(XEnd::RIGHT, YEnd::CENTER)); chain } }
等等——AreaEndingPosition 是新的!我想要一种保证出口在地图右侧的方法,所以我制作了一个新的构建器层。它就像 AreaStartingPosition,但设置的是楼梯而不是起点。它在文件 map_builders/area_ending_point.rs 中:
#![allow(unused)] fn main() { use super::{MetaMapBuilder, BuilderMap, TileType}; use crate::map; use rltk::RandomNumberGenerator; #[allow(dead_code)] pub enum XEnd { LEFT, CENTER, RIGHT } #[allow(dead_code)] pub enum YEnd{ TOP, CENTER, BOTTOM } pub struct AreaEndingPosition { x : XEnd, y : YEnd } impl MetaMapBuilder for AreaEndingPosition { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl AreaEndingPosition { #[allow(dead_code)] pub fn new(x : XEnd, y : YEnd) -> Box<AreaEndingPosition> { Box::new(AreaEndingPosition{ x, y }) } fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { let seed_x; let seed_y; match self.x { XEnd::LEFT => seed_x = 1, XEnd::CENTER => seed_x = build_data.map.width / 2, XEnd::RIGHT => seed_x = build_data.map.width - 2 } match self.y { YEnd::TOP => seed_y = 1, YEnd::CENTER => seed_y = build_data.map.height / 2, YEnd::BOTTOM => seed_y = build_data.map.height - 2 } let mut available_floors : Vec<(usize, f32)> = Vec::new(); for (idx, tiletype) in build_data.map.tiles.iter().enumerate() { if map::tile_walkable(*tiletype) { available_floors.push( ( idx, rltk::DistanceAlg::PythagorasSquared.distance2d( rltk::Point::new(idx as i32 % build_data.map.width, idx as i32 / build_data.map.width), rltk::Point::new(seed_x, seed_y) ) ) ); } } if available_floors.is_empty() { panic!("No valid floors to start on"); } available_floors.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); build_data.map.tiles[available_floors[0].0] = TileType::DownStairs; } } }
所以将所有这些放在一起并运行它——你将拥有一个与我们目标非常一致的地牢:

填充我们的新关卡
除了各种掉落物,例如口粮之外,这个关卡基本上是空的!我们限制了上一关的掉落物,这很好——我们希望开始向更偏“怪物”的关卡过渡。据称,堡垒的陷落是因为一条讨厌的龙(而不是友善的那种!),所以更多龙族爪牙是有道理的。希望到目前为止,玩家将接近 3 级或 4 级,因此我们可以向他们扔一些更难的怪物,而不会使游戏变得不可能。
在 spawns.json 中,在 spawn_table 部分——让我们为龙类生物添加一些占位符生成点:
{ "name" : "Dragon Wyrmling", "weight" : 1, "min_depth" : 5, "max_depth" : 7 },
{ "name" : "Lizardman", "weight" : 10, "min_depth" : 5, "max_depth" : 7 },
{ "name" : "Giant Lizard", "weight" : 4, "min_depth" : 5, "max_depth" : 7 }
考虑到这曾经是矮人的领地,让我们也添加一些矮人可能会留下的东西:
{ "name" : "Rock Golem", "weight" : 4, "min_depth" : 5, "max_depth" : 7 },
{ "name" : "Stonefall Trap", "weight" : 4, "min_depth" : 5, "max_depth" : 7 },
{ "name" : "Landmine", "weight" : 1, "min_depth" : 5, "max_depth" : 7 }
矮人也以其盔甲和武器而闻名,因此为他们的装备添加一些占位符听起来不错:
{ "name" : "Breastplate", "weight" : 7, "min_depth" : 5, "max_depth" : 7 },
{ "name" : "War Axe", "weight" : 7, "min_depth" : 5, "max_depth" : 7 },
{ "name" : "Dwarf-Steel Shirt", "weight" : 1, "min_depth" : 5, "max_depth" : 7 }
这总共有 9 个新实体要创建!我们将首先使用我们已经拥有的系统来构建它们,在未来的章节中,我们将为它们添加一些特殊效果。(“矮人钢”以前是“秘银”——但托尔金基金会过去以对这个词有点律师函成瘾而闻名。所以矮人钢就是矮人钢了!)
龙类生物
我们将首先在 spawns.json 中为它们提供一个新的阵营:
{ "name" : "Wyrm", "responses": { "Default" : "attack", "Wyrm" : "ignore" }}
我们还将稍微推断一下,并想出一些他们可能掉落的战利品(对于 loot_tables):
{ "name" : "Wyrms",
"drops" : [
{ "name" : "Dragon Scale", "weight" : 10 },
{ "name" : "Meat", "weight" : 10 }
]
}
现在,让我们进入 mobs 部分,制作我们的小龙:
{
"name" : "Dragon Wyrmling",
"renderable": {
"glyph" : "d",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 12,
"movement" : "random_waypoint",
"attributes" : {
"might" : 3,
"fitness" : 3
},
"skills" : {
"Melee" : 15,
"Defense" : 14
},
"natural" : {
"armor_class" : 15,
"attacks" : [
{ "name" : "bite", "hit_bonus" : 4, "damage" : "1d10+2" }
]
},
"loot_table" : "Wyrms",
"faction" : "Wyrm",
"level" : 3,
"gold" : "3d6"
}
即使没有特殊能力,这也是一个强大的敌人!TODO
我们绝对应该通过使蜥蜴人和巨蜥蜴相对较弱来抵消幼龙的强大本质(因为它们可能会更多):
{
"name" : "Lizardman",
"renderable": {
"glyph" : "l",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 4,
"movement" : "random_waypoint",
"attributes" : {},
"faction" : "Wyrm",
"gold" : "1d12",
"level" : 2
},
{
"name" : "Giant Lizard",
"renderable": {
"glyph" : "l",
"fg" : "#FFFF00",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 4,
"movement" : "random",
"attributes" : {},
"faction" : "Wyrm",
"level" : 2,
"loot_table" : "Animal"
}
我们还需要添加“龙鳞”作为一种非常值得奖励的商品。在 spawns.json 的 items 部分:
{
"name" : "Dragon Scale",
"renderable": {
"glyph" : "ß",
"fg" : "#FFD700",
"bg" : "#000000",
"order" : 2
},
"weight_lbs" : 2.0,
"base_value" : 75.0
},
矮人生成物
由于矮人已经死了(据推测他们又挖得太深了……),我们只有他们文明的一些遗留物需要处理。魔像、陷阱和地雷(哦,我的天!)。让我们为魔像创建一个新的阵营;他们应该不太喜欢龙,但让我们对玩家好一点,让他们被忽略:
{ "name" : "Dwarven Remnant", "responses": { "Default" : "attack", "Player" : "ignore", "Dwarven Remnant" : "ignore" }}
这使我们能够构建一个相对强大的魔像。它可以很强大,因为它将与蜥蜴战斗:
{
"name" : "Rock Golem",
"renderable": {
"glyph" : "g",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 6,
"movement" : "random_waypoint",
"attributes" : {},
"faction" : "Dwarven Remnant",
"level" : 3
}
落石陷阱和地雷就像一个格外危险的捕熊陷阱:
{
"name" : "Stonefall Trap",
"renderable": {
"glyph" : "^",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 2
},
"hidden" : true,
"entry_trigger" : {
"effects" : {
"damage" : "12",
"single_activation" : "1"
}
}
},
{
"name" : "Landmine",
"renderable": {
"glyph" : "^",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 2
},
"hidden" : true,
"entry_trigger" : {
"effects" : {
"damage" : "18",
"single_activation" : "1"
}
}
},
矮人战利品
这些只是 spawns.json 的 items 部分的更多物品:
{
"name" : "Breastplate",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Torso",
"armor_class" : 3.0
},
"weight_lbs" : 25.0,
"base_value" : 100.0,
"initiative_penalty" : 2.0,
"vendor_category" : "armor"
},
{
"name" : "Dwarf-Steel Shirt",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Torso",
"armor_class" : 3.0
},
"weight_lbs" : 5.0,
"base_value" : 500.0,
"initiative_penalty" : 0.0,
"vendor_category" : "armor"
},
{
"name" : "War Axe",
"renderable": {
"glyph" : "¶",
"fg" : "#FF55FF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "might",
"base_damage" : "1d12",
"hit_bonus" : 0
},
"weight_lbs" : 4.0,
"base_value" : 100.0,
"initiative_penalty" : 2,
"vendor_category" : "weapon"
},
总结
这个关卡仍然有点太容易杀死你,但它有效。我们将在接下来的章节中使事情变得更容易一些,所以我们暂时将难度保持在“钢铁侠”级别!
...
本章的源代码可以在这里找到
在您的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson。
城镇传送门
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
我们在设计文档中提到了城镇传送门,现在越来越明显它们会有多大帮助:长途跋涉回到城镇出售你辛苦赚来的战利品(并可能存钱升级来对抗那些小小的龙形杀手!)真的很累人。
城镇传送卷轴的基本思路很简单:你施放法术,一个传送门打开并将你送回城镇。你在城镇里做你的事情,然后返回传送门 - 它会把你传送回你原来的位置。取决于游戏,它可能会在你离开期间治愈该层级的怪物。通常,怪物不会跟随你穿过传送门(如果它们会,你可以用一个位置恰当的传送门摧毁城镇!)。
生成城镇传送卷轴
我们应该从在 spawns.json 中将它们定义为另一个物品开始:
{
"name" : "Town Portal Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#AAAAFF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"town_portal" : ""
}
},
"weight_lbs" : 0.5,
"base_value" : 20.0,
"vendor_category" : "alchemy"
},
我们还应该使它们在生成表中相当常见:
{ "name" : "Town Portal Scroll", "weight" : 4, "min_depth" : 0, "max_depth" : 100 },
这足以让它们进入游戏:它们作为掉落物生成,并且可以从城镇的炼金术士处购买(诚然,当您需要它们时,这并没有什么帮助,但是通过一些计划,它可以提供帮助!)。
实现城镇传送门
下一个阶段是让城镇传送门做一些事情。我们已经添加了一个 "effects" 标签,使其在使用时被消耗并查找该标签。其他效果使用组件来指示发生的事情;所以我们将打开 components.rs 并创建一个新的组件类型(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct TownPortal {} }
我们还需要打开 rawmaster.rs,并编辑 spawn_named_item 以添加标签:
#![allow(unused)] fn main() { if let Some(consumable) = &item_template.consumable { eb = eb.with(crate::components::Consumable{}); for effect in consumable.effects.iter() { let effect_name = effect.0.as_str(); match effect_name { "provides_healing" => { eb = eb.with(ProvidesHealing{ heal_amount: effect.1.parse::<i32>().unwrap() }) } "ranged" => { eb = eb.with(Ranged{ range: effect.1.parse::<i32>().unwrap() }) }, "damage" => { eb = eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }) } "area_of_effect" => { eb = eb.with(AreaOfEffect{ radius: effect.1.parse::<i32>().unwrap() }) } "confusion" => { eb = eb.with(Confusion{ turns: effect.1.parse::<i32>().unwrap() }) } "magic_mapping" => { eb = eb.with(MagicMapper{}) } "town_portal" => { eb = eb.with(TownPortal{}) } "food" => { eb = eb.with(ProvidesFood{}) } _ => { rltk::console::log(format!("Warning: consumable effect {} not implemented.", effect_name)); } } } } }
到目前为止,我们所有的层级转换都是通过 main.rs 中的 RunState 完成的。所以在 main.rs 中,我们将添加一个新的状态:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, Ticking, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, PreviousLevel, TownPortal, ShowRemoveItem, GameOver, MagicMapReveal { row : i32 }, MapGeneration, ShowCheatMenu, ShowVendor { vendor: Entity, mode : VendorMode } } }
这样就标记了效果。现在我们需要让它发挥作用!打开 inventory_system.rs,我们想要编辑 ItemUseSystem。在魔法地图之后,以下代码只是记录一个事件,消耗物品并更改游戏状态:
#![allow(unused)] fn main() { // 如果是城镇传送门... if let Some(_townportal) = town_portal.get(useitem.item) { if map.depth == 1 { gamelog.entries.push("You are already in town, so the scroll does nothing.".to_string()); } else { used_item = true; gamelog.entries.push("You are telported back to town!".to_string()); *runstate = RunState::TownPortal; } } }
接下来是在 main.rs 中处理状态:
#![allow(unused)] fn main() { RunState::TownPortal => { // 生成传送门 spawner::spawn_town_portal(&mut self.ecs); // 转换 let map_depth = self.ecs.fetch::<Map>().depth; let destination_offset = 0 - (map_depth-1); self.goto_level(destination_offset); self.mapgen_next_state = Some(RunState::PreRun); newrunstate = RunState::MapGeneration; } }
这相对简单:它调用尚未编写的 spawn_town_portal 函数,检索深度,并使用与 NextLevel 和 PreviousLevel 相同的逻辑切换到城镇层级(计算偏移量以产生深度为 1)。
我们还需要修改 Ticking 处理程序,以允许 TownPortal 从循环中退出:
#![allow(unused)] fn main() { RunState::Ticking => { while newrunstate == RunState::Ticking { self.run_systems(); self.ecs.maintain(); match *self.ecs.fetch::<RunState>() { RunState::AwaitingInput => newrunstate = RunState::AwaitingInput, RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 }, RunState::TownPortal => newrunstate = RunState::TownPortal, _ => newrunstate = RunState::Ticking } } } }
兔子洞自然而然地将我们引向 spawner.rs,以及 spawn_town_portal 函数。让我们编写它:
#![allow(unused)] fn main() { pub fn spawn_town_portal(ecs: &mut World) { // 获取当前位置和深度 let map = ecs.fetch::<Map>(); let player_depth = map.depth; let player_pos = ecs.fetch::<rltk::Point>(); let player_x = player_pos.x; let player_y = player_pos.y; std::mem::drop(player_pos); std::mem::drop(map); // 找到城镇中传送门的位置 let dm = ecs.fetch::<MasterDungeonMap>(); let town_map = dm.get_map(1).unwrap(); let mut stairs_idx = 0; for (idx, tt) in town_map.tiles.iter().enumerate() { if *tt == TileType::DownStairs { stairs_idx = idx; } } let portal_x = (stairs_idx as i32 % town_map.width)-2; let portal_y = stairs_idx as i32 / town_map.width; std::mem::drop(dm); // 生成传送门本身 ecs.create_entity() .with(OtherLevelPosition { x: portal_x, y: portal_y, depth: 1 }) .with(Renderable { glyph: rltk::to_cp437('♥'), fg: RGB::named(rltk::CYAN), bg: RGB::named(rltk::BLACK), render_order: 0 }) .with(EntryTrigger{}) .with(TeleportTo{ x: player_x, y: player_y, depth: player_depth, player_only: true }) .with(Name{ name : "Town Portal".to_string() }) .with(SingleActivation{}) .build(); } }
这是一个繁忙的函数,因此我们将逐步介绍它:
- 我们检索玩家的深度和位置,然后释放对资源的访问(以防止借用继续)。
- 我们在
MasterDungeonMap中查找城镇地图,并找到生成点。我们将传送门向西移动两个图块,并将它存储为portal_x和portal_y。然后我们再次释放对地下城地图的访问,以避免保持借用。 - 我们为传送门创建一个实体。我们给它一个
OtherLevelPosition,表明它在城镇中 - 在我们计算的坐标处。我们给它一个Renderable(青色的心形),一个Name(以便它显示在工具提示中)。我们还给它一个EntryTrigger- 所以进入它将触发一个效果。最后,我们给它一个TeleportTo组件;我们还没有编写它,但是您可以看到我们正在指定目的地坐标(回到玩家开始的地方)。还有一个player_only设置 - 如果传送器对每个人都有效,城镇醉汉可能会错误地走进传送门,导致(可笑的)情况,他们被传送到地下城并可怕地死去。为了避免这种情况,我们将使这个传送器仅对玩家有效!
既然我们已经使用了它,我们最好在 components.rs 中创建 TeleportTo(并在 main.rs 和 saveload_system.rs 中注册)。它非常简单:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct TeleportTo { pub x: i32, pub y: i32, pub depth: i32, pub player_only : bool } }
我们将稍后担心如何使传送器工作。
为了帮助测试系统,我们将让玩家以城镇传送卷轴开始。在 spawner.rs 中,我们将修改 player:
#![allow(unused)] fn main() { spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Town Portal Scroll", SpawnType::Carried{by : player}); }
如果您现在 cargo run,您将以一个 Town Portal Scroll 开始。尝试在城镇中使用它会给您“没有效果”的消息。前往另一个层级,然后使用它会将您传送回城镇,并出现一个传送门 - 正是我们想要的(但还没有返回的方式):

实现传送器
现在我们需要使传送门返回你在地下城中的起始点。由于我们已经实现了可以拥有 TeleportTo 的触发器,因此值得花时间使传送触发器更通用(例如,您可以拥有传送陷阱 - 或房间之间的传送器,甚至通往最终层级的传送门)。实际上这里有很多需要考虑的:
- 除非您将其标记为“仅限玩家”,否则传送器会影响任何进入图块的人。
- 传送可能发生在当前层级中,在这种情况下,它就像常规移动。
- 传送也可能跨层级发生,在这种情况下,有两种可能性:
- 玩家正在传送,我们需要像其他层级转换一样调整游戏状态。
- 另一个实体正在传送,在这种情况下,我们需要删除其
Position组件并添加一个OtherLevelPosition组件,以便当玩家到达那里时,它们就位。
总体上清理移动
我们看到越来越多的地方实现了相同的基本移动代码:清除阻塞,移动,恢复阻塞。您可以在各处找到它,并且添加传送只会使其更加复杂(随着我们制作更大的游戏,其他系统也会如此)。这使得忘记更新某些内容变得太容易,并且还会使许多系统与可变的 position 和 map 访问混淆 - 而移动是他们需要写入访问的唯一原因。
对于大多数其他操作,我们都使用了基于意图的组件 - 移动也不应例外。打开 components.rs,我们将创建一些新组件(并在 main.rs 和 saveload_system.rs 中注册它们):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct ApplyMove { pub dest_idx : usize } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct ApplyTeleport { pub dest_x : i32, pub dest_y : i32, pub dest_depth : i32 } }
为了处理这些,让我们创建一个新的系统文件 - movement_system.rs:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Map, Position, BlocksTile, ApplyMove, ApplyTeleport, OtherLevelPosition, EntityMoved, Viewshed}; pub struct MovementSystem {} impl<'a> System<'a> for MovementSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, WriteStorage<'a, Position>, ReadStorage<'a, BlocksTile>, Entities<'a>, WriteStorage<'a, ApplyMove>, WriteStorage<'a, ApplyTeleport>, WriteStorage<'a, OtherLevelPosition>, WriteStorage<'a, EntityMoved>, WriteStorage<'a, Viewshed>, ReadExpect<'a, Entity>); fn run(&mut self, data : Self::SystemData) { let (mut map, mut position, blockers, entities, mut apply_move, mut apply_teleport, mut other_level, mut moved, mut viewsheds, player_entity) = data; // 应用传送 for (entity, teleport) in (&entities, &apply_teleport).join() { if teleport.dest_depth == map.depth { apply_move.insert(entity, ApplyMove{ dest_idx: map.xy_idx(teleport.dest_x, teleport.dest_y) }) .expect("Unable to insert"); } else if entity == *player_entity { // 这是玩家 - 我们遇到了麻烦 rltk::console::log(format!("Not implemented yet.")); } else if let Some(pos) = position.get(entity) { let idx = map.xy_idx(pos.x, pos.y); let dest_idx = map.xy_idx(teleport.dest_x, teleport.dest_y); crate::spatial::move_entity(entity, idx, dest_idx); other_level.insert(entity, OtherLevelPosition{ x: teleport.dest_x, y: teleport.dest_y, depth: teleport.dest_depth }) .expect("Unable to insert"); position.remove(entity); } } apply_teleport.clear(); // 应用广泛的移动 for (entity, movement, mut pos) in (&entities, &apply_move, &mut position).join() { let start_idx = map.xy_idx(pos.x, pos.y); let dest_idx = movement.dest_idx as usize; crate::spatial::move_entity(entity, start_idx, dest_idx); pos.x = movement.dest_idx as i32 % map.width; pos.y = movement.dest_idx as i32 / map.width; if let Some(vs) = viewsheds.get_mut(entity) { vs.dirty = true; } moved.insert(entity, EntityMoved{}).expect("Unable to insert"); } apply_move.clear(); } } }
这是一个内容丰富的系统,但您应该非常熟悉它 - 它没有做太多我们以前没有做过的事情,它只是将其集中在一个地方。让我们逐步了解它:
- 我们迭代所有标记为传送的实体。
- 如果是在当前深度上的传送,我们添加一个
apply_move组件来指示我们正在跨地图移动。 - 如果不是本地传送:
- 如果是玩家,我们暂时放弃(代码在本章稍后部分)。
- 如果不是玩家,我们删除他们的
Position组件并添加一个OtherLevelPosition组件,以将实体移动到传送目的地。
- 如果是在当前深度上的传送,我们添加一个
- 我们删除所有传送意图,因为我们已经处理了它们。
- 我们迭代所有带有
ApplyMove组件的实体。- 我们获取移动的起始和目标索引。
- 如果实体阻塞了图块,我们清除源图块中的阻塞,并在目标图块中设置阻塞状态。
- 我们将实体移动到目的地。
- 如果实体具有视野,我们将其标记为脏。
- 我们应用一个
EntityMoved组件。
您会注意到,这几乎与我们在其他系统中一直在做的事情完全相同 - 但它更具条件性:没有视野的实体可以移动,不阻塞图块的实体不会阻塞图块。
然后我们可以更新 ai/approach_system.rs,ai/chase_ai_system.rs,ai/default_move_system.rs 和 ai/flee_ai_system.rs,使其不再计算移动,而是为他们正在考虑的实体设置一个 ApplyMove 组件。这大大简化了系统,减少了大量的写入访问和几个完整的组件访问!系统没有改变它们的逻辑 - 只是它们的功能。与其在这里复制/粘贴所有内容,不如查看源代码 - 否则这将是创纪录长度的一章!
最后,我们需要将移动添加到 main.rs 中的 run_systems 中。在 defaultmove 之后和 triggers 之前添加它:
#![allow(unused)] fn main() { defaultmove.run_now(&self.ecs); let mut moving = movement_system::MovementSystem{}; moving.run_now(&self.ecs); let mut triggers = trigger_system::TriggerSystem{}; }
完成这些更改后,您可以 cargo run - 并看到事物的行为与以前相同。
使玩家传送工作
当玩家进入传送器时,我们不应该只是打印“尚未支持!”,而应该真正地传送他们!之所以在 movement_system.rs 中对此进行了特殊处理,是因为我们始终在主循环中处理层级转换(因为它们涉及到大量游戏状态)。因此,为了使此功能起作用,我们需要 main.rs 中的另一个状态:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, Ticking, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, PreviousLevel, TownPortal, ShowRemoveItem, GameOver, MagicMapReveal { row : i32 }, MapGeneration, ShowCheatMenu, ShowVendor { vendor: Entity, mode : VendorMode }, TeleportingToOtherLevel { x: i32, y: i32, depth: i32 } } }
现在我们可以打开 movement_system.rs 并进行一些简单的更改,以使系统发出 RunState 更改:
#![allow(unused)] fn main() { impl<'a> System<'a> for MovementSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, WriteStorage<'a, Position>, ReadStorage<'a, BlocksTile>, Entities<'a>, WriteStorage<'a, ApplyMove>, WriteStorage<'a, ApplyTeleport>, WriteStorage<'a, OtherLevelPosition>, WriteStorage<'a, EntityMoved>, WriteStorage<'a, Viewshed>, ReadExpect<'a, Entity>, WriteExpect<'a, RunState>); fn run(&mut self, data : Self::SystemData) { let (mut map, mut position, blockers, entities, mut apply_move, mut apply_teleport, mut other_level, mut moved, mut viewsheds, player_entity, mut runstate) = data; // 应用传送 for (entity, teleport) in (&entities, &apply_teleport).join() { if teleport.dest_depth == map.depth { apply_move.insert(entity, ApplyMove{ dest_idx: map.xy_idx(teleport.dest_x, teleport.dest_y) }) .expect("Unable to insert"); } else if entity == *player_entity { *runstate = RunState::TeleportingToOtherLevel{ x: teleport.dest_x, y: teleport.dest_y, depth: teleport.dest_depth }; ... }
在 main.rs 中,让我们修改 Ticking 状态,使其也接受 TeleportingToOtherLevel 作为退出条件:
#![allow(unused)] fn main() { RunState::Ticking => { while newrunstate == RunState::Ticking { self.run_systems(); self.ecs.maintain(); match *self.ecs.fetch::<RunState>() { RunState::AwaitingInput => newrunstate = RunState::AwaitingInput, RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 }, RunState::TownPortal => newrunstate = RunState::TownPortal, RunState::TeleportingToOtherLevel{ x, y, depth } => newrunstate = RunState::TeleportingToOtherLevel{ x, y, depth }, _ => newrunstate = RunState::Ticking } } } }
现在在 trigger_system.rs 中,我们需要进行一些更改,以便在触发时实际调用传送:
#![allow(unused)] fn main() { impl<'a> System<'a> for TriggerSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Map>, WriteStorage<'a, EntityMoved>, ReadStorage<'a, Position>, ReadStorage<'a, EntryTrigger>, WriteStorage<'a, Hidden>, ReadStorage<'a, Name>, Entities<'a>, WriteExpect<'a, GameLog>, ReadStorage<'a, InflictsDamage>, WriteExpect<'a, ParticleBuilder>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, SingleActivation>, ReadStorage<'a, TeleportTo>, WriteStorage<'a, ApplyTeleport>, ReadExpect<'a, Entity>); fn run(&mut self, data : Self::SystemData) { let (map, mut entity_moved, position, entry_trigger, mut hidden, names, entities, mut log, inflicts_damage, mut particle_builder, mut inflict_damage, single_activation, teleporters, mut apply_teleport, player_entity) = data; ... // 如果是传送器,则执行传送 if let Some(teleport) = teleporters.get(*entity_id) { if (teleport.player_only && entity == *player_entity) || !teleport.player_only { apply_teleport.insert(entity, ApplyTeleport{ dest_x : teleport.x, dest_y : teleport.y, dest_depth : teleport.depth }).expect("Unable to insert"); } } }
完成上述操作后,我们需要完成 main.rs 并将 TeleportingToOtherLevel 添加到主循环中:
#![allow(unused)] fn main() { RunState::TeleportingToOtherLevel{x, y, depth} => { self.goto_level(depth-1); let player_entity = self.ecs.fetch::<Entity>(); if let Some(pos) = self.ecs.write_storage::<Position>().get_mut(*player_entity) { pos.x = x; pos.y = y; } let mut ppos = self.ecs.fetch_mut::<rltk::Point>(); ppos.x = x; ppos.y = y; self.mapgen_next_state = Some(RunState::PreRun); newrunstate = RunState::MapGeneration; } }
因此,这会将玩家发送到指定的层级,更新他们的 Position 组件,并更新存储的玩家位置(覆盖楼梯查找)。
如果您现在 cargo run,您将拥有一个可用的城镇传送门!

幽默的旁白
让我们看看当我们从城镇传送门中删除 player_only 和 SingleActivation 安全措施时会发生什么。在 spawner.rs 中:
#![allow(unused)] fn main() { ecs.create_entity() .with(OtherLevelPosition { x: portal_x, y: portal_y, depth: 1 }) .with(Renderable { glyph: rltk::to_cp437('♥'), fg: RGB::named(rltk::CYAN), bg: RGB::named(rltk::BLACK), render_order: 0 }) .with(EntryTrigger{}) .with(TeleportTo{ x: player_x, y: player_y, depth: player_depth, player_only: false }) // .with(SingleActivation{}) .with(Name{ name : "Town Portal".to_string() }) .build(); }
现在 cargo run,找到一个危险的地方,然后城镇传送门回家。在那里闲逛一会儿,直到一些无辜的镇民掉入传送门。然后跟随传送门返回,惊慌失措的镇民遭受了可怕的死亡!

我包含这个作为说明,说明了我们为什么要加入安全措施!
确保在您完成观看发生的事情后删除这些注释标签!
总结
在本章中,我们开始创建城镇传送门 - 并最终得到了一个通用的传送系统和一个清理后的移动系统。这为玩家提供了更多的战术选择,并启用了“抓取战利品,返回并出售”的游戏机制(如 Diablo 中所见)。我们越来越接近设计文档中描述的游戏了!
...
本章的源代码可以在这里找到
使用 web assembly 在您的浏览器中运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson.
魔法物品与物品辨识
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出精彩的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
魔法物品是 D&D 和 roguelike 游戏的中流砥柱。从不起眼的 “+1 剑” 到强大的 “圣复仇者” - 再到 “诅咒反噬者” - 物品帮助定义了这个游戏类型。在 roguelike 游戏中,不自动知晓物品是什么也是一种传统;你找到一把 “未鉴定的长剑”,在找到鉴定它的方法之前,你不知道它有什么用(或者是否被诅咒)。你找到一张 “猫咪在键盘上走过 卷轴”(无法发音的名字似乎是其特色!),在你鉴定或阅读它之前 - 你不知道会发生什么。有些游戏将此变成完整的元游戏 - 通过赌频率、供应商价格和类似信息来给你关于你刚刚找到的东西的线索。即使是 暗黑破坏神,最主流的 roguelike 游戏(即使它变成了实时制!)也保留了这个游戏特色 - 但往往会使鉴定卷轴非常丰富(还有乐于助人的苏格兰老人们)。
魔法物品的类别
在现代游戏中,区分魔法物品为 魔法的、稀有的 或 传说的(以及物品套装,我们暂且不讨论)。这些通常通过颜色来区分,因此您可以一目了然地判断物品是否值得考虑。这也提供了一个机会来表示某物 是 魔法物品 - 因此我们将打开 components.rs(并在 main.rs 和 saveload_system.rs 中注册)并创建 MagicItem:
#![allow(unused)] fn main() { #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)] pub enum MagicItemClass { Common, Rare, Legendary } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct MagicItem { pub class : MagicItemClass } }
下一步是让物品被标记为魔法物品,并拥有这些类别之一。将以下内容添加到 raws/item_structs.rs:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Item { pub name : String, pub renderable : Option<Renderable>, pub consumable : Option<Consumable>, pub weapon : Option<Weapon>, pub wearable : Option<Wearable>, pub initiative_penalty : Option<f32>, pub weight_lbs : Option<f32>, pub base_value : Option<f32>, pub vendor_category : Option<String>, pub magic : Option<MagicItem> } #[derive(Deserialize, Debug)] pub struct MagicItem { pub class: String } }
为什么我们在这里使用完整的结构体,而不是仅仅一个字符串?当我们开始充实魔法物品时,我们稍后将在本章中想要指定更多信息。
现在你可以在 spawns.json 中装饰物品,例如:
{
"name" : "Health Potion",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "provides_healing" : "8" }
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common" }
},
我已经将 common 魔法标签添加到 JSON 列表中的魔法卷轴和药剂中,详情请参阅源代码 - 目前这非常简单。接下来,我们需要修改 raws/rawmaster.rs 中的 spawn_named_item 以应用适当的组件标签:
#![allow(unused)] fn main() { if let Some(magic) = &item_template.magic { let class = match magic.class.as_str() { "rare" => MagicItemClass::Rare, "legendary" => MagicItemClass::Legendary, _ => MagicItemClass::Common }; eb = eb.with(MagicItem{ class }); } }
现在我们有了这些数据,我们需要 使用 它。目前,我们只想设置物品名称在 GUI 中出现时的显示 颜色 - 以更好地了解魔法物品的价值(就像所有那些 MMO 游戏一样!)。在 gui.rs 中,我们将为此目的创建一个通用函数:
#![allow(unused)] fn main() { pub fn get_item_color(ecs : &World, item : Entity) -> RGB { if let Some(magic) = ecs.read_storage::<MagicItem>().get(item) { match magic.class { MagicItemClass::Common => return RGB::from_f32(0.5, 1.0, 0.5), MagicItemClass::Rare => return RGB::from_f32(0.0, 1.0, 1.0), MagicItemClass::Legendary => return RGB::from_f32(0.71, 0.15, 0.93) } } RGB::from_f32(1.0, 1.0, 1.0) } }
现在我们需要遍历 gui.rs 中所有显示物品名称的函数,并将硬编码的颜色替换为对这个函数的调用。在 draw_ui 中(gui.rs 的第 121 行),稍微展开一下已装备列表:
#![allow(unused)] fn main() { // Equipped let mut y = 13; let entities = ecs.entities(); let equipped = ecs.read_storage::<Equipped>(); let name = ecs.read_storage::<Name>(); for (entity, equipped_by, item_name) in (&entities, &equipped, &name).join() { if equipped_by.owner == *player_entity { ctx.print_color(50, y, get_item_color(ecs, entity), black, &item_name.name); y += 1; } } }
消耗品部分也进行相同的更改:
#![allow(unused)] fn main() { ctx.print_color(53, y, get_item_color(ecs, entity), black, &item_name.name); }
我们将暂时不修改工具提示,改进它们(和日志)是(目前假设的)未来章节的主题。在 show_inventory(大约第 321 行)、drop_item_menu(大约第 373 行)、remove_item_menu(大约第 417 行)和 vendor_sell_menu(大约第 660 行):
#![allow(unused)] fn main() { ctx.print_color(21, y, get_item_color(&gs.ecs, entity), RGB::from_f32(0.0, 0.0, 0.0), &name.name.to_string()); }
请注意:一旦我们添加物品辨识,这些行将 再次 更改!
完成这些后,如果您 cargo run,您将看到您的 城镇传送卷轴 现在已被很好地突出显示为普通魔法物品:

辨识:卷轴
在 Roguelike 游戏中,当你不知道药剂的作用时,药剂具有完全无法发音的名称是很常见的。据推测,这代表了某种喉音,可以触发魔法效果(并且围绕此构建一个巨大的语法会非常有趣,但本教程会变得更大!)。因此,一张 Lorem Ipsum 卷轴 可能是游戏中的 任何 卷轴,这取决于您决定通过使用它来辨识(一种赌博,它可能根本不是您想要的!),对其进行鉴定,或者只是忽略它,因为您不喜欢这种风险。
让我们从打开 spawner.rs 开始,转到 player 函数并删除赠送免费 城镇传送 的行。它太慷慨了,并且意味着你一开始就知道它是什么!
所以这里是有趣的部分:如果我们只是简单地为卷轴分配一个未鉴定的名称,玩家可以简单地记住这些名称 - 并且辨识将只不过是一个记忆游戏。因此,我们需要在 游戏开始时 分配名称(而不是在加载原始文件时,因为您可能在每个会话中玩多次)。让我们从 raws/item_structs.rs 开始,并在 MagicItem 中添加另一个字段,指示 “这是一个卷轴,应该使用卷轴命名。”
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct MagicItem { pub class: String, pub naming: String } }
现在我们必须遍历 spawns.json 并在我们的 “magic” 条目中添加命名标签。我为命名卷轴选择了 “scroll”(并将其他名称留空)。例如,这是魔法飞弹卷轴:
{
"name" : "Magic Missile Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20"
}
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "scroll" }
},
我们已经有一个贯穿整个游戏(但仍然是全局资源)的结构,并且在每次我们更改关卡时都会重置:MasterDungeonMap。使用它来存储关于整个游戏的状态是有意义的,因为它已经是地下城主了!我们也在序列化它,这很有帮助!因此我们将打开 map/dungeon.rs 并添加几个结构:
#![allow(unused)] fn main() { #[derive(Default, Serialize, Deserialize, Clone)] pub struct MasterDungeonMap { maps : HashMap<i32, Map>, identified_items : HashSet<String>, scroll_mappings : HashMap<String, String> } }
我们还必须更新构造函数以提供空值(目前):
#![allow(unused)] fn main() { impl MasterDungeonMap { pub fn new() -> MasterDungeonMap { MasterDungeonMap{ maps: HashMap::new() , pub identified_items : HashSet::new(), pub scroll_mappings : HashMap::new() } } }
这个想法是,当物品被鉴定时,我们将把它的名称标签放入 identified_items 中,从而提供一种快速的方法来判断物品是否已被鉴定。scroll_mappings 旨在将卷轴的实际名称与随机名称映射。这些将持续整个游戏会话期间(并自动包含在保存游戏中!)。为了填充卷轴映射,我们需要一种方法来获取原始文件中标记为卷轴的物品名称。因此,在 raws/rawmaster.rs 中,我们将创建一个新函数:
#![allow(unused)] fn main() { pub fn get_scroll_tags() -> Vec<String> { let raws = &super::RAWS.lock().unwrap(); let mut result = Vec::new(); for item in raws.raws.items.iter() { if let Some(magic) = &item.magic { if &magic.naming == "scroll" { result.push(item.name.clone()); } } } result } }
这获取了对全局 raws 的访问权限,遍历所有物品以查找具有 scroll 命名约定的魔法物品,并将名称作为字符串向量返回。我们不会经常这样做,因此我们不会尝试在性能方面做得聪明(克隆所有这些字符串有点慢)。因此,现在在 map/dungeon.rs 中,我们进一步扩展构造函数以进行卷轴名称映射:
#![allow(unused)] fn main() { impl MasterDungeonMap { pub fn new() -> MasterDungeonMap { let mut dm = MasterDungeonMap{ maps: HashMap::new() , identified_items : HashSet::new(), scroll_mappings : HashMap::new() }; let mut rng = rltk::RandomNumberGenerator::new(); for scroll_tag in crate::raws::get_scroll_tags().iter() { let masked_name = make_scroll_name(&mut rng); dm.scroll_mappings.insert(scroll_tag.to_string(), masked_name); } dm } }
这引用了一个新函数 make_scroll_name,它看起来像这样:
#![allow(unused)] fn main() { fn make_scroll_name(rng: &mut rltk::RandomNumberGenerator) -> String { let length = 4 + rng.roll_dice(1, 4); let mut name = "Scroll of ".to_string(); for i in 0..length { if i % 2 == 0 { name += match rng.roll_dice(1, 5) { 1 => "a", 2 => "e", 3 => "i", 4 => "o", _ => "u" } } else { name += match rng.roll_dice(1, 21) { 1 => "b", 2 => "c", 3 => "d", 4 => "f", 5 => "g", 6 => "h", 7 => "j", 8 => "k", 9 => "l", 10 => "m", 11 => "n", 12 => "p", 13 => "q", 14 => "r", 15 => "s", 16 => "t", 17 => "v", 18 => "w", 19 => "x", 20 => "y", _ => "z" } } } name } }
此函数以词干 “Scroll of ” 开头,然后添加随机字母。每隔一个字母是元音,中间是辅音。这会让你得到无意义的词,但它是 可发音的 无意义词,例如 iladi 或 omuruxo。它没有提供关于底层卷轴性质的任何线索。
接下来,我们需要一种方法来表示实体 具有 混淆的名称。我们将为此任务创建一个新组件,因此在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册)我们添加:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct ObfuscatedName { pub name : String } }
当我们生成物品时,我们也需要添加这些标签,因此在 raws/rawmaster.rs 中,我们将以下内容添加到 spawn_named_item 中。首先,在顶部,我们复制名称映射(以避免借用问题):
#![allow(unused)] fn main() { let item_template = &raws.raws.items[raws.item_index[key]]; let scroll_names = ecs.fetch::<crate::map::MasterDungeonMap>().scroll_mappings.clone(); let mut eb = ecs.create_entity().marked::<SimpleMarker<SerializeMe>>(); }
然后我们扩展 magic 处理程序:
#![allow(unused)] fn main() { if let Some(magic) = &item_template.magic { let class = match magic.class.as_str() { "rare" => MagicItemClass::Rare, "legendary" => MagicItemClass::Legendary, _ => MagicItemClass::Common }; eb = eb.with(MagicItem{ class }); #[allow(clippy::single_match)] // 为了阻止 Clippy 在我们添加更多之前抱怨 match magic.naming.as_str() { "scroll" => { eb = eb.with(ObfuscatedName{ name : scroll_names[&item_template.name].clone() }); } _ => {} } } }
现在,我们回到 gui.rs 并创建一个新函数来获取物品的显示名称:
#![allow(unused)] fn main() { pub fn get_item_display_name(ecs: &World, item : Entity) -> String { if let Some(name) = ecs.read_storage::<Name>().get(item) { if ecs.read_storage::<MagicItem>().get(item).is_some() { let dm = ecs.fetch::<crate::map::MasterDungeonMap>(); if dm.identified_items.contains(&name.name) { name.name.clone() } else if let Some(obfuscated) = ecs.read_storage::<ObfuscatedName>().get(item) { obfuscated.name.clone() } else { "Unidentified magic item".to_string() } } else { name.name.clone() } } else { "Nameless item (bug)".to_string() } } }
再一次,我们需要遍历 gui.rs 中所有引用物品名称的地方,并将它们更改为使用此函数。在 draw_ui 中,这实际上缩短了一些代码,因为我们不再需要实际的 Name 组件:
#![allow(unused)] fn main() { // Equipped let mut y = 13; let entities = ecs.entities(); let equipped = ecs.read_storage::<Equipped>(); for (entity, equipped_by) in (&entities, &equipped).join() { if equipped_by.owner == *player_entity { ctx.print_color(50, y, get_item_color(ecs, entity), black, &get_item_display_name(ecs, entity)); y += 1; } } // Consumables y += 1; let yellow = RGB::named(rltk::YELLOW); let consumables = ecs.read_storage::<Consumable>(); let backpack = ecs.read_storage::<InBackpack>(); let mut index = 1; for (entity, carried_by, _consumable) in (&entities, &backpack, &consumables).join() { if carried_by.owner == *player_entity && index < 10 { ctx.print_color(50, y, yellow, black, &format!("↑{}", index)); ctx.print_color(53, y, get_item_color(ecs, entity), black, &get_item_display_name(ecs, entity)); y += 1; index += 1; } } }
再一次,我们将稍后担心工具提示(尽管我们稍后会调整它们,使其不会泄露对象的实际身份!)。我们之前更改的其他项更改为 ctx.print_color(21, y, get_item_color(&gs.ecs, entity), RGB::from_f32(0.0, 0.0, 0.0), &get_item_display_name(&gs.ecs, entity));,也可以删除一些 name 组件。
完成这些之后,如果您 cargo run 该项目,您找到的卷轴将显示混淆的名称:

鉴定混淆的卷轴
现在我们正确地隐藏了它们,让我们引入一种鉴定卷轴的机制。最明显的是,如果您 使用 卷轴,它应该被鉴定 - 并且该卷轴类型的所有现有/未来实例都将被鉴定。identified_items 列表处理未来,但我们将不得不做一些额外的工作来处理现有卷轴。我们将会有相当多的潜在鉴定发生 - 当您使用鉴定魔法(最终),当您使用或装备物品,当您购买物品时(因为您知道您正在购买魔法地图卷轴,您对其进行鉴定是有道理的) - 并且随着我们的进展,可能会有更多。
我们将使用一个新组件来处理这个问题,以指示物品可能已被鉴定,并使用一个系统来处理数据。首先,在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct IdentifiedItem { pub name : String } }
现在我们将转到我们已经拥有的可以鉴定物品的各种位置,并在玩家使用物品时将此组件附加到玩家。首先,扩展 inventory_system.rs 以能够写入适当的存储:
#![allow(unused)] fn main() { impl<'a> System<'a> for ItemUseSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, WriteExpect<'a, Map>, Entities<'a>, WriteStorage<'a, WantsToUseItem>, ReadStorage<'a, Name>, ReadStorage<'a, Consumable>, ReadStorage<'a, ProvidesHealing>, ReadStorage<'a, InflictsDamage>, WriteStorage<'a, Pools>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, AreaOfEffect>, WriteStorage<'a, Confusion>, ReadStorage<'a, Equippable>, WriteStorage<'a, Equipped>, WriteStorage<'a, InBackpack>, WriteExpect<'a, ParticleBuilder>, ReadStorage<'a, Position>, ReadStorage<'a, ProvidesFood>, WriteStorage<'a, HungerClock>, ReadStorage<'a, MagicMapper>, WriteExpect<'a, RunState>, WriteStorage<'a, EquipmentChanged>, ReadStorage<'a, TownPortal>, WriteStorage<'a, IdentifiedItem> ); #[allow(clippy::cognitive_complexity)] fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, map, entities, mut wants_use, names, consumables, healing, inflict_damage, mut combat_stats, mut suffer_damage, aoe, mut confused, equippable, mut equipped, mut backpack, mut particle_builder, positions, provides_food, mut hunger_clocks, magic_mapper, mut runstate, mut dirty, town_portal, mut identified_item) = data; ... }
然后,在定位之后(第 113 行):
#![allow(unused)] fn main() { // Identify if entity == *player_entity { identified_item.insert(entity, IdentifiedItem{ name: names.get(useitem.item).unwrap().name.clone() }) .expect("Unable to insert"); } }
此外,在 main.rs 中,当我们处理生成购买的物品时,我们也应该鉴定这些物品:
#![allow(unused)] fn main() { gui::VendorResult::Buy => { let tag = result.2.unwrap(); let price = result.3.unwrap(); let mut pools = self.ecs.write_storage::<Pools>(); let player_entity = self.ecs.fetch::<Entity>(); let mut identified = self.ecs.write_storage::<IdentifiedItem>(); identified.insert(*player_entity, IdentifiedItem{ name : tag.clone() }).expect("Unable to insert"); std::mem::drop(identified); let player_pools = pools.get_mut(*player_entity).unwrap(); std::mem::drop(player_entity); if player_pools.gold >= price { player_pools.gold -= price; std::mem::drop(pools); let player_entity = *self.ecs.fetch::<Entity>(); crate::raws::spawn_named_item(&RAWS.lock().unwrap(), &mut self.ecs, &tag, SpawnType::Carried{ by: player_entity }); } } }
现在我们正在添加组件,我们需要读取它们并利用这些知识做些事情!
我们需要在 raws/rawmaster.rs 中再添加一个辅助函数来帮助这个过程:
#![allow(unused)] fn main() { pub fn is_tag_magic(tag : &str) -> bool { let raws = &super::RAWS.lock().unwrap(); if raws.item_index.contains_key(tag) { let item_template = &raws.raws.items[raws.item_index[tag]]; item_template.magic.is_some() } else { false } } }
由于鉴定物品纯粹是一个物品栏问题,我们将在已经很大的 inventory_system.rs 中添加另一个系统(注意到有一天我们会把它变成一个模块的暗示了吗?):
#![allow(unused)] fn main() { pub struct ItemIdentificationSystem {} impl<'a> System<'a> for ItemIdentificationSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadStorage<'a, crate::components::Player>, WriteStorage<'a, IdentifiedItem>, WriteExpect<'a, crate::map::MasterDungeonMap>, ReadStorage<'a, Item>, ReadStorage<'a, Name>, WriteStorage<'a, ObfuscatedName>, Entities<'a> ); fn run(&mut self, data : Self::SystemData) { let (player, mut identified, mut dm, items, names, mut obfuscated_names, entities) = data; for (_p, id) in (&player, &identified).join() { if !dm.identified_items.contains(&id.name) && crate::raws::is_tag_magic(&id.name) { dm.identified_items.insert(id.name.clone()); for (entity, _item, name) in (&entities, &items, &names).join() { if name.name == id.name { obfuscated_names.remove(entity); } } } } // Clean up identified.clear(); } } }
我们还需要修改 raws/rawmaster.rs 中的 spawn_named_item,使其不混淆我们已经识别的物品的名称。我们将首先获取已鉴定物品列表:
#![allow(unused)] fn main() { let dm = ecs.fetch::<crate::map::MasterDungeonMap>(); let scroll_names = dm.scroll_mappings.clone(); let identified = dm.identified_items.clone(); std::mem::drop(dm); }
然后我们将使名称混淆取决于现在是否知道物品是什么:
#![allow(unused)] fn main() { if !identified.contains(&item_template.name) { #[allow(clippy::single_match)] // 为了阻止 Clippy 在我们添加更多之前抱怨 match magic.naming.as_str() { "scroll" => { eb = eb.with(ObfuscatedName{ name : scroll_names[&item_template.name].clone() }); } _ => {} } } }
最后,我们将其添加到 main.rs 中的 run_systems:
#![allow(unused)] fn main() { let mut item_id = inventory_system::ItemIdentificationSystem{}; item_id.run_now(&self.ecs); }
因此,如果您现在 cargo run,您将能够通过使用或购买物品来鉴定物品。

混淆药剂
我们可以为药剂使用非常相似的设置,但我们需要考虑如何命名它们。通常,药剂将一些形容词与药剂一词结合起来:“粘稠的黑色药剂”、“漩涡状的绿色药剂” 等。幸运的是,我们现在已经构建了很多基础设施框架 - 因此这只是插入细节的问题。
我们将从打开 spawns.json 开始,并使用命名约定 “potion” 注释我们的生命药剂:
"items" : [
{
"name" : "Health Potion",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "provides_healing" : "8" }
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "potion" }
},
...
然后,在 raws/rawmaster.rs 中,我们将重复 get_scroll_tags 功能 - 但用于药剂。我们想要检索所有具有 “potion” 命名方案的物品 - 我们需要它来生成药剂名称。这是新函数:
#![allow(unused)] fn main() { pub fn get_potion_tags() -> Vec<String> { let raws = &super::RAWS.lock().unwrap(); let mut result = Vec::new(); for item in raws.raws.items.iter() { if let Some(magic) = &item.magic { if &magic.naming == "potion" { result.push(item.name.clone()); } } } result } }
现在我们将重新访问 map/dungeon.rs 并访问 MasterDungeonMap。我们需要添加一个用于存储药剂名称的结构(并将其添加到构造函数)。它将像卷轴映射一样:
#![allow(unused)] fn main() { #[derive(Default, Serialize, Deserialize, Clone)] pub struct MasterDungeonMap { maps : HashMap<i32, Map>, pub identified_items : HashSet<String>, pub scroll_mappings : HashMap<String, String>, pub potion_mappings : HashMap<String, String> } impl MasterDungeonMap { pub fn new() -> MasterDungeonMap { let mut dm = MasterDungeonMap{ maps: HashMap::new() , identified_items : HashSet::new(), scroll_mappings : HashMap::new(), potion_mappings : HashMap::new() }; ... }
现在,在 make_scroll_name 函数下面,我们将定义一些字符串常量数组。这些代表药剂的可用描述符;您可以(并且应该!)添加/编辑这些内容以适应您想要制作的游戏:
#![allow(unused)] fn main() { const POTION_COLORS: &[&str] = &["Red", "Orange", "Yellow", "Green", "Brown", "Indigo", "Violet"]; const POTION_ADJECTIVES : &[&str] = &["Swirling", "Effervescent", "Slimey", "Oiley", "Viscous", "Smelly", "Glowing"]; }
我们还需要一个函数来组合这些名称,包括重复检查(以确保我们永远不会有两种药剂类型具有相同的名称):
#![allow(unused)] fn main() { fn make_potion_name(rng: &mut rltk::RandomNumberGenerator, used_names : &mut HashSet<String>) -> String { loop { let mut name : String = POTION_ADJECTIVES[rng.roll_dice(1, POTION_ADJECTIVES.len() as i32) as usize -1].to_string(); name += " "; name += POTION_COLORS[rng.roll_dice(1, POTION_COLORS.len() as i32) as usize -1]; name += " Potion"; if !used_names.contains(&name) { used_names.insert(name.clone()); return name; } } } }
然后在 MasterDungeonMap 构造函数中,我们重复卷轴逻辑 - 但使用我们的新命名方案,以及 HashSet 以避免重复名称:
#![allow(unused)] fn main() { let mut used_potion_names : HashSet<String> = HashSet::new(); for potion_tag in crate::raws::get_potion_tags().iter() { let masked_name = make_potion_name(&mut rng, &mut used_potion_names); dm.potion_mappings.insert(potion_tag.to_string(), masked_name); } }
这为我们提供了一组不错的随机名称;在我刚刚运行的测试中,Health Potion 的混淆名称是 Slimey Violet Potion。听起来不好吃!
我们最后需要做的是向药剂生成添加 ObfuscatedName 组件。在 raws/rawmaster.rs 中,我们已经为卷轴执行了此操作 - 因此我们复制药剂的功能:
#![allow(unused)] fn main() { let scroll_names = dm.scroll_mappings.clone(); let potion_names = dm.potion_mappings.clone(); ... if !identified.contains(&item_template.name) { match magic.naming.as_str() { "scroll" => { eb = eb.with(ObfuscatedName{ name : scroll_names[&item_template.name].clone() }); } "potion" => { eb = eb.with(ObfuscatedName{ name: potion_names[&item_template.name].clone() }); } _ => {} } } }
我们已经完成了所有剩余的艰苦工作!所以现在,如果您 cargo run,四处走走并找到药剂,您会发现它有一个混淆的名称:

其他魔法物品
我们也应该支持其他魔法物品,而无需特殊的命名方案。让我们打开 spawns.json 并定义一个魔法 +1 长剑,并在命名方案中给它一个通用名称:
{
"name" : "Longsword +1",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAFF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "might",
"base_damage" : "1d8+1",
"hit_bonus" : 1
},
"weight_lbs" : 2.0,
"base_value" : 100.0,
"initiative_penalty" : 1,
"vendor_category" : "weapon",
"magic" : { "class" : "common", "naming" : "Unidentified Longsword" }
},
看看我们是如何调整统计数据以反映其魔法状态的?它更频繁地击中,造成更多伤害,重量更轻,价值更高,并且先攻惩罚更少。这将是一个不错的发现!我们也应该将其添加到生成表中;我们现在给它一个非常高的出现可能性,以便我们可以测试它:
{ "name" : "Longsword +1", "weight" : 100, "min_depth" : 3, "max_depth" : 100 },
我们没有生成任何新名称,因此无需在 dungeon.rs 中构建命名系统(除非您想这样做 - 制作自己的游戏总是比我的好!) - 因此我们将直接跳到 raws/rawmaster.rs 中的 spawn_named_items,并扩展魔法物品代码以在未提供名称时包含指定的名称:
#![allow(unused)] fn main() { if !identified.contains(&item_template.name) { match magic.naming.as_str() { "scroll" => { eb = eb.with(ObfuscatedName{ name : scroll_names[&item_template.name].clone() }); } "potion" => { eb = eb.with(ObfuscatedName{ name: potion_names[&item_template.name].clone() }); } _ => { eb = eb.with(ObfuscatedName{ name : magic.naming.clone() }); } } } }
如果您现在 cargo run,并冲到 3 级(我使用作弊码,反斜杠 是您的朋友),您 很可能 找到一把魔法长剑:

清理
我们应该将魔法长剑的生成权重改回合理的值,并使普通长剑更频繁地出现。在 spawns.json 中:
{ "name" : "Longsword", "weight" : 2, "min_depth" : 3, "max_depth" : 100 },
{ "name" : "Longsword +1", "weight" : 1, "min_depth" : 3, "max_depth" : 100 },
此外,如果您在 spawner.rs 中为角色提供了任何免费物品,请继续删除它们(除非您希望它们无处不在)!
工具提示
我们仍然有一个问题需要处理。如果您将鼠标悬停在物品上,它会显示其真实名称 - 而不是其混淆的名称。打开 gui.rs,我们将修复它。幸运的是,现在我们已经构建了名称框架,这非常容易!我们可以从 ECS 结构中完全删除名称,只需将实体和 ECS 传递给 get_item_display_name 以获取实体的名称:
#![allow(unused)] fn main() { fn draw_tooltips(ecs: &World, ctx : &mut Rltk) { use rltk::to_cp437; let (min_x, _max_x, min_y, _max_y) = camera::get_screen_bounds(ecs, ctx); let map = ecs.fetch::<Map>(); let positions = ecs.read_storage::<Position>(); let hidden = ecs.read_storage::<Hidden>(); let attributes = ecs.read_storage::<Attributes>(); let pools = ecs.read_storage::<Pools>(); let entities = ecs.entities(); let mouse_pos = ctx.mouse_pos(); let mut mouse_map_pos = mouse_pos; mouse_map_pos.0 += min_x - 1; mouse_map_pos.1 += min_y - 1; if mouse_pos.0 < 1 || mouse_pos.0 > 49 || mouse_pos.1 < 1 || mouse_pos.1 > 40 { return; } if mouse_map_pos.0 >= map.width-1 || mouse_map_pos.1 >= map.height-1 || mouse_map_pos.0 < 1 || mouse_map_pos.1 < 1 { return; } if !map.visible_tiles[map.xy_idx(mouse_map_pos.0, mouse_map_pos.1)] { return; } let mut tip_boxes : Vec<Tooltip> = Vec::new(); for (entity, position, _hidden) in (&entities, &positions, !&hidden).join() { if position.x == mouse_map_pos.0 && position.y == mouse_map_pos.1 { let mut tip = Tooltip::new(); tip.add(get_item_display_name(ecs, entity)); ... }
如果您现在将鼠标悬停在事物上,您将看到混淆的名称。如果您想将间谍融入您的游戏中,您甚至可以使用它来混淆 NPC 名称!
通过日志泄露信息
还有另一个明显的问题:如果您在拾取或掉落物品时观看日志,它会显示物品的真实名称!问题都发生在 inventory_system.rs 中,因此我们将从 gui.rs 中取出我们的 “ECS 外部” 函数,并使其适应在系统内部工作。这是函数:
#![allow(unused)] fn main() { fn obfuscate_name( item: Entity, names: &ReadStorage::<Name>, magic_items : &ReadStorage::<MagicItem>, obfuscated_names : &ReadStorage::<ObfuscatedName>, dm : &MasterDungeonMap, ) -> String { if let Some(name) = names.get(item) { if magic_items.get(item).is_some() { if dm.identified_items.contains(&name.name) { name.name.clone() } else if let Some(obfuscated) = obfuscated_names.get(item) { obfuscated.name.clone() } else { "Unidentified magic item".to_string() } } else { name.name.clone() } } else { "Nameless item (bug)".to_string() } } }
然后我们可以更改 ItemCollectionSystem 以使用它。涉及相当多的其他系统:
#![allow(unused)] fn main() { pub struct ItemCollectionSystem {} impl<'a> System<'a> for ItemCollectionSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToPickupItem>, WriteStorage<'a, Position>, ReadStorage<'a, Name>, WriteStorage<'a, InBackpack>, WriteStorage<'a, EquipmentChanged>, ReadStorage<'a, MagicItem>, ReadStorage<'a, ObfuscatedName>, ReadExpect<'a, MasterDungeonMap> ); fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, mut wants_pickup, mut positions, names, mut backpack, mut dirty, magic_items, obfuscated_names, dm) = data; for pickup in wants_pickup.join() { positions.remove(pickup.item); backpack.insert(pickup.item, InBackpack{ owner: pickup.collected_by }).expect("Unable to insert backpack entry"); dirty.insert(pickup.collected_by, EquipmentChanged{}).expect("Unable to insert"); if pickup.collected_by == *player_entity { gamelog.entries.push( format!( "You pick up the {}.", obfuscate_name(pickup.item, &names, &magic_items, &obfuscated_names, &dm) ) ); } } wants_pickup.clear(); } } }
同样,我们需要调整物品掉落系统:
#![allow(unused)] fn main() { pub struct ItemDropSystem {} impl<'a> System<'a> for ItemDropSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, Entities<'a>, WriteStorage<'a, WantsToDropItem>, ReadStorage<'a, Name>, WriteStorage<'a, Position>, WriteStorage<'a, InBackpack>, WriteStorage<'a, EquipmentChanged>, ReadStorage<'a, MagicItem>, ReadStorage<'a, ObfuscatedName>, ReadExpect<'a, MasterDungeonMap> ); fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, entities, mut wants_drop, names, mut positions, mut backpack, mut dirty, magic_items, obfuscated_names, dm) = data; for (entity, to_drop) in (&entities, &wants_drop).join() { let mut dropper_pos : Position = Position{x:0, y:0}; { let dropped_pos = positions.get(entity).unwrap(); dropper_pos.x = dropped_pos.x; dropper_pos.y = dropped_pos.y; } positions.insert(to_drop.item, Position{ x : dropper_pos.x, y : dropper_pos.y }).expect("Unable to insert position"); backpack.remove(to_drop.item); dirty.insert(entity, EquipmentChanged{}).expect("Unable to insert"); if entity == *player_entity { gamelog.entries.push( format!( "You drop the {}.", obfuscate_name(to_drop.item, &names, &magic_items, &obfuscated_names, &dm) ) ); } } wants_drop.clear(); } } }
修复物品颜色
另一个问题是我们已经为各种魔法物品进行了颜色编码。眼尖的玩家可能会因为颜色而知道 “blah 卷轴” 实际上是火球卷轴!解决方案是遍历 spawns.json 并确保物品具有相同的颜色。
总结
这为我们提供了物品辨识迷你游戏的基础知识。我们尚未触及诅咒物品 - 这将在未来的章节中介绍(在我们清理物品系统中的一些问题之后;更多内容将在下一章中介绍)。
...
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson.
效果
关于本教程
本教程是免费和开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在上一章中,我们为魔法物品添加了物品识别功能 - 并且清楚地表明我们可以创建大量物品。 我们的物品栏系统严重超载 - 它在一个地方做了太多事情,从装备/卸下物品到魔法飞弹法术飞行的内部机制。 更糟糕的是,我们悄无声息地撞到了一堵墙:Specs 限制了您可以传递到系统中的数据存储的数量(并且可能会继续这样做,直到 Rust 支持 C++ 风格的可变参数包)。 我们可以只是绕过这个问题,但通过实施更通用的解决方案来彻底解决这个问题会更好。 它还使我们能够解决一个我们尚未意识到的问题:处理来自物品以外的东西的效果,例如法术(或做古怪事情的陷阱等)。 这也是一个机会来修复您可能没有注意到的错误; 一个实体只能有一个给定类型的组件,因此如果两个东西在一个给定的 tick 中对一个组件造成了伤害 - 实际上只有一次伤害会发生!
什么是效果?
为了正确地建模效果,我们需要思考它们是什么。 效果是某物正在做某事。 它可能是一把剑击中目标,一个法术从深渊召唤出一个强大的恶魔,或者一根魔杖清除召唤出一堆鲜花 - 实际上几乎任何东西! 我们希望保持事物产生多个效果的能力(如果您向物品添加了多个组件,它将触发所有组件 - 这是一件好事;一根雷霆与闪电之杖很容易具有两个或多个效果!)。 因此,由此我们可以推断出:
- 一个效果做一件事情 - 但一个效果的来源可能会产生多个效果。 因此,效果非常适合作为其自身的
Entity。 - 效果有一个来源:例如,如果效果杀死了某人,则必须有人获得经验值。 它还需要可以选择不有来源 - 它可能纯粹是环境的。
- 效果有一个或多个目标; 它可以是自我目标、针对另一个人或范围效果。 因此,目标是实体或位置。
- 效果可能会触发链中其他效果的产生(例如,想想连锁闪电)。
- 效果做某事,但我们真的不想在早期规划阶段明确具体做什么!
- 我们希望效果来源于多个地方:使用物品、触发陷阱、怪物的特殊攻击、魔法武器的“触发”效果、施法,甚至环境效果!
所以,我们要求的不多! 幸运的是,这完全在我们使用 ECS 可以管理的范围内。 我们将稍微扩展“S”(系统),并使用更通用的工厂模型来实际创建效果 - 然后一旦我们到位,就可以获得相对通用设置的好处。
物品栏系统:快速清理
在我们深入之前,我们应该花一点时间将物品栏系统分解为一个模块。 我们将保留它已经具有的完全相同的功能(目前),但它是一个怪物 - 而怪物通常最好分块处理! 创建一个新文件夹 src/inventory_system 并将 inventory_system.rs 移动到其中 - 并将其重命名为 mod.rs。 这会将其转换为多文件模块。 (这些步骤实际上足以让您获得可运行的设置 - 这很好地说明了 Rust 中模块的工作原理;名为 inventory_system.rs 的文件是一个模块,inventory_system/mod.rs 也是如此)。
现在打开 inventory_system/mod.rs,您会看到它包含许多系统:
ItemCollectionSystemItemUseSystemItemDropSystemItemRemoveSystemItemIdentificationSystem
我们将为每个系统创建一个新文件,从 mod.rs 中剪切系统代码并将其粘贴到其自己的文件中。 我们需要将 mod.rs 的 use 部分复制到这些文件的顶部,然后删除我们不使用的部分。 最后,我们将在 mod.rs 中添加 mod X、use X::SystemName 行,以告诉编译器该模块正在共享这些系统。 如果我粘贴这些更改中的每一个,这将是一个巨大的章节,并且由于最大的更改 - ItemUseSystem 将会发生巨大变化,那将是相当大的空间浪费。 相反,我们将浏览第一个 - 您可以查看源代码 以查看其余部分。
例如,我们创建一个新文件 inventory_system/collection_system.rs:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{WantsToPickupItem, Name, InBackpack, Position, gamelog::GameLog, EquipmentChanged }; pub struct ItemCollectionSystem {} impl<'a> System<'a> for ItemCollectionSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToPickupItem>, WriteStorage<'a, Position>, ReadStorage<'a, Name>, WriteStorage<'a, InBackpack>, WriteStorage<'a, EquipmentChanged> ); fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, mut wants_pickup, mut positions, names, mut backpack, mut dirty) = data; for pickup in wants_pickup.join() { positions.remove(pickup.item); backpack.insert(pickup.item, InBackpack{ owner: pickup.collected_by }).expect("Unable to insert backpack entry"); dirty.insert(pickup.collected_by, EquipmentChanged{}).expect("Unable to insert"); if pickup.collected_by == *player_entity { gamelog.entries.push(format!("You pick up the {}.", names.get(pickup.item).unwrap().name)); } } wants_pickup.clear(); } } }
这完全是原始系统中的代码,这就是我们在此处不重复所有代码的原因。 唯一的区别是我们浏览了顶部的 use super:: 列表,并删除了我们不使用的内容。 您可以对 inventory_system/drop_system.rs、inventory_system/identification_system.rs、inventory_system/remove_system.rs 和 use_system.rs 执行相同的操作。 然后,将它们捆绑到 inventory_system/mod.rs 中:
#![allow(unused)] fn main() { use super::{WantsToPickupItem, Name, InBackpack, Position, gamelog, WantsToUseItem, Consumable, ProvidesHealing, WantsToDropItem, InflictsDamage, Map, SufferDamage, AreaOfEffect, Confusion, Equippable, Equipped, WantsToRemoveItem, particle_system, ProvidesFood, HungerClock, HungerState, MagicMapper, RunState, Pools, EquipmentChanged, TownPortal, IdentifiedItem, Item, ObfuscatedName}; mod collection_system; pub use collection_system::ItemCollectionSystem; mod use_system; pub use use_system::ItemUseSystem; mod drop_system; pub use drop_system::ItemDropSystem; mod remove_system; pub use remove_system::ItemRemoveSystem; mod identification_system; pub use identification_system::ItemIdentificationSystem; }
我们调整了一些 use 路径以使其他组件感到满意,然后添加了一对 mod(使用文件)和 pub use(与项目的其余部分共享)。
如果一切顺利,cargo run 将为您提供与之前完全相同的游戏! 它甚至应该编译得更快一些。
一个新的效果模块
我们将从基础知识开始。 创建一个新文件夹 src/effects 并在其中放置一个名为 mod.rs 的文件。 正如您之前所见,这将创建一个名为 effects 的基本模块。 现在开始有趣的部分; 我们需要能够从任何地方添加效果,包括系统内部:因此无法传入 World。 但是,生成效果将需要完全的 World 访问权限! 因此,我们将创建一个排队系统。 调用在排队效果,并在稍后扫描队列会导致效果触发。 这基本上是一个消息传递系统,您通常会在大型游戏引擎中找到类似的东西。 因此,这是一个非常简单的 effects/mod.rs(还要在 main.rs 中的 use 列表中添加 pub mod effects;,将其包含在您的编译中并使其可供其他模块使用):
#![allow(unused)] fn main() { use std::sync::Mutex; use specs::prelude::*; use std::collections::VecDeque; lazy_static! { pub static ref EFFECT_QUEUE : Mutex<VecDeque<EffectSpawner>> = Mutex::new(VecDeque::new()); } pub enum EffectType { Damage { amount : i32 } } #[derive(Clone)] pub enum Targets { Single { target : Entity }, Area { target: Vec<Entity> } } pub struct EffectSpawner { pub creator : Option<Entity>, pub effect_type : EffectType, pub targets : Targets } pub fn add_effect(creator : Option<Entity>, effect_type: EffectType, targets : Targets) { EFFECT_QUEUE .lock() .unwrap() .push_back(EffectSpawner{ creator, effect_type, targets }); } }
如果您正在使用 IDE,它会抱怨所有这些都没有被使用。 没关系,我们首先构建基本功能! VecDeque 是新的; 它是一个队列(实际上是一个双端队列),其后有一个向量以提高性能。 它允许您添加到任一端,并从中 pop 结果。 请参阅文档 以了解更多信息。
排队伤害
让我们从一个相对简单的开始。 目前,每当实体受到伤害时,我们都会为其分配一个 SufferDamage 组件。 这工作正常,但存在我们之前讨论过的问题 - 一次只能有一个伤害源。 我们希望以多种方式同时谋杀我们的玩家(只是稍微开玩笑一下)! 因此,我们将扩展基础以允许插入伤害。 我们将更改 EffectType 以具有 Damage 类型:
#![allow(unused)] fn main() { pub enum EffectType { Damage { amount : i32 } } }
请注意,我们没有存储受害者或发起者 - 这些都包含在消息的 source 和 target 部分中。 现在我们搜索我们的代码,看看我们在哪里使用 SufferDamage 组件。 最重要的用户是饥饿系统、近战系统、物品使用系统和触发系统:它们都可能导致伤害发生。 打开 melee_combat_system.rs 并找到以下行(在我的源代码中是第 106 行):
#![allow(unused)] fn main() { SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage, from_player: entity == *player_entity); }
我们可以用对队列插入的调用来替换它:
#![allow(unused)] fn main() { add_effect( Some(entity), EffectType::Damage{ amount: damage }, Targets::Single{ target: wants_melee.target } ); }
我们还可以从系统中删除对 inflict_damage 的所有引用,因为我们不再使用它。
我们应该对 trigger_system.rs 做同样的事情。 我们可以替换以下行:
#![allow(unused)] fn main() { SufferDamage::new_damage(&mut inflict_damage, entity, damage.damage, false); }
替换为:
#![allow(unused)] fn main() { add_effect( None, EffectType::Damage{ amount: damage.damage }, Targets::Single{ target: entity } ); }
同样,我们也可以摆脱对 SufferDamage 的所有引用。
我们将暂时忽略 item_use_system(稍后我们将回到它,我保证)。
应用伤害
因此,现在如果您击中某物,您正在将伤害添加到队列中(并且没有其他事情发生)。 下一步是读取效果队列并对其执行某些操作。 我们将为此采用调度器模型:读取队列,并将命令调度到相关位置。 我们将从骨架开始; 在 effects/mod.rs 中,我们添加以下函数:
#![allow(unused)] fn main() { pub fn run_effects_queue(ecs : &mut World) { loop { let effect : Option<EffectSpawner> = EFFECT_QUEUE.lock().unwrap().pop_front(); if let Some(effect) = effect { // target_applicator(ecs, &effect); // 当我们编写此函数时取消注释! } else { break; } } } }
这非常简单! 它获取锁,时间足够长,可以从队列中弹出第一条消息,如果它有值 - 则对其执行某些操作。 然后,它重复锁/弹出循环,直到队列完全为空。 这是一种有用的模式:锁仅在足够长的时间内持有以读取队列,因此如果内部的任何系统想要添加到队列中,您都不会遇到“死锁”(两个系统永久等待队列访问)。
它还没有对数据做任何事情 - 但这向您展示了如何一次排空队列一条消息。 我们正在接受 World,因为我们希望修改它。 我们应该添加一个调用来使用此函数; 在 main.rs 中找到 run_systems 并几乎在最后添加它(在粒子和照明之后):
#![allow(unused)] fn main() { effects::run_effects_queue(&mut self.ecs); let mut particles = particle_system::ParticleSpawnSystem{}; particles.run_now(&self.ecs); let mut lighting = lighting_system::LightingSystem{}; lighting.run_now(&self.ecs); }
既然我们正在排空队列,让我们用它做点什么。 在 effects/mod.rs 中,我们将添加注释掉的函数 target_applicator。 想法是获取 TargetType,并将其扩展为处理它的调用(该函数具有很高的“扇出” - 意味着我们将经常调用它,并且它将调用许多其他函数)。 有几种不同的方法可以影响目标,因此这里有几个相关函数:
#![allow(unused)] fn main() { fn target_applicator(ecs : &mut World, effect : &EffectSpawner) { match &effect.targets { Targets::Tile{tile_idx} => affect_tile(ecs, effect, *tile_idx), Targets::Tiles{tiles} => tiles.iter().for_each(|tile_idx| affect_tile(ecs, effect, *tile_idx)), Targets::Single{target} => affect_entity(ecs, effect, *target), Targets::TargetList{targets} => targets.iter().for_each(|entity| affect_entity(ecs, effect, *entity)), } } fn tile_effect_hits_entities(effect: &EffectType) -> bool { match effect { EffectType::Damage{..} => true } } fn affect_tile(ecs: &mut World, effect: &EffectSpawner, tile_idx : i32) { if tile_effect_hits_entities(&effect.effect_type) { let content = ecs.fetch::<Map>().tile_content[tile_idx as usize].clone(); content.iter().for_each(|entity| affect_entity(ecs, effect, *entity)); } // TODO: 运行效果 } fn affect_entity(ecs: &mut World, effect: &EffectSpawner, target: Entity) { // TODO: 运行效果 } }
这里有很多需要解释的地方,但它为处理效果目标提供了非常通用的机制。 让我们逐步了解它:
- 调用
target_applicator。 - 它匹配效果的
targets字段:- 如果它是
Tile目标类型,则使用目标瓦片的索引调用Targets::tile。affect_tile调用另一个函数tile_effect_hits_entities,该函数查看请求的效果类型并确定是否应将其应用于瓦片内的实体。 现在,我们只有Damage- 这对于传递给实体来说是有意义的,因此它当前始终返回 true。- 如果它确实影响了瓦片中的实体,则它从地图中检索瓦片内容 - 并对瓦片中的每个实体调用
affect_entity。 我们稍后会看到这一点。 - 如果有与瓦片相关的事情要做,它会在这里发生。 现在,它是一个
TODO注释。
- 如果它是
Tiles目标类型,它会迭代列表中的所有瓦片,依次对每个瓦片调用affect_tile- 就像单个瓦片(如上所述),但涵盖了它们中的每一个。 - 如果它是
Single实体目标,它会为该目标调用affect_entity。 - 如果它是
TargetList(目标实体列表),它会依次为这些目标实体中的每一个调用affect_entity。
- 如果它是
因此,此框架允许我们拥有可以击中瓦片(并可选择击中其中的所有人)、一组瓦片(再次,可选择包括内容)、单个实体或实体列表的效果。 您可以使用它来描述几乎任何目标机制!
接下来,在 run_effects_queue 函数中,取消注释调用者(以便我们的辛勤工作实际运行!):
#![allow(unused)] fn main() { pub fn run_effects_queue(ecs : &mut World) { loop { let effect : Option<EffectSpawner> = EFFECT_QUEUE.lock().unwrap().pop_front(); if let Some(effect) = effect { target_applicator(ecs, &effect); } else { break; } } } }
回到我们正在实现的 Damage 类型,我们需要实现它! 我们将创建一个新文件 effects/damage.rs 并将应用伤害的代码放入其中。 伤害是一次性的、非持久性的东西 - 因此我们将立即处理它。 这是最基本的功能:
#![allow(unused)] fn main() { use specs::prelude::*; use super::*; use crate::components::Pools; pub fn inflict_damage(ecs: &mut World, damage: &EffectSpawner, target: Entity) { let mut pools = ecs.write_storage::<Pools>(); if let Some(pool) = pools.get_mut(target) { if !pool.god_mode { if let EffectType::Damage{amount} = damage.effect_type { pool.hit_points.current -= amount; } } } } }
请注意,我们没有处理血迹、经验值或任何类似的东西! 但是,我们正在应用伤害。 如果您现在 cargo run,您可以进行近战(并且这样做不会获得任何好处)。
为血神献血!
我们之前的版本在每次造成伤害时都会生成血迹。 将其包含在上面的 inflict_damage 函数中很容易,但我们可能在其他地方使用血迹! 我们还需要验证我们的效果消息队列是否真的足够智能以处理事件期间的插入。 因此,我们将使血迹成为一种效果。 我们将其添加到 effects/mod.rs 中的 EffectType 枚举中:
#![allow(unused)] fn main() { pub enum EffectType { Damage { amount : i32 }, Bloodstain } }
血迹对(现在混乱的)瓦片中的实体没有影响,因此我们将更新 tile_effect_hits_entities 以使其默认不执行任何操作(这样我们就可以不断添加装饰效果,而无需记住每次都添加它):
#![allow(unused)] fn main() { fn tile_effect_hits_entities(effect: &EffectType) -> bool { match effect { EffectType::Damage{..} => true, _ => false } } }
同样,affect_entity 可以忽略该事件 - 以及其他装饰事件:
#![allow(unused)] fn main() { fn affect_entity(ecs: &mut World, effect: &EffectSpawner, target: Entity) { match &effect.effect_type { EffectType::Damage{..} => damage::inflict_damage(ecs, effect, target), _ => {} } } }
我们确实希望它影响瓦片,因此我们将更新 affect_tile 以调用血迹函数。
#![allow(unused)] fn main() { fn affect_tile(ecs: &mut World, effect: &EffectSpawner, tile_idx : i32) { if tile_effect_hits_entities(&effect.effect_type) { let content = ecs.fetch::<Map>().tile_content[tile_idx as usize].clone(); content.iter().for_each(|entity| affect_entity(ecs, effect, *entity)); } match &effect.effect_type { EffectType::Bloodstain => damage::bloodstain(ecs, tile_idx), _ => {} } } }
现在,在 effects/damage.rs 中,我们将编写血迹代码:
#![allow(unused)] fn main() { pub fn bloodstain(ecs: &mut World, tile_idx : i32) { let mut map = ecs.fetch_mut::<Map>(); map.bloodstains.insert(tile_idx as usize); } }
我们还将更新 inflict_damage 以生成血迹:
#![allow(unused)] fn main() { pub fn inflict_damage(ecs: &mut World, damage: &EffectSpawner, target: Entity) { let mut pools = ecs.write_storage::<Pools>(); if let Some(pool) = pools.get_mut(target) { if !pool.god_mode { if let EffectType::Damage{amount} = damage.effect_type { pool.hit_points.current -= amount; if let Some(tile_idx) = entity_position(ecs, target) { add_effect(None, EffectType::Bloodstain, Targets::Tile{tile_idx}); } } } } } }
相关代码向一个神秘函数 entity_position 请求数据 - 如果它返回值,则插入一个 Bloodstain 类型的效果,其中包含瓦片索引。 那么这个函数是什么呢? 我们将定位很多目标,因此我们应该制作一些辅助函数,以便调用者更容易使用该过程。 创建一个新文件 effects/targeting.rs 并将以下内容放入其中:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::components::Position; use crate::map::Map; pub fn entity_position(ecs: &World, target: Entity) -> Option<i32> { if let Some(pos) = ecs.read_storage::<Position>().get(target) { let map = ecs.fetch::<Map>(); return Some(map.xy_idx(pos.x, pos.y) as i32); } None } }
现在在 effects/mods.rs 中添加几行,将目标辅助函数暴露给效果模块的消费者:
#![allow(unused)] fn main() { mod targeting; pub use targeting::*; }
那么这是做什么的呢? 它遵循我们经常使用的模式:它检查实体是否具有位置。 如果是,则从全局地图获取瓦片索引并返回它 - 否则,它返回 None。
如果您现在 cargo run,并攻击一个无辜的啮齿动物,您将看到血液! 我们已经证明事件系统不会死锁,并且我们添加了一种添加血迹的简单方法。 您可以从任何地方调用该事件,血液将倾盆而下!
微粒物质
您可能已经注意到,当实体受到伤害时,我们会生成一个粒子。 粒子是我们也可以大量使用的东西,因此将它们作为事件类型也很有意义。 到目前为止,每当我们造成伤害时,我们都会在受害者身上闪烁橙色指示器。 我们不妨在伤害系统中对其进行编纂(并为以后的章节改进留出空间)。 我们很可能也希望出于其他目的启动粒子 - 因此我们将提出另一个相当通用的设置。
我们将从 effects/mod.rs 开始,并将 EffectType 扩展为包含粒子:
#![allow(unused)] fn main() { pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 } } }
您会注意到,我们再次没有指定粒子去哪里; 我们将其留给目标系统。 现在我们将制作一个实际生成粒子的函数。 为了清晰起见,我们将其放在自己的文件中; 在新文件 effects/particles.rs 中添加以下内容:
#![allow(unused)] fn main() { use specs::prelude::*; use super::*; use crate::particle_system::ParticleBuilder; use crate::map::Map; pub fn particle_to_tile(ecs: &mut World, tile_idx : i32, effect: &EffectSpawner) { if let EffectType::Particle{ glyph, fg, bg, lifespan } = effect.effect_type { let map = ecs.fetch::<Map>(); let mut particle_builder = ecs.fetch_mut::<ParticleBuilder>(); particle_builder.request( tile_idx % map.width, tile_idx / map.width, fg, bg, glyph, lifespan ); } } }
这与我们对 ParticleBuilder 的其他调用基本相同,但使用消息的内容来定义要构建的内容。 现在我们将回到 effects/mod.rs 并添加一个 mod particles; 到顶部的使用列表中。 然后我们将扩展 affect_tile 以调用它:
#![allow(unused)] fn main() { fn affect_tile(ecs: &mut World, effect: &EffectSpawner, tile_idx : i32) { if tile_effect_hits_entities(&effect.effect_type) { let content = ecs.fetch::<Map>().tile_content[tile_idx as usize].clone(); content.iter().for_each(|entity| affect_entity(ecs, effect, *entity)); } match &effect.effect_type { EffectType::Bloodstain => damage::bloodstain(ecs, tile_idx), EffectType::Particle{..} => particles::particle_to_tile(ecs, tile_idx, &effect), _ => {} } } }
能够将粒子附加到实体也非常方便,即使它实际上没有太大的效果。 在某些情况下,我们检索了 Position 组件只是为了放置效果,因此这可以让我们简化代码! 因此,我们像这样扩展 affect_entity:
#![allow(unused)] fn main() { fn affect_entity(ecs: &mut World, effect: &EffectSpawner, target: Entity) { match &effect.effect_type { EffectType::Damage{..} => damage::inflict_damage(ecs, effect, target), EffectType::Bloodstain{..} => if let Some(pos) = entity_position(ecs, target) { damage::bloodstain(ecs, pos) }, EffectType::Particle{..} => if let Some(pos) = entity_position(ecs, target) { particles::particle_to_tile(ecs, pos, &effect) }, _ => {} } } }
因此,现在我们可以打开 effects/damage.rs,既可以清理血迹代码,又可以应用伤害粒子:
#![allow(unused)] fn main() { pub fn inflict_damage(ecs: &mut World, damage: &EffectSpawner, target: Entity) { let mut pools = ecs.write_storage::<Pools>(); if let Some(pool) = pools.get_mut(target) { if !pool.god_mode { if let EffectType::Damage{amount} = damage.effect_type { pool.hit_points.current -= amount; add_effect(None, EffectType::Bloodstain, Targets::Single{target}); add_effect(None, EffectType::Particle{ glyph: rltk::to_cp437('‼'), fg : rltk::RGB::named(rltk::ORANGE), bg : rltk::RGB::named(rltk::BLACK), lifespan: 200.0 }, Targets::Single{target} ); } } } } }
现在打开 melee_combat_system.rs。 我们可以通过删除伤害时的粒子调用来简化它,并用效果调用替换对 ParticleBuilder 的其他调用。 这使我们可以摆脱对粒子系统、位置和玩家实体的所有引用! 这是我想要的改进:系统正在简化为它们应该关注的内容! 有关更改,请参阅源代码; 它们太长了,无法在此处正文文本中包含。
如果您现在 cargo run,如果您伤害了某些东西,您将看到粒子 - 血迹应该仍然有效。
经验值
因此,我们仍然缺少一些重要的东西:当您杀死怪物时,它应该掉落战利品/现金,提供经验值等等。 与其用太多无关的东西污染“伤害”函数(基于函数做好一件事的原则),不如添加一个新的 EffectType - EntityDeath:
#![allow(unused)] fn main() { pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 }, EntityDeath } }
现在在 inflict_damage 中,如果实体死亡,我们将发出此事件:
#![allow(unused)] fn main() { if pool.hit_points.current < 1 { add_effect(damage.creator, EffectType::EntityDeath, Targets::Single{target}); } }
我们还将创建一个新函数; 这与 damage_system 中的代码相同(当我们处理完物品使用后,我们将删除系统的大部分内容):
#![allow(unused)] fn main() { pub fn death(ecs: &mut World, effect: &EffectSpawner, target : Entity) { let mut xp_gain = 0; let mut gold_gain = 0.0f32; let mut pools = ecs.write_storage::<Pools>(); let attributes = ecs.read_storage::<Attributes>(); let mut map = ecs.fetch_mut::<Map>(); if let Some(pos) = entity_position(ecs, target) { crate::spatial::remove_entity(target, pos as usize); } if let Some(source) = effect.creator { if ecs.read_storage::<Player>().get(source).is_some() { if let Some(stats) = pools.get(target) { xp_gain += stats.level * 100; gold_gain += stats.gold; } if xp_gain != 0 || gold_gain != 0.0 { let mut log = ecs.fetch_mut::<GameLog>(); let mut player_stats = pools.get_mut(source).unwrap(); let player_attributes = attributes.get(source).unwrap(); player_stats.xp += xp_gain; player_stats.gold += gold_gain; if player_stats.xp >= player_stats.level * 1000 { // 我们升级了! player_stats.level += 1; log.entries.push(format!("Congratulations, you are now level {}", player_stats.level)); player_stats.hit_points.max = player_hp_at_level( player_attributes.fitness.base + player_attributes.fitness.modifiers, player_stats.level ); player_stats.hit_points.current = player_stats.hit_points.max; player_stats.mana.max = mana_at_level( player_attributes.intelligence.base + player_attributes.intelligence.modifiers, player_stats.level ); player_stats.mana.current = player_stats.mana.max; let player_pos = ecs.fetch::<rltk::Point>(); for i in 0..10 { if player_pos.y - i > 1 { add_effect(None, EffectType::Particle{ glyph: rltk::to_cp437('░'), fg : rltk::RGB::named(rltk::GOLD), bg : rltk::RGB::named(rltk::BLACK), lifespan: 400.0 }, Targets::Tile{ tile_idx : map.xy_idx(player_pos.x, player_pos.y - i) as i32 } ); } } } } } } } }
最后,我们将效果添加到 affect_entity:
#![allow(unused)] fn main() { fn affect_entity(ecs: &mut World, effect: &EffectSpawner, target: Entity) { match &effect.effect_type { EffectType::Damage{..} => damage::inflict_damage(ecs, effect, target), EffectType::EntityDeath => damage::death(ecs, effect, target), EffectType::Bloodstain{..} => if let Some(pos) = entity_position(ecs, target) { damage::bloodstain(ecs, pos) }, EffectType::Particle{..} => if let Some(pos) = entity_position(ecs, target) { particles::particle_to_tile(ecs, pos, &effect) }, _ => {} } } }
因此,现在如果您 cargo run 该项目,我们将回到我们原来的位置 - 但具有更灵活的系统来处理粒子、伤害(现在可以堆叠!)和一般杀死事物。
物品效果
现在我们有了效果系统的基础知识(并清理了伤害),是时候真正思考物品(和触发器)应该如何工作了。 我们希望它们足够通用,以便您可以像乐高积木一样组合实体并构建有趣的东西。 我们还希望停止在多个地方定义效果; 目前,我们在一个系统中列出触发效果,在另一个系统中列出物品效果 - 如果我们添加法术,我们将有另一个地方需要调试!
我们将首先查看物品使用系统 (inventory_system/use_system.rs)。 它非常庞大,并且在一个地方做了太多事情。 它处理目标选择、识别、装备切换、触发使用物品的效果以及消耗品的销毁! 这对于构建一个玩具游戏进行测试来说很好,但它无法扩展到“真实”游戏。
对于其中的一部分 - 并本着使用 ECS 的精神 - 我们将制作更多系统,并让它们做好一件事。
移动装备
装备(和交换)物品目前位于物品使用系统中,因为它从用户界面的角度来看适合在那里:您“使用”一把剑,使用它的逻辑方法是握住它(并收起您手中持有的任何东西)。 虽然将其作为物品使用系统的一部分使系统过于混乱 - 该系统只是做得太多(并且目标选择实际上不是问题,因为您是在自己身上使用它)。
因此,我们将在文件 inventory_system/use_equip.rs 中创建一个新系统,并将功能移动到其中。 这将导致一个紧凑的新系统:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Name, InBackpack, gamelog::GameLog, WantsToUseItem, Equippable, Equipped, EquipmentChanged}; pub struct ItemEquipOnUse {} impl<'a> System<'a> for ItemEquipOnUse { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, Entities<'a>, WriteStorage<'a, WantsToUseItem>, ReadStorage<'a, Name>, ReadStorage<'a, Equippable>, WriteStorage<'a, Equipped>, WriteStorage<'a, InBackpack>, WriteStorage<'a, EquipmentChanged> ); #[allow(clippy::cognitive_complexity)] fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, entities, mut wants_use, names, equippable, mut equipped, mut backpack, mut dirty) = data; let mut remove_use : Vec<Entity> = Vec::new(); for (target, useitem) in (&entities, &wants_use).join() { // 如果它是可装备的,那么我们想要装备它 - 并卸下该槽位中的任何其他物品 if let Some(can_equip) = equippable.get(useitem.item) { let target_slot = can_equip.slot; // 删除目标在该物品槽位中拥有的任何物品 let mut to_unequip : Vec<Entity> = Vec::new(); for (item_entity, already_equipped, name) in (&entities, &equipped, &names).join() { if already_equipped.owner == target && already_equipped.slot == target_slot { to_unequip.push(item_entity); if target == *player_entity { gamelog.entries.push(format!("You unequip {}.", name.name)); } } } for item in to_unequip.iter() { equipped.remove(*item); backpack.insert(*item, InBackpack{ owner: target }).expect("Unable to insert backpack entry"); } // 装备物品 equipped.insert(useitem.item, Equipped{ owner: target, slot: target_slot }).expect("Unable to insert equipped component"); backpack.remove(useitem.item); if target == *player_entity { gamelog.entries.push(format!("You equip {}.", names.get(useitem.item).unwrap().name)); } // 完成物品 remove_use.push(target); } } remove_use.iter().for_each(|e| { dirty.insert(*e, EquipmentChanged{}).expect("Unable to insert"); wants_use.remove(*e).expect("Unable to remove"); }); } } }
现在进入 use_system.rs 并删除相同的代码块。 最后,跳到 main.rs 并将系统添加到 run_systems 中(就在当前的 use 系统调用之前):
#![allow(unused)] fn main() { let mut itemequip = inventory_system::ItemEquipOnUse{}; itemequip.run_now(&self.ecs); ... let mut itemuse = ItemUseSystem{}; }
继续 cargo run 并切换一些装备,以确保它仍然有效。 这是不错的进展 - 我们可以从我们的 use_system 中删除三个完整的组件存储!
物品效果
现在我们已经将物品栏管理清理到它自己的系统中,是时候真正切入此更改的核心了:具有效果的物品使用。 目标是拥有一个了解物品的系统,但可以“扇出”到通用代码,我们可以将该代码重用于每个其他效果使用。 我们将从 effects/mod.rs 开始,添加一个用于“我想使用物品”的效果类型:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 }, EntityDeath, ItemUse { item: Entity }, WellFed, Healing { amount : i32 }, Confusion { turns : i32 } } }
我们希望这些效果与常规效果略有不同(必须处理消耗品使用,并且目标选择通过到实际效果,而不是直接从物品传递)。 我们将其添加到 target_applicator 中:
#![allow(unused)] fn main() { fn target_applicator(ecs : &mut World, effect : &EffectSpawner) { if let EffectType::ItemUse{item} = effect.effect_type { triggers::item_trigger(effect.creator, item, &effect.targets, ecs); } else { match &effect.targets { Targets::Tile{tile_idx} => affect_tile(ecs, effect, *tile_idx), Targets::Tiles{tiles} => tiles.iter().for_each(|tile_idx| affect_tile(ecs, effect, *tile_idx)), Targets::Single{target} => affect_entity(ecs, effect, *target), Targets::TargetList{targets} => targets.iter().for_each(|entity| affect_entity(ecs, effect, *entity)), } } } }
这“短路”了调用树,因此它处理物品一次(物品然后可以将其他事件发射到队列中,因此所有事件都得到处理)。 由于我们已经调用了它,现在我们必须编写 triggers:item_trigger! 创建一个新文件 effects/triggers.rs(并在 mod.rs 中添加 mod triggers;):
#![allow(unused)] fn main() { pub fn item_trigger(creator : Option<Entity>, item: Entity, targets : &Targets, ecs: &mut World) { // 通过通用系统使用物品 event_trigger(creator, item, targets, ecs); // 如果它是消耗品,则将其删除 if ecs.read_storage::<Consumable>().get(item).is_some() { ecs.entities().delete(item).expect("Delete Failed"); } } }
此函数是我们必须以不同方式处理物品的原因:它调用 event_trigger(一个本地的私有函数)来生成物品的所有效果 - 然后,如果该物品是消耗品,它会将其删除。 让我们创建一个骨架 event_trigger 函数:
#![allow(unused)] fn main() { fn event_trigger(creator : Option<Entity>, entity: Entity, targets : &Targets, ecs: &mut World) { let mut gamelog = ecs.fetch_mut::<GameLog>(); } }
因此,这不会做任何事情 - 但游戏现在可以编译,您可以看到当您使用物品时,它会被正确删除。 它提供了足够的占位符,使我们能够修复物品栏系统!
使用系统清理
inventory_system/use_system.rs 文件是此清理的根本原因,我们现在有足够的框架使其成为一个相当小的精简系统! 我们只需要将其装备标记为已更改,构建适当的 Targets 列表,并添加一个使用事件。 这是整个新系统:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Name, WantsToUseItem,Map, AreaOfEffect, EquipmentChanged, IdentifiedItem}; use crate::effects::*; pub struct ItemUseSystem {} impl<'a> System<'a> for ItemUseSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, Map>, Entities<'a>, WriteStorage<'a, WantsToUseItem>, ReadStorage<'a, Name>, ReadStorage<'a, AreaOfEffect>, WriteStorage<'a, EquipmentChanged>, WriteStorage<'a, IdentifiedItem> ); #[allow(clippy::cognitive_complexity)] fn run(&mut self, data : Self::SystemData) { let (player_entity, map, entities, mut wants_use, names, aoe, mut dirty, mut identified_item) = data; for (entity, useitem) in (&entities, &wants_use).join() { dirty.insert(entity, EquipmentChanged{}).expect("Unable to insert"); // 识别 if entity == *player_entity { identified_item.insert(entity, IdentifiedItem{ name: names.get(useitem.item).unwrap().name.clone() }) .expect("Unable to insert"); } // 调用效果系统 add_effect( Some(entity), EffectType::ItemUse{ item : useitem.item }, match useitem.target { None => Targets::Single{ target: *player_entity }, Some(target) => { if let Some(aoe) = aoe.get(useitem.item) { Targets::Tiles{ tiles: aoe_tiles(&*map, target, aoe.radius) } } else { Targets::Tile{ tile_idx : map.xy_idx(target.x, target.y) as i32 } } } } ); } wants_use.clear(); } } }
这是一个很大的改进! 小得多,而且非常容易理解。
现在我们需要完成各种与物品相关的事件,并使其发挥作用。
喂食时间
我们将从食物开始。 任何带有 ProvidesFood 组件标签的物品都会将进食者的饥饿时钟设置回 Well Fed。 我们将首先为此添加一个事件类型:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 }, EntityDeath, ItemUse { item: Entity }, WellFed, } }
现在,我们将创建一个新文件 - effects/hunger.rs 并将处理此问题的核心内容放入其中(不要忘记在 effects/mod.rs 中添加 mod hunger;!):
#![allow(unused)] fn main() { use specs::prelude::*; use super::*; use crate::components::{HungerClock, HungerState}; pub fn well_fed(ecs: &mut World, _damage: &EffectSpawner, target: Entity) { if let Some(hc) = ecs.write_storage::<HungerClock>().get_mut(target) { hc.state = HungerState::WellFed; hc.duration = 20; } } }
非常简单,并且直接来自原始代码。 我们需要食物影响实体,而不仅仅是位置(以防您制作类似自动售货机的东西,在某个区域分发食物!):
#![allow(unused)] fn main() { fn tile_effect_hits_entities(effect: &EffectType) -> bool { match effect { EffectType::Damage{..} => true, EffectType::WellFed => true, _ => false } } }
我们还需要调用该函数:
#![allow(unused)] fn main() { fn affect_entity(ecs: &mut World, effect: &EffectSpawner, target: Entity) { match &effect.effect_type { EffectType::Damage{..} => damage::inflict_damage(ecs, effect, target), EffectType::EntityDeath => damage::death(ecs, effect, target), EffectType::Bloodstain{..} => if let Some(pos) = entity_position(ecs, target) { damage::bloodstain(ecs, pos) }, EffectType::Particle{..} => if let Some(pos) = entity_position(ecs, target) { particles::particle_to_tile(ecs, pos, &effect) }, EffectType::WellFed => hunger::well_fed(ecs, effect, target), _ => {} } } }
最后,我们需要将其添加到 effects/triggers.rs 中的 event_trigger 函数中:
#![allow(unused)] fn main() { fn event_trigger(creator : Option<Entity>, entity: Entity, targets : &Targets, ecs: &mut World) { let mut gamelog = ecs.fetch_mut::<GameLog>(); // 提供食物 if ecs.read_storage::<ProvidesFood>().get(entity).is_some() { add_effect(creator, EffectType::WellFed, targets.clone()); let names = ecs.read_storage::<Name>(); gamelog.entries.push(format!("You eat the {}.", names.get(entity).unwrap().name)); } } }
如果您现在 cargo run,您可以吃掉您的口粮并再次吃饱。
魔法地图
魔法地图有点特殊,因为需要切换回用户界面进行更新。 它也很简单,因此我们将在 event_trigger 中完全处理它:
#![allow(unused)] fn main() { // 魔法地图 if ecs.read_storage::<MagicMapper>().get(entity).is_some() { let mut runstate = ecs.fetch_mut::<RunState>(); gamelog.entries.push("The map is revealed to you!".to_string()); *runstate = RunState::MagicMapReveal{ row : 0}; } }
就像旧的物品使用系统中的代码一样:它将运行状态设置为 MagicMapReveal 并播放日志消息。 您可以 cargo run,魔法地图现在可以工作了。
城镇传送门
城镇传送门也有点特殊,因此我们也将在 event_trigger 中处理它们:
#![allow(unused)] fn main() { // 城镇传送门 if ecs.read_storage::<TownPortal>().get(entity).is_some() { let map = ecs.fetch::<Map>(); if map.depth == 1 { gamelog.entries.push("You are already in town, so the scroll does nothing.".to_string()); } else { gamelog.entries.push("You are telported back to town!".to_string()); let mut runstate = ecs.fetch_mut::<RunState>(); *runstate = RunState::TownPortal; } } }
再一次,这基本上是旧代码 - 已重新定位。
治疗
治疗是一种更通用的效果,我们很可能会在多个地方使用它。 很容易想象到一个带有入口触发器的道具可以治愈您(魔法恢复区、电子修复店 - 您的想象力是无限的!),或者使用时可以治愈的物品(例如药水)。 因此,我们将 Healing 添加到 mod.rs 中的效果类型中:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 }, EntityDeath, ItemUse { item: Entity }, WellFed, Healing { amount : i32 }, Confusion { turns : i32 } } }
治疗影响实体而不是瓦片,因此我们将标记这一点:
#![allow(unused)] fn main() { fn tile_effect_hits_entities(effect: &EffectType) -> bool { match effect { EffectType::Damage{..} => true, EffectType::WellFed => true, EffectType::Healing{..} => true, _ => false } } }
由于治疗基本上是反向伤害,我们将在 effects/damage.rs 文件中添加一个函数来处理治疗:
#![allow(unused)] fn main() { pub fn heal_damage(ecs: &mut World, heal: &EffectSpawner, target: Entity) { let mut pools = ecs.write_storage::<Pools>(); if let Some(pool) = pools.get_mut(target) { if let EffectType::Healing{amount} = heal.effect_type { pool.hit_points.current = i32::min(pool.hit_points.max, pool.hit_points.current + amount); add_effect(None, EffectType::Particle{ glyph: rltk::to_cp437('‼'), fg : rltk::RGB::named(rltk::GREEN), bg : rltk::RGB::named(rltk::BLACK), lifespan: 200.0 }, Targets::Single{target} ); } } } }
这与旧的治疗代码类似,但我们添加了一个绿色粒子来显示实体已获得治疗。 现在我们需要教 mod.rs 中的 affect_entity 应用治疗:
#![allow(unused)] fn main() { fn affect_entity(ecs: &mut World, effect: &EffectSpawner, target: Entity) { match &effect.effect_type { EffectType::Damage{..} => damage::inflict_damage(ecs, effect, target), EffectType::EntityDeath => damage::death(ecs, effect, target), EffectType::Bloodstain{..} => if let Some(pos) = entity_position(ecs, target) { damage::bloodstain(ecs, pos) }, EffectType::Particle{..} => if let Some(pos) = entity_position(ecs, target) { particles::particle_to_tile(ecs, pos, &effect) }, EffectType::WellFed => hunger::well_fed(ecs, effect, target), EffectType::Healing{..} => damage::heal_damage(ecs, effect, target), _ => {} } } }
最后,我们在 event_trigger 函数中添加对 ProvidesHealing 标签的支持:
#![allow(unused)] fn main() { // 治疗 if let Some(heal) = ecs.read_storage::<ProvidesHealing>().get(entity) { add_effect(creator, EffectType::Healing{amount: heal.heal_amount}, targets.clone()); } }
如果您现在 cargo run,您的治疗药水现在可以工作了。
伤害
我们已经编写了处理伤害所需的大部分内容,因此我们可以将其添加到 event_trigger 中:
#![allow(unused)] fn main() { // 伤害 if let Some(damage) = ecs.read_storage::<InflictsDamage>().get(entity) { add_effect(creator, EffectType::Damage{ amount: damage.damage }, targets.clone()); } }
由于我们已经通过目标选择涵盖了范围效果和类似效果,并且伤害代码来自近战改造 - 这将使魔法飞弹、火球和类似效果起作用。
混乱
混乱需要以类似于饥饿的方式处理。 我们添加一个事件类型:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 }, EntityDeath, ItemUse { item: Entity }, WellFed, Healing { amount : i32 }, Confusion { turns : i32 } } }
将其标记为影响实体:
#![allow(unused)] fn main() { fn tile_effect_hits_entities(effect: &EffectType) -> bool { match effect { EffectType::Damage{..} => true, EffectType::WellFed => true, EffectType::Healing{..} => true, EffectType::Confusion{..} => true, _ => false } } }
在 damage.rs 文件中添加一个方法:
#![allow(unused)] fn main() { pub fn add_confusion(ecs: &mut World, effect: &EffectSpawner, target: Entity) { if let EffectType::Confusion{turns} = &effect.effect_type { ecs.write_storage::<Confusion>().insert(target, Confusion{ turns: *turns }).expect("Unable to insert status"); } } }
将其包含在 affect_entity 中:
#![allow(unused)] fn main() { fn affect_entity(ecs: &mut World, effect: &EffectSpawner, target: Entity) { match &effect.effect_type { EffectType::Damage{..} => damage::inflict_damage(ecs, effect, target), EffectType::EntityDeath => damage::death(ecs, effect, target), EffectType::Bloodstain{..} => if let Some(pos) = entity_position(ecs, target) { damage::bloodstain(ecs, pos) }, EffectType::Particle{..} => if let Some(pos) = entity_position(ecs, target) { particles::particle_to_tile(ecs, pos, &effect) }, EffectType::WellFed => hunger::well_fed(ecs, effect, target), EffectType::Healing{..} => damage::heal_damage(ecs, effect, target), EffectType::Confusion{..} => damage::add_confusion(ecs, effect, target), _ => {} } } }
最后,在 event_trigger 中支持它:
#![allow(unused)] fn main() { // 混乱 if let Some(confusion) = ecs.read_storage::<Confusion>().get(entity) { add_effect(creator, EffectType::Confusion{ turns : confusion.turns }, targets.clone()); } }
这就足以使混乱效果起作用。
触发器
既然我们已经有了一个适用于物品的工作系统(它非常灵活;您可以根据需要混合和匹配标签,并且所有效果都会触发),我们需要对触发器执行相同的操作。 我们将首先为它们提供一个进入效果 API 的入口点,就像我们对物品所做的那样。 在 effects/mod.rs 中,我们将进一步扩展物品效果枚举:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 }, EntityDeath, ItemUse { item: Entity }, WellFed, Healing { amount : i32 }, Confusion { turns : i32 }, TriggerFire { trigger: Entity } } }
我们还将特殊处理它的激活:
#![allow(unused)] fn main() { fn target_applicator(ecs : &mut World, effect : &EffectSpawner) { if let EffectType::ItemUse{item} = effect.effect_type { triggers::item_trigger(effect.creator, item, &effect.targets, ecs); } else if let EffectType::TriggerFire{trigger} = effect.effect_type { triggers::trigger(effect.creator, trigger, &effect.targets, ecs); } else { match &effect.targets { Targets::Tile{tile_idx} => affect_tile(ecs, effect, *tile_idx), Targets::Tiles{tiles} => tiles.iter().for_each(|tile_idx| affect_tile(ecs, effect, *tile_idx)), Targets::Single{target} => affect_entity(ecs, effect, *target), Targets::TargetList{targets} => targets.iter().for_each(|entity| affect_entity(ecs, effect, *entity)), } } } }
现在在 effects/triggers.rs 中,我们需要添加 trigger 作为公共函数:
#![allow(unused)] fn main() { pub fn trigger(creator : Option<Entity>, trigger: Entity, targets : &Targets, ecs: &mut World) { // 触发物品不再隐藏 ecs.write_storage::<Hidden>().remove(trigger); // 通过通用系统使用物品 event_trigger(creator, trigger, targets, ecs); // 如果它是单次激活,则将其删除 if ecs.read_storage::<SingleActivation>().get(trigger).is_some() { ecs.entities().delete(trigger).expect("Delete Failed"); } } }
现在我们有了一个框架,我们可以进入 trigger_system.rs。 就像物品效果一样,它可以大大简化; 我们真的只需要检查是否发生了激活 - 并调用事件系统:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{EntityMoved, Position, EntryTrigger, Map, Name, gamelog::GameLog, effects::*, AreaOfEffect}; pub struct TriggerSystem {} impl<'a> System<'a> for TriggerSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Map>, WriteStorage<'a, EntityMoved>, ReadStorage<'a, Position>, ReadStorage<'a, EntryTrigger>, ReadStorage<'a, Name>, Entities<'a>, WriteExpect<'a, GameLog>, ReadStorage<'a, AreaOfEffect>); fn run(&mut self, data : Self::SystemData) { let (map, mut entity_moved, position, entry_trigger, names, entities, mut log, area_of_effect) = data; // 迭代移动的实体及其最终位置 for (entity, mut _entity_moved, pos) in (&entities, &mut entity_moved, &position).join() { let idx = map.xy_idx(pos.x, pos.y); for entity_id in map.tile_content[idx].iter() { if entity != *entity_id { // 不要费心检查自己是否是陷阱! let maybe_trigger = entry_trigger.get(*entity_id); match maybe_trigger { None => {}, Some(_trigger) => { // 我们触发了它 let name = names.get(*entity_id); if let Some(name) = name { log.entries.push(format!("{} triggers!", &name.name)); } // 调用效果系统 add_effect( Some(entity), EffectType::TriggerFire{ trigger : *entity_id }, if let Some(aoe) = area_of_effect.get(*entity_id) { Targets::Tiles{ tiles : aoe_tiles(&*map, rltk::Point::new(pos.x, pos.y), aoe.radius) } } else { Targets::Tile{ tile_idx: idx as i32 } } ); } } } } } // 删除所有实体移动标记 entity_moved.clear(); } } }
我们只有一个触发器尚未作为效果实现:传送。 让我们在 effects/mod.rs 中将其添加为效果类型:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 }, EntityDeath, ItemUse { item: Entity }, WellFed, Healing { amount : i32 }, Confusion { turns : i32 }, TriggerFire { trigger: Entity }, TeleportTo { x:i32, y:i32, depth: i32, player_only : bool } } }
它影响实体,因此我们将标记这一事实:
#![allow(unused)] fn main() { fn tile_effect_hits_entities(effect: &EffectType) -> bool { match effect { EffectType::Damage{..} => true, EffectType::WellFed => true, EffectType::Healing{..} => true, EffectType::Confusion{..} => true, EffectType::TeleportTo{..} => true, _ => false } } }
affect_entity 应该调用它:
#![allow(unused)] fn main() { fn affect_entity(ecs: &mut World, effect: &EffectSpawner, target: Entity) { match &effect.effect_type { EffectType::Damage{..} => damage::inflict_damage(ecs, effect, target), EffectType::EntityDeath => damage::death(ecs, effect, target), EffectType::Bloodstain{..} => if let Some(pos) = entity_position(ecs, target) { damage::bloodstain(ecs, pos) }, EffectType::Particle{..} => if let Some(pos) = entity_position(ecs, target) { particles::particle_to_tile(ecs, pos, &effect) }, EffectType::WellFed => hunger::well_fed(ecs, effect, target), EffectType::Healing{..} => damage::heal_damage(ecs, effect, target), EffectType::Confusion{..} => damage::add_confusion(ecs, effect, target), EffectType::TeleportTo{..} => movement::apply_teleport(ecs, effect, target), _ => {} } } }
我们还需要将其添加到 effects/triggers.rs 中的 event_trigger:
#![allow(unused)] fn main() { // 传送 if let Some(teleport) = ecs.read_storage::<TeleportTo>().get(entity) { add_effect( creator, EffectType::TeleportTo{ x : teleport.x, y : teleport.y, depth: teleport.depth, player_only: teleport.player_only }, targets.clone() ); } }
最后,我们将实现它。 创建一个新文件 effects/movement.rs 并将以下内容粘贴到其中:
#![allow(unused)] fn main() { use specs::prelude::*; use super::*; use crate::components::{ApplyTeleport}; pub fn apply_teleport(ecs: &mut World, destination: &EffectSpawner, target: Entity) { let player_entity = ecs.fetch::<Entity>(); if let EffectType::TeleportTo{x, y, depth, player_only} = &destination.effect_type { if !player_only || target == *player_entity { let mut apply_teleport = ecs.write_storage::<ApplyTeleport>(); apply_teleport.insert(target, ApplyTeleport{ dest_x : *x, dest_y : *y, dest_depth : *depth }).expect("Unable to insert"); } } } }
现在 cargo run 该项目,然后继续尝试一些触发器。 城镇传送门和陷阱是明显的例子。 您应该能够像以前一样使用传送门并遭受陷阱伤害。
将单次使用限制为在它做了某事时
您可能已经注意到,我们正在拿走您的城镇传送门卷轴,即使它没有激活。 我们正在拿走传送器,即使它实际上没有发射(因为它仅限玩家使用)。 这需要修复! 我们将修改 event_trigger 以返回 bool - true 如果它做了某些事情,false 如果它没有。 这是执行此操作的版本:
#![allow(unused)] fn main() { fn event_trigger(creator : Option<Entity>, entity: Entity, targets : &Targets, ecs: &mut World) -> bool { let mut did_something = false; let mut gamelog = ecs.fetch_mut::<GameLog>(); // 提供食物 if ecs.read_storage::<ProvidesFood>().get(entity).is_some() { add_effect(creator, EffectType::WellFed, targets.clone()); let names = ecs.read_storage::<Name>(); gamelog.entries.push(format!("You eat the {}.", names.get(entity).unwrap().name)); did_something = true; } // 魔法地图 if ecs.read_storage::<MagicMapper>().get(entity).is_some() { let mut runstate = ecs.fetch_mut::<RunState>(); gamelog.entries.push("The map is revealed to you!".to_string()); *runstate = RunState::MagicMapReveal{ row : 0}; did_something = true; } // 城镇传送门 if ecs.read_storage::<TownPortal>().get(entity).is_some() { let map = ecs.fetch::<Map>(); if map.depth == 1 { gamelog.entries.push("You are already in town, so the scroll does nothing.".to_string()); } else { gamelog.entries.push("You are telported back to town!".to_string()); let mut runstate = ecs.fetch_mut::<RunState>(); *runstate = RunState::TownPortal; did_something = true; } } // 治疗 if let Some(heal) = ecs.read_storage::<ProvidesHealing>().get(entity) { add_effect(creator, EffectType::Healing{amount: heal.heal_amount}, targets.clone()); did_something = true; } // 伤害 if let Some(damage) = ecs.read_storage::<InflictsDamage>().get(entity) { add_effect(creator, EffectType::Damage{ amount: damage.damage }, targets.clone()); did_something = true; } // 混乱 if let Some(confusion) = ecs.read_storage::<Confusion>().get(entity) { add_effect(creator, EffectType::Confusion{ turns : confusion.turns }, targets.clone()); did_something = true; } // 传送 if let Some(teleport) = ecs.read_storage::<TeleportTo>().get(entity) { add_effect( creator, EffectType::TeleportTo{ x : teleport.x, y : teleport.y, depth: teleport.depth, player_only: teleport.player_only }, targets.clone() ); did_something = true; } did_something } }
现在我们需要修改我们的入口点,以便仅删除实际使用的物品:
#![allow(unused)] fn main() { pub fn item_trigger(creator : Option<Entity>, item: Entity, targets : &Targets, ecs: &mut World) { // 通过通用系统使用物品 let did_something = event_trigger(creator, item, targets, ecs); // 如果它是消耗品,则将其删除 if did_something && ecs.read_storage::<Consumable>().get(item).is_some() { ecs.entities().delete(item).expect("Delete Failed"); } } pub fn trigger(creator : Option<Entity>, trigger: Entity, targets : &Targets, ecs: &mut World) { // 触发物品不再隐藏 ecs.write_storage::<Hidden>().remove(trigger); // 通过通用系统使用物品 let did_something = event_trigger(creator, trigger, targets, ecs); // 如果它是单次激活,则将其删除 if did_something && ecs.read_storage::<SingleActivation>().get(trigger).is_some() { ecs.entities().delete(trigger).expect("Delete Failed"); } } }
清理
现在我们已经有了这个系统,我们可以清理所有其他类型的系统。 我们首先可以做的是从 components.rs 中删除 SufferDamage 组件(并从 main.rs 和 saveload_system.rs 中删除它)。 删除此项会导致编译器找到一些我们在没有使用效果系统的情况下造成伤害的地方!
在 hunger_system.rs 中,我们可以用以下内容替换 SufferDamage 代码:
#![allow(unused)] fn main() { HungerState::Starving => { // 饥饿造成的伤害 if entity == *player_entity { log.entries.push("Your hunger pangs are getting painful! You suffer 1 hp damage.".to_string()); } add_effect( None, EffectType::Damage{ amount: 1}, Targets::Single{ target: entity } ); } }
我们还可以打开 damage_system.rs 并删除实际的 DamageSystem(但保留 delete_the_dead)。 我们还需要从 main.rs 中的 run_systems 中删除它。
通用生成代码
在 raws/rawmaster.rs 中,我们仍然重复解析物品的可能效果。 不幸的是,传递 EntityBuilder 对象 (eb) 会导致一些生命周期问题,这些问题导致 Rust 编译器拒绝看起来完全有效的代码。 因此,我们将使用宏来解决这个问题。 在 spawn_named_item 之前:
#![allow(unused)] fn main() { macro_rules! apply_effects { ( $effects:expr, $eb:expr ) => { for effect in $effects.iter() { let effect_name = effect.0.as_str(); match effect_name { "provides_healing" => $eb = $eb.with(ProvidesHealing{ heal_amount: effect.1.parse::<i32>().unwrap() }), "ranged" => $eb = $eb.with(Ranged{ range: effect.1.parse::<i32>().unwrap() }), "damage" => $eb = $eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }), "area_of_effect" => $eb = $eb.with(AreaOfEffect{ radius: effect.1.parse::<i32>().unwrap() }), "confusion" => $eb = $eb.with(Confusion{ turns: effect.1.parse::<i32>().unwrap() }), "magic_mapping" => $eb = $eb.with(MagicMapper{}), "town_portal" => $eb = $eb.with(TownPortal{}), "food" => $eb = $eb.with(ProvidesFood{}), "single_activation" => $eb = $eb.with(SingleActivation{}), _ => rltk::console::log(format!("Warning: consumable effect {} not implemented.", effect_name)) } } }; } }
因此,这就像一个函数,但它遵循相当复杂的宏语法。 基本上,我们将宏定义为期望 effects 和 eb 作为表达式 - 也就是说,我们并不真正在意它们是什么,我们将执行文本替换(在编译之前)以将它们插入到发出的代码中。 (宏基本上是在调用站点复制/粘贴到您的代码中的,但表达式被替换了)。 深入研究 spawn_named_item,您会看到在消耗品部分,我们正在使用此代码。 我们现在可以用以下内容替换它:
#![allow(unused)] fn main() { if let Some(consumable) = &item_template.consumable { eb = eb.with(crate::components::Consumable{}); apply_effects!(consumable.effects, eb); } }
如果我们转到 spawn_named_prop,您会看到我们正在做基本相同的事情:
#![allow(unused)] fn main() { for effect in entry_trigger.effects.iter() { match effect.0.as_str() { "damage" => { eb = eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }) } "single_activation" => { eb = eb.with(SingleActivation{}) } _ => {} } } }
我们现在可以用另一个对宏的调用来替换它:
#![allow(unused)] fn main() { if let Some(entry_trigger) = &prop_template.entry_trigger { eb = eb.with(EntryTrigger{}); apply_effects!(entry_trigger.effects, eb); } }
毫无疑问,我们稍后会添加更多 - 用于武器“触发”、法术发射以及使用后不会消耗的物品。 进行此更改意味着相同的定义 JSON 适用于入口触发器和消耗品效果 - 因此任何可以与其中一个效果一起使用的效果都可以与另一个效果一起使用。
这种方法如何提供帮助的一些示例
让我们在神庙中添加一个新的道具:一个可以治愈您的祭坛。 打开 map_builders/town.rs 并找到 build_temple 函数。 将 Altar 添加到道具列表中:
#![allow(unused)] fn main() { fn build_temple(&mut self, building: &(i32, i32, i32, i32), build_data : &mut BuilderMap, rng: &mut rltk::RandomNumberGenerator) { // 放置物品 let mut to_place : Vec<&str> = vec!["Priest", "Altar", "Parishioner", "Parishioner", "Chair", "Chair", "Candle", "Candle"]; self.random_building_spawn(building, build_data, rng, &mut to_place, 0); } }
现在在 spawns.json 中,我们将 Altar 添加到道具列表中:
{
"name" : "Altar",
"renderable": {
"glyph" : "╫",
"fg" : "#55FF55",
"bg" : "#000000",
"order" : 2
},
"hidden" : false,
"entry_trigger" : {
"effects" : {
"provides_healing" : "100"
}
}
},
您现在可以 cargo run 该项目,损失一些生命值,然后去神庙免费治疗。 我们在没有额外代码的情况下实现了它,因为我们正在共享来自其他物品的效果属性。 从现在开始,当我们添加效果时 - 我们可以随时随地轻松地实现它们。
恢复魔法飞弹和火球的视觉效果
我们重构的一个副作用是,当您施放火球时,您不再获得火焰效果(只有伤害指示器)。 当您用魔法飞弹射击时,您也不会获得漂亮的线条,或者当您使某人混乱时也不会获得标记。 这是故意的 - 之前的范围效果代码显示了任何 AoE 攻击的火球效果! 我们可以通过支持效果作为物品定义的一部分来制作更灵活的系统。
让我们首先用我们希望它们做的事情来装饰 spawns.json 中的两个卷轴:
{
"name" : "Magic Missile Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20",
"particle_line" : "*;#00FFFF;200.0"
}
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "scroll" }
},
{
"name" : "Fireball Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#FFA500",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20",
"area_of_effect" : "3",
"particle" : "*;#FFA500;200.0"
}
},
"weight_lbs" : 0.5,
"base_value" : 100.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "scroll" }
},
我们添加了两个新条目 - particle 和 particle_line。 它们都采用相当神秘的字符串(因为我们正在以字符串形式传递参数)。 这是一个分号分隔的列表。 第一个参数是字形,第二个参数是 RGB 格式的颜色,最后一个参数是生命周期。
现在我们需要几个新组件(在 components.rs 中,并在 main.rs 和 saveload_system.rs 中注册)来存储此信息:
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct SpawnParticleLine { pub glyph : rltk::FontCharType, pub color : RGB, pub lifetime_ms : f32 } #[derive(Component, Serialize, Deserialize, Clone)] pub struct SpawnParticleBurst { pub glyph : rltk::FontCharType, pub color : RGB, pub lifetime_ms : f32 } }
现在在 raws/rawmaster.rs 中,我们需要将其解析为效果并附加新组件:
#![allow(unused)] fn main() { fn parse_particle_line(n : &str) -> SpawnParticleLine { let tokens : Vec<_> = n.split(';').collect(); SpawnParticleLine{ glyph : rltk::to_cp437(tokens[0].chars().next().unwrap()), color : rltk::RGB::from_hex(tokens[1]).expect("Bad RGB"), lifetime_ms : tokens[2].parse::<f32>().unwrap() } } fn parse_particle(n : &str) -> SpawnParticleBurst { let tokens : Vec<_> = n.split(';').collect(); SpawnParticleBurst{ glyph : rltk::to_cp437(tokens[0].chars().next().unwrap()), color : rltk::RGB::from_hex(tokens[1]).expect("Bad RGB"), lifetime_ms : tokens[2].parse::<f32>().unwrap() } } macro_rules! apply_effects { ( $effects:expr, $eb:expr ) => { for effect in $effects.iter() { let effect_name = effect.0.as_str(); match effect_name { "provides_healing" => $eb = $eb.with(ProvidesHealing{ heal_amount: effect.1.parse::<i32>().unwrap() }), "ranged" => $eb = $eb.with(Ranged{ range: effect.1.parse::<i32>().unwrap() }), "damage" => $eb = $eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }), "area_of_effect" => $eb = $eb.with(AreaOfEffect{ radius: effect.1.parse::<i32>().unwrap() }), "confusion" => $eb = $eb.with(Confusion{ turns: effect.1.parse::<i32>().unwrap() }), "magic_mapping" => $eb = $eb.with(MagicMapper{}), "town_portal" => $eb = $eb.with(TownPortal{}), "food" => $eb = $eb.with(ProvidesFood{}), "single_activation" => $eb = $eb.with(SingleActivation{}), "particle_line" => $eb = $eb.with(parse_particle_line(&effect.1)), "particle" => $eb = $eb.with(parse_particle(&effect.1)), _ => rltk::console::log(format!("Warning: consumable effect {} not implemented.", effect_name)) } } }; } }
实现粒子爆发就像进入 effects/triggers.rs 并在 event_trigger 函数的开头添加以下内容一样简单(因此它会在伤害之前触发,使伤害指示器仍然出现):
#![allow(unused)] fn main() { fn event_trigger(creator : Option<Entity>, entity: Entity, targets : &Targets, ecs: &mut World) -> bool { let mut did_something = false; let mut gamelog = ecs.fetch_mut::<GameLog>(); // 简单粒子生成 if let Some(part) = ecs.read_storage::<SpawnParticleBurst>().get(entity) { add_effect( creator, EffectType::Particle{ glyph : part.glyph, fg : part.color, bg : rltk::RGB::named(rltk::BLACK), lifespan : part.lifetime_ms }, targets.clone() ); } ... }
线条粒子生成更困难,但还不错。 一个问题是我们实际上不知道物品在哪里! 我们将纠正这一点; 在 effects/targeting.rs 中,我们添加一个新函数:
#![allow(unused)] fn main() { pub fn find_item_position(ecs: &World, target: Entity) -> Option<i32> { let positions = ecs.read_storage::<Position>(); let map = ecs.fetch::<Map>(); // 简单 - 它有一个位置 if let Some(pos) = positions.get(target) { return Some(map.xy_idx(pos.x, pos.y) as i32); } // 也许它被携带了? if let Some(carried) = ecs.read_storage::<InBackpack>().get(target) { if let Some(pos) = positions.get(carried.owner) { return Some(map.xy_idx(pos.x, pos.y) as i32); } } // 也许它被装备了? if let Some(equipped) = ecs.read_storage::<Equipped>().get(target) { if let Some(pos) = positions.get(equipped.owner) { return Some(map.xy_idx(pos.x, pos.y) as i32); } } // 不知道 - 放弃 None } }
此函数首先检查物品是否具有位置(因为它在地面上)。 如果有,则返回它。 然后它查看它是否在背包中; 如果是,它会尝试返回背包所有者的位置。 为装备的物品重复。 如果仍然不知道,则返回 None。
我们可以将以下内容添加到我们的 event_trigger 函数中,以处理每种目标情况的线条生成:
#![allow(unused)] fn main() { // 线条粒子生成 if let Some(part) = ecs.read_storage::<SpawnParticleLine>().get(entity) { if let Some(start_pos) = targeting::find_item_position(ecs, entity) { match targets { Targets::Tile{tile_idx} => spawn_line_particles(ecs, start_pos, *tile_idx, part), Targets::Tiles{tiles} => tiles.iter().for_each(|tile_idx| spawn_line_particles(ecs, start_pos, *tile_idx, part)), Targets::Single{ target } => { if let Some(end_pos) = entity_position(ecs, *target) { spawn_line_particles(ecs, start_pos, end_pos, part); } } Targets::TargetList{ targets } => { targets.iter().for_each(|target| { if let Some(end_pos) = entity_position(ecs, *target) { spawn_line_particles(ecs, start_pos, end_pos, part); } }); } } } } }
每种情况都调用 spawn_line_particles,所以我们也来编写它:
#![allow(unused)] fn main() { fn spawn_line_particles(ecs:&World, start: i32, end: i32, part: &SpawnParticleLine) { let map = ecs.fetch::<Map>(); let start_pt = rltk::Point::new(start % map.width, end / map.width); let end_pt = rltk::Point::new(end % map.width, end / map.width); let line = rltk::line2d(rltk::LineAlg::Bresenham, start_pt, end_pt); for pt in line.iter() { add_effect( None, EffectType::Particle{ glyph : part.glyph, fg : part.color, bg : rltk::RGB::named(rltk::BLACK), lifespan : part.lifetime_ms }, Targets::Tile{ tile_idx : map.xy_idx(pt.x, pt.y) as i32} ); } } }
这非常简单:它在起点和终点之间绘制一条线,并在每个瓦片上放置一个粒子。
您现在可以 cargo run 并享受火球和魔法飞弹的效果。
总结
这是一个巨大的章节,其中包含表面上没有做太多事情的更改。 但是,我们已经获得了很大的收获:
- 物品栏系统现在易于理解。
- 通用效果系统现在可以将任何效果应用于物品或触发器,并且可以轻松扩展新物品,而不会遇到
Specs限制。 - 责任分配减少了很多:系统不再需要记住显示伤害粒子,甚至不需要了解粒子如何工作 - 它们只是请求它们。 系统通常可以不必担心位置,并以一致的方式应用位置效果(包括 AoE)。
- 我们现在拥有足够灵活的系统,可以让我们构建大型、有凝聚力的效果 - 而无需过多担心细节。
本章很好地说明了 ECS 的局限性 - 以及如何利用它来发挥您的优势。 通过使用组件作为标志,我们可以轻松地组合效果 - 可以治愈您并使您感到困惑的药水就像组合两个标签一样简单。 但是,Specs 实际上与一次读取大量数据存储的系统配合不好 - 因此我们通过在系统之上添加消息传递来解决它。 这很常见:即使是基于 ECS 的引擎 Amethyst 也为此目的实现了消息传递系统。
...
本章的源代码可以在此处找到
使用 Web Assembly 在浏览器中运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
诅咒物品以及应对方法
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此你可以随意使用它。我希望你喜欢本教程,并制作出色的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
现在我们有了一个坚实的魔法物品框架,是时候加入诅咒物品了。诅咒物品是 Roguelike 类型游戏的主要内容,但如果过度使用,真的会惹恼你的玩家!诅咒物品是物品识别迷你游戏的一部分:它们为在知道物品作用之前装备/使用物品提供了风险。如果装备你找到的所有东西没有风险,玩家就会这样做以找出它们是什么 - 那么迷你游戏就毫无意义了。另一方面,如果有太多诅咒物品,玩家在物品使用上就会变得非常保守,并且在确定物品是什么之前不会碰它们。所以,就像生活中的许多事情一样,这是一个难以把握的平衡。
你的基础 -1 长剑
作为一个简单的例子,我们将从实现一把诅咒长剑开始。我们已经有了 Longsword +1,所以定义一个具有惩罚而不是增益的 JSON(来自 spawns.json)相对容易:
{
"name" : "Longsword -1",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAFF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "might",
"base_damage" : "1d8-1",
"hit_bonus" : -1
},
"weight_lbs" : 2.0,
"base_value" : 100.0,
"initiative_penalty" : 3,
"vendor_category" : "weapon",
"magic" : { "class" : "common", "naming" : "Unidentified Longsword", "cursed" : true }
},
你会注意到这里有一个命中和伤害惩罚,更多的先攻惩罚,并且我们将 cursed: true 添加到 magic 部分。大部分内容已经可以工作了,但 cursed 部分是新的。为了开始支持这一点,我们打开 raws/item_structs.rs 并添加模板支持:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct MagicItem { pub class: String, pub naming: String, pub cursed: Option<bool> } }
我们将其设为 Option - 因此对于非诅咒物品,你不必指定它。现在我们需要一个新的组件来指示物品实际上是被诅咒的。在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct CursedItem {} }
接下来,我们将调整 raws/rawmaster.rs 中的 spawn_named_item 函数,以处理将 CursedItem 组件添加到诅咒物品:
#![allow(unused)] fn main() { if let Some(magic) = &item_template.magic { let class = match magic.class.as_str() { "rare" => MagicItemClass::Rare, "legendary" => MagicItemClass::Legendary, _ => MagicItemClass::Common }; eb = eb.with(MagicItem{ class }); if !identified.contains(&item_template.name) { match magic.naming.as_str() { "scroll" => { eb = eb.with(ObfuscatedName{ name : scroll_names[&item_template.name].clone() }); } "potion" => { eb = eb.with(ObfuscatedName{ name: potion_names[&item_template.name].clone() }); } _ => { eb = eb.with(ObfuscatedName{ name : magic.naming.clone() }); } } } if let Some(cursed) = magic.cursed { if cursed { eb = eb.with(CursedItem{}); } } } }
让我们回到 spawns.json 并给它们一个生成的机率。现在,我们将使它们出现在任何地方,以便于测试它们:
{ "name" : "Longsword -1", "weight" : 100, "min_depth" : 1, "max_depth" : 100 },
这样我们就做得足够多了,你可以运行游戏,诅咒长剑将会出现并且具有较差的战斗性能。物品识别已经可以工作了,所以装备一把诅咒的剑会告诉你它是什么 - 但是这样做绝对没有任何惩罚,除了当你使用它时它具有较差的属性。这是一个好的开始!
让玩家知道它是被诅咒的
在 gui.rs 中,我们在 get_item_color 函数中仔细地按类别为物品着色。我们希望诅咒物品变成红色 - 但只有当你知道它们被诅咒时(所以你不会看着你的物品栏列表并看到“哦,那是被诅咒的 - 最好不要装备它!”)。所以让我们修改该函数以提供此功能:
#![allow(unused)] fn main() { pub fn get_item_color(ecs : &World, item : Entity) -> RGB { let dm = ecs.fetch::<crate::map::MasterDungeonMap>(); if let Some(name) = ecs.read_storage::<Name>().get(item) { if ecs.read_storage::<CursedItem>().get(item).is_some() && dm.identified_items.contains(&name.name) { return RGB::from_f32(1.0, 0.0, 0.0); } } if let Some(magic) = ecs.read_storage::<MagicItem>().get(item) { match magic.class { MagicItemClass::Common => return RGB::from_f32(0.5, 1.0, 0.5), MagicItemClass::Rare => return RGB::from_f32(0.0, 1.0, 1.0), MagicItemClass::Legendary => return RGB::from_f32(0.71, 0.15, 0.93) } } RGB::from_f32(1.0, 1.0, 1.0) } }

阻止卸下诅咒物品
阻止移除的简单情况是在 inventory_system/remove_system.rs 中:它只是获取物品并将其放入你的背包中。我们可以使其有条件,我们就完成了!这是系统的源代码:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{InBackpack, Equipped, WantsToRemoveItem, CursedItem, Name}; pub struct ItemRemoveSystem {} impl<'a> System<'a> for ItemRemoveSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteStorage<'a, WantsToRemoveItem>, WriteStorage<'a, Equipped>, WriteStorage<'a, InBackpack>, ReadStorage<'a, CursedItem>, WriteExpect<'a, crate::gamelog::GameLog>, ReadStorage<'a, Name> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut wants_remove, mut equipped, mut backpack, cursed, mut gamelog, names) = data; for (entity, to_remove) in (&entities, &wants_remove).join() { if cursed.get(to_remove.item).is_some() { gamelog.entries.push(format!("You cannot remove {}, it is cursed", names.get(to_remove.item).unwrap().name)); } else { equipped.remove(to_remove.item); backpack.insert(to_remove.item, InBackpack{ owner: entity }).expect("Unable to insert backpack"); } } wants_remove.clear(); } } }
equip_use.rs 的情况有点复杂。我们装备物品,扫描要替换的物品并卸下替换物品。我们将不得不稍微改变一下:查找要移除的东西,看看它是否被诅咒(如果是,则取消并显示消息),然后如果它仍然有效,则实际执行交换。我们还希望在你无法装备物品时不识别物品,以避免出现有趣的后门,你装备一个诅咒物品,然后用它来识别所有其他诅咒物品!我们可以像这样调整系统:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Name, InBackpack, gamelog::GameLog, WantsToUseItem, Equippable, Equipped, EquipmentChanged, IdentifiedItem, CursedItem}; pub struct ItemEquipOnUse {} impl<'a> System<'a> for ItemEquipOnUse { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, Entities<'a>, WriteStorage<'a, WantsToUseItem>, ReadStorage<'a, Name>, ReadStorage<'a, Equippable>, WriteStorage<'a, Equipped>, WriteStorage<'a, InBackpack>, WriteStorage<'a, EquipmentChanged>, WriteStorage<'a, IdentifiedItem>, ReadStorage<'a, CursedItem> ); #[allow(clippy::cognitive_complexity)] fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, entities, mut wants_use, names, equippable, mut equipped, mut backpack, mut dirty, mut identified_item, cursed) = data; let mut remove_use : Vec<Entity> = Vec::new(); for (target, useitem) in (&entities, &wants_use).join() { // If it is equippable, then we want to equip it - and unequip whatever else was in that slot if let Some(can_equip) = equippable.get(useitem.item) { let target_slot = can_equip.slot; // Remove any items the target has in the item's slot let mut can_equip = true; let mut log_entries : Vec<String> = Vec::new(); let mut to_unequip : Vec<Entity> = Vec::new(); for (item_entity, already_equipped, name) in (&entities, &equipped, &names).join() { if already_equipped.owner == target && already_equipped.slot == target_slot { if cursed.get(item_entity).is_some() { can_equip = false; gamelog.entries.push(format!("You cannot unequip {}, it is cursed.", name.name)); } else { to_unequip.push(item_entity); if target == *player_entity { log_entries.push(format!("You unequip {}.", name.name)); } } } } if can_equip { // Identify the item if target == *player_entity { identified_item.insert(target, IdentifiedItem{ name: names.get(useitem.item).unwrap().name.clone() }) .expect("Unable to insert"); } for item in to_unequip.iter() { equipped.remove(*item); backpack.insert(*item, InBackpack{ owner: target }).expect("Unable to insert backpack entry"); } for le in log_entries.iter() { gamelog.entries.push(le.to_string()); } // Wield the item equipped.insert(useitem.item, Equipped{ owner: target, slot: target_slot }).expect("Unable to insert equipped component"); backpack.remove(useitem.item); if target == *player_entity { gamelog.entries.push(format!("You equip {}.", names.get(useitem.item).unwrap().name)); } } // Done with item remove_use.push(target); } } remove_use.iter().for_each(|e| { dirty.insert(*e, EquipmentChanged{}).expect("Unable to insert"); wants_use.remove(*e).expect("Unable to remove"); }); } } }
我们将物品识别移到了物品扫描之下,并添加了一个 can_use 布尔值;如果切换会导致卸下诅咒物品,我们取消操作。如果你现在 cargo run 运行项目,你将发现一旦装备上诅咒装备,就无法移除它了:

移除诅咒
既然玩家可能会意外地诅咒自己,那么给他们一种从错误中恢复的方法是个好主意!让我们添加传统的移除诅咒卷轴。在 spawns.json 中,我们将首先定义我们想要什么:
{
"name" : "Remove Curse Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"remove_curse" : ""
}
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "scroll" }
},
我们也应该允许它生成:
{ "name" : "Remove Curse Scroll", "weight" : 4, "min_depth" : 0, "max_depth" : 100 },
那里唯一的新东西是效果:remove_curse。我们将像处理其他效果一样处理它,所以我们首先创建一个新组件来表示“这个 X 触发诅咒移除”。在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct ProvidesRemoveCurse {} }
现在在 raws/rawmaster.rs 中,我们将它添加到效果生成列表(所以它仍然是一个通用能力;例如,你可以有一个可以移除诅咒的神龛):
#![allow(unused)] fn main() { macro_rules! apply_effects { ( $effects:expr, $eb:expr ) => { for effect in $effects.iter() { let effect_name = effect.0.as_str(); match effect_name { "provides_healing" => $eb = $eb.with(ProvidesHealing{ heal_amount: effect.1.parse::<i32>().unwrap() }), "ranged" => $eb = $eb.with(Ranged{ range: effect.1.parse::<i32>().unwrap() }), "damage" => $eb = $eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }), "area_of_effect" => $eb = $eb.with(AreaOfEffect{ radius: effect.1.parse::<i32>().unwrap() }), "confusion" => $eb = $eb.with(Confusion{ turns: effect.1.parse::<i32>().unwrap() }), "magic_mapping" => $eb = $eb.with(MagicMapper{}), "town_portal" => $eb = $eb.with(TownPortal{}), "food" => $eb = $eb.with(ProvidesFood{}), "single_activation" => $eb = $eb.with(SingleActivation{}), "particle_line" => $eb = $eb.with(parse_particle_line(&effect.1)), "particle" => $eb = $eb.with(parse_particle(&effect.1)), "remove_curse" => $eb = $eb.with(ProvidesRemoveCurse{}), _ => rltk::console::log(format!("Warning: consumable effect {} not implemented.", effect_name)) } } }; } }
现在我们已经正确标记了移除诅咒物品,剩下的就是使它们发挥作用!当你使用卷轴时,它应该向你显示所有你知道是被诅咒的物品,并让你选择一个来解除诅咒。这将需要一个新的 RunState,所以我们将在 main.rs 中添加它,并在运行循环中添加一个占位符,以便程序可以编译:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, Ticking, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, PreviousLevel, TownPortal, ShowRemoveItem, GameOver, MagicMapReveal { row : i32 }, MapGeneration, ShowCheatMenu, ShowVendor { vendor: Entity, mode : VendorMode }, TeleportingToOtherLevel { x: i32, y: i32, depth: i32 }, ShowRemoveCurse } ... RunState::ShowRemoveCurse => {} }
我们还将它添加到 Ticking 状态的退出子句中:
#![allow(unused)] fn main() { RunState::Ticking => { while newrunstate == RunState::Ticking { self.run_systems(); self.ecs.maintain(); match *self.ecs.fetch::<RunState>() { RunState::AwaitingInput => newrunstate = RunState::AwaitingInput, RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 }, RunState::TownPortal => newrunstate = RunState::TownPortal, RunState::TeleportingToOtherLevel{ x, y, depth } => newrunstate = RunState::TeleportingToOtherLevel{ x, y, depth }, RunState::ShowRemoveCurse => newrunstate = RunState::ShowRemoveCurse, _ => newrunstate = RunState::Ticking } } } }
现在我们将打开 effects/triggers.rs 并支持运行状态转换(我把它放在魔法地图之后):
#![allow(unused)] fn main() { // Remove Curse if ecs.read_storage::<ProvidesRemoveCurse>().get(entity).is_some() { let mut runstate = ecs.fetch_mut::<RunState>(); *runstate = RunState::ShowRemoveCurse; did_something = true; } }
所以现在我们必须进入 gui.rs 并制作另一个物品列表系统。我们将使用物品掉落/移除系统作为模板,但用一个迭代器替换选择列表,该迭代器移除非诅咒物品和那些被诅咒但你还不知道的物品:
#![allow(unused)] fn main() { pub fn remove_curse_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) { let player_entity = gs.ecs.fetch::<Entity>(); let equipped = gs.ecs.read_storage::<Equipped>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let entities = gs.ecs.entities(); let items = gs.ecs.read_storage::<Item>(); let cursed = gs.ecs.read_storage::<CursedItem>(); let names = gs.ecs.read_storage::<Name>(); let dm = gs.ecs.fetch::<MasterDungeonMap>(); let build_cursed_iterator = || { (&entities, &items, &cursed).join().filter(|(item_entity,_item,_cursed)| { let mut keep = false; if let Some(bp) = backpack.get(*item_entity) { if bp.owner == *player_entity { if let Some(name) = names.get(*item_entity) { if dm.identified_items.contains(&name.name) { keep = true; } } } } // It's equipped, so we know it's cursed if let Some(equip) = equipped.get(*item_entity) { if equip.owner == *player_entity { keep = true; } } keep }) }; let count = build_cursed_iterator().count(); let mut y = (25 - (count / 2)) as i32; ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Remove Curse From Which Item?"); ctx.print_color(18, y+ count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); let mut equippable : Vec<Entity> = Vec::new(); for (j, (entity, _item, _cursed)) in build_cursed_iterator().enumerate() { ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as rltk::FontCharType); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print_color(21, y, get_item_color(&gs.ecs, entity), RGB::from_f32(0.0, 0.0, 0.0), &get_item_display_name(&gs.ecs, entity)); equippable.push(entity); y += 1; } match ctx.key { None => (ItemMenuResult::NoResponse, None), Some(key) => { match key { VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) } _ => { let selection = rltk::letter_to_option(key); if selection > -1 && selection < count as i32 { return (ItemMenuResult::Selected, Some(equippable[selection as usize])); } (ItemMenuResult::NoResponse, None) } } } } } }
然后在 main.rs 中,我们只需要完成逻辑:
#![allow(unused)] fn main() { RunState::ShowRemoveCurse => { let result = gui::remove_curse_menu(self, ctx); match result.0 { gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput, gui::ItemMenuResult::NoResponse => {} gui::ItemMenuResult::Selected => { let item_entity = result.1.unwrap(); self.ecs.write_storage::<CursedItem>().remove(item_entity); newrunstate = RunState::Ticking; } } } }
你现在可以 cargo run,找到一把诅咒剑(它们无处不在),装备它,并使用移除诅咒卷轴将自己从它的控制中解放出来。

鉴定物品
如果你能找到一个不起眼的鉴定卷轴并在你尝试它们之前鉴定魔法物品,这将非常有帮助!这几乎与移除诅咒相同的过程。让我们首先在 spawns.json 中构建物品:
{
"name" : "Identify Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"identify" : ""
}
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "scroll" }
},
再一次,我们需要一个新的组件来表示这种能力。在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct ProvidesIdentification {} }
就像之前一样,我们然后需要在 raws/rawmaster.rs 中将其添加为效果:
#![allow(unused)] fn main() { macro_rules! apply_effects { ( $effects:expr, $eb:expr ) => { for effect in $effects.iter() { let effect_name = effect.0.as_str(); match effect_name { "provides_healing" => $eb = $eb.with(ProvidesHealing{ heal_amount: effect.1.parse::<i32>().unwrap() }), "ranged" => $eb = $eb.with(Ranged{ range: effect.1.parse::<i32>().unwrap() }), "damage" => $eb = $eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }), "area_of_effect" => $eb = $eb.with(AreaOfEffect{ radius: effect.1.parse::<i32>().unwrap() }), "confusion" => $eb = $eb.with(Confusion{ turns: effect.1.parse::<i32>().unwrap() }), "magic_mapping" => $eb = $eb.with(MagicMapper{}), "town_portal" => $eb = $eb.with(TownPortal{}), "food" => $eb = $eb.with(ProvidesFood{}), "single_activation" => $eb = $eb.with(SingleActivation{}), "particle_line" => $eb = $eb.with(parse_particle_line(&effect.1)), "particle" => $eb = $eb.with(parse_particle(&effect.1)), "remove_curse" => $eb = $eb.with(ProvidesRemoveCurse{}), "identify" => $eb = $eb.with(ProvidesIdentification{}), _ => rltk::console::log(format!("Warning: consumable effect {} not implemented.", effect_name)) } } }; } }
接下来,我们将在 effects/triggers.rs 文件中处理它:
#![allow(unused)] fn main() { // Identify Item if ecs.read_storage::<ProvidesIdentification>().get(entity).is_some() { let mut runstate = ecs.fetch_mut::<RunState>(); *runstate = RunState::ShowIdentify; did_something = true; } }
我们将转到 main.rs 并添加 ShowIdentify 作为 RunState:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, Ticking, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, PreviousLevel, TownPortal, ShowRemoveItem, GameOver, MagicMapReveal { row : i32 }, MapGeneration, ShowCheatMenu, ShowVendor { vendor: Entity, mode : VendorMode }, TeleportingToOtherLevel { x: i32, y: i32, depth: i32 }, ShowRemoveCurse, ShowIdentify } }
将其添加为退出子句:
#![allow(unused)] fn main() { RunState::Ticking => { while newrunstate == RunState::Ticking { self.run_systems(); self.ecs.maintain(); match *self.ecs.fetch::<RunState>() { RunState::AwaitingInput => newrunstate = RunState::AwaitingInput, RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 }, RunState::TownPortal => newrunstate = RunState::TownPortal, RunState::TeleportingToOtherLevel{ x, y, depth } => newrunstate = RunState::TeleportingToOtherLevel{ x, y, depth }, RunState::ShowRemoveCurse => newrunstate = RunState::ShowRemoveCurse, RunState::ShowIdentify => newrunstate = RunState::ShowIdentify, _ => newrunstate = RunState::Ticking } } } }
并在我们的 tick 系统中处理它:
#![allow(unused)] fn main() { RunState::ShowIdentify => { let result = gui::identify_menu(self, ctx); match result.0 { gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput, gui::ItemMenuResult::NoResponse => {} gui::ItemMenuResult::Selected => { let item_entity = result.1.unwrap(); if let Some(name) = self.ecs.read_storage::<Name>().get(item_entity) { let mut dm = self.ecs.fetch_mut::<MasterDungeonMap>(); dm.identified_items.insert(name.name.clone()); } newrunstate = RunState::Ticking; } } } }
最后,打开 gui.rs 并提供菜单函数:
#![allow(unused)] fn main() { pub fn identify_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) { let player_entity = gs.ecs.fetch::<Entity>(); let equipped = gs.ecs.read_storage::<Equipped>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let entities = gs.ecs.entities(); let items = gs.ecs.read_storage::<Item>(); let names = gs.ecs.read_storage::<Name>(); let dm = gs.ecs.fetch::<MasterDungeonMap>(); let obfuscated = gs.ecs.read_storage::<ObfuscatedName>(); let build_cursed_iterator = || { (&entities, &items).join().filter(|(item_entity,_item)| { let mut keep = false; if let Some(bp) = backpack.get(*item_entity) { if bp.owner == *player_entity { if let Some(name) = names.get(*item_entity) { if obfuscated.get(*item_entity).is_some() && !dm.identified_items.contains(&name.name) { keep = true; } } } } // It's equipped, so we know it's cursed if let Some(equip) = equipped.get(*item_entity) { if equip.owner == *player_entity { if let Some(name) = names.get(*item_entity) { if obfuscated.get(*item_entity).is_some() && !dm.identified_items.contains(&name.name) { keep = true; } } } } keep }) }; let count = build_cursed_iterator().count(); let mut y = (25 - (count / 2)) as i32; ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)); ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Identify Which Item?"); ctx.print_color(18, y+ count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); let mut equippable : Vec<Entity> = Vec::new(); for (j, (entity, _item)) in build_cursed_iterator().enumerate() { ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('(')); ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as rltk::FontCharType); ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')')); ctx.print_color(21, y, get_item_color(&gs.ecs, entity), RGB::from_f32(0.0, 0.0, 0.0), &get_item_display_name(&gs.ecs, entity)); equippable.push(entity); y += 1; } match ctx.key { None => (ItemMenuResult::NoResponse, None), Some(key) => { match key { VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) } _ => { let selection = rltk::letter_to_option(key); if selection > -1 && selection < count as i32 { return (ItemMenuResult::Selected, Some(equippable[selection as usize])); } (ItemMenuResult::NoResponse, None) } } } } } }
你现在可以鉴定物品了!

在我们忘记之前修复生成权重
在我们忘记之前,我们真的不想用诅咒剑遍布整个地形。打开 spawns.json,我们将诅咒长剑更改为与 +1 长剑具有相同的生成特性:
{ "name" : "Longsword -1", "weight" : 1, "min_depth" : 3, "max_depth" : 100 },
总结
本章添加了诅咒物品、移除诅咒卷轴和物品鉴定卷轴。还不错,我们非常接近一个完整的物品系统了!
...
本章的源代码可以在 这里 找到
在你的浏览器中使用 web assembly 运行本章的示例,(需要 WebGL2)
Copyright (C) 2019, Herbert Wolverson.
影响属性的物品和更好的状态效果
关于本教程
本教程是免费和开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
我们仍然有一些常见的物品类型尚未支持。本章将完成这些,并为施法(在下一章中)奠定基础框架。
提升属性的物品
在类 D&D 游戏中,一种常见的物品类型是增强(或降低!)你的属性的物品。 例如,食人魔力量手套 赋予力量加值,而 巫师之帽 赋予智力加值。 我们已经有了支持这些物品的大部分框架,所以让我们完成最后一步,使它们能够工作! 打开 spawns.json,我们将定义手套可能的样子:
{
"name" : "食人魔力量手套",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Hands",
"armor_class" : 0.1,
"might" : 5
},
"weight_lbs" : 1.0,
"base_value" : 300.0,
"initiative_penalty" : 0.0,
"vendor_category" : "armor",
"magic" : { "class" : "common", "naming" : "未鉴定的手套" },
"attributes" : { "might" : 5 }
}
为什么我们不直接将其添加到 "wearable" 中? 我们可能希望为其他事物提供属性提升! 为了支持加载这个 - 和其他属性提升 - 我们需要编辑 item_structs.rs:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Item { pub name : String, pub renderable : Option<Renderable>, pub consumable : Option<Consumable>, pub weapon : Option<Weapon>, pub wearable : Option<Wearable>, pub initiative_penalty : Option<f32>, pub weight_lbs : Option<f32>, pub base_value : Option<f32>, pub vendor_category : Option<String>, pub magic : Option<MagicItem>, pub attributes : Option<ItemAttributeBonus> } ... #[derive(Deserialize, Debug)] pub struct ItemAttributeBonus { pub might : Option<i32>, pub fitness : Option<i32>, pub quickness : Option<i32>, pub intelligence : Option<i32> } }
和之前一样,我们需要一个 component 来支持这些数据。 在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct AttributeBonus { pub might : Option<i32>, pub fitness : Option<i32>, pub quickness : Option<i32>, pub intelligence : Option<i32> } }
我们将修改 raws/rawmaster.rs 的函数 spawn_named_item 以支持添加此 component 类型:
#![allow(unused)] fn main() { if let Some(ab) = &item_template.attributes { eb = eb.with(AttributeBonus{ might : ab.might, fitness : ab.fitness, quickness : ab.quickness, intelligence : ab.intelligence, }); } }
现在 component 可以应用于物品了,让我们将其放入生成表 (spawn table) 中,使其非常常见以便于测试:
{ "name" : "食人魔力量手套", "weight" : 100, "min_depth" : 0, "max_depth" : 100 },
最后,我们需要让它真正发挥作用。 我们在 ai/encumbrance_system.rs 中做了非常相似的事情 - 所以这是放置它的自然位置。 我们将向系统添加很多内容,所以这里是整个系统:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{EquipmentChanged, Item, InBackpack, Equipped, Pools, Attributes, gamelog::GameLog, AttributeBonus, gamesystem::attr_bonus}; use std::collections::HashMap; pub struct EncumbranceSystem {} impl<'a> System<'a> for EncumbranceSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, EquipmentChanged>, Entities<'a>, ReadStorage<'a, Item>, ReadStorage<'a, InBackpack>, ReadStorage<'a, Equipped>, WriteStorage<'a, Pools>, WriteStorage<'a, Attributes>, ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, ReadStorage<'a, AttributeBonus> ); fn run(&mut self, data : Self::SystemData) { let (mut equip_dirty, entities, items, backpacks, wielded, mut pools, mut attributes, player, mut gamelog, attrbonus) = data; if equip_dirty.is_empty() { return; } struct ItemUpdate { weight : f32, initiative : f32, might : i32, fitness : i32, quickness : i32, intelligence : i32 } // Build the map of who needs updating let mut to_update : HashMap<Entity, ItemUpdate> = HashMap::new(); // (weight, intiative) for (entity, _dirty) in (&entities, &equip_dirty).join() { to_update.insert(entity, ItemUpdate{ weight: 0.0, initiative: 0.0, might: 0, fitness: 0, quickness: 0, intelligence: 0 }); } // Remove all dirty statements equip_dirty.clear(); // Total up equipped items for (item, equipped, entity) in (&items, &wielded, &entities).join() { if to_update.contains_key(&equipped.owner) { let totals = to_update.get_mut(&equipped.owner).unwrap(); totals.weight += item.weight_lbs; totals.initiative += item.initiative_penalty; if let Some(attr) = attrbonus.get(entity) { totals.might += attr.might.unwrap_or(0); totals.fitness += attr.fitness.unwrap_or(0); totals.quickness += attr.quickness.unwrap_or(0); totals.intelligence += attr.intelligence.unwrap_or(0); } } } // Total up carried items for (item, carried, entity) in (&items, &backpacks, &entities).join() { if to_update.contains_key(&carried.owner) { let totals = to_update.get_mut(&carried.owner).unwrap(); totals.weight += item.weight_lbs; totals.initiative += item.initiative_penalty; } } // Apply the data to Pools for (entity, item) in to_update.iter() { if let Some(pool) = pools.get_mut(*entity) { pool.total_weight = item.weight; pool.total_initiative_penalty = item.initiative; if let Some(attr) = attributes.get_mut(*entity) { attr.might.modifiers = item.might; attr.fitness.modifiers = item.fitness; attr.quickness.modifiers = item.quickness; attr.intelligence.modifiers = item.intelligence; attr.might.bonus = attr_bonus(attr.might.base + attr.might.modifiers); attr.fitness.bonus = attr_bonus(attr.fitness.base + attr.fitness.modifiers); attr.quickness.bonus = attr_bonus(attr.quickness.base + attr.quickness.modifiers); attr.intelligence.bonus = attr_bonus(attr.intelligence.base + attr.intelligence.modifiers); let carry_capacity_lbs = (attr.might.base + attr.might.modifiers) * 15; if pool.total_weight as i32 > carry_capacity_lbs { // Overburdened pool.total_initiative_penalty += 4.0; if *entity == *player { gamelog.entries.push("你超负重了,并受到了先攻惩罚。".to_string()); } } } } } } } }
这与之前的逻辑基本相同,但我们更改了很多内容:
- 我们不再使用元组来保存重量和先攻效果,而是添加了一个
struct来保存我们想要累加的所有内容。Rust 很棒,如果你只需要使用一次 struct,你可以在函数内部声明它! - 和以前一样,我们累加所有物品的重量,以及已装备物品的先攻惩罚。
- 如果物品具有属性加成/惩罚,我们也会累加每个物品的属性加成/惩罚。
- 然后我们将它们应用于属性的
modifiers部分,并重新计算加值 (bonuses)。
最棒的是,由于使用这些属性的其他系统已经在查看加值(并且 GUI 正在查看 modifiers 以进行显示),很多东西 就能正常工作 (而且并非完全是 Bethesda 意义上的短语……是的,我实际上很喜欢 Fallout 76,但如果事情真的可以正常工作就好了!)。
现在,如果你 cargo run 该项目,你可以找到 食人魔力量手套 并装备它们以获得加值 - 然后移除它们以取消加值:

充能物品
并非所有物品在使用后都会化为尘土。一个药水瓶可能装有多剂药剂,一根魔法棒可能多次施放其效果(像往常一样,你的想象力是无限的!)。 让我们制作一件新物品,火球法杖。 在 spawns.json 中,我们将定义基本属性;它基本上是一个火球卷轴,但带有充能:
{
"name" : "火球法杖",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20",
"area_of_effect" : "3",
"particle" : "▓;#FFA500;200.0"
},
"charges" : 5
},
"weight_lbs" : 0.5,
"base_value" : 500.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "未鉴定的法杖" }
}
我们需要扩展 raws/item_structs.rs 中的物品定义以处理新数据:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Consumable { pub effects : HashMap<String, String>, pub charges : Option<i32> } }
我们还将扩展 components.rs 中的 Consumable component:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Consumable { pub max_charges : i32, pub charges : i32 } }
请注意,我们同时存储了最大值和当前值。 这样我们以后就可以允许充能。 我们需要扩展 raws/rawmaster.rs 以应用此信息:
#![allow(unused)] fn main() { if let Some(consumable) = &item_template.consumable { let max_charges = consumable.charges.unwrap_or(1); eb = eb.with(crate::components::Consumable{ max_charges, charges : max_charges }); apply_effects!(consumable.effects, eb); } }
现在我们需要让带有充能的消耗品利用它们。 这意味着如果 max_charges 大于 1,则不会自毁,只有在剩余充能时才会触发,并在使用后减少充能计数。 幸运的是,这很容易在 effects/triggers.rs 的 item_trigger 函数中进行更改:
#![allow(unused)] fn main() { pub fn item_trigger(creator : Option<Entity>, item: Entity, targets : &Targets, ecs: &mut World) { // 检查充能 if let Some(c) = ecs.write_storage::<Consumable>().get_mut(item) { if c.charges < 1 { // 取消 let mut gamelog = ecs.fetch_mut::<GameLog>(); gamelog.entries.push(format!("{} 的充能耗尽了!", ecs.read_storage::<Name>().get(item).unwrap().name)); return; } else { c.charges -= 1; } } // 通过通用系统使用物品 let did_something = event_trigger(creator, item, targets, ecs); // 如果它是一个消耗品,那么它将被删除 if did_something { if let Some(c) = ecs.read_storage::<Consumable>().get(item) { if c.max_charges == 0 { ecs.entities().delete(item).expect("删除失败"); } } } } }
这样你就得到了一个可多次使用的火球法杖! 但是,我们应该有一些方法让玩家知道是否剩余充能 - 以帮助进行物品管理。 毕竟,当你用你的法杖指向强大的巨龙并听到 "噗" 的一声,然后被它吃掉时,真的太糟糕了。 我们将进入 gui.rs 并扩展 get_item_display_name:
#![allow(unused)] fn main() { pub fn get_item_display_name(ecs: &World, item : Entity) -> String { if let Some(name) = ecs.read_storage::<Name>().get(item) { if ecs.read_storage::<MagicItem>().get(item).is_some() { let dm = ecs.fetch::<crate::map::MasterDungeonMap>(); if dm.identified_items.contains(&name.name) { if let Some(c) = ecs.read_storage::<Consumable>().get(item) { if c.max_charges > 1 { format!("{} ({})", name.name.clone(), c.charges).to_string() } else { name.name.clone() } } else { name.name.clone() } } else if let Some(obfuscated) = ecs.read_storage::<ObfuscatedName>().get(item) { obfuscated.name.clone() } else { "未鉴定的魔法物品".to_string() } } else { name.name.clone() } } else { "无名物品 (bug)".to_string() } } }
所以这个函数基本上没有改变,但是一旦我们确定物品是魔法的并且已被鉴定,我们就会查看它是否有充能。 如果有,我们将充能数量以括号括起来附加到显示列表中的物品名称。
如果你现在 cargo run 该项目,你可以找到你的 火球法杖 并尽情轰炸,直到充能耗尽:

状态效果
目前,我们正在逐个案例地处理状态效果,并且应用它们相对来说并不常见。 大多数深度 Roguelike 游戏都有 很多 可能的效果 - 从蘑菇引起的幻觉到喝了一些棕色粘液后以超音速移动! 我们之所以将这些留到现在,是因为它们与我们在本章中所做的其他事情完美地结合在一起。
直到本章为止,我们都将 Confusion 作为标签添加到目标 - 并依赖于该标签来存储持续时间。 这实际上不符合 ECS 的精神! 相反,Confusion 是一种实体 效果,它应用于 目标,持续 duration 回合。 像往常一样,检查分类法是弄清楚某些东西应该具有哪些实体/component 组的好方法。 因此,我们将访问 components.rs 并创建两个新的 component (也在 main.rs 和 saveload_system.rs 中注册它们),并修改 Confusion component 以匹配此项:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Confusion {} #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Duration { pub turns : i32 } #[derive(Component, Debug, ConvertSaveload, Clone)] pub struct StatusEffect { pub target : Entity } }
同样因为我们正在存储一个 Entity,我们需要编写一个包装器来保持序列化正常工作:
rust
这很好 - 但我们破坏了一些东西! 所有期望 Confusion 具有 turns 字段的东西现在都在抱怨。
我们将从 raws/rawmaster.rs 开始,将效果与持续时间分开:
#![allow(unused)] fn main() { "confusion" => { $eb = $eb.with(Confusion{}); $eb = $eb.with(Duration{ turns: effect.1.parse::<i32>().unwrap() }); } }
在 effects/triggers.rs 中,我们将使持续时间从效果的 Duration component 中获取:
#![allow(unused)] fn main() { // Confusion if let Some(confusion) = ecs.read_storage::<Confusion>().get(entity) { if let Some(duration) = ecs.read_storage::<Duration>().get(entity) { add_effect(creator, EffectType::Confusion{ turns : duration.turns }, targets.clone()); did_something = true; } } }
我们将更改 effects/damage.rs 的 confusion 函数以匹配新的方案:
#![allow(unused)] fn main() { pub fn add_confusion(ecs: &mut World, effect: &EffectSpawner, target: Entity) { if let EffectType::Confusion{turns} = &effect.effect_type { ecs.create_entity() .with(StatusEffect{ target }) .with(Confusion{}) .with(Duration{ turns : *turns}) .with(Name{ name : "Confusion".to_string() }) .marked::<SimpleMarker<SerializeMe>>() .build(); } } }
剩下的是 ai/effect_status.rs。我们将更改此项,使其不再担心持续时间,而只是检查效果的存在 - 如果是 Confusion,则取消目标的 turn:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Confusion, RunState, StatusEffect}; use std::collections::HashSet; pub struct TurnStatusSystem {} impl<'a> System<'a> for TurnStatusSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, ReadStorage<'a, Confusion>, Entities<'a>, ReadExpect<'a, RunState>, ReadStorage<'a, StatusEffect> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, confusion, entities, runstate, statuses) = data; if *runstate != RunState::Ticking { return; } // Collect a set of all entities whose turn it is let mut entity_turns = HashSet::new(); for (entity, _turn) in (&entities, &turns).join() { entity_turns.insert(entity); } // Find status effects affecting entities whose turn it is let mut not_my_turn : Vec<Entity> = Vec::new(); for (effect_entity, status_effect) in (&entities, &statuses).join() { if entity_turns.contains(&status_effect.target) { // Skip turn for confusion if confusion.get(effect_entity).is_some() { not_my_turn.push(status_effect.target); } } } for e in not_my_turn { turns.remove(e); } } } }
如果你 cargo run,这将起作用 - 但存在一个明显的题:一旦陷入 confusion,你就会 永远 处于 confusion 状态(或者直到有人把你从痛苦中解脱出来)。 这不太符合我们的想法。 我们已经将效果的持续时间与效果的发生脱钩(这是一件好事!),但这意味着我们必须处理持续时间!
这是一个有趣的难题:状态效果是它们自己的实体,但没有 Initiative。 回合是相对的,因为实体可以以不同的速度运行。 那么我们什么时候想要处理持续时间呢? 答案是 玩家的回合; 时间可能是相对的,但从玩家的角度来看,回合是定义明确的。 实际上,当您被减速时,世界的其余部分都在加速 - 因为我们不想强迫玩家坐着感到无聊,而世界在他们周围缓慢移动。 由于我们正在 ai/initiative_system.rs 中切换到玩家控制 - 我们将在其中处理它:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{Initiative, Position, MyTurn, Attributes, RunState, Pools, Duration, EquipmentChanged, StatusEffect}; pub struct InitiativeSystem {} impl<'a> System<'a> for InitiativeSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, Initiative>, ReadStorage<'a, Position>, WriteStorage<'a, MyTurn>, Entities<'a>, WriteExpect<'a, rltk::RandomNumberGenerator>, ReadStorage<'a, Attributes>, WriteExpect<'a, RunState>, ReadExpect<'a, Entity>, ReadExpect<'a, rltk::Point>, ReadStorage<'a, Pools>, WriteStorage<'a, Duration>, WriteStorage<'a, EquipmentChanged>, ReadStorage<'a, StatusEffect> ); fn run(&mut self, data : Self::SystemData) { let (mut initiatives, positions, mut turns, entities, mut rng, attributes, mut runstate, player, player_pos, pools, mut durations, mut dirty, statuses) = data; if *runstate != RunState::Ticking { return; } // Clear any remaining MyTurn we left by mistkae turns.clear(); // Roll initiative for (entity, initiative, pos) in (&entities, &mut initiatives, &positions).join() { initiative.current -= 1; if initiative.current < 1 { let mut myturn = true; // Re-roll initiative.current = 6 + rng.roll_dice(1, 6); // Give a bonus for quickness if let Some(attr) = attributes.get(entity) { initiative.current -= attr.quickness.bonus; } // Apply pool penalty if let Some(pools) = pools.get(entity) { initiative.current += f32::floor(pools.total_initiative_penalty) as i32; } // TODO: More initiative granting boosts/penalties will go here later // TODO: 稍后将在此处添加更多授予/惩罚先攻的加成 // If its the player, we want to go to an AwaitingInput state // 如果是玩家,我们希望进入 AwaitingInput 状态 if entity == *player { // Give control to the player // 将控制权交给玩家 *runstate = RunState::AwaitingInput; } else { let distance = rltk::DistanceAlg::Pythagoras.distance2d(*player_pos, rltk::Point::new(pos.x, pos.y)); if distance > 20.0 { myturn = false; } } // It's my turn! // 轮到我了! if myturn { turns.insert(entity, MyTurn{}).expect("无法插入 turn"); } } } // Handle durations // 处理持续时间 if *runstate == RunState::AwaitingInput { for (effect_entity, duration, status) in (&entities, &mut durations, &statuses).join() { duration.turns -= 1; if duration.turns < 1 { dirty.insert(status.target, EquipmentChanged{}).expect("无法插入"); entities.delete(effect_entity).expect("无法删除"); } } } } } }
该系统基本保持不变,但我们在不同的 component storage 中添加了一些访问器 - 并在末尾添加了 "Handle durations" 部分。 这只是连接具有持续时间和状态效果的实体,并减少持续时间。 如果持续时间完成,它会将状态的目标标记为 dirty(以便发生任何需要发生的重新计算),并删除状态效果实体。
显示玩家状态
现在我们有了一个通用的状态效果系统,我们应该修改 UI 以显示正在进行的状态。 饥饿的处理方式不同,所以我们将它保留在那里 - 但让我们完成 gui.rs 的那部分。 在 draw_ui 中,将 Status 部分替换为:
#![allow(unused)] fn main() { // Status // 状态 let mut y = 44; let hunger = ecs.read_storage::<HungerClock>(); let hc = hunger.get(*player_entity).unwrap(); match hc.state { HungerState::WellFed => { ctx.print_color(50, y, RGB::named(rltk::GREEN), RGB::named(rltk::BLACK), "Well Fed"); y -= 1; } HungerState::Normal => {} HungerState::Hungry => { ctx.print_color(50, y, RGB::named(rltk::ORANGE), RGB::named(rltk::BLACK), "Hungry"); y -= 1; } HungerState::Starving => { ctx.print_color(50, y, RGB::named(rltk::RED), RGB::named(rltk::BLACK), "Starving"); y -= 1; } } let statuses = ecs.read_storage::<StatusEffect>(); let durations = ecs.read_storage::<Duration>(); let names = ecs.read_storage::<Name>(); for (status, duration, name) in (&statuses, &durations, &names).join() { if status.target == *player_entity { ctx.print_color( 50, y, RGB::named(rltk::RED), RGB::named(rltk::BLACK), &format!("{} ({})", name.name, duration.turns) ); y -= 1; } } }
这与我们之前的情况非常相似,但我们将 y 存储为一个变量 - 因此状态效果列表可以向上增长。 然后我们查询 ECS 以查找具有状态、持续时间和名称的实体 - 如果它是以玩家为目标的,我们就使用它来显示状态。
显示怪物状态
如果能有一些迹象表明状态效果正在应用于 NPC,那也很不错。 这有两个层面 - 我们可以将状态显示在工具提示中,也可以使用粒子效果来指示常规游戏中发生的事情。
为了处理工具提示,打开 gui.rs 并转到 draw_tooltips 函数。 在 "Comment on Pools" 下面,添加以下内容:
#![allow(unused)] fn main() { // Status effects // 状态效果 let statuses = ecs.read_storage::<StatusEffect>(); let durations = ecs.read_storage::<Duration>(); let names = ecs.read_storage::<Name>(); for (status, duration, name) in (&statuses, &durations, &names).join() { if status.target == entity { tip.add(format!("{} ({})", name.name, duration.turns)); } } }
因此,现在如果你让一个怪物陷入 confusion 状态,它会在工具提示中显示效果。 这是一个很好的开始,可以解释为什么怪物没有移动!
另一半是在因 confusion 失去回合时显示粒子效果。 我们将在效果系统中添加一个调用以请求粒子效果! 在 ai/turn_status.rs 中展开 confusion 部分:
#![allow(unused)] fn main() { // Skip turn for confusion // 因 confusion 跳过回合 if confusion.get(effect_entity).is_some() { add_effect( None, EffectType::Particle{ glyph : rltk::to_cp437('?'), fg : rltk::RGB::named(rltk::CYAN), bg : rltk::RGB::named(rltk::BLACK), lifespan: 200.0 }, Targets::Single{ target:status_effect.target } ); not_my_turn.push(status_effect.target); } }
因此,如果你现在 cargo run 该项目,你可以在游戏中看到 confusion 的效果:

宿醉 (Hangovers)
回到设计文档,我们提到你开始游戏时会宿醉。 我们终于可以实现它了! 由于你开始游戏时会宿醉,请打开 spawner.rs 并在玩家生成结束时添加以下内容以创建一个宿醉实体:
#![allow(unused)] fn main() { // Starting hangover // 初始宿醉 ecs.create_entity() .with(StatusEffect{ target : player }) .with(Duration{ turns:10 }) .with(Name{ name: "Hangover".to_string() }) .with(AttributeBonus{ might : Some(-1), fitness : None, quickness : Some(-1), intelligence : Some(-1) }) .marked::<SimpleMarker<SerializeMe>>() .build(); }
宿醉很糟糕! 你会变得更虚弱、更慢且更不聪明。 或者一旦我们修改了 encumbrance 系统(它真的需要一个新名称)来处理状态引起的属性变化,你就会这样。 该系统需要一个小小的改进:
#![allow(unused)] fn main() { // Total up status effect modifiers // 累加状态效果 modifiers for (status, attr) in (&statuses, &attrbonus).join() { if to_update.contains_key(&status.target) { let totals = to_update.get_mut(&status.target).unwrap(); totals.might += attr.might.unwrap_or(0); totals.fitness += attr.fitness.unwrap_or(0); totals.quickness += attr.quickness.unwrap_or(0); totals.intelligence += attr.intelligence.unwrap_or(0); } } }
这显示了拥有宿醉系统的 真正 原因:它使我们能够安全地测试改变你属性的效果,并确保到期时间有效!
如果你现在 cargo run 游戏,你可以观察到宿醉的效果以及它的消退:

力量药水 (Potion of Strength)
现在我们拥有了所有这些,让我们用它来制作一种力量药水(我总是想到旧的 Asterix The Gaul 漫画)。 在 spawns.json 中,我们定义了新的药水:
{
"name" : "力量药水",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "particle" : "!;#FF0000;200.0" }
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "potion" },
"attributes" : { "might" : 5 }
},
这里没有什么新的东西:我们将显示一个粒子效果,并且我们像其他药水一样为药水附加了一个 attributes 部分。 但是,我们将不得不调整效果系统,使其知道如何应用瞬时属性效果。 在 effects/mod.rs 中,我们将添加一个新的效果类型:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 }, EntityDeath, ItemUse { item: Entity }, WellFed, Healing { amount : i32 }, Confusion { turns : i32 }, TriggerFire { trigger: Entity }, TeleportTo { x:i32, y:i32, depth: i32, player_only : bool }, AttributeEffect { bonus : AttributeBonus, name : String, duration : i32 } } }
我们将它标记为影响实体:
#![allow(unused)] fn main() { fn tile_effect_hits_entities(effect: &EffectType) -> bool { match effect { EffectType::Damage{..} => true, EffectType::WellFed => true, EffectType::Healing{..} => true, EffectType::Confusion{..} => true, EffectType::TeleportTo{..} => true, EffectType::AttributeEffect{..} => true, _ => false } } }
并告诉它调用一个我们尚未编写的新函数:
#![allow(unused)] fn main() { fn affect_entity(ecs: &mut World, effect: &EffectSpawner, target: Entity) { match &effect.effect_type { EffectType::Damage{..} => damage::inflict_damage(ecs, effect, target), EffectType::EntityDeath => damage::death(ecs, effect, target), EffectType::Bloodstain{..} => if let Some(pos) = entity_position(ecs, target) { damage::bloodstain(ecs, pos) }, EffectType::Particle{..} => if let Some(pos) = entity_position(ecs, target) { particles::particle_to_tile(ecs, pos, &effect) }, EffectType::WellFed => hunger::well_fed(ecs, effect, target), EffectType::Healing{..} => damage::heal_damage(ecs, effect, target), EffectType::Confusion{..} => damage::add_confusion(ecs, effect, target), EffectType::TeleportTo{..} => movement::apply_teleport(ecs, effect, target), EffectType::AttributeEffect{..} => damage::attribute_effect(ecs, effect, target), _ => {} } } }
现在我们需要进入 effects/damage.rs 并编写新函数:
#![allow(unused)] fn main() { pub fn attribute_effect(ecs: &mut World, effect: &EffectSpawner, target: Entity) { if let EffectType::AttributeEffect{bonus, name, duration} = &effect.effect_type { ecs.create_entity() .with(StatusEffect{ target }) .with(bonus.clone()) .with(Duration { turns : *duration }) .with(Name { name : name.clone() }) .marked::<SimpleMarker<SerializeMe>>() .build(); ecs.write_storage::<EquipmentChanged>().insert(target, EquipmentChanged{}).expect("插入失败"); } } }
剩下的就是打开 effects/triggers.rs 并添加属性加成效果作为触发器类型:
#![allow(unused)] fn main() { // Attribute Modifiers // 属性修改器 if let Some(attr) = ecs.read_storage::<AttributeBonus>().get(entity) { add_effect( creator, EffectType::AttributeEffect{ bonus : attr.clone(), duration : 10, name : ecs.read_storage::<Name>().get(entity).unwrap().name.clone() }, targets.clone() ); did_something = true; } }
这与其他触发器类似 - 它会触发另一个事件,这次是属性效果生效。 你现在可以 cargo run,力量药水在游戏中可以正常工作了。 这是在宿醉时喝下力量药水的截图,向您展示了效果现在可以正确叠加:

总结
我们有了:一个良好且通用的状态效果系统,以及一个允许物品触发它们以及提供属性加成和惩罚的系统。 到目前为止,物品系统就到此为止了。 在下一章中,我们将转向魔法咒语 - 这将使用我们在这些章节中构建的许多基础。
...
本章的源代码可以在这里找到
在您的浏览器中使用 Web 程序集运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
魔法咒语 - 终于找到了蓝色法力条的用途
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出伟大的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持 我的 Patreon。
前几章一直在为实现这一章铺路:施法。屏幕上出现蓝色法力条已经有一段时间了,现在我们让它做一些有用的事情!
了解咒语
施法是游戏的一种可选玩法 - 如果你喜欢硬碰硬,也能玩得很好。角色扮演游戏中一个常见的特性是,你必须先学会咒语才能施放;你努力学习,记住手势和咒语,现在就可以向世界释放你强大的魔法力量了。
这首先意味着实体需要能够了解咒语。一个好的副作用是,它为我们提供了一种方便的方式来为怪物添加特殊攻击 - 我们稍后会介绍。现在,我们将在 components.rs 中添加一个新的组件(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct KnownSpell { pub display_name : String, pub mana_cost : i32 } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct KnownSpells { pub spells : Vec<KnownSpell> } }
我们还将它添加到 spawner.rs 的 player 函数中。最终,我们将清空咒语列表(只需将其设置为 Vec::new(),但现在我们将添加 Zap 作为占位符):
#![allow(unused)] fn main() { .with(KnownSpells{ spells : vec![ KnownSpell{ display_name : "Zap".to_string(), mana_cost: 1 } ] }) }
如果你还记得 4.9 节,我们指定用户界面应该列出你可以施放的咒语。我们预期的界面看起来像这样:

现在我们有了填充这些内容所需的数据!打开 gui.rs,找到 draw_ui 中渲染消耗品的部分。在其正下方,插入以下代码:
#![allow(unused)] fn main() { // 咒语 y += 1; let blue = RGB::named(rltk::CYAN); let known_spells_storage = ecs.read_storage::<KnownSpells>(); let known_spells = &known_spells_storage.get(*player_entity).unwrap().spells; let mut index = 1; for spell in known_spells.iter() { ctx.print_color(50, y, blue, black, &format!("^{}", index)); ctx.print_color(53, y, blue, black, &format!("{} ({})", spell.display_name, spell.mana_cost)); index += 1; y += 1; } }
这段代码读取 KnownSpells 组件(玩家必须拥有一个),提取列表并使用它来渲染带有快捷键列表的咒语。我们将蓝色改为了青色以提高可读性,但看起来还不错:

施放咒语
显示咒语是一个好的开始,但我们需要能够真正施放(或尝试施放)它们!你可能还记得在 player.rs 中我们处理了消耗品快捷键。我们将使用非常相似的系统来处理咒语快捷键。在 player_input 中,添加以下内容:
#![allow(unused)] fn main() { if ctx.control && ctx.key.is_some() { let key : Option<i32> = match ctx.key.unwrap() { VirtualKeyCode::Key1 => Some(1), VirtualKeyCode::Key2 => Some(2), VirtualKeyCode::Key3 => Some(3), VirtualKeyCode::Key4 => Some(4), VirtualKeyCode::Key5 => Some(5), VirtualKeyCode::Key6 => Some(6), VirtualKeyCode::Key7 => Some(7), VirtualKeyCode::Key8 => Some(8), VirtualKeyCode::Key9 => Some(9), _ => None }; if let Some(key) = key { return use_spell_hotkey(gs, key-1); } } }
这就像消耗品快捷键代码一样(明智的用户会将其中一些重构为一个函数,但为了教程的清晰起见,我们将它们分开)。它调用了 use_spell_hotkey - 我们还没有编写这个函数!让我们开始编写:
#![allow(unused)] fn main() { fn use_spell_hotkey(gs: &mut State, key: i32) -> RunState { use super::KnownSpells; let player_entity = gs.ecs.fetch::<Entity>(); let known_spells_storage = gs.ecs.read_storage::<KnownSpells>(); let known_spells = &known_spells_storage.get(*player_entity).unwrap().spells; if (key as usize) < known_spells.len() { let pools = gs.ecs.read_storage::<Pools>(); let player_pools = pools.get(*player_entity).unwrap(); if player_pools.mana.current >= known_spells[key as usize].mana_cost { // TODO: 施放咒语 } else { let mut gamelog = gs.ecs.fetch_mut::<GameLog>(); gamelog.entries.push("你没有足够的法力来施放那个咒语!".to_string()); } } RunState::Ticking } }
注意其中大的 TODO!在我们可以真正实现施法之前,我们需要先建立一些基础设施。
定义我们的 Zap 咒语
我们在这里遇到一点障碍的主要原因是,我们实际上还没有告诉引擎 Zap 咒语是做什么的。我们在原始的 spawns.json 文件中定义了其他所有内容,所以让我们添加一个新的 spells 部分:
"spells" : [
{
"name" : "Zap",
"effects" : {
"ranged" : "6",
"damage" : "5",
"particle_line" : "▓;#00FFFF;200.0"
}
}
]
让我们扩展我们的 raws 系统,使其能够读取这个,并在游戏中可用。我们将从一个新文件 raws/spell_structs.rs 开始,它将定义咒语对 JSON 系统来说是什么样的:
#![allow(unused)] fn main() { use serde::{Deserialize}; use std::collections::HashMap; #[derive(Deserialize, Debug)] pub struct Spell { pub name : String, pub effects : HashMap<String, String> } }
现在我们将在 raws/mod.rs 中添加 mod spells; pub use spells::Spell;,并扩展 Raws 结构以包含它:
#![allow(unused)] fn main() { mod spell_structs; pub use spell_structs::Spell; ... #[derive(Deserialize, Debug)] pub struct Raws { pub items : Vec<Item>, pub mobs : Vec<Mob>, pub props : Vec<Prop>, pub spawn_table : Vec<SpawnTableEntry>, pub loot_tables : Vec<LootTable>, pub faction_table : Vec<FactionInfo>, pub spells : Vec<Spell> } }
既然我们已经创建了字段,我们应该将其添加到 raws/rawmaster.rs 中的 empty() 系统中。我们还将添加一个索引,就像其他原始类型一样:
#![allow(unused)] fn main() { pub struct RawMaster { raws : Raws, item_index : HashMap<String, usize>, mob_index : HashMap<String, usize>, prop_index : HashMap<String, usize>, loot_index : HashMap<String, usize>, faction_index : HashMap<String, HashMap<Reaction>>, spell_index : HashMap<String, usize> } impl RawMaster { pub fn empty() -> RawMaster { RawMaster { raws : Raws{ items: Vec::new(), mobs: Vec::new(), props: Vec::new(), spawn_table: Vec::new(), loot_tables: Vec::new(), faction_table : Vec::new(), spells : Vec::new() }, item_index : HashMap::new(), mob_index : HashMap::new(), prop_index : HashMap::new(), loot_index : HashMap::new(), faction_index : HashMap::new(), spell_index : HashMap::new() } } }
在 load 函数中,我们需要填充索引:
#![allow(unused)] fn main() { for (i,spell) in self.raws.spells.iter().enumerate() { self.spell_index.insert(spell.name.clone(), i); } }
我们将咒语设计与现有的物品效果系统紧密结合,但现在我们遇到了另一个小问题:我们实际上并没有将咒语作为实体生成 - 在某些情况下,它们只是直接进入效果系统。但是,如果能够继续使用我们编写的所有效果代码,那就太好了。所以我们将为咒语生成模板实体。这允许我们找到咒语模板,并使用现有代码来生成其结果。首先,在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册),我们将创建一个新的 SpellTemplate 组件:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct SpellTemplate { pub mana_cost : i32 } }
在 raws/rawmaster.rs 中,我们需要一个新函数:spawn_named_spell:
#![allow(unused)] fn main() { pub fn spawn_named_spell(raws: &RawMaster, ecs : &mut World, key : &str) -> Option<Entity> { if raws.spell_index.contains_key(key) { let spell_template = &raws.raws.spells[raws.spell_index[key]]; let mut eb = ecs.create_entity().marked::<SimpleMarker<SerializeMe>>(); eb = eb.with(SpellTemplate{ mana_cost : spell_template.mana_cost }); eb = eb.with(Name{ name : spell_template.name.clone() }); apply_effects!(spell_template.effects, eb); return Some(eb.build()); } None } }
这很简单:我们创建一个新实体,将其标记为可序列化和作为咒语模板,给它一个名称,并使用我们现有的 effects! 宏来填充空白。然后我们返回该实体。
我们希望在新游戏开始时对所有咒语执行此操作。我们将首先在 raws/rawmaster.rs 中添加一个函数,为所有咒语调用它:
#![allow(unused)] fn main() { pub fn spawn_all_spells(ecs : &mut World) { let raws = &super::RAWS.lock().unwrap(); for spell in raws.raws.spells.iter() { spawn_named_spell(raws, ecs, &spell.name); } } }
由于玩家只生成一次,我们将在 spawner.rs 的 player 函数的开头调用它。这保证了它会存在,因为没有玩家是一个致命的错误(也是一件令人悲伤的事情!):
#![allow(unused)] fn main() { pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity { spawn_all_spells(ecs); ... }
最后,我们将添加一个实用函数(到 raws/rawmaster.rs),以帮助我们找到咒语实体。这非常简单:
#![allow(unused)] fn main() { pub fn find_spell_entity(ecs : &World, name : &str) -> Option<Entity> { let names = ecs.read_storage::<Name>(); let spell_templates = ecs.read_storage::<SpellTemplate>(); let entities = ecs.entities(); for (entity, sname, _template) in (&entities, &names, &spell_templates).join() { if name == sname.name { return Some(entity); } } None } }
排队 Zap
现在我们已经将 Zap 定义为咒语模板,我们可以完成我们之前开始的 spell_hotkeys 系统。首先,我们需要一个组件来指示施放咒语的意愿。在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, ConvertSaveload, Clone)] pub struct WantsToCastSpell { pub spell : Entity, pub target : Option<rltk::Point> } }
这为我们提供了足够的信息来完成 player.rs 中的施法:
#![allow(unused)] fn main() { fn use_spell_hotkey(gs: &mut State, key: i32) -> RunState { use super::KnownSpells; use super::raws::find_spell_entity; let player_entity = gs.ecs.fetch::<Entity>(); let known_spells_storage = gs.ecs.read_storage::<KnownSpells>(); let known_spells = &known_spells_storage.get(*player_entity).unwrap().spells; if (key as usize) < known_spells.len() { let pools = gs.ecs.read_storage::<Pools>(); let player_pools = pools.get(*player_entity).unwrap(); if player_pools.mana.current >= known_spells[key as usize].mana_cost { if let Some(spell_entity) = find_spell_entity(&gs.ecs, &known_spells[key as usize].display_name) { use crate::components::Ranged; if let Some(ranged) = gs.ecs.read_storage::<Ranged>().get(spell_entity) { return RunState::ShowTargeting{ range: ranged.range, item: spell_entity }; }; let mut intent = gs.ecs.write_storage::<WantsToCastSpell>(); intent.insert( *player_entity, WantsToCastSpell{ spell: spell_entity, target: None } ).expect("无法插入意图"); return RunState::Ticking; } } else { let mut gamelog = gs.ecs.fetch_mut::<GameLog>(); gamelog.entries.push("你没有足够的法力来施放那个咒语!".to_string()); } } RunState::Ticking } }
你会注意到我们正在重用 ShowTargeting - 但是使用的是咒语实体而不是物品。我们需要调整 main.rs 中的条件来处理这种情况:
#![allow(unused)] fn main() { RunState::ShowTargeting{range, item} => { let result = gui::ranged_target(self, ctx, range); match result.0 { gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput, gui::ItemMenuResult::NoResponse => {} gui::ItemMenuResult::Selected => { if self.ecs.read_storage::<SpellTemplate>().get(item).is_some() { let mut intent = self.ecs.write_storage::<WantsToCastSpell>(); intent.insert(*self.ecs.fetch::<Entity>(), WantsToCastSpell{ spell: item, target: result.1 }).expect("无法插入意图"); newrunstate = RunState::Ticking; } else { let mut intent = self.ecs.write_storage::<WantsToUseItem>(); intent.insert(*self.ecs.fetch::<Entity>(), WantsToUseItem{ item, target: result.1 }).expect("无法插入意图"); newrunstate = RunState::Ticking; } } } } }
因此,当选择目标时,它会查看 item 实体 - 如果它有咒语组件,它会启动 WantsToCastSpell - 否则它会坚持使用 WantsToUseItem。
你可能已经注意到我们实际上没有在任何地方使用 WantsToCastSpell!我们需要另一个系统来处理它。它基本上与使用物品相同,所以我们将在它旁边添加它。在 inventory_system/use_system.rs 中,我们将添加第二个系统:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Name, WantsToUseItem,Map, AreaOfEffect, EquipmentChanged, IdentifiedItem, WantsToCastSpell}; use crate::effects::*; ... // ItemUseSystem 在这里 ... pub struct SpellUseSystem {} impl<'a> System<'a> for SpellUseSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, Map>, Entities<'a>, WriteStorage<'a, WantsToCastSpell>, ReadStorage<'a, Name>, ReadStorage<'a, AreaOfEffect>, WriteStorage<'a, EquipmentChanged>, WriteStorage<'a, IdentifiedItem> ); #[allow(clippy::cognitive_complexity)] fn run(&mut self, data : Self::SystemData) { let (player_entity, map, entities, mut wants_use, names, aoe, mut dirty, mut identified_item) = data; for (entity, useitem) in (&entities, &wants_use).join() { dirty.insert(entity, EquipmentChanged{}).expect("无法插入"); // 识别 if entity == *player_entity { identified_item.insert(entity, IdentifiedItem{ name: names.get(useitem.spell).unwrap().name.clone() }) .expect("无法插入"); } // 调用效果系统 add_effect( Some(entity), EffectType::SpellUse{ spell : useitem.spell }, match useitem.target { None => Targets::Single{ target: *player_entity }, Some(target) => { if let Some(aoe) = aoe.get(useitem.spell) { Targets::Tiles{ tiles: aoe_tiles(&*map, target, aoe.radius) } } else { Targets::Tile{ tile_idx : map.xy_idx(target.x, target.y) as i32 } } } } ); } wants_use.clear(); } } }
这与 ItemUseSystem 非常相似,但以 WantsToCastSpell 作为输入。然后它将 EffectType::SpellUse 发送到效果系统。我们还没有编写它 - 让我们来编写它。我们将首先将其添加到 EffectType 枚举中:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 }, EntityDeath, ItemUse { item: Entity }, SpellUse { spell: Entity }, WellFed, Healing { amount : i32 }, Confusion { turns : i32 }, TriggerFire { trigger: Entity }, TeleportTo { x:i32, y:i32, depth: i32, player_only : bool }, AttributeEffect { bonus : AttributeBonus, name : String, duration : i32 } } }
然后我们需要将其添加到 spell_applicator 函数中:
#![allow(unused)] fn main() { fn target_applicator(ecs : &mut World, effect : &EffectSpawner) { if let EffectType::ItemUse{item} = effect.effect_type { triggers::item_trigger(effect.creator, item, &effect.targets, ecs); } else if let EffectType::SpellUse{spell} = effect.effect_type { triggers::spell_trigger(effect.creator, spell, &effect.targets, ecs); } else if let EffectType::TriggerFire{trigger} = effect.effect_type { triggers::trigger(effect.creator, trigger, &effect.targets, ecs); } else { match &effect.targets { Targets::Tile{tile_idx} => affect_tile(ecs, effect, *tile_idx), Targets::Tiles{tiles} => tiles.iter().for_each(|tile_idx| affect_tile(ecs, effect, *tile_idx)), Targets::Single{target} => affect_entity(ecs, effect, *target), Targets::TargetList{targets} => targets.iter().for_each(|entity| affect_entity(ecs, effect, *entity)), } } } }
这将施法发送到一个新的触发函数 spell_trigger。这在 triggers.rs 中定义:
#![allow(unused)] fn main() { pub fn spell_trigger(creator : Option<Entity>, spell: Entity, targets : &Targets, ecs: &mut World) { if let Some(template) = ecs.read_storage::<SpellTemplate>().get(spell) { let mut pools = ecs.write_storage::<Pools>(); if let Some(caster) = creator { if let Some(pool) = pools.get_mut(caster) { if template.mana_cost <= pool.mana.current { pool.mana.current -= template.mana_cost; } } } } event_trigger(creator, spell, targets, ecs); } }
这相对简单。它:
- 检查输入是否附加了咒语模板。
- 获取施法者的 Pools,以访问他们的法力。
- 将施法者的法力值减少咒语的消耗值。
- 将咒语发送到效果系统 - 我们已经编写了该系统。
我们还需要修复一个视觉问题。以前,find_item_position(在 effects/targeting.rs 中)一直足以弄清楚某些视觉效果的起始位置。由于物品现在是咒语模板 - 并且没有位置 - 视觉效果将无法正常工作。我们将向该函数添加一个额外的参数 - owner - 它可以回退到 owner 的位置:
#![allow(unused)] fn main() { pub fn find_item_position(ecs: &World, target: Entity, creator: Option<Entity>) -> Option<i32> { let positions = ecs.read_storage::<Position>(); let map = ecs.fetch::<Map>(); // 简单 - 它有一个位置 if let Some(pos) = positions.get(target) { return Some(map.xy_idx(pos.x, pos.y) as i32); } // 也许它是被携带的? if let Some(carried) = ecs.read_storage::<InBackpack>().get(target) { if let Some(pos) = positions.get(carried.owner) { return Some(map.xy_idx(pos.x, pos.y) as i32); } } // 也许它是被装备的? if let Some(equipped) = ecs.read_storage::<Equipped>().get(target) { if let Some(pos) = positions.get(equipped.owner) { return Some(map.xy_idx(pos.x, pos.y) as i32); } } // 也许 creator 有位置? if let Some(creator) = creator { if let Some(pos) = positions.get(creator) { return Some(map.xy_idx(pos.x, pos.y) as i32); } } // 不知道 - 放弃 None } }
然后我们只需要在 event_trigger 中做一个小的更改(在 effects/triggers.rs 中):
#![allow(unused)] fn main() { // 线条粒子生成 if let Some(part) = ecs.read_storage::<SpawnParticleLine>().get(entity) { ... }
现在就这样了。如果你现在 cargo run,你可以按 ctrl+1 来电击别人了!
恢复法力
你可能会注意到,你现在实际上永远无法恢复法力。你只能电击几次(默认 4 次),然后就结束了。虽然这非常像第一版《龙与地下城》,但在视频游戏中却没什么乐趣。另一方面,咒语是强大的 - 所以我们不希望成为魔法的劲量兔子太容易!
法力药水
一个好的开始是提供法力药水来恢复你的魔法渴望。在 spawns.json 中:
{
"name" : "法力药水",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "provides_mana" : "4" }
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "potion" }
},
并使它们成为丰富的生成物:
{ "name" : "法力药水", "weight" : 7, "min_depth" : 0, "max_depth" : 100 },
我们尚不支持 provides_mana,所以我们需要为其创建一个组件。在 components.rs 中(以及 main.rs 和 saveload_system.rs):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct ProvidesMana { pub mana_amount : i32 } }
在 raws/rawmaster.rs 中,我们将其添加为生成效果:
#![allow(unused)] fn main() { macro_rules! apply_effects { ( $effects:expr, $eb:expr ) => { for effect in $effects.iter() { let effect_name = effect.0.as_str(); match effect_name { "provides_healing" => $eb = $eb.with(ProvidesHealing{ heal_amount: effect.1.parse::<i32>().unwrap() }), "provides_mana" => $eb = $eb.with(ProvidesMana{ mana_amount: effect.1.parse::<i32>().unwrap() }), "ranged" => $eb = $eb.with(Ranged{ range: effect.1.parse::<i32>().unwrap() }), "damage" => $eb = $eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }), "area_of_effect" => $eb = $eb.with(AreaOfEffect{ radius: effect.1.parse::<i32>().unwrap() }), "confusion" => { $eb = $eb.with(Confusion{}); $eb = $eb.with(Duration{ turns: effect.1.parse::<i32>().unwrap() }); } "magic_mapping" => $eb = $eb.with(MagicMapper{}), "town_portal" => $eb = $eb.with(TownPortal{}), "food" => $eb = $eb.with(ProvidesFood{}), "single_activation" => $eb = $eb.with(SingleActivation{}), "particle_line" => $eb = $eb.with(parse_particle_line(&effect.1)), "particle" => $eb = $eb.with(parse_particle(&effect.1)), "remove_curse" => $eb = $eb.with(ProvidesRemoveCurse{}), "identify" => $eb = $eb.with(ProvidesIdentification{}), _ => rltk::console::log(format!("警告:消耗品效果 {} 尚未实现。", effect_name)) } } }; } }
这将创建组件(你现在应该已经习惯了!),所以我们还需要处理使用它的效果。我们将首先在 effects/mod.rs 中创建一个新的 EffectType:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 }, EntityDeath, ItemUse { item: Entity }, SpellUse { spell: Entity }, WellFed, Healing { amount : i32 }, Mana { amount : i32 }, Confusion { turns : i32 }, TriggerFire { trigger: Entity }, TeleportTo { x:i32, y:i32, depth: i32, player_only : bool }, AttributeEffect { bonus : AttributeBonus, name : String, duration : i32 } } }
我们将它标记为影响实体:
#![allow(unused)] fn main() { fn tile_effect_hits_entities(effect: &EffectType) -> bool { match effect { EffectType::Damage{..} => true, EffectType::WellFed => true, EffectType::Healing{..} => true, EffectType::Mana{..} => true, EffectType::Confusion{..} => true, EffectType::TeleportTo{..} => true, EffectType::AttributeEffect{..} => true, _ => false } } }
并将其包含在我们的 affect_entities 函数中:
#![allow(unused)] fn main() { fn affect_entity(ecs: &mut World, effect: &EffectSpawner, target: Entity) { match &effect.effect_type { EffectType::Damage{..} => damage::inflict_damage(ecs, effect, target), EffectType::EntityDeath => damage::death(ecs, effect, target), EffectType::Bloodstain{..} => if let Some(pos) = entity_position(ecs, target) { damage::bloodstain(ecs, pos) }, EffectType::Particle{..} => if let Some(pos) = entity_position(ecs, target) { particles::particle_to_tile(ecs, pos, &effect) }, EffectType::WellFed => hunger::well_fed(ecs, effect, target), EffectType::Healing{..} => damage::heal_damage(ecs, effect, target), EffectType::Mana{..} => damage::restore_mana(ecs, effect, target), EffectType::Confusion{..} => damage::add_confusion(ecs, effect, target), EffectType::TeleportTo{..} => movement::apply_teleport(ecs, effect, target), EffectType::AttributeEffect{..} => damage::attribute_effect(ecs, effect, target), _ => {} } } }
在 effects/triggers.rs 中的触发器列表中添加以下内容(紧挨着 Healing 下面):
#![allow(unused)] fn main() { // 法力 if let Some(mana) = ecs.read_storage::<ProvidesMana>().get(entity) { add_effect(creator, EffectType::Mana{amount: mana.mana_amount}, targets.clone()); did_something = true; } }
最后,我们需要在 effects/damage.rs 中实现 restore_mana:
#![allow(unused)] fn main() { pub fn restore_mana(ecs: &mut World, mana: &EffectSpawner, target: Entity) { let mut pools = ecs.write_storage::<Pools>(); if let Some(pool) = pools.get_mut(target) { if let EffectType::Mana{amount} = mana.effect_type { pool.mana.current = i32::min(pool.mana.max, pool.mana.current + amount); add_effect(None, EffectType::Particle{ glyph: rltk::to_cp437('‼'), fg : rltk::RGB::named(rltk::BLUE), bg : rltk::RGB::named(rltk::BLACK), lifespan: 200.0 }, Targets::Single{target} ); } } } }
这与我们的治疗效果非常相似 - 但使用的是法力而不是生命值。
因此,如果你现在 cargo run,你很有可能找到恢复法力的药水。
随时间恢复法力
如果你远离敌人休息,我们已经支持随时间推移为玩家恢复生命值。对法力做同样的事情是有意义的,但我们希望它慢得多。法力是强大的 - 使用远程 zap,你可以造成大量伤害,风险相对较小(尽管定位仍然是关键,因为受伤的敌人仍然会伤害你)。所以我们希望在玩家休息时恢复他们的法力 - 但非常缓慢。在 player.rs 中,skip_turn 函数处理恢复生命值。让我们扩展治疗部分,使其有时也恢复一些法力:
#![allow(unused)] fn main() { if can_heal { let mut health_components = ecs.write_storage::<Pools>(); let pools = health_components.get_mut(*player_entity).unwrap(); pools.hit_points.current = i32::min(pools.hit_points.current + 1, pools.hit_points.max); let mut rng = ecs.fetch_mut::<rltk::RandomNumberGenerator>(); if rng.roll_dice(1,6)==1 { pools.mana.current = i32::min(pools.mana.current + 1, pools.mana.max); } } }
如果你有资格获得治疗,这会在你休息时提供 1/6 的几率恢复一些法力。
学习咒语
在设计咒语效果方面,天空才是真正的极限。你可以愉快地玩一整夜(我就是这么做的!)。我们将首先进入 spawner.rs 并删除起始咒语 - 你开始时没有任何咒语:
#![allow(unused)] fn main() { .with(KnownSpells{ spells : Vec::new() }) }
现在我们将介绍我们的第一本咒语书,并使其成为可生成的宝藏。让我们在 spawns.json 中定义我们的第一本书:
{
"name" : "魔法入门",
"renderable": {
"glyph" : "¶",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "teach_spell" : "Zap" }
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy"
},
再一次,其中 90% 已经编写完成。新部分是效果 teach_spells。我们需要一个组件来表示这种效果,所以在 components.rs 中再次(并在 main.rs / saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct TeachesSpell { pub spell : String } }
现在我们将其添加到 raws/rawmaster.rs 中的效果系统内部:
#![allow(unused)] fn main() { "teach_spell" => $eb = $eb.with(TeachesSpell{ spell: effect.1.to_string() }), }
最后,我们需要将其作为另一种效果集成到我们的 effects/triggers.rs 系统中:
#![allow(unused)] fn main() { // 学习咒语 if let Some(spell) = ecs.read_storage::<TeachesSpell>().get(entity) { if let Some(known) = ecs.write_storage::<KnownSpells>().get_mut(creator.unwrap()) { if let Some(spell_entity) = crate::raws::find_spell_entity(ecs, &spell.spell) { if let Some(spell_info) = ecs.read_storage::<SpellTemplate>().get(spell_entity) { let mut already_known = false; known.spells.iter().for_each(|s| if s.display_name == spell.spell { already_known = true }); if !already_known { known.spells.push(KnownSpell{ display_name: spell.spell.clone(), mana_cost : spell_info.mana_cost }); } } } } did_something = true; } }
这是一个很大的 if let 链,但它是有道理的:我们确保该物品教授咒语,然后我们找到学生的已知咒语列表,然后我们找到咒语的模板 - 如果所有这些都有效,我们检查他们是否已经知道该咒语,如果他们不知道,就学习它。然后我们标记 did_something,以便销毁这本书。
为了测试目的,打开 spawns.json,让我们让咒语书出现在任何地方:
{ "name" : "魔法入门指南", "weight" : 200, "min_depth" : 0, "max_depth" : 100 },
现在 cargo run 该项目,你应该不难找到一本书并学会用 Zap 电击事物!

完成后,记得将 weight 降低到合理的数值。
{ "name" : "魔法入门指南", "weight" : 5, "min_depth" : 0, "max_depth" : 100 },
将所有内容整合在一起 - 毒药
在经历了几个章节来制作通用效果系统的漫长道路之后。在我们回到完成我们游戏的有趣部分(地图和怪物!)之前,最好将所有内容整合在一起 - 并结合一个新的(小的)系统 - 以展示我们所取得的成就。为此,我们将添加两种类型的毒药 - 随时间推移的伤害(DOT)和减速毒液。我们将使其作为一种不幸的药水选择(将来会变得有用!)、攻击卷轴、咒语以及蜘蛛可以施加给受害者的东西。令人惊奇的是,现在我们有了一个统一的系统,这真的不太难!
减速、憎恨和随时间推移的伤害效果
我们将首先创建两个新组件来表示效果。在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Slow { pub initiative_penalty : f32 } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct DamageOverTime { pub damage : i32 } }
接下来,我们将打开 raws/rawmaster.rs 并将它们添加为可加载的效果类型:
#![allow(unused)] fn main() { macro_rules! apply_effects { ( $effects:expr, $eb:expr ) => { for effect in $effects.iter() { let effect_name = effect.0.as_str(); match effect_name { "provides_healing" => $eb = $eb.with(ProvidesHealing{ heal_amount: effect.1.parse::<i32>().unwrap() }), "provides_mana" => $eb = $eb.with(ProvidesMana{ mana_amount: effect.1.parse::<i32>().unwrap() }), "teach_spell" => $eb = $eb.with(TeachesSpell{ spell: effect.1.to_string() }), "ranged" => $eb = $eb.with(Ranged{ range: effect.1.parse::<i32>().unwrap() }), "damage" => $eb = $eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }), "area_of_effect" => $eb = $eb.with(AreaOfEffect{ radius: effect.1.parse::<i32>().unwrap() }), "confusion" => { $eb = $eb.with(Confusion{}); $eb = $eb.with(Duration{ turns: effect.1.parse::<i32>().unwrap() }); } "magic_mapping" => $eb = $eb.with(MagicMapper{}), "town_portal" => $eb = $eb.with(TownPortal{}), "food" => $eb = $eb.with(ProvidesFood{}), "single_activation" => $eb = $eb.with(SingleActivation{}), "particle_line" => $eb = $eb.with(parse_particle_line(&effect.1)), "particle" => $eb = $eb.with(parse_particle(&effect.1)), "remove_curse" => $eb = $eb.with(ProvidesRemoveCurse{}), "identify" => $eb = $eb.with(ProvidesIdentification{}), "slow" => $eb = $eb.with(Slow{ initiative_penalty : effect.1.parse::<f32>().unwrap() }), "damage_over_time" => $eb = $eb.with( DamageOverTime { damage : effect.1.parse::<i32>().unwrap() } ), _ => rltk::console::log(format!("警告:消耗品效果 {} 尚未实现。", effect_name)) } } }; } }
所以现在 Slow 和 DamageOverTime 被识别为各种原始文件条目中的效果,并且可以应用它们的组件。接下来,我们需要教效果系统来应用它。我们将从在 effects/mod.rs 中将它们添加到 EffectType 枚举开始:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum EffectType { Damage { amount : i32 }, Bloodstain, Particle { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32 }, EntityDeath, ItemUse { item: Entity }, SpellUse { spell: Entity }, WellFed, Healing { amount : i32 }, Mana { amount : i32 }, Confusion { turns : i32 }, TriggerFire { trigger: Entity }, TeleportTo { x:i32, y:i32, depth: i32, player_only : bool }, AttributeEffect { bonus : AttributeBonus, name : String, duration : i32 }, Slow { initiative_penalty : f32 }, DamageOverTime { damage : i32 } } }
在同一文件中,我们需要指示它们适用于实体:
#![allow(unused)] fn main() { fn tile_effect_hits_entities(effect: &EffectType) -> bool { match effect { EffectType::Damage{..} => true, EffectType::WellFed => true, EffectType::Healing{..} => true, EffectType::Mana{..} => true, EffectType::Confusion{..} => true, EffectType::TeleportTo{..} => true, EffectType::AttributeEffect{..} => true, EffectType::Slow{..} => true, EffectType::DamageOverTime{..} => true, _ => false } } }
我们还需要 affect_entity 中的路由表来正确地引导它们:
#![allow(unused)] fn main() { fn affect_entity(ecs: &mut World, effect: &EffectSpawner, target: Entity) { match &effect.effect_type { EffectType::Damage{..} => damage::inflict_damage(ecs, effect, target), EffectType::EntityDeath => damage::death(ecs, effect, target), EffectType::Bloodstain{..} => if let Some(pos) = entity_position(ecs, target) { damage::bloodstain(ecs, pos) }, EffectType::Particle{..} => if let Some(pos) = entity_position(ecs, target) { particles::particle_to_tile(ecs, pos, &effect) }, EffectType::WellFed => hunger::well_fed(ecs, effect, target), EffectType::Healing{..} => damage::heal_damage(ecs, effect, target), EffectType::Mana{..} => damage::restore_mana(ecs, effect, target), EffectType::Confusion{..} => damage::add_confusion(ecs, effect, target), EffectType::TeleportTo{..} => movement::apply_teleport(ecs, effect, target), EffectType::AttributeEffect{..} => damage::attribute_effect(ecs, effect, target), EffectType::Slow{..} => damage::slow(ecs, effect, target), EffectType::DamageOverTime{..} => damage::damage_over_time(ecs, effect, target), _ => {} } } }
反过来,这要求我们在 effects/damage.rs 中创建两个新函数,以匹配我们刚刚调用的函数:
#![allow(unused)] fn main() { pub fn slow(ecs: &mut World, effect: &EffectSpawner, target: Entity) { if let EffectType::Slow{initiative_penalty} = &effect.effect_type { ecs.create_entity() .with(StatusEffect{ target }) .with(Slow{ initiative_penalty : *initiative_penalty }) .with(Duration{ turns : 5}) .with( if *initiative_penalty > 0.0 { Name{ name : "减速".to_string() } } else { Name{ name : "加速".to_string() } } ) .marked::<SimpleMarker<SerializeMe>>() .build(); } } pub fn damage_over_time(ecs: &mut World, effect: &EffectSpawner, target: Entity) { if let EffectType::DamageOverTime{damage} = &effect.effect_type { ecs.create_entity() .with(StatusEffect{ target }) .with(DamageOverTime{ damage : *damage }) .with(Duration{ turns : 5}) .with(Name{ name : "持续伤害".to_string() }) .marked::<SimpleMarker<SerializeMe>>() .build(); } } }
你会注意到,这两个都类似于 Confusion - 它们应用状态效果。现在我们需要在 effects/triggers.rs 文件中处理这些效果 - 在 event_trigger 函数中:
#![allow(unused)] fn main() { // 减速 if let Some(slow) = ecs.read_storage::<Slow>().get(entity) { add_effect(creator, EffectType::Slow{ initiative_penalty : slow.initiative_penalty }, targets.clone()); did_something = true; } // 持续伤害 if let Some(damage) = ecs.read_storage::<DamageOverTime>().get(entity) { add_effect(creator, EffectType::DamageOverTime{ damage : damage.damage }, targets.clone()); did_something = true; } }
最后,我们需要让状态效果对受害者产生影响!第一个 Slow 效果在 ai/encumbrance_system.rs 文件中处理是有意义的。在处理属性效果之后,添加:
#![allow(unused)] fn main() { // 汇总加速/减速 for (status, slow) in (&statuses, &slowed).join() { if to_update.contains_key(&status.target) { let totals = to_update.get_mut(&status.target).unwrap(); totals.initiative += slow.initiative_penalty; } } }
我们将 DamageOverTime 支持添加到持续时间计时器(它可以是一个单独的系统,但我们已经在正确的时间迭代状态效果 - 所以我们不妨将它们组合起来)。扩展 ai/initiative_system.rs 中的持续时间检查以包含它:
#![allow(unused)] fn main() { impl<'a> System<'a> for InitiativeSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, Initiative>, ReadStorage<'a, Position>, WriteStorage<'a, MyTurn>, Entities<'a>, WriteExpect<'a, rltk::RandomNumberGenerator>, ReadStorage<'a, Attributes>, WriteExpect<'a, RunState>, ReadExpect<'a, Entity>, ReadExpect<'a, rltk::Point>, ReadStorage<'a, Pools>, WriteStorage<'a, Duration>, WriteStorage<'a, EquipmentChanged>, ReadStorage<'a, StatusEffect>, ReadStorage<'a, DamageOverTime> ); fn run(&mut self, data : Self::SystemData) { let (mut initiatives, positions, mut turns, entities, mut rng, attributes, mut runstate, player, player_pos, pools, mut durations, mut dirty, statuses, dots) = data; ... // 处理持续时间 if *runstate == RunState::AwaitingInput { use crate::effects::*; for (effect_entity, duration, status) in (&entities, &mut durations, &statuses).join() { if entities.is_alive(status.target) { duration.turns -= 1; if let Some(dot) = dots.get(effect_entity) { add_effect( None, EffectType::Damage{ amount : dot.damage }, Targets::Single{ target : status.target } ); } if duration.turns < 1 { dirty.insert(status.target, EquipmentChanged{}).expect("无法插入"); entities.delete(effect_entity).expect("无法删除"); } } } } }
这段代码中有一个新概念:is_alive。状态效果可能会比它们的目标寿命更长,所以我们只想在目标仍然是有效实体时应用它们。否则,游戏将崩溃!
仅添加物品
这就是使两种效果起作用所需的一切 - 现在我们只需要将它们添加到一些物品和咒语中。让我们添加三种药水来演示我们刚刚完成的工作:
{ "name" : "毒药药水", "weight" : 100, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "缓慢药水", "weight" : 100, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "急速药水", "weight" : 100, "min_depth" : 0, "max_depth" : 100 },
...
{
"name" : "毒药药水",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "damage_over_time" : "2" }
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "potion" }
},
{
"name" : "缓慢药水",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "slow" : "2.0" }
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "potion" }
},
{
"name" : "急速药水",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "slow" : "-2.0" }
},
"weight_lbs" : 0.5,
"base_value" : 100.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "potion" }
},
请注意,我们给它们设置了非常高的生成几率 - 一旦我们知道它们可以工作,我们将纠正这一点!如果你现在 cargo run,你会在树林中找到药水 - 它们会像你期望的那样伤害/加速/减慢你的速度。这表明:
- 我们的通用药水命名正确地混淆了新药水。
- 我们的减速/持续伤害效果正在应用于自我使用的物品。
- 我们现在只需将这些效果添加到
spawns.json文件中,就可以使这些效果适用于药水。你甚至可以使用负伤害来获得随时间推移的治疗效果。
现在为了展示该系统,我们还要制作一个 Web Scroll 和一个 Rod of Venom:
{
"name" : "蛛网卷轴",
"renderable": {
"glyph" : ")",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"slow" : "10.0",
"area_of_effect" : "3",
"particle_line" : "☼;#FFFFFF;200.0"
}
},
"weight_lbs" : 0.5,
"base_value" : 500.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "scroll" }
},
{
"name" : "毒液之杖",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage_over_time" : "1",
"particle_line" : "▓;#00FF00;200.0"
},
"charges" : 5
},
"weight_lbs" : 0.5,
"base_value" : 500.0,
"vendor_category" : "alchemy",
"magic" : { "class" : "common", "naming" : "Unidentified Rod" }
}
我们将使这些成为常见的生成物,并将药水的生成几率降低到合理的数值:
{ "name" : "毒药药水", "weight" : 3, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "缓慢药水", "weight" : 3, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "急速药水", "weight" : 3, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "蛛网卷轴", "weight" : 2, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "毒液之杖", "weight" : 2, "min_depth" : 0, "max_depth" : 100 },
如果我们现在 cargo run 并找到新的卷轴和法杖,我们可以对毫无戒心的受害者施加毒药和范围效果减速(这基本上就是蛛网!)。再一次,我们证明了该系统非常灵活:
- 你也可以将新效果应用于卷轴和法杖,并且命名系统继续工作。
- 这些效果适用于范围效果和单目标受害者。
为了继续展示我们灵活的效果系统,我们将添加两个咒语 - Venom 和 Web,以及几本可以从中学习的咒语书 - Arachnophilia 101 和 Venom 101。在 spawns.json 的 Spells 部分中,我们可以添加:
{
"name" : "蛛网",
"mana_cost" : 2,
"effects" : {
"ranged" : "6",
"slow" : "10",
"area_of_effect" : "3",
"particle_line" : "☼;#FFFFFF;400.0"
}
},
{
"name" : "毒液",
"mana_cost" : 2,
"effects" : {
"ranged" : "6",
"damage_over_time" : "4",
"particle_line" : "▓;#00FF00;400.0"
}
}
我们将像魔法入门书一样添加这本书:
{
"name" : "Arachnophilia 101",
"renderable": {
"glyph" : "¶",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "teach_spell" : "Web" }
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy"
},
{
"name" : "Venom 101",
"renderable": {
"glyph" : "¶",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "teach_spell" : "Venom" }
},
"weight_lbs" : 0.5,
"base_value" : 50.0,
"vendor_category" : "alchemy"
},
我们将修复生成概率:
{ "name" : "蛛网卷轴", "weight" : 2, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "毒液之杖", "weight" : 2, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Arachnophilia 101", "weight" : 100, "min_depth" : 0, "max_depth" : 100 },
{ "name" : "Venom 101", "weight" : 100, "min_depth" : 0, "max_depth" : 100 },
再次,如果你 cargo run 该项目 - 你可以四处奔波学习这些咒语 - 并将它们施加在你的敌人身上!我们验证了:
- 我们的咒语学习系统是灵活的。
- 效果系统继续适当地应用这些效果,这次是通过施法。
更多效果触发器
我们在本章部分进行的测试向我们展示了我们构建的功能的强大之处:单个系统可以为物品和咒语提供效果,支持多种目标类型和附加效果。这真的很棒,并展示了你可以使用 ECS(以及顶部的消息传递系统)做些什么。似乎为了真正为系统锦上添花,还有两种情况下应该触发效果:
- 作为武器击中后的“proc”效果(所以“毒液匕首”可能会毒害目标)。
- 作为敌人的特殊能力(我向你保证我们快要讲到那里了!虽然还不太快……)
伤害 Proc
让我们从武器上的“proc”效果开始。考虑到这一点,武器 proc 可以影响目标或施法者(例如,你可能有一把剑,在你击中某些东西时会治疗你 - 或者你可能想用你的超锋利的剑对目标施加随时间推移的伤害)。它们不应该总是 proc - 所以需要有一个机会(可能是 100%)让它发生。并且它们需要具有效果,这可以方便地使用我们辛苦定义的效应系统。让我们在 spawns.json 中将此组合成 毒液匕首:
{
"name" : "毒液匕首",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "Quickness",
"base_damage" : "1d4+1",
"hit_bonus" : 1,
"proc_chance" : 0.5,
"proc_target" : "Target",
"proc_effects" : { "damage_over_time" : "2" }
},
"weight_lbs" : 1.0,
"base_value" : 2.0,
"initiative_penalty" : -1,
"vendor_category" : "weapon",
"magic" : { "class" : "common", "naming" : "Unidentified Dagger" }
},
为了制作这个,我复制/粘贴了一个基本的 Dagger 并给它添加了命中/伤害/先攻奖励。然后我添加了一些新字段:proc_chance,proc_target 和 proc_effects。效果系统可以在一点帮助下处理效果。首先,我们需要扩展 raws/item_structs.rs 中的“weapon”结构以处理额外的字段:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Weapon { pub range: String, pub attribute: String, pub base_damage: String, pub hit_bonus: i32, pub proc_chance : Option<f32>, pub proc_target : Option<String>, pub proc_effects : Option<HashMap<String, String>> } }
现在,我们将这些字段添加到 MeleeWeapon 组件类型中(在 components.rs 中):
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct MeleeWeapon { pub attribute : WeaponAttribute, pub damage_n_dice : i32, pub damage_die_type : i32, pub damage_bonus : i32, pub hit_bonus : i32, pub proc_chance : Option<f32>, pub proc_target : Option<String>, } }
当我们读取关于武器的信息时,我们也需要实例化这些数据。我们可以相当容易地扩展 raws/rawmaster.rs 中 spawn_named_item 的 weapon 部分:
#![allow(unused)] fn main() { if let Some(weapon) = &item_template.weapon { eb = eb.with(Equippable{ slot: EquipmentSlot::Melee }); let (n_dice, die_type, bonus) = parse_dice_string(&weapon.base_damage); let mut wpn = MeleeWeapon{ attribute : WeaponAttribute::Might, damage_n_dice : n_dice, damage_die_type : die_type, damage_bonus : bonus, hit_bonus : weapon.hit_bonus, proc_chance : weapon.proc_chance, proc_target : weapon.proc_target.clone() }; match weapon.attribute.as_str() { "Quickness" => wpn.attribute = WeaponAttribute::Quickness, _ => wpn.attribute = WeaponAttribute::Might } eb = eb.with(wpn); if let Some(proc_effects) =& weapon.proc_effects { apply_effects!(proc_effects, eb); } } }
现在我们需要让 proc 效果发生(或者不发生,它是随机的!)。我们在 melee_combat_system.rs 中还有一些工作要做。首先,当我们生成默认武器(徒手)时,我们需要新字段:
#![allow(unused)] fn main() { // 定义基本的徒手攻击 - 如果装备了武器,则会被下面的挥舞检查覆盖 let mut weapon_info = MeleeWeapon{ attribute : WeaponAttribute::Might, hit_bonus : 0, damage_n_dice : 1, damage_die_type : 4, damage_bonus : 0, proc_chance : None, proc_target : None }; }
在我们找到挥舞的武器的地方,我们还需要存储实体(以便我们可以访问效果组件):
#![allow(unused)] fn main() { let mut weapon_entity : Option<Entity> = None; for (weaponentity,wielded,melee) in (&entities, &equipped_items, &meleeweapons).join() { if wielded.owner == entity && wielded.slot == EquipmentSlot::Melee { weapon_info = melee.clone(); weapon_entity = Some(weaponentity); } } }
然后,在成功命中的 add_effect 之后,我们添加武器“proccing”:
#![allow(unused)] fn main() { log.entries.push(format!("{} 击中 {}, 造成 {} hp 伤害。", &name.name, &target_name.name, damage)); // Proc 效果 if let Some(chance) = &weapon_info.proc_chance { if rng.roll_dice(1, 100) <= (chance * 100.0) as i32 { let effect_target = if weapon_info.proc_target.unwrap() == "Self" { Targets::Single{ target: entity } } else { Targets::Single { target : wants_melee.target } }; add_effect( Some(entity), EffectType::ItemUse{ item: weapon_entity.unwrap() }, effect_target ) } } }
这非常简单:它掷一个 100 面骰子,并使用分数“proc 几率”作为其发生的百分比几率。如果它确实发生了,它会根据 proc 效果将效果目标设置为挥舞者或目标,并调用 add_effect 系统来启动它。
记住将 毒液匕首 放入你的生成表:
{ "name" : "毒液匕首", "weight" : 100, "min_depth" : 0, "max_depth" : 100 },
如果你现在 cargo run,你可以找到一把匕首 - 有时你可以毒害你的受害者。再次,我们在这里真正展示了 ECS/消息传递系统的强大功能:通过一点扩展,我们的整个效果系统也适用于武器 proc!
敌人施法/能力使用
除了魔法武器(谁挥舞它们都会受益)之外,效果系统现在非常不对称。怪物无法将大多数这些效果发送回给你。在 roguelike 游戏中,怪物使用与玩家相同的规则是很常见的(这实际上是我们尝试实现的 柏林解释 中的一个低价值目标)。我们不会尝试让怪物使用它们可能生成的任何物品(暂且!),但我们将赋予它们施放咒语的能力 - 作为特殊攻击。让我们赋予 大型蜘蛛 使用我们在上面定义的 Web 咒语来减慢你的速度的能力。像往常一样,我们将从 JSON 文件开始,确定它应该是什么样子:
{
"name" : "大型蜘蛛",
"level" : 2,
"attributes" : {},
"renderable": {
"glyph" : "s",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 6,
"movement" : "static",
"natural" : {
"armor_class" : 12,
"attacks" : [
{ "name" : "咬", "hit_bonus" : 1, "damage" : "1d12" }
]
},
"abilities" : [
{ "spell" : "Web", "chance" : 0.2, "range" : 6.0, "min_range" : 3.0 }
],
"faction" : "食肉动物"
},
这与之前的 大型蜘蛛 相同,但我们添加了一个 abilities 部分,列出了它有 20% 的几率决定制作蛛网。我们需要扩展 raws/mob_structs.rs 以支持这一点:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub vision_range : i32, pub movement : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32>, pub equipped : Option<Vec<String>>, pub natural : Option<MobNatural>, pub loot_table : Option<String>, pub light : Option<MobLight>, pub faction : Option<String>, pub gold : Option<String>, pub vendor : Option<Vec<String>>, pub abilities : Option<Vec<MobAbility>> } #[derive(Deserialize, Debug)] pub struct MobAbility { pub spell : String, pub chance : f32, pub range : f32, pub min_range : f32 } }
让我们创建一个新组件来保存怪物的这些数据(以及任何其他具有特殊能力的实体)。在 components.rs 中(以及 main.rs 和 saveload_system.rs 中的常用注册;你只需要注册组件 SpecialAbilities):
#![allow(unused)] fn main() { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct SpecialAbility { pub spell : String, pub chance : f32, pub range : f32, pub min_range : f32 } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct SpecialAbilities { pub abilities : Vec<SpecialAbility> } }
现在我们转到 raws/rawmaster.rs 以在 spawn_named_mob 函数中附加组件。在 build() 调用之前,我们可以添加特殊能力:
#![allow(unused)] fn main() { if let Some(ability_list) = &mob_template.abilities { let mut a = SpecialAbilities { abilities : Vec::new() }; for ability in ability_list.iter() { a.abilities.push( SpecialAbility{ chance : ability.chance, spell : ability.spell.clone(), range : ability.range, min_range : ability.min_range } ); } eb = eb.with(a); } }
现在我们已经创建了组件,我们应该给怪物一个机会来使用它们新发现的能力。可以轻松修改 visible_ai_system 以实现此目的:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Faction, Position, Map, raws::Reaction, Viewshed, WantsToFlee, WantsToApproach, Chasing, SpecialAbilities, WantsToCastSpell, Name, SpellTemplate}; pub struct VisibleAI {} impl<'a> System<'a> for VisibleAI { #[allow(clippy::type_complexity)] type SystemData = ( ReadStorage<'a, MyTurn>, ReadStorage<'a, Faction>, ReadStorage<'a, Position>, ReadExpect<'a, Map>, WriteStorage<'a, WantsToApproach>, WriteStorage<'a, WantsToFlee>, Entities<'a>, ReadExpect<'a, Entity>, ReadStorage<'a, Viewshed>, WriteStorage<'a, Chasing>, ReadStorage<'a, SpecialAbilities>, WriteExpect<'a, rltk::RandomNumberGenerator>, WriteStorage<'a, WantsToCastSpell>, ReadStorage<'a, Name>, ReadStorage<'a, SpellTemplate> ); fn run(&mut self, data : Self::SystemData) { let (turns, factions, positions, map, mut want_approach, mut want_flee, entities, player, viewsheds, mut chasing, abilities, mut rng, mut casting, names, spells) = data; for (entity, _turn, my_faction, pos, viewshed) in (&entities, &turns, &factions, &positions, &viewsheds).join() { if entity != *player { let my_idx = map.xy_idx(pos.x, pos.y); let mut reactions : Vec<(usize, Reaction, Entity)> = Vec::new(); let mut flee : Vec<usize> = Vec::new(); for visible_tile in viewshed.visible_tiles.iter() { let idx = map.xy_idx(visible_tile.x, visible_tile.y); if my_idx != idx { evaluate(idx, &map, &factions, &my_faction.name, &mut reactions); } } let mut done = false; for reaction in reactions.iter() { match reaction.1 { Reaction::Attack => { if let Some(abilities) = abilities.get(entity) { let range = rltk::DistanceAlg::Pythagoras.distance2d( rltk::Point::new(pos.x, pos.y), rltk::Point::new(reaction.0 as i32 % map.width, reaction.0 as i32 / map.width) ); for ability in abilities.abilities.iter() { if range >= ability.min_range && range <= ability.range && rng.roll_dice(1,100) >= (ability.chance * 100.0) as i32 { use crate::raws::find_spell_entity_by_name; casting.insert( entity, WantsToCastSpell{ spell : find_spell_entity_by_name(&ability.spell, &names, &spells, &entities).unwrap(), target : Some(rltk::Point::new(reaction.0 as i32 % map.width, reaction.0 as i32 / map.width))} ).expect("无法插入"); done = true; } } } if !done { want_approach.insert(entity, WantsToApproach{ idx: reaction.0 as i32 }).expect("无法插入"); chasing.insert(entity, Chasing{ target: reaction.2}).expect("无法插入"); done = true; } } Reaction::Flee => { flee.push(reaction.0); } _ => {} } } if !done && !flee.is_empty() { want_flee.insert(entity, WantsToFlee{ indices : flee }).expect("无法插入"); } } } } } }
这里有一个技巧:find_spell_entity_by_name;因为我们是在系统内部,所以我们不能只传递 World 参数。所以我向 raws/rawmaster.rs 添加了一个系统内版本:
#![allow(unused)] fn main() { pub fn find_spell_entity_by_name( name : &str, names : &ReadStorage::<Name>, spell_templates : &ReadStorage::<SpellTemplate>, entities : &Entities) -> Option<Entity> { for (entity, sname, _template) in (entities, names, spell_templates).join() { if name == sname.name { return Some(entity); } } None } }
一旦到位,你就可以 cargo run - 蜘蛛可以用蛛网击中你!

总结
这是物品效果迷你系列的最后一章:我们已经完成了我们的目标!现在有一个用于定义效果的单一管道,它们可以通过以下方式应用:
- 施放咒语(你可以从书中学习)
- 使用卷轴
- 饮用法力药水
- 武器“proc”效果命中时
- 怪物特殊能力
这些效果可以:
- 瞄准单个图块,
- 瞄准单个实体,
- 瞄准范围效果,
- 瞄准多个实体
效果也可以链接起来,允许你指定视觉效果和其他在效果触发时发生的事情。我们只需付出相对较小的努力就可以为生物添加新效果,并且只需少量工作即可在需要时添加新效果。这将有助于即将到来的章节,其中将以一条在其巢穴中挥舞酸性吐息武器的龙为特色。
...
本章的源代码可以在这里找到
在你的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
恶龙现身
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
现在我们有了法术和更高级的物品能力,玩家或许能够在我们第 4.17 节中构建的矮人要塞的第一层中生存下来了!此外,既然 NPC 也可以使用特殊能力了 - 我们应该能够模拟(根据我们的设计文档)占据矮人要塞的邪恶巨龙了!本章将完全关于构建龙穴,填充它,并使龙成为一个可怕但可击败的敌人。
构建龙穴
根据设计文档,第六层曾经是一个强大的矮人要塞 - 但已被一条邪恶的黑龙占据,这条龙非常喜欢吃冒险者(并且大概也解决了过去的矮人)。这意味着我们想要的是一个基于走廊的地牢 - 矮人往往喜欢这种风格,但被侵蚀和爆破成了龙穴的样子。
为了辅助实现这一点,我们将重新启用地图生成观察。在 main.rs 中,将切换开关更改为 true:
#![allow(unused)] fn main() { const SHOW_MAPGEN_VISUALIZER : bool = true; // 启用地图生成可视化器 }
接下来,我们将组合一个骨架来构建关卡。在 map_builders/mod.rs 中,添加以下内容:
#![allow(unused)] fn main() { mod dwarf_fort_builder; use dwarf_fort_builder::*; }
并更新末尾的函数:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, rng, width, height), 2 => forest_builder(new_depth, rng, width, height), 3 => limestone_cavern_builder(new_depth, rng, width, height), 4 => limestone_deep_cavern_builder(new_depth, rng, width, height), 5 => limestone_transition_builder(new_depth, rng, width, height), 6 => dwarf_fort_builder(new_depth, rng, width, height), _ => random_builder(new_depth, rng, width, height) } } }
现在,我们将创建一个新文件 - map_builders/dwarf_fort.rs,并在其中放入一个基于最小 BSP 的房间地牢:
#![allow(unused)] fn main() { use super::{BuilderChain, XStart, YStart, AreaStartingPosition, RoomSorter, RoomSort, CullUnreachable, VoronoiSpawning, BspDungeonBuilder, DistantExit, BspCorridors, CorridorSpawner, RoomDrawer}; pub fn dwarf_fort_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Dwarven Fortress"); chain.start_with(BspDungeonBuilder::new()); chain.with(RoomSorter::new(RoomSort::CENTRAL)); chain.with(RoomDrawer::new()); chain.with(BspCorridors::new()); chain.with(CorridorSpawner::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::TOP)); chain.with(CullUnreachable::new()); chain.with(AreaEndingPosition::new(XEnd::RIGHT, YEnd::BOTTOM)); chain.with(VoronoiSpawning::new()); chain.with(DistantExit::new()); chain } }
这将为您提供一个非常基本的基于房间的地牢:

这是一个好的开始,但并不完全是我们想要的。它显然是人(矮人!)造的,但它没有“可怕的龙住在这里”的氛围。因此,我们也将制作一个看起来可怕的地图,其中包含更大的中心区域,并将两者合并在一起。我们想要一种有点不祥的感觉,所以我们将制作一个自定义构建器层来生成 DLA Insectoid 地图并将其合并进来:
#![allow(unused)] fn main() { pub struct DragonsLair {} impl MetaMapBuilder for DragonsLair { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl DragonsLair { #[allow(dead_code)] pub fn new() -> Box<DragonsLair> { Box::new(DragonsLair{}) } fn build(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { build_data.map.depth = 7; build_data.take_snapshot(); let mut builder = BuilderChain::new(6, build_data.width, build_data.height, "New Map"); builder.start_with(DLABuilder::insectoid()); builder.build_map(rng); // 将历史记录添加到我们的历史记录中 for h in builder.build_data.history.iter() { build_data.history.push(h.clone()); } build_data.take_snapshot(); // 合并地图 for (idx, tt) in build_data.map.tiles.iter_mut().enumerate() { if *tt == TileType::Wall && builder.build_data.map.tiles[idx] == TileType::Floor { *tt = TileType::Floor; } } build_data.take_snapshot(); } } }
我们将它添加到我们的构建器函数中:
#![allow(unused)] fn main() { pub fn dwarf_fort_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Dwarven Fortress"); chain.start_with(BspDungeonBuilder::new()); chain.with(RoomSorter::new(RoomSort::CENTRAL)); chain.with(RoomDrawer::new()); chain.with(BspCorridors::new()); chain.with(CorridorSpawner::new()); chain.with(DragonsLair::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::TOP)); chain.with(CullUnreachable::new()); chain.with(AreaEndingPosition::new(XEnd::RIGHT, YEnd::BOTTOM)); chain.with(VoronoiSpawning::new()); chain.with(DistantExit::new()); chain } }
这给出了一个更合适的地图:

您会注意到我们从一个对角角落开始,到另一个对角角落结束 - 目的是让玩家难以完全避开中间区域!
常规生成
如果您现在 cargo run,您会注意到关卡主要充满了物品 - 免费的战利品! - 而怪物却不多。实际上这里发生了两件事:我们将物品和怪物的权重放在同一个表中,并且我们最近添加了大量物品(相对于怪物和道具而言) - 而且这个关卡一开始就不允许有那么多怪物。随着我们添加越来越多的物品,这个问题只会变得更糟!所以让我们修复它。
让我们首先打开 raws/rawmaster.rs 并添加一个新函数来确定条目的生成类型:
#![allow(unused)] fn main() { pub enum SpawnTableType { Item, Mob, Prop } pub fn spawn_type_by_name(raws: &RawMaster, key : &str) -> SpawnTableType { if raws.item_index.contains_key(key) { SpawnTableType::Item } else if raws.mob_index.contains_key(key) { SpawnTableType::Mob } else { SpawnTableType::Prop } } }
我们将向 random_table.rs 文件添加一个新的 MasterTable 结构。它充当表的持有者,按物品类型排序。我们还将修复先前版本中一些奇怪的布局:
#![allow(unused)] fn main() { use rltk::RandomNumberGenerator; use crate::raws::{SpawnTableType, spawn_type_by_name, RawMaster}; pub struct RandomEntry { name : String, weight : i32 } impl RandomEntry { pub fn new<S:ToString>(name: S, weight: i32) -> RandomEntry { RandomEntry{ name: name.to_string(), weight } } } #[derive(Default)] pub struct MasterTable { items : RandomTable, mobs : RandomTable, props : RandomTable } impl MasterTable { pub fn new() -> MasterTable { MasterTable{ items : RandomTable::new(), mobs : RandomTable::new(), props : RandomTable::new() } } pub fn add<S:ToString>(&mut self, name : S, weight: i32, raws: &RawMaster) { match spawn_type_by_name(raws, &name.to_string()) { SpawnTableType::Item => self.items.add(name, weight), SpawnTableType::Mob => self.mobs.add(name, weight), SpawnTableType::Prop => self.props.add(name, weight), } } pub fn roll(&self, rng : &mut RandomNumberGenerator) -> String { let roll = rng.roll_dice(1, 4); match roll { 1 => self.items.roll(rng), 2 => self.props.roll(rng), 3 => self.mobs.roll(rng), _ => "None".to_string() } } } #[derive(Default)] pub struct RandomTable { entries : Vec<RandomEntry>, total_weight : i32 } impl RandomTable { pub fn new() -> RandomTable { RandomTable{ entries: Vec::new(), total_weight: 0 } } pub fn add<S:ToString>(&mut self, name : S, weight: i32) { if weight > 0 { self.total_weight += weight; self.entries.push(RandomEntry::new(name.to_string(), weight)); } } pub fn roll(&self, rng : &mut RandomNumberGenerator) -> String { if self.total_weight == 0 { return "None".to_string(); } let mut roll = rng.roll_dice(1, self.total_weight)-1; let mut index : usize = 0; while roll > 0 { if roll < self.entries[index].weight { return self.entries[index].name.clone(); } roll -= self.entries[index].weight; index += 1; } "None".to_string() } } }
正如您所看到的,这会将可用的生成物按类型划分 - 然后滚动以选择要使用的表,然后再在表本身上滚动。现在在 rawmaster.rs 中,我们将修改 get_spawn_table_for_depth 函数以使用 master table:
#![allow(unused)] fn main() { pub fn get_spawn_table_for_depth(raws: &RawMaster, depth: i32) -> MasterTable { use super::SpawnTableEntry; let available_options : Vec<&SpawnTableEntry> = raws.raws.spawn_table .iter() .filter(|a| depth >= a.min_depth && depth <= a.max_depth) .collect(); let mut rt = MasterTable::new(); for e in available_options.iter() { let mut weight = e.weight; if e.add_map_depth_to_weight.is_some() { weight += depth; } rt.add(e.name.clone(), weight, raws); } rt } }
由于我们已经为 MasterTable 实现了基本相同的接口,所以我们基本上可以保留现有代码 - 而只是使用新类型来代替。在 spawner.rs 中,我们还需要更改一个函数签名:
#![allow(unused)] fn main() { fn room_table(map_depth: i32) -> MasterTable { get_spawn_table_for_depth(&RAWS.lock().unwrap(), map_depth) } }
如果您现在 cargo run,那么怪物和物品的平衡性会好得多(在所有关卡中!)。
恶龙现身
这个关卡由一条黑龙统治。查看我们的 D&D 规则,这些都是可怕的巨大蜥蜴,拥有令人难以置信的体能,锋利的牙齿和爪子,以及可怕的酸性吐息,可以烧掉你骨头上的肉。有了这样的介绍,这条龙最好是相当可怕的!它也需要是可能被击败的,被一个等级达到 5 级的玩家。杀死龙真的应该给予一些非常惊人的奖励。如果你小心谨慎,也应该可以偷偷绕过龙!
在 spawns.json 中,我们将开始勾勒出龙的样子:
{
"name" : "Black Dragon",
"renderable": {
"glyph" : "D",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 12,
"movement" : "static",
"attributes" : {
"might" : 13,
"fitness" : 13
},
"skills" : {
"Melee" : 18,
"Defense" : 16
},
"natural" : {
"armor_class" : 17,
"attacks" : [
{ "name" : "bite", "hit_bonus" : 4, "damage" : "1d10+2" },
{ "name" : "left_claw", "hit_bonus" : 2, "damage" : "1d10" },
{ "name" : "right_claw", "hit_bonus" : 2, "damage" : "1d10" }
]
},
"loot_table" : "Wyrms",
"faction" : "Wyrm",
"level" : 6,
"gold" : "10d6",
"abilities" : [
{ "spell" : "Acid Breath", "chance" : 0.2, "range" : 8.0, "min_range" : 2.0 }
]
},
我们还需要定义一个 Acid Breath 效果:
{
"name" : "Acid Breath",
"mana_cost" : 2,
"effects" : {
"ranged" : "6",
"damage" : "10",
"area_of_effect" : "3",
"particle" : "☼;#00FF00;400.0"
}
}
现在我们需要实际生成龙。我们不想将龙放入我们的生成表 - 那样会使它随机出现,并可能出现在错误的关卡中。这会破坏它作为 boss 的声誉!我们也不希望它被朋友包围 - 那样对玩家来说太难了(并且会分散对 boss 战的注意力)。
在 dwarf_fort_builder.rs 中,我们将为生成添加另一个层:
#![allow(unused)] fn main() { pub struct DragonSpawner {} impl MetaMapBuilder for DragonSpawner { fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build(rng, build_data); } } impl DragonSpawner { #[allow(dead_code)] pub fn new() -> Box<DragonSpawner> { Box::new(DragonSpawner{}) } fn build(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) { // 找到一个未被占用的中心位置 let seed_x = build_data.map.width / 2; let seed_y = build_data.map.height / 2; let mut available_floors : Vec<(usize, f32)> = Vec::new(); for (idx, tiletype) in build_data.map.tiles.iter().enumerate() { if crate::map::tile_walkable(*tiletype) { available_floors.push( ( idx, rltk::DistanceAlg::PythagorasSquared.distance2d( rltk::Point::new(idx as i32 % build_data.map.width, idx as i32 / build_data.map.width), rltk::Point::new(seed_x, seed_y) ) ) ); } } if available_floors.is_empty() { panic!("No valid floors to start on"); // 没有可供开始的有效地面 } available_floors.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); let start_x = available_floors[0].0 as i32 % build_data.map.width; let start_y = available_floors[0].0 as i32 / build_data.map.width; let dragon_pt = rltk::Point::new(start_x, start_y); // 移除龙周围 25 格范围内的所有生成物 let w = build_data.map.width as i32; build_data.spawn_list.retain(|spawn| { let spawn_pt = rltk::Point::new( spawn.0 as i32 % w, spawn.0 as i32 / w ); let distance = rltk::DistanceAlg::Pythagoras.distance2d(dragon_pt, spawn_pt); distance > 25.0 }); // 添加龙 let dragon_idx = build_data.map.xy_idx(start_x, start_y); build_data.spawn_list.push((dragon_idx, "Black Dragon".to_string())); } } }
这个函数非常直接,并且与我们之前编写的函数非常相似。我们找到地图中心附近的开放空间,然后移除所有距离中心点小于 25 格的怪物生成点(使怪物远离中心)。然后我们在中心生成黑龙。
让我们转到 main.rs 并临时更改 main 函数中的一行:
#![allow(unused)] fn main() { gs.generate_world_map(6, 0); }
这将使您从龙穴关卡开始(记住在我们完成后改回!),这样您就不必导航其他关卡来完成它。现在 cargo run 项目,使用作弊码(反斜杠 后跟 g)启用 God Mode - 并探索关卡。看起来不错,但是龙太强大了,以至于杀死它需要很长时间 - 而且如果您查看伤害日志,玩家肯定会死!但是,通过一些练习 - 您可以使用法术和物品的组合来击倒龙。所以我们暂时就先这样了。
继续并将 main 函数改回去:
#![allow(unused)] fn main() { gs.generate_world_map(1, 0); }
龙不是很可怕
如果您玩游戏,龙非常致命。然而,它并没有太多的发自内心的冲击 - 它只是一个红色的 D 符号,有时会向您发射绿色的云雾。这是一个不错的想象力触发器,您甚至可以查看它的工具提示以了解 D 代表 Dragon - 但似乎我们可以做得更好。此外,龙真的相当大 - 而且龙占据与绵羊相同的地图空间有点奇怪。
我们将首先添加一个新的组件来表示更大的实体:
#![allow(unused)] fn main() { #[derive(Component, ConvertSaveload, Clone)] pub struct TileSize { pub x: i32, pub y: i32, } }
像往常一样,我们不会忘记在 main.rs 和 saveload_system.rs 中注册它!我们还将允许您在 JSON 文件中为实体指定大小。在 raws/item_structs.rs 中,我们将扩展 Renderable(记住,我们将其重用于其他类型):
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Renderable { pub glyph: String, pub fg : String, pub bg : String, pub order: i32, pub x_size : Option<i32>, pub y_size : Option<i32> } }
我们将新字段设为可选 - 这样我们现有的代码就可以工作了。现在在 raws/rawmaster.rs 中,找到将 Renderable 组件添加到实体的 spawn_named_mob 函数部分(在我的源代码中大约在第 418 行)。如果指定了大小,我们需要添加一个 TileSize 组件:
#![allow(unused)] fn main() { // Renderable if let Some(renderable) = &mob_template.renderable { eb = eb.with(get_renderable_component(renderable)); if renderable.x_size.is_some() || renderable.y_size.is_some() { eb = eb.with(TileSize{ x : renderable.x_size.unwrap_or(1), y : renderable.y_size.unwrap_or(1) }); } } }
现在,我们将进入 spawns.json 并为龙添加额外的大小:
{
"name" : "Black Dragon",
"renderable": {
"glyph" : "D",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1,
"x_size" : 2,
"y_size" : 2
},
...
在处理完内务管理后,我们需要能够将更大的实体渲染到地图上。打开 camera.rs,我们将像这样修改渲染:
#![allow(unused)] fn main() { // 渲染实体 let positions = ecs.read_storage::<Position>(); let renderables = ecs.read_storage::<Renderable>(); let hidden = ecs.read_storage::<Hidden>(); let map = ecs.fetch::<Map>(); let sizes = ecs.read_storage::<TileSize>(); let entities = ecs.entities(); let mut data = (&positions, &renderables, &entities, !&hidden).join().collect::<Vec<_>>(); data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) ); for (pos, render, entity, _hidden) in data.iter() { if let Some(size) = sizes.get(*entity) { for cy in 0 .. size.y { for cx in 0 .. size.x { let tile_x = cx + pos.x; let tile_y = cy + pos.y; let idx = map.xy_idx(tile_x, tile_y); if map.visible_tiles[idx] { let entity_screen_x = (cx + pos.x) - min_x; let entity_screen_y = (cy + pos.y) - min_y; if entity_screen_x > 0 && entity_screen_x < map_width && entity_screen_y > 0 && entity_screen_y < map_height { ctx.set(entity_screen_x + 1, entity_screen_y + 1, render.fg, render.bg, render.glyph); } } } } } else { let idx = map.xy_idx(pos.x, pos.y); if map.visible_tiles[idx] { let entity_screen_x = pos.x - min_x; let entity_screen_y = pos.y - min_y; if entity_screen_x > 0 && entity_screen_x < map_width && entity_screen_y > 0 && entity_screen_y < map_height { ctx.set(entity_screen_x + 1, entity_screen_y + 1, render.fg, render.bg, render.glyph); } } } } }
这是如何工作的?我们检查我们正在渲染的实体是否具有 TileSize 组件,使用 if let 语法进行匹配赋值。如果它有,我们为其指定的大小单独渲染每个瓦片。如果它没有,我们完全像以前一样渲染。请注意,我们正在对每个瓦片进行边界和可见性检查;这不是最快的方法,但确实保证了如果您可以看到龙的一部分,它将被渲染。
如果您现在 cargo run,您会发现自己面对的是一条更大的龙:

选择龙
如果您实际上与龙交战,就会出现很多问题:
- 对于远程攻击,您只能以龙的左上角瓦片为目标。这包括范围效果。
- 近战也仅影响龙的左上角瓦片。
- 您实际上可以穿过龙的其他瓦片。
- 龙可以穿过地形并仍然沿着狭窄的走廊行走。它可能很擅长折叠翅膀!
幸运的是,我们可以通过 map_indexing_system 解决很多问题。系统需要扩展以考虑多瓦片实体,并为实体占用的每个瓦片存储一个条目:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Map, Position, BlocksTile, Pools, spatial, TileSize}; pub struct MapIndexingSystem {} impl<'a> System<'a> for MapIndexingSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Map>, ReadStorage<'a, Position>, ReadStorage<'a, BlocksTile>, ReadStorage<'a, Pools>, ReadStorage<'a, TileSize>, Entities<'a>,); fn run(&mut self, data : Self::SystemData) { let (map, position, blockers, pools, sizes, entities) = data; spatial::clear(); spatial::populate_blocked_from_map(&*map); for (entity, position) in (&entities, &position).join() { let mut alive = true; if let Some(pools) = pools.get(entity) { if pools.hit_points.current < 1 { alive = false; } } if alive { if let Some(size) = sizes.get(entity) { // 多瓦片 for y in position.y .. position.y + size.y { for x in position.x .. position.x + size.x { if x > 0 && x < map.width-1 && y > 0 && y < map.height-1 { let idx = map.xy_idx(x, y); spatial::index_entity(entity, idx, blockers.get(entity).is_some()); } } } } else { // 单瓦片 let idx = map.xy_idx(position.x, position.y); spatial::index_entity(entity, idx, blockers.get(entity).is_some()); } } } } } }
这解决了几个问题:您现在可以攻击龙的任何部分,龙的所有身体都阻止其他人穿过它,并且远程目标定位可以针对其任何瓦片。然而,工具提示仍然顽固地不起作用 - 您只能从龙的左上角瓦片获取信息。幸运的是,很容易切换工具提示系统以使用 map.tile_content 结构,而不是重复迭代位置。它可能也表现更好。在 gui.rs 中,将函数的开头替换为:
#![allow(unused)] fn main() { fn draw_tooltips(ecs: &World, ctx : &mut Rltk) { use rltk::to_cp437; use rltk::Algorithm2D; let (min_x, _max_x, min_y, _max_y) = camera::get_screen_bounds(ecs, ctx); let map = ecs.fetch::<Map>(); let hidden = ecs.read_storage::<Hidden>(); let attributes = ecs.read_storage::<Attributes>(); let pools = ecs.read_storage::<Pools>(); let mouse_pos = ctx.mouse_pos(); let mut mouse_map_pos = mouse_pos; mouse_map_pos.0 += min_x - 1; mouse_map_pos.1 += min_y - 1; if mouse_pos.0 < 1 || mouse_pos.0 > 49 || mouse_pos.1 < 1 || mouse_pos.1 > 40 { return; } if mouse_map_pos.0 >= map.width-1 || mouse_map_pos.1 >= map.height-1 || mouse_map_pos.0 < 1 || mouse_map_pos.1 < 1 { return; } if !map.in_bounds(rltk::Point::new(mouse_map_pos.0, mouse_map_pos.1)) { return; } let mouse_idx = map.xy_idx(mouse_map_pos.0, mouse_map_pos.1); if !map.visible_tiles[mouse_idx] { return; } let mut tip_boxes : Vec<Tooltip> = Vec::new(); for entity in map.tile_content[mouse_idx].iter().filter(|e| hidden.get(**e).is_none()) { ... }
现在您可以使用工具提示来识别龙,并以它的任何部分为目标。为了展示代码的通用性,这是一张带有真正巨大的龙的屏幕截图:

您可能注意到龙死得非常容易。发生了什么?
- 龙对自己的吐息武器免疫,因此处于吐息半径内会对这可怜的野兽造成伤害。
- 范围效果系统意味着龙被反复击中 - 多个瓦片在半径范围内,因此对于每个瓦片 - 龙都受到了伤害。这并非完全不现实(您会期望火球术击中大型目标时会击中更大的表面积),但这绝对是一个意想不到的后果!范围效果毒药或网也会在可怜的受害者身上堆叠每个瓦片一个状态效果。
范围效果是否通常会击中施法者是一个有趣的问题;它在 火球术 上实现了良好的平衡(并且是 D&D 的一句老话 - 小心不要击中自己),但它绝对会导致意想不到的效果。
幸运的是,我们可以通过简单地更改 effects/damage.rs 来解决第一部分 - 自残:
#![allow(unused)] fn main() { pub fn inflict_damage(ecs: &mut World, damage: &EffectSpawner, target: Entity) { let mut pools = ecs.write_storage::<Pools>(); if let Some(pool) = pools.get_mut(target) { if !pool.god_mode { if let Some(creator) = damage.creator { if creator == target { return; } } ... }
范围效果法术不再消灭施法者,但仍然可以击中友方。这是一个不错的折衷方案!我们还将在 effects 系统中添加去重功能。无论如何,这可能是一个好主意。打开 effects/mod.rs,我们将开始。首先,我们需要包含 HashSet 作为导入的类型:
#![allow(unused)] fn main() { use std::collections::{HashSet, VecDeque}; }
接下来,我们将向 EffectSpawner 类型添加一个 dedupe 字段:
#![allow(unused)] fn main() { #[derive(Debug)] pub struct EffectSpawner { pub creator : Option<Entity>, pub effect_type : EffectType, pub targets : Targets, dedupe : HashSet<Entity> } }
并修改 add_effect 函数以包含一个:
#![allow(unused)] fn main() { pub fn add_effect(creator : Option<Entity>, effect_type: EffectType, targets : Targets) { EFFECT_QUEUE .lock() .unwrap() .push_back(EffectSpawner{ creator, effect_type, targets, dedupe : HashSet::new() }); } }
接下来,我们需要修改许多位置以使引用的效果可变 - 就像它可以被更改一样:
#![allow(unused)] fn main() { pub fn run_effects_queue(ecs : &mut World) { loop { let effect : Option<EffectSpawner> = EFFECT_QUEUE.lock().unwrap().pop_front(); if let Some(mut effect) = effect { target_applicator(ecs, &mut effect); } else { break; } } } fn target_applicator(ecs : &mut World, effect : &mut EffectSpawner) { if let EffectType::ItemUse{item} = effect.effect_type { triggers::item_trigger(effect.creator, item, &effect.targets, ecs); } else if let EffectType::SpellUse{spell} = effect.effect_type { triggers::spell_trigger(effect.creator, spell, &effect.targets, ecs); } else if let EffectType::TriggerFire{trigger} = effect.effect_type { triggers::trigger(effect.creator, trigger, &effect.targets, ecs); } else { match &effect.targets.clone() { Targets::Tile{tile_idx} => affect_tile(ecs, effect, *tile_idx), Targets::Tiles{tiles} => tiles.iter().for_each(|tile_idx| affect_tile(ecs, effect, *tile_idx)), Targets::Single{target} => affect_entity(ecs, effect, *target), Targets::TargetList{targets} => targets.iter().for_each(|entity| affect_entity(ecs, effect, *entity)), } } } fn affect_tile(ecs: &mut World, effect: &mut EffectSpawner, tile_idx : i32) { ... } }
最后,让我们将重复预防添加到 affect_entity:
#![allow(unused)] fn main() { fn affect_entity(ecs: &mut World, effect: &mut EffectSpawner, target: Entity) { if effect.dedupe.contains(&target) { return; } effect.dedupe.insert(target); match &effect.effect_type { EffectType::Damage{..} => damage::inflict_damage(ecs, effect, target), EffectType::EntityDeath => damage::death(ecs, effect, target), EffectType::Bloodstain{..} => if let Some(pos) = entity_position(ecs, target) { damage::bloodstain(ecs, pos) }, EffectType::Particle{..} => if let Some(pos) = entity_position(ecs, target) { particles::particle_to_tile(ecs, pos, &effect) }, EffectType::WellFed => hunger::well_fed(ecs, effect, target), EffectType::Healing{..} => damage::heal_damage(ecs, effect, target), EffectType::Mana{..} => damage::restore_mana(ecs, effect, target), EffectType::Confusion{..} => damage::add_confusion(ecs, effect, target), EffectType::TeleportTo{..} => movement::apply_teleport(ecs, effect, target), EffectType::AttributeEffect{..} => damage::attribute_effect(ecs, effect, target), EffectType::Slow{..} => damage::slow(ecs, effect, target), EffectType::DamageOverTime{..} => damage::damage_over_time(ecs, effect, target), _ => {} } } }
如果您现在 cargo run,龙将完全不会影响自己。如果您向龙发射火球术(我临时修改了 spawner.rs 以便开始时拥有 火焰球法杖 来进行测试!),它只会对龙产生一次影响。太棒了!
让龙从任何瓦片攻击
您可能注意到的另一个问题是,龙只能从其“头部”(左上角瓦片)攻击您。我喜欢将龙想象成具有猫一样的敏捷性(我倾向于认为它们通常很像猫!),因此这行不通!我们将从一个辅助函数开始。打开历史悠久的 rect.rs(自从开始以来我们就没有碰过它!),我们将向其中添加一个新函数:
#![allow(unused)] fn main() { use std::collections::HashSet; ... pub fn get_all_tiles(&self) -> HashSet<(i32,i32)> { let mut result = HashSet::new(); for y in self.y1 .. self.y2 { for x in self.x1 .. self.x2 { result.insert((x,y)); } } result } }
这将返回矩形内瓦片的 HashSet。非常简单,并希望优化成一个非常快速的函数!现在我们进入 ai/adjacent_ai_system.rs。我们将修改系统以也查询 TileSize:
#![allow(unused)] fn main() { impl<'a> System<'a> for AdjacentAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, ReadStorage<'a, Faction>, ReadStorage<'a, Position>, ReadExpect<'a, Map>, WriteStorage<'a, WantsToMelee>, Entities<'a>, ReadExpect<'a, Entity>, ReadStorage<'a, TileSize> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, factions, positions, map, mut want_melee, entities, player, sizes) = data; }
然后我们将检查是否存在不规则大小(如果没有,则使用旧代码) - 否则进行一些矩形数学运算以找到相邻的瓦片:
#![allow(unused)] fn main() { fn run(&mut self, data : Self::SystemData) { let (mut turns, factions, positions, map, mut want_melee, entities, player, sizes) = data; let mut turn_done : Vec<Entity> = Vec::new(); for (entity, _turn, my_faction, pos) in (&entities, &turns, &factions, &positions).join() { if entity != *player { let mut reactions : Vec<(Entity, Reaction)> = Vec::new(); let idx = map.xy_idx(pos.x, pos.y); let w = map.width; let h = map.height; if let Some(size) = sizes.get(entity) { use crate::rect::Rect; let mob_rect = Rect::new(pos.x, pos.y, size.x, size.y).get_all_tiles(); let parent_rect = Rect::new(pos.x -1, pos.y -1, size.x+2, size.y + 2); parent_rect.get_all_tiles().iter().filter(|t| !mob_rect.contains(t)).for_each(|t| { if t.0 > 0 && t.0 < w-1 && t.1 > 0 && t.1 < h-1 { let target_idx = map.xy_idx(t.0, t.1); evaluate(target_idx, &map, &factions, &my_faction.name, &mut reactions); } }); } else { // 为每个方向的相邻位置添加可能的反应 if pos.x > 0 { evaluate(idx-1, &map, &factions, &my_faction.name, &mut reactions); } if pos.x < w-1 { evaluate(idx+1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y > 0 { evaluate(idx-w as usize, &map, &factions, &my_faction.name, &mut reactions); } if pos.y < h-1 { evaluate(idx+w as usize, &map, &factions, &my_faction.name, &mut reactions); } if pos.y > 0 && pos.x > 0 { evaluate((idx-w as usize)-1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y > 0 && pos.x < w-1 { evaluate((idx-w as usize)+1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y < h-1 && pos.x > 0 { evaluate((idx+w as usize)-1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y < h-1 && pos.x < w-1 { evaluate((idx+w as usize)+1, &map, &factions, &my_faction.name, &mut reactions); } } ... }
逐步分析:
- 我们从与之前相同的设置开始。
- 我们使用
if let来获取瓦片大小(如果有)。 - 我们设置一个
mob_rect,它等于怪物的位置和尺寸,并获取它覆盖的瓦片的 Set。 - 我们设置一个
parent_rect,它在所有方向上都大一格瓦片。 - 我们调用
parent_rect.get_all_tiles()并将其转换为迭代器。然后我们filter它以仅包含不在mob_rect中的瓦片 - 因此我们拥有所有相邻的瓦片。 - 然后我们在结果瓦片集上使用
for_each,确保瓦片在地图范围内,并将它们添加到调用evaluate的位置。
如果您现在 cargo run,当您在龙周围行走时,龙可以攻击您。
裁剪龙的翅膀
另一个问题是,即使龙不适合,它也可以跟随您进入狭窄的走廊。大型实体的寻路在游戏中通常存在问题;《矮人要塞》最终为货车建立了一个糟糕的完全独立的系统!让我们希望我们可以做得更好。我们的移动系统在很大程度上依赖于地图内部的 blocked 结构,因此我们需要一种为大于一个瓦片的实体添加阻塞信息的方法。在 map/mod.rs 中,我们添加以下内容:
#![allow(unused)] fn main() { pub fn populate_blocked_multi(&mut self, width : i32, height : i32) { self.populate_blocked(); for y in 1 .. self.height-1 { for x in 1 .. self.width - 1 { let idx = self.xy_idx(x, y); if !crate::spatial::is_blocked(idx) { for cy in 0..height { for cx in 0..width { let tx = x + cx; let ty = y + cy; if tx < self.width-1 && ty < self.height-1 { let tidx = self.xy_idx(tx, ty); if crate::spatial::is_blocked(tidx) { crate::spatial::set_blocked(idx, true); } } else { crate::spatial::set_blocked(idx, true); } } } } } } } }
我要警告不要使用那么多嵌套循环,但至少它有转义子句!那么这是做什么的呢:
- 它首先使用现有的
populate_blocked函数为所有瓦片构建blocked信息。 - 然后它迭代地图,如果一个瓦片没有被阻塞:
- 它迭代实体大小范围内的每个瓦片,添加到当前坐标。
- 如果这些瓦片中的任何一个被阻塞,它也会将要检查的瓦片设置为阻塞。
因此,最终结果是您获得了一个大型实体可以站立的位置的地图。现在我们需要将其插入 ai/chase_ai_system.rs:
#![allow(unused)] fn main() { ... turn_done.push(entity); let target_pos = targets[&entity]; let path; if let Some(size) = sizes.get(entity) { let mut map_copy = map.clone(); map_copy.populate_blocked_multi(size.x, size.y); path = rltk::a_star_search( map_copy.xy_idx(pos.x, pos.y) as i32, map_copy.xy_idx(target_pos.0, target_pos.1) as i32, &mut map_copy ); } else { path = rltk::a_star_search( map.xy_idx(pos.x, pos.y) as i32, map.xy_idx(target_pos.0, target_pos.1) as i32, &mut *map ); } if path.success && path.steps.len()>1 && path.steps.len()<15 { ... }
因此,我们已将 path 的值更改为与 1x1 实体之前的代码相同,但对于大型实体,则获取地图的 克隆 并使用新的 populate_blocked_multi 函数来添加适合此大小生物的阻塞。
如果您现在 cargo run,您可以通过狭窄的通道逃脱龙的追捕。
为什么要付出这么多努力?
那么,当我们本可以为黑龙特殊处理时,为什么我们要花费这么多时间使不规则大小的对象如此通用地工作?这是为了让我们以后可以制作更多的大型物体。:-)
友情提示:总是很想为所有事物都这样做,但请记住 YAGNI 规则:You Ain't Gonna Need It(你不会需要它)。如果您没有充分的理由来实现某个功能,请坚持到您需要它或它有意义为止!(有趣的是:我第一次听到这个规则是一位网名为 TANSAAFL 的朋友说的。我花了很长时间才意识到他的意思是“天下没有免费的午餐。”)
担心平衡/游戏测试
现在到了困难的部分。现在是玩几次 1 到 6 级关卡的好时机,看看您能走多远,并看看您遇到了什么问题。以下是我注意到的一些事项:
- 我遇到了 鹿 比预期更烦人的情况,所以目前我已将它们从生成表中删除。
- 老鼠 实际上无法伤害您!将它们的威力更改为 7 会带来最小的惩罚,但会使它们偶尔造成一些伤害。
- 治疗药水需要更频繁地生成!我将其生成权重更改为 15。我遇到了几次我真的需要紧急治疗的情况。
- 游戏有点太难了;您真的要听天由命。您可能会做得很好,但一个掷骰子好的强盗会无情地屠杀您 - 即使在您设法穿上一些盔甲并升级之后!我决定做两件事来纠正这一点:
我通过更改 gamessytem.rs 中的 player_hp_per_level 和 player_hp_at_level,使玩家每级获得 20 点生命值而不是 10 点:
#![allow(unused)] fn main() { pub fn player_hp_per_level(fitness: i32) -> i32 { 15 + attr_bonus(fitness) } pub fn player_hp_at_level(fitness:i32, level:i32) -> i32 { 15 + (player_hp_per_level(fitness) * level) } }
在 effects/damage.rs 中,当我们处理升级时,我给了玩家更多升级的理由!随机属性和所有技能都会提高。因此,升级使您更快、更强壮且更具破坏力。它也使您更难被击中。这是代码:
#![allow(unused)] fn main() { if xp_gain != 0 || gold_gain != 0.0 { let mut log = ecs.fetch_mut::<GameLog>(); let mut player_stats = pools.get_mut(source).unwrap(); let mut player_attributes = attributes.get_mut(source).unwrap(); player_stats.xp += xp_gain; player_stats.gold += gold_gain; if player_stats.xp >= player_stats.level * 1000 { // 我们升级了! player_stats.level += 1; log.entries.push(format!("Congratulations, you are now level {}", player_stats.level)); // 恭喜,您现在是 {} 级了 // 提升一个随机属性 let mut rng = ecs.fetch_mut::<rltk::RandomNumberGenerator>(); let attr_to_boost = rng.roll_dice(1, 4); match attr_to_boost { 1 => { player_attributes.might.base += 1; log.entries.push("You feel stronger!".to_string()); // 您感觉更强壮了! } 2 => { player_attributes.fitness.base += 1; log.entries.push("You feel healthier!".to_string()); // 您感觉更健康了! } 3 => { player_attributes.quickness.base += 1; log.entries.push("You feel quicker!".to_string()); // 您感觉更快了! } _ => { player_attributes.intelligence.base += 1; log.entries.push("You feel smarter!".to_string()); // 您感觉更聪明了! } } // 提升所有技能 let mut skills = ecs.write_storage::<Skills>(); let player_skills = skills.get_mut(*ecs.fetch::<Entity>()).unwrap(); for sk in player_skills.skills.iter_mut() { *sk.1 += 1; } ecs.write_storage::<EquipmentChanged>() .insert( *ecs.fetch::<Entity>(), EquipmentChanged{}) .expect("Insert Failed"); // 插入失败 player_stats.hit_points.max = player_hp_at_level( player_attributes.fitness.base + player_attributes.fitness.modifiers, player_stats.level ); player_stats.hit_points.current = player_stats.hit_points.max; player_stats.mana.max = mana_at_level( player_attributes.intelligence.base + player_attributes.intelligence.modifiers, player_stats.level ); player_stats.mana.current = player_stats.mana.max; let player_pos = ecs.fetch::<rltk::Point>(); let map = ecs.fetch::<Map>(); for i in 0..10 { if player_pos.y - i > 1 { add_effect(None, EffectType::Particle{ glyph: rltk::to_cp437('░'), fg : rltk::RGB::named(rltk::GOLD), bg : rltk::RGB::named(rltk::BLACK), lifespan: 400.0 }, Targets::Tile{ tile_idx : map.xy_idx(player_pos.x, player_pos.y - i) as i32 } ); } } } } }
在这些更改之后再次玩游戏,游戏变得容易得多 - 而且感觉我可以取得进展(但仍然面临真正的死亡风险)。龙仍然击败了我,但是这是一场非常接近的战斗 - 我几乎赢了!所以我又玩了几次,一旦我找到了一种奖励我的法术和物品使用策略,我就获得了胜利。太棒了 - 这就是 roguelike 应该有的游戏类型!
我还发现在早期关卡中,如果我不注意就会死 - 但如果我专心致志,通常会取得胜利。
总结
因此,在本章中,我们构建了一个龙穴 - 并用一条邪恶的龙填充了它。它几乎可以被击败,但您真的需要动脑筋。
...
本章的源代码可以在这里找到
在您的浏览器中使用 Web Assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
蘑菇森林
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持 我的 Patreon。
设计文档指出,一旦你征服了堡垒中的巨龙,你将进入一片广阔的蘑菇森林。这是一个有趣的过渡:我们之前做过森林,但我们想让蘑菇森林与深入树林关卡有所不同。在这个关卡中,我们也希望在堡垒和森林之间进行过渡 - 所以我们需要另一种分层方法。
我们将从在 map_builder/mod.rs 中的关卡构建器中添加一个新函数开始:
#![allow(unused)] fn main() { mod mushroom_forest; use mushroom_forest::*; ... pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, rng, width, height), 2 => forest_builder(new_depth, rng, width, height), 3 => limestone_cavern_builder(new_depth, rng, width, height), 4 => limestone_deep_cavern_builder(new_depth, rng, width, height), 5 => limestone_transition_builder(new_depth, rng, width, height), 6 => dwarf_fort_builder(new_depth, rng, width, height), 7 => mushroom_entrance(new_depth, rng, width, height), _ => random_builder(new_depth, rng, width, height) } } }
现在我们将创建一个新文件,map_builder/mushroom_forest.rs:
#![allow(unused)] fn main() { use super::{BuilderChain, XStart, YStart, AreaStartingPosition, CullUnreachable, VoronoiSpawning, AreaEndingPosition, XEnd, YEnd, CellularAutomataBuilder, PrefabBuilder, WaveformCollapseBuilder}; use crate::map_builders::prefab_builder::prefab_sections::UNDERGROUND_FORT; pub fn mushroom_entrance(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Into The Mushroom Grove"); chain.start_with(CellularAutomataBuilder::new()); chain.with(WaveformCollapseBuilder::new()); chain.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaStartingPosition::new(XStart::RIGHT, YStart::CENTER)); chain.with(AreaEndingPosition::new(XEnd::LEFT, YEnd::CENTER)); chain.with(VoronoiSpawning::new()); chain.with(PrefabBuilder::sectional(UNDERGROUND_FORT)); chain } }
这应该看起来很熟悉:我们再次使用了细胞自动机 - 但将其与一些波形坍缩混合在一起,然后在上面添加了一个堡垒边缘。这为一个森林模板提供了一个相当不错的开始,尽管它需要视觉效果(和人口):

蘑菇林的 themed
我们之前使用过分割主题(用于进入堡垒),所以我们将会打开 map/themes.rs 并添加另一个主题应该不足为奇! 在这种情况下,我们希望堡垒主题应用于地图东部的防御工事,而新的蘑菇林外观应用于其余部分。
我们可以更新 tile_glyph 看起来像这样:
#![allow(unused)] fn main() { pub fn tile_glyph(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { let (glyph, mut fg, mut bg) = match map.depth { 7 => { let x = idx as i32 % map.width; if x > map.width-16 { get_tile_glyph_default(idx, map) } else { get_mushroom_glyph(idx, map) } } 5 => { let x = idx as i32 % map.width; if x < map.width/2 { get_limestone_cavern_glyph(idx, map) } else { get_tile_glyph_default(idx, map) } } 4 => get_limestone_cavern_glyph(idx, map), 3 => get_limestone_cavern_glyph(idx, map), 2 => get_forest_glyph(idx, map), _ => get_tile_glyph_default(idx, map) }; ... }
get_mushroom_glyph 函数基本上与 get_forest_glyph 相同,但更改为更像游戏 Dwarf Fortress 中的蘑菇林外观(耶,胖矮帽!):
#![allow(unused)] fn main() { fn get_mushroom_glyph(idx:usize, map: &Map) -> (rltk::FontCharType, RGB, RGB) { let glyph; let fg; let bg = RGB::from_f32(0., 0., 0.); match map.tiles[idx] { TileType::Wall => { glyph = rltk::to_cp437('♠'); fg = RGB::from_f32(1.0, 0.0, 1.0); } TileType::Bridge => { glyph = rltk::to_cp437('.'); fg = RGB::named(rltk::GREEN); } TileType::Road => { glyph = rltk::to_cp437('≡'); fg = RGB::named(rltk::CHOCOLATE); } TileType::Grass => { glyph = rltk::to_cp437('"'); fg = RGB::named(rltk::GREEN); } TileType::ShallowWater => { glyph = rltk::to_cp437('~'); fg = RGB::named(rltk::CYAN); } TileType::DeepWater => { glyph = rltk::to_cp437('~'); fg = RGB::named(rltk::BLUE); } TileType::Gravel => { glyph = rltk::to_cp437(';'); fg = RGB::from_f32(0.5, 0.5, 0.5); } TileType::DownStairs => { glyph = rltk::to_cp437('>'); fg = RGB::from_f32(0., 1.0, 1.0); } TileType::UpStairs => { glyph = rltk::to_cp437('<'); fg = RGB::from_f32(0., 1.0, 1.0); } _ => { glyph = rltk::to_cp437('"'); fg = RGB::from_f32(0.0, 0.6, 0.0); } } (glyph, fg, bg) } }
这给出了一个略显迷幻但非常漂亮的世界观:

填充蘑菇林
我首先编辑了 spawns.json,从这个关卡中移除了 dragon wyrmlings;蜥蜴人和巨型蜥蜴可以留下,但我们现在要将重心从蜥蜴人身上移开!你期望在一个神秘的地下蘑菇森林中找到什么?由于它们在现实生活中不存在,这是一个有点开放式的问题!我想关注一些自然危害,一种新型怪物,以及更多战利品。玩家刚刚完成了一场重要的 Boss 战,所以最好稍微降低一下装备等级,给他们一些时间来恢复。
自然危害
让我们从添加一些危害开始。蘑菇经常释放孢子,孢子对玩家(以及任何其他触发孢子的人!)产生有趣的影响是一个常见的主题。实际上,这些是道具还是 NPC 是一个有趣的问题;它们像 NPC 一样对发现玩家做出反应,但实际上并不移动或做太多其他事情,只是触发效果 - 更像道具(但与道具不同,您不必站在它们上面才能生效)。
爆炸火帽蘑菇
让我们从添加一个爆炸蘑菇开始。在 spawns.json 中(在怪物部分):
{
"name": "Firecap Mushroom",
"renderable": {
"glyph": "♠",
"fg": "#FFAA50",
"bg": "#000000",
"order": 1
},
"blocks_tile": true,
"vision_range": 3,
"movement": "static",
"attributes": {},
"faction": "Fungi",
"level": 1,
"abilities": [
{ "spell": "Explode", "chance": 1.0, "range": 3.0, "min_range": 0.0 }
]
}
所以我们给了它一个漂亮的蘑菇字形,并使其呈橙色(这似乎很合适)。它具有较短的视觉范围,因为我从未将真菌想象成具有最好的视力(甚至眼睛,真的)。它属于一个尚未存在的派系 Fungi,并具有一个尚未存在的 Explode 咒语能力!
让我们继续将其添加到派系表中:
{ "name": "Fungi", "responses": { "Default": "attack", "Fungi": "ignore" } }
我们还将开始定义 Explode 能力。在 spawns.json 的咒语部分:
{
"name": "Explode",
"mana_cost": 1,
"effects": {
"ranged": "3",
"damage": "20",
"area_of_effect": "3",
"particle": "▒;#FFAA50;400.0",
"single_activation": "1"
}
}
几乎所有这些都是我们已经构建到效果系统中的东西:它具有 3 的范围,效果区域和 single_activation。我们以前只对陷阱道具使用过这个标签,但它传达了信息 - 蘑菇只能爆炸一次,并且会在这个过程中被摧毁。我们已经支持在 raws/rawmaster.rs 中附加标签 - 所以那里不需要做任何事情。 我们确实需要扩展效果系统以允许自毁序列运行。在 effects/triggers.rs 中,我们需要扩展 spell_trigger 以支持自毁:
#![allow(unused)] fn main() { pub fn spell_trigger(creator : Option<Entity>, spell: Entity, targets : &Targets, ecs: &mut World) { let mut self_destruct = false; if let Some(template) = ecs.read_storage::<SpellTemplate>().get(spell) { let mut pools = ecs.write_storage::<Pools>(); if let Some(caster) = creator { if let Some(pool) = pools.get_mut(caster) { if template.mana_cost <= pool.mana.current { pool.mana.current -= template.mana_cost; } } } if let Some(_destruct) = ecs.read_storage::<SingleActivation>().get(spell) { self_destruct = true; } } event_trigger(creator, spell, targets, ecs); if self_destruct && creator.is_some() { ecs.entities().delete(creator.unwrap()).expect("Unable to delete owner"); } } }
所以这几乎是之前的代码,但增加了一个额外的检查,以查看咒语是否删除了施法者 - 如果是,它会在爆炸发生后立即删除施法者。
我们还应该使它们在蘑菇关卡中生成。在 spawns.json 的生成表部分:
{ "name" : "Firecap Mushroom", "weight" : 10, "min_depth" : 7, "max_depth" : 9 },
如果您现在 cargo run,蘑菇会在您靠近时引爆:

有一个小问题,蜥蜴人和真菌正在战斗,这没有多大意义。所以我们将更新他们的派系以防止这种情况发生:
{ "name" : "Wyrm", "responses": { "Default" : "attack", "Wyrm" : "ignore", "Fungi" : "ignore" }},
{ "name" : "Fungi", "responses": { "Default" : "attack", "Fungi" : "ignore", "Wyrm" : "ignore" }}
如果真菌在死亡时爆炸也会非常好;如果你有一些在一起,这可能会产生非常有趣的连锁反应(并且可以扩展到另一个关卡的爆炸桶!)。我们将向蘑菇添加一个注释:
{
"name" : "Firecap Mushroom",
"renderable": {
"glyph" : "♠",
"fg" : "#FFAA50",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 3,
"movement" : "static",
"attributes" : {},
"faction" : "Fungi",
"level" : 1
"abilities" : [
{ "spell" : "Explode", "chance" : 1.0, "range" : 3.0, "min_range" : 0.0 }
],
"on_death" : [
{ "spell" : "Explode", "chance" : 1.0, "range" : 0.0, "min_range" : 0.0 }
]
}
所以,现在我们有一个 on_death 触发器需要实现。我们将从 raws/mob_structs.rs 开始,以支持此 JSON 标签。我们正在重复使用 spell 标签,即使范围没有意义 - 只是为了帮助保持一致性。所以我们只需要在原始结构中添加一行:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub vision_range : i32, pub movement : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32>, pub equipped : Option<Vec<String>>, pub natural : Option<MobNatural>, pub loot_table : Option<String>, pub light : Option<MobLight>, pub faction : Option<String>, pub gold : Option<String>, pub vendor : Option<Vec<String>>, pub abilities : Option<Vec<MobAbility>>, pub on_death : Option<Vec<MobAbility>> } }
我们还需要一个新的组件来存储 on_death 事件触发器。我们可以重复使用一些 SpecialAbilities 代码来保持简单。在 components.rs 中(并注册在 main.rs 和 saveload_system.rs 中):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct OnDeath { pub abilities : Vec<SpecialAbility> } }
然后我们在 raws/rawmaster.rs 的 spawn_named_mob 中添加一些代码来实例化它。它就像特殊能力代码一样 - 所以把它放在旁边:
#![allow(unused)] fn main() { if let Some(ability_list) = &mob_template.on_death { let mut a = OnDeath{ abilities : Vec::new() }; for ability in ability_list.iter() { a.abilities.push( SpecialAbility{ chance : ability.chance, spell : ability.spell.clone(), range : ability.range, min_range : ability.min_range } ); } eb = eb.with(a); } }
最后,我们需要使 on_death 事件实际触发。如果您将此添加到 damage_system.rs 中 delete_the_dead 函数的末尾(在最终实体删除之前),您将获得一个很好的错峰效果,并在蘑菇被杀死之后立即爆炸:
#![allow(unused)] fn main() { // Fire death events // 触发死亡事件 use crate::effects::*; use crate::Map; use crate::components::{OnDeath, AreaOfEffect}; for victim in dead.iter() { let death_effects = ecs.read_storage::<OnDeath>(); if let Some(death_effect) = death_effects.get(*victim) { let mut rng = ecs.fetch_mut::<rltk::RandomNumberGenerator>(); for effect in death_effect.abilities.iter() { if rng.roll_dice(1,100) <= (effect.chance * 100.0) as i32 { let map = ecs.fetch::<Map>(); if let Some(pos) = ecs.read_storage::<Position>().get(*victim) { let spell_entity = crate::raws::find_spell_entity(ecs, &effect.spell).unwrap(); let tile_idx = map.xy_idx(pos.x, pos.y); let target = if let Some(aoe) = ecs.read_storage::<AreaOfEffect>().get(spell_entity) { Targets::Tiles { tiles : aoe_tiles(&map, rltk::Point::new(pos.x, pos.y), aoe.radius) } } else { Targets::Tile{ tile_idx : tile_idx as i32 } }; add_effect( None, EffectType::SpellUse{ spell: crate::raws::find_spell_entity( ecs, &effect.spell ).unwrap() }, target ); } } } } } }
还有一个明显的问题;当蘑菇爆炸时,爆炸中心位于玩家而不是蘑菇身上。让我们创建一个新组件来表示覆盖咒语目标始终以自身为目标。在 components.rs 中(并且,像往常一样,注册在 main.rs 和 saveload_system.rs 中):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct AlwaysTargetsSelf {} }
我们将把它添加到 raws/rawmaster.rs 中的 effects 宏中:
#![allow(unused)] fn main() { "target_self" => $eb = $eb.with( AlwaysTargetsSelf{} ), }
我们应该将其应用于 spawns.json 中的 Explode 能力:
{
"name": "Explode",
"mana_cost": 1,
"effects": {
"ranged": "3",
"damage": "20",
"area_of_effect": "3",
"particle": "▒;#FFAA50;400.0",
"single_activation": "1",
"target_self": "1"
}
}
最后,我们需要修改 effects/triggers.rs 中的 spell_trigger 以能够修改目标选择:
#![allow(unused)] fn main() { pub fn spell_trigger(creator : Option<Entity>, spell: Entity, targets : &Targets, ecs: &mut World) { let mut targeting = targets.clone(); let mut self_destruct = false; if let Some(template) = ecs.read_storage::<SpellTemplate>().get(spell) { let mut pools = ecs.write_storage::<Pools>(); if let Some(caster) = creator { if let Some(pool) = pools.get_mut(caster) { if template.mana_cost <= pool.mana.current { pool.mana.current -= template.mana_cost; } } // Handle self-targeting override // 处理自目标覆盖 if ecs.read_storage::<AlwaysTargetsSelf>().get(spell).is_some() { if let Some(pos) = ecs.read_storage::<Position>().get(caster) { let map = ecs.fetch::<Map>(); targeting = if let Some(aoe) = ecs.read_storage::<AreaOfEffect>().get(spell) { Targets::Tiles { tiles : aoe_tiles(&map, rltk::Point::new(pos.x, pos.y), aoe.radius) } } else { Targets::Tile{ tile_idx : map.xy_idx(pos.x, pos.y) as i32 } } } } } if let Some(_destruct) = ecs.read_storage::<SingleActivation>().get(spell) { self_destruct = true; } } event_trigger(creator, spell, &targeting, ecs); if self_destruct && creator.is_some() { ecs.entities().delete(creator.unwrap()).expect("Unable to delete owner"); } } }
为了演示我们刚刚创建的怪物,我将蘑菇的生成密度提高到 300 - 并将爆炸半径更改为 6。开始了:

改回设置可能是一个好主意!现在真的很想制作一个连锁蘑菇关卡,以便在整个关卡中产生多米诺骨牌般的爆炸涟漪 - 但这可能更适合观看而不是玩!
混乱蘑菇
另一个明显的效果是孢子播撒混乱的蘑菇。我们拥有实现它们所需的一切!
在 spawns.json 的怪物部分,我们定义了基本蘑菇:
{
"name": "Sporecap Mushroom",
"renderable": {
"glyph": "♠",
"fg": "#00AAFF",
"bg": "#000000",
"order": 1
},
"blocks_tile": true,
"vision_range": 3,
"movement": "static",
"attributes": {},
"faction": "Fungi",
"level": 1,
"abilities": [
{ "spell": "ConfusionCloud", "chance": 1.0, "range": 3.0, "min_range": 0.0 }
],
"on_death": [
{ "spell": "ConfusionCloud", "chance": 1.0, "range": 0.0, "min_range": 0.0 }
]
}
在生成权重中,我们使它们在真菌林中很常见:
{ "name" : "Sporecap Mushroom", "weight" : 10, "min_depth" : 7, "max_depth" : 9 },
我们可以将咒语定义如下:
{
"name": "ConfusionCloud",
"mana_cost": 1,
"effects": {
"ranged": "3",
"confusion": "4",
"area_of_effect": "3",
"particle": "?;#FFFF00;400.0",
"single_activation": "1",
"target_self": "1"
}
}
无需其他代码!如果您现在 cargo run,您将获得蓝色蘑菇爆炸,带来令人困惑的美好:

毒气蘑菇
我们将添加另一种蘑菇类型:一种传播有毒孢子的死帽蘑菇! 再次,我们拥有实现此目的所需的一切。我们将蘑菇定义为 spawns.json 中的怪物:
{
"name": "Deathcap Mushroom",
"renderable": {
"glyph": "♠",
"fg": "#55FF55",
"bg": "#000000",
"order": 1
},
"blocks_tile": true,
"vision_range": 3,
"movement": "static",
"attributes": {},
"faction": "Fungi",
"level": 1,
"abilities": [
{ "spell": "PoisonCloud", "chance": 1.0, "range": 3.0, "min_range": 0.0 }
],
"on_death": [
{ "spell": "PoisonCloud", "chance": 1.0, "range": 0.0, "min_range": 0.0 }
]
}
使其生成:
{ "name" : "Deathcap Mushroom", "weight" : 7, "min_depth" : 7, "max_depth" : 9 },
并定义咒语效果:
{
"name": "PoisonCloud",
"mana_cost": 1,
"effects": {
"ranged": "3",
"damage_over_time": "4",
"area_of_effect": "3",
"particle": "*;#00FF00;400.0",
"single_activation": "1",
"target_self": "1"
}
}
瞧 - 你有了有毒蘑菇孢子云。
真菌林怪物
我们不只是想让玩家浑身沾满孢子。还有一些蜥蜴人需要担心,但一些怪物也居住在树林中是有道理的。我想到了几个:真菌人,你可以与他们战斗 - 并吃掉他们的尸体,还有一种整天漫游啃食真菌(或玩家)的野兽。我们还可以引入“孢子僵尸” - 大脑被真菌控制的人,他们只寻求杀死真菌的敌人(有一些令人不安的寄生虫以类似的方式接管宿主,所以这并不像听起来那么不切实际!)。
真菌人
让我们从真菌人开始。在 spawns.json 中,我们可以将它们定义为常规的敌人类别:
{
"name": "Fungus Man",
"renderable": {
"glyph": "f",
"fg": "#FF0000",
"bg": "#000000",
"order": 1
},
"blocks_tile": true,
"vision_range": 8,
"movement": "random_waypoint",
"attributes": {},
"faction": "Fungi",
"gold": "2d8",
"level": 4,
"loot_table": "Animal"
}
我们还将使它们生成:
{ "name" : "Fungus Man", "weight" : 8, "min_depth" : 7, "max_depth" : 9 },
这增加了真菌人,他们掉落肉。你可能不想过多考虑味道。
孢子僵尸
同样,我们将从基本的怪物定义开始。我们不能让所有东西都做时髦的事情 - 那会压倒玩家:
{
"name": "Spore Zombie",
"renderable": {
"glyph": "z",
"fg": "#FF0000",
"bg": "#000000",
"order": 1
},
"blocks_tile": true,
"vision_range": 8,
"movement": "random_waypoint",
"attributes": {},
"faction": "Fungi",
"gold": "2d8",
"level": 5
}
我们还需要使它们生成:
{ "name" : "Spore Zombie", "weight" : 7, "min_depth" : 7, "max_depth" : 9 },
真菌野兽
我们将野兽模仿其他动物,但将它们放入 “Fungi” 派系:
{
"name": "Fungal Beast",
"renderable": {
"glyph": "F",
"fg": "#995555",
"bg": "#000000",
"order": 1
},
"blocks_tile": true,
"vision_range": 6,
"movement": "random",
"attributes": {},
"natural": {
"armor_class": 11,
"attacks": [{ "name": "bite", "hit_bonus": 0, "damage": "1d4" }]
},
"faction": "Fungi"
}
我们还需要使它们生成:
{ "name" : "Fungal Beast", "weight" : 9, "min_depth" : 7, "max_depth" : 9 },
如果您现在 cargo run,您将拥有一个充满生机和会爆炸事物的关卡!
一些物品
作为对永久吸入毒气、被僵尸啃咬和被野兽嚼碎的奖励,现在是时候向树林引入一些新物品了! 让我们考虑玩家可能会遇到的一些新物品。
一个简单的提升是一把更好的长剑:
{
"name" : "Longsword +2",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAFF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "might",
"base_damage" : "1d8+2",
"hit_bonus" : 2
},
"weight_lbs" : 1.0,
"base_value" : 100.0,
"initiative_penalty" : 0,
"vendor_category" : "weapon",
"magic" : { "class" : "common", "naming" : "Unidentified Longsword" }
},
当然,将其添加到生成列表:
{ "name" : "Longsword +2", "weight" : 1, "min_depth" : 7, "max_depth" : 100 },
另一个简单的物品是一件魔法胸甲:
{
"name" : "Breastplate +1",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Torso",
"armor_class" : 4.0
},
"weight_lbs" : 20.0,
"base_value" : 200.0,
"initiative_penalty" : 1.0,
"vendor_category" : "armor",
"magic" : { "class" : "common", "naming" : "Unidentified Breastplate" }
},
同样,它也需要可生成:
{ "name" : "Breastplate +1", "weight" : 1, "min_depth" : 7, "max_depth" : 100 },
同样,很容易采用基本的塔盾并提供改进的版本:
{
"name" : "Tower Shield +1",
"renderable": {
"glyph" : "[",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Shield",
"armor_class" : 3.0
},
"weight_lbs" : 45.0,
"base_value" : 30.0,
"initiative_penalty" : 0.0,
"vendor_category" : "armor"
},
当然,它也需要一些生成数据:
{ "name" : "Tower Shield +1", "weight" : 1, "min_depth" : 7, "max_depth" : 100 },
我们还应该考虑填充一些未使用的装备槽位。我们有很多以躯干为中心的物品,而很少有物品可以填充其他槽位。 为了完整性,我们应该添加一些!
头部物品
目前,我们只有一个头部物品:锁子头盔。为我们目前使用的主要盔甲类别中的每一个都配备一个头部物品是有道理的:布甲,皮甲,锁甲(我们有这个!),板甲。
物品定义是:
{
"name" : "Cloth Cap",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Head",
"armor_class" : 0.2
},
"weight_lbs" : 0.25,
"base_value" : 5.0,
"initiative_penalty" : 0.1,
"vendor_category" : "armor"
},
{
"name" : "Leather Cap",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Head",
"armor_class" : 0.4
},
"weight_lbs" : 0.5,
"base_value" : 10.0,
"initiative_penalty" : 0.2,
"vendor_category" : "armor"
},
{
"name" : "Chain Coif",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Head",
"armor_class" : 1.0
},
"weight_lbs" : 5.0,
"base_value" : 20.0,
"initiative_penalty" : 0.5,
"vendor_category" : "armor"
},
{
"name" : "Steel Helm",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Head",
"armor_class" : 2.0
},
"weight_lbs" : 15.0,
"base_value" : 100.0,
"initiative_penalty" : 1.0,
"vendor_category" : "armor"
},
这是它们更新后的生成信息:
{ "name" : "Cloth Cap", "weight" : 5, "min_depth" : 4, "max_depth" : 100 },
{ "name" : "Leather Cap", "weight" : 4, "min_depth" : 4, "max_depth" : 100 },
{ "name" : "Chain Coif", "weight" : 3, "min_depth" : 4, "max_depth" : 100 },
{ "name" : "Steel Helm", "weight" : 2, "min_depth" : 4, "max_depth" : 100 },
腿部物品
我们现在也有一些腿部物品,但不多:我们有破旧的裤子和布裤。 让我们也将它们扩展到包括皮革、锁甲和钢铁。
{
"name" : "Leather Pants",
"renderable": {
"glyph" : "[",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Legs",
"armor_class" : 0.2
},
"weight_lbs" : 5.0,
"base_value" : 25.0,
"initiative_penalty" : 0.2,
"vendor_category" : "clothes"
},
{
"name" : "Chain Leggings",
"renderable": {
"glyph" : "[",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Legs",
"armor_class" : 0.3
},
"weight_lbs" : 10.0,
"base_value" : 50.0,
"initiative_penalty" : 0.3,
"vendor_category" : "clothes"
},
{
"name" : "Steel Greaves",
"renderable": {
"glyph" : "[",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Legs",
"armor_class" : 0.5
},
"weight_lbs" : 20.0,
"base_value" : 100.0,
"initiative_penalty" : 0.5,
"vendor_category" : "clothes"
},
同样,我们需要给它们生成数据:
{ "name" : "Cloth Pants", "weight" : 6, "min_depth" : 1, "max_depth" : 100 },
{ "name" : "Leather Pants", "weight" : 5, "min_depth" : 1, "max_depth" : 100 },
{ "name" : "Chain Leggings", "weight" : 4, "min_depth" : 1, "max_depth" : 100 },
{ "name" : "Steel Greaves", "weight" : 3, "min_depth" : 5, "max_depth" : 100 },
足部物品
同样,我们关于足部盔甲的故事非常有限。 我们有旧靴子、拖鞋和皮靴。 我们也应该为这些添加锁甲和板甲选项:
{
"name" : "Leather Boots",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Feet",
"armor_class" : 0.2
},
"weight_lbs" : 2.0,
"base_value" : 5.0,
"initiative_penalty" : 0.25,
"vendor_category" : "clothes"
},
{
"name" : "Chain Boots",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Feet",
"armor_class" : 0.3
},
"weight_lbs" : 3.0,
"base_value" : 10.0,
"initiative_penalty" : 0.25,
"vendor_category" : "armor"
},
{
"name" : "Steel Boots",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Feet",
"armor_class" : 0.5
},
"weight_lbs" : 5.0,
"base_value" : 10.0,
"initiative_penalty" : 0.4,
"vendor_category" : "armor"
},
以及一些生成信息:
{ "name" : "Leather Boots", "weight" : 5, "min_depth" : 1, "max_depth" : 100 },
{ "name" : "Chain Boots", "weight" : 4, "min_depth" : 3, "max_depth" : 100 },
{ "name" : "Steel Boots", "weight" : 2, "min_depth" : 5, "max_depth" : 100 },
手部物品
我们现在的手部盔甲故事真的很糟糕:我们只有食人魔力量手套,仅此而已! 让我们添加一些“普通”手套来完善一下:
{
"name" : "Cloth Gloves",
"renderable": {
"glyph" : "[",
"fg" : "#FF9999",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Hands",
"armor_class" : 0.1
},
"weight_lbs" : 0.5,
"base_value" : 1.0,
"initiative_penalty" : 0.1,
"vendor_category" : "clothes"
},
{
"name" : "Leather Gloves",
"renderable": {
"glyph" : "[",
"fg" : "#FF9999",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Hands",
"armor_class" : 0.2
},
"weight_lbs" : 1.0,
"base_value" : 1.0,
"initiative_penalty" : 0.1,
"vendor_category" : "clothes"
},
{
"name" : "Chain Gloves",
"renderable": {
"glyph" : "[",
"fg" : "#FF9999",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Hands",
"armor_class" : 0.3
},
"weight_lbs" : 2.0,
"base_value" : 10.0,
"initiative_penalty" : 0.2,
"vendor_category" : "clothes"
},
{
"name" : "Steel Gloves",
"renderable": {
"glyph" : "[",
"fg" : "#FF9999",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Hands",
"armor_class" : 0.5
},
"weight_lbs" : 5.0,
"base_value" : 10.0,
"initiative_penalty" : 0.3,
"vendor_category" : "clothes"
},
当然,还有一些生成数据:
{ "name" : "Cloth Gloves", "weight" : 6, "min_depth" : 1, "max_depth" : 100 },
{ "name" : "Leather Gloves", "weight" : 5, "min_depth" : 1, "max_depth" : 100 },
{ "name" : "Chain Gloves", "weight" : 3, "min_depth" : 1, "max_depth" : 100 },
{ "name" : "Steel Gloves", "weight" : 2, "min_depth" : 5, "max_depth" : 100 },
总结
这就是我们所拥有的 - 一个可用的从堡垒到蘑菇林的过渡关卡,以及一个充实的物品表。 在下一章中,我们将继续在实现设计文档方面取得进展,完成蘑菇森林的剩余部分,并在改进物品故事方面做更多工作。
...
本章的源代码可以在这里找到
使用 web assembly 在您的浏览器中运行本章示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
幽深蘑菇森林
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
本章将为游戏添加另一个蘑菇林地关卡,这次没有矮人要塞。它还将添加最终的蘑菇关卡,根据设计文档,这个关卡将通往黑暗精灵城市。最后,我们将通过自动化添加魔法和诅咒物品的一些繁琐工作,进一步改进我们的物品故事。
构建蘑菇森林
我们将从打开 map_builders/mod.rs 并向地图构建器调用添加另一行开始:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, rng, width, height), 2 => forest_builder(new_depth, rng, width, height), 3 => limestone_cavern_builder(new_depth, rng, width, height), 4 => limestone_deep_cavern_builder(new_depth, rng, width, height), 5 => limestone_transition_builder(new_depth, rng, width, height), 6 => dwarf_fort_builder(new_depth, rng, width, height), 7 => mushroom_entrance(new_depth, rng, width, height), 8 => mushroom_builder(new_depth, rng, width, height), _ => random_builder(new_depth, rng, width, height) } } }
然后我们将打开 map_builders/mushroom_forest.rs 并为该关卡存根一个基本的地图构建器:
#![allow(unused)] fn main() { pub fn mushroom_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Into The Mushroom Grove"); chain.start_with(CellularAutomataBuilder::new()); chain.with(WaveformCollapseBuilder::new()); chain.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaStartingPosition::new(XStart::RIGHT, YStart::CENTER)); chain.with(AreaEndingPosition::new(XEnd::LEFT, YEnd::CENTER)); chain.with(VoronoiSpawning::new()); chain } }
这基本上与另一个蘑菇构建器相同,但没有预制件覆盖。如果您进入 main.rs 并更改起始关卡:
#![allow(unused)] fn main() { gs.generate_world_map(8, 0); rltk::main_loop(context, gs) }
并 cargo run,您将获得一个相当不错的关卡。它保留了我们之前关卡的怪物生成,因为我们仔细地将它们包含在我们的生成关卡范围中。
真菌森林的尽头
再一次,我们将在 map_builders/mod.rs 中添加另一个关卡:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, rng, width, height), 2 => forest_builder(new_depth, rng, width, height), 3 => limestone_cavern_builder(new_depth, rng, width, height), 4 => limestone_deep_cavern_builder(new_depth, rng, width, height), 5 => limestone_transition_builder(new_depth, rng, width, height), 6 => dwarf_fort_builder(new_depth, rng, width, height), 7 => mushroom_entrance(new_depth, rng, width, height), 8 => mushroom_builder(new_depth, rng, width, height), 9 => mushroom_exit(new_depth, rng, width, height), _ => random_builder(new_depth, rng, width, height) } } }
并赋予它与 mushroom_builder 相同的起始代码:
#![allow(unused)] fn main() { pub fn mushroom_exit(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Into The Mushroom Grove"); chain.start_with(CellularAutomataBuilder::new()); chain.with(WaveformCollapseBuilder::new()); chain.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaStartingPosition::new(XStart::RIGHT, YStart::CENTER)); chain.with(AreaEndingPosition::new(XEnd::LEFT, YEnd::CENTER)); chain.with(VoronoiSpawning::new()); chain } }
我们还将访问 main.rs 以使我们从这个关卡开始:
#![allow(unused)] fn main() { gs.generate_world_map(9, 0); }
连续两个相同的关卡(设计方面;内容会因程序生成而异)非常枯燥,我们需要传达这里有一个通往黑暗精灵城市的入口的想法。我们将从向地图添加一个新的预制件截面开始:
#![allow(unused)] fn main() { #[allow(dead_code)] pub const DROW_ENTRY : PrefabSection = PrefabSection{ template : DROW_ENTRY_TXT, width: 12, height: 10, placement: ( HorizontalPlacement::Center, VerticalPlacement::Center ) }; #[allow(dead_code)] const DROW_ENTRY_TXT : &str = " ######### # > # # e # e # e # ######### "; }
注意空格:预制件周围有空格,这些空格是有意的 - 以确保它周围有一个“沟槽”。现在我们修改我们的 mushroom_exit 函数来生成它:
#![allow(unused)] fn main() { pub fn mushroom_exit(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height, "Into The Mushroom Grove"); chain.start_with(CellularAutomataBuilder::new()); chain.with(WaveformCollapseBuilder::new()); chain.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaStartingPosition::new(XStart::RIGHT, YStart::CENTER)); chain.with(AreaEndingPosition::new(XEnd::LEFT, YEnd::CENTER)); chain.with(VoronoiSpawning::new()); chain.with(PrefabBuilder::sectional(DROW_ENTRY)); chain } }
未知字形加载地图: e
您可以 cargo run 并找到现在位于中间的出口,但是没有黑暗精灵! “e”什么也没生成,并生成一个警告。没关系 - 我们还没有实现任何黑暗精灵。在 map_builders/prefab_builder/mod.rs 中,我们将添加 e 以在加载器文件中表示“Dark Elf”:
#![allow(unused)] fn main() { fn char_to_map(&mut self, ch : char, idx: usize, build_data : &mut BuilderMap) { // 边界检查 if idx >= build_data.map.tiles.len()-1 { return; } match ch { ' ' => build_data.map.tiles[idx] = TileType::Floor, '#' => build_data.map.tiles[idx] = TileType::Wall, '≈' => build_data.map.tiles[idx] = TileType::DeepWater, '@' => { let x = idx as i32 % build_data.map.width; let y = idx as i32 / build_data.map.width; build_data.map.tiles[idx] = TileType::Floor; build_data.starting_position = Some(Position{ x:x as i32, y:y as i32 }); } '>' => build_data.map.tiles[idx] = TileType::DownStairs, 'e' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Dark Elf".to_string())); } 'g' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Goblin".to_string())); } 'o' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Orc".to_string())); } 'O' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Orc Leader".to_string())); } '^' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Bear Trap".to_string())); } '%' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Rations".to_string())); } '!' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Health Potion".to_string())); } '☼' => { build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Watch Fire".to_string())); } _ => { rltk::console::log(format!("Unknown glyph loading map: {}", (ch as u8) as char)); } } } }
如果您 cargo run,则错误现在被 WARNING: We don't know how to spawn [Dark Elf]! 取代 - 这是进步。
为了解决这个问题,我们将定义黑暗精灵!让我们从一个非常简单的 spawns.json 条目开始:
{
"name" : "Dark Elf",
"renderable": {
"glyph" : "e",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "random_waypoint",
"attributes" : {},
"equipped" : [ "Dagger", "Shield", "Leather Armor", "Leather Boots" ],
"faction" : "DarkElf",
"gold" : "3d6",
"level" : 6
},
我们还将为他们提供一个 faction 条目:
{ "name" : "DarkElf", "responses" : { "Default" : "attack", "DarkElf" : "ignore" } }
如果您现在 cargo run,您将有一些中等强度的黑暗精灵需要对付。问题是,它们不是很“黑暗精灵”:它们基本上是重新蒙皮的土匪。当您想到“黑暗精灵”时,您会想到什么(除了 Drizzt Do'Urden,如果我包含他,他的版权所有者会从远处击打我)?他们非常邪恶,魔法,行动迅速,并且通常非常强大。他们也倾向于拥有自己的黑暗技术,并用远程武器攻击他们的敌人!
在下一章之前,我们不会支持远程武器,但是我们可以采取一些步骤使它们更像黑暗精灵。让我们给他们一套更像黑暗精灵的物品。在 equipped 标签中,我们将使用:
"equipped" : [ "Scimitar", "Buckler", "Drow Chain", "Drow Leggings", "Drow Boots" ],
我们还需要为这些制作物品条目。我们将 弯刀 基本视为长剑,但稍微好一点:
{
"name" : "Scimitar",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAFF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "might",
"base_damage" : "1d6+2",
"hit_bonus" : 1
},
"weight_lbs" : 2.5,
"base_value" : 25.0,
"initiative_penalty" : 1,
"vendor_category" : "weapon"
},
我们将遵循 卓尔盔甲 的趋势:它基本上是链甲,但启动惩罚要小得多:
{
"name" : "Drow Leggings",
"renderable": {
"glyph" : "[",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Legs",
"armor_class" : 0.4
},
"weight_lbs" : 10.0,
"base_value" : 50.0,
"initiative_penalty" : 0.1,
"vendor_category" : "clothes"
},
{
"name" : "Drow Chain",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Torso",
"armor_class" : 3.0
},
"weight_lbs" : 5.0,
"base_value" : 50.0,
"initiative_penalty" : 0.0,
"vendor_category" : "armor"
},
{
"name" : "Drow Boots",
"renderable": {
"glyph" : "[",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"wearable" : {
"slot" : "Feet",
"armor_class" : 0.4
},
"weight_lbs" : 2.0,
"base_value" : 10.0,
"initiative_penalty" : 0.1,
"vendor_category" : "armor"
},
这些的结果是它们很快 - 它们的启动惩罚比类似装甲的玩家小得多。另一个好处是,您可以杀死一个,拿走他们的东西 - 并获得相同的好处!
至此,我们已经添加了两个可玩的关卡 - 只需几行代码。收获在通用系统上努力工作的回报!所以现在,让我们使事情更通用一些 - 并为自己节省一些打字时间。
程序化生成的魔法物品
我们一直在添加“长剑 +1”、“长剑 -1”等等。我们可以坐下来费力地输入每个物品的每个魔法变体,我们将拥有一个相当可玩的游戏。或者 - 我们可以自动化一些繁琐的工作!
如果我们可以在 spawns.json 中的武器定义中附加一个“template”属性,并使其自动为我们生成变体呢?这并不像听起来那么牵强。让我们草拟一下我们想要的东西:
{
"name" : "Longsword",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAFF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"attribute" : "might",
"base_damage" : "1d8",
"hit_bonus" : 0
},
"weight_lbs" : 3.0,
"base_value" : 15.0,
"initiative_penalty" : 2,
"vendor_category" : "weapon",
"template_magic" : {
"unidentified_name" : "Unidentified Longsword",
"bonus_min" : 1,
"bonus_max" : 5,
"include_cursed" : true
}
},
因此,我们添加了一个 template_magic 部分,描述了我们想要添加的物品类型。我们需要扩展 raws/item_structs.rs 以支持加载此信息:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug, Clone)] pub struct Item { pub name : String, pub renderable : Option<Renderable>, pub consumable : Option<Consumable>, pub weapon : Option<Weapon>, pub wearable : Option<Wearable>, pub initiative_penalty : Option<f32>, pub weight_lbs : Option<f32>, pub base_value : Option<f32>, pub vendor_category : Option<String>, pub magic : Option<MagicItem>, pub attributes : Option<ItemAttributeBonus>, pub template_magic : Option<ItemMagicTemplate> } ... #[derive(Deserialize, Debug, Clone)] pub struct ItemMagicTemplate { pub unidentified_name: String, pub bonus_min: i32, pub bonus_max: i32, pub include_cursed: bool } }
这足以加载额外的信息 - 它只是什么也不做。我们还需要遍历并为该文件中的所有结构在 #[derive] 列表中添加 Clone。我们将使用 clone() 制作副本,然后为每个变体进行修改。
与其他添加不同,这不会修改我们在 rawmaster.rs 中的 spawn_named_item 函数;我们希望在开始生成之前修改原始文件模板。相反,我们将后处理 load 函数本身构建的物品列表(包括修改生成列表)。在该函数的顶部,我们将读取每个物品,如果它附加了模板(并且是武器或盔甲物品),我们将它添加到要处理的列表中:
#![allow(unused)] fn main() { pub fn load(&mut self, raws : Raws) { self.raws = raws; self.item_index = HashMap::new(); let mut used_names : HashSet<String> = HashSet::new(); struct NewMagicItem { name : String, bonus : i32 } let mut items_to_build : Vec<NewMagicItem> = Vec::new(); for (i,item) in self.raws.items.iter().enumerate() { if used_names.contains(&item.name) { rltk::console::log(format!("WARNING - duplicate item name in raws [{}]", item.name)); } self.item_index.insert(item.name.clone(), i); used_names.insert(item.name.clone()); if let Some(template) = &item.template_magic { if item.weapon.is_some() || item.wearable.is_some() { if template.include_cursed { items_to_build.push(NewMagicItem{ name : item.name.clone(), bonus : -1 }); } for bonus in template.bonus_min ..= template.bonus_max { items_to_build.push(NewMagicItem{ name : item.name.clone(), bonus }); } } else { rltk::console::log(format!("{} is marked as templated, but isn't a weapon or armor.", item.name)); } } } }
然后,在我们完成读取物品后,我们将在最后添加一个循环来创建这些物品:
#![allow(unused)] fn main() { for nmw in items_to_build.iter() { let base_item_index = self.item_index[&nmw.name]; let mut base_item_copy = self.raws.items[base_item_index].clone(); if nmw.bonus == -1 { base_item_copy.name = format!("{} -1", nmw.name); } else { base_item_copy.name = format!("{} +{}", nmw.name, nmw.bonus); } base_item_copy.magic = Some(super::MagicItem{ class : match nmw.bonus { 2 => "rare".to_string(), 3 => "rare".to_string(), 4 => "rare".to_string(), 5 => "legendary".to_string(), _ => "common".to_string() }, naming : base_item_copy.template_magic.as_ref().unwrap().unidentified_name.clone(), cursed: if nmw.bonus == -1 { Some(true) } else { None } }); if let Some(initiative_penalty) = base_item_copy.initiative_penalty.as_mut() { *initiative_penalty -= nmw.bonus as f32; } if let Some(base_value) = base_item_copy.base_value.as_mut() { *base_value += (nmw.bonus as f32 + 1.0) * 50.0; } if let Some(mut weapon) = base_item_copy.weapon.as_mut() { weapon.hit_bonus += nmw.bonus; let (n,die,plus) = parse_dice_string(&weapon.base_damage); let final_bonus = plus+nmw.bonus; if final_bonus > 0 { weapon.base_damage = format!("{}d{}+{}", n, die, final_bonus); } else if final_bonus < 0 { weapon.base_damage = format!("{}d{}-{}", n, die, i32::abs(final_bonus)); } } if let Some(mut armor) = base_item_copy.wearable.as_mut() { armor.armor_class += nmw.bonus as f32; } let real_name = base_item_copy.name.clone(); self.raws.items.push(base_item_copy); self.item_index.insert(real_name.clone(), self.raws.items.len()-1); self.raws.spawn_table.push(super::SpawnTableEntry{ name : real_name.clone(), weight : 10 - i32::abs(nmw.bonus), min_depth : 1 + i32::abs((nmw.bonus-1)*3), max_depth : 100, add_map_depth_to_weight : None }); } }
因此,这循环遍历我们在初始解析期间创建的所有“长剑 +1”、“长剑 -1”、“长剑 +2”等。然后它:
- 获取原始物品的副本。
- 如果奖励是
-1,则将其重命名为“物品 -x”;否则将其重命名为“物品 +x”,其中 x 是奖励。 - 它为物品创建一个新的
magic条目,并按奖励设置 common/rare/legendary 状态,并根据需要设置诅咒标志。 - 如果该物品有启动惩罚,它会从其中减去奖励(使诅咒物品更糟,魔法物品更好)。
- 它将基本价值提高奖励 +1 * 50 金币。
- 如果是武器,则将奖励添加到
to_hit奖励和伤害骰子。它通过重新格式化骰子数字来完成伤害骰子。 - 如果是盔甲,它会将奖励添加到护甲等级。
- 然后,它将新物品插入生成表,更好的物品权重更低,更好的物品稍后出现在地下城中。
如果您查看 在线源代码 - 我已经遍历并删除了所有 +1、+2 和简单的诅咒盔甲和武器 - 并将 template_magic 附加到它们中的每一个。这导致生成了 168 个新物品!这比全部输入它们要好得多。
如果您现在 cargo run,您会在整个地下城中找到逐渐改进的各种类型的魔法物品。更好的物品会在您深入地下城时出现,因此玩家的力量会很好地提升。
特性物品
通过 dagger of venom,我们引入了一种新型的物品:一种在您击中时会产生效果的物品。鉴于这可以是游戏中的任何效果,因此效果有很多可能性!手动添加所有效果将花费一段时间 - 可能更快地提出一个通用系统,并因此在我们的物品中获得真正的多样性(以及不会忘记添加它们!)。
让我们从在 spawns.json 中添加一个新部分开始,专门用于武器特性:
"weapon_traits" : [
{
"name" : "Venomous",
"effects" : { "damage_over_time" : "2" }
}
]
稍后我们将添加更多特性,现在我们将专注于使系统完全工作!为了读取数据,我们将创建一个新文件 raws/weapon_traits.rs(不要将 Rust 特性和武器特性混淆;它们根本不是一回事)。我们将放入足够的结构以允许 Serde 读取 JSON 文件:
#![allow(unused)] fn main() { use serde::{Deserialize}; use std::collections::HashMap; #[derive(Deserialize, Debug)] pub struct WeaponTrait { pub name : String, pub effects : HashMap<String, String> } }
现在我们需要扩展 raws/mod.rs 中的数据以包含它。在文件的顶部,包含:
#![allow(unused)] fn main() { mod weapon_traits; pub use weapon_traits::*; }
然后我们将它添加到 Raws 结构中,就像我们对法术所做的那样:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Raws { pub items : Vec<Item>, pub mobs : Vec<Mob>, pub props : Vec<Prop>, pub spawn_table : Vec<SpawnTableEntry>, pub loot_tables : Vec<LootTable>, pub faction_table : Vec<FactionInfo>, pub spells : Vec<Spell>, pub weapon_traits : Vec<WeaponTrait> } }
反过来,我们必须扩展 raws/rawmaster.rs 中的构造函数以包含一个空的特性列表:
#![allow(unused)] fn main() { impl RawMaster { pub fn empty() -> RawMaster { RawMaster { raws : Raws{ items: Vec::new(), mobs: Vec::new(), props: Vec::new(), spawn_table: Vec::new(), loot_tables: Vec::new(), faction_table : Vec::new(), spells : Vec::new(), weapon_traits : Vec::new() }, item_index : HashMap::new(), mob_index : HashMap::new(), prop_index : HashMap::new(), loot_index : HashMap::new(), faction_index : HashMap::new(), spell_index : HashMap::new() } } ... }
感谢 Serde 的魔力,这就是实际加载数据的所有内容!现在是困难的部分:程序化生成具有一个或多个特性的魔法物品。为了避免重复自己,我们将把之前编写的代码分离成可重用的函数:
#![allow(unused)] fn main() { // 将此放在 raws 实现之上 struct NewMagicItem { name : String, bonus : i32 } ... // 在 raws 实现内部 fn append_magic_template(items_to_build : &mut Vec<NewMagicItem>, item : &super::Item) { if let Some(template) = &item.template_magic { if item.weapon.is_some() || item.wearable.is_some() { if template.include_cursed { items_to_build.push(NewMagicItem{ name : item.name.clone(), bonus : -1 }); } for bonus in template.bonus_min ..= template.bonus_max { items_to_build.push(NewMagicItem{ name : item.name.clone(), bonus }); } } else { rltk::console::log(format!("{} is marked as templated, but isn't a weapon or armor.", item.name)); } } } fn build_base_magic_item(&self, nmw : &NewMagicItem) -> super::Item { let base_item_index = self.item_index[&nmw.name]; let mut base_item_copy = self.raws.items[base_item_index].clone(); base_item_copy.vendor_category = None; // 不要出售魔法物品! if nmw.bonus == -1 { base_item_copy.name = format!("{} -1", nmw.name); } else { base_item_copy.name = format!("{} +{}", nmw.name, nmw.bonus); } base_item_copy.magic = Some(super::MagicItem{ class : match nmw.bonus { 2 => "rare".to_string(), 3 => "rare".to_string(), 4 => "rare".to_string(), 5 => "legendary".to_string(), _ => "common".to_string() }, naming : base_item_copy.template_magic.as_ref().unwrap().unidentified_name.clone(), cursed: if nmw.bonus == -1 { Some(true) } else { None } }); if let Some(initiative_penalty) = base_item_copy.initiative_penalty.as_mut() { *initiative_penalty -= nmw.bonus as f32; } if let Some(base_value) = base_item_copy.base_value.as_mut() { *base_value += (nmw.bonus as f32 + 1.0) * 50.0; } if let Some(mut weapon) = base_item_copy.weapon.as_mut() { weapon.hit_bonus += nmw.bonus; let (n,die,plus) = parse_dice_string(&weapon.base_damage); let final_bonus = plus+nmw.bonus; if final_bonus > 0 { weapon.base_damage = format!("{}d{}+{}", n, die, final_bonus); } else if final_bonus < 0 { weapon.base_damage = format!("{}d{}-{}", n, die, i32::abs(final_bonus)); } } if let Some(mut armor) = base_item_copy.wearable.as_mut() { armor.armor_class += nmw.bonus as f32; } base_item_copy } fn build_magic_weapon_or_armor(&mut self, items_to_build : &[NewMagicItem]) { for nmw in items_to_build.iter() { let base_item_copy = self.build_base_magic_item(&nmw); let real_name = base_item_copy.name.clone(); self.raws.items.push(base_item_copy); self.item_index.insert(real_name.clone(), self.raws.items.len()-1); self.raws.spawn_table.push(super::SpawnTableEntry{ name : real_name.clone(), weight : 10 - i32::abs(nmw.bonus), min_depth : 1 + i32::abs((nmw.bonus-1)*3), max_depth : 100, add_map_depth_to_weight : None }); } } fn build_traited_weapons(&mut self, items_to_build : &[NewMagicItem]) { items_to_build.iter().filter(|i| i.bonus > 0).for_each(|nmw| { for wt in self.raws.weapon_traits.iter() { let mut base_item_copy = self.build_base_magic_item(&nmw); if let Some(mut weapon) = base_item_copy.weapon.as_mut() { base_item_copy.name = format!("{} {}", wt.name, base_item_copy.name); if let Some(base_value) = base_item_copy.base_value.as_mut() { *base_value *= 2.0; } weapon.proc_chance = Some(0.25); weapon.proc_effects = Some(wt.effects.clone()); let real_name = base_item_copy.name.clone(); self.raws.items.push(base_item_copy); self.item_index.insert(real_name.clone(), self.raws.items.len()-1); self.raws.spawn_table.push(super::SpawnTableEntry{ name : real_name.clone(), weight : 9 - i32::abs(nmw.bonus), min_depth : 2 + i32::abs((nmw.bonus-1)*3), max_depth : 100, add_map_depth_to_weight : None }); } } }); } pub fn load(&mut self, raws : Raws) { self.raws = raws; self.item_index = HashMap::new(); let mut used_names : HashSet<String> = HashSet::new(); let mut items_to_build = Vec::new(); for (i,item) in self.raws.items.iter().enumerate() { if used_names.contains(&item.name) { rltk::console::log(format!("WARNING - duplicate item name in raws [{}]", item.name)); } self.item_index.insert(item.name.clone(), i); used_names.insert(item.name.clone()); RawMaster::append_magic_template(&mut items_to_build, item); } for (i,mob) in self.raws.mobs.iter().enumerate() { if used_names.contains(&mob.name) { rltk::console::log(format!("WARNING - duplicate mob name in raws [{}]", mob.name)); } self.mob_index.insert(mob.name.clone(), i); used_names.insert(mob.name.clone()); } for (i,prop) in self.raws.props.iter().enumerate() { if used_names.contains(&prop.name) { rltk::console::log(format!("WARNING - duplicate prop name in raws [{}]", prop.name)); } self.prop_index.insert(prop.name.clone(), i); used_names.insert(prop.name.clone()); } for spawn in self.raws.spawn_table.iter() { if !used_names.contains(&spawn.name) { rltk::console::log(format!("WARNING - Spawn tables references unspecified entity {}", spawn.name)); } } for (i,loot) in self.raws.loot_tables.iter().enumerate() { self.loot_index.insert(loot.name.clone(), i); } for faction in self.raws.faction_table.iter() { let mut reactions : HashMap<String, Reaction> = HashMap::new(); for other in faction.responses.iter() { reactions.insert( other.0.clone(), match other.1.as_str() { "ignore" => Reaction::Ignore, "flee" => Reaction::Flee, _ => Reaction::Attack } ); } self.faction_index.insert(faction.name.clone(), reactions); } for (i,spell) in self.raws.spells.iter().enumerate() { self.spell_index.insert(spell.name.clone(), i); } self.build_magic_weapon_or_armor(&items_to_build); self.build_traited_weapons(&items_to_build); } }
您会注意到其中有一个新函数 build_traited_weapons。它迭代魔法物品,仅过滤武器 - 并且仅过滤那些带有奖励的武器(我现在真的不想深入研究诅咒的剧毒匕首的作用)。它读取所有特性,并使用应用的特性制作每个魔法武器的(更稀有的)版本。
让我们继续在 spawns.json 中添加一个特性:
"weapon_traits" : [
{
"name" : "Venomous",
"effects" : { "damage_over_time" : "2" }
},
{
"name" : "Dazzling",
"effects" : { "confusion" : "2" }
}
]
如果您现在 cargo run 并玩游戏,您有时会发现诸如 Dazzling Longsword +1 或 Venomous Dagger +2 之类的奇迹。
总结
在本章中,我们构建了一个蘑菇林地关卡,以及一个过渡到黑暗精灵据点的第二个关卡。我们已经开始添加黑暗精灵,为了增强功能(并节省打字时间),我们正在自动生成从 -1 到 +5 的魔法物品。然后我们生成了相同武器的“特性化”版本。现在,运行之间存在着巨大的差异,这应该会让注重装备的玩家感到高兴。关卡也有很好的进展,我们已准备好迎接黑暗精灵城市 - 以及远程武器!
...
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
导弹和远程攻击
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢本教程并希望我继续写作,请考虑支持我的 Patreon。
当您阅读关于黑暗精灵的小说时,他们通常会偷偷地从黑暗中发射导弹武器。这实际上是他们被包含在本教程书中的原因:他们为拓展到精彩的远程战斗世界提供了一个很好的理由。我们已经有了一些远程战斗:法术效果可以发生在远处,但目标选择系统有点笨拙 - 而且对于弓箭决斗来说一点也不符合人体工程学。因此,在本章中,我们将介绍远程武器,并使黑暗精灵更可怕一些。我们还将尝试改进导弹的粒子效果,以便玩家可以看到发生了什么。
引入远程武器
我们将稍微作弊一下,不担心弹药;有些游戏会计算每一支箭,对于远程战斗角色来说,保持箭袋装满箭矢可能非常重要。我们将专注于远程武器方面,并假设弹药充足;这不是最现实的选择,但可以使事情易于管理!
定义短弓
让我们首先打开 spawns.json 文件,并为短弓创建一个条目:
{
"name" : "Shortbow",
"renderable": {
"glyph" : ")",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "4",
"attribute" : "Quickness",
"base_damage" : "1d4",
"hit_bonus" : 0
},
"weight_lbs" : 2.0,
"base_value" : 5.0,
"initiative_penalty" : 1,
"vendor_category" : "weapon"
},
您会注意到这与匕首条目非常相似;实际上,我复制/粘贴了它,然后将 "range" 从 "melee" 更改为 "4"!我还暂时删除了模板化的魔法部分,以保持事情简单明了。现在我们打开 components.rs,并查看 MeleeWeapon - 目的是制作远程武器。不幸的是,我们看到了一个设计错误!伤害都在武器内部,因此如果我们创建一个通用的 RangedWeapon 组件,我们将重复自己。通常,最好不要将同一件事键入两次,因此我们将 MeleeWeapon 的名称更改为 Weapon - 并添加一个 range 字段。如果它没有射程(它是一个 Option),那么它就只是近战武器:
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct Weapon { pub range : Option<i32>, pub attribute : WeaponAttribute, pub damage_n_dice : i32, pub damage_die_type : i32, pub damage_bonus : i32, pub hit_bonus : i32, pub proc_chance : Option<f32>, pub proc_target : Option<String>, } }
您需要打开 main.rs,saveload_system.rs 并将 MeleeWeapon 更改为 Weapon。还有一些其他的代码也崩溃了。在 melee_combat_system.rs 中,只需将所有 MeleeWeapon 实例替换为 Weapon。您还需要将 range 添加到为处理自然攻击而创建的虚拟武器中:
#![allow(unused)] fn main() { let mut weapon_info = Weapon{ range: None, attribute : WeaponAttribute::Might, hit_bonus : 0, damage_n_dice : 1, damage_die_type : 4, damage_bonus : 0, proc_chance : None, proc_target : None }; }
为了使其像以前一样编译和运行,您可以更改 raws/rawmaster.rs 的一个部分:
#![allow(unused)] fn main() { let mut wpn = Weapon{ range : None, attribute : WeaponAttribute::Might, damage_n_dice : n_dice, damage_die_type : die_type, damage_bonus : bonus, hit_bonus : weapon.hit_bonus, proc_chance : weapon.proc_chance, proc_target : weapon.proc_target.clone() }; }
这足以使旧代码再次运行,并且具有显着的优点:我们基本上保持了武器代码不变,因此所有的 “特性” 和 “魔法模板” 系统仍然有效。但是,有一个明显的限制:短弓仍然是近战武器!
我们可以打开 raws/rawmaster.rs 并更改相同的代码片段,以便在存在射程时实例化 range。这是一个好的开始 - 至少游戏可以选择知道它是一种远程武器!
#![allow(unused)] fn main() { let mut wpn = Weapon{ range : if weapon.range == "melee" { None } else { Some(weapon.range.parse::<i32>().expect("Not a number")) }, attribute : WeaponAttribute::Might, damage_n_dice : n_dice, damage_die_type : die_type, damage_bonus : bonus, hit_bonus : weapon.hit_bonus, proc_chance : weapon.proc_chance, proc_target : weapon.proc_target.clone() }; }
让玩家射击物体
所以现在我们知道武器 是 远程武器,这是一个很好的开始。让我们进入 spawner.rs 并让玩家从一把短弓开始。我们可能不会保留它,但这为我们构建提供了一个良好的基础:
#![allow(unused)] fn main() { spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Rusty Longsword", SpawnType::Equipped{by : player}); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Dried Sausage", SpawnType::Carried{by : player} ); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Beer", SpawnType::Carried{by : player}); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Stained Tunic", SpawnType::Equipped{by : player}); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Torn Trousers", SpawnType::Equipped{by : player}); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Old Boots", SpawnType::Equipped{by : player}); spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Shortbow", SpawnType::Carried{by : player}); }
我们已经让它从背包开始,所以玩家仍然必须有意识地决定切换到使用远程武器(我们已经做了足够的近战工作,以至于射击物体不应该是默认选项!) - 但这使我们不必在测试我们正在构建的系统时四处寻找武器。继续并 cargo run 以快速测试您是否可以装备新的弓。您还不能射击任何东西,但至少可以装备它(并确信我们没有因组件更改而破坏太多东西)。
远程武器最难的部分是它有一个 目标:您正在射击的东西。我们希望目标选择易于操作,以免玩家弄不清楚如何射击物体!让我们首先向玩家展示他们装备的武器的信息 - 如果它有射程,我们将包含它。在 gui.rs 中,找到我们迭代已装备物品并显示它们的部分(在我的版本中大约在第 162 行)。我们将稍微扩展它:
#![allow(unused)] fn main() { // Equipped // 已装备 let mut y = 13; let entities = ecs.entities(); let equipped = ecs.read_storage::<Equipped>(); let weapon = ecs.read_storage::<Weapon>(); for (entity, equipped_by) in (&entities, &equipped).join() { if equipped_by.owner == *player_entity { let name = get_item_display_name(ecs, entity); ctx.print_color(50, y, get_item_color(ecs, entity), black, &name); y += 1; if let Some(weapon) = weapon.get(entity) { let mut weapon_info = if weapon.damage_bonus < 0 { format!("┤ {} ({}d{}{})", &name, weapon.damage_n_dice, weapon.damage_die_type, weapon.damage_bonus) } else if weapon.damage_bonus == 0 { format!("┤ {} ({}d{})", &name, weapon.damage_n_dice, weapon.damage_die_type) } else { format!("┤ {} ({}d{}+{})", &name, weapon.damage_n_dice, weapon.damage_die_type, weapon.damage_bonus) }; if let Some(range) = weapon.range { weapon_info += &format!(" (range: {}, F to fire)", range); } weapon_info += " ├"; ctx.print_color(3, 45, yellow, black, &weapon_info); } } } }
这是一个好的开始,因为现在我们正在告诉用户他们拥有一件远程武器(并且通常显示武器升级的即时结果是好的!):

那么,现在让玩家可以轻松地瞄准敌人!我们将从制作一个 Target 组件开始。在 components.rs 中(和往常一样,在 main.rs 和 saveload_system.rs 中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Target {} }
这个想法很简单:我们将把 Target 组件附加到我们当前正在瞄准的任何人身上。我们应该在地图上突出显示目标;所以我们转到 camera.rs 并将以下内容添加到实体渲染代码中:
#![allow(unused)] fn main() { // Render entities // 渲染实体 let positions = ecs.read_storage::<Position>(); let renderables = ecs.read_storage::<Renderable>(); let hidden = ecs.read_storage::<Hidden>(); let map = ecs.fetch::<Map>(); let sizes = ecs.read_storage::<TileSize>(); let entities = ecs.entities(); let targets = ecs.read_storage::<Target>(); let mut data = (&positions, &renderables, &entities, !&hidden).join().collect::<Vec<_>>(); data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) ); for (pos, render, entity, _hidden) in data.iter() { if let Some(size) = sizes.get(*entity) { for cy in 0 .. size.y { for cx in 0 .. size.x { let tile_x = cx + pos.x; let tile_y = cy + pos.y; let idx = map.xy_idx(tile_x, tile_y); if map.visible_tiles[idx] { let entity_screen_x = (cx + pos.x) - min_x; let entity_screen_y = (cy + pos.y) - min_y; if entity_screen_x > 0 && entity_screen_x < map_width && entity_screen_y > 0 && entity_screen_y < map_height { ctx.set(entity_screen_x + 1, entity_screen_y + 1, render.fg, render.bg, render.glyph); } } } } } else { let idx = map.xy_idx(pos.x, pos.y); if map.visible_tiles[idx] { let entity_screen_x = pos.x - min_x; let entity_screen_y = pos.y - min_y; if entity_screen_x > 0 && entity_screen_x < map_width && entity_screen_y > 0 && entity_screen_y < map_height { ctx.set(entity_screen_x + 1, entity_screen_y + 1, render.fg, render.bg, render.glyph); } } } if targets.get(*entity).is_some() { let entity_screen_x = pos.x - min_x; let entity_screen_y = pos.y - min_y; ctx.set(entity_screen_x , entity_screen_y + 1, rltk::RGB::named(rltk::RED), rltk::RGB::named(rltk::YELLOW), rltk::to_cp437('[')); ctx.set(entity_screen_x +2, entity_screen_y + 1, rltk::RGB::named(rltk::RED), rltk::RGB::named(rltk::YELLOW), rltk::to_cp437(']')); } } }
这段代码正在检查我们渲染的每个实体,以查看它是否被瞄准,如果是,则在其周围渲染颜色鲜艳的方括号。我们还应该提供一些关于如何使用目标选择系统的提示,所以在 gui.rs 中,我们按如下方式修改我们的远程武器代码:
#![allow(unused)] fn main() { if let Some(weapon) = weapon.get(entity) { let mut weapon_info = if weapon.damage_bonus < 0 { format!("┤ {} ({}d{}{})", &name, weapon.damage_n_dice, weapon.damage_die_type, weapon.damage_bonus) } else if weapon.damage_bonus == 0 { format!("┤ {} ({}d{})", &name, weapon.damage_n_dice, weapon.damage_die_type) } else { format!("┤ {} ({}d{}+{})", &name, weapon.damage_n_dice, weapon.damage_die_type, weapon.damage_bonus) }; if let Some(range) = weapon.range { weapon_info += &format!(" (range: {}, F to fire, V cycle targets)", range); } weapon_info += " ├"; ctx.print_color(3, 45, yellow, black, &weapon_info); } }
我们正在告诉用户按下 V 键来更改目标,所以我们需要实现该功能!在我们这样做之前,我们需要提出一个默认的目标选择方案。由于我们正在担心 玩家的 目标,我们将前往 player.rs 并添加一些新函数。第一个函数确定哪些实体有资格作为目标:
#![allow(unused)] fn main() { fn get_player_target_list(ecs : &mut World) -> Vec<(f32,Entity)> { let mut possible_targets : Vec<(f32,Entity)> = Vec::new(); let viewsheds = ecs.read_storage::<Viewshed>(); let player_entity = ecs.fetch::<Entity>(); let equipped = ecs.read_storage::<Equipped>(); let weapon = ecs.read_storage::<Weapon>(); let map = ecs.fetch::<Map>(); let positions = ecs.read_storage::<Position>(); let factions = ecs.read_storage::<Faction>(); for (equipped, weapon) in (&equipped, &weapon).join() { if equipped.owner == *player_entity && weapon.range.is_some() { let range = weapon.range.unwrap(); if let Some(vs) = viewsheds.get(*player_entity) { let player_pos = positions.get(*player_entity).unwrap(); for tile_point in vs.visible_tiles.iter() { let tile_idx = map.xy_idx(tile_point.x, tile_point.y); let distance_to_target = rltk::DistanceAlg::Pythagoras.distance2d(*tile_point, rltk::Point::new(player_pos.x, player_pos.y)); if distance_to_target < range as f32 { crate::spatial::for_each_tile_content(tile_idx, |possible_target| { if possible_target != *player_entity && factions.get(possible_target).is_some() { possible_targets.push((distance_to_target, possible_target)); } }); } } } } } possible_targets.sort_by(|a,b| a.0.partial_cmp(&b.0).unwrap()); possible_targets } }
这是一个稍微复杂的函数,所以让我们逐步了解它:
- 我们创建一个空的结果列表,其中包含可作为目标的实体及其与玩家的距离。
- 我们迭代已装备的武器,以查看玩家是否拥有远程武器。
- 如果他们有,我们记下它的射程。
- 然后我们查看他们的视野,并检查每个瓦片是否在武器的射程内。
- 如果它在射程内,我们通过
tile_content系统查看该瓦片中的实体。如果该实体实际上是一个有效的目标(他们拥有Faction成员资格),我们会将它们添加到可能的目标列表中。 - 我们按射程对可能的目标列表进行排序。
现在我们需要在玩家移动时选择一个新目标。我们将选择最近的目标,因为您更有可能瞄准直接威胁。以下函数完成了这项工作:
#![allow(unused)] fn main() { pub fn end_turn_targeting(ecs: &mut World) { let possible_targets = get_player_target_list(ecs); let mut targets = ecs.write_storage::<Target>(); targets.clear(); if !possible_targets.is_empty() { targets.insert(possible_targets[0].1, Target{}).expect("Insert fail"); } } }
我们希望在新回合 开始 时调用此函数。所以我们前往 main.rs,并修改游戏循环以捕获新回合的开始并调用此函数:
#![allow(unused)] fn main() { RunState::Ticking => { let mut should_change_target = false; while newrunstate == RunState::Ticking { self.run_systems(); self.ecs.maintain(); match *self.ecs.fetch::<RunState>() { RunState::AwaitingInput => { newrunstate = RunState::AwaitingInput; should_change_target = true; } RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 }, RunState::TownPortal => newrunstate = RunState::TownPortal, RunState::TeleportingToOtherLevel{ x, y, depth } => newrunstate = RunState::TeleportingToOtherLevel{ x, y, depth }, RunState::ShowRemoveCurse => newrunstate = RunState::ShowRemoveCurse, RunState::ShowIdentify => newrunstate = RunState::ShowIdentify, _ => newrunstate = RunState::Ticking } } if should_change_target { player::end_turn_targeting(&mut self.ecs); } } }
现在我们将返回 player.rs 并添加另一个函数来循环目标:
#![allow(unused)] fn main() { fn cycle_target(ecs: &mut World) { let possible_targets = get_player_target_list(ecs); let mut targets = ecs.write_storage::<Target>(); let entities = ecs.entities(); let mut current_target : Option<Entity> = None; for (e,_t) in (&entities, &targets).join() { current_target = Some(e); } targets.clear(); if let Some(current_target) = current_target { if !possible_targets.len() > 1 { let mut index = 0; for (i, target) in possible_targets.iter().enumerate() { if target.1 == current_target { index = i; } } if index > possible_targets.len()-2 { targets.insert(possible_targets[0].1, Target{}); } else { targets.insert(possible_targets[index+1].1, Target{}); } } } } }
这是一个很长的函数,但我为了清晰起见而保持它的长度。它在当前目标列表中找到当前目标的索引。如果存在多个目标,它会选择列表中的下一个目标。如果它在列表的末尾,它会移动到开头。现在我们需要捕获 V 键的按下并调用此函数。在 player_input 函数中,我们将添加一个新部分:
#![allow(unused)] fn main() { // Ranged // 远程 VirtualKeyCode::V => { cycle_target(&mut gs.ecs); return RunState::AwaitingInput; } }
如果您现在 cargo run,您可以装备您的弓并开始瞄准:

射击物体
我们有一个完善的战斗模式:使用 WantsToMelee 组件标记动作,然后它会在 MeleeCombatSystem 中被拾取。我们对想要接近、使用技能或物品的情况使用了类似的模式 - 因此对于想要射击的情况,我们再次这样做是有道理的。在 components.rs 中(并在 main.rs 和 saveload_system.rs 中注册),我们将添加以下内容:
#![allow(unused)] fn main() { #[derive(Component, Debug, ConvertSaveload, Clone)] pub struct WantsToShoot { pub target : Entity } }
我们还需要创建一个新的系统,并将其存储在 ranged_combat_system.rs 中。它基本上是 melee_combat_system 的剪切和粘贴,但查找的是 WantsToShoot 而不是 WantsToMelee:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{Attributes, Skills, WantsToShoot, Name, gamelog::GameLog, HungerClock, HungerState, Pools, skill_bonus, Skill, Equipped, Weapon, EquipmentSlot, WeaponAttribute, Wearable, NaturalAttackDefense, effects::*, Map, Position}; use rltk::{to_cp437, RGB, Point}; pub struct RangedCombatSystem {} impl<'a> System<'a> for RangedCombatSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToShoot>, ReadStorage<'a, Name>, ReadStorage<'a, Attributes>, ReadStorage<'a, Skills>, ReadStorage<'a, HungerClock>, ReadStorage<'a, Pools>, WriteExpect<'a, rltk::RandomNumberGenerator>, ReadStorage<'a, Equipped>, ReadStorage<'a, Weapon>, ReadStorage<'a, Wearable>, ReadStorage<'a, NaturalAttackDefense>, ReadStorage<'a, Position>, ReadExpect<'a, Map> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut log, mut wants_shoot, names, attributes, skills, hunger_clock, pools, mut rng, equipped_items, weapon, wearables, natural, positions, map) = data; for (entity, wants_shoot, name, attacker_attributes, attacker_skills, attacker_pools) in (&entities, &wants_shoot, &names, &attributes, &skills, &pools).join() { // Are the attacker and defender alive? Only attack if they are // 攻击者和防御者还活着吗?只有当他们活着时才攻击 let target_pools = pools.get(wants_shoot.target).unwrap(); let target_attributes = attributes.get(wants_shoot.target).unwrap(); let target_skills = skills.get(wants_shoot.target).unwrap(); if attacker_pools.hit_points.current > 0 && target_pools.hit_points.current > 0 { let target_name = names.get(wants_shoot.target).unwrap(); // Fire projectile effect // 发射抛射物效果 let apos = positions.get(entity).unwrap(); let dpos = positions.get(wants_shoot.target).unwrap(); add_effect( None, EffectType::ParticleProjectile{ glyph: to_cp437('*'), fg : RGB::named(rltk::CYAN), bg : RGB::named(rltk::BLACK), lifespan : 300.0, speed: 50.0, path: rltk::line2d( rltk::LineAlg::Bresenham, Point::new(apos.x, apos.y), Point::new(dpos.x, dpos.y) ) }, Targets::Tile{tile_idx : map.xy_idx(apos.x, apos.y) as i32} ); // Define the basic unarmed attack - overridden by wielding check below if a weapon is equipped // 定义基本的徒手攻击 - 如果装备了武器,则会被下面的挥舞检查覆盖 let mut weapon_info = Weapon{ range: None, attribute : WeaponAttribute::Might, hit_bonus : 0, damage_n_dice : 1, damage_die_type : 4, damage_bonus : 0, proc_chance : None, proc_target : None }; if let Some(nat) = natural.get(entity) { if !nat.attacks.is_empty() { let attack_index = if nat.attacks.len()==1 { 0 } else { rng.roll_dice(1, nat.attacks.len() as i32) as usize -1 }; weapon_info.hit_bonus = nat.attacks[attack_index].hit_bonus; weapon_info.damage_n_dice = nat.attacks[attack_index].damage_n_dice; weapon_info.damage_die_type = nat.attacks[attack_index].damage_die_type; weapon_info.damage_bonus = nat.attacks[attack_index].damage_bonus; } } let mut weapon_entity : Option<Entity> = None; for (weaponentity,wielded,melee) in (&entities, &equipped_items, &weapon).join() { if wielded.owner == entity && wielded.slot == EquipmentSlot::Melee { weapon_info = melee.clone(); weapon_entity = Some(weaponentity); } } let natural_roll = rng.roll_dice(1, 20); let attribute_hit_bonus = if weapon_info.attribute == WeaponAttribute::Might { attacker_attributes.might.bonus } else { attacker_attributes.quickness.bonus}; let skill_hit_bonus = skill_bonus(Skill::Melee, &*attacker_skills); let weapon_hit_bonus = weapon_info.hit_bonus; let mut status_hit_bonus = 0; if let Some(hc) = hunger_clock.get(entity) { // Well-Fed grants +1 // Well-Fed 状态提供 +1 加成 if hc.state == HungerState::WellFed { status_hit_bonus += 1; } } let modified_hit_roll = natural_roll + attribute_hit_bonus + skill_hit_bonus + weapon_hit_bonus + status_hit_bonus; //println!("Natural roll: {}", natural_roll); //println!("Modified hit roll: {}", modified_hit_roll); let mut armor_item_bonus_f = 0.0; for (wielded,armor) in (&equipped_items, &wearables).join() { if wielded.owner == wants_shoot.target { armor_item_bonus_f += armor.armor_class; } } let base_armor_class = match natural.get(wants_shoot.target) { None => 10, Some(nat) => nat.armor_class.unwrap_or(10) }; let armor_quickness_bonus = target_attributes.quickness.bonus; let armor_skill_bonus = skill_bonus(Skill::Defense, &*target_skills); let armor_item_bonus = armor_item_bonus_f as i32; let armor_class = base_armor_class + armor_quickness_bonus + armor_skill_bonus + armor_item_bonus; //println!("Armor class: {}", armor_class); if natural_roll != 1 && (natural_roll == 20 || modified_hit_roll > armor_class) { // Target hit! Until we support weapons, we're going with 1d4 // 目标命中!在我们支持武器之前,我们先使用 1d4 let base_damage = rng.roll_dice(weapon_info.damage_n_dice, weapon_info.damage_die_type); let attr_damage_bonus = attacker_attributes.might.bonus; let skill_damage_bonus = skill_bonus(Skill::Melee, &*attacker_skills); let weapon_damage_bonus = weapon_info.damage_bonus; let damage = i32::max(0, base_damage + attr_damage_bonus + skill_damage_bonus + weapon_damage_bonus); /*println!("Damage: {} + {}attr + {}skill + {}weapon = {}", base_damage, attr_damage_bonus, skill_damage_bonus, weapon_damage_bonus, damage );*/ add_effect( Some(entity), EffectType::Damage{ amount: damage }, Targets::Single{ target: wants_shoot.target } ); log.entries.push(format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage)); // Proc effects // 触发效果 if let Some(chance) = &weapon_info.proc_chance { let roll = rng.roll_dice(1, 100); //println!("Roll {}, Chance {}", roll, chance); if roll <= (chance * 100.0) as i32 { //println!("Proc!"); let effect_target = if weapon_info.proc_target.unwrap() == "Self" { Targets::Single{ target: entity } } else { Targets::Single { target : wants_shoot.target } }; add_effect( Some(entity), EffectType::ItemUse{ item: weapon_entity.unwrap() }, effect_target ) } } } else if natural_roll == 1 { // Natural 1 miss // 自然 1 失误 log.entries.push(format!("{} considers attacking {}, but misjudges the timing.", name.name, target_name.name)); add_effect( None, EffectType::Particle{ glyph: rltk::to_cp437('‼'), fg: rltk::RGB::named(rltk::BLUE), bg : rltk::RGB::named(rltk::BLACK), lifespan: 200.0 }, Targets::Single{ target: wants_shoot.target } ); } else { // Miss // 失误 log.entries.push(format!("{} attacks {}, but can't connect.", name.name, target_name.name)); add_effect( None, EffectType::Particle{ glyph: rltk::to_cp437('‼'), fg: rltk::RGB::named(rltk::CYAN), bg : rltk::RGB::named(rltk::BLACK), lifespan: 200.0 }, Targets::Single{ target: wants_shoot.target } ); } } } wants_shoot.clear(); } } }
大部分内容都直接来自之前的系统。您还需要将其添加到 main.rs 中的 run_systems 中;紧随近战之后的位置是一个不错的选择:
#![allow(unused)] fn main() { let mut ranged = RangedCombatSystem{}; ranged.run_now(&self.ecs); }
眼尖的读者会注意到,我们还偷偷添加了一个额外的 add_effect 调用,这次调用的是 EffectType::ParticleProjectile。这不是必需的,但显示飞行的抛射物确实突出了远程战斗的风味。到目前为止,我们的粒子是静止的,所以让我们为它们添加一些 “活力”!
在 components.rs 中,我们将更新 ParticleLifetime 组件以包含可选的动画:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone)] pub struct ParticleAnimation { pub step_time : f32, pub path : Vec<Point>, pub current_step : usize, pub timer : f32 } #[derive(Component, Serialize, Deserialize, Clone)] pub struct ParticleLifetime { pub lifetime_ms : f32, pub animation : Option<ParticleAnimation> } }
这增加了一个 step_time - 粒子应该在每个步骤停留多长时间。一个 path - 一个 Point 向量,列出了沿途的每个步骤。current_step 和 timer 将用于跟踪抛射物的进度。
您需要进入 particle_system.rs 并修改粒子生成,使其默认包含 None:
#![allow(unused)] fn main() { particles.insert(p, ParticleLifetime{ lifetime_ms: new_particle.lifetime, animation: None }).expect("Unable to insert lifetime"); }
当我们在这里时,我们将重命名剔除函数 (cull_dead_particles) 为 update_particles - 更好地反映它所做的事情。我们还将添加一些逻辑来查看是否存在动画,并使其更新其在动画轨道上的位置:
#![allow(unused)] fn main() { pub fn update_particles(ecs : &mut World, ctx : &Rltk) { let mut dead_particles : Vec<Entity> = Vec::new(); { // Age out particles // 使粒子老化 let mut particles = ecs.write_storage::<ParticleLifetime>(); let entities = ecs.entities(); let map = ecs.fetch::<Map>(); for (entity, mut particle) in (&entities, &mut particles).join() { if let Some(animation) = &mut particle.animation { animation.timer += ctx.frame_time_ms; if animation.timer > animation.step_time && animation.current_step < animation.path.len()-2 { animation.current_step += 1; if let Some(pos) = ecs.write_storage::<Position>().get_mut(entity) { pos.x = animation.path[animation.current_step].x; pos.y = animation.path[animation.current_step].y; } } } particle.lifetime_ms -= ctx.frame_time_ms; if particle.lifetime_ms < 0.0 { dead_particles.push(entity); } } } for dead in dead_particles.iter() { ecs.delete_entity(*dead).expect("Particle will not die"); } } }
再次打开 main.rs,搜索 cull_dead_particles 并将其替换为 update_particles。
这足以实际动画化粒子,并在完成后仍然使它们消失,但我们需要更新 Effects 系统以生成新型粒子。在 effects/mod.rs 中,我们将扩展 EffectType 枚举以包含新的粒子类型:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum EffectType { ... ParticleProjectile { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32, speed: f32, path: Vec<Point> }, ... }
我们还必须更新同一文件中的 affect_tile:
#![allow(unused)] fn main() { fn affect_tile(ecs: &mut World, effect: &mut EffectSpawner, tile_idx : i32) { if tile_effect_hits_entities(&effect.effect_type) { let content = ecs.fetch::<Map>().tile_content[tile_idx as usize].clone(); content.iter().for_each(|entity| affect_entity(ecs, effect, *entity)); } match &effect.effect_type { EffectType::Bloodstain => damage::bloodstain(ecs, tile_idx), EffectType::Particle{..} => particles::particle_to_tile(ecs, tile_idx, &effect), EffectType::ParticleProjectile{..} => particles::projectile(ecs, tile_idx, &effect), _ => {} } } }
这将调用 particles::projectile,所以打开 effects/particles.rs,我们将添加该函数:
#![allow(unused)] fn main() { pub fn projectile(ecs: &mut World, tile_idx : i32, effect: &EffectSpawner) { if let EffectType::ParticleProjectile{ glyph, fg, bg, lifespan, speed, path } = &effect.effect_type { let map = ecs.fetch::<Map>(); let x = tile_idx % map.width; let y = tile_idx / map.width; std::mem::drop(map); ecs.create_entity() .with(Position{ x, y }) .with(Renderable{ fg: *fg, bg: *bg, glyph: *glyph, render_order: 0 }) .with(ParticleLifetime{ lifetime_ms: path.len() as f32 * speed, animation: Some(ParticleAnimation{ step_time: *speed, path: path.to_vec(), current_step: 0, timer: 0.0 }) }) .build(); } } }
如果您现在 cargo run 该项目,您可以瞄准和射击物体 - 并享受一点动画:

让怪物反击
只有玩家拥有弓有点不公平。这也大大降低了游戏的挑战性:您可以射击接近您的物体,但它们无法反击。让我们添加一个新的怪物,强盗弓箭手。它主要是一个 强盗 的副本,但他们拥有一把短弓而不是匕首。在 spawns.json 中:
{ "name" : "Bandit Archer", "weight" : 9, "min_depth" : 2, "max_depth" : 3 },
...
{
"name" : "Bandit Archer",
"renderable": {
"glyph" : "☻",
"fg" : "#FF5500",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 6,
"movement" : "random_waypoint",
"quips" : [ "Stand and deliver!", "Alright, hand it over" ],
"attributes" : {},
"equipped" : [ "Shortbow", "Shield", "Leather Armor", "Leather Boots" ],
"light" : {
"range" : 6,
"color" : "#FFFF55"
},
"faction" : "Bandits",
"gold" : "1d6"
},
我们稍微改变了它们的颜色,并在它们的装备列表中添加了一个 Shortbow。我们已经支持装备生成,所以这应该足以使弓出现在它们的装备中 - 但他们不知道如何使用它。我们已经在 ai/visible_ai_systems.rs 中处理了施法(以及诸如龙息之类的东西)- 所以这是一个考虑添加射击的逻辑位置。我们可以非常简单地添加它:检查是否装备了远程武器,如果有 - 检查射程并生成一个 WantsToShoot。我们将修改反应 Attack:
#![allow(unused)] fn main() { Reaction::Attack => { let range = rltk::DistanceAlg::Pythagoras.distance2d( rltk::Point::new(pos.x, pos.y), rltk::Point::new(reaction.0 as i32 % map.width, reaction.0 as i32 / map.width) ); if let Some(abilities) = abilities.get(entity) { for ability in abilities.abilities.iter() { if range >= ability.min_range && range <= ability.range && rng.roll_dice(1,100) <= (ability.chance * 100.0) as i32 { use crate::raws::find_spell_entity_by_name; casting.insert( entity, WantsToCastSpell{ spell : find_spell_entity_by_name(&ability.spell, &names, &spells, &entities).unwrap(), target : Some(rltk::Point::new(reaction.0 as i32 % map.width, reaction.0 as i32 / map.width))} ).expect("Unable to insert"); done = true; } } } if !done { for (weapon, equip) in (&weapons, &equipped).join() { if let Some(wrange) = weapon.range { if equip.owner == entity { rltk::console::log(format!("Owner found. Ranges: {}/{}", wrange, range)); if wrange >= range as i32 { rltk::console::log("Inserting shoot"); wants_shoot.insert(entity, WantsToShoot{ target: reaction.2 }).expect("Insert fail"); done = true; } } } } } ... }
如果您现在 cargo run,强盗会反击!
模板化魔法弓
将短弓添加到您的生成列表中:
{ "name" : "Shortbow", "weight" : 2, "min_depth" : 3, "max_depth" : 100 },
您还可以为其添加魔法模板:
{
"name" : "Shortbow",
"renderable": {
"glyph" : ")",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "4",
"attribute" : "Quickness",
"base_damage" : "1d4",
"hit_bonus" : 0
},
"weight_lbs" : 2.0,
"base_value" : 5.0,
"initiative_penalty" : 1,
"vendor_category" : "weapon",
"template_magic" : {
"unidentified_name" : "Unidentified Shortbow",
"bonus_min" : 1,
"bonus_max" : 5,
"include_cursed" : true
}
},
让黑暗精灵更可怕
所以现在我们可以引入一些地精弓箭手,让洞穴更可怕一些。我们不会在龙/蜥蜴关卡中引入任何远程武器,以稍微平衡一下几率(游戏刚刚变得更容易了!)。我们可以像对强盗一样剪切和粘贴地精:
{
"name" : "Goblin Archer",
"renderable": {
"glyph" : "g",
"fg" : "#FFFF00",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "static",
"attributes" : {},
"faction" : "Cave Goblins",
"gold" : "1d6",
"equipped" : [ "Shortbow", "Leather Armor", "Leather Boots" ],
},
这使我们达到了本章开始时的目标。我们想给黑暗精灵手弩。我们将首先在 spawns.json 中生成新的武器类型:
{
"name" : "Hand Crossbow",
"renderable": {
"glyph" : ")",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "6",
"attribute" : "Quickness",
"base_damage" : "1d6",
"hit_bonus" : 0
},
"weight_lbs" : 2.0,
"base_value" : 5.0,
"initiative_penalty" : 1,
"vendor_category" : "weapon",
"template_magic" : {
"unidentified_name" : "Unidentified Hand Crossbow",
"bonus_min" : 1,
"bonus_max" : 5,
"include_cursed" : true
}
},
我们还应该将其添加到生成表中,但仅适用于黑暗精灵关卡:
{ "name" : "Hand Crossbow", "weight" : 2, "min_depth" : 9, "max_depth" : 11 }
最后,我们将其提供给黑暗精灵:
{
"name" : "Dark Elf",
"renderable": {
"glyph" : "e",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "random_waypoint",
"attributes" : {},
"equipped" : [ "Hand Crossbow", "Scimitar", "Buckler", "Drow Chain", "Drow Leggings", "Drow Boots" ],
"faction" : "DarkElf",
"gold" : "3d6",
"level" : 6
},
就这样!当您到达守卫他们城市入口的黑暗精灵时 - 他们现在可以射击您了。我们将在下一章中充实这座城市。
...
本章的源代码可以在 这里 找到
使用 WebAssembly 在您的浏览器中运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
改进的日志记录和计数成就
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。我希望您会喜欢本教程,并制作出很棒的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
大多数 Roguelike 游戏都很重视游戏日志。它会在最后被汇总到 morgue 文件 中(详细描述你的游戏过程),它被用来展示世界中正在发生的事情,并且对于硬核玩家来说非常宝贵。我们一直在使用一个相当简单的日志记录设置(感谢 Mark McCaskey 的辛勤工作,它不再慢得令人发指)。在本章中,我们将构建一个良好的日志记录系统 - 并将其用作成就和进度跟踪系统的基础。我们还将使日志记录 GUI 更好一些。
目前,我们通过直接调用数据结构来添加到游戏日志。它看起来像这样:
#![allow(unused)] fn main() { log.entries.push(format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage)); }
这不是一个好方法:它要求你能够直接访问日志,不提供任何格式化,并且要求系统了解日志的内部工作方式。我们也没有将日志序列化为游戏保存的一部分(以及在加载时反序列化)。最后,有很多我们本可以记录但没有记录的事情;那是因为将日志作为资源包含进来非常麻烦。像效果系统一样,它应该是无缝的、容易的并且是线程安全的(如果你不使用 WASM!)。
本章将纠正这些缺陷。
构建 API
我们将从创建一个新目录 src/gamelog 开始。我们将把 src/gamelog.rs 的内容移入其中,并将文件重命名为 mod.rs - 换句话说,我们创建了一个新模块。这应该继续有效 - 模块的名称没有改变。
将以下内容追加到 mod.rs:
#![allow(unused)] fn main() { pub struct LogFragment { pub color : RGB, pub text : String } }
新的 LogFragment 类型将存储日志条目的 片段。每个片段可以有一些文本和颜色,从而允许使用丰富多彩的日志条目。它们组合在一起可以构成一个日志行。
接下来,我们将创建另一个新文件 - 这次命名为 src/gamelog/logstore.rs。将以下内容粘贴到其中:
#![allow(unused)] fn main() { use std::sync::Mutex; use super::LogFragment; use rltk::prelude::*; lazy_static! { static ref LOG : Mutex<Vec<Vec<LogFragment>>> = Mutex::new(Vec::new()); } pub fn append_fragment(fragment : LogFragment) { LOG.lock().unwrap().push(vec![fragment]); } pub fn append_entry(fragments : Vec<LogFragment>) { LOG.lock().unwrap().push(fragments); } pub fn clear_log() { LOG.lock().unwrap().clear(); } pub fn log_display() -> TextBuilder { let mut buf = TextBuilder::empty(); LOG.lock().unwrap().iter().rev().take(12).for_each(|log| { log.iter().for_each(|frag| { buf.fg(frag.color); buf.line_wrap(&frag.text); }); buf.ln(); }); buf } }
这里有很多内容需要消化:
- 核心在于,我们使用
lazy_static来定义一个 全局 日志条目存储。它是一个向量的向量,这次构成了片段。因此,外部向量是日志中的 行,内部向量构成了组成日志的 片段。它受到Mutex的保护,使其在线程环境中可以安全使用。 append_fragment锁定日志,并将单个片段作为新行追加。append_entry锁定日志,并追加一个片段向量(新行)。clear_log的作用正如其名称所示:它清空日志。log_display构建一个 RLTKTextBuilder对象,这是一种构建大量文本以进行渲染的安全方法,同时考虑了诸如换行之类的事情。它接受 12 个条目,因为这是我们可以显示的最大日志量。
在 mod.rs 中,添加以下三行来处理模块的使用和导出部分内容:
#![allow(unused)] fn main() { mod logstore; use logstore::*; pub use logstore::{clear_log, log_display}; }
这使我们可以大大简化日志的显示。打开 gui.rs,找到日志绘制代码(在示例中是第 248 行)。将日志绘制替换为:
#![allow(unused)] fn main() { // Draw the log let mut block = TextBlock::new(1, 46, 79, 58); block.print(&gamelog::log_display()); block.render(&mut rltk::BACKEND_INTERNAL.lock().consoles[0].console); }
这指定了日志文本块的确切位置,作为一个 RLTK TextBlock 对象。然后它将 log_display() 的结果打印到块中,并将其渲染到控制台零(我们正在使用的控制台)。
现在,我们需要一种向日志添加文本的方法。构建器模式是一个自然的选择;大多数时候,我们都在逐步构建日志条目中的细节。创建另一个文件 src/gamelog/builder.rs:
#![allow(unused)] fn main() { use rltk::prelude::*; use super::{LogFragment, append_entry}; pub struct Logger { current_color : RGB, fragments : Vec<LogFragment> } impl Logger { pub fn new() -> Self { Logger{ current_color : RGB::named(rltk::WHITE), fragments : Vec::new() } } pub fn color(mut self, color: (u8, u8, u8)) -> Self { self.current_color = RGB::named(color); self } pub fn append<T: ToString>(mut self, text : T) -> Self { self.fragments.push( LogFragment{ color : self.current_color, text : text.to_string() } ); self } pub fn log(self) { append_entry(self.fragments) } } }
这定义了一个新类型 Logger。它跟踪当前的输出颜色,以及组成日志条目的当前片段列表。new 函数创建一个新的 Logger,而 log 将其提交给受互斥锁保护的全局变量。你可以调用 color 来更改当前的写入颜色,并调用 append 来添加字符串(我们正在使用 ToString,所以不再需要在到处进行混乱的 to_string() 调用!)。
在 gamelog/mod.rs 中,我们想要使用并导出这个模块:
#![allow(unused)] fn main() { mod builder; pub use builder::*; }
为了查看它的实际效果,打开 main.rs 并找到我们向资源列表添加新日志文件的行,以及 “Welcome to Rusty Roguelike” 行。现在,我们将保留原始的 - 并利用新的设置来启动日志:
#![allow(unused)] fn main() { gs.ecs.insert(gamelog::GameLog{ entries : vec!["Welcome to Rusty Roguelike".to_string()] }); gamelog::clear_log(); gamelog::Logger::new() .append("Welcome to") .color(rltk::CYAN) .append("Rusty Roguelike") .log(); }
这很简洁明了:无需获取资源,并且文本/颜色追加易于阅读!如果你现在 cargo run,你将看到一个以彩色显示的日志条目:

强制执行 API 使用
现在是时候破坏一些东西了。在 src/gamelog/mod.rs 中,删除 以下内容:
#![allow(unused)] fn main() { pub struct GameLog { pub entries : Vec<String> } }
如果你正在使用 IDE,你的项目刚刚变成了一片红色!我们刚刚删除了旧的日志记录方式 - 因此每个对旧日志的引用现在都变成了编译失败。没关系,因为我们想要过渡到新系统。
从 main.rs 开始,我们可以删除对旧日志的引用。删除新的日志行,以及我们之前添加的所有日志记录信息。找到 generate_world_map 函数,并将初始的日志清除/设置移到那里:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32, offset: i32) { self.mapgen_index = 0; self.mapgen_timer = 0.0; self.mapgen_history.clear(); let map_building_info = map::level_transition(&mut self.ecs, new_depth, offset); if let Some(history) = map_building_info { self.mapgen_history = history; } else { map::thaw_level_entities(&mut self.ecs); } gamelog::clear_log(); gamelog::Logger::new() .append("Welcome to") .color(rltk::CYAN) .append("Rusty Roguelike") .log(); } }
如果你现在 cargo build 项目,你将会有很多错误。我们需要逐步处理并更新所有日志记录引用,以使用新系统。
使用 API
打开 src/inventory_system/collection_system.rs。在 use 语句中,删除对 gamelog::GameLog 的引用(它不再存在了)。删除寻找游戏日志的 WriteExpect(以及元组中匹配的 mut gamelog)。将 gamelog.push 语句替换为:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .append("You pick up the") .color(rltk::CYAN) .append( super::obfuscate_name(pickup.item, &names, &magic_items, &obfuscated_names, &dm) ) .log(); }
你需要在 src/inventory_system/drop_system.rs 中进行基本相同的更改。在删除导入和资源后,日志消息系统变为:
#![allow(unused)] fn main() { if entity == *player_entity { crate::gamelog::Logger::new() .append("You drop the") .color(rltk::CYAN) .append( super::obfuscate_name(to_drop.item, &names, &magic_items, &obfuscated_names, &dm) ) .log(); } }
同样,在 src/inventory_system/equip_use.rs 中,删除 gamelog。还要删除 log_entries 变量和追加它的循环。有很多日志条目需要清理:
#![allow(unused)] fn main() { // Cursed item unequipping crate::gamelog::Logger::new() .append("You cannot unequip") .color(rltk::CYAN) .append(&name.name) .color(rltk::WHITE) .append("- it is cursed!") .log(); can_equip = false; ... // Unequipped item crate::gamelog::Logger::new() .append("You unequip") .color(rltk::CYAN) .append(&name.name) .log(); ... // Wield crate::gamelog::Logger::new() .append("You equip") .color(rltk::CYAN) .append(&names.get(useitem.item).unwrap().name) .log(); }
同样,文件 src/hunger_system.rs 需要更新。再次删除 gamelog 并将 log.push 行替换为使用新系统的等效行。
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .color(rltk::ORANGE) .append("You are no longer well fed") .log(); ... crate::gamelog::Logger::new() .color(rltk::ORANGE) .append("You are hungry") .log(); ... crate::gamelog::Logger::new() .color(rltk::RED) .append("You are starving!") .log(); ... crate::gamelog::Logger::new() .color(rltk::RED) .append("Your hunger pangs are getting painful! You suffer 1 hp damage.") .log(); }
src/trigger_system.rs 也需要相同的处理。再次删除 gamelog 并替换日志条目。我们将使用一些颜色高亮来强调陷阱:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .color(rltk::RED) .append(&name.name) .color(rltk::WHITE) .append("triggers!") .log(); }
src/ai/quipping.rs 需要完全相同的处理。删除 gamelog,并将日志调用替换为:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .color(rltk::YELLOW) .append(&name.name) .color(rltk::WHITE) .append("says") .color(rltk::CYAN) .append(&quip.available[quip_index]) .log(); }
src/ai/encumbrance_system.rs 也有相同的更改。再次,gamelog 必须消失 - 并且日志追加被替换为:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .color(rltk::ORANGE) .append("You are overburdened, and suffering an initiative penalty.") .log(); }
src/effects/damage.rs 的日志记录方式略有不同,但我们现在可以统一机制。首先删除 use crate::gamelog::GameLog; 行。然后将所有 log_entries.push 行替换为使用新的 Logger 接口的行:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .color(rltk::MAGENTA) .append("Congratulations, you are now level") .append(format!("{}", player_stats.level)) .log(); ... crate::gamelog::Logger::new().color(rltk::GREEN).append("You feel stronger!").log(); ... crate::gamelog::Logger::new().color(rltk::GREEN).append("You feel healthier!").log(); ... crate::gamelog::Logger::new().color(rltk::GREEN).append("You feel quicker!").log(); ... crate::gamelog::Logger::new().color(rltk::GREEN).append("You feel smarter!").log(); }
src\effects\trigger.rs 中的情况也相同;删除 GameLog 并将日志代码替换为:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .color(rltk::CYAN) .append(&ecs.read_storage::<Name>().get(item).unwrap().name) .color(rltk::WHITE) .append("is out of charges!") .log(); ... crate::gamelog::Logger::new() .append("You eat the") .color(rltk::CYAN) .append(&names.get(entity).unwrap().name) .log(); ... crate::gamelog::Logger::new().append("The map is revealed to you!").log(); ... crate::gamelog::Logger::new().append("You are already in town, so the scroll does nothing.").log(); ... crate::gamelog::Logger::new().append("You are telported back to town!").log(); ... }
再次,src/player.rs 也是类似的情况。删除 GameLog,并将日志条目替换为新的构建器语法:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .append("You fire at") .color(rltk::CYAN) .append(&name.name) .log(); ... crate::gamelog::Logger::new().append("There is no way down from here.").log(); ... crate::gamelog::Logger::new().append("There is no way up from here.").log(); ... None => crate::gamelog::Logger::new().append("There is nothing here to pick up.").log(), ... crate::gamelog::Logger::new().append("You don't have enough mana to cast that!").log(); }
visibility_system.rs 中的情况也相同。再次删除 GameLog 并将日志推送替换为:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .append("You spotted:") .color(rltk::RED) .append(&name.name) .log(); }
再次,melee_combat_system.rs 需要相同的更改:不再有 GameLog,并将文本输出更新为使用新的构建系统:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .color(rltk::YELLOW) .append(&name.name) .color(rltk::WHITE) .append("hits") .color(rltk::YELLOW) .append(&target_name.name) .color(rltk::WHITE) .append("for") .color(rltk::RED) .append(format!("{}", damage)) .color(rltk::WHITE) .append("hp.") .log(); ... crate::gamelog::Logger::new() .color(rltk::CYAN) .append(&name.name) .color(rltk::WHITE) .append("considers attacking") .color(rltk::CYAN) .append(&target_name.name) .color(rltk::WHITE) .append("but misjudges the timing!") .log(); ... crate::gamelog::Logger::new() .color(rltk::CYAN) .append(&name.name) .color(rltk::WHITE) .append("attacks") .color(rltk::CYAN) .append(&target_name.name) .color(rltk::WHITE) .append("but can't connect.") .log(); }
现在你应该对所需的更改有一个很好的理解了。如果你查看 源代码,我已经对所有其他 gamelog 实例进行了更改。
在你完成所有更改后,你可以 cargo run 你的游戏 - 并看到一个色彩鲜艳的日志:

让常见的日志记录任务更轻松
在遍历代码,更新日志条目时 - 出现了很多共同之处。最好强制执行一些样式一致性(并减少所需的输入量)。我们将在日志构建器(在 src/gamelog/builder.rs 中)中添加一些方法来帮助我们:
#![allow(unused)] fn main() { pub fn npc_name<T: ToString>(mut self, text : T) -> Self { self.fragments.push( LogFragment{ color : RGB::named(rltk::YELLOW), text : text.to_string() } ); self } pub fn item_name<T: ToString>(mut self, text : T) -> Self { self.fragments.push( LogFragment{ color : RGB::named(rltk::CYAN), text : text.to_string() } ); self } pub fn damage(mut self, damage: i32) -> Self { self.fragments.push( LogFragment{ color : RGB::named(rltk::RED), text : format!("{}", damage).to_string() } ); self } }
现在我们可以再次遍历并更新一些日志条目代码,使用更简单的语法。例如,在 src\ai\quipping.rs 中,我们可以替换:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .color(rltk::YELLOW) .append(&name.name) .color(rltk::WHITE) .append("says") .color(rltk::CYAN) .append(&quip.available[quip_index]) .log(); }
为:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .npc_name(&name.name) .append("says") .npc_name(&quip.available[quip_index]) .log(); }
或者在 melee_combat_system.rs 中,可以大大缩短伤害公告:
#![allow(unused)] fn main() { crate::gamelog::Logger::new() .npc_name(&name.name) .append("hits") .npc_name(&target_name.name) .append("for") .damage(damage) .append("hp.") .log(); }
再次,我已经遍历了项目源代码并应用了这些增强功能。
保存和加载日志
为了更轻松地保存和加载日志,我们将在 gamelog/logstore.rs 中添加两个辅助函数:
#![allow(unused)] fn main() { pub fn clone_log() -> Vec<Vec<crate::gamelog::LogFragment>> { LOG.lock().unwrap().clone() } pub fn restore_log(log : &mut Vec<Vec<crate::gamelog::LogFragment>>) { LOG.lock().unwrap().clear(); LOG.lock().unwrap().append(log); } }
第一个函数提供日志的克隆副本。第二个函数清空日志,并追加一个新的日志。你需要打开 gamelog/mod.rs 并将它们添加到导出的函数列表中:
#![allow(unused)] fn main() { pub use logstore::{clear_log, log_display, clone_log, restore_log}; }
当你在编辑 mod.rs 时,我们需要向 LogFragment 结构添加一些派生:
#![allow(unused)] fn main() { use serde::{Serialize, Deserialize}; #[derive(Serialize, Deserialize, Clone)] pub struct LogFragment { pub color : RGB, pub text : String } }
现在打开 components.rs,并修改 DMSerializationHelper 结构以包含日志:
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct DMSerializationHelper { pub map : super::map::MasterDungeonMap, pub log : Vec<Vec<crate::gamelog::LogFragment>> } }
打开 saveload_system.rs,我们将在序列化地图时包含日志:
#![allow(unused)] fn main() { let savehelper2 = ecs .create_entity() .with(DMSerializationHelper{ map : dungeon_master, log: crate::gamelog::clone_log() }) .marked::<SimpleMarker<SerializeMe>>() .build(); }
当我们反序列化地图时,我们也将恢复日志:
#![allow(unused)] fn main() { for (e,h) in (&entities, &helper2).join() { let mut dungeonmaster = ecs.write_resource::<super::map::MasterDungeonMap>(); *dungeonmaster = h.map.clone(); deleteme2 = Some(e); crate::gamelog::restore_log(&mut h.log.clone()); } }
这就是保存/加载日志的全部内容:它与 Serde 配合良好(在完整的 JSON 上可能会有点慢),但它工作得很好。
计数事件
作为迈向成就的第一步,我们需要能够计数相关事件。创建一个新文件 src/gamelog/events.rs,并将以下内容粘贴到其中:
#![allow(unused)] fn main() { use std::collections::HashMap; use std::sync::Mutex; lazy_static! { static ref EVENTS : Mutex<HashMap<String, i32>> = Mutex::new(HashMap::new()); } pub fn clear_events() { EVENTS.lock().unwrap().clear(); } pub fn record_event<T: ToString>(event: T, n : i32) { let event_name = event.to_string(); let mut events_lock = EVENTS.lock(); let mut events = events_lock.as_mut().unwrap(); if let Some(e) = events.get_mut(&event_name) { *e += n; } else { events.insert(event_name, n); } } pub fn get_event_count<T: ToString>(event: T) -> i32 { let event_name = event.to_string(); let events_lock = EVENTS.lock(); let events = events_lock.unwrap(); if let Some(e) = events.get(&event_name) { *e } else { 0 } } }
这与我们存储日志的方式类似:它是一个 “lazy static”,带有互斥锁安全包装器。内部是一个 HashMap,以事件名称为索引,并包含一个计数器。record_event 将一个事件添加到运行总计中(如果不存在则创建一个新事件)。get_event_count 返回 0,或指定名称计数器的总数。
在 main.rs 中,找到 RunState::AwaitingInput 的主循环处理程序 - 我们将扩展它来计算玩家存活的回合数:
#![allow(unused)] fn main() { RunState::AwaitingInput => { newrunstate = player_input(self, ctx); if newrunstate != RunState::AwaitingInput { crate::gamelog::record_event("Turn", 1); } } }
我们还应该在 generate_world_map 的末尾清除计数器状态:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32, offset: i32) { self.mapgen_index = 0; self.mapgen_timer = 0.0; self.mapgen_history.clear(); let map_building_info = map::level_transition(&mut self.ecs, new_depth, offset); if let Some(history) = map_building_info { self.mapgen_history = history; } else { map::thaw_level_entities(&mut self.ecs); } gamelog::clear_log(); gamelog::Logger::new() .append("Welcome to") .color(rltk::CYAN) .append("Rusty Roguelike") .log(); gamelog::clear_events(); } }
为了演示它是否有效,让我们在死亡屏幕上显示玩家存活的回合数。在 gui.rs 中,打开函数 game_over 并添加一个回合计数器:
#![allow(unused)] fn main() { pub fn game_over(ctx : &mut Rltk) -> GameOverResult { ctx.print_color_centered(15, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Your journey has ended!"); ctx.print_color_centered(17, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "One day, we'll tell you all about how you did."); ctx.print_color_centered(18, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "That day, sadly, is not in this chapter.."); ctx.print_color_centered(19, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), &format!("You lived for {} turns.", crate::gamelog::get_event_count("Turn"))); ctx.print_color_centered(21, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Press any key to return to the menu."); match ctx.key { None => GameOverResult::NoSelection, Some(_) => GameOverResult::QuitToMenu } } }
如果你现在 cargo run,你的回合数将被计数。这是我尝试被杀死的一次运行的结果:

Bracket 进行数量调查
这是一个非常灵活的系统:你几乎可以从任何地方计数任何你喜欢的东西!让我们记录玩家在整个游戏中受到的伤害量。打开 src/effects/damage.rs 并修改函数 inflict_damage:
#![allow(unused)] fn main() { pub fn inflict_damage(ecs: &mut World, damage: &EffectSpawner, target: Entity) { let mut pools = ecs.write_storage::<Pools>(); let player_entity = ecs.fetch::<Entity>(); if let Some(pool) = pools.get_mut(target) { if !pool.god_mode { if let Some(creator) = damage.creator { if creator == target { return; } } if let EffectType::Damage{amount} = damage.effect_type { pool.hit_points.current -= amount; add_effect(None, EffectType::Bloodstain, Targets::Single{target}); add_effect(None, EffectType::Particle{ glyph: rltk::to_cp437('‼'), fg : rltk::RGB::named(rltk::ORANGE), bg : rltk::RGB::named(rltk::BLACK), lifespan: 200.0 }, Targets::Single{target} ); if target == *player_entity { crate::gamelog::record_event("Damage Taken", amount); } if damage.creator == *player_entity { crate::gamelog::record_event("Damage Inflicted", amount); } if pool.hit_points.current < 1 { add_effect(damage.creator, EffectType::EntityDeath, Targets::Single{target}); } } } } } }
我们将再次修改 gui.rs 的 game_over 函数以显示受到的伤害:
#![allow(unused)] fn main() { pub fn inflict_damage(ecs: &mut World, damage: &EffectSpawner, target: Entity) { let mut pools = ecs.write_storage::<Pools>(); let player_entity = ecs.fetch::<Entity>(); if let Some(pool) = pools.get_mut(target) { if !pool.god_mode { if let Some(creator) = damage.creator { if creator == target { return; } } if let EffectType::Damage{amount} = damage.effect_type { pool.hit_points.current -= amount; add_effect(None, EffectType::Bloodstain, Targets::Single{target}); add_effect(None, EffectType::Particle{ glyph: rltk::to_cp437('‼'), fg : rltk::RGB::named(rltk::ORANGE), bg : rltk::RGB::named(rltk::BLACK), lifespan: 200.0 }, Targets::Single{target} ); if target == *player_entity { crate::gamelog::record_event("Damage Taken", amount); } if let Some(creator) = damage.creator { if creator == *player_entity { crate::gamelog::record_event("Damage Inflicted", amount); } } if pool.hit_points.current < 1 { add_effect(damage.creator, EffectType::EntityDeath, Targets::Single{target}); } } } } } }
现在死亡会显示你在整个运行过程中遭受了多少伤害:

当然,你可以根据自己的意愿扩展这个功能。现在几乎所有可量化的东西都可以被追踪,如果你愿意的话。
保存和加载计数器
向 src/gamelog/events.rs 添加另外两个函数:
#![allow(unused)] fn main() { pub fn clone_events() -> HashMap<String, i32> { EVENTS.lock().unwrap().clone() } pub fn load_events(events : HashMap<String, i32>) { EVENTS.lock().unwrap().clear(); events.iter().for_each(|(k,v)| { EVENTS.lock().unwrap().insert(k.to_string(), *v); }); } }
现在打开 components.rs,并修改 DMSerializationHelper:
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct DMSerializationHelper { pub map : super::map::MasterDungeonMap, pub log : Vec<Vec<crate::gamelog::LogFragment>>, pub events : HashMap<String, i32> } }
然后在 saveload_system.rs 中,我们可以将克隆的事件包含在我们的序列化中:
#![allow(unused)] fn main() { let savehelper2 = ecs .create_entity() .with(DMSerializationHelper{ map : dungeon_master, log: crate::gamelog::clone_log(), events : crate::gamelog::clone_events() }) .marked::<SimpleMarker<SerializeMe>>() .build(); }
并在我们反序列化时导入事件:
#![allow(unused)] fn main() { for (e,h) in (&entities, &helper2).join() { let mut dungeonmaster = ecs.write_resource::<super::map::MasterDungeonMap>(); *dungeonmaster = h.map.clone(); deleteme2 = Some(e); crate::gamelog::restore_log(&mut h.log.clone()); crate::gamelog::load_events(h.events.clone()); } }
总结
我们现在有了色彩鲜艳的日志,以及玩家成就的计数器。这使我们离 Steam(或 XBOX)风格的成就仅一步之遥 - 我们将在接下来的章节中介绍。
本章的源代码可以在 这里 找到
在你的浏览器中使用 Web 程序集运行本章的示例 (需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
文本图层
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
默认的 8x8 字体对于大段文本来说会变得难以阅读,特别是当与后期处理效果结合使用时。RLTK 的图形化 console 模式(基本上除了 curses 之外的所有模式)支持在同一屏幕上显示多个 console,可以选择使用不同的字体。 RLTK 附带了一个 VGA 字体 (8x16),它更容易阅读。我们将使用它,但仅用于日志。
使用 VGA 字体的第二个图层进行初始化非常简单(详见 RLTK 示例 2)。 展开 main.rs 中的 builder 代码:
#![allow(unused)] fn main() { let mut context = RltkBuilder::simple(80, 60) .with_title("Roguelike Tutorial") .with_font("vga8x16.png", 8, 16) .with_sparse_console(80, 30, "vga8x16.png") .build()?; }
主循环的 “clear screen”(清屏)需要扩展为清除两个图层。在 main.rs(tick 函数)中,我们有一段代码在 70 个章节中都没有修改过 - 在帧开始时清除屏幕。 现在我们想要清除两个 console:
#![allow(unused)] fn main() { ctx.set_active_console(1); ctx.cls(); ctx.set_active_console(0); ctx.cls(); }
我遇到了一些关于 TextBlock 组件和多个 console 的问题,所以我编写了一个替代方案。在 src/gamelog/logstore.rs 中,我们移除 display_log 函数并添加一个替换:
#![allow(unused)] fn main() { pub fn print_log(console: &mut Box<dyn Console>, pos: Point) { let mut y = pos.y; let mut x = pos.x; LOG.lock().unwrap().iter().rev().take(6).for_each(|log| { log.iter().for_each(|frag| { console.print_color(x, y, frag.color, RGB::named(rltk::BLACK), &frag.text); x += frag.text.len() as i32; x += 1; }); y += 1; x = pos.x; }); } }
并更正 src/gamelog/mod.rs 中的导出:
#![allow(unused)] fn main() { pub use logstore::{clear_log, clone_log, restore_log, print_log}; }
由于新代码处理渲染,因此绘制日志文件非常容易! 更改 gui.rs 中的日志渲染:
#![allow(unused)] fn main() { // Draw the log gamelog::print_log(&mut rltk::BACKEND_INTERNAL.lock().consoles[1].console, Point::new(1, 23)); }
如果您现在 cargo run,您将看到一个更容易阅读的日志部分:

让我们清理 GUI 代码
既然我们正在处理 GUI,现在是清理它的好时机。 添加一些鼠标支持也会很好。 我们将首先将 gui.rs 变成一个多文件 module。 它非常庞大,所以将它分解开本身就是一种胜利! 创建一个新文件夹 src/gui 并将 gui.rs 文件移动到其中。 然后将该文件重命名为 mod.rs。 游戏将像以前一样工作。
然后我们进行一些重新排列:
- 创建一个新文件
gui/item_render.rs。 将mod item_render; pub use item_render::*;添加到gui/mod.rs,并将函数get_item_color和get_item_display_name移动到其中。 - RLTK 现在支持绘制空心框,因此我们可以删除
draw_hollow_box函数。 将调用draw_hollow_box(ctx, ...)替换为ctx.draw_hollow_box(...)。 - 创建一个新文件
gui/hud.rs。 将mod hud; pub use hud::*;添加到gui/mod.rs。 将以下函数移动到其中:draw_attribute、draw_ui。 - 创建一个新文件
gui/tooltips.rs。 将mod tooltips; pub use tooltips::*;添加到gui/mod.rs。 将Tooltipstruct 和实现移动到其中,以及函数draw_tooltips。 您必须使该函数为pub。 - 创建一个新文件
gui/inventory_menu.rs。 将mod inventory_menu; pub use inventory_menu::*;添加到gui/mod.rs。 将 inventory menu 代码移动到那里。 - item dropping 也是一样。 创建
gui/drop_item_menu.rs,将mod drop_item_menu; pub use drop_item_menu::*;添加到mod.rs并移动 item dropping menu。 - 为
gui/remove_item_menu.rs和 move item 代码重复此操作。 - 再次重复 - 这次是
gui/remove_curse_menu.rs。 - 再次 - 这次是
gui/identify_menu.rs、gui/ranged_target.rs、gui/main_menu.rs、gui/game_over_menu.rs、gui/cheat_menu.rs和gui/vendor_menu.rs。
还有很多 import 清理工作。 如果您不确定需要什么,我建议参考源代码。 完成所有操作后,gui/mod.rs 不包含任何功能:只有指向各个文件的指针。
游戏应该像以前一样运行:但您的编译时间有所缩短(尤其是在增量构建时)!
当我们清理时 - 摄像机
在几个章节中,camera.rs 不在 map module 中一直困扰着我。 让我们把它移动到那里。 将文件移动到 map 文件夹中。 将行 pub mod camera; 添加到 map/mod.rs。 这留下了一些引用需要清理:
- 从
main.rs中删除pub mod camera;。 - 将
map/camera.rs中的use super::更改为use crate::。
批量渲染
RLTK 最近获得了一个新的渲染功能:批量渲染的能力。 这使得渲染与 system 兼容(您不能将 RLTK 添加为 resource,它有太多线程不安全的功能)。 在本章中我们不打算处理 system,但我们将切换到新的渲染路径。 它速度更快,总体上更简洁。 好消息是,您可以在切换过程中大量混合和匹配两种样式。
首先启用 system。 在 main.rs 中 tick 的末尾,添加一行:
#![allow(unused)] fn main() { rltk::render_draw_buffer(ctx); }
这告诉 RLTK 将它累积的所有 draw buffer 提交到屏幕。 通过首先添加此行,我们确保任何我们切换过去的内容都将被渲染。
批量处理摄像机
打开 map/camera.rs。 将 use rltk:: 行替换为 use rltk::prelude::*;。 既然 RLTK 支持 prelude,我们就应该使用它! 然后,在 render_camera 的第一行,添加以下内容:
#![allow(unused)] fn main() { let mut draw_batch = DrawBatch::new(); }
这请求 RLTK 创建一个新的 “draw batch”。 这些是高性能的、池化的对象,它们收集绘制指令,然后可以一次性提交。 这非常缓存友好,并且通常会显着提高性能。
将第一个 set 命令替换为 draw_batch.set:
#![allow(unused)] fn main() { // FROM // 来自 ctx.set(x as i32+1, y as i32+1, fg, bg, glyph); // TO // 变为 draw_batch.set( Point::new(x+1, y+1), ColorPair::new(fg, bg), glyph ); }
您需要通读并对所有绘制调用进行相同的更改。 在最后添加新行:
#![allow(unused)] fn main() { draw_batch.submit(0); }
这会将 map 渲染作为 batch 提交。 完成的函数如下所示:
#![allow(unused)] fn main() { pub fn render_camera(ecs: &World, ctx : &mut Rltk) { let mut draw_batch = DrawBatch::new(); let map = ecs.fetch::<Map>(); let (min_x, max_x, min_y, max_y) = get_screen_bounds(ecs, ctx); // Render the Map // 渲染地图 let map_width = map.width-1; let map_height = map.height-1; for (y,ty) in (min_y .. max_y).enumerate() { for (x,tx) in (min_x .. max_x).enumerate() { if tx > 0 && tx < map_width && ty > 0 && ty < map_height { let idx = map.xy_idx(tx, ty); if map.revealed_tiles[idx] { let (glyph, fg, bg) = tile_glyph(idx, &*map); draw_batch.set( Point::new(x+1, y+1), ColorPair::new(fg, bg), glyph ); } } else if SHOW_BOUNDARIES { draw_batch.set( Point::new(x+1, y+1), ColorPair::new(RGB::named(rltk::GRAY), RGB::named(rltk::BLACK)), to_cp437('·') ); } } } // Render entities // 渲染实体 let positions = ecs.read_storage::<Position>(); let renderables = ecs.read_storage::<Renderable>(); let hidden = ecs.read_storage::<Hidden>(); let map = ecs.fetch::<Map>(); let sizes = ecs.read_storage::<TileSize>(); let entities = ecs.entities(); let targets = ecs.read_storage::<Target>(); let mut data = (&positions, &renderables, &entities, !&hidden).join().collect::<Vec<_>>(); data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) ); for (pos, render, entity, _hidden) in data.iter() { if let Some(size) = sizes.get(*entity) { for cy in 0 .. size.y { for cx in 0 .. size.x { let tile_x = cx + pos.x; let tile_y = cy + pos.y; let idx = map.xy_idx(tile_x, tile_y); if map.visible_tiles[idx] { let entity_screen_x = (cx + pos.x) - min_x; let entity_screen_y = (cy + pos.y) - min_y; if entity_screen_x > 0 && entity_screen_x < map_width && entity_screen_y > 0 && entity_screen_y < map_height { draw_batch.set( Point::new(entity_screen_x + 1, entity_screen_y + 1), ColorPair::new(render.fg, render.bg), render.glyph ); } } } } } else { let idx = map.xy_idx(pos.x, pos.y); if map.visible_tiles[idx] { let entity_screen_x = pos.x - min_x; let entity_screen_y = pos.y - min_y; if entity_screen_x > 0 && entity_screen_x < map_width && entity_screen_y > 0 && entity_screen_y < map_height { draw_batch.set( Point::new(entity_screen_x + 1, entity_screen_y + 1), ColorPair::new(render.fg, render.bg), render.glyph ); } } } if targets.get(*entity).is_some() { let entity_screen_x = pos.x - min_x; let entity_screen_y = pos.y - min_y; draw_batch.set( Point::new(entity_screen_x , entity_screen_y + 1), ColorPair::new(RGB::named(rltk::RED), RGB::named(rltk::YELLOW)), to_cp437('[') ); draw_batch.set( Point::new(entity_screen_x +2, entity_screen_y + 1), ColorPair::new(RGB::named(rltk::RED), RGB::named(rltk::YELLOW)), to_cp437(']') ); } } draw_batch.submit(0); } }
如果您现在 cargo run,它与之前基本相同:但是通常出现在 map 顶部的 tool-tip 不可见(它们在下面,因为我们在最后提交)。
批量处理 GUI
我们将从 gui/hud.rs 开始,因为它最混乱! 在开头添加 let mut draw_batch = DrawBatch::new();,并在结尾添加 draw_batch.submit(5000);。 为什么是 5,000? map 中有 80x60 (4,800) 个可能的 tile。 提供的数字充当排序:因此我们保证在 map 之后绘制 GUI。 然后就是将 ctx 调用转换为等效的 batch 调用的问题。 现在也是将巨大的 draw_gui 函数分解成更小的部分的好时机。 完全重构的 gui/hud.rs 看起来像这样:
#![allow(unused)] fn main() { use rltk::prelude::*; use specs::prelude::*; use crate::{Pools, Map, Name, InBackpack, Equipped, HungerClock, HungerState, Attributes, Attribute, Consumable, StatusEffect, Duration, KnownSpells, Weapon, gamelog }; use super::{draw_tooltips, get_item_display_name, get_item_color}; fn draw_attribute(name : &str, attribute : &Attribute, y : i32, draw_batch: &mut DrawBatch) { let black = RGB::named(rltk::BLACK); let attr_gray : RGB = RGB::from_hex("#CCCCCC").expect("Oops"); draw_batch.print_color(Point::new(50, y), name, ColorPair::new(attr_gray, black)); let color : RGB = if attribute.modifiers < 0 { RGB::from_f32(1.0, 0.0, 0.0) } else if attribute.modifiers == 0 { RGB::named(rltk::WHITE) } else { RGB::from_f32(0.0, 1.0, 0.0) }; draw_batch.print_color(Point::new(67, y), &format!("{}", attribute.base + attribute.modifiers), ColorPair::new(color, black)); draw_batch.print_color(Point::new(73, y), &format!("{}", attribute.bonus), ColorPair::new(color, black)); if attribute.bonus > 0 { draw_batch.set(Point::new(72, y), ColorPair::new(color, black), to_cp437('+')); } } fn box_framework(draw_batch : &mut DrawBatch) { let box_gray : RGB = RGB::from_hex("#999999").expect("Oops"); let black = RGB::named(rltk::BLACK); draw_batch.draw_hollow_box(Rect::with_size(0, 0, 79, 59), ColorPair::new(box_gray, black)); // Overall box draw_batch.draw_hollow_box(Rect::with_size(0, 0, 49, 45), ColorPair::new(box_gray, black)); // Map box draw_batch.draw_hollow_box(Rect::with_size(0, 45, 79, 14), ColorPair::new(box_gray, black)); // Log box draw_batch.draw_hollow_box(Rect::with_size(49, 0, 30, 8), ColorPair::new(box_gray, black)); // Top-right panel // Draw box connectors // 绘制盒子连接器 draw_batch.set(Point::new(0, 45), ColorPair::new(box_gray, black), to_cp437('├')); draw_batch.set(Point::new(49, 8), ColorPair::new(box_gray, black), to_cp437('├')); draw_batch.set(Point::new(49, 0), ColorPair::new(box_gray, black), to_cp437('┬')); draw_batch.set(Point::new(49, 45), ColorPair::new(box_gray, black), to_cp437('┴')); draw_batch.set(Point::new(79, 8), ColorPair::new(box_gray, black), to_cp437('┤')); draw_batch.set(Point::new(79, 45), ColorPair::new(box_gray, black), to_cp437('┤')); } pub fn map_label(ecs: &World, draw_batch: &mut DrawBatch) { let box_gray : RGB = RGB::from_hex("#999999").expect("Oops"); let black = RGB::named(rltk::BLACK); let white = RGB::named(rltk::WHITE); let map = ecs.fetch::<Map>(); let name_length = map.name.len() + 2; let x_pos = (22 - (name_length / 2)) as i32; draw_batch.set(Point::new(x_pos, 0), ColorPair::new(box_gray, black), to_cp437('┤')); draw_batch.set(Point::new(x_pos + name_length as i32 - 1, 0), ColorPair::new(box_gray, black), to_cp437('├')); draw_batch.print_color(Point::new(x_pos+1, 0), &map.name, ColorPair::new(white, black)); } fn draw_stats(ecs: &World, draw_batch: &mut DrawBatch, player_entity: &Entity) { let black = RGB::named(rltk::BLACK); let white = RGB::named(rltk::WHITE); let pools = ecs.read_storage::<Pools>(); let player_pools = pools.get(*player_entity).unwrap(); let health = format!("Health: {}/{}", player_pools.hit_points.current, player_pools.hit_points.max); let mana = format!("Mana: {}/{}", player_pools.mana.current, player_pools.mana.max); let xp = format!("Level: {}", player_pools.level); draw_batch.print_color(Point::new(50, 1), &health, ColorPair::new(white, black)); draw_batch.print_color(Point::new(50, 2), &mana, ColorPair::new(white, black)); draw_batch.print_color(Point::new(50, 3), &xp, ColorPair::new(white, black)); draw_batch.bar_horizontal( Point::new(64, 1), 14, player_pools.hit_points.current, player_pools.hit_points.max, ColorPair::new(RGB::named(rltk::RED), RGB::named(rltk::BLACK)) ); draw_batch.bar_horizontal( Point::new(64, 2), 14, player_pools.mana.current, player_pools.mana.max, ColorPair::new(RGB::named(rltk::BLUE), RGB::named(rltk::BLACK)) ); let xp_level_start = (player_pools.level-1) * 1000; draw_batch.bar_horizontal( Point::new(64, 3), 14, player_pools.xp - xp_level_start, 1000, ColorPair::new(RGB::named(rltk::GOLD), RGB::named(rltk::BLACK)) ); } fn draw_attributes(ecs: &World, draw_batch: &mut DrawBatch, player_entity: &Entity) { let attributes = ecs.read_storage::<Attributes>(); let attr = attributes.get(*player_entity).unwrap(); draw_attribute("Might:", &attr.might, 4, draw_batch); draw_attribute("Quickness:", &attr.quickness, 5, draw_batch); draw_attribute("Fitness:", &attr.fitness, 6, draw_batch); draw_attribute("Intelligence:", &attr.intelligence, 7, draw_batch); } fn initiative_weight(ecs: &World, draw_batch: &mut DrawBatch, player_entity: &Entity) { let attributes = ecs.read_storage::<Attributes>(); let attr = attributes.get(*player_entity).unwrap(); let black = RGB::named(rltk::BLACK); let white = RGB::named(rltk::WHITE); let pools = ecs.read_storage::<Pools>(); let player_pools = pools.get(*player_entity).unwrap(); draw_batch.print_color( Point::new(50, 9), &format!("{:.0} lbs ({} lbs max)", player_pools.total_weight, (attr.might.base + attr.might.modifiers) * 15 ), ColorPair::new(white, black) ); draw_batch.print_color( Point::new(50,10), &format!("Initiative Penalty: {:.0}", player_pools.total_initiative_penalty), ColorPair::new(white, black) ); draw_batch.print_color( Point::new(50,11), &format!("Gold: {:.1}", player_pools.gold), ColorPair::new(RGB::named(rltk::GOLD), black) ); } fn equipped(ecs: &World, draw_batch: &mut DrawBatch, player_entity: &Entity) -> i32 { let black = RGB::named(rltk::BLACK); let yellow = RGB::named(rltk::YELLOW); let mut y = 13; let entities = ecs.entities(); let equipped = ecs.read_storage::<Equipped>(); let weapon = ecs.read_storage::<Weapon>(); for (entity, equipped_by) in (&entities, &equipped).join() { if equipped_by.owner == *player_entity { let name = get_item_display_name(ecs, entity); draw_batch.print_color( Point::new(50, y), &name, ColorPair::new(get_item_color(ecs, entity), black)); y += 1; if let Some(weapon) = weapon.get(entity) { let mut weapon_info = if weapon.damage_bonus < 0 { format!("┤ {} ({}d{}{})", &name, weapon.damage_n_dice, weapon.damage_die_type, weapon.damage_bonus) } else if weapon.damage_bonus == 0 { format!("┤ {} ({}d{})", &name, weapon.damage_n_dice, weapon.damage_die_type) } else { format!("┤ {} ({}d{}+{})", &name, weapon.damage_n_dice, weapon.damage_die_type, weapon.damage_bonus) }; if let Some(range) = weapon.range { weapon_info += &format!(" (range: {}, F to fire, V cycle targets)", range); } weapon_info += " ├"; draw_batch.print_color( Point::new(3, 45), &weapon_info, ColorPair::new(yellow, black)); } } } y } fn consumables(ecs: &World, draw_batch: &mut DrawBatch, player_entity: &Entity, mut y : i32) -> i32 { y += 1; let black = RGB::named(rltk::BLACK); let yellow = RGB::named(rltk::YELLOW); let entities = ecs.entities(); let consumables = ecs.read_storage::<Consumable>(); let backpack = ecs.read_storage::<InBackpack>(); let mut index = 1; for (entity, carried_by, _consumable) in (&entities, &backpack, &consumables).join() { if carried_by.owner == *player_entity && index < 10 { draw_batch.print_color( Point::new(50, y), &format!("↑{}", index), ColorPair::new(yellow, black) ); draw_batch.print_color( Point::new(53, y), &get_item_display_name(ecs, entity), ColorPair::new(get_item_color(ecs, entity), black) ); y += 1; index += 1; } } y } fn spells(ecs: &World, draw_batch: &mut DrawBatch, player_entity: &Entity, mut y : i32) -> i32 { y += 1; let black = RGB::named(rltk::BLACK); let blue = RGB::named(rltk::CYAN); let known_spells_storage = ecs.read_storage::<KnownSpells>(); let known_spells = &known_spells_storage.get(*player_entity).unwrap().spells; let mut index = 1; for spell in known_spells.iter() { draw_batch.print_color( Point::new(50, y), &format!("^{}", index), ColorPair::new(blue, black) ); draw_batch.print_color( Point::new(53, y), &format!("{} ({})", &spell.display_name, spell.mana_cost), ColorPair::new(blue, black) ); index += 1; y += 1; } y } fn status(ecs: &World, draw_batch: &mut DrawBatch, player_entity: &Entity) { let mut y = 44; let hunger = ecs.read_storage::<HungerClock>(); let hc = hunger.get(*player_entity).unwrap(); match hc.state { HungerState::WellFed => { draw_batch.print_color( Point::new(50, y), "Well Fed", ColorPair::new(RGB::named(rltk::GREEN), RGB::named(rltk::BLACK)) ); y -= 1; } HungerState::Normal => {} HungerState::Hungry => { draw_batch.print_color( Point::new(50, y), "Hungry", ColorPair::new(RGB::named(rltk::ORANGE), RGB::named(rltk::BLACK)) ); y -= 1; } HungerState::Starving => { draw_batch.print_color( Point::new(50, y), "Starving", ColorPair::new(RGB::named(rltk::RED), RGB::named(rltk::BLACK)) ); y -= 1; } } let statuses = ecs.read_storage::<StatusEffect>(); let durations = ecs.read_storage::<Duration>(); let names = ecs.read_storage::<Name>(); for (status, duration, name) in (&statuses, &durations, &names).join() { if status.target == *player_entity { draw_batch.print_color( Point::new(50, y), &format!("{} ({})", name.name, duration.turns), ColorPair::new(RGB::named(rltk::RED), RGB::named(rltk::BLACK)), ); y -= 1; } } } pub fn draw_ui(ecs: &World, ctx : &mut Rltk) { let mut draw_batch = DrawBatch::new(); let player_entity = ecs.fetch::<Entity>(); box_framework(&mut draw_batch); map_label(ecs, &mut draw_batch); draw_stats(ecs, &mut draw_batch, &player_entity); draw_attributes(ecs, &mut draw_batch, &player_entity); initiative_weight(ecs, &mut draw_batch, &player_entity); let mut y = equipped(ecs, &mut draw_batch, &player_entity); y += consumables(ecs, &mut draw_batch, &player_entity, y); spells(ecs, &mut draw_batch, &player_entity, y); status(ecs, &mut draw_batch, &player_entity); gamelog::print_log(&mut rltk::BACKEND_INTERNAL.lock().consoles[1].console, Point::new(1, 23)); draw_tooltips(ecs, ctx); draw_batch.submit(5000); } }
批量处理菜单
在我们的各种菜单之间有很多共享的功能,可以组合成 helper 函数。 考虑到批量处理,我们将首先构建一个新的 module gui/menus.rs 来保存通用功能:
#![allow(unused)] fn main() { use rltk::prelude::*; pub fn menu_box<T: ToString>(draw_batch: &mut DrawBatch, x: i32, y: i32, width: i32, title: T) { draw_batch.draw_box( Rect::with_size(x, y-2, 31, width), ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)) ); draw_batch.print_color( Point::new(18, y-2), &title.to_string(), ColorPair::new(RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK)) ); } pub fn menu_option<T:ToString>(draw_batch: &mut DrawBatch, x: i32, y: i32, hotkey: rltk::FontCharType, text: T) { draw_batch.set( Point::new(x, y), ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)), rltk::to_cp437('(') ); draw_batch.set( Point::new(x+1, y), ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK)), hotkey ); draw_batch.set( Point::new(x+2, y), ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)), rltk::to_cp437(')') ); draw_batch.print_color( Point::new(x+5, y), &text.to_string(), ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK)) ); } }
不要忘记修改 gui/mod.rs 以公开该功能:
#![allow(unused)] fn main() { mod menus; pub use menus::*; }
Cheat Menu
使用新的 helper,gui/cheat_menur.rs 文件很容易重构:
#![allow(unused)] fn main() { use rltk::prelude::*; use crate::State; use super::{menu_option, menu_box}; #[derive(PartialEq, Copy, Clone)] pub enum CheatMenuResult { NoResponse, Cancel, TeleportToExit, Heal, Reveal, GodMode } pub fn show_cheat_mode(_gs : &mut State, ctx : &mut Rltk) -> CheatMenuResult { let mut draw_batch = DrawBatch::new(); let count = 4; let mut y = (25 - (count / 2)) as i32; menu_box(&mut draw_batch, 15, y, (count+3) as i32, "Cheating!"); draw_batch.print_color( Point::new(18, y+count as i32+1), "ESCAPE to cancel", ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK)) ); menu_option(&mut draw_batch, 17, y, rltk::to_cp437('T'), "Teleport to next level"); y += 1; menu_option(&mut draw_batch, 17, y, rltk::to_cp437('H'), "Heal all wounds"); y += 1; menu_option(&mut draw_batch, 17, y, rltk::to_cp437('R'), "Reveal the map"); y += 1; menu_option(&mut draw_batch, 17, y, rltk::to_cp437('G'), "God Mode (No Death)"); draw_batch.submit(6000); match ctx.key { None => CheatMenuResult::NoResponse, Some(key) => { match key { VirtualKeyCode::T => CheatMenuResult::TeleportToExit, VirtualKeyCode::H => CheatMenuResult::Heal, VirtualKeyCode::R => CheatMenuResult::Reveal, VirtualKeyCode::G => CheatMenuResult::GodMode, VirtualKeyCode::Escape => CheatMenuResult::Cancel, _ => CheatMenuResult::NoResponse } } } } }
Drop Item Menu
对于各种 item menu,另一个 helper 可用于减少重复代码。 在 gui/menus.rs 中添加以下内容:
#![allow(unused)] fn main() { pub fn item_result_menu<S: ToString>( draw_batch: &mut DrawBatch, title: S, count: usize, items: &[(Entity, String)], key: Option<VirtualKeyCode> ) -> (ItemMenuResult, Option<Entity>) { let mut y = (25 - (count / 2)) as i32; draw_batch.draw_box( Rect::with_size(15, y-2, 31, (count+3) as i32), ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)) ); draw_batch.print_color( Point::new(18, y-2), &title.to_string(), ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK)) ); draw_batch.print_color( Point::new(18, y+count as i32+1), "ESCAPE to cancel", ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK)) ); let mut item_list : Vec<Entity> = Vec::new(); let mut j = 0; for item in items { menu_option(draw_batch, 17, y, 97+j as rltk::FontCharType, &item.1); item_list.push(item.0); y += 1; j += 1; } match key { None => (ItemMenuResult::NoResponse, None), Some(key) => { match key { VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) } _ => { let selection = rltk::letter_to_option(key); if selection > -1 && selection < count as i32 { return (ItemMenuResult::Selected, Some(item_list[selection as usize])); } (ItemMenuResult::NoResponse, None) } } } } } }
这基本上是我们其他 menu 的通用版本,它返回一个 ItemMenuResult。 我们可以使用它来显着简化 gui/drop_item_menu.rs:
#![allow(unused)] fn main() { use rltk::prelude::*; use specs::prelude::*; use crate::{State, InBackpack}; use super::{get_item_display_name, ItemMenuResult, item_result_menu}; pub fn drop_item_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) { let mut draw_batch = DrawBatch::new(); let player_entity = gs.ecs.fetch::<Entity>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let entities = gs.ecs.entities(); let mut items : Vec<(Entity, String)> = Vec::new(); (&entities, &backpack).join() .filter(|item| item.1.owner == *player_entity ) .for_each(|item| { items.push((item.0, get_item_display_name(&gs.ecs, item.0))) }); let result = item_result_menu( &mut draw_batch, "Drop which item?", items.len(), &items, ctx.key ); draw_batch.submit(6000); result } }
Remove Item Menu
相同的 helper 代码也使 gui/remove_item_menu.rs 更短:
#![allow(unused)] fn main() { use rltk::prelude::*; use specs::prelude::*; use crate::{State, Equipped }; use super::{get_item_display_name, ItemMenuResult, item_result_menu}; pub fn remove_item_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) { let mut draw_batch = DrawBatch::new(); let player_entity = gs.ecs.fetch::<Entity>(); let backpack = gs.ecs.read_storage::<Equipped>(); let entities = gs.ecs.entities(); let mut items : Vec<(Entity, String)> = Vec::new(); (&entities, &backpack).join() .filter(|item| item.1.owner == *player_entity ) .for_each(|item| { items.push((item.0, get_item_display_name(&gs.ecs, item.0))) }); let result = item_result_menu( &mut draw_batch, "Remove which item?", items.len(), &items, ctx.key ); draw_batch.submit(6000); result } }
Inventory Menu
再次,我们的 helper 大大简化了 inventory menu。 我们可以将 gui/inventory_menu.rs 替换为:
#![allow(unused)] fn main() { use rltk::prelude::*; use specs::prelude::*; use crate::{State, InBackpack }; use super::{get_item_display_name, item_result_menu}; #[derive(PartialEq, Copy, Clone)] pub enum ItemMenuResult { Cancel, NoResponse, Selected } pub fn show_inventory(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) { let player_entity = gs.ecs.fetch::<Entity>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let entities = gs.ecs.entities(); let mut draw_batch = DrawBatch::new(); let mut items : Vec<(Entity, String)> = Vec::new(); (&entities, &backpack).join() .filter(|item| item.1.owner == *player_entity ) .for_each(|item| { items.push((item.0, get_item_display_name(&gs.ecs, item.0))) }); let result = item_result_menu( &mut draw_batch, "Inventory", items.len(), &items, ctx.key ); draw_batch.submit(6000); result } }
Identify Menu
helper 在缩短 gui/identify_menu.rs 方面也有些用处 - 但复杂的 filter 仍然相当长。 将文件内容替换为以下内容:
#![allow(unused)] fn main() { use rltk::prelude::*; use specs::prelude::*; use crate::{Name, State, InBackpack, Equipped, MasterDungeonMap, Item, ObfuscatedName }; use super::{get_item_display_name, item_result_menu, ItemMenuResult}; pub fn identify_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) { let mut draw_batch = DrawBatch::new(); let player_entity = gs.ecs.fetch::<Entity>(); let equipped = gs.ecs.read_storage::<Equipped>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let entities = gs.ecs.entities(); let item_components = gs.ecs.read_storage::<Item>(); let names = gs.ecs.read_storage::<Name>(); let dm = gs.ecs.fetch::<MasterDungeonMap>(); let obfuscated = gs.ecs.read_storage::<ObfuscatedName>(); let mut items : Vec<(Entity, String)> = Vec::new(); (&entities, &item_components).join() .filter(|(item_entity,_item)| { let mut keep = false; if let Some(bp) = backpack.get(*item_entity) { if bp.owner == *player_entity { if let Some(name) = names.get(*item_entity) { if obfuscated.get(*item_entity).is_some() && !dm.identified_items.contains(&name.name) { keep = true; } } } } // It's equipped, so we know it's cursed // 它是已装备的,所以我们知道它被诅咒了 if let Some(equip) = equipped.get(*item_entity) { if equip.owner == *player_entity { if let Some(name) = names.get(*item_entity) { if obfuscated.get(*item_entity).is_some() && !dm.identified_items.contains(&name.name) { keep = true; } } } } keep }) .for_each(|item| { items.push((item.0, get_item_display_name(&gs.ecs, item.0))) }); let result = item_result_menu( &mut draw_batch, "Inventory", items.len(), &items, ctx.key ); draw_batch.submit(6000); result } }
Remove Curse Menu
remove curse menu 与 identification menu 非常相似,因此相同的原则适用。 将 gui/remove_curse_menu.rs 替换为:
#![allow(unused)] fn main() { use rltk::prelude::*; use specs::prelude::*; use crate::{Name, State, InBackpack, Equipped, MasterDungeonMap, CursedItem, Item }; use super::{get_item_display_name, item_result_menu, ItemMenuResult}; pub fn remove_curse_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) { let player_entity = gs.ecs.fetch::<Entity>(); let equipped = gs.ecs.read_storage::<Equipped>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let entities = gs.ecs.entities(); let item_components = gs.ecs.read_storage::<Item>(); let cursed = gs.ecs.read_storage::<CursedItem>(); let names = gs.ecs.read_storage::<Name>(); let dm = gs.ecs.fetch::<MasterDungeonMap>(); let mut draw_batch = DrawBatch::new(); let mut items : Vec<(Entity, String)> = Vec::new(); (&entities, &item_components, &cursed).join() .filter(|(item_entity,_item,_cursed)| { let mut keep = false; if let Some(bp) = backpack.get(*item_entity) { if bp.owner == *player_entity { if let Some(name) = names.get(*item_entity) { if dm.identified_items.contains(&name.name) { keep = true; } } } } // It's equipped, so we know it's cursed // 它是已装备的,所以我们知道它被诅咒了 if let Some(equip) = equipped.get(*item_entity) { if equip.owner == *player_entity { keep = true; } } keep }) .for_each(|item| { items.push((item.0, get_item_display_name(&gs.ecs, item.0))) }); let result = item_result_menu( &mut draw_batch, "Inventory", items.len(), &items, ctx.key ); draw_batch.submit(6000); result } }
Game Over Menu
game over menu 是一个简单的 ctx 到 DrawBatch 端口。 在 gui/game_over_menu.rs 中:
#![allow(unused)] fn main() { use rltk::prelude::*; #[derive(PartialEq, Copy, Clone)] pub enum GameOverResult { NoSelection, QuitToMenu } pub fn game_over(ctx : &mut Rltk) -> GameOverResult { let mut draw_batch = DrawBatch::new(); draw_batch.print_color_centered( 15, "Your journey has ended!", ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK)) ); draw_batch.print_color_centered( 17, "One day, we'll tell you all about how you did.", ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)) ); draw_batch.print_color_centered( 18, "That day, sadly, is not in this chapter..", ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)) ); draw_batch.print_color_centered( 19, &format!("You lived for {} turns.", crate::gamelog::get_event_count("Turn")), ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)) ); draw_batch.print_color_centered( 20, &format!("You suffered {} points of damage.", crate::gamelog::get_event_count("Damage Taken")), ColorPair::new(RGB::named(rltk::RED), RGB::named(rltk::BLACK)) ); draw_batch.print_color_centered( 21, &format!("You inflicted {} points of damage.", crate::gamelog::get_event_count("Damage Inflicted")), ColorPair::new(RGB::named(rltk::RED), RGB::named(rltk::BLACK))); draw_batch.print_color_centered( 23, "Press any key to return to the menu.", ColorPair::new(RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK)) ); draw_batch.submit(6000); match ctx.key { None => GameOverResult::NoSelection, Some(_) => GameOverResult::QuitToMenu } } }
Ranged Targeting Menu
gui/ranged_target.rs 是另一个简单的转换:
#![allow(unused)] fn main() { use rltk::prelude::*; use specs::prelude::*; use crate::{State, camera, Viewshed }; use super::ItemMenuResult; pub fn ranged_target(gs : &mut State, ctx : &mut Rltk, range : i32) -> (ItemMenuResult, Option<Point>) { let (min_x, max_x, min_y, max_y) = camera::get_screen_bounds(&gs.ecs, ctx); let player_entity = gs.ecs.fetch::<Entity>(); let player_pos = gs.ecs.fetch::<Point>(); let viewsheds = gs.ecs.read_storage::<Viewshed>(); let mut draw_batch = DrawBatch::new(); draw_batch.print_color( Point::new(5, 0), "Select Target:", ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK)) ); // Highlight available target cells // 高亮显示可用的目标单元格 let mut available_cells = Vec::new(); let visible = viewsheds.get(*player_entity); if let Some(visible) = visible { // We have a viewshed // 我们有一个视野 for idx in visible.visible_tiles.iter() { let distance = rltk::DistanceAlg::Pythagoras.distance2d(*player_pos, *idx); if distance <= range as f32 { let screen_x = idx.x - min_x; let screen_y = idx.y - min_y; if screen_x > 1 && screen_x < (max_x - min_x)-1 && screen_y > 1 && screen_y < (max_y - min_y)-1 { draw_batch.set_bg(Point::new(screen_x, screen_y), RGB::named(rltk::BLUE)); available_cells.push(idx); } } } } else { return (ItemMenuResult::Cancel, None); } // Draw mouse cursor // 绘制鼠标光标 let mouse_pos = ctx.mouse_pos(); let mut mouse_map_pos = mouse_pos; mouse_map_pos.0 += min_x - 1; mouse_map_pos.1 += min_y - 1; let mut valid_target = false; for idx in available_cells.iter() { if idx.x == mouse_map_pos.0 && idx.y == mouse_map_pos.1 { valid_target = true; } } if valid_target { draw_batch.set_bg(Point::new(mouse_pos.0, mouse_pos.1), RGB::named(rltk::CYAN)); if ctx.left_click { return (ItemMenuResult::Selected, Some(Point::new(mouse_map_pos.0, mouse_map_pos.1))); } } else { draw_batch.set_bg(Point::new(mouse_pos.0, mouse_pos.1), RGB::named(rltk::RED)); if ctx.left_click { return (ItemMenuResult::Cancel, None); } } draw_batch.submit(5000); (ItemMenuResult::NoResponse, None) } }
Main Menu
gui/main_menu.rs 文件是另一个简单的转换:
use rltk::prelude::*; use crate::{State, RunState, rex_assets::RexAssets }; #[derive(PartialEq, Copy, Clone)] pub enum MainMenuSelection { NewGame, LoadGame, Quit } #[derive(PartialEq, Copy, Clone)] pub enum MainMenuResult { NoSelection{ selected : MainMenuSelection }, Selected{ selected: MainMenuSelection } } pub fn main_menu(gs : &mut State, ctx : &mut Rltk) -> MainMenuResult { let mut draw_batch = DrawBatch::new(); let save_exists = crate::saveload_system::does_save_exist(); let runstate = gs.ecs.fetch::<RunState>(); let assets = gs.ecs.fetch::<RexAssets>(); ctx.render_xp_sprite(&assets.menu, 0, 0); draw_batch.draw_double_box(Rect::with_size(24, 18, 31, 10), ColorPair::new(RGB::named(rltk::WHEAT), RGB::named(rltk::BLACK))); draw_batch.print_color_centered(20, "Rust Roguelike Tutorial", ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK))); draw_batch.print_color_centered(21, "by Herbert Wolverson", ColorPair::new(RGB::named(rltk::CYAN), RGB::named(rltk::BLACK))); draw_batch.print_color_centered(22, "Use Up/Down Arrows and Enter", ColorPair::new(RGB::named(rltk::GRAY), RGB::named(rltk::BLACK))); let mut y = 24; if let RunState::MainMenu{ menu_selection : selection } = *runstate { if selection == MainMenuSelection::NewGame { draw_batch.print_color_centered(y, "Begin New Game", ColorPair::new(RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK))); } else { draw_batch.print_color_centered(y, "Begin New Game", ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK))); } y += 1; if save_exists { if selection == MainMenuSelection::LoadGame { draw_batch.print_color_centered(y, "Load Game", ColorPair::new(RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK))); } else { draw_batch.print_color_centered(y, "Load Game", ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK))); } y += 1; } if selection == MainMenuSelection::Quit { draw_batch.print_color_centered(y, "Quit", ColorPair::new(RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK))); } else { draw_batch.print_color_centered(y, "Quit", ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK))); } draw_batch.submit(6000); match ctx.key { None => return MainMenuResult::NoSelection{ selected: selection }, Some(key) => { match key { VirtualKeyCode::Escape => { return MainMenuResult::NoSelection{ selected: MainMenuSelection::Quit } } VirtualKeyCode::Up => { let mut newselection; match selection { MainMenuSelection::NewGame => newselection = MainMenuSelection::Quit, MainMenuSelection::LoadGame => newselection = MainMenuSelection::NewGame, MainMenuSelection::Quit => newselection = MainMenuSelection::LoadGame } if newselection == MainMenuSelection::LoadGame && !save_exists { newselection = MainMenuSelection::NewGame; } return MainMenuResult::NoSelection{ selected: newselection } } VirtualKeyCode::Down => { let mut newselection; match selection { MainMenuSelection::NewGame => newselection = MainMenuSelection::LoadGame, MainMenuSelection::LoadGame => newselection = MainMenuSelection::Quit, MainMenuSelection::Quit => newselection = MainMenuSelection::NewGame } if newselection == MainMenuSelection::LoadGame && !save_exists { newselection = MainMenuSelection::Quit; } return MainMenuResult::NoSelection{ selected: newselection } } VirtualKeyCode::Return => return MainMenuResult::Selected{ selected : selection }, _ => return MainMenuResult::NoSelection{ selected: selection } } } } } MainMenuResult::NoSelection { selected: MainMenuSelection::NewGame } }
Vendor Menus
vendor menu system 需要做更多的工作,但不多。 我们的 helper 在这里不是很有用:
#![allow(unused)] fn main() { use rltk::prelude::*; use specs::prelude::*; use crate::{Name, State, InBackpack, VendorMode, Vendor, Item }; use super::{get_item_display_name, get_item_color, menu_box}; #[derive(PartialEq, Copy, Clone)] pub enum VendorResult { NoResponse, Cancel, Sell, BuyMode, SellMode, Buy } fn vendor_sell_menu(gs : &mut State, ctx : &mut Rltk, _vendor : Entity, _mode : VendorMode) -> (VendorResult, Option<Entity>, Option<String>, Option<f32>) { let mut draw_batch = DrawBatch::new(); let player_entity = gs.ecs.fetch::<Entity>(); let names = gs.ecs.read_storage::<Name>(); let backpack = gs.ecs.read_storage::<InBackpack>(); let items = gs.ecs.read_storage::<Item>(); let entities = gs.ecs.entities(); let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity ); let count = inventory.count(); let mut y = (25 - (count / 2)) as i32; menu_box(&mut draw_batch, 15, y, (count+3) as i32, "Sell Which Item? (space to switch to buy mode)"); draw_batch.print_color( Point::new(18, y+count as i32+1), "ESCAPE to cancel", ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK)) ); let mut equippable : Vec<Entity> = Vec::new(); let mut j = 0; for (entity, _pack, item) in (&entities, &backpack, &items).join().filter(|item| item.1.owner == *player_entity ) { draw_batch.set(Point::new(17, y), ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)), rltk::to_cp437('(')); draw_batch.set(Point::new(18, y), ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK)), 97+j as rltk::FontCharType); draw_batch.set(Point::new(19, y), ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)), rltk::to_cp437(')')); draw_batch.print_color( Point::new(21, y), &get_item_display_name(&gs.ecs, entity), ColorPair::new(get_item_color(&gs.ecs, entity), RGB::from_f32(0.0, 0.0, 0.0)) ); draw_batch.print(Point::new(50, y), &format!("{:.1} gp", item.base_value * 0.8)); equippable.push(entity); y += 1; j += 1; } draw_batch.submit(6000); match ctx.key { None => (VendorResult::NoResponse, None, None, None), Some(key) => { match key { VirtualKeyCode::Space => { (VendorResult::BuyMode, None, None, None) } VirtualKeyCode::Escape => { (VendorResult::Cancel, None, None, None) } _ => { let selection = rltk::letter_to_option(key); if selection > -1 && selection < count as i32 { return (VendorResult::Sell, Some(equippable[selection as usize]), None, None); } (VendorResult::NoResponse, None, None, None) } } } } } fn vendor_buy_menu(gs : &mut State, ctx : &mut Rltk, vendor : Entity, _mode : VendorMode) -> (VendorResult, Option<Entity>, Option<String>, Option<f32>) { use crate::raws::*; let mut draw_batch = DrawBatch::new(); let vendors = gs.ecs.read_storage::<Vendor>(); let inventory = crate::raws::get_vendor_items(&vendors.get(vendor).unwrap().categories, &RAWS.lock().unwrap()); let count = inventory.len(); let mut y = (25 - (count / 2)) as i32; menu_box(&mut draw_batch, 15, y, (count+3) as i32, "Buy Which Item? (space to switch to sell mode)"); draw_batch.print_color( Point::new(18, y+count as i32+1), "ESCAPE to cancel", ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK)) ); for (j,sale) in inventory.iter().enumerate() { draw_batch.set(Point::new(17, y), ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)), rltk::to_cp437('(')); draw_batch.set(Point::new(18, y), ColorPair::new(RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK)), 97+j as rltk::FontCharType); draw_batch.set(Point::new(19, y), ColorPair::new(RGB::named(rltk::WHITE), RGB::named(rltk::BLACK)), rltk::to_cp437(')')); draw_batch.print(Point::new(21, y), &sale.0); draw_batch.print(Point::new(50, y), &format!("{:.1} gp", sale.1 * 1.2)); y += 1; } draw_batch.submit(6000); match ctx.key { None => (VendorResult::NoResponse, None, None, None), Some(key) => { match key { VirtualKeyCode::Space => { (VendorResult::SellMode, None, None, None) } VirtualKeyCode::Escape => { (VendorResult::Cancel, None, None, None) } _ => { let selection = rltk::letter_to_option(key); if selection > -1 && selection < count as i32 { return (VendorResult::Buy, None, Some(inventory[selection as usize].0.clone()), Some(inventory[selection as usize].1)); } (VendorResult::NoResponse, None, None, None) } } } } } pub fn show_vendor_menu(gs : &mut State, ctx : &mut Rltk, vendor : Entity, mode : VendorMode) -> (VendorResult, Option<Entity>, Option<String>, Option<f32>) { match mode { VendorMode::Buy => vendor_buy_menu(gs, ctx, vendor, mode), VendorMode::Sell => vendor_sell_menu(gs, ctx, vendor, mode) } } }
Tooltips
gui/tooltip.rs 相对来说也很容易:
#![allow(unused)] fn main() { use rltk::prelude::*; use specs::prelude::*; use crate::{Pools, Map, Name, Hidden, camera, Attributes, StatusEffect, Duration }; use super::get_item_display_name; struct Tooltip { lines : Vec<String> } impl Tooltip { fn new() -> Tooltip { Tooltip { lines : Vec::new() } } fn add<S:ToString>(&mut self, line : S) { self.lines.push(line.to_string()); } fn width(&self) -> i32 { let mut max = 0; for s in self.lines.iter() { if s.len() > max { max = s.len(); } } max as i32 + 2i32 } fn height(&self) -> i32 { self.lines.len() as i32 + 2i32 } fn render(&self, draw_batch : &mut DrawBatch, x : i32, y : i32) { let box_gray : RGB = RGB::from_hex("#999999").expect("Oops"); let light_gray : RGB = RGB::from_hex("#DDDDDD").expect("Oops"); let white = RGB::named(rltk::WHITE); let black = RGB::named(rltk::BLACK); draw_batch.draw_box(Rect::with_size(x, y, self.width()-1, self.height()-1), ColorPair::new(white, box_gray)); for (i,s) in self.lines.iter().enumerate() { let col = if i == 0 { white } else { light_gray }; draw_batch.print_color(Point::new(x+1, y+i as i32+1), &s, ColorPair::new(col, black)); } } } pub fn draw_tooltips(ecs: &World, ctx : &mut Rltk) { let mut draw_batch = DrawBatch::new(); let (min_x, _max_x, min_y, _max_y) = camera::get_screen_bounds(ecs, ctx); let map = ecs.fetch::<Map>(); let hidden = ecs.read_storage::<Hidden>(); let attributes = ecs.read_storage::<Attributes>(); let pools = ecs.read_storage::<Pools>(); let mouse_pos = ctx.mouse_pos(); let mut mouse_map_pos = mouse_pos; mouse_map_pos.0 += min_x - 1; mouse_map_pos.1 += min_y - 1; if mouse_pos.0 < 1 || mouse_pos.0 > 49 || mouse_pos.1 < 1 || mouse_pos.1 > 40 { return; } if mouse_map_pos.0 >= map.width-1 || mouse_map_pos.1 >= map.height-1 || mouse_map_pos.0 < 1 || mouse_map_pos.1 < 1 { return; } if !map.in_bounds(rltk::Point::new(mouse_map_pos.0, mouse_map_pos.1)) { return; } let mouse_idx = map.xy_idx(mouse_map_pos.0, mouse_map_pos.1); if !map.visible_tiles[mouse_idx] { return; } let mut tip_boxes : Vec<Tooltip> = Vec::new(); for entity in map.tile_content[mouse_idx].iter().filter(|e| hidden.get(**e).is_none()) { let mut tip = Tooltip::new(); tip.add(get_item_display_name(ecs, *entity)); // Comment on attributes // 属性注释 let attr = attributes.get(*entity); if let Some(attr) = attr { let mut s = "".to_string(); if attr.might.bonus < 0 { s += "Weak. " }; if attr.might.bonus > 0 { s += "Strong. " }; if attr.quickness.bonus < 0 { s += "Clumsy. " }; if attr.quickness.bonus > 0 { s += "Agile. " }; if attr.fitness.bonus < 0 { s += "Unheathy. " }; if attr.fitness.bonus > 0 { s += "Healthy." }; if attr.intelligence.bonus < 0 { s += "Unintelligent. "}; if attr.intelligence.bonus > 0 { s += "Smart. "}; if s.is_empty() { s = "Quite Average".to_string(); } tip.add(s); } // Comment on pools // 池注释 let stat = pools.get(*entity); if let Some(stat) = stat { tip.add(format!("Level: {}", stat.level)); } // Status effects // 状态效果 let statuses = ecs.read_storage::<StatusEffect>(); let durations = ecs.read_storage::<Duration>(); let names = ecs.read_storage::<Name>(); for (status, duration, name) in (&statuses, &durations, &names).join() { if status.target == *entity { tip.add(format!("{} ({})", name.name, duration.turns)); } } tip_boxes.push(tip); } if tip_boxes.is_empty() { return; } let box_gray : RGB = RGB::from_hex("#999999").expect("Oops"); let white = RGB::named(rltk::WHITE); let arrow; let arrow_x; let arrow_y = mouse_pos.1; if mouse_pos.0 < 40 { // Render to the left // 渲染到左边 arrow = to_cp437('→'); arrow_x = mouse_pos.0 - 1; } else { // Render to the right // 渲染到右边 arrow = to_cp437('←'); arrow_x = mouse_pos.0 + 1; } draw_batch.set(Point::new(arrow_x, arrow_y), ColorPair::new(white, box_gray), arrow); let mut total_height = 0; for tt in tip_boxes.iter() { total_height += tt.height(); } let mut y = mouse_pos.1 - (total_height / 2); while y + (total_height/2) > 50 { y -= 1; } for tt in tip_boxes.iter() { let x = if mouse_pos.0 < 40 { mouse_pos.0 - (1 + tt.width()) } else { mouse_pos.0 + (1 + tt.width()) }; tt.render(&mut draw_batch, x, y); y += tt.height(); } draw_batch.submit(7000); } }
总结
本章有点痛苦,但是我们已经让我们的渲染使用了新的批量处理 system - 并且很好地渲染了更大的日志文本。 我们将在以后的章节中以此为基础,当我们处理 system(和并发)时。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson.
系统扫描
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢本教程并希望我继续写作,请考虑支持我的 Patreon。
Specs 提供了一个非常棒的 dispatcher(调度器)系统:它可以自动利用并发,让你的游戏运行如飞。那么为什么我们没有使用它呢?Web Assembly!WASM 不支持像其他平台那样的线程,而且一个使用 dispatcher 编译到 WASM 的 Specs 应用程序会在第一次尝试调度系统时就崩溃。让桌面应用程序也因此受罪是不公平的。此外,当前的 run_systems 函数看起来一点都不友好:
#![allow(unused)] fn main() { fn run_systems(&mut self) { let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); ... // 还有很多系统 }
所以本章的目标是构建一个接口,它可以检测 WASM,并回退到单线程 dispatcher。如果不是 WASM 环境,我们希望使用 Specs 的 dispatcher。我们也希望系统接口更友好一些 - 并且不必多次指定系统。
启动 Systems 模块
首先,我们将创建一个新目录:src/systems。这将用于存放独立的 系统 设置,但现在我们将使用它来开始构建一个可以处理在原生使用的 Specs dispatcher 和 WASM 中的单线程调用器之间切换的设置。在新的 src/systems 目录中,创建一个文件:mod.rs。现在你可以让它保持为空。
警告:这里有一些稍微高级的宏和配置。你可以随意使用它,如果需要,稍后再学习它是如何工作的。我们已经进行了 73 章,所以我希望我们已经准备好了!
再创建一个新目录:src/systems/dispatcher。在该文件夹中,放置另一个空的 mod.rs 文件。
现在转到 main.rs 并添加一行 mod systems;:这是为了将其包含在编译中(我们稍后会处理整洁的用法)。修改 src/systems/mod.rs 以包含行 mod dispatcher - 同样,这只是为了确保它被编译。
通用化 Dispatch
根据我们的规范/想法,我们知道我们需要一种通用的方式来运行系统 - 并且不必关心哪个底层设置处于活动状态(从编程的角度来看)。这听起来像是 trait(特征)的工作 - trait 在 Rust 中是(在其他方面)对多态、继承(有点像)和接口的答案。将以下内容添加到 src/systems/dispatcher/mod.rs:
#![allow(unused)] fn main() { use specs::prelude::World; use super::*; pub trait UnifiedDispatcher { fn run_now(&mut self, ecs : *mut World); } }
这指定了我们的 UnifiedDispatcher trait 将提供一个名为 run_now 的方法,该方法接受自身(用于状态)和 ECS 作为可变参数。
单线程 Dispatch
我们将从简单的例子开始。添加一个新文件,src/systems/dispatcher/single_thread.rs。在 dispatcher/mod.rs 中,添加行 mod single_thread; pub use single_thread::*;。
在 single_thread.rs 内部,我们将需要一些库的支持 - 所以首先导入以下内容:
#![allow(unused)] fn main() { use super::super::*; use super::UnifiedDispatcher; use specs::prelude::*; }
接下来,我们需要一个地方来存储我们的系统。我们将以 Specs 风格传递可运行的目标(见下文),但对于单线程执行,我们的目标是按照传递的顺序运行它们。与之前的 run_now 不同,我们将提前创建系统,然后迭代/执行它们 - 而不是每次都重新创建它们。让我们从结构定义开始:
#![allow(unused)] fn main() { pub struct SingleThreadedDispatcher<'a> { pub systems : Vec<Box<dyn RunNow<'a>>> } }
这里为生命周期增加了一些复杂性(我们将来自 Specs 的 RunNow trait 与结构体放在同一个生命周期中),但这很简单:每个使用 Specs 系统功能的系统都实现了 RunNow trait。因此,我们只是存储一个 boxed(装箱,因为它们的大小各不相同,我们必须使用指针间接寻址)RunNow trait 的 vector(向量)。
实际执行它们有点困难。以下方法有效:
#![allow(unused)] fn main() { impl<'a> UnifiedDispatcher for SingleThreadedDispatcher<'a> { fn run_now(&mut self, ecs : *mut World) { unsafe { for sys in self.systems.iter_mut() { sys.run_now(&*ecs); } crate::effects::run_effects_queue(&mut *ecs); } } } }
可能有更好的方法来编写这个,但我一直遇到生命周期问题。World 和系统都倾向于有效地 'static - 它们与程序的生命周期一样长。说服 Rust 相信这一点让我头疼了一整天,直到我最终决定只使用 unsafe 并相信自己会做正确的事情!
请注意,我们将 World 作为 可变指针,而不是常规的可变引用。解引用可变指针本质上是不安全的:Rust 不能确定你不会破坏生命周期保证。因此,unsafe 代码块在那里允许我们这样做。由于我们添加系统并且从不删除它们,并且在没有工作的 World 的情况下调用系统无论如何都会崩溃 - 我们可以侥幸逃脱。(如果有人想给我一个安全的实现,我将很乐意使用它!)。该函数只是迭代所有系统,并执行它们 - 并在最后运行效果队列。
所以那是相对容易的部分。困难 的部分是我们想要获取 Specs 风格的 dispatcher 调用 - 并将其转换为有用的系统数据。我们还希望以一种适用于 任何一种 dispatching 类型的方式来完成它,并且我们不想多次声明我们的系统。在挠头思考了一段时间后,我想出了一个 宏,它可以生成一个函数:
#![allow(unused)] fn main() { macro_rules! construct_dispatcher { ( $( ( $type:ident, $name:expr, $deps:expr ) ),* ) => { fn new_dispatch() -> Box<dyn UnifiedDispatcher + 'static> { let mut dispatch = SingleThreadedDispatcher{ systems : Vec::new() }; $( dispatch.systems.push( Box::new( $type {} )); )* return Box::new(dispatch); } }; } }
宏总是很难教授的;如果你不小心,它们就会开始看起来像 Perl。它们与其说是生成 代码,不如说是生成 语法 - 然后在编译期间“烹饪”成代码。看看 Specs 如何构建系统,每个系统都有一行这样的代码:
#![allow(unused)] fn main() { .with(HelloWorld, "hello_world", &[]) }
所以我们为每个系统指定了三个数据片段:系统 类型、名称和一个字符串数组,用于指定依赖项。对于单线程使用,我们实际上会忽略最后两个(并相信用户会按正确的顺序输入系统)。将此映射到宏的参数部分,我们有:
#![allow(unused)] fn main() { macro_rules! construct_dispatcher { ( $( ( $type:ident, $name:expr, $deps:expr ) ),* ) => { }
$(...),* 表示“重复此代码块的内容,0..n 次。然后这三个参数在括号内 - 使它们成为一个 元组。$type 是系统的类型 - 并且是一个 标识符(而不是纯粹的类型)。$name 和 $deps 只是表达式。
在宏的主体中:
#![allow(unused)] fn main() { ) => { fn new_dispatch() -> Box<dyn UnifiedDispatcher + 'static> { let mut dispatch = SingleThreadedDispatcher{ systems : Vec::new() }; $( dispatch.systems.push( Box::new( $type {} )); )* return Box::new(dispatch); } }; }
我们定义了一个新函数,名为 new_dispatch。它返回一个 boxed、动态且 'static 的 UnifiedDispatcher。(宏在您运行它之前不会定义该函数!)。它首先创建一个新的 SingleThreadedDispatcher 实例,其中包含一个空的系统 vector。然后它迭代每个 元组,将一个空系统推入 vector。最后,它返回该结构 - 并在其周围放置一个 box。
我们实际上还没有 创建 函数 - 我们只是教会了 Rust 如何做到这一点。所以在 src/systems/dispatch/mod.rs 中,我们需要定义它,以及它需要使用的系统:
#![allow(unused)] fn main() { #[macro_use] mod single_thread; pub use single_thread::*; construct_dispatcher!( (MapIndexingSystem, "map_index", &[]), (VisibilitySystem, "visibility", &[]), (EncumbranceSystem, "encumbrance", &[]), (InitiativeSystem, "initiative", &[]), (TurnStatusSystem, "turnstatus", &[]), (QuipSystem, "quips", &[]), (AdjacentAI, "adjacent", &[]), (VisibleAI, "visible", &[]), (ApproachAI, "approach", &[]), (FleeAI, "flee", &[]), (ChaseAI, "chase", &[]), (DefaultMoveAI, "default_move", &[]), (MovementSystem, "movement", &[]), (TriggerSystem, "triggers", &[]), (MeleeCombatSystem, "melee", &[]), (RangedCombatSystem, "ranged", &[]), (ItemCollectionSystem, "pickup", &[]), (ItemEquipOnUse, "equip", &[]), (ItemUseSystem, "use", &[]), (SpellUseSystem, "spells", &[]), (ItemIdentificationSystem, "itemid", &[]), (ItemDropSystem, "drop", &[]), (ItemRemoveSystem, "remove", &[]), (HungerSystem, "hunger", &[]), (ParticleSpawnSystem, "particle_spawn", &[]), (LightingSystem, "lighting", &[]) ); pub fn new() -> Box<dyn UnifiedDispatcher + 'static> { new_dispatch() } }
这定义了一个 new() 函数,它只是传递调用 new_dispatch 的结果。对 construct_dispatcher! 的宏调用 创建 了这个函数 - 包含了所有系统定义(我包含了所有系统)。
移动我们的系统
为了方便访问,我已经将 所有 我们的系统(仅限 Specs 系统)移动到了新的 systems 模块中。你可以在 源代码 中查看实现细节。移动它们实际上非常简单:
- 将系统(或系统文件夹)移动到
systems中。 - 从
main.rs中删除正在查找它的mod和use语句。 - 在系统中,将
use super::替换为use crate::。 - 调整
src/systems/mod.rs以编译 (mod) 和use系统。
完成后的 src/systems/mod.rs 看起来像这样。请注意,我们添加了一个易于访问的 new 函数来获取新的系统 dispatcher:
#![allow(unused)] fn main() { mod dispatcher; pub use dispatcher::UnifiedDispatcher; // 系统导入 mod map_indexing_system; use map_indexing_system::MapIndexingSystem; mod visibility_system; use visibility_system::VisibilitySystem; mod ai; use ai::*; mod movement_system; use movement_system::MovementSystem; mod trigger_system; use trigger_system::TriggerSystem; mod melee_combat_system; use melee_combat_system::MeleeCombatSystem; mod ranged_combat_system; use ranged_combat_system::RangedCombatSystem; mod inventory_system; use inventory_system::*; mod hunger_system; use hunger_system::HungerSystem; pub mod particle_system; use particle_system::ParticleSpawnSystem; mod lighting_system; use lighting_system::LightingSystem; pub fn build() -> Box<dyn UnifiedDispatcher + 'static> { dispatcher::new() } }
particle_system 有点不同,因为它有 其他 函数在其他地方使用。你需要找到这些函数,并将它们的路径调整为 crate::systems::particle_system::。
现在打开 main.rs,并将新的 dispatcher 添加到 State:
#![allow(unused)] fn main() { pub struct State { pub ecs: World, mapgen_next_state : Option<RunState>, mapgen_history : Vec<Map>, mapgen_index : usize, mapgen_timer : f32, dispatcher : Box<dyn systems::UnifiedDispatcher + 'static> } }
State 的初始化器更改为(在 fn main() 中):
#![allow(unused)] fn main() { let mut gs = State { ecs: World::new(), mapgen_next_state : Some(RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame }), mapgen_index : 0, mapgen_history: Vec::new(), mapgen_timer: 0.0, dispatcher: systems::build() }; }
我们现在可以 大幅度 简化我们的 run_systems 函数:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { self.dispatcher.run_now(&mut self.ecs); self.ecs.maintain(); } } }
如果你现在 cargo run 项目,它将像以前一样运行:但系统执行现在更直接了。它甚至可能更快一点,因为我们没有在每次执行时都重新创建系统。
多线程 Dispatch
我们通过单线程 dispatcher 获得了一些清晰度和组织性,但我们尚未释放 Specs 的力量!创建一个新文件,src/systems/dispatcher/multi_thread.rs。
我们将首先创建一个新结构来保存 Specs dispatcher,并包含一些引用:
#![allow(unused)] fn main() { use super::UnifiedDispatcher; use specs::prelude::*; pub struct MultiThreadedDispatcher { pub dispatcher: specs::Dispatcher<'static, 'static> } }
我们还需要实现 run_now(使用我们创建的 UnifiedDispatcher trait):
#![allow(unused)] fn main() { impl<'a> UnifiedDispatcher for MultiThreadedDispatcher { fn run_now(&mut self, ecs : *mut World) { unsafe { self.dispatcher.dispatch(&mut *ecs); crate::effects::run_effects_queue(&mut *ecs); } } } }
这非常简单:它只是告诉 Specs “dispatch” 我们正在存储的 dispatcher,然后执行效果队列。
再一次,我们需要一个宏来处理输入:
#![allow(unused)] fn main() { macro_rules! construct_dispatcher { ( $( ( $type:ident, $name:expr, $deps:expr ) ),* ) => { fn new_dispatch() -> Box<dyn UnifiedDispatcher + 'static> { use specs::DispatcherBuilder; let dispatcher = DispatcherBuilder::new() $( .with($type{}, $name, $deps) )* .build(); let dispatch = MultiThreadedDispatcher{ dispatcher : dispatcher }; return Box::new(dispatch); } }; } }
这与单线程版本采用 完全相同 的输入。这是有意的:它们被设计为可互换的。它还创建了一个 new_dispatch 函数,具有相同的返回类型。它从 Specs 调用 DispatcherBuilder::new,然后迭代宏参数,为每组系统数据添加一个 .with(...) 行。最后,它调用 .build 并将其存储在 MultiThreadedDispatcher 结构中 - 并在一个 box 中返回自身。
这实际上非常简单,但留下了一个大问题:我们如何知道我们将使用哪个 dispatcher 呢?我们几乎只希望在 WASM32 中使用单线程版本,否则我们希望利用 Specs 的线程和效率。因此,我们修改 src/systems/dispatcher/mod.rs 以包含 条件编译:
#![allow(unused)] fn main() { #[cfg(target_arch = "wasm32")] #[macro_use] mod single_thread; #[cfg(not(target_arch = "wasm32"))] #[macro_use] mod multi_thread; #[cfg(target_arch = "wasm32")] pub use single_thread::*; #[cfg(not(target_arch = "wasm32"))] pub use multi_thread::*; use specs::prelude::World; use super::*; pub trait UnifiedDispatcher { fn run_now(&mut self, ecs : *mut World); } construct_dispatcher!( (MapIndexingSystem, "map_index", &[]), (VisibilitySystem, "visibility", &[]), (EncumbranceSystem, "encumbrance", &[]), (InitiativeSystem, "initiative", &[]), (TurnStatusSystem, "turnstatus", &[]), (QuipSystem, "quips", &[]), (AdjacentAI, "adjacent", &[]), (VisibleAI, "visible", &[]), (ApproachAI, "approach", &[]), (FleeAI, "flee", &[]), (ChaseAI, "chase", &[]), (DefaultMoveAI, "default_move", &[]), (MovementSystem, "movement", &[]), (TriggerSystem, "triggers", &[]), (MeleeCombatSystem, "melee", &[]), (RangedCombatSystem, "ranged", &[]), (ItemCollectionSystem, "pickup", &[]), (ItemEquipOnUse, "equip", &[]), (ItemUseSystem, "use", &[]), (SpellUseSystem, "spells", &[]), (ItemIdentificationSystem, "itemid", &[]), (ItemDropSystem, "drop", &[]), (ItemRemoveSystem, "remove", &[]), (HungerSystem, "hunger", &[]), (ParticleSpawnSystem, "particle_spawn", &[]), (LightingSystem, "lighting", &[]) ); pub fn new() -> Box<dyn UnifiedDispatcher + 'static> { new_dispatch() } }
单线程导入以条件编译标记为前缀:
#![allow(unused)] fn main() { #[cfg(target_arch = "wasm32")] }
这表示“仅当目标架构为 wasm32 时才编译随附的行”。
同样,多线程版本具有相反的标记:
#![allow(unused)] fn main() { #[cfg(not(target_arch = "wasm32"))] }
我们对 dispatcher 中的 mod 和 use 语句重复这些标记。因此,如果你运行 WASM,你将获得 #[macro_use] mod single_thread; use single_thread::*;。如果你在本地运行,你将获得 #[macro_use] mod multi_thread; use multi_thread::*;。Rust 不会编译未包含 mod 语句的模块:因此我们只构建 一种 dispatcher 策略。由于我们随后使用了它,宏 construct_dispatcher! 被放置到我们的本地 (systems::dispatcher) 命名空间中 - 因此我们对宏的调用运行了我们连接到的任何版本。
这是 编译时 dispatch,并且是一个非常强大的设置。RLTK 在内部大量使用它来自定义自身以适应各种硬件后端。
所以,如果你现在 cargo run 你的项目 - 游戏将使用 Specs dispatcher 运行。如果你启动系统监视器,你可以看到它正在使用多个线程!
为什么当我们添加线程时它没有崩溃?
一个常见的说法是“我有一个 bug。我添加了 8 个线程,现在我有 8 个 bug。” 这可能是非常真实的,但 Rust 非常努力地推广“无畏并发”。Rust 本身可以防止 数据竞争 - 不允许两个系统同时访问/更改相同的数据,这是某些其他语言中 bug 的常见来源。但是,它不能防止逻辑问题,例如系统需要来自先前系统的信息,但该系统尚未运行。
Specs 代表你将安全性更进一步。这是我们地图索引系统的 SystemData 定义:
#![allow(unused)] fn main() { WriteExpect<'a, Map>, ReadStorage<'a, Position>, ReadStorage<'a, BlocksTile>, ReadStorage<'a, TileSize>, Entities<'a> }
还记得我们如何煞费苦心地指定我们是想要对资源和组件的 Write(写入)访问还是 Read(读取)访问吗?Specs 实际上使用此信息进行调度。当它构建 dispatcher 时,它会查找 Write 访问 - 并确保没有两个系统可以同时对相同的数据具有写入访问权限。它还确保在数据被锁定以进行写入时不会发生读取数据。但是,系统可以并发地 读取 数据。因此,在这种情况下,Specs 保证任何需要 读取 地图的内容都将等到 MapIndexingSystem 完成对其的 写入 操作。
这具有构建依赖链的效果 - 并按逻辑顺序排列系统。作为一个简短的示例:
MapIndexingSystem写入地图,并读取Position、BlocksTile和TileSize。VisibilitySystem写入地图、Viewshed、Hidden和RandomNumberGenerator。它读取Position、Name和BlocksVisibility。EncumbranceSystem写入EquipmentChanged、Pools、Attributes,并从Item、InBackpack、Equipped、Entity、AttributeBonus、StatusEffect和Slow读取。InitiativeSystem写入Initiative、MyTurn、RandomNumberGenerator、RunState、Duration、EquipmentChanged,并从Position、Attributes、Entity、Point、Pools、StatusEffect、DamageOverTime读取。TurnStatusSystem写入MyTurn,并从Confusion、RunState、StatusEffect读取。
我们可以继续枚举所有这些系统,但这已经是一个很好的说明。由此,我们可以确定:
MapIndexingSystem锁定地图,因此它不能与VisibilitySystem并发运行。由于MapIndexingSystem首先定义,因此它将首先运行。VisibilitySystem锁定地图、视野、隐藏和 RNG。因此它必须等待 Visibility 系统。EncumbranceSystem锁定 RNG,因此它必须等待VisibilitySystem完成。InitiativeSystem也锁定 RNG,因此它必须等待 Encumbrance 完成。TurnStatusSystem锁定MyTurn-InitiativeSystem也是如此。因此它必须等待该系统完成。
换句话说:我们还没有真正进行那么多多线程处理!我们正在通过使用 Specs 的 dispatcher 来获得效率提升 - 因此我们获得了一些好处(在我的本地计算机上,在调试模式下感觉肯定更快了!)。
量化“感觉更快”
让我们在屏幕上添加一个帧率指示器,以便我们 知道 我们所做的事情是否有帮助。我们将使其成为可选的,作为编译时标志(很像地图调试显示)。在 main.rs 中的地图标志旁边,添加:
#![allow(unused)] fn main() { const SHOW_FPS : bool = true; }
在 tick 的末尾,当你提交渲染批次时 - 添加以下行:
#![allow(unused)] fn main() { if SHOW_FPS { ctx.print(1, 59, &format!("FPS: {}", ctx.fps)); } }
如果你现在 cargo run,你将在屏幕底部看到一个 FPS 计数器。在我的系统上,它几乎总是显示 60。如果你想看看它 能 有多快,我们需要关闭 vsync(垂直同步)。这很容易在 main 函数中更改 RLTK 初始化:
#![allow(unused)] fn main() { let mut context = RltkBuilder::simple(80, 60) .with_title("Roguelike Tutorial") .with_font("vga8x16.png", 8, 16) .with_sparse_console(80, 30, "vga8x16.png") .with_vsync(false) .build(); }
现在它显示我的帧率在 200 区域!
线程化 RNG
RandomNumberGenerator 作为一个可写资源是我们系统中无法实现并发的最大原因。它在各处都被使用,系统必须相互等待才能生成随机数。我们 可以 在需要随机数时简单地使用本地 RNG - 但这样我们就会失去设置 随机种子 的能力(更多内容将在以后的章节中介绍!)。相反,我们将创建一个 全局 随机数生成器,并使用 mutex(互斥锁)保护它 - 这样程序就可以从同一个来源获取随机数。
让我们创建一个新文件,src/rng.rs。在 main.rs 中添加 pub mod rng;,我们将把随机数包装器作为 lazy_static 来充实。我们将使用 Mutex 保护它,以便可以从多个线程安全地使用它:
#![allow(unused)] fn main() { use std::sync::Mutex; use rltk::prelude::*; lazy_static! { static ref RNG: Mutex<RandomNumberGenerator> = Mutex::new(RandomNumberGenerator::new()); } pub fn reseed(seed: u64) { *RNG.lock().unwrap() = RandomNumberGenerator::seeded(seed); } pub fn roll_dice(n:i32, die_type: i32) -> i32 { RNG.lock().unwrap().roll_dice(n, die_type) } pub fn range(min: i32, max: i32) -> i32 { RNG.lock().unwrap().range(min, max) } }
现在转到 main.rs 的 main 函数,并删除以下行:
#![allow(unused)] fn main() { gs.ecs.insert(rltk::RandomNumberGenerator::new()); }
现在,每当游戏尝试访问 RNG 资源时都会崩溃!因此,我们需要搜索整个程序,找到我们使用 RNG 的地方 - 并将它们全部替换为 crate::rng::roll_dice 或 crate::rng::range。否则,语法保持不变。这是一个 很大的 更改,但大部分代码都相同。请参阅 源代码 以获取工作版本。(一个不错的副作用是,我们不再在地图构建器中到处传递 rng;它们变得更简洁了!)
随着该依赖项的解决,我们现在能够以更高的并发性运行。你的 FPS 应该有所提高,如果你在进程监视器中观察,我们会更多地使用线程。
总结
本章极大地清理了我们的系统处理。它更快、更精简、更好看 - 以几个 unsafe 代码块(管理良好)和一个令人讨厌的宏为代价。我们还使 RNG 成为全局的,但安全地将其包装在 mutex 中。结果呢?游戏在我的系统上以发布模式以 1,300 FPS 运行,并且现在受益于 Specs 惊人的线程功能。即使在单线程模式下,它也能以不错的 1,100 FPS 运行(在我的系统上:core i7)。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
城市一夜
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
游戏的下一关是一个黑暗精灵城市。设计文档在细节上有些简略,但这是我们所知道的:
- 它最终通向一个前往深渊的传送门。
- 黑暗精灵天生好斗,尔虞我诈,应该表现得像那样。
- 黑暗精灵城市出奇地像城市,只是位于地下深处。
- 光照将非常重要。
生成一个基础城市
map_builders/mod.rs 中的 level_builder 函数控制为给定关卡调用哪个地图算法。为新的地图类型添加一个占位符条目:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, width, height), 2 => forest_builder(new_depth, width, height), 3 => limestone_cavern_builder(new_depth, width, height), 4 => limestone_deep_cavern_builder(new_depth, width, height), 5 => limestone_transition_builder(new_depth, width, height), 6 => dwarf_fort_builder(new_depth, width, height), 7 => mushroom_entrance(new_depth, width, height), 8 => mushroom_builder(new_depth, width, height), 9 => mushroom_exit(new_depth, width, height), 10 => dark_elf_city(new_depth, width, height), _ => random_builder(new_depth, width, height) } } }
在同一文件的顶部,为新的 builder 模块添加导入:
#![allow(unused)] fn main() { mod dark_elves; use dark_elves::*; }
并创建新的 map_builders/dark_elves.rs 文件,并在其中放置一个占位符 builder:
#![allow(unused)] fn main() { use super::{BuilderChain, XStart, YStart, AreaStartingPosition, CullUnreachable, VoronoiSpawning, AreaEndingPosition, XEnd, YEnd, BspInteriorBuilder }; pub fn dark_elf_city(new_depth: i32, width: i32, height: i32) -> BuilderChain { println!("Dark elf builder"); // 黑暗精灵生成器 let mut chain = BuilderChain::new(new_depth, width, height, "Dark Elven City"); chain.start_with(BspInteriorBuilder::new()); chain.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaStartingPosition::new(XStart::RIGHT, YStart::CENTER)); chain.with(AreaEndingPosition::new(XEnd::LEFT, YEnd::CENTER)); chain.with(VoronoiSpawning::new()); chain } }
这创建了一个完全不像城市的地图(只是一个 bsp 内部地图)——但这是一个好的开始。我选择这个作为基础 builder,因为它不浪费任何空间。我喜欢想象这座城市是一个巨大的相互连接的房间组成的迷宫,较贫穷的精灵住在危险的地方(顶部)。因此,我们将在这一层填充相对“正常”的黑暗精灵和他们的奴隶。
添加一些黑暗精灵
如果我们只是想在到处放置黑暗精灵,那么只需在 spawns.json 的 spawn_table 部分添加一行即可:
{ "name": "Dark Elf", "weight": 10, "min_depth": 10, "max_depth": 11 }
那样太无聊了,所以我们不那样做。我们的黑暗精灵分为 Arbat 氏族、Barbo 氏族 和 Cirro 氏族(A、B、C,明白了吗?)。由于 YALA 护身符的深渊影响,他们内部充满了可怕的争斗和战争!我们稍后会担心区分氏族,现在让我们先创建一些条目,提供三组互相憎恨的黑暗精灵。
在 spawns.json 的 factions 部分,创建三个新的派系:
{ "name" : "DarkElfA", "responses" : { "Default" : "attack", "DarkElfA" : "ignore", "DarkElfB" : "attack", "DarkElfC" : "attack" } },
{ "name" : "DarkElfB", "responses" : { "Default" : "attack", "DarkElfB" : "ignore", "DarkElfA" : "attack", "DarkElfC" : "attack" } },
{ "name" : "DarkElfC", "responses" : { "Default" : "attack", "DarkElfC" : "ignore", "DarkElfA" : "attack", "DarkElfB" : "attack" } }
请注意他们如何忽略自己的氏族,并攻击其他氏族。这是制造战区的关键!我们的派系系统已经支持交战团体——我们只是没有广泛使用它。现在找到 mobs 部分,并将“Dark Elf”复制三次——每个派系一次:
{
"name" : "Arbat Dark Elf",
"renderable": {
"glyph" : "e",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "random_waypoint",
"attributes" : {},
"equipped" : [ "Hand Crossbow", "Scimitar", "Buckler", "Drow Chain", "Drow Leggings", "Drow Boots" ],
"faction" : "DarkElfA",
"gold" : "3d6",
"level" : 6
},
{
"name" : "Barbo Dark Elf",
"renderable": {
"glyph" : "e",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "random_waypoint",
"attributes" : {},
"equipped" : [ "Hand Crossbow", "Scimitar", "Buckler", "Drow Chain", "Drow Leggings", "Drow Boots" ],
"faction" : "DarkElfB",
"gold" : "3d6",
"level" : 6
},
{
"name" : "Cirro Dark Elf",
"renderable": {
"glyph" : "e",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "random_waypoint",
"attributes" : {},
"equipped" : [ "Hand Crossbow", "Scimitar", "Buckler", "Drow Chain", "Drow Leggings", "Drow Boots" ],
"faction" : "DarkElfC",
"gold" : "3d6",
"level" : 6
},
在 spawn 表中,我们希望它们出现在 10 级:
{ "name" : "Arbat Dark Elf", "weight": 10, "min_depth": 10, "max_depth": 11 },
{ "name" : "Barbo Dark Elf", "weight": 10, "min_depth": 10, "max_depth": 11 },
{ "name" : "Cirro Dark Elf", "weight": 10, "min_depth": 10, "max_depth": 11 }
如果你现在 cargo run,并作弊下到 10 层(我建议使用上帝模式和传送)——你会发现自己身处三个氏族之间的战区之中。到处都是战斗,他们只会在互相残杀的间隙停下来谋杀玩家。这里有大量的混乱——混沌之神会感到自豪的。
氏族区分
让所有氏族都一模一样有点无聊。基本的“Dark Elf”可以保持不变,但让我们添加一些特色,使氏族 感觉 与众不同。
Arbat 氏族
我们将首先使 Arbat 氏族变成不同的颜色——更浅的红色。将他们的 Dark Elf 的 “fg” 属性替换为 #FFAAAA ——一种粉红色。我们也将拿走他们的弩。他们是一个以近战为主的氏族。将 Scimitar 替换为 Scimitar +1。修改后的 Arbat Dark Elf 看起来像这样:
{
"name" : "Arbat Dark Elf",
"renderable": {
"glyph" : "e",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "random_waypoint",
"attributes" : {},
"equipped" : [ "Scimitar +1", "Buckler", "Drow Chain", "Drow Leggings", "Drow Boots" ],
"faction" : "DarkElfA",
"gold" : "3d6",
"level" : 6
},
我们也给他们一些首领——更强大的战士:
{
"name" : "Arbat Dark Elf Leader",
"renderable": {
"glyph" : "E",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "random_waypoint",
"attributes" : {},
"equipped" : [ "Scimitar +2", "Buckler +1", "Drow Chain", "Drow Leggings", "Drow Boots" ],
"faction" : "DarkElfA",
"gold" : "3d6",
"level" : 7
},
他们也应该得到一些兽人奴隶:
{
"name" : "Arbat Orc Slave",
"renderable": {
"glyph" : "o",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "static",
"attributes" : {},
"faction" : "DarkElfA",
"gold" : "1d8"
},
最后,将这些放入 spawn 表:
{ "name" : "Arbat Dark Elf", "weight": 10, "min_depth": 10, "max_depth": 11 },
{ "name" : "Arbat Dark Elf Leader", "weight": 7, "min_depth": 10, "max_depth": 11 },
{ "name" : "Arbat Orc Slave", "weight": 14, "min_depth": 10, "max_depth": 11 },
他们可能会后悔专注于近战,但我们不太关心他们的健康!
Barbo 氏族
相反,我们将使 Barbo 氏族非常侧重于远程武器——并且更加稀有,因为那样非常危险。我们还将给他们一把匕首而不是弯刀,并将他们的颜色改为橙色:
{
"name" : "Barbo Dark Elf",
"renderable": {
"glyph" : "e",
"fg" : "#FF9900",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "random_waypoint",
"attributes" : {},
"equipped" : [ "Hand Crossbow +1", "Dagger", "Buckler", "Drow Chain", "Drow Leggings", "Drow Boots" ],
"faction" : "DarkElfB",
"gold" : "3d6",
"level" : 6
},
他们也得到一些奴隶——这次是地精,带有远程武器:
{
"name" : "Barbo Goblin Archer",
"renderable": {
"glyph" : "g",
"fg" : "#FF9900",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "static",
"attributes" : {},
"faction" : "Cave Goblins",
"gold" : "1d6",
"equipped" : [ "Shortbow", "Leather Armor", "Leather Boots" ]
},
最后,更新 spawns 表以包含它们:
{ "name" : "Barbo Dark Elf", "weight": 9, "min_depth": 10, "max_depth": 11 },
{ "name" : "Barbo Goblin Archer", "weight": 13, "min_depth": 10, "max_depth": 11 },
Cirro 氏族
我们将使 Cirro 氏族强大而稀有。基本的 Cirro Dark Elf 看起来像这样:
{
"name" : "Cirro Dark Elf",
"renderable": {
"glyph" : "e",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "random_waypoint",
"attributes" : {},
"equipped" : [ "Hand Crossbow", "Scimitar", "Buckler", "Drow Chain", "Drow Leggings", "Drow Boots" ],
"faction" : "DarkElfC",
"gold" : "3d6",
"level" : 7
},
我们还将给他们首领——可以网住你的女祭司:
{
"name" : "Cirro Dark Priestess",
"renderable": {
"glyph" : "E",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"movement" : "random_waypoint",
"attributes" : {},
"equipped" : [ "Hand Crossbow", "Scimitar", "Buckler", "Drow Chain", "Drow Leggings", "Drow Boots" ],
"faction" : "DarkElfC",
"gold" : "3d6",
"level" : 8,
"abilities" : [
{ "spell" : "Web", "chance" : 0.2, "range" : 6.0, "min_range" : 3.0 }
]
},
我们不给他们奴隶,而是给他们蜘蛛:
{
"name" : "Cirro Spider",
"level" : 3,
"attributes" : {},
"renderable": {
"glyph" : "s",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 6,
"movement" : "static",
"natural" : {
"armor_class" : 12,
"attacks" : [
{ "name" : "bite", "hit_bonus" : 1, "damage" : "1d12" }
]
},
"abilities" : [
{ "spell" : "Web", "chance" : 0.2, "range" : 6.0, "min_range" : 3.0 }
],
"faction" : "DarkElfC"
},
这也需要更新 spawn 表:
{ "name" : "Cirro Dark Elf", "weight": 7, "min_depth": 10, "max_depth": 11 },
{ "name" : "Cirro Dark Priestess", "weight": 6, "min_depth": 10, "max_depth": 11 },
{ "name" : "Cirro Spider", "weight": 10, "min_depth": 10, "max_depth": 11 }
如果你现在 cargo run 该项目,你会发现黑暗精灵正在互相残杀——并且呈现出良好的多样性。
总结
这是一个简短的章节:因为大多数先决条件已经编写完成。这对整个引擎来说是一个好兆头:我们现在可以构建非常不同风格的关卡,而无需编写太多新代码。在下一章中,我们将进一步深入黑暗精灵城市——尝试创建一个更开放的城市关卡。混乱将继续!
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.
广场之夜
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持 我的 Patreon。
城市关卡被特意设计得很混乱:英雄在狭窄、蔓延的黑暗精灵地下城市中战斗——面对不同贵族家族的部队,他们也一心想杀死彼此。 这使得战斗节奏快、紧张。 城市的最后一部分是广场——旨在提供更多的对比。 城市中的公园里有一个通往深渊的传送门,只有最富有/最有影响力的黑暗精灵才能在这里建造。 因此,尽管身处地下,但它更像是一个户外城市的感觉。
所以让我们思考一下构成广场关卡的要素:
- 一个规模适中的公园,由一些强大的坏蛋守卫。 我们可以第一次在这里添加一些恶魔,因为我们就位于通往他们家园的传送门旁边。
- 一些较大的建筑物。
- 雕像、喷泉和类似的装饰品。
继续思考黑暗精灵,他们实际上并不以城市规划而闻名。 从本质上讲,它们是混乱的物种。 因此,我们希望避免让他们感觉他们真的规划好了自己的城市,并一丝不苟地建造它使其有意义。 事实上,不合逻辑反而增加了超现实感。
生成广场
就像我们为其他关卡构建器所做的那样,我们需要为关卡 11 添加一个占位符构建器。 打开 map_builders/mod.rs 并为关卡 11 添加对 dark_elf_plaza 的调用:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, width, height), 2 => forest_builder(new_depth, width, height), 3 => limestone_cavern_builder(new_depth, width, height), 4 => limestone_deep_cavern_builder(new_depth, width, height), 5 => limestone_transition_builder(new_depth, width, height), 6 => dwarf_fort_builder(new_depth, width, height), 7 => mushroom_entrance(new_depth, width, height), 8 => mushroom_builder(new_depth, width, height), 9 => mushroom_exit(new_depth, width, height), 10 => dark_elf_city(new_depth, width, height), 11 => dark_elf_plaza(new_depth, width, height), _ => random_builder(new_depth, width, height) } } }
现在打开 map_builders/dark_elves.rs 并创建新的地图构建器函数——dark_elf_plaza。 我们将从生成 BSP 内部地图开始; 这将会改变,但先让它编译通过总是好的:
#![allow(unused)] fn main() { pub fn dark_elf_plaza(new_depth: i32, width: i32, height: i32) -> BuilderChain { println!("Dark elf plaza builder"); let mut chain = BuilderChain::new(new_depth, width, height, "Dark Elven Plaza"); chain.start_with(BspInteriorBuilder::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaEndingPosition::new(XEnd::RIGHT, YEnd::CENTER)); chain.with(VoronoiSpawning::new()); chain } }
刻意糟糕的城市规划
既然我们有了和上一个关卡完全相同的地图,那么让我们构建一个生成器来创建广场。 我们将从制作一个无聊的空地图开始——只是为了验证我们的地图构建器是否正常工作。 在 dark_elves.rs 的末尾,粘贴以下代码:
#![allow(unused)] fn main() { // Plaza Builder use super::{InitialMapBuilder, BuilderMap, TileType }; pub struct PlazaMapBuilder {} impl InitialMapBuilder for PlazaMapBuilder { #[allow(dead_code)] fn build_map(&mut self, build_data : &mut BuilderMap) { self.empty_map(build_data); } } impl PlazaMapBuilder { #[allow(dead_code)] pub fn new() -> Box<PlazaMapBuilder> { Box::new(PlazaMapBuilder{}) } fn empty_map(&mut self, build_data : &mut BuilderMap) { build_data.map.tiles.iter_mut().for_each(|t| *t = TileType::Floor); } } }
您还需要进入 dark_elf_plaza 并更改初始构建器以使用它:
#![allow(unused)] fn main() { pub fn dark_elf_plaza(new_depth: i32, width: i32, height: i32) -> BuilderChain { println!("Dark elf plaza builder"); let mut chain = BuilderChain::new(new_depth, width, height, "Dark Elven Plaza"); chain.start_with(PlazaMapBuilder::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaEndingPosition::new(XEnd::RIGHT, YEnd::CENTER)); chain.with(VoronoiSpawning::new()); chain } }
如果您现在运行游戏并传送至最后一关,“广场”将是一个巨大的开放空间,到处都是互相残杀的人。 我觉得这很有趣,但这不是我们想要的。

广场需要划分为区域,这些区域包含广场内容。 这类似于我们对 Voronoi 地图所做的,但我们不是要创建蜂窝墙——只是放置内容的区域。 让我们从创建一个基本的 Voronoi 单元区域开始。 扩展您的地图构建器以调用一个名为 spawn_zones 的新函数:
#![allow(unused)] fn main() { impl InitialMapBuilder for PlazaMapBuilder { #[allow(dead_code)] fn build_map(&mut self, build_data : &mut BuilderMap) { self.empty_map(build_data); self.spawn_zones(build_data); } } }
我们将从采用之前的 Voronoi 代码开始,并使其始终具有 32 个种子并使用毕达哥拉斯距离:
#![allow(unused)] fn main() { fn spawn_zones(&mut self, build_data : &mut BuilderMap) { let mut voronoi_seeds : Vec<(usize, rltk::Point)> = Vec::new(); while voronoi_seeds.len() < 32 { let vx = crate::rng::roll_dice(1, build_data.map.width-1); let vy = crate::rng::roll_dice(1, build_data.map.height-1); let vidx = build_data.map.xy_idx(vx, vy); let candidate = (vidx, rltk::Point::new(vx, vy)); if !voronoi_seeds.contains(&candidate) { voronoi_seeds.push(candidate); } } let mut voronoi_distance = vec![(0, 0.0f32) ; 32]; let mut voronoi_membership : Vec<i32> = vec![0 ; build_data.map.width as usize * build_data.map.height as usize]; for (i, vid) in voronoi_membership.iter_mut().enumerate() { let x = i as i32 % build_data.map.width; let y = i as i32 / build_data.map.width; for (seed, pos) in voronoi_seeds.iter().enumerate() { let distance = rltk::DistanceAlg::PythagorasSquared.distance2d( rltk::Point::new(x, y), pos.1 ); voronoi_distance[seed] = (seed, distance); } voronoi_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); *vid = voronoi_distance[0].0 as i32; } // Spawning code will go here // 生成代码将放在这里 } }
在新的 spawn_zones 函数的末尾,我们有一个名为 voronoi_membership 的数组,它将每个地块分类为 32 个区域之一。 这些区域保证是连续的。 让我们编写一些快速代码来计算每个区域的大小,以验证我们的工作:
#![allow(unused)] fn main() { // Make a list of zone sizes and cull empty ones // 制作区域大小列表并剔除空区域 let mut zone_sizes : Vec<(i32, usize)> = Vec::with_capacity(32); for zone in 0..32 { let num_tiles = voronoi_membership.iter().filter(|z| **z == zone).count(); if num_tiles > 0 { zone_sizes.push((zone, num_tiles)); } } println!("{:?}", zone_sizes); }
这将每次给出不同的结果,但会很好地了解我们创建了多少区域以及它们有多大。 这是一个快速测试运行的输出:
[(0, 88), (1, 60), (2, 143), (3, 261), (4, 192), (5, 165), (6, 271), (7, 68), (8, 151), (9, 78), (10, 45), (11, 154), (12, 132), (13, 88), (14, 162), (15, 49), (16, 138), (17, 57), (18, 206), (19, 117), (20, 168), (21, 67), (22, 153), (23, 119), (24, 41), (25, 48), (26, 78), (27, 118), (28, 197), (29, 129), (30, 163), (31, 94)]
所以我们知道区域创建工作正常:有 32 个区域,没有一个区域过小——尽管有些区域相当大。 让我们按大小降序对列表进行排序:
#![allow(unused)] fn main() { zone_sizes.sort_by(|a,b| b.1.cmp(&a.1)); }
这产生了加权的“重要性”地图:大区域在前,小区域在后。 我们将使用它来按重要性顺序生成内容。 大型“传送门公园”保证是最大的区域。 这是我们创建系统的开始:
#![allow(unused)] fn main() { // Start making zonal terrain // 开始制作区域地形 zone_sizes.iter().enumerate().for_each(|(i, (zone, _))| { match i { 0 => self.portal_park(build_data, &voronoi_membership, *zone), _ => {} } }); }
portal_park 的占位符签名如下:
#![allow(unused)] fn main() { fn portal_park(&mut self, build_data : &mut BuilderMap, voronoi_membership: &[i32], zone: i32) { } }
我们将使用这种模式逐步填充广场。 现在,我们将跳过传送门公园,先添加一些其他功能。
坚固的岩石
让我们从最简单的开始:我们将把一些较小的区域变成坚固的岩石。 这些可能是精灵尚未开采的区域,或者——更有可能——他们将它们留在原位以支撑洞穴。 我们将使用一个以前没有接触过的功能:“匹配守卫”。 您可以使 match 适用于“大于”,如下所示:
#![allow(unused)] fn main() { // Start making zonal terrain // 开始制作区域地形 zone_sizes.iter().enumerate().for_each(|(i, (zone, _))| { match i { 0 => self.portal_park(build_data, &voronoi_membership, *zone), i if i > 20 => self.fill_zone(build_data, &voronoi_membership, *zone, TileType::Wall), _ => {} } }); }
实际的 fill_zone 函数非常简单:它查找区域中的地块并将它们变成墙壁:
#![allow(unused)] fn main() { fn fill_zone(&mut self, build_data : &mut BuilderMap, voronoi_membership: &[i32], zone: i32, tile_type: TileType) { voronoi_membership .iter() .enumerate() .filter(|(_, tile_zone)| **tile_zone == zone) .for_each(|(idx, _)| build_data.map.tiles[idx] = tile_type); } }
这已经为我们的地图注入了一些活力:

水池
洞穴往往是阴暗潮湿的地方。 黑暗精灵可能喜欢一些水池——广场以壮丽的水池而闻名! 让我们扩展“默认”匹配,有时创建区域水池:
#![allow(unused)] fn main() { // Start making zonal terrain // 开始制作区域地形 zone_sizes.iter().enumerate().for_each(|(i, (zone, _))| { match i { 0 => self.portal_park(build_data, &voronoi_membership, *zone), i if i > 20 => self.fill_zone(build_data, &voronoi_membership, *zone, TileType::Wall), _ => { let roll = crate::rng::roll_dice(1, 6); match roll { 1 => self.fill_zone(build_data, &voronoi_membership, *zone, TileType::DeepWater), 2 => self.fill_zone(build_data, &voronoi_membership, *zone, TileType::ShallowWater), _ => {} } } } }); }
看看如果我们没有匹配任何其他内容,我们是如何掷骰子的? 如果掷出 1 或 2,我们会添加一个深度不同的水池。 实际上添加水池就像添加坚固的岩石一样——但我们添加的是水而不是岩石。
一些水景的加入继续为区域带来生机:

钟乳石公园
钟乳石(以及据推测的它们的孪生兄弟,石笋)可以是真实洞穴的美丽特征。 它们是黑暗精灵公园中包含的天然候选者。 如果城市里能有一些色彩就太好了,所以让我们用草地环绕它们。 它们是一个精心培育的公园,为黑暗精灵在业余时间所做的任何事情提供隐私(你不会想知道的......)。
将其添加到“未知”区域选项中:
#![allow(unused)] fn main() { // Start making zonal terrain // 开始制作区域地形 zone_sizes.iter().enumerate().for_each(|(i, (zone, _))| { match i { 0 => self.portal_park(build_data, &voronoi_membership, *zone), i if i > 20 => self.fill_zone(build_data, &voronoi_membership, *zone, TileType::Wall), _ => { let roll = crate::rng::roll_dice(1, 6); match roll { 1 => self.fill_zone(build_data, &voronoi_membership, *zone, TileType::DeepWater), 2 => self.fill_zone(build_data, &voronoi_membership, *zone, TileType::ShallowWater), 3 => self.stalactite_display(build_data, &voronoi_membership, *zone), _ => {} } } } }); }
并使用类似于 fill_zone 的函数来填充区域中的每个地块,可以是草地或钟乳石:
#![allow(unused)] fn main() { fn stalactite_display(&mut self, build_data : &mut BuilderMap, voronoi_membership: &[i32], zone: i32) { voronoi_membership .iter() .enumerate() .filter(|(_, tile_zone)| **tile_zone == zone) .for_each(|(idx, _)| { build_data.map.tiles[idx] = match crate::rng::roll_dice(1,10) { 1 => TileType::Stalactite, 2 => TileType::Stalagmite, _ => TileType::Grass, }; }); } }
公园和献祭区
一些植被覆盖的区域,加上座位,增加了公园的感觉。 这些应该是较大的区域——那是该区域的主要主题。 我真的不认为黑暗精灵会坐在那里听一场美好的音乐会---所以让我们在中间放一个祭坛,上面布满血迹。 请注意,我们如何在 match 中使用“或”语句来匹配第二大和第三大区域:
#![allow(unused)] fn main() { // Start making zonal terrain // 开始制作区域地形 zone_sizes.iter().enumerate().for_each(|(i, (zone, _))| { match i { 0 => self.portal_park(build_data, &voronoi_membership, *zone), 1 | 2 => self.park(build_data, &voronoi_membership, *zone), i if i > 20 => self.fill_zone(build_data, &voronoi_membership, *zone, TileType::Wall), _ => { let roll = crate::rng::roll_dice(1, 6); match roll { 1 => self.fill_zone(build_data, &voronoi_membership, *zone, TileType::DeepWater), 2 => self.fill_zone(build_data, &voronoi_membership, *zone, TileType::ShallowWater), 3 => self.stalactite_display(build_data, &voronoi_membership, *zone), _ => {} } } } }); }
实际填充公园稍微复杂一些:
#![allow(unused)] fn main() { fn park(&mut self, build_data : &mut BuilderMap, voronoi_membership: &[i32], zone: i32, seeds: &[(usize, rltk::Point)]) { let zone_tiles : Vec<usize> = voronoi_membership .iter() .enumerate() .filter(|(_, tile_zone)| **tile_zone == zone) .map(|(idx, _)| idx) .collect(); // Start all grass // 开始全部铺上草地 zone_tiles.iter().for_each(|idx| build_data.map.tiles[*idx] = TileType::Grass); // Add a stone area in the middle // 在中间添加一个石头区域 let center = seeds[zone as usize].1; for y in center.y-2 ..= center.y+2 { for x in center.x-2 ..= center.x+2 { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::Road; if crate::rng::roll_dice(1,6) > 2 { build_data.map.bloodstains.insert(idx); } } } // With an altar at the center // 中心有一个祭坛 build_data.spawn_list.push(( build_data.map.xy_idx(center.x, center.y), "Altar".to_string() )); // And chairs for spectators // 以及供观众就座的椅子 zone_tiles.iter().for_each(|idx| { if build_data.map.tiles[*idx] == TileType::Grass && crate::rng::roll_dice(1, 6)==1 { build_data.spawn_list.push(( *idx, "Chair".to_string() )); } }); } }
我们首先收集可用地块的列表。 然后我们用漂亮的草地覆盖它们。 找到 Voronoi 区域的中心点(它将是生成它的种子),并用道路覆盖该区域。 在中间生成一个祭坛,一些随机的血迹和一堆椅子。 这都是我们之前做过的生成——但汇集在一起形成一个(并非完全令人愉悦的)主题公园。
公园区域看起来足够混乱:

添加走道
此时,不能保证您实际上可以穿过地图。 水和墙壁完全有可能以错误的方式重合,从而阻碍您的前进。 这可不好! 让我们使用我们在创建第一个 Voronoi 构建器时遇到的系统来识别 Voronoi 区域之间的边缘——并将边缘地块替换为道路。 这确保了区域之间有通道,并在地图上产生漂亮的蜂窝效果。
首先在 spawn_zones 的末尾添加一个调用,调用道路构建器:
#![allow(unused)] fn main() { // Clear the path // 清理路径 self.make_roads(build_data, &voronoi_membership); }
现在我们实际上必须建造一些道路。 大部分代码与 Voronoi 边缘检测相同。 我们不是在区域内放置地板,而是在边缘放置道路:
#![allow(unused)] fn main() { fn make_roads(&mut self, build_data : &mut BuilderMap, voronoi_membership: &[i32]) { for y in 1..build_data.map.height-1 { for x in 1..build_data.map.width-1 { let mut neighbors = 0; let my_idx = build_data.map.xy_idx(x, y); let my_seed = voronoi_membership[my_idx]; if voronoi_membership[build_data.map.xy_idx(x-1, y)] != my_seed { neighbors += 1; } if voronoi_membership[build_data.map.xy_idx(x+1, y)] != my_seed { neighbors += 1; } if voronoi_membership[build_data.map.xy_idx(x, y-1)] != my_seed { neighbors += 1; } if voronoi_membership[build_data.map.xy_idx(x, y+1)] != my_seed { neighbors += 1; } if neighbors > 1 { build_data.map.tiles[my_idx] = TileType::Road; } } } } }
有了这个功能,地图就可以通行了。 道路划定了边缘,看起来又不太方正:

清理生成物
目前,地图非常混乱——并且很可能很快杀死你。 有很大的开放区域,到处都是坏人、陷阱(你为什么要在一个公园里建造陷阱?),以及散落的物品。 混乱是好的,但随机性太多也是不好的。 我们希望地图具有某种意义——以一种随机的方式。
让我们首先从构建器链中完全删除随机实体生成器:
#![allow(unused)] fn main() { pub fn dark_elf_plaza(new_depth: i32, width: i32, height: i32) -> BuilderChain { println!("Dark elf plaza builder"); let mut chain = BuilderChain::new(new_depth, width, height, "Dark Elven Plaza"); chain.start_with(PlazaMapBuilder::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); chain.with(CullUnreachable::new()); chain.with(AreaEndingPosition::new(XEnd::RIGHT, YEnd::CENTER)); chain } }
这为您提供了一个没有敌人的地图,尽管它仍然有一些椅子和祭坛。 这是一个“主题公园”地图——所以我们将保留对给定区域中生成内容的某些控制。 它对玩家的帮助很少——我们快到最后了,所以希望他们已经储备了物资!
让我们首先在公园/祭坛区域放置一些怪物。 一个黑暗精灵家族或其他家族在那里,导致成群的敌人。 现在找到 park 函数,我们将扩展“添加椅子”部分:
#![allow(unused)] fn main() { // And chairs for spectators, and the spectators themselves // 以及供观众就座的椅子,以及观众本身 let available_enemies = match crate::rng::roll_dice(1, 3) { 1 => vec![ "Arbat Dark Elf", "Arbat Dark Elf Leader", "Arbat Orc Slave", ], 2 => vec![ "Barbo Dark Elf", "Barbo Goblin Archer", ], _ => vec![ "Cirro Dark Elf", "Cirro Dark Priestess", "Cirro Spider", ] }; zone_tiles.iter().for_each(|idx| { if build_data.map.tiles[*idx] == TileType::Grass { match crate::rng::roll_dice(1, 10) { 1 => build_data.spawn_list.push(( *idx, "Chair".to_string() )), 2 => { let to_spawn = crate::rng::range(0, available_enemies.len() as i32); build_data.spawn_list.push(( *idx, available_enemies[to_spawn as usize].to_string() )); } _ => {} } } }); }
我们在这里做一些新的事情。 我们随机为公园分配一个所有者——A、B 或 C 组黑暗精灵。 然后我们为每个组制作一个可用的生成列表,并在该公园中生成一些。 这确保了公园开始时由一个派系拥有。 由于他们经常可以看到彼此,屠杀将开始——但至少这是主题屠杀。
我们将让钟乳石画廊和水池空无一人,没有敌人。 它们只是装饰,并提供一个安静的区域来隐藏/休息(看到了吗?我们并没有完全不公平!)。
传送门公园
既然我们已经确定了地图的基本形状,那么是时候关注公园了。 首先要做的是阻止出口随机生成。 更改基本地图构建器以不包括出口放置:
#![allow(unused)] fn main() { pub fn dark_elf_plaza(new_depth: i32, width: i32, height: i32) -> BuilderChain { println!("Dark elf plaza builder"); let mut chain = BuilderChain::new(new_depth, width, height, "Dark Elven Plaza"); chain.start_with(PlazaMapBuilder::new()); chain.with(AreaStartingPosition::new(XStart::LEFT, YStart::CENTER)); chain.with(CullUnreachable::new()); chain } }
这让您完全没有出口。 我们想把它放在传送门公园的中间。 让我们扩展函数签名以包含 Voronoi 种子,并使用种子点放置出口——就像我们对其他公园所做的那样:
#![allow(unused)] fn main() { fn portal_park(&mut self, build_data : &mut BuilderMap, voronoi_membership: &[i32], zone: i32, seeds: &[(usize, rltk::Point)]) { let center = seeds[zone as usize].1; let idx = build_data.map.xy_idx(center.x, center.y); build_data.map.tiles[idx] = TileType::DownStairs; } }
现在,让我们通过用砾石覆盖传送门公园来使其更加突出:
#![allow(unused)] fn main() { fn portal_park(&mut self, build_data : &mut BuilderMap, voronoi_membership: &[i32], zone: i32, seeds: &[(usize, rltk::Point)]) { let zone_tiles : Vec<usize> = voronoi_membership .iter() .enumerate() .filter(|(_, tile_zone)| **tile_zone == zone) .map(|(idx, _)| idx) .collect(); // Start all gravel // 开始全部铺上砾石 zone_tiles.iter().for_each(|idx| build_data.map.tiles[*idx] = TileType::Gravel); // Add the exit // 添加出口 let center = seeds[zone as usize].1; let idx = build_data.map.xy_idx(center.x, center.y); build_data.map.tiles[idx] = TileType::DownStairs; } }
接下来,我们将在出口周围添加一些祭坛:
#![allow(unused)] fn main() { // Add some altars around the exit // 在出口周围添加一些祭坛 let altars = [ build_data.map.xy_idx(center.x - 2, center.y), build_data.map.xy_idx(center.x + 2, center.y), build_data.map.xy_idx(center.x, center.y - 2), build_data.map.xy_idx(center.x, center.y + 2), ]; altars.iter().for_each(|idx| build_data.spawn_list.push((*idx, "Altar".to_string()))); }
这为深渊的出口提供了一个非常好的开始。 您在正确的位置有出口、令人毛骨悚然的祭坛和清晰标记的入口。 它也避免了风险(除了精灵在地图上到处互相残杀)。
让我们通过在出口处添加一场 Boss 战来使出口更具挑战性。 这是进入深渊之前的最后一次重大推进,因此它是 Boss 战的自然地点。 我随机生成了一个恶魔的名字,并决定将 Boss 命名为“沃科斯 (Vokoth)”。 让我们把它生成在出口旁边的一个地块上:
#![allow(unused)] fn main() { let demon_spawn = build_data.map.xy_idx(center.x+1, center.y+1); build_data.spawn_list.push((demon_spawn, "Vokoth".to_string())); }
在我们定义沃科斯之前,这不会有任何作用! 我们想要一个强大的坏蛋。 让我们在 spawns.json 中快速回顾一下,并提醒自己我们是如何定义黑龙的:
{
"name" : "Black Dragon",
"renderable": {
"glyph" : "D",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1,
"x_size" : 2,
"y_size" : 2
},
"blocks_tile" : true,
"vision_range" : 12,
"movement" : "static",
"attributes" : {
"might" : 13,
"fitness" : 13
},
"skills" : {
"Melee" : 18,
"Defense" : 16
},
"natural" : {
"armor_class" : 17,
"attacks" : [
{ "name" : "bite", "hit_bonus" : 4, "damage" : "1d10+2" },
{ "name" : "left_claw", "hit_bonus" : 2, "damage" : "1d10" },
{ "name" : "right_claw", "hit_bonus" : 2, "damage" : "1d10" }
]
},
"loot_table" : "Wyrms",
"faction" : "Wyrm",
"level" : 6,
"gold" : "20d10",
"abilities" : [
{ "spell" : "Acid Breath", "chance" : 0.2, "range" : 8.0, "min_range" : 2.0 }
]
},
那是一个非常强大的怪物,是深渊恶魔的良好模板。 让我们克隆它(复制/粘贴时间!)并为沃科斯构建一个条目:
{
"name" : "Vokoth",
"renderable": {
"glyph" : "&",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1,
"x_size" : 2,
"y_size" : 2
},
"blocks_tile" : true,
"vision_range" : 6,
"movement" : "static",
"attributes" : {
"might" : 13,
"fitness" : 13
},
"skills" : {
"Melee" : 18,
"Defense" : 16
},
"natural" : {
"armor_class" : 17,
"attacks" : [
{ "name" : "whip", "hit_bonus" : 4, "damage" : "1d10+2" }
]
},
"loot_table" : "Wyrms",
"faction" : "Wyrm",
"level" : 8,
"gold" : "20d10",
"abilities" : []
}
现在,如果您玩游戏,您会发现自己在深渊出口处面对着一个讨厌的恶魔怪物。

总结
我们现在完成了倒数第二个部分! 您可以一路战斗到黑暗精灵广场,并找到通往深渊的门户——但前提是您能够避开一个笨重的恶魔和一大群精灵——并且几乎没有提供任何帮助。 接下来,我们将开始构建深渊。
本章的源代码可以在这里找到
在您的浏览器中使用 Web Assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。
贡献者
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。我希望您喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
以下人员为本项目做出了贡献:
- Herbert Wolverson,主要作者。
- Marius Gedminas 为可见性系统和第 5 章提供了一些修复。修正了第 7 章中的一个 typo,修正了第 7 章中的一个 Markdown 错误,修正了第 13 章中的缩进,以及第 18 章中的一个 typo。他还修复了我的饥饿时钟中的一些代码,以便如果需要,怪物也会感到饥饿。
- Ben Morrison 修复了第 4 章中的一个问题,正确地将房间按引用传递与随附的源代码相匹配。
- Tommi Jalkanen 帮助我移除了一些我不小心遗留下来的 Dispatcher 代码。他还发现了一个关于移动时边界检查的问题,我对此表示由衷的感谢!
- Gabriel Martinez 帮助我修复了 Vi 键。
- Dominic D 发现了第 7 章中的一些问题。
- FlorentKI 发现了一些未被正确删除的 dispatch 代码,并帮助修复了一些关于狗腿走廊的代码问题。
- Zirael07 发现了许多 typo 和遗漏的代码片段。
- Tyler Vipond 在第 7 章和第 9 章中发现了一堆问题。
- JP Moresmau 发现系统运行顺序存在问题,导致你可以穿过怪物。
- toyboot4e 在波函数坍缩系统中发现了一些 typo 和代码改进。
- Sebastian N. Kaupe 修复了工具提示空白问题。
- adamnemecek 帮助解决了一些空白问题。
- Kawzeg 帮助修复了第 4 章中的一个越界错误。
- joguSD 提醒我修复我的 SSL 证书。
- Olivia Ifrim 帮助我修复了一些指向 Specs 书籍的损坏链接。
- NielsRenard 修正了我糟糕的关于掉落物品的英语,并帮助修复了更多损坏的 Specs 书籍链接。他还帮助发现了一个可怕的问题,即阻止一个人在一个回合中受到来自多个来源的伤害。
- ZeroCity 修正了第 2 章中的一个 typo (
Position而不是Pos)。 - 来自 r/roguelikedev Discord 的 Fuddles 指出了结构命名方面的一个问题。
- dethmuffin 指出了第 9 章中新
ConvertSaveLoad代码中的不一致之处。 - Reddit 用户 u/Koavf 要求我澄清该项目的许可。
- Till Arnold 修正了第 5 章中的一个小 typo。
- pk 帮助移除了 一些未使用的变量警告。
- Vojta7 发现了一个损坏的模块引用并为我修复了它。他还发现了 WFC 部分中的许多 typo 并也修复了它们。
- skierpage 修复了第 2 章中的大量 typo。
- Mark McCaskey 提供了一个不糟糕的日志系统!
- Thibaut 帮助我修复了第 7 章中的一些示例代码。
- Matteo Guglielmetti 发现了第 7 章代码中的一个类型错误并为我修复了它。
- Jubilee 帮助我修复了一堆从
RLTK到bracket-lib的链接,以及一堆 typo。 - Rich Churcher 帮助我找到/修复了一些忘记更新初始化代码的地方。
- Matteo Gugliemetti 注意到我在初始化
RunState之前就使用了它。 - Luca Beltrami 指出我不再需要
extern crate和macro_use了。 - HammerAndTongs 注意到 A* 实现也需要实现
get_pathing_distance。 - mdtro 发现了第 7 章代码中的一个问题。
- pprobst 注意到工具提示会显示隐藏的怪物。
- Ben Doerr 提醒我提醒你运行第 7 章中创建的新系统。
- Charlie Hornsby 帮助解决第 1 章中的一些文本/代码不一致问题。
- ddalcino 修复了一些 FOV 问题。
- Remi Marchand 帮助修复了一些文本中的一致性问题。
支持者
我也想花一点时间感谢所有向我发送鼓励的话语、提交问题报告以及以下 Patreon (来自 patreon.com) 赞助者的人:
- Aslan Magomadov
- Ben Gamble
- Boyd Trolinger
- Brian Bucklew
- Caleb C.
- Caleb M.
- Chad Thorenson
- Crocodylus Pontifex (好名字!)
- David Hagerty
- Enrique Garcia
- Finn Günther
- Fredrik Holmqvist
- Galen Palmer
- George Madrid
- Jeffrey Lyne
- Josh Isaak
- Kenton Hamaluik
- KnightDave
- Kris Shamloo
- Matthew Bradford
- Mark Rowe
- Neikos
- Noah
- Oliver Uvman
- Oskar Edgren
- Pat LaBine
- Pedro Probst
- Pete Bevin
- Rafin de Castro
- Russel Myers
- Ryan Orlando
- Shane Sveller
- Simon Dickinson
- Snayff
- Steve Hyatt
- Teague Lasser
- Tom Leys
- Tommi Sinuvuo
- Torben Clasen
如果我遗漏了您的贡献,请告诉我!
版权所有 (C) 2019, Herbert Wolverson。
许可
关于本教程
本教程是免费和开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
本教程(以及相关材料)的源代码 基于 MIT 许可证。 这意味着:
- 您几乎可以对它做任何您想做的事情,包括在其他许可协议下的项目中使用它。
- 您不能假定有担保并起诉我试图提供帮助。 请不要这样做。
- 您必须在您的项目中包含一个声明,说明您使用了 Herbert Wolverson 编写的 MIT 许可代码。
- 虽然不是必需的,但如果您能链接到本项目,我将不胜感激。 如果您使用了它,请告诉我,我将非常乐意在“人们使用本教程制作的酷炫作品”页面中链接回您的项目!
教程文本/书籍 基于 署名-非商业性使用-相同方式共享 4.0 国际 许可协议。
您可以自由地:
- 共享 - 在任何媒介或格式中复制和再分发本材料。
- 改编 - 为了任何目的,混编、转换和基于本材料构建。
但是,您必须:
- 保留署名 - 署名归于我(Herbert Wolverson),即原作者。 我真的不想在某天早上醒来,发现我的辛勤工作被贴上了别人的名字。
- 相同方式共享 - 如果您混编、转换或基于本材料构建,您必须在相同的许可协议下分发您的贡献。
- 非商业性使用 - 您不得将本材料用于商业目的。 如果您想这样做,请与我联系。
- 无额外限制 - 您不得施加法律条款或技术措施,以在法律上限制他人从事许可协议允许的任何行为。
如果您不喜欢我的许可选择,请告诉我 - 我很乐意讨论。
MIT 许可证文本
版权所有 2019 Herbert Wolverson (DBA Bracket Productions)
特此授予任何人免费许可,以获取本软件和相关文档文件(“软件”)的副本,并在不受限制的情况下处理本软件,包括但不限于使用、复制、修改、合并、发布、分发、再许可和/或销售软件副本的权利,并允许向其提供软件的人员这样做,但须符合以下条件:
上述版权声明和本许可声明应包含在软件的所有副本或主要部分中。
本软件按“现状”提供,不提供任何形式的明示或暗示担保,包括但不限于适销性、特定用途适用性和不侵权担保。在任何情况下,作者或版权持有人均不对任何索赔、损害或其他责任负责,无论是在合同诉讼、侵权诉讼或其他诉讼中,因本软件或本软件的使用或其他交易而引起或与之相关的任何索赔、损害或其他责任。
知识共享 BY-NC-SA-4.0 法律条款
请参阅 https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode,以获取格式良好、链接正确的版本。 由 creativecommons.org 托管的链接版本应被视为权威版本 - 这只是一个摘要。
知识共享
署名-非商业性使用-相同方式共享 4.0 国际
知识共享组织(“知识共享”)不是律师事务所,不提供法律服务或法律建议。 知识共享公共许可证的分发并不构成律师-客户或其他关系。 知识共享按“现状”提供其许可证和相关信息。 知识共享对其许可证、根据其条款和条件许可的任何材料或任何相关信息不作任何保证。 知识共享在最大可能范围内声明不对因使用它们而造成的损害承担任何责任。
使用知识共享公共许可证
知识共享公共许可证提供了一套标准的条款和条件,创作者和其他权利持有人可以使用这些条款和条件来共享原创著作以及受版权和以下公共许可证中规定的某些其他权利约束的其他材料。 以下注意事项仅供参考,并非详尽无遗,也不构成我们许可证的一部分。
-
许可人的注意事项: 我们的公共许可证旨在供那些被授权允许公众以受版权和某些其他权利限制的方式使用材料的人使用。 我们的许可证是不可撤销的。 许可人在应用他们选择的许可证之前应阅读并理解其条款和条件。 许可人还应在应用我们的许可证之前获得所有必要的权利,以便公众可以按预期重用该材料。 许可人应明确标记任何不受许可证约束的材料。 这包括其他基于 CC 许可的材料,或根据版权的例外或限制使用的材料。更多许可人的注意事项。
-
公众的注意事项: 通过使用我们的公共许可证之一,许可人授予公众在特定条款和条件下使用许可材料的许可。 如果出于任何原因不需要许可人的许可 - 例如,由于任何适用的版权例外或限制 - 则该使用不受许可证的约束。 我们的许可证仅授予根据版权和许可人有权授予的某些其他权利的许可。 许可材料的使用可能仍然因其他原因受到限制,包括因为其他人对该材料拥有版权或其他权利。 许可人可能会提出特殊要求,例如要求标记或描述所有更改。 尽管我们的许可证没有要求,但我们鼓励您在合理的情况下尊重这些要求。更多公众的注意事项。
知识共享 署名-非商业性使用-相同方式共享 4.0 国际公共许可证
通过行使许可权利(定义如下),您接受并同意受本知识共享署名-非商业性使用-相同方式共享 4.0 国际公共许可证(“公共许可证”)的条款和条件的约束。 如果本公共许可证可以被解释为合同,则您被授予许可权利以作为您接受这些条款和条件的对价,并且许可人授予您此类权利以作为许可人从根据这些条款和条件提供许可作品中获得的利益的对价。
第 1 条 – 定义。
a. 演绎作品 指受版权及相关权利约束的材料,该材料来源于或基于许可作品,并且其中许可作品以需要根据许可人持有的版权及相关权利获得许可的方式进行翻译、更改、编排、转换或以其他方式修改。 就本公共许可证而言,如果许可作品是音乐作品、表演或录音作品,则当许可作品与动态图像同步定时关系时,始终会产生演绎作品。
b. 演绎者许可证 指您根据本公共许可证的条款和条件应用于您对演绎作品的贡献中的版权及相关权利的许可证。
c. BY-NC-SA 兼容许可证 指 creativecommons.org/compatiblelicenses 上列出的,并经知识共享批准为与本公共许可证基本等效的许可证。
d. 版权及相关权利 指版权和/或与版权密切相关的类似权利,包括但不限于表演权、广播权、录音权和独创性数据库权利,而无论这些权利如何标记或分类。 就本公共许可证而言,第 2(b)(1)-(2) 条中规定的权利不是版权及相关权利。
e. 有效技术措施 指那些在没有适当授权的情况下,根据履行 1996 年 12 月 20 日通过的《世界知识产权组织版权条约》第 11 条和/或类似国际协议项下义务的法律,可能无法规避的措施。
f. 例外与限制 指适用于您使用许可作品的合理使用、公平交易和/或任何其他版权及相关权利的例外或限制。
g. 许可证要素 指知识共享公共许可证名称中列出的许可证属性。 本公共许可证的许可证要素是署名、非商业性使用和相同方式共享。
h. 许可作品 指许可人应用本公共许可证的艺术或文学作品、数据库或其他材料。
i. 许可权利 指根据本公共许可证的条款和条件授予您的权利,这些权利仅限于适用于您使用许可作品的,并且许可人有权许可的所有版权及相关权利。
j. 许可人 指根据本公共许可证授予权利的个人或实体。
k. 非商业性使用 指主要不用于或不针对商业优势或金钱补偿。 就本公共许可证而言,通过数字文件共享或类似方式交换许可作品以获得其他受版权及相关权利约束的材料是非商业性使用,前提是与交换无关的金钱补偿支付。
l. 共享 指通过任何需要根据许可权利获得许可的方式或过程向公众提供材料,例如复制、公开展示、公开表演、分发、传播、传播或进口,以及向公众提供材料,包括公众成员可以从他们个人选择的地点和时间访问材料的方式。
m. 独创性数据库权利 指源于经修订和/或后续的欧洲议会和理事会 1996 年 3 月 11 日关于数据库法律保护的 96/9/EC 指令,以及世界任何地方的其他基本等效权利的版权以外的权利。
n. 您 指根据本公共许可证行使许可权利的个人或实体。 “您的”具有相应的含义。
第 2 条 – 范围。
a. 许可证授予。
-
在遵守本公共许可证的条款和条件的前提下,许可人特此授予您在全球范围内、免版税、不可再许可、非排他性、不可撤销的许可证,以对许可作品行使许可权利,用于:
A. 仅为非商业性目的复制和共享许可作品的全部或部分;以及
B. 仅为非商业性目的制作、复制和共享演绎作品。
-
例外与限制。 为避免疑义,如果例外与限制适用于您的使用,则本公共许可证不适用,您无需遵守其条款和条件。
-
期限。 本公共许可证的期限在第 6(a) 条中规定。
-
媒介和格式;允许技术修改。 许可人授权您在所有现在已知或以后创建的媒介和格式中行使许可权利,并进行必要的技术修改以实现此目的。 许可人放弃和/或同意不主张任何权利或权限来禁止您进行必要的技术修改以行使许可权利,包括规避有效技术措施所必要的技术修改。 就本公共许可证而言,仅进行本第 2(a)(4) 条授权的修改绝不会产生演绎作品。
-
下游接收者。
A. 许可人发出的要约 – 许可作品。 许可作品的每个接收者都会自动收到许可人发出的要约,以根据本公共许可证的条款和条件行使许可权利。
B. 许可人发出的额外要约 – 演绎作品。 来自您的演绎作品的每个接收者都会自动收到许可人发出的要约,以根据您应用的演绎者许可证的条件在演绎作品中行使许可权利。
C. 无下游限制。 如果这样做会限制许可作品的任何接收者行使许可权利,则您不得对许可作品提供或施加任何附加或不同的条款或条件,或对其应用任何有效技术措施。
-
无背书。 本公共许可证中的任何内容均不构成或不得解释为允许您声明或暗示您或您对许可作品的使用与许可人或指定为根据第 3(a)(1)(A)(i) 条提供署名的人员有关联,或受其赞助、认可或授予官方地位。
b. 其他权利。
-
精神权利,例如完整性权,未在本公共许可证下许可,公开权、隐私权和/或其他类似的人格权也未在本公共许可证下许可; 但是,在可能的范围内,许可人放弃和/或同意不主张许可人持有的任何此类权利,但以允许您行使许可权利的必要程度为限,而非其他。
-
专利权和商标权未在本公共许可证下许可。
-
在可能的范围内,许可人放弃向您收取因行使许可权利而产生的版税的任何权利,无论是直接收取还是通过任何自愿或可放弃的法定或强制许可计划下的集体管理组织收取。 在所有其他情况下,许可人明确保留收取此类版税的任何权利,包括当许可作品用于非商业性目的以外的用途时。
第 3 条 – 许可证条件。
您行使许可权利明确受以下条件的约束。
a. 署名。
-
如果您共享许可作品(包括以修改后的形式),您必须:
A. 保留以下内容,如果这些内容由许可人随许可作品提供:
i. 许可作品的创作者和任何其他被指定接受署名的人员的身份标识,以许可人要求的任何合理方式(包括通过指定的笔名);
ii. 版权声明;
iii. 指向本公共许可证的声明;
iv. 指向免责声明的声明;
v. 在合理可行的范围内,指向许可作品的 URI 或超链接;
B. 指明您是否修改了许可作品,并保留对任何先前修改的指示;以及
C. 指明许可作品是根据本公共许可证许可的,并包含本公共许可证的文本,或指向本公共许可证的 URI 或超链接。
-
您可以根据您共享许可作品的媒介、方式和背景,以任何合理的方式满足第 3(a)(1) 条中的条件。 例如,通过提供指向包含所需信息的资源的 URI 或超链接来满足条件可能是合理的。
-
如果许可人要求,您必须在合理可行的范围内删除第 3(a)(1)(A) 条要求的任何信息。
b. 相同方式共享。
除了第 3(a) 条中的条件外,如果您共享您制作的演绎作品,则以下条件也适用。
-
您应用的演绎者许可证必须是具有相同许可证要素的知识共享许可证、本版本或更高版本,或 BY-NC-SA 兼容许可证。
-
您必须包含您应用的演绎者许可证的文本,或指向该许可证的 URI 或超链接。 您可以根据您共享演绎作品的媒介、方式和背景,以任何合理的方式满足此条件。
-
您不得对演绎作品提供或施加任何附加或不同的条款或条件,或对其应用任何有效技术措施,从而限制行使根据您应用的演绎者许可证授予的权利。
第 4 条 – 独创性数据库权利。
如果许可权利包括适用于您使用许可作品的独创性数据库权利:
a. 为避免疑义,第 2(a)(1) 条授予您仅为非商业性目的提取、重用、复制和共享数据库的全部或大部分内容的权利;
b. 如果您将数据库的全部或大部分内容包含在您拥有独创性数据库权利的数据库中,则您拥有独创性数据库权利的数据库(但不包括其个别内容)是演绎作品,包括为了第 3(b) 条的目的;并且
c. 如果您共享数据库的全部或大部分内容,您必须遵守第 3(a) 条中的条件。
为避免疑义,本第 4 条补充而非取代您在本公共许可证下对许可权利包括其他版权及相关权利的情况承担的义务。
第 5 条 – 免责声明和责任限制。
a. 除非许可人另行单独承诺,在可能的范围内,许可人按“现状”和“现有”情况提供许可作品,并且不对许可作品作出任何类型的陈述或保证,无论是明示、暗示、法定或其他形式的。 这包括但不限于关于所有权、适销性、特定用途的适用性、不侵权、不存在潜在或其他缺陷、准确性或是否存在错误(无论已知与否或是否可发现)的保证。 如果在全部或部分范围内不允许免责声明,则本免责声明可能不适用于您。
b. 在可能的范围内,在任何情况下,许可人均不对您在任何法律理论(包括但不限于过失)或其他方面承担因本公共许可证或使用许可作品而引起的任何直接、特殊、间接、附带、后果性、惩罚性、惩戒性或其他损失、成本、费用或损害赔偿责任,即使许可人已被告知存在此类损失、成本、费用或损害赔偿的可能性。 如果在全部或部分范围内不允许责任限制,则本责任限制可能不适用于您。
c. 上述免责声明和责任限制应以尽可能接近绝对免责声明和放弃所有责任的方式解释。
第 6 条 – 期限和终止。
a. 本公共许可证适用于此处许可的版权及相关权利的期限。 但是,如果您未能遵守本公共许可证,则您在本公共许可证下的权利将自动终止。
b. 如果您根据第 6(a) 条使用许可作品的权利已终止,则该权利将恢复:
-
自违规行为得到纠正之日起自动恢复,前提是违规行为在您发现违规行为后 30 天内得到纠正;或
-
经许可人明确恢复。
为避免疑义,本第 6(b) 条不影响许可人可能拥有的寻求补救您违反本公共许可证行为的任何权利。
c. 为避免疑义,许可人也可能根据单独的条款或条件提供许可作品,或随时停止分发许可作品; 但是,这样做不会终止本公共许可证。
d. 第 1、5、6、7 和 8 条在本公共许可证终止后仍然有效。
第 7 条 – 其他条款和条件。
a. 除非明确同意,否则许可人不受您传达的任何附加或不同条款或条件的约束。
b. 关于许可作品的任何安排、谅解或协议,如果未在此处说明,则与本公共许可证的条款和条件分开且独立。
第 8 条 – 解释。
a. 为避免疑义,本公共许可证不减少、限制、约束或施加条件于任何可以合法进行的,且无需本公共许可证许可的许可作品的使用,并且不应被解释为减少、限制、约束或施加条件于此类使用。
b. 在可能的范围内,如果本公共许可证的任何条款被视为不可执行,则应自动修改至使其可执行的最小程度。 如果该条款无法修改,则应从本公共许可证中删除,而不影响其余条款和条件的可执行性。
c. 除非许可人明确同意,否则不得放弃本公共许可证的任何条款或条件,也不得同意任何不遵守行为。
d. 本公共许可证中的任何内容均不构成或不得解释为限制或放弃适用于许可人或您的任何特权和豁免,包括来自任何司法管辖区或当局的法律程序。
知识共享不是其公共许可证的当事方。 尽管如此,知识共享可以选择将其公共许可证之一应用于其发布的材料,并且在这些情况下将被视为“许可人”。 除非为了表明材料是根据知识共享公共许可证共享的或经 creativecommons.org/policies 上发布的知识共享政策另行允许,否则未经知识共享事先书面同意,知识共享不授权使用“知识共享”商标或知识共享的任何其他商标或徽标,包括但不限于与对其任何公共许可证的任何未经授权的修改或关于许可材料使用的任何其他安排、谅解或协议有关的情况。 为避免疑义,本段不构成本公共许可证的一部分。
可以通过 creativecommons.org 联系知识共享。