jianboy 1 year ago
parent
commit
ae967cd9fb
40 changed files with 4489 additions and 97 deletions
  1. 115 0
      lib/components/action_dialog.dart
  2. 25 0
      lib/components/board.dart
  3. 518 0
      lib/components/chess.dart
  4. 158 0
      lib/components/chess_box.dart
  5. 88 0
      lib/components/chess_pieces.dart
  6. 154 0
      lib/components/chess_single_box.dart
  7. 262 0
      lib/components/edit_fen.dart
  8. 113 0
      lib/components/game_bottom_bar.dart
  9. 40 0
      lib/components/mark_component.dart
  10. 107 0
      lib/components/piece.dart
  11. 152 0
      lib/components/play.dart
  12. 63 0
      lib/components/play_bot.dart
  13. 205 0
      lib/components/play_player.dart
  14. 147 0
      lib/components/play_single_player.dart
  15. 106 0
      lib/components/play_step.dart
  16. 26 0
      lib/components/point_component.dart
  17. 35 0
      lib/driver/driver_online.dart
  18. 435 0
      lib/driver/driver_robot.dart
  19. 46 0
      lib/driver/driver_user.dart
  20. 77 0
      lib/driver/player_driver.dart
  21. 57 0
      lib/foundation/customer_notifier.dart
  22. 376 0
      lib/game_board.dart
  23. 18 0
      lib/global.dart
  24. 17 0
      lib/html/ffi.dart
  25. 55 0
      lib/html/win32.dart
  26. 85 97
      lib/main.dart
  27. 116 0
      lib/models/chess_skin.dart
  28. 161 0
      lib/models/engine.dart
  29. 6 0
      lib/models/engine_level.dart
  30. 19 0
      lib/models/engine_type.dart
  31. 73 0
      lib/models/game_event.dart
  32. 68 0
      lib/models/game_setting.dart
  33. 11 0
      lib/models/play_mode.dart
  34. 56 0
      lib/models/player.dart
  35. 51 0
      lib/models/sound.dart
  36. 160 0
      lib/setting.dart
  37. 6 0
      lib/utils/core.dart
  38. 87 0
      lib/widgets/game_wrapper.dart
  39. 47 0
      lib/widgets/list_item.dart
  40. 148 0
      lib/widgets/tab_card.dart

+ 115 - 0
lib/components/action_dialog.dart

@@ -0,0 +1,115 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+
+enum ActionType {
+  eat,
+  checkMate,
+  kill,
+}
+
+class ActionDialog extends StatefulWidget {
+  final ActionType type;
+  final double? delay;
+  final void Function()? onHide;
+  const ActionDialog(this.type, {Key? key, this.delay, this.onHide})
+      : super(key: key);
+
+  @override
+  State<ActionDialog> createState() => _ActionDialogState();
+}
+
+String assets(ActionType type) {
+  switch (type) {
+    case ActionType.eat:
+      return 'assets/images/action_eat.png';
+    case ActionType.checkMate:
+      return 'assets/images/action_checkmate.png';
+    case ActionType.kill:
+      return 'assets/images/action_kill.png';
+  }
+}
+
+class _ActionDialogState extends State<ActionDialog>
+    with SingleTickerProviderStateMixin {
+  late AnimationController imageAnimation = AnimationController(
+    duration: const Duration(milliseconds: 500),
+    vsync: this,
+  );
+  late Completer<bool> showAction;
+
+  @override
+  void initState() {
+    super.initState();
+    showAction = Completer();
+    imageAnimation.addListener(_onAnimate);
+    imageAnimation.animateTo(1);
+    if (widget.delay != null) {
+      Future.delayed(Duration(milliseconds: (widget.delay! * 1000).toInt()))
+          .then((value) => imageAnimation.animateTo(0));
+    }
+  }
+
+  @override
+  void dispose() {
+    imageAnimation.stop(canceled: true);
+    imageAnimation.removeListener(_onAnimate);
+    imageAnimation.value = 0;
+    if (!showAction.isCompleted) {
+      showAction.complete(false);
+    }
+    super.dispose();
+  }
+
+  void _onAnimate() {
+    if (showAction.isCompleted) {
+      if (imageAnimation.value < 1) {
+        if (imageAnimation.value == 0) {
+          widget.onHide?.call();
+        }
+        setState(() {});
+      }
+    } else {
+      if (imageAnimation.value == 1) {
+        showAction.complete(true);
+      }
+      setState(() {});
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return IgnorePointer(
+      child: SizedBox(
+        width: 220,
+        height: 220,
+        child: Opacity(
+          opacity: showAction.isCompleted ? imageAnimation.value : 1,
+          child: Stack(
+            children: [
+              Center(
+                child: FutureBuilder<bool>(
+                  future: showAction.future,
+                  initialData: false,
+                  builder:
+                      (BuildContext context, AsyncSnapshot<bool> snapshot) {
+                    if (snapshot.hasData && snapshot.data!) {
+                      return Image.asset(assets(widget.type));
+                    }
+                    return const SizedBox();
+                  },
+                ),
+              ),
+              Center(
+                child: Opacity(
+                  opacity: showAction.isCompleted ? 1 : imageAnimation.value,
+                  child: Image.asset('assets/images/action_background.png'),
+                ),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 25 - 0
lib/components/board.dart

@@ -0,0 +1,25 @@
+import 'package:flutter/material.dart';
+
+import '../models/game_manager.dart';
+import '../widgets/game_wrapper.dart';
+
+/// 棋盘
+class Board extends StatefulWidget {
+  const Board({Key? key}) : super(key: key);
+
+  @override
+  State<Board> createState() => BoardState();
+}
+
+class BoardState extends State<Board> {
+  @override
+  Widget build(BuildContext context) {
+    GameManager gamer =
+        context.findAncestorStateOfType<GameWrapperState>()!.gamer;
+    return SizedBox(
+      width: gamer.skin.width,
+      height: gamer.skin.height,
+      child: Image.asset(gamer.skin.boardImage),
+    );
+  }
+}

+ 518 - 0
lib/components/chess.dart

@@ -0,0 +1,518 @@
+import 'dart:async';
+
+import 'package:cchess/cchess.dart';
+import 'package:shirne_dialog/shirne_dialog.dart';
+import 'package:flutter/material.dart';
+
+import 'action_dialog.dart';
+import 'board.dart';
+import 'piece.dart';
+import 'chess_pieces.dart';
+import 'mark_component.dart';
+import 'point_component.dart';
+import '../global.dart';
+import '../models/game_event.dart';
+import '../models/sound.dart';
+import '../models/game_manager.dart';
+import '../driver/player_driver.dart';
+import '../widgets/game_wrapper.dart';
+
+class Chess extends StatefulWidget {
+  final String skin;
+
+  const Chess({Key? key, this.skin = 'woods'}) : super(key: key);
+
+  @override
+  State<Chess> createState() => ChessState();
+}
+
+class ChessState extends State<Chess> {
+  // 当前激活的子
+  ChessItem? activeItem;
+  double skewStepper = 0;
+
+  // 被吃的子
+  ChessItem? dieFlash;
+
+  // 刚走过的位置
+  String lastPosition = 'a0';
+
+  // 可落点,包括吃子点
+  List<String> movePoints = [];
+  bool isInit = false;
+  late GameManager gamer;
+  bool isLoading = true;
+
+  // 棋局初始化时所有的子力
+  List<ChessItem> items = [];
+
+  @override
+  void initState() {
+    super.initState();
+    initGamer();
+  }
+
+  void initGamer() {
+    if (isInit) return;
+    isInit = true;
+    GameWrapperState? gameWrapper =
+        context.findAncestorStateOfType<GameWrapperState>();
+    if (gameWrapper == null) return;
+    gamer = gameWrapper.gamer;
+
+    gamer.on<GameLoadEvent>(reloadGame);
+    gamer.on<GameResultEvent>(onResult);
+    gamer.on<GameMoveEvent>(onMove);
+    gamer.on<GameFlipEvent>(onFlip);
+
+    reloadGame(GameLoadEvent(0));
+  }
+
+  @override
+  void dispose() {
+    gamer.off<GameLoadEvent>(reloadGame);
+    gamer.off<GameResultEvent>(onResult);
+    gamer.off<GameMoveEvent>(onMove);
+    super.dispose();
+  }
+
+  void onFlip(GameEvent event) {
+    setState(() {});
+  }
+
+  void onResult(GameEvent event) {
+    if (event.data == null || event.data!.isEmpty) {
+      return;
+    }
+    List<String> parts = event.data.split(' ');
+    String? resultText =
+        (parts.length > 1 && parts[1].isNotEmpty) ? parts[1] : null;
+    switch (parts[0]) {
+      case 'checkMate':
+        //toast(context.l10n.check);
+        showAction(ActionType.checkMate);
+        break;
+      case 'eat':
+        showAction(ActionType.eat);
+        break;
+      case ChessManual.resultFstLoose:
+        alertResult(resultText ?? context.l10n.redLoose);
+        break;
+      case ChessManual.resultFstWin:
+        alertResult(resultText ?? context.l10n.redWin);
+        break;
+      case ChessManual.resultFstDraw:
+        alertResult(resultText ?? context.l10n.redDraw);
+        break;
+      default:
+        break;
+    }
+  }
+
+  void reloadGame(GameEvent event) {
+    if (event.data < -1) {
+      return;
+    }
+    if (event.data < 0) {
+      if (!isLoading) {
+        setState(() {
+          isLoading = true;
+        });
+      }
+      return;
+    }
+    setState(() {
+      items = gamer.manual.getChessItems();
+      isLoading = false;
+      lastPosition = '';
+      activeItem = null;
+    });
+    String position = gamer.lastMove;
+    if (position.isNotEmpty) {
+      logger.info('last move $position');
+      Future.delayed(const Duration(milliseconds: 32)).then((value) {
+        setState(() {
+          lastPosition = position.substring(0, 2);
+          ChessPos activePos = ChessPos.fromCode(position.substring(2, 4));
+          activeItem = items.firstWhere(
+            (item) =>
+                !item.isBlank &&
+                item.position == ChessPos.fromCode(lastPosition),
+            orElse: () => ChessItem('0'),
+          );
+          activeItem!.position = activePos;
+        });
+      });
+    }
+  }
+
+  void addStep(ChessPos chess, ChessPos next) {
+    gamer.addStep(chess, next);
+  }
+
+  Future<void> fetchMovePoints() async {
+    setState(() {
+      movePoints = gamer.rule.movePoints(activeItem!.position);
+      // print('move points: $movePoints');
+    });
+  }
+
+  /// 从外部过来的指令
+  void onMove(GameEvent event) {
+    String move = event.data!;
+    logger.info('onmove $move');
+    if (move.isEmpty) return;
+    if (move == PlayerDriver.rstGiveUp) return;
+    if (move.contains(PlayerDriver.rstRqstDraw)) {
+      toast(
+        context.l10n.requestDraw,
+        SnackBarAction(
+          label: context.l10n.agreeToDraw,
+          onPressed: () {
+            gamer.player.completeMove(PlayerDriver.rstDraw);
+          },
+        ),
+        5,
+      );
+      move = move.replaceAll(PlayerDriver.rstRqstDraw, '').trim();
+      if (move.isEmpty) {
+        return;
+      }
+    }
+    if (move == PlayerDriver.rstRqstRetract) {
+      confirm(
+        context.l10n.requestRetract,
+        context.l10n.agreeRetract,
+        context.l10n.disagreeRetract,
+      ).then((bool? isAgree) {
+        gamer.player
+            .completeMove(isAgree == true ? PlayerDriver.rstRetract : '');
+      });
+      return;
+    }
+
+    ChessPos fromPos = ChessPos.fromCode(move.substring(0, 2));
+    ChessPos toPosition = ChessPos.fromCode(move.substring(2, 4));
+    activeItem = items.firstWhere(
+      (item) => !item.isBlank && item.position == fromPos,
+      orElse: () => ChessItem('0'),
+    );
+    ChessItem newActive = items.firstWhere(
+      (item) => !item.isBlank && item.position == toPosition,
+      orElse: () => ChessItem('0'),
+    );
+    setState(() {
+      if (activeItem != null && !activeItem!.isBlank) {
+        logger.info('$activeItem => $move');
+
+        activeItem!.position = ChessPos.fromCode(move.substring(2, 4));
+        lastPosition = fromPos.toCode();
+
+        if (!newActive.isBlank) {
+          logger.info('eat $newActive');
+
+          //showAction(ActionType.eat);
+          // 被吃的子的快照
+          dieFlash = ChessItem(newActive.code, position: toPosition);
+          newActive.isDie = true;
+          Future.delayed(const Duration(milliseconds: 250), () {
+            setState(() {
+              dieFlash = null;
+            });
+          });
+        }
+      } else {
+        logger.info('Remote move error $move');
+      }
+    });
+  }
+
+  void animateMove(ChessPos nextPosition) {
+    logger.info('$activeItem => $nextPosition');
+    setState(() {
+      activeItem!.position = nextPosition.copy();
+    });
+  }
+
+  void clearActive() {
+    setState(() {
+      activeItem = null;
+      lastPosition = '';
+    });
+  }
+
+  /// 检测用户的输入位置是否有效
+  Future<bool> checkCanMove(
+    String activePos,
+    ChessPos toPosition, [
+    ChessItem? toItem,
+  ]) async {
+    if (!movePoints.contains(toPosition.toCode())) {
+      if (toItem != null) {
+        toast('can\'t eat ${toItem.code} at $toPosition');
+      } else {
+        toast('can\'t move to $toPosition');
+      }
+      return false;
+    }
+    String move = activePos + toPosition.toCode();
+    ChessRule rule = ChessRule(gamer.fen.copy());
+    rule.fen.move(move);
+    if (rule.isKingMeet(gamer.curHand)) {
+      toast(context.l10n.cantSendCheck);
+      return false;
+    }
+
+    // 区分应将和送将
+    if (rule.isCheck(gamer.curHand)) {
+      if (gamer.isCheckMate) {
+        toast(context.l10n.plsParryCheck);
+      } else {
+        toast(context.l10n.cantSendCheck);
+      }
+      return false;
+    }
+    return true;
+  }
+
+  /// 用户点击棋盘位置的反馈 包括选中子,走子,吃子,无反馈
+  bool onPointer(ChessPos toPosition) {
+    ChessItem newActive = items.firstWhere(
+      (item) => !item.isBlank && item.position == toPosition,
+      orElse: () => ChessItem('0'),
+    );
+
+    int ticker = DateTime.now().millisecondsSinceEpoch;
+    if (newActive.isBlank) {
+      if (activeItem != null && activeItem!.team == gamer.curHand) {
+        String activePos = activeItem!.position.toCode();
+        animateMove(toPosition);
+        checkCanMove(activePos, toPosition).then((canMove) {
+          int delay = 250 - (DateTime.now().millisecondsSinceEpoch - ticker);
+          if (delay < 0) {
+            delay = 0;
+          }
+          if (canMove) {
+            // 立即更新的部分
+            setState(() {
+              // 清掉落子点
+              movePoints = [];
+              lastPosition = activePos;
+            });
+
+            addStep(ChessPos.fromCode(activePos), toPosition);
+          } else {
+            Future.delayed(Duration(milliseconds: delay), () {
+              setState(() {
+                activeItem!.position = ChessPos.fromCode(activePos);
+              });
+            });
+          }
+        });
+
+        return true;
+      }
+      return false;
+    }
+
+    // 置空当前选中状态
+    if (activeItem != null && activeItem!.position == toPosition) {
+      Sound.play(Sound.click);
+      setState(() {
+        activeItem = null;
+        lastPosition = '';
+        movePoints = [];
+      });
+    } else if (newActive.team == gamer.curHand) {
+      Sound.play(Sound.click);
+      // 切换选中的子
+      setState(() {
+        activeItem = newActive;
+        lastPosition = '';
+        movePoints = [];
+      });
+      fetchMovePoints();
+      return true;
+    } else {
+      // 吃对方的子
+      if (activeItem != null && activeItem!.team == gamer.curHand) {
+        String activePos = activeItem!.position.toCode();
+        animateMove(toPosition);
+        checkCanMove(activePos, toPosition, newActive).then((canMove) {
+          int delay = 250 - (DateTime.now().millisecondsSinceEpoch - ticker);
+          if (delay < 0) {
+            delay = 0;
+          }
+          if (canMove) {
+            addStep(ChessPos.fromCode(activePos), toPosition);
+            //showAction(ActionType.eat);
+            setState(() {
+              // 清掉落子点
+              movePoints = [];
+              lastPosition = activePos;
+
+              // 被吃的子的快照
+              dieFlash = ChessItem(newActive.code, position: toPosition);
+              newActive.isDie = true;
+            });
+            Future.delayed(Duration(milliseconds: delay), () {
+              setState(() {
+                dieFlash = null;
+              });
+            });
+          } else {
+            Future.delayed(Duration(milliseconds: delay), () {
+              setState(() {
+                activeItem!.position = ChessPos.fromCode(activePos);
+              });
+            });
+          }
+        });
+        return true;
+      }
+    }
+    return false;
+  }
+
+  ChessPos pointTrans(Offset tapPoint) {
+    int x = (tapPoint.dx - gamer.skin.offset.dx * gamer.scale) ~/
+        (gamer.skin.size * gamer.scale);
+    int y = 9 -
+        (tapPoint.dy - gamer.skin.offset.dy * gamer.scale) ~/
+            (gamer.skin.size * gamer.scale);
+    return ChessPos(gamer.isFlip ? 8 - x : x, gamer.isFlip ? 9 - y : y);
+  }
+
+  void toast(String message, [SnackBarAction? action, int duration = 3]) {
+    MyDialog.snack(
+      message,
+      action: action,
+      duration: Duration(seconds: duration),
+    );
+  }
+
+  void alertResult(message) {
+    confirm(message, context.l10n.oneMoreGame, context.l10n.letMeSee)
+        .then((isConfirm) {
+      if (isConfirm ?? false) {
+        gamer.newGame();
+      }
+    });
+  }
+
+  Future<bool?> confirm(String message, String agreeText, String cancelText) {
+    return MyDialog.confirm(
+      message,
+      buttonText: agreeText,
+      cancelText: cancelText,
+    );
+  }
+
+  Future<bool?> alert(String message) async {
+    return MyDialog.alert(message);
+  }
+
+  // 显示吃/将效果
+  void showAction(ActionType type) {
+    final overlay = Overlay.of(context);
+
+    late OverlayEntry entry;
+    entry = OverlayEntry(
+      builder: (context) => Center(
+        child: ActionDialog(
+          type,
+          delay: 2,
+          onHide: () {
+            entry.remove();
+          },
+        ),
+      ),
+    );
+    overlay.insert(entry);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    initGamer();
+    if (isLoading) {
+      return const Center(
+        child: CircularProgressIndicator(),
+      );
+    }
+
+    List<Widget> widgets = [const Board()];
+
+    List<Widget> layer0 = [];
+    if (dieFlash != null) {
+      layer0.add(
+        Align(
+          alignment: gamer.skin.getAlign(dieFlash!.position),
+          child: Piece(item: dieFlash!, isActive: false, isAblePoint: false),
+        ),
+      );
+    }
+    if (lastPosition.isNotEmpty) {
+      ChessItem emptyItem =
+          ChessItem('0', position: ChessPos.fromCode(lastPosition));
+      layer0.add(
+        Align(
+          alignment: gamer.skin.getAlign(emptyItem.position),
+          child: MarkComponent(
+            size: gamer.skin.size * gamer.scale,
+          ),
+        ),
+      );
+    }
+    widgets.add(
+      Stack(
+        alignment: Alignment.center,
+        fit: StackFit.expand,
+        children: layer0,
+      ),
+    );
+
+    widgets.add(
+      ChessPieces(
+        items: items,
+        activeItem: activeItem,
+      ),
+    );
+
+    List<Widget> layer2 = [];
+    for (var element in movePoints) {
+      ChessItem emptyItem =
+          ChessItem('0', position: ChessPos.fromCode(element));
+      layer2.add(
+        Align(
+          alignment: gamer.skin.getAlign(emptyItem.position),
+          child: PointComponent(size: gamer.skin.size * gamer.scale),
+        ),
+      );
+    }
+    widgets.add(
+      Stack(
+        alignment: Alignment.center,
+        fit: StackFit.expand,
+        children: layer2,
+      ),
+    );
+
+    return GestureDetector(
+      onTapUp: (detail) {
+        if (gamer.isLock) return;
+        setState(() {
+          onPointer(pointTrans(detail.localPosition));
+        });
+      },
+      child: SizedBox(
+        width: gamer.skin.width,
+        height: gamer.skin.height,
+        child: Stack(
+          alignment: Alignment.center,
+          fit: StackFit.expand,
+          children: widgets,
+        ),
+      ),
+    );
+  }
+}

+ 158 - 0
lib/components/chess_box.dart

@@ -0,0 +1,158 @@
+import 'package:cchess/cchess.dart';
+import 'package:flutter/material.dart';
+
+import 'edit_fen.dart';
+import 'piece.dart';
+import '../global.dart';
+import '../models/game_manager.dart';
+import '../widgets/game_wrapper.dart';
+
+/// 棋子盒 双方
+class ChessBox extends StatefulWidget {
+  final String itemChrs;
+  final String activeChr;
+  final double height;
+
+  const ChessBox({
+    Key? key,
+    required this.itemChrs,
+    this.activeChr = '',
+    required this.height,
+  }) : super(key: key);
+
+  @override
+  State<ChessBox> createState() => _ChessBoxState();
+}
+
+class _ChessBoxState extends State<ChessBox> {
+  static const allItemChrs = 'kabcnrp';
+
+  int matchCount(String chr) {
+    return RegExp(chr).allMatches(widget.itemChrs).length;
+  }
+
+  void setActive(String chr) {
+    EditFenState parent = context.findAncestorStateOfType<EditFenState>()!;
+    parent.setActiveChr(chr);
+  }
+
+  void clearAll() {
+    EditFenState parent = context.findAncestorStateOfType<EditFenState>()!;
+    parent.clearAll();
+  }
+
+  @override
+  void initState() {
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      width: 114,
+      height: widget.height,
+      child: Flex(
+        direction: Axis.vertical,
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        children: [
+          Wrap(
+            children: allItemChrs
+                .split('')
+                .map<Widget>(
+                  (String chr) => ItemWidget(
+                    chr: chr,
+                    count: matchCount(chr),
+                    isActive: widget.activeChr == chr,
+                  ),
+                )
+                .toList(),
+          ),
+          Wrap(
+            children: allItemChrs
+                .toUpperCase()
+                .split('')
+                .map<Widget>(
+                  (String chr) => ItemWidget(
+                    chr: chr,
+                    count: matchCount(chr),
+                    isActive: widget.activeChr == chr,
+                  ),
+                )
+                .toList(),
+          ),
+          Wrap(
+            children: [
+              ElevatedButton(
+                onPressed: () {
+                  clearAll();
+                },
+                child: Text(
+                  context.l10n.clearAll,
+                ),
+              )
+            ],
+          )
+        ],
+      ),
+    );
+  }
+}
+
+class ItemWidget extends StatelessWidget {
+  final String chr;
+  final int count;
+  final bool isActive;
+
+  const ItemWidget({
+    Key? key,
+    required this.chr,
+    required this.count,
+    this.isActive = false,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    GameWrapperState wrapper =
+        context.findAncestorStateOfType<GameWrapperState>()!;
+    GameManager manager = wrapper.gamer;
+    _ChessBoxState parent = context.findAncestorStateOfType<_ChessBoxState>()!;
+    return GestureDetector(
+      onTap: () {
+        if (count > 0) {
+          parent.setActive(chr);
+        }
+      },
+      child: SizedBox(
+        width: manager.skin.size * manager.scale,
+        height: manager.skin.size * manager.scale,
+        child: Stack(
+          children: [
+            Piece(
+              isActive: isActive,
+              item: ChessItem(
+                chr,
+              ),
+            ),
+            Align(
+              alignment: Alignment.topRight,
+              child: Container(
+                width: 20,
+                height: 20,
+                decoration: BoxDecoration(
+                  color: count > 0 ? Colors.red : Colors.grey,
+                  borderRadius: const BorderRadius.all(Radius.circular(10)),
+                ),
+                child: Center(
+                  child: Text(
+                    count.toString(),
+                    style: const TextStyle(color: Colors.white),
+                  ),
+                ),
+              ),
+            )
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 88 - 0
lib/components/chess_pieces.dart

@@ -0,0 +1,88 @@
+import 'package:cchess/cchess.dart';
+import 'package:flutter/material.dart';
+
+import '../models/game_event.dart';
+import '../models/game_manager.dart';
+import 'piece.dart';
+
+class ChessPieces extends StatefulWidget {
+  final List<ChessItem> items;
+  final ChessItem? activeItem;
+
+  const ChessPieces({
+    Key? key,
+    required this.items,
+    this.activeItem,
+  }) : super(key: key);
+
+  @override
+  State<ChessPieces> createState() => _ChessPiecesState();
+}
+
+class _ChessPiecesState extends State<ChessPieces> {
+  late GameManager gamer = GameManager.instance;
+  int curTeam = -1;
+
+  @override
+  void initState() {
+    super.initState();
+    initGamer();
+  }
+
+  void initGamer() {
+    gamer.on<GamePlayerEvent>(onChangePlayer);
+    curTeam = gamer.curHand;
+  }
+
+  @override
+  void dispose() {
+    gamer.off<GamePlayerEvent>(onChangePlayer);
+    super.dispose();
+  }
+
+  void onChangePlayer(GameEvent event) {
+    if (!mounted) return;
+    setState(() {
+      curTeam = event.data;
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    initGamer();
+    return Stack(
+      alignment: Alignment.center,
+      fit: StackFit.expand,
+      children: widget.items.map<Widget>((ChessItem item) {
+        bool isActive = false;
+        bool isHover = false;
+        if (item.isBlank) {
+          //return;
+        } else if (widget.activeItem != null) {
+          if (widget.activeItem!.position == item.position) {
+            isActive = true;
+            if (curTeam == item.team) {
+              isHover = true;
+            }
+          }
+        }
+
+        return AnimatedAlign(
+          duration: const Duration(milliseconds: 250),
+          curve: Curves.easeOutQuint,
+          alignment: gamer.skin.getAlign(item.position),
+          child: SizedBox(
+            width: gamer.skin.size * gamer.scale,
+            height: gamer.skin.size * gamer.scale,
+            //transform: isActive && lastPosition.isEmpty ? Matrix4(1, 0, 0, 0.0, -0.105 * skewStepper, 1 - skewStepper*0.1, 0, -0.004 * skewStepper, 0, 0, 1, 0, 0, 0, 0, 1) : Matrix4.identity(),
+            child: Piece(
+              item: item,
+              isHover: isHover,
+              isActive: isActive,
+            ),
+          ),
+        );
+      }).toList(),
+    );
+  }
+}

+ 154 - 0
lib/components/chess_single_box.dart

@@ -0,0 +1,154 @@
+import 'package:cchess/cchess.dart';
+import 'package:flutter/material.dart';
+
+import 'edit_fen.dart';
+import 'piece.dart';
+import '../global.dart';
+import '../models/game_manager.dart';
+import '../widgets/game_wrapper.dart';
+
+/// 棋子盒 单方
+class ChessSingleBox extends StatefulWidget {
+  final String itemChrs;
+  final String activeChr;
+  final double width;
+  final int team;
+
+  const ChessSingleBox({
+    Key? key,
+    required this.itemChrs,
+    this.activeChr = '',
+    required this.width,
+    this.team = 0,
+  }) : super(key: key);
+
+  @override
+  State<ChessSingleBox> createState() => _ChessBoxState();
+}
+
+class _ChessBoxState extends State<ChessSingleBox> {
+  static String allItemChrs = 'kabcnrp';
+  GameManager? gamer;
+
+  int matchCount(String chr) {
+    return RegExp(chr).allMatches(widget.itemChrs).length;
+  }
+
+  void setActive(String chr) {
+    EditFenState parent = context.findAncestorStateOfType<EditFenState>()!;
+    parent.setActiveChr(chr);
+  }
+
+  void clearAll() {
+    EditFenState parent = context.findAncestorStateOfType<EditFenState>()!;
+    parent.clearAll();
+  }
+
+  @override
+  void initState() {
+    super.initState();
+    if (widget.team == 0) {
+      allItemChrs = allItemChrs.toUpperCase();
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (gamer == null) {
+      GameWrapperState gameWrapper =
+          context.findAncestorStateOfType<GameWrapperState>()!;
+      gamer = gameWrapper.gamer;
+    }
+    return SizedBox(
+      width: widget.width,
+      height: gamer!.skin.size * gamer!.scale,
+      child: Flex(
+        direction: Axis.horizontal,
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        children: [
+          Wrap(
+            children: allItemChrs
+                .split('')
+                .map<Widget>(
+                  (String chr) => ItemWidget(
+                    chr: chr,
+                    count: matchCount(chr),
+                    isActive: widget.activeChr == chr,
+                  ),
+                )
+                .toList(),
+          ),
+          Wrap(
+            children: [
+              ElevatedButton(
+                onPressed: () {
+                  clearAll();
+                },
+                child: Text(context.l10n.clearAll),
+              )
+            ],
+          )
+        ],
+      ),
+    );
+  }
+}
+
+class ItemWidget extends StatelessWidget {
+  final String chr;
+  final int count;
+  final bool isActive;
+
+  const ItemWidget({
+    Key? key,
+    required this.chr,
+    required this.count,
+    this.isActive = false,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    GameWrapperState wrapper =
+        context.findAncestorStateOfType<GameWrapperState>()!;
+    GameManager manager = wrapper.gamer;
+    _ChessBoxState parent = context.findAncestorStateOfType<_ChessBoxState>()!;
+    return GestureDetector(
+      onTap: () {
+        if (count > 0) {
+          parent.setActive(chr);
+        }
+      },
+      child: SizedBox(
+        width: manager.skin.size * manager.scale,
+        height: manager.skin.size * manager.scale,
+        child: Stack(
+          children: [
+            Piece(
+              isActive: isActive,
+              item: ChessItem(
+                chr,
+              ),
+            ),
+            Align(
+              alignment: Alignment.topRight,
+              child: Container(
+                width: 20,
+                height: 20,
+                decoration: BoxDecoration(
+                  color: count > 0 ? Colors.red : Colors.grey,
+                  borderRadius: const BorderRadius.all(Radius.circular(10)),
+                ),
+                child: Center(
+                  child: Text(
+                    count.toString(),
+                    style: const TextStyle(color: Colors.white),
+                  ),
+                ),
+              ),
+            )
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 262 - 0
lib/components/edit_fen.dart

@@ -0,0 +1,262 @@
+import 'package:cchess/cchess.dart';
+import 'package:flutter/material.dart';
+
+import 'chess_box.dart';
+import 'chess_pieces.dart';
+import 'chess_single_box.dart';
+import 'board.dart';
+import '../global.dart';
+import '../widgets/game_wrapper.dart';
+import '../models/game_manager.dart';
+
+/// 编辑局面
+class EditFen extends StatefulWidget {
+  final String fen;
+
+  const EditFen({Key? key, required this.fen}) : super(key: key);
+
+  @override
+  State<EditFen> createState() => EditFenState();
+}
+
+class EditFenState extends State<EditFen> {
+  late ChessManual manual;
+  GameManager? gamer;
+  late List<ChessItem> items;
+  ChessItem? activeItem;
+  String activeChr = '';
+  String dieChrs = '';
+
+  @override
+  void initState() {
+    super.initState();
+    manual = ChessManual();
+
+    manual.setFen(widget.fen);
+    items = manual.getChessItems();
+    dieChrs = manual.currentFen.getDieChr();
+  }
+
+  @override
+  void dispose() {
+    super.dispose();
+  }
+
+  void editOK() {
+    Navigator.of(context).pop<String>(manual.currentFen.fen);
+  }
+
+  bool onPointer(ChessPos toPosition) {
+    ChessItem targetItem = items.firstWhere(
+      (item) => !item.isBlank && item.position == toPosition,
+      orElse: () => ChessItem('0'),
+    );
+    if (targetItem.isBlank) {
+      if (activeItem != null) {
+        manual.doMove('${activeItem!.position.toCode()}${toPosition.toCode()}');
+        setState(() {
+          activeItem!.position = toPosition;
+          activeItem = null;
+        });
+        return true;
+      } else if (activeChr.isNotEmpty) {
+        manual.setItem(toPosition, activeChr);
+        setState(() {
+          items = manual.getChessItems();
+          activeChr = '';
+          dieChrs = manual.currentFen.getDieChr();
+        });
+        return true;
+      }
+    } else {
+      if (activeItem != null) {
+        if (activeItem!.position == toPosition) {
+          manual.setItem(toPosition, '0');
+          setState(() {
+            items = manual.getChessItems();
+            activeItem = null;
+            dieChrs = manual.currentFen.getDieChr();
+          });
+        } else {
+          //targetItem.position = ChessPos.fromCode('i4');
+          //targetItem.isDie = true;
+          manual
+              .doMove('${activeItem!.position.toCode()}${toPosition.toCode()}');
+          setState(() {
+            items = manual.getChessItems();
+            activeItem = null;
+          });
+        }
+        return true;
+      } else if (activeChr.isNotEmpty && activeChr != targetItem.code) {
+        //targetItem.position = ChessPos.fromCode('i4');
+        bool seted = manual.setItem(toPosition, activeChr);
+        if (seted) {
+          setState(() {
+            items = manual.getChessItems();
+            activeChr = '';
+            dieChrs = manual.currentFen.getDieChr();
+          });
+        }
+      } else {
+        setState(() {
+          activeItem = targetItem;
+          activeChr = '';
+        });
+      }
+    }
+    return false;
+  }
+
+  void removeItem(ChessPos fromPosition) {
+    manual.currentFen[fromPosition.y][fromPosition.x] = '0';
+    setState(() {
+      items = manual.getChessItems();
+      activeItem = null;
+      activeChr = '';
+    });
+  }
+
+  void setActiveChr(String chr) {
+    setState(() {
+      activeItem = null;
+      activeChr = chr;
+    });
+  }
+
+  void clearAll() {
+    manual.setFen('4k4/9/9/9/9/9/9/9/9/4K4');
+    setState(() {
+      items = manual.getChessItems();
+      dieChrs = manual.currentFen.getDieChr();
+      activeChr = '';
+      activeItem = null;
+    });
+  }
+
+  ChessPos pointTrans(Offset tapPoint) {
+    int x = (tapPoint.dx - gamer!.skin.offset.dx * gamer!.scale) ~/
+        (gamer!.skin.size * gamer!.scale);
+    int y = 9 -
+        (tapPoint.dy - gamer!.skin.offset.dy * gamer!.scale) ~/
+            (gamer!.skin.size * gamer!.scale);
+    return ChessPos(x, y);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (gamer == null) {
+      GameWrapperState gameWrapper =
+          context.findAncestorStateOfType<GameWrapperState>()!;
+      gamer = gameWrapper.gamer;
+    }
+
+    return Scaffold(
+      appBar: AppBar(
+        title: Text(context.l10n.editCode),
+        actions: [
+          TextButton(
+            onPressed: () {
+              editOK();
+            },
+            child: Text(
+              context.l10n.save,
+              style: const TextStyle(color: Colors.white),
+            ),
+          )
+        ],
+      ),
+      body: Center(
+        child: gamer!.scale < 1 ? _mobileContainer() : _windowContainer(),
+      ),
+    );
+  }
+
+  Widget _mobileContainer() {
+    return SizedBox(
+      width: gamer!.skin.width * gamer!.scale,
+      height: (gamer!.skin.height + gamer!.skin.size * 2 + 20) * gamer!.scale,
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.center,
+        mainAxisAlignment: MainAxisAlignment.spaceAround,
+        children: [
+          ChessSingleBox(
+            width: gamer!.skin.width * gamer!.scale,
+            itemChrs: dieChrs,
+            activeChr: activeChr,
+          ),
+          const SizedBox(width: 10),
+          GestureDetector(
+            onTapUp: (detail) {
+              onPointer(pointTrans(detail.localPosition));
+            },
+            onLongPressEnd: (detail) {
+              logger.info('longPressEnd $detail');
+            },
+            onPanEnd: (detail) {},
+            child: SizedBox(
+              width: gamer!.skin.width * gamer!.scale,
+              height: gamer!.skin.height * gamer!.scale,
+              child: Stack(
+                alignment: Alignment.center,
+                children: [
+                  const Board(),
+                  ChessPieces(
+                    items: items,
+                    activeItem: activeItem,
+                  )
+                ],
+              ),
+            ),
+          ),
+          const SizedBox(width: 10),
+          ChessSingleBox(
+            width: gamer!.skin.width * gamer!.scale,
+            itemChrs: dieChrs,
+            activeChr: activeChr,
+          )
+        ],
+      ),
+    );
+  }
+
+  Widget _windowContainer() {
+    return SizedBox(
+      width: gamer!.skin.width + 10 + gamer!.skin.size * 2 + 10,
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.center,
+        children: [
+          GestureDetector(
+            onTapUp: (detail) {
+              onPointer(pointTrans(detail.localPosition));
+            },
+            onLongPressEnd: (detail) {
+              logger.info('longPressEnd $detail');
+            },
+            onPanEnd: (detail) {},
+            child: SizedBox(
+              width: gamer!.skin.width,
+              height: gamer!.skin.height,
+              child: Stack(
+                alignment: Alignment.center,
+                children: [
+                  const Board(),
+                  ChessPieces(
+                    items: items,
+                    activeItem: activeItem,
+                  )
+                ],
+              ),
+            ),
+          ),
+          const SizedBox(width: 10),
+          ChessBox(
+            height: gamer!.skin.height,
+            itemChrs: dieChrs,
+            activeChr: activeChr,
+          )
+        ],
+      ),
+    );
+  }
+}

+ 113 - 0
lib/components/game_bottom_bar.dart

@@ -0,0 +1,113 @@
+import 'package:flutter/material.dart';
+import 'package:shirne_dialog/shirne_dialog.dart';
+
+import '../models/game_manager.dart';
+import '../models/play_mode.dart';
+import 'play_step.dart';
+
+class GameBottomBar extends StatefulWidget {
+  final PlayMode mode;
+
+  const GameBottomBar(this.mode, {Key? key}) : super(key: key);
+
+  @override
+  State<GameBottomBar> createState() => GameBottomBarState();
+}
+
+class GameBottomBarState extends State<GameBottomBar> {
+  final GameManager gamer = GameManager.instance;
+
+  @override
+  Widget build(BuildContext context) {
+    if (widget.mode == PlayMode.modeRobot) {
+      return robotBottomBar();
+    }
+    if (widget.mode == PlayMode.modeRobot) {
+      return onlineBottomBar();
+    }
+
+    return freeBottomBar();
+  }
+
+  void _showStepList() {
+    final size = MediaQuery.of(context).size;
+    MyDialog.popup(
+      SizedBox(
+        height: size.height * 0.75,
+        child: Center(
+          child: PlayStep(
+            width: size.width * 0.8,
+          ),
+        ),
+      ),
+      isScrollControlled: true,
+    );
+  }
+
+  void _showCode() {}
+
+  void _doReply() {}
+
+  void _goPrev() {
+    if (gamer.currentStep < 1) return;
+    gamer.loadHistory(gamer.currentStep - 1);
+    setState(() {});
+  }
+
+  void _goNext() {
+    if (gamer.currentStep + 1 >= gamer.stepCount) return;
+    gamer.loadHistory(gamer.currentStep + 1);
+    setState(() {});
+  }
+
+  Widget freeBottomBar() {
+    return BottomAppBar(
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.spaceAround,
+        children: [
+          IconButton(icon: const Icon(Icons.list), onPressed: _showStepList),
+          IconButton(icon: const Icon(Icons.code), onPressed: _showCode),
+          IconButton(
+            icon: const Icon(Icons.navigate_before),
+            onPressed: _goPrev,
+          ),
+          IconButton(icon: const Icon(Icons.navigate_next), onPressed: _goNext)
+        ],
+      ),
+    );
+  }
+
+  Widget onlineBottomBar() {
+    return BottomAppBar(
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.spaceAround,
+        children: [
+          IconButton(icon: const Icon(Icons.list), onPressed: _showStepList),
+          IconButton(icon: const Icon(Icons.replay), onPressed: _doReply),
+          IconButton(
+            icon: const Icon(Icons.navigate_before),
+            onPressed: _goPrev,
+          ),
+          IconButton(icon: const Icon(Icons.navigate_next), onPressed: _goNext)
+        ],
+      ),
+    );
+  }
+
+  Widget robotBottomBar() {
+    return BottomAppBar(
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.spaceAround,
+        children: [
+          IconButton(icon: const Icon(Icons.list), onPressed: _showStepList),
+          IconButton(icon: const Icon(Icons.replay), onPressed: _doReply),
+          IconButton(
+            icon: const Icon(Icons.navigate_before),
+            onPressed: _goPrev,
+          ),
+          IconButton(icon: const Icon(Icons.navigate_next), onPressed: _goNext)
+        ],
+      ),
+    );
+  }
+}

+ 40 - 0
lib/components/mark_component.dart

@@ -0,0 +1,40 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+
+/// 标记点
+class MarkComponent extends StatelessWidget {
+  final double size;
+
+  const MarkComponent({Key? key, required this.size}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      width: size,
+      height: size,
+      child: Center(
+        child: Container(
+          width: 22,
+          height: 22,
+          decoration: BoxDecoration(
+            borderRadius: const BorderRadius.all(Radius.circular(30)),
+            border: Border.all(
+              width: 1.0,
+              color: const Color.fromRGBO(255, 255, 255, .8),
+            ),
+          ),
+          child: Center(
+            child: Container(
+              width: 15,
+              height: 15,
+              decoration: const BoxDecoration(
+                color: Color.fromRGBO(255, 255, 255, .8),
+                borderRadius: BorderRadius.all(Radius.circular(15)),
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 107 - 0
lib/components/piece.dart

@@ -0,0 +1,107 @@
+import 'package:cchess/cchess.dart';
+import 'package:flutter/material.dart';
+
+import '../models/game_manager.dart';
+import '../widgets/game_wrapper.dart';
+
+/// 棋子
+class Piece extends StatelessWidget {
+  final ChessItem item;
+  final bool isActive;
+  final bool isAblePoint;
+  final bool isHover;
+
+  const Piece({
+    Key? key,
+    required this.item,
+    this.isActive = false,
+    this.isHover = false,
+    this.isAblePoint = false,
+  }) : super(key: key);
+
+  Widget blankWidget(GameManager gamer) {
+    double size = gamer.skin.size;
+
+    return Container(
+      width: size,
+      height: size,
+      decoration: const BoxDecoration(color: Colors.transparent),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    GameManager gamer =
+        context.findAncestorStateOfType<GameWrapperState>()!.gamer;
+    String team = item.team == 0 ? 'r' : 'b';
+
+    return item.isBlank
+        ? blankWidget(gamer)
+        : AnimatedContainer(
+            width: gamer.skin.size * gamer.scale,
+            height: gamer.skin.size * gamer.scale,
+            duration: const Duration(milliseconds: 300),
+            curve: Curves.easeOutQuint,
+            transform: isHover
+                ? (Matrix4.translationValues(-4, -4, -4))
+                : (Matrix4.translationValues(0, 0, 0)),
+            transformAlignment: Alignment.topCenter,
+            decoration: (isHover)
+                ? BoxDecoration(
+                    boxShadow: const [
+                      BoxShadow(
+                        color: Color.fromRGBO(0, 0, 0, .1),
+                        offset: Offset(2, 3),
+                        blurRadius: 1,
+                        spreadRadius: 0,
+                      ),
+                      BoxShadow(
+                        color: Color.fromRGBO(0, 0, 0, .1),
+                        offset: Offset(4, 6),
+                        blurRadius: 2,
+                        spreadRadius: 2,
+                      )
+                    ],
+                    //border: Border.all(color: Color.fromRGBO(255, 255, 255, .7), width: 2),
+                    borderRadius: BorderRadius.all(
+                      Radius.circular(gamer.skin.size / 2),
+                    ),
+                  )
+                : BoxDecoration(
+                    boxShadow: const [
+                      BoxShadow(
+                        color: Color.fromRGBO(0, 0, 0, .2),
+                        offset: Offset(2, 2),
+                        blurRadius: 1,
+                        spreadRadius: 0,
+                      ),
+                      BoxShadow(
+                        color: Color.fromRGBO(0, 0, 0, .1),
+                        offset: Offset(3, 3),
+                        blurRadius: 1,
+                        spreadRadius: 1,
+                      ),
+                    ],
+                    border: isActive
+                        ? Border.all(
+                            color: Colors.white54,
+                            width: 2,
+                            style: BorderStyle.solid,
+                          )
+                        : null,
+                    borderRadius: BorderRadius.all(
+                      Radius.circular(gamer.skin.size / 2),
+                    ),
+                  ),
+            child: Stack(
+              children: [
+                Image.asset(
+                  team == 'r'
+                      ? gamer.skin.getRedChess(item.code)
+                      : gamer.skin.getBlackChess(item.code),
+                ),
+              ],
+            ),
+          );
+  }
+}

+ 152 - 0
lib/components/play.dart

@@ -0,0 +1,152 @@
+import 'package:flutter/material.dart';
+
+import 'chess.dart';
+import 'play_step.dart';
+import 'play_single_player.dart';
+import 'play_bot.dart';
+import 'play_player.dart';
+import '../global.dart';
+import '../widgets/tab_card.dart';
+import '../models/game_manager.dart';
+import '../models/play_mode.dart';
+import '../driver/player_driver.dart';
+
+/// 游戏布局框
+class PlayPage extends StatefulWidget {
+  final PlayMode mode;
+
+  const PlayPage({Key? key, required this.mode}) : super(key: key);
+  @override
+  State<StatefulWidget> createState() => PlayPageState();
+}
+
+class PlayPageState extends State<PlayPage> {
+  final GameManager gamer = GameManager.instance;
+  bool inited = false;
+
+  @override
+  void initState() {
+    super.initState();
+  }
+
+  void initGame() async {
+    if (inited) return;
+    inited = true;
+    gamer.newGame();
+    if (widget.mode == PlayMode.modeRobot) {
+      gamer.switchDriver(1, DriverType.robot);
+    }
+    gamer.next();
+  }
+
+  @override
+  void dispose() {
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    initGame();
+    return MediaQuery.of(context).size.width < 980
+        ? _mobileContainer()
+        : _windowContainer();
+  }
+
+  Widget _mobileContainer() {
+    return Column(
+      mainAxisAlignment: MainAxisAlignment.spaceAround,
+      children: [
+        const PlaySinglePlayer(
+          team: 1,
+        ),
+        SizedBox(
+          width: gamer.skin.width * gamer.scale,
+          height: gamer.skin.height * gamer.scale,
+          child: const Chess(),
+        ),
+        const PlaySinglePlayer(
+          team: 0,
+          placeAt: Alignment.bottomCenter,
+        ),
+      ],
+    );
+  }
+
+  Widget _windowContainer() {
+    BoxDecoration decoration = BoxDecoration(
+      border: Border.all(color: Colors.grey, width: 0.5),
+      borderRadius: const BorderRadius.all(Radius.circular(2)),
+    );
+    return SizedBox(
+      width: 980,
+      height: 577,
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        crossAxisAlignment: CrossAxisAlignment.start,
+        mainAxisSize: MainAxisSize.max,
+        children: [
+          const SizedBox(
+            width: 521,
+            child: Chess(),
+          ),
+          Container(
+            width: 439,
+            padding: const EdgeInsets.all(10),
+            decoration: const BoxDecoration(
+              color: Colors.white,
+              borderRadius: BorderRadius.all(Radius.circular(2)),
+              boxShadow: [
+                BoxShadow(
+                  color: Color.fromRGBO(0, 0, 0, .1),
+                  offset: Offset(1, 1),
+                  blurRadius: 1.0,
+                  spreadRadius: 1.0,
+                )
+              ],
+            ),
+            child: Column(
+              mainAxisSize: MainAxisSize.max,
+              children: [
+                Expanded(
+                  child: Row(
+                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                    children: [
+                      const PlayPlayer(),
+                      const SizedBox(width: 10),
+                      PlayStep(
+                        decoration: decoration,
+                        width: 180,
+                      ),
+                    ],
+                  ),
+                ),
+                const SizedBox(height: 10),
+                Container(
+                  height: 180,
+                  decoration: decoration,
+                  child: TabCard(
+                    titleFit: FlexFit.tight,
+                    titlePadding: const EdgeInsets.symmetric(
+                      vertical: 10,
+                      horizontal: 30,
+                    ),
+                    titles: [
+                      Text(context.l10n.recommendMove),
+                      Text(context.l10n.remark)
+                    ],
+                    bodies: [
+                      const PlayBot(),
+                      Center(
+                        child: Text(context.l10n.noRemark),
+                      )
+                    ],
+                  ),
+                )
+              ],
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 63 - 0
lib/components/play_bot.dart

@@ -0,0 +1,63 @@
+import 'package:flutter/material.dart';
+
+import '../models/game_event.dart';
+import '../models/game_manager.dart';
+
+/// 引擎提示框
+class PlayBot extends StatefulWidget {
+  const PlayBot({Key? key}) : super(key: key);
+
+  @override
+  State<PlayBot> createState() => PlayStepState();
+}
+
+class PlayStepState extends State<PlayBot> {
+  List<String> botMessages = [];
+  late ScrollController _controller;
+  late GameManager gamer = GameManager.instance;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = ScrollController(keepScrollOffset: true);
+
+    gamer.on<GameEngineEvent>(updateMessage);
+  }
+
+  @override
+  void dispose() {
+    gamer.off<GameEngineEvent>(updateMessage);
+    super.dispose();
+  }
+
+  void updateMessage(GameEvent event) {
+    if (event.data == null || event.data.isEmpty) return;
+    if (event.data == 'clear') {
+      setState(() {
+        botMessages = [];
+      });
+    } else {
+      setState(() {
+        botMessages.add(event.data);
+      });
+    }
+    Future.delayed(const Duration(milliseconds: 16)).then((value) {
+      ScrollPositionWithSingleContext position =
+          _controller.position as ScrollPositionWithSingleContext;
+      _controller.animateTo(
+        position.maxScrollExtent,
+        duration: const Duration(milliseconds: 100),
+        curve: Curves.easeOut,
+      );
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return ListView(
+      controller: _controller,
+      padding: const EdgeInsets.all(10),
+      children: botMessages.map<Widget>((e) => Text(e)).toList(),
+    );
+  }
+}

+ 205 - 0
lib/components/play_player.dart

@@ -0,0 +1,205 @@
+import 'package:flutter/material.dart';
+
+import '../global.dart';
+import '../driver/player_driver.dart';
+import '../models/game_event.dart';
+import '../widgets/game_wrapper.dart';
+import '../models/game_manager.dart';
+import '../widgets/tab_card.dart';
+
+/// 组合玩家框及对局双方信息框
+class PlayPlayer extends StatefulWidget {
+  const PlayPlayer({Key? key}) : super(key: key);
+
+  @override
+  State<PlayPlayer> createState() => PlayPlayerState();
+}
+
+class PlayPlayerState extends State<PlayPlayer> {
+  late GameManager gamer = GameManager.instance;
+  int currentTeam = 0;
+
+  @override
+  void initState() {
+    super.initState();
+    GameWrapperState gameWrapper =
+        context.findAncestorStateOfType<GameWrapperState>()!;
+    gamer = gameWrapper.gamer;
+    gamer.on<GamePlayerEvent>(onChangePlayer);
+    gamer.on<GameLoadEvent>(onReloadGame);
+    gamer.on<GameResultEvent>(onResult);
+  }
+
+  @override
+  void dispose() {
+    gamer.off<GamePlayerEvent>(onChangePlayer);
+    gamer.off<GameLoadEvent>(onReloadGame);
+    gamer.off<GameResultEvent>(onResult);
+    super.dispose();
+  }
+
+  void onResult(GameEvent event) {
+    setState(() {});
+  }
+
+  void onReloadGame(GameEvent event) {
+    if (event.data != 0) return;
+    setState(() {});
+  }
+
+  void onChangePlayer(GameEvent event) {
+    setState(() {
+      currentTeam = event.data;
+    });
+  }
+
+  Widget switchRobot(int team) {
+    if (gamer.hands[team].isUser) {
+      return IconButton(
+        icon: const Icon(Icons.android),
+        tooltip: context.l10n.trusteeshipToRobots,
+        onPressed: () {
+          changePlayDriver(team, DriverType.robot);
+        },
+      );
+    } else if (gamer.hands[team].isRobot) {
+      return IconButton(
+        icon: const Icon(
+          Icons.android,
+          color: Colors.blueAccent,
+        ),
+        tooltip: context.l10n.cancelRobots,
+        onPressed: () {
+          changePlayDriver(team, DriverType.user);
+        },
+      );
+    }
+
+    return const SizedBox();
+  }
+
+  void changePlayDriver(int team, DriverType driverType) {
+    setState(() {
+      gamer.switchDriver(team, driverType);
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    BoxDecoration decoration = BoxDecoration(
+      border: Border.all(color: Colors.grey, width: 0.5),
+      borderRadius: const BorderRadius.all(Radius.circular(2)),
+    );
+    return SizedBox(
+      width: 229,
+      child: Column(
+        children: [
+          ListTile(
+            leading: Icon(
+              Icons.person,
+              color: currentTeam == 1 ? Colors.blueAccent : Colors.black12,
+            ),
+            title: Text(gamer.getPlayer(1).title),
+            subtitle: Text(currentTeam == 1 ? context.l10n.thinking : ''),
+            trailing: switchRobot(1),
+          ),
+          const SizedBox(width: 10),
+          ListTile(
+            leading: Icon(
+              Icons.person,
+              color: currentTeam == 0 ? Colors.blueAccent : Colors.black12,
+            ),
+            title: Text(gamer.getPlayer(0).title),
+            subtitle: Text(currentTeam == 0 ? context.l10n.thinking : ''),
+            trailing: switchRobot(0),
+          ),
+          const SizedBox(width: 10),
+          Expanded(
+            child: Container(
+              decoration: decoration,
+              child: TabCard(
+                titlePadding: const EdgeInsets.only(top: 10, bottom: 10),
+                titles: [
+                  Text(context.l10n.currentInfo),
+                  Text(context.l10n.manual)
+                ],
+                bodies: [
+                  Center(
+                    child: Column(
+                      mainAxisAlignment: MainAxisAlignment.center,
+                      crossAxisAlignment: CrossAxisAlignment.center,
+                      children: [
+                        Text(gamer.manual.event),
+                        Text(
+                          '${gamer.manual.red} (${gamer.manual.chineseResult}) ${gamer.manual.black}',
+                        ),
+                        Text(
+                          gamer.manual.ecco.isEmpty
+                              ? ''
+                              : '${gamer.manual.opening}(${gamer.manual.ecco})',
+                        ),
+                      ],
+                    ),
+                  ),
+                  Container(
+                    padding: const EdgeInsets.all(10),
+                    child: Table(
+                      border: null,
+                      columnWidths: const {
+                        0: IntrinsicColumnWidth(),
+                        1: FlexColumnWidth(),
+                      },
+                      defaultVerticalAlignment: TableCellVerticalAlignment.top,
+                      children: [
+                        TableRow(
+                          children: [
+                            Text(context.l10n.theEvent),
+                            Text(gamer.manual.event)
+                          ],
+                        ),
+                        TableRow(
+                          children: [
+                            Text(context.l10n.theSite),
+                            Text(gamer.manual.site)
+                          ],
+                        ),
+                        TableRow(
+                          children: [
+                            Text(context.l10n.theDate),
+                            Text(gamer.manual.date)
+                          ],
+                        ),
+                        TableRow(
+                          children: [
+                            Text(context.l10n.theRound),
+                            Text(gamer.manual.round)
+                          ],
+                        ),
+                        TableRow(
+                          children: [
+                            Text(context.l10n.theRed),
+                            Text(
+                              '${gamer.manual.redTeam}/${gamer.manual.red}',
+                            ),
+                          ],
+                        ),
+                        TableRow(
+                          children: [
+                            Text(context.l10n.theBlack),
+                            Text(
+                              '${gamer.manual.blackTeam}/${gamer.manual.black}',
+                            ),
+                          ],
+                        ),
+                      ],
+                    ),
+                  )
+                ],
+              ),
+            ),
+          )
+        ],
+      ),
+    );
+  }
+}

+ 147 - 0
lib/components/play_single_player.dart

@@ -0,0 +1,147 @@
+import 'package:flutter/material.dart';
+
+import '../global.dart';
+import '../driver/player_driver.dart';
+import '../models/game_event.dart';
+import '../models/game_manager.dart';
+import '../widgets/list_item.dart';
+
+/// 单个玩家框
+class PlaySinglePlayer extends StatefulWidget {
+  final int team;
+  final Alignment placeAt;
+
+  const PlaySinglePlayer({
+    Key? key,
+    required this.team,
+    this.placeAt = Alignment.topCenter,
+  }) : super(key: key);
+
+  @override
+  State<PlaySinglePlayer> createState() => PlaySinglePlayerState();
+}
+
+class PlaySinglePlayerState extends State<PlaySinglePlayer> {
+  late GameManager gamer = GameManager.instance;
+  int currentTeam = 0;
+
+  @override
+  void initState() {
+    super.initState();
+
+    gamer.on<GamePlayerEvent>(onChangePlayer);
+    gamer.on<GameLoadEvent>(onReloadGame);
+    gamer.on<GameResultEvent>(onResult);
+  }
+
+  @override
+  void dispose() {
+    gamer.off<GamePlayerEvent>(onChangePlayer);
+    gamer.off<GameLoadEvent>(onReloadGame);
+    gamer.off<GameResultEvent>(onResult);
+    super.dispose();
+  }
+
+  void onResult(GameEvent event) {
+    setState(() {});
+  }
+
+  void onReloadGame(GameEvent event) {
+    if (event.data != 0) return;
+    setState(() {});
+  }
+
+  void onChangePlayer(GameEvent event) {
+    setState(() {
+      currentTeam = event.data;
+    });
+  }
+
+  Widget switchRobot(int team) {
+    if (gamer.hands[team].isUser) {
+      return IconButton(
+        icon: const Icon(Icons.android),
+        tooltip: context.l10n.trusteeshipToRobots,
+        onPressed: () {
+          changePlayDriver(team, DriverType.robot);
+        },
+      );
+    } else if (gamer.hands[team].isRobot) {
+      return IconButton(
+        icon: const Icon(
+          Icons.android,
+          color: Colors.blueAccent,
+        ),
+        tooltip: context.l10n.cancelRobots,
+        onPressed: () {
+          changePlayDriver(team, DriverType.user);
+        },
+      );
+    }
+    return const SizedBox();
+  }
+
+  void changePlayDriver(int team, DriverType driverType) {
+    setState(() {
+      gamer.switchDriver(team, driverType);
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    Widget leading;
+    Widget trailing;
+    TextDirection tDirect;
+    if (widget.placeAt == Alignment.topCenter) {
+      leading = Icon(
+        Icons.person,
+        size: 28,
+        color: currentTeam == widget.team ? Colors.blueAccent : Colors.black12,
+      );
+      trailing = switchRobot(widget.team);
+      tDirect = TextDirection.ltr;
+    } else {
+      trailing = Icon(
+        Icons.person,
+        size: 28,
+        color: currentTeam == widget.team ? Colors.blueAccent : Colors.black12,
+      );
+      leading = switchRobot(widget.team);
+      tDirect = TextDirection.rtl;
+    }
+    List<Widget> childs = [
+      SizedBox(
+        width: 229,
+        child: ListItem(
+          leading: leading,
+          title: Text(
+            gamer.getPlayer(widget.team).title,
+            style: const TextStyle(fontSize: 14),
+            textDirection: tDirect,
+          ),
+          subtitle: currentTeam == widget.team
+              ? Text(
+                  context.l10n.thinking,
+                  style: const TextStyle(fontSize: 10),
+                  textDirection: tDirect,
+                )
+              : null,
+          trailing: trailing,
+          titleAlign: widget.placeAt == Alignment.topCenter
+              ? CrossAxisAlignment.start
+              : CrossAxisAlignment.end,
+        ),
+      ),
+      const SizedBox(width: 10),
+    ];
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 10),
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        children: widget.placeAt == Alignment.topCenter
+            ? childs
+            : childs.reversed.toList(),
+      ),
+    );
+  }
+}

+ 106 - 0
lib/components/play_step.dart

@@ -0,0 +1,106 @@
+import 'package:chinese_chess/models/game_event.dart';
+import 'package:flutter/material.dart';
+
+import '../global.dart';
+import '../models/game_manager.dart';
+
+/// 着法列表
+class PlayStep extends StatefulWidget {
+  final BoxDecoration? decoration;
+  final double width;
+
+  const PlayStep({Key? key, this.decoration, required this.width})
+      : super(key: key);
+
+  @override
+  State<PlayStep> createState() => PlayStepState();
+}
+
+class PlayStepState extends State<PlayStep> {
+  final List<String> steps = [''];
+  final ScrollController _controller = ScrollController(keepScrollOffset: true);
+  final GameManager gamer = GameManager.instance;
+
+  int currentStep = 0;
+
+  @override
+  void initState() {
+    super.initState();
+
+    gamer.on<GameStepEvent>(updateStep);
+    steps.addAll(gamer.getSteps());
+    currentStep = gamer.currentStep;
+  }
+
+  @override
+  void dispose() {
+    gamer.off<GameStepEvent>(updateStep);
+    super.dispose();
+  }
+
+  void updateStep(GameEvent event) {
+    String message = event.data!;
+    if (message.isEmpty || !mounted) return;
+    if (message == 'clear') {
+      setState(() {
+        currentStep = gamer.currentStep - 1;
+        steps.removeRange(currentStep + 2, steps.length);
+      });
+    } else if (message == 'step') {
+      setState(() {
+        currentStep = gamer.currentStep;
+      });
+    } else {
+      setState(() {
+        message.split('\n').forEach((element) {
+          steps.add(element);
+        });
+        currentStep = steps.length - 1;
+      });
+    }
+    Future.delayed(const Duration(milliseconds: 16)).then((value) {
+      ScrollPositionWithSingleContext position =
+          _controller.position as ScrollPositionWithSingleContext;
+      _controller.animateTo(
+        position.maxScrollExtent,
+        duration: const Duration(milliseconds: 100),
+        curve: Curves.easeOut,
+      );
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      width: widget.width,
+      padding: const EdgeInsets.all(10),
+      decoration: widget.decoration,
+      child: ListView.builder(
+        controller: _controller,
+        itemCount: steps.length,
+        itemBuilder: (context, index) => GestureDetector(
+          onTap: () {
+            if (!gamer.canBacktrace) return;
+            gamer.loadHistory(index - 1);
+            setState(() {
+              currentStep = index;
+            });
+          },
+          child: Container(
+            height: 23,
+            padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 5),
+            alignment: Alignment.centerLeft,
+            decoration: BoxDecoration(
+              color: currentStep == index ? Colors.black26 : Colors.transparent,
+            ),
+            child: (index > 0 && index % 2 == 1)
+                ? Text('${(index + 1) ~/ 2}.${steps[index]}')
+                : Text(
+                    '   ${index == 0 ? context.l10n.stepStart : steps[index]}',
+                  ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 26 - 0
lib/components/point_component.dart

@@ -0,0 +1,26 @@
+import 'package:flutter/material.dart';
+
+/// 标记点
+class PointComponent extends StatelessWidget {
+  final double size;
+
+  const PointComponent({Key? key, required this.size}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      width: size,
+      height: size,
+      child: Center(
+        child: Container(
+          width: 10,
+          height: 10,
+          decoration: const BoxDecoration(
+            color: Colors.green,
+            borderRadius: BorderRadius.all(Radius.circular(10)),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 35 - 0
lib/driver/driver_online.dart

@@ -0,0 +1,35 @@
+import '../models/game_event.dart';
+import '../models/player.dart';
+import 'player_driver.dart';
+
+class DriverOnline extends PlayerDriver {
+  DriverOnline(Player player) : super(player) {
+    canBacktrace = false;
+  }
+
+  @override
+  Future<bool> tryDraw() {
+    return Future.value(true);
+  }
+
+  @override
+  Future<String?> move() {
+    player.manager.add(GameLockEvent(true));
+    throw UnimplementedError();
+  }
+
+  @override
+  Future<String> ponder() {
+    throw UnimplementedError();
+  }
+
+  @override
+  void completeMove(String move) {
+    throw UnimplementedError();
+  }
+
+  @override
+  Future<bool> tryRetract() {
+    throw UnimplementedError();
+  }
+}

+ 435 - 0
lib/driver/driver_robot.dart

@@ -0,0 +1,435 @@
+import 'dart:async';
+import 'dart:isolate';
+import 'dart:math';
+
+import 'package:cchess/cchess.dart';
+import 'package:cchess_engine/cchess_engine.dart';
+import 'package:flutter/foundation.dart';
+
+import '../global.dart';
+import '../models/engine_type.dart';
+import '../models/game_event.dart';
+import '../models/game_setting.dart';
+import '../models/engine.dart';
+import '../models/player.dart';
+
+import 'player_driver.dart';
+
+class DriverRobot extends PlayerDriver {
+  DriverRobot(Player player) : super(player);
+  late Completer<String> requestMove;
+  bool isCleared = true;
+
+  @override
+  Future<bool> tryDraw() {
+    return Future.value(true);
+  }
+
+  @override
+  Future<String?> move() {
+    requestMove = Completer<String>();
+    player.manager.add(GameLockEvent(true));
+
+    // 网页版用不了引擎
+    Future.delayed(const Duration(seconds: 1)).then((value) {
+      if (Engine.isSupportEngine &&
+          player.manager.setting.robotType == EngineType.elephantEye) {
+        getMoveFromEngine();
+      } else {
+        // getMove();
+        getBuiltInMove();
+      }
+    });
+
+    return requestMove.future;
+  }
+
+  Future<void> getMoveFromEngine() async {
+    player.manager.startEngine().then((v) {
+      if (v) {
+        player.manager.engine!
+            .requestMove(player.manager.fenStr, depth: 10)
+            .then(onEngineMessage);
+      } else {
+        getMove();
+      }
+    });
+  }
+
+  void onEngineMessage(String message) {
+    List<String> parts = message.split(' ');
+    switch (parts[0]) {
+      case 'ucciok':
+        break;
+      case 'nobestmove':
+      case 'isbusy':
+        if (!isCleared) {
+          isCleared = true;
+          return;
+        }
+        if (!requestMove.isCompleted) {
+          player.manager.engine!.removeListener(onEngineMessage);
+          getMove();
+        }
+        break;
+      case 'bestmove':
+        if (!isCleared) {
+          isCleared = true;
+          return;
+        }
+        player.manager.engine!.removeListener(onEngineMessage);
+        completeMove(parts[1]);
+        break;
+      case 'info':
+        break;
+      case 'id':
+      case 'option':
+      default:
+        return;
+    }
+  }
+
+  Future<void> getBuiltInMove() async {
+    GameSetting setting = await GameSetting.getInstance();
+    XQIsoSearch.level = setting.robotLevel;
+
+    if (kIsWeb) {
+      completeMove(
+        await XQIsoSearch.getMove(IsoMessage(player.manager.fenStr)),
+      );
+    } else {
+      ReceivePort rPort = ReceivePort();
+      rPort.listen((message) {
+        completeMove(message);
+      });
+      Isolate.spawn<IsoMessage>(
+        XQIsoSearch.getMove,
+        IsoMessage(player.manager.fenStr, rPort.sendPort),
+      );
+    }
+  }
+
+  Future<void> getMove() async {
+    logger.info('thinking');
+    int team = player.team == 'r' ? 0 : 1;
+    List<String> moves = await getAbleMoves(player.manager.fen, team);
+    if (moves.isEmpty) {
+      completeMove('giveup');
+      return;
+    }
+    //print(moves);
+    await Future.delayed(const Duration(milliseconds: 100));
+    Map<String, int> moveGroups =
+        await checkMoves(player.manager.fen, team, moves);
+    //print(moveGroups);
+    await Future.delayed(const Duration(milliseconds: 100));
+
+    String move = await pickMove(moveGroups);
+    //print(move);
+    completeMove(move);
+  }
+
+  /// 获取可以走的着法
+  Future<List<String>> getAbleMoves(ChessFen fen, int team) async {
+    List<String> moves = [];
+    List<ChessItem> items = fen.getAll();
+    for (var item in items) {
+      if (item.team == team) {
+        List<String> curMoves = ChessRule(fen)
+            .movePoints(item.position)
+            .map<String>((toPos) => item.position.toCode() + toPos)
+            .toList();
+
+        curMoves = curMoves.where((move) {
+          ChessRule rule = ChessRule(fen.copy());
+          rule.fen.move(move);
+          if (rule.isKingMeet(team)) {
+            return false;
+          }
+          if (rule.isCheck(team)) {
+            return false;
+          }
+          return true;
+        }).toList();
+        if (curMoves.isNotEmpty) {
+          moves += curMoves;
+        }
+      }
+    }
+
+    return moves;
+  }
+
+  /// todo 检查着法优势 吃子(被吃子是否有根以及与本子权重),躲吃,生根,将军,叫杀 将着法按权重分组
+  Future<Map<String, int>> checkMoves(
+    ChessFen fen,
+    int team,
+    List<String> moves,
+  ) async {
+    // 着法加分
+    List<int> weights = [
+      49, // 0.将军
+      199, // 1.叫杀
+      199, // 2.挡将,挡杀
+      9, // 3.捉 这四项根据子力价值倍乘
+      19, // 4.保
+      19, // 5.吃
+      9, // 6.躲
+      0, // 7.闲 进 退
+    ];
+    Map<String, int> moveWeight = {};
+
+    ChessRule rule = ChessRule(fen);
+
+    int enemyTeam = team == 0 ? 1 : 0;
+    // 被将军的局面,生成的都是挡着
+    if (rule.isCheck(team)) {
+      // 计算挡着后果
+      for (var move in moves) {
+        ChessRule nRule = ChessRule(fen.copy());
+        nRule.fen.move(move);
+
+        // 走着后还能不能被将
+        bool canCheck = nRule.teamCanCheck(enemyTeam);
+        if (!canCheck) {
+          moveWeight[move] = weights[2];
+        } else {
+          moveWeight[move] = weights[2] * 3;
+        }
+      }
+    } else {
+      // 获取要被吃的子
+      List<ChessItem> willBeEaten = rule.getBeEatenList(team);
+
+      for (var move in moves) {
+        moveWeight[move] = 0;
+        ChessPos fromPos = ChessPos.fromCode(move.substring(0, 2));
+        ChessPos toPos = ChessPos.fromCode(move.substring(2, 4));
+
+        String chess = fen[fromPos.y][fromPos.x];
+        String toChess = fen[toPos.y][toPos.x];
+        if (toChess != '0') {
+          int toRootCount = rule.rootCount(toPos, enemyTeam);
+          int wPower = rule.getChessWeight(toPos);
+
+          // 被吃子有根,则要判断双方子力价值才吃
+          if (toRootCount > 0) {
+            wPower -= rule.getChessWeight(fromPos);
+          }
+          moveWeight[move] = moveWeight[move]! + weights[5] * wPower;
+        }
+        int rootCount = rule.rootCount(fromPos, team);
+        int eRootCount = rule.rootCount(fromPos, enemyTeam);
+
+        // 躲吃
+        /*if(rootCount < 1 && eRootCount > 0){
+          moveWeight[move] += weights[6] * rule.getChessWeight(fromPos);
+        }else if(rootCount < eRootCount){
+          moveWeight[move] += weights[6] * (rule.getChessWeight(fromPos) - rule.getChessWeight(toPos));
+        }*/
+
+        // 开局兵不挡马路不动兵
+        int chessCount = rule.fen.getAllChr().length;
+        if (chessCount > 28) {
+          if (chess == 'p') {
+            if (fen[fromPos.y + 1][fromPos.x] == 'n') {
+              moveWeight[move] = moveWeight[move]! + 9;
+            }
+          } else if (chess == 'P') {
+            if (fen[fromPos.y - 1][fromPos.x] == 'N') {
+              moveWeight[move] = moveWeight[move]! + 9;
+            }
+          }
+
+          // 开局先动马炮
+          if (['c', 'C', 'n', 'N'].contains(chess)) {
+            moveWeight[move] = moveWeight[move]! + 9;
+          }
+        }
+        if (chessCount > 20) {
+          // 车马炮在原位的优先动
+          if ((chess == 'C' &&
+                  fromPos.y == 2 &&
+                  (fromPos.x == 1 || fromPos.x == 7)) ||
+              (chess == 'c' &&
+                  fromPos.y == 7 &&
+                  (fromPos.x == 1 || fromPos.x == 7))) {
+            moveWeight[move] = moveWeight[move]! + 19;
+          }
+          if ((chess == 'N' && fromPos.y == 0) ||
+              (chess == 'n' && fromPos.y == 9)) {
+            moveWeight[move] = moveWeight[move]! + 19;
+          }
+          if ((chess == 'R' && fromPos.y == 0) ||
+              (chess == 'r' && fromPos.y == 9)) {
+            moveWeight[move] = moveWeight[move]! + 9;
+          }
+        }
+
+        // 马往前跳权重增加
+        if ((chess == 'n' && toPos.y < fromPos.y) ||
+            (chess == 'N' && toPos.y > fromPos.y)) {
+          moveWeight[move] = moveWeight[move]! + 9;
+        }
+
+        // 马在原位不动车
+        if ((chess == 'r' && fromPos.y == 9) ||
+            (chess == 'R' && fromPos.y == 0)) {
+          ChessPos nPos = rule.fen.find(chess == 'R' ? 'N' : 'n')!;
+          if (fromPos.x == 0) {
+            if (nPos.x == 1 && nPos.y == fromPos.y) {
+              moveWeight[move] = moveWeight[move]! - rule.getChessWeight(nPos);
+            }
+          } else if (fromPos.x == 8) {
+            if (nPos.x == 7 && nPos.y == fromPos.y) {
+              moveWeight[move] = moveWeight[move]! - rule.getChessWeight(nPos);
+            }
+          }
+        }
+
+        ChessPos ekPos = fen.find(enemyTeam == 0 ? 'K' : 'k')!;
+        // 炮是否应着老将
+        if (chess == 'c' || chess == 'C') {
+          if (fromPos.y == ekPos.y || fromPos.x == ekPos.x) {
+            if (toPos.y != ekPos.y && toPos.x != ekPos.x) {
+              moveWeight[move] = moveWeight[move]! - weights[0];
+            }
+          } else {
+            if (toPos.y == ekPos.y || toPos.x == ekPos.x) {
+              moveWeight[move] = moveWeight[move]! + weights[0];
+            }
+          }
+        }
+
+        ChessRule mRule = ChessRule(fen.copy());
+        mRule.fen.move(move);
+
+        // 走招后要被将军
+        if (mRule.teamCanCheck(enemyTeam)) {
+          List<String> checkMoves = mRule.getCheckMoves(enemyTeam);
+          //print('将军招法: $checkMoves');
+          for (var eMove in checkMoves) {
+            ChessRule eRule = ChessRule(mRule.fen.copy());
+            eRule.fen.move(eMove);
+            // 不能应将,就是杀招
+            if (eRule.canParryKill(team)) {
+              //print('$move 要被将军');
+              moveWeight[move] = moveWeight[move]! - weights[0];
+            } else {
+              logger.info('$move 有杀招');
+              moveWeight[move] = moveWeight[move]! - weights[1];
+            }
+          }
+        } else {
+          rootCount = mRule.rootCount(toPos, team);
+          eRootCount = mRule.rootCount(toPos, enemyTeam);
+
+          for (var bItem in willBeEaten) {
+            // 当前走的子就是被吃的子
+            if (bItem.position == fromPos) {
+              // 走之后不被吃了
+              if (eRootCount < 1) {
+                moveWeight[move] = moveWeight[move]! +
+                    mRule.getChessWeight(toPos) * weights[6];
+              } else if (rootCount > 0) {
+                List<ChessItem> eItems = mRule.getBeEatList(toPos);
+                moveWeight[move] = moveWeight[move]! +
+                    (mRule.getChessWeight(eItems[0].position) -
+                            mRule.getChessWeight(toPos)) *
+                        weights[6];
+              }
+            } else {
+              // 不是被吃的子,但是也躲过去了
+              int oRootCount = mRule.rootCount(bItem.position, enemyTeam);
+              if (oRootCount < 1) {
+                moveWeight[move] = moveWeight[move]! +
+                    mRule.getChessWeight(bItem.position) * weights[6];
+              } else {
+                // 有根了
+                List<ChessItem> eItems = mRule.getBeEatList(bItem.position);
+                moveWeight[move] = moveWeight[move]! +
+                    (mRule.getChessWeight(eItems[0].position) -
+                            mRule.getChessWeight(bItem.position)) *
+                        weights[6];
+              }
+            }
+          }
+
+          // 走着后要被吃
+          if ((rootCount == 0 && eRootCount > 0) || rootCount < eRootCount) {
+            moveWeight[move] =
+                moveWeight[move]! - mRule.getChessWeight(toPos) * weights[5];
+          }
+
+          // 捉子优先
+          List<ChessItem> canEatItems = mRule.getEatList(toPos);
+          List<ChessItem> oldCanEatItems = rule.getEatList(fromPos);
+          int eatWeight = 0;
+          for (var oItem in oldCanEatItems) {
+            eatWeight += mRule.getChessWeight(oItem.position) * weights[3];
+          }
+          for (var oItem in canEatItems) {
+            eatWeight -= mRule.getChessWeight(oItem.position) * weights[3];
+          }
+          moveWeight[move] = moveWeight[move]! - eatWeight;
+        }
+      }
+    }
+    int minWeight = 0;
+    moveWeight.forEach((key, value) {
+      if (minWeight > value) minWeight = value;
+    });
+
+    if (minWeight < 0) {
+      moveWeight.updateAll((key, value) => value - minWeight);
+    }
+
+    logger.info(moveWeight);
+
+    return moveWeight;
+  }
+
+  /// todo 从分组好的招法中随机筛选一个
+  Future<String> pickMove(Map<String, int> groups) async {
+    int totalSum = 0;
+    for (var wgt in groups.values) {
+      wgt += 1;
+      if (wgt < 0) wgt = 0;
+      totalSum += wgt;
+    }
+
+    Random random = Random(DateTime.now().millisecondsSinceEpoch);
+
+    double rand = random.nextDouble() * totalSum;
+    int curSum = 0;
+    String move = '';
+    for (String key in groups.keys) {
+      move = key;
+      curSum += groups[key]!;
+      if (curSum > rand) {
+        break;
+      }
+    }
+
+    return move;
+  }
+
+  @override
+  Future<String> ponder() {
+    // TODO: implement ponder
+    throw UnimplementedError();
+  }
+
+  @override
+  Future<void> completeMove(String move) async {
+    player.onMove(move).then((value) {
+      requestMove.complete(move);
+    });
+  }
+
+  @override
+  Future<bool> tryRetract() {
+    // TODO: implement tryRetract
+    throw UnimplementedError();
+  }
+}

+ 46 - 0
lib/driver/driver_user.dart

@@ -0,0 +1,46 @@
+import 'dart:async';
+
+import '../models/game_event.dart';
+import '../models/player.dart';
+
+import 'player_driver.dart';
+
+class DriverUser extends PlayerDriver {
+  late Completer<String> requestMove;
+
+  DriverUser(Player player) : super(player);
+
+  @override
+  Future<bool> tryDraw() {
+    return Future.value(true);
+  }
+
+  @override
+  Future<String?> move() {
+    requestMove = Completer<String>();
+    player.manager.add(GameLockEvent(false));
+
+    // 招法提示
+    player.manager.requestHelp();
+
+    return requestMove.future;
+  }
+
+  @override
+  Future<String> ponder() {
+    // TODO: implement ponder
+    throw UnimplementedError();
+  }
+
+  @override
+  void completeMove(String move) {
+    if (!requestMove.isCompleted) {
+      requestMove.complete(move);
+    }
+  }
+
+  @override
+  Future<bool> tryRetract() {
+    throw UnimplementedError();
+  }
+}

+ 77 - 0
lib/driver/player_driver.dart

@@ -0,0 +1,77 @@
+import '../models/player.dart';
+
+import 'driver_online.dart';
+import 'driver_robot.dart';
+import 'driver_user.dart';
+
+abstract class PlayerDriver {
+  final Player player;
+  bool canBacktrace = true;
+
+  // 认输
+  static const rstGiveUp = 'giveup';
+  // 提和
+  static const rstRqstDraw = 'rqstrdraw';
+  // 悔棋
+  static const rstRqstRetract = 'rqstretract';
+  // 同意提和
+  static const rstDraw = 'draw';
+  // 同意悔棋
+  static const rstRetract = 'retract';
+
+  static const rstActions = [
+    rstGiveUp,
+    rstRqstDraw,
+    rstRqstRetract,
+    rstDraw,
+    rstRetract
+  ];
+
+  static bool isAction(String move) {
+    return rstActions.contains(move) || move.contains(rstRqstDraw);
+  }
+
+  PlayerDriver(this.player);
+
+  static PlayerDriver createDriver(
+    Player manager, [
+    DriverType type = DriverType.user,
+  ]) {
+    switch (type) {
+      case DriverType.robot:
+        return DriverRobot(manager);
+      case DriverType.online:
+        return DriverOnline(manager);
+      default:
+        return DriverUser(manager);
+    }
+  }
+
+  /// 申请和棋
+  Future<bool> tryDraw();
+
+  /// 申请悔棋
+  Future<bool> tryRetract();
+
+  /// 获取走招
+  Future<String?> move();
+
+  /// 思考
+  Future<String> ponder();
+
+  /// 完成走招
+  void completeMove(String move);
+
+  @override
+  String toString() => "$runtimeType ${player.team}";
+}
+
+class DriverType {
+  final String type;
+
+  static const user = DriverType('user');
+  static const robot = DriverType('robot');
+  static const online = DriverType('online');
+
+  const DriverType(this.type);
+}

+ 57 - 0
lib/foundation/customer_notifier.dart

@@ -0,0 +1,57 @@
+class CustomNotifier<T> {
+  
+  List<void Function(T value)> callBacks = [];
+  bool _lockList = false;
+
+  void addListener(void Function(T value) func) {
+    if (_lockList) {
+      Future.delayed(const Duration(milliseconds: 1)).then((v) {
+        addListener(func);
+      });
+      return;
+    }
+    _lockList = true;
+    callBacks.add(func);
+    _lockList = false;
+  }
+
+  bool get hasListeners => callBacks.isNotEmpty;
+
+  void removeListener(void Function(T value) func) {
+    if (_lockList) {
+      Future.delayed(const Duration(milliseconds: 1)).then((v) {
+        removeListener(func);
+      });
+      return;
+    }
+    _lockList = true;
+    callBacks.remove(func);
+    _lockList = false;
+  }
+
+  void notifyListeners(T value) {
+    if (_lockList) {
+      Future.delayed(const Duration(milliseconds: 1)).then((v) {
+        notifyListeners(value);
+      });
+      return;
+    }
+    _lockList = true;
+    for (var element in callBacks) {
+      element(value);
+    }
+    _lockList = false;
+  }
+
+  void dispose() {
+    if (_lockList) {
+      Future.delayed(const Duration(milliseconds: 1)).then((v) {
+        dispose();
+      });
+      return;
+    }
+    _lockList = true;
+    callBacks.clear();
+    _lockList = false;
+  }
+}

+ 376 - 0
lib/game_board.dart

@@ -0,0 +1,376 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:fast_gbk/fast_gbk.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:shirne_dialog/shirne_dialog.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:universal_html/html.dart' as html;
+
+import 'global.dart';
+import 'setting.dart';
+import 'components/game_bottom_bar.dart';
+import 'models/play_mode.dart';
+import 'widgets/game_wrapper.dart';
+import 'models/game_manager.dart';
+import 'components/play.dart';
+import 'components/edit_fen.dart';
+
+/// 游戏页面
+class GameBoard extends StatefulWidget {
+  const GameBoard({Key? key}) : super(key: key);
+
+  @override
+  State<GameBoard> createState() => _GameBoardState();
+}
+
+class _GameBoardState extends State<GameBoard> {
+  GameManager gamer = GameManager.instance;
+  PlayMode? mode;
+
+  @override
+  void initState() {
+    super.initState();
+  }
+
+  Widget selectMode() {
+    final maxHeight = MediaQuery.of(context).size.height;
+
+    return Center(
+      child: SizedBox(
+        height: maxHeight * 0.6,
+        child: Column(
+          mainAxisAlignment: MainAxisAlignment.spaceAround,
+          children: [
+            ElevatedButton.icon(
+              onPressed: () {
+                setState(() {
+                  mode = PlayMode.modeRobot;
+                });
+              },
+              icon: const Icon(Icons.android),
+              label: Text(context.l10n.modeRobot),
+            ),
+            ElevatedButton.icon(
+              onPressed: () {
+                MyDialog.toast(
+                  context.l10n.featureNotAvailable,
+                  iconType: IconType.error,
+                );
+              },
+              icon: const Icon(Icons.wifi),
+              label: Text(context.l10n.modeOnline),
+            ),
+            ElevatedButton.icon(
+              onPressed: () {
+                setState(() {
+                  mode = PlayMode.modeFree;
+                });
+              },
+              icon: const Icon(Icons.map),
+              label: Text(context.l10n.modeFree),
+            ),
+            if (kIsWeb)
+              TextButton(
+                onPressed: () {
+                  var link =
+                      html.window.document.getElementById('download-apk');
+                  if (link == null) {
+                    link = html.window.document.createElement('a');
+                    link.style.display = 'none';
+                    link.setAttribute('id', 'download-apk');
+                    link.setAttribute('target', '_blank');
+                    link.setAttribute('href', 'chinese-chess.apk');
+                    html.window.document
+                        .getElementsByTagName('body')[0]
+                        .append(link);
+                  }
+                  link.click();
+                },
+                child: const Text('Download APK'),
+              ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        title: Text(context.l10n.appTitle),
+        leading: Builder(
+          builder: (BuildContext context) {
+            return IconButton(
+              icon: const Icon(Icons.menu),
+              tooltip: context.l10n.openMenu,
+              onPressed: () {
+                Scaffold.of(context).openDrawer();
+              },
+            );
+          },
+        ),
+        actions: mode == null
+            ? null
+            : [
+                IconButton(
+                  icon: const Icon(Icons.swap_vert),
+                  tooltip: context.l10n.flipBoard,
+                  onPressed: () {
+                    gamer.flip();
+                  },
+                ),
+                IconButton(
+                  icon: const Icon(Icons.copy),
+                  tooltip: context.l10n.copyCode,
+                  onPressed: () {
+                    copyFen();
+                  },
+                ),
+                IconButton(
+                  icon: const Icon(Icons.airplay),
+                  tooltip: context.l10n.parseCode,
+                  onPressed: () {
+                    applyFen();
+                  },
+                ),
+                IconButton(
+                  icon: const Icon(Icons.airplay),
+                  tooltip: context.l10n.editCode,
+                  onPressed: () {
+                    editFen();
+                  },
+                ),
+                /*IconButton(icon: Icon(Icons.minimize), onPressed: (){
+
+          }),
+          IconButton(icon: Icon(Icons.zoom_out_map), onPressed: (){
+
+          }),
+          IconButton(icon: Icon(Icons.clear), color: Colors.red, onPressed: (){
+            this._showDialog(context.l10n.exit_now,
+                [
+                  TextButton(
+                    onPressed: (){
+                      Navigator.of(context).pop();
+                    },
+                    child: Text(context.l10n.dont_exit),
+                  ),
+                  TextButton(
+                      onPressed: (){
+                        if(!kIsWeb){
+                          Isolate.current.pause();
+                          exit(0);
+                        }
+                      },
+                      child: Text(context.l10n.yes_exit,style: TextStyle(color:Colors.red)),
+                  )
+                ]
+            );
+          })*/
+              ],
+      ),
+      drawer: Drawer(
+        semanticLabel: context.l10n.menu,
+        child: ListView(
+          padding: EdgeInsets.zero,
+          children: [
+            DrawerHeader(
+              decoration: const BoxDecoration(
+                color: Colors.blue,
+              ),
+              child: Center(
+                child: Column(
+                  children: [
+                    Image.asset(
+                      'assets/images/logo.png',
+                      width: 100,
+                      height: 100,
+                    ),
+                    Text(
+                      context.l10n.appTitle,
+                      style: const TextStyle(
+                        color: Colors.white,
+                        fontSize: 24,
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+            ),
+            ListTile(
+              leading: const Icon(Icons.add),
+              title: Text(context.l10n.newGame),
+              onTap: () {
+                Navigator.pop(context);
+                setState(() {
+                  if (mode == null) {
+                    setState(() {
+                      mode = PlayMode.modeFree;
+                    });
+                  }
+                  gamer.newGame();
+                  //mode = null;
+                });
+              },
+            ),
+            ListTile(
+              leading: const Icon(Icons.description),
+              title: Text(context.l10n.loadManual),
+              onTap: () {
+                Navigator.pop(context);
+                if (mode == null) {
+                  setState(() {
+                    mode = PlayMode.modeFree;
+                  });
+                }
+                loadFile();
+              },
+            ),
+            ListTile(
+              leading: const Icon(Icons.save),
+              title: Text(context.l10n.saveManual),
+              onTap: () {
+                Navigator.pop(context);
+                saveManual();
+              },
+            ),
+            ListTile(
+              leading: const Icon(Icons.copy),
+              title: Text(context.l10n.copyCode),
+              onTap: () {
+                Navigator.pop(context);
+                copyFen();
+              },
+            ),
+            ListTile(
+              leading: const Icon(Icons.settings),
+              title: Text(context.l10n.setting),
+              onTap: () {
+                Navigator.pop(context);
+                Navigator.push(
+                  context,
+                  MaterialPageRoute(
+                    builder: (BuildContext context) => const SettingPage(),
+                  ),
+                );
+              },
+            ),
+          ],
+        ),
+      ),
+      body: SafeArea(
+        child: Center(
+          child: mode == null ? selectMode() : PlayPage(mode: mode!),
+        ),
+      ),
+      bottomNavigationBar:
+          (mode == null || MediaQuery.of(context).size.width >= 980)
+              ? null
+              : GameBottomBar(mode!),
+    );
+  }
+
+  void editFen() {
+    Navigator.of(context).push<String>(
+      MaterialPageRoute(
+        builder: (BuildContext context) {
+          return GameWrapper(child: EditFen(fen: gamer.fenStr));
+        },
+      ),
+    ).then((fenStr) {
+      if (fenStr != null && fenStr.isNotEmpty) {
+        gamer.newGame(fenStr);
+      }
+    });
+  }
+
+  Future<void> applyFen() async {
+    final l10n = context.l10n;
+    ClipboardData? cData = await Clipboard.getData(Clipboard.kTextPlain);
+    String fenStr = cData?.text ?? '';
+    TextEditingController filenameController =
+        TextEditingController(text: fenStr);
+    filenameController.addListener(() {
+      fenStr = filenameController.text;
+    });
+
+    final confirmed = await MyDialog.confirm(
+      TextField(
+        controller: filenameController,
+      ),
+      buttonText: l10n.apply,
+      title: l10n.situationCode,
+    );
+    if (confirmed ?? false) {
+      if (RegExp(
+        r'^[abcnrkpABCNRKP\d]{1,9}(?:/[abcnrkpABCNRKP\d]{1,9}){9}(\s[wb]\s-\s-\s\d+\s\d+)?$',
+      ).hasMatch(fenStr)) {
+        gamer.newGame(fenStr);
+      } else {
+        MyDialog.alert(l10n.invalidCode);
+      }
+    }
+  }
+
+  void copyFen() {
+    Clipboard.setData(ClipboardData(text: gamer.fenStr));
+    MyDialog.alert(context.l10n.copySuccess);
+  }
+
+  Future<void> saveManual() async {
+    String content = gamer.manual.export();
+    String filename = '${DateTime.now().millisecondsSinceEpoch ~/ 1000}.pgn';
+    if (kIsWeb) {
+      await _saveManualWeb(content, filename);
+    } else if (Platform.isAndroid || Platform.isIOS) {
+      await _saveManualNative(content, filename);
+    }
+  }
+
+  Future<void> _saveManualNative(String content, String filename) async {
+    final result = await FilePicker.platform.saveFile(
+      dialogTitle: 'Save pgn file',
+      fileName: filename,
+      allowedExtensions: ['pgn'],
+    );
+    if (context.mounted && result != null) {
+      List<int> fData = gbk.encode(content);
+      await File('$result/$filename').writeAsBytes(fData);
+      MyDialog.toast(context.l10n.saveSuccess);
+    }
+  }
+
+  Future<void> _saveManualWeb(String content, String filename) async {
+    List<int> fData = gbk.encode(content);
+    var link = html.window.document.createElement('a');
+    link.setAttribute('download', filename);
+    link.style.display = 'none';
+    link.setAttribute('href', Uri.dataFromBytes(fData).toString());
+    html.window.document.getElementsByTagName('body')[0].append(link);
+    link.click();
+    await Future<void>.delayed(const Duration(seconds: 10));
+    link.remove();
+  }
+
+  Future<void> loadFile() async {
+    FilePickerResult? result = await FilePicker.platform.pickFiles(
+      type: FileType.custom,
+      allowedExtensions: ['pgn', 'PGN'],
+      withData: true,
+    );
+
+    if (result != null && result.count == 1) {
+      String content = gbk.decode(result.files.single.bytes!);
+      if (gamer.isStop) {
+        gamer.newGame();
+      }
+      gamer.loadPGN(content);
+    } else {
+      // User canceled the picker
+    }
+  }
+}

+ 18 - 0
lib/global.dart

@@ -0,0 +1,18 @@
+import 'dart:developer';
+
+import 'package:logging/logging.dart';
+
+export 'utils/core.dart';
+export 'theme.dart';
+
+final logger = Logger.root
+  ..onRecord.listen((record) {
+    log(
+      record.message,
+      time: record.time,
+      level: record.level.value,
+      error: record.error,
+      stackTrace: record.stackTrace,
+      sequenceNumber: record.sequenceNumber,
+    );
+  });

+ 17 - 0
lib/html/ffi.dart

@@ -0,0 +1,17 @@
+class Allocator {}
+
+class Opaque {}
+
+class Pointer<T> {}
+
+Pointer<T> malloc<T>(int byteCount, {int? alignment}) {
+  return Pointer<T>();
+}
+
+class Utf16 extends Opaque {}
+
+extension StringUtf16Pointer on String {
+  Pointer<Utf16> toNativeUtf16({Allocator? allocator}) {
+    return Pointer<Utf16>();
+  }
+}

+ 55 - 0
lib/html/win32.dart

@@ -0,0 +1,55 @@
+// ignore_for_file: non_constant_identifier_names, constant_identifier_names
+
+import 'ffi.dart';
+
+int PlaySound(Pointer<Utf16> pszSound, int hmod, int fdwSound) => 0;
+
+const NULL = 0;
+
+/// play synchronously (default)
+const SND_SYNC = 0x0000;
+
+/// play asynchronously
+const SND_ASYNC = 0x0001;
+
+/// silence (!default) if sound not found
+const SND_NODEFAULT = 0x0002;
+
+/// pszSound points to a memory file
+const SND_MEMORY = 0x0004;
+
+/// loop the sound until next sndPlaySound
+const SND_LOOP = 0x0008;
+
+/// don't stop any currently playing sound
+const SND_NOSTOP = 0x0010;
+
+/// don't wait if the driver is busy
+const SND_NOWAIT = 0x00002000;
+
+/// name is a registry alias
+const SND_ALIAS = 0x00010000;
+
+/// alias is a predefined ID
+const SND_ALIAS_ID = 0x00110000;
+
+/// name is file name
+const SND_FILENAME = 0x00020000;
+
+/// name is resource name or atom
+const SND_RESOURCE = 0x00040004;
+
+/// purge non-static events for task
+const SND_PURGE = 0x0040;
+
+/// look for application specific association
+const SND_APPLICATION = 0x0080;
+
+/// Generate a SoundSentry event with this sound
+const SND_SENTRY = 0x00080000;
+
+/// Treat this as a "ring" from a communications app - don't duck me
+const SND_RING = 0x00100000;
+
+/// Treat this as a system sound
+const SND_SYSTEM = 0x00200000;

+ 85 - 97
lib/main.dart

@@ -1,97 +1,85 @@
-import 'dart:io';
-
-import 'package:flutter/foundation.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_chinese_chees/routes.dart';
-import 'package:flutter_chinese_chees/theme.dart';
-import 'package:flutter_chinese_chees/utils/game_manager.dart';
-import 'package:window_manager/window_manager.dart';
-import 'package:flutter_localizations/flutter_localizations.dart';
-import 'package:shirne_dialog/shirne_dialog.dart';
-import 'l10n/generated/app_localizations.dart';
-
-/// Description: enter point
-/// Time       : 04/28/2023 Friday
-/// Author     : liuyuqi.gov@msn.cn
-void main() async {
-  WidgetsFlutterBinding.ensureInitialized();
-  // pc 平台,非 web 版本
-  if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux)) {
-    await windowManager.ensureInitialized();
-
-    const windowOptions = WindowOptions(
-        size: Size(1024, 720),
-        center: true,
-        backgroundColor: Colors.transparent,
-        skipTaskbar: false,
-        titleBarStyle: TitleBarStyle.normal);
-
-    windowManager.waitUntilReadyToShow(windowOptions, () async {
-      await windowManager.show();
-      await windowManager.focus();
-    });
-
-    windowManager.addListener(MainWindowListener());
-  }
-
-  final gamer = GameManager();
-  await gamer.init();
-
-  runApp(const MyApp());
-
-  if (Platform.isAndroid) {
-    SystemUiOverlayStyle systemUiOverlayStyle =
-        const SystemUiOverlayStyle(statusBarColor: Colors.transparent);
-    SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle);
-  }
-}
-
-class MainWindowListener extends WindowListener {
-  @override
-  void onWindowClose() {
-    super.onWindowClose();
-    GameManager.instance.engine?.dispose();
-  }
-}
-
-class MyApp extends StatelessWidget {
-  const MyApp({super.key});
-
-  // This widget is the root of your application.
-  @override
-  Widget build(BuildContext context) {
-    return MaterialApp(
-      title: '中国象棋',
-      debugShowCheckedModeBanner: false,
-      onGenerateRoute: Routes.onGenerateRoute,
-      initialRoute: Routes.home,
-      onGenerateTitle: (BuildContext context) {
-        if (!kIsWeb &&
-            (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
-          windowManager.setTitle(context.l10n.appTitle);
-        }
-        return context.l10n.appTitle;
-      },
-      navigatorKey: MyDialog.navigatorKey,
-      localizationsDelegates: const [
-        AppLocalizations.delegate,
-        ShirneDialogLocalizations.delegate,
-        GlobalMaterialLocalizations.delegate,
-        GlobalWidgetsLocalizations.delegate,
-        GlobalCupertinoLocalizations.delegate,
-      ],
-      supportedLocales: const [
-        Locale('en', ''),
-        Locale('zh', 'CN'),
-      ],
-      theme: AppTheme.createTheme(),
-      highContrastTheme: AppTheme.createTheme(isHighContrast: true),
-      darkTheme: AppTheme.createTheme(isDark: true),
-      highContrastDarkTheme: AppTheme.createTheme(
-        isDark: true,
-        isHighContrast: true,
-      ),
-    );
-  }
-}
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:shirne_dialog/shirne_dialog.dart';
+import 'package:window_manager/window_manager.dart';
+
+import 'global.dart';
+import 'l10n/generated/app_localizations.dart';
+import 'models/game_manager.dart';
+import 'widgets/game_wrapper.dart';
+import 'game_board.dart';
+
+void main() async {
+  WidgetsFlutterBinding.ensureInitialized();
+  
+  if (!kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux)) {
+    await windowManager.ensureInitialized();
+
+    const windowOptions = WindowOptions(
+      size: Size(1024, 720),
+      center: true,
+      backgroundColor: Colors.transparent,
+      skipTaskbar: false,
+      titleBarStyle: TitleBarStyle.normal,
+    );
+    windowManager.waitUntilReadyToShow(windowOptions, () async {
+      await windowManager.show();
+      await windowManager.focus();
+    });
+    windowManager.addListener(MainWindowListener());
+  }
+  final gamer = GameManager();
+  await gamer.init();
+  runApp(const MainApp());
+}
+
+class MainWindowListener extends WindowListener {
+  @override
+  void onWindowClose() {
+    GameManager.instance.engine?.dispose();
+  }
+}
+
+class MainApp extends StatelessWidget {
+  const MainApp({Key? key}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      title: '',
+      onGenerateTitle: (BuildContext context) {
+        if (!kIsWeb &&
+            (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
+          windowManager.setTitle(context.l10n.appTitle);
+        }
+        return context.l10n.appTitle;
+      },
+      navigatorKey: MyDialog.navigatorKey,
+      localizationsDelegates: const [
+        AppLocalizations.delegate,
+        ShirneDialogLocalizations.delegate,
+        GlobalMaterialLocalizations.delegate,
+        GlobalWidgetsLocalizations.delegate,
+        GlobalCupertinoLocalizations.delegate,
+      ],
+      supportedLocales: const [
+        Locale('en', ''),
+        Locale('zh', 'CN'),
+      ],
+      theme: AppTheme.createTheme(),
+      highContrastTheme: AppTheme.createTheme(isHighContrast: true),
+      darkTheme: AppTheme.createTheme(isDark: true),
+      highContrastDarkTheme: AppTheme.createTheme(
+        isDark: true,
+        isHighContrast: true,
+      ),
+      home: const GameWrapper(
+        isMain: true,
+        child: GameBoard(),
+      ),
+    );
+  }
+}

+ 116 - 0
lib/models/chess_skin.dart

@@ -0,0 +1,116 @@
+import 'dart:convert';
+import 'package:cchess/cchess.dart';
+import 'package:flutter/services.dart';
+
+import 'package:flutter/material.dart';
+
+import '../global.dart';
+import 'game_manager.dart';
+
+/// 棋子和棋盘的皮肤
+class ChessSkin {
+  GameManager manager;
+  String folder = "";
+
+  double width = 521;
+  double height = 577;
+  double size = 57;
+  Offset offset = const Offset(4, 3);
+
+  String board = "board.jpg";
+  String blank = "blank.png";
+  Map<String, String> redMap = {
+    "K": "rk.gif",
+    "A": "ra.png",
+    "B": "rb.png",
+    "C": "rc.png",
+    "N": "rn.png",
+    "R": "rr.png",
+    "P": "rp.png"
+  };
+  Map<String, String> blackMap = {
+    "k": "bk.png",
+    "a": "ba.png",
+    "b": "bb.png",
+    "c": "bc.png",
+    "n": "bn.png",
+    "r": "br.png",
+    "p": "bp.png"
+  };
+
+  late ValueNotifier<bool> readyNotifier;
+
+  ChessSkin(this.folder, this.manager) {
+    readyNotifier = ValueNotifier(false);
+    String jsonfile = "assets/skins/$folder/config.json";
+    rootBundle.loadString(jsonfile).then((String fileContents) {
+      loadJson(fileContents);
+    }).catchError((error) {
+      logger.warning('Skin file $jsonfile error', error);
+      readyNotifier.value = true;
+    });
+  }
+
+  void loadJson(String content) {
+    Map<String, dynamic> json = jsonDecode(content);
+    json.forEach((key, value) {
+      switch (key) {
+        case 'width':
+          width = value.toDouble();
+          break;
+        case 'height':
+          height = value.toDouble();
+          break;
+        case 'size':
+          size = value.toDouble();
+          break;
+        case 'board':
+          board = value.toString();
+          break;
+        case 'blank':
+          blank = value.toString();
+          break;
+        case 'offset':
+          offset = Offset(value['dx'].toDouble(), value['dy'].toDouble());
+          break;
+        case 'red':
+          redMap = value.cast<String, String>();
+          break;
+        case 'black':
+          blackMap = value.cast<String, String>();
+          break;
+      }
+    });
+    readyNotifier.value = true;
+  }
+
+  String get boardImage => "assets/skins/$folder/$board";
+
+  String getRedChess(String code) {
+    if (!redMap.containsKey(code.toUpperCase())) {
+      logger.info('Code error: $code');
+      return "assets/skins/$folder/$blank";
+    }
+    return "assets/skins/$folder/${redMap[code.toUpperCase()]}";
+  }
+
+  String getBlackChess(String code) {
+    if (!blackMap.containsKey(code.toLowerCase())) {
+      logger.info('Code error: $code');
+      return "assets/skins/$folder/$blank";
+    }
+    return "assets/skins/$folder/${blackMap[code.toLowerCase()]}";
+  }
+
+  Alignment getAlign(ChessPos? pos) {
+    if (pos == null) {
+      return const Alignment(1.2, 0);
+    }
+    final x = ((pos.x * size + offset.dx) * 2) / (width - size) - 1;
+    final y = ((((9 - pos.y) * size + offset.dy) * 2) / (height - size) - 1);
+    return Alignment(
+      manager.isFlip ? -x : x,
+      manager.isFlip ? -y : y,
+    );
+  }
+}

+ 161 - 0
lib/models/engine.dart

@@ -0,0 +1,161 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+
+import '../foundation/customer_notifier.dart';
+import '../global.dart';
+import 'engine_type.dart';
+
+class Engine extends CustomNotifier<String> {
+  Engine(this.engine);
+
+  final EngineType engine;
+  List<Completer<String>> readyCompleters = [];
+  Completer<bool>? stopCompleter;
+  bool ready = false;
+
+  Process? process;
+
+  Future<Process?> init() {
+    ready = false;
+    if (!isSupportEngine) {
+      return Future.value(null);
+    }
+
+    String path = '${Directory.current.path}/assets/engines/${engine.path}';
+    if (!File(path).existsSync()) {
+      path =
+          '${Directory.current.path}/data/flutter_assets/assets/engines/${engine.path}';
+    }
+    return Process.start(path, [], mode: ProcessStartMode.normal).then((value) {
+      process = value;
+      ready = true;
+      process?.stdout.listen(onMessage);
+      process?.stdin.writeln(engine.scheme);
+      return process!;
+    });
+  }
+
+  static bool get isSupportEngine {
+    if (kIsWeb) {
+      return false;
+    }
+    if (Platform.isWindows) {
+      return true;
+    }
+
+    return false;
+  }
+
+  void onMessage(List<int> event) {
+    String lines = String.fromCharCodes(event).trim();
+    lines.split('\n').forEach((line) {
+      line = line.trim();
+      if (line == 'bye') {
+        ready = false;
+        process = null;
+        if (stopCompleter != null && !stopCompleter!.isCompleted) {
+          stopCompleter?.complete(true);
+        }
+      } else if (line.isNotEmpty && hasListeners) {
+        if (line.startsWith('nobestmove') || line.startsWith('bestmove ')) {
+          if (stopCompleter != null && !stopCompleter!.isCompleted) {
+            stopCompleter!.complete(true);
+          } else if (readyCompleters.isNotEmpty) {
+            readyCompleters.removeAt(0).complete(line);
+          }
+        }
+        notifyListeners(line);
+      }
+    });
+  }
+
+  Future<String> requestMove(
+    String fen, {
+    int time = 0,
+    int increment = 0,
+    String type = '',
+    int depth = 0,
+    int nodes = 0,
+  }) {
+    Completer<String> readyCompleter = Completer();
+    stop().then((b) {
+      if (b) {
+        readyCompleters.add(readyCompleter);
+        position(fen);
+        go(
+          time: time,
+          increment: increment,
+          type: type,
+          depth: depth,
+          nodes: nodes,
+        );
+      } else {
+        readyCompleter.complete('isbusy');
+      }
+    });
+    return readyCompleter.future;
+  }
+
+  void sendCommand(String command) {
+    if (!ready) {
+      logger.info('Engine is not ready');
+      return;
+    }
+    logger.info('command: $command');
+    process?.stdin.writeln(command);
+  }
+
+  void setOption(String option) {
+    sendCommand('setoption $option');
+  }
+
+  void position(String fen) {
+    sendCommand('position fen $fen');
+  }
+
+  void banMoves(List<String> moveList) {
+    sendCommand('banmoves ${moveList.join(' ')}');
+  }
+
+  void go({
+    int time = 0,
+    int increment = 0,
+    String type = '',
+    int depth = 0,
+    int nodes = 0,
+  }) {
+    if (time > 0) {
+      sendCommand('go $type time $time increment $increment');
+    } else if (depth > 0) {
+      sendCommand('go depth $depth');
+    } else if (depth < 0) {
+      sendCommand('go depth infinite');
+    } else if (nodes > 0) {
+      sendCommand('go nodes $depth');
+    }
+  }
+
+  void ponderHit(String type) {
+    sendCommand('ponderhit $type');
+  }
+
+  void probe(String fen) {
+    sendCommand('probe $fen');
+  }
+
+  Future<bool> stop() {
+    if (!ready || (stopCompleter != null && !stopCompleter!.isCompleted)) {
+      return Future.value(false);
+    }
+    stopCompleter = Completer();
+    sendCommand('stop');
+    return stopCompleter!.future;
+  }
+
+  void quit() {
+    sendCommand('quit');
+    ready = false;
+  }
+}

+ 6 - 0
lib/models/engine_level.dart

@@ -0,0 +1,6 @@
+
+class EngineLevel{
+  static const learn = 10;
+  static const middle = 11;
+  static const master = 12;
+}

+ 19 - 0
lib/models/engine_type.dart

@@ -0,0 +1,19 @@
+enum EngineType {
+  elephantEye('eleeye/eleeye.exe'),
+  pikafish('pikafish/pikafish.exe', 'uci'),
+  builtIn();
+
+  final String path;
+  final String scheme;
+
+  const EngineType([this.path = '', this.scheme = 'ucci']);
+
+  static EngineType? fromName(String? name) {
+    for (var e in values) {
+      if (e.name == name) {
+        return e;
+      }
+    }
+    return null;
+  }
+}

+ 73 - 0
lib/models/game_event.dart

@@ -0,0 +1,73 @@
+enum GameEventType {
+  move,
+  engine,
+  player,
+  load,
+  result,
+  lock,
+  step,
+  flip,
+}
+
+abstract class GameEvent<T> {
+  GameEventType type;
+  T? data;
+  GameEvent(this.type, [this.data]);
+
+  @override
+  String toString() => '$type $data';
+
+  static GameEventType? eventType(Type t) {
+    switch (t) {
+      case GameMoveEvent:
+        return GameEventType.move;
+      case GameEngineEvent:
+        return GameEventType.engine;
+      case GamePlayerEvent:
+        return GameEventType.player;
+      case GameLoadEvent:
+        return GameEventType.load;
+      case GameResultEvent:
+        return GameEventType.result;
+      case GameLockEvent:
+        return GameEventType.lock;
+      case GameStepEvent:
+        return GameEventType.step;
+      case GameFlipEvent:
+        return GameEventType.flip;
+    }
+    return null;
+  }
+}
+
+class GameMoveEvent extends GameEvent<String> {
+  GameMoveEvent(String move) : super(GameEventType.move, move);
+}
+
+class GameEngineEvent extends GameEvent<String> {
+  GameEngineEvent(String move) : super(GameEventType.engine, move);
+}
+
+class GamePlayerEvent extends GameEvent<int> {
+  GamePlayerEvent(int hand) : super(GameEventType.player, hand);
+}
+
+class GameLoadEvent extends GameEvent<int> {
+  GameLoadEvent(int state) : super(GameEventType.load, state);
+}
+
+class GameResultEvent extends GameEvent<String> {
+  GameResultEvent(String move) : super(GameEventType.result, move);
+}
+
+class GameLockEvent extends GameEvent<bool> {
+  GameLockEvent(bool isLock) : super(GameEventType.lock, isLock);
+}
+
+class GameStepEvent extends GameEvent<String> {
+  GameStepEvent(String step) : super(GameEventType.step, step);
+}
+
+class GameFlipEvent extends GameEvent<bool> {
+  GameFlipEvent(bool isFlip) : super(GameEventType.flip, isFlip);
+}

+ 68 - 0
lib/models/game_setting.dart

@@ -0,0 +1,68 @@
+import 'dart:convert';
+
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'engine_type.dart';
+
+class GameSetting {
+  static SharedPreferences? storage;
+  static GameSetting? _instance;
+  static const cacheKey = 'setting';
+
+  EngineType robotType = EngineType.builtIn;
+  int robotLevel = 10;
+  bool sound = true;
+  double soundVolume = 1;
+
+  GameSetting({
+    this.robotType = EngineType.builtIn,
+    this.robotLevel = 10,
+    this.sound = true,
+    this.soundVolume = 1,
+  });
+
+  GameSetting.fromJson(String? jsonStr) {
+    if (jsonStr == null || jsonStr.isEmpty) return;
+    Map<String, dynamic> json = jsonDecode(jsonStr);
+    if (json.containsKey('robotType')) {
+      robotType = EngineType.fromName(json['robotType']) ?? EngineType.builtIn;
+    }
+    if (json.containsKey('robotLevel')) {
+      robotLevel = json['robotLevel'];
+      if (robotLevel < 10 || robotLevel > 12) {
+        robotLevel = 10;
+      }
+    }
+    if (json.containsKey('sound')) {
+      sound = json['sound'];
+    }
+    if (json.containsKey('soundVolume')) {
+      soundVolume = json['soundVolume'];
+    }
+  }
+
+  static Future<GameSetting> getInstance() async {
+    _instance ??= await GameSetting.init();
+    return _instance!;
+  }
+
+  static Future<GameSetting> init() async {
+    storage ??= await SharedPreferences.getInstance();
+    String? json = storage!.getString(cacheKey);
+    return GameSetting.fromJson(json);
+  }
+
+  Future<bool> save() async {
+    storage ??= await SharedPreferences.getInstance();
+    storage!.setString(cacheKey, toString());
+    return true;
+  }
+
+  @override
+  String toString() => jsonEncode({
+        'robotType': robotType.name,
+        'robotLevel': robotLevel,
+        'sound': sound,
+        'soundVolume': soundVolume,
+      });
+}

+ 11 - 0
lib/models/play_mode.dart

@@ -0,0 +1,11 @@
+
+
+class PlayMode{
+  final String mode;
+
+  static const modeRobot = PlayMode('robot');
+  static const modeOnline = PlayMode('online');
+  static const modeFree = PlayMode('free');
+
+  const PlayMode(this.mode);
+}

+ 56 - 0
lib/models/player.dart

@@ -0,0 +1,56 @@
+import 'package:chinese_chess/models/game_event.dart';
+
+import '../driver/player_driver.dart';
+import '../global.dart';
+import 'game_manager.dart';
+
+class Player {
+  GameManager manager;
+  String lastPosition = '';
+  String team = 'r';
+  String title = '红方';
+
+  int totalTime = 0;
+  int stepTime = 0;
+
+  late DriverType _driverType;
+  late PlayerDriver driver;
+
+  Player(
+    this.team,
+    this.manager, {
+    this.title = '',
+    DriverType type = DriverType.user,
+  }) {
+    _driverType = type;
+  }
+
+  set driverType(DriverType type) {
+    _driverType = type;
+    driver = PlayerDriver.createDriver(this, _driverType);
+  }
+
+  DriverType get driverType => _driverType;
+
+  bool get isUser => _driverType == DriverType.user;
+
+  bool get isRobot => _driverType == DriverType.robot;
+
+  bool get canBacktrace => driver.canBacktrace;
+
+  // 通知界面,从界面上过来的着法不需要调用
+  Future<String> onMove(String move) {
+    logger.info('onmove');
+    manager.add(GameMoveEvent(move));
+
+    return Future.value(move);
+  }
+
+  Future<bool> onDraw() => driver.tryDraw();
+
+  Future<String?> move() => driver.move();
+
+  void completeMove(String move) {
+    driver.completeMove(move);
+  }
+}

+ 51 - 0
lib/models/sound.dart

@@ -0,0 +1,51 @@
+import 'dart:async';
+
+//import 'package:flutter/services.dart';
+import 'package:audioplayers/audioplayers.dart';
+//import 'package:soundpool/soundpool.dart';
+
+import 'game_setting.dart';
+
+class Sound {
+  static const move = 'move2.wav';
+  static const capture = 'capture2.wav';
+  static const check = 'check2.wav';
+  static const click = 'click.wav';
+  static const newGame = 'newgame.wav';
+  static const loose = 'loss.wav';
+  static const win = 'win.wav';
+  static const draw = 'draw.wav';
+  static const illegal = 'illegal.wav';
+
+  static AudioPlayer audioPlayer = AudioPlayer()
+    ..audioCache = AudioCache(prefix: 'assets/sounds/');
+
+  // static final Soundpool pool = Soundpool.fromOptions(
+  //     options: const SoundpoolOptions(streamType: StreamType.notification));
+
+  static GameSetting? setting;
+
+  static Future<bool> play(String id) async {
+    setting ??= await GameSetting.getInstance();
+    if (!setting!.sound) return false;
+
+    await audioPlayer.play(AssetSource(id), volume: setting!.soundVolume);
+
+    // final soundId = await loadAsset(id);
+    // await pool.play(soundId);
+    return true;
+  }
+
+  // static final Map<String, Completer<int>> _loaders = {};
+  // static Future<int> loadAsset(String id) async {
+  //   if (_loaders.containsKey(id)) {
+  //     return _loaders[id]!.future;
+  //   }
+  //   _loaders[id] = Completer<int>();
+  //   rootBundle.load("assets/sounds/$id").then((value) {
+  //     _loaders[id]!.complete(pool.load(value));
+  //   });
+
+  //   return _loaders[id]!.future;
+  // }
+}

+ 160 - 0
lib/setting.dart

@@ -0,0 +1,160 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:shirne_dialog/shirne_dialog.dart';
+
+import 'global.dart';
+import 'models/engine_type.dart';
+import 'models/engine_level.dart';
+import 'models/game_setting.dart';
+
+/// 设置页
+class SettingPage extends StatefulWidget {
+  const SettingPage({Key? key}) : super(key: key);
+
+  @override
+  State<SettingPage> createState() => _SettingPageState();
+}
+
+class _SettingPageState extends State<SettingPage> {
+  GameSetting? setting;
+
+  @override
+  void initState() {
+    super.initState();
+    GameSetting.getInstance().then(
+      (value) => setState(() {
+        setting = value;
+      }),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    double width = 500;
+    if (MediaQuery.of(context).size.width < width) {
+      width = MediaQuery.of(context).size.width;
+    }
+
+    return Scaffold(
+      appBar: AppBar(
+        title: Text(context.l10n.settingTitle),
+        actions: [
+          TextButton(
+            onPressed: () {
+              setting?.save().then((v) {
+                Navigator.pop(context);
+                MyDialog.toast('保存成功', iconType: IconType.success);
+              });
+            },
+            child: const Text(
+              '保存',
+              style: TextStyle(color: Colors.white),
+            ),
+          ),
+        ],
+      ),
+      body: Center(
+        child: setting == null
+            ? const CircularProgressIndicator()
+            : Container(
+                width: width,
+                padding: const EdgeInsets.all(20),
+                child: SingleChildScrollView(
+                  child: ListBody(
+                    children: [
+                      ListTile(
+                        title: const Text('AI类型'),
+                        trailing: CupertinoSegmentedControl(
+                          onValueChanged: (value) {
+                            if (value == null) return;
+                            setState(() {
+                              setting!.robotType = value as EngineType;
+                            });
+                          },
+                          groupValue: setting!.robotType,
+                          children: const {
+                            EngineType.builtIn: Padding(
+                              padding: EdgeInsets.symmetric(
+                                horizontal: 10,
+                              ),
+                              child: Text('内置引擎'),
+                            ),
+                            EngineType.elephantEye: Padding(
+                              padding: EdgeInsets.symmetric(
+                                horizontal: 10,
+                              ),
+                              child: Text('elephantEye'),
+                            ),
+                            EngineType.pikafish: Padding(
+                              padding: EdgeInsets.symmetric(
+                                horizontal: 10,
+                              ),
+                              child: Text('皮卡鱼'),
+                            ),
+                          },
+                        ),
+                      ),
+                      ListTile(
+                        title: const Text('AI级别'),
+                        trailing: CupertinoSegmentedControl(
+                          onValueChanged: (value) {
+                            if (value == null) return;
+                            setState(() {
+                              setting!.robotLevel = value as int;
+                            });
+                          },
+                          groupValue: setting!.robotLevel,
+                          children: const {
+                            EngineLevel.learn: Padding(
+                              padding: EdgeInsets.symmetric(
+                                horizontal: 10,
+                              ),
+                              child: Text('初级'),
+                            ),
+                            EngineLevel.middle: Padding(
+                              padding: EdgeInsets.symmetric(
+                                horizontal: 10,
+                              ),
+                              child: Text('中级'),
+                            ),
+                            EngineLevel.master: Padding(
+                              padding: EdgeInsets.symmetric(
+                                horizontal: 10,
+                              ),
+                              child: Text('大师'),
+                            )
+                          },
+                        ),
+                      ),
+                      ListTile(
+                        title: const Text('游戏声音'),
+                        trailing: CupertinoSwitch(
+                          value: setting!.sound,
+                          onChanged: (v) {
+                            setState(() {
+                              setting!.sound = v;
+                            });
+                          },
+                        ),
+                      ),
+                      ListTile(
+                        title: const Text('游戏音量'),
+                        trailing: CupertinoSlider(
+                          value: setting!.soundVolume,
+                          min: 0,
+                          max: 1,
+                          onChanged: (v) {
+                            setState(() {
+                              setting!.soundVolume = v;
+                            });
+                          },
+                        ),
+                      ),
+                    ],
+                  ),
+                ),
+              ),
+      ),
+    );
+  }
+}

+ 6 - 0
lib/utils/core.dart

@@ -0,0 +1,6 @@
+import 'package:chinese_chess/l10n/generated/app_localizations.dart';
+import 'package:flutter/cupertino.dart';
+
+extension ContextExtension on BuildContext {
+  AppLocalizations get l10n => AppLocalizations.of(this)!;
+}

+ 87 - 0
lib/widgets/game_wrapper.dart

@@ -0,0 +1,87 @@
+import 'dart:async';
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:shirne_dialog/shirne_dialog.dart';
+import 'package:flutter/material.dart';
+import 'package:window_manager/window_manager.dart';
+
+import '../global.dart';
+import '../models/game_manager.dart';
+
+class GameWrapper extends StatefulWidget {
+  final Widget child;
+  final bool isMain;
+
+  const GameWrapper({Key? key, required this.child, this.isMain = false})
+      : super(key: key);
+
+  static GameWrapperState of(BuildContext context) {
+    return context.findAncestorStateOfType<GameWrapperState>()!;
+  }
+
+  @override
+  State<GameWrapper> createState() => GameWrapperState();
+}
+
+class GameWrapperState extends State<GameWrapper> with WindowListener {
+  final GameManager gamer = GameManager();
+
+  @override
+  void initState() {
+    super.initState();
+    if (widget.isMain) {
+      if (!kIsWeb &&
+          (Platform.isWindows || Platform.isMacOS || Platform.isLinux)) {
+        windowManager.addListener(this);
+      }
+    }
+  }
+
+  @override
+  void dispose() {
+    if (widget.isMain) {
+      gamer.dispose();
+    }
+    super.dispose();
+  }
+
+  @override
+  void onWindowClose() {
+    logger.info('gamer destroy');
+    windowManager.removeListener(this);
+    gamer.dispose();
+    GameManager.instance.engine?.dispose();
+  }
+
+  Future<bool> _willPop() async {
+    logger.info('onwillpop');
+    final sure = await MyDialog.confirm(
+      context.l10n.exitNow,
+      buttonText: context.l10n.yesExit,
+      cancelText: context.l10n.dontExit,
+    );
+
+    if (sure ?? false) {
+      logger.info('gamer destroy');
+      gamer.dispose();
+      //gamer = null;
+      await Future.delayed(const Duration(milliseconds: 200));
+      return true;
+    }
+    return false;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    Size size = MediaQuery.of(context).size;
+    if (size.width < 541) {
+      gamer.scale = (size.width - 20) / 521;
+    } else {
+      gamer.scale = 1;
+    }
+    return WillPopScope(
+      onWillPop: widget.isMain ? _willPop : null,
+      child: widget.child,
+    );
+  }
+}

+ 47 - 0
lib/widgets/list_item.dart

@@ -0,0 +1,47 @@
+import 'package:flutter/material.dart';
+
+class ListItem extends StatelessWidget {
+  final Widget title;
+  final Widget? subtitle;
+  final Widget? leading;
+  final Widget? trailing;
+  final CrossAxisAlignment titleAlign;
+
+  const ListItem({
+    Key? key,
+    required this.title,
+    this.subtitle,
+    this.leading,
+    this.trailing,
+    this.titleAlign = CrossAxisAlignment.start,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    List<Widget> children = [];
+    List<Widget> titles = [title];
+    if (leading != null) {
+      children.add(leading!);
+    }
+    if (subtitle != null) {
+      titles.add(subtitle!);
+    }
+    children.add(
+      Expanded(
+        flex: 1,
+        child: Column(
+          crossAxisAlignment: titleAlign,
+          children: titles,
+        ),
+      ),
+    );
+    if (trailing != null) {
+      children.add(trailing!);
+    }
+    return Row(
+      mainAxisAlignment: MainAxisAlignment.start,
+      crossAxisAlignment: CrossAxisAlignment.center,
+      children: children,
+    );
+  }
+}

+ 148 - 0
lib/widgets/tab_card.dart

@@ -0,0 +1,148 @@
+import 'package:flutter/material.dart';
+
+class TabCard extends StatefulWidget {
+  final List<Widget> titles;
+  final List<Widget> bodies;
+  final MainAxisAlignment titleAlign;
+  final Alignment bodyAlign;
+  final FlexFit titleFit;
+  final EdgeInsetsGeometry? titlePadding;
+  final BoxDecoration titleDecoration;
+  final BoxDecoration titleActiveDecoration;
+  final Axis direction;
+
+  const TabCard({
+    Key? key,
+    required this.titles,
+    required this.bodies,
+    this.direction = Axis.vertical,
+    this.titleAlign = MainAxisAlignment.start,
+    this.titlePadding,
+    this.titleDecoration =
+        const BoxDecoration(color: Color.fromRGBO(0, 0, 0, .1)),
+    this.titleActiveDecoration = const BoxDecoration(color: Colors.white),
+    this.titleFit = FlexFit.loose,
+    this.bodyAlign = Alignment.topLeft,
+  }) : super(key: key);
+
+  @override
+  State<TabCard> createState() => TabCardState();
+}
+
+class TabCardState extends State<TabCard> {
+  int index = 0;
+  late List<Widget> titles;
+
+  late ValueNotifier<int> onTabChange;
+
+  @override
+  void initState() {
+    super.initState();
+    onTabChange = ValueNotifier<int>(index);
+
+    titles = widget.titles.map<Widget>((e) {
+      int curIndex = widget.titles.indexOf(e);
+      return Flexible(
+        flex: widget.titleFit == FlexFit.tight ? 0 : 1,
+        fit: widget.titleFit,
+        child: TabCardTitleItem(
+          myIndex: curIndex,
+          child: e,
+        ),
+      );
+    }).toList();
+  }
+
+  void updateIndex(int i) {
+    setState(() {
+      index = i;
+    });
+    onTabChange.value = i;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Flex(
+      direction: widget.direction,
+      children: [
+        Container(
+          decoration: widget.titleDecoration,
+          child: Flex(
+            direction: widget.direction == Axis.horizontal
+                ? Axis.vertical
+                : Axis.horizontal,
+            mainAxisSize: MainAxisSize.max,
+            mainAxisAlignment: widget.titleAlign,
+            children: titles,
+          ),
+        ),
+        Expanded(
+          child: IndexedStack(
+            index: index,
+            alignment: widget.bodyAlign,
+            sizing: StackFit.expand,
+            children: widget.bodies,
+          ),
+        ),
+      ],
+    );
+  }
+}
+
+class TabCardTitleItem extends StatefulWidget {
+  final int myIndex;
+  final Widget child;
+
+  const TabCardTitleItem({Key? key, required this.myIndex, required this.child})
+      : super(key: key);
+
+  @override
+  State<TabCardTitleItem> createState() => TabCardTitleItemState();
+}
+
+class TabCardTitleItemState extends State<TabCardTitleItem> {
+  bool isActive = false;
+  TabCardState? tabCard;
+
+  @override
+  void initState() {
+    super.initState();
+    tabCard = context.findRootAncestorStateOfType<TabCardState>();
+    if (tabCard != null) {
+      if (widget.myIndex == tabCard!.index) {
+        isActive = true;
+      }
+      tabCard?.onTabChange.addListener(indexListener);
+    }
+  }
+
+  void indexListener() {
+    setState(() {
+      isActive = tabCard!.index == widget.myIndex;
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      onTap: () {
+        tabCard?.updateIndex(widget.myIndex);
+      },
+      child: AnimatedContainer(
+        duration: const Duration(milliseconds: 300),
+        curve: Curves.easeOutQuint,
+        padding: tabCard!.widget.titlePadding,
+        decoration: isActive
+            ? tabCard!.widget.titleActiveDecoration
+            : tabCard!.widget.titleDecoration,
+        child: Center(child: widget.child),
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    tabCard?.onTabChange.removeListener(indexListener);
+    super.dispose();
+  }
+}