[Java] Design and implement block breaking game with clean architecture

10 minute read

Introduction

I am studying clean architecture, and I am trying various designs and implementations. There is an article that seems to be good on the subject, and I tried to redesign and reimplement with a clean architecture in mind. I wrote it in the comment section of the original article, but since it is a big deal, I will organize the design process etc. into an article. Subject: I tried to make a simple block break-Qiita

image.png

Design

The game program can classify the appearances, so I think class extraction is not so difficult.

  • Appearance (Model): Game court, wall, block, racket, ball
  • Input (Controller): Mouse operation, keyboard operation
  • Output: Game screen, splash screen
  • Process (Logic): Main process, game process

The class diagram is shown below. I tried to color-code the background according to the figure famous for clean architecture. image.png

UI, Web, DB, Devices (blue part)

UI

  • Breakout: Block breaking GUI application An application class that has a main method. Create various objects, build relationships, and start working.

Controllers, Presenters, Gateways (green part)

Controllers (input conversion)

Since I made it an anonymous class, I did not write it in the class diagram, but what should I do?

  • MouseMotionAdapter anonymous class Converts mouse movements to racket movements.

Presenters (Output conversion)

  • BreakoutGameView Converts the game state to a graphic display.

  • Splash Splash screen that is displayed for a short time at startup.

Use Cases (red part)

  • BreakoutGame Manage the court and things in the court. Game API and game logic to move rackets and balls.

Use Case Output Port uses Entities, but is it a foul?

Entities (yellow part)

A class group that has data such as position information, size information, and color information.

  • Court: Game court It has walls on the top, left and right. There is no wall below.
  • Wall: Wall Bounce the ball.
  • Block: Block Bounce the ball. There are blocks that do not break even if the ball hits, blocks that break when the ball hits once, blocks that break when hit twice, and blocks that break when hit three times.
  • Racket: Racket Can be moved left and right. Bounce the ball.
  • Ball: Ball Fly around the court. You lose the ball if you stick out under the court.

There is also an alternative data output interface for getters. (Red interface in yellow) I think the real pleasure of clean architecture is to prepare a data output interface (Output Port) and reverse the dependency.

In addition, the bounced object is abstracted as Bounder, and the bounced object (ball) is abstracted as Boundee so that it can be processed without depending on the concrete class.

Implementation

The splash screen, block layout, and multiple ball layout match the original work. My policy is to keep 25 lines per method so that it can be seen on one screen of the terminal.

import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Random;
import java.awt.*;
import java.awt.event.*;
import java.awt.Color;
import java.awt.Rectangle;
import javax.swing.*;

public class Breakout extends JFrame {

    public static void main(String[] args) {
        Breakout breakout = new Breakout();
        breakout.setVisible(true);
        breakout.playAfterSplash(5);
    }

    private final Splash splash = new Splash();
    private final BreakoutGame game = new BreakoutGame();
    private final BreakoutGameView view = new BreakoutGameView(game);
    private final CardLayout card = new CardLayout(0, 0);
    private Timer timer = null;

    public Breakout() {
        setTitle("break the block");
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        Container pane = getContentPane();
        pane.setLayout(card);
        pane.add(splash, "splash");
        pane.add(view, "play");
        setupControllers();
        pack();
    }

    private void setupControllers() {
        view.addMouseMotionListener(new MouseMotionAdapter() {
            @Override
            public void mouseMoved(MouseEvent event) {
                game.moveRacket(event.getX());
                view.repaint();
            }
        });
    }

    public void playAfterSplash(int sec) {
        card.show(getContentPane(), "splash");
        timer = new javax.swing.Timer(sec * 1000, (e) -> {
            timer.stop();
            timer = null;
            play();
        });
        timer.start();
    }

    public void play() {
        if (timer != null) timer.stop();
        card.show(getContentPane(), "play");
        timer = new javax.swing.Timer(50, (e) -> {
            game.update();
            view.repaint();
            if (game.isClear() || game.isOver()) {
                timer.stop();
                timer = null;
            }
        });
        timer.start();
    }
}

class Splash extends JPanel {
    @Override
    public void paint(Graphics g) {
        super.paint(g);
        g.setFont(new Font("TimeRoman", Font.CENTER_BASELINE, 30));
        g.setColor(Color.red);
        g.drawString("Block breaking", 300, 200);
        g.drawString("It will start in 5 seconds!", 250, 300);
        g.drawString("Time limit is 30 seconds", 250, 400);
        g.drawString("Move the mouse sideways to bounce the ball", 125, 500);
        g.drawString("The mouse has the tip of the arrow below it in the initial position", 100, 600);
        g.drawString("↓", 425, 700);
    }
}

class BreakoutGameView extends JPanel {
    private final BreakoutGame game;

    public BreakoutGameView(BreakoutGame game) {
        this.game = game;
        setPreferredSize(new Dimension(game.WIDTH, game.HEIGHT));
        setBackground(Color.BLACK);
    }

    @Override
    public void paint(Graphics g) {
        super.paint(g);
        paintBalls(g);
        paintWalls(g);
        paintBlocks(g);
        paintRacket(g);
        if (game.isClear()) {
            paintGameClear(g);
        } else if (game.isOver()) {
            paintGameOver(g);
        }
    }

    private void paintBalls(Graphics g) {
        g.setColor(Color.RED);
        game.viewBalls((x, y, size) -> g.fillOval(x, y, size, size));
    }private void paintWalls(Graphics g) {
        final int[] offset = {0, -16, -8};
        final int blockWidth = 26, blockHeight = 10, gapX = 6, gapY = 5;
        g.setColor(Color.GREEN);
        game.viewWalls((wallX, wallY, wallWidth, wallHeight) -> {
            for (int y = wallY, iy = 0; y < wallY + wallHeight; y += blockHeight + gapY, iy++) {
                for (int blockX = wallX + offset[iy % offset.length]; blockX < wallX + wallWidth; blockX += blockWidth + gapX) {
                    int x = blockX;
                    int width = blockWidth;
                    int height = blockHeight;
                    if (x < wallX) {
                        x = wallX;
                        width -= wallX - blockX;
                    } else if (x + blockWidth >= wallX + wallWidth) {
                        width = wallX + wallWidth - x;
                    }
                    if (wallY + height >= wallY + wallHeight) {
                        height = wallY + wallHeight - y;
                    }
                    g.fillRect(x, y, width, height);
                }
            }
        });
    }

    private void paintBlocks(Graphics g) {
        game.viewBlocks((x, y, width, height, color) -> {
            g.setColor(color);
            g.fillRect(x, y, width, height);
        });
    }

    private void paintRacket(Graphics g) {
        g.setColor(Color.WHITE);
        game.viewRacket((x, y, width, height) -> g.fillRect(x, y, width, height));
    }

    private void paintGameClear(Graphics g) {
        g.setFont(new Font("TimeRoman", Font.BOLD, 50));
        g.setColor(Color.orange);
        g.drawString("Game Clear!", 300, 550);
    }

    private void paintGameOver(Graphics g) {
        g.setFont(new Font("TimeRoman", Font.BOLD, 50));
        g.setColor(Color.orange);
        g.drawString("Game Over!", 300, 550);
    }
}

class BreakoutGame {
    public static final int WIDTH = 855;
    public static final int HEIGHT = 800;
    public static final int WALL = 40;
    private final Court court;
    private final Racket racket;
    private final List<Block> blocks;
    private final List<Ball> balls;
    private final Random rand = new Random();

    public BreakoutGame() {
        court = new Court(WIDTH, HEIGHT, WALL);
        racket = new Racket(WIDTH / 2, HEIGHT - 110, 120, 5, WALL, WIDTH - WALL);
        blocks = makeBlocks();
        balls = Arrays.asList(makeBalls());
    }

    private List<Block> makeBlocks() {
        List<Block> blocks = new ArrayList<>();
        addBlocks(blocks,  60, 40, 16, 40, 48, 8, 4, Color.YELLOW, 3);
        addBlocks(blocks, 300, 40, 16, 40, 16, 8, 3, Color.GREEN, 2);
        addBlocks(blocks, 450, 40, 16, 40, 16, 8, 1, Color.GRAY, Block.UNBREAKABLE);
        addBlocks(blocks, 600, 40, 16, 40, 16, 9, 4, Color.CYAN, 1);
        return blocks;
    }

    private void addBlocks(List<Block> blocks, int topY,
                           int width, int height, int gapX, int gapY,
                           int cols, int rows, Color color, int strength) {
        int topX = (WIDTH - width * cols - gapX * (cols - 1)) / 2;
        int endY = topY + (height + gapY) * rows;
        int endX = topX + (width + gapX) * cols;
        for (int y = topY; y < endY; y += height + gapY) {
            for (int x = topX; x < endX; x += width + gapX) {
                blocks.add(new Block(x, y, width, height, color, strength));
            }
        }
    }

    private Ball[] makeBalls() {
        return new Ball[] {
            makeBall(250, 5, -6, 7),
            makeBall(260, -5, -3, 10),
            makeBall(420, 4, 6, 8),
            makeBall(480, -5, 2, 10),
            makeBall(590, 5, -6, 11),
            makeBall(550, -5, -3, 12),
            makeBall(570, 4, 6, 13),
            makeBall(480, -5, 2, 14),
            makeBall(490, 5, -6, 8),
            makeBall(400, -5, -3, 8),
            makeBall(350, 4, 6, 9),
            makeBall(400, -5, 2, 10),
            makeBall(390, -5, -3, 10),
            makeBall(500, 4, 6, 10),
            makeBall(530, -5, 2, 7),
        };
    }

    private Ball makeBall(int y, int vx, int vy, int size) {
        return new Ball(40 + rand.nextInt(700), y, vx, vy, size);
    }

    public boolean isClear() {
        return blocks.stream().allMatch(block -> block.isClear());
    }

    public boolean isOver() {
        return balls.stream().allMatch(ball -> ball.isDead());
    }

    public void moveRacket(int x) {
        racket.move(x);
    }

    public void update() {
        for (var ball: balls) {
            ball.move(HEIGHT);
            court.rebound(ball);
            blocks.forEach(ball::bound);
            ball.bound(racket);  // ball bound racket after bounding blocks
        }
    }

    public void viewWalls(WallViewer viewer) {
        court.viewWalls(viewer);
    }

    public void viewBlocks(BlockViewer viewer) {
        blocks.forEach(block -> block.view(viewer));
    }

    public void viewRacket(RacketViewer viewer) {racket.view(viewer);
    }

    public void viewBalls(BallViewer viewer) {
        balls.forEach(ball -> ball.view(viewer));
    }
}

class Bounder {
    protected Rectangle rect;

    public Bounder(int x, int y, int width, int height) {
        this.rect = new Rectangle(x, y, width, height);
    }

    public boolean isHit(int x, int y) {
        return this.rect.contains(x, y);
    }

    public void hit() {
        // default: nothing to do
    }

    public void view(BounderViewer viewer) {
        viewer.view(rect.x, rect.y, rect.width, rect.height);
    }
}

interface BounderViewer {
    public void view(int x, int y, int width, int height);
}

class Court {
    private final Wall up, left, right;

    public Court(int width, int height, int wall) {
        up = new Wall(0, 0, width, wall);
        left = new Wall(0, 0, wall, height);
        right = new Wall(width - wall, 0, wall, height);
    }

    public boolean isHit(int x, int y) {
        return up.isHit(x, y) || left.isHit(x, y) || right.isHit(x, y);
    }

    public void rebound(Boundee boundee) {
        boundee.bound(up);
        boundee.bound(left);
        boundee.bound(right);
    }

    public void viewWalls(WallViewer viewer) {
        up.view(viewer);
        left.view(viewer);
        right.view(viewer);
    }
}

class Wall extends Bounder {
    public Wall(int x, int y, int width, int height) {
        super(x, y, width, height);
    }
}

interface WallViewer extends BounderViewer {
}

class Block extends Bounder {
    public static final int UNBREAKABLE = -1;
    private static final int BROKEN = 0;
    private final Color color;
    private int strength;

    public Block(int x, int y, int width, int height, Color color, int strength) {
        super(x, y, width, height);
        this.color = color;
        this.strength = strength;
    }

    @Override
    public boolean isHit(int x, int y) {
        return isBroken() ? false : super.isHit(x, y);
    }

    @Override
    public void hit() {
        if (strength > 0) strength--;
    }

    public boolean isBroken() {
        return strength == BROKEN;
    }

    public boolean isClear() {
        return strength <= 0;
    }

    public void view(BlockViewer viewer) {
        if (!isBroken()) {
            viewer.view(rect.x, rect.y, rect.width, rect.height, color);
        }
    }
}

interface BlockViewer {
    public void view(int x, int y, int width, int height, Color color);
}

class Racket extends Bounder {
    public static final int SPEED = 5;
    private final int left, right;

    public Racket(int centerX, int centerY, int width, int height,
                  int limitLeft, int limitRight) {
        super(centerX - width / 2, centerY - height / 2, width, height);
        left = limitLeft;
        right = limitRight - width;
    }

    public void move(int x) {
        x -= rect.width / 2;
        rect.x = x < left ? left
               : x > right ? right
               : x;
    }
}

interface RacketViewer extends BounderViewer {
}

interface Boundee {
    public void bound(Bounder bounder);
}

class Ball implements Boundee {
    private int x, y, vx, vy;
    private final int size, r;
    private boolean alive = true;

    public Ball(int x, int y, int vx, int vy, int size) {
        this.x = x;
        this.y = y;
        this.vx = vx;
        this.vy = vy;
        this.size = size | 1;  // always odd
        this.r = this.size / 2;
    }

    public void move(int bottomY) {
        if (alive) {
            x += vx;
            y += vy;
            alive = y - r < bottomY;
        }
    }

    @Override
    public void bound(Bounder bounder) {
        boolean up = bounder.isHit(x, y - r);
        boolean down = bounder.isHit(x, y + r);
        boolean left = bounder.isHit(x - r, y);
        boolean right = bounder.isHit(x + r, y);
        boolean up_left = bounder.isHit(x - r, y - r);
        boolean up_right = bounder.isHit(x + r, y - r);
        boolean down_left = bounder.isHit(x - r, y + r);
        boolean down_right = bounder.isHit(x + r, y + r);
        if (vy < 0 && up && !bounder.isHit(x, y - r - vy) ||
            vy > 0 && down && !bounder.isHit(x, y + r - vy)) {
            bounder.hit();
            vy *= -1;
        } else if (vx < 0 && left && !bounder.isHit(x - r - vx, y - r) ||
                   vx > 0 && right && !bounder.isHit(x + r - vx, y - r)) {
            bounder.hit();
            vx *= -1;
        } else if (up_left && vx < 0 && vy < 0 ||
                   up_right && vx > 0 && vy < 0 ||
                   down_left && vx < 0 && vy > 0 ||
                   down_right && vx > 0 && vy > 0) {
            bounder.hit();
            vy *= -1;
            vx *= -1;
        }
    }

    public boolean isDead() {
        return !alive;
    }

    public void view(BallViewer viewer) {
        viewer.view(x - r, y - r, size);
    }
}

interface BallViewer {
    public void view(int x, int y, int size);
}

さいごにI wrote the controller in the inner class in the Breakout class which is the UI, but I am not confident that this is all right.

If you have any suggestions or suggestions, please feel free to comment.