♟️

Flutter で将棋ゲームを作ろう #3 駒を動かそう(前編)

2023/06/29に公開

はじめに

こんにちは!水瀬ひろと申します。
趣味で Flutter を楽しんでいるエンジニアです。

僕はソシャゲ(特にHoYoverse作品)が好きで、自分でもゲームを作ってみたいと思い、まずは将棋ゲームに挑戦してみました。
Flame などのライブラリは使用せず、自前実装のみでおこなったので学びが多かったです。

将棋ゲームの制作を通じて、以下のようなことを学ぶことができたので、シリーズ化して残しておこうと思います。

  • 将棋ゲームの作り方やルールの理解
  • 応用としてボードゲーム系アプリの作成手法の習得
  • ゲームの基本的な動作原理についての理解

このシリーズでは、実際に行った実装内容を細かく解説していきます。
最終的には以下のような将棋ゲームが完成する予定です!
将棋ゲーム

駒を選択できるようにしよう

前回の記事では将棋盤に駒を配置しました。
https://zenn.dev/flutteruniv_dev/articles/84d43ef198065d

今回はその駒を動かせるようにしていきます。
まずはどの駒を動かすか、選べるようにしましょう!

main.dart
class _MyHomePageState extends State<MyHomePage> {
  late List<List<dynamic>> shogiBoard; // 盤面管理用の配列
  
  ShogiPiece? selectedPiece; // 選択されている駒
  int selectedRow = -1; // 選択されている駒の行番号
  int selectedCol = -1; // 選択されている駒の列番号

新しい変数を4つ定義しました。

selectedRow, selectedCol の初期値 -1 は
盤面の外、つまり何も選択されていない状態であることを表しています。

次に、ピースを選択するメソッドを作成しましょう。

main.dart
// ピースを選択する
void selectPiece(int row, int col) {
  setState(() {
    selectedPiece = shogiBoard[row][col];
    selectedRow = row;
    selectedCol = col;
  });
}

座標を受け取って、選択状態を管理する変数に値をセットします。

駒を選択中であればそれが分かるようにしたいので、 square.dart を調整します。

square.dart
import 'package:flutter/material.dart';
import 'package:for_zenn/shogi_piece.dart';

class Square extends StatelessWidget {
  final ShogiPiece? shogiPiece;
  final bool isSelected; // 選択されているかどうか
  final void Function()? onTap; // タップされた時に呼ばれるメソッド

  const Square({
    super.key,
    required this.shogiPiece,
    required this.isSelected,
    required this.onTap,
  });

  
  Widget build(BuildContext context) {
    Color? squareColor;

    // 座標の状態によって背景色を変化
    if (isSelected) {
      squareColor = Colors.green; // 駒を選択中
    } else {
      squareColor = Colors.orange[100]; // 盤面の色
    }

    return GestureDetector(
      onTap: onTap,
      child: Container(
        decoration: BoxDecoration(
          color: squareColor,
          border: Border.all(color: Colors.black, width: 1.0),
        ),
        // 駒を引数に受け取っていれば駒の画像を表示
        child: shogiPiece != null ? Padding(
          padding: const EdgeInsets.all(5.0),
          child: Image.asset(shogiPiece!.imagePath),
        ) : null,
      ),
    );
  }
}
  • isSelected という bool を受け取って、 Container の color 設定に使用
  • onTap という Function を受け取って、 GestureDetector を介してタップ時に呼ばれるように

square.dart を呼んでいる箇所も変更しましょう。

main.dart
itemBuilder: (context, index) {
  int row = index ~/ 9;
  int col = index % 9;
  bool isSelected = row == selectedRow && col == selectedCol; // 座標が選択されているかどうか

  return Square(
    piece: board[row][col],
    isSelected: isSelected,
    onTap: () => selectPiece(row, col),
  );
},
bool isSelected = row == selectedRow && col == selectedCol; // 座標が選択されているかどうか

選択されているかどうかは座標で判定。

onTap: () => selectPiece(row, col),

メソッドも引数付きで渡します。

iOS シミュレータ
これで、タップすると背景が緑に変化するようになりました。

駒を動かせる範囲を可視化しよう

将棋の駒は種類によって動かせる範囲が変わります。
それぞれ判定するため helper_methods.dart を作成し、以下2つのメソッドを用意します。
この helper_methods には値の更新をしないメソッドを入れていく予定です。

helper_methods.dart
// 対象の座標が盤面にあるか
bool isInBoard(int row, int col) {
  return row >= 0 && row < 9 && col >= 0 && col < 9;
}

こちらはシンプルです。駒が盤外に出ないための判定。

helper_methods.dart
// 駒が移動可能な座標を配列で返す
List<List<int>> calculateRawValidMoves(List<List<dynamic>> shogiBoard, int row, int col, ShogiPiece? piece) {
  if (piece == null) {
    return [];
  }

  List<List<int>> candidateMoves = [];
  int direction = piece.isAlly ? -1 : 1;

  switch (piece.type) {
    case ShogiPieceType.hohei: // 歩兵
      var newRow = row + direction;

      // 盤面から出ていない
      if (isInBoard(newRow, col)) {
        // 空の座標か、敵の座標だった場合
        if (shogiBoard[newRow][col] == null || shogiBoard[newRow][col]!.isAlly != piece.isAlly) {
          candidateMoves.add([newRow, col]);
        }
      }

      break;
    case ShogiPieceType.hisya: // 飛車
      var directions = [
        [-1, 0], // 上
        [1, 0], // 下
        [0, -1], // 左
        [0, 1], // 右
      ];

      for (var direction in directions) {
        var i = 1;

        while (true) {
          var newRow = row + (direction[0] * i);
          var newCol = col + (direction[1] * i);

          // 盤面から出た場合
          if (!isInBoard(newRow, newCol)) {
            break;
          }

          // 対象の座標に駒がある
          if (shogiBoard[newRow][newCol] != null) {
            // 対象の駒が敵
            if (shogiBoard[newRow][newCol]!.isAlly != piece.isAlly) {
              candidateMoves.add([newRow, newCol]);
            }
            break;
          }

          candidateMoves.add([newRow, newCol]);
          i++;
        }
      }

      break;
    case ShogiPieceType.promotedHisya: // 龍王
    // 上下左右にはいくらでも移動できる
      var runningDirections = [
        [-1, 0], // 上
        [1, 0], // 下
        [0, -1], // 左
        [0, 1], // 右
      ];

      // 斜めにはひとつ移動できる
      var singleDirections = [
        [-1, -1], // 左上
        [-1, 1], // 右上
        [1, -1], // 左下
        [1, 1], // 右下
      ];

      // 上下左右移動判定
      for (var direction in runningDirections) {
        var i = 1;

        while (true) {
          var newRow = row + (direction[0] * i);
          var newCol = col + (direction[1] * i);

          // 盤面から出た場合
          if (!isInBoard(newRow, newCol)) {
            break;
          }

          // 対象の座標に駒がある
          if (shogiBoard[newRow][newCol] != null) {
            // 対象の駒が敵
            if (shogiBoard[newRow][newCol]!.isAlly != piece.isAlly) {
              candidateMoves.add([newRow, newCol]);
            }
            break;
          }

          candidateMoves.add([newRow, newCol]);
          i++;
        }
      }

      // 斜め移動判定
      for (var direction in singleDirections) {
        var newRow = row + (direction[0]);
        var newCol = col + (direction[1]);

        // 盤面から出た場合
        if (!isInBoard(newRow, newCol)) {
          continue;
        }

        // 対象の座標に駒がある
        if (shogiBoard[newRow][newCol] != null) {
          // 対象の駒が敵
          if (shogiBoard[newRow][newCol]!.isAlly != piece.isAlly) {
            candidateMoves.add([newRow, newCol]);
          }
          continue;
        }

        candidateMoves.add([newRow, newCol]);
      }

      break;
    case ShogiPieceType.kakugyo: // 角行
      var directions = [
        [-1, -1], // 左上
        [-1, 1], // 右上
        [1, -1], // 左下
        [1, 1], // 右下
      ];

      for (var direction in directions) {
        var i = 1;

        while (true) {
          var newRow = row + (direction[0] * i);
          var newCol = col + (direction[1] * i);

          // 盤面から出た場合
          if (!isInBoard(newRow, newCol)) {
            break;
          }

          // 対象の座標に駒がある
          if (shogiBoard[newRow][newCol] != null) {
            // 対象の駒が敵
            if (shogiBoard[newRow][newCol]!.isAlly != piece.isAlly) {
              candidateMoves.add([newRow, newCol]);
            }
            break;
          }

          candidateMoves.add([newRow, newCol]);
          i++;
        }
      }

      break;
    case ShogiPieceType.promotedKakugyo: // 龍馬
    // 斜めにはいくらでも移動できる
      var runningDirections = [
        [-1, -1], // 左上
        [-1, 1], // 右上
        [1, -1], // 左下
        [1, 1], // 右下
      ];

      // 上下左右にはひとつ移動できる
      var singleDirections = [
        [-1, 0], // 上
        [1, 0], // 下
        [0, -1], // 左
        [0, 1], // 右
      ];

      // 斜め移動判定
      for (var direction in runningDirections) {
        var i = 1;

        while (true) {
          var newRow = row + (direction[0] * i);
          var newCol = col + (direction[1] * i);

          // 盤面から出た場合
          if (!isInBoard(newRow, newCol)) {
            break;
          }

          // 対象の座標に駒がある
          if (shogiBoard[newRow][newCol] != null) {
            // 対象の駒が敵
            if (shogiBoard[newRow][newCol]!.isAlly != piece.isAlly) {
              candidateMoves.add([newRow, newCol]);
            }
            break;
          }

          candidateMoves.add([newRow, newCol]);
          i++;
        }
      }

      // 上下左右移動判定
      for (var direction in singleDirections) {
        var newRow = row + (direction[0]);
        var newCol = col + (direction[1]);

        // 盤面から出た場合
        if (!isInBoard(newRow, newCol)) {
          continue;
        }

        // 対象の座標に駒がある
        if (shogiBoard[newRow][newCol] != null) {
          // 対象の駒が敵
          if (shogiBoard[newRow][newCol]!.isAlly != piece.isAlly) {
            candidateMoves.add([newRow, newCol]);
          }
          continue;
        }

        candidateMoves.add([newRow, newCol]);
      }

      break;
    case ShogiPieceType.kyousya: // 香車
      var kyousyaMoves = [
        [direction, 0]
      ];

      for (var move in kyousyaMoves) {
        var i = 1;

        while (true) {
          var newRow = row + (move[0] * i);

          // 盤面から出た場合
          if (!isInBoard(newRow, col)) {
            break;
          }

          // 対象の座標に駒がある
          if (shogiBoard[newRow][col] != null) {
            // 対象の駒が敵
            if (shogiBoard[newRow][col]!.isAlly != piece.isAlly) {
              candidateMoves.add([newRow, col]);
            }
            break;
          }

          candidateMoves.add([newRow, col]);
          i++;
        }
      }

      break;
    case ShogiPieceType.keima: // 桂馬
      var keimaMoves = [
        [direction * 2, -1], // 左斜め
        [direction * 2, 1], // 右斜め
      ];

      for (var move in keimaMoves) {
        var newRow = row + move[0];
        var newCol = col + move[1];

        // 盤面から出た場合
        if (!isInBoard(newRow, newCol)) {
          continue;
        }

        // 対象の座標に駒がある
        if (shogiBoard[newRow][newCol] != null) {
          // 対象の駒が敵
          if (shogiBoard[newRow][newCol]!.isAlly != piece.isAlly) {
            candidateMoves.add([newRow, newCol]);
          }
          continue;
        }

        candidateMoves.add([newRow, newCol]);
      }

      break;
    case ShogiPieceType.ginsho: // 銀将
      var directions = [
        [direction, 0], // 上
        [-1, -1], // 左上
        [-1, 1], // 右上
        [1, -1], // 左下
        [1, 1], // 右下
      ];

      for (var direction in directions) {
        var newRow = row + (direction[0]);
        var newCol = col + (direction[1]);

        // 盤面から出た場合
        if (!isInBoard(newRow, newCol)) {
          continue;
        }

        // 対象の座標に駒がある
        if (shogiBoard[newRow][newCol] != null) {
          // 対象の駒が敵
          if (shogiBoard[newRow][newCol]!.isAlly != piece.isAlly) {
            candidateMoves.add([newRow, newCol]);
          }
          continue;
        }

        candidateMoves.add([newRow, newCol]);
      }

      break;
    case ShogiPieceType.kinsho: // 金将
    case ShogiPieceType.promotedKeima:
    case ShogiPieceType.promotedKyousya:
    case ShogiPieceType.promotedGinsho:
    case ShogiPieceType.promotedHohei:
      var directions = [
        [-1, 0], // 上
        [1, 0], // 下
        [0, -1], // 左
        [0, 1], // 右
        [direction, -1], // 左上
        [direction, 1], // 右上
      ];

      for (var direction in directions) {
        var newRow = row + (direction[0]);
        var newCol = col + (direction[1]);

        // 盤面から出た場合
        if (!isInBoard(newRow, newCol)) {
          continue;
        }

        // 対象の座標に駒がある
        if (shogiBoard[newRow][newCol] != null) {
          // 対象の駒が敵
          if (shogiBoard[newRow][newCol]!.isAlly != piece.isAlly) {
            candidateMoves.add([newRow, newCol]);
          }
          continue;
        }

        candidateMoves.add([newRow, newCol]);
      }

      break;
    case ShogiPieceType.gyokusho: // 玉将
    case ShogiPieceType.ousho: // 王将
      var directions = [
        [-1, 0], // 上
        [1, 0], // 下
        [0, -1], // 左
        [0, 1], // 右
        [-1, -1], // 左上
        [-1, 1], // 右上
        [1, -1], // 左下
        [1, 1], // 右下
      ];

      for (var direction in directions) {
        var newRow = row + (direction[0]);
        var newCol = col + (direction[1]);

        // 盤面から出た場合
        if (!isInBoard(newRow, newCol)) {
          continue;
        }

        // 対象の座標に駒がある
        if (shogiBoard[newRow][newCol] != null) {
          // 対象の駒が敵
          if (shogiBoard[newRow][newCol]!.isAlly != piece.isAlly) {
            candidateMoves.add([newRow, newCol]);
          }
          continue;
        }

        candidateMoves.add([newRow, newCol]);
      }

      break;
    default:
  }

  return candidateMoves;
}

こちらは駒を受け取り、移動できる範囲を返すメソッドです。
(これめちゃくちゃ長い...省エネ案を思いついた方はぜひコメントでお助けください)

このメソッドだけの解説記事を書きました。
https://zenn.dev/flutteruniv_dev/articles/59dc62deadf6d2

ShogiPieceType ごとに case で分岐し、判定。
移動可能な座標が配列となり以下のように返ってきます。
[[7, 6], [7, 5], [7, 4], [7, 3], [7, 2], [7, 8]]

では作成したメソッドを利用していきましょう。

main.dart
late List<List<dynamic>> shogiBoard; // 盤面管理用の配列
ShogiPiece? selectedPiece; // 選択されている駒
int selectedRow = -1; // 選択されている駒の行番号
int selectedCol = -1; // 選択されている駒の列番号
List<List<int>> validMoves = []; // 選択中の駒が移動可能な座標

まずは validMoves を定義。

main.dart
// ピースを選択する
void selectPiece(int row, int col) {
  setState(() {
    selectedPiece = shogiBoard[row][col];
    selectedRow = row;
    selectedCol = col;

    validMoves = calculateValidMoves(shogiBoard, selectedRow, selectedCol, selectedPiece); // 移動可能な座標を計算
  });
}

ピースの選択時に移動可能な座標を計算します。

main.dart
GridView.builder(
  shrinkWrap: true, // GridView のサイズを itemBuilder で表示するコンテンツによって決定
  itemCount: 9 * 9,
  physics: const NeverScrollableScrollPhysics(), // スクロールさせない
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 9), // 9列で表示
  itemBuilder: (context, index) {
    int row = index ~/ 9;
    int col = index % 9;
    bool isSelected = row == selectedRow && col == selectedCol; // 座標が選択されているかどうか
    bool isValidMove = false;

    // 選択中の駒が移動可能な座標かどうか
    for (var position in validMoves) {
      if (position[0] == row && position[1] == col) {
        isValidMove = true;
      }
    }

    return Square(
      shogiPiece: shogiBoard[row][col],
      isSelected: isSelected,
      onTap: () => selectPiece(row, col),
      isValidMove: isValidMove,
    );
  },
),

Square に移動可能かどうかの情報を渡すため、判定を加えます。

square.dart
final void Function()? onTap; // タップされた時に呼ばれるメソッド
final bool isValidMove; // 選択中の駒が移動可能か

const Square({
  super.key,
  required this.shogiPiece,
  required this.isSelected,
  required this.onTap,
  required this.isValidMove,
});

判定の結果を受け取り、

square.dart
Color? squareColor;

// 座標の状態によって背景色を変化
if (isSelected) {
  squareColor = Colors.green; // 駒を選択中
} else if (isValidMove) {
  squareColor = Colors.green[100]; // 選択中の駒が移動可能
} else {
  squareColor = Colors.orange[100]; // 盤面の色
}

Square の背景色に適用。
これで選択したマスの色が変わり、さらに移動可能な範囲の色も変わるはずです。

iOS シミュレータ
歩だとこう。

iOS シミュレータ
飛車だとこうですね。

終わりに

ここまでお疲れさまでした〜!
無事に駒の移動可能範囲を定義し、可視化することができました。

駒の移動までこの記事にまとめる予定でしたが、想定より量が多くなってしまったので前編・後編と分けることにしました。
後編もぜひご覧ください。
今度こそ駒を動かせるようにしていきます!
https://zenn.dev/flutteruniv_dev/articles/3507e5a99d21fa

その後も以下のような記事を準備しているのでお付き合いいただけると嬉しいです。

  • 「成り」の実装
  • 持ち駒を盤面に出す
  • 王手の判定
  • 詰みの判定
  • CPUを実装する
  • 「二歩」の禁止
  • 棋譜を記録する

Twitter

https://twitter.com/minase_hiro_

Threads

https://www.threads.net/@minasehiro

Flutter大学

Discussion