数据驱动设计:Raw Files


关于本教程

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

如果您喜欢这个教程并希望我继续创作,请考虑支持我的 Patreon

Hands-On Rust


如果您玩过 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 - 这就是 lockunwrap 代码的用途:它管理 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
}
}

这是一个很长的函数,但它实际上非常直接 - 并且使用了我们之前多次遇到的模式。它执行以下操作:

  1. 它查找我们传递的 key 是否存在于 item_index 中。如果不存在,则返回 None - 它什么也没做。
  2. 如果 key 确实存在,则它将 Name 组件添加到实体 - 使用 raw 文件中的名称。
  3. 如果 Renderable 存在于物品定义中,它将创建一个类型为 Renderable 的组件。
  4. 如果 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.rsspawn_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
    }
}

这里有两个新字段!shieldweapon。我们需要扩展我们的 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。