[JAVA] Design and implement a breakout game with a clean architecture

Introduction

I am studying clean architecture and am trying various designs and implementations. There was an article that looked good on the subject, and I tried to redesign and reimplement with a clean architecture in mind. I commented on the design proposal and implementation proposal in the comment section of the original article, but since it has been further brushed up, I will organize the design process etc. and make it an article. Subject: java I tried to break a simple block --Qiita

image.png

design

Clean architecture

In the clean architecture, the layers are separated as shown in the figure below. clean.jpg

The clean architecture also shows a class diagram. clean_boundary.JPG

Breakout game

I don't think it's that difficult to extract a game program class because you can classify the appearances.

I divided the classes while being aware of the layers.

Frameworks & Drivers (blue part)

UI

Devices

JFrame and JPanel combine a mouse device, a keyboard device, and a display device.

Web

I think it is possible to make it possible to play games with a web browser, but I will omit it.

DB

It is possible to record the high score of the game, but omit it.

External Interfaces

It is possible to connect to a remote user via a network and play against each other, but omit it.

Interface Adapters (green part)

A group of data conversion adapters for joining (adapting) Frameworks & Drivers (UI, Web, DB, Devices, etc.) in the outside world and Use Cases in the inside world.

Controllers (input conversion)

Presenters (output conversion)

Gateways

Serialization conversion of battle data relay can be considered, but it will be omitted.

Application Business Rules (red part)

Use Cases

Enterprise Business Rules (yellow part)

Entities

A group of classes that have data such as location information, size information, and color information.

The class diagram of the breakout game is shown below. I tried to color-code the background according to the layer diagram of the clean architecture. image.png In Entity, I made a data output interface instead of getter. (Red interface in yellow) Furthermore, the bounced object is abstracted as Bounder, and the bounced object (ball) is abstracted as Boundee so that it can be processed independently of the concrete class.

The lower right part of the clean architecture layer diagram is as shown below. image.png

When applied to the class configuration diagram of the clean architecture, it is as shown in the figure below. The Input Data part is not due to the use of primitive types. image.png The Output Data part is a data storage of <DS>, and I think that it is supposed to be used to create and notify data every time. In the initial version, BreakoutViewData corresponding to Output Data was set as a class to have data, but I felt it was redundant, so I made it an interface to retrieve the data that BreakoutUseCase has. In addition, it is not just a getter, but primitive data is passed to the callback interface that passes the data so that the data cannot be changed.

Implementation

The splash screen, block arrangement and multiple ball arrangement are matched to the original work. My policy is to keep 25 lines or less per method so that you can see it on one screen of the terminal.

Source code

import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Random;
import java.util.function.Consumer;
import java.awt.Component;
import java.awt.CardLayout;
import java.awt.Graphics;
import java.awt.Font;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Rectangle;
import java.awt.event.MouseEvent;
import java.awt.event.MouseMotionListener;
import java.awt.event.MouseMotionAdapter;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.Timer;

public class Breakout extends JFrame {

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

    private final BreakoutGame game = new BreakoutGame();
    private final CardLayout card = new CardLayout(0, 0);
    private Timer timer;

    public Breakout() {
        setTitle("Breakout");
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        var pane = getContentPane();
        pane.setLayout(card);
        pane.add(new Splash(), "splash");
        pane.add(game.getView(), "game");
        pack();
    }

    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() {
        card.show(getContentPane(), "game");
        game.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("Breakout", 300, 200);
        g.drawString("It will start in 5 seconds!", 250, 300);
        g.drawString("Move the mouse sideways to bounce the ball", 125, 500);
        g.drawString("The tip of the arrow below is the initial position of the mouse", 100, 600);
        g.drawString("↓", 425, 700);
    }
}

class BreakoutGame {
    private final BreakoutViewModel viewModel = new BreakoutViewModel();
    private final BreakoutPresenter presenter = new BreakoutPresenter(viewModel);
    private final BreakoutUseCase uc = new BreakoutUseCase(presenter);
    private final BreakoutView view = new BreakoutView(viewModel, uc.WIDTH, uc.HEIGHT);
    private final BreakoutController controller = new BreakoutController(uc, view);

    public BreakoutGame() {
        presenter.addListener(viewModel -> view.repaint());
        presenter.addListener(viewModel -> {
            if (viewModel.isGameClear() || viewModel.isGameOver()) {
                stop();
            }
        });
    }

    public Component getView() {
        return view;
    }

    public void start() {
        controller.enable();
    }

    public void stop() {
        controller.disable();
    }
}

class BreakoutView extends JPanel {
    private final BreakoutViewModel model;

    public BreakoutView(BreakoutViewModel model, int width, int height) {
        this.model = model;
        setPreferredSize(new Dimension(width, height));
        setBackground(Color.black);
    }

    @Override
    public void paint(Graphics g) {
        super.paint(g);
        model.paint(g);
    }
}

class BreakoutViewModel {
    private BreakoutViewData data;

    public void update(BreakoutViewData data) {
        this.data = data;
    }

    public boolean isAvailable() {
        return data != null;
    }

    public boolean isGameClear() {
        return data.isGameClear();
    }

    public boolean isGameOver() {
        return data.isGameOver();
    }

    public void paint(Graphics g) {
        if (!isAvailable()) return;
        paintBalls(g);
        paintWalls(g);
        paintBlocks(g);
        paintRacket(g);
        if (isGameClear()) {
            paintGameClear(g);
        } else if (isGameOver()) {
            paintGameOver(g);
        }
    }

    public void paintWalls(Graphics g) {
        final int[] offset = {0, -16, -8};
        final int blockWidth = 26, blockHeight = 10, gapX = 6, gapY = 5;
        g.setColor(Color.GREEN);
        data.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);
                }
            }
        });
    }

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

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

    private void paintBalls(Graphics g) {
        g.setColor(Color.RED);
        data.viewBalls((x, y, size) -> g.fillOval(x, y, size, size));
    }

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

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

class BreakoutPresenter implements BreakoutViewer {
    private final BreakoutViewModel viewModel;
    private final List<Consumer<BreakoutViewModel>> listeners = new ArrayList<>();

    public BreakoutPresenter(BreakoutViewModel viewModel) {
        this.viewModel = viewModel;
    }

    public void addListener(Consumer<BreakoutViewModel> listener) {
        listeners.add(listener);
    }

    public void removeListener(Consumer<BreakoutViewModel> listener) {
        listeners.remove(listener);
    }

    @Override
    public void view(BreakoutViewData data) {
        viewModel.update(data);
        listeners.forEach(listener -> listener.accept(viewModel));
    }
}

class BreakoutController {
    public static final int MOVE_BALLS_INTERVAL_MILLISEC = 50;
    private final BreakoutOperation operation;
    private final Component mouseDevice;
    private final MouseMotionListener mouseController;
    private final Timer ballController;

    public BreakoutController(BreakoutOperation operation, Component mouseDevice) {
        this.operation = operation;
        this.mouseDevice = mouseDevice;
        mouseController = makeMouseController();
        ballController = makeBallController(MOVE_BALLS_INTERVAL_MILLISEC);
    }

    private MouseMotionListener makeMouseController() {
        return new MouseMotionAdapter() {
            @Override
            public void mouseMoved(MouseEvent event) {
                operation.moveRacket(event.getX());
            }
        };
    }

    private Timer makeBallController(int interval_millisec) {
        return new javax.swing.Timer(interval_millisec, e -> {
            operation.moveBalls();
        });
    }

    public void enable() {
        mouseDevice.addMouseMotionListener(mouseController);
        ballController.start();
    }

    public void disable() {
        mouseDevice.removeMouseMotionListener(mouseController);
        ballController.stop();
    }
}

interface BreakoutOperation {
    public void moveRacket(int x);
    public void moveBalls();
}

interface BreakoutViewer {
    public void view(BreakoutViewData data);
}

interface BreakoutViewData {
    public boolean isGameClear();
    public boolean isGameOver();
    public void viewWalls(WallViewer viewer);
    public void viewBlocks(BlockViewer viewer);
    public void viewRacket(RacketViewer viewer);
    public void viewBalls(BallViewer viewer);
}

class BreakoutUseCase implements BreakoutOperation, BreakoutViewData {
    public static final int WIDTH = 855;
    public static final int HEIGHT = 800;
    public static final int WALL_SIZE = 40;
    private final BreakoutViewer viewer;
    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 BreakoutUseCase(BreakoutViewer viewer) {
        this.viewer = viewer;
        court = new Court(WIDTH, HEIGHT, WALL_SIZE);
        racket = new Racket(WIDTH / 2, HEIGHT - 110, 120, 5, WALL_SIZE, WIDTH - WALL_SIZE);
        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);
    }

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

    @Override
    public void moveBalls() {
        balls.forEach(ball -> {
            ball.move(HEIGHT);
            court.rebound(ball);
            blocks.forEach(ball::bound);
            ball.bound(racket);
        });
        output();
    }

    private void output() {
        viewer.view(this);
    }

    @Override
    public boolean isGameClear() {
        return blocks.stream().allMatch(block -> block.isCleared());
    }

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

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

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

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

    @Override
    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 wallSize) {
        up = new Wall(0, 0, width, wallSize);
        left = new Wall(0, 0, wallSize, height);
        right = new Wall(width - wallSize, 0, wallSize, 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 isCleared() {
        return strength <= 0;
    }

    public void view(BlockViewer viewer) {
        if (isBroken()) return;
        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);
}

at the end

I made BreakoutViewData an interface, but I'm not sure if I can do it this way. If you have any advice on how to do this, please leave a comment.

Recommended Posts

Design and implement a breakout game with a clean architecture
Design and implement a breakout game with a clean architecture
A simple rock-paper-scissors game with JavaFX and SceneBuilder
[Book Review] Clean Architecture Software structure and design learned from masters
Consideration about Rails and Clean Architecture
Make a typing game with ruby
Draw a graph with Sinatra and Chartkick
A story about Apache Wicket and atomic design
Prepare a scraping environment with Docker and Java
Try drawing a cube with View and Layer