3 Commits 36d744de9a ... 5471a32ca4

Author SHA1 Message Date
  liuyuqi-cnb 5471a32ca4 最后一手支持缩放入场与高亮光圈 1 week ago
  liuyuqi-cnb 68e7418519 优化项目结构 1 week ago
  liuyuqi-cnb fe0d8724c6 优化代码,文件大小写 1 week ago
69 changed files with 1664 additions and 1400 deletions
  1. 15 7
      README.md
  2. 14 1
      analysis_options.yaml
  3. 0 401
      lib/ai/Ai.dart
  4. 0 7
      lib/bridge/ChessShape.dart
  5. 0 7
      lib/bridge/CircleShape.dart
  6. 0 7
      lib/bridge/RectShape.dart
  7. 30 0
      lib/components/game_action_bar.dart
  8. 20 0
      lib/components/game_status_text.dart
  9. 200 0
      lib/controllers/game_controller.dart
  10. 0 11
      lib/factory/BlackTheme.dart
  11. 0 10
      lib/factory/BlackThemeFactory.dart
  12. 0 10
      lib/factory/BlueTheme.dart
  13. 0 10
      lib/factory/BlueThemeFactory.dart
  14. 0 5
      lib/factory/ThemeFactory.dart
  15. 0 38
      lib/flyweight/Chess.dart
  16. 0 33
      lib/flyweight/ChessFlyweightFactory.dart
  17. 0 28
      lib/flyweight/Position.dart
  18. 0 362
      lib/home_page.dart
  19. 30 16
      lib/main.dart
  20. 0 23
      lib/memorandum/CareTaker.dart
  21. 0 56
      lib/memorandum/Checkerboard.dart
  22. 0 5
      lib/memorandum/Memo.dart
  23. 29 0
      lib/models/care_taker.dart
  24. 38 0
      lib/models/checkerboard.dart
  25. 27 0
      lib/models/chess.dart
  26. 3 0
      lib/models/chess_shape.dart
  27. 6 0
      lib/models/circle_shape.dart
  28. 72 0
      lib/models/game_state.dart
  29. 7 0
      lib/models/memo.dart
  30. 21 0
      lib/models/position.dart
  31. 6 0
      lib/models/rect_shape.dart
  32. 27 0
      lib/models/user_context.dart
  33. 0 16
      lib/pages/about_page.dart
  34. 65 0
      lib/pages/home/home_page.dart
  35. 0 16
      lib/pages/login_page.dart
  36. 0 16
      lib/pages/register_page.dart
  37. 0 16
      lib/pages/splash_page.dart
  38. 0 4
      lib/routes.dart
  39. 18 0
      lib/routes/app_pages.dart
  40. 4 0
      lib/routes/app_routes.dart
  41. 221 0
      lib/services/ai_service.dart
  42. 24 0
      lib/services/chess_flyweight_factory.dart
  43. 0 96
      lib/state/State.dart
  44. 0 34
      lib/state/UserContext.dart
  45. 2 2
      lib/themes/app_theme.dart
  46. 8 0
      lib/themes/black_theme.dart
  47. 8 0
      lib/themes/black_theme_factory.dart
  48. 8 0
      lib/themes/blue_theme.dart
  49. 8 0
      lib/themes/blue_theme_factory.dart
  50. 5 0
      lib/themes/theme_factory.dart
  51. 0 77
      lib/utils/TipsDialog.dart
  52. 2 0
      lib/utils/constants.dart
  53. 64 0
      lib/utils/tips_dialog.dart
  54. 0 58
      lib/viewModel/GameViewModel.dart
  55. 91 0
      lib/widgets/board_painter.dart
  56. 90 0
      lib/widgets/gobang_board.dart
  57. 1 0
      linux/.gitignore
  58. 128 0
      linux/CMakeLists.txt
  59. 88 0
      linux/flutter/CMakeLists.txt
  60. 11 0
      linux/flutter/generated_plugin_registrant.cc
  61. 15 0
      linux/flutter/generated_plugin_registrant.h
  62. 23 0
      linux/flutter/generated_plugins.cmake
  63. 26 0
      linux/runner/CMakeLists.txt
  64. 6 0
      linux/runner/main.cc
  65. 130 0
      linux/runner/my_application.cc
  66. 18 0
      linux/runner/my_application.h
  67. 13 4
      pubspec.yaml
  68. 38 0
      test/ai_test.dart
  69. 4 24
      test/widget_test.dart

+ 15 - 7
README.md

@@ -1,11 +1,19 @@
 # gobang
 
-五子棋 app
+南瓜五子棋 — Flutter 五子棋 App(工厂 / 享元 / 备忘录 / 状态 / 桥接)。
 
+## 目录结构
 
-
-
-## Reference
-
-[fushen1024/five-in-a-row](https://github.com/fushen1024/five-in-a-row)
-
+```
+lib/
+├── main.dart
+├── routes/          # 路由表
+├── pages/           # 页面
+├── components/      # 页面级组合组件
+├── controllers/     # GetX 控制器
+├── models/          # 领域模型(棋子、棋盘、状态等)
+├── services/        # AI、享元工厂等
+├── themes/          # 主题工厂
+├── widgets/         # 通用绘制组件
+└── utils/           # 常量、弹窗工具
+```

+ 14 - 1
analysis_options.yaml

@@ -1,3 +1,16 @@
+analyzer:
+  exclude:
+    - build/**
+    - android/**
+    - ios/**
+    - web/**
+    - windows/**
+    - macos/**
+    - linux/**
 include: package:flutter_lints/flutter.yaml
+
 linter:
-  rules:
+  rules:
+    prefer_const_constructors: true
+    prefer_final_locals: true
+    avoid_print: true

+ 0 - 401
lib/ai/Ai.dart

@@ -1,401 +0,0 @@
-//下棋业务核心类,与界面棋盘对应,业务放在这里,可以和界面代码分离
-import 'dart:core';
-import 'package:gobang/flyweight/ChessFlyweightFactory.dart';
-import 'package:gobang/flyweight/Position.dart';
-
-class Ai {
-  Ai._();
-
-  static Ai? _ai;
-
-  static Ai getInstance() {
-    if (_ai == null) {
-      _ai = Ai._();
-    }
-    return _ai!;
-  }
-
-  static var CHESSBOARD_SIZE = 15;
-  static var FIRST = 1; //先手,-1表示机器,1表示人类,与Position类中的对应
-  var chessboard = List.generate(
-      CHESSBOARD_SIZE, (i) => List.filled(CHESSBOARD_SIZE, 0, growable: false),
-      growable: false); //与界面棋盘对应,0代表空,-1代表机器,1代表人类
-  var score = List.generate(
-      CHESSBOARD_SIZE, (i) => List.filled(CHESSBOARD_SIZE, 0, growable: false),
-      growable: false); //每个位置得分
-
-  void init() {
-    FIRST = 1; //默认人类先手
-    for (int i = 0; i < CHESSBOARD_SIZE; i++) {
-      for (int j = 0; j < CHESSBOARD_SIZE; j++) {
-        chessboard[i][j] = 0;
-        score[i][j] = 0;
-      }
-    }
-  }
-
-  //落子
-  void addChessman(int x, int y, int owner) {
-    chessboard[x][y] = owner;
-  }
-
-  //判断落子位置是否合法
-  bool isLegal(int x, int y) {
-    if (x >= 0 &&
-        x < CHESSBOARD_SIZE &&
-        y >= 0 &&
-        y < CHESSBOARD_SIZE &&
-        chessboard[x][y] == 0) {
-      return true;
-    }
-    return false;
-  }
-
-  //判断哪方赢了(必定有刚落的子引发,因此只需判断刚落子的周围),owner为-1代表机器,owner为1代表人类
-  bool isWin(int x, int y, int owner) {
-    int sum = 0;
-    //判断横向左边
-    for (int i = x - 1; i >= 0; i--) {
-      if (chessboard[i][y] == owner) {
-        sum++;
-      } else {
-        break;
-      }
-    }
-    //判断横向右边
-    for (int i = x + 1; i < CHESSBOARD_SIZE; i++) {
-      if (chessboard[i][y] == owner) {
-        sum++;
-      } else {
-        break;
-      }
-    }
-    if (sum >= 4) {
-      return true;
-    }
-
-    sum = 0;
-    //判断纵向上边
-    for (int i = y - 1; i >= 0; i--) {
-      if (chessboard[x][i] == owner) {
-        sum++;
-      } else {
-        break;
-      }
-    }
-    //判断纵向下边
-    for (int i = y + 1; i < CHESSBOARD_SIZE; i++) {
-      if (chessboard[x][i] == owner) {
-        sum++;
-      } else {
-        break;
-      }
-    }
-    if (sum >= 4) {
-      return true;
-    }
-
-    sum = 0;
-    //判断左上角到右下角方向上侧
-    for (int i = x - 1, j = y - 1; i >= 0 && j >= 0; i--, j--) {
-      if (chessboard[i][j] == owner) {
-        sum++;
-      } else {
-        break;
-      }
-    }
-    //判断左上角到右下角方向下侧
-    for (int i = x + 1, j = y + 1;
-        i < CHESSBOARD_SIZE && j < CHESSBOARD_SIZE;
-        i++, j++) {
-      if (chessboard[i][j] == owner) {
-        sum++;
-      } else {
-        break;
-      }
-    }
-    if (sum >= 4) {
-      return true;
-    }
-
-    sum = 0;
-    //判断右上角到左下角方向上侧
-    for (int i = x + 1, j = y - 1; i < CHESSBOARD_SIZE && j >= 0; i++, j--) {
-      if (chessboard[i][j] == owner) {
-        sum++;
-      } else {
-        break;
-      }
-    }
-    //判断右上角到左下角方向下侧
-    for (int i = x - 1, j = y + 1; i >= 0 && j < CHESSBOARD_SIZE; i--, j++) {
-      if (chessboard[i][j] == owner) {
-        sum++;
-      } else {
-        break;
-      }
-    }
-    if (sum >= 4) {
-      return true;
-    }
-
-    return false;
-  }
-
-  //【【【【【*******整个游戏的核心*******】】】】】______确定机器落子位置
-  //使用五元组评分算法,该算法参考博客地址:https://blog.csdn.net/u011587401/article/details/50877828
-  //算法思路:对15X15的572个五元组分别评分,一个五元组的得分就是该五元组为其中每个位置贡献的分数,
-  //	   一个位置的分数就是其所在所有五元组分数之和。所有空位置中分数最高的那个位置就是落子位置。
-  Position searchPosition() {
-    //每次都初始化下score评分数组
-    for (int i = 0; i < CHESSBOARD_SIZE; i++) {
-      for (int j = 0; j < CHESSBOARD_SIZE; j++) {
-        score[i][j] = 0;
-      }
-    }
-
-    //每次机器找寻落子位置,评分都重新算一遍(虽然算了很多多余的,因为上次落子时候算的大多都没变)
-    //先定义一些变量
-    int humanChessmanNum = 0; //五元组中的黑棋数量
-    int machineChessmanNum = 0; //五元组中的白棋数量
-    int tupleScoreTmp = 0; //五元组得分临时变量
-
-    int goalX = -1; //目标位置x坐标
-    int goalY = -1; //目标位置y坐标
-    int maxScore = -1; //最大分数
-
-    //1.扫描横向的15个行
-    for (int i = 0; i < 15; i++) {
-      for (int j = 0; j < 11; j++) {
-        int k = j;
-        while (k < j + 5) {
-          if (chessboard[i][k] == -1)
-            machineChessmanNum++;
-          else if (chessboard[i][k] == 1) humanChessmanNum++;
-
-          k++;
-        }
-        tupleScoreTmp = tupleScore(humanChessmanNum, machineChessmanNum);
-        //为该五元组的每个位置添加分数
-        for (k = j; k < j + 5; k++) {
-          score[i][k] += tupleScoreTmp;
-        }
-        //置零
-        humanChessmanNum = 0; //五元组中的黑棋数量
-        machineChessmanNum = 0; //五元组中的白棋数量
-        tupleScoreTmp = 0; //五元组得分临时变量
-      }
-    }
-
-    //2.扫描纵向15行
-    for (int i = 0; i < 15; i++) {
-      for (int j = 0; j < 11; j++) {
-        int k = j;
-        while (k < j + 5) {
-          if (chessboard[k][i] == -1)
-            machineChessmanNum++;
-          else if (chessboard[k][i] == 1) humanChessmanNum++;
-
-          k++;
-        }
-        tupleScoreTmp = tupleScore(humanChessmanNum, machineChessmanNum);
-        //为该五元组的每个位置添加分数
-        for (k = j; k < j + 5; k++) {
-          score[k][i] += tupleScoreTmp;
-        }
-        //置零
-        humanChessmanNum = 0; //五元组中的黑棋数量
-        machineChessmanNum = 0; //五元组中的白棋数量
-        tupleScoreTmp = 0; //五元组得分临时变量
-      }
-    }
-
-    //3.扫描右上角到左下角上侧部分
-    for (int i = 14; i >= 4; i--) {
-      for (int k = i, j = 0; j < 15 && k >= 0; j++, k--) {
-        int m = k;
-        int n = j;
-        while (m > k - 5 && k - 5 >= -1) {
-          if (chessboard[m][n] == -1)
-            machineChessmanNum++;
-          else if (chessboard[m][n] == 1) humanChessmanNum++;
-
-          m--;
-          n++;
-        }
-        //注意斜向判断的时候,可能构不成五元组(靠近四个角落),遇到这种情况要忽略掉
-        if (m == k - 5) {
-          tupleScoreTmp = tupleScore(humanChessmanNum, machineChessmanNum);
-          //为该五元组的每个位置添加分数
-          m = k;
-          n = j;
-          for (; m > k - 5; m--, n++) {
-            score[m][n] += tupleScoreTmp;
-          }
-        }
-
-        //置零
-        humanChessmanNum = 0; //五元组中的黑棋数量
-        machineChessmanNum = 0; //五元组中的白棋数量
-        tupleScoreTmp = 0; //五元组得分临时变量
-      }
-    }
-
-    //4.扫描右上角到左下角下侧部分
-    for (int i = 1; i < 15; i++) {
-      for (int k = i, j = 14; j >= 0 && k < 15; j--, k++) {
-        int m = k;
-        int n = j;
-        while (m < k + 5 && k + 5 <= 15) {
-          if (chessboard[n][m] == -1)
-            machineChessmanNum++;
-          else if (chessboard[n][m] == 1) humanChessmanNum++;
-
-          m++;
-          n--;
-        }
-        //注意斜向判断的时候,可能构不成五元组(靠近四个角落),遇到这种情况要忽略掉
-        if (m == k + 5) {
-          tupleScoreTmp = tupleScore(humanChessmanNum, machineChessmanNum);
-          //为该五元组的每个位置添加分数
-          m = k;
-          n = j;
-          for (; m < k + 5; m++, n--) {
-            score[n][m] += tupleScoreTmp;
-          }
-        }
-        //置零
-        humanChessmanNum = 0; //五元组中的黑棋数量
-        machineChessmanNum = 0; //五元组中的白棋数量
-        tupleScoreTmp = 0; //五元组得分临时变量
-      }
-    }
-
-    //5.扫描左上角到右下角上侧部分
-    for (int i = 0; i < 11; i++) {
-      for (int k = i, j = 0; j < 15 && k < 15; j++, k++) {
-        int m = k;
-        int n = j;
-        while (m < k + 5 && k + 5 <= 15) {
-          if (chessboard[m][n] == -1)
-            machineChessmanNum++;
-          else if (chessboard[m][n] == 1) humanChessmanNum++;
-
-          m++;
-          n++;
-        }
-        //注意斜向判断的时候,可能构不成五元组(靠近四个角落),遇到这种情况要忽略掉
-        if (m == k + 5) {
-          tupleScoreTmp = tupleScore(humanChessmanNum, machineChessmanNum);
-          //为该五元组的每个位置添加分数
-          m = k;
-          n = j;
-          for (; m < k + 5; m++, n++) {
-            score[m][n] += tupleScoreTmp;
-          }
-        }
-
-        //置零
-        humanChessmanNum = 0; //五元组中的黑棋数量
-        machineChessmanNum = 0; //五元组中的白棋数量
-        tupleScoreTmp = 0; //五元组得分临时变量
-      }
-    }
-
-    //6.扫描左上角到右下角下侧部分
-    for (int i = 1; i < 11; i++) {
-      for (int k = i, j = 0; j < 15 && k < 15; j++, k++) {
-        int m = k;
-        int n = j;
-        while (m < k + 5 && k + 5 <= 15) {
-          if (chessboard[n][m] == -1)
-            machineChessmanNum++;
-          else if (chessboard[n][m] == 1) humanChessmanNum++;
-
-          m++;
-          n++;
-        }
-        //注意斜向判断的时候,可能构不成五元组(靠近四个角落),遇到这种情况要忽略掉
-        if (m == k + 5) {
-          tupleScoreTmp = tupleScore(humanChessmanNum, machineChessmanNum);
-          //为该五元组的每个位置添加分数
-          m = k;
-          n = j;
-          for (; m < k + 5; m++, n++) {
-            score[n][m] += tupleScoreTmp;
-          }
-        }
-
-        //置零
-        humanChessmanNum = 0; //五元组中的黑棋数量
-        machineChessmanNum = 0; //五元组中的白棋数量
-        tupleScoreTmp = 0; //五元组得分临时变量
-      }
-    }
-
-    //从空位置中找到得分最大的位置
-    for (int i = 0; i < 15; i++) {
-      for (int j = 0; j < 15; j++) {
-        if (chessboard[i][j] == 0 && score[i][j] > maxScore) {
-          goalX = i;
-          goalY = j;
-          maxScore = score[i][j];
-        }
-      }
-    }
-
-    if (goalX != -1 && goalY != -1) {
-      return Position(goalX.toDouble(), goalY.toDouble(),
-          ChessFlyweightFactory.getInstance().getChess(""));
-    }
-
-    //没找到坐标说明平局了,笔者不处理平局
-    return Position(
-        -1, -1, ChessFlyweightFactory.getInstance().getChess(""));
-  }
-
-  //各种五元组情况评分表
-  int tupleScore(int humanChessmanNum, int machineChessmanNum) {
-    //1.既有人类落子,又有机器落子,判分为0
-    if (humanChessmanNum > 0 && machineChessmanNum > 0) {
-      return 0;
-    }
-    //2.全部为空,没有落子,判分为7
-    if (humanChessmanNum == 0 && machineChessmanNum == 0) {
-      return 7;
-    }
-    //3.机器落1子,判分为35
-    if (machineChessmanNum == 1) {
-      return 35;
-    }
-    //4.机器落2子,判分为800
-    if (machineChessmanNum == 2) {
-      return 800;
-    }
-    //5.机器落3子,判分为15000
-    if (machineChessmanNum == 3) {
-      return 15000;
-    }
-    //6.机器落4子,判分为800000
-    if (machineChessmanNum == 4) {
-      return 800000;
-    }
-    //7.人类落1子,判分为15
-    if (humanChessmanNum == 1) {
-      return 15;
-    }
-    //8.人类落2子,判分为400
-    if (humanChessmanNum == 2) {
-      return 400;
-    }
-    //9.人类落3子,判分为1800
-    if (humanChessmanNum == 3) {
-      return 1800;
-    }
-    //10.人类落4子,判分为100000
-    if (humanChessmanNum == 4) {
-      return 100000;
-    }
-    return -1; //若是其他结果肯定出错了。这行代码根本不可能执行
-  }
-}

+ 0 - 7
lib/bridge/ChessShape.dart

@@ -1,7 +0,0 @@
-abstract class ChessShape {
-  int? _shape;
-  int get shape => _shape!;
-  set shape(int value) {
-    _shape = value;
-  }
-}

+ 0 - 7
lib/bridge/CircleShape.dart

@@ -1,7 +0,0 @@
-import 'ChessShape.dart';
-
-class CircleShape extends ChessShape{
-  CircleShape(){
-    shape = 1;
-  }
-}

+ 0 - 7
lib/bridge/RectShape.dart

@@ -1,7 +0,0 @@
-import 'ChessShape.dart';
-
-class RectShape extends ChessShape{
-  RectShape(){
-    shape = 2;
-  }
-}

+ 30 - 0
lib/components/game_action_bar.dart

@@ -0,0 +1,30 @@
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:gobang/controllers/game_controller.dart';
+
+/// 悔棋 / 投降 / 重开
+class GameActionBar extends StatelessWidget {
+  const GameActionBar({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final c = Get.find<GameController>();
+    return Row(
+      mainAxisAlignment: MainAxisAlignment.center,
+      children: [
+        IconButton(
+          onPressed: c.undo,
+          icon: const Icon(Icons.undo),
+        ),
+        IconButton(
+          onPressed: c.surrender,
+          icon: const Icon(Icons.sports_handball, color: Colors.deepPurple),
+        ),
+        IconButton(
+          onPressed: c.restart,
+          icon: const Icon(Icons.restart_alt, color: Colors.indigo),
+        ),
+      ],
+    );
+  }
+}

+ 20 - 0
lib/components/game_status_text.dart

@@ -0,0 +1,20 @@
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:gobang/controllers/game_controller.dart';
+
+/// 对局阶段提示文案
+class GameStatusText extends StatelessWidget {
+  const GameStatusText({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final c = Get.find<GameController>();
+    return Obx(
+      () => Text(
+        c.statusText.value,
+        style: const TextStyle(color: Colors.white),
+        textAlign: TextAlign.center,
+      ),
+    );
+  }
+}

+ 200 - 0
lib/controllers/game_controller.dart

@@ -0,0 +1,200 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:gobang/models/chess.dart';
+import 'package:gobang/models/checkerboard.dart';
+import 'package:gobang/models/chess_shape.dart';
+import 'package:gobang/models/circle_shape.dart';
+import 'package:gobang/models/game_state.dart';
+import 'package:gobang/models/position.dart';
+import 'package:gobang/models/rect_shape.dart';
+import 'package:gobang/models/user_context.dart';
+import 'package:gobang/services/ai_service.dart';
+import 'package:gobang/services/chess_flyweight_factory.dart';
+import 'package:gobang/themes/black_theme_factory.dart';
+import 'package:gobang/themes/blue_theme_factory.dart';
+import 'package:gobang/themes/theme_factory.dart';
+import 'package:gobang/utils/constants.dart';
+import 'package:gobang/utils/tips_dialog.dart';
+
+/// 对局控制器:棋盘交互、AI、主题与阶段状态
+class GameController extends GetxController {
+  final UserContext _userContext = UserContext();
+  final Checkerboard board = Checkerboard.getInstance();
+  final AiService ai = AiService.getInstance();
+
+  final themeFactory = Rx<ThemeFactory>(BlueThemeFactory());
+  final useCircle = true.obs;
+  final gameOver = false.obs;
+  final paintVersion = 0.obs;
+  final statusText = '热身阶段,不能悔棋,不能投降'.obs;
+
+  static const lightOn = Icon(Icons.lightbulb, color: Colors.amberAccent);
+  static const lightOff = Icon(Icons.lightbulb_outline_rounded);
+  static const circleIcon = Icon(Icons.circle_outlined);
+  static const rectIcon = Icon(Icons.crop_square);
+
+  Icon get currentLight =>
+      themeFactory.value is BlackThemeFactory ? lightOff : lightOn;
+
+  Icon get currentShape => useCircle.value ? circleIcon : rectIcon;
+
+  Color get themeColor => themeFactory.value.getTheme().getThemeColor();
+
+  @override
+  void onInit() {
+    super.onInit();
+    ai.init();
+    _refreshStatus();
+  }
+
+  void toggleTheme() {
+    themeFactory.value = themeFactory.value is BlackThemeFactory
+        ? BlueThemeFactory()
+        : BlackThemeFactory();
+  }
+
+  void toggleShape() => useCircle.toggle();
+
+  ChessShape _shapeFor(bool circle) =>
+      circle ? CircleShape() : RectShape();
+
+  void _refreshStatus() {
+    final s = _userContext.state;
+    if (s is StartState) {
+      statusText.value = '热身阶段,不能悔棋,不能投降';
+    } else if (s is MidState) {
+      statusText.value = '入神阶段,可以悔棋且剩余${3 - s.reg}次,可以投降';
+    } else if (s is EndState) {
+      statusText.value = '白热化阶段,悔棋次数已用完,但可以投降';
+    }
+  }
+
+  void onBoardTap(Offset local, double boardWidth) {
+    if (gameOver.value) return;
+
+    final cell = boardWidth / (kBoardSize - 1);
+    final snap = _snapToGrid(local, cell);
+    if (snap == null) return;
+
+    final (gridX, gridY, px, py) = snap;
+    if (!ai.isLegal(gridX, gridY)) return;
+
+    _userContext.play();
+    final chess = ChessFlyweightFactory.getInstance().getChess('white');
+    board.add(Position(
+      px,
+      py,
+      chess,
+      gridX: gridX,
+      gridY: gridY,
+      chessShape: _shapeFor(useCircle.value),
+    ));
+    ai.addChessman(gridX, gridY, 1);
+    paintVersion.value++;
+    _refreshStatus();
+
+    if (ai.isWin(gridX, gridY, 1)) {
+      gameOver.value = true;
+      TipsDialog.show(Get.context!, '恭喜', '您打败了决策树算法');
+      return;
+    }
+
+    _playAi(cell);
+  }
+
+  (int, int, double, double)? _snapToGrid(Offset local, double cell) {
+    final gx = (local.dx / cell).round().clamp(0, kBoardSize - 1);
+    final gy = (local.dy / cell).round().clamp(0, kBoardSize - 1);
+    final px = gx * cell;
+    final py = gy * cell;
+    final dist = sqrt(pow(local.dx - px, 2) + pow(local.dy - py, 2));
+    if (dist > cell / 2 - 2) return null;
+    return (gx, gy, px, py);
+  }
+
+  void _playAi(double cell) {
+    final raw = ai.searchPosition();
+    if (raw.dx < 0 || raw.dy < 0) return;
+
+    final gridX = raw.dx.toInt();
+    final gridY = raw.dy.toInt();
+    board.add(Position(
+      gridX * cell,
+      gridY * cell,
+      raw.chess,
+      gridX: gridX,
+      gridY: gridY,
+      chessShape: CircleShape(),
+    ));
+    ai.addChessman(gridX, gridY, -1);
+    paintVersion.value++;
+
+    if (ai.isWin(gridX, gridY, -1)) {
+      gameOver.value = true;
+      TipsDialog.show(Get.context!, '很遗憾', '决策树算法打败了您');
+    }
+  }
+
+  void undo() {
+    if (gameOver.value) {
+      TipsDialog.show(Get.context!, '提示', '对局已结束,请重新开局');
+      return;
+    }
+    if (!_userContext.regretChess()) {
+      TipsDialog.show(Get.context!, '提示', '现阶段不能悔棋');
+      return;
+    }
+    board.undo();
+    _rebuildAiFromBoard();
+    paintVersion.value++;
+    _refreshStatus();
+  }
+
+  void _rebuildAiFromBoard() {
+    ai.init();
+    for (final po in board.state) {
+      ai.addChessman(
+        po.gridX,
+        po.gridY,
+        po.chess is WhiteChess ? 1 : -1,
+      );
+    }
+  }
+
+  Future<void> surrender() async {
+    if (!_userContext.surrender()) {
+      await TipsDialog.show(Get.context!, '提示', '现阶段不能投降');
+      return;
+    }
+    final ok = await TipsDialog.showByChoose(
+      Get.context!,
+      '提示',
+      '是否要投降并重新开局?',
+      '是',
+      '否',
+    );
+    if (ok == true) resetGame();
+  }
+
+  Future<void> restart() async {
+    final ok = await TipsDialog.showByChoose(
+      Get.context!,
+      '提示',
+      '是否重新开局?',
+      '是',
+      '否',
+    );
+    if (ok == true) resetGame();
+  }
+
+  void resetGame() {
+    board.clean();
+    _userContext.reset();
+    ai.init();
+    gameOver.value = false;
+    paintVersion.value++;
+    _refreshStatus();
+  }
+}

+ 0 - 11
lib/factory/BlackTheme.dart

@@ -1,11 +0,0 @@
-import 'package:flutter/material.dart';
-
-import 'Theme.dart' as t;
-
-class BlackTheme extends t.Theme{
-  
-  @override
-  Color getThemeColor() {
-    return Colors.black;
-  }
-}

+ 0 - 10
lib/factory/BlackThemeFactory.dart

@@ -1,10 +0,0 @@
-import 'BlackTheme.dart';
-import 'Theme.dart';
-import 'ThemeFactory.dart';
-
-class BlackThemeFactory extends ThemeFactory{
-  @override
-  Theme getTheme() {
-    return BlackTheme();
-  }
-}

+ 0 - 10
lib/factory/BlueTheme.dart

@@ -1,10 +0,0 @@
-import 'package:flutter/material.dart';
-
-import 'Theme.dart' as t;
-
-class BlueTheme extends t.Theme{
-  @override
-  Color getThemeColor() {
-    return Colors.blue;
-  }
-}

+ 0 - 10
lib/factory/BlueThemeFactory.dart

@@ -1,10 +0,0 @@
-import 'BlueTheme.dart';
-import 'Theme.dart';
-import 'ThemeFactory.dart';
-
-class BlueThemeFactory extends ThemeFactory{
-  @override
-  Theme getTheme() {
-    return BlueTheme();
-  }
-}

+ 0 - 5
lib/factory/ThemeFactory.dart

@@ -1,5 +0,0 @@
-import 'Theme.dart';
-
-abstract class ThemeFactory {
-  Theme getTheme();
-}

+ 0 - 38
lib/flyweight/Chess.dart

@@ -1,38 +0,0 @@
-
-import 'package:flutter/material.dart';
-import 'package:gobang/bridge/ChessShape.dart';
-
-/// 棋子的抽象类
-/// 使用了桥接模式,外观和颜色是两个不同的维度
-abstract class Chess{
-
-  Color? _color;
-
-  Color get color => _color!;
-
-  ChessShape? _chessShape;
-
-  ChessShape get chessShape => _chessShape!;
-
-  set chessShape(ChessShape? __chessShape);
-}
-
-class BlackChess extends Chess{
-  BlackChess() {
-    _color = Colors.black;
-  }
-
-  set chessShape(ChessShape? __chessShape) {
-    super._chessShape = __chessShape;
-  }
-}
-
-class WhiteChess extends Chess{
-  WhiteChess() {
-    _color = Colors.white;
-  }
-
-  set chessShape(ChessShape? __chessShape) {
-    super._chessShape = __chessShape;
-  }
-}

+ 0 - 33
lib/flyweight/ChessFlyweightFactory.dart

@@ -1,33 +0,0 @@
-import 'dart:collection';
-import 'package:gobang/flyweight/Chess.dart';
-
-/// 棋子的享元工厂,采用单例模式
-class ChessFlyweightFactory {
-  ChessFlyweightFactory._();
-
-  static ChessFlyweightFactory? _factory;
-
-  static ChessFlyweightFactory getInstance() {
-    if (_factory == null) {
-      _factory = ChessFlyweightFactory._();
-    }
-    return _factory!;
-  }
-
-  HashMap<String, Chess> _hashMap = HashMap<String, Chess>();
-
-  Chess getChess(String type) {
-    Chess chess;
-    if (_hashMap[type] != null) {
-      chess = _hashMap[type]!;
-    } else {
-      if (type == "white") {
-        chess = WhiteChess();
-      } else {
-        chess = BlackChess();
-      }
-      _hashMap[type] = chess;
-    }
-    return chess;
-  }
-}

+ 0 - 28
lib/flyweight/Position.dart

@@ -1,28 +0,0 @@
-import 'package:gobang/flyweight/Chess.dart';
-
-/// [position] 是棋子的位置类
-class Position{
-  double? _dx;
-  double? _dy;
-  Chess? _chess;
-
-  Chess get chess => _chess!;
-
-  set chess(Chess value) {
-    _chess = value;
-  }
-
-  Position(this._dx, this._dy, this._chess);
-
-  double get dx => _dx!;
-
-  set dx(double value) {
-    _dx = value;
-  }
-
-  get dy => _dy!;
-
-  set dy(value) {
-    _dy = value;
-  }
-}

+ 0 - 362
lib/home_page.dart

@@ -1,362 +0,0 @@
-import 'dart:math';
-
-import 'package:flutter/cupertino.dart';
-import 'package:flutter/material.dart';
-import 'package:gobang/ai/Ai.dart';
-import 'package:gobang/factory/ThemeFactory.dart';
-import 'package:gobang/flyweight/Chess.dart';
-import 'package:gobang/memorandum/Checkerboard.dart';
-import 'package:gobang/utils/TipsDialog.dart';
-import 'package:gobang/viewModel/GameViewModel.dart';
-
-import 'bridge/CircleShape.dart';
-import 'factory/BlackThemeFactory.dart';
-import 'factory/BlueThemeFactory.dart';
-import 'flyweight/Position.dart';
-
-var width = 0.0;
-
-///简单的实现五子棋效果
-class HomePage extends StatefulWidget {
-  @override
-  State<StatefulWidget> createState() => HomePageState();
-}
-
-class HomePageState extends State<HomePage> {
-  ThemeFactory? _themeFactory;
-  GameViewModel _viewModel = GameViewModel.getInstance();
-  Checkerboard _originator = Checkerboard.getInstance();
-  Icon lightOn = Icon(Icons.lightbulb, color: Colors.amberAccent);
-  Icon lightOff = Icon(Icons.lightbulb_outline_rounded);
-  Icon circle = Icon(Icons.circle_outlined);
-  Icon rect = Icon(Icons.crop_square);
-  Icon? currentLight, currentShape;
-
-  @override
-  void initState() {
-    currentLight = lightOn;
-    _themeFactory = BlueThemeFactory();
-    currentShape = circle;
-    super.initState();
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    width = MediaQuery.of(context).size.width * 0.8;
-
-    return Scaffold(
-      appBar: AppBar(
-        elevation: 0,
-        backgroundColor: _themeFactory!.getTheme().getThemeColor(),
-        title: Text("南瓜五子棋"),
-        actions: [
-          IconButton(
-              onPressed: () {
-                setState(() {
-                  if (_themeFactory is BlackThemeFactory) {
-                    currentLight = lightOn;
-                    _themeFactory = BlueThemeFactory();
-                  } else {
-                    currentLight = lightOff;
-                    _themeFactory = BlackThemeFactory();
-                  }
-                });
-              },
-              icon: currentLight!),
-          IconButton(
-              onPressed: () {
-                setState(() {
-                  if (currentShape == circle) {
-                    currentShape = rect;
-                  } else {
-                    currentShape = circle;
-                  }
-                });
-              },
-              icon: currentShape!),
-        ],
-      ),
-      body: Container(
-        decoration: BoxDecoration(
-            gradient: LinearGradient(
-                colors: [
-              _themeFactory!.getTheme().getThemeColor(),
-              Colors.white,
-            ],
-                stops: [
-              0.0,
-              1
-            ],
-                begin: FractionalOffset.topCenter,
-                end: FractionalOffset.bottomCenter,
-                tileMode: TileMode.repeated)),
-        child: Center(
-          child: Column(
-              mainAxisAlignment: MainAxisAlignment.center,
-              crossAxisAlignment: CrossAxisAlignment.center,
-              mainAxisSize: MainAxisSize.max,
-              children: <Widget>[
-                Padding(
-                  padding: EdgeInsets.only(top: 14, bottom: 30),
-                  child: Text(
-                    _viewModel.state,
-                    style: TextStyle(color: Colors.white),
-                  ),
-                ),
-                GestureDetector(
-                    onTapDown: (topDownDetails) {
-                      var position = topDownDetails.localPosition;
-                      Chess chess = _viewModel.play(currentShape == circle);
-                      setState(() {
-                        ChessPainter._position =
-                            Position(position.dx, position.dy, chess);
-                      });
-                    },
-                    child: Stack(
-                      children: [
-                        CustomPaint(
-                          size: Size(width, width),
-                          painter: CheckerBoardPainter(),
-                        ),
-                        CustomPaint(
-                          size: Size(width, width),
-                          painter: ChessPainter(turnAi),
-                        )
-                      ],
-                    )),
-                Padding(
-                  padding: const EdgeInsets.only(top: 16.0),
-                  child: Row(
-                    mainAxisAlignment: MainAxisAlignment.center,
-                    children: [
-                      IconButton(
-                          onPressed: () {
-                            if (_viewModel.undo()) {
-                              _originator.undo();
-                              Ai.getInstance().init();
-                              for (Position po in _originator.state) {
-                                Ai.getInstance().addChessman(
-                                    po.dx ~/ (width / 15),
-                                    po.dy ~/ (width / 15),
-                                    po.chess is WhiteChess ? 1 : -1);
-                              }
-                              setState(() {});
-                            } else {
-                              TipsDialog.show(context, "提示", "现阶段不能悔棋");
-                            }
-                          },
-                          icon: Icon(Icons.undo)),
-                      IconButton(
-                          onPressed: () {
-                            if (_viewModel.surrender()) {
-                              TipsDialog.showByChoose(
-                                  context, "提示", "是否要投降并重新开局?", "是", "否",
-                                  (value) {
-                                if (value) {
-                                  setState(() {
-                                    ChessPainter._position = null;
-                                    _originator.clean();
-                                    _viewModel.reset();
-                                    Ai.getInstance().init();
-                                  });
-                                }
-                                Navigator.pop(context);
-                              });
-                            } else {
-                              TipsDialog.show(context, "提示", "现阶段不能投降");
-                            }
-                          },
-                          icon: Icon(
-                            Icons.sports_handball,
-                            color: Colors.deepPurple,
-                          )),
-                      IconButton(
-                          onPressed: () {
-                            TipsDialog.showByChoose(
-                                context, "提示", "是否重新开局?", "是", "否", (value) {
-                              if (value) {
-                                setState(() {
-                                  ChessPainter._position = null;
-                                  _originator.clean();
-                                  _viewModel.reset();
-                                  Ai.getInstance().init();
-                                });
-                              }
-                              Navigator.pop(context);
-                            });
-                          },
-                          icon: Icon(
-                            Icons.restart_alt,
-                            color: Colors.indigo,
-                          )),
-                    ],
-                  ),
-                ),
-              ]),
-        ),
-      ),
-    );
-  }
-
-  /// Ai 下棋
-  void turnAi() {
-    if (ChessPainter._position!.chess is WhiteChess &&
-        Ai.getInstance().isWin(ChessPainter._position!.dx ~/ (width / 15),
-            ChessPainter._position!.dy ~/ (width / 15), 1)) {
-      TipsDialog.show(context, "恭喜", "您打败了决策树算法");
-    }
-    // 获取Ai下棋地址
-    Ai ai = Ai.getInstance();
-    ChessPainter._position = ai.searchPosition();
-    // 设置棋子外观
-    ChessPainter._position!.chess.chessShape = CircleShape();
-    // 加入决策中
-    Ai.getInstance().addChessman(ChessPainter._position!.dx.toInt(),
-        ChessPainter._position!.dy.toInt(), -1);
-    if (ChessPainter._position!.chess is BlackChess &&
-        Ai.getInstance().isWin(ChessPainter._position!.dx.toInt(),
-            ChessPainter._position!.dy.toInt(), -1)) {
-      TipsDialog.show(context, "很遗憾", "决策树算法打败了您");
-    }
-    setState(() {
-      ChessPainter._position!.dx = ChessPainter._position!.dx * (width / 15);
-      ChessPainter._position!.dy = ChessPainter._position!.dy * (width / 15);
-    });
-  }
-}
-
-class ChessPainter extends CustomPainter {
-  static Position? _position;
-  final Function _function;
-  Checkerboard _originator = Checkerboard.getInstance();
-
-  ChessPainter(Function f) : _function = f;
-
-  @override
-  void paint(Canvas canvas, Size size) {
-    if (_position == null) {
-      return;
-    }
-    bool add = false;
-    double mWidth = size.width / 15;   // 每行/列 15 个
-    double mHeight = size.height / 15;
-    var mPaint = Paint();
-    //求两个点之间的距离,让棋子正确的显示在坐标轴上面
-    var dx = _position!.dx;
-    var dy = _position!.dy;
-    for (int i = 0; i < CheckerBoardPainter._crossOverBeanList.length; i++) {
-      var absX =
-          (dx - CheckerBoardPainter._crossOverBeanList[i]._dx).abs(); //两个点的x轴距离
-      var absY =
-          (dy - CheckerBoardPainter._crossOverBeanList[i]._dy).abs(); //两个点的y轴距离
-      var s = sqrt(absX * absX +
-          absY * absY); //利用直角三角形求斜边公式(a的平方 + b的平方 = c的平方)来计算出两点间的距离
-      if (s <= mWidth / 2 - 2) {
-        // 触摸点到棋盘坐标坐标点距离小于等于棋子半径,那么
-        //找到离触摸点最近的棋盘坐标点并记录保存下来
-        _position!.dx = CheckerBoardPainter._crossOverBeanList[i]._dx;
-        _position!.dy = CheckerBoardPainter._crossOverBeanList[i]._dy;
-        _originator.add(_position!);
-        add = true;
-        if (_position!.chess is WhiteChess) {
-          Ai.getInstance().addChessman(
-              _position!.dx ~/ (width / 15), _position!.dy ~/ (width / 15), 1);
-        }
-        // flag = false; //白子下完了,该黑子下了
-        break;
-      }
-    }
-
-    //画子
-    mPaint..style = PaintingStyle.fill;
-    if (_originator.state.isNotEmpty) {
-      for (int i = 0; i < _originator.state.length; i++) {
-        mPaint..color = _originator.state[i].chess.color;
-        if (_originator.state[i].chess.chessShape.shape == 1) {
-          canvas.drawCircle(
-              Offset(_originator.state[i].dx, _originator.state[i].dy),
-              min(mWidth / 2, mHeight / 2) - 2,
-              mPaint);
-        }
-        if (_originator.state[i].chess.chessShape.shape == 2) {
-          Rect rect = Rect.fromCircle(
-              center: Offset(_originator.state[i].dx, _originator.state[i].dy),
-              radius: min(mWidth / 2, mHeight / 2) - 2);
-          canvas.drawRect(rect, mPaint);
-        }
-      }
-    }
-    WidgetsBinding.instance!.addPostFrameCallback((_) {
-      if (add && _position!.chess is WhiteChess) {
-        _function();
-      }
-    });
-  }
-
-  //在实际场景中正确利用此回调可以避免重绘开销,本示例我们简单的返回true
-  @override
-  bool shouldRepaint(CustomPainter oldDelegate) {
-    return true;
-  }
-}
-
-class CheckerBoardPainter extends CustomPainter {
-  static List<CrossOverBean> _crossOverBeanList = [];
-  static int _state = 0;
-
-  @override
-  void paint(Canvas canvas, Size size) {
-    double mWidth = size.width / 15;
-    double mHeight = size.height / 15;
-    var mPaint = Paint();
-
-    _crossOverBeanList.clear();
-    //重绘下整个界面的画布北京颜色
-    //设置画笔,画棋盘背景
-    mPaint
-      ..isAntiAlias = true //抗锯齿
-      ..style = PaintingStyle.fill //填充
-      ..color = Color(0x77cdb175); //背景为纸黄色
-    canvas.drawRect(
-        Rect.fromCenter(
-            center: Offset(size.width / 2, size.height / 2),
-            width: size.width,
-            height: size.height),
-        mPaint);
-    //画棋盘网格
-    mPaint
-      ..style = PaintingStyle.stroke
-      ..color = CupertinoColors.systemGrey6
-      ..strokeWidth = 1.0;
-    for (var i = 0; i <= 15; i++) {
-      //画横线
-      canvas.drawLine(
-          Offset(0, mHeight * i), Offset(size.width, mHeight * i), mPaint);
-    }
-    for (var i = 0; i <= 15; i++) {
-      //画竖线
-      canvas.drawLine(
-          Offset(mWidth * i, 0), Offset(mWidth * i, size.height), mPaint);
-    }
-    //记录横竖线所有的交叉点
-    for (int i = 0; i <= 15; i++) {
-      for (int j = 0; j <= 15; j++) {
-        _crossOverBeanList.add(CrossOverBean(mWidth * j, mHeight * i));
-      }
-    }
-  }
-
-  //在实际场景中正确利用此回调可以避免重绘开销,本示例我们简单的返回true
-  @override
-  bool shouldRepaint(CustomPainter oldDelegate) {
-    return false;
-  }
-}
-
-///记录棋盘上横竖线的交叉点
-class CrossOverBean {
-  double _dx;
-  double _dy;
-
-  CrossOverBean(this._dx, this._dy);
-}

+ 30 - 16
lib/main.dart

@@ -1,29 +1,43 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
-import 'home_page.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+import 'package:get/get.dart';
+import 'package:gobang/routes/app_pages.dart';
 
 void main() {
   WidgetsFlutterBinding.ensureInitialized();
-  runApp(MyApp());
-  SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
-    statusBarColor: Colors.transparent,
-    statusBarIconBrightness: Brightness.dark,
-    systemNavigationBarColor: Colors.transparent,
-    systemNavigationBarIconBrightness: Brightness.dark,
-  ));
+  SystemChrome.setSystemUIOverlayStyle(
+    const SystemUiOverlayStyle(
+      statusBarColor: Colors.transparent,
+      statusBarIconBrightness: Brightness.dark,
+      systemNavigationBarColor: Colors.transparent,
+      systemNavigationBarIconBrightness: Brightness.dark,
+    ),
+  );
+  runApp(const MyApp());
 }
 
 class MyApp extends StatelessWidget {
-  // This widget is the root of your application.
+  const MyApp({super.key});
+
   @override
   Widget build(BuildContext context) {
-    return MaterialApp(
-      debugShowCheckedModeBanner: false,
-      title: '南瓜五子棋',
-      theme: ThemeData(
-        primarySwatch: Colors.blue,
-      ),
-      home: HomePage(),
+    return ScreenUtilInit(
+      designSize: const Size(375, 812),
+      minTextAdapt: true,
+      splitScreenMode: true,
+      builder: (_, __) {
+        return GetMaterialApp(
+          debugShowCheckedModeBanner: false,
+          title: '南瓜五子棋',
+          theme: ThemeData(
+            colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
+            useMaterial3: true,
+          ),
+          initialRoute: AppPages.initial,
+          getPages: AppPages.routes,
+        );
+      },
     );
   }
 }

+ 0 - 23
lib/memorandum/CareTaker.dart

@@ -1,23 +0,0 @@
-import 'Memo.dart';
-
-class CareTaker{
-  List<Memo> mementoList = [];
-
-  void add(Memo memo) {
-    mementoList.add(memo);
-    if (mementoList.length > 10) {
-      mementoList.removeRange(0, 1);
-    }
-  }
-
-  Memo get(int index){
-    return mementoList[index];
-  }
-
-  Memo getLast() {
-    Memo memo = mementoList[mementoList.length-3];
-    mementoList.removeLast();
-    mementoList.removeLast();
-    return memo;
-  }
-}

+ 0 - 56
lib/memorandum/Checkerboard.dart

@@ -1,56 +0,0 @@
-import 'package:gobang/flyweight/Position.dart';
-import 'package:gobang/memorandum/CareTaker.dart';
-
-import 'Memo.dart';
-
-class Checkerboard {
-
-  Checkerboard._();
-
-  static Checkerboard? _originator;
-
-  static getInstance(){
-    if(_originator == null){
-      _originator = Checkerboard._();
-    }
-    return _originator;
-  }
-
-  bool _canAdd = true; //是否需要添加
-
-  List<Position> _state = [];
-
-  List<Position> get state{
-    return _state;
-  }
-
-  CareTaker _careTaker = CareTaker();
-
-  add(Position position) {
-    if(_canAdd) { //因为每次渲染完默认会把最后一个下棋的位置添加上,但在悔棋阶段最后一个是不需要,因此需要这个判断。
-      _state.add(position);
-      _careTaker.add(_save());
-    }
-    _canAdd = true;
-  }
-
-  _from(Memo memo) {
-    this._state = memo.state;
-  }
-
-  Memo _save() {
-    return Memo()..state.addAll(this._state);
-  }
-
-  clean(){
-    _state = [];
-  }
-
-  undo() {
-    Memo memo = _careTaker.getLast();
-    _from(memo);
-    _canAdd = false;
-  }
-
-
-}

+ 0 - 5
lib/memorandum/Memo.dart

@@ -1,5 +0,0 @@
-import 'package:gobang/flyweight/Position.dart';
-
-class Memo {
-  List<Position> state = [];
-}

+ 29 - 0
lib/models/care_taker.dart

@@ -0,0 +1,29 @@
+import 'memo.dart';
+
+class CareTaker {
+  final List<Memo> _mementoList = [];
+
+  void add(Memo memo) {
+    _mementoList.add(memo);
+    if (_mementoList.length > 10) {
+      _mementoList.removeAt(0);
+    }
+  }
+
+  /// 撤销人类+AI 各一步(共两步),恢复到此前快照。
+  Memo getLast() {
+    if (_mementoList.length < 3) {
+      return Memo();
+    }
+    final memo = _mementoList[_mementoList.length - 3];
+    _mementoList.removeLast();
+    _mementoList.removeLast();
+    return Memo(memo.state);
+  }
+
+  void clear() {
+    _mementoList.clear();
+  }
+
+  int get length => _mementoList.length;
+}

+ 38 - 0
lib/models/checkerboard.dart

@@ -0,0 +1,38 @@
+import 'package:gobang/models/position.dart';
+import 'package:gobang/models/care_taker.dart';
+import 'package:gobang/models/memo.dart';
+
+class Checkerboard {
+  Checkerboard._();
+
+  static final Checkerboard instance = Checkerboard._();
+
+  static Checkerboard getInstance() => instance;
+
+  List<Position> _state = [];
+
+  List<Position> get state => _state;
+
+  final CareTaker _careTaker = CareTaker();
+
+  void add(Position position) {
+    _state.add(position);
+    _careTaker.add(_save());
+  }
+
+  void _from(Memo memo) {
+    _state = List.of(memo.state);
+  }
+
+  Memo _save() => Memo(_state);
+
+  void clean() {
+    _state = [];
+    _careTaker.clear();
+  }
+
+  void undo() {
+    final memo = _careTaker.getLast();
+    _from(memo);
+  }
+}

+ 27 - 0
lib/models/chess.dart

@@ -0,0 +1,27 @@
+import 'package:flutter/material.dart';
+import 'package:gobang/models/chess_shape.dart';
+import 'package:gobang/models/circle_shape.dart';
+
+/// 棋子抽象类(桥接:颜色与外观分离)
+/// 颜色为享元内部状态;实际绘制以外层 Position.chessShape 为准。
+abstract class Chess {
+  Color get color;
+
+  ChessShape get chessShape;
+}
+
+class BlackChess extends Chess {
+  @override
+  Color get color => Colors.black;
+
+  @override
+  ChessShape get chessShape => CircleShape();
+}
+
+class WhiteChess extends Chess {
+  @override
+  Color get color => Colors.white;
+
+  @override
+  ChessShape get chessShape => CircleShape();
+}

+ 3 - 0
lib/models/chess_shape.dart

@@ -0,0 +1,3 @@
+abstract class ChessShape {
+  int get shape;
+}

+ 6 - 0
lib/models/circle_shape.dart

@@ -0,0 +1,6 @@
+import 'chess_shape.dart';
+
+class CircleShape extends ChessShape {
+  @override
+  int get shape => 1;
+}

+ 72 - 0
lib/models/game_state.dart

@@ -0,0 +1,72 @@
+import 'package:gobang/models/user_context.dart';
+
+abstract class GameState {
+  int step = 0;
+  int reg = 0;
+  final UserContext userContext;
+
+  GameState(this.userContext);
+
+  bool regretChess();
+
+  bool surrender();
+
+  void play() {
+    step++;
+  }
+}
+
+/// 开局:不可悔棋、不可投降
+class StartState extends GameState {
+  StartState(super.userContext);
+
+  @override
+  bool regretChess() => false;
+
+  @override
+  bool surrender() => false;
+
+  @override
+  void play() {
+    super.play();
+    if (step >= 4) {
+      userContext.setState(
+        MidState(userContext)
+          ..step = step
+          ..reg = reg,
+      );
+    }
+  }
+}
+
+/// 中盘:可悔棋(最多 3 次)、可投降
+class MidState extends GameState {
+  MidState(super.userContext);
+
+  @override
+  bool regretChess() {
+    reg++;
+    if (reg == 3) {
+      userContext.setState(
+        EndState(userContext)
+          ..step = step
+          ..reg = reg,
+      );
+    }
+    return true;
+  }
+
+  @override
+  bool surrender() => true;
+}
+
+/// 终盘:悔棋用尽,仍可投降
+class EndState extends GameState {
+  EndState(super.userContext);
+
+  @override
+  bool regretChess() => false;
+
+  @override
+  bool surrender() => true;
+}

+ 7 - 0
lib/models/memo.dart

@@ -0,0 +1,7 @@
+import 'package:gobang/models/position.dart';
+
+class Memo {
+  final List<Position> state;
+
+  Memo([List<Position>? state]) : state = List.of(state ?? const []);
+}

+ 21 - 0
lib/models/position.dart

@@ -0,0 +1,21 @@
+import 'package:gobang/models/chess.dart';
+import 'package:gobang/models/chess_shape.dart';
+
+/// 棋子位置;颜色由享元 [Chess] 共享,形状与坐标为外部状态。
+class Position {
+  double dx;
+  double dy;
+  int gridX;
+  int gridY;
+  Chess chess;
+  ChessShape chessShape;
+
+  Position(
+    this.dx,
+    this.dy,
+    this.chess, {
+    this.gridX = 0,
+    this.gridY = 0,
+    ChessShape? chessShape,
+  }) : chessShape = chessShape ?? chess.chessShape;
+}

+ 6 - 0
lib/models/rect_shape.dart

@@ -0,0 +1,6 @@
+import 'chess_shape.dart';
+
+class RectShape extends ChessShape {
+  @override
+  int get shape => 2;
+}

+ 27 - 0
lib/models/user_context.dart

@@ -0,0 +1,27 @@
+import 'package:gobang/models/game_state.dart';
+
+class UserContext {
+  late GameState _state;
+
+  GameState get state => _state;
+
+  UserContext() {
+    _state = StartState(this);
+  }
+
+  void play() {
+    _state.play();
+  }
+
+  bool regretChess() => _state.regretChess();
+
+  bool surrender() => _state.surrender();
+
+  void setState(GameState state) {
+    _state = state;
+  }
+
+  void reset() {
+    _state = StartState(this);
+  }
+}

+ 0 - 16
lib/pages/about_page.dart

@@ -1,16 +0,0 @@
-import 'package:flutter/src/widgets/framework.dart';
-import 'package:flutter/src/widgets/placeholder.dart';
-
-class AboutPage extends StatefulWidget {
-  const AboutPage({Key? key}) : super(key: key);
-
-  @override
-  State<AboutPage> createState() => _AboutPageState();
-}
-
-class _AboutPageState extends State<AboutPage> {
-  @override
-  Widget build(BuildContext context) {
-    return const Placeholder();
-  }
-}

+ 65 - 0
lib/pages/home/home_page.dart

@@ -0,0 +1,65 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+import 'package:get/get.dart';
+import 'package:gobang/components/game_action_bar.dart';
+import 'package:gobang/components/game_status_text.dart';
+import 'package:gobang/controllers/game_controller.dart';
+import 'package:gobang/widgets/gobang_board.dart';
+
+class HomePage extends GetView<GameController> {
+  const HomePage({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Obx(() {
+      final themeColor = controller.themeColor;
+      // 订阅形状切换,刷新 AppBar icon
+      final _ = controller.useCircle.value;
+      final boardSize = (1.sw * 0.8).clamp(0.0, 1.sh * 0.55);
+
+      return Scaffold(
+        appBar: AppBar(
+          elevation: 0,
+          backgroundColor: themeColor,
+          title: const Text('南瓜五子棋'),
+          actions: [
+            IconButton(
+              onPressed: controller.toggleTheme,
+              icon: controller.currentLight,
+            ),
+            IconButton(
+              onPressed: controller.toggleShape,
+              icon: controller.currentShape,
+            ),
+          ],
+        ),
+        body: Container(
+          width: double.infinity,
+          decoration: BoxDecoration(
+            gradient: LinearGradient(
+              colors: [themeColor, Colors.white],
+              begin: Alignment.topCenter,
+              end: Alignment.bottomCenter,
+            ),
+          ),
+          child: SafeArea(
+            child: SingleChildScrollView(
+              padding: EdgeInsets.symmetric(vertical: 16.h),
+              child: Column(
+                children: [
+                  Padding(
+                    padding: EdgeInsets.only(bottom: 20.h),
+                    child: const GameStatusText(),
+                  ),
+                  GobangBoard(size: boardSize.toDouble()),
+                  SizedBox(height: 16.h),
+                  const GameActionBar(),
+                ],
+              ),
+            ),
+          ),
+        ),
+      );
+    });
+  }
+}

+ 0 - 16
lib/pages/login_page.dart

@@ -1,16 +0,0 @@
-import 'package:flutter/src/widgets/framework.dart';
-import 'package:flutter/src/widgets/placeholder.dart';
-
-class LoginPage extends StatefulWidget {
-  const LoginPage({Key? key}) : super(key: key);
-
-  @override
-  State<LoginPage> createState() => _LoginPageState();
-}
-
-class _LoginPageState extends State<LoginPage> {
-  @override
-  Widget build(BuildContext context) {
-    return const Placeholder();
-  }
-}

+ 0 - 16
lib/pages/register_page.dart

@@ -1,16 +0,0 @@
-import 'package:flutter/src/widgets/framework.dart';
-import 'package:flutter/src/widgets/placeholder.dart';
-
-class RegisgerPage extends StatefulWidget {
-  const RegisgerPage({Key? key}) : super(key: key);
-
-  @override
-  State<RegisgerPage> createState() => _RegisgerPageState();
-}
-
-class _RegisgerPageState extends State<RegisgerPage> {
-  @override
-  Widget build(BuildContext context) {
-    return const Placeholder();
-  }
-}

+ 0 - 16
lib/pages/splash_page.dart

@@ -1,16 +0,0 @@
-import 'package:flutter/src/widgets/framework.dart';
-import 'package:flutter/src/widgets/placeholder.dart';
-
-class SpalshPage extends StatefulWidget {
-  const SpalshPage({Key? key}) : super(key: key);
-
-  @override
-  State<SpalshPage> createState() => _SpalshPageState();
-}
-
-class _SpalshPageState extends State<SpalshPage> {
-  @override
-  Widget build(BuildContext context) {
-    return const Placeholder();
-  }
-}

+ 0 - 4
lib/routes.dart

@@ -1,4 +0,0 @@
-
-class Routes {
-  static const String index = "/index";
-}

+ 18 - 0
lib/routes/app_pages.dart

@@ -0,0 +1,18 @@
+import 'package:get/get.dart';
+import 'package:gobang/controllers/game_controller.dart';
+import 'package:gobang/pages/home/home_page.dart';
+import 'package:gobang/routes/app_routes.dart';
+
+class AppPages {
+  static const initial = AppRoutes.home;
+
+  static final routes = <GetPage<dynamic>>[
+    GetPage(
+      name: AppRoutes.home,
+      page: () => const HomePage(),
+      binding: BindingsBuilder(() {
+        Get.lazyPut(GameController.new);
+      }),
+    ),
+  ];
+}

+ 4 - 0
lib/routes/app_routes.dart

@@ -0,0 +1,4 @@
+/// 路由名常量
+abstract class AppRoutes {
+  static const home = '/home';
+}

+ 221 - 0
lib/services/ai_service.dart

@@ -0,0 +1,221 @@
+import 'package:gobang/utils/constants.dart';
+import 'package:gobang/services/chess_flyweight_factory.dart';
+import 'package:gobang/models/position.dart';
+
+/// 五子棋 AI:五元组评分算法
+/// 参考:https://blog.csdn.net/u011587401/article/details/50877828
+class AiService {
+  AiService._() {
+    chessboard = List.generate(
+      kBoardSize,
+      (_) => List.filled(kBoardSize, 0),
+    );
+    score = List.generate(
+      kBoardSize,
+      (_) => List.filled(kBoardSize, 0),
+    );
+  }
+
+  static final AiService instance = AiService._();
+
+  static AiService getInstance() => instance;
+
+  /// 先手:1 人类,-1 机器
+  int first = 1;
+
+  late final List<List<int>> chessboard;
+  late final List<List<int>> score;
+
+  void init() {
+    first = 1;
+    for (var i = 0; i < kBoardSize; i++) {
+      for (var j = 0; j < kBoardSize; j++) {
+        chessboard[i][j] = 0;
+        score[i][j] = 0;
+      }
+    }
+  }
+
+  void addChessman(int x, int y, int owner) {
+    if (x < 0 || x >= kBoardSize || y < 0 || y >= kBoardSize) return;
+    chessboard[x][y] = owner;
+  }
+
+  bool isLegal(int x, int y) {
+    return x >= 0 &&
+        x < kBoardSize &&
+        y >= 0 &&
+        y < kBoardSize &&
+        chessboard[x][y] == 0;
+  }
+
+  /// 判断 [owner] 在 (x,y) 落子后是否五连
+  bool isWin(int x, int y, int owner) {
+    const directions = [
+      (1, 0),
+      (0, 1),
+      (1, 1),
+      (1, -1),
+    ];
+    for (final (dx, dy) in directions) {
+      var count = 1;
+      count += _countDirection(x, y, dx, dy, owner);
+      count += _countDirection(x, y, -dx, -dy, owner);
+      if (count >= 5) return true;
+    }
+    return false;
+  }
+
+  int _countDirection(int x, int y, int dx, int dy, int owner) {
+    var count = 0;
+    var cx = x + dx;
+    var cy = y + dy;
+    while (cx >= 0 &&
+        cx < kBoardSize &&
+        cy >= 0 &&
+        cy < kBoardSize &&
+        chessboard[cx][cy] == owner) {
+      count++;
+      cx += dx;
+      cy += dy;
+    }
+    return count;
+  }
+
+  /// 选取评分最高的空位作为落子点
+  Position searchPosition() {
+    for (var i = 0; i < kBoardSize; i++) {
+      for (var j = 0; j < kBoardSize; j++) {
+        score[i][j] = 0;
+      }
+    }
+
+    _scoreRows();
+    _scoreColumns();
+    _scoreDiagDown();
+    _scoreDiagUp();
+
+    var goalX = -1;
+    var goalY = -1;
+    var maxScore = -1;
+
+    for (var i = 0; i < kBoardSize; i++) {
+      for (var j = 0; j < kBoardSize; j++) {
+        if (chessboard[i][j] == 0 && score[i][j] > maxScore) {
+          goalX = i;
+          goalY = j;
+          maxScore = score[i][j];
+        }
+      }
+    }
+
+    final black = ChessFlyweightFactory.getInstance().getChess('black');
+    if (goalX != -1 && goalY != -1) {
+      return Position(goalX.toDouble(), goalY.toDouble(), black);
+    }
+    return Position(-1, -1, black);
+  }
+
+  void _scoreRows() {
+    for (var i = 0; i < kBoardSize; i++) {
+      for (var j = 0; j <= kBoardSize - 5; j++) {
+        var human = 0;
+        var machine = 0;
+        for (var k = j; k < j + 5; k++) {
+          final cell = chessboard[i][k];
+          if (cell == -1) {
+            machine++;
+          } else if (cell == 1) {
+            human++;
+          }
+        }
+        final s = tupleScore(human, machine);
+        for (var k = j; k < j + 5; k++) {
+          score[i][k] += s;
+        }
+      }
+    }
+  }
+
+  void _scoreColumns() {
+    for (var i = 0; i < kBoardSize; i++) {
+      for (var j = 0; j <= kBoardSize - 5; j++) {
+        var human = 0;
+        var machine = 0;
+        for (var k = j; k < j + 5; k++) {
+          final cell = chessboard[k][i];
+          if (cell == -1) {
+            machine++;
+          } else if (cell == 1) {
+            human++;
+          }
+        }
+        final s = tupleScore(human, machine);
+        for (var k = j; k < j + 5; k++) {
+          score[k][i] += s;
+        }
+      }
+    }
+  }
+
+  /// 左上 → 右下
+  void _scoreDiagDown() {
+    for (var i = 0; i <= kBoardSize - 5; i++) {
+      for (var j = 0; j <= kBoardSize - 5; j++) {
+        var human = 0;
+        var machine = 0;
+        for (var k = 0; k < 5; k++) {
+          final cell = chessboard[i + k][j + k];
+          if (cell == -1) {
+            machine++;
+          } else if (cell == 1) {
+            human++;
+          }
+        }
+        final s = tupleScore(human, machine);
+        for (var k = 0; k < 5; k++) {
+          score[i + k][j + k] += s;
+        }
+      }
+    }
+  }
+
+  /// 左下 → 右上
+  void _scoreDiagUp() {
+    for (var i = 4; i < kBoardSize; i++) {
+      for (var j = 0; j <= kBoardSize - 5; j++) {
+        var human = 0;
+        var machine = 0;
+        for (var k = 0; k < 5; k++) {
+          final cell = chessboard[i - k][j + k];
+          if (cell == -1) {
+            machine++;
+          } else if (cell == 1) {
+            human++;
+          }
+        }
+        final s = tupleScore(human, machine);
+        for (var k = 0; k < 5; k++) {
+          score[i - k][j + k] += s;
+        }
+      }
+    }
+  }
+
+  /// 五元组评分表
+  int tupleScore(int humanChessmanNum, int machineChessmanNum) {
+    if (humanChessmanNum > 0 && machineChessmanNum > 0) return 0;
+    if (humanChessmanNum == 0 && machineChessmanNum == 0) return 7;
+
+    const machineScores = {1: 35, 2: 800, 3: 15000, 4: 800000};
+    const humanScores = {1: 15, 2: 400, 3: 1800, 4: 100000};
+
+    if (machineChessmanNum > 0) {
+      return machineScores[machineChessmanNum] ?? -1;
+    }
+    if (humanChessmanNum > 0) {
+      return humanScores[humanChessmanNum] ?? -1;
+    }
+    return -1;
+  }
+}

+ 24 - 0
lib/services/chess_flyweight_factory.dart

@@ -0,0 +1,24 @@
+import 'package:gobang/models/chess.dart';
+
+/// 棋子享元工厂(单例)
+class ChessFlyweightFactory {
+  ChessFlyweightFactory._();
+
+  static final ChessFlyweightFactory instance = ChessFlyweightFactory._();
+
+  static ChessFlyweightFactory getInstance() => instance;
+
+  final Map<String, Chess> _cache = {};
+
+  Chess getChess(String type) {
+    return _cache.putIfAbsent(type, () {
+      switch (type) {
+        case 'white':
+          return WhiteChess();
+        case 'black':
+        default:
+          return BlackChess();
+      }
+    });
+  }
+}

+ 0 - 96
lib/state/State.dart

@@ -1,96 +0,0 @@
-import 'package:gobang/state/UserContext.dart';
-
-abstract class State {
-  int _step = 0;
-
-  int get step => _step;
-  int _reg = 0;
-
-  int get reg => _reg;
-  UserContext _userContext;
-
-  State(UserContext userContext):_userContext = userContext;
-
-  // 悔棋只能悔棋三次
-  bool regretChess();
-
-  // 认输10步之内不能认输
-  bool surrender();
-
-  play() {
-    _step++;
-  }
-
-}
-
-/// [StartState] 开始状态
-class StartState extends State {
-
-  StartState(UserContext userContext) : super(userContext);
-
-  // 悔棋只能悔棋三次
-  @override
-  bool regretChess(){
-    return false;
-  }
-
-  @override
-  bool surrender() {
-    return false;
-  }
-
-  @override
-  play() {
-    super.play();
-    if(_step >= 4) {
-      _userContext.setState(MidState(_userContext).._step = _step.._reg = _reg);
-    }
-  }
-
-}
-
-/// [MidState] 中场状态
-class MidState extends State {
-  MidState(UserContext userContext) : super(userContext);
-
-  @override
-  int get _reg{
-    return super._reg;
-  }
-
-  // 悔棋只能悔棋三次
-  @override
-  bool regretChess(){
-    _reg++;
-    if(_reg == 3) {
-      print('切换到白热化阶段');
-      _userContext.setState(EndState(_userContext).._step = _step.._reg = _reg);
-    }
-    return true;
-  }
-
-  @override
-  bool surrender() {
-    return true;
-  }
-
-}
-
-/// [EndState] 结尾状态
-class EndState extends State {
-  EndState(UserContext userContext) : super(userContext);
-
-
-
-  // 悔棋只能悔棋三次
-  @override
-  regretChess(){
-    return false;
-  }
-
-  @override
-  surrender() {
-    return true;
-  }
-
-}

+ 0 - 34
lib/state/UserContext.dart

@@ -1,34 +0,0 @@
-import 'package:gobang/state/State.dart';
-
-class UserContext {
-
-  late State _state;
-
-  State get state => _state;
-
-  UserContext(){
-    _state = StartState(this);
-  }
-
-  play() {
-    _state.play();
-  }
-
-  // 悔棋只能悔棋三次
-  bool regretChess() {
-    return _state.regretChess();
-  }
-
-  // 认输10步之内不能认输
-  bool surrender() {
-    return _state.surrender();
-  }
-
-  setState(State state){
-    _state = state;
-  }
-
-  void reset() {
-    _state = StartState(this);
-  }
-}

+ 2 - 2
lib/factory/Theme.dart → lib/themes/app_theme.dart

@@ -1,5 +1,5 @@
 import 'dart:ui';
 
-abstract class Theme{
+abstract class AppTheme {
   Color getThemeColor();
-}
+}

+ 8 - 0
lib/themes/black_theme.dart

@@ -0,0 +1,8 @@
+import 'package:flutter/material.dart';
+
+import 'app_theme.dart';
+
+class BlackTheme extends AppTheme {
+  @override
+  Color getThemeColor() => Colors.black;
+}

+ 8 - 0
lib/themes/black_theme_factory.dart

@@ -0,0 +1,8 @@
+import 'app_theme.dart';
+import 'black_theme.dart';
+import 'theme_factory.dart';
+
+class BlackThemeFactory extends ThemeFactory {
+  @override
+  AppTheme getTheme() => BlackTheme();
+}

+ 8 - 0
lib/themes/blue_theme.dart

@@ -0,0 +1,8 @@
+import 'package:flutter/material.dart';
+
+import 'app_theme.dart';
+
+class BlueTheme extends AppTheme {
+  @override
+  Color getThemeColor() => Colors.blue;
+}

+ 8 - 0
lib/themes/blue_theme_factory.dart

@@ -0,0 +1,8 @@
+import 'app_theme.dart';
+import 'blue_theme.dart';
+import 'theme_factory.dart';
+
+class BlueThemeFactory extends ThemeFactory {
+  @override
+  AppTheme getTheme() => BlueTheme();
+}

+ 5 - 0
lib/themes/theme_factory.dart

@@ -0,0 +1,5 @@
+import 'app_theme.dart';
+
+abstract class ThemeFactory {
+  AppTheme getTheme();
+}

+ 0 - 77
lib/utils/TipsDialog.dart

@@ -1,77 +0,0 @@
-import 'package:flutter/material.dart';
-
-class TipsDialog {
-  static show(BuildContext context, String title, tips) async {
-    await showDialog<Null>(
-      context: context,
-      barrierDismissible: false,
-      builder: (BuildContext context) {
-        return AlertDialog(
-          title: Text(title),
-          content: SingleChildScrollView(
-            child: ListBody(
-              children: <Widget>[Text(tips)],
-            ),
-          ),
-          actions: <Widget>[
-            TextButton(
-              child: Text('确定'),
-              onPressed: () {
-                Navigator.of(context).pop();
-              },
-            ),
-          ],
-        );
-      },
-    );
-  }
-
-  static wait(BuildContext context, String title, tips) async {
-    await showDialog<Null>(
-      context: context,
-      barrierDismissible: false,
-      builder: (BuildContext context) {
-        return AlertDialog(
-          title: Text(title),
-          content: SingleChildScrollView(
-            child: ListBody(
-              children: <Widget>[Text(tips)],
-            ),
-          ),
-        );
-      },
-    );
-  }
-
-  static showByChoose(
-      BuildContext context, String title, tips, yes, no, Function f) async {
-    await showDialog<Null>(
-      context: context,
-      barrierDismissible: false,
-      builder: (BuildContext context) {
-        return AlertDialog(
-          title: Text(title),
-          content: SingleChildScrollView(
-            child: ListBody(
-              children: <Widget>[Text(tips)],
-            ),
-          ),
-          actions: <Widget>[
-            TextButton(
-              child: Text(no),
-              onPressed: () {
-                f(false);
-              },
-            ),
-            TextButton(
-              child: Text(yes),
-              onPressed: () {
-                f(true);
-              },
-            ),
-          ],
-        );
-      },
-    );
-  }
-}

+ 2 - 0
lib/utils/constants.dart

@@ -0,0 +1,2 @@
+/// 棋盘交叉点数量(标准五子棋 15×15)
+const int kBoardSize = 15;

+ 64 - 0
lib/utils/tips_dialog.dart

@@ -0,0 +1,64 @@
+import 'package:flutter/material.dart';
+import 'package:fluttertoast/fluttertoast.dart';
+
+class TipsDialog {
+  static Future<void> show(
+    BuildContext context,
+    String title,
+    String tips,
+  ) {
+    return showDialog<void>(
+      context: context,
+      barrierDismissible: false,
+      builder: (context) {
+        return AlertDialog(
+          title: Text(title),
+          content: SingleChildScrollView(
+            child: ListBody(children: [Text(tips)]),
+          ),
+          actions: [
+            TextButton(
+              child: const Text('确定'),
+              onPressed: () => Navigator.of(context).pop(),
+            ),
+          ],
+        );
+      },
+    );
+  }
+
+  static Future<bool?> showByChoose(
+    BuildContext context,
+    String title,
+    String tips,
+    String yes,
+    String no,
+  ) {
+    return showDialog<bool>(
+      context: context,
+      barrierDismissible: false,
+      builder: (context) {
+        return AlertDialog(
+          title: Text(title),
+          content: SingleChildScrollView(
+            child: ListBody(children: [Text(tips)]),
+          ),
+          actions: [
+            TextButton(
+              child: Text(no),
+              onPressed: () => Navigator.of(context).pop(false),
+            ),
+            TextButton(
+              child: Text(yes),
+              onPressed: () => Navigator.of(context).pop(true),
+            ),
+          ],
+        );
+      },
+    );
+  }
+
+  static void toast(String message) {
+    Fluttertoast.showToast(msg: message, gravity: ToastGravity.CENTER);
+  }
+}

+ 0 - 58
lib/viewModel/GameViewModel.dart

@@ -1,58 +0,0 @@
-import 'package:gobang/bridge/ChessShape.dart';
-import 'package:gobang/bridge/CircleShape.dart';
-import 'package:gobang/bridge/RectShape.dart';
-import 'package:gobang/flyweight/Chess.dart';
-import 'package:gobang/flyweight/ChessFlyweightFactory.dart';
-import 'package:gobang/state/State.dart';
-import 'package:gobang/state/UserContext.dart';
-
-class GameViewModel {
-  GameViewModel._();
-
-  static GameViewModel? _gameViewModel;
-
-  static getInstance() {
-    if (_gameViewModel == null) {
-      _gameViewModel = GameViewModel._();
-    }
-    return _gameViewModel;
-  }
-
-  UserContext _userContext = UserContext();
-
-  Chess play(bool current) {
-    _userContext.play();
-    Chess chess;
-
-    /// 设置棋子外观
-    ChessShape shape = RectShape();
-    if (current) {
-      shape = CircleShape();
-    }
-    chess = ChessFlyweightFactory.getInstance().getChess("white");
-    chess.chessShape = shape;
-    return chess;
-  }
-
-  bool undo() {
-    return _userContext.regretChess();
-  }
-
-  get state {
-    if (_userContext.state is StartState) {
-      return "热身阶段,不能悔棋,不能投降";
-    } else if (_userContext.state is MidState) {
-      return "入神阶段,可以悔棋且剩余${3 - _userContext.state.reg}次,可以投降";
-    } else if (_userContext.state is EndState) {
-      return "白热化阶段,悔棋次数已用完,但可以投降";
-    }
-  }
-
-  void reset() {
-    _userContext.reset();
-  }
-
-  bool surrender() {
-    return _userContext.surrender();
-  }
-}

+ 91 - 0
lib/widgets/board_painter.dart

@@ -0,0 +1,91 @@
+import 'package:flutter/cupertino.dart';
+import 'package:gobang/models/position.dart';
+import 'package:gobang/utils/constants.dart';
+
+/// 棋盘 + 棋子绘制;最后一手支持缩放入场与高亮光圈。
+class BoardPainter extends CustomPainter {
+  BoardPainter({
+    required this.pieces,
+    required this.version,
+    this.lastMoveScale = 1,
+    this.lastMovePulse = 0,
+  });
+
+  final List<Position> pieces;
+  final int version;
+  final double lastMoveScale;
+  final double lastMovePulse;
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    final cell = size.width / (kBoardSize - 1);
+    final bg = Paint()
+      ..isAntiAlias = true
+      ..style = PaintingStyle.fill
+      ..color = const Color(0x77cdb175);
+    canvas.drawRect(Offset.zero & size, bg);
+
+    final line = Paint()
+      ..style = PaintingStyle.stroke
+      ..color = CupertinoColors.systemGrey6
+      ..strokeWidth = 1;
+    for (var i = 0; i < kBoardSize; i++) {
+      final o = cell * i;
+      canvas.drawLine(Offset(0, o), Offset(size.width, o), line);
+      canvas.drawLine(Offset(o, 0), Offset(o, size.height), line);
+    }
+
+    final radius = cell / 2 - 2;
+    final fill = Paint()..style = PaintingStyle.fill;
+    final lastIndex = pieces.length - 1;
+
+    for (var i = 0; i < pieces.length; i++) {
+      final p = pieces[i];
+      final isLast = i == lastIndex;
+      final center = Offset(p.dx, p.dy);
+      final scale = isLast ? lastMoveScale.clamp(0.0, 1.2) : 1.0;
+      final r = radius * scale;
+
+      if (isLast && lastMovePulse > 0) {
+        final ring = Paint()
+          ..style = PaintingStyle.stroke
+          ..strokeWidth = 2.5
+          ..color = const Color(0xE0E53935).withValues(
+            alpha: 0.85 * lastMovePulse,
+          );
+        canvas.drawCircle(center, radius * (1.15 + 0.45 * lastMovePulse), ring);
+      }
+
+      fill.color = p.chess.color;
+      if (p.chessShape.shape == 1) {
+        canvas.drawCircle(center, r, fill);
+      } else {
+        canvas.drawRect(
+          Rect.fromCircle(center: center, radius: r),
+          fill,
+        );
+      }
+
+      // 最后一手中心小三角标记,方便辨认落点
+      if (isLast && scale > 0.85) {
+        final mark = Paint()
+          ..style = PaintingStyle.fill
+          ..color = const Color(0xFFE53935);
+        final s = radius * 0.28;
+        final path = Path()
+          ..moveTo(center.dx, center.dy - s)
+          ..lineTo(center.dx - s * 0.9, center.dy + s * 0.7)
+          ..lineTo(center.dx + s * 0.9, center.dy + s * 0.7)
+          ..close();
+        canvas.drawPath(path, mark);
+      }
+    }
+  }
+
+  @override
+  bool shouldRepaint(covariant BoardPainter oldDelegate) {
+    return oldDelegate.version != version ||
+        oldDelegate.lastMoveScale != lastMoveScale ||
+        oldDelegate.lastMovePulse != lastMovePulse;
+  }
+}

+ 90 - 0
lib/widgets/gobang_board.dart

@@ -0,0 +1,90 @@
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:gobang/controllers/game_controller.dart';
+import 'package:gobang/models/position.dart';
+import 'package:gobang/widgets/board_painter.dart';
+
+/// 可点击的五子棋棋盘;最后一手带落子动画与高亮标记。
+class GobangBoard extends StatefulWidget {
+  const GobangBoard({super.key, required this.size});
+
+  final double size;
+
+  @override
+  State<GobangBoard> createState() => _GobangBoardState();
+}
+
+class _GobangBoardState extends State<GobangBoard>
+    with SingleTickerProviderStateMixin {
+  late final AnimationController _controller;
+  late final Animation<double> _scale;
+  late final Animation<double> _pulse;
+  Worker? _paintWorker;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = AnimationController(
+      vsync: this,
+      duration: const Duration(milliseconds: 520),
+    );
+    _scale = CurvedAnimation(
+      parent: _controller,
+      curve: const Interval(0, 0.55, curve: Curves.easeOutBack),
+    );
+    _pulse = TweenSequence<double>([
+      TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 1),
+      TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.35), weight: 1),
+      TweenSequenceItem(tween: Tween(begin: 0.35, end: 0.8), weight: 1),
+      TweenSequenceItem(tween: Tween(begin: 0.8, end: 0.0), weight: 1),
+    ]).animate(
+      CurvedAnimation(
+        parent: _controller,
+        curve: const Interval(0.2, 1.0, curve: Curves.easeInOut),
+      ),
+    );
+
+    final c = Get.find<GameController>();
+    _paintWorker = ever<int>(c.paintVersion, (_) {
+      if (c.board.state.isEmpty) {
+        _controller.value = 0;
+        return;
+      }
+      _controller.forward(from: 0);
+    });
+  }
+
+  @override
+  void dispose() {
+    _paintWorker?.dispose();
+    _controller.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final c = Get.find<GameController>();
+    return Obx(() {
+      // 必须在 Obx 作用域内直接读取 .obs,不能放进 AnimatedBuilder
+      final version = c.paintVersion.value;
+      final pieces = List<Position>.unmodifiable(c.board.state);
+      return GestureDetector(
+        onTapDown: (d) => c.onBoardTap(d.localPosition, widget.size),
+        child: AnimatedBuilder(
+          animation: _controller,
+          builder: (context, _) {
+            return CustomPaint(
+              size: Size.square(widget.size),
+              painter: BoardPainter(
+                pieces: pieces,
+                version: version,
+                lastMoveScale: _scale.value,
+                lastMovePulse: _pulse.value,
+              ),
+            );
+          },
+        ),
+      );
+    });
+  }
+}

+ 1 - 0
linux/.gitignore

@@ -0,0 +1 @@
+flutter/ephemeral

+ 128 - 0
linux/CMakeLists.txt

@@ -0,0 +1,128 @@
+# Project-level configuration.
+cmake_minimum_required(VERSION 3.13)
+project(runner LANGUAGES CXX)
+
+# The name of the executable created for the application. Change this to change
+# the on-disk name of your application.
+set(BINARY_NAME "gobang")
+# The unique GTK application identifier for this application. See:
+# https://wiki.gnome.org/HowDoI/ChooseApplicationID
+set(APPLICATION_ID "com.example.gobang")
+
+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
+# versions of CMake.
+cmake_policy(SET CMP0063 NEW)
+
+# Load bundled libraries from the lib/ directory relative to the binary.
+set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
+
+# Root filesystem for cross-building.
+if(FLUTTER_TARGET_PLATFORM_SYSROOT)
+  set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
+  set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
+  set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
+  set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
+  set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
+  set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+endif()
+
+# Define build configuration options.
+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+  set(CMAKE_BUILD_TYPE "Debug" CACHE
+    STRING "Flutter build mode" FORCE)
+  set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
+    "Debug" "Profile" "Release")
+endif()
+
+# Compilation settings that should be applied to most targets.
+#
+# Be cautious about adding new options here, as plugins use this function by
+# default. In most cases, you should add new options to specific targets instead
+# of modifying this function.
+function(APPLY_STANDARD_SETTINGS TARGET)
+  target_compile_features(${TARGET} PUBLIC cxx_std_14)
+  target_compile_options(${TARGET} PRIVATE -Wall -Werror)
+  target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
+  target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
+endfunction()
+
+# Flutter library and tool build rules.
+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
+add_subdirectory(${FLUTTER_MANAGED_DIR})
+
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+
+# Application build; see runner/CMakeLists.txt.
+add_subdirectory("runner")
+
+# Run the Flutter tool portions of the build. This must not be removed.
+add_dependencies(${BINARY_NAME} flutter_assemble)
+
+# Only the install-generated bundle's copy of the executable will launch
+# correctly, since the resources must in the right relative locations. To avoid
+# people trying to run the unbundled copy, put it in a subdirectory instead of
+# the default top-level location.
+set_target_properties(${BINARY_NAME}
+  PROPERTIES
+  RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
+)
+
+
+# Generated plugin build rules, which manage building the plugins and adding
+# them to the application.
+include(flutter/generated_plugins.cmake)
+
+
+# === Installation ===
+# By default, "installing" just makes a relocatable bundle in the build
+# directory.
+set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
+  set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
+endif()
+
+# Start with a clean build bundle directory every time.
+install(CODE "
+  file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
+  " COMPONENT Runtime)
+
+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
+
+install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
+  COMPONENT Runtime)
+
+install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+  COMPONENT Runtime)
+
+install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+  COMPONENT Runtime)
+
+foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
+  install(FILES "${bundled_library}"
+    DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+    COMPONENT Runtime)
+endforeach(bundled_library)
+
+# Copy the native assets provided by the build.dart from all packages.
+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
+install(DIRECTORY "${NATIVE_ASSETS_DIR}"
+   DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+   COMPONENT Runtime)
+
+# Fully re-copy the assets directory on each build to avoid having stale files
+# from a previous install.
+set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
+install(CODE "
+  file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
+  " COMPONENT Runtime)
+install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
+  DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
+
+# Install the AOT library on non-Debug builds only.
+if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
+  install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+    COMPONENT Runtime)
+endif()

+ 88 - 0
linux/flutter/CMakeLists.txt

@@ -0,0 +1,88 @@
+# This file controls Flutter-level build steps. It should not be edited.
+cmake_minimum_required(VERSION 3.10)
+
+set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
+
+# Configuration provided via flutter tool.
+include(${EPHEMERAL_DIR}/generated_config.cmake)
+
+# TODO: Move the rest of this into files in ephemeral. See
+# https://github.com/flutter/flutter/issues/57146.
+
+# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
+# which isn't available in 3.10.
+function(list_prepend LIST_NAME PREFIX)
+    set(NEW_LIST "")
+    foreach(element ${${LIST_NAME}})
+        list(APPEND NEW_LIST "${PREFIX}${element}")
+    endforeach(element)
+    set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
+endfunction()
+
+# === Flutter Library ===
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
+pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
+
+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
+
+# Published to parent scope for install step.
+set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
+set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
+set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
+set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
+
+list(APPEND FLUTTER_LIBRARY_HEADERS
+  "fl_basic_message_channel.h"
+  "fl_binary_codec.h"
+  "fl_binary_messenger.h"
+  "fl_dart_project.h"
+  "fl_engine.h"
+  "fl_json_message_codec.h"
+  "fl_json_method_codec.h"
+  "fl_message_codec.h"
+  "fl_method_call.h"
+  "fl_method_channel.h"
+  "fl_method_codec.h"
+  "fl_method_response.h"
+  "fl_plugin_registrar.h"
+  "fl_plugin_registry.h"
+  "fl_standard_message_codec.h"
+  "fl_standard_method_codec.h"
+  "fl_string_codec.h"
+  "fl_value.h"
+  "fl_view.h"
+  "flutter_linux.h"
+)
+list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
+add_library(flutter INTERFACE)
+target_include_directories(flutter INTERFACE
+  "${EPHEMERAL_DIR}"
+)
+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
+target_link_libraries(flutter INTERFACE
+  PkgConfig::GTK
+  PkgConfig::GLIB
+  PkgConfig::GIO
+)
+add_dependencies(flutter flutter_assemble)
+
+# === Flutter tool backend ===
+# _phony_ is a non-existent file to force this command to run every time,
+# since currently there's no way to get a full input/output list from the
+# flutter tool.
+add_custom_command(
+  OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
+    ${CMAKE_CURRENT_BINARY_DIR}/_phony_
+  COMMAND ${CMAKE_COMMAND} -E env
+    ${FLUTTER_TOOL_ENVIRONMENT}
+    "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
+      ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
+  VERBATIM
+)
+add_custom_target(flutter_assemble DEPENDS
+  "${FLUTTER_LIBRARY}"
+  ${FLUTTER_LIBRARY_HEADERS}
+)

+ 11 - 0
linux/flutter/generated_plugin_registrant.cc

@@ -0,0 +1,11 @@
+//
+//  Generated file. Do not edit.
+//
+
+// clang-format off
+
+#include "generated_plugin_registrant.h"
+
+
+void fl_register_plugins(FlPluginRegistry* registry) {
+}

+ 15 - 0
linux/flutter/generated_plugin_registrant.h

@@ -0,0 +1,15 @@
+//
+//  Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GENERATED_PLUGIN_REGISTRANT_
+#define GENERATED_PLUGIN_REGISTRANT_
+
+#include <flutter_linux/flutter_linux.h>
+
+// Registers Flutter plugins.
+void fl_register_plugins(FlPluginRegistry* registry);
+
+#endif  // GENERATED_PLUGIN_REGISTRANT_

+ 23 - 0
linux/flutter/generated_plugins.cmake

@@ -0,0 +1,23 @@
+#
+# Generated file, do not edit.
+#
+
+list(APPEND FLUTTER_PLUGIN_LIST
+)
+
+list(APPEND FLUTTER_FFI_PLUGIN_LIST
+)
+
+set(PLUGIN_BUNDLED_LIBRARIES)
+
+foreach(plugin ${FLUTTER_PLUGIN_LIST})
+  add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
+  target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
+  list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
+  list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
+endforeach(plugin)
+
+foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
+  add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
+  list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
+endforeach(ffi_plugin)

+ 26 - 0
linux/runner/CMakeLists.txt

@@ -0,0 +1,26 @@
+cmake_minimum_required(VERSION 3.13)
+project(runner LANGUAGES CXX)
+
+# Define the application target. To change its name, change BINARY_NAME in the
+# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
+# work.
+#
+# Any new source files that you add to the application should be added here.
+add_executable(${BINARY_NAME}
+  "main.cc"
+  "my_application.cc"
+  "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
+)
+
+# Apply the standard set of build settings. This can be removed for applications
+# that need different build settings.
+apply_standard_settings(${BINARY_NAME})
+
+# Add preprocessor definitions for the application ID.
+add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
+
+# Add dependency libraries. Add any application-specific dependencies here.
+target_link_libraries(${BINARY_NAME} PRIVATE flutter)
+target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
+
+target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")

+ 6 - 0
linux/runner/main.cc

@@ -0,0 +1,6 @@
+#include "my_application.h"
+
+int main(int argc, char** argv) {
+  g_autoptr(MyApplication) app = my_application_new();
+  return g_application_run(G_APPLICATION(app), argc, argv);
+}

+ 130 - 0
linux/runner/my_application.cc

@@ -0,0 +1,130 @@
+#include "my_application.h"
+
+#include <flutter_linux/flutter_linux.h>
+#ifdef GDK_WINDOWING_X11
+#include <gdk/gdkx.h>
+#endif
+
+#include "flutter/generated_plugin_registrant.h"
+
+struct _MyApplication {
+  GtkApplication parent_instance;
+  char** dart_entrypoint_arguments;
+};
+
+G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
+
+// Implements GApplication::activate.
+static void my_application_activate(GApplication* application) {
+  MyApplication* self = MY_APPLICATION(application);
+  GtkWindow* window =
+      GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
+
+  // Use a header bar when running in GNOME as this is the common style used
+  // by applications and is the setup most users will be using (e.g. Ubuntu
+  // desktop).
+  // If running on X and not using GNOME then just use a traditional title bar
+  // in case the window manager does more exotic layout, e.g. tiling.
+  // If running on Wayland assume the header bar will work (may need changing
+  // if future cases occur).
+  gboolean use_header_bar = TRUE;
+#ifdef GDK_WINDOWING_X11
+  GdkScreen* screen = gtk_window_get_screen(window);
+  if (GDK_IS_X11_SCREEN(screen)) {
+    const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
+    if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
+      use_header_bar = FALSE;
+    }
+  }
+#endif
+  if (use_header_bar) {
+    GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
+    gtk_widget_show(GTK_WIDGET(header_bar));
+    gtk_header_bar_set_title(header_bar, "gobang");
+    gtk_header_bar_set_show_close_button(header_bar, TRUE);
+    gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
+  } else {
+    gtk_window_set_title(window, "gobang");
+  }
+
+  gtk_window_set_default_size(window, 1280, 720);
+  gtk_widget_show(GTK_WIDGET(window));
+
+  g_autoptr(FlDartProject) project = fl_dart_project_new();
+  fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
+
+  FlView* view = fl_view_new(project);
+  gtk_widget_show(GTK_WIDGET(view));
+  gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
+
+  fl_register_plugins(FL_PLUGIN_REGISTRY(view));
+
+  gtk_widget_grab_focus(GTK_WIDGET(view));
+}
+
+// Implements GApplication::local_command_line.
+static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {
+  MyApplication* self = MY_APPLICATION(application);
+  // Strip out the first argument as it is the binary name.
+  self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
+
+  g_autoptr(GError) error = nullptr;
+  if (!g_application_register(application, nullptr, &error)) {
+     g_warning("Failed to register: %s", error->message);
+     *exit_status = 1;
+     return TRUE;
+  }
+
+  g_application_activate(application);
+  *exit_status = 0;
+
+  return TRUE;
+}
+
+// Implements GApplication::startup.
+static void my_application_startup(GApplication* application) {
+  //MyApplication* self = MY_APPLICATION(object);
+
+  // Perform any actions required at application startup.
+
+  G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
+}
+
+// Implements GApplication::shutdown.
+static void my_application_shutdown(GApplication* application) {
+  //MyApplication* self = MY_APPLICATION(object);
+
+  // Perform any actions required at application shutdown.
+
+  G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
+}
+
+// Implements GObject::dispose.
+static void my_application_dispose(GObject* object) {
+  MyApplication* self = MY_APPLICATION(object);
+  g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
+  G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
+}
+
+static void my_application_class_init(MyApplicationClass* klass) {
+  G_APPLICATION_CLASS(klass)->activate = my_application_activate;
+  G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;
+  G_APPLICATION_CLASS(klass)->startup = my_application_startup;
+  G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
+  G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
+}
+
+static void my_application_init(MyApplication* self) {}
+
+MyApplication* my_application_new() {
+  // Set the program name to the application ID, which helps various systems
+  // like GTK and desktop environments map this running application to its
+  // corresponding .desktop file. This ensures better integration by allowing
+  // the application to be recognized beyond its binary name.
+  g_set_prgname(APPLICATION_ID);
+
+  return MY_APPLICATION(g_object_new(my_application_get_type(),
+                                     "application-id", APPLICATION_ID,
+                                     "flags", G_APPLICATION_NON_UNIQUE,
+                                     nullptr));
+}

+ 18 - 0
linux/runner/my_application.h

@@ -0,0 +1,18 @@
+#ifndef FLUTTER_MY_APPLICATION_H_
+#define FLUTTER_MY_APPLICATION_H_
+
+#include <gtk/gtk.h>
+
+G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
+                     GtkApplication)
+
+/**
+ * my_application_new:
+ *
+ * Creates a new Flutter-based application.
+ *
+ * Returns: a new #MyApplication.
+ */
+MyApplication* my_application_new();
+
+#endif  // FLUTTER_MY_APPLICATION_H_

+ 13 - 4
pubspec.yaml

@@ -1,18 +1,27 @@
 name: gobang
-description: A new Flutter project.
-publish_to: 'none' # Remove this line if you wish to publish to pub.dev
+description: 南瓜五子棋
+publish_to: 'none'
 version: 1.1.0+1
+
 environment:
   sdk: ^3.7.0
 
 dependencies:
   flutter:
     sdk: flutter
-  cupertino_icons: ^1.0.2
-  # get: ^
+  cupertino_icons: ^1.0.8
+  get: ^4.7.2
+  flutter_screenutil: ^5.9.3
+  fluttertoast: ^8.2.12
+  shared_preferences: ^2.5.3
+  package_info_plus: ^8.3.0
+  device_info_plus: ^11.4.0
+  connectivity_plus: ^6.1.4
 
 dev_dependencies:
   flutter_test:
     sdk: flutter
+  flutter_lints: ^5.0.0
+
 flutter:
   uses-material-design: true

+ 38 - 0
test/ai_test.dart

@@ -0,0 +1,38 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:gobang/services/ai_service.dart';
+import 'package:gobang/utils/constants.dart';
+
+void main() {
+  late AiService ai;
+
+  setUp(() {
+    ai = AiService.getInstance();
+    ai.init();
+  });
+
+  test('board size constant', () {
+    expect(kBoardSize, 15);
+    expect(ai.chessboard.length, 15);
+  });
+
+  test('isWin detects horizontal five', () {
+    for (var i = 0; i < 5; i++) {
+      ai.addChessman(i, 7, 1);
+    }
+    expect(ai.isWin(2, 7, 1), isTrue);
+    expect(ai.isWin(2, 7, -1), isFalse);
+  });
+
+  test('searchPosition returns empty cell', () {
+    ai.addChessman(7, 7, 1);
+    final pos = ai.searchPosition();
+    expect(pos.dx, isNonNegative);
+    expect(pos.dy, isNonNegative);
+    expect(ai.isLegal(pos.dx.toInt(), pos.dy.toInt()), isTrue);
+  });
+
+  test('illegal after occupied', () {
+    ai.addChessman(3, 3, 1);
+    expect(ai.isLegal(3, 3), isFalse);
+  });
+}

+ 4 - 24
test/widget_test.dart

@@ -1,30 +1,10 @@
-// This is a basic Flutter widget test.
-//
-// To perform an interaction with a widget in your test, use the WidgetTester
-// utility that Flutter provides. For example, you can send tap and scroll
-// gestures. You can also use WidgetTester to find child widgets in the widget
-// tree, read text, and verify that the values of widget properties are correct.
-
-import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
-
 import 'package:gobang/main.dart';
 
 void main() {
-  testWidgets('Counter increments smoke test', (WidgetTester tester) async {
-    // Build our app and trigger a frame.
-    await tester.pumpWidget(MyApp());
-
-    // Verify that our counter starts at 0.
-    expect(find.text('0'), findsOneWidget);
-    expect(find.text('1'), findsNothing);
-
-    // Tap the '+' icon and trigger a frame.
-    await tester.tap(find.byIcon(Icons.add));
-    await tester.pump();
-
-    // Verify that our counter has incremented.
-    expect(find.text('0'), findsNothing);
-    expect(find.text('1'), findsOneWidget);
+  testWidgets('app loads home page', (tester) async {
+    await tester.pumpWidget(const MyApp());
+    await tester.pumpAndSettle();
+    expect(find.text('南瓜五子棋'), findsOneWidget);
   });
 }