Work

Shooter System

Unity
C#
Windows
Keyboard/Mouse & Gamepad
Mutliplayer

It's a prototype 3D Game in first person view made in Unity. It's a local multiplayer game to play with some friends and the goal is simple: eliminate other players to continuously upgrade your arsenal, much like the arms race in Counter-Strike: Global Offensive.My purpose was to learn and master game development concepts, particularly shooting systems and weapon management.

Iridescent ripples of a bright blue and pink liquid

What I have learned

  • Create an multiplayer game in Unity
  • Create a Shooting system from scratch
  • Create a Pool system
  • Create a Scope for Sniper and other weapon

Input System

Unity’s Input System is a comprehensive framework designed to handle various types of input from different devices (keyboard, mouse, gamepads, etc.). The script provided is part of the Starter Assets package, which simplifies the process of handling player input in Unity projects. Below, I’ll explain the key components and functions of this script.

	
using UnityEngine;
#if ENABLE_INPUT_SYSTEM && STARTER_ASSETS_PACKAGES_CHECKED
using UnityEngine.InputSystem;
#endif
	

The script begins by including necessary namespaces and using conditional compilation to ensure compatibility with the new Input System. The ENABLE_INPUT_SYSTEM directive ensures that the code related to the new Input System only compiles if the system is enabled, preventing errors if it’s not.

The StarterAssetsInputs class inherits from MonoBehaviour and manages various input actions for a character. These are public variables store input values for various character actions like movement, looking around, jumping, sprinting, etc.

	
  // Public variables to hold input values
        [Header("Character Input Values")]
        public Vector2 move;
        public Vector2 look;
        public bool jump;
        public bool sprint;
        public bool shoot;
        public bool aim;
        public bool reload;
        public bool crouch;

        [Header("Movement Settings")]
        public bool analogMovement;

        [Header("Mouse Cursor Settings")]
        public bool cursorLocked = true;
        public bool cursorInputForLook = true;
	

Input Methods

Methods to handle different input actions are defined. These methods are called automatically by Unity’s Input System when the corresponding actions are triggered. Each of these methods receives an InputValue parameter, which holds the value of the input (e.g., the direction for movement or whether a button is pressed).

	
#if ENABLE_INPUT_SYSTEM && STARTER_ASSETS_PACKAGES_CHECKED
        public void OnMove(InputValue value)
        {
            MoveInput(value.Get());
        }

        public void OnLook(InputValue value)
        {
            if (cursorInputForLook)
            {
                LookInput(value.Get());
            }
        }

        public void OnJump(InputValue value)
        {
            JumpInput(value.isPressed);
        }

        public void OnSprint(InputValue value)
        {
            SprintInput(value.isPressed);
        }

        public void OnAim(InputValue value)
        {
            AimInput(value.isPressed);
        }
        
        public void OnShoot(InputValue value)
        {
            ShootInput(value.isPressed);
        }

        public void OnReload(InputValue value)
        {
            ReloadInput(value.isPressed);
        }

        public void OnCrouch(InputValue value)
        {
            CrouchInput(value.isPressed);
        }
#endif

	

Input Handling Methods

These private methods are used to update the respective public variables with the new input values:

	
        public void MoveInput(Vector2 newMoveDirection)
        {
            move = newMoveDirection;
        }

        public void LookInput(Vector2 newLookDirection)
        {
            look = newLookDirection;
        }

        public void JumpInput(bool newJumpState)
        {
            jump = newJumpState;
        }

        public void SprintInput(bool newSprintState)
        {
            sprint = newSprintState;
        }

        public void AimInput(bool newAimState)
        {
            aim = newAimState;
        }

        public void ShootInput(bool newShootState)
        {
            shoot = newShootState;
        }

        public void ReloadInput(bool newReloadState)
        {
            reload = newReloadState;
        }

        public void CrouchInput(bool newCrouchState)
        {
            crouch = newCrouchState;
        }
	

These methods are called by the corresponding input action methods to update the internal state of the character’s inputs.

Cursor Management

The script also manages the cursor state to lock or unlock it based on the game’s focus:


    private void OnApplicationFocus(bool hasFocus)
    {
        SetCursorState(cursorLocked);
	}

    private void SetCursorState(bool newState)
    {
        Cursor.lockState = newState ? CursorLockMode.Locked : CursorLockMode.None;
    }

When the application gains or loses focus, the cursor state is set accordingly, ensuring the cursor is locked (invisible and confined to the game window) or unlocked based on the cursorLocked variable.

Character

The First Person Controller (FPC) in Unity is a pre-built component that allows game developers to easily create first-person characters in their projects. This controller manages the character’s movement and camera to simulate the experience of moving through an environment from a first-person perspective, as seen in many shooter, exploration, and simulation games.

Player Movement

  • Movement: The FPC handles forward, backward, and lateral movement of the character in response to user inputs via the keyboard (W, A, S, D).
  • Jumping: The FPC allows the character to jump by pressing a dedicated key (usually the spacebar).
  • Sprinting: The controller can include a sprinting feature, temporarily increasing the character’s speed when the sprint key is held down.

Camera Control

  • Camera Rotation: The camera follows mouse movements, allowing a 360-degree free view. Horizontal rotation (yaw) turns the character, while vertical rotation (pitch) tilts the camera up or down.
  • View Limits: The FPC often includes limits to prevent the camera from flipping completely up or down, avoiding extreme and disorienting rotations
	
 private void Update()
 {
     //Debug.Log(GetComponent().devices[0].name);
     if (!_isDead)
     {
         Move();
         JumpAndGravity();
         ToggleCrouch();
     }
     GroundedCheck();
 }

 private void LateUpdate()
 {
     if (!_isDead && _input.look.sqrMagnitude >= _threshold)
     {
         CameraRotation();
     }
 }

 private void ToggleCrouch()
 {
     _isCrouching = _input.crouch;

     // Ajustez la hauteur du CharacterController en fonction de l'état de l'accroupissement
     if (_isCrouching)
     {
         _controller.height = CrouchHeight;
         // Peut-être jouer une animation d'accroupissement ici
     }
     else
     {
         _controller.height = StandHeight;
         // Peut-être jouer une animation de se relever ici
     }


 }

 private void GroundedCheck()
 {
     // set sphere position, with offset
     Vector3 spherePosition = new Vector3(transform.position.x, transform.position.y - GroundedOffset,
         transform.position.z);
     Grounded = Physics.CheckSphere(spherePosition, GroundedRadius, GroundLayers,
         QueryTriggerInteraction.Ignore);
 }

 private void CameraRotation()
 {
     // if there is an input
     if (_input.look.sqrMagnitude >= _threshold)
     {
         //Don't multiply mouse input by Time.deltaTime
         float deltaTimeMultiplier = IsCurrentDeviceMouse ? 1.0f : Time.deltaTime;

         _cinemachineTargetPitch += _input.look.y * RotationSpeed * deltaTimeMultiplier;
         _rotationVelocity = _input.look.x * RotationSpeed * deltaTimeMultiplier;

         // clamp our pitch rotation
         _cinemachineTargetPitch = ClampAngle(_cinemachineTargetPitch, BottomClamp, TopClamp);

         // Update Cinemachine camera target pitch
         CinemachineCameraTarget.transform.localRotation = Quaternion.Euler(_cinemachineTargetPitch, 0.0f, 0.0f);

         // rotate the player left and right
         transform.Rotate(Vector3.up * _rotationVelocity);
     }
 }

 private void Move()
 {
     // set target speed based on move speed, sprint speed and if sprint is pressed
     float targetSpeed = _isCrouching ? CrouchSpeed : (_input.sprint ? SprintSpeed : MoveSpeed);

     // a simplistic acceleration and deceleration designed to be easy to remove, replace, or iterate upon

     // note: Vector2's == operator uses approximation so is not floating point error prone, and is cheaper than magnitude
     // if there is no input, set the target speed to 0
     if (_input.move == Vector2.zero) targetSpeed = 0.0f;

     // a reference to the players current horizontal velocity
     float currentHorizontalSpeed = new Vector3(_controller.velocity.x, 0.0f, _controller.velocity.z).magnitude;

     float speedOffset = 0.1f;
     float inputMagnitude = _input.analogMovement ? _input.move.magnitude : 1f;

     // accelerate or decelerate to target speed
     if (currentHorizontalSpeed < targetSpeed - speedOffset ||
         currentHorizontalSpeed > targetSpeed + speedOffset)
     {
         // creates curved result rather than a linear one giving a more organic speed change
         // note T in Lerp is clamped, so we don't need to clamp our speed
         _speed = Mathf.Lerp(currentHorizontalSpeed, targetSpeed * inputMagnitude,
             Time.deltaTime * SpeedChangeRate);

         // round speed to 3 decimal places
         _speed = Mathf.Round(_speed * 1000f) / 1000f;
     }
     else
     {
         _speed = targetSpeed;
     }

     // normalise input direction
     Vector3 inputDirection = new Vector3(_input.move.x, 0.0f, _input.move.y).normalized;

     // note: Vector2's != operator uses approximation so is not floating point error prone, and is cheaper than magnitude
     // if there is a move input rotate player when the player is moving
     if (_input.move != Vector2.zero)
     {
         // move
         inputDirection = transform.right * _input.move.x + transform.forward * _input.move.y;
         if (_input.aim)
         {
             _state.currentState = _isCrouching ? Player_State.Move_Crouch : Player_State.Move_Stand;
         }
         else
         {
             _state.currentState = _isCrouching ? Player_State.Aim_Crouch_Move : Player_State.Aim_Stand_Move;
         }

     }
     else
     {
         if (_input.aim)
         {
             _state.currentState = _isCrouching ? Player_State.Aim_Crouch : Player_State.Aim_Stand;
         }
         else
         {
             _state.currentState = _isCrouching ? Player_State.Idle_Crouch : Player_State.Idle_Stand;
         }

     }


     _controller.Move(inputDirection.normalized * (_speed * Time.deltaTime) +
                      new Vector3(0.0f, _verticalVelocity, 0.0f) * Time.deltaTime);
 }

 private void JumpAndGravity()
 {

     if (Grounded)
     {

         // reset the fall timeout timer
         _fallTimeoutDelta = FallTimeout;

         // stop our velocity dropping infinitely when grounded
         if (_verticalVelocity < 0.0f)
         {
             _verticalVelocity = -2f;
         }

         // Jump
         if (_input.jump && _jumpTimeoutDelta <= 0.0f)
         {
             // the square root of H * -2 * G = how much velocity needed to reach desired height
             _verticalVelocity = Mathf.Sqrt(JumpHeight * -2f * Gravity);
         }

         // jump timeout
         if (_jumpTimeoutDelta >= 0.0f)
         {
             _jumpTimeoutDelta -= Time.deltaTime;
         }
     }
     else
     {
         // reset the jump timeout timer
         _jumpTimeoutDelta = JumpTimeout;

         // fall timeout
         if (_fallTimeoutDelta >= 0.0f)
         {
             _fallTimeoutDelta -= Time.deltaTime;
         }

         // if we are not grounded, do not jump
         _input.jump = false;
     }

     // apply gravity over time if under terminal (multiply by delta time twice to linearly speed up over time)
     if (_verticalVelocity < _terminalVelocity)
     {
         _verticalVelocity += Gravity * Time.deltaTime;
     }
 }

 private static float ClampAngle(float lfAngle, float lfMin, float lfMax)
 {
     if (lfAngle < -360f) lfAngle += 360f;
     if (lfAngle > 360f) lfAngle -= 360f;
     return Mathf.Clamp(lfAngle, lfMin, lfMax);
 }
	

Gun

Basic of the system gun

The script demonstrates how to implement a weapon system with shooting mechanics, aiming, reloading, and other features typically found in first-person shooter games. The Gun class handles all functionalities related to the weapon, such as shooting, reloading, aiming, and managing ammunition. Here’s a detailed explanation of the code.

Variables and Initialization

  • Weapon Settings: The script defines various public variables that control the behavior of the gun, such as recoil, aiming position, spread, speed, range, ammo count, reload time, fire rate, and damage.
  • References: It references other components like StarterAssetsInputs, Recoil, FirstPersonController, and UI elements like TMP_Text for displaying ammunition count.

Start Method

The Start method initializes some values, such as getting the player’s index and storing the original aiming position.

 
 void Start()
{
    indexPlayer = GetComponentInParent().GetIndexPlayer();
    _ammunation = ammunation;
    _aimingOriginalPosition = transform.localPosition;
}

 

Shooting Mechanism

The Shoot method checks if the player is attempting to shoot and if enough time has passed since the last shot. It handles reducing ammo, playing the recoil and muzzle flash, and calling RaycastShoot for each shot.


private void Shoot()
{
    if (input.shoot && Time.time > _lastShootTime + fireRate)
    {
        if (ammunation > 0 && _reloadCoroutine == null)
        {
            ammunation--;
            recoil.RecoilFire();
            muzzleFlash.Play();
            if (AudioManager.Instance != null)
            {
                AudioManager.Instance.PlayOneShot(shootSound);
            }

            for (int i = 0; i < repeatShoot; i++)
            {
                RaycastShoot();
            }
            _lastShootTime = Time.time;
        }
        else if (_reloadCoroutine == null)
        {
            _reloadCoroutine = StartCoroutine(Reload());
        }
    }
}


Raycasting for Shooting

The RaycastShoot method performs a raycast to detect what the bullet hits. It applies a spread to the shooting direction, creates bullet trails, and handles impact effects and damage to other players.


private void RaycastShoot()
{
    RaycastHit hit;
    Vector3 shootDirection = muzzleWeapon.right + new Vector3(Random.Range(-spreadShoot.x, spreadShoot.x), Random.Range(-spreadShoot.y, spreadShoot.y), Random.Range(-spreadShoot.z, spreadShoot.z));
    if (Physics.Raycast(muzzleWeapon.position, shootDirection, out hit, rangeShoot, layerMask))
    {
        StartBulletTrail(hit.point, hit.normal);
        if (hit.collider.gameObject.layer == LayerMask.NameToLayer("Character"))
        {
            FirstPersonController playerHit = hit.collider.GetComponent();
            if (playerHit != null && playerHit is IHitPlayer)
            {
                playerHit.OnHitPlayer(indexPlayer, damagePerBullet);
            }
        }
        else
        {
            ImpactPool.Instance.SpawnImpact(hit.point, hit.normal);
        }
    }
    else
    {
        StartBulletTrail(endTarget.position);
    }
}


Aim

The Aim method smoothly transitions the weapon’s position between the original and aiming positions based on the player’s input.


private void Aim()
{
    transform.localPosition = Vector3.Lerp(transform.localPosition, input.aim ? aimingPosition : _aimingOriginalPosition, Time.deltaTime * speedAiming);
}


Bullet Trail

The StartBulletTrail and SpawnTrail methods create a visual trail for the bullets, enhancing the shooting effect.


private void StartBulletTrail(Vector3 HitPoint, Vector3 HitNormal = default(Vector3))
{
    TrailRenderer trail = Instantiate(BulletTrail, muzzleWeapon.position, Quaternion.identity);
    StartCoroutine(SpawnTrail(trail, HitPoint, HitNormal, false));
}

private IEnumerator SpawnTrail(TrailRenderer Trail, Vector3 HitPoint, Vector3 HitNormal, bool MadeImpact)
{
    Vector3 startPosition = Trail.transform.position;
    float distance = Vector3.Distance(Trail.transform.position, HitPoint);
    float remainingDistance = distance;

    while (remainingDistance > 0)
    {
        Trail.transform.position = Vector3.Lerp(startPosition, HitPoint, 1 - (remainingDistance / distance));
        remainingDistance -= bulletSpeed * Time.deltaTime;
        yield return null;
    }
    Trail.transform.position = HitPoint;
    Destroy(Trail.gameObject, Trail.time);
}


Reloading

The ReloadPress method checks if the player presses the reload button and initiates the reloading coroutine if necessary.


private void ReloadPress()
{
    if (input.reload && _reloadCoroutine == null)
    {
        _reloadCoroutine = StartCoroutine(Reload());
    }
}


The Reload coroutine waits for a specified duration and then refills the ammo.


IEnumerator Reload()
{
    yield return new WaitForSeconds(timeToReload);
    ammunation = _ammunation;
    _reloadCoroutine = null;
}

This Gun script is a comprehensive implementation of a weapon system for a first-person shooter. It handles player input, shooting mechanics, reloading, aiming, and visual effects, showcasing a robust and flexible approach to creating immersive weapon behavior in Unity. This script highlights my ability to implement complex gameplay mechanics and integrate various Unity systems to create a cohesive and interactive experience.

Impact pool

The provided script is an object pool manager for impacts in a Unity game. Object pooling is a technique used to reuse a set number of objects instead of constantly creating and destroying them, which can be performance-intensive. Here’s a detailed explanation of the script:

Initialize Pool


void InitializePool()
{
    impactPool = new Queue();
    for (int i = 0; i < poolSize; i++)
    {
        GameObject impact = Instantiate(impactPrefab);
        impact.SetActive(false);
        impactPool.Enqueue(impact);
    }
}

  • Initializes impactPool as a queue.
  • Creates poolSize impact objects, deactivates them, and adds them to the queue.

Spawn Impact


public void SpawnImpact(Vector3 position, Vector3 normal)
{
    if (impactPool.Count == 0)
    {
        Debug.LogWarning("Impact pool is empty! Consider increasing the pool size.");
        return;
    }

    GameObject impact = impactPool.Dequeue();
    impact.transform.position = position + (normal * .01f);
    impact.transform.rotation = Quaternion.FromToRotation(Vector3.up, normal);
    impact.SetActive(true);

    StartCoroutine(ReturnImpactToPool(impact));
}


  • Checks if the pool is empty. If it is, it logs a warning and returns.
  • Dequeues an object from the pool, positions it, and rotates it according to the provided parameters.
  • Activates the object to make it visible/active in the game.
  • Starts a coroutine to return the object to the pool after a certain delay.

Return Impact to pool


IEnumerator ReturnImpactToPool(GameObject impact)
{
    yield return new WaitForSeconds(2f); // Impact's lifetime

    impact.SetActive(false);
    impactPool.Enqueue(impact);
}

  • Waits for 2 seconds (WaitForSeconds(2f)), which corresponds to the time the impact remains active.
  • Deactivates the object and enqueues it back into the pool.

This script efficiently manages an object pool for impacts, minimizing the cost of creating and destroying objects during gameplay. By using this pool, the game’s performance can be significantly improved, especially in scenarios where impacts need to be frequently generated.

Recoil

This script manages the recoil of a weapon by adjusting the rotation of the weapon or camera in response to firing and gradually returning it to a neutral position. The recoil is applied realistically with interpolation for smooth movements and clamps to prevent excessive rotations.

Update

The Update method is called once per frame.

  • isAiming: Updates the aiming state by calling the IsAiming method of the gun instance.
  • targetRotation: Linearly interpolates (using Vector3.Lerp) the target rotation towards the zero vector, simulating the return to a no-recoil position.
  • currentRotation: Spherically interpolates (using Vector3.Slerp) the current rotation towards the target rotation, creating a smooth recoil movement effect.
  • transform.localRotation: Applies the calculated rotation to the object’s transform.

void Update()
{
    isAiming = gun.IsAiming();
    targetRotation = Vector3.Lerp(targetRotation, Vector3.zero, returnSpeed * Time.deltaTime);
    currentRotation = Vector3.Slerp(currentRotation, targetRotation, snappiness * Time.fixedDeltaTime);
    transform.localRotation = Quaternion.Euler(currentRotation);
}



Recoil Fire

This method is called to apply recoil when firing the weapon.

  • recoilVector: Gets the current recoil vector from playerState.
  • recoilAmount: Calculates a random recoil vector based on recoilVector and a recoil multiplier from the weapon.
  • currentRotation: Adds the calculated recoil to the current rotation.
  • ClampRotation: Calls a method to ensure the rotation does not exceed certain limits.
  • targetRotation: Updates the target rotation with the current rotation after applying recoil.

public void RecoilFire()
{
    Vector3 recoilVector = playerState.GetCurrentRecoil();
    Vector3 recoilAmount = new Vector3(Random.Range(-recoilVector.x, recoilVector.x) * gun.weaponRecoilMultiplier,
                                       Random.Range(-recoilVector.y, recoilVector.y) * gun.weaponRecoilMultiplier,
                                       0);
    currentRotation += recoilAmount;
    currentRotation = ClampRotation(currentRotation);
    targetRotation = currentRotation;
}


Clamp Rotation

This method ensures that the rotations are limited to certain values to avoid unrealistic movements.

  • Clamp on X: Limits the rotation on the X axis between -90 and 90 degrees.
  • Clamp on Y: Limits the rotation on the Y axis between -360 and 360 degrees.
  • Z axis: Forces the rotation on the Z axis to remain at 0.

private Vector3 ClampRotation(Vector3 rotation)
{
    rotation.x = Mathf.Clamp(rotation.x, -90f, 90f); 
    rotation.y = Mathf.Clamp(rotation.y, -360f, 360f); 
    rotation.z = 0f; 
    return rotation;
}


Idea to improve the project

  • Convert the Gun script from MonoBehaviopur to a ScriptableObject, it will allow to create more weapons
  • Convert the Scope to an Animation