home_board_play.dart 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925
  1. import 'dart:async';
  2. import 'dart:math';
  3. import 'dart:ui' as ui;
  4. import 'package:flutter/material.dart';
  5. import 'package:fluttertoast/fluttertoast.dart';
  6. import 'package:puzzleweave/audio/jc_audio_controller.dart';
  7. import 'package:puzzleweave/config/device.dart';
  8. import 'package:puzzleweave/homepage/home_board.dart';
  9. import 'package:puzzleweave/l10n/app_localizations.dart';
  10. import 'package:puzzleweave/models/cached_request.dart';
  11. import 'package:puzzleweave/models/data.dart';
  12. import 'package:puzzleweave/models/items.dart';
  13. import 'package:puzzleweave/play/confetti_layer.dart';
  14. import 'package:puzzleweave/skin/skin.dart';
  15. import 'package:logging/logging.dart';
  16. import 'package:provider/provider.dart';
  17. import 'package:puzzleweave/utils/memory_monitor.dart';
  18. final Logger _log = Logger('home_board_play');
  19. // ignore: must_be_immutable
  20. class HomeBoardPlay extends StatefulWidget {
  21. final double canvasWidth;
  22. final double canvasHeight;
  23. final GlobalKey collectionKey; // 接收 Collection Button Key, 方便定位
  24. VoidCallback? onCollectionDone; // 新增一个合集完成的回调,外部home_screen可能会关心,当合集解锁动画完成,home_screen的左上角合集icon需要放大再复原
  25. HomeBoardPlay({super.key, required this.canvasWidth, required this.canvasHeight, required this.collectionKey, this.onCollectionDone});
  26. @override
  27. State<HomeBoardPlay> createState() => HomeBoardPlayState();
  28. }
  29. class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMixin {
  30. late HomeBoard board;
  31. late JcAudioController audio;
  32. late Data data;
  33. List<ListItem>? collection;
  34. late CachedRequest collectionCachedRequest;
  35. late StreamSubscription? collectionSubscription;
  36. late ConfettiLayer confettiLayer;
  37. // 翻牌动画,完成一个关卡后翻开一个卡片
  38. late AnimationController _flipController;
  39. late Animation<double> _flipAnimation;
  40. // todo... 合集解锁动画,完成整个合集之后执行, 效果是整个图片缩小并位移到左上角的colleciton iconbutton, 形成合集“收纳”的效果
  41. late AnimationController _unlockController; // 解锁动画控制器
  42. late Animation<double> _unlockAnimation; // 0.0 -> 1.0
  43. // 当前合集是否已经完成(一个合集需要完成5x5即25个关卡才能解锁)(在切换到下一个合集之前调用判断)
  44. bool get _isCurCollectionDone => data.currentLevel != 0 && (data.currentCollectionIndex) * 25 == data.currentLevel;
  45. // bool get _isCurCollectionDone => true; // for test
  46. OverlayEntry? _overlayEntry; // 新增:用于管理全屏动画层
  47. // 发牌动画
  48. late AnimationController _dealingController; // 发牌动画控制器
  49. late Animation<double> _dealingAnimation;
  50. // 发牌间隔(ms)
  51. final int _dealingPieceInterval = 70;
  52. // 每个卡片移动时间(ms)
  53. final int _dealingPieceDuration = 200;
  54. // 发牌动画总时长(需要考虑到最后一个卡片的动画)
  55. // 共有 board.rows * board.cols 个卡片
  56. int get _totalDealingDuration => (board.rows * board.cols - 1) * _dealingPieceInterval + _dealingPieceDuration;
  57. Timer? _dealingPeriodicTimer;
  58. int _dealingCount = 0; // 计数:记录执行次数
  59. // ✅ 优化:Picture 缓存静止背景,避免 flip 动画期间重绘 24 张静止碎片
  60. ui.Picture? _staticBackgroundPicture;
  61. // ✅ 优化:跟踪上次录制的关卡,实现增量更新(只更新新完成的碎片)
  62. int? _lastRecordedLevel;
  63. // ✅ 优化:发牌动画每个卡片的预录制 Picture
  64. List<ui.Picture>? _dealingPictures;
  65. @override
  66. void initState() {
  67. super.initState();
  68. Device device = context.read<Device>();
  69. board = HomeBoard(canvasWidth: widget.canvasWidth, canvasHeight: widget.canvasHeight, device: device);
  70. board.isReadyNotifier.addListener(_onBoardReady);
  71. confettiLayer = ConfettiLayer(this);
  72. Future.delayed(Duration.zero, () {
  73. if (mounted) confettiLayer.setup(context);
  74. });
  75. audio = context.read<JcAudioController>();
  76. data = context.read<Data>();
  77. collectionCachedRequest = data.collection;
  78. // 主动获取缓存数据(关键)
  79. final collectionCachedData = collectionCachedRequest.cachedData;
  80. if (collectionCachedData != null) {
  81. _onCollectionDataUpdate(collectionCachedData);
  82. }
  83. collectionSubscription = collectionCachedRequest.stream.listen(_onCollectionDataUpdate, onError: _onCollectionDataError);
  84. _flipController = AnimationController(duration: const Duration(milliseconds: 1000), vsync: this);
  85. _flipAnimation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _flipController, curve: Curves.easeOut))
  86. ..addStatusListener((status) {
  87. if (status == AnimationStatus.completed) {
  88. _checkCollectionDone();
  89. }
  90. });
  91. _unlockController = AnimationController(duration: const Duration(milliseconds: 800), vsync: this);
  92. _unlockAnimation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _unlockController, curve: Curves.easeInBack))
  93. ..addStatusListener((status) {
  94. if (status == AnimationStatus.completed) {
  95. _overlayEntry?.remove();
  96. _overlayEntry = null;
  97. widget.onCollectionDone?.call();
  98. // 如果是低端设备,直接跳过发牌动画
  99. if (board.device.isLowEndDevice) {
  100. _log.info('低端设备,跳过发牌动画');
  101. switchToNextCollection();
  102. if (mounted) {
  103. setState(() {
  104. board.status = HomeBoardStatus.playing;
  105. board.invalidate();
  106. });
  107. }
  108. } else {
  109. _startDealingAnimation();
  110. }
  111. }
  112. });
  113. _dealingController = AnimationController(
  114. duration: Duration(milliseconds: _totalDealingDuration),
  115. vsync: this,
  116. );
  117. _dealingAnimation = CurvedAnimation(parent: _dealingController, curve: Curves.easeOut)
  118. ..addStatusListener((status) {
  119. if (status == AnimationStatus.completed) {
  120. switchToNextCollection();
  121. if (mounted) {
  122. setState(() {
  123. board.status = HomeBoardStatus.playing;
  124. board.invalidate();
  125. });
  126. }
  127. }
  128. });
  129. }
  130. _onCollectionDataUpdate(colldata) async {
  131. _log.info('_onCollectionDataUpdate.... ');
  132. if (colldata != null) {
  133. collection = colldata as List<ListItem>;
  134. if (collection != null && collection!.isNotEmpty) {
  135. if ((data.completedWorks.value.length / 25).floor() > data.currentCollectionIndex) {
  136. if (currentCollectionItem != null) {
  137. _log.info('合集落后于关卡,没有正常切换,矫正!');
  138. data.collectionDone(currentCollectionItem!);
  139. }
  140. }
  141. board.currentCollectionItem = currentCollectionItem;
  142. }
  143. if (mounted) setState(() {});
  144. if (colldata.length < 5) {
  145. Future.delayed(const Duration(seconds: 3), () => refresh());
  146. }
  147. }
  148. }
  149. _onCollectionDataError(error) {
  150. _log.info('_onCollectionDataError.... $error');
  151. if (collection == null || collection!.isEmpty || collection!.length <= 2) {
  152. // 列表数据如果少于20,说明只是内置图,仍然刷新远程请求
  153. _log.warning("_onCollectionDataError, retry again");
  154. Future.delayed(Duration(seconds: 3), () => refresh());
  155. }
  156. }
  157. Future<void> refresh() async {
  158. _log.info('refresh...');
  159. await collectionCachedRequest.refresh();
  160. }
  161. // board 图片等资源加载完成的回调
  162. _onBoardReady() {
  163. if (board.status == HomeBoardStatus.loading && mounted) {
  164. setState(() {
  165. board.status = HomeBoardStatus.playing;
  166. });
  167. // ✅ 优化:图片加载完成后立即预热 Picture 缓存
  168. // 这样 flip 时就不需要等待 PictureRecorder,避免首次 flip 的卡顿
  169. Future.delayed(const Duration(milliseconds: 100), () {
  170. if (mounted && board.image != null && board.cardImage != null && _staticBackgroundPicture == null) {
  171. _recordStaticBackgroundPicture();
  172. }
  173. });
  174. }
  175. }
  176. // !!! 改造点 2: 启动发牌动画
  177. void _startDealingAnimation() {
  178. if (!mounted) return;
  179. // 低端设备无需发牌动画,直接进入下一合集状态
  180. if (board.device.isLowEndDevice) {
  181. _log.info('低端设备,跳过发牌动画 (_startDealingAnimation)');
  182. switchToNextCollection();
  183. if (mounted) {
  184. setState(() {
  185. board.status = HomeBoardStatus.playing;
  186. board.invalidate();
  187. });
  188. }
  189. return;
  190. }
  191. // 录制每张卡片图像(包括文字和边框)
  192. _recordDealingPictures();
  193. setState(() {
  194. board.status = HomeBoardStatus.dealing;
  195. });
  196. _dealingController.duration = Duration(milliseconds: _totalDealingDuration);
  197. _dealingController.forward(from: 0.0);
  198. _dealingCount = 0;
  199. audio.playSfx(SfxType.card);
  200. _dealingPeriodicTimer?.cancel();
  201. _dealingPeriodicTimer = Timer.periodic(const Duration(milliseconds: 130), (timer) {
  202. if (mounted) {
  203. _dealingCount++;
  204. if (_dealingCount >= (_totalDealingDuration / 130) - 2) {
  205. timer.cancel();
  206. } else {
  207. audio.playSfx(SfxType.card);
  208. }
  209. } else {
  210. timer.cancel();
  211. }
  212. });
  213. }
  214. ListItem? get currentCollectionItem {
  215. if (collection != null && collection!.isNotEmpty && data.currentCollectionIndex < collection!.length) {
  216. return collection![data.currentCollectionIndex];
  217. }
  218. return null;
  219. }
  220. @override
  221. void dispose() {
  222. MemoryMonitor.logMemoryUsage('HomeBoardPlay dispose (before)');
  223. board.isReadyNotifier.removeListener(_onBoardReady);
  224. _dealingPeriodicTimer?.cancel();
  225. _overlayEntry?.remove();
  226. _overlayEntry = null;
  227. // ✅ 清理 Picture 缓存
  228. _staticBackgroundPicture = null;
  229. _lastRecordedLevel = null;
  230. board.dispose(); // 调用优化后的 dispose
  231. confettiLayer.dispose();
  232. _flipController.dispose();
  233. _unlockController.dispose();
  234. _dealingController.dispose();
  235. _dealingPictures = null;
  236. collectionSubscription?.cancel();
  237. MemoryMonitor.logMemoryUsage('HomeBoardPlay dispose (after)');
  238. super.dispose();
  239. }
  240. void _checkCollectionDone() async {
  241. if (_isCurCollectionDone) {
  242. if (mounted) {
  243. setState(() {
  244. board.status = HomeBoardStatus.done;
  245. board.invalidate();
  246. });
  247. }
  248. _startUnlockAnimation();
  249. }
  250. }
  251. void _startUnlockAnimation() {
  252. // 防御检查 1: 确保图片已加载,否则跳过动画直接发牌
  253. if (board.image == null) {
  254. _log.warning('Unlock image not ready, skipping animation.');
  255. widget.onCollectionDone?.call();
  256. _startDealingAnimation();
  257. return;
  258. }
  259. final RenderBox? targetRenderBox = widget.collectionKey.currentContext?.findRenderObject() as RenderBox?;
  260. final RenderBox? canvasRenderBox = context.findRenderObject() as RenderBox?;
  261. if (targetRenderBox == null || canvasRenderBox == null || !mounted) return;
  262. final targetPosition = targetRenderBox.localToGlobal(targetRenderBox.size.center(Offset.zero));
  263. final canvasGlobalTL = canvasRenderBox.localToGlobal(Offset.zero);
  264. final canvasCenter = canvasRenderBox.localToGlobal(canvasRenderBox.size.center(Offset.zero));
  265. final Offset delta = targetPosition - canvasCenter;
  266. board.setUnlockAnimationTarget(targetOffset: delta, targetScale: targetRenderBox.size.width * 0.2 / widget.canvasWidth);
  267. _overlayEntry = OverlayEntry(
  268. builder: (context) {
  269. return Positioned(
  270. left: canvasGlobalTL.dx,
  271. top: canvasGlobalTL.dy,
  272. child: SizedBox(
  273. width: widget.canvasWidth,
  274. height: widget.canvasHeight,
  275. child: CustomPaint(
  276. painter: CanvasPainter(
  277. board: board,
  278. level: data.currentLevel,
  279. collectionIndex: data.currentCollectionIndex,
  280. flipAnimation: _flipAnimation,
  281. unlockAnimation: _unlockAnimation,
  282. forceStatus: HomeBoardStatus.unlocking,
  283. dealingAnimation: _dealingAnimation,
  284. dealingPieceInterval: _dealingPieceInterval,
  285. dealingPieceDuration: _dealingPieceDuration,
  286. ),
  287. ),
  288. ),
  289. );
  290. },
  291. );
  292. Overlay.of(context).insert(_overlayEntry!);
  293. if (mounted) {
  294. setState(() {
  295. board.status = HomeBoardStatus.unlocking;
  296. });
  297. }
  298. _unlockController.forward(from: 0.0);
  299. }
  300. void startFlipAnimation() {
  301. // ✅ 优化:每次 flip 前都重新录制背景(因为已完成关卡数在变化)
  302. // 此时 TextPainter 缓存已预热,录制成本大幅降低
  303. if (board.image != null && board.cardImage != null) {
  304. _recordStaticBackgroundPicture();
  305. }
  306. MemoryMonitor.logMemoryUsage('Collection flip animation');
  307. _flipController.forward(from: 0.0);
  308. audio.playSfx(SfxType.flip);
  309. if (data.currentLevel != 0 && (data.currentCollectionIndex + 1) * 25 == data.currentLevel && currentCollectionItem != null) {
  310. data.collectionDone(currentCollectionItem!);
  311. audio.playSfx(SfxType.star);
  312. confettiLayer.play();
  313. // 合集完成时清理内存
  314. // MemoryMonitor().manualCleanup();
  315. }
  316. }
  317. // ✅ 新增:录制所有静止碎片到 Picture,供 flip 动画复用
  318. void _recordStaticBackgroundPicture() {
  319. if (board.image == null || board.cardImage == null) return;
  320. final recorder = ui.PictureRecorder();
  321. final canvas = Canvas(recorder, Rect.fromLTWH(0, 0, widget.canvasWidth, widget.canvasHeight));
  322. // ✅ 优化:增量更新(只更新新完成的碎片)
  323. if (_staticBackgroundPicture != null && _lastRecordedLevel != null && data.currentLevel == _lastRecordedLevel! + 1) {
  324. // 增量模式:复用上次的 Picture,只绘制新完成的碎片
  325. canvas.drawPicture(_staticBackgroundPicture!);
  326. // 只更新新完成的碎片(新翻转的碎片在上一层绘制,覆盖背景中的对应位置)
  327. final int newFlippedIndex = data.currentLevel - 1 - (data.currentCollectionIndex * board.count);
  328. if (newFlippedIndex >= 0 && newFlippedIndex < board.count) {
  329. final int row = newFlippedIndex ~/ board.cols;
  330. final int col = newFlippedIndex % board.cols;
  331. _drawStaticPieceToRecorder(canvas, row, col, true, newFlippedIndex);
  332. }
  333. } else {
  334. // 首次录制或状态不连续(集合切换等),全量重新录制
  335. for (var i = 0; i < board.rows; i++) {
  336. for (var j = 0; j < board.cols; j++) {
  337. final int curIndex = i * board.rows + j;
  338. final bool flipped = data.currentLevel > data.currentCollectionIndex * board.count + curIndex;
  339. _drawStaticPieceToRecorder(canvas, i, j, flipped, curIndex);
  340. }
  341. }
  342. }
  343. _staticBackgroundPicture = recorder.endRecording();
  344. _lastRecordedLevel = data.currentLevel;
  345. }
  346. // ✅ 新增:录制发牌动画所需的每张卡片 Picture
  347. void _recordDealingPictures() {
  348. if (board.cardImage == null) return;
  349. final int collectionIndex = data.currentCollectionIndex;
  350. final img = board.cardImage!;
  351. final w = widget.canvasWidth / board.cols;
  352. final h = widget.canvasHeight / board.rows;
  353. _dealingPictures = [];
  354. for (var i = 0; i < board.rows; i++) {
  355. for (var j = 0; j < board.cols; j++) {
  356. final int curIndex = i * board.cols + j;
  357. final int displayIndex = collectionIndex * board.count + curIndex;
  358. final recorder = ui.PictureRecorder();
  359. final canvas = Canvas(recorder, Rect.fromLTWH(0, 0, w, h));
  360. // draw card image
  361. final cardSourceRect = Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble());
  362. final rect = Rect.fromLTWH(0, 0, w, h);
  363. canvas.drawImageRect(img, cardSourceRect, rect, Paint()..isAntiAlias = true);
  364. // number text (include collection offset)
  365. final (textPainter, textWidth, textHeight) = CanvasPainter._getTextPainterWithLayout((displayIndex + 1).toString(), h * 0.25, w);
  366. textPainter.paint(canvas, Offset((w - textWidth) / 2, (h - textHeight) / 2));
  367. // borders
  368. canvas.drawRRect(
  369. RRect.fromRectAndRadius(rect.deflate(0.5), const Radius.circular(4.0)),
  370. Paint()
  371. ..color = SkinHelper.outLineBorderColor
  372. ..style = PaintingStyle.stroke
  373. ..strokeWidth = 1.0
  374. ..isAntiAlias = true,
  375. );
  376. canvas.drawRRect(
  377. RRect.fromRectAndRadius(rect.deflate(1.5), const Radius.circular(4.0)),
  378. Paint()
  379. ..color = SkinHelper.innerLineBorderColor
  380. ..style = PaintingStyle.stroke
  381. ..strokeWidth = 1.0
  382. ..isAntiAlias = true,
  383. );
  384. _dealingPictures!.add(recorder.endRecording());
  385. }
  386. }
  387. }
  388. // ✅ 辅助方法:绘制单个碎片到 PictureRecorder
  389. void _drawStaticPieceToRecorder(Canvas canvas, int row, int col, bool flipped, int curIndex) {
  390. final img = board.image;
  391. final cardImg = board.cardImage;
  392. if (img == null || cardImg == null) return;
  393. final w = widget.canvasWidth / board.cols;
  394. final h = widget.canvasHeight / board.rows;
  395. final left = col * w;
  396. final top = row * h;
  397. final rect = Rect.fromLTWH(left, top, w, h);
  398. final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(4.0));
  399. final pieceWidth = img.width / board.cols;
  400. final pieceHeight = img.height / board.rows;
  401. final imageSourceRect = Rect.fromLTWH(pieceWidth * col, pieceHeight * row, pieceWidth, pieceHeight);
  402. final cardSourceRect = Rect.fromLTWH(0, 0, cardImg.width.toDouble(), cardImg.height.toDouble());
  403. canvas.save();
  404. canvas.clipRRect(rrect);
  405. if (flipped) {
  406. canvas.drawImageRect(img, imageSourceRect, rect, Paint()..isAntiAlias = true);
  407. } else {
  408. canvas.drawImageRect(cardImg, cardSourceRect, rect, Paint()..isAntiAlias = true);
  409. // ✅ 修复:绘制背卡上的数字(直接创建临时 TextPainter)
  410. final textStyle = TextStyle(
  411. color: Colors.white,
  412. fontSize: h * 0.25,
  413. fontWeight: FontWeight.bold,
  414. shadows: const [Shadow(offset: Offset(1, 1), blurRadius: 2, color: Colors.black38)],
  415. );
  416. final textPainter = TextPainter(
  417. text: TextSpan(text: (curIndex + 1).toString(), style: textStyle),
  418. textDirection: TextDirection.ltr,
  419. textAlign: TextAlign.center,
  420. );
  421. textPainter.layout(minWidth: 0, maxWidth: w);
  422. textPainter.paint(canvas, Offset(left + (w - textPainter.width) / 2, top + (h - textPainter.height) / 2));
  423. }
  424. canvas.restore();
  425. // 绘制边框
  426. canvas.drawRRect(
  427. RRect.fromRectAndRadius(rect.deflate(0.5), const Radius.circular(4.0)),
  428. Paint()
  429. ..color = SkinHelper.outLineBorderColor
  430. ..style = PaintingStyle.stroke
  431. ..strokeWidth = 1.0
  432. ..isAntiAlias = true,
  433. );
  434. canvas.drawRRect(
  435. RRect.fromRectAndRadius(rect.deflate(1.5), const Radius.circular(4.0)),
  436. Paint()
  437. ..color = SkinHelper.innerLineBorderColor
  438. ..style = PaintingStyle.stroke
  439. ..strokeWidth = 1.0
  440. ..isAntiAlias = true,
  441. );
  442. }
  443. void switchToNextCollection() {
  444. if (currentCollectionItem == null) {
  445. Fluttertoast.showToast(msg: AppLocalizations.of(context)!.noMorePicture);
  446. return;
  447. }
  448. // ✅ 清理 Picture 缓存,因为集合改变了
  449. _staticBackgroundPicture = null;
  450. _lastRecordedLevel = null;
  451. _dealingPictures = null;
  452. board.switchToNextCollection(currentCollectionItem!);
  453. }
  454. @override
  455. Widget build(BuildContext context) {
  456. final isUnlocking = _unlockController.isAnimating || board.status == HomeBoardStatus.unlocking;
  457. return CustomPaint(
  458. size: Size(widget.canvasWidth, widget.canvasHeight),
  459. painter: isUnlocking && _overlayEntry != null
  460. ? null
  461. : CanvasPainter(
  462. board: board,
  463. level: data.currentLevel,
  464. collectionIndex: data.currentCollectionIndex,
  465. flipAnimation: _flipAnimation,
  466. unlockAnimation: _unlockAnimation,
  467. dealingAnimation: _dealingAnimation,
  468. dealingPieceDuration: _dealingPieceDuration,
  469. dealingPieceInterval: _dealingPieceInterval,
  470. staticBackgroundPicture: _staticBackgroundPicture,
  471. dealingPictures: _dealingPictures,
  472. ),
  473. child: board.status == HomeBoardStatus.loading
  474. ? Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(SkinHelper.slotBorderColor)))
  475. : Container(),
  476. );
  477. }
  478. }
  479. class CanvasPainter extends CustomPainter {
  480. final HomeBoard board;
  481. final int level;
  482. final int collectionIndex;
  483. final Animation<double> flipAnimation;
  484. final Animation<double> unlockAnimation;
  485. final HomeBoardStatus? forceStatus;
  486. final Animation<double> dealingAnimation;
  487. final int dealingPieceDuration;
  488. final int dealingPieceInterval;
  489. // ✅ 优化:Picture 缓存,flip 动画期间使用预录制的背景
  490. final ui.Picture? staticBackgroundPicture;
  491. // ✅ 优化:缓存发牌碎片 Picture
  492. final List<ui.Picture>? dealingPictures;
  493. // ✅ 优化:缓存 Matrix4 变换矩阵,避免每帧重新计算
  494. static double _lastFlipProgress = -1.0;
  495. static Matrix4? _cachedFlipTransform;
  496. // ✅ 优化:缓存 TextPainter 的布局结果(宽高),避免重复 layout() 调用
  497. static final Map<String, (double width, double height)> _textPainterLayoutCache = {};
  498. // ⚠️ 优化:缓存 TextPainter 避免每帧重建
  499. static final Map<String, TextPainter> _textPainterCache = {};
  500. CanvasPainter({
  501. required this.board,
  502. required this.level,
  503. required this.collectionIndex,
  504. required this.flipAnimation,
  505. required this.unlockAnimation,
  506. this.forceStatus,
  507. required this.dealingAnimation,
  508. required this.dealingPieceDuration,
  509. required this.dealingPieceInterval,
  510. this.staticBackgroundPicture,
  511. this.dealingPictures,
  512. }) : super(repaint: Listenable.merge([board.boardNotifier, flipAnimation, unlockAnimation, dealingAnimation]));
  513. static TextPainter _getOrCreateTextPainter(String text, double fontSize) {
  514. final key = '$text-$fontSize';
  515. if (!_textPainterCache.containsKey(key)) {
  516. final textStyle = TextStyle(
  517. color: Colors.white,
  518. fontSize: fontSize,
  519. fontWeight: FontWeight.bold,
  520. shadows: const [Shadow(offset: Offset(1, 1), blurRadius: 2, color: Colors.black38)],
  521. );
  522. _textPainterCache[key] = TextPainter(
  523. text: TextSpan(text: text, style: textStyle),
  524. textDirection: TextDirection.ltr,
  525. textAlign: TextAlign.center,
  526. );
  527. }
  528. return _textPainterCache[key]!;
  529. }
  530. // ✅ 新增:获取 TextPainter 并返回其宽高(缓存布局结果,避免重复 layout)
  531. static (TextPainter, double width, double height) _getTextPainterWithLayout(String text, double fontSize, double maxWidth) {
  532. final key = '$text-$fontSize-$maxWidth';
  533. final textPainter = _getOrCreateTextPainter(text, fontSize);
  534. // 检查是否已缓存布局结果
  535. if (!_textPainterLayoutCache.containsKey(key)) {
  536. textPainter.layout(minWidth: 0, maxWidth: maxWidth);
  537. _textPainterLayoutCache[key] = (textPainter.width, textPainter.height);
  538. }
  539. final (width, height) = _textPainterLayoutCache[key]!;
  540. return (textPainter, width, height);
  541. }
  542. @override
  543. void paint(Canvas canvas, Size size) {
  544. final statusToPaint = forceStatus ?? board.status;
  545. // 根据不同状态执行绘制,每个方法内部现在都有 null 检查
  546. switch (statusToPaint) {
  547. case HomeBoardStatus.playing:
  548. _paintPlaying(canvas, size);
  549. break;
  550. case HomeBoardStatus.done:
  551. _paintSuccess(canvas, size);
  552. break;
  553. case HomeBoardStatus.unlocking:
  554. _paintUnlocking(canvas, size);
  555. break;
  556. case HomeBoardStatus.dealing:
  557. _paintDealing(canvas, size);
  558. break;
  559. case HomeBoardStatus.loading:
  560. break;
  561. }
  562. }
  563. void _paintDealing(Canvas canvas, Size size) {
  564. final pictures = dealingPictures;
  565. if (board.cardImage == null) return; // 防御性检查
  566. final totalPieces = board.rows * board.cols;
  567. final totalAnimationDuration = dealingPieceInterval * (totalPieces - 1) + dealingPieceDuration;
  568. final double w = board.pieceLogicalWidth;
  569. final double h = board.pieceLogicalHeight;
  570. final startX = board.cols - 1;
  571. final startY = board.rows - 1;
  572. final startPieceCenterX = startX * w + w / 2;
  573. final startPieceCenterY = startY * h + h / 2;
  574. final currentGlobalTime = dealingAnimation.value * totalAnimationDuration;
  575. for (int idx = 0; idx < totalPieces; idx++) {
  576. final int i = idx ~/ board.cols;
  577. final int j = idx % board.cols;
  578. final double startTime = idx * dealingPieceInterval.toDouble();
  579. if (currentGlobalTime < startTime) continue;
  580. double progress = (currentGlobalTime - startTime) / dealingPieceDuration;
  581. progress = progress.clamp(0.0, 1.0);
  582. final double targetLeft = j * w;
  583. final double targetTop = i * h;
  584. final double currentOffsetX = ui.lerpDouble(startPieceCenterX - (j * w + w / 2), 0.0, progress) ?? 0.0;
  585. final double currentOffsetY = ui.lerpDouble(startPieceCenterY - (i * h + h / 2), 0.0, progress) ?? 0.0;
  586. canvas.save();
  587. canvas.translate(targetLeft + currentOffsetX, targetTop + currentOffsetY);
  588. if (pictures != null && idx < pictures.length) {
  589. canvas.drawPicture(pictures[idx]);
  590. } else {
  591. // fallback to old drawing path
  592. final cardImg = board.cardImage!;
  593. final cardSourceRect = Rect.fromLTWH(0, 0, cardImg.width.toDouble(), cardImg.height.toDouble());
  594. final rect = Rect.fromLTWH(0, 0, w, h);
  595. canvas.drawImageRect(cardImg, cardSourceRect, rect, Paint()..isAntiAlias = true);
  596. final int displayIdx = collectionIndex * board.count + idx;
  597. final (textPainter, textWidth, textHeight) = CanvasPainter._getTextPainterWithLayout((displayIdx + 1).toString(), h * 0.25, w);
  598. textPainter.paint(canvas, Offset((w - textWidth) / 2, (h - textHeight) / 2));
  599. _drawBorders(canvas, 0, 0, w, h);
  600. }
  601. canvas.restore();
  602. }
  603. }
  604. void _paintUnlocking(Canvas canvas, Size size) {
  605. final img = board.image;
  606. if (img == null) return;
  607. final progress = unlockAnimation.value;
  608. final currentScale = ui.lerpDouble(1.0, board.unlockTargetScale, progress) ?? 1.0;
  609. final currentOffset = Offset(
  610. ui.lerpDouble(0.0, board.unlockTargetOffset.dx, progress) ?? 0.0,
  611. ui.lerpDouble(0.0, board.unlockTargetOffset.dy, progress) ?? 0.0,
  612. );
  613. canvas.save();
  614. final centerX = size.width / 2;
  615. final centerY = size.height / 2;
  616. canvas.translate(centerX + currentOffset.dx, centerY + currentOffset.dy);
  617. canvas.scale(currentScale);
  618. canvas.translate(-centerX, -centerY);
  619. final rect = Rect.fromLTWH(0, 0, size.width, size.height);
  620. final sourceRect = Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble());
  621. canvas.drawImageRect(img, sourceRect, rect, Paint()..isAntiAlias = true);
  622. canvas.restore();
  623. }
  624. void _paintSuccess(Canvas canvas, Size size) {
  625. final img = board.image;
  626. if (img == null) return;
  627. final rect = Rect.fromLTWH(0, 0, size.width, size.height);
  628. final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(4.0));
  629. final sourceRect = Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble());
  630. canvas.save();
  631. canvas.clipRRect(rrect);
  632. canvas.drawImageRect(img, sourceRect, rect, Paint()..isAntiAlias = true);
  633. canvas.restore();
  634. _drawBorders(canvas, 0, 0, size.width, size.height);
  635. }
  636. void _paintPlaying(Canvas canvas, Size size) {
  637. // ✅ 优化:如果 flip 动画正在进行且背景已缓存,直接绘制 Picture
  638. final isFlipping = flipAnimation.isAnimating;
  639. if (isFlipping && staticBackgroundPicture != null) {
  640. // 绘制预录制的背景(所有非翻转和已翻转的碎片都在其中)
  641. canvas.drawPicture(staticBackgroundPicture!);
  642. // 只在翻转碎片上绘制 3D 变换版本(覆盖 Picture 中该位置的内容)
  643. // 但首先需要清空预录制背景中该位置的内容,使翻转过程中显示为空白
  644. final int curFlippingIndex = level - 1;
  645. if (curFlippingIndex >= 0) {
  646. final int row = curFlippingIndex ~/ board.cols;
  647. final int col = curFlippingIndex % board.cols;
  648. // 计算碎片在画布上的区域并清空该区域
  649. final double w = size.width / board.cols;
  650. final double h = size.height / board.rows;
  651. final double left = col * w;
  652. final double top = row * h;
  653. final Rect clearRect = Rect.fromLTWH(left, top, w, h);
  654. // 填充为白色背景,覆盖预录制的碎片,使翻转过程中该位置显示为白色
  655. final rrect = RRect.fromRectAndRadius(clearRect, const Radius.circular(4.0));
  656. canvas.drawRRect(rrect, Paint()..color = Colors.white);
  657. bool flipped = level > collectionIndex * board.count + curFlippingIndex;
  658. _drawPiece(canvas, size, row, col, flipped);
  659. }
  660. } else {
  661. // 常规方式:绘制所有碎片(首次加载、非翻转状态)
  662. for (var i = 0; i < board.rows; i++) {
  663. for (var j = 0; j < board.cols; j++) {
  664. final int curIndex = i * board.cols + j;
  665. bool flipped = level > collectionIndex * board.count + curIndex;
  666. _drawPiece(canvas, size, i, j, flipped);
  667. }
  668. }
  669. }
  670. }
  671. void _drawPiece(Canvas canvas, Size size, int row, int col, bool flipped) {
  672. final img = board.image;
  673. final cardImg = board.cardImage;
  674. if (img == null || cardImg == null) return;
  675. final w = size.width / board.cols;
  676. final h = size.height / board.rows;
  677. final left = col * w;
  678. final top = row * h;
  679. final rect = Rect.fromLTWH(left, top, w, h);
  680. final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(4.0));
  681. final pieceWidth = img.width / board.cols;
  682. final pieceHeight = img.height / board.rows;
  683. final imageSourceRect = Rect.fromLTWH(pieceWidth * col, pieceHeight * row, pieceWidth, pieceHeight);
  684. final cardSourceRect = Rect.fromLTWH(0, 0, cardImg.width.toDouble(), cardImg.height.toDouble());
  685. final curIndex = collectionIndex * board.count + row * board.rows + col;
  686. double flipProgress = (flipAnimation.isAnimating && curIndex == level - 1) ? flipAnimation.value : 0.0;
  687. // ⚠️ 优化:只有当前翻转的碎片才计算 3D 变换
  688. if (flipProgress > 0) {
  689. flipped = flipProgress > 0.5;
  690. _drawFlippingPiece(canvas, rect, rrect, img, cardImg, imageSourceRect, cardSourceRect, flipProgress, flipped, curIndex, w, h, left, top);
  691. } else {
  692. // 静态碎片,无需 3D 变换
  693. _drawStaticPiece(canvas, rect, rrect, img, cardImg, imageSourceRect, cardSourceRect, flipped, curIndex, w, h, left, top);
  694. }
  695. _drawBorders(canvas, left, top, w, h);
  696. }
  697. void _drawStaticPiece(
  698. Canvas canvas,
  699. Rect rect,
  700. RRect rrect,
  701. ui.Image img,
  702. ui.Image cardImg,
  703. Rect imageSourceRect,
  704. Rect cardSourceRect,
  705. bool flipped,
  706. int curIndex,
  707. double w,
  708. double h,
  709. double left,
  710. double top,
  711. ) {
  712. canvas.save();
  713. canvas.clipRRect(rrect);
  714. if (flipped) {
  715. canvas.drawImageRect(img, imageSourceRect, rect, Paint()..isAntiAlias = true);
  716. } else {
  717. canvas.drawImageRect(cardImg, cardSourceRect, rect, Paint()..isAntiAlias = true);
  718. // ✅ 优化:使用缓存的 TextPainter 和布局结果
  719. final (textPainter, textWidth, textHeight) = _getTextPainterWithLayout((curIndex + 1).toString(), h * 0.25, w);
  720. textPainter.paint(canvas, Offset(left + (w - textWidth) / 2, top + (h - textHeight) / 2));
  721. }
  722. canvas.restore();
  723. }
  724. void _drawFlippingPiece(
  725. Canvas canvas,
  726. Rect rect,
  727. RRect rrect,
  728. ui.Image img,
  729. ui.Image cardImg,
  730. Rect imageSourceRect,
  731. Rect cardSourceRect,
  732. double flipProgress,
  733. bool flipped,
  734. int curIndex,
  735. double w,
  736. double h,
  737. double left,
  738. double top,
  739. ) {
  740. canvas.save();
  741. final centerX = left + w / 2;
  742. final centerY = top + h / 2;
  743. canvas.translate(centerX, centerY);
  744. // ✅ 优化:缓存 Matrix4 变换矩阵,只在 flipProgress 变化时重新计算
  745. Matrix4 transform;
  746. if (_lastFlipProgress != flipProgress) {
  747. double angle = flipProgress * pi;
  748. transform = Matrix4.identity()
  749. ..setEntry(3, 2, 0.0015)
  750. ..rotateY(angle);
  751. if (flipProgress > 0.5) transform.scale(-1.0, 1.0, 1.0);
  752. _cachedFlipTransform = transform;
  753. _lastFlipProgress = flipProgress;
  754. } else {
  755. transform = _cachedFlipTransform ?? Matrix4.identity();
  756. }
  757. canvas.transform(transform.storage);
  758. canvas.translate(-centerX, -centerY);
  759. canvas.clipRRect(rrect);
  760. if (flipped) {
  761. canvas.drawImageRect(img, imageSourceRect, rect, Paint()..isAntiAlias = true);
  762. } else {
  763. final targetImg = flipProgress > 0.5 ? img : cardImg;
  764. final sourceRect = flipProgress > 0.5 ? imageSourceRect : cardSourceRect;
  765. canvas.drawImageRect(targetImg, sourceRect, rect, Paint()..isAntiAlias = true);
  766. if (flipProgress <= 0.5) {
  767. // ✅ 优化:使用缓存的 TextPainter 和布局结果
  768. final (textPainter, textWidth, textHeight) = _getTextPainterWithLayout((curIndex + 1).toString(), h * 0.25, w);
  769. textPainter.paint(canvas, Offset(left + (w - textWidth) / 2, top + (h - textHeight) / 2));
  770. }
  771. }
  772. canvas.restore();
  773. }
  774. void _drawBorders(Canvas canvas, double x, double y, double w, double h) {
  775. final rect = Rect.fromLTWH(x, y, w, h);
  776. canvas.drawRRect(
  777. RRect.fromRectAndRadius(rect.deflate(0.5), const Radius.circular(4.0)),
  778. Paint()
  779. ..color = SkinHelper.outLineBorderColor
  780. ..style = PaintingStyle.stroke
  781. ..strokeWidth = 1.0
  782. ..isAntiAlias = true,
  783. );
  784. canvas.drawRRect(
  785. RRect.fromRectAndRadius(rect.deflate(1.5), const Radius.circular(4.0)),
  786. Paint()
  787. ..color = SkinHelper.innerLineBorderColor
  788. ..style = PaintingStyle.stroke
  789. ..strokeWidth = 1.0
  790. ..isAntiAlias = true,
  791. );
  792. }
  793. @override
  794. bool shouldRepaint(covariant CanvasPainter oldDelegate) {
  795. // ✅ 优化:flip 动画期间,如果使用了 Picture 缓存,仍需重绘但不需要重新计算所有碎片
  796. // - 状态变化 / level 变化 / collectionIndex 变化 → 总是需要重绘
  797. // - flipAnimation.isAnimating → 需要重绘,但使用 Picture 缓存只绘制翻转碎片
  798. // - unlockAnimation.isAnimating / dealingAnimation.isAnimating → 总是需要重绘
  799. return oldDelegate.board.status != (forceStatus ?? board.status) ||
  800. oldDelegate.level != level ||
  801. oldDelegate.collectionIndex != collectionIndex ||
  802. flipAnimation.isAnimating ||
  803. unlockAnimation.isAnimating ||
  804. dealingAnimation.isAnimating;
  805. }
  806. }