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

Cerință : Joc Split-Screen Time Trial

  • Fiecare jucător concurează pe o pistă proprie (split-screen orizontal/vertical).
  • Scop: terminarea cursei în cel mai scurt timp.
  • Se afișează în UI timpul curent și cel mai bun timp.

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)

  • Adăugarea unui Player Manager care detectează automat dispozitivele conectate și alocă controlul.
  • Introducerea unui AIPlayer controlat de un `Strategy` diferit (folosind strategy pattern).
  • Ecran de rezultate comun la finalul cursei.
  • Sunet 3D individual pentru fiecare cameră.
  • State – pentru comportamentele jucătorului (Idle, Moving, Crashed);

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

  • Înțelegerea conceptului de split-screen și a modului de configurare a camerelor multiple.
  • Crearea unui sistem de input modular pentru controlul a doi jucători independenți.
  • Implementarea unui design pattern (Command, Observer, Strategy sau State) într-un context de gameplay.
  • Înțelegerea interacțiunii dintre camere, UI și gameplay logic.
  • Familiarizarea cu New Input System din Unity.

Noțiuni teoretice

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

  • Non-network: jucatorii joaca pe acelasi dispozitiv prin ture, controland caractere diferite sau avand ecranul impartit
  • Network: jucatorii joaca pe dispozitive diferite, existand un server sau unul din juctori preluand si rolul de server (host) iar restul fiind clienti

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

  • Fiecare jucător are propria cameră și propriul viewport.

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

  • Fiecare cameră urmărește o mașină sau un personaj distinct.
  • Considerații de performanță: fiecare cameră are propriul render pass → cost suplimentar.

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).

  • UI-ul fiecărui jucător trebuie izolat (`Canvas` separat, *World Space* sau *Screen Space – Camera*).
  • Folosirea prefab-urilor pentru HUD per jucător (vitezometru, timer, scor).

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:

  • MovementOnFoot pentru mișcare pedestră (WASD pe tastatură sau joystick pe un controller)
  • MovementInCar pentru condusul unei mașini (aceleași taste WASD pe tastatură sau joystick pe un controller, dar într-un alt context!)
  • MovementInBoat pentru navigarea cu o barcă

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:

  • Trebuie să aveți pachetul Input System integrat în proiect
  • La o cale dorită în Project window din Unity, click-dreapta → Create → Input Actions
  • În acest asset puteți crea mapping-uri, acțiuni și binding-uri, un exemplu este prezentat în imaginea de mai jos.
  • De asemenea, este util să bifați Generate C# Class din inspectorul acestui asset pentru a-l utiliza mai ușor în script-urile voastre

Î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:

  • ICommand – interfață generală pentru toate comenzile.
  • AccelerateCommand, BrakeCommand, TurnCommand – implementări concrete.
  • CommandRecorder – înregistrează comenzile în timpul jocului.
  • CommandPlayer – redă comenzile în ordinea și la timpul în care au fost executate.
  • CarController – execută acțiunile asupra mașinii.

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:

  • PlayerCar → are CarController + PlayerInputRecorder
  • GhostCar → are CarController + GhostCarPlayer

După terminarea cursei:

  • salvezi lista de comenzi din PlayerInputRecorder
  • o transmiți către GhostCarPlayer.replayCommands

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

Avantaje ale Command Pattern aici:

  • Separă complet inputul de logică — același cod de mișcare poate fi refolosit pentru AI, replay sau multiplayer.
  • Poți salva comenzile pe disc (JSON) și relua oricând.
  • Permite debugging ușor: poți „derula” fiecare comandă înapoi sau înainte.

Extensii posibile:

  • Adaugă un RecordToFile() care serializează comenzile în JSON.
  • Adaugă un replay speed slider (1x, 2x, slow-motion).
  • Adaugă vizualizare „fantomă” cu shader semitransparent pentru GhostCar.

Resurse recomandate


pjv/laboratoare/2025/a01.txt · Last modified: 2025/10/06 13:54 by alexandru.gradinaru
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0