【Flutter】ドラクエ風RPG開発記:第3回 コントローラの作成

対象者

  • Flutterでゲーム機のコントローラを実装してみたい人
  • onTapUp, onTapDownの実装例を見たい人

はじめに

ドラクエ風RPGの第3回目、キャラクターの移動を実装しようと思いました。しかし、それ前にキャラクターを動かすためのインターフェースが必要ですのでコントローラを実装します。
ツインファミコン(前期型黒)とかPCエンジンDuo-Rとかのコントローラを参考にしようと思いましたが、スマホ前提のためゲームボーイが一番近いかと思い、採用しました。デザインがシンプルで再現が楽そうだし、最近のゲーム機知らんし。

インターフェースについて考える

ゲームのインターフェースを考えるにあたり、昔のコントローラーに見られる十字キーとAボタン、Bボタンを基本として採用することにしました。

十字キーについては、良い画像を見つけたので、それを使用することにしました。十字キーを3つのセクションに分けて、どの方向が押されたかを判別します。ボタンが押されたときの画像内の座標を取得して、上下左右のどのボタンが押されたかを識別します。「右上」のような斜めはないとします。
また、十字キーには「押しっぱなし」の機能があります。各ボタン(上、下、左、右)が認識され、押され続けている状態が継続し、ボタンが離されたときには、ボタンが押されていない状態に戻るように実装します。

AボタンとBボタンについては、どちらも押した瞬間にイベントが一度だけ発火するように設定します。押しっぱなしの機能は設けません(ファミコンで16連射とか意味あったのかな)。

十字キーの状態を表すクラスの作成

プレイヤーの操作を受け付けるために十字キーの状態を管理する必要があります。このために、CrossButtonStatusという列挙型(enum)を作成しました。この列挙型は、十字キーの各方向と「何も押されていない」状態を表します。

enum CrossButtonStatus {
  up(0, -1),
  down(0, 1),
  left(-1, 0),
  right(1, 0),
  none(0, 0);

  const CrossButtonStatus(this.x, this.y);
  final int x;
  final int y;
}

ここで、up、down、left、rightはそれぞれ上、下、左、右のボタンが押されたときの状態を表しています。例えば、upは上ボタンが押されたときを表し、その場合のx方向の移動量は0、y方向の移動量は-1となります。これは、上に移動することを意味します。

noneは十字キーが押されていない状態を表します。この状態では、x方向とy方向の両方の移動量が0となり、キャラクターは動かないことを意味します。

このCrossButtonStatus列挙型を使用することで、十字キーの状態を簡潔に表現し、ゲームのロジックをわかりやすく実装することができます。

十字キーの実装

十字キーの状態を管理するために、まず_buttonStatusという変数をCrossButtonStatus.noneで初期化しています。これは、最初はどのボタンも押されていない状態を表します。

十字キーの実装には、GestureDetectorウィジェットを使用しています。このウィジェットは、タップやスワイプなどのジェスチャーを検出するためのものです。ここでは、onTapUpとonTapDownの二つのイベントを処理しています。

var _buttonStatus = CrossButtonStatus.none;

GestureDetector(
    onTapUp: (_) {
        setState(() {
          _buttonStatus = CrossButtonStatus.none;
        });
    },
    onTapDown: (detail) {
        final x = (detail.localPosition.dx / kButtonPartSize)
                .floor() - 1;
        final y = (detail.localPosition.dy / kButtonPartSize)
                .floor() - 1;

        final buttonStatus = CrossButtonStatus.values
            .firstWhere((e) => e.x == x && e.y == y,
                orElse: () => CrossButtonStatus.none);
        setState(() {
          _buttonStatus = buttonStatus;
        });
    },
    child: SvgPicture.asset('assets/cross_button.svg')
),

onTapUpイベントは、ユーザーが画面から指を離したときに発生します。このとき、_buttonStatusをCrossButtonStatus.noneに設定して、十字キーが押されていない状態に戻します。

onTapDownイベントは、ユーザーが画面をタップしたときに発生します。detail.localPositionは、タップされた位置の座標を提供します。これをkButtonPartSize(十字キーの各セクションのサイズ)で割って、どのセクションがタップされたかを判断します。その後、CrossButtonStatus.valuesを使用して、タップされたセクションに対応するCrossButtonStatusを見つけて、_buttonStatusに設定します。対応しない場合(斜めとか真ん中とか)はorElse: () => CrossButtonStatus.noneで「何も押してない」扱いをしています。
kButtonPartSizeが十字キーを三分割した一つの大きさとしてます。 (detail.localPosition.dx / kButtonPartSize).floor() にて、画像内の押された座標を取得し、その値を三分割の大きさで割って切り捨てることで、0: 左ないし上、1:真ん中, 2: 右ないし上、を押したことが分かります。それをCrossButtonStatusの座標と合うように、それぞれの値を1で引きます。そして、対応するCrossButtonStatusを取得してます。

最後に、childプロパティにSvgPicture.assetを使用して、十字キーの画像を表示しています。

Aボタンの実装

ゲーム内でAボタンを実装するために、SizedBoxウィジェットを使用してボタンのサイズを指定し、その中にFilledButtonウィジェットを配置しています。FilledButtonはマテリアルデザインの充填されたボタンを提供するウィジェットです。

SizedBox(
  height: kButtonPartSize * 1.3,
  child: FilledButton(
    style: FilledButton.styleFrom(
      backgroundColor: Colors.pink,
      shape: const CircleBorder(),
    ),
    onPressed: () {
      print('A');
    },
    child: const Text(''),
  ),
),

Bボタンも同様ですが、Paddingで場所を調整してます。

ここで、kButtonPartSize * 1.3はボタンの高さを指定しています。FilledButton.styleFromメソッドを使用して、ボタンのスタイルを設定しています。backgroundColor: Colors.pinkでボタンの背景色をピンクに設定し、shape: const CircleBorder()でボタンの形状を円形に設定しています。

onPressedプロパティには、ボタンが押されたときに実行される関数を指定します。この例では、コンソールに'A'と表示するだけの簡単な関数を設定しています。

childプロパティには、ボタンの中に表示されるウィジェットを指定します。この例では、テキストを表示しないのでconst Text('')を使用しています。

Bボタンの実装も同様ですが、Paddingウィジェットを使用してボタンの位置を調整しています。

コントローラの実装

上記の十字キーとABボタンを配置し、その上に現在の十字キーの状態を表示するようにしました。最終的なソースは「ソース(main.dartにコピペして動作確認用)」をご参照ください。

まとめ

今回はコントローラを作成してみました。Flutterアプリを作るときは「どんなインターフェースにすればいいんだろう」と考えますが、すでにデザインが決まっていると気が楽です。アプリを作るときは、Figmaなどで事前にデザインすることの大切さを再認識しました。

参考

ソース(main.dartにコピペして動作確認用)

import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';

enum CrossButtonStatus {
  up(0, -1),
  down(0, 1),
  left(-1, 0),
  right(1, 0),
  none(0, 0);

  const CrossButtonStatus(this.x, this.y);
  final int x;
  final int y;
}

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  static const kButtonPartSize = 38.0;
  var _buttonStatus = CrossButtonStatus.none;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Padding(
        padding: const EdgeInsets.all(32.0),
        child: Column(
          children: [
            Text(_buttonStatus.toString()),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                SizedBox(
                  height: 3 * kButtonPartSize,
                  width: 3 * kButtonPartSize,
                  child: GestureDetector(
                      onTapUp: (_) {
                        setState(() {
                          _buttonStatus = CrossButtonStatus.none;
                        });
                      },
                      onTapDown: (detail) {
                        final x = (detail.localPosition.dx / kButtonPartSize)
                                .floor() -
                            1;
                        final y = (detail.localPosition.dy / kButtonPartSize)
                                .floor() -
                            1;

                        final buttonStatus = CrossButtonStatus.values
                            .firstWhere((e) => e.x == x && e.y == y,
                                orElse: () => CrossButtonStatus.none);
                        setState(() {
                          _buttonStatus = buttonStatus;
                        });
                      },
                      child: SvgPicture.asset('assets/cross_button.svg')),
                ),
                const Spacer(),
                Padding(
                  padding: const EdgeInsets.only(
                      top: kButtonPartSize, right: kButtonPartSize / 3),
                  child: SizedBox(
                    height: kButtonPartSize * 1.3,
                    child: FilledButton(
                      style: FilledButton.styleFrom(
                        backgroundColor: Colors.pink,
                        shape: const CircleBorder(),
                      ),
                      onPressed: () {
                        print('B');
                      },
                      child: const Text(''),
                    ),
                  ),
                ),
                SizedBox(
                  height: kButtonPartSize * 1.3,
                  child: FilledButton(
                    style: FilledButton.styleFrom(
                      backgroundColor: Colors.pink,
                      shape: const CircleBorder(),
                    ),
                    onPressed: () {
                      print('A');
                    },
                    child: const Text(''),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}