Table of Contents

1. Recapitulare: Sisteme Split-Screen și Arhitecturi de Input Extensibile

Cerință : Joc Split-Screen Time Trial

Cerințe minime

  1. Două camere active simultan (split-screen orizontal sau vertical).
  2. Două entități controlabile independent (ex: mașini, personaje).
  3. Sistem de input separat pentru fiecare jucător (keyboard + controller sau două controllere).
  4. Interfață UI distinctă per jucător (timer, vitezometru, viață etc).
  5. Sistem de Replay / Ghost Car folosind patternul `Command`.
  6. Observer – pentru actualizarea automată a UI-ului în funcție de starea personajului;

Cerințe opționale (bonus)


Scopul laboratorului

În acest laborator, studenții vor recapitula noțiuni esențiale de dezvoltare a jocurilor multiplayer locale (split-screen), cu accent pe gestionarea camerelor, viewport-urilor și sistemelor de input independente. De asemenea, se va introduce conceptul de arhitectură extensibilă prin utilizarea unor design patterns fundamentale în dezvoltarea jocurilor video.


Obiective


Noțiuni teoretice

Exista mai multe metode de a face un joc controlabil de mai multi jucatori in acelasi timp:

1. Camere și Viewport

Pentru jocuri locale de multiplayer se foloseste in general o tehnica de split-screen: folosirea mai multor camere care se afiseaza impart viewport-ul intantei locale a jocului https://docs.unity3d.com/ScriptReference/Camera-rect.html.

Astfel, pentru un split-screen vertical se pot folosi doua camere cu valorile urmatoare pentru ViewPort Rect (x, y, width, height)

Camera 1 : (0, 0, 0.5, 1) ► left
Camera 2 : (0.5, 0, 0.5, 1) ► right

iar pentru pentru un split-screen orizontal

Camera 1 : (0, 0.5, 1, 0.5) ► top
Camera 2: (0, 0, 1, 0.5) ► bottom

Ex.: `Camera.rect = new Rect(0f, 0.5f, 1f, 0.5f)` pentru ecran divizat orizontal.

2. GUI adaptiv per jucător

Daca se doreste implementarea unui GUI personalizat/diferit pentru fiecare jucator, se pot folosi masti de camera (culling masks). Se vor plasa toate obiecte Jucatorului 1 intr-un layer separat (de ex Player1Layer), si obiectele jucatorului 2 in alt layer (Player2Layer) si se va asigura ca pentru fiecare camera sunt selectate layer-urile corecte (se vor exclude pentru camera 1 layer-ul cu Player2, si pentru camera 2 layer-ul cu Player 1).

3. Sisteme de input

Sistemul de input legacy (Unity)

În sistemul legacy de input din Unity testarea acțiunilor este destul de simplă în cod, dar are câteva limitări. În cel mai simplu mod, input-ul se poate testa prin polling-ul unor butoane de input specifice.

public class LegacyInputExample : MonoBehaviour
{
    // Update is needed to poll every frame.
    private void Update()
    {
        // I want 3 buttons to call the same logic: LMB (mouse), Space (key), LCtrl (key).
        // In the legacy system, I need to hardcode the keycodes.
        if (Input.GetMouseButtonDown(0) || 
            Input.GetKeyDown(KeyCode.Space) || 
            Input.GetKeyDown(KeyCode.LeftControl))
        {
            Debug.Log("[LegacyInputExample] Action performed!");
        }
    }
}

Definirea unui control split-scren / local co-op se poate realiza și folosind sistemul legacy, dar este probabil să necesite unele hardcoding-uri, așadar varianta recomandată pentru tratarea input-ului (fie ca e single-player sau nu) este folosind noul sistem bazat pe acțiuni, prezentat în continuare.

Sistemul de input baza pe acțiuni (Unity)

Noul sistem de input abstractizează partea de testare manuală a acestor input-uri prin mapping-uri și acțiuni.

Un mapping definește un set de acțiuni legate de anumite input-uri fizice (cum ar fi tastatura, mouse-ul, controllerele, etc.). Practic, un mapping este o schemă de control care asociază diferite acțiuni cu diverse metode de input. Puteți să vă gândiți la un mapping ca la modul în care un player interacționează cu jocul în funcție de context. Câteva exemple de mapping-uri:

O acțiune reprezintă o interacțiune definită de utilizator care poate fi activată prin diverse tipuri de input-uri. Acțiunile se pot lega la una sau mai multe intrări fizice printr-un binding.

Pentru a folosi acest sistem de input:

În final, următorul snippet prezintă modul de utilizare al acestui sistem. Comentariile din cod ar trebuie să fie explicative.

public class ActionsInputExample : MonoBehaviour
{
    private InputActionAssetExample playerInputActions;
    private InputAction fireAction;
 
    // Initialize input asset.
    private void Awake() => playerInputActions = new InputActionAssetExample();
 
    // Get the action `Fire` from the mapping `PlayerMapping`. Enable it & bind event.
    private void OnEnable()
    {
        fireAction = playerInputActions.PlayerMapping.Fire;
        fireAction.Enable();
        fireAction.performed += FirePerformed;
    }
 
    private void OnDisable()
    {
        fireAction.Disable();
        fireAction.performed -= FirePerformed;
    }
 
    // Called whenever any of the bindings defined for the `Fire` action are called.
    // No need for explicit checks or the `Update` method!
    private void FirePerformed(InputAction.CallbackContext context) => Debug.Log("Fired action!");
}

În mod alternativ, aceeași funcționalitate se poate obține prin polling în Update folosind ReadValue astfel:

public class ActionsInputExample : MonoBehaviour
{
    private InputActionAssetExample _playerInputActions;
 
    private void Awake() => _playerInputActions = new InputActionAssetExample();
 
    private void OnEnable() => _playerInputActions.Enable();
 
    private void OnDisable() => _playerInputActions.Disable();
 
    private void Update()
    {
        var fireAction = _playerInputActions.PlayerMapping.Fire.ReadValue<float>();
        if (fireAction > 0.5f)
        {
            Debug.Log("Fire action is currently being pressed.");
        }
    }
}

Control schemes (grupuri de binding-uri) utile pentru split-screen

Pentru a diferenția input-ul fiecărui jucător, o metodă facilă este reprezentată de control schemes. Din Input Action Editor puteți crea un nou Control scheme sau grup, căruia în alăturăm binding-uri, astfel diferențiind input-ul fiecărui jucător. De exemplu, se poate crea o schemă de control pentru tastatură și alta pentru gamepad – apoi, fiecare binding este selectat, iar în panoul din dreapta Action Properties se poate atribui apoi acest binding unui scheme de control.

Mai jos se observă un binding în grupul Keyboard.

În următoarea imagine sunt afișate toate schemele de control. Observați numele grupului între paranteze pătrate.


Exemple de design pattern-uri

Command Pattern (pentru input)

public interface ICommand {
    void Execute(CarController car);
}
 
public class AccelerateCommand : ICommand {
    public void Execute(CarController car) => car.Accelerate();
}
 
public class PlayerInputHandler : MonoBehaviour {
    public CarController car;
    private ICommand accelerate = new AccelerateCommand();
    void Update() {
        if (Input.GetKey(KeyCode.W)) accelerate.Execute(car);
    }
}

Observer Pattern (pentru UI)

public class Car : MonoBehaviour {
    public event Action<float> OnSpeedChanged;
    private float speed;
    void Update() {
        speed = Mathf.Abs(GetComponent<Rigidbody>().velocity.magnitude);
        OnSpeedChanged?.Invoke(speed);
    }
}
 
public class Speedometer : MonoBehaviour {
    [SerializeField] private Text text;
    public void Subscribe(Car car) => car.OnSpeedChanged += UpdateUI;
    private void UpdateUI(float s) => text.text = $"{s:0.0} km/h";
}

Concept general – „Replay / Ghost Car”

În timpul cursei, fiecare acțiune de control (accelerare, frânare, viraj) este înregistrată ca un Command cu un timestamp.

După terminarea cursei, aceste comenzi pot fi reluate pentru a reproduce mișcarea exactă a mașinii – rezultând un Ghost Car.

Clase principale:

public interface ICommand
{
    void Execute(CarController car);
    float Timestamp { get; set; }
}
 
public class AccelerateCommand : ICommand
{
    public float Timestamp { get; set; }
    public void Execute(CarController car) => car.Accelerate();
}
 
public class BrakeCommand : ICommand
{
    public float Timestamp { get; set; }
    public void Execute(CarController car) => car.Brake();
}
 
public class TurnCommand : ICommand
{
    public float Timestamp { get; set; }
    private float direction;
    public TurnCommand(float dir) { direction = dir; }
    public void Execute(CarController car) => car.Turn(direction);
}
public class PlayerInputRecorder : MonoBehaviour
{
    public CarController car;
    private List<ICommand> recorded = new List<ICommand>();
    private float startTime;
 
    void Start() => startTime = Time.time;
 
    void Update()
    {
        float current = Time.time - startTime;
 
        if (Input.GetKey(KeyCode.W))
            Record(new AccelerateCommand { Timestamp = current });
 
        if (Input.GetKey(KeyCode.S))
            Record(new BrakeCommand { Timestamp = current });
 
        if (Input.GetKey(KeyCode.A))
            Record(new TurnCommand(-1f) { Timestamp = current });
 
        if (Input.GetKey(KeyCode.D))
            Record(new TurnCommand(1f) { Timestamp = current });
    }
 
    void Record(ICommand cmd)
    {
        cmd.Execute(car);        // aplicăm acțiunea în timp real
        recorded.Add(cmd);       // și o înregistrăm pentru replay
    }
 
    public List<ICommand> GetRecordedCommands() => recorded;
}
public class GhostCarPlayer : MonoBehaviour
{
    public CarController ghostCar;
    public List<ICommand> replayCommands;
    private int currentIndex = 0;
    private float startTime;
 
    void Start() => startTime = Time.time;
 
    void FixedUpdate()
    {
        if (currentIndex >= replayCommands.Count) return;
 
        float elapsed = Time.time - startTime;
        ICommand cmd = replayCommands[currentIndex];
 
        if (elapsed >= cmd.Timestamp)
        {
            cmd.Execute(ghostCar);
            currentIndex++;
        }
    }
}
public class CarController : MonoBehaviour
{
    private Rigidbody rb;
 
    void Awake() => rb = GetComponent<Rigidbody>();
 
    public void Accelerate() => rb.AddForce(transform.forward * 5f, ForceMode.Acceleration);
    public void Brake() => rb.velocity *= 0.95f;
    public void Turn(float direction) => transform.Rotate(0, direction * 100f * Time.deltaTime, 0);
}

Integrare în scenă

Creezi două mașini:

După terminarea cursei:

Rulezi scena în mod replay → GhostCar se mișcă exact ca jucătorul.

Avantaje ale Command Pattern aici:

Extensii posibile:

Resurse recomandate