在自学UE4的过程中,个人感觉最无力的就是面对一个项目无法理解它的流程。本文是笔者对UE4官方的战略游戏的研究,主要集中在AI的研究上,希望可以对读者有所帮助。
由于能力与时间的限制,如果文中有纰漏,请务必斧正!非常感谢!!
本文的AI分析主要集中在两点:
1、character如何从移动状态切换到攻击状态(能感知周围的敌人)?
2、如何对敌人造到伤害?
感知敌人
怎么打开编辑器,打开VS工程就不说了,如果这都不会,那你需要先过一遍官方视频教程。我们直接从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的名字居然是一样的:
好吧,仔细看才发现字母的顺序不一样,这是要有多坑!