TwinStick

A fast-paced 3D twin-stick shooter focused on precision movement and tactical combat.

About the Project

This school project was a collaboration with two game artists and another developer. The objective was to design and implement a 3D twin-stick shooter from the ground up, balancing fast action with smooth controls.

Player Mechanics

The player's movement is built using Unity's Rigidbody system for physical interactions and the new Input System for responsive, cross-platform control.

using UnityEngine;
using UnityEngine.InputSystem;

[SelectionBase]
public class PlayerMovement : MonoBehaviour
{
    private Rigidbody _Rigidbody;
    private InputAction _MoveAction;
    private PlayerInput _PlayerInput;

    [SerializeField] private float _PlayerSpeed;

    private void Start()
    {      
        _PlayerInput = GetComponent();
        _MoveAction = _PlayerInput.actions.FindAction("Move");

        _Rigidbody = GetComponent();
    }

    private void Update()
    {
        CalculateMovement();
    }

    private void CalculateMovement()
    {
        Vector2 direction = _MoveAction.ReadValue().normalized;

        Vector3 movement = new Vector3(direction.x, 0f, direction.y);

        _Rigidbody.velocity = movement * _PlayerSpeed;
    }
}

Handling collectibles like coins involves an OnTriggerEnter approach that communicates with the GameManager to update the player's score.

using UnityEngine;

public class CollectibleHandler : MonoBehaviour
{
    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Coin"))
        {
            GameManager.Instance.GetPointValue(other.GetComponent().GetPoints());
            Destroy(other.gameObject); 
        }
    }
}

Enemy AI

Enemy AI movement demonstration

Enemy movement is powered by Unity's NavMesh system. An EnemyManager dictates when enemies are active and allows for centralized control over the battlefield's difficulty.

The enemies use a state machine to switch between patrolling and chasing the player based on their line of sight.

public enum EnemyStates
{
    patroling,
    playerInSight,
}

private void Update()
{
    if (enemyStates == EnemyStates.patroling)
    {
        Patroling();
        if (sightOfEnemy.visibleTargets.Count > 0)
        {
            enemyStates = EnemyStates.playerInSight;
        }
    }

    if (enemyStates == EnemyStates.playerInSight)
    {
        if (sightOfEnemy.visibleTargets.Count == 0)
        {
             agent.SetDestination(transform.position);
             enemyStates = EnemyStates.patroling;
        }
        ChasePlayer();
    }
}

public void Patroling()
{
    agent.stoppingDistance = 0;
    if (agent.remainingDistance <= 1)
    {
        int prevIndex = currentIndex;
        if (walkingPointRandom)
        {
            currentIndex = Random.Range(0, fixedLocation.Count);
            if (currentIndex == prevIndex)
            {
                currentIndex++;
            }
        }
        else currentIndex++;

        currentIndex %= fixedLocation.Count;

        agent.SetDestination(fixedLocation[currentIndex].position);
    }
}

public void ChasePlayer()
{
    if (player == null)
        return;

    agent.stoppingDistance = 4;
    agent.SetDestination(player.transform.position);
    
    Quaternion lookRotation = Quaternion.LookRotation(player.transform.position - transform.position);
    transform.rotation = Quaternion.Lerp(transform.rotation, lookRotation, Time.deltaTime * 4);

    if (agent.remainingDistance < 4)
    {
        if (!attackedAlready)
        {
            Vector3 adjustedPlayerPos = new Vector3(player.transform.position.x, shootingPoint.position.y, player.transform.position.z);
            Vector3 shootingDirection = (adjustedPlayerPos - shootingPoint.position).normalized;
            
            Rigidbody enemyRigidBody = Instantiate(projectile, shootingPoint.position, Quaternion.identity).GetComponent();
            enemyRigidBody.AddForce(shootingDirection * 12f, ForceMode.Impulse);
            attackedAlready = true;
        }
        else
        {
            timeInterval += Time.deltaTime;
            if (timeInterval > timeBetweenNextAttack)
            {
                attackedAlready = false;
                timeInterval = 0;
            }
        }
    }
}

While a more decentralized "EnemyBrain" script might be preferred for larger projects, this approach was highly efficient for the project's timeline and allowed for quick iteration on enemy behaviors.