FlutterでRPGゲームを作る【Part 5/5 - NPCと会話】

![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 5です。
    - [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：**アイテム**取得と**イベント**の実装](https://qiita.com/KAZMA_WED/items/1272279fb3ea2c251fd9)
    - Part 5：**NPC**と**会話**の実装
- **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. NPCクラスの作成と実装 (マップへの配置にTiled利用)
1. NPCとの会話の実装
1. NPCがランダムに歩くようにする
![Screen Shot 2022-11-09 at 19.30.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/5f34f141-ffae-3252-2c6b-96bd513c3e0d.png)

### 本記事で実装する機能について

NPCとの会話のために主に下記の実装をします。
- プレイヤーがNPCの近くにいるかどうか判定して
- ユーザーの入力に合わせて会話テキストの表示

上記の機能だけでも会話としては最小限は機能はするのですが、今回はよりナチュラルな振る舞いを再現するために、追加でこちらも実装しています。
- ランダムで歩きまわる
- プレイヤーが近くに来ると反応する (プレイヤーの方向を見て立ち止まる)
- 押し退けられる (勝手に道を塞いでも無理やり動かせる)

## 1. アセットの準備

- NPC用の画像は、プレイヤーと同じく[こちらから](https://franuka.itch.io/rpg-townsfolk-pack)ダウンロードした物を利用します
- そのままでも使えますが、分かりやすいので今回利用するキャラクターの箇所だけ128 x 96pxでクロップします
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323730303630322f39306430613962302d373135632d356431372d373762342d6133313463656561326264662e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/3890dba7-f1f3-bab5-4023-73f4dbff98d4.png)
- ファイル名を`house_mother.png `(またはお好みの名前)にして`assets/charactors/`に配置します
- `pubspec.yaml`に画像ディレクトのパスが正しく設定されているか確認してください
(過去Partから変更してない場合は、既に設定済みです)
```pubspec.yaml
flutter:
  uses-material-design: true
  assets:
    - assets/images/
    - assets/images/objects/
    - assets/images/maps/
    - assets/images/charactors/
```

## 2. NPCクラスの作成

### 2.1 NPC用スプライトクラスの作成と実装

- `lib/npc/`下に`npc_house_mother_sprite.dart`を作成
[**Part 2**](https://qiita.com/KAZMA_WED/items/b9e5413050bba938a528)のプレイヤー実装の時と同じのため詳細な詳細は割愛します。
```lib/npc/npc_house_mother_sprite.dart
import 'package:bonfire/bonfire.dart';

class HouseMotherSprite {
  static late SpriteSheet sheet;

  // ゲーム起動時に実行するメソッド
  static Future<void> load() async {
    sheet = await _create('charactors/house_mother.png');
  }

  // 画像からSpriteSheetを生成するメソッド
  static Future<SpriteSheet> _create(String path) async {
    final image = await Flame.images.load(path);
    return SpriteSheet.fromColumnsAndRows(image: image, columns: 8, rows: 4);
  }
}
```

- 起動後のロードの実装
```lib/main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  if (!kIsWeb) {
    await Flame.device.setLandscape();
    await Flame.device.fullScreen();
  }

  await BeardedDudeSprite.load();
  await HouseMotherSprite.load(); // <- 追加

  // Stateコントローラー
  BonfireInjector().put((i) => BearedDudeController());

  runApp(const MyApp());
}
```

### 2.2 NPCクラスの作成

ここではまず、ゲーム内に配置できるほぼ最小限の実装をします

- `lib/npc/`下に`npc_house_mother.dart`を作成
- NPCは`SimpleNpc`クラスと、当たり判定を持たせるために`ObjectCollision`Mixinを継承します。
ほとんど[プレイヤークラスの作成時](https://qiita.com/KAZMA_WED/items/b9e5413050bba938a528)と同じなので詳細な説明は割愛します。

```lib/npc/npc_house_mother.dart
import 'package:bonfire/bonfire.dart';
import 'package:flutter/material.dart';

class NpcHouseMother extends SimpleNpc with ObjectCollision {
  NpcHouseMother(Vector2 position, SpriteSheet spriteSheet,
      {Direction initDirection = Direction.right})
      : super(
          // アニメーションの設定
          animation: SimpleDirectionAnimation(
            idleDown: spriteSheet.createAnimation(row: 0, stepTime: 0.6, from: 1, to: 3).asFuture(),
            idleLeft: spriteSheet.createAnimation(row: 1, stepTime: 0.6, from: 1, to: 3).asFuture(),
            idleRight:
                spriteSheet.createAnimation(row: 2, stepTime: 0.7, from: 1, to: 3).asFuture(),
            idleUp: spriteSheet.createAnimation(row: 3, stepTime: 0.6, from: 1, to: 3).asFuture(),
            runDown: spriteSheet.createAnimation(row: 0, stepTime: 0.2, from: 4, to: 8).asFuture(),
            runLeft: spriteSheet.createAnimation(row: 1, stepTime: 0.2, from: 4, to: 8).asFuture(),
            runRight: spriteSheet.createAnimation(row: 2, stepTime: 0.2, from: 4, to: 8).asFuture(),
            runUp: spriteSheet.createAnimation(row: 3, stepTime: 0.2, from: 4, to: 8).asFuture(),
          ),
          // 表示サイズ、初期位置と方向、移動スピード (歩行時)
          size: Vector2(16, 24) * 3,
          position: position,
          initDirection: initDirection,
          speed: 32,
        ) {
    // 当たり判定の設定
    setupCollision(
      CollisionConfig(
        collisions: [
          CollisionArea.rectangle(
            size: Vector2(sizeNpc.x, sizeNpc.y * 0.5),
            align: Vector2(0, sizeNpc.y * 0.5),
          ),
        ],
      ),
    );
  }

  static final sizeNpc = Vector2(16, 23) * 3;
}
```

### 2.3 NPCをゲームに配置

Bonfireにおけるオブジェクト = `Decoration`をマップに配置する方法はいくつか考えられますが、今回は[Part2]((https://qiita.com/KAZMA_WED/items/b9e5413050bba938a528))の移動用センサーと同じ方法で、マップ用jsonファイル内で位置を決めます。

- NPCを配置する場所を[Tiled](https://thorbjorn.itch.io/tiled)を使ってマップ用json内に記述
    1. Tiledで作成済みのマップjsonを開く
    過去Partから変更がなければ`assets/images/maps/`下の`halloween_map_02.json`です。
    1. 右上の`レイヤー`一覧からオブジェクトのレイヤー(パープルのアイコン)を選択する
    1. 上のメニューバーから`四角形を追加`を選択する。
    1. 真ん中のマップ上をドラッグして配置する場所を決める。
    1. 左のカラムのプロパティから名前をつける (今回は`houseMother`とした)
    今回はClassの値は特に使わないため空白でかまいません
    1. プロパティからXY座標を調節し、幅と高さをタイルに合わせて16x16pxにする
![Screen Shot 2022-11-07 at 13.10.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/044124af-bfcc-de0b-bb87-d600f9587972.png)
↑ 完成イメージ

- Widget側にもNPCを追加
`Decoration`を追加するだけならすぐ下の`'bottomExitSensor'`の箇所と同じように、インスタンスの生成と同時にゲームに追加することもできます。ここでは、後に`onReady`で同じNPCのインスタンスを参照するため、生成はひとつ外のスコープで先に行います。
(`build()`内の最初の`late NpcHouseMother npcHouseMother;`の箇所です)

```main.dart
import 'package:simple_bonfire/npc/npc_house_mother.dart';
import 'package:simple_bonfire/npc/npc_house_mother_sprite.dart';

// ...省略...

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

  @override
  Widget build(BuildContext context) {

    // ---------- 追加 ---------- 
    late NpcHouseMother npcHouseMother;

    // 画面
    return BonfireWidget(
      // マップ用jsonファイル読み込み
      map: WorldMapByTiled(
        objectsBuilder: {

          // ---------- 追加ここから ---------- 
          'houseMother': (properties) {
            npcHouseMother = NpcHouseMother(
              properties.position,
              HouseMotherSprite.sheet,
            );
            return npcHouseMother;
          },
          // ---------- ここまで ---------- 

          'bottomExitSensor': (properties) => ExitMapSensor(
                position: properties.position,
                size: properties.size,
                nextMap: const HalloweenMap01(),
              ),
          
          // ...省略...
        },
      ),
      // ...省略...
    );
  }
  // ...省略...
}
```

- ゲーム画面で確認する
まだ話しかけることもできず同じ場所でじっとしているだけですが、一旦の実装ができました。
![Screen Shot 2022-11-07 at 13.58.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/f84280fc-ee43-742a-0774-80929922ca2d.png)

## 3. NPCとの会話の実装

NPCとの会話イベントは、分解すると以下の3つの処理に分けられます。
- プレイヤーが近くにいることを検知して
- キーボードまたは画面上のボタンの押下に合わせて
- 会話テキストを表示するメソッドを呼び出す

### 3.1 プレイヤーが近くにいることを検知する

Bonfireの`Npc`クラスは`Vision`Mixinを継承しており、`Player`との距離に応じて処理を実行するメソッド`seePlayer()`を持っています。

- `update()`内で`seePlayer()`を呼び出す
今回与える引数は以下の３つです。
    - `radiusVisionn`：視野の広さ
    - `observed`：プレイヤーが視野内にいる時に実行する処理 (今回はプレイヤーの方向を向かせる)
    - `notObserved`：視野外にいる時に実行する処理
- 接近時に一度だけ実行する処理がある場合は`if(!_seePlayer){}`の中に記述してください。
([ソースコード](https://github.com/KazmaWed/simple_bonfire)の方ではビックリマークを出す実装をしてるので、興味ある方はご覧ください。)

```lib/npc/npc_house_mother.dart
class NpcHouseMother extends SimpleNpc with ObjectCollision {
  NpcHouseMother() : super() {}
  // ..省略...

  // 視野の半径
  static const radiusVision = 54.0;
  // プレイヤーが近くにいるかどうか
  bool _seePlayer = false;

  @override
  void update(double dt) {
    super.update(dt);

    // Playerとの距離に応じて処理を実行
    seePlayer(
      // 視野の半径
      radiusVision: radiusVision,
      // プレイヤーが視野内にいる時
      observed: (player) {
        if (!_seePlayer) {
          _seePlayer = true;
        }
        _faceToPlayer(player);
      },
      // プレイヤーが視野内にいない時
      notObserved: () {
        if (_seePlayer) {
          _seePlayer = false;
        }
      },
    );
  }

  // プレイヤーの方向を見る
  void _faceToPlayer(GameComponent player) {
    // プレイヤーとの位置の差
     final displacement = player.center - center;

    // プレイヤーの位置に応じてアニメーションを変更
    if (displacement.x.abs() > displacement.y.abs()) {
      if (0 < displacement.x) {
        animation!.play(SimpleAnimationEnum.idleRight);
      } else {
        animation!.play(SimpleAnimationEnum.idleLeft);
      }
    } else {
      if (0 < displacement.y) {
        animation!.play(SimpleAnimationEnum.idleDown);
      } else {
        animation!.play(SimpleAnimationEnum.idleUp);
      }
    }
  }
}
```

- `_faceToPlayer()`内`if`の条件文については詳細な説明は割愛しますが、三角関数の考え方でプレイヤーがいる方向の上下左右を判定しています。

### 3.2 会話テキストを表示するメソッド作成

会話テキストの表示には`TalkDialog.show()`メソッドが用意されています。

- 演出のために、会話中はNPCをカメラの中心にする実装をしています。
- 画面内をクリックする他に、スペースバーでも会話を進められる様に追加しています。
- `TalkDialog.show()`内二つ目の引数で`Say()`を複数与えると、連続でテキストを表示します。

```lib/npc/npc_house_mother.dart
// ...省略...

// JoystickListenerを追加
class NpcHouseMother extends SimpleNpc with ObjectCollision {
  NpcHouseMother() : super() {}

  // ...省略...

  // 会話テキストを表示するメソッド
  void _showTalk() {
    gameRef.camera.moveToTargetAnimated(this); // カメラをNPCに向ける
    TalkDialog.show(
      context,
      // テキストの量だけ`Say()`を配列に追加する
      [
        Say(
          text: [const TextSpan(text: 'カボチャはカボチャでも、食べられないカボチャって、一体何だと思う？')], //　表示するテキスト
          personSayDirection: PersonSayDirection.LEFT, // NPCをテキストの左に表示
          person: SizedBox(
                    width: size.x,
                    height: size.y,
                    child: animation!.idleDown!.asFuture().asWidget(),
                  ), // 表示するアニメーション
        ),
        // 必要な数だけSay()を追加
      ],
      // 会話を次に進めるキーの追加
      logicalKeyboardKeysToNext: [LogicalKeyboardKey.space],
      // テキストのスタイル
      style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white),
      // 会話終了後にカメラをプレイヤーに戻す
      onFinish: () {
        gameRef.camera.moveToTargetAnimated(gameRef.player!);
      },
    );
  }
}

```

### 3.3 キーボードとボタンの入力でメソッドを実行する実装

NPCには追加で`JoystickListener`を継承し、ゲーム側では`addLister()`でNPCをリスナーに追加します。

- `JoystickListener`を継承する
- `joystickAction()`メソッド内に、入力があった時に実行する処理を書く
`if`の条件文3つの意味はそれぞれ下記の通りです
    1. キーがスペースバーまたは画面上のボタン
    1. 入力の種類が「押下」である
    1. `seePlayer`が`true` ( = プレイヤーとの距離が十分近い)
- `if`分内で先に作成した`_showTalk()`を実行する

```lib/npc/npc_house_mother.dart
// ...省略...

// JoystickListenerを追加
class NpcHouseMother extends SimpleNpc with ObjectCollision, JoystickListener {
  NpcHouseMother() : super() {}

  // ...省略...

  // キーボードやボタンの押下で実行する処理
  @override
  void joystickAction(JoystickActionEvent event) {
    if ((event.id == 1 || event.id == LogicalKeyboardKey.space.keyId) &&
        event.event == ActionEvent.DOWN &&
        seePlayer) {
      _showTalk();
    }
    super.joystickAction(event);
  }
}
```

### 3.4 キーボードとボタンの入力をNPCに通知する

- `JoystickObserver`にNPCを追加
マップ生成後に実行される`onReady()`内で`BonfireGame`の`addJoystickObserver()`メソッドを実行しNPCを追加する。
- キーボードとボタン入力の有効化
[Part 1](https://qiita.com/KAZMA_WED/items/ed43e1c7bbaec397c486)で作成したマップでは有効化済みですが、念の為以下のふたつを確認してください。
  - `Joystick()`の`actions`に`id： 1`のボタンが追加されてる
  - `keyboardConfig`の`acceptedKeys`に`LogicalKeyboardKey.space`が追加されている

```map.dart
// ...省略...

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

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

      onReady: (game) {
        // ...省略...
        game.addJoystickObserver(npcHouseMother); // ←追加
      },

      // 入力インターフェースの設定
      joystick: Joystick(
        // ...省略...

        actions: [
          // 画面上のアクションボタン追加
          JoystickAction(
            color: Colors.white,
            actionId: 1,
            margin: const EdgeInsets.all(65),
          ),
        ],
        // キーボード用入力の設定
        keyboardConfig: KeyboardConfig(
          keyboardDirectionalType: KeyboardDirectionalType.wasdAndArrows, // キーボードの矢印とWASDを有効化
          acceptedKeys: [LogicalKeyboardKey.space], // キーボードのスペースバーを有効化
        ),
      ),
      // ...省略...
    );
  }
  // ...省略...
}
```

- ゲーム画面で確認
NPCに近づくとプレイヤーの方向を向き、スペースバーまたは画面上のボタン押下で会話テキストが表示されたら実装完了です。
![Screen Shot 2022-11-09 at 19.30.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/5f34f141-ffae-3252-2c6b-96bd513c3e0d.png)

## 4. NPCがランダムに歩くようにする

NPCのランダムな移動を実装するために`AutomaticRandomMovement`Mixinが用意されています。

### 4.1 `AutomaticRandomMovement`の継承

- `AutomaticRandomMovement`を継承する
- `update()`内に`runRandomMovement()`を実装し、適当な引数を与えます。
引数の意味は名前からそれぞれ想像がつくと思いますので、説明は割愛します。
- 会話したいのにNPCが勝手に離れていくのを防ぐために`if(!_seePlayer)`でガードしています。

```lib/npc/npc_house_mother.dart
// ...省略...

// JoystickListenerを追加
class NpcHouseMother extends SimpleNpc
        with ObjectCollision, JoystickListener, AutomaticRandomMovement {
  NpcHouseMother() : super() {}

  // ...省略...

  @override
  void update(double dt) {
    // ...省略...

    if (!_seePlayer) {
      runRandomMovement(
        dt,
        speed: speed,
        maxDistance: (speed * 3).toInt(),
        timeKeepStopped: 3000,
      );
    }

    super.update(dt);
  }
}
```

### 4.2 `Pushable`の継承

NPCランダムに動くようになると、マップによっては道を塞いでしまう可能性があるため、万が一にそなえて`Pushable`も継承して、押し退けられる様にしてみました。
(真上のコードで既に実装済みです)
```lib/npc/npc_house_mother.dart
// ...省略...

// Pushableを追加
class NpcHouseMother extends SimpleNpc
        with ObjectCollision, JoystickListener, AutomaticRandomMovement, Pushable {
  NpcHouseMother() : super() {}

  // ...省略...

}
```

- ゲーム画面で確認して、NPCがテクテクと歩いていたら実装完了です。押し退けられるかどうかも試してみましょう。

![Screen Shot 2022-11-09 at 20.04.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/25632181-5e48-3ab6-fa0e-66187c990d1c.png)

## おわりに

Part 1から続いた本記事ですが、ここで一旦終わりといたします！

Bonfire内のクラスやメソッドだけでかなり多くの機能がとにかく爆速で実装できたと思います。作りたいゲームが要件さえ満たしていれば、Bonfreは強力なツールとして十分使えそうだなと感じました。
攻撃や弾を飛ばすなどのアクションの機能も充実しているので、横スクロールのシューティングゲームなども開発できるかもしれません。
ただし現状ではクォータービューやマリオ的な2Dスクロールには対応していないなどの制約もあるため、注意が必要です。

すでに当初の想像以上にご覧いただいている様子ですので、ご要望などあればアクション要素の実装と解説も投稿するかもしれません。よろしければ、いいね、コメントやご質問などお送りください！

ありがとうございました。