Estou trabalhando em um novo jogo ao estilo de Bejeweled. Estou feliz com a liberdade de organização do código que a engine Unity3D está permitindo. Nos meus primeiros contatos com a engine, achei que muita coisa teria que ser feita orientada à classe MonoBehaviour, mas isto não é verdade. Essa classe é necessária apenas como um ponto de cola entre qualquer código C# e os objetos da engine. Abaixo relato como comecei a codificação deste jogo e as mudanças que fiz na modelagem, antes disso deixo um vídeo de demonstração do estado atual do jogo:
Iniciei a construção criando um GameObject para cada entidade que identifiquei na mecânica do jogo:
- Tabuleiro
- Peça
public class Board : MonoBehaviour { private GameObject[,] pieces; void Awake() { pieces = /* Initialize pieces */; } }O tipo da peça é definido por um MonoBehaviour associado que expõe uma enumeração para escolha:
public class Piece : MonoBehaviour { public PieceType Type; } public enum PieceType { Circle, Square, Star, Triangle, Hexagon, Polygon }
Depois dessa definição das entidades que participam do jogo, comecei a codificar a lógica dentro dessas classes já existentes. Funcionou até certo ponto, mas alguns problemas começaram a surgir quando eu decidia colocar mais alguma funcionalidade. As classes se responsabilizavam por muitas atividades (e.g. respeitar as regras, cuidar de cada animação, tratar a entrada do usuário) e isto tornava difícil a codificação de algumas coisas, pois eu sempre tinha que manter tudo isso em meu mapa mental para evitar quebrar alguma coisa. Além disso, durante as animações o tabuleiro em memória as vezes ficava em um estado inconsistente, esperando o término de alguma animação para então criar uma outra peça. Este comportamento também contribuía com a dificuldade de manutenção deste modelo.
Já havia lido algumas coisas sobre Domain-driven design (DDD) e resolvi aplicar um pouco disto na construção deste jogo. O primeiro passo foi separar o meu core domain do restante, entendi que a mecânica do jogo é o domínio principal: se esta mecânica não estiver bem resolvida e a manutenção ou criação de novas funcionalidades neste domínio for difícil, estarei ferrado. Então fui criar classes deste domínio completamente separadas de qualquer outro conhecimento necessário para fazer o jogo funcionar, ignorei a existência da Unity3D neste momento.
Só visualizei uma entidade para este meu domínio: o tabuleiro. Não faz sentido a peça existir por si só, tudo que envolve peças sempre acontece dentro de um tabuleiro. Apesar de eu ainda ter uma classe separada para representar a peça e seu tipo, elas possuem um papel interno e só são acessadas através do tabuleiro. Minha modelagem ficou neste formato:
public class BoardPosition { public readonly int Row; public readonly int Column; public BoardPosition(int row, int column) { Row = row; Column = column; } } public class Board { private Piece[,] pieces; public Board() { pieces = /* Initialize pieces */; } #region Queries public Piece PieceAt(BoardPosition p) { /* ... */ } #endregion #region Events public delegate void PieceCreatedDelegate(BoardPosition position, Piece piece); public event PieceCreatedDelegate PieceCreated; public delegate void PieceDestroyedDelegate(BoardPosition position); public event PieceDestroyedDelegate PieceDestroyed; public delegate void PieceMovedDelegate(BoardPosition from, BoardPosition to); public event PieceMovedDelegate PieceMoved; public delegate void PiecesSwappedDelegate(BoardPosition a, BoardPosition b); public event PiecesSwappedDelegate PiecesSwapped; #endregion #region Commands public void SwapPieces(BoardPosition a, BoardPosition b) { ...; // Swap pieces PiecesSwapped(a, b); } public void StepGameState() { ...; // Destroy pieces ...; // Move pieces ...; // Create pieces for (...) { PieceDestroyed(...); } for (...) { PieceMoved(...); } for (...) { PieceCreated(...); } } #endregion }Desta forma, as classes responsáveis por apresentar o jogo de forma visual se registram para os eventos gerados pelo tabuleiro e atualizam a interface conforme necessidade.
public class BoardView : MonoBehaviour { private Board board; private GameObject[,] pieces; void Awake() { board = new Board(); board.PieceCreated += HandlePieceCreated; board.PieceDestroyed += HandlePieceDestroyed; board.PieceMoved += HandlePieceMoved; board.PiecesSwapped += HandlePiecesSwapped; pieces = /* Initialize pieces based on 'board' */; } public void HandlePieceCreated(BoardPosition position, Piece piece) { /* ... */ } public void HandlePieceDestroyed(BoardPosition position) { /* ... */ } public void HandlePieceMoved(BoardPosition from, BoardPosition to) { /* ... */ } public void HandlePiecesSwapped(BoardPosition a, BoardPosition b) { /* ... */ } void Update() { board.Step(); if (/* ... */) { board.SwapPieces(a, b); } } }
O que senti de dificuldade neste formato foi sincronizar o modelo com a representação gráfica. Como o próprio modelo notifica seus interessados, sobra pouco espaço para o cliente decidir quando tratar cada evento. No meu caso, alguns eventos geram animações que precisam segurar outros eventos de acontecer, i.e. existe um sequenciamento temporal entre alguns eventos.
Decidi então alterar meu modelo para informar os eventos que ocorreram em cada comando executado ao invés de já causar a execução do código do listener:
#region Events public interface BoardEvent {} public class PieceCreated : BoardEvent { /* ... */ } public class PieceDestroyed : BoardEvent { /* ... */ } public class PieceMoved : BoardEvent { /* ... */ } public class PiecesSwapped : BoardEvent { /* ... */ } #endregion #region Commands public List<BoardEvent> SwapPieces(BoardPosition a, BoardPosition b) { /* ... */ } public List<BoardEvent> StepGameState() { /* ... */ } #endregionTudo o que mudou é que agora a interface precisa chamar os handlers sozinha e decidir quando tratar cada evento:
public class BoardView : MonoBehaviour { private List<BoardEvent> events; void Update() { if (events.Count < 1) { events = board.StepGameState(); } foreach (BoardEvent e in events) { if (CanHandleNow(e)) { Handle(e); } } // ... if (HandledEverything) { events.Clear(); } } }Ainda assim, o conceito de sequenciamento temporal não estava muito claro. Parecia que ele estava "flutuando no ar" e precisava ser fixado. Entendi que isto faz parte do domínio do meu problema e cada evento passou a ter um identificador de sequenciamento no tempo, cada BoardEvent tem um public int When() que informa o momento sequencial do tempo em que ele ocorreu.
public class Board { private int timeCounter; public List<BoardEvent> StepGameState() { ...; // Destroy pieces for (...) { events.add(new PieceDestroyed(timeCounter, ...)); } if (eventHappened) { timeCounter++; } ...; // Move pieces for (...) { events.add(new PieceMoved(timeCounter, ...)); } if (eventHappened) { timeCounter++; } ...; // Create pieces for (...) { events.add(new PieceCreated(timeCounter, ...)); } if (eventHappened) { timeCounter++; } return events; } } public class BoardView : MonoBehaviour { private int timeCounter; private List<BoardEvent> events; void Update() { if (events.Count < 1) { events = board.StepGameState(); } foreach (BoardEvent e in events) { if (e.When() == timeCounter) Handle(e); if (e.When() > timeCounter) { stillHasEventsToHandle = true; break; } } if (/*handledAnimationOfAllEventsOfMyTimeCounter*/) { // Advance time perception of view timeCounter++; } if (!stillHasEventsToHandle) { events.Clear(); // Will step game state at next frame } } }Desta forma, tanto o modelo quanto a representação gráfica possuem um contador temporal e a própria representação se responsabiliza por sincronizar o tempo percebido pelo usuário da interface com o tempo percebido pelo modelo.
Até então o código está mais ou menos como foi listado acima, este modelo está atendendo bem até então. Ainda me sinto mal com um ponto: os commands do modelo sempre mantém o tabuleiro todo cheio, sem estados pela metade, porém o método de step faz no máximo uma iteração de eventos e checagem de colisões no tabuleiro, porém podem ocorrer novas colisões depois de mover e criar as peças. Apesar do tabuleiro nunca estar inconsistente, pode ser que sejam necessárias várias execuções de StepGameState até que o tabuleiro fique em um estado consolidado, sem nada a ser feito e aguardando entrada do usuário. Não queria fazer várias iterações em uma única chamada do step para não ter perigos de entupir a memória do jogo com eventos antes deles começarem a ser tratados pela interface. Nestas horas sinto falta da avaliação lazy do Haskell.
Ainda tenho alguns itens para adicionar nesta mecânica de jogo. Verei quais os problemas deste formato nos próximos dias e postarei novidades com as próximas mudanças. Sugestões também são bem vindas.