StrategyGame中的AI控制

一个开发者的点击积累

在自学UE4的过程中,个人感觉最无力的就是面对一个项目无法理解它的流程。本文是笔者对UE4官方的战略游戏的研究,主要集中在AI的研究上,希望可以对读者有所帮助。

由于能力与时间的限制,如果文中有纰漏,请务必斧正!非常感谢!!

本文的AI分析主要集中在两点:
1、character如何从移动状态切换到攻击状态(能感知周围的敌人)?
2、如何对敌人造到伤害?

感知敌人

怎么打开编辑器,打开VS工程就不说了,如果这都不会,那你需要先过一遍官方视频教程。我们直接从Wave 1这个事件调用作为起点来开始研究。打开关卡蓝图的事件图表,找到Wave 1:


Wave 1启动

笔者在研究的时候习惯把声音关掉,勿怪勿怪:)

我们把焦点集中到SpawnNormalMacro节点上。这个宏有两个输入值,一个是工厂对象,还有一个是产生的敌人数(Number)。这个Number会直接赋值给了UStrategyAIDirector的成员变量WaveSize从而影响生成的小兵数量。我们来看代码(UStrategyAIDirector::SpawnMinions函数):

if(WaveSize > 0)
{
        ...

            AStrategyChar* const MinionChar =  GetWorld()->SpawnActor<AStrategyChar>(Owner->MinionCharClass, Loc, Owner->GetActorRotation(), SpawnInfo);
            // don't continue if he died right away on spawn
            if ( (MinionChar != nullptr) && (MinionChar->bIsDying == false) )
            {
                // Flag a successful spawn
                bSpawnedNewMinion = true;

                MinionChar->SetTeamNum(GetTeamNum());

                MinionChar->SpawnDefaultController();
                MinionChar->GetCapsuleComponent()->SetRelativeScale3D(Scale);
                MinionChar->GetCapsuleComponent()->SetCapsuleSize(CapsuleRadius, CapsuleHalfHeight);
                MinionChar->GetMesh()->GlobalAnimRateScale = AnimationRate;
      ...

                WaveSize -= 1;
      ...
}

小兵生成后,会自己创建一个AIController(MinionChar->SpawnDefaultController()函数)来控制自己,这样AIController就会接管(Possess)这个小兵的控制权。AIController控制小兵的方式是启用Tick,然后在Tick函数中检测到底是要执行什么动作(移动还是攻击),并且选择可以攻击目标:

void AStrategyAIController::Tick(float DeltaTime)
{
...

    // select best action to execute
    const bool bCanBreakCurrentAction = CurrentAction != NULL ? CurrentAction->IsSafeToAbort() : true;
    if (bCanBreakCurrentAction)
    {
        for (int32 Idx = 0; Idx < AllActions.Num(); Idx++)
        {
            if (CurrentAction == AllActions[Idx] && AllActions[Idx]->ShouldActivate())
            {
                break;
            }

            if (CurrentAction != AllActions[Idx] && AllActions[Idx]->ShouldActivate())
            {
                if (CurrentAction != NULL)
                {
                    UE_VLOG(this, LogStrategyAI, Log, TEXT("Break on '%s' action, found better one '%s'"), *CurrentAction->GetName(), *AllActions[Idx]->GetName()); 
                    CurrentAction->Abort();
                }

                CurrentAction = AllActions[Idx];
                if (CurrentAction != NULL)
                {
                    UE_VLOG(this, LogStrategyAI, Log, TEXT("Execute on '%s' action"), *CurrentAction->GetName(), *AllActions[Idx]->GetName()); 
                    CurrentAction->Activate();
                    break;
                }
            }
        }
    }

    SelectTarget();
}

仔细阅读Tick函数,发现它做了两件事情:

  • 1、选择要执行的动作(移动or攻击)
  • 2、确定攻击目标

我们来看看它是如何实现的。

选择要执行的动作
首先要知道当前在执行的是什么动作,然后判断这个动作是否可以丢弃(IsSafeToAbort)。移动的动作可以被攻击代替,而攻击的动作无法被移动代替。所以,移动是可以被丢弃的,但攻击不行。是否可以丢弃的判断在IsSafeToAbort函数中完成,每个动作都会重载这个函数。然后,通过ShouldActivate函数判断动作是否需要被激活,当小兵遇到敌人时,攻击动作的这个函数就会返回true,从而导致移动动作的终止,攻击动作的激活(Activate)。

确定攻击目标
SelectTarget()函数,这是非常重要的一个函数!

先思考一个问题,如果是我们自己实现的话,我们怎么确定攻击目标?
第一种方法:计算所有地方小兵与我的距离,如果距离小于一个值,那么就被我选定为攻击目标。
第二种方法:先圈定我自己周围一个范围,在这个范围之内的小兵我才和它计算距离,然后确定是否选为攻击目标。

这个项目所采用的是第二种方法。显然,第二种方法更加合理,更符合我们的认知(我们根本不知道百八十里外是否有人!)。采用第二种方法的问题是,我怎么样才能知道我周围有敌人了?(换句话说,我怎样“看到”敌人?)

好在,UE4中有个感知组件,大大减少了我们的工作量。

SensingComponent(UStrategyAISensingComponent类型)中保存了我可以感知到的敌人,变量名是KnownTargets。UStrategyAISensingComponent类中有一个UpdateAISensing()函数,这个函数每帧都会被调用,在这个函数里小兵会检查其他的小兵是否可感知,是否能被看到,如果可以被看到,那就会被加到这个小兵的KnownTargets中,表示当前小兵已经感知到了敌人。关键代码如下:

for (FConstPawnIterator Iterator = Owner->GetWorld()->GetPawnIterator(); Iterator; ++Iterator)
{
    AStrategyChar* const TestChar = Cast<AStrategyChar>(*Iterator);
    if (!IsSensorActor(TestChar) && ShouldCheckVisibilityOf(TestChar))
    {
        if (CouldSeePawn(TestChar, true))
        {
            KnownTargets.AddUnique(TestChar);
        }
    }
}

AStrategyAIController::SelectTarget则会判断KnowTargets中的所有敌人,计算它们与自己的距离,根据一套固定的打分机制(比如当前判断目标是否已经成为自己的攻击目标,我自己是否已经被目标当成敌人等等),选择一个敌人作为自己的目标,同时,通知敌人:小子,你已经成为我的目标了!

void AStrategyAIController::SelectTarget()
{
    if( GetPawn() == NULL )
    {
        return;
    }
    const FVector PawnLocation = GetPawn()->GetActorLocation();
    AActor* BestUnit = NULL;
    float BestUnitScore = 10000;

    for (int32 Idx = 0; Idx < SensingComponent->KnownTargets.Num(); Idx++)
    {
        AActor* const TestTarget = SensingComponent->KnownTargets[Idx].Get();
        if (TestTarget == NULL || !IsTargetValid(TestTarget) )
        {
            continue;
        }

        /** don't care about targets with disabled logic */
        const APawn* TestPawn = Cast<APawn>(TestTarget);
        const AStrategyAIController *AITarget = (TestPawn ? Cast<AStrategyAIController>(TestPawn->Controller) : NULL);
        if (AITarget != NULL && !AITarget->IsLogicEnabled())
        {
            continue;
        }

        float TargetScore = (PawnLocation - TestTarget->GetActorLocation()).SizeSquared();
        if (CurrentTarget == TestTarget && TestTarget->IsA(AStrategyChar::StaticClass()) )
        {
            TargetScore -= FMath::Square(300.0f);
        }

        if (AITarget != NULL)
        {
            if (AITarget->IsClaimedBy(this))
            {
                TargetScore -= FMath::Square(300.0f);
            }
            else
            {
                TargetScore += AITarget->GetNumberOfAttackers() * FMath::Square(900.0f);
            }
        }

        if (BestUnit == NULL || BestUnitScore > TargetScore)
        {
            BestUnitScore = TargetScore;
            BestUnit = TestTarget;
        }
    }

    const AActor* OldTarget = CurrentTarget;
    CurrentTarget = BestUnit;    // 将最合适的敌人当成自己的目标
// 告诉敌人它已经成为我目标的代码
...

...
}

最后回到ShouldActivate函数,有了敌方目标之后,攻击动作就该被激活了:

bool UStrategyAIAction_AttackTarget::ShouldActivate() const
{
    check(MyAIController.IsValid());
    return MyAIController->CurrentTarget != NULL && MyAIController->IsTargetValid(MyAIController->CurrentTarget);
}

然后,顺利成章的放弃移动,转为攻击!这样,切换动作的流程就走通了。

感知伤害

找感知伤害的路径比较费劲,主要是由于一个函数不知道在哪调用却确确实实被调用了,这个函数就是AnimNotify_Melee函数。

我们先来看看造成伤害的函数是在哪调用的。函数名是TakeDamage,这是一个虚函数,调用的地方在引擎自身的代码中,全局一搜之后,发现这几个地方最像是会调用的地方:



但是要具体到哪个函数,还真无法分辨。怎么办呢,说简单也简单,说复杂也复杂。调试呗!不过只用下载的引擎是看不出调用堆栈的,必须要下载源码,然后打断点,下面是笔者获得的调用堆栈:



这下确定了,就是ApplyPointDamage函数。再上一层是OnMeleeImpactNotify函数,这个函数负责挑选受到“我”伤害的敌人,我们来看:
void AStrategyChar::OnMeleeImpactNotify()
{
    const int32 MeleeDamage = FMath::RandRange(ModifiedPawnData.AttackMin, ModifiedPawnData.AttackMax);
    const TSubclassOf<UDamageType> MeleeDmgType = UDamageType::StaticClass();

    // Do a trace to see what we hit
    const float CollisionRadius = GetCapsuleComponent() ? GetCapsuleComponent()->GetScaledCapsuleRadius() : 0.f;
    const float TraceDistance = CollisionRadius + (ModifiedPawnData.AttackDistance * 1.3f);
    const FVector TraceStart = GetActorLocation();
    const FVector TraceDir = GetActorForwardVector();
    const FVector TraceEnd = TraceStart + TraceDir * TraceDistance;
    TArray<FHitResult> Hits;
    FCollisionQueryParams TraceParams(SCENE_QUERY_STAT(MeleeHit), false, this);
    TraceParams.bTraceAsyncScene = true;            // also trace against the async/cosmetic scene
    FCollisionResponseParams ResponseParam(ECollisionResponse::ECR_Overlap);
    GetWorld()->SweepMultiByChannel(Hits, TraceStart, TraceEnd, FQuat::Identity, COLLISION_WEAPON, FCollisionShape::MakeBox(FVector(80.f)), TraceParams, ResponseParam);

    for (int32 i=0; i<Hits.Num(); i++)
    {
        FHitResult const& Hit = Hits[i];
        if (AStrategyGameMode::OnEnemyTeam(this, Hit.GetActor()))
        {
            UGameplayStatics::ApplyPointDamage(Hit.GetActor(), MeleeDamage, TraceDir, Hit, Controller, this, MeleeDmgType);

            // only damage first hit
            break;
        }
    }
}

函数先计算攻击距离,方法是从“我”当前的位置向前方发出一条射线检查碰撞,如果有碰撞的,选择第一个敌人作为我攻击的对象。

再上一层。AnimNotify_Melee中调用了OnMeleeImpactNotify函数,但是再往上一层的execAnimNotify_Melee就直接定位到GENERATED_UCLASS_BODY()了。这怎么搞?

换个思路。如果要造成伤害,角色的动画中肯定要发出一个通知,能不能从通知入手?说干就干,还真让我们找到了:



有一个Melee通知,但是在事件图表中没发现实现这个通知的地方啊,这是怎么回事呢?从现象来看,肯定是UStrategyAnimInstance里的AnimNotify_Melee被调用了,但是没见到调用地方,一看函数的声明,前面有个UFUCNTION(),更加肯定是被调用了,但是到底怎么调用的?突然灵光一闪,在创建动画蓝图的时候是可以选父类的,而UStrategyAnimInstance正好继承自UAnimInstance,可以作为父类,难不成?



果然如此!再试试能不能在动画蓝图的事件图标里定义AnimNotify_Melee函数,果然不行:

大功告成,伤害感知的路径也走通了!

总结

这里面容易忽视掉的就是动画蓝图的继承这一个环节,笔者一直无法理解为什么UStrategyAnimInstance的AnimNotify_Melee会被调用,蓝图中根本找不到调用的地方啊,找了很久才找到原来是已经被继承了,坑!还有就是两个AnimNotify的名字居然是一样的:



好吧,仔细看才发现字母的顺序不一样,这是要有多坑!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容

  • 2016暑假有幸参加旅日团,去日本短暂的观光旅行。出去旅行,有时我可不会老老实实的呆在宾馆里看看电视啥,宁可起个大...
    艾冰台阅读 464评论 3 6
  • 五月,高温提前闯入 得给汗如雨下找一个 正当的理由 曲腿挺胸抬头向后看 做成英雄式,流汗 伸臂后仰前俯卧倒 练习拜...
    阿钡钉阅读 160评论 0 5
  • melissa_2a49阅读 93评论 0 1
  • 一 第一次见面应该算是小学的时候,你在61班,我在62班,距离仅仅只是隔了一堵墙而已。 可是那时候还太小,哪怕每天...
    ikoko的世界全是你阅读 468评论 0 0
  • 一个绚丽易用的输入框烟花效果,模仿网页360搜索框。gif图片表现效果不好,实际的Demo里显示的效果更佳,同时不...
    眼热阅读 2,688评论 10 39