Compartilhamento de tecnologia

Jogar Tetris manualmente (3) - design do módulo principal do jogo

2024-07-12

한어Русский языкEnglishFrançaisIndonesianSanskrit日本語DeutschPortuguêsΕλληνικάespañolItalianoSuomalainenLatina

Jogando Tetris manualmente - design do módulo principal do jogo

Começar o jogo

De acordo com o design anterior, precisamos dos elementos do jogo necessários para iniciar o jogo. Vamos dar o exemplo de rodar o Tetris no console para explicar.

import { ConsoleCanvas, ConsoleController, ConsoleColorTheme, Color } from '@shushanfx/tetris-console';
import { Dimension, ColorFactory, Game } from '@shushanfx/tetris-core';

const theme = new ConsoleColorTheme();
const canvas = new ConsoleCanvas(theme);
const controller = new ConsoleController();
const dimension = new Dimension(10, 20);
const factory = new ColorFactory(dimension, [
  Color.red,
  Color.green,
  Color.yellow,
  Color.blue,
  Color.magenta,
  Color.cyan,
]);
const game = new Game({ dimension, canvas, factory, controller });
game.start();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

Vamos analisar linha por linha:

  • A introdução dos pacotes é dividida em tetris-core e tetris-console. Esta é a divisão dos pacotes. Os componentes do pacote principal são colocados no tetris-core, e a implementação específica é colocada no tetris-console.

  • Inicialização de tema, canvas e controlador;

  • Inicialização de fábrica e dimensão;

  • Para inicialização do jogo, use os objetos canvas, factory, canvas e dimension previamente inicializados;

  • O jogo chama o método start.

A seguir, vamos dar uma olhada no que o start faz?

Lógica Game.start


class Game {
  start() {
    const { status } = this;
    if (status === GameStatus.RUNNING) {
      return ;
    }
    if (status === GameStatus.OVER
      || status === GameStatus.STOP) {
      this.stage.reset();
      this.canvas.render();
    } else if (status === GameStatus.READY) {
      this.controller?.bind();
      this.canvas.render();
    }
    this.status = GameStatus.RUNNING;
    this.tickCount = 0;
    this.canvas.update();
    // @ts-ignore
    this.tickTimer = setInterval(() => {
      if (this.tickCount == 0) {
        // 处理向下
        this.stage.tick();
        this.checkIsOver();
      }
      this.canvas.update();
      this.tickCount++;
      if (this.tickCount >= this.tickMaxCount) {
        this.tickCount = 0;
      }
    }, this.speed);
  }
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34

Vamos analisar linha por linha:

  1. Obtivermosstatusvariável,statusparaGameA representação interna do estado do jogo, respectivamente准备就绪(READY)游戏中(RUNNING)暂停(PAUSE)停止(STOP)游戏结束(OVER) . A diferença entre parar e terminar o jogo é que o primeiro interrompe ativamente o jogo, enquanto o último aciona a lógica de final do jogo e faz com que o jogo termine.

  2. Se o jogo estiver em andamento, retorne diretamente;

  3. Se o jogo for interrompido e o jogo terminar, entãoStagefaça um reset ecanvasExecute um redesenho geral.

  4. Se o jogo continuar durante a preparação, significa que o jogo acabou de concluir a inicialização e nunca foi iniciado. Chame o controlador para vincular eventos e desenhar a tela pela primeira vez;

  5. Defina o estado do jogo para 游戏中(RUNNING), estado interno tickCount = 0;

  6. transferircanvasExecute uma atualização parcial imediatamente. A principal atualização aqui é que o status mudou, fazendo com que o status do jogo precise ser renderizado novamente;

  7. Inicie o cronômetro, o tempo do cronômetro passa assim.velocidade,speedNo futuro, consideraremos combiná-lo com o nível do jogo (ainda não suportado).

  • Se tickCount == 0, acione a ação de tick de um estágio e verifique se ela termina imediatamente após o acionamento;
  • Acione a operação de atualização do canvas
  • tickCount aumenta automaticamente e é redefinido se >= tickMaxCount for satisfeito;

A razão pela qual o mecanismo tickCount foi introduzido é principalmente para garantir a frequência de atualização da tela. Geralmente, a taxa de atualização da tela é maior que a velocidade do stage.tick. Se os dois forem consistentes, a interface do jogo pode não ser suave.

Carrapato de palco

Como pode ser visto no código acima, a lógica central do jogo éstage.tick, sua implementação interna é a seguinte:

class Stage {
  tick(): void {
    if (this.isOver || this.clearTimers.length > 0) {
      return;
    }
    // 首次加载,current为空
    if (!this.current) {
      this.next = this.factory.randomBlock();
      this.toTop(this.next);
      this.current = this.factory.randomBlock();
      this.toTop(this.current);
      return ;
    }
    const isOver = this.current.points.some((point) => {
      return !this.points[point.y][point.x].isEmpty;
    });
    if (isOver) {
      this.isOver = true;
      return;
    }
    const canMove = this.current.canMove('down', this.points);
    if (canMove) {
      this.current.move('down');
    } else {
      this.handleClear();
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • Primeiro determine se o jogo terminou ou se uma operação de limpeza está sendo executada.

  • securrentSe estiver vazio, significa que o jogo foi carregado pela primeira vez e inicializado separadamente.currentenext

  • Determine se o jogo atinge a condição final, ou sejacurrentepoints Há sobreposição. Se houver uma sobreposição, o jogo será marcado como encerrado.

  • Determine se a corrente atual pode se mover para baixo. Se ela puder se mover para baixo, mova-a um espaço para baixo. Caso contrário, verifique se ela pode ser eliminada.

A seguir, veremos como detectar a eliminação, ou sejahandleClearrealização.

class Stage {
  private handleClear() {
    if (!this.current) {
      return;
    }
    // 1. 复制新的points
    const pointsClone: Point[][] = this.points.map((row) => row.map((point) => point.clone()));
    this.current.points.forEach((point) => {
      pointsClone[point.y][point.x] = point.clone();
    });
    // 2. 检查是否有消除的行
    const cleanRows: number[] = [];
    for(let i = 0; i < pointsClone.length; i ++) {
      const row = pointsClone[i];
      const isFull = row.every((point) => {
        return !point.isEmpty
      });
      if (isFull) {
        cleanRows.push(i);
      }
    }
    // 3. 对行进行消除
    if (cleanRows.length > 0) {
      this.startClear(pointsClone, cleanRows, () => {
        // 处理计算分数
        this.score += this.getScore(cleanRows.length);
        // 处理消除和下落
        cleanRows.forEach((rowIndex) => {
          for(let i = rowIndex; i >= 0; i--) {
            if (i === 0) {
              pointsClone[0] = Array.from({ length: this.dimension.xSize }, () => new Point(-1, -1));
            } else {
              pointsClone[i] = pointsClone[i - 1];
            }
          }
        });
        // 4. 扫尾工作,变量赋值
        this.points = pointsClone;
        this.current = this.next;
        this.next = this.factory.randomBlock();
        this.toTop(this.next);
      });
    } else {
      // 4. 扫尾工作,变量赋值
      this.points = pointsClone;
      this.current = this.next;
      this.next = this.factory.randomBlock();
      this.toTop(this.next);
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51

Como pode ser visto no código acima, todo o processo é dividido em quatro etapas:

  1. Copie um novo pointsClone, incluindo os pontos atuais e atuais.

  2. Detectar pontosClone linha por linha e marque se a linha inteira está preenchida;

  3. Exclua linha por linha de acordo com o conteúdo marcado gerado em 2. Observe que a operação de exclusão é executada de cima para baixo. Ao excluir uma linha, uma linha em branco é adicionada de cima.

  4. Trabalho de limpeza.Esta etapa precisa ser executada independentemente de a operação de limpeza ser executada. Assign pointsClone to.this.points, complete ao mesmo tempocurrentenexttrocar.

girar

O que está acontecendo com a rotação dos blocos?

Todos os comportamentos de rotação são acionados pela chamada do método game.rotate, incluindo eventos definidos pelo controlador, chamadas externas, etc.;

A lógica implementada no Game é a seguinte:

class Game {
  rotate() {
    this.stage.rotate();  
    this.canvas.update();
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

Assista a seguirStagerealização

class Stage {
  rotate(): boolean {
    if (!this.current) {
      return false;
    }
    const canChange = this.current.canRotate(this.points);
    if (canChange) {
      this.current.rotate();
    }
    return false;
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • julgar primeirocurrentSe existir, se não existir, será devolvido diretamente;

  • transferircurrentdecanRotateMétodo para verificar se a posição atual pode ser girada, se puder ser selecionada, chame o método de rotação para girar;

Vamos mais longe e vejaBlockdecanRotateerotatemétodo.

class Block {
  canRotate(points: Point[][]): boolean {
    const centerIndex = this.getCenterIndex();
    if (centerIndex === -1) {
      return false;
    }
    const changes = this.getChanges();
    if (changes.length === 0) {
      return false;
    }
    const nextChange = changes[(this.currentChangeIndex + 1) % changes.length];
    const newPoints = this.changePoints(this.points, this.points[centerIndex], nextChange);
    const isValid = Block.isValid(newPoints, this.dimension);
    if (isValid) {
      return newPoints.every((point) => {
        return points[point.y][point.x].isEmpty;
      });
    }
    return isValid;
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

Vamos olhar primeirocanRotaterealização.

  • Obtenha centerIndex, centerIndex é o índice do ponto central de rotação. Cada gráfico é diferente, como o IBlock, que é definido da seguinte forma:

    class IBlock extends Block {
      getCenterIndex(): number {
        return 1;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    Ou seja, o ponto central de rotação é o segundo nó.como口口口口, o segundo ponto central口田口口

    Além disso, ao projetar este bloco, também consideramos que alguns blocos não podem ser girados, como o OBlock, que não pode ser selecionado.masgetCenterIndexretornar-1

  • Obtenha a matriz de alterações. A matriz é definida como o ângulo da rotação atual. O comprimento da matriz representa o número de rotações.comoIBlocké definido da seguinte forma:

    class IBlock extends Block {
      currentChangeIndex: number = -1;
      getChanges(): number[] {
        return [
          Math.PI / 2,
          0 - Math.PI / 2
        ];
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    Ou seja, a primeira rotação é o estado inicial Math.PI/2 (ou seja, 90 graus), e a segunda rotação é -Math.PI/2 da primeira rotação (ou seja, -90 graus). do seguinte modo:

    // 初始状态
    // 口田口口
    
    // 第一次旋转
    //  口
    //  田
    //  口
    //  口
    
    // 第二次旋转
    // 口田口口
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    PS: Observe aqui que o eixo de coordenadas é da esquerda para a direita e de cima para baixo.

  • Para julgamento de rotação, os critérios de julgamento são:

    1. Os pontos coordenados girados não podem exceder os limites de todo o jogo;
    2. Os pontos coordenados girados não podem ocupar os pontos do quadrado preenchido.

    Portanto, vemos que existemisValidenewPoints.everyjulgamento.

Vamos ver a seguirBlock.rotate,do seguinte modo:

class Block {
  rotate() {
    const centerIndex = this.getCenterIndex();
    if (centerIndex === -1) {
      return false;
    }
    const changes = this.getChanges();
    if (changes.length === 0) {
      return false;
    }
    const nextChange = changes[(this.currentChangeIndex + 1) % changes.length];
    const newPoints = this.changePoints(this.points, this.points[centerIndex], nextChange);
    const isValid = Block.isValid(newPoints, this.dimension);
    if (isValid) {
      this.currentChangeIndex = (this.currentChangeIndex + 1) % changes.length;
      this.points = newPoints;
    }
    return isValid;
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

Através da descrição acima,rotateA lógica é fácil de entender.

  • ObtivermoscenterIndexechanges,VaicurrentChangeIndexExecute um incremento cíclico e aponte o Bloco para as novas coordenadas.

  • emcurrentChangeIndex O valor inicial é -1, o que significa que a rotação atual está em andamento, e se for maior ou igual a 0, significa que o índice + 1 está selecionado. (Por favor, pense com cuidado aqui, porque o índice do array começa em 0)

mover

Movimento significa mover o Bloco em quatro direções.Vamos ver sua implementação

class Game {
  move(direction: Direction) {
    this.stage.move(direction);
    this.canvas.update();
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

Entre eles, a Direção é definida da seguinte forma:

type Direction = 'up' | 'down' | 'left' | 'right'
  • 1

Olhar mais longeStageImplementação de:

class Stage {
  move(direction: Direction) {
    if (!this.current) {
      return false;
    }
    const canMove = this.current.canMove(direction, this.points);
    if (canMove) {
      this.current.move(direction);
    }
    return canMove;
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

Olhar mais longecanMoveemoverealização.

class Block {
  canMove(direction: Direction, points: Point[][]): boolean {
    return this.points.every((point) => {
      switch (direction) {
        case 'up':
          return point.y > 0 && points[point.y - 1][point.x].isEmpty;
        case 'down':
          return point.y < this.dimension.ySize - 1 && points[point.y + 1][point.x].isEmpty;
        case 'left':
          return point.x > 0 && points[point.y][point.x - 1].isEmpty;
        case 'right':
          return point.x < this.dimension.xSize - 1 && points[point.y][point.x + 1].isEmpty;
      }
    });
  };
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

Vamos traduzi-lo brevemente da seguinte forma:

  • Para subir, todos os pontos do eixo y devem ser maiores que 0 (ou seja, maiores ou iguais a 1) e os pontos após o movimento devem ser pontos vazios;

  • Desloque para a esquerda, todos os pontos do eixo x devem ser maiores que 0 (ou seja, maiores ou iguais a 1) e os pontos após o movimento devem ser pontos vazios;

  • Deslocando para a direita, todos os pontos do eixo x devem ser menores que o comprimento do eixo da coordenada x -1 (ou seja, menores ou iguais a xSize - 2) e os pontos após o movimento devem ser pontos vazios;

  • Para mover para baixo, todos os pontos do eixo y devem ser menores que o comprimento do eixo da coordenada y -1 (ou seja, menores ou iguais a ySize - 2) e os pontos após o movimento devem ser pontos vazios.

Depois de atender às condições de movimento, vejamosmoverealização.

class Block {
  move(direction: Direction): boolean {
    switch (direction) {
      case 'up':
        this.points.forEach((point) => { point.y = point.y - 1})
        break;
      case 'down':
        this.points.forEach((point) => { point.y = point.y + 1})
        break;
      case 'left':
        this.points.forEach((point) => { point.x = point.x - 1})
        break;
      case 'right':
        this.points.forEach((point) => { point.x = point.x + 1})
        break;
    }
    return true;
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

Modifique diretamente o valor do ponto de coordenadas.

resumo

Este capítulo descreve três comportamentos importantes do jogo: limpar, girar e mover. Os três cooperam entre si para completar o jogo. No próximo capítulo compartilharemos a renderização da interface e o controle de operação do jogo.