Flutter 拼圖遊戲開發教學:JigsawEase 實作分享

前言

嗨,我是 Steven。繼 SandTrace 粒子模擬之後,今天來分享我開發 JigsawEase 拼圖遊戲的完整歷程。拼圖遊戲看似簡單,但從「把一張圖片切成不規則形狀的碎片」到「讓使用者能直覺地拖放拼合」,背後其實藏著不少技術細節。

JigsawEase 的目標很明確:讓使用者可以匯入自己的照片,自動切割成拼圖碎片,然後享受拼圖的樂趣。聽起來直觀,做起來卻花了我好幾個月的時間反覆迭代。

第一步:拼圖形狀的生成

傳統拼圖最有辨識度的特徵就是那些凸出和凹入的鉤扣(knob and socket)。要在程式中產生這些形狀,我選擇用 貝茲曲線(Bézier Curve) 來繪製。

每一片拼圖有四個邊,每個邊可能是平的(邊緣片)、凸出或凹入。我設計了一個結構來描述每片拼圖的形狀:

class PuzzlePieceShape {
  final EdgeType top;
  final EdgeType right;
  final EdgeType bottom;
  final EdgeType left;

  const PuzzlePieceShape({
    required this.top,
    required this.right,
    required this.bottom,
    required this.left,
  });
}

enum EdgeType { flat, knob, socket }

生成拼圖形狀時,最關鍵的規則是相鄰兩片的邊必須互補:如果 A 片的右邊是凸出,那 B 片的左邊就必須是凹入。

List<List<PuzzlePieceShape>> generatePuzzleShapes(int rows, int cols) {
  final shapes = List.generate(rows, (_) => List<PuzzlePieceShape>.empty(growable: true));
  final random = Random();

  for (int r = 0; r < rows; r++) {
    for (int c = 0; c < cols; c++) {
      final top = r == 0 ? EdgeType.flat : shapes[r - 1][c].bottom.opposite;
      final left = c == 0 ? EdgeType.flat : shapes[r][c - 1].right.opposite;
      final bottom = r == rows - 1 ? EdgeType.flat : random.nextBool() ? EdgeType.knob : EdgeType.socket;
      final right = c == cols - 1 ? EdgeType.flat : random.nextBool() ? EdgeType.knob : EdgeType.socket;

      shapes[r].add(PuzzlePieceShape(top: top, right: right, bottom: bottom, left: left));
    }
  }
  return shapes;
}

第二步:用 Path 繪製拼圖邊緣

有了形狀定義之後,接下來要把它轉換成 Flutter 的 Path 物件。每個鉤扣用三階貝茲曲線繪製,這需要精確計算控制點的位置。

Path buildPiecePath(PuzzlePieceShape shape, double width, double height) {
  final path = Path();
  path.moveTo(0, 0);

  // 上邊
  _drawEdge(path, shape.top, Axis.horizontal, width, isForward: true);
  // 右邊
  _drawEdge(path, shape.right, Axis.vertical, height, isForward: true);
  // 下邊(反向)
  _drawEdge(path, shape.bottom, Axis.horizontal, width, isForward: false);
  // 左邊(反向)
  _drawEdge(path, shape.left, Axis.vertical, height, isForward: false);

  path.close();
  return path;
}

void _drawEdge(Path path, EdgeType type, Axis axis, double length, {required bool isForward}) {
  if (type == EdgeType.flat) {
    // 直線
    final end = axis == Axis.horizontal
        ? Offset(isForward ? length : -length, 0)
        : Offset(0, isForward ? length : -length);
    path.relativeLineTo(end.dx, end.dy);
    return;
  }

  final knobSize = length * 0.15;
  final direction = type == EdgeType.knob ? 1.0 : -1.0;

  // 用三段貝茲曲線畫出鉤扣形狀
  final segmentLength = length / 3;
  path.relativeLineTo(
    axis == Axis.horizontal ? segmentLength : 0,
    axis == Axis.vertical ? segmentLength : 0,
  );

  // 鉤扣的貝茲曲線
  if (axis == Axis.horizontal) {
    path.relativeCubicTo(
      0, -knobSize * direction,
      segmentLength, -knobSize * direction,
      segmentLength, 0,
    );
  } else {
    path.relativeCubicTo(
      -knobSize * direction, 0,
      -knobSize * direction, segmentLength,
      0, segmentLength,
    );
  }

  path.relativeLineTo(
    axis == Axis.horizontal ? segmentLength : 0,
    axis == Axis.vertical ? segmentLength : 0,
  );
}

我在開發過程中花最多時間的就是調整這些控制點。差一點點的偏移就會讓鉤扣看起來很不自然,這部分真的需要反覆微調。

第三步:圖片裁切

有了 Path 之後,要從原始圖片中裁切出對應區域,關鍵在於使用 Canvas.clipPath

Future<ui.Image> clipPieceImage(
  ui.Image sourceImage,
  Path piecePath,
  Rect sourceRect,
  Size pieceSize,
) async {
  final recorder = ui.PictureRecorder();
  final canvas = Canvas(recorder);

  canvas.clipPath(piecePath);
  canvas.drawImageRect(
    sourceImage,
    sourceRect,
    Rect.fromLTWH(0, 0, pieceSize.width, pieceSize.height),
    Paint(),
  );

  final picture = recorder.endRecording();
  return picture.toImage(pieceSize.width.ceil(), pieceSize.height.ceil());
}

這裡要注意 sourceRect 的計算,必須精確對應到原圖上的正確位置,還要考慮鉤扣突出的部分需要額外的邊距。

第四步:拖放互動

拼圖遊戲的核心體驗就是拖放。我用 GestureDetector 來處理拖曳,並在放手時判斷是否已經接近正確位置:

class PuzzlePiece extends StatefulWidget {
  final int correctRow;
  final int correctCol;
  final ui.Image image;
  final double snapDistance;

  // ...
}

class _PuzzlePieceState extends State<PuzzlePiece> {
  Offset _position = Offset.zero;
  bool _isPlaced = false;

  void _onPanEnd(DragEndDetails details) {
    final correctPosition = Offset(
      widget.correctCol * pieceWidth,
      widget.correctRow * pieceHeight,
    );

    final distance = (_position - correctPosition).distance;

    if (distance < widget.snapDistance) {
      setState(() {
        _position = correctPosition;
        _isPlaced = true;
      });
      // 播放吸附音效
      _playSnapSound();
      // 檢查是否全部完成
      widget.onPiecePlaced?.call();
    }
  }
}

吸附效果的體驗優化

吸附距離的設定很講究。設太小,使用者會覺得很難對準而感到挫折;設太大,又會失去挑戰性。我最後根據拼圖片的大小動態調整,設定為片寬的 25% 左右,玩起來的手感最舒服。

第五步:照片匯入功能

JigsawEase 的一大特色是可以用自己的照片來拼圖。我使用 image_picker 套件來讀取相簿:

Future<void> _pickImage() async {
  final picker = ImagePicker();
  final picked = await picker.pickImage(
    source: ImageSource.gallery,
    maxWidth: 1920,
    maxHeight: 1920,
  );

  if (picked != null) {
    final bytes = await picked.readAsBytes();
    final codec = await ui.instantiateImageCodec(bytes);
    final frame = await codec.getNextFrame();
    _startPuzzle(frame.image);
  }
}

這裡我刻意限制了最大解析度為 1920,因為太大的圖片在切割和渲染時會嚴重影響效能,尤其是在中低階手機上。

效能考量

拼圖遊戲的效能瓶頸主要在兩個地方:

  1. 初始切割 — 需要把一張大圖切成數十甚至上百片小圖,這個過程要在背景執行,否則 UI 會卡住
  2. 同時渲染大量拼圖片 — 當片數很多時,每片都是一個需要渲染的圖層

針對第一個問題,我用 compute 函式把切割工作丟到獨立的 Isolate。針對第二個問題,已經歸位的拼圖片我會合併成一張底圖,減少需要獨立渲染的元素數量。

Future<List<PieceData>> generatePieces(GenerateParams params) async {
  return await compute(_generatePiecesInIsolate, params);
}

小結

開發 JigsawEase 讓我學到最多的是 Flutter 的 PathCanvas API。從貝茲曲線的控制點計算、圖片裁切到觸控手勢處理,每一個環節都需要對 Flutter 的繪圖系統有深入的理解。

如果你也想嘗試開發類似的遊戲,建議先從簡單的矩形切割開始,確認拖放邏輯沒問題之後,再慢慢加入不規則形狀。循序漸進會讓開發過程順暢很多。

歡迎到 App Store 或 Google Play 搜尋 JigsawEase 體驗看看!