Flutter と Flame でゲームを作成する

1. はじめに

Flutter と Flame でプラットフォーム ゲームを作成する方法を学習します。Doodle Jump から着想を得た Doodle Dash ゲームでは、Dash(Flutter のマスコット)またはその親友 Sparky(Firebase のマスコット)となってプレイし、プラットフォーム(足場)の間を跳び移りながら、できるだけ高い位置に到達することを目指します。

学習内容

  • Flutter でクロス プラットフォーム ゲームを作成する方法
  • Flame ゲームループの一環としてレンダリングと更新ができる再利用可能なゲーム コンポーネントを作成する方法
  • キャラクター(スプライト)の動きを制御し、ゲーム物理を通じてアニメーションを付ける方法
  • 衝突検出を追加して管理する方法
  • ゲームにコントロールとしてキーボードとタップ入力を追加する方法

前提条件

この Codelab は、Flutter の使用経験があることを前提としています。それがない場合、初めての Flutter アプリの Codelab で基本を学習できます。

作成するアプリの概要

この Codelab では、Dash(Flutter のマスコット)または Sparky(Firebase のマスコット)を主人公とした、Doodle Dash というプラットフォーム ゲームの作成過程を説明します(この Codelab の以降のコードでは Dash を参照していますが、Sparky にも当てはまります)。作成するゲームには次の特徴があります。

  • 上下左右に移動できるスプライト
  • ランダムに生成されるプラットフォーム
  • スプライトを引き寄せる重力効果
  • ゲームメニュー
  • 一時停止やリプレイなどのゲーム内コントロール
  • スコアの保存機能

ゲームプレイ

Doodle Dash のプレイでは、ゲームを通じて、Dash を左右に動かし、プラットフォームの間を跳び移りながら、パワーアップを使用して能力を高めます。ゲームを開始するには、初期難易度(1 から 5)を選び、[Start] をクリックします。

d1e75aa0e05c526.gif

レベル

レベルは 5 段階あります。(レベル 1 から)レベルが上がるごとに新しい機能が解放されます。

  • レベル 1(デフォルト): このレベルでは、NormalPlatform プラットフォームと SpringBoard プラットフォームが発生します。発生したプラットフォームは、20% の確率で移動するプラットフォームになります。
  • レベル 2(スコア >= 20): 1 回だけジャンプできる BrokenPlatform を追加します。
  • レベル 3(スコア >= 40): NooglerHat パワーアップを解放します。この特別なプラットフォームは 5 秒間有効で、Dash のジャンプ力を通常の速度の 2.5 倍にします。また、その 5 秒間はクールな Noogler ハットを被っています。
  • レベル 4(スコア >= 80): Rocket パワーアップを解放します。宇宙船の姿をした特別なプラットフォームであり、Dash を無敵にします。また、Dash のジャンプ力を通常の 3.5 倍の速度にします。
  • レベル 5(スコア >= 100): Enemy プラットフォームを解放します。Dash が敵に衝突すると、自動的にゲームオーバーになります。

レベルごとのプラットフォーム タイプ

レベル 1(デフォルト)

NormalPlatform

SpringBoard

レベル 2(スコア >= 20)

レベル 3(スコア >= 40)

レベル 4(スコア >= 80)

レベル 5(スコア >= 100)

BrokenPlatform

NooglerHat

Rocket

Enemy

ゲームオーバー

次の場合にゲームオーバーとなります。

  • Dash が画面下に落ちた場合
  • Dash が敵に衝突した場合(敵はレベル 5 で発生します)

パワーアップ

パワーアップにより、ジャンプ力の強化、無敵化、またはその両方など、キャラクターのプレイ能力が強化されます。Doodle Dash には、2 つのパワーアップがあります。同時にアクティブできるパワーアップは、1 つだけです。

  • Noogler ハットのパワーアップは、Dash のジャンプ力を通常の 2.5 倍の高さにします。さらに、パワーアップ中は Noogler ハットを被ります。
  • 宇宙船のパワーアップは、Dash を敵プラットフォームに対して無敵にし(敵に衝突してもダメージを受けない)、ジャンプ力を通常の 3.5 倍の高さにします。宇宙船で飛び、速度が重力に負けるとプラットフォームに着地します。

2. Codelab のスターター コードを取得する

a3c16fc17be25f6c.pngGitHub からプロジェクトの初期バージョンをダウンロードする:

  1. コマンドラインから、GitHub リポジトリのクローンを flutter-codelabs ディレクトリに作成する:
git clone https://github.com/flutter/codelabs.git flutter-codelabs

この Codelab のコードは flutter-codelabs/flame-building-doodle-dash ディレクトリにあります。このディレクトリには、Codelab の各ステップの完成したプロジェクト コードが含まれています。

a3c16fc17be25f6c.pngスターター アプリをインポートする

  • お好みの IDE に flutter-codelabs/flame-building-doodle-dash/step_02 ディレクトリをインポートします。

a3c16fc17be25f6c.pngパッケージをインストールする

  • Flame などのすべての必要なパッケージは、すでにプロジェクトの pubspec.yaml ファイルに追加されています。IDE が依存関係を自動的にインストールしない場合は、コマンドライン ターミナルを開き、Flutter プロジェクトのルートから次のコマンドを実行して、プロジェクトの依存関係を取得します。
flutter pub get

Flutter の開発環境をセットアップする

この Codelab を完了するには、以下が必要です。

3. コードについて

では、コードの内容について見てみましょう。

FlameGame を拡張する DoodleDash ゲームを含んだ lib/game/doodle_dash.dart ファイルを取得します。コンポーネントを、Flame の最も基本的なコンポーネント(Flutter の Scaffold と同様)である FlameGame のインスタンスに登録します。このインスタンスが、それに登録されたコンポーネントのすべてをゲームプレイ中にレンダリングし、更新します。これをゲームの中枢神経だと考えてください。

コンポーネントとは何でしょうか?Flutter アプリが Widgets から作られているのと同様に、FlameGame は、ゲームを作り出すすべての構成要素である Components から作られています(コンポーネントも、Flutter ウィジェットと同じように、子コンポーネントを持つことができます)。キャラクターのスプライト、ゲーム背景、新しいゲーム コンポーネント(敵など)を生成するオブジェクトは、どれもコンポーネントです。FlameGame 自体Component であり、これを Flame では Flame コンポーネント システムと呼んでいます。

コンポーネントは Component 抽象クラスを継承します。Component 抽象メソッドを実装して、FlameGame クラスの仕組みを作ります。たとえば、DoodleDash では、次のメソッドがよく実装されています。

  • onLoad: 非同期にコンポーネントを初期化します(Flutter の initState メソッドと同様)
  • update: ゲームループの各ティックでコンポーネントを更新します(Flutter の build メソッドと同様)

また、add メソッドはコンポーネントを Flame エンジンに登録します。

たとえば、lib/game/world.dart ファイルには、ParallaxComponent を拡張してゲーム背景をレンダリングする World クラスが含まれています。このクラスは、画像アセットのリストを取り、それらをレイヤにレンダリングして、各レイヤの動きがリアルに見えるように異なる速度で動かします。DoodleDash クラスは、ParallaxComponent のインスタンスを含み、それを DoodleDash の onLoad メソッドでゲームに追加します。

lib/game/world.dart

class World extends ParallaxComponent<DoodleDash> {
 @override
 Future<void> onLoad() async {
   parallax = await gameRef.loadParallax(
     [
       ParallaxImageData('game/background/06_Background_Solid.png'),
       ParallaxImageData('game/background/05_Background_Small_Stars.png'),
       ParallaxImageData('game/background/04_Background_Big_Stars.png'),
       ParallaxImageData('game/background/02_Background_Orbs.png'),
       ParallaxImageData('game/background/03_Background_Block_Shapes.png'),
       ParallaxImageData('game/background/01_Background_Squiggles.png'),
     ],
     fill: LayerFill.width,
     repeat: ImageRepeat.repeat,
     baseVelocity: Vector2(0, -5),
     velocityMultiplierDelta: Vector2(0, 1.2),
   );
 }
}

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
   with HasKeyboardHandlerComponents, HasCollisionDetection {
 ...
 final World _world = World();
 ...

 @override
 Future<void> onLoad() async {
   await add(_world);
   ...
 }
}

状態管理

lib/game/managers ディレクトリには、Doodle Dash の状態管理を扱う 3 つのファイル(game_manager.dartobject_manager.dartlevel_manager.dart)が含まれています。

GameManager クラス(game_manager.dart 内)は、ゲーム ステータスとスコア記録の全体を管理しています。

ObjectManager クラス(object_manager.dart 内)は、プラットフォームが生成、削除する場所とタイミングを管理します。このクラスには、あとで追加を行います。

そして、LevelManager クラス(level_manager.dart 内)は、プレーヤーがレベルアップするタイミングの適切なゲーム設定とともに、ゲームの難易度を管理します。このゲームには 5 つの難易度レベルがあります。プレーヤーは、スコアのマイルストーンのうちの 1 つに到達すると、次のレベルに進みます。レベルが上がるごとに、難易度が上がり、Dash はより高くジャンプすることが必要になります。重力はゲームを通して一定なので、ジャンプのスピードは距離が遠くなるにつれ、少しずつ速くなります。

プレーヤーのスコアは、プラットフォームを通過すると増えます。プレーヤーがあるポイントしきい値を達成すると、ゲームはレベルアップし、ゲームをより楽しく難しくする新しい特別なプラットフォームを解放します。

4. ゲームにプレーヤーを追加する

このステップでは、ゲームにキャラクターを追加します(この場合は Dash)。プレーヤーはキャラクターを制御し、すべてのロジックは Player クラス(player.dart ファイル内)にあります。Player クラスは、オーバーライドしてカスタムのロジックを実装する抽象メソッドを含んでいる Flame の SpriteGroupComponent クラスを拡張します。これには、画像とスプライトの読み込み、プレーヤーの位置決め(水平方向と垂直方向)、衝突検出の設定、ユーザー入力の受け取りなどがあります。

アセットを読み込む

Dash はさまざまなスプライトで表示され、キャラクターのさまざまなバージョンやパワーアップを表現します。たとえば、以下のアイコンは、正面、左、右を向いている Dash と Sparky を示しています。

Flame の SpriteGroupComponent を使用することで、複数のスプライト状態を sprites プロパティで管理できます。これについては、_loadCharacterSprites メソッドで見ていきます。

a3c16fc17be25f6c.pngPlayer クラスで、以下の行を onLoad メソッドに追加して、スプライト アセットを読み込み、Player のスプライト状態を正面に設定します。

lib/game/sprites/player.dart

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

  await _loadCharacterSprites();                                      // Add this line
  current = PlayerState.center;                                       // Add this line
}

_loadCharacterSprites のスプライトとアセットを読み込むコードを調べます。このコードは、onLoad メソッドで直接実装することもできますが、別のメソッドに入れることでソースコードが整理され、可読性が高まります。このメソッドでは、以下のように、各キャラクター状態と読み込まれたスプライト アセットをペアリングするマップを sprites に代入しています。

lib/game/sprites/player.dart

Future<void> _loadCharacterSprites() async {
   final left = await gameRef.loadSprite('game/${character.name}_left.png');
   final right = await gameRef.loadSprite('game/${character.name}_right.png');
   final center =
       await gameRef.loadSprite('game/${character.name}_center.png');
   final rocket = await gameRef.loadSprite('game/rocket_4.png');
   final nooglerCenter =
       await gameRef.loadSprite('game/${character.name}_hat_center.png');
   final nooglerLeft =
       await gameRef.loadSprite('game/${character.name}_hat_left.png');
   final nooglerRight =
       await gameRef.loadSprite('game/${character.name}_hat_right.png');

   sprites = <PlayerState, Sprite>{
     PlayerState.left: left,
     PlayerState.right: right,
     PlayerState.center: center,
     PlayerState.rocket: rocket,
     PlayerState.nooglerCenter: nooglerCenter,
     PlayerState.nooglerLeft: nooglerLeft,
     PlayerState.nooglerRight: nooglerRight,
   };
 }

プレーヤー コンポーネントを更新する

イベントループのティック(またはフレーム)ごとに、Flame がコンポーネントの update メソッドを一回呼び出して、変更された各ゲーム コンポーネントを再描画します(Flutter の build メソッドと同様)。次に、Player クラスの update メソッドにロジックを追加して、画面上でのキャラクターの位置を決定します。

a3c16fc17be25f6c.png次のコードを Playerupdate メソッドに追加し、キャラクターの現在の速度と位置を計算します。

lib/game/sprites/player.dart

 void update(double dt) {
                                                             // Add lines from here...
   if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;

   _velocity.x = _hAxisInput * jumpSpeed;                              // ... to here.

   final double dashHorizontalCenter = size.x / 2;

   if (position.x < dashHorizontalCenter) {                  // Add lines from here...
     position.x = gameRef.size.x - (dashHorizontalCenter);
   }
   if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
     position.x = dashHorizontalCenter;
   }                                                                   // ... to here.

   // Core gameplay: Add gravity

   position += _velocity * dt;                                       // Add this line
   super.update(dt);
 }

プレーヤーを移動する前に、初期状態(ゲームの初回読み込み時)やゲームオーバー状態など、プレーヤーが移動すべきでないプレイ不可能な状態にないことを確認するためのチェックを update メソッドで行います。

プレイ可能な状態であれば、Dash の位置を new_position = current_position + (velocity * time-elapsed-since-last-game-loop-tick) という等式を使用して、つまり以下のコードで計算します。

 position += _velocity * dt

Doodle Dash を作成する際のもう一つの重要な点は、必ず無限の側面境界を含めるという点です。そうすることで、Dash が画面の左端から飛び降りて、右側から再登場するといったことや、その逆も可能になります。

7068325e8b2f35fc.gif

これは、Dash の位置が画面の左端または右端を超えたかどうかをチェックし、超えた場合には反対側に位置を設定することによって実装します。

キーイベント

まず、Doodle Dash はウェブとデスクトップで実行されるので、キーボード入力をサポートして、プレーヤーがキャラクターの動きを制御できるようにする必要があります。onKeyEvent メソッドを使用すると、Player コンポーネントで矢印キーが押されたのを認識し、Dash が左右のどちらを向き、どちらに進むのかを決定できます。

Dash は左に進むときに左を向く

Dash は右に進むときに右を向く

次に、Dash が水平に移動する能力を実装します(_hAxisInput 変数で定義)。また、それを Dash が進んでいる方向を向くようにもします。

a3c16fc17be25f6c.pngPlayer クラスの moveLeft メソッドと moveRight メソッドを変更して、Dash の現在位置を定義します。

lib/game/sprites/player.dart

 void moveLeft() {
   _hAxisInput = 0;

   current = PlayerState.left;                                      // Add this line

   _hAxisInput += movingLeftInput;                                  // Add this line

 }

 void moveRight() {
   _hAxisInput = 0;

   current = PlayerState.right;                                     // Add this line

   _hAxisInput += movingRightInput;                                 // Add this line

 }

a3c16fc17be25f6c.pngPlayer クラスの onKeyEvent メソッドを変更して、左矢印キーまたは右矢印キーが押されたときに、それぞれ moveLeft メソッドまたは moveRight メソッドを呼び出します。

lib/game/sprites/player.dart

@override
 bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
   _hAxisInput = 0;

                                                             // Add lines from here...
   if (keysPressed.contains(LogicalKeyboardKey.arrowLeft)) {
     moveLeft();
   }

   if (keysPressed.contains(LogicalKeyboardKey.arrowRight)) {
     moveRight();
   }                                                                   // ... to here.

   // During development, it's useful to "cheat"
   if (keysPressed.contains(LogicalKeyboardKey.arrowUp)) {
     // jump();
   }

   return true;
 }

これで、Player クラスが機能し、Doodle Dash ゲームで使用できるようになりました。

a3c16fc17be25f6c.pngDoodleDash ファイルで sprites.dart をインポートして、Player クラスを利用できるようにします。

lib/game/doodle_dash.dart

import 'sprites/sprites.dart';                                       // Add this line

a3c16fc17be25f6c.pngDoodleDash クラスで Player インスタンスを作成します。

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
    with HasKeyboardHandlerComponents, HasCollisionDetection {
  DoodleDash({super.children});

  final World _world = World();
  LevelManager levelManager = LevelManager();
  GameManager gameManager = GameManager();
  int screenBufferSpace = 300;
  ObjectManager objectManager = ObjectManager();

  late Player player;                                                // Add this line
  ...
}

a3c16fc17be25f6c.png 次に、Player のジャンプ速度を初期化し、プレーヤーが選択した難易度レベルに基づいて設定して、Player コンポーネントを FlameGame に追加します。setCharacter メソッドに次のコードを記入します。

lib/game/doodle_dash.dart

void setCharacter() {
  player = Player(                                           // Add lines from here...
     character: gameManager.character,
     jumpSpeed: levelManager.startingJumpSpeed,
   );
  add(player);                                                         // ... to here.
}

a3c16fc17be25f6c.pngsetCharacter メソッドを initializeGameStart の先頭で呼び出します。

lib/game/doodle_dash.dart

void initializeGameStart() {
    setCharacter();                                                   // Add this line

    ...
}

a3c16fc17be25f6c.pngまた、initializeGameStart で、プレーヤーの resetPosition を呼び出して、ゲームが開始されるたびに開始位置に戻るようにします。

lib/game/doodle_dash.dart

void initializeGameStart() {
    ...

    levelManager.reset();

    player.resetPosition();                                           // Add this line

    objectManager = ObjectManager(
        minVerticalDistanceToNextPlatform: levelManager.minDistance,
        maxVerticalDistanceToNextPlatform: levelManager.maxDistance);

    ...
  }

a3c16fc17be25f6c.png アプリを実行し、ゲームを開始すると、Dash が画面に現れます。

ed15a9c6762595c9.png

トラブルシューティング

アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

5. プラットフォームを追加する

このステップでは、(Dash が着地する、またはジャンプする際の足場にする)プラットフォームと、Dash がジャンプするべきタイミングを決定する衝突検出ロジックを追加します。

まず、Platform 抽象クラスを調べます。

lib/game/sprites/platform.dart

abstract class Platform<T> extends SpriteGroupComponent<T>
    with HasGameRef<DoodleDash>, CollisionCallbacks {
  final hitbox = RectangleHitbox();
  bool isMoving = false;

  Platform({
    super.position,
  }) : super(
          size: Vector2.all(100),
          priority: 2,
        );

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    await add(hitbox);
  }
}

ヒットボックスについて

Doodle Dash で導入されたプラットフォーム コンポーネントは、どれも Platform<T> 抽象クラス(ヒットボックスのある SpriteComponent クラス)を拡張しています。ヒットボックスがあることで、スプライト コンポーネントで、ヒットボックスのある他のオブジェクトに衝突したことを検出できるようになります。Flame は、四角形、円形、多角形などのさまざまなヒットボックス形状をサポートしています。たとえば、Doodle Dash では、プラットフォームに四角形のヒットボックスを使用し、Dash には円形のヒットボックスを使用しています。Flame が衝突を判断する計算を行います。

Platform クラスは、すべのサブタイプにヒットボックスと衝突検出を追加します。

標準プラットフォームを追加する

Platform クラスはプラットフォームをゲームに追加します。通常のプラットフォームは、ランダムに選ばれた 4 つの外観(モニタ、スマートフォン、ターミナル、ノートパソコン)のうちの 1 つで表現されます。外観の選択はプラットフォームの動作に影響しません。

NormalPlatform

a3c16fc17be25f6c.pngNormalPlatformState 列挙型と NormalPlatform クラスを追加することで、通常の静的なプラットフォームを追加します。

lib/game/sprites/platform.dart

enum NormalPlatformState { only }                            // Add lines from here...

class NormalPlatform extends Platform<NormalPlatformState> {
  NormalPlatform({super.position});

  final Map<String, Vector2> spriteOptions = {
    'platform_monitor': Vector2(115, 84),
    'platform_phone_center': Vector2(100, 55),
    'platform_terminal': Vector2(110, 83),
    'platform_laptop': Vector2(100, 63),
  };

  @override
  Future<void>? onLoad() async {
    var randSpriteIndex = Random().nextInt(spriteOptions.length);

    String randSprite = spriteOptions.keys.elementAt(randSpriteIndex);

    sprites = {
      NormalPlatformState.only: await gameRef.loadSprite('game/$randSprite.png')
    };

    current = NormalPlatformState.only;

    size = spriteOptions[randSprite]!;
    await super.onLoad();
  }
}                                                                      // ... to here.

次に、操作するキャラクターのプラットフォームを発生させます。

ObjectManager クラスは Flame の Component クラスを拡張し、ゲームを通じて Platform オブジェクトを生成します。ObjectManagerupdate メソッドと onMount メソッドにプラットフォームを発生させる機能を実装します。

a3c16fc17be25f6c.png_semiRandomPlatform という新しいメソッドを作成して、ObjectManager クラスでプラットフォームを発生させます。このメソッドは後で更新して、さまざまな種類のプラットフォームを返すようにしますが、ここでは NormalPlatform だけを返します。

lib/game/managers/object_manager.dart

Platform _semiRandomPlatform(Vector2 position) {             // Add lines from here...
    return NormalPlatform(position: position);
}                                                                      // ... to here.

a3c16fc17be25f6c.pngObjectManagerupdate メソッドをオーバーライドし、_semiRandomPlatform メソッドを使用してプラットフォームを生成し、それをゲームに追加します。

lib/game/managers/object_manager.dart

 @override                                                   // Add lines from here...
 void update(double dt) {
   final topOfLowestPlatform =
       _platforms.first.position.y + _tallestPlatformHeight;

   final screenBottom = gameRef.player.position.y +
       (gameRef.size.x / 2) +
       gameRef.screenBufferSpace;

   if (topOfLowestPlatform > screenBottom) {
     var newPlatY = _generateNextY();
     var newPlatX = _generateNextX(100);
     final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
     add(nextPlat);

     _platforms.add(nextPlat);

     gameRef.gameManager.increaseScore();

     _cleanupPlatforms();
     // Losing the game: Add call to _maybeAddEnemy()
     // Powerups: Add call to _maybeAddPowerup();
   }

   super.update(dt);
 }                                                                     // ... to here.

同じことを ObjectManageronMount メソッドでも行い、ゲームが最初に実行されたときに _semiRandomPlatform メソッドが最初のプラットフォームを生成し、それをゲームに追加するようにします。

a3c16fc17be25f6c.png次のコードの onMount メソッドを追加します。

lib/game/managers/object_manager.dart

 @override                                                   // Add lines from here...
 void onMount() {
   super.onMount();

   var currentX = (gameRef.size.x.floor() / 2).toDouble() - 50;

   var currentY =
       gameRef.size.y - (_rand.nextInt(gameRef.size.y.floor()) / 3) - 50;

   for (var i = 0; i < 9; i++) {
     if (i != 0) {
       currentX = _generateNextX(100);
       currentY = _generateNextY();
     }
     _platforms.add(
       _semiRandomPlatform(
         Vector2(
           currentX,
           currentY,
         ),
       ),
     );

     add(_platforms[i]);
   }
 }                                                                     // ... to here.

たとえば、以下のコードのように、configure メソッドにより、Doodle Dash ゲームで、プラットフォーム間の最小距離と最大距離を再設定できるようになり、難易度レベルが増えたときの特別なプラットフォームが実現されます。

lib/game/managers/object_manager.dart

 void configure(int nextLevel, Difficulty config) {
    minVerticalDistanceToNextPlatform = gameRef.levelManager.minDistance;
    maxVerticalDistanceToNextPlatform = gameRef.levelManager.maxDistance;

    for (int i = 1; i <= nextLevel; i++) {
      enableLevelSpecialty(i);
    }
  }

DoodleDash インスタンスは(initializeGameStart メソッド内で)、初期化され、難易度レベルに基づいて設定され、Flame ゲームに追加される ObjectManager を作成します。

lib/game/doodle_dash.dart

  void initializeGameStart() {
    gameManager.reset();

    if (children.contains(objectManager)) objectManager.removeFromParent();

    levelManager.reset();

    player.resetPosition();

    objectManager = ObjectManager(
        minVerticalDistanceToNextPlatform: levelManager.minDistance,
        maxVerticalDistanceToNextPlatform: levelManager.maxDistance);

    add(objectManager);

    objectManager.configure(levelManager.level, levelManager.difficulty);
  }

ObjectManagercheckLevelUp メソッド内で再登場します。プレーヤーがレベルアップすると、ObjectManager がプラットフォーム生成パラメータを難易度レベルに基づいて再設定します。

lib/game/doodle_dash.dart

  void checkLevelUp() {
    if (levelManager.shouldLevelUp(gameManager.score.value)) {
      levelManager.increaseLevel();

      objectManager.configure(levelManager.level, levelManager.difficulty);
    }
  }

a3c16fc17be25f6c.png ホットリロード 7f9a9e103c7b5e5.png で(ウェブ上でテストしている場合は再起動して)変更点を有効にします(ファイルを保存するか、IDE のボタンを使用するか、コマンドラインから r を入力すると、ホットリロードされます)。ゲームを開始すると、Dash とプラットフォームが画面に現れます。

7c6a6c6e630c42ce.png

トラブルシューティング

アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

6. コア ゲームプレイ

Player ウィジェットと Platform ウィジェットを実装したので、次は全体をまとめましょう。このステップでは、コア機能、衝突検出、カメラ移動を実装します。

重力

ゲームをもっと現実感のあるものにするために、Dash に重力(ジャンプしたときに Dash を下に引っ張る力)を作用させる必要があります。今回の Doodle Dash では、重力は正の定数のままにして、Dash が常に下に引っ張られるようにします。将来は、重力を変化させて、他の効果を作ることができます。

a3c16fc17be25f6c.png Player クラスで、値が 9 の _gravity プロパティを追加します。

lib/game/sprites/player.dart

class Player extends SpriteGroupComponent<PlayerState>
    with HasGameRef<DoodleDash>, KeyboardHandler, CollisionCallbacks {

  ...

  Character character;
  double jumpSpeed;
  final double _gravity = 9;                                         // Add this line

  @override
  Future<void> onLoad() async {
    ...
  }
  ...
}

a3c16fc17be25f6c.pngPlayerupdate メソッドを変更して、_gravity 変数を追加し、Dash の垂直速度に影響を与えます。

lib/game/sprites/player.dart

 void update(double dt) {
   if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;

   _velocity.x = _hAxisInput * jumpSpeed;
   final double dashHorizontalCenter = size.x / 2;

   if (position.x < dashHorizontalCenter) {
     position.x = gameRef.size.x - (dashHorizontalCenter);
   }
   if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
     position.x = dashHorizontalCenter;
   }

   _velocity.y += _gravity;                                          // Add this line

   position += _velocity * dt;
   super.update(dt);
 }

衝突検出

Flame は、衝突検出を初めからサポートしています。それを Flame ゲームで有効にするには、HasCollisionDetection ミックスインを追加します。DoodleDash クラスを調べると、このミックスインがすでに追加されています。

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
    with HasKeyboardHandlerComponents, HasCollisionDetection {
    ...
}

次に、CollisionCallbacks ミックスインを使用して、各ゲーム コンポーネントに衝突検出を追加します。このミックスインにより、コンポーネントが onCollision コールバックにアクセスできるようになります。ヒットボックスのある 2 つのオブジェクトが衝突すると、onCollision コールバックがトリガーされて、衝突したオブジェクトへの参照が渡されるので、オブジェクトがどのように反応すべきかのロジックを実装できます。

前のステップでは Platform 抽象クラスに CollisionCallbacks ミックスインとヒットボックスがあったことを思い出してください。Player クラスには、すでに CollisionCallbacks ミックスインがあるため、必要なのは CircleHitboxPlayer クラスに追加することだけです。Dash は四角形よりも円形に近いので、Dash のヒットボックスは実際には円形になっています。

a3c16fc17be25f6c.png Player クラスで sprites.dart をインポートして、さまざまな Platform クラスにアクセスできるようにします。

lib/game/sprites/player.dart

import 'sprites.dart';

a3c16fc17be25f6c.png CircleHitboxPlayer クラスの onLoad メソッドに追加します。

lib/game/sprites/player.dart

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

  await add(CircleHitbox());                                         // Add this line

  await _loadCharacterSprites();
  current = PlayerState.center;
}

Dash は、プラットフォームと衝突したときにジャンプできるので、ジャンプ メソッドを必要としています。

a3c16fc17be25f6c.png オプションの specialJumpSpeed を取る jump メソッドを追加します。

lib/game/sprites/player.dart

void jump({double? specialJumpSpeed}) {
  _velocity.y = specialJumpSpeed != null ? -specialJumpSpeed : -jumpSpeed;
}

a3c16fc17be25f6c.png次のコードを追加して、PlayeronCollision メソッドをオーバーライドします。

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
   super.onCollision(intersectionPoints, other);
   bool isCollidingVertically =
       (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

   if (isMovingDown && isCollidingVertically) {
     current = PlayerState.center;
     if (other is NormalPlatform) {
       jump();
       return;
     }
   }
 }

このコールバックは、Dash が下に落ちて NormalPlatform の上面に衝突したときに、jump メソッドを呼び出します。isMovingDown && isCollidingVertically というステートメントにより、Dash は、ジャンプをトリガーすることなく、プラットフォームを通過して上に移動できます。

カメラ移動

Dash がゲーム内で上に移動するときには、カメラが Dash を追いますが、Dash が落ちるときには、カメラは固定されるべきです。

Flame では、「世界」が画面よりも大きい場合、カメラの worldBounds を使用して、世界のどの部分を表示すべきかを Flame に支持する境界を追加します。カメラが水平方向に固定されたまま上に移動しているように見せるには、プレーヤーの位置に基づいて、更新ごとに上下の世界境界を調整し、左右の境界は同じにします。

a3c16fc17be25f6c.pngDoodleDash クラスで、次のコードを update メソッドに追加して、ゲームプレイ中にカメラが Dash を追えるようにします。

lib/game/doodle_dash.dart

@override
  void update(double dt) {
    super.update(dt);

    if (gameManager.isIntro) {
      overlays.add('mainMenuOverlay');
      return;
    }

    if (gameManager.isPlaying) {
      checkLevelUp();

                                                            // Add lines from here...
      final Rect worldBounds = Rect.fromLTRB(
        0,
        camera.position.y - screenBufferSpace,
        camera.gameSize.x,
        camera.position.y + _world.size.y,
      );
      camera.worldBounds = worldBounds;

      if (player.isMovingDown) {
        camera.worldBounds = worldBounds;
      }

      var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
      if (!player.isMovingDown && isInTopHalfOfScreen) {
        camera.followComponent(player);
      }                                                               // ... to here.
    }
  }

次に、ゲーム再開時に Player の位置とカメラ境界を開始値にリセットする必要があります。

a3c16fc17be25f6c.png次のコードを initializeGameStart メソッドに追加します。

lib/game/doodle_dash.dart

void initializeGameStart() {
    ...
    levelManager.reset();

                                                        // Add the lines from here...
    player.reset();
    camera.worldBounds = Rect.fromLTRB(
      0,
      -_world.size.y,
      camera.gameSize.x,
      _world.size.y +
          screenBufferSpace,
    );
    camera.followComponent(player);
                                                                      // ... to here.

   player.resetPosition();
    ...
  }

レベルアップ時にジャンプ速度を上げる

コア ゲームプレイの最後のピースでは、難易度レベルが上がり、プラットフォームが互いに間隔を広げて発生したときに、Dash のジャンプ速度を上げることが必要になります。

a3c16fc17be25f6c.pngsetJumpSpeed メソッドの呼び出しを追加し、現在のレベルに関連付けられているジャンプ速度を設定します。

lib/game/doodle_dash.dart

void checkLevelUp() {
    if (levelManager.shouldLevelUp(gameManager.score.value)) {
      levelManager.increaseLevel();

      objectManager.configure(levelManager.level, levelManager.difficulty);

      player.setJumpSpeed(levelManager.jumpSpeed);                   // Add this line
    }
  }

a3c16fc17be25f6c.png ホットリロード 7f9a9e103c7b5e5.png して(またはウェブ上で再起動して)変更点を有効にします(ファイルを保存するか、IDE のボタンを使用するか、コマンドラインから r を入力すると、ホットリロードされます)。

2bc7c856064d74ca.gif

トラブルシューティング

アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

7. さらにプラットフォームについて

Dash がジャンプする際の足場となるプラットフォームを ObjectManager が生成するようになったので、Dash に刺激的で特別なプラットフォームを与えることができます。

次は、BrokenPlatform クラスと SpringBoard クラスを追加します。その名前からわかるとおり、BrokenPlatform は一回のジャンプで壊れ、SpringBoard は Dash を高く高速に跳ね上げるトランポリンを用意します。

BrokenPlatform

SpringBoard

Player クラスと同じように、それぞれのプラットフォーム クラスでは enums を使用して現在の状態を表現します。

lib/game/sprites/platform.dart

enum BrokenPlatformState { cracked, broken }

プラットフォームの current 状態が変化すると、ゲーム内で現れるスプライトも変化します。State 列挙型と sprites プロパティの画像アセットとの間のマッピングを定義して、各状態にどのスプライトを割り当てるかを関連付けます。

a3c16fc17be25f6c.pngBrokenPlatformState 列挙型と BrokenPlatform クラスを追加します。

lib/game/sprites/platform.dart

enum BrokenPlatformState { cracked, broken }                // Add lines from here...

class BrokenPlatform extends Platform<BrokenPlatformState> {
  BrokenPlatform({super.position});

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    sprites = <BrokenPlatformState, Sprite>{
      BrokenPlatformState.cracked:
          await gameRef.loadSprite('game/platform_cracked_monitor.png'),
      BrokenPlatformState.broken:
          await gameRef.loadSprite('game/platform_monitor_broken.png'),
    };

    current = BrokenPlatformState.cracked;
    size = Vector2(115, 84);
  }

  void breakPlatform() {
    current = BrokenPlatformState.broken;
  }
}                                                                     // ... to here.

a3c16fc17be25f6c.pngSpringState 列挙型と SpringBoard クラスを追加します。

lib/game/sprites/platform.dart

enum SpringState { down, up }                                // Add lines from here...

class SpringBoard extends Platform<SpringState> {
  SpringBoard({
    super.position,
  });

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    sprites = <SpringState, Sprite>{
      SpringState.down:
          await gameRef.loadSprite('game/platform_trampoline_down.png'),
      SpringState.up:
          await gameRef.loadSprite('game/platform_trampoline_up.png'),
    };

    current = SpringState.up;

    size = Vector2(100, 45);
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isCollidingVertically) {
      current = SpringState.down;
    }
  }

  @override
  void onCollisionEnd(PositionComponent other) {
    super.onCollisionEnd(other);

    current = SpringState.up;
  }
}                                                                      // ... to here.

次に、これらの特別なプラットフォームを ObjectManager の中で有効にします。特別なプラットフォームは、ゲーム中で常に表示するのは望ましくないので、SpringBoard は 15% の確率で、BrokenPlatform は 10% の確率で発生させます。

a3c16fc17be25f6c.png ObjectManager _semiRandomPlatform メソッドの中、NormalPlatform を返しているステートメントの前に次のコードを追加し、条件付きで特別なプラットフォームを発生させます。

lib/game/managers/object_manager.dart

Platform _semiRandomPlatform(Vector2 position) {
   if (specialPlatforms['spring'] == true &&                 // Add lines from here...
       probGen.generateWithProbability(15)) {
     return SpringBoard(position: position);
   }

   if (specialPlatforms['broken'] == true &&
       probGen.generateWithProbability(10)) {
     return BrokenPlatform(position: position);
   }                                                                   // ... to here.

   return NormalPlatform(position: position);
}

ゲームをプレイする楽しみの一つは、レベルアップで新たな課題と機能を解放することです。

レベル 1 の最初からトランポリンが登場し、レベル 2 で BrokenPlatform を解放するようにして、ゲームを少し難しくするのがよいでしょう。

a3c16fc17be25f6c.pngObjectManager クラスで、レベル 1 で SpringBoard プラットフォームを有効にし、レベル 2 で BrokenPlatform を有効にする switch 文を追加して、enableLevelSpecialty メソッド(現在はスタブ)を変更します。

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
  switch (level) {                                           // Add lines from here...
    case 1:
      enableSpecialty('spring');
      break;
    case 2:
      enableSpecialty('broken');
      break;
  }                                                                    // ... to here.
}

a3c16fc17be25f6c.png次に、プラットフォームに水平方向に移動する機能を追加します。Platform 抽象クラスに、次の _move メソッドを追加します。

lib/game/sprites/platform.dart

void _move(double dt) {
    if (!isMoving) return;

    final double gameWidth = gameRef.size.x;

    if (position.x <= 0) {
      direction = 1;
    } else if (position.x >= gameWidth - size.x) {
      direction = -1;
    }

    _velocity.x = direction * speed;

    position += _velocity * dt;
}

プラットフォームが移動中の場合、ゲーム画面の端に達したときに、動きを反対方向に変えます。Dash と同じように、プラットフォームの位置も、_direction にプラットフォームの speed を乗じて速度を得ることで決定されます。そして、速度に time-elapsed を乗じて、その結果をプラットフォームの現在の position に加算します。

a3c16fc17be25f6c.pngPlatform クラスの update メソッドをオーバーライドして、_move メソッドを呼び出します。

lib/game/sprites/platform.dart

@override
void update(double dt) {
  _move(dt);
  super.update(dt);
}

a3c16fc17be25f6c.pngPlatform の移動をトリガーするために、onLoad メソッドの中で、isMoving ブール値を 20% の確率で true に設定します。

lib/game/sprites/platform.dart

@override
Future<void>? onLoad() async {
  await super.onLoad();

  await add(hitbox);

  final int rand = Random().nextInt(100);                            // Add this line
  if (rand > 80) isMoving = true;                                    // Add this line
}

a3c16fc17be25f6c.png最後に、Player で、Player クラスの onCollision メソッドを変更して、Springboard または BrokenPlatform との衝突を検出します。SpringBoardjump を 2 倍のスピードで呼び出し、BrokenPlatform は状態が .broken(ジャンプ済み)ではなく .cracked のときにだけ jump を呼び出していることに注目してください。

lib/game/sprites/player.dart

@override
 void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
   super.onCollision(intersectionPoints, other);

   bool isCollidingVertically =
       (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

   if (isMovingDown && isCollidingVertically) {
     current = PlayerState.center;
     if (other is NormalPlatform) {
       jump();
       return;
     } else if (other is SpringBoard) {                      // Add lines from here...
       jump(specialJumpSpeed: jumpSpeed * 2);
       return;
     } else if (other is BrokenPlatform &&
         other.current == BrokenPlatformState.cracked) {
       jump();
       other.breakPlatform();
       return;
     }                                                                 // ... to here.
   }
 }

a3c16fc17be25f6c.png アプリを再起動して、ゲームを開始すると、プラットフォーム SpringBoard とプラットフォーム BrokenPlatform が移動しているのが表示されます。

d4949925e897f665.gif

トラブルシューティング

アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

8. ゲームオーバー

このステップでは、Doodle Dash ゲームにゲームオーバー条件を追加します。ゲームオーバーは 2 通りあります。

  1. Dash がプラットフォームに乗り損ねて、画面下に落ちる。
  2. Dash が Enemy プラットフォームに衝突する。

いずれかの「ゲームオーバー」を実装する前に、DoodleDash のゲーム状態を gameOver に設定するロジックを追加する必要があります。

a3c16fc17be25f6c.pngDoodleDash クラスで、ゲームを終了するときに呼び出される onLose メソッドを追加します。このメソッドでは、ゲーム状態を設定し、画面からプレーヤーを取り除き、ゲームオーバーのメニューやオーバーレイを有効にします。

lib/game/sprites/doodle_dash.dart

 void onLose() {                                             // Add lines from here...
    gameManager.state = GameState.gameOver;
    player.removeFromParent();
    overlays.add('gameOverOverlay');
  }                                                                    // ... to here.

ゲームオーバー メニュー:

6a79b43f4a1f780d.png

a3c16fc17be25f6c.pngDoodleDashupdate メソッドの上部で、以下のコードを追加して、ゲーム状態が GameOver であるときにゲームの更新を停止します。

lib/game/sprites/doodle_dash.dart

@override
 void update(double dt) {
   super.update(dt);

   if (gameManager.isGameOver) {                             // Add lines from here...
     return;
   }                                                                   // ... to here.
   ...
}

a3c16fc17be25f6c.pngまた、update メソッドで、プレーヤーが画面下に落ちたときに onLose を呼び出します。

lib/game/sprites/doodle_dash.dart

@override
 void update(double dt) {
   ...

   if (gameManager.isPlaying) {
     checkLevelUp();

     final Rect worldBounds = Rect.fromLTRB(
       0,
       camera.position.y - screenBufferSpace,
       camera.gameSize.x,
       camera.position.y + _world.size.y,
     );
     camera.worldBounds = worldBounds;
     if (player.isMovingDown) {
       camera.worldBounds = worldBounds;
     }

     var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
     if (!player.isMovingDown && isInTopHalfOfScreen) {
       camera.followComponent(player);
     }

                                                             // Add lines from here...
     if (player.position.y >
         camera.position.y +
             _world.size.y +
             player.size.y +
             screenBufferSpace) {
       onLose();
     }                                                                 // ... to here.
   }
 }

敵の形状やサイズはさまざまで、Doodle Dash ではゴミ箱やエラーフォルダのアイコンで表示されます。プレーヤーは、いずれかに衝突するとすぐにゲームオーバーになるので、それを避ける必要があります。

Enemy

a3c16fc17be25f6c.pngEnemyPlatformState 列挙型と EnemyPlatform クラスを追加して、敵のプラットフォーム タイプを作成します。

lib/game/sprites/platform.dart

enum EnemyPlatformState { only }                             // Add lines from here...

class EnemyPlatform extends Platform<EnemyPlatformState> {
  EnemyPlatform({super.position});

  @override
  Future<void>? onLoad() async {
    var randBool = Random().nextBool();
    var enemySprite = randBool ? 'enemy_trash_can' : 'enemy_error';

    sprites = <EnemyPlatformState, Sprite>{
      EnemyPlatformState.only:
          await gameRef.loadSprite('game/$enemySprite.png'),
    };

    current = EnemyPlatformState.only;

    return super.onLoad();
  }
}                                                                      // ... to here.

EnemyPlatform クラスは Platform スーパータイプを拡張しています。ObjectManager は、敵のプラットフォームを、他のプラットフォームと同じように、発生させ、管理します。

a3c16fc17be25f6c.pngObjectManager次のコードを追加して、敵のプラットフォームを発生させ、管理します。

lib/game/managers/object_manager.dart

final List<EnemyPlatform> _enemies = [];                    // Add lines from here...
void _maybeAddEnemy() {
  if (specialPlatforms['enemy'] != true) {
    return;
  }
  if (probGen.generateWithProbability(20)) {
    var enemy = EnemyPlatform(
      position: Vector2(_generateNextX(100), _generateNextY()),
    );
    add(enemy);
    _enemies.add(enemy);
    _cleanupEnemies();
  }
}

void _cleanupEnemies() {
  final screenBottom = gameRef.player.position.y +
      (gameRef.size.x / 2) +
      gameRef.screenBufferSpace;

  while (_enemies.isNotEmpty && _enemies.first.position.y > screenBottom) {
    remove(_enemies.first);
    _enemies.removeAt(0);
  }
}                                                                      // ... to here.

ObjectManager は、敵オブジェクトである _enemies のリストを管理します。_maybeAddEnemy は、敵を 20 パーセントの確率で発生させ、そのオブジェクトを敵リストに追加します。_cleanupEnemies() メソッドは、表示されなくなった古い EnemyPlatform オブジェクトを削除します。

a3c16fc17be25f6c.pngObjectManager では、update メソッドの中で _maybeAddEnemy() を呼び出して、敵のプラットフォームを発生させます。

lib/game/managers/object_manager.dart

@override
void update(double dt) {
  final topOfLowestPlatform =
      _platforms.first.position.y + _tallestPlatformHeight;

  final screenBottom = gameRef.player.position.y +
      (gameRef.size.x / 2) +
      gameRef.screenBufferSpace;
  if (topOfLowestPlatform > screenBottom) {
    var newPlatY = _generateNextY();
    var newPlatX = _generateNextX(100);
    final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
    add(nextPlat);

    _platforms.add(nextPlat);
    gameRef.gameManager.increaseScore();

    _cleanupPlatforms();
    _maybeAddEnemy();                                                 // Add this line
  }

  super.update(dt);
}

a3c16fc17be25f6c.pngPlayeronCollision メソッドに追加して、EnemyPlatform と衝突していないかをチェックします。衝突している場合は onLose() メソッドを呼び出します。

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is EnemyPlatform) {                           // Add lines from here...
      gameRef.onLose();
      return;
    }                                                                 // ... to here.

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isMovingDown && isCollidingVertically) {
      current = PlayerState.center;
      if (other is NormalPlatform) {
        jump();
        return;
      } else if (other is SpringBoard) {
        jump(specialJumpSpeed: jumpSpeed * 2);
        return;
      } else if (other is BrokenPlatform &&
          other.current == BrokenPlatformState.cracked) {
        jump();
        other.breakPlatform();
        return;
      }
    }
  }

a3c16fc17be25f6c.png最後に、ObjectManagerenableLevelSpecialty メソッドを変更して、switch 文にレベル 5 を追加します。

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
  switch (level) {
    case 1:
      enableSpecialty('spring');
      break;
    case 2:
      enableSpecialty('broken');
      break;
    case 5:                                                  // Add lines from here...
      enableSpecialty('enemy');
      break;                                                           // ... to here.
  }
}

a3c16fc17be25f6c.png ゲームを難しくできたので、ホットリロード 7f9a9e103c7b5e5.png して変更を有効にします(ファイルを保存するか、IDE のボタンを使用するか、コマンドラインから r を入力すると、ホットリロードされます)。

壊れたフォルダの敵にご注意を。彼らは卑怯です。背景に溶け込んでいます。

トラブルシューティング

アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

9. パワーアップ

このステップでは、ゲームを通じて、ゲーム機能を強化して Dash をパワーアップします。Doodle Dash には、Noogler ハットと宇宙船の 2 つのパワーアップがあります。どちらのパワーアップも、別のタイプの特別なプラットフォームと考えることができます。Dash がゲームでジャンプしているとき、Noogler ハットか宇宙船のパワーアップに衝突して獲得すると、スピードが上がります。

NooglerHat

Rocket

プレーヤーがスコア 40 以上を達成してレベル 3 になると、Noogler ハットが発生します。Dash がこのハットに衝突すると、Noogler ハットを被り、通常の 2.5 倍に加速されます。これは 5 秒間継続します。

プレーヤーがスコア 80 以上を達成してレベル 4 になると、宇宙船が発生します。Dash が宇宙船に衝突すると、スプライトが宇宙船に置き換わり、プラットフォームに着地するまで通常の 3.5 倍の速度になります。宇宙船のパワーアップがあるときには、無敵になるという特典もあります。

Noogler ハットと宇宙船のスプライトは、PowerUp 抽象クラスを拡張しています。Platform 抽象クラスと同じように、以下に示す PowerUp 抽象クラスにもサイズ設定とヒットボックスが含まれています。

lib/game/sprites/powerup.dart

abstract class PowerUp extends SpriteComponent
    with HasGameRef<DoodleDash>, CollisionCallbacks {
  final hitbox = RectangleHitbox();
  double get jumpSpeedMultiplier;

  PowerUp({
    super.position,
  }) : super(
          size: Vector2.all(50),
          priority: 2,
        );

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    await add(hitbox);
  }
}

a3c16fc17be25f6c.png PowerUp 抽象クラスを拡張する Rocket クラスを作成します。Dash が宇宙船に衝突すると、通常の 3.5 倍のスピードになります。

lib/game/sprites/powerup.dart

class Rocket extends PowerUp {                               // Add lines from here...
  @override
  double get jumpSpeedMultiplier => 3.5;

  Rocket({
    super.position,
  });

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    sprite = await gameRef.loadSprite('game/rocket_1.png');
    size = Vector2(50, 70);
  }
}                                                                      // ... to here.

a3c16fc17be25f6c.png PowerUp 抽象クラスを拡張する NooglerHat クラスを作成します。Dash が NooglerHat に衝突すると、通常の 2.5 倍の速度になります。この加速は 5 秒間継続します。

lib/game/sprites/powerup.dart

class NooglerHat extends PowerUp {                          // Add lines from here...
  @override
  double get jumpSpeedMultiplier => 2.5;

  NooglerHat({
    super.position,
  });

  final int activeLengthInMS = 5000;

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    sprite = await gameRef.loadSprite('game/noogler_hat.png');
    size = Vector2(75, 50);
  }
}                                                                      // ... to here.

NooglerHat パワーアップと Rocket パワーアップを実装したので、ObjectManager を更新してゲーム内に発生させます。

a3c16fc17be25f6c.png ObjectManger クラスを変更して、発生させたパワーアップを管理するリストとともに、新しいパワーアップ プラットフォームの生成と削除を行う _maybePowerup メソッドと _cleanupPowerups メソッドを追加します。

lib/game/managers/object_manager.dart

final List<PowerUp> _powerups = [];                          // Add lines from here...

 void _maybeAddPowerup() {
   if (specialPlatforms['noogler'] == true &&
       probGen.generateWithProbability(20)) {
     var nooglerHat = NooglerHat(
       position: Vector2(_generateNextX(75), _generateNextY()),
     );
     add(nooglerHat);
     _powerups.add(nooglerHat);
   } else if (specialPlatforms['rocket'] == true &&
       probGen.generateWithProbability(15)) {
     var rocket = Rocket(
       position: Vector2(_generateNextX(50), _generateNextY()),
     );
     add(rocket);
     _powerups.add(rocket);
   }

   _cleanupPowerups();
 }

 void _cleanupPowerups() {
   final screenBottom = gameRef.player.position.y +
       (gameRef.size.x / 2) +
       gameRef.screenBufferSpace;
   while (_powerups.isNotEmpty && _powerups.first.position.y > screenBottom) {
     if (_powerups.first.parent != null) {
       remove(_powerups.first);
     }
     _powerups.removeAt(0);
   }
 }                                                                     // ... to here.

_maybeAddPowerup メソッドは、20% の確率で Noogler ハットを発生させ、15% の確率で宇宙船を発生させます。_cleanupPowerups メソッドは、画面の底面境界の下にあるパワーアップを削除するために呼び出されます。

a3c16fc17be25f6c.png ObjectManager update メソッドを変更して、ゲームループのティックごとに _maybePowerup を呼び出します。

lib/game/managers/object_manager.dart

@override
  void update(double dt) {
    final topOfLowestPlatform =
        _platforms.first.position.y + _tallestPlatformHeight;

    final screenBottom = gameRef.player.position.y +
        (gameRef.size.x / 2) +
        gameRef.screenBufferSpace;

    if (topOfLowestPlatform > screenBottom) {
      var newPlatY = _generateNextY();
      var newPlatX = _generateNextX(100);
      final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
      add(nextPlat);

      _platforms.add(nextPlat);

      gameRef.gameManager.increaseScore();

      _cleanupPlatforms();
      _maybeAddEnemy();
      _maybeAddPowerup();                                            // Add this line
    }

    super.update(dt);
  }

a3c16fc17be25f6c.pngenableLevelSpecialty メソッドを変更して、switch 文に、レベル 3 で NooglerHat を有効にする case と、レベル 4 で Rocket を有効にする case を追加します。

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
    switch (level) {
      case 1:
        enableSpecialty('spring');
        break;
      case 2:
        enableSpecialty('broken');
        break;
      case 3:                                               // Add lines from here...
        enableSpecialty('noogler');
        break;
      case 4:
        enableSpecialty('rocket');
        break;                                                        // ... to here.
      case 5:
        enableSpecialty('enemy');
        break;
    }
  }

a3c16fc17be25f6c.png 次のブール値のゲッターを Player クラスに追加します。Dash が有効なパワーアップを持っている場合、状態によってさまざまに表示されます。これらのゲッターにより、どのパワーアップが有効なのかを簡単にチェックできます。

lib/game/sprites/player.dart

 bool get hasPowerup =>                                      // Add lines from here...
     current == PlayerState.rocket ||
     current == PlayerState.nooglerLeft ||
     current == PlayerState.nooglerRight ||
     current == PlayerState.nooglerCenter;

 bool get isInvincible => current == PlayerState.rocket;

 bool get isWearingHat =>
     current == PlayerState.nooglerLeft ||
     current == PlayerState.nooglerRight ||
     current == PlayerState.nooglerCenter;                             // ... to here.

a3c16fc17be25f6c.pngPlayeronCollision メソッドを変更して、NooglerHat または Rocket との衝突に反応します。このコードでは、Dash がまだパワーアップを持っていないときに、新しいパワーアップだけを有効にするようにします。

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is EnemyPlatform && !isInvincible) {
      gameRef.onLose();
      return;
    }

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isMovingDown && isCollidingVertically) {
      current = PlayerState.center;
      if (other is NormalPlatform) {
        jump();
        return;
      } else if (other is SpringBoard) {
        jump(specialJumpSpeed: jumpSpeed * 2);
        return;
      } else if (other is BrokenPlatform &&
          other.current == BrokenPlatformState.cracked) {
        jump();
        other.breakPlatform();
        return;
      }
    }

    if (!hasPowerup && other is Rocket) {                    // Add lines from here...
      current = PlayerState.rocket;
      other.removeFromParent();
      jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
      return;
    } else if (!hasPowerup && other is NooglerHat) {
      if (current == PlayerState.center) current = PlayerState.nooglerCenter;
      if (current == PlayerState.left) current = PlayerState.nooglerLeft;
      if (current == PlayerState.right) current = PlayerState.nooglerRight;
      other.removeFromParent();
      _removePowerupAfterTime(other.activeLengthInMS);
      jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
      return;
    }                                                                  // ... to here.
  }

Dash が宇宙船に衝突すると、PlayerStateRocket に変更され、Dash が 3.5 倍の jumpSpeedMultiplier でジャンプできるようになります。

Dash が Noogler ハットに衝突すると、現在の PlayerState 方向(.center.left.right)に応じて PlayerState が対応する Noogler PlayerState に変化し、Noogler ハットを被って jumpSpeedMultiplier が 2.5 倍になります。そのパワーアップは _removePowerupAfterTime メソッドにより 5 秒後に削除され、PlayerState がパワーアップ状態から center に変更されます。

other.removeFromParent への呼び出しにより Noogler ハットまたは宇宙船のスプライト プラットフォームが画面から消され、Dash がパワーアップを獲得したことが反映されます。

ede04fdfe074f471.gif

a3c16fc17be25f6c.pngPlayer クラスの moveLeft メソッドと moveRight メソッドを変更して、NooglerHat スプライトを考慮するようにします。Rocket パワーアップは、移動方向には関係なく同じ方向を向いているので、考慮する必要はありません。

lib/game/sprites/player.dart

 void moveLeft() {
   _hAxisInput = 0;
   if (isWearingHat) {                                       // Add lines from here...
     current = PlayerState.nooglerLeft;
   } else if (!hasPowerup) {                                           // ... to here.
     current = PlayerState.left;
   }                                                                  // Add this line
   _hAxisInput += movingLeftInput;
 }

 void moveRight() {
   _hAxisInput = 0;
   if (isWearingHat) {                                       // Add lines from here...
     current = PlayerState.nooglerRight;
   } else if (!hasPowerup) {                                            //... to here.
     current = PlayerState.right;
   }                                                                  // Add this line
   _hAxisInput += movingRightInput;
 }

Dash が Rocket パワーアップを持っているときは無敵になり、その間はゲーム終了を回避できます。

a3c16fc17be25f6c.pngonCollision コールバックを更新して、Dash が EnemyPlatform と衝突したときに isInvincible が true(無敵状態)かどうかをチェックしてから、ゲームオーバーをトリガーします。

lib/game/sprites/player.dart

   if (other is EnemyPlatform && !isInvincible) {                 // Modify this line
     gameRef.onLose();
     return;
   }

a3c16fc17be25f6c.png アプリを再起動し、ゲームを開始して、パワーアップが機能していることを確認します。

e1fece51429dae55.gif

トラブルシューティング

アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

10. オーバーレイ

Flame ゲームはウィジェットでラップすることができ、それによって Flutter アプリの他のウィジェットといっしょに統合することが簡単になります。Flutter ウィジェットは Flame Game の最前面にオーバーレイとして表示することもできます。これは、メニュー、一時停止画面、ボタン、スライダーなど、ゲームループに無関係な、ゲームプレイ コンポーネント以外のコンポーネントに便利です。

ゲーム中だけでなく Doodle Dash のすべてのメニューにも表示されるスコア表示は、通常の Flutter ウィジェットであり、Flame コンポーネントではありません。ウィジェットのコードはすべて lib/game/widgets にあり、たとえば次のコードのように、ゲームオーバー メニューは、TextElevatedButton のような他のウィジェットを含んだ Column にすぎません。

lib/game/widgets/game_over_overlay.dart

class GameOverOverlay extends StatelessWidget {
 const GameOverOverlay(this.game, {super.key});

 final Game game;

 @override
 Widget build(BuildContext context) {
   return Material(
     color: Theme.of(context).colorScheme.background,
     child: Center(
       child: Padding(
         padding: const EdgeInsets.all(48.0),
         child: Column(
           mainAxisAlignment: MainAxisAlignment.center,
           crossAxisAlignment: CrossAxisAlignment.center,
           children: [
             Text(
               'Game Over',
               style: Theme.of(context).textTheme.displayMedium!.copyWith(),
             ),
             const WhiteSpace(height: 50),
             ScoreDisplay(
               game: game,
               isLight: true,
             ),
             const WhiteSpace(
               height: 50,
             ),
             ElevatedButton(
               onPressed: () {
                 (game as DoodleDash).resetGame();
               },
               style: ButtonStyle(
                 minimumSize: MaterialStateProperty.all(
                   const Size(200, 75),
                 ),
                 textStyle: MaterialStateProperty.all(
                     Theme.of(context).textTheme.titleLarge),
               ),
               child: const Text('Play Again'),
             ),
           ],
         ),
       ),
     ),
   );
 }
}

Flame ゲームでウィジェットをオーバーレイとして使用するには、次のコードのように、GameWidgetoverlayBuilderMap プロパティを、オーバーレイを(String として)表す key と、ウィジェットを返すウィジェット関数である value で定義します。

lib/main.dart

GameWidget(
  game: game,
  overlayBuilderMap: <String, Widget Function(BuildContext, Game)>{
    'gameOverlay': (context, game) => GameOverlay(game),
    'mainMenuOverlay': (context, game) => MainMenuOverlay(game),
    'gameOverOverlay': (context, game) => GameOverOverlay(game),
  },
)

追加すると、オーバーレイはゲーム内のどこでも使用できるようになります。次のコードのように、overlays.add を使用してオーバーレイを表示し、overlays.remove を使用して非表示にします。

lib/game/doodle_dash.dart

void resetGame() {
   startGame();
   overlays.remove('gameOverOverlay');
 }

 void onLose() {
   gameManager.state = GameState.gameOver;
   player.removeFromParent();
   overlays.add('gameOverOverlay');
 }

11. モバイル サポート

Doodle Dash は、Flutter と Flame を土台にして作られているので、すでに Flutter でサポートされているプラットフォームであれば動作するようになっています。しかし今のところ、Doodle Dash は、キーボードベースの入力のみをサポートしています。スマートフォンのようなキーボードのないデバイスでは、オーバーレイにオンスクリーンのタップ操作ボタンを追加するのが簡単です。

a3c16fc17be25f6c.png ゲームがモバイル プラットフォームで実行されていることを示すブール値の状態変数を GameOverlay に追加します。

lib/game/widgets/game_overlay.dart

class GameOverlayState extends State<GameOverlay> {
 bool isPaused = false;

                                                                      // Add this line
 final bool isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);

 @override
 Widget build(BuildContext context) {
   ...
 }
}

次に、ゲームがモバイルで実行されているときに、左右の方向ボタンを表示します。ステップ 4 の「キーイベント」のロジックと同じように、左ボタンをタップすると Dash が左に動きます。右ボタンをタップすると右に動きます。

a3c16fc17be25f6c.png GameOverlaybuild メソッドに、左ボタンをタップしたときに moveLeft を呼び出し、右ボタンで moveRight を呼び出すという、ステップ 4 と同じ動作をする isMobile セクションを追加します。いずれかのボタンを離すと resetDirection が呼び出され、Dash の水平方向の動きが止まります。

lib/game/widgets/game_overlay.dart

@override
 Widget build(BuildContext context) {
   return Material(
     color: Colors.transparent,
     child: Stack(
       children: [
         Positioned(... child: ScoreDisplay(...)),
         Positioned(... child: ElevatedButton(...)),
         if (isMobile)                                       // Add lines from here...
           Positioned(
             bottom: MediaQuery.of(context).size.height / 4,
             child: SizedBox(
               width: MediaQuery.of(context).size.width,
               child: Row(
                 mainAxisAlignment: MainAxisAlignment.spaceBetween,
                 children: [
                   Padding(
                     padding: const EdgeInsets.only(left: 24),
                     child: GestureDetector(
                       onTapDown: (details) {
                         (widget.game as DoodleDash).player.moveLeft();
                       },
                       onTapUp: (details) {
                         (widget.game as DoodleDash).player.resetDirection();
                       },
                       child: Material(
                         color: Colors.transparent,
                         elevation: 3.0,
                         shadowColor: Theme.of(context).colorScheme.background,
                         child: const Icon(Icons.arrow_left, size: 64),
                       ),
                     ),
                   ),
                   Padding(
                     padding: const EdgeInsets.only(right: 24),
                     child: GestureDetector(
                       onTapDown: (details) {
                         (widget.game as DoodleDash).player.moveRight();
                       },
                       onTapUp: (details) {
                         (widget.game as DoodleDash).player.resetDirection();
                       },
                       child: Material(
                         color: Colors.transparent,
                         elevation: 3.0,
                         shadowColor: Theme.of(context).colorScheme.background,
                         child: const Icon(Icons.arrow_right, size: 64),
                       ),
                     ),
                   ),
                 ],
               ),
             ),
           ),                                                          // ... to here.
         if (isPaused)
           ...
       ],
     ),
   );
 }

これで完了です。Doodle Dash アプリが、実行されているプラットフォームの種類を自動的に検出し、それに応じて入力を切り替えるようになりました。

a3c16fc17be25f6c.png iOS か Android でアプリを実行して、方向ボタンが機能していることを確認します。

7b0cac5fb69bc89.gif

トラブルシューティング

アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

12. 次のステップ

おめでとうございます!

この Codelab を修了し、Flame ゲームエンジンを使用して Flutter でゲームを作成する方法を習得しました。

学習した内容

  • 以下のような、Flame パッケージを使用してプラットフォーム ゲームを作成する方法。
  • キャラクターの追加
  • さまざまなプラットフォーム タイプの追加
  • 衝突検出の実装
  • 重力コンポーネントの追加
  • カメラ移動の定義
  • 敵の作成
  • パワーアップの作成
  • ゲームを実行しているプラットフォームの検出方法と、
  • その情報を使用したキーボード制御とタップ入力制御の切り替え

参考資料

ここでは、Flutter でゲームを作成する方法について学習しました。

また、次のリソースも役に立ち、そこからインスピレーションを得られるかもしれません。