【Flutter】宣言的な書き方でゲームを作りたかった
日頃開発をしていると,急に簡単なゲームを作りたくなってくるときがあります.ありますよね?
ゲーム開発といえば,Unity,Unreal Engine,Processingなど,様々なツールがあります.これらのツールを触るのももちろん面白い(どれも少しやったことはある)ですが,今回はFlutterを使ってゲームを作ってみたいと思いました.その理由は以下の通りです.
- 宣言的UIをゲームに取り込んでみたかったから
- なんか面白そうだから(?)
Flutterといえばパフォーマンスが良い,Hot Reloadによる開発効率の向上,宣言的UIなどの特徴があります.
この記事は,これらに乗っかった上で基本のユースケースであるツール・アプリ開発ではなくゲーム開発をするという試みをしたときの個人的考察・見解を書いていきます.
また,この記事では上記の方向性に沿って,ツールやユーティリティなど普段Flutterが対象とするようなアプリを「アプリ」,一般的なゲームエンジンやフルスクラッチで書くようなゲームを「ゲーム」と表記します.
なお,Flutterで動作するゲームエンジンも既にありますが,ここではそういったものは使いません.
手続き的・宣言的
ゲーム開発
ゲーム開発においては,どのツールを使ったとしても一つの概念のもとに実装をすることになります.それが ゲームループ です.
アプリでもゲームでも,コンピューターに計算させたものをユーザーに向けて描画することに変わりありませんが,
アプリでは描画するための状態が変化するのはユーザーがアクションを起こしたときなどに限定されます.
それに対して,ゲームではユーザーからのアクションによらず状態が変化することがあり,また演出として常時アニメーションを表示することが多々あります.そのため,主に描画するための関数を毎フレーム実行することになります.この毎フレーム実行する処理をゲームループといいます.
UnityでいうとGameObject#update
,Processingでいうとdraw()
がそれに当たります.
自分はそこまでゲーム開発に詳しいわけではないのでベストプラクティス・アーキテクチャなどあると思いますが,標準的な方法でゲームを作ろうと思うと以下のようになるかと思います.
- 初期化用の関数でゲーム内で使う変数・オブジェクトを準備する
- 毎フレーム実行される関数(主に描画用関数)でゲームの状態更新やユーザー入力を処理する
- 描画用関数で描画処理を書く
ゲームの構造を表した図
ゲームの構造
上記の図でゲームループの部分に注目すると,1フレームで処理する内容に状態更新が含まれていることがわかります.これは, オブジェクトの状態を手続き的に定義・計算している ことを意味します.
例えば時間に応じて移動するようなオブジェクト
-
の位置(A )を時間Ax, Ay の関数を使って計算するt -
の位置にAx, Ay を描画するA
といった処理を一連の流れとして記述します.
このように,登場するオブジェクトに「この位置にいてほしい」という指示を与えたり,動いた結果によって状態を変化させたりといった書き方は手続き的な書き方と言えます.
ゲーム開発においてはこのような考え方が主流となっており,またアニメーションを多用するような要件を鑑みても適切と考えられます.
宣言的な書き方
ゲーム開発では手続き的が適切とは言いましたが,Flutterという高パフォーマンスかつ宣言的プログラミングに適したツールがある以上,それでゲーム開発もしてみたくなるものです.
Flutterは先述のように「宣言的UI」という特徴を持っていますが,これの説明は公式サイトを見ていただいたほうが早いかと思います.
Start thinking declaratively
Introduction to declarative UI
個人的に一番簡潔な説明は以下の図だと思います.
UIは状態を入力とした関数である
https://flutter.dev/docs/development/data-and-backend/state-mgmt/declarative
つまり,命令的にオブジェクトを定義するのではなく,登場するオブジェクトなどは予め定義されており,その状態(上記の
作ってみた
ここで,今回作ったものを紹介します.
今回はそこそこ歴史のあるゲーム「Snake Game」(ヘビゲーム)を実装してみました.
仕様
Snake Gameは,ユーザー入力で移動する蛇に餌を与えて,蛇を長くしていくゲームです.
上の画面でいうと,左側にいる3つの円が自機である蛇,右側のオレンジ色の円が餌です.
画面下の方向キーによって蛇を操作します.一度方向を入力すると蛇はその方向に動き続けます.
ゲーム中,蛇の頭と体がぶつかってしまうと,そのぶつかった部分から尻尾までの部分が無くなってしまいます.
いかに自分の体にぶつからずに蛇を長くしていけるかに挑戦するのがこのゲームの目的です.
このゲームはシンプルな故,実装により様々な仕様がありますが,今回は以下のような仕様となっています.
- 画面の端(壁)にぶつかったとき,蛇は反対側の壁から出てくる
- 時間制限やスコア記録などはしない
- つまり終わりは存在しない
- 単に実装していないため
- 画面の方向キーだけでなく,キーボードの矢印キーでも操作できる
解説
実装にあたり,webで動くようにするためbeta channelを使っています.fvmを導入している方はそのままfvm flutter
コマンドを使っていただければOKです.
また,状態管理ライブラリとしてRiverpodを,ゲーム全体の状態を格納するデータクラスを作るためにfreezedを採用しています.
基本UI
特別なことはせず,単純にWidgetの組み合わせでUIを書いています.
基礎部分では,キーボード入力を受け取るためにRawKeyboardListener
を使っており,autoFocus
を使って全面的にキー入力を受け付けるようにしています.
方向キー部分は少しだけアニメーションを含むStatefulWidget
となっています.
Widget build(BuildContext context) {
return Scaffold(
body: RawKeyboardListener(
focusNode: _focus,
autofocus: true,
onKey: (event) => context
.read(appStateControllerProvider)
.processKeyEvent(event.logicalKey.keyId),
child: SafeArea(
...
状態更新
enum InputDirection { none, left, up, right, down }
abstract class AppState with _$AppState {
const factory AppState({
([Offset.zero]) List<Offset> snake,
(InputDirection.none) InputDirection currentDirection,
(Offset.zero) Offset item,
}) = _AppState;
}
ゲーム全体の状態として,自機の位置を表現するリスト,現在入力されている方向,餌の位置を保持しておきます.
この状態クラスのインスタンスをコントローラークラスに保持しておき,外部から与えられるクロックで更新していきます.
状態更新のためのメソッド:
void _addTrail() {
state = state.copyWith(
snake: [...state.snake, state.snake.first],
);
}
void _updatePlayer() {
final moveAmount = _mapDirectionToOffset(state.currentDirection);
final playerPosition = _getNextPosition(state.snake.first, moveAmount);
state = state.copyWith(
snake: [
playerPosition,
...state.snake.getRange(0, state.snake.length - 1)
],
);
}
void _judgeCollision() {
final snakes = state.snake.getRange(1, state.snake.length).toList();
final target = snakes.indexWhere((e) => e == state.snake.first);
if (target == -1) return;
state = state.copyWith(snake: [...state.snake.getRange(0, target + 1)]);
}
void _updateGameState() {
_updatePlayer();
_judgeCollision();
if (state.snake.first == state.item) {
_addTrail();
state = state.copyWith(
item: Offset(
_random.nextInt(BOARD_SIZE).toDouble(),
_random.nextInt(BOARD_SIZE).toDouble(),
));
}
}
一つだけメソッド分離してませんが…
流れとしては
- 自機の移動
- 自機の頭と体が衝突したかどうかの判定
- 餌を食べたときのロジック
となっています.この部分に関しては手続き的な書き方であっても同様であり,要するにパラダイムによらず共通化できるビジネスロジックという事になります.
さて,これを実行するタイミングですが,外部から何らかの形でクロックを与える必要があります.
Flutterでそのような仕組みといえばAnimationController
によるsetState
ですが,今回はそれを使わず,純粋なStream.periodic
を起点とします.
このような単純なStreamProvider
を用意した上で,コントローラー側でlisten
し更新をかけます.
final clockProvider = StreamProvider<int>((ref) async* {
final baseStream =
Stream<int>.periodic(const Duration(milliseconds: 120), (c) => c);
await for (final value in baseStream) {
yield value;
}
});
class AppStateController {
AppState state;
StreamSubscription clock;
AppStateController({this.state, ProviderReference ref}) {
clock = ref.read(clockProvider.stream).listen((_) {
_updateGameState();
});
描画
ここまで来たら,後は状態を入力として受け取り,それを描画するだけです.
ここでも先程コントローラー側で参照していたクロックをwatchします.これにより,状態更新と描画が紐付いた形となります.
StreamProvider
はAsyncValue
として値を受け取れるので,AsyncValue#when
を使ってデータが届いたタイミングでリビルドがかかるように記述します.
class GameBoard extends ConsumerWidget {
const GameBoard({Key key}) : super(key: key);
Widget build(BuildContext context, ScopedReader watch) {
final clock = watch(clockProvider);
final controller = watch(appStateControllerProvider);
return clock.when(
data: (value) => _Contents(
snake: controller.state.snake,
item: controller.state.item,
),
loading: () => Container(),
error: (_, __) => Container(),
);
}
}
_Contents
内では受け取った状態を元に,普段のFlutterと同じようにWidgetを記述していきます.
_Contents内
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraint) {
final gridSize = constraint.maxWidth / BOARD_SIZE;
return Stack(
children: [
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: BOARD_SIZE,
),
itemCount: BOARD_SIZE * BOARD_SIZE,
itemBuilder: (context, index) => Container(
color: index.isEven
? Colors.green.withOpacity(0.4)
: Colors.lightGreen.withOpacity(0.4),
),
),
Positioned(
top: item.dy * gridSize,
left: item.dx * gridSize,
child: Container(
width: gridSize,
height: gridSize,
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
),
),
for (final element in snake)
Positioned(
top: element.dy * gridSize,
left: element.dx * gridSize,
child: Container(
width: gridSize,
height: gridSize,
decoration: BoxDecoration(
color: snake.indexOf(element) == 0
? Colors.lightBlue
: Colors.lightBlueAccent,
shape: BoxShape.circle,
),
),
),
],
);
},
);
}
思ったこと
ゲーム開発を宣言的な書き方で行う方法を実践してみて,
- 良くも悪くもアプリ開発で重要な「責務の分離」ができる
- クリーンなコードを書いている気分になれるので良い
- 必要かどうかと言われると微妙
- リッチなアクションを必要とするゲームはもちろん厳しいだろう
- 手続き型だとグラフィックライブラリを直で使えるのに対し,宣言型では1レイヤー多くなる
- 更新頻度が高くなるにつれてパフォーマンスの差として現れるはず
- ボードゲームなら十分こんな感じでできそう
- アクション系でもFlappy Bird程度だったら何とかなるかも?
- 手続き型だとグラフィックライブラリを直で使えるのに対し,宣言型では1レイヤー多くなる
- ビジネスロジックは抽出可能
- だからどうというわけではないが
- 手続き的に書くときであっても意識していれば良いコードが書けるのかも?
- だからどうというわけではないが
などと感じました.
個人的な結論としては,「用途によってはアリ」ということになります.既存のアプリにちょっとしたミニゲームを仕込むとかだったら検討してもいいかと.
まとめ
宣言的プログラミングのフレームワークでゲームを作ることについて,その一例を示しました.
通常のアプリ開発で役に立つことはそうそうないような内容の記事でしたが,Flutterの可能性の一つとして楽しんでいただけたら幸いです.
また,ここで示した例は完璧ではありません.よりパフォーマンスを求めるならば,例えばAppState
をミュータブルなものにすれば自機の更新はより少ない計算量で達成できますし,描画にしてもWidgetではなくRenderBox
を直接描画するようにすれば多少速くなるはずです.
更に状態更新と描画を行うためのクロックですが,今回は双方でlistenしているため関係性が並列になっています.この場合,どちらかの処理が遅れると同期が取れていない状態になってしまいます.状態と描画の同期を重視するならばコントローラー側から描画側に通知するのがベターです.
改善点はありますが,取っ掛かりと検証として良い取り組みだったと思っています.皆さんも是非チャレンジしてみてください.
Discussion