Flutter Flameでゲームを作るチュートリアル

掲載日
更新日

はじめに

まず最初に、Flutterとは以下の通りです。

Flutter は、1 つのコードベースからモバイル、ウェブ、デスクトップのアプリケーションを作成できる Google の UI ツールキットです。

初めての Flutter アプリより引用

ざっくり一個のプログラミング言語でAndroid、iOS、デスクトップアプリ、Webアプリをいい感じに作れる素敵な言語です。(なお、QRコードの読み取り等やろうとするとOS毎の設定や、そもそも対応できない等もあります。)

そんなFlutterにはFlameというフレームワークがあり、以下の通りです。

Flame は Flutter ベースの 2D ゲームエンジンです。

FlutterでFlameの概要のより引用

ざっくりFlutterでいい感じに2Dゲームを作れるようにできるフレームワークです。

自分は昔からゲーム大好きユーザーでいつか作る側もやりたいと思っていたので、Flameを使ってゲームを作っていきたいと思います。

とはいえ、いきなり作りたいゲームを作ろうと思っても途方に暮れるので、まずはGoogleが用意してくれているチュートリアルを使って勉強して、そこを改造するところから始めます。

チュートリアルは脳死でコピペしていくだけでもできてしまうのですが、ちゃんと読むとなかなかにしんどいので頑張って解読した内容を備忘録として残しておきます。

(実際のチュートリアルを進めるとわかりますが、いきなり各オブジェクトの完成形を作るわけではなく、動く単位で少しずつコードを追加しています。自分のメモも都度追加した内容をコメントしているので前後関係が滅茶苦茶なメモがあります。)

チュートリアルに沿ってゲームを作成する。

早速ゲームを作っていきます。
チュートリアルで作成できるゲームは、ゲーム好きなら一度は見たことがありそうな「Breakout 」風のゲームです。
自分は正式な名称を初めて知りました。ざっくりいうと上の方にブロック、下に左右に操作できるバーがあり、ボールをバーを使って跳ね返してすべてのブロックを破壊するゲームです。
(チュートリアルではそれぞれブロックをレンガ、バーをバット、ボールをボールと表現しています。というかそっちが正式名称なんでしょうか。)

前提

  • Flutterは一通り触ったことがある。
  • チュートリアルの「3.プロジェクトを作成する」までは作業完了してる。

プレイエリアを作成する。

プレイエリア(赤枠)の場所を示す画像

まずはプレイエリアを作成します。(添付赤枠部分)
以下のコードですが早速HasGameReferenceとか知らないクラスが説明なしに出てきてしんどいです。
でも衝突判定(当たり判定)も出てきてゲームっぽくてテンションも上がります。
頑張ってコメントで注釈を入れながら進めます。

import 'dart:async';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  // extends RectangleComponent: 四角形を描画するコンポーネントRectangleComponentを継承している
  // with:mixinのことらしい。phper的にはphpのtraitみたいなものと理解。(HasGameReferenceで実装したメソッドを読み込んでこのクラス内で実行できる、という感じだと思います。)
  // HasGameReference: FlameGameにアクセスするためのコンポーネントらしいがFlameGameとは。
  // FlameGame: コンポーネントのツリーを持っていて、ゲームに追加されたすべてのコンポーネントにアクセスできるっぽい?そのアクセスや操作をやりやすくするのがHasGameReferenceという感じだろうか。

  PlayArea()
    : super(
        paint: Paint()..color = const Color(0xfff2e8cf),
        // Paint:「[Canvas] に描画するときに使用するスタイル。」らしいがCanvasとは。指定の色が付いているので、プレイエリアをCanvasとしてスタイルを指定できるという意味と理解。
        children: [RectangleHitbox()], // コンポーネントのサイズと一致する衝突検出用ヒットボックスが作成される。作成した画面の外周にぶつかると衝突検知して何かしら出来るということっぽい。(当たり判定のことだと思います。テンション上がる。)
      );

  @override
  FutureOr<void> onLoad() async { // 非同期処理をいい感じにやってくれるらしいFutureOrという関数をoverride
    super.onLoad(); // ゲーム開始時に実行したい処理を実行できるらしい
    size = Vector2(game.width, game.height); // 2D列ベクター。とりあえずここで画面の幅と高さをしていているので、ここで画面を作っていると思う。
  }
}

バット(バー)を追加

バット(赤枠)の場所を示す画像

ボールを打ち返すバット(添付赤枠部分)を追加します。
ここでユーザーが操作できるオブジェクトを追加できるコンポーネントPositionComponentが出てきます。

今は左右移動だけですが、上下移動を加えれば色々なゲームに応用が出来そうです。

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

// ユーザーが操作するバットのクラス
class Bat extends PositionComponent
    with DragCallbacks, HasGameReference<BrickBreaker> {
  // PositionComponent: 画面上で自由に移動、回転、拡大縮小できるオブジェクトのコンポーネント。レンダリングをする必要がある。
  // DragCallbacks: バットなのでドラッグで動かすからそれ関係のmixinと思われる。

  Bat({
    required this.cornerRadius,
    required super.position,
    required super.size,
  }) : super(anchor: Anchor.center, children: [RectangleHitbox()]);
  // 四角形の衝突判定を作成

  final Radius cornerRadius;

  final _paint = Paint()
    ..color = const Color(0xff1e6091)
    ..style = PaintingStyle.fill;

  // playareaやballと違ってレンダリングを自分でやる必要がある
  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRRect(
      RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
      _paint,
    );
    // バットを描画
  }

  // ドラッグでバットを移動させる(x軸のみ)
  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    position.x = (position.x + event.localDelta.x).clamp(0, game.width);
  }

  // キーボード操作でバットを移動させる。
  void moveBy(double dx) {
    add(
      MoveToEffect(
        Vector2((position.x + dx).clamp(0, game.width), position.y),
        EffectController(duration: 0.1),
      ),
    );
  }
}

レンガ(ブロック)を追加

レンガ(赤枠)の場所を示す画像

ボールに触れると破壊されるレンガを追加します。
当たり判定があった際に処理を行う「onCollisionStart」メソッドが出てきます。
レンガは基本的に当たり判定有=消滅するだけなので、特に当たった対象について判定せず消滅とスコア追加のみ行います。

レンガはステージに合わせて配置が必要ですが、これはあくまでレンガ一個のオブジェクトなので配置は別のクラスで行います。

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

// レンガクラス
class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

  // ボール衝突時の処理
  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent(); // レンガを消す
    game.score.value++; // スコア追加

    // 残っているレンガが一つならボールやバットも消して勝利
    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

ボール追加

ボール(赤枠)の場所を示す画像

バットやレンガに当たると反射するボールを追加します。
当たり判定があったときに、「PositionComponent is XX」とすることでどのオブジェクトと衝突したか判定できるので、
バットにぶつかったら反射、壁(プレイエリア)にぶつかった場合はぶつかった位置によって反射(下の場合はゲームオーバー)、レンガと衝突時はぶつかった方向に合わせて反射、のように実装が出来ます。

他にも、ボールはフレームごとに移動し続けるので、「update」メソッドで常に位置を更新し続けさせられることも分かりました。

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';

// ボールのコンポーネント
class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  // CircleComponent: 円を描画するコンポーネント
  // CollisionCallbacks: 衝突判定に使うらしい。レンガやバットとの衝突に使うっぽい
  Ball({
    required this.velocity, // ボールの速度
    required super.position,
    required double radius,
    required this.difficultyModifier,
  }) : super(
         radius: radius, // border radiusみたいなやつ
         anchor: Anchor.center, // 相対位置
         paint: Paint()
           ..color =
               const Color(0xff1e6091) // ボールの色
           ..style = PaintingStyle.fill, // 塗りつぶし指定とか
         children: [CircleHitbox()], // 円形のヒットボックスを作成できる
       );

  final Vector2 velocity;
  final double difficultyModifier;

  // ボールの位置を更新する
  @override
  void update(double dt) {
    // dt: 前のフレームと今のフレームの時間差
    super.update(dt);
    position += velocity * dt;
  }

  // 衝突を検出した際の動作
  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      // プレイエリアと衝突したら
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        // 画面外に移動したら
        add(
          RemoveEffect(
            delay: 0.35,
            onComplete: () {
              game.playState = PlayState.gameOver;
              // ゲームオーバー
            },
          ),
        );
      }
    } else if (other is Bat) {
      // バットと衝突したら
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {
      // レンガと衝突時ボールを反射する
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);
    }
  }
}

ゲームのアクションを調整する

必要なオブジェクトが用意できたので、全体を調整していきます。
必要な変数の宣言や、world.addを使ってオブジェクトを追加(表示)出来ることが分かります。

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }

// ゲームのアクションを調整するクラス
class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {
  // HasCollisionDetection: 物理演算を使わない当たり判定をすることできるmixin
  //  これを追加するとヒットボックスが追加され、衝突コールバックがトリガーされるらしい。(衝突を検知して何かできるっぽい。)
  // KeyboardEvents: キーボード関係
  // TapDetector: タッチイベントを検出するのに使う
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
        // CameraComponent: 「Worldを観察するためのコンポーネント」
らしいがWorldとは?
        // World: 「すべてのゲームワールド要素のルートコンポーネント」??
        // 推測だけどワールドを作って、それをどう画面に映すかを制御するのがCameraComponent
?ということだということにする
      );

  final ValueNotifier<int> score = ValueNotifier(0); // スコア管理
  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  // プレイヤーの状態管理パラメータ
  late PlayState _playState;
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
        // 開始・ゲームオーバー・勝利時はオーバーレイを表示する
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
        
// プレイ中はオーバーレイ削除
    }
  }

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;
    // Anchor: 2Dオブジェクト内の相対位置

    world.add(PlayArea());
    // ワールドにプレイエリアを追加する

    playState = PlayState.welcome;
    // プレイヤーの状態をwelcomにする
  }

  // ゲームが開始したら
  void startGame() {
    if (playState == PlayState.playing) return;
    // プレイヤーの状態がプレイ中の間は処理しない

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());
    // ワールドからボール、バット、レンガ削除

    score.value = 0;
    // スコアを0点にする
    playState = PlayState.playing;
    // プレイヤーの状態をプレイ中に変更

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );
    // ワールドにボールを追加する

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    ); // ワールドにバットを追加する

    world.addAll([
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
    // ワールドにレンガを追加する

    // debugMode = true;
    // デバッグモードフラグ。コメントアウトするとオブジェクトのxy値とか見れる。
  }

  // タップイベント
  @override
  void onTap() {
    super.onTap();
    startGame();
    // タップでゲーム開始
  }

  // キーボードイベント
  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft: // ←キーを押したらバットが左に移動
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight: // →キーを押したらバットが右に移動
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:
      case LogicalKeyboardKey.enter:
        // スペースキー、エンターキーでゲーム開始
        startGame();
    }
    return KeyEventResult.handled;
  }

  // 背景色
  @override
  Color backgroundColor() => const Color(0xfff2e8cf);
}

その他の調整して起動

他にもオーバーレイを追加したり、OS毎の機能の調整をしていきます。オーバーレイの中身はflameというよりはflutterの機能で行われていて、flutterやってる方はわかると思うので割愛。
開始前・勝利・ゲームオーバー画面の作り方のページや、スコア記録の作り方のページのコピペです。)

諸々調整してデバッグを行うとチュートリアルのゲームが起動できます。

機能を追加

折角なので応用して機能を追加してみます。
このゲームでよくあるパターンとして、特定のレンガを破壊すると強化アイテムが落下し、それにバットが振れるとボールが分裂して二つになります。

そこで、以下の改造を行ってみます。

  • 特殊なブロッククラス「buff_brick」を作成して、ブロックに紛れて表示される。
  • 強化アイテムクラス「buff_item」を作成する。
  • ボールが「buff_brick」に当たると、「buff_item」がworldに追加され、ブロック位置から落下を始める。
    • 「buff_item」がバットに当たると、ボールが1つ追加される。
    • バットに当たらず一番下まで落下すると削除される。
  • ボールが複数個ある場合、最後の一つが消えるまではゲームオーバーにはならない。

強化アイテムbuff_itemを追加

以下のクラスを追加します。
見やすいように赤色の四角いアイテムで表示。落ちていくだけなので、velocityはyだけ設定してxには触れません。
バットとの衝突を検知したらボールを追加するだけ。

import 'dart:math' as math;
import 'package:brick_breaker/src/components/components.dart';
import 'package:brick_breaker/src/config.dart';
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

// 強化アイテムコンポーネント
class BuffItem extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  BuffItem({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         size: Vector2(brickWidth / 2, brickHeight / 2),
         anchor: Anchor.center,
         paint: Paint()
           ..color =
               const Color.fromARGB(255, 217, 0, 0) // ボールの色
           ..style = PaintingStyle.fill,
         children: [RectangleHitbox()],
       );

  final Vector2 velocity;
  final rand = math.Random();

  // アイテムの位置を更新する
  @override
  void update(double dt) {
    // dt: 前のフレームと今のフレームの時間差
    super.update(dt);
    position.y += velocity.y * dt;
  }

  // 衝突を検出した際の動作
  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      // プレイエリアと衝突したら消える
      removeFromParent(); // レンガを消す
    } else if (other is Bat) {
      removeFromParent(); // 触れたら消える
      // バットと衝突したら、ボールがworldに追加される
      game.world.add(
        Ball(
          difficultyModifier: difficultyModifier,
          radius: ballRadius,
          position: game.size / 2,
          velocity: Vector2(
            (rand.nextDouble() - 0.5) * game.width,
            game.height * 0.2,
          ).normalized()..scale(game.height / 4),
        ),
      );
    }
  }
}

特殊なレンガbuff_brickを追加

以下のクラスを追加します。brickを継承した方がいいでしょうが、今回は写経を重ねたいので別クラスに作ってます。当たり判定時に自分と同じ位置から落下を開始するbuff_itemを追加するのと、ゲーム終了時にbuff_itemをworldから削除するだけで他はbrickと同じです。

import 'dart:math' as math;

import 'package:brick_breaker/src/components/buff_item.dart';
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

// レンガクラス
class BuffBrick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  BuffBrick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

  final rand = math.Random();

  // ボール衝突時の処理
  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent(); // レンガを消す
    game.score.value++; // スコア追加

    // buff_itemをworldに追加
    game.world.add(
      BuffItem(
        velocity: Vector2(0, gameHeight * 0.2),
        radius: ballRadius,
        position: super.position, // 自分と同じ場所からアイテムが落下する
      ),
    );

    // 残っているレンガが一つならボールやバット、強化アイテムを消して勝利
    if (game.world.children.query<BuffBrick>().length == 1) {
      game.playState = PlayState.won;
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
      game.world.removeAll(game.world.children.query<BuffItem>());
    }
  }
}

既存クラス調整

追加したオブジェクトを使えるよう、他のクラスも調整していきます。

まず、テストのためにレンガを追加しているbrick_braker.dartでBrick→BuffBrickに変更。

 // ... 
          BuffBrick(
 // ここ変えるだけ
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ), 
 // ...

また、ボールが複数個ある場合にボールが画面外に移動してもゲームオーバーにならないようにball.dartを修正します。

// ...
      } else if (intersectionPoints.first.y >= game.height) {
        // 最後の一つが消えたらゲームオーバー
        if (game.world.children.query<Ball>().length == 1) {
          add(
            RemoveEffect(
              delay: 0.35,
              onComplete: () {
                game.playState = PlayState.gameOver;
                // ゲームオーバー
              },
            ),
          );
        } else {
          removeFromParent();
        }
// ...

動作確認

これで修正が出来たので、起動して動作確認します。

応用機能を追加したゲーム画面のGIF動画

ボールがレンガに触れると赤い四角形の強化アイテムが落下して、バットで触れることでボールが追加されることが確認できました。

おわりに

動作確認のためにすべてのレンガを強化アイテムが出現するものにしてますが、レンガの位置を通常と特殊を混ぜてランダム関数を使ってばらけさせたり、アイテムの種類を変える(今存在するボールを二倍にするというのもこのゲームではよく見ますね)等他にも応用して機能を追加することでちゃんとしたゲームを、そこまで専門的知識がない人でも作れそうです。

今後更なる応用でバットに上下移動を追加してシューティングゲームに変更するなど、いろんなゲームを作っていきたいです。

記事の作成者のA.W.のアイコン

この記事を書いた人

A.W.
茨城県在住Webエンジニアです。 PHPなどを業務で使用しています。 趣味ではGoやNuxt、Flutterをやってます。

Comment