概要
Flutterで箱入り娘を作成し、iOS と Android のアプリとして販売する
このシリーズでは、作り始めから販売までを順番に書いていく
- 第2回:Flutterで箱入り娘を作成する2
- 第4回:Flutterで箱入り娘を作成する4
第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 にする。
/// 名前の付いた配置。
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個は、手数の少ない順にリストに書く。
駒の座標だけだと形が分からないので、盤の絵をコメントに添える。
/// 昔から伝わる、名前の付いた配置(中国の「華容道」)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('手数の少ない順に並んでいる', () {
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 を使うと、左の絵・名前・下の行・右のボタンの場所が決まっていて、押したときの反応も付く。
// 挿翅難飛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枚の絵として描く。
色は遊ぶ画面の盤とそろえる。
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 ボタンを押すか行を長押しすると、下から出す。
遊ぶのには要らないので、遊ぶ画面には出さない。
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
// つまみが上の時計などの下に入らないようにする
useSafeArea: true,
// 文字が大きいときは、画面の半分より高くなってもいいようにする
isScrollControlled: true,
builder: (context) {
...
},
);
-
showDragHandleで、上の端につまみを出す。下へなぞると閉じられることが見て分かる(標準は出さない) -
isScrollControlledが false だと、高さは画面の半分までになる。文字を大きくしたときに入りきるよう true にする
遊ぶ画面
バーに番号と名前
バーに「1. 前呼後擁」のように番号と名前を出す。
何番目のステージを遊んでいるかが分かる。
appBar: AppBar(
// 文字が大きくて入りきらないときは、切らずに小さくして全部見せる
title: FittedBox(fit: BoxFit.scaleDown, child: Text(_title)),
...
FittedBox に BoxFit.scaleDown を付けると、入るときはそのまま、入らないときだけ小さくする。
文字の大きさは2倍まで
文字を大きくすると、盤の上の案内と手数が高くなり、その分だけ盤が小さくなる。
遊ぶ画面では、文字の大きさを2倍で止める。
// 文字は 2 倍で止める。Apple の「大きな文字」の目安(200%)は満たし、それより大きくして盤を削らない
return MediaQuery.withClampedTextScaling(
maxScaleFactor: 2,
child: _scaffold(textTheme, colors),
);
-
MediaQuery.withClampedTextScalingは、中の Widget の文字の倍率に上限を付ける -
App Store には「大きな文字」に対応しているかを示す項目があり、目安は200%以上
テスト
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
実行結果
次回
どこまで遊んだかを覚えておき、途中から続けられるようにする。
