guoziyun 6 hónapja
szülő
commit
a2baf40373
44 módosított fájl, 663 hozzáadás és 791 törlés
  1. 8 0
      android/app/build.gradle.kts
  2. BIN
      assets/audio/bgm/canon.mp3
  3. BIN
      assets/audio/bgm/loop.mp3
  4. BIN
      assets/audio/bgm/loop4.mp3
  5. BIN
      assets/audio/bgm/loop7.mp3
  6. BIN
      assets/audio/sfx/collect_coins.mp3
  7. BIN
      assets/audio/sfx/hint.mp3
  8. BIN
      assets/audio/sfx/hint_collected.mp3
  9. BIN
      assets/audio/sfx/silence.mp3
  10. BIN
      assets/audio/sfx/success.mp3
  11. BIN
      assets/audio/sfx/success2.mp3
  12. BIN
      assets/audio/sfx/success3.mp3
  13. BIN
      assets/audio/sfx/success4.mp3
  14. BIN
      assets/audio/sfx/success5.mp3
  15. BIN
      assets/audio/sfx/success6.mp3
  16. BIN
      assets/audio/sfx/win.mp3
  17. BIN
      assets/builtin/1.jpeg
  18. BIN
      assets/builtin/4.jpeg
  19. 85 0
      assets/builtin/6915869d4b99f02d1db82cf2.json
  20. BIN
      assets/builtin/7.jpeg
  21. BIN
      assets/builtin/8.jpeg
  22. BIN
      assets/images/banner.png
  23. BIN
      assets/images/banner2.png
  24. BIN
      assets/images/banner3.png
  25. BIN
      assets/images/banner4.png
  26. BIN
      assets/images/test.jpeg
  27. 7 7
      lib/ads/applovin_ads_controller.dart
  28. 208 209
      lib/audio/audio_controller.dart
  29. 2 2
      lib/audio/jc_audio_controller.dart
  30. 3 0
      lib/config/config.dart
  31. 91 57
      lib/homepage/home_screen.dart
  32. 16 22
      lib/main.dart
  33. 1 0
      lib/models/items.dart
  34. 6 0
      lib/persistence/persistence.dart
  35. 92 1
      lib/play/board.dart
  36. 138 403
      lib/play/board_play.dart
  37. 2 0
      lib/play/piece.dart
  38. 0 4
      linux/flutter/generated_plugin_registrant.cc
  39. 0 1
      linux/flutter/generated_plugins.cmake
  40. 0 2
      macos/Flutter/GeneratedPluginRegistrant.swift
  41. 2 74
      pubspec.lock
  42. 2 5
      pubspec.yaml
  43. 0 3
      windows/flutter/generated_plugin_registrant.cc
  44. 0 1
      windows/flutter/generated_plugins.cmake

+ 8 - 0
android/app/build.gradle.kts

@@ -56,6 +56,14 @@ android {
 
     buildTypes {
         release {
+            // ✅ 启用 R8 代码混淆和收缩(Kotlin DSL: 使用 isMinifyEnabled = true)
+            isMinifyEnabled = true
+            // ✅ 启用资源收缩(Kotlin DSL: 使用 isShrinkResources = true)
+            isShrinkResources = true
+
+            // ✅ 指定 ProGuard 规则文件(Kotlin DSL: 使用 proguardFiles(...) 函数)
+            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+            
             // TODO: Add your own signing config for the release build.
             // Signing with the debug keys for now, so `flutter run --release` works.
             // signingConfig = signingConfigs.getByName("debug")

BIN
assets/audio/bgm/canon.mp3


BIN
assets/audio/bgm/loop.mp3


BIN
assets/audio/bgm/loop4.mp3


BIN
assets/audio/bgm/loop7.mp3


BIN
assets/audio/sfx/collect_coins.mp3


BIN
assets/audio/sfx/hint.mp3


BIN
assets/audio/sfx/hint_collected.mp3


BIN
assets/audio/sfx/silence.mp3


BIN
assets/audio/sfx/success.mp3


BIN
assets/audio/sfx/success2.mp3


BIN
assets/audio/sfx/success3.mp3


BIN
assets/audio/sfx/success4.mp3


BIN
assets/audio/sfx/success5.mp3


BIN
assets/audio/sfx/success6.mp3


BIN
assets/audio/sfx/win.mp3


BIN
assets/builtin/1.jpeg


BIN
assets/builtin/4.jpeg


+ 85 - 0
assets/builtin/6915869d4b99f02d1db82cf2.json

@@ -0,0 +1,85 @@
+{
+  "pieces": [
+    {
+      "index": 6,
+      "rows": 3,
+      "cols": 3,
+      "row": 2,
+      "col": 0,
+      "curRow": 2,
+      "curCol": 2
+    },
+    {
+      "index": 5,
+      "rows": 3,
+      "cols": 3,
+      "row": 1,
+      "col": 2,
+      "curRow": 1,
+      "curCol": 1
+    },
+    {
+      "index": 7,
+      "rows": 3,
+      "cols": 3,
+      "row": 2,
+      "col": 1,
+      "curRow": 1,
+      "curCol": 0
+    },
+    {
+      "index": 1,
+      "rows": 3,
+      "cols": 3,
+      "row": 0,
+      "col": 1,
+      "curRow": 0,
+      "curCol": 1
+    },
+    {
+      "index": 0,
+      "rows": 3,
+      "cols": 3,
+      "row": 0,
+      "col": 0,
+      "curRow": 0,
+      "curCol": 0
+    },
+    {
+      "index": 2,
+      "rows": 3,
+      "cols": 3,
+      "row": 0,
+      "col": 2,
+      "curRow": 1,
+      "curCol": 2
+    },
+    {
+      "index": 8,
+      "rows": 3,
+      "cols": 3,
+      "row": 2,
+      "col": 2,
+      "curRow": 0,
+      "curCol": 2
+    },
+    {
+      "index": 4,
+      "rows": 3,
+      "cols": 3,
+      "row": 1,
+      "col": 1,
+      "curRow": 2,
+      "curCol": 1
+    },
+    {
+      "index": 3,
+      "rows": 3,
+      "cols": 3,
+      "row": 1,
+      "col": 0,
+      "curRow": 2,
+      "curCol": 0
+    }
+  ]
+}

BIN
assets/builtin/7.jpeg


BIN
assets/builtin/8.jpeg


BIN
assets/images/banner.png


BIN
assets/images/banner2.png


BIN
assets/images/banner3.png


BIN
assets/images/banner4.png


BIN
assets/images/test.jpeg


+ 7 - 7
lib/ads/applovin_ads_controller.dart

@@ -88,13 +88,13 @@ class ApplovinAdsController {
     extraParameters: const {'adaptive_banner': 'true'},
     listener: AdViewAdListener(
       onAdLoadedCallback: (ad) {
-        // 广告加载成功后, ad.size 包含实际的宽度和高度 (dp)
-        double? widthDp = ad.size?.width;
-        double? heightDp = ad.size?.height;
-        if (heightDp != null) {
-          context.read<Device>().bannerHeight = heightDp;
-        }
-        _log.info('banner广告 width = $widthDp, height = $heightDp');
+        // // 广告加载成功后, ad.size 包含实际的宽度和高度 (dp)
+        // double? widthDp = ad.size?.width;
+        // double? heightDp = ad.size?.height;
+        // if (heightDp != null) {
+        //   context.read<Device>().bannerHeight = heightDp;
+        // }
+        // _log.info('banner广告 width = $widthDp, height = $heightDp');
         _log.info(() => 'applovin banner Ad loaded: ${ad.hashCode}');
       },
       onAdLoadFailedCallback: (adUnitId, error) {

+ 208 - 209
lib/audio/audio_controller.dart

@@ -1,209 +1,208 @@
-// puzzleweave/audio/audio_controller.dart
-
-import 'package:flutter/widgets.dart';
-import 'package:audioplayers/audioplayers.dart';
-import 'package:puzzleweave/settings/settings_controller.dart';
-import 'package:logging/logging.dart';
-
-enum SfxType { drop, click, tap, kick, pop, appear, alert, star, success, flip, card, cardShort, panstart, panend, panend2 }
-
-/// 允许播放音乐和声音效果。
-class AudioControllerX {
-  static final _log = Logger('AudioController');
-
-  SettingsController? _settings;
-  ValueNotifier<AppLifecycleState>? _lifecycleNotifier;
-
-  // 1. BGM 播放器 (使用常规模式进行流式传输,专用于音乐)
-  final AudioPlayer _musicPlayer = AudioPlayer();
-
-  // 2. 音效音频池 (用于低延迟播放 SFX)
-  // Key: SfxType, Value: AudioPool 实例
-  final Map<SfxType, AudioPool> _sfxPools = {};
-
-  // 预加载的 BGM 文件路径
-  final String _bgmAsset = 'audio/bgm/canon.mp3';
-
-  // 所有音效文件映射
-  static const Map<SfxType, String> _sfxPaths = {
-    SfxType.drop: 'audio/sfx/button_click8.mp3',
-    SfxType.click: 'audio/sfx/click2.mp3',
-    SfxType.tap: 'audio/sfx/tap.mp3',
-    SfxType.kick: 'audio/sfx/kick.mp3',
-    SfxType.pop: 'audio/sfx/pop.mp3',
-    SfxType.appear: 'audio/sfx/appear.mp3',
-    SfxType.alert: 'audio/sfx/alert.mp3',
-    SfxType.star: 'audio/sfx/star.mp3',
-    SfxType.success: 'audio/sfx/success3.mp3',
-    SfxType.card: 'audio/sfx/card.mp3',
-    SfxType.cardShort: 'audio/sfx/card2.mp3',
-    SfxType.flip: 'audio/sfx/flip.mp3',
-    SfxType.panstart: 'audio/sfx/pan_start.mp3',
-    SfxType.panend: 'audio/sfx/pan_end.mp3',
-    SfxType.panend2: 'audio/sfx/pan_end2.mp3',
-  };
-
-  AudioControllerX() {
-    // 配置 BGM 播放器:设置为循环模式
-    _musicPlayer.setReleaseMode(ReleaseMode.loop);
-  }
-
-  /// 在应用启动时异步调用,用于预加载所有音乐和音效。
-  Future<void> initialize() async {
-    // 1. 预加载 BGM
-    await _musicPlayer.setSource(AssetSource(_bgmAsset));
-
-    // 2. 预加载所有 SFX 到 AudioPool 中 (实现真正的低延迟)
-    final futures = _sfxPaths.entries.map((entry) async {
-      final type = entry.key;
-      final path = entry.value;
-
-      // ⚠️ 修复:使用 AudioPool.createFromAssetSource 替换 fromAsset
-      final pool = await AudioPool.createFromAsset(
-        path: path, // 直接传入 String 路径
-        maxPlayers: type == SfxType.card ? 10 : 3,
-      );
-      _sfxPools[type] = pool;
-    }).toList();
-
-    await Future.wait(futures);
-    _log.info('All sounds and music preloaded successfully via AudioPool.');
-  }
-
-  void dispose() {
-    _lifecycleNotifier?.removeListener(_handleAppLifecycle);
-    stopMusic();
-
-    // 停止并清理所有的音频池,释放原生资源
-    for (final pool in _sfxPools.values) {
-      pool.dispose();
-    }
-    _sfxPools.clear();
-
-    _musicPlayer.dispose();
-  }
-
-  /// Enables the [AudioController] to listen to [AppLifecycleState] events...
-  void attachLifecycleNotifier(ValueNotifier<AppLifecycleState> lifecycleNotifier) {
-    _lifecycleNotifier?.removeListener(_handleAppLifecycle);
-    lifecycleNotifier.addListener(_handleAppLifecycle);
-    _lifecycleNotifier = lifecycleNotifier;
-  }
-
-  /// Enables the [AudioController] to track changes to settings...
-  void attachSettings(SettingsController settingsController) {
-    if (_settings == settingsController) return;
-
-    final oldSettings = _settings;
-    if (oldSettings != null) {
-      oldSettings.music.removeListener(_musicOnHandler);
-      oldSettings.sound.removeListener(_soundOnHandler);
-    }
-
-    _settings = settingsController;
-
-    settingsController.music.addListener(_musicOnHandler);
-    settingsController.sound.addListener(_soundOnHandler);
-  }
-
-  /// Plays a single sound effect, defined by [type].
-  /// 使用 AudioPool 实现极低延迟播放。
-  void playSfx(SfxType type, {Duration? duration}) async {
-    final soundsOn = _settings?.sound.value ?? false;
-    if (!soundsOn) {
-      _log.info(() => 'Ignoring playing sound ($type) because sounds are turned off.');
-      return;
-    }
-
-    final pool = _sfxPools[type];
-    if (pool == null) {
-      _log.severe('Missing audio pool for SFX type: $type');
-      return;
-    }
-
-    // 核心优化:从 AudioPool 快速启动播放。
-    await pool.start(volume: 1.0);
-
-    if (duration != null) {
-      _log.warning('Duration control is complex with AudioPool; generally SFX should be short.');
-    }
-  }
-
-  void _handleAppLifecycle() {
-    switch (_lifecycleNotifier!.value) {
-      case AppLifecycleState.paused:
-      case AppLifecycleState.detached:
-      case AppLifecycleState.hidden:
-      case AppLifecycleState.inactive:
-        pauseMusic();
-        break;
-      case AppLifecycleState.resumed:
-        startMusic();
-        break;
-    }
-  }
-
-  void _musicOnHandler() {
-    if (_settings!.music.value) {
-      startMusic();
-    } else {
-      stopMusic();
-    }
-  }
-
-  void _soundOnHandler() {
-    // 声音设置关闭时,不需要特殊处理 AudioPool 播放的短音效
-  }
-
-  void setMusicVolume(double volume) {
-    _musicPlayer.setVolume(volume);
-    _log.info('Music volume set to $volume');
-  }
-
-  void startMusic() async {
-    _log.info('starting music');
-    final musicOn = _settings?.music.value ?? false;
-    if (!musicOn) {
-      _log.info(() => 'Ignoring playing music because music are turned off.');
-      return;
-    }
-
-    // 假设我们希望 BGM 默认音量为 0.5
-    const double defaultBGMVolume = 0.1;
-
-    final state = _musicPlayer.state;
-    if (state == PlayerState.playing) return;
-
-    // 如果音乐已经停止或未播放,则重新设置源并播放
-    if (state == PlayerState.stopped || state == PlayerState.disposed) {
-      await _musicPlayer.setSource(AssetSource(_bgmAsset));
-      await _musicPlayer.setVolume(defaultBGMVolume);
-      await _musicPlayer.resume();
-    } else {
-      // 从暂停状态恢复
-      await _musicPlayer.resume();
-    }
-  }
-
-  void stopMusic() {
-    _log.info('Stopping music');
-    _musicPlayer.stop();
-  }
-
-  void pauseMusic() {
-    _log.info('pause music');
-    _musicPlayer.pause();
-  }
-
-  Future<void> resumeMusic() async {
-    _log.info('Resuming music');
-    final musicOn = _settings?.music.value ?? false;
-    if (musicOn) {
-      _musicPlayer.resume();
-    }
-  }
-
-  void _stopSound() {
-    // AudioPool 播放的短音效不需要手动停止
-  }
-}
+// // puzzleweave/audio/audio_controller.dart
+
+// import 'package:flutter/widgets.dart';
+// import 'package:audioplayers/audioplayers.dart';
+// import 'package:puzzleweave/settings/settings_controller.dart';
+// import 'package:logging/logging.dart';
+
+// enum SfxType { drop, click, tap, pop, appear, alert, star, success, flip, card, cardShort, panstart, panend, panend2 }
+
+// /// 允许播放音乐和声音效果。
+// class AudioControllerX {
+//   static final _log = Logger('AudioController');
+
+//   SettingsController? _settings;
+//   ValueNotifier<AppLifecycleState>? _lifecycleNotifier;
+
+//   // 1. BGM 播放器 (使用常规模式进行流式传输,专用于音乐)
+//   final AudioPlayer _musicPlayer = AudioPlayer();
+
+//   // 2. 音效音频池 (用于低延迟播放 SFX)
+//   // Key: SfxType, Value: AudioPool 实例
+//   final Map<SfxType, AudioPool> _sfxPools = {};
+
+//   // 预加载的 BGM 文件路径
+//   final String _bgmAsset = 'audio/bgm/canon.mp3';
+
+//   // 所有音效文件映射
+//   static const Map<SfxType, String> _sfxPaths = {
+//     SfxType.drop: 'audio/sfx/button_click8.mp3',
+//     SfxType.click: 'audio/sfx/click2.mp3',
+//     SfxType.tap: 'audio/sfx/tap.mp3',
+//     SfxType.pop: 'audio/sfx/pop.mp3',
+//     SfxType.appear: 'audio/sfx/appear.mp3',
+//     SfxType.alert: 'audio/sfx/alert.mp3',
+//     SfxType.star: 'audio/sfx/star.mp3',
+//     SfxType.success: 'audio/sfx/win.mp3',
+//     SfxType.card: 'audio/sfx/card.mp3',
+//     SfxType.cardShort: 'audio/sfx/card2.mp3',
+//     SfxType.flip: 'audio/sfx/flip.mp3',
+//     SfxType.panstart: 'audio/sfx/pan_start.mp3',
+//     SfxType.panend: 'audio/sfx/pan_end.mp3',
+//     SfxType.panend2: 'audio/sfx/pan_end2.mp3',
+//   };
+
+//   AudioControllerX() {
+//     // 配置 BGM 播放器:设置为循环模式
+//     _musicPlayer.setReleaseMode(ReleaseMode.loop);
+//   }
+
+//   /// 在应用启动时异步调用,用于预加载所有音乐和音效。
+//   Future<void> initialize() async {
+//     // 1. 预加载 BGM
+//     await _musicPlayer.setSource(AssetSource(_bgmAsset));
+
+//     // 2. 预加载所有 SFX 到 AudioPool 中 (实现真正的低延迟)
+//     final futures = _sfxPaths.entries.map((entry) async {
+//       final type = entry.key;
+//       final path = entry.value;
+
+//       // ⚠️ 修复:使用 AudioPool.createFromAssetSource 替换 fromAsset
+//       final pool = await AudioPool.createFromAsset(
+//         path: path, // 直接传入 String 路径
+//         maxPlayers: type == SfxType.card ? 10 : 3,
+//       );
+//       _sfxPools[type] = pool;
+//     }).toList();
+
+//     await Future.wait(futures);
+//     _log.info('All sounds and music preloaded successfully via AudioPool.');
+//   }
+
+//   void dispose() {
+//     _lifecycleNotifier?.removeListener(_handleAppLifecycle);
+//     stopMusic();
+
+//     // 停止并清理所有的音频池,释放原生资源
+//     for (final pool in _sfxPools.values) {
+//       pool.dispose();
+//     }
+//     _sfxPools.clear();
+
+//     _musicPlayer.dispose();
+//   }
+
+//   /// Enables the [AudioController] to listen to [AppLifecycleState] events...
+//   void attachLifecycleNotifier(ValueNotifier<AppLifecycleState> lifecycleNotifier) {
+//     _lifecycleNotifier?.removeListener(_handleAppLifecycle);
+//     lifecycleNotifier.addListener(_handleAppLifecycle);
+//     _lifecycleNotifier = lifecycleNotifier;
+//   }
+
+//   /// Enables the [AudioController] to track changes to settings...
+//   void attachSettings(SettingsController settingsController) {
+//     if (_settings == settingsController) return;
+
+//     final oldSettings = _settings;
+//     if (oldSettings != null) {
+//       oldSettings.music.removeListener(_musicOnHandler);
+//       oldSettings.sound.removeListener(_soundOnHandler);
+//     }
+
+//     _settings = settingsController;
+
+//     settingsController.music.addListener(_musicOnHandler);
+//     settingsController.sound.addListener(_soundOnHandler);
+//   }
+
+//   /// Plays a single sound effect, defined by [type].
+//   /// 使用 AudioPool 实现极低延迟播放。
+//   void playSfx(SfxType type, {Duration? duration}) async {
+//     final soundsOn = _settings?.sound.value ?? false;
+//     if (!soundsOn) {
+//       _log.info(() => 'Ignoring playing sound ($type) because sounds are turned off.');
+//       return;
+//     }
+
+//     final pool = _sfxPools[type];
+//     if (pool == null) {
+//       _log.severe('Missing audio pool for SFX type: $type');
+//       return;
+//     }
+
+//     // 核心优化:从 AudioPool 快速启动播放。
+//     await pool.start(volume: 1.0);
+
+//     if (duration != null) {
+//       _log.warning('Duration control is complex with AudioPool; generally SFX should be short.');
+//     }
+//   }
+
+//   void _handleAppLifecycle() {
+//     switch (_lifecycleNotifier!.value) {
+//       case AppLifecycleState.paused:
+//       case AppLifecycleState.detached:
+//       case AppLifecycleState.hidden:
+//       case AppLifecycleState.inactive:
+//         pauseMusic();
+//         break;
+//       case AppLifecycleState.resumed:
+//         startMusic();
+//         break;
+//     }
+//   }
+
+//   void _musicOnHandler() {
+//     if (_settings!.music.value) {
+//       startMusic();
+//     } else {
+//       stopMusic();
+//     }
+//   }
+
+//   void _soundOnHandler() {
+//     // 声音设置关闭时,不需要特殊处理 AudioPool 播放的短音效
+//   }
+
+//   void setMusicVolume(double volume) {
+//     _musicPlayer.setVolume(volume);
+//     _log.info('Music volume set to $volume');
+//   }
+
+//   void startMusic() async {
+//     _log.info('starting music');
+//     final musicOn = _settings?.music.value ?? false;
+//     if (!musicOn) {
+//       _log.info(() => 'Ignoring playing music because music are turned off.');
+//       return;
+//     }
+
+//     // 假设我们希望 BGM 默认音量为 0.5
+//     const double defaultBGMVolume = 0.1;
+
+//     final state = _musicPlayer.state;
+//     if (state == PlayerState.playing) return;
+
+//     // 如果音乐已经停止或未播放,则重新设置源并播放
+//     if (state == PlayerState.stopped || state == PlayerState.disposed) {
+//       await _musicPlayer.setSource(AssetSource(_bgmAsset));
+//       await _musicPlayer.setVolume(defaultBGMVolume);
+//       await _musicPlayer.resume();
+//     } else {
+//       // 从暂停状态恢复
+//       await _musicPlayer.resume();
+//     }
+//   }
+
+//   void stopMusic() {
+//     _log.info('Stopping music');
+//     _musicPlayer.stop();
+//   }
+
+//   void pauseMusic() {
+//     _log.info('pause music');
+//     _musicPlayer.pause();
+//   }
+
+//   Future<void> resumeMusic() async {
+//     _log.info('Resuming music');
+//     final musicOn = _settings?.music.value ?? false;
+//     if (musicOn) {
+//       _musicPlayer.resume();
+//     }
+//   }
+
+//   void _stopSound() {
+//     // AudioPool 播放的短音效不需要手动停止
+//   }
+// }

+ 2 - 2
lib/audio/jc_audio_controller.dart

@@ -7,7 +7,7 @@ import 'package:logging/logging.dart';
 import 'package:puzzleweave/settings/settings_controller.dart';
 import 'package:jc_audio_player/jc_audio_player.dart';
 
-enum SfxType { drop, click, tap, kick, pop, appear, alert, star, success, flip, card, cardShort, panstart, panend, panend2 }
+enum SfxType { drop, click, tap, pop, appear, alert, star, success, flip, card, cardShort, panstart, panend, panend2 }
 
 //AudioLogger.logLevel = AudioLogLevel.info;
 
@@ -31,7 +31,7 @@ class JcAudioController {
     _audioPlayer.addSound(SfxType.appear.index, 'assets/audio/sfx/appear.mp3');
     _audioPlayer.addSound(SfxType.alert.index, 'assets/audio/sfx/alert.mp3');
     _audioPlayer.addSound(SfxType.star.index, 'assets/audio/sfx/star.mp3');
-    _audioPlayer.addSound(SfxType.success.index, 'assets/audio/sfx/success3.mp3');
+    _audioPlayer.addSound(SfxType.success.index, 'assets/audio/sfx/win.mp3');
     _audioPlayer.addSound(SfxType.card.index, 'assets/audio/sfx/card.mp3');
     _audioPlayer.addSound(SfxType.cardShort.index, 'assets/audio/sfx/card2.mp3');
     _audioPlayer.addSound(SfxType.flip.index, 'assets/audio/sfx/flip.mp3');

+ 3 - 0
lib/config/config.dart

@@ -7,7 +7,10 @@ import 'device.dart';
 class Config {
   static bool get isDebug => false;
 
+  static String firstId = '6915869d4b99f02d1db82cf2';
+
   late Device device;
 
   Config(BuildContext context, Directory baseDir) : device = Device(context, baseDir);
 }
+

+ 91 - 57
lib/homepage/home_screen.dart

@@ -2,13 +2,10 @@ import 'dart:async';
 import 'dart:io';
 import 'dart:math';
 
-import 'package:advertising_id/advertising_id.dart';
 import 'package:app_tracking_transparency/app_tracking_transparency.dart';
 import 'package:firebase_crashlytics/firebase_crashlytics.dart';
 import 'package:firebase_messaging/firebase_messaging.dart';
-import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
 import 'package:flutter_svg/svg.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:logging/logging.dart';
@@ -17,6 +14,7 @@ import 'package:provider/provider.dart';
 import 'package:puzzleweave/ads/applovin_ads_controller.dart';
 import 'package:puzzleweave/audio/jc_audio_controller.dart';
 import 'package:puzzleweave/collection/collection_screen.dart';
+import 'package:puzzleweave/config/config.dart';
 import 'package:puzzleweave/config/device.dart';
 import 'package:puzzleweave/homepage/home_board_play.dart';
 import 'package:puzzleweave/l10n/app_localizations.dart';
@@ -24,11 +22,14 @@ import 'package:puzzleweave/models/cached_request.dart';
 import 'package:puzzleweave/models/data.dart';
 import 'package:puzzleweave/models/download.dart';
 import 'package:puzzleweave/models/items.dart';
+import 'package:puzzleweave/persistence/persistence.dart';
 import 'package:puzzleweave/platform/my_method_channel.dart';
 import 'package:puzzleweave/play/board_play.dart';
 import 'package:puzzleweave/settings/settings_screen.dart';
 import 'package:puzzleweave/skin/skin.dart';
 import 'package:puzzleweave/utils/mybutton.dart';
+import 'package:puzzleweave/utils/utils.dart';
+
 import '../ads/ads_state.dart';
 
 final Logger _log = Logger('home_screen');
@@ -61,12 +62,24 @@ class _HomeScreen extends AdsState<HomeScreen> with TickerProviderStateMixin {
   late AnimationController _collectionController; // 左上角 collection button 的动画控制器
   late Animation<double> _collectionAnimation; // 放大/缩小动画
 
+  bool firstRun = false;
+
   @override
   void initState() {
     super.initState();
 
     _log.info("首页初始化");
 
+    // 在组件绘制后检查 firstRun 并导航
+    if (Persistence().firstRun) {
+      firstRun = true;
+      WidgetsBinding.instance.addPostFrameCallback((_) {
+        // 仅当未跳转过时执行
+        _handleFirstRunNavigation();
+      });
+      Persistence().firstRun = false;
+    }
+
     device = context.read<Device>();
     audio = context.read<JcAudioController>();
     data = context.read<Data>();
@@ -101,6 +114,33 @@ class _HomeScreen extends AdsState<HomeScreen> with TickerProviderStateMixin {
     audio.startMusic();
   }
 
+  // 首页初始化之后的跳转,首次运行直接进入play页面,上次从play页面退出有缓存存在也跳转到play页面
+  void _handleFirstRunNavigation() async {
+    _log.info('First run detected, navigating to initial play page.');
+    final AssetItem initialItem = AssetItem(
+      Config.firstId,
+      '',
+      2000,
+      3000,
+      3,
+      false,
+      'assets/builtin/${Config.firstId}.jpeg',
+      'assets/builtin/${Config.firstId}.jpeg',
+    );
+    return gotoPlay(initialItem, firstRun: true);
+  }
+
+  // 检查是否需要跳转到boardplay
+  void checkGoPlay() async {
+    if (currentItem != null) {
+      final jsonFile = await localFile(currentItem!.jsonPath);
+      if (await jsonFile.exists()) {
+        // 之前玩过有缓存, 直接进入play界面
+        gotoPlay(currentItem!);
+      }
+    }
+  }
+
   @override
   void dispose() {
     latestSubscription?.cancel();
@@ -108,15 +148,19 @@ class _HomeScreen extends AdsState<HomeScreen> with TickerProviderStateMixin {
     super.dispose();
   }
 
-  _onLatestDataUpdate(data) {
+  _onLatestDataUpdate(datalist) {
     _log.info('_onLatestDataUpdate.... ');
-    if (data != null) {
-      latest = data as List<ListItem>;
+    if (datalist != null) {
+      bool check = false;
+      if (currentItem == null && datalist != null && !firstRun) {
+        check = true;
+      }
+      latest = datalist as List<ListItem>;
       isLoading = false;
       setState(() {});
 
       // 1. 检查数据量是否达到最低要求 (>= 30)
-      final bool hasSufficientData = data.length >= minimumRemoteLoadCount;
+      final bool hasSufficientData = datalist.length >= minimumRemoteLoadCount;
 
       // 2. 检查数据是否来自最近一次成功的网络请求
       final bool isNetworkActive = latestCachedRequest.hasRecentSuccessfulFetch; // !!! 关键检查点
@@ -138,9 +182,13 @@ class _HomeScreen extends AdsState<HomeScreen> with TickerProviderStateMixin {
         }
       } else {
         // 数据不足 (例如,只有内置图),无论是缓存还是远程失败,都需要重试
-        _log.info('Data insufficient (only ${data.length} items). Attempting refresh in 3s.');
+        _log.info('Data insufficient (only ${datalist.length} items). Attempting refresh in 3s.');
         Future.delayed(Duration(seconds: 3), () => refresh());
       }
+
+      if (check) {
+        checkGoPlay();
+      }
     }
   }
 
@@ -392,6 +440,40 @@ class _HomeScreen extends AdsState<HomeScreen> with TickerProviderStateMixin {
     );
   }
 
+  void gotoPlay(ListItem item, {bool firstRun = false}) async {
+    _log.info('goto play, firstRun = $firstRun');
+
+    PageRouteBuilder? pageRouteBuilder = BoardPlay.buildRoute(item, firstRun: firstRun);
+    final result = await Navigator.push(context, pageRouteBuilder);
+    if (!mounted) return;
+    if (result != null && result == true) {
+      // 通关返回, 展示翻牌
+      _canvasKey.currentState?.startFlipAnimation();
+
+      final bool hasSufficientData = latest != null && latest!.length >= minimumRemoteLoadCount;
+      final bool isNetworkActive = latestCachedRequest.hasRecentSuccessfulFetch;
+
+      if (hasSufficientData) {
+        // 1. 数据完整:如果网络活跃,立即顺延预加载。
+        if (isNetworkActive) {
+          _log.info('Game finished, data complete & Network Active. Triggering sequential preloading...');
+          _preloadNextImages();
+        } else {
+          // 2. 数据完整但网络不活跃/状态未知:尝试刷新,让 _onLatestDataUpdate 负责后续处理
+          _log.info('Game finished, data complete but Network inactive. Attempting refresh.');
+          refresh();
+        }
+      } else {
+        // 3. 数据不完整:无论如何都需要刷新,让 _onLatestDataUpdate 重新处理
+        _log.info('Game finished, remote data incomplete. Attempting refresh...');
+        refresh();
+      }
+    } else {
+      // 非关卡通关返回,在这里播放插屏广告
+      // showInterstitialAd("level_exit", currentItem!.id, data.currentLevel);
+    }
+  }
+
   Widget get playButton {
     return MyElevatedButton(
       width: device.isTablet ? 300 : 200,
@@ -400,32 +482,8 @@ class _HomeScreen extends AdsState<HomeScreen> with TickerProviderStateMixin {
       gradient: LinearGradient(colors: [SkinHelper.coreBgColor, SkinHelper.slotBorderColor]),
       onPressed: () async {
         audio.playSfx(SfxType.click);
-        // _canvasKey.currentState?.startFlipAnimation(); // for test
-        // _canvasKey.currentState?.testAnimation(); // for test;
         if (currentItem != null) {
-          PageRouteBuilder? pageRouteBuilder = BoardPlay.buildRoute(currentItem!);
-          final result = await Navigator.push(context, pageRouteBuilder);
-          if (result == true) {
-            _canvasKey.currentState?.startFlipAnimation();
-            final bool hasSufficientData = latest != null && latest!.length >= minimumRemoteLoadCount;
-            final bool isNetworkActive = latestCachedRequest.hasRecentSuccessfulFetch;
-
-            if (hasSufficientData) {
-              // 1. 数据完整:如果网络活跃,立即顺延预加载。
-              if (isNetworkActive) {
-                _log.info('Game finished, data complete & Network Active. Triggering sequential preloading...');
-                _preloadNextImages();
-              } else {
-                // 2. 数据完整但网络不活跃/状态未知:尝试刷新,让 _onLatestDataUpdate 负责后续处理
-                _log.info('Game finished, data complete but Network inactive. Attempting refresh.');
-                refresh();
-              }
-            } else {
-              // 3. 数据不完整:无论如何都需要刷新,让 _onLatestDataUpdate 重新处理
-              _log.info('Game finished, remote data incomplete. Attempting refresh...');
-              refresh();
-            }
-          }
+          gotoPlay(currentItem!);
         } else {
           Fluttertoast.showToast(
             msg: AppLocalizations.of(context)!.noMorePicture,
@@ -499,30 +557,6 @@ class _HomeScreen extends AdsState<HomeScreen> with TickerProviderStateMixin {
 
     final idfa = await AppTrackingTransparency.getAdvertisingIdentifier();
     _log.info("idfa: $idfa");
-
-    if (kDebugMode) {
-      _printInfo();
-    }
-  }
-
-  _printInfo() async {
-    String? advertisingId;
-    // Platform messages may fail, so we use a try/catch PlatformException.
-    try {
-      advertisingId = await AdvertisingId.id(true);
-    } on PlatformException {
-      advertisingId = null;
-    }
-
-    bool? isLimitAdTrackingEnabled;
-    // Platform messages may fail, so we use a try/catch PlatformException.
-    try {
-      isLimitAdTrackingEnabled = await AdvertisingId.isLimitAdTrackingEnabled;
-    } on PlatformException {
-      isLimitAdTrackingEnabled = false;
-    }
-
-    _log.info('advertisingId: $advertisingId, isLimitAdTrackingEnabled: $isLimitAdTrackingEnabled');
   }
 
   /////////////////////////// ATT ///////////////////////////

+ 16 - 22
lib/main.dart

@@ -22,7 +22,7 @@ import 'package:puzzleweave/persistence/persistence.dart';
 import 'package:puzzleweave/play/board_play.dart';
 import 'package:puzzleweave/remote_config/remote_config.dart';
 import 'package:puzzleweave/settings/settings_controller.dart';
-import 'package:shared_preferences/shared_preferences.dart';
+import 'package:puzzleweave/utils/utils.dart';
 
 import 'config/config.dart' as cfg;
 import 'config/device.dart';
@@ -94,36 +94,29 @@ void main() async {
     }
   }
 
-  // 检查是否是首次进入,首次进入直接进入引导游戏界面
-  bool firstRun = false;
-  SharedPreferences prefs = await SharedPreferences.getInstance();
-  int? timestamp = prefs.getInt('first_run_time');
-  if (timestamp == null) {
-    firstRun = true;
-  }
-  _log.info('firstRun = $firstRun');
-
   //本地参数存储初始化
   await Persistence().initialize();
 
   // 远程参数初始化
   await RemoteConfig().initialize();
 
-  // 程序首次运行时间
-  DateTime firstRunTime = Persistence().firstRunTime;
-  _log.info("first_run_time: $firstRunTime, now: ${DateTime.now()}");
   // 记录程序运行时间
   Persistence().lastRunTime = DateTime.now();
 
   Directory baseDir = await getApplicationDocumentsDirectory();
 
-  runApp(MyApp(baseDir: baseDir, firstRun: firstRun));
+  // 首次运行, 将json写入
+  if (Persistence().firstRun) {
+    final json = await loadJSONFromAsset('assets/builtin/${cfg.Config.firstId}.json');
+    await saveJson('work/${cfg.Config.firstId}.json', json);
+  }
+
+  runApp(MyApp(baseDir: baseDir));
 }
 
 class MyApp extends StatelessWidget {
-  final bool firstRun;
   final Directory baseDir;
-  const MyApp({super.key, required this.baseDir, required this.firstRun});
+  const MyApp({super.key, required this.baseDir});
 
   // This widget is the root of your application.
   @override
@@ -165,23 +158,24 @@ class MyApp extends StatelessWidget {
         child: Prepare(
           child: MaterialApp(
             key: GlobalKey(),
-            title: 'PuzzleWeave',
-            initialRoute: firstRun ? '/play' : '/', // 首次游戏直接进入游戏页面,而不是合集页
+            title: 'Jigsort Solitaire',
+            // initialRoute: firstRun ? '/play' : '/', // 首次游戏直接进入游戏页面,而不是合集页
+            initialRoute: '/', // 统一先到HomeScreen, 再根据情况跳转到相应页面
             navigatorObservers: [routeObserver],
             routes: {
               '/': (context) => const HomeScreen(),
               '/play': (context) => BoardPlay(
                 item: AssetItem(
-                  '6915869d4b99f02d1db82cf2',
+                  cfg.Config.firstId,
                   '',
                   2000,
                   3000,
                   3,
                   false,
-                  'assets/builtin/6915869d4b99f02d1db82cf2.jpeg',
-                  'assets/builtin/6915869d4b99f02d1db82cf2.jpeg',
+                  'assets/builtin/${cfg.Config.firstId}.jpeg',
+                  'assets/builtin/${cfg.Config.firstId}.jpeg',
                 ),
-                firstRun: firstRun,
+                firstRun: true,
               ),
             },
             theme: ThemeData(

+ 1 - 0
lib/models/items.dart

@@ -12,6 +12,7 @@ abstract class ListItem {
   String get thumb;
   String get image;
   String get cachePath => 'cache/$id.jpeg';
+  String get jsonPath => 'work/$id.json';
   ValueNotifier notifier = ValueNotifier(0);
 
   // 新增:用于序列化

+ 6 - 0
lib/persistence/persistence.dart

@@ -46,6 +46,7 @@ class Persistence {
 
     //系统
     _uuid = PreferencesValue<String>('uuid', const Uuid().v4(), _prefs);
+    _firstRun = PreferencesValue<bool>('first_run', true, _prefs);
     _firstRunTime = PreferencesValue<DateTime>('first_run_time', DateTime.now(), _prefs, saveDefault: true);
     _lastRunTime = PreferencesValue<DateTime>('last_run_time', DateTime.now(), _prefs, saveDefault: true);
     _lastDailyRewardTime = PreferencesValue<DateTime>('last_daily_reward_time', DateTime.now().subtract(const Duration(days: 1)), _prefs);
@@ -89,6 +90,11 @@ class Persistence {
   String get uuid => _uuid.value;
   set uuid(String value) => _uuid.value = value;
 
+  // firstRun
+  late PreferencesValue<bool> _firstRun;
+  bool get firstRun => _firstRun.value;
+  set firstRun(bool value) => _firstRun.value = value;
+
   // 程序第一次运行
   late PreferencesValue<DateTime> _firstRunTime;
   DateTime get firstRunTime => _firstRunTime.value;

+ 92 - 1
lib/play/board.dart

@@ -102,11 +102,61 @@ class Board {
 
   final TickerProviderStateMixin ticker;
 
+  static Future<Board> create(
+    TickerProviderStateMixin ticker,
+    ui.Image image,
+    ui.Image cardImage,
+    int rows,
+    int cols,
+    bool hard,
+    Rect targetRect,
+    Device device,
+  ) async {
+    _log.info('Image.size=${image.width}x${image.height}');
+    final board = Board(ticker, image, cardImage, rows, cols, hard, targetRect, device);
+    return board;
+  }
+
+  static Future<Board> restore(
+    TickerProviderStateMixin ticker,
+    ui.Image image,
+    ui.Image cardImage,
+    int rows,
+    int cols,
+    bool hard,
+    Rect targetRect,
+    Device device,
+    String jsonPath,
+  ) async {
+    _log.info('Image.size=${image.width}x${image.height}');
+
+    try {
+      Map<String, dynamic> json = await loadJson(jsonPath) as Map<String, dynamic>;
+      if (json['pieces'] == null || json['pieces'][0] == null) {
+        throw Exception('invalid json: $json');
+      }
+      rows = json['pieces'][0]['rows'];
+      cols = json['pieces'][0]['cols'];
+
+      final board = Board(ticker, image, cardImage, rows, cols, hard, targetRect, device, json: json);
+      return board;
+    } catch (e) {
+      _log.warning("board restore failed: $e");
+      _log.info("游戏恢复失败,转为重新创建");
+      final board = Board(ticker, image, cardImage, rows, cols, hard, targetRect, device);
+      return board;
+    }
+  }
+
   Board(this.ticker, this.image, this.cardImage, this.rows, this.cols, this.hard, this.targetRect, this.device, {Map<String, dynamic>? json})
     : finalRect = targetRect {
     _recordBackground(); // 录制静态背景,提升性能
     _recordCard(); // 新增:录制卡片 Picture
-    _initPieces();
+    if (json != null) {
+      _restorePieces(json);
+    } else {
+      _initPieces();
+    }
     rebuildAllGroups();
   }
 
@@ -135,6 +185,45 @@ class Board {
     _log.info('_initPieces');
   }
 
+  // restore 碎片
+  void _restorePieces(Map<String, dynamic> json) {
+    pieces.clear();
+    final imageWidth = image.width.toDouble();
+    final imageHeight = image.height.toDouble();
+    final pieceWidth = imageWidth / cols;
+    final pieceHeight = imageHeight / rows;
+
+    for (var i = 0; i < (json['pieces'] as List).length; i++) {
+      var jsonPiece = json['pieces'][i];
+      final int index = jsonPiece['index'];
+      final int row = jsonPiece['row'];
+      final int col = jsonPiece['col'];
+      final int curRow = jsonPiece['curRow'];
+      final int curCol = jsonPiece['curCol'];
+
+      final sourceRect = Rect.fromLTWH(col * pieceWidth, row * pieceHeight, pieceWidth, pieceHeight);
+      final transform = getTransformByCoordinate(curRow, curCol);
+
+      pieces.add(
+        Piece(
+          board: this,
+          index: index,
+          row: row,
+          col: col,
+          rows: rows,
+          cols: cols,
+          sourceRect: sourceRect,
+          curRow: curRow,
+          curCol: curCol,
+          transform: transform,
+        ),
+      );
+    }
+
+    _sortPieces();
+    _log.info('_restorePieces');
+  }
+
   // 洗牌(随机打乱碎片位置)
   void _shufflePieces() {
     final shuffledPositions = List.generate(pieces.length, (i) => i)..shuffle();
@@ -494,4 +583,6 @@ class Board {
     cardPicture = recorder.endRecording();
     _log.info('Card picture recorded (with border). Size: ${recordBounds.size}');
   }
+
+  Map<String, dynamic> toJson() => {'pieces': pieces.map((e) => e.toJson()).toList()};
 }

+ 138 - 403
lib/play/board_play.dart

@@ -47,18 +47,19 @@ enum Action {
 class BoardPlay extends StatefulWidget {
   final ListItem item;
   final bool firstRun;
+  final bool reset;
 
-  const BoardPlay({super.key, required this.item, this.firstRun = false});
+  const BoardPlay({super.key, required this.item, this.firstRun = false, this.reset = false});
 
   @override
   State<StatefulWidget> createState() {
     return _BoardPlayState();
   }
 
-  static PageRouteBuilder buildRoute(ListItem item, {bool firstRun = false}) {
+  static PageRouteBuilder buildRoute(ListItem item, {bool firstRun = false, bool reset = false}) {
     return PageRouteBuilder(
       pageBuilder: (context, animation, secondaryAnimation) {
-        return BoardPlay(item: item, firstRun: firstRun);
+        return BoardPlay(item: item, firstRun: firstRun, reset: reset);
       },
       transitionsBuilder: (context, animation, secondaryAnimation, child) {
         return FadeTransition(opacity: animation, child: child);
@@ -89,12 +90,7 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
   late ConfettiLayer confettiLayer;
 
   ui.Image? _fingerImage; // 手指形状图片,用于制作引导动画
-  int _hintCount = 0; // 已经展示的手势指引次数
   OverLayer? _overLayer; // 用于展示手势指引的layer层,采用OverlayEntry方案,置于顶层
-  Timer? _hintTimer;
-  int? _lastInteractionTick;
-  // final int maxHints = 3;
-  final int maxHints = 99; // 无限提示
 
   Piece? _draggingPiece;
 
@@ -109,6 +105,8 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
   late Animation<double> _mergeScaleAnimation;
   List<PieceGroup>? _mergeGroups; // 记录当前merge的group
 
+  bool showDealing = true; // 是否需要发牌
+
   late AnimationController _prepareAnimationController; // 预备动画, Opacity透明动画展示核心绘制区
 
   late AnimationController dealingAnimationController; // 发牌动画
@@ -316,6 +314,15 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
     setState(() {});
   }
 
+  // 保存状态,本来是不用保存的,竞品也没有保存
+  void saveProgress() async {
+    _log.info('saveProgress');
+    // 没有完成才需要保存
+    if (board != null && board!.isAllDone == false) {
+      await saveJson(widget.item.jsonPath, board!.toJson());
+    }
+  }
+
   void _successAnimationListener() {
     final delta = _offsetAnimation.value;
     board!.finalRect = board!.targetRect.translate(0, delta);
@@ -607,7 +614,19 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
     final ui.FrameInfo cardFrameInfo = await cardCodec.getNextFrame();
     final cardImage = cardFrameInfo.image;
 
-    board = Board(this, image, cardImage, widget.item.rows, widget.item.cols, widget.item.hard, targetRect, device);
+    // 看看有没有缓存
+    if (widget.reset) {
+      board = await Board.create(this, image, cardImage, widget.item.rows, widget.item.cols, widget.item.hard, targetRect, device);
+    } else {
+      final jsonFile = await localFile(widget.item.jsonPath);
+      if (await jsonFile.exists()) {
+        showDealing = false; // 恢复状态不必发牌
+        board = await Board.restore(this, image, cardImage, widget.item.rows, widget.item.cols, widget.item.hard, targetRect, device, widget.item.jsonPath);
+      } else {
+        board = await Board.create(this, image, cardImage, widget.item.rows, widget.item.cols, widget.item.hard, targetRect, device);
+      }
+    }
+
     board!.prepare();
 
     // 首次打开应用,需要新手指引
@@ -616,7 +635,11 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
     // **修正:在调用 AnimationController 之前检查 `mounted` 状态**
     if (!mounted) return;
 
-    _prepareAnimationController.forward(from: 0.0);
+    if (showDealing == false) {
+      board!.start();
+    } else {
+      _prepareAnimationController.forward(from: 0.0);
+    }
 
     setState(() {
       _isLoading = false;
@@ -647,365 +670,43 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
     // 首次打开应用或设置开启提示时,启动自动提示计时器
     if (widget.firstRun) {
       Future.delayed(const Duration(seconds: 1), () {
-        _lastInteractionTick = DateTime.now().millisecondsSinceEpoch;
-
-        // 每秒检查一次是否需要提示
-        _hintTimer = Timer.periodic(const Duration(seconds: 1), (Timer t) {
-          if (!mounted) return;
-          int nowTick = DateTime.now().millisecondsSinceEpoch;
-          if (_overLayer!.isHinting) {
-            _lastInteractionTick = DateTime.now().millisecondsSinceEpoch;
-          }
-          // 超过3秒没动静,且提示次数未超限,则给提示
-          if ((nowTick - _lastInteractionTick!) > 3 * 1000 && _hintCount < maxHints) {
-            hint();
-            _lastInteractionTick = nowTick; // 提示后重置计时
-            _hintCount++;
-          } else if (_hintCount >= maxHints) {
-            // 提示次数达到上限,取消计时器
-            _hintTimer?.cancel();
-          }
-        });
+        hint();
       });
-    }
-  }
-
-  // board_play.dart (在 _BoardPlayState 中,修正 hint 方法)
-
-  hint() async {
-    // 使用私有字段 _fingerImage, _overLayer, _hintCount
-    if (_fingerImage == null || _overLayer == null || board == null || board!.status != BoardStatus.playing) return;
-
-    Piece? p1Ref; // 拖拽起点 piece 的参考 (单碎片或群组的 topLeftPiece)
-    Piece? p2; // 合并目标 piece
-    int bestSize = 0; // 记录找到的最佳群组大小
-
-    // --- Step 1: 确定所有可移动的实体(单碎片和群组)及其大小 ---
-    final List<Map<String, dynamic>> movableEntities = [];
-    final Set<PieceGroup> seenGroups = {};
-
-    for (final piece in board!.pieces) {
-      if (piece.isOK) continue; // 已归位的碎片/群组不移动
-
-      if (piece.group == null) {
-        // 1. 单个碎片
-        movableEntities.add({'ref': piece, 'size': 1});
-      } else if (!seenGroups.contains(piece.group!)) {
-        // 2. 群组:使用 topLeftPiece 作为群组的参考点
-        movableEntities.add({'ref': piece.group!.topLeftPiece, 'size': piece.group!.length});
-        seenGroups.add(piece.group!);
-      }
-    }
-
-    // --- Step 2: 按实体大小降序排列,优先引导大群组 ---
-    // b['size'].compareTo(a['size']) 实现降序排序
-    movableEntities.sort((a, b) => b['size'].compareTo(a['size']));
-
-    // --- Step 3: 搜索有效的合并机会 (Merge Opportunity) ---
-
-    for (final entityMap in movableEntities) {
-      final p1RefCandidate = entityMap['ref'] as Piece;
-      final currentSize = entityMap['size'] as int;
-      final movingEntity = p1RefCandidate.group ?? p1RefCandidate;
-      final movingPieces = (movingEntity is PieceGroup) ? movingEntity.pieces : [p1RefCandidate];
-
-      // 遍历移动实体内的所有碎片 p1,寻找一个可以与外部 p2 合并的边缘碎片
-      for (final p1 in movingPieces) {
-        // 遍历 p1 的原图邻居 p2
-        for (final neighborIndex in p1.getNeighbourIndexes()) {
-          final p2Candidate = board!.getPieceByIndex(neighborIndex);
-
-          if (p2Candidate == null) continue;
-
-          // p2Candidate 必须不是正在移动的实体的一部分
-          if (p2Candidate.isSameGroup(p1)) continue;
-
-          // 1. 检查 p1 和 p2Candidate 的相对位置是否正确 (满足合并的前提条件)
-          final isRelativePositionCorrect =
-              (p1.col == p2Candidate.col && (p1.row - p2Candidate.row) == (p1.curRow - p2Candidate.curRow)) ||
-              (p1.row == p2Candidate.row && (p1.col - p2Candidate.col) == (p1.curCol - p2Candidate.curCol));
-
-          // 2. 如果它们已经是邻居,则应已自动合并,跳过提示
-          if (p1.isCurNeighbour(p2Candidate)) continue;
-
-          if (isRelativePositionCorrect) {
-            // --- Step 3.1: 模拟移动并进行碰撞/边界检查 (Validity Check) ---
-
-            // 计算使 p1 与 p2Candidate 合并所需的位移量 (dMoveRow, dMoveCol)
-
-            // a. p1 在原图上相对于 p2 的差值
-            final int dRow = p1.row - p2Candidate.row;
-            final int dCol = p1.col - p2Candidate.col;
-
-            // b. p1 移动后的目标网格坐标
-            final targetP1Row = p2Candidate.curRow + dRow;
-            final targetP1Col = p2Candidate.curCol + dCol;
-
-            // c. 整个实体所需的移动位移 (从 p1 的当前位置到目标位置的距离)
-            final dMoveRow = targetP1Row - p1.curRow;
-            final dMoveCol = targetP1Col - p1.curCol;
-
-            // 检查整个实体 (movingPieces) 移动 (dMoveRow, dMoveCol) 是否可行
-            bool canPlace = true;
-            for (final movingPiece in movingPieces) {
-              final newRow = movingPiece.curRow + dMoveRow;
-              final newCol = movingPiece.curCol + dMoveCol;
-
-              // i. 边界检查
-              if (newRow < 0 || newRow >= board!.rows || newCol < 0 || newCol >= board!.cols) {
-                canPlace = false;
-                break;
-              }
-
-              // ii. 碰撞检查: 目标槽位不能被非本实体内的其他碎片占据
-              final overlapPiece = board!.getPieceByCoordinate(newRow, newCol);
-
-              // 碰撞条件:目标槽位被占据 且 占据者不属于正在移动的实体
-              if (overlapPiece != null && !movingPieces.contains(overlapPiece)) {
-                canPlace = false;
-                break;
-              }
-            }
-
-            if (canPlace) {
-              p1Ref = p1RefCandidate; // 拖拽起点 (群组的参考 Piece)
-              p2 = p2Candidate; // 合并目标 Piece
-              bestSize = currentSize; // 记录大小
-              break; // 找到有效的合并提示,跳出 p2 循环
-            }
-          }
-        }
-        if (p1Ref != null) break; // 找到有效的合并提示,跳出 p1 循环
-      }
-      if (p1Ref != null) break; // 找到有效的合并提示,跳出实体循环 (因为它涉及当前找到的最大群组)
-    }
-
-    // ----------------------------------------------------
-    // --- Step 4: 执行引导动画 (Merge or Revert) ---
-    // ----------------------------------------------------
-
-    // 引导参数
-    const double fingerSize = 30.0;
-    HintItem? hintItem;
-
-    if (p1Ref != null && p2 != null) {
-      // 找到了有效的合并提示 (优先选择的合并操作)
-
-      // a. 拖拽起点中心点: p1Ref 的群组中心或自身中心
-      final p1Center = p1Ref!.group?.center ?? p1Ref!.currentCenter;
-
-      // b. 拖拽终点中心点: p1Ref 移动后的目标槽位中心
-
-      // 重新计算 p1Ref 应该移动到的目标网格坐标 (targetRefRow, targetRefCol)
-      final movingEntity = p1Ref!.group ?? p1Ref;
-      final movingPieces = (movingEntity is PieceGroup) ? movingEntity.pieces : [p1Ref!];
-
-      // 寻找群组中能与 p2 合并的那个边缘碎片 p1
-      final p1 = movingPieces.firstWhere(
-        (p) =>
-            p.isNeighbour(p2!) &&
-            ((p.col == p2!.col && (p.row - p2!.row) == (p.curRow - p2!.curRow)) || (p.row == p2!.row && (p.col - p2!.col) == (p.curCol - p2!.curCol))),
-      );
-
-      final int dRow = p1.row - p2!.row;
-      final int dCol = p1.col - p2!.col;
-      final targetP1Row = p2!.curRow + dRow;
-      final targetP1Col = p2!.curCol + dCol;
-
-      final dMoveRow = targetP1Row - p1.curRow;
-      final dMoveCol = targetP1Col - p1.curCol;
-
-      // 最终目标网格坐标是 p1Ref 的当前坐标加上总位移
-      final targetRefRow = p1Ref!.curRow + dMoveRow;
-      final targetRefCol = p1Ref!.curCol + dMoveCol;
-
-      // 获取目标槽位的中心点
-      final targetTransform = board!.getTransformByCoordinate(targetRefRow, targetRefCol);
-      final targetCenter = Offset(targetTransform.storage[12] + board!.pieceLogicalWidth / 2, targetTransform.storage[13] + board!.pieceLogicalHeight / 2);
-
-      // 引导:从当前位置拖拽到目标位置
-      final rectStart = Rect.fromCenter(center: p1Center, width: fingerSize, height: fingerSize);
-      final rectEnd = Rect.fromCenter(center: targetCenter, width: fingerSize, height: fingerSize);
-
-      _log.info(
-        'Hint: MERGE guidance for largest Entity (size: $bestSize) starting at ${p1Ref!.index} to grid ($targetRefRow, $targetRefCol). Merges with ${p2!.index}',
-      );
-      hintItem = HintItem(_fingerImage!, rectStart, rectEnd);
-    } else {
-      // 3. 找不到合并操作,回退到归位引导
-
-      // 沿用之前的逻辑:找一个未归位的单碎片,提示归位。
-      Piece? pRevert = board!.pieces.firstWhereOrNull((p) => !p.isOK && (p.group == null || p.group!.length == 1));
-
-      if (pRevert != null) {
-        final pRevertCenter = pRevert.group?.center ?? pRevert.currentCenter;
-
-        // 归位目标位置 (正确网格槽位的中心点)
-        final targetTransform = board!.getTransformByCoordinate(pRevert.row, pRevert.col);
-        final targetCenter = Offset(targetTransform.storage[12] + board!.pieceLogicalWidth / 2, targetTransform.storage[13] + board!.pieceLogicalHeight / 2);
-
-        // 如果当前中心点和目标中心点距离很近,不提示归位
-        if ((pRevertCenter - targetCenter).distanceSquared < pow(fingerSize * 2, 2)) {
-          _log.info('Hint: Revert target for Piece ${pRevert.index} too close. Skipping.');
-          return;
+      Future.delayed(const Duration(seconds: 3), () {
+        if (!mounted) return;
+        if (_overLayer != null && _overLayer!.isHinting) {
+          Fluttertoast.showToast(
+            msg: AppLocalizations.of(context)!.moveToComplete,
+            toastLength: Toast.LENGTH_SHORT,
+            gravity: ToastGravity.BOTTOM,
+            timeInSecForIosWeb: 1,
+            backgroundColor: SkinHelper.slotBorderColor,
+            textColor: Colors.white,
+            fontSize: 16.0,
+          );
         }
-
-        // 引导:从当前位置拖拽到正确网格中心
-        final rectStart = Rect.fromCenter(center: pRevertCenter, width: fingerSize, height: fingerSize);
-        final rectEnd = Rect.fromCenter(center: targetCenter, width: fingerSize, height: fingerSize);
-
-        _log.info('Hint: Revert guidance for Piece ${pRevert.index}');
-        hintItem = HintItem(_fingerImage!, rectStart, rectEnd);
-      }
-    }
-
-    // 4. 执行引导动画
-    if (hintItem != null) {
-      _overLayer?.doHint(hintItem);
-      Fluttertoast.showToast(
-        msg: AppLocalizations.of(context)!.moveToComplete,
-        toastLength: Toast.LENGTH_SHORT,
-        gravity: ToastGravity.BOTTOM,
-        timeInSecForIosWeb: 1,
-        backgroundColor: SkinHelper.slotBorderColor,
-        textColor: Colors.white,
-        fontSize: 16.0,
-      );
+      });
     }
   }
 
-  // 展现提示 (自动手势引导)
-  hint2() async {
-    _log.info('新手手势提示');
-
+  hint() async {
     // 使用私有字段 _fingerImage, _overLayer, _hintCount
     if (_fingerImage == null || _overLayer == null || board == null || board!.status != BoardStatus.playing) return;
 
-    // 1. 尝试寻找一个可以触发合并 (merge) 的拖拽操作 (p1 拖向 p2 的邻居槽位)
-    Piece? p1; // 拖拽起点 piece
-    Piece? p2; // 拖拽目标 piece (p1 将拖到 p2 的邻居槽位,从而实现合并)
-
-    // 遍历所有碎片,寻找可以作为拖拽起点 p1 的候选碎片:
-    // 仅考虑未归位的、单个碎片或群组的边缘碎片。
-    // 我们依然使用 (p.group == null || length == 1) 的逻辑来简化起点筛选,即只引导单个碎片。
-    for (final piece in board!.pieces) {
-      // 限制 p1 为未归位的单个碎片/群组 (修正后的 null/length == 1 检查)
-      if (piece.isOK || (piece.group != null && piece.group!.length > 1)) {
-        continue;
-      }
-
-      // 找到 piece 在原图上的所有邻居 (p2 的候选)
-      for (final neighborIndex in piece.getNeighbourIndexes()) {
-        final neighbor = board!.getPieceByIndex(neighborIndex);
-
-        if (neighbor == null) continue;
-
-        // 检查 p1 和 p2 是否满足合并的“原图条件”和“相对位置条件”
-        // canMerge() 依赖 isNeighbour(),所以 p1 和 p2 必须是原图邻居。
-        // 注意:canMerge() 也会检查 isCurNeighbour()。
-
-        // 核心逻辑:如果满足 canMerge,说明当前已经合并或即将自动合并,不需要提示。
-        // 我们需要找的是:原图相邻且相对位置正确,但 *当前不相邻* 的碎片。
-
-        // p1 和 p2 必须是原图邻居
-        if (!piece.isNeighbour(neighbor)) continue;
-
-        // 检查 p1 和 p2 的相对位置是否正确(确保可以合并)
-        final isRelativePositionCorrect =
-            (piece.col == neighbor.col && (piece.row - neighbor.row) == (piece.curRow - neighbor.curRow)) ||
-            (piece.row == neighbor.row && (piece.col - neighbor.col) == (piece.curCol - neighbor.curCol));
-
-        // 检查它们当前是否相邻
-        final isCurrentlyNeighbor = piece.isCurNeighbour(neighbor);
-
-        // 提示条件:原图是邻居 AND 相对位置正确 AND 当前不相邻
-        if (isRelativePositionCorrect && !isCurrentlyNeighbor) {
-          p1 = piece;
-          p2 = neighbor;
-          break; // 找到第一个非相邻的合并机会即可
-        }
-      }
-      if (p1 != null) break;
-    }
-
-    // 引导参数
-    const double fingerSize = 30.0;
-    HintItem? hintItem;
-
-    if (p1 != null && p2 != null) {
-      // 2. 执行“连接”引导 (p1 拖向 p2 所在的网格槽位,使其相邻)
-
-      // a. 拖拽起点:p1 群组的当前中心点
-      final p1Center = p1.group?.center ?? p1.currentCenter;
-
-      // b. 拖拽终点:p1 拖动后应到达的网格槽位的中心点。
-      // 这个目标槽位是 p2 当前所占网格槽位旁边的一个空槽位,该槽位应与 p1 在原图上的相对位置一致。
+    double fingerSize = board!.pieceLogicalWidth / 2.5;
 
-      // 确定 p1 应该移动到的目标网格坐标 (row, col)
-      int targetRow = p2.curRow;
-      int targetCol = p2.curCol;
-
-      // 根据 p1 和 p2 在原图上的相对位置,计算 p1 移动后应占领的网格槽位
-      // 目标网格坐标 = p2 的当前网格坐标 + (p1 的正确坐标 - p2 的正确坐标)
-      // 假设 p1(R:1, C:2) 和 p2(R:1, C:3) 是原图邻居。
-      // 相对位移: dR = 0, dC = -1.
-      // 如果 p2 当前在 (curR: 5, curC: 5), 那么 p1 应该移动到 (5, 5 + (-1)) = (5, 4)。
-
-      final int dRow = p1.row - p2.row; // p1 相对于 p2 的行差值 (-1, 0, 1)
-      final int dCol = p1.col - p2.col; // p1 相对于 p2 的列差值 (-1, 0, 1)
-
-      targetRow = p2.curRow + dRow;
-      targetCol = p2.curCol + dCol;
-
-      // 检查目标网格是否溢出边界(理论上不需要,因为 p2 在板上,dRow/dCol 只有 +/-1 或 0)
-      if (targetRow < 0 || targetRow >= board!.rows || targetCol < 0 || targetCol >= board!.cols) {
-        // 目标网格无效,跳过本次提示
-        _log.warning('Hint target coordinate ($targetRow, $targetCol) out of bounds. Skipping.');
-        return;
-      }
-
-      // 获取目标槽位的变换矩阵 (左上角坐标)
-      final targetTransform = board!.getTransformByCoordinate(targetRow, targetCol);
-      final targetCenter = Offset(targetTransform.storage[12] + board!.pieceLogicalWidth / 2, targetTransform.storage[13] + board!.pieceLogicalHeight / 2);
-
-      // 拖拽起点 Rect (中心在 p1Center)
-      final rectStart = Rect.fromCenter(center: p1Center, width: fingerSize, height: fingerSize);
-      // 拖拽终点 Rect (中心在 targetCenter)
-      final rectEnd = Rect.fromCenter(center: targetCenter, width: fingerSize, height: fingerSize);
-
-      _log.info('Hint: Merge guidance for Piece ${p1.index} to neighbour of Piece ${p2.index} at grid ($targetRow, $targetCol)');
-      hintItem = HintItem(_fingerImage!, rectStart, rectEnd);
-    } else {
-      // 3. 找不到合并操作,尝试执行“归位”引导 (将未归位的 piece 拖向正确槽位)
-      // 沿用之前的逻辑:找一个未归位的单碎片,提示归位。
-      Piece? pRevert = board!.pieces.firstWhereOrNull((p) => !p.isOK && (p.group == null || p.group!.length == 1));
-
-      if (pRevert != null) {
-        final pRevertCenter = pRevert.group?.center ?? pRevert.currentCenter;
-
-        // 归位目标位置 (正确网格槽位的中心点)
-        final targetTransform = board!.getTransformByCoordinate(pRevert.row, pRevert.col);
-        final targetCenter = Offset(targetTransform.storage[12] + board!.pieceLogicalWidth / 2, targetTransform.storage[13] + board!.pieceLogicalHeight / 2);
-
-        // 如果当前中心点和目标中心点距离很近,不提示归位
-        if ((pRevertCenter - targetCenter).distanceSquared < pow(fingerSize * 2, 2)) {
-          _log.info('Hint: Revert target for Piece ${pRevert.index} too close. Skipping.');
-          return;
-        }
+    // 固定的位置提示,因为首关我们已经编排好了, 不是随机排序
+    final Offset centerStart = Offset(
+      board!.targetRect.topLeft.dx + board!.pieceLogicalWidth,
+      board!.targetRect.topLeft.dy + board!.pieceLogicalHeight * 5 / 2,
+    );
+    final Offset centerEnd = Offset(board!.targetRect.topLeft.dx + board!.pieceLogicalWidth, board!.targetRect.topLeft.dy + board!.pieceLogicalHeight * 3 / 2);
 
-        // 引导:从当前位置拖拽到正确网格中心
-        final rectStart = Rect.fromCenter(center: pRevertCenter, width: fingerSize, height: fingerSize);
-        final rectEnd = Rect.fromCenter(center: targetCenter, width: fingerSize, height: fingerSize);
+    final rectStart = Rect.fromCenter(center: centerStart, width: fingerSize, height: fingerSize);
+    final rectEnd = Rect.fromCenter(center: centerEnd, width: fingerSize, height: fingerSize);
 
-        _log.info('Hint: Revert guidance for Piece ${pRevert.index}');
-        hintItem = HintItem(_fingerImage!, rectStart, rectEnd);
-      }
-    }
-
-    // 4. 执行引导动画
-    if (hintItem != null) {
-      _overLayer?.doHint(hintItem);
-    }
+    final hintItem = HintItem(_fingerImage!, rectStart, rectEnd);
+    _overLayer?.doHint(hintItem);
   }
 
   @override
@@ -1016,6 +717,8 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
 
   @override
   dispose() {
+    _log.info('dispose');
+
     timer.cancel();
     itemLoader.progress.removeListener(_onProgressUpdate);
 
@@ -1055,55 +758,80 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
     super.dispose();
   }
 
+  @override
+  onInactive() {
+    super.onInactive();
+    // 游戏进入后台,保存一下状态
+    saveProgress();
+  }
+
   /// gallery页面加载的时候,可能广告模块还没有初始化完毕
   Future<bool> _bannerReadyAndShouldShow() async {
     bool ready = await adSDKReady();
     return ready && shouldShowBannerAd(data.currentLevel);
   }
 
+  void _onWillPop(bool didPop, dynamic result) async {
+    _log.info('board play will pop, dipPop=$didPop, result=$result');
+    if (didPop) {
+      // 页面已经退出了
+    } else {
+      // 页面尚未退出
+      Future.delayed(const Duration(milliseconds: 100)).then((_) {
+        saveProgress();
+      });
+      if (!mounted) return;
+      Navigator.of(context).pop();
+    }
+  }
+
   @override
   Widget build(BuildContext context) {
     Device device = context.read<Device>();
 
-    return Scaffold(
-      body: Stack(
-        children: <Widget>[
-          if (!_isLoading) Positioned.fill(child: _buildPuzzleCanvas(device.screenSize.width, device.screenSize.height)),
-          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,
-            right: 0,
-            child: SafeArea(
-              child: SizedBox(
-                // 始终预留一个固定的高度,防止布局跳变
-                height: context.read<Device>().bannerHeight,
-                width: double.infinity,
-                child: FutureBuilder<bool>(
-                  future: _bannerReadyAndShouldShow(),
-                  builder: (context, snapshot) {
-                    if (snapshot.hasData && snapshot.data == true) {
-                      return adBanner;
-                    }
-                    return Container(
-                      // color: Colors.grey.shade100,
-                    );
-                  },
+    return PopScope(
+      canPop: false,
+      onPopInvokedWithResult: _onWillPop,
+      child: Scaffold(
+        body: Stack(
+          children: <Widget>[
+            if (!_isLoading) Positioned.fill(child: _buildPuzzleCanvas(device.screenSize.width, device.screenSize.height)),
+            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,
+              right: 0,
+              child: SafeArea(
+                child: SizedBox(
+                  // 始终预留一个固定的高度,防止布局跳变
+                  height: context.read<Device>().bannerHeight,
+                  width: double.infinity,
+                  child: FutureBuilder<bool>(
+                    future: _bannerReadyAndShouldShow(),
+                    builder: (context, snapshot) {
+                      if (snapshot.hasData && snapshot.data == true) {
+                        return adBanner;
+                      }
+                      return Container(
+                        // color: Colors.grey.shade100,
+                      );
+                    },
+                  ),
                 ),
               ),
             ),
-          ),
-          successBanner,
-          nextButton,
-          if (_isLoading)
-            Positioned.fill(
-              child: Container(
-                color: SkinHelper.wholeBgColor,
-                child: const Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(Colors.white))),
+            successBanner,
+            nextButton,
+            if (_isLoading)
+              Positioned.fill(
+                child: Container(
+                  color: SkinHelper.wholeBgColor,
+                  child: const Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(Colors.white))),
+                ),
               ),
-            ),
-        ],
+          ],
+        ),
       ),
     );
   }
@@ -1134,6 +862,7 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
               padding: EdgeInsets.zero, // 清除默认内边距,确保按钮尺寸准确
               onPressed: () {
                 audio.playSfx(SfxType.click);
+                saveProgress(); // 有可能会返回,保存一下进度
                 Navigator.push(context, SettingsDialog.buildRoute(showReturn: true, showRestart: true, item: widget.item));
               },
               style: ButtonStyle(
@@ -1231,13 +960,22 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
         gradient: LinearGradient(colors: [SkinHelper.coreBgColor, SkinHelper.slotBorderColor]),
         onPressed: () async {
           audio.playSfx(SfxType.click);
-          ///////////////// 播放插屏广告 //////////////////
-          audio.pauseMusic();
-          await showInterstitialAd('level_exit', widget.item.id, data.currentLevel);
-          audio.startMusic();
+          bool adResult = false;
+          if (data.currentLevel % 25 != 0) {
+            // 如果是合集最后的关卡, 这个时候不要展示插屏,因为接下来的合集解锁动画比较密集
+            adResult = await showInterstitialAd('level_done', widget.item.id, data.currentLevel - 1);
+          }
           if (!mounted) return;
-          //////////////// 插屏广告结束 /////////////////
-          Navigator.pop(context, true);
+          if (adResult) {
+            // 广告播放结束,延迟一下再返回
+            Future.delayed(Duration(milliseconds: 100), () {
+              if (mounted) {
+                Navigator.pop(context, true);
+              }
+            });
+          } else {
+            Navigator.pop(context, true);
+          }
         },
         child: Text(AppLocalizations.of(context)!.next, style: TextStyle(color: Colors.white, fontSize: 20)),
       ),
@@ -1268,7 +1006,7 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
           children: [
             // 1. 图片充满容器(与容器尺寸一致)
             Image.asset(
-              'assets/images/banner3.png',
+              'assets/images/banner.png',
               width: double.infinity, // 图片宽=容器宽
               height: double.infinity, // 图片高=容器高
               fit: BoxFit.cover, // 图片填充容器(不拉伸,超出部分裁剪)
@@ -1303,7 +1041,6 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
   void _onPanStart(DragStartDetails details) {
     _log.info('_onPanStart');
 
-    _lastInteractionTick = DateTime.now().millisecondsSinceEpoch;
     _overLayer?.stopHint();
 
     if (board!.status != BoardStatus.playing) {
@@ -1387,7 +1124,6 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
   }
 
   void _onPanUpdate(DragUpdateDetails details) {
-    _lastInteractionTick = DateTime.now().millisecondsSinceEpoch;
     _overLayer?.stopHint();
 
     if (_draggingPiece == null) return;
@@ -1410,7 +1146,6 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
   void _onPanEnd(DragEndDetails details) {
     _log.info('_onPanEnd');
 
-    _lastInteractionTick = DateTime.now().millisecondsSinceEpoch;
     _overLayer?.stopHint();
 
     if (_draggingPiece == null) {

+ 2 - 0
lib/play/piece.dart

@@ -558,6 +558,8 @@ class Piece {
   @override
   String toString() => 'Piece($index,$row:$col)';
 
+  Map<String, dynamic> toJson() => {'index': index, 'rows': rows, 'cols': cols, 'row': row, 'col': col, 'curRow': curRow, 'curCol': curCol};
+
   double get width => board.pieceLogicalWidth;
   double get height => board.pieceLogicalHeight;
 

+ 0 - 4
linux/flutter/generated_plugin_registrant.cc

@@ -6,14 +6,10 @@
 
 #include "generated_plugin_registrant.h"
 
-#include <audioplayers_linux/audioplayers_linux_plugin.h>
 #include <jc_audio_player/jc_audio_player_plugin.h>
 #include <url_launcher_linux/url_launcher_plugin.h>
 
 void fl_register_plugins(FlPluginRegistry* registry) {
-  g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
-      fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
-  audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
   g_autoptr(FlPluginRegistrar) jc_audio_player_registrar =
       fl_plugin_registry_get_registrar_for_plugin(registry, "JcAudioPlayerPlugin");
   jc_audio_player_plugin_register_with_registrar(jc_audio_player_registrar);

+ 0 - 1
linux/flutter/generated_plugins.cmake

@@ -3,7 +3,6 @@
 #
 
 list(APPEND FLUTTER_PLUGIN_LIST
-  audioplayers_linux
   jc_audio_player
   url_launcher_linux
 )

+ 0 - 2
macos/Flutter/GeneratedPluginRegistrant.swift

@@ -5,7 +5,6 @@
 import FlutterMacOS
 import Foundation
 
-import audioplayers_darwin
 import device_info_plus
 import firebase_analytics
 import firebase_core
@@ -22,7 +21,6 @@ import sqflite_darwin
 import url_launcher_macos
 
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
-  AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
   DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
   FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin"))
   FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))

+ 2 - 74
pubspec.lock

@@ -9,14 +9,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.3.64"
-  advertising_id:
-    dependency: "direct main"
-    description:
-      name: advertising_id
-      sha256: ab06ee85203ab500be85b7f45de2a75a629d8d9c453dba779276fbc4e97ad8d3
-      url: "https://pub.dev"
-    source: hosted
-    version: "2.7.1"
   ansicolor:
     dependency: transitive
     description:
@@ -37,10 +29,10 @@ packages:
     dependency: "direct main"
     description:
       name: applovin_max
-      sha256: "25c6dfdac6e6a641b9c54945032c11d23bafe8858e7dbb2c470d405e51c9a585"
+      sha256: "3c89ceb7cbd7f970d1a29680d29eb99731f415bdc79b4c9936b7f51721d37a6b"
       url: "https://pub.dev"
     source: hosted
-    version: "4.6.1"
+    version: "3.11.1"
   archive:
     dependency: transitive
     description:
@@ -65,62 +57,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.13.0"
-  audioplayers:
-    dependency: "direct main"
-    description:
-      name: audioplayers
-      sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4"
-      url: "https://pub.dev"
-    source: hosted
-    version: "6.5.1"
-  audioplayers_android:
-    dependency: transitive
-    description:
-      name: audioplayers_android
-      sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
-      url: "https://pub.dev"
-    source: hosted
-    version: "5.2.1"
-  audioplayers_darwin:
-    dependency: transitive
-    description:
-      name: audioplayers_darwin
-      sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333"
-      url: "https://pub.dev"
-    source: hosted
-    version: "6.3.0"
-  audioplayers_linux:
-    dependency: transitive
-    description:
-      name: audioplayers_linux
-      sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
-      url: "https://pub.dev"
-    source: hosted
-    version: "4.2.1"
-  audioplayers_platform_interface:
-    dependency: transitive
-    description:
-      name: audioplayers_platform_interface
-      sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
-      url: "https://pub.dev"
-    source: hosted
-    version: "7.1.1"
-  audioplayers_web:
-    dependency: transitive
-    description:
-      name: audioplayers_web
-      sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7"
-      url: "https://pub.dev"
-    source: hosted
-    version: "5.1.1"
-  audioplayers_windows:
-    dependency: transitive
-    description:
-      name: audioplayers_windows
-      sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7"
-      url: "https://pub.dev"
-    source: hosted
-    version: "4.2.1"
   boolean_selector:
     dependency: transitive
     description:
@@ -469,14 +405,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "9.0.0"
-  google_fonts:
-    dependency: "direct main"
-    description:
-      name: google_fonts
-      sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c"
-      url: "https://pub.dev"
-    source: hosted
-    version: "6.3.2"
   html:
     dependency: transitive
     description:

+ 2 - 5
pubspec.yaml

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 # In Windows, build-name is used as the major, minor, and patch parts
 # of the product and file versions while build-number is used as the build suffix.
-version: 1.0.1+2
+version: 1.0.3+4
 
 environment:
   sdk: ^3.8.1
@@ -54,17 +54,14 @@ dependencies:
   fluttertoast: ^9.0.0
   lottie: ^3.3.1
   cached_network_image: ^3.4.1
-  applovin_max: ^4.6.1
+  applovin_max: ^3.10.0
   firebase_core: ^4.2.1
   firebase_analytics: ^12.0.4
   firebase_remote_config: ^6.1.2
   firebase_crashlytics: ^5.0.5
   firebase_messaging: ^16.0.4
   url_launcher: ^6.3.2
-  google_fonts: ^6.3.2
   app_tracking_transparency: ^2.0.6+1
-  advertising_id: ^2.7.1
-  audioplayers: ^6.5.1
   jc_audio_player:
     version: ^1.0.0
     git:

+ 0 - 3
windows/flutter/generated_plugin_registrant.cc

@@ -6,15 +6,12 @@
 
 #include "generated_plugin_registrant.h"
 
-#include <audioplayers_windows/audioplayers_windows_plugin.h>
 #include <firebase_core/firebase_core_plugin_c_api.h>
 #include <jc_audio_player/jc_audio_player_plugin_c_api.h>
 #include <share_plus/share_plus_windows_plugin_c_api.h>
 #include <url_launcher_windows/url_launcher_windows.h>
 
 void RegisterPlugins(flutter::PluginRegistry* registry) {
-  AudioplayersWindowsPluginRegisterWithRegistrar(
-      registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
   FirebaseCorePluginCApiRegisterWithRegistrar(
       registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
   JcAudioPlayerPluginCApiRegisterWithRegistrar(

+ 0 - 1
windows/flutter/generated_plugins.cmake

@@ -3,7 +3,6 @@
 #
 
 list(APPEND FLUTTER_PLUGIN_LIST
-  audioplayers_windows
   firebase_core
   jc_audio_player
   share_plus