游戏开发中的人工智能(十一):规则式 AI

版权声明:本文为Jurbo原创文章,转载请加上链接和作者名,标明出处。 https://blog.csdn.net/Jurbo/article/details/76069806

接上文 游戏开发中的人工智能(十):模糊逻辑

本文内容:技术上而言,有限状态机和模糊逻辑都落在基于规则的方法这个大伞之下。本章将谈这些方法,以及其他变化的方法。


规则式 AI

本章我们要研讨基于规则的 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;i<NUM_RULES;i++)
        {
            if (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;i<NUM_RULES;i++)
    {
        if(Rules[i].antecedentA == WorikingMemory.strikeA && Rules[i].antecedentB == WorikingMemory.strikeB)
            Rules[i].matched=true;
        else
            Rules[i].matched=false;
    }
    //选出权重最高的规则
    RuleToFire= -1;
    for(i=0;i<NUM_RULES;i++)
    {
        if(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( ) 返回预测的招式。

猜你喜欢

转载自blog.csdn.net/Jurbo/article/details/76069806