I wrote Star Fortress (click here to play it) in about 3 days in Unity because I wanted to see what Zaxxon would have been like if we’d had true 3D engines back in the 80s. Since then, I’ve had a number of people ask to see the code, especially the rail controller, because they wanted to try making their own. So, naturally I thought I’d oblige. Rather than just post a git, (which I can’t really do because I used a couple paid assets I can’t redistribute) I figured I’d go over the code for each component and show how it worked. So without further ado, here it is.

The Rail Controller System

The rail controller system consists of two scripts and three game objects objects. Rail shooters like Star Fox and others don’t really allow the player to “fly” the plane – just move it up down left and right. This is no different. On the outside, we have the RailBox game object, which is an empty game object with a RailMover component attached. The RailMover component just moves the player assembly forward at a fixed speed, and handles rewinding the level when the player loses a life. Code below.


using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class RailMover : MonoBehaviour {

    public float moveSpeed = 10;
    public static bool move = true;
    public GameObject player;
    public bool isRewind;
    float rewindStart;
    public float rewindAmount = 200;
    public static RailMover instance;

	// Use this for initialization
	void Start () {
        instance = this; // I tend to make a lot of classes singletons for convenience sake.
	}
	
	// Update is called once per frame
	void Update () {
        if (isRewind) // we got killed, so rewind the level a bit
        {
            transform.Translate(-transform.forward * moveSpeed * 3 * Time.deltaTime);
            if (transform.position.z < rewindStart - rewindAmount) { isRewind = false; player.SetActive(true); RailController.instance.invulnCount = 2; } } else if (move) { transform.Translate(transform.forward * moveSpeed * (1+UITracker.level * 0.2f) * Time.deltaTime); // move forward } if (transform.position.z > 5500)
        {
            UITracker.instance.AdvanceLevel(); // This triggers the display that we beat the level and increases speed
        }
        if (transform.position.z > 6000)
        {
            SceneManager.LoadScene("Level1");
        }
	}

    public void Rewind()
    {
		// We're rewinding so hide the player
        rewindStart = transform.position.z;
        player.transform.parent.position = transform.position;
        isRewind = true;
        player.SetActive(false);
    }
}

 

Inside the rail mover object is the Pivot. It’s just an empty game object which contains the player and holds a constant rotation so it’s easy to move up, down, left and right when the player object is banking or pitching. Inside the pivot, is the player object with the RailController component attached. The RailController component controls banking and pitching of the player’s ship as well as movement of the parent pivot object. Code below.


using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class RailController : MonoBehaviour {

	// These are the bounds of the box we can move in
    public float xMax = 20;
    public float xMin = -20;
    public float yMax = 20;
    public float yMin = -20;
    public float maxRoll=23;
    public float maxPitch=15;
    public float maxYaw = 23;
    public float speed = 2;
	// This is the parent pivot object.  We could get it also by just calling transform.parent, but this works too
    public Transform pivot;
	// Our shot prefab and how fast it should go.
    public Rigidbody shot;
    public float shotspeed = 100;
	// When we explode
    public GameObject splode;
	// As I said, singletons are convenient
    public static RailController instance;
	// Fuel, lives and other incidentals
    public float maxFuel = 60;
    float fuel;
    public static float lives = 3;
    public float shotInterval = 0.2f;
    public float shotCount =0;
    public float invulnCount = 2;

    private void Awake()
    {
        instance = this;
    }

    // Use this for initialization
    void Start () {
        instance = this;
        fuel = maxFuel;
	}
	
	// Update is called once per frame
	void Update () {
        float rollInput = 0;
        float pitchInput = 0;
        float xVel = 0;
        float yVel = 0;
        shotCount += Time.deltaTime;
		
		// Get joystick / mouse input
        rollInput = -Input.GetAxis("Horizontal");
        pitchInput = Input.GetAxis("Vertical");

        if (invulnCount > 0)
            invulnCount -= Time.deltaTime;
			
		// Getting keyboard input is probably unnecessary as it's covered by GetAxis above, but I wrote this first so I left it in.
        if (Input.GetKey(KeyCode.LeftArrow))
        {
            rollInput = 1;
        }
        else if (Input.GetKey(KeyCode.RightArrow))
        {
            rollInput = -1;
        }

        if (Input.GetKey(KeyCode.DownArrow))
        {
            pitchInput = -1;
        }
        else if (Input.GetKey(KeyCode.UpArrow))
        {
            pitchInput = 1;
        }

		// Pitch up or down unless we're at the bounds of box
        if (pivot.position.y <= yMin) { pitchInput = Mathf.Min(pitchInput, 0); } else if (pivot.position.y >= yMax)
        {
            pitchInput = Mathf.Max(pitchInput, 0);
        }

		// Bank left or right unless at bounds of box
        if (pivot.position.x <= xMin) { rollInput = Mathf.Min(rollInput, 0); } else if (pivot.position.x >= xMax)
        {
            rollInput = Mathf.Max(rollInput, 0);
        }

		// Calculate the rotation we're aiming for
        Quaternion targetRotation = Quaternion.Euler(pitchInput * maxPitch, -rollInput * maxYaw, rollInput * maxRoll);

		// Rotate towards it
        transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, 2);

		// Now calculate how fast the pivot should be moving based on how we're oriented
        Vector3 angles = transform.rotation.eulerAngles;

        if (angles.x > maxPitch+1)
        {
            yVel = ((360 - angles.x) / maxPitch) * speed * (1+UITracker.level * 0.2f) * Time.deltaTime;
        }
        else if (angles.x > 0)
        {
            yVel = -(angles.x / maxPitch) * speed * (1 + UITracker.level * 0.2f) * Time.deltaTime;
        }

        if (angles.z > maxRoll+1)
        {
            xVel = ((360 - angles.z) / maxRoll) * speed * (1 + UITracker.level * 0.2f) * Time.deltaTime;
        }
        else if (angles.z > 0)
        {
            xVel = -(angles.z / maxRoll) * speed * (1 + UITracker.level * 0.2f) * Time.deltaTime;
        }

        pivot.Translate(new Vector3(xVel, yVel, 0));

		// Fire
        if ((Input.GetKeyDown(KeyCode.Space) || Input.GetButtonDown("Fire1")) && shotCount > shotInterval)
        {
            shotCount = 0;
            var s = Instantiate(shot, transform.position + transform.forward * 2, Quaternion.identity);
            s.velocity = transform.forward * shotspeed * (1 + UITracker.level * 0.2f);
            Destroy(s.gameObject, 5);
        }

		// Use fuel and explode if we're out
		
        fuel -= Time.deltaTime;

        UITracker.instance.SetFuel(fuel / maxFuel);
        if (fuel <= 0) { OnCollisionEnter(null); } } private void OnCollisionEnter(Collision collision) { if (RailMover.instance.isRewind) return; if (invulnCount > 0)
            return;
        // RailMover.move = false;
        fuel = maxFuel;
        UITracker.instance.SetFuel(fuel / maxFuel);
        lives--;
        UITracker.instance.lives.text = lives.ToString("0");
        var phys = GetComponent();
        phys.velocity = Vector3.zero;
        phys.angularVelocity = Vector3.zero;
        if (lives <= 0) { var exp = Instantiate(splode, transform.position, Quaternion.identity); Destroy(exp, 3); UITracker.instance.GameOver(); Destroy(gameObject); } else { var exp = Instantiate(splode, transform.position, Quaternion.identity); Destroy(exp, 3); RailMover.instance.Rewind(); } } public void AddFuel(float amt) { fuel += amt; if (fuel > maxFuel)
        {
            fuel = maxFuel;
        }
        UITracker.instance.SetFuel(fuel / maxFuel);
    }
}

 

The Target Script

This is a simple script that attaches to each object you can shoot in the game. It handles collisions, explosions, score and fuel bonuses, and can spawn wreckage of an object when it’s destroyed.


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class TargetScript : MonoBehaviour {

    public GameObject splosion;
    public float health;
    public float splodedelay = 0.5f;
    bool didSplode = false;
    public GameObject wreck;
    public float pointval;
    public float fuelval;
    AudioSource sound;

	// Use this for initialization
	void Start () {
        sound = GetComponent();
        if (sound != null)
            sound.Stop();
	}
	
	// Update is called once per frame
	void Update () {
		
	}

    public virtual void TakeDamage(float dmg)
    {
        health -= dmg;
        if (health <= 0 && !didSplode)
        {
            didSplode = true;
            //GetComponent().enabled = false;
            Splode();
        }
    }

    public virtual void Splode()
    {
        if (sound != null)
            sound.Play();
        UITracker.instance.Score += pointval;
        RailController.instance.AddFuel(fuelval);
        var exp = Instantiate(splosion, transform.position, Quaternion.identity);
        Destroy(exp, 5);
        if (wreck != null)
            Instantiate(wreck, transform.position, Quaternion.identity);
        Destroy(gameObject,splodedelay);
    }
}

 

Missiles

This is a simple script for homing missiles. They go straight up and then target the player. They will not go below a set altitude.


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MissileScript : MonoBehaviour {

    public float controlHeight = -9;
    public float speed = 50;
    public float sensitivity = 70;
    public float minY = 20;

    Rigidbody phys;
    Transform target;

	// Use this for initialization
	void Start () {
        phys = GetComponent();
        phys.velocity = transform.forward * speed;
        target = RailController.instance.transform;
	}
	
	// Update is called once per frame
	void Update () {
		if (transform.position.y > controlHeight)
        {
            var vec2tar = new Vector3(target.position.x,Mathf.Max(target.position.y,minY),target.position.z) - transform.position;
            var rot2tar = Quaternion.LookRotation(vec2tar);
            transform.rotation = Quaternion.RotateTowards(transform.rotation, rot2tar, sensitivity * Time.deltaTime);
        }
        phys.velocity = transform.forward * speed;
        if (transform.position.z < target.position.z)
        {
            GetComponent().Splode();
        }
	}

    private void OnCollisionEnter(Collision collision)
    {
        if (collision.transform == target)  
            GetComponent().Splode();
    }
}

 

And this is the code for the silos that launch them…


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SiloScript : MonoBehaviour {

    Transform target;
    public Transform missile;
    bool didFire = false;
    public float firedist = 200;
    public float minTargetHeight = 20;
    public GameObject flash;

	// Use this for initialization
	void Start () {
        target = RailController.instance.transform;
	}
	
	// Update is called once per frame
	void Update () {
		if (!didFire && Vector3.Distance(transform.position,target.position) < firedist)
        {
            didFire = true;
            var f = Instantiate(flash, transform.position, Quaternion.identity);
            Destroy(f, 5);
            var m = Instantiate(missile, transform.position + Vector3.down * 5, Quaternion.Euler(new Vector3(-90, 90, 0)));
            m.GetComponent().minY = minTargetHeight;
        }
	}
}

 

The Turrets

The turrets use a utility script that’s available on the Unity wiki to calculate first order intercepts. The code for the object and utility code are below.


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class TurretScript : MonoBehaviour {

    Transform target;
    public float maxTargetY = 5;
    public GameObject shot;
    public Transform turretBase;
    public Transform turretGun;
    public float maxDist = 400;
    public float fireInterval = 5;
    float fireCount=0;
    Vector3 targetLastPos = Vector3.zero;
    public float shotSpeed = 100;
    public Transform shooter;

	// Use this for initialization
	void Start () {
        target = RailController.instance.transform;
	}
	
	// Update is called once per frame
	void Update () {
        fireCount += Time.deltaTime;

		if (target.position.y < maxTargetY && Vector3.Distance(transform.position,target.position) < maxDist) { // The turret and base rotate independently, so we can't just look at the target turretBase.rotation = Quaternion.LookRotation(new Vector3(target.position.x, transform.position.y, target.position.z) - transform.position); turretGun.localRotation = Quaternion.Euler(Mathf.Atan2((target.position.y - transform.position.y), Vector3.Distance(transform.position, target.position))-90,0,0); if (fireCount > fireInterval)
            {
                fireCount = 0;
                Fire();
            }
        }
        targetLastPos = target.position;
	}

    void Fire()
    {
        var aimpos = TurretUtils.FirstOrderIntercept(turretGun.position - turretGun.right, Vector3.zero, shotSpeed,
                    target.position, (target.position - targetLastPos) / Time.deltaTime);
        shooter.rotation = Quaternion.LookRotation(aimpos - shooter.position, shooter.up);
        var s = Instantiate(shot, shooter.position, shooter.rotation);
        var p = s.GetComponent();
        // var d2t = Vector3.Distance(target.position, turretGun.position);
        Destroy(s, 5);
        p.velocity = shooter.forward * shotSpeed; 
    }

}

 


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class TurretUtils : MonoBehaviour {

	// Use this for initialization
	void Start () {
		
	}
	
	// Update is called once per frame
	void Update () {
		
	}

    //first-order intercept using absolute target position
    public static Vector3 FirstOrderIntercept
    (
        Vector3 shooterPosition,
        Vector3 shooterVelocity,
        float shotSpeed,
        Vector3 targetPosition,
        Vector3 targetVelocity
    )
    {
        Vector3 targetRelativePosition = targetPosition - shooterPosition;
        Vector3 targetRelativeVelocity = targetVelocity - shooterVelocity;
        float t = FirstOrderInterceptTime
        (
            shotSpeed,
            targetRelativePosition,
            targetRelativeVelocity
        );
        return targetPosition + t * (targetRelativeVelocity);
    }

    //first-order intercept using relative target position
    public static float FirstOrderInterceptTime
    (
        float shotSpeed,
        Vector3 targetRelativePosition,
        Vector3 targetRelativeVelocity
    )
    {
        float velocitySquared = targetRelativeVelocity.sqrMagnitude;
        if (velocitySquared < 0.001f)
            return 0f;

        float a = velocitySquared - shotSpeed * shotSpeed;

        //handle similar velocities
        if (Mathf.Abs(a) < 0.001f) { float t = -targetRelativePosition.sqrMagnitude / ( 2f * Vector3.Dot ( targetRelativeVelocity, targetRelativePosition ) ); return Mathf.Max(t, 0f); //don't shoot back in time } float b = 2f * Vector3.Dot(targetRelativeVelocity, targetRelativePosition); float c = targetRelativePosition.sqrMagnitude; float determinant = b * b - 4f * a * c; if (determinant > 0f)
        { //determinant > 0; two intercept paths (most common)
            float t1 = (-b + Mathf.Sqrt(determinant)) / (2f * a),
                    t2 = (-b - Mathf.Sqrt(determinant)) / (2f * a);
            if (t1 > 0f)
            {
                if (t2 > 0f)
                    return Mathf.Min(t1, t2); //both are positive
                else
                    return t1; //only t1 is positive
            }
            else
                return Mathf.Max(t2, 0f); //don't shoot back in time
        }
        else if (determinant < 0f) //determinant < 0; no intercept path
            return 0f;
        else //determinant = 0; one intercept path, pretty much never happens
            return Mathf.Max(-b / (2f * a), 0f); //don't shoot back in time
    }
}

 

HUDTracker

This is the last class I’m going over as the rest are somewhat trivial, but this is the class that lets the HUD objects track game objects to create the sight for your plane. It attaches to the canvas object which will track a game object.


using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class HUDTracker : MonoBehaviour {

    public Transform target;
    public Canvas theCanvas;
    public bool checkPlanes = false;
    public Vector2 offset;
    RectTransform rxfm;
    Image img;

    // Use this for initialization
    void Start()
    {
        rxfm = GetComponent();
        img = GetComponent();
    }

    // Update is called once per frame
    void Update()
    {
        if (transform == null || theCanvas == null || target == null)
        {
            Destroy(gameObject);
            return;
        }

        RectTransform cRectT = theCanvas.GetComponent();

        var screenPos = Camera.main.WorldToViewportPoint(target.transform.position);

        rxfm.anchoredPosition = new Vector2((screenPos.x * cRectT.sizeDelta.x) - (cRectT.sizeDelta.x * 0.5f) + offset.x,
                    (screenPos.y * cRectT.sizeDelta.y) - (cRectT.sizeDelta.y * 0.5f) + offset.y);

    }

    public static Vector3 ProjectPointOnPlane(Vector3 planeNormal, Vector3 planePoint, Vector3 point)
    {
        planeNormal.Normalize();
        var distance = -Vector3.Dot(planeNormal.normalized, (point - planePoint));
        return point + planeNormal * distance;
    }

    public static float SignedAngle(Vector3 v1, Vector3 v2, Vector3 normal)
    {
        var perp = Vector3.Cross(normal, v1);
        var angle = Vector3.Angle(v1, v2);
        angle *= Mathf.Sign(Vector3.Dot(perp, v2));
        return angle;
    }

}

 

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.
You need to agree with the terms to proceed