AI Perception: Core

This is a small sample of the core perception logic, written in the exp language created by John Haggerty.

Exp Cheatsheet:

  • aslongas - keeps the current scope alive "as long as" the expression remains true
  • whenever - Runs the following statement repeatedly, once each time the expression is true, ending the statement when it becomes false, and waiting for it to become true again when the statement ends
  • meantime - Whenever may be paired with "meantime" to provide a statement that will be run when the expression is false
  • thread - Exp "threads" are asynchronous function calls. They are akin to coroutines in some other languages: they take turns executing and so are not truly asynchronous.
  • localthread - A localthread is a thread that is guaranteed to end when the scope it is defined in is exited. You can customize the scope it is attached to with the @ symbol.
  • cleanup - After a cleanup block is passed during execution, the statement (or block of statements) after cleanup is guaranteed to run when the scope is exited.
shared ctconst float THINK_RATE_DEFAULT = 0.2

// Think() runs every 0.2s as an optimization, which is fine since it only handles broad updates like perception and enemy selection.
UFUNCTION( EventOverride )
shared virtual override codecall
fn ABaseAI::Think()
{
    // Run visual perception (see targets)
    TickPerception_Sight( THINK_RATE_DEFAULT )

    // Select a target
    if ( HasAnyThreats() )
    {
        ProcessSenses( true, THINK_RATE_DEFAULT )
    }

    // Update aiming and fire location
    AControllerForAI? controller = GetControllerForAI()
    if ( controller && IsFocusedOnCurrentThreat() )
    {
        if ( senseInfo.enemyIsSeen )
        {
            controller.SetFocus( enemy! )
            SetFocusAimDifficultyParamsForTarget( enemy! )
        }
        else
        {
            controller.SetFocalPoint( senseInfo.lastKnownThreatPosition )
        }
    }
}


// This is the main workhorse of perception, where we set our state and update info about all our threats.
shared maydefer fn ABaseAI::ProcessSenses( bool shouldRefreshKnowledge, float dt )
{
    defersignals // We want to apply all of the senseInfo watchable changes at once
    {
        if ( shouldRefreshKnowledge ) // prevents recursion from other calls outside think.
        {
            const timeval currentTime = GetTime()
            foreach ( threat, knowledge in senseInfo.threatMap )
            {
                UpdateThreatKnowledge( threat, threat.GetActorLocation(), currentTime )
            }
        }

        // Select our new target
        AActor? newEnemy = TickGetBestEnemy(dt)
        if ( !disableAICombat )
        {
            AActor? originalEnemy = enemy
            bool didEnemyChange = originalEnemy != newEnemy

            if ( this is AVillain && didEnemyChange && IsInPlay(originalEnemy) && IsInPlay(newEnemy) )
            {
                thread RequestBattleChatter( EBarkEvent.EnemyChanged, newEnemy )
            }

            if ( didEnemyChange || !IsInPlay(originalEnemy) )
            {
                SetEnemy( newEnemy )
                AddAIDebugEvent( "Enemy changed: {newEnemy}" )
            }

            UpdateEnemyData( didEnemyChange )
        }

        // Pick our new ai state
        const AIState desiredState = GetDesiredAIState()
        SetCurrentState( desiredState )

        // If we're in combat we should set our "last known threat position" every frame to preserve some older behaviors
        if ( desiredState == AIState.COMBAT && IsInPlay(newEnemy) )
        {
            senseInfo.lastKnownThreatPosition = newEnemy!.GetActorLocation()
        }
    }
}