※ 一度記事を公開したのですが、設計を見直して再投稿しました。
2020.08.31更新 @turanukimaru さんのコメントを参考に仕様と実装を見直しました。
はじめに
クリーンアーキテクチャの勉強をしていまして、いろいろな設計・実装に挑戦しています。
題材に良さそうな記事があり、クリーンアーキテクチャを意識して再設計・再実装に挑戦してみました。
元記事のコメント欄に設計案と実装案をコメントしたのですが、更にブラッシュアップしましたので、設計の過程なども整理して記事にします。
題材: java簡素なブロック崩し作ってみた - Qiita
設計
クリーンアーキテクチャ
また、クリーンアーキテクチャではクラス構成図も示されています。
ブロック崩しゲーム
クラス抽出
ゲームプログラムのクラス抽出は、登場物をクラスにできるのでそんなに難しくないと思います。
- 登場物(Model): ゲームコート、壁、ブロック、ラケット、ボール
- 入力(Controller): マウス操作
- 出力(View): ゲーム画面、スプラッシュ画面
- 処理(Logic): メイン処理、ゲーム処理
クラス分割
クリーンアーキテクチャのレイヤーを意識しながらクラス分割しました。
Frameworks & Drivers(青色部分)
UI
- Breakout: ブロック崩しアプリケーション
- mainメソッドを持つクラス。
- 起動に必要なオブジェクトを準備して関係を構築し、動作開始します。
- BreakoutGame: ブロック崩しゲームUI
- ゲーム本体、コントローラ、ディスプレイを準備し、関係を構築します。
- Splash: スプラッシュ画面
- 起動時に短時間表示する説明画面
- GameView: ゲーム画面
- ブロック崩し以外のゲームでも使用できる画面
Devices
JFrame や JPanel が マウス装置、キーボード装置、ディスプレイ装置を兼ね備えています。
Web
Webブラウザでゲームできるようにすることも可能だとは思いますが、省きます。
DB
ゲームのハイスコアを記録することなどが考えられますが、省きます。
External Interfaces
遠隔地のユーザとネットワーク接続して対戦するなどが考えられますが、省きます。
Interface Adapters (緑色部分)
外界の Frameworks & Drivers (UI, Web, DB, Devices等) と 内界の Use Cases を接合(アダプト)するためのデータ変換アダプタ群です。
Controllers (入力変換)
- BreakoutController: マウス操作をラケット操作に変換
Presenters (出力変換)
- BreakoutPresenter: ゲーム出力データ変化を各処理に通知
- BreakoutViewModel: ゲーム出力データをグラフィク表示データに変換
- GameViewModel: GameViewで表示できる汎用的モデル
Gateways
対戦データ中継のシリアライズ変換などが考えられますが、省きます。
Application Business Rules (赤色部分)
Use Cases
クリーンアーキテクチャのクラス構成に合わせてクラス分割しました。
Input Data の部分はプリミティブ型にしたため、クラスはありません。
- BreakoutUseCase: ブロック崩しゲームユースケース本体
- BreakoutOperation: ゲーム操作インタフェース
- BreakoutViewer: ゲーム出力インタフェース
- BreakoutViewData: ゲーム出力データ
Enterprise Business Rules (黄色部分)
Entities
位置情報、サイズ情報、色情報などのデータを持つクラス群。
- BreakoutStage: ブロック崩しゲームの1面
- 壁、ラケット、ブロック、ボールを配置したゲームステージ
- 複数のステージを入れ替えることも可能ですが、本記事では1ステージだけ実装
- Court: ゲームコート
- 上と左と右に壁を持つ
- 下には壁がない
- Wall: 壁
- ボールを跳ね返す
- Block: ブロック
- ボールを跳ね返す
- ボールが当たっても壊れないブロック、ボールが1回当たると壊れるブロック、2回当たると壊れるブロック、3回当たると壊れるブロックがある
- Racket: ラケット
- 左右に移動できる。
- ボールを跳ね返す。
- Ball: ボール
- コートの中を飛び回る。
- コートの下側にはみ出るとボールを失う。
クラス図
ブロック崩しゲームのクラス図を以下に示します。
クリーンアーキテクチャのレイヤー図に合わせて背景を色分けしてみました。
Entityには、ゲッターの代わりとなるデータ出力インタフェースを作りました。(黄色の中の赤色インタフェース)
更に、跳ね返す物は Bounder、跳ね返される物(ボール)は Boundee として抽象化し、具象クラスに依存せずに処理できるようにしました。
クリーンアーキテクチャ―のレイヤー図の右下部分は下図のようになりました。
クリーンアーキテクチャのクラス構成図に当てはめると下図のようになりました。
Input Data の部分は、プリミティブ型を使ったためありません。
実装
スプラッシュ画面や、ブロック配置や複数ボール配置はオリジナル作品に合わせています。
BreakoutStageクラスを抽象クラスにして、BreakoutStage1クラスを第一ステージにしました。
端末1画面で見渡せるように1メソッド25行以内にするのが私のポリシーです。
ソースコード
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("ブロック崩し");
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("ブロック崩し", 300, 200);
g.drawString("五秒後に始まるよー!", 250, 300);
g.drawString("マウスを横に動かしてボールを跳ね返そう", 125, 500);
g.drawString("マウスは下の矢印の先っぽがが初期位置でつ", 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 GameView view = new GameView(viewModel, BreakoutStage.WIDTH, BreakoutStage.HEIGHT);
private final BreakoutController controller = new BreakoutController(uc, view);
public BreakoutGame() {
presenter.addListener(model -> view.repaint());
presenter.addListener(model -> {
if (viewModel.isGameClear() || viewModel.isGameOver()) {
stop();
}
});
}
public Component getView() {
return view;
}
public void start() {
controller.enable();
}
public void stop() {
controller.disable();
}
}
class GameView extends JPanel {
private final GameViewModel model;
public GameView(GameViewModel 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);
}
}
interface GameViewModel {
public void paint(Graphics g);
}
class BreakoutViewModel implements GameViewModel {
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();
}
@Override
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 {
protected final BreakoutViewer viewer;
protected final BreakoutViewData viewData = createViewData();
protected BreakoutStage stage = createStage();
public BreakoutUseCase(BreakoutViewer viewer) {
this.viewer = viewer;
}
public BreakoutStage createStage() {
return new BreakoutStage1();
}
public BreakoutViewData createViewData() {
return new BreakoutViewData() {
@Override
public boolean isGameClear() {
return stage.isClear();
}
@Override
public boolean isGameOver() {
return stage.isOver();
}
@Override
public void viewWalls(WallViewer viewer) {
stage.viewWalls(viewer);
}
@Override
public void viewBlocks(BlockViewer viewer) {
stage.viewBlocks(viewer);
}
@Override
public void viewRacket(RacketViewer viewer) {
stage.viewRacket(viewer);
}
@Override
public void viewBalls(BallViewer viewer) {
stage.viewBalls(viewer);
}
};
}
@Override
public void moveRacket(int x) {
stage.moveRacket(x);
output();
}
@Override
public void moveBalls() {
stage.moveBalls();
output();
}
public void output() {
viewer.view(viewData);
}
}
abstract class BreakoutStage {
public static final int WIDTH = 855;
public static final int HEIGHT = 800;
public static final int WALL_SIZE = 40;
protected final Random rand = new Random();
protected final Court court = makeCourt();
protected final Racket racket = makeRacket();
protected final List<Block> blocks = makeBlocks();
protected final List<Ball> balls = makeBalls();
protected Court makeCourt() {
return new Court(WIDTH, HEIGHT, WALL_SIZE);
}
abstract protected Racket makeRacket();
abstract protected List<Block> makeBlocks();
abstract protected List<Ball> makeBalls();
public void moveRacket(int x) {
racket.move(x);
}
public void moveBalls() {
balls.forEach(ball -> {
ball.move(HEIGHT);
court.rebound(ball);
blocks.forEach(ball::bound);
ball.bound(racket);
});
}
public boolean isClear() {
return blocks.stream().allMatch(block -> block.isCleared());
}
public boolean isOver() {
return balls.stream().allMatch(ball -> ball.isDead());
}
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 BreakoutStage1 extends BreakoutStage {
@Override
protected Racket makeRacket() {
return new Racket(WIDTH / 2, HEIGHT - 110, 120, 5, WALL_SIZE, WIDTH - WALL_SIZE);
}
@Override
protected 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;
}
protected 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));
}
}
}
@Override
protected List<Ball> makeBalls() {
return Arrays.asList(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),
});
}
protected Ball makeBall(int y, int vx, int vy, int size) {
return new Ball(40 + rand.nextInt(700), y, vx, vy, size);
}
}
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 Racket extends Bounder {
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 {
}
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);
}
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);
}
シーケンス図
ラケット操作シーケンス図
コメントでリクエストがありましたので、ラケット操作のシーケンス図を以下に示します。
ラケット以外の描画処理は省いています。
さいごに
こうした方がいいよとかアドバイスがありましたら、是非コメントをお願いします。