The Isometric Camera – How to Spy on Your Survivors Without Shame

The Isometric Camera – How to Spy on Your Survivors Without Shame

The Need: A Leader's Watchful Eye

In Refuge, the player takes on the role of a leader managing a small community of survivors hiding in an underground shelter after a devastating zombie outbreak. Managing the shelter, its inhabitants, and resources needs to be clear, intuitive, and enjoyable. And to achieve that, I needed the perfect camera setup.

After testing multiple approaches, an isometric view seemed like the ideal choice—offering great visibility over the shelter while keeping the gameplay immersive. It also gave me that nostalgic vibe from classic management games we all grew up with (ah, the good old days!). So, I needed a camera that could zoom, rotate, and move smoothly, allowing players to keep a close eye on their not-always-cooperative survivors.

0:00
/0:29

Introducing the Component: The Isometric Camera, Your Slightly Nosy Best Friend

The CameraIsometricController class was created to meet these needs. It manages an isometric camera that revolves around a central pivot. Its main features include:

  • Moving horizontally across the scene using keyboard inputs.
  • Zooming in and out for detailed inspections or a broader view.
  • Rotating around a focal point for easier navigation and a cinematic feel.
  • Smooth panning using the mouse for quick adjustments.

All while being fluid, responsive, and comfortable—because let’s be honest, a bad camera can be scarier than a zombie horde!

Under the Hood: A Breakdown of the Code

The CameraIsometricController script extends MonoBehaviour and leverages Unity’s Input System to capture player inputs efficiently.

Key Functions

  • Awake & OnEnable/OnDisable: Handles initialization and enables keyboard/mouse inputs via Unity Input System.
  • Update(): The heart of the component—constantly checking user inputs and calling dedicated movement, zoom, rotation, and panning methods.

Core Methods:

  • HandleKeyboardMovement(): Allows intuitive horizontal movement using WASD/ZQSD keys, relative to the pivot’s orientation.
  • HandleMouseZoom(): Manages smooth zooming with defined limits—no accidental space travel or trips to the Earth's core!
  • HandleMouseRotation(): Enables rotation around the pivot when the right mouse button is held, ensuring a comfortable and immersive navigation experience.
  • HandleMousePanning(): Lets players smoothly pan across the scene using the middle mouse button—perfect for keeping an eye on every corner of the shelter.

Bonus Feature: MouseInScreen()

A clever little function ensuring that your camera doesn’t go haywire when your cursor decides to take a spontaneous vacation outside the game window.

Dive into the Code

using UnityEngine;
using UnityEngine.InputSystem;

namespace Valcriss.Camera.Scripts
{
    [RequireComponent(typeof(UnityEngine.Camera))]
    public class CameraIsometricController : MonoBehaviour
    {
        [Header("Links")]
        [SerializeField] private Transform cameraTransform;
        [SerializeField] private Transform pivot;
        [Header("Camera Settings")]
        [SerializeField] private float moveSpeed = 20f;
        [SerializeField] private float panSpeed = 20f;
        [SerializeField] private float zoomSpeed = 100f;
        [SerializeField] private float rotationSpeed = 100f;
        [SerializeField] private float minZoom = 5f;
        [SerializeField] private float maxZoom = 20f;

        private InputActions inputActions;
        private Vector2 moveInput;
        private Vector2 lookInput;
        private float zoomInput;
        private bool isRotating;
        private bool isPanning;

        private void Awake()
        {
            inputActions = new InputActions();
        }

        private void OnEnable()
        {
            inputActions.Enable();

            inputActions.Camera.Move.performed += ctx => moveInput = ctx.ReadValue<Vector2>();
            inputActions.Camera.Move.canceled += ctx => moveInput = Vector2.zero;

            inputActions.Camera.Look.performed += ctx => lookInput = ctx.ReadValue<Vector2>();
            inputActions.Camera.Look.canceled += ctx => lookInput = Vector2.zero;

            inputActions.Camera.Zoom.performed += ctx => zoomInput = ctx.ReadValue<Vector2>().y;
            inputActions.Camera.Zoom.canceled += ctx => zoomInput = 0;
        }

        private void OnDisable()
        {
            inputActions.Disable();
        }

        private void Update()
        {
            HandleKeyboardMovement();
            if(!MouseInScreen()) return;
            HandleMouseZoom();
            HandleMouseRotation();
            HandleMousePanning();
        }

        private bool MouseInScreen()
        {
            Vector3 mousePos = Input.mousePosition;
            Rect safeArea = Screen.safeArea;

            return safeArea.Contains(mousePos);
        }

        private void HandleKeyboardMovement()
        {
            Vector3 moveDirection = new(moveInput.x, 0, moveInput.y);
            Vector3 move = Quaternion.Euler(0, pivot.eulerAngles.y, 0) * moveDirection;
            pivot.position += move * (moveSpeed * Time.deltaTime);
        }

        private void HandleMouseZoom()
        {
            float zoomAmount = zoomInput * zoomSpeed * Time.deltaTime;
            Vector3 newLocalPosition = cameraTransform.localPosition + new Vector3(0, -zoomAmount, zoomAmount);

            float distance = newLocalPosition.magnitude;
            if (distance >= minZoom && distance <= maxZoom)
            {
                cameraTransform.localPosition = newLocalPosition;
            }
        }

        private void HandleMousePanning()
        {
            if (!isPanning) return;
            Vector3 pan = new Vector3(-lookInput.x, 0, -lookInput.y) * (panSpeed * Time.deltaTime);
            pivot.position += Quaternion.Euler(0, pivot.eulerAngles.y, 0) * pan;
        }

        private void HandleMouseRotation()
        {
            if (!isRotating) return;
            float rotateY = lookInput.x * rotationSpeed * Time.deltaTime;
            pivot.Rotate(Vector3.up, rotateY);
        }

        private void LateUpdate()
        {
            isRotating = Mouse.current.rightButton.isPressed;
            isPanning = Mouse.current.middleButton.isPressed;
        }
    }
}

Conclusion: A Camera That Knows No Bounds (But Should)

With this well-designed camera component, managing your survivors becomes an oddly satisfying experience. It responds swiftly and effortlessly, letting you monitor your survivors' every move—even their little arguments. In short, it’s the perfect tool for an aspiring post-apocalyptic leader, keeping your people (and their secrets) under close watch!


Le besoin : L'œil vigilant du leader

Dans Refuge, le joueur endosse le rôle de leader d'une petite communauté de survivants réfugiés dans un sous-sol après une terrible épidémie zombie. La gestion du refuge, de ses habitants et des ressources doit être claire, intuitive et agréable. Et pour y parvenir, il me fallait une caméra adaptée.

Après avoir testé plusieurs solutions, une vue isométrique m'a semblé idéale pour offrir à la fois une bonne lisibilité du refuge et une immersion agréable, rappelant ces classiques jeux de gestion qui berçaient nos enfances (Ah, nostalgie !). J'avais donc besoin d'une caméra capable de zoomer, pivoter, et se déplacer fluidement autour de mes petits protégés, qui parfois méritent quand même une petite surveillance rapprochée.

0:00
/0:29

Présentation du composant : La caméra isométrique, votre meilleure amie un peu intrusive

La classe CameraIsometricController est née pour satisfaire ces exigences. Elle contrôle une caméra isométrique en s’articulant principalement autour d’un « pivot », point central de toute observation stratégique. Ses principales fonctionnalités :

  • Se déplacer horizontalement selon les entrées clavier.
  • Zoomer et dézoomer pour observer chaque détail ou avoir une vue d'ensemble.
  • Pivoter autour d'un point précis pour gérer facilement les espaces du refuge.
  • Panoramiquer en douceur grâce à la souris.

Tout ça dans le confort d'une fluidité maximale, parce que oui, une caméra mal optimisée, ça donne la nausée plus vite qu'une attaque de zombies !

Sous le capot : Le code expliqué pas à pas

Le script CameraIsometricController hérite de MonoBehaviour et utilise Unity Input System pour capter les actions du joueur.

Fonctions clés

  • Awake & OnEnable / OnDisable : Gestion de l'initialisation et activation des contrôles clavier/souris avec Unity Input System.
  • Update() : Vérifie continuellement les entrées utilisateur et appelle les méthodes dédiées au mouvement, zoom, rotation et panoramique.

Méthodes principales :

  • HandleKeyboardMovement() : Permet un déplacement intuitif sur les axes horizontaux (WASD/ZQSD), en respectant l'orientation du pivot.
  • HandleMouseZoom() : Offre une gestion maîtrisée du zoom, avec des limites précises pour éviter les voyages spatiaux imprévus ou les visites au centre de la Terre.
  • HandleMouseRotation() : Active une rotation autour du pivot quand le joueur garde le bouton droit de la souris enfoncé, garantissant une vue agréable et immersive.
  • HandleMousePanning() : Permet un déplacement latéral fluide en maintenant le bouton central, pratique pour scruter chaque recoin du refuge.

Bonus technique : MouseInScreen()

Petite méthode maligne qui évite que ta caméra s'emballe lorsque ton curseur décide de partir en vadrouille hors de l'écran de jeu.

Plongée dans le code

using UnityEngine;
using UnityEngine.InputSystem;

namespace Valcriss.Camera.Scripts
{
    [RequireComponent(typeof(UnityEngine.Camera))]
    public class CameraIsometricController : MonoBehaviour
    {
        [Header("Links")]
        [SerializeField] private Transform cameraTransform;
        [SerializeField] private Transform pivot;
        [Header("Camera Settings")]
        [SerializeField] private float moveSpeed = 20f;
        [SerializeField] private float panSpeed = 20f;
        [SerializeField] private float zoomSpeed = 100f;
        [SerializeField] private float rotationSpeed = 100f;
        [SerializeField] private float minZoom = 5f;
        [SerializeField] private float maxZoom = 20f;

        private InputActions inputActions;
        private Vector2 moveInput;
        private Vector2 lookInput;
        private float zoomInput;
        private bool isRotating;
        private bool isPanning;

        private void Awake()
        {
            inputActions = new InputActions();
        }

        private void OnEnable()
        {
            inputActions.Enable();

            inputActions.Camera.Move.performed += ctx => moveInput = ctx.ReadValue<Vector2>();
            inputActions.Camera.Move.canceled += ctx => moveInput = Vector2.zero;

            inputActions.Camera.Look.performed += ctx => lookInput = ctx.ReadValue<Vector2>();
            inputActions.Camera.Look.canceled += ctx => lookInput = Vector2.zero;

            inputActions.Camera.Zoom.performed += ctx => zoomInput = ctx.ReadValue<Vector2>().y;
            inputActions.Camera.Zoom.canceled += ctx => zoomInput = 0;
        }

        private void OnDisable()
        {
            inputActions.Disable();
        }

        private void Update()
        {
            HandleKeyboardMovement();
            if(!MouseInScreen()) return;
            HandleMouseZoom();
            HandleMouseRotation();
            HandleMousePanning();
        }

        private bool MouseInScreen()
        {
            Vector3 mousePos = Input.mousePosition;
            Rect safeArea = Screen.safeArea;

            return safeArea.Contains(mousePos);
        }

        private void HandleKeyboardMovement()
        {
            Vector3 moveDirection = new(moveInput.x, 0, moveInput.y);
            Vector3 move = Quaternion.Euler(0, pivot.eulerAngles.y, 0) * moveDirection;
            pivot.position += move * (moveSpeed * Time.deltaTime);
        }

        private void HandleMouseZoom()
        {
            float zoomAmount = zoomInput * zoomSpeed * Time.deltaTime;
            Vector3 newLocalPosition = cameraTransform.localPosition + new Vector3(0, -zoomAmount, zoomAmount);

            float distance = newLocalPosition.magnitude;
            if (distance >= minZoom && distance <= maxZoom)
            {
                cameraTransform.localPosition = newLocalPosition;
            }
        }

        private void HandleMousePanning()
        {
            if (!isPanning) return;
            Vector3 pan = new Vector3(-lookInput.x, 0, -lookInput.y) * (panSpeed * Time.deltaTime);
            pivot.position += Quaternion.Euler(0, pivot.eulerAngles.y, 0) * pan;
        }

        private void HandleMouseRotation()
        {
            if (!isRotating) return;
            float rotateY = lookInput.x * rotationSpeed * Time.deltaTime;
            pivot.Rotate(Vector3.up, rotateY);
        }

        private void LateUpdate()
        {
            isRotating = Mouse.current.rightButton.isPressed;
            isPanning = Mouse.current.middleButton.isPressed;
        }
    }
}

Conclusion : Une caméra aux petits oignons

Grâce à ce composant, gérer tes survivants devient presque un plaisir coupable. La caméra répond aux doigts et à l'œil, prête à espionner tes survivants dans leurs moindres gestes et parfois même leurs petites disputes. Bref, une caméra à la hauteur de ton ambition de leader post-apocalyptique, pour surveiller sans complexe tes protégés préférés !