5
\$\begingroup\$

I've designed a basic Tetris game, and implemented it in Java language. I'll use it for demonstrating software engineering, designing. I'll re-implement it in many different programming languages, in different platforms. Thus, good concept is very important.

Goals:

  • Follow general OOP principles.
  • Readablity, reasonable classes/algorithms/dependencies.
  • Views must be replaceable without changing the game's basic logic (console, LED matrix output).
  • Should be easy to extend with popular features (display next tetromino, scores, time, etc.), change existing features (colors, sizes).

Perforance (ie more code/abstraction), KISS/YAGNI are less important.

Java implementation: Updated version | Original: Full source code, Class diagram, Relevant codes below:

TetrisGame.java

public class TetrisGame {
    private final static Random random = new Random();
    private final int blockSize = 30;
    private final int columns = 11;
    private final int rows = 16;

    private boolean isRunning;
    private static TetrisGame instance;
    private final Board board;
    private Tetromino fallingTetromino;
    private final PeriodicTask gravity;
    private final MainWindow window;

    private TetrisGame(MainWindow window) {
        this.window = window;
        BoardView view = new BoardView(window, blockSize);
        board = new Board(rows, columns, view);
        gravity = new PeriodicTask(() -> {
            boolean moved = fallingTetromino.moveDown();
            if (!moved)
                tetrominoCantMoveFurther();
        }, 700);
    }
    public static TetrisGame createNew(MainWindow window) {
        instance = new TetrisGame(window);
        return instance;
    }
    public static TetrisGame getInstance() {
        return instance;
    }

    public void start() {
        isRunning = true;
        generateNextTetromino();
        gravity.start();
    }
    private void stop() {
        isRunning = false;
        fallingTetromino.dispose();
        fallingTetromino = null;
        gravity.stop();
    }
    public void handleCommand(UserCommand command) {
        if (!isRunning)
            return;
        switch (command) {
            case ROTATE:
                fallingTetromino.rotateRight();
                break;
            case MOVE_LEFT:
                fallingTetromino.moveLeft();
                break;
            case MOVE_DOWN:
                if (fallingTetromino.moveDown())
                    gravity.reset();
                else
                    tetrominoCantMoveFurther();
                break;
            case MOVE_RIGHT:
                fallingTetromino.moveRight();
                break;
            case DROP:
                fallingTetromino.drop();
                tetrominoCantMoveFurther();
                break;
        }
    }

    public boolean canMoveTetrominoTo(Tetromino tetromino, int x, int y) {
        return board.canAddTetromino(tetromino, x, y);
    }

    private void tetrominoCantMoveFurther() {
        board.addTetromino(fallingTetromino);
        board.removeFullRows();
        generateNextTetromino();
    }
    private void generateNextTetromino() {
        if (fallingTetromino != null)
            fallingTetromino.dispose();
        int type = random.nextInt(7);
        TetrominoView view = new TetrominoView(window, blockSize);
        Tetromino next = Tetromino.createAtCenter(type, view, columns);
        fallingTetromino = next;
        if (next != null)
            gravity.reset();
        else
            stop();
    }
}

Board.java

public class Board {
    private final int rows;
    private final int columns;
    private SquareView[][] board;
    private final BoardView view;

    public Board(int rows, int cols, BoardView view) {
        this.rows = rows;
        this.columns = cols;
        this.board = new SquareView[rows][cols];
        this.view = view;
        updateView();
    }

    public boolean canAddTetromino(Tetromino tetromino, int fromX, int fromY) {
        SquareView[][] data = tetromino.getPolyominoData();
        int height = data.length;
        int width = data[0].length;
        if (fromX < 0 || fromX + width > columns ||
            fromY < 0 || fromY + height > rows)
            return false;
        for (int i = 0; i < height; ++i) {
            for (int j = 0; j < width; ++j) {
                if (data[i][j] != null && board[fromY + i][fromX + j] != null)
                    return false;
            }
        }
        return true;
    }
    public void addTetromino(Tetromino tetromino) {
        SquareView[][] data = tetromino.getPolyominoData();
        int x = tetromino.getPosX();
        int y = tetromino.getPosY();
        int height = data.length;
        int width = data[0].length;
        for (int i = 0; i < height; ++i) {
            for (int j = 0; j < width; ++j) {
                if (data[i][j] != null)
                    board[y + i][x + j] = data[i][j];
            }
        }
        updateView();
    }
    public void removeFullRows() {
        boolean isRowFull;
        for (int i = 0; i < rows; ++i) {
            isRowFull = true;
            for (int j = 0; j < columns && isRowFull; ++j) {
                if (board[i][j] == null)
                    isRowFull = false;
            }
            if (isRowFull) {
                for (int j = i; j > 0; --j)
                    System.arraycopy(board[j - 1], 0, board[j], 0, columns);
                for (int j = 0; j < columns; ++j)
                    board[0][j] = null;
            }
        }
        updateView();
    }

    private void updateView() {
        view.update(board);
    }
}

Tetromino.java

public class Tetromino {
    private SquareView[][][] partsData;
    private TetrominoView view;
    private int currentX = 0;
    private int currentY = 0;
    private int rotation;
    private int width;
    private int height;

    private Tetromino(int type, TetrominoView view) {
        this.view = view;
        partsData = TetrominoDataSource.getData(type);
        setRotation(0);
    }
    public static Tetromino createAtCenter(int type, TetrominoView view, int boardWidth) {
        Tetromino tetromino = new Tetromino(type, view);
        int x = (int) Math.ceil((boardWidth - tetromino.width) / 2);
        boolean moved = tetromino.tryMove(x, 0);
        if (!moved) {
            tetromino.dispose();
            return null;
        }
        return tetromino;
    }
    public void dispose() {
        view.clear();
    }

    public SquareView[][] getPolyominoData() {
        return partsData[rotation];
    }
    public int getPosX() {
        return currentX;
    }
    public int getPosY() {
        return currentY;
    }

    public boolean rotateRight() {
        int nextRotation = (rotation + 1) % 4;
        boolean canRotate = false;
        int oldRotation = rotation;
        setRotation(nextRotation);
        if (canMoveTo(0, 0))
            canRotate = true;
        else {
            for (int i = 1; i < width && !canRotate; ++i) {
                if (canMoveTo(-i, 0)) {
                    currentX -= i;
                    canRotate = true;
                }
            }
        }
        if (!canRotate)
            setRotation(oldRotation);
        else {
            setRotation(nextRotation);
            updateView();
        }
        return canRotate;
    }
    public boolean moveRight() {
        return tryMove(1, 0);
    }
    public boolean moveLeft() {
        return tryMove(-1, 0);
    }
    public boolean moveDown() {
        return tryMove(0, 1);
    }
    public void drop() {
        boolean movedDown;
        do {
            movedDown = moveDown();
        } while (movedDown);
    }

    private void setRotation(int rotation) {
        this.rotation = rotation % partsData.length;
        height = partsData[this.rotation].length;
        width = partsData[this.rotation][0].length;
    }
    private void updateView() {
        view.update(partsData[rotation], currentX, currentY);
    }
    private boolean tryMove(int x, int y) {
        boolean canSlide = canMoveTo(x, y);
        if (canSlide) {
            currentX += x;
            currentY += y;
            updateView();
        }
        return canSlide;
    }
    private boolean canMoveTo(int deltaX, int deltaY) {
        return TetrisGame.getInstance().canMoveTetrominoTo(this, currentX + deltaX, currentY + deltaY);
    }
}

TetrominoDataSource.java

public class TetrominoDataSource {
    public static SquareView[][][] getData(int type) {
        String[][] masks = rawData[type];
        SquareView[][][] result = new SquareView[masks.length][][];
        for (int rotation = 0; rotation < masks.length; ++rotation) {
            int height = masks[rotation].length;
            int width = masks[rotation][0].length();
            result[rotation] = new SquareView[height][width];
            for (int i = 0; i < height; ++i) {
                for (int j = 0; j < width; ++j) {
                    if (masks[rotation][i].charAt(j) != ' ')
                        result[rotation][i][j] = new SquareView(type);
                }
            }
        }
        return result;
    }

    private static String[][][] rawData = new String[][][] {
        new String[][] {
            new String[] {
                "XX",
                "XX"
            }
        },
        new String[][] {
            new String[] {
                "X",
                "X",
                "X",
                "X"
            },
            new String[] {
                "XXXX"
            }
        },

view/TetrominoView.java (view/BoardView.java is very similar)

public class TetrominoView extends CanvasView {
    public TetrominoView(MainWindow window, int squareSize) {
        super(window.getTetrominoCanvas(), squareSize);
    }
    public void update(SquareView[][] data, int baseX, int baseY) {
        clear();
        int height = data.length;
        int width = data[0].length;
        for (int i = 0; i < height; ++i) {
            for (int j = 0; j < width; ++j) {
                if (data[i][j] != null)
                    data[i][j].update(context, baseX + j, baseY + i, squareSize);
            }
        }
    }
}

view/SquareView.java: Draws a filled sqaure.

view/CanvasView.java: Helper class for using canvases.

util/PeriodicTask.java: Wrapper around java.util.Timer.

window/EventController.java: Converst javafx.scene.input.KeyEvent to UserCommand.

window/MainWindow.java: Manages a JavaFX application.

\$\endgroup\$
  • \$\begingroup\$ TetrominoDataSource was posted with an incomplete code. Update it to match the one you have on github. \$\endgroup\$ – Isac Jul 18 '18 at 14:08
  • \$\begingroup\$ @Isac Not necessary, you can see the data structure from the first two tetromino. \$\endgroup\$ – klenium Jul 18 '18 at 14:17
4
\$\begingroup\$

Singletons are generally a bad idea unless you have a specific reason for them. (They are almost like global variables.) In this case, I see no reason why TetrisGame should be a singleton. In fact, it seems quite plausible that you might want to instantiate two of them, if you wanted to allow two players to play their games simultaneously.

\$\endgroup\$
  • \$\begingroup\$ Good point. Singleton was so easy to add, but yeah, it doesn't scale well, I've fixed it, thanks. Did you see anything else? \$\endgroup\$ – klenium Jul 19 '18 at 14:09
2
\$\begingroup\$

My friends also reviewed my code, and I found some things that could be improved:

  • They said there were too much null checks. Idneed, I used nulls in the board for empty cells, and in the tetromino's 2D array that contains its parts. Null checks are not good in OOP, because you deal with the variable's state instead of the object's state. Also, if you forget to check for null, you'll get NullPointerException (and yes, I got sometimes when I was creating the original version).
  • Using nulls in the board assumes that empty cells does not have any views. This is not good for reusability (maybe a text-based version would need to print a space).
  • Width, height, x and y coordinates were "hard-coded" into tha tetromino's 2D arrays. Originally I used matrix transforms to generate rotations for tetrominoes. This is easy in 2D arrays (just some transpose/reverse operations), but in view of OOP, they're less meaningful. What is the array's dimensions? width x height, or height x width? How is it indexed? Iterator variables named i and j doesn't tell it.

To solve these null-check problems, I added two helper data structures. Boad cells implement Null object pattern. In Tetromino, the order of parts was not needed anywhere, so a (full) 1D array is enough, and the helper structure stores the offset (relative distance from the tetromino's position). You can view changes here.

  • In function generateNextTetromino, there're also a null check. I used a factory method pattern. If the tetromino could not be added to the board (ie. the at game over), it returned null. Creating a new object should not belong to any condition, it's not the Tetromino's job to dispose itself.
  • generateNextTetromino also contained logic that's not related to the generation, but the result of it.
  • These names aren't such good to describe what's happening:

    int x = (int) Math.ceil((boardWidth - tetromino.width) / 2);
    boolean moved = tetromino.tryMove(x, 0);
    if (!moved) {
    
  • The game wasn't thread-safe. Because the Timer and JavaFX run on different threads, if the user presses a key at the same time when the timer expires, concurrent operations can broke the Tetromino's state. Only one transformation shoukd be evaluated at the same time.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.