技術共有

テトリスを手でプレイする (3) - ゲームのコアモジュール設計

2024-07-12

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

テトリスを手でプレイする - ゲームのコアモジュール設計

ゲームをスタート

前の設計に従って、ゲームを開始するにはゲームに必要な要素が必要です。コンソールでテトリスを実行する例を見てみましょう。

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

それを一行ずつ分析してみましょう。

  • パッケージの紹介は tetris-core と tetris-console に分かれています。これは、コア パッケージのコンポーネントが tetris-core に配置され、具体的な実装が tetris-console に配置されます。

  • テーマ、キャンバス、コントローラーの初期化。

  • ファクトリとディメンションの初期化。

  • ゲームの初期化には、以前に初期化されたキャンバス、ファクトリ、キャンバス、およびディメンション オブジェクトを使用します。

  • ゲームは start メソッドを呼び出します。

次に、start が何をするのかを見てみましょう。

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

一行ずつ分析してみましょう。

  1. 得るstatus変数、statusのためにGameゲーム状態の内部表現はそれぞれ准备就绪(READY)游戏中(RUNNING)暂停(PAUSE)停止(STOP)游戏结束(OVER) 。停止とゲーム終了の違いは、前者はゲームを積極的に停止するのに対し、後者はゲームの終了ロジックをトリガーしてゲームを終了させることです。

  2. ゲームが進行中の場合は、直接戻ります。

  3. ゲームが停止してゲームオーバーになった場合、Stageリセットを実行し、canvas全体的な再描画を実行します。

  4. 準備中にゲームが続行されている場合、それはゲームが初期化を完了したばかりで、まだ開始されていないことを意味します。コントローラーを呼び出してイベントをバインドし、初めてキャンバスを描画します。

  5. ゲームの状態を次のように設定します 游戏中(RUNNING)、内部状態tickCount = 0;

  6. 移行canvas部分的な更新をすぐに実行します。ここでの主な更新は、ステータスが変更され、ゲーム ステータスの再レンダリングが必要になることです。

  7. タイマーを開始します。タイマー時間が経過すると、この速度が経過します。speed今後はゲームレベルに合わせて検討していきます(未対応)。

  • ticCount == 0 の場合、ステージのティック アクションをトリガーし、トリガー直後に終了するかどうかを確認します。
  • キャンバスの更新操作をトリガーする
  • tinyCount は自動的に増加し、>= tinyMaxCount が満たされるとリセットされます。

tinyCount メカニズムが導入される理由は、主に Canvas の更新頻度を確保するためです。一般に、画面のリフレッシュ レートが stage.tick の速度よりも高いと、ゲーム インターフェイスがスムーズにならない可能性があります。

ステージティック

上記のコードからわかるように、ゲームの核となるロジックは次のとおりです。stage.tick、その内部実装は次のとおりです。

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
  • まず、ゲームが終了したか、クリーンアップ操作が実行されているかを判断します。

  • もしcurrent空の場合は、ゲームが初めてロードされ、個別に初期化されることを意味します。currentそしてnext

  • ゲームが終了条件に達したかどうかを判断します。currentそしてpoints重複があります。重複がある場合、ゲームは終了としてマークされます。

  • 現在の流れが下に移動できるかどうかを判断します。下に移動できる場合は、1 つ下に移動します。そうでない場合は、電流を除去できるかどうかを確認します。

次に、除去を検出する方法を見ていきます。handleClear実現。

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

上記のコードからわかるように、プロセス全体は 4 つのステップに分かれています。

  1. 現在のポイントと現在のポイントを含む新しい PointsClone をコピーします。

  2. ポイントを検出し、行ごとにクローンを作成し、行全体が塗りつぶされているかどうかをマークします。

  3. 2で生成したマーク内容に従って1行ずつ削除していきます。なお、削除操作は上から下へ行われます。行を削除すると、上から空白行が追加されます。

  4. 清掃作業。この手順は、ポイントクローンの割り当てをクリアするかどうかに関係なく実行する必要があります。this.points、同時に完了しますcurrentそしてnextスイッチ。

回転させる

ブロックの回転はどうなっているのでしょうか?

すべての回転動作は、コントローラーによって定義されたイベント、外部呼び出しなどを含め、game.rotate メソッドを呼び出すことによってトリガーされます。

ゲームに実装されたロジックは次のとおりです。

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

次を見るStage実現

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
  • 最初に判断するcurrent存在するかどうかに関係なく、存在しない場合は直接返されます。

  • 移行currentcanRotate現在位置を回転できるかどうかを確認するメソッド。選択できる場合は、回転メソッドを呼び出して回転します。

さらに進んで見てみましょうBlockcanRotateそしてrotate方法。

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

まずは見てみましょうcanRotate実現。

  • centerIndex を取得します。centerIndex は回転の中心点のインデックスです。各グラフィックは異なります (IBlock など)。次のように定義されます。

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

    つまり、回転中心点が第2ノードとなる。のように口口口口、2 番目の中心点口田口口

    また、このブロックを設計する際には、OBlock など回転できないブロックが選択できないことも考慮しました。しかしgetCenterIndex戻る-1

  • 変更配列を取得します。配列は、現在の回転の角度として定義されます。配列の長さは、最後の回転に対する相対的な回転の角度を表します。のようにIBlockは次のように定義されます。

    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

    つまり、1 回目の回転は初期状態の Math.PI / 2 (つまり 90 度) であり、2 回目の回転は 1 回目の回転の -Math.PI / 2 (つまり -90 度) です。次のように:

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

    PS: ここで、座標軸は左から右、上から下であることに注意してください。

  • 回転判定の判定基準は以下の通りです。

    1. 回転された座標ポイントは、ゲーム全体の境界を超えることはできません。
    2. 回転された座標点は、塗りつぶされた正方形の点を占めることはできません。

    したがって、次のことがわかります。isValidそしてnewPoints.every判定。

次に見てみましょうBlock.rotate、次のように:

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

以上の説明を通して、rotateロジックは理解しやすいです。

  • 得るcenterIndexそしてchanges、意思currentChangeIndex循環インクリメントを実行し、ブロックを新しい座標にポイントします。

  • currentChangeIndex初期値は -1 で、現在の回転が進行中であることを意味します。0 以上の場合は、インデックス + 1 が選択されていることを意味します。 (配列のインデックスは0から始まるので、ここはよく考えてください)

動く

移動とはブロックを4方向に動かすことを意味します。その実装を見てみましょう

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

このうち、方向は次のように定義されます。

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

さらに見てみるStage実装:

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

さらに見てみるcanMoveそしてmove実現。

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

以下のように簡単に訳してみます。

  • 上に移動するには、すべての y 軸点が 0 より大きい (つまり、1 以上) 必要があり、移動後の点は空の点である必要があります。

  • 左にシフトすると、すべての X 軸の点が 0 より大きい (つまり、1 以上) 必要があり、移動後の点は空の点でなければなりません。

  • 右にシフトすると、すべての X 軸点は X 座標軸の長さ -1 未満 (つまり、xSize - 2 以下) でなければならず、移動後の点は空の点でなければなりません。

  • 下に移動するには、すべての y 軸ポイントが y 座標軸の長さ -1 未満 (つまり、ySize - 2 以下) でなければならず、移動後のポイントは空のポイントでなければなりません。

移動条件を満たしたら見てみましょうmove実現。

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

座標点の値を直接変更します。

まとめ

この章では、ゲームの 3 つの重要な動作、クリア、回転、移動について説明します。 3人は協力してゲームをクリアしていきます。次の章では、ゲームのインターフェイスのレンダリングと操作制御について説明します。