Padrão de Projeto Mediator

September 28, 2022

Motivação

Durante o início da pandemia de Covid 19, eu e meus amigos procurávamos todos os dias jogos online diferentes para passarmos o tempo conectados mesmo distantes um dos outros. Um dos jogos que mais jogávamos pessoalmente era o Uno.

Sempre era possível encontrar ao menos três pessoas e se a turma tivesse grande, como por exemplo oito pessoas, ainda assim dava pra jogar com um único baralho de Uno. Pensando nessas características, nós pensamos em procurar um Uno online! Eu procurei bastante e no tempo nós não encontramos nenhum que desse para jogar online com as características que falei acima.

Pensei... - Bem, eu poderia fazer um jogo de Uno, certo? E foi ai que comecei a escrever no tempo um Uno em JavaScript. Porém, parei porquê acabei focando em outras coisas e até esse ano de 2022 eu não tinha colocado "as mãos" novamente nessa ideia.

Pensando em exercitar minhas habilidades com padrões de projeto, resolvi escrever um Uno em Kotlin e ver no que dava. E aqui estou eu com esse artigo como resultado, espero que gostem :wink:.

Cartas e Regras

O jogo de uno é composto pelas seguintes cartas:

  • 19 Cartas azuis, numeradas de 0 a 9;
  • 19 Cartas verdes, numeradas de 0 a 9;
  • 19 Cartas amarelas, numeradas de 0 a 9;
  • 19 Cartas vermelhas, numeradas de 0 a 9;
  • 8 Cartas de inversão de sentido (duas de cada cor);
  • 8 Cartas de +2 (duas de cada cor);
  • 8 Cartas de Pular (duas de cada cor);
  • 4 Cartas de joker para troca de cor;
  • 4 Carta

Temos as seguintes regras:

  • O jogador inicia com 6 cartas;
  • O primeiro jogador joga a carta que inicia o jogo;
  • A próxima carta a ser jogada tem que ter a mesma cor ou o mesmo valor ou mesmo tipo;
  • Algumas cartas têm habilidades especiais que podem mudar o curso do jogo;
  • Ganha o jogador que conseguir jogar todas as cartas "na mesa".

Habilidade das cartas:

  • Carta de inversão de sentido: Se o sentido do jogo estiver da direita -> esquerda,

ao jogar essa carta o sentido do jogo é invertido para o sentido esquerda -> direita;

  • Carta de +2: Ao jogar essa carta o próximo jogador receberá duas cartas e perde a vez.

Caso esse próximo jogador tenha uma carta de valor (+2) igual poderá joga-lá e o efeito dessa carta é acumulado para o próximo jogador, fazendo com que ele pegue 4 cartas. Essa regra também vale para todos os outros jogadores consequentes;

  • Carta de Pular: Esta carta pula a vez do jogador próximo jogador. Mesmo que o jogador tenha uma carta igual a esta na mão, terá de cumprir o castigo. Esta carta só pode ser jogada caso esteja na mesa uma carta com a mesma cor ou com valor igual;
  • Carta de joker para troca de cor: Na vez do jogador que tem essa carta ele poderá jogar acima de qualquer carta

e escolher a cor que o próximo jogador deve jogar;

  • Carta de joker para troca de cor e +4: Tem o mesmo efeito da carta de Joker para troca de cor com o acréscimo de que o próximo jogador pega 4 cartas e perde a vez. Se o próximo jogador tiver uma carta igual poderá jogar e transferir o efeito dessa carta para o próximo jogador que deverá pegar 8 cartas (2 vezes 4+), assim sucessivamente para os próximos.s de joker troca de cor e +4.

Começando pelo começo

Agora que já entendemos a motivação e como que o jogo de Uno que foi implementado nesse projeto, vamos para a parte de design do projeto.

Sempre falo que antes de por a mão na massa é importante colocarmos no papel as nossas ideias. Por isso, apesar de ter começado a implementação do código antes desse desenho de fluxo de jogo, depois de um tempo foi impossível avançar com o desenvolvimento antes de ter "no papel" todo esse fluxo desenhado.

Abaixo, segue a imagem do fluxo de jogo: Chinese Salty Egg

Se a qualidade da imagem estiver ruim, abra em uma nova guia e use o zoom para visualizar melhor.

Vou resumir em uma lista os passos (vamos chamar também de Steps) "centrais" do fluxo mostrado na imagem acima:

  1. Início da jogada;
  2. Jogador está sob efeito de alguma carta?
  3. Se não, jogador tem carta disponível na mão?
  4. Se sim, escolhe carta para jogar;
  5. É possível jogar carta escolhida?
  6. Se sim, remove carta da mão do jogador;
  7. Jogador zerou as cartas na mão?
  8. Se não, coloca a carta removida da mão no topo da pilha das cartas jogadas;
  9. Carta tem habilidade especial?
  10. Se não, passa a vez para o próximo jogador e volta ao passo 01.

Como eu falei acima, essa lista é apenas um resumo dos passos e vários side Steps foram omitidos de propósito para não perdermos tempo falando apenas do fluxo de jogo.

Ideia inicial de implementação

No princípio eu pensei em criar classes de Services, elas seriam responsáveis pela lógica em cada um dos Steps. Teríamos o serviço responsável por servir o jogador de cartas, por pegar as cartas do baralho e de todos os outros Steps.

Porém, ao tentar implementar eu percebi que como cada um dos Services de acordo com a entrada, poderiam chamar Steps diferentes. Sendo assim eu precisaria da referência dos dos Services dos Steps possíveis a serem chamados dentro de cada um dos Services. Se um novo Service A fosse adicionado, seria complexo adicionar a referência desse novo Service A nos outros que necessitam dele. Também teríamos complexidade em adicionar referências dos outros Services a esse novo Service A.

Isso me daria um problema de gestão de dependências dentro de cada um dos services. Um service A poderia ter instâncias de B e C, e esses por suas vez para D e E, enquanto que F teria para A de volta. Ficaria um caos ter que gerenciar essas dependências entre esses objetos e ainda teríamos que ter cuidado com a chance de um instanciação de objetos recursiva, como é o caso de A e F citados acima, em que um chama o outro indiretamente e diretamente respectivamente.

Entrando nos Padrões de Projeto

Ao ver o fluxo de jogo, é possível observar que temos uma cadeia de eventos que podem acontecer durante o turno de um jogador. Vendo essa cadeia de eventos, já imaginei que poderíamos implementar aqui o padrão de projeto Chains of Responsibility.

Porém, ao tentar implementar e analisando melhor o fluxo de jogo, podemos ver que os steps mostrados acima seguem um fluxo mas podem encerrar mais cedo uma jogada ou até pular a vez do próximo jogador a depender da carta que foi jogada. E isso quer dizer que esse fluxo pode mudar totalmente dentro de um step.

No padrão Chains of Responsibility, os steps podem ser ou não executados e até encerrados antes de completar ao todo a sua função, e até aqui isso nos atenderia. Mas mudar ou fluxo ou não saber qual pode ser o próximo step antes do step iniciar sua execução foi algo que impossibilitou a implementação desse padrão de projeto aqui. Esse padrão precisa ter definida a ordem e todos os passos antes deles serem executados, o que não acontece no nosso fluxo de jogo.

O padrão de projeto Mediator

Depois de tudo que analisamos a respeito de nosso fluxo de jogo, percebemos algumas informações do que precisamos para seguir com o desenvolvimento. Então o que precisamos?

  • Precisamos de um gestor de dependências que gerencie a comunicação entre os objetos de steps;
  • Precisamos de um gestor que saiba qual será o próximo step a ser executado apenas ao fim da execução do step atual;

Ah, agora acho que chegamos a um padrão, certo?

O padrão de projeto Mediator é capaz de gerenciar as dependências caóticas em objetos, afim de quê ele seja o único responsável por se comunicar com os objetos, evitando que eles precisem se comunicarem entre si.

Analogia

Quando estamos nos movendo em um carro, nós temos regras de transito que nos auxiliam a saber como proceder quando algo acontecer. Por exemplo, se o sinal está vermelho quer dizer que temos que aguardar e esperar o sinal ficar verde para podermos passar. Temos aparatos como placas, sinalização de transito e semáforos que nos ajudam a tomar decisões e evitar acidentes.

Quando falamos de aviões as coisas já mudam um pouco, no ar nós não temos como por regras no meio do céu para sinalizar ao piloto que ele deve diminuir a velocidade, baixar a altitude, realizar um pouso de emergência ou se comunicar com outro avião que venha em sentido contrário para evitar uma colisão. Mesmo que tivéssemos como se comunicar com outros aviões, já pensou na quantidade de aviões que existem no céu? Acho que seria inviável manter contato com todos enquanto tentamos pilotar o nosso avião.

Nesse caso, nós precisamos da torre de controle* para nos auxiliar na tomada de decisões e se comunicar com todos os outros aviões no meio do ar. Assim, nós em nosso avião fazemos o precisamos e informamos para a torre de controle o que acabamos de fazer. Após isso, a torre de controle decide o que fazer com essa informação, decide se passa para outro avião, se aguarda mais informações de outros aviões ou que quer que seja.

O padrão de projeto Mediator funciona como a torre de controle. Cada um dos aviões não precisam saber da existência dos outros 10 aviões que estão naquela região, eles apenas precisam saber da torre de comando se precisam atuar naquele momento de alguma forma ou se seguem seu curso normalmente.

Implementando o Mediator

Esse artigo se atém exclusivamente a falar do padrão Mediator implementado no uno-kotlin. Sendo assim, detalhes da implementação de outras classes e outros padrões de projetos que também foram utilizados aqui não serão abortados nesse artigo e sim em outros que ainda irei escrever :wink:.

Como falado anteriormente, a ideia do Mediator é centralizar toda a gestão de depências entre objetos para um único lugar que é o próprio Mediator. Agora, os componentes que antes se comunicavam entre si devem informar ao Mediator que está realizando uma interação. Esse por sua vez vai receber essa informação e repassar para o próximo componente responsável por aquela execução.

No padrão de projeto Mediator nós temos Componentes, que são as classes responsáveis por executar alguma lógica de negócio por exemplo. No nosso caso, nossos Componentes são os steps.

Steps

Sem o Mediator, nossos Steps precisariam conhecer cada um dos outros Steps para podermos sair da execução de um para qualquer um outro Step. Por exemplo, o Step de verificar cartas na mão do jogador teria que ter uma referência para o Step de obter carta caso a verificação de cartas na mão falhasse, e caso ela não falhasse nós deveriamos chamar o Step de selecionar carta para jogar. Só nesse exemplo, já teriamos duas referência de outros Steps dentro de um único Step.

TODO: colocar diagrama aqui

Então criamos a interface Step::

interface Step {

    fun getHandlerGame(): GameContext
    fun execute()
    fun setNext(stepStrategy: Step)

}

Ela tem três métodos, são eles:

  • getHanlerGame: Responsável por retornar o GameContext que contém as informações do jogo atual;
  • execute: Meio que implementamos o padrão Command aqui (fica para um outro artigo falar dele), assim o Mediator não precisa saber o que o próximo Step faz, apenas chama esse método para ser executado;
  • setNext: Esse método é responsável por encadear a execução de dois Steps.

Vamos agora observar a implementação do Step responsável verificar qual a habilitade especial da carta que foi jogada e passar para o evento correspondente:

class SpecialCardEffectStep(
    private val mediator: Mediator,
    private val gameContext: GameContext
) : AbstractStep(gameContext, mediator) {

    override fun execute() {

        val cardPlayed = gameContext.lastCardPlayed()
        gameContext.isSpecialEffectActive = true

        when (cardPlayed.cardType) {
            CardType.REVERT -> mediator.notify(this, MediatorEvent.REVERT_GAME_DIRECTION)
            CardType.BLOCK -> mediator.notify(this, MediatorEvent.NEXT_TURN)
            CardType.PLUS_TWO -> mediator.notify(this, MediatorEvent.NEXT_TURN, MediatorEvent.MAKE_PLAYER_GET_CARDS)
            CardType.JOKER -> {
                gameContext.isSpecialEffectActive = false
                mediator.notify(this, MediatorEvent.SELECT_COLOR_GAME)
            }

            CardType.JOKER_PLUS_FOUR -> mediator.notify(this, MediatorEvent.SELECT_COLOR_GAME)
            else -> {
                gameContext.isSpecialEffectActive = false
                mediator.notify(this, MediatorEvent.NEXT_TURN)
            }
        }
    }
}

Imagina só se tivéssemos uma referência para cada uma das habilidades das cartas especiais? E se posteriormente alguma habilidade especial mudasse ou uma nova surgisse? Teríamos que gerênciar todas essas dependências aqui na classe SpecialCardEffectStep.

Um outro detalhe importante que o Mediator ajudou a melhorar a respeito de delegação de responsábilidade é que: A classe Step agora só se preocupa em fazer toda a lógica de jogo que ela precisa e receber do Mediator para que ele faça a gestão de todos os outros Steps.

Com o Mediator apenas dizemos qual o evento que queremos após esse e ele gerência toda esse encadeamento. Isso acontece quando chamamos o método notify do objeto mediator que é passado como referência para as classes de Step.

Abaixo, vemos como ficou a interface Mediator:

interface Mediator {

    fun notify(vararg nextEvent: MediatorEvent)

}

Agora vamos a implementação do GameMediator, classe que implementa a interface Mediator.

class GameMediator(gameContext: GameContext) : Mediator {

    private val choseCardStepHandler = ChoseCardStepHandler(this, gameContext)
    private val specialCardEffectStep = SpecialCardEffectStep(this, gameContext)
    private val getCardStep = GetCardStep(this, gameContext)
    private val underEffectVerificationStep = UnderEffectVerificationStep(this, gameContext)
    private val validatePlayerHasCardStep = ValidatePlayerHasCardStep(this, gameContext)
    private val validateCardPlayedStep = ValidateCardPlayedStep(this, gameContext)
    private val selectColorStepHandler = SelectColorStepHandler(this, gameContext)
    private val playerBlockedStep = PlayerBlockedStep(this, gameContext)
    private val revertGameDirectionStep = RevertGameDirectionStep(this, gameContext)
    private val nextTurnStep = NextTurnStep(this, gameContext)
    private val endGameStep = EndGameStep(this, gameContext)

    init {
        notify(MediatorEvent.UNDER_EFFECT_VERIFICATION)
    }

    override fun notify(vararg nextEvent: MediatorEvent) {
        nextEvent.forEachIndexed { index, event ->
            if (index > 0) {
                underEffectVerificationStep.setNext(getHandler(event))
            }
        }
        getHandler(nextEvent.first()).execute()
    }

    private fun getHandler(event: MediatorEvent): Step {
        return when (event) {
            MediatorEvent.UNDER_EFFECT_VERIFICATION -> underEffectVerificationStep
            MediatorEvent.VALIDATE_IF_PLAYER_HAS_CARDS -> validatePlayerHasCardStep
            MediatorEvent.VALIDATE_CARD_PLAYED -> validateCardPlayedStep
            MediatorEvent.MAKE_PLAYER_GET_CARDS -> getCardStep
            MediatorEvent.CHOSE_CARD -> choseCardStepHandler
            MediatorEvent.SELECT_COLOR_GAME -> selectColorStepHandler
            MediatorEvent.PLAYER_BLOCKED -> playerBlockedStep
            MediatorEvent.REVERT_GAME_DIRECTION -> revertGameDirectionStep
            MediatorEvent.ACTIVATE_SPECIAL_CARD_EFFECT -> specialCardEffectStep
            MediatorEvent.NEXT_TURN -> nextTurnStep
            MediatorEvent.END_GAME -> endGameStep
            else -> getCardStep
        }
    }

}

Veja que a classe que implementa a interface que tem todas as instâncias das classes de Step. Assim, quando uma nova classe de Step surgir, o GameMediator quem deve adiciona-lá aqui gerenciar qual evento que é responsável acionar esse novo Step.

O que aprendemos com o padrão Mediator?

O padrão Mediator nos ajuda a manter o princípio da responsábilidade única, S do S.O.L.I.D, lembram? Após a aplicação do Mediator, tanto ele próprio tem bem definido o que deve fazer quanto as classes de Step também tem bem definido o que devem fazer.

O pricípio aberto/fechado (O do S.O.L.I.D) também foi respeitado graças a implementação desse padrão. Não é o nosso caso nesse projeto, mas caso os Steps fossem necessários para outros fluxos, eles estariam livres para serem utilizados por outros Mediators quando forem necessários.

É nítido que reduzimos o acomplamento e dependência entre Steps dentro do nosso projeto. Na verdade foi o motivo pelo qual o Mediator foi implementado :laughing:.

Você pode reutilizar os componentes quando quiser. A passagem de um Mediator como parâmetro poderia ser mudada para ser possível deixar o Step totalmente independente do Mediator caso fosse necessário.


Profile picture

Escrito por Marcelo Azevedo que mora no interior da paraíba tentando aprender coisinhas legais para ensinar a quem quiser ler. E ai, bora me seguir no LinkedIn?

© 2024, por mim mesmo 😝.