概要
Flutterで箱入り娘を作成し、iOS と Android のアプリとして販売する
このシリーズでは、作り始めから販売までを順番に書いていく
第5回では、次のことをする
ギブアップしたら、今の盤面からの解き方を探す
解き方を、1 マスずつ自動で動かして見せる
止めたところから、自分で続けられるようにする
実行環境
Flutter
$ flutter --version
Flutter 3.47.9 • channel stable • https://github.com/flutter/flutter.git
Framework • revision c197ca5199 (6 days ago) • 2026-10-29 10:10:20 -0400
Engine • hash 9e025265bef30b838c0c1b68cad19decf97048f2 (revision 89c39edfdf) (7 days ago) • 2026-10-27 19:51:20.000Z
Tools • Dart 3.13.7 • DevTools 2.60.0
手順
ファイルの構成
lib/
model/
replay.dart … 解き方を見せるときの、再生の位置
solver/
solver.dart … 1 つの駒の道順を探す route を足す(第2回から)
find_solution.dart … 画面とは別の isolate で解き方を探す
screen/
game_screen.dart … 遊ぶ画面(前回から)
widget/
board_view.dart … 盤面(第1回から)
test/
game_menu.dart … テスト用の、メニューを押す手助け
model/replay_test.dart
solver/find_solution_test.dart
screen/give_up_test.dart
以下のコードは主要な部分の抜粋(... は省略)。
見せ方を決める
| 決めたこと | 理由 |
|---|---|
上のバーのメニュー(PopupMenuButton)に「ギブアップ」と「リセット」を置く |
絵だけのボタンでは意味が伝わりにくく、字のボタンを並べると題名が縮む |
| ギブアップの前に確かめる | 押しまちがえて、記録に残らなくなるのを防ぐ |
| 解き方は自動で動かし、再生・止める・1 手進む・1 手戻る のボタンも出す | 見ているだけでも分かり、気になる手は止めて見直せる |
| 止めて駒を動かすと、その盤面から自分で続ける | 少しだけ見て、残りは自分で解きたいときのため |
| ギブアップした回は、リセットするか開き直すまで記録しない | 解き方を見てクリアしても、自分で解いたことにはならない |
前回までの「はじめから」のボタンは、メニューの「リセット」に移した。
確かめる画面は、次のようにする。
Future<bool> _confirmGiveUp() async =>
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('ここからの解き方を見る?'),
content: const Text('この回のクリアは記録に残らなくなる。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('キャンセル'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('見る'),
),
],
),
) ??
false;
- 題は「答えを見る?」にしない。見せるのは始めからの答えではなく、今の盤面からの解き方なので
- 画面の外を押して閉じると
showDialogはnullを返すので、?? falseでキャンセルと同じにする
今の盤面から解き方を探す
第2回の solve() は、渡した盤面からクリアまでの最短の手順を探す。
始めの盤面でなくてもよいので、今の盤面をそのまま渡す。
solve(board).path // board からクリアした盤面までの並び。解けなければ null
画面を止めずに探す
探す時間を測る
44 個の配置の始めの盤面で、探す時間を測った(パソコンで、release でビルドして、各 1 回)。
| 配置 | 最短の手数 | 時間 | |
|---|---|---|---|
| 近在咫尺 | 98 手 | 785 ミリ秒 | いちばん時間がかかった |
| 峰回路転 | 138 手 | 531 ミリ秒 | 最短の手数がいちばん多い |
| 前呼後擁 | 22 手 | 7 ミリ秒 | 最短の手数がいちばん少ない |
遊んでいる途中の盤面でも、クリアからいちばん遠い盤面で 555 ミリ秒かかった。
探すときに調べる盤面は、多いもので約 4.4 万個になる。
1 秒に 60 回描きかえる画面なら、1 回あたり 16 ミリ秒ほどしかない。
Flutter の説明には、次のように書いてある。
Whenever a process can't be completed in a frame gap, ... offload the work to another isolate
1 回の描きかえの間に終わらない計算は、別の isolate(画面とは別の、計算をする場所)に移す。
compute で別の isolate に移す
compute() は、渡した関数を別の isolate で動かし、結果を Future で返す。
/// [board] からクリアまでの最短の手順を、画面とは別の isolate で探す。解けなければ null。
...
Future<List<Board>?> findSolution(Board board) => compute(_solvePath, board);
...
List<Board>? _solvePath(Board board) => solve(board).path;
説明には、次のように書いてある。
The
callback, themessagegiven to it as well as the result have to be objects that can be sent across isolates
渡す関数・渡す値・返す値は、isolate の間で送れるものにする。
盤面(Board)と、盤面のリストは送れる。
関数の中で作った関数(クロージャ)も渡せるが、まわりの値を余分につかんで送ってしまうことがある、と注意が書いてある。
なので、まわりの値をつかまないトップレベルの関数にした。
web では先に「探しています」を描く
同じ説明に、web では次のように書いてある。
On web platforms this will run
callbackon the current eventloop.
web では別の isolate を使えず、画面と同じ所で探す。
探している間は画面が止まるので、先に「解き方を探しています」を描いてから探す。
Future<void> _giveUp() async {
// 一度ギブアップした回は、もう失う記録が無いので確かめない
if (!_gaveUp && !await _confirmGiveUp()) return;
if (!mounted) return;
setState(() {
_gaveUp = true;
_searching = true;
_notFound = false;
});
unawaited(_progress.clearUnfinished(_layout));
// web では探している間は画面が止まるので、先に「探しています」を描いておく。
// 1 回目は描くまで、2 回目はそれがブラウザの画面に出て、次の描く番が来るまで待つ
await WidgetsBinding.instance.endOfFrame;
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
List<Board>? path;
try {
path = await widget.solver(_board);
} catch (e, stack) {
// 探す途中で落ちたら(メモリが足りないなど)、見つからなかったときと同じにする
debugPrint('解き方を探す途中で落ちたので、見つからなかったことにする: $e\n$stack');
path = null;
}
if (!mounted) return;
setState(() {
_searching = false;
if (path == null) {
_notFound = true;
return;
}
_replay = Replay(path);
_movesAtGiveUp = _moves;
});
if (path != null) _play();
}
-
endOfFrameは、次の描きかえが終わったら終わるFuture。描きかえの予定が無ければ、予定を入れてくれる - 描き終わってからブラウザの画面に出るまでに間があるかもしれないので、念のためもう 1 回待つ(1 回で足りるかは確かめていない)
- web 以外でも同じように待つ。待つのは描きかえ 2 回分なので、一瞬で終わる
-
awaitのたびに、その間に画面が閉じられていないかmountedを確かめる - web で、峰回路転の始めの盤面でギブアップすると、「解き方を探しています」が出たまま 1〜2 秒止まってから動き出した
解き方を 1 マスずつ見せる
1 手を 1 マスずつに分ける
このアプリでは、指を離すまでを 1 手と数えるので、1 手で駒が角を曲がることがある(第2回)。
1 手の前と後の盤面だけで動かすと、駒は斜めにすべり、ほかの駒の上を横切って見える。
そこで、動いた駒の、1 マスずつの道順を探す。
/// [index] 番目の駒を、ほかの駒を動かさずに [to] まで 1 マスずつ動かす、いちばん短い道順。
/// 今の位置は含まず、最後は [to]。行けなければ [ArgumentError]。
List<Piece> route(Board board, int index, Piece to) {
final start = board.pieces[index];
// 見つけた位置と、その 1 マス前の位置
final previous = <Piece, Piece?>{start: null};
final queue = Queue<Piece>()..add(start);
while (queue.isNotEmpty) {
final piece = queue.removeFirst();
if (piece == to) {
final cells = <Piece>[];
for (Piece? p = piece; p != start; p = previous[p]) {
cells.add(p!);
}
return cells.reversed.toList();
}
for (final d in Direction.values) {
final moved = piece.moved(d);
if (!previous.containsKey(moved) && board.canPlace(index, moved)) {
previous[moved] = piece;
queue.add(moved);
}
}
}
throw ArgumentError('$start から $to へは行けない');
}
第2回の、1 つの駒が行ける位置を幅優先で集める _reachables に、1 マス前の位置を覚えるのを足したもの。
着いたら、1 マス前の位置を逆にたどる。
再生の位置を持つクラス
「何手目の、何マス目か」を Replay のクラスで持つ。
class Replay {
/// [path] はソルバーが返す、最初の盤面からクリアした盤面までの並び。
Replay(List<Board> path)
: _path = List.unmodifiable(path),
_cells = [
for (var m = 0; m + 1 < path.length; m++)
_cellsOf(path[m], path[m + 1]),
] {
...
}
final List<Board> _path;
/// 手ごとの、1 マス動かすたびの盤面。最後はその手のあとの盤面。
final List<List<Board>> _cells;
int _move = 0;
int _cell = 0;
/// 終わった手数。
int get moves => _move;
/// 全部の手数。
int get length => _path.length - 1;
/// 今見せる盤面。
Board get board => _cell == 0 ? _path[_move] : _cells[_move][_cell - 1];
bool get isFirst => _move == 0 && _cell == 0;
bool get isLast => _move == length;
/// 手の途中(駒が動いている最中)か。
bool get isMidMove => _cell != 0;
/// 1 マス進める。最後なら何もしない。
void step() {
if (isLast) return;
_cell++;
if (_cell == _cells[_move].length) {
_move++;
_cell = 0;
}
}
...
static List<Board> _cellsOf(Board before, Board after) {
// ソルバーの盤面は駒の並び順を変えないので、場所が変わった駒が動かした駒
final index = [
for (final (i, piece) in before.pieces.indexed)
if (piece != after.pieces[i]) i,
].single;
var board = before;
return [
for (final piece in route(before, index, after.pieces[index]))
board = board.place(index, piece),
];
}
}
- 手ごとの 1 マスずつの盤面は、作るときに全部作っておく
- 1 手進む(
forward)・1 手戻る(back)は、手の途中なら、その手の後・前までにする
自動で進める
盤面の駒は AnimatedPositioned(第1回)で、新しいマスへすべる。
すべる時間を BoardView に持たせ、再生でも同じ値を使う。
/// 駒が新しいマスへすべる時間。
static const slideDuration = Duration(milliseconds: 120);
/// 解き方を見せるとき、手と手の間にあける時間。
/// 同じ手の中では、すべり終わったらすぐ次のマスへ進め、2 マス以上でも止まらず動いて見せる。
static const _pause = Duration(milliseconds: 300);
...
void _scheduleStep() {
_timer = Timer(
_replay!.isMidMove ? BoardView.slideDuration : _pause,
() => setState(() {
_replay!.step();
if (_replay!.isLast) {
_timer = null;
} else {
_scheduleStep();
}
}),
);
}
- 同じ手の中では、すべり終わったらすぐ次のマスへ進める。待つ時間をすべる時間より長くすると、2 マス動く手が 1 マスごとに止まって見える
- 手と手の間だけ、少し長く待つ
- 1 マス進むたびに次の
Timerを作る。待つ時間が手の中と手の間で変わるので、決まった間隔でくり返すTimer.periodicは使わない
止めるときは、手の切れ目まで進める
手の途中で止めると、その手のあとまで進めて止める。
止めた盤面から自分で動かしたとき、手数が手の切れ目で数えられるように。
/// 自動で進めるのをやめる。手の途中なら、その手のあとまで進めて止める
/// (止めた盤面から自分で動かすとき、手数が手の切れ目になるように)。
void _stop() {
if (_timer == null) return;
_timer!.cancel();
setState(() {
_timer = null;
if (_replay?.isMidMove ?? false) {
_replay!.forward();
_jumped = true;
}
});
}
飛ぶときは、すべらせない
1 手進む・1 手戻る・手の途中で止めたときは、何マスも離れた所へ飛ぶことがある。
そのときもすべらせると、駒が斜めに横切って見えるので、すべらせない。
遊ぶ画面では、飛んだかどうかを _jumped で持ち、盤面に渡す。
1 手進む・1 手戻るでも、上の _stop() と同じように _jumped = true にする。
/// 見せている盤面が、1 マスずつでなく 1 手ぶん飛んだか(1 手進む・戻る、手の途中で止めたとき)。
/// 飛んだときは、曲がる手でもほかの駒の上を斜めに横切って見えないよう、すべらせない。
bool _jumped = false;
...
child: BoardView(
board: _shownBoard,
onMove: _move,
// 解き方を自動で進めている間は触れない(止めれば動かせる)
enabled: !_shownBoard.isSolved && !_searching && !_playing,
animate: !_jumped,
),
盤面では、指についていく駒のほかは、animate が false ならすべらせない。
/// false なら、盤面が替わったとき駒をすべらせずに、すぐ新しいマスに描く
/// (何マスも離れた所へ飛ぶとき、ほかの駒の上を斜めに横切って見えないように)。
final bool animate;
...
_cellRect(
board.pieces[i],
cell,
key: ValueKey(i),
shift: _grabbed == i ? shift : Offset.zero,
// 指についていくときはアニメーションさせない(遅れて見えるため)
animate: widget.animate && _grabbed != i,
...
/// 駒 [piece] が占めるマスの位置(から [shift] ずらした所)に [child] を置く。
Widget _cellRect(
Piece piece,
double cell, {
Key? key,
Offset shift = Offset.zero,
bool animate = true,
required Widget child,
}) {
return AnimatedPositioned(
key: key,
duration: animate ? BoardView.slideDuration : Duration.zero,
...
ボタンの列の場所は空けておく
再生のボタンは、盤の下に並べる。
見せていないときに列ごと消すと、出たときに盤が小さくなるので、場所は空けたまま見えなくする。
// 解き方を見せるボタン。出たときに盤が動かないよう、見せていないときも場所は空けておく
Visibility(
visible: _replay != null,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: Row(
...
-
maintainSizeで、見えなくしても場所を空けたままにする。maintainSizeを使うには、maintainAnimationとmaintainStateも true にする決まり -
maintainInteractivityとmaintainSemanticsは付けていない(既定は false)。なので見えない間は押せず、画面の読み上げにも出ない
記録とのつなぎ
- ギブアップしたら、途中の盤面を消す。ギブアップした回の盤面から「続きから」で始めても、記録に残らないので
- 止めて駒を動かしたら、見せるのをやめて、その盤面から続ける。手数は、ギブアップしたときの手数に、解き方で進んだ手数を足したもの
- ギブアップした回は、クリアしても途中でやめても書かない。リセットするか、画面を閉じて開き直すと、また書く
Board get _shownBoard => _replay?.board ?? _board;
int get _shownMoves =>
_replay == null ? _moves : _movesAtGiveUp + _replay!.moves;
...
void _move(int index, Piece to) {
if (_shownBoard.isSolved) return;
setState(() {
_board = _shownBoard.place(index, to);
_moves = _shownMoves + 1;
_replay = null;
_jumped = false;
_notFound = false;
});
if (_gaveUp) return;
unawaited(
_board.isSolved
? _progress.recordClear(_layout, _moves)
: _progress.saveUnfinished(_layout, _board, _moves),
);
}
_shownBoard は、見せている解き方の盤面(見せていなければ遊んでいる盤面)。_shownMoves は、そのときの手数。
テスト
探す関数を差しかえる
画面のテストは、時計を進めた分だけ動く、作りものの時計の中で動く。
別の isolate で探すと、その答えを待てないので、探す関数を外から渡せるようにする。
const GameScreen({
super.key,
required this.layout,
required this.progress,
this.solver = findSolution,
});
...
/// 解き方を探す関数(テストでは isolate を使わないものに替える)。
final Future<List<Board>?> Function(Board board) solver;
/// isolate を使わずに探す(テストの時計の中で、すぐ答えが出るように)。
Future<List<Board>?> solveHere(Board board) async => solve(board).path;
Completer を渡せば「探している間」で止めておけ、例外を投げる関数を渡せば「探す途中で落ちたとき」も確かめられる。
testWidgets('探す途中で落ちたら、見つからないときと同じにして、自分で続けられる', (tester) async {
await pumpGame(tester, solver: (_) async => throw StateError('落ちた'));
...
findSolution 自身は、別のテストで solve と同じ答えになることを確かめる。
test('別の isolate で探しても、solve と同じ手順', () async {
final board = namedLayouts.first.board;
expect(await findSolution(board), solve(board).path);
});
待ち時間を確かめる
時計を 10 ミリ秒ずつ進め、駒が進むたびに、前に進んでからの時間を確かめる。
/// 解き方を見せるとき、手と手の間にあける時間(画面の値と同じ)。
const pause = Duration(milliseconds: 300);
/// 同じ手の中で、次のマスへ進むまでの時間(すべる時間と同じ)。
const slide = BoardView.slideDuration;
/// 時計を細かく進めるときの 1 回ぶん。どの待ち時間も、これで割り切れる。
const tick = Duration(milliseconds: 10);
...
// path は今の盤面からの解き方、move は終わった手数。
// movedOneCell は、2 つの盤面で 1 つの駒が 1 マスだけ動いたか(replay_test.dart にある)
...
for (var i = 0; move < path.length - 1; i++) {
...
final before = view(tester).board;
await tester.pump(tick);
since += tick;
final after = view(tester).board;
if (after == before) continue;
cells++;
expect(movedOneCell(before, after), isTrue, reason: '$cells マス目');
// 同じ手の中では、すべり終わるとすぐ次のマスへ進み、手と手の間だけ間をあける。
// 最初の 1 マスは、ギブアップの間に時計が進んでいるので測らない
if (cells > 1) {
expect(since, midMove ? slide : pause, reason: '$cells マス目');
}
since = Duration.zero;
midMove = after != path[move + 1];
...
}
// 2 マス以上の手で、手の中の速さも確かめた
expect(cells, greaterThan(path.length - 1));
- 1 回に 1 マスだけ進むこと、同じ手の中ではすべる時間、手と手の間では 300 ミリ秒で進むことを確かめる
- 最後に、マスの数が手数より多いことを確かめる。2 マス以上動く手が無いと、手の中の待ち時間を 1 度も確かめないまま通ってしまうため
flutter test
アプリの実行
アプリを起動
flutter run
実行結果
次回
販売の準備をする。
