0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutterで箱入り娘を作成する3

0
Last updated at Posted at 2026-11-02

概要

Flutterで箱入り娘を作成し、iOS と Android のアプリとして販売する
このシリーズでは、作り始めから販売までを順番に書いていく

第3回では、次のことをする
昔から伝わる、名前の付いた配置を44個入れ、ソルバーで手数を確かめて、手数の少ない順に並べる
ステージを選ぶ一覧の画面を作る
遊ぶ画面に、ステージの番号と名前を出す

実行環境

Flutter

$ flutter --version
Flutter 3.47.9 • channel stable • https://github.com/flutter/flutter.git
Framework • revision c197ca5199 (4 days ago) • 2026-10-29 10:10:20 -0400
Engine • hash 9e025265bef30b838c0c1b68cad19decf97048f2 (revision 89c39edfdf) (5 days ago) • 2026-10-27 19:51:20.000Z
Tools • Dart 3.13.7 • DevTools 2.60.0

手順

ファイルの構成

lib/
  model/
    named_layouts.dart   … 名前の付いた配置44個
    name_notes.dart      … 名前の読みと意味
  screen/
    stage_list_screen.dart … ステージの一覧
    game_screen.dart       … 遊ぶ画面(前回から)
  widget/
    board_thumbnail.dart … 盤の小さな絵
    board_view.dart      … 盤(前回から)
test/
  model/named_layouts_test.dart
  screen/

以下のコードは主要な部分の抜粋(... は省略)。

名前の付いた配置

箱入り娘は、中国では「華容道」と呼ばれる。
華容道には、配置ごとに名前が付いている。1943年に梁青が名前を付けたとされる。
定番の配置の名前は「横刀立馬」。

名前の付いた配置のうち、解けて、ほかと同じ盤面にならない44個をステージにする。

  • 名前は日本の漢字に直す(峰迴路轉 → 峰回路転 など)
  • 同じ名前の配置が2つあるものは、番号を付ける(挿翅難飛1、挿翅難飛2)
  • 手数は、前回のソルバーで出した数を使う

配置をクラスにする

名前・手数・始めの盤面をまとめて、NamedLayout にする。

lib/model/named_layouts.dart
/// 名前の付いた配置。
class NamedLayout {
  const NamedLayout(this.name, this.moves, this.board, {this.alias});

  /// 日本の漢字で書いた名前。
  final String name;

  /// 日本の漢字で書いた別名(1987 年の本《独立钻石和华容道》での名前)。付けていなければ null。
  final String? alias;

  /// 最短手数(同じ駒なら曲がっても1手)。テストでソルバーと照らし合わせる。
  final int moves;

  /// 始めの盤面。
  final Board board;
}

手数はアプリの中で毎回解かずに、数として持つ。
一覧を開くたびに44個を解くと、138手の配置などで時間がかかるため。
数が正しいかは、テストでソルバーと照らし合わせる(後述)。

配置をコードに書く

44個は、手数の少ない順にリストに書く。
駒の座標だけだと形が分からないので、盤の絵をコメントに添える。

lib/model/named_layouts.dart
/// 昔から伝わる、名前の付いた配置(中国の「華容道」)44 個。手数の少ない順。
///
/// 盤の絵は D = 娘、V = 縦長、H = 横長、S = 小さい駒、. = 空き。
final namedLayouts = [
  // S S D D
  // H H D D
  // H H H H
  // H H H H
  // . . S S
  NamedLayout(
    '前呼後擁',
    22,
    Board(const [
      Piece(PieceType.small, 0, 0),
      Piece(PieceType.small, 1, 0),
      Piece(PieceType.daughter, 2, 0),
      Piece(PieceType.horizontal, 0, 1),
      ...
    ]),
  ),
  ...
];
  • 駒のリストは const にする。同じ値のリストを何度も作らずに済む
  • Board は const にできない(前回、hashCode を late final にしたため)。final のリストにする

データの正しさをテストで守る

手で入力したデータは、打ち間違いが起きる。
44個を1つずつ目で確かめるのではなく、決まりごとをテストにする。

test/model/named_layouts_test.dart
  test('手数の少ない順に並んでいる', () {
    for (var i = 1; i < namedLayouts.length; i++) {
      expect(
        namedLayouts[i].moves,
        greaterThanOrEqualTo(namedLayouts[i - 1].moves),
        reason: namedLayouts[i].name,
      );
    }
    ...
  });

  test('左右を反転しても、同じ盤面が2つない', () {
    final seen = <Board>{};
    for (final layout in namedLayouts) {
      expect(seen, isNot(contains(layout.board)), reason: layout.name);
      seen
        ..add(layout.board)
        ..add(mirror(layout.board));
    }
  });

  group('最短手数がソルバーと同じ', () {
    for (final layout in namedLayouts) {
      test(layout.name, () {
        expect(layout.board.isSolved, isFalse);
        expect(solve(layout.board).path!.length - 1, layout.moves);
      });
    }
  });
  • reason に名前を入れる。落ちたときに、どの配置か分かる
  • group の中で配置ごとに test を作る。1つ落ちても、ほかの配置の結果も見られる
  • 左右を反転した盤面は、遊ぶ人から見れば同じ配置なので、重なりとして数える
  • 前回、Board の == を「同じ種類の駒が同じマスにあれば同じ」にしたので、Set で重なりを調べられる

手数は22手から138手。定番の横刀立馬(81手)は31番目になった。
44個のうち、いくつかを挙げる。

名前 手数 意味
1. 前呼後擁
ぜんここうよう
22 お供をたくさん連れた、にぎやかな行列のようす
14. 四面楚歌
しめんそか
56 まわりが敵ばかりで、味方がいないこと
31. 横刀立馬
おうとうりつば
81 刀を横に構え、馬を止めて立つこと(定番の配置)
43. 層層設防2
そうそうせつぼう
120 何重にも守りを固めること
44. 峰回路転
ほうかいろてん
138 行き詰まった物事に、思いがけず道が開けること

定番の81手は、どれくらい難しいのか。
定番の駒のセット(縦長4・横長1・小さい駒4)だけで作れる盤面を、ゴールから逆向きにたどって全部数えた。

  • 作れる盤面は65,880個
  • そのうち、横刀立馬から動かしてたどり着ける盤面は25,955個
  • その中で、ゴールからいちばん遠い盤面は93手

定番の81手は、いちばん難しい盤面ではない。
ただ、横刀立馬はどう1手動かしても、ゴールからそれより遠くならない盤面になっている。

ステージの一覧

一覧の行

1行に、盤の小さな絵・番号と名前・最短手数・i ボタンを並べる。
ListTile を使うと、左の絵・名前・下の行・右のボタンの場所が決まっていて、押したときの反応も付く。

lib/screen/stage_list_screen.dart
          // 挿翅難飛1 と 2 を聞き分けられるよう、末尾の番号は読みにも付ける
          final number = RegExp(r'\d+$').stringMatch(layout.name) ?? '';
          return ListTile(
            leading: BoardThumbnail(board: layout.board),
            title: Text(
              '${index + 1}. ${layout.name}',
              // 読み上げは漢字を読みまちがえうるので、かなで読ませる
              semanticsLabel:
                  '${index + 1}. ${noteOf(layout.name).reading}$number',
            ),
            subtitle: Text(
              '最短 ${layout.moves} 手',
              style: TextStyle(color: colors.onSurfaceVariant),
            ),
            trailing: IconButton(
              tooltip: '名前の読みと意味',
              icon: const Icon(Icons.info_outline),
              onPressed: () => _showNotes(context, index),
            ),
            onTap: () => Navigator.of(context).push(
              MaterialPageRoute<void>(
                builder: (context) => GameScreen(layout: layout),
              ),
            ),
            onLongPress: () => _showNotes(context, index),
          );
  • semanticsLabel は、画面の読み上げ機能(iPhone の VoiceOver など)で読む文字。画面に出す文字は変えずに、読み方だけをかなにできる
  • 読みは行に出さず、i ボタンの中に入れる。行には遊ぶのに要るもの(名前と手数)だけを残す
  • 長押しでも同じ説明を出す。ただし、長押しだけだと気づきにくく、iPhone の読み上げ機能からは使えないので、i ボタンも置く

盤の小さな絵

絵は触らないので、駒ごとに Widget を作らず、CustomPainter で1枚の絵として描く。
色は遊ぶ画面の盤とそろえる。

lib/widget/board_thumbnail.dart
class _ThumbnailPainter extends CustomPainter {
  _ThumbnailPainter(this.board);

  final Board board;

  @override
  void paint(Canvas canvas, Size size) {
    // 枠の太さをマスの 1/4 にして、縦横どちらにも収まるマスの大きさにする
    final cell = math.min(
      size.width / (Board.width + 0.5),
      size.height / (Board.height + 0.5),
    );
    ...
    for (final piece in board.pieces) {
      ...
      canvas.drawRRect(
        RRect.fromRectAndRadius(rect, Radius.circular(cell * 0.12)),
        Paint()
          ..color = piece.type == PieceType.daughter
              ? Colors.red.shade400
              : Colors.orange.shade200,
      );
    }
  }

  @override
  bool shouldRepaint(_ThumbnailPainter oldDelegate) =>
      !identical(oldDelegate.board, board);
}

shouldRepaint は、描き直すかどうかを決める。
盤面は変わらないので、別の盤面になったときだけ描き直す。

i ボタンで、名前の読みと意味を出す

名前の読みと意味は、i ボタンを押すか行を長押しすると、下から出す。
遊ぶのには要らないので、遊ぶ画面には出さない。

lib/screen/stage_list_screen.dart
    showModalBottomSheet<void>(
      context: context,
      showDragHandle: true,
      // つまみが上の時計などの下に入らないようにする
      useSafeArea: true,
      // 文字が大きいときは、画面の半分より高くなってもいいようにする
      isScrollControlled: true,
      builder: (context) {
        ...
      },
    );
  • showDragHandle で、上の端につまみを出す。下へなぞると閉じられることが見て分かる(標準は出さない)
  • isScrollControlled が false だと、高さは画面の半分までになる。文字を大きくしたときに入りきるよう true にする

遊ぶ画面

バーに番号と名前

バーに「1. 前呼後擁」のように番号と名前を出す。
何番目のステージを遊んでいるかが分かる。

lib/screen/game_screen.dart
      appBar: AppBar(
        // 文字が大きくて入りきらないときは、切らずに小さくして全部見せる
        title: FittedBox(fit: BoxFit.scaleDown, child: Text(_title)),
        ...

FittedBox に BoxFit.scaleDown を付けると、入るときはそのまま、入らないときだけ小さくする。

文字の大きさは2倍まで

文字を大きくすると、盤の上の案内と手数が高くなり、その分だけ盤が小さくなる。
遊ぶ画面では、文字の大きさを2倍で止める。

lib/screen/game_screen.dart
    // 文字は 2 倍で止める。Apple の「大きな文字」の目安(200%)は満たし、それより大きくして盤を削らない
    return MediaQuery.withClampedTextScaling(
      maxScaleFactor: 2,
      child: _scaffold(textTheme, colors),
    );

テスト

test/screen/game_screen_test.dart
  testWidgets('文字は 2 倍で止まり、320x568・時計の帯ありで 3 倍以上にしても、はみ出さず盤も 2 倍のときと同じ', (
    tester,
  ) async {
    ...
    for (final scale in const [2.5, 3.1, 3.5]) {
      await pumpGame(tester, first, size: const Size(320, 568), scale: scale);
      expect(tester.takeException(), isNull, reason: '$scale');
      expect(board(), atTwo, reason: '$scale');
      ...
    }
  });

tester.platformDispatcher.textScaleFactorTestValue で、端末の文字の大きさの設定をテストの中で変えられる。

flutter test

アプリの実行

アプリを起動

flutter run

実行結果

klotski3.gif

次回

どこまで遊んだかを覚えておき、途中から続けられるようにする。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?