让 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。