import 'dart:async'; import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:image_puzzle/audio/audio_controller.dart'; import 'package:image_puzzle/config/device.dart'; import 'package:image_puzzle/homepage/home_board.dart'; import 'package:image_puzzle/models/cached_request.dart'; import 'package:image_puzzle/models/data.dart'; import 'package:image_puzzle/models/items.dart'; import 'package:image_puzzle/play/confetti_layer.dart'; import 'package:image_puzzle/skin/skin.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; // !!! 改造点 2: 接收 Collection Key VoidCallback? onCollectionDone; // 新增一个合集完成的回调,外部home_screen可能会关心,当合集解锁动画完成,home_screen的左上角合集icon需要放大再复原 // todo ... 可能需要传递外部home_screen 的左上角leading IconButton 空间的global key或者位置信息过来,方便执行unlock动画定位 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 AudioController 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 _isCollectionDone => data.currentLevel != 0 && data.currentCollectionIndex * 25 == data.currentLevel; bool get _isCollectionDone => true; // for test OverlayEntry? _overlayEntry; // 新增:用于管理全屏动画层 @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: 1000), // 动画时长 vsync: this, ); _unlockAnimation = Tween(begin: 0, end: 1).animate(CurvedAnimation(parent: _unlockController, curve: Curves.easeInBack)) ..addStatusListener((status) { if (status == AnimationStatus.completed) { // 动画结束后,通知外部(HomeScreen) widget.onCollectionDone?.call(); // 可选:将状态最终置回 playing,准备加载下一个合集 // setState(() { // _status = HomeBoardStatus.playing; // }); } }); } _onCollectionDataUpdate(data) async { _log.info('_onCollectionDataUpdate.... '); if (data != null) { collection = data as List; if (collection != null && collection!.isNotEmpty) { board.currentCollectionItem = currentCollectionItem; } setState(() {}); } } _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(); } _onBoardReady() { if (board.isReadyNotifier.value == true) { setState(() { board.status = HomeBoardStatus.playing; }); } } ListItem? get currentCollectionItem { if (collection != null && collection!.isNotEmpty) { return collection![data.currentCollectionIndex]; } return null; } @override void dispose() { board.isReadyNotifier.removeListener(_onBoardReady); confettiLayer.dispose(); _flipController.dispose(); _unlockController.dispose(); collectionSubscription?.cancel(); super.dispose(); } // 翻牌动画结束后,检查整个合集是否全部完成,如果全部完成,需要展示一系列的动画 void _checkCollectionDone() async { if (_isCollectionDone) { // 将状态置为done,canvas绘制一整张图,不再是单个卡片 setState(() { board.status = HomeBoardStatus.done; board.invalidate(); }); // 展示撒花动画 audio.playSfx(SfxType.success); confettiLayer.play(); await Future.delayed(Duration(milliseconds: 200)); // confetti动画结束 // todo... 开始位移+缩放动画,将整个合集图片 _startUnlockAnimation(); } } // !!! 改造点 5: 实现解锁动画启动方法 void _startUnlockAnimation() { // 检查 Key 是否关联到 RenderBox 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; final canvasCenter = canvasRenderBox.localToGlobal(canvasRenderBox.size.center(Offset.zero)); // 计算位移量 // dx: 目标中心X - 画布中心X // dy: 目标中心Y - 画布中心Y final Offset delta = targetPosition - canvasCenter; // 存储计算出的动画目标数据,供 CustomPainter 使用 board.setUnlockAnimationTarget( targetOffset: delta, // 目标缩放比例 // targetScale: targetRenderBox.size.width / widget.canvasWidth, targetScale: 0, ); // 启动动画 setState(() { board.status = HomeBoardStatus.unlocking; // 切换到 unlocking 状态 }); _unlockController.forward(from: 0.0); } // 对外暴露的触发动画方法 (供 HomeScreen 调用) void startFlipAnimation() { _flipController.forward(from: 0.0); audio.playSfx(SfxType.flip); } @override Widget build(BuildContext context) { return CustomPaint( size: Size(widget.canvasWidth, widget.canvasHeight), // 固定画布尺寸 painter: CanvasPainter(board: board, level: data.currentLevel, flipAnimation: _flipAnimation, unlockAnimation: _unlockAnimation), child: board.status == HomeBoardStatus.loading ? Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(SkinHelper.slotBorderColor))) : Container(), // 加载完成后不显示child ); } } // 自定义画笔实现(实际绘制逻辑在这里) class CanvasPainter extends CustomPainter { final HomeBoard board; final int level; final Animation flipAnimation; // 0.0 -> 1.0 final Animation unlockAnimation; CanvasPainter({required this.board, required this.level, required this.flipAnimation, required this.unlockAnimation}) : super(repaint: Listenable.merge([board.boardNotifier, flipAnimation, unlockAnimation])); // 触发重绘; @override void paint(Canvas canvas, Size size) { if (board.status == HomeBoardStatus.playing) { _paintPlaying(canvas, size); } else if (board.status == HomeBoardStatus.done) { _paintSuccess(canvas, size); } else if (board.status == HomeBoardStatus.unlocking) { // !!! 改造点 2: 处理 unlocking 状态 _paintUnlocking(canvas, size); } } void _paintUnlocking(Canvas canvas, Size size) { // 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 原点到新的位置 canvas.translate(currentOffset.dx, currentOffset.dy); // 缩放:以 Canvas 中心为缩放原点进行缩放 final centerX = size.width / 2; final centerY = size.height / 2; // 缩放操作 canvas.translate(centerX, centerY); canvas.scale(currentScale); 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 * rows + j; // bool flipped = level % (rows * cols) > curIndex; // for test: bool flipped = (i == 4 && j == 4) ? false : true; _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 int curCollectionIndex = (level / (board.rows * board.cols)).floor(); final curIndex = curCollectionIndex * board.rows * board.cols + 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, fontFamily: 'Roboto', 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.board != board; } }