概要
Flutterで箱入り娘を作成し、iOS と Android のアプリとして販売する
このシリーズでは、作り始めから販売までを順番に書いていく
第1回では、次のことをする
盤面と駒のルールを作る
駒を指でなぞって動かせる画面を作る
駒を動かしやすくする
箱入り娘について
4x5 の箱の中で駒をすべらせて、大きな駒(娘)を下の出口まで動かすパズル。
駒は 娘(2x2)・縦長(1x2)・横長(2x1)・小(1x1) の4種類。
実行環境
Flutter
$ flutter --version
Flutter 3.47.8 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 737ba8f885 (2 days ago) • 2026-10-22 21:37:31 -0700
Engine • hash dfd2e6cb7d319db05e3bf1503f3e2ac60f6288a2 (revision cf636b1dfb) (2 days ago) • 2026-10-23 02:13:38.000Z
Tools • Dart 3.13.7 • DevTools 2.60.0
手順
Flutterのプロジェクトを作成
flutter create --org xxx.xxx.xxx --platforms android,ios,web klotski
作成したプロジェクトに移動
cd klotski
ファイルの構成
lib/
main.dart
model/ … ルール(Flutter を使わない)
piece.dart … 駒
board.dart … 盤面
layouts.dart … 最初の配置
screen/
game_screen.dart … 画面(盤面と手数を持つ)
widget/
board_view.dart … 盤の表示と、なぞる操作
test/
model/board_test.dart
widget_test.dart
見た目(Widget)とルール(model)を分ける。
model は Flutter を import しない普通の Dart なので、画面を起動しなくてもテストできる。
次回の最短手数を探すプログラムも、この model をそのまま使う。
以下のコードは主要な部分の抜粋(... は省略)。
盤面と駒のルール
駒の種類と向き
enum に値を持たせる(Dart 2.17 から使える)。
種類ごとの幅と高さを switch で書き分けずに済み、種類を増やすときも1か所の修正で済む。
/// 駒の種類。幅と高さはマス数。
enum PieceType {
/// 娘(2x2)。この駒を出口まで動かせばクリア。
daughter(2, 2),
/// 縦長の駒(1x2)。
vertical(1, 2),
/// 横長の駒(2x1)。
horizontal(2, 1),
/// 小さい駒(1x1)。
small(1, 1);
const PieceType(this.width, this.height);
final int width;
final int height;
}
/// 駒を動かす向き。y は下向きが正。
enum Direction {
up(0, -1),
down(0, 1),
left(-1, 0),
right(1, 0);
const Direction(this.dx, this.dy);
final int dx;
final int dy;
}
向きに dx, dy を持たせると、「1マス動かす」が足し算1つで書ける。
駒
駒は「種類」と「左上のマス (x, y)」だけで表す。
/// 盤の上の駒。(x, y) は駒の左上のマス。
class Piece {
const Piece(this.type, this.x, this.y);
final PieceType type;
final int x;
final int y;
int get width => type.width;
int get height => type.height;
/// 1マス動かしたあとの駒。
Piece moved(Direction direction) =>
Piece(type, x + direction.dx, y + direction.dy);
bool overlaps(Piece other) =>
x < other.x + other.width &&
other.x < x + width &&
y < other.y + other.height &&
other.y < y + height;
@override
bool operator ==(Object other) =>
other is Piece && other.type == type && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(type, x, y);
...
}
変更できないオブジェクトにする
フィールドはすべて final。moved は自分を書き換えず、新しい駒を返す。
x, y を直接書き換えられると、覚えておいた盤面まで一緒に変わる。
// x, y を書き換えられる場合
final history = <Board>[];
history.add(board); // 1手戻す機能のために、今の盤面を覚えておく
board.pieces[0].x += 1; // 駒を動かす
// history に入れた盤面も同じオブジェクトなので、一緒に変わってしまう
== と hashCode はセットで書く
Dart のクラスは、何もしないと「同じオブジェクトかどうか」で比べる。
中身が同じでも、別に作った駒は「違う」になる。
const a = Piece(PieceType.small, 1, 3);
final b = Piece(PieceType.small, 1, 3); // const なしで別に作る
print(a == b); // == を書かないと false
== だけ書けば List.indexOf は動くが、hashCode も書かないと Set や Map のキーに入れたときに同じ駒が見つからない。
次回、調べた盤面を Set に入れて覚えるので、2つセットで書く。
盤面
おかしな盤面を作れないようにする
駒が重なっている、盤からはみ出している、娘が2人いる、という盤面はコンストラクタでエラーにする。
使う側で毎回チェックしなくても、Board があれば必ず正しい盤面になる。
最初の配置を手で書いたときの座標の間違いも、ここで見つかる。
/// 4x5 の盤面。駒は動かすたびに新しい Board を作る(元の盤面は変わらない)。
class Board {
/// 駒が盤からはみ出す・重なる・娘がちょうど1個でない場合は [ArgumentError]。
Board(List<Piece> pieces) : pieces = List.unmodifiable(pieces) {
// 娘の数・はみ出し・重なりを調べ、おかしければ ArgumentError
...
}
static const width = 4;
static const height = 5;
/// 娘の左上がこのマスに来たらクリア(下辺の真ん中)。
static const goalX = 1;
static const goalY = 3;
final List<Piece> pieces;
...
リストは List.unmodifiable で包み、外から書き換えられないようにする。
動かすたびに新しい盤面を返す
bool get isSolved => pieces.any(
(p) => p.type == PieceType.daughter && p.x == goalX && p.y == goalY,
);
/// [index] 番目の駒を [direction] に1マス動かせるか。
bool canMove(int index, Direction direction) {
final moved = pieces[index].moved(direction);
if (!_isInside(moved)) return false;
for (var i = 0; i < pieces.length; i++) {
if (i != index && pieces[i].overlaps(moved)) return false;
}
return true;
}
/// [index] 番目の駒を [direction] に1マス動かした盤面。動かせなければ [StateError]。
Board move(int index, Direction direction) {
if (!canMove(index, direction)) {
throw StateError('動かせない: ${pieces[index]} を ${direction.name} へ');
}
final next = [...pieces];
next[index] = pieces[index].moved(direction);
return Board(next);
}
元の盤面はそのまま残るので、「1手戻す」や、次回の「調べた盤面を全部覚えておく」が書きやすい。
最初の配置
定番の配置「横刀立馬」。
/// 定番の配置(横刀立馬)。
///
/// ```
/// V D D V
/// V D D V
/// V H H V
/// V S S V
/// S . . S
/// ```
final classicLayout = Board(const [
Piece(PieceType.vertical, 0, 0),
Piece(PieceType.daughter, 1, 0),
...
]);
コメントの図と同じ並びで、10個の駒を Piece(種類, x, y) で並べる。
画面
駒をなぞると、マスの4割ごとに1マス動く。1マス動くたびに1手と数える。
void _onPanUpdate(DragUpdateDetails details) {
_drag += details.delta;
// マスの 4 割なぞったら 1 マス動かす。続けてなぞれば何マスでも動く。
final threshold = widget.cell * 0.4;
if (_drag.distance < threshold) return;
final Direction direction;
if (_drag.dx.abs() > _drag.dy.abs()) {
direction = _drag.dx > 0 ? Direction.right : Direction.left;
} else {
direction = _drag.dy > 0 ? Direction.down : Direction.up;
}
_drag = Offset.zero;
widget.onMove(direction);
}
駒を動かしやすくする
- 掴んだ駒の色を変える(どの駒を掴んでいるか分かるように)
- なぞっている間、駒が指についてくる(縦か横の一方だけ。行ける端で止まる)
- 移動先のマスを緑で示す(離す前に、どこまで動くか分かるように)
- 指を離したときに動かして、1手と数える。まっすぐなら2マス動かしても1手
(定番の配置の「81手」に近い数え方にするため。正確な数え方は次回確かめる)
指を離すまで決まらないので、元の位置に戻してから離せば、動かさずにやめられる。
状態を持つ場所
状態は2種類ある。
| 状態 | 持つ場所 | 理由 |
|---|---|---|
| 盤面、手数 | GameScreen | 手数の表示、はじめからボタンでも使う |
| 掴んでいる駒、なぞった距離 | BoardView | なぞっている間だけ必要。ほかでは使わない |
BoardView は盤面を受け取って表示するだけにする。
BoardView の中で盤面を書き換えると、GameScreen の手数表示やはじめからボタンが今の盤面を知らないことになる。
指を離したら onMove で GameScreen に知らせ、GameScreen が新しい盤面を作って渡し直す。
class _GameScreenState extends State<GameScreen> {
Board _board = classicLayout;
int _moves = 0;
/// 指を離したときに呼ばれる。何マス動かしても 1 手と数える。
void _move(int index, Direction direction, int steps) {
if (_board.isSolved || steps == 0) return;
setState(() {
_board = _board.slide(index, direction, steps);
_moves++;
});
}
...
BoardView(board: _board, onMove: _move, enabled: !_board.isSolved),
class _BoardViewState extends State<BoardView> {
/// 掴んでいる駒の番号。掴んでいなければ null。
int? _grabbed;
/// 掴んでから指が進んだ距離。
Offset _drag = Offset.zero;
...
void _onStart(int index) {
// ほかの駒を掴んでいる間は、別の指で触っても掴まない
if (!widget.enabled || _grabbed != null) return;
setState(() => _grabbed = index);
}
...
void _onEnd(int index, double cell) {
if (_grabbed != index) return;
final target = _target(cell);
setState(_release);
if (target != null) {
widget.onMove(index, target.$1, target.$2);
}
}
_grabbed は全部の駒で1つを共有している。駒ごとの操作は、掴んだ駒の番号と同じときだけ受け付ける。
こうしないと、2本の指で別々の駒を触ったときに、触っていない駒が動いてしまう。
何マス動くかを決める
まっすぐ何マス動けるかを返す reach と、まとめて動かす slide を model に追加する。
/// [index] 番目の駒を [direction] にまっすぐ何マスまで動かせるか。
int reach(int index, Direction direction) {
var steps = 0;
var board = this;
while (board.canMove(index, direction)) {
board = board.move(index, direction);
steps++;
}
return steps;
}
/// [index] 番目の駒を [direction] にまっすぐ [steps] マス動かした盤面。
Board slide(int index, Direction direction, int steps) {
var board = this;
for (var i = 0; i < steps; i++) {
board = board.move(index, direction);
}
return board;
}
画面側は、なぞった距離から向きとマス数を決め、reach で上限を付ける。
/// 今指を離したら、どの向きに何マス動くか。
(Direction, int)? _target(double cell) {
final index = _grabbed;
if (index == null) return null;
// なぞった距離の縦横の大きいほうから、向き direction と距離 distance を決める
...
// マスの 4 割なぞったら 1 マス、1 マス 4 割で 2 マス…
final wanted = (distance / cell + 0.6).floor();
final steps = wanted.clamp(0, widget.board.reach(index, direction));
if (steps == 0) return null;
return (direction, steps);
}
(Direction, int) は Dart 3 のレコード。「向き」と「マス数」を、クラスを作らずにまとめて返せる。
指についてくる
掴んだ駒は、なぞった分だけずらして表示する。縦か横の一方だけで、reach の端で止める。
/// なぞっている間、掴んだ駒を指に合わせてずらす量。縦か横の一方だけで、行ける端で止まる。
Offset _follow(double cell) {
final index = _grabbed;
if (index == null) return Offset.zero;
final board = widget.board;
if (_drag.dx.abs() > _drag.dy.abs()) {
final dx = _drag.dx.clamp(
-board.reach(index, Direction.left) * cell,
board.reach(index, Direction.right) * cell,
);
return Offset(dx, 0);
}
// 縦も同じ(up / down の reach で止める)
...
}
駒の位置は AnimatedPositioned で、離したときに移動先へすべらせる。
指についていく間はアニメーションの時間を0にする(120ms のままだと指から遅れて見える)。
return AnimatedPositioned(
key: key,
duration: animate ? const Duration(milliseconds: 120) : Duration.zero,
left: _frame + piece.x * cell + _gap + shift.dx,
top: _frame + piece.y * cell + _gap + shift.dy,
...
);
テスト
model は Flutter を使わないので、普通のテストで確かめられる。
test('娘は最初どこにも動かせない', () {
final daughter = classicLayout.pieces.indexWhere(
(p) => p.type == PieceType.daughter,
);
for (final d in Direction.values) {
expect(classicLayout.canMove(daughter, d), isFalse, reason: d.name);
}
});
test('新しい盤面を返し、元の盤面は変わらない', () {
final index = classicLayout.pieces.indexOf(
const Piece(PieceType.small, 1, 3),
);
final next = classicLayout.move(index, Direction.down);
expect(next.pieces[index], const Piece(PieceType.small, 1, 4));
expect(classicLayout.pieces[index], const Piece(PieceType.small, 1, 3));
});
flutter test
アプリの実行
アプリを起動
flutter run
実行結果
次回
定番の配置「横刀立馬」には81手の解があるとされる。
最短で何手かを探すプログラムを作って確かめる。
