Hoài Cổ Với Game Tetris Lập Tình Bằng Java

Hoài Cổ Với Game Tetris Lập Tình Bằng Java

Tetris là game huyền thoại mà các thế hệ 8x, 9x đều biết được chơi. Từ những năm 2000, Những máy chơi game đen trắng bùng nổ, xuất hiện khắp mọi nơi, hầu như trẻ em thời điểm đấy ai cũng master thể loại Game này.

Sau đây, mình sẽ hướng dẫn các bạn làm game Tetris bằng ngôn ngữ Java.

Chuẩn bị

  • Một file ảnh block 7 màu (nếu có). Bạn có thể tham khảo của mình tại đây
  • Cài đặt Visual Studio Code tại đây
  • Cài đặt Java 8 trên VSCode tại đây

Tiến hành

Mình sẽ tạo 2 folder gổm src (chứa source code) và textures (chứa ảnh)

Tạo một file ảnh gồm 7 khối màu

File ảnh kích thước là 210x30 nghĩa là ảnh gồm 7 khối, mỗi khối kích thước 30x30. Mình phóng to hình cho các bạn dễ quan sát. Bạn có thể tải từ source code của mình file ảnh trên hoặc tự tạo cho mình một khối ảnh 7 màu như sau:

  • Lấy mã hex hoặc mã RGB màu của 7 màu rainbow trên từ trang schemecolor
  • Sử dụng thư viện pillow của Python vẽ các khối hình vuông cạnh nhau rồi lưu vào folder textures
from PIL import Image, ImageDraw

im = Image.new('RGB', (210, 30), (0, 0, 0))
draw = ImageDraw.Draw(im)

draw.rectangle((0, 0, 30, 30), fill=(246, 0, 0))
draw.rectangle((30, 0, 60, 30), fill=(255, 140, 0))
draw.rectangle((60, 0, 90, 30), fill=(255, 238, 0))
draw.rectangle((90, 0, 120, 30), fill=(77, 233, 76))
draw.rectangle((120, 0, 150, 30), fill=(55, 131, 255))
draw.rectangle((150, 0, 180, 30), fill=(72, 21, 170))
draw.rectangle((180, 0, 210, 30), fill=(255, 26, 206))

im.save('textures/tiles.png', quality=100)

Chia lớp

Mình chia đơn giản thành 3 phần:

  • Lớp Window: Tạo cửa số chính, các thanh ngang trên, kích thước cửa số. Chạy cửa số chính, load Board game.
  • Lớp Board: Chia các block ảnh màu, lưu thành 7 shape như trong game Tetris, random shape, vẽ các đường bảng, …
  • Lớp Shape: Tập hợp các thuộc tính của Shape, render Shape, cập nhập Shape sau mỗi lần xuống, biến đối Shape, thao tác phím với Shape, …

Mình sẽ đi từng bước thực hiện game Tetris như sau:

Thiết lập Window Game

 

Window có kích thước 306x629. Window có kích thước này là vì phải trừ đi vài pixcel 2 bên lề trái phải và trên dưới để ta được bên trong kích thước Board game sẽ là 300x600. Nút Close được thêm vào, không thêm nút Resize.

Bây giờ tạo thêm class Board rồi thêm Board vào trong Window để load game.

import javax.swing.JFrame;

/**
 * Window
 */

public class Window {
    public static final int WIDTH = 306, HEIGHT = 629;
    private JFrame window;
    private Board board;

    public Window(){
        window = new JFrame("Tetris Game");
        window.setSize(WIDTH, HEIGHT);
        window.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        window.setResizable(false);
        window.setLocationRelativeTo(null);

        board = new Board();
        window. add(board);
        window.addKeyListener(board);

        window.setVisible(true);
    }
    public static void main(String[] args) {
        new Window();
    }
}

Vẽ Board game

Với kích thước Board game là 300x600, ta chia bề rộng thành 10 phần, bề ngang thành 20 phần. Kẻ các đường màu đen phân chia tạo thành Board.

    private final int blockSize = 30;
    private final int boardWidth = 10, boardHeight = 20;
    public void paintComponent(Graphics g) {
        super.paintComponent(g);

        for (int i = 0; i < boardHeight; i++) {
            g.drawLine(0, i * blockSize, boardWidth * blockSize, i * blockSize);
        }
        for (int j = 0; j < boardWidth; j++) {
            g.drawLine(j * blockSize, 0, j * blockSize, boardHeight * blockSize);
        }
    }

Load ảnh, chia Block và tạo hình các Shape

Sau khi có Board game, ta sẽ load bức ảnh tiles.png chứa các Block 7 màu như trên. Tiến hành chia bức ảnh thành 7 block với 7 màu kích thước 30x30.

        try {
            blocks = ImageIO.read(Board.class.getResource("tiles.png"));
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

Tiếp theo, ta tạo Shape lưu một số thông tin hiện tại như khối màu, vị trí. Sau đó tạo hàm render để vẽ các khối Block cùng màu lên Board game để tạo thành hình các Shape.

    private BufferedImage block;
    private int[][] coords;
    private Board board;
    public void render(Graphics g){
        for (int row = 0; row < coords.length; row++) {
            for (int col = 0; col < coords[row].length; col++) {
                if(coords[row][col] != 0)
                    g.drawImage(block, col*board.getBlockSize() + x*board.getBlockSize(), row * board.getBlockSize() + y*board.getBlockSize(), null);
            }
        }
    }

Các Shape gồm 7 tạo hình gồm hình chữ O, I, S, Z, L, J, T. Các chia là chúng ta sử dụng một ma trận kích thước nhỏ, đánh dấu các ô số 1 nghĩa là chứa block, số 0 là không chứa block. Các ô số 1 xếp thành hình các Shape rồi ta render cho Shape là được. 

        shapes[0] = new Shape(blocks.getSubimage(0, 0, blockSize, blockSize), new int[][] { { 1, 1, 1, 1 } // I shape
        }, this, 1);
        shapes[1] = new Shape(blocks.getSubimage(blockSize, 0, blockSize, blockSize),
                new int[][] { { 1, 1, 0 }, { 0, 1, 1 } // Z shape
                }, this, 2);
        shapes[2] = new Shape(blocks.getSubimage(blockSize * 2, 0, blockSize, blockSize),
                new int[][] { { 0, 1, 1 }, { 1, 1, 0 } // S shape
                }, this, 3);
        shapes[3] = new Shape(blocks.getSubimage(blockSize * 3, 0, blockSize, blockSize),
                new int[][] { { 1, 1, 1 }, { 0, 0, 1 } // J shape
                }, this, 4);
        shapes[4] = new Shape(blocks.getSubimage(blockSize * 4, 0, blockSize, blockSize),
                new int[][] { { 1, 1, 1 }, { 1, 0, 0 } // L shape
                }, this, 5);
        shapes[5] = new Shape(blocks.getSubimage(blockSize * 5, 0, blockSize, blockSize),
                new int[][] { { 1, 1, 1 }, { 0, 1, 0 } // T shape
                }, this, 6);
        shapes[6] = new Shape(blocks.getSubimage(blockSize * 6, 0, blockSize, blockSize),
                new int[][] { { 1, 1 }, { 1, 1 } // O shape
                }, this, 7);

Tạo chức năng cho Shape

Sau khi có các Shape việc tiếp theo cần làm là tạo các Event Key tạo tác các Shape. Xử lý va chạm với border của Board.

Bình thường, Shape sẽ tự động di chuyển từ trên xuống dưới. Key thao tác gồm phím trái, phải để di chuyển Shape sang trái hay phải. Phím xuống dưới dùng để tăng tốc độ cho Shape. Bình thường thì deltaX = 0 còn deltaX = -1 hoặc 1 sẽ di chuyển shape sang trái hoặc phải. Ở board.java:

    @Override
    public void keyPressed(KeyEvent e) {
        // TODO Auto-generated method stub
        if(e.getKeyCode() == KeyEvent.VK_LEFT){
            currentShape.setDeltaX(-1);
        }
        if(e.getKeyCode() == KeyEvent.VK_RIGHT){
            currentShape.setDeltaX(1);
        }
    }

Khi gặp các border trái phải, ta sẽ hủy các Event Key. Còn gặp border dưới sẽ dừng lại.

    public void update(){
        time += System.currentTimeMillis() - lastTime;
        lastTime = System.currentTimeMillis();

        if(collision){
            for (int row = 0; row < coords.length; row++) {
                for (int col = 0; col < coords[row].length; col++) {
                    if(coords[row][col] != 0){
                        board.getBoard()[y + row][x + col] = color;
                    }
                }
            }
            checkLine();
            board.setNextShape();
        }

        if(!(x + deltaX + coords[0].length > 10) && !(x + deltaX < 0)){
            for (int row = 0; row < coords.length; row++) {
                for (int col = 0; col < coords[row].length; col++) {
                    if(coords[row][col] != 0){                    
                        if(board.getBoard()[y + row][x + deltaX + col] != 0){
                            moveX = false;
                        }
                    }
                }
            }
            if(moveX)
                x += deltaX;

        }
        if(!(y + 1 + coords.length >  20)){
            for (int row = 0; row < coords.length; row++) {
                for (int col = 0; col < coords[row].length; col++) {
                    if(coords[row][col] != 0){                    
                        if(board.getBoard()[y + row + 1][col + x] != 0){
                            collision = true;
                        }
                    }
                }
            }
            if(time > currentSpeed){
                y++;
                time = 0;
            }
        } else {
            collision = true;
        }


        deltaX = 0;
        moveX = true;

    }

Xoay Shape

Ta tạo thao tác phím lên dùng để xoay Shape. Như trên, mỗi Shape sẽ được định nghĩa là một ma trận gồm các phần từ 0 và 1. Do đó, Shape sẽ được xoay theo công thức: chuyển vị ma trận (Transpose) sau đó ta đảo ngược ma trận này (Reverse).

    public void rotate(){
        if(collision)
            return;
        int[][] rotateMatrix = null;

        rotateMatrix = getTranspose(coords);
        rotateMatrix = getReverseMatrix(rotateMatrix);
        if(x + rotateMatrix[0].length > 10 || y + rotateMatrix.length > 20)
            return;

        for (int row = 0; row < rotateMatrix.length; row++) {
            for (int col = 0; col < rotateMatrix[0].length; col++) {
                if(board.getBoard()[y + col][x + col] != 0){
                    return;
                }
            }
        }
        coords = rotateMatrix;
        
    }

    private int[][] getTranspose(int[][] matrix){
        int[][] newMatrix = new int[matrix[0].length][matrix.length];

        for (int i = 0; i < matrix.length; i++) {
            for (int j = 0; j < matrix[0].length; j++) {
                newMatrix[j][i] = matrix[i][j];
            }
        }
        return newMatrix;
    }

    private int[][] getReverseMatrix(int[][] matrix){
        int middle = matrix.length/2;

        for(int i = 0; i < middle; i++){
            int[] m = matrix[i];
            matrix[i] = matrix[matrix.length - i - 1];
            matrix[matrix.length - i - 1] = m;
        }
        return matrix;
    }

Tiếp theo ta tiếp tục xử lý va chạm đối với Shape sau khi xoay. Vì sau khi xoay số hàng và cột cũng thay đổi theo.

Tạo Shape mới và Xử lý va chạm giữa các Shape

Sau khi ta đã có các thao tác đối với một Shape. Tiếp theo ta sẽ tạo ra một Shape mới sau khi Shape cũ va chạm với đáy hoặc va chạm với Shape khác. Mỗi Shape được tạo ra bằng hàm random và được tạo ra ngay giữa hàng trên cùng. 

    public void setNextShape(){
        int index = (int)(Math.random()*shapes.length);

        Shape newShape = new Shape(shapes[index].getBlock(), shapes[index].getCoords(), this, shapes[index].getColor());
        currentShape = newShape;

        for (int row = 0; row < currentShape.getCoords().length; row++) {
            for (int col = 0; col < currentShape.getCoords()[row].length; col++) {
                if(currentShape.getCoords()[row][col] != 0){
                    if(board[row][col + 3] != 0)
                        gameOver = true;
                }
            }
        }
    }

Các Shape va chạm với nhau ở trạng thái động (xoay Shape) thì sẽ xảy ra lỗi do đó, khi thấy trạng thái xoay mới của Shape va chạm với Shape cũ thì ta dừng chức năng xoay để đảm bảo Shape va chạm hợp lí.

    public void update(){
        time += System.currentTimeMillis() - lastTime;
        lastTime = System.currentTimeMillis();

        if(collision){
            for (int row = 0; row < coords.length; row++) {
                for (int col = 0; col < coords[row].length; col++) {
                    if(coords[row][col] != 0){
                        board.getBoard()[y + row][x + col] = color;
                    }
                }
            }
            checkLine();
            board.setNextShape();
        }

        if(!(x + deltaX + coords[0].length > 10) && !(x + deltaX < 0)){
            for (int row = 0; row < coords.length; row++) {
                for (int col = 0; col < coords[row].length; col++) {
                    if(coords[row][col] != 0){                    
                        if(board.getBoard()[y + row][x + deltaX + col] != 0){
                            moveX = false;
                        }
                    }
                }
            }
            if(moveX)
                x += deltaX;
        }
        if(!(y + 1 + coords.length >  20)){
            for (int row = 0; row < coords.length; row++) {
                for (int col = 0; col < coords[row].length; col++) {
                    if(coords[row][col] != 0){                    
                        if(board.getBoard()[y + row + 1][col + x] != 0){
                            collision = true;
                        }
                    }
                }
            }
            if(time > currentSpeed){
                y++;
                time = 0;
            }
        } else {
            collision = true;
        }

        deltaX = 0;
        moveX = true;

    }

Ghi điểm

Khi một hàng được điền tất cả các ô trống nó sẽ tự động mất đi và đấy các hàng ở trên xuống dưới. Bước này được gọi là bước ghi điểm.

    private void checkLine(){
        int height = board.getBoard().length - 1;
        for (int i = height; i > 0; i--) {
            int count = 0;
            for (int j = 0; j < board.getBoard()[0].length; j++) {
                if(board.getBoard()[i][j] != 0){
                    count++;
                }
                board.getBoard()[height][j] = board.getBoard()[i][j];
            }
            if(count < board.getBoard()[0].length)
                height--;
        }
    }

Game Over

Game kết thúc khi không thể tạo ra Shape mới. Tức là khi tạo ra một Shape mới nếu Shape mới có ô đè lên Shape cũ thì game dừng lại.

    public void update(){
        currentShape.update();
        if(gameOver){
            timer.stop();
        }
    }

    public void setNextShape(){
        int index = (int)(Math.random()*shapes.length);

        Shape newShape = new Shape(shapes[index].getBlock(), shapes[index].getCoords(), this, shapes[index].getColor());
        currentShape = newShape;

        for (int row = 0; row < currentShape.getCoords().length; row++) {
            for (int col = 0; col < currentShape.getCoords()[row].length; col++) {
                if(currentShape.getCoords()[row][col] != 0){
                    if(board[row][col + 3] != 0)
                        gameOver = true;
                }
            }
        }


    }

Source code

Một game Tetris cơ bản đã xong. Mình để lại cho các bạn Full code để các bạn tham khảo nhé.

Phát triển

Những phần khó nhất của game Tetris đã hoàn thành. Từ bài viết chúng ta có thể cải tiến thêm một số chức năng như sau:

  • Thêm background (Chỉ cần tải ảnh và thêm ảnh vào phía sau window)
  • Tăng độ khó cho game level (Ta có thể tăng độ khó cho game bằng 2 cách: tăng độ khó khi score đạt một mức nào đó hoặc tăng độ khó sau một khoảng thời gian nào đó)
  • Tính điểm số score (Ta có thể tính điểm bằng công thức total += score_line*level)
  • Luyện nào brain. Một vài ý tưởng khoai chuối khác như chỉnh tốc độ speed thất thường cho Shape. Tạo 2 Tetris game cạnh nhau để chơi. Thêm chướng ngại vật,…

Cảm ơn các bạn đã đọc và quan tâm. Nếu có ý kiến gì thêm hãy để lại bình luận bên dưới.

Chào quyết thắng