Technologieaustausch

Tetris von Hand spielen (3) – Kernmoduldesign des Spiels

2024-07-12

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

Tetris von Hand spielen – Kernmoduldesign des Spiels

Spiel beginnen

Gemäß dem vorherigen Entwurf benötigen wir zum Starten des Spiels die erforderlichen Elemente. Nehmen wir zur Erläuterung das Beispiel der Ausführung von Tetris auf der Konsole.

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

Lassen Sie es uns Zeile für Zeile analysieren:

  • Die Einführung von Paketen ist in Tetris-Kern und Tetris-Konsole unterteilt. Die Komponenten des Kernpakets werden in Tetris-Kern platziert, und die spezifische Implementierung wird in Tetris-Konsole platziert.

  • Initialisierung von Theme, Canvas und Controller;

  • Initialisierung von Fabrik und Dimension;

  • Verwenden Sie für die Spielinitialisierung die zuvor initialisierten Canvas-, Factory-, Canvas- und Dimensionsobjekte.

  • Das Spiel ruft die Startmethode auf.

Schauen wir uns als Nächstes an, was Start bewirkt.

Game.start-Logik


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

Lassen Sie es uns Zeile für Zeile analysieren:

  1. ErhaltenstatusVariable,statusfürGameDie interne Darstellung des Spielstatus准备就绪(READY)游戏中(RUNNING)暂停(PAUSE)停止(STOP)游戏结束(OVER) . Der Unterschied zwischen Stopp und Spielende besteht darin, dass ersteres das Spiel aktiv stoppt, während letzteres die Endlogik des Spiels auslöst und das Ende des Spiels bewirkt.

  2. Wenn das Spiel läuft, kehren Sie direkt zurück;

  3. Wenn das Spiel gestoppt und das Spiel vorbei ist, dannStageFühren Sie einen Reset durch undcanvasFühren Sie eine allgemeine Neuzeichnung durch.

  4. Wenn das Spiel während der Vorbereitung fortgesetzt wird, bedeutet dies, dass die Initialisierung gerade abgeschlossen und noch nie gestartet wurde. Rufen Sie den Controller auf, um Ereignisse zu binden und die Leinwand zum ersten Mal zu zeichnen.

  5. Stellen Sie den Spielstatus auf ein 游戏中(RUNNING), interner Zustand tickCount = 0;

  6. überweisencanvasFühren Sie sofort ein Teilupdate durch. Das Hauptupdate besteht darin, dass sich der Status geändert hat, sodass der Spielstatus neu gerendert werden muss.

  7. Starten Sie den Timer, die Timerzeit läuft ab. Geschwindigkeit,speedIn Zukunft werden wir darüber nachdenken, es an das Spielniveau anzupassen (noch nicht unterstützt).

  • Wenn tickCount == 0, lösen Sie die Tick-Aktion einer Stufe aus und prüfen Sie, ob sie unmittelbar nach dem Auslösen endet.
  • Lösen Sie den Aktualisierungsvorgang von Canvas aus
  • tickCount erhöht sich automatisch und wird zurückgesetzt, wenn >= tickMaxCount erfüllt ist;

Der Grund für die Einführung des tickCount-Mechanismus besteht hauptsächlich darin, die Aktualisierungsfrequenz von Canvas sicherzustellen. Im Allgemeinen ist die Bildschirmaktualisierungsrate höher als die Stage.tick-Geschwindigkeit. Wenn beide konsistent sind, ist die Spieloberfläche möglicherweise nicht reibungslos.

Bühnen-Tick

Wie aus dem obigen Code ersichtlich ist, ist die Kernlogik des Spielsstage.tick, seine interne Implementierung ist wie folgt:

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
  • Stellen Sie zunächst fest, ob das Spiel beendet ist oder eine Bereinigung durchgeführt wird.

  • WenncurrentWenn leer, bedeutet dies, dass das Spiel zum ersten Mal geladen und separat initialisiert wird.currentUndnext

  • Bestimmen Sie, ob das Spiel die Endbedingung erreichtcurrentUndpoints Es gibt Überschneidungen. Kommt es zu einer Überschneidung, wird das Spiel als beendet markiert.

  • Stellen Sie fest, ob sich der Strom nach unten bewegen kann. Wenn er sich nach unten bewegen kann, verschieben Sie ihn um ein Feld nach unten. Überprüfen Sie andernfalls, ob er beseitigt werden kann.

Als nächstes schauen wir uns an, wie man eine Eliminierung erkennthandleClearRealisierung.

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

Wie aus dem obigen Code ersichtlich ist, ist der gesamte Prozess in vier Schritte unterteilt:

  1. Kopieren Sie einen neuen Punkteklon, einschließlich der aktuellen und aktuellen Punkte.

  2. Erkennen Sie Punkte. Klonen Sie Zeile für Zeile und markieren Sie, ob die gesamte Zeile ausgefüllt ist.

  3. Löschen Sie Zeile für Zeile entsprechend dem in 2 generierten markierten Inhalt. Beachten Sie, dass der Löschvorgang von oben nach unten durchgeführt wird. Beim Löschen einer Zeile wird von oben eine leere Zeile hinzugefügt.

  4. Aufräumarbeiten.Dieser Schritt muss unabhängig davon durchgeführt werden, ob der Löschvorgang ausgeführt wirdthis.points, gleichzeitig abschließencurrentUndnextschalten.

drehen

Was ist mit der Drehung der Blöcke los?

Alle Rotationsverhalten werden durch den Aufruf der game.rotate-Methode ausgelöst, einschließlich vom Controller definierter Ereignisse, externer Aufrufe usw.;

Die im Spiel implementierte Logik ist wie folgt:

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

Als nächstes ansehenStageRealisierung

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
  • Richter zuerstcurrentOb es existiert, wenn es nicht existiert, wird es direkt zurückgegeben;

  • überweisencurrentvoncanRotateMethode zum Überprüfen, ob die aktuelle Position gedreht werden kann. Wenn sie ausgewählt werden kann, rufen Sie die Rotationsmethode zum Drehen auf.

Gehen wir weiter und sehenBlockvoncanRotateUndrotateMethode.

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

Schauen wir zuerstcanRotateRealisierung.

  • Holen Sie sich centerIndex, centerIndex ist der Index des Mittelpunkts der Drehung. Jede Grafik ist anders, z. B. IBlock, der wie folgt definiert ist:

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

    Das heißt, der Rotationsmittelpunkt ist der zweite Knoten.wie口口口口, der zweite Mittelpunkt口田口口

    Darüber hinaus haben wir beim Entwerfen dieses Blocks auch berücksichtigt, dass einige Blöcke nicht gedreht werden können, z. B. OBlock, der nicht ausgewählt werden kann.AbergetCenterIndexzurückkehren-1

  • Holen Sie sich das Array „Änderungen“. Das Array ist als der Winkel der aktuellen Drehung definiert. Die Länge des Arrays stellt die Anzahl der Drehungen dar.wieIBlockist wie folgt definiert:

    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

    Das heißt, die erste Drehung ist der Anfangszustand Math.PI / 2 (also 90 Grad) und die zweite Drehung ist -Math.PI / 2 der ersten Drehung (also -90 Grad). wie folgt:

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

    PS: Bitte beachten Sie hier, dass die Koordinatenachse von links nach rechts und von oben nach unten verläuft.

  • Für die Rotationsbeurteilung gelten folgende Beurteilungskriterien:

    1. Die gedrehten Koordinatenpunkte dürfen die Grenzen des gesamten Spiels nicht überschreiten;
    2. Die gedrehten Koordinatenpunkte können nicht die Punkte des ausgefüllten Quadrats belegen.

    Deshalb sehen wir, dass es welche gibtisValidUndnewPoints.everyBeurteilung.

Mal sehen, wie es weitergehtBlock.rotate,wie folgt:

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

Durch die obige Beschreibung,rotateDie Logik ist leicht zu verstehen.

  • ErhaltencenterIndexUndchanges,WillecurrentChangeIndexFühren Sie eine zyklische Inkrementierung durch und richten Sie den Block auf die neuen Koordinaten aus.

  • IncurrentChangeIndex Der Anfangswert ist -1, was bedeutet, dass die aktuelle Drehung ausgeführt wird. Wenn er größer oder gleich 0 ist, bedeutet dies, dass Index + 1 ausgewählt ist. (Bitte denken Sie hier sorgfältig nach, da der Index des Arrays bei 0 beginnt.)

bewegen

Bewegung bedeutet, den Block in vier Richtungen zu bewegen.Sehen wir uns die Umsetzung an

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

Unter diesen ist die Richtung wie folgt definiert:

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

Schau weiterStageImplementierung von:

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

Schau weitercanMoveUndmoveRealisierung.

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

Lassen Sie es uns kurz wie folgt übersetzen:

  • Um nach oben zu gelangen, müssen alle y-Achsenpunkte größer als 0 sein (dh größer oder gleich 1), und die Punkte nach der Verschiebung müssen leere Punkte sein.

  • Nach links verschieben, alle x-Achsenpunkte müssen größer als 0 sein (dh größer oder gleich 1) und die Punkte nach der Verschiebung müssen leere Punkte sein.

  • Wenn Sie nach rechts verschieben, müssen alle x-Achsenpunkte kleiner als die Länge der x-Koordinatenachse -1 sein (dh kleiner oder gleich xSize - 2), und die Punkte nach der Verschiebung müssen leere Punkte sein.

  • Um sich nach unten zu bewegen, müssen alle y-Achsenpunkte kleiner als die Länge der y-Koordinatenachse -1 sein (d. h. kleiner oder gleich ySize - 2), und die Punkte nach der Bewegung müssen leere Punkte sein.

Nachdem wir die Bewegungsbedingungen erfüllt haben, werfen wir einen Blick daraufmoveRealisierung.

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

Ändern Sie direkt den Wert des Koordinatenpunkts.

Zusammenfassung

In diesem Kapitel werden drei wichtige Verhaltensweisen des Spiels beschrieben: Räumen, Drehen und Bewegen. Die drei arbeiten zusammen, um das Spiel zu beenden. Im nächsten Kapitel werden wir das Interface-Rendering und die Betriebssteuerung des Spiels teilen.