// board.dart import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:puzzleweave/config/device.dart'; import 'package:puzzleweave/play/piece.dart'; import 'package:puzzleweave/skin/skin.dart'; import 'package:puzzleweave/utils/utils.dart'; import 'package:vector_math/vector_math.dart' as vmath; final Logger _log = Logger('board.dart'); // 增加一个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 pieces = []; /// 拼图行数(3/4/5,对应9/16/25宫格) final int rows; /// 拼图列数(3/4/5) final int cols; /// 困难模式 final bool hard; /// 整个拼图在屏幕上的目标区域(最终完整显示的位置和大小) final Rect targetRect; // 初始等于targetRect, 关卡成功后, 核心绘制区上移, 这个rect用于做动画控制,success之后使用这个 Rect finalRect; // 碎片的逻辑宽高 double get pieceLogicalWidth => targetRect.width / cols; double get pieceLogicalHeight => targetRect.height / rows; // 碎片的圆角半径,宫格越多圆角半径越小 double get cornerRadius => 10.0 - rows.toDouble(); // 设备信息 final Device device; // 静态背景绘图 Picture (只绘制一次) ui.Picture? backgroundPicture; // 卡片绘图 ui.Picture? cardPicture; ValueNotifier boardNotifier = ValueNotifier(1); BoardStatus _status = BoardStatus.loading; BoardStatus get status => _status; // 备份的 groups (PieceGroup 对象的列表) List 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(); } void success() { _status = BoardStatus.success; invalidate(); } void invalidate() { boardNotifier.value++; } // 是否全部完成 bool get isAllDone => pieces.every((p) => p.isOK); final TickerProviderStateMixin ticker; static Future create( TickerProviderStateMixin ticker, ui.Image image, ui.Image cardImage, int rows, int cols, bool hard, Rect targetRect, Device device, ) async { _log.info('Image.size=${image.width}x${image.height}'); final board = Board(ticker, image, cardImage, rows, cols, hard, targetRect, device); return board; } static Future restore( TickerProviderStateMixin ticker, ui.Image image, ui.Image cardImage, int rows, int cols, bool hard, Rect targetRect, Device device, String jsonPath, ) async { _log.info('Image.size=${image.width}x${image.height}'); try { Map json = await loadJson(jsonPath) as Map; if (json['pieces'] == null || json['pieces'][0] == null) { throw Exception('invalid json: $json'); } rows = json['pieces'][0]['rows']; cols = json['pieces'][0]['cols']; final board = Board(ticker, image, cardImage, rows, cols, hard, targetRect, device, json: json); return board; } catch (e) { _log.warning("board restore failed: $e"); _log.info("游戏恢复失败,转为重新创建"); final board = Board(ticker, image, cardImage, rows, cols, hard, targetRect, device); return board; } } Board(this.ticker, this.image, this.cardImage, this.rows, this.cols, this.hard, this.targetRect, this.device, {Map? json}) : finalRect = targetRect { _recordBackground(); // 录制静态背景,提升性能 _recordCard(); // 新增:录制卡片 Picture if (json != null) { _restorePieces(json); } else { _initPieces(); } rebuildAllGroups(); } /// 初始化碎片 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 transform = getTransformByCoordinate(i, j); pieces.add( Piece(board: this, index: index, row: i, col: j, rows: rows, cols: cols, sourceRect: sourceRect, curRow: i, curCol: j, transform: transform), ); } } _shufflePieces(); _sortPieces(); _log.info('_initPieces'); } // restore 碎片 void _restorePieces(Map json) { pieces.clear(); final imageWidth = image.width.toDouble(); final imageHeight = image.height.toDouble(); final pieceWidth = imageWidth / cols; final pieceHeight = imageHeight / rows; for (var i = 0; i < (json['pieces'] as List).length; i++) { var jsonPiece = json['pieces'][i]; final int index = jsonPiece['index']; final int row = jsonPiece['row']; final int col = jsonPiece['col']; final int curRow = jsonPiece['curRow']; final int curCol = jsonPiece['curCol']; final sourceRect = Rect.fromLTWH(col * pieceWidth, row * pieceHeight, pieceWidth, pieceHeight); final transform = getTransformByCoordinate(curRow, curCol); pieces.add( Piece( board: this, index: index, row: row, col: col, rows: rows, cols: cols, sourceRect: sourceRect, curRow: curRow, curCol: curCol, transform: transform, ), ); } _sortPieces(); _log.info('_restorePieces'); } // 洗牌(随机打乱碎片位置) 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; }); } // 根据坐标查找指定位置的碎片 Piece? findPieceAt(Offset localPos) { for (var piece in pieces.reversed) { // 从上层开始找 // 计算碎片在画布上的绝对位置board final transform = piece.transform; final posX = transform.storage[12]; final posY = transform.storage[13]; final pieceRect = Rect.fromLTWH(posX, posY, pieceLogicalWidth, pieceLogicalHeight); if (pieceRect.contains(localPos)) { return piece; } } return null; } // 查找某个坐标上的碎片,排除某个piece Piece? findPieceAtExclude(Offset localPos, Piece excludePiece) { for (var piece in pieces.reversed) { // 从上层开始找 if (piece == excludePiece) continue; // 计算碎片在画布上的绝对位置 final transform = piece.transform; final posX = transform.storage[12]; final posY = transform.storage[13]; final pieceRect = Rect.fromLTWH(posX, posY, pieceLogicalWidth, pieceLogicalHeight); if (pieceRect.contains(localPos)) { return piece; } } 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; final transform = vmath.Matrix4.translationValues(x, y, 0.0); return transform; } // 根据坐标获取该位置上的piece Piece? getPieceByCoordinate(int row, int col) { return pieces.firstWhereOrNull((p) => p.curRow == row && p.curCol == col); } // 根据坐标获取该位置上的piece Piece? getPieceByIndex(int index) { return pieces.firstWhereOrNull((p) => p.index == index); } // 根据当前坐标查找邻居碎片 /// 获取碎片在其当前网格位置上四个方向的邻居碎片。 List getCurNeighbors(Piece piece) { final List 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'); // 1. 清除所有旧组 和 原path for (var p in pieces) { p.group = null; p.path = null; p.outLinePath = null; p.innerLinePath = null; } bool mergedInPass; // 2. 迭代合并,直到一轮循环中没有发生任何合并 do { mergedInPass = false; // 3. 遍历所有碎片对 (i, j) for (int i = 0; i < pieces.length; i++) { for (int j = i + 1; j < pieces.length; j++) { final piece = pieces[i]; final otherPiece = pieces[j]; // 4. 检查是否可以合并,并且它们不属于同一个组 if (piece.canMerge(otherPiece)) { if (!piece.isSameGroup(otherPiece)) { // 5. 合并组 // piece.groupWith(otherPiece) 会创建一个新的 PieceGroup, // 并将 piece 和 otherPiece (以及它们可能已有的组员) 的 group 引用全部指向新组。 piece.groupWith(otherPiece); mergedInPass = true; } } } } // 如果 mergedInPass 为 true,说明本轮循环发生了合并,需要重新开始下一轮遍历 // 因为新的合并可能促使其他碎片或碎片组也得以连接 } while (mergedInPass); } // 备份所有group, 方便进行比较, 发现新合成的group,以便呈现动画特效 void backupAllGroups() { backupGroups.clear(); for (var p in pieces) { if (p.group != null && !backupGroups.contains(p.group)) { backupGroups.add(p.group!); } } _log.info('backupAllGroups: ${backupGroups.length}'); } // 当前group与之前备份的group进行比较,返回新合并的group列表,这些group需要展示动画特效 List compareAllGroups() { _log.info('compareAllGroups'); List newGroups = []; List currentGroups = []; for (var p in pieces) { if (p.group != null && !currentGroups.contains(p.group)) { currentGroups.add(p.group!); } } if (backupGroups.isEmpty) { // 之前都不存在群组,那么当前新合成的所有group都是新group newGroups.addAll(currentGroups); } else { for (var g in currentGroups) { bool alreadyExists = false; for (var bakgroup in backupGroups) { if (bakgroup.containsGroup(g)) { alreadyExists = true; break; } } if (!alreadyExists) { newGroups.add(g); } } } if (newGroups.isNotEmpty) { for (var g in newGroups) { _log.info('发现新群组:'); g.print(); } } return newGroups; } // 检查游戏是否全部完成 bool checkWinCondition() { return pieces.every((p) => p.isOK); } /// 退出释放资源 dispose() { backgroundPicture?.dispose(); backgroundPicture = null; cardPicture?.dispose(); cardPicture = null; image.dispose(); boardNotifier.dispose(); } // 录制背景,避免每次都重复绘制,提升性能 void _recordBackground() { // 确保在录制前释放旧的 Picture 资源 backgroundPicture?.dispose(); backgroundPicture = null; final recorder = ui.PictureRecorder(); // 录制器的边界设置为整个屏幕尺寸,因为我们在这里绘制的是 full screen background。 final recordBounds = Rect.fromLTWH(0, 0, device.screenSize.width, device.screenSize.height); final canvas = Canvas(recorder, recordBounds); // --- 静态绘制配置 --- const double strokeWidth = 1.0; // 拼图槽位的线宽 final double halfStroke = strokeWidth / 2.0; // 1. 绘制整个屏幕背景 canvas.drawRect( recordBounds, Paint() ..color = SkinHelper.wholeBgColor ..style = PaintingStyle.fill, ); // 2. 绘制每个拼图槽位(圆角矩形)作为背景的一部分 (静态内容) final slotFillPaint = Paint() ..color = SkinHelper.slotBgColor // .shade100 // 槽位填充色 ..style = PaintingStyle.fill; final slotStrokePaint = Paint() ..color = SkinHelper.slotBorderColor ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { // 计算当前槽位的边界 (Canvas坐标系) final left = targetRect.left + c * pieceLogicalWidth; final top = targetRect.top + r * pieceLogicalHeight; final right = left + pieceLogicalWidth; final bottom = top + pieceLogicalHeight; // 为了避免描边溢出到相邻槽位,将矩形向内收缩 halfStroke final slotRect = Rect.fromLTRB(left + halfStroke, top + halfStroke, right - halfStroke, bottom - halfStroke); final slotRRect = RRect.fromRectAndRadius(slotRect, Radius.circular(cornerRadius)); // 绘制填充 canvas.drawRRect(slotRRect, slotFillPaint); // 绘制描边 canvas.drawRRect(slotRRect, slotStrokePaint); } } // --- 结束录制并存储 --- backgroundPicture = recorder.endRecording(); _log.info('Static background picture recorded. Size: ${recordBounds.size}'); } // 提前录制卡牌(包含边框),避免每次都重复绘制,提升效率 void _recordCard() { // 确保在录制前释放旧的 Picture 资源 cardPicture?.dispose(); cardPicture = null; final recorder = ui.PictureRecorder(); // 录制器的边界设置为单个碎片区域 final recordBounds = Rect.fromLTWH(0, 0, pieceLogicalWidth, pieceLogicalHeight); final canvas = Canvas(recorder, recordBounds); final double w = pieceLogicalWidth; final double h = pieceLogicalHeight; final double cornerRadius = this.cornerRadius; // 1. 裁剪区域 final rrect = RRect.fromRectAndRadius(Rect.fromLTWH(0, 0, w, h), Radius.circular(cornerRadius)); // 绘制图片的 Paint final imagePaint = Paint() ..isAntiAlias ..filterQuality = FilterQuality.none; // 禁用过滤(发牌动画中肉眼无差异) = true; // 边框 Paint const double pieceStrokeWidth = 1.0; final Paint outerBorderPaint = Paint() ..color = SkinHelper.outLineBorderColor ..style = PaintingStyle.stroke ..strokeWidth = pieceStrokeWidth ..isAntiAlias = true; final Paint innerBorderPaint = Paint() ..color = SkinHelper.innerLineBorderColor ..style = PaintingStyle.stroke ..strokeWidth = pieceStrokeWidth ..isAntiAlias = true; // 边框 RRects final outerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(0.5, 0.5, w - 1.0, h - 1.0), Radius.circular(cornerRadius)); final innerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(2.5, 2.5, w - 5.0, h - 5.0), Radius.circular(cornerRadius)); // --- 录制流程 --- // 1. 绘制图片内容 (需要裁剪) canvas.save(); canvas.clipRRect(rrect); // 应用裁剪 final Rect sourceRect = Rect.fromLTWH(0, 0, cardImage.width.toDouble(), cardImage.height.toDouble()); final Rect dstRect = Rect.fromLTWH(0, 0, w, h); canvas.drawImageRect(cardImage, sourceRect, dstRect, imagePaint); canvas.restore(); // 移除裁剪 // 2. 绘制边框 (不需要裁剪,因为边框是为了在图片外部描绘边界) canvas.drawRRect(outerRRect, outerBorderPaint); canvas.drawRRect(innerRRect, innerBorderPaint); // --- 结束录制并存储 --- cardPicture = recorder.endRecording(); _log.info('Card picture recorded (with border). Size: ${recordBounds.size}'); } Map toJson() => {'pieces': pieces.map((e) => e.toJson()).toList()}; }