Creating a Flight Simulator in Unity3D Part 3: Weapons and AI

Now that we have the flight mechanics and HUD, we can start working on the weapons and AI. The AI will have all of the same capabilities and limitations as the player. It’s plane will have identical stats and it will use the same weapons.

The AI will be simple, but still capable of shooting you down. It will have logic for aiming and using it’s weapons optimally, for avoiding the ground, and for maintaining a reasonable amount of energy.

Github at tag part-3

Weapons

The planes in this demo will have two weapons: cannons and missiles.

Cannons and missiles have one thing in common: since they are physical objects traveling at finite speed, they must lead their targets. For cannons, the pilot must provide the lead by aiming his nose ahead of the target. Missiles calculate the lead themselves and use that for guidance.

In both cases, we use this code from wiki.unity3d.com to calculate lead or interception. This code calculates the lead needed to hit a moving target, given the speed of the projectile and the speed of the platform that launches it.

Cannons

The cannon is simple. The plane spawns bullet which travels forward and damages the enemy plane. Bullets are small and fast, so they could potentially “tunnel” through an object. That is, they would pass right through without detecting a collision. To avoid this issue, we don’t use physics colliders at all. Instead, all collision detection is handled using raycasts.

Given the position and velocity of the two planes and the velocity of the bullet, we can calculate the lead needed to hit the target. This lead is displayed to players as a reticle on the HUD. The AI will aim at this point when it uses the cannon.

Missile Lock

For a missile to track it’s target, the plane has to lock on to the target first.

//default neutral position is forward
Vector3 targetDir = Vector3.forward;
MissileTracking = false;

if (Target != null && !Target.Plane.Dead) {
    var error = target.Position - Rigidbody.position;
    var errorDir = Quaternion.Inverse(Rigidbody.rotation) * error.normalized; //transform into local space

    if (error.magnitude <= lockRange && Vector3.Angle(Vector3.forward, errorDir) <= lockAngle) {
        MissileTracking = true;
        targetDir = errorDir;
    }
}

This code determines whether the target plane can be locked on to, based on range and angle. If the target is too far away or too far off boresight, the missile can’t lock on and targetDir is set to the default forward position. If it can lock on, then targetDir is set to the direction of the target.

//missile lock either rotates towards the target, or towards the neutral position
missileLockDirection = Vector3.RotateTowards(missileLockDirection, targetDir, Mathf.Deg2Rad * lockSpeed * dt, 0);

Then the missile lock direction is slowly walked towards the target.

The missile lock (the circle) moving towards the target (the box)

Missiles

Once the missile has a lock, it doesn’t need to be precisely aimed since it has it’s own guidance. It will calculate the lead needed to reach the target and steer towards that. Missiles deal damage with an explosion, so a direct hit is not needed. As long as the missile explodes close enough to the target, it will deal damage.

Real missiles have energy just like the planes that launch them. They need a minimum air speed to maneuver and lose energy from maneuvering and from drag. Real missiles only contain a few seconds worth of fuel and will glide towards the target once that runs out.

However, to keep things simple, we won’t design our missiles to work like that. Missiles won’t have to worry about energy or drag. They will always fly at a fixed speed. When the missile runs out of fuel, it simply explodes.

var targetPosition = Utilities.FirstOrderIntercept(Rigidbody.position, Vector3.zero, speed, target.Position, target.Velocity);

The missile calculates the lead needed to intercept the target. Note that we report the missile’s velocity as Vector3.zero since the missile’s speed is passed as the projectile speed argument. Since the missile is the projectile, we only pass it’s speed to the function once. Otherwise, it would calculate the trajectory for a projectile launched at X m/s from a platform moving X m/s, giving an effective velocity of 2X.

A projectile going twice as fast would need less lead, so the lead calculation would return a serious underestimate of the lead needed. That causes the missile to act a “tail chaser”. It goes to where the target is, instead of where the target will be. So we set the velocity to Vector3.zero to avoid that.

var error = targetPosition - Rigidbody.position;
var targetDir = error.normalized;
var currentDir = Rigidbody.rotation * Vector3.forward;

//if angle to target is too large, explode
if (Vector3.Angle(currentDir, targetDir) > trackingAngle) {
    Explode();
    return;
}

The missile knows where it is at all times. By subtracting where it is from where it isn’t, the targetPosition, it obtains the targetDir. This is the direction it needs to face in order to intercept the target.

currentDir is the direction the missile is currently facing. If the angle between currentDir and targetDir is too large, the missile will explode. This can occur if the target plane successfully dodges the missile, in which case it explodes a harmless distance away. However, if the missile successfully intercepts the target and passes it, the angle will quickly approach 180 degrees, causing the missile to explode right next to the target.

If the angle is less than the threshold though, the missile will continue steering it self.

//calculate turning rate from G Force and speed
float maxTurnRate = (turningGForce * 9.81f) / speed;  //radians / s
var dir = Vector3.RotateTowards(currentDir, targetDir, maxTurnRate * dt, 0);

Rigidbody.rotation = Quaternion.LookRotation(dir);

Instead of defining turn rate in degrees per second, we can define it in Gs induced during the turn. So we can design the missile to have an 8G turn, for example.

Rigidbody.velocity = Rigidbody.rotation * new Vector3(0, 0, speed);

And finally, we set the speed in the direction the missile is traveling. This means the missile will always move at the same speed. It doesn’t lose energy from turning or from drag.

HUD

A few elements need to be added to the HUD, to allow the player to use the weapons. These are the missile lock on indicator, the cannon reticle, and the incoming missile arrow.

The target box and lock on indicator are trivial.

    //update target box, missile lock
    var targetDistance = Vector3.Distance(plane.Rigidbody.position, plane.Target.Position);
    var targetPos = TransformToHUDSpace(plane.Target.Position);
    var missileLockPos = plane.MissileLocked ? targetPos : TransformToHUDSpace(plane.Rigidbody.position + plane.MissileLockDirection * targetDistance);

    if (targetPos.z > 0) {
        targetBoxGO.SetActive(true);
        targetBox.localPosition = new Vector3(targetPos.x, targetPos.y, 0);
    } else {
        targetBoxGO.SetActive(false);
    }

    if (plane.MissileTracking && missileLockPos.z > 0) {
        missileLockGO.SetActive(true);
        missileLock.localPosition = new Vector3(missileLockPos.x, missileLockPos.y, 0);
    } else {
        missileLockGO.SetActive(false);
    }

The box is placed directly over the target. The circle shows where the missile lock direction is currently pointing.

The reticle is an image displayed at the calculated lead position, and a thin line connecting the reticle to the target.

    //update target lead
    var leadPos = Utilities.FirstOrderIntercept(plane.Rigidbody.position, plane.Rigidbody.velocity, bulletSpeed, plane.Target.Position, plane.Target.Velocity);
    var reticlePos = TransformToHUDSpace(leadPos);

    if (reticlePos.z > 0 && targetDistance <= cannonRange) {
        //update reticle
        reticleGO.SetActive(true);
        reticle.localPosition = new Vector3(reticlePos.x, reticlePos.y, 0);
    } else {
        reticleGO.SetActive(false);
    }

The reticle line just calculates the angle in HUD space from the target to the reticle.

//update reticle line
var reticlePos2 = new Vector2(reticlePos.x, reticlePos.y);
if (Mathf.Sign(targetPos.z) != Mathf.Sign(reticlePos.z)) reticlePos2 = -reticlePos2;    //negate position if reticle and target are on opposite sides
var targetPos2 = new Vector2(targetPos.x, targetPos.y);
var reticleError = reticlePos2 - targetPos2;

var lineAngle = Vector2.SignedAngle(Vector3.up, reticleError);
reticleLine.localEulerAngles = new Vector3(0, 0, lineAngle + 180f);
reticleLine.sizeDelta = new Vector2(reticleLine.sizeDelta.x, reticleError.magnitude);

The HUD also includes arrows that show the direction of the target plane or incoming missiles when off screen.

The target plane and an incoming missile are somewhere above me

The rotation of the these arrows are calculated like the reticle line: find the position in HUD space and calculate the angle.

//update target arrow
var targetDir = (plane.Target.Position - plane.Rigidbody.position).normalized;
var targetAngle = Vector3.Angle(cameraTransform.forward, targetDir);

if (targetAngle > targetArrowThreshold) {
    targetArrowGO.SetActive(true);
    //add 180 degrees if target is behind camera
    float flip = targetPos.z > 0 ? 0 : 180;
    targetArrow.localEulerAngles = new Vector3(0, 0, flip + Vector2.SignedAngle(Vector2.up, new Vector2(targetPos.x, targetPos.y)));
} else {
    targetArrowGO.SetActive(false);
}

There is one caveat though. If the target (or missile) is behind the player, then we need to add 180 degrees the the angle. This is because we calculate the angle based on targetPos, which is the position of the target in the player’s camera.

The view frustum of a camera is commonly shown as a truncated pyramid. This pyramid is shows how the camera’s projection matrix warps world space. Any position in world space that falls inside the frustum can be seen on screen. (Specifically, it will be mapped to clip space such that -1 <= X <= 1; -1 <= Y <= 1; 0 <= Z <= 1) However, the projection matrix can be applied to any vector, not just those within in the view frustum.

That includes positions just outside the frustum, which is how we know where the arrow should point. It also includes positions behind the camera. The view frustum can be thought to continue behind the camera as a second truncated pyramid.

Artist’s depiction

Positions that fall in this second frustum will be mapped into clip space except the X, Y, and Z axes will be negated. So if an object starts at (.4, .5, .6), up and to the right, when in front of the camera, and travels directly behind the camera, it would end with a position of (-.4, -.5, -.6) in clip space, while still up and to the right in world space.

To account for this, we need to rotate angles by 180 degrees when the target or missile is behind the camera.

AI

The AI is a simple finite state machine. There isn’t anything more complicated like action planning or pathfinding. The AI will try to avoid getting hit, dodge missiles, and use it’s weapons when possible.

There is one AI plane flying around for the player to fight. The player can also enable the AI on their own plane, to see an AI vs AI fight.

Steering

The AI’s main goal is to point it’s nose at the target. This one behavior is what drives most of the feeling of a dogfight. When the player is behind the AI, the AI is constantly turning to get behind the player, which has the side effect of dodging the player’s attacks.

Determining which direction to turn is fairly simple. The plane is most maneuverable when pitching up, so the AI tries to align itself by rolling so that the target can be reached by pitching up.

The steering function starts by selecting the position the AI is aiming for. This is either the target plane, or if in cannon range, the computed lead to hit the target with cannon fire. This position is transformed into the plane’s local space.

var error = targetPosition - plane.Rigidbody.position;
error = Quaternion.Inverse(plane.Rigidbody.rotation) * error;   //transform into local space

The position of the two planes in world space doesn’t matter. Steering is calculated based on the position of the target plane in local space.

var pitchError = new Vector3(0, error.y, error.z).normalized;
var pitch = Vector3.SignedAngle(Vector3.forward, pitchError, Vector3.right);
if (-pitch < pitchUpThreshold) pitch += 360f;
targetInput.x = pitch;

The error value is flattened onto the YZ plane to get pitchError. This is passed into Vector3.SignedAngle to get the angle needed to pitch up or down to point at the target. If the target is directly in front, the angle is 0, so the plane receives a pitch input of 0.

If that angle is a large pitch down, it gets converted into a pitch up maneuver. For example, if pitchUpThreshold is 15 degrees, then when the target plane is more than 15 degrees below AI plane, the AI will always try to pitch up. If the angle is less than the threshold, it will pitch up or down normally. This avoids situations where the plane would try to pitch down by a large amount, which would result in a slow maneuver.

var rollError = new Vector3(error.x, error.y, 0).normalized;

if (Vector3.Angle(Vector3.forward, errorDir) < fineSteeringAngle) {
    targetInput.y = error.x;
} else {
    var roll = Vector3.SignedAngle(Vector3.up, rollError, Vector3.forward);
    targetInput.z = roll * rollFactor;
}

rollError is calculated from the error on the XY plane. However this rollError is used to calculate either the roll input or the yaw input. If the AI plane is pointing within a small angle, fineSteeringAngle of the target, then it will aim using the rudders for finer control. This gives the AI the ability to aim it’s cannon.

If the AI is more than fineSteeringAngle, the roll error will cause the plane to roll, to allow a large pitch up maneuver. The roll error is 0 when the target is directly overhead.

targetInput.x = Mathf.Clamp(targetInput.x, -1, 1);
targetInput.y = Mathf.Clamp(targetInput.y, -1, 1);
targetInput.z = Mathf.Clamp(targetInput.z, -1, 1);

var input = Vector3.MoveTowards(lastInput, targetInput, steeringSpeed * dt);
lastInput = input;

Finally, the targetInput is clamped on each axis to prevent something like an input of (360, 0, 0). We use Vector3.MoveTowards to add a slight delay to the inputs generated by the steering function.

Ground Avoidance

The steering logic above allows the AI to steer towards a target, but we need special logic to avoid crashing. Right now, the AI will happily fly into the ground while chasing it’s target.

The AI avoids the ground by casting a ray 10 degrees below where it’s nose is pointing. If that ray hits terrain, a ground avoidance routine will take over and steer the plane away from the ground.

The plane will roll level, pull up as hard as possible, and reduce speed if necessary until the ground detection raycast no longer hits an obstacle.

Vector3 AvoidGround() {
    //roll level and pull up
    var roll = plane.Rigidbody.rotation.eulerAngles.z;
    if (roll > 180f) roll -= 360f;
    return new Vector3(-1, 0, Mathf.Clamp(-roll * rollFactor, -1, 1));
}

This function returns the steering input needed to avoid the ground. rollFactor is a small number like 0.01, to reduce oscillation when rolling. The steering vector is set to -1 on the X axis, a maximum pitch up.

Energy Management

While dogfighting, the AI plane will eventually use up it’s energy and have no speed left to maneuver. When this happens, the AI must recover energy somehow. A human pilot might change their maneuver to take advantage of a gravity assist and gain energy, however I don’t know how to write that ????‍♀️.

Instead, the AI will roll level and point the nose at the horizon, and fly straight until it regains speed.

Vector3 RecoverSpeed() {
    //roll and pitch level
    var roll = plane.Rigidbody.rotation.eulerAngles.z;
    var pitch = plane.Rigidbody.rotation.eulerAngles.x;
    if (roll > 180f) roll -= 360f;
    if (pitch > 180f) pitch -= 360f;
    return new Vector3(Mathf.Clamp(-pitch, -1, 1), 0, Mathf.Clamp(-roll * rollFactor, -1, 1));
}

The AI will start this maneuver when it’s speed drops too low, and continue until the speed rises enough. This leaves the AI vulnerable, allowing the player to get into a better position.

Weapons

To use the cannon, the AI must get within cannon range and point the nose of the plane at the calculated lead point. If it isn’t in range, then it will simply point it’s nose at the target, without trying to fire.

Vector3 GetTargetPosition() {
    var targetPosition = plane.Target.Position;

    if (Vector3.Distance(targetPosition, plane.Rigidbody.position) < cannonRange) {
        return Utilities.FirstOrderIntercept(plane.Rigidbody.position, plane.Rigidbody.velocity, bulletSpeed, targetPosition, plane.Target.Velocity);
    }

    return targetPosition;
}

Once in range, the AI then waits until the nose is within a small threshold of angular error, before firing the cannon.

var targetPosition = Utilities.FirstOrderIntercept(plane.Rigidbody.position, plane.Rigidbody.velocity, bulletSpeed, plane.Target.Position, plane.Target.Velocity);

var error = targetPosition - plane.Rigidbody.position;
var range = error.magnitude;
var targetDir = error.normalized;
var targetAngle = Vector3.Angle(targetDir, plane.Rigidbody.rotation * Vector3.forward);

if (range < cannonRange && targetAngle < cannonMaxFireAngle && cannonCooldownTimer == 0) {
    //fire cannon
}

Firing missiles has a range check as well, but there’s no need to lead the target plane, since the missile does that automatically.

var error = plane.Target.Position - plane.Rigidbody.position;
var range = error.magnitude;
var targetDir = error.normalized;
var targetAngle = Vector3.Angle(targetDir, plane.Rigidbody.rotation * Vector3.forward);

if (!plane.MissileLocked || !(targetAngle < missileMaxFireAngle || (180f - targetAngle) < missileMaxFireAngle)) {
    //don't fire if not locked or target is too off angle
    //can fire if angle is close to 0 (chasing) or 180 (head on)
    missileDelayTimer = missileLockFiringDelay;
}

if (range < missileMaxRange && range > missileMinRange && missileDelayTimer == 0 && missileCooldownTimer == 0) {
    //fire missile
}

Dodging Missiles

The AI not only be able to use it’s weapons, but must also dodge incoming attacks. The steering behavior of the AI is the only logic for dodging cannon fire. The AI is constantly turning to get behind the player, which means it is more or less dodging cannon fire at all times.

While there is no special logic for dodging bullets, there is a separate code path for dodging missiles. Missiles fired at long range have plenty of time to correct their course and intercept their target. So the AI can’t simply rely on turning towards the enemy like it does with bullets.

There’s probably a lot of different ways that the AI could dodge missiles. The system I chose is simple: calculate four “dodge points” above, below, left, and right of the missile. The distance from the missile to the dodge points is the same as the distance from the AI to the missile. Select the dodge point closest to the plane and steer towards it.

Since there are multiple dodge points, there is also a timer to prevent the plane from choosing new dodge points too often. This avoids an issue where the plane might rapidly switch between two opposing dodge points, causing the plane to fly in a straight line.

Then the dodges points are simply fed into the above steering function, instead of the target plane’s position.

The system isn’t perfect, but it can dodge missiles reasonably well.

Conclusion

All of these systems combine to create an AI that can be surprisingly challenging to fight. The AI will try to line up a shot with it’s guns and missiles. It will avoid hitting the ground and will try to dodge your attacks. It will try to maintain energy, but this leaves it vulnerable to attack.

The AI will carry out these functions in a priority order. The priorities from highest to lowest are:

  • Ground avoidance
  • Missile dodging
  • Energy management
  • Steering (dogfighting)

You can overcome the AI by out turning it, by draining it’s energy and forcing it to recover, or by causing it to fly into the ground. The AI can do the same to you, so I hope your ground avoidance and energy recovery routines are better than the ones I wrote.