Condivisione della tecnologia

Giocare a Tetris a mano (3) - design del modulo principale del gioco

2024-07-12

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

Giocare a Tetris manualmente: progettazione del modulo principale del gioco

Inizia il gioco

Secondo il progetto precedente, per avviare il gioco abbiamo bisogno degli elementi necessari. Per spiegarlo, prendiamo l'esempio dell'esecuzione di Tetris sulla console.

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

Analizziamolo riga per riga:

  • L'introduzione dei pacchetti è divisa in tetris-core e tetris-console. Questa è la divisione dei pacchetti. I componenti del pacchetto principale sono inseriti in tetris-core e l'implementazione specifica è inserita in tetris-console.

  • Inizializzazione di tema, tela e controller;

  • Inizializzazione di fabbrica e dimensione;

  • Per l'inizializzazione del gioco, utilizzare gli oggetti canvas, factory, canvas e dimensione precedentemente inizializzati;

  • Il gioco chiama il metodo di avvio.

Quindi, diamo un'occhiata a cosa fa l'inizio?

Logica di avvio del gioco


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

Analizziamolo riga per riga:

  1. Ottenerestatusvariabile,statusperGameLa rappresentazione interna dello stato del gioco, rispettivamente准备就绪(READY)游戏中(RUNNING)暂停(PAUSE)停止(STOP)游戏结束(OVER) . La differenza tra stop e fine del gioco è che il primo ferma attivamente il gioco, mentre il secondo innesca la logica finale del gioco e ne provoca la fine.

  2. Se il gioco è in corso, torna direttamente;

  3. Se il gioco viene interrotto e il gioco finisce, alloraStageeseguire un ripristino ecanvasEseguire un ridisegno generale.

  4. Se il gioco continua durante la preparazione, significa che il gioco ha appena completato l'inizializzazione e non è mai iniziato. Chiama il controller per associare gli eventi e disegnare la tela per la prima volta;

  5. Imposta lo stato del gioco su 游戏中(RUNNING), stato interno tickCount = 0;

  6. trasferimentocanvasEsegui immediatamente un aggiornamento parziale L'aggiornamento principale qui è che lo stato è cambiato, rendendo necessario il rendering dello stato del gioco;

  7. Avvia il timer, il tempo del timer passa a questa velocità,speedIn futuro valuteremo l'abbinamento con il livello del gioco (non ancora supportato).

  • Se tickCount == 0, attiva l'azione tick di una fase e controlla se termina immediatamente dopo l'attivazione;
  • Attiva l'operazione di aggiornamento del canvas
  • tickCount aumenta automaticamente e viene azzerato se >= tickMaxCount è soddisfatto;

Il motivo per cui viene introdotto il meccanismo tickCount è principalmente quello di garantire la frequenza di aggiornamento della tela. In generale, la frequenza di aggiornamento dello schermo è superiore alla velocità di stage.tick. Se i due sono coerenti, l'interfaccia di gioco potrebbe non essere fluida.

Ticchettio di fase

Come si può vedere dal codice sopra, la logica fondamentale del gioco èstage.tick, la sua implementazione interna è la seguente:

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
  • Per prima cosa determina se il gioco è finito o se è in corso un'operazione di pulizia.

  • SecurrentSe vuoto significa che il gioco viene caricato per la prima volta e inizializzato separatamente.currentEnext

  • Determina se il gioco raggiunge la condizione finale, cioècurrentEpoints C'è sovrapposizione. Se c'è una sovrapposizione, la partita viene considerata finita.

  • Determina se la corrente attuale può spostarsi verso il basso. Se può spostarsi verso il basso, spostala di uno spazio verso il basso. Altrimenti controlla se può essere eliminata.

Successivamente esamineremo come rilevare l'eliminazionehandleClearrealizzazione.

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

Come si può vedere dal codice sopra, l'intero processo è diviso in quattro passaggi:

  1. Copia un nuovo clone di punti, inclusi i punti attuali e attuali.

  2. Rileva punti, clona riga per riga e contrassegna se l'intera riga è riempita;

  3. Elimina riga per riga in base al contenuto contrassegnato generato in 2. Tieni presente che l'operazione di eliminazione viene eseguita dall'alto verso il basso Quando si elimina una riga, viene aggiunta una riga vuota dall'alto.

  4. Lavoro di pulizia.Questo passaggio deve essere eseguito indipendentemente dal fatto che venga eseguita l'operazione di cancellazione. Assegna puntiClone athis.points, completare allo stesso tempocurrentEnextinterruttore.

ruotare

Cosa succede con la rotazione dei blocchi?

Tutti i comportamenti di rotazione vengono attivati ​​chiamando il metodo game.rotate, inclusi gli eventi definiti dal controller, le chiamate esterne, ecc.;

La logica implementata nel gioco è la seguente:

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

Guarda dopoStagerealizzazione

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
  • giudicare primacurrentSe esiste, se non esiste verrà restituito direttamente;

  • trasferimentocurrentDicanRotateMetodo per verificare se la posizione corrente può essere ruotata; se può essere selezionata, chiamare il metodo di rotazione per ruotare.

Andiamo oltre e vediamoBlockDicanRotateErotatemetodo.

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

Diamo un'occhiata primacanRotaterealizzazione.

  • Ottieni centerIndex, centerIndex è l'indice del punto centrale di rotazione. Ogni elemento grafico è diverso, come IBlock, che è definito come segue:

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

    Cioè, il punto centrale di rotazione è il secondo nodo.Piace口口口口, il secondo punto centrale口田口口

    Inoltre, durante la progettazione di questo blocco, abbiamo considerato anche che alcuni blocchi non possono essere ruotati, come OBlock, che non può essere selezionato.MagetCenterIndexritorno-1

  • Ottieni l'array delle modifiche. L'array è definito come l'angolo della rotazione corrente. La lunghezza dell'array rappresenta il numero di rotazioni. Il contenuto dell'array rappresenta l'angolo di questa rotazione rispetto all'ultima rotazione.PiaceIBlockè definito come segue:

    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

    Cioè, la prima rotazione è lo stato iniziale Math.PI / 2 (ovvero 90 gradi) e la seconda rotazione è -Math.PI / 2 della prima rotazione (ovvero -90 gradi). come segue:

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

    PS: Tieni presente che l'asse delle coordinate va da sinistra a destra e dall'alto verso il basso.

  • Per il giudizio a rotazione i criteri di giudizio sono:

    1. I punti delle coordinate ruotati non possono superare i confini dell'intero gioco;
    2. I punti delle coordinate ruotati non possono occupare i punti del quadrato riempito.

    Quindi vediamo che ci sonoisValidEnewPoints.everygiudizio.

Vediamo dopoBlock.rotate,come segue:

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

Attraverso la descrizione di cui sopra,rotateLa logica è facile da capire.

  • OttenerecenterIndexEchanges,VolerecurrentChangeIndexEseguire un incremento ciclico e puntare il Blocco sulle nuove coordinate.

  • IncurrentChangeIndex Il valore iniziale è -1, significa che è in corso la rotazione corrente, e se è maggiore o uguale a 0 significa che è selezionato indice + 1. (Per favore pensa attentamente qui, perché l'indice dell'array inizia da 0)

mossa

Movimento significa muovere il Blocco in quattro direzioni.Vediamo la sua realizzazione

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

Tra questi, la Direzione è definita come segue:

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

Guarda oltreStageImplementazione di:

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

Guarda oltrecanMoveEmoverealizzazione.

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

Traduciamolo brevemente così:

  • Per spostarsi verso l'alto, tutti i punti dell'asse y devono essere maggiori di 0 (ovvero maggiori o uguali a 1) e i punti dopo lo spostamento devono essere punti vuoti;

  • Spostandosi a sinistra, tutti i punti dell'asse x devono essere maggiori di 0 (ovvero maggiori o uguali a 1) e i punti dopo lo spostamento devono essere punti vuoti;

  • Spostandosi a destra, tutti i punti dell'asse x devono essere inferiori alla lunghezza dell'asse delle coordinate x -1 (ovvero, inferiori o uguali a xSize - 2) e i punti dopo lo spostamento devono essere punti vuoti;

  • Per spostarsi verso il basso, tutti i punti dell'asse y devono essere inferiori alla lunghezza dell'asse delle coordinate y -1 (ovvero, inferiori o uguali a ySize - 2) e i punti dopo il movimento devono essere punti vuoti.

Dopo aver soddisfatto le condizioni di movimento, diamo un'occhiatamoverealizzazione.

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

Modificare direttamente il valore del punto delle coordinate.

riepilogo

Questo capitolo descrive tre importanti comportamenti del gioco: pulizia, rotazione e movimento. I tre collaborano tra loro per completare il gioco. Nel prossimo capitolo condivideremo il rendering dell'interfaccia e il controllo operativo del gioco.