Tạo game Tetris(xếp hình) bằng Angular (Phần 2)

Chào mọi người đã tiếp tục đến với series tạo game xếp hình bằng Angular 2+ của mình, ở phần một mình đã làm được một số logic cơ bản liên quan đến tạo các khối, điều khiển các khối, xử lý va chạm. Tuy nhiên mới chỉ có thể thì sẽ chẳng thể chơi được chút nào cả, ở phần này mình sẽ code hoàn thiện các chức năng của trò chơi ở mức cơ bản, các bạn cùng theo dõi nhé. Còn ai chưa theo dõi từ đầu thì có thể theo dõi phần 1 ở đây

Cùng tiếp tục với xử lý xoay khối nhé các bạn!

Xử lý xoay

Như phàn trước code chúng ta đã có thể di chuyển khối đi xung quanh, tuy nhiên sẽ không còn là trò chơi xếp hình nếu không thể xoay khối, điều này sẽ làm giảm sự thú vị cũng như tăng độ khó cho game lên rất nhiều. Nào cùng xem chứng ta cần làm gì nhé!

Đôi khi ở trường đại học có một số môn như đại số tuyến tính, phương pháp tính, ... các bạn tự hỏi không biết học để làm gì. Tuy không phải ai cũng dùng đến và có lẽ khi đi làm cũng chẳng máy khi gặp, tuy nhiên trong trường hợp này chúng ta cũng sẽ áp dụng một chút kiến thức của đại số tuyến tính đấy nhé. Cụ thể là sao?

Một phép xoay 90 độ có thể thực hiện bằng hai phản xạ ở góc 45 độ, điều đó có nghĩa là bạn có thể lấy ma trận chuyển vị của ma trận sau đó nhận nó với ma trận hoán vị đảo ngược thức tự của các cột, chắc cũng ít ai nhớ hoán vị chuyển vị nó là như nào, vì vậy hình ảnh sau sẽ giúp các bạn hình dung được một chút Nói thế chú việc code lại rất dễ:

// Transpose matrix
for (let y = 0; y < this.shape.length; ++y) {
  for (let x = 0; x < y; ++x) {
    [this.shape[x][y], this.shape[y][x]] = 
    [this.shape[y][x], this.shape[x][y]];
  }
}
// Reverse the order of the columns.
this.shape.forEach(row => row.reverse());

Chúng ta có thể thêm một function rotate khối. Trước đó mình đã sử dụng toán tử spread để sao chép các tọa độ, trong trường hợp này, chúng ta đang làm việc với mảng nhiều cập tuy nhiên toán tử spread chỉ sao chép sâu một cấp. Phần còn lại được sao chép bằng cách reference. Với case này mình đã thay thế bằng các sử dụng đến các method xử lý JSON là JSON.parse và JSON.stringify. Method stringify() chuyển đổi ma trận thành một chuỗi JSON. Method parse() phân tích cú pháp chuỗi JSON, xây dựng ma trận của chúng ta trở lại thành một bản sao.

rotate(p: IPiece): IPiece {
 // Cloning with JSON
 let clone: IPiece = JSON.parse(JSON.stringify(p));
 
 // Do algorithm
 return clone;
}

Sau đó mình thêm một trạng thái mới cho ArrowUp trong board.component.ts

[KEY.UP]: (p: IPiece): IPiece => this.service.rotate(p)

Nào cùng xoay nào

Lấy ngẫu nhiên khối Tetromino

Bây giờ để hiện các khối chúng ta đang fix cứng code nhằm mục đích để test kết quả khi dev, bây giờ hãy cùng nhau tạo ra các khối ngẫu nhiên nhé. Chúng ta sẽ tạo vị trí đầu tiên của các khối cũng như màu sắc của khối trong hằng số và 2 hằng số đó được mapping với nhau thông qua index


export const COLORS = [
  'cyan',
  'blue',
  'orange',
  'yellow',
  'green',
  'purple',
  'red'
];
export const SHAPES = [
  [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
  [[2, 0, 0], [2, 2, 2], [0, 0, 0]]
  // And so on
];

Chúng ta cần ngẫu nhiên hóa chỉ mục của một trong số này để chọn một mảnh. Để lấy một số ngẫu nhiên, chúng ta tạo một hàm sử dụng độ dài của mảng.

randomizeTetrominoType(noOfTypes: number): number {
  return Math.floor(Math.random() * noOfTypes);
}

Với phương pháp này, chúng ta có thể nhận được một loại tetromino ngẫu nhiên khi chúng ta sinh sản và sau đó thiết lập màu sắc và hình dạng từ nó:

randomizeTetrominoType(noOfTypes: number): number {
  return Math.floor(Math.random() * noOfTypes);
}

Nếu chúng ta nhấn play, trang sẽ hiển thị các mảnh có hình dạng và màu sắc khác nhau.

Game Loop

Hầu hết tất cả các trò chơi đều có một chức năng chính giúp trò chơi tiếp tục chạy ngay cả khi người dùng không làm gì cả. Chu kỳ chạy đi chạy lại cùng một chức năng cốt lõi này được gọi là vòng lặp trò chơi. Trong trò chơi của chúng ta, chúng ta cần một vòng lặp trò chơi để di chuyển các khối xuống màn hình.

RequestAnimationFrame

Để tạo vòng lặp trò chơi của chúng ta, chúng ta có thể sử dụng requestAnimationFrame. Nó cho trình duyệt biết rằng chúng ta muốn tạo hoạt ảnh và nó sẽ gọi một hàm để cập nhật hoạt ảnh trước lần vẽ lại tiếp theo. Nói cách khác, chúng ta nói với trình duyệt: “Lần tới khi bạn vẽ trên màn hình, hãy chạy chức năng này vì tôi cũng muốn vẽ một cái gì đó”. Cách tạo hoạt ảnh với window.requestAnimationFrame() là tạo một hàm vẽ khung và sau đó tự re-schedules. Chúng ta cần ràng buộc lời gọi này, hoặc nó có đối tượng window làm ngữ cảnh của nó. Vì nó không chứa hàm hoạt ảnh nên mình gặp lỗi.

animate() {
  this.piece.draw();
  requestAnimationFrame(this.animate.bind(this));
}

Chúng ta có thể xóa tất cả các lệnh gọi draw() trước đây của và thay vào đó gọi animate() từ hàm play() để bắt đầu hoạt ảnh. Nếu chúng ta test trò chơi của mình, nó vẫn sẽ chạy như trước.

Timer

Tiếp theo, chúng ta cần một bộ đếm thời gian. Mỗi khung thời gian, chúng ta thả khối. Hãy bắt đầu với một đối tượng cơ bản này nhé

time = { start: 0, elapsed: 0, level: 1000 };

Trong vòng lặp trò chơi, mình cập nhật trạng thái trò chơi của mình dựa trên khoảng thời gian và sau đó rút ra kết quả.

animate(now = 0) {
  // Update elapsed time.
  this.time.elapsed = now - this.time.start;
  // If elapsed time has passed time for current level
  if (this.time.elapsed > this.time.level) {
    // Reset start time
    this.time.start = now;
    this.drop();
  }
  this.draw();
  requestAnimationFrame(this.animate.bind(this));
}

Vậy là chúng ta đã có animation rồi!

Tiếp theo, hãy xem điều gì sẽ xảy ra khi chúng ta đến cuối.

Freeze

Khi chúng ta không thể di chuyển xuống được nữa, chúng ta nên đóng băng khối đó và sinh ra một khối mới. Hãy bắt đầu bằng cách xác định giá trị freeze(). Hàm này hợp nhất các khối tetromino vào bảng:

freeze() {
  this.piece.shape.forEach((row, y) => {
    row.forEach((value, x) => {
      if (value > 0) {
        this.board[y + this.piece.y][x + this.piece.x] = value;
      }
    });
  });
}

Mình chưa thể nhìn thấy bất cứ điều gì, nhưng bằng cách ghi lại hình đại diện của bảng, mình có thể thấy rằng hình dạng trên bảng. Hãy thêm một chức năng vẽ bảng:

drawBoard() {
  this.board.forEach((row, y) => {
    row.forEach((value, x) => {
      if (value > 0) {
        this.ctx.fillStyle = COLORS[value];
        this.ctx.fillRect(x, y, 1, 1);
      }
    });
  });
}

Bây giờ hàm draw sẽ giống như sau:

draw() {
  this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
  this.piece.draw();
  this.drawBoard();
}

Bây giờ chúng ta đang đóng băng các mảnh, chúng ta cần thêm tính năng phát hiện va chạm mới. Lần này, mình phải đảm bảo rằng các khối không va chạm với các khối đóng băng trên bảng. Chúng ta có thể làm điều này bằng cách kiểm tra xem ô có bằng không. Thêm điều này vào method valid và gửi trong bảng dưới dạng argument:

board[p.y + y][p.x + x] === 0;

Bây giờ chúng ta đang thêm các mảnh vào bảng, nó sẽ nhanh chóng cao dần lên. Chúng ta nên làm điều gì đó về điều đó.

Line clear

Với mỗi hàng được lấp đầy(không có khoảng trống), thì hàng đó được xóa đi, đây là nguyên tắc của trò chơi này.

Việc phát hiện một hàng đã được lấp đầy chưa đơn giản mình chỉ cần kiểm tra xem hàng đó còn số không nào không, nếu không có thì chắc chắn nó đã được lấp đầy.


this.board.forEach((row, y) => {
  // If every value is greater than 0.
  if (row.every(value => value > 0)) {
    // Remove the row.
    this.board.splice(y, 1);
    // Add a zero filled at the top.
    this.board.unshift(Array(COLS).fill(0));
  }
});

Chúng ta có thể thêm một lệnh gọi vào hàm clearLines() này sau lệnh gọi freeze (). Chúng ta có thể thử chơi và hy vọng rằng các hàng được xóa.

Ok! Kết quả mđã ok rồi, nhưng xóa không thì trò chơi nó lại nhạt quá. Xóa xong thì phải tính điểm chứ đúng không 😄

Tính điểm

Để có thêm một chút phấn khích, chúng ta cần một hệ thống điểm. Từ hướng dẫn Tetris, chúng tôi nhận được các giá trị sau:

export class Points {
  static readonly SINGLE = 100;
  static readonly DOUBLE = 300;
  static readonly TRIPLE = 500;
  static readonly TETRIS = 800;
  static readonly SOFT_DROP = 1;
  static readonly HARD_DROP = 2;
}

Trước tiên, hãy cộng điểm cho việc soft và hard drops(tức rơi nhanh hay hơi chậm - tức nghĩa là mỗi lần rơi vẫn sẽ tính điểm và sẽ tính điểm nhiều hơn khi người chơi cho khối rơi nhanh) trong trình xử lý sự kiện của chúng ta:

@HostListener('window:keydown', ['$event'])
  keyEvent(event: KeyboardEvent) {
    if (this.moves[event.key]) {
      event.preventDefault();
      let p: ITetromino = this.moves[event.key](this.piece);
      if (event.key === ' ') {
        while (this.service.valid(p, this.board)) {
          this.points += Points.HARD_DROP; // Points for every drop
          this.piece.move(p);
          p = this.moves[' '](this.piece);
        }
      } else if (this.service.valid(p, this.board)) {
        this.piece.move(p);
        if (event.key === 'ArrowDown') {
          this.points += Points.SOFT_DROP; // Points if we move down
        }
      }
    }
  }

Bây giờ chúng ta sẽ code tiếp với phần tính điểm tùy thuộc vào số dòng mà chúng ta xóa được

getLineClearPoints(lines: number): number {
  return lines === 1 ? Points.SINGLE :
         lines === 2 ? Points.DOUBLE :
         lines === 3 ? Points.TRIPLE :
         lines === 4 ? Points.TETRIS : 0;
}

Để điều này hoạt động, chúng ta cần thêm một chút logic để đếm xem chúng ta xóa bao nhiêu dòng:

clearLines() {
  let lines = 0; // Set variable
  this.board.forEach((row, y) => {
    if (row.every(value => value !== 0)) {
      lines++; // Increase for cleared line
      this.board.splice(y, 1);
      this.board.unshift(Array(COLS).fill(0));
    }
  });
  if (lines > 0) {
    // Add points if we cleared some lines
    this.points += this.getLineClearPoints(lines);
  }
}

Chơi thử ngay thôi, chúng ta có thể thấy rằng chúng ta đang tăng điểm số của mình.

Cấp độ

Khi chơi với mãi một kiểu và chứ mãi như vậy thì quá dễ dàng khi đã quen thuộc với nó. Và quá dễ dàng có nghĩa là nhàm chán. Vì vậy chúng ta cần tăng mức độ khó lên. Mình làm điều này bằng cách giảm tốc độ khoảng thời gian trong vòng lặp trò chơi.

export const LINES_PER_LEVEL = 10;
export class Level {
  static readonly 0 = 800;
  static readonly 1 = 720;
  static readonly 2 = 630;
  static readonly 3 = 550;
  // ...
}

Chúng ta cũng có thể cho người chơi biết họ hiện đang ở cấp độ nào. Logic của việc theo dõi và hiển thị các level và dòng cũng giống như đối với điểm. Mình khởi tạo một giá trị cho chúng và khi mình bắt đầu một trò chơi mới, mình phải đặt lại chúng.

resetGame() {
  this.points = 0; 
  this.lines = 0;
  this.level = 0;
  this.board = this.getEmptyBoard();
}

Tùy thuộc vào mức độ mà người chơi nhận được nhiều điểm hơn cho xóa dòng. Mình nhân số điểm với cấp độ đang có đang có. Và kể từ khi chúng ta bắt đầu ở cấp độ 0, hãy thêm một vào nó.

(level + 1) * lineClearPoints;

Chúng ta sẽ tăng cấp độ mỗi khi người chơi đạt đến các dòng người chơi có trên mỗi cấp độ. Khi thay đổi cấp độ chúng ta cũng sẽ thay đổi thời gian theo từng cấp độ đó.

if (lines > 0) {
  // Calculate points from cleared lines and level.
  this.points += this.service.getLinesClearedPoints(lines, this.level);
  this.lines += lines;
  // If we have reached the lines per level
  if (this.lines >= LINES_PER_LEVEL) {
    // Goto next level
    this.level++;
    // Remove lines so we start working for the next level
    this.lines -= LINES_PER_LEVEL;
    // Increase speed of game.
    this.time.level = Level[this.level];
  }
}

Bây giờ nếu chúng ta chơi và xóa 10 dòng, chúng ta sẽ thấy levels và số điểm của chúng ta tăng gấp đôi. Và tất nhiên các khối bắt đầu di chuyển nhanh hơn một chút.

Game over

Tất nhiên rồi, trò chơi nào rồi cũng có hồi kết, và trò chơi này cũng vậy, đến bây giờ trò chơi của chúng ta chỉ kết thúc khi tắt trình duyệt =)) vậy làm sao để kết thúc trò chơi của chúng ta khi các khối đã được xếp chồng lên nhau đến mức đội sàn 😄 Hãy chùng mình code tiếp nhé!

Điều này cũng không mấy khó ăn cho lắm, sau khi khối rơi chúng ta chỉ cần kiểm tra xem nó có đang ở hàng 0 hay không và trong trường hợp đang ở hàng không, tức nó đang trên đỉnh rồi =))

if (this.piece.y === 0) {
  this.gameOver();
  return;
}

Khi đó chúng ta sẽ thoát khỏi vòng lặp trò chơi. Trước khi thoát, mình gọi cancelAnimationFrame để dừng nó. Ngoài ra, mình in một thông tin cho người dùng rằng "Game Over!".

gameOver() {
  cancelAnimationFrame(this.requestId);
  this.ctx.fillStyle = ‘black’;
  this.ctx.fillRect(1, 3, 8, 1.2);
  this.ctx.font =1px Arial’;
  this.ctx.fillStyle = ‘red’;
  this.ctx.fillText(GAME OVER, 1.8, 4);
}

Khối tetromino tiếp theo

Nếu người dùng đến khối nào thì chơi khối đấy thì có vẻ vẫn có thể chơi được tuy nhiên nó hơi hên xui một chút, vậy nên nếu cho người chơi biết trước khối gì sẽ hiện ra tiếp theo thì trò chơi sẽ có thêm sự tính toán hơn, người dùng sẽ suy nghĩ trước sẽ đặt khối hiện tại và khối tiếp theo sao cho hợp lý. Hãy cùng thêm tính năng này nhé!

<canvas #next class=”next”></canvas>

Tiếp theo, mình làm như mình đã làm cho canvas đầu tiên của mình:

@ViewChild('next', { static: true })
canvasNext: ElementRef<HTMLCanvasElement>;

ctxNext: CanvasRenderingContext2D;

initNext() {
  this.ctxNext = this.canvasNext.nativeElement.getContext('2d');

  // Size it for four blocks.
  this.ctxNext.canvas.width = 4 * BLOCK_SIZE;
  this.ctxNext.canvas.height = 4 * BLOCK_SIZE;

  this.ctxNext.scale(BLOCK_SIZE, BLOCK_SIZE);
}

// Create and draw it in play function
play() {
  // ...
  this.next = new Piece(this.ctx);
  this.next.drawNext(this.ctxNext);
  // ...
}

Chúng ta phải thay đổi logic một chút trong hàm drop. Thay vì tạo một phần mới, mình thay khối mới đó bằng phần tiếp theo, và thay phần tiếp theo bằng một khối mới. Bây giờ hãy thử xem nó đã ok chưa nhé!

Phần implement của mình đến đây là hết rồi còn một số phần các bạn có thể code tiếp mình có thể nghĩ ra, các bạn thử xem nhé

  • Pause game
  • Lưu lại điểm cao
  • Âm thanh của trò chơi cũng như bật/tắt âm thanh
  • ...

Tác giả

Bài biết được dịch từ : https://medium.com/angular-in-depth/game-development-tetris-in-angular-64ef96ce56f7 của tác giả Michael Karén . Hãy chơi thử game sau khi hoàn thiện ở đây và để lại feedback nhé


All Rights Reserved