paint-brush
Flutter と Flame を使用した MVP ゲーム開発@leobit
544 測定値
544 測定値

Flutter と Flame を使用した MVP ゲーム開発

Leobit28m2024/06/06
Read on Terminal Reader

長すぎる; 読むには

MVP (最小限の実行可能な製品) は、ゲーム業界でますます人気が高まっています。MVP を使用すると、短期間で限られた予算で基本的な機能を備えたアプリを構築できます。Flutter 上に構築された堅牢なオープンソース ゲーム エンジンである Flame の助けを借りて、魅力的な 2D ゲームを作成できます。
featured image - Flutter と Flame を使用した MVP ゲーム開発
Leobit HackerNoon profile picture
0-item

最近の調査によると、収益を上げているスタートアップは 5 社中 2 社に過ぎません。MVP (最小限の実行可能な製品) を使用すると、完全な機能を備えたアプリに全予算を費やすことなく、早期にユーザーからのフィードバックを収集できるため、スタートアップの収益性が大幅に高まります。


MVP を使用すると、短期間で限られた予算内で基本的な機能を備えたアプリを構築し、ユーザーからのフィードバックを収集し、そのフィードバックに基づいて開発チームと連携してソリューションを拡張し続けることができます。


MVP はゲーム業界でますます人気が高まっています。今日は、クロスプラットフォームで最小限の実行可能な製品を構築するための優れた組み合わせである Flutter と Flame を使用した迅速なゲーム MVP 開発の詳細について説明します。

Flutter と Flame を選ぶ理由

クロスプラットフォーム開発のための機能満載で安全なプラットフォームであるFlutter は、モバイル アプリの世界に旋風を巻き起こし、その影響は UI をはるかに超えています。Flutter 上に構築された堅牢なオープンソース ゲーム エンジンであるFlameを使用すると、Android、iOS、Web、デスクトップ デバイスでスムーズに実行される魅力的な 2D ゲームを作成できます。


Flutter は、さまざまなデバイスに基本的な機能を提供するソリューションの迅速な開発を容易にする不可欠な機能を備えているため、ゲーム MVP を構築するための一般的なソリューションにもなっています。特に、Flutter のさまざまな利点と不可欠な機能により、次のことが可能になります。


  • Android や iOS など、さまざまなプラットフォーム用の共有コードベースを使用して製品を作成すると、異なるプラットフォーム用に個別のネイティブ アプリを構築するよりもはるかに高速でコスト効率が高くなります。同じコードベースを使用してFlutter Web アプリを構築するための特定のプラクティスもあります。


  • 事前に構築されたウィジェットとデフォルトのアニメーションを使用して柔軟なユーザー インターフェイスを構築し、MVP 開発において最も重要な要素の 1 つである開発速度を向上させます。


  • Flutter はホット リロード機能を提供しており、開発者は画面に同時に表示されるアプリ内コードの変更を確認できるため、MVP 開発の柔軟性が向上します。この機能により、反復と実験が大幅に簡素化され、開発者はさまざまなメカニズムとビジュアルをすばやく試すことができます。


  • 通常、最小限の実行可能な製品の開発には最小限のリソースが必要ですが、Flutter は Firebase とのデフォルトの統合によりサーバー側プログラミングの複雑さが大幅に軽減されるため、この要件を完全に満たしています。


Flutter は多くのコンピューティング リソースを消費せず、クロスプラットフォーム アプリケーションの簡単なセットアップを可能にします。


Flutter とFlame の組み合わせに基づくアプリ MVP は、信頼性が高く、開発が比較的簡単なソリューションです。ネイティブ コードに直接コンパイルされるため、スムーズなゲームプレイと応答性が保証されます。ゲーム MVP を一度開発すれば、さまざまなプラットフォームに展開できるため、時間とリソースを節約できます。Flutter と Flame は、プラットフォームの違いを内部で処理します。


さらに、どちらのテクノロジーも、豊富なドキュメント、チュートリアル、コード例を備えた活発なコミュニティを誇っています。つまり、答えやインスピレーションに困ることはありません。

それで、Flame は何ができるのでしょうか?

Flame は、短期間でリソースを過剰に費やすことなく MVP ゲーム機能を作成するためのツールセット全体を提供します。このクロスプラットフォーム モデリング フレームワークは、さまざまなユースケースに対応するツールを提供します。


  • スプライトとアニメーション: スプライトをすばやく作成したり、さまざまなオンライン ライブラリから使用したりできます。Flame はスケルトン アニメーションもサポートしており、より複雑でリアルなアニメーションを作成できます。


  • 衝突検出: 衝突検出システムが組み込まれているため、独自の物理法則を使用してゲームを簡単に作成できます。衝突検出を使用して、プラットフォーム、壁、収集品、およびゲーム キャラクターが相互作用できるその他のものを構築できます。


  • 物理シミュレーション: Flame は物理シミュレーションもサポートしており、よりダイナミックで魅力的なゲームプレイ メカニクスを作成できます。物理シミュレーションを使用して、重力、ジャンプ、バウンドなどを作成できます。


  • オーディオとサウンド エフェクト: オーディオを使用して、バックグラウンド ミュージック、サウンド エフェクト (ヒット、ジャンプなど)、さらには音声演技を作成できます。


  • 状態管理: Flame は、ゲームの状態を管理するためのさまざまな機能を提供します。これには、スコア管理、レベル管理、プレーヤー データなどが含まれます。


  • 入力デバイス: Flame は、タッチ スクリーン、キーボード、ゲーム コントローラなど、さまざまな入力デバイスをサポートしています。これにより、さまざまなプラットフォーム向けのゲームを開発するのに最適です。


  • 視差スクロール: 視差スクロールをサポートしており、ゲームの世界に奥行きと没入感を加えることができます。視差スクロールは、背景のさまざまなレイヤーをさまざまな速度で動かすことで、奥行きがあるような錯覚を生み出します。


  • パーティクル システム: Flame はパーティクル システムもサポートしており、これを使用して爆発、煙、雨などのさまざまな視覚効果を作成できます。


  • マルチプレイヤー ゲームプレイ: これにより、プレイヤーはリアルタイムで互いに競争したり協力したりすることができます。


上記の機能のほとんどは多くのゲームに不可欠であり、MVP 開発段階であっても見逃してはいけません。本当に重要なのは、Flame によって上記の機能の開発速度が大幅に向上し、最も初期の製品バージョンでもそのような機能をリリースできるようになることです。

試してみよう

さて、Flame について話す代わりに、このフレームワークを使用して独自のゲームの基本機能を含む MVP を作成しましょう。開始する前に、Flutter 3.13 以降、お気に入りの IDE、テスト用のデバイスをインストールしておく必要があります。

アイデア

このゲームは Chrome Dino にインスピレーションを受けています。ああ、あの有名な Dino Run です! これは単なる Chrome のゲームではありません。ブラウザのオフライン モードに隠された、愛すべきイースター エッグなのです。


私たちのプロジェクトには次のようなゲームプレイがあります:

  • 暗い森を果てしなく走り回る冒険好きな男、ジャックとしてプレイします。
  • コントロールは最小限です。スペースバーをタップするか、画面をクリックしてジャンプします。
  • ゲームはゆっくりと始まりますが、徐々にスピードが上がっていくため、緊張感を保つことができます。
  • あなたの目標はシンプルです。障害物を避けてできるだけ遠くまで走り、途中でポイントを獲得することです。


その名は「フォレスト・ラン!」

覚悟を決める

新しいアプリを起動するたびに行うように、空の Flutter プロジェクトを作成します。まず、プロジェクトの pubspec.yaml で依存関係を設定する必要があります。この記事を書いている時点では、Flame の最新バージョンは 1.14.0 です。また、後でこのファイルに戻る必要がないように、すべてのアセット パスをここで定義しましょう。そして、画像をディレクトリ asset/images/ に配置します。Flame はこのパスを正確にスキャンするため、ここに配置する必要があります。


 environment: sdk: '>=3.2.3 <4.0.0' flutter: '>=3.13.0' dependencies: flutter: sdk: flutter flame: ^1.14.0 flutter: uses-material-design: true assets: - assets/images/ - assets/images/character/ - assets/images/background/ - assets/images/forest/ - assets/images/font/


Flame は他のディレクトリを解析しないため、すべての画像を asset/images/ の下に置くことを忘れないでください。


どのゲームでも、たくさんの画像が必要になります。しかし、デザインが得意でない場合はどうすればよいでしょうか? ありがたいことに、プロジェクトに使用できるオープンソースのアセットがたくさんあります。このゲームのアセットはitch.ioから取得しました。プロジェクトでは、次のリソースを使用します。



これらのリンクにアクセスするか、このプロジェクト用に用意されたアセット (アセット アーカイブへのリンク) をダウンロードして、すべてのコンテンツをプロジェクトにコピーすることもできます。


Flame の哲学は Flutter に似ています。Flutter ではすべてがウィジェットですが、Flame ではすべてがコンポーネントであり、ゲーム全体もコンポーネントです。すべてのコンポーネントは、onLoad() と update() の 2 つのメソッドをオーバーライドできます。onLoad() は、コンポーネントが ComponentTree にマウントされたときに 1 回だけ呼び出され、update() は各フレームで実行されます。Flutter の StatefulWidget の initState() および build() と非常によく似ています。


さて、コードを書いてみましょう。FlameGame を拡張し、すべてのアセットをキャッシュに読み込むクラスを作成します。


 class ForestRunGame extends FlameGame { @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); } }


次に、main.dart で ForestRunGame を使用します。また、Flame.device のメソッドを使用してデバイスの向きを設定することもできます。また、ウィジェットとコンポーネント間のブリッジとして機能する GameWidget もあります。


 Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Flame.device.fullScreen(); await Flame.device.setLandscape(); runApp(GameWidget(game: ForestRunGame())); }


この時点で、ゲームを開始できますが、黒い画面しか表示されません。そのため、コンポーネントを追加する必要があります。

暗い森

森を背景と前景の 2 つのコンポーネントに分けます。まず、背景を処理します。ページをスクロールしたときに、ダイナミックに感じたことはありますか? まるで一度に複数のビューをスクロールしているかのようです。これは視差効果で、ページ内のさまざまな要素が異なる速度で移動するときに発生し、3D の奥行き効果を生み出します。


ご想像のとおり、背景には視差を使用します。ParallaxComponent を拡張し、ParallaxImageData を使用して画像のスタックを設定します。また、初期レイヤーの速度を表す baseVelocity と、レイヤー間の速度の相対的な差を表す velocityMultiplierDelta があります。最後に、優先度フィールド (z-index) を構成して、他のコンポーネントの背後に移動します。


 class ForestBackground extends ParallaxComponent<ForestRunGame> { @override Future<void> onLoad() async { priority = -10; parallax = await game.loadParallax( [ ParallaxImageData('background/plx-1.png'), ParallaxImageData('background/plx-2.png'), ParallaxImageData('background/plx-3.png'), ParallaxImageData('background/plx-4.png'), ParallaxImageData('background/plx-5.png'), ], baseVelocity: Vector2.zero(), velocityMultiplierDelta: Vector2(1.4, 1.0), ); } }


背景は完成しました。次は前景を追加します。PositionComponent を拡張して、地面を画面の下部に揃えます。ゲーム キャッシュにアクセスするには、HasGameReference ミックスインも必要です。


地面を作成するには、地面ブロック画像を複数回並べるだけです。Flame では、画像コンポーネントはスプライトと呼ばれます。スプライトは、キャンバスでレンダリングできる画像の領域です。画像全体を表す場合もあれば、スプライト シートを構成する部分の 1 つを表す場合もあります。


また、X 軸は右向き、Y 軸は下向きであることを覚えておいてください。軸の中心は、画面の左上隅に配置されます。



 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { static final blockSize = Vector2(480, 96); late final Sprite groundBlock; late final Queue<SpriteComponent> ground; @override void onLoad() { super.onLoad(); groundBlock = Sprite(game.images.fromCache('forest/ground.png')); ground = Queue(); } @override void onGameResize(Vector2 size) { super.onGameResize(size); final newBlocks = _generateBlocks(); ground.addAll(newBlocks); addAll(newBlocks); y = size.y - blockSize.y; } List<SpriteComponent> _generateBlocks() { final number = 1 + (game.size.x / blockSize.x).ceil() - ground.length; final lastBlock = ground.lastOrNull; final lastX = lastBlock == null ? 0 : lastBlock.x + lastBlock.width; return List.generate( max(number, 0), (i) => SpriteComponent( sprite: groundBlock, size: blockSize, position: Vector2(lastX + blockSize.x * i, y), priority: -5, ), growable: false, ); } }


最後に、これらのコンポーネントを ForestRunGame に追加します。


 class ForestRunGame extends FlameGame { late final foreground = ForestForeground(); late final background = ForestBackground(); @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); add(foreground); add(background); } }


さて、ゲームを起動してみましょう。この時点ですでに森ができています。


森の中の見知らぬ人

森は良さそうですが、現時点では単なる絵です。そこで、プレイヤーの指示に従って森の中を走るジャックを作成します。木や地面とは異なり、プレイヤーが生きていると感じるにはアニメーションが必要です。地面のブロックには Sprite を使用しましたが、ジャックには SpriteAnimation を使用します。これはどのように動作するのでしょうか。すべては簡単で、スプライトのシーケンスをループするだけです。たとえば、走るアニメーションには 8 つのスプライトがあり、わずかな時間差で互いに入れ替わります。



ジャックは走ったり、ジャンプしたり、アイドル状態になったりできます。ジャックの状態を表すには、PlayerState 列挙型を追加します。次に、SpriteAnimationGroupComponent を拡張する Player を作成し、PlayerState を汎用引数として渡します。このコンポーネントには、すべての PlayerState のアニメーションが格納される animations フィールドと、アニメーション化する必要があるプレーヤーの現在の状態を表す current フィールドがあります。


 enum PlayerState { jumping, running, idle } class Player extends SpriteAnimationGroupComponent<PlayerState> { @override void onLoad() { super.onLoad(); animations = { PlayerState.running: SpriteAnimation.fromFrameData( game.images.fromCache('character/run.png'), SpriteAnimationData.sequenced( amount: 8, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(23, 34), ), ), PlayerState.idle: SpriteAnimation.fromFrameData( game.images.fromCache('character/idle.png'), SpriteAnimationData.sequenced( amount: 12, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(21, 35), ), ), PlayerState.jumping: SpriteAnimation.spriteList( [ Sprite(game.images.fromCache('character/jump.png')), Sprite(game.images.fromCache('character/land.png')), ], stepTime: 0.4, loop: false, ), }; current = PlayerState.idle; } }


プレイヤーの状態は準備完了です。次に、プレイヤーに画面上のサイズと位置を与える必要があります。サイズは 69x102 ピクセルに設定しますが、自由に変更してください。位置については、地面の座標を知る必要があります。HasGameReference ミックスインを追加することで、フォアグラウンド フィールドにアクセスしてその座標を取得できます。次に、アプリケーションのサイズが変更されるたびに呼び出される onGameResize メソッドをオーバーライドし、そこにジャックの位置を設定します。


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { static const startXPosition = 80.0; Player() : super(size: Vector2(69, 102)); double get groundYPosition => game.foreground.y - height + 20; // onLoad() {...} with animation setup @override void onGameResize(Vector2 size) { super.onGameResize(size); x = startXPosition; y = groundYPosition; } }


以前と同じように、プレイヤーをゲームに追加します。


 class ForestRunGame extends FlameGame { // Earlier written code here... late final player = Player(); @override Future<void> onLoad() async { // Earlier written code here... add(player); } }


ゲームを開始すると、ジャックがすでに森の中にいることがわかります。


走れジャック、走れ!

ゲームには、イントロ、プレイ、ゲームオーバーの 3 つの状態があります。そこで、これらを表す列挙型 GameState を追加します。ジャックを走らせるには、速度と加速度の変数が必要です。また、移動距離を計算する必要があります (後で使用します)。


前述のように、コンポーネントには onLoad() と update() という 2 つの主なメソッドがあります。onLoad メソッドはすでに数回使用しました。次に update() について説明します。このメソッドには dt という 1 つのパラメーターがあります。これは、最後に update() が呼び出されてから経過した時間を表します。


現在の速度と移動距離を計算するには、update() メソッドといくつかの基本的な運動学の式を使用します。

  • 距離 = 速度 * 時間;
  • 速度 = 加速度 * 時間;


 enum GameState { intro, playing, gameOver } class ForestRunGame extends FlameGame { static const acceleration = 10.0; static const maxSpeed = 2000.0; static const startSpeed = 400.0; GameState state = GameState.intro; double currentSpeed = 0; double traveledDistance = 0; // Earlier written code here... @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } }


実際には、開発をシンプルにするためにトリックを使用します。ジャックは安定していますが、森はジャックに向かって移動します。そのため、森にゲーム速度を適用する必要があります。


視差背景の場合、ゲーム速度を渡すだけで、残りは自動的に処理されます。


 class ForestBackground extends ParallaxComponent<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); parallax?.baseVelocity = Vector2(game.currentSpeed / 10, 0); } }


前景については、すべての地面のブロックをシフトする必要があります。また、キューの最初のブロックが画面から消えたかどうかを確認する必要があります。消えた場合は、それを削除してキューの最後に配置します。


 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); final shift = game.currentSpeed * dt; for (final block in ground) { block.x -= shift; } final firstBlock = ground.first; if (firstBlock.x <= -firstBlock.width) { firstBlock.x = ground.last.x + ground.last.width; ground.remove(firstBlock); ground.add(firstBlock); } } }


すべて準備はできていますが、トリガーが残っています。クリックすると実行を開始したいと思います。ターゲットはモバイルとデスクトップの両方なので、画面のタップとキーボード イベントを処理したいと考えています。


幸いなことに、Flame にはそれを実現する方法があります。入力タイプにミックスインを追加するだけです。キーボードの場合は KeyboardEvents、画面タップの場合は TapCallbacks です。これらのミックスインを使用すると、関連するメソッドをオーバーライドしてロジックを提供できます。


ユーザーがスペースバーを押すか画面をタップすると、ゲームが開始される必要があります。


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { start(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { start(); } void start() { state = GameState.playing; player.current = PlayerState.running; currentSpeed = startSpeed; traveledDistance = 0; } }


その結果、クリック後にジャックが走れるようになりました。


ああ、ブッシュだ!

ここで、道路に障害物を配置します。この場合、それらは有毒な茂みとして表現されます。茂みはアニメーション化されていないため、SpriteComponent を使用できます。また、茂みの速度にアクセスするにはゲーム参照が必要です。さらに、茂みを 1 つずつ生成することは望ましくありません。このアプローチでは、ジャックがジャンプで茂みの列を通過できない状況が発生する可能性があるためです。これは、現在のゲーム速度に依存する範囲からのランダムな数値です。


 class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { late double gap; Bush() : super(size: Vector2(200, 84)); bool get isVisible => x + width > 0; @override Future<void> onLoad() async { x = game.size.x + width; y = -height + 20; gap = _computeRandomGap(); sprite = Sprite(game.images.fromCache('forest/bush.png')); } double _computeRandomGap() { final minGap = width * game.currentSpeed * 100; final maxGap = minGap * 5; return (Random().nextDouble() * (maxGap - minGap + 1)).floor() + minGap; } @override void update(double dt) { super.update(dt); x -= game.currentSpeed * dt; if (!isVisible) { removeFromParent(); } } }


誰が茂みを植えているのでしょうか? もちろん、自然です。茂みの世代を管理する自然を創りましょう。


 class Nature extends Component with HasGameReference<ForestRunGame> { @override void update(double dt) { super.update(dt); if (game.currentSpeed > 0) { final plant = children.query<Bush>().lastOrNull; if (plant == null || (plant.x + plant.width + plant.gap) < game.size.x) { add(Bush()); } } } }


それでは、ForestForeground に Nature を追加しましょう。


 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... late final Nature nature; @override void onLoad() { // Earlier written code here... nature = Nature(); add(nature); }


さて、森には茂みがあります。でも、ちょっと待ってください。ジャックは茂みの中を走り抜けているだけです。なぜこんなことが起きるのでしょうか? それは、まだ打撃を実装していないからです。


ここで、Hitbox が役に立ちます。Hitbox は、Flame のコンポーネント コレクションのもう 1 つのコンポーネントです。衝突検出をカプセル化し、カスタム ロジックで処理できるようにします。


Jack 用に 1 つ追加します。コンポーネントの位置は、中央ではなく左右の角に配置されることを覚えておいてください。サイズについては、残りを処理します。


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(2, 2), size: Vector2(60, 100), ), ); } }


そして茂みにも 1 つ。ここでは、最適化のために衝突タイプをパッシブに設定します。デフォルトでは、タイプはアクティブです。つまり、Flame はこのヒットボックスが他のすべてのヒットボックスと衝突するかどうかを確認します。プレイヤーと茂みしかありません。プレイヤーにはすでにアクティブな衝突タイプがあり、茂みは互いに衝突できないため、タイプをパッシブに設定できます。


 class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(30, 30), size: Vector2(150, 54), collisionType: CollisionType.passive, ), ); } }


かっこいいですが、ヒットボックスの位置が正しく調整されているかどうかわかりません。どうすればテストできますか?


Player と Bush の debugMode フィールドを true に設定できます。これにより、ヒットボックスの配置を確認できます。紫色はコンポーネントのサイズを定義し、黄色はヒットボックスを示します。


ここで、プレイヤーと茂みの間に衝突が発生したときにそれを検出します。そのためには、HasCollisionDetection ミックスインを Game に追加し、衝突を処理する必要があるコンポーネントに CollisionCallbacks を追加する必要があります。


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... }


今のところは、衝突が検出されたらゲームを一時停止するだけです。


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.paused = true; } }


ジャンプするか死ぬか

ジャックが茂みを避けたい場合、ジャンプする必要があります。教えましょう。この機能には、重力定数とジャックのジャンプの初期の垂直速度が必要です。これらの値は目視で選択したので、自由に調整してください。


では、重力はどのように機能するのでしょうか? 基本的には、同じ加速度ですが、地面に向けられています。したがって、垂直位置と速度に同じ式を使用できます。したがって、ジャンプには 3 つのステップがあります。

  1. ジャンプがトリガーされ、ジャックの垂直速度がゼロから初期値に変化します。
  2. 彼は上昇していますが、重力によって速度が徐々に変化します。ある瞬間、ジャックは上昇をやめて下降を始めます。
  3. ジャックが地面に触れたら、重力の適用を停止し、ジャックの状態を走行状態に戻す必要があります。


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { static const gravity = 1400.0; static const initialJumpVelocity = -700.0; double jumpSpeed = 0; // Earlier written code here... void jump() { if (current != PlayerState.jumping) { current = PlayerState.jumping; jumpSpeed = initialJumpVelocity - (game.currentSpeed / 500); } } void reset() { y = groundYPos; jumpSpeed = 0; current = PlayerState.running; } @override void update(double dt) { super.update(dt); if (current == PlayerState.jumping) { y += jumpSpeed * dt; jumpSpeed += gravity * dt; if (y > groundYPos) { reset(); } } else { y = groundYPos; } } }


それでは、ForestRunGameからクリックしてジャンプをトリガーしてみましょう


class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { onAction(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { onAction(); } void onAction() { switch (state) { case GameState.intro: case GameState.gameOver: start(); break; case GameState.playing: player.jump(); break; } } }


今、ジャックは茂みを扱うことができます。

ゲームオーバー

ゲームが終了したら、画面にテキストを表示する必要があります。Flame のテキストは Flutter とは動作が異なります。まずフォントを作成する必要があります。内部的には、char がキーで sprite が値であるマップにすぎません。ほとんどの場合、ゲームのフォントは必要なすべてのシンボルが集められた 1 つの画像です。


このゲームに必要なのは数字と大文字だけです。それではフォントを作成しましょう。そのためには、ソース イメージとグリフを渡す必要があります。グリフとは何でしょうか? グリフは文字、そのサイズ、ソース イメージ内の位置に関する情報の集合です。


 class StoneText extends TextBoxComponent { static const digits = '123456789'; static const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; StoneText({ required Image source, required super.position, super.text = '', }) : super( textRenderer: SpriteFontRenderer.fromFont( SpriteFont( source: source, size: 32, ascent: 32, glyphs: [ _buildGlyph(char: '0', left: 480, top: 0), for (var i = 0; i < digits.length; i++) _buildGlyph(char: digits[i], left: 32.0 * i, top: 32), for (var i = 0; i < letters.length; i++) _buildGlyph( char: letters[i], left: 32.0 * (i % 16), top: 64.0 + 32 * (i ~/ 16), ), ], ), letterSpacing: 2, ), ); static Glyph _buildGlyph({ required String char, required double left, required double top, }) => Glyph(char, left: left, top: top, height: 32, width: 32); }


これで、ゲームオーバー パネルを作成してゲームで使用できるようになります。


 class GameOverPanel extends PositionComponent with HasGameReference<ForestRunGame> { bool visible = false; @override Future<void> onLoad() async { final source = game.images.fromCache('font/keypound.png'); add(StoneText(text: 'GAME', source: source, position: Vector2(-144, -16))); add(StoneText(text: 'OVER', source: source, position: Vector2(16, -16))); } @override void renderTree(Canvas canvas) { if (visible) { super.renderTree(canvas); } } @override void onGameResize(Vector2 size) { super.onGameResize(size); x = size.x / 2; y = size.y / 2; } }


これで、ジャックが茂みにぶつかったときにパネルを表示できるようになりました。また、クリックするとゲームを再開できるように start() メソッドを変更しましょう。また、森から茂みをすべて取り除く必要があります。


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... late final gameOverPanel = GameOverPanel(); @override Future<void> onLoad() async { // Earlier written code here... add(gameOverPanel); } void gameOver() { paused = true; gameOverPanel.visible = true; state = GameState.gameOver; currentSpeed = 0; } void start() { paused = false; state = GameState.playing; currentSpeed = startSpeed; traveledDistance = 0; player.reset(); foreground.nature.removeAll(foreground.nature.children); gameOverPanel.visible = false; } }


ここで、プレーヤーの衝突コールバックを更新する必要があります。


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.gameOver(); } }


これで、ジャックが茂みにぶつかるとゲームオーバーになることがわかります。そして、もう一度クリックするだけでゲームを再開できます。


私のスコアはどうなりますか?

そして最終的なタッチスコアの計算です。


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { late final StoneText scoreText; late final StoneText highText; late final StoneText highScoreText; int _score = 0; int _highScore = 0; // Earlier written code here... @override Future<void> onLoad() async { // Earlier written code here... final font = images.fromCache('font/keypound.png'); scoreText = StoneText(source: font, position: Vector2(20, 20)); highText = StoneText(text: 'HI', source: font, position: Vector2(256, 20)); highScoreText = StoneText( text: '00000', source: font, position: Vector2(332, 20), ); add(scoreText); add(highScoreText); add(highText); setScore(0); } void start() { // Earlier written code here... if (_score > _highScore) { _highScore = _score; highScoreText.text = _highScore.toString().padLeft(5, '0'); } _score = 0; } @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; setScore(traveledDistance ~/ 50); if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } void setScore(int score) { _score = score; scoreText.text = _score.toString().padLeft(5, '0'); } }


以上です!


さあ、試してみて、私の最高スコアを破ってみてください。2537 ポイントです!

結論

大変な作業でしたが、やり遂げることができました。物理、アニメーション、スコア計算などを備えたモバイル ゲーム用の最小限の実行可能な製品を作成しました。改善の余地は常にあり、他の MVP と同様に、当社の製品には将来的に新しい機能、メカニズム、ゲーム モードが追加される予定です。


また、flame_audio パッケージもあり、これを使用して、バックグラウンド ミュージック、ジャンプや打撃の音などを追加できます。


現時点では、私たちの主な目標は、短期間で限られたリソース割り当てで基本的な製品機能を作成することでした。Flutter と Flame の組み合わせは、ユーザーからのフィードバックを収集し、将来的にアプリをアップグレードし続けるために使用できるゲーム MVP を構築するのに最適であることが証明されました。


私たちの取り組みの結果はここで確認できます。


強力な機能、使いやすさ、活発なコミュニティを備えた Flutter と Flame は、ゲーム開発者を目指す人にとって魅力的な選択肢です。熟練したプロでも、始めたばかりでも、この組み合わせはゲームのアイデアを実現するためのツールと可能性を提供します。さあ、創造力を駆使して Flutter と Flame の世界に飛び込み、次のモバイル ゲーム センセーションを巻き起こしましょう。


この記事が皆さんにとって楽しく有益なものであったことを願っています。ソフトウェア開発についてさらに詳しく知りたい場合や、独自の MVP プロジェクトについて話し合いたい場合は、 Leobitをご覧いただくか、当社の技術チームにお問い合わせください。