https://zetcode.com/javagames/tetris/
In this chapter, we created a Tetris game clone in Java Swing. The source code and images can be found in the author's Github Java-Tetris-Game Found in repository.
Tetris
Tetris is one of the most popular computer games in history. The original game was designed and programmed by Russian programmer Alexey Pajitnov in 1985. Since then, Tetris can appear in many forms on almost every computer platform. Even my phone has a modified version of Tetris.
Tetris is called a puzzle game. In this game, we have seven different shapes called tetrominoes. S, Z, T, L, linear, mirrored, and square. Each of these shapes is formed by four squares. The shape is falling off the board. The goal of Tetris is to move and rotate shapes to make them fit as well as possible. If we try to form a line, the line will be destroyed and we will score. We played Tetris until we reached the top.
Figure: Tetromino
development history
tetrominoes are drawn using the Swing painting API. We use Java util. Timer to create a game loop. The shape moves on the basis of a square (not a pixel by pixel). Mathematically speaking, the chessboard in the game is a simple list of numbers.
The game starts immediately after it starts. We can pause the game by pressing the p key. The space bar will immediately put the Tetris at the bottom. The d key will move the pieces one line down. (it can be used to accelerate descent.) The game is played at a constant speed without acceleration. The score is the number of rows we deleted.
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[][]; private int[][][] coordsTable; public Shape() { initShape(); } private void initShape() { coords = new int[4][2]; 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 } } }; setShape(Tetrominoe.NoShape); } protected void setShape(Tetrominoe shape) { 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 Tetrominoe getShape() { return pieceShape; } public 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; } 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 == 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; } public 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; } }
Class Shape provides information about Tetris works.
protected enum Tetrominoe { NoShape, ZShape, SShape, LineShape, TShape, SquareShape, LShape, MirroredLShape }
The Tetrominoe enumeration has seven Tetris shapes called the empty shape NoShape.
coords = new int[4][2]; setShape(Tetrominoe.NoShape);
The coords array holds the actual coordinates of the Tetris.
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}} };
The coordstab array contains all possible coordinate values of Tetris. This is a template from which all parts get their coordinate values.
for (int i = 0; i < 4; i++) { System.arraycopy(coordsTable[shape.ordinal()], 0, coords, 0, 4); }
We put a row of coordinate values {coords table from into the array of coords Tetris. Note the use of the ordinal() method. In C + +, an enumeration type is essentially an integer. Unlike C + +, Java enumeration is a complete class. The {ordinal() method returns the current position of the enumeration type in the enumeration object.
The following illustration will help you learn more about coordinate values. The coords array holds the coordinates of Tetris. For example, the numbers (- 1, 1), (- 1, 0), (0, 0), and (0, - 1) represent the rotating S-shape. The following illustration illustrates the shape.
Figure: coordinates
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; }
This code rotates one block to the left. The square doesn't have to rotate. This is why we simply return a reference to the current object. Viewing the previous picture will help you understand the rotation.
package com.zetcode; import com.zetcode.Shape.Tetrominoe; import static javax.swing.JOptionPane.showMessageDialog; import static javax.swing.JOptionPane.showConfirmDialog; import javax.swing.*; 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 = 20; 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() { numLinesRemoved = 0; statusbar.setText("0"); 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) { // vertical line g.setColor(Color.black); for (int i = 1; i < BOARD_WIDTH; i++) { g.drawLine(i * (int) getSize().getWidth() / BOARD_WIDTH, 0, i * (int) getSize().getWidth() / BOARD_WIDTH, (int) getSize().getHeight()); } // g.drawLine(0, (int) getSize().getWidth() / BOARD_WIDTH, 20, 120); // (int) getSize().getWidth() / BOARD_WIDTH 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); // showMessageDialog(null, "This is even shorter"); // int result = showConfirmDialog(null), "restart?", "Restart", // JOptionPane.YES_NO_OPTION, // JOptionPane.QUESTION_MESSAGE); // if (result == JOptionPane.YES_OPTION) { start(); // } else if (result == JOptionPane.NO_OPTION) { // statusbar.setText("You selected: No"); // } else { // statusbar.setText("None selected"); // } } } 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); // System.out.println("Width="+squareWidth()); // System.out.println("Height="+squareHeight()); 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(); case KeyEvent.VK_R -> start(); } } } }
Finally, we have board Java file. This is where the game logic is.
private final int BOARD_WIDTH = 10; private final int BOARD_HEIGHT = 22; private final int PERIOD_INTERVAL = 300;
We have four constants. The BOARD_WIDTH and} BOARD_HEIGHT defines the size of the circuit board. Period of_ The interval constant defines the speed of the 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; ...
Some important variables are initialized. After isFallingFinished , it is determined that the Tetris shape has finished falling, and then we need to create a new shape. The isStarted is used to check if the game has started. Similarly, isPaused is used to check whether the game is paused. The numLinesRemoved calculation is the number of lines we have deleted so far. The curX and curY determine the actual position of the falling Tetris shape.
private int squareWidth() { return (int) getSize().getWidth() / BOARD_WIDTH; } private int squareHeight() { return (int) getSize().getHeight() / BOARD_HEIGHT; }
These lines determine the width and height of a single Tetrominoe square.
private Tetrominoe shapeAt(int x, int y) { return board[(y * BOARD_WIDTH) + x]; }
We determine the shape at a given coordinate. Shapes are stored in the board array.
void start() { curPiece = new Shape(); board = new Tetrominoe[BOARD_WIDTH * BOARD_HEIGHT]; ...
We create a new current shape and a new board.
clearBoard(); newPiece();
The chessboard is cleared and new fallen pieces are initialized.
timer = new Timer(PERIOD_INTERVAL, new GameCycle()); timer.start();
We create a timer. Timer per PERIOD_INTERVAL is executed once every other period of time to form a game cycle.
private void pause() { isPaused = !isPaused; if (isPaused) { statusbar.setText("paused"); } else { statusbar.setText(String.valueOf(numLinesRemoved)); } repaint(); }
The pause() method pauses or resumes the game. When the game is paused, we will paused to display a message in the status bar.
Inside the doDrawing() method, we draw all objects on the board. Painting has two steps.
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); } } }
In the first step, we draw all the shapes or the rest of the shapes that fall to the bottom of the board. All the squares are remembered in the chessboard array. We use the shapeAt() method to access it.
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()); } }
In the second step, we draw the actual falling part.
private void dropDown() { int newY = curY; while (newY > 0) { if (!tryMove(curPiece, curX, newY - 1)) { break; } newY--; } pieceDropped(); }
If we press the Space key, the piece will fall to the bottom. We just try to put the piece down a line until it reaches the bottom or top of another fallen Tetris. When the Tetris has finished falling, pieceDropped() is called.
private void oneLineDown() { if (!tryMove(curPiece, curX, curY - 1)) { pieceDropped(); } }
In this oneLineDown() method, we try to move the falling part down one line until it falls completely.
private void clearBoard() { for (int i = 0; i < BOARD_HEIGHT * BOARD_WIDTH; i++) { board[i] = Tetrominoe.NoShape; } }
The clearBoard() method uses an empty filler plate, tetrominoe NoShape. This is later used for collision detection.
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(); } }
The pieceDropped() method puts the falling fragments into the {board array. The chessboard again accommodates the squares of all the pieces and the rest of the fallen pieces. When the pieces fall, it's time to check if we can remove some lines from the board. This is the work of the removeFullLines() method. Then we create a new work, or more accurately, we try to create a new work.
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); } }
The newPiece() method creates a new Tetris. This work obtains a new random shape. Then we calculate the initial curX and curY values. If we can't move to the initial position, the game is over - we're on top. When the timer stops, our Game over displays a string containing scores on the status bar.
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; }
The tryMove() method attempts to move the Tetris. false if it has reached the board boundary or is adjacent to a fallen Tetris piece, the method returns.
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); } }
Inside the removeFullLines() method, we check whether there are any complete lines in all lines in the board. If there is at least one complete row, delete it. After finding the entire row, we increment the counter. We move all the lines above the whole line down one line. So we destroy the whole line. Please note that in our Tetris game, we use the so-called naive gravity. This means that the square may float above the blank gap.
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); }
Each Tetris has four squares. Each square is drawn with this drawSquare() method. Tetris fragments have different colors. The left and top of the square are drawn in lighter colors. Similarly, the bottom and right are drawn in darker colors. This is to simulate 3D edges.
private class GameCycle implements ActionListener { @Override public void actionPerformed(ActionEvent e) { doGameCycle(); } }
In GameCycle, we call the doGameCycle() method to create a game cycle.
private void doGameCycle() { update(); repaint(); }
The game is divided into game cycles. Each cycle updates the game and redraws the board.
private void update() { if (isPaused) { return; } if (isFallingFinished) { isFallingFinished = false; newPiece(); } else { oneLineDown(); } }
The update() represents a step in the game. The falling piece moves down one line, or if the previous piece has finished falling, a new piece is created.
private class TAdapter extends KeyAdapter { @Override public void keyPressed(KeyEvent e) { ...
The game is controlled by the cursor keys. We check the KeyAdapter
int keycode = e.getKeyCode();
We use the getKeyCode() method to get the key code.
// 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(); }
Using the Java 12 switch expression, we bind key events to methods. For example, in Space, we use the key to put down the falling Tetris.
package com.zetcode; import java.awt.BorderLayout; import java.awt.EventQueue; import javax.swing.JFrame; import javax.swing.JLabel; /* Java Tetris game clone Author: Jan Bodnar Website: http://zetcode.com */ public class Tetris extends JFrame { private JLabel statusbar; public Tetris() { initUI(); } private void initUI() { statusbar = new JLabel(" 0"); add(statusbar, BorderLayout.SOUTH); var board = new Board(this); add(board); board.start(); setTitle("Tetris"); setSize(320, 660); setDefaultCloseOperation(EXIT_ON_CLOSE); setLocationRelativeTo(null); } JLabel getStatusBar() { return statusbar; } public static void main(String[] args) { EventQueue.invokeLater(() -> { var game = new Tetris(); game.setVisible(true); }); } }
In Tetris Java file, we set up the game. We created a board to play the game. We create a status bar.
statusbar = new JLabel(" 0"); add(statusbar, BorderLayout.SOUTH);
The score is displayed in a label at the bottom of the board.
var board = new Board(this); add(board); board.start();
The plate is created and added to the container. The start() method starts the Tetris game.
Figure: Tetris
This is a Tetris game.