Skip to main content

First-Person Shooter: Projectile Weapon

info

This is Elympics First-Person Shooter tutorial: part 5. In this part we’ll be creating a weapon that fires projectiles. See: Part 4.

Rocket Launcher

Let’s start by creating a weapon that fires projectiles. The first step is to create a prefab representing our weapon consisting of a container for all the meshes of our weapon as well as an empty game object designating the place where your projectiles will be spawned.

First-Person Shooter

Remove all the colliders (if there are any) from the prefab because you won’t need them.

Add the ElympicsBehaviour component to the parent object in your prefab. Then, go to the script of your weapon and the missile itself: RocketLauncher.cs and ProjectileBullet.cs

Start with a new RocketLauncher script:

public class RocketLauncher : Weapon
{
[SerializeField] private Transform bulletSpawnPoint = null;
[SerializeField] private ProjectileBullet bulletPrefab = null;

public ProjectileBullet BulletPrefab => bulletPrefab;

protected override void ProcessWeaponAction()
{
var bullet = CreateBullet();

bullet.transform.position = bulletSpawnPoint.position;
bullet.transform.rotation = bulletSpawnPoint.transform.rotation;
bullet.GetComponent<ProjectileBullet>().Launch(bulletSpawnPoint.transform.forward);
}

private GameObject CreateBullet()
{
var bullet = ElympicsInstantiate(bulletPrefab.gameObject.name, ElympicsPlayer.FromIndex(Owner.GetComponent<PlayerData>().PlayerId));
bullet.GetComponent<ProjectileBullet>().SetOwner(Owner.gameObject.transform.root.gameObject.GetComponent<ElympicsBehaviour>());

return bullet;
}
}

The RocketLauncher class has two serialized fields: bulletSpawnPoint and bulletPrefab. BulletSpawnPoint is where the bullet will be fired from. BulletPrefab is the bullet the weapon will fire. It’s a ProjectileBullet type bullet, so it will have its own logic (we’ll describe it in a moment).

The core element of the RocketLauncher class is the overriden ProcessWeaponAction method. The ProcessWeaponAction method is called by another method in the Weapon base class if the weapon is ready to fire.

Please note that in Elympics, instantiating and destroying objects is possible only in the ElympicsUpdate function.

Fortunately, ProcessWeaponAction is called as part of ElympicsUpdate in InputController, so you can fire the projectile right away.

The bullet is instantiated using the CreateBullet method. In this method, ElympicsInstantiate is called and it takes the path to the object in the Resources file as arguments (in this case, the projectile won’t be in any subfolder, so you can simply give its name). The second argument to pass is the id of the player who uses the given weapon. Thanks to this, the instantiated bullet will automatically have a Predictable For for the given player in the ElympicsBehaviour component. In the last line, you call SetOwner and pass your player's parent ElympicsBehaviour component. The use of this function will be described in the ProjectileBullet class.

Let’s get back to the ProcessWeaponAction function. After creating the projectile, you’ll need to set its position and rotation in accordance with the set spawnPoint, and then call the Launch method, giving the direction of the projectile flight as an argument.

Projectile bullet

The weapon fires bullets with its own logic. An example of such a bullet would be the ProjectileBullet.cs script:

[RequireComponent(typeof(Rigidbody))]
public class ProjectileBullet : ElympicsMonoBehaviour, IUpdatable, IInitializable
{
[Header("Parameters:")]
[SerializeField] protected float speed = 5.0f;
[SerializeField] protected float lifeTime = 5.0f;
[SerializeField] protected float timeToDestroyOnExplosion = 1.0f;

[Header("References:")]
[SerializeField] private ExplosionArea explosionArea = null;
[SerializeField] private GameObject bulletMeshRoot = null;
[SerializeField] protected new Rigidbody rigidbody = null;
[SerializeField] protected new Collider collider = null;

public float LifeTime => lifeTime;

protected ElympicsBool readyToLaunchExplosion = new ElympicsBool(false);
protected ElympicsBool markedAsReadyToDestroy = new ElympicsBool(false);

protected ElympicsBool colliderEnabled = new ElympicsBool(false);
protected ElympicsBool bulletExploded = new ElympicsBool(false);

private ElympicsGameObject owner = new ElympicsGameObject();
private ElympicsFloat deathTimer = new ElympicsFloat(0.0f);


public void Initialize()
{
colliderEnabled.ValueChanged += UpdateColliderEnabled;
}

private void UpdateColliderEnabled(bool lastValue, bool newValue)
{
collider.enabled = newValue;
}

public void SetOwner(ElympicsBehaviour owner)
{
this.owner.Value = owner;
}

public void Launch(Vector3 direction)
{
rigidbody.useGravity = true;
rigidbody.isKinematic = false;
colliderEnabled.Value = true;

ChangeBulletVelocity(direction);
}

private void ChangeBulletVelocity(Vector3 direction)
{
rigidbody.velocity = direction * speed;
}

private void OnCollisionEnter(Collision collision)
{
if (owner.Value == null)
return;

if (collision.transform.root.gameObject == owner.Value.gameObject)
return;

DetonateProjectile();
}

private IEnumerator SelfDestoryTimer(float time)
{
yield return new WaitForSeconds(time);

DestroyProjectile();
}

private void DestroyProjectile()
{
markedAsReadyToDestroy.Value = true;
}

private void DetonateProjectile()
{
readyToLaunchExplosion.Value = true;
}

public void ElympicsUpdate()
{
if (readyToLaunchExplosion.Value && !bulletExploded)
LaunchExplosion();
if (markedAsReadyToDestroy.Value)
ElympicsDestroy(this.gameObject);

deathTimer.Value += Elympics.TickDuration;

if ((!bulletExploded && deathTimer >= lifeTime)
|| (bulletExploded && deathTimer >= timeToDestroyOnExplosion))
{
DestroyProjectile();
}
}

private void LaunchExplosion()
{
bulletMeshRoot.SetActive(false);
rigidbody.isKinematic = true;
rigidbody.useGravity = false;
colliderEnabled.Value = false;

explosionArea.Detonate();

bulletExploded.Value = true;
deathTimer.Value = 0.0f;
}
}

The main assumptions of this ProjectileBullet script are defined by the first three variables: The projectile has a specific speed and lifetime after which it will automatically detonate. As a result of the explosion, the projectile still exists in the game world for a second so that you can recreate the appropriate feedback of the explosion (e.g. particles).

The references this script needs are:

  • Explosion Area: a separate class that defines the behavior of the projectile when it explodes. All in all it’s mean to deal damage to players;
  • BulletMeshRoot: a reference to the main container of the bullet that contains all the meshes. We don't want the projectile to show up in the game scene while exploding;
  • Rigidbody and Collider: to manage and synchronize the state of these two components respectively.

This class also uses many ElympicsVars:

  • readyToLaunchExplosion, markedAsReadyToDestroy and bulletExploded: ElympicsBools used to help synchronize the current state of the object;
  • colliderEnabled: a variable that helps to synchronize the collider state;
  • Owner: the player who fired the projectile;
  • deathTimer: the current time counted down to control the state changed as a result of the passage of time, e.g. Lifetime.

The main method of initiating the projectile's operation is Launch() - where the parameters are set and velocity is assigned.

The missile explodes when it collides (OnCollisionEnter) with another object other than its owner (set while creating this object in the RocketLauncher class using the SetOwner method). The DetonateProjectile method is called, changing the readyToLaunchExplosion synchronized flag to true. This flag is checked in ElympicsUpdate and if the conditions are met, the projectile explodes by calling the LaunchExplosion method.

This method stops the rigidbody and disables collisions, but its main function is to call the ExplosionArea object's Detonate() function (ExplosionArea will be described later in this chapter). ExplosionArea’s main task is to detect players within its firing range and deal damage to them. At the end, further flags that don’t allow the bullet to explode again are set (bulletExploded), and the timer responsible for tracking the bullet's lifetime is reset. If the projectile didn’t hit any other object and its lifetime expired or it exploded and its lifetime expired, it’s destroyed after the explosion (ElympicsDestroy).

if ((!bulletExploded && deathTimer >= lifeTime)
|| (bulletExploded && deathTimer >= timeToDestroyOnExplosion))
{
DestroyProjectile();
}

The last element necessary for the proper functioning of your weapon is the explosion. The projectile's job is to move and detect a collision with an object, while an explosion is triggered on impact to deal damage to targets within its range.

Explosion area

Here’s an example implementation of ExplosionArea.cs:

public class ExplosionArea : ElympicsMonoBehaviour
{
[Header("Parameters:")]
[SerializeField] private float explosionDamage = 10.0f;
[SerializeField] private float explosionRange = 2.0f;

[Header("References:")]
[SerializeField] private ParticleSystem explosionPS = null;
[SerializeField] private ElympicsMonoBehaviour bulletOwner = null;

public void Detonate()
{
DetectTargetsInExplosionRange();

explosionPS.Play();
}

private void DetectTargetsInExplosionRange()
{
Collider[] objectsInExplosionRange = Physics.OverlapSphere(this.transform.position, explosionRange);

foreach (Collider objectInExplosionRange in objectsInExplosionRange)
{
if (TargetIsNotBehindObstacle(objectInExplosionRange.transform.root.gameObject))
TryToApplyDamageToTarget(objectInExplosionRange.transform.root.gameObject);
}
}

private void TryToApplyDamageToTarget(GameObject objectInExplosionRange)
{
//Damage to apply here!
Debug.Log("Apply damage to: " + objectInExplosionRange.gameObject.name);
}

private bool TargetIsNotBehindObstacle(GameObject objectInExplosionRange)
{
var directionToObjectInExplosionRange = (objectInExplosionRange.transform.position - this.transform.position).normalized;

if (Physics.Raycast(this.transform.position, directionToObjectInExplosionRange, out RaycastHit hit, explosionRange))
{
return hit.transform.gameObject == objectInExplosionRange;
}

return false;
}
}

The main parameters describing this object are explosionDamage and explosionRange as well as the following references:

  • bulletOwner: useful when you want to know who was the character dealing damage;
  • explosionPS: a particle system recreated when calling the initializing Detonate() method (not required).

During the initialization of the explosion (i.e. when calling the Detonate method) all objects within the explosion range are checked and saved (objectsInExplosionRange). Then, for each object, Explosion Area checks if the potential target is behind an obstacle that should block the damage. It’s an overly simplified implementation, but it’s fully sufficient for the needs of our project.

If the target is not hiding behind any obstacle, the last step is to try to apply damage (TryToApplyDamage). In this method, we should try to get a component responsible for managing statistics, but leave it empty for now because you don’t have such a component yet.

At this point, you should have a complete, functional bullet projectile weapon system, so let's create appropriate prefabs and assign references to be able to fully test your project.

Prefabs and references

Start by creating an ExplosionArea. Create a new empty game object and add an ElympicsBehaviour and an ExplosionArea component to it. In the case of ExplosionArea, it’s also worth adding the object transform synchronization (Add Transform Synchronization). You can also optionally add a particle system to it to be called when the explosion is initiated.

First-Person Shooter

Then, create a prefab from this object.

You’ll use the ExplosionArea prepared in this way to prepare the ProjectileBullet. Create a new EmptyGameObject in the scene and add the previously created ExplosionArea prefab and another EmptyGameObject to it. It’ll be a container for all the meshes related to the projectile.

First-Person Shooter

Add the following components to the prepared RocketLauncherBullet object:

  • ElympicsBehaviour (and Add Rigidbody Synchronization with Synchronize _useGravity and Synchronize _isKinematic checked);
  • Rigidbody (Interpolate: interpolate, Collision Detection: Continous);
  • Collider (in this case, capsule collider);
  • Previously created Projectile Bullet script.

Assign appropriate references to the ProjectileBullet script. The projectile object should look like this:

First-Person Shooter

Create a prefab from this object and place it in the Resources folder: it’s very important, because if you place this prefab in a different folder, you won’t be able to instantiate it using the ElympicsInstantiate method!

First-Person Shooter

With the projectileBullet and explosionArea prefabs ready, you can proceed to the full preparation of your weapons. Complete the previously created prefab of your weapons with appropriate scripts and references:

First-Person Shooter

Now, go to the player prefab. Find the FirstPersonViewContainer component and add your RocketLauncher prefab to it. Modify its position accordingly so that the view from the camera corresponds to the view from the FPS game (the weapon visible in the bottom right corner of the screen).

First-Person Shooter

Also, make sure that the ElympicsBehaviour component in the RocketLauncher object will have the predictable for value set appropriately depending on the player prefab you’re currently modifying.

First-Person Shooter

The last step is to find the LoadoutController component in the player's parent object and assign your RocketLauncher to the currently available weapons.

First-Person Shooter

From now on, you’ll be able to fire your weapon, and the projectile explosion will be ready to deal damage!

From now on you can fire projectiles!

First-Person Shooter First-Person Shooter

Players can now fire projectiles by clicking LMB! In the next part we'll handle dealing damage and processing player's death! 🩹💀