Exercise: playable with one thumb
Assets/
Scenes/
Arena.unity
Scripts/
Settings/
everything from tab 01–03
you already havethis lesson adds
Give the Arena a control rig that works on keyboard, gamepad and touch from one single action map — with a third-person camera that never clips through walls, never jitters, shakes on impact, and cuts to a dramatic angle for the ultimate.
what you reuse
Three devices, one action map, one camera rig.
Part 1 — one action map for everything
Every action gets bindings for all three devices at once. If you find yourself writing if (touch) … else if (keyboard) …, stop — that is exactly what the action map exists to prevent.
Map: Gameplay
Move Value / Vector2
├─ 2D Vector Composite W A S D
├─ Gamepad <Gamepad>/leftStick
└─ On-Screen Stick (bound from the UI prefab)
Look Value / Vector2
├─ Pointer delta <Mouse>/delta
├─ Gamepad <Gamepad>/rightStick
└─ Touch drag (right half of the screen)
Jump Button
├─ <Keyboard>/space ├─ <Gamepad>/buttonSouth └─ On-Screen Button
Skill1 / Skill2 / Skill3 Button
├─ Q E R ├─ West / North / buttonEast └─ On-Screen Buttons
Pause Button
├─ <Keyboard>/escape └─ <Gamepad>/start
Map: UI ← enabled only while paused
Navigate · Submit · Cancelusing UnityEngine;
using UnityEngine.InputSystem;
public class InputRouter : MonoBehaviour
{
private PlayerControls controls;
public Vector2 Move { get; private set; }
public Vector2 Look { get; private set; }
public bool JumpQueued { get; private set; }
[SerializeField] private SkillCaster caster;
void Awake()
{
controls = new PlayerControls();
var g = controls.Gameplay;
g.Move.performed += c => Move = c.ReadValue<Vector2>();
g.Move.canceled += _ => Move = Vector2.zero;
g.Look.performed += c => Look = c.ReadValue<Vector2>();
g.Look.canceled += _ => Look = Vector2.zero;
g.Jump.performed += _ => JumpQueued = true;
g.Skill1.performed += _ => caster.TryCast(0);
g.Skill2.performed += _ => caster.TryCast(1);
g.Skill3.performed += _ => caster.TryCast(2);
g.Pause.performed += _ => TogglePause();
}
void OnEnable() => controls.Enable();
void OnDisable() => controls.Disable();
void LateUpdate() => JumpQueued = false;
void TogglePause()
{
bool paused = Time.timeScale > 0f;
Time.timeScale = paused ? 0f : 1f;
// swap which map is live
if (paused) { controls.Gameplay.Disable(); controls.UI.Enable(); }
else { controls.UI.Disable(); controls.Gameplay.Enable(); }
Cursor.lockState = paused ? CursorLockMode.None : CursorLockMode.Locked;
Cursor.visible = paused;
}
}Part 2 — the camera rig
A Cinemachine camera with Third Person Follow, Follow = Knight, LookAt = CameraTarget at chest height.
Camera Collision Filter set to your Environment layer, so it pulls in at walls instead of clipping.
Damping 0.3 on all three axes — enough to feel weighty, not enough to feel drunk.
A second camera for the ultimate, Priority 0 normally, raised to 20 for 1.5 s when R fires.
An Impulse Listener on both cameras, an Impulse Source on the player, fired on every hit.
using System.Collections;
using UnityEngine;
using Unity.Cinemachine;
public class CameraDirector : MonoBehaviour
{
[SerializeField] private CinemachineCamera gameplayCam;
[SerializeField] private CinemachineCamera ultimateCam;
[SerializeField] private CinemachineImpulseSource impulse;
/// call from SkillCaster when a hit lands
public void Shake(float strength = 0.35f) => impulse.GenerateImpulseWithForce(strength);
/// call when the ultimate starts
public void PlayUltimateShot(float seconds = 1.5f) => StartCoroutine(Cut(seconds));
IEnumerator Cut(float seconds)
{
ultimateCam.Priority = 20;
yield return new WaitForSeconds(seconds);
ultimateCam.Priority = 0;
}
}Part 3 — the touch layer
A Canvas set to Scale With Screen Size, reference 1080 × 1920, Match 0.5.
Bottom-left: On-Screen Stick bound to Move. Bottom-right: three skill buttons and jump.
SafeAreaFitter on the root panel, and every control anchored to a corner, never the centre.
The whole touch canvas is disabled on desktop — check Application.isMobilePlatform in Awake.
void Awake()
{
bool mobile = Application.isMobilePlatform;
#if UNITY_EDITOR
mobile = forceTouchInEditor; // a bool you tick to test on desktop
#endif
touchCanvas.SetActive(mobile);
}Done when
- ✓The same build responds to keyboard, a plugged-in gamepad, and on-screen touch controls with no code branches.
- ✓Pressing W always walks away from the camera, at every camera angle.
- ✓Walking the character into a wall pulls the camera in smoothly instead of showing the inside of the wall.
- ✓The character never vibrates while moving — camera logic is in LateUpdate.
- ✓Every landed hit shakes the camera slightly; the shake ends inside 0.25 s.
- ✓The ultimate cuts to a second camera and blends back automatically.
- ✓Pressing Esc pauses, frees the cursor, and gameplay input stops — the character does not keep walking.
- ✓There is no Input.GetAxis or Input.GetKey left anywhere in the project.
Numbers to hit
| Metric | Target | Why |
|---|---|---|
| Camera damping | 0.2–0.4 s | Below 0.2 it snaps, above 0.5 the player loses the character. |
| Shake duration | ≤ 0.25 s | Longer reads as a bug and makes people motion sick on mobile. |
| Pitch clamp | -25° … 65° | Outside that range the camera flips over the head. |
| Touch target size | ≥ 44 pt | Smaller and thumbs miss constantly on a real phone. |
If you want more
Add a Sensitivity slider and an Invert Y toggle that persist between runs.
Detect the active device and swap the on-screen key hints between Q/E/R and gamepad glyphs.
Add a lock-on camera: click the middle mouse button to frame the nearest enemy. (Tab already cycles characters.)
Zoom the camera in when the player is close to a wall, using a Cinemachine extension.