Flutter从头到尾设计一款简单的五子棋游戏(五) | UI代码

168 阅读5分钟

image.png 开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第14天,点击查看活动详情

前言

在前面几篇的文章中,我们已经完成了所有设计模式的代码的实现,现在我们将把这些模式代码与FlutterUI进行衔接,本次的项目就大功告成了。

不熟悉这个项目的同学可以往前看几期,注意,我们这次的设计是完全使用Flutter自带的包进行设计,没有使用第三方包。

正文

1. 代码结构

lib
├─ai // AI,即下棋的对手
├─bridge // 桥接模式
├─factory // 工厂模式
├─flyweight // 享元模式 
├─memorandum // 备忘模式
├─state // 状态模式
├─utils // 工具类
└─viewModel // 视图数据管理

在这里,我们没有按照比较常用的MVVM模型对组件数据进行严格拆分,而是将每一个模式都放在了一个文件夹中,方便学习。

2. 详细代码

2.1 主界面

image.png

主界面如上,中间有一个棋盘组件,下方有三个功能按钮。布局很简单。

这里我们主要使用Column对界面进行布局。

代码如下:

return Scaffold(
  appBar: AppBar(
    elevation: 0,
    backgroundColor: _themeFactory!.getTheme().getThemeColor(),
    title: Text("南瓜五子棋"),
    actions: [
      IconButton(// 控制主题
          onPressed: () {
            setState(() {
              if (_themeFactory is BlackThemeFactory) {
                currentLight = lightOn;
                _themeFactory = BlueThemeFactory();
              } else {
                currentLight = lightOff;
                _themeFactory = BlackThemeFactory();
              }
            });
          },
          icon: currentLight!),
      IconButton( // 控制自己下棋的棋子形状
          onPressed: () {
            setState(() {
              if (currentShape == circle) {
                currentShape = rect;
              } else {
                currentShape = circle;
              }
            });
          },
          icon: currentShape!),
    ],
  ),
  body: Container(
    decoration: new BoxDecoration(
        gradient: new LinearGradient(
            colors: [
          _themeFactory!.getTheme().getThemeColor(), // 获取需要主题
          Colors.white,
        ],
            stops: [
          0.0,
          1
        ],
            begin: FractionalOffset.topCenter,
            end: FractionalOffset.bottomCenter,
            tileMode: TileMode.repeated)),
    child: Center(
      child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          mainAxisSize: MainAxisSize.max,
          children: <Widget>[
            Padding(
              padding: EdgeInsets.only(top: 14, bottom: 30),
              child: Text(
                _viewModel.state,
                style: TextStyle(color: Colors.white),
              ),
            ),
            GestureDetector(
                onTapDown: (topDownDetails) {
                  var position = topDownDetails.localPosition;
                  Chess chess = _viewModel.play(currentShape == circle);
                  setState(() {
                    ChessPainter._position =
                        Position(position.dx, position.dy, chess);
                  });
                },
                child: Stack(
                  children: [
                    CustomPaint(
                      size: Size(width, width),
                      painter: CheckerBoardPainter(), // 绘制棋盘
                    ),
                    CustomPaint(
                      size: Size(width, width),
                      painter: ChessPainter(turnAi),  // 绘制棋子
                    )
                  ],
                )),
            Padding(
              padding: const EdgeInsets.only(top: 16.0),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  IconButton(
                      onPressed: () {
                        if (_viewModel.undo()) {// 悔棋
                          _originator.undo();
                          Ai.getInstance().init();
                          for (Position po in _originator.state) {
                            Ai.getInstance().addChessman(
                                po.dx ~/ (width / 15),
                                po.dy ~/ (width / 15),
                                po.chess is WhiteChess ? 1 : -1);
                          }
                          setState(() {});
                        } else {
                          TipsDialog.show(context, "提示", "现阶段不能悔棋");
                        }
                      },
                      icon: Icon(Icons.undo)),
                  IconButton(
                      onPressed: () {
                        if (_viewModel.surrender()) {
                          TipsDialog.showByChoose(
                              context, "提示", "是否要投降并重新开局?", "是", "否",
                              (value) {
                            if (value) {
                              setState(() {
                                ChessPainter._position = null;
                                _originator.clean();
                                _viewModel.reset();
                                Ai.getInstance().init();
                              });
                            }
                            Navigator.pop(context);
                          });
                        } else {
                          TipsDialog.show(context, "提示", "现阶段不能投降");
                        }
                      },
                      icon: Icon(
                        Icons.sports_handball,
                        color: Colors.deepPurple,
                      )),
                  IconButton(
                      onPressed: () {
                        TipsDialog.showByChoose(
                            context, "提示", "是否重新开局?", "是", "否",
                            (value) {
                          if (value) {
                            setState(() {
                              ChessPainter._position = null;
                              _originator.clean();
                              _viewModel.reset();
                              Ai.getInstance().init();
                            });
                          }
                          Navigator.pop(context);
                        });
                      },
                      icon: Icon(
                        Icons.restart_alt,
                        color: Colors.indigo,
                      )),
                ],
              ),
            ),
          ]),
    ),
  ),
);

2.2 棋盘布局

这里我们使用自定义的CustomPainter来进行实现。代码主要参考了Flutter实战第二版,感兴趣的同学可以前往看看。

具体的代码如下:

class CheckerBoardPainter extends CustomPainter {
  static List<CrossOverBean> _crossOverBeanList = [];
  static int _state = 0;

  @override
  void paint(Canvas canvas, Size size) {
    double mWidth = size.width / 15;
    double mHeight = size.height / 15;
    var mPaint = Paint();

    _crossOverBeanList.clear();
    //重绘下整个界面的画布北京颜色
    //设置画笔,画棋盘背景
    mPaint
      ..isAntiAlias = true //抗锯齿
      ..style = PaintingStyle.fill //填充
      ..color = Color(0x77cdb175); //背景为纸黄色
    canvas.drawRect(
        Rect.fromCenter(
            center: Offset(size.width / 2, size.height / 2),
            width: size.width,
            height: size.height),
        mPaint);
    //画棋盘网格
    mPaint
      ..style = PaintingStyle.stroke
      ..color = CupertinoColors.systemGrey6
      ..strokeWidth = 1.0;
    for (var i = 0; i <= 15; i++) {
      //画横线
      canvas.drawLine(
          Offset(0, mHeight * i), Offset(size.width, mHeight * i), mPaint);
    }
    for (var i = 0; i <= 15; i++) {
      //画竖线
      canvas.drawLine(
          Offset(mWidth * i, 0), Offset(mWidth * i, size.height), mPaint);
    }
    //记录横竖线所有的交叉点
    for (int i = 0; i <= 15; i++) {
      for (int j = 0; j <= 15; j++) {
        _crossOverBeanList.add(CrossOverBean(mWidth * j, mHeight * i));
      }
    }
  }

  //在实际场景中正确利用此回调可以避免重绘开销,本示例我们简单的返回true
  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }
}

2.3 棋子绘制

首先,我们需要获得上面棋盘每条线交点的位置。我们用一个类进行定义:

///记录棋盘上横竖线的交叉点
class CrossOverBean {
  double _dx;
  double _dy;

  CrossOverBean(this._dx, this._dy);
}

而后便是对用户手点击的位置对棋子进行绘制,这里我们也是使用了一个CustomPainter对棋子进行绘制,这样棋子和棋盘分离,可以减轻软件重新绘制的负担。

class ChessPainter extends CustomPainter {
  static int _state = 0;
  static Position? _position;
  final Function _function;
  Checkerboard _originator = Checkerboard.getInstance();

  ChessPainter(Function f) : _function = f;

  @override
  void paint(Canvas canvas, Size size) {
    if (_position == null) {
      return;
    }
    bool add = false;
    double mWidth = size.width / 15;
    double mHeight = size.height / 15;
    var mPaint = Paint();
    //求两个点之间的距离,让棋子正确的显示在坐标轴上面
    var dx = _position!.dx;
    var dy = _position!.dy;
    for (int i = 0; i < CheckerBoardPainter._crossOverBeanList.length; i++) {
      var absX =
          (dx - CheckerBoardPainter._crossOverBeanList[i]._dx).abs(); //两个点的x轴距离
      var absY =
          (dy - CheckerBoardPainter._crossOverBeanList[i]._dy).abs(); //两个点的y轴距离
      var s = sqrt(absX * absX +
          absY * absY); //利用直角三角形求斜边公式(a的平方 + b的平方 = c的平方)来计算出两点间的距离
      if (s <= mWidth / 2 - 2) {
        // 触摸点到棋盘坐标坐标点距离小于等于棋子半径,那么
        //找到离触摸点最近的棋盘坐标点并记录保存下来
        _position!.dx = CheckerBoardPainter._crossOverBeanList[i]._dx;
        _position!.dy = CheckerBoardPainter._crossOverBeanList[i]._dy;
        _originator.add(_position!);
        add = true;
        if (_position!.chess is WhiteChess) {
          Ai.getInstance().addChessman(
              _position!.dx ~/ (width / 15), _position!.dy ~/ (width / 15), 1);
        }
        // flag = false; //白子下完了,该黑子下了
        break;
      }
    }

    //画子
    mPaint..style = PaintingStyle.fill;
    if (_originator.state.isNotEmpty) {
      for (int i = 0; i < _originator.state.length; i++) {
        mPaint..color = _originator.state[i].chess.color;
        if (_originator.state[i].chess.chessShape.shape == 1) {
          canvas.drawCircle(
              Offset(_originator.state[i].dx, _originator.state[i].dy),
              min(mWidth / 2, mHeight / 2) - 2,
              mPaint);
        }
        if (_originator.state[i].chess.chessShape.shape == 2) {
          Rect rect = Rect.fromCircle(
              center: Offset(_originator.state[i].dx, _originator.state[i].dy),
              radius: min(mWidth / 2, mHeight / 2) - 2);
          canvas.drawRect(rect, mPaint);
        }
      }
    }
    WidgetsBinding.instance!.addPostFrameCallback((_) {
      if (add && _position!.chess is WhiteChess) {
        _function();
      }
    });
  }

  //在实际场景中正确利用此回调可以避免重绘开销,本示例我们简单的返回true
  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

2.4 AI自动下棋函数

每次用户点击完以后,我们便可以确定其落子的位置,而后将整个棋盘的局势传给AI,让AI产生下一步。当然这里的AI其实是一个决策树算法,并不是很AI。

/// Ai 下棋
void turnAi() {
  // print("Ai下棋");
  if (ChessPainter._position!.chess is WhiteChess &&
      Ai.getInstance().isWin(ChessPainter._position!.dx ~/ (width / 15),
          ChessPainter._position!.dy ~/ (width / 15), 1)) {
    TipsDialog.show(context, "恭喜", "您打败了决策树算法");
  }
  // 获取Ai下棋地址
  Ai ai = Ai.getInstance();
  ChessPainter._position = ai.searchPosition();
  // 设置棋子外观
  ChessPainter._position!.chess.chessShape = CircleShape();
  // 加入决策中
  Ai.getInstance().addChessman(ChessPainter._position!.dx.toInt(),
      ChessPainter._position!.dy.toInt(), -1);
  if (ChessPainter._position!.chess is BlackChess &&
      Ai.getInstance().isWin(ChessPainter._position!.dx.toInt(),
          ChessPainter._position!.dy.toInt(), -1)) {
    TipsDialog.show(context, "很遗憾", "决策树算法打败了您");
  }
  setState(() {
    ChessPainter._position!.dx = ChessPainter._position!.dx * (width / 15);
    ChessPainter._position!.dy = ChessPainter._position!.dy * (width / 15);
  });
}

3. 总结

这次的UI设计代码就基本完成了,对于一些对接的细节,大家可以先自己琢磨琢磨,我这边后续会将代码上传github,感兴趣的大家可以收藏这篇文章,后续我会将链接放在这篇文章中。