へっぽこSEが◯時間でFlutter(iOS、android、web)で2Dゲームアプリを作ってみる(9)

どうも、すえきあおいです。
YouTubeのチュートリアル動画を使った2Dゲーム(テトリス)モバイルアプリ開発が順調に進んでいます。前回はゲームクラスでブロックを作ってみました。
今日は、タイマーを使ってブロックを動かしていきます。ようやく2Dゲームっぽくなってきました。
フルのソースコードはこちらにあるので、面倒な人はダウンロードどうぞ。
今日の内容は、こちらの動画の15:28〜24:40までの内容になっています。
早速、タイマーをセットしていきましょう!
startGame関数に、内部的なブロックの動きを定義する(15:28〜)
lib/game.dart
const BLOCKS_X = 10;//ゲームエリアの幅 const BLOCKS_Y = 20;//ゲームエリアの高さ const GAME_AREA_BORDER_WIDTH = 2.0; //ゲームエリアの枠線の幅 const REFRESH_RATE = 300; //ゲームの速度。300ミリ秒ごとに1ユニット下に移動する ... class _GameState extends State { double subBlockWidth; Duration duration = Duration(milliseconds: REFRESH_RATE); //期間(ゲームの速度) GlobalKey _keyGameArea = GlobalKey(); //秘密にしたいのでプライベート(_から始める) Block block; Timer timer; Block getNewBlock() { ... } void startGame() { //GlobalKeyを使い、ゲームエリアの現在のcontextにアクセスする //findRenderObjectで、レンダリングされたゲームエリアのオブジェクトを取得できる RenderBox renderBoxGame = _keyGameArea.currentContext.findRenderObject(); //利用するゲームエリアは、ゲームエリアの枠線の幅を含まない subBlockWidth = (renderBoxGame.size.width - GAME_AREA_BORDER_WIDTH * 2) / BLOCKS_X; block = getNewBlock(); // 300ミリ秒ごとにonPlay(コールバック関数)を呼び出す timer = Timer.periodic(duration, onPlay); } // timer引数は必須だが、別に使わなくてもいい void onPlay(Timer timer){ // Flutterがブロックの位置と状態が変化したことを認識するため、setStateを呼び出す setState(() { // ブロックを下に移動させる block.move(BlockMovement.DOWN); }); }
なるほど。。
ここまでやってわかったのですが、別に動きがあるゲームだからといって特殊なことをしているわけではないんですね。シンプルに、タイマーを使って1マスずつ動かして描画(レンダリング)してるだけなのか。なんか、もっと難しい感じなのかと思ってた。
「コールバック関数をトリガー します」を解説します
チュートリアル動画でタイマーを作るときに、「コールバック関数をトリガーします」と言われて??????ってなりました。いや、へっぽこながらもこの業界長いのでなんとなくわかるんですが、説明しろと言われると難しいなと思いまして。
ググったらすごいわかりやすい解説が出てきたのでそのまま貼ります。
コールバック関数とは、上から順番に実行されない関数のこと。プログラムは上から下へと実行されますが、コールバック関数は何らかの条件の後に登録され実行される関数のことになります。
コールバック関数とは、トリガーによって実行される関数のことです。
timer = Timer.periodic(duration, onPlay);
この場合、
duration:トリガー(このチュートリアルだと300ミリ秒ごと)
onPlay:コールバック関数
という感じ。これがゲームアプリのミソかもしれませんね。この300ミリ秒ごと、というのをもっと速くしたら、我々が普段プレイしているような、ポケ森とかドラクエみたいに、滑らかな動きになる、、、と。ふむふむ。
なんか、仕掛けがわかると面白いですね。作るまではイメージしにくかったけど、ちょっとずつわかってきた感じがする。
ブロックを描画する(17:28〜)
さて、ブロックの内部的な動きを定義し終わったので、次は描画する部分を実装していきます。
lib/game.dart
// timer引数は必須だが、別に使わなくてもいい void onPlay(Timer timer){ ... } // 配置されたコンテナを作成する関数 Widget getPositionedSquareContainer(Color color, int x, int y){ return Positioned( // ピクセル座標(絶対座標) left: x * subBlockWidth, top: y * subBlockWidth, child: Container( width: subBlockWidth - SUB_BLOCK_EDGE_WIDTH, //サブブロック同士がくっついて見えないようにする height: subBlockWidth - SUB_BLOCK_EDGE_WIDTH, decoration: BoxDecoration( color: color, // BorderまたはBoxDecotationを描画するときに使う形状。circle(円)とrectangle(長方形)が選べる。 shape: BoxShape.rectangle, borderRadius: BorderRadius.all(const Radius.circular(3.0)), ), ), ); } // ブロックを描画する Widget drawBlocks(){ // 初期化 if (block == null) return null; // サブブロックは、配置可能なWidgetのリストとして宣言する List<Positioned> subBlocks = List(); // ブロックを作る=各サブブロックをループし、それぞれをコンテナに変換する block.subBlocks.forEach((subBlock){ subBlocks.add(getPositionedSquareContainer( //絶対座標にする(サブブロックの座標はブロックの相対位置なのでそれぞれ足す) subBlock.color, subBlock.x + block.x, subBlock.y + block.y)); }); return Stack(children: subBlocks,); }
ここでのポイントは、以下4つ。
- ブロックは、Stack Widget(多くの子Widgetを持つことができる)として定義すること
- ブロックを構成するサブブロックのリストは、位置を制御するためにList<Positioned>で宣言する
- ブロックは、サブブロック(ブロックを構成する正方形)をループさせて、それぞれをContainer Widgetとして描画することで作る
- サブブロックの座標はブロックの相対位置なので、x,y座標それぞれにブロックの座標を足して、絶対座標に変換する
最後に、作成したdrawBlocks ウィジェットをゲームエリアのbuildに組み込んでいきます。
lib/game.dart
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: BLOCKS_X / BLOCKS_Y, //高さに対する幅の比率
child: Container(
key: _keyGameArea, //ゲームエリアのグローバルキー
decoration: BoxDecoration(
color: Colors.indigo[800],
border: Border.all(
width: 2.0,
color: Colors.indigoAccent
),
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
child: drawBlocks(), // ブロックを描画する
),
);
}
endGame関数でゲームを停止できるようにする(21:00〜)
ゲームを停止できるようにします。これは、タイマーを止めるだけでOKです。
lib/game.dart
void endGame() { timer.cancel(); }
スタートボタンを実装する(21:25〜)
やることは、こんな感じ。
- スタートボタンを押したらgame.dartのstartGame関数が呼ばれるようにする
- プレイ中のスタートボタンの表示は「停止」にする
- プレイ中にスタートボタンを押すと、game.dartのendGame関数が呼ばれるようにする
- Gameウィジェットに実装されている関数を呼ぶために、Global Keyを使う
では行ってみましょう。
lib/game.dart
class Game extends StatefulWidget { // GlobalKeyを受け入れるコンストラクタを作る // コンストラクタは受け取ったキーをその親に渡す必要がある。 Game({Key key}): super(key: key); @override State createState() => GameState(); } // _GameStateはプライベートなので、他のクラスからアクセスすることはできない。 // GameStateに修正する。 class GameState extends State { ... bool isPlaying = false; //ゲームがプレイ中かどうかを示すフラグ ... void startGame() { //プレイ中フラグをONにする isPlaying = true; ... } void endGame() { //プレイ中フラグをOFFにする isPlaying = false; ... }
続けて、mainの方でスタートボタンを実装していきます。
lib/main.dart
// 「_」をつけるとプライベートになる class _TetrisState extends State { // Gameエリアのウィジェットにアクセスするためグローバルキーを使う // ※GameStateをパブリッククラスにし、keyを受け入れるコンストラクタを作っておくこと。 GlobalKey _keyGame = GlobalKey(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( ... ), backgroundColor: Colors.indigo, body: SafeArea( child: Column( children: [ ScoreBar(), Expanded( child: Center( child:Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Flexible( flex: 3, child: Padding( padding: EdgeInsets.fromLTRB(10.0, 10.0, 5.0, 10.0), child: Game(key: _keyGame)//ゲームwidgetに置き換える。グローバルキーをゲームのコンストラクターに渡す。 ), ), Flexible( flex: 1, child: Padding( padding: EdgeInsets.fromLTRB(5.0, 10.0, 10.0, 10.0), child:Column( mainAxisSize: MainAxisSize.min, children: [ NextBlock(), //次のブロックを表示する枠 SizedBox(height: 30,), //余白 RaisedButton( //スタートボタン child: Text( // グローバルキーを使って、GameStateにアクセスする // isPlaying変数には、GameStateのcurrentStateでアクセスできる _keyGame.currentState != null && _keyGame.currentState.isPlaying ? 'End': 'Start', style: TextStyle( fontSize: 18, color: Colors.grey[200], ), ), color: Colors.indigo[700], onPressed: () { //Flutterにボタンを再描画させるため、setStateを使う setState(() { // グローバルキーを使って、GameStateにアクセスする _keyGame.currentState != null && _keyGame.currentState.isPlaying ? _keyGame.currentState.endGame() : _keyGame.currentState.startGame(); }); }, ) ], ), ), ), ], ), ), ), ], ), ) ); } }
では実行してみます!
左がweb、右がiPhoneです。どちらもきちんと動いてますね。
今日はすごい進んだ感じがします!
特に勉強になったのは、GlobalKeyの使い方ですね。やはり手を動かしてみると理解が深まります。
次回は床に落ちたらブロックが止まるように修正していきます。
いよいよ終わりが見えてきました。
それでは!