import 'dart:async'; import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:puzzleweave/audio/jc_audio_controller.dart'; import 'package:puzzleweave/config/device.dart'; import 'package:puzzleweave/homepage/home_board.dart'; import 'package:puzzleweave/l10n/app_localizations.dart'; import 'package:puzzleweave/models/cached_request.dart'; import 'package:puzzleweave/models/data.dart'; import 'package:puzzleweave/models/items.dart'; import 'package:puzzleweave/play/confetti_layer.dart'; import 'package:puzzleweave/skin/skin.dart'; import 'package:puzzleweave/utils/star.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; final Logger _log = Logger('home_board_play'); // ignore: must_be_immutable class HomeBoardPlay extends StatefulWidget { final double canvasWidth; final double canvasHeight; final GlobalKey collectionKey; // 接收 Collection Button Key, 方便定位 VoidCallback? onCollectionDone; // 新增一个合集完成的回调,外部home_screen可能会关心,当合集解锁动画完成,home_screen的左上角合集icon需要放大再复原 HomeBoardPlay({super.key, required this.canvasWidth, required this.canvasHeight, required this.collectionKey, this.onCollectionDone}); @override State createState() => HomeBoardPlayState(); } class HomeBoardPlayState extends State with TickerProviderStateMixin { late HomeBoard board; late JcAudioController audio; late Data data; List? collection; late CachedRequest collectionCachedRequest; late StreamSubscription? collectionSubscription; late ConfettiLayer confettiLayer; // 翻牌动画,完成一个关卡后翻开一个卡片 late AnimationController _flipController; late Animation _flipAnimation; // todo... 合集解锁动画,完成整个合集之后执行, 效果是整个图片缩小并位移到左上角的colleciton iconbutton, 形成合集“收纳”的效果 late AnimationController _unlockController; // 解锁动画控制器 late Animation _unlockAnimation; // 0.0 -> 1.0 // 当前合集是否已经完成(一个合集需要完成5x5即25个关卡才能解锁)(在切换到下一个合集之前调用判断) bool get _isCurCollectionDone => data.currentLevel != 0 && (data.currentCollectionIndex) * 25 == data.currentLevel; // bool get _isCurCollectionDone => true; // for test OverlayEntry? _overlayEntry; // 新增:用于管理全屏动画层 // 发牌动画 late AnimationController _dealingController; // 发牌动画控制器 late Animation _dealingAnimation; // 发牌间隔(ms) final int _dealingPieceInterval = 70; // 每个卡片移动时间(ms) final int _dealingPieceDuration = 300; // 发牌动画总时长(需要考虑到最后一个卡片的动画) // 共有 board.rows * board.cols 个卡片 int get _totalDealingDuration => (board.rows * board.cols - 1) * _dealingPieceInterval + _dealingPieceDuration; Timer? _dealingPeriodicTimer; int _dealingCount = 0; // 计数:记录执行次数 @override void initState() { super.initState(); Device device = context.read(); board = HomeBoard(canvasWidth: widget.canvasWidth, canvasHeight: widget.canvasHeight, device: device); board.isReadyNotifier.addListener(_onBoardReady); confettiLayer = ConfettiLayer(this); Future.delayed(Duration.zero, () { if (mounted) { confettiLayer.setup(context); } }); audio = context.read(); data = context.read(); collectionCachedRequest = data.collection; // 主动获取缓存数据(关键) final collectionCachedData = collectionCachedRequest.cachedData; if (collectionCachedData != null) { _onCollectionDataUpdate(collectionCachedData); } collectionSubscription = collectionCachedRequest.stream.listen(_onCollectionDataUpdate, onError: _onCollectionDataError); // 初始化翻牌动画控制器 _flipController = AnimationController( duration: const Duration(milliseconds: 1000), // 动画时长 vsync: this, // HomeBoardState 必须实现 TickerProviderStateMixin ); // 创建一个 0.0 到 1.0 的动画,用于控制翻转角度 _flipAnimation = Tween(begin: 0, end: 1).animate(CurvedAnimation(parent: _flipController, curve: Curves.easeOut)) ..addStatusListener((status) { if (status == AnimationStatus.completed) { // 检查整个合集是否全部完成 _checkCollectionDone(); } }); // 初始化解锁动画控制器 _unlockController = AnimationController( duration: const Duration(milliseconds: 800), // 动画时长 vsync: this, ); _unlockAnimation = Tween(begin: 0, end: 1).animate(CurvedAnimation(parent: _unlockController, curve: Curves.easeInBack)) ..addStatusListener((status) { if (status == AnimationStatus.completed) { _log.info('合集解锁动画结束'); _overlayEntry?.remove(); // 动画结束时移除浮层 _overlayEntry = null; // 动画结束后,通知外部(HomeScreen) widget.onCollectionDone?.call(); // 启动发牌动画 _startDealingAnimation(); } }); // 初始化发牌动画 _dealingController = AnimationController( duration: Duration(milliseconds: _totalDealingDuration), // 动态设置总时长 vsync: this, ); _dealingAnimation = CurvedAnimation(parent: _dealingController, curve: Curves.easeOut) // 使用缓动曲线 ..addStatusListener((status) { if (status == AnimationStatus.completed) { // 发牌结束,这个时候再来切换到下一个合集 switchToNextCollection(); setState(() { board.status = HomeBoardStatus.playing; // 发牌结束进入正常的绘制状态 board.invalidate(); // 确保最终状态绘制正确 }); } }); } _onCollectionDataUpdate(colldata) async { _log.info('_onCollectionDataUpdate.... '); if (colldata != null) { collection = colldata as List; if (collection != null && collection!.isNotEmpty) { // 做一个矫正,避免没有正常退出,合集没有切换的情况 if ((data.completedWorks.value.length / 25).floor() > data.currentCollectionIndex) { if (currentCollectionItem != null) { _log.info('合集落后于关卡,没有正常切换,矫正!'); data.collectionDone(currentCollectionItem!); } } board.currentCollectionItem = currentCollectionItem; } setState(() {}); // 远程数据没有加载到,3秒后重试 if (colldata.length < 5) { Future.delayed(Duration(seconds: 3), () => refresh()); } } } _onCollectionDataError(error) { _log.info('_onCollectionDataError.... $error'); if (collection == null || collection!.isEmpty || collection!.length <= 2) { // 列表数据如果少于20,说明只是内置图,仍然刷新远程请求 _log.warning("_onCollectionDataError, retry again"); Future.delayed(Duration(seconds: 3), () => refresh()); } } Future refresh() async { _log.info('refresh...'); await collectionCachedRequest.refresh(); } // board 图片等资源加载完成的回调 _onBoardReady() { if (board.status == HomeBoardStatus.loading) { setState(() { board.status = HomeBoardStatus.playing; }); } } // !!! 改造点 2: 启动发牌动画 void _startDealingAnimation() { setState(() { board.status = HomeBoardStatus.dealing; }); _dealingController.duration = Duration(milliseconds: _totalDealingDuration); // 确保duration是正确的 _dealingController.forward(from: 0.0); _dealingCount = 0; audio.playSfx(SfxType.card); _dealingPeriodicTimer = Timer.periodic(Duration(milliseconds: 150), (timer) { if (mounted) { _dealingCount++; if (_dealingCount >= (_totalDealingDuration / 150) - 2) { timer.cancel(); } else { audio.playSfx(SfxType.card); } } }); } ListItem? get currentCollectionItem { if (collection != null && collection!.isNotEmpty && data.currentCollectionIndex < collection!.length) { return collection![data.currentCollectionIndex]; } return null; } @override void dispose() { board.isReadyNotifier.removeListener(_onBoardReady); confettiLayer.dispose(); _flipController.dispose(); _unlockController.dispose(); _dealingController.dispose(); collectionSubscription?.cancel(); super.dispose(); } // for test void testAnimation() async { // setState(() { // board.status = HomeBoardStatus.done; // board.invalidate(); // }); // audio.playSfx(SfxType.star); // confettiLayer.play(); // // 等待confetti动画结束, 然后启动解锁动画 // await Future.delayed(Duration(milliseconds: 500)); // _startUnlockAnimation(); setState(() { board.status = HomeBoardStatus.dealing; board.invalidate(); }); _startDealingAnimation(); _dealingCount = 0; audio.playSfx(SfxType.card); _dealingPeriodicTimer = Timer.periodic(Duration(milliseconds: 150), (timer) { if (mounted) { _dealingCount++; if (_dealingCount >= (_totalDealingDuration / 150) - 2) { timer.cancel(); } else { audio.playSfx(SfxType.card); } } }); } // 翻牌动画结束后,检查整个合集是否全部完成,如果全部完成,需要展示一系列的动画 void _checkCollectionDone() async { if (_isCurCollectionDone) { // 将状态置为done,canvas绘制一整张图,不再是单个卡片 setState(() { board.status = HomeBoardStatus.done; board.invalidate(); }); _startUnlockAnimation(); } } // 实现解锁动画启动方法 void _startUnlockAnimation() { // 1. 获取动画参数 (与您之前的逻辑保持一致,用于计算位移和缩放目标) final RenderBox? targetRenderBox = widget.collectionKey.currentContext?.findRenderObject() as RenderBox?; if (targetRenderBox == null || !mounted) return; final targetPosition = targetRenderBox.localToGlobal(targetRenderBox.size.center(Offset.zero)); final RenderBox? canvasRenderBox = context.findRenderObject() as RenderBox?; if (canvasRenderBox == null) return; // 获取 CustomPaint 顶层左上角的全局坐标 final canvasGlobalTL = canvasRenderBox.localToGlobal(Offset.zero); // 计算 Canvas 中心到目标图标中心的全局位移量 final canvasCenter = canvasRenderBox.localToGlobal(canvasRenderBox.size.center(Offset.zero)); final Offset delta = targetPosition - canvasCenter; // 存储计算出的动画目标数据 board.setUnlockAnimationTarget(targetOffset: delta, targetScale: targetRenderBox.size.width / widget.canvasWidth); // 存储计算出的动画目标数据,供 CustomPainter 使用 board.setUnlockAnimationTarget( targetOffset: delta, // 目标缩放比例 targetScale: targetRenderBox.size.width * 0.2 / widget.canvasWidth, // targetScale: 0, ); // 2. 创建并插入全屏 Overlay Entry _overlayEntry = OverlayEntry( builder: (context) { // OverlayEntry 的 (0,0) 是屏幕的左上角。 // 我们将 CustomPaint 放置在它原来的全局位置 return Positioned( left: canvasGlobalTL.dx, // 初始 X 坐标 top: canvasGlobalTL.dy, // 初始 Y 坐标 child: SizedBox( // 尺寸和 HomeBoard 一致 width: widget.canvasWidth, height: widget.canvasHeight, child: CustomPaint( size: Size(widget.canvasWidth, widget.canvasHeight), // 使用 CanvasPainter 绘制,并强制设置为 unlocking 状态 painter: CanvasPainter( board: board, level: data.currentLevel, collectionIndex: data.currentCollectionIndex, flipAnimation: _flipAnimation, unlockAnimation: _unlockAnimation, forceStatus: HomeBoardStatus.unlocking, // 强制状态,用于 Overlay 绘制 dealingAnimation: _dealingAnimation, dealingPieceInterval: _dealingPieceInterval, dealingPieceDuration: _dealingPieceDuration, ), ), ), ); }, ); Overlay.of(context).insert(_overlayEntry!); // 启动动画 setState(() { board.status = HomeBoardStatus.unlocking; // 切换到 unlocking 状态 }); _unlockController.forward(from: 0.0); } // 对外暴露的触发动画方法 (供 HomeScreen 调用) void startFlipAnimation() { _flipController.forward(from: 0.0); audio.playSfx(SfxType.flip); if (data.currentLevel != 0 && (data.currentCollectionIndex + 1) * 25 == data.currentLevel && currentCollectionItem != null) { data.collectionDone(currentCollectionItem!); // 展示撒花动画 audio.playSfx(SfxType.star); confettiLayer.play(); } } void switchToNextCollection() { if (currentCollectionItem == null) { _log.info('没有更多的合集了'); Fluttertoast.showToast( msg: AppLocalizations.of(context)!.noMorePicture, toastLength: Toast.LENGTH_SHORT, gravity: ToastGravity.CENTER, timeInSecForIosWeb: 1, backgroundColor: SkinHelper.slotBorderColor, textColor: Colors.white, fontSize: 16.0, ); return; } board.switchToNextCollection(currentCollectionItem!); } @override Widget build(BuildContext context) { // 检查是否正在执行全屏动画 final isUnlocking = _unlockController.isAnimating || board.status == HomeBoardStatus.unlocking; return CustomPaint( size: Size(widget.canvasWidth, widget.canvasHeight), // 固定画布尺寸 // 正在解锁动画时,原 CustomPaint 不绘制内容 (或者只绘制一个透明的占位图) painter: isUnlocking && _overlayEntry != null ? null // 动画运行时,原 CustomPaint 不绘制,避免冲突 : CanvasPainter( board: board, level: data.currentLevel, collectionIndex: data.currentCollectionIndex, flipAnimation: _flipAnimation, unlockAnimation: _unlockAnimation, dealingAnimation: _dealingAnimation, dealingPieceDuration: _dealingPieceDuration, dealingPieceInterval: _dealingPieceInterval, ), child: board.status == HomeBoardStatus.loading ? Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(SkinHelper.slotBorderColor))) : Container(), // child: childWidget, ); } Widget get childWidget { if (board.status == HomeBoardStatus.loading) { return Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(SkinHelper.slotBorderColor))); } else if (board.status == HomeBoardStatus.done) { return const ShiningStars(size: 80); } else { return Container(); } } } // 自定义画笔实现(实际绘制逻辑在这里) class CanvasPainter extends CustomPainter { final HomeBoard board; final int level; //当前关卡序号 final int collectionIndex; // 当前合集序号 final Animation flipAnimation; // 0.0 -> 1.0 final Animation unlockAnimation; final HomeBoardStatus? forceStatus; // !!! 新增字段 final Animation dealingAnimation; // !!! 改造点 6: 发牌动画 final int dealingPieceDuration; // !!! 改造点 7: 单个卡片动画时长 final int dealingPieceInterval; // !!! 改造点 8: 单个卡片动画间隔 CanvasPainter({ required this.board, required this.level, required this.collectionIndex, required this.flipAnimation, required this.unlockAnimation, this.forceStatus, required this.dealingAnimation, required this.dealingPieceDuration, required this.dealingPieceInterval, }) : super(repaint: Listenable.merge([board.boardNotifier, flipAnimation, unlockAnimation, dealingAnimation])); // 触发重绘; @override void paint(Canvas canvas, Size size) { // !!! 改造点 1: 优先使用强制状态,否则使用 Board 状态 final statusToPaint = forceStatus ?? board.status; if (statusToPaint == HomeBoardStatus.playing) { _paintPlaying(canvas, size); } else if (statusToPaint == HomeBoardStatus.done) { // 动画结束前,原 CustomPaint 处于 done 状态 _paintSuccess(canvas, size); } else if (statusToPaint == HomeBoardStatus.unlocking) { // 仅在 Overlay 中调用,执行动画 _paintUnlocking(canvas, size); } else if (statusToPaint == HomeBoardStatus.dealing) { _paintDealing(canvas, size); } } // !!! 改造点 11: 实现 _paintDealing 方法 void _paintDealing(Canvas canvas, Size size) { _log.info('_paintDealing'); final totalPieces = board.rows * board.cols; final totalAnimationDuration = dealingPieceInterval * (totalPieces - 1) + dealingPieceDuration; // 起始位置 (右下角宫格的中心) final startX = board.cols - 1; final startY = board.rows - 1; final startPieceCenterX = startX * board.pieceLogicalWidth + board.pieceLogicalWidth / 2; final startPieceCenterY = startY * board.pieceLogicalHeight + board.pieceLogicalHeight / 2; for (var i = 0; i < board.rows; i++) { for (var j = 0; j < board.cols; j++) { final pieceIndex = i * board.cols + j; // 卡片索引 (0 到 24) // 计算该卡片的动画起始时间和结束时间 (相对于 _dealingController 的总 duration) final animationStartTime = pieceIndex * dealingPieceInterval; // 当前卡片开始动画的毫秒数 final animationEndTime = animationStartTime + dealingPieceDuration; // 当前卡片结束动画的毫秒数 // 计算当前全局动画进度 (0.0 - 1.0) 对应的毫秒数 final currentGlobalTime = dealingAnimation.value * totalAnimationDuration; // 判断当前卡片是否应该开始动画 if (currentGlobalTime < animationStartTime) { // 还没轮到这个卡片,保持在起始位置 _drawDealingPiece( canvas, size, i, j, startPieceCenterX - (j * board.pieceLogicalWidth + board.pieceLogicalWidth / 2), // 补偿目标位置的x startPieceCenterY - (i * board.pieceLogicalHeight + board.pieceLogicalHeight / 2), // 补偿目标位置的y 0.0, // 进度为0,即起始位置 ); } else if (currentGlobalTime >= animationEndTime) { // 动画已结束,停留在目标位置 _drawDealingPiece( canvas, size, i, j, 0.0, // 目标位置的相对偏移量为0 0.0, 1.0, // 进度为1,即目标位置 ); } else { // 正在动画中,计算该卡片的局部动画进度 final localProgress = (currentGlobalTime - animationStartTime) / dealingPieceDuration; _drawDealingPiece( canvas, size, i, j, startPieceCenterX - (j * board.pieceLogicalWidth + board.pieceLogicalWidth / 2), // 补偿目标位置的x startPieceCenterY - (i * board.pieceLogicalHeight + board.pieceLogicalHeight / 2), // 补偿目标位置的y localProgress, ); } } } } // 辅助方法:绘制发牌动画中的单个卡片 void _drawDealingPiece( Canvas canvas, Size size, int row, int col, double startOffsetX, double startOffsetY, // 起始位置相对于目标位置的偏移量 double progress, // 0.0 -> 1.0 ) { final curIndex = collectionIndex * board.count + row * board.cols + col; final w = board.pieceLogicalWidth; final h = board.pieceLogicalHeight; final targetLeft = col * w; final targetTop = row * h; // 计算当前动画帧的偏移量 final currentOffsetX = ui.lerpDouble(startOffsetX, 0.0, progress)!; final currentOffsetY = ui.lerpDouble(startOffsetY, 0.0, progress)!; canvas.save(); // 先平移到卡片最终位置的左上角,再应用动画偏移 canvas.translate(targetLeft + currentOffsetX, targetTop + currentOffsetY); // 绘制卡片背面 final cardSourceRect = Rect.fromLTWH(0, 0, board.cardImage.width.toDouble(), board.cardImage.height.toDouble()); final rect = Rect.fromLTWH(0, 0, w, h); // 绘制在当前变换后的 (0,0,w,h) canvas.drawImageRect(board.cardImage, cardSourceRect, rect, Paint()..isAntiAlias = true); // 绘制关卡数字 (与 _drawPiece 中的逻辑类似) final textStyle = TextStyle( color: Colors.white, fontSize: h * 0.25, fontWeight: FontWeight.bold, shadows: [Shadow(offset: const Offset(1, 1), blurRadius: 2, color: Colors.black38)], ); final textSpan = TextSpan(text: (curIndex + 1).toString(), style: textStyle); final textPainter = TextPainter(text: textSpan, textDirection: TextDirection.ltr, textAlign: TextAlign.center); textPainter.layout(minWidth: 0, maxWidth: w); final textOffset = Offset((w - textPainter.width) / 2, (h - textPainter.height) / 2); textPainter.paint(canvas, textOffset); canvas.restore(); // 绘制边框(为了避免裁剪,在 restore 后绘制) final cornerRadius = 4.0; final outerRRect = RRect.fromRectAndRadius( Rect.fromLTWH(targetLeft + currentOffsetX, targetTop + currentOffsetY, w, h).deflate(0.5), Radius.circular(cornerRadius), ); final innerRRect = RRect.fromRectAndRadius( Rect.fromLTWH(targetLeft + currentOffsetX, targetTop + currentOffsetY, w, h).deflate(1.5), Radius.circular(cornerRadius), ); canvas.drawRRect(outerRRect, outerBorderPaint); canvas.drawRRect(innerRRect, innerBorderPaint); } void _paintUnlocking(Canvas canvas, Size size) { _log.info('_paintUnlocking'); // 1. 获取动画进度 (0.0 -> 1.0) final progress = unlockAnimation.value; // 3. 计算当前的位移和缩放 // 缩放:从 1.0 缩小到 targetScale final startScale = 1.0; final endScale = board.unlockTargetScale; final currentScale = ui.lerpDouble(startScale, endScale, progress)!; // 位移:从 (0, 0) 平移到 targetOffset final startOffset = Offset.zero; final endOffset = board.unlockTargetOffset; final currentOffset = Offset(ui.lerpDouble(startOffset.dx, endOffset.dx, progress)!, ui.lerpDouble(startOffset.dy, endOffset.dy, progress)!); // 4. 应用 Canvas 变换 canvas.save(); // 缩放:以 Canvas 中心为缩放原点进行缩放 final centerX = size.width / 2; final centerY = size.height / 2; // 4.1. 移动到 Canvas 中心点 (将原点移到 CustomPaint 的中心) canvas.translate(centerX, centerY); // 4.2. 应用位移 (这是中心点相对 CustomPaint 中心点的移动) canvas.translate(currentOffset.dx, currentOffset.dy); // 4.3. 应用缩放 (以当前中心点为原点) canvas.scale(currentScale); // 4.4. 移回 Canvas 原点 (回到 CustomPaint 的左上角,已应用了 位移 + 缩放) canvas.translate(-centerX, -centerY); // 5. 绘制完整的合集图片 (与 _paintSuccess 逻辑相同) // 简单点, 动画就不画边框圆角这些了 // final cornerRadius = 4.0; final rect = Rect.fromLTWH(0, 0, size.width, size.height); // final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius)); // final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius)); // final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius)); final sourceRect = Rect.fromLTWH(0, 0, board.image!.width.toDouble(), board.image!.height.toDouble()); // 裁剪并绘制图片 // canvas.clipRRect(rrect); // 动画就不用clipRRect了 canvas.drawImageRect(board.image!, sourceRect, rect, Paint()..isAntiAlias = true); // 绘制边框 // canvas.drawRRect(outerRRect, outerBorderPaint); // canvas.drawRRect(innerRRect, innerBorderPaint); canvas.restore(); } _paintSuccess(Canvas canvas, Size size) { _log.info('_paintSuccess'); final cornerRadius = 4.0; final rect = Rect.fromLTWH(0, 0, size.width, size.height); final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius)); final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius)); final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius)); final sourceRect = Rect.fromLTWH(0, 0, board.image!.width.toDouble(), board.image!.height.toDouble()); canvas.save(); canvas.clipRRect(rrect); canvas.drawImageRect(board.image!, sourceRect, rect, Paint()..isAntiAlias = true); canvas.restore(); // 绘制边框 canvas.drawRRect(outerRRect, outerBorderPaint); canvas.drawRRect(innerRRect, innerBorderPaint); } _paintPlaying(Canvas canvas, Size size) { _log.info('_paintPlaying'); for (var i = 0; i < board.rows; i++) { for (var j = 0; j < board.cols; j++) { // 玩过的关卡翻正面显示, 否则显示卡片背面 final int curIndex = i * board.rows + j; bool flipped = level > collectionIndex * board.count + curIndex; _drawPiece(canvas, size, i, j, flipped); } } } final Paint outerBorderPaint = Paint() ..color = SkinHelper.outLineBorderColor ..style = PaintingStyle.stroke ..strokeWidth = 1.0 ..isAntiAlias = true; // 边框画笔 final Paint innerBorderPaint = Paint() ..color = SkinHelper.innerLineBorderColor ..style = PaintingStyle.stroke ..strokeWidth = 1.0 ..isAntiAlias = true; void _drawPiece(Canvas canvas, Size size, int row, int col, bool flipped) { final cornerRadius = 4.0; final w = size.width / board.cols; final h = size.height / board.rows; final pieceWidth = board.image!.width / board.cols; final pieceHeight = board.image!.height / board.rows; final left = col * w; final top = row * h; final right = left + w; final bottom = top + h; // 为了避免描边溢出到相邻槽位,将矩形向内收缩 halfStroke final rect = Rect.fromLTRB(left, top, right, bottom); final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius)); final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius)); final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius)); final cardSourceRect = Rect.fromLTWH(0, 0, board.cardImage.width.toDouble(), board.cardImage.height.toDouble()); final imageSourceRect = Rect.fromLTWH(pieceWidth * col, pieceHeight * row, pieceWidth, pieceHeight); // 0-based index final curIndex = collectionIndex * board.count + row * board.rows + col; // 1. 计算当前的翻转状态 double flipProgress = 0.0; if (flipAnimation.isAnimating && curIndex == level - 1) { // for test,只为方便查看动画效果,真正的代码是上面注释掉的 // if (flipAnimation.isAnimating && curIndex == 24) { flipProgress = flipAnimation.value; // 0.0 -> 1.0 flipped = (flipProgress > 0.5); } // _log.info('level=$level, row=$row, col=$col, flippingIndex=$flippingIndex, flipProgress=$flipProgress, currentPieceFlipped=$currentPieceFlipped'); canvas.save(); // 2. 居中变换原点到拼图块中心 final centerX = left + w / 2; final centerY = top + h / 2; canvas.translate(centerX, centerY); // 3. 应用 3D 旋转 (围绕 Y 轴) if (flipProgress > 0.0) { // 旋转角度从 0 到 pi (180度) double angle = flipProgress * pi; // 引入透视投影(z轴缩放),让翻转效果更立体 const double perspective = 0.0015; // 3D 变换矩阵 Matrix4 transform; if (flipProgress <= 0.5) { transform = Matrix4.identity() ..setEntry(3, 2, perspective) // 3D 效果 ..rotateY(angle); } else { transform = Matrix4.identity() ..setEntry(3, 2, perspective) // 3D 效果 ..rotateY(angle) ..scale(-1.0, 1.0, 1.0); // 3. X轴缩放-1:抵消旋转带来的左右镜像 } canvas.transform(transform.storage); } // 4. 移回原点 canvas.translate(-centerX, -centerY); // ... 现有裁剪逻辑 ... canvas.clipRRect(rrect); if (flipped) { // 绘制正面 canvas.drawImageRect(board.image!, imageSourceRect, rect, Paint()..isAntiAlias = true); } else { // 绘制背面 // 必须反转图片源矩形,以修正翻转180度后图像的镜像问题 final sourceRect = flipProgress > 0.5 ? imageSourceRect // 翻转后使用正面图像 : cardSourceRect; // 翻转前使用背面卡片 final targetImage = flipProgress > 0.5 ? board.image! : board.cardImage; canvas.drawImageRect(targetImage, sourceRect, rect, Paint()..isAntiAlias = true); if (flipProgress <= 0.5) { // todo... 绘制关卡数字, 在卡片中间位置把curIndex绘制上去, 颜色白色 // 1. 配置文字样式:白色、加粗、动态字体大小(适配卡片尺寸) final textStyle = TextStyle( color: Colors.white, fontSize: h * 0.25, // 字体大小为卡片高度的40%,适配不同尺寸 fontWeight: FontWeight.bold, shadows: [ // 增加黑色阴影,让白色文字在卡片背景上更清晰(可选但推荐) Shadow(offset: const Offset(1, 1), blurRadius: 2, color: Colors.black38), ], ); // 2. 初始化文字绘制器 final textSpan = TextSpan(text: (curIndex + 1).toString(), style: textStyle); final textPainter = TextPainter( text: textSpan, textDirection: TextDirection.ltr, textAlign: TextAlign.center, // 文字水平居中 ); // 3. 计算文字尺寸(必须调用layout()) textPainter.layout( minWidth: 0, maxWidth: w, // 文字最大宽度不超过卡片宽度 ); // 4. 计算文字居中偏移量 final textOffset = Offset( left + (w - textPainter.width) / 2, // 水平居中 top + (h - textPainter.height) / 2, // 垂直居中 ); // 5. 绘制文字 textPainter.paint(canvas, textOffset); } } canvas.restore(); // 恢复 Canvas 状态 (移除裁剪和 transform) // --- 绘制边框 --- // 为了确保边框线宽向外延伸的部分不会被裁剪,我们需要在裁剪区域被移除后重新绘制。 canvas.save(); canvas.drawRRect(outerRRect, outerBorderPaint); canvas.drawRRect(innerRRect, innerBorderPaint); canvas.restore(); } @override bool shouldRepaint(covariant CanvasPainter oldDelegate) { return oldDelegate.level != level || oldDelegate.flipAnimation != flipAnimation || oldDelegate.unlockAnimation != unlockAnimation || oldDelegate.dealingAnimation != dealingAnimation || oldDelegate.board != board; } }