Source file unity-csharp/arcade-classics/robotron-lite/TwinStickPlayer.cs from the GDnD code examples. Download raw file

// GDnD wiki example
// Demonstrates: twin-stick control — move on one set of keys, aim/fire on another — plus respawn invulnerability
// Related pages: [[overview-arcade-classics-as-learning-projects]], [[unity-input]], [[unity-input-system]]
 
using UnityEngine;
 
// The defining idea of Robotron: movement and aiming are independent. WASD moves
// the character; the arrow keys fire in their own direction (with the mouse as a
// fallback aim). That decoupling is what twin-stick means — see [[unity-input-system]]
// for doing the same with a gamepad's two analogue sticks.
public class TwinStickPlayer : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 7f;
 
    [Header("Firing")]
    [SerializeField] private Bullet bulletPrefab;
    [SerializeField] private float bulletSpeed = 14f;
    [SerializeField] private float fireCooldown = 0.12f;
    [SerializeField] private LayerMask enemyMask;
 
    private RobotronGame game;
    private Camera cachedCamera;
    private float cooldownTimer;
    private float invulnerableTimer;
    private float halfSize;
 
    public bool IsInvulnerable => invulnerableTimer > 0f;
 
    private void Awake()
    {
        cachedCamera = Camera.main;
        SpriteRenderer sprite = GetComponent<SpriteRenderer>();
        halfSize = sprite != null ? sprite.bounds.extents.x : 0.25f;
    }
 
    public void Bind(RobotronGame owner)
    {
        game = owner;
    }
 
    public void Respawn(float invulnerableSeconds)
    {
        transform.position = Vector3.zero;
        invulnerableTimer = invulnerableSeconds;
    }
 
    private void Update()
    {
        if (invulnerableTimer > 0f)
        {
            invulnerableTimer -= Time.deltaTime;
        }
 
        Move();
        AimAndFire();
    }
 
    private void Move()
    {
        // Movement keys only — deliberately NOT the arrow keys.
        Vector2 move = Vector2.zero;
        if (Input.GetKey(KeyCode.W)) move.y += 1f;
        if (Input.GetKey(KeyCode.S)) move.y -= 1f;
        if (Input.GetKey(KeyCode.D)) move.x += 1f;
        if (Input.GetKey(KeyCode.A)) move.x -= 1f;
 
        Vector3 position = transform.position + (Vector3)(move.normalized * (moveSpeed * Time.deltaTime));
 
        float halfHeight = cachedCamera.orthographicSize - halfSize;
        float halfWidth = cachedCamera.orthographicSize * cachedCamera.aspect - halfSize;
        position.x = Mathf.Clamp(position.x, -halfWidth, halfWidth);
        position.y = Mathf.Clamp(position.y, -halfHeight, halfHeight);
        transform.position = position;
    }
 
    private void AimAndFire()
    {
        cooldownTimer -= Time.deltaTime;
 
        Vector2 aim = ReadAimDirection();
        if (aim == Vector2.zero || cooldownTimer > 0f || bulletPrefab == null)
        {
            return;
        }
 
        cooldownTimer = fireCooldown;
        Bullet bullet = Instantiate(bulletPrefab, transform.position, Quaternion.identity);
        bullet.Launch(aim, bulletSpeed, enemyMask);
    }
 
    private Vector2 ReadAimDirection()
    {
        // Primary: arrow keys form the second "stick".
        Vector2 aim = Vector2.zero;
        if (Input.GetKey(KeyCode.UpArrow)) aim.y += 1f;
        if (Input.GetKey(KeyCode.DownArrow)) aim.y -= 1f;
        if (Input.GetKey(KeyCode.RightArrow)) aim.x += 1f;
        if (Input.GetKey(KeyCode.LeftArrow)) aim.x -= 1f;
 
        if (aim != Vector2.zero)
        {
            return aim.normalized;
        }
 
        // Fallback: hold the mouse button to fire toward the cursor.
        if (Input.GetMouseButton(0))
        {
            Vector3 mouse = cachedCamera.ScreenToWorldPoint(Input.mousePosition);
            return ((Vector2)(mouse - transform.position)).normalized;
        }
 
        return Vector2.zero;
    }
}