diff --git a/README.md b/README.md index 44d8e14..01f8164 100644 --- a/README.md +++ b/README.md @@ -42,16 +42,28 @@ freader-media-player(FMP Player),一个使用 flutter 开发的简单的本地 ## 更新说明 +### 2024-02-01 主要更新 + +- feat:添加了扫雷小游戏 + - 更多参看对应模块的 [readme](lib/views/game_center/minesweeper/readme.md) 。 + +### 2024-01-31 主要更新 + +- feat:添加了恐龙快跑小游戏 + - 更多参看对应模块的 [readme](lib/views/game_center/t-rex_dinosaur/readme.md) 。 +- feat:添加了贪吃蛇小游戏 + - 更多参看对应模块的 [readme](lib/views/game_center/snake/readme.md) 。 + ### 2024-01-30 主要更新 - feat:添加了俄罗斯方块小游戏 - - 更多参看对应模块的[readme](lib/views/game_center/tetris/readme.md). + - 更多参看对应模块的 [readme](lib/views/game_center/tetris/readme.md) 。 ### 2024-01-29 主要更新 - feat:添加了 2048 小游戏 - - 更多参看对应模块的[readme](lib/views/game_center/flutter_2048/readme.md). - - 添加了休闲游戏模块后,原本的“本地图片”和“本地视频”模块就初始默认隐藏,同样长按退出弹窗正文可切换. + - 更多参看对应模块的 [readme](lib/views/game_center/flutter_2048/readme.md) 。 + - 添加了休闲游戏模块后,原本的“本地图片”和“本地视频”模块就初始默认隐藏,同样长按退出弹窗正文可切换。 ### 2024-01-26 主要更新 diff --git a/assets/games/cover-dinosaur.jpg b/assets/games/cover-dinosaur.jpg new file mode 100755 index 0000000..5999c8a Binary files /dev/null and b/assets/games/cover-dinosaur.jpg differ diff --git a/assets/games/cover-minesweeper.jpg b/assets/games/cover-minesweeper.jpg new file mode 100755 index 0000000..8b147aa Binary files /dev/null and b/assets/games/cover-minesweeper.jpg differ diff --git a/assets/games/cover-snake.jpg b/assets/games/cover-snake.jpg new file mode 100755 index 0000000..b9af94e Binary files /dev/null and b/assets/games/cover-snake.jpg differ diff --git a/assets/games/cover-sudoku.png b/assets/games/cover-sudoku.png new file mode 100755 index 0000000..7a4eb16 Binary files /dev/null and b/assets/games/cover-sudoku.png differ diff --git a/assets/games/dinosaur/cacti/cacti_group.png b/assets/games/dinosaur/cacti/cacti_group.png new file mode 100755 index 0000000..795fa22 Binary files /dev/null and b/assets/games/dinosaur/cacti/cacti_group.png differ diff --git a/assets/games/dinosaur/cacti/cacti_large_1.png b/assets/games/dinosaur/cacti/cacti_large_1.png new file mode 100755 index 0000000..d002ac8 Binary files /dev/null and b/assets/games/dinosaur/cacti/cacti_large_1.png differ diff --git a/assets/games/dinosaur/cacti/cacti_large_2.png b/assets/games/dinosaur/cacti/cacti_large_2.png new file mode 100755 index 0000000..b2ec8c9 Binary files /dev/null and b/assets/games/dinosaur/cacti/cacti_large_2.png differ diff --git a/assets/games/dinosaur/cacti/cacti_small_1.png b/assets/games/dinosaur/cacti/cacti_small_1.png new file mode 100755 index 0000000..18534a4 Binary files /dev/null and b/assets/games/dinosaur/cacti/cacti_small_1.png differ diff --git a/assets/games/dinosaur/cacti/cacti_small_2.png b/assets/games/dinosaur/cacti/cacti_small_2.png new file mode 100755 index 0000000..9952701 Binary files /dev/null and b/assets/games/dinosaur/cacti/cacti_small_2.png differ diff --git a/assets/games/dinosaur/cacti/cacti_small_3.png b/assets/games/dinosaur/cacti/cacti_small_3.png new file mode 100755 index 0000000..48353c8 Binary files /dev/null and b/assets/games/dinosaur/cacti/cacti_small_3.png differ diff --git a/assets/games/dinosaur/cloud.png b/assets/games/dinosaur/cloud.png new file mode 100755 index 0000000..f6fd638 Binary files /dev/null and b/assets/games/dinosaur/cloud.png differ diff --git a/assets/games/dinosaur/dino/dino_1.png b/assets/games/dinosaur/dino/dino_1.png new file mode 100755 index 0000000..b065319 Binary files /dev/null and b/assets/games/dinosaur/dino/dino_1.png differ diff --git a/assets/games/dinosaur/dino/dino_2.png b/assets/games/dinosaur/dino/dino_2.png new file mode 100755 index 0000000..2b2b33f Binary files /dev/null and b/assets/games/dinosaur/dino/dino_2.png differ diff --git a/assets/games/dinosaur/dino/dino_3.png b/assets/games/dinosaur/dino/dino_3.png new file mode 100755 index 0000000..6ba88a0 Binary files /dev/null and b/assets/games/dinosaur/dino/dino_3.png differ diff --git a/assets/games/dinosaur/dino/dino_4.png b/assets/games/dinosaur/dino/dino_4.png new file mode 100755 index 0000000..872142a Binary files /dev/null and b/assets/games/dinosaur/dino/dino_4.png differ diff --git a/assets/games/dinosaur/dino/dino_5.png b/assets/games/dinosaur/dino/dino_5.png new file mode 100755 index 0000000..3dc22d1 Binary files /dev/null and b/assets/games/dinosaur/dino/dino_5.png differ diff --git a/assets/games/dinosaur/dino/dino_6.png b/assets/games/dinosaur/dino/dino_6.png new file mode 100755 index 0000000..9a7551c Binary files /dev/null and b/assets/games/dinosaur/dino/dino_6.png differ diff --git a/assets/games/dinosaur/dino_all.png b/assets/games/dinosaur/dino_all.png new file mode 100755 index 0000000..5aab52f Binary files /dev/null and b/assets/games/dinosaur/dino_all.png differ diff --git a/assets/games/dinosaur/ground.png b/assets/games/dinosaur/ground.png new file mode 100755 index 0000000..be13348 Binary files /dev/null and b/assets/games/dinosaur/ground.png differ diff --git a/assets/games/dinosaur/ptera/ptera_1.png b/assets/games/dinosaur/ptera/ptera_1.png new file mode 100755 index 0000000..79895d3 Binary files /dev/null and b/assets/games/dinosaur/ptera/ptera_1.png differ diff --git a/assets/games/dinosaur/ptera/ptera_2.png b/assets/games/dinosaur/ptera/ptera_2.png new file mode 100755 index 0000000..e7cd081 Binary files /dev/null and b/assets/games/dinosaur/ptera/ptera_2.png differ diff --git a/assets/games/minesweeper/audio/blue.wav b/assets/games/minesweeper/audio/blue.wav new file mode 100755 index 0000000..0d28c5e Binary files /dev/null and b/assets/games/minesweeper/audio/blue.wav differ diff --git a/assets/games/minesweeper/audio/clickEmpty.wav b/assets/games/minesweeper/audio/clickEmpty.wav new file mode 100755 index 0000000..b6fb861 Binary files /dev/null and b/assets/games/minesweeper/audio/clickEmpty.wav differ diff --git a/assets/games/minesweeper/audio/clickFour.wav b/assets/games/minesweeper/audio/clickFour.wav new file mode 100755 index 0000000..84759cb Binary files /dev/null and b/assets/games/minesweeper/audio/clickFour.wav differ diff --git a/assets/games/minesweeper/audio/clickOne.wav b/assets/games/minesweeper/audio/clickOne.wav new file mode 100755 index 0000000..82837be Binary files /dev/null and b/assets/games/minesweeper/audio/clickOne.wav differ diff --git a/assets/games/minesweeper/audio/clickThree.wav b/assets/games/minesweeper/audio/clickThree.wav new file mode 100755 index 0000000..97a635d Binary files /dev/null and b/assets/games/minesweeper/audio/clickThree.wav differ diff --git a/assets/games/minesweeper/audio/clickTwo.wav b/assets/games/minesweeper/audio/clickTwo.wav new file mode 100755 index 0000000..e7b1a93 Binary files /dev/null and b/assets/games/minesweeper/audio/clickTwo.wav differ diff --git a/assets/games/minesweeper/audio/lastHit.wav b/assets/games/minesweeper/audio/lastHit.wav new file mode 100755 index 0000000..bbc1f7a Binary files /dev/null and b/assets/games/minesweeper/audio/lastHit.wav differ diff --git a/assets/games/minesweeper/audio/lose.wav b/assets/games/minesweeper/audio/lose.wav new file mode 100755 index 0000000..559197b Binary files /dev/null and b/assets/games/minesweeper/audio/lose.wav differ diff --git a/assets/games/minesweeper/audio/pink.wav b/assets/games/minesweeper/audio/pink.wav new file mode 100755 index 0000000..6fb1be4 Binary files /dev/null and b/assets/games/minesweeper/audio/pink.wav differ diff --git a/assets/games/minesweeper/audio/purple.wav b/assets/games/minesweeper/audio/purple.wav new file mode 100755 index 0000000..655f62a Binary files /dev/null and b/assets/games/minesweeper/audio/purple.wav differ diff --git a/assets/games/minesweeper/audio/putFlag.wav b/assets/games/minesweeper/audio/putFlag.wav new file mode 100755 index 0000000..5a18316 Binary files /dev/null and b/assets/games/minesweeper/audio/putFlag.wav differ diff --git a/assets/games/minesweeper/audio/removeFlag.wav b/assets/games/minesweeper/audio/removeFlag.wav new file mode 100755 index 0000000..3753cf0 Binary files /dev/null and b/assets/games/minesweeper/audio/removeFlag.wav differ diff --git a/assets/games/minesweeper/audio/win.wav b/assets/games/minesweeper/audio/win.wav new file mode 100755 index 0000000..9746298 Binary files /dev/null and b/assets/games/minesweeper/audio/win.wav differ diff --git a/assets/games/minesweeper/images/flag.png b/assets/games/minesweeper/images/flag.png new file mode 100755 index 0000000..9c28976 Binary files /dev/null and b/assets/games/minesweeper/images/flag.png differ diff --git a/assets/games/minesweeper/images/homeScreenBg.png b/assets/games/minesweeper/images/homeScreenBg.png new file mode 100755 index 0000000..f4d31b5 Binary files /dev/null and b/assets/games/minesweeper/images/homeScreenBg.png differ diff --git a/assets/games/minesweeper/images/how_to_play/how_to_play_1.png b/assets/games/minesweeper/images/how_to_play/how_to_play_1.png new file mode 100755 index 0000000..cef4722 Binary files /dev/null and b/assets/games/minesweeper/images/how_to_play/how_to_play_1.png differ diff --git a/assets/games/minesweeper/images/how_to_play/how_to_play_2.png b/assets/games/minesweeper/images/how_to_play/how_to_play_2.png new file mode 100755 index 0000000..3939971 Binary files /dev/null and b/assets/games/minesweeper/images/how_to_play/how_to_play_2.png differ diff --git a/assets/games/minesweeper/images/how_to_play/how_to_play_3.png b/assets/games/minesweeper/images/how_to_play/how_to_play_3.png new file mode 100755 index 0000000..c5c5182 Binary files /dev/null and b/assets/games/minesweeper/images/how_to_play/how_to_play_3.png differ diff --git a/assets/games/minesweeper/images/how_to_play/how_to_play_4.png b/assets/games/minesweeper/images/how_to_play/how_to_play_4.png new file mode 100755 index 0000000..5caa782 Binary files /dev/null and b/assets/games/minesweeper/images/how_to_play/how_to_play_4.png differ diff --git a/assets/games/minesweeper/images/how_to_play/how_to_play_5.png b/assets/games/minesweeper/images/how_to_play/how_to_play_5.png new file mode 100755 index 0000000..fe14956 Binary files /dev/null and b/assets/games/minesweeper/images/how_to_play/how_to_play_5.png differ diff --git a/assets/games/minesweeper/images/logo.png b/assets/games/minesweeper/images/logo.png new file mode 100755 index 0000000..aebe9fb Binary files /dev/null and b/assets/games/minesweeper/images/logo.png differ diff --git a/assets/games/minesweeper/images/loseScreen.png b/assets/games/minesweeper/images/loseScreen.png new file mode 100755 index 0000000..97c2793 Binary files /dev/null and b/assets/games/minesweeper/images/loseScreen.png differ diff --git a/assets/games/minesweeper/images/pickaxe.png b/assets/games/minesweeper/images/pickaxe.png new file mode 100755 index 0000000..dcb4dca Binary files /dev/null and b/assets/games/minesweeper/images/pickaxe.png differ diff --git a/assets/games/minesweeper/images/pickaxe_old.png b/assets/games/minesweeper/images/pickaxe_old.png new file mode 100755 index 0000000..485624e Binary files /dev/null and b/assets/games/minesweeper/images/pickaxe_old.png differ diff --git a/assets/games/minesweeper/images/redCross.png b/assets/games/minesweeper/images/redCross.png new file mode 100755 index 0000000..6dea4db Binary files /dev/null and b/assets/games/minesweeper/images/redCross.png differ diff --git a/assets/games/minesweeper/images/stopwatch.png b/assets/games/minesweeper/images/stopwatch.png new file mode 100755 index 0000000..d7b03be Binary files /dev/null and b/assets/games/minesweeper/images/stopwatch.png differ diff --git a/assets/games/minesweeper/images/trophy.png b/assets/games/minesweeper/images/trophy.png new file mode 100755 index 0000000..d375dfd Binary files /dev/null and b/assets/games/minesweeper/images/trophy.png differ diff --git a/assets/games/minesweeper/images/winScreen.png b/assets/games/minesweeper/images/winScreen.png new file mode 100755 index 0000000..2ade7a2 Binary files /dev/null and b/assets/games/minesweeper/images/winScreen.png differ diff --git a/assets/games/sodoku/audio/answer_tip.mp3 b/assets/games/sodoku/audio/answer_tip.mp3 new file mode 100755 index 0000000..37e3cac Binary files /dev/null and b/assets/games/sodoku/audio/answer_tip.mp3 differ diff --git a/assets/games/sodoku/audio/gameover_tip.mp3 b/assets/games/sodoku/audio/gameover_tip.mp3 new file mode 100755 index 0000000..dcfee83 Binary files /dev/null and b/assets/games/sodoku/audio/gameover_tip.mp3 differ diff --git a/assets/games/sodoku/audio/victory_tip.mp3 b/assets/games/sodoku/audio/victory_tip.mp3 new file mode 100755 index 0000000..01004f0 Binary files /dev/null and b/assets/games/sodoku/audio/victory_tip.mp3 differ diff --git a/assets/games/sodoku/audio/wrong_tip.mp3 b/assets/games/sodoku/audio/wrong_tip.mp3 new file mode 100755 index 0000000..9d64120 Binary files /dev/null and b/assets/games/sodoku/audio/wrong_tip.mp3 differ diff --git a/assets/games/sodoku/image/about_me.jpg b/assets/games/sodoku/image/about_me.jpg new file mode 100755 index 0000000..147cab5 Binary files /dev/null and b/assets/games/sodoku/image/about_me.jpg differ diff --git a/assets/games/sodoku/image/icon_eraser.png b/assets/games/sodoku/image/icon_eraser.png new file mode 100755 index 0000000..f96d660 Binary files /dev/null and b/assets/games/sodoku/image/icon_eraser.png differ diff --git a/assets/games/sodoku/image/icon_idea.png b/assets/games/sodoku/image/icon_idea.png new file mode 100755 index 0000000..160259a Binary files /dev/null and b/assets/games/sodoku/image/icon_idea.png differ diff --git a/assets/games/sodoku/image/icon_life.png b/assets/games/sodoku/image/icon_life.png new file mode 100755 index 0000000..09c4739 Binary files /dev/null and b/assets/games/sodoku/image/icon_life.png differ diff --git a/assets/games/sodoku/image/logo.png b/assets/games/sodoku/image/logo.png new file mode 100755 index 0000000..34354d1 Binary files /dev/null and b/assets/games/sodoku/image/logo.png differ diff --git a/assets/games/sodoku/image/sudoku_icon.png b/assets/games/sodoku/image/sudoku_icon.png new file mode 100755 index 0000000..e791b36 Binary files /dev/null and b/assets/games/sodoku/image/sudoku_icon.png differ diff --git a/assets/games/sodoku/image/sudoku_logo.png b/assets/games/sodoku/image/sudoku_logo.png new file mode 100755 index 0000000..4cd66b9 Binary files /dev/null and b/assets/games/sodoku/image/sudoku_logo.png differ diff --git a/assets/games/sodoku/svg/idea.svg b/assets/games/sodoku/svg/idea.svg new file mode 100755 index 0000000..70414b4 --- /dev/null +++ b/assets/games/sodoku/svg/idea.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/common/global/constants.dart b/lib/common/global/constants.dart index 220853b..444fcb9 100644 --- a/lib/common/global/constants.dart +++ b/lib/common/global/constants.dart @@ -14,6 +14,10 @@ class GlobalConstants { const String placeholderImageUrl = 'assets/launch_background.png'; const String cover2048ImageUrl = 'assets/games/cover-2048.jpg'; const String coverTetrisImageUrl = 'assets/games/cover-tetris.jpg'; +const String coverDinosaurImageUrl = 'assets/games/cover-dinosaur.jpg'; +const String coverSnakeImageUrl = 'assets/games/cover-snake.jpg'; +const String coverMinesweeperImageUrl = 'assets/games/cover-minesweeper.jpg'; +const String coverSudokuImageUrl = 'assets/games/cover-sudoku.png'; /* // 音频播放列表支持的类型,使用扩展可以直接比较属性值 diff --git a/lib/layout/home.dart b/lib/layout/home.dart index 36a1d05..6e99730 100644 --- a/lib/layout/home.dart +++ b/lib/layout/home.dart @@ -14,15 +14,24 @@ import '../views/local_video/index.dart'; /// 主页面 class HomePage extends StatefulWidget { - const HomePage({super.key}); + const HomePage({super.key, this.selectedIndex}); + + // 2024-02-01 新加可以指定默认选中的底部导航索引 + // 主要是游戏中心的扫雷游戏,退出游戏界面后是使用pushAndRemoveUntil导航到home页面, + // 所以可以指定索引以便显示的是正确的游戏中心而不是初始化的第一个导航栏 + final int? selectedIndex; @override State createState() => _HomePageState(); } class _HomePageState extends State { - // 默认选中第一个底部导航条目 - int _selectedIndex = 0; + // // 当前选中项的索引 默认选中第一个底部导航条目 + int _currentIndex = 0; + // 2024-02-02 新加记录上一个底部导航索引,如果是从休闲游戏模块切换到其他模块。需要重新构建音频播放列表 + // 因为目前游戏中心的背景音乐播放器和本地音乐模块播放器是同一个,因为背景播放插件的限制 + // 上一个选中项的索引 + int _previousIndex = 0; final _audioHandler = getIt(); // 统一简单存储操作的工具类实例 @@ -44,6 +53,10 @@ class _HomePageState extends State { // 2024-01-25 根据缓存值显示底部导航条目数量 changeBottomNavItemNum(); + + if (widget.selectedIndex != null) { + _currentIndex = widget.selectedIndex!; + } } /// 2024-01-25 彩蛋功能,根据缓存展示底部导航栏条目的数量 @@ -51,7 +64,7 @@ class _HomePageState extends State { changeBottomNavItemNum() { setState(() { // 2024-01-25 注意,因为可能在4切换成2的时候,当前标签tab在2或者3,那就找不到对应的了。所以默认都改成第一个。 - _selectedIndex = 0; + _currentIndex = 0; var num = _simpleStorage.getBottomNavItemMun(); @@ -106,7 +119,23 @@ class _HomePageState extends State { void _onItemTapped(int index) { setState(() { - _selectedIndex = index; + _previousIndex = _currentIndex; // 更新上一个索引值 + _currentIndex = index; // 更新当前索引值 + + // 2024-02-02 + // 检查是否是从游戏中心tab切换到其他tab + int gameCenterIndex = _simpleStorage.getBottomNavItemMun() > 3 ? 4 : 2; + if (_previousIndex == gameCenterIndex && + _currentIndex != _previousIndex) { + initAudio(); + } + + // 如果是从其他tab进入游戏中心,则暂停音乐播放 + var num = _simpleStorage.getBottomNavItemMun(); + if (num > 3 ? _currentIndex == 4 : _currentIndex == 2) { + // 默认是暂停状态 + _audioHandler.pause(); + } }); } @@ -198,11 +227,11 @@ class _HomePageState extends State { ], ), ) - : Center(child: _widgetOptions.elementAt(_selectedIndex)), + : Center(child: _widgetOptions.elementAt(_currentIndex)), bottomNavigationBar: BottomNavigationBar( type: BottomNavigationBarType.fixed, items: bottomNavBarItems, - currentIndex: _selectedIndex, + currentIndex: _currentIndex, // // 底部导航栏的颜色 // backgroundColor: Theme.of(context).primaryColor, // // 被选中的item的图标颜色和文本颜色 diff --git a/lib/services/my_get_storage.dart b/lib/services/my_get_storage.dart index d2b26f0..07b8294 100644 --- a/lib/services/my_get_storage.dart +++ b/lib/services/my_get_storage.dart @@ -108,4 +108,18 @@ class MyGetStorage { } int? getTetrisBestScore() => box.read("gameTetrisBestScore"); + + /// 2024-01-31 恐龙游戏保存获取历史最高分 + Future setDinosaurBestScore(int score) async { + await box.write("gameDinosaurBestScore", score); + } + + int? getDinosaurBestScore() => box.read("gameDinosaurBestScore"); + + /// 2024-01-31 贪吃蛇小游戏保存获取历史最高分 + Future setSnakeBestScore(int score) async { + await box.write("gameSnakeBestScore", score); + } + + int? getSnakeBestScore() => box.read("gameSnakeBestScore"); } diff --git a/lib/views/game_center/flutter_2048/managers/board.dart b/lib/views/game_center/flutter_2048/managers/board.dart index c41e886..976f9fa 100755 --- a/lib/views/game_center/flutter_2048/managers/board.dart +++ b/lib/views/game_center/flutter_2048/managers/board.dart @@ -56,7 +56,9 @@ class BoardManager extends StateNotifier { // 创建一个新游戏棋盘状态 Board _newGame() { - return Board.newGame(state.best + state.score, state.bestNum, [random([])]); + // return Board.newGame(state.best + state.score, state.bestNum, [random([])]); + // 2024-02-19 初始化的最佳分数不应该包含上次得分,需要在游戏结束时更新最佳得分 + return Board.newGame(state.best, state.bestNum, [random([])]); } // 开始新游戏 @@ -364,6 +366,7 @@ class BoardManager extends StateNotifier { state = state.copyWith( tiles: tiles, + best: max(state.score, state.best), bestNum: max(maxValue, state.bestNum), won: gameWon, over: gameOver, diff --git a/lib/views/game_center/index.dart b/lib/views/game_center/index.dart index ec315ac..dce0a44 100644 --- a/lib/views/game_center/index.dart +++ b/lib/views/game_center/index.dart @@ -6,6 +6,11 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../../common/global/constants.dart'; import 'flutter_2048/index.dart'; +import 'minesweeper/index.dart'; + +import 'snake/index.dart'; +import 'sudoku/index.dart'; +import 't-rex_dinosaur/index.dart'; import 'tetris/index.dart'; class GameCenter extends StatefulWidget { @@ -46,7 +51,7 @@ class _GameCenterState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Expanded(flex: 1, child: Container()), + // Expanded(flex: 1, child: Container()), Expanded( flex: 2, child: Row( @@ -70,7 +75,53 @@ class _GameCenterState extends State { ], ), ), - Expanded(flex: 1, child: Container()), + Expanded( + flex: 2, + child: Row( + children: [ + Expanded( + child: buildCoverCardColumn( + context, + const TRexDinosaur(), + "恐龙快跑", + imageUrl: coverDinosaurImageUrl, + ), + ), + Expanded( + child: buildCoverCardColumn( + context, + const SnakeGame(), + "贪吃蛇", + imageUrl: coverSnakeImageUrl, + ), + ) + ], + ), + ), + Expanded( + flex: 2, + child: Row( + children: [ + Expanded( + child: buildCoverCardColumn( + context, + const InitMinesweeper(), + "扫雷", + imageUrl: coverMinesweeperImageUrl, + ), + ), + Expanded( + child: buildCoverCardColumn( + context, + const InitSudoku(), + "数独", + imageUrl: coverSudokuImageUrl, + ), + ) + ], + ), + ), + // Expanded(flex: 1, child: Container()), ], ), ); @@ -99,11 +150,13 @@ buildCoverCardColumn( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Padding( - padding: EdgeInsets.all(5.sp), - child: Image.asset( - imageUrl ?? placeholderImageUrl, - fit: BoxFit.contain, + Expanded( + child: Padding( + padding: EdgeInsets.all(5.sp), + child: Image.asset( + imageUrl ?? placeholderImageUrl, + fit: BoxFit.scaleDown, + ), ), ), Text( diff --git a/lib/views/game_center/minesweeper/controller/game_controller.dart b/lib/views/game_center/minesweeper/controller/game_controller.dart new file mode 100755 index 0000000..055439a --- /dev/null +++ b/lib/views/game_center/minesweeper/controller/game_controller.dart @@ -0,0 +1,414 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import '../helper/audio_player.dart'; +import '../helper/shared_helper.dart'; +import '../model/tile_model.dart'; +import '../utils/game_consts.dart'; +import '../utils/game_sounds.dart'; +import '../view/home_view/home_view.dart'; +import '../widgets/game_popup_screen.dart'; + +class GameController extends ChangeNotifier { + late GameAudioPlayer _audioPlayer; + SharedHelper? _sharedHelper; + + GameController() { + _audioPlayer = GameAudioPlayer(); + _createGameBoard(); + } + + /// The game board matrix / minefield + final List> _mineField = []; + List> get mineField => _mineField; + + int _boardLength = 10; + int get boardLength => _boardLength; + + int _flagCount = 15; + int get flagCount => _flagCount; + + int _mineCount = 15; + int _openedTileCount = 0; + + int _timeElapsed = 0; + int get timeElapsed => _timeElapsed; + + bool _gameHasStarted = false; + bool get gameHasStarted => _gameHasStarted; + bool _gameOver = false; + bool _minesAnimation = false; + + bool get isMineAnimationOn => _minesAnimation; + + set minesAnimation(bool value) { + _minesAnimation = value; + notifyListeners(); + } + + bool _volumeOn = true; + + /// Volume setting (on/off) + bool get volumeOn => _volumeOn; + + /// Volume setting setter + set changeVolumeSetting(bool value) { + _volumeOn = value; + GameAudioPlayer.setVolume(_volumeOn); + notifyListeners(); + } + + /// Game difficulty setting. + /// This setting determines the matrix size and number of mines + GameMode _gameMode = GameMode.easy; + GameMode get gameMode => _gameMode; + + /// Game mode setter + set gameMode(GameMode mode) { + _gameMode = mode; + _boardLength = getBoardLength(_gameMode); + _mineCount = mineCount(_gameMode); + resetGame(); + createNewGame(); + } + + /// Starts the timer + void _startTimer() { + Timer.periodic(const Duration(seconds: 1), (timer) { + if (!_gameHasStarted || _gameOver || _timeElapsed >= 999) { + timer.cancel(); + return; + } + _timeElapsed++; + notifyListeners(); + }); + } + + List findMines() { + List mines = []; + + for (List row in mineField) { + for (Tile tile in row) { + if (!tile.visible && tile.hasMine && !tile.hasFlag) { + mines.add(tile); + } + } + } + + return mines; + } + + List findMissPlacesFlags() { + List missPlacesFlags = []; + + for (List row in mineField) { + for (Tile tile in row) { + if (!tile.visible && !tile.hasMine && tile.hasFlag) { + missPlacesFlags.add(tile); + } + } + } + + return missPlacesFlags; + } + + /// Makes all mines visible + Future showAllMines() async { + List mines = findMines(); + mines.shuffle(); + + await Future.delayed(const Duration(milliseconds: 300)); + + _minesAnimation = true; + + var rnd = Random(); + + for (var mine in mines) { + int r = mine.row; + int c = mine.col; + + mineField[r][c].setVisible = true; + if (_minesAnimation) { + notifyListeners(); + await _audioPlayer.playAudio(GameSounds.mineSound[rnd.nextInt(3)]); + await Future.delayed(const Duration(milliseconds: 300)); + } + } + notifyListeners(); + + await showMissPlacesFlags(); + + _minesAnimation = false; + } + + Future showMissPlacesFlags() async { + if (_minesAnimation) { + await Future.delayed(const Duration(milliseconds: 500)); + } + List missPlacesFlags = findMissPlacesFlags(); + for (var mine in missPlacesFlags) { + int r = mine.row; + int c = mine.col; + + mineField[r][c].setVisible = true; + notifyListeners(); + } + if (missPlacesFlags.isNotEmpty && _minesAnimation) { + await _audioPlayer.playAudio(GameSounds.removeFlag); + await Future.delayed(const Duration(milliseconds: 1500)); + } + } + + /// Creates empty board + void _createGameBoard() { + for (var i = 0; i < _boardLength; i++) { + _mineField.add([]); + for (var j = 0; j < 10; j++) { + _mineField[i].add(Tile(i, j)); + } + } + } + + /// Creates a new game + void createNewGame() { + resetGame(); + _createGameBoard(); + notifyListeners(); + } + + /// Game start function + void startGame(Tile tile) { + _gameHasStarted = true; + _addGameStartLog(); + _startTimer(); + _placeMines(tile); + _openTile(tile.row, tile.col, playSound: true); + } + + Future _addGameStartLog() async { + _sharedHelper ??= await SharedHelper.init(); + + await _sharedHelper?.increaseGamesStarted(gameMode); + } + + /// Reset game variables + void resetGame() { + _gameOver = true; + _mineField.clear(); + _flagCount = _mineCount; + _openedTileCount = 0; + _gameHasStarted = false; + _gameOver = false; + _timeElapsed = 0; + GameAudioPlayer.playable = true; + notifyListeners(); + } + + /// Exit game function + void exitGame(BuildContext context) { + // 2024-02-01 因为和音乐播放器共用一个player,退出扫雷游戏是,停止当前音乐播放 + GameAudioPlayer().resetPlayer(true); + + if (isMineAnimationOn) { + minesAnimation = false; + } else if (gameHasStarted) { + GamePopupScreen.exitGame(context, this); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (context) => const MinesweeperHomeView(), + ), + (route) => false, + ); + }); + } + } + + /// Win game function + Future winTheGame() async { + _gameOver = true; + notifyListeners(); + + _audioPlayer.playAudio(GameSounds.lastHit); + await Future.delayed(const Duration(milliseconds: 1500), () { + _audioPlayer.playAudio(GameSounds.win, loop: true); + }); + } + + /// Lose game function + Future loseTheGame() async { + _gameOver = true; + notifyListeners(); + await showAllMines(); + _audioPlayer.playAudio(GameSounds.lose, loop: true); + } + + /// Places mines to empty game board. The number of mines depends on the game difficulty. + void _placeMines(Tile tile) { + var rnd = Random(); + int mines = _mineCount; + int row = tile.row; + int col = tile.col; + + while (mines > 0) { + var i = rnd.nextInt(_boardLength); + var j = rnd.nextInt(10); + + List> restricted = [ + [row - 1, col - 1], + [row - 1, col], + [row - 1, col + 1], + [row, col - 1], + [row, col], + [row, col + 1], + [row + 1, col - 1], + [row + 1, col], + [row + 1, col + 1], + ]; + + if (restricted.any((element) => element[0] == i && element[1] == j)) { + continue; + } + + if (!_mineField[i][j].hasMine) { + _mineField[i][j].setMine = true; + mines--; + } + } + } + + /// Remove/Add flag from/to specified tile + void placeFlag(Tile tile) { + if (!_gameOver) { + bool flagValue = !tile.hasFlag; + _mineField[tile.row][tile.col].setFlag = flagValue; + _flagCount += flagValue ? -1 : 1; + notifyListeners(); + _audioPlayer + .playAudio(flagValue ? GameSounds.putFlag : GameSounds.removeFlag); + } + } + + /// When user clicks a tile, this function calls the [_openTile] function and starts the game if it is the first move of user's + Future? clickTile(Tile tile) async { + if (!_gameHasStarted) { + startGame(tile); + } else if (!_gameOver) { + return await _openTile(tile.row, tile.col, playSound: true); + } + return null; + } + + /// Opens the clicked tile. Calls the [checkMinesAround] function and updates + /// the tile value as mine count. + Future? _openTile(int row, int col, {bool playSound = false}) async { + if (row < 0 || + col < 0 || + row >= mineField.length || + col >= mineField[0].length) return null; + if (mineField[row][col].visible) return null; + if (mineField[row][col].hasMine) { + mineField[row][col].setVisible = true; + _audioPlayer.playAudio(GameSounds.mineSound[0]); + await loseTheGame(); + return false; + } + + _openedTileCount++; + int minesAround = checkMinesAround(row, col); + mineField[row][col].setValue = minesAround; + if (mineField[row][col].hasFlag) { + _flagCount += 1; + } + notifyListeners(); + + if (_openedTileCount + _mineCount == _boardLength * 10) { + await winTheGame(); + return true; + } else { + if (playSound) { + _audioPlayer.playAudio(GameSounds.clickSounds[ + minesAround >= GameSounds.clickSounds.length + ? GameSounds.clickSounds.length - 1 + : minesAround]); + } + if (minesAround == 0) { + _openTile(row + 1, col - 1); + _openTile(row + 1, col); + _openTile(row + 1, col + 1); + _openTile(row, col - 1); + _openTile(row, col + 1); + _openTile(row - 1, col - 1); + _openTile(row - 1, col); + _openTile(row - 1, col + 1); + } + } + return null; + } + + /// Checks for surrounding mines and returns number of mines + int checkMinesAround(int row, int col) { + int rowLength = mineField.length; + int colLength = mineField[0].length; + + int minesAround = 0; + + if (row - 1 >= 0) { + // top-left + if (col - 1 >= 0 && mineField[row - 1][col - 1].hasMine) { + minesAround++; + } // top + if (mineField[row - 1][col].hasMine) { + minesAround++; + } // top-right + if (col + 1 < colLength && mineField[row - 1][col + 1].hasMine) { + minesAround++; + } + + if (mineField[row - 1][col].visible == false) { + mineField[row - 1][col].addBorder = 3; + } + } + + // left + if (col - 1 >= 0) { + if (mineField[row][col - 1].hasMine) { + minesAround++; + } + if (mineField[row][col - 1].visible == false) { + mineField[row][col - 1].addBorder = 2; + } + } + // right + if (col + 1 < colLength) { + if (mineField[row][col + 1].hasMine) { + minesAround++; + } + if (mineField[row][col + 1].visible == false) { + mineField[row][col + 1].addBorder = 0; + } + } + + if (row + 1 < rowLength) { + // bottom-left + if (col - 1 >= 0 && mineField[row + 1][col - 1].hasMine) { + minesAround++; + } // bottom + if (mineField[row + 1][col].hasMine) { + minesAround++; + } // bottom-right + if (col + 1 < colLength && mineField[row + 1][col + 1].hasMine) { + minesAround++; + } + if (mineField[row + 1][col].visible == false) { + mineField[row + 1][col].addBorder = 1; + } + } + + return minesAround; + } +} diff --git a/lib/views/game_center/minesweeper/helper/audio_player.dart b/lib/views/game_center/minesweeper/helper/audio_player.dart new file mode 100755 index 0000000..aa5c281 --- /dev/null +++ b/lib/views/game_center/minesweeper/helper/audio_player.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:just_audio_background/just_audio_background.dart'; + +import '../../../../services/my_audio_handler.dart'; +import '../../../../services/service_locator.dart'; +import '../utils/game_sounds.dart'; + +class GameAudioPlayer { + final _audioHandler = getIt(); + + static late AudioPlayer _player; + static bool playable = true; + + GameAudioPlayer() { + // 2024-02-01 因为有用到 just_audio_background,不支持多音源播放,可以使用 audio_service ,、 + // 但暂时不做,因为放着歌还听背景音乐不方便,也主要是懒 + // 因为和音乐播放器共用同一个player,所以这里直接复用音乐播放器的那个 + // _player = AudioPlayer(); + _player = _audioHandler.player(); + // _player.play(); + } + + // 2024-02-01 因为和音乐播放器共用同一个player,所以扫雷重置音乐播放只是停止即可 + void resetPlayer(bool soundOn) { + _player.stop(); + _player.setVolume(soundOn ? 1 : 0); + } + + static void pause() { + playable = false; + _player.pause(); + } + + static void resume() { + playable = true; + try { + _player.play(); + } catch (e) { + debugPrint(e.toString()); + } + } + + static Future setVolume(bool soundOn) async { + await _player.setVolume(soundOn ? 1 : 0); + } + + /// 2024-02-01 因为我原本使用的音频播放器也是just audio,所以这里会报错: + /// Unhandled Exception: PlatformException(error, just_audio_background supports only a single player instance, null, null) + + Future _setAudio(String audioPath) async { + try { + // 2024-02-01 原本使用这些方式替换音乐,但是会报错如下: + // Error loading audio source: type 'Null' is not a subtype of type 'MediaItem' in type cast + // 应该还是和背景播放有些冲突,所以使用下面那个 + // await _player.setAudioSource( + // AudioSource.asset(audioPath), + // ); + // await _player.setAsset(audioPath); + + await _player.setAudioSource(AudioSource.asset( + audioPath, + tag: MediaItem( + id: audioPath, + title: audioPath, + ), + )); + + _player.setLoopMode(LoopMode.off); + return true; + } catch (e) { + debugPrint("Error loading audio source: $e"); + } + return false; + } + + Future playAudio(Sound sound, {bool loop = false}) async { + if (await _setAudio(sound.toPath) && playable) { + if (loop) { + _player.setLoopMode(LoopMode.one); + } + _player.play(); + } + } +} diff --git a/lib/views/game_center/minesweeper/helper/shared_helper.dart b/lib/views/game_center/minesweeper/helper/shared_helper.dart new file mode 100755 index 0000000..5e52942 --- /dev/null +++ b/lib/views/game_center/minesweeper/helper/shared_helper.dart @@ -0,0 +1,66 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/game_consts.dart'; + +class SharedHelper { + late final SharedPreferences _prefs; + + SharedHelper._create(); + + static Future init() async { + var sharedHelper = SharedHelper._create(); + sharedHelper._prefs = await SharedPreferences.getInstance(); + return sharedHelper; + } + + Future getHowToPlayShown() async { + return _prefs.getBool("HowToPlay") ?? false; + } + + Future setHowToPlayShown(bool value) async { + return _prefs.setBool("HowToPlay", value); + } + + Future getBestTime(GameMode gameMode) async { + return _prefs.getInt("${gameMode.name}:BestTime"); + } + + Future setBestTime(GameMode gameMode, int time) async { + return _prefs.setInt("${gameMode.name}:BestTime", time); + } + + Future getGamesWon(GameMode gameMode) async { + return _prefs.getInt("${gameMode.name}:GamesWon"); + } + + Future increaseGamesWon(GameMode gameMode) async { + int gamesWon = await getGamesWon(gameMode) ?? 0; + return _prefs.setInt("${gameMode.name}:GamesWon", gamesWon + 1); + } + + Future getGamesStarted(GameMode gameMode) async { + return _prefs.getInt("${gameMode.name}:GamesStarted"); + } + + Future increaseGamesStarted(GameMode gameMode) async { + int gamesStarted = await getGamesStarted(gameMode) ?? 0; + return _prefs.setInt("${gameMode.name}:GamesStarted", gamesStarted + 1); + } + + Future getAverageTime(GameMode gameMode) async { + return _prefs.getInt("${gameMode.name}:AverageTime"); + } + + Future updateAverageTime(GameMode gameMode, int time) async { + int? averageTime = await getAverageTime(gameMode); + int? gamesWon = await getGamesWon(gameMode); + + if (averageTime == null || gamesWon == null) { + averageTime = time; + } else { + var totalTime = gamesWon * averageTime; + averageTime = ((totalTime + time) / gamesWon + 1).round(); + } + return _prefs.setInt("${gameMode.name}:AverageTime", averageTime); + } +} diff --git a/lib/views/game_center/minesweeper/index.dart b/lib/views/game_center/minesweeper/index.dart new file mode 100644 index 0000000..98db99f --- /dev/null +++ b/lib/views/game_center/minesweeper/index.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +import 'utils/exports.dart'; +import 'view/splash_view/splash_view.dart'; + +class InitMinesweeper extends StatefulWidget { + const InitMinesweeper({super.key}); + + @override + State createState() => _InitMinesweeperState(); +} + +class _InitMinesweeperState extends State { + @override + Widget build(BuildContext context) { + GameSizes.init(context); + + return const SplashView(); + } +} diff --git a/lib/views/game_center/minesweeper/mixins/statistics_mixin.dart b/lib/views/game_center/minesweeper/mixins/statistics_mixin.dart new file mode 100755 index 0000000..d5e66de --- /dev/null +++ b/lib/views/game_center/minesweeper/mixins/statistics_mixin.dart @@ -0,0 +1,41 @@ +import '../helper/shared_helper.dart'; +import '../utils/game_consts.dart'; + +mixin StatisticsMixin { + String timeFormatter(int? time) { + if (time == null) { + return "--:--"; + } + Duration duration = Duration(seconds: time); + int minutes = duration.inMinutes; + int seconds = duration.inSeconds - minutes * 60; + return "${(minutes > 9 ? "" : "0")}$minutes:${(seconds > 9 ? "" : "0")}$seconds"; + } + + Future> getStatistic(GameMode gameMode) async { + final SharedHelper sharedHelper = await SharedHelper.init(); + + int? gamesStarted = await sharedHelper.getGamesStarted(gameMode); + int? gamesWon = await sharedHelper.getGamesWon(gameMode); + int? bestTime = await sharedHelper.getBestTime(gameMode); + int? averageTime = await sharedHelper.getAverageTime(gameMode); + + String? winRate; + + if (gamesStarted != null) { + if (gamesWon != null) { + winRate = "${(gamesWon * 100 / gamesStarted).round()}%"; + } else { + winRate = "0%"; + } + } + + return { + "gamesStarted": gamesStarted, + "gamesWon": gamesWon, + "winRate": winRate, + "bestTime": timeFormatter(bestTime), + "averageTime": timeFormatter(averageTime), + }; + } +} diff --git a/lib/views/game_center/minesweeper/model/tile_model.dart b/lib/views/game_center/minesweeper/model/tile_model.dart new file mode 100755 index 0000000..fab1c00 --- /dev/null +++ b/lib/views/game_center/minesweeper/model/tile_model.dart @@ -0,0 +1,55 @@ +class Tile { + late bool _visible; + late bool _hasMine; + late bool _hasFlag; + late int _value; + late List _offset; + late List _ltrb; + + bool get visible => _visible; + bool get hasMine => _hasMine; + bool get hasFlag => _hasFlag; + int get value => _value; + int get row => _offset[0]; + int get col => _offset[1]; + List get ltrb => _ltrb; + + Tile(int row, int col) { + _visible = false; + _hasMine = false; + _hasFlag = false; + _value = -1; + _offset = [row, col]; + _ltrb = [false, false, false, false]; + } + + set setVisible(bool value) { + _visible = value; + } + + set setMine(bool value) { + _hasMine = value; + } + + set setFlag(bool value) { + _hasFlag = value; + } + + set setValue(int value) { + _value = value; + _visible = true; + } + + set setOffset(List value) { + _offset = value; + } + + set addBorder(int index) { + _ltrb[index] = true; + } + + @override + String toString() { + return value.toString(); + } +} diff --git a/lib/views/game_center/minesweeper/readme.md b/lib/views/game_center/minesweeper/readme.md new file mode 100644 index 0000000..d491040 --- /dev/null +++ b/lib/views/game_center/minesweeper/readme.md @@ -0,0 +1,25 @@ +# 扫雷小游戏说明 + +2024-02-01: + +原项目在 github 中的 [recepsenoglu/minesweeper](https://github.com/recepsenoglu/minesweeper/tree/main)。 + +如果有 flutter 学习的需要,强烈建议查看原项目进行学习,作为一个可以发布到 Google play 的应用,各方面细节也比较完善,比如打分、隐私策略、分享等一般学习 demo 很少考虑的。 + +## 和原项目的一些改动: + +因为时间关系,并没有太深入学习和研究,但为了减少一些内容清除了很多东西: + +- 原项目的”settings“有很多内容,基本都删除了,仅仅保留 howToPlay 和原作者简单信息; +- i10n 也删除了,也未启用 generateRoute 的导航方式 + - 原本默认的英文文字也简单替换为中文 +- 使用 google_fonts 美化的一些字体体验也取消了 +- 因为使用了 just_audio 来播放游戏音乐,和我原本的音乐播放器功能和背景播放使用的 just_audio_background 有一些冲突 + - 后者只能单音源,所以复用了音乐播放器的 AudioPlayer,但在播放游戏背景音乐时还会在状态栏有显示 + - 这个问题,暂不处理,后续考虑整体的替换为 audio_service 来支持多音源 +- 原本是单应用,现在作为一个子模块,有修改一些页面导航的问题 + - 部分导航的跳转和之前的小游戏有区别,尤其在使用了 pushAndRemoveUntil 的地方 +- TODO + - 没有替换原本的 shared_preferences,后续可以考虑使用 get_storage 替换之 + - 在使用 just_audio 的前提下,添加 audio_service 支持多音源 + - 游戏背景音乐时,状态栏不显示 diff --git a/lib/views/game_center/minesweeper/utils/exports.dart b/lib/views/game_center/minesweeper/utils/exports.dart new file mode 100755 index 0000000..c8e5537 --- /dev/null +++ b/lib/views/game_center/minesweeper/utils/exports.dart @@ -0,0 +1,6 @@ +export 'extensions.dart'; +export 'game_colors.dart'; +export 'game_images.dart'; +export 'game_sizes.dart'; +export 'game_sounds.dart'; +export 'game_consts.dart'; diff --git a/lib/views/game_center/minesweeper/utils/extensions.dart b/lib/views/game_center/minesweeper/utils/extensions.dart new file mode 100755 index 0000000..93c5521 --- /dev/null +++ b/lib/views/game_center/minesweeper/utils/extensions.dart @@ -0,0 +1,5 @@ +extension StringExtension on String { + String capitalizeFirst() { + return "${this[0].toUpperCase()}${substring(1).toLowerCase()}"; + } +} \ No newline at end of file diff --git a/lib/views/game_center/minesweeper/utils/game_colors.dart b/lib/views/game_center/minesweeper/utils/game_colors.dart new file mode 100755 index 0000000..c92b0b8 --- /dev/null +++ b/lib/views/game_center/minesweeper/utils/game_colors.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +class GameColors { + static const Color _mainSkyBlue = Color(0xFF4AC0FD); + static const Color _mainDarkGreen = Color(0xFF547436); + static const Color _darkBlue = Color(0xFF4994EC); + static const Color _background = Color(0xFFF6F6F6); + + static const Color _grassLight = Color(0xFFA7D948); + static const Color _grassDark = Color(0xFF8ECC39); + + static const Color _tileLight = Color(0xFFE5C29F); + static const Color _tileDark = Color(0xFFD7B899); + + static const Color _tileBorder = Color(0xFF8FAE4D); + + static const Color _valueText1 = Color(0xFF3874CB); + static const Color _valueText2 = Color(0xFF508C46); + static const Color _valueText3 = Color(0xFFC23F38); + static const Color _valueText4 = Color(0xFF71279C); + static const Color _valueText5 = Color(0xFFF09536); + static const Color _valueText6 = Color(0xFFDA893D); + static const Color _valueText7 = Color(0xFF000000); + static const Color _valueText8 = Color(0xFFFF0000); + + static const Color _mine1 = Color(0xFFA94FEA); + static const Color _mine2 = Color(0xFFE58A35); + static const Color _mine3 = Color(0xFFDB52B1); + static const Color _mine4 = Color(0xFF5783E6); + static const Color _mine5 = Color(0xFFECC444); + static const Color _mine6 = Color(0xFFCA423E); + static const Color _mine7 = Color(0xFF7AE3EF); + + static Color get appBar => _mainDarkGreen; + static Color get darkBlue => _darkBlue; + static Color get mainSkyBlue => _mainSkyBlue; + static Color get mainDarkGreen => _mainDarkGreen; + static Color get background => _background; + + static Color get grassLight => _grassLight; + static Color get grassDark => _grassDark; + + static Color get tileLight => _tileLight; + static Color get tileDark => _tileDark; + + static Color get tileBorder => _tileBorder; + + static Color get popupBackground => _mainSkyBlue; + static Color get popupPlayAgainButton => _mainDarkGreen; + static Color get skipButton => _mainDarkGreen; + + static List get valueTextColors => [ + _valueText1, + _valueText2, + _valueText3, + _valueText4, + _valueText5, + _valueText6, + _valueText7, + _valueText8 + ]; + + static List get mineColors => + [_mine1, _mine2, _mine3, _mine4, _mine5, _mine6, _mine7]; + + static Color darken(Color color, [double amount = .2]) { + assert(amount >= 0 && amount <= 1); + + final hsl = HSLColor.fromColor(color); + final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0)); + + return hslDark.toColor(); + } +} diff --git a/lib/views/game_center/minesweeper/utils/game_consts.dart b/lib/views/game_center/minesweeper/utils/game_consts.dart new file mode 100755 index 0000000..c195f44 --- /dev/null +++ b/lib/views/game_center/minesweeper/utils/game_consts.dart @@ -0,0 +1,19 @@ +enum GameMode { easy, medium, hard } + +int getBoardLength(GameMode gameMode) { + if (gameMode.name == 'easy') { + return 10; + } else if (gameMode.name == 'medium') { + return 25; + } + return 48; +} + +int mineCount(GameMode gameMode) { + if (gameMode.name == 'easy') { + return 15; + } else if (gameMode.name == 'medium') { + return 40; + } + return 99; +} diff --git a/lib/views/game_center/minesweeper/utils/game_images.dart b/lib/views/game_center/minesweeper/utils/game_images.dart new file mode 100755 index 0000000..093e760 --- /dev/null +++ b/lib/views/game_center/minesweeper/utils/game_images.dart @@ -0,0 +1,14 @@ +enum Images { + stopwatch, + trophy, + flag, + redCross, + loseScreen, + winScreen, + homeScreenBg, + pickaxe, +} + +extension ImagesExtension on Images { + String get toPath => 'assets/games/minesweeper/images/$name.png'; +} diff --git a/lib/views/game_center/minesweeper/utils/game_sizes.dart b/lib/views/game_center/minesweeper/utils/game_sizes.dart new file mode 100755 index 0000000..0a15cef --- /dev/null +++ b/lib/views/game_center/minesweeper/utils/game_sizes.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +class GameSizes { + static late double _screenWidth; + static late double _screenHeight; + static late EdgeInsets _padding; + static BorderRadius borderRadius = BorderRadius.circular(0); + + static void init(BuildContext context) { + final size = MediaQuery.sizeOf(context); + _screenWidth = size.width; + _screenHeight = size.height; + + _padding = MediaQuery.paddingOf(context); + } + + static double get width => _screenWidth; + static double get height => _screenHeight; + static double get bottomPadding => _padding.bottom; + static double get topPadding => _padding.top; + + static BorderRadius getRadius(double radius) { + final double value = _screenWidth < 500 ? radius : radius * 2; + return BorderRadius.circular(value); + } + + static double getWidth(double percent) { + return _screenWidth * percent; + } + + static double getHeight(double percent) { + return _screenHeight * percent; + } + + static EdgeInsets getPadding(double percent) { + return EdgeInsets.all(getWidth(percent)); + } + + static EdgeInsets getHorizontalPadding(double percent) { + return EdgeInsets.symmetric(horizontal: getWidth(percent)); + } + + static EdgeInsets getVerticalPadding(double percent) { + return EdgeInsets.symmetric(vertical: getHeight(percent)); + } + + static EdgeInsets getSymmetricPadding(double horizontal, double vertical) { + return EdgeInsets.symmetric( + horizontal: getWidth(horizontal), + vertical: getHeight(vertical), + ); + } +} diff --git a/lib/views/game_center/minesweeper/utils/game_sounds.dart b/lib/views/game_center/minesweeper/utils/game_sounds.dart new file mode 100755 index 0000000..e4e4f3c --- /dev/null +++ b/lib/views/game_center/minesweeper/utils/game_sounds.dart @@ -0,0 +1,47 @@ +enum Sound { + win, + lose, + putFlag, + removeFlag, + lastHit, + clickEmpty, + clickOne, + clickTwo, + clickThree, + clickFour, + blue, + pink, + purple, +} + +extension SoundExtension on Sound { + String get toPath => 'assets/games/minesweeper/audio/$name.wav'; +} + +class GameSounds { + static const Sound _win = Sound.win; + static const Sound _lose = Sound.lose; + static const Sound _putFlag = Sound.putFlag; + static const Sound _removeFlag = Sound.removeFlag; + static const Sound _lastHit = Sound.lastHit; + static const Sound _clickEmpty = Sound.clickEmpty; + static const Sound _clickOne = Sound.clickOne; + static const Sound _clickTwo = Sound.clickTwo; + static const Sound _clickThree = Sound.clickThree; + static const Sound _clickFour = Sound.clickFour; + + static const Sound _blue = Sound.blue; + static const Sound _pink = Sound.pink; + static const Sound _purple = Sound.purple; + + static Sound get win => _win; + static Sound get lose => _lose; + static Sound get putFlag => _putFlag; + static Sound get removeFlag => _removeFlag; + static Sound get lastHit => _lastHit; + + static List get clickSounds => + [_clickEmpty, _clickOne, _clickTwo, _clickThree, _clickFour]; + + static List get mineSound => [_purple, _blue, _pink]; +} diff --git a/lib/views/game_center/minesweeper/view/about_view/about_view.dart b/lib/views/game_center/minesweeper/view/about_view/about_view.dart new file mode 100755 index 0000000..cf1699e --- /dev/null +++ b/lib/views/game_center/minesweeper/view/about_view/about_view.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import '../../utils/game_colors.dart'; +import '../../utils/game_sizes.dart'; + +class AboutView extends StatefulWidget { + const AboutView({super.key}); + + @override + State createState() => _AboutViewState(); +} + +class _AboutViewState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: GameColors.background, + appBar: AppBar( + elevation: 0, + centerTitle: true, + title: const Text('关于'), + titleTextStyle: TextStyle( + color: Colors.black, + fontSize: GameSizes.getWidth(0.05), + ), + ), + body: SingleChildScrollView( + padding: GameSizes.getSymmetricPadding(0.05, 0.04), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: GameSizes.getSymmetricPadding(0.05, 0.02), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.asset( + 'assets/games/minesweeper/images/logo.png', + width: GameSizes.getWidth(0.25), + ), + SizedBox(width: GameSizes.getWidth(0.05)), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'minesweeper', + style: TextStyle( + fontSize: GameSizes.getWidth(0.06), + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: GameSizes.getHeight(0.005)), + Text( + 'version 1.0.3', + style: TextStyle( + fontSize: GameSizes.getWidth(0.04), + ), + ), + SizedBox(height: GameSizes.getHeight(0.005)), + Text( + '原开发者信息:', + style: TextStyle( + fontSize: GameSizes.getWidth(0.04), + ), + ), + SizedBox(height: GameSizes.getHeight(0.005)), + Text( + ' Recep Oğuzhan Şenoğlu', + style: TextStyle( + fontSize: GameSizes.getWidth(0.04), + ), + ), + Text( + ' İstanbul, Türkiye', + style: TextStyle( + fontSize: GameSizes.getWidth(0.035), + ), + ), + ], + ), + ], + ), + ), + SizedBox(height: GameSizes.getHeight(0.02)), + ], + ), + ), + ); + } +} diff --git a/lib/views/game_center/minesweeper/view/game_view/game_view.dart b/lib/views/game_center/minesweeper/view/game_view/game_view.dart new file mode 100755 index 0000000..f553452 --- /dev/null +++ b/lib/views/game_center/minesweeper/view/game_view/game_view.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../controller/game_controller.dart'; +import '../../helper/audio_player.dart'; +import '../../widgets/skip_button.dart'; +import 'mine_field.dart'; +import 'top_bar.dart'; + +class GameView extends StatefulWidget { + const GameView({super.key}); + + @override + State createState() => _GameViewState(); +} + +class _GameViewState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + GameAudioPlayer.resume(); + } else { + GameAudioPlayer.pause(); + } + super.didChangeAppLifecycleState(state); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => GameController(), + child: Consumer(builder: (context, gameController, _) { + return PopScope( + canPop: !gameController.gameHasStarted, + onPopInvoked: (popped) { + gameController.exitGame(context); + }, + child: SafeArea( + child: Scaffold( + backgroundColor: Colors.black, + body: Column( + children: [ + const GameTopBar(), + Expanded( + child: Stack( + alignment: Alignment.center, + children: [ + MineField(gameController: gameController), + SkipButton(gameController: gameController), + ], + ), + ), + ], + ), + ), + ), + ); + }), + ); + } +} diff --git a/lib/views/game_center/minesweeper/view/game_view/mine_field.dart b/lib/views/game_center/minesweeper/view/game_view/mine_field.dart new file mode 100755 index 0000000..32d1873 --- /dev/null +++ b/lib/views/game_center/minesweeper/view/game_view/mine_field.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; + +import '../../controller/game_controller.dart'; +import '../../helper/shared_helper.dart'; +import '../../model/tile_model.dart'; +import '../../utils/exports.dart'; +import '../../widgets/game_popup_screen.dart'; + +class MineField extends StatelessWidget { + final GameController gameController; + const MineField({super.key, required this.gameController}); + + @override + Widget build(BuildContext context) { + List> mineField = gameController.mineField; + + return Center( + child: GridView.builder( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 10), + itemCount: gameController.boardLength * 10, + itemBuilder: (BuildContext context, index) { + Tile tile = mineField[index ~/ 10][index % 10]; + + if (tile.visible == false || tile.hasFlag) { + return Grass( + tile: tile, + gameController: gameController, + parentContext: context, + ); + } else { + if (tile.hasMine) return Mine(index: index, tile: tile); + + return OpenedTile(tile: tile); + } + }), + ); + } +} + +class Grass extends StatelessWidget { + final Tile tile; + final GameController gameController; + final BuildContext parentContext; + + const Grass({ + super.key, + required this.tile, + required this.gameController, + required this.parentContext, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () async { + if (tile.hasFlag) return; + + await gameController.clickTile(tile)?.then((win) async { + if (win != null) { + final sharedHelper = await SharedHelper.init(); + int? bestTime = + await sharedHelper.getBestTime(gameController.gameMode); + if (win) { + await sharedHelper.updateAverageTime( + gameController.gameMode, gameController.timeElapsed); + await sharedHelper.increaseGamesWon(gameController.gameMode); + if (gameController.timeElapsed < (bestTime ?? 999)) { + bestTime = gameController.timeElapsed; + await sharedHelper.setBestTime( + gameController.gameMode, bestTime); + } + } + return (win, bestTime); + } + return null; + }).then((value) { + if (value?.$1 != null) { + GamePopupScreen.gameOver( + parentContext, + controller: gameController, + bestTime: value!.$2, + win: value.$1, + ); + } + }); + }, + onLongPress: () => gameController.placeFlag(tile), + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: tile.row % 2 == 0 && tile.col % 2 == 0 || + tile.row % 2 != 0 && tile.col % 2 != 0 + ? GameColors.grassLight + : GameColors.grassDark, + border: tileBorder(tile), + ), + child: tile.hasFlag + ? tile.visible + ? Image.asset(Images.redCross.toPath) + : Image.asset(Images.flag.toPath) + : const SizedBox(), + )); + } +} + +class Mine extends StatelessWidget { + final int index; + final Tile tile; + + const Mine({super.key, required this.index, required this.tile}); + + @override + Widget build(BuildContext context) { + Color mineColor = + GameColors.mineColors[index % GameColors.mineColors.length]; + return Container( + alignment: Alignment.center, + padding: GameSizes.getPadding(0.02), + decoration: BoxDecoration( + color: mineColor, + border: tileBorder(tile), + ), + child: CircleAvatar(backgroundColor: GameColors.darken(mineColor)), + ); + } +} + +class OpenedTile extends StatelessWidget { + final Tile tile; + + const OpenedTile({super.key, required this.tile}); + + @override + Widget build(BuildContext context) { + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: tile.row % 2 == 0 && tile.col % 2 == 0 || + tile.row % 2 != 0 && tile.col % 2 != 0 + ? GameColors.tileLight + : GameColors.tileDark, + ), + child: tile.value > 0 + ? Text( + tile.toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: GameSizes.getWidth(0.06), + color: GameColors.valueTextColors[tile.value - 1], + ), + ) + : const SizedBox(), + ); + } +} + +BoxBorder tileBorder(Tile tile) { + return Border( + top: createBorderSide(tile.ltrb[1]), + left: createBorderSide(tile.ltrb[0]), + right: createBorderSide(tile.ltrb[2]), + bottom: createBorderSide(tile.ltrb[3]), + ); +} + +BorderSide createBorderSide(bool isSolid) { + return BorderSide( + color: GameColors.tileBorder, + width: GameSizes.getWidth(0.005), + style: isSolid ? BorderStyle.solid : BorderStyle.none, + ); +} diff --git a/lib/views/game_center/minesweeper/view/game_view/top_bar.dart b/lib/views/game_center/minesweeper/view/game_view/top_bar.dart new file mode 100755 index 0000000..4e548b8 --- /dev/null +++ b/lib/views/game_center/minesweeper/view/game_view/top_bar.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../controller/game_controller.dart'; +// import '../../utils/extensions.dart'; +import '../../utils/game_colors.dart'; +import '../../utils/game_consts.dart'; +import '../../utils/game_images.dart'; +import '../../utils/game_sizes.dart'; + +class GameTopBar extends StatelessWidget { + const GameTopBar({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: GameColors.appBar, + padding: GameSizes.getSymmetricPadding(0.02, 0.01), + child: Consumer( + builder: (context, GameController controller, child) => Row( + children: [ + DifficultySettings(controller: controller), + const Spacer(), + Flags(flagCount: controller.flagCount), + SizedBox(width: GameSizes.getWidth(0.04)), + Stopwatch(timeElapsed: controller.timeElapsed), + const Spacer(), + IconButton( + onPressed: () { + controller.changeVolumeSetting = !controller.volumeOn; + }, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + icon: Icon( + controller.volumeOn ? Icons.volume_up : Icons.volume_off_sharp, + color: controller.volumeOn ? Colors.white : Colors.white70, + ), + iconSize: GameSizes.getWidth(0.075), + ), + IconButton( + onPressed: () => controller.exitGame(context), + icon: const Icon(Icons.close, color: Colors.white), + visualDensity: VisualDensity.compact, + iconSize: GameSizes.getWidth(0.07), + padding: EdgeInsets.zero, + ), + ], + ), + ), + ); + } +} + +class Stopwatch extends StatelessWidget { + final int timeElapsed; + const Stopwatch({super.key, required this.timeElapsed}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Image.asset( + Images.stopwatch.toPath, + width: GameSizes.getWidth(0.07), + ), + SizedBox(width: GameSizes.getWidth(0.01)), + Container( + width: GameSizes.getWidth(0.095), + alignment: Alignment.centerLeft, + child: Text( + "0" * (3 - timeElapsed.toString().length) + timeElapsed.toString(), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: GameSizes.getWidth(0.045), + ), + ), + ), + ], + ); + } +} + +class Flags extends StatelessWidget { + final int flagCount; + const Flags({super.key, required this.flagCount}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Image.asset( + Images.flag.toPath, + width: GameSizes.getWidth(0.08), + ), + SizedBox(width: GameSizes.getWidth(0.01)), + Text( + flagCount.toString(), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: GameSizes.getWidth(0.045), + ), + ), + ], + ); + } +} + +class DifficultySettings extends StatelessWidget { + final GameController controller; + const DifficultySettings({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + GameMode gameMode = controller.gameMode; + List allModes = [GameMode.easy, GameMode.medium, GameMode.hard]; + + return Container( + height: GameSizes.getWidth(0.08), + margin: GameSizes.getSymmetricPadding(0.02, 0.01), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: GameSizes.getRadius(6), + ), + child: Center( + child: DropdownButton( + isDense: true, + value: gameMode, + alignment: Alignment.centerLeft, + borderRadius: GameSizes.getRadius(10), + underline: const SizedBox(), + padding: GameSizes.getVerticalPadding(0.005) + .copyWith(left: GameSizes.getWidth(0.02)), + items: allModes.map>((GameMode value) { + return DropdownMenuItem( + value: value, + child: FittedBox( + child: Text( + // value.name.capitalizeFirst(), + value == GameMode.easy + ? "简单" + : value == GameMode.medium + ? "中等" + : "困难", + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: GameSizes.getHeight(0.02), + ), + ), + ), + ); + }).toList(), + onChanged: (value) { + if (controller.isMineAnimationOn) { + controller.minesAnimation = false; + } else if (value != null && value != controller.gameMode) { + controller.gameMode = value; + } + }, + ), + ), + ); + } +} diff --git a/lib/views/game_center/minesweeper/view/home_view/components/animated_play_button.dart b/lib/views/game_center/minesweeper/view/home_view/components/animated_play_button.dart new file mode 100755 index 0000000..043fd44 --- /dev/null +++ b/lib/views/game_center/minesweeper/view/home_view/components/animated_play_button.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../utils/game_images.dart'; +import '../../../utils/game_sizes.dart'; +import '../../../widgets/custom_button.dart'; +import '../../game_view/game_view.dart'; + +class AnimatedPlayButton extends StatefulWidget { + const AnimatedPlayButton({super.key}); + + @override + State createState() => _AnimatedPlayButtonState(); +} + +class _AnimatedPlayButtonState extends State { + double _top = 0; + double _left = 0; + double _axeSize = GameSizes.getWidth(0.38); + + Timer? _timer; + + void _startTimer() { + _timer = Timer.periodic(const Duration(milliseconds: 1000), (timer) { + if (mounted) { + setState(() { + _axeSize = _axeSize > GameSizes.getWidth(0.2) + ? GameSizes.getWidth(0.2) + : GameSizes.getWidth(0.25); + }); + } else { + timer.cancel(); + } + }); + } + + void _killTimer() { + _timer?.cancel(); + } + + @override + void initState() { + super.initState(); + _startTimer(); + } + + @override + void dispose() { + super.dispose(); + _killTimer(); + } + + void _onTapDown() { + setState(() { + _top = GameSizes.getWidth(0.03); + _left = GameSizes.getWidth(0.02); + }); + } + + void _onTapUp() { + Future.delayed(const Duration(milliseconds: 100), () { + setState(() { + _top = 0; + _left = 0; + }); + }); + } + + void _onTapDownWithDetails(TapDownDetails details) { + _onTapDown(); + } + + void _onTapUpWithDetails(TapUpDetails details) { + _onTapUp(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Container( + width: GameSizes.getWidth(0.38), + height: GameSizes.getWidth(0.32), + margin: EdgeInsets.only( + top: GameSizes.getWidth(0.03), + left: GameSizes.getWidth(0.02), + ), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: GameSizes.getRadius(22), + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 100), + top: _top, + left: _left, + curve: Curves.easeIn, + child: GestureDetector( + onTap: _onTapDown, + onTapUp: _onTapUpWithDetails, + onTapDown: _onTapDownWithDetails, + onTapCancel: _onTapUp, + child: CustomButton( + text: '', + onPressed: () { + Future.delayed(const Duration(milliseconds: 200), () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const GameView(), + )); + }); + }, + radius: 16, + elevation: 20, + width: GameSizes.getWidth(0.38), + height: GameSizes.getWidth(0.32), + padding: GameSizes.getPadding(0.025), + color: Colors.grey.shade200, + textColor: Colors.white, + child: AnimatedSize( + duration: const Duration(milliseconds: 800), + curve: Curves.easeInOut, + child: AnimatedContainer( + duration: const Duration(milliseconds: 800), + decoration: BoxDecoration( + // color: Colors.grey.shade200, + borderRadius: GameSizes.getRadius(16), + ), + width: _axeSize, + height: _axeSize, + child: Image.asset( + Images.pickaxe.toPath, + width: _axeSize, + height: _axeSize, + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/game_center/minesweeper/view/home_view/components/miniature_minefield.dart b/lib/views/game_center/minesweeper/view/home_view/components/miniature_minefield.dart new file mode 100755 index 0000000..790f487 --- /dev/null +++ b/lib/views/game_center/minesweeper/view/home_view/components/miniature_minefield.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import '../../../utils/game_colors.dart'; +import '../../../utils/game_sizes.dart'; + +/// +/// 微型雷区 +/// +class MiniatureMinefield extends StatefulWidget { + const MiniatureMinefield({super.key}); + + @override + State createState() => _MiniatureMinefieldState(); +} + +class _MiniatureMinefieldState extends State { + Timer? timer; + + void startTimer() { + timer = Timer.periodic(const Duration(milliseconds: 1500), (timer) { + if (mounted) { + setState(() {}); + } else { + timer.cancel(); + } + }); + } + + void stopTimer() { + timer?.cancel(); + } + + @override + void initState() { + super.initState(); + startTimer(); + } + + @override + void dispose() { + super.dispose(); + stopTimer(); + } + + @override + Widget build(BuildContext context) { + var rnd = Random(); + List minePlaces = [ + rnd.nextInt(20), + rnd.nextInt(20), + rnd.nextInt(20), + ]; + + return SizedBox( + height: GameSizes.getWidth(0.6), + child: Center( + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 10), + itemCount: 40, + itemBuilder: (BuildContext context, index) { + Color? mineColor; + bool mineCell = minePlaces.contains(index - 19); + if (mineCell) { + var rnd = Random(); + + mineColor = GameColors + .mineColors[rnd.nextInt(GameColors.mineColors.length)]; + } + return Container( + decoration: BoxDecoration( + color: mineCell + ? mineColor + : (index / 10).floor() < 2 + ? index % 2 == (index / 10).floor() % 2 + ? GameColors.grassLight + : GameColors.grassDark + : index % 2 != (index / 10).floor() % 2 + ? GameColors.tileDark + : GameColors.tileLight, + ), + alignment: Alignment.center, + padding: GameSizes.getPadding(0.02), + child: mineCell + ? CircleAvatar( + backgroundColor: GameColors.darken(mineColor!), + ) + : const SizedBox(), + ); + }, + ), + ), + ); + } +} diff --git a/lib/views/game_center/minesweeper/view/home_view/home_view.dart b/lib/views/game_center/minesweeper/view/home_view/home_view.dart new file mode 100755 index 0000000..42e8e2b --- /dev/null +++ b/lib/views/game_center/minesweeper/view/home_view/home_view.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:freader_media_player/layout/home.dart'; + +import '../../../../../services/my_get_storage.dart'; +import '../../../../../services/service_locator.dart'; +import '../../utils/exports.dart'; +import '../../widgets/custom_button.dart'; +import '../settings_view/settings_view.dart'; +import '../statistics_view/statistics_view.dart'; +import 'components/animated_play_button.dart'; +import 'components/miniature_minefield.dart'; + +class MinesweeperHomeView extends StatefulWidget { + const MinesweeperHomeView({super.key}); + + @override + State createState() => _MinesweeperHomeViewState(); +} + +class _MinesweeperHomeViewState extends State { + // 统一简单存储操作的工具类实例 + final _simpleStorage = getIt(); + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvoked: (popped) { + // 2024-02-01 不包裹在这里面,点击返回到游戏中心可能不生效,还会报错,可参看: + // https://stackoverflow.com/questions/55618717/error-thrown-on-navigator-pop-until-debuglocked-is-not-true + // 其他使用pushAndRemoveUntil的同理 + WidgetsBinding.instance.addPostFrameCallback((_) { + // Navigator.of(context) + // ..pop() + // ..pop(); + // Navigator.of(context).pop(); + + // 理论上这里点击返回应该返回到游戏中心页面,但是不加这个popscope,返回就直接退出app了; + // 如果只有pop,也不会生效; 如果替换路由时gamecenter,那就不会有下方导航栏了 + // 因此,需要指定展示的底部导航索引(存在底部显示3个或者5个索引的情况,要区分传值) + + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (context) => HomePage( + selectedIndex: _simpleStorage.getBottomNavItemMun() > 3 ? 4 : 2, + ), + ), + (route) => false, + ); + }); + }, + child: Scaffold( + backgroundColor: GameColors.mainSkyBlue, + body: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox(height: GameSizes.getHeight(0.05)), + // 游戏名称 + const GameTitle(title: 'minesweeper'), + // 中间简单的游戏示例 + const MiniatureMinefield(), + Column( + children: [ + // 动画开始游戏按钮 + const AnimatedPlayButton(), + SizedBox(height: GameSizes.getHeight(0.04)), + // 统计按钮和设置按钮 + Padding( + padding: GameSizes.getHorizontalPadding(0.1), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: CustomButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const StatisticsView(), + )); + }, + elevation: 6, + icon: Icons.bar_chart, + text: '统计', + iconSize: GameSizes.getWidth(0.06), + height: GameSizes.getHeight(0.06), + ), + ), + SizedBox(width: GameSizes.getWidth(0.05)), + Expanded( + child: CustomButton( + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SettingsView(), + )); + setState(() {}); + }, + elevation: 6, + icon: Icons.settings, + text: "设置", + iconSize: GameSizes.getWidth(0.06), + height: GameSizes.getHeight(0.06), + ), + ), + ], + ), + ), + ], + ), + Image.asset(Images.homeScreenBg.toPath), + ], + ), + ), + ); + } +} + +class GameTitle extends StatelessWidget { + const GameTitle({super.key, required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: GameSizes.getHorizontalPadding(0.1), + child: FittedBox( + child: + // Text( + // title.toUpperCase(), + // style: TextStyle( + // letterSpacing: 4, + // color: Colors.white, + // fontWeight: FontWeight.w600, + // backgroundColor: Colors.black, + // fontSize: GameSizes.getWidth(0.1), + // ), + // ), + Text( + title.toUpperCase(), + style: TextStyle( + letterSpacing: 4, + color: Colors.white, + fontSize: GameSizes.getWidth(0.1), + fontWeight: FontWeight.w600, + fontStyle: FontStyle.italic, + shadows: const [ + Shadow(offset: Offset(-1.5, -1.5), color: Colors.black), + Shadow(offset: Offset(1.5, -1.5), color: Colors.black), + Shadow(offset: Offset(1.5, 1.5), color: Colors.black), + Shadow(offset: Offset(-1.5, 1.5), color: Colors.black), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/game_center/minesweeper/view/how_to_play_view/how_to_play_view.dart b/lib/views/game_center/minesweeper/view/how_to_play_view/how_to_play_view.dart new file mode 100755 index 0000000..0db8c7c --- /dev/null +++ b/lib/views/game_center/minesweeper/view/how_to_play_view/how_to_play_view.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; + +import '../../helper/shared_helper.dart'; +import '../../utils/game_colors.dart'; +import '../../utils/game_sizes.dart'; +import '../home_view/home_view.dart'; + +class HowToPlayView extends StatefulWidget { + const HowToPlayView({super.key, this.redirectToHome = false}); + + final bool redirectToHome; + + @override + State createState() => _HowToPlayViewState(); +} + +class _HowToPlayViewState extends State { + late PageController _pageController; + int _currentPage = 0; + bool _loading = true; + @override + void initState() { + super.initState(); + _pageController = PageController(initialPage: 0); + Future.delayed(const Duration(milliseconds: 500), () { + setState(() { + _loading = false; + }); + }); + } + + void _onPageChanged(int index) { + if (index < 0 || index > 4) return; + setState(() { + _currentPage = index; + _pageController.jumpToPage(index); + }); + } + + void _onSkip() async { + if (widget.redirectToHome) { + Navigator.of(context).pushReplacement(MaterialPageRoute( + builder: (BuildContext ctx) => const MinesweeperHomeView(), + )); + final sharedHelper = await SharedHelper.init(); + await sharedHelper.setHowToPlayShown(true); + } else { + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: GameColors.background, + appBar: AppBar( + elevation: 0, + centerTitle: true, + title: const Text('玩法说明'), + titleTextStyle: TextStyle( + color: Colors.black, + fontSize: GameSizes.getWidth(0.05), + ), + leading: const SizedBox(), + actions: [ + TextButton( + onPressed: _onSkip, + child: Text( + '跳过', + style: TextStyle( + color: Colors.black87, + fontSize: GameSizes.getWidth(0.038), + ), + ), + ) + ], + ), + body: Padding( + padding: GameSizes.getHorizontalPadding(0.015), + child: Column( + children: [ + Visibility( + visible: !_loading, + replacement: Expanded( + child: Column( + children: [ + SizedBox( + height: 1, + child: LinearProgressIndicator( + color: GameColors.appBar, + backgroundColor: Colors.white, + ), + ), + ], + ), + ), + child: Expanded( + child: PageView.builder( + itemCount: 5, + controller: _pageController, + onPageChanged: _onPageChanged, + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + return SingleChildScrollView( + child: Column( + children: [ + SizedBox(height: GameSizes.getHeight(0.05)), + Image.asset( + 'assets/games/minesweeper/images/how_to_play/how_to_play_${index + 1}.png', + fit: BoxFit.fitWidth, + ), + SizedBox(height: GameSizes.getHeight(0.04)), + Padding( + padding: GameSizes.getHorizontalPadding(0.045), + child: Text( + '玩法说明 ${index + 1}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: GameSizes.getWidth(0.045), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + }), + ), + ), + Padding( + padding: GameSizes.getHorizontalPadding(0.04) + .copyWith(bottom: GameSizes.getHeight(0.03)), + child: Row( + children: [ + Opacity( + opacity: _currentPage == 0 ? 0 : 1, + child: IconButton( + icon: const Icon(Icons.arrow_back), + iconSize: GameSizes.getWidth(0.06), + onPressed: () { + _onPageChanged(_currentPage - 1); + }, + ), + ), + Expanded( + child: Container( + height: GameSizes.getWidth(0.02), + alignment: Alignment.center, + child: Center( + child: ListView.builder( + itemCount: 5, + primary: false, + shrinkWrap: true, + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return Container( + margin: EdgeInsets.symmetric( + horizontal: GameSizes.getWidth(0.01)), + width: GameSizes.getWidth(0.02), + height: GameSizes.getWidth(0.02), + decoration: BoxDecoration( + color: index == _currentPage + ? GameColors.grassDark + : GameColors.grassLight.withOpacity(0.5), + shape: BoxShape.circle, + ), + ); + }), + ), + ), + ), + if (_currentPage >= 4) + IconButton( + icon: const Icon(Icons.check), + iconSize: GameSizes.getWidth(0.06), + onPressed: () { + _onSkip(); + }, + ) + else + IconButton( + icon: const Icon(Icons.arrow_forward), + iconSize: GameSizes.getWidth(0.06), + onPressed: () { + _onPageChanged(_currentPage + 1); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/game_center/minesweeper/view/settings_view/settings_view.dart b/lib/views/game_center/minesweeper/view/settings_view/settings_view.dart new file mode 100755 index 0000000..362f439 --- /dev/null +++ b/lib/views/game_center/minesweeper/view/settings_view/settings_view.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../../utils/exports.dart'; +import '../../widgets/option_group_widget.dart'; +import '../../widgets/option_widget.dart'; +import '../about_view/about_view.dart'; +import '../how_to_play_view/how_to_play_view.dart'; + +class SettingsView extends StatefulWidget { + const SettingsView({super.key}); + + @override + State createState() => _SettingsViewState(); +} + +class _SettingsViewState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: GameColors.background, + appBar: AppBar( + elevation: 0, + centerTitle: true, + title: const Text('设置'), + titleTextStyle: TextStyle( + color: Colors.black, + fontSize: GameSizes.getWidth(0.05), + ), + ), + body: SingleChildScrollView( + padding: GameSizes.getSymmetricPadding(0.05, 0.02), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OptionGroup(options: [ + OptionWidget( + title: '玩法说明', + iconData: Icons.play_arrow, + iconColor: Colors.green, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const HowToPlayView(), + )); + }, + ), + ]), + OptionGroup( + options: [ + OptionWidget( + title: '关于', + iconData: Icons.info, + iconColor: Colors.grey, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AboutView(), + )); + }, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/game_center/minesweeper/view/splash_view/splash_view.dart b/lib/views/game_center/minesweeper/view/splash_view/splash_view.dart new file mode 100755 index 0000000..549684e --- /dev/null +++ b/lib/views/game_center/minesweeper/view/splash_view/splash_view.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import '../../helper/shared_helper.dart'; +import '../../utils/game_sizes.dart'; +import '../home_view/home_view.dart'; +import '../how_to_play_view/how_to_play_view.dart'; + +class SplashView extends StatefulWidget { + const SplashView({super.key}); + + @override + State createState() => _SplashViewState(); +} + +class _SplashViewState extends State { + Future _init() async { + await SharedHelper.init().then((sharedHelper) async { + await sharedHelper.getHowToPlayShown().then((hasShown) { + if (hasShown) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext ctx) => const MinesweeperHomeView(), + ), + ); + } else { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const HowToPlayView(redirectToHome: true), + ), + ); + } + }); + }); + } + + @override + void initState() { + super.initState(); + _init(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: Center( + child: Image.asset( + 'assets/games/minesweeper/images/logo.png', + width: GameSizes.getWidth(0.5), + ), + ), + ); + } +} diff --git a/lib/views/game_center/minesweeper/view/statistics_view/statistics_view.dart b/lib/views/game_center/minesweeper/view/statistics_view/statistics_view.dart new file mode 100755 index 0000000..b7f353d --- /dev/null +++ b/lib/views/game_center/minesweeper/view/statistics_view/statistics_view.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import '../../utils/game_colors.dart'; +import '../../utils/game_consts.dart'; +import '../../utils/game_sizes.dart'; +import 'stats_table.dart'; + +class StatisticsView extends StatelessWidget { + const StatisticsView({super.key}); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 3, + child: Scaffold( + backgroundColor: GameColors.background, + appBar: AppBar( + elevation: 0, + centerTitle: true, + backgroundColor: GameColors.darkBlue, + title: const Text("游玩统计"), + titleTextStyle: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: GameSizes.getWidth(0.055), + ), + leading: IconButton( + onPressed: () { + Navigator.pop(context); + }, + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + ), + ), + bottom: TabBar( + indicatorColor: Colors.white, + indicatorSize: TabBarIndicatorSize.tab, + indicatorWeight: 2, + tabs: const [ + Tab(text: "简单"), + Tab(text: "中等"), + Tab(text: "困难"), + ], + labelStyle: TextStyle( + letterSpacing: 2, + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: GameSizes.getWidth(0.04), + ), + unselectedLabelStyle: const TextStyle(color: Colors.white60), + ), + ), + body: const TabBarView(children: [ + StatsTable(gameMode: GameMode.easy), + StatsTable(gameMode: GameMode.medium), + StatsTable(gameMode: GameMode.hard), + ]), + ), + ); + } +} diff --git a/lib/views/game_center/minesweeper/view/statistics_view/stats_table.dart b/lib/views/game_center/minesweeper/view/statistics_view/stats_table.dart new file mode 100755 index 0000000..6d84bc3 --- /dev/null +++ b/lib/views/game_center/minesweeper/view/statistics_view/stats_table.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; + +import '../../mixins/statistics_mixin.dart'; +import '../../utils/game_colors.dart'; +import '../../utils/game_consts.dart'; +import '../../utils/game_sizes.dart'; + +class StatsTable extends StatelessWidget with StatisticsMixin { + final GameMode gameMode; + const StatsTable({super.key, required this.gameMode}); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: getStatistic(gameMode), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (!snapshot.hasData) { + return const Center(child: Text("statisticsError")); + } + + Map stats = snapshot.data!; + + return Padding( + padding: GameSizes.getPadding(0.04), + child: Column( + children: [ + StatWidget( + iconData: Icons.grid_on_rounded, + statName: "游戏次数", + statValue: stats['gamesStarted'], + ), + StatWidget( + iconData: Icons.workspace_premium_outlined, + statName: "通关次数", + statValue: stats['gamesWon'], + ), + StatWidget( + iconData: Icons.flag_outlined, + statName: "通关概率", + statValue: stats['winRate'], + ), + StatWidget( + iconData: Icons.timer_outlined, + statName: "最短耗时", + statValue: stats['bestTime'], + ), + StatWidget( + iconData: Icons.access_time_sharp, + statName: "平均耗时", + statValue: stats['averageTime'], + ), + ], + ), + ); + }); + } +} + +class StatWidget extends StatelessWidget { + const StatWidget({ + super.key, + required this.iconData, + required this.statName, + required this.statValue, + }); + + final IconData iconData; + final String statName; + final dynamic statValue; + + @override + Widget build(BuildContext context) { + return Container( + padding: GameSizes.getPadding(0.04), + margin: EdgeInsets.only(bottom: GameSizes.getHeight(0.015)), + decoration: BoxDecoration( + color: GameColors.darkBlue.withOpacity(0.2), + borderRadius: GameSizes.getRadius(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + iconData, + size: GameSizes.getWidth(0.08), + color: Colors.black, + ), + SizedBox(height: GameSizes.getHeight(0.015)), + Text( + statName, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: GameSizes.getWidth(0.035), + ), + ), + ], + ), + Text( + statValue == null ? "-" : statValue.toString(), + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: GameSizes.getWidth(0.048), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/game_center/minesweeper/widgets/custom_button.dart b/lib/views/game_center/minesweeper/widgets/custom_button.dart new file mode 100755 index 0000000..14dc17d --- /dev/null +++ b/lib/views/game_center/minesweeper/widgets/custom_button.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +import '../utils/game_sizes.dart'; + +class CustomButton extends StatelessWidget { + const CustomButton({ + required this.text, + required this.onPressed, + this.icon, + this.child, + this.loading = false, + this.disabled = false, + this.radius = 12, + this.elevation = 10, + this.color, + this.textColor = Colors.black, + this.borderColor = Colors.transparent, + this.width, + this.height, + this.iconSize, + this.textSize, + this.padding, + super.key, + }); + + final String text; + final Function() onPressed; + final IconData? icon; + final bool loading; + final bool disabled; + final double elevation; + final double radius; + final Color? color; + final Color textColor; + final Color borderColor; + final double? iconSize; + final double? textSize; + final double? width; + final double? height; + final Widget? child; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width ?? GameSizes.getWidth(0.5), + height: height, + child: IgnorePointer( + ignoring: loading || disabled, + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + shadowColor: Colors.black.withOpacity(0.5), + disabledBackgroundColor: Colors.grey, + padding: padding ?? GameSizes.getSymmetricPadding(0.04, 0.01), + shape: RoundedRectangleBorder( + borderRadius: GameSizes.getRadius(radius), + side: BorderSide(color: borderColor), + ), + elevation: elevation, + foregroundColor: textColor, + ), + child: _getButtonChild(), + ), + ), + ); + } + + Widget _getButtonChild() { + if (loading) { + return const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ); + } else if (icon != null) { + return FittedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: iconSize ?? GameSizes.getWidth(0.1), + ), + const SizedBox(width: 10), + Text( + text, + style: TextStyle( + fontSize: textSize ?? GameSizes.getWidth(0.05), + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } else if (child != null) { + return child!; + } else { + return Text( + text, + style: TextStyle( + fontSize: textSize ?? GameSizes.getWidth(0.05), + fontWeight: FontWeight.bold, + ), + ); + } + } +} diff --git a/lib/views/game_center/minesweeper/widgets/game_popup_screen.dart b/lib/views/game_center/minesweeper/widgets/game_popup_screen.dart new file mode 100755 index 0000000..9b851b4 --- /dev/null +++ b/lib/views/game_center/minesweeper/widgets/game_popup_screen.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; + +import '../controller/game_controller.dart'; + +import '../utils/exports.dart'; +import '../view/home_view/home_view.dart'; +import 'play_again_button.dart'; + +class GamePopupScreen { + GamePopupScreen(_); + + static Future gameOver( + BuildContext context, { + required GameController controller, + required int? bestTime, + required bool win, + }) async { + String time = win + ? "0" * (3 - controller.timeElapsed.toString().length) + + controller.timeElapsed.toString() + : "---"; + + String record = bestTime != null + ? "0" * (3 - bestTime.toString().length) + bestTime.toString() + : "---"; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Padding( + padding: GameSizes.getHorizontalPadding(0.08), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + height: GameSizes.getWidth(0.8), + decoration: BoxDecoration( + color: GameColors.popupBackground, + borderRadius: GameSizes.getRadius(16), + ), + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 0, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset(win + ? Images.winScreen.toPath + : Images.loseScreen.toPath))), + Padding( + padding: GameSizes.getSymmetricPadding(0.03, 0.04), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: GameSizes.getWidth(0.18), + child: Image.asset(Images.stopwatch.toPath), + ), + SizedBox(height: GameSizes.getWidth(0.01)), + Text( + time, + style: TextStyle( + color: Colors.white, + fontSize: GameSizes.getWidth(0.09), + ), + ), + ], + ), + Column( + children: [ + SizedBox( + height: GameSizes.getWidth(0.18), + child: Image.asset(Images.trophy.toPath), + ), + SizedBox(height: GameSizes.getWidth(0.01)), + Text( + record, + style: TextStyle( + color: Colors.white, + fontSize: GameSizes.getWidth(0.09), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + SizedBox(height: GameSizes.getHeight(0.02)), + PlayAgainButton(controller: controller, win: win), + ], + ), + ), + ); + } + + static void exitGame(BuildContext context, GameController gameController) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + "退出扫雷", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: GameSizes.getWidth(0.05), + ), + ), + content: Text( + "确定要退出游戏吗?", + style: TextStyle( + fontSize: GameSizes.getWidth(0.04), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text( + "取消", + style: TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + fontSize: GameSizes.getWidth(0.04), + ), + ), + ), + TextButton( + onPressed: () { + gameController.createNewGame(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.pop(context); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (context) => const MinesweeperHomeView(), + ), + (route) => false, + ); + }); + }, + child: Text( + "确定", + style: TextStyle( + color: Colors.red.shade700, + fontWeight: FontWeight.bold, + fontSize: GameSizes.getWidth(0.04), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/game_center/minesweeper/widgets/option_group_widget.dart b/lib/views/game_center/minesweeper/widgets/option_group_widget.dart new file mode 100755 index 0000000..761155a --- /dev/null +++ b/lib/views/game_center/minesweeper/widgets/option_group_widget.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import '../utils/exports.dart'; + +class OptionGroup extends StatelessWidget { + const OptionGroup({ + required this.options, + this.groupDescription, + this.bgColor = Colors.white, + this.dividerColor, + this.dividerPadding, + super.key, + }); + + final List options; + final String? groupDescription; + final Color bgColor; + final Color? dividerColor; + final double? dividerPadding; + + @override + Widget build(BuildContext context) { + if (options.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: GameSizes.getHeight(0.015)), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: bgColor, + borderRadius: GameSizes.getRadius(14), + ), + child: Column( + children: List.generate( + options.length, + (index) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: GameSizes.getSymmetricPadding(0.015, 0.009), + child: options[index], + ), + Visibility( + visible: index < options.length - 1, + child: Container( + height: 0.35, + width: double.infinity, + margin: EdgeInsets.only( + left: dividerPadding ?? GameSizes.getWidth(0.13)), + color: dividerColor ?? Colors.grey[300], + ), + ), + ], + ); + }, + ), + ), + ), + if (groupDescription != null) ...[ + SizedBox(height: GameSizes.getHeight(0.01)), + Padding( + padding: GameSizes.getHorizontalPadding(0.02), + child: Text( + groupDescription!, + style: TextStyle( + color: GameColors.darkBlue, + fontSize: GameSizes.getWidth(0.04), + ), + ), + ), + ], + SizedBox( + height: groupDescription != null + ? GameSizes.getHeight(0.01) + : GameSizes.getHeight(0.015)), + ], + ); + } +} diff --git a/lib/views/game_center/minesweeper/widgets/option_widget.dart b/lib/views/game_center/minesweeper/widgets/option_widget.dart new file mode 100755 index 0000000..acf0c68 --- /dev/null +++ b/lib/views/game_center/minesweeper/widgets/option_widget.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../utils/exports.dart'; + +class OptionWidget extends StatelessWidget { + const OptionWidget({ + required this.title, + required this.iconData, + required this.iconColor, + this.loading = false, + this.onTap, + super.key, + }); + + final String title; + final IconData iconData; + final Color iconColor; + final Function()? onTap; + final bool loading; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: loading ? null : onTap, + borderRadius: GameSizes.getRadius(6), + child: Padding( + padding: GameSizes.getVerticalPadding(0.005), + child: Row( + children: [ + SizedBox(width: GameSizes.getWidth(0.01)), + Container( + width: GameSizes.getWidth(0.07), + height: GameSizes.getWidth(0.07), + padding: GameSizes.getPadding(0.01), + decoration: BoxDecoration( + color: iconColor, + borderRadius: GameSizes.getRadius(6), + ), + child: Center( + child: FittedBox( + child: Icon( + iconData, + color: Colors.white, + ), + ), + ), + ), + SizedBox(width: GameSizes.getWidth(0.04)), + Text(title, + style: TextStyle( + fontSize: GameSizes.getWidth(0.04), + color: Colors.black, + )), + const Spacer(), + Icon( + Icons.keyboard_arrow_right, + color: GameColors.darkBlue, + size: GameSizes.getWidth(0.07), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/game_center/minesweeper/widgets/play_again_button.dart b/lib/views/game_center/minesweeper/widgets/play_again_button.dart new file mode 100755 index 0000000..12e8dff --- /dev/null +++ b/lib/views/game_center/minesweeper/widgets/play_again_button.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '../controller/game_controller.dart'; +import '../helper/audio_player.dart'; +import '../utils/exports.dart'; +import 'custom_button.dart'; + +class PlayAgainButton extends StatelessWidget { + final GameController controller; + final bool win; + const PlayAgainButton( + {super.key, required this.controller, required this.win}); + + @override + Widget build(BuildContext context) { + return CustomButton( + text: win ? "再来一局" : "再试一次", + onPressed: () { + Navigator.pop(context); + controller.createNewGame(); + GameAudioPlayer().resetPlayer(controller.volumeOn); + }, + icon: Icons.refresh, + textColor: Colors.white, + iconSize: GameSizes.getWidth(0.08), + color: GameColors.popupPlayAgainButton, + padding: GameSizes.getSymmetricPadding(0.05, 0.015), + borderColor: Colors.white, + ); + } +} diff --git a/lib/views/game_center/minesweeper/widgets/skip_button.dart b/lib/views/game_center/minesweeper/widgets/skip_button.dart new file mode 100755 index 0000000..2ccadf2 --- /dev/null +++ b/lib/views/game_center/minesweeper/widgets/skip_button.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import '../controller/game_controller.dart'; +import '../utils/exports.dart'; +import 'custom_button.dart'; + +class SkipButton extends StatelessWidget { + final GameController gameController; + const SkipButton({super.key, required this.gameController}); + + @override + Widget build(BuildContext context) { + if (!gameController.isMineAnimationOn) return const SizedBox(); + + return Positioned( + bottom: GameSizes.getHeight(0.1), + child: CustomButton( + onPressed: () { + gameController.minesAnimation = false; + }, + text: "跳过", + icon: Icons.fast_forward_sharp, + width: GameSizes.getWidth(0.4), + iconSize: GameSizes.getWidth(0.08), + ), + ); + } +} diff --git a/lib/views/game_center/snake/index.dart b/lib/views/game_center/snake/index.dart new file mode 100755 index 0000000..30e631a --- /dev/null +++ b/lib/views/game_center/snake/index.dart @@ -0,0 +1,469 @@ +// ignore_for_file: curly_braces_in_flow_control_structures, constant_identifier_names + +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../services/my_get_storage.dart'; +import '../../../services/service_locator.dart'; + +// 2024-01-31 蛇头方向的枚举 +enum SnakeHead { + LEFT, + RIGHT, + UP, + DOWN, +} + +class SnakeGame extends StatefulWidget { + const SnakeGame({super.key}); + + @override + State createState() => _SnakeGameState(); +} + +class _SnakeGameState extends State with TickerProviderStateMixin { + // 统一简单存储操作的工具类实例 + final _simpleStorage = getIt(); + + // 当前得分 + late int _playerScore; + + // 缓存中的最佳得分 + late int _bsetScore; + + // 是否已经开始(true为还没开始?因为init的时候设置为true,蛇是静止的,下方也是显示开始按钮) + late bool _hasStarted; + // 蛇的动画 + late Animation _snakeAnimation; + // 蛇的控制器 + late AnimationController _snakeController; + // 蛇的初始位置和长度(蛇头在数字大的那边) + List _snake = [304, 305, 306, 307]; + + /// 用gridview创建游戏区域的棋盘, + /// _squareSize表示一行多少个方块,_noOfSquares + _squareSize表示方块总数量 + final int _noOfSquares = 380; + final int _squareSize = 20; + + // 动画的速度(理论上应该和下面蛇的速度的定时器一致, + // 不然可能出现蛇移动很慢动画更新很快也没意义,或者蛇已经走了,但动画没更新???实际上没出现这种情况) + final Duration _duration = const Duration(milliseconds: 250); + // 当前蛇前进的方形(用于控制改变) + late SnakeHead _currentSnakeDirection; + // 蛇的食物出现的位置 + late int _snakeFoodPosition; + // 随机函数实例 + final Random _random = Random(); + + // 游戏结束在当前页面上方显示一个浮层 + OverlayEntry? _overlayEntry; + + @override + void initState() { + super.initState(); + _setUpGame(); + } + + List getRandomSublist(List list) { + Random random = Random(); + // 随机选择起始索引,确保剩余空间可以容纳长度为4的子列表 + int startIndex = random.nextInt(list.length - 3); + // 返回从startIndex开始的长度为4的子列表 + return list.sublist(startIndex, startIndex + 4); + } + + void _setUpGame() { + _bsetScore = _simpleStorage.getSnakeBestScore() ?? 0; + _playerScore = 0; + + // 每次都随机生成初始蛇列表 + _snake = getRandomSublist( + List.generate(_noOfSquares + _squareSize, (index) => index), + ); + _currentSnakeDirection = SnakeHead.RIGHT; + _hasStarted = true; + do { + // 食物随机出现在某一个格子里面 + _snakeFoodPosition = _random.nextInt(_noOfSquares); + } while (_snake.contains(_snakeFoodPosition)); + + _snakeController = AnimationController( + vsync: this, + duration: _duration, + ); + + _snakeAnimation = CurvedAnimation( + curve: Curves.easeInOut, + parent: _snakeController, + ); + } + + void _gameStart() { + // 蛇的速度 + Timer.periodic(const Duration(milliseconds: 250), (Timer timer) { + _updateSnake(); + if (_hasStarted) timer.cancel(); + }); + } + + // 如果表示蛇的列表的最后一个元素(蛇头)是蛇列表中任何一个,表示蛇碰到自己了,游戏结束 + bool _gameOver() { + for (int i = 0; i < _snake.length - 1; i++) { + if (_snake.last == _snake[i]) { + return true; + } + } + return false; + } + + void _updateSnake() async { + if (!_hasStarted) { + if (!mounted) return; + setState(() { + // 当前分数就是吃掉的食物的数量*100 + _playerScore = (_snake.length - 4) * 100; + + // 如果方向有修改,进行相关判断 + switch (_currentSnakeDirection) { + case SnakeHead.DOWN: + // 方向朝下时,如果蛇头已经超过了最后一行,则从第一行出来; + // 如果还在中间范围,就是正常向下一行(即加1行的数量) + // 其他同理,不过左右就是单纯数字1而不是1行的数量了 + if (_snake.last > _noOfSquares) { + _snake.add( + _snake.last + _squareSize - (_noOfSquares + _squareSize), + ); + } else { + _snake.add(_snake.last + _squareSize); + } + break; + case SnakeHead.UP: + if (_snake.last < _squareSize) { + _snake.add( + _snake.last - _squareSize + (_noOfSquares + _squareSize), + ); + } else { + _snake.add(_snake.last - _squareSize); + } + break; + case SnakeHead.RIGHT: + if ((_snake.last + 1) % _squareSize == 0) { + _snake.add(_snake.last + 1 - _squareSize); + } else { + _snake.add(_snake.last + 1); + } + break; + case SnakeHead.LEFT: + if ((_snake.last) % _squareSize == 0) { + _snake.add(_snake.last - 1 + _squareSize); + } else { + _snake.add(_snake.last - 1); + } + } + + // 上面方向改变的操作,是在列表last(也就是蛇头)添加了一个元素, + // 因此如果转向没有吃到食物,则需要把蛇尾从列表移除(即移除列表索引第一个元素) + if (_snake.last != _snakeFoodPosition) { + _snake.removeAt(0); + } else { + // 如果吃到了食物,就要随机再生成一个新的食物 + do { + _snakeFoodPosition = _random.nextInt(_noOfSquares); + } while (_snake.contains(_snakeFoodPosition)); + } + + // 如果游戏结束了(蛇头碰到了蛇身),重置游戏开始状态并跳转到游戏结束页面 + if (_gameOver()) { + if (!mounted) return; + setState(() { + _hasStarted = !_hasStarted; + }); + + // 这里原本是跳转到新的gameover页面 + // ???其实如果和其他小游戏类似的话,就直接当前页面提示失败就好,不要跳转新页面 + // 这是一个简单的overlay示例 + _showGameOverOverlay(context); + } + }); + } + } + + void _showGameOverOverlay(BuildContext context) { + _overlayEntry = OverlayEntry(builder: (context) { + return Positioned( + top: 0, + left: 0, + right: 0, + bottom: 0, + child: Container( + color: Colors.black.withOpacity(0.5), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '游戏结束', + style: TextStyle( + // 取消overlay中文字的下划线 + decoration: TextDecoration.none, + color: Colors.redAccent, + fontSize: 50.0, + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic, + shadows: [ + Shadow( + offset: Offset(-1.5, -1.5), + color: Colors.black, + ), + Shadow( + offset: Offset(1.5, -1.5), + color: Colors.black, + ), + Shadow( + offset: Offset(1.5, 1.5), + color: Colors.black, + ), + Shadow( + offset: Offset(-1.5, 1.5), + color: Colors.black, + ), + ], + ), + ), + SizedBox(height: 25.sp), + Text( + '游戏得分: $_playerScore', + style: TextStyle( + // 取消overlay中文字的下划线 + decoration: TextDecoration.none, + fontSize: 24.sp, + color: Colors.white, + ), + ), + SizedBox(height: 25.sp), + ElevatedButton( + onPressed: () async { + _overlayEntry?.remove(); + + // 2023-02-01 更新完当前得分后更新最佳得分 + // 之前放在_updateSnake中,则会出现在达到最高分之后,每吃一次食物就因为更新最大值而卡顿的问题 + if (_playerScore > _bsetScore) { + await _simpleStorage.setSnakeBestScore(_playerScore); + if (!mounted) return; + setState(() { + _bsetScore = _playerScore; + }); + } + + if (!mounted) return; + setState(() { + _setUpGame(); + }); + }, + child: const Text('返回'), + ), + ], + ), + ), + ), + ); + }); + + Overlay.of(context).insert(_overlayEntry!); // 插入overlay + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + // 手势控制蛇前进的方向 + child: GestureDetector( + onVerticalDragUpdate: (drag) { + if (drag.delta.dy > 0 && _currentSnakeDirection != SnakeHead.UP) { + _currentSnakeDirection = SnakeHead.DOWN; + } else if (drag.delta.dy < 0 && + _currentSnakeDirection != SnakeHead.DOWN) + _currentSnakeDirection = SnakeHead.UP; + }, + onHorizontalDragUpdate: (drag) { + if (drag.delta.dx > 0 && _currentSnakeDirection != SnakeHead.LEFT) { + _currentSnakeDirection = SnakeHead.RIGHT; + } else if (drag.delta.dx < 0 && + _currentSnakeDirection != SnakeHead.RIGHT) + _currentSnakeDirection = SnakeHead.LEFT; + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Padding( + padding: EdgeInsets.all(20.sp), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + '当前得分: $_playerScore', + style: TextStyle(fontSize: 16.sp), + ), + Text( + '最佳得分: $_bsetScore', + style: TextStyle(fontSize: 16.sp), + ), + ], + ), + ), + SizedBox( + width: MediaQuery.of(context).size.width, + child: GridView.builder( + // 不管原始设计如何,我都修改为20*20的量 + // 避免修改原逻辑,只是把_noOfSquares值改动了 + itemCount: _squareSize + _noOfSquares, + shrinkWrap: true, // 设置为true以避免GridView高度冲突 + physics: const NeverScrollableScrollPhysics(), // 禁止GridView滚动 + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _squareSize, + ), + itemBuilder: (BuildContext context, int index) { + return Center( + // 方块的背景色 + child: Container( + color: const Color.fromARGB(127, 158, 173, 134), + padding: _snake.contains(index) + ? EdgeInsets.all(0.5.sp) + : EdgeInsets.all(0.3.sp), + + // 如果是食物或者蛇头的方块,就使用圆角部件,且圆弧大一些circular(7); + // 如果是蛇身,圆弧就稍微小一些circular(2.5); + // 如果是正常背景,圆弧就再小一些circular(1)(有这个圆弧时为了稍微显示一下背景的方块边框) + // 但现在我将上方padding默认留有边框线,这最后一个其实可以不用圆弧了 + child: ClipRRect( + borderRadius: index == _snakeFoodPosition || + index == _snake.last + ? BorderRadius.circular(7) + : _snake.contains(index) + ? BorderRadius.circular(2.5) + : BorderRadius.circular(0), + // 属于蛇身体的为黑色,食物为绿色,背景为蓝色 + child: Container( + color: _snake.contains(index) + ? Colors.black + : index == _snakeFoodPosition + ? Colors.green + : const Color.fromARGB(127, 138, 152, 117), + ), + ), + ), + ); + }, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: FractionallySizedBox( + widthFactor: 0.5, // 设置宽度为父容器的一半 + child: ElevatedButton.icon( + label: Text(_hasStarted ? '开始' : '暂停'), + onPressed: () { + setState(() { + if (_hasStarted) { + _snakeController.forward(); + } else { + _snakeController.reverse(); + } + _hasStarted = !_hasStarted; + _gameStart(); + }); + }, + icon: AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: _snakeAnimation, + ), + // 给按钮添加了圆弧 + style: ButtonStyle( + shape: MaterialStateProperty.resolveWith( + (Set states) { + return RoundedRectangleBorder( + // 指定圆角半径 + borderRadius: BorderRadius.circular(20), + ); + }, + ), + ), + ), + ), + ), + // ???这里的方向键还可以设定按钮的节流,就是在比如上方动画间隔时间内重复点击了按钮,只让第一次生效 + // 简单实现可以有一个_isButtonEnabled 标识,在onPressed前判断是否为true, + // 如果是true,点击后置为false,然后设置一个延迟函数,250ms之后再置为true + // 如果是false,点击不作为(例如onPressed置为null) + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: Icon(Icons.arrow_back, size: 32.sp), + onPressed: () { + // 2024-01-13 注意按键的逻辑: + // 只有当前方向是向左或者向右时候,才能改变为上下; + // 同理,只有当前方向是上下时,才能改变为左右。 + + // 这也是上面手势滑动类似,例如垂直向下滑动、且方向不是UP时才会调转蛇头为向下 + // <=> 点击向下按钮,只有当前方向是LEFT 或者RIGHT才生效 + if (_currentSnakeDirection == SnakeHead.UP || + _currentSnakeDirection == SnakeHead.DOWN) { + _currentSnakeDirection = SnakeHead.LEFT; + } + }, + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: Icon(Icons.arrow_upward, size: 32.sp), + onPressed: () { + if (_currentSnakeDirection == SnakeHead.LEFT || + _currentSnakeDirection == SnakeHead.RIGHT) { + _currentSnakeDirection = SnakeHead.UP; + } + }, + ), + SizedBox(height: 32.sp), + IconButton( + icon: Icon(Icons.arrow_downward, size: 32.sp), + onPressed: () { + if (_currentSnakeDirection == SnakeHead.LEFT || + _currentSnakeDirection == SnakeHead.RIGHT) { + _currentSnakeDirection = SnakeHead.DOWN; + } + }, + ), + ], + ), + IconButton( + icon: Icon(Icons.arrow_forward, size: 32.sp), + onPressed: () { + if (_currentSnakeDirection == SnakeHead.UP || + _currentSnakeDirection == SnakeHead.DOWN) { + _currentSnakeDirection = SnakeHead.RIGHT; + } + }, + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/game_center/snake/readme.md b/lib/views/game_center/snake/readme.md new file mode 100644 index 0000000..c0f1f8d --- /dev/null +++ b/lib/views/game_center/snake/readme.md @@ -0,0 +1,21 @@ +# 贪吃蛇小游戏说明 + +2024-01-31: + +原项目在 github 中的 [ahmedgulabkhan/SnakeGameFlutter](https://github.com/ahmedgulabkhan/SnakeGameFlutter)。 + +相较于其他几个小游戏,贪吃蛇的逻辑比较简单,大体上来讲: + +- 使用 gridview 构建网格,格子的索引就是蛇可以出现的位置; +- 默认一个网格范围类的`List`作为蛇本体,初始长度为 4,值为构建网格的列表中长度为 4 的连续子列表 +- 棋盘随机空白处生成一个食物 +- 每个网格有独立的颜色:默认的棋盘背景色、蛇身体的背景色、食物的背景色 +- 屏幕手势滑动或者下方方向键控制蛇头方向,吃到食物增加蛇身长度,碰到蛇身游戏结束。 +- 其他内容可查看代码注释 + +## 和原项目的一些改动: + +- 取消了主页面和游戏结束页面,只保留游戏界面,游戏结束使用 overlay 显示; +- 原始是手势滑动控制蛇头方向,新增了方向键控制方向,蛇头方向也单独使用枚举存放; +- 增加了历史最高分栏位,并存入缓存; +- 棋盘的颜色边框线、不在 appbar 显示分数等其他细节修改。 diff --git a/lib/views/game_center/sudoku/constant.dart b/lib/views/game_center/sudoku/constant.dart new file mode 100755 index 0000000..4384659 --- /dev/null +++ b/lib/views/game_center/sudoku/constant.dart @@ -0,0 +1,5 @@ +/// application global constant define here +class Constant{ + static const String packageName = "com.sevlow.app.sudoku"; + static const String githubRepository= "https://github.com/einsitang/sudoku-flutter"; +} diff --git a/lib/views/game_center/sudoku/effect/sound_effect.dart b/lib/views/game_center/sudoku/effect/sound_effect.dart new file mode 100755 index 0000000..a979286 --- /dev/null +++ b/lib/views/game_center/sudoku/effect/sound_effect.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:just_audio_background/just_audio_background.dart'; + +import '../../../../services/my_audio_handler.dart'; +import '../../../../services/service_locator.dart'; + +/// this class define sound effect +// class SoundEffect { +// static bool _init = false; + +// static final AudioPlayer _wrongAudio = AudioPlayer(); +// static final AudioPlayer _victoryAudio = AudioPlayer(); +// static final AudioPlayer _gameOverAudio = AudioPlayer(); +// // show user tips sound effect +// static final AudioPlayer _answerTipAudio = AudioPlayer(); + +// static init() async { +// if (!_init) { +// await _wrongAudio.setAsset("assets/games/sodoku/audio/wrong_tip.mp3"); +// await _victoryAudio.setAsset("assets/games/sodoku/audio/victory_tip.mp3"); +// await _gameOverAudio +// .setAsset("assets/games/sodoku/audio/gameover_tip.mp3"); +// await _answerTipAudio.setAsset("assets/games/sodoku/audio/wrong_tip.mp3"); +// } +// _init = true; +// } + +// static stuffError() async { +// if (!_init) { +// await init(); +// } +// await _wrongAudio.seek(Duration.zero); +// await _wrongAudio.play(); +// return; +// } + +// static solveVictory() async { +// if (!_init) { +// await init(); +// } +// await _victoryAudio.seek(Duration.zero); +// await _victoryAudio.play(); +// } + +// static gameOver() async { +// if (!_init) { +// await init(); +// } +// await _gameOverAudio.seek(Duration.zero); +// await _gameOverAudio.play(); +// } + +// static answerTips() async { +// if (!_init) { +// await init(); +// } +// await _answerTipAudio.seek(Duration.zero); +// await _answerTipAudio.play(); +// } +// } + +/// 和其他使用just audio插件做游戏背景音的类似,这和我默认的背景播放冲突,简单改造如下 + +/// this class define sound effect +class SoundEffect { + static bool _init = false; + + final _audioHandler = getIt(); + static late AudioPlayer _player; + + final String _wrongAudio = "assets/games/sodoku/audio/wrong_tip.mp3"; + final String _victoryAudio = "assets/games/sodoku/audio/victory_tip.mp3"; + final String _gameOverAudio = "assets/games/sodoku/audio/gameover_tip.mp3"; + final String _answerTipAudio = "assets/games/sodoku/audio/wrong_tip.mp3"; + + init() { + if (!_init) { + _player = _audioHandler.player(); + } + _init = true; + } + + Future playAudio(String audioPath, {bool loop = false}) async { + try { + await _player.setAudioSource(AudioSource.asset( + audioPath, + tag: MediaItem(id: audioPath, title: audioPath), + )); + + // 只播放一次 + await _player.setLoopMode(LoopMode.off); + await _player.seek(Duration.zero); + await _player.play(); + + await _player.stop(); + } catch (e) { + debugPrint("Error loading audio source: $e"); + } + } + + stuffError() async { + if (!_init) { + init(); + } + await playAudio(_wrongAudio); + return; + } + + solveVictory() async { + if (!_init) { + init(); + } + + await playAudio(_victoryAudio); + } + + gameOver() async { + if (!_init) { + init(); + } + await playAudio(_gameOverAudio); + } + + answerTips() async { + if (!_init) { + await init(); + } + await playAudio(_answerTipAudio); + } +} diff --git a/lib/views/game_center/sudoku/index.dart b/lib/views/game_center/sudoku/index.dart new file mode 100644 index 0000000..c4c68eb --- /dev/null +++ b/lib/views/game_center/sudoku/index.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:scoped_model/scoped_model.dart'; + +import 'effect/sound_effect.dart'; +import 'page/bootstrap.dart'; +import 'state/sudoku_state.dart'; + +class InitSudoku extends StatefulWidget { + const InitSudoku({super.key}); + + @override + State createState() => _InitSudokuState(); +} + +class _InitSudokuState extends State { + // initialization effect when application build before + _initEffect() async { + await SoundEffect().init(); + } + + Future _loadState() async { + await _initEffect(); + return await SudokuState.resumeFromDB(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _loadState(), + builder: (context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Container( + color: Colors.white, + alignment: Alignment.center, + child: Center( + child: Text( + '数独游戏初始化中...', + style: TextStyle(color: Colors.black, fontSize: 12.sp), + textDirection: TextDirection.ltr, + ), + ), + ); + } + if (snapshot.hasError) { + debugPrint("here is builder future throws error you shoud see it"); + } + SudokuState sudokuState = snapshot.data ?? SudokuState(); + BootstrapPage bootstrapPage = const BootstrapPage(title: "Loading"); + + // return ScopedModel( + // model: sudokuState, + // child: bootstrapPage, + // ); + + /// todo 2024-02-02 由于这个 scoped_model 还有去研究,所以这里还是app + return ScopedModel( + model: sudokuState, + child: MaterialApp( + title: 'Sudoku', + theme: ThemeData( + primarySwatch: Colors.blue, + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + locale: const Locale('zh'), + home: bootstrapPage, + ), + ); + }, + ); + } +} diff --git a/lib/views/game_center/sudoku/page/bootstrap.dart b/lib/views/game_center/sudoku/page/bootstrap.dart new file mode 100755 index 0000000..a2bec3d --- /dev/null +++ b/lib/views/game_center/sudoku/page/bootstrap.dart @@ -0,0 +1,273 @@ +// ignore_for_file: use_build_context_synchronously + +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart' hide Level; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import 'package:scoped_model/scoped_model.dart'; + +import 'package:sudoku_dart/sudoku_dart.dart'; + +import '../state/sudoku_state.dart'; +import '../util/localization_util.dart'; +import 'sudoku_game.dart'; + +final Logger log = Logger(); + +class BootstrapPage extends StatefulWidget { + const BootstrapPage({super.key, required this.title}); + + final String title; + + @override + State createState() => _BootstrapPageState(); +} + +Widget _buttonWrapper( + BuildContext context, + Widget Function(BuildContext content) childBuilder, +) { + return Container( + margin: const EdgeInsets.fromLTRB(0, 10, 0, 10), + width: 300, + height: 60, + child: childBuilder(context), + ); +} + +Widget _scanButton(BuildContext context) { + return Offstage( + offstage: true, + child: _buttonWrapper( + context, + (content) => CupertinoButton( + color: Colors.blue, + child: const Text("扫独解题"), + onPressed: () { + log.d("scan"); + }, + ))); +} + +Widget _continueGameButton(BuildContext context) { + return ScopedModelDescendant(builder: (context, child, state) { + String buttonLabel = "继续游戏"; + String continueMessage = + "${LocalizationUtils.localizationLevelName(context, state.level ?? Level.easy)} - ${state.timer}"; + return Offstage( + offstage: state.status != SudokuGameStatus.pause, + child: SizedBox( + width: 300, + height: 80, + child: CupertinoButton( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(buttonLabel, + style: const TextStyle( + color: Colors.blue, fontWeight: FontWeight.bold)), + Text(continueMessage, style: const TextStyle(fontSize: 13)) + ], + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SudokuGamePage(title: "Sudoku"), + ), + ); + }), + )); + }); +} + +void _internalSudokuGenerate(List args) { + Level level = args[0]; + SendPort sendPort = args[1]; + + Sudoku sudoku = Sudoku.generate(level); + log.d("数独生成完毕"); + sendPort.send(sudoku); +} + +Future _sudokuGenerate(BuildContext context, Level level) async { + String sudokuGenerateText = "正在为你加载数独,请稍后..."; + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Dialog( + child: Container( + padding: const EdgeInsets.all(10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + Container( + margin: const EdgeInsets.fromLTRB(10, 0, 0, 0), + child: Text(sudokuGenerateText), + ) + ], + ), + ), + ), + ); + + ReceivePort receivePort = ReceivePort(); + + Isolate isolate = await Isolate.spawn( + _internalSudokuGenerate, + [level, receivePort.sendPort], + ); + + var data = await receivePort.first; + Sudoku sudoku = data; + SudokuState state = ScopedModel.of(context); + state.initialize(sudoku: sudoku, level: level); + state.updateStatus(SudokuGameStatus.pause); + receivePort.close(); + isolate.kill(priority: Isolate.immediate); + + log.d("receivePort.listen done! ffffffffffffffffffffffff$receivePort"); + + debugPrint("应该关闭弹窗了-----"); + // dismiss dialog + Navigator.pop(context); +} + +Widget _newGameButton(BuildContext context) { + return _buttonWrapper( + context, + (_) => CupertinoButton( + color: Colors.blue, + child: const Text( + "新游戏", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + onPressed: () { + // cancel new game button + Widget cancelButton = SizedBox( + height: 60, + width: MediaQuery.of(context).size.width, + child: Container( + margin: const EdgeInsets.fromLTRB(0, 5, 0, 0), + child: CupertinoButton( + // color: Colors.red, + child: const Text("取消"), + onPressed: () { + Navigator.of(context).pop(false); + }, + ))); + + // iterative difficulty build buttons + List buttons = []; + for (var level in Level.values) { + String levelName = + LocalizationUtils.localizationLevelName(context, level); + buttons.add(SizedBox( + height: 60, + width: MediaQuery.of(context).size.width, + child: Container( + margin: const EdgeInsets.all(2.0), + child: CupertinoButton( + child: Text( + levelName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + onPressed: () async { + log.d( + "begin generator Sudoku with level : $levelName"); + // await _sudokuGenerate(context, level); + + ReceivePort receivePort = ReceivePort(); + + Isolate isolate = await Isolate.spawn( + _internalSudokuGenerate, + [level, receivePort.sendPort], + ); + + var data = await receivePort.first; + Sudoku sudoku = data; + SudokuState state = + ScopedModel.of(context); + state.initialize(sudoku: sudoku, level: level); + state.updateStatus(SudokuGameStatus.pause); + receivePort.close(); + isolate.kill(priority: Isolate.immediate); + + debugPrint("-------------"); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => + const SudokuGamePage(title: "Sudoku"), + ), + ); + }, + )))); + } + buttons.add(cancelButton); + + showCupertinoModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Material( + child: SizedBox( + height: 300, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: buttons))), + ), + ); + }, + ); + })); +} + +class _BootstrapPageState extends State { + @override + Widget build(BuildContext context) { + Widget body = Container( + color: Colors.white, + padding: const EdgeInsets.all(20.0), + child: Center( + child: Column( + children: [ + // logo + Expanded( + flex: 1, + child: Container( + alignment: Alignment.center, + color: Colors.white, + width: 280, + height: 280, + child: const Image( + image: AssetImage("assets/games/sodoku/image/logo.png"), + ))), + Expanded( + flex: 1, + child: + Column(mainAxisAlignment: MainAxisAlignment.end, children: [ + // continue the game + _continueGameButton(context), + // new game + _newGameButton(context), + // scanner ? + _scanButton(context), + ])) + ], + ))); + + return ScopedModelDescendant( + builder: (context, child, model) => Scaffold(body: body), + ); + } +} diff --git a/lib/views/game_center/sudoku/page/sudoku_game.dart b/lib/views/game_center/sudoku/page/sudoku_game.dart new file mode 100755 index 0000000..0019048 --- /dev/null +++ b/lib/views/game_center/sudoku/page/sudoku_game.dart @@ -0,0 +1,970 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:scoped_model/scoped_model.dart'; +import 'package:sudoku_dart/sudoku_dart.dart'; + +import '../constant.dart'; +import '../effect/sound_effect.dart'; +import '../state/sudoku_state.dart'; +import '../util/localization_util.dart'; +import 'sudoku_pause_cover.dart'; + +final Logger log = Logger(); + +String sudokuImagePre = "assets/games/sodoku/image"; + +final ButtonStyle flatButtonStyle = TextButton.styleFrom( + foregroundColor: Colors.black54, + shadowColor: Colors.blue, + minimumSize: const Size(88, 36), + padding: const EdgeInsets.symmetric(horizontal: 16.0), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(3.0)), + ), +); + +final ButtonStyle primaryFlatButtonStyle = TextButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.lightBlue, + shadowColor: Colors.blue, + minimumSize: const Size(88, 36), + padding: const EdgeInsets.symmetric(horizontal: 16.0), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(3.0)), + ), +); + +Image ideaPng = Image( + image: AssetImage("$sudokuImagePre/icon_idea.png"), + width: 25, + height: 25, +); +Image lifePng = Image( + image: AssetImage("$sudokuImagePre/icon_life.png"), + width: 25, + height: 25, +); + +class SudokuGamePage extends StatefulWidget { + const SudokuGamePage({super.key, required this.title}); + final String title; + + @override + State createState() => _SudokuGamePageState(); +} + +class _SudokuGamePageState extends State + with WidgetsBindingObserver { + int _chooseSudokuBox = 0; + bool _markOpen = false; + bool _manualPause = false; + + SudokuState get _state => ScopedModel.of(context); + + _aboutDialogAction(BuildContext context) { + Widget appIcon = GestureDetector( + child: Image( + image: AssetImage("$sudokuImagePre/sudoku_logo.png"), + width: 45, + height: 45, + ), + onDoubleTap: () { + columnWidget(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image( + image: AssetImage("$sudokuImagePre/sudoku_logo.png"), + ), + CupertinoButton( + child: const Text("Sudoku"), + onPressed: () { + Navigator.pop(context, false); + }, + ) + ]); + } + + showDialog(context: context, builder: columnWidget); + }, + ); + + return showAboutDialog( + applicationIcon: appIcon, + context: context, + children: [ + GestureDetector( + child: const Text( + "Github Repository", + style: TextStyle(color: Colors.blue), + ), + // onTap: () async { + // if (await canLaunchUrlString(Constant.githubRepository)) { + // if (Platform.isAndroid) { + // await launchUrlString(Constant.githubRepository, + // mode: LaunchMode.platformDefault); + // } else { + // await launchUrlString(Constant.githubRepository, + // mode: LaunchMode.externalApplication); + // } + // } else { + // log.e("can't open browser to url : ${Constant.githubRepository}"); + // } + // }, + ), + Container( + margin: const EdgeInsets.fromLTRB(0, 10, 0, 5), + padding: const EdgeInsets.all(0), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Sudoku powered by Flutter", + style: TextStyle(fontSize: 12), + ), + Text( + Constant.githubRepository, + style: TextStyle(fontSize: 12), + ) + ])) + ], + ); + } + + bool _isOnlyReadGrid(int index) => (_state.sudoku?.puzzle[index] ?? 0) != -1; + + // 游戏盘点,检查是否游戏结束 + // check the game is done + void _gameStackCount() { + if (_state.isComplete) { + _pauseTimer(); + _state.updateStatus(SudokuGameStatus.success); + return _gameOver(); + } + } + + /// game over trigger function + /// 游戏结束触发 执行判断逻辑 + void _gameOver() async { + bool isWinner = _state.status == SudokuGameStatus.success; + String title, conclusion; + Function playSoundEffect; + + // @TODO this place wait for I18N support + + // define i18n begin + const String elapsedTimeText = "耗时"; + const String winnerConclusionText = "恭喜你完成 [%level%] 数独挑战!"; + const String failureConclusionText = "很遗憾,本轮 [%level%] 数独错误次数太多,挑战失败!"; + final String levelLabel = + LocalizationUtils.localizationLevelName(context, _state.level!); + // define i18n end + if (isWinner) { + title = "厉害,干得漂亮!"; + conclusion = winnerConclusionText.replaceFirst("%level%", levelLabel); + playSoundEffect = SoundEffect().solveVictory; + } else { + title = "可惜,就差一点!"; + conclusion = failureConclusionText.replaceFirst("%level%", levelLabel); + playSoundEffect = SoundEffect().gameOver; + } + + // route to game over show widget page + PageRouteBuilder gameOverPageRouteBuilder = PageRouteBuilder( + opaque: false, + pageBuilder: (BuildContext context, animation, _) { + // sound effect : victory or failure + playSoundEffect(); + // game over show widget + Widget gameOverWidget = Scaffold( + backgroundColor: Colors.white.withOpacity(0.85), + body: Align( + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + flex: 1, + child: Align( + alignment: Alignment.center, + child: Text( + title, + style: TextStyle( + color: isWinner ? Colors.black : Colors.redAccent, + fontSize: 26, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Expanded( + flex: 2, + child: Column( + children: [ + Container( + padding: const EdgeInsetsDirectional.fromSTEB( + 25.0, + 0.0, + 25.0, + 0.0, + ), + child: Text( + conclusion, + style: const TextStyle( + fontSize: 16, + height: 1.5, + ), + ), + ), + Container( + margin: const EdgeInsets.fromLTRB(0, 15, 0, 10), + child: Text( + "$elapsedTimeText : ${_state.timer}'s", + style: const TextStyle(color: Colors.blue), + ), + ), + Container( + padding: const EdgeInsets.all(10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Offstage( + offstage: + _state.status == SudokuGameStatus.success, + child: const IconButton( + icon: Icon(Icons.tv), + onPressed: null, + ), + ), + const IconButton( + icon: Icon(Icons.thumb_up), + onPressed: null, + ), + IconButton( + icon: const Icon(Icons.exit_to_app), + onPressed: () { + Navigator.pop(context, "exit"); + }, + ) + ], + ), + ) + ], + ), + ) + ], + ), + ), + ); + + return ScaleTransition( + scale: Tween(begin: 3.0, end: 1.0).animate(animation), + child: gameOverWidget, + ); + }, + ); + + String signal = await Navigator.of(context).push(gameOverPageRouteBuilder); + switch (signal) { + case "ad": + // @TODO give extra life logic coding + // may do something to give user extra life , like watch ad video / make comment of this app ? + break; + case "exit": + default: + if (!mounted) return; + Navigator.pop(context); + break; + } + } + + // fill zone [ 1 - 9 ] + Widget _fillZone(BuildContext context) { + List fillTools = List.generate(9, (index) { + int num = index + 1; + bool hasNumStock = _state.hasNumStock(num); + Future Function()? fillOnPressed; + if (!hasNumStock) { + fillOnPressed = null; + } else { + fillOnPressed = () async { + log.d("input : $num"); + if (_isOnlyReadGrid(_chooseSudokuBox)) { + // 非填空项 + return; + } + if (_state.status != SudokuGameStatus.gaming) { + // 未在游戏进行时 + return; + } + if (_markOpen) { + /// markOpen , mean use mark notes + log.d("填写笔记"); + _state.switchMark(_chooseSudokuBox, num); + } else { + // 填写数字 + _state.switchRecord(_chooseSudokuBox, num); + // 判断真伪 + if (_state.record[_chooseSudokuBox] != -1 && + _state.sudoku!.solution[_chooseSudokuBox] != num) { + // 填入错误数字 wrong answer on _chooseSudokuBox with num + _state.lifeLoss(); + if (_state.life <= 0) { + // 游戏结束 + return _gameOver(); + } + + // "\nWrong Input\nYou can't afford ${_state.life} more turnovers" + String wrongInputAlertText = "填写错误\n你还可以尝试 %attempts% 次"; + wrongInputAlertText = wrongInputAlertText.replaceFirst( + "%attempts%", + "${_state.life}", + ); + String gotItText = "明白"; + + showCupertinoDialog( + context: context, + builder: (context) { + // sound stuff error + SoundEffect().stuffError(); + return CupertinoAlertDialog( + title: const Text("Oops..."), + content: Text(wrongInputAlertText), + actions: [ + CupertinoDialogAction( + child: Text(gotItText), + onPressed: () { + Navigator.of(context).pop(); + }, + ) + ], + ); + }); + + return; + } + _gameStackCount(); + } + }; + } + + Color recordFontColor = hasNumStock ? Colors.black : Colors.white; + Color recordBgColor = hasNumStock ? Colors.black12 : Colors.white24; + + Color markFontColor = hasNumStock ? Colors.white : Colors.white; + Color markBgColor = hasNumStock ? Colors.black : Colors.white24; + + return Expanded( + flex: 1, + child: Container( + margin: const EdgeInsets.all(2), + decoration: const BoxDecoration(border: BorderDirectional()), + child: CupertinoButton( + color: _markOpen ? markBgColor : recordBgColor, + padding: const EdgeInsets.all(1), + onPressed: fillOnPressed, + child: Text( + '${index + 1}', + style: TextStyle( + color: _markOpen ? markFontColor : recordFontColor, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + }); + + fillTools.add( + Expanded( + flex: 1, + child: CupertinoButton( + padding: const EdgeInsets.all(8), + child: Image( + image: AssetImage("$sudokuImagePre/icon_eraser.png"), + width: 40, + height: 40, + ), + onPressed: () { + log.d(""" + when ${_chooseSudokuBox + 1} is not a puzzle , then clean the choose \n + 清除 ${_chooseSudokuBox + 1} 选型 , 如果他不是固定值的话 + """); + if (_isOnlyReadGrid(_chooseSudokuBox)) { + // read only item , skip it - 只读格 + return; + } + if (_state.status != SudokuGameStatus.gaming) { + // not playing , skip it - 未在游戏进行时 + return; + } + _state.cleanMark(_chooseSudokuBox); + _state.cleanRecord(_chooseSudokuBox); + }, + ), + ), + ); + + return Align( + alignment: Alignment.centerLeft, + child: SizedBox( + height: 40, + width: MediaQuery.of(context).size.width, + child: Row(children: fillTools), + ), + ); + } + + Widget _toolZone(BuildContext context) { + // pause button tap function + pauseOnPressed() { + if (_state.status != SudokuGameStatus.gaming) { + return; + } + + // 标记手动暂停 + setState(() { + _manualPause = true; + }); + + _pause(); + Navigator.push( + context, + PageRouteBuilder( + opaque: false, + pageBuilder: (BuildContext context, _, __) { + return const SudokuPauseCoverPage(); + }, + ), + ).then((_) { + _gaming(); + + // 解除手动暂停 + setState(() { + _manualPause = false; + }); + }); + } + + // tips button tap function + // ignore: prefer_typing_uninitialized_variables + var tipsOnPressed; + if (_state.hint > 0) { + tipsOnPressed = () { + // tips next cell answer + log.d("top tips button"); + int hint = _state.hint; + if (hint <= 0) { + return; + } + List puzzle = _state.sudoku!.puzzle; + List solution = _state.sudoku!.solution; + List record = _state.record; + // random point tips + int randomBeginPoint = Random().nextInt(puzzle.length); + for (int i = 0; i < puzzle.length; i++) { + int index = (i + randomBeginPoint) % puzzle.length; + if (puzzle[index] == -1 && record[index] == -1) { + SoundEffect().answerTips(); + _state.setRecord(index, solution[index]); + _state.hintLoss(); + _chooseSudokuBox = index; + _gameStackCount(); + return; + } + } + }; + } + + // mark button tap function + markOnPressed() { + log.d("enable mark function - 启用笔记功能"); + setState(() { + _markOpen = !_markOpen; + }); + } + + // define i18n text begin + var exitGameText = "退出游戏"; + var cancelText = "取消"; + var pauseText = "暂停游戏"; + var tipsText = "随机提示"; + var enableMarkText = "启用笔记"; + var closeMarkText = "关闭笔记"; + var exitGameContentText = "是否要结束本轮数独?"; + // define i18n text end + exitGameOnPressed() async { + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text( + exitGameText, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + content: Text(exitGameContentText), + actions: [ + TextButton( + style: flatButtonStyle, + onPressed: () { + Navigator.pop(context, true); + }, + child: Text(exitGameText), + ), + TextButton( + style: primaryFlatButtonStyle, + onPressed: () { + Navigator.pop(context, false); + }, + child: Text(cancelText), + ), + ]); + }, + ).then((val) { + bool confirm = val; + if (confirm == true) { + // exit the game 退出游戏 + ScopedModel.of(context).initialize(); + Navigator.pop(context); + } + }); + } + + return Container( + height: 50, + padding: const EdgeInsets.all(5), + child: Row( + children: [ + // 暂停游戏 + Expanded( + flex: 1, + child: Align( + alignment: Alignment.centerLeft, + child: CupertinoButton( + padding: const EdgeInsets.all(5), + onPressed: pauseOnPressed, + child: Text( + pauseText, + style: const TextStyle(fontSize: 15), + ), + ), + ), + ), + // tips 提示 + Expanded( + flex: 1, + child: Align( + alignment: Alignment.center, + child: CupertinoButton( + padding: const EdgeInsets.all(5), + onPressed: tipsOnPressed, + child: Text( + tipsText, + style: const TextStyle(fontSize: 15), + ), + ), + ), + ), + // mark 笔记 + Expanded( + flex: 1, + child: Align( + alignment: Alignment.center, + child: CupertinoButton( + padding: const EdgeInsets.all(5), + onPressed: markOnPressed, + child: Text( + _markOpen ? closeMarkText : enableMarkText, + style: const TextStyle(fontSize: 15), + ), + ), + ), + ), + // 退出 + Expanded( + flex: 1, + child: Align( + alignment: Alignment.centerRight, + child: CupertinoButton( + padding: const EdgeInsets.all(5), + onPressed: exitGameOnPressed, + child: Text( + exitGameText, + style: const TextStyle(fontSize: 15), + ), + ), + ), + ) + ], + ), + ); + } + + /// 计算网格背景色 + Color _gridInWellBgColor(int index) { + Color gridWellBackgroundColor; + // same zones + List zoneIndexes = + Matrix.getZoneIndexes(zone: Matrix.getZone(index: index)); + // same rows + List rowIndexes = Matrix.getRowIndexes(Matrix.getRow(index)); + // same columns + List colIndexes = Matrix.getColIndexes(Matrix.getCol(index)); + + Set indexSet = {}; + indexSet.addAll(zoneIndexes); + indexSet.addAll(rowIndexes); + indexSet.addAll(colIndexes); + + if (index == _chooseSudokuBox) { + gridWellBackgroundColor = const Color.fromARGB(255, 0x70, 0xF3, 0xFF); + } else if (indexSet.contains(_chooseSudokuBox)) { + gridWellBackgroundColor = const Color.fromARGB(255, 0x44, 0xCE, 0xF6); + } else { + if (Matrix.getZone(index: index).isOdd) { + gridWellBackgroundColor = Colors.white; + } else { + gridWellBackgroundColor = const Color.fromARGB(255, 0xCC, 0xCC, 0xCC); + } + } + return gridWellBackgroundColor; + } + + /// + /// 正常网格控件 + /// + Widget _gridInWellWidget( + BuildContext context, int index, int num, GestureTapCallback onTap) { + Sudoku sudoku = _state.sudoku!; + List puzzle = sudoku.puzzle; + List solution = sudoku.solution; + List record = _state.record; + bool readOnly = true; + bool isWrong = false; + int num = puzzle[index]; + if (puzzle[index] == -1) { + num = record[index]; + readOnly = false; + + if (record[index] != -1 && record[index] != solution[index]) { + isWrong = true; + } + } + return InkWell( + highlightColor: Colors.blue, + customBorder: Border.all(color: Colors.blue), + onTap: onTap, + child: Center( + child: Container( + alignment: Alignment.center, + margin: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: _gridInWellBgColor(index), + border: Border.all(color: Colors.black12)), + child: Text( + '${num == -1 ? '' : num}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 25, + fontWeight: readOnly ? FontWeight.w800 : FontWeight.normal, + color: readOnly + ? Colors.blueGrey + : (isWrong + ? Colors.red + : const Color.fromARGB(255, 0x3B, 0x2E, 0x7E)), + ), + ), + ), + )); + } + + /// + /// 笔记网格控件 + /// + Widget _markGridWidget( + BuildContext context, int index, GestureTapCallback onTap) { + Widget markGrid = InkWell( + highlightColor: Colors.blue, + customBorder: Border.all(color: Colors.blue), + onTap: onTap, + child: Container( + alignment: Alignment.center, + margin: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: _gridInWellBgColor(index), + border: Border.all(color: Colors.black12), + ), + child: GridView.builder( + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + itemCount: 9, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + ), + itemBuilder: (BuildContext context, int index) { + String markNum = + '${_state.mark[index][index + 1] ? index + 1 : ""}'; + return Text( + markNum, + textAlign: TextAlign.center, + style: TextStyle( + color: _chooseSudokuBox == index + ? Colors.white + : const Color.fromARGB(255, 0x16, 0x69, 0xA9), + fontSize: 12), + ); + }, + ), + ), + ); + + return markGrid; + } + + // well onTop function + _wellOnTapBuilder(index) { + // log.d("_wellOnTapBuilder build $index ..."); + return () { + setState(() { + _chooseSudokuBox = index; + }); + if (_state.sudoku!.puzzle[index] != -1) { + return; + } + log.d('choose position : $index'); + }; + } + + Widget _bodyWidget(BuildContext context) { + if (_state.sudoku == null) { + return Container( + color: Colors.white, + alignment: Alignment.center, + child: const Center( + child: Text('Sudoku Exiting...', + style: TextStyle(color: Colors.black), + textDirection: TextDirection.ltr), + ), + ); + } + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + /// status zone + /// life / tips / timer on here + Container( + height: 50, + padding: const EdgeInsets.all(10.0), + // color: Colors.red, + child: Row( + children: [ + Expanded( + flex: 1, + child: Row( + children: [ + lifePng, + Text( + " x ${_state.life}", + style: const TextStyle(fontSize: 18), + ) + ], + ), + ), + // indicator + Expanded( + flex: 2, + child: Container( + alignment: AlignmentDirectional.center, + child: Text( + "${LocalizationUtils.localizationLevelName(context, _state.level!)} - ${_state.timer} - ${LocalizationUtils.localizationGameStatus(context, _state.status)}", + ), + ), + ), + // tips + Expanded( + flex: 1, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ideaPng, + Text( + " x ${_state.hint}", + style: const TextStyle(fontSize: 18), + ) + ], + ), + ) + ], + ), + ), + + /// 9 x 9 cells sudoku puzzle board + /// the whole sudoku game draw it here + GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: 81, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 9, + ), + itemBuilder: ((BuildContext context, int index) { + int num = -1; + if (_state.sudoku?.puzzle.length == 81) { + num = _state.sudoku!.puzzle[index]; + } + + // 用户做标记 + bool isUserMark = _state.sudoku!.puzzle[index] == -1 && + _state.mark[index].any((element) => element); + + if (isUserMark) { + return _markGridWidget( + context, index, _wellOnTapBuilder(index)); + } + + return _gridInWellWidget( + context, index, num, _wellOnTapBuilder(index)); + })), + + /// user input zone + /// use fillZone choose number fill cells or mark notes + /// use toolZone to pause / exit game + Container(margin: const EdgeInsets.fromLTRB(0, 5, 0, 5)), + _fillZone(context), + _toolZone(context) + ], + ), + ); + } + + @override + void deactivate() { + log.d("on deactivate"); + WidgetsBinding.instance.removeObserver(this); + super.deactivate(); + } + + @override + void dispose() { + log.d("on dispose"); + _pauseTimer(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _gaming(); + } + + @override + void didChangeDependencies() { + log.d("didChangeDependencies"); + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(SudokuGamePage oldWidget) { + super.didUpdateWidget(oldWidget); + log.d("on did update widget"); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + log.d("is paused app lifecycle state"); + _pause(); + break; + case AppLifecycleState.resumed: + log.d("is resumed app lifecycle state"); + if (!_manualPause) { + _gaming(); + } + break; + default: + break; + } + } + + // 定时器 + Timer? _timer; + + void _gaming() { + if (_state.status == SudokuGameStatus.pause) { + log.d("on _gaming"); + _state.updateStatus(SudokuGameStatus.gaming); + _state.persistent(); + _beginTimer(); + } + } + + void _pause() { + if (_state.status == SudokuGameStatus.gaming) { + log.d("on _pause"); + _state.updateStatus(SudokuGameStatus.pause); + _state.persistent(); + _pauseTimer(); + } + } + + // 开始计时 + void _beginTimer() { + log.d("timer begin"); + _timer ??= Timer.periodic(const Duration(seconds: 1), (timer) { + if (_state.status == SudokuGameStatus.gaming) { + _state.tick(); + return; + } + timer.cancel(); + }); + } + + // 暂停计时 + void _pauseTimer() { + if (_timer != null) { + if (_timer!.isActive) { + _timer!.cancel(); + } + } + _timer = null; + } + + @override + Widget build(BuildContext context) { + log.d("on build"); + Scaffold scaffold = Scaffold( + appBar: AppBar(title: Text(widget.title), actions: [ + IconButton( + icon: const Icon(Icons.info_outline), + onPressed: () { + // ignore: void_checks + return _aboutDialogAction(context); + }, + ) + ]), + body: PopScope( + canPop: false, + onPopInvoked: (didPop) async { + if (didPop) { + return; + } + _pause(); + }, + child: ScopedModelDescendant( + builder: (context, child, model) => _bodyWidget(context), + ), + ), + ); + + return scaffold; + } +} diff --git a/lib/views/game_center/sudoku/page/sudoku_pause_cover.dart b/lib/views/game_center/sudoku/page/sudoku_pause_cover.dart new file mode 100755 index 0000000..d5e02c4 --- /dev/null +++ b/lib/views/game_center/sudoku/page/sudoku_pause_cover.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:scoped_model/scoped_model.dart'; + +import '../state/sudoku_state.dart'; +import '../util/localization_util.dart'; + +class SudokuPauseCoverPage extends StatefulWidget { + const SudokuPauseCoverPage({super.key}); + + @override + State createState() => _SudokuPauseCoverPageState(); +} + +class _SudokuPauseCoverPageState extends State { + SudokuState get _state => ScopedModel.of(context); + + @override + Widget build(BuildContext context) { + TextStyle pageTextStyle = const TextStyle(color: Colors.white); + + // define i18n begin + const String levelText = "难度"; + const String pauseGameText = "游戏暂停"; + const String elapsedTimeText = "耗时"; + const String continueGameContentText = "双击屏幕继续游戏"; + // define i18n end + Widget titleView = const Align( + child: Text(pauseGameText, style: TextStyle(fontSize: 26)), + ); + Widget bodyView = Align( + child: Column(children: [ + Expanded(flex: 3, child: titleView), + Expanded( + flex: 5, + child: Column(children: [ + Text( + "$levelText [${LocalizationUtils.localizationLevelName(context, _state.level!)}] $elapsedTimeText : ${_state.timer}", + ) + ])), + const Expanded( + flex: 1, + child: Align( + alignment: Alignment.center, child: Text(continueGameContentText)), + ) + ])); + + onDoubleTap() { + log.d("double click : leave this stack"); + Navigator.pop(context); + } + + onTap() { + log.d("single click , do nothing"); + } + + return GestureDetector( + onTap: onTap, + onDoubleTap: onDoubleTap, + child: Scaffold( + backgroundColor: Colors.black.withOpacity(0.98), + body: DefaultTextStyle(style: pageTextStyle, child: bodyView), + ), + ); + } +} diff --git a/lib/views/game_center/sudoku/readme.md b/lib/views/game_center/sudoku/readme.md new file mode 100644 index 0000000..3a5877a --- /dev/null +++ b/lib/views/game_center/sudoku/readme.md @@ -0,0 +1,21 @@ +# 扫雷小游戏说明(TBC) + +2024-02-02: + +原项目在 github 中的 [einsitang/sudoku-flutter](https://github.com/einsitang/sudoku-flutter)。 + +原项目也比较完善,有需求可以看看 + +## 和原项目的一些改动: + +因为时间关系,基本上只是拿来放在这里了,很多都没处理,甚至其主页都还是用的 MaterialApp,者一个应用有两个 MaterialApp 就很不合理,放假回来之后再处理: + +- 删除了 i10n 的部分,简单替换了中文(主要因为报错太多,为了快速跑起来) +- TODO + - 原本在加载数独时的\_sudokuGenerate 会有加载中弹窗,但加载完成之后弹窗未关闭(现在是不弹窗了) + - scoped_model 和 modal_bottom_sheet 库还没看 + - 应该替换掉 hive 和 hive_flutter + - debug 的 logger 也没有必要 + - 只有一个小地方使用了 sprintf 库,可以想办法取消掉 + - 在使用 just_audio 的前提下,添加 audio_service 支持多音源 + - 需要让播放游戏背景音乐时,状态栏不显示 diff --git a/lib/views/game_center/sudoku/state/hive/level_type_adapter.dart b/lib/views/game_center/sudoku/state/hive/level_type_adapter.dart new file mode 100755 index 0000000..8999338 --- /dev/null +++ b/lib/views/game_center/sudoku/state/hive/level_type_adapter.dart @@ -0,0 +1,24 @@ +import 'package:hive/hive.dart'; +import 'package:sudoku_dart/sudoku_dart.dart'; + +class SudokuLevelAdapter extends TypeAdapter{ + + @override + final typeId = 1; + + @override + void write(BinaryWriter writer, Level obj) { + writer.writeString(obj.toString()); + } + + @override + Level read(BinaryReader reader) { + String levelStr = reader.readString(); + for(Level level in Level.values){ + if(level.toString() == levelStr){ + return level; + } + } + return Level.easy; + } +} diff --git a/lib/views/game_center/sudoku/state/hive/sudoku_type_adapter.dart b/lib/views/game_center/sudoku/state/hive/sudoku_type_adapter.dart new file mode 100755 index 0000000..93b2899 --- /dev/null +++ b/lib/views/game_center/sudoku/state/hive/sudoku_type_adapter.dart @@ -0,0 +1,20 @@ +import 'package:hive/hive.dart'; +import 'package:sudoku_dart/sudoku_dart.dart'; + +class SudokuAdapter extends TypeAdapter{ + + @override + final typeId = 0; + + @override + void write(BinaryWriter writer, Sudoku obj) { + List puzzle = obj.puzzle; + writer.writeIntList(puzzle); + } + + @override + Sudoku read(BinaryReader reader) { + List list = reader.readIntList(); + return Sudoku(list); + } +} diff --git a/lib/views/game_center/sudoku/state/sudoku_state.dart b/lib/views/game_center/sudoku/state/sudoku_state.dart new file mode 100755 index 0000000..f053322 --- /dev/null +++ b/lib/views/game_center/sudoku/state/sudoku_state.dart @@ -0,0 +1,344 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:logger/logger.dart' hide Level; +import 'package:scoped_model/scoped_model.dart'; +import 'package:sprintf/sprintf.dart'; +import 'package:sudoku_dart/sudoku_dart.dart'; + +import '../constant.dart'; +import 'hive/level_type_adapter.dart'; +import 'hive/sudoku_type_adapter.dart'; + +part 'sudoku_state.g.dart'; + +final Logger log = Logger(); + +/// +/// global constant +class _Default { + static const int life = 3; + static const int hint = 2; +} + +@HiveType(typeId: 6) +enum SudokuGameStatus { + @HiveField(0) + initialize, + @HiveField(1) + gaming, + @HiveField(2) + pause, + @HiveField(3) + fail, + @HiveField(4) + success +} + +@HiveType(typeId: 5) +class SudokuState extends Model { + static const String _hiveBoxName = "sudoku.store"; + static const String _hiveStateName = "state"; + + @HiveField(0) + late SudokuGameStatus status; + + // sudoku + @HiveField(1) + Sudoku? sudoku; + + // level + @HiveField(2) + Level? level; + + // timing + @HiveField(3) + late int timing; + + // 可用生命 + @HiveField(4) + late int life; + + // 可用提示 + @HiveField(5) + late int hint; + + // sudoku 填写记录 + @HiveField(6) + late List record; + + // 笔记 + @HiveField(7) + late List> mark; + + // 是否完成 + bool get isComplete { + if (sudoku == null) { + return false; + } + int value; + for (int i = 0; i < 81; ++i) { + value = sudoku!.puzzle[i]; + if (value == -1) { + value = record[i]; + } + if (value == -1) { + return false; + } + } + + return true; + } + + SudokuState({Level? level, Sudoku? sudoku}) { + initialize(level: level, sudoku: sudoku); + } + + static SudokuState newSudokuState({Level? level, Sudoku? sudoku}) { + SudokuState state = SudokuState(level: level, sudoku: sudoku); + return state; + } + + void initialize({Level? level, Sudoku? sudoku}) { + status = SudokuGameStatus.initialize; + this.sudoku = sudoku; + this.level = level; + timing = 0; + life = _Default.life; + hint = _Default.hint; + record = List.generate(81, (index) => -1); + mark = List.generate(81, (index) => List.generate(10, (index) => false)); + notifyListeners(); + } + + void tick() { + timing++; + notifyListeners(); + } + + String get timer => sprintf("%02i:%02i", [timing ~/ 60, timing % 60]); + + void lifeLoss() { + if (life > 0) { + life--; + } + if (life <= 0) { + status = SudokuGameStatus.fail; + } + notifyListeners(); + } + + void hintLoss() { + if (hint > 0) { + hint--; + } + notifyListeners(); + } + + void setRecord(int index, int num) { + if (index < 0 || index > 80 || num < 0 || num > 9) { + throw ArgumentError( + 'index border [0,80] num border [0,9] , input index:$index | num:$num out of the border'); + } + if (status == SudokuGameStatus.initialize) { + throw ArgumentError("can't update record in \"initialize\" status"); + } + + List puzzle = sudoku!.puzzle; + + if (puzzle[index] != -1) { + record[index] = -1; + notifyListeners(); + return; + } + record[index] = num; + // 清空笔记 + cleanMark(index); + + /// 更新填写记录,笔记清除 + /// 清空当前index笔记 + /// 移除 zone row col 中的对应笔记 + + List colIndexes = Matrix.getColIndexes(Matrix.getCol(index)); + List rowIndexes = Matrix.getRowIndexes(Matrix.getRow(index)); + List zoneIndexes = + Matrix.getZoneIndexes(zone: Matrix.getZone(index: index)); + + for (var _ in colIndexes) { + cleanMark(_, num: num); + } + for (var _ in rowIndexes) { + cleanMark(_, num: num); + } + for (var _ in zoneIndexes) { + cleanMark(_, num: num); + } + } + + void cleanRecord(int index) { + if (status == SudokuGameStatus.initialize) { + throw ArgumentError("can't update record in \"initialize\" status"); + } + List puzzle = sudoku!.puzzle; + if (puzzle[index] == -1) { + record[index] = -1; + } + notifyListeners(); + } + + void switchRecord(int index, int num) { + log.d('switchRecord $index - $num'); + if (index < 0 || index > 80 || num < 0 || num > 9) { + throw ArgumentError( + 'index border [0,80] num border [0,9] , input index:$index | num:$num out of the border'); + } + if (status == SudokuGameStatus.initialize) { + throw ArgumentError("can't update record in \"initialize\" status"); + } + if (sudoku!.puzzle[index] != -1) { + return; + } + if (record[index] == num) { + cleanRecord(index); + } else { + setRecord(index, num); + } + } + + void setMark(int index, int num) { + if (index < 0 || index > 80) { + throw ArgumentError( + 'index border [0,80], input index:$index out of the border'); + } + if (num < 1 || num > 9) { + throw ArgumentError("num must be [1,9]"); + } + + if (sudoku!.puzzle[index] != -1) { + mark[index] = List.generate(10, (index) => false); + notifyListeners(); + return; + } + + // 清空数字 + cleanRecord(index); + + List markPoint = mark[index]; + markPoint[num] = true; + mark[index] = markPoint; + notifyListeners(); + } + + void cleanMark(int index, {int? num}) { + if (index < 0 || index > 80) { + throw ArgumentError( + 'index border [0,80], input index:$index out of the border'); + } + List markPoint = mark[index]; + if (num == null) { + markPoint = List.generate(10, (index) => false); + } else { + markPoint[num] = false; + } + mark[index] = markPoint; + notifyListeners(); + } + + void switchMark(int index, int num) { + if (index < 0 || index > 80) { + throw ArgumentError( + 'index border [0,80], input index:$index out of the border'); + } + if (num < 1 || num > 9) { + throw ArgumentError("num must be [1,9]"); + } + + List markPoint = mark[index]; + if (!markPoint[num]) { + setMark(index, num); + } else { + cleanMark(index, num: num); + } + } + + void updateSudoku(Sudoku sudoku) { + this.sudoku = sudoku; + notifyListeners(); + } + + void updateStatus(SudokuGameStatus status) { + this.status = status; + notifyListeners(); + } + + void updateLevel(Level level) { + this.level = level; + notifyListeners(); + } + + // 检查该数字是否还有库存(判断是否填写满) + bool hasNumStock(int num) { + if (status == SudokuGameStatus.initialize) { + throw ArgumentError("can't check num stock in \"initialize\" status"); + } + int puzzleLength = sudoku!.puzzle.where((element) => element == num).length; + int recordLength = record.where((element) => element == num).length; + return 9 > (puzzleLength + recordLength); + } + + void persistent() async { + await _initHive(); + var sudokuStore = await Hive.openBox(_hiveBoxName); + await sudokuStore.put(_hiveStateName, this); + if (sudokuStore.isOpen) { + await sudokuStore.compact(); + await sudokuStore.close(); + } + + log.d("hive persistent"); + } + + /// + /// resume SudokuState from db(hive) + static Future resumeFromDB() async { + await _initHive(); + + SudokuState state; + Box? sudokuStore; + + try { + sudokuStore = await Hive.openBox(_hiveBoxName); + state = sudokuStore.get(_hiveStateName, + defaultValue: SudokuState.newSudokuState()); + } catch (e) { + log.d(e); + state = SudokuState.newSudokuState(); + } finally { + if (sudokuStore?.isOpen ?? false) { + await sudokuStore!.close(); + } + } + + return state; + } + + static final SudokuAdapter _sudokuAdapter = SudokuAdapter(); + static final SudokuStateAdapter _sudokuStateAdapter = SudokuStateAdapter(); + static final SudokuGameStatusAdapter _sudokuGameStatusAdapter = + SudokuGameStatusAdapter(); + static final SudokuLevelAdapter _sudokuLevelAdapter = SudokuLevelAdapter(); + + static _initHive() async { + await Hive.initFlutter(Constant.packageName); + if (!Hive.isAdapterRegistered(_sudokuAdapter.typeId)) { + Hive.registerAdapter(_sudokuAdapter); + } + if (!Hive.isAdapterRegistered(_sudokuStateAdapter.typeId)) { + Hive.registerAdapter(_sudokuStateAdapter); + } + if (!Hive.isAdapterRegistered(_sudokuGameStatusAdapter.typeId)) { + Hive.registerAdapter(_sudokuGameStatusAdapter); + } + if (!Hive.isAdapterRegistered(_sudokuLevelAdapter.typeId)) { + Hive.registerAdapter(_sudokuLevelAdapter); + } + } +} diff --git a/lib/views/game_center/sudoku/state/sudoku_state.g.dart b/lib/views/game_center/sudoku/state/sudoku_state.g.dart new file mode 100755 index 0000000..57d596c --- /dev/null +++ b/lib/views/game_center/sudoku/state/sudoku_state.g.dart @@ -0,0 +1,118 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sudoku_state.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class SudokuStateAdapter extends TypeAdapter { + @override + final int typeId = 5; + + @override + SudokuState read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SudokuState( + level: fields[2] as Level?, + sudoku: fields[1] as Sudoku?, + ) + ..status = fields[0] as SudokuGameStatus + ..timing = fields[3] as int + ..life = fields[4] as int + ..hint = fields[5] as int + ..record = (fields[6] as List).cast() + ..mark = (fields[7] as List) + .map((dynamic e) => (e as List).cast()) + .toList(); + } + + @override + void write(BinaryWriter writer, SudokuState obj) { + writer + ..writeByte(8) + ..writeByte(0) + ..write(obj.status) + ..writeByte(1) + ..write(obj.sudoku) + ..writeByte(2) + ..write(obj.level) + ..writeByte(3) + ..write(obj.timing) + ..writeByte(4) + ..write(obj.life) + ..writeByte(5) + ..write(obj.hint) + ..writeByte(6) + ..write(obj.record) + ..writeByte(7) + ..write(obj.mark); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SudokuStateAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class SudokuGameStatusAdapter extends TypeAdapter { + @override + final int typeId = 6; + + @override + SudokuGameStatus read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return SudokuGameStatus.initialize; + case 1: + return SudokuGameStatus.gaming; + case 2: + return SudokuGameStatus.pause; + case 3: + return SudokuGameStatus.fail; + case 4: + return SudokuGameStatus.success; + default: + return SudokuGameStatus.initialize; + } + } + + @override + void write(BinaryWriter writer, SudokuGameStatus obj) { + switch (obj) { + case SudokuGameStatus.initialize: + writer.writeByte(0); + break; + case SudokuGameStatus.gaming: + writer.writeByte(1); + break; + case SudokuGameStatus.pause: + writer.writeByte(2); + break; + case SudokuGameStatus.fail: + writer.writeByte(3); + break; + case SudokuGameStatus.success: + writer.writeByte(4); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SudokuGameStatusAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/views/game_center/sudoku/util/localization_util.dart b/lib/views/game_center/sudoku/util/localization_util.dart new file mode 100644 index 0000000..11b9b35 --- /dev/null +++ b/lib/views/game_center/sudoku/util/localization_util.dart @@ -0,0 +1,36 @@ +import 'package:flutter/widgets.dart'; +import 'package:sudoku_dart/sudoku_dart.dart'; + +import '../state/sudoku_state.dart'; + +/// LocalizationUtils +class LocalizationUtils { + static String localizationLevelName(BuildContext context, Level level) { + switch (level) { + case Level.easy: + return "简单"; + case Level.medium: + return "中等"; + case Level.hard: + return "困难"; + case Level.expert: + return "专家"; + } + } + + static String localizationGameStatus( + BuildContext context, SudokuGameStatus status) { + switch (status) { + case SudokuGameStatus.initialize: + return "初始化"; + case SudokuGameStatus.gaming: + return "进行中"; + case SudokuGameStatus.pause: + return "暂停"; + case SudokuGameStatus.fail: + return "失败"; + case SudokuGameStatus.success: + return "胜利"; + } + } +} diff --git a/lib/views/game_center/t-rex_dinosaur/components/cactus.dart b/lib/views/game_center/t-rex_dinosaur/components/cactus.dart new file mode 100755 index 0000000..d056179 --- /dev/null +++ b/lib/views/game_center/t-rex_dinosaur/components/cactus.dart @@ -0,0 +1,60 @@ +import 'dart:math'; + +import 'package:flutter/widgets.dart'; + +import '../const/constants.dart'; +import '../models/game_object.dart'; +import '../models/sprite.dart'; + +/// +/// 仙人掌 +/// +List cacti = [ + Sprite() + ..imagePath = "assets/games/dinosaur/cacti/cacti_group.png" + ..imageWidth = 104 + ..imageHeight = 100, + Sprite() + ..imagePath = "assets/games/dinosaur/cacti/cacti_large_1.png" + ..imageWidth = 50 + ..imageHeight = 100, + Sprite() + ..imagePath = "assets/games/dinosaur/cacti/cacti_large_2.png" + ..imageWidth = 98 + ..imageHeight = 100, + Sprite() + ..imagePath = "assets/games/dinosaur/cacti/cacti_small_1.png" + ..imageWidth = 34 + ..imageHeight = 70, + Sprite() + ..imagePath = "assets/games/dinosaur/cacti/cacti_small_2.png" + ..imageWidth = 68 + ..imageHeight = 70, + Sprite() + ..imagePath = "assets/games/dinosaur/cacti/cacti_small_3.png" + ..imageWidth = 107 + ..imageHeight = 70, +]; + +class Cactus extends GameObject { + final Sprite sprite; + final Offset worldLocation; + + Cactus({required this.worldLocation}) + : sprite = cacti[Random().nextInt(cacti.length)]; + + @override + Rect getRect(Size screenSize, double runDistance) { + return Rect.fromLTWH( + (worldLocation.dx - runDistance) * worlToPixelRatio, + screenSize.height / 1.75 - sprite.imageHeight, + sprite.imageWidth.toDouble(), + sprite.imageHeight.toDouble(), + ); + } + + @override + Widget render() { + return Image.asset(sprite.imagePath); + } +} diff --git a/lib/views/game_center/t-rex_dinosaur/components/cloud.dart b/lib/views/game_center/t-rex_dinosaur/components/cloud.dart new file mode 100755 index 0000000..e767156 --- /dev/null +++ b/lib/views/game_center/t-rex_dinosaur/components/cloud.dart @@ -0,0 +1,34 @@ +import 'package:flutter/widgets.dart'; + +import '../const/constants.dart'; +import '../models/game_object.dart'; +import '../models/sprite.dart'; + +/// +/// 云朵 +/// +Sprite cloudSprite = Sprite() + ..imagePath = "assets/games/dinosaur/cloud.png" + ..imageWidth = 92 + ..imageHeight = 27; + +class Cloud extends GameObject { + final Offset worldLocation; + + Cloud({required this.worldLocation}); + + @override + Rect getRect(Size screenSize, double runDistance) { + return Rect.fromLTWH( + (worldLocation.dx - runDistance) * worlToPixelRatio / 5, + screenSize.height / 3 - cloudSprite.imageHeight - worldLocation.dy, + cloudSprite.imageWidth.toDouble(), + cloudSprite.imageHeight.toDouble(), + ); + } + + @override + Widget render() { + return Image.asset(cloudSprite.imagePath); + } +} diff --git a/lib/views/game_center/t-rex_dinosaur/components/dino.dart b/lib/views/game_center/t-rex_dinosaur/components/dino.dart new file mode 100755 index 0000000..897f778 --- /dev/null +++ b/lib/views/game_center/t-rex_dinosaur/components/dino.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import '../const/constants.dart'; +import '../models/game_object.dart'; +import '../models/sprite.dart'; + +/// +/// 恐龙 +/// +List dino = [ + Sprite() + ..imagePath = "assets/games/dinosaur/dino/dino_1.png" + ..imageWidth = 88 + ..imageHeight = 94, + Sprite() + ..imagePath = "assets/games/dinosaur/dino/dino_2.png" + ..imageWidth = 88 + ..imageHeight = 94, + Sprite() + ..imagePath = "assets/games/dinosaur/dino/dino_3.png" + ..imageWidth = 88 + ..imageHeight = 94, + Sprite() + ..imagePath = "assets/games/dinosaur/dino/dino_4.png" + ..imageWidth = 88 + ..imageHeight = 94, + Sprite() + ..imagePath = "assets/games/dinosaur/dino/dino_5.png" + ..imageWidth = 88 + ..imageHeight = 94, + Sprite() + ..imagePath = "assets/games/dinosaur/dino/dino_6.png" + ..imageWidth = 88 + ..imageHeight = 94, +]; + +enum DinoState { + jumping, + running, + dead, +} + +class Dino extends GameObject { + Sprite currentSprite = dino[0]; + double dispY = 0; + double velY = 0; + DinoState state = DinoState.running; + + @override + Widget render() { + return Image.asset(currentSprite.imagePath); + } + + @override + Rect getRect(Size screenSize, double runDistance) { + return Rect.fromLTWH( + screenSize.width / 10, + screenSize.height / 1.75 - currentSprite.imageHeight - dispY, + currentSprite.imageWidth.toDouble(), + currentSprite.imageHeight.toDouble(), + ); + } + + @override + void update(Duration lastUpdate, Duration? elapsedTime) { + double elapsedTimeSeconds; + try { + currentSprite = dino[(elapsedTime!.inMilliseconds / 100).floor() % 2 + 2]; + } catch (_) { + currentSprite = dino[0]; + } + try { + elapsedTimeSeconds = (elapsedTime! - lastUpdate).inMilliseconds / 1000; + } catch (_) { + elapsedTimeSeconds = 0; + } + + dispY += velY * elapsedTimeSeconds; + if (dispY <= 0) { + dispY = 0; + velY = 0; + state = DinoState.running; + } else { + velY -= gravity * elapsedTimeSeconds; + } + } + + void jump() { + if (state != DinoState.jumping) { + state = DinoState.jumping; + velY = jumpVelocity; + } + } + + void die() { + currentSprite = dino[5]; + state = DinoState.dead; + } +} diff --git a/lib/views/game_center/t-rex_dinosaur/components/ground.dart b/lib/views/game_center/t-rex_dinosaur/components/ground.dart new file mode 100755 index 0000000..7bddf96 --- /dev/null +++ b/lib/views/game_center/t-rex_dinosaur/components/ground.dart @@ -0,0 +1,34 @@ +import 'package:flutter/widgets.dart'; + +import '../const/constants.dart'; +import '../models/game_object.dart'; +import '../models/sprite.dart'; + +/// +/// 地面 +/// +Sprite groundSprite = Sprite() + ..imagePath = "assets/games/dinosaur/ground.png" + ..imageWidth = 2399 + ..imageHeight = 24; + +class Ground extends GameObject { + final Offset worldLocation; + + Ground({required this.worldLocation}); + + @override + Rect getRect(Size screenSize, double runDistance) { + return Rect.fromLTWH( + (worldLocation.dx - runDistance) * worlToPixelRatio, + screenSize.height / 1.75 - groundSprite.imageHeight, + groundSprite.imageWidth.toDouble(), + groundSprite.imageHeight.toDouble(), + ); + } + + @override + Widget render() { + return Image.asset(groundSprite.imagePath); + } +} diff --git a/lib/views/game_center/t-rex_dinosaur/components/ptera.dart b/lib/views/game_center/t-rex_dinosaur/components/ptera.dart new file mode 100755 index 0000000..bff5205 --- /dev/null +++ b/lib/views/game_center/t-rex_dinosaur/components/ptera.dart @@ -0,0 +1,51 @@ +import '../const/constants.dart'; +import 'package:flutter/widgets.dart'; + +import '../models/game_object.dart'; +import '../models/sprite.dart'; + +/// +/// 无齿翼龙 +/// +List pteraFrames = [ + Sprite() + ..imagePath = "assets/games/dinosaur/ptera/ptera_1.png" + ..imageHeight = 80 + ..imageWidth = 92, + Sprite() + ..imagePath = "assets/games/dinosaur/ptera/ptera_2.png" + ..imageHeight = 80 + ..imageWidth = 92, +]; + +class Ptera extends GameObject { + // this is a logical location which is translated to pixel coordinates + final Offset worldLocation; + int frame = 0; + + Ptera({required this.worldLocation}); + + @override + Rect getRect(Size screenSize, double runDistance) { + return Rect.fromLTWH( + (worldLocation.dx - runDistance) * worlToPixelRatio, + 4 / 7 * screenSize.height - + pteraFrames[frame].imageHeight - + worldLocation.dy, + pteraFrames[frame].imageWidth.toDouble(), + pteraFrames[frame].imageHeight.toDouble()); + } + + @override + Widget render() { + return Image.asset( + pteraFrames[frame].imagePath, + gaplessPlayback: true, + ); + } + + @override + void update(Duration lastUpdate, Duration elapsedTime) { + frame = (elapsedTime.inMilliseconds / 200).floor() % 2; + } +} diff --git a/lib/views/game_center/t-rex_dinosaur/const/constants.dart b/lib/views/game_center/t-rex_dinosaur/const/constants.dart new file mode 100755 index 0000000..d094a5c --- /dev/null +++ b/lib/views/game_center/t-rex_dinosaur/const/constants.dart @@ -0,0 +1,6 @@ +int gravity = 2500; +const int worlToPixelRatio = 10; +double initialVelocity = 30; +double acceleration = 1; +int dayNightOffest = 1000; +double jumpVelocity = 850; diff --git a/lib/views/game_center/t-rex_dinosaur/index.dart b/lib/views/game_center/t-rex_dinosaur/index.dart new file mode 100755 index 0000000..59726c1 --- /dev/null +++ b/lib/views/game_center/t-rex_dinosaur/index.dart @@ -0,0 +1,583 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +import '../../../services/my_get_storage.dart'; +import '../../../services/service_locator.dart'; +import 'components/cactus.dart'; +import 'components/cloud.dart'; +import 'components/dino.dart'; +import 'components/ground.dart'; +import 'const/constants.dart'; +import 'models/game_object.dart'; + +class TRexDinosaur extends StatefulWidget { + const TRexDinosaur({super.key}); + @override + State createState() => _TRexDinosaurState(); +} + +class _TRexDinosaurState extends State + with SingleTickerProviderStateMixin { + // 恐龙实例 + Dino dino = Dino(); + // 默认的初始速度 + double runVelocity = initialVelocity; + // 恐龙前进的距离(当前得分) + double runDistance = 0; + // 最高得分(历史最高得分) + // 2024-01-31 我会加入缓存中进行持久化 + int highScore = 0; + // 或许设置中各项数值:重力、加速度、跳跃速度、初始速度、昼夜偏移 + // 其实可以使用项目中已经有的formbuilder,但此处保持原样 + TextEditingController gravityController = + TextEditingController(text: gravity.toString()); + TextEditingController accelerationController = + TextEditingController(text: acceleration.toString()); + TextEditingController jumpVelocityController = + TextEditingController(text: jumpVelocity.toString()); + TextEditingController runVelocityController = + TextEditingController(text: initialVelocity.toString()); + TextEditingController dayNightOffestController = + TextEditingController(text: dayNightOffest.toString()); + + // 整体的动画控制器 + late AnimationController worldController; + // 上次更新的时间(随着运行时间增加,上次更新的时间也会变化) + Duration lastUpdateCall = const Duration(); + + /// 这些都会根据运行时间,也就是恐龙跑的距离增加而在屏幕中刷新 + /// 所以这里只是初始的值 + // 仙人掌实例 + List cacti = [ + Cactus(worldLocation: const Offset(200, 0)), + ]; + // 地面实例 + List ground = [ + Ground(worldLocation: const Offset(0, 0)), + Ground(worldLocation: Offset(groundSprite.imageWidth / 10, 0)) + ]; + // 云朵实例 + List clouds = [ + Cloud(worldLocation: const Offset(100, 20)), + Cloud(worldLocation: const Offset(200, 10)), + Cloud(worldLocation: const Offset(350, -10)), + ]; + + // 统一简单存储操作的工具类实例 + final _simpleStorage = getIt(); + + @override + void initState() { + super.initState(); + + // 给动画控制器添加更新侦听 + worldController = AnimationController( + vsync: this, + duration: const Duration(days: 99), + ); + worldController.addListener(_update); + // worldController.forward(); + // 初始默认为停止状态 + _die(); + + // 2024-01-31 获取缓存中历史最高分数 + highScore = _simpleStorage.getDinosaurBestScore() ?? 0; + } + + @override + void dispose() { + gravityController.dispose(); + accelerationController.dispose(); + jumpVelocityController.dispose(); + runVelocityController.dispose(); + dayNightOffestController.dispose(); + worldController.dispose(); + super.dispose(); + } + + void _die() { + setState(() { + worldController.stop(); + dino.die(); + }); + } + + // 开始新游戏,基本就是全部重置 + void _newGame() async { + // 添加异步的话,就要先判断是否挂载了 + if (!mounted) return; + setState(() { + highScore = max(highScore, runDistance.toInt()); + runDistance = 0; + runVelocity = initialVelocity; + dino.state = DinoState.running; + dino.dispY = 0; + worldController.reset(); + cacti = [ + Cactus(worldLocation: const Offset(200, 0)), + Cactus(worldLocation: const Offset(300, 0)), + Cactus(worldLocation: const Offset(450, 0)), + ]; + + ground = [ + Ground(worldLocation: const Offset(0, 0)), + Ground(worldLocation: Offset(groundSprite.imageWidth / 10, 0)) + ]; + + clouds = [ + Cloud(worldLocation: const Offset(100, 20)), + Cloud(worldLocation: const Offset(200, 10)), + Cloud(worldLocation: const Offset(350, -15)), + Cloud(worldLocation: const Offset(500, 10)), + Cloud(worldLocation: const Offset(550, -10)), + ]; + + worldController.forward(); + }); + + // 2024-01-31 保存历史最好分数 + await _simpleStorage.setDinosaurBestScore(highScore); + } + + // 动画控制器侦听的更新方法 + _update() { + try { + // 实际运行的时间 + double elapsedTimeSeconds; + // 更新恐龙实例状态 + dino.update(lastUpdateCall, worldController.lastElapsedDuration); + try { + elapsedTimeSeconds = + (worldController.lastElapsedDuration! - lastUpdateCall) + .inMilliseconds / + 1000; + } catch (_) { + elapsedTimeSeconds = 0; + } + + // 已经跑的距离就是初始速度*间隔时间 + runDistance += runVelocity * elapsedTimeSeconds; + if (runDistance < 0) runDistance = 0; + + // 如果有设置加速度了,则更新恐龙的初始速度 + runVelocity += acceleration * elapsedTimeSeconds; + + // 获取屏幕尺寸 + Size screenSize = MediaQuery.of(context).size; + // 获取恐龙实例的方块位置 + Rect dinoRect = dino.getRect(screenSize, runDistance); + + // 遍历屏幕中的仙人掌 + for (Cactus cactus in cacti) { + // 如果说恐龙所在的方块和屏幕中的仙人掌方块有重叠,那说明恐龙碰到了障碍物,游戏结束 + Rect obstacleRect = cactus.getRect(screenSize, runDistance); + if (dinoRect.overlaps(obstacleRect.deflate(20))) { + _die(); + } + + // 如果有仙人掌实例已经离开了屏幕视野,则从列表中移除,并随机添加一个新的仙人掌 + if (obstacleRect.right < 0) { + setState(() { + cacti.remove(cactus); + cacti.add(Cactus( + worldLocation: Offset( + runDistance + + Random().nextInt(100) + + MediaQuery.of(context).size.width / worlToPixelRatio, + 0))); + }); + } + } + + // 如果有地面实例已经离开了屏幕视野,则从列表中移除,并随机添加一个新的地面 + for (Ground groundlet in ground) { + if (groundlet.getRect(screenSize, runDistance).right < 0) { + setState(() { + ground.remove(groundlet); + ground.add( + Ground( + worldLocation: Offset( + ground.last.worldLocation.dx + groundSprite.imageWidth / 10, + 0, + ), + ), + ); + }); + } + } + + // 如果有云朵实例已经离开了屏幕视野,则从列表中移除,并随机添加一个新的云朵 + for (Cloud cloud in clouds) { + if (cloud.getRect(screenSize, runDistance).right < 0) { + setState(() { + clouds.remove(cloud); + clouds.add( + Cloud( + worldLocation: Offset( + clouds.last.worldLocation.dx + + Random().nextInt(200) + + MediaQuery.of(context).size.width / worlToPixelRatio, + Random().nextInt(50) - 25.0, + ), + ), + ); + }); + } + } + + // 更新一下上次更新调用的时间,用于恐龙实例更新等 + lastUpdateCall = worldController.lastElapsedDuration!; + } catch (e) { + // + } + } + + @override + Widget build(BuildContext context) { + // 构建屏幕中出现的各个动画实例用于布局 + Size screenSize = MediaQuery.of(context).size; + List children = []; + + for (GameObject object in [...clouds, ...ground, ...cacti, dino]) { + children.add( + AnimatedBuilder( + animation: worldController, + builder: (context, _) { + Rect objectRect = object.getRect(screenSize, runDistance); + return Positioned( + left: objectRect.left, + top: objectRect.top, + width: objectRect.width, + height: objectRect.height, + child: object.render(), + ); + }, + ), + ); + } + + return Scaffold( + body: AnimatedContainer( + duration: const Duration(milliseconds: 5000), + color: (runDistance ~/ dayNightOffest) % 2 == 0 + ? Colors.white + : Colors.black, + child: GestureDetector( + // 命中测试行为,恐龙与障碍物有碰撞,游戏结束;如果没有,就让恐龙跳跃 + behavior: HitTestBehavior.translucent, + onTap: () { + if (dino.state != DinoState.dead) { + dino.jump(); + } + if (dino.state == DinoState.dead) { + _newGame(); + } + }, + // 构建页面上的各个游戏图像实例和其他分数、设置、结束按钮等组件 + child: Stack( + alignment: Alignment.center, + children: [ + ...children, + AnimatedBuilder( + animation: worldController, + builder: (context, _) { + return Positioned( + left: screenSize.width / 2 - 50, + top: 100, + child: Text( + '当前得分: ${runDistance.toInt()}', + style: TextStyle( + color: (runDistance ~/ dayNightOffest) % 2 == 0 + ? Colors.black + : Colors.white, + ), + ), + ); + }, + ), + AnimatedBuilder( + animation: worldController, + builder: (context, _) { + return Positioned( + left: screenSize.width / 2 - 50, + top: 120, + child: Text( + '历史最高: $highScore', + style: TextStyle( + color: (runDistance ~/ dayNightOffest) % 2 == 0 + ? Colors.black + : Colors.white, + ), + ), + ); + }, + ), + Positioned( + right: 20, + top: 20, + child: IconButton( + icon: const Icon(Icons.settings), + onPressed: () { + _die(); + _buildSettingDialog(); + }, + ), + ), + // 2024-02-02 这里有个问题,游戏还没开始,状态也是dead,不知道是未开始还是游戏结束 + // 还是按照原来的,停止了,就是游戏结束了,不用显示文字 + // if (dino.state == DinoState.dead) + // Positioned( + // top: screenSize.height / 2 - 100, + // child: const Text( + // "游戏结束", + // style: TextStyle(color: Colors.red, fontSize: 28), + // ), + // ), + Positioned( + bottom: 10, + child: TextButton( + onPressed: () { + _die(); + }, + child: const Text( + "强制结束游戏", + style: TextStyle(color: Colors.red), + ), + ), + ), + ], + ), + ), + ), + ); + } + + _buildSettingDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("修改设定"), + content: SingleChildScrollView( + child: Column( + children: [ + _buildPadding("重力:", gravityController), + _buildPadding("加速度:", accelerationController), + _buildPadding("初速度:", runVelocityController), + _buildPadding("跳跃速度:", jumpVelocityController), + _buildPadding("昼夜偏移:", dayNightOffestController), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + gravity = int.parse(gravityController.text); + acceleration = double.parse(accelerationController.text); + initialVelocity = double.parse(runVelocityController.text); + jumpVelocity = double.parse(jumpVelocityController.text); + dayNightOffest = int.parse(dayNightOffestController.text); + Navigator.of(context).pop(); + }, + child: const Text("完成"), + ) + ], + ); + }, + ); + } + + _buildPadding(String text, TextEditingController? controller) { + return Padding( + padding: const EdgeInsets.all(2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(text), + SizedBox( + height: 25, + width: 75, + child: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + ), + ), + ), + ), + ], + ), + ); + } + + /// 这是原版的设定弹窗内容,上面是有一些修改 + originalSettingDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("Change Physics"), + actions: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: 25, + width: 280, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Gravity:"), + SizedBox( + height: 25, + width: 75, + child: TextField( + controller: gravityController, + key: UniqueKey(), + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + ), + ), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: 25, + width: 280, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Acceleration:"), + SizedBox( + height: 25, + width: 75, + child: TextField( + controller: accelerationController, + key: UniqueKey(), + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + ), + ), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: 25, + width: 280, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Initial Velocity:"), + SizedBox( + height: 25, + width: 75, + child: TextField( + controller: runVelocityController, + key: UniqueKey(), + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + ), + ), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: 25, + width: 280, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Jump Velocity:"), + SizedBox( + height: 25, + width: 75, + child: TextField( + controller: jumpVelocityController, + key: UniqueKey(), + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + ), + ), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: 25, + width: 280, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Day-Night Offset:"), + SizedBox( + height: 25, + width: 75, + child: TextField( + controller: dayNightOffestController, + key: UniqueKey(), + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + ), + ), + ), + ), + ], + ), + ), + ), + TextButton( + onPressed: () { + gravity = int.parse(gravityController.text); + acceleration = double.parse(accelerationController.text); + initialVelocity = double.parse(runVelocityController.text); + jumpVelocity = double.parse(jumpVelocityController.text); + dayNightOffest = int.parse(dayNightOffestController.text); + Navigator.of(context).pop(); + }, + child: const Text( + "Done", + style: TextStyle(color: Colors.grey), + ), + ) + ], + ); + }, + ); + } +} diff --git a/lib/views/game_center/t-rex_dinosaur/models/game_object.dart b/lib/views/game_center/t-rex_dinosaur/models/game_object.dart new file mode 100755 index 0000000..50a9126 --- /dev/null +++ b/lib/views/game_center/t-rex_dinosaur/models/game_object.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +/// +/// 游戏对象抽象类 +/// +/// 每个部件都可能有的,需要跟着恐龙跑的过程中渲染图片和更新贴图。 +/// 渲染实例、需要获取贴图元素所在方块的位置(用于计算是否碰撞判断游戏结束与否)、根据上次更新时间和运行时间更新贴图 +/// +abstract class GameObject { + Widget render(); + Rect getRect(Size screenSize, double runDistance); + void update(Duration lastUpdate, Duration elapsedTime) {} +} diff --git a/lib/views/game_center/t-rex_dinosaur/models/sprite.dart b/lib/views/game_center/t-rex_dinosaur/models/sprite.dart new file mode 100755 index 0000000..803c10f --- /dev/null +++ b/lib/views/game_center/t-rex_dinosaur/models/sprite.dart @@ -0,0 +1,7 @@ +/// 贴图 model +/// 指定各个部件的图片地址和宽高 +class Sprite { + late String imagePath; + late int imageWidth; + late int imageHeight; +} diff --git a/lib/views/game_center/t-rex_dinosaur/readme.md b/lib/views/game_center/t-rex_dinosaur/readme.md new file mode 100644 index 0000000..2628e3e --- /dev/null +++ b/lib/views/game_center/t-rex_dinosaur/readme.md @@ -0,0 +1,14 @@ +# “恐龙游戏”小游戏说明 + +恐龙游戏是原本内嵌在 chrome 里的小游戏,更多可去官网 [T-Rex Chrome Dino Game](https://chromedino.com/) 游玩。 + +这个原项目是 github 上的 [HeveshL/flutter-dinosaur](https://github.com/HeveshL/flutter-dinosaur),上次提交是在 2022-06-28。 + +但我还发现一个项目 [avinashkranjan/Dino](https://github.com/avinashkranjan/Dino) 代码几乎一模一样,上次提交是在 2023-05-09,但好像没有明确说明是否 fork 而来。 + +## 和原项目的一些改动: + +- 简单调整了项目的结构和加了一点点注释帮助自己理解 +- 设置的弹窗有一些小改动,避免了键盘弹出后出现溢出问题和莫名自动收起的情况 +- 添加了当游戏结束时界面上显示“游戏结束”文字 +- 最高得分进行本地持久化,下一次游玩还能看到之前历史最佳 diff --git a/pubspec.yaml b/pubspec.yaml index c9e1238..9a6175c 100644 --- a/pubspec.yaml +++ b/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: 0.3.0-beta+1 +version: 0.4.0-beta+1 environment: sdk: '>=3.0.1 <4.0.0' @@ -98,10 +98,24 @@ dependencies: ### 2024-01-29新加:俄罗斯方块需要的一些库 soundpool: ^2.4.1 vector_math: ^2.1.4 + ### 扫雷小游戏需要用到相关依赖库 + shared_preferences: ^2.0.18 + ### 数独游戏用到相关依赖库 + sudoku_dart: ^1.1.0 + logger: ^2.0.2+1 + sprintf: ^7.0.0 + scoped_model: ^2.0.0 + modal_bottom_sheet: ^3.0.0-pre + hive: ^2.2.3 + hive_flutter: ^1.1.0 + url_launcher: ^6.2.4 + dev_dependencies: flutter_test: sdk: flutter + hive_generator: ^2.0.0 + build_runner: ^2.3.3 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -130,6 +144,19 @@ flutter: - assets/games/ - assets/games/tetris/ - assets/games/tetris/audios/ + - assets/games/dinosaur/ + - assets/games/dinosaur/cacti/ + - assets/games/dinosaur/dino/ + - assets/games/dinosaur/ptera/ + - assets/games/minesweeper/ + - assets/games/minesweeper/audio/ + - assets/games/minesweeper/images/ + - assets/games/minesweeper/images/how_to_play/ + - assets/games/sodoku/ + - assets/games/sodoku/audio/ + - assets/games/sodoku/image/ + - assets/games/sodoku/svg/ + # An image asset can refer to one or more resolution-specific "variants", see