作者:Ed Welch

在动作类游戏中,AI对手总是拥有完美的灵敏度和快速反应能力等天然优势,所以这类游戏的AI设计挑战就是让AI更为人性化而非百战不败。

在回合制策略游戏中,情况就会有所改变,速度和准确度并非重要致胜因素,聪明的人类玩家总有办法轻松击败AI对手。实际上,我们根本不可能设计出能够打败骨灰级玩家的AI,但这并非问题的关键所在。

这里的AI设计挑战在于,让AI的攻击和防御策略显得机智而成熟,既要为玩家创造挑战,又要确保玩家最终总能获胜。假如玩家深谙AI策略的套路,那么他们很快就会感到无趣,所以必须为他们创造一些不可预期的挑战。

以典型的策略游戏为例

我们在此一款4X太空战斗游戏为例,玩家在游戏中的任务是扩大并统治银河系。每名玩家都有太空战舰,殖民战舰和自己的家园星球,并能够殖民其他可居住的星球。

ai_1(from gamasutra) ai_1(from gamasutra)

我们会先从最重要的任务开始,为其编写一个简单的AI算法,令其分配靠近某项资源(例如星球或战舰)的顺序。保卫拥有生产队列的星球是第一要务,因为它们最有价值。

其次是保卫没有生产队列的殖民地,然后就是进攻敌人的家园星球,殖民可居住的星球,攻击敌人战舰,随后修复破损的战舰,最后就是探索未知的领域。所以我们要先着眼于首要任务,查看是否有敌军试图染指自己的殖民地。

从上图中可以看出,敌人护卫舰X和Y正同时威胁AI的家园和殖民地。我们可以看到与敌军最近的驱逐舰,并令其攻击敌军护卫舰。但这个算法存在一些瑕疵。如果要先对付Y,就得让与其距离最近的驱逐般A采取攻击行动。而当护卫舰X靠近时,我们就只剩下一个驱逐舰B可用,但B与其距离过远,无法近身攻击X,结果就让X成功轰炸了我们的家园星球。很显然,我们应该给B分配攻击Y的任务,让A去解决X。

这种简单算法还会产生其他问题,让我们看另一种更复杂的场景:

ai_2(from gamastra) ai_2(from gamastra)

在这种新场景中,我们的驱逐舰A在上次攻击中遭遇重创,无法再有效发挥战斗力。最明智的做法就令其返回基地进行修复。那么我们就只剩下驱逐舰C和B可以保卫殖民地。但驱逐舰C因距离过远,无法及时靠近敌军护卫舰Y,它的位置更适合攻击敌人的殖民地。与此同时,AI殖民者已经全副武装,可以从其主要的殖民任务中脱身。

任务分值

为了解决上述问题,我们首先得设计一个计分系统。为每个任务分配以下的一般优先顺序:

保卫殖民地:1

攻击敌人殖民地:2

殖民星球:3

攻击敌舰:4

维修受损的战舰:5

探索未知领域:6

每个任务都有一个优先顺序调节器,例如,保卫殖民地的任务会根据殖民地的价值而调节其优先顺序(拥有生产队列的殖民地价值最高)。与此类似,维修任务的优先顺序也可根据战舰受损的严重程度进行调整,殖民任务则根据星球“可居住程度”分出优先顺序。

最后还要考虑负责执行任务的战舰距离:

任务分值=(6- 一般优先顺序 + 调节分值)/ 战舰与任务目标的距离

因此在前面的场景中,虽然防御任务优先顺序更靠前,但驱逐舰C进攻敌人殖民地的任务分值更高,因为它与敌人大本营的距离更近。

除此之外,驱逐舰A因为严重受损,其维修调节分值也会更高,再加上它与维修队列的距离更近,所以它的维修任务分值明显高于防御任务。

算法大纲

整个算法可划分为4个步骤:

ai_3(from gamasutra) ai_3(from gamasutra)

收集任务

AI会根据其传感范围列出一个敌军战舰和星球等目标,以及自己的资产列表。其任务内容如下:

任务 任务

潜在任务

另一个问题就是,假如我们以错误的顺序分配任务,可能就无法达到最佳资源利用效果。我们可以通过分阶段分配任务解决问题。可以使用两个不同的分类:潜在任务(PossibleAssignment)、任务(Task)。前者将为潜在的“任务执行者”分配一个任务,并存储“任务分值”。任务中还要存储优先顺序、顺序调节器和目标。

ai_4(from gamasutra) ai_4(from gamasutra)

现在我们要为每个“任务执行者”分配一个潜在任务目标,但必须注意排除不可行的组合。例如,我们不能让没有武器的战舰执行攻击任务,也不能让它在缺乏足够燃料的情况下执行某些任务。算法代码如下:

listAsset contains a list of all assets (for instance ships)
for (n = 0; n < listTask.size(); n++)
{
for(f = 0; f < listAsset.size(); f++){
if (listAsset[f].isTaskSuitable(listTask[n])){
listPossAssignment.add(new PossibleAssignment(listTaskn]));
}
}
}

下一步我们就要计算每个潜在任务的分配分值,从最高分到最低分罗列出优先顺序。最后,我们就能完成任务分配。任务分配完成之后,就要给任务执行者标注“忙碌”状态,给该任务打上“已分配”标注,从而避免重复分配任务的情况。

以下是这种操作的部分代码:

for (n = 0; n < listPossAssignment.size();n++)
{
listPossAssignment[n].assign();
}
public void PossibleAssignment::assign()
{
if (task.isAssigned()) return;
possibleTaskDoer.assign(this);
}
public void Ship::assign(PossibleAssignment possAssign) {
if (task != null) return;
task = possAssign.getTask();
possAssign.getTask().assign(this);
}

重用针对星球生产任务的算法

如果还有剩余任务未分配,AI就得制造新的星际飞船进行补充。例如,我们已经发现有敌舰来袭,但却没有空余的战舰可击退敌人,所以我们就得制造新战舰来填补这个空缺。与之类似,如果我们探索到了一个可居住的星球,但手头上却没有空闲的殖民者可供调遣,就需要创建新的殖民者。

生产队列的创建优先顺序分配与星际飞船的任务分配一样,从分类图表可以看出,各类飞船与星球都来源于SpaceObject,所以它们可以使用同样的算法,仅需一些微小的调整即可。

ai_5(from gamasutra) ai_5(from gamasutra)

简化操作:抛弃旧任务

因为这是一款回合制游戏,每一个新回合开始之时,上一回合的分配的任务就要作废。例如,你的驱逐舰打算攻击的敌方护卫舰可能突然中途撤退,或者你发现自己打算殖民的星球已经被敌人捷足先登了,这样你就不得不临时改变策略。

最简单的方法就是在每一回合开始时,抛弃所有的旧任务,重新分配资源。虽然这种做法看似缺乏效率,因为并非所有的任务都需要调整,但它确实可以简化AI代码,这样你就无需维持前几个回合的任务。

对AI算法来说,保持代码的简洁性尤其重要,因为这些代码总是很容易就变得极为复杂,增加调试和维护的难度。另外,我们必须在最后阶段,即运算彻底完工时才能进行最优化任务分配。

中途意外状况

在某个回合中,我们的战舰有可能发现新的敌人殖民地或者敌舰。这时我们就要给自己的战舰指派新的攻击任务,但如果这些战舰已经有任务在身,这就会造成其他问题。最简单的的解决方法就是再次运行资源分配路径,这样才能有效保证实现最优化的资源分配。

实际运用情况

这种AI算法是为4X策略游戏而设计,从上述例子中我们可以看出其控制敌人战舰所体现出的智能特点。

战舰可能会出乎意料地改变策略,假如敌舰弹药耗尽,它就有可能突然撤离战场,重返基地填充弹药;如果它没有可供其重返基地的燃料,可能就会转向探索未知领域(这是它可执行的最后一个任务)。等到新战舰出炉的时候,整个舰队的任务顺序可能都会重新调整。有些战舰返回基地维修,让新战舰接手作战任务。

这种相当简单的算法不但容易执行和调试,而且还可以创造一个极具挑战性的AI对手。

虽然这种算法是为回合制策略游戏而设计,但经过适当调整后也应该可以为其他类型的策略游戏所用。