Java 2D – Trò chơi xếp hình – Tetris


Được đăng vào ngày 24/02/2016 | 0 bình luận
Java 2D – Trò chơi xếp hình – Tetris
Đánh giá bài viết

Trong phần này chúng ta sẽ viết một game rất nổi tiếng đó là game Xếp hình 😀 (Tetris)

Tetris

Game được thiết kế và phát triển lần đầu tiên vào năm 1985 bởi một lập trình viên người Nga tên là Alexey Pajitnov. Kể từ đó, game này được phát triển thành nhiều phiên bản trên nhiều hệ máy khác nhau.

Trong game có 7 khối gạch có dạng hình chữ S, hình chữ Z, hình chữ T, hình chữ L, hình đường thẳng, hình chữ L ngược và hình vuông. Mỗi khối gạch được tạo nên bởi 4 hình vuông. Các khối gạch sẽ rơi từ trên xuống. Người chơi phải di chuyển và xoay các khối gạch sao cho chúng vừa khít với nhau, nếu các khối gạch lấp đầy một đường ngang thì người chơi ghi điểm. Trò chơi kết thúc khi các khối gạch chồng lên nhau quá cao.

tetrominoes

 

Mô tả game

Kích thước của các đối tượng trong game này được tính theo đơn vị 1 hình vuông chứ không theo pixel nữa. Kích thước của cửa sổ giờ đây là 10×22 hình vuông. Kích thước thật của các hình vuông được tính động theo kích thước cửa sổ. Tức là nếu kích thước cửa sổ là 200×400 pixel thì kích thước thật của môi hình vuông là 200×400 / 10×22 là khoảng 20×18 pixel (nhưng chúng ta cũng không quan tâm đến con số này).

Mỗi lần chạy chương trình là game sẽ tự động chạy ngay, tuy nhiên chúng ta có thể cho tạm dừng game bằng cách bấm phím P. Nếu muốn khối gạch rơi nhanh xuống đáy ngay lập tức thì bấm phím Space. Bấm phím D sẽ làm cho khối gạch rơi xuống thêm 1 hàng, có thể dùng phím này để tăng tốc độ rơi thêm một chút. Tốc độ game không thay đổi. Điểm sẽ là số hàng đã được loại bỏ.

import java.awt.BorderLayout;

import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.SwingUtilities;


public class Tetris extends JFrame {

    private JLabel statusbar;

    public Tetris() {
        
        initUI();
   }
    
   private void initUI() {

        statusbar = new JLabel(" 0");
        add(statusbar, BorderLayout.SOUTH);
        Board board = new Board(this);
        add(board);
        board.start();

        setSize(200, 400);
        setTitle("Tetris");
        setDefaultCloseOperation(EXIT_ON_CLOSE);
        setLocationRelativeTo(null);       
   }

   public JLabel getStatusBar() {
       
       return statusbar;
   }

    public static void main(String[] args) {

        SwingUtilities.invokeLater(new Runnable() {
            
            @Override
            public void run() {
                
                Tetris game = new Tetris();
                game.setVisible(true);
            }
        });                
    } 
}

Toàn bộ game này chúng ta viết trong 3 file, đầu tiên là file Tetris.java, file này là file main, làm các công việc khởi tạo cửa sổ, khởi tạo kích thước, chạy luồng main…

board.start();

Chúng ta gọi phương thức start() để khi chương trình chạy thì game cũng chạy luôn.

import java.util.Random;

public class Shape {

    protected enum Tetrominoes { NoShape, ZShape, SShape, LineShape, 
               TShape, SquareShape, LShape, MirroredLShape };

    private Tetrominoes pieceShape;
    private int coords[][];
    private int[][][] coordsTable;


    public Shape() {

        coords = new int[4][2];
        setShape(Tetrominoes.NoShape);
    }

    public void setShape(Tetrominoes shape) {

         coordsTable = new int[][][] {
            { { 0, 0 },   { 0, 0 },   { 0, 0 },   { 0, 0 } },
            { { 0,-1 },   { 0, 0 },   {-1, 0 },   {-1, 1 } },
            { { 0,-1 },   { 0, 0 },   { 1, 0 },   { 1, 1 } },
            { { 0,-1 },   { 0, 0 },   { 0, 1 },   { 0, 2 } },
            { {-1, 0 },   { 0, 0 },   { 1, 0 },   { 0, 1 } },
            { { 0, 0 },   { 1, 0 },   { 0, 1 },   { 1, 1 } },
            { {-1,-1 },   { 0,-1 },   { 0, 0 },   { 0, 1 } },
            { { 1,-1 },   { 0,-1 },   { 0, 0 },   { 0, 1 } }
        };

        for (int i = 0; i < 4 ; i++) {
            
            for (int j = 0; j < 2; ++j) {
                
                coords[i][j] = coordsTable[shape.ordinal()][i][j];
            }
        }
        
        pieceShape = shape;
    }

    private void setX(int index, int x) { coords[index][0] = x; }
    private void setY(int index, int y) { coords[index][1] = y; }
    public int x(int index) { return coords[index][0]; }
    public int y(int index) { return coords[index][1]; }
    public Tetrominoes getShape()  { return pieceShape; }

    public void setRandomShape() {
        
        Random r = new Random();
        int x = Math.abs(r.nextInt()) % 7 + 1;
        Tetrominoes[] values = Tetrominoes.values(); 
        setShape(values[x]);
    }

    public int minX() {
        
      int m = coords[0][0];
      
      for (int i=0; i < 4; i++) {
          
          m = Math.min(m, coords[i][0]);
      }
      
      return m;
    }


    public int minY() {
        
      int m = coords[0][1];
      
      for (int i=0; i < 4; i++) {
          
          m = Math.min(m, coords[i][1]);
      }
      
      return m;
    }

    public Shape rotateLeft() {
        
        if (pieceShape == Tetrominoes.SquareShape)
            return this;

        Shape result = new Shape();
        result.pieceShape = pieceShape;

        for (int i = 0; i < 4; ++i) {
            
            result.setX(i, y(i));
            result.setY(i, -x(i));
        }
        
        return result;
    }

    public Shape rotateRight() {
        
        if (pieceShape == Tetrominoes.SquareShape)
            return this;

        Shape result = new Shape();
        result.pieceShape = pieceShape;

        for (int i = 0; i < 4; ++i) {

            result.setX(i, -y(i));
            result.setY(i, x(i));
        }
        
        return result;
    }
}

Tiếp theo là file Shape.java, file này lưu thông tin về các khối gạch.

protected enum Tetrominoes { NoShape, ZShape, SShape, LineShape, 
            TShape, SquareShape, LShape, MirroredLShape };

Chúng ta định nghĩa tên các khối gạch trong enum Tetrominoes. Ngoài 7 khối cơ bản chúng ta tạo thêm 1 mối đặc biệt nữa là NoShape, khối này không có hình thù.

public Shape() {

    coords = new int[4][2];
    setShape(Tetrominoes.NoShape);
}

Trong phương thức khởi tạo, chúng ta tạo mảng coords để lưu giữ tọa độ thật của khối gạch.

coordsTable = new int[][][] {
   { { 0, 0 },   { 0, 0 },   { 0, 0 },   { 0, 0 } },
   { { 0, -1 },  { 0, 0 },   { -1, 0 },  { -1, 1 } },
   { { 0, -1 },  { 0, 0 },   { 1, 0 },   { 1, 1 } },
   { { 0, -1 },  { 0, 0 },   { 0, 1 },   { 0, 2 } },
   { { -1, 0 },  { 0, 0 },   { 1, 0 },   { 0, 1 } },
   { { 0, 0 },   { 1, 0 },   { 0, 1 },   { 1, 1 } },
   { { -1, -1 }, { 0, -1 },  { 0, 0 },   { 0, 1 } },
   { { 1, -1 },  { 0, -1 },  { 0, 0 },   { 0, 1 } }
};

Mảng coordsTable lưu tọa độ của các hình vuông tạo nên từng loại khối gạch.

for (int i = 0; i < 4 ; i++) {
    
    for (int j = 0; j < 2; ++j) {
        
        coords[i][j] = coordsTable[shape.ordinal()][i][j];
    }
}

Phương thức setShape() sẽ quy định cho đối tượng khối gạch đó là loại nào, tham số của phương thức này là một giá trị của enum Tetrominoes, từ tham số đó chúng ta lấy số thứ tự của thuộc tính đó bằng phương thức ordinal(), rồi dùng số thứ tự đó để lấy tọa độ trong mảng coordsTable và chép vào mảng coords.

Lý do tại sao tọa độ ở đây chỉ là 0, 1 và -1 thì bạn xem hình dưới đây để hình dung. Ví dụ { 0, -1 }, { 0, 0 }, { -1, 0 }, { -1, -1 } , sẽ đại diện cho khối gạch hình chữ S.

Coordinates
public Shape rotateLeft() {
    
    if (pieceShape == Tetrominoes.SquareShape)
        return this;

    Shape result = new Shape();
    result.pieceShape = pieceShape;

    for (int i = 0; i < 4; ++i) {
        
        result.setX(i, y(i));
        result.setY(i, -x(i));
    }
    
    return result;
}

Phương thức rotateLeft() làm nhiệm vụ xoay khối gạch qua bên trái. Ở đây mình sẽ không giải thích vì sao chúng ta lại có đoạn code xoay như trên vì rất khó giải thích, bạn cứ nhìn hình ở trên rồi tự suy ra sẽ thấy, nếu thắc mắc chỗ nào thì hãy comment ở cuối bài này và mình sẽ giải thích. Phương thức rotateRight() cũng tương tự là xoay khối gạch sang bên phải.

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.Timer;

import Shape.Tetrominoes;

public class Board extends JPanel 
        implements ActionListener {

    private final int BoardWidth = 10;
    private final int BoardHeight = 22;

    private Timer timer;
    private boolean isFallingFinished = false;
    private boolean isStarted = false;
    private boolean isPaused = false;
    private int numLinesRemoved = 0;
    private int curX = 0;
    private int curY = 0;
    private JLabel statusbar;
    private Shape curPiece;
    private Tetrominoes[] board;

    public Board(Tetris parent) {

        initBoard(parent);
    }
    
    private void initBoard(Tetris parent) {
        
       setFocusable(true);
       curPiece = new Shape();
       timer = new Timer(400, this);
       timer.start(); 

       statusbar =  parent.getStatusBar();
       board = new Tetrominoes[BoardWidth * BoardHeight];
       addKeyListener(new TAdapter());
       clearBoard();          
    }

    @Override
    public void actionPerformed(ActionEvent e) {
        
        if (isFallingFinished) {
            
            isFallingFinished = false;
            newPiece();
        } else {
            
            oneLineDown();
        }
    }

    private int squareWidth() { return (int) getSize().getWidth() / BoardWidth; }
    private int squareHeight() { return (int) getSize().getHeight() / BoardHeight; }
    private Tetrominoes shapeAt(int x, int y) { return board[(y * BoardWidth) + x]; }


    public void start()  {
        
        if (isPaused)
            return;

        isStarted = true;
        isFallingFinished = false;
        numLinesRemoved = 0;
        clearBoard();

        newPiece();
        timer.start();
    }

    private void pause()  {
        
        if (!isStarted)
            return;

        isPaused = !isPaused;
        
        if (isPaused) {
            
            timer.stop();
            statusbar.setText("paused");
        } else {
            
            timer.start();
            statusbar.setText(String.valueOf(numLinesRemoved));
        }
        
        repaint();
    }
    
    private void doDrawing(Graphics g) {
        
        Dimension size = getSize();
        int boardTop = (int) size.getHeight() - BoardHeight * squareHeight();

        for (int i = 0; i < BoardHeight; ++i) {
            
            for (int j = 0; j < BoardWidth; ++j) {
                
                Tetrominoes shape = shapeAt(j, BoardHeight - i - 1);
                
                if (shape != Tetrominoes.NoShape)
                    drawSquare(g, 0 + j * squareWidth(),
                               boardTop + i * squareHeight(), shape);
            }
        }

        if (curPiece.getShape() != Tetrominoes.NoShape) {
            
            for (int i = 0; i < 4; ++i) { 
                int x = curX + curPiece.x(i); 
                int y = curY - curPiece.y(i); 
                drawSquare(g, 0 + x * squareWidth(), boardTop + (BoardHeight - y - 1) * squareHeight(), curPiece.getShape()); 
            } 
        } 
    } 
    
    @Override 
    public void paintComponent(Graphics g) { 
        super.paintComponent(g); 
        doDrawing(g); 
    } 

    private void dropDown() { 
        int newY = curY; 
        while (newY > 0) {
            
            if (!tryMove(curPiece, curX, newY - 1))
                break;
            --newY;
        }
        
        pieceDropped();
    }

    private void oneLineDown()  {
        
        if (!tryMove(curPiece, curX, curY - 1))
            pieceDropped();
    }


    private void clearBoard() {
        
        for (int i = 0; i < BoardHeight * BoardWidth; ++i)
            board[i] = Tetrominoes.NoShape;
    }

    private void pieceDropped() {
        
        for (int i = 0; i < 4; ++i) {
            
            int x = curX + curPiece.x(i);
            int y = curY - curPiece.y(i);
            board[(y * BoardWidth) + x] = curPiece.getShape();
        }

        removeFullLines();

        if (!isFallingFinished)
            newPiece();
    }

    private void newPiece()  {
        
        curPiece.setRandomShape();
        curX = BoardWidth / 2 + 1;
        curY = BoardHeight - 1 + curPiece.minY();

        if (!tryMove(curPiece, curX, curY)) {
            
            curPiece.setShape(Tetrominoes.NoShape);
            timer.stop();
            isStarted = false;
            statusbar.setText("game over");
        }
    }

    private boolean tryMove(Shape newPiece, int newX, int newY) {
        
        for (int i = 0; i < 4; ++i) {
            
            int x = newX + newPiece.x(i);
            int y = newY - newPiece.y(i);
            
            if (x < 0 || x >= BoardWidth || y < 0 || y >= BoardHeight)
                return false;
            
            if (shapeAt(x, y) != Tetrominoes.NoShape)
                return false;
        }

        curPiece = newPiece;
        curX = newX;
        curY = newY;

        repaint();

        return true;
    }

    private void removeFullLines() {
        
        int numFullLines = 0;

        for (int i = BoardHeight - 1; i >= 0; --i) {
            boolean lineIsFull = true;

            for (int j = 0; j < BoardWidth; ++j) {
                if (shapeAt(j, i) == Tetrominoes.NoShape) {
                    lineIsFull = false;
                    break;
                }
            }

            if (lineIsFull) {
                ++numFullLines;
                for (int k = i; k < BoardHeight - 1; ++k) {
                    for (int j = 0; j < BoardWidth; ++j) 
                        board[(k * BoardWidth) + j] = shapeAt(j, k + 1); 
                } 
            } 
        } 
        if (numFullLines > 0) {
            
            numLinesRemoved += numFullLines;
            statusbar.setText(String.valueOf(numLinesRemoved));
            isFallingFinished = true;
            curPiece.setShape(Tetrominoes.NoShape);
            repaint();
        }
     }

    private void drawSquare(Graphics g, int x, int y, Tetrominoes shape)  {
        
        Color colors[] = { new Color(0, 0, 0), new Color(204, 102, 102), 
            new Color(102, 204, 102), new Color(102, 102, 204), 
            new Color(204, 204, 102), new Color(204, 102, 204), 
            new Color(102, 204, 204), new Color(218, 170, 0)
        };

        Color color = colors[shape.ordinal()];

        g.setColor(color);
        g.fillRect(x + 1, y + 1, squareWidth() - 2, squareHeight() - 2);

        g.setColor(color.brighter());
        g.drawLine(x, y + squareHeight() - 1, x, y);
        g.drawLine(x, y, x + squareWidth() - 1, y);

        g.setColor(color.darker());
        g.drawLine(x + 1, y + squareHeight() - 1,
                         x + squareWidth() - 1, y + squareHeight() - 1);
        g.drawLine(x + squareWidth() - 1, y + squareHeight() - 1,
                         x + squareWidth() - 1, y + 1);

    }

    class TAdapter extends KeyAdapter {
        
         @Override
         public void keyPressed(KeyEvent e) {

             if (!isStarted || curPiece.getShape() == Tetrominoes.NoShape) {  
                 return;
             }

             int keycode = e.getKeyCode();

             if (keycode == 'p' || keycode == 'P') {
                 pause();
                 return;
             }

             if (isPaused)
                 return;

             switch (keycode) {
                 
             case KeyEvent.VK_LEFT:
                 tryMove(curPiece, curX - 1, curY);
                 break;
                 
             case KeyEvent.VK_RIGHT:
                 tryMove(curPiece, curX + 1, curY);
                 break;
                 
             case KeyEvent.VK_DOWN:
                 tryMove(curPiece.rotateRight(), curX, curY);
                 break;
                 
             case KeyEvent.VK_UP:
                 tryMove(curPiece.rotateLeft(), curX, curY);
                 break;
                 
             case KeyEvent.VK_SPACE:
                 dropDown();
                 break;
                 
             case 'd':
                 oneLineDown();
                 break;
                 
             case 'D':
                 oneLineDown();
                 break;
             }
         }
     }
}

Cuối cùng chúng ta có file Board.java. File này chịu trách nhiệm điều khiển các khối gạch cũng như xử lý các hoạt động diễn ra trong game.

...
private boolean isFallingFinished = false;
private boolean isStarted = false;
private boolean isPaused = false;
private int numLinesRemoved = 0;
private int curX = 0;
private int curY = 0;
...

Ý nghĩa của các thuộc tính trên như sau:

  • isFallingFinished: cho biết khối gạch hiện tại đã rơi xuống xong chưa, nếu rồi thì tạo một khối gạch mới
  • isStartedisPaused: dùng để kiểm tra trạng thái hiện tại của game là đang chơi hay tạm dừng hay đã kết thúc
  • numLinesRemoved: lưu số lượng các đường ngang đã bị xóa
  • curXcurY: lưu tọa độ hiện tại của khối gạch đang rơi
setFocusable(true);

Chúng ta phải gọi phương thức setFocusable() một cách tường minh để bàn phím có thể nhận sự kiện bấm phím.

timer = new Timer(400, this);
timer.start(); 

Đối tượng Timer sẽ giải phóng sự kiện sau mỗi 400 mili-giây và gọi đến phương thức actionPerformed(), có thể hiểu đây là vòng lặp của game.

@Override
public void actionPerformed(ActionEvent e) {
    
    if (isFallingFinished) {
        
        isFallingFinished = false;
        newPiece();
    } else {
        
        oneLineDown();
    }
}

Trong phương thức actionPerformed(), chúng ta kiểm tra xem khối gạch đang rơi đã rơi xong chưa, nếu chưa thì chúng ta gọi phương thức oneLineDown() để cho khối gạch này rơi thêm 1 hàng, nếu đã rơi xong rồi thì chúng ta gọi phương thức newPiece() để tạo một khối gạch mới.

Trong phương thức doDrawing() chúng ta vẽ các đối tượng lên màn hình.

for (int i = 0; i < BoardHeight; ++i) {
    
    for (int j = 0; j < BoardWidth; ++j) {
        
        Tetrominoes shape = shapeAt(j, BoardHeight - i - 1);
        
        if (shape != Tetrominoes.NoShape)
            drawSquare(g, 0 + j * squareWidth(),
                        boardTop + i * squareHeight(), shape);
    }
}

Đầu tiên chúng ta vẽ các khối gạch đã rơi xuống đáy màn hình. Các khối gạch đã rơi xong sẽ được lưu lại vị trí trong mảng board, chúng ta lấy lại thông tin đó thông qua phương thức shapeAt().

if (curPiece.getShape() != Tetrominoes.NoShape) {
    
    for (int i = 0; i < 4; ++i) {
        
        int x = curX + curPiece.x(i);
        int y = curY - curPiece.y(i);
        drawSquare(g, 0 + x * squareWidth(),
                    boardTop + (BoardHeight - y - 1) * squareHeight(),
                    curPiece.getShape());
    }
}

Tiếp theo chúng ta vẽ khối gạch đang rơi.

private void dropDown() {
    
    int newY = curY;
    
    while (newY > 0) {
        
        if (!tryMove(curPiece, curX, newY - 1))
            break;
        --newY;
    }
    
    pieceDropped();
}

Nếu bấm phím space, khối gạch sẽ được rơi xuống đáy ngay lập tức bằng cách cho chạy một vòng lặp riêng.

private void clearBoard() {
    
    for (int i = 0; i < BoardHeight * BoardWidth; ++i)
        board[i] = Tetrominoes.NoShape;
}

Phương thức clearBoard() có nhiệm vụ lấp đầy cửa sổ bằng khối gạch NoShape, chúng ta làm thế để phục vụ việc kiểm tra va chạm sau này.

private void pieceDropped() {
    
    for (int i = 0; i < 4; ++i) {
        
        int x = curX + curPiece.x(i);
        int y = curY - curPiece.y(i);
        board[(y * BoardWidth) + x] = curPiece.getShape();
    }

    removeFullLines();

    if (!isFallingFinished)
        newPiece();
}

Phương thức pieceDropped() được gọi mỗi lần có một khối gạch đã rơi xong (chạm đáy hoặc chạm một khối gạch khác đã nằm sẵn), phương thức này sẽ thêm tọa độ mới của các khối gạch trong mảng board sau đó tiến hành kiểm tra xem có hàng nào full hay không thông qua phương thức removeFullLines(), cuối cùng khởi tạo một khối gạch mới (chúng ta thêm câu lệnh if để kiểm tra xem khối gạch chắc chắn đã rơi xong chưa).

private void newPiece()  {
    
    curPiece.setRandomShape();
    curX = BoardWidth / 2 + 1;
    curY = BoardHeight - 1 + curPiece.minY();

    if (!tryMove(curPiece, curX, curY)) {
        
        curPiece.setShape(Tetrominoes.NoShape);
        timer.stop();
        isStarted = false;
        statusbar.setText("game over");
    }
}

Phương thức newPiece() khởi tạo một khối gạch mới và tính toán vị trí cho khối gạch. Nếu vị trí khởi tạo của khối gạch không đủ vì phần gạch bên dưới đã quá cao thì chúng ta tiến hành cho game kết thúc.

private boolean tryMove(Shape newPiece, int newX, int newY) {
    
    for (int i = 0; i < 4; ++i) {
        
        int x = newX + newPiece.x(i);
        int y = newY - newPiece.y(i);
        
        if (x < 0 || x >= BoardWidth || y < 0 || y >= BoardHeight)
            return false;
        
        if (shapeAt(x, y) != Tetrominoes.NoShape)
            return false;
    }

    curPiece = newPiece;
    curX = newX;
    curY = newY;

    repaint();

    return true;
}

Phương thức tryMove() sẽ di chuyển khối gạch, phương thức này trả về false nếu khối gạch không thể di chuyển được do đã chạm biên cửa sổ hoặc chạm các khối gạch khác.

int numFullLines = 0;

for (int i = BoardHeight - 1; i >= 0; --i) {
    boolean lineIsFull = true;

    for (int j = 0; j < BoardWidth; ++j) {
        if (shapeAt(j, i) == Tetrominoes.NoShape) {
            lineIsFull = false;
            break;
        }
    }

    if (lineIsFull) {
        ++numFullLines;
        for (int k = i; k < BoardHeight - 1; ++k) {
            for (int j = 0; j < BoardWidth; ++j)
                    board[(k * BoardWidth) + j] = shapeAt(j, k + 1);
        }
    }
}

Phương thức removeFullLines() kiểm tra xem có hàng nào đã full hay chưa, nếu có thì chúng ta tăng một biến đếm lên để dùng vào việc tính điểm, đồng thời chúng ta kéo các hàng phía trên xuống một hàng, để đảm bảo tất cả các hàng full đều bị xóa.

Các khối gạch của game có màu khác nhau. Các khối gạch được vẽ trong phương thức drawSquare().

g.setColor(color.brighter());
g.drawLine(x, y + squareHeight() - 1, x, y);
g.drawLine(x, y, x + squareWidth() - 1, y);

Chúng ta vẽ các hình vuông có cạnh bên trái và cạnh trên màu sáng hơn với 2 cạnh còn lại để tạo hiệu ứng khối 3D.

Sự kiện bàn phím sẽ được nhận thông qua phương thức keyPressed() của lớp KeyAdapter.

case KeyEvent.VK_LEFT:
    tryMove(curPiece, curX - 1, curY);
    break;

Chẳng hạn như nếu bấm phím mũi tên trái, chúng ta thử di chuyển khối gạch sang bên trái.

Untitled






Bình luận

Hãy trở thành người đầu tiên bình luận

Thông báo cho tôi qua email khi
avatar
wpDiscuz