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で箱入り娘を作成する1

0
Last updated at Posted at 2026-10-25

概要

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か所の修正で済む。

lib/model/piece.dart
/// 駒の種類。幅と高さはマス数。
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)」だけで表す。

lib/model/piece.dart
/// 盤の上の駒。(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 があれば必ず正しい盤面になる。
最初の配置を手で書いたときの座標の間違いも、ここで見つかる。

lib/model/board.dart
/// 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 で包み、外から書き換えられないようにする。

動かすたびに新しい盤面を返す

lib/model/board.dart
  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手戻す」や、次回の「調べた盤面を全部覚えておく」が書きやすい。

最初の配置

定番の配置「横刀立馬」。

lib/model/layouts.dart
/// 定番の配置(横刀立馬)。
///
/// ```
/// 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手と数える。

lib/widget/board_view.dart(改善前)
  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. なぞっている間、駒が指についてくる(縦か横の一方だけ。行ける端で止まる)
  3. 移動先のマスを緑で示す(離す前に、どこまで動くか分かるように)
  4. 指を離したときに動かして、1手と数える。まっすぐなら2マス動かしても1手
    (定番の配置の「81手」に近い数え方にするため。正確な数え方は次回確かめる)

指を離すまで決まらないので、元の位置に戻してから離せば、動かさずにやめられる。

状態を持つ場所

状態は2種類ある。

状態 持つ場所 理由
盤面、手数 GameScreen 手数の表示、はじめからボタンでも使う
掴んでいる駒、なぞった距離 BoardView なぞっている間だけ必要。ほかでは使わない

BoardView は盤面を受け取って表示するだけにする。
BoardView の中で盤面を書き換えると、GameScreen の手数表示やはじめからボタンが今の盤面を知らないことになる。
指を離したら onMove で GameScreen に知らせ、GameScreen が新しい盤面を作って渡し直す。

lib/screen/game_screen.dart
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),
lib/widget/board_view.dart
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 に追加する。

lib/model/board.dart
  /// [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 で上限を付ける。

lib/widget/board_view.dart
  /// 今指を離したら、どの向きに何マス動くか。
  (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 の端で止める。

lib/widget/board_view.dart
  /// なぞっている間、掴んだ駒を指に合わせてずらす量。縦か横の一方だけで、行ける端で止まる。
  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 のままだと指から遅れて見える)。

lib/widget/board_view.dart
    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/model/board_test.dart
    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

実行結果

klotski1.gif

次回

定番の配置「横刀立馬」には81手の解があるとされる。
最短で何手かを探すプログラムを作って確かめる。

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?