AI behavior: Turret

This is the ballistic firing logic for a stationary enemy turret. It is 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.
ctconst timeval BALLISTIC_SHOT_INTERVAL = 5.0
ctconst string FXC_RADIUS_PARAM = "Radius"

// set desired barrel aim orientation, then fire
maywait fn ASentryTurret::UpdateDesiredAimLocationAndFire_BallisticArc()
{
    localthread ScopedDebugString( "UpdateDesiredAimLocationAndFire_BallisticArc" )

    AShooterWeapon? currentWeapon = GetCurrentWeapon()
    assert( currentWeapon is AShooterWeapon_Projectile )

    loop
    {
        timeval timeWhenReadyToFire = LastBallisticAttackTime + BALLISTIC_SHOT_INTERVAL
        waitfor GetTime() > timeWhenReadyToFire

        // get the target loc + velocity
        vector targetStartLocation
        vector targetVelocity

        if ( IsInPlay(enemy) )
        {
            targetVelocity = enemy!.GetVelocity()
            targetStartLocation = enemy!.GetActorLocation() + targetVelocity * SERVER_FRAME_RATE // get one frame ahead per Haggerty's antilag suggestion.
        }
        else
        {
            targetStartLocation = senseInfo.lastKnownThreatPosition
        }

        // how high above the turret do we want the shot arc to peak?
        float dist2Target2d = Distance2d( targetStartLocation, GetActorLocation().ToVector() )
        float arcHeight = RemapClamped( dist2Target2d, ArcHeightMax, 80 * METERS_TO_CM, ArcHeightMin, 1000 )

        // we're searching for the angle of the barrel, so use a simulated position from where the bullet would fire from.
        vector simulatedFiringPos = GetSimulatedFiringPosition( currentWeapon )
        auto lateralSpeedResult = GetBallisticLaunchVector_LateralSpeed( simulatedFiringPos, targetStartLocation, targetVelocity, ProjectileSpeed, arcHeight )

        // we got a result, so now move the barrel into the correct orientation
        if ( lateralSpeedResult.ReturnValue )
        {
            // DrawDebugSphere(lateralSpeedResult.ImpactLocation, 200, 12, FLinearColor_Yellow, 6, 4 )
            // DrawDebugArrow( simulatedFiringPos, simulatedFiringPos + lateralSpeedResult.LaunchVelocity.normalized() * 500, 500, FLinearColor_Violet, 5, 4 )

            if ( CheckIsThreatWithinAngularLimits(lateralSpeedResult.ImpactLocation) )
                DoBallisticShot( currentWeapon, lateralSpeedResult, simulatedFiringPos, arcHeight )
        }
        else
        {
            waitfor (changeto senseInfo.lastKnownThreatPosition) || (changeto watchableLocation)
        }
    }
}


maywait fn ASentryTurret::DoBallisticShot( AShooterWeapon_Projectile weapon, BallisticLaunchResultsLateral launchResult, vector simulatedFiringPos, float arcHeight )
{
    UWeaponSettings weaponSettings = weapon.GetWeaponSettings()

    vector launchVec = launchResult.LaunchVelocity
    float gravityScale = launchResult.GravityValue / abs( GravityZ )
    vector impactLoc = launchResult.ImpactLocation

    DesiredAimLoc = simulatedFiringPos + launchVec

    // wait for barrel to get into position. Barrel is rotated in external logic.
    watchable int yawPitchReached
    localthread
    {
        waitfor changeto YawRangeReachedCount
        yawPitchReached++
    }
    localthread
    {
        waitfor changeto PitchRangeReachedCount
        yawPitchReached++
    }

    waitfor yawPitchReached >= 2

    // we did the initial calculation to get the barrel angle for visuals.
    // now do another calculation for the actual shot, which uses the muzzle location instead of the barrel pivot location.
    // also time has passed from the first calc, so this updates with a more accurate shot
    auto lateralSpeedResult2 = GetBallisticLaunchVector_LateralSpeed( weapon.GetMuzzleLocation(), enemy!.watchableLocation, enemy!.watchableVelocity, ProjectileSpeed, arcHeight )
    if ( lateralSpeedResult2.ReturnValue && CheckIsThreatWithinAngularLimits( lateralSpeedResult2.ImpactLocation ) )
    {
        launchVec = lateralSpeedResult2.LaunchVelocity
        gravityScale = lateralSpeedResult2.GravityValue / abs( GetWorld().GetGravityZ() )
        impactLoc = lateralSpeedResult2.ImpactLocation
    }

    FRotator muzzleRot = Mesh!.GetSocketRotation( SOCKET_TURRET_MUZZLE )
    FRotator launchVelocityAsRot = UMathLibrary_MakeRotFromXZ( launchVec, muzzleRot.GetUpVector() )
    FTransform projLaunchXform = UMathLibrary_MakeTransform( weapon.GetMuzzleLocation(), launchVelocityAsRot )

    AActor? projectileToSpawn = BeginDeferredActorSpawnFromClass( weapon.ProjectileClass.Get(), projLaunchXform )
    assert( projectileToSpawn is AProjectile )

    projectileToSpawn.SetParentWeapon( weapon )    
    projectileToSpawn.SetUserCharacter( this )
    projectileToSpawn.SetOwner( this )

    projectileToSpawn.MovementComp!.InitialSpeed = launchVec.length()
    projectileToSpawn.MovementComp!.MaxSpeed = launchVec.length()
    projectileToSpawn.MovementComp!.ProjectileGravityScale = gravityScale
    projectileToSpawn.InitVelocity( launchVec.normalized(), GetVelocity() )

    projectileToSpawn.DamageData = weaponSettings.DamageData
    projectileToSpawn.WeaponConfig = weaponSettings.ProjectileSettings
    projectileToSpawn.Instigator = this

    if ( projectileToSpawn is AProjectileFlak )
    {
        if ( projectileToSpawn.ShouldFragment )
        {
            float ballisticLengthFlat = Distance2d( projLaunchXform.Translation.ToVector(), impactLoc )
            if ( ballisticLengthFlat > projectileToSpawn.DistanceFromTargetToFragment + 100.0 )
            {
                float lifespanDistance = ballisticLengthFlat - projectileToSpawn.DistanceFromTargetToFragment
                float lifespan = lifespanDistance / ProjectileSpeed
                projectileToSpawn.SetExplosionTime( lifespan )
            }
        }
    }

    AActor? projectile = FinishSpawningActor( projectileToSpawn, projLaunchXform )
    assert( projectile is AProjectile )
    LastBallisticAttackTime = GetTime()

    thread aslongas projectile.inPlay
    {
        AEffectContainer? indicatorFxc = AEffectContainer_PlayAtLocation_Server( ImpactIndicatorFXC, impactLoc, ZeroVector, OneVector, null )
        if ( indicatorFxc )
            indicatorFxc.SetGameplayData_Float( FXC_RADIUS_PARAM, weaponSettings.ProjectileSettings.ExplosionRadius )

        cleanup
        {
            if ( indicatorFxc )
                indicatorFxc.StopEffectContainer()    
        }
        waitwhile !projectile.HasExplodedExp
    }
}


// ballistic origin is theoretically where the bullet would fire from, in order to give us our barrel angle.
// since the muzzle/barrel could be arted in some weird way in relation to the pivot, find a projected point using the muzzle forward.
vector fn ASentryTurret::GetSimulatedFiringPosition( AShooterWeapon currentWeapon )
{
    vector muzzleLoc = currentWeapon.GetMuzzleLocation()
    vector muzzleforward = currentWeapon.GetMuzzleDirection()
    vector pivotLoc = Mesh!.GetSocketLocation( SOCKET_TURRET_HEAD_PIVOT )
    vector simulatedFiringPos = UMathLibrary_ProjectPointOnToPlane( muzzleLoc, pivotLoc, muzzleforward )

    return simulatedFiringPos
}


shared struct BallisticLaunchResultsLateral
{
    bool ReturnValue
    vector LaunchVelocity
    vector ImpactLocation
    float GravityValue
}


// - https://www.forrestthewoods.com/blog/solving_ballistic_trajectories/
shared BallisticLaunchResultsLateral fn GetBallisticLaunchVector_LateralSpeed( vector launchLocation, vector targetLocation, vector targetVelocity, float lateralSpeed, float maxHeightOffset )
{
    BallisticLaunchResultsLateral results

    vector targetVelFlat = FlattenVector( targetVelocity )
    vector launchLoc2TargetFlat = FlattenVector( targetLocation - launchLocation )

    float c0 = Dot( targetVelFlat, targetVelFlat ) - sqr( lateralSpeed )
    float c1 = 2.0 * Dot( launchLoc2TargetFlat, targetVelFlat )
    float c2 = Dot( launchLoc2TargetFlat, launchLoc2TargetFlat )
    QuadraticResults quadResults = SolveQuadratic( c0, c1, c2 )

    bool isValid0 = quadResults.numSolutions > 0 && quadResults.s0 > 0
    bool isValid1 = quadResults.numSolutions > 1 && quadResults.s1 > 0

    float t // if s0 is positive, take it.
    if ( !isValid0 && !isValid1 )
    {
        return results
    }
    if ( isValid0 && isValid1 )
    {
        t = min( quadResults.s0, quadResults.s1 )
    }
    else
    {
        t = isValid0 ? quadResults.s0 : quadResults.s1
    }

    results.ImpactLocation = targetLocation + ( targetVelocity * t )
    vector dir = results.ImpactLocation - launchLocation
    vector launchVelocity = FlattenVector( dir ).normalized() * lateralSpeed

    float a = launchLocation.z
    float b = max( launchLocation.z, results.ImpactLocation.z ) + maxHeightOffset
    float c = results.ImpactLocation.z

    results.GravityValue = -4 * (a - 2*b + c) / sqr( t )
    launchVelocity.z = -(3*a - 4*b + c) / t

    results.LaunchVelocity = launchVelocity
    results.ReturnValue = true

    return results
}