Sfoglia il codice sorgente

尝试group整体绘制,但有问题,暂时搁置

guoziyun 7 mesi fa
parent
commit
788d64a88e
5 ha cambiato i file con 1061 aggiunte e 187 eliminazioni
  1. BIN
      assets/images/backcard.png
  2. 88 46
      lib/play/board.dart
  3. 260 44
      lib/play/board_painter.dart
  4. 199 11
      lib/play/board_play.dart
  5. 514 86
      lib/play/piece.dart

BIN
assets/images/backcard.png


+ 88 - 46
lib/play/board.dart

@@ -12,12 +12,21 @@ import 'package:vector_math/vector_math.dart' as vmath;
 
 final Logger _log = Logger('board.dart');
 
-enum BoardStatus { loading, playing, success }
+// 增加一个preparing 和 shuffle洗牌的状态
+// preparing: Opacity透明度动画展示核心绘制区
+// shuffle: 发牌和翻牌动画构成
+enum BoardStatus { loading, preparing, shuffle, playing, success }
+
+// 发牌阶段,dealing:发牌中(发牌动画), fliping:翻转中(翻转动画)
+enum ShuffleStep { dealing, flipping }
 
 class Board {
   // 原图
   final ui.Image image;
 
+  // 纸牌背面图,用于
+  final ui.Image cardImage;
+
   // 所有拼图碎片
   final List<Piece> pieces = [];
 
@@ -44,7 +53,7 @@ class Board {
   ValueNotifier boardNotifier = ValueNotifier(1);
 
   // 用户是否真正可以开始动手开玩(所有加载初始化完成,并且进入的插屏广告播放完成)
-  final Completer<bool> startPlayCompleter = Completer();
+  final Completer<bool> PlayCompleter = Completer();
 
   BoardStatus _status = BoardStatus.loading;
   BoardStatus get status => _status;
@@ -52,6 +61,20 @@ class Board {
   // 备份的 groups (PieceGroup 对象的列表)
   List<PieceGroup> backupGroups = [];
 
+  ShuffleStep _shuffleStep = ShuffleStep.dealing;
+  ShuffleStep get shuffleStep => _shuffleStep;
+
+  void shuffle(ShuffleStep step) {
+    _status = BoardStatus.shuffle;
+    _shuffleStep = step;
+    invalidate();
+  }
+
+  void prepare() {
+    _status = BoardStatus.preparing;
+    invalidate();
+  }
+
   void start() {
     _status = BoardStatus.playing;
     invalidate();
@@ -71,69 +94,82 @@ class Board {
 
   final TickerProviderStateMixin ticker;
 
-  Board(this.ticker, this.image, this.cols, this.rows, this.targetRect, this.device, {Map<String, dynamic>? json}) {
+  Board(this.ticker, this.image, this.cardImage, this.rows, this.cols, this.targetRect, this.device, {Map<String, dynamic>? json}) {
     _recordBackground(); // 录制静态背景,提升性能
     _initPieces();
     rebuildAllGroups();
-    backupAllGroups();
   }
 
   /// 初始化碎片
-  _initPieces() {
-    final double imagePixelWidth = image.width.toDouble(); // 图片像素宽度
-    final double imagePixelHeight = image.height.toDouble(); // 图片像素高度
-    final double pieceLogicalWidth = targetRect.width / cols; // 绘图区域逻辑宽度 / 列数 = 每个piece的逻辑宽度
-    final double pieceLogicalHeight = targetRect.height / rows; // 绘图区域逻辑高度 / 行数 = 每个piece的逻辑高度
-    final double scaleX = imagePixelWidth / targetRect.width; // 像素宽度与逻辑宽度的scale比例
-    final double scaleY = imagePixelHeight / targetRect.height; // 像素高度与逻辑高度的scale比例
-    final double piecePixelWidth = pieceLogicalWidth * scaleX; // 每个piece的像素宽度
-    final double piecePixelHeight = pieceLogicalHeight * scaleY; // 每个piece的像素高度
-
-    final List<({int col, int row})> slotCoords = [];
-
-    for (int r = 0; r < rows; r++) {
-      for (int c = 0; c < cols; c++) {
-        slotCoords.add((col: c, row: r));
-      }
-    }
-
-    slotCoords.shuffle(Random());
-
-    int index = 0;
-
-    for (int r = 0; r < rows; r++) {
-      for (int c = 0; c < cols; c++) {
-        final correctX = targetRect.left + c * pieceLogicalWidth; // piece的正确位置坐标 x
-        final correctY = targetRect.top + r * pieceLogicalHeight; // piece的正确位置坐标 y
-        final correctOffset = Offset(correctX, correctY);
-
-        final initialSlot = slotCoords[index];
-        final initialX = targetRect.left + initialSlot.col * pieceLogicalWidth; // 初始位置坐标
-        final initialY = targetRect.top + initialSlot.row * pieceLogicalHeight;
-
-        final initialTransform = vmath.Matrix4.translationValues(initialX, initialY, 0.0); // 记录初始位置坐标transform matrix
-
-        final sourceRect = Rect.fromLTWH(c * piecePixelWidth, r * piecePixelHeight, piecePixelWidth, piecePixelHeight); // 对应图片的像素矩形
+  void _initPieces() {
+    pieces.clear();
+    final imageWidth = image.width.toDouble();
+    final imageHeight = image.height.toDouble();
+    final pieceWidth = imageWidth / cols;
+    final pieceHeight = imageHeight / rows;
+
+    for (int i = 0; i < rows; i++) {
+      for (int j = 0; j < cols; j++) {
+        final index = i * cols + j;
+        final sourceRect = Rect.fromLTWH(j * pieceWidth, i * pieceHeight, pieceWidth, pieceHeight);
+        final correctOffset = Offset(targetRect.left + j * pieceLogicalWidth, targetRect.top + i * pieceLogicalHeight);
+        final transform = getTransformByCoordinate(i, j);
 
         pieces.add(
           Piece(
             board: this,
             index: index,
-            row: r,
-            col: c,
+            row: i,
+            col: j,
             rows: rows,
             cols: cols,
             correctOffset: correctOffset,
-            curCol: initialSlot.col,
-            curRow: initialSlot.row,
             sourceRect: sourceRect,
-            transform: initialTransform,
-            borders: [true, true, true, true],
+            curRow: i,
+            curCol: j,
+            transform: transform,
           ),
         );
-        index++;
       }
     }
+
+    _shufflePieces();
+    _sortPieces();
+    _log.info('_initPieces');
+  }
+
+  // 洗牌(随机打乱碎片位置)
+  void _shufflePieces() {
+    final shuffledPositions = List.generate(pieces.length, (i) => i)..shuffle();
+    for (int i = 0; i < pieces.length; i++) {
+      final targetIndex = shuffledPositions[i];
+      pieces[i].curRow = targetIndex ~/ cols;
+      pieces[i].curCol = targetIndex % cols;
+      pieces[i].transform = getTransformByCoordinate(pieces[i].curRow, pieces[i].curCol);
+    }
+  }
+
+  void resetAllPieces() {
+    for (var p in pieces) {
+      p.transform = getTransformByCoordinate(p.curRow, p.curCol);
+    }
+  }
+
+  void setAllPieceToBottomRight() {
+    for (var p in pieces) {
+      p.transform = getBottomRightTransform();
+    }
+  }
+
+  // 是否应该根据当前的curRow和curCol对pieces做一次排序,以方便dealing动画实现依次从左上到右下的发牌效果
+  void _sortPieces() {
+    pieces.sort((Piece a, Piece b) {
+      int ret = a.curRow - b.curRow;
+      if (ret == 0) {
+        ret = a.curCol - b.curCol;
+      }
+      return ret;
+    });
   }
 
   // 根据坐标查找指定位置的碎片
@@ -171,6 +207,12 @@ class Board {
     return null;
   }
 
+  // 获取右下角变换矩阵
+  vmath.Matrix4 getBottomRightTransform() {
+    return getTransformByCoordinate(rows - 1, cols - 1);
+  }
+
+  // 根据格子位置(row, col)获取变换矩阵
   vmath.Matrix4 getTransformByCoordinate(int row, int col) {
     final x = targetRect.left + col * pieceLogicalWidth;
     final y = targetRect.top + row * pieceLogicalHeight;

+ 260 - 44
lib/play/board_painter.dart

@@ -1,58 +1,197 @@
 // board_painter.dart
 
+import 'dart:math';
 import 'dart:typed_data';
 
 import 'package:flutter/material.dart';
 
 import 'package:image_puzzle/play/board.dart';
 import 'package:image_puzzle/play/piece.dart';
+import 'package:logging/logging.dart';
+
+final Logger _log = Logger('board_painter.dart');
 
 class BoardPainter extends CustomPainter {
   final Board board;
+  final AnimationController prepareAnimation;
 
-  // 碎片边框宽度 (可以比背景槽位稍粗,以便突出)
+  // 碎片边框宽度
   static const double _pieceStrokeWidth = 1.0;
 
-  // 边框画笔
-  final Paint _pieceBorderPaint = Paint()
-    ..color = Colors
-        .black // 碎片边框颜色
+  // 边框画笔 (外边框,黑色)
+  final Paint _outerBorderPaint = Paint()
+    ..color = Colors.black
     ..style = PaintingStyle.stroke
     ..strokeWidth = _pieceStrokeWidth
     ..isAntiAlias = true;
 
-  // 边框画笔
-  final Paint _innerPieceBorderPaint = Paint()
-    ..color = Colors
-        .white // 碎片边框颜色
+  // 边框画笔 (内边框,白色)
+  final Paint _innerBorderPaint = Paint()
+    ..color = Colors.white
     ..style = PaintingStyle.stroke
     ..strokeWidth = _pieceStrokeWidth
     ..isAntiAlias = true;
 
-  BoardPainter({required this.board}) : super(repaint: Listenable.merge([board.boardNotifier])); // 触发重绘
+  // 卡片背面画笔
+  final Paint _cardBackPaint = Paint()..isAntiAlias = true;
+
+  BoardPainter({required this.board, required this.prepareAnimation}) : super(repaint: Listenable.merge([board.boardNotifier, prepareAnimation])); // 触发重绘
 
   @override
   void paint(Canvas canvas, Size size) {
+    switch (board.status) {
+      case BoardStatus.loading:
+        _paintLoading(canvas, size);
+        break;
+      case BoardStatus.preparing:
+        _paintPreparing(canvas, size);
+        break;
+      case BoardStatus.shuffle:
+        _paintShuffle(canvas, size);
+        break;
+      case BoardStatus.playing:
+        _paintPlaying(canvas, size);
+        break;
+      case BoardStatus.success:
+        _paintSuccess(canvas, size);
+        break;
+    }
+  }
+
+  _paintLoading(Canvas canvas, Size size) {
+    _log.info('_paintLoading');
+    // 绘制整个背景底色
+    canvas.drawRect(
+      Rect.fromLTWH(0, 0, size.width, size.height),
+      Paint()
+        ..color = Colors.lightGreen
+        ..style = PaintingStyle.fill,
+    );
+  }
+
+  _paintPreparing(Canvas canvas, Size size) {
+    _log.info('_paintPreparing');
+    // 绘制整个背景底色, 但不绘制核心宫格游戏区
+    canvas.drawRect(
+      Rect.fromLTWH(0, 0, size.width, size.height),
+      Paint()
+        ..color = Colors.lightGreen
+        ..style = PaintingStyle.fill,
+    );
+    if (prepareAnimation.isAnimating) {
+      _paintBackground(canvas, size, (prepareAnimation.value * 255).toInt());
+    } else {
+      _paintBackground(canvas, size, 255);
+    }
+  }
+
+  _paintShuffle(Canvas canvas, Size size) {
+    _log.info('_paintShuffle');
     // 绘制背景
-    // _paintBackground(canvas, size);
     if (board.backgroundPicture != null) {
       canvas.drawPicture(board.backgroundPicture!);
     }
 
-    // 绘制pieces
+    // 绘制所有卡片
+    for (final piece in board.pieces) {
+      _drawDealingPiece(canvas, size, piece);
+    }
+  }
+
+  /// 绘制进行中的游戏界面
+  _paintPlaying(Canvas canvas, Size size) {
+    _log.info('_paintPlaying');
+    // 1. 绘制背景
+    if (board.backgroundPicture != null) {
+      canvas.drawPicture(board.backgroundPicture!);
+    }
+
+    // 2. 收集所有不重复的群组(避免重复绘制)
+    final Set<PieceGroup> drawnGroups = {};
     for (final piece in board.pieces) {
       _drawPiece(canvas, size, piece);
+      // if (piece.group != null) {
+      //   if (!drawnGroups.contains(piece.group)) {
+      //     drawnGroups.add(piece.group!);
+      //     // 调用更新后的 _drawGroup 方法
+      //     _drawGroup(canvas, size, piece.group!);
+      //   }
+      // } else {
+      //   _drawPiece(canvas, size, piece);
+      // }
     }
   }
 
-  @override
-  bool shouldRepaint(covariant BoardPainter oldDelegate) {
-    return true;
+  _paintSuccess(Canvas canvas, Size size) {
+    // 成功状态的绘制逻辑,目前和 playing 相同
+    _paintPlaying(canvas, size);
   }
 
+  void _drawDealingPiece(Canvas canvas, Size size, Piece piece) {
+    final Paint outerBorderPaint = Paint()
+      ..color = Colors.black
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 1.0
+      ..isAntiAlias = true;
+
+    // 边框画笔
+    final Paint innerBorderPaint = Paint()
+      ..color = Colors.white
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 2.0
+      ..isAntiAlias = true;
+
+    final w = piece.width;
+    final h = piece.height;
+    final storage64 = Float64List.fromList(piece.transform.storage);
+
+    final rrect = RRect.fromRectAndRadius(Rect.fromLTWH(0, 0, w, h), Radius.circular(8.0));
+    final outerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(0.5, 0.5, w - 1.0, h - 1.0), Radius.circular(8.0));
+    final innerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(2.5, 2.5, w - 5.0, h - 5.0), Radius.circular(8.0));
+
+    final Rect dstRect = Rect.fromLTWH(0, 0, w, h);
+
+    canvas.save();
+
+    canvas.transform(storage64); // 应用平移
+
+    canvas.clipRRect(rrect);
+
+    // 绘制图片:只有落在 rrect 内部的部分图片会被绘制
+    if (!piece.isFlipped) {
+      // _log.info("show card");
+      final Rect sourceRect = Rect.fromLTWH(0, 0, board.cardImage.width.toDouble(), board.cardImage.height.toDouble());
+      canvas.drawImageRect(board.cardImage, sourceRect, dstRect, Paint()..isAntiAlias = true);
+    } else {
+      // _log.info("show image");
+      canvas.drawImageRect(board.image, piece.sourceRect, dstRect, Paint()..isAntiAlias = true);
+    }
+
+    canvas.restore(); // 恢复 Canvas 状态 (移除裁剪和 transform)
+
+    // --- 绘制边框 ---
+    // 为了确保边框线宽向外延伸的部分不会被裁剪,我们需要在裁剪区域被移除后重新绘制。
+    canvas.save();
+    canvas.transform(storage64); // 重新应用平移
+
+    if (!piece.isFlipped) {
+      canvas.drawRRect(outerRRect, outerBorderPaint);
+      canvas.drawRRect(innerRRect, innerBorderPaint);
+    } else {
+      final List<Path> paths = _getPiecePath(piece);
+      final Path outerBorderPath = paths[1];
+      final Path innerBorderPath = paths[2];
+      canvas.drawPath(outerBorderPath, _outerBorderPaint); // 外边框,黑色
+      canvas.drawPath(innerBorderPath, _innerBorderPaint); // 内边框,白色
+    }
+
+    canvas.restore();
+  }
+
+  /// 绘制单个碎片
   void _drawPiece(Canvas canvas, Size size, Piece piece) {
-    final double w = board.pieceLogicalWidth;
-    final double h = board.pieceLogicalHeight;
+    final double w = piece.width;
+    final double h = piece.height;
 
     // 1. 准备变换矩阵和动态 Path
     final Float64List storage64 = Float64List.fromList(piece.transform.storage);
@@ -81,13 +220,69 @@ class BoardPainter extends CustomPainter {
     canvas.transform(storage64); // 重新应用平移
 
     // 绘制碎片边框
-    canvas.drawPath(outerBorderPath, _pieceBorderPaint); // 外边框,黑色
-    canvas.drawPath(innerBorderPath, _innerPieceBorderPaint); // 内边框,白色
+    canvas.drawPath(outerBorderPath, _outerBorderPaint); // 外边框,黑色
+    canvas.drawPath(innerBorderPath, _innerBorderPaint); // 内边框,白色
+
+    canvas.restore();
+  }
+
+  /// 绘制整个群组(核心实现)
+  void _drawGroup(Canvas canvas, Size size, PieceGroup group) {
+    if (group.pieces.isEmpty) return;
+
+    // 1. 准备群组基础数据
+    final topLeftPiece = group.topLeftPiece;
+    group.generateGroupPaths(); // 确保路径已生成
+
+    // 2. 群组基准点变换(基于左上角碎片的绝对位置)
+    final baseTransform = Float64List.fromList(topLeftPiece.transform.storage);
+
+    // 3. 计算群组内碎片的相对偏移(相对于左上角碎片)
+    final minR = topLeftPiece.curRow;
+    final minC = topLeftPiece.curCol;
+    final pieceWidth = topLeftPiece.width;
+    final pieceHeight = topLeftPiece.height;
+
+    // 4. 绘制群组图片(整体裁剪 + 拼接碎片)
+    canvas.save();
+    canvas.transform(baseTransform); // 移动到群组基准点
+
+    // 4.1 应用群组裁剪路径(确保超出群组范围的内容不显示)
+    if (group.groupClipPath != null) {
+      canvas.clipPath(group.groupClipPath!);
+    }
+
+    // 4.2 绘制群组内所有碎片的图片部分(相对位置拼接)
+    for (final piece in group.pieces) {
+      // 计算碎片在群组内的相对坐标(相对于左上角碎片)
+      final dr = piece.curRow - minR;
+      final dc = piece.curCol - minC;
+      final dx = dc * pieceWidth;
+      final dy = dr * pieceHeight;
+
+      // 绘制碎片图片到群组内的对应位置
+      final dstRect = Rect.fromLTWH(dx, dy, pieceWidth, pieceHeight);
+      canvas.drawImageRect(board.image, piece.sourceRect, dstRect, Paint()..isAntiAlias = true);
+    }
+
+    canvas.restore(); // 恢复裁剪和基准变换
+
+    // 5. 绘制群组边框(外边框 + 内边框)
+    canvas.save();
+    canvas.transform(baseTransform); // 重新应用基准变换
+
+    if (group.groupOutLinePath != null) {
+      canvas.drawPath(group.groupOutLinePath!, _outerBorderPaint);
+    }
+    if (group.groupInnerLinePath != null) {
+      canvas.drawPath(group.groupInnerLinePath!, _innerBorderPaint);
+    }
 
     canvas.restore();
   }
 
   List<Path> _getPiecePath(Piece piece) {
+    // 如果是单个碎片,直接使用 piece 自身的方法获取路径
     if (piece.path == null || piece.outLinePath == null || piece.innerLinePath == null) {
       return piece.generatePaths();
     } else {
@@ -96,8 +291,17 @@ class BoardPainter extends CustomPainter {
   }
 
   // 绘制背景(已经被drawPicture取代)
-  void _paintBackground(Canvas canvas, Size size) {
-    // 绘制整个背景
+  void _paintBackground(Canvas canvas, Size size, int alpha) {
+    final targetRect = board.targetRect;
+    final pieceLogicalWidth = board.pieceLogicalWidth;
+    final pieceLogicalHeight = board.pieceLogicalHeight;
+
+    // --- 静态绘制配置 ---
+    const double cornerRadius = 8.0;
+    const double strokeWidth = 1.0; // 拼图槽位的线宽
+    final double halfStroke = strokeWidth / 2.0;
+
+    // 1. 绘制整个屏幕背景
     canvas.drawRect(
       Rect.fromLTWH(0, 0, size.width, size.height),
       Paint()
@@ -105,36 +309,48 @@ class BoardPainter extends CustomPainter {
         ..style = PaintingStyle.fill,
     );
 
-    for (final piece in board.pieces) {
-      canvas.save();
+    // 2. 绘制每个拼图槽位(圆角矩形)作为背景的一部分 (静态内容)
+    final slotFillPaint = Paint()
+      ..color = Colors.green.withAlpha(alpha)
+      ..style = PaintingStyle.fill;
 
-      // 转换 Matrix4 存储格式 (Float32List -> Float64List)
-      final Float64List storage64 = Float64List.fromList(piece.transform.storage);
+    final slotStrokePaint = Paint()
+      ..color = Color(0xff26600c).withAlpha(alpha)
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = strokeWidth;
 
-      // 应用碎片的几何变换 (平移/旋转/缩放)
-      // 这将 Canvas 原点移动到 piece.transform.storage[12] 和 [13] 处。
-      canvas.transform(storage64);
+    for (int r = 0; r < board.rows; r++) {
+      for (int c = 0; c < board.cols; c++) {
+        // 计算当前槽位的边界 (Canvas坐标系)
+        final left = targetRect.left + c * pieceLogicalWidth;
+        final top = targetRect.top + r * pieceLogicalHeight;
+        final right = left + pieceLogicalWidth;
+        final bottom = top + pieceLogicalHeight;
 
-      final Rect rect = Rect.fromLTWH(0, 0, board.pieceLogicalWidth, board.pieceLogicalHeight);
+        // 为了避免描边溢出到相邻槽位,将矩形向内收缩 halfStroke
+        final slotRect = Rect.fromLTRB(left + halfStroke, top + halfStroke, right - halfStroke, bottom - halfStroke);
 
-      final RRect rrect = RRect.fromRectAndRadius(rect, Radius.circular(8.0));
+        final slotRRect = RRect.fromRectAndRadius(slotRect, const Radius.circular(cornerRadius));
 
-      canvas.drawRRect(
-        rrect,
-        Paint()
-          ..color = Colors.green
-          ..style = PaintingStyle.fill,
-      );
+        // 绘制填充
+        canvas.drawRRect(slotRRect, slotFillPaint);
 
-      canvas.drawRRect(
-        rrect,
-        Paint()
-          ..color = Colors.blueGrey
-          ..style = PaintingStyle.stroke
-          ..strokeWidth = 1.0,
-      );
+        // 绘制描边
+        canvas.drawRRect(slotRRect, slotStrokePaint);
+      }
+    }
+  }
 
-      canvas.restore();
+  @override
+  bool shouldRepaint(covariant BoardPainter oldDelegate) {
+    // 检查不可变的核心数据是否发生变化
+    if (oldDelegate.board != board || oldDelegate.prepareAnimation != prepareAnimation) {
+      return true;
     }
+
+    // 如果 board 和 animation controller 引用没有变化,
+    // 由于构造函数中已经添加了监听器,理论上只需要在监听器触发时重绘。
+    // 但为了确保,可以比较 board 的状态,因为它决定了执行哪个绘制逻辑。
+    return oldDelegate.board.status != board.status;
   }
 }

+ 199 - 11
lib/play/board_play.dart

@@ -1,3 +1,4 @@
+import 'dart:math';
 import 'dart:typed_data';
 
 import 'package:flutter/material.dart';
@@ -49,16 +50,29 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
 
   // 动画控制器
   late AnimationController _moveAnimationController; // 移动动画(位移)
-  late AnimationController _mergeAnimationController; // merge动画(scale)
 
+  late AnimationController _mergeAnimationController; // merge动画(scale)
   // merge 动画的缩放值
   late Animation<double> _mergeScaleAnimation;
+  List<PieceGroup>? _mergeGroups; // 记录当前merge的group
+
+  late AnimationController _prepareAnimationController; // 预备动画, Opacity透明动画展示核心绘制区
+
+  late AnimationController dealingAnimationController; // 发牌动画
+  late AnimationController flipAnimationController; // 翻牌动画
 
-  List<PieceGroup>? _mergeGroups;
+  // 发牌动画相关
+  late Animation<double> _dealingAnimation;
+  // 发牌动画参数
+  List<double> _pieceStartTimes = []; // 每个卡片的启动时间(单位:ms)
+  List<double> _pieceDurations = []; // 每个卡片的移动持续时间(单位:ms)
+  double _totalDealingDuration = 0; // 发牌动画总时长(ms)
 
   @override
   initState() {
     super.initState();
+
+    // 初始化移动动画,在dragging结束松手后的swap或evert操作都需要用到移动
     _moveAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
     _moveAnimationController.addListener(_moveAnimationListener);
     _moveAnimationController.addStatusListener(_moveAnimationStatusListener);
@@ -67,7 +81,6 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     _mergeAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300)); // 0.4s for scale up/down
     _mergeAnimationController.addListener(_mergeAnimationListener);
     _mergeAnimationController.addStatusListener(_mergeAnimationStatusListener);
-
     // 缩放值从 1.0 -> 1.1 -> 1.0 (使用 TweenSequence 实现放大再缩小)
     _mergeScaleAnimation =
         TweenSequence<double>([
@@ -81,7 +94,131 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
           ),
         );
 
-    init();
+    _prepareAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 800));
+    _prepareAnimationController.addListener(_prepareAnimationListener);
+    _prepareAnimationController.addStatusListener(_prepareAnimationStatusListener);
+
+    // 初始化发牌动画
+    dealingAnimationController = AnimationController(vsync: this);
+    _dealingAnimation = CurvedAnimation(parent: dealingAnimationController, curve: Curves.linear);
+    dealingAnimationController.addListener(_dealingAnimationListener);
+    dealingAnimationController.addStatusListener(_dealingAnimationStatusListener);
+
+    // 初始化翻转动画
+    flipAnimationController = AnimationController(vsync: this, duration: Duration(milliseconds: 1000));
+    flipAnimationController.addListener(_flipAnimationListener);
+    flipAnimationController.addStatusListener(_flipAnimationStatusListener);
+
+    _init();
+  }
+
+  // 初始化发牌时间点
+  void _initDealTimes() {
+    _pieceStartTimes.clear();
+    _pieceDurations.clear();
+
+    // 3. 计算动画参数(速度恒定)
+    const double interval = 120; // 发牌间隔(ms):每张牌间隔50ms发出
+    const double maxDuration = 500; // 最长移动时间(ms):距离最远的卡片用1000ms
+
+    // 为每个卡片计算启动时间和持续时间
+    for (int i = 0; i < board!.pieces.length; i++) {
+      // final duration = maxDuration - i * interval;
+      // 启动时间:第1张0ms,第2张50ms,第3张100ms...)
+      final startTime = i * interval;
+
+      _pieceStartTimes.add(startTime);
+      _pieceDurations.add(maxDuration);
+    }
+
+    // 总动画时长 = 最后一张卡片的启动时间 + 其持续时间
+    _totalDealingDuration = _pieceStartTimes.last + _pieceDurations.last;
+
+    // 更新动画控制器时长
+    dealingAnimationController.duration = Duration(milliseconds: _totalDealingDuration.round());
+  }
+
+  void _dealingAnimationListener() {
+    if (board == null) return;
+
+    // 当前动画已运行的时间(ms)
+    final currentTime = _dealingAnimation.value * _totalDealingDuration;
+
+    // 逐个更新卡片位置(最后一张不需要动)
+    for (int i = 0; i < board!.pieces.length - 1; i++) {
+      final piece = board!.pieces[i];
+      final startTime = _pieceStartTimes[i];
+      final duration = _pieceDurations[i];
+
+      // 尚未到启动时间:保持在起点
+      if (currentTime < startTime) {
+        continue;
+      }
+
+      // 计算移动进度(0~1):已移动时间 / 总持续时间
+      double progress = (currentTime - startTime) / duration;
+      progress = progress.clamp(0.0, 1.0); // 限制进度不超过1(防止超调)
+
+      // 计算当前位置(起点到终点的插值)
+      final startTransform = board!.getBottomRightTransform();
+      final endTransform = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
+      final tween = Matrix4Tween(begin: startTransform, end: endTransform);
+      piece.transform = tween.lerp(progress);
+    }
+
+    board!.invalidate();
+  }
+
+  // 发牌动画状态监听器
+  void _dealingAnimationStatusListener(AnimationStatus status) {
+    if (status == AnimationStatus.completed) {
+      board!.resetAllPieces();
+
+      board!.shuffle(ShuffleStep.flipping);
+      flipAnimationController.forward(from: 0.0);
+    }
+  }
+
+  // 翻转动画监听器
+  void _flipAnimationListener() {
+    if (board == null) return;
+
+    final flipValue = flipAnimationController.value;
+    for (final piece in board!.pieces) {
+      // 1. 计算翻转角度(0→π,180度翻转)
+      final angle = flipValue * pi;
+
+      // 2. 获取卡片的固定目标位置(基于curRow/curCol,不依赖动态transform)
+      final targetTranslate = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
+
+      // 3. 执行翻转动画(传入固定目标位置)
+      piece.updateFlipTransform(angle, targetTranslate);
+    }
+
+    board!.invalidate();
+  }
+
+  // 翻转动画状态监听器
+  void _flipAnimationStatusListener(AnimationStatus status) {
+    if (status == AnimationStatus.completed) {
+      // 翻转完成,开始游戏
+      board!.resetAllPieces();
+      board!.rebuildAllGroups();
+
+      // 检查是否初始化就已经merge的group
+      final mergeGroups = board!.compareAllGroups();
+
+      // 有新的区块合成,执行 merge 动画,稍稍放大然后再复原
+      if (mergeGroups.isNotEmpty) {
+        _log.info('Merge animation start for ${mergeGroups.length} groups.');
+
+        // 启动 Merge 动画
+        _mergeGroups = mergeGroups;
+        _mergeAnimationController.forward(from: 0.0);
+      }
+
+      board!.start();
+    }
   }
 
   // 关键修正:动画监听器,只注册一次
@@ -192,17 +329,32 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     }
   }
 
-  init() async {
+  void _prepareAnimationListener() {
+    board!.invalidate();
+  }
+
+  // prepare动画结束,进入洗牌动画
+  void _prepareAnimationStatusListener(AnimationStatus status) {
+    if (status == AnimationStatus.completed) {
+      _initDealTimes();
+      board!.setAllPieceToBottomRight();
+      board!.shuffle(ShuffleStep.dealing);
+      dealingAnimationController.forward(from: 0.0);
+    }
+  }
+
+  _init() async {
     Device device = context.read<Device>();
 
     setState(() {
       _isLoading = true;
     });
 
+    final dpr = device.devicePixelRatio;
     final targetRect = device.targetRect;
     final bestImageSize = device.bestImageSize;
 
-    // TODO: 替换为实际的图片加载逻辑
+    // 加载图片,后续改为从远程服务器加载, 目前demo从本地assets读取
     final ByteData data = await rootBundle.load('assets/images/test.jpeg');
     final ui.Codec codec = await ui.instantiateImageCodec(
       data.buffer.asUint8List(),
@@ -212,9 +364,22 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     final ui.FrameInfo frameInfo = await codec.getNextFrame();
     final image = frameInfo.image;
 
-    board = Board(this, image, widget.cols, widget.rows, targetRect, device);
-    board!.start(); // 游戏开始
+    // 加载扑克背面图片,用于制作发牌动画
+    final Size bestCardImageSize = Size(targetRect.width * dpr / widget.rows, targetRect.height * dpr / widget.cols);
+    final ByteData cardData = await rootBundle.load('assets/images/backcard.png');
+    final ui.Codec cardCodec = await ui.instantiateImageCodec(
+      cardData.buffer.asUint8List(),
+      targetWidth: bestCardImageSize.width.round(),
+      targetHeight: bestCardImageSize.height.round(),
+    );
+    final ui.FrameInfo cardFrameInfo = await cardCodec.getNextFrame();
+    final cardImage = cardFrameInfo.image;
+
+    board = Board(this, image, cardImage, widget.rows, widget.cols, targetRect, device);
+    board!.prepare();
+    _prepareAnimationController.forward(from: 0.0);
 
+    if (!mounted) return;
     setState(() {
       _isLoading = false;
     });
@@ -236,6 +401,18 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     _mergeAnimationController.removeStatusListener(_mergeAnimationStatusListener);
     _mergeAnimationController.dispose();
 
+    _prepareAnimationController.removeListener(_prepareAnimationListener);
+    _prepareAnimationController.removeStatusListener(_prepareAnimationStatusListener);
+    _prepareAnimationController.dispose();
+
+    dealingAnimationController.removeListener(_dealingAnimationListener);
+    dealingAnimationController.removeStatusListener(_dealingAnimationStatusListener);
+    dealingAnimationController.dispose();
+
+    flipAnimationController.removeListener(_flipAnimationListener);
+    flipAnimationController.removeStatusListener(_flipAnimationStatusListener);
+    flipAnimationController.dispose();
+
     board?.dispose();
     super.dispose();
   }
@@ -260,7 +437,18 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
               child: const Text('Banner 广告区域', style: TextStyle(fontSize: 12)),
             ),
           ),
-          if (_isLoading) const Positioned.fill(child: Center(child: CircularProgressIndicator())),
+          if (_isLoading)
+            Positioned.fill(
+              child: Container(
+                color: Colors.lightGreen, // 填充绿色背景(可根据需要调整色值,如 Colors.green.shade100 浅绿)
+                child: const Center(
+                  child: CircularProgressIndicator(
+                    // 可选:调整进度条颜色,与绿色背景对比更明显
+                    valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
+                  ),
+                ),
+              ),
+            ),
         ],
       ),
     );
@@ -280,7 +468,7 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
 
   Widget _buildPuzzleCanvas(double width, double height) {
     return CustomPaint(
-      painter: BoardPainter(board: board!),
+      painter: BoardPainter(board: board!, prepareAnimation: _prepareAnimationController),
       size: Size(width, height),
       child: GestureDetector(
         key: boardKey,
@@ -418,7 +606,7 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     }
   }
 
-  // 关键重构:为所有涉及移动的 piece 创建独立的 MoveItem
+  // 为所有涉及移动的 piece 创建独立的 MoveItem
   void _animateSwap(Piece leaderPiece, Piece targetPiece) {
     List<MoveItem> items = [];
 

+ 514 - 86
lib/play/piece.dart

@@ -9,17 +9,28 @@ import 'package:vector_math/vector_math.dart' as vmath;
 
 final Logger _log = Logger('piece.dart');
 
+const double _cornerRadius = 8.0;
+const double _outLineOffset = 0.5;
+const double _innerLineOffset = 1.5;
+
 // piece分组数据结构
 class PieceGroup {
   List<Piece> pieces = [];
 
   int get length => pieces.length;
 
+  // 群组的组合路径缓存 (路径是相对坐标,相对于群组基准点)
+  Path? groupClipPath;
+  Path? groupOutLinePath;
+  Path? groupInnerLinePath;
+
   void add(Piece piece) {
     if (!pieces.contains(piece)) {
       pieces.add(piece);
     }
+    // 确保 piece 的 group 指向自己
     piece.group = this;
+    clearPathCache(); // 群组变化,清空缓存
   }
 
   void remove(Piece piece) {
@@ -27,6 +38,7 @@ class PieceGroup {
       pieces.remove(piece);
     }
     piece.group = null;
+    clearPathCache(); // 群组变化,清空缓存
   }
 
   bool contains(Piece piece) {
@@ -43,26 +55,28 @@ class PieceGroup {
     return true;
   }
 
-  // 群组中心点
+  // 网格坐标边界计算辅助属性
+  int get _minR => pieces.map((p) => p.curRow).reduce(min);
+  int get _maxR => pieces.map((p) => p.curRow).reduce(max);
+  int get _minC => pieces.map((p) => p.curCol).reduce(min);
+  int get _maxC => pieces.map((p) => p.curCol).reduce(max);
+  // -----------------------------
+
+  // 群组中心点(绝对坐标)
   Offset get center {
     if (pieces.isEmpty) return Offset.zero;
 
     final board = pieces[0].board;
-
-    // 计算群组在Canvas中的边界框
-    double minX = double.infinity;
-    double minY = double.infinity;
-    double maxX = -double.infinity;
-    double maxY = -double.infinity;
+    double minX = double.infinity, minY = double.infinity;
+    double maxX = -double.infinity, maxY = -double.infinity;
 
     for (var piece in pieces) {
-      final transform = piece.transform;
-      final x = transform.storage[12]; // 碎片左上角x
-      final y = transform.storage[13]; // 碎片左上角y
+      // 碎片当前在 Canvas 上的左上角坐标
+      final x = piece.transform.storage[12];
+      final y = piece.transform.storage[13];
       final w = board.pieceLogicalWidth;
       final h = board.pieceLogicalHeight;
 
-      // 更新边界
       minX = min(minX, x);
       minY = min(minY, y);
       maxX = max(maxX, x + w);
@@ -73,6 +87,416 @@ class PieceGroup {
     return Offset((minX + maxX) / 2, (minY + maxY) / 2);
   }
 
+  // 计算群组的左上角碎片(以 curRow/curCol 为准)
+  Piece get topLeftPiece {
+    if (pieces.isEmpty) throw Exception("Group is empty");
+
+    // 找到最上面,然后最左边的格子作为起点
+    return pieces.reduce((a, b) {
+      if (a.curRow < b.curRow) return a;
+      if (b.curRow < a.curRow) return b;
+      if (a.curCol < b.curCol) return a;
+      return b;
+    });
+  }
+
+  // --- 新增辅助方法:用于不规则群组路径追踪 ---
+
+  // 检查指定网格坐标 (r, c) 是否有碎片属于本群组
+  // r, c 是绝对网格坐标 (curRow, curCol)
+  bool _pieceAtCurCoord(int r, int c) {
+    if (r < 0 || c < 0) return false;
+
+    // 只需要检查群组内的碎片
+    for (var piece in pieces) {
+      if (piece.curRow == r && piece.curCol == c) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  // 检查 (r, c) 单元格的指定边是否是群组的外部边界
+  // r, c 是绝对网格坐标 (curRow, curCol)
+  bool _isExternalGroupBoundary(int r, int c, String side) {
+    if (!_pieceAtCurCoord(r, c)) {
+      return false; // 该坐标没有碎片属于这个群组,不可能是边界
+    }
+
+    final board = pieces[0].board;
+    switch (side) {
+      case 'top':
+        if (r == 0) return true; // Board top
+        return !_pieceAtCurCoord(r - 1, c); // 上邻居不在群组内
+      case 'right':
+        if (c == board.cols - 1) return true; // Board right
+        return !_pieceAtCurCoord(r, c + 1); // 右邻居不在群组内
+      case 'bottom':
+        if (r == board.rows - 1) return true; // Board bottom
+        return !_pieceAtCurCoord(r + 1, c); // 下邻居不在群组内
+      case 'left':
+        if (c == 0) return true; // Board left
+        return !_pieceAtCurCoord(r, c - 1); // 左邻居不在群组内
+      default:
+        return false;
+    }
+  }
+
+  // 生成群组整体路径(裁剪/边框的核心逻辑)
+  Path _generateIrregularGroupPath({required bool isClipPath, double offset = 0}) {
+    if (pieces.isEmpty) return Path();
+
+    final path = Path();
+    final board = pieces[0].board;
+    final w = board.pieceLogicalWidth;
+    final h = board.pieceLogicalHeight;
+    final radius = _cornerRadius;
+
+    final r = radius; // 使用原始半径,偏移量通过坐标实现
+
+    // 1. 确定起始 Piece 和起始点
+    Piece startPiece = topLeftPiece;
+    Piece loopPiece = startPiece;
+
+    // 群组本地坐标系下的起始点 (top-left corner of the top-left piece)
+    final startRelX = (startPiece.curCol - _minC) * w;
+    final startRelY = (startPiece.curRow - _minR) * h;
+
+    // 决定起点 MoveTo 坐标 (Group Local)
+    double initialX = startRelX + offset;
+    double initialY = startRelY + offset;
+
+    // 初始方向:向右 (Tracing Top edge)
+    String direction = 'right';
+
+    final hasStartTop = _isExternalGroupBoundary(startPiece.curRow, startPiece.curCol, 'top');
+    final hasStartLeft = _isExternalGroupBoundary(startPiece.curRow, startPiece.curCol, 'left');
+
+    if (hasStartTop && hasStartLeft) {
+      path.moveTo(initialX + r, initialY);
+      initialX += r;
+    } else if (hasStartTop) {
+      path.moveTo(initialX, initialY);
+    } else if (hasStartLeft) {
+      // 这种情况意味着左边是外部边界,但上面是内部边界,路径起点在左边中央
+      path.moveTo(initialX, initialY + r);
+      initialY += r;
+    } else {
+      // TL 角是内部的,但 topLeftPiece 保证是群组最左上角,因此至少有一条边是外部边界
+      // 如果两边都是内部,说明群组内部有空隙,但此处假设群组是连通的。
+      path.moveTo(initialX, initialY);
+    }
+
+    // 当前路径点 (Group Local)
+    double currentX = initialX;
+    double currentY = initialY;
+
+    // Safety break
+    int maxIterations = pieces.length * 8; // 增加安全上限
+    int iteration = 0;
+
+    // --- 路径追踪循环 (Wall Follower - Keep Group on Left) ---
+    do {
+      iteration++;
+      if (iteration > maxIterations) {
+        _log.severe('Group path tracing failed to close after $maxIterations iterations. Breaking.');
+        break;
+      }
+
+      final absR = loopPiece.curRow;
+      final absC = loopPiece.curCol;
+      final relC = absC - _minC;
+      final relR = absR - _minR;
+
+      // 当前 Piece 的边界坐标 (Group Local) - 包含偏移
+      final x0 = relC * w + offset; // Left
+      final y0 = relR * h + offset; // Top
+      final x1 = (relC + 1) * w - offset; // Right
+      final y1 = (relR + 1) * h - offset; // Bottom
+
+      String nextDirection = '';
+      Piece? nextPiece;
+      bool isCornerRounded = false;
+
+      // 检查当前方向的边是否是外部边界
+      bool isExternalEdge = false;
+      switch (direction) {
+        case 'right':
+          isExternalEdge = _isExternalGroupBoundary(absR, absC, 'top');
+          break;
+        case 'down':
+          isExternalEdge = _isExternalGroupBoundary(absR, absC, 'right');
+          break;
+        case 'left':
+          isExternalEdge = _isExternalGroupBoundary(absR, absC, 'bottom');
+          break;
+        case 'up':
+          isExternalEdge = _isExternalGroupBoundary(absR, absC, 'left');
+          break;
+      }
+
+      if (isExternalEdge) {
+        // --- 1. Edge Segment Drawing ---
+        if (direction == 'right') {
+          // Tracing Top edge to TR corner (x1, y0)
+          final hasR = _isExternalGroupBoundary(absR, absC, 'right');
+          isCornerRounded = hasR;
+          final targetX = x1;
+          final targetY = y0;
+
+          // LineTo segment
+          final lineToX = isCornerRounded ? targetX - r : targetX;
+          if (currentX < lineToX) {
+            // 避免重复 LineTo
+            path.lineTo(lineToX, targetY);
+            currentX = lineToX;
+            currentY = targetY;
+          }
+
+          // Arc for TR corner
+          if (isCornerRounded) {
+            path.arcToPoint(Offset(targetX, targetY + r), radius: Radius.circular(r), clockwise: true);
+            currentX = targetX;
+            currentY = targetY + r;
+          } else {
+            currentX = targetX;
+            currentY = targetY;
+          }
+
+          // --- 2. Decision: Turn Down or Move Right ---
+          nextPiece = board.getPieceByCoordinate(absR, absC + 1);
+          bool rightNeighborInGroup = nextPiece != null && pieces.contains(nextPiece);
+
+          if (hasR) {
+            // Right edge exposed -> TURN DOWN (Stay on loopPiece)
+            nextDirection = 'down';
+          } else if (rightNeighborInGroup) {
+            // Right edge internal -> MOVE RIGHT (Change loopPiece)
+            loopPiece = nextPiece!;
+            nextDirection = 'right';
+          } else {
+            // Board boundary or outer space (R is external) -> TURN DOWN (This case is covered by hasR=true, but as a fallback)
+            nextDirection = 'down';
+          }
+        } else if (direction == 'down') {
+          // Tracing Right edge to BR corner (x1, y1)
+          final hasB = _isExternalGroupBoundary(absR, absC, 'bottom');
+          isCornerRounded = hasB;
+          final targetX = x1;
+          final targetY = y1;
+
+          // LineTo segment
+          final lineToY = isCornerRounded ? targetY - r : targetY;
+          if (currentY < lineToY) {
+            path.lineTo(targetX, lineToY);
+            currentX = targetX;
+            currentY = lineToY;
+          }
+
+          // Arc for BR corner
+          if (isCornerRounded) {
+            path.arcToPoint(Offset(targetX - r, targetY), radius: Radius.circular(r), clockwise: true);
+            currentX = targetX - r;
+            currentY = targetY;
+          } else {
+            currentX = targetX;
+            currentY = targetY;
+          }
+
+          // --- 2. Decision: Turn Left or Move Down ---
+          nextPiece = board.getPieceByCoordinate(absR + 1, absC);
+          bool bottomNeighborInGroup = nextPiece != null && pieces.contains(nextPiece);
+
+          if (hasB) {
+            // Bottom edge exposed -> TURN LEFT
+            nextDirection = 'left';
+          } else if (bottomNeighborInGroup) {
+            // Bottom edge internal -> MOVE DOWN
+            loopPiece = nextPiece!;
+            nextDirection = 'down';
+          } else {
+            nextDirection = 'left';
+          }
+        } else if (direction == 'left') {
+          // Tracing Bottom edge to BL corner (x0, y1)
+          final hasL = _isExternalGroupBoundary(absR, absC, 'left');
+          isCornerRounded = hasL;
+          final targetX = x0;
+          final targetY = y1;
+
+          // LineTo segment
+          final lineToX = isCornerRounded ? targetX + r : targetX;
+          if (currentX > lineToX) {
+            path.lineTo(lineToX, targetY);
+            currentX = lineToX;
+            currentY = targetY;
+          }
+
+          // Arc for BL corner
+          if (isCornerRounded) {
+            path.arcToPoint(Offset(targetX, targetY - r), radius: Radius.circular(r), clockwise: true);
+            currentX = targetX;
+            currentY = targetY - r;
+          } else {
+            currentX = targetX;
+            currentY = targetY;
+          }
+
+          // --- 2. Decision: Turn Up or Move Left ---
+          nextPiece = board.getPieceByCoordinate(absR, absC - 1);
+          bool leftNeighborInGroup = nextPiece != null && pieces.contains(nextPiece);
+
+          if (hasL) {
+            // Left edge exposed -> TURN UP
+            nextDirection = 'up';
+          } else if (leftNeighborInGroup) {
+            // Left edge internal -> MOVE LEFT
+            loopPiece = nextPiece!;
+            nextDirection = 'left';
+          } else {
+            nextDirection = 'up';
+          }
+        } else if (direction == 'up') {
+          // Tracing Left edge to TL corner (x0, y0)
+          final hasT = _isExternalGroupBoundary(absR, absC, 'top');
+          isCornerRounded = hasT;
+          final targetX = x0;
+          final targetY = y0;
+
+          // LineTo segment
+          final lineToY = isCornerRounded ? targetY + r : targetY;
+          if (currentY > lineToY) {
+            path.lineTo(targetX, lineToY);
+            currentX = targetX;
+            currentY = lineToY;
+          }
+
+          // Arc for TL corner
+          if (isCornerRounded) {
+            path.arcToPoint(Offset(targetX + r, targetY), radius: Radius.circular(r), clockwise: true);
+            currentX = targetX + r;
+            currentY = targetY;
+          } else {
+            currentX = targetX;
+            currentY = targetY;
+          }
+
+          // --- 2. Decision: Turn Right or Move Up ---
+          nextPiece = board.getPieceByCoordinate(absR - 1, absC);
+          bool topNeighborInGroup = nextPiece != null && pieces.contains(nextPiece);
+
+          if (hasT) {
+            // Top edge exposed -> TURN RIGHT
+            nextDirection = 'right';
+          } else if (topNeighborInGroup) {
+            // Top edge internal -> MOVE UP
+            loopPiece = nextPiece!;
+            nextDirection = 'up';
+          } else {
+            nextDirection = 'right';
+          }
+        }
+      } else {
+        // --- Edge is Internal: Move Straight or Turn Right ---
+        // This piece's current edge is internal, meaning the external boundary is on the neighbor's side.
+        // We must check if we can move *through* the current piece to the next one in the same direction,
+        // or if we must turn right immediately because the straight path is blocked by an external boundary.
+
+        // Next piece to check (straight ahead)
+        Piece? straightPiece;
+        switch (direction) {
+          case 'right':
+            straightPiece = board.getPieceByCoordinate(absR, absC + 1);
+            break;
+          case 'down':
+            straightPiece = board.getPieceByCoordinate(absR + 1, absC);
+            break;
+          case 'left':
+            straightPiece = board.getPieceByCoordinate(absR, absC - 1);
+            break;
+          case 'up':
+            straightPiece = board.getPieceByCoordinate(absR - 1, absC);
+            break;
+        }
+
+        bool straightInGroup = straightPiece != null && pieces.contains(straightPiece);
+
+        if (straightInGroup) {
+          // Straight piece is in group -> MOVE STRAIGHT (Skip current internal piece)
+          loopPiece = straightPiece!;
+          nextDirection = direction;
+        } else {
+          // Straight piece is outside/null, and current edge is internal.
+          // This is an internal concave corner. We must turn right to follow the external boundary.
+          // Turn right (change direction, stay on current piece)
+          switch (direction) {
+            case 'right':
+              nextDirection = 'down';
+              break;
+            case 'down':
+              nextDirection = 'left';
+              break;
+            case 'left':
+              nextDirection = 'up';
+              break;
+            case 'up':
+              nextDirection = 'right';
+              break;
+          }
+        }
+      }
+
+      // Update direction and loopPiece for next iteration
+      direction = nextDirection;
+
+      // --- Termination Check ---
+      if (currentX == initialX && currentY == initialY) {
+        _log.info('Closed path at initial point. Iterations: $iteration');
+        break;
+      }
+    } while (true);
+
+    if (isClipPath) {
+      path.close();
+    }
+
+    return path;
+  }
+
+  // 生成群组裁剪路径(现在支持不规则形状)
+  Path _generateGroupClipPath() {
+    // 裁剪路径是封闭的,且没有偏移
+    return _generateIrregularGroupPath(isClipPath: true);
+  }
+
+  // 生成群组边框路径(现在支持不规则形状)
+  Path _generateGroupBorderPath(double offset) {
+    // 边框路径是开放的(不 close),且有偏移
+    return _generateIrregularGroupPath(isClipPath: false, offset: offset);
+  }
+
+  // 生成群组整体路径(裁剪+边框)
+  void generateGroupPaths() {
+    // 使用新的不规则路径生成逻辑
+    groupClipPath = _generateGroupClipPath();
+    groupOutLinePath = _generateGroupBorderPath(_outLineOffset);
+    groupInnerLinePath = _generateGroupBorderPath(_innerLineOffset);
+  }
+
+  // 群组整体位移(优化:避免遍历碎片)
+  void applyDelta(Offset delta) {
+    for (var piece in pieces) {
+      piece.transform.storage[12] += delta.dx;
+      piece.transform.storage[13] += delta.dy;
+    }
+  }
+
+  void clearPathCache() {
+    groupClipPath = null;
+    groupOutLinePath = null;
+    groupInnerLinePath = null;
+  }
+
   void print() {
     String str = '======= group size: $length =======\n';
     for (var p in pieces) {
@@ -87,9 +511,7 @@ class PieceGroup {
 // 碎片基础数据结构
 class Piece {
   final int index;
-
-  final Board board; // 保存board的引用
-
+  final Board board;
   PieceGroup? group;
 
   // 总计的行数和列数
@@ -113,10 +535,6 @@ class Piece {
   // 碎片在原图片中的裁剪矩形 (Source Rect of the image)
   final Rect sourceRect;
 
-  // 5. 碎片周围四个边的拼接状态 (用于动态绘制内部边框)
-  // [Top, Right, Bottom, Left]
-  List<bool> borders; // 拟废弃,采用实时计算
-
   bool get isOK => row == curRow && col == curCol;
 
   // clipPath, 碎片的裁剪路径(闭合), 这是为了绘制出圆角效果
@@ -126,9 +544,8 @@ class Piece {
   // 外边框path (可能非闭合)
   Path? outLinePath;
 
-  static const double _cornerRadius = 8.0;
-  static const double _outLineOffset = 0.5;
-  static const double _innerLineOffset = 1.5;
+  // 翻转相关属性
+  double flipProgress = 0.0;
 
   Piece({
     required this.board,
@@ -142,13 +559,10 @@ class Piece {
     required this.curCol,
     required this.curRow,
     required this.transform,
-    required this.borders, // 初始设置为 [true, true, true, true]
   });
 
   @override
-  String toString() {
-    return 'Piece($index,$row:$col)';
-  }
+  String toString() => 'Piece($index,$row:$col)';
 
   double get width => board.pieceLogicalWidth;
   double get height => board.pieceLogicalHeight;
@@ -160,12 +574,12 @@ class Piece {
   // 辅助函数:获取碎片当前的中心点 (在 Canvas 坐标系中)
   Offset get currentCenter {
     // 碎片的本地中心点
-    final Offset pieceLocalCenter = Offset(board.pieceLogicalWidth / 2, board.pieceLogicalHeight / 2);
+    final Offset localCenter = Offset(width / 2, height / 2);
 
     // 应用当前变换矩阵
-    final vmath.Vector4 transformedVector = transform.transform(vmath.Vector4(pieceLocalCenter.dx, pieceLocalCenter.dy, 0.0, 1.0));
+    final vmath.Vector4 transformed = transform.transform(vmath.Vector4(localCenter.dx, localCenter.dy, 0.0, 1.0));
 
-    return Offset(transformedVector.x, transformedVector.y);
+    return Offset(transformed.x, transformed.y);
   }
 
   // 辅助函数:将碎片移动指定的位移量
@@ -178,8 +592,7 @@ class Piece {
 
   // 归位, 回到原来的位置
   void revert() {
-    final originalTransform = board.getTransformByCoordinate(curRow, curCol);
-    transform = originalTransform;
+    transform = board.getTransformByCoordinate(curRow, curCol);
   }
 
   // 判断当前piece是否可以安置到other的槽位去
@@ -197,11 +610,8 @@ class Piece {
     for (var p in group!.pieces) {
       int newRow = p.curRow + dr;
       int newCol = p.curCol + dc;
-      if (newRow < 0 || newRow >= rows || newCol < 0 || newCol >= cols) {
-        return false;
-      }
+      if (newRow < 0 || newRow >= rows || newCol < 0 || newCol >= cols) return false;
     }
-
     return true;
   }
 
@@ -229,9 +639,15 @@ class Piece {
     if (other.group != null) list.addAll(other.group!.pieces);
     list.add(this);
     list.add(other);
-    for (var p in list) {
+
+    // 确保没有重复的 Piece
+    final Set<Piece> uniquePieces = list.toSet();
+
+    for (var p in uniquePieces) {
       finalGroup.add(p);
     }
+    // 合并后需要重新计算 group paths
+    finalGroup.generateGroupPaths();
   }
 
   // 获取碎片本来的邻居(原正确位置上的邻居)
@@ -244,22 +660,15 @@ class Piece {
     return list;
   }
 
-  /// 是否同一个组
-  bool isSameGroup(Piece other) {
-    return group != null && group == other.group;
-  }
+  bool isSameGroup(Piece other) => group != null && group == other.group;
+  bool isNeighbour(Piece other) => row == other.row && (col - other.col).abs() == 1 || col == other.col && (row - other.row).abs() == 1;
+  bool isCurNeighbour(Piece other) =>
+      curRow == other.curRow && (curCol - other.curCol).abs() == 1 || curCol == other.curCol && (curRow - other.curRow).abs() == 1;
 
-  /// 是否邻居 (原始位置)
-  bool isNeighbour(Piece other) {
-    return row == other.row && (col - other.col).abs() == 1 || col == other.col && (row - other.row).abs() == 1;
-  }
+  // 以下四个边界检测逻辑:
+  // 仅当邻居在当前位置相邻,且它们的原图相对位置正确时,才移除边界(即边界被“吸收”)。
+  // 否则,需要绘制边界(群组内部边界或群组外部边界)。
 
-  // 当前位置是否是邻居
-  bool isCurNeighbour(Piece other) {
-    return curRow == other.curRow && (curCol - other.curCol).abs() == 1 || curCol == other.curCol && (curRow - other.curRow).abs() == 1;
-  }
-
-  // 是否有上边框
   bool get _hasTopBorder {
     int topCurRow = curRow - 1;
     int topCurCol = curCol;
@@ -269,15 +678,20 @@ class Piece {
 
     // 获取本碎片当前的上边邻居
     final topPiece = board.getPieceByCoordinate(topCurRow, topCurCol);
+
+    // 邻居缺失(空槽位),需要边框
     if (topPiece == null) {
       _log.warning('找不到 ${toString()} 的上邻居,有错误发生,请检查');
       return true;
     }
-    // 如果与上邻居的相对位置是正确的,那么没有上边框
+
+    // 如果它们在当前位置相邻,且原图相对位置正确,则边界被内部吸收,不需要绘制。
+    // 即:当前碎片(row)在邻居碎片(topPiece.row)的下方一个位置
     if (row == topPiece.row + 1 && col == topPiece.col) {
       return false;
     }
 
+    // 否则,需要绘制边界
     return true;
   }
 
@@ -339,7 +753,7 @@ class Piece {
       _log.warning('找不到 ${toString()} 的左邻居,有错误发生,请检查');
       return true;
     }
-    // 如果与下邻居的相对位置是正确的,那么没有下边框
+    // 如果与左邻居的相对位置是正确的,那么没有左边框
     if (row == leftPiece.row && col == leftPiece.col + 1) {
       return false;
     }
@@ -347,6 +761,22 @@ class Piece {
     return true;
   }
 
+  // 生成翻转变换矩阵(结合平移)
+  void updateFlipTransform(double flipAngle, vmath.Matrix4 targetTranslate) {
+    flipProgress = flipAngle;
+    final flipMatrix = vmath.Matrix4.identity()
+      ..translate(width / 2, height / 2) // 1. 移到卡片中心(旋转中心)
+      ..rotateY(flipAngle) // 2. Y轴旋转(翻转动画)
+      ..scale(-1.0, 1.0, 1.0) // 3. X轴缩放-1:抵消旋转带来的左右镜像
+      ..translate(-width / 2, -height / 2); // 4. 移回原位
+    transform = targetTranslate * flipMatrix;
+  }
+
+  // 判断是否显示正面
+  // 当flipProgress在[0, pi/2)(0~90 度)时:卡片未完全翻转,显示背面,isFlipped为false。
+  // 当flipProgress在[pi/2, pi](90~180 度)时:卡片已翻转到正面,isFlipped为true。
+  bool get isFlipped => flipProgress >= pi / 2;
+
   // 生成clip path
   Path _generateClipPath(double w, double h, List<bool> borders, double radius) {
     Path path = Path();
@@ -373,11 +803,7 @@ class Piece {
 
     // B. 右上角 (TR) - Arc
     if (trRounded) {
-      path.arcToPoint(
-        Offset(w, radius), // 终点 (w, radius)
-        radius: Radius.circular(radius),
-        clockwise: true, // 逆时针
-      );
+      path.arcToPoint(Offset(w, radius), radius: Radius.circular(radius), clockwise: true);
     }
 
     // C. 右边 (R) - LineTo
@@ -390,11 +816,7 @@ class Piece {
 
     // D. 右下角 (BR) - Arc
     if (brRounded) {
-      path.arcToPoint(
-        Offset(w - radius, h), // 终点 (w - radius, h)
-        radius: Radius.circular(radius),
-        clockwise: true, // 逆时针
-      );
+      path.arcToPoint(Offset(w - radius, h), radius: Radius.circular(radius), clockwise: true);
     }
 
     // E. 底边 (B) - LineTo
@@ -407,11 +829,7 @@ class Piece {
 
     // F. 左下角 (BL) - Arc
     if (blRounded) {
-      path.arcToPoint(
-        Offset(0, h - radius), // 终点 (0, h - radius)
-        radius: Radius.circular(radius),
-        clockwise: true, // 逆时针
-      );
+      path.arcToPoint(Offset(0, h - radius), radius: Radius.circular(radius), clockwise: true);
     }
 
     // G. 左边 (L) - LineTo
@@ -424,19 +842,14 @@ class Piece {
 
     // H. 左上角 (TL) - Arc & Close
     if (tlRounded) {
-      path.arcToPoint(
-        Offset(radius, 0), // 终点 (radius, 0),即起点
-        radius: Radius.circular(radius),
-        clockwise: true, // 逆时针
-      );
+      path.arcToPoint(Offset(radius, 0), radius: Radius.circular(radius), clockwise: true);
     }
-    // path.close() 确保路径闭合,但上面的逻辑已经把路径连回了起点。
-    path.close();
 
+    path.close();
     return path;
   }
 
-  // 生成border path (重构为开放路径,仅绘制需要的边)
+  // 生成单个碎片边框路径
   Path _generateBorderPath(double w, double h, List<bool> borders, double radius, double offset) {
     Path path = Path();
     final double r = radius;
@@ -469,14 +882,16 @@ class Piece {
       if (hasL) {
         path.moveTo(pTL_T.dx, pTL_T.dy); // 从TL弧的终点开始
       } else {
-        path.moveTo(x0, y0); // 从左上尖角开始
+        // path.moveTo(x0, y0); // 从左上尖角开始
+        path.moveTo(0, y0); // 从左上尖角开始
       }
 
       // 绘制 Top 直线段
       if (hasR) {
         path.lineTo(pTR_T.dx, pTR_T.dy);
       } else {
-        path.lineTo(x1, y0); // 到右上尖角
+        // path.lineTo(x1, y0); // 到右上尖角
+        path.lineTo(w, y0); // 到右上尖角
       }
     }
 
@@ -489,7 +904,8 @@ class Piece {
       if (hasT) {
         path.moveTo(pTR_R.dx, pTR_R.dy); // 从 TR 弧终点开始
       } else {
-        path.moveTo(x1, y0); // 从右上尖角开始
+        // path.moveTo(x1, y0); // 从右上尖角开始
+        path.moveTo(x1, 0); // 从右上尖角开始
       }
     }
 
@@ -498,7 +914,8 @@ class Piece {
       if (hasB) {
         path.lineTo(pBR_R.dx, pBR_R.dy);
       } else {
-        path.lineTo(x1, y1); // 到右下尖角
+        // path.lineTo(x1, y1); // 到右下尖角
+        path.lineTo(x1, h); // 到右下尖角
       }
     }
 
@@ -511,7 +928,8 @@ class Piece {
       if (hasR) {
         path.moveTo(pBR_B.dx, pBR_B.dy); // 从 BR 弧终点开始
       } else {
-        path.moveTo(x1, y1); // 从右下尖角开始
+        // path.moveTo(x1, y1); // 从右下尖角开始
+        path.moveTo(w, y1); // 从右下尖角开始
       }
     }
 
@@ -520,7 +938,8 @@ class Piece {
       if (hasL) {
         path.lineTo(pBL_B.dx, pBL_B.dy);
       } else {
-        path.lineTo(x0, y1); // 到左下尖角
+        // path.lineTo(x0, y1); // 到左下尖角
+        path.lineTo(0, y1); // 到左下尖角
       }
     }
 
@@ -533,7 +952,8 @@ class Piece {
       if (hasB) {
         path.moveTo(pBL_L.dx, pBL_L.dy); // 从 BL 弧终点开始
       } else {
-        path.moveTo(x0, y1); // 从左下尖角开始
+        // path.moveTo(x0, y1); // 从左下尖角开始
+        path.moveTo(x0, h); // 从左下尖角开始
       }
     }
 
@@ -542,7 +962,8 @@ class Piece {
       if (hasT) {
         path.lineTo(pTL_L.dx, pTL_L.dy);
       } else {
-        path.lineTo(x0, y0); // 到左上尖角
+        // path.lineTo(x0, y0); // 到左上尖角
+        path.lineTo(x0, 0); // 到左上尖角
       }
     }
 
@@ -557,12 +978,19 @@ class Piece {
   }
 
   // 生成碎片的clipPath, innerLinePath, outLinePath
-  List<Path> generatePaths() {
-    // _log.info('${toString()} generatePaths');
+  // 增加 forceRecalculate 参数,用于群组路径计算时强制重新计算边界状态
+  List<Path> generatePaths({bool forceRecalculate = false}) {
+    // 如果没有 group 且路径已缓存,则直接返回
+    if (group == null && !forceRecalculate && path != null && outLinePath != null && innerLinePath != null) {
+      return [path!, outLinePath!, innerLinePath!];
+    }
+
     // 先确定4条边的状态[Top, Right, Bottom, Left]
     List<bool> borders = [true, true, true, true];
-    // 如果是单个碎片,4条边都需要,只有piece在group中才需要判断
-    if (group != null) {
+
+    // 如果是单个碎片,4条边都需要,只有 piece 在 group 中才需要判断
+    if (group != null || forceRecalculate) {
+      // 即使是单独计算,也需要实时检查
       borders = [_hasTopBorder, _hasRightBorder, _hasBottomBorder, _hasLeftBorder];
     }