|
|
@@ -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;
|
|
|
}
|