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;
}
}