FlutterでRPGゲームを作る【Part 4/5 - アイテム取得とイベント】

![Banner.gif](https://img.itch.zone/aW1nLzcyODIxNjYuZ2lm/189x150%23/pu%2FnNJ.gif)

## はじめに

- **Flutter**でマルチプラットフォーム対応 (Web, モバイル, PC) の**RPGゲーム**を作成します
- [ソースコードはこちら](https://github.com/kazmawedton/simple_bonfire) (アセットなど一部差し替えています)
- この記事は5つのPartに分かれていて (予定)、今回はそのPart 4です。
    - [Part 1：**マップ**と**UI**の実装](https://qiita.com/KAZMA_WED/items/ed43e1c7bbaec397c486)
    - [Part 2：**プレイヤー**と当たり判定の実装](https://qiita.com/KAZMA_WED/items/b9e5413050bba938a528)
    - [Part 3：**マップ移動**の実装](https://qiita.com/KAZMA_WED/items/0d37df4a6681a2f97017)
    - Part 4：**アイテム**取得と**イベント**の実装
    - [Part 5：**NPC**と**会話**の実装](https://qiita.com/KAZMA_WED/items/c1d33ff4dcd293ade1af)
- **Bonfire**という2Dゲームに特化したフレームワーク(Flutterパッケージ)を利用しています
- マップ制作には[**Tiled**](https://thorbjorn.itch.io/tiled)という無料のGUIツールを利用しています
**Tiled**は必須ではありませんが、使い方も簡単で制作作業がとても捗ります
- アセットは[**itch.io**](https://itch.io/)で購入できるものを利用しています
- トップの画像は[利用しているアセット](https://franuka.itch.io/fantasy-rpg-halloween-pack)のサンプル画像です

## 開発環境
```
OS:       macOS 12.6
Flutter:  3.3.5
Bonfire:  2.10.10
Tiled:    1.9.2
Xcode:    14.0.1
テスト環境: Chrome, iOS Simulator
```

## 今回のパートの大まかな流れ

1. アセットの準備とアイテムクラスの作成
1. アイテムオブジェクトクラスの作成
1. アイテムオブジェクトをマップに配置
1. StateControllerの作成と実装
1. イベント用Decorationの作成と配置

## 1. アセットの準備とアイテムクラスの作成

今回作成するアイテムは、このような機能実装になっています。
- マップ上に配置されている
- 触れるだけで取得できる
- 所持数が管理できる
- 取得済みのアイテムは再生成しない

アイテムの画像には[**タイルセット**](https://franuka.itch.io/fantasy-rpg-halloween-pack)中のキノコのマスを、16x16pxで切り出したものを利用します。
↓ ゲーム内に配置しイメージこちら
![Screen Shot 2022-11-07 at 0.17.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/ee5e74b4-f5c4-53e9-4a88-4422eef048ef.png)



### 1.1 アセットの準備

- アイテムの画像を追加
`assets/images/objects/`ディレクトリを作成し画像を配置します。
今回はファイル名を`big_shroom.png`としました。
- 追加したディレクトリを`pubspec.yaml`に追記
```pubspec.yaml
flutter:
  uses-material-design: true
  assets:
    - assets/images/
    - assets/images/objects/ # 追加
    - assets/images/maps/
    - assets/images/charactors/
```

### 1.2 アイテムクラスの作成

- ファイルの作成
`lib/model/`ディレクトリを作成し、`game_item.dart`を作成します。
- `GameItem`クラスの作成
この記事シリーズで作成するアイテムは１種類だけですが、拡張性を考慮して汎用的な`GameItem`クラスを作っておきました。
プロパティには画像パスと、おまけで日本語名と値段を持たせました。
名前は「〇〇を手に入れた！」などとメッセージを出したいときに使えます (本記事では実装しません)。
- `BigShroom`クラスの作成
アイテムクラスを継承した`BigShroom`クラスを作成します。
先に追加したアセットの画像のパスと、お好みで名前と値段をつけてあげてください。
```lib/model/game_item.dart
// アイテムクラス
abstract class GameItem {
  GameItem({required this.imagePath, required this.jpName, required this.price});
  String imagePath;
  String jpName; // 日本語名
  int price; // 値段
}

// アイテムクラスを継承したキノコ
class BigShroom extends GameItem {
  BigShroom()
      : super(
          imagePath: 'objects/big_shroom.png',
          jpName: 'デカキノコ',
          price: 180,
        );
}

```

## 2. アイテムオブジェクトクラスの作成

- ファイルの作成
`lib/decorations/`ディレクトリを作成し、`big_shroom.dart`を作成します。
先に作成したアイテムのクラスと衝突しないように、作成するファイル名やクラス名に注意します。
- キノコオブジェクトクラスを作成
次はマップに配置するためのオブジェクトクラス  (`GameDecoration`)を作成します。
- 接触を取得のトリガーとするため`Sensor`Mixinも継承します。
```lib/decorations/big_shroom.dart
import 'package:bonfire/bonfire.dart';
import 'package:simple_bonfire/player/player_bearded_dude.dart';
import 'package:simple_bonfire/model/game_item.dart';

class BigShroomDecoration extends GameDecoration with Sensor {
  BigShroomDecoration(
      {required this.initPosition, required this.map, required this.id, required this.player})
      : super.withSprite(
          sprite: Sprite.load(gameItem.imagePath),
          position: initPosition,
          size: Vector2(48, 48),
        ) {
    setupSensorArea(areaSensor: [
      CollisionArea.rectangle(size: size, align: Vector2.zero()),
    ]);
  }
  static final gameItem = BigShroom(); // アイテムクラス

  final Vector2 initPosition; // 初期
  final Type map; // 配置されているマップ
  final int id; // マップ中のアイテムのID番号
  final PlayerBeardedDude player; // 取得するプレイヤーキャラクター

  @override
  void onContact(GameComponent component) {
    // プレイヤーが接触したら
    if (component is player) {
      // 画面から消える
      removeFromParent();
    }
  }
}

```
- まだ状態管理 (プレイヤーが取得したアイテムの管理) を実装していないので、ひとまず`onContact`で画面から消えるまでを実装をします。
- 引数について
    - `initPosition`：マップ上の表示位置
    - `map`, `id`：どのマップの、どのアイテムか識別用
    後に実装する状態管理で使います
    - `player`：プレイヤーキャラクター
    接触の判定と、後に実装する状態管理でも使います
- `super.withSprite()`について
表示の設定をします。
- `setupSensorArea()`について
当たり判定の設定をします
今回は16x16pxで、表示よりも大きめに設定しています。

## 3. アイテムオブジェクトをマップに配置

- アイテムの種類について
今回のアイテムは一度きりしか取得できないようにします。他にも、マップ移動のたびに繰り返し生成され取得できるものも考えられると思います。
    - 一度しか取得できないアイテム：ゼルダの伝説のダンジョンのカギなど
    - 繰り返し生成されるアイテム：ゼルダの伝説の壺など

- 繰り返し生成されるものや状態管理が不要なものであれば、Part 2で実装したマップ移動用のセンサーのように、`BonfireWidget`内の`objectBuilder`で生成するのが簡単です。
[サンプルこちら](https://github.com/RafaelBarbosatec/darkness_dungeon)

### 3.1 アイテム生成メソッドを作成

[Part 2](https://qiita.com/KAZMA_WED/items/b9e5413050bba938a528)で作成したマップにメソッドを追加、実装します。

- `BonfireGame.add()`メソッド実装
表示中のゲームの状態管理している`BonfireGame`の`add()`メソッドで`Decoration`をオブジェクトとしてマップに追加します。
作成した`BigShroomDecoration`に初期位置と、状態管理用にマップWidgetとID番号、状態管理と接触の判定用にプレイヤーを渡します。

```lib/maps/halloween_map_02.dart
import 'package:simple_bonfire/decorations/big_shroom.dart';　// 追加

// ...省略...

class _HalloweenMap02State extends State<HalloweenMap02> {
  // ...省略...

  // 追加
  void _addGameItems(BonfireGame game) {
    // キノコ１つ目
    game.add(
      BigShroomDecoration(
        initPosition: Vector2(tileSize * 9, tileSize * 3),
        map: widget.runtimeType,
        id: 0,
        player: game.player! as PlayerBeardedDude,
      ),
    );
    // キノコ２つ目
    game.add(
      BigShroomDecoration(
        initPosition: Vector2(tileSize * 13, tileSize * 4),
        map: widget.runtimeType,
        id: 1,
        player: game.player! as PlayerBeardedDude,
      ),
    );
  }
}

```

### 3.2 アイテム生成メソッドを実装
`BonfireWidget`に`onReady`を追加し、作成したメソッドを実行します。

```lib/maps/halloween_map_02.dart
import 'package:simple_bonfire/decorations/big_shroom.dart';

// ...省略...

class _HalloweenMap02State extends State<HalloweenMap02> {
  // ...省略...

  @override
  Widget build(BuildContext context) {
    return BonfireWidget(
      // showCollisionArea: true,
      onReady: (game) {
        _addGameItems(game); // 追加
      },

      // ...省略...
    );
  }
}

```

- ゲーム画面でキノコが表示され、接触で消えることを確認
![Videotogif(1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/918c2e15-7b71-1093-2bfa-38e44bff83e9.gif)

## 4. StateControllerの作成と実装

マップにアイテムを配置して消すだけでは、マップ移動をするたびにアイテムが再生成されてしまいますし、アイテムの所持や使用をすることもできません。
まずは取得したアイテムを管理し、アイテムが再生成されないようにします。

### 4.1 `StateController`の作成と実装

- ファイルの作成
`lib/player/`ディレクトリに`player_bearded_dude_controller.dart`を作成します。
- `StateController`クラスの作成
ここでは`BearedDudeController`という名前にしました。
クラス内にはアイテムの状態を格納するためにプロパティを設けます。
    - `itemCounts`：各アイテムの所有数
    - `itemObtained`：マップごとの取得済みアイテム
```lib/player/player_bearded_dude_controller.dart
import 'package:bonfire/bonfire.dart';
import 'package:simple_bonfire/model/game_item.dart';
import 'package:simple_bonfire/player/player_bearded_dude.dart';

class BearedDudeController extends StateController<PlayerBeardedDude> {
  Map<GameItem, int> itemCounts = {}; // 持っているアイテムの数
  Map<Type, Set<int>> itemObtained = {}; // マップごとの取得済みアイテム

  @override
  void update(dt, component) {}
}

```
プロパティだけでなくプレイヤーに関するメソッドなどのロジカルな要素も、プレイヤークラスとは切り離して`StateContoroller`内に記述するとよいでしょう。

- プレイヤーに`StateController`を継承
`Mixin`として`UseStateController`を追加します。

```lib/player/player_bearded_dude.dart
// 追加
import 'package:simple_bonfire/player/player_bearded_dude_controller.dart';
// ...省略...

// UseStateControllerを追加
class PlayerBeardedDude extends SimplePlayer
    with ObjectCollision, UseStateController<BearedDudeController> {
  // ...省略...
}
```

- `StateController`を`Inject`
作成した`BearedDudeController`をゲーム内のどこからでも単一のインスタンスとして扱える (繰り返し再生成されずに状態を保持する) 様に、`main()`で`BonfireInjector().put()`を実行します。

```lib/main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  if (!kIsWeb) {
    await Flame.device.setLandscape();
    await Flame.device.fullScreen();
  }

  await PlayerSpriteSheet.load();

  // StateControllerをInject
  BonfireInjector().put((i) => BearedDudeController());

  runApp(const MyApp());
}
```
- アイテムの取得処理を実装する
`UseStateController`を継承したプレイヤークラスは、下記の様に`controller`でプロパティの参照と更新ができる様になります。
- `itemCounts`と`itemObtained`にアイテムを追加するだけですが、それぞれ`map`型で管理しているため、初回取得時の`null`だけガードしておきます。

```lib/decorations/big_shroom.dart
import 'package:bonfire/bonfire.dart';
import 'package:simple_bonfire/player/player_bearded_dude.dart';
import 'package:simple_bonfire/model/game_item.dart';

class BigShroomDecoration extends GameDecoration with Sensor {

  //...省略...

  @override
  void onContact(GameComponent component) {
    // プレイヤーに接触
    if (component == player) {
      // アイテム所持数の更新
      if (player.controller.itemCounts[gameItem] == null) { // map要素のnullガード
        player.controller.itemCounts[gameItem] = 0;
      }
      player.controller.itemCounts[gameItem] = player.controller.itemCounts[gameItem]! + 1;

      // マップのアイテム取得状態の更新
      if (player.controller.itemObtained[map] == null) { // map要素のnullガード
        player.controller.itemObtained[map] = {};
      }
      player.controller.itemObtained[map]!.add(id);

      // 画面から消える
      removeFromParent();
    }
  }
}
```

### 4.2 アイテムが再生成されない実装

先の`2. アイテムオブジェクトの作成`でも触れた様に、今回はアイテムを生成するかどうかの分岐を、アイテム側で行います。
アイテムがマップに追加された直後に実行される`onMount()`内でアイテムの取得状態を確認し、取得済みであればマップから削除します。

```lib/decorations/big_shroom.dart
import 'package:bonfire/bonfire.dart';
import 'package:simple_bonfire/player/player_bearded_dude.dart';
import 'package:simple_bonfire/model/game_item.dart';

class BigShroomDecoration extends GameDecoration with Sensor {
  // ...省略...

  @override
  void onMount() {
    if (player.controller.itemObtained[map] != null &&
        player.controller.itemObtained[map]!.contains(id)) {
      removeFromParent();
    }
    super.onMount();
  }

  // ...省略...
}
```

ゲーム画面で、一度取得したキノコがマップ移動を挟んでも再生成されないのを確認する。
![Screen Shot 2022-10-27 at 0.46.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/58eac230-82ef-658a-b7c9-e7c5fdee988e.png)

## 5. イベント用Decorationの作成と配置

- イベントと一言で言っても色々なパターンが考えられますが、今回は「スイッチで扉が開く」的なイベントを実装します。状態に応じて画像と当たり判定を更新します。
今回はアセットやマップの準備の手間を惜しんで「キノコを拾うとカカシの**画像が変化**し、**当たり判定**がなくなる」という例を紹介します。

### 5.1 アセットの準備

キノコと同様の[**タイルセット**](https://franuka.itch.io/fantasy-rpg-halloween-pack)から、今回は縦長の16x32pxで切り出したものを利用します。

↓ ゲーム内に配置したイメージ、変化前がこちらで
![Screen Shot 2022-11-07 at 0.14.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/7b844b91-b62a-1a66-63d8-b49da35801cc.png)
↓ ゲーム内に配置したイメージ、変化後はこちら
![Screen Shot 2022-11-07 at 0.14.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/b2a539f7-2b4d-e1fb-8748-1a54d9c9093c.png)

### 5.2 イベントオブジェクトクラスの実装

- `lib/decorations/`に`scarecrow.dart`を作成します。
- カカシはただの置き物なので、`Sensor`は継承させず、代わりに当たり判定を持たせるために`ObjectCollision`を継承します。
- 2マス分の縦長の画像ですが、接地部分は画像の下半分の想定なので、`position.y`をマイナスにして上方向に飛び出す様にしています
- `setupCollision()`で当たり判定を定義しています。
- 変化する前後の画像それぞれ必要なので、画像パスを二つ定義します。
- `BonfireInjector().get()`でプレイヤーの`StateController`を参照しています。

```lib/decorations/scarecrow.dart
import 'package:bonfire/bonfire.dart';
import 'package:simple_bonfire/maps/halloween_map_02.dart';
import 'package:simple_bonfire/player/player_bearded_dude_controller.dart';

class ScarecrowDecoration extends GameDecoration with ObjectCollision {
  ScarecrowDecoration({required this.initPosition})
      : super.withSprite(
          sprite: Sprite.load(imagePath),
          position: initPosition - Vector2(0, -48),
          size: Vector2(48, 96),
        ) {
    setupCollision(
      CollisionConfig(
        collisions: [
          CollisionArea.rectangle(size: Vector2(12, 18), align: Vector2(18, 78)),
        ],
      ),
    );
  }
  final Vector2 initPosition; 

  static const imagePathOn = 'objects/scarecrow_pumpkin_on.png';
  static const imagePath = 'objects/scarecrow_pumpkin_off.png';

  // StateControllerを取得
  final controller = BonfireInjector().get<BearedDudeController>();
  // 連続処理のガード用
  bool lightOn = false;

  @override
  void update(dt) async {
    // HalloweenMap02のアイテム取得状態を参照して
    final itemObtained = controller.itemObtained[HalloweenMap02];
    // キノコ取得済みなら
    if (itemObtained != null && itemObtained.containsAll({0, 1})) {
      // 画像を変更
      sprite = await Sprite.load(imagePathOn);
      // 当たり判定を変更
      setupCollision(
        CollisionConfig(collisions: []),
      );
      lightOn = true;
    }
    super.update(dt);
  }
}
```

### 5.3 オブジェクトの生成メソッドの作成と実装

アイテム追加とほぼ同じ手順で、まずは生成メソッドを作成し、`BonfireWidget`の`onReady`内で実行します。
解説はコメントに留めます。

```a.dart
import 'package:simple_bonfire/decorations/scarecrow.dart';

// ...省略...

class _HalloweenMap02State extends State<HalloweenMap02> {
  final tileSize = 48.0; // タイルのサイズ定義

  // カカシ追加メソッドを追加
  void _addScarecrow(BonfireGame game) {
    game.add(ScarecrowDecoration(
      initPosition: Vector2(tileSize * 15, tileSize * 7),
    ));
  }

  @override
  Widget build(BuildContext context) {
    // 画面
    return BonfireWidget(
      // ...省略...
      // デコレーションの配置
      onReady: (game) {
        _addGameItems(game);
        _addScarecrow(game); // 追加
      },
      
      // ...省略...
    );
  }
}

```

- 最初は大人しかったカカシが…
![Screen Shot 2022-10-27 at 0.39.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/e7dd4a73-e820-b364-10c4-ee15cc43d552.png)
- キノコを取るとピカっと光り、すり抜ける様になったら完成！
![Screen Shot 2022-10-27 at 0.39.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/c4491e16-0e4f-ce38-d75e-af95920a8f50.png)
ちなみに、[Part 3](https://qiita.com/KAZMA_WED/items/0d37df4a6681a2f97017)でマップをTiledで作成した段階では、カカシは木などと同じくマップ内のタイルとして配置していました。
今回、カカシはオブジェクト化に際してマップからは削除しました。

## おわりに

状態管理と分岐を実装し、かなりゲームらしく仕上がってきたと思います。

今回は、プレイヤークラスにマップ毎のアイテムの状態管理まで任せてしまいましたが、実際は所持品などの管理のみに留める方が汎用的だと思います。
あくまで最も簡単な実装例のひとつとしてご参考にしてください。

次回最終パートでは、動きのあるNPCと会話のイベントを実装します。
いいね、コメントや質問などいつでもお待ちしております！！

[Part 5はこちら](https://qiita.com/KAZMA_WED/items/c1d33ff4dcd293ade1af)