3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-11-10

Banner.gif

はじめに

開発環境

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. NPCクラスの作成と実装 (マップへの配置にTiled利用)
  3. NPCとの会話の実装
  4. NPCがランダムに歩くようにする
    Screen Shot 2022-11-09 at 19.30.50.png

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

NPCとの会話のために主に下記の実装をします。

  • プレイヤーがNPCの近くにいるかどうか判定して
  • ユーザーの入力に合わせて会話テキストの表示

上記の機能だけでも会話としては最小限は機能はするのですが、今回はよりナチュラルな振る舞いを再現するために、追加でこちらも実装しています。

  • ランダムで歩きまわる
  • プレイヤーが近くに来ると反応する (プレイヤーの方向を見て立ち止まる)
  • 押し退けられる (勝手に道を塞いでも無理やり動かせる)

1. アセットの準備

  • NPC用の画像は、プレイヤーと同じくこちらからダウンロードした物を利用します
  • そのままでも使えますが、分かりやすいので今回利用するキャラクターの箇所だけ128 x 96pxでクロップします
    68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323730303630322f39306430613962302d373135632d356431372d373762342d6133313463656561326264662e706e67.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のプレイヤー実装の時と同じのため詳細な詳細は割愛します。
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クラスと、当たり判定を持たせるためにObjectCollisionMixinを継承します。
    ほとんどプレイヤークラスの作成時と同じなので詳細な説明は割愛します。
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の移動用センサーと同じ方法で、マップ用jsonファイル内で位置を決めます。

  • NPCを配置する場所をTiledを使ってマップ用json内に記述

    1. Tiledで作成済みのマップjsonを開く
      過去Partから変更がなければassets/images/maps/下のhalloween_map_02.jsonです。
    2. 右上のレイヤー一覧からオブジェクトのレイヤー(パープルのアイコン)を選択する
    3. 上のメニューバーから四角形を追加を選択する。
    4. 真ん中のマップ上をドラッグして配置する場所を決める。
    5. 左のカラムのプロパティから名前をつける (今回はhouseMotherとした)
      今回はClassの値は特に使わないため空白でかまいません
    6. プロパティからXY座標を調節し、幅と高さをタイルに合わせて16x16pxにする
      Screen Shot 2022-11-07 at 13.10.15.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

3. NPCとの会話の実装

NPCとの会話イベントは、分解すると以下の3つの処理に分けられます。

  • プレイヤーが近くにいることを検知して
  • キーボードまたは画面上のボタンの押下に合わせて
  • 会話テキストを表示するメソッドを呼び出す

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

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

  • update()内でseePlayer()を呼び出す
    今回与える引数は以下の3つです。
    • radiusVisionn:視野の広さ
    • observed:プレイヤーが視野内にいる時に実行する処理 (今回はプレイヤーの方向を向かせる)
    • notObserved:視野外にいる時に実行する処理
  • 接近時に一度だけ実行する処理がある場合はif(!_seePlayer){}の中に記述してください。
    (ソースコードの方ではビックリマークを出す実装をしてるので、興味ある方はご覧ください。)
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. キーがスペースバーまたは画面上のボタン
    2. 入力の種類が「押下」である
    3. seePlayertrue ( = プレイヤーとの距離が十分近い)
  • 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()内でBonfireGameaddJoystickObserver()メソッドを実行しNPCを追加する。
  • キーボードとボタン入力の有効化
    Part 1で作成したマップでは有効化済みですが、念の為以下のふたつを確認してください。
    • Joystick()actionsid: 1のボタンが追加されてる
    • keyboardConfigacceptedKeysLogicalKeyboardKey.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

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

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

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

おわりに

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

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

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

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

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?