First-Person Shooter: Loadout controller and Weapon abstract class
This is Elympics First-Person Shooter tutorial: part 4. In this part we’ll be preparing the Loadout Controller and Weapon abstract class. See: Part 3.
Weapon abstract class
In FPS games, players need to be able to shoot using diverse weapons. In this project, you’ll have two types of it: one that shoots projectiles and another one based on raycast.
Start by creating an abstract class for your weapon: Weapon.cs
. This class will support some universal behaviors for all the weapons that you’ll be creating.
public abstract class Weapon : ElympicsMonoBehaviour, IInitializable, IUpdatable
{
[SerializeField] protected float fireRate = 60.0f;
[SerializeField] private GameObject meshContainer = null;
protected ElympicsFloat currentTimeBetweenShots = new ElympicsFloat();
protected float timeBetweenShots = 0.0f;
public float TimeBetweenShoots => timeBetweenShots;
protected bool IsReady => currentTimeBetweenShots >= timeBetweenShots;
public GameObject Owner => this.transform.root.gameObject;
public void Initialize()
{
CalculateTimeBetweenShoots();
}
public void CalculateTimeBetweenShoots()
{
if (fireRate > 0)
timeBetweenShots = 60.0f / fireRate;
else
timeBetweenShots = 0.0f;
}
public void ExecutePrimaryAction()
{
ExecuteWeaponActionIfReady();
}
private void ExecuteWeaponActionIfReady()
{
if (IsReady)
{
ProcessWeaponAction();
currentTimeBetweenShots.Value = 0.0f;
}
}
protected abstract void ProcessWeaponAction();
public virtual void SetIsActive(bool isActive)
{
meshContainer.SetActive(isActive);
}
public virtual void ElympicsUpdate()
{
if (!IsReady)
{
currentTimeBetweenShots.Value += Elympics.TickDuration;
}
}
}
Each of your weapons will have a specific fireRate, i.e. number of shots per minute. Its base setting, 60.0f
, suggests that the weapon will fire at the rate of one round per second. The mesh container variable will be used to disable the weapon view once the player stops using it.
The script begins its initialization in the Elympics Initialize()
method. The only action it performs is to convert the fireRate
value to the real time between the successive shots using the CalculateTimeBetweenShots()
. The other methods will be called by external classes.
ExecutePrimaryAction()
is a method that should be called when the player presses the shot button. It calls another ExecuteWeaponActionIfReady()
method that checks whether the time elapsed since the last shot corresponds to what was calculated on the basis of the typed fireRate
value. If so, the ProcessWeaponAction
abstract method is executed: it contains the firing logic adjusted to a specific type of weapon, e.g. creating and firing a projectile or shooting with a raycast. This condition is controlled by ElympicsFloat currentTimeBetweenShots
: a variable that synchronizes its value according to the current state on the server.
The ElympicsUpdate
method checks whether the weapon is ready to fire. It increases the currentTimeBetweenShots
variable with the TickDuration
value.
Loadout controller
Once you prepare the abstract class for your weapons, you can move on to creating another controller: LoadoutController
. This class will be responsible for storing the weapons available to the player, setting the equipped one and performing all the actions related to it.
public class LoadoutController : ElympicsMonoBehaviour, IInitializable, IUpdatable
{
[Header("References:")]
[SerializeField] private Weapon[] availableWeapons = null;
[Header("Parameters:")]
[SerializeField] private float weaponSwapTime = 0.3f;
public event Action WeaponSwapped = null;
private ElympicsInt currentEquipedWeaponIndex = new ElympicsInt(0);
private ElympicsFloat currentWeaponSwapTime = null;
private Weapon currentEquipedWeapon = null;
public void Initialize()
{
currentWeaponSwapTime = new ElympicsFloat(weaponSwapTime);
DisableAllWeapons();
currentEquipedWeaponIndex.ValueChanged += UpdateCurrentEquipedWeaponByIndex;
UpdateCurrentEquipedWeaponByIndex(currentEquipedWeaponIndex, 0);
}
private void DisableAllWeapons()
{
foreach (Weapon weapon in availableWeapons)
weapon.SetIsActive(false);
}
public void ProcessLoadoutActions(bool weaponPrimaryAction, int weaponIndex)
{
if (weaponIndex != -1 && weaponIndex != currentEquipedWeaponIndex)
{
SwitchWeapon(weaponIndex);
}
else
{
if (currentWeaponSwapTime.Value >= weaponSwapTime)
ProcessWeaponActions(weaponPrimaryAction);
}
}
private void ProcessWeaponActions(bool weaponPrimaryAction)
{
if (weaponPrimaryAction)
ProcessWeaponPrimaryAction();
}
private void ProcessWeaponPrimaryAction()
{
currentEquipedWeapon.ExecutePrimaryAction();
}
private void SwitchWeapon(int weaponIndex)
{
currentEquipedWeaponIndex.Value = weaponIndex;
currentWeaponSwapTime.Value = 0.0f;
}
private void UpdateCurrentEquipedWeaponByIndex(int lastValue, int newValue)
{
if (currentEquipedWeapon != null)
currentEquipedWeapon.SetIsActive(false);
currentEquipedWeapon = availableWeapons[newValue];
currentEquipedWeapon.SetIsActive(true);
WeaponSwapped?.Invoke();
}
public void ElympicsUpdate()
{
if (currentWeaponSwapTime < weaponSwapTime)
currentWeaponSwapTime.Value += Elympics.TickDuration;
}
}
The first variable in your LoadoutController
class is an array that holds a reference to all the weapons the player may have. The ability to change weapons will be added later, once you have more than one weapon prefab ready. Another variable is the parameter that defines the time that must elapse after performing the weapon change action to make it usable (while it’s happening, you may e.g. play an appropriate weapon change animation).
In the Initialize()
method, assign the appropriate value to the currentWeaponSwapTime
variable. This mechanism works similarly to timeBetweenShots
in the case of the Weapon
class described earlier. Next, disable all the available weapon visuals using the DisableAllWeapons()
method and initialize the assignment of the first, default weapon (the first element of the availableWeapons
array). The weapon change as a consequence of changing the value of the currentEquipedWeaponIndex
variable of the ElympicsInt
type. Thanks to it, all of your players will be able to synchronize their weapons and display the one currently used by the given player. The UpdateCurrentEquipedWeaponByIndex
method is assigned to change the value of this variable. When executed, it disables the previously used weapon properly and assigns a new value based on the set index from the table.
The LoadoutController
class exposes the ProcessLoadoutActions
function to external classes. This function takes two arguments: bool weaponPrimaryAction
and int weaponIndex
. This method will be called by InputController
that will provide information whether the player is currently holding the shot button (weaponPrimaryAction
) and whether they have pressed the slot change button (weaponIndex
).
Changing weapons is checked in the first if statement because it has a higher priority. If the weaponIndex
differs from the currently stored index of the active weapon, it is changed (SwitchWeapon
). Otherwise, if it’s possible to perform actions on the weapon, the ProcessWeaponActions
method is called. Currently, it checks only if weaponPrimaryAction
is equal to true. If so, the ProcessWeaponPrimaryAction
method is called on the currently selected weapon. This method was described when creating the Weapon.cs
class.
Update player inputs
Once you have Loadout Controller, you can immediately add its handling via InputController
and InputProvider
:
Updated InputProvider.cs:
public class InputProvider : MonoBehaviour
[...]
public bool WeaponPrimaryAction { get; private set; }
public int WeaponSlot { get; private set; }
private void Update()
{
movement.x = Input.GetAxis("Horizontal");
movement.y = Input.GetAxis("Vertical");
var mouseX = Input.GetAxis("Mouse X") * (invertedMouseXAxis ? -1 : 1);
var mouseY = Input.GetAxis("Mouse Y") * (invertedMouseYAxis ? -1 : 1);
var newMouseAngles = mouseAxis + new Vector3(mouseY, mouseX) * mouseSensivity;
mouseAxis = FixTooLargeMouseAngles(newMouseAngles);
Jump = Input.GetButton("Jump");
WeaponPrimaryAction = Input.GetButton("Fire1");
WeaponSlot = Input.GetKey(KeyCode.Alpha1) ? 0 :
Input.GetKey(KeyCode.Alpha2) ? 1 : -1;
}
[...]
}
Updated InputController.cs:
[RequireComponent(typeof(InputProvider))]
public class InputController : ElympicsMonoBehaviour, IInputHandler, IInitializable, IUpdatable
{
[...]
[SerializeField] private LoadoutController loadoutController = null;
private void SerializeInput(IInputWriter inputWriter)
{
[...]
inputWriter.Write(inputProvider.Jump);
inputWriter.Write(inputProvider.WeaponPrimaryAction);
inputWriter.Write(inputProvider.WeaponSlot);
}
public void ElympicsUpdate()
{
{
[...]
inputReader.Read(out jump);
inputReader.Read(out bool weaponPrimaryAction);
inputReader.Read(out int weaponSlot);
ProcessMouse(Quaternion.Euler(new Vector3(xRotation, yRotation, zRotation)));
ProcessLoadoutActions(weaponPrimaryAction, weaponSlot);
}
ProcessMovement(forwardMovement, rightMovement);
}
private void ProcessLoadoutActions(bool weaponPrimaryAction, int weaponSlot)
{
loadoutController.ProcessLoadoutActions(weaponPrimaryAction, weaponSlot);
}
[...]
}
You’ll also have to update the player's prefab:
It's not finished yet!
At this point, you’ll be able to shoot from the currently selected weapon by clicking LMB. If you don’t have any weapons yet, go to the next chapter of this tutorial. 🔫