Flutter【手势&绘制】围棋棋盘

前言

今天我们继续探索绘制与手势的组合的实践,不知道掘金的朋友有没有喜欢下围棋的,其实下棋也是绘制和手势结合的一个典型的实际应用,毕竟棋盘肯定是需要我们自定义绘制出来的,那么今天我们就用Flutter来继续绘制一个标准的围棋棋盘吧。

绘制

绘制之前,我们先了解下,围棋棋盘和棋子以及规则,在我们下棋时,每落下一颗棋子,棋盘是固定不变的,变化的只是棋盘上的棋子,那么在下棋刷新画布时,我们只需刷新棋盘上的棋子即可,棋子落子生根,落下不可随意走动,那么在手势中处理中我们只需处理点击事件即可,没有移动以及其他事件处理,Ok,有了这个思路,接下来我们开始绘制。

棋盘

第一步,画棋盘,围棋中,目前主流的游戏规则有9、13、19路三种棋盘,9路适合新手入门,而19路则是围棋发展至今的标准的围棋棋盘,也是各种比赛默认的围棋棋盘。

题外话:虽然只是19 * 19路的棋盘,但是就在这小小的围棋棋盘上可出现的变化绝对是一个无法想象的天文数字,以至于围棋发展了2000多年,古今棋局至今没有完全相同的一局棋,正所谓千古无同局说的就是围棋,这也是人类对围棋为之着迷的原因,正是这样的原因,人类相较于电脑一直统治着围棋游戏水平的天花板,直至2016年Google研发的Alphago问世之后,首次与当时人类顶尖棋手李世石的对局中,以4-1战绩横扫人类,人类统治围棋的神话也随之崩塌了,而赢的那一局也成为了目前为止人类战胜Alphago的唯一的一局棋,之后Alphago横扫围棋棋坛,对战人类再无败绩,也侧面说明了人类对于围棋当中无穷奥秘的掌握首次被人工智能超越,之后人类不服,约Alphago在17年与当时世界冠军柯洁进行的三番局对弈,结果Alphago以3-0完胜柯洁,随之Google宣布,Alphago将永久退出围棋届所有比赛,并将源代码开源,再之后,国内围棋人工智能如雨后春笋般的涌出,至此,人工智能统治围棋的时代到来。

哈哈,扯远了,接下来我们开始画棋盘了, 首先创建棋盘和棋子,棋盘为背景,棋子为前景,接下来首先绘制棋盘。

CustomPaint(
  size: Size(
      // MediaQuery.of(context).size.width, MediaQuery.of(context).size.width),
      size,
      size),
  painter: _QpPainter(),// 棋盘
  foregroundPainter: _QzPainter(),// 棋子
),
复制代码

我们知道围棋棋盘由相同线条组成的是一个正方矩形。首先绘制背景以及竖线:
image.png

//eSide 为线之间的距离
for (int i = 0; i < qpSize; i++) {
  canvas.save();
  canvas.translate(eSide * i, 0);
  if (i == 0 || i == qpSize - 1) {
    paint..strokeWidth = 2;
  } else {
    paint..strokeWidth = 1;
  }
  canvas.drawLine(Offset(0,0),
      Offset(0, size.height), paint..color = Colors.black);
  canvas.restore();
}
复制代码

绘制横线:
image.png

是不是很简单,我们将外部周围的线粗1个像素单位,也就是在绘制横竖线第一条和最后一条时画笔加粗即可。

image.png

星位及天元

接下来我们绘制星位和天元,19路围棋棋盘为例,一共有8个星位以及1个天元,分别是4个角星位,4个边星位,加中间1个天元,,用大黑点标记,方便定位,

image.png

代码:

double eSide = size.width / 18; //格子边长

/// 星位 坐标数据
List<Offset> offsetXList = [];
// 19 路
// 左星位
offsetXList.add(Offset(eSide * 3, eSide * 3));
offsetXList.add(Offset(eSide * 3, eSide * 9));
offsetXList.add(Offset(eSide * 3, eSide * 15));
// 中间
offsetXList.add(Offset(eSide * 9, eSide * 3));
offsetXList.add(Offset(eSide * 9, eSide * 9)); // 天元
offsetXList.add(Offset(eSide * 9, eSide * 15));
// 右星位
offsetXList.add(Offset(eSide * 15, eSide * 3));
offsetXList.add(Offset(eSide * 15, eSide * 9));
offsetXList.add(Offset(eSide * 15, eSide * 15));

for (var i = 0; i < offsetXList.length; i++) {
  canvas.drawCircle(offsetXList[i], 3, paint..style = PaintingStyle.fill);
}
复制代码

坐标

接下来绘制棋盘坐标,上面只是棋盘的核心区域,其实除了下棋区域,围棋棋盘上还会标有坐标,可以对某一个点进行精准的描述,不同棋盘坐标位置不同,这里我们将他定位到左边和上边,那么我们上面的棋盘的下棋区域就不能占据全部位置了,需要腾出点空间留给坐标,比如我们将左边和上边分别留出40个像素的值设为margin1,下方和右方留出20个像素值设为margin2,变成这样, 格子边长计算公式就变为:

double eSide = (size.width - margin1 - margin2) / (qpSize - 1); //格子边长
复制代码

棋盘样式就变为这样:
image.png

接下来我们就可以在左边和上边绘制坐标了,我们先绘制左边坐标,坐标为1-19数字对齐网格线。

首先我们再来回忆下绘制文字的方法,

var textPainter = TextPainter(
    text: TextSpan(
        text: "888",
        style: TextStyle(
          fontSize: 40,
          foreground: Paint()
            ..style = PaintingStyle.fill
            ..strokeWidth = 1,
        )),
    textAlign: TextAlign.left,
    maxLines: 1,
    ellipsis: "...",
    textDirection: TextDirection.ltr);
textPainter.layout();
textPainter.paint(canvas, Offset(0,0));
复制代码

上方代码绘制888文字,默认绘制区域为文字区域的左上角和原点重合,

image.png

这里我们希望让888对于原点居中,可以通过下方代码获取文本Size大小,将文本向左上平移,即可让文字和原点居中。

Size textSize = textPainter.size;
textPainter.paint(canvas, Offset(-textSize.width / 2, -textSize.height / 2));
canvas.drawRect(
    Rect.fromLTRB(0, 0, textSize.width, textSize.height)
        .translate(-textSize.width / 2, -textSize.height / 2),
    _paint
      ..color = Colors.blue.withAlpha(88)
      ..style = PaintingStyle.fill);
复制代码

image.png
ok,有了以上了解,接下来我们就可以开始绘制坐标了,按照以上方式,先绘制个1,因为我们的棋盘画布原点是没有经过平移的,所以在原始的左上角,是如下效果,
image.png
接着我们平移画布,将文本对齐最下面的网格线,向下移动区域长度为 margin1 +18个格子边长,


canvas.save();
canvas.translate(
    margin1 - 20, (size.height - margin2) - eSide * 18);
canvas.drawLine(Offset(margin1, margin1),
    Offset(margin1, size.height - margin2), paint..color = Colors.black);
canvas.restore();

复制代码

1就跑到我们想要的位置了。

image.png
接下来的工作就变的简单了,循环改变平移即可。

复制粘贴循环以后最终得到了以下效果: image.png

棋盘的基本绘制到这里就结束了。

棋子

接下来开始绘制棋子, 棋子其实还是比较简单的,黑白两个圆即可,

image.png

但是这么看着太平面了,接下来这里我们可以给棋子添加一点点细节,让棋子看起来更加的饱满立体些,首先棋子我们不要设置纯黑和纯白色,纯黑和纯白在设备上显示上有时不太友好,第二我们从棋子的左上角像右下角调整渐变色进行线性渐变,让棋子有一些阴影过渡的感觉,这样看起来就有些立体感了。

代码:

canvas.save();
canvas.rotate(pi * 2 - pi / 4);
canvas.drawPath(
    path,
    _paint
      ..shader = ui.Gradient.linear(Offset(0, -40), Offset(10, 10),
          [Color(0xFFa5a5a5), Color(0xFF333333)])
      ..style = PaintingStyle.fill);
// 白字
path.addOval(Rect.fromCenter(center: Offset.zero, width: 40, height: 40));
path.close();
canvas.translate(60, 0);
canvas.rotate(pi * 2 - pi / 4);
canvas.drawPath(
    path,
    _paint
      ..shader = ui.Gradient.linear(Offset(0, -40), Offset(10, 10),
          [Color(0xFFa5a5a5), Color(0xFFF3F3F3)])
      ..style = PaintingStyle.fill);
canvas.restore();
复制代码

调整完毕的效果:

image.png

看起来是不是比刚开始纯黑白稍稍舒服了一点。

手数

下一步绘制棋子上的手数,我们都知道围棋棋子上是可以显示手数的,这里我们只需要在棋子坐标中心添加文字即可, 也非常的简单。

效果:

image.png

插曲: 这里遇到了一个小插曲,当我们的文本字号设置比较小的时候,例如fontSize = 7.2时,文本没有在文本区域中居中, 以8数字为例,因为8是左右对称数字,但是在字号较小时它出现了比较明显的不对称的效果,出现这样的效果会导致,当棋子过小,手数的数字不会出现在棋子中央,会出现一些比较明显的偏移,大一点是看不出来的。

字号7.2:
image.png

当我调整为7的时候,貌似左右对称,但是上下间距和7.2时差距又不同,

字号7:明显下边距变大了
image.png

当我将字号调大一点时,例如14时的效果:

字号14:
image.png

好像确实没有完全对称,只是字号大一些没有那么明显,不知道文本的区域是怎么计算的,有知道的小伙伴可以告诉我下。感谢感谢~~

当前标记

接下来继续绘制当前标记,围棋下棋时当落子时,需要对刚落下的子进行一个标记,好让对手一眼看到你下到了哪个地方,这里我们就简单的在棋子的下方绘制一个倒三角,三角形上底边宽度为字号的大小,也是非常的简单。

image.png

到这里,棋子也画完了,接下来我们就需要结合手势将这些棋子下到棋盘上。

手势

手势这里比较的简单,我们只需要处理点击事件即可。首先我们先来确定手势触摸区域的落子的范围。

手势点击触发落子范围

因为整个棋盘是我们的手势触摸区域,由于坐标原因,真正的手势触发区域应该是真正的棋盘,见下图外围红框,可以看到但是当手指在外围红框内时,才认为是落子范围,因为棋子是要下在横竖线条的交叉点上的,所以这里的触摸范围我们设置为以交叉点为中心,边长 = 棋盘格子边长,见下图蓝色区域的范围,对于边角,我们需要向外扩展格子边长的一半,见下方小红框,这时我们就认为当前用户准备落到区域中心的这个位置,其他点同理。
image.png
点击事件处理代码:

onPanDown: (e) {
  double dx = e.localPosition.dx;
  double dy = e.localPosition.dy;
  double eSide =
      (widget.size - (margin1 + margin2)) / (widget.qpSize - 1); // 格子边长
  if (dx < margin1 - eSide / 2 ||
      dy < margin1 - eSide / 2 ||
      dx - (margin1 + ((widget.qpSize - 1) * eSide) + eSide / 2) > 0 ||
      dy - (margin1 + ((widget.qpSize - 1) * eSide) + eSide / 2) > 0) {
    return;
  }
  print("边长:$eSide ");
  // 将原点设置为上方红框左上角
  dx = dx - (margin1 - eSide / 2);
  dy = dy - (margin1 - eSide / 2);
  print("点击坐标:dx= $dx dy= $dy");
  dx = dx - dx % eSide;
  dy = dy - dy % eSide;
  print("点击计算后棋盘上的坐标:dx= $dx dy= $dy");
  for (var i = 0; i < qzList.length; i++) {
    double x = goList.value[i].dx;
    double y = goList.value[i].dy;
    if (dx == x && dy == y) {
      //说明这个位置已经有棋子 return
      return;
    }
  }
  qzList.add(Offset(dx, dy));
  List<Offset> qList = [];
  qList.addAll(qzList);
  // 更新棋子
  goList.value = qList;
  print("添加坐标:dx= $dx dy= $dy");
},
复制代码

获取到最终落子坐标,接下来的工作就比较简单了,只需更新数据,刷新画布将棋子、手数以及标记绘制到对应坐标即可。

看下效果:

Jul-29-2022 17-33-10.gif

试下

如果在手机上操作,由于屏幕小的原因,免不了会有误触的时候,这时候首次点击棋盘时就需要有一个提示是否下在此处的功能,那么我们就需要再增加一个试下的坐标点数据,

// 试下点数据
ValueNotifier<Offset?> tryOffset = ValueNotifier(null);
复制代码

试下时,这里将棋子原本颜色设置为了0.6的的透明度,

if (qzType == QzType.black) {
  if (isTry) {
    qzColors = [
      Color(0xFFa5a5a5).withOpacity(0.6),
      Color(0xFF333333).withOpacity(0.6)
    ];
  } else {
    qzColors = [Color(0xFFa5a5a5), Color(0xFF333333)];
  }
} else {
  if (isTry) {
    qzColors = [
      Color(0xFFa5a5a5).withOpacity(0.6),
      Color(0xFFF3F3F3).withOpacity(0.6)
    ];
  } else {
    qzColors = [Color(0xFFa5a5a5), Color(0xFFF3F3F3)];
  }
}
复制代码

围棋讲究落子生根,所以这里没有加悔棋功能,悔棋功能加的话也很简单,只需将棋子数组的最后一条数据删除刷新画布即可。

之后我们将棋盘大小、棋盘路数、是否显示手数等字段对外暴露,就是一个支持9、13、19路的围棋棋盘了,现在离真正的下棋就差规则算法了,这个以后有时间再加。

最终演示:

Jul-29-2022 17-40-28.gif

围棋棋盘的绘制以及手势交互到这里就基本完成了, 因为棋盘用的Flutter的纯UI绘制,所以天然的支持跨端展示,之后有时间加上围棋的游戏规则,连个网,就可以对战了。

总结

本篇文章主要介绍了围棋棋盘的绘制以及落子的手势交互,并不能直接下棋哈,因为我还没加游戏算法规则,主要围棋的规则虽然简单,但是算起来还是有点复杂的,这个有时间再研究下,有机会可以做一个内网联机围棋对战平台,嘿嘿,那本篇文章到这里就结束了,希望对你有所帮助~

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

猜你喜欢

转载自juejin.im/post/7125734483990413320