home_board_play.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. import 'dart:async';
  2. import 'dart:math';
  3. import 'dart:ui' as ui;
  4. import 'package:flutter/material.dart';
  5. import 'package:image_puzzle/audio/audio_controller.dart';
  6. import 'package:image_puzzle/config/device.dart';
  7. import 'package:image_puzzle/homepage/home_board.dart';
  8. import 'package:image_puzzle/models/cached_request.dart';
  9. import 'package:image_puzzle/models/data.dart';
  10. import 'package:image_puzzle/models/items.dart';
  11. import 'package:image_puzzle/play/confetti_layer.dart';
  12. import 'package:image_puzzle/skin/skin.dart';
  13. import 'package:logging/logging.dart';
  14. import 'package:provider/provider.dart';
  15. final Logger _log = Logger('home_board_play');
  16. // ignore: must_be_immutable
  17. class HomeBoardPlay extends StatefulWidget {
  18. final double canvasWidth;
  19. final double canvasHeight;
  20. final GlobalKey collectionKey; // !!! 改造点 2: 接收 Collection Key
  21. VoidCallback? onCollectionDone; // 新增一个合集完成的回调,外部home_screen可能会关心,当合集解锁动画完成,home_screen的左上角合集icon需要放大再复原
  22. // todo ... 可能需要传递外部home_screen 的左上角leading IconButton 空间的global key或者位置信息过来,方便执行unlock动画定位
  23. HomeBoardPlay({super.key, required this.canvasWidth, required this.canvasHeight, required this.collectionKey, this.onCollectionDone});
  24. @override
  25. State<HomeBoardPlay> createState() => HomeBoardPlayState();
  26. }
  27. class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMixin {
  28. late HomeBoard board;
  29. late AudioController audio;
  30. late Data data;
  31. List<ListItem>? collection;
  32. late CachedRequest collectionCachedRequest;
  33. late StreamSubscription? collectionSubscription;
  34. late ConfettiLayer confettiLayer;
  35. // 翻牌动画,完成一个关卡后翻开一个卡片
  36. late AnimationController _flipController;
  37. late Animation<double> _flipAnimation;
  38. // todo... 合集解锁动画,完成整个合集之后执行, 效果是整个图片缩小并位移到左上角的colleciton iconbutton, 形成合集“收纳”的效果
  39. late AnimationController _unlockController; // 解锁动画控制器
  40. late Animation<double> _unlockAnimation; // 0.0 -> 1.0
  41. // 当前合集是否已经完成(一个合集需要完成5x5即25个关卡才能解锁)
  42. // bool get _isCollectionDone => data.currentLevel != 0 && data.currentCollectionIndex * 25 == data.currentLevel;
  43. bool get _isCollectionDone => true; // for test
  44. OverlayEntry? _overlayEntry; // 新增:用于管理全屏动画层
  45. @override
  46. void initState() {
  47. super.initState();
  48. Device device = context.read<Device>();
  49. board = HomeBoard(canvasWidth: widget.canvasWidth, canvasHeight: widget.canvasHeight, device: device);
  50. board.isReadyNotifier.addListener(_onBoardReady);
  51. confettiLayer = ConfettiLayer(this);
  52. Future.delayed(Duration.zero, () {
  53. if (mounted) {
  54. confettiLayer.setup(context);
  55. }
  56. });
  57. audio = context.read<AudioController>();
  58. data = context.read<Data>();
  59. collectionCachedRequest = data.collection;
  60. // 主动获取缓存数据(关键)
  61. final collectionCachedData = collectionCachedRequest.cachedData;
  62. if (collectionCachedData != null) {
  63. _onCollectionDataUpdate(collectionCachedData);
  64. }
  65. collectionSubscription = collectionCachedRequest.stream.listen(_onCollectionDataUpdate, onError: _onCollectionDataError);
  66. // 初始化翻牌动画控制器
  67. _flipController = AnimationController(
  68. duration: const Duration(milliseconds: 1000), // 动画时长
  69. vsync: this, // HomeBoardState 必须实现 TickerProviderStateMixin
  70. );
  71. // 创建一个 0.0 到 1.0 的动画,用于控制翻转角度
  72. _flipAnimation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _flipController, curve: Curves.easeOut))
  73. ..addStatusListener((status) {
  74. if (status == AnimationStatus.completed) {
  75. // 检查整个合集是否全部完成
  76. _checkCollectionDone();
  77. }
  78. });
  79. // 初始化解锁动画控制器
  80. _unlockController = AnimationController(
  81. duration: const Duration(milliseconds: 1000), // 动画时长
  82. vsync: this,
  83. );
  84. _unlockAnimation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _unlockController, curve: Curves.easeInBack))
  85. ..addStatusListener((status) {
  86. if (status == AnimationStatus.completed) {
  87. // 动画结束后,通知外部(HomeScreen)
  88. widget.onCollectionDone?.call();
  89. // 可选:将状态最终置回 playing,准备加载下一个合集
  90. // setState(() {
  91. // _status = HomeBoardStatus.playing;
  92. // });
  93. }
  94. });
  95. }
  96. _onCollectionDataUpdate(data) async {
  97. _log.info('_onCollectionDataUpdate.... ');
  98. if (data != null) {
  99. collection = data as List<ListItem>;
  100. if (collection != null && collection!.isNotEmpty) {
  101. board.currentCollectionItem = currentCollectionItem;
  102. }
  103. setState(() {});
  104. }
  105. }
  106. _onCollectionDataError(error) {
  107. _log.info('_onCollectionDataError.... $error');
  108. if (collection == null || collection!.isEmpty || collection!.length <= 2) {
  109. // 列表数据如果少于20,说明只是内置图,仍然刷新远程请求
  110. _log.warning("_onCollectionDataError, retry again");
  111. Future.delayed(Duration(seconds: 3), () => refresh());
  112. }
  113. }
  114. Future<void> refresh() async {
  115. _log.info('refresh...');
  116. await collectionCachedRequest.refresh();
  117. }
  118. _onBoardReady() {
  119. if (board.isReadyNotifier.value == true) {
  120. setState(() {
  121. board.status = HomeBoardStatus.playing;
  122. });
  123. }
  124. }
  125. ListItem? get currentCollectionItem {
  126. if (collection != null && collection!.isNotEmpty) {
  127. return collection![data.currentCollectionIndex];
  128. }
  129. return null;
  130. }
  131. @override
  132. void dispose() {
  133. board.isReadyNotifier.removeListener(_onBoardReady);
  134. confettiLayer.dispose();
  135. _flipController.dispose();
  136. _unlockController.dispose();
  137. collectionSubscription?.cancel();
  138. super.dispose();
  139. }
  140. // 翻牌动画结束后,检查整个合集是否全部完成,如果全部完成,需要展示一系列的动画
  141. void _checkCollectionDone() async {
  142. if (_isCollectionDone) {
  143. // 将状态置为done,canvas绘制一整张图,不再是单个卡片
  144. setState(() {
  145. board.status = HomeBoardStatus.done;
  146. board.invalidate();
  147. });
  148. // 展示撒花动画
  149. audio.playSfx(SfxType.success);
  150. confettiLayer.play();
  151. await Future.delayed(Duration(milliseconds: 200)); // confetti动画结束
  152. // todo... 开始位移+缩放动画,将整个合集图片
  153. _startUnlockAnimation();
  154. }
  155. }
  156. // !!! 改造点 5: 实现解锁动画启动方法
  157. void _startUnlockAnimation() {
  158. // 检查 Key 是否关联到 RenderBox
  159. final RenderBox? targetRenderBox = widget.collectionKey.currentContext?.findRenderObject() as RenderBox?;
  160. if (targetRenderBox == null || !mounted) return;
  161. // 获取目标图标的中心点(屏幕全局坐标)
  162. final targetPosition = targetRenderBox.localToGlobal(targetRenderBox.size.center(Offset.zero));
  163. // 获取画布的中心点(屏幕全局坐标)
  164. final RenderBox? canvasRenderBox = context.findRenderObject() as RenderBox?;
  165. if (canvasRenderBox == null) return;
  166. final canvasCenter = canvasRenderBox.localToGlobal(canvasRenderBox.size.center(Offset.zero));
  167. // 计算位移量
  168. // dx: 目标中心X - 画布中心X
  169. // dy: 目标中心Y - 画布中心Y
  170. final Offset delta = targetPosition - canvasCenter;
  171. // 存储计算出的动画目标数据,供 CustomPainter 使用
  172. board.setUnlockAnimationTarget(
  173. targetOffset: delta,
  174. // 目标缩放比例
  175. // targetScale: targetRenderBox.size.width / widget.canvasWidth,
  176. targetScale: 0,
  177. );
  178. // 启动动画
  179. setState(() {
  180. board.status = HomeBoardStatus.unlocking; // 切换到 unlocking 状态
  181. });
  182. _unlockController.forward(from: 0.0);
  183. }
  184. // 对外暴露的触发动画方法 (供 HomeScreen 调用)
  185. void startFlipAnimation() {
  186. _flipController.forward(from: 0.0);
  187. audio.playSfx(SfxType.flip);
  188. }
  189. @override
  190. Widget build(BuildContext context) {
  191. return CustomPaint(
  192. size: Size(widget.canvasWidth, widget.canvasHeight), // 固定画布尺寸
  193. painter: CanvasPainter(board: board, level: data.currentLevel, flipAnimation: _flipAnimation, unlockAnimation: _unlockAnimation),
  194. child: board.status == HomeBoardStatus.loading
  195. ? Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(SkinHelper.slotBorderColor)))
  196. : Container(), // 加载完成后不显示child
  197. );
  198. }
  199. }
  200. // 自定义画笔实现(实际绘制逻辑在这里)
  201. class CanvasPainter extends CustomPainter {
  202. final HomeBoard board;
  203. final int level;
  204. final Animation<double> flipAnimation; // 0.0 -> 1.0
  205. final Animation<double> unlockAnimation;
  206. CanvasPainter({required this.board, required this.level, required this.flipAnimation, required this.unlockAnimation})
  207. : super(repaint: Listenable.merge([board.boardNotifier, flipAnimation, unlockAnimation])); // 触发重绘;
  208. @override
  209. void paint(Canvas canvas, Size size) {
  210. if (board.status == HomeBoardStatus.playing) {
  211. _paintPlaying(canvas, size);
  212. } else if (board.status == HomeBoardStatus.done) {
  213. _paintSuccess(canvas, size);
  214. } else if (board.status == HomeBoardStatus.unlocking) {
  215. // !!! 改造点 2: 处理 unlocking 状态
  216. _paintUnlocking(canvas, size);
  217. }
  218. }
  219. void _paintUnlocking(Canvas canvas, Size size) {
  220. // 1. 获取动画进度 (0.0 -> 1.0)
  221. final progress = unlockAnimation.value;
  222. // 3. 计算当前的位移和缩放
  223. // 缩放:从 1.0 缩小到 targetScale
  224. final startScale = 1.0;
  225. final endScale = board.unlockTargetScale;
  226. final currentScale = ui.lerpDouble(startScale, endScale, progress)!;
  227. // 位移:从 (0, 0) 平移到 targetOffset
  228. final startOffset = Offset.zero;
  229. final endOffset = board.unlockTargetOffset;
  230. final currentOffset = Offset(ui.lerpDouble(startOffset.dx, endOffset.dx, progress)!, ui.lerpDouble(startOffset.dy, endOffset.dy, progress)!);
  231. // 4. 应用 Canvas 变换
  232. canvas.save();
  233. // 位移:移动 Canvas 原点到新的位置
  234. canvas.translate(currentOffset.dx, currentOffset.dy);
  235. // 缩放:以 Canvas 中心为缩放原点进行缩放
  236. final centerX = size.width / 2;
  237. final centerY = size.height / 2;
  238. // 缩放操作
  239. canvas.translate(centerX, centerY);
  240. canvas.scale(currentScale);
  241. canvas.translate(-centerX, -centerY);
  242. // 5. 绘制完整的合集图片 (与 _paintSuccess 逻辑相同)
  243. final cornerRadius = 4.0;
  244. final rect = Rect.fromLTWH(0, 0, size.width, size.height);
  245. // final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius));
  246. final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius));
  247. final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius));
  248. final sourceRect = Rect.fromLTWH(0, 0, board.image!.width.toDouble(), board.image!.height.toDouble());
  249. // 裁剪并绘制图片
  250. // canvas.clipRRect(rrect); // 动画就不用clipRRect了
  251. canvas.drawImageRect(board.image!, sourceRect, rect, Paint()..isAntiAlias = true);
  252. // 绘制边框
  253. // canvas.drawRRect(outerRRect, outerBorderPaint);
  254. // canvas.drawRRect(innerRRect, innerBorderPaint);
  255. canvas.restore();
  256. }
  257. _paintSuccess(Canvas canvas, Size size) {
  258. _log.info('_paintSuccess');
  259. final cornerRadius = 4.0;
  260. final rect = Rect.fromLTWH(0, 0, size.width, size.height);
  261. final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius));
  262. final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius));
  263. final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius));
  264. final sourceRect = Rect.fromLTWH(0, 0, board.image!.width.toDouble(), board.image!.height.toDouble());
  265. canvas.save();
  266. canvas.clipRRect(rrect);
  267. canvas.drawImageRect(board.image!, sourceRect, rect, Paint()..isAntiAlias = true);
  268. canvas.restore();
  269. // 绘制边框
  270. canvas.drawRRect(outerRRect, outerBorderPaint);
  271. canvas.drawRRect(innerRRect, innerBorderPaint);
  272. }
  273. _paintPlaying(Canvas canvas, Size size) {
  274. _log.info('_paintPlaying');
  275. for (var i = 0; i < board.rows; i++) {
  276. for (var j = 0; j < board.cols; j++) {
  277. // 玩过的关卡翻正面显示, 否则显示卡片背面
  278. // final int curIndex = i * rows + j;
  279. // bool flipped = level % (rows * cols) > curIndex;
  280. // for test:
  281. bool flipped = (i == 4 && j == 4) ? false : true;
  282. _drawPiece(canvas, size, i, j, flipped);
  283. }
  284. }
  285. }
  286. final Paint outerBorderPaint = Paint()
  287. ..color = SkinHelper.outLineBorderColor
  288. ..style = PaintingStyle.stroke
  289. ..strokeWidth = 1.0
  290. ..isAntiAlias = true;
  291. // 边框画笔
  292. final Paint innerBorderPaint = Paint()
  293. ..color = SkinHelper.innerLineBorderColor
  294. ..style = PaintingStyle.stroke
  295. ..strokeWidth = 1.0
  296. ..isAntiAlias = true;
  297. void _drawPiece(Canvas canvas, Size size, int row, int col, bool flipped) {
  298. final cornerRadius = 4.0;
  299. final w = size.width / board.cols;
  300. final h = size.height / board.rows;
  301. final pieceWidth = board.image!.width / board.cols;
  302. final pieceHeight = board.image!.height / board.rows;
  303. final left = col * w;
  304. final top = row * h;
  305. final right = left + w;
  306. final bottom = top + h;
  307. // 为了避免描边溢出到相邻槽位,将矩形向内收缩 halfStroke
  308. final rect = Rect.fromLTRB(left, top, right, bottom);
  309. final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius));
  310. final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius));
  311. final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius));
  312. final cardSourceRect = Rect.fromLTWH(0, 0, board.cardImage.width.toDouble(), board.cardImage.height.toDouble());
  313. final imageSourceRect = Rect.fromLTWH(pieceWidth * col, pieceHeight * row, pieceWidth, pieceHeight);
  314. // 0-based index
  315. final int curCollectionIndex = (level / (board.rows * board.cols)).floor();
  316. final curIndex = curCollectionIndex * board.rows * board.cols + row * board.rows + col;
  317. // 1. 计算当前的翻转状态
  318. double flipProgress = 0.0;
  319. // if (flipAnimation.isAnimating && curIndex == level - 1) {
  320. // for test,只为方便查看动画效果,真正的代码是上面注释掉的
  321. if (flipAnimation.isAnimating && curIndex == 24) {
  322. flipProgress = flipAnimation.value; // 0.0 -> 1.0
  323. flipped = (flipProgress > 0.5);
  324. }
  325. // _log.info('level=$level, row=$row, col=$col, flippingIndex=$flippingIndex, flipProgress=$flipProgress, currentPieceFlipped=$currentPieceFlipped');
  326. canvas.save();
  327. // 2. 居中变换原点到拼图块中心
  328. final centerX = left + w / 2;
  329. final centerY = top + h / 2;
  330. canvas.translate(centerX, centerY);
  331. // 3. 应用 3D 旋转 (围绕 Y 轴)
  332. if (flipProgress > 0.0) {
  333. // 旋转角度从 0 到 pi (180度)
  334. double angle = flipProgress * pi;
  335. // 引入透视投影(z轴缩放),让翻转效果更立体
  336. const double perspective = 0.0015;
  337. // 3D 变换矩阵
  338. Matrix4 transform;
  339. if (flipProgress <= 0.5) {
  340. transform = Matrix4.identity()
  341. ..setEntry(3, 2, perspective) // 3D 效果
  342. ..rotateY(angle);
  343. } else {
  344. transform = Matrix4.identity()
  345. ..setEntry(3, 2, perspective) // 3D 效果
  346. ..rotateY(angle)
  347. ..scale(-1.0, 1.0, 1.0); // 3. X轴缩放-1:抵消旋转带来的左右镜像
  348. }
  349. canvas.transform(transform.storage);
  350. }
  351. // 4. 移回原点
  352. canvas.translate(-centerX, -centerY);
  353. // ... 现有裁剪逻辑 ...
  354. canvas.clipRRect(rrect);
  355. if (flipped) {
  356. // 绘制正面
  357. canvas.drawImageRect(board.image!, imageSourceRect, rect, Paint()..isAntiAlias = true);
  358. } else {
  359. // 绘制背面
  360. // 必须反转图片源矩形,以修正翻转180度后图像的镜像问题
  361. final sourceRect = flipProgress > 0.5
  362. ? imageSourceRect // 翻转后使用正面图像
  363. : cardSourceRect; // 翻转前使用背面卡片
  364. final targetImage = flipProgress > 0.5 ? board.image! : board.cardImage;
  365. canvas.drawImageRect(targetImage, sourceRect, rect, Paint()..isAntiAlias = true);
  366. if (flipProgress <= 0.5) {
  367. // todo... 绘制关卡数字, 在卡片中间位置把curIndex绘制上去, 颜色白色
  368. // 1. 配置文字样式:白色、加粗、动态字体大小(适配卡片尺寸)
  369. final textStyle = TextStyle(
  370. color: Colors.white,
  371. fontFamily: 'Roboto',
  372. fontSize: h * 0.25, // 字体大小为卡片高度的40%,适配不同尺寸
  373. fontWeight: FontWeight.bold,
  374. shadows: [
  375. // 增加黑色阴影,让白色文字在卡片背景上更清晰(可选但推荐)
  376. Shadow(offset: const Offset(1, 1), blurRadius: 2, color: Colors.black38),
  377. ],
  378. );
  379. // 2. 初始化文字绘制器
  380. final textSpan = TextSpan(text: (curIndex + 1).toString(), style: textStyle);
  381. final textPainter = TextPainter(
  382. text: textSpan,
  383. textDirection: TextDirection.ltr,
  384. textAlign: TextAlign.center, // 文字水平居中
  385. );
  386. // 3. 计算文字尺寸(必须调用layout())
  387. textPainter.layout(
  388. minWidth: 0,
  389. maxWidth: w, // 文字最大宽度不超过卡片宽度
  390. );
  391. // 4. 计算文字居中偏移量
  392. final textOffset = Offset(
  393. left + (w - textPainter.width) / 2, // 水平居中
  394. top + (h - textPainter.height) / 2, // 垂直居中
  395. );
  396. // 5. 绘制文字
  397. textPainter.paint(canvas, textOffset);
  398. }
  399. }
  400. canvas.restore(); // 恢复 Canvas 状态 (移除裁剪和 transform)
  401. // --- 绘制边框 ---
  402. // 为了确保边框线宽向外延伸的部分不会被裁剪,我们需要在裁剪区域被移除后重新绘制。
  403. canvas.save();
  404. canvas.drawRRect(outerRRect, outerBorderPaint);
  405. canvas.drawRRect(innerRRect, innerBorderPaint);
  406. canvas.restore();
  407. }
  408. @override
  409. bool shouldRepaint(covariant CanvasPainter oldDelegate) {
  410. return oldDelegate.level != level || oldDelegate.flipAnimation != flipAnimation || oldDelegate.board != board;
  411. }
  412. }