第二章 - 实体和组件


关于本教程

本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!

如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon

实践中的 Rust


本章将介绍一个实体组件系统(ECS)的全部内容,这将成为本教程其余部分的基础。Rust 有一个非常好的 ECS,称为 Specs - 本教程将向您展示如何使用它,并尝试演示一些早期使用它的好处。

关于实体和组件

如果你以前做过游戏,你可能已经习惯了面向对象的设计(这在原始的 Python libtcod教程中非常常见,这个教程也启发了我们)。面向对象(OOP)设计并没有什么真正的错误——但游戏开发者已经逐渐远离了它,主要是因为当你开始扩展游戏时,它可能会变得非常混乱,超出了你最初的设计想法。

你可能已经见过这样的“类层次结构”,例如这个简化的:

BaseEntity
    Monster
        MeleeMob
            OrcWarrior
        ArcherMob
            OrcArcher

你可能会有比这更复杂的东西,但它作为一个例子是有效的。BaseEntity 将包含代码/数据,以便在地图上显示为一个实体,Monster 表示它是一个坏人,MeleeMob 将包含寻找近战目标、接近并杀死它们的逻辑。同样,ArcherMob 将尝试保持最佳距离并使用远程武器从安全距离射击。这种分类的问题在于它可能具有限制性,而且在你意识到之前——你已经开始为更复杂的组合编写单独的类。例如,如果我们设计一个既能近战又能射箭的兽人——如果你完成了 与绿皮交朋友 任务,它可能会变得友好?你可能会将所有这些逻辑组合到一个特殊情况类中。这行得通——而且很多游戏都是这样发布的——但如果有一种更简单的方法呢?

基于实体组件的设计试图消除层次结构,转而实现一组描述你想要的“组件”。一个“实体”是一个东西——任何东西,真的。一个兽人,一只狼,一瓶药水,一个虚幻的硬盘格式化幽灵——任何你想要的。它也非常简单:几乎只是一个识别号码。魔力来自于实体能够拥有你想要添加的任意数量的组件。组件只是数据,按你想要赋予实体的任何属性分组。

例如,你可以使用以下组件构建相同的一组生物:PositionRenderableHostileMeleeAIRangedAI和某种战斗统计组件(用于描述它们的武器、生命值等)。一个兽人战士需要一个位置,这样你知道它们在哪里,一个可渲染组件,这样你知道如何绘制它们。它是敌对的,所以你标记为敌对。给它一个近战 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,它知道如何存储PositionRenderable组件。仅仅让这些组件存在并不能帮助我们,除了提供结构上的指示。为了使用它们,它们需要附加到游戏中的某个东西上。在 ECS 世界中,这个东西被称为实体。实体非常简单;它们只不过是一个识别号码,告诉 ECS 一个实体存在。它们可以附加任何组合的组件。在这种情况下,我们将创建一个知道自己在屏幕上的位置,并且知道如何在屏幕上表示的实体

我们可以创建一个同时具有 RenderablePosition 组件的实体,如下所示:

#![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++ 中,迭代器提供了一个 beginnextend 函数 - 你可以使用它们在集合中的元素之间移动。Rust 扩展了相同的概念,只是更加强大:只要你愿意,几乎任何东西都可以变成迭代器。迭代器与 for 循环配合得非常好 - 你可以将任何迭代器作为 for x in iterator 循环的目标。我们之前讨论的 0..10 实际上是一个范围 - 并为 Rust 提供了一个迭代器来导航。

这里另一个有趣的事情是括号。在 Rust 中,当你用括号包裹变量时,你正在创建一个元组。这些只是变量的集合,组合在一起——但不需要为此情况专门创建一个结构体。你可以通过数字访问(mytuple.0mytuple.1等)单独访问它们,以获取每个字段,或者你可以解构它们。(one, two) = (1, 2) 将变量 one 设置为 1,将变量 two 设置为 2。这就是我们在这里所做的:join 迭代器返回包含 PositionRenderable 组件的元组作为 .0.1。由于输入这些内容既丑陋又不清晰,我们将它们解构为命名的变量 posrender。这在一开始可能会令人困惑,所以如果你有困难,我推荐阅读 Rust By Example 的元组部分

#![allow(unused)]
fn main() {
ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph); 
}

我们为每个同时具有 PositionRenderable 组件的实体运行此操作。join 方法保证传递给我们的这两个组件属于同一个实体。任何只有一个组件而没有另一个组件的实体都不会包含在我们返回的数据中。

ctx 是 RLTK 的实例,当 tick 运行时传递给我们。它提供了一个名为 set 的函数,该函数将单个终端字符设置为您选择的字形/颜色。因此,我们将来自 pos(该实体的 Position 组件)的数据和来自 render(该实体的 Renderable 组件)的颜色/字形传递给它。

有了这个设置,任何同时具有PositionRenderable的实体都会被渲染到屏幕上!你可以添加任意多个,它们都会被渲染。移除其中一个组件或另一个组件,它们就不会被渲染(例如,如果一个物品被捡起,你可能会移除它的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 循环是系统的简写,用于在渲染系统中进行的相同迭代:它将对每个同时具有 LeftMoverPosition 的实体运行一次。 注意,我们在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();
    }
}
}

这相对简单:

  1. impl State 表示我们希望为 State 实现功能。
  2. fn run_systems(&mut self) 表示我们正在定义一个函数,并且它需要可变(即允许更改)访问self;这意味着它可以使用 self. 关键字访问其 State 实例中的数据。
  3. let mut lw = LeftWalker{} 创建一个新的(可更改的)LeftWalker 系统实例。
  4. lw.run_now(&self.ecs) 告诉系统运行,并告诉它如何找到 ECS。
  5. 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));
    }
}
}

借鉴我们之前的经验,我们可以看到这获得了对 PlayerPosition 的写访问权限。然后它将两者连接起来,确保它只对同时具有这两种组件类型的实体起作用——在这种情况下,只有玩家。然后它将 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 的情况下,只有两种可能的值:SomeNoneNone => {} 这一行表示“匹配 ctx.key 没有数据的情况” - 并运行一个空块。Some(key) 是另一种选择;有一些数据 - 我们会要求 Rust 将其作为名为 key 的变量提供给我们(你可以随意命名它)。

然后我们再次匹配,这次是根据键。我们为每个想要处理的情况写了一行代码:VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs)表示如果key等于VirtualKeyCode::LeftVirtualKeyCode是枚举类型的名称),我们应该调用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)
}

这一章有很多内容需要消化,但它提供了一个非常坚实的基础来构建。很棒的是:你现在比许多有抱负的开发者走得更远!你已经在屏幕上有实体,并且可以用键盘移动。

本章的源代码可以在此处找到这里

在浏览器中运行本章的示例(需要 WebGL2)


版权 (C) 2019, Herbert Wolverson.

版权 (C) 2024, myedgetech.com.