board.dart 14 KB

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