Unity + Arquitetura Model-View-Presenter?

lepsistemas

Leandro Boeing Vieira

Posted on January 4, 2024

Unity + Arquitetura Model-View-Presenter?

Motivação

Ao longo da minha carreira de engenheiro de software, eu fui dando cada vez mais valor a código manutenível. Para muitos, perda de tempo, ou mesmo falta de tempo pra aplicar boas práticas e testes. Para outros, o grande salvador dos projetos de longo prazo, por ter sido possível dar manutenção às infinitas mudanças nos requisitos/escopo que sempre irão ocorrer.

Claro que para projetos pequenos, frutos de hobby, Provas de Conceito (PoC), eu não recomendo fazer seguindo essas práticas, principalmente pra quem ainda as está aprendendo e treinando. Isso pode sim atrasar bastante o desenvolvimento.

Porém, eu recomendo o estudo, a prática, o treinamento. Pois se um dia surgir uma vaga numa empresa grande de games, essas skills certamente chamarão a atenção do recrutador. Ou mesmo se você tiver aquela ideia genial de um jogo relativamente grande, elas podem te salvar horas de debug e dezenas de bugs.

Como primeira tentativa de trazer um padrão arquitetural para a Unity, eu pensei em MVC ou MVP. Inclusive, tem um artigo excelente do Baeldung que traz as diferenças dos dois: https://www.baeldung.com/mvc-vs-mvp-pattern

E dadas essas diferenças, por exemplo entre as funções da Controller do MVC e da Presenter do MVP, eu optei por iniciar pelo MVP. Me pareceu que encaixou melhor. Principalmente porque eu também queria escrever testes unitários para as regras do Model e Presenter. E pra isso o MVP se mostra melhor que o MVC, já que a Presenter é desacoplada da View, enquanto a Controller tem um alto acoplamento.

TicTacToe (Jogo da Velha)

Jogo simples, de UI simples, principalmente a minha que chega a ser feia de tão simples, porém vamos focar é na programação, no código, e principalmente na separação de responsabilidades desse código.

Na Unity, crie seu Canvas e sua grid da maneira que achar melhor. Eu utilizei Panel e Button com TextMeshPro:

Unity

A estrutura ficou assim:

MainScene
  GameManager
  Canvas
    GameBordPanel (Panel)
      Cell00 (Button)
      ...
      Cell22 (Button)
    PlayerInfoPanel (Panel)
      PlayerInfoText (TextMeshPro)
      WinnerInfoText (TextMeshPro)
      Reset (Button)
      Exit (Button)
Enter fullscreen mode Exit fullscreen mode

Onde GameManager é um GameObject com 2 Script Components: GameManager e TicTacToeView. GameManager é o entrypoint da "aplicação", similar à classe Main do Java ou ao index.js do Javascript.

using UnityEngine;
using System;

public class GameManager : MonoBehaviour
{

    void Start()
    {
        var view = GetComponent<TicTacToeView>();
        if (view != null)
        {
            var model = new TicTacToeModel();
            new TicTacToePresenter(view, model);
        }
        else
        {
            Debug.LogError("TicTacToeView component not found on the GameObject.");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

E esse é o Inspector do GameObject chamado GameManager:

GameManager Inspector

Ainda não sabemos o que são a TicTacToePresenter, TicTacToeModel e nem TicTacToeView, mas sabemos que GameManager as gerencia.

Model

Essa camada existe independentemente de framework e biblioteca, dependendo apenas da arquitetura geral do sistema, nesse caso utilizando linguagem C#. Ela inclusive independe de detalhes de implementação da Unity.

public class TicTacToeModel
{
    private char[,] board = new char[3, 3];
    public char CurrentPlayer { get; private set; } = 'X';
    private bool gameOver = false;

    public TicTacToeModel()
    {
        ResetBoard();
    }

    public bool MakeMove(int x, int y)
    {
        if (board[x, y] == '\0' && !gameOver)
        {
            board[x, y] = CurrentPlayer;
            gameOver = CheckForWinner(x, y);
            if (!gameOver) 
            {
                SwitchPlayer();
            }
            return true;
        }
        return false;
    }

    private void SwitchPlayer()
    {
        CurrentPlayer = CurrentPlayer == 'X' ? 'O' : 'X';
    }

    private bool CheckForWinner(int x, int y)
    {
        // Check row
        if (board[x, 0] == CurrentPlayer && board[x, 1] == CurrentPlayer && board[x, 2] == CurrentPlayer)
        return true;

        // Check column
        if (board[0, y] == CurrentPlayer && board[1, y] == CurrentPlayer && board[2, y] == CurrentPlayer)
            return true;

        // Check diagonals
        if (x == y && board[0, 0] == CurrentPlayer && board[1, 1] == CurrentPlayer && board[2, 2] == CurrentPlayer)
            return true;

        if (x + y == 2 && board[0, 2] == CurrentPlayer && board[1, 1] == CurrentPlayer && board[2, 0] == CurrentPlayer)
            return true;

        return false;
    }

    public bool IsGameOver(out bool isDraw)
    {
        isDraw = false;

        if (gameOver)
        {
            // Game over due to a win
            return true;
        }

        if (IsBoardFull())
        {
            // Game over due to a draw
            isDraw = true;
            return true;
        }

        return false;
    }

    private bool IsBoardFull()
    {
        for (int i = 0; i < 3; i++)
        {
            for (int j = 0; j < 3; j++)
            {
                if (board[i, j] == '\0')
                {
                    return false;
                }
            }
        }
        return true;
    }

    public void ResetBoard()
    {
        for (int i = 0; i < 3; i++)
        {
            for (int j = 0; j < 3; j++)
            {
                board[i, j] = '\0';
            }
        }
        CurrentPlayer = 'X';
        gameOver = false;
    }

    public char GetCurrentPlayerSymbol()
    {
        return CurrentPlayer;
    }

}
Enter fullscreen mode Exit fullscreen mode

Note que ela não tem nenhuma dependência a nenhum namespace da Unity, como using UnityEngine; ou using System; no código. Ela poderia até ser utilizada em um projeto .NET tranquilamente.

O intuito dela é guardar e proteger as regras do negócio. Que negócio? Nesse caso as regras do Jogo da Velha. Eu confesso que fiz na pressa, e poderia ter sido mais bem escrita, com alguns métodos a mais para extrair a lógica da checagem de um ganhador na horizontal, vertical e diagonal, entre outros, mas a ideia é mostrar essa classe sendo responsável pelo core do jogo.

O importante é perceber que ela está isolada de detalhes de infraestrutura.

Presenter

Presenter na MVP serve para orquestrar os inputs recebidos da View e informar ao Model, atualizando assim seu estado, bem como receber operações do Model e atualizar a View se necessário.

Model-View-Presenter

Sua implementação é até mais simples que o Model, já que ela apenas faz a intermediação entre Model e View:

public class TicTacToePresenter
{

    private TicTacToeModel model;
    private ITicTacToeView view;

    public TicTacToePresenter(ITicTacToeView view, TicTacToeModel model) {
        this.view = view;
        this.model = model;
        this.view.OnCellClicked += HandleCellClicked;
        this.view.OnResetClicked += ResetBoard;
        UpdatePlayerInfo();
    }

    public void HandleCellClicked(int x, int y) {
        char currentPlayerSymbol = model.GetCurrentPlayerSymbol();
        if (model.MakeMove(x, y)) {
            view.UpdateCell(x, y, currentPlayerSymbol);
            if (model.IsGameOver(out bool isDraw)) {
                if (isDraw)
                {
                    view.ShowWinner('\0');
                }
                else
                {
                    view.ShowWinner(model.CurrentPlayer);
                }
            } else {
                UpdatePlayerInfo();
            }
        }
    }

    public void UpdatePlayerInfo() {
        string info = model.CurrentPlayer == 'X' ? "Player X's Turn" : "Player O's Turn";
        view.UpdatePlayerInfo(info);
    }

    public void ResetBoard()
    {
        model.ResetBoard();
        view.ResetBoard();
    }

}

Enter fullscreen mode Exit fullscreen mode

Note que ela orquestra os inputs recebidos na View e executa os comandos necessários no Model:

// Recebe inputs da View
this.view.OnCellClicked += HandleCellClicked;
// invoca ações no Model
model.MakeMove(x, y)
...
Enter fullscreen mode Exit fullscreen mode

Note também que a Presenter recebe como parâmetro no seu construtor tanto a View quanto a Model, e mais, a View que ela recebe é uma interface e não uma classe concreta. Explico logo mais.

SOLID Principles

Os SOLID Principles ajudam a guiar seu código a uma solução manutenível. Se você aplicá-los, o efeito colateral será sempre um código desacoplado, testável e flexível.

Nesse caso específico estamos utilizando a DIP, Dependency Inversion Principle, com uma interface de View como dependência, para conseguir os seguintes resultados:

  • Desacoplamento: O TicTacToePresenter é desacoplado da implementação específica da View. Ele não precisa conhecer os detalhes de como a View é implementada; ele depende apenas do contrato definido pela interface.

  • Testabilidade: Fica mais fácil escrever testes unitários para o TicTacToePresenter, pois você pode criar implementações mock ou stub de ITicTacToeView para fins de teste. Isso permite isolar e testar o comportamento do Presenter independentemente da View real.

  • Flexibilidade: Você pode trocar a implementação concreta da View sem afetar a Presenter. Por exemplo, se um dia você decidir criar uma interface de usuário diferente para o seu jogo, ou delegar isso a um outro time mais especializado em UI, você pode implementar uma nova View que respeita o contrato da interface ITicTacToeView, e a Presenter não precisa mudar.

Você verá essa capacidade de testabilidade mais pra frente.\ quando falarmos dos testes automatizados.

View

Como sabemos, a View tem uma interface e uma implementação concreta. A interface fica no mesmo pacote que a Presenter, pois o motivo dela existir é a segregação de interface promovida pela camada Presenter.

using System;

public interface ITicTacToeView
{
    event Action<int, int> OnCellClicked;
    event Action OnResetClicked;
    event Action OnExitClicked;
    void UpdateCell(int x, int y, char playerSymbol);
    void UpdatePlayerInfo(string info);
    void ShowWinner(char winner);
    void ResetBoard();
    void ExitGame();
}
Enter fullscreen mode Exit fullscreen mode

Perceba que ela expõe o contrato que uma View deve ter para saber se comunicar corretamente com a Presenter.

using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System;

public class TicTacToeView : MonoBehaviour, ITicTacToeView
{
    public event Action<int, int> OnCellClicked;
    public event Action OnResetClicked;

    public event Action OnExitClicked;

    public Button[] cellButtons;

    public Button resetButton;

    public Button exitButton;

    public TextMeshProUGUI playerInfoText;
    public TextMeshProUGUI winnerInfoText;

    void Start()
    {
        for (int i = 0; i < cellButtons.Length; i++)
        {
            int index = i;
            cellButtons[i].onClick.AddListener(() => OnCellButtonClicked(index));
        }
        resetButton.onClick.AddListener(OnResetButtonClicked);
        exitButton.onClick.AddListener(OnExitButtonClicked);
        OnExitClicked += ExitGame;
    }

    private void OnCellButtonClicked(int cellIndex)
    {
        int x = cellIndex / 3;
        int y = cellIndex % 3;
        OnCellClicked?.Invoke(x, y);
    }

    private void OnResetButtonClicked()
    {
        OnResetClicked?.Invoke();
    }

    private void OnExitButtonClicked()
    {
        OnExitClicked?.Invoke();
    }

    public void UpdateCell(int x, int y, char playerSymbol)
    {
        int cellIndex = x * 3 + y;
        this.cellButtons[cellIndex].GetComponentInChildren<TextMeshProUGUI>().text = playerSymbol.ToString();
    }

    public void UpdatePlayerInfo(string info)
    {
        this.playerInfoText.text = info;
    }

    public void ShowWinner(char winner)
    {
        this.winnerInfoText.text = winner == '\0' ? "It's a draw!" : $"Player {winner} wins!";
    }

    public void ResetBoard()
    {
        foreach (var button in cellButtons)
        {
            button.GetComponentInChildren<TextMeshProUGUI>().text = "";
        }
        UpdatePlayerInfo("Player X's Turn");
        winnerInfoText.text = "";
    }

    public void ExitGame()
    {
        #if UNITY_EDITOR
            UnityEditor.EditorApplication.isPlaying = false;
        #else
            Application.Quit();
        #endif
    }
}
Enter fullscreen mode Exit fullscreen mode

E perceba que a View e a primeira classe em nosso projeto que conhece dependências de Unity, como as namespaces UnityEngine, UnityEngine.UI e TMPro.

Tests

Você pode clonar o repo no link do final do artigo, e rodar o jogo na Unity e vai ver que ele roda e funciona bem. Porém, lembra que uma das maiores vantagens de ter usado o padrão arquitetural MVP com DIP foi conseguir uma testabilidade isolada dos componentes?

Olha como podemos testar as regras do jogo, independentemente de UI:

using NUnit.Framework;

public class TicTacToeModelTests
{
    private TicTacToeModel model;

    [SetUp]
    public void Setup()
    {
        model = new TicTacToeModel();
    }

    [Test]
    public void Should_Return_True_For_A_Valid_Move()
    {
        bool result = model.MakeMove(0, 0);

        Assert.IsTrue(result);
    }

    [Test]
    public void Should_Return_False_For_An_Invalid_Move()
    {
        model.MakeMove(0, 0);

        bool result = model.MakeMove(0, 0);

        Assert.IsFalse(result);
    }

    [Test]
    public void Should_Have_Winner_In_Horizontal_Line()
    {
        model.MakeMove(0, 0); // X
        model.MakeMove(1, 0); // O
        model.MakeMove(0, 1); // X
        model.MakeMove(1, 1); // O

        bool result = model.MakeMove(0, 2);

        Assert.IsTrue(result);
    }

    public void Should_Have_Winner_In_Vertical_Line()
    {
        model.MakeMove(0, 0); // X
        model.MakeMove(0, 1); // O
        model.MakeMove(1, 0); // X
        model.MakeMove(1, 1); // O

        bool result = model.MakeMove(2, 0);

        Assert.IsTrue(result);
    }

    // Other tests for the Model can be added here
}
Enter fullscreen mode Exit fullscreen mode

E, mais do que isso, se usar uma biblioteca tipo Moq, podemos ainda testar a Presenter para garantir que ela chama as atualizações de View corretamente:

using NUnit.Framework;
using Moq;

public class TicTacToePresenterTests
{
    private TicTacToePresenter presenter;
    private Mock<ITicTacToeView> viewMock;
    private TicTacToeModel model;

    [SetUp]
    public void Setup()
    {
        viewMock = new Mock<ITicTacToeView>();
        model = new TicTacToeModel();
        presenter = new TicTacToePresenter(viewMock.Object, model);
    }

    [Test]
    public void Should_Update_Player_Info_In_View()
    {
        var expectedInfo = "Player X's Turn";
        viewMock.Setup(view => view.UpdatePlayerInfo(It.IsAny<string>()));

        presenter.UpdatePlayerInfo();

        viewMock.Verify(view => view.UpdatePlayerInfo(expectedInfo), Times.Exactly(2));
    }

    // Other tests for the Presenter can be added here
}
Enter fullscreen mode Exit fullscreen mode

E note, adicionar essa meia dúzia de testes não atrasa muito o desenvolvimento do seu projeto, seja honesto. Não estou falando que você precisa atingir 100% de cobertura de linhas, mas as features principais, como determinar que uma linha com 3 X's faz um vencedor, pode estar testada, certo?

Tests Run

Clean Architecture

Model-View-Presenter não compreende todas as peculiaridades da Arquitetura Limpa, porém ela endereça pelo menos a separação entre camada de negocio da UI. Framework Adapters e Use Cases com Entities são assunto para um outro momento.

Clean Architecture

Conclusão

Embora a MVP não resolva todas as nuances da Clean Architecture, ela pelo menos separa o domínio da UI. Isso em projetos grandes/longos tende a facilitar muito em termos de manutenção.

Note que criar as classes Model-View-Controller não mudaram MUITO o tempo de implementação. Com exceção de um evento a mais aqui ou ali, a lógica que antes estaria na View, e agora está na Model.

E o maior ganho do advento dessa arquitetura foi a possibilidade da criação dos testes automatizados. Você não precisa testar seu jogo inteiro de forma automatizada, mas que tranquilidade saber que a lógica para o vencedor do TicTacToe permanecerá intacta, garantida por uma suíte de teste automatizado!

Espero que tenha gostado desse artigo. E se quiser aprender mais sobre Clean Code, Clean Architecture, Hexagonal Architecture, Domain-Driven Design, Agilidade, testes Automatizados, TDD, recomendo ler meu livro "Minha Jornada para Ganhar em Dólar como Programador". Link da Hotmart seguir.

Link do projeto no Github: https://github.com/lepsistemas/MVP-TicTacToe
Minha Jornada para ganhar em Dólar como Programador: https://go.hotmart.com/K89021018W

💖 💪 🙅 🚩
lepsistemas
Leandro Boeing Vieira

Posted on January 4, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related