board.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  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:image_puzzle/config/device.dart';
  8. import 'package:image_puzzle/play/piece.dart';
  9. import 'package:logging/logging.dart';
  10. import 'package:vector_math/vector_math.dart' as vmath;
  11. final Logger _log = Logger('board.dart');
  12. enum BoardStatus { loading, playing, success }
  13. class Board {
  14. // 原图
  15. final ui.Image image;
  16. // 所有拼图碎片
  17. final List<Piece> pieces = [];
  18. /// 拼图行数(3/4/5,对应9/16/25宫格)
  19. final int rows;
  20. /// 拼图列数(3/4/5)
  21. final int cols;
  22. /// 整个拼图在屏幕上的目标区域(最终完整显示的位置和大小)
  23. final Rect targetRect;
  24. // 碎片的逻辑宽高
  25. double get pieceLogicalWidth => targetRect.width / cols;
  26. double get pieceLogicalHeight => targetRect.height / rows;
  27. // 设备信息
  28. final Device device;
  29. // 静态背景绘图 Picture (只绘制一次)
  30. ui.Picture? _backgroundPicture;
  31. ui.Picture? get backgroundPicture => _backgroundPicture;
  32. ValueNotifier boardNotifier = ValueNotifier(1);
  33. // 用户是否真正可以开始动手开玩(所有加载初始化完成,并且进入的插屏广告播放完成)
  34. final Completer<bool> startPlayCompleter = Completer();
  35. BoardStatus _status = BoardStatus.loading;
  36. BoardStatus get status => _status;
  37. // 备份的 groups (PieceGroup 对象的列表)
  38. List<PieceGroup> backupGroups = [];
  39. void start() {
  40. _status = BoardStatus.playing;
  41. invalidate();
  42. }
  43. void success() {
  44. _status = BoardStatus.success;
  45. invalidate();
  46. }
  47. void invalidate() {
  48. boardNotifier.value++;
  49. }
  50. // 是否全部完成
  51. bool get isAllDone => pieces.every((p) => p.isOK);
  52. final TickerProviderStateMixin ticker;
  53. Board(this.ticker, this.image, this.cols, this.rows, this.targetRect, this.device, {Map<String, dynamic>? json}) {
  54. _recordBackground(); // 录制静态背景,提升性能
  55. _initPieces();
  56. rebuildAllGroups();
  57. backupAllGroups();
  58. }
  59. /// 初始化碎片
  60. _initPieces() {
  61. final double imagePixelWidth = image.width.toDouble(); // 图片像素宽度
  62. final double imagePixelHeight = image.height.toDouble(); // 图片像素高度
  63. final double pieceLogicalWidth = targetRect.width / cols; // 绘图区域逻辑宽度 / 列数 = 每个piece的逻辑宽度
  64. final double pieceLogicalHeight = targetRect.height / rows; // 绘图区域逻辑高度 / 行数 = 每个piece的逻辑高度
  65. final double scaleX = imagePixelWidth / targetRect.width; // 像素宽度与逻辑宽度的scale比例
  66. final double scaleY = imagePixelHeight / targetRect.height; // 像素高度与逻辑高度的scale比例
  67. final double piecePixelWidth = pieceLogicalWidth * scaleX; // 每个piece的像素宽度
  68. final double piecePixelHeight = pieceLogicalHeight * scaleY; // 每个piece的像素高度
  69. final List<({int col, int row})> slotCoords = [];
  70. for (int r = 0; r < rows; r++) {
  71. for (int c = 0; c < cols; c++) {
  72. slotCoords.add((col: c, row: r));
  73. }
  74. }
  75. slotCoords.shuffle(Random());
  76. int index = 0;
  77. for (int r = 0; r < rows; r++) {
  78. for (int c = 0; c < cols; c++) {
  79. final correctX = targetRect.left + c * pieceLogicalWidth; // piece的正确位置坐标 x
  80. final correctY = targetRect.top + r * pieceLogicalHeight; // piece的正确位置坐标 y
  81. final correctOffset = Offset(correctX, correctY);
  82. final initialSlot = slotCoords[index];
  83. final initialX = targetRect.left + initialSlot.col * pieceLogicalWidth; // 初始位置坐标
  84. final initialY = targetRect.top + initialSlot.row * pieceLogicalHeight;
  85. final initialTransform = vmath.Matrix4.translationValues(initialX, initialY, 0.0); // 记录初始位置坐标transform matrix
  86. final sourceRect = Rect.fromLTWH(c * piecePixelWidth, r * piecePixelHeight, piecePixelWidth, piecePixelHeight); // 对应图片的像素矩形
  87. pieces.add(
  88. Piece(
  89. board: this,
  90. index: index,
  91. row: r,
  92. col: c,
  93. rows: rows,
  94. cols: cols,
  95. correctOffset: correctOffset,
  96. curCol: initialSlot.col,
  97. curRow: initialSlot.row,
  98. sourceRect: sourceRect,
  99. transform: initialTransform,
  100. borders: [true, true, true, true],
  101. ),
  102. );
  103. index++;
  104. }
  105. }
  106. }
  107. // 根据坐标查找指定位置的碎片
  108. Piece? findPieceAt(Offset localPos) {
  109. for (var piece in pieces.reversed) {
  110. // 从上层开始找
  111. // 计算碎片在画布上的绝对位置board
  112. final transform = piece.transform;
  113. final posX = transform.storage[12];
  114. final posY = transform.storage[13];
  115. final pieceRect = Rect.fromLTWH(posX, posY, pieceLogicalWidth, pieceLogicalHeight);
  116. if (pieceRect.contains(localPos)) {
  117. return piece;
  118. }
  119. }
  120. return null;
  121. }
  122. // 查找某个坐标上的碎片,排除某个piece
  123. Piece? findPieceAtExclude(Offset localPos, Piece excludePiece) {
  124. for (var piece in pieces.reversed) {
  125. // 从上层开始找
  126. if (piece == excludePiece) continue;
  127. // 计算碎片在画布上的绝对位置
  128. final transform = piece.transform;
  129. final posX = transform.storage[12];
  130. final posY = transform.storage[13];
  131. final pieceRect = Rect.fromLTWH(posX, posY, pieceLogicalWidth, pieceLogicalHeight);
  132. if (pieceRect.contains(localPos)) {
  133. return piece;
  134. }
  135. }
  136. return null;
  137. }
  138. vmath.Matrix4 getTransformByCoordinate(int row, int col) {
  139. final x = targetRect.left + col * pieceLogicalWidth;
  140. final y = targetRect.top + row * pieceLogicalHeight;
  141. final transform = vmath.Matrix4.translationValues(x, y, 0.0);
  142. return transform;
  143. }
  144. // 根据坐标获取该位置上的piece
  145. Piece? getPieceByCoordinate(int row, int col) {
  146. return pieces.firstWhereOrNull((p) => p.curRow == row && p.curCol == col);
  147. }
  148. // 根据坐标获取该位置上的piece
  149. Piece? getPieceByIndex(int index) {
  150. return pieces.firstWhereOrNull((p) => p.index == index);
  151. }
  152. // 初始化检查合并分组 (改进版:固定点迭代合并)
  153. void rebuildAllGroups() {
  154. _log.info('rebuildAllGroups');
  155. // 1. 清除所有旧组 和 原path
  156. for (var p in pieces) {
  157. p.group = null;
  158. p.path = null;
  159. p.outLinePath = null;
  160. p.innerLinePath = null;
  161. }
  162. bool mergedInPass;
  163. // 2. 迭代合并,直到一轮循环中没有发生任何合并
  164. do {
  165. mergedInPass = false;
  166. // 3. 遍历所有碎片对 (i, j)
  167. for (int i = 0; i < pieces.length; i++) {
  168. for (int j = i + 1; j < pieces.length; j++) {
  169. final piece = pieces[i];
  170. final otherPiece = pieces[j];
  171. // 4. 检查是否可以合并,并且它们不属于同一个组
  172. if (piece.canMerge(otherPiece)) {
  173. if (!piece.isSameGroup(otherPiece)) {
  174. // 5. 合并组
  175. // piece.groupWith(otherPiece) 会创建一个新的 PieceGroup,
  176. // 并将 piece 和 otherPiece (以及它们可能已有的组员) 的 group 引用全部指向新组。
  177. piece.groupWith(otherPiece);
  178. mergedInPass = true;
  179. }
  180. }
  181. }
  182. }
  183. // 如果 mergedInPass 为 true,说明本轮循环发生了合并,需要重新开始下一轮遍历
  184. // 因为新的合并可能促使其他碎片或碎片组也得以连接
  185. } while (mergedInPass);
  186. }
  187. // 备份所有group, 方便进行比较, 发现新合成的group,以便呈现动画特效
  188. void backupAllGroups() {
  189. backupGroups.clear();
  190. for (var p in pieces) {
  191. if (p.group != null && !backupGroups.contains(p.group)) {
  192. backupGroups.add(p.group!);
  193. }
  194. }
  195. _log.info('backupAllGroups: ${backupGroups.length}');
  196. }
  197. // 当前group与之前备份的group进行比较,返回新合并的group列表,这些group需要展示动画特效
  198. List<PieceGroup> compareAllGroups() {
  199. _log.info('compareAllGroups');
  200. List<PieceGroup> newGroups = [];
  201. List<PieceGroup> currentGroups = [];
  202. for (var p in pieces) {
  203. if (p.group != null && !currentGroups.contains(p.group)) {
  204. currentGroups.add(p.group!);
  205. }
  206. }
  207. if (backupGroups.isEmpty) {
  208. // 之前都不存在群组,那么当前新合成的所有group都是新group
  209. newGroups.addAll(currentGroups);
  210. } else {
  211. for (var g in currentGroups) {
  212. bool alreadyExists = false;
  213. for (var bakgroup in backupGroups) {
  214. if (bakgroup.containsGroup(g)) {
  215. alreadyExists = true;
  216. break;
  217. }
  218. }
  219. if (!alreadyExists) {
  220. newGroups.add(g);
  221. }
  222. }
  223. }
  224. if (newGroups.isNotEmpty) {
  225. for (var g in newGroups) {
  226. _log.info('发现新群组:');
  227. g.print();
  228. }
  229. }
  230. return newGroups;
  231. }
  232. // 检查游戏是否全部完成
  233. bool checkWinCondition() {
  234. return pieces.every((p) => p.isOK);
  235. }
  236. /// 退出释放资源
  237. dispose() {
  238. _backgroundPicture?.dispose();
  239. _backgroundPicture = null;
  240. image.dispose();
  241. boardNotifier.dispose();
  242. }
  243. // 录制背景,避免每次都重复绘制,提升性能
  244. void _recordBackground() {
  245. // 确保在录制前释放旧的 Picture 资源
  246. _backgroundPicture?.dispose();
  247. _backgroundPicture = null;
  248. final recorder = ui.PictureRecorder();
  249. // 录制器的边界设置为整个屏幕尺寸,因为我们在这里绘制的是 full screen background。
  250. final recordBounds = Rect.fromLTWH(0, 0, device.screenSize.width, device.screenSize.height);
  251. final canvas = Canvas(recorder, recordBounds);
  252. // --- 静态绘制配置 ---
  253. const double cornerRadius = 8.0;
  254. const double strokeWidth = 1.0; // 拼图槽位的线宽
  255. final double halfStroke = strokeWidth / 2.0;
  256. // 1. 绘制整个屏幕背景
  257. canvas.drawRect(
  258. recordBounds,
  259. Paint()
  260. ..color = Colors
  261. .lightGreen // 主背景色
  262. ..style = PaintingStyle.fill,
  263. );
  264. // 2. 绘制每个拼图槽位(圆角矩形)作为背景的一部分 (静态内容)
  265. final slotFillPaint = Paint()
  266. ..color = Colors.green
  267. // .shade100 // 槽位填充色
  268. ..style = PaintingStyle.fill;
  269. final slotStrokePaint = Paint()
  270. ..color =
  271. Color(0xff26600c) // 槽位边框色
  272. ..style = PaintingStyle.stroke
  273. ..strokeWidth = strokeWidth;
  274. for (int r = 0; r < rows; r++) {
  275. for (int c = 0; c < cols; c++) {
  276. // 计算当前槽位的边界 (Canvas坐标系)
  277. final left = targetRect.left + c * pieceLogicalWidth;
  278. final top = targetRect.top + r * pieceLogicalHeight;
  279. final right = left + pieceLogicalWidth;
  280. final bottom = top + pieceLogicalHeight;
  281. // 为了避免描边溢出到相邻槽位,将矩形向内收缩 halfStroke
  282. final slotRect = Rect.fromLTRB(left + halfStroke, top + halfStroke, right - halfStroke, bottom - halfStroke);
  283. final slotRRect = RRect.fromRectAndRadius(slotRect, const Radius.circular(cornerRadius));
  284. // 绘制填充
  285. canvas.drawRRect(slotRRect, slotFillPaint);
  286. // 绘制描边
  287. canvas.drawRRect(slotRRect, slotStrokePaint);
  288. }
  289. }
  290. // --- 结束录制并存储 ---
  291. _backgroundPicture = recorder.endRecording();
  292. _log.info('Static background picture recorded. Size: ${recordBounds.size}');
  293. }
  294. }
  295. // 辅助扩展:解决 Dart 缺少 firstWhereOrNull 的问题
  296. extension IterableExtension<T> on Iterable<T> {
  297. T? firstWhereOrNull(bool Function(T element) test) {
  298. for (final element in this) {
  299. if (test(element)) {
  300. return element;
  301. }
  302. }
  303. return null;
  304. }
  305. }