0

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

Chắc hẳn tuổi thơ chúc ta ai cũng biết đến trò chơi "Tetris" hay thường gọi là game 'xếp hình'. Nhưng nó được tạo ra thế nào thì những người không làm về lập trình thì chắc sẽ không biết được còn nhưng người có kiến thức về lập trình thì tại sao chúng ta không thử tự code và chơi trên chính game mình code ra nhỉ? Hôm nay, tôi sẽ đưa bạn đến với hành trình implement trò chơi cổ điển Tetris. Tôi sẽ đề cập đến các khái niệm như đồ họa, vòng lặp trò chơi và phát hiện va chạm. Cuối cùng, chúng ta sẽ có một trò chơi hoạt động đầy đủ với điểm và cấp độ. Code của trò chơi phiên bản đầy đủ đã có trên GitHub. Bạn hãy lên và nguyên cứu thêm nhé.

Chắc hẵn chúng ta ai cũng biết chơi trò này rồi nên hãy cùng đến với phần implement nó luôn nhé

Bạn cần có kiến thức gì

Thực ra cũng chẳng có gì to tát cả, mọi thứ đều rất là cơ bản

  • Một chút kiến thức về typescript
  • Biết một chút về Angular(chỉ một xíu xiu thôi, học 2 3 tiếng chắc cũng đủ =)) )
  • Một chút kiến thức về Canvas(cũng chỉ là vẽ hình cơ bản luôn bạn có thể xem ở đây)

Cài đặt môi trường

Với angular thì tất nhiên đầu tiên chúng ta sẽ cài Angular CLI rồi

npm install -g @angular/cli

Với CLI được cài đặt, chúng ta có thể tạo một project mới với ng new:

ng new ng-tetris — minimal — defaults

Playing board bao gồm 10 cột và 20 hàng. Các giá trị này có lẽ chúng ta sẽ phải sử dụng thường xuyên để chạy vòng lặp trong các logic khi implement, vì vậy tôi sẽ đưa chúng vào các giá trị constants ở constants.ts

export const COLS = 10;
export const ROWS = 20;
export const BLOCK_SIZE = 30;

Trước tiên, chúng ta cần giới thiệu một phần tử <canvas>, mà chúng ta có thể thực hiện trong component template. Chúng tôi cũng sẽ sử dụng một reference variable vào phần tử để có thể tham chiếu đến nó component class. Đây là template hoàn chỉnh, tôi sẽ implement một số logic để làm cho những điều hay ho xảy ra:

<div class="grid">
  <canvas #board class="game-board"></canvas>
  <div class="right-column">
    <div>
      <h1>TETRIS</h1>
      <p>Score: {{ points }}</p>
      <p>Lines: {{ lines }}</p>
      <p>Level: {{ level }}</p>
    </div>
    <button (click)="play()" class="play-button">Play</button>
  </div>
</div>

Đây là board.component.ts và đây là những logic khởi đầu, component này là component chính để thực hiện các logic, hãy xem nhé:

import { Component, ViewChild, ElementRef, OnInit } from '@angular/core';
import { COLS, BLOCK_SIZE, ROWS } from './constants';

@Component({
  selector: 'game-board',
  templateUrl: 'board.component.html' 
})
export class BoardComponent implements OnInit {
  // Get reference to the canvas.
  @ViewChild('board', { static: true })
  canvas: ElementRef<HTMLCanvasElement>;

  ctx: CanvasRenderingContext2D;
  points: number;
  lines: number;
  level: number;

  ngOnInit() {
    this.initBoard();
  }

  initBoard() {
    // Get the 2D context that we draw on.
    this.ctx = this.canvas.nativeElement.getContext('2d');

    // Calculate size of canvas from constants.
    this.ctx.canvas.width = COLS * BLOCK_SIZE;
    this.ctx.canvas.height = ROWS * BLOCK_SIZE;
  }

  play() {}
}

Trong app.component.ts hãy thêm game-board được khai báo ở trên nhé:

<game-board></game-board>

Styling

Đây là một trò chơi của thập kỉ 80 có thể thiết kể sẽ cổ cổ, Press Start 2P là một phông chữ bitmap dựa trên thiết kế phông chữ từ trò chơi arcade Namco những năm 1980. Chúng ta có thể thêm nó theo hai bước:

<!-- index.html -->
<link href=”https://fonts.googleapis.com/css?family=Press+Start+2P" rel=”stylesheet” />
/* styles.css */
* {
  font-family: 'Press Start 2P', cursive;
}

Chúng ta đã tạo style cho vùng chứa trò chơi của mình và và chuẩn bị code logic nha.

The Board

Bảng trong Tetris bao gồm các ô, ô đó có được hiển thị hay không. Suy nghĩ đầu tiên của tôi là đại diện cho một ô có giá trị boolean tuy nhiên bạn đã nghĩ đến với màu sắc của ô sẽ hiển thị thế nào chưa? nó có nhiều giá trị mà phải không? Chúng ta có thể biểu diễn một ô trống với 0 và các màu sẽ được gán số.

Khái niệm tiếp theo là đại diện cho các hàng và cột của trò chơi. Ngay từ đầu chúng ta đã có thể hình dung ngay được nó thể sử mảng hai chiều (2D) rồi phải không, khái niện này nhập môn lập trình có lẽ ai cũng phải làm các bài luyện tập liên quan đến khái niệm này, và đây là lúc nó phát huy tác dụng.

Let go, bắt đầu code logic nào! Đầu tiền hãy tạo một hàm trả về một bảng trống với tất cả các ô được đặt thành 0.

getEmptyBoard(): number[][] {
  return Array.from({ length: ROWS }, () => Array(COLS).fill(0));
}

Khi bắt đầu trò chơi, thì tất nhiên là mình phải sử dụng hàm phía trên rồi, chúng ta khởi động bằng nút play, vậy đặt tên là play luôn nhé

play() {
  this.board = this.boardService.getEmptyBoard();
  console.table(this.board);
}

Bằng cách sử dụng console.table, chúng ta thấy biểu diễn của mảng(bảng điều khiển chính của game)

Các tọa độ X và Y đại diện cho các ô của bảng. Bây giờ chúng ta đã có bảng, chúng ta hãy xem xét các khối hình(các mảnh).

Tetrominos

Một mảnh trong Tetris là một khối khối có thể xoay theo một trục ở 1 trạng thái. Chúng thường được gọi là tetrominos và có bảy kiểu và mỗi kiểu có một màu sắc khác nhau. Các khối này cũng không xa lạ gì với chúng ta, có lẽ nó lấy theo hình dạng của các chữ cái I, J, L, O, S, T và Z

Tôi biểu diễn tetromino J dưới dạng ma trận trong đó số hai đại diện cho các ô màu tôi thêm hàng số không để có tâm xoay xung quanh:

[2, 0, 0],
[2, 2, 2],
[0, 0, 0];

Với các giá trị còn lại bạn hãy tự suy ra nhé, hãy nhớ hãy định nghĩa sao có mỗi khối sẽ phải có tâm xoay để lúc xoay hình được thuận tiện cũng như sử dụng nó để đại diện cho vị trí của khối đó.

export interface IPiece {
  x: number;
  y: number;
  color: string;
  shape: number[][];
}

Tôi muốn Piece class biết vị trí của nó trên bảng, màu sắc và hình dạng của nó. Vì vậy, để có thể vẽ chính nó trên bảng, nó cần một tham chiếu đến canvas context.

export class Piece implements IPiece {
  x: number;
  y: number;
  color: string;
  shape: number[][];

  constructor(private ctx: CanvasRenderingContext2D) {
    this.spawn();
  }

  spawn() {
    this.color = 'blue';
    this.shape = [[2, 0, 0], [2, 2, 2], [0, 0, 0]];

    // Position where the shape spawns.
    this.x = 3;
    this.y = 0;
  }
}

Để vẽ tetromino trên bảng, chúng ta lặp qua tất cả các ô của mảnh. Nếu giá trị trong ô lớn hơn 0, thì ta tô màu cho mảnh đó.

draw() {
  this.ctx.fillStyle = this.color;
  this.shape.forEach((row, y) => {
    row.forEach((value, x) => {
      if (value > 0) {
        // this.x & this.y = position on the board
        // x & y position are the positions of the shape
        this.ctx.fillRect(this.x + x, this.y + y, 1, 1);
      }
    });
  });
}

Lúc này, bạn có thể nhận thấy rằng các cạnh của mảnh dính với nhau thành một nguyên nhận là vì nó rất nhỏ, vì vậy chúng ta cần phải "thổi to nó lên", tăng tỷ lệ nó theo kích thước khối như ở constan này BLOCK_SIZE:

this.ctx.scale(BLOCK_SIZE, BLOCK_SIZE);

Ở bảng chính chúng ta cùng thử thể tạo và vẽ nó khi chúng ta nhấn nút play:

play() {
  this.board = this.boardService.getEmptyBoard();
  this.piece = new Piece(this.ctx);
  this.piece.draw();
}

Khối tetromino J màu xanh xuất hiện!

Với những chữ khác bạn hãy tự tạo rồi vẽ thử lên xem sao nhé

Sau khi có các mảnh rồi, hãy cũng sử dụng các keyboard event để điều khiển chúng nha

Keyboard input

Bây giờ, hãy xem cách chúng ta di chuyển các mảnh trên bảng qua hàm này

move(p: IPiece) {
  this.x = p.x;
  this.y = p.y;
}

Hàm này với tham số truyền vào là một dạng IPiece và gán tọa độ của IPiece(argument đó) vào Piece hiện tại. Mới đầu có vẻ sẽ hơi kì quoặc phải không, hãy cùng xem mục đích của nó là gì nhé.

Trước hết là chúng ta phải map các phím(key) định dùng với mã của nó đã, để khi có một phím được bấm chúng ta biết được phím đó có điều khiển khối của chúng ta hay không

export class KEY {
  static readonly LEFT = 37;
  static readonly RIGHT = 39;
  static readonly DOWN = 40;
}

Như ở trên tôi có hàm move với một kiểu IPiece, tôi chưa nói mục đích là gì đúng không. Mục đích của tôi rất đợn giản, đó là: tôi sẽ tạo ra một bản sao của nó, sau đó tôi gán tọa độ mới mà khối đó đến vào bản sao đó, và truyền vào hàm move kia, như vậy tọa độ của khối đó đã được thay đổi. Note: Trong JavaScript, chúng ta có thể sử dụng tính năng shallow copying để sao chép các kiểu dữ liệu nguyên thủy như số và chuỗi. Tuy nhiên với một object thì cơ chế nó có sự khác biệt, chắc hẵn ai làm một thời gian sẽ hiểu. Vì vậy ES6 cung cấp hai cơ chế shallow copy: Object.assign ()spread operator.

Ta sử dụng luôn spread operator nhé:

moves = {
  [KEY.LEFT]:  (p: IPiece): IPiece => ({ ...p, x: p.x - 1 }),
  [KEY.RIGHT]: (p: IPiece): IPiece => ({ ...p, x: p.x + 1 }),
  [KEY.UP]:    (p: IPiece): IPiece => ({ ...p, y: p.y + 1 })
};

Chúng ta có thể sử dụng như thế này để có được trạng thái mới mà không làm thay đổi phần ban đầu. Điều này quan trọng vì không phải lúc nào chúng ta cũng muốn chuyển sang một vị trí mới.

const p = this.moves[event.key](this.piece);

đến đây mọi người thấy cực kì thuyết phục rồi chứ =))

Để nghe các sự kiện bàn phím, chúng ta có thể sử dụng @HostListener decorator trong component bảng.

@HostListener('window:keydown', ['$event'])
keyEvent(event: KeyboardEvent) {
  if (this.moves[event.keyCode]) {
    // If the keyCode exists in our moves stop the event from bubbling.
    event.preventDefault();
    // Get the next state of the piece.
    const p = this.moves[event.key](this.piece);
    // Move the piece
    this.piece.move(p);
    // Clear the old position before drawing
    this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
    // Draw the new position.
    this.piece.draw();
  }
}

Bây giờ chúng ta đang nghe các sự kiện trên bàn phím và nếu chúng ta nhấn mũi tên trái, phải hoặc mũi tên xuống, thì chúng ta có thể thấy phần di chuyển.

Cũng ổn rồi đấy, tuy nhiên, những mảnh ma đi xuyên tường không phải là điều chúng ta muốn. Chúng ta tiếp tục cải tiến nó nhé

Phát hiện va chạm

Việc phát hiện va chạm này không chỉ giải quyết vấn để chỉ ở case bên trên chúng ta vừa mắc phải mà nó còn ở nhiều case đặc biệt nữa. Thông qua lúc chơi chúng ta cũng đã nghĩ ra một vài case rồi. Cụ thể đó là:

  • Lên đỉnh =)) (cao quá đụng sàn)
  • Di chuyển sáng hai bên và va vào tường
  • Chạm vào một khối(mảnh) khác trên bảng
  • Lúc xoay dọc, ngang hình đó chạm vào tường hoặc khối khác

Ở phần trước chúng ta đã xác định được vị trí với khi chúng ta điều khiển khối bằng bàn phím, điều này cũng tương tụ với khi khối tự rơi(vì tự rơi cũng như ta bấm nút 'xuống' vậy). Vậy đơn giản chúng ta sẽ kiểm tra vị trí mới(nếu đủ điều kiện) trước, nếu nó hợp lệ thì mới chuyển sang nó. Để kiểm tra sự va chạm, tôi lặp qua tất cả các khoảng trống trong bảng mà tetromino sẽ có thể đến. Nếu nó không phải là giá trị 0, thì chắc chắn nó đã có khối khác hoặc là tường rồi. Arraut method phù hợp nhất cho điều này là every(). Với nó, chúng ta có thể kiểm tra xem tất cả các phần tử trong mảng có pass qua các logic mà chúng ta cần hay không hay không. Chúng ta cần tính toán mọi ô trong khối(mảnh) xem nó có phải là vị trí hợp lệ hay không, hãy cùng xem đoạn code này nhé:

valid(p: IPiece): boolean {
  return p.shape.every((row, dy) => {
    return row.every((value, dx) => {
      let x = p.x + dx;
      let y = p.y + dy;
      return (
        this.isEmpty(value) ||
       (this.insideWalls(x) &&
        this.aboveFloor(y)
      );
    });
  });
}

Bằng cách sử dụng phương pháp này trước khi di chuyển khối sang vị trí mới, chúng ta đảm bảo rằng khối không di chuyển đến bất kỳ nơi nào nó không thể đến:

if (this.service.valid(p)) {
  this.piece.move(p);
}

Hàm này mình để trong service, còn để dâu bạn hãy tạo nhé. Hoặc làm them mình cũng được Hãy cùng kiểm tra tại nhé

Rồi, có vẻ đã ổn rồi đấy, không còn hiện tượng đi xuyên tường nữa rồi. Bây giờ còn chúng ta sẽ code tiếp để khi cũng ta cho khối rơi khi điều khiển, nó có thể dừng lại được khi có vật cản


export class KEY {
  static readonly SPACE = 32;
  // ...
}

moves = {
  [KEY.SPACE]: (p: IPiece): IPiece => ({ ...p, y: p.y + 1 })
  // ...
};

if (event.keyCode === KEY.SPACE) {
  while (this.service.valid(p, this.board)) {
    this.piece.move(p);
    p = this.moves[KEY.DOWN](this.piece);
  }
}

Xử lý xoay

... Phần này mình sẽ tiếp tục ở phần sau các bạn nhé bài này cũng hơi dài rồi 😄 Phần kế tiếp mình sẽ tiếp tục với

  • Xử lý xoay
  • Cách để ramdom một khối
  • Game loop
  • Timer
  • Ăn điểm
  • ... Nói chung là sẽ hoàn thiện trò chơi nhé. Cảm ơn các bạn đã theo dõi

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

Viblo
Let's register a Viblo Account to get more interesting posts.