In 2023 I enrolled for a master's degree on game development where I spent a whole year working with 6 other developers making a game in Unreal Engine. The game is a sci-fi action-adventure game with stealth elements where the players control an alien creature trying to escape a lab spaceship the humans own.
To make the main character more interesting we decided to play with its movement. It can move through floors, walls and ceilings as well as use webs to move quickly around rooms and suspend itself mid-air. This decision increased the complexity of the project and we had to rethink some other decisions that would've been trivial otherwise. One of them being the behavior of enemies.
Since our character can move through ceilings we wanted to give it a bit of an advantage over moving through the floor to encourage using the full range of motion. UE's system didn't allow the flexibility to do the following:
I'm writing this by heart, so I might be missing some stuff, but it was enough for us to write our own perception system. Besides, every time I struggled with any of these issues I would put some time into thinking how the new system would look like, so by the time we decided to make the switch it turned out to be pretty easy to build.
Each soldier has a set of cones of vision which are defined by height, angle, minimum radius and maximum radius. Every update each soldiers checks if the player falls into any of the cone and, if it does, throws a raycast to check if the player is in their line of sight.
It's simple, but effective. With this new system some of the design decisions were easy to add, these being the most relevant ones:
I haven't properly tested if this method is more resource intensive than UE's system, but my guess is that it can't be much slower. After all our system is simpler. We don't have to account for all kinds of AIs populating the world and more than one player interacting with them.
Overall I'm happy how it turned out. It's not always obvious when it's a good idea to roll your own code for something the engine already provides, but as problems piled up it felt right to do so. Here's the code for the check, it's as straightforward as you'd imagine:
bool ASoldierAIController::IsPlayerInSight() {
ASpider *PlayerCharacter = GetPlayerCharacter();
if (!PlayerCharacter) { return false; }
ASLSoldier *SoldierCharacter = Cast<ASLSoldier>(GetInstigator());
if (!SoldierCharacter) { return false; }
FVector SoldierPosition = SoldierCharacter->GetActorLocation();
FVector PlayerPosition = PlayerCharacter ->GetActorLocation();
// If the Soldier is alerted we don't care about the cones
if (bIsAlerted) {
return IsPlayerInSightRaycast(SoldierPosition, PlayerPosition);
}
FVector SoldierPositionNoZ = FVector(SoldierPosition.X, SoldierPosition.Y, 0);
FVector PlayerPositionNoZ = FVector( PlayerPosition.X, PlayerPosition.Y, 0);
FVector SoldierToPlayer = PlayerPositionNoZ - SoldierPositionNoZ;
float DistanceSqr = SoldierToPlayer.SquaredLength();
// Check if the player is too far away
if (DistanceSqr > MaxSightRadius*MaxSightRadius) { return false; }
float ZDistance = FMath::Abs(SoldierPosition.Z - PlayerPosition.Z);
FVector SoldierForward = SoldierCharacter->GetActorForwardVector();
SoldierForward .Normalize();
SoldierToPlayer.Normalize();
float DotResult = FVector::DotProduct(SoldierForward, SoldierToPlayer);
for (FUSLAICone Cone : SoldierCharacter->ConesOfVision) {
// Check if player is close enough
float MaxRadiusSqr = Cone.MaxSightRadius * Cone.MaxSightRadius;
float MinRadiusSqr = Cone.MinSightRadius * Cone.MinSightRadius;
if (DistanceSqr > MaxRadiusSqr) continue;
if (DistanceSqr < MinRadiusSqr) continue;
// Check if player is at the right height
if (ZDistance > Cone.SightHeight) continue;
// Check if player is in front
float CosPeripherialVision =
FMath::Cos(FMath::DegreesToRadians(Cone.PeripherialVision/2));
if (DotResult <= CosPeripherialVision) continue;
// At this point we know the player is inside the cone,
// so we raycast to check if it can be seen and return
return IsPlayerInSightRaycast(SoldierPosition, PlayerPosition);
}
return false;
}
bool ASoldierAIController::IsPlayerInSightRaycast(
FVector SoldierPosition, FVector PlayerPosition)
{
FHitResult HitResult;
FCollisionQueryParams TraceParams;
TraceParams.AddIgnoredActor(GetInstigator());
bool bHit = GetWorld()->LineTraceSingleByChannel(
HitResult, SoldierPosition, PlayerPosition,
SL_ECC_SoldierAI, TraceParams
);
return (bHit && (HitResult.GetActor() == GetPlayerCharacter()));
}