让 NPC 栩栩如生


关于本教程

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

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

Hands-On Rust


我本想建议用黑暗的咒语和蜡烛来赋予 NPC 生命,但实际上 - 这更多的是代码。我们不希望我们的旁观者再像石头一样傻站着了。他们不必表现得特别明智,但如果他们至少能四处走动一下(除了商人,否则会很烦人 - “铁匠去哪儿了?”)并告诉你他们的一天,那就太好了。

新组件 - 区分商人和旁观者

首先,我们将创建一个新组件 - Vendor(商人)。在 components.rs 中,添加以下组件类型:

#![allow(unused)]
fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Vendor {}
}

不要忘记在 main.rssaveload_system.rs 中注册它!

现在我们将调整我们的原始文件(spawns.json);所有带有 "ai" : "bystander" 的商人需要更改为 "ai" : "vendor"。因此,我们将为我们的酒保、炼金术士、布商、铁匠和可疑商人进行更改。

接下来,我们调整 raws/rawmaster.rsspawn_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 们在随机地笨拙地走动。让他们移动在很大程度上避免了城镇感觉像雕像之城!

Screenshot

会说话的 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.rssaveload_system.rs 中注册它!

我们需要更新 rawmaster.rsspawn_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 的写入访问权限,以及对 QuipsName 组件存储的访问权限。现在,我们将俏皮话添加到函数体中:

#![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
...
}

我们可以逐步了解它的工作原理:

  1. 它从 quips 存储中请求一个组件。这将是一个 Option - 要么是 None(没什么可说的),要么是 Some - 包含俏皮话。
  2. 如果 确实 有一些俏皮话...
  3. 如果可用俏皮话列表不为空,视野包含玩家的图块,并且 1d6 掷骰结果为 1...
  4. 我们查找实体的名称,
  5. quipavailable 列表中随机选择一个条目。
  6. 将字符串记录为 NameQuip
  7. 从该实体的可用俏皮话列表中删除该俏皮话 - 他们不会一直重复自己。

如果您现在运行游戏,您会发现顾客愿意评论普遍的生活:

Screenshot

我们会发现这可以在游戏的其他部分中使用,例如让守卫喊警报,或者让地精说一些适当的“地精式”的话。为了简洁起见,我们不会在此处列出游戏中所有的俏皮话。查看源代码 以查看我们添加了什么。

这种“花絮”在让世界感觉生动方面大有帮助,即使它并没有真正对游戏玩法产生有意义的增加。由于城镇是玩家看到的第一个区域,因此最好有一些花絮。

户外 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 运行,您将看到一个充满生机的城镇:

Screenshot

总结

本章真正地让我们的城镇栩栩如生。总是有改进的空间,但这对于起始地图来说已经足够好了!下一章将改变方向,开始为游戏添加属性

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

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

版权所有 (C) 2019, Herbert Wolverson。