|
|
@@ -1,16 +1,28 @@
|
|
|
+import 'dart:async';
|
|
|
+import 'dart:io';
|
|
|
import 'dart:math';
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
import 'package:flutter/services.dart';
|
|
|
+import 'package:fluttertoast/fluttertoast.dart';
|
|
|
+import 'package:image_puzzle/audio/audio_controller.dart';
|
|
|
import 'package:image_puzzle/config/device.dart';
|
|
|
+import 'package:image_puzzle/models/data.dart';
|
|
|
+import 'package:image_puzzle/models/download.dart';
|
|
|
+import 'package:image_puzzle/models/items.dart';
|
|
|
import 'package:image_puzzle/play/board.dart';
|
|
|
import 'package:image_puzzle/play/board_painter.dart';
|
|
|
+import 'package:image_puzzle/play/confetti_layer.dart';
|
|
|
import 'package:image_puzzle/play/piece.dart';
|
|
|
+import 'package:image_puzzle/settings/settings_dialog.dart';
|
|
|
+import 'package:image_puzzle/skin/skin.dart';
|
|
|
+import 'package:image_puzzle/utils/mybutton.dart';
|
|
|
import 'package:logging/logging.dart';
|
|
|
import 'package:provider/provider.dart';
|
|
|
import 'dart:ui' as ui;
|
|
|
import 'package:vector_math/vector_math.dart' as vmath;
|
|
|
+import 'package:vibration/vibration.dart';
|
|
|
|
|
|
final Logger _log = Logger('board_play.dart');
|
|
|
|
|
|
@@ -27,21 +39,45 @@ enum Action {
|
|
|
}
|
|
|
|
|
|
class BoardPlay extends StatefulWidget {
|
|
|
- final int cols;
|
|
|
- final int rows;
|
|
|
+ final ListItem item;
|
|
|
|
|
|
- const BoardPlay({super.key, required this.cols, required this.rows});
|
|
|
+ const BoardPlay({super.key, required this.item});
|
|
|
|
|
|
@override
|
|
|
State<StatefulWidget> createState() {
|
|
|
return _BoardPlayState();
|
|
|
}
|
|
|
+
|
|
|
+ static PageRouteBuilder buildRoute(ListItem item, {difficult = 1, count = 6}) {
|
|
|
+ return PageRouteBuilder(
|
|
|
+ pageBuilder: (context, animation, secondaryAnimation) {
|
|
|
+ return BoardPlay(item: item);
|
|
|
+ },
|
|
|
+ transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
|
|
+ return FadeTransition(opacity: animation, child: child);
|
|
|
+ // return SlideTransition(
|
|
|
+ // position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(animation),
|
|
|
+ // child: child,
|
|
|
+ // );
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
final GlobalKey boardKey = GlobalKey();
|
|
|
Board? board;
|
|
|
bool _isLoading = true;
|
|
|
+ int progress = 0;
|
|
|
+ bool isDownloadSlow = false;
|
|
|
+ late Timer timer;
|
|
|
+
|
|
|
+ late ItemLoader itemLoader;
|
|
|
+
|
|
|
+ late AudioController audio;
|
|
|
+ late Data data;
|
|
|
+
|
|
|
+ late ConfettiLayer confettiLayer;
|
|
|
|
|
|
Piece? _draggingPiece;
|
|
|
|
|
|
@@ -64,14 +100,88 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
// 发牌动画相关
|
|
|
late Animation<double> _dealingAnimation;
|
|
|
// 发牌动画参数
|
|
|
- final List<double> _pieceStartTimes = []; // 每个卡片的启动时间(单位:ms)
|
|
|
- final List<double> _pieceDurations = []; // 每个卡片的移动持续时间(单位:ms)
|
|
|
- double _totalDealingDuration = 0; // 发牌动画总时长(ms)
|
|
|
+ // 发牌间隔(ms)
|
|
|
+ int get _dealingPieceInterval {
|
|
|
+ if (board!.rows <= 3) return 120;
|
|
|
+ if (board!.rows == 4) return 100;
|
|
|
+ if (board!.rows == 5) return 80;
|
|
|
+ if (board!.rows == 6) return 60;
|
|
|
+ return 50;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 每个卡片移动时间(ms)
|
|
|
+ int get _dealingPieceDuration {
|
|
|
+ if (board!.rows <= 3) return 500;
|
|
|
+ if (board!.rows == 4) return 400;
|
|
|
+ if (board!.rows == 5) return 300;
|
|
|
+ if (board!.rows == 6) return 200;
|
|
|
+ return 100;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 发牌动画总时长
|
|
|
+ int get _totalDealingDuration => (board!.pieces.length - 1) * _dealingPieceInterval + _dealingPieceDuration; // 发牌动画总时长(ms)
|
|
|
+
|
|
|
+ Timer? _dealingPeriodicTimer;
|
|
|
+ int _dealingCount = 0; // 计数:记录执行次数
|
|
|
+
|
|
|
+ // 成功动画控制器
|
|
|
+ late AnimationController _successAnimationController;
|
|
|
+ late Animation<double> _offsetAnimation; // 用于控制核心绘制区上移
|
|
|
+ late Animation<double> _bottomSlideAnimation; // 用于控制next按钮从屏幕下方移动上来
|
|
|
+ late Animation<double> _topSlideAnimation; // 用于控制通关banner从屏幕上方移动上来
|
|
|
+
|
|
|
+ // Hard Mode Banner 动画控制器
|
|
|
+ late AnimationController _hardModeBannerController;
|
|
|
+ // 缩放动画
|
|
|
+ late Animation<double> _bannerScaleAnimation;
|
|
|
+ // 透明度动画
|
|
|
+ late Animation<double> _bannerFadeAnimation;
|
|
|
+ // 是否显示 Hard Mode Banner 的标志
|
|
|
+ bool _showHardModeBanner = false;
|
|
|
|
|
|
@override
|
|
|
initState() {
|
|
|
super.initState();
|
|
|
|
|
|
+ itemLoader = ItemLoader.load(widget.item);
|
|
|
+ _onProgressUpdate();
|
|
|
+ itemLoader.progress.addListener(_onProgressUpdate);
|
|
|
+ timer = Timer(const Duration(seconds: 5), () {
|
|
|
+ if (mounted && progress < 50) {
|
|
|
+ if (progress <= 1) {
|
|
|
+ //啥都没下载到, 直接弹toast然后退出
|
|
|
+ Fluttertoast.showToast(
|
|
|
+ msg: "网络状况不佳",
|
|
|
+ toastLength: Toast.LENGTH_SHORT,
|
|
|
+ gravity: ToastGravity.CENTER,
|
|
|
+ timeInSecForIosWeb: 1,
|
|
|
+ backgroundColor: SkinHelper.slotBorderColor,
|
|
|
+ textColor: Colors.white,
|
|
|
+ fontSize: 16.0,
|
|
|
+ );
|
|
|
+ Navigator.pop(context);
|
|
|
+ } else {
|
|
|
+ // 有下载只是慢
|
|
|
+ setState(() {
|
|
|
+ isDownloadSlow = true;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ Device device = context.read<Device>();
|
|
|
+
|
|
|
+ audio = context.read<AudioController>();
|
|
|
+ data = context.read<Data>();
|
|
|
+
|
|
|
+ confettiLayer = ConfettiLayer(this);
|
|
|
+
|
|
|
+ Future.delayed(Duration.zero, () {
|
|
|
+ if (mounted) {
|
|
|
+ confettiLayer.setup(context);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
// 初始化移动动画,在dragging结束松手后的swap或evert操作都需要用到移动
|
|
|
_moveAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
|
|
|
_moveAnimationController.addListener(_moveAnimationListener);
|
|
|
@@ -109,31 +219,78 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
flipAnimationController.addListener(_flipAnimationListener);
|
|
|
flipAnimationController.addStatusListener(_flipAnimationStatusListener);
|
|
|
|
|
|
+ // 初始化成功动画
|
|
|
+ _successAnimationController = AnimationController(vsync: this, duration: Duration(milliseconds: 500));
|
|
|
+ final deltaY = (device.targetRect.top - device.appBarHeight) / 2;
|
|
|
+ _offsetAnimation = Tween<double>(begin: 0.0, end: -deltaY).animate(_successAnimationController);
|
|
|
+ _bottomSlideAnimation =
|
|
|
+ Tween<double>(
|
|
|
+ begin: -500, // 初始在屏幕外
|
|
|
+ end: device.screenSize.height - device.targetRect.bottom + deltaY - 60,
|
|
|
+ ).animate(
|
|
|
+ CurvedAnimation(
|
|
|
+ parent: _successAnimationController,
|
|
|
+ curve: Curves.easeOut, // 缓出曲线,滑入更自然
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ _topSlideAnimation =
|
|
|
+ Tween<double>(
|
|
|
+ begin: -200, // 初始在屏幕外
|
|
|
+ end: device.targetRect.top - deltaY - 70,
|
|
|
+ ).animate(
|
|
|
+ CurvedAnimation(
|
|
|
+ parent: _successAnimationController,
|
|
|
+ curve: Curves.easeOut, // 缓出曲线,滑入更自然
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ _successAnimationController.addListener(_successAnimationListener);
|
|
|
+ _successAnimationController.addStatusListener(_successAnimationStatusListener);
|
|
|
+
|
|
|
+ // 初始化 Hard Mode Banner 动画
|
|
|
+ _hardModeBannerController = AnimationController(vsync: this, duration: const Duration(milliseconds: 1500));
|
|
|
+ // 缩放:0.0 -> 1.0 (前 40% 时间快速放大)
|
|
|
+ _bannerScaleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
|
|
+ CurvedAnimation(
|
|
|
+ parent: _hardModeBannerController,
|
|
|
+ curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ // 透明度:1.0 -> 0.0 (后 70% 时间逐渐淡出)
|
|
|
+ _bannerFadeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
|
|
+ CurvedAnimation(
|
|
|
+ parent: _hardModeBannerController,
|
|
|
+ curve: const Interval(0.3, 1.0, curve: Curves.easeIn),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ // 监听器用于触发重绘
|
|
|
+ _hardModeBannerController.addListener(() {
|
|
|
+ // if (mounted) setState(() {}); // 效率较低,改用AnimatedBuilder来实现局部重绘
|
|
|
+ });
|
|
|
+ // 动画完成时,设置标志为 false,完全隐藏
|
|
|
+ _hardModeBannerController.addStatusListener((status) {
|
|
|
+ if (status == AnimationStatus.completed) {
|
|
|
+ if (mounted) setState(() => _showHardModeBanner = false);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
_init();
|
|
|
}
|
|
|
|
|
|
- // 初始化发牌时间点
|
|
|
- void _initDealTimes() {
|
|
|
- _pieceStartTimes.clear();
|
|
|
- _pieceDurations.clear();
|
|
|
-
|
|
|
- // 3. 计算动画参数(速度恒定)
|
|
|
- const double interval = 120; // 发牌间隔(ms)
|
|
|
- const double duration = 500; // 每个卡片移动时间(ms)
|
|
|
-
|
|
|
- // 为每个卡片计算启动时间和持续时间
|
|
|
- for (int i = 0; i < board!.pieces.length; i++) {
|
|
|
- final startTime = i * interval;
|
|
|
-
|
|
|
- _pieceStartTimes.add(startTime);
|
|
|
- _pieceDurations.add(duration);
|
|
|
- }
|
|
|
+ _onProgressUpdate() {
|
|
|
+ // progress = (downloadItem.progress.value * 100).ceil();
|
|
|
+ progress = (itemLoader.progress.value * 100).ceil();
|
|
|
+ _log.info('onProgressUpdate: progress=$progress');
|
|
|
+ setState(() {});
|
|
|
+ }
|
|
|
|
|
|
- // 总动画时长 = 最后一张卡片的启动时间 + 其持续时间
|
|
|
- _totalDealingDuration = _pieceStartTimes.last + _pieceDurations.last;
|
|
|
+ void _successAnimationListener() {
|
|
|
+ final delta = _offsetAnimation.value;
|
|
|
+ board!.finalRect = board!.targetRect.translate(0, delta);
|
|
|
+ board!.invalidate();
|
|
|
+ }
|
|
|
|
|
|
- // 更新动画控制器时长
|
|
|
- dealingAnimationController.duration = Duration(milliseconds: _totalDealingDuration.round());
|
|
|
+ void _successAnimationStatusListener(AnimationStatus status) {
|
|
|
+ if (status == AnimationStatus.completed) {}
|
|
|
}
|
|
|
|
|
|
void _dealingAnimationListener() {
|
|
|
@@ -145,8 +302,8 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
// 逐个更新卡片位置(最后一张不需要动)
|
|
|
for (int i = 0; i < board!.pieces.length - 1; i++) {
|
|
|
final piece = board!.pieces[i];
|
|
|
- final startTime = _pieceStartTimes[i];
|
|
|
- final duration = _pieceDurations[i];
|
|
|
+ final startTime = i * _dealingPieceInterval;
|
|
|
+ final duration = _dealingPieceDuration;
|
|
|
|
|
|
// 尚未到启动时间:保持在起点
|
|
|
if (currentTime < startTime) {
|
|
|
@@ -174,6 +331,7 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
|
|
|
board!.shuffle(ShuffleStep.flipping);
|
|
|
flipAnimationController.forward(from: 0.0);
|
|
|
+ audio.playSfx(SfxType.flip);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -213,6 +371,7 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
// 启动 Merge 动画
|
|
|
_mergeGroups = mergeGroups;
|
|
|
_mergeAnimationController.forward(from: 0.0);
|
|
|
+ audio.playSfx(SfxType.pop);
|
|
|
}
|
|
|
|
|
|
board!.start();
|
|
|
@@ -255,6 +414,7 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
// 启动 Merge 动画
|
|
|
_mergeGroups = mergeGroups;
|
|
|
_mergeAnimationController.forward(from: 0.0);
|
|
|
+ audio.playSfx(SfxType.pop);
|
|
|
|
|
|
// 如果执行了 merge 动画,将胜利条件检查推迟到 merge 动画完成时
|
|
|
moveItems = null;
|
|
|
@@ -262,11 +422,6 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 如果没有 merge 发生,或者 move action 是 revert,检查胜利条件
|
|
|
- if (board!.checkWinCondition()) {
|
|
|
- board!.success();
|
|
|
- }
|
|
|
-
|
|
|
moveItems = null;
|
|
|
}
|
|
|
}
|
|
|
@@ -318,9 +473,9 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
|
|
|
_mergeGroups = null;
|
|
|
|
|
|
- // 检查胜利条件 (现在所有 pieces 都在正确位置且 scale=1.0)
|
|
|
+ // 检查胜利条件
|
|
|
if (board!.checkWinCondition()) {
|
|
|
- board!.success();
|
|
|
+ _onSuccess();
|
|
|
}
|
|
|
|
|
|
board!.invalidate();
|
|
|
@@ -334,13 +489,40 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
// prepare动画结束,进入洗牌动画
|
|
|
void _prepareAnimationStatusListener(AnimationStatus status) {
|
|
|
if (status == AnimationStatus.completed) {
|
|
|
- _initDealTimes();
|
|
|
+ if (board != null && board!.hard == true) {
|
|
|
+ setState(() => _showHardModeBanner = true);
|
|
|
+ _hardModeBannerController.forward(from: 0.0);
|
|
|
+ }
|
|
|
+
|
|
|
board!.setAllPieceToBottomRight();
|
|
|
board!.shuffle(ShuffleStep.dealing);
|
|
|
+ dealingAnimationController.duration = Duration(milliseconds: _totalDealingDuration);
|
|
|
dealingAnimationController.forward(from: 0.0);
|
|
|
+ audio.playSfx(SfxType.card);
|
|
|
+ _dealingPeriodicTimer = Timer.periodic(Duration(milliseconds: 120), (timer) {
|
|
|
+ if (mounted) {
|
|
|
+ _dealingCount++;
|
|
|
+ if (_dealingCount >= (_totalDealingDuration / 120) - 2) {
|
|
|
+ timer.cancel();
|
|
|
+ } else {
|
|
|
+ audio.playSfx(SfxType.card);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ _onSuccess() {
|
|
|
+ _log.info('success! 游戏完成!');
|
|
|
+ data.workDone(widget.item);
|
|
|
+ board!.success();
|
|
|
+ audio.playSfx(SfxType.success);
|
|
|
+ confettiLayer.play();
|
|
|
+
|
|
|
+ _successAnimationController.forward(from: 0.0);
|
|
|
+ setState(() {});
|
|
|
+ }
|
|
|
+
|
|
|
_init() async {
|
|
|
Device device = context.read<Device>();
|
|
|
|
|
|
@@ -352,19 +534,23 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
final targetRect = device.targetRect;
|
|
|
final bestImageSize = device.bestImageSize;
|
|
|
|
|
|
+ final image = await itemLoader.getImageBySize(bestImageSize.width.round(), bestImageSize.height.round());
|
|
|
+ _log.info('imageSize: (${image.width},${image.height}), bestImageSize: ($bestImageSize)');
|
|
|
+
|
|
|
// 加载图片,后续改为从远程服务器加载, 目前demo从本地assets读取
|
|
|
- final ByteData data = await rootBundle.load('assets/images/test.jpeg');
|
|
|
- final ui.Codec codec = await ui.instantiateImageCodec(
|
|
|
- data.buffer.asUint8List(),
|
|
|
- targetWidth: bestImageSize.width.round(),
|
|
|
- targetHeight: bestImageSize.height.round(),
|
|
|
- );
|
|
|
- final ui.FrameInfo frameInfo = await codec.getNextFrame();
|
|
|
- final image = frameInfo.image;
|
|
|
+ // final ByteData data = await rootBundle.load('assets/images/test.jpeg');
|
|
|
+ // final ByteData data = await rootBundle.load(widget.item.image);
|
|
|
+ // final ui.Codec codec = await ui.instantiateImageCodec(
|
|
|
+ // data.buffer.asUint8List(),
|
|
|
+ // targetWidth: bestImageSize.width.round(),
|
|
|
+ // targetHeight: bestImageSize.height.round(),
|
|
|
+ // );
|
|
|
+ // final ui.FrameInfo frameInfo = await codec.getNextFrame();
|
|
|
+ // final image = frameInfo.image;
|
|
|
|
|
|
// 加载扑克背面图片,用于制作发牌动画
|
|
|
- final Size bestCardImageSize = Size(targetRect.width * dpr / widget.rows, targetRect.height * dpr / widget.cols);
|
|
|
- final ByteData cardData = await rootBundle.load('assets/images/backcard.png');
|
|
|
+ final Size bestCardImageSize = Size(targetRect.width * dpr / widget.item.rows, targetRect.height * dpr / widget.item.cols);
|
|
|
+ final ByteData cardData = await rootBundle.load(widget.item.hard ? 'assets/images/backcard_red.png' : 'assets/images/backcard_blue.png');
|
|
|
final ui.Codec cardCodec = await ui.instantiateImageCodec(
|
|
|
cardData.buffer.asUint8List(),
|
|
|
targetWidth: bestCardImageSize.width.round(),
|
|
|
@@ -373,11 +559,14 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
final ui.FrameInfo cardFrameInfo = await cardCodec.getNextFrame();
|
|
|
final cardImage = cardFrameInfo.image;
|
|
|
|
|
|
- board = Board(this, image, cardImage, widget.rows, widget.cols, targetRect, device);
|
|
|
+ board = Board(this, image, cardImage, widget.item.rows, widget.item.cols, widget.item.hard, targetRect, device);
|
|
|
board!.prepare();
|
|
|
- _prepareAnimationController.forward(from: 0.0);
|
|
|
|
|
|
+ // **修正:在调用 AnimationController 之前检查 `mounted` 状态**
|
|
|
if (!mounted) return;
|
|
|
+
|
|
|
+ _prepareAnimationController.forward(from: 0.0);
|
|
|
+
|
|
|
setState(() {
|
|
|
_isLoading = false;
|
|
|
});
|
|
|
@@ -391,6 +580,9 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
|
|
|
@override
|
|
|
dispose() {
|
|
|
+ timer.cancel();
|
|
|
+ itemLoader.progress.removeListener(_onProgressUpdate);
|
|
|
+
|
|
|
_moveAnimationController.removeListener(_moveAnimationListener);
|
|
|
_moveAnimationController.removeStatusListener(_moveAnimationStatusListener);
|
|
|
_moveAnimationController.dispose();
|
|
|
@@ -411,7 +603,17 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
flipAnimationController.removeStatusListener(_flipAnimationStatusListener);
|
|
|
flipAnimationController.dispose();
|
|
|
|
|
|
+ _successAnimationController.removeListener(_successAnimationListener);
|
|
|
+ _successAnimationController.removeStatusListener(_successAnimationStatusListener);
|
|
|
+
|
|
|
+ _hardModeBannerController.dispose();
|
|
|
+
|
|
|
+ _dealingPeriodicTimer?.cancel();
|
|
|
+
|
|
|
+ confettiLayer.dispose();
|
|
|
+
|
|
|
board?.dispose();
|
|
|
+
|
|
|
super.dispose();
|
|
|
}
|
|
|
|
|
|
@@ -423,7 +625,8 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
body: Stack(
|
|
|
children: <Widget>[
|
|
|
if (!_isLoading) Positioned.fill(child: _buildPuzzleCanvas(device.screenSize.width, device.screenSize.height)),
|
|
|
- Positioned(top: 0, left: 0, right: 0, child: appBar),
|
|
|
+ if (board == null || board!.status != BoardStatus.success) Positioned(top: 0, left: 0, right: 0, child: appBar),
|
|
|
+ // Positioned(top: 0, left: 0, right: 0, child: appBar),
|
|
|
Positioned(
|
|
|
bottom: 0,
|
|
|
left: 0,
|
|
|
@@ -435,45 +638,194 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
child: const Text('Banner 广告区域', style: TextStyle(fontSize: 12)),
|
|
|
),
|
|
|
),
|
|
|
+ successBanner,
|
|
|
+ nextButton,
|
|
|
if (_isLoading)
|
|
|
Positioned.fill(
|
|
|
child: Container(
|
|
|
- color: Colors.lightGreen, // 填充绿色背景(可根据需要调整色值,如 Colors.green.shade100 浅绿)
|
|
|
- child: const Center(
|
|
|
- child: CircularProgressIndicator(
|
|
|
- // 可选:调整进度条颜色,与绿色背景对比更明显
|
|
|
- valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
|
+ color: SkinHelper.wholeBgColor,
|
|
|
+ child: const Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(Colors.white))),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget get appBar => SafeArea(
|
|
|
+ child: Padding(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 30.0, vertical: 10.0),
|
|
|
+ child: Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
+ children: [
|
|
|
+ // 左侧占位(保持标题居中)
|
|
|
+ const SizedBox(width: 30),
|
|
|
+ // 中间标题
|
|
|
+ Text(
|
|
|
+ board != null && board!.status == BoardStatus.success ? '关卡通过!' : '关卡${data.currentLevel}',
|
|
|
+ style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600),
|
|
|
+ ),
|
|
|
+ // 右侧设置按钮(30x30 圆形、深绿色背景、白色图标)
|
|
|
+ SizedBox(
|
|
|
+ width: 30,
|
|
|
+ height: 30,
|
|
|
+ child: IconButton(
|
|
|
+ icon: const Icon(Icons.settings, color: Colors.white, size: 22),
|
|
|
+ iconSize: 22,
|
|
|
+ padding: EdgeInsets.zero, // 清除默认内边距,确保按钮尺寸准确
|
|
|
+ onPressed: () {
|
|
|
+ Navigator.push(context, SettingsDialog.buildRoute(showReturn: true, showRestart: true, item: widget.item));
|
|
|
+ },
|
|
|
+ style: ButtonStyle(
|
|
|
+ // 深绿色背景(与你之前的按钮风格一致,使用 Color(0xff26600c) 深绿色)
|
|
|
+ backgroundColor: WidgetStateProperty.all(SkinHelper.slotBorderColor),
|
|
|
+ // 圆形形状
|
|
|
+ shape: WidgetStateProperty.all(
|
|
|
+ RoundedRectangleBorder(
|
|
|
+ borderRadius: BorderRadius.circular(15), // 30x30 按钮对应 15 圆角
|
|
|
),
|
|
|
),
|
|
|
+ // 固定按钮尺寸(30x30)
|
|
|
+ minimumSize: WidgetStateProperty.all(const Size(30, 30)),
|
|
|
+ maximumSize: WidgetStateProperty.all(const Size(30, 30)),
|
|
|
),
|
|
|
),
|
|
|
+ ),
|
|
|
],
|
|
|
),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+
|
|
|
+ Widget _buildPuzzleCanvas(double width, double height) {
|
|
|
+ return RepaintBoundary(
|
|
|
+ child: CustomPaint(
|
|
|
+ painter: BoardPainter(board: board!, prepareAnimation: _prepareAnimationController),
|
|
|
+ size: Size(width, height),
|
|
|
+ child: GestureDetector(
|
|
|
+ key: boardKey,
|
|
|
+ onPanStart: _onPanStart,
|
|
|
+ onPanUpdate: _onPanUpdate,
|
|
|
+ onPanEnd: _onPanEnd,
|
|
|
+ child: // 根据游戏状态动态显示提示动画或透明容器
|
|
|
+ board != null && board!.hard && _showHardModeBanner
|
|
|
+ ? _hardModeBanner
|
|
|
+ : Container(color: Colors.transparent), // 非显示条件时,使用透明容器
|
|
|
+ ),
|
|
|
+ ),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- final appBar = SafeArea(
|
|
|
- child: Center(
|
|
|
- child: Padding(
|
|
|
- padding: const EdgeInsets.all(10.0),
|
|
|
- child: Text(
|
|
|
- '关卡1',
|
|
|
- style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600),
|
|
|
+ // 困难模式提示动画组件
|
|
|
+ Widget get _hardModeBanner => Center(
|
|
|
+ // 使用 AnimatedBuilder 包裹需要动画的组件
|
|
|
+ child: AnimatedBuilder(
|
|
|
+ animation: _hardModeBannerController, // 监听控制器
|
|
|
+ builder: (context, child) {
|
|
|
+ return FadeTransition(
|
|
|
+ opacity: _bannerFadeAnimation, // 使用控制器驱动的动画值
|
|
|
+ child: ScaleTransition(
|
|
|
+ scale: _bannerScaleAnimation, // 使用控制器驱动的动画值
|
|
|
+ child: child, // 不随动画重建的子组件
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ },
|
|
|
+ // child 是不依赖动画状态变化的组件,只会构建一次
|
|
|
+ child: Container(
|
|
|
+ width: double.infinity,
|
|
|
+ height: 60,
|
|
|
+ margin: const EdgeInsets.symmetric(horizontal: 10),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: Colors.red,
|
|
|
+ borderRadius: BorderRadius.circular(10),
|
|
|
+ border: Border.all(color: const Color.fromARGB(255, 247, 143, 135), width: 2),
|
|
|
+ boxShadow: [BoxShadow(color: Color.fromRGBO(0, 0, 0, 0.3), blurRadius: 5, offset: const Offset(0, 3))],
|
|
|
+ ),
|
|
|
+ child: const Center(
|
|
|
+ child: Text(
|
|
|
+ '困难模式',
|
|
|
+ style: TextStyle(color: Colors.white, fontSize: 30, fontWeight: FontWeight.bold),
|
|
|
+ ),
|
|
|
),
|
|
|
),
|
|
|
),
|
|
|
);
|
|
|
|
|
|
- Widget _buildPuzzleCanvas(double width, double height) {
|
|
|
- return CustomPaint(
|
|
|
- painter: BoardPainter(board: board!, prepareAnimation: _prepareAnimationController),
|
|
|
- size: Size(width, height),
|
|
|
- child: GestureDetector(
|
|
|
- key: boardKey,
|
|
|
- onPanStart: _onPanStart,
|
|
|
- onPanUpdate: _onPanUpdate,
|
|
|
- onPanEnd: _onPanEnd,
|
|
|
- child: Container(color: Colors.transparent),
|
|
|
+ Widget get nextButton {
|
|
|
+ Device device = context.read<Device>();
|
|
|
+ return AnimatedBuilder(
|
|
|
+ // 监听显式动画 _bottomSlideAnimation
|
|
|
+ animation: _bottomSlideAnimation,
|
|
|
+ builder: (context, child) {
|
|
|
+ return AnimatedPositioned(
|
|
|
+ duration: _successAnimationController.duration!,
|
|
|
+ // 从动画中获取实时 value,赋值给 bottom
|
|
|
+ bottom: _bottomSlideAnimation.value,
|
|
|
+ left: (device.screenSize.width - 200) / 2,
|
|
|
+ child: child!, // 固定子组件,优化性能
|
|
|
+ );
|
|
|
+ },
|
|
|
+ // 固定的按钮组件(仅构建一次,优化性能)
|
|
|
+ child: MyElevatedButton(
|
|
|
+ width: 200,
|
|
|
+ borderRadius: BorderRadius.circular(20),
|
|
|
+ gradient: LinearGradient(colors: [SkinHelper.coreBgColor, SkinHelper.slotBorderColor]),
|
|
|
+ onPressed: () {
|
|
|
+ audio.playSfx(SfxType.tap);
|
|
|
+ Navigator.pop(context, true);
|
|
|
+ },
|
|
|
+ child: const Text('Next', style: TextStyle(color: Colors.white, fontSize: 20)),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget get successBanner {
|
|
|
+ Device device = context.read<Device>();
|
|
|
+ // 计算banner宽高
|
|
|
+ final bannerWidth = device.screenSize.width - 60; // 左右各30间距
|
|
|
+ final bannerHeight = 60.0;
|
|
|
+
|
|
|
+ return AnimatedBuilder(
|
|
|
+ animation: _bottomSlideAnimation,
|
|
|
+ builder: (context, child) {
|
|
|
+ return AnimatedPositioned(
|
|
|
+ duration: _successAnimationController.duration!,
|
|
|
+ top: _topSlideAnimation.value, // 固定底部位置
|
|
|
+ left: 30, // 左间距30,与bannerWidth配合实现水平居中
|
|
|
+ child: child!,
|
|
|
+ );
|
|
|
+ },
|
|
|
+ // 核心:用Container固定尺寸,Stack填充Container,确保图片和文字尺寸对齐
|
|
|
+ child: SizedBox(
|
|
|
+ width: bannerWidth, // 容器宽=图片宽
|
|
|
+ height: bannerHeight, // 容器高=图片高
|
|
|
+ child: Stack(
|
|
|
+ children: [
|
|
|
+ // 1. 图片充满容器(与容器尺寸一致)
|
|
|
+ Image.asset(
|
|
|
+ 'assets/images/banner3.png',
|
|
|
+ width: double.infinity, // 图片宽=容器宽
|
|
|
+ height: double.infinity, // 图片高=容器高
|
|
|
+ fit: BoxFit.cover, // 图片填充容器(不拉伸,超出部分裁剪)
|
|
|
+ cacheWidth: (context.watch<Device>().devicePixelRatio * bannerWidth).toInt(),
|
|
|
+ cacheHeight: (context.watch<Device>().devicePixelRatio * bannerHeight).toInt(),
|
|
|
+ ),
|
|
|
+ const Center(
|
|
|
+ child: Padding(
|
|
|
+ padding: EdgeInsets.only(top: 16.0),
|
|
|
+ child: Text(
|
|
|
+ '关卡通过!',
|
|
|
+ style: TextStyle(
|
|
|
+ color: Colors.white,
|
|
|
+ fontSize: 22,
|
|
|
+ fontWeight: FontWeight.bold,
|
|
|
+ shadows: [Shadow(color: Colors.black54, offset: Offset(1, 1), blurRadius: 2)],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
@@ -485,6 +837,10 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
|
|
|
void _onPanStart(DragStartDetails details) {
|
|
|
_log.info('_onPanStart');
|
|
|
+ if (board!.status != BoardStatus.playing) {
|
|
|
+ _log.info('不是playing状态,不响应onPanStart');
|
|
|
+ return;
|
|
|
+ }
|
|
|
// 动画中断逻辑:如果动画正在进行,立即停止并强制所有 pieces 归位到最终位置
|
|
|
if (_moveAnimationController.isAnimating && moveItems != null) {
|
|
|
_log.info('移动动画中断,强制归位/交换');
|
|
|
@@ -522,6 +878,8 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
board!.invalidate();
|
|
|
}
|
|
|
|
|
|
+ // audio.playSfx(SfxType.tap);
|
|
|
+
|
|
|
// 停止所有正在运行的动画(如果尚未停止)
|
|
|
_moveAnimationController.stop();
|
|
|
_mergeAnimationController.stop();
|
|
|
@@ -544,10 +902,15 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
}
|
|
|
board!.invalidate();
|
|
|
}
|
|
|
+
|
|
|
+ if (Platform.isAndroid) {
|
|
|
+ Vibration.vibrate(duration: 60, amplitude: 50);
|
|
|
+ } else {
|
|
|
+ HapticFeedback.mediumImpact();
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
void _onPanUpdate(DragUpdateDetails details) {
|
|
|
- _log.info('_onPanUpdate');
|
|
|
if (_draggingPiece == null) return;
|
|
|
|
|
|
final Offset delta = details.delta;
|
|
|
@@ -665,26 +1028,31 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
|
|
|
|
|
|
// 4. 为所有涉及移动的碎片创建 MoveItem
|
|
|
|
|
|
- // a. 拖拽群组/碎片
|
|
|
- for (var p in draggingPieces) {
|
|
|
- // 动画起点:拖拽结束时的实际 Canvas 坐标 (p.transform)
|
|
|
+ // a. 被推开的碎片
|
|
|
+ for (var p in displacedPieces) {
|
|
|
+ // 动画起点:旧的逻辑网格坐标 (p.transform)
|
|
|
final startTransform = p.transform;
|
|
|
// 动画终点:新的逻辑网格坐标
|
|
|
final endTransform = board!.getTransformByCoordinate(p.curRow, p.curCol);
|
|
|
final animation = Matrix4Tween(begin: startTransform, end: endTransform).animate(_moveAnimationController);
|
|
|
|
|
|
items.add(MoveItem(piece: p, animation: animation, startTransform: startTransform, endTransform: endTransform, action: Action.swap));
|
|
|
+
|
|
|
+ board!.pieces.remove(p);
|
|
|
+ board!.pieces.add(p);
|
|
|
}
|
|
|
|
|
|
- // b. 被推开的碎片
|
|
|
- for (var p in displacedPieces) {
|
|
|
- // 动画起点:旧的逻辑网格坐标 (p.transform)
|
|
|
+ // b. 拖拽群组/碎片
|
|
|
+ for (var p in draggingPieces) {
|
|
|
+ // 动画起点:拖拽结束时的实际 Canvas 坐标 (p.transform)
|
|
|
final startTransform = p.transform;
|
|
|
// 动画终点:新的逻辑网格坐标
|
|
|
final endTransform = board!.getTransformByCoordinate(p.curRow, p.curCol);
|
|
|
final animation = Matrix4Tween(begin: startTransform, end: endTransform).animate(_moveAnimationController);
|
|
|
|
|
|
items.add(MoveItem(piece: p, animation: animation, startTransform: startTransform, endTransform: endTransform, action: Action.swap));
|
|
|
+ board!.pieces.remove(p);
|
|
|
+ board!.pieces.add(p);
|
|
|
}
|
|
|
|
|
|
// 5. 启动动画
|