board_play.dart 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213
  1. import 'dart:async';
  2. import 'dart:io';
  3. import 'dart:math';
  4. import 'dart:ui' as ui;
  5. import 'package:flutter/foundation.dart';
  6. import 'package:flutter/material.dart';
  7. import 'package:flutter/services.dart';
  8. import 'package:fluttertoast/fluttertoast.dart';
  9. import 'package:logging/logging.dart';
  10. import 'package:provider/provider.dart';
  11. import 'package:puzzleweave/audio/jc_audio_controller.dart';
  12. import 'package:puzzleweave/config/device.dart';
  13. import 'package:puzzleweave/firebase/adjust_helper.dart';
  14. import 'package:puzzleweave/firebase/firebase_helper.dart';
  15. import 'package:puzzleweave/l10n/app_localizations.dart';
  16. import 'package:puzzleweave/models/download.dart';
  17. import 'package:puzzleweave/models/items.dart';
  18. import 'package:puzzleweave/persistence/persistence.dart';
  19. import 'package:puzzleweave/play/board.dart';
  20. import 'package:puzzleweave/play/board_painter.dart';
  21. import 'package:puzzleweave/play/confetti_layer.dart';
  22. import 'package:puzzleweave/play/overlayer.dart';
  23. import 'package:puzzleweave/play/piece.dart';
  24. import 'package:puzzleweave/rating/rating_helper.dart';
  25. import 'package:puzzleweave/rating/rating_utils.dart';
  26. import 'package:puzzleweave/settings/settings_controller.dart';
  27. import 'package:puzzleweave/settings/settings_dialog.dart';
  28. import 'package:puzzleweave/skin/skin.dart';
  29. import 'package:puzzleweave/statistics/statistics.dart';
  30. import 'package:puzzleweave/utils/mybutton.dart';
  31. import 'package:puzzleweave/utils/utils.dart';
  32. import 'package:puzzleweave/utils/memory_monitor.dart';
  33. import 'package:vector_math/vector_math.dart' as vmath;
  34. import 'package:vibration/vibration.dart';
  35. import '../ads/ads_state.dart';
  36. final Logger _log = Logger('board_play.dart');
  37. enum MoveType { group, single }
  38. enum Action { revert, swap }
  39. class BoardPlay extends StatefulWidget {
  40. final ListItem item;
  41. final bool firstRun;
  42. final bool reset;
  43. final String tag;
  44. const BoardPlay({super.key, required this.item, this.firstRun = false, this.reset = false, this.tag = 'home'});
  45. @override
  46. State<StatefulWidget> createState() => _BoardPlayState();
  47. static PageRouteBuilder buildRoute(ListItem item, {bool firstRun = false, bool reset = false}) {
  48. return PageRouteBuilder(
  49. pageBuilder: (context, animation, secondaryAnimation) => BoardPlay(item: item, firstRun: firstRun, reset: reset),
  50. transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
  51. );
  52. }
  53. }
  54. class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin {
  55. final GlobalKey boardKey = GlobalKey();
  56. Board? board;
  57. bool _isLoading = true;
  58. int progress = 0;
  59. bool isDownloadSlow = false;
  60. late Timer timer;
  61. late ItemLoader itemLoader;
  62. late JcAudioController audio;
  63. late SettingsController settings;
  64. late ConfettiLayer confettiLayer;
  65. ui.Image? _fingerImage;
  66. OverLayer? _overLayer;
  67. Piece? _draggingPiece;
  68. List<MoveItem>? moveItems;
  69. // ✅ 优化:资源清理定时器
  70. Timer? _resourceCleanupTimer;
  71. // 动画控制器
  72. late AnimationController _moveAnimationController;
  73. late AnimationController _mergeAnimationController;
  74. late Animation<double> _mergeScaleAnimation;
  75. List<PieceGroup>? _mergeGroups;
  76. bool showDealing = true;
  77. late AnimationController _prepareAnimationController;
  78. late AnimationController dealingAnimationController;
  79. late AnimationController flipAnimationController;
  80. late Animation<double> _dealingAnimation;
  81. Timer? _dealingPeriodicTimer;
  82. int _dealingCount = 0;
  83. late AnimationController _successAnimationController;
  84. late Animation<double> _offsetAnimation;
  85. late Animation<double> _bottomSlideAnimation;
  86. late Animation<double> _topSlideAnimation;
  87. late AnimationController _hardModeBannerController;
  88. late Animation<double> _bannerScaleAnimation;
  89. late Animation<double> _bannerFadeAnimation;
  90. bool _showHardModeBanner = false;
  91. // ✅ 保留:重试标记,防止无限循环
  92. bool _hasRetried = false;
  93. @override
  94. initState() {
  95. super.initState();
  96. MemoryMonitor.logMemoryUsage('BoardPlay initState');
  97. final Device device = context.read<Device>();
  98. itemLoader = ItemLoader.load(widget.item, device.suggestedQuality);
  99. _onProgressUpdate();
  100. itemLoader.progress.addListener(_onProgressUpdate);
  101. timer = Timer(const Duration(seconds: 5), () {
  102. if (mounted && progress < 50) {
  103. if (progress <= 1) {
  104. Fluttertoast.showToast(
  105. msg: AppLocalizations.of(context)!.networkNotGood,
  106. toastLength: Toast.LENGTH_SHORT,
  107. gravity: ToastGravity.CENTER,
  108. timeInSecForIosWeb: 1,
  109. backgroundColor: SkinHelper.slotBorderColor,
  110. textColor: Colors.white,
  111. fontSize: 16.0,
  112. );
  113. Navigator.pop(context);
  114. } else {
  115. setState(() => isDownloadSlow = true);
  116. }
  117. }
  118. });
  119. audio = context.read<JcAudioController>();
  120. settings = context.read<SettingsController>();
  121. confettiLayer = ConfettiLayer(this);
  122. Future.delayed(Duration.zero, () {
  123. if (mounted) confettiLayer.setup(context);
  124. });
  125. _initAnimations();
  126. try {
  127. _init();
  128. } catch (error) {
  129. _log.info('board init error: $error');
  130. Fluttertoast.showToast(
  131. msg: AppLocalizations.of(context)!.networkNotGood,
  132. toastLength: Toast.LENGTH_SHORT,
  133. gravity: ToastGravity.CENTER,
  134. timeInSecForIosWeb: 1,
  135. backgroundColor: SkinHelper.slotBorderColor,
  136. textColor: Colors.white,
  137. fontSize: 16.0,
  138. );
  139. Navigator.pop(context);
  140. }
  141. }
  142. void _initAnimations() {
  143. final Device device = context.read<Device>();
  144. _moveAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
  145. _moveAnimationController.addListener(_moveAnimationListener);
  146. _moveAnimationController.addStatusListener(_moveAnimationStatusListener);
  147. _mergeAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
  148. _mergeAnimationController.addListener(_mergeAnimationListener);
  149. _mergeAnimationController.addStatusListener(_mergeAnimationStatusListener);
  150. _mergeScaleAnimation = TweenSequence<double>([
  151. TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 1.06), weight: 50),
  152. TweenSequenceItem(tween: Tween<double>(begin: 1.06, end: 1.0), weight: 50),
  153. ]).animate(CurvedAnimation(parent: _mergeAnimationController, curve: Curves.easeInOut));
  154. _prepareAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 800));
  155. _prepareAnimationController.addListener(_prepareAnimationListener);
  156. _prepareAnimationController.addStatusListener(_prepareAnimationStatusListener);
  157. dealingAnimationController = AnimationController(vsync: this);
  158. _dealingAnimation = CurvedAnimation(parent: dealingAnimationController, curve: Curves.linear);
  159. dealingAnimationController.addListener(_dealingAnimationListener);
  160. dealingAnimationController.addStatusListener(_dealingAnimationStatusListener);
  161. flipAnimationController = AnimationController(vsync: this, duration: Duration(milliseconds: 500));
  162. flipAnimationController.addListener(_flipAnimationListener);
  163. flipAnimationController.addStatusListener(_flipAnimationStatusListener);
  164. _successAnimationController = AnimationController(vsync: this, duration: Duration(milliseconds: 500));
  165. final deltaY = (device.targetRect.top - device.appBarHeight) / 3;
  166. _offsetAnimation = Tween<double>(begin: 0.0, end: -deltaY).animate(_successAnimationController);
  167. _bottomSlideAnimation = Tween<double>(
  168. begin: -500,
  169. end: device.screenSize.height - device.targetRect.bottom + deltaY - 60,
  170. ).animate(CurvedAnimation(parent: _successAnimationController, curve: Curves.easeOut));
  171. _topSlideAnimation = Tween<double>(
  172. begin: -200,
  173. end: device.targetRect.top - deltaY - 70,
  174. ).animate(CurvedAnimation(parent: _successAnimationController, curve: Curves.easeOut));
  175. _successAnimationController.addListener(_successAnimationListener);
  176. _successAnimationController.addStatusListener(_successAnimationStatusListener);
  177. _hardModeBannerController = AnimationController(vsync: this, duration: const Duration(milliseconds: 1500));
  178. _bannerScaleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
  179. CurvedAnimation(
  180. parent: _hardModeBannerController,
  181. curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
  182. ),
  183. );
  184. _bannerFadeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
  185. CurvedAnimation(
  186. parent: _hardModeBannerController,
  187. curve: const Interval(0.3, 1.0, curve: Curves.easeIn),
  188. ),
  189. );
  190. _hardModeBannerController.addStatusListener((status) {
  191. if (status == AnimationStatus.completed) {
  192. if (mounted) setState(() => _showHardModeBanner = false);
  193. }
  194. });
  195. }
  196. _onProgressUpdate() {
  197. progress = (itemLoader.progress.value * 100).ceil();
  198. _log.info('onProgressUpdate: progress=$progress');
  199. setState(() {});
  200. }
  201. void saveProgress() async {
  202. _log.info('saveProgress');
  203. if (board != null && board!.isAllDone == false) {
  204. await saveJson(widget.item.jsonPath, board!.toJson());
  205. }
  206. }
  207. // ✅ 优化后的 _init() 方法 - 保留图片损坏检测和重试逻辑
  208. _init() async {
  209. Device device = context.read<Device>();
  210. setState(() => _isLoading = true);
  211. try {
  212. final dpr = device.effectivePixelRatio;
  213. final targetRect = device.targetRect;
  214. final bestImageSize = device.bestImageSize;
  215. // ✅ 1. 尝试获取并解码图片(保留原有的错误处理逻辑)
  216. ui.Image image;
  217. try {
  218. image = await itemLoader.getImageBySize(bestImageSize.width.round(), bestImageSize.height.round());
  219. } catch (e) {
  220. _log.severe('Image decode failed: $e. Possible corrupted file.');
  221. // ✅ 保留:如果解码失败且还没重试过,尝试删除缓存重来
  222. if (!_hasRetried) {
  223. _hasRetried = true;
  224. final file = await localFile(widget.item.cachePath);
  225. if (await file.exists()) {
  226. await file.delete();
  227. _log.warning('Deleted corrupted cache file: ${widget.item.cachePath}');
  228. }
  229. // 重新初始化下载逻辑
  230. itemLoader = ItemLoader.load(widget.item, device.suggestedQuality);
  231. // 递归调用一次
  232. return _init();
  233. } else {
  234. // 已经重试过还是失败,抛出异常进入 catch 块
  235. throw Exception("Image data invalid after retry");
  236. }
  237. }
  238. _log.info('imageSize: (${image.width},${image.height}), bestImageSize: ($bestImageSize)');
  239. // ✅ 2. 加载扑克背面图片
  240. final Size bestCardImageSize = Size(targetRect.width * dpr / widget.item.rows, targetRect.height * dpr / widget.item.cols);
  241. final ByteData cardData = await rootBundle.load(widget.item.hard ? 'assets/images/backcard_red.png' : 'assets/images/backcard_blue.png');
  242. ui.Image cardImage;
  243. final ui.Codec cardCodec = await ui.instantiateImageCodec(
  244. cardData.buffer.asUint8List(),
  245. targetWidth: bestCardImageSize.width.round(),
  246. targetHeight: bestCardImageSize.height.round(),
  247. );
  248. final ui.FrameInfo cardFrameInfo = await cardCodec.getNextFrame();
  249. cardImage = cardFrameInfo.image;
  250. // ✅ 3. 构建或恢复 Board 实例
  251. if (widget.reset) {
  252. board = await Board.create(this, image, cardImage, widget.item.rows, widget.item.cols, widget.item.hard, targetRect, device);
  253. } else {
  254. final jsonFile = await localFile(widget.item.jsonPath);
  255. if (await jsonFile.exists()) {
  256. showDealing = false;
  257. board = await Board.restore(this, image, cardImage, widget.item.rows, widget.item.cols, widget.item.hard, targetRect, device, widget.item.jsonPath);
  258. } else {
  259. board = await Board.create(this, image, cardImage, widget.item.rows, widget.item.cols, widget.item.hard, targetRect, device);
  260. _reportLevelStart();
  261. }
  262. }
  263. // 4. 初始化准备
  264. board!.prepare();
  265. _loadFingerImageAndSetupHint();
  266. if (mounted) {
  267. setState(() => _isLoading = false);
  268. }
  269. // 5. 启动入场动画
  270. if (showDealing) {
  271. _prepareAnimationController.forward(from: 0.0);
  272. } else {
  273. board!.start();
  274. }
  275. } catch (error) {
  276. _log.severe('Board _init critical error: $error');
  277. if (mounted) {
  278. Fluttertoast.showToast(
  279. msg: AppLocalizations.of(context)!.networkNotGood,
  280. toastLength: Toast.LENGTH_LONG,
  281. gravity: ToastGravity.CENTER,
  282. backgroundColor: SkinHelper.slotBorderColor,
  283. textColor: Colors.white,
  284. );
  285. Navigator.pop(context);
  286. }
  287. }
  288. }
  289. _reportLevelStart() {
  290. FirebaseHelper.logEvent("level_start", {'id': widget.item.id, 'level': data.currentLevel + 1});
  291. AdjustHelper.trackEvent(AdjustHelper.levelStartToken);
  292. Statistics.postEvent({
  293. "project_id": Persistence().projectId,
  294. "user_id": Persistence().uuid,
  295. "library_name": Persistence().libraryName,
  296. "library_version": Persistence().packageVersion,
  297. "name": 'level_start',
  298. "tab_source": widget.tag,
  299. "sku_id": widget.item.id,
  300. });
  301. }
  302. Future<void> _loadFingerImageAndSetupHint() async {
  303. if (!widget.firstRun) return;
  304. try {
  305. _fingerImage = await loadUiImageFromAsset('assets/images/finger.png');
  306. } catch (e) {
  307. _log.severe('Failed to load assets/images/finger.png: $e');
  308. return;
  309. }
  310. if (!mounted || board == null) return;
  311. _overLayer = OverLayer(board!, this);
  312. _overLayer!.setup(context);
  313. if (widget.firstRun) {
  314. Future.delayed(const Duration(seconds: 1), () => hint());
  315. Future.delayed(const Duration(seconds: 3), () {
  316. if (!mounted) return;
  317. if (_overLayer != null && _overLayer!.isHinting) {
  318. Fluttertoast.showToast(
  319. msg: AppLocalizations.of(context)!.moveToComplete,
  320. toastLength: Toast.LENGTH_SHORT,
  321. gravity: ToastGravity.BOTTOM,
  322. timeInSecForIosWeb: 1,
  323. backgroundColor: SkinHelper.slotBorderColor,
  324. textColor: Colors.white,
  325. fontSize: 16.0,
  326. );
  327. }
  328. });
  329. }
  330. }
  331. hint() async {
  332. if (_fingerImage == null || _overLayer == null || board == null || board!.status != BoardStatus.playing) return;
  333. double fingerSize = board!.pieceLogicalWidth / 2.5;
  334. final Offset centerStart = Offset(
  335. board!.targetRect.topLeft.dx + board!.pieceLogicalWidth,
  336. board!.targetRect.topLeft.dy + board!.pieceLogicalHeight * 5 / 2,
  337. );
  338. final Offset centerEnd = Offset(board!.targetRect.topLeft.dx + board!.pieceLogicalWidth, board!.targetRect.topLeft.dy + board!.pieceLogicalHeight * 3 / 2);
  339. final rectStart = Rect.fromCenter(center: centerStart, width: fingerSize, height: fingerSize);
  340. final rectEnd = Rect.fromCenter(center: centerEnd, width: fingerSize, height: fingerSize);
  341. final hintItem = HintItem(_fingerImage!, rectStart, rectEnd);
  342. _overLayer?.doHint(hintItem);
  343. }
  344. // 🔥 关键修复:成功回调,延迟资源清理到页面退出时
  345. _onSuccess() {
  346. _log.info('success! 游戏完成!');
  347. MemoryMonitor.logMemoryUsage('Level completed');
  348. data.workDone(widget.item);
  349. board!.success();
  350. audio.playSfx(SfxType.success);
  351. confettiLayer.play();
  352. _successAnimationController.forward(from: 0.0);
  353. setState(() {});
  354. // 数据上报
  355. FirebaseHelper.logEvent("level_done", {'id': widget.item.id, 'level': data.currentLevel});
  356. AdjustHelper.trackEvent(AdjustHelper.levelDoneToken);
  357. Statistics.postEvent({
  358. "project_id": Persistence().projectId,
  359. "user_id": Persistence().uuid,
  360. "library_name": Persistence().libraryName,
  361. "library_version": Persistence().packageVersion,
  362. "name": 'level_done',
  363. "tab_source": widget.tag,
  364. "sku_id": widget.item.id,
  365. });
  366. // 里程碑上报
  367. if (data.currentLevel == 3) {
  368. FirebaseHelper.logEvent("level_done_3", {});
  369. AdjustHelper.trackEvent(AdjustHelper.levelDone3Token);
  370. } else if (data.currentLevel == 10) {
  371. FirebaseHelper.logEvent("level_done_10", {});
  372. AdjustHelper.trackEvent(AdjustHelper.levelDone10Token);
  373. } else if (data.currentLevel == 20) {
  374. FirebaseHelper.logEvent("level_done_20", {});
  375. AdjustHelper.trackEvent(AdjustHelper.levelDone20Token);
  376. } else if (data.currentLevel == 30) {
  377. FirebaseHelper.logEvent("level_done_30", {});
  378. AdjustHelper.trackEvent(AdjustHelper.levelDone30Token);
  379. }
  380. }
  381. // 动画监听器方法(保持原有逻辑)
  382. void _successAnimationListener() {
  383. final delta = _offsetAnimation.value;
  384. board!.finalRect = board!.targetRect.translate(0, delta);
  385. board!.invalidate();
  386. }
  387. void _successAnimationStatusListener(AnimationStatus status) async {
  388. if (status == AnimationStatus.completed) {
  389. Future.delayed(Duration(seconds: 1), () async {
  390. if (!mounted) return;
  391. final bool shouldShowRateDialog = await RatingHelper.shouldShowRateDialog(data.currentLevel);
  392. if (shouldShowRateDialog && mounted) {
  393. showRateDialog(context);
  394. }
  395. });
  396. }
  397. }
  398. // 其他动画监听器方法保持原有逻辑...
  399. void _dealingAnimationListener() {
  400. if (board == null) return;
  401. final currentTime = _dealingAnimation.value * _totalDealingDuration;
  402. for (int i = 0; i < board!.pieces.length - 1; i++) {
  403. final piece = board!.pieces[i];
  404. final startTime = i * _dealingPieceInterval;
  405. final duration = _dealingPieceDuration;
  406. if (currentTime < startTime) continue;
  407. double progress = (currentTime - startTime) / duration;
  408. progress = progress.clamp(0.0, 1.0);
  409. final startTransform = board!.getBottomRightTransform();
  410. final endTransform = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
  411. final tween = Matrix4Tween(begin: startTransform, end: endTransform);
  412. piece.transform = tween.lerp(progress);
  413. }
  414. board!.invalidate();
  415. }
  416. void _dealingAnimationStatusListener(AnimationStatus status) {
  417. if (status == AnimationStatus.completed) {
  418. board!.resetAllPieces();
  419. board!.shuffle(ShuffleStep.flipping);
  420. flipAnimationController.forward(from: 0.0);
  421. audio.playSfx(SfxType.flip);
  422. }
  423. }
  424. void _flipAnimationListener() {
  425. if (board == null) return;
  426. final flipValue = flipAnimationController.value;
  427. for (final piece in board!.pieces) {
  428. final angle = flipValue * pi;
  429. final targetTranslate = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
  430. piece.updateFlipTransform(angle, targetTranslate);
  431. }
  432. board!.invalidate();
  433. }
  434. void _flipAnimationStatusListener(AnimationStatus status) {
  435. if (status == AnimationStatus.completed) {
  436. board!.resetAllPieces();
  437. board!.rebuildAllGroups();
  438. final mergeGroups = board!.compareAllGroups();
  439. if (mergeGroups.isNotEmpty) {
  440. _log.info('Merge animation start for ${mergeGroups.length} groups.');
  441. _mergeGroups = mergeGroups;
  442. _mergeAnimationController.forward(from: 0.0);
  443. audio.playSfx(SfxType.pop);
  444. }
  445. board!.start();
  446. }
  447. }
  448. void _moveAnimationListener() {
  449. if (moveItems == null || moveItems!.isEmpty) return;
  450. for (var item in moveItems!) {
  451. item.move();
  452. }
  453. board!.invalidate();
  454. }
  455. void _moveAnimationStatusListener(AnimationStatus status) {
  456. if (status == AnimationStatus.completed) {
  457. if (moveItems != null) {
  458. bool needRebuildGroup = moveItems!.any((item) => item.action == Action.swap);
  459. for (var item in moveItems!) {
  460. item.stop();
  461. }
  462. if (needRebuildGroup) {
  463. board!.backupAllGroups();
  464. board!.rebuildAllGroups();
  465. final mergeGroups = board!.compareAllGroups();
  466. if (mergeGroups.isNotEmpty) {
  467. _log.info('Merge animation start for ${mergeGroups.length} groups.');
  468. _mergeGroups = mergeGroups;
  469. _mergeAnimationController.forward(from: 0.0);
  470. audio.playSfx(SfxType.pop);
  471. moveItems = null;
  472. return;
  473. }
  474. }
  475. moveItems = null;
  476. }
  477. }
  478. }
  479. void _mergeAnimationListener() {
  480. if (_mergeGroups == null || _mergeGroups!.isEmpty || board == null) return;
  481. final double scale = _mergeScaleAnimation.value;
  482. for (var group in _mergeGroups!) {
  483. final groupCenter = group.center;
  484. for (var piece in group.pieces) {
  485. final baseTransform = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
  486. final pieceTopLeft = Offset(baseTransform.storage[12], baseTransform.storage[13]);
  487. final offsetToCenter = groupCenter - pieceTopLeft;
  488. final scaleMatrix = vmath.Matrix4.identity()
  489. ..translate(offsetToCenter.dx, offsetToCenter.dy)
  490. ..scale(scale, scale, 1.0)
  491. ..translate(-offsetToCenter.dx, -offsetToCenter.dy);
  492. piece.transform = baseTransform * scaleMatrix;
  493. }
  494. }
  495. board!.invalidate();
  496. }
  497. void _mergeAnimationStatusListener(AnimationStatus status) {
  498. if (status == AnimationStatus.completed) {
  499. if (_mergeGroups != null && _mergeGroups!.isNotEmpty) {
  500. for (var group in _mergeGroups!) {
  501. for (var piece in group.pieces) {
  502. piece.transform = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
  503. }
  504. }
  505. }
  506. _mergeGroups = null;
  507. if (board!.checkWinCondition()) {
  508. _onSuccess();
  509. }
  510. board!.invalidate();
  511. }
  512. }
  513. void _prepareAnimationListener() {
  514. board!.invalidate();
  515. }
  516. void _prepareAnimationStatusListener(AnimationStatus status) {
  517. if (status == AnimationStatus.completed) {
  518. if (board != null && board!.hard == true) {
  519. setState(() => _showHardModeBanner = true);
  520. _hardModeBannerController.forward(from: 0.0);
  521. }
  522. board!.setAllPieceToBottomRight();
  523. board!.shuffle(ShuffleStep.dealing);
  524. dealingAnimationController.duration = Duration(milliseconds: _totalDealingDuration);
  525. dealingAnimationController.forward(from: 0.0);
  526. audio.playSfx(SfxType.card);
  527. _dealingPeriodicTimer = Timer.periodic(Duration(milliseconds: 130), (timer) {
  528. if (mounted) {
  529. _dealingCount++;
  530. if (_dealingCount >= (_totalDealingDuration / 130) - 2) {
  531. timer.cancel();
  532. } else {
  533. audio.playSfx(SfxType.card);
  534. }
  535. }
  536. });
  537. }
  538. }
  539. // 发牌动画参数
  540. int get _dealingPieceInterval {
  541. if (board!.rows <= 3) return 90;
  542. if (board!.rows == 4) return 80;
  543. if (board!.rows == 5) return 70;
  544. if (board!.rows == 6) return 50;
  545. return 50;
  546. }
  547. int get _dealingPieceDuration {
  548. if (board!.rows <= 3) return 400;
  549. if (board!.rows == 4) return 300;
  550. if (board!.rows == 5) return 200;
  551. if (board!.rows == 6) return 150;
  552. return 100;
  553. }
  554. int get _totalDealingDuration => (board!.pieces.length - 1) * _dealingPieceInterval + _dealingPieceDuration;
  555. @override
  556. void didChangeDependencies() async {
  557. super.didChangeDependencies();
  558. _log.info("didChangeDependencies");
  559. }
  560. // ✅ 优化:增强资源释放顺序和完整性
  561. @override
  562. dispose() {
  563. MemoryMonitor.logMemoryUsage('BoardPlay dispose (before)');
  564. _log.info('dispose - starting cleanup');
  565. // ✅ 1. 先停止所有动画(防止回调访问已销毁的资源)
  566. _moveAnimationController.stop();
  567. _mergeAnimationController.stop();
  568. _prepareAnimationController.stop();
  569. dealingAnimationController.stop();
  570. flipAnimationController.stop();
  571. _successAnimationController.stop();
  572. _hardModeBannerController.stop();
  573. // ✅ 2. 取消所有定时器
  574. timer.cancel();
  575. _dealingPeriodicTimer?.cancel();
  576. _resourceCleanupTimer?.cancel();
  577. // ✅ 3. 清理原生 Banner
  578. cleanBanner();
  579. // ✅ 4. 移除监听器
  580. itemLoader.progress.removeListener(_onProgressUpdate);
  581. _moveAnimationController.removeListener(_moveAnimationListener);
  582. _moveAnimationController.removeStatusListener(_moveAnimationStatusListener);
  583. _mergeAnimationController.removeListener(_mergeAnimationListener);
  584. _mergeAnimationController.removeStatusListener(_mergeAnimationStatusListener);
  585. _prepareAnimationController.removeListener(_prepareAnimationListener);
  586. _prepareAnimationController.removeStatusListener(_prepareAnimationStatusListener);
  587. dealingAnimationController.removeListener(_dealingAnimationListener);
  588. dealingAnimationController.removeStatusListener(_dealingAnimationStatusListener);
  589. flipAnimationController.removeListener(_flipAnimationListener);
  590. flipAnimationController.removeStatusListener(_flipAnimationStatusListener);
  591. _successAnimationController.removeListener(_successAnimationListener);
  592. _successAnimationController.removeStatusListener(_successAnimationStatusListener);
  593. // ✅ 5. 释放动画控制器
  594. _moveAnimationController.dispose();
  595. _mergeAnimationController.dispose();
  596. _prepareAnimationController.dispose();
  597. dealingAnimationController.dispose();
  598. flipAnimationController.dispose();
  599. _successAnimationController.dispose();
  600. _hardModeBannerController.dispose();
  601. // ✅ 6. 清空动画数据
  602. moveItems = null;
  603. _mergeGroups = null;
  604. // ✅ 7. 释放其他资源
  605. confettiLayer.dispose();
  606. _overLayer?.destroy();
  607. _overLayer = null;
  608. // ✅ 8. 最后释放 Board(最大的内存占用)
  609. board?.dispose();
  610. board = null;
  611. MemoryMonitor.logMemoryUsage('BoardPlay dispose (after)');
  612. _log.info('dispose - cleanup complete');
  613. super.dispose();
  614. }
  615. @override
  616. onInactive() {
  617. super.onInactive();
  618. saveProgress();
  619. }
  620. // 🔥 优化:页面退出时先保存再清理
  621. void _onWillPop(bool didPop, dynamic result) async {
  622. _log.info('board play will pop, didPop=$didPop, result=$result');
  623. if (didPop) return;
  624. // 立即停止所有动画,防止在销毁过程中动画还在执行 setState
  625. _moveAnimationController.stop();
  626. _mergeAnimationController.stop();
  627. _prepareAnimationController.stop();
  628. dealingAnimationController.stop();
  629. // 🔥 先保存进度,再清理资源
  630. saveProgress();
  631. // 延迟清理,确保保存完成
  632. Future.delayed(const Duration(milliseconds: 100)).then((_) {
  633. if (board != null) {
  634. _log.info('Cleaning up board resources on page exit');
  635. board!.dispose();
  636. board = null;
  637. }
  638. });
  639. if (!mounted) return;
  640. Navigator.of(context).pop(result);
  641. }
  642. @override
  643. Widget build(BuildContext context) {
  644. Device device = context.read<Device>();
  645. return PopScope(
  646. canPop: false,
  647. onPopInvokedWithResult: _onWillPop,
  648. child: Scaffold(
  649. body: Stack(
  650. children: <Widget>[
  651. if (!_isLoading) Positioned.fill(child: _buildPuzzleCanvas(device.screenSize.width, device.screenSize.height)),
  652. if (board == null || board!.status != BoardStatus.success) Positioned(top: 0, left: 0, right: 0, child: appBar),
  653. Positioned(
  654. bottom: 0,
  655. left: 0,
  656. right: 0,
  657. child: SafeArea(
  658. child: SizedBox(
  659. height: context.read<Device>().bannerHeight,
  660. width: double.infinity,
  661. child: isBannerVisible ? getBanner('playBottom') : const SizedBox.shrink(),
  662. ),
  663. ),
  664. ),
  665. successBanner,
  666. nextButton,
  667. // Release模式下显示内存信息
  668. // Positioned(top: 100, right: 10, child: MemoryMonitor.getMemoryWidget()),
  669. if (_isLoading)
  670. Positioned.fill(
  671. child: Container(
  672. color: SkinHelper.wholeBgColor,
  673. child: const Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(Colors.white))),
  674. ),
  675. ),
  676. ],
  677. ),
  678. ),
  679. );
  680. }
  681. Widget get appBar => SafeArea(
  682. child: Padding(
  683. padding: const EdgeInsets.symmetric(horizontal: 30.0, vertical: 10.0),
  684. child: Row(
  685. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  686. crossAxisAlignment: CrossAxisAlignment.center,
  687. children: [
  688. const SizedBox(width: 30),
  689. Text(
  690. board != null && board!.status == BoardStatus.success
  691. ? AppLocalizations.of(context)!.levelPass
  692. : '${AppLocalizations.of(context)!.level} ${data.currentLevel + 1}',
  693. style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600),
  694. ),
  695. SizedBox(
  696. width: 30,
  697. height: 30,
  698. child: IconButton(
  699. icon: const Icon(Icons.settings, color: Colors.white, size: 22),
  700. iconSize: 22,
  701. padding: EdgeInsets.zero,
  702. onPressed: () {
  703. audio.playSfx(SfxType.click);
  704. saveProgress();
  705. Navigator.push(context, SettingsDialog.buildRoute(showReturn: true, showRestart: true, item: widget.item));
  706. },
  707. style: ButtonStyle(
  708. backgroundColor: WidgetStateProperty.all(SkinHelper.slotBorderColor),
  709. shape: WidgetStateProperty.all(RoundedRectangleBorder(borderRadius: BorderRadius.circular(15))),
  710. minimumSize: WidgetStateProperty.all(const Size(30, 30)),
  711. maximumSize: WidgetStateProperty.all(const Size(30, 30)),
  712. ),
  713. ),
  714. ),
  715. ],
  716. ),
  717. ),
  718. );
  719. Widget _buildPuzzleCanvas(double width, double height) {
  720. // ✅ 此时如果为了播广告已经把 board 释放了,直接返回空,避免任何 GPU 绘制指令
  721. if (board == null) {
  722. return Container(color: SkinHelper.wholeBgColor);
  723. }
  724. return RepaintBoundary(
  725. child: CustomPaint(
  726. painter: BoardPainter(board: board!, prepareAnimation: _prepareAnimationController),
  727. size: Size(width, height),
  728. child: GestureDetector(
  729. key: boardKey,
  730. onPanStart: _onPanStart,
  731. onPanUpdate: _onPanUpdate,
  732. onPanEnd: _onPanEnd,
  733. child: board != null && board!.hard && _showHardModeBanner ? _hardModeBanner : Container(color: Colors.transparent),
  734. ),
  735. ),
  736. );
  737. }
  738. Widget get _hardModeBanner => Center(
  739. child: AnimatedBuilder(
  740. animation: _hardModeBannerController,
  741. builder: (context, child) {
  742. return FadeTransition(
  743. opacity: _bannerFadeAnimation,
  744. child: ScaleTransition(scale: _bannerScaleAnimation, child: child),
  745. );
  746. },
  747. child: Container(
  748. width: double.infinity,
  749. height: 60,
  750. margin: const EdgeInsets.symmetric(horizontal: 10),
  751. decoration: BoxDecoration(
  752. color: Colors.red,
  753. borderRadius: BorderRadius.circular(10),
  754. border: Border.all(color: const Color.fromARGB(255, 247, 143, 135), width: 2),
  755. boxShadow: [BoxShadow(color: Color.fromRGBO(0, 0, 0, 0.3), blurRadius: 5, offset: const Offset(0, 3))],
  756. ),
  757. child: Center(
  758. child: Text(
  759. AppLocalizations.of(context)!.hardMode,
  760. style: TextStyle(color: Colors.white, fontSize: 30, fontWeight: FontWeight.bold),
  761. ),
  762. ),
  763. ),
  764. ),
  765. );
  766. bool _isExiting = false;
  767. Widget get nextButton {
  768. Device device = context.read<Device>();
  769. return AnimatedBuilder(
  770. animation: _bottomSlideAnimation,
  771. builder: (context, child) {
  772. return AnimatedPositioned(
  773. duration: _successAnimationController.duration!,
  774. bottom: _bottomSlideAnimation.value,
  775. left: (device.screenSize.width - 200) / 2,
  776. child: child!,
  777. );
  778. },
  779. child: MyElevatedButton(
  780. width: 200,
  781. borderRadius: BorderRadius.circular(20),
  782. gradient: LinearGradient(colors: [SkinHelper.coreBgColor, SkinHelper.slotBorderColor]),
  783. onPressed: () async {
  784. if (_isExiting) return;
  785. _isExiting = true;
  786. audio.playSfx(SfxType.click);
  787. // 🔥 策略:在广告弹出前,先主动销毁最占资源的 Board 和 UI Image
  788. // 此时用户已经看到成功界面,底层画布即使变白也没关系(被动画层挡住了)
  789. // MemoryMonitor().manualCleanup(); // 广告前清理
  790. if (board != null) {
  791. _log.info('Pre-Ad Cleanup: Disposing board to free GPU memory');
  792. board!.dispose();
  793. board = null;
  794. }
  795. // 尝试直接pop up, 不等待返回结果,避免用户等待广告结束后才看到界面响应
  796. if (data.currentLevel % 25 != 0) {
  797. showInterstitialAd('level_done', widget.item.id, data.currentLevel - 1);
  798. }
  799. _safePop(result: true); // 返回true表示关卡完成
  800. },
  801. child: Text(AppLocalizations.of(context)!.next, style: TextStyle(color: Colors.white, fontSize: 20)),
  802. ),
  803. );
  804. }
  805. void _safePop({bool result = true}) {
  806. if (!mounted) return;
  807. final navigator = Navigator.of(context);
  808. if (navigator.canPop()) {
  809. navigator.pop(result);
  810. }
  811. }
  812. Widget get successBanner {
  813. Device device = context.read<Device>();
  814. final bannerWidth = device.screenSize.width - 40 * 2;
  815. final bannerHeight = 60.0;
  816. return AnimatedBuilder(
  817. animation: _bottomSlideAnimation,
  818. builder: (context, child) {
  819. return AnimatedPositioned(duration: _successAnimationController.duration!, top: _topSlideAnimation.value, left: 40, child: child!);
  820. },
  821. child: SizedBox(
  822. width: bannerWidth,
  823. height: bannerHeight,
  824. child: Stack(
  825. children: [
  826. Image.asset(
  827. 'assets/images/banner.png',
  828. width: double.infinity,
  829. height: double.infinity,
  830. fit: BoxFit.cover,
  831. cacheWidth: (context.watch<Device>().realPixelRatio * bannerWidth).toInt(),
  832. cacheHeight: (context.watch<Device>().realPixelRatio * bannerHeight).toInt(),
  833. ),
  834. Center(
  835. child: Padding(
  836. padding: EdgeInsets.only(top: 16.0),
  837. child: Text(
  838. AppLocalizations.of(context)!.levelPass,
  839. style: TextStyle(
  840. color: Colors.white,
  841. fontSize: 22,
  842. fontWeight: FontWeight.bold,
  843. shadows: [Shadow(color: Colors.black54, offset: Offset(1, 1), blurRadius: 2)],
  844. ),
  845. ),
  846. ),
  847. ),
  848. ],
  849. ),
  850. ),
  851. );
  852. }
  853. // 手势处理方法保持原有逻辑...
  854. Offset _globalToLocal(Offset globalPosition) {
  855. final RenderBox renderBox = boardKey.currentContext!.findRenderObject() as RenderBox;
  856. return renderBox.globalToLocal(globalPosition);
  857. }
  858. void _onPanStart(DragStartDetails details) {
  859. _log.info('_onPanStart');
  860. _overLayer?.stopHint();
  861. if (board!.status != BoardStatus.playing) {
  862. _log.info('不是playing状态,不响应onPanStart');
  863. return;
  864. }
  865. if (board!.checkWinCondition()) {
  866. _log.info('游戏已经完成,不再响应onPanStart');
  867. return;
  868. }
  869. // 动画中断逻辑
  870. if (_moveAnimationController.isAnimating && moveItems != null) {
  871. _log.info('移动动画中断,强制归位/交换');
  872. for (var item in moveItems!) {
  873. item.stop();
  874. }
  875. bool needRebuildGroup = moveItems!.any((item) => item.action == Action.swap);
  876. if (needRebuildGroup) {
  877. board!.backupAllGroups();
  878. board!.rebuildAllGroups();
  879. }
  880. moveItems = null;
  881. _moveAnimationController.stop();
  882. board!.invalidate();
  883. }
  884. if (_mergeAnimationController.isAnimating && _mergeGroups != null) {
  885. _log.info('合并动画中断,强制归位');
  886. for (var group in _mergeGroups!) {
  887. for (var piece in group.pieces) {
  888. piece.transform = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
  889. }
  890. }
  891. _mergeGroups = null;
  892. _mergeAnimationController.stop();
  893. board!.invalidate();
  894. }
  895. _moveAnimationController.stop();
  896. _mergeAnimationController.stop();
  897. moveItems = null;
  898. final localPosition = _globalToLocal(details.globalPosition);
  899. final touchedPiece = board?.findPieceAt(localPosition);
  900. if (touchedPiece != null) {
  901. audio.playSfx(SfxType.panstart);
  902. if (settings.vibrate.value) {
  903. if (Platform.isAndroid) {
  904. Vibration.vibrate(duration: 60, amplitude: 50);
  905. } else {
  906. HapticFeedback.mediumImpact();
  907. }
  908. }
  909. _draggingPiece = touchedPiece;
  910. final draggingGroup = _draggingPiece!.group;
  911. if (draggingGroup != null) {
  912. board!.pieces.removeWhere((p) => draggingGroup.contains(p));
  913. board!.pieces.addAll(draggingGroup.pieces);
  914. } else {
  915. board!.pieces.remove(_draggingPiece);
  916. board!.pieces.add(_draggingPiece!);
  917. }
  918. board!.invalidate();
  919. }
  920. }
  921. void _onPanUpdate(DragUpdateDetails details) {
  922. _overLayer?.stopHint();
  923. if (_draggingPiece == null) return;
  924. final Offset delta = details.delta;
  925. final draggingGroup = _draggingPiece!.group;
  926. if (draggingGroup != null) {
  927. for (var piece in draggingGroup.pieces) {
  928. piece.applyDelta(delta);
  929. }
  930. } else {
  931. _draggingPiece!.applyDelta(delta);
  932. }
  933. board!.invalidate();
  934. }
  935. void _onPanEnd(DragEndDetails details) {
  936. _log.info('_onPanEnd');
  937. _overLayer?.stopHint();
  938. if (_draggingPiece == null) return;
  939. audio.playSfx(SfxType.tap);
  940. Piece leaderPiece = _draggingPiece!;
  941. _draggingPiece = null;
  942. board!.invalidate();
  943. Piece? targetPiece = board!.findPieceAtExclude(leaderPiece.currentCenter, leaderPiece);
  944. if (targetPiece == null && leaderPiece.group != null) {
  945. for (var p in leaderPiece.group!.pieces) {
  946. targetPiece = board!.findPieceAtExclude(p.currentCenter, p);
  947. if (targetPiece != null) {
  948. _log.info('推举 ${p.toString()} 为新leader');
  949. leaderPiece = p;
  950. break;
  951. }
  952. }
  953. }
  954. if (targetPiece != null && targetPiece != leaderPiece && leaderPiece.canPlaceTo(targetPiece)) {
  955. _log.info("swap animation start");
  956. _animateSwap(leaderPiece, targetPiece);
  957. } else {
  958. _log.info("revert animation start");
  959. _animateRevert(leaderPiece);
  960. }
  961. }
  962. void _animateSwap(Piece leaderPiece, Piece targetPiece) {
  963. List<MoveItem> items = [];
  964. final List<Piece> draggingPieces = leaderPiece.group != null ? leaderPiece.group!.pieces : [leaderPiece];
  965. final int dr = targetPiece.curRow - leaderPiece.curRow;
  966. final int dc = targetPiece.curCol - leaderPiece.curCol;
  967. List<Piece> displacedPieces = [];
  968. if (leaderPiece.group != null) {
  969. for (var p in draggingPieces) {
  970. final int targetRow = p.curRow + dr;
  971. final int targetCol = p.curCol + dc;
  972. final Piece? other = board!.getPieceByCoordinate(targetRow, targetCol);
  973. if (other != null && !p.isSameGroup(other)) {
  974. displacedPieces.add(other);
  975. }
  976. }
  977. } else {
  978. displacedPieces.add(targetPiece);
  979. }
  980. for (var p in draggingPieces) {
  981. p.curRow += dr;
  982. p.curCol += dc;
  983. }
  984. if (leaderPiece.group == null) {
  985. targetPiece.curRow -= dr;
  986. targetPiece.curCol -= dc;
  987. } else {
  988. for (var p in displacedPieces) {
  989. int newRow = p.curRow - dr;
  990. int newCol = p.curCol - dc;
  991. do {
  992. final Piece? pieceInSlot = board!.getPieceByCoordinate(newRow, newCol);
  993. if (pieceInSlot == null) {
  994. p.curRow = newRow;
  995. p.curCol = newCol;
  996. break;
  997. } else {
  998. newRow -= dr;
  999. newCol -= dc;
  1000. }
  1001. } while (newRow >= 0 && newRow < p.rows && newCol >= 0 && newCol < p.cols);
  1002. }
  1003. }
  1004. for (var p in displacedPieces) {
  1005. final startTransform = p.transform;
  1006. final endTransform = board!.getTransformByCoordinate(p.curRow, p.curCol);
  1007. final animation = Matrix4Tween(begin: startTransform, end: endTransform).animate(_moveAnimationController);
  1008. items.add(MoveItem(piece: p, animation: animation, startTransform: startTransform, endTransform: endTransform, action: Action.swap));
  1009. board!.pieces.remove(p);
  1010. board!.pieces.add(p);
  1011. }
  1012. for (var p in draggingPieces) {
  1013. final startTransform = p.transform;
  1014. final endTransform = board!.getTransformByCoordinate(p.curRow, p.curCol);
  1015. final animation = Matrix4Tween(begin: startTransform, end: endTransform).animate(_moveAnimationController);
  1016. items.add(MoveItem(piece: p, animation: animation, startTransform: startTransform, endTransform: endTransform, action: Action.swap));
  1017. board!.pieces.remove(p);
  1018. board!.pieces.add(p);
  1019. }
  1020. moveItems = items;
  1021. _moveAnimationController.forward(from: 0.0);
  1022. }
  1023. void _animateRevert(Piece piece) {
  1024. List<MoveItem> items = [];
  1025. final List<Piece> groupPieces = piece.group != null ? piece.group!.pieces : [piece];
  1026. for (var p in groupPieces) {
  1027. final startTransform = p.transform;
  1028. final endTransform = board!.getTransformByCoordinate(p.curRow, p.curCol);
  1029. final animation = Matrix4Tween(begin: startTransform, end: endTransform).animate(_moveAnimationController);
  1030. items.add(MoveItem(piece: p, animation: animation, startTransform: startTransform, endTransform: endTransform, action: Action.revert));
  1031. }
  1032. moveItems = items;
  1033. _moveAnimationController.forward(from: 0.0);
  1034. }
  1035. }
  1036. class Matrix4Tween extends Tween<vmath.Matrix4> {
  1037. Matrix4Tween({required vmath.Matrix4 begin, required vmath.Matrix4 end}) : super(begin: begin, end: end);
  1038. @override
  1039. vmath.Matrix4 lerp(double t) {
  1040. if (begin == null || end == null) return begin ?? end ?? vmath.Matrix4.identity();
  1041. final List<double> lerpedStorage = List.generate(16, (i) {
  1042. return ui.lerpDouble(begin!.storage[i], end!.storage[i], t)!;
  1043. });
  1044. return vmath.Matrix4.fromList(lerpedStorage.cast<double>());
  1045. }
  1046. }
  1047. class MoveItem {
  1048. final Piece piece;
  1049. final Animation<vmath.Matrix4> animation;
  1050. final vmath.Matrix4 startTransform;
  1051. final vmath.Matrix4 endTransform;
  1052. final Action action;
  1053. MoveItem({required this.piece, required this.animation, required this.startTransform, required this.endTransform, required this.action});
  1054. void move() {
  1055. piece.transform = animation.value;
  1056. }
  1057. void stop() {
  1058. piece.transform = endTransform;
  1059. }
  1060. }