1. はじめに
Flutter と Flame でプラットフォーム ゲームを作成する方法を学習します。Doodle Jump から着想を得た Doodle Dash ゲームでは、Dash(Flutter のマスコット)またはその親友 Sparky(Firebase のマスコット)となってプレイし、プラットフォーム(足場)の間を跳び移りながら、できるだけ高い位置に到達することを目指します。
学習内容
- Flutter でクロス プラットフォーム ゲームを作成する方法
- Flame ゲームループの一環としてレンダリングと更新ができる再利用可能なゲーム コンポーネントを作成する方法
- キャラクター(スプライト)の動きを制御し、ゲーム物理を通じてアニメーションを付ける方法
- 衝突検出を追加して管理する方法
- ゲームにコントロールとしてキーボードとタップ入力を追加する方法
前提条件
この Codelab は、Flutter の使用経験があることを前提としています。それがない場合、初めての Flutter アプリの Codelab で基本を学習できます。
作成するアプリの概要
この Codelab では、Dash(Flutter のマスコット)または Sparky(Firebase のマスコット)を主人公とした、Doodle Dash というプラットフォーム ゲームの作成過程を説明します(この Codelab の以降のコードでは Dash を参照していますが、Sparky にも当てはまります)。作成するゲームには次の特徴があります。
- 上下左右に移動できるスプライト
- ランダムに生成されるプラットフォーム
- スプライトを引き寄せる重力効果
- ゲームメニュー
- 一時停止やリプレイなどのゲーム内コントロール
- スコアの保存機能
ゲームプレイ
Doodle Dash のプレイでは、ゲームを通じて、Dash を左右に動かし、プラットフォームの間を跳び移りながら、パワーアップを使用して能力を高めます。ゲームを開始するには、初期難易度(1 から 5)を選び、[Start] をクリックします。
レベル
レベルは 5 段階あります。(レベル 1 から)レベルが上がるごとに新しい機能が解放されます。
- レベル 1(デフォルト): このレベルでは、
NormalPlatform
プラットフォームとSpringBoard
プラットフォームが発生します。発生したプラットフォームは、20% の確率で移動するプラットフォームになります。 - レベル 2(スコア >= 20): 1 回だけジャンプできる
BrokenPlatform
を追加します。 - レベル 3(スコア >= 40):
NooglerHat
パワーアップを解放します。この特別なプラットフォームは 5 秒間有効で、Dash のジャンプ力を通常の速度の 2.5 倍にします。また、その 5 秒間はクールな Noogler ハットを被っています。 - レベル 4(スコア >= 80):
Rocket
パワーアップを解放します。宇宙船の姿をした特別なプラットフォームであり、Dash を無敵にします。また、Dash のジャンプ力を通常の 3.5 倍の速度にします。 - レベル 5(スコア >= 100):
Enemy
プラットフォームを解放します。Dash が敵に衝突すると、自動的にゲームオーバーになります。
レベルごとのプラットフォーム タイプ
レベル 1(デフォルト)
|
|
レベル 2(スコア >= 20) | レベル 3(スコア >= 40) | レベル 4(スコア >= 80) | レベル 5(スコア >= 100) |
|
|
|
|
ゲームオーバー
次の場合にゲームオーバーとなります。
- Dash が画面下に落ちた場合
- Dash が敵に衝突した場合(敵はレベル 5 で発生します)
パワーアップ
パワーアップにより、ジャンプ力の強化、無敵化、またはその両方など、キャラクターのプレイ能力が強化されます。Doodle Dash には、2 つのパワーアップがあります。同時にアクティブできるパワーアップは、1 つだけです。
- Noogler ハットのパワーアップは、Dash のジャンプ力を通常の 2.5 倍の高さにします。さらに、パワーアップ中は Noogler ハットを被ります。
- 宇宙船のパワーアップは、Dash を敵プラットフォームに対して無敵にし(敵に衝突してもダメージを受けない)、ジャンプ力を通常の 3.5 倍の高さにします。宇宙船で飛び、速度が重力に負けるとプラットフォームに着地します。
2. Codelab のスターター コードを取得する
GitHub からプロジェクトの初期バージョンをダウンロードする:
- コマンドラインから、GitHub リポジトリのクローンを
flutter-codelabs
ディレクトリに作成する:
git clone https://github.com/flutter/codelabs.git flutter-codelabs
この Codelab のコードは flutter-codelabs/flame-building-doodle-dash
ディレクトリにあります。このディレクトリには、Codelab の各ステップの完成したプロジェクト コードが含まれています。
スターター アプリをインポートする
- お好みの IDE に
flutter-codelabs/flame-building-doodle-dash/step_02
ディレクトリをインポートします。
パッケージをインストールする
- Flame などのすべての必要なパッケージは、すでにプロジェクトの
pubspec.yaml
ファイルに追加されています。IDE が依存関係を自動的にインストールしない場合は、コマンドライン ターミナルを開き、Flutter プロジェクトのルートから次のコマンドを実行して、プロジェクトの依存関係を取得します。
flutter pub get
Flutter の開発環境をセットアップする
この Codelab を完了するには、以下が必要です。
3. コードについて
では、コードの内容について見てみましょう。
FlameGame
を拡張する DoodleDash ゲームを含んだ lib/game/doodle_dash.dart
ファイルを取得します。コンポーネントを、Flame の最も基本的なコンポーネント(Flutter の Scaffold
と同様)である FlameGame
のインスタンスに登録します。このインスタンスが、それに登録されたコンポーネントのすべてをゲームプレイ中にレンダリングし、更新します。これをゲームの中枢神経だと考えてください。
コンポーネントとは何でしょうか?Flutter アプリが Widgets
から作られているのと同様に、FlameGame
は、ゲームを作り出すすべての構成要素である Components
から作られています(コンポーネントも、Flutter ウィジェットと同じように、子コンポーネントを持つことができます)。キャラクターのスプライト、ゲーム背景、新しいゲーム コンポーネント(敵など)を生成するオブジェクトは、どれもコンポーネントです。FlameGame
自体が Component
であり、これを Flame では Flame コンポーネント システムと呼んでいます。
コンポーネントは Component
抽象クラスを継承します。Component
抽象メソッドを実装して、FlameGame
クラスの仕組みを作ります。たとえば、DoodleDash では、次のメソッドがよく実装されています。
onLoad
: 非同期にコンポーネントを初期化します(Flutter のinitState
メソッドと同様)update
: ゲームループの各ティックでコンポーネントを更新します(Flutter のbuild
メソッドと同様)
また、add
メソッドはコンポーネントを Flame エンジンに登録します。
たとえば、lib/game/world.dart
ファイルには、ParallaxComponent
を拡張してゲーム背景をレンダリングする World
クラスが含まれています。このクラスは、画像アセットのリストを取り、それらをレイヤにレンダリングして、各レイヤの動きがリアルに見えるように異なる速度で動かします。DoodleDash
クラスは、ParallaxComponent
のインスタンスを含み、それを DoodleDash の onLoad
メソッドでゲームに追加します。
lib/game/world.dart
class World extends ParallaxComponent<DoodleDash> {
@override
Future<void> onLoad() async {
parallax = await gameRef.loadParallax(
[
ParallaxImageData('game/background/06_Background_Solid.png'),
ParallaxImageData('game/background/05_Background_Small_Stars.png'),
ParallaxImageData('game/background/04_Background_Big_Stars.png'),
ParallaxImageData('game/background/02_Background_Orbs.png'),
ParallaxImageData('game/background/03_Background_Block_Shapes.png'),
ParallaxImageData('game/background/01_Background_Squiggles.png'),
],
fill: LayerFill.width,
repeat: ImageRepeat.repeat,
baseVelocity: Vector2(0, -5),
velocityMultiplierDelta: Vector2(0, 1.2),
);
}
}
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
...
final World _world = World();
...
@override
Future<void> onLoad() async {
await add(_world);
...
}
}
状態管理
lib/game/managers
ディレクトリには、Doodle Dash の状態管理を扱う 3 つのファイル(game_manager.dart
、object_manager.dart
、level_manager.dart
)が含まれています。
GameManager
クラス(game_manager.dart
内)は、ゲーム ステータスとスコア記録の全体を管理しています。
ObjectManager
クラス(object_manager.dart
内)は、プラットフォームが生成、削除する場所とタイミングを管理します。このクラスには、あとで追加を行います。
そして、LevelManager
クラス(level_manager.dart
内)は、プレーヤーがレベルアップするタイミングの適切なゲーム設定とともに、ゲームの難易度を管理します。このゲームには 5 つの難易度レベルがあります。プレーヤーは、スコアのマイルストーンのうちの 1 つに到達すると、次のレベルに進みます。レベルが上がるごとに、難易度が上がり、Dash はより高くジャンプすることが必要になります。重力はゲームを通して一定なので、ジャンプのスピードは距離が遠くなるにつれ、少しずつ速くなります。
プレーヤーのスコアは、プラットフォームを通過すると増えます。プレーヤーがあるポイントしきい値を達成すると、ゲームはレベルアップし、ゲームをより楽しく難しくする新しい特別なプラットフォームを解放します。
4. ゲームにプレーヤーを追加する
このステップでは、ゲームにキャラクターを追加します(この場合は Dash)。プレーヤーはキャラクターを制御し、すべてのロジックは Player
クラス(player.dart
ファイル内)にあります。Player
クラスは、オーバーライドしてカスタムのロジックを実装する抽象メソッドを含んでいる Flame の SpriteGroupComponent
クラスを拡張します。これには、画像とスプライトの読み込み、プレーヤーの位置決め(水平方向と垂直方向)、衝突検出の設定、ユーザー入力の受け取りなどがあります。
アセットを読み込む
Dash はさまざまなスプライトで表示され、キャラクターのさまざまなバージョンやパワーアップを表現します。たとえば、以下のアイコンは、正面、左、右を向いている Dash と Sparky を示しています。
Flame の SpriteGroupComponent
を使用することで、複数のスプライト状態を sprites
プロパティで管理できます。これについては、_loadCharacterSprites
メソッドで見ていきます。
Player
クラスで、以下の行を onLoad
メソッドに追加して、スプライト アセットを読み込み、Player
のスプライト状態を正面に設定します。
lib/game/sprites/player.dart
@override
Future<void> onLoad() async {
await super.onLoad();
await _loadCharacterSprites(); // Add this line
current = PlayerState.center; // Add this line
}
_loadCharacterSprites
のスプライトとアセットを読み込むコードを調べます。このコードは、onLoad
メソッドで直接実装することもできますが、別のメソッドに入れることでソースコードが整理され、可読性が高まります。このメソッドでは、以下のように、各キャラクター状態と読み込まれたスプライト アセットをペアリングするマップを sprites
に代入しています。
lib/game/sprites/player.dart
Future<void> _loadCharacterSprites() async {
final left = await gameRef.loadSprite('game/${character.name}_left.png');
final right = await gameRef.loadSprite('game/${character.name}_right.png');
final center =
await gameRef.loadSprite('game/${character.name}_center.png');
final rocket = await gameRef.loadSprite('game/rocket_4.png');
final nooglerCenter =
await gameRef.loadSprite('game/${character.name}_hat_center.png');
final nooglerLeft =
await gameRef.loadSprite('game/${character.name}_hat_left.png');
final nooglerRight =
await gameRef.loadSprite('game/${character.name}_hat_right.png');
sprites = <PlayerState, Sprite>{
PlayerState.left: left,
PlayerState.right: right,
PlayerState.center: center,
PlayerState.rocket: rocket,
PlayerState.nooglerCenter: nooglerCenter,
PlayerState.nooglerLeft: nooglerLeft,
PlayerState.nooglerRight: nooglerRight,
};
}
プレーヤー コンポーネントを更新する
イベントループのティック(またはフレーム)ごとに、Flame がコンポーネントの update
メソッドを一回呼び出して、変更された各ゲーム コンポーネントを再描画します(Flutter の build
メソッドと同様)。次に、Player
クラスの update
メソッドにロジックを追加して、画面上でのキャラクターの位置を決定します。
次のコードを Player
の update
メソッドに追加し、キャラクターの現在の速度と位置を計算します。
lib/game/sprites/player.dart
void update(double dt) {
// Add lines from here...
if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;
_velocity.x = _hAxisInput * jumpSpeed; // ... to here.
final double dashHorizontalCenter = size.x / 2;
if (position.x < dashHorizontalCenter) { // Add lines from here...
position.x = gameRef.size.x - (dashHorizontalCenter);
}
if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
position.x = dashHorizontalCenter;
} // ... to here.
// Core gameplay: Add gravity
position += _velocity * dt; // Add this line
super.update(dt);
}
プレーヤーを移動する前に、初期状態(ゲームの初回読み込み時)やゲームオーバー状態など、プレーヤーが移動すべきでないプレイ不可能な状態にないことを確認するためのチェックを update
メソッドで行います。
プレイ可能な状態であれば、Dash の位置を new_position = current_position + (velocity * time-elapsed-since-last-game-loop-tick)
という等式を使用して、つまり以下のコードで計算します。
position += _velocity * dt
Doodle Dash を作成する際のもう一つの重要な点は、必ず無限の側面境界を含めるという点です。そうすることで、Dash が画面の左端から飛び降りて、右側から再登場するといったことや、その逆も可能になります。
これは、Dash の位置が画面の左端または右端を超えたかどうかをチェックし、超えた場合には反対側に位置を設定することによって実装します。
キーイベント
まず、Doodle Dash はウェブとデスクトップで実行されるので、キーボード入力をサポートして、プレーヤーがキャラクターの動きを制御できるようにする必要があります。onKeyEvent
メソッドを使用すると、Player
コンポーネントで矢印キーが押されたのを認識し、Dash が左右のどちらを向き、どちらに進むのかを決定できます。
Dash は左に進むときに左を向く | Dash は右に進むときに右を向く |
次に、Dash が水平に移動する能力を実装します(_hAxisInput
変数で定義)。また、それを Dash が進んでいる方向を向くようにもします。
Player
クラスの moveLeft
メソッドと moveRight
メソッドを変更して、Dash の現在位置を定義します。
lib/game/sprites/player.dart
void moveLeft() {
_hAxisInput = 0;
current = PlayerState.left; // Add this line
_hAxisInput += movingLeftInput; // Add this line
}
void moveRight() {
_hAxisInput = 0;
current = PlayerState.right; // Add this line
_hAxisInput += movingRightInput; // Add this line
}
Player
クラスの onKeyEvent
メソッドを変更して、左矢印キーまたは右矢印キーが押されたときに、それぞれ moveLeft
メソッドまたは moveRight
メソッドを呼び出します。
lib/game/sprites/player.dart
@override
bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
_hAxisInput = 0;
// Add lines from here...
if (keysPressed.contains(LogicalKeyboardKey.arrowLeft)) {
moveLeft();
}
if (keysPressed.contains(LogicalKeyboardKey.arrowRight)) {
moveRight();
} // ... to here.
// During development, it's useful to "cheat"
if (keysPressed.contains(LogicalKeyboardKey.arrowUp)) {
// jump();
}
return true;
}
これで、Player
クラスが機能し、Doodle Dash ゲームで使用できるようになりました。
DoodleDash ファイルで sprites.dart
をインポートして、Player
クラスを利用できるようにします。
lib/game/doodle_dash.dart
import 'sprites/sprites.dart'; // Add this line
DoodleDash
クラスで Player
インスタンスを作成します。
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
DoodleDash({super.children});
final World _world = World();
LevelManager levelManager = LevelManager();
GameManager gameManager = GameManager();
int screenBufferSpace = 300;
ObjectManager objectManager = ObjectManager();
late Player player; // Add this line
...
}
次に、Player
のジャンプ速度を初期化し、プレーヤーが選択した難易度レベルに基づいて設定して、Player
コンポーネントを FlameGame
に追加します。setCharacter
メソッドに次のコードを記入します。
lib/game/doodle_dash.dart
void setCharacter() {
player = Player( // Add lines from here...
character: gameManager.character,
jumpSpeed: levelManager.startingJumpSpeed,
);
add(player); // ... to here.
}
setCharacter
メソッドを initializeGameStart
の先頭で呼び出します。
lib/game/doodle_dash.dart
void initializeGameStart() {
setCharacter(); // Add this line
...
}
また、initializeGameStart
で、プレーヤーの resetPosition
を呼び出して、ゲームが開始されるたびに開始位置に戻るようにします。
lib/game/doodle_dash.dart
void initializeGameStart() {
...
levelManager.reset();
player.resetPosition(); // Add this line
objectManager = ObjectManager(
minVerticalDistanceToNextPlatform: levelManager.minDistance,
maxVerticalDistanceToNextPlatform: levelManager.maxDistance);
...
}
アプリを実行し、ゲームを開始すると、Dash が画面に現れます。
トラブルシューティング
アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。
5. プラットフォームを追加する
このステップでは、(Dash が着地する、またはジャンプする際の足場にする)プラットフォームと、Dash がジャンプするべきタイミングを決定する衝突検出ロジックを追加します。
まず、Platform
抽象クラスを調べます。
lib/game/sprites/platform.dart
abstract class Platform<T> extends SpriteGroupComponent<T>
with HasGameRef<DoodleDash>, CollisionCallbacks {
final hitbox = RectangleHitbox();
bool isMoving = false;
Platform({
super.position,
}) : super(
size: Vector2.all(100),
priority: 2,
);
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
}
}
ヒットボックスについて
Doodle Dash で導入されたプラットフォーム コンポーネントは、どれも Platform<T>
抽象クラス(ヒットボックスのある SpriteComponent
クラス)を拡張しています。ヒットボックスがあることで、スプライト コンポーネントで、ヒットボックスのある他のオブジェクトに衝突したことを検出できるようになります。Flame は、四角形、円形、多角形などのさまざまなヒットボックス形状をサポートしています。たとえば、Doodle Dash では、プラットフォームに四角形のヒットボックスを使用し、Dash には円形のヒットボックスを使用しています。Flame が衝突を判断する計算を行います。
Platform
クラスは、すべのサブタイプにヒットボックスと衝突検出を追加します。
標準プラットフォームを追加する
Platform
クラスはプラットフォームをゲームに追加します。通常のプラットフォームは、ランダムに選ばれた 4 つの外観(モニタ、スマートフォン、ターミナル、ノートパソコン)のうちの 1 つで表現されます。外観の選択はプラットフォームの動作に影響しません。
|
NormalPlatformState
列挙型と NormalPlatform
クラスを追加することで、通常の静的なプラットフォームを追加します。
lib/game/sprites/platform.dart
enum NormalPlatformState { only } // Add lines from here...
class NormalPlatform extends Platform<NormalPlatformState> {
NormalPlatform({super.position});
final Map<String, Vector2> spriteOptions = {
'platform_monitor': Vector2(115, 84),
'platform_phone_center': Vector2(100, 55),
'platform_terminal': Vector2(110, 83),
'platform_laptop': Vector2(100, 63),
};
@override
Future<void>? onLoad() async {
var randSpriteIndex = Random().nextInt(spriteOptions.length);
String randSprite = spriteOptions.keys.elementAt(randSpriteIndex);
sprites = {
NormalPlatformState.only: await gameRef.loadSprite('game/$randSprite.png')
};
current = NormalPlatformState.only;
size = spriteOptions[randSprite]!;
await super.onLoad();
}
} // ... to here.
次に、操作するキャラクターのプラットフォームを発生させます。
ObjectManager
クラスは Flame の Component
クラスを拡張し、ゲームを通じて Platform
オブジェクトを生成します。ObjectManager
の update
メソッドと onMount
メソッドにプラットフォームを発生させる機能を実装します。
_semiRandomPlatform
という新しいメソッドを作成して、ObjectManager
クラスでプラットフォームを発生させます。このメソッドは後で更新して、さまざまな種類のプラットフォームを返すようにしますが、ここでは NormalPlatform
だけを返します。
lib/game/managers/object_manager.dart
Platform _semiRandomPlatform(Vector2 position) { // Add lines from here...
return NormalPlatform(position: position);
} // ... to here.
ObjectManager
の update
メソッドをオーバーライドし、_semiRandomPlatform
メソッドを使用してプラットフォームを生成し、それをゲームに追加します。
lib/game/managers/object_manager.dart
@override // Add lines from here...
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
// Losing the game: Add call to _maybeAddEnemy()
// Powerups: Add call to _maybeAddPowerup();
}
super.update(dt);
} // ... to here.
同じことを ObjectManager
の onMount
メソッドでも行い、ゲームが最初に実行されたときに _semiRandomPlatform
メソッドが最初のプラットフォームを生成し、それをゲームに追加するようにします。
次のコードの onMount
メソッドを追加します。
lib/game/managers/object_manager.dart
@override // Add lines from here...
void onMount() {
super.onMount();
var currentX = (gameRef.size.x.floor() / 2).toDouble() - 50;
var currentY =
gameRef.size.y - (_rand.nextInt(gameRef.size.y.floor()) / 3) - 50;
for (var i = 0; i < 9; i++) {
if (i != 0) {
currentX = _generateNextX(100);
currentY = _generateNextY();
}
_platforms.add(
_semiRandomPlatform(
Vector2(
currentX,
currentY,
),
),
);
add(_platforms[i]);
}
} // ... to here.
たとえば、以下のコードのように、configure
メソッドにより、Doodle Dash ゲームで、プラットフォーム間の最小距離と最大距離を再設定できるようになり、難易度レベルが増えたときの特別なプラットフォームが実現されます。
lib/game/managers/object_manager.dart
void configure(int nextLevel, Difficulty config) {
minVerticalDistanceToNextPlatform = gameRef.levelManager.minDistance;
maxVerticalDistanceToNextPlatform = gameRef.levelManager.maxDistance;
for (int i = 1; i <= nextLevel; i++) {
enableLevelSpecialty(i);
}
}
DoodleDash
インスタンスは(initializeGameStart
メソッド内で)、初期化され、難易度レベルに基づいて設定され、Flame ゲームに追加される ObjectManager
を作成します。
lib/game/doodle_dash.dart
void initializeGameStart() {
gameManager.reset();
if (children.contains(objectManager)) objectManager.removeFromParent();
levelManager.reset();
player.resetPosition();
objectManager = ObjectManager(
minVerticalDistanceToNextPlatform: levelManager.minDistance,
maxVerticalDistanceToNextPlatform: levelManager.maxDistance);
add(objectManager);
objectManager.configure(levelManager.level, levelManager.difficulty);
}
ObjectManager
は checkLevelUp
メソッド内で再登場します。プレーヤーがレベルアップすると、ObjectManager
がプラットフォーム生成パラメータを難易度レベルに基づいて再設定します。
lib/game/doodle_dash.dart
void checkLevelUp() {
if (levelManager.shouldLevelUp(gameManager.score.value)) {
levelManager.increaseLevel();
objectManager.configure(levelManager.level, levelManager.difficulty);
}
}
ホットリロード で(ウェブ上でテストしている場合は再起動して)変更点を有効にします(ファイルを保存するか、IDE のボタンを使用するか、コマンドラインから r
を入力すると、ホットリロードされます)。ゲームを開始すると、Dash とプラットフォームが画面に現れます。
トラブルシューティング
アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。
6. コア ゲームプレイ
Player
ウィジェットと Platform
ウィジェットを実装したので、次は全体をまとめましょう。このステップでは、コア機能、衝突検出、カメラ移動を実装します。
重力
ゲームをもっと現実感のあるものにするために、Dash に重力(ジャンプしたときに Dash を下に引っ張る力)を作用させる必要があります。今回の Doodle Dash では、重力は正の定数のままにして、Dash が常に下に引っ張られるようにします。将来は、重力を変化させて、他の効果を作ることができます。
Player
クラスで、値が 9 の _gravity
プロパティを追加します。
lib/game/sprites/player.dart
class Player extends SpriteGroupComponent<PlayerState>
with HasGameRef<DoodleDash>, KeyboardHandler, CollisionCallbacks {
...
Character character;
double jumpSpeed;
final double _gravity = 9; // Add this line
@override
Future<void> onLoad() async {
...
}
...
}
Player
の update
メソッドを変更して、_gravity
変数を追加し、Dash の垂直速度に影響を与えます。
lib/game/sprites/player.dart
void update(double dt) {
if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;
_velocity.x = _hAxisInput * jumpSpeed;
final double dashHorizontalCenter = size.x / 2;
if (position.x < dashHorizontalCenter) {
position.x = gameRef.size.x - (dashHorizontalCenter);
}
if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
position.x = dashHorizontalCenter;
}
_velocity.y += _gravity; // Add this line
position += _velocity * dt;
super.update(dt);
}
衝突検出
Flame は、衝突検出を初めからサポートしています。それを Flame ゲームで有効にするには、HasCollisionDetection
ミックスインを追加します。DoodleDash
クラスを調べると、このミックスインがすでに追加されています。
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
...
}
次に、CollisionCallbacks
ミックスインを使用して、各ゲーム コンポーネントに衝突検出を追加します。このミックスインにより、コンポーネントが onCollision
コールバックにアクセスできるようになります。ヒットボックスのある 2 つのオブジェクトが衝突すると、onCollision
コールバックがトリガーされて、衝突したオブジェクトへの参照が渡されるので、オブジェクトがどのように反応すべきかのロジックを実装できます。
前のステップでは Platform
抽象クラスに CollisionCallbacks
ミックスインとヒットボックスがあったことを思い出してください。Player
クラスには、すでに CollisionCallbacks
ミックスインがあるため、必要なのは CircleHitbox
を Player
クラスに追加することだけです。Dash は四角形よりも円形に近いので、Dash のヒットボックスは実際には円形になっています。
Player
クラスで sprites.dart
をインポートして、さまざまな Platform
クラスにアクセスできるようにします。
lib/game/sprites/player.dart
import 'sprites.dart';
CircleHitbox
を Player
クラスの onLoad
メソッドに追加します。
lib/game/sprites/player.dart
@override
Future<void> onLoad() async {
await super.onLoad();
await add(CircleHitbox()); // Add this line
await _loadCharacterSprites();
current = PlayerState.center;
}
Dash は、プラットフォームと衝突したときにジャンプできるので、ジャンプ メソッドを必要としています。
オプションの specialJumpSpeed
を取る jump
メソッドを追加します。
lib/game/sprites/player.dart
void jump({double? specialJumpSpeed}) {
_velocity.y = specialJumpSpeed != null ? -specialJumpSpeed : -jumpSpeed;
}
次のコードを追加して、Player
の onCollision
メソッドをオーバーライドします。
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
}
}
}
このコールバックは、Dash が下に落ちて NormalPlatform
の上面に衝突したときに、jump
メソッドを呼び出します。isMovingDown && isCollidingVertically
というステートメントにより、Dash は、ジャンプをトリガーすることなく、プラットフォームを通過して上に移動できます。
カメラ移動
Dash がゲーム内で上に移動するときには、カメラが Dash を追いますが、Dash が落ちるときには、カメラは固定されるべきです。
Flame では、「世界」が画面よりも大きい場合、カメラの worldBounds
を使用して、世界のどの部分を表示すべきかを Flame に支持する境界を追加します。カメラが水平方向に固定されたまま上に移動しているように見せるには、プレーヤーの位置に基づいて、更新ごとに上下の世界境界を調整し、左右の境界は同じにします。
DoodleDash
クラスで、次のコードを update
メソッドに追加して、ゲームプレイ中にカメラが Dash を追えるようにします。
lib/game/doodle_dash.dart
@override
void update(double dt) {
super.update(dt);
if (gameManager.isIntro) {
overlays.add('mainMenuOverlay');
return;
}
if (gameManager.isPlaying) {
checkLevelUp();
// Add lines from here...
final Rect worldBounds = Rect.fromLTRB(
0,
camera.position.y - screenBufferSpace,
camera.gameSize.x,
camera.position.y + _world.size.y,
);
camera.worldBounds = worldBounds;
if (player.isMovingDown) {
camera.worldBounds = worldBounds;
}
var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
if (!player.isMovingDown && isInTopHalfOfScreen) {
camera.followComponent(player);
} // ... to here.
}
}
次に、ゲーム再開時に Player
の位置とカメラ境界を開始値にリセットする必要があります。
次のコードを initializeGameStart
メソッドに追加します。
lib/game/doodle_dash.dart
void initializeGameStart() {
...
levelManager.reset();
// Add the lines from here...
player.reset();
camera.worldBounds = Rect.fromLTRB(
0,
-_world.size.y,
camera.gameSize.x,
_world.size.y +
screenBufferSpace,
);
camera.followComponent(player);
// ... to here.
player.resetPosition();
...
}
レベルアップ時にジャンプ速度を上げる
コア ゲームプレイの最後のピースでは、難易度レベルが上がり、プラットフォームが互いに間隔を広げて発生したときに、Dash のジャンプ速度を上げることが必要になります。
setJumpSpeed
メソッドの呼び出しを追加し、現在のレベルに関連付けられているジャンプ速度を設定します。
lib/game/doodle_dash.dart
void checkLevelUp() {
if (levelManager.shouldLevelUp(gameManager.score.value)) {
levelManager.increaseLevel();
objectManager.configure(levelManager.level, levelManager.difficulty);
player.setJumpSpeed(levelManager.jumpSpeed); // Add this line
}
}
ホットリロード して(またはウェブ上で再起動して)変更点を有効にします(ファイルを保存するか、IDE のボタンを使用するか、コマンドラインから r
を入力すると、ホットリロードされます)。
トラブルシューティング
アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。
7. さらにプラットフォームについて
Dash がジャンプする際の足場となるプラットフォームを ObjectManager
が生成するようになったので、Dash に刺激的で特別なプラットフォームを与えることができます。
次は、BrokenPlatform
クラスと SpringBoard
クラスを追加します。その名前からわかるとおり、BrokenPlatform
は一回のジャンプで壊れ、SpringBoard
は Dash を高く高速に跳ね上げるトランポリンを用意します。
|
|
Player
クラスと同じように、それぞれのプラットフォーム クラスでは enums
を使用して現在の状態を表現します。
lib/game/sprites/platform.dart
enum BrokenPlatformState { cracked, broken }
プラットフォームの current
状態が変化すると、ゲーム内で現れるスプライトも変化します。State
列挙型と sprites
プロパティの画像アセットとの間のマッピングを定義して、各状態にどのスプライトを割り当てるかを関連付けます。
BrokenPlatformState
列挙型と BrokenPlatform
クラスを追加します。
lib/game/sprites/platform.dart
enum BrokenPlatformState { cracked, broken } // Add lines from here...
class BrokenPlatform extends Platform<BrokenPlatformState> {
BrokenPlatform({super.position});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprites = <BrokenPlatformState, Sprite>{
BrokenPlatformState.cracked:
await gameRef.loadSprite('game/platform_cracked_monitor.png'),
BrokenPlatformState.broken:
await gameRef.loadSprite('game/platform_monitor_broken.png'),
};
current = BrokenPlatformState.cracked;
size = Vector2(115, 84);
}
void breakPlatform() {
current = BrokenPlatformState.broken;
}
} // ... to here.
SpringState
列挙型と SpringBoard
クラスを追加します。
lib/game/sprites/platform.dart
enum SpringState { down, up } // Add lines from here...
class SpringBoard extends Platform<SpringState> {
SpringBoard({
super.position,
});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprites = <SpringState, Sprite>{
SpringState.down:
await gameRef.loadSprite('game/platform_trampoline_down.png'),
SpringState.up:
await gameRef.loadSprite('game/platform_trampoline_up.png'),
};
current = SpringState.up;
size = Vector2(100, 45);
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isCollidingVertically) {
current = SpringState.down;
}
}
@override
void onCollisionEnd(PositionComponent other) {
super.onCollisionEnd(other);
current = SpringState.up;
}
} // ... to here.
次に、これらの特別なプラットフォームを ObjectManager
の中で有効にします。特別なプラットフォームは、ゲーム中で常に表示するのは望ましくないので、SpringBoard
は 15% の確率で、BrokenPlatform
は 10% の確率で発生させます。
ObjectManager
の _semiRandomPlatform
メソッドの中、NormalPlatform
を返しているステートメントの前に次のコードを追加し、条件付きで特別なプラットフォームを発生させます。
lib/game/managers/object_manager.dart
Platform _semiRandomPlatform(Vector2 position) {
if (specialPlatforms['spring'] == true && // Add lines from here...
probGen.generateWithProbability(15)) {
return SpringBoard(position: position);
}
if (specialPlatforms['broken'] == true &&
probGen.generateWithProbability(10)) {
return BrokenPlatform(position: position);
} // ... to here.
return NormalPlatform(position: position);
}
ゲームをプレイする楽しみの一つは、レベルアップで新たな課題と機能を解放することです。
レベル 1 の最初からトランポリンが登場し、レベル 2 で BrokenPlatform
を解放するようにして、ゲームを少し難しくするのがよいでしょう。
ObjectManager
クラスで、レベル 1 で SpringBoard
プラットフォームを有効にし、レベル 2 で BrokenPlatform
を有効にする switch
文を追加して、enableLevelSpecialty
メソッド(現在はスタブ)を変更します。
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) { // Add lines from here...
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
} // ... to here.
}
次に、プラットフォームに水平方向に移動する機能を追加します。Platform
抽象クラスに、次の _move
メソッドを追加します。
lib/game/sprites/platform.dart
void _move(double dt) {
if (!isMoving) return;
final double gameWidth = gameRef.size.x;
if (position.x <= 0) {
direction = 1;
} else if (position.x >= gameWidth - size.x) {
direction = -1;
}
_velocity.x = direction * speed;
position += _velocity * dt;
}
プラットフォームが移動中の場合、ゲーム画面の端に達したときに、動きを反対方向に変えます。Dash と同じように、プラットフォームの位置も、_direction
にプラットフォームの speed
を乗じて速度を得ることで決定されます。そして、速度に time-elapsed
を乗じて、その結果をプラットフォームの現在の position
に加算します。
Platform
クラスの update
メソッドをオーバーライドして、_move
メソッドを呼び出します。
lib/game/sprites/platform.dart
@override
void update(double dt) {
_move(dt);
super.update(dt);
}
Platform
の移動をトリガーするために、onLoad
メソッドの中で、isMoving
ブール値を 20% の確率で true
に設定します。
lib/game/sprites/platform.dart
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
final int rand = Random().nextInt(100); // Add this line
if (rand > 80) isMoving = true; // Add this line
}
最後に、Player
で、Player
クラスの onCollision
メソッドを変更して、Springboard
または BrokenPlatform
との衝突を検出します。SpringBoard
は jump
を 2 倍のスピードで呼び出し、BrokenPlatform
は状態が .broken
(ジャンプ済み)ではなく .cracked
のときにだけ jump
を呼び出していることに注目してください。
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) { // Add lines from here...
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
} // ... to here.
}
}
アプリを再起動して、ゲームを開始すると、プラットフォーム SpringBoard
とプラットフォーム BrokenPlatform
が移動しているのが表示されます。
トラブルシューティング
アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。
8. ゲームオーバー
このステップでは、Doodle Dash ゲームにゲームオーバー条件を追加します。ゲームオーバーは 2 通りあります。
- Dash がプラットフォームに乗り損ねて、画面下に落ちる。
- Dash が
Enemy
プラットフォームに衝突する。
いずれかの「ゲームオーバー」を実装する前に、DoodleDash のゲーム状態を gameOver
に設定するロジックを追加する必要があります。
DoodleDash
クラスで、ゲームを終了するときに呼び出される onLose
メソッドを追加します。このメソッドでは、ゲーム状態を設定し、画面からプレーヤーを取り除き、ゲームオーバーのメニューやオーバーレイを有効にします。
lib/game/sprites/doodle_dash.dart
void onLose() { // Add lines from here...
gameManager.state = GameState.gameOver;
player.removeFromParent();
overlays.add('gameOverOverlay');
} // ... to here.
ゲームオーバー メニュー:
DoodleDash
の update
メソッドの上部で、以下のコードを追加して、ゲーム状態が GameOver
であるときにゲームの更新を停止します。
lib/game/sprites/doodle_dash.dart
@override
void update(double dt) {
super.update(dt);
if (gameManager.isGameOver) { // Add lines from here...
return;
} // ... to here.
...
}
また、update
メソッドで、プレーヤーが画面下に落ちたときに onLose
を呼び出します。
lib/game/sprites/doodle_dash.dart
@override
void update(double dt) {
...
if (gameManager.isPlaying) {
checkLevelUp();
final Rect worldBounds = Rect.fromLTRB(
0,
camera.position.y - screenBufferSpace,
camera.gameSize.x,
camera.position.y + _world.size.y,
);
camera.worldBounds = worldBounds;
if (player.isMovingDown) {
camera.worldBounds = worldBounds;
}
var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
if (!player.isMovingDown && isInTopHalfOfScreen) {
camera.followComponent(player);
}
// Add lines from here...
if (player.position.y >
camera.position.y +
_world.size.y +
player.size.y +
screenBufferSpace) {
onLose();
} // ... to here.
}
}
敵の形状やサイズはさまざまで、Doodle Dash ではゴミ箱やエラーフォルダのアイコンで表示されます。プレーヤーは、いずれかに衝突するとすぐにゲームオーバーになるので、それを避ける必要があります。
|
EnemyPlatformState
列挙型と EnemyPlatform
クラスを追加して、敵のプラットフォーム タイプを作成します。
lib/game/sprites/platform.dart
enum EnemyPlatformState { only } // Add lines from here...
class EnemyPlatform extends Platform<EnemyPlatformState> {
EnemyPlatform({super.position});
@override
Future<void>? onLoad() async {
var randBool = Random().nextBool();
var enemySprite = randBool ? 'enemy_trash_can' : 'enemy_error';
sprites = <EnemyPlatformState, Sprite>{
EnemyPlatformState.only:
await gameRef.loadSprite('game/$enemySprite.png'),
};
current = EnemyPlatformState.only;
return super.onLoad();
}
} // ... to here.
EnemyPlatform
クラスは Platform
スーパータイプを拡張しています。ObjectManager
は、敵のプラットフォームを、他のプラットフォームと同じように、発生させ、管理します。
ObjectManager
で、次のコードを追加して、敵のプラットフォームを発生させ、管理します。
lib/game/managers/object_manager.dart
final List<EnemyPlatform> _enemies = []; // Add lines from here...
void _maybeAddEnemy() {
if (specialPlatforms['enemy'] != true) {
return;
}
if (probGen.generateWithProbability(20)) {
var enemy = EnemyPlatform(
position: Vector2(_generateNextX(100), _generateNextY()),
);
add(enemy);
_enemies.add(enemy);
_cleanupEnemies();
}
}
void _cleanupEnemies() {
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
while (_enemies.isNotEmpty && _enemies.first.position.y > screenBottom) {
remove(_enemies.first);
_enemies.removeAt(0);
}
} // ... to here.
ObjectManager
は、敵オブジェクトである _enemies
のリストを管理します。_maybeAddEnemy
は、敵を 20 パーセントの確率で発生させ、そのオブジェクトを敵リストに追加します。_cleanupEnemies()
メソッドは、表示されなくなった古い EnemyPlatform
オブジェクトを削除します。
ObjectManager
では、update
メソッドの中で _maybeAddEnemy()
を呼び出して、敵のプラットフォームを発生させます。
lib/game/managers/object_manager.dart
@override
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
_maybeAddEnemy(); // Add this line
}
super.update(dt);
}
Player
の onCollision
メソッドに追加して、EnemyPlatform
と衝突していないかをチェックします。衝突している場合は onLose()
メソッドを呼び出します。
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
if (other is EnemyPlatform) { // Add lines from here...
gameRef.onLose();
return;
} // ... to here.
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) {
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
}
}
}
最後に、ObjectManager
の enableLevelSpecialty
メソッドを変更して、switch
文にレベル 5 を追加します。
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) {
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
case 5: // Add lines from here...
enableSpecialty('enemy');
break; // ... to here.
}
}
ゲームを難しくできたので、ホットリロード して変更を有効にします(ファイルを保存するか、IDE のボタンを使用するか、コマンドラインから r
を入力すると、ホットリロードされます)。
壊れたフォルダの敵にご注意を。彼らは卑怯です。背景に溶け込んでいます。
トラブルシューティング
アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。
9. パワーアップ
このステップでは、ゲームを通じて、ゲーム機能を強化して Dash をパワーアップします。Doodle Dash には、Noogler ハットと宇宙船の 2 つのパワーアップがあります。どちらのパワーアップも、別のタイプの特別なプラットフォームと考えることができます。Dash がゲームでジャンプしているとき、Noogler ハットか宇宙船のパワーアップに衝突して獲得すると、スピードが上がります。
|
|
プレーヤーがスコア 40 以上を達成してレベル 3 になると、Noogler ハットが発生します。Dash がこのハットに衝突すると、Noogler ハットを被り、通常の 2.5 倍に加速されます。これは 5 秒間継続します。
プレーヤーがスコア 80 以上を達成してレベル 4 になると、宇宙船が発生します。Dash が宇宙船に衝突すると、スプライトが宇宙船に置き換わり、プラットフォームに着地するまで通常の 3.5 倍の速度になります。宇宙船のパワーアップがあるときには、無敵になるという特典もあります。
Noogler ハットと宇宙船のスプライトは、PowerUp
抽象クラスを拡張しています。Platform
抽象クラスと同じように、以下に示す PowerUp
抽象クラスにもサイズ設定とヒットボックスが含まれています。
lib/game/sprites/powerup.dart
abstract class PowerUp extends SpriteComponent
with HasGameRef<DoodleDash>, CollisionCallbacks {
final hitbox = RectangleHitbox();
double get jumpSpeedMultiplier;
PowerUp({
super.position,
}) : super(
size: Vector2.all(50),
priority: 2,
);
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
}
}
PowerUp
抽象クラスを拡張する Rocket
クラスを作成します。Dash が宇宙船に衝突すると、通常の 3.5 倍のスピードになります。
lib/game/sprites/powerup.dart
class Rocket extends PowerUp { // Add lines from here...
@override
double get jumpSpeedMultiplier => 3.5;
Rocket({
super.position,
});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprite = await gameRef.loadSprite('game/rocket_1.png');
size = Vector2(50, 70);
}
} // ... to here.
PowerUp
抽象クラスを拡張する NooglerHat
クラスを作成します。Dash が NooglerHat
に衝突すると、通常の 2.5 倍の速度になります。この加速は 5 秒間継続します。
lib/game/sprites/powerup.dart
class NooglerHat extends PowerUp { // Add lines from here...
@override
double get jumpSpeedMultiplier => 2.5;
NooglerHat({
super.position,
});
final int activeLengthInMS = 5000;
@override
Future<void>? onLoad() async {
await super.onLoad();
sprite = await gameRef.loadSprite('game/noogler_hat.png');
size = Vector2(75, 50);
}
} // ... to here.
NooglerHat
パワーアップと Rocket
パワーアップを実装したので、ObjectManager
を更新してゲーム内に発生させます。
ObjectManger
クラスを変更して、発生させたパワーアップを管理するリストとともに、新しいパワーアップ プラットフォームの生成と削除を行う _maybePowerup
メソッドと _cleanupPowerups
メソッドを追加します。
lib/game/managers/object_manager.dart
final List<PowerUp> _powerups = []; // Add lines from here...
void _maybeAddPowerup() {
if (specialPlatforms['noogler'] == true &&
probGen.generateWithProbability(20)) {
var nooglerHat = NooglerHat(
position: Vector2(_generateNextX(75), _generateNextY()),
);
add(nooglerHat);
_powerups.add(nooglerHat);
} else if (specialPlatforms['rocket'] == true &&
probGen.generateWithProbability(15)) {
var rocket = Rocket(
position: Vector2(_generateNextX(50), _generateNextY()),
);
add(rocket);
_powerups.add(rocket);
}
_cleanupPowerups();
}
void _cleanupPowerups() {
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
while (_powerups.isNotEmpty && _powerups.first.position.y > screenBottom) {
if (_powerups.first.parent != null) {
remove(_powerups.first);
}
_powerups.removeAt(0);
}
} // ... to here.
_maybeAddPowerup
メソッドは、20% の確率で Noogler ハットを発生させ、15% の確率で宇宙船を発生させます。_cleanupPowerups
メソッドは、画面の底面境界の下にあるパワーアップを削除するために呼び出されます。
ObjectManager
update
メソッドを変更して、ゲームループのティックごとに _maybePowerup
を呼び出します。
lib/game/managers/object_manager.dart
@override
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
_maybeAddEnemy();
_maybeAddPowerup(); // Add this line
}
super.update(dt);
}
enableLevelSpecialty
メソッドを変更して、switch 文に、レベル 3 で NooglerHat
を有効にする case と、レベル 4 で Rocket
を有効にする case を追加します。
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) {
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
case 3: // Add lines from here...
enableSpecialty('noogler');
break;
case 4:
enableSpecialty('rocket');
break; // ... to here.
case 5:
enableSpecialty('enemy');
break;
}
}
次のブール値のゲッターを Player
クラスに追加します。Dash が有効なパワーアップを持っている場合、状態によってさまざまに表示されます。これらのゲッターにより、どのパワーアップが有効なのかを簡単にチェックできます。
lib/game/sprites/player.dart
bool get hasPowerup => // Add lines from here...
current == PlayerState.rocket ||
current == PlayerState.nooglerLeft ||
current == PlayerState.nooglerRight ||
current == PlayerState.nooglerCenter;
bool get isInvincible => current == PlayerState.rocket;
bool get isWearingHat =>
current == PlayerState.nooglerLeft ||
current == PlayerState.nooglerRight ||
current == PlayerState.nooglerCenter; // ... to here.
Player
の onCollision
メソッドを変更して、NooglerHat
または Rocket
との衝突に反応します。このコードでは、Dash がまだパワーアップを持っていないときに、新しいパワーアップだけを有効にするようにします。
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
if (other is EnemyPlatform && !isInvincible) {
gameRef.onLose();
return;
}
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) {
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
}
}
if (!hasPowerup && other is Rocket) { // Add lines from here...
current = PlayerState.rocket;
other.removeFromParent();
jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
return;
} else if (!hasPowerup && other is NooglerHat) {
if (current == PlayerState.center) current = PlayerState.nooglerCenter;
if (current == PlayerState.left) current = PlayerState.nooglerLeft;
if (current == PlayerState.right) current = PlayerState.nooglerRight;
other.removeFromParent();
_removePowerupAfterTime(other.activeLengthInMS);
jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
return;
} // ... to here.
}
Dash が宇宙船に衝突すると、PlayerState
が Rocket
に変更され、Dash が 3.5 倍の jumpSpeedMultiplier
でジャンプできるようになります。
Dash が Noogler ハットに衝突すると、現在の PlayerState
方向(.center
、.left
、.right
)に応じて PlayerState
が対応する Noogler PlayerState
に変化し、Noogler ハットを被って jumpSpeedMultiplier
が 2.5 倍になります。そのパワーアップは _removePowerupAfterTime
メソッドにより 5 秒後に削除され、PlayerState
がパワーアップ状態から center
に変更されます。
other.removeFromParent
への呼び出しにより Noogler ハットまたは宇宙船のスプライト プラットフォームが画面から消され、Dash がパワーアップを獲得したことが反映されます。
Player クラスの moveLeft
メソッドと moveRight
メソッドを変更して、NooglerHat
スプライトを考慮するようにします。Rocket
パワーアップは、移動方向には関係なく同じ方向を向いているので、考慮する必要はありません。
lib/game/sprites/player.dart
void moveLeft() {
_hAxisInput = 0;
if (isWearingHat) { // Add lines from here...
current = PlayerState.nooglerLeft;
} else if (!hasPowerup) { // ... to here.
current = PlayerState.left;
} // Add this line
_hAxisInput += movingLeftInput;
}
void moveRight() {
_hAxisInput = 0;
if (isWearingHat) { // Add lines from here...
current = PlayerState.nooglerRight;
} else if (!hasPowerup) { //... to here.
current = PlayerState.right;
} // Add this line
_hAxisInput += movingRightInput;
}
Dash が Rocket
パワーアップを持っているときは無敵になり、その間はゲーム終了を回避できます。
onCollision
コールバックを更新して、Dash が EnemyPlatform
と衝突したときに isInvincible
が true(無敵状態)かどうかをチェックしてから、ゲームオーバーをトリガーします。
lib/game/sprites/player.dart
if (other is EnemyPlatform && !isInvincible) { // Modify this line
gameRef.onLose();
return;
}
アプリを再起動し、ゲームを開始して、パワーアップが機能していることを確認します。
トラブルシューティング
アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。
10. オーバーレイ
Flame ゲームはウィジェットでラップすることができ、それによって Flutter アプリの他のウィジェットといっしょに統合することが簡単になります。Flutter ウィジェットは Flame Game の最前面にオーバーレイとして表示することもできます。これは、メニュー、一時停止画面、ボタン、スライダーなど、ゲームループに無関係な、ゲームプレイ コンポーネント以外のコンポーネントに便利です。
ゲーム中だけでなく Doodle Dash のすべてのメニューにも表示されるスコア表示は、通常の Flutter ウィジェットであり、Flame コンポーネントではありません。ウィジェットのコードはすべて lib/game/widgets
にあり、たとえば次のコードのように、ゲームオーバー メニューは、Text
や ElevatedButton
のような他のウィジェットを含んだ Column にすぎません。
lib/game/widgets/game_over_overlay.dart
class GameOverOverlay extends StatelessWidget {
const GameOverOverlay(this.game, {super.key});
final Game game;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.background,
child: Center(
child: Padding(
padding: const EdgeInsets.all(48.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Game Over',
style: Theme.of(context).textTheme.displayMedium!.copyWith(),
),
const WhiteSpace(height: 50),
ScoreDisplay(
game: game,
isLight: true,
),
const WhiteSpace(
height: 50,
),
ElevatedButton(
onPressed: () {
(game as DoodleDash).resetGame();
},
style: ButtonStyle(
minimumSize: MaterialStateProperty.all(
const Size(200, 75),
),
textStyle: MaterialStateProperty.all(
Theme.of(context).textTheme.titleLarge),
),
child: const Text('Play Again'),
),
],
),
),
),
);
}
}
Flame ゲームでウィジェットをオーバーレイとして使用するには、次のコードのように、GameWidget
の overlayBuilderMap
プロパティを、オーバーレイを(String
として)表す key
と、ウィジェットを返すウィジェット関数である value
で定義します。
lib/main.dart
GameWidget(
game: game,
overlayBuilderMap: <String, Widget Function(BuildContext, Game)>{
'gameOverlay': (context, game) => GameOverlay(game),
'mainMenuOverlay': (context, game) => MainMenuOverlay(game),
'gameOverOverlay': (context, game) => GameOverOverlay(game),
},
)
追加すると、オーバーレイはゲーム内のどこでも使用できるようになります。次のコードのように、overlays.add
を使用してオーバーレイを表示し、overlays.remove
を使用して非表示にします。
lib/game/doodle_dash.dart
void resetGame() {
startGame();
overlays.remove('gameOverOverlay');
}
void onLose() {
gameManager.state = GameState.gameOver;
player.removeFromParent();
overlays.add('gameOverOverlay');
}
11. モバイル サポート
Doodle Dash は、Flutter と Flame を土台にして作られているので、すでに Flutter でサポートされているプラットフォームであれば動作するようになっています。しかし今のところ、Doodle Dash は、キーボードベースの入力のみをサポートしています。スマートフォンのようなキーボードのないデバイスでは、オーバーレイにオンスクリーンのタップ操作ボタンを追加するのが簡単です。
ゲームがモバイル プラットフォームで実行されていることを示すブール値の状態変数を GameOverlay
に追加します。
lib/game/widgets/game_overlay.dart
class GameOverlayState extends State<GameOverlay> {
bool isPaused = false;
// Add this line
final bool isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
@override
Widget build(BuildContext context) {
...
}
}
次に、ゲームがモバイルで実行されているときに、左右の方向ボタンを表示します。ステップ 4 の「キーイベント」のロジックと同じように、左ボタンをタップすると Dash が左に動きます。右ボタンをタップすると右に動きます。
GameOverlay
の build
メソッドに、左ボタンをタップしたときに moveLeft
を呼び出し、右ボタンで moveRight
を呼び出すという、ステップ 4 と同じ動作をする isMobile
セクションを追加します。いずれかのボタンを離すと resetDirection
が呼び出され、Dash の水平方向の動きが止まります。
lib/game/widgets/game_overlay.dart
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Stack(
children: [
Positioned(... child: ScoreDisplay(...)),
Positioned(... child: ElevatedButton(...)),
if (isMobile) // Add lines from here...
Positioned(
bottom: MediaQuery.of(context).size.height / 4,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(left: 24),
child: GestureDetector(
onTapDown: (details) {
(widget.game as DoodleDash).player.moveLeft();
},
onTapUp: (details) {
(widget.game as DoodleDash).player.resetDirection();
},
child: Material(
color: Colors.transparent,
elevation: 3.0,
shadowColor: Theme.of(context).colorScheme.background,
child: const Icon(Icons.arrow_left, size: 64),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 24),
child: GestureDetector(
onTapDown: (details) {
(widget.game as DoodleDash).player.moveRight();
},
onTapUp: (details) {
(widget.game as DoodleDash).player.resetDirection();
},
child: Material(
color: Colors.transparent,
elevation: 3.0,
shadowColor: Theme.of(context).colorScheme.background,
child: const Icon(Icons.arrow_right, size: 64),
),
),
),
],
),
),
), // ... to here.
if (isPaused)
...
],
),
);
}
これで完了です。Doodle Dash アプリが、実行されているプラットフォームの種類を自動的に検出し、それに応じて入力を切り替えるようになりました。
iOS か Android でアプリを実行して、方向ボタンが機能していることを確認します。
トラブルシューティング
アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。
12. 次のステップ
おめでとうございます!
この Codelab を修了し、Flame ゲームエンジンを使用して Flutter でゲームを作成する方法を習得しました。
学習した内容
- 以下のような、Flame パッケージを使用してプラットフォーム ゲームを作成する方法。
- キャラクターの追加
- さまざまなプラットフォーム タイプの追加
- 衝突検出の実装
- 重力コンポーネントの追加
- カメラ移動の定義
- 敵の作成
- パワーアップの作成
- ゲームを実行しているプラットフォームの検出方法と、
- その情報を使用したキーボード制御とタップ入力制御の切り替え
参考資料
ここでは、Flutter でゲームを作成する方法について学習しました。
また、次のリソースも役に立ち、そこからインスピレーションを得られるかもしれません。
- Flame のドキュメントと Flame のパッケージ(pub.dev)
- 「Basics of the Flame game engine」 (Lukas Klingsbo による YouTube 動画)
- 「Simple Platformer, The Flame + Flutter game series」(DevKage)
- 「Dino Run; The Flutter Game Development series」(DevKage)
- 「Spacescape, The Flutter Game Development series」(DevKage)
- Flutter のゲーム
- Flutter の Casual Games ツールキットのページと、それに対応する Casual Games ツールキット用の Getting Started テンプレート(Casual Games ツールキットに Flame エンジンは使用されていませんが、モバイル広告とゲーム内アプリ購入をサポートするように設計されています)。
- 「Build your own game in Flutter」(Casual Games ツールキットの動画)
- 「Flutter Puzzle Hack」のページ(2022 年 1 月開催のコンペティション)とその受賞作品の動画