board.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. // board.dart
  2. import 'dart:async';
  3. import 'dart:math';
  4. import 'dart:typed_data';
  5. import 'dart:ui' as ui;
  6. import 'package:flutter/material.dart';
  7. import 'package:puzzleweave/config/device.dart';
  8. import 'package:puzzleweave/play/piece.dart';
  9. import 'package:puzzleweave/skin/skin.dart';
  10. import 'package:puzzleweave/utils/utils.dart';
  11. import 'package:logging/logging.dart';
  12. import 'package:vector_math/vector_math.dart' as vmath;
  13. final Logger _log = Logger('board.dart');
  14. // 增加一个preparing 和 shuffle洗牌的状态
  15. // preparing: Opacity透明度动画展示核心绘制区
  16. // shuffle: 发牌和翻牌动画构成
  17. enum BoardStatus { loading, preparing, shuffle, playing, success }
  18. // 发牌阶段,dealing:发牌中(发牌动画), fliping:翻转中(翻转动画)
  19. enum ShuffleStep { dealing, flipping }
  20. class Board {
  21. // 原图
  22. final ui.Image image;
  23. // 纸牌背面图,用于
  24. final ui.Image cardImage;
  25. // 所有拼图碎片
  26. final List<Piece> pieces = [];
  27. /// 拼图行数(3/4/5,对应9/16/25宫格)
  28. final int rows;
  29. /// 拼图列数(3/4/5)
  30. final int cols;
  31. /// 困难模式
  32. final bool hard;
  33. /// 整个拼图在屏幕上的目标区域(最终完整显示的位置和大小)
  34. final Rect targetRect;
  35. // 初始等于targetRect, 关卡成功后, 核心绘制区上移, 这个rect用于做动画控制,success之后使用这个
  36. Rect finalRect;
  37. // 碎片的逻辑宽高
  38. double get pieceLogicalWidth => targetRect.width / cols;
  39. double get pieceLogicalHeight => targetRect.height / rows;
  40. // 碎片的圆角半径,宫格越多圆角半径越小
  41. double get cornerRadius => 10.0 - rows.toDouble();
  42. // 设备信息
  43. final Device device;
  44. // 静态背景绘图 Picture (只绘制一次)
  45. ui.Picture? backgroundPicture;
  46. // 卡片绘图
  47. ui.Picture? cardPicture;
  48. ValueNotifier boardNotifier = ValueNotifier(1);
  49. BoardStatus _status = BoardStatus.loading;
  50. BoardStatus get status => _status;
  51. // 备份的 groups (PieceGroup 对象的列表)
  52. List<PieceGroup> backupGroups = [];
  53. ShuffleStep _shuffleStep = ShuffleStep.dealing;
  54. ShuffleStep get shuffleStep => _shuffleStep;
  55. void shuffle(ShuffleStep step) {
  56. _status = BoardStatus.shuffle;
  57. _shuffleStep = step;
  58. invalidate();
  59. }
  60. void prepare() {
  61. _status = BoardStatus.preparing;
  62. invalidate();
  63. }
  64. void start() {
  65. _status = BoardStatus.playing;
  66. invalidate();
  67. }
  68. void success() {
  69. _status = BoardStatus.success;
  70. invalidate();
  71. }
  72. void invalidate() {
  73. boardNotifier.value++;
  74. }
  75. // 是否全部完成
  76. bool get isAllDone => pieces.every((p) => p.isOK);
  77. final TickerProviderStateMixin ticker;
  78. Board(this.ticker, this.image, this.cardImage, this.rows, this.cols, this.hard, this.targetRect, this.device, {Map<String, dynamic>? json})
  79. : finalRect = targetRect {
  80. _recordBackground(); // 录制静态背景,提升性能
  81. _recordCard(); // 新增:录制卡片 Picture
  82. _initPieces();
  83. rebuildAllGroups();
  84. }
  85. /// 初始化碎片
  86. void _initPieces() {
  87. pieces.clear();
  88. final imageWidth = image.width.toDouble();
  89. final imageHeight = image.height.toDouble();
  90. final pieceWidth = imageWidth / cols;
  91. final pieceHeight = imageHeight / rows;
  92. for (int i = 0; i < rows; i++) {
  93. for (int j = 0; j < cols; j++) {
  94. final index = i * cols + j;
  95. final sourceRect = Rect.fromLTWH(j * pieceWidth, i * pieceHeight, pieceWidth, pieceHeight);
  96. final transform = getTransformByCoordinate(i, j);
  97. pieces.add(
  98. Piece(board: this, index: index, row: i, col: j, rows: rows, cols: cols, sourceRect: sourceRect, curRow: i, curCol: j, transform: transform),
  99. );
  100. }
  101. }
  102. _shufflePieces();
  103. _sortPieces();
  104. _log.info('_initPieces');
  105. }
  106. // 洗牌(随机打乱碎片位置)
  107. void _shufflePieces() {
  108. final shuffledPositions = List.generate(pieces.length, (i) => i)..shuffle();
  109. for (int i = 0; i < pieces.length; i++) {
  110. final targetIndex = shuffledPositions[i];
  111. pieces[i].curRow = targetIndex ~/ cols;
  112. pieces[i].curCol = targetIndex % cols;
  113. pieces[i].transform = getTransformByCoordinate(pieces[i].curRow, pieces[i].curCol);
  114. }
  115. }
  116. void resetAllPieces() {
  117. for (var p in pieces) {
  118. p.transform = getTransformByCoordinate(p.curRow, p.curCol);
  119. }
  120. }
  121. void setAllPieceToBottomRight() {
  122. for (var p in pieces) {
  123. p.transform = getBottomRightTransform();
  124. }
  125. }
  126. // 是否应该根据当前的curRow和curCol对pieces做一次排序,以方便dealing动画实现依次从左上到右下的发牌效果
  127. void _sortPieces() {
  128. pieces.sort((Piece a, Piece b) {
  129. int ret = a.curRow - b.curRow;
  130. if (ret == 0) {
  131. ret = a.curCol - b.curCol;
  132. }
  133. return ret;
  134. });
  135. }
  136. // 根据坐标查找指定位置的碎片
  137. Piece? findPieceAt(Offset localPos) {
  138. for (var piece in pieces.reversed) {
  139. // 从上层开始找
  140. // 计算碎片在画布上的绝对位置board
  141. final transform = piece.transform;
  142. final posX = transform.storage[12];
  143. final posY = transform.storage[13];
  144. final pieceRect = Rect.fromLTWH(posX, posY, pieceLogicalWidth, pieceLogicalHeight);
  145. if (pieceRect.contains(localPos)) {
  146. return piece;
  147. }
  148. }
  149. return null;
  150. }
  151. // 查找某个坐标上的碎片,排除某个piece
  152. Piece? findPieceAtExclude(Offset localPos, Piece excludePiece) {
  153. for (var piece in pieces.reversed) {
  154. // 从上层开始找
  155. if (piece == excludePiece) continue;
  156. // 计算碎片在画布上的绝对位置
  157. final transform = piece.transform;
  158. final posX = transform.storage[12];
  159. final posY = transform.storage[13];
  160. final pieceRect = Rect.fromLTWH(posX, posY, pieceLogicalWidth, pieceLogicalHeight);
  161. if (pieceRect.contains(localPos)) {
  162. return piece;
  163. }
  164. }
  165. return null;
  166. }
  167. // 获取右下角变换矩阵
  168. vmath.Matrix4 getBottomRightTransform() {
  169. return getTransformByCoordinate(rows - 1, cols - 1);
  170. }
  171. // 根据格子位置(row, col)获取变换矩阵
  172. vmath.Matrix4 getTransformByCoordinate(int row, int col) {
  173. final x = targetRect.left + col * pieceLogicalWidth;
  174. final y = targetRect.top + row * pieceLogicalHeight;
  175. final transform = vmath.Matrix4.translationValues(x, y, 0.0);
  176. return transform;
  177. }
  178. // 根据坐标获取该位置上的piece
  179. Piece? getPieceByCoordinate(int row, int col) {
  180. return pieces.firstWhereOrNull((p) => p.curRow == row && p.curCol == col);
  181. }
  182. // 根据坐标获取该位置上的piece
  183. Piece? getPieceByIndex(int index) {
  184. return pieces.firstWhereOrNull((p) => p.index == index);
  185. }
  186. // 初始化检查合并分组 (改进版:固定点迭代合并)
  187. void rebuildAllGroups() {
  188. _log.info('rebuildAllGroups');
  189. // 1. 清除所有旧组 和 原path
  190. for (var p in pieces) {
  191. p.group = null;
  192. p.path = null;
  193. p.outLinePath = null;
  194. p.innerLinePath = null;
  195. }
  196. bool mergedInPass;
  197. // 2. 迭代合并,直到一轮循环中没有发生任何合并
  198. do {
  199. mergedInPass = false;
  200. // 3. 遍历所有碎片对 (i, j)
  201. for (int i = 0; i < pieces.length; i++) {
  202. for (int j = i + 1; j < pieces.length; j++) {
  203. final piece = pieces[i];
  204. final otherPiece = pieces[j];
  205. // 4. 检查是否可以合并,并且它们不属于同一个组
  206. if (piece.canMerge(otherPiece)) {
  207. if (!piece.isSameGroup(otherPiece)) {
  208. // 5. 合并组
  209. // piece.groupWith(otherPiece) 会创建一个新的 PieceGroup,
  210. // 并将 piece 和 otherPiece (以及它们可能已有的组员) 的 group 引用全部指向新组。
  211. piece.groupWith(otherPiece);
  212. mergedInPass = true;
  213. }
  214. }
  215. }
  216. }
  217. // 如果 mergedInPass 为 true,说明本轮循环发生了合并,需要重新开始下一轮遍历
  218. // 因为新的合并可能促使其他碎片或碎片组也得以连接
  219. } while (mergedInPass);
  220. }
  221. // 备份所有group, 方便进行比较, 发现新合成的group,以便呈现动画特效
  222. void backupAllGroups() {
  223. backupGroups.clear();
  224. for (var p in pieces) {
  225. if (p.group != null && !backupGroups.contains(p.group)) {
  226. backupGroups.add(p.group!);
  227. }
  228. }
  229. _log.info('backupAllGroups: ${backupGroups.length}');
  230. }
  231. // 当前group与之前备份的group进行比较,返回新合并的group列表,这些group需要展示动画特效
  232. List<PieceGroup> compareAllGroups() {
  233. _log.info('compareAllGroups');
  234. List<PieceGroup> newGroups = [];
  235. List<PieceGroup> currentGroups = [];
  236. for (var p in pieces) {
  237. if (p.group != null && !currentGroups.contains(p.group)) {
  238. currentGroups.add(p.group!);
  239. }
  240. }
  241. if (backupGroups.isEmpty) {
  242. // 之前都不存在群组,那么当前新合成的所有group都是新group
  243. newGroups.addAll(currentGroups);
  244. } else {
  245. for (var g in currentGroups) {
  246. bool alreadyExists = false;
  247. for (var bakgroup in backupGroups) {
  248. if (bakgroup.containsGroup(g)) {
  249. alreadyExists = true;
  250. break;
  251. }
  252. }
  253. if (!alreadyExists) {
  254. newGroups.add(g);
  255. }
  256. }
  257. }
  258. if (newGroups.isNotEmpty) {
  259. for (var g in newGroups) {
  260. _log.info('发现新群组:');
  261. g.print();
  262. }
  263. }
  264. return newGroups;
  265. }
  266. // 检查游戏是否全部完成
  267. bool checkWinCondition() {
  268. return pieces.every((p) => p.isOK);
  269. }
  270. /// 退出释放资源
  271. dispose() {
  272. backgroundPicture?.dispose();
  273. backgroundPicture = null;
  274. cardPicture?.dispose();
  275. cardPicture = null;
  276. image.dispose();
  277. boardNotifier.dispose();
  278. }
  279. // 录制背景,避免每次都重复绘制,提升性能
  280. void _recordBackground() {
  281. // 确保在录制前释放旧的 Picture 资源
  282. backgroundPicture?.dispose();
  283. backgroundPicture = null;
  284. final recorder = ui.PictureRecorder();
  285. // 录制器的边界设置为整个屏幕尺寸,因为我们在这里绘制的是 full screen background。
  286. final recordBounds = Rect.fromLTWH(0, 0, device.screenSize.width, device.screenSize.height);
  287. final canvas = Canvas(recorder, recordBounds);
  288. // --- 静态绘制配置 ---
  289. const double strokeWidth = 1.0; // 拼图槽位的线宽
  290. final double halfStroke = strokeWidth / 2.0;
  291. // 1. 绘制整个屏幕背景
  292. canvas.drawRect(
  293. recordBounds,
  294. Paint()
  295. ..color = SkinHelper.wholeBgColor
  296. ..style = PaintingStyle.fill,
  297. );
  298. // 2. 绘制每个拼图槽位(圆角矩形)作为背景的一部分 (静态内容)
  299. final slotFillPaint = Paint()
  300. ..color = SkinHelper.slotBgColor
  301. // .shade100 // 槽位填充色
  302. ..style = PaintingStyle.fill;
  303. final slotStrokePaint = Paint()
  304. ..color = SkinHelper.slotBorderColor
  305. ..style = PaintingStyle.stroke
  306. ..strokeWidth = strokeWidth;
  307. for (int r = 0; r < rows; r++) {
  308. for (int c = 0; c < cols; c++) {
  309. // 计算当前槽位的边界 (Canvas坐标系)
  310. final left = targetRect.left + c * pieceLogicalWidth;
  311. final top = targetRect.top + r * pieceLogicalHeight;
  312. final right = left + pieceLogicalWidth;
  313. final bottom = top + pieceLogicalHeight;
  314. // 为了避免描边溢出到相邻槽位,将矩形向内收缩 halfStroke
  315. final slotRect = Rect.fromLTRB(left + halfStroke, top + halfStroke, right - halfStroke, bottom - halfStroke);
  316. final slotRRect = RRect.fromRectAndRadius(slotRect, Radius.circular(cornerRadius));
  317. // 绘制填充
  318. canvas.drawRRect(slotRRect, slotFillPaint);
  319. // 绘制描边
  320. canvas.drawRRect(slotRRect, slotStrokePaint);
  321. }
  322. }
  323. // --- 结束录制并存储 ---
  324. backgroundPicture = recorder.endRecording();
  325. _log.info('Static background picture recorded. Size: ${recordBounds.size}');
  326. }
  327. // 提前录制卡牌(包含边框),避免每次都重复绘制,提升效率
  328. void _recordCard() {
  329. // 确保在录制前释放旧的 Picture 资源
  330. cardPicture?.dispose();
  331. cardPicture = null;
  332. final recorder = ui.PictureRecorder();
  333. // 录制器的边界设置为单个碎片区域
  334. final recordBounds = Rect.fromLTWH(0, 0, pieceLogicalWidth, pieceLogicalHeight);
  335. final canvas = Canvas(recorder, recordBounds);
  336. final double w = pieceLogicalWidth;
  337. final double h = pieceLogicalHeight;
  338. final double cornerRadius = this.cornerRadius;
  339. // 1. 裁剪区域
  340. final rrect = RRect.fromRectAndRadius(Rect.fromLTWH(0, 0, w, h), Radius.circular(cornerRadius));
  341. // 绘制图片的 Paint
  342. final imagePaint = Paint()
  343. ..isAntiAlias
  344. ..filterQuality = FilterQuality.none; // 禁用过滤(发牌动画中肉眼无差异) = true;
  345. // 边框 Paint
  346. const double pieceStrokeWidth = 1.0;
  347. final Paint outerBorderPaint = Paint()
  348. ..color = SkinHelper.outLineBorderColor
  349. ..style = PaintingStyle.stroke
  350. ..strokeWidth = pieceStrokeWidth
  351. ..isAntiAlias = true;
  352. final Paint innerBorderPaint = Paint()
  353. ..color = SkinHelper.innerLineBorderColor
  354. ..style = PaintingStyle.stroke
  355. ..strokeWidth = pieceStrokeWidth
  356. ..isAntiAlias = true;
  357. // 边框 RRects
  358. final outerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(0.5, 0.5, w - 1.0, h - 1.0), Radius.circular(cornerRadius));
  359. final innerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(2.5, 2.5, w - 5.0, h - 5.0), Radius.circular(cornerRadius));
  360. // --- 录制流程 ---
  361. // 1. 绘制图片内容 (需要裁剪)
  362. canvas.save();
  363. canvas.clipRRect(rrect); // 应用裁剪
  364. final Rect sourceRect = Rect.fromLTWH(0, 0, cardImage.width.toDouble(), cardImage.height.toDouble());
  365. final Rect dstRect = Rect.fromLTWH(0, 0, w, h);
  366. canvas.drawImageRect(cardImage, sourceRect, dstRect, imagePaint);
  367. canvas.restore(); // 移除裁剪
  368. // 2. 绘制边框 (不需要裁剪,因为边框是为了在图片外部描绘边界)
  369. canvas.drawRRect(outerRRect, outerBorderPaint);
  370. canvas.drawRRect(innerRRect, innerBorderPaint);
  371. // --- 结束录制并存储 ---
  372. cardPicture = recorder.endRecording();
  373. _log.info('Card picture recorded (with border). Size: ${recordBounds.size}');
  374. }
  375. }