board.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  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. static Future<Board> create(
  76. TickerProviderStateMixin ticker,
  77. ui.Image image,
  78. ui.Image cardImage,
  79. int rows,
  80. int cols,
  81. bool hard,
  82. Rect targetRect,
  83. Device device,
  84. ) async {
  85. _log.info('Image.size=${image.width}x${image.height}');
  86. final board = Board(ticker, image, cardImage, rows, cols, hard, targetRect, device);
  87. return board;
  88. }
  89. static Future<Board> restore(
  90. TickerProviderStateMixin ticker,
  91. ui.Image image,
  92. ui.Image cardImage,
  93. int rows,
  94. int cols,
  95. bool hard,
  96. Rect targetRect,
  97. Device device,
  98. String jsonPath,
  99. ) async {
  100. _log.info('Image.size=${image.width}x${image.height}');
  101. try {
  102. Map<String, dynamic> json = await loadJson(jsonPath) as Map<String, dynamic>;
  103. if (json['pieces'] == null || json['pieces'][0] == null) {
  104. throw Exception('invalid json: $json');
  105. }
  106. rows = json['pieces'][0]['rows'];
  107. cols = json['pieces'][0]['cols'];
  108. final board = Board(ticker, image, cardImage, rows, cols, hard, targetRect, device, json: json);
  109. return board;
  110. } catch (e) {
  111. _log.warning("board restore failed: $e");
  112. _log.info("游戏恢复失败,转为重新创建");
  113. final board = Board(ticker, image, cardImage, rows, cols, hard, targetRect, device);
  114. return board;
  115. }
  116. }
  117. Board(this.ticker, this.image, this.cardImage, this.rows, this.cols, this.hard, this.targetRect, this.device, {Map<String, dynamic>? json})
  118. : finalRect = targetRect {
  119. _recordBackground(); // 录制静态背景,提升性能
  120. _recordCard(); // 新增:录制卡片 Picture
  121. if (json != null) {
  122. _restorePieces(json);
  123. } else {
  124. _initPieces();
  125. }
  126. rebuildAllGroups();
  127. }
  128. /// 初始化碎片
  129. void _initPieces() {
  130. pieces.clear();
  131. final imageWidth = image.width.toDouble();
  132. final imageHeight = image.height.toDouble();
  133. final pieceWidth = imageWidth / cols;
  134. final pieceHeight = imageHeight / rows;
  135. for (int i = 0; i < rows; i++) {
  136. for (int j = 0; j < cols; j++) {
  137. final index = i * cols + j;
  138. final sourceRect = Rect.fromLTWH(j * pieceWidth, i * pieceHeight, pieceWidth, pieceHeight);
  139. final transform = getTransformByCoordinate(i, j);
  140. pieces.add(
  141. Piece(board: this, index: index, row: i, col: j, rows: rows, cols: cols, sourceRect: sourceRect, curRow: i, curCol: j, transform: transform),
  142. );
  143. }
  144. }
  145. _shufflePieces();
  146. _sortPieces();
  147. _log.info('_initPieces');
  148. }
  149. // restore 碎片
  150. void _restorePieces(Map<String, dynamic> json) {
  151. pieces.clear();
  152. final imageWidth = image.width.toDouble();
  153. final imageHeight = image.height.toDouble();
  154. final pieceWidth = imageWidth / cols;
  155. final pieceHeight = imageHeight / rows;
  156. for (var i = 0; i < (json['pieces'] as List).length; i++) {
  157. var jsonPiece = json['pieces'][i];
  158. final int index = jsonPiece['index'];
  159. final int row = jsonPiece['row'];
  160. final int col = jsonPiece['col'];
  161. final int curRow = jsonPiece['curRow'];
  162. final int curCol = jsonPiece['curCol'];
  163. final sourceRect = Rect.fromLTWH(col * pieceWidth, row * pieceHeight, pieceWidth, pieceHeight);
  164. final transform = getTransformByCoordinate(curRow, curCol);
  165. pieces.add(
  166. Piece(
  167. board: this,
  168. index: index,
  169. row: row,
  170. col: col,
  171. rows: rows,
  172. cols: cols,
  173. sourceRect: sourceRect,
  174. curRow: curRow,
  175. curCol: curCol,
  176. transform: transform,
  177. ),
  178. );
  179. }
  180. _sortPieces();
  181. _log.info('_restorePieces');
  182. }
  183. // 洗牌(随机打乱碎片位置)
  184. void _shufflePieces() {
  185. final shuffledPositions = List.generate(pieces.length, (i) => i)..shuffle();
  186. for (int i = 0; i < pieces.length; i++) {
  187. final targetIndex = shuffledPositions[i];
  188. pieces[i].curRow = targetIndex ~/ cols;
  189. pieces[i].curCol = targetIndex % cols;
  190. pieces[i].transform = getTransformByCoordinate(pieces[i].curRow, pieces[i].curCol);
  191. }
  192. }
  193. void resetAllPieces() {
  194. for (var p in pieces) {
  195. p.transform = getTransformByCoordinate(p.curRow, p.curCol);
  196. }
  197. }
  198. void setAllPieceToBottomRight() {
  199. for (var p in pieces) {
  200. p.transform = getBottomRightTransform();
  201. }
  202. }
  203. // 是否应该根据当前的curRow和curCol对pieces做一次排序,以方便dealing动画实现依次从左上到右下的发牌效果
  204. void _sortPieces() {
  205. pieces.sort((Piece a, Piece b) {
  206. int ret = a.curRow - b.curRow;
  207. if (ret == 0) {
  208. ret = a.curCol - b.curCol;
  209. }
  210. return ret;
  211. });
  212. }
  213. // 根据坐标查找指定位置的碎片
  214. Piece? findPieceAt(Offset localPos) {
  215. for (var piece in pieces.reversed) {
  216. // 从上层开始找
  217. // 计算碎片在画布上的绝对位置board
  218. final transform = piece.transform;
  219. final posX = transform.storage[12];
  220. final posY = transform.storage[13];
  221. final pieceRect = Rect.fromLTWH(posX, posY, pieceLogicalWidth, pieceLogicalHeight);
  222. if (pieceRect.contains(localPos)) {
  223. return piece;
  224. }
  225. }
  226. return null;
  227. }
  228. // 查找某个坐标上的碎片,排除某个piece
  229. Piece? findPieceAtExclude(Offset localPos, Piece excludePiece) {
  230. for (var piece in pieces.reversed) {
  231. // 从上层开始找
  232. if (piece == excludePiece) continue;
  233. // 计算碎片在画布上的绝对位置
  234. final transform = piece.transform;
  235. final posX = transform.storage[12];
  236. final posY = transform.storage[13];
  237. final pieceRect = Rect.fromLTWH(posX, posY, pieceLogicalWidth, pieceLogicalHeight);
  238. if (pieceRect.contains(localPos)) {
  239. return piece;
  240. }
  241. }
  242. return null;
  243. }
  244. // 获取右下角变换矩阵
  245. vmath.Matrix4 getBottomRightTransform() {
  246. return getTransformByCoordinate(rows - 1, cols - 1);
  247. }
  248. // 根据格子位置(row, col)获取变换矩阵
  249. vmath.Matrix4 getTransformByCoordinate(int row, int col) {
  250. final x = targetRect.left + col * pieceLogicalWidth;
  251. final y = targetRect.top + row * pieceLogicalHeight;
  252. final transform = vmath.Matrix4.translationValues(x, y, 0.0);
  253. return transform;
  254. }
  255. // 根据坐标获取该位置上的piece
  256. Piece? getPieceByCoordinate(int row, int col) {
  257. return pieces.firstWhereOrNull((p) => p.curRow == row && p.curCol == col);
  258. }
  259. // 根据坐标获取该位置上的piece
  260. Piece? getPieceByIndex(int index) {
  261. return pieces.firstWhereOrNull((p) => p.index == index);
  262. }
  263. // 根据当前坐标查找邻居碎片
  264. /// 获取碎片在其当前网格位置上四个方向的邻居碎片。
  265. List<Piece> getCurNeighbors(Piece piece) {
  266. final List<Piece> neighbors = [];
  267. final int r = piece.curRow;
  268. final int c = piece.curCol;
  269. // 上邻居 (curRow - 1, curCol)
  270. Piece? top = getPieceByCoordinate(r - 1, c);
  271. if (top != null) {
  272. neighbors.add(top);
  273. }
  274. // 下邻居 (curRow + 1, curCol)
  275. Piece? bottom = getPieceByCoordinate(r + 1, c);
  276. if (bottom != null) {
  277. neighbors.add(bottom);
  278. }
  279. // 左邻居 (curRow, curCol - 1)
  280. Piece? left = getPieceByCoordinate(r, c - 1);
  281. if (left != null) {
  282. neighbors.add(left);
  283. }
  284. // 右邻居 (curRow, curCol + 1)
  285. Piece? right = getPieceByCoordinate(r, c + 1);
  286. if (right != null) {
  287. neighbors.add(right);
  288. }
  289. return neighbors;
  290. }
  291. // 初始化检查合并分组 (改进版:固定点迭代合并)
  292. void rebuildAllGroups() {
  293. _log.info('rebuildAllGroups');
  294. // 1. 清除所有旧组 和 原path
  295. for (var p in pieces) {
  296. p.group = null;
  297. p.path = null;
  298. p.outLinePath = null;
  299. p.innerLinePath = null;
  300. }
  301. bool mergedInPass;
  302. // 2. 迭代合并,直到一轮循环中没有发生任何合并
  303. do {
  304. mergedInPass = false;
  305. // 3. 遍历所有碎片对 (i, j)
  306. for (int i = 0; i < pieces.length; i++) {
  307. for (int j = i + 1; j < pieces.length; j++) {
  308. final piece = pieces[i];
  309. final otherPiece = pieces[j];
  310. // 4. 检查是否可以合并,并且它们不属于同一个组
  311. if (piece.canMerge(otherPiece)) {
  312. if (!piece.isSameGroup(otherPiece)) {
  313. // 5. 合并组
  314. // piece.groupWith(otherPiece) 会创建一个新的 PieceGroup,
  315. // 并将 piece 和 otherPiece (以及它们可能已有的组员) 的 group 引用全部指向新组。
  316. piece.groupWith(otherPiece);
  317. mergedInPass = true;
  318. }
  319. }
  320. }
  321. }
  322. // 如果 mergedInPass 为 true,说明本轮循环发生了合并,需要重新开始下一轮遍历
  323. // 因为新的合并可能促使其他碎片或碎片组也得以连接
  324. } while (mergedInPass);
  325. }
  326. // 备份所有group, 方便进行比较, 发现新合成的group,以便呈现动画特效
  327. void backupAllGroups() {
  328. backupGroups.clear();
  329. for (var p in pieces) {
  330. if (p.group != null && !backupGroups.contains(p.group)) {
  331. backupGroups.add(p.group!);
  332. }
  333. }
  334. _log.info('backupAllGroups: ${backupGroups.length}');
  335. }
  336. // 当前group与之前备份的group进行比较,返回新合并的group列表,这些group需要展示动画特效
  337. List<PieceGroup> compareAllGroups() {
  338. _log.info('compareAllGroups');
  339. List<PieceGroup> newGroups = [];
  340. List<PieceGroup> currentGroups = [];
  341. for (var p in pieces) {
  342. if (p.group != null && !currentGroups.contains(p.group)) {
  343. currentGroups.add(p.group!);
  344. }
  345. }
  346. if (backupGroups.isEmpty) {
  347. // 之前都不存在群组,那么当前新合成的所有group都是新group
  348. newGroups.addAll(currentGroups);
  349. } else {
  350. for (var g in currentGroups) {
  351. bool alreadyExists = false;
  352. for (var bakgroup in backupGroups) {
  353. if (bakgroup.containsGroup(g)) {
  354. alreadyExists = true;
  355. break;
  356. }
  357. }
  358. if (!alreadyExists) {
  359. newGroups.add(g);
  360. }
  361. }
  362. }
  363. if (newGroups.isNotEmpty) {
  364. for (var g in newGroups) {
  365. _log.info('发现新群组:');
  366. g.print();
  367. }
  368. }
  369. return newGroups;
  370. }
  371. // 检查游戏是否全部完成
  372. bool checkWinCondition() {
  373. return pieces.every((p) => p.isOK);
  374. }
  375. /// 退出释放资源
  376. dispose() {
  377. backgroundPicture?.dispose();
  378. backgroundPicture = null;
  379. cardPicture?.dispose();
  380. cardPicture = null;
  381. image.dispose();
  382. boardNotifier.dispose();
  383. }
  384. // 录制背景,避免每次都重复绘制,提升性能
  385. void _recordBackground() {
  386. // 确保在录制前释放旧的 Picture 资源
  387. backgroundPicture?.dispose();
  388. backgroundPicture = null;
  389. final recorder = ui.PictureRecorder();
  390. // 录制器的边界设置为整个屏幕尺寸,因为我们在这里绘制的是 full screen background。
  391. final recordBounds = Rect.fromLTWH(0, 0, device.screenSize.width, device.screenSize.height);
  392. final canvas = Canvas(recorder, recordBounds);
  393. // --- 静态绘制配置 ---
  394. const double strokeWidth = 1.0; // 拼图槽位的线宽
  395. final double halfStroke = strokeWidth / 2.0;
  396. // 1. 绘制整个屏幕背景
  397. canvas.drawRect(
  398. recordBounds,
  399. Paint()
  400. ..color = SkinHelper.wholeBgColor
  401. ..style = PaintingStyle.fill,
  402. );
  403. // 2. 绘制每个拼图槽位(圆角矩形)作为背景的一部分 (静态内容)
  404. final slotFillPaint = Paint()
  405. ..color = SkinHelper.slotBgColor
  406. // .shade100 // 槽位填充色
  407. ..style = PaintingStyle.fill;
  408. final slotStrokePaint = Paint()
  409. ..color = SkinHelper.slotBorderColor
  410. ..style = PaintingStyle.stroke
  411. ..strokeWidth = strokeWidth;
  412. for (int r = 0; r < rows; r++) {
  413. for (int c = 0; c < cols; c++) {
  414. // 计算当前槽位的边界 (Canvas坐标系)
  415. final left = targetRect.left + c * pieceLogicalWidth;
  416. final top = targetRect.top + r * pieceLogicalHeight;
  417. final right = left + pieceLogicalWidth;
  418. final bottom = top + pieceLogicalHeight;
  419. // 为了避免描边溢出到相邻槽位,将矩形向内收缩 halfStroke
  420. final slotRect = Rect.fromLTRB(left + halfStroke, top + halfStroke, right - halfStroke, bottom - halfStroke);
  421. final slotRRect = RRect.fromRectAndRadius(slotRect, Radius.circular(cornerRadius));
  422. // 绘制填充
  423. canvas.drawRRect(slotRRect, slotFillPaint);
  424. // 绘制描边
  425. canvas.drawRRect(slotRRect, slotStrokePaint);
  426. }
  427. }
  428. // --- 结束录制并存储 ---
  429. backgroundPicture = recorder.endRecording();
  430. _log.info('Static background picture recorded. Size: ${recordBounds.size}');
  431. }
  432. // 提前录制卡牌(包含边框),避免每次都重复绘制,提升效率
  433. void _recordCard() {
  434. // 确保在录制前释放旧的 Picture 资源
  435. cardPicture?.dispose();
  436. cardPicture = null;
  437. final recorder = ui.PictureRecorder();
  438. // 录制器的边界设置为单个碎片区域
  439. final recordBounds = Rect.fromLTWH(0, 0, pieceLogicalWidth, pieceLogicalHeight);
  440. final canvas = Canvas(recorder, recordBounds);
  441. final double w = pieceLogicalWidth;
  442. final double h = pieceLogicalHeight;
  443. final double cornerRadius = this.cornerRadius;
  444. // 1. 裁剪区域
  445. final rrect = RRect.fromRectAndRadius(Rect.fromLTWH(0, 0, w, h), Radius.circular(cornerRadius));
  446. // 绘制图片的 Paint
  447. final imagePaint = Paint()
  448. ..isAntiAlias
  449. ..filterQuality = FilterQuality.none; // 禁用过滤(发牌动画中肉眼无差异) = true;
  450. // 边框 Paint
  451. const double pieceStrokeWidth = 1.0;
  452. final Paint outerBorderPaint = Paint()
  453. ..color = SkinHelper.outLineBorderColor
  454. ..style = PaintingStyle.stroke
  455. ..strokeWidth = pieceStrokeWidth
  456. ..isAntiAlias = true;
  457. final Paint innerBorderPaint = Paint()
  458. ..color = SkinHelper.innerLineBorderColor
  459. ..style = PaintingStyle.stroke
  460. ..strokeWidth = pieceStrokeWidth
  461. ..isAntiAlias = true;
  462. // 边框 RRects
  463. final outerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(0.5, 0.5, w - 1.0, h - 1.0), Radius.circular(cornerRadius));
  464. final innerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(2.5, 2.5, w - 5.0, h - 5.0), Radius.circular(cornerRadius));
  465. // --- 录制流程 ---
  466. // 1. 绘制图片内容 (需要裁剪)
  467. canvas.save();
  468. canvas.clipRRect(rrect); // 应用裁剪
  469. final Rect sourceRect = Rect.fromLTWH(0, 0, cardImage.width.toDouble(), cardImage.height.toDouble());
  470. final Rect dstRect = Rect.fromLTWH(0, 0, w, h);
  471. canvas.drawImageRect(cardImage, sourceRect, dstRect, imagePaint);
  472. canvas.restore(); // 移除裁剪
  473. // 2. 绘制边框 (不需要裁剪,因为边框是为了在图片外部描绘边界)
  474. canvas.drawRRect(outerRRect, outerBorderPaint);
  475. canvas.drawRRect(innerRRect, innerBorderPaint);
  476. // --- 结束录制并存储 ---
  477. cardPicture = recorder.endRecording();
  478. _log.info('Card picture recorded (with border). Size: ${recordBounds.size}');
  479. }
  480. Map<String, dynamic> toJson() => {'pieces': pieces.map((e) => e.toJson()).toList()};
  481. }