前言
嗨,我是 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,因為太大的圖片在切割和渲染時會嚴重影響效能,尤其是在中低階手機上。
效能考量
拼圖遊戲的效能瓶頸主要在兩個地方:
- 初始切割 — 需要把一張大圖切成數十甚至上百片小圖,這個過程要在背景執行,否則 UI 會卡住
- 同時渲染大量拼圖片 — 當片數很多時,每片都是一個需要渲染的圖層
針對第一個問題,我用 compute 函式把切割工作丟到獨立的 Isolate。針對第二個問題,已經歸位的拼圖片我會合併成一張底圖,減少需要獨立渲染的元素數量。
Future<List<PieceData>> generatePieces(GenerateParams params) async {
return await compute(_generatePiecesInIsolate, params);
}
小結
開發 JigsawEase 讓我學到最多的是 Flutter 的 Path 和 Canvas API。從貝茲曲線的控制點計算、圖片裁切到觸控手勢處理,每一個環節都需要對 Flutter 的繪圖系統有深入的理解。
如果你也想嘗試開發類似的遊戲,建議先從簡單的矩形切割開始,確認拖放邏輯沒問題之後,再慢慢加入不規則形狀。循序漸進會讓開發過程順暢很多。
歡迎到 App Store 或 Google Play 搜尋 JigsawEase 體驗看看!