| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839 |
- 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<HomeBoardPlay> createState() => HomeBoardPlayState();
- }
- class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMixin {
- late HomeBoard board;
- late JcAudioController audio;
- late Data data;
- List<ListItem>? collection;
- late CachedRequest collectionCachedRequest;
- late StreamSubscription? collectionSubscription;
- late ConfettiLayer confettiLayer;
- // 翻牌动画,完成一个关卡后翻开一个卡片
- late AnimationController _flipController;
- late Animation<double> _flipAnimation;
- // todo... 合集解锁动画,完成整个合集之后执行, 效果是整个图片缩小并位移到左上角的colleciton iconbutton, 形成合集“收纳”的效果
- late AnimationController _unlockController; // 解锁动画控制器
- late Animation<double> _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<double> _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<Device>();
- 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<JcAudioController>();
- data = context.read<Data>();
- 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<double>(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<double>(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<ListItem>;
- 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<void> 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<Color>(SkinHelper.slotBorderColor)))
- : Container(),
- // child: childWidget,
- );
- }
- Widget get childWidget {
- if (board.status == HomeBoardStatus.loading) {
- return Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(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<double> flipAnimation; // 0.0 -> 1.0
- final Animation<double> unlockAnimation;
- final HomeBoardStatus? forceStatus; // !!! 新增字段
- final Animation<double> 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;
- }
- }
|