AI 清理和状态效果
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在设计文档中,我们指出我们希望 AI 比普通的石头更智能。在完成各个章节的过程中,我们也添加了很多与 AI 相关的系统,并且(您可能已经注意到)诸如持续应用混乱效果(以及偶尔出现无法击中刚刚移动的怪物的问题)之类的事情已经悄悄地溜走了!
随着我们为怪物增加复杂性,最好把所有这些都理顺,使其更容易支持新功能,并一致地处理诸如移动之类的常见问题。
链式系统
与其尝试在一个系统中为 NPC 完成所有事情,我们可以将这个过程分解为几个步骤。这样做会增加一些输入量,但其优点是每个步骤都是独立的、清晰的,并且只做一件事 - 这使得调试容易得多。这类似于我们处理 WantsToMelee
的方式 - 我们指示一个意图,然后在自己的步骤中处理它 - 这使我们可以将目标选择和实际战斗分开。
让我们看看这些步骤,看看它们是如何分解的:
- 我们确定轮到 NPC 行动了。
- 我们检查状态效果 - 例如混乱,以确定他们是否真的可以行动。
- 该 AI 类型对应的 AI 模块扫描周围环境,并确定他们是想移动、攻击还是什么都不做。
- 发生移动,这会更新各种全局状态。
- 发生战斗,这可能会杀死怪物或使其在未来无法行动。
模块化 AI
我们已经有了相当多的 AI 系统,而这只是增加了更多。因此,让我们将 AI 移到一个模块中。创建一个新文件夹 src/ai
- 这将是新的 AI 模块。创建一个 mod.rs
文件,并将以下内容放入其中:
#![allow(unused)] fn main() { mod animal_ai_system; mod bystander_ai_system; mod monster_ai_system; pub use animal_ai_system::AnimalAI; pub use bystander_ai_system::BystanderAI; pub use monster_ai_system::MonsterAI; }
这告诉它使用其他 AI 模块,并在 ai
命名空间中共享它们。现在将 animal_ai_system
、bystander_ai_system
和 monster_ai_system
从您的 src
目录移动到 src\ai
。在 main.rs
的序言中(您放置所有 mod
和 use
语句的地方),删除这些系统的 mod
和 use
语句。将它们替换为单行 mod ai;
。最后,您可以清理 run_systems
以通过 ai
命名空间引用这些系统:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut mob = ai::MonsterAI{}; mob.run_now(&self.ecs); let mut animal = ai::AnimalAI{}; animal.run_now(&self.ecs); let mut bystander = ai::BystanderAI{}; bystander.run_now(&self.ecs); ... }
在您的 ai/X_system
文件中,您有读取 use super::{...}
的行。将 super
替换为 crate
,以表明您想使用来自父 crate 的组件(和其他类型)。
如果您现在 cargo run
,您会得到与之前完全相同的游戏 - 您的重构成功了!
确定轮到谁了 - 先攻权/能量消耗
到目前为止,我们以严格但不够灵活的方式处理了我们的回合:玩家先行动,然后所有 NPC 再行动。来回循环,永远如此。这效果相当好,但它不允许太多变化:您不能让某个实体比其他实体更快,所有动作都花费相同的时间,并且诸如加速和减速法术之类的东西将无法实现。
许多 roguelike 游戏使用先攻权或先攻权消耗的变体来确定轮到谁了,所以我们将采用类似的方法。我们不想过于随机,这样您就不会突然看到事物加速和减速,但我们也希望更加灵活。我们也希望它稍微有点随机性,这样默认情况下所有 NPC 都不会同时行动 - 基本上就是我们已经拥有的情况。减慢重甲/武器使用者的速度,并让轻型装备的使用者更快(匕首使用者可以比双手巨剑使用者更频繁地攻击!)也会很好。
在 components.rs
中(并在 main.rs
和 saveload_system.rs
中注册),让我们创建一个新的 Initiative
组件:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Initiative { pub current : i32 } }
我们希望玩家从一个先攻权分数开始(我们将使用 0,这样他们总是先开始)。在 spawners.rs
中,我们只需将其添加到 player
函数中,作为玩家的另一个组件:
#![allow(unused)] fn main() { .with(Initiative{current: 0}) }
我们还希望所有 NPC 都从一个先攻权分数开始。因此,在 raws/rawmaster.rs
中,我们将其添加到 spawn_named_mob
函数中,作为另一个始终存在的组件。我们将给怪物一个 2 的初始先攻权 - 因此在第一回合中,它们将在玩家之后立即处理(稍后我们将担心后续回合)。
#![allow(unused)] fn main() { // 先攻权为 2 eb = eb.with(Initiative{current: 2}); }
这添加了组件,但目前它根本没有做任何事情。我们将从在 components.rs
中创建另一个新组件开始(并在 main.rs
和 saveload_system.rs
中注册),称为 MyTurn
:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct MyTurn {} }
MyTurn
组件背后的想法是,如果您拥有该组件,那么就轮到您行动了 - 并且您应该被包括在 AI/回合控制中(如果玩家拥有 MyTurn
,那么我们等待输入)。如果您没有它,那么您就不能行动。我们也可以将其用作过滤器:因此,诸如状态效果之类的东西可以检查是否轮到您了,并且您是否受到状态的影响,它们可能会确定您必须跳过您的回合。
现在我们应该制作一个新的 - 简单的 - 系统来处理先攻权掷骰。创建一个新文件 ai/initiative_system.rs
:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{Initiative, Position, MyTurn, Attributes, RunState}; pub struct InitiativeSystem {} impl<'a> System<'a> for InitiativeSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, Initiative>, ReadStorage<'a, Position>, WriteStorage<'a, MyTurn>, Entities<'a>, WriteExpect<'a, rltk::RandomNumberGenerator>, ReadStorage<'a, Attributes>, WriteExpect<'a, RunState>, ReadExpect<'a, Entity>); fn run(&mut self, data : Self::SystemData) { let (mut initiatives, positions, mut turns, entities, mut rng, attributes, mut runstate, player) = data; if *runstate != RunState::Ticking { return; } // 我们稍后会添加 Ticking;如果您想在此时测试,请使用 MonsterTurn // 清除我们错误留下的任何剩余 MyTurn turns.clear(); // 掷先攻权 for (entity, initiative, _pos) in (&entities, &mut initiatives, &positions).join() { initiative.current -= 1; if initiative.current < 1 { // 轮到我了! turns.insert(entity, MyTurn{}).expect("无法插入回合"); // 重新掷骰 initiative.current = 6 + rng.roll_dice(1, 6); // 给予敏捷奖励 if let Some(attr) = attributes.get(entity) { initiative.current -= attr.quickness.bonus; } // TODO: 稍后将在此处添加更多先攻权授予的增益/惩罚 // 如果是玩家,我们希望进入 AwaitingInput 状态 if entity == *player { *runstate = RunState::AwaitingInput; } } } } } }
这非常简单:
- 我们首先清除所有剩余的
MyTurn
组件,以防我们忘记删除一个(这样实体就不会乱跑)。 - 我们迭代所有具有
Initiative
组件的实体(表明它们可以行动)和Position
组件的实体(我们不使用它,但表明它们在当前地图层上并且可以行动)。 - 我们从实体的当前先攻权中减去 1。
- 如果当前先攻权为 0(或更小,以防我们搞砸了!),我们向它们应用
MyTurn
组件。然后我们重新掷骰它们的当前先攻权;我们现在使用6 + 1d6 + 敏捷奖励
。请注意,我们留下了一个注释,表明我们稍后会使它更复杂! - 如果现在轮到玩家了,我们将全局
RunState
更改为AwaitingInput
- 是时候处理玩家的指令了。
我们也在检查是否轮到怪物了;我们实际上会改变这一点 - 但如果我们在测试它,我不希望系统一遍又一遍地旋转掷骰先攻权!
现在我们需要进入 mod.rs
并添加 mod initiative_system.rs; pub use initiative_system::InitiativeSystem;
这对行,以将其暴露给程序的其余部分。然后我们打开 main.rs
并将其添加到 run_systems
中:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut initiative = ai::InitiativeSystem{}; initiative.run_now(&self.ecs); ... }
我们在各种 AI 函数运行之前添加了它,但在我们获得地图索引和视野之后 - 因此它们具有最新的数据可以使用。
调整游戏循环以使用先攻权
打开 main.rs
,我们将编辑 RunState
以摆脱 PlayerTurn
和 MonsterTurn
条目 - 将它们替换为 Ticking
。这将破坏很多代码 - 但没关系,我们实际上是在简化和获得功能,这在大多数标准下都是双赢!这是新的 RunState
:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, Ticking, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel, PreviousLevel, ShowRemoveItem, GameOver, MagicMapReveal { row : i32 }, MapGeneration, ShowCheatMenu } }
在我们的主循环的 match
函数中,我们可以完全删除 MonsterTurn
条目,并将 PlayerTurn
调整为更通用的 Ticking
状态:
#![allow(unused)] fn main() { RunState::Ticking => { self.run_systems(); self.ecs.maintain(); match *self.ecs.fetch::<RunState>() { RunState::AwaitingInput => newrunstate = RunState::AwaitingInput, RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 }, _ => newrunstate = RunState::Ticking } } }
您还需要在 main.rs
中搜索 PlayerTurn
和 MonsterTurn
;当许多状态完成时,它们会返回到其中一个状态。它们现在想要返回到 Ticking
。
同样,在 player.rs
中,有很多地方我们返回 RunState::PlayerTurn
- 您需要将所有这些更改为 Ticking
。
我们将修改饥饿时钟,使其仅在您的回合中计时。这实际上变得更简单了;我们只需加入 MyTurn
即可删除整个“proceed”系统:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{HungerClock, RunState, HungerState, SufferDamage, gamelog::GameLog, MyTurn}; pub struct HungerSystem {} impl<'a> System<'a> for HungerSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteStorage<'a, HungerClock>, ReadExpect<'a, Entity>, // 玩家 ReadExpect<'a, RunState>, WriteStorage<'a, SufferDamage>, WriteExpect<'a, GameLog>, ReadStorage<'a, MyTurn> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut hunger_clock, player_entity, runstate, mut inflict_damage, mut log, turns) = data; for (entity, mut clock, _myturn) in (&entities, &mut hunger_clock, &turns).join() { clock.duration -= 1; if clock.duration < 1 { match clock.state { HungerState::WellFed => { clock.state = HungerState::Normal; clock.duration = 200; if entity == *player_entity { log.entries.push("您不再吃得饱饱的了。".to_string()); } } HungerState::Normal => { clock.state = HungerState::Hungry; clock.duration = 200; if entity == *player_entity { log.entries.push("您饿了。".to_string()); } } HungerState::Hungry => { clock.state = HungerState::Starving; clock.duration = 200; if entity == *player_entity { log.entries.push("您饿得要命了!".to_string()); } } HungerState::Starving => { // 饥饿造成的伤害 if entity == *player_entity { log.entries.push("您的饥饿感变得痛苦起来!您受到 1 点生命值伤害。".to_string()); } SufferDamage::new_damage(&mut inflict_damage, entity, 1, false); } } } } } } }
这将使 ai
中的文件出现错误。我们将进行最少的更改,以使其现在可以运行。删除检查游戏状态的行,并为 MyTurn
添加读取存储。将回合添加到 join 中,以便实体仅在其回合时才行动。所以在 ai/animal_ai_system.rs
中:
#![allow(unused)] fn main() { impl<'a> System<'a> for AnimalAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, Entity>, ReadExpect<'a, RunState>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Herbivore>, ReadStorage<'a, Carnivore>, ReadStorage<'a, Item>, WriteStorage<'a, WantsToMelee>, WriteStorage<'a, EntityMoved>, WriteStorage<'a, Position>, ReadStorage<'a, MyTurn> ); fn run(&mut self, data : Self::SystemData) { let (mut map, player_entity, runstate, entities, mut viewshed, herbivore, carnivore, item, mut wants_to_melee, mut entity_moved, mut position, turns) = data; ... for (entity, mut viewshed, _herbivore, mut pos, _turn) in (&entities, &mut viewshed, &herbivore, &mut position, &turns).join() { ... for (entity, mut viewshed, _carnivore, mut pos, _turn) in (&entities, &mut viewshed, &carnivore, &mut position, &turns).join() { }
同样,在 bystander_ai_system.rs
中:
#![allow(unused)] fn main() { 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>, ReadExpect<'a, Point>, WriteExpect<'a, GameLog>, WriteStorage<'a, Quips>, ReadStorage<'a, Name>, ReadStorage<'a, MyTurn>); 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, turns) = data; for (entity, mut viewshed,_bystander,mut pos, _turn) in (&entities, &mut viewshed, &bystander, &mut position, &turns).join() { ... }
再次在 monster_ai_system.rs
中:
#![allow(unused)] fn main() { impl<'a> System<'a> for MonsterAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, Point>, ReadExpect<'a, Entity>, ReadExpect<'a, RunState>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Monster>, WriteStorage<'a, Position>, WriteStorage<'a, WantsToMelee>, WriteStorage<'a, Confusion>, WriteExpect<'a, ParticleBuilder>, WriteStorage<'a, EntityMoved>, ReadStorage<'a, MyTurn>); fn run(&mut self, data : Self::SystemData) { let (mut map, player_pos, player_entity, runstate, entities, mut viewshed, monster, mut position, mut wants_to_melee, mut confused, mut particle_builder, mut entity_moved, turns) = data; for (entity, mut viewshed,_monster,mut pos, _turn) in (&entities, &mut viewshed, &monster, &mut position, &turns).join() { }
这样就解决了编译错误!现在 cargo run
游戏。它像以前一样运行,只是稍微慢了一点。一旦我们有了基本的功能,我们将担心性能 - 所以这是一个很大的进步,我们有了一个先攻权系统!
处理状态效果
现在,我们在 monster_ai_system
中检查混乱 - 实际上在旁观者、商人和动物中忘记了它。与其到处复制/粘贴代码,不如利用这个机会创建一个系统来处理状态效果回合跳过,并清理其他系统以从中受益。创建一个新文件 ai/turn_status.rs
:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Confusion, RunState}; pub struct TurnStatusSystem {} impl<'a> System<'a> for TurnStatusSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, WriteStorage<'a, Confusion>, Entities<'a>, ReadExpect<'a, RunState>); fn run(&mut self, data : Self::SystemData) { let (mut turns, mut confusion, entities, runstate) = data; if *runstate != RunState::Ticking { return; } let mut not_my_turn : Vec<Entity> = Vec::new(); let mut not_confused : Vec<Entity> = Vec::new(); for (entity, _turn, confused) in (&entities, &mut turns, &mut confusion).join() { confused.turns -= 1; if confused.turns < 1 { not_confused.push(entity); } else { not_my_turn.push(entity); } } for e in not_my_turn { turns.remove(e); } for e in not_confused { confusion.remove(e); } } } }
这非常简单:它迭代每个处于混乱状态的人,并减少他们的回合计数器。如果他们仍然处于混乱状态,则移除 MyTurn
。如果他们已经恢复,则移除 Confusion
。您需要在 ai/mod.rs
中为其添加 mod
和 pub use
语句,并将其添加到 main.rs
中的 run_systems
函数中:
#![allow(unused)] fn main() { let mut initiative = ai::InitiativeSystem{}; initiative.run_now(&self.ecs); let mut turnstatus = ai::TurnStatusSystem{}; turnstatus.run_now(&self.ecs); }
这展示了我们正在使用的新模式:系统只做一件事,并且可以移除 MyTurn
以防止将来执行。您还可以进入 monster_ai_system
并删除与混乱相关的所有内容。
爱说话的 NPC
还记得当我们添加土匪时,我们给他们一些评论来增加趣味性吗?您可能已经注意到他们实际上并没有说话!那是因为我们在旁观者 AI 中处理了说话 - 而不是作为一个普遍的概念。让我们将说话移到它自己的系统中。创建一个新文件 ai/quipping.rs
:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{gamelog::GameLog, Quips, Name, MyTurn, Viewshed}; pub struct QuipSystem {} impl<'a> System<'a> for QuipSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, GameLog>, WriteStorage<'a, Quips>, ReadStorage<'a, Name>, ReadStorage<'a, MyTurn>, ReadExpect<'a, rltk::Point>, ReadStorage<'a, Viewshed>, WriteExpect<'a, rltk::RandomNumberGenerator>); fn run(&mut self, data : Self::SystemData) { let (mut gamelog, mut quips, names, turns, player_pos, viewsheds, mut rng) = data; for (quip, name, viewshed, _turn) in (&mut quips, &names, &viewsheds, &turns).join() { if !quip.available.is_empty() && viewshed.visible_tiles.contains(&player_pos) && rng.roll_dice(1,6)==1 { 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!("{} 说 \"{}\"", name.name, quip.available[quip_index]) ); quip.available.remove(quip_index); } } } } }
这基本上是 bystander_ai_system
中的说话代码,所以我们真的不需要过多地讨论它。您确实想将其添加到 main.rs
中的 run_systems
中,以便它可以运行(并在 ai/mod.rs
中添加 mod
和 pub use
语句):
#![allow(unused)] fn main() { turnstatus.run_now(&self.ecs); let mut quipper = ai::QuipSystem{}; quipper.run_now(&self.ecs); }
还要进入 bystander_ai_system.rs
并删除所有说话代码!它缩短了很多,如果您现在 cargo run
,土匪就可以侮辱您了。实际上,现在可以给任何 NPC 添加俏皮话 - 并且会愉快地对您说些什么。再一次,我们使系统更小并且获得了功能。又一次胜利!
让 AI 看起来像在思考
目前,我们为每种类型的 AI 都设置了一个单独的系统 - 结果导致一些代码重复。我们还有一些非常不切实际的事情正在发生:怪物保持完全静止,直到它们能看到您,并且一旦您绕过一个角落,它们就会完全忘记您。村民像喝醉了的酒鬼一样随机移动,即使他们是清醒的。狼会追捕鹿 - 但同样,仅当它们可见时才追捕。您可以通过给 NPC 目标 - 并让目标持续不止一个回合,从而显着提高表面上的 AI 智能(它仍然很笨!)。然后,您可以将基于类型的决策更改为基于目标的决策;帮助 NPC 实现他们生活中想要的任何东西。
让我们花一点时间来考虑一下我们的 NPC 在生活中真正想要什么:
- 鹿和其他食草动物真正想要吃草,不被打扰,并逃离可能杀死它们的东西(实际上是所有东西;在食物链中不是一个好地方)。
- 怪物想要守卫地下城,杀死玩家,并在其他方面过着平静的生活。
- 狼(和其他食肉动物)想要吃玩家和食草动物。
- 凝胶状立方体实际上并不以思考而闻名!
- 村民真正想要过他们的日常生活,偶尔对路过的玩家说些什么。
- 商人想待在他们的商店里,并在未来的章节更新中向您出售商品!
这并没有真正考虑到短暂的目标;受伤的怪物可能想要逃离战斗,怪物可能想要考虑拿起碰巧就在它们旁边的发光的末日长剑,等等。但这仍然是一个好的开始。
我们实际上可以将很多这些归结为“状态机”。您以前见过这些:RunState
使整个游戏成为一种状态,并且每个 UI 框都返回当前状态。在这种情况下,我们将让 NPC 拥有一个状态 - 代表他们现在尝试做什么,以及他们是否已经实现它。我们应该能够用 json
原始文件中的标签来描述 AI 的目标,并实现更小的子系统,以使 AI 的行为在某种程度上可信。
确定 AI 对其他实体的感觉
AI 面临的许多决策都围绕着:那是什么,以及我对它们的感觉如何?如果它们是敌人,我应该攻击还是逃跑(取决于我的性格)。如果我对它们感到中立,那么我真的不在乎它们的存在。如果我喜欢它们,我甚至可能想靠近它们!输入每个实体对每个其他实体的感觉将是一项巨大的数据输入工作 - 每次您添加一个实体时,您都需要去将它们添加到每个其他实体(并记住如果您删除它们/想要更改它们,则在所有地方删除/编辑它们)。这不是一个好主意!
像许多游戏一样,我们可以通过一个简单的阵营系统来解决这个问题。NPC(和玩家)是阵营的成员。阵营对其他阵营(包括默认阵营)有感觉。然后我们可以进行简单的阵营查找,以了解 NPC 对潜在目标的感觉。我们还可以在用户界面中包含阵营信息,以帮助玩家了解正在发生的事情。
我们将从 spawns.json
中的阵营表开始。这是第一个草案:
"faction_table" : [
{ "name" : "Player", "responses": { }},
{ "name" : "Mindless", "responses": { "Default" : "attack" } },
{ "name" : "Townsfolk", "responses" : { "Default" : "ignore" } },
{ "name" : "Bandits", "responses" : { "Default" : "attack" } },
{ "name" : "Cave Goblins", "responses" : { "Default" : "attack" } },
{ "name" : "Carnivores", "responses" : { "Default" : "attack" } },
{ "name" : "Herbivores", "responses" : { "Default" : "flee" } }
],
我们还需要为每个 NPC 添加一个条目,例如:"faction" : "Bandit"
。
为了使它工作,我们需要创建一个新的组件来存储阵营成员资格。与往常一样,它需要在 main.rs
和 saveload_system.rs
中注册,并在 components.rs
中定义:
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct Faction { pub name : String } }
让我们从打开 spawner.rs
并修改 player
函数开始,始终将玩家添加到“Player”阵营:
#![allow(unused)] fn main() { .with(Faction{name : "Player".to_string() }) }
现在我们需要在加载其余原始数据时加载阵营表。我们将创建一个新文件 raws/faction_structs.rs
来保存此信息。我们的目标是镜像我们为 JSON 设计的内容:
#![allow(unused)] fn main() { use serde::{Deserialize}; use std::collections::HashMap; #[derive(Deserialize, Debug)] pub struct FactionInfo { pub name : String, pub responses : HashMap<String, String> } }
反过来,我们将其添加到 raws/mod.rs
中的 Raws
结构中:
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Raws { pub items : Vec<Item>, pub mobs : Vec<Mob>, pub props : Vec<Prop>, pub spawn_table : Vec<SpawnTableEntry>, pub loot_tables : Vec<LootTable>, pub faction_table : Vec<FactionInfo> } }
我们还需要将其添加到 raws/rawmaster.rs
中的原始构造函数中:
#![allow(unused)] fn main() { impl RawMaster { pub fn empty() -> RawMaster { RawMaster { raws : Raws{ items: Vec::new(), mobs: Vec::new(), props: Vec::new(), spawn_table: Vec::new(), loot_tables: Vec::new(), faction_table : Vec::new(), }, item_index : HashMap::new(), mob_index : HashMap::new(), prop_index : HashMap::new(), loot_index : HashMap::new() } } }
我们还需要在 Raws
中添加一些索引。我们需要一种比字符串更好的方式来表示反应,所以让我们先在 faction_structs.rs
中添加一个枚举:
#![allow(unused)] fn main() { #[derive(PartialEq, Eq, Hash, Copy, Clone)] pub enum Reaction { Ignore, Attack, Flee } }
现在我们为 RawMaster
添加反应索引:
#![allow(unused)] fn main() { pub struct RawMaster { raws : Raws, item_index : HashMap<String, usize>, mob_index : HashMap<String, usize>, prop_index : HashMap<String, usize>, loot_index : HashMap<String, usize>, faction_index : HashMap<String, HashMap<String, Reaction>> } }
也将其作为 faction_index : HashMap::new()
添加到 RawMaster
构造函数中。最后,我们将设置索引 - 打开 load
函数并在末尾添加以下内容:
#![allow(unused)] fn main() { for faction in self.raws.faction_table.iter() { let mut reactions : HashMap<String, Reaction> = HashMap::new(); for other in faction.responses.iter() { reactions.insert( other.0.clone(), match other.1.as_str() { "ignore" => Reaction::Ignore, "flee" => Reaction::Flee, _ => Reaction::Attack } ); } self.faction_index.insert(faction.name.clone(), reactions); } }
这会迭代所有阵营,然后迭代它们对其他阵营的反应 - 构建一个关于它们如何响应每个阵营的 HashMap
。然后将这些存储在 faction_index
表中。
这样就加载了原始阵营信息,我们仍然必须将其转化为游戏中易于使用的东西。我们还应该在 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 vision_range : i32, pub ai : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32>, pub equipped : Option<Vec<String>>, pub natural : Option<MobNatural>, pub loot_table : Option<String>, pub light : Option<MobLight>, pub faction : Option<String> } }
并在 spawn_named_mob
中添加组件。如果没有,我们将自动将“mindless”应用于怪物:
#![allow(unused)] fn main() { if let Some(faction) = &mob_template.faction { eb = eb.with(Faction{ name: faction.clone() }); } else { eb = eb.with(Faction{ name : "Mindless".to_string() }) } }
现在在 rawmaster.rs
中,我们将添加另一个函数:查询阵营表以获得关于阵营的反应:
#![allow(unused)] fn main() { pub fn faction_reaction(my_faction : &str, their_faction : &str, raws : &RawMaster) -> Reaction { if raws.faction_index.contains_key(my_faction) { let mf = &raws.faction_index[my_faction]; if mf.contains_key(their_faction) { return mf[their_faction]; } else if mf.contains_key("Default") { return mf["Default"]; } else { return Reaction::Ignore; } } Reaction::Ignore } }
因此,给定 my_faction
的名称和另一个实体的阵营(their_faction
),我们可以查询阵营表并返回一个反应。如果没有反应,我们默认使用 Ignore
(这不应该发生,因为我们默认使用 Mindless
)。
通用 AI 任务:处理相邻实体
几乎每个 AI 都需要知道如何处理相邻实体。它可能是敌人(攻击或逃离),可能是可以忽略的人等等 - 但它需要被处理。与其在每个 AI 模块中单独处理它,不如构建一个通用系统来处理它。让我们创建一个新文件 ai/adjacent_ai_system.rs
(并在 ai/mod.rs
中像其他文件一样为它添加 mod
和 pub use
条目):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Faction, Position, Map, raws::Reaction, WantsToMelee}; pub struct AdjacentAI {} impl<'a> System<'a> for AdjacentAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, ReadStorage<'a, Faction>, ReadStorage<'a, Position>, ReadExpect<'a, Map>, WriteStorage<'a, WantsToMelee>, Entities<'a>, ReadExpect<'a, Entity> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, factions, positions, map, mut want_melee, entities, player) = data; let mut turn_done : Vec<Entity> = Vec::new(); for (entity, _turn, my_faction, pos) in (&entities, &turns, &factions, &positions).join() { if entity != *player { let mut reactions : Vec<(Entity, Reaction)> = Vec::new(); let idx = map.xy_idx(pos.x, pos.y); let w = map.width; let h = map.height; // 为每个方向的邻居添加可能的反应 if pos.x > 0 { evaluate(idx-1, &map, &factions, &my_faction.name, &mut reactions); } if pos.x < w-1 { evaluate(idx+1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y > 0 { evaluate(idx-w as usize, &map, &factions, &my_faction.name, &mut reactions); } if pos.y < h-1 { evaluate(idx+w as usize, &map, &factions, &my_faction.name, &mut reactions); } if pos.y > 0 && pos.x > 0 { evaluate((idx-w as usize)-1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y > 0 && pos.x < w-1 { evaluate((idx-w as usize)+1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y < h-1 && pos.x > 0 { evaluate((idx+w as usize)-1, &map, &factions, &my_faction.name, &mut reactions); } if pos.y < h-1 && pos.x < w-1 { evaluate((idx+w as usize)+1, &map, &factions, &my_faction.name, &mut reactions); } let mut done = false; for reaction in reactions.iter() { if let Reaction::Attack = reaction.1 { want_melee.insert(entity, WantsToMelee{ target: reaction.0 }).expect("插入近战错误"); done = true; } } if done { turn_done.push(entity); } } } // 删除已完成实体的回合标记 for done in turn_done.iter() { turns.remove(*done); } } } fn evaluate(idx : usize, map : &Map, factions : &ReadStorage<Faction>, my_faction : &str, reactions : &mut Vec<(Entity, Reaction)>) { for other_entity in map.tile_content[idx].iter() { if let Some(faction) = factions.get(*other_entity) { reactions.push(( *other_entity, crate::raws::faction_reaction(my_faction, &faction.name, &crate::raws::RAWS.lock().unwrap()) )); } } } }
这个系统的工作方式如下:
- 我们查询所有具有阵营、位置和回合的实体,并通过检查具有玩家实体资源的实体来确保我们没有修改玩家的行为。
- 我们查询地图上所有相邻的瓦片,记录对相邻实体的反应。
- 我们迭代生成的反应,如果是
Attack
反应 - 我们取消它们的回合并启动WantsToMelee
结果。
要实际使用这个系统,请将其添加到 main.rs
中 run_systems
中的 MonsterAI
之前:
#![allow(unused)] fn main() { let mut adjacent = ai::AdjacentAI{}; adjacent.run_now(&self.ecs); }
如果您现在 cargo run
游戏,骚乱爆发了!每个人都属于“mindless”阵营,因此对其他人怀有敌意!这实际上是一个很棒的演示,展示了我们的引擎的性能;尽管战斗从四面八方进行,但它运行得非常好:
恢复城镇的和平
这也完全不是我们对和平起始城镇的设想。它可能适用于僵尸末日,但这最好留给 Cataclysm: Dark Days Ahead(顺便说一句,这是一个很棒的游戏)!幸运的是,我们可以通过向所有城镇 NPC 添加 "faction" : "Townsfolk"
行来恢复城镇的和平。这是酒保的例子;您需要对所有城镇居民执行相同的操作:
{
"name" : "Barkeep",
"renderable": {
"glyph" : "☻",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 4,
"ai" : "vendor",
"attributes" : {
"intelligence" : 13
},
"skills" : {
"Melee" : 2
},
"equipped" : [ "Cudgel", "Cloth Tunic", "Cloth Pants", "Slippers" ],
"faction" : "Townsfolk"
},
一旦您将这些放入,您就可以 cargo run
- 并在我们的时代获得和平!好吧,几乎:如果您观看战斗日志,老鼠会互相猛烈攻击。同样,这不太符合我们的意图。打开 spawns.json
,让我们为老鼠添加一个阵营 - 并让它们互相忽略。我们还将互相忽略添加到其他几个阵营中 - 这样土匪就不会无缘无故地互相残杀:
"faction_table" : [
{ "name" : "Player", "responses": { }},
{ "name" : "Mindless", "responses": { "Default" : "attack" } },
{ "name" : "Townsfolk", "responses" : { "Default" : "ignore" } },
{ "name" : "Bandits", "responses" : { "Default" : "attack", "Bandits" : "ignore" } },
{ "name" : "Cave Goblins", "responses" : { "Default" : "attack", "Cave Goblins" : "ignore" } },
{ "name" : "Carnivores", "responses" : { "Default" : "attack", "Carnivores" : "ignore" } },
{ "name" : "Herbivores", "responses" : { "Default" : "flee", "Herbivores" : "ignore" } },
{ "name" : "Hungry Rodents", "responses": { "Default" : "attack", "Hungry Rodents" : "ignore" }}
],
此外,将 Rat
添加到 Hungry Rodents
阵营:
{
"name" : "Rat",
"renderable": {
"glyph" : "r",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"vision_range" : 8,
"ai" : "melee",
"attributes" : {
"Might" : 3,
"Fitness" : 3
},
"skills" : {
"Melee" : -1,
"Defense" : -1
},
"natural" : {
"armor_class" : 11,
"attacks" : [
{ "name" : "bite", "hit_bonus" : 0, "damage" : "1d4" }
]
},
"faction" : "Hungry Rodents"
},
现在 cargo run
,您会看到老鼠不再互相攻击了。
响应更远的实体
响应您旁边的人是迈出的伟大第一步,并且实际上有助于处理时间(因为相邻的敌人是在没有代价高昂的整个视野搜索的情况下处理的) - 但是如果没有相邻的敌人,AI 需要寻找更远的敌人。如果发现一个需要反应的敌人,我们需要一些组件来指示意图。在 components.rs
中(并在 main.rs
和 saveload_system.rs
中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct WantsToApproach { pub idx : i32 } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct WantsToFlee { pub indices : Vec<usize> } }
这些旨在表明 AI 想要做什么:接近一个瓦片(敌人),或逃离一系列敌方瓦片。
我们将创建另一个新系统 ai/visible_ai_system.rs
(并将其添加到 ai/mod.rs
中的 mod
和 pub use
中):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Faction, Position, Map, raws::Reaction, Viewshed, WantsToFlee, WantsToApproach}; pub struct VisibleAI {} impl<'a> System<'a> for VisibleAI { #[allow(clippy::type_complexity)] type SystemData = ( ReadStorage<'a, MyTurn>, ReadStorage<'a, Faction>, ReadStorage<'a, Position>, ReadExpect<'a, Map>, WriteStorage<'a, WantsToApproach>, WriteStorage<'a, WantsToFlee>, Entities<'a>, ReadExpect<'a, Entity>, ReadStorage<'a, Viewshed> ); fn run(&mut self, data : Self::SystemData) { let (turns, factions, positions, map, mut want_approach, mut want_flee, entities, player, viewsheds) = data; for (entity, _turn, my_faction, pos, viewshed) in (&entities, &turns, &factions, &positions, &viewsheds).join() { if entity != *player { let my_idx = map.xy_idx(pos.x, pos.y); let mut reactions : Vec<(usize, Reaction)> = Vec::new(); let mut flee : Vec<usize> = Vec::new(); for visible_tile in viewshed.visible_tiles.iter() { let idx = map.xy_idx(visible_tile.x, visible_tile.y); if my_idx != idx { evaluate(idx, &map, &factions, &my_faction.name, &mut reactions); } } let mut done = false; for reaction in reactions.iter() { match reaction.1 { Reaction::Attack => { want_approach.insert(entity, WantsToApproach{ idx: reaction.0 as i32 }).expect("无法插入"); done = true; } Reaction::Flee => { flee.push(reaction.0); } _ => {} } } if !done && !flee.is_empty() { want_flee.insert(entity, WantsToFlee{ indices : flee }).expect("无法插入"); } } } } } fn evaluate(idx : usize, map : &Map, factions : &ReadStorage<Faction>, my_faction : &str, reactions : &mut Vec<(usize, Reaction)>) { for other_entity in map.tile_content[idx].iter() { if let Some(faction) = factions.get(*other_entity) { reactions.push(( idx, crate::raws::faction_reaction(my_faction, &faction.name, &crate::raws::RAWS.lock().unwrap()) )); } } } }
请记住,如果我们已经在处理相邻的敌人 - 这根本不会运行 - 因此无需担心分配近战。它也不会做任何事情 - 它会触发其他系统/服务的意图。所以我们不必担心结束回合。它只是扫描每个可见的瓦片,并评估对瓦片内容的可用反应。如果它看到它想要攻击的东西,它会设置一个 WantsToApproach
组件。如果它看到它应该逃离的东西,它会填充一个 WantsToFlee
结构。
您还需要将其添加到 main.rs
中的 run_systems
中,也在邻接检查之后:
#![allow(unused)] fn main() { let mut visible = ai::VisibleAI{}; visible.run_now(&self.ecs); }
接近
现在我们标记了接近瓦片的愿望(无论出于何种原因;目前是因为居住者应该受到殴打),我们可以编写一个非常简单的系统来处理这个问题。创建一个新文件 ai/approach_ai_system.rs
(并在 ai/mod.rs
中 mod
/pub use
它):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, WantsToApproach, Position, Map, Viewshed, EntityMoved}; pub struct ApproachAI {} impl<'a> System<'a> for ApproachAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, WriteStorage<'a, WantsToApproach>, WriteStorage<'a, Position>, WriteExpect<'a, Map>, WriteStorage<'a, Viewshed>, WriteStorage<'a, EntityMoved>, Entities<'a> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, mut want_approach, mut positions, mut map, mut viewsheds, mut entity_moved, entities) = data; let mut turn_done : Vec<Entity> = Vec::new(); for (entity, mut pos, approach, mut viewshed, _myturn) in (&entities, &mut positions, &want_approach, &mut viewsheds, &turns).join() { turn_done.push(entity); let path = rltk::a_star_search( map.xy_idx(pos.x, pos.y) as i32, map.xy_idx(approach.idx % map.width, approach.idx / map.width) as i32, &mut *map ); if path.success && path.steps.len()>1 { let mut idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = false; pos.x = path.steps[1] as i32 % map.width; pos.y = path.steps[1] as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("无法插入标记"); idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = true; viewshed.dirty = true; } } want_approach.clear(); // 删除已完成实体的回合标记 for done in turn_done.iter() { turns.remove(*done); } } } }
这基本上与 MonsterAI
中的接近代码相同,但它适用于所有接近请求 - 适用于任何目标。它还在完成后移除 MyTurn
,并移除所有接近请求。将其添加到 main.rs
中的 run_systems
中,在远程 AI 处理程序之后:
#![allow(unused)] fn main() { let mut approach = ai::ApproachAI{}; approach.run_now(&self.ecs); }
逃跑
我们还需要实现一个逃跑系统,主要基于我们动物 AI 中的逃跑代码。创建一个新文件 flee_ai_system.rs
(并记住 ai/mod.rs
中的 mod
和 pub use
):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, WantsToFlee, Position, Map, Viewshed, EntityMoved}; pub struct FleeAI {} impl<'a> System<'a> for FleeAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, WriteStorage<'a, WantsToFlee>, WriteStorage<'a, Position>, WriteExpect<'a, Map>, WriteStorage<'a, Viewshed>, WriteStorage<'a, EntityMoved>, Entities<'a> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, mut want_flee, mut positions, mut map, mut viewsheds, mut entity_moved, entities) = data; let mut turn_done : Vec<Entity> = Vec::new(); for (entity, mut pos, flee, mut viewshed, _myturn) in (&entities, &mut positions, &want_flee, &mut viewsheds, &turns).join() { turn_done.push(entity); let my_idx = map.xy_idx(pos.x, pos.y); map.populate_blocked(); let flee_map = rltk::DijkstraMap::new(map.width as usize, map.height as usize, &flee.indices, &*map, 100.0); let flee_target = rltk::DijkstraMap::find_highest_exit(&flee_map, my_idx, &*map); if let Some(flee_target) = flee_target { if !map.blocked[flee_target as usize] { map.blocked[my_idx] = false; map.blocked[flee_target as usize] = true; viewshed.dirty = true; pos.x = flee_target as i32 % map.width; pos.y = flee_target as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("无法插入标记"); } } } want_flee.clear(); // 删除已完成实体的回合标记 for done in turn_done.iter() { turns.remove(*done); } } } }
我们还需要在 run_systems
(main.rs
)中注册它,在接近系统之后:
#![allow(unused)] fn main() { let mut flee = ai::FleeAI{}; flee.run_now(&self.ecs); }
为了增加效果,让我们让 Townsfolk 逃离潜在的敌对实体。在 spawns.json
中:
{ "name" : "Townsfolk", "responses" : { "Default" : "flee", "Player" : "ignore", "Townsfolk" : "ignore" } },
如果您现在 cargo run
并玩游戏,怪物将接近并攻击 - 而懦夫将逃离敌对势力。
清理
我们现在正在执行 MonsterAI
执行的最小 AI 以及我们的通用系统中大部分食肉动物和食草动物的处理,并且赋予了城镇居民比以前更高的智能!如果您查看 MonsterAI
- 没有任何剩余的东西不是已经执行的!因此,我们可以删除 ai/monster_ai_system.rs
,并将其从 run_systems
(在 main.rs
中)中完全删除!删除后,您应该 cargo run
以查看游戏是否未更改 - 它应该是!
同样,ai/animal_ai_system.rs
的逃跑和接近现在是多余的。您实际上也可以删除此系统!
最好确保现在所有 NPC 都有一个阵营(除了实际上是无意识的凝胶状立方体)。您可以查看 spawns.json
的源代码以查看更改:这很明显,现在一切都有一个阵营。
剩余的 AI:旁观者
因此,剩余的独特 AI 模块是旁观者,他们只做一件事:随机移动。这实际上是一种非常适合鹿的行为(而不仅仅是站在那里)。如果城镇居民表现出稍微更多的智能,那也会很好。
让我们考虑一下我们现在的 AI 是如何工作的:
- 先攻权 决定是否轮到 NPC 行动。
- 状态 可以根据正在经历的效果来取消行动。
- 邻接关系 决定对附近实体的即时反应。
- 视野 决定对稍微不太近的实体的反应。
- 每个 AI 系统 决定实体现在做什么。
我们可以用更通用的“移动选项”集来替换每个 AI 系统。如果其他系统都没有导致 NPC 行动,这些选项将控制 NPC 的行为。现在让我们考虑一下我们希望城镇居民和其他人如何移动:
- 商人留在他们的商店里。
- 顾客应留在他们光顾的商店中。
- 醉汉应该随机蹒跚而行。鹿可能也应该随机移动,这很合理。
- 普通城镇居民应该在建筑物之间移动,表现得好像他们有计划一样。
- 警卫可以巡逻(我们没有任何警卫,但它们是有意义的)。对于其他怪物类型来说,巡逻而不是保持静态也可能很好。也许土匪应该在森林中漫游以寻找受害者。
- 敌对势力应在超出视觉范围之外追逐其目标,但有一定的逃脱机会。
制作移动模式组件
让我们创建一个新组件(在 components.rs
中,并在 main.rs
和 saveload_system.rs
中注册)来捕获移动模式。我们将从简单的开始:静态(不去任何地方)和随机(像傻瓜一样游荡!)。请注意,您不需要注册枚举 - 只需注册组件:
#![allow(unused)] fn main() { #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)] pub enum Movement { Static, Random } #[derive(Component, Debug, Serialize, Deserialize, Clone)] pub struct MoveMode { pub mode : Movement } }
现在我们将打开 raws/mob_structs.rs
并对其进行编辑以捕获移动模式 - 并且不再提供 AI 标签(因为这将使我们能够完全摆脱它们):
#![allow(unused)] fn main() { #[derive(Deserialize, Debug)] pub struct Mob { pub name : String, pub renderable : Option<Renderable>, pub blocks_tile : bool, pub vision_range : i32, pub movement : String, pub quips : Option<Vec<String>>, pub attributes : MobAttributes, pub skills : Option<HashMap<String, i32>>, pub level : Option<i32>, pub hp : Option<i32>, pub mana : Option<i32>, pub equipped : Option<Vec<String>>, pub natural : Option<MobNatural>, pub loot_table : Option<String>, pub light : Option<MobLight>, pub faction : Option<String> } }
(我们将 ai
重命名为 movement
)。这破坏了 rawmaster
的一部分;打开 spawn_named_mob
函数并将 AI 标签选择替换为:
#![allow(unused)] fn main() { match mob_template.movement.as_ref() { "random" => eb = eb.with(MoveMode{ mode: Movement::Random }), _ => eb = eb.with(MoveMode{ mode: Movement::Static }) } }
现在,我们需要一个新的系统来处理“默认”移动(即,我们已经尝试了其他一切)。创建一个新文件 ai/default_move_system.rs
(不要忘记在 ai/mod.rs
中 mod
和 pub use
它!):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, MoveMode, Movement, Position, Map, Viewshed, EntityMoved}; pub struct DefaultMoveAI {} impl<'a> System<'a> for DefaultMoveAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, ReadStorage<'a, MoveMode>, WriteStorage<'a, Position>, WriteExpect<'a, Map>, WriteStorage<'a, Viewshed>, WriteStorage<'a, EntityMoved>, WriteExpect<'a, rltk::RandomNumberGenerator>, Entities<'a> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, move_mode, mut positions, mut map, mut viewsheds, mut entity_moved, mut rng, entities) = data; let mut turn_done : Vec<Entity> = Vec::new(); for (entity, mut pos, mode, mut viewshed, _myturn) in (&entities, &mut positions, &move_mode, &mut viewsheds, &turns).join() { turn_done.push(entity); match mode.mode { Movement::Static => {}, Movement::Random => { 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("无法插入标记"); map.blocked[dest_idx] = true; viewshed.dirty = true; } } } } } // 删除已完成实体的回合标记 for done in turn_done.iter() { turns.remove(*done); } } } }
现在打开 main.rs
,找到 run_systems
并将对 BystanderAI
的调用替换为 DefaultMoveAI
:
#![allow(unused)] fn main() { let mut defaultmove = ai::DefaultMoveAI{}; defaultmove.run_now(&self.ecs); }
最后,我们需要打开 spawns.json
并将 mobs 中所有对 ai=
的引用替换为 movement=
。为除顾客、食草动物和醉汉以外的所有人选择 static
。
如果您现在 cargo run
,您会看到每个人都站在那里 - 除了随机的人,他们漫无目的地游荡。
本节的最后一件事:继续删除 bystander_ai_system.rs
文件以及对它的所有引用。我们不再需要它了!
添加基于航点的移动
我们提到我们希望城镇居民四处走动,但不是随机走动。打开 components.rs
,并为 Movement
添加一种模式:
#![allow(unused)] fn main() { #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)] pub enum Movement { Static, Random, RandomWaypoint{ path : Option<Vec<usize>> } } }
请注意,我们正在使用 Rust 的功能,即 enum
在其他语言中实际上是 union
,以添加用于随机移动的可选 path
。这表示 AI 尝试去的地方 - 如果没有当前目标,则为 None
(要么是因为他们刚开始,要么是因为他们到达那里了)。我们希望不要每回合都运行昂贵的 A-Star 搜索,因此我们将存储路径 - 并继续遵循它,直到它无效。
现在在 rawmaster.rs
中,我们将其添加到移动模式列表中:
#![allow(unused)] fn main() { match mob_template.movement.as_ref() { "random" => eb = eb.with(MoveMode{ mode: Movement::Random }), "random_waypoint" => eb = eb.with(MoveMode{ mode: Movement::RandomWaypoint{ path: None } }), _ => eb = eb.with(MoveMode{ mode: Movement::Static }) } }
在 default_move_system.rs
中,我们可以添加实际的移动逻辑:
#![allow(unused)] fn main() { Movement::RandomWaypoint{path} => { if let Some(path) = path { // 我们有一个目标 - 去那里 let mut idx = map.xy_idx(pos.x, pos.y); if path.len()>1 { if !map.blocked[path[1] as usize] { map.blocked[idx] = false; pos.x = path[1] % map.width; pos.y = path[1] / map.width; entity_moved.insert(entity, EntityMoved{}).expect("无法插入标记"); idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = true; viewshed.dirty = true; path.remove(0); // 删除路径中的第一步 } // 否则我们等待一个回合,看看路径是否畅通 } else { mode.mode = Movement::RandomWaypoint{ path : None }; } } else { let target_x = rng.roll_dice(1, map.width-2); let target_y = rng.roll_dice(1, map.height-2); let idx = map.xy_idx(target_x, target_y); if tile_walkable(map.tiles[idx]) { let path = rltk::a_star_search( map.xy_idx(pos.x, pos.y) as i32, map.xy_idx(target_x, target_y) as i32, &mut *map ); if path.success && path.steps.len()>1 { mode.mode = Movement::RandomWaypoint{ path: Some(path.steps) }; } } } }
这有点复杂,所以让我们逐步了解一下:
- 我们匹配
RandomWaypoint
并捕获path
作为一个变量(以便在枚举内部访问它)。 - 如果路径存在:
- 如果它有多个条目。
- 如果下一步没有被阻塞。
- 通过遵循路径来实际执行移动。
- 从路径中删除第一个条目,这样我们就可以继续遵循它。
- 等待一个回合,路径可能会畅通
- 如果下一步没有被阻塞。
- 放弃并设置无路径。
- 如果它有多个条目。
- 如果路径不存在:
- 选择一个随机位置。
- 如果随机位置是可步行的,则路径到该位置。
- 如果路径成功,则将其存储为 AI 的
path
。 - 否则,离开时没有路径 - 知道我们将在下一回合回来尝试另一个路径。
如果您现在 cargo run
(并在 spawns.json
中将某些 AI 类型设置为 random_waypoint
),您将看到村民现在的行为就像他们有计划一样 - 他们沿着路径移动。由于 A-Star 尊重我们的移动成本,他们甚至会自动优先选择路径和道路!现在看起来更真实了。
追逐目标
我们的另一个既定目标是,一旦 AI 开始追逐目标,它就不应该仅仅因为失去了视线而放弃。另一方面,它也不应该对地图有全知的视图,并完美地跟踪其目标!它也需要不是默认操作 - 但如果它是一个选项,则应在默认操作之前发生。
我们可以通过创建一个新组件来实现这一点(在 components.rs
中,记住在 main.rs
和 saveload_system.rs
中注册):
#![allow(unused)] fn main() { #[derive(Component, Debug, ConvertSaveload, Clone)] pub struct Chasing { pub target : Entity } }
不幸的是,我们正在存储一个 Entity
- 因此我们需要一些额外的样板代码来使序列化系统满意:
rust
现在我们可以修改我们的 visible_ai_system.rs
文件,以便在它想要追逐目标时添加 Chasing
组件。有很多小的更改,所以我包含了整个文件:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Faction, Position, Map, raws::Reaction, Viewshed, WantsToFlee, WantsToApproach, Chasing}; pub struct VisibleAI {} impl<'a> System<'a> for VisibleAI { #[allow(clippy::type_complexity)] type SystemData = ( ReadStorage<'a, MyTurn>, ReadStorage<'a, Faction>, ReadStorage<'a, Position>, ReadExpect<'a, Map>, WriteStorage<'a, WantsToApproach>, WriteStorage<'a, WantsToFlee>, Entities<'a>, ReadExpect<'a, Entity>, ReadStorage<'a, Viewshed>, WriteStorage<'a, Chasing> ); fn run(&mut self, data : Self::SystemData) { let (turns, factions, positions, map, mut want_approach, mut want_flee, entities, player, viewsheds, mut chasing) = data; for (entity, _turn, my_faction, pos, viewshed) in (&entities, &turns, &factions, &positions, &viewsheds).join() { if entity != *player { let my_idx = map.xy_idx(pos.x, pos.y); let mut reactions : Vec<(usize, Reaction, Entity)> = Vec::new(); let mut flee : Vec<usize> = Vec::new(); for visible_tile in viewshed.visible_tiles.iter() { let idx = map.xy_idx(visible_tile.x, visible_tile.y); if my_idx != idx { evaluate(idx, &map, &factions, &my_faction.name, &mut reactions); } } let mut done = false; for reaction in reactions.iter() { match reaction.1 { Reaction::Attack => { want_approach.insert(entity, WantsToApproach{ idx: reaction.0 as i32 }).expect("无法插入"); chasing.insert(entity, Chasing{ target: reaction.2}).expect("无法插入"); done = true; } Reaction::Flee => { flee.push(reaction.0); } _ => {} } } if !done && !flee.is_empty() { want_flee.insert(entity, WantsToFlee{ indices : flee }).expect("无法插入"); } } } } } fn evaluate(idx : usize, map : &Map, factions : &ReadStorage<Faction>, my_faction : &str, reactions : &mut Vec<(usize, Reaction, Entity)>) { for other_entity in map.tile_content[idx].iter() { if let Some(faction) = factions.get(*other_entity) { reactions.push(( idx, crate::raws::faction_reaction(my_faction, &faction.name, &crate::raws::RAWS.lock().unwrap()), *other_entity )); } } } }
这是一个好的开始:当追击 NPC 时,我们将自动开始追逐它们。现在,让我们创建一个新系统来处理追逐;创建 ai/chase_ai_system.rs
(并在 ai/mod.rs
中 mod
,pub use
):
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{MyTurn, Chasing, Position, Map, Viewshed, EntityMoved}; use std::collections::HashMap; pub struct ChaseAI {} impl<'a> System<'a> for ChaseAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, MyTurn>, WriteStorage<'a, Chasing>, WriteStorage<'a, Position>, WriteExpect<'a, Map>, WriteStorage<'a, Viewshed>, WriteStorage<'a, EntityMoved>, Entities<'a> ); fn run(&mut self, data : Self::SystemData) { let (mut turns, mut chasing, mut positions, mut map, mut viewsheds, mut entity_moved, entities) = data; let mut targets : HashMap<Entity, (i32, i32)> = HashMap::new(); let mut end_chase : Vec<Entity> = Vec::new(); for (entity, _turn, chasing) in (&entities, &turns, &chasing).join() { let target_pos = positions.get(chasing.target); if let Some(target_pos) = target_pos { targets.insert(entity, (target_pos.x, target_pos.y)); } else { end_chase.push(entity); } } for done in end_chase.iter() { chasing.remove(*done); } end_chase.clear(); let mut turn_done : Vec<Entity> = Vec::new(); for (entity, mut pos, _chase, mut viewshed, _myturn) in (&entities, &mut positions, &chasing, &mut viewsheds, &turns).join() { turn_done.push(entity); let target_pos = targets[&entity]; let path = rltk::a_star_search( map.xy_idx(pos.x, pos.y) as i32, map.xy_idx(target_pos.0, target_pos.1) as i32, &mut *map ); if path.success && path.steps.len()>1 && path.steps.len()<15 { let mut idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = false; pos.x = path.steps[1] as i32 % map.width; pos.y = path.steps[1] as i32 / map.width; entity_moved.insert(entity, EntityMoved{}).expect("无法插入标记"); idx = map.xy_idx(pos.x, pos.y); map.blocked[idx] = true; viewshed.dirty = true; turn_done.push(entity); } else { end_chase.push(entity); } } for done in end_chase.iter() { chasing.remove(*done); } for done in turn_done.iter() { turns.remove(*done); } } } }
这个系统最终比我希望的要复杂,因为借用检查器真的不希望我两次访问 Position
存储。所以我们最终得到了以下结果:
- 我们迭代所有具有
Chasing
组件以及回合的实体。我们查看他们的目标是否有效,如果有效 - 我们将其存储在一个临时的 HashMap 中。这避免了需要两次查看Position
内部。如果它无效,我们移除该组件。 - 我们迭代每个仍在追逐的人,并路径到他们的目标。如果路径成功,则遵循该路径。如果路径不成功,我们移除追逐组件。
- 我们从
MyTurn
列表中删除每个执行回合的人。
将其添加到默认移动系统之前的 run_systems
中:
#![allow(unused)] fn main() { let mut approach = ai::ApproachAI{}; approach.run_now(&self.ecs); }
删除每个 AI 标签
我们不再使用 Bystander
、Monster
、Carnivore
、Herbivore
和 Vendor
标签!打开 components.rs
并删除它们。您还需要删除它们在 main.rs
和 saveload_system.rs
中的注册。一旦它们消失,您仍然会在 player.rs
中看到错误;为什么?我们过去使用这些标签来确定我们是否应该攻击或与 NPC 交换位置。我们可以很容易地替换 try_move_player
中失败的代码。首先,从您的 using
语句中删除对这些组件的引用。然后替换这两行:
#![allow(unused)] fn main() { let bystanders = ecs.read_storage::<Bystander>(); let vendors = ecs.read_storage::<Vendor>(); }
替换为:
#![allow(unused)] fn main() { let factions = ecs.read_storage::<Faction>(); }
然后我们将标签检查替换为:
#![allow(unused)] fn main() { for potential_target in map.tile_content[destination_idx].iter() { let mut hostile = true; if combat_stats.get(*potential_target).is_some() { if let Some(faction) = factions.get(*potential_target) { let reaction = crate::raws::faction_reaction( &faction.name, "Player", &crate::raws::RAWS.lock().unwrap() ); if reaction != Reaction::Attack { hostile = false; } } } if !hostile { // 请注意,我们想要移动旁观者 }
请注意,我们正在使用我们之前制作的阵营系统!player.rs
中还有一个修复 - 决定我们是否可以因为附近的怪物而治疗。这基本上是相同的更改 - 我们检查实体是否是敌对的,如果是,则禁止治疗(因为您感到紧张/不安!):
#![allow(unused)] fn main() { fn skip_turn(ecs: &mut World) -> RunState { let player_entity = ecs.fetch::<Entity>(); let viewshed_components = ecs.read_storage::<Viewshed>(); let factions = ecs.read_storage::<Faction>(); let worldmap_resource = ecs.fetch::<Map>(); let mut can_heal = true; let viewshed = viewshed_components.get(*player_entity).unwrap(); for tile in viewshed.visible_tiles.iter() { let idx = worldmap_resource.xy_idx(tile.x, tile.y); for entity_id in worldmap_resource.tile_content[idx].iter() { let faction = factions.get(*entity_id); match faction { None => {} Some(faction) => { let reaction = crate::raws::faction_reaction( &faction.name, "Player", &crate::raws::RAWS.lock().unwrap() ); if reaction == Reaction::Attack { can_heal = false; } } } } } ... }
距离剔除 AI
我们目前在远离玩家的事件上花费了大量 CPU 周期。性能仍然可以,但这在两个方面都不是最优的:
- 如果阵营在我们远离时正在战斗,我们可能只是四处奔波寻找死人。最好是到达时发现正在发生的事情,而不是仅仅发现余波。
- 我们不想浪费我们宝贵的 CPU 周期!
让我们打开 initiative_system.rs
并对其进行修改以检查与玩家的距离,如果他们距离很远,则不进行回合:
#![allow(unused)] fn main() { use specs::prelude::*; use crate::{Initiative, Position, MyTurn, Attributes, RunState}; pub struct InitiativeSystem {} impl<'a> System<'a> for InitiativeSystem { #[allow(clippy::type_complexity)] type SystemData = ( WriteStorage<'a, Initiative>, ReadStorage<'a, Position>, WriteStorage<'a, MyTurn>, Entities<'a>, WriteExpect<'a, rltk::RandomNumberGenerator>, ReadStorage<'a, Attributes>, WriteExpect<'a, RunState>, ReadExpect<'a, Entity>, ReadExpect<'a, rltk::Point>); fn run(&mut self, data : Self::SystemData) { let (mut initiatives, positions, mut turns, entities, mut rng, attributes, mut runstate, player, player_pos) = data; if *runstate != RunState::Ticking { return; } // 清除我们错误留下的任何剩余 MyTurn turns.clear(); // 掷先攻权 for (entity, initiative, pos) in (&entities, &mut initiatives, &positions).join() { initiative.current -= 1; if initiative.current < 1 { let mut myturn = true; // 重新掷骰 initiative.current = 6 + rng.roll_dice(1, 6); // 给予敏捷奖励 if let Some(attr) = attributes.get(entity) { initiative.current -= attr.quickness.bonus; } // TODO: 稍后将在此处添加更多先攻权授予的增益/惩罚 // 如果是玩家,我们希望进入 AwaitingInput 状态 if entity == *player { *runstate = RunState::AwaitingInput; } else { let distance = rltk::DistanceAlg::Pythagoras.distance2d(*player_pos, rltk::Point::new(pos.x, pos.y)); if distance > 20.0 { myturn = false; } } // 轮到我了! if myturn { turns.insert(entity, MyTurn{}).expect("无法插入回合"); } } } } } }
修复性能
您可能已经注意到,在本章的学习过程中,性能有所下降。我们添加了很多功能,因此系统似乎是罪魁祸首 - 但事实并非如此!我们的系统实际上以非常好的速度运行(每个系统做一件事的一个优势:您的 CPU 缓存非常高兴!)。如果您想证明这一点,请执行调试构建,启动分析器(我在 Windows 上使用 Very Sleepy)并将其附加到游戏中!
罪魁祸首实际上是先攻权。不再是每个实体都在同一刻移动,因此通过主循环花费更多周期才能到达玩家的回合。这是一个小减速,但很明显。幸运的是,您可以通过快速更改 main.rs
中的主循环来修复它:
#![allow(unused)] fn main() { RunState::Ticking => { while newrunstate == RunState::Ticking { self.run_systems(); self.ecs.maintain(); match *self.ecs.fetch::<RunState>() { RunState::AwaitingInput => newrunstate = RunState::AwaitingInput, RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 }, _ => newrunstate = RunState::Ticking } } } }
这将运行所有先攻权周期,直到轮到玩家。它将游戏恢复到全速运行。
总结
这是一个漫长的章节,对此我深感抱歉 - 但这是一个非常富有成效的章节!AI 不再只是站在那里或完全随机地漫游,而是分层运作 - 首先决定相邻的目标,然后是可见的目标,然后再是默认操作。它甚至可以追捕您。这在很大程度上使 AI 感觉更智能。
如果您现在 cargo run
,您可以享受一个更加丰富的世界!
...
本章的源代码可以在这里找到
在您的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。