Skip to content

ミニゲームの作り方

つまみ edited this page Jan 22, 2021 · 8 revisions

ミニゲームを作る方法をRaceGame(レースはしない)を例に簡単に説明します。

必要なファイル

必要なファイルは次の通りです。

  • symbols (オブジェクトをしまう場所)
    • Car.java
    • Background.java
  • RaceGameController.java (コントローラ)
  • RaceGameModel.java (モデル)
  • RaceGameScene.java (シーン)
  • RaceGameView.java (ビュー)

これらを組み合わせることでゲームを作ることができます。

Model

ここではゲームに使うオブジェクトを管理します。このゲームでは車オブジェクトと背景オブジェクトを作っています。

public class RaceGameModel extends GameModel {

    private Car car;
    private Background background;

    public RaceGameModel() {
        car = new Car();
        background = new Background();

    }

    public Car getCar() { return car; }

    public Background getBackground() { return background; }

    // 略
}

こんな感じで書ければ大丈夫です。

オブジェクト

車オブジェクトを作ってみましょう。このゲーム全体ではオブジェクトのことを Symbol と言います。(別に深い意味はないが……) 動くシンボルなので extends MovableSymbol したクラス Car を作ります。

public class Car extends MovableSymbol {
    private Image image;

    final private int MAX_SPEED = 500;
    private int nowspeed = 0;

    // 速度調整用タイマー
    private final Timer speedTimer   = new Timer(10, e -> {
        if(nowspeed > 0) accelerate(-8);
        if(nowspeed < 0) accelerate(8);
        setSpeedPxPerSecond(nowspeed); // MovableSymbolのAPI
        moveMilliseconds(10); // MovableSymbolのAPI
    });


    public Car() {

        // 初期位置の設定 (SymbolのAPI)
        setLocation(100, 100);

        // 速度の設定 (MovableSymbolのAPI)
        setSpeedPxPerSecond(nowspeed);

        // 当たり判定の設定
        var hitbox = RelativeHitBox.makeRectangle(80, 40);
        this.setRelativeHitBox(hitbox);
    }

    /**
     * 加速します
     * @param a 加速度
     */
    public void accelerate(int a) {
        if(a > 0) {
            nowspeed = Math.min( MAX_SPEED, nowspeed + a);
        } else {
            nowspeed = Math.max(-MAX_SPEED, nowspeed + a);
        }
    }
}

ここでオブジェクトに関する設定を色々すれば良いです。Symbolの関係のAPIについてはここを読めば良いです。

タイマーを使ったので、終了時用に停止処理を書く必要があります。また、再開時用に再開処理も書くと良いでしょう。 SymbolMovableSymbol にはこれをサポートするメソッドがないので、Suspendable を実装します。

public class Car extends MovableSymbol implements Suspendable {

    // 略
    
    @Override
    public void suspend() {
        speedTimer.stop();
    }

    @Override
    public void resume() {
        speedTimer.start();
    }
}

これで再開/停止処理ができました。次にSymbolなので描画ができないと困ります。描画にはSymbolのAPIの setDrawer(Drawable) を使います。Drawableは描画用のインタフェースです。

public interface Drawable {
    void draw(Graphics2D g);
}

ラムダ式を使って setDrawer(g -> g.drawImage(...)) としても良いのですが、ここではDrawableを実装して setDrawer(this) する方法を考えます。

public class Car extends MovableSymbol implements Drawable, Suspendable {
    private Image image;
    
    // 略
    
    public Car() {
        // 略
        
        // 画像の設定
        Path imagePath = Paths.get(".", "resource", "mini_game", "race_game", "car.png");
        image = Toolkit.getDefaultToolkit().getImage(String.valueOf(imagePath));

        // Drawableを乗せる
        setDrawer(this);
    }

    @Override
    public void draw(Graphics2D g) {
        Rectangle rect = new Rectangle((int)getX(), (int)getY(), 0, 0);
        rect.grow(40, 20);

        // 回転して画像をdraw
        double angle = this.getAngleRadians();
        g.rotate(angle, (int)getX(), (int)getY());
        g.drawImage(image, (int)rect.getX(),     (int)rect.getY(),
                           (int)rect.getWidth(), (int)rect.getHeight(), null);
        // 回転を戻さないと後の描画が狂う
        g.rotate(-angle, (int)getX(), (int)getY());
    }
}

画像は resource/mini_game/race_game 配下に置きます。また、取得は Paths.get して得たPathを Toolkit#getImage で取得してください。Paths.getしないと、ファイルパスが違うOSでファイルを読み込めなくなってしまいます。

今回は行っていませんが、画面サイズの取得は MainView.getInstance().getWidth()MainView.getInstance().getHeight() で行うことができます。

当たり判定

このゲームでは当たり判定が実装されていませんが、ここを読むと理解できるでしょう。(理解できるのか?)

理解できないと思うのでざっくりまとめると

  • Symbolの座標を中心の長方形状の当たり判定を作りたい!
    • RelativeHitBox.makeRectangle(int 幅, int 高さ)
  • Symbolの座標を中心の円形の当たり判定を作りたい!
    • RelativeHitBox.makeRectangle(int 半径)
  • 凝った形の当たり判定を作りたい!
    • new RelativeHitBox(Shape 形状)

を使って RelativeHitBox インスタンスを作り、Symbolに登録 (Symbol#setRelativeHitBox(RelativeHitBox)) すると当たり判定の枠を作ることができます。

判定には Symbol#touches(Symbol) を使います。touches の実装は次の通りです。

public boolean touches(Symbol other) {
    Area myHitBox    = this .getRelativeHitBox().createAbsoluteHitBoxArea(this);
    Area otherHitBox = other.getRelativeHitBox().createAbsoluteHitBoxArea(other);
    myHitBox.intersect(otherHitBox);
    return !myHitBox.isEmpty();
}

このように相対的な位置で記録する RelativeHitBoxSymbol を渡して、絶対的な位置の図形オブジェクトを取得、それが交差しているかどうかで判定しています。ですから、完全に包含しているなどの細かい当たり判定の条件を作る場合はこのように RelativeHitBox#createAbsoluteHitBoxArea を使えば良いです。

あくまで RelativeHitBox は相対的な位置で記録するだけで、これ自体に判定するメソッドが用意されていないことに気をつけてください。

Model (2)

CarSuspendable を実装しましたが、これでは不十分です。モデルから呼び出す必要があります。モデルの最後に次のコードを付け加えましょう。

@Override
public void suspend() {
    car.suspend();
}

@Override
public void resume() {
    car.resume();
}

View

次にViewを作ります。Viewなのでまず GameView を継承します。あとはコードの通りです。

public class RaceGameView extends GameView {

    private RaceGameModel model;
    private Timer paintTimer = new Timer(10, g -> repaint());

    public RaceGameView(RaceGameModel model) {
        super(model);
        this.model = model;
        setBackground(new Color(0x111111));
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        // 背景のトラックを描画
        model.getBackground().getDrawer().draw((Graphics2D) g);
        // 車を描画
        model.getCar().getDrawer().draw((Graphics2D) g);
    }

    // paint用のタイマーを制御
    @Override
    public void suspend() { paintTimer.stop(); }
    @Override
    public void resume() { paintTimer.start(); }
}

ViewはModelから情報をもらって画面に出力するくらいが書ければOKです。paint用のタイマーをstop/startするのを忘れないようにしましょう。

Controller

public class RaceGameController extends GameController implements KeyListener {

    private RaceGameModel model;
    private RaceGameView  view;
    private boolean active = false;

    public RaceGameController(RaceGameModel model, RaceGameView view) {
        super(model, view);
        this.model = model;
        this.view = view;

        // view に listener を add する
        view.addKeyListener(this);
    }

    // 操作用タイマー
    private final Timer forwardTimer = new Timer(10, e -> model.getCar().accelerate(10));

    @Override
    public void keyTyped(KeyEvent e) {}

    @Override
    public void keyPressed(KeyEvent e) {
        if(!active) return;
        switch(e.getKeyChar()) {
            case 'w' -> forwardTimer.start();
        }
    }

    @Override
    public void keyReleased(KeyEvent e) {
        if(!active) return;
        switch(e.getKeyChar()) {
            case 'w' -> forwardTimer.stop();
        }
    }

    // この2つでタイマーの制御をする
    @Override
    public void suspend() {
        active = false;
        forwardTimer.stop();
    }
    @Override
    public void resume() { active = true; }
}

こうです。(雑) 無駄にコードが長いだけでしてることは大したことないです。KeyListener とか MouseListener とかを実装して、Viewに貼り付けてあげます。

押している間動かし続ける処理にはタイマーを使うことに気をつけてください。keyPressed()にそのまま書くとうまく動きません。詳細はここを読んでください。

Scene

授業でやったMVCパターンと決定的に異なるのがこの「Scene」です。なぜこれが用意されているのかというと「ミニゲームが集まったゲームだからゲームのまとまったオブジェクトが欲しいよね!」という理由です。理由からわかる通り、Sceneでは、model, view, controllerを全て管理します。それだけです。 !追記!makeDescriptionDialog()をしないと画面が出てきません

public class RaceGameScene extends MiniGameScene {

    private RaceGameModel model;
    private RaceGameView  view;

    public RaceGameScene() {
        model = new RaceGameModel();
        view  = new RaceGameView(model);
        setModel(model);
        setView(view);
        setController(new RaceGameController(model, view));

        setTitle("Sample Race Game");
        setCreatorName("TrpFrog");
        setDescription("ようこそつまみ自動車学校へ!");
        setHowToPlay("まずキーボードを使います");
        
        makeDescriptionDialog();
    }

    // テスト用にこういうのがあると便利
    public static void main(String[] args) {
        SceneManager.getInstance().push(new RaceGameScene());
    }
}

本当にこれ以外書きません。mainメソッドがうまく動けば完成です!

その他

ポーズ画面を作りたい!

net.trpfrog.medipro_game.pause.EscapeToPause をnewして addKeyListener するとESCキーでポーズできるようになります。簡単!

ラムダ式ってなんだよ?

1つしか関数がないインタフェースを実装したクラスを即席で作るための文法です。例えばTimerクラスは引数に ActionListener を要求してくるので

class LambdaExample implements ActionListener {
    private Timer timer;
    
    public LambdaExample() {
        timer = new Timer(10, this);
        timer.start();
    }
    
    @Override
    public void actionPerformed(ActionEvent e) {
        System.out.println("fired");
    }
}

する必要がありますが、ラムダ式を使うと

class LambdaExample {
    private Timer timer;
    
    public LambdaExample() {
        timer = new Timer(10, e -> System.out.println("fired!"));
        timer.start();
    }
}

と書くことができます。もちろんこのゲーム用につまみさんが作ったインタフェースの Drawable に対しても使うことができます。

class LambdaExample extends Symbol {
    public LambdaExample() {
        this.setDrawer(g -> {
            g.setColor(new Color(0x90e200));
            g.fillOval(0, 0, 100, 100);
            g.setColor(Color.ORANGE);
            g.fillRect(0, 50, 100, 75);
        }
    }
}

2行以上の場合は波かっこを使えば良いです。詳しくは調べてみてください。

GIFアニメが使いたい!

net.trpfrog.medipro-game.util.GifConverter を使うとGIFアニメを List<Image> に変換することができます。

これで出来上がったリストを ImageAnimationSymbol に渡して、setFps(int) して、 start() するとアニメートされます。もちろん paintComponent から draw(Graphics2D g) を呼び出してあげる必要があります。詳しくはExplosionAnimation.javaImageAnimationSymbolのJavadocを読むと良いです。

Graphicsで画像を半透明に描画したい!

こうして

public void draw(Graphics2D g) {
    float alpha = 0.5f
    g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha));
    
    g.drawImage(...);
    g.drawImage(...);

    // 終わったら戻す
    g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f));
}