2024-07-12
한어Русский языкEnglishFrançaisIndonesianSanskrit日本語DeutschPortuguêsΕλληνικάespañolItalianoSuomalainenLatina
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();
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?
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);
}
}
Let's analyze it line by line:
Obtainstatus
variable,status
forGame
The 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.
If the game is in progress, return directly;
If the game is in the stopped and game over states,Stage
Reset andcanvas
Perform an overall repaint.
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;
Set the game state to 游戏中(RUNNING)
, internal state tickCount = 0;
transfercanvas
Immediately perform a partial update. The update here mainly means that the status has changed, which causes the game status to be re-rendered.
Start the timer, and the timer time is determined by this.speed.speed
We will consider matching it with the game level in the future (not supported yet).
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.
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();
}
}
}
First determine whether the game is over or a cleanup operation is being performed.
ifcurrent
If it is empty, it means that the game is loaded for the first time, and initializecurrent
andnext
。
Determine whether the game has reached the end condition, that iscurrent
andpoints
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,handleClear
Implementation.
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);
}
}
}
As can be seen from the above code, the whole process is divided into four steps:
Duplicate a new pointsClone, including current and the current points.
Check pointsClone line by line, and mark if the entire line is filled;
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.
Cleanup work. This step is required regardless of whether the cleanup operation is performed. Assign pointsClone tothis.points
, while completingcurrent
andnext
Switch.
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();
}
}
Next watchStage
Implementation
class Stage {
rotate(): boolean {
if (!this.current) {
return false;
}
const canChange = this.current.canRotate(this.points);
if (canChange) {
this.current.rotate();
}
return false;
}
}
First judgecurrent
If it does not exist, return directly;
transfercurrent
ofcanRotate
Method 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 checkBlock
ofcanRotate
androtate
method.
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;
}
}
Let’s look firstcanRotate
Implementation.
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;
}
}
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.getCenterIndex
return-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.IBlock
The definition of is as follows:
class IBlock extends Block {
currentChangeIndex: number = -1;
getChanges(): number[] {
return [
Math.PI / 2,
0 - Math.PI / 2
];
}
}
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:
// 初始状态
// 口田口口
// 第一次旋转
// 口
// 田
// 口
// 口
// 第二次旋转
// 口田口口
PS: Please note that the coordinate axis is from left to right and from top to bottom.
Perform rotation judgment, the judgment criteria are:
Therefore, we see that there areisValid
andnewPoints.every
judgment.
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;
}
}
Through the above description,rotate
The logic is easy to understand.
ObtaincenterIndex
andchanges
,WillcurrentChangeIndex
Perform loop self-increment and point the Block to the new coordinates.
incurrentChangeIndex
The 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 means moving the Block in four directions. Let's see its implementation
class Game {
move(direction: Direction) {
this.stage.move(direction);
this.canvas.update();
}
}
Where Direction is defined as follows:
type Direction = 'up' | 'down' | 'left' | 'right';
Further readingStage
Implementation:
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;
}
}
Further readingcanMove
andmove
Implementation.
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;
}
});
};
}
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 atmove
Implementation.
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;
}
}
Directly modify the value of the coordinate point.
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.