1. 始める前に
Flame は、Flutter ベースの 2D ゲームエンジンです。この Codelab では、Box2D のラインに沿って 2D 物理シミュレーションを使用する Forge2D というゲームを作成します。Flame のコンポーネントを使用して、ユーザーが操作できるようにシミュレートされた現実を画面に描画します。完了すると、ゲームは次のアニメーション GIF のようになります。
前提条件
- Flutter を使用した Flame の概要 Codelab を修了している
学習内容
- Forge2D の基本的な仕組み、まずはさまざまなタイプの身体について。
- 2D の物理シミュレーションをセットアップする方法
必要なもの
選択した開発ターゲットのコンパイラ ソフトウェア。この Codelab は、Flutter がサポートする 6 つのプラットフォームすべてで機能します。Windows を対象とするには Visual Studio、macOS または iOS を対象とするには Xcode、Android をターゲットとするには Android Studio が必要です。
2. プロジェクトを作成する
Flutter プロジェクトを作成する
Flutter プロジェクトの作成方法はたくさんあります。簡潔にするために、このセクションではコマンドラインを使用します。
まず、次の手順に従います。
- コマンドラインで 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.
- プロジェクトの依存関係を変更して Flame と Forge2D を追加します。
$ cd forge2d_game $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml Resolving dependencies... Downloading packages... characters 1.3.0 (from transitive dependency to direct dependency) collection 1.18.0 (1.19.0 available) + flame 1.18.0 + flame_forge2d 0.18.1 + flame_kenney_xml 0.1.0 flutter_lints 3.0.2 (4.0.0 available) + forge2d 0.13.0 leak_tracker 10.0.4 (10.0.5 available) leak_tracker_flutter_testing 3.0.3 (3.0.5 available) lints 3.0.0 (4.0.0 available) material_color_utilities 0.8.0 (0.12.0 available) meta 1.12.0 (1.15.0 available) + ordered_set 5.0.3 (6.0.1 available) + petitparser 6.0.2 test_api 0.7.0 (0.7.3 available) vm_service 14.2.1 (14.2.4 available) + xml 6.5.0 Changed 8 dependencies! 10 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 ランナーのプロジェクト構成を変更してタイトルバーを非表示にすることができます。
方法は次のとおりです。
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
ディレクトリには存在しません。プロジェクトの変更に使用するコマンドライン ツールです。
- プロジェクトのベース ディレクトリから、次のようにツールを実行します。
$ dart bin/modify_macos_config.dart
すべて計画どおりに進めば、コマンドラインに出力は生成されません。ただし、macos/Runner/Base.lproj/MainMenu.xib
構成ファイルを変更して、タイトルバーを表示せずにゲームを実行し、Flame ゲームがウィンドウ全体を占めるようにします。
ゲームを実行して、すべてが機能していることを確認します。空白の黒い背景のみで新しいウィンドウが表示されます。
3. 画像アセットを追加する
画像を追加
どんなゲームでも、楽しい発見機能で画面をペイントするにはアートアセットが必要です。この Codelab では、Kenney.nl の Physics Assets パックを使用します。これらのアセットにはクリエイティブ・コモンズ CC0 のライセンスが付与されていますが、ケニー大学のチームには、その取り組みを継続できるように寄付を強くおすすめします。助けようとしたわよ。
Kenney のアセットを使用できるようにするには、pubspec.yaml
構成ファイルを変更する必要があります。これを次のように変更します。
pubspec.yaml
name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.3 <4.0.0'
dependencies:
characters: ^1.3.0
flame: ^1.17.0
flame_forge2d: ^0.18.0
flutter:
sdk: flutter
xml: ^6.5.0
dev_dependencies:
flutter_lints: ^3.0.0
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
assets: # Add from here
- assets/
- assets/images/ # To here.
Flame では画像アセットが assets/images
にあることが想定されていますが、別の構成も可能です。詳しくは、Flame の Images のドキュメントをご覧ください。パスが構成されたので、パスをプロジェクト自体に追加する必要があります。そのための 1 つの方法は、次のようなコマンドラインを使用することです。
$ mkdir -p assets/images
mkdir
コマンドの出力は表示されませんが、新しいディレクトリがエディタまたはファイル エクスプローラに表示されます。
ダウンロードした kenney_physics-assets.zip
ファイルを開くと、次のように表示されます。
PNG/Backgrounds
ディレクトリから、colored_desert.png
、colored_grass.png
、colored_land.png
、colored_shroom.png
ファイルをプロジェクトの assets/images
ディレクトリにコピーします。
スプライト シートもあります。PNG 画像と XML ファイルの組み合わせで、スプライトシート画像内でより小さい画像が存在する場所が記述されます。スプライトシートは、数百(数百ではないにしても数十)の個別画像ファイルではなく、1 つのファイルだけを読み込むことで読み込み時間を短縮する手法です。
spritesheet_aliens.png
、spritesheet_elements.png
、spritesheet_tiles.png
をプロジェクトの assets/images
ディレクトリにコピーします。この画面で、spritesheet_aliens.xml
、spritesheet_elements.xml
、spritesheet_tiles.xml
の各ファイルをプロジェクトの assets
ディレクトリにコピーします。プロジェクトは次のようになります。
背景をペイントする
プロジェクトに画像アセットを追加したので、次は画面上に画像アセットを配置します。画面に 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 つを表示する役割を担っています。このコードには単純化のための前提条件がいくつかあります。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
コンストラクタ引数の隣には、物理学的に調整された gravity
という引数があります。Gravity は、x
が 0、y
が 10 の Vector2
に設定されています。10 は、重力として一般的に許容されている毎秒 9.81 メートルの値に近似しています。重力が正の 10 に設定されていることは、このシステムでは Y 軸の方向が下向きであることを示しています。これは一般的に Box2D とは異なりますが、Flame の通常の構成方法に沿ったものです。
次は onLoad
メソッドです。このメソッドは非同期です。ディスクから画像アセットを読み込むため、この方法が適しています。images.load
を呼び出すと Future<Image>
が返され、副作用として読み込まれた画像を Game オブジェクトにキャッシュに保存します。これらの Future は、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 つのシェイプが関連付けられており、PolygonShape
が BoxXY
に設定されています。このタイプのボックスは、回転ポイントを中心に回転できる BoxXY
として設定された PolygonShape
とは異なり、世界と軸を合わせた軸になります。ここでも有用ですが、この Codelab の範囲外でもあります。シェイプとボディは固定具で接続されます。これは、friction
などをシステムに追加する場合に便利です。
デフォルトでは、ボディはアタッチされたシェイプをデバッグには役立つ方法でレンダリングしますが、ゲームプレイには適していません。super
引数の renderBody
を false
に設定すると、このデバッグ レンダリングが無効になります。このボディをゲーム内レンダリングするのは、子 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
コンポーネントの結果リストを world
の addAll
メソッドに渡すことで、一連の Ground
コンポーネントをワールドに追加します。
ゲームを実行すると、背景と地面が表示されます。
5. ブロックを追加する
壁を作る
地面からは静止した物体の例が見えました。次に、最初の動的コンポーネントを作成します。Forge2D の動的コンポーネントはプレーヤー エクスペリエンスの基盤となるもので、周りの世界と相互作用して移動します。このステップでは、レンガを導入します。レンガはランダムに選択され、レンガのクラスタとして画面に表示されます。2 匹が落ちてぶつかる様子が見られます。
レンガは要素スプライト シートから作成されます。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
を追加するメソッドが Future.delayed
を await
することです。これは、sleep()
呼び出しの非同期的な呼び出しです。ただし、これを機能させるもう 1 つの部分があります。onLoad
メソッド内の addBricks
の呼び出しは 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.withOpacity(0.7)
..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.
}
プレーヤーをゲームに追加する手順は前のコンポーネントと似ていますが、少し違いがあります。プレーヤーのエイリアンは、特定の条件下で自身をゲームから削除するように設計されています。そのため、ここには、ゲームに Player
コンポーネントがないかどうかを確認し、存在しない場合は再度追加する更新ハンドラがあります。ゲームの実行は次のようになります。
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
コールバックを組み合わせることで、本体間の影響についてプログラムで通知を受け取れます。実際、影響の通知を受け取りたいコンポーネントを編集する必要があります。Brick
、Ground
、Player
の各コンポーネントを編集して、これらのコンポーネントが現在使用している 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.
}
}
チャレンジを承諾すると、ゲームを実行してこの画面に到達します。
8. 完了
これで、Flutter と Flame でゲームを作成することができました。
Flame 2D ゲームエンジンを使用してゲームを作成し、Flutter ラッパーに埋め込みました。Flame のエフェクトを使用して、コンポーネントのアニメーション化と削除を行いました。Google Fonts と Flutter Animate パッケージを使用して、ゲーム全体を効果的にデザインしました。
次のステップ
以下の Codelab をご覧ください。