はじめに
- Flutterでマルチプラットフォーム対応 (Web, モバイル, PC) のRPGゲームを作成します
- ソースコードはこちら (アセットなど一部差し替えています)
- この記事は5つのPartに分かれていて (予定)、今回はそのPart 4です。
- 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
今回のパートの大まかな流れ
- アセットの準備とアイテムクラスの作成
- アイテムオブジェクトクラスの作成
- アイテムオブジェクトをマップに配置
- StateControllerの作成と実装
- イベント用Decorationの作成と配置
1. アセットの準備とアイテムクラスの作成
今回作成するアイテムは、このような機能実装になっています。
- マップ上に配置されている
- 触れるだけで取得できる
- 所持数が管理できる
- 取得済みのアイテムは再生成しない
アイテムの画像にはタイルセット中のキノコのマスを、16x16pxで切り出したものを利用します。
↓ ゲーム内に配置しイメージこちら
1.1 アセットの準備
- アイテムの画像を追加
assets/images/objects/
ディレクトリを作成し画像を配置します。
今回はファイル名をbig_shroom.png
としました。 - 追加したディレクトリを
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
クラスの作成
この記事シリーズで作成するアイテムは1種類だけですが、拡張性を考慮して汎用的なGameItem
クラスを作っておきました。
プロパティには画像パスと、おまけで日本語名と値段を持たせました。
名前は「〇〇を手に入れた!」などとメッセージを出したいときに使えます (本記事では実装しません)。 -
BigShroom
クラスの作成
アイテムクラスを継承したBigShroom
クラスを作成します。
先に追加したアセットの画像のパスと、お好みで名前と値段をつけてあげてください。
// アイテムクラス
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も継承します。
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
で生成するのが簡単です。
サンプルこちら
3.1 アイテム生成メソッドを作成
Part 2で作成したマップにメソッドを追加、実装します。
-
BonfireGame.add()
メソッド実装
表示中のゲームの状態管理しているBonfireGame
のadd()
メソッドでDecoration
をオブジェクトとしてマップに追加します。
作成したBigShroomDecoration
に初期位置と、状態管理用にマップWidgetとID番号、状態管理と接触の判定用にプレイヤーを渡します。
import 'package:simple_bonfire/decorations/big_shroom.dart'; // 追加
// ...省略...
class _HalloweenMap02State extends State<HalloweenMap02> {
// ...省略...
// 追加
void _addGameItems(BonfireGame game) {
// キノコ1つ目
game.add(
BigShroomDecoration(
initPosition: Vector2(tileSize * 9, tileSize * 3),
map: widget.runtimeType,
id: 0,
player: game.player! as PlayerBeardedDude,
),
);
// キノコ2つ目
game.add(
BigShroomDecoration(
initPosition: Vector2(tileSize * 13, tileSize * 4),
map: widget.runtimeType,
id: 1,
player: game.player! as PlayerBeardedDude,
),
);
}
}
3.2 アイテム生成メソッドを実装
BonfireWidget
にonReady
を追加し、作成したメソッドを実行します。
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); // 追加
},
// ...省略...
);
}
}
4. StateControllerの作成と実装
マップにアイテムを配置して消すだけでは、マップ移動をするたびにアイテムが再生成されてしまいますし、アイテムの所持や使用をすることもできません。
まずは取得したアイテムを管理し、アイテムが再生成されないようにします。
4.1 StateController
の作成と実装
- ファイルの作成
lib/player/
ディレクトリにplayer_bearded_dude_controller.dart
を作成します。 -
StateController
クラスの作成
ここではBearedDudeController
という名前にしました。
クラス内にはアイテムの状態を格納するためにプロパティを設けます。-
itemCounts
:各アイテムの所有数 -
itemObtained
:マップごとの取得済みアイテム
-
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
を追加します。
// 追加
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()
を実行します。
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
だけガードしておきます。
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()
内でアイテムの取得状態を確認し、取得済みであればマップから削除します。
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();
}
// ...省略...
}
ゲーム画面で、一度取得したキノコがマップ移動を挟んでも再生成されないのを確認する。
5. イベント用Decorationの作成と配置
- イベントと一言で言っても色々なパターンが考えられますが、今回は「スイッチで扉が開く」的なイベントを実装します。状態に応じて画像と当たり判定を更新します。
今回はアセットやマップの準備の手間を惜しんで「キノコを拾うとカカシの画像が変化し、当たり判定がなくなる」という例を紹介します。
5.1 アセットの準備
キノコと同様のタイルセットから、今回は縦長の16x32pxで切り出したものを利用します。
↓ ゲーム内に配置したイメージ、変化前がこちらで
↓ ゲーム内に配置したイメージ、変化後はこちら
5.2 イベントオブジェクトクラスの実装
-
lib/decorations/
にscarecrow.dart
を作成します。 - カカシはただの置き物なので、
Sensor
は継承させず、代わりに当たり判定を持たせるためにObjectCollision
を継承します。 - 2マス分の縦長の画像ですが、接地部分は画像の下半分の想定なので、
position.y
をマイナスにして上方向に飛び出す様にしています -
setupCollision()
で当たり判定を定義しています。 - 変化する前後の画像それぞれ必要なので、画像パスを二つ定義します。
-
BonfireInjector().get()
でプレイヤーのStateController
を参照しています。
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
内で実行します。
解説はコメントに留めます。
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); // 追加
},
// ...省略...
);
}
}
- 最初は大人しかったカカシが…
- キノコを取るとピカっと光り、すり抜ける様になったら完成!
ちなみに、Part 3でマップをTiledで作成した段階では、カカシは木などと同じくマップ内のタイルとして配置していました。
今回、カカシはオブジェクト化に際してマップからは削除しました。
おわりに
状態管理と分岐を実装し、かなりゲームらしく仕上がってきたと思います。
今回は、プレイヤークラスにマップ毎のアイテムの状態管理まで任せてしまいましたが、実際は所持品などの管理のみに留める方が汎用的だと思います。
あくまで最も簡単な実装例のひとつとしてご参考にしてください。
次回最終パートでは、動きのあるNPCと会話のイベントを実装します。
いいね、コメントや質問などいつでもお待ちしております!!