본문 바로가기

JAVA

[JAVA] 4주차 | 테트리스 게임 만들기

https://zetcode.com/javagames/tetris/ 출처입니다.

https://github.com/janbodnar/Java-Tetris-Game 전체 코드는 여기서 볼 수 있습니다.


 

 

1. 우선 테트리스에는 tetrominoes라 불리는 7개의 테트리스 블록이 있습니다. 
 
2.  Swing painting API와 java.util.Timer를 사용합니다.
 
3. 모양은 square by square로 움직입니다. (직각으로)
 
4. 게임은 바로 시작되며, p키를 누르면 게임을 멈출 수 있습니다.
 
5. d키를 누르면, 모양이 바로 바닥으로 떨어집니다. (speed-up)
 
6. 일정한 속도로 게임이 진행되며, 가속은 구현하지 않습니다.
 
7. 지워진 라인만큼 점수를 얻습니다.
 
 

1) Shape 클래스

테트리스 블록의 모양, 회전 기능을 설정하는 클래스입니다.

전체 코드는 ->

더보기
package com.zetcode;

 

import java.util.Random;

 

public class Shape {

 

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

 

    private Tetrominoe pieceShape;
    private int[][] coords;

 

    public Shape() {

 

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

 

    void setShape(Tetrominoe shape) {

 

        int[][][] 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++) {

 

            System.arraycopy(coordsTable[shape.ordinal()], 0, coords, 0, 4);
        }

 

        pieceShape = shape;
    }

 

    private void setX(int index, int x) {

 

        coords[index][0] = x;
    }

 

    private void setY(int index, int y) {

 

        coords[index][1] = y;
    }

 

    int x(int index) {

 

        return coords[index][0];
    }

 

    int y(int index) {

 

        return coords[index][1];
    }

 

    Tetrominoe getShape() {

 

        return pieceShape;
    }

 

    void setRandomShape() {

 

        var r = new Random();
        int x = Math.abs(r.nextInt()) % 7 + 1;

 

        Tetrominoe[] values = Tetrominoe.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;
    }



    int minY() {

 

        int m = coords[0][1];

 

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

 

            m = Math.min(m, coords[i][1]);
        }

 

        return m;
    }

 

    Shape rotateLeft() {

 

        if (pieceShape == Tetrominoe.SquareShape) {

 

            return this;
        }

 

        var 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;
    }

 

    Shape rotateRight() {

 

        if (pieceShape == Tetrominoe.SquareShape) {

 

            return this;
        }

 

        var 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;
    }
}
package com.zetcode;

import java.util.Random;

public class Shape {

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

    private Tetrominoe pieceShape;
    private int[][] coords;

7개의 tetrominoe와 NoShape.
coords는 테트리스 배열의 좌표를 의미합니다. 
 

public Shape() {

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

    void setShape(Tetrominoe shape) {

        int[][][] 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++) {

            System.arraycopy(coordsTable[shape.ordinal()], 0, coords, 0, 4);
        }

        pieceShape = shape;
    }

coordsTable은 테트리스의 조각을 나타냅니다.

예를 들어, (-1, 1), (-1, 0), (0, 0), (0, -1) 은 밑에 있는 그림과 같습니다.

Java 열거형은 전체 클래스이며 Ordinal 메서드는 다음에서 열거형 개체의 현재 위치를 반환합니다.

 

System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length);

src: 원본배열, srcPos: 원본배열에서 복사할 항목의 시작 인덱스, dest: 새 배열, destPos: 새 배열에서 붙여넣을 시작 인덱스, length: 복사할 개수

 

    Shape rotateLeft() {

        if (pieceShape == Tetrominoe.SquareShape) {

            return this;
        }

        var 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;
    }

    Shape rotateRight() {

        if (pieceShape == Tetrominoe.SquareShape) {

            return this;
        }

        var 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;
    }
}

SquareShape은 회전해도 그대로이기 때문에 return this로 해줍니다.

 

2) Board 클래스

gui를 설정하는 클래스입니다.

전체 코드는 ->

더보기
package com.zetcode;

import com.zetcode.Shape.Tetrominoe;

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

public class Board extends JPanel {

    private final int BOARD_WIDTH = 10;
    private final int BOARD_HEIGHT = 22;
    private final int PERIOD_INTERVAL = 300;

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

    public Board(Tetris parent) {

        initBoard(parent);
    }

    private void initBoard(Tetris parent) {

        setFocusable(true);
        statusbar = parent.getStatusBar();
        addKeyListener(new TAdapter());
    }

    private int squareWidth() {

        return (int) getSize().getWidth() / BOARD_WIDTH;
    }

    private int squareHeight() {

        return (int) getSize().getHeight() / BOARD_HEIGHT;
    }

    private Tetrominoe shapeAt(int x, int y) {

        return board[(y * BOARD_WIDTH) + x];
    }

    void start() {

        curPiece = new Shape();
        board = new Tetrominoe[BOARD_WIDTH * BOARD_HEIGHT];

        clearBoard();
        newPiece();

        timer = new Timer(PERIOD_INTERVAL, new GameCycle());
        timer.start();
    }

    private void pause() {

        isPaused = !isPaused;

        if (isPaused) {

            statusbar.setText("paused");
        } else {

            statusbar.setText(String.valueOf(numLinesRemoved));
        }

        repaint();
    }

    @Override
    public void paintComponent(Graphics g) {

        super.paintComponent(g);
        doDrawing(g);
    }

    private void doDrawing(Graphics g) {

        var size = getSize();
        int boardTop = (int) size.getHeight() - BOARD_HEIGHT * squareHeight();

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

            for (int j = 0; j < BOARD_WIDTH; j++) {

                Tetrominoe shape = shapeAt(j, BOARD_HEIGHT - i - 1);

                if (shape != Tetrominoe.NoShape) {

                    drawSquare(g, j * squareWidth(),
                            boardTop + i * squareHeight(), shape);
                }
            }
        }

        if (curPiece.getShape() != Tetrominoe.NoShape) {

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

                int x = curX + curPiece.x(i);
                int y = curY - curPiece.y(i);

                drawSquare(g, x * squareWidth(),
                        boardTop + (BOARD_HEIGHT - y - 1) * squareHeight(),
                        curPiece.getShape());
            }
        }
    }

    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 < BOARD_HEIGHT * BOARD_WIDTH; i++) {

            board[i] = Tetrominoe.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 * BOARD_WIDTH) + x] = curPiece.getShape();
        }

        removeFullLines();

        if (!isFallingFinished) {

            newPiece();
        }
    }

    private void newPiece() {

        curPiece.setRandomShape();
        curX = BOARD_WIDTH / 2 + 1;
        curY = BOARD_HEIGHT - 1 + curPiece.minY();

        if (!tryMove(curPiece, curX, curY)) {

            curPiece.setShape(Tetrominoe.NoShape);
            timer.stop();

            var msg = String.format("Game over. Score: %d", numLinesRemoved);
            statusbar.setText(msg);
        }
    }

    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 >= BOARD_WIDTH || y < 0 || y >= BOARD_HEIGHT) {

                return false;
            }

            if (shapeAt(x, y) != Tetrominoe.NoShape) {

                return false;
            }
        }

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

        repaint();

        return true;
    }

    private void removeFullLines() {

        int numFullLines = 0;

        for (int i = BOARD_HEIGHT - 1; i >= 0; i--) {

            boolean lineIsFull = true;

            for (int j = 0; j < BOARD_WIDTH; j++) {

                if (shapeAt(j, i) == Tetrominoe.NoShape) {

                    lineIsFull = false;
                    break;
                }
            }

            if (lineIsFull) {

                numFullLines++;

                for (int k = i; k < BOARD_HEIGHT - 1; k++) {
                    for (int j = 0; j < BOARD_WIDTH; j++) {
                        board[(k * BOARD_WIDTH) + j] = shapeAt(j, k + 1);
                    }
                }
            }
        }

        if (numFullLines > 0) {

            numLinesRemoved += numFullLines;

            statusbar.setText(String.valueOf(numLinesRemoved));
            isFallingFinished = true;
            curPiece.setShape(Tetrominoe.NoShape);
        }
    }

    private void drawSquare(Graphics g, int x, int y, Tetrominoe 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)
        };

        var 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);
    }

    private class GameCycle implements ActionListener {

        @Override
        public void actionPerformed(ActionEvent e) {

            doGameCycle();
        }
    }

    private void doGameCycle() {

        update();
        repaint();
    }

    private void update() {

        if (isPaused) {

            return;
        }

        if (isFallingFinished) {

            isFallingFinished = false;
            newPiece();
        } else {

            oneLineDown();
        }
    }

    class TAdapter extends KeyAdapter {

        @Override
        public void keyPressed(KeyEvent e) {

            if (curPiece.getShape() == Tetrominoe.NoShape) {

                return;
            }

            int keycode = e.getKeyCode();

            // Java 12 switch expressions
            switch (keycode) {

                case KeyEvent.VK_P -> pause();
                case KeyEvent.VK_LEFT -> tryMove(curPiece, curX - 1, curY);
                case KeyEvent.VK_RIGHT -> tryMove(curPiece, curX + 1, curY);
                case KeyEvent.VK_DOWN -> tryMove(curPiece.rotateRight(), curX, curY);
                case KeyEvent.VK_UP -> tryMove(curPiece.rotateLeft(), curX, curY);
                case KeyEvent.VK_SPACE -> dropDown();
                case KeyEvent.VK_D -> oneLineDown();
            }
        }
    }
}
private final int BOARD_WIDTH = 10;
private final int BOARD_HEIGHT = 22;
private final int PERIOD_INTERVAL = 300;

3개의 상수를 설정해줍니다. 

BOARD_WIDTH, BOARD_HEIGHT -> 보드의 사이즈 결정

PERIOD_INTERVEAL -> 게임의 speed

 

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

isFallingFinished= true이면, 새로운 블록을 만듭니다.

isStarted는 게임이 시작됐는지, isPaused는 게임이 paused됐는지 체크하는 변수입니다.

numLinesRemoved는 지금까지 제거한 라인의 수를 계산합니다.

curX 및 curY는 떨어지는 테트리스 모양의 실제 위치를 결정합니다.

 

private int squareWidth() {

    return (int) getSize().getWidth() / BOARD_WIDTH;
}

private int squareHeight() {

    return (int) getSize().getHeight() / BOARD_HEIGHT;
}

테트리스 블록의 크기를 결정하는 코드입니다. 

 

private Tetrominoe shapeAt(int x, int y) {

    return board[(y * BOARD_WIDTH) + x];
}

주어진 좌표에서 모양을 결정합니다. 모양은 보드 배열에 저장됩니다.

 

void start() {

    curPiece = new Shape();
    board = new Tetrominoe[BOARD_WIDTH * BOARD_HEIGHT];
...

start() 메소드에서는, 새 shape과 board를 생성합니다.

 

clearBoard();
newPiece();

board와 테트리스 블록을 초기화합니다.

 

timer = new Timer(PERIOD_INTERVAL, new GameCycle());
timer.start();

타이머는 PERIOD_INTERVAL 간격으로 실행되어 게임 사이클을 생성합니다.

 

 

private void pause() {

    isPaused = !isPaused;

    if (isPaused) {

        statusbar.setText("paused");
    } else {

        statusbar.setText(String.valueOf(numLinesRemoved));
    }

    repaint();
}

pause() 메소드는 게임이 일시 중지되면 상태 표시줄에 일시 중지된 메시지가 표시됩니다.

 

 

doDrawing() 메소드는 두 스텝으로 이루어집니다. 

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

    for (int j = 0; j < BOARD_WIDTH; j++) {

        Tetrominoe shape = shapeAt(j, BOARD_HEIGHT - i - 1);

        if (shape != Tetrominoe.NoShape) {

            drawSquare(g, j * squareWidth(),
                    boardTop + i * squareHeight(), shape);
        }
    }
}

먼저 모든 테트로미노를 만들고

 

if (curPiece.getShape() != Tetrominoe.NoShape) {

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

        int x = curX + curPiece.x(i);
        int y = curY - curPiece.y(i);

        drawSquare(g, x * squareWidth(),
                boardTop + (BOARD_HEIGHT - y - 1) * squareHeight(),
                curPiece.getShape());
    }
}

떨어뜨릴 테트리스 조각을 만듭니다.

 

private void dropDown() {

    int newY = curY;

    while (newY > 0) {

        if (!tryMove(curPiece, curX, newY - 1)) {

            break;
        }

        newY--;
    }

    pieceDropped();
}

스페이스바를 누르면 바로 떨어지게 합니다.

 

// Java 12 switch expressions
switch (keycode) {

    case KeyEvent.VK_P -> pause();
    case KeyEvent.VK_LEFT -> tryMove(curPiece, curX - 1, curY);
    case KeyEvent.VK_RIGHT -> tryMove(curPiece, curX + 1, curY);
    case KeyEvent.VK_DOWN -> tryMove(curPiece.rotateRight(), curX, curY);
    case KeyEvent.VK_UP -> tryMove(curPiece.rotateLeft(), curX, curY);
    case KeyEvent.VK_SPACE -> dropDown();
    case KeyEvent.VK_D -> oneLineDown();
}

각 키와 메소드들을 연결한 것입니다. 

3) Tetris 클래스

var board = new Board(this);
add(board);
board.start();

보드가 생성되어 컨테이너에 추가됩니다. start() 메소드는 테트리스 게임을 시작합니다.