接上文 游戏开发中的人工智能(十):模糊逻辑
本文内容:技术上而言,有限状态机和模糊逻辑都落在基于规则的方法这个大伞之下。本章将谈这些方法,以及其他变化的方法。
本章我们要研讨基于规则的 AI 系统。基于规则的 AI 系统可能是真实世界和游戏软件 AI 中最为广泛使用的 AI 系统了。规则系统最简单的形式由一连串的 if-then 规则组成,用来推论或行动决策。从形式上来说,在第九章的有限状态机中,已经看过规则系统的一种形式:我们用规则处理状态的转换问题。第十章谈到模糊逻辑时,也看过另一种规则系统(模糊规则)。
规则系统有两个主要的部分,一个是工作记忆,另一个是规则记忆。
工作记忆储存已知的游戏世界信息,这部分是动态的。规则记忆储存设游戏设计师设计的的规则。当工作记忆符合规则记忆的某一条规则时,相应的行动就会被触发。或者,规则记忆中的规则也能修改工作记忆的内容。
为了说明规则系统,我们举个实时战略模拟游戏中科技树的例子。在实时战略模拟游戏中,玩家必须训练农民,建立设施以及收割农作物。与此同时计算机对手也会追踪玩家当前的科技状态进行评估并推论,更新自己的科技。玩家也可以以同样的方式评估计算机对手的科技状态。因此,玩家和计算机都必须排除侦察兵,收集信息,根据所收集到的信息做推论。(可以利用简单的规则系统达到这种效果)。图11-1 说明了科技树的构成。
例11-1 是实时策略游戏科技树的工作记忆内容。
//例11-1:工作记忆示例
enum TMemoryValue(Yes,No,Maybe,Unknown);
TMemoryValue peasants; //农民
TMemoryValue Woodcutter; //伐木工
TMemoryValue Stonemason; //石匠
TMemoryValue Blacksmith; //铁匠
TMemoryValue Barracks; //兵营
TMemoryValue Fletcher; //箭工
TMemoryValue WoodWalls; //木栅栏
TMemoryValue StoneWalls; //石墙
TMemoryValue Cavalry; //骑兵
TMemoryValue FootSoldier; //步兵
TMemoryValue Spearman; //矛兵
TMemoryValue Archer; //弓箭手
TMemoryValue Temple; //庙宇
TMemoryValue Priest; //僧侣
TMemoryValue Crossbowman; //十字弓箭手
TMemoryValue Longbowman; //长弓箭手
就此例而言,我们让工作记忆里的每个元素都以 TMemoryValue 类型声明,而且可以取下列四个值之一:Yes、No、Maybe 或 Unknown。主要目的是,让计算机对手知道当前玩家对手的科技状态。Yes 表示玩家有某种科技,No 表示没有。如果玩家满足所有获得某种科技的条件,但其状态尚未被侦察兵确认,则其值是 Maybe。如果计算机不知道玩家对某科技的能力,则取值 Unknown。
计算机可以收集玩家当前科技状态的事实,做法是派出侦察兵,并做观察。例如,如果计算机派出一名侦察兵,而侦察兵看见玩家建了庙宇,则 Temple 设为 Yes。不过在此之前,使用一组 if-then 规则,在侦测兵确认之前,计算机能根据既有事实推论玩家的科技状态。例如,看图11-1 ,如果玩家有伐木工和石匠,则有能力建庙宇,则 Temple 的值会是 Maybe。如例11-2 所示。
例11-2:庙宇规则示例
if(Woodcutter==Yes && Stonemason==Yes && Temple==Unknown)
Temple=Maybe;
推论也可以以反推的方式得到。例如,如果玩家被观察到有僧侣,则计算机可以推论,玩家一定有庙宇,因此,也一定有兵营、伐木工以及石匠。如例11-3 所示。
//例11-3:僧侣规则示例
if(Priest==Yes)
{
Temple=Yes;
Barracks=Yes;
Woodcutter=Yes;
Stonemason=Yes;
}
根据图11-1 的科技树还可以写出许多规则,例11-4 是可以写出的其他规则。
//例11-4:其他规则示例
if(Peasants==Yes && Woodcutter==Unknown)
Woodcutter=Maybe;
if(Peasants==Yes && Stonemason==Unknown)
Stonemason=Maybe;
…
如前所述,就此例而言能写的规则不止这些,你可以开发更多规则,包含如图11-1 所示的所有可能科技。思路是:你可以写这类规则,并在游戏中不断执行(GameCycle 时),以保持计算机对手看待玩家科技能力的最新图像,以决定如何部署攻防兵力。
此例让你大致了解规则系统的运作方式,实际上就是一组 if-then 规则。但是,注意,开发人员经常不用本节所用的 if 语句建构规则系统,因为直接把 if 语句写在程序里,会让某种推论难以达到。开发人员时常使用描述语言或 shell 语言,使他们能建立规则并予以修改,而不用修改源代码再重新编译。
此例中,我们的目标是,在对战游戏中,预测人类对手的下一个招式。我们想让计算机对手,能够利用玩家最近出的招式以及玩家过去所出招式的某些模式,预测玩家下次要出什么招。如果计算机可以预测下一招,就能采取适当的反击、阻挡或闪躲动作,比如往侧边跳或往后退。这会让战斗模拟游戏有更强烈的真实感,给玩家新的挑战。
为了达到这种效果,我们要实现一个有学习能力的规则系统。让每条规则加权,强化某些规则,压抑另外一些规则,借此达到学习的效果。
为了让范例能在讨论的掌控范围内,我们做一些简化工作。假定玩家的招式可以分成挥拳、下踢、上踢。
例11-6 是工作记忆的操作方式。
//例11-6:工作记忆
enum TStrikes(Punch,LowKick,HighKick,Unknown);
struct TWorkingMemory
{
TStrikes strikeA; //前前次攻击
TStrikes strikeB; //前次攻击
TStrikes strikeC; /预测的下次攻击
//可以在这里加上其他元素,比如要怎么反击等
};
TWorkingMemory WorkingMemory; //全局工作记忆变量
例11-7 是此例的规则类。这里我们没有直接写出 if-then 规则,我们以 TRule 对象数组表示规则记忆。
//例11-7:规则类
class TRule
{
public:
TRule();
void SetRule(TStrikes A,TStrikes B,TStrikes C);
TStrikes antecedentA; //前前次攻击
TStrikes antecedentB; //前次攻击
TStrikes consequentC; //预测的下次攻击
bool matched; //工作记忆是否与规则记忆相匹配
int weight; //权值因子
};
TRule 规则类只有两个方法:SetRule( ) 和构造方法。构造方法是把 matched 赋初值 false,weight 赋为 0。我们以 SetRule( ) 设定其他成员:antecedentA、antecedentB、consequentC,由此就可以定义出一条规则。SetRule( ) 方法如例11-8 所示。
//例11-8:SetRule()方法
void TRule::SetRule(TStrikes A,TStrikes B,TStrikes C)
{
antecedentA=A;
antecedentB=B;
consequentC=C;
}
此例需要几个全局变量,第一个是 WorkingMemory,如例11-6 所示。例11-9 是其他的全局变量。
//例11-9:全局变量
TRule Rules[NUM_RULES]; //存储规则记忆 TRule对象的数组,此例指定为27
int PreviousRuleFired; //存储上一次游戏循环中启动的规则索引值
TStrikes Prediction; //规则系统中所作的招式预测,技术上而言并不需要,因为预测招式都会存储在工作记忆中
TStrikes RandomPrediction; //存储随机产生的预测招式,用以比较随机和我们预测的成功率
int N; //存储预测次数
int NSuccess; //成功预测次数
int NRandomSuccess; //随机猜测成的次数
游戏开始时,我们必须对所有规则和工作记忆做初始化。例11-10 的 Initialize( ) 函数会完成此任务。
//例11-10:Initialize()函数
void TFom1::Initialize()
{
Rules[0].SetRule(Punch,Punch,Punch);
…
Rules[26].SetRule(HighKick,HighKick,HighKick);
WorkingMemory.strikeA=sUnknown;
WorkingMemory.strikeB=sUnknown;
WorkingMemory.strikeC=sUnknown;
PreviousRuleFired= -1;
N=0;
NSuccess=0;
NRandomSuccess=0;
UpdateForm();
}
这里我们一共有27条规则,对应出拳、下踢、上踢这三招的所有可能组合模式。例如,第一条规则 Rules[0] 可以理解成这样:
if ( WorkingMemory.strikeA == Punch && WorkingMemory.strikeB == Punch)
then
WorkingMemory.strikeC = Punch
检视这些规则可以发现,任何时刻都有一条以上的规则可以吻合工作记忆中的事实。例如,如果招式A 和 B 都是出拳,则前三条规则都吻合,预测的招式可以是出拳、下踢或者上踢。此时我们用加权因子,协助我们找出要启动哪条规则。我们只用权重最高的规则。如果有两条或两条以上的规则有相同的权重,那就用最前面那一条。
当游戏开始运行,每次玩家出招之后,我们都必须做招式预测。我们用函数 ProcessMove( ) 处理玩家出的每一招,并预测其下一招。如例11-11 所示。
//例11-11:ProcessMove()
TStrikes TForm1::ProcessMove(TStrikes move)
{
int i;
int RuleToFire= -1;
//第一块:
if(WorkingMemory.strikeA == sUnknown)
{
WorkingMemory.strikeA=move;
return sUnknown;
}
if(WorkingMemory.strikeB == sUnknown)
{
WorkingMemory.strikeB=move;
return sUnknown;
}
//第二块:
//先处理前次预测,记录并调整权重
N++;
if(move==Prediction)
{
NSuccess++;
if(PreviousRuleFired != -1)
Rules[PreviousRuleFired].weight++;
}
else
{
if(PreviousRuleFired != -1)
Rules[PreviousRuleFired].weight--;
//增加应该启动规则的权重
for(i=0;iif (Rules[i].matched && (Rules[i].consequentC == move) )
{
Rules[i].weight++;
break;
}
}
}
if(move == RandomPrediction)
NRandomSuccess++;
//删除旧值
WorkingMemory.strikeA=WorkingMemory.strikeB;
WorkingMemory.strikeB=move;
//第三块:
//开始做新预测
for(i=0;iif(Rules[i].antecedentA == WorikingMemory.strikeA && Rules[i].antecedentB == WorikingMemory.strikeB)
Rules[i].matched=true;
else
Rules[i].matched=false;
}
//选出权重最高的规则
RuleToFire= -1;
for(i=0;iif(Rules[i].matched)
{
if(RuleToFire == -1)
RuleToFire=i;
else if(Rules[i].weight > Rules[RuleToFire].weight)
RuleToFire=i;
}
}
//启动规则
if(RuleToFire != -1)
{
WorikingMemory.strikeC=Rules[i].antecedentC;
PreviousRuleFired=RuleToFire;
}
else
{
WorkingMemory.strikeC=sUnknown;
PreviousRuleFired= -1;
}
return WorikingMemory.strikeC;
}
第一块
第一块是填写工作记忆。游戏开始时,工作记忆初始化之后,任何招式出击之前,工作记忆中只有 Unknown 值,这样使无法预测的,所以我们要在玩家开始出招后,从玩家那里搜集资料。
第一招存储在 WorkingMemory.strikeA 中,而 ProcessMove( ) 返回 Unknown。第二招打出后,ProcessMove( ) 再次被调用,第二招存储在 WorkingMemory.strikeB 中,ProcessMove( ) 依旧返回 Unknown。
第二块
ProcessMove( ) 的第一块是处理前次预测,也就是上一次调用 ProcessMove( ) 后所返回的预测招式。
第二块首先要确认前次预测时候有效。ProcessMove( ) 以 move 为参数。move 是玩家最近一次出的招。如果 move 等于存储在 Predicition 的前次预测招式,那么我们的预测就是成功的。我们递增 NSuccess,以更新成功率。然后我们我们强化上次启动的规则即增加该规则的权重。如果前次预测是错的,我们则要递减前次启动的规则权重。
接下来我们查看前次随机预测是否正确,正确就递增 NRandomSuccess。最后,我们更新工作记忆中的招式,以便做新预测,即 WoringMemory.strikeB 变成
WoringMemory.strikeA,而 move 变成 WoringMemory.strikeB 。
第三块
首先我们要找出符合工作记忆中事实的规则(第一个 for 循环)。配对步骤完成后,我们要从那些吻合的规则中挑选一条出来,即冲突解决(第二个 for 循环),循环工作完成之后,选定的规则的索引值会存储在 RuleToFire 中。要实际启动规则,只需要把 Rules[RuleToFire] 的 consequentC 赋值给 WorkingMemory.strikeC 即可。
ProcessMove( ) 把要启动的规则索引值 RuleToFire 存储在 PreviousRuleFired中,下次 ProcessMove( )被调用时,会在第二块使用。最后,ProcessMove( ) 返回预测的招式。