Explorar o código

1. 调大背景音乐;2.新手引导

guoziyun hai 6 meses
pai
achega
73bec7e5a2

+ 4 - 0
README.md

@@ -43,3 +43,7 @@ flutter build ipa --export-method ad-hoc --bundle-sksl-path flutter_01.sksl.json
 ```
 flutter build appbundle --release
 ```
+
+## 国际化
+
+flutter gen-l10n

BIN=BIN
assets/images/finger.png


+ 1 - 1
lib/audio/jc_audio_controller.dart

@@ -135,7 +135,7 @@ class JcAudioController {
       return;
     }
 
-    _audioPlayer.playMusic(1, volume: 0.1);
+    _audioPlayer.playMusic(1, volume: 0.3);
   }
 
   void stopMusic() {

+ 3 - 1
lib/homepage/home_screen.dart

@@ -64,6 +64,8 @@ class _HomeScreen extends State<HomeScreen> with TickerProviderStateMixin {
   void initState() {
     super.initState();
 
+    _log.info("首页初始化");
+
     device = context.read<Device>();
     audio = context.read<JcAudioController>();
     data = context.read<Data>();
@@ -127,7 +129,7 @@ class _HomeScreen extends State<HomeScreen> with TickerProviderStateMixin {
         // !!! 核心修改:只有在数据完整且最近网络请求成功时,才启动预加载
         if (isNetworkActive) {
           _log.info('Data sufficient AND Network Active. Starting preload.');
-          _preloadNextImages();
+          Future.delayed(const Duration(seconds: 3), () => _preloadNextImages());
         } else {
           // 数据完整,但来自缓存,网络状态未知,3秒后尝试刷新(refresh)
           _log.info('Data sufficient BUT Network status unknown/inactive. Attempting refresh in 3s.');

+ 1 - 0
lib/l10n/app_ar.arb

@@ -123,5 +123,6 @@
  "levelPass": "اجتياز المستوى!", 
  "backToHome": "العودة إلى الصفحة الرئيسية", 
  "hardMode": "الوضع الصعب", 
+ "moveToComplete": "حرك القطع لإكمال اللغز", 
   "collectionLocked": "لم يتم فتح هذا الرسم القابل للتحصيل بعد."
 }

+ 1 - 0
lib/l10n/app_de.arb

@@ -123,6 +123,7 @@
   "levelPass": "Level bestanden!",
   "backToHome": "Zur Startseite",
   "hardMode": "Schwieriger Modus",
+  "moveToComplete": "Karten bewegen, um das Puzzle abzuschließen",
   "collectionLocked": "Dieses Sammelbild ist noch gesperrt."
 }
 

+ 1 - 0
lib/l10n/app_en.arb

@@ -123,5 +123,6 @@
   "levelPass": "Level Passed!",
   "backToHome": "Back to Home",
   "hardMode": "Hard Mode",
+  "moveToComplete": "Move pieces to complete the puzzle",
   "collectionLocked": "This collectible image is not yet unlocked."
 }

+ 1 - 0
lib/l10n/app_es.arb

@@ -123,5 +123,6 @@
   "levelPass": "¡Nivel superado!",
   "backToHome": "Volver a la página de inicio",
   "hardMode": "Modo Difícil",
+  "moveToComplete": "Mueve las piezas para completar el puzle",
   "collectionLocked": "Esta imagen coleccionable aún no está desbloqueada."
 }

+ 1 - 0
lib/l10n/app_fr.arb

@@ -123,5 +123,6 @@
   "levelPass": "Niveau terminé !",
   "backToHome": "Retour à l'accueil",
   "hardMode": "Mode Difficile",
+  "moveToComplete": "Déplacez les pièces pour terminer le puzzle",
   "collectionLocked": "Cette image de collection n'est pas encore déverrouillée."
 }

+ 1 - 0
lib/l10n/app_ja.arb

@@ -123,5 +123,6 @@
   "levelPass": "レベルクリア!",
   "backToHome": "ホームに戻る",
   "hardMode": "ハードモード",
+  "moveToComplete": "ピースを動かしてパズルを完成させる",
   "collectionLocked": "このコレクション画像はまだロック解除されていません"
 }

+ 1 - 0
lib/l10n/app_ko.arb

@@ -123,5 +123,6 @@
   "levelPass": "레벨 통과!",
   "backToHome": "홈으로 돌아가기",
   "hardMode": "하드 모드",
+  "moveToComplete": "조각을 이동하여 퍼즐을 완성하세요",
   "collectionLocked": "이 수집 가능한 이미지는 아직 잠금 해제되지 않았습니다."
 }

+ 6 - 0
lib/l10n/app_localizations.dart

@@ -859,6 +859,12 @@ abstract class AppLocalizations {
   /// **'Hard Mode'**
   String get hardMode;
 
+  /// No description provided for @moveToComplete.
+  ///
+  /// In en, this message translates to:
+  /// **'Move pieces to complete the puzzle'**
+  String get moveToComplete;
+
   /// No description provided for @collectionLocked.
   ///
   /// In en, this message translates to:

+ 3 - 0
lib/l10n/app_localizations_ar.dart

@@ -380,6 +380,9 @@ class AppLocalizationsAr extends AppLocalizations {
   @override
   String get hardMode => 'الوضع الصعب';
 
+  @override
+  String get moveToComplete => 'حرك القطع لإكمال اللغز';
+
   @override
   String get collectionLocked => 'لم يتم فتح هذا الرسم القابل للتحصيل بعد.';
 }

+ 3 - 0
lib/l10n/app_localizations_de.dart

@@ -380,6 +380,9 @@ class AppLocalizationsDe extends AppLocalizations {
   @override
   String get hardMode => 'Schwieriger Modus';
 
+  @override
+  String get moveToComplete => 'Karten bewegen, um das Puzzle abzuschließen';
+
   @override
   String get collectionLocked => 'Dieses Sammelbild ist noch gesperrt.';
 }

+ 3 - 0
lib/l10n/app_localizations_en.dart

@@ -380,6 +380,9 @@ class AppLocalizationsEn extends AppLocalizations {
   @override
   String get hardMode => 'Hard Mode';
 
+  @override
+  String get moveToComplete => 'Move pieces to complete the puzzle';
+
   @override
   String get collectionLocked => 'This collectible image is not yet unlocked.';
 }

+ 3 - 0
lib/l10n/app_localizations_es.dart

@@ -380,6 +380,9 @@ class AppLocalizationsEs extends AppLocalizations {
   @override
   String get hardMode => 'Modo Difícil';
 
+  @override
+  String get moveToComplete => 'Mueve las piezas para completar el puzle';
+
   @override
   String get collectionLocked => 'Esta imagen coleccionable aún no está desbloqueada.';
 }

+ 3 - 0
lib/l10n/app_localizations_fr.dart

@@ -380,6 +380,9 @@ class AppLocalizationsFr extends AppLocalizations {
   @override
   String get hardMode => 'Mode Difficile';
 
+  @override
+  String get moveToComplete => 'Déplacez les pièces pour terminer le puzzle';
+
   @override
   String get collectionLocked => 'Cette image de collection n\'est pas encore déverrouillée.';
 }

+ 3 - 0
lib/l10n/app_localizations_ja.dart

@@ -380,6 +380,9 @@ class AppLocalizationsJa extends AppLocalizations {
   @override
   String get hardMode => 'ハードモード';
 
+  @override
+  String get moveToComplete => 'ピースを動かしてパズルを完成させる';
+
   @override
   String get collectionLocked => 'このコレクション画像はまだロック解除されていません';
 }

+ 3 - 0
lib/l10n/app_localizations_ko.dart

@@ -380,6 +380,9 @@ class AppLocalizationsKo extends AppLocalizations {
   @override
   String get hardMode => '하드 모드';
 
+  @override
+  String get moveToComplete => '조각을 이동하여 퍼즐을 완성하세요';
+
   @override
   String get collectionLocked => '이 수집 가능한 이미지는 아직 잠금 해제되지 않았습니다.';
 }

+ 3 - 0
lib/l10n/app_localizations_pt.dart

@@ -380,6 +380,9 @@ class AppLocalizationsPt extends AppLocalizations {
   @override
   String get hardMode => 'Modo Difícil';
 
+  @override
+  String get moveToComplete => 'Mova as peças para completar o quebra-cabeça';
+
   @override
   String get collectionLocked => 'Esta imagem colecionável ainda não foi desbloqueada.';
 }

+ 3 - 0
lib/l10n/app_localizations_ru.dart

@@ -380,6 +380,9 @@ class AppLocalizationsRu extends AppLocalizations {
   @override
   String get hardMode => 'Сложный режим';
 
+  @override
+  String get moveToComplete => 'Переместите фрагменты, чтобы завершить головоломку';
+
   @override
   String get collectionLocked => 'Это коллекционное изображение еще не разблокировано.';
 }

+ 15 - 0
lib/l10n/app_localizations_zh.dart

@@ -380,6 +380,9 @@ class AppLocalizationsZh extends AppLocalizations {
   @override
   String get hardMode => '困难模式';
 
+  @override
+  String get moveToComplete => '移动卡片完成拼图';
+
   @override
   String get collectionLocked => '此收藏图尚未解锁';
 }
@@ -760,6 +763,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh {
   @override
   String get hardMode => '困难模式';
 
+  @override
+  String get moveToComplete => '移动卡片完成拼图';
+
   @override
   String get collectionLocked => '此收藏图尚未解锁';
 }
@@ -1140,6 +1146,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
   @override
   String get hardMode => '困難模式';
 
+  @override
+  String get moveToComplete => '移動卡片完成拼圖';
+
   @override
   String get collectionLocked => '此收藏圖尚未解鎖';
 }
@@ -1520,6 +1529,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh {
   @override
   String get hardMode => '困難模式';
 
+  @override
+  String get moveToComplete => '移動卡片完成拼圖';
+
   @override
   String get collectionLocked => '此收藏圖尚未解鎖';
 }
@@ -1900,6 +1912,9 @@ class AppLocalizationsZhHantTw extends AppLocalizationsZh {
   @override
   String get hardMode => '困難模式';
 
+  @override
+  String get moveToComplete => '移動卡片完成拼圖';
+
   @override
   String get collectionLocked => '此收藏圖尚未解鎖';
 }

+ 1 - 0
lib/l10n/app_pt.arb

@@ -123,5 +123,6 @@
   "levelPass": "Nível Concluído!",
   "backToHome": "Voltar para a página inicial",
   "hardMode": "Modo Difícil",
+  "moveToComplete": "Mova as peças para completar o quebra-cabeça",
   "collectionLocked": "Esta imagem colecionável ainda não foi desbloqueada."
 }

+ 1 - 0
lib/l10n/app_ru.arb

@@ -123,5 +123,6 @@
   "levelPass": "Уровень пройден!",
   "backToHome": "Вернуться на главный экран",
   "hardMode": "Сложный режим",
+  "moveToComplete": "Переместите фрагменты, чтобы завершить головоломку",
   "collectionLocked": "Это коллекционное изображение еще не разблокировано."
 }

+ 1 - 0
lib/l10n/app_zh.arb

@@ -123,5 +123,6 @@
   "levelPass": "关卡通过!",
   "backToHome": "回到主页",
   "hardMode": "困难模式",
+  "moveToComplete": "移动卡片完成拼图",
   "collectionLocked": "此收藏图尚未解锁"
 }

+ 1 - 0
lib/l10n/app_zh_Hans.arb

@@ -123,5 +123,6 @@
   "levelPass": "关卡通过!",
   "backToHome": "回到主页",
   "hardMode": "困难模式",
+  "moveToComplete": "移动卡片完成拼图",
   "collectionLocked": "此收藏图尚未解锁"
 }

+ 1 - 0
lib/l10n/app_zh_Hant.arb

@@ -123,5 +123,6 @@
   "levelPass": "關卡通過!",
   "backToHome": "回到主頁",
   "hardMode": "困難模式",
+  "moveToComplete": "移動卡片完成拼圖",
   "collectionLocked": "此收藏圖尚未解鎖"
 }

+ 1 - 0
lib/l10n/app_zh_Hant_HK.arb

@@ -123,5 +123,6 @@
   "levelPass": "關卡通過!",
   "backToHome": "回到主頁",
   "hardMode": "困難模式",
+  "moveToComplete": "移動卡片完成拼圖",
   "collectionLocked": "此收藏圖尚未解鎖"
 }

+ 1 - 0
lib/l10n/app_zh_Hant_TW.arb

@@ -123,5 +123,6 @@
   "levelPass": "關卡通過!",
   "backToHome": "回到主頁",
   "hardMode": "困難模式",
+  "moveToComplete": "移動卡片完成拼圖",
   "collectionLocked": "此收藏圖尚未解鎖"
 }

+ 21 - 9
lib/main.dart

@@ -21,6 +21,7 @@ import 'package:puzzleweave/persistence/persistence.dart';
 import 'package:puzzleweave/play/board_play.dart';
 import 'package:puzzleweave/remote_config/remote_config.dart';
 import 'package:puzzleweave/settings/settings_controller.dart';
+import 'package:shared_preferences/shared_preferences.dart';
 
 import 'config/config.dart' as cfg;
 import 'config/device.dart';
@@ -92,6 +93,15 @@ void main() async {
     }
   }
 
+  // 检查是否是首次进入,首次进入直接进入引导游戏界面
+  bool firstRun = false;
+  SharedPreferences prefs = await SharedPreferences.getInstance();
+  int? timestamp = prefs.getInt('first_run_time');
+  if (timestamp == null) {
+    firstRun = true;
+  }
+  _log.info('firstRun = $firstRun');
+
   //本地参数存储初始化
   await Persistence().initialize();
 
@@ -106,12 +116,13 @@ void main() async {
 
   Directory baseDir = await getApplicationDocumentsDirectory();
 
-  runApp(MyApp(baseDir: baseDir));
+  runApp(MyApp(baseDir: baseDir, firstRun: firstRun));
 }
 
 class MyApp extends StatelessWidget {
+  final bool firstRun;
   final Directory baseDir;
-  const MyApp({super.key, required this.baseDir});
+  const MyApp({super.key, required this.baseDir, required this.firstRun});
 
   // This widget is the root of your application.
   @override
@@ -154,21 +165,22 @@ class MyApp extends StatelessWidget {
           child: MaterialApp(
             key: GlobalKey(),
             title: 'PuzzleWeave',
-            initialRoute: '/',
+            initialRoute: firstRun ? '/play' : '/', // 首次游戏直接进入游戏页面,而不是合集页
             navigatorObservers: [routeObserver],
             routes: {
               '/': (context) => const HomeScreen(),
               '/play': (context) => BoardPlay(
                 item: AssetItem(
-                  '0',
-                  'title',
+                  '6915869d4b99f02d1db82cf2',
+                  '',
                   2000,
                   3000,
-                  5,
-                  true,
-                  'assets/images/691585964b99f02d1db82b44.jpeg',
-                  'assets/images/691585964b99f02d1db82b44.jpeg',
+                  3,
+                  false,
+                  'assets/builtin/6915869d4b99f02d1db82cf2.jpeg',
+                  'assets/builtin/6915869d4b99f02d1db82cf2.jpeg',
                 ),
+                firstRun: firstRun,
               ),
             },
             theme: ThemeData(

+ 34 - 0
lib/play/board.dart

@@ -228,6 +228,40 @@ class Board {
     return pieces.firstWhereOrNull((p) => p.index == index);
   }
 
+  // 根据当前坐标查找邻居碎片
+  /// 获取碎片在其当前网格位置上四个方向的邻居碎片。
+  List<Piece> getCurNeighbors(Piece piece) {
+    final List<Piece> neighbors = [];
+    final int r = piece.curRow;
+    final int c = piece.curCol;
+
+    // 上邻居 (curRow - 1, curCol)
+    Piece? top = getPieceByCoordinate(r - 1, c);
+    if (top != null) {
+      neighbors.add(top);
+    }
+
+    // 下邻居 (curRow + 1, curCol)
+    Piece? bottom = getPieceByCoordinate(r + 1, c);
+    if (bottom != null) {
+      neighbors.add(bottom);
+    }
+
+    // 左邻居 (curRow, curCol - 1)
+    Piece? left = getPieceByCoordinate(r, c - 1);
+    if (left != null) {
+      neighbors.add(left);
+    }
+
+    // 右邻居 (curRow, curCol + 1)
+    Piece? right = getPieceByCoordinate(r, c + 1);
+    if (right != null) {
+      neighbors.add(right);
+    }
+
+    return neighbors;
+  }
+
   // 初始化检查合并分组 (改进版:固定点迭代合并)
   void rebuildAllGroups() {
     _log.info('rebuildAllGroups');

+ 415 - 3
lib/play/board_play.dart

@@ -17,6 +17,7 @@ import 'package:puzzleweave/models/items.dart';
 import 'package:puzzleweave/play/board.dart';
 import 'package:puzzleweave/play/board_painter.dart';
 import 'package:puzzleweave/play/confetti_layer.dart';
+import 'package:puzzleweave/play/overlayer.dart';
 import 'package:puzzleweave/play/piece.dart';
 import 'package:puzzleweave/rating/rating_helper.dart';
 import 'package:puzzleweave/rating/rating_utils.dart';
@@ -24,6 +25,7 @@ import 'package:puzzleweave/settings/settings_controller.dart';
 import 'package:puzzleweave/settings/settings_dialog.dart';
 import 'package:puzzleweave/skin/skin.dart';
 import 'package:puzzleweave/utils/mybutton.dart';
+import 'package:puzzleweave/utils/utils.dart';
 import 'package:vector_math/vector_math.dart' as vmath;
 import 'package:vibration/vibration.dart';
 
@@ -43,18 +45,19 @@ enum Action {
 
 class BoardPlay extends StatefulWidget {
   final ListItem item;
+  final bool firstRun;
 
-  const BoardPlay({super.key, required this.item});
+  const BoardPlay({super.key, required this.item, this.firstRun = false});
 
   @override
   State<StatefulWidget> createState() {
     return _BoardPlayState();
   }
 
-  static PageRouteBuilder buildRoute(ListItem item) {
+  static PageRouteBuilder buildRoute(ListItem item, {bool firstRun = false}) {
     return PageRouteBuilder(
       pageBuilder: (context, animation, secondaryAnimation) {
-        return BoardPlay(item: item);
+        return BoardPlay(item: item, firstRun: firstRun);
       },
       transitionsBuilder: (context, animation, secondaryAnimation, child) {
         return FadeTransition(opacity: animation, child: child);
@@ -84,6 +87,14 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
 
   late ConfettiLayer confettiLayer;
 
+  ui.Image? _fingerImage; // 手指形状图片,用于制作引导动画
+  int _hintCount = 0; // 已经展示的手势指引次数
+  OverLayer? _overLayer; // 用于展示手势指引的layer层,采用OverlayEntry方案,置于顶层
+  Timer? _hintTimer;
+  int? _lastInteractionTick;
+  // final int maxHints = 3;
+  final int maxHints = 99; // 无限提示
+
   Piece? _draggingPiece;
 
   // 记录所有动画中的移动项 (位移/交换/归位)
@@ -598,6 +609,9 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     board = Board(this, image, cardImage, widget.item.rows, widget.item.cols, widget.item.hard, targetRect, device);
     board!.prepare();
 
+    // 首次打开应用,需要新手指引
+    _loadFingerImageAndSetupHint();
+
     // **修正:在调用 AnimationController 之前检查 `mounted` 状态**
     if (!mounted) return;
 
@@ -608,6 +622,391 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     });
   }
 
+  // board_play.dart (在 _BoardPlayState 中新增)
+
+  // !!! 新增:加载手势图片并设置 Overlay
+  Future<void> _loadFingerImageAndSetupHint() async {
+    // 仅在首次运行时或用户需要提示时才加载图片和设置 OverLayer
+    if (!widget.firstRun) return;
+
+    try {
+      // 假设您将 fingerImage 命名为 _fingerImage
+      _fingerImage = await loadUiImageFromAsset('assets/images/finger.png');
+    } catch (e) {
+      _log.severe('Failed to load assets/images/finger.png: $e');
+      return;
+    }
+
+    if (!mounted || board == null) return;
+
+    // 初始化 OverLayer
+    _overLayer = OverLayer(board!, this);
+    _overLayer!.setup(context);
+
+    // 首次打开应用或设置开启提示时,启动自动提示计时器
+    if (widget.firstRun) {
+      Future.delayed(const Duration(seconds: 1), () {
+        _lastInteractionTick = DateTime.now().millisecondsSinceEpoch;
+
+        // 每秒检查一次是否需要提示
+        _hintTimer = Timer.periodic(const Duration(seconds: 1), (Timer t) {
+          if (!mounted) return;
+          int nowTick = DateTime.now().millisecondsSinceEpoch;
+          if (_overLayer!.isHinting) {
+            _lastInteractionTick = DateTime.now().millisecondsSinceEpoch;
+          }
+          // 超过3秒没动静,且提示次数未超限,则给提示
+          if ((nowTick - _lastInteractionTick!) > 3 * 1000 && _hintCount < maxHints) {
+            hint();
+            _lastInteractionTick = nowTick; // 提示后重置计时
+            _hintCount++;
+          } else if (_hintCount >= maxHints) {
+            // 提示次数达到上限,取消计时器
+            _hintTimer?.cancel();
+          }
+        });
+      });
+    }
+  }
+
+  // board_play.dart (在 _BoardPlayState 中,修正 hint 方法)
+
+  hint() async {
+    // 使用私有字段 _fingerImage, _overLayer, _hintCount
+    if (_fingerImage == null || _overLayer == null || board == null || board!.status != BoardStatus.playing) return;
+
+    Piece? p1Ref; // 拖拽起点 piece 的参考 (单碎片或群组的 topLeftPiece)
+    Piece? p2; // 合并目标 piece
+    int bestSize = 0; // 记录找到的最佳群组大小
+
+    // --- Step 1: 确定所有可移动的实体(单碎片和群组)及其大小 ---
+    final List<Map<String, dynamic>> movableEntities = [];
+    final Set<PieceGroup> seenGroups = {};
+
+    for (final piece in board!.pieces) {
+      if (piece.isOK) continue; // 已归位的碎片/群组不移动
+
+      if (piece.group == null) {
+        // 1. 单个碎片
+        movableEntities.add({'ref': piece, 'size': 1});
+      } else if (!seenGroups.contains(piece.group!)) {
+        // 2. 群组:使用 topLeftPiece 作为群组的参考点
+        movableEntities.add({'ref': piece.group!.topLeftPiece, 'size': piece.group!.length});
+        seenGroups.add(piece.group!);
+      }
+    }
+
+    // --- Step 2: 按实体大小降序排列,优先引导大群组 ---
+    // b['size'].compareTo(a['size']) 实现降序排序
+    movableEntities.sort((a, b) => b['size'].compareTo(a['size']));
+
+    // --- Step 3: 搜索有效的合并机会 (Merge Opportunity) ---
+
+    for (final entityMap in movableEntities) {
+      final p1RefCandidate = entityMap['ref'] as Piece;
+      final currentSize = entityMap['size'] as int;
+      final movingEntity = p1RefCandidate.group ?? p1RefCandidate;
+      final movingPieces = (movingEntity is PieceGroup) ? movingEntity.pieces : [p1RefCandidate];
+
+      // 遍历移动实体内的所有碎片 p1,寻找一个可以与外部 p2 合并的边缘碎片
+      for (final p1 in movingPieces) {
+        // 遍历 p1 的原图邻居 p2
+        for (final neighborIndex in p1.getNeighbourIndexes()) {
+          final p2Candidate = board!.getPieceByIndex(neighborIndex);
+
+          if (p2Candidate == null) continue;
+
+          // p2Candidate 必须不是正在移动的实体的一部分
+          if (p2Candidate.isSameGroup(p1)) continue;
+
+          // 1. 检查 p1 和 p2Candidate 的相对位置是否正确 (满足合并的前提条件)
+          final isRelativePositionCorrect =
+              (p1.col == p2Candidate.col && (p1.row - p2Candidate.row) == (p1.curRow - p2Candidate.curRow)) ||
+              (p1.row == p2Candidate.row && (p1.col - p2Candidate.col) == (p1.curCol - p2Candidate.curCol));
+
+          // 2. 如果它们已经是邻居,则应已自动合并,跳过提示
+          if (p1.isCurNeighbour(p2Candidate)) continue;
+
+          if (isRelativePositionCorrect) {
+            // --- Step 3.1: 模拟移动并进行碰撞/边界检查 (Validity Check) ---
+
+            // 计算使 p1 与 p2Candidate 合并所需的位移量 (dMoveRow, dMoveCol)
+
+            // a. p1 在原图上相对于 p2 的差值
+            final int dRow = p1.row - p2Candidate.row;
+            final int dCol = p1.col - p2Candidate.col;
+
+            // b. p1 移动后的目标网格坐标
+            final targetP1Row = p2Candidate.curRow + dRow;
+            final targetP1Col = p2Candidate.curCol + dCol;
+
+            // c. 整个实体所需的移动位移 (从 p1 的当前位置到目标位置的距离)
+            final dMoveRow = targetP1Row - p1.curRow;
+            final dMoveCol = targetP1Col - p1.curCol;
+
+            // 检查整个实体 (movingPieces) 移动 (dMoveRow, dMoveCol) 是否可行
+            bool canPlace = true;
+            for (final movingPiece in movingPieces) {
+              final newRow = movingPiece.curRow + dMoveRow;
+              final newCol = movingPiece.curCol + dMoveCol;
+
+              // i. 边界检查
+              if (newRow < 0 || newRow >= board!.rows || newCol < 0 || newCol >= board!.cols) {
+                canPlace = false;
+                break;
+              }
+
+              // ii. 碰撞检查: 目标槽位不能被非本实体内的其他碎片占据
+              final overlapPiece = board!.getPieceByCoordinate(newRow, newCol);
+
+              // 碰撞条件:目标槽位被占据 且 占据者不属于正在移动的实体
+              if (overlapPiece != null && !movingPieces.contains(overlapPiece)) {
+                canPlace = false;
+                break;
+              }
+            }
+
+            if (canPlace) {
+              p1Ref = p1RefCandidate; // 拖拽起点 (群组的参考 Piece)
+              p2 = p2Candidate; // 合并目标 Piece
+              bestSize = currentSize; // 记录大小
+              break; // 找到有效的合并提示,跳出 p2 循环
+            }
+          }
+        }
+        if (p1Ref != null) break; // 找到有效的合并提示,跳出 p1 循环
+      }
+      if (p1Ref != null) break; // 找到有效的合并提示,跳出实体循环 (因为它涉及当前找到的最大群组)
+    }
+
+    // ----------------------------------------------------
+    // --- Step 4: 执行引导动画 (Merge or Revert) ---
+    // ----------------------------------------------------
+
+    // 引导参数
+    const double fingerSize = 30.0;
+    HintItem? hintItem;
+
+    if (p1Ref != null && p2 != null) {
+      // 找到了有效的合并提示 (优先选择的合并操作)
+
+      // a. 拖拽起点中心点: p1Ref 的群组中心或自身中心
+      final p1Center = p1Ref!.group?.center ?? p1Ref!.currentCenter;
+
+      // b. 拖拽终点中心点: p1Ref 移动后的目标槽位中心
+
+      // 重新计算 p1Ref 应该移动到的目标网格坐标 (targetRefRow, targetRefCol)
+      final movingEntity = p1Ref!.group ?? p1Ref;
+      final movingPieces = (movingEntity is PieceGroup) ? movingEntity.pieces : [p1Ref!];
+
+      // 寻找群组中能与 p2 合并的那个边缘碎片 p1
+      final p1 = movingPieces.firstWhere(
+        (p) =>
+            p.isNeighbour(p2!) &&
+            ((p.col == p2!.col && (p.row - p2!.row) == (p.curRow - p2!.curRow)) || (p.row == p2!.row && (p.col - p2!.col) == (p.curCol - p2!.curCol))),
+      );
+
+      final int dRow = p1.row - p2!.row;
+      final int dCol = p1.col - p2!.col;
+      final targetP1Row = p2!.curRow + dRow;
+      final targetP1Col = p2!.curCol + dCol;
+
+      final dMoveRow = targetP1Row - p1.curRow;
+      final dMoveCol = targetP1Col - p1.curCol;
+
+      // 最终目标网格坐标是 p1Ref 的当前坐标加上总位移
+      final targetRefRow = p1Ref!.curRow + dMoveRow;
+      final targetRefCol = p1Ref!.curCol + dMoveCol;
+
+      // 获取目标槽位的中心点
+      final targetTransform = board!.getTransformByCoordinate(targetRefRow, targetRefCol);
+      final targetCenter = Offset(targetTransform.storage[12] + board!.pieceLogicalWidth / 2, targetTransform.storage[13] + board!.pieceLogicalHeight / 2);
+
+      // 引导:从当前位置拖拽到目标位置
+      final rectStart = Rect.fromCenter(center: p1Center, width: fingerSize, height: fingerSize);
+      final rectEnd = Rect.fromCenter(center: targetCenter, width: fingerSize, height: fingerSize);
+
+      _log.info(
+        'Hint: MERGE guidance for largest Entity (size: $bestSize) starting at ${p1Ref!.index} to grid ($targetRefRow, $targetRefCol). Merges with ${p2!.index}',
+      );
+      hintItem = HintItem(_fingerImage!, rectStart, rectEnd);
+    } else {
+      // 3. 找不到合并操作,回退到归位引导
+
+      // 沿用之前的逻辑:找一个未归位的单碎片,提示归位。
+      Piece? pRevert = board!.pieces.firstWhereOrNull((p) => !p.isOK && (p.group == null || p.group!.length == 1));
+
+      if (pRevert != null) {
+        final pRevertCenter = pRevert.group?.center ?? pRevert.currentCenter;
+
+        // 归位目标位置 (正确网格槽位的中心点)
+        final targetTransform = board!.getTransformByCoordinate(pRevert.row, pRevert.col);
+        final targetCenter = Offset(targetTransform.storage[12] + board!.pieceLogicalWidth / 2, targetTransform.storage[13] + board!.pieceLogicalHeight / 2);
+
+        // 如果当前中心点和目标中心点距离很近,不提示归位
+        if ((pRevertCenter - targetCenter).distanceSquared < pow(fingerSize * 2, 2)) {
+          _log.info('Hint: Revert target for Piece ${pRevert.index} too close. Skipping.');
+          return;
+        }
+
+        // 引导:从当前位置拖拽到正确网格中心
+        final rectStart = Rect.fromCenter(center: pRevertCenter, width: fingerSize, height: fingerSize);
+        final rectEnd = Rect.fromCenter(center: targetCenter, width: fingerSize, height: fingerSize);
+
+        _log.info('Hint: Revert guidance for Piece ${pRevert.index}');
+        hintItem = HintItem(_fingerImage!, rectStart, rectEnd);
+      }
+    }
+
+    // 4. 执行引导动画
+    if (hintItem != null) {
+      _overLayer?.doHint(hintItem);
+      Fluttertoast.showToast(
+        msg: AppLocalizations.of(context)!.moveToComplete,
+        toastLength: Toast.LENGTH_SHORT,
+        gravity: ToastGravity.BOTTOM,
+        timeInSecForIosWeb: 1,
+        backgroundColor: SkinHelper.slotBorderColor,
+        textColor: Colors.white,
+        fontSize: 16.0,
+      );
+    }
+  }
+
+  // 展现提示 (自动手势引导)
+  hint2() async {
+    _log.info('新手手势提示');
+
+    // 使用私有字段 _fingerImage, _overLayer, _hintCount
+    if (_fingerImage == null || _overLayer == null || board == null || board!.status != BoardStatus.playing) return;
+
+    // 1. 尝试寻找一个可以触发合并 (merge) 的拖拽操作 (p1 拖向 p2 的邻居槽位)
+    Piece? p1; // 拖拽起点 piece
+    Piece? p2; // 拖拽目标 piece (p1 将拖到 p2 的邻居槽位,从而实现合并)
+
+    // 遍历所有碎片,寻找可以作为拖拽起点 p1 的候选碎片:
+    // 仅考虑未归位的、单个碎片或群组的边缘碎片。
+    // 我们依然使用 (p.group == null || length == 1) 的逻辑来简化起点筛选,即只引导单个碎片。
+    for (final piece in board!.pieces) {
+      // 限制 p1 为未归位的单个碎片/群组 (修正后的 null/length == 1 检查)
+      if (piece.isOK || (piece.group != null && piece.group!.length > 1)) {
+        continue;
+      }
+
+      // 找到 piece 在原图上的所有邻居 (p2 的候选)
+      for (final neighborIndex in piece.getNeighbourIndexes()) {
+        final neighbor = board!.getPieceByIndex(neighborIndex);
+
+        if (neighbor == null) continue;
+
+        // 检查 p1 和 p2 是否满足合并的“原图条件”和“相对位置条件”
+        // canMerge() 依赖 isNeighbour(),所以 p1 和 p2 必须是原图邻居。
+        // 注意:canMerge() 也会检查 isCurNeighbour()。
+
+        // 核心逻辑:如果满足 canMerge,说明当前已经合并或即将自动合并,不需要提示。
+        // 我们需要找的是:原图相邻且相对位置正确,但 *当前不相邻* 的碎片。
+
+        // p1 和 p2 必须是原图邻居
+        if (!piece.isNeighbour(neighbor)) continue;
+
+        // 检查 p1 和 p2 的相对位置是否正确(确保可以合并)
+        final isRelativePositionCorrect =
+            (piece.col == neighbor.col && (piece.row - neighbor.row) == (piece.curRow - neighbor.curRow)) ||
+            (piece.row == neighbor.row && (piece.col - neighbor.col) == (piece.curCol - neighbor.curCol));
+
+        // 检查它们当前是否相邻
+        final isCurrentlyNeighbor = piece.isCurNeighbour(neighbor);
+
+        // 提示条件:原图是邻居 AND 相对位置正确 AND 当前不相邻
+        if (isRelativePositionCorrect && !isCurrentlyNeighbor) {
+          p1 = piece;
+          p2 = neighbor;
+          break; // 找到第一个非相邻的合并机会即可
+        }
+      }
+      if (p1 != null) break;
+    }
+
+    // 引导参数
+    const double fingerSize = 30.0;
+    HintItem? hintItem;
+
+    if (p1 != null && p2 != null) {
+      // 2. 执行“连接”引导 (p1 拖向 p2 所在的网格槽位,使其相邻)
+
+      // a. 拖拽起点:p1 群组的当前中心点
+      final p1Center = p1.group?.center ?? p1.currentCenter;
+
+      // b. 拖拽终点:p1 拖动后应到达的网格槽位的中心点。
+      // 这个目标槽位是 p2 当前所占网格槽位旁边的一个空槽位,该槽位应与 p1 在原图上的相对位置一致。
+
+      // 确定 p1 应该移动到的目标网格坐标 (row, col)
+      int targetRow = p2.curRow;
+      int targetCol = p2.curCol;
+
+      // 根据 p1 和 p2 在原图上的相对位置,计算 p1 移动后应占领的网格槽位
+      // 目标网格坐标 = p2 的当前网格坐标 + (p1 的正确坐标 - p2 的正确坐标)
+      // 假设 p1(R:1, C:2) 和 p2(R:1, C:3) 是原图邻居。
+      // 相对位移: dR = 0, dC = -1.
+      // 如果 p2 当前在 (curR: 5, curC: 5), 那么 p1 应该移动到 (5, 5 + (-1)) = (5, 4)。
+
+      final int dRow = p1.row - p2.row; // p1 相对于 p2 的行差值 (-1, 0, 1)
+      final int dCol = p1.col - p2.col; // p1 相对于 p2 的列差值 (-1, 0, 1)
+
+      targetRow = p2.curRow + dRow;
+      targetCol = p2.curCol + dCol;
+
+      // 检查目标网格是否溢出边界(理论上不需要,因为 p2 在板上,dRow/dCol 只有 +/-1 或 0)
+      if (targetRow < 0 || targetRow >= board!.rows || targetCol < 0 || targetCol >= board!.cols) {
+        // 目标网格无效,跳过本次提示
+        _log.warning('Hint target coordinate ($targetRow, $targetCol) out of bounds. Skipping.');
+        return;
+      }
+
+      // 获取目标槽位的变换矩阵 (左上角坐标)
+      final targetTransform = board!.getTransformByCoordinate(targetRow, targetCol);
+      final targetCenter = Offset(targetTransform.storage[12] + board!.pieceLogicalWidth / 2, targetTransform.storage[13] + board!.pieceLogicalHeight / 2);
+
+      // 拖拽起点 Rect (中心在 p1Center)
+      final rectStart = Rect.fromCenter(center: p1Center, width: fingerSize, height: fingerSize);
+      // 拖拽终点 Rect (中心在 targetCenter)
+      final rectEnd = Rect.fromCenter(center: targetCenter, width: fingerSize, height: fingerSize);
+
+      _log.info('Hint: Merge guidance for Piece ${p1.index} to neighbour of Piece ${p2.index} at grid ($targetRow, $targetCol)');
+      hintItem = HintItem(_fingerImage!, rectStart, rectEnd);
+    } else {
+      // 3. 找不到合并操作,尝试执行“归位”引导 (将未归位的 piece 拖向正确槽位)
+      // 沿用之前的逻辑:找一个未归位的单碎片,提示归位。
+      Piece? pRevert = board!.pieces.firstWhereOrNull((p) => !p.isOK && (p.group == null || p.group!.length == 1));
+
+      if (pRevert != null) {
+        final pRevertCenter = pRevert.group?.center ?? pRevert.currentCenter;
+
+        // 归位目标位置 (正确网格槽位的中心点)
+        final targetTransform = board!.getTransformByCoordinate(pRevert.row, pRevert.col);
+        final targetCenter = Offset(targetTransform.storage[12] + board!.pieceLogicalWidth / 2, targetTransform.storage[13] + board!.pieceLogicalHeight / 2);
+
+        // 如果当前中心点和目标中心点距离很近,不提示归位
+        if ((pRevertCenter - targetCenter).distanceSquared < pow(fingerSize * 2, 2)) {
+          _log.info('Hint: Revert target for Piece ${pRevert.index} too close. Skipping.');
+          return;
+        }
+
+        // 引导:从当前位置拖拽到正确网格中心
+        final rectStart = Rect.fromCenter(center: pRevertCenter, width: fingerSize, height: fingerSize);
+        final rectEnd = Rect.fromCenter(center: targetCenter, width: fingerSize, height: fingerSize);
+
+        _log.info('Hint: Revert guidance for Piece ${pRevert.index}');
+        hintItem = HintItem(_fingerImage!, rectStart, rectEnd);
+      }
+    }
+
+    // 4. 执行引导动画
+    if (hintItem != null) {
+      _overLayer?.doHint(hintItem);
+    }
+  }
+
   @override
   void didChangeDependencies() async {
     super.didChangeDependencies();
@@ -650,6 +1049,8 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
 
     board?.dispose();
 
+    _overLayer?.destroy();
+
     super.dispose();
   }
 
@@ -899,6 +1300,10 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
 
   void _onPanStart(DragStartDetails details) {
     _log.info('_onPanStart');
+
+    _lastInteractionTick = DateTime.now().millisecondsSinceEpoch;
+    _overLayer?.stopHint();
+
     if (board!.status != BoardStatus.playing) {
       _log.info('不是playing状态,不响应onPanStart');
       return;
@@ -980,6 +1385,9 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
   }
 
   void _onPanUpdate(DragUpdateDetails details) {
+    _lastInteractionTick = DateTime.now().millisecondsSinceEpoch;
+    _overLayer?.stopHint();
+
     if (_draggingPiece == null) return;
 
     final Offset delta = details.delta;
@@ -999,6 +1407,10 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
 
   void _onPanEnd(DragEndDetails details) {
     _log.info('_onPanEnd');
+
+    _lastInteractionTick = DateTime.now().millisecondsSinceEpoch;
+    _overLayer?.stopHint();
+
     if (_draggingPiece == null) {
       return;
     }

+ 131 - 0
lib/play/overlayer.dart

@@ -0,0 +1,131 @@
+import 'dart:ui' as ui;
+
+import 'package:flutter/material.dart';
+import 'package:logging/logging.dart';
+import 'package:puzzleweave/play/board.dart';
+
+final _log = Logger('overlayer.dart');
+
+class HintItem {
+  final ui.Image finger;
+  final Rect srcRect;
+  final Rect destRect;
+  final CurveTween tween;
+
+  HintItem(this.finger, this.srcRect, this.destRect) : tween = CurveTween(curve: Curves.easeInOut);
+
+  Rect getCurrent(double t) {
+    return Rect.lerp(srcRect, destRect, tween.transform(t))!;
+  }
+
+  // 动画的前 10% 和后 10% 渐入渐出,中间 80% 完全不透明
+  double getOpacity(double t) {
+    if (t < 0.1) {
+      return t * 10;
+    } else if (t >= 0.1 && t <= 0.9) {
+      return 1;
+    } else {
+      return (1 - t) * 10;
+    }
+  }
+}
+
+class OverLayer {
+  final Board board;
+  final TickerProvider tickerProvider;
+  OverlayEntry? _overlayEntry;
+
+  final ValueNotifier<int> _notifier = ValueNotifier(0);
+
+  late AnimationController hintAnimation;
+  Listenable get notifiers => Listenable.merge([_notifier, hintAnimation]);
+
+  OverLayer(this.board, this.tickerProvider) {
+    hintAnimation = AnimationController(value: 0, vsync: tickerProvider, duration: const Duration(milliseconds: 1200));
+  }
+
+  void invalidate() {
+    _notifier.value++;
+  }
+
+  void setup(BuildContext context) {
+    if (_overlayEntry != null) return;
+
+    // 创建并插入 OverlayEntry
+    _overlayEntry = OverlayEntry(
+      builder: (context) {
+        return Positioned.fill(
+          // 关键:IgnorePointer 确保手势引导动画不会阻碍底层的 BoardPlay 交互
+          child: IgnorePointer(
+            child: RepaintBoundary(child: CustomPaint(painter: OverLayerPainter(this))),
+          ),
+        );
+      },
+    );
+    Overlay.of(context).insert(_overlayEntry!);
+  }
+
+  HintItem? _hintItem;
+  HintItem? get hintItem => _hintItem;
+
+  void doHint(HintItem hintItem) {
+    ///上一次hint没有完成
+    if (_hintItem != null) {
+      return;
+    }
+    _hintItem = hintItem;
+    hintAnimation.reset();
+    hintAnimation.repeat(); // 重复播放动画
+  }
+
+  void stopHint() {
+    if (_hintItem != null) {
+      hintAnimation.stop();
+      _hintItem = null;
+
+      invalidate();
+    }
+  }
+
+  bool get isHinting => _hintItem != null && hintAnimation.isAnimating;
+
+  destroy() {
+    _overlayEntry?.remove();
+    hintAnimation.dispose();
+  }
+}
+
+class OverLayerPainter extends CustomPainter {
+  final OverLayer overLayer;
+
+  OverLayerPainter(this.overLayer) : super(repaint: overLayer.notifiers);
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    // canvas.clipRect(Offset.zero & size);
+    if (overLayer.hintItem != null) {
+      _paintHintItem(canvas, overLayer.hintItem!);
+    }
+  }
+
+  void _paintHintItem(Canvas canvas, HintItem hintItem) {
+    Rect srcRect = Offset.zero & Size(hintItem.finger.width.toDouble(), hintItem.finger.height.toDouble());
+    Rect currentRect = hintItem.getCurrent(overLayer.hintAnimation.value);
+
+    // 完善点: 计算并应用透明度
+    double opacity = hintItem.getOpacity(overLayer.hintAnimation.value);
+
+    // 创建 Paint,通过 color.withOpacity 控制透明度
+    final paint = Paint()
+      ..color = Colors.white.withOpacity(opacity)
+      ..isAntiAlias = true;
+
+    // 绘制图片
+    canvas.drawImageRect(hintItem.finger, srcRect, currentRect, paint);
+  }
+
+  @override
+  bool shouldRepaint(covariant CustomPainter oldDelegate) {
+    return false;
+  }
+}