Partage de technologie

Jouer à Tetris à la main (3) - conception du module de base du jeu

2024-07-12

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

Jouer à Tetris à la main - conception du module de base du jeu

Démarrer jeu

Selon la conception précédente, nous avons besoin des éléments nécessaires du jeu pour démarrer le jeu. Prenons l'exemple de l'exécution de Tetris sur la console pour expliquer.

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

Analysons-le ligne par ligne :

  • L'introduction des packages est divisée en tetris-core et tetris-console. Il s'agit de la division des packages. Les composants du package principal sont placés dans tetris-core et l'implémentation spécifique est placée dans tetris-console.

  • Initialisation du thème, du canevas et du contrôleur ;

  • Initialisation de l'usine et des dimensions ;

  • Pour l'initialisation du jeu, utilisez les objets canevas, usine, canevas et dimension précédemment initialisés ;

  • Le jeu appelle la méthode start.

Voyons ensuite ce que fait start ?

Logique de démarrage du jeu


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

Analysons-le ligne par ligne :

  1. Obtenirstatusvariable,statuspourGameLa représentation interne de l'état du jeu, respectivement准备就绪(READY)游戏中(RUNNING)暂停(PAUSE)停止(STOP)游戏结束(OVER) . La différence entre l'arrêt et la fin du jeu est que le premier arrête activement le jeu, tandis que le second déclenche la logique de fin du jeu et provoque la fin du jeu.

  2. Si le jeu est en cours, revenez directement ;

  3. Si le jeu est arrêté et terminé, alorsStageeffectuer une réinitialisation etcanvasEffectuez un redessinage global.

  4. Si le jeu continue pendant la préparation, cela signifie que le jeu vient de terminer son initialisation et n'a jamais démarré. Appelez le contrôleur pour lier les événements et dessiner le canevas pour la première fois ;

  5. Définissez l'état du jeu sur 游戏中(RUNNING), état interne tickCount = 0 ;

  6. transfertcanvasEffectuez immédiatement une mise à jour partielle. La mise à jour principale ici est que le statut a changé, ce qui nécessite un nouveau rendu du statut du jeu ;

  7. Démarrez la minuterie, le temps de la minuterie dépasse cette vitesse,speedÀ l'avenir, nous envisagerons de le faire correspondre au niveau du jeu (pas encore pris en charge).

  • Si tickCount == 0, déclenchez l'action de tick d'une étape et vérifiez si elle se termine immédiatement après le déclenchement ;
  • Déclencher l'opération de mise à jour du canevas
  • tickCount augmente automatiquement et est réinitialisé si >= tickMaxCount est satisfait ;

La raison pour laquelle le mécanisme tickCount est introduit est principalement pour garantir la fréquence de mise à jour du canevas. Généralement, le taux de rafraîchissement de l'écran est supérieur à la vitesse de stage.tick. Si les deux sont cohérents, l'interface de jeu peut ne pas être fluide.

Étape tic

Comme le montre le code ci-dessus, la logique de base du jeu eststage.tick, sa mise en œuvre interne est la suivante :

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
  • Déterminez d’abord si le jeu est terminé ou si une opération de nettoyage est en cours.

  • sicurrentS'il est vide, cela signifie que le jeu est chargé pour la première fois et initialisé séparément.currentetnext

  • Déterminer si le jeu atteint la condition de fin, c'est-à-direcurrentetpoints Il y a un chevauchement. En cas de chevauchement, la partie est marquée comme terminée.

  • Déterminez si le courant actuel peut descendre. S'il peut descendre, déplacez-le d'un espace vers le bas. Sinon, vérifiez s'il peut être éliminé.

Nous verrons ensuite comment détecter l'élimination, c'est-à-direhandleClearla concrétisation.

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

Comme le montre le code ci-dessus, l’ensemble du processus est divisé en quatre étapes :

  1. Copiez un nouveau pointsClone, y compris les points actuels et actuels.

  2. Détecter les pointsCloner ligne par ligne et marquer si la ligne entière est remplie ;

  3. Supprimer ligne par ligne en fonction du contenu marqué généré en 2. Notez que l'opération de suppression s'effectue de haut en bas. Lors de la suppression d'une ligne, une ligne vide est ajoutée par le haut.

  4. Travaux de nettoyage.Cette étape doit être effectuée indépendamment du fait que l'opération de compensation soit effectuée ou non.this.points, terminer en même tempscurrentetnextchanger.

tourner

Que se passe-t-il avec la rotation des blocs ?

Tous les comportements de rotation sont déclenchés par l'appel de la méthode game.rotate, y compris les événements définis par le contrôleur, les appels externes, etc. ;

La logique implémentée dans Game est la suivante :

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

Regarder ensuiteStageLa concrétisation

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
  • juger d'abordcurrentQu'il existe, s'il n'existe pas, il sera restitué directement ;

  • transfertcurrentdecanRotateMéthode pour vérifier si la position actuelle peut être pivotée ; si elle peut être sélectionnée, appelez la méthode de rotation pour faire pivoter.

Allons plus loin et voyonsBlockdecanRotateetrotateméthode.

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

Regardons d'abordcanRotatela concrétisation.

  • Obtenez centerIndex, centerIndex est l'index du point central de rotation. Chaque graphique est différent, comme IBlock, qui est défini comme suit :

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

    Autrement dit, le point central de rotation est le deuxième nœud.comme口口口口, le deuxième point central口田口口

    De plus, lors de la conception de ce bloc, nous avons également considéré que certains blocs ne peuvent pas pivoter, comme OBlock, qui ne peut pas être sélectionné.maisgetCenterIndexretour-1

  • Récupère le tableau des modifications. Le tableau est défini comme l'angle de la rotation actuelle. La longueur du tableau représente le nombre de rotations. Le contenu du tableau représente l'angle de cette rotation par rapport à la dernière rotation.commeIBlockest défini comme suit :

    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

    C'est-à-dire que la première rotation est l'état initial Math.PI/2 (c'est-à-dire 90 degrés), et la deuxième rotation est -Math.PI/2 de la première rotation (c'est-à-dire -90 degrés). comme suit:

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

    PS : Veuillez noter ici que l'axe des coordonnées est de gauche à droite et de haut en bas.

  • Pour le jugement de rotation, les critères de jugement sont :

    1. Les points de coordonnées pivotés ne peuvent pas dépasser les limites du jeu entier ;
    2. Les points de coordonnées pivotés ne peuvent pas occuper les points du carré rempli.

    On voit donc qu'il y aisValidetnewPoints.everyjugement.

Voyons ensuiteBlock.rotate,comme suit:

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

Grâce à la description ci-dessus,rotateLa logique est facile à comprendre.

  • ObtenircenterIndexetchanges,VolontécurrentChangeIndexEffectuez un incrément cyclique et pointez le bloc vers les nouvelles coordonnées.

  • danscurrentChangeIndex La valeur initiale est -1, ce qui signifie que la rotation en cours est en cours, et si elle est supérieure ou égale à 0, cela signifie que l'index + 1 est sélectionné. (Veuillez bien réfléchir ici, car l'index du tableau commence à 0)

se déplacer

Le mouvement signifie déplacer le bloc dans quatre directions.Voyons sa mise en œuvre

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

Parmi eux, la Direction est définie comme suit :

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

Regardez plus loinStageImplémentation 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

Regardez plus loincanMoveetmovela concrétisation.

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

Traduisons-le brièvement comme suit :

  • Pour monter, tous les points de l'axe y doivent être supérieurs à 0 (c'est-à-dire supérieurs ou égaux à 1) et les points après le déplacement doivent être des points vides ;

  • Décalage vers la gauche, tous les points de l'axe x doivent être supérieurs à 0 (c'est-à-dire supérieurs ou égaux à 1) et les points après le déplacement doivent être des points vides ;

  • Décalage vers la droite, tous les points de l'axe x doivent être inférieurs à la longueur de l'axe des coordonnées x -1 (c'est-à-dire inférieur ou égal à xSize - 2) et les points après le déplacement doivent être des points vides ;

  • Pour descendre, tous les points de l'axe y doivent être inférieurs à la longueur de l'axe des coordonnées y -1 (c'est-à-dire inférieur ou égal à ySize - 2) et les points après le mouvement doivent être des points vides.

Après avoir satisfait aux conditions de mouvement, regardonsmovela concrétisation.

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

Modifiez directement la valeur du point de coordonnées.

résumé

Ce chapitre décrit trois comportements importants du jeu : dégager, faire pivoter et se déplacer. Les trois coopèrent pour terminer le jeu. Dans le prochain chapitre, nous partagerons le rendu de l'interface et le contrôle du fonctionnement du jeu.