Technology Sharing

Playing Tetris (Part 3) - Game Core Module Design

2024-07-12

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

Playing Tetris by hand - Game core module design

Start the game

According to the previous design, we need the necessary elements of the game before we can start the game. The following will take running Tetris on the console as an example to explain.

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

Let's analyze it line by line:

  • The introduction of the package is divided into tetris-core and tetris-console. This is the division of the package. The components of the core package are placed in tetris-core, and the specific implementation is placed in tetris-console.

  • Initialization of theme, canvas, and controller;

  • Initialization of factory and dimension;

  • Initialize the game, using the previously initialized canvas, factory, canvas, and dimension objects;

  • game calls the start method.

Next, let’s see what start does?

Game.start Logic


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

Let's analyze it line by line:

  1. Obtainstatusvariable,statusforGameThe internal representation of the game state is准备就绪(READY)游戏中(RUNNING)暂停(PAUSE)停止(STOP)游戏结束(OVER)The difference between stop and game end is that the former actively stops the game, while the latter triggers the game end logic, causing the game to end.

  2. If the game is in progress, return directly;

  3. If the game is in the stopped and game over states,StageReset andcanvasPerform an overall repaint.

  4. If the game is in the process of preparing to continue, it means that the game has just been initialized and has never been started. Call the controller to bind events and draw the canvas for the first time;

  5. Set the game state to 游戏中(RUNNING), internal state tickCount = 0;

  6. transfercanvasImmediately perform a partial update. The update here mainly means that the status has changed, which causes the game status to be re-rendered.

  7. Start the timer, and the timer time is determined by this.speed.speedWe will consider matching it with the game level in the future (not supported yet).

  • If tickCount == 0, a stage tick action is triggered, and immediately after the triggering, it is checked whether it is finished;
  • Trigger the update operation of canvas
  • tickCount increases automatically, and if it >= tickMaxCount, it is reset;

The reason for introducing the tickCount mechanism is mainly to ensure the update frequency of the canvas. Generally, the screen refresh rate should be higher than the stage.tick speed. If the two are kept consistent, the game interface may not be smooth.

Stage tick

As can be seen from the above code, the core logic of the game isstage.tick, its internal implementation is as follows:

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
  • First determine whether the game is over or a cleanup operation is being performed.

  • ifcurrentIf it is empty, it means that the game is loaded for the first time, and initializecurrentandnext

  • Determine whether the game has reached the end condition, that iscurrentandpoints There is overlap. If there is an overlap, the game is marked as over.

  • Determine whether the current can move down. If it can, move down one grid. Otherwise, check whether it can be eliminated.

Next, let's look at how to detect and eliminate, that is,handleClearImplementation.

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

As can be seen from the above code, the whole process is divided into four steps:

  1. Duplicate a new pointsClone, including current and the current points.

  2. Check pointsClone line by line, and mark if the entire line is filled;

  3. Delete the marked content generated in 2 line by line. Note that the deletion operation is performed from top to bottom, and a blank line is added from the top when a line is deleted.

  4. Cleanup work. This step is required regardless of whether the cleanup operation is performed. Assign pointsClone tothis.points, while completingcurrentandnextSwitch.

Rotate

What's going on with the cube rotation?

All rotation behaviors are triggered by calling the game.rotate method, including events defined by the controller, external calls, etc.

The logic implemented in Game is as follows:

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

Next watchStageImplementation

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
  • First judgecurrentIf it does not exist, return directly;

  • transfercurrentofcanRotateMethod to check whether the current position can be rotated; if it can be selected, call the rotation method to rotate it.

Let's go further and checkBlockofcanRotateandrotatemethod.

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

Let’s look firstcanRotateImplementation.

  • Get centerIndex, which is the index of the center point of rotation. This is different for each graphic, such as IBlock, which is defined as follows:

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

    That is, the rotation center is the second node.口口口口, the second center point口田口口

    In addition, when designing this block, it is also considered that some blocks cannot be rotated, such as OBlock, which cannot be selected.getCenterIndexreturn-1

  • Get the changes array, which is defined as the current rotation angle. The array length indicates the number of rotations, and the array content indicates the angle of this rotation relative to the last rotation.IBlockThe definition of is as follows:

    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

    That is, the first rotation is the initial state Math.PI / 2 (ie 90 degrees), and the second rotation is -Math.PI / 2 (ie -90 degrees) of the first rotation. As follows:

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

    PS: Please note that the coordinate axis is from left to right and from top to bottom.

  • Perform rotation judgment, the judgment criteria are:

    1. The coordinate point after rotation cannot exceed the boundary of the entire game;
    2. The rotated coordinate point cannot occupy the point of a filled block.

    Therefore, we see that there areisValidandnewPoints.everyjudgment.

Let's look at it nextBlock.rotate,as follows:

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

Through the above description,rotateThe logic is easy to understand.

  • ObtaincenterIndexandchanges,WillcurrentChangeIndexPerform loop self-increment and point the Block to the new coordinates.

  • incurrentChangeIndexThe initial value is -1, which means it is currently rotating. If it is greater than or equal to 0, it means selecting index + 1 times. (Please think carefully here, because the index of the array starts from 0)

move

Move means moving the Block in four directions. Let's see its implementation

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

Where Direction is defined as follows:

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

Further readingStageImplementation:

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

Further readingcanMoveandmoveImplementation.

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

We simply translate it as follows:

  • To move up, all y-axis points must be greater than 0 (i.e. greater than or equal to 1), and the points after the move must be empty points;

  • Move left, all x-axis points must be greater than 0 (that is, greater than or equal to 1), and the point after the move must be an empty point;

  • Move right, all x-axis points must be less than the x-axis length - 1 (that is, less than or equal to xSize - 2), and the points after moving must be empty points;

  • When moving downward, all y-axis points must be less than the y-axis length - 1 (that is, less than or equal to ySize - 2), and the points after moving must be empty points.

After the movement conditions are met, let's look atmoveImplementation.

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

Directly modify the value of the coordinate point.

summary

This chapter describes the three important behaviors of the game: clearing, rotating, and moving. The three of them work together to complete the game. In the next chapter, we will share the game's interface rendering and operation control.