Compartir tecnología

Jugar Tetris a mano (3): diseño del módulo central del juego

2024-07-12

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

Jugar Tetris a mano: diseño del módulo central del juego

Empezar juego

Según el diseño anterior, necesitamos los elementos necesarios del juego para iniciarlo. Tomemos el ejemplo de ejecutar Tetris en la consola para explicarlo.

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

Analicémoslo línea por línea:

  • La introducción de paquetes se divide en tetris-core y tetris-console. Esta es la división de paquetes. Los componentes del paquete principal se colocan en tetris-core y la implementación específica se coloca en tetris-console.

  • Inicialización de tema, lienzo y controlador;

  • Inicialización de fábrica y dimensión;

  • Para la inicialización del juego, utilice los objetos de lienzo, fábrica, lienzo y dimensión previamente inicializados;

  • El juego llama al método de inicio.

A continuación, echemos un vistazo a lo que significa el inicio.

Lógica de inicio del juego


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

Analicémoslo línea por línea:

  1. Obtenerstatusvariable,statusparaGameLa representación interna del estado del juego, respectivamente.准备就绪(READY)游戏中(RUNNING)暂停(PAUSE)停止(STOP)游戏结束(OVER) . La diferencia entre detener y finalizar el juego es que el primero detiene activamente el juego, mientras que el segundo activa la lógica de finalización del juego y hace que el juego termine.

  2. Si el juego está en curso, regresa directamente;

  3. Si el juego se detiene y termina, entoncesStagerealizar un reinicio ycanvasRealice un rediseño general.

  4. Si el juego continúa mientras se prepara, significa que acaba de completar la inicialización y nunca comenzó. Llame al controlador para vincular eventos y dibujar el lienzo por primera vez;

  5. Establece el estado del juego en 游戏中(RUNNING), estado interno tickCount = 0;

  6. transferircanvasRealice una actualización parcial de inmediato. La actualización principal aquí es que el estado ha cambiado, lo que hace que sea necesario volver a representar el estado del juego;

  7. Inicie el cronómetro, el tiempo del cronómetro pasa a esta velocidad,speedEn el futuro, consideraremos combinarlo con el nivel del juego (aún no es compatible).

  • Si tickCount == 0, activa la acción de tick de una etapa y verifica si finaliza inmediatamente después de la activación;
  • Activar la operación de actualización del lienzo.
  • tickCount aumenta automáticamente y se restablece si se cumple >= tickMaxCount;

La razón por la que se introduce el mecanismo tickCount es principalmente para garantizar la frecuencia de actualización del lienzo. Generalmente, la frecuencia de actualización de la pantalla es mayor que la velocidad de stage.tick. Si las dos son consistentes, es posible que la interfaz del juego no sea fluida.

Marca de etapa

Como se puede ver en el código anterior, la lógica central del juego esstage.tick, su implementación interna es la siguiente:

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
  • Primero determine si el juego ha terminado o si se está realizando una operación de limpieza.

  • sicurrentSi está vacío, significa que el juego se carga por primera vez y se inicializa por separado.currentynext

  • Determinar si el juego alcanza la condición final, es decircurrentypoints Hay superposición. Si hay una superposición, el juego se marca como terminado.

  • Determine si la corriente actual puede moverse hacia abajo. Si puede moverse hacia abajo, muévala un espacio hacia abajo. De lo contrario, verifique si se puede eliminar.

A continuación veremos cómo detectar la eliminación, es decirhandleClearrealización.

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 puede verse en el código anterior, todo el proceso se divide en cuatro pasos:

  1. Copie un nuevo clon de puntos, incluidos los puntos actuales y actuales.

  2. Detectar puntosClonar línea por línea y marcar si toda la línea está llena;

  3. Eliminar línea por línea según el contenido marcado generado en 2. Tenga en cuenta que la operación de eliminación se realiza de arriba a abajo. Al eliminar una fila, se agrega una fila en blanco desde la parte superior.

  4. Trabajo de limpieza.Este paso debe realizarse independientemente de si se realiza la operación de limpieza. Asignar puntosClonar a.this.points, completo al mismo tiempocurrentynextcambiar.

girar

¿Qué pasa con la rotación de los bloques?

Todos los comportamientos de rotación se activan llamando al método game.rotate, incluidos los eventos definidos por el controlador, llamadas externas, etc.;

La lógica implementada en Game es la siguiente:

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

Ver siguienteStagerealización

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
  • juez primerocurrentSi existe, si no existe, se devolverá directamente;

  • transferircurrentdecanRotateMétodo para verificar si la posición actual se puede rotar; si se puede seleccionar, llame al método de rotación para rotar.

Vayamos más lejos y veamosBlockdecanRotateyrotatemé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

miremos primerocanRotaterealización.

  • Obtenga centerIndex, centerIndex es el índice del punto central de rotación. Cada gráfico es diferente, como por ejemplo IBlock, que se define de la siguiente manera:

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

    Es decir, el punto central de rotación es el segundo nodo.como口口口口, el segundo punto central口田口口

    Además, al diseñar este bloque, también consideramos que algunos bloques no se pueden rotar, como OBlock, que no se puede seleccionar.perogetCenterIndexdevolver-1

  • Obtenga la matriz de cambios. La matriz se define como el ángulo de la rotación actual. La longitud de la matriz representa el número de rotaciones. El contenido de la matriz representa el ángulo de esta rotación en relación con la última rotación.comoIBlockse define de la siguiente manera:

    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

    Es decir, la primera rotación es el estado inicial Math.PI/2 (es decir, 90 grados), y la segunda rotación es -Math.PI/2 de la primera rotación (es decir, -90 grados). como sigue:

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

    PD: tenga en cuenta aquí que el eje de coordenadas es de izquierda a derecha y de arriba a abajo.

  • Para el juicio de rotación, los criterios de juicio son:

    1. Los puntos de coordenadas rotados no pueden exceder los límites de todo el juego;
    2. Los puntos de coordenadas girados no pueden ocupar los puntos del cuadrado relleno.

    Por lo tanto, vemos que hayisValidynewPoints.everyjuicio.

veamos a continuaciónBlock.rotate,como sigue:

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

A través de la descripción anterior,rotateLa lógica es fácil de entender.

  • ObtenercenterIndexychanges,VoluntadcurrentChangeIndexRealice un incremento cíclico y apunte el bloque a las nuevas coordenadas.

  • encurrentChangeIndex El valor inicial es -1, lo que significa que la rotación actual está en progreso, y si es mayor o igual a 0, significa que se selecciona el índice + 1. (Piense detenidamente aquí, porque el índice de la matriz comienza desde 0)

mover

Movimiento significa mover el Bloque en cuatro direcciones.Veamos su implementación.

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

Entre ellos, Dirección se define de la siguiente manera:

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

Mira más alláStageImplementación 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

Mira más allácanMoveymoverealización.

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

Traducámoslo brevemente de la siguiente manera:

  • Para subir, todos los puntos del eje y deben ser mayores que 0 (es decir, mayores o iguales a 1) y los puntos después del movimiento deben ser puntos vacíos;

  • Desplazarse hacia la izquierda, todos los puntos del eje x deben ser mayores que 0 (es decir, mayores o iguales a 1) y los puntos después del movimiento deben ser puntos vacíos;

  • Desplazarse hacia la derecha, todos los puntos del eje x deben ser menores que la longitud del eje de coordenadas x -1 (es decir, menor o igual que xSize - 2), y los puntos después del movimiento deben ser puntos vacíos;

  • Para moverse hacia abajo, todos los puntos del eje y deben ser menores que la longitud del eje de coordenadas y -1 (es decir, menor o igual que ySize - 2), y los puntos después del movimiento deben ser puntos vacíos.

Después de cumplir las condiciones de movimiento, veamosmoverealización.

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 directamente el valor del punto de coordenadas.

resumen

Este capítulo describe tres comportamientos importantes del juego: limpiar, rotar y moverse. Los tres cooperan entre sí para completar el juego. En el próximo capítulo compartiremos la representación de la interfaz y el control de operación del juego.