board_play.dart 40 KB

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