board_play.dart 42 KB

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