FlutterでRPGゲームを作る【Part 3/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 3です。
    - [Part 1：**マップ**と**UI**の実装](https://qiita.com/KAZMA_WED/items/ed43e1c7bbaec397c486)
    - [Part 2：**プレイヤー**と当たり判定の実装](https://qiita.com/KAZMA_WED/items/b9e5413050bba938a528)
    - Part 3：**マップ移動**の実装
    - [Part 4：**アイテム**と**取得イベント**の実装](https://qiita.com/KAZMA_WED/items/1272279fb3ea2c251fd9)
    - [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. マップを作成しセンサーを配置する
2. マップ移動のメソッドを作成する
3. マップ移動を実装する

## 1. マップを作成しセンサーを配置する

### 1.1 2つ目のマップを作成する

- マップ移動を実装する前に、まず2つ目のマップを作成します。
マップWidgetはマップ生成用のjsonファイルを元に作られます。
GUIツール[**Tiled**](https://thorbjorn.itch.io/tiled)を使ったマップ生成用jsonファイルの作成方法は[Part 1](https://qiita.com/KAZMA_WED/items/ed43e1c7bbaec397c486)や[Part 2](https://qiita.com/KAZMA_WED/items/b9e5413050bba938a528)でも簡単に解説していますので、そちらをご覧ください。
今回も[このアセット](https://franuka.itch.io/fantasy-rpg-halloween-pack)で画像の様なマップを用意し、名前は`simple_bonfire/assets/maps/halloween_map_02.json`としました。
無料アセットを利用されたいかたは[こちらなど](https://0x72.itch.io/dungeontileset-ii)がおすすめです
![Screen Shot 2022-10-24 at 20.06.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/742b5627-336b-dccc-2701-695c62858ec4.png)
- 今回は柵と木や家を重ねて描画したり大きな木を隣接して配置するために、レイヤーをたくさん重ねています。

### 1.2 センサーを配置する

マップの移動はセンサーオブジェクトとプレイヤーの接触をトリガーにします。

- オブジェクトレイヤーの追加
センサーを配置するために、マップにオブジェクトレイヤーを追加します。
`レイヤー` > `新規` > `オブジェクトレイヤー`の順で選択すると、画像の様にタイルレイヤーとは異なるアイコンのレイヤーが右のカラムに追加されます。
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323730303630322f38346265613536392d613564342d383434612d653836352d3562616363666562336331342e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/e1e0dc61-e13d-ced6-4ddd-f21f178ef1af.png)

- センサーを配置
    - 追加されたオブジェクトレイヤーをリストから選択した状態で、メニューの`四角形を追加`を選択する。
    - マップの出口付近をドラッグしてセンサーを配置する。
- センサーのプロパティの編集
    - メニューの`オブジェクトを選択`を選択後、中央のマップ上に配置したセンサーを選択
    - 名前に`bottomExitSensor`, Classに`exitSensor`を指定
値は任意ですが、わかりやすく統一感のあるものがよいです。
    - 必要であれば位置やサイズを微調整して、保存
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323730303630322f30363231366565362d353935322d313531362d353137352d3435393765353433313263612e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/bff0b2b4-68d3-2c05-3f41-975422758ddc.png)
- 生成されたjsonファイルの`layers`の配列に`"type": "objectgroup"`の要素が追加されているのを確認してください。
```simple_bonfire/assets/maps/halloween_map_02.json
{
   "compressionlevel": -1,
   "height": 16,
   "infinite": false,
   "layers": [
      {"tilelayerが": "ここに並びます"},
      {
         "draworder": "topdown",
         "id": 6,
         "name": "Object Layer 1",
         "objects": [
            {
               "class": "exitSensor",
               "height": 8,
               "id": 1,
               "name": "bottomExitSensor",
               "rotation": 0,
               "visible": true,
               "width": 64,
               "x": 128,
               "y": 216
            }
         ],
         "opacity": 1,
         "type": "objectgroup",
         "visible": true,
         "x": 0,
         "y": 0
      }
   ],
   "以下": "略"
}
```

### 1.3 マップWidgetを作成する

ほぼ[Part 1](https://qiita.com/KAZMA_WED/items/ed43e1c7bbaec397c486)で作成した`halloween_map_01.dart`のコピペなので、ここには変更箇所のみ記載します。
- 読み込むjsonのパス
- プレイヤーの初期位置を調整し、初期方向を上に
- `cameraConfig()`に`zoom: 1.25`を追加
```simple_bonfire/lib/maps/halloween_map_02.dart
import 'package:flutter/material.dart';
import 'package:bonfire/bonfire.dart';
import 'package:flutter/services.dart';
import 'package:simple_bonfire/player/player_bearded_dude.dart';
import 'package:simple_bonfire/player/player_sprite.dart';

class HalloweenMap02 extends StatefulWidget {
  const HalloweenMap02({Key? key}) : super(key: key);
  @override
  State<HalloweenMap02> createState() => _HalloweenMap01State();
}

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

  @override
  Widget build(BuildContext context) {
    // 画面
    return BonfireWidget(
      // showCollisionArea: true,
      // マップ用jsonファイル読み込み
      map: WorldMapByTiled(
        'maps/halloween_map_02.json',
        forceTileSize: Vector2(tileSize, tileSize),
      ),
      // プレイヤーキャラクター
      player: PlayerBeardedDude(
        Vector2(tileSize * 9.5, tileSize * 12),
        spriteSheet: PlayerSpriteSheet.all,
        initDirection: Direction.up,
      ),
      // カメラ設定
      cameraConfig: CameraConfig(
        zoom: 1.25, // 小さめのマップなので少し拡大してみる
        moveOnlyMapArea: true,
        sizeMovementWindow: Vector2.zero(),
        smoothCameraEnabled: true,
        smoothCameraSpeed: 10,
      ),
      // 入力インターフェースの設定
      joystick: Joystick(
        // 画面上のジョイスティック追加
        directional: JoystickDirectional(
          color: Colors.white,
        ),
        actions: [
          // 画面上のアクションボタン追加
          JoystickAction(
            color: Colors.white,
            actionId: 1,
            margin: const EdgeInsets.all(65),
          ),
        ],
        // キーボード用入力の設定
        keyboardConfig: KeyboardConfig(
          keyboardDirectionalType: KeyboardDirectionalType.wasdAndArrows,
          acceptedKeys: [LogicalKeyboardKey.space],
        ),
      ),
      // ロード中の画面の設定
      progress: Container(
        width: double.maxFinite,
        height: double.maxFinite,
        color: Colors.black,
      ),
    );
  }
}
```
### 1.4 もう一つのマップを調整する
[Part 1](https://qiita.com/KAZMA_WED/items/ed43e1c7bbaec397c486)で作ったマップのプレイヤーの初期位置を入り口の近くに変更します。(今回はセンサーのすぐ上あたりに変更)
```simple_bonfire/lib/maps/halloween_map_01.dart
// ...省略...

class _HalloweenMap01State extends State<HalloweenMap01> {
  final tileSize = 48.0;

  @override
  Widget build(BuildContext context) {
    // 画面
    return BonfireWidget(
      // ...省略...

      // プレイヤーキャラクター
      player: PlayerBeardedDude(
        Vector2(tileSize * 9.5, tileSize * 12.5), // 初期位置変更
        spriteSheet: PlayerSpriteSheet.all,
        initDirection: Direction.up, // 初期向き変更
      ),

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

## 2. マップ移動のメソッドを作成する

Flutterでよく使われる`Navigator.push()`や`pop()`による画面遷移はゲームのマップ移動には不向きです。
`pushAndRemoveUntil()`を使い、より適した、そしてゲームらしいエフェクトの画面遷移を実装します。

### 2.1 遷移メソッドの作成

コードは[**Bonfire公式サンプル**](https://bonfire-engine.github.io/#/examples)のひとつ、[**MultiBiomeGame**](https://github.com/RafaelBarbosatec/multi-biome)から拝借しました。

```simple_bonfire/lib/utilities/extentions.dart
import 'package:flutter/material.dart';

extension BuildContextExtensions on BuildContext {
  Future goTo(Widget page) {
    return Navigator.pushAndRemoveUntil(
      this,
      PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) => page,
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          return Container(
            color: Colors.black,
            child: FadeTransition(
              opacity: animation,
              child: child,
            ),
          );
        },
      ),
      (Route<dynamic> route) => false,
    );
  }
}
```

### 2.2 マップ移動センサーデコレーションを作成

Bonfireでは、プレイヤーとマップ以外のゲーム内の要素は正式には[**Decoration**](https://bonfire-engine.github.io/#/decoration)と呼びます。
ここでは遷移を実行する`Decoration`を作成します。
プレイヤーと接触をトリガーにメソッドを実行できる`Sensor`を継承しています。
引数で`position`, `size`と、移動先のマップWidgetを受け取ります。

```simple_bonfire/lib/utilities/exit_map_sensor.dart
import 'package:flutter/material.dart';
import 'package:bonfire/bonfire.dart';
import 'package:simple_bonfire/utilities/extentions.dart';

class ExitMapSensor extends GameDecoration with Sensor {
  ExitMapSensor({required Vector2 position, required Vector2 size, required this.nextMap})
      : super(position: position, size: size);
  // 移動先のマップ
  Widget nextMap;
  // 連続実行防止用
  bool hasContact = false;

  @override
  void onContact(component) {
    if (!hasContact && component is Player) {
      hasContact = true;
      context.goTo(nextMap);
    }
  }
}
```


## 3. マップ移動を実装する

作成した`ExitMapSensor`デコレーションをオブジェクトとしてゲーム内に生成します。

### 3.1 マップにセンサーを追加

- `BonfireWidget`内の`map: WorldMapByTiled`に`objectsBuilder`を追加
- `objectsBuilder`に`ExitMapSensor`デコレーションを渡す
オブジェクト群はmap型で渡します。keyはTiledで作成した (jsonファイルに記載されている) オブジェクトの名前を、valueに作成した`ExitMapSensor`を渡します。
Tiledで設定した (jsonファイルに記載されている) オブジェクトの座標やサイズなどの値は`properties`の各プロパティから参照できます。

```simple_bonfire/lib/maps/halloween_map_02.dart
import 'package:simple_bonfire/utilities/exit_map_sensor.dart';
// ...省略...

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

  @override
  Widget build(BuildContext context) {
    // 画面
    return BonfireWidget(
      showCollisionArea: true, // センサー確認するためtrueにする
      // マップ用jsonファイル読み込み
      map: WorldMapByTiled(
        'maps/halloween_map_02.json',
        forceTileSize: Vector2(tileSize, tileSize),
        objectsBuilder: {
          // デコレーションを追加
          'bottomExitSensor': (properties) => ExitMapSensor(
                position: properties.position, // jsonから取得
                size: properties.size,         // jsonから取得
                nextMap: const HalloweenMap01(), // 移動先のマップ
              ),
        },
      ),

      // ...中略...
    );
  }
}
```

### 3.2 ゲーム内で確認

`BonfireWidget`内に`showCollisionArea: true`を追加してオブジェクトを可視化したら、ゲーム画面を確認します。
![Screen Shot 2022-10-25 at 1.08.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/b860f07f-29af-c35f-9d10-95331abcedc6.png)
想定通りの位置にセンサーがあり、接触で実際にマップ移動することを確かめてください。
![Screen Shot 2022-10-25 at 1.08.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/b0483c8b-f55c-94fc-2e51-195fbb820604.png)

正しく実装できているのが確認できたら、移動先のマップにも同様にセンサーを実装しましょう。
同じ作業ですので詳しい説明は省きます。
- オブジェクトレイヤーを追加しセンサーを配置して…
![Screen Shot 2022-10-24 at 23.52.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/a31955fe-88cf-3163-204e-8eafbaa65f7a.png)

- `objectsBuilder`にセンサーを追加して…
```simple_bonfire/lib/maps/halloween_map_01.dart
import 'package:simple_bonfire/utilities/exit_map_sensor.dart';
// ...省略...

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

  @override
  Widget build(BuildContext context) {
    // 画面
    return BonfireWidget(
      showCollisionArea: true, // センサー確認するためtrueにする
      // マップ用jsonファイル読み込み
      map: WorldMapByTiled(
        'maps/halloween_map_01.json',
        forceTileSize: Vector2(tileSize, tileSize),
        objectsBuilder: {
          // デコレーションを追加
          'bottomExitSensor': (properties) => ExitMapSensor(
                position: properties.position, // jsonから取得
                size: properties.size,         // jsonから取得
                nextMap: const HalloweenMap02(), // 移動先のマップ
              ),
        },
      ),

      // ...中略...
    );
  }
}
```
- ゲーム内で表示と動作を確認して完了です。
![Screen Shot 2022-10-25 at 11.12.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/2377668a-e469-a6d3-1f09-f402bf92ab55.png)

## おわりに

今回はセンサーをマップ移動に利用しました。
次回Part 4では、落ちてるアイテムを拾ったりスイッチを踏むなどのイベントを引き続きセンサーを使って実装していきます。
いいねとコメント、質問などお待ちしております！

[Part 4はこちら](https://qiita.com/KAZMA_WED/items/1272279fb3ea2c251fd9)