Flutter と Flame で 2D 物理ゲームを作成する

1. 始める前に

Flame は Flutter ベースの 2D ゲームエンジンです。この Codelab では、Box2D に似た 2D 物理シミュレーション(Forge2D)を使用するゲームを作成します。Flame のコンポーネントを使用して、ユーザーが操作できるシミュレートされた物理現実を画面に描画します。完了すると、ゲームは次のアニメーション GIF のようになります。

この 2D 物理ゲームのゲームプレイのアニメーション

前提条件

学習内容

  • Forge2D の基本的な仕組みについて、さまざまな種類の物理ボディから説明します。
  • 2D で物理シミュレーションを設定する方法。

必要なもの

選択した開発ターゲット用のコンパイラ ソフトウェア。この Codelab は、Flutter でサポートされている 6 つのプラットフォームすべてで動作します。Windows をターゲットとする場合は Visual Studio、macOS または iOS をターゲットとする場合は Xcode、Android をターゲットとする場合は Android Studio が必要です。

2. プロジェクトを作成する

Flutter プロジェクトを作成する

Flutter プロジェクトを作成する方法はいくつかあります。このセクションでは、簡潔にするためコマンドラインを使用します。

まず、次の手順を行います。

  1. コマンドラインで Flutter プロジェクトを作成します。
    $ flutter create --empty forge2d_game
    Creating project forge2d_game...
    Resolving dependencies in forge2d_game... (4.7s)
    Got dependencies in forge2d_game.
    Wrote 128 files.
    
    All done!
    You can find general documentation for Flutter at: https://docs.flutter.dev/
    Detailed API documentation is available at: https://api.flutter.dev/
    If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev
    
    In order to run your empty application, type:
    
      $ cd forge2d_game
      $ flutter run
    
    Your empty application code is in forge2d_game/lib/main.dart.
    
  2. プロジェクトの依存関係を変更して、Flame と Forge2D を追加します。
    $ cd forge2d_game
    $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
    Resolving dependencies...
    Downloading packages...
      characters 1.4.0 (from transitive dependency to direct dependency)
    + flame 1.29.0
    + flame_forge2d 0.19.0+2
    + flame_kenney_xml 0.1.1+12
      flutter_lints 5.0.0 (6.0.0 available)
    + forge2d 0.14.0
      leak_tracker 10.0.9 (11.0.1 available)
      leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
      leak_tracker_testing 3.0.1 (3.0.2 available)
      lints 5.1.1 (6.0.0 available)
      material_color_utilities 0.11.1 (0.13.0 available)
      meta 1.16.0 (1.17.0 available)
    + ordered_set 8.0.0
    + petitparser 6.1.0 (7.0.0 available)
      test_api 0.7.4 (0.7.6 available)
      vector_math 2.1.4 (2.2.0 available)
      vm_service 15.0.0 (15.0.2 available)
    + xml 6.5.0 (6.6.0 available)
    Changed 8 dependencies!
    12 packages have newer versions incompatible with dependency constraints.
    Try `flutter pub outdated` for more information.
    

flame パッケージはよく知られていますが、他の 3 つについては説明が必要かもしれません。characters パッケージは、UTF8 に準拠した方法でパスを操作するために使用されます。flame_forge2d パッケージは、Flame と連携するように Forge2D 機能を公開します。最後に、xml パッケージは、XML コンテンツの使用と変更のためにさまざまな場所で使用されます。

プロジェクトを開き、lib/main.dart ファイルの内容を次のように置き換えます。

lib/main.dart

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

void main() {
  runApp(GameWidget.controlled(gameFactory: FlameGame.new));
}

これにより、FlameGame インスタンスをインスタンス化する GameWidget でアプリが起動します。この Codelab には、ゲーム インスタンスの状態を使用して実行中のゲームに関する情報を表示する Flutter コードがないため、この簡素化されたブートストラップが適切に機能します。

省略可: macOS 専用のサイドクエストに挑戦する

このプロジェクトのスクリーンショットは、macOS デスクトップ アプリとしてのゲームのものです。アプリのタイトルバーが全体的なエクスペリエンスの邪魔にならないようにするには、macOS ランナーのプロジェクト構成を変更してタイトルバーを省略します。

方法は次のとおりです。

  1. bin/modify_macos_config.dart ファイルを作成し、次の内容を追加します。

bin/modify_macos_config.dart

import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
  final file = File('macos/Runner/Base.lproj/MainMenu.xib');
  var document = XmlDocument.parse(file.readAsStringSync());
  document.xpath('//document/objects/window').first
    ..setAttribute('titlebarAppearsTransparent', 'YES')
    ..setAttribute('titleVisibility', 'hidden');
  document
      .xpath('//document/objects/window/windowStyleMask')
      .first
      .setAttribute('fullSizeContentView', 'YES');
  file.writeAsStringSync(document.toString());
}

このファイルはゲームのランタイム コードベースの一部ではないため、lib ディレクトリにはありません。これは、プロジェクトの変更に使用されるコマンドライン ツールです。

  1. プロジェクトのベース ディレクトリから、次のコマンドを使用してツールを実行します。
dart bin/modify_macos_config.dart

すべてが計画どおりに進んだ場合、プログラムはコマンドラインに出力を生成しません。ただし、タイトルバーが表示されず、Flame ゲームがウィンドウ全体を占有するようにゲームを実行するように macos/Runner/Base.lproj/MainMenu.xib 構成ファイルを変更します。

ゲームを実行して、すべてが正常に動作することを確認します。黒い背景のみの新しいウィンドウが表示されます。

黒い背景でフォアグラウンドに何も表示されていないアプリ ウィンドウ

3. 画像アセットを追加する

画像を追加

どのゲームでも、Find Fun を使用する方法で画面をペイントするには、アートアセットが必要です。この Codelab では、Kenney.nlPhysics Assets パックを使用します。これらのアセットは クリエイティブ コモンズ CC0 ライセンスですが、Kenney のチームが素晴らしい活動を継続できるよう、寄付することを強くおすすめします。助けようとしたわよ。

Kenney のアセットを使用できるようにするには、pubspec.yaml 構成ファイルを変更する必要があります。次のように変更します。

pubspec.yaml

name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.8.1

dependencies:
  flutter:
    sdk: flutter
  characters: ^1.4.0
  flame: ^1.29.0
  flame_forge2d: ^0.19.0+2
  flame_kenney_xml: ^0.1.1+12
  xml: ^6.5.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true
  assets:                        # Add from here
    - assets/
    - assets/images/             # To here.

Flame では、画像アセットが assets/images に配置されていることを前提としていますが、これは別の場所に配置することもできます。詳しくは、Flame の画像のドキュメントをご覧ください。パスが構成されたので、プロジェクト自体に追加する必要があります。その方法の 1 つは、次のようにコマンドラインを使用することです。

mkdir -p assets/images

mkdir コマンドからの出力はありませんが、新しいディレクトリがエディタまたはファイル エクスプローラに表示されます。

ダウンロードした kenney_physics-assets.zip ファイルを開くと、次のように表示されます。

展開された kenney_physics-assets パックのファイル リスト(PNG/Backgrounds ディレクトリがハイライト表示されています)

PNG/Backgrounds ディレクトリから、colored_desert.pngcolored_grass.pngcolored_land.pngcolored_shroom.png の各ファイルをプロジェクトの assets/images ディレクトリにコピーします。

スプライトシートもあります。これは、PNG 画像と、スプライトシート画像のどこに小さな画像が存在するかを記述する XML ファイルの組み合わせです。スプライトシートは、数十個、場合によっては数百個の個々の画像ファイルではなく、1 つのファイルのみを読み込むことで読み込み時間を短縮する手法です。

kenney_physics-assets パックのファイル リストが開き、Spritesheet ディレクトリがハイライト表示されている

spritesheet_aliens.pngspritesheet_elements.pngspritesheet_tiles.png をプロジェクトの assets/images ディレクトリにコピーします。ついでに、spritesheet_aliens.xmlspritesheet_elements.xmlspritesheet_tiles.xml の各ファイルをプロジェクトの assets ディレクトリにコピーします。プロジェクトは次のようになります。

forge2d_game プロジェクト ディレクトリのファイル一覧(アセット ディレクトリがハイライト表示されています)

背景をペイントする

プロジェクトに画像アセットが追加されたので、次は画面に表示します。画面に 1 つの画像が表示されます。詳細については、次のステップで説明します。

lib/components という新しいディレクトリに background.dart というファイルを作成し、次の内容を追加します。

lib/components/background.dart

import 'dart:math';
import 'package:flame/components.dart';
import 'game.dart';

class Background extends SpriteComponent with HasGameReference<MyPhysicsGame> {
  Background({required super.sprite})
    : super(anchor: Anchor.center, position: Vector2(0, 0));

  @override
  void onMount() {
    super.onMount();

    size = Vector2.all(
      max(
        game.camera.visibleWorldRect.width,
        game.camera.visibleWorldRect.height,
      ),
    );
  }
}

このコンポーネントは、特殊な SpriteComponent です。これは、Kenney.nl の 4 つの背景画像のいずれかを表示する役割を担います。このコードには、いくつかの単純化された前提条件があります。1 つ目は、画像が正方形であることです。Kenney の背景画像は 4 つすべて正方形です。2 つ目は、可視ワールドのサイズが決して変更されないことです。変更される場合、このコンポーネントはゲームのサイズ変更イベントを処理する必要があります。3 つ目の前提は、位置 (0,0) が画面の中央にあることです。これらの前提条件を満たすには、ゲームの CameraComponent を特定の方法で構成する必要があります。

lib/components ディレクトリに、game.dart という名前の新しいファイルを作成します。

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
    : super(
        gravity: Vector2(0, 10),
        camera: CameraComponent.withFixedResolution(width: 800, height: 600),
      );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));

    return super.onLoad();
  }
}

多くのことが行われています。MyPhysicsGame クラスから始めます。前の Codelab とは異なり、これは FlameGame ではなく Forge2DGame を拡張します。Forge2DGame 自体は、興味深い調整を加えて FlameGame を拡張しています。1 つ目は、デフォルトで zoom が 10 に設定されていることです。この zoom 設定は、Box2D スタイルの物理シミュレーション エンジンが適切に動作する有効な値の範囲に関係しています。このエンジンは MKS システムを使用して記述されており、単位はメートル、キログラム、秒と想定されています。オブジェクトの計算エラーが目立たない範囲は、0.1 メートルから数十メートルです。ある程度のダウンスケーリングを行わずにピクセル寸法を直接入力すると、Forge2D が有効な範囲外に出てしまいます。要約すると、コーラの缶からバスまでの範囲のオブジェクトをシミュレートすることを考えます。

CameraComponent の解像度を 800 x 600 仮想ピクセルに固定することで、Background コンポーネントで想定された条件が満たされます。つまり、ゲーム領域は (0,0) を中心として、幅 80 単位、高さ 60 単位になります。これは表示解像度には影響しませんが、ゲームシーン内のオブジェクトの配置には影響します。

camera コンストラクタ引数に加えて、物理に沿ったもう 1 つの引数 gravity があります。重力は Vector2 に設定され、x0y10 です。10 は、一般に認められている重力の値である 9.81 メートル / 秒 / 秒に近い近似値です。重力が正の 10 に設定されているということは、このシステムでは Y 軸の向きが下であることを示しています。これは一般的な Box2D とは異なりますが、Flame の通常の構成と一致しています。

次は onLoad メソッドです。このメソッドは非同期です。これは、ディスクから画像アセットを読み込むため適切な方法です。images.load の呼び出しは Future<Image> を返します。また、副作用として、読み込まれた画像を Game オブジェクトにキャッシュします。これらのフューチャーは、Futures.wait 静的メソッドを使用して 1 つのユニットとして集められ、待機されます。返された画像のリストは、パターン マッチングによって個々の名前に分割されます。

スプライトシートの画像は、スプライトシートに含まれる個別に名前が付けられたスプライトを取得する一連の XmlSpriteSheet オブジェクトにフィードされます。XmlSpriteSheet クラスは flame_kenney_xml パッケージで定義されています。

準備が整ったら、lib/main.dart を少し編集するだけで、画像を画面に表示できます。

lib/main.dart

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

import 'components/game.dart';                                    // Add this import

void main() {
  runApp(GameWidget.controlled(gameFactory: MyPhysicsGame.new));  // Modify this line
}

この変更により、ゲームを再度実行して画面に背景が表示されるようになりました。CameraComponent.withFixedResolution() カメラ インスタンスは、ゲームの 800 x 600 の比率が機能するように、必要に応じてレターボックスを追加します。

緑の丘と奇妙な抽象的な木々が描かれたアプリ。

4. 地面を追加します

基盤となるもの

重力がある場合は、ゲーム内のオブジェクトが画面の下端から落ちないようにキャッチする何かが必要です。もちろん、画面から落ちることがゲームデザインの一部である場合は除きます。lib/components ディレクトリに新しい ground.dart ファイルを作成し、次の内容を追加します。

lib/components/ground.dart

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const groundSize = 7.0;

class Ground extends BodyComponent {
  Ground(Vector2 position, Sprite sprite)
    : super(
        renderBody: false,
        bodyDef: BodyDef()
          ..position = position
          ..type = BodyType.static,
        fixtureDefs: [
          FixtureDef(
            PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
            friction: 0.3,
          ),
        ],
        children: [
          SpriteComponent(
            anchor: Anchor.center,
            sprite: sprite,
            size: Vector2.all(groundSize),
            position: Vector2(0, 0),
          ),
        ],
      );
}

この Ground コンポーネントは BodyComponent から派生しています。Forge2D では、ボディが重要です。ボディは 2 次元の物理シミュレーションの一部となるオブジェクトです。このコンポーネントの BodyDef には BodyType.static が指定されています。

Forge2D では、ボディには 3 種類があります。静的ボディは動きません。重量はゼロ(重力に反応しない)と無限(他の物体にぶつかっても、その物体の重さに関係なく動かない)の両方を持つことになります。静的ボディは動かないため、地面のサーフェスに最適です。

残りの 2 つのタイプは、キネマティック ボディとダイナミック ボディです。動的ボディは完全にシミュレートされたボディで、重力や衝突したオブジェクトに反応します。この Codelab の残りの部分では、多くの動的ボディについて説明します。キネマティック ボディは、静的と動的の中間的なものです。動くものの、重力や他の物体にぶつかっても反応しません。便利ですが、この Codelab の範囲外です。

本文自体はほとんど機能しません。ボディに実体を持たせるには、関連するシェイプが必要です。この場合、この本文には 1 つのシェイプ(BoxXY として設定された PolygonShape)が関連付けられています。このタイプのボックスは、回転ポイントを中心に回転できる BoxXY として設定された PolygonShape とは異なり、ワールドに対して軸が整列しています。これは便利ですが、この Codelab の範囲外です。シェイプとボディはフィクスチャで接続されています。これは、friction などのものをシステムに追加する場合に便利です。

デフォルトでは、ボディは、デバッグには便利だがゲームプレイには適さない方法で、アタッチされたシェイプをレンダリングします。super 引数 renderBodyfalse に設定すると、このデバッグ レンダリングが無効になります。このボディにゲーム内レンダリングを適用するのは、子 SpriteComponent の責任です。

Ground コンポーネントをゲームに追加するには、次のように game.dart ファイルを編集します。

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'ground.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
    : super(
        gravity: Vector2(0, 10),
        camera: CameraComponent.withFixedResolution(width: 800, height: 600),
      );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();                                     // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {                               // Add from here...
    return world.addAll([
      for (
        var x = camera.visibleWorldRect.left;
        x < camera.visibleWorldRect.right + groundSize;
        x += groundSize
      )
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }                                                        // To here.
}

この編集では、List コンテキスト内で for ループを使用して、一連の Ground コンポーネントをワールドに追加し、生成された Ground コンポーネントのリストを worldaddAll メソッドに渡します。

ゲームを実行すると、背景と地面が表示されます。

背景と地面レイヤのあるアプリケーション ウィンドウ。

5. レンガを追加します

壁を建てる

地面は静止した物体です。次に、最初の動的コンポーネントを作成します。Forge2D の動的コンポーネントは、プレーヤーの体験の基盤となるものです。動的なコンポーネントは、周囲の世界を移動して操作するものです。このステップでは、画面にランダムに表示される一連のブロックを導入します。落下し、落下中にぶつかり合う様子を確認できます。

レンガは要素のスプライトシートから作成されます。assets/spritesheet_elements.xml のスプライトシートの説明を見ると、興味深い問題があることがわかります。名前があまり役に立たないように思えます。素材の種類、サイズ、損傷の程度でレンガを選択できると便利です。幸い、ある心優しいエルフがファイル名のパターンを解明し、皆さんの作業を楽にするためのツールを作成しました。bin ディレクトリに新しいファイル generate_brick_file_names.dart を作成し、次の内容を追加します。

bin/generate_brick_file_names.dart

import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
  final file = File('assets/spritesheet_elements.xml');
  final rects = <String, Rect>{};
  final document = XmlDocument.parse(file.readAsStringSync());
  for (final node in document.xpath('//TextureAtlas/SubTexture')) {
    final name = node.getAttribute('name')!;
    rects[name] = Rect(
      x: int.parse(node.getAttribute('x')!),
      y: int.parse(node.getAttribute('y')!),
      width: int.parse(node.getAttribute('width')!),
      height: int.parse(node.getAttribute('height')!),
    );
  }
  print(generateBrickFileNames(rects));
}

class Rect extends Equatable {
  final int x;
  final int y;
  final int width;
  final int height;
  const Rect({
    required this.x,
    required this.y,
    required this.width,
    required this.height,
  });

  Size get size => Size(width, height);

  @override
  List<Object?> get props => [x, y, width, height];

  @override
  bool get stringify => true;
}

class Size extends Equatable {
  final int width;
  final int height;
  const Size(this.width, this.height);

  @override
  List<Object?> get props => [width, height];

  @override
  bool get stringify => true;
}

String generateBrickFileNames(Map<String, Rect> rects) {
  final groups = <Size, List<String>>{};
  for (final entry in rects.entries) {
    groups.putIfAbsent(entry.value.size, () => []).add(entry.key);
  }
  final buff = StringBuffer();
  buff.writeln('''
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {''');
  for (final entry in groups.entries) {
    final size = entry.key;
    final entries = entry.value;
    entries.sort();
    for (final type in ['Explosive', 'Glass', 'Metal', 'Stone', 'Wood']) {
      var filtered = entries.where((element) => element.contains(type));
      if (filtered.length == 5) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(0)}',
        BrickDamage.some: '${filtered.elementAt(1)}',
        BrickDamage.lots: '${filtered.elementAt(4)}',
      },''');
      } else if (filtered.length == 10) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(3)}',
        BrickDamage.some: '${filtered.elementAt(4)}',
        BrickDamage.lots: '${filtered.elementAt(9)}',
      },''');
      } else if (filtered.length == 15) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(7)}',
        BrickDamage.some: '${filtered.elementAt(8)}',
        BrickDamage.lots: '${filtered.elementAt(13)}',
      },''');
      }
    }
  }
  buff.writeln('''
  };
}''');
  return buff.toString();
}

エディタに、依存関係がないことを示す警告またはエラーが表示されます。次のコマンドを使用して追加します。

flutter pub add equatable

これで、次のようにしてこのプログラムを実行できるようになります。

$ dart run bin/generate_brick_file_names.dart
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
[Content elided...]
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

このツールは、スプライトシートの説明ファイルを解析し、画面に配置する各ブリックに適した画像ファイルを選択するために使用できる Dart コードに変換します。役に立つ

次の内容の brick.dart ファイルを作成します。

lib/components/brick.dart

import 'dart:math';
import 'dart:ui' as ui;

import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const brickScale = 0.5;

enum BrickType {
  explosive(density: 1, friction: 0.5),
  glass(density: 0.5, friction: 0.2),
  metal(density: 1, friction: 0.4),
  stone(density: 2, friction: 1),
  wood(density: 0.25, friction: 0.6);

  final double density;
  final double friction;

  const BrickType({required this.density, required this.friction});
  static BrickType get randomType => values[Random().nextInt(values.length)];
}

enum BrickSize {
  size70x70(ui.Size(70, 70)),
  size140x70(ui.Size(140, 70)),
  size220x70(ui.Size(220, 70)),
  size70x140(ui.Size(70, 140)),
  size140x140(ui.Size(140, 140)),
  size220x140(ui.Size(220, 140)),
  size140x220(ui.Size(140, 220)),
  size70x220(ui.Size(70, 220));

  final ui.Size size;

  const BrickSize(this.size);

  static BrickSize get randomSize => values[Random().nextInt(values.length)];
}

enum BrickDamage { none, some, lots }

Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
      BrickDamage.none: 'elementExplosive009.png',
      BrickDamage.some: 'elementExplosive012.png',
      BrickDamage.lots: 'elementExplosive050.png',
    },
    (BrickType.glass, BrickSize.size140x70) => {
      BrickDamage.none: 'elementGlass010.png',
      BrickDamage.some: 'elementGlass013.png',
      BrickDamage.lots: 'elementGlass048.png',
    },
    (BrickType.metal, BrickSize.size140x70) => {
      BrickDamage.none: 'elementMetal009.png',
      BrickDamage.some: 'elementMetal012.png',
      BrickDamage.lots: 'elementMetal050.png',
    },
    (BrickType.stone, BrickSize.size140x70) => {
      BrickDamage.none: 'elementStone009.png',
      BrickDamage.some: 'elementStone012.png',
      BrickDamage.lots: 'elementStone047.png',
    },
    (BrickType.wood, BrickSize.size140x70) => {
      BrickDamage.none: 'elementWood011.png',
      BrickDamage.some: 'elementWood014.png',
      BrickDamage.lots: 'elementWood054.png',
    },
    (BrickType.explosive, BrickSize.size70x70) => {
      BrickDamage.none: 'elementExplosive011.png',
      BrickDamage.some: 'elementExplosive014.png',
      BrickDamage.lots: 'elementExplosive049.png',
    },
    (BrickType.glass, BrickSize.size70x70) => {
      BrickDamage.none: 'elementGlass011.png',
      BrickDamage.some: 'elementGlass012.png',
      BrickDamage.lots: 'elementGlass046.png',
    },
    (BrickType.metal, BrickSize.size70x70) => {
      BrickDamage.none: 'elementMetal011.png',
      BrickDamage.some: 'elementMetal014.png',
      BrickDamage.lots: 'elementMetal049.png',
    },
    (BrickType.stone, BrickSize.size70x70) => {
      BrickDamage.none: 'elementStone011.png',
      BrickDamage.some: 'elementStone014.png',
      BrickDamage.lots: 'elementStone046.png',
    },
    (BrickType.wood, BrickSize.size70x70) => {
      BrickDamage.none: 'elementWood010.png',
      BrickDamage.some: 'elementWood013.png',
      BrickDamage.lots: 'elementWood045.png',
    },
    (BrickType.explosive, BrickSize.size220x70) => {
      BrickDamage.none: 'elementExplosive013.png',
      BrickDamage.some: 'elementExplosive016.png',
      BrickDamage.lots: 'elementExplosive051.png',
    },
    (BrickType.glass, BrickSize.size220x70) => {
      BrickDamage.none: 'elementGlass014.png',
      BrickDamage.some: 'elementGlass017.png',
      BrickDamage.lots: 'elementGlass049.png',
    },
    (BrickType.metal, BrickSize.size220x70) => {
      BrickDamage.none: 'elementMetal013.png',
      BrickDamage.some: 'elementMetal016.png',
      BrickDamage.lots: 'elementMetal051.png',
    },
    (BrickType.stone, BrickSize.size220x70) => {
      BrickDamage.none: 'elementStone013.png',
      BrickDamage.some: 'elementStone016.png',
      BrickDamage.lots: 'elementStone048.png',
    },
    (BrickType.wood, BrickSize.size220x70) => {
      BrickDamage.none: 'elementWood012.png',
      BrickDamage.some: 'elementWood015.png',
      BrickDamage.lots: 'elementWood047.png',
    },
    (BrickType.explosive, BrickSize.size70x140) => {
      BrickDamage.none: 'elementExplosive017.png',
      BrickDamage.some: 'elementExplosive022.png',
      BrickDamage.lots: 'elementExplosive052.png',
    },
    (BrickType.glass, BrickSize.size70x140) => {
      BrickDamage.none: 'elementGlass018.png',
      BrickDamage.some: 'elementGlass023.png',
      BrickDamage.lots: 'elementGlass050.png',
    },
    (BrickType.metal, BrickSize.size70x140) => {
      BrickDamage.none: 'elementMetal017.png',
      BrickDamage.some: 'elementMetal022.png',
      BrickDamage.lots: 'elementMetal052.png',
    },
    (BrickType.stone, BrickSize.size70x140) => {
      BrickDamage.none: 'elementStone017.png',
      BrickDamage.some: 'elementStone022.png',
      BrickDamage.lots: 'elementStone049.png',
    },
    (BrickType.wood, BrickSize.size70x140) => {
      BrickDamage.none: 'elementWood016.png',
      BrickDamage.some: 'elementWood021.png',
      BrickDamage.lots: 'elementWood048.png',
    },
    (BrickType.explosive, BrickSize.size140x140) => {
      BrickDamage.none: 'elementExplosive018.png',
      BrickDamage.some: 'elementExplosive023.png',
      BrickDamage.lots: 'elementExplosive053.png',
    },
    (BrickType.glass, BrickSize.size140x140) => {
      BrickDamage.none: 'elementGlass019.png',
      BrickDamage.some: 'elementGlass024.png',
      BrickDamage.lots: 'elementGlass051.png',
    },
    (BrickType.metal, BrickSize.size140x140) => {
      BrickDamage.none: 'elementMetal018.png',
      BrickDamage.some: 'elementMetal023.png',
      BrickDamage.lots: 'elementMetal053.png',
    },
    (BrickType.stone, BrickSize.size140x140) => {
      BrickDamage.none: 'elementStone018.png',
      BrickDamage.some: 'elementStone023.png',
      BrickDamage.lots: 'elementStone050.png',
    },
    (BrickType.wood, BrickSize.size140x140) => {
      BrickDamage.none: 'elementWood017.png',
      BrickDamage.some: 'elementWood022.png',
      BrickDamage.lots: 'elementWood049.png',
    },
    (BrickType.explosive, BrickSize.size220x140) => {
      BrickDamage.none: 'elementExplosive019.png',
      BrickDamage.some: 'elementExplosive024.png',
      BrickDamage.lots: 'elementExplosive054.png',
    },
    (BrickType.glass, BrickSize.size220x140) => {
      BrickDamage.none: 'elementGlass020.png',
      BrickDamage.some: 'elementGlass025.png',
      BrickDamage.lots: 'elementGlass052.png',
    },
    (BrickType.metal, BrickSize.size220x140) => {
      BrickDamage.none: 'elementMetal019.png',
      BrickDamage.some: 'elementMetal024.png',
      BrickDamage.lots: 'elementMetal054.png',
    },
    (BrickType.stone, BrickSize.size220x140) => {
      BrickDamage.none: 'elementStone019.png',
      BrickDamage.some: 'elementStone024.png',
      BrickDamage.lots: 'elementStone051.png',
    },
    (BrickType.wood, BrickSize.size220x140) => {
      BrickDamage.none: 'elementWood018.png',
      BrickDamage.some: 'elementWood023.png',
      BrickDamage.lots: 'elementWood050.png',
    },
    (BrickType.explosive, BrickSize.size70x220) => {
      BrickDamage.none: 'elementExplosive020.png',
      BrickDamage.some: 'elementExplosive025.png',
      BrickDamage.lots: 'elementExplosive055.png',
    },
    (BrickType.glass, BrickSize.size70x220) => {
      BrickDamage.none: 'elementGlass021.png',
      BrickDamage.some: 'elementGlass026.png',
      BrickDamage.lots: 'elementGlass053.png',
    },
    (BrickType.metal, BrickSize.size70x220) => {
      BrickDamage.none: 'elementMetal020.png',
      BrickDamage.some: 'elementMetal025.png',
      BrickDamage.lots: 'elementMetal055.png',
    },
    (BrickType.stone, BrickSize.size70x220) => {
      BrickDamage.none: 'elementStone020.png',
      BrickDamage.some: 'elementStone025.png',
      BrickDamage.lots: 'elementStone052.png',
    },
    (BrickType.wood, BrickSize.size70x220) => {
      BrickDamage.none: 'elementWood019.png',
      BrickDamage.some: 'elementWood024.png',
      BrickDamage.lots: 'elementWood051.png',
    },
    (BrickType.explosive, BrickSize.size140x220) => {
      BrickDamage.none: 'elementExplosive021.png',
      BrickDamage.some: 'elementExplosive026.png',
      BrickDamage.lots: 'elementExplosive056.png',
    },
    (BrickType.glass, BrickSize.size140x220) => {
      BrickDamage.none: 'elementGlass022.png',
      BrickDamage.some: 'elementGlass027.png',
      BrickDamage.lots: 'elementGlass054.png',
    },
    (BrickType.metal, BrickSize.size140x220) => {
      BrickDamage.none: 'elementMetal021.png',
      BrickDamage.some: 'elementMetal026.png',
      BrickDamage.lots: 'elementMetal056.png',
    },
    (BrickType.stone, BrickSize.size140x220) => {
      BrickDamage.none: 'elementStone021.png',
      BrickDamage.some: 'elementStone026.png',
      BrickDamage.lots: 'elementStone053.png',
    },
    (BrickType.wood, BrickSize.size140x220) => {
      BrickDamage.none: 'elementWood020.png',
      BrickDamage.some: 'elementWood025.png',
      BrickDamage.lots: 'elementWood052.png',
    },
  };
}

class Brick extends BodyComponent {
  Brick({
    required this.type,
    required this.size,
    required BrickDamage damage,
    required Vector2 position,
    required Map<BrickDamage, Sprite> sprites,
  }) : _damage = damage,
       _sprites = sprites,
       super(
         renderBody: false,
         bodyDef: BodyDef()
           ..position = position
           ..type = BodyType.dynamic,
         fixtureDefs: [
           FixtureDef(
               PolygonShape()..setAsBoxXY(
                 size.size.width / 20 * brickScale,
                 size.size.height / 20 * brickScale,
               ),
             )
             ..restitution = 0.4
             ..density = type.density
             ..friction = type.friction,
         ],
       );

  late final SpriteComponent _spriteComponent;

  final BrickType type;
  final BrickSize size;
  final Map<BrickDamage, Sprite> _sprites;

  BrickDamage _damage;
  BrickDamage get damage => _damage;
  set damage(BrickDamage value) {
    _damage = value;
    _spriteComponent.sprite = _sprites[value];
  }

  @override
  Future<void> onLoad() {
    _spriteComponent = SpriteComponent(
      anchor: Anchor.center,
      scale: Vector2.all(1),
      sprite: _sprites[_damage],
      size: size.size.toVector2() / 10 * brickScale,
      position: Vector2(0, 0),
    );
    add(_spriteComponent);
    return super.onLoad();
  }
}

これで、前回生成した Dart コードがこのコードベースに統合され、素材、サイズ、状態に基づいてレンガの画像をすばやく選択できるようになりました。enum を越えて Brick コンポーネント自体に目を向けると、このコードのほとんどは、前の手順の Ground コンポーネントとかなり似ていることがわかります。レンガが損傷する可能性があるため、変更可能な状態が設定されていますが、この使用は読者の課題として残しておきます。

次は、画面にブロックを表示します。game.dart ファイルを次のように編集します。

lib/components/game.dart

import 'dart:async';
import 'dart:math';                                        // Add this import

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';                                       // Add this import
import 'ground.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
    : super(
        gravity: Vector2(0, 10),
        camera: CameraComponent.withFixedResolution(width: 800, height: 600),
      );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks());                                // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (
        var x = camera.visibleWorldRect.left;
        x < camera.visibleWorldRect.right + groundSize;
        x += groundSize
      )
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();                                // Add from here...

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
            camera.visibleWorldRect.right / 3 +
                (_random.nextDouble() * 5 - 2.5),
            0,
          ),
          sprites: brickFileNames(
            type,
            size,
          ).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }                                                        // To here.
}

この追加コードは、Ground コンポーネントの追加に使用したコードとは少し異なります。今回は、Brick が時間の経過とともにランダムなクラスタに追加されます。これには 2 つの部分があります。1 つは、Brick を追加するメソッドが awaitFuture.delayed に追加することです。これは、sleep() 呼び出しの非同期同等物です。ただし、この仕組みを機能させるには、もう 1 つ条件があります。onLoad メソッドの addBricks への呼び出しは await 化されていません。await 化されていると、すべてのブリックが画面に表示されるまで onLoad メソッドは完了しません。addBricks への呼び出しを unawaited 呼び出しでラップすると、リンタが満足し、将来のプログラマにとって意図が明確になります。このメソッドが返されるのを待たないことは意図的です。

ゲームを実行すると、レンガが現れ、ぶつかり合って地面に散らばります。

背景に緑の丘、地面レイヤ、地面に着地するブロックがあるアプリ ウィンドウ。

6. プレーヤーを追加する

レンガにエイリアンを投げる

最初のうちはレンガが崩れ落ちるのを見るのは楽しいですが、プレイヤーが世界とやり取りするために使用できるアバターを用意すれば、このゲームはもっと楽しくなると思います。レンガに投げつけるエイリアンはどうですか?

lib/components ディレクトリに新しい player.dart ファイルを作成し、次の内容を追加します。

lib/components/player.dart

import 'dart:math';

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

const playerSize = 5.0;

enum PlayerColor {
  pink,
  blue,
  green,
  yellow;

  static PlayerColor get randomColor =>
      PlayerColor.values[Random().nextInt(PlayerColor.values.length)];

  String get fileName =>
      'alien${toString().split('.').last.capitalize}_round.png';
}

class Player extends BodyComponent with DragCallbacks {
  Player(Vector2 position, Sprite sprite)
    : _sprite = sprite,
      super(
        renderBody: false,
        bodyDef: BodyDef()
          ..position = position
          ..type = BodyType.static
          ..angularDamping = 0.1
          ..linearDamping = 0.1,
        fixtureDefs: [
          FixtureDef(CircleShape()..radius = playerSize / 2)
            ..restitution = 0.4
            ..density = 0.75
            ..friction = 0.5,
        ],
      );

  final Sprite _sprite;

  @override
  Future<void> onLoad() {
    addAll([
      CustomPainterComponent(
        painter: _DragPainter(this),
        anchor: Anchor.center,
        size: Vector2(playerSize, playerSize),
        position: Vector2(0, 0),
      ),
      SpriteComponent(
        anchor: Anchor.center,
        sprite: _sprite,
        size: Vector2(playerSize, playerSize),
        position: Vector2(0, 0),
      ),
    ]);
    return super.onLoad();
  }

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

    if (!body.isAwake) {
      removeFromParent();
    }

    if (position.x > camera.visibleWorldRect.right + 10 ||
        position.x < camera.visibleWorldRect.left - 10) {
      removeFromParent();
    }
  }

  Vector2 _dragStart = Vector2.zero();
  Vector2 _dragDelta = Vector2.zero();
  Vector2 get dragDelta => _dragDelta;

  @override
  void onDragStart(DragStartEvent event) {
    super.onDragStart(event);
    if (body.bodyType == BodyType.static) {
      _dragStart = event.localPosition;
    }
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    if (body.bodyType == BodyType.static) {
      _dragDelta = event.localEndPosition - _dragStart;
    }
  }

  @override
  void onDragEnd(DragEndEvent event) {
    super.onDragEnd(event);
    if (body.bodyType == BodyType.static) {
      children
          .whereType<CustomPainterComponent>()
          .firstOrNull
          ?.removeFromParent();
      body.setType(BodyType.dynamic);
      body.applyLinearImpulse(_dragDelta * -50);
      add(RemoveEffect(delay: 5.0));
    }
  }
}

extension on String {
  String get capitalize =>
      characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

class _DragPainter extends CustomPainter {
  _DragPainter(this.player);

  final Player player;

  @override
  void paint(Canvas canvas, Size size) {
    if (player.dragDelta != Vector2.zero()) {
      var center = size.center(Offset.zero);
      canvas.drawLine(
        center,
        center + (player.dragDelta * -1).toOffset(),
        Paint()
          ..color = Colors.orange.withAlpha(180)
          ..strokeWidth = 0.4
          ..strokeCap = StrokeCap.round,
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

これは、前のステップの Brick コンポーネントよりも進化したものです。この Player コンポーネントには、認識すべき SpriteComponent と新しい CustomPainterComponent の 2 つの子コンポーネントがあります。CustomPainter コンセプトは Flutter から派生したもので、キャンバスにペイントできます。ここでは、投げたときに丸いエイリアンが飛ぶ方向をプレーヤーにフィードバックするために使用されています。

プレイヤーはどのようにエイリアンを投げ飛ばしますか?ドラッグ操作。Player コンポーネントは DragCallbacks コールバックでドラッグ操作を検出します。よく見ると、他にも変更点があります。

Ground コンポーネントは静的ボディでしたが、Brick コンポーネントは動的ボディでした。このプレーヤーは、両方の組み合わせです。プレーヤーは最初は静止状態になっていて、ユーザーがドラッグするのを待ちます。ドラッグを離すと、静止状態から動的状態に変換され、ドラッグに比例したリニア インパルスが追加され、エイリアンのアバターが飛行します。

また、Player コンポーネントには、境界外に出た場合、スリープ状態になった場合、タイムアウトした場合に画面から削除するコードも含まれています。プレイヤーがエイリアンを投げて、どうなるかを確認してから、もう一度試すことができます。

次のように game.dart を編集して、Player コンポーネントをゲームに統合します。

lib/components/game.dart

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

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';
import 'ground.dart';
import 'player.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
    : super(
        gravity: Vector2(0, 10),
        camera: CameraComponent.withFixedResolution(width: 800, height: 600),
      );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks());
    await addPlayer();                                     // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (
        var x = camera.visibleWorldRect.left;
        x < camera.visibleWorldRect.right + groundSize;
        x += groundSize
      )
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
            camera.visibleWorldRect.right / 3 +
                (_random.nextDouble() * 5 - 2.5),
            0,
          ),
          sprites: brickFileNames(
            type,
            size,
          ).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }

  Future<void> addPlayer() async => world.add(             // Add from here...
    Player(
      Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
      aliens.getSprite(PlayerColor.randomColor.fileName),
    ),
  );

  @override
  void update(double dt) {
    super.update(dt);
    if (isMounted && world.children.whereType<Player>().isEmpty) {
      addPlayer();
    }
  }                                                        // To here.
}

プレーヤーをゲームに追加する方法は、これまでのコンポーネントと似ていますが、1 つだけ違いがあります。プレーヤーのエイリアンは、特定の条件下でゲームから削除されるように設計されているため、ゲームに Player コンポーネントが存在しないかどうかを確認し、存在しない場合は 1 つ追加する更新ハンドラが用意されています。ゲームの実行は次のようになります。

背景に緑の丘、地面レイヤ、地面にブロック、飛行中のプレーヤー アバターがあるアプリ ウィンドウ。

7. 影響に反応する

敵を追加する

静的オブジェクトと動的オブジェクトが相互に作用する様子を確認しました。ただし、本当に何かを成し遂げるには、競合が発生したときにコードでコールバックを取得する必要があります。プレイヤーが戦う敵を導入します。これにより、勝利条件への道が開かれます。ゲームからすべての敵を排除しましょう。

lib/components ディレクトリに enemy.dart ファイルを作成し、次の内容を追加します。

lib/components/enemy.dart

import 'dart:math';

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

import 'body_component_with_user_data.dart';

const enemySize = 5.0;

enum EnemyColor {
  pink(color: 'pink', boss: false),
  blue(color: 'blue', boss: false),
  green(color: 'green', boss: false),
  yellow(color: 'yellow', boss: false),
  pinkBoss(color: 'pink', boss: true),
  blueBoss(color: 'blue', boss: true),
  greenBoss(color: 'green', boss: true),
  yellowBoss(color: 'yellow', boss: true);

  final bool boss;
  final String color;

  const EnemyColor({required this.color, required this.boss});

  static EnemyColor get randomColor =>
      EnemyColor.values[Random().nextInt(EnemyColor.values.length)];

  String get fileName =>
      'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}

class Enemy extends BodyComponentWithUserData with ContactCallbacks {
  Enemy(Vector2 position, Sprite sprite)
    : super(
        renderBody: false,
        bodyDef: BodyDef()
          ..position = position
          ..type = BodyType.dynamic,
        fixtureDefs: [
          FixtureDef(
            PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
            friction: 0.3,
          ),
        ],
        children: [
          SpriteComponent(
            anchor: Anchor.center,
            sprite: sprite,
            size: Vector2.all(enemySize),
            position: Vector2(0, 0),
          ),
        ],
      );

  @override
  void beginContact(Object other, Contact contact) {
    var interceptVelocity =
        (contact.bodyA.linearVelocity - contact.bodyB.linearVelocity).length
            .abs();
    if (interceptVelocity > 35) {
      removeFromParent();
    }

    super.beginContact(other, contact);
  }

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

    if (position.x > camera.visibleWorldRect.right + 10 ||
        position.x < camera.visibleWorldRect.left - 10) {
      removeFromParent();
    }
  }
}

extension on String {
  String get capitalize =>
      characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

Player コンポーネントと Brick コンポーネントの操作をすでに行っている場合は、このファイルのほとんどが馴染みがあるはずです。ただし、新しい不明なベースクラスが原因で、エディタにいくつかの赤い下線が表示されます。次の内容の body_component_with_user_data.dart という名前のファイルを lib/components に追加して、このクラスを追加します。

lib/components/body_component_with_user_data.dart

import 'package:flame_forge2d/flame_forge2d.dart';

class BodyComponentWithUserData extends BodyComponent {
  BodyComponentWithUserData({
    super.key,
    super.bodyDef,
    super.children,
    super.fixtureDefs,
    super.paint,
    super.priority,
    super.renderBody,
  });

  @override
  Body createBody() {
    final body = world.createBody(super.bodyDef!)..userData = this;
    fixtureDefs?.forEach(body.createFixture);
    return body;
  }
}

このベースクラスと、Enemy コンポーネントの新しい beginContact コールバックを組み合わせることで、ボディ間の衝突をプログラムで通知する基盤が形成されます。実際、影響に関する通知を受け取るコンポーネントを編集する必要があります。BrickGroundPlayer コンポーネントを編集して、これらのコンポーネントで使用している BodyComponent 基本クラスの代わりにこの BodyComponentWithUserData を使用するようにします。たとえば、Ground コンポーネントを編集する方法は次のとおりです。

lib/components/ground.dart

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

import 'body_component_with_user_data.dart';               // Add this import

const groundSize = 7.0;

class Ground extends BodyComponentWithUserData {           // Edit this line
  Ground(Vector2 position, Sprite sprite)
    : super(
        renderBody: false,
        bodyDef: BodyDef()
          ..position = position
          ..type = BodyType.static,
        fixtureDefs: [
          FixtureDef(
            PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
            friction: 0.3,
          ),
        ],
        children: [
          SpriteComponent(
            anchor: Anchor.center,
            sprite: sprite,
            size: Vector2.all(groundSize),
            position: Vector2(0, 0),
          ),
        ],
      );
}

Forge2d が接触を処理する方法の詳細については、接触コールバックに関する Forge2D のドキュメントをご覧ください。

ゲームに勝つ

敵と、世界から敵を除去する方法ができました。このシミュレーションをゲームに変換する簡単な方法があります。すべての敵を倒すことを目標にしましょう。次のように game.dart ファイルを編集します。

lib/components/game.dart

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

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'package:flutter/material.dart';                    // Add this import

import 'background.dart';
import 'brick.dart';
import 'enemy.dart';                                       // Add this import
import 'ground.dart';
import 'player.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
    : super(
        gravity: Vector2(0, 10),
        camera: CameraComponent.withFixedResolution(width: 800, height: 600),
      );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks().then((_) => addEnemies()));      // Modify this line
    await addPlayer();

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (
        var x = camera.visibleWorldRect.left;
        x < camera.visibleWorldRect.right + groundSize;
        x += groundSize
      )
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
            camera.visibleWorldRect.right / 3 +
                (_random.nextDouble() * 5 - 2.5),
            0,
          ),
          sprites: brickFileNames(
            type,
            size,
          ).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }

  Future<void> addPlayer() async => world.add(
    Player(
      Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
      aliens.getSprite(PlayerColor.randomColor.fileName),
    ),
  );

  @override
  void update(double dt) {
    super.update(dt);
    if (isMounted &&                                       // Modify from here...
        world.children.whereType<Player>().isEmpty &&
        world.children.whereType<Enemy>().isNotEmpty) {
      addPlayer();
    }
    if (isMounted &&
        enemiesFullyAdded &&
        world.children.whereType<Enemy>().isEmpty &&
        world.children.whereType<TextComponent>().isEmpty) {
      world.addAll(
        [
          (position: Vector2(0.5, 0.5), color: Colors.white),
          (position: Vector2.zero(), color: Colors.orangeAccent),
        ].map(
          (e) => TextComponent(
            text: 'You win!',
            anchor: Anchor.center,
            position: e.position,
            textRenderer: TextPaint(
              style: TextStyle(color: e.color, fontSize: 16),
            ),
          ),
        ),
      );
    }
  }

  var enemiesFullyAdded = false;

  Future<void> addEnemies() async {
    await Future<void>.delayed(const Duration(seconds: 2));
    for (var i = 0; i < 3; i++) {
      await world.add(
        Enemy(
          Vector2(
            camera.visibleWorldRect.right / 3 +
                (_random.nextDouble() * 7 - 3.5),
            (_random.nextDouble() * 3),
          ),
          aliens.getSprite(EnemyColor.randomColor.fileName),
        ),
      );
      await Future<void>.delayed(const Duration(seconds: 1));
    }
    enemiesFullyAdded = true;                              // To here.
  }
}

ゲームを実行して、この画面に到達することが今回のチャレンジです。

背景に緑色の丘、地面レイヤ、地面にブロックがあり、「You win!」というテキスト オーバーレイが表示されているアプリ ウィンドウ。

8. 完了

これで、Flutter と Flame でゲームを作成できました。

Flame 2D ゲームエンジンを使用してゲームを作成し、Flutter ラッパーに埋め込みました。Flame のエフェクトを使用して、コンポーネントをアニメーション化して削除しました。Google Fonts パッケージと Flutter Animate パッケージを使用して、ゲーム全体をデザイン性の高いものにしました。

次のステップ

以下の Codelab をご覧ください。

関連情報