First-Person Shooter: Loadout controller and Weapon abstract class

Tutorial: First-Person Shooter in Unity - Preparing the 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:

First-Person Shooter

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. 🔫


Last modified December 12, 2022: Part 4 (loadout) and part 5 (projectile) (bda0545)