Architecture of an FPS in Unity
The FPS Microgame is a FPS template for learning Unity. In this post, I'll explore the project to understand how things are made. This will help me make my own games later. This is a work in progress.
Health
The Health class is a simple class that contains max health, current health, whether the user is invincible, a method to heal or take damage, and listeners for when the user dies or heals.
They also have a flag IsCritical, that is true when life goes below a certain ratio.
public class Health : MonoBehaviour
{
[Tooltip("Maximum amount of health")]
public float maxHealth = 10f;
[Tooltip("Health ratio at which the critical health vignette starts appearing")]
public float criticalHealthRatio = 0.3f;
public UnityAction<float, GameObject> onDamaged;
public UnityAction<float> onHealed;
public UnityAction onDie;
public float currentHealth { get; set; }
public bool invincible { get; set; }
public bool canPickup() => currentHealth < maxHealth;
public float getRatio() => currentHealth / maxHealth;
public bool isCritical() => getRatio() <= criticalHealthRatio;
bool m_IsDead;
...
A few things I've learned from this:
- Adding a Tooltip annotation, so that it shows up in the Inspector.
UnityAction
to trigger events. Up till now I had been using theUnityEvent
. I'll have to research the difference later.
Weapons
Projectiles
Projectiles are stick looking meshes with emissive material. They all have the same Projectile script, with parameters such as speed, damage, whether they are affected by gravity, as well as SFX (sound effect) and VFX (visual effect, ie particle effects) on impact.
There are two classes, ProjectileBase and ProjectileStandard. ProjectileBase sets the initial position, direction on Shoot. It also calls the OnShoot listeners. The child classes listen to that. ProjectileStandard contains the properties mentioned above.
using UnityEngine;
using UnityEngine.Events;
public class ProjectileBase : MonoBehaviour
{
public GameObject owner { get; private set; }
public Vector3 initialPosition { get; private set; }
public Vector3 initialDirection { get; private set; }
public Vector3 inheritedMuzzleVelocity { get; private set; }
public float initialCharge { get; private set; }
public UnityAction onShoot;
public void Shoot(WeaponController controller)
{
owner = controller.owner;
initialPosition = transform.position;
initialDirection = transform.forward;
inheritedMuzzleVelocity = controller.muzzleWorldVelocity;
initialCharge = controller.currentCharge;
if (onShoot != null)
{
onShoot.Invoke();
}
}
}
The Projectile moves OnUpdate by a direct update to its position. There's a piece of code that corrects the trajectory, so that the projectile travels directly from the screen center even though it is fired off a gun slightly on the right. I haven't dug into that piece of code yet.
Hit detection is done by finding all objects within its trajectory between its last known position and its current position.
// Sphere cast
Vector3 displacementSinceLastFrame = tip.position - m_LastRootPosition;
RaycastHit[] hits = Physics.SphereCastAll(m_LastRootPosition, radius, displacementSinceLastFrame.normalized, displacementSinceLastFrame.magnitude, hittableLayers, k_TriggerInteraction);
It will find the closest one, then call OnHit. OnHit will inflict area of damage, or damage, depending on the projectile's settings, then play the visual and sound effects on impact, and finally destroy itself.
DamageArea is a script, added as a component to the projectile itself. Then it is assigned to the damageArea field of the StandardProjectile script. It's a great way to configure projectiles in a self-contained manner!
Damage
Direct damage
Direct damage is simple, find the object the projectile has collided with, get its Damageable component, and call the interface's method:
Damageable damageable = collider.GetComponent<Damageable>();
if (damageable)
{
damageable.InflictDamage(damage, false, m_ProjectileBase.owner);
}
Area damage
Area of damage is more interesting. First, the Projectile script checks if the areaDamage component has been set. If so, it calls its damage method:
if (areaOfDamage)
{
areaOfDamage.InflictDamageInArea(damage, point, hittableLayers, k_TriggerInteraction, m_ProjectileBase.owner);
}
Something amazing I found was how they use an AnimationCurve to specify how much damage objects will incur depending on their distance to the explosion:
public class DamageArea : MonoBehaviour
{
[Tooltip("Area of damage when the projectile hits something")]
public float areaOfEffectDistance = 5f;
[Tooltip("Damage multiplier over distance for area of effect")]
public AnimationCurve damageRatioOverDistance;
...
Then in the InflictDamage method, they cast a sphere to find all objects with the Health and Damageable components, then call InflictDamage individually based on the distance:
Collider[] affectedColliders = Physics.OverlapSphere(center, areaOfEffectDistance, layers, interaction);
// Collect affected characters.
foreach (var uniqueDamageable in affectedCharacters)
{
uniqueDamageable.InflictDamage(damage * damageRatioOverDistance.Evaluate(distance / areaOfEffectDistance), true, owner);
}
UI
All the UI elements are nested inside the GameManager object. All the scripts related to the UI are added as components of the GameHUD.
Toast Managers
They appear in the top left, middle and bottom left corners of the screen and are handled respectively by the ObjectiveHUDManager, DisplayMessageManager and NotificationHUDManager.
Upon instantiating an Objective, it'll register itself to the Notification and Objective HUD Managers, and the ObjectiveManager.
public class Objective : MonoBehaviour
{
[Tooltip("Name of the objective that will be shown on screen")]
public string title;
[Tooltip("Short text explaining the objective that will be shown on screen")]
public string description;
[Tooltip("Whether the objective is required to win or not")]
public bool isOptional;
[Tooltip("Delay before the objective becomes visible")]
public float delayVisible;
public bool isCompleted { get; private set; }
public bool isBlocking() => !(isOptional || isCompleted);
public UnityAction<UnityActionUpdateObjective> onUpdateObjective;
NotificationHUDManager m_NotificationHUDManager;
ObjectiveHUDManger m_ObjectiveHUDManger;
void Start()
{
// add this objective to the list contained in the objective manager
ObjectiveManager objectiveManager = FindObjectOfType<ObjectiveManager>();
DebugUtility.HandleErrorIfNullFindObject<ObjectiveManager, Objective>(objectiveManager, this);
objectiveManager.RegisterObjective(this);
// register this objective in the ObjectiveHUDManger
m_ObjectiveHUDManger = FindObjectOfType<ObjectiveHUDManger>();
DebugUtility.HandleErrorIfNullFindObject<ObjectiveHUDManger, Objective>(m_ObjectiveHUDManger, this);
m_ObjectiveHUDManger.RegisterObjective(this);
// register this objective in the NotificationHUDManager
m_NotificationHUDManager = FindObjectOfType<NotificationHUDManager>();
DebugUtility.HandleErrorIfNullFindObject<NotificationHUDManager, Objective>(m_NotificationHUDManager, this);
m_NotificationHUDManager.RegisterObjective(this);
}
Those HUDManagers will then display it upon registration, and also when the objective fires updates (when it is complete).
ObjectiveHUDManager
Let's take a look at the ObjectiveHUDManager. To display the objective, it instantiates an ObjectiveUIPrefab. Then it gets its ObjectiveToast component, and tells it to initialize with the given text. Then it adds it to a collection for disposal later, and finally displays it in the panel:
UnityEngine.UI.LayoutRebuilder.ForceRebuildLayoutImmediate(objectivePanel);
Toasts
ObjectiveToast handles setting the objective text into its UI, and fading in and out, and sliding in or out. It can also play a sound.
Move in and out is configured using AnimationCurves. In its Update method, they evaluate the curve and use it to assign the toast's padding left.
if (m_IsMovingIn && !m_IsMovingOut)
{
// move in
if (timeSinceFadeStarted < moveInDuration)
{
layoutGroup.padding.left = (int)moveInCurve.Evaluate(timeSinceFadeStarted / moveInDuration);
if (GetComponent<RectTransform>())
{
LayoutRebuilder.ForceRebuildLayoutImmediate(GetComponent<RectTransform>());
}
}
...
One thing about the animation curve is that it goes from -100 to 0, and from 0 to -100.
For fading out, they went with a hardcoded linear curve:
canvasGroup.alpha = 1 - (timeSinceFadeStarted) / fadeOutDuration;