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.
TetrominoDataSource
was posted with an incomplete code. Update it to match the one you have on github. \$\endgroup\$