Skip to content

UI の作成

3.1: UI の作成概要

3.1.1: 作成する UI について(三目並べのゲーム画面)

3章では、三目並べを遊ぶためのゲーム画面を作ります。
作成するゲーム画面では、プレーヤーがタップすることで、指手の ○×マークを配置したり、 プレーヤーに次の指し手を促したり、ゲームの勝敗を告げたりすることができるようにします。

これらを前章で作成した「ゲーム進行の状態を表す ゲームロジックのモデル」を使って、
ゲーム開始から終了までを モデル内容の変遷 ⇒ 状態遷移 により表現できるようにします。

    UI完成図
    完成図
  • 脚注
    • ゲームロジックのモデル
      TicTacTow クラスのインスタンス・オブジェクトを表します。

    • 指手 / 指し手
      盤面に自分の戦略手(○×マーク)を打つこと。
      もしくは、盤面に自分の戦略手(○×マーク)を置く人(プレーヤー)のこと。

3.1.2: ゲームロジック モデル(ゲーム進行状態モデル)についてのおさらい。

  1. TicTacTow クラス
    TicTacToe クラスは、三目並べゲームロジックのモデルです。
    そのインスタンス・オブジェクトは、三目並べゲームの「盤面状態値」と「今回のプレーヤー値」および「盤面更新」「勝敗判定」の機能を持っています。

つまり「次のゲームプレイ状態を提供」できるようにする「ある時点のゲーム進行状態」を表すことができます。

ゲームロジックのモデルは、「ゲームプレイの初期状態」や、指し手により盤面更新された「次のゲームプレイ状態」および、 ゲーム再開のための「新しいゲームプレイの初期状態」を提供することに留意ください。
これはプレイ状態を保持して状態遷移を管理するコントローラではないことを表します。


  1. Players クラス
    プレーヤーを表す「先手 ×マーク」と「後手 ○マーク」のプレーヤー名を保持するモデルです。

【参考】TicTacToe クラスのプロパティおよびメソッドの概要一覧

dart
/// TicTacToe クラスのプロパティおよびメソッドの概要一覧
class TicTacToe {
  /// 三目並べゲームの盤面状態値(行:列からなる ○×マークを配置する2次元配列)
  final List<List<String>> board;

  /// 今回のプレーヤー値(今回の指手名)
  final String currentPlayer;

  /// (勝敗判定)勝者判定(勝者○×マーク、もしくは未決着なら空文字列が返る)
  String getWinner();

  /// (勝敗判定)引分終了判定(未決着でゲーム終了なら true が返る)
  bool isDraw();

  /// 盤面更新 ファクトリパターン・メソッド(今回の指し手により更新された、次のゲーム進行状態を返す)
  TicTacToe placeMark(int row, int col);

  /// 新しいゲームプレイの初期状態 ファクトリパターン・メソッド
  TicTacToe resetBoard();

  /// ゲームプレイの初期状態 ファクトリ
  factory TicTacToe.start()
}
/// TicTacToe クラスのプロパティおよびメソッドの概要一覧
class TicTacToe {
  /// 三目並べゲームの盤面状態値(行:列からなる ○×マークを配置する2次元配列)
  final List<List<String>> board;

  /// 今回のプレーヤー値(今回の指手名)
  final String currentPlayer;

  /// (勝敗判定)勝者判定(勝者○×マーク、もしくは未決着なら空文字列が返る)
  String getWinner();

  /// (勝敗判定)引分終了判定(未決着でゲーム終了なら true が返る)
  bool isDraw();

  /// 盤面更新 ファクトリパターン・メソッド(今回の指し手により更新された、次のゲーム進行状態を返す)
  TicTacToe placeMark(int row, int col);

  /// 新しいゲームプレイの初期状態 ファクトリパターン・メソッド
  TicTacToe resetBoard();

  /// ゲームプレイの初期状態 ファクトリ
  factory TicTacToe.start()
}

3.2: UI 作成手順概要 (ゲーム画面の作成ステップ)

前章までの作業は、flutter プロジェクトの新規作成とゲームロジックのモデルの新規追加までとなっています。

このためアプリの UIは、カウンターアプリのままですから、以下の手順で、三目並べを遊ぶための画面を作り上げていきます。

  1. main パッケージの修正
    前章でのアプリの UIは、カウンターアプリのままです。
    このためプロジェクトにゲーム画面(Board ⇒ はじめは空コンテンツ)を新規追加します。
    次に元々のカウンターアプリから不要コードの削除とアプリタイトルの修正を行い、
    ホーム画面を MyHomePage からゲーム画面(はじめは空コンテンツ)に差し替えます。

      main パッケージの修正
      main パッケージの修正

  2. 三目並べ盤面の追加
    ゲーム画面(Board)に「ゲーム進行状態」から、今までの指手(○×マーク) の配置や、 今回のプレーヤーの指手(○×マーク) を配置して、次のゲーム進行状態状態遷移 できるようにします。
    このために縦横 3x3 に区切られたマス目(セル)を新規追加します。

      三目並べ盤面の追加
      三目並べ盤面の追加

  3. メッセージ表示欄の追加
    ゲーム画面に「ゲーム進行状態」から 今回の指手や、ゲーム勝敗終了 を表示する、
    メッセージ欄を新規追加します。

      メッセージ表示欄の追加
      メッセージ表示欄の追加

  4. ゲーム・リセットボタンの追加
    ゲーム画面に、新しいゲーム(ゲームプレイの初期状態)へ 状態遷移 させる
    リセット・ボタンを追加します。

      ゲーム・リセットボタンの追加
      ゲーム・リセットボタンの追加

3.3: UI 作成作業 (ゲーム画面の具体的作成ステップ)

三目並べを遊ぶための画面 UIの作成ステップを紹介しましたので、具体的な作業に入りましょう。

3.3.1: main パッケージの修正

1. ゲーム画面ウィジェット(はじめは空コンテンツ)の新規作成

プロジェクトにゲーム画面(Board ⇒ はじめは空コンテンツ)を新規追加します。
IDEやエディタで libディレクトリview ディレクトリを新規追加して、空コンテンツのゲーム画面(board.dart)ファイルを追加してください。


  • 空コンテンツのゲーム画面(Board) ウィジェットのコードファイル
    コードファイル名 board.dart、パッケージ配置先 ⇒ lib/view/board.dart
dart
import 'package:flutter/material.dart';

class Board extends StatefulWidget {
  const Board({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return _BoardState();
  }
}

class _BoardState extends State<Board> {
  @override
  Widget build(BuildContext context) {
    // 画面いっぱいに描画領域だけを確保しています。
    return const SizedBox.expand();
  }
}
import 'package:flutter/material.dart';

class Board extends StatefulWidget {
  const Board({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return _BoardState();
  }
}

class _BoardState extends State<Board> {
  @override
  Widget build(BuildContext context) {
    // 画面いっぱいに描画領域だけを確保しています。
    return const SizedBox.expand();
  }
}

2. 不要コードの削除(コメント削除)

カウンターアプリのコードには、たくさんのコメントがあります。
コードの見通しを良くするため MaterialApp の中にある ThemeData のコメントを削除しましょう。

またダークテーマ対応として、MaterialAppのプロパティ darkTheme を設定するコード ⇒ darkTheme: ThemeData.dark(),を追加してみてください。


  • 作業後の MaterialApp と ThemeData のコード内容
dart
 〜 省略 〜
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(), //【新規追加】
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
 〜 省略 〜
 〜 省略 〜
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(), //【新規追加】
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
 〜 省略 〜

  • MaterialAppは、マテリアルライブラリに属する アプリケーション構成 を提供するウィジェットです。
    マテリアルライブラリは、マテリアルデザインが実装された Flutterウィジェット を提供します。

  • ThemeDataは、アプリ内のウィジェット全般の色やテキストスタイルなどのビジュアルテーマを指定するウィジェトです。

  • darkThemeは、システム設定からダークテーマが指定されたときにアプリのビジュアルテーマを指定するプロパティです。

  • ThemeDataによりアプリ全般の設定ができることを確認してみましょう。
    時間があれば ThemeDataのプロパティ scaffoldBackgroundColorにコード ⇒ scaffoldBackgroundColor: Colors.amber, を追加して背景色が変化することを確認してみてください。


3. 不要コードの削除(ホーム画面削除)

次にホーム画面を MyHomePage から、アプリ画面の足場(Scaffold)に差し替えます。
差し替えが終わりましたら、不要になった MyHomePage_MyHomePageState を削除してください。


  • 作業後の MaterialApp: homeプロパティのコード内容
dart
 〜 省略 〜
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(),
      home: Scaffold(), //【コード差替】
    );
  }
 〜 省略 〜
 〜 省略 〜
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(),
      home: Scaffold(), //【コード差替】
    );
  }
 〜 省略 〜


4. アプリタイトル表示を追加

一つ前の作業ステップで、MyHomePageを削除したのでアプリバーがなくなっています。
アプリバーを表示させるため Scaffoldアプリバー・プロパティ(appBar)アプリケーションバー・ウィジェット(AppBar) を追加します。

画面にアプリバーが追加されたのでハンズオン・アプリを示すよう、 AppBarタイトル・プロパティ(title)'FlutterKaigi 2023 - TicTacToe' を設定します。

また MaterialAppタイトル・プロパティ(title) にも同様に 'FlutterKaigi 2023 - TicTacToe’ を設定してください。


  • 作業後の MaterialApp と Scaffold のコード内容
dart
 〜 省略 〜
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FlutterKaigi 2023 - TicTacToe', //【タイトル差替】アプリ説明のタイトル
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(),
      home: Scaffold(
        appBar: AppBar(                                       //【新規追加】アプリバー
          title: const Text('FlutterKaigi 2023 - TicTacToe'), //【新規追加】アプリのタイトル
        ),                                                    //【新規追加】
      ),
    );
  }
 〜 省略 〜
 〜 省略 〜
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FlutterKaigi 2023 - TicTacToe', //【タイトル差替】アプリ説明のタイトル
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(),
      home: Scaffold(
        appBar: AppBar(                                       //【新規追加】アプリバー
          title: const Text('FlutterKaigi 2023 - TicTacToe'), //【新規追加】アプリのタイトル
        ),                                                    //【新規追加】
      ),
    );
  }
 〜 省略 〜

5. アプリコンテンツ(ゲーム画面)表示を追加

アプリバーを追加したので、残るゲーム画面をアプリボディに追加しましょう。
ゲーム画面を表示させるため Scaffoldボディ・プロパティ(body)ゲーム画面・ウィジェット(Board) を追加します。

【注意】現状のゲーム画面(Board)は、何も表示するものがありません。


  • 作業後の main パッケージ内容
dart
import 'package:flutter/material.dart';
import 'package:tic_tac_toe_handson/view/board.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FlutterKaigi 2023 - TicTacToe',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(),
      home: Scaffold(
        appBar: AppBar(
          title: const Text('FlutterKaigi 2023 - TicTacToe'),
        ),
        body: const Board(), //【新規追加】ゲーム画面ウィジェット
      ),
    );
  }
}
import 'package:flutter/material.dart';
import 'package:tic_tac_toe_handson/view/board.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FlutterKaigi 2023 - TicTacToe',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(),
      home: Scaffold(
        appBar: AppBar(
          title: const Text('FlutterKaigi 2023 - TicTacToe'),
        ),
        body: const Board(), //【新規追加】ゲーム画面ウィジェット
      ),
    );
  }
}

    main パッケージ修正後のアプリ画面
    main パッケージ修正後のアプリ画面


3.3.2: 三目並べ盤面の追加

1. 空コンテンツのゲーム画面の修正

前章で新規作成した 空コンテンツのゲーム画面(Board ウィジェット) は、画面いっぱいに空欄を表示するだけでした。

ゲーム画面は、三目並べ盤面だけではなくメッセージ欄もありますので、任意複数のコンテンツを追加できるように修正します。

三目並べのゲーム画面は、縦方向にコンテンツが並びます。 このためコンテンツを列表示させる Column ウィジェット を使います。
そしてコンテンツが画面端に付かないようにする ⇒ 四方枠に空隙をとる ⇒ ため、ColumnPadding ウィジェット でラップします。

このような設計により最初のコード return const SizedBox.expand(); を、Paddingと Columnの入れ子 に差し替えます。
具体的なコードは、(修正後)空コンテンツのゲーム画面のコードを参照ください。

最後にゲームの進行が ゲーム開始から終了まで状態遷移で表現できるよう、
StatefulWidgetStateゲーム進行状態のモデル(TicTacTow クラス) を保持させます。


  • (修正後)空コンテンツのゲーム画面(Board) ウィジェットのコードファイル
    コードファイル名 board.dart、パッケージ配置先 ⇒ lib/view/board.dart
dart
import 'package:flutter/material.dart';

class Board extends StatefulWidget {
  const Board({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return _BoardState();
  }
}

class _BoardState extends State<Board> {
  //【新規追加】ゲーム進行状態の初期値
  TicTacToe ticTacToe = TicTacToe.start(playerX: 'Dash', playerO: 'Sparky');

  @override
  Widget build(BuildContext context) {
    //【差替】コンテンツを列方向(縦並び)に配置する Column を Padding でラップ(ここから)
    return Padding(
      padding: EdgeInsets.all(16),
      child: Column(
        children: [
        ],
      ),
    );
    //【差替】コンテンツを列方向(縦並び)に配置する Column を Padding でラップ(ここまで)
  }
}
import 'package:flutter/material.dart';

class Board extends StatefulWidget {
  const Board({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return _BoardState();
  }
}

class _BoardState extends State<Board> {
  //【新規追加】ゲーム進行状態の初期値
  TicTacToe ticTacToe = TicTacToe.start(playerX: 'Dash', playerO: 'Sparky');

  @override
  Widget build(BuildContext context) {
    //【差替】コンテンツを列方向(縦並び)に配置する Column を Padding でラップ(ここから)
    return Padding(
      padding: EdgeInsets.all(16),
      child: Column(
        children: [
        ],
      ),
    );
    //【差替】コンテンツを列方向(縦並び)に配置する Column を Padding でラップ(ここまで)
  }
}

  • Columnは、複数の子ウィジェットを縦方向の列並びにするレイアウト・ウィジェットです。
  • Rowは、複数の子ウィジェットを横方向の行並びにするレイアウト・ウィジェットです。
  • Paddingは、子ウィジェットの四方に空隙を詰めるウィジェットです。

2. 3×3のマス目の追加(セル追加)

三目並べ盤面には、3×3のマス目があります。
ここでは、GridView という グリッド ⇒ ウィジェットの2D配列 ⇒ 縦横格子レイアウト を行うウィジェットを使ってマス目を表現します。
具体的なコードは、(修正後)ゲーム画面のコードを参照ください。


  • (修正後)ゲーム画面(Board) ウィジェットのコード内容
dart
      〜 省略 〜
      child: Column(
        children: [
          //【新規追加】(ここから)
          // 三目並べ盤面
          GridView.builder(
            shrinkWrap: true,
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3, //横方向のマス個数(3個)
            ),
            itemCount: 9, //縦横のマス個数(3×3)
            itemBuilder: (context, index) {
              return const SizedBox.expand(); //マス目に空欄を確保するだけのダミー
            },
          ),
          //
          //【新規追加】(ここまで)
        ],
      ),
      〜 省略 〜
      〜 省略 〜
      child: Column(
        children: [
          //【新規追加】(ここから)
          // 三目並べ盤面
          GridView.builder(
            shrinkWrap: true,
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3, //横方向のマス個数(3個)
            ),
            itemCount: 9, //縦横のマス個数(3×3)
            itemBuilder: (context, index) {
              return const SizedBox.expand(); //マス目に空欄を確保するだけのダミー
            },
          ),
          //
          //【新規追加】(ここまで)
        ],
      ),
      〜 省略 〜

  • GridViewは、いくつかの状況に対応できるよう複数のコンストラクタがあります。
    ハンズオンでは、builderコンストラクタを使って、横方向 3個のアイテムを 9個分描画させることで3×3のマス目を表現しています。

3. 3×3のマス目の追加(縦横の罫線表示)

三目並べ盤面の3×3のマス目が確保できたので、各マスに縦横の罫線を引きましょう。
ここでは Container ウィジェットdecoration プロパティBoxDecoration を指定して、枠線 を描画させます。

各マス目に空欄を確保するだけのダミー return const SizedBox.expand();Containerに差し替えます。
具体的なコードは、(修正後)ゲーム画面のコードを参照ください。


  • (修正後)ゲーム画面(Board) ウィジェットのコード内容
dart
            〜 省略 〜
            itemBuilder: (context, index) {
              //【差替】(ここから)
              return Container(
                //マス目の縦横罫線をGrayで描画
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey),
                ),
              );
              //【差替】(ここまで)
            },
            〜 省略 〜
            〜 省略 〜
            itemBuilder: (context, index) {
              //【差替】(ここから)
              return Container(
                //マス目の縦横罫線をGrayで描画
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey),
                ),
              );
              //【差替】(ここまで)
            },
            〜 省略 〜

  • Containerは、自分の描画領域や子ウィジェットに制約を与えるウィジェットです。
    このため自分の描画領域にサイズや背景色や枠線を、子ウィジェットに配置位置(右寄、中央寄、etc)や変形などの制約を与えることができます。

  • 【注意】正確にいえば罫線でなく枠線を描画しています。
        このため内側罫線よりも外枠が細くなっていることに注意ください。


4. 3×3のマス目の追加(○×マーク表示)

三目並べ盤面の3×3のマス目には、先攻と後攻指手の○×マークが描画されます。
先攻と後攻指手の○×マークは、ある時点の ゲーム進行状態のモデル(TicTacTow クラス) オブジェクト の 2次元配列(List<List<String>> board)に記録されているので、以下のような「行と列」の判定と「マス目の ○×マーク」を取得するコードを追加します。

dart
itemBuilder: (context, index) {
  // マス目ごとの行と列を判定し、カレントマス目の ○×(あるいは空文字列)マークを取得する。
  int row = index ~/ 3;
  int col = index % 3;
  String mark = ticTacToe.board[row][col]; //○× または空文字列を返す。
}
itemBuilder: (context, index) {
  // マス目ごとの行と列を判定し、カレントマス目の ○×(あるいは空文字列)マークを取得する。
  int row = index ~/ 3;
  int col = index % 3;
  String mark = ticTacToe.board[row][col]; //○× または空文字列を返す。
}

またマス目ごとに ○×マークを描画するため、Containerの childプロパティText(mark)を追加します。
具体的なコードは、(修正後)ゲーム画面のコードを参照ください。


  • (修正後)ゲーム画面(Board) ウィジェットのコード内容
dart
            〜 省略 〜
            itemBuilder: (context, index) {
              //【新規追加】(ここから)
              final row = index ~/ 3;
              final col = index % 3;
              final mark = ticTacToe.board[row][col]; //ゲーム進捗状態から、マス目に対応する○×マークを取得
              //【新規追加】(ここまで)

               return Container(
                //マス目の縦横罫線をGrayで描画
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey),
                ),
                //【新規追加】(ここから)
                child: Center(
                  //マス目の ○×マーク(もしくは空欄)を描画
                  child: Text(
                    mark,
                    style: const TextStyle(fontSize: 32),
                  ),
                ),
                //【新規追加】(ここまで)
              );
              〜 省略 〜
            〜 省略 〜
            itemBuilder: (context, index) {
              //【新規追加】(ここから)
              final row = index ~/ 3;
              final col = index % 3;
              final mark = ticTacToe.board[row][col]; //ゲーム進捗状態から、マス目に対応する○×マークを取得
              //【新規追加】(ここまで)

               return Container(
                //マス目の縦横罫線をGrayで描画
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey),
                ),
                //【新規追加】(ここから)
                child: Center(
                  //マス目の ○×マーク(もしくは空欄)を描画
                  child: Text(
                    mark,
                    style: const TextStyle(fontSize: 32),
                  ),
                ),
                //【新規追加】(ここまで)
              );
              〜 省略 〜

  • 先攻と後攻の指手が交代するごとに、ゲーム盤面全体が更新される ことに留意ください。

5. 3×3のマス目の追加(マス目のタップイベント追加)

三目並べ盤面の3×3のマス目は、タップにより新しい指し手(○×マーク)が配置されて、次の対局に進みます。

これはタップされたマス目により、カレント指し手が有効であるか ⇒ ○×マーク配置可能か否かを判定し、 有効であればカレント指し手が記録された「新しいゲーム進行状態」に状態遷移することを示します。

タップ・イベントのハンドリング ⇒ タップに対応する任意処理を指定できるウィジェットには、 GestureDetector があります。

ハンドラでは、タップされると カレント指し手が有効か否かを判定し、有効であれば カレント指し手がマス目に配置された三目並べの盤面を作成 させるため、 ゲーム進行状態のモデル(TicTacTow クラス)の placeMark(row, col) を実行することになります。

よってこれらの処理が、StatefulWidget の状態を更新する StatesetState() で実行されるようにすれば良いことになります。

dart
setState(() {
  //カレント指し手が有効か否かをチェックするため、
  //カレント指し手のマス目(mark)が現在空欄であり、勝敗もついていないことを確認する。
  final winner = ticTacToe.getWinner();
  if (mark.isEmpty && winner.isEmpty) {
    //カレント指し手が有効であれば、
    //カレント指し手がゲーム盤面に反映された「新しいゲーム進行状態」を生成する。
    ticTacToe = ticTacToe.placeMark(row, col);
  }
});
setState(() {
  //カレント指し手が有効か否かをチェックするため、
  //カレント指し手のマス目(mark)が現在空欄であり、勝敗もついていないことを確認する。
  final winner = ticTacToe.getWinner();
  if (mark.isEmpty && winner.isEmpty) {
    //カレント指し手が有効であれば、
    //カレント指し手がゲーム盤面に反映された「新しいゲーム進行状態」を生成する。
    ticTacToe = ticTacToe.placeMark(row, col);
  }
});

ここでは、GestureDetectorでマス目を描画する Containerをラップし onTap ハンドラを使ってタップのハンドリングを行なわせましょう。
具体的なコードは、(修正後)ゲーム画面のコードを参照ください。


  • (修正後)ゲーム画面(Board) ウィジェットのコード内容
dart
            〜 省略 〜
            itemBuilder: (context, index) {
              final row = index ~/ 3;
              final col = index % 3;
              final mark = ticTacToe.board[row][col];

              //【新規追加】GestureDetector を新規追加(ここから)
              return GestureDetector(
                onTap: () {
                  setState(() {
                    final winner = ticTacToe.getWinner();
                    if (mark.isEmpty && winner.isEmpty) {
                      ticTacToe = ticTacToe.placeMark(row, col);
                    }
                  });
                },
                //【新規追加】GestureDetector を新規追加(ここまで)
                //【差替】Container が GestureDetector にラップされるよう child にする(ここから)
                child: Container(
                //【差替】Container が GestureDetector にラップされるよう child にする(ここまで)
                  decoration: BoxDecoration(
                    border: Border.all(color: Colors.grey),
                  ),
                  child: Center(
                    child: Text(
                      mark,
                      style: const TextStyle(fontSize: 32),
                    ),
                  ),
                //【差替】Container が GestureDetector にラップされるよう child にする(ここから)
                ),
                //【差替】Container が GestureDetector にラップされるよう child にする(ここまで)
              //【新規追加】GestureDetector を新規追加(ここから)
              );
              //【新規追加】GestureDetector を新規追加(ここまで)
            },
            〜 省略 〜
            〜 省略 〜
            itemBuilder: (context, index) {
              final row = index ~/ 3;
              final col = index % 3;
              final mark = ticTacToe.board[row][col];

              //【新規追加】GestureDetector を新規追加(ここから)
              return GestureDetector(
                onTap: () {
                  setState(() {
                    final winner = ticTacToe.getWinner();
                    if (mark.isEmpty && winner.isEmpty) {
                      ticTacToe = ticTacToe.placeMark(row, col);
                    }
                  });
                },
                //【新規追加】GestureDetector を新規追加(ここまで)
                //【差替】Container が GestureDetector にラップされるよう child にする(ここから)
                child: Container(
                //【差替】Container が GestureDetector にラップされるよう child にする(ここまで)
                  decoration: BoxDecoration(
                    border: Border.all(color: Colors.grey),
                  ),
                  child: Center(
                    child: Text(
                      mark,
                      style: const TextStyle(fontSize: 32),
                    ),
                  ),
                //【差替】Container が GestureDetector にラップされるよう child にする(ここから)
                ),
                //【差替】Container が GestureDetector にラップされるよう child にする(ここまで)
              //【新規追加】GestureDetector を新規追加(ここから)
              );
              //【新規追加】GestureDetector を新規追加(ここまで)
            },
            〜 省略 〜

  • 【備考】新しいゲーム進行状態への状態遷移により、カレント指し手がマス目に描画されます。
    この描画は1つ前で追加した、3×3のマス目の追加(○×マーク表示) により行われます。

6. 三目並べ盤面の追加(修正全容)

  • (修正全容)ゲーム画面(Board) ウィジェットのコード内容
dart
class _BoardState extends State<Board> {
  TicTacToe ticTacToe = TicTacToe.start(playerX: 'Dash', playerO: 'Sparky');

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          //【新規追加】(ここから)
          // 三目並べ盤面
          GridView.builder(
            shrinkWrap: true,
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3,
            ),
            itemCount: 9,
            itemBuilder: (context, index) {
              final row = index ~/ 3;
              final col = index % 3;
              final mark = ticTacToe.board[row][col];

              return GestureDetector(
                onTap: () {
                  setState(() {
                    final winner = ticTacToe.getWinner();
                    if (mark.isEmpty && winner.isEmpty) {
                      ticTacToe = ticTacToe.placeMark(row, col);
                    }
                  });
                },
                child: Container(
                  decoration: BoxDecoration(
                    border: Border.all(color: Colors.grey),
                  ),
                  child: Center(
                    child: Text(
                      mark,
                      style: const TextStyle(fontSize: 32),
                    ),
                  ),
                ),
              );
            },
          ),
          //
          //【新規追加】(ここまで)
        ],
      ),
    );
  }
}
class _BoardState extends State<Board> {
  TicTacToe ticTacToe = TicTacToe.start(playerX: 'Dash', playerO: 'Sparky');

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          //【新規追加】(ここから)
          // 三目並べ盤面
          GridView.builder(
            shrinkWrap: true,
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3,
            ),
            itemCount: 9,
            itemBuilder: (context, index) {
              final row = index ~/ 3;
              final col = index % 3;
              final mark = ticTacToe.board[row][col];

              return GestureDetector(
                onTap: () {
                  setState(() {
                    final winner = ticTacToe.getWinner();
                    if (mark.isEmpty && winner.isEmpty) {
                      ticTacToe = ticTacToe.placeMark(row, col);
                    }
                  });
                },
                child: Container(
                  decoration: BoxDecoration(
                    border: Border.all(color: Colors.grey),
                  ),
                  child: Center(
                    child: Text(
                      mark,
                      style: const TextStyle(fontSize: 32),
                    ),
                  ),
                ),
              );
            },
          ),
          //
          //【新規追加】(ここまで)
        ],
      ),
    );
  }
}

    三目並べ盤面の追加(修正全容)
    三目並べ盤面の追加(修正全容)


3.3.3: メッセージ表示欄の追加

「ゲーム進行状態」から 今回の指手や、ゲーム勝敗終了 を表示する、メッセージ欄を新規追加します。

1. ゲーム進行状態からメッセージを作るロジックの追加

ゲームの進行は、「対局の勝敗が決定する」まで繰り返されます。
これは 「現在未決着なので、次の指し手を依頼」 の繰り返しから、「既にいずれかが勝利したのでゲーム終了」 まで 状態遷移を続けることでもあります。

つまりゲーム進行状態 ⇒ ゲーム進行状態のモデル(TicTacTow クラス) オブジェクト の状況から、 現在未決着と判定されれば 次の指し手を依頼し、既にいずれかが勝利と判定されれば いずれかの勝利でゲーム終了のメッセージが作られれば良いことになります。
このロジックを関数化すると、以下のようになります。

dart
/// 今回の指し手の依頼や、勝利か引き分けかのメッセージを作成
String _statusMessage(TicTacToe ticTacToe) {
  //三目並べ盤面状況から、勝利者を判定
  final winner = ticTacToe.getWinner();

  //三目並べ盤面状況から、引き分けを判定
  final isDraw = ticTacToe.isDraw();

  if (winner.isNotEmpty) {
    return '$winnerの勝ち';
  } else if (isDraw) {
    return '引き分けです';
  } else {
    //勝利も引き分けでもないので、次の指し手を依頼
    return '${ticTacToe.currentPlayer}の番';
  }
}
/// 今回の指し手の依頼や、勝利か引き分けかのメッセージを作成
String _statusMessage(TicTacToe ticTacToe) {
  //三目並べ盤面状況から、勝利者を判定
  final winner = ticTacToe.getWinner();

  //三目並べ盤面状況から、引き分けを判定
  final isDraw = ticTacToe.isDraw();

  if (winner.isNotEmpty) {
    return '$winnerの勝ち';
  } else if (isDraw) {
    return '引き分けです';
  } else {
    //勝利も引き分けでもないので、次の指し手を依頼
    return '${ticTacToe.currentPlayer}の番';
  }
}

それでは、ゲーム画面のステートを表す _BoardStateの末尾に、この _statusMessage()関数を追加しましょう。
具体的なコードは、(修正後)ゲーム画面のコードを参照ください。

  • (修正後)ゲーム画面(Board) ウィジェットのコード内容
dart
class _BoardState extends State<Board> {
  TicTacToe ticTacToe = TicTacToe.start(playerX: 'Dash', playerO: 'Sparky');

  〜 省略 〜

  //【新規追加】(ここから)
  /// 今回の指し手の依頼や、勝利か引き分けかのメッセージを作成
  String _statusMessage(TicTacToe ticTacToe) {
    final winner = ticTacToe.getWinner();
    final isDraw = ticTacToe.isDraw();

    if (winner.isNotEmpty) {
      return '$winnerの勝ち';
    } else if (isDraw) {
      return '引き分けです';
    } else {
      return '${ticTacToe.currentPlayer}の番';
    }
  }
  //【新規追加】(ここまで)
}
class _BoardState extends State<Board> {
  TicTacToe ticTacToe = TicTacToe.start(playerX: 'Dash', playerO: 'Sparky');

  〜 省略 〜

  //【新規追加】(ここから)
  /// 今回の指し手の依頼や、勝利か引き分けかのメッセージを作成
  String _statusMessage(TicTacToe ticTacToe) {
    final winner = ticTacToe.getWinner();
    final isDraw = ticTacToe.isDraw();

    if (winner.isNotEmpty) {
      return '$winnerの勝ち';
    } else if (isDraw) {
      return '引き分けです';
    } else {
      return '${ticTacToe.currentPlayer}の番';
    }
  }
  //【新規追加】(ここまで)
}

2. メッセージを表示する Textウィジェットの追加

ゲーム進行状態から、「今回の指し手の依頼や、勝利か引き分けかのメッセージ」を作る _statusMessage()関数が追加されたので、 ゲーム画面のヘッダーにメッセージを表示する Textウィジェットを追加します。

さらにメッセージが画面端に付かないようにする ⇒ 四方枠に空隙をとる ⇒ ため、 TextウィジェットPadding ウィジェット でラップしましょう。
具体的なコードは、(修正後)ゲーム画面のコードを参照ください。


  • (修正後)ゲーム画面(Board) ウィジェットのコード内容
dart
        〜 省略 〜
        children: [
          //【新規追加】(ここから)
          // メッセージ表示欄
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              _statusMessage(ticTacToe),
              style: Theme.of(context).textTheme.headlineSmall,
            ),
          ),
          //
          //【新規追加】(ここまで)
          〜 省略 〜
        〜 省略 〜
        children: [
          //【新規追加】(ここから)
          // メッセージ表示欄
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              _statusMessage(ticTacToe),
              style: Theme.of(context).textTheme.headlineSmall,
            ),
          ),
          //
          //【新規追加】(ここまで)
          〜 省略 〜


3. メッセージ表示欄の追加(修正全容)

  • (修正全容)ゲーム画面(Board) ウィジェットのコード内容
dart
class _BoardState extends State<Board> {
  TicTacToe ticTacToe = TicTacToe.start(playerX: 'Dash', playerO: 'Sparky');

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          //【新規追加】(ここから)
          // メッセージ表示欄
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              _statusMessage(ticTacToe),
              style: Theme.of(context).textTheme.headlineSmall,
            ),
          ),
          //【新規追加】(ここまで)
          //
          // 三目並べ盤面
          GridView.builder(
            shrinkWrap: true,
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3,
            ),
            itemCount: 9,
            itemBuilder: (context, index) {
              final row = index ~/ 3;
              final col = index % 3;
              final mark = ticTacToe.board[row][col];

              return GestureDetector(
                onTap: () {
                  setState(() {
                    final winner = ticTacToe.getWinner();
                    if (mark.isEmpty && winner.isEmpty) {
                      ticTacToe = ticTacToe.placeMark(row, col);
                    }
                  });
                },
                child: Container(
                  decoration: BoxDecoration(
                    border: Border.all(color: Colors.grey),
                  ),
                  child: Center(
                    child: Text(
                      mark,
                      style: const TextStyle(fontSize: 32),
                    ),
                  ),
                ),
              );
            },
          ),
          //
        ],
      ),
    );
  }

  //【新規追加】(ここから)
  // 今回の指し手の依頼や、勝利か引き分けかのメッセージを作成
  String _statusMessage(TicTacToe ticTacToe) {
    final winner = ticTacToe.getWinner();
    final isDraw = ticTacToe.isDraw();

    if (winner.isNotEmpty) {
      return '$winnerの勝ち';
    } else if (isDraw) {
      return '引き分けです';
    } else {
      return '${ticTacToe.currentPlayer}の番';
    }
  }
  //【新規追加】(ここまで)
}
class _BoardState extends State<Board> {
  TicTacToe ticTacToe = TicTacToe.start(playerX: 'Dash', playerO: 'Sparky');

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          //【新規追加】(ここから)
          // メッセージ表示欄
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              _statusMessage(ticTacToe),
              style: Theme.of(context).textTheme.headlineSmall,
            ),
          ),
          //【新規追加】(ここまで)
          //
          // 三目並べ盤面
          GridView.builder(
            shrinkWrap: true,
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3,
            ),
            itemCount: 9,
            itemBuilder: (context, index) {
              final row = index ~/ 3;
              final col = index % 3;
              final mark = ticTacToe.board[row][col];

              return GestureDetector(
                onTap: () {
                  setState(() {
                    final winner = ticTacToe.getWinner();
                    if (mark.isEmpty && winner.isEmpty) {
                      ticTacToe = ticTacToe.placeMark(row, col);
                    }
                  });
                },
                child: Container(
                  decoration: BoxDecoration(
                    border: Border.all(color: Colors.grey),
                  ),
                  child: Center(
                    child: Text(
                      mark,
                      style: const TextStyle(fontSize: 32),
                    ),
                  ),
                ),
              );
            },
          ),
          //
        ],
      ),
    );
  }

  //【新規追加】(ここから)
  // 今回の指し手の依頼や、勝利か引き分けかのメッセージを作成
  String _statusMessage(TicTacToe ticTacToe) {
    final winner = ticTacToe.getWinner();
    final isDraw = ticTacToe.isDraw();

    if (winner.isNotEmpty) {
      return '$winnerの勝ち';
    } else if (isDraw) {
      return '引き分けです';
    } else {
      return '${ticTacToe.currentPlayer}の番';
    }
  }
  //【新規追加】(ここまで)
}

    メッセージ表示欄の追加(修正全容)
    メッセージ表示欄の追加(修正全容)


3.3.4: ゲーム・リセットボタンの追加

新しいゲーム(ゲームプレイの初期状態)へ 状態遷移 させるリセット・ボタンを追加します。

1. ゲームリセット・ロジックの確認

リセットボタンは、新しいゲーム(ゲーム進行初期状態)に ゲーム進行状態状態遷移 させることを示します。 ゲーム進行状態のモデル(TicTacTow クラス) には、 ゲーム進行初期状態を作る専用メソッド resetBoard()が提供されていたことを思い出してください。

よってリセットボタンのタップにより resetBoard()メソッドが、 StatefulWidget の状態を更新する StatesetState() で実行されるようにすれば良いことになります。
このロジックは、以下のようなシンプルなものになります。

dart
setState(() {
  ticTacToe = ticTacToe.resetBoard();
});
setState(() {
  ticTacToe = ticTacToe.resetBoard();
});

2. リセットボタンの追加

ゲーム画面のフッターにゲーム・リセットボタンとして表示するため ElevatedButton を追加して、 イベント・ハンドラに前ステップのゲームリセット・ロジックを指定します。

またここでは、リセットボタン三目並べ盤面とのサイズや配置位置の調整として SizedBox ウィジェット を利用しています。

SizedBox は、横幅無制限指定(width: double.infinity)してリセットボタン幅を調整するためにラップしたり、三目並べ盤面とのあいだの「空隙」に使われています。
具体的なコードは、(修正後)ゲーム画面のコードを参照ください。


  • (修正後)ゲーム画面(Board) ウィジェットのコード内容
dart
        〜 省略 〜
        child: Column(
          children: [
          〜 省略 〜
          //
          //【新規追加】(ここから)
          // 盤面との空隙
          const SizedBox(height: 16),
          //
          // ゲーム・リセットボタン
          SizedBox(
            width: double.infinity,
            child: ElevatedButton(
              onPressed: () {
                setState(() {
                  ticTacToe = ticTacToe.resetBoard();
                });
              },
              child: const Text('ゲームをリセット'),
            ),
          ),
          //
          //【新規追加】(ここまで)
          ],
        ),
        〜 省略 〜
        〜 省略 〜
        child: Column(
          children: [
          〜 省略 〜
          //
          //【新規追加】(ここから)
          // 盤面との空隙
          const SizedBox(height: 16),
          //
          // ゲーム・リセットボタン
          SizedBox(
            width: double.infinity,
            child: ElevatedButton(
              onPressed: () {
                setState(() {
                  ticTacToe = ticTacToe.resetBoard();
                });
              },
              child: const Text('ゲームをリセット'),
            ),
          ),
          //
          //【新規追加】(ここまで)
          ],
        ),
        〜 省略 〜

  • ElevatedButton は、マテリアルデザインのエレベーション(背景との高低差概念により浮き上がっているように見せる)が表現されたボタン。
    タップ時のハンドラは、onPressed プロパティ で指定できます。

  • SizedBox は、自分または子ウィジェットに特定の幅および高さを強制します。
    ただし SizedBoxの親となるウィジェットで強制指定があれば、親制約が優先されます。


3. ゲーム・リセットボタンの追加(修正全容)

  • (修正全容)ゲーム画面(Board) ウィジェットのコード内容
dart
import 'package:flutter/material.dart';

class Board extends StatefulWidget {
  const Board({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return _BoardState();
  }
}

class _BoardState extends State<Board> {
   TicTacToe ticTacToe = TicTacToe.start(playerX: 'Dash', playerO: 'Sparky');

   @override
   Widget build(BuildContext context) {
      return Padding(
         padding: const EdgeInsets.all(16),
         child: Column(
            children: [
               // メッセージ表示欄
               Padding(
                  padding: const EdgeInsets.all(16),
                  child: Text(
                     _statusMessage(ticTacToe),
                     style: Theme.of(context).textTheme.headlineSmall,
                  ),
               ),
               //
               // 三目並べ盤面
               GridView.builder(
                  shrinkWrap: true,
                  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                     crossAxisCount: 3,
                  ),
                  itemCount: 9,
                  itemBuilder: (context, index) {
                     final row = index ~/ 3;
                     final col = index % 3;
                     final mark = ticTacToe.board[row][col];

                     return GestureDetector(
                        onTap: () {
                           setState(() {
                              final winner = ticTacToe.getWinner();
                              if (mark.isEmpty && winner.isEmpty) {
                                 ticTacToe = ticTacToe.placeMark(row, col);
                              }
                           });
                        },
                        child: Container(
                           decoration: BoxDecoration(
                              border: Border.all(color: Colors.grey),
                           ),
                           child: Center(
                              child: Text(
                                 mark,
                                 style: const TextStyle(fontSize: 32),
                              ),
                           ),
                        ),
                     );
                  },
               ),
               //
               //【新規追加】(ここから)
               // 盤面との空隙
               const SizedBox(height: 16),
               //
               // ゲーム・リセットボタン
               SizedBox(
                  width: double.infinity,
                  child: ElevatedButton(
                     onPressed: () {
                        setState(() {
                           ticTacToe = ticTacToe.resetBoard();
                        });
                     },
                     child: const Text('ゲームをリセット'),
                  ),
               ),
               //【新規追加】(ここまで)
               //
            ],
         ),
      );
   }

   // 今回の指し手の依頼や、勝利か引き分けかのメッセージを作成
   String _statusMessage(TicTacToe ticTacToe) {
      final winner = ticTacToe.getWinner();
      final isDraw = ticTacToe.isDraw();

      if (winner.isNotEmpty) {
         return '$winnerの勝ち';
      } else if (isDraw) {
         return '引き分けです';
      } else {
         return '${ticTacToe.currentPlayer}の番';
      }
   }
}
import 'package:flutter/material.dart';

class Board extends StatefulWidget {
  const Board({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return _BoardState();
  }
}

class _BoardState extends State<Board> {
   TicTacToe ticTacToe = TicTacToe.start(playerX: 'Dash', playerO: 'Sparky');

   @override
   Widget build(BuildContext context) {
      return Padding(
         padding: const EdgeInsets.all(16),
         child: Column(
            children: [
               // メッセージ表示欄
               Padding(
                  padding: const EdgeInsets.all(16),
                  child: Text(
                     _statusMessage(ticTacToe),
                     style: Theme.of(context).textTheme.headlineSmall,
                  ),
               ),
               //
               // 三目並べ盤面
               GridView.builder(
                  shrinkWrap: true,
                  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                     crossAxisCount: 3,
                  ),
                  itemCount: 9,
                  itemBuilder: (context, index) {
                     final row = index ~/ 3;
                     final col = index % 3;
                     final mark = ticTacToe.board[row][col];

                     return GestureDetector(
                        onTap: () {
                           setState(() {
                              final winner = ticTacToe.getWinner();
                              if (mark.isEmpty && winner.isEmpty) {
                                 ticTacToe = ticTacToe.placeMark(row, col);
                              }
                           });
                        },
                        child: Container(
                           decoration: BoxDecoration(
                              border: Border.all(color: Colors.grey),
                           ),
                           child: Center(
                              child: Text(
                                 mark,
                                 style: const TextStyle(fontSize: 32),
                              ),
                           ),
                        ),
                     );
                  },
               ),
               //
               //【新規追加】(ここから)
               // 盤面との空隙
               const SizedBox(height: 16),
               //
               // ゲーム・リセットボタン
               SizedBox(
                  width: double.infinity,
                  child: ElevatedButton(
                     onPressed: () {
                        setState(() {
                           ticTacToe = ticTacToe.resetBoard();
                        });
                     },
                     child: const Text('ゲームをリセット'),
                  ),
               ),
               //【新規追加】(ここまで)
               //
            ],
         ),
      );
   }

   // 今回の指し手の依頼や、勝利か引き分けかのメッセージを作成
   String _statusMessage(TicTacToe ticTacToe) {
      final winner = ticTacToe.getWinner();
      final isDraw = ticTacToe.isDraw();

      if (winner.isNotEmpty) {
         return '$winnerの勝ち';
      } else if (isDraw) {
         return '引き分けです';
      } else {
         return '${ticTacToe.currentPlayer}の番';
      }
   }
}

    ゲーム・リセットボタンの追加(修正全容)
    ゲーム・リセットボタンの追加(修正全容)


3.3.5: 三目並べのゲーム画面完成(ゲーム盤UI 作成作業完了)

お疲れさまです、これで三目並べゲームがプレイできるゲーム画面が完成しました。

3章 UI の作成の全コードへのリンクは、以下のとおりです。

    完成した三目並べのゲーム画面
    完成した三目並べのゲーム画面


3.4: UI 作成資料室

3.4.1: Widgetについての基本資料


コントリビューター

robo

robo

既存 iOS/Android ネイティブアプリの Flutter リプレースに携わっています。Flutter の底力を見てくださいね。