home_board_play.dart 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839
  1. import 'dart:async';
  2. import 'dart:math';
  3. import 'dart:ui' as ui;
  4. import 'package:flutter/material.dart';
  5. import 'package:fluttertoast/fluttertoast.dart';
  6. import 'package:puzzleweave/audio/jc_audio_controller.dart';
  7. import 'package:puzzleweave/config/device.dart';
  8. import 'package:puzzleweave/homepage/home_board.dart';
  9. import 'package:puzzleweave/l10n/app_localizations.dart';
  10. import 'package:puzzleweave/models/cached_request.dart';
  11. import 'package:puzzleweave/models/data.dart';
  12. import 'package:puzzleweave/models/items.dart';
  13. import 'package:puzzleweave/play/confetti_layer.dart';
  14. import 'package:puzzleweave/skin/skin.dart';
  15. import 'package:puzzleweave/utils/star.dart';
  16. import 'package:logging/logging.dart';
  17. import 'package:provider/provider.dart';
  18. final Logger _log = Logger('home_board_play');
  19. // ignore: must_be_immutable
  20. class HomeBoardPlay extends StatefulWidget {
  21. final double canvasWidth;
  22. final double canvasHeight;
  23. final GlobalKey collectionKey; // 接收 Collection Button Key, 方便定位
  24. VoidCallback? onCollectionDone; // 新增一个合集完成的回调,外部home_screen可能会关心,当合集解锁动画完成,home_screen的左上角合集icon需要放大再复原
  25. HomeBoardPlay({super.key, required this.canvasWidth, required this.canvasHeight, required this.collectionKey, this.onCollectionDone});
  26. @override
  27. State<HomeBoardPlay> createState() => HomeBoardPlayState();
  28. }
  29. class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMixin {
  30. late HomeBoard board;
  31. late JcAudioController audio;
  32. late Data data;
  33. List<ListItem>? collection;
  34. late CachedRequest collectionCachedRequest;
  35. late StreamSubscription? collectionSubscription;
  36. late ConfettiLayer confettiLayer;
  37. // 翻牌动画,完成一个关卡后翻开一个卡片
  38. late AnimationController _flipController;
  39. late Animation<double> _flipAnimation;
  40. // todo... 合集解锁动画,完成整个合集之后执行, 效果是整个图片缩小并位移到左上角的colleciton iconbutton, 形成合集“收纳”的效果
  41. late AnimationController _unlockController; // 解锁动画控制器
  42. late Animation<double> _unlockAnimation; // 0.0 -> 1.0
  43. // 当前合集是否已经完成(一个合集需要完成5x5即25个关卡才能解锁)(在切换到下一个合集之前调用判断)
  44. bool get _isCurCollectionDone => data.currentLevel != 0 && (data.currentCollectionIndex) * 25 == data.currentLevel;
  45. // bool get _isCurCollectionDone => true; // for test
  46. OverlayEntry? _overlayEntry; // 新增:用于管理全屏动画层
  47. // 发牌动画
  48. late AnimationController _dealingController; // 发牌动画控制器
  49. late Animation<double> _dealingAnimation;
  50. // 发牌间隔(ms)
  51. final int _dealingPieceInterval = 70;
  52. // 每个卡片移动时间(ms)
  53. final int _dealingPieceDuration = 300;
  54. // 发牌动画总时长(需要考虑到最后一个卡片的动画)
  55. // 共有 board.rows * board.cols 个卡片
  56. int get _totalDealingDuration => (board.rows * board.cols - 1) * _dealingPieceInterval + _dealingPieceDuration;
  57. Timer? _dealingPeriodicTimer;
  58. int _dealingCount = 0; // 计数:记录执行次数
  59. @override
  60. void initState() {
  61. super.initState();
  62. Device device = context.read<Device>();
  63. board = HomeBoard(canvasWidth: widget.canvasWidth, canvasHeight: widget.canvasHeight, device: device);
  64. board.isReadyNotifier.addListener(_onBoardReady);
  65. confettiLayer = ConfettiLayer(this);
  66. Future.delayed(Duration.zero, () {
  67. if (mounted) {
  68. confettiLayer.setup(context);
  69. }
  70. });
  71. audio = context.read<JcAudioController>();
  72. data = context.read<Data>();
  73. collectionCachedRequest = data.collection;
  74. // 主动获取缓存数据(关键)
  75. final collectionCachedData = collectionCachedRequest.cachedData;
  76. if (collectionCachedData != null) {
  77. _onCollectionDataUpdate(collectionCachedData);
  78. }
  79. collectionSubscription = collectionCachedRequest.stream.listen(_onCollectionDataUpdate, onError: _onCollectionDataError);
  80. // 初始化翻牌动画控制器
  81. _flipController = AnimationController(
  82. duration: const Duration(milliseconds: 1000), // 动画时长
  83. vsync: this, // HomeBoardState 必须实现 TickerProviderStateMixin
  84. );
  85. // 创建一个 0.0 到 1.0 的动画,用于控制翻转角度
  86. _flipAnimation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _flipController, curve: Curves.easeOut))
  87. ..addStatusListener((status) {
  88. if (status == AnimationStatus.completed) {
  89. // 检查整个合集是否全部完成
  90. _checkCollectionDone();
  91. }
  92. });
  93. // 初始化解锁动画控制器
  94. _unlockController = AnimationController(
  95. duration: const Duration(milliseconds: 800), // 动画时长
  96. vsync: this,
  97. );
  98. _unlockAnimation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _unlockController, curve: Curves.easeInBack))
  99. ..addStatusListener((status) {
  100. if (status == AnimationStatus.completed) {
  101. _log.info('合集解锁动画结束');
  102. _overlayEntry?.remove(); // 动画结束时移除浮层
  103. _overlayEntry = null;
  104. // 动画结束后,通知外部(HomeScreen)
  105. widget.onCollectionDone?.call();
  106. // 启动发牌动画
  107. _startDealingAnimation();
  108. }
  109. });
  110. // 初始化发牌动画
  111. _dealingController = AnimationController(
  112. duration: Duration(milliseconds: _totalDealingDuration), // 动态设置总时长
  113. vsync: this,
  114. );
  115. _dealingAnimation =
  116. CurvedAnimation(parent: _dealingController, curve: Curves.easeOut) // 使用缓动曲线
  117. ..addStatusListener((status) {
  118. if (status == AnimationStatus.completed) {
  119. // 发牌结束,这个时候再来切换到下一个合集
  120. switchToNextCollection();
  121. setState(() {
  122. board.status = HomeBoardStatus.playing; // 发牌结束进入正常的绘制状态
  123. board.invalidate(); // 确保最终状态绘制正确
  124. });
  125. }
  126. });
  127. }
  128. _onCollectionDataUpdate(colldata) async {
  129. _log.info('_onCollectionDataUpdate.... ');
  130. if (colldata != null) {
  131. collection = colldata as List<ListItem>;
  132. if (collection != null && collection!.isNotEmpty) {
  133. // 做一个矫正,避免没有正常退出,合集没有切换的情况
  134. if ((data.completedWorks.value.length / 25).floor() > data.currentCollectionIndex) {
  135. if (currentCollectionItem != null) {
  136. _log.info('合集落后于关卡,没有正常切换,矫正!');
  137. data.collectionDone(currentCollectionItem!);
  138. }
  139. }
  140. board.currentCollectionItem = currentCollectionItem;
  141. }
  142. setState(() {});
  143. // 远程数据没有加载到,3秒后重试
  144. if (colldata.length < 5) {
  145. Future.delayed(Duration(seconds: 3), () => refresh());
  146. }
  147. }
  148. }
  149. _onCollectionDataError(error) {
  150. _log.info('_onCollectionDataError.... $error');
  151. if (collection == null || collection!.isEmpty || collection!.length <= 2) {
  152. // 列表数据如果少于20,说明只是内置图,仍然刷新远程请求
  153. _log.warning("_onCollectionDataError, retry again");
  154. Future.delayed(Duration(seconds: 3), () => refresh());
  155. }
  156. }
  157. Future<void> refresh() async {
  158. _log.info('refresh...');
  159. await collectionCachedRequest.refresh();
  160. }
  161. // board 图片等资源加载完成的回调
  162. _onBoardReady() {
  163. if (board.status == HomeBoardStatus.loading) {
  164. setState(() {
  165. board.status = HomeBoardStatus.playing;
  166. });
  167. }
  168. }
  169. // !!! 改造点 2: 启动发牌动画
  170. void _startDealingAnimation() {
  171. setState(() {
  172. board.status = HomeBoardStatus.dealing;
  173. });
  174. _dealingController.duration = Duration(milliseconds: _totalDealingDuration); // 确保duration是正确的
  175. _dealingController.forward(from: 0.0);
  176. _dealingCount = 0;
  177. audio.playSfx(SfxType.card);
  178. _dealingPeriodicTimer = Timer.periodic(Duration(milliseconds: 150), (timer) {
  179. if (mounted) {
  180. _dealingCount++;
  181. if (_dealingCount >= (_totalDealingDuration / 150) - 2) {
  182. timer.cancel();
  183. } else {
  184. audio.playSfx(SfxType.card);
  185. }
  186. }
  187. });
  188. }
  189. ListItem? get currentCollectionItem {
  190. if (collection != null && collection!.isNotEmpty && data.currentCollectionIndex < collection!.length) {
  191. return collection![data.currentCollectionIndex];
  192. }
  193. return null;
  194. }
  195. @override
  196. void dispose() {
  197. board.isReadyNotifier.removeListener(_onBoardReady);
  198. confettiLayer.dispose();
  199. _flipController.dispose();
  200. _unlockController.dispose();
  201. _dealingController.dispose();
  202. collectionSubscription?.cancel();
  203. super.dispose();
  204. }
  205. // for test
  206. void testAnimation() async {
  207. // setState(() {
  208. // board.status = HomeBoardStatus.done;
  209. // board.invalidate();
  210. // });
  211. // audio.playSfx(SfxType.star);
  212. // confettiLayer.play();
  213. // // 等待confetti动画结束, 然后启动解锁动画
  214. // await Future.delayed(Duration(milliseconds: 500));
  215. // _startUnlockAnimation();
  216. setState(() {
  217. board.status = HomeBoardStatus.dealing;
  218. board.invalidate();
  219. });
  220. _startDealingAnimation();
  221. _dealingCount = 0;
  222. audio.playSfx(SfxType.card);
  223. _dealingPeriodicTimer = Timer.periodic(Duration(milliseconds: 150), (timer) {
  224. if (mounted) {
  225. _dealingCount++;
  226. if (_dealingCount >= (_totalDealingDuration / 150) - 2) {
  227. timer.cancel();
  228. } else {
  229. audio.playSfx(SfxType.card);
  230. }
  231. }
  232. });
  233. }
  234. // 翻牌动画结束后,检查整个合集是否全部完成,如果全部完成,需要展示一系列的动画
  235. void _checkCollectionDone() async {
  236. if (_isCurCollectionDone) {
  237. // 将状态置为done,canvas绘制一整张图,不再是单个卡片
  238. setState(() {
  239. board.status = HomeBoardStatus.done;
  240. board.invalidate();
  241. });
  242. _startUnlockAnimation();
  243. }
  244. }
  245. // 实现解锁动画启动方法
  246. void _startUnlockAnimation() {
  247. // 1. 获取动画参数 (与您之前的逻辑保持一致,用于计算位移和缩放目标)
  248. final RenderBox? targetRenderBox = widget.collectionKey.currentContext?.findRenderObject() as RenderBox?;
  249. if (targetRenderBox == null || !mounted) return;
  250. final targetPosition = targetRenderBox.localToGlobal(targetRenderBox.size.center(Offset.zero));
  251. final RenderBox? canvasRenderBox = context.findRenderObject() as RenderBox?;
  252. if (canvasRenderBox == null) return;
  253. // 获取 CustomPaint 顶层左上角的全局坐标
  254. final canvasGlobalTL = canvasRenderBox.localToGlobal(Offset.zero);
  255. // 计算 Canvas 中心到目标图标中心的全局位移量
  256. final canvasCenter = canvasRenderBox.localToGlobal(canvasRenderBox.size.center(Offset.zero));
  257. final Offset delta = targetPosition - canvasCenter;
  258. // 存储计算出的动画目标数据
  259. board.setUnlockAnimationTarget(targetOffset: delta, targetScale: targetRenderBox.size.width / widget.canvasWidth);
  260. // 存储计算出的动画目标数据,供 CustomPainter 使用
  261. board.setUnlockAnimationTarget(
  262. targetOffset: delta,
  263. // 目标缩放比例
  264. targetScale: targetRenderBox.size.width * 0.2 / widget.canvasWidth,
  265. // targetScale: 0,
  266. );
  267. // 2. 创建并插入全屏 Overlay Entry
  268. _overlayEntry = OverlayEntry(
  269. builder: (context) {
  270. // OverlayEntry 的 (0,0) 是屏幕的左上角。
  271. // 我们将 CustomPaint 放置在它原来的全局位置
  272. return Positioned(
  273. left: canvasGlobalTL.dx, // 初始 X 坐标
  274. top: canvasGlobalTL.dy, // 初始 Y 坐标
  275. child: SizedBox(
  276. // 尺寸和 HomeBoard 一致
  277. width: widget.canvasWidth,
  278. height: widget.canvasHeight,
  279. child: CustomPaint(
  280. size: Size(widget.canvasWidth, widget.canvasHeight),
  281. // 使用 CanvasPainter 绘制,并强制设置为 unlocking 状态
  282. painter: CanvasPainter(
  283. board: board,
  284. level: data.currentLevel,
  285. collectionIndex: data.currentCollectionIndex,
  286. flipAnimation: _flipAnimation,
  287. unlockAnimation: _unlockAnimation,
  288. forceStatus: HomeBoardStatus.unlocking, // 强制状态,用于 Overlay 绘制
  289. dealingAnimation: _dealingAnimation,
  290. dealingPieceInterval: _dealingPieceInterval,
  291. dealingPieceDuration: _dealingPieceDuration,
  292. ),
  293. ),
  294. ),
  295. );
  296. },
  297. );
  298. Overlay.of(context).insert(_overlayEntry!);
  299. // 启动动画
  300. setState(() {
  301. board.status = HomeBoardStatus.unlocking; // 切换到 unlocking 状态
  302. });
  303. _unlockController.forward(from: 0.0);
  304. }
  305. // 对外暴露的触发动画方法 (供 HomeScreen 调用)
  306. void startFlipAnimation() {
  307. _flipController.forward(from: 0.0);
  308. audio.playSfx(SfxType.flip);
  309. if (data.currentLevel != 0 && (data.currentCollectionIndex + 1) * 25 == data.currentLevel && currentCollectionItem != null) {
  310. data.collectionDone(currentCollectionItem!);
  311. // 展示撒花动画
  312. audio.playSfx(SfxType.star);
  313. confettiLayer.play();
  314. }
  315. }
  316. void switchToNextCollection() {
  317. if (currentCollectionItem == null) {
  318. _log.info('没有更多的合集了');
  319. Fluttertoast.showToast(
  320. msg: AppLocalizations.of(context)!.noMorePicture,
  321. toastLength: Toast.LENGTH_SHORT,
  322. gravity: ToastGravity.CENTER,
  323. timeInSecForIosWeb: 1,
  324. backgroundColor: SkinHelper.slotBorderColor,
  325. textColor: Colors.white,
  326. fontSize: 16.0,
  327. );
  328. return;
  329. }
  330. board.switchToNextCollection(currentCollectionItem!);
  331. }
  332. @override
  333. Widget build(BuildContext context) {
  334. // 检查是否正在执行全屏动画
  335. final isUnlocking = _unlockController.isAnimating || board.status == HomeBoardStatus.unlocking;
  336. return CustomPaint(
  337. size: Size(widget.canvasWidth, widget.canvasHeight), // 固定画布尺寸
  338. // 正在解锁动画时,原 CustomPaint 不绘制内容 (或者只绘制一个透明的占位图)
  339. painter: isUnlocking && _overlayEntry != null
  340. ? null // 动画运行时,原 CustomPaint 不绘制,避免冲突
  341. : CanvasPainter(
  342. board: board,
  343. level: data.currentLevel,
  344. collectionIndex: data.currentCollectionIndex,
  345. flipAnimation: _flipAnimation,
  346. unlockAnimation: _unlockAnimation,
  347. dealingAnimation: _dealingAnimation,
  348. dealingPieceDuration: _dealingPieceDuration,
  349. dealingPieceInterval: _dealingPieceInterval,
  350. ),
  351. child: board.status == HomeBoardStatus.loading
  352. ? Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(SkinHelper.slotBorderColor)))
  353. : Container(),
  354. // child: childWidget,
  355. );
  356. }
  357. Widget get childWidget {
  358. if (board.status == HomeBoardStatus.loading) {
  359. return Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(SkinHelper.slotBorderColor)));
  360. } else if (board.status == HomeBoardStatus.done) {
  361. return const ShiningStars(size: 80);
  362. } else {
  363. return Container();
  364. }
  365. }
  366. }
  367. // 自定义画笔实现(实际绘制逻辑在这里)
  368. class CanvasPainter extends CustomPainter {
  369. final HomeBoard board;
  370. final int level; //当前关卡序号
  371. final int collectionIndex; // 当前合集序号
  372. final Animation<double> flipAnimation; // 0.0 -> 1.0
  373. final Animation<double> unlockAnimation;
  374. final HomeBoardStatus? forceStatus; // !!! 新增字段
  375. final Animation<double> dealingAnimation; // !!! 改造点 6: 发牌动画
  376. final int dealingPieceDuration; // !!! 改造点 7: 单个卡片动画时长
  377. final int dealingPieceInterval; // !!! 改造点 8: 单个卡片动画间隔
  378. CanvasPainter({
  379. required this.board,
  380. required this.level,
  381. required this.collectionIndex,
  382. required this.flipAnimation,
  383. required this.unlockAnimation,
  384. this.forceStatus,
  385. required this.dealingAnimation,
  386. required this.dealingPieceDuration,
  387. required this.dealingPieceInterval,
  388. }) : super(repaint: Listenable.merge([board.boardNotifier, flipAnimation, unlockAnimation, dealingAnimation])); // 触发重绘;
  389. @override
  390. void paint(Canvas canvas, Size size) {
  391. // !!! 改造点 1: 优先使用强制状态,否则使用 Board 状态
  392. final statusToPaint = forceStatus ?? board.status;
  393. if (statusToPaint == HomeBoardStatus.playing) {
  394. _paintPlaying(canvas, size);
  395. } else if (statusToPaint == HomeBoardStatus.done) {
  396. // 动画结束前,原 CustomPaint 处于 done 状态
  397. _paintSuccess(canvas, size);
  398. } else if (statusToPaint == HomeBoardStatus.unlocking) {
  399. // 仅在 Overlay 中调用,执行动画
  400. _paintUnlocking(canvas, size);
  401. } else if (statusToPaint == HomeBoardStatus.dealing) {
  402. _paintDealing(canvas, size);
  403. }
  404. }
  405. // !!! 改造点 11: 实现 _paintDealing 方法
  406. void _paintDealing(Canvas canvas, Size size) {
  407. _log.info('_paintDealing');
  408. final totalPieces = board.rows * board.cols;
  409. final totalAnimationDuration = dealingPieceInterval * (totalPieces - 1) + dealingPieceDuration;
  410. // 起始位置 (右下角宫格的中心)
  411. final startX = board.cols - 1;
  412. final startY = board.rows - 1;
  413. final startPieceCenterX = startX * board.pieceLogicalWidth + board.pieceLogicalWidth / 2;
  414. final startPieceCenterY = startY * board.pieceLogicalHeight + board.pieceLogicalHeight / 2;
  415. for (var i = 0; i < board.rows; i++) {
  416. for (var j = 0; j < board.cols; j++) {
  417. final pieceIndex = i * board.cols + j; // 卡片索引 (0 到 24)
  418. // 计算该卡片的动画起始时间和结束时间 (相对于 _dealingController 的总 duration)
  419. final animationStartTime = pieceIndex * dealingPieceInterval; // 当前卡片开始动画的毫秒数
  420. final animationEndTime = animationStartTime + dealingPieceDuration; // 当前卡片结束动画的毫秒数
  421. // 计算当前全局动画进度 (0.0 - 1.0) 对应的毫秒数
  422. final currentGlobalTime = dealingAnimation.value * totalAnimationDuration;
  423. // 判断当前卡片是否应该开始动画
  424. if (currentGlobalTime < animationStartTime) {
  425. // 还没轮到这个卡片,保持在起始位置
  426. _drawDealingPiece(
  427. canvas,
  428. size,
  429. i,
  430. j,
  431. startPieceCenterX - (j * board.pieceLogicalWidth + board.pieceLogicalWidth / 2), // 补偿目标位置的x
  432. startPieceCenterY - (i * board.pieceLogicalHeight + board.pieceLogicalHeight / 2), // 补偿目标位置的y
  433. 0.0, // 进度为0,即起始位置
  434. );
  435. } else if (currentGlobalTime >= animationEndTime) {
  436. // 动画已结束,停留在目标位置
  437. _drawDealingPiece(
  438. canvas,
  439. size,
  440. i,
  441. j,
  442. 0.0, // 目标位置的相对偏移量为0
  443. 0.0,
  444. 1.0, // 进度为1,即目标位置
  445. );
  446. } else {
  447. // 正在动画中,计算该卡片的局部动画进度
  448. final localProgress = (currentGlobalTime - animationStartTime) / dealingPieceDuration;
  449. _drawDealingPiece(
  450. canvas,
  451. size,
  452. i,
  453. j,
  454. startPieceCenterX - (j * board.pieceLogicalWidth + board.pieceLogicalWidth / 2), // 补偿目标位置的x
  455. startPieceCenterY - (i * board.pieceLogicalHeight + board.pieceLogicalHeight / 2), // 补偿目标位置的y
  456. localProgress,
  457. );
  458. }
  459. }
  460. }
  461. }
  462. // 辅助方法:绘制发牌动画中的单个卡片
  463. void _drawDealingPiece(
  464. Canvas canvas,
  465. Size size,
  466. int row,
  467. int col,
  468. double startOffsetX,
  469. double startOffsetY, // 起始位置相对于目标位置的偏移量
  470. double progress, // 0.0 -> 1.0
  471. ) {
  472. final curIndex = collectionIndex * board.count + row * board.cols + col;
  473. final w = board.pieceLogicalWidth;
  474. final h = board.pieceLogicalHeight;
  475. final targetLeft = col * w;
  476. final targetTop = row * h;
  477. // 计算当前动画帧的偏移量
  478. final currentOffsetX = ui.lerpDouble(startOffsetX, 0.0, progress)!;
  479. final currentOffsetY = ui.lerpDouble(startOffsetY, 0.0, progress)!;
  480. canvas.save();
  481. // 先平移到卡片最终位置的左上角,再应用动画偏移
  482. canvas.translate(targetLeft + currentOffsetX, targetTop + currentOffsetY);
  483. // 绘制卡片背面
  484. final cardSourceRect = Rect.fromLTWH(0, 0, board.cardImage.width.toDouble(), board.cardImage.height.toDouble());
  485. final rect = Rect.fromLTWH(0, 0, w, h); // 绘制在当前变换后的 (0,0,w,h)
  486. canvas.drawImageRect(board.cardImage, cardSourceRect, rect, Paint()..isAntiAlias = true);
  487. // 绘制关卡数字 (与 _drawPiece 中的逻辑类似)
  488. final textStyle = TextStyle(
  489. color: Colors.white,
  490. fontSize: h * 0.25,
  491. fontWeight: FontWeight.bold,
  492. shadows: [Shadow(offset: const Offset(1, 1), blurRadius: 2, color: Colors.black38)],
  493. );
  494. final textSpan = TextSpan(text: (curIndex + 1).toString(), style: textStyle);
  495. final textPainter = TextPainter(text: textSpan, textDirection: TextDirection.ltr, textAlign: TextAlign.center);
  496. textPainter.layout(minWidth: 0, maxWidth: w);
  497. final textOffset = Offset((w - textPainter.width) / 2, (h - textPainter.height) / 2);
  498. textPainter.paint(canvas, textOffset);
  499. canvas.restore();
  500. // 绘制边框(为了避免裁剪,在 restore 后绘制)
  501. final cornerRadius = 4.0;
  502. final outerRRect = RRect.fromRectAndRadius(
  503. Rect.fromLTWH(targetLeft + currentOffsetX, targetTop + currentOffsetY, w, h).deflate(0.5),
  504. Radius.circular(cornerRadius),
  505. );
  506. final innerRRect = RRect.fromRectAndRadius(
  507. Rect.fromLTWH(targetLeft + currentOffsetX, targetTop + currentOffsetY, w, h).deflate(1.5),
  508. Radius.circular(cornerRadius),
  509. );
  510. canvas.drawRRect(outerRRect, outerBorderPaint);
  511. canvas.drawRRect(innerRRect, innerBorderPaint);
  512. }
  513. void _paintUnlocking(Canvas canvas, Size size) {
  514. _log.info('_paintUnlocking');
  515. // 1. 获取动画进度 (0.0 -> 1.0)
  516. final progress = unlockAnimation.value;
  517. // 3. 计算当前的位移和缩放
  518. // 缩放:从 1.0 缩小到 targetScale
  519. final startScale = 1.0;
  520. final endScale = board.unlockTargetScale;
  521. final currentScale = ui.lerpDouble(startScale, endScale, progress)!;
  522. // 位移:从 (0, 0) 平移到 targetOffset
  523. final startOffset = Offset.zero;
  524. final endOffset = board.unlockTargetOffset;
  525. final currentOffset = Offset(ui.lerpDouble(startOffset.dx, endOffset.dx, progress)!, ui.lerpDouble(startOffset.dy, endOffset.dy, progress)!);
  526. // 4. 应用 Canvas 变换
  527. canvas.save();
  528. // 缩放:以 Canvas 中心为缩放原点进行缩放
  529. final centerX = size.width / 2;
  530. final centerY = size.height / 2;
  531. // 4.1. 移动到 Canvas 中心点 (将原点移到 CustomPaint 的中心)
  532. canvas.translate(centerX, centerY);
  533. // 4.2. 应用位移 (这是中心点相对 CustomPaint 中心点的移动)
  534. canvas.translate(currentOffset.dx, currentOffset.dy);
  535. // 4.3. 应用缩放 (以当前中心点为原点)
  536. canvas.scale(currentScale);
  537. // 4.4. 移回 Canvas 原点 (回到 CustomPaint 的左上角,已应用了 位移 + 缩放)
  538. canvas.translate(-centerX, -centerY);
  539. // 5. 绘制完整的合集图片 (与 _paintSuccess 逻辑相同)
  540. // 简单点, 动画就不画边框圆角这些了
  541. // final cornerRadius = 4.0;
  542. final rect = Rect.fromLTWH(0, 0, size.width, size.height);
  543. // final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius));
  544. // final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius));
  545. // final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius));
  546. final sourceRect = Rect.fromLTWH(0, 0, board.image!.width.toDouble(), board.image!.height.toDouble());
  547. // 裁剪并绘制图片
  548. // canvas.clipRRect(rrect); // 动画就不用clipRRect了
  549. canvas.drawImageRect(board.image!, sourceRect, rect, Paint()..isAntiAlias = true);
  550. // 绘制边框
  551. // canvas.drawRRect(outerRRect, outerBorderPaint);
  552. // canvas.drawRRect(innerRRect, innerBorderPaint);
  553. canvas.restore();
  554. }
  555. _paintSuccess(Canvas canvas, Size size) {
  556. _log.info('_paintSuccess');
  557. final cornerRadius = 4.0;
  558. final rect = Rect.fromLTWH(0, 0, size.width, size.height);
  559. final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius));
  560. final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius));
  561. final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius));
  562. final sourceRect = Rect.fromLTWH(0, 0, board.image!.width.toDouble(), board.image!.height.toDouble());
  563. canvas.save();
  564. canvas.clipRRect(rrect);
  565. canvas.drawImageRect(board.image!, sourceRect, rect, Paint()..isAntiAlias = true);
  566. canvas.restore();
  567. // 绘制边框
  568. canvas.drawRRect(outerRRect, outerBorderPaint);
  569. canvas.drawRRect(innerRRect, innerBorderPaint);
  570. }
  571. _paintPlaying(Canvas canvas, Size size) {
  572. _log.info('_paintPlaying');
  573. for (var i = 0; i < board.rows; i++) {
  574. for (var j = 0; j < board.cols; j++) {
  575. // 玩过的关卡翻正面显示, 否则显示卡片背面
  576. final int curIndex = i * board.rows + j;
  577. bool flipped = level > collectionIndex * board.count + curIndex;
  578. _drawPiece(canvas, size, i, j, flipped);
  579. }
  580. }
  581. }
  582. final Paint outerBorderPaint = Paint()
  583. ..color = SkinHelper.outLineBorderColor
  584. ..style = PaintingStyle.stroke
  585. ..strokeWidth = 1.0
  586. ..isAntiAlias = true;
  587. // 边框画笔
  588. final Paint innerBorderPaint = Paint()
  589. ..color = SkinHelper.innerLineBorderColor
  590. ..style = PaintingStyle.stroke
  591. ..strokeWidth = 1.0
  592. ..isAntiAlias = true;
  593. void _drawPiece(Canvas canvas, Size size, int row, int col, bool flipped) {
  594. final cornerRadius = 4.0;
  595. final w = size.width / board.cols;
  596. final h = size.height / board.rows;
  597. final pieceWidth = board.image!.width / board.cols;
  598. final pieceHeight = board.image!.height / board.rows;
  599. final left = col * w;
  600. final top = row * h;
  601. final right = left + w;
  602. final bottom = top + h;
  603. // 为了避免描边溢出到相邻槽位,将矩形向内收缩 halfStroke
  604. final rect = Rect.fromLTRB(left, top, right, bottom);
  605. final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius));
  606. final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius));
  607. final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius));
  608. final cardSourceRect = Rect.fromLTWH(0, 0, board.cardImage.width.toDouble(), board.cardImage.height.toDouble());
  609. final imageSourceRect = Rect.fromLTWH(pieceWidth * col, pieceHeight * row, pieceWidth, pieceHeight);
  610. // 0-based index
  611. final curIndex = collectionIndex * board.count + row * board.rows + col;
  612. // 1. 计算当前的翻转状态
  613. double flipProgress = 0.0;
  614. if (flipAnimation.isAnimating && curIndex == level - 1) {
  615. // for test,只为方便查看动画效果,真正的代码是上面注释掉的
  616. // if (flipAnimation.isAnimating && curIndex == 24) {
  617. flipProgress = flipAnimation.value; // 0.0 -> 1.0
  618. flipped = (flipProgress > 0.5);
  619. }
  620. // _log.info('level=$level, row=$row, col=$col, flippingIndex=$flippingIndex, flipProgress=$flipProgress, currentPieceFlipped=$currentPieceFlipped');
  621. canvas.save();
  622. // 2. 居中变换原点到拼图块中心
  623. final centerX = left + w / 2;
  624. final centerY = top + h / 2;
  625. canvas.translate(centerX, centerY);
  626. // 3. 应用 3D 旋转 (围绕 Y 轴)
  627. if (flipProgress > 0.0) {
  628. // 旋转角度从 0 到 pi (180度)
  629. double angle = flipProgress * pi;
  630. // 引入透视投影(z轴缩放),让翻转效果更立体
  631. const double perspective = 0.0015;
  632. // 3D 变换矩阵
  633. Matrix4 transform;
  634. if (flipProgress <= 0.5) {
  635. transform = Matrix4.identity()
  636. ..setEntry(3, 2, perspective) // 3D 效果
  637. ..rotateY(angle);
  638. } else {
  639. transform = Matrix4.identity()
  640. ..setEntry(3, 2, perspective) // 3D 效果
  641. ..rotateY(angle)
  642. ..scale(-1.0, 1.0, 1.0); // 3. X轴缩放-1:抵消旋转带来的左右镜像
  643. }
  644. canvas.transform(transform.storage);
  645. }
  646. // 4. 移回原点
  647. canvas.translate(-centerX, -centerY);
  648. // ... 现有裁剪逻辑 ...
  649. canvas.clipRRect(rrect);
  650. if (flipped) {
  651. // 绘制正面
  652. canvas.drawImageRect(board.image!, imageSourceRect, rect, Paint()..isAntiAlias = true);
  653. } else {
  654. // 绘制背面
  655. // 必须反转图片源矩形,以修正翻转180度后图像的镜像问题
  656. final sourceRect = flipProgress > 0.5
  657. ? imageSourceRect // 翻转后使用正面图像
  658. : cardSourceRect; // 翻转前使用背面卡片
  659. final targetImage = flipProgress > 0.5 ? board.image! : board.cardImage;
  660. canvas.drawImageRect(targetImage, sourceRect, rect, Paint()..isAntiAlias = true);
  661. if (flipProgress <= 0.5) {
  662. // todo... 绘制关卡数字, 在卡片中间位置把curIndex绘制上去, 颜色白色
  663. // 1. 配置文字样式:白色、加粗、动态字体大小(适配卡片尺寸)
  664. final textStyle = TextStyle(
  665. color: Colors.white,
  666. fontSize: h * 0.25, // 字体大小为卡片高度的40%,适配不同尺寸
  667. fontWeight: FontWeight.bold,
  668. shadows: [
  669. // 增加黑色阴影,让白色文字在卡片背景上更清晰(可选但推荐)
  670. Shadow(offset: const Offset(1, 1), blurRadius: 2, color: Colors.black38),
  671. ],
  672. );
  673. // 2. 初始化文字绘制器
  674. final textSpan = TextSpan(text: (curIndex + 1).toString(), style: textStyle);
  675. final textPainter = TextPainter(
  676. text: textSpan,
  677. textDirection: TextDirection.ltr,
  678. textAlign: TextAlign.center, // 文字水平居中
  679. );
  680. // 3. 计算文字尺寸(必须调用layout())
  681. textPainter.layout(
  682. minWidth: 0,
  683. maxWidth: w, // 文字最大宽度不超过卡片宽度
  684. );
  685. // 4. 计算文字居中偏移量
  686. final textOffset = Offset(
  687. left + (w - textPainter.width) / 2, // 水平居中
  688. top + (h - textPainter.height) / 2, // 垂直居中
  689. );
  690. // 5. 绘制文字
  691. textPainter.paint(canvas, textOffset);
  692. }
  693. }
  694. canvas.restore(); // 恢复 Canvas 状态 (移除裁剪和 transform)
  695. // --- 绘制边框 ---
  696. // 为了确保边框线宽向外延伸的部分不会被裁剪,我们需要在裁剪区域被移除后重新绘制。
  697. canvas.save();
  698. canvas.drawRRect(outerRRect, outerBorderPaint);
  699. canvas.drawRRect(innerRRect, innerBorderPaint);
  700. canvas.restore();
  701. }
  702. @override
  703. bool shouldRepaint(covariant CanvasPainter oldDelegate) {
  704. return oldDelegate.level != level ||
  705. oldDelegate.flipAnimation != flipAnimation ||
  706. oldDelegate.unlockAnimation != unlockAnimation ||
  707. oldDelegate.dealingAnimation != dealingAnimation ||
  708. oldDelegate.board != board;
  709. }
  710. }