数据驱动的生成表


关于本教程

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

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

Hands-On Rust


在上一章节中,我们将生成机制改为数据驱动:您在 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
}
}

这个函数非常简单:

  1. 我们获取 raws.raws.spawn_table - 这是主生成表列表。
  2. 我们使用 iter() 获取一个迭代器。
  3. 我们使用 filter 仅包含在请求的地图深度范围内的项目。
  4. 我们 collect() 将其收集到一个 SpawnTableEntry 行的引用向量中。
  5. 我们迭代所有收集到的可用选项:
    1. 我们获取权重。
    2. 如果条目具有“增加地图深度到权重”的要求,我们将该深度添加到该条目的权重。
    3. 我们将其添加到我们的 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 该项目,您将在游戏中找到新实体:

Screenshot.

总结

生成表就到此为止了!在过去的两个章节中,您获得了相当大的力量 - 请明智地使用它。您现在可以添加各种实体,而无需编写一行 Rust 代码,并且可以轻松地开始将游戏塑造成您想要的样子。在下一章中,我们将开始这样做。

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

在您的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)

版权 (C) 2019, Herbert Wolverson.