はじめに
- Flutterでマルチプラットフォーム対応 (Web, モバイル, PC) のRPGゲームを作成します
- ソースコードはこちら (アセットなど一部差し替えています)
- この記事は5つのPartに分かれていて、今回はそのPart 5です。
- Part 1:マップとUIの実装
- Part 2:プレイヤーと当たり判定の実装
- Part 3:マップ移動の実装
- Part 4:アイテム取得とイベントの実装
- Part 5:NPCと会話の実装
- Bonfireという2Dゲームに特化したフレームワーク(Flutterパッケージ)を利用しています
- マップ制作にはTiledという無料のGUIツールを利用しています
Tiledは必須ではありませんが、使い方も簡単で制作作業がとても捗ります - アセットはitch.ioで購入できるものを利用しています
- トップの画像は利用しているアセットのサンプル画像です
開発環境
OS: macOS 12.6
Flutter: 3.3.5
Bonfire: 2.10.10
Tiled: 1.9.2
Xcode: 14.0.1
テスト環境: Chrome, iOS Simulator
今回のパートの大まかな流れ
本記事で実装する機能について
NPCとの会話のために主に下記の実装をします。
- プレイヤーがNPCの近くにいるかどうか判定して
- ユーザーの入力に合わせて会話テキストの表示
上記の機能だけでも会話としては最小限は機能はするのですが、今回はよりナチュラルな振る舞いを再現するために、追加でこちらも実装しています。
- ランダムで歩きまわる
- プレイヤーが近くに来ると反応する (プレイヤーの方向を見て立ち止まる)
- 押し退けられる (勝手に道を塞いでも無理やり動かせる)
1. アセットの準備
- NPC用の画像は、プレイヤーと同じくこちらからダウンロードした物を利用します
- そのままでも使えますが、分かりやすいので今回利用するキャラクターの箇所だけ128 x 96pxでクロップします
- ファイル名を
house_mother.png
(またはお好みの名前)にしてassets/charactors/
に配置します -
pubspec.yaml
に画像ディレクトのパスが正しく設定されているか確認してください
(過去Partから変更してない場合は、既に設定済みです)
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のプレイヤー実装の時と同じのため詳細な詳細は割愛します。
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);
}
}
- 起動後のロードの実装
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を継承します。
ほとんどプレイヤークラスの作成時と同じなので詳細な説明は割愛します。
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内に記述
-
Widget側にもNPCを追加
Decoration
を追加するだけならすぐ下の'bottomExitSensor'
の箇所と同じように、インスタンスの生成と同時にゲームに追加することもできます。ここでは、後にonReady
で同じNPCのインスタンスを参照するため、生成はひとつ外のスコープで先に行います。
(build()
内の最初のlate NpcHouseMother npcHouseMother;
の箇所です)
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(),
),
// ...省略...
},
),
// ...省略...
);
}
// ...省略...
}
3. NPCとの会話の実装
NPCとの会話イベントは、分解すると以下の3つの処理に分けられます。
- プレイヤーが近くにいることを検知して
- キーボードまたは画面上のボタンの押下に合わせて
- 会話テキストを表示するメソッドを呼び出す
3.1 プレイヤーが近くにいることを検知する
BonfireのNpc
クラスはVision
Mixinを継承しており、Player
との距離に応じて処理を実行するメソッドseePlayer()
を持っています。
-
update()
内でseePlayer()
を呼び出す
今回与える引数は以下の3つです。-
radiusVisionn
:視野の広さ -
observed
:プレイヤーが視野内にいる時に実行する処理 (今回はプレイヤーの方向を向かせる) -
notObserved
:視野外にいる時に実行する処理
-
- 接近時に一度だけ実行する処理がある場合は
if(!_seePlayer){}
の中に記述してください。
(ソースコードの方ではビックリマークを出す実装をしてるので、興味ある方はご覧ください。)
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()
を複数与えると、連続でテキストを表示します。
// ...省略...
// 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つの意味はそれぞれ下記の通りです- キーがスペースバーまたは画面上のボタン
- 入力の種類が「押下」である
-
seePlayer
がtrue
( = プレイヤーとの距離が十分近い)
-
if
分内で先に作成した_showTalk()
を実行する
// ...省略...
// 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で作成したマップでは有効化済みですが、念の為以下のふたつを確認してください。-
Joystick()
のactions
にid: 1
のボタンが追加されてる -
keyboardConfig
のacceptedKeys
にLogicalKeyboardKey.space
が追加されている
-
// ...省略...
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], // キーボードのスペースバーを有効化
),
),
// ...省略...
);
}
// ...省略...
}
4. NPCがランダムに歩くようにする
NPCのランダムな移動を実装するためにAutomaticRandomMovement
Mixinが用意されています。
4.1 AutomaticRandomMovement
の継承
-
AutomaticRandomMovement
を継承する -
update()
内にrunRandomMovement()
を実装し、適当な引数を与えます。
引数の意味は名前からそれぞれ想像がつくと思いますので、説明は割愛します。 - 会話したいのにNPCが勝手に離れていくのを防ぐために
if(!_seePlayer)
でガードしています。
// ...省略...
// 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
も継承して、押し退けられる様にしてみました。
(真上のコードで既に実装済みです)
// ...省略...
// Pushableを追加
class NpcHouseMother extends SimpleNpc
with ObjectCollision, JoystickListener, AutomaticRandomMovement, Pushable {
NpcHouseMother() : super() {}
// ...省略...
}
- ゲーム画面で確認して、NPCがテクテクと歩いていたら実装完了です。押し退けられるかどうかも試してみましょう。
おわりに
Part 1から続いた本記事ですが、ここで一旦終わりといたします!
Bonfire内のクラスやメソッドだけでかなり多くの機能がとにかく爆速で実装できたと思います。作りたいゲームが要件さえ満たしていれば、Bonfreは強力なツールとして十分使えそうだなと感じました。
攻撃や弾を飛ばすなどのアクションの機能も充実しているので、横スクロールのシューティングゲームなども開発できるかもしれません。
ただし現状ではクォータービューやマリオ的な2Dスクロールには対応していないなどの制約もあるため、注意が必要です。
すでに当初の想像以上にご覧いただいている様子ですので、ご要望などあればアクション要素の実装と解説も投稿するかもしれません。よろしければ、いいね、コメントやご質問などお送りください!
ありがとうございました。