管理群怪进攻节奏的系统——功夫圈

管理群怪攻击的简单AI系统——功夫圈

本文参考《GameAIPro》的第28章《阿玛拉王国:惩罚》的Belgian AI,尝试实现一个类似的敌方NPC攻击管理系统并提供一个示例

以一敌多

很多类型的游戏中玩家都难免会遇到以一敌多的场合。通常,除非玩家自身能力超标,否则不会真让敌人蜂拥而上,而是会像早期功夫电影那样,让一部分敌人在外围徘徊当气氛组,少数几个有序上前来攻击,这也就是功夫圈(Kung-Fu Circle)。这种限制能避免玩家被海量攻击淹没而无力招架,但有时我们可能需要增加游戏难度,让更多的敌人攻击玩家、让敌人能使用更多技能,甚至是因为剧情需要,刻意让大量敌人一起围攻以击败玩家。

image

那么,有没有比传统的功夫圈系统更好地、更直接控制战斗难度的方案呢?有的兄弟,有的!《阿玛拉王国:惩罚》中使用了一种名为Belgian AI的增强版的功夫圈系统,能直接地控制进攻的敌人数量与攻击时敌人下手的轻重,并用这一套相同的系统设计了各种各样的遭遇战。让我们学习下这套系统的运行逻辑吧。

敌人权重与攻击权重

敌人之间不可直接相提并论,打10只虾兵蟹将与打10个Boss是完全不同的情况,所以只是指定“攻击玩家的敌人数量”并不太好。我们可以为不同威胁程度的敌人设置不同的权重,就像“三个臭皮匠顶个诸葛亮”,我们设臭皮匠的权重为1,诸葛亮为3,而能与玩家进行对决的敌人总权重设为6。这样一来,能与玩家进行交锋的敌人顶多就是2个诸葛亮或者6个臭皮匠,又或者是3个臭皮匠加1个诸葛亮,总之,不会出现比这些更艰难的情况。在传统功夫圈之上,将玩家能同时对付的敌人数量改为玩家能同时对付的敌人总权重,为精英怪分配更大的权重,就可以避免玩家对付复数敌人时碰上过多精英怪。

更进一步,用类似敌人权重的方式还可以限制敌人的攻击方式。也许你猜到了,也设置一个玩家能承受的攻击总权重,然后为敌人的不同攻击方式都设置一个对应的权重。比如有头巨龙,它有吐焰、爪击、扫尾这三种攻击方式:巨龙的吐焰伤害高范围广,设为6攻击权重;爪击伤害中等范围较小,设为3;扫尾伤害低范围较大,就也设为3吧。这时我们把玩家能承受的攻击总权重设为9,在他面对三头巨龙时,也顶多受到一头巨龙的吐焰。

image

但是!但是!如果三头巨龙一起使用扫尾,玩家岂不是很难躲避?是的,所以我们再进一步为每类攻击都加上冷却。冷却期间敌人就无法使用该类攻击,只能尝试其他攻击方式了。等等,如果巨龙的三个攻击都进入了冷却,它们岂不是只能干瞪眼了吗?这就算是给了玩家喘息的时间吧,但就算是这种情况,它们也可以继续停留在较为靠近玩家的区域内等待某个攻击冷却好,这能让玩家知道哪几个是正在与他对决的敌人。

如上所述,在功夫圈系统之上,稍加改造就能更方便地控制战斗的节奏了。当我们需要提高游戏难度时,就可以不单单只是提高个体敌人的数值,还可以增大功夫圈中的敌人权重让玩家面对更多更强的敌人、增大攻击权重让玩家面对更凶猛的攻击。

代码实现

功夫圈

首先我们要实现功夫圈类(KungFuCircle),它除了用来存储前文所述的总敌人权重总攻击权重,还要划分好敌人的阵列,分为3层:因功夫圈不能再容纳更多权重时,剩余的敌人会待在最外层的徘徊圈;而那些被算入敌人权重的会待在较靠近玩家的等待圈;当等待圈中的敌人发起的攻击类型权重在承受范围内时,他们会进入攻击圈并发起攻击。

image

让我们先声明好相关变量,后三个关于阵型的变量它们表达的意思如上图:

[CreateAssetMenu(fileName ="CircleConfig_", menuName = "KungFuCircle_Plus/KungFuCircle_Config")]
public class KungFuCircle: ScriptableObject
{
    [Tooltip("可攻击敌人的权重上限")]
    public int weightLimit;

    [Tooltip("可攻击强度的权重上限")]
    public int atkLimit;

    [Tooltip("内圈(攻击圈)半径")]
    public float atkRadius;

    [Tooltip("外圈(等待圈)半径")]
    public float waitRadius;

    [Tooltip("圈外徘徊间距")]
    public float outCircleDis = 2;
}
image

在这三个圈中,我们要对等待圈做位置划分,在圈上得到均等的若干个位置用来让敌人占位并且要关注这些位置被占据的情况,避免多个怪抢占同一个位置。至于划分多少个位置,应当根据敌人权重上限来定,因为权重是正整数,最小的权重便是1,如果是8敌人权重上限,那么8个位置就一定够了。效果就是像上图那样 (但并非均等,因为是我手画的

public class KungFuCircle: ScriptableObject
{
    ……

    [HideInInspector] 
    public Vector3[] waitPosArr; //等待圈的点位位置
    
    [HideInInspector]
    public bool[] waitPosFlags; //等待圈点位被占据的情况(true为被占据,false为空闲)

    /// <summary>
    /// 初始化功夫圈
    /// </summary>
    public void Init()
    {
        waitPosArr = new Vector3[weightLimit];
        waitPosFlags = new bool[weightLimit];
    }

    /// <summary>
    /// 指定圆心center、半径radius的圆上均分count个位置并储存在posArr
    /// </summary>
    /// <param name="center">圆心位置</param>
    /// <param name="radius">半径</param>
    /// <param name="posArr">存储位置的数组(需已经分配大小)</param>
    public void PlaceCirclePos(Vector3 center, float radius, Vector3[] posArr)
    {
        float baseAngle = 360f / posArr.Length;
        for (int i = 0; i < posArr.Length; i++)
        {
            float rad = baseAngle * i * Mathf.Deg2Rad;

            posArr[i] = new Vector3(
                center.x + radius * Mathf.Cos(rad),
                center.y,
                center.z + radius * Mathf.Sin(rad)
            );
        }
    }
}

我们不直接在这个类中使用分配逻辑,所以只是提供给外部调用的公开函数。专门用来管理功夫圈敌人进出、攻击的将是另一个叫StageManager(舞台督导)的类。为敌人分配位置时我们采取就近原则,被允许进入功夫圈的敌人会走向当前离他最近的等待圈点位:

public class KungFuCircle: ScriptableObject
{
    ……

    /// <summary>
    /// 获取与curTrans位置最接近的等待圈点位置
    /// </summary>
    /// <param name="curPos">当前敌人位置</param>
    /// <returns>最接近的等待圈点位下标(无则为-1)</returns>
    public int GetClosestPosIndex(Vector3 curPos)
    {
        int posIndex = -1;
        float curDist, closestDis = float.MaxValue;
        for(int i = 0; i < waitPosArr.Length; ++i)
        {
            //如果i下标的点位已被占据,就找其他的
            if(waitPosFlags[i])
            {
                continue;
            }
            //计算与i下标点位置的距离平方(仅比较大小,可不开根号)
            curDist = Vector3.SqrMagnitude(curPos - waitPosArr[i]);
            if(curDist < closestDis)
            {
                closestDis = curDist;
                posIndex = i;
            }
        }
        return posIndex;
    }
}

最后还要考虑下徘徊圈的敌人,其实我们只需让它们与等待圈保持对应距离即可,至于他们如何踱步并不关心。我们就简单地让他们都绕着徘徊圈逆时针旋转吧,并且让徘徊圈的略微随机变化:

public class KungFuCircle: ScriptableObject
{
    ……
    /// <summary>
    /// 未有进圈机会的敌人在圈外徘徊
    /// </summary>
    /// <param name="enemyPos">敌人位置</param>
    /// <param name="circleCenter">功夫圈中心位置</param>
    /// <param name="patrolSpeed">徘徊速度</param>
    /// <returns>徘徊点位置</returns>
    public Vector3 CirclePatrol(Vector3 enemyPos, Vector3 circleCenter, float patrolSpeed)
    {
        var dirToCenter = circleCenter - enemyPos;
        dirToCenter.y = 0;
        dirToCenter.Normalize();
        //圈外徘徊半径距离(略大于等待圈半径 )
        var desiredDistance = waitRadius + outCircleDis + Random.Range(-0.5f, 0.5f);
        var targetPos = circleCenter - dirToCenter * desiredDistance;
        //沿徘徊半径所形成的圆切线方向移动
        Vector3 right = Vector3.Cross(Vector3.up, dirToCenter);
        return targetPos + right * patrolSpeed;
    }
}

敌人配置

接下来需要设计敌人配置类,让我们能自定义不同敌人需要的与功夫圈相关的属性,就叫它KungFuCircle_EnemyConfig……但这名字有点长,简称KFC_EnemyConfig吧。根据先前内容我们知道,每个敌人都要有自己的权重,以及为每种攻击设置权重和冷却。

这里做出一点小改动:对于攻击冷却,我们也为每个攻击设置对应的使用次数上限,只有当攻击方式的使用次数达到上限时才进入冷却。这样可以让同类敌人使用共同的一个冷却表就行,而且能严格地限制场景中同时存在的攻击数量,不会因为不同敌人各自冷却时间的偶然重合而超出预期难度限制。

还是以前文提到的会吐焰、爪击、扫尾这三种攻击方式的巨龙为例,如果把扫尾的攻击次数上限设为2,冷却设为10s,就意味着场上的巨龙无论有多少,只要用了两次扫尾后,就会扫尾这个攻击方式进入10s冷却,这10s内不会再有巨龙使用爪击。

[CreateAssetMenu(menuName = "KungFuCircle_Plus/EnemyConfig", fileName = "KFC_Enemy_")]
public class KFC_EnemyConfig : ScriptableObject
{
    [Tooltip("敌人自身的权重(越厉害的敌人权重越大)")]
    public int selfWeight = 1;

    [Tooltip("不同攻击类型的权重")]
    public int[] atkWeightTalbe; 

    [Tooltip("同时存在的攻击类型数量上限")]
    public int[] useTimesTable;
    
    [Tooltip("不同攻击类型的冷却(避免频繁使用同类攻击)")]
    public float[] atkCDTable;

    [HideInInspector]
    public float[] lastAtkTimes; //各类攻击上次使用的时间(用以计算冷却)

    [HideInInspector]
    public int[] curUseTable; //当前使用次数记录表

    private void OnEnable() 
    {
        int len = atkCDTable.Length;
        lastAtkTimes = new float[len];
        curUseTable = new int[len];
        //初始化上次使用攻击时间为极小值,以保证首次使用算作不在冷却
        for(int i = 0; i < len; ++i)
        {
            lastAtkTimes[i] = -10000f; 
            curUseTable[i] = useTimesTable[i];
        }
    }
}

在这个类中,我们使用lastAtkTimes数组记录上次该技能使用次数耗尽时的时间,因此,判断下标为i的攻击方式是否在冷却中,就只需要 当前时间-lastAtkTimes[i] >= atkCDTable[i] 就可以了。

敌人接口

刚刚只是实现了敌人应有的属性配置,为了让我们制作的功夫圈系统能用于各种各样的敌人,我们需要一个接口,只要继承了这个接口的敌人就都能被用在功夫圈了。这个接口的内容很简单,敌人只需要拥有两个变量就可以了,一个是前面实现的KFC_EnemyConfig,另一个是能反应敌人自身位置的变量,就直接用Transform类型的变量吧。接口本身不能有成员变量,但有个常用的技巧就是用属性来代替:

public interface IKFCEnemy
{
    KFC_EnemyConfig KFCConfig{get; set;}

    /// <summary>
    /// 敌人自身的Transform组件,以便实时查询位置
    /// </summary>
    Transform SelfTrans{get; set;}
}

舞台督导

真正管理着功夫圈的类舞台督导(StageManager)出现了,它会为敌人分配位置、指挥敌人要采取的行动,就像个导演一样(所以才叫「舞台督导」是吗 先来看看它应当做什么事情:

image
  1. 敌人不能直接发起攻击,在需要攻击时应当向StageManager发出请求;
  2. StageManager会记录下发出请求的敌人并挑选出功夫圈如今能容纳下权重的敌人,为他们指定各自的等待圈点位;
  3. StageManager还会查找等待圈中的敌人所能使用的攻击方式,并选出满足条件(攻击权重能承受、不在冷却中)的攻击方式;
  4. 被允许攻击的敌人得以进入攻击圈并使用StageManager所指定的攻击方式对玩家发起攻击;
  5. StageManager会指定那些未能进入等待圈的敌人会在圈外徘徊。

记住这5点,看看要如何实现吧!

首先是「发出请求」,虽说是敌人发出的请求,但相关函数还是在StageManager上,他们只是调用而已。StageManager很可能会同时收到多个敌人的请求,需要用一个容器储存起来,可以用数组、列表、堆……但,真的随便用一个就可以吗?很容易想到一种情况:有10个敌人都发出了请求,每次被选中的却总是个别两三个,其他人永远只能是徘徊圈的龙套,这是不合理的。用什么容器能很方便地避免这种情况呢?队列(Queue)!队列先进先出的特点,保证了这次被选中的敌人出队后下次再来时会排在最后面。

image

还要注意,对于每个敌人,StageManager只接收他的一次请求就行了,用HashSet来标记哪些敌人已经发出了请求,避免重复接收:

public class StageManager
{
    public KungFuCircle kungFuCircle; //功夫圈配置
    public Transform circleCenterTrans; //功夫圈的中心(一般为玩家)
    private int curWeightLimit; //当前剩余的敌人权重
    private int curAtkLimit; //当前剩余的攻击权重

    private readonly Queue<IKFCEnemy> requestQueue = new (); //请求发起攻击的敌人队列
    private readonly HashSet<IKFCEnemy> requestMarks = new (); //标记已进入请求队列的敌人

    public StageManager(KungFuCircle kungFuCircle, Transform circleCenterTrans)
    {
        this.kungFuCircle = kungFuCircle;
        this.circleCenterTrans = circleCenterTrans;
        kungFuCircle.Init();
        curWeightLimit = kungFuCircle.weightLimit;
        curAtkLimit = kungFuCircle.atkLimit;
    }

    /// <summary>
    /// 发起攻击请求
    /// </summary>
    /// <param name="enemy">发起攻击的敌人</param>
    public void RequestAttack(IKFCEnemy enemy)
    {
        if(!requestMarks.Contains(enemy))
        {
            requestQueue.Enqueue(enemy);
            requestMarks.Add(enemy);
        }
    }
}

接下来是第二点:选出进入等待区的敌人并分配给他们位置。分配位置的函数很简单,在功夫圈类中我们已经实现了「获取最近等待圈点位的方法 GetClosestPosIndex」,我们在调用它之前先判断下当前敌人权重是否可以被容纳就行;但我们还需要记录这些在等待圈中的敌人和他们对应的点位信息,方便指派与后续回收点位。同样可以使用队列来存储,至于位置信息就用字典(Dictionary)来记录吧:

public class StageManager
{
    ……

    private readonly Queue<IKFCEnemy> waitQueue = new (); //已在等待圈中的敌人队列
    private readonly Dictionary<IKFCEnemy, int> posIndexTable = new (); //占据等待圈的敌人对应位置列表下标

    ……

    /// <summary>
    /// 为已在等待圈中的敌人重新分配位置
    /// </summary>
    /// <param name="enemyOnWait"></param>
    public void ReSetWaitPos(IKFCEnemy enemyOnWait)
    {
        kungFuCircle.waitPosFlags[posIndexTable[enemyOnWait]] = false;
        int res = kungFuCircle.GetClosestPosIndex(enemyOnWait.SelfTrans.position);
        if(res > -1)
        {
            posIndexTable[enemyOnWait] = res;
            kungFuCircle.waitPosFlags[res] = true;
        }
    }

    /// <summary>
    /// 遍历请求队列,符合条件的转入等待队列
    /// </summary>
    private void Requst2Wait()
    {
        int length = requestQueue.Count;
        for(int i = 0; i < length; ++i)
        {
            var cur = requestQueue.Dequeue();
            if(TryGetWaitPos(cur))
            {
                requestMarks.Remove(cur);
                waitQueue.Enqueue(cur);
            }
            else if(requestMarks.Contains(cur))
            {
                requestQueue.Enqueue(cur);
            }
        }
    }

    /// <summary>
    /// 尝试为敌人分配等待圈点位
    /// </summary>
    /// <param name="enemy">当前敌人</param>
    /// <returns>是否分配成功</returns>
    private bool TryGetWaitPos(IKFCEnemy enemy)
    {
        if(enemy.KFCConfig.selfWeight <= curWeightLimit)
        {
            curWeightLimit -= enemy.KFCConfig.selfWeight;
            int res = kungFuCircle.GetClosestPosIndex(enemy.SelfTrans.position);
            if(res > -1)
            {
                posIndexTable[enemy] = res;
                kungFuCircle.waitPosFlags[res] = true;
                return true;
            }
        }
        return false;
    }
}

预测提问环节:

  • 咦,字典为什么不是Dictionary<IKFCEnemy, Vector3>类型的?
    因为玩家的移动会导致等待区点位的变化,如果是用Dictionary<IKFCEnemy, Vector3>就得不断更新字典中的值 (麻烦捏int作为点位数组下标来记录就方便多了。

  • ReSetWaitPos函数有什么用?
    有时,敌人在发起攻击时,玩家会闪避(或逃跑)使得现在的功夫圈偏移,可能现在敌人放弃旧的点位找个新的点位会更合适,比如下图这种情况:

    image

    玩家在敌人攻击期间,一个冲刺窜到了两个敌人的前方,那在这两个敌人攻击结束后,其实可以继续待在玩家后方的等待圈点位。所以这时就要重新分配一下点位,重新分配的逻辑也很简单,就是将原本点位的占据情况清除,再找一个新位置就行。

  • Requst2Wait函数中requestQueue队列调整的逻辑是什么,为什么出队又入队?
    requestQueue真正要扩充只能依赖RequestAttack函数,而Requst2Wait函数依次查询队列中的敌人是否能进入waitQueue,队列只能访问到队首的元素,所以要每查询一个敌人就将他移到队尾(通过出队后又入队的方式)。而有时可能敌人出现了一些意外,比如之前虽然发起了攻击请求但现在脱战了,那么他只需将自身在requestMarks的记录移除就行,在Requst2Wait里便只会让他出队而不入队,达成了移除的效果。

第三点需要我们为waitQueue中的敌人分配攻击方式,和之前分配位置一样,我们也用一个字典记录每个敌人所要使用的攻击方式的下标。通过记录下标,我们可以知道攻击方式所消耗的权重,敌人自身也能通过下标使用对应的攻击。下面的TryAttack函数便是通过遍历角色的攻击类型表找到其中能使用的并通过out int res返回:

public class StageManager
{
    ……
    private readonly Dictionary<IKFCEnemy, int> atkIndexTable = new (); //发起攻击敌人所用的对应攻击列表下标

    ……

    /// <summary>
    /// 返还攻击权重(适用于一次攻击结束后)
    /// </summary>
    /// <param name="enemy">移除权重的敌人</param>
    public void ReleaseAttackWeight(IKFCEnemy enemy)
    {
        if(atkIndexTable.TryGetValue(enemy, out int res))
        {
            curAtkLimit += enemy.KFCConfig.atkWeightTalbe[res];
            atkIndexTable.Remove(enemy);
        } 
    }

    /// <summary>
    /// 遍历等待队列,符合条件的转入攻击记录
    /// </summary>
    private void Wait2Attack()
    {
        int length = waitQueue.Count;
        for(int i = 0, res; i < length && curAtkLimit > 0; ++i)
        {
            var cur = waitQueue.Dequeue();
            //如果该敌人已选过了攻击方式,这次就跳过它
            if(atkIndexTable.ContainsKey(cur))
            {
                waitQueue.Enqueue(cur);
                continue;
            }
            if(posIndexTable.ContainsKey(cur))
            {
                if(TryAttack(cur, out res))
                {
                    atkIndexTable[cur] = res;
                    waitQueue.Enqueue(cur);   
                }
                else 
                {
                    waitQueue.Enqueue(cur);
                    return;
                }
            }
        }
    }

    /// <summary>
    /// 尝试进行攻击
    /// </summary>
    /// <param name="enemy">当前敌人</param>
    /// <returns>返回攻击类型的下标(返回-1表示未能攻击)</returns>
    private bool TryAttack(IKFCEnemy enemy, out int res)
    {
        res = -1;
        var KFCConfig = enemy.KFCConfig;
        int len = KFCConfig.atkWeightTalbe.Length;
        //遍历攻击类型,找出权重满足、不在冷却的攻击
        for(int i = 0; i < len; ++i)
        {
            if(KFCConfig.atkWeightTalbe[i] <= curAtkLimit &&
                KFCConfig.atkCDTable[i] <= Time.time - KFCConfig.lastAtkTimes[i])
            {
                if(--KFCConfig.curUseTable[i] == 0) 
                {
                    //如果可用次数耗尽,就进入冷却并重置次数以便下次使用
                    KFCConfig.lastAtkTimes[i] = Time.time;
                    KFCConfig.curUseTable[i] = KFCConfig.useTimesTable[i];
                }
                curAtkLimit -= KFCConfig.atkWeightTalbe[i];
                res = i;
                return true;
            }
        }
        return false;
    }
}

Wait2Attack和之前的Request2Wait逻辑很像,有两处不同:

琪一,它需要首先判断敌人是否已经获取到了攻击方式,避免重复分配攻击;那为什么Request2Wait不需要这么做呢?首先需要明确一点,因为敌人随时都有可能发出攻击请求,所以Wait2Attack和Request2Wait是每帧都会调用的,而requestQueue中所记录的敌人一定是未被允许进入等待圈的(因为被允许的都被移到waitQueue了);但是waitQueue中所记录的却没说一定是未进行攻击的敌人,他们中有的可能已经得到了攻击方式,只是还没结束攻击,因而需要多一步判断。

另一,for循环中当curAtkLimit > 0才进入循环且else之后重新进队后直接return结束函数了:

else 
{
    waitQueue.Enqueue(cur);
    return;
}

为什么这么做呢?来看看下面这种情况:假设现在玩家所能承受的总攻击权重是3,与玩家打斗的有3只小怪和1只大怪(下图黑圆表示小怪,红圈表示大怪),小怪只有一种权重为1的普通攻击,大怪也只有一种权重为3的普通攻击并且现在在waitQueue的队列是「小、大、小、小」,如果没有curAtkLimit > 0return意味着函数会搜索整个队列。调用一次Wait2Attack后,被允许攻击的敌人头上标上箭头:

image

可以看到,是3只小怪被分配到了攻击,等它们都攻击完后,总攻击权重会重新回到3。但是,再来一次的结果会不一样吗?不会的,大怪永远没有机会出手的,因为队伍的顺序不会发生变化,毕竟每个敌人都进行了一次出队、入队。这并不是我们想要的,在等待圈中的敌人应当都有出手的机会才是。

加了return,意味着在遍历队列时,当权重分配完,或者遇到了这次无法发动攻击的敌人并将它重新入队(移到队尾)后就停止了。这样一来,下次遍历的顺序就会改变,上次无法攻击的敌人在后续就会有机会出手。继续刚才的例子,在有了return后,第三次时,大怪迎来了出手机会。

image

至此,舞台督导的核心函数已经完成了,剩下的便是一些方便外部调用的简单函数,相信你看函数上方的注释就能明白它在做什么:

public class StageManager
{
    ……

    /// <summary>
    /// 更新功夫圈的点位(可在FixedUpdate中调用)
    /// </summary>
    public void UpdateCirclePos() 
    {
        kungFuCircle.PlaceCirclePos(circleCenterTrans.position, kungFuCircle.waitRadius, kungFuCircle.waitPosArr);
    }

    /// <summary>
    /// 更新攻击请求与实际攻击(可在LateUpdate中调用)
    /// </summary>
    public void UpdateRequst() 
    {
        Requst2Wait();
        Wait2Attack();
    }

    /// <summary>
    /// 一并返还占据的位置权重与攻击权重(适用于敌人自身死亡、失去攻击欲望等)
    /// </summary>
    /// <param name="enemy">移除权重的敌人</param>
    public void ReleaseAllWeight(IKFCEnemy enemy)
    {
        requestMarks.Remove(enemy);
        if(posIndexTable.TryGetValue(enemy, out int res))
        {
            kungFuCircle.waitPosFlags[res] = false;
            curWeightLimit += enemy.KFCConfig.selfWeight;
            posIndexTable.Remove(enemy);
        }
        ReleaseAttackWeight(enemy);
    }

    /// <summary>
    /// 查询敌人是否在等待圈中(获得了等待攻击的机会)
    /// </summary>
    /// <param name="enemy">查询的敌人</param>
    /// <returns>如果是,返回等待圈位置下标;否则返回-1</returns>
    public int IsOnWaitCircle(IKFCEnemy enemy)
    {
        return posIndexTable.TryGetValue(enemy, out int res) ? res : -1;
    }

    /// <summary>
    /// 查询敌人是否在攻击圈中(获得了攻击许可)
    /// </summary>
    /// <param name="enemy">查询的敌人</param>
    /// <returns>如果是,返回攻击类型下标;否则返回-1</returns>
    public int IsOnAtkCircle(IKFCEnemy enemy)
    {
        return atkIndexTable.TryGetValue(enemy, out int res) ? res : -1;
    }

    /// <summary>
    /// 根据敌人当前位置,获取圈外徘徊点
    /// </summary>
    /// <param name="curPos">敌人当前位置</param>
    /// <param name="patrolSpeed">徘徊速度</param>
    /// <param name="res">返回的徘徊点位置</param>
    public void GetOutCirclePatrolPos(Vector3 curPos, float patrolSpeed, out Vector3 res)
    {
        res = kungFuCircle.CirclePatrol(curPos, circleCenterTrans.position, patrolSpeed);
    }

    /// <summary>
    /// 让敌人靠近攻击圈
    /// </summary>
    /// <param name="curPos">敌人当前位置</param>
    /// <param name="res">返回的目标攻击点位置</param>
    public void GetAtkCirclePos(Vector3 curPos, out Vector3 res)
    {
        var dir = (curPos - circleCenterTrans.position).normalized;
        res = circleCenterTrans.position + dir * kungFuCircle.atkRadius;
    }
}

示例

可以看看开头提供的小示例

现在试试正常运行功夫圈,我们需要一个运行舞台督导的脚本并挂载在场景物体上:

public class Test : MonoBehaviour
{
    public static StageManager manager;
    public KungFuCircle kungFuCircle;
    public Transform circleCenter;

    private void Awake() 
    {
        manager = new StageManager(kungFuCircle, circleCenter);  
    }

    private void FixedUpdate() 
    {
        manager.UpdateCirclePos();    
    }

    private void LateUpdate()
    {
        manager.UpdateRequst();
    }
}

然后就是敌人了,在示例项目中使用了状态机制作了简单的敌人AI,它有4个状态:闲置、察觉、等待、攻击,关系如下所示:

image

完整的代码在项目中有,这里就简单讲下与功夫圈相关的一些处理:

  1. 进入闲置状态时,敌人视为脱战,应当调用舞台督导提供的ReleaseAllWeight返还权重(就算敌人原本没有在等待圈攻击圈也没关系,不会报错);

  2. 进入察觉状态时,向舞台督导发出一次攻击请求即可;

  3. 退出攻击状态时,要调用舞台督导ReSetWaitPos来更新自身等待点位(如前文所提到的,因玩家的移动,可能有更适合的位置);

  4. 攻击完毕时,调用ReleaseAttackWeight则只是返还攻击权重,等待圈中的敌人不会变更(如下图左边效果);调用ReleaseAllWeight则会变更人(下图右边效果);

    image image

在这个项目中,敌人分成三种:橙色(权重2)、红色(权重4)、黑色(权重6);攻击方式简单的用发射方块子弹来代替,也分为橙红黑三种,权重因人而已,可自行配置。因为我以发射子弹后就算攻击结束,所以攻击节奏看着会有些快。实际可以攻击动画播放完毕作为结束,这时再返还攻击权重更合理。

在选用ReleaseAttackWeight后,可以在敌人攻击时,移动玩家,观察其重新分配点位的变化:

image

开关「是否察觉玩家」可以模拟功夫圈中的敌人脱战的情景:

image

尾声

上述这些只是个人根据《GameAIPro》的第28章中的功能描述进行的尝试复现,多少会有不周到的地方 (我会极力避免的。正如书中所说的那样,这个系统的实现与使用都比较简单。也许大家也有自己的实现想法或者改良方向,都不妨大胆尝试下。

posted @ 2025-07-04 19:52  狐王驾虎  阅读(132)  评论(0)    收藏  举报