Browse Source

优化项目结构

liuyuqi-cnb 1 hour ago
parent
commit
68e7418519

+ 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/           # 常量、弹窗工具
+```

+ 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 - 13
lib/flyweight/position.dart

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

+ 0 - 331
lib/home_page.dart

@@ -1,331 +0,0 @@
-import 'dart:math';
-
-import 'package:flutter/cupertino.dart';
-import 'package:flutter/material.dart';
-import 'package:gobang/ai/ai.dart';
-import 'package:gobang/bridge/circle_shape.dart';
-import 'package:gobang/constants.dart';
-import 'package:gobang/factory/black_theme_factory.dart';
-import 'package:gobang/factory/blue_theme_factory.dart';
-import 'package:gobang/factory/theme_factory.dart';
-import 'package:gobang/flyweight/chess.dart';
-import 'package:gobang/flyweight/position.dart';
-import 'package:gobang/memorandum/checkerboard.dart';
-import 'package:gobang/utils/tips_dialog.dart';
-import 'package:gobang/viewModel/game_view_model.dart';
-
-class HomePage extends StatefulWidget {
-  const HomePage({super.key});
-
-  @override
-  State<HomePage> createState() => HomePageState();
-}
-
-class HomePageState extends State<HomePage> {
-  late ThemeFactory _themeFactory;
-  final GameViewModel _viewModel = GameViewModel.getInstance();
-  final Checkerboard _board = Checkerboard.getInstance();
-  final Ai _ai = Ai.getInstance();
-
-  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 _currentLight = _lightOn;
-  Icon _currentShape = _circleIcon;
-  bool _useCircle = true;
-  bool _gameOver = false;
-  int _paintVersion = 0;
-
-  double get _boardWidth => MediaQuery.of(context).size.width * 0.8;
-
-  double get _cellSize => _boardWidth / (kBoardSize - 1);
-
-  @override
-  void initState() {
-    super.initState();
-    _themeFactory = BlueThemeFactory();
-    _ai.init();
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    final themeColor = _themeFactory.getTheme().getThemeColor();
-
-    return Scaffold(
-      appBar: AppBar(
-        elevation: 0,
-        backgroundColor: themeColor,
-        title: const Text('南瓜五子棋'),
-        actions: [
-          IconButton(
-            onPressed: _toggleTheme,
-            icon: _currentLight,
-          ),
-          IconButton(
-            onPressed: _toggleShape,
-            icon: _currentShape,
-          ),
-        ],
-      ),
-      body: Container(
-        decoration: BoxDecoration(
-          gradient: LinearGradient(
-            colors: [themeColor, Colors.white],
-            begin: Alignment.topCenter,
-            end: Alignment.bottomCenter,
-          ),
-        ),
-        child: Center(
-          child: Column(
-            mainAxisAlignment: MainAxisAlignment.center,
-            children: [
-              Padding(
-                padding: const EdgeInsets.only(top: 14, bottom: 30),
-                child: Text(
-                  _viewModel.state,
-                  style: const TextStyle(color: Colors.white),
-                ),
-              ),
-              GestureDetector(
-                onTapDown: _onBoardTap,
-                child: SizedBox(
-                  width: _boardWidth,
-                  height: _boardWidth,
-                  child: CustomPaint(
-                    size: Size.square(_boardWidth),
-                    painter: BoardPainter(
-                      pieces: List.unmodifiable(_board.state),
-                      version: _paintVersion,
-                    ),
-                  ),
-                ),
-              ),
-              Padding(
-                padding: const EdgeInsets.only(top: 16),
-                child: Row(
-                  mainAxisAlignment: MainAxisAlignment.center,
-                  children: [
-                    IconButton(
-                      onPressed: _onUndo,
-                      icon: const Icon(Icons.undo),
-                    ),
-                    IconButton(
-                      onPressed: _onSurrender,
-                      icon: const Icon(
-                        Icons.sports_handball,
-                        color: Colors.deepPurple,
-                      ),
-                    ),
-                    IconButton(
-                      onPressed: _onRestart,
-                      icon: const Icon(
-                        Icons.restart_alt,
-                        color: Colors.indigo,
-                      ),
-                    ),
-                  ],
-                ),
-              ),
-            ],
-          ),
-        ),
-      ),
-    );
-  }
-
-  void _toggleTheme() {
-    setState(() {
-      if (_themeFactory is BlackThemeFactory) {
-        _currentLight = _lightOn;
-        _themeFactory = BlueThemeFactory();
-      } else {
-        _currentLight = _lightOff;
-        _themeFactory = BlackThemeFactory();
-      }
-    });
-  }
-
-  void _toggleShape() {
-    setState(() {
-      _useCircle = !_useCircle;
-      _currentShape = _useCircle ? _circleIcon : _rectIcon;
-    });
-  }
-
-  void _onBoardTap(TapDownDetails details) {
-    if (_gameOver) return;
-
-    final snap = _snapToGrid(details.localPosition);
-    if (snap == null) return;
-
-    final (gridX, gridY, px, py) = snap;
-    if (!_ai.isLegal(gridX, gridY)) return;
-
-    final chess = _viewModel.play();
-    final shape = _viewModel.shapeFor(_useCircle);
-    final position = Position(px, py, chess, chessShape: shape);
-
-    _board.add(position);
-    _ai.addChessman(gridX, gridY, 1);
-    _paintVersion++;
-
-    if (_ai.isWin(gridX, gridY, 1)) {
-      setState(() => _gameOver = true);
-      TipsDialog.show(context, '恭喜', '您打败了决策树算法');
-      return;
-    }
-
-    _playAi();
-    setState(() {});
-  }
-
-  /// 将触摸点吸附到最近交叉点;过远则返回 null
-  (int, int, double, double)? _snapToGrid(Offset local) {
-    final cell = _cellSize;
-    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() {
-    final raw = _ai.searchPosition();
-    if (raw.dx < 0 || raw.dy < 0) return;
-
-    final gridX = raw.dx.toInt();
-    final gridY = raw.dy.toInt();
-    final cell = _cellSize;
-    final position = Position(
-      gridX * cell,
-      gridY * cell,
-      raw.chess,
-      chessShape: CircleShape(),
-    );
-
-    _board.add(position);
-    _ai.addChessman(gridX, gridY, -1);
-    _paintVersion++;
-
-    if (_ai.isWin(gridX, gridY, -1)) {
-      _gameOver = true;
-      TipsDialog.show(context, '很遗憾', '决策树算法打败了您');
-    }
-  }
-
-  void _rebuildAiFromBoard() {
-    _ai.init();
-    final cell = _cellSize;
-    for (final po in _board.state) {
-      final gx = (po.dx / cell).round();
-      final gy = (po.dy / cell).round();
-      _ai.addChessman(gx, gy, po.chess is WhiteChess ? 1 : -1);
-    }
-  }
-
-  void _onUndo() {
-    if (_gameOver) {
-      TipsDialog.show(context, '提示', '对局已结束,请重新开局');
-      return;
-    }
-    if (!_viewModel.undo()) {
-      TipsDialog.show(context, '提示', '现阶段不能悔棋');
-      return;
-    }
-    _board.undo();
-    _rebuildAiFromBoard();
-    setState(() => _paintVersion++);
-  }
-
-  Future<void> _onSurrender() async {
-    if (!_viewModel.surrender()) {
-      await TipsDialog.show(context, '提示', '现阶段不能投降');
-      return;
-    }
-    final ok = await TipsDialog.showByChoose(
-      context,
-      '提示',
-      '是否要投降并重新开局?',
-      '是',
-      '否',
-    );
-    if (ok == true && mounted) {
-      _resetGame();
-    }
-  }
-
-  Future<void> _onRestart() async {
-    final ok = await TipsDialog.showByChoose(
-      context,
-      '提示',
-      '是否重新开局?',
-      '是',
-      '否',
-    );
-    if (ok == true && mounted) {
-      _resetGame();
-    }
-  }
-
-  void _resetGame() {
-    setState(() {
-      _board.clean();
-      _viewModel.reset();
-      _ai.init();
-      _gameOver = false;
-      _paintVersion++;
-    });
-  }
-}
-
-/// 棋盘 + 棋子同层绘制,避免双重 CustomPaint 与 paint 内副作用
-class BoardPainter extends CustomPainter {
-  BoardPainter({required this.pieces, required this.version});
-
-  final List<Position> pieces;
-  final int version;
-
-  @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;
-    for (final p in pieces) {
-      fill.color = p.chess.color;
-      final center = Offset(p.dx, p.dy);
-      if (p.chessShape.shape == 1) {
-        canvas.drawCircle(center, radius, fill);
-      } else {
-        canvas.drawRect(
-          Rect.fromCircle(center: center, radius: radius),
-          fill,
-        );
-      }
-    }
-  }
-
-  @override
-  bool shouldRepaint(covariant BoardPainter oldDelegate) {
-    return oldDelegate.version != version;
-  }
-}

+ 19 - 9
lib/main.dart

@@ -1,6 +1,8 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
-import 'package:gobang/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();
@@ -20,14 +22,22 @@ class MyApp extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return MaterialApp(
-      debugShowCheckedModeBanner: false,
-      title: '南瓜五子棋',
-      theme: ThemeData(
-        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
-        useMaterial3: true,
-      ),
-      home: const 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 - 0
lib/memorandum/care_taker.dart → lib/models/care_taker.dart


+ 3 - 3
lib/memorandum/checkerboard.dart → lib/models/checkerboard.dart

@@ -1,6 +1,6 @@
-import 'package:gobang/flyweight/position.dart';
-import 'package:gobang/memorandum/care_taker.dart';
-import 'package:gobang/memorandum/memo.dart';
+import 'package:gobang/models/position.dart';
+import 'package:gobang/models/care_taker.dart';
+import 'package:gobang/models/memo.dart';
 
 class Checkerboard {
   Checkerboard._();

+ 2 - 2
lib/flyweight/chess.dart → lib/models/chess.dart

@@ -1,6 +1,6 @@
 import 'package:flutter/material.dart';
-import 'package:gobang/bridge/chess_shape.dart';
-import 'package:gobang/bridge/circle_shape.dart';
+import 'package:gobang/models/chess_shape.dart';
+import 'package:gobang/models/circle_shape.dart';
 
 /// 棋子抽象类(桥接:颜色与外观分离)
 /// 颜色为享元内部状态;实际绘制以外层 Position.chessShape 为准。

+ 0 - 0
lib/bridge/chess_shape.dart → lib/models/chess_shape.dart


+ 0 - 0
lib/bridge/circle_shape.dart → lib/models/circle_shape.dart


+ 1 - 1
lib/state/game_state.dart → lib/models/game_state.dart

@@ -1,4 +1,4 @@
-import 'package:gobang/state/user_context.dart';
+import 'package:gobang/models/user_context.dart';
 
 abstract class GameState {
   int step = 0;

+ 1 - 1
lib/memorandum/memo.dart → lib/models/memo.dart

@@ -1,4 +1,4 @@
-import 'package:gobang/flyweight/position.dart';
+import 'package:gobang/models/position.dart';
 
 class Memo {
   final List<Position> state;

+ 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;
+}

+ 0 - 0
lib/bridge/rect_shape.dart → lib/models/rect_shape.dart


+ 1 - 1
lib/state/user_context.dart → lib/models/user_context.dart

@@ -1,4 +1,4 @@
-import 'package:gobang/state/game_state.dart';
+import 'package:gobang/models/game_state.dart';
 
 class UserContext {
   late GameState _state;

+ 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(),
+                ],
+              ),
+            ),
+          ),
+        ),
+      );
+    });
+  }
+}

+ 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';
+}

+ 7 - 7
lib/ai/ai.dart → lib/services/ai_service.dart

@@ -1,11 +1,11 @@
-import 'package:gobang/constants.dart';
-import 'package:gobang/flyweight/chess_flyweight_factory.dart';
-import 'package:gobang/flyweight/position.dart';
+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 Ai {
-  Ai._() {
+class AiService {
+  AiService._() {
     chessboard = List.generate(
       kBoardSize,
       (_) => List.filled(kBoardSize, 0),
@@ -16,9 +16,9 @@ class Ai {
     );
   }
 
-  static final Ai instance = Ai._();
+  static final AiService instance = AiService._();
 
-  static Ai getInstance() => instance;
+  static AiService getInstance() => instance;
 
   /// 先手:1 人类,-1 机器
   int first = 1;

+ 1 - 1
lib/flyweight/chess_flyweight_factory.dart → lib/services/chess_flyweight_factory.dart

@@ -1,4 +1,4 @@
-import 'package:gobang/flyweight/chess.dart';
+import 'package:gobang/models/chess.dart';
 
 /// 棋子享元工厂(单例)
 class ChessFlyweightFactory {

+ 0 - 0
lib/factory/app_theme.dart → lib/themes/app_theme.dart


+ 0 - 0
lib/factory/black_theme.dart → lib/themes/black_theme.dart


+ 0 - 0
lib/factory/black_theme_factory.dart → lib/themes/black_theme_factory.dart


+ 0 - 0
lib/factory/blue_theme.dart → lib/themes/blue_theme.dart


+ 0 - 0
lib/factory/blue_theme_factory.dart → lib/themes/blue_theme_factory.dart


+ 0 - 0
lib/factory/theme_factory.dart → lib/themes/theme_factory.dart


+ 0 - 0
lib/constants.dart → lib/utils/constants.dart


+ 5 - 0
lib/utils/tips_dialog.dart

@@ -1,4 +1,5 @@
 import 'package:flutter/material.dart';
+import 'package:fluttertoast/fluttertoast.dart';
 
 class TipsDialog {
   static Future<void> show(
@@ -56,4 +57,8 @@ class TipsDialog {
       },
     );
   }
+
+  static void toast(String message) {
+    Fluttertoast.showToast(msg: message, gravity: ToastGravity.CENTER);
+  }
 }

+ 0 - 45
lib/viewModel/game_view_model.dart

@@ -1,45 +0,0 @@
-import 'package:gobang/bridge/chess_shape.dart';
-import 'package:gobang/bridge/circle_shape.dart';
-import 'package:gobang/bridge/rect_shape.dart';
-import 'package:gobang/flyweight/chess.dart';
-import 'package:gobang/flyweight/chess_flyweight_factory.dart';
-import 'package:gobang/state/game_state.dart';
-import 'package:gobang/state/user_context.dart';
-
-class GameViewModel {
-  GameViewModel._();
-
-  static final GameViewModel instance = GameViewModel._();
-
-  static GameViewModel getInstance() => instance;
-
-  final UserContext _userContext = UserContext();
-
-  Chess play() {
-    _userContext.play();
-    return ChessFlyweightFactory.getInstance().getChess('white');
-  }
-
-  ChessShape shapeFor(bool useCircle) =>
-      useCircle ? CircleShape() : RectShape();
-
-  bool undo() => _userContext.regretChess();
-
-  String get state {
-    final s = _userContext.state;
-    if (s is StartState) {
-      return '热身阶段,不能悔棋,不能投降';
-    }
-    if (s is MidState) {
-      return '入神阶段,可以悔棋且剩余${3 - s.reg}次,可以投降';
-    }
-    if (s is EndState) {
-      return '白热化阶段,悔棋次数已用完,但可以投降';
-    }
-    return '';
-  }
-
-  void reset() => _userContext.reset();
-
-  bool surrender() => _userContext.surrender();
-}

+ 51 - 0
lib/widgets/board_painter.dart

@@ -0,0 +1,51 @@
+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});
+
+  final List<Position> pieces;
+  final int version;
+
+  @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;
+    for (final p in pieces) {
+      fill.color = p.chess.color;
+      final center = Offset(p.dx, p.dy);
+      if (p.chessShape.shape == 1) {
+        canvas.drawCircle(center, radius, fill);
+      } else {
+        canvas.drawRect(
+          Rect.fromCircle(center: center, radius: radius),
+          fill,
+        );
+      }
+    }
+  }
+
+  @override
+  bool shouldRepaint(covariant BoardPainter oldDelegate) {
+    return oldDelegate.version != version;
+  }
+}

+ 28 - 0
lib/widgets/gobang_board.dart

@@ -0,0 +1,28 @@
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:gobang/controllers/game_controller.dart';
+import 'package:gobang/widgets/board_painter.dart';
+
+/// 可点击的五子棋棋盘
+class GobangBoard extends StatelessWidget {
+  const GobangBoard({super.key, required this.size});
+
+  final double size;
+
+  @override
+  Widget build(BuildContext context) {
+    final c = Get.find<GameController>();
+    return Obx(
+      () => GestureDetector(
+        onTapDown: (d) => c.onBoardTap(d.localPosition, size),
+        child: CustomPaint(
+          size: Size.square(size),
+          painter: BoardPainter(
+            pieces: List.unmodifiable(c.board.state),
+            version: c.paintVersion.value,
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 8 - 1
pubspec.yaml

@@ -1,5 +1,5 @@
 name: gobang
-description: 南瓜五子棋 — Flutter 五子棋
+description: 南瓜五子棋
 publish_to: 'none'
 version: 1.1.0+1
 
@@ -10,6 +10,13 @@ dependencies:
   flutter:
     sdk: flutter
   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:

+ 4 - 4
test/ai_test.dart

@@ -1,12 +1,12 @@
 import 'package:flutter_test/flutter_test.dart';
-import 'package:gobang/ai/ai.dart';
-import 'package:gobang/constants.dart';
+import 'package:gobang/services/ai_service.dart';
+import 'package:gobang/utils/constants.dart';
 
 void main() {
-  late Ai ai;
+  late AiService ai;
 
   setUp(() {
-    ai = Ai.getInstance();
+    ai = AiService.getInstance();
     ai.init();
   });
 

+ 1 - 0
test/widget_test.dart

@@ -4,6 +4,7 @@ import 'package:gobang/main.dart';
 void main() {
   testWidgets('app loads home page', (tester) async {
     await tester.pumpWidget(const MyApp());
+    await tester.pumpAndSettle();
     expect(find.text('南瓜五子棋'), findsOneWidget);
   });
 }