A physics based flight simulator and first person explorer mini game
Six weeks
Unity Engine, Blender, Krita, Unity Add-Ons (Probuilder, Gaia, Unity Shader Graph).
As a game physics enthusiast, I wanted to dive deep into Unity’s physics features. People seemed to enjoy the flight simulator the most when I tested out my ideas. I designed the world from a flying perspective instead of the other way around. The game evolved into a flight simulator-first person explorer. This project explores physics-based scripting in Unity, crafting modular kits and designing with Probuilder.
Level design
I created an open world game with short stories to complete at your own pace. when I designed this game i had my older brothers in mind. They love games but can only play for a couple of minutes at a time.
To enjoy the game, I wanted just flying around to be enough. Whenever you fly around, you can discover clues and explore the world. Among many indoor exploration elements, this facility is one. The interiors were designed to reward players who took their time to explore. Providing hints about the goal and then presenting the solutions.
The Mass Effect trilogy inspired me a lot, in particular the part when you get to scan planets for materials. The interior on this planet sets inside an abandoned space station, where the first mission is to activate the energy base. Light is a rare resource, and more rare items are placed in the darkest corners to encourage replayability. A player that upgrades their gear to cast light means they get more material. The interior balances open and narrow spaces to give the player a well paced experience navigating the station and rewarding curiosity.
The design should indicate what needs to be changed. Vegetation makes landing impossible!. Penalties should provide insight. Creating a game world according to what I want the player to experience is my job as a level designer. Experience matters more than logic. Realistic is not necessary, but it should appear reasonable in the world it builds. They’d feel almost successful even though they’d been pretty far off. It’s all about faking it sometimes!
Here are some sketches to illustrate some interactive game mechanics and interior.
Asset creation in Blender
I’ve experimented with Blender for a couple of years now, and when I got accepted at TGA I decided to try and create all the 3D modeling myself. I like how it gives me total control over what I can create, and I don’t have to depend on somebody else’s design.
Blender has been my go-to program for a couple of years now, and when I got accepted to TGA I decided to create all the 3D modeling myself. I like how it makes it possible to show not tell to my team about new Level Design ideas
Scripting
A big part of the game’s fun is the flight controller. My goal was to make flying around and landing enjoyable for novice players. The spaceship is controlled like a helicopter, so whenever the ship leans toward a direction, it will follow that direction. Players can land and exit the ship to explore gated areas. I spent a lot of time tweaking variables for the movement controller to make it feel engaging. Next step is to add enemy encounters and environmental hazards to this project.
HandleLift function
The first function I wrote was to make the spaceship lift, it felt like a good start. I designed the terrain after testing different heights.
protected virtual void HandleLift(Rigidbody rb, KeyboardInput input)
{
// handles the vertical movement of the ship, make sure that the spaceship can have "room to move"
RaycastHit groundHit;
Ray groundRay = new Ray(transform.position, Vector3.down);
if (Physics.SphereCast(groundRay, .1f, out groundHit, Mathf.Infinity, enviormentMask))
{
currentWantedPlayerHeight = groundHit.distance;
currentUsablePlayerHeight = Mathf.Lerp(currentUsablePlayerHeight ,currentWantedPlayerHeight , Time.deltaTime);
RaycastHit playerExitShipHit;
Ray playerExitShipRay = new Ray(transform.position, Vector3.down);
if (Physics.SphereCast(playerExitShipRay, .1f, out playerExitShipHit, Mathf.Infinity))
if (playerExitShipHit.distance < minGroundDistanceForDeloadPlayer)
{
playerPoint = playerExitShipHit.point + transform.up * 6f;
if (Input.GetKey(KeyCode.C))
FPSCam.SetActive(true);
FPSCam.gameObject.transform.position = playerPoint;
if (!canSpawn)
canSpawn = true;
}else canSpawn =false;
RaycastHit forwardHit;
Ray forwardRay = new Ray(groundHit.point, -flatFwd);
if (Physics.Raycast(forwardRay, out forwardHit, maxDiffDistance, enviormentMask))
{
distanceToEnv = forwardHit.distance;
Ray upRay = new Ray(forwardHit.point, Vector3.up);
RaycastHit upHit;
if (Physics.SphereCast(upRay, .5f, out upHit, Mathf.Infinity, topMask))
{
currentEnvAimHeight = upHit.distance + minConsistantHeight;
Ray topRay = new Ray(upHit.point, Vector3.up);
RaycastHit topHit;
if (Physics.SphereCast(topRay, rayRadius, out topHit, Mathf.Infinity, topMask))
{
top = topHit.distance;
currentEnvAimHeight += top / 2;
}
heightModifier = currentEnvAimHeight - currentUsablePlayerHeight + minConsistantHeight;
dir = heightModifier / Mathf.Abs(heightModifier);
powerMuliplier = heightModifier / maxDiffHeight;
}
else
{
heightModifier = maxDiffHeight - currentUsablePlayerHeight + minConsistantHeight;
dir = heightModifier / Mathf.Abs(heightModifier);
powerMuliplier = heightModifier / maxDiffHeight;
}
}
else
{
heightModifier = maxDiffHeight - currentUsablePlayerHeight + minConsistantHeight;
dir = heightModifier / Mathf.Abs(heightModifier);
powerMuliplier = heightModifier / maxDiffHeight;
}
}
power = Mathf.Pow(powerMuliplier, pow) * maxLiftForce * dir;
power = Mathf.Clamp(power, 0, maxLiftForce);
Debug.Log(power);
Vector3 liftForce = (transform.up * (Physics.gravity.magnitude + power) * rb.mass);
rb.AddForce(liftForce * input.StickeyCollectiveInput, ForceMode.Force);
}
CalculateAngles function
I could not find the direction I needed to push the spacecraft in based on the world location since it ignored forward and backward motion. Local angle was straight down. As a result, I got a flat angle.
private void CalculateAngles()
{
// Calculate the flat angle in world space
flatRight = transform.right;
flatRight.y = 0f;
flatRight = flatRight.normalized;
Debug.DrawRay(transform.position, flatRight, Color.red);
flatFwd = transform.forward;
flatFwd.y = 0f;
flatFwd = flatFwd.normalized;
Debug.DrawRay(transform.position, flatFwd, Color.blue);
forwardDot = Vector3.Dot(transform.up, flatFwd);
rightDot = Vector3.Dot(transform.up, flatRight);
}
HandleCyclic function
This function handles spaceship force. If anything blocks the path, ship power is reduced. It acts like an automatic break, making maneuvering easier for novice players
protected virtual void HandleCyclic(Rigidbody rb, KeyboardInput input)
{
// Handle the amount of stability the player receives, decrease stability when any input is received to give more control to the steering of the ship.
if (Input.anyKey)
stability = Mathf.Lerp(maxStability, 0.2f, 5 * Time.deltaTime);
else
stability = Mathf.Lerp(maxStability, maxStability, 5 * Time.deltaTime);
//Handle the tilting of the spaceship in forward / backward directions
float cyclicXForce = (MathF.Pow(input.CyclicInput.y, pow) * (cyclicForce * cyclicForceMultiplier));
rb.AddRelativeTorque(Vector3.right * (cyclicXForce * input.CyclicInput.y), ForceMode.Acceleration);
//Handle the tilting of the spaceship in left / right directions
float cyclicZForce = MathF.Pow(input.CyclicInput.x, pow) * (cyclicForce * cyclicForceMultiplier);
rb.AddRelativeTorque(Vector3.forward * (cyclicZForce * input.CyclicInput.x), ForceMode.Acceleration);
Vector3 forwardVec = (flatFwd * forwardDot);
Vector3 rightVec = (flatRight * rightDot);
// calculates the final angle to apply force in.
cyclicDir = Vector3.ClampMagnitude(forwardVec + rightVec, 1f) * (forwardPower * cyclicForce);
RaycastHit hit;
Ray ray = new Ray(transform.position, cyclicDir);
if (Physics.SphereCast(ray, .2f, out hit, maxDiffDistance, enviormentMask))
{
// Calculate the distance to the closest object in the world in the direction witch the ship is going to move toward
distanceModifier = maxDiffDistance - hit.distance;
distanceForceMultiplier = distanceModifier / maxDiffDistance;
if(hit.distance > maxDiffDistance *.1f)
{
forwardPower = (1 - distanceForceMultiplier) * Mathf.Pow(distanceForceMultiplier, pow) * maxForwardPowerMuliplier;
rb.AddForce(cyclicDir, ForceMode.Force);
}
else
{
// handle the "breaking" of the ship by reversing the power when the ship is less than 10m away from the object
forwardPower = distanceForceMultiplier * Mathf.Pow(distanceForceMultiplier, pow) * maxForwardPowerMuliplier;
rb.AddForce(-cyclicDir, ForceMode.Force);
}
// apply force in the direction wich the ship is tilting toward
forwardPower = Mathf.Clamp(forwardPower, 0, maxForwardPowerMuliplier);
}
else {
// apply force in the direction wich the ship is tilting toward
forwardPower = maxForwardPowerMuliplier;
rb.AddForce(cyclicDir, ForceMode.Force);
}
}
AutoLevel function
The force applied to the spaceship is handled by this function. The player will get less power if anything blocks the path. Player input determines auto-level force. A spaceship’s pitch is less controlled since it auto-levels. When a player hits something and begins to wobble, they can take their hands off the keyboard and let the autoleveling do its thing
private void AutoLevel(Rigidbody rb)
{
// Calculate in what direction the spaceship need to apply force to be able to stay steady in the air
Vector3 predictedUp = Quaternion.AngleAxis(
rb.angularVelocity.magnitude * Mathf.Rad2Deg * stability / maxStability,
rb.angularVelocity
) * transform.up;
Vector3 torqueVector = Vector3.Cross(predictedUp, Vector3.up);
rb.AddTorque(torqueVector * maxStability * stability);
Quaternion corrAngle = Quaternion.Euler(0f, 0f, 0f);
float rightForce = -forwardDot * autolevelForce;
float forwardForce = rightDot * autolevelForce;
rb.AddRelativeTorque(Vector3.right * rightForce, ForceMode.Acceleration);
rb.AddRelativeTorque(Vector3.forward * forwardForce, ForceMode.Acceleration);
}
HandlePlasmaCanon function
The cannon moves slowly towards points in the world where the mouse hovers. A projectile moves forward and checks for collisions. After a hit happens, collision effects are spawned.
void HandlePlasmaCanon()
{
firePos.LookAt(lookAt);
Quaternion OriginalRot = gunPos.localRotation;
gunPos.LookAt(lookAt.position);
Quaternion NewRot = gunPos.localRotation;
gunPos.localRotation = OriginalRot;
gunPos.localRotation = Quaternion.Lerp(gunPos.localRotation, NewRot, rotSpeed * Time.deltaTime);
foreach (Projectile projectile in projectileList)
{
RaycastHit projectileHit;
Ray projectileRay = new Ray(projectile.transform.position, projectile.transform.forward);
if (Physics.SphereCast(projectileRay, .2f, out projectileHit, Mathf.Infinity))
{
Debug.DrawLine(projectile.transform.position, projectileHit.point, Color.cyan, 2000000f);
if (projectileHit.distance < 1f && projectileHit.distance > .5f)
{
StartCoroutine(InstansiateHitPlane(projectileHit.point));
Debug.Log("Stat");
StartCoroutine(InstansiateHitEffect(projectileHit.point));
Destroy(projectile.gameObject);
projectileList.Clear();
}
}
}
RaycastHit hit;
Ray ray = new Ray(firePos.position, gunPos.forward);
if (Physics.SphereCast(ray, 0.2f, out hit, shootRange, gunMask))
{
aim.position = hit.point;
aim.rotation = Quaternion.FromToRotation(aim.up, hit.normal) * aim.rotation;
hitEffectPos = aim;
}
if (Input.GetButton("Fire1") && canShoot)
{
Shoot();
}
}
IEnumerator InstantiateHitPlane(Vector3 pos)
{
GameObject planeInstance = Instantiate(impactPlane,pos, hitEffectPos.rotation);
yield return new WaitForSeconds(.1f);
Destroy(planeInstance);
StopCoroutine(InstantiateHitPlane(pos));
}
IEnumerator InstantiateHitEffect(Vector3 pos)
{
VisualEffect hitEffectInsante = Instantiate(impactVFX, pos, hitEffectPos.rotation);
hitEffectInsante.transform.localScale = new Vector3(5, 5, 5);
vFXList.Add(hitEffectInsante);
yield return new WaitForSeconds(hitEffectInsante.playRate);
foreach(VisualEffect vfx in vFXList)
{
Destroy(vfx.gameObject);
}
vFXList.Clear();
StopCoroutine(InstantiateHitEffect(pos));
}
void Shoot()
{
RaycastHit hit;
Ray ray = new Ray(firePos.position, gunPos.forward);
if (Physics.SphereCast(ray, 0.2f, out hit, shootRange, gunMask))
{
Projectile projectileInstance = Instantiate(projectile, firePos.position, gunPos.rotation);
projectileList.Add(projectileInstance);
VisualEffect muzzleFlash = Instantiate(muzzleFlashVFX, firePos);
muzzleFlash.transform.localPosition = new Vector3(0, 0, 0);
muzzleFlash.transform.localScale = new Vector3(5, 5, 5);
}
StartCoroutine(Timer());
}
IEnumerator Timer()
{
canShoot = false;
yield return new WaitForSeconds(1f);
canShoot = true;
StopCoroutine(Timer());
}
HandlePedal function
This function rotates the spacecraft horizontally.
protected virtual void HandlePedal(Rigidbody rb, KeyboardInput input)
{
rb.AddTorque(Vector3.up * input.PedalInput * tailForce, ForceMode.Acceleration);
}
FPSController function
This function controls the camera movement when the player exits the spacecraft to explore.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Spaceship
{
public enum playerMoveStatus { notMoving, crouching, walking, running, notGrounded, landing }
public enum CurvedControlledBobCallBackType {horizontal, vertical}
public delegate void CurvedControlledBobCallback();
[System.Serializable]
public class CurvedControlledBobEvent
{
public float time = 0f;
public CurvedControlledBobCallback Function = null;
public CurvedControlledBobCallBackType Type = CurvedControlledBobCallBackType.vertical;
}
[System.Serializable]
public class CurvedControlledBob
{
[SerializeField] AnimationCurve bobCurve = new AnimationCurve(new Keyframe(0f,0f),new Keyframe(0.5f, 1f)
,new Keyframe(1f, 0f), new Keyframe(1.5f, -1f), new Keyframe(2f, 0f));
[SerializeField] float horizontalMultiplier = 0.01f;
[SerializeField] float verticalMultiplier = 0.02f;
[SerializeField] float verticaltoHorizontalSpeedRatio = 2.0f;
[SerializeField] float baseInterval = 1f;
private float prevXPlayHead;
private float prevYPlayHead;
private float xPlayHead;
private float yPlayHead;
private float cureveEndTime;
private List<CurvedControlledBobEvent> events = new List<CurvedControlledBobEvent>();
public void Initialize ()
{
cureveEndTime = bobCurve[bobCurve.length - 1].time;
xPlayHead = 0f;
yPlayHead = 0f;
prevXPlayHead = 0f;
prevYPlayHead = 0f;
}
public void RegisterEventCallback(float time, CurvedControlledBobCallback function, CurvedControlledBobCallBackType type)
{
CurvedControlledBobEvent ccbeEnvet = new CurvedControlledBobEvent();
ccbeEnvet.time = time;
ccbeEnvet.Function = function;
ccbeEnvet.Type = type;
events.Add(ccbeEnvet);
events.Sort(
delegate(CurvedControlledBobEvent t1, CurvedControlledBobEvent t2)
{
return (t1.time.CompareTo (t2.time));
}
);
}
public Vector3 GetVectorOffset(float speed)
{
xPlayHead += (speed * Time.deltaTime)/ baseInterval;
yPlayHead += ((speed * Time.deltaTime) / baseInterval)* verticaltoHorizontalSpeedRatio;
if(xPlayHead > cureveEndTime)
xPlayHead -= cureveEndTime;
if (yPlayHead > cureveEndTime)
yPlayHead -= cureveEndTime;
for (int i = 0; i < events.Count; i++)
{
CurvedControlledBobEvent ev = events[i];
if(ev!=null)
if(ev.Type == CurvedControlledBobCallBackType.vertical)
if(prevYPlayHead < ev.time && yPlayHead >= ev.time || prevYPlayHead > yPlayHead && (ev.time > prevYPlayHead || ev.time <= yPlayHead))
ev.Function();
if (ev.Type == CurvedControlledBobCallBackType.vertical)
if (prevXPlayHead < ev.time && xPlayHead >= ev.time || prevXPlayHead > xPlayHead && (ev.time > prevXPlayHead || ev.time <= xPlayHead))
ev.Function();
}
float xPos = bobCurve.Evaluate(xPlayHead)*horizontalMultiplier;
float yPos = bobCurve.Evaluate(yPlayHead)*verticalMultiplier;
prevXPlayHead = xPlayHead;
prevYPlayHead = yPlayHead;
return new Vector3(xPos,yPos,0f);
}
}
[RequireComponent(typeof(CharacterController))]
public class FPSController : MonoBehaviour
{
public List<AudioSource> audioSource = new List<AudioSource>();
private int audioToUse = 0;
public Vector3 spawnPoint { get; set; }
#region Gameplay
[SerializeField] private float crouchSpeed = 2.5f;
[SerializeField] private float walkSpeed = 1f;
[SerializeField] private float runSpeed = 4.5f;
[SerializeField] private float jumpSpeed = 7.5f;
[SerializeField] private float stickToGroundForce = 5f;
[SerializeField] private float gravityMultiplier = 2.5f;
[SerializeField] private float runStepLengthen = 0.75f;
[SerializeField] private float walkStepLengthen = 0.75f;
[SerializeField] private float staminaDepletion = 5f;
[SerializeField] private float staminaRecovery = 10f;
[SerializeField] private MouseLook mouseLook;
[SerializeField] private CurvedControlledBob headBob = new CurvedControlledBob();
// [SerializeField] SpaceShipCharacteristics charactaristics;
const float interactDistance = 20f;
public Camera fpsCamera = null;
private bool isJumpButtonPressed = false;
private Vector2 input = Vector2.zero;
private Vector3 moveDiection = Vector2.zero;
private bool pervGrounded;
private bool isWalking = false;
private bool isJumping = false;
private bool isCrouching = false;
private float fallingTimer = 0f;
private float controllerHeight = 0f;
private float stamina = 100f;
private Vector3 localSpaceCameraPos = Vector3.zero;
private CharacterController characterController = null;
private playerMoveStatus movementStatus = playerMoveStatus.notMoving;
public LayerMask groundMask;
public playerMoveStatus movemeStatus { get { return movementStatus; }}
public float WalkSpeed { get { return walkSpeed; }}
public float RunSpeed { get { return runSpeed; }}
[SerializeField] LayerMask interactebleMask;
float vertical;
float horizontal;
void Update ()
{
// if(Input.GetButtonDown("Interact"))
// Interact();
if (characterController.isGrounded) fallingTimer = 0f;
else
fallingTimer += Time.deltaTime;
if(Time.timeScale>Mathf.Epsilon)
mouseLook.LookRotation(transform, fpsCamera.transform);
if (!isJumpButtonPressed && !isCrouching)
isJumpButtonPressed = Input.GetButtonDown("Jump");
if(Input.GetButtonDown("Crouch"))
{
isCrouching = !isCrouching;
characterController.height = isCrouching == true ? controllerHeight/2f : controllerHeight;
}
if (!pervGrounded && characterController.isGrounded)
{
if (fallingTimer > 0.5f)
{
}
moveDiection.y = 0f;
isJumping = false;
movementStatus = playerMoveStatus.landing;
}
else if (characterController.velocity.sqrMagnitude < 0.01f)
movementStatus = playerMoveStatus.notMoving;
else if (isCrouching)
movementStatus = playerMoveStatus.crouching;
else if (isWalking)
movementStatus = playerMoveStatus.walking;
else
movementStatus = playerMoveStatus.running;
pervGrounded = characterController.isGrounded;
if(movementStatus == playerMoveStatus.running)
stamina = Mathf.Max(stamina - staminaDepletion * Time.deltaTime, 0f);
else
stamina = Mathf.Min(stamina + staminaRecovery * Time.deltaTime, 100f);
}
private void FixedUpdate()
{
horizontal = Input.GetAxis("Horizontal");
vertical = Input.GetAxis("Vertical");
bool wasWalking = isWalking;
isWalking = !Input.GetKey(KeyCode.LeftShift);
float speed = isCrouching ? crouchSpeed : isWalking ? walkSpeed : Mathf.Lerp(walkSpeed,runSpeed, stamina/100f);
input = new Vector2(horizontal, vertical);
if (input.sqrMagnitude > 1) input.Normalize();
Vector3 desierdMove = transform.forward * input.y + transform.right * input.x;
RaycastHit hit;
Ray ray = new Ray(transform.position, Vector3.down);
if (Physics.SphereCast(ray, characterController.radius, out hit, characterController.height / 2f, groundMask))
desierdMove = Vector3.ProjectOnPlane(desierdMove, hit.normal).normalized;
moveDiection.x = desierdMove.x * speed;
moveDiection.z = desierdMove.z * speed;
if (characterController.isGrounded)
{
moveDiection.y = -stickToGroundForce;
if (isJumpButtonPressed)
{
moveDiection.y = jumpSpeed;
isJumpButtonPressed = false;
isJumping = true;
}
}
else
moveDiection += Physics.gravity * gravityMultiplier * Time.fixedDeltaTime;
characterController.Move(moveDiection * Time.fixedDeltaTime);
Vector3 speedXZ = new Vector3(characterController.velocity.x, 0f, characterController.velocity.z);
if(speedXZ.magnitude > 0.01f)
fpsCamera.transform.localPosition = new Vector3(0f,0f,0f) + headBob.GetVectorOffset(speedXZ.magnitude * (isWalking || isCrouching? walkStepLengthen : runStepLengthen));
else fpsCamera.gameObject.transform.localPosition = new Vector3(0f, 0f, 0f);
}
#endregion
public float Stamina { get{return stamina; }}
private void Start()
{
characterController = GetComponent<CharacterController>();
controllerHeight = characterController.height;
fpsCamera = GetComponentInChildren<Camera>();
mouseLook.Init(transform, fpsCamera.transform);
headBob.Initialize();
headBob.RegisterEventCallback(1.5f, PlayFootstepSound, CurvedControlledBobCallBackType.vertical);
}
void PlayFootstepSound()
{
if(isCrouching) return;
audioSource[audioToUse].Play();
audioToUse = (audioToUse == 0) ? 1 : 0;
}
// void Interact ()
// {
// RaycastHit hit;
// Ray ray = new Ray (transform.position, transform.forward);
// if(Physics.SphereCast(ray, .1f, out hit, interactDistance))
// {
// if(hit.transform.gameObject.GetComponent<IInteracteble>() != null)
// {
// Debug.Log(hit.transform.gameObject);
// hit.transform.gameObject.GetComponent<IInteracteble>().OnInteract();
// }
// }
// }
private void OnEnable()
{
GameObject spaceship = GameObject.FindGameObjectWithTag("spaceship");
transform.position = spaceship.transform.position;
Debug.Log("iscalled");
characterController = GetComponent<CharacterController>();
controllerHeight = characterController.height;
fpsCamera = GetComponentInChildren<Camera>();
mouseLook.Init(transform, fpsCamera.transform);
headBob.Initialize();
headBob.RegisterEventCallback(1.5f, PlayFootstepSound, CurvedControlledBobCallBackType.vertical);
}
}
}