FlutterのCasual Game ToolKitを使ってみる! その5

ブログ

Flutter Casual Game Toolkit:

samples/game_template at main · flutter/samples
AcollectionofFlutterexamplesanddemos.Contributetoflutter/samplesdevelopmentbycreatinganaccountonGitHub.

前回まででベースのgithubソースコードのmain.dartの内容を主に見て実装をし、画面遷移や状態管理等の基本中の基本から外堀を埋めていきました。

いよいよ少しずつアプリケーションの部分を作っていきたいわけです。で、こういうアプリを作る場合まずいきなり中身に入る必要はないと思うのです。

まずはどのアプリでも成り立つような機能を先に固めてしまい、そこからアプリケーション固有の実装を進めるのが王道の作り方かと。

というわけで、どのアプリでも成り立つような機能。ただ今回はゲームを作りたいので、ゲームに必ずある機能。

そうです!設定画面ですね。

設定画面の変数の設定

というわけで、設定画面を作っていくわけです。

いつも通り、画面の状態を保持するStateから定義していきましょう。カウンターアプリと同様の感じで構成していきます。

とりあえず、設定のパラメータは1つで始めてみましょう。BGMをonにするか・offにするか。

以下のクラスを作成してみました。構成はcounter_state.dart周りとほぼ同じです。

settings_persistence.dart

abstract class SettingsPersistence {
  Future<bool> getMusicOn();

  Future<void> setMusicOn(bool musicOn);
}

local_settings_persistence.dart

class LocalSettingsPersistence extends SettingsPersistence{
  final Future<SharedPreferences> instanceFuture =
  SharedPreferences.getInstance();

  @override
  Future<bool> getMusicOn() async {
    final prefs = await instanceFuture;
    return prefs.getBool('musicOn') ?? false;
  }

  @override
  Future<void> setMusicOn(bool musicOn) async {
    final prefs = await instanceFuture;
    await prefs.setBool('counter', musicOn);
  }
}

ここら辺は全くcounter_persistence.dartとlocal_counter_persistence.dartと同じですね。

settings_state.dart

class SettingsState extends ChangeNotifier {
  final SettingsPersistence _store;
  bool _musicOn = false;

  SettingsState({required SettingsPersistence store}) : _store = store;

  bool getMusicOn() {
    return _musicOn;
  }

  loadSettingsFromPersistence() async {
    await Future.wait([_store.getMusicOn().then((value) => _musicOn = value)]);
  }

  void toggleMusicOn() {
    _musicOn = !_musicOn;
    _store.setMusicOn(_musicOn);
  }
}

ここだけcounter_state.dartとメソッドを変えてあります。(というかサンプルのソースコードをパクった)以下のメソッドで構成されています。

  • bool getMusicOn() :ローカルにある変数へのアクセサ。
  • loadSettingsFromPersistence():全ての設定値をローカルストレージから取得する(これについては後述)
  • void toggleMusicOn() :呼び出したらon・offを切り替える。合わせて状態をローカルストレージに送信する。

このうち、loadSettingsFromPersistence()についてなのですがここでFuture.waitとしており、非同期処理を一つにまとめて取得できるようにしています。以下が参考になると思います。

Flutter(Dart)で複数の非同期処理を並行して待ちたい(Future.wait()の挙動) - Qiita
はじめにDartでは,dart:asyncのFuture.wait()を使う事で複数の非同期処理を並行して待つことができます.Flutterなどで,複数のAPIを同時に取得したい時に使えます.…

今回は暫定的に1つの変数のみで検証していますが、実際は音楽の再生する・しないや効果音をつける・つけないなど設定画面で設定する内容は他にもあるため、非同期でローカルストレージから取得する場合には全部まとめて取りたいですからね。

こんな感じで、データの状態管理を整理できたところで次は画面を作成(及び一部修正)していきましょう。

画面の作成

最初に先ほど作成していたState周りを使用するようにmain.dartを修正しましょう。以下のようになります。

main.dart

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(MyApp(
    counterPersistence: LocalCounterPersistence(),
    settingsPersistence: LocalSettingsPersistence(),
  ));
}

class MyApp extends StatelessWidget {
  const MyApp(
      {super.key,
      required this.counterPersistence,
      required this.settingsPersistence});

  final CounterPersistence counterPersistence;
  final SettingsPersistence settingsPersistence;

  static final _router = GoRouter(routes: [
    GoRoute(
      path: '/',
      builder: (context, state) =>
          const MainPage(title: 'Flutter Demo Home Page'),
      routes: [
        GoRoute(
          path: 'homepage',
          pageBuilder: (context, state) => buildMyTransition<void>(
            key: ValueKey('homepage'),
            child: const MyHomePage(title: 'Flutter Demo Home Page'),
            color: Colors.deepPurple,
          ),
        ),
      ],
    ),
  ]);

  @override
  Widget build(BuildContext context) {
    return AppLifecycleObserver(
      child: MultiProvider(
        providers: [
          ChangeNotifierProvider(create: (context) {
            var counterState = CounterState(store: counterPersistence);
            counterState.getFirstCounter();
            return counterState;
          }),
          ChangeNotifierProvider( <-追加
              lazy: false, <-追加
              create: (context) => SettingsState(store: settingsPersistence) <-追加
                ..loadSettingsFromPersistence()), <-追加
        ],
        child: Builder(builder: (context) {
          return MaterialApp.router(
            title: 'Flutter Demo',
            theme: ThemeData.from(
              colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
              useMaterial3: true,
            ),
            routeInformationProvider: _router.routeInformationProvider,
            routeInformationParser: _router.routeInformationParser,
            routerDelegate: _router.routerDelegate,
          );
        }),
      ),
    );
  }
}

main.dart内でChangeNotifierを新しく追加しました。

続いて、設定画面を作成していくわけですが、その前に少し既存の画面も少し改修しましょう。

リスポンシブ対応の追加

サンプルソースは全て画面の表示部分ではない箇所は以下のウィジェットを使っているようです。

responsive_screen.dart

/// A widget that makes it easy to create a screen with a square-ish
/// main area, a smaller menu area, and a small area for a message on top.
/// It works in both orientations on mobile- and tablet-sized screens.
class ResponsiveScreen extends StatelessWidget {
  /// This is the "hero" of the screen. It's more or less square, and will
  /// be placed in the visual "center" of the screen.
  final Widget squarishMainArea;

  /// The second-largest area after [squarishMainArea]. It can be narrow
  /// or wide.
  final Widget rectangularMenuArea;

  /// An area reserved for some static text close to the top of the screen.
  final Widget topMessageArea;

  /// How much bigger should the [squarishMainArea] be compared to the other
  /// elements.
  final double mainAreaProminence;

  const ResponsiveScreen({
    required this.squarishMainArea,
    required this.rectangularMenuArea,
    this.topMessageArea = const SizedBox.shrink(),
    this.mainAreaProminence = 0.8,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        // This widget wants to fill the whole screen.
        final size = constraints.biggest;
        final padding = EdgeInsets.all(size.shortestSide / 30);

        if (size.height >= size.width) {
          // "Portrait" / "mobile" mode.
          return Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              SafeArea(
                bottom: false,
                child: Padding(
                  padding: padding,
                  child: topMessageArea,
                ),
              ),
              Expanded(
                flex: (mainAreaProminence * 100).round(),
                child: SafeArea(
                  top: false,
                  bottom: false,
                  minimum: padding,
                  child: squarishMainArea,
                ),
              ),
              SafeArea(
                top: false,
                maintainBottomViewPadding: true,
                child: Padding(
                  padding: padding,
                  child: rectangularMenuArea,
                ),
              ),
            ],
          );
        } else {
          // "Landscape" / "tablet" mode.
          final isLarge = size.width > 900;

          return Row(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Expanded(
                flex: isLarge ? 7 : 5,
                child: SafeArea(
                  right: false,
                  maintainBottomViewPadding: true,
                  minimum: padding,
                  child: squarishMainArea,
                ),
              ),
              Expanded(
                flex: 3,
                child: Column(
                  children: [
                    SafeArea(
                      bottom: false,
                      left: false,
                      maintainBottomViewPadding: true,
                      child: Padding(
                        padding: padding,
                        child: topMessageArea,
                      ),
                    ),
                    Expanded(
                      child: SafeArea(
                        top: false,
                        left: false,
                        maintainBottomViewPadding: true,
                        child: Align(
                          alignment: Alignment.bottomCenter,
                          child: Padding(
                            padding: padding,
                            child: rectangularMenuArea,
                          ),
                        ),
                      ),
                    )
                  ],
                ),
              ),
            ],
          );
        }
      },
    );
  }
}

手前のコメントに書かれている内容を読む限り、以下のことがわかります。

  • 正方形に近いメインエリア、小さなメニュー・エリア、上部にメッセージを表示する小さなエリアの合計3つのエリアで構成されている
  • mainAreaProminenceのパラメータで他の要素と比較してどのくらい大きくするかを設定できる

とはいえ、あまりここら辺はいじるよりもデフォルトの設定を使用しましょう。

というわけで、既に作っている2画面をレスポンシブ対応してみます。

main_page.dart

class MainPage extends StatelessWidget {
  const MainPage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: Text(title),
        ),
        body: ResponsiveScreen( <- 追加
          squarishMainArea: const Center(<- 追加
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Text(
                  'Start App!',
                ),
              ],
            ),
          ),
          rectangularMenuArea: Column( <- 追加
            children: [
              SizedBox(
                width: 100,
                child: FloatingActionButton(
                  onPressed: () {
                    GoRouter.of(context).go('/homepage');
                  },
                  child: const Icon(Icons.play_arrow_outlined),
                ),
              ),
            ],
          ),
        ));
  }
}

上記のように、ResponsiveScreenというウィジェットで既存のColumnなどをWrapしてあげれば大丈夫です。

squarishMainArea(メインとなるエリア)、rectangularMenuArea(下の方に表示されるエリア)の2つは最低いないとダメなようなので、文字部分はsquarishMainArea、ボタンエリアはrectangularMenuAreaに配置してみました。

合わせて本体のアプリ部分もレスポンシブ対応してみましょう。

my_home_page.dart

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    var counter = context.watch<CounterState>();
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: ResponsiveScreen( <- 追加
        squarishMainArea: Center( <- 追加
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              const Text(
                'You have pushed the button this many times:',
              ),
              Text(
                counter.getCounter().toString(),
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ],
          ),
        ),
        rectangularMenuArea: Column(children: [ <- 追加
          SizedBox(
            width: 100,
            child: FloatingActionButton(
              onPressed: () {
                counter.incrementCounter();
              },
              tooltip: 'Increment',
              child: const Icon(Icons.add),
            ),
          ),
        ]),
      ),
    );
  }
}

こちらも構成としてはほぼ同じですね。ではいよいよ画面作成します。

設定画面作成(2回目)

settings_page.dart

class SettingsPage extends StatelessWidget {
  const SettingsPage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    final settings = context.watch<SettingsState>();
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: ResponsiveScreen(
          squarishMainArea: ListView(
            children: [
              const SizedBox(height: 60),
              const Text('Settings',
                  textAlign: TextAlign.center, style: TextStyle(fontSize: 40)),
              const SizedBox(height: 40),
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Text('Music : ', style: TextStyle(fontSize: 30)),
                  const SizedBox(width: 100),
                  GestureDetector(
                      child: Icon(settings.getMusicOn()
                          ? Icons.music_note
                          : Icons.music_off),
                      onTap: () => settings.toggleMusicOn())
                ],
              )
            ],
          ),
          rectangularMenuArea: Column(
            children: [
              SizedBox(
                width: 100,
                child: FloatingActionButton(
                  onPressed: () {
                    GoRouter.of(context).go("/");
                  },
                  child: const Text('Back'),
                ),
              ),
            ],
          )),
    );
  }
}

更に、main.dartにルーティングを追加します。

main.dart

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(MyApp(
    counterPersistence: LocalCounterPersistence(),
    settingsPersistence: LocalSettingsPersistence(),
  ));
}

class MyApp extends StatelessWidget {
  const MyApp(
      {super.key,
      required this.counterPersistence,
      required this.settingsPersistence});

  final CounterPersistence counterPersistence;
  final SettingsPersistence settingsPersistence;

  static final _router = GoRouter(routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const MainPage(
        title: 'Flutter Demo Home Page',
        key: Key('MainPage'),
      ),
      routes: [
        GoRoute(
          path: 'homepage',
          pageBuilder: (context, state) => buildMyTransition<void>(
            key: ValueKey('homepage'),
            child: const MyHomePage(title: 'Flutter Demo Home Page'),
            color: Colors.deepPurple,
          ),
        ),
        GoRoute( <-追加
            path: 'settings', <-追加
            builder: (context, state) => <-追加
                const SettingsPage(title: "Settings", key: Key('MainPage'))), <-追加
      ],
    ),
  ]);

  @override
  Widget build(BuildContext context) {
    return AppLifecycleObserver(
      child: MultiProvider(
        providers: [
          ChangeNotifierProvider(create: (context) {
            var counterState = CounterState(store: counterPersistence);
            counterState.getFirstCounter();
            return counterState;
          }),
          ChangeNotifierProvider(
              lazy: false,
              create: (context) => SettingsState(store: settingsPersistence)
                ..loadSettingsFromPersistence()),
        ],
        child: Builder(builder: (context) {
          return MaterialApp.router(
            title: 'Flutter Demo',
            theme: ThemeData.from(
              colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
              useMaterial3: true,
            ),
            routeInformationProvider: _router.routeInformationProvider,
            routeInformationParser: _router.routeInformationParser,
            routerDelegate: _router.routerDelegate,
          );
        }),
      ),
    );
  }
}

プラスで、メイン画面に設定画面への遷移を追加してみます。

main_page.dart

class MainPage extends StatelessWidget {
  const MainPage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: Text(title),
        ),
        body: ResponsiveScreen(
          squarishMainArea: const Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Text(
                  'Start App!',
                ),
              ],
            ),
          ),
          rectangularMenuArea: Column(
            children: [
              SizedBox(
                width: 100,
                child: FloatingActionButton(
                  heroTag: "homepage", <-追加
                  onPressed: () {
                    GoRouter.of(context).go('/homepage');
                  },
                  child: const Icon(Icons.play_arrow_outlined),
                ),
              ),
              SizedBox( <-追加
                width: 100, <-追加
                child: FloatingActionButton( <-追加
                  heroTag: "settings", <-追加
                  onPressed: () { <-追加
                    GoRouter.of(context).go('/settings'); <-追加
                  }, <-追加
                  child: const Icon(Icons.settings), <-追加
                ),
              ),
            ],
          ),
        ));
  }
}

これで画面を作成しました。画面の操作の様子は以下のようになります。

まとめ

一旦設定画面を作成しました。次回、いよいよ音楽再生機能を作ってみたいと思います。

併せて設定画面もいじってみようと思います。

コメント