First-Person Shooter: Projectile Weapon
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.
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 theElympicsUpdate
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
andbulletExploded
: 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 initializingDetonate()
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.
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.
Add the following components to the prepared RocketLauncherBullet object:
ElympicsBehaviour
(andAdd Rigidbody Synchronization
withSynchronize _useGravity
andSynchronize _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:
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!
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:
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).
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.
The last step is to find the LoadoutController
component in the player's parent object and assign your RocketLauncher
to the currently available weapons.
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!
Players can now fire projectiles by clicking LMB! In the next part we'll handle dealing damage and processing player's death! 🩹💀