board_play.dart 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200
  1. import 'dart:async';
  2. import 'dart:io';
  3. import 'dart:math';
  4. import 'dart:ui' as ui;
  5. import 'package:flutter/material.dart';
  6. import 'package:flutter/services.dart';
  7. import 'package:fluttertoast/fluttertoast.dart';
  8. import 'package:logging/logging.dart';
  9. import 'package:provider/provider.dart';
  10. import 'package:puzzleweave/audio/jc_audio_controller.dart';
  11. import 'package:puzzleweave/config/device.dart';
  12. import 'package:puzzleweave/l10n/app_localizations.dart';
  13. import 'package:puzzleweave/models/data.dart';
  14. import 'package:puzzleweave/models/download.dart';
  15. import 'package:puzzleweave/models/items.dart';
  16. import 'package:puzzleweave/play/board.dart';
  17. import 'package:puzzleweave/play/board_painter.dart';
  18. import 'package:puzzleweave/play/confetti_layer.dart';
  19. import 'package:puzzleweave/play/piece.dart';
  20. import 'package:puzzleweave/rating/rating_helper.dart';
  21. import 'package:puzzleweave/rating/rating_utils.dart';
  22. import 'package:puzzleweave/settings/settings_controller.dart';
  23. import 'package:puzzleweave/settings/settings_dialog.dart';
  24. import 'package:puzzleweave/skin/skin.dart';
  25. import 'package:puzzleweave/utils/mybutton.dart';
  26. import 'package:vector_math/vector_math.dart' as vmath;
  27. import 'package:vibration/vibration.dart';
  28. final Logger _log = Logger('board_play.dart');
  29. // 移动类型 (不再需要,但保留枚举以防止其他文件引用报错)
  30. enum MoveType {
  31. group, // 整个群组一起移动
  32. single, // 单个碎片移动
  33. }
  34. // 操作类型
  35. enum Action {
  36. revert, // 回归
  37. swap, // 交换
  38. }
  39. class BoardPlay extends StatefulWidget {
  40. final ListItem item;
  41. const BoardPlay({super.key, required this.item});
  42. @override
  43. State<StatefulWidget> createState() {
  44. return _BoardPlayState();
  45. }
  46. static PageRouteBuilder buildRoute(ListItem item) {
  47. return PageRouteBuilder(
  48. pageBuilder: (context, animation, secondaryAnimation) {
  49. return BoardPlay(item: item);
  50. },
  51. transitionsBuilder: (context, animation, secondaryAnimation, child) {
  52. return FadeTransition(opacity: animation, child: child);
  53. // return SlideTransition(
  54. // position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(animation),
  55. // child: child,
  56. // );
  57. },
  58. );
  59. }
  60. }
  61. class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
  62. final GlobalKey boardKey = GlobalKey();
  63. Board? board;
  64. bool _isLoading = true;
  65. int progress = 0;
  66. bool isDownloadSlow = false;
  67. late Timer timer;
  68. late ItemLoader itemLoader;
  69. // late AudioController audio;
  70. late JcAudioController audio;
  71. late Data data;
  72. late SettingsController settings;
  73. late ConfettiLayer confettiLayer;
  74. Piece? _draggingPiece;
  75. // 记录所有动画中的移动项 (位移/交换/归位)
  76. List<MoveItem>? moveItems;
  77. // 动画控制器
  78. late AnimationController _moveAnimationController; // 移动动画(位移)
  79. late AnimationController _mergeAnimationController; // merge动画(scale)
  80. // merge 动画的缩放值
  81. late Animation<double> _mergeScaleAnimation;
  82. List<PieceGroup>? _mergeGroups; // 记录当前merge的group
  83. late AnimationController _prepareAnimationController; // 预备动画, Opacity透明动画展示核心绘制区
  84. late AnimationController dealingAnimationController; // 发牌动画
  85. late AnimationController flipAnimationController; // 翻牌动画
  86. // 发牌动画相关
  87. late Animation<double> _dealingAnimation;
  88. // 发牌动画参数
  89. // 发牌间隔(ms)
  90. int get _dealingPieceInterval {
  91. if (board!.rows <= 3) return 120;
  92. if (board!.rows == 4) return 100;
  93. if (board!.rows == 5) return 80;
  94. if (board!.rows == 6) return 60;
  95. return 50;
  96. }
  97. // 每个卡片移动时间(ms)
  98. int get _dealingPieceDuration {
  99. if (board!.rows <= 3) return 500;
  100. if (board!.rows == 4) return 400;
  101. if (board!.rows == 5) return 300;
  102. if (board!.rows == 6) return 200;
  103. return 100;
  104. }
  105. // 发牌动画总时长
  106. int get _totalDealingDuration => (board!.pieces.length - 1) * _dealingPieceInterval + _dealingPieceDuration; // 发牌动画总时长(ms)
  107. Timer? _dealingPeriodicTimer;
  108. int _dealingCount = 0; // 计数:记录执行次数
  109. // 成功动画控制器
  110. late AnimationController _successAnimationController;
  111. late Animation<double> _offsetAnimation; // 用于控制核心绘制区上移
  112. late Animation<double> _bottomSlideAnimation; // 用于控制next按钮从屏幕下方移动上来
  113. late Animation<double> _topSlideAnimation; // 用于控制通关banner从屏幕上方移动上来
  114. // Hard Mode Banner 动画控制器
  115. late AnimationController _hardModeBannerController;
  116. // 缩放动画
  117. late Animation<double> _bannerScaleAnimation;
  118. // 透明度动画
  119. late Animation<double> _bannerFadeAnimation;
  120. // 是否显示 Hard Mode Banner 的标志
  121. bool _showHardModeBanner = false;
  122. @override
  123. initState() {
  124. super.initState();
  125. itemLoader = ItemLoader.load(widget.item);
  126. _onProgressUpdate();
  127. itemLoader.progress.addListener(_onProgressUpdate);
  128. timer = Timer(const Duration(seconds: 5), () {
  129. if (mounted && progress < 50) {
  130. if (progress <= 1) {
  131. //啥都没下载到, 直接弹toast然后退出
  132. Fluttertoast.showToast(
  133. msg: AppLocalizations.of(context)!.networkNotGood,
  134. toastLength: Toast.LENGTH_SHORT,
  135. gravity: ToastGravity.CENTER,
  136. timeInSecForIosWeb: 1,
  137. backgroundColor: SkinHelper.slotBorderColor,
  138. textColor: Colors.white,
  139. fontSize: 16.0,
  140. );
  141. Navigator.pop(context);
  142. } else {
  143. // 有下载只是慢
  144. setState(() {
  145. isDownloadSlow = true;
  146. });
  147. }
  148. }
  149. });
  150. Device device = context.read<Device>();
  151. // audio = context.read<AudioController>();
  152. audio = context.read<JcAudioController>();
  153. data = context.read<Data>();
  154. settings = context.read<SettingsController>();
  155. confettiLayer = ConfettiLayer(this);
  156. Future.delayed(Duration.zero, () {
  157. if (mounted) {
  158. confettiLayer.setup(context);
  159. }
  160. });
  161. // 初始化移动动画,在dragging结束松手后的swap或evert操作都需要用到移动
  162. _moveAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
  163. _moveAnimationController.addListener(_moveAnimationListener);
  164. _moveAnimationController.addStatusListener(_moveAnimationStatusListener);
  165. // 初始化 Merge 动画
  166. _mergeAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300)); // 0.4s for scale up/down
  167. _mergeAnimationController.addListener(_mergeAnimationListener);
  168. _mergeAnimationController.addStatusListener(_mergeAnimationStatusListener);
  169. // 缩放值从 1.0 -> 1.1 -> 1.0 (使用 TweenSequence 实现放大再缩小)
  170. _mergeScaleAnimation =
  171. TweenSequence<double>([
  172. TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 1.06), weight: 50),
  173. TweenSequenceItem(tween: Tween<double>(begin: 1.06, end: 1.0), weight: 50),
  174. ]).animate(
  175. // 应用曲线:用 CurvedAnimation 包装控制器
  176. CurvedAnimation(
  177. parent: _mergeAnimationController, // 动画控制器
  178. curve: Curves.easeInOut, // 曲线类型(先加速后减速)
  179. ),
  180. );
  181. _prepareAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 800));
  182. _prepareAnimationController.addListener(_prepareAnimationListener);
  183. _prepareAnimationController.addStatusListener(_prepareAnimationStatusListener);
  184. // 初始化发牌动画
  185. dealingAnimationController = AnimationController(vsync: this);
  186. _dealingAnimation = CurvedAnimation(parent: dealingAnimationController, curve: Curves.linear);
  187. dealingAnimationController.addListener(_dealingAnimationListener);
  188. dealingAnimationController.addStatusListener(_dealingAnimationStatusListener);
  189. // 初始化翻转动画
  190. flipAnimationController = AnimationController(vsync: this, duration: Duration(milliseconds: 500));
  191. flipAnimationController.addListener(_flipAnimationListener);
  192. flipAnimationController.addStatusListener(_flipAnimationStatusListener);
  193. // 初始化成功动画
  194. _successAnimationController = AnimationController(vsync: this, duration: Duration(milliseconds: 500));
  195. final deltaY = (device.targetRect.top - device.appBarHeight) / 3;
  196. _offsetAnimation = Tween<double>(begin: 0.0, end: -deltaY).animate(_successAnimationController);
  197. _bottomSlideAnimation =
  198. Tween<double>(
  199. begin: -500, // 初始在屏幕外
  200. end: device.screenSize.height - device.targetRect.bottom + deltaY - 60,
  201. ).animate(
  202. CurvedAnimation(
  203. parent: _successAnimationController,
  204. curve: Curves.easeOut, // 缓出曲线,滑入更自然
  205. ),
  206. );
  207. _topSlideAnimation =
  208. Tween<double>(
  209. begin: -200, // 初始在屏幕外
  210. end: device.targetRect.top - deltaY - 70,
  211. ).animate(
  212. CurvedAnimation(
  213. parent: _successAnimationController,
  214. curve: Curves.easeOut, // 缓出曲线,滑入更自然
  215. ),
  216. );
  217. _successAnimationController.addListener(_successAnimationListener);
  218. _successAnimationController.addStatusListener(_successAnimationStatusListener);
  219. // 初始化 Hard Mode Banner 动画
  220. _hardModeBannerController = AnimationController(vsync: this, duration: const Duration(milliseconds: 1500));
  221. // 缩放:0.0 -> 1.0 (前 40% 时间快速放大)
  222. _bannerScaleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
  223. CurvedAnimation(
  224. parent: _hardModeBannerController,
  225. curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
  226. ),
  227. );
  228. // 透明度:1.0 -> 0.0 (后 70% 时间逐渐淡出)
  229. _bannerFadeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
  230. CurvedAnimation(
  231. parent: _hardModeBannerController,
  232. curve: const Interval(0.3, 1.0, curve: Curves.easeIn),
  233. ),
  234. );
  235. // 监听器用于触发重绘
  236. _hardModeBannerController.addListener(() {
  237. // if (mounted) setState(() {}); // 效率较低,改用AnimatedBuilder来实现局部重绘
  238. });
  239. // 动画完成时,设置标志为 false,完全隐藏
  240. _hardModeBannerController.addStatusListener((status) {
  241. if (status == AnimationStatus.completed) {
  242. if (mounted) setState(() => _showHardModeBanner = false);
  243. }
  244. });
  245. try {
  246. _init();
  247. } catch (error) {
  248. _log.info('board init error: $error');
  249. Fluttertoast.showToast(
  250. msg: AppLocalizations.of(context)!.networkNotGood,
  251. toastLength: Toast.LENGTH_SHORT,
  252. gravity: ToastGravity.CENTER,
  253. timeInSecForIosWeb: 1,
  254. backgroundColor: SkinHelper.slotBorderColor,
  255. textColor: Colors.white,
  256. fontSize: 16.0,
  257. );
  258. Navigator.pop(context);
  259. }
  260. }
  261. _onProgressUpdate() {
  262. // progress = (downloadItem.progress.value * 100).ceil();
  263. progress = (itemLoader.progress.value * 100).ceil();
  264. _log.info('onProgressUpdate: progress=$progress');
  265. setState(() {});
  266. }
  267. void _successAnimationListener() {
  268. final delta = _offsetAnimation.value;
  269. board!.finalRect = board!.targetRect.translate(0, delta);
  270. board!.invalidate();
  271. }
  272. void _successAnimationStatusListener(AnimationStatus status) async {
  273. if (status == AnimationStatus.completed) {
  274. // delay 一下,等待撒花基本消失
  275. Future.delayed(Duration(seconds: 1), () async {
  276. if (!mounted) return;
  277. // 关卡完成判断是否需要评分
  278. final bool shouldShowRateDialog = await RatingHelper.shouldShowRateDialog(data.currentLevel);
  279. if (shouldShowRateDialog) {
  280. if (mounted) {
  281. showRateDialog(context);
  282. }
  283. }
  284. });
  285. }
  286. }
  287. void _dealingAnimationListener() {
  288. if (board == null) return;
  289. // 当前动画已运行的时间(ms)
  290. final currentTime = _dealingAnimation.value * _totalDealingDuration;
  291. // 逐个更新卡片位置(最后一张不需要动)
  292. for (int i = 0; i < board!.pieces.length - 1; i++) {
  293. final piece = board!.pieces[i];
  294. final startTime = i * _dealingPieceInterval;
  295. final duration = _dealingPieceDuration;
  296. // 尚未到启动时间:保持在起点
  297. if (currentTime < startTime) {
  298. continue;
  299. }
  300. // 计算移动进度(0~1):已移动时间 / 总持续时间
  301. double progress = (currentTime - startTime) / duration;
  302. progress = progress.clamp(0.0, 1.0); // 限制进度不超过1(防止超调)
  303. // 计算当前位置(起点到终点的插值)
  304. final startTransform = board!.getBottomRightTransform();
  305. final endTransform = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
  306. final tween = Matrix4Tween(begin: startTransform, end: endTransform);
  307. piece.transform = tween.lerp(progress);
  308. }
  309. board!.invalidate();
  310. }
  311. // 发牌动画状态监听器
  312. void _dealingAnimationStatusListener(AnimationStatus status) {
  313. if (status == AnimationStatus.completed) {
  314. board!.resetAllPieces();
  315. board!.shuffle(ShuffleStep.flipping);
  316. flipAnimationController.forward(from: 0.0);
  317. audio.playSfx(SfxType.flip);
  318. }
  319. }
  320. // 翻转动画监听器
  321. void _flipAnimationListener() {
  322. if (board == null) return;
  323. final flipValue = flipAnimationController.value;
  324. for (final piece in board!.pieces) {
  325. // 1. 计算翻转角度(0→π,180度翻转)
  326. final angle = flipValue * pi;
  327. // 2. 获取卡片的固定目标位置(基于curRow/curCol,不依赖动态transform)
  328. final targetTranslate = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
  329. // 3. 执行翻转动画(传入固定目标位置)
  330. piece.updateFlipTransform(angle, targetTranslate);
  331. }
  332. board!.invalidate();
  333. }
  334. // 翻转动画状态监听器
  335. void _flipAnimationStatusListener(AnimationStatus status) {
  336. if (status == AnimationStatus.completed) {
  337. // 翻转完成,开始游戏
  338. board!.resetAllPieces();
  339. board!.rebuildAllGroups();
  340. // 检查是否初始化就已经merge的group
  341. final mergeGroups = board!.compareAllGroups();
  342. // 有新的区块合成,执行 merge 动画,稍稍放大然后再复原
  343. if (mergeGroups.isNotEmpty) {
  344. _log.info('Merge animation start for ${mergeGroups.length} groups.');
  345. // 启动 Merge 动画
  346. _mergeGroups = mergeGroups;
  347. _mergeAnimationController.forward(from: 0.0);
  348. audio.playSfx(SfxType.pop);
  349. }
  350. board!.start();
  351. }
  352. }
  353. // 关键修正:动画监听器,只注册一次
  354. void _moveAnimationListener() {
  355. if (moveItems == null || moveItems!.isEmpty) {
  356. return;
  357. }
  358. for (var item in moveItems!) {
  359. item.move();
  360. }
  361. board!.invalidate();
  362. }
  363. void _moveAnimationStatusListener(AnimationStatus status) {
  364. if (status == AnimationStatus.completed) {
  365. // 动画完成,确保所有 piece 都在它们的最终位置(endTransform)
  366. if (moveItems != null) {
  367. bool needRebuildGroup = moveItems!.any((item) => item.action == Action.swap);
  368. // 确保所有 piece 的 transform 最终停留在 endTransform
  369. for (var item in moveItems!) {
  370. item.stop();
  371. }
  372. if (needRebuildGroup) {
  373. board!.backupAllGroups();
  374. board!.rebuildAllGroups();
  375. final mergeGroups = board!.compareAllGroups();
  376. // 有新的区块合成,执行 merge 动画,稍稍放大然后再复原
  377. if (mergeGroups.isNotEmpty) {
  378. _log.info('Merge animation start for ${mergeGroups.length} groups.');
  379. // 启动 Merge 动画
  380. _mergeGroups = mergeGroups;
  381. _mergeAnimationController.forward(from: 0.0);
  382. audio.playSfx(SfxType.pop);
  383. // 如果执行了 merge 动画,将胜利条件检查推迟到 merge 动画完成时
  384. moveItems = null;
  385. return;
  386. }
  387. }
  388. moveItems = null;
  389. }
  390. }
  391. }
  392. // 新增:Merge 动画监听器
  393. void _mergeAnimationListener() {
  394. if (_mergeGroups == null || _mergeGroups!.isEmpty || board == null) {
  395. return;
  396. }
  397. // 当前缩放值 (从 1.0 -> 1.1 -> 1.0)
  398. final double scale = _mergeScaleAnimation.value;
  399. for (var group in _mergeGroups!) {
  400. final groupCenter = group.center;
  401. for (var piece in group.pieces) {
  402. // 1. 获取碎片归位后的基础位置(纯平移)
  403. final baseTransform = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
  404. // 2. 计算碎片左上角到群组中心的偏移量
  405. final pieceTopLeft = Offset(baseTransform.storage[12], baseTransform.storage[13]);
  406. final offsetToCenter = groupCenter - pieceTopLeft;
  407. // 3. 创建围绕群组中心的缩放矩阵
  408. final scaleMatrix = vmath.Matrix4.identity()
  409. ..translate(offsetToCenter.dx, offsetToCenter.dy) // 移到群组中心
  410. ..scale(scale, scale, 1.0) // 缩放
  411. ..translate(-offsetToCenter.dx, -offsetToCenter.dy); // 移回原位
  412. // 4. 应用最终变换
  413. piece.transform = baseTransform * scaleMatrix;
  414. }
  415. }
  416. board!.invalidate();
  417. }
  418. // 新增:Merge 动画状态监听器
  419. void _mergeAnimationStatusListener(AnimationStatus status) {
  420. if (status == AnimationStatus.completed) {
  421. if (_mergeGroups != null && _mergeGroups!.isNotEmpty) {
  422. for (var group in _mergeGroups!) {
  423. for (var piece in group.pieces) {
  424. piece.transform = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
  425. }
  426. }
  427. }
  428. _mergeGroups = null;
  429. // 检查胜利条件
  430. if (board!.checkWinCondition()) {
  431. _onSuccess();
  432. }
  433. board!.invalidate();
  434. }
  435. }
  436. void _prepareAnimationListener() {
  437. board!.invalidate();
  438. }
  439. // prepare动画结束,进入洗牌动画
  440. void _prepareAnimationStatusListener(AnimationStatus status) {
  441. if (status == AnimationStatus.completed) {
  442. if (board != null && board!.hard == true) {
  443. setState(() => _showHardModeBanner = true);
  444. _hardModeBannerController.forward(from: 0.0);
  445. }
  446. board!.setAllPieceToBottomRight();
  447. board!.shuffle(ShuffleStep.dealing);
  448. dealingAnimationController.duration = Duration(milliseconds: _totalDealingDuration);
  449. dealingAnimationController.forward(from: 0.0);
  450. audio.playSfx(SfxType.card);
  451. _dealingPeriodicTimer = Timer.periodic(Duration(milliseconds: 150), (timer) {
  452. if (mounted) {
  453. _dealingCount++;
  454. if (_dealingCount >= (_totalDealingDuration / 150) - 2) {
  455. timer.cancel();
  456. } else {
  457. audio.playSfx(SfxType.card);
  458. }
  459. }
  460. });
  461. }
  462. }
  463. _onSuccess() {
  464. _log.info('success! 游戏完成!');
  465. data.workDone(widget.item);
  466. board!.success();
  467. audio.playSfx(SfxType.success);
  468. confettiLayer.play();
  469. _successAnimationController.forward(from: 0.0);
  470. setState(() {});
  471. }
  472. _init() async {
  473. Device device = context.read<Device>();
  474. setState(() {
  475. _isLoading = true;
  476. });
  477. final dpr = device.devicePixelRatio;
  478. final targetRect = device.targetRect;
  479. final bestImageSize = device.bestImageSize;
  480. final image = await itemLoader.getImageBySize(bestImageSize.width.round(), bestImageSize.height.round());
  481. // final image = await itemLoader.getImage();
  482. _log.info('imageSize: (${image.width},${image.height}), bestImageSize: ($bestImageSize)');
  483. // 加载图片,后续改为从远程服务器加载, 目前demo从本地assets读取
  484. // final ByteData data = await rootBundle.load('assets/images/test.jpeg');
  485. // final ByteData data = await rootBundle.load(widget.item.image);
  486. // final ui.Codec codec = await ui.instantiateImageCodec(
  487. // data.buffer.asUint8List(),
  488. // targetWidth: bestImageSize.width.round(),
  489. // targetHeight: bestImageSize.height.round(),
  490. // );
  491. // final ui.FrameInfo frameInfo = await codec.getNextFrame();
  492. // final image = frameInfo.image;
  493. // 加载扑克背面图片,用于制作发牌动画
  494. final Size bestCardImageSize = Size(targetRect.width * dpr / widget.item.rows, targetRect.height * dpr / widget.item.cols);
  495. final ByteData cardData = await rootBundle.load(widget.item.hard ? 'assets/images/backcard_red.png' : 'assets/images/backcard_blue.png');
  496. final ui.Codec cardCodec = await ui.instantiateImageCodec(
  497. cardData.buffer.asUint8List(),
  498. targetWidth: bestCardImageSize.width.round(),
  499. targetHeight: bestCardImageSize.height.round(),
  500. );
  501. final ui.FrameInfo cardFrameInfo = await cardCodec.getNextFrame();
  502. final cardImage = cardFrameInfo.image;
  503. board = Board(this, image, cardImage, widget.item.rows, widget.item.cols, widget.item.hard, targetRect, device);
  504. board!.prepare();
  505. // **修正:在调用 AnimationController 之前检查 `mounted` 状态**
  506. if (!mounted) return;
  507. _prepareAnimationController.forward(from: 0.0);
  508. setState(() {
  509. _isLoading = false;
  510. });
  511. }
  512. @override
  513. void didChangeDependencies() async {
  514. super.didChangeDependencies();
  515. _log.info("didChangeDependencies");
  516. }
  517. @override
  518. dispose() {
  519. timer.cancel();
  520. itemLoader.progress.removeListener(_onProgressUpdate);
  521. _moveAnimationController.removeListener(_moveAnimationListener);
  522. _moveAnimationController.removeStatusListener(_moveAnimationStatusListener);
  523. _moveAnimationController.dispose();
  524. _mergeAnimationController.removeListener(_mergeAnimationListener);
  525. _mergeAnimationController.removeStatusListener(_mergeAnimationStatusListener);
  526. _mergeAnimationController.dispose();
  527. _prepareAnimationController.removeListener(_prepareAnimationListener);
  528. _prepareAnimationController.removeStatusListener(_prepareAnimationStatusListener);
  529. _prepareAnimationController.dispose();
  530. dealingAnimationController.removeListener(_dealingAnimationListener);
  531. dealingAnimationController.removeStatusListener(_dealingAnimationStatusListener);
  532. dealingAnimationController.dispose();
  533. flipAnimationController.removeListener(_flipAnimationListener);
  534. flipAnimationController.removeStatusListener(_flipAnimationStatusListener);
  535. flipAnimationController.dispose();
  536. _successAnimationController.removeListener(_successAnimationListener);
  537. _successAnimationController.removeStatusListener(_successAnimationStatusListener);
  538. _hardModeBannerController.dispose();
  539. _dealingPeriodicTimer?.cancel();
  540. confettiLayer.dispose();
  541. board?.dispose();
  542. super.dispose();
  543. }
  544. /// gallery页面加载的时候,可能广告模块还没有初始化完毕
  545. // Future<bool> _bannerReadyAndShouldShow() async {
  546. // bool ready = await adSDKReady();
  547. // return ready && shouldShowBannerAd(data.currentLevel);
  548. // }
  549. @override
  550. Widget build(BuildContext context) {
  551. Device device = context.read<Device>();
  552. return Scaffold(
  553. body: Stack(
  554. children: <Widget>[
  555. if (!_isLoading) Positioned.fill(child: _buildPuzzleCanvas(device.screenSize.width, device.screenSize.height)),
  556. if (board == null || board!.status != BoardStatus.success) Positioned(top: 0, left: 0, right: 0, child: appBar),
  557. // Positioned(top: 0, left: 0, right: 0, child: appBar),
  558. Positioned(
  559. bottom: 0,
  560. left: 0,
  561. right: 0,
  562. child: Container(),
  563. // child: SafeArea(
  564. // child: SizedBox(
  565. // // 始终预留一个固定的高度,防止布局跳变
  566. // height: context.read<Device>().bannerHeight,
  567. // width: double.infinity,
  568. // child: FutureBuilder<bool>(
  569. // future: _bannerReadyAndShouldShow(),
  570. // builder: (context, snapshot) {
  571. // if (snapshot.hasData && snapshot.data == true) {
  572. // return adBanner;
  573. // }
  574. // return Container(
  575. // // color: Colors.grey.shade100,
  576. // );
  577. // },
  578. // ),
  579. // ),
  580. // ),
  581. ),
  582. successBanner,
  583. nextButton,
  584. if (_isLoading)
  585. Positioned.fill(
  586. child: Container(
  587. color: SkinHelper.wholeBgColor,
  588. child: const Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(Colors.white))),
  589. ),
  590. ),
  591. ],
  592. ),
  593. );
  594. }
  595. Widget get appBar => SafeArea(
  596. child: Padding(
  597. padding: const EdgeInsets.symmetric(horizontal: 30.0, vertical: 10.0),
  598. child: Row(
  599. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  600. crossAxisAlignment: CrossAxisAlignment.center,
  601. children: [
  602. // 左侧占位(保持标题居中)
  603. const SizedBox(width: 30),
  604. // 中间标题
  605. Text(
  606. board != null && board!.status == BoardStatus.success
  607. ? AppLocalizations.of(context)!.levelPass
  608. : '${AppLocalizations.of(context)!.level} ${data.currentLevel + 1}',
  609. style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600),
  610. ),
  611. // 右侧设置按钮(30x30 圆形、深绿色背景、白色图标)
  612. SizedBox(
  613. width: 30,
  614. height: 30,
  615. child: IconButton(
  616. icon: const Icon(Icons.settings, color: Colors.white, size: 22),
  617. iconSize: 22,
  618. padding: EdgeInsets.zero, // 清除默认内边距,确保按钮尺寸准确
  619. onPressed: () {
  620. audio.playSfx(SfxType.click);
  621. Navigator.push(context, SettingsDialog.buildRoute(showReturn: true, showRestart: true, item: widget.item));
  622. },
  623. style: ButtonStyle(
  624. // 深绿色背景(与你之前的按钮风格一致,使用 Color(0xff26600c) 深绿色)
  625. backgroundColor: WidgetStateProperty.all(SkinHelper.slotBorderColor),
  626. // 圆形形状
  627. shape: WidgetStateProperty.all(
  628. RoundedRectangleBorder(
  629. borderRadius: BorderRadius.circular(15), // 30x30 按钮对应 15 圆角
  630. ),
  631. ),
  632. // 固定按钮尺寸(30x30)
  633. minimumSize: WidgetStateProperty.all(const Size(30, 30)),
  634. maximumSize: WidgetStateProperty.all(const Size(30, 30)),
  635. ),
  636. ),
  637. ),
  638. ],
  639. ),
  640. ),
  641. );
  642. Widget _buildPuzzleCanvas(double width, double height) {
  643. return RepaintBoundary(
  644. child: CustomPaint(
  645. painter: BoardPainter(board: board!, prepareAnimation: _prepareAnimationController),
  646. size: Size(width, height),
  647. child: GestureDetector(
  648. key: boardKey,
  649. onPanStart: _onPanStart,
  650. onPanUpdate: _onPanUpdate,
  651. onPanEnd: _onPanEnd,
  652. child: // 根据游戏状态动态显示提示动画或透明容器
  653. board != null && board!.hard && _showHardModeBanner
  654. ? _hardModeBanner
  655. : Container(color: Colors.transparent), // 非显示条件时,使用透明容器
  656. ),
  657. ),
  658. );
  659. }
  660. // 困难模式提示动画组件
  661. Widget get _hardModeBanner => Center(
  662. // 使用 AnimatedBuilder 包裹需要动画的组件
  663. child: AnimatedBuilder(
  664. animation: _hardModeBannerController, // 监听控制器
  665. builder: (context, child) {
  666. return FadeTransition(
  667. opacity: _bannerFadeAnimation, // 使用控制器驱动的动画值
  668. child: ScaleTransition(
  669. scale: _bannerScaleAnimation, // 使用控制器驱动的动画值
  670. child: child, // 不随动画重建的子组件
  671. ),
  672. );
  673. },
  674. // child 是不依赖动画状态变化的组件,只会构建一次
  675. child: Container(
  676. width: double.infinity,
  677. height: 60,
  678. margin: const EdgeInsets.symmetric(horizontal: 10),
  679. decoration: BoxDecoration(
  680. color: Colors.red,
  681. borderRadius: BorderRadius.circular(10),
  682. border: Border.all(color: const Color.fromARGB(255, 247, 143, 135), width: 2),
  683. boxShadow: [BoxShadow(color: Color.fromRGBO(0, 0, 0, 0.3), blurRadius: 5, offset: const Offset(0, 3))],
  684. ),
  685. child: Center(
  686. child: Text(
  687. AppLocalizations.of(context)!.hardMode,
  688. style: TextStyle(color: Colors.white, fontSize: 30, fontWeight: FontWeight.bold),
  689. ),
  690. ),
  691. ),
  692. ),
  693. );
  694. Widget get nextButton {
  695. Device device = context.read<Device>();
  696. return AnimatedBuilder(
  697. // 监听显式动画 _bottomSlideAnimation
  698. animation: _bottomSlideAnimation,
  699. builder: (context, child) {
  700. return AnimatedPositioned(
  701. duration: _successAnimationController.duration!,
  702. // 从动画中获取实时 value,赋值给 bottom
  703. bottom: _bottomSlideAnimation.value,
  704. left: (device.screenSize.width - 200) / 2,
  705. child: child!, // 固定子组件,优化性能
  706. );
  707. },
  708. // 固定的按钮组件(仅构建一次,优化性能)
  709. child: MyElevatedButton(
  710. width: 200,
  711. borderRadius: BorderRadius.circular(20),
  712. gradient: LinearGradient(colors: [SkinHelper.coreBgColor, SkinHelper.slotBorderColor]),
  713. onPressed: () async {
  714. audio.playSfx(SfxType.click);
  715. // audio.pauseMusic();
  716. // await showInterstitialAd('level_exit', widget.item.id, data.currentLevel);
  717. // audio.startMusic();
  718. // if (!mounted) return;
  719. Navigator.pop(context, true);
  720. },
  721. child: Text(AppLocalizations.of(context)!.next, style: TextStyle(color: Colors.white, fontSize: 20)),
  722. ),
  723. );
  724. }
  725. Widget get successBanner {
  726. Device device = context.read<Device>();
  727. // 计算banner宽高
  728. final bannerWidth = device.screenSize.width - 40 * 2; // 左右各30间距
  729. final bannerHeight = 60.0;
  730. return AnimatedBuilder(
  731. animation: _bottomSlideAnimation,
  732. builder: (context, child) {
  733. return AnimatedPositioned(
  734. duration: _successAnimationController.duration!,
  735. top: _topSlideAnimation.value, // 固定底部位置
  736. left: 40, // 左间距30,与bannerWidth配合实现水平居中
  737. child: child!,
  738. );
  739. },
  740. // 核心:用Container固定尺寸,Stack填充Container,确保图片和文字尺寸对齐
  741. child: SizedBox(
  742. width: bannerWidth, // 容器宽=图片宽
  743. height: bannerHeight, // 容器高=图片高
  744. child: Stack(
  745. children: [
  746. // 1. 图片充满容器(与容器尺寸一致)
  747. Image.asset(
  748. 'assets/images/banner3.png',
  749. width: double.infinity, // 图片宽=容器宽
  750. height: double.infinity, // 图片高=容器高
  751. fit: BoxFit.cover, // 图片填充容器(不拉伸,超出部分裁剪)
  752. cacheWidth: (context.watch<Device>().devicePixelRatio * bannerWidth).toInt(),
  753. cacheHeight: (context.watch<Device>().devicePixelRatio * bannerHeight).toInt(),
  754. ),
  755. Center(
  756. child: Padding(
  757. padding: EdgeInsets.only(top: 16.0),
  758. child: Text(
  759. AppLocalizations.of(context)!.levelPass,
  760. style: TextStyle(
  761. color: Colors.white,
  762. fontSize: 22,
  763. fontWeight: FontWeight.bold,
  764. shadows: [Shadow(color: Colors.black54, offset: Offset(1, 1), blurRadius: 2)],
  765. ),
  766. ),
  767. ),
  768. ),
  769. ],
  770. ),
  771. ),
  772. );
  773. }
  774. Offset _globalToLocal(Offset globalPosition) {
  775. final RenderBox renderBox = boardKey.currentContext!.findRenderObject() as RenderBox;
  776. return renderBox.globalToLocal(globalPosition);
  777. }
  778. void _onPanStart(DragStartDetails details) {
  779. _log.info('_onPanStart');
  780. if (board!.status != BoardStatus.playing) {
  781. _log.info('不是playing状态,不响应onPanStart');
  782. return;
  783. }
  784. if (board!.checkWinCondition()) {
  785. _log.info('游戏已经完成,不再响应onPanStart');
  786. return;
  787. }
  788. // 动画中断逻辑:如果动画正在进行,立即停止并强制所有 pieces 归位到最终位置
  789. if (_moveAnimationController.isAnimating && moveItems != null) {
  790. _log.info('移动动画中断,强制归位/交换');
  791. for (var item in moveItems!) {
  792. item.stop();
  793. }
  794. bool needRebuildGroup = moveItems!.any((item) => item.action == Action.swap);
  795. if (needRebuildGroup) {
  796. board!.backupAllGroups();
  797. board!.rebuildAllGroups();
  798. final mergeGroups = board!.compareAllGroups();
  799. if (mergeGroups.isNotEmpty) {
  800. // 此时不需要触发 merge 动画,只需确保数据结构正确
  801. }
  802. }
  803. moveItems = null; // 清空动画列表
  804. _moveAnimationController.stop();
  805. board!.invalidate(); // 触发一次重绘来显示最终位置
  806. }
  807. // 如果 merge 动画正在进行,也应该中断并立即归位
  808. if (_mergeAnimationController.isAnimating && _mergeGroups != null) {
  809. _log.info('合并动画中断,强制归位');
  810. for (var group in _mergeGroups!) {
  811. for (var piece in group.pieces) {
  812. piece.transform = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
  813. }
  814. }
  815. _mergeGroups = null;
  816. _mergeAnimationController.stop();
  817. board!.invalidate();
  818. }
  819. // 停止所有正在运行的动画(如果尚未停止)
  820. _moveAnimationController.stop();
  821. _mergeAnimationController.stop();
  822. moveItems = null;
  823. final localPosition = _globalToLocal(details.globalPosition);
  824. final touchedPiece = board?.findPieceAt(localPosition);
  825. if (touchedPiece != null) {
  826. audio.playSfx(SfxType.panstart);
  827. if (settings.vibrate.value) {
  828. if (Platform.isAndroid) {
  829. Vibration.vibrate(duration: 60, amplitude: 50);
  830. } else {
  831. HapticFeedback.mediumImpact();
  832. }
  833. }
  834. _draggingPiece = touchedPiece;
  835. final draggingGroup = _draggingPiece!.group;
  836. if (draggingGroup != null) {
  837. // 将拖拽群组置于 pieces 列表末尾,确保它在 CustomPainter 中被最后绘制(在最上层)
  838. board!.pieces.removeWhere((p) => draggingGroup.contains(p));
  839. board!.pieces.addAll(draggingGroup.pieces);
  840. } else {
  841. board!.pieces.remove(_draggingPiece);
  842. board!.pieces.add(_draggingPiece!);
  843. }
  844. board!.invalidate();
  845. }
  846. }
  847. void _onPanUpdate(DragUpdateDetails details) {
  848. if (_draggingPiece == null) return;
  849. final Offset delta = details.delta;
  850. final draggingGroup = _draggingPiece!.group;
  851. if (draggingGroup != null) {
  852. // 拖拽过程中,所有群组成员共享相同的位移
  853. for (var piece in draggingGroup.pieces) {
  854. piece.applyDelta(delta);
  855. }
  856. } else {
  857. _draggingPiece!.applyDelta(delta);
  858. }
  859. board!.invalidate();
  860. }
  861. void _onPanEnd(DragEndDetails details) {
  862. _log.info('_onPanEnd');
  863. if (_draggingPiece == null) {
  864. return;
  865. }
  866. audio.playSfx(SfxType.tap);
  867. // 保存当前拖拽结束的碎片,以备动画使用
  868. Piece leaderPiece = _draggingPiece!;
  869. _draggingPiece = null; // 结束拖拽
  870. board!.invalidate();
  871. /// 交换或归位
  872. // 获取碎片的中心点,判断中心点是否落到某个piece上
  873. Piece? targetPiece = board!.findPieceAtExclude(leaderPiece.currentCenter, leaderPiece);
  874. // 群组特殊处理:如果 leaderPiece 没有落在其他碎片上,检查群组其他成员
  875. if (targetPiece == null && leaderPiece.group != null) {
  876. for (var p in leaderPiece.group!.pieces) {
  877. targetPiece = board!.findPieceAtExclude(p.currentCenter, p);
  878. if (targetPiece != null) {
  879. _log.info('推举 ${p.toString()} 为新leader');
  880. leaderPiece = p; // p 落在有效的其他piece上,推举为leaderPiece
  881. break;
  882. }
  883. }
  884. }
  885. // 判断是否可以交换
  886. if (targetPiece != null && targetPiece != leaderPiece && leaderPiece.canPlaceTo(targetPiece)) {
  887. _log.info("swap animation start");
  888. _animateSwap(leaderPiece, targetPiece);
  889. } else {
  890. _log.info("revert animation start");
  891. _animateRevert(leaderPiece);
  892. }
  893. }
  894. // 为所有涉及移动的 piece 创建独立的 MoveItem
  895. void _animateSwap(Piece leaderPiece, Piece targetPiece) {
  896. List<MoveItem> items = [];
  897. // 1. 确定涉及移动的所有碎片
  898. final List<Piece> draggingPieces = leaderPiece.group != null ? leaderPiece.group!.pieces : [leaderPiece];
  899. final int dr = targetPiece.curRow - leaderPiece.curRow; // 目标位置的行位移
  900. final int dc = targetPiece.curCol - leaderPiece.curCol; // 目标位置的列位移
  901. // 2. 识别被替换/推开的碎片
  902. List<Piece> displacedPieces = [];
  903. if (leaderPiece.group != null) {
  904. // 群组交换:找到群组新目标位置上的所有非自身群组的碎片
  905. for (var p in draggingPieces) {
  906. final int targetRow = p.curRow + dr;
  907. final int targetCol = p.curCol + dc;
  908. final Piece? other = board!.getPieceByCoordinate(targetRow, targetCol);
  909. if (other != null && !p.isSameGroup(other)) {
  910. displacedPieces.add(other);
  911. }
  912. }
  913. } else {
  914. // 单碎片交换:被替换的碎片就是 targetPiece
  915. displacedPieces.add(targetPiece);
  916. }
  917. // 3. 更新逻辑坐标 (curRow, curCol) - 必须在创建动画前完成
  918. // a. 更新拖拽群组/碎片的位置
  919. for (var p in draggingPieces) {
  920. p.curRow += dr;
  921. p.curCol += dc;
  922. }
  923. // b. 更新被推开的碎片的位置 (移入拖拽群组腾出的槽位)
  924. if (leaderPiece.group == null) {
  925. // 单碎片交换:targetPiece 移入 leaderPiece 的旧槽位
  926. targetPiece.curRow -= dr;
  927. targetPiece.curCol -= dc;
  928. } else {
  929. // 群组交换:被推开的碎片向后退 dr/dc 距离,找到空槽位
  930. for (var p in displacedPieces) {
  931. int newRow = p.curRow - dr;
  932. int newCol = p.curCol - dc;
  933. do {
  934. final Piece? pieceInSlot = board!.getPieceByCoordinate(newRow, newCol);
  935. if (pieceInSlot == null) {
  936. p.curRow = newRow;
  937. p.curCol = newCol;
  938. break;
  939. } else {
  940. newRow -= dr;
  941. newCol -= dc;
  942. }
  943. } while (newRow >= 0 && newRow < p.rows && newCol >= 0 && newCol < p.cols);
  944. }
  945. }
  946. // 4. 为所有涉及移动的碎片创建 MoveItem
  947. // a. 被推开的碎片
  948. for (var p in displacedPieces) {
  949. // 动画起点:旧的逻辑网格坐标 (p.transform)
  950. final startTransform = p.transform;
  951. // 动画终点:新的逻辑网格坐标
  952. final endTransform = board!.getTransformByCoordinate(p.curRow, p.curCol);
  953. final animation = Matrix4Tween(begin: startTransform, end: endTransform).animate(_moveAnimationController);
  954. items.add(MoveItem(piece: p, animation: animation, startTransform: startTransform, endTransform: endTransform, action: Action.swap));
  955. board!.pieces.remove(p);
  956. board!.pieces.add(p);
  957. }
  958. // b. 拖拽群组/碎片
  959. for (var p in draggingPieces) {
  960. // 动画起点:拖拽结束时的实际 Canvas 坐标 (p.transform)
  961. final startTransform = p.transform;
  962. // 动画终点:新的逻辑网格坐标
  963. final endTransform = board!.getTransformByCoordinate(p.curRow, p.curCol);
  964. final animation = Matrix4Tween(begin: startTransform, end: endTransform).animate(_moveAnimationController);
  965. items.add(MoveItem(piece: p, animation: animation, startTransform: startTransform, endTransform: endTransform, action: Action.swap));
  966. board!.pieces.remove(p);
  967. board!.pieces.add(p);
  968. }
  969. // 5. 启动动画
  970. moveItems = items;
  971. _moveAnimationController.forward(from: 0.0);
  972. }
  973. // 关键重构:为所有涉及归位的 piece 创建独立的 MoveItem
  974. void _animateRevert(Piece piece) {
  975. List<MoveItem> items = [];
  976. final List<Piece> groupPieces = piece.group != null ? piece.group!.pieces : [piece];
  977. for (var p in groupPieces) {
  978. // 动画起点:拖拽结束时的实际 Canvas 坐标 (p.transform)
  979. final startTransform = p.transform;
  980. // 动画终点:归位位置(即拖拽前所在的逻辑网格坐标)
  981. final endTransform = board!.getTransformByCoordinate(p.curRow, p.curCol);
  982. final animation = Matrix4Tween(begin: startTransform, end: endTransform).animate(_moveAnimationController);
  983. items.add(MoveItem(piece: p, animation: animation, startTransform: startTransform, endTransform: endTransform, action: Action.revert));
  984. }
  985. moveItems = items;
  986. _moveAnimationController.forward(from: 0.0);
  987. }
  988. }
  989. // 辅助类:用于对 vmath.Matrix4 进行线性插值 (lerp),实现平滑动画
  990. class Matrix4Tween extends Tween<vmath.Matrix4> {
  991. Matrix4Tween({required vmath.Matrix4 begin, required vmath.Matrix4 end}) : super(begin: begin, end: end);
  992. @override
  993. vmath.Matrix4 lerp(double t) {
  994. if (begin == null || end == null) return begin ?? end ?? vmath.Matrix4.identity();
  995. final List<double> lerpedStorage = List.generate(16, (i) {
  996. // 确保使用 ui.lerpDouble 进行插值
  997. return ui.lerpDouble(begin!.storage[i], end!.storage[i], t)!;
  998. });
  999. return vmath.Matrix4.fromList(lerpedStorage.cast<double>());
  1000. }
  1001. }
  1002. // 动画辅助类,记录移动信息
  1003. class MoveItem {
  1004. // 要移动的piece
  1005. final Piece piece;
  1006. // 动画animation
  1007. final Animation<vmath.Matrix4> animation;
  1008. // 起始位置(拖拽结束时的实际 Canvas 坐标)
  1009. final vmath.Matrix4 startTransform;
  1010. // 结束位置(目标网格槽位的 Canvas 坐标)
  1011. final vmath.Matrix4 endTransform;
  1012. // 移除了 MoveType,因为现在每个 piece 都有自己的 MoveItem
  1013. // final MoveType moveType;
  1014. final Action action;
  1015. MoveItem({required this.piece, required this.animation, required this.startTransform, required this.endTransform, required this.action});
  1016. // 关键修正:直接设置 piece 的 transform 为动画插值
  1017. void move() {
  1018. // 关键修正:直接设置piece的transform为动画插值,而不是累加delta
  1019. piece.transform = animation.value;
  1020. }
  1021. void stop() {
  1022. // 关键修正:动画中断时,直接设置到最终目标位置 (endTransform)
  1023. // 此时 piece 的 curRow/curCol 已经是目标网格坐标
  1024. piece.transform = endTransform;
  1025. }
  1026. }