はじめに

こんにちは、育児と仕事の両立に日々悩んでいる、エンジニアの遠藤です。

以前、子供のためにお絵描きアプリを Flutter で作成しましたが、子どもの成長に合わせて簡単なゲームを作ってみました。
ゲームといっても、おしゃれなものではなく、画面を触ってちょっと遊べるようなアプリです。

あらかじめお断りしておきますが、私はこれまで、ゲーム開発をしたことはないので、一般的なゲーム開発ではお勧めされないようなコードを書いていたり、無駄なことをたくさんしている可能性があります。
娘のために父親が頑張ったんだなーと生暖かい目でみていただけると幸いです。
あるいは、 github にコードを公開していますので、プルリクや issues の作成をお待ちしています!

背景

娘は星型のものが好きで、星を見たり、「お星さま」と声をかけると、手をキラキラさせて喜びます。
(かわいすぎる)

そこで、星が画面にたくさん出ていて、タップすると星をゲットできる、そんなゲームアプリを作ろうと思い立ちました。

名付けて「Catch the Star」。
構想2時間、開発3日という(壮大な)プロジェクトです。

できたもの

今回は、 Flutter iOS/Android だけでなく、 web でもビルドしているので、 github pages に実際のゲーム画面を公開しています。
https://milldea.github.io/blog-flutter-game-sample-1/#/

どんなものができたか興味のある方はぜひ触ってみてください。

コード本体はこちら。
https://github.com/milldea/blog-flutter-game-sample-1

主要部分の説明

それではゲームの実装について、主要な点を解説していきます。

アニメーション制御

何はなくとも、まずは星の移動をアニメーションさせる必要があります。
これには、 SchedulerBinding クラスの scheduleFrameCallback を呼び出します。
画面の更新が行われた際に確実に callback を呼び出してくれる関数です。

処理の流れとしては、以下のようなフローになります。

  1. scheduleFrameCallback を呼ぶ
  2. state を変更する
  3. 描画が走る
  4. callback が呼ばれる
  5. scheduleFrameCallback を呼ぶ
  6. state を変更する
  7. 描画が走る
  8. callback が呼ばれる...(以下無限ループ)

これだけです。
毎フレームごとに scheduleFrameCallback を呼び出し続けるのがなんだか気持ち悪さを感じますが、比較的簡単に実装できます。
あとは、手順の 「state を変更する」 箇所で、移動させたい星の位置を適切に変更してあげれば、移動してくれます。
描画には、前回同様カスタムペインターを使用していますので、細かい説明は省略いたします。

お星さまクラス (class Star)

どこかの保育園にありそうなクラス名ですが...
描画する星自体は、クラス化しておくと便利そうです。
基本的に必要な情報は、

  • 星の座標
  • 星のサイズ
  • 星の移動速度

あたりですね。
これらの情報を callback の中で更新していき、描画の時には指定された値を参照する流れとなります。

class Star {
  Star({
    required this.x, // x 座標
    required this.y, // y 座標
    required this.size,  // 星のサイズ
    required this.xSpeed, // x 軸方向のスピード
    required this.ySpeed, // y 軸方向のスピード
  });
  // 座標
  double x;
  double y;
  // 移動速度
  double xSpeed;
  double ySpeed;
  // サイズ
  int size;
}

引数が増えてくると何の値か分からなくなるので、 required を付与しつつ、ラベル付けできるようにしておくと分かりやすそうです。

x 座標、 y 座標は Offset で座標としてひとまとめの情報にして持たせても良さそうですが、この辺りは好みの問題でしょうか?

星の移動

星の移動は callback の中で、座標を足し算することで実現します。

void frameCallback(Duration duration){
   // 今回の時間 - 前回呼ばれた時間
   time = duration.inMilliseconds - lastMilliseconds;
   // 距離 = 速さ × 時間
   star.x += star.xSpeed * time;
   star.y += star.ySpeed * time;
}

前回呼ばれた時間を覚えておき、今回の時間との差分を見るだけですね。
あとは、星に設定された速さと時間をかければ移動距離が計算できます。

そう、算数ですね。
私は、「はじき」で覚えました。
「みはじ」派もいたような気がします。

はじき

これで、星の座標移動は完成です。

ですが、このままだと画面外に消えてしまうので、画面の外に出ないように反射させます。
モン○トみたいなイメージです。

星の x, y 座標がそれぞれ画面から飛び出そうになった場合に、スピードの正負を逆転させます。
すると、右に進んでいたら左に、上に進んでいたら下に、という挙動になります。

int width = size.width.toInt();  // 画面の横
int height = size.height.toInt();  // 画面の縦


if (star.x < 0 || star.x > width - star.size) {
    star.xSpeed = -(star.xSpeed);
}
if (star.y < 0 || star.y > height - star.size) {
    star.ySpeed = -(star.ySpeed);
}

タップ判定

続けて、タップ判定を行います。

こちらも前回と同じく GestureDetector を採用しています。
まずは onPanDown イベントが発火した際に、座標を覚えておきます。

    child: GestureDetector(
        onPanDown: (DragDownDetails details){
            tapX = details.localPosition.dx;
            tapY = details.localPosition.dy;
        })

その後、 scheduleFrameCallback 内で当たり判定などを行います。
callback の終了で、覚えた座標をリセットさせて当たり判定としてます。

if (star.x < tapX
  && star.x + star.size > tapX
  && star.y < tapY
  && star.y + star.size > tapY) {
      // 当たりの際の処理
}

タップされた X, Y 座標がそれぞれ、

  • X 座標: 星の 「X」 より大きく、 「X と星のサイズを足したもの」より小さい
  • Y 座標: 星の 「Y」 より大きく、 「Y と星のサイズを足したもの」より小さい

といった条件で判定できます。
図示すると、以下のようなイメージですね。

はじき

星の形などを考慮すると、厳密には当たっていないのですが、その辺りはご愛嬌です。

ゲーム性

ここまでできれば、触ると星が消える、といった基本的なゲーム部分は完成です。
ただ、子供が遊ぶだけならそれでも良さそうですが、このままではとても退屈なゲームになります。
ゲーム性が皆無です。

なので、ここからゲーム性を少し追加していきます。

スコア設定

ゲームなので、報酬がないと楽しくないように思います。
なので、星を消すとスコアが上がるようにします。
1つ星を消すと、1点とかでも良いですね。

スコアを設定し、画面に表示しておくことで、少しゲーム感が増します。

足し算を間違えてとんでもないスコアが出た時の絵。

ゲームオーバーの設定

続いて、ゲームオーバーを考える必要があります。
今回は表示された2つの星がぶつかったら終了とするようにしてみます。

星同士の当たり判定もタップ時の判定とほぼ同じですが、少し範囲が広くなります。

以下の図は X 軸についてのみ表現したものです。
青い矢印の範囲が当たり判定となります。

足し算を間違えてとんでもないスコアが出た時の絵。

それぞれ

  • 赤い星の座標が(AX, AY) サイズが AS
  • 青い星の座標が(BX, BY) サイズが BS

とした時に、 X 軸と Y 軸が以下の範囲の時に、ぶつかったと判定します。
(タップと同様、正確には星形なので、厳密な当たり判定ではありません。)

AX < BX + BS
  && AX + AS > BX
  && AY < BY + BS
  && AY + AS > BY

リスクとリターン

これで、一応ゲームオーバーにスコア設定ができましたが、やはりまだ面白みはありません。
そこでリスクとリターンを考えてみたいと思います。

リスクはゲームオーバです。
リターンはスコアです。

ハイリスクハイリターン、つまり、ゲームオーバーに近くなると、高いスコアをゲットできるようにしたいわけです。

ということで、星をタップすると、小さい星を出現させ、その星にぶつかった場合は連鎖で、別の星を消すことができるようにします。
連鎖させるには、星同士が近くにいないといけませんが、近すぎるとぶつかってゲームオーバーになってしまう。

さらに連鎖がつながると、スコアが上昇しやすくします。
スコアを伸ばしたければ、星をたくさん画面に呼び込み続ける必要があります。
つまり、リスクが高まりつつ、リターンも高まるわけです。

うん、良いですね。(何が?)

小さい星たちは、タップ判定が発生した際にランダムで、元々の星の位置にいくつか表示させます。

    double meteoriteX = Random().nextInt(star.size) + star.x;
    double meteoriteY = Random().nextInt(star.size) + star.y;

小さい星の X 座標と Y 座標の範囲は、元の星の座標とサイズ間で乱数生成させます。

生成した後は、元の星の中心からの距離をそのまま X 方向、 Y 方向のスピードとして、小さい星を発射させれば完成です。

アニメーションにすると、以下のようになります。

より遠くに発生したものはより速く動きますね。

余談ですが、「リスクとリターン」は桜井政博さんが「桜井政博のゲーム作るには」チャンネルの中で仰っていましたので、これを念頭に考えてみました。
https://www.youtube.com/watch?v=cTSMohV3TgQ

無敵状態

ここまで作成して動かしてみたところ、遊んでいて突然ゲームオーバーになるケースが発生しました。
原因は、星がランダム生成されるので、すでに星があるところに星が生成されていたためです。

すでに星がそこにあれば出さない、といった制御にすることもできましたが、今回は出現から数秒間は無敵の状態を作ることでこれを回避しました。
無敵中は、 Image を透かし、当たらないことがなんとなく分かるようにしています。

Image と一緒に渡す Paint の color にアルファを設定してあげることで、透かした画像の描画が可能です。

    Paint paint = Paint()
      ..color = const Color.fromRGBO(0, 0, 0, 0.2);

エンドレスモードの実装

さて、ゲーム性は増したのですが、「はて、これは誰向けのアプリだったかな?」と我に帰ります。
このままでは 1 歳の娘はまったく遊べずにゲームオーバーになってしまいます。

というわけで気を取り直し、エンドレスモードを用意しました。
エンドレスモードでは、当たり判定などを全てスキップするようにしており、ただ星が出てきて、消すとキラキラするのを楽しむモードとなっています。

このモードではゲームを終わらせることができないので、画面の長押しをすることでゲームが終わるようにしました。
通常の長押しだと、数秒で反応してしまい、誤操作でも終わってしまうため、自前で長押しの時間を判定しました。

onLongPressStart で長押しの開始秒数を取得しておき、 onLongPressEnd でリセットします。
経過時間が指定秒数を超えたらゲームオーバーになるように実装しています。

// GestureDetector 内でイベント検知
onLongPressStart: (LongPressStartDetails _) {
    longPressStart = lastMilliseconds;
},
onLongPressEnd: (LongPressEndDetails _) {
    longPressStart = 0;
},

...

// frameCallback 内で経過時間を判定
if (lastMilliseconds - longPressStart > longPressFinishSeconds * 1000 && longPressStart != 0) {
    gameOver();
}

演出

これで一通りゲームの実装は終わったわけですが、いざプレイしてみるとまぁまぁ地味なわけです。
何というか、 「Flutter のチュートリアルそのままのアプリです!」 みたいな空気が出ます。
そこで、ちょっとだけ演出を加えてよりゲームっぽさを出してみます。

まずは、音ですね。
そもそも子供の興味を引こうと思ったら、やはり音を出さなければいけません。

今は本当に良い時代で、フリーで商用利用可能、クレジット表記も不要といったサイトがたくさんあります。
今回は、「効果音ラボ」様のサイトから良さそうなものをいくつかダウンロードして取り込みました。
https://soundeffect-lab.info/

再生するライブラリには audioplayers を使用しています。

// ライブラリをインポート
import 'package:audioplayers/audioplayers.dart';

AudioCache cache = AudioCache();
// 音源をキャッシュしておく
cache.loadAll(["sounds/file/path.mp3"]);

// 必要なタイミングで再生
cache.play(birthSoundPath);

1点ハマったところとしては、ライブラリがデフォルトのプレフィックスとして assets/ を付与しているので、気付かないと、「ファイル置いたのに再生されない!」ということになります。(なりました。)

https://github.com/bluefireteam/audioplayers/blob/main/packages/audioplayers/lib/src/audio_cache.dart#L41

The default prefix (if not provided) is 'assets/'

ここはちょっと注意かなと思いました。

フォント

ゲームのダサさを助長させる要因として、フォントも一役買っていたように思います。
ありがたいことに、フォントも今はフリーのものがたくさんあるので、アプリの雰囲気にあったものを選びます。
今回は「MODI工場」様のフォントを利用させてもらいました。
良い感じにおしゃれなフォントですね。
http://modi.jpn.org/font_mushin.php

フォントを使用するには、ダウンロードしたフォントを適当なディレクトリに配置します。
その後、 path と名称を pubspec.yml でフォントを宣言します。

  fonts:
    - family: mushin
      fonts:
       - asset: fonts/mushin.otf

あとは、メインテーマにフォントを設定すればおしまいです。

return MaterialApp(
    title: 'Flutter Demo',
    theme: ThemeData(
        primarySwatch: Colors.blue,
        fontFamily: 'mushin'
    ),
    home: const MyHomePage(title: 'Flutter Demo Home Page'),
);

もちろんそれぞれのテキストに設定することも可能です。

Text("Hello World!",
    style:const TextStyle(
        color: Colors.blueAccent,
        fontSize: 20, 
        fontFamily: "mushin"
    )
)

フォントの取り扱いについて、公式ガイドはこちら。
https://docs.flutter.dev/cookbook/design/fonts

星の回転

最初に作った状態では、星が回転していませんでした。

回転せずに、星が左右に動いているのはなんだか寂しい感じがしますね。

そこで、それぞれの星に回転方向と角度情報を持たせて、 frameCallback 内で更新するように実装しました。

Canvas に Image を回転させて描画させる方法が見つからなかったので、今回は Canvas 自体を回転させるようにしています。

    // 画像を回転させて描画する方法がないので、キャンバス自体を回転させて描画する
    Rect imageRect = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
    canvas.save();
    // 回転の中心座標を星の中心に設定
    canvas.translate(center.dx, center.dy);
    // キャンバスを回転
    canvas.rotate(angle * (pi / 180));
    // 中心を戻す
    canvas.translate(-center.dx, -center.dy);
    // 画像を描画
    canvas.drawImageRect(image, imageRect, dstRect, p);
    // 回転をリセット
    canvas.restore();

できたものがこちら。
まぁ、心なしか良くなったような気がしますね。

ゲームスピードの変化

最後に、ゲームオーバーになった瞬間に「ストップ」+「振動」+「スロー」の演出を入れてみました。

ストップは frameCallback 内の処理を一定秒数 skip すれば OK です。

if (lastMilliseconds - gameOverStart < 500) {
    return;
}

画面振動は、frame毎に、X軸Y軸方向に適当な乱数を設定し、描画する全てのオブジェクトに対してこの補正をかけています。
キャンバスをズラす、でも良かったかもしれません。

vibrationX = Random().nextInt(20);
vibrationY = Random().nextInt(20);

スロー演出は、数フレーム1回のみ描画するようにして、制御しました。
ゲームスピードみたいなパラメータを持たせておいて、移動距離にその係数をかける実装の方が、より滑らかなスローになりそうです。

余談ですが、この止めるという点についてはは桜井政博さんが「桜井政博のゲーム作るには」チャンネルの中で「大事なところはストップ!」と仰っていましたので、これを念頭に考えてみました。(デジャブ...?)
https://www.youtube.com/watch?v=APOD1fmwPaM

子供のフィードバック

キラキラモード(Twinkle Mode) の実装

最後にできたところまでを娘に遊ばせてみました。
1歳の娘は、たまたま星を触ることはできることもありますが、基本的に狙ったところを指でタップするという繊細な動きにまだ対応していませんでした。

というわけで、どこでも良いから触ったら星がたくさん出てくるモードを用意しました。

実際にユーザのフィードバックをみて、アプリを改善していくのは本当に大切ですね。(は?)
もはや何が何だか分からないゲーム(?)アプリになっています。

さいごに

前回は3分くらいで飽きてしまった娘ですが、今回はトータルで 15 分くらい遊んでくれたと思います。
(コスパはとても悪い...)

ここまで書いてきて、そういえば flutter が標準でゲームライブラリとか出してないのかな?と思ったら出してました...orz
https://flutter.dev/games

今度はこっちを使って 遊んで 子供のためにゲームを作ってみようと思いました。