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;
}
}