Explicando a lógica de movimento, captura e promoção de peças em um jogo de damas online

Bom dia/tarde/noite para os leitores! Nesta postagem, vou explicar a lógica que criei durante o desenvolvimento de um jogo de damas, espero que gostem!

Teoria

Entidades

Para o jogo funcionar, são necessário dessas 2 entidades:

  • Match - Armazena as informações relevantes da partida

    export class Match { // entidade do TypeORM
      uuid: UUID;
    
      player1: User; // ManyToOne
      player2: User; // ManyToOne
    
      winner: 'player1' | 'player2';
      winnerStatus: 'checkmate' | 'resign' | 'timeout';
    
      turn: 'player1' | 'player2';
    
      dateInit: Date;
      dateEnd?: Date;
    }
    
  • Piece - Representam cada peça do tabuleiro, sendo excluídas quando capturadas ou quando a partida é finalizada.

    export class Piece {
      id: number;
    
      match: Match; // ManyToOne
      player: 'player1' | 'player2';
    
      x: number;
      y: number;
    
      isQueen?: boolean;
    }
    

Como uma partida é iniciada

Quando um 2° jogador entra na fila de pareamento, os seguintes processos acontecem nessa ordem:

  1. Os dois jogadores são retirados da sala queue.
  2. Uma entidade Match é criada, juntamente com 12 Piece para cada jogador.
  3. Os dois jogadores entram na sala correspondente à partida.
  4. É enviada uma mensagem para os dois jogadores contendo todas as informações da partida.

Fluxo da Movimentação

Após a partida ser iniciada, o fluxo de movimentação ocorre da seguinte forma (desde que o jogador não desista ou não se desconecte):

  1. O jogador clica em uma peça.
  2. O frontend requisita ao backend os movimentos disponíveis para aquela peça.
  3. O frontend recebe as informações e exibe os movimentos disponíveis como casas verdes.
  4. O jogador clica na casa verde para onde deseja mover a peça.
  5. O frontend envia o ID da peça junto com as coordenadas do movimento para o backend.
  6. O backend executa a lógica/magia para validar o movimento, salva as alterações no banco de dados e envia informações úteis para ambos os jogadores, como:
    • Um array de coordenadas que corresponde ao trajeto até a coordenadas enviadas.
    • Se a peça que se moveu é ou se tornou uma dama.
    • O ID das peças capturadas.
  7. O backend também troca o turno para o outro jogador, faz uma contagem das peças e envia as informações para ambos os jogadores.
  8. O backend verifica se os dois jogadores ainda têm peças na partida. Se alguém não tiver, a partida termina com essa pessoa como perdedora. As peças são deletadas do banco e o resultado é enviado para ambos os jogadores.

Vocabulário

Mais adiante, falarei sobre palavras em um contexto específico. Quando me referir a elas, terão os seguintes significados:

  • Square (quadrado) ou casa - Refere-se às casas do tabuleiro, representadas no código por esta interface:

    interface Square {
      coord: Coord; // Coord = {x: number, y: number}
      piece?: Piece;
      side?: Square[][]; // side é um array de caminhos laterais, usado quando acontece captura em cadeia com curvas
    }
    
  • Direção - Uma das quatro direções em que uma peça pode se mover. Uso um enum para representá-las, armazenando também os sinais (- ou +) de x e y correspondentes a cada direção.

    const directions = {
      upRight: [1, 1], // [x, y]
      upLeft: [-1, 1],
      downRight: [1, -1],
      downLeft: [-1, -1],
    } as const;
    export type DMap = keyof typeof directions;
    
  • Caminhos ou path - Um array de casas que segue em uma das quatro direções.
  • Captura em cadeia com curvas - Refere-se a uma captura em série que muda de direção.

    Não sou muito bom com nomes.

Código

Hora do código! Agora vou mostrar cada método que compõe a lógica/magia de movimentação, captura e promoção. Para não ficar tão seco, vou comentar nas linhas de código o que está acontecendo.

  1. forEachSquare - Método que itera sobre as casas até os limites do tabuleiro ou até que o callback retorne um valor verdadeiro. Ele começa na coordenada especificada e segue na direção desejada.

    private forEachSquare(startSquare: Coord, direction: DMap, cb: (coord: Coord) => boolean | void) {
        let i = 1;
        while (true) {
          const [sinalX, sinalY] = directions[direction]; // pega os sinais da direção
          const x = startSquare.x + (sinalX * i); // Dê olhada em vocabulário > direção, pode ajudar a entender essa parte.
          const y = startSquare.y + (sinalY * i); 
    
          if (x < 0 || x > 7 || y < 0 || y > 7) break; // verifica se as coordenadas ainda estão dentro dos limites do tabuleiro
          if (cb({ x, y })) break; // chama o callback; se ele retornar true, termina o loop
          i++; // incrementa a diferença
        }
      }
    
  2. forEachSide - Lembra do "Captura em cadeia com curvas"? Ela acabou complicando pra caramba o código a partir daqui. Esse método itera as laterais de uma casa de acordo com a direção do caminho e será usado no próximo método.

     private forEachSide(dire: DMap, cb: (direLateral: DMap) => boolean | void) {
        const direR =  // monto a direção oposta da que estava indo. ex: upRight ⟷ downLeft
          (dire.includes('up') ? 'down' : 'up') + // obtém a direção vertical oposta
          (dire.includes('Right') ? 'Left' : 'Right'); // obtém a direção horizontal oposta
    
        const direKeys = Object.keys(directions) as DMap[]; // transformo as keys do enum em um array. ex: [upLeft, upRight, downLeft, downRight]
        const sides = direKeys.filter((d) => ![direR, dire].includes(d)); // filtro a direção que o caminho está indo e oposta, sobrando apenas os caminhos laterais
    
        for (const dire2 of sides) { // itero os caminhos laterais
          if (cb(dire2 as DMap)) break; // chamo o callback com cada caminho lateral
        }
      }
    
  3. createPath - Esse método é responsável por formar o caminho que a peça pode se mover em determinada direção e finalizá-lo quando encontra uma regra que interrompe o movimento.

      private createPath(
      { isQueen, player, ...cord }: Omit<Piece, 'id' | 'match'>, // a peça que está sendo movida
      pieces: Piece[], // as peças no tabuleiro
      direction: DMap, // a direção do movimento
      isSide = false, // indica se o movimento é lateral, utilizado em chamadas recursivas
    ) {
      const path: Square[] = []; // armazena o caminho formado
    
      // itera sobre as casas na direção especificada
      this.forEachSquare(cord, direction, ({ x, y }) => {
        const pieceInSquare = pieces.find((p) => p.x === x && p.y === y); // encontra a peça na casa atual, se houver
        const squareCurrent = { coord: { x, y }, piece: pieceInSquare, side: [] }; // cria o objeto square para representar a casa atual
        const squarePrev = path.at(-1); // pega a última casa do caminho
    
        // Verifica se a peça na casa atual pertence ao mesmo jogador, interrompendo o caminho se for
        if (squareCurrent?.piece?.player === player) return true;
    
        // Regras para a primeira casa no caminho
        if (path.length === 0) {
          // Se for um caminho lateral e a primeira casa estiver vazia, interrompe o caminho
          if (isSide && !squareCurrent.piece) return true;
    
          // Se a primeira casa estiver vazia e não for um caminho lateral
          if (!squareCurrent.piece) {
            path.push(squareCurrent);
             // Se a peça for uma dama, adiciona a primeira casa ao caminho e continua verificando as próximas
             // Se a peça não for uma dama, adiciona a primeira casa ao caminho e encerra o caminho (peça comum pode andar apenas uma casa se não for uma captura em cadeia)
            return isQueen ? false : true;
          }
        }
    
        // Verifica se o caminho deve ser interrompido após a primeira casa
        if (path.length > 0) {
          // Se ambas as casas (anterior e atual) estiverem vazias 
          const isBothEmpty = !squarePrev?.piece && !squareCurrent.piece;
          // Se ambas as casas (anterior e atual) estiverem ocupadas
          const isBothOccupied = squarePrev?.piece && squareCurrent.piece;
    
          // Verifica se chegou ao fim de uma sequência de capturas (para evitar que a dama continue após uma captura)
          const isEndChain = isBothEmpty && path.find((p) => p.piece);
    
          if ((isBothEmpty && !isQueen) || isBothOccupied || isEndChain) return true;
        }
    
        // Adiciona a casa atual ao caminho
        path.push(squareCurrent);
    
        // Verifica se houve uma captura
        const isCaptured = squarePrev?.piece && !squareCurrent.piece;
        if (!isCaptured) return; // se não houver captura, continua a iteração
    
        // Chama o método forEachSide para explorar as capturas laterais
        this.forEachSide(direction, (dire2) => {
          const side = this.createPath({ player, x, y }, pieces, dire2, true); // chamada recursiva para capturas laterais
          if (side.length) squareCurrent.side.push(side); // se houver capturas laterais, adiciona ao square atual
        });
      });
    
      return path; // retorna o caminho formado
    }
    
  4. createPaths - Método que gera um array de todas as direções disponíveis para a peça. Utiliza o método anterior.

    private createPaths(piece: Piece, pieces: Piece[]) {
      const mapDirections = Object.keys(directions) as DMap[]; 
      // Enquanto o player1 começa na parte inferior (Y:0,1,2), o player2 começa na parte superior (Y: 5, 6, 7).
      // Por isso é necessário inverter o array, pois o 'up' do player2 é na verdade o 'down'
      if (piece.player === 'player2') mapDirections.reverse(); 
    
      return mapDirections.map((direcao, i) => { 
        const path = this.createPath(piece, pieces, direcao as DMap);
    
        // É necessário identificar se é uma captura, pois apenas quando uma peça está capturando ou quando é uma dama que pode andar para trás.
        const isPieceCapture = path[0]?.piece && !path[1]?.piece;
        
        return i < 2 // se a peça estiver avançando para frente
            ? path // o caminho é válido, então é retornado
            : piece.isQueen || isPieceCapture  // se não, verificar se a peça é uma dama ou está capturando
                ? path  // se sim, o caminho ainda é válido, então é retornado
                : []; // se não, então não, o caminho não é válido e não é retornado
      });
    }
    

Divisão

A partir daqui, os métodos são divididos entre aqueles específicos para mostrar as casas nas quais uma peça pode se mover e aqueles que realmente movem a peça.

Até chegar no método público que retorna as coordenadas disponíveis para movimento

  1. flatPaths - Este método cria um array de coordenadas de casas disponiveis para se movimentar

    private flatPaths(path: Square[], coords = []): Coord[] {
      for (const square of path) { 
        const { side, coord } = square;
    
        if (!square.piece) coords.push(coord); // se a casa não contiver uma peça, adiciono sua coordenada ao array
        if (side?.length) side.forEach((s) => this.flatPaths(s, coords)); // se houver algum caminho lateral, chamo flatPaths neles com o array atual como argumento
      }
      return coords;
    }
    
  2. getPaths - É o método publico que o resto da aplicação chama para pegar os movimentos disponíveis.

    Realmente o nome não é muito bom, olhando agora deveria ser 'getMovements' ou algo assim

    getPaths(piece: Piece, pieces: Piece[]) { 
     // Geramos os caminhos disponíveis com createPaths e, em seguida, usamos o método nativo flatMap para achatar e mapear o array com usando flatPaths em conjunto
     const paths = this.createPaths(piece, pieces).flatMap((s) => this.flatPaths(s));
    
     if (!paths.length) 
       throw new BadRequestException('Essa peça não tem movimentos disponíveis');
    
     return paths;
    }
    

Até chegar no método público que move, captura e promove peças

  1. createChainSquare - Este método procura a casa para onde a peça quer se mover, enquanto cria uma cadeia (array) de casas até as coordenadas correspondentes ao movimento. Se as coordenadas de destino não forem encontradas no caminho fornecido, o método retorna null.
    private createChainSquare(
      coord: Coord, // Coordenada para onde a peça vai se mover
      path: Square[], // O caminho onde vai procurar
      chain: Square[] = [], // A cadeia que está sendo montada, necessário como parâmetro pois é recursiva
    ): Square[] | null {
      const pathAccum: Square[] = []; // Criando uma cadeia temporária
    
      for (const square of path) { // Iterando pelo caminho
        pathAccum.push(square); // Adiciona a casa na cadeia temporária
    
        const isFound = square.coord.x === coord.x && square.coord.y === coord.y; // Verifica se o quadrado atual corresponde à coordenada do movimento
        if (isFound) return [...chain, ...pathAccum]; // Se sim, termina a recursão e retorna a cadeia com o caminho temporário na ponta
    
        if (!square.side?.length) continue; // Se a casa atual não tiver caminhos laterais válidos, continue para a próxima casa
        const resultSides = square.side.map((pathSide) => // Se houver caminhos laterais, mapeie-os
          this.createChainSquare(coord, pathSide, [...chain, ...pathAccum]), // cria uma cadeia do caminho lateral a partir da cadeia atual
        );
        const result = resultSides.find((r) => r); // Verifica se a cadeia formada pelo caminho lateral contém as coordenadas do movimento
        if (result) return result; // Se sim, retorna a cadeia da lateral correspondente
      }
    
      return null; // Se nenhum caminho ou lateral correspondeu ao movimento, retorna null
    }
    
  2. movePiece - Este método percorre as direções disponiveis da peça em busca da coordenada do movimento. Quando encontra o caminho correto, ele transforma a cadeia de casas (que corresponde ao trajeto do movimento) em informações úteis.
    private movePiece(pieces: Piece[], piece: Piece, mov: Coord) {
      const paths = this.createPaths(piece, pieces); // gero os caminhos disponveis para peça
      for (const path of paths) { //itero esses caminhos
        const chain = this.createChainSquare(mov, path); // procuro uma cadeia de movimento para aquele movimento no caminho
        if (!chain) continue; // se o movimentação não está nesse caminho, pula para o proximo
    
        // Crio uma cadeia de movimento até a casa correspondente às coordenadas de movimentação
        const chainOfMotion = chain.filter((c) => !c.piece).map((c) => c.coord);
        // Obtenho o ID das peças que estão nas casas da cadeia, considerando-as como capturadas.
        const piecesDeads = chain.filter((c) => c.piece).map((c) => c.piece.id);
          
        // verifico se esse movimento é uma promoção vendo se a peça se moveu para a extremidade oposta
        const isPromotion = piece.player === 'player1' ? mov.y === 7 : mov.y === 0;
        const isQueen = piece.isQueen || isPromotion; // ela é uma dama se já for uma ou acabou de se promover
    
        return { isQueen, chainOfMotion, piecesDeads }; // informações úteis 👍
      }
      // se nenhum caminho contiver o movimento, retorna undefined
    }
    
  3. pieceMove - Este é o método público que a aplicação chama para mover uma peça. Ele utiliza os métodos anteriores para gerar informações úteis, salva as alterações no banco de dados e retorna esses dados.

    Na época não percebi que 'pieceMove' é apenas o nome do método anterior ao contrário...

     async pieceMove(piece: Piece, pieces: Piece[], mov: Coord) {
        const movePieceData = this.movePiece(pieces, piece, mov); // um método vai chamando outro até formar os dados correspondendo
        if (!movePieceData) throw new BadRequestException('Movimento inválido'); // se retornar undefined, o movimento não existe
        const { chainOfMotion, isQueen, piecesDeads } = movePieceData; 
    
        await this.dataSource.transaction(async (manager) => { // começo uma transação do typeOrm
          await manager.update(Piece, piece, { ...mov, isQueen }); // atualizo a coordenada da peça correspondendte
          if (piecesDeads.length) await manager.delete(Piece, piecesDeads); // deleto as peças capturadas
        });
    
        // retorno as informações
        return { pieceId: piece.id, isQueen, chainOfMotion, piecesDeads };
      }
    

Fim

É isso! O getPaths e o pieceMove são as pontas da classe que cuida dessa lógica. Você pode chamar qualquer uma delas com a peça que deseja mover, as peças da partida e, se quiser movimentar, as coordenadas também. Pronto! Ela cospe os movimentos disponíveis ou as alterações no tabuleiro que a movimentação da peça causou.

Links:

ótima explicação parabéns.