home_board_play.dart 32 KB

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