Flutter AI五子棋

前言
在上一篇文章中,讲解了如何实现双人在本地对战的五子棋,但是只有一个人的时候就不太好玩,同时博主也没有把五子棋相关的文章写过瘾。那么这篇文章,我们来实现一个功能更加丰富的五子棋吧!在设计五子棋的算法方面,我们将引入一些经典的算法,如最大最小搜索(Max-Min)算法和Alpha-Beta剪枝算法。这些算法将帮助我们创建一个智能的对手,使游戏更具挑战性和趣味性。除了算法的介绍,本文还将深入探讨五子棋的基本玩法和规则。我们将详细解释如何落子、如何判断胜负以及如何对各种局面进行评分估值。通过学习这些基础知识,您将能够更好地理解和享受五子棋游戏。
效果图:
在这里插入图片描述

仓库地址:https://github.com/taxze6/flutter_game_collection/tree/main/gomoku_ai
release apk下载体验:https://github.com/taxze6/flutter_game_collection/tree/main/gomoku_ai/release
棋盘绘制
本次采用的棋盘绘制与上篇文章的方式不同,上篇文章中采用的是GridView这样的基础组件,使用简单,无需手动编写绘制逻辑。利用GridView的布局特性,可以很方便地进行排列和调整。但是它也有缺点,就是不够灵活,当我们想实现更多的棋盘细节时,实现起来就不是很方便了,所以在本篇文章中,我们采用CustomPaint绘制的方式。
那在绘制棋盘之前,我们需要先定义游戏所需要的一些参数和实体类:
玩家类
//玩家
class Player {
static final Player black = Player(Colors.black);
static final Player white = Player(Colors.white);
late Color color;

Player(this.color);

@override
String toString() {
return ‘Player{${this == black ? “black” : “white”}}’;
}
}
单颗棋子类
class Chessman {
//坐标
late Offset position;

//该棋子的所属人
late Player owner;

//棋子id
int numberId = chessmanList.length;

//棋子的分数,默认为0
int score = 0;

Chessman(this.position, this.owner);

Chessman.white(this.position) {
owner = Player.white;
}

Chessman.black(this.position) {
owner = Player.black;
}

@override
String toString() {
return ‘Chessman{position: ( p o s i t i o n . d x , {position.dx}, position.dx,{position.dy}), owner: ${owner == Player.black ? “black” : “white”}, score: $score, numberId: $numberId}’;
}
}
全局通用参数
//初始化一个玩家,掌握黑棋
Player firstPlayer = Player.black;
//存放所有的棋子
List chessmanList = [];
//存放胜利的棋子
List winResult = [];
那么所需的参数及实体类编写完成后,就可以开始棋盘的绘制啦!
游戏页面整体布局结构
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(“五子棋AI版”),
),
body: Padding(
padding: EdgeInsets.only(top: 50, left: 20, right: 20),
child: Column(
children: [
//棋盘
GestureDetector(
child: CustomPaint(
painter: ChessmanPaint(),
size: Size(400, 400),
),
onTapDown: (details) {
onTapDown(details);
setState(() {});
},
),
//底部操作项目
Padding()
],
),
),
);
}
棋盘绘制主体
● 定义所需绘制参数
//默认棋盘的行列数
const int LINE_COUNT = 14;
//根据屏幕大小与行列数,计算得出每个格子的宽高,初始化先为0
double cellWidth = 0, cellHeight = 0;
● 绘制黄褐色背景
在绘制背景这里:canvas.drawRect(Offset.zero & size, painter),用了个dart的语法糖,有些朋友可能会有些疑惑,drawRect方法第一个参数不是Rect类型的吗,这里传了个Offset.zero & size是什么鬼?这里单独解释下:Offset.zero表示矩形范围的左上角坐标为原点(0,0),size表示矩形的大小。这个表达式使用&符号将两个对象合并成了一个Rect对象作为canvas.drawRect()方法的第一个参数。实际上,&符号在这里是Dart语言中的语法糖,等效于使用Rect.fromLTWH(0, 0, size.width, size.height)来创建一个矩形。因此,这里的语法Offset.zero & size可以通过Rect.fromLTWH(0, 0, size.width, size.height)来替代。
class ChessmanPaint extends CustomPainter {
late Canvas canvas;
late Paint painter;
//用于控制打印在棋子上的id
static const bool printLog = true;

@override
void paint(Canvas canvas, Size size) {
this.canvas = canvas;
//计算单个格子的宽高
cellWidth = size.width / LINE_COUNT;
cellHeight = size.height / LINE_COUNT;

painter = Paint()..isAntiAlias = true..style = PaintingStyle.fill..color = Color(0x77cdb175);
//绘制背景
canvas.drawRect(Offset.zero & size, painter);
}
...

}
● 绘制棋盘上的线条(格子)
@override
void paint(Canvas canvas, Size size) {

painter
…style = PaintingStyle.stroke
…color = Colors.black87
…strokeWidth = 1.0;

for (int i = 0; i <= LINE_COUNT; ++i) {double y = cellHeight * i;canvas.drawLine(Offset(0, y), Offset(size.width, y), painter);
}for (int i = 0; i <= LINE_COUNT; ++i) {double x = cellWidth * i;canvas.drawLine(Offset(x, 0), Offset(x, size.height), painter);
}

}
● 绘制五子棋盘上的五个交叉点

这5个点称为“星”。中间的星也称天元,表示棋盘的正中心,其他4个星,也叫小星。星在棋盘上起标示位置的作用,利于在行棋、复盘、记录等时,更清晰、迅速地找到所需位置。
//绘制棋盘上的5个黑点
void _drawMarkPoints() {
// 通过多次调用_drawMarkPoint方法来绘制标记点
_drawMarkPoint(const Offset(7.0, 7.0));
_drawMarkPoint(const Offset(3.0, 3.0));
_drawMarkPoint(const Offset(3.0, 11.0));
_drawMarkPoint(const Offset(11.0, 3.0));
_drawMarkPoint(const Offset(11.0, 11.0));
}

void _drawMarkPoint(Offset offset) {
painter
…style = PaintingStyle.fill
…color = Colors.black;

// 计算标记点在画布上的具体位置
Offset center = Offset(offset.dx * cellWidth, offset.dy * cellHeight);

// 在计算得到的位置绘制一个半径为3的圆形标记点
canvas.drawCircle(center, 3, painter);
}
● 绘制棋子
这里使用min(cellWidth / 2, cellHeight / 2) - 2计算出较小的一边长度减去2作为圆的半径,可以使得所有棋子的大小一致,并且不会越出格子范围。
//遍历chessmanList绘制,每下一颗子,触发setState
if (chessmanList.isNotEmpty) {
for (Chessman c in chessmanList) {
_drawChessman©;
}
}

void _drawChessman(Chessman chessman) {
painter
…style = PaintingStyle.fill
//根据owner取得每课棋子对应的颜色
…color = chessman.owner.color;

Offset center = Offset(
chessman.position.dx * cellWidth, chessman.position.dy * cellHeight);
canvas.drawCircle(center, min(cellWidth / 2, cellHeight / 2) - 2, painter);
//如果当前棋子的编号是最后一枚棋子,则使用painter绘制一个描边的蓝色圆圈,表示这是最后下的一枚棋子。
if (chessman.numberId == chessmanList.length - 1) {
painter
…color = Colors.blue
…style = PaintingStyle.stroke
…strokeWidth = 3.0;
canvas.drawCircle(
center, min(cellWidth / 2, cellHeight / 2) - 2, painter);
}
}
● 绘制棋子编号(非主要功能,可以跳过这步)
//在棋子上绘制它的id
if (printLog) {
_drawText((i.toString()),
Offset(-19, y - _calcTrueTextSize(i.toString(), 15.0).dy / 2));
}

void _drawText(String text, Offset offset, {Color? color, double? textSize}) {
// 创建ParagraphBuilder对象,用于构建文本段落
ui.ParagraphBuilder builder = ui.ParagraphBuilder(ui.ParagraphStyle(
textAlign: TextAlign.center,
ellipsis: ‘…’,
maxLines: 1,
));

// 使用pushStyle方法设置文本风格,包括颜色和字体大小
builder.pushStyle(
ui.TextStyle(color: color ?? Colors.red, fontSize: textSize ?? 15.0));

// 添加文本到builder对象中
builder.addText(text);

// 构建一个Paragraph对象
ui.Paragraph paragraph = builder.build();

// 对paragraph进行layout,指定宽度为无限大
paragraph.layout(const ui.ParagraphConstraints(width: double.infinity));

// 在Canvas上绘制paragraph对象,位置为offset
canvas.drawParagraph(paragraph, offset);
}

//根据给定的文本字符串和字体大小,计算出该文本所占据的实际宽度和高度,以便在UI布局中更好地控制文本的位置和尺寸。
Offset _calcTrueTextSize(String text, double textSize) {
// 创建ParagraphBuilder对象,并设置字体大小
var paragraph = ui.ParagraphBuilder(ui.ParagraphStyle(fontSize: textSize))
…addText(text);

// 构建Paragraph对象,并进行layout,指定宽度为无限大
var p = paragraph.build()
…layout(const ui.ParagraphConstraints(width: double.infinity));

// 返回Paragraph对象的最小内在宽度和高度作为偏移量
return Offset(p.minIntrinsicWidth, p.height);
}
用户交互(下棋)
取得用户点击的位置
通过GestureDetector的onTapDown取得用户点击的位置
GestureDetector(
child: CustomPaint(
painter: ChessmanPaint(),
size: Size(400, 400),
),
onTapDown: (details) {
onTapDown(details);
setState(() {});
},
),
点击事件
//棋盘点击事件
void onTapDown(TapDownDetails details) {
//游戏胜利后,再点击棋盘就无效
if (winResult.isNotEmpty) {
return;
}
double clickX = details.localPosition.dx;
//计算点击点所在列的索引值 floorX。通过将 clickX 除以格子的宽度 cellWidth 并向下取整,可以得到点击点所处的列索引值
int floorX = clickX ~/ cellWidth;
//计算了当前列横坐标网格线中点的横坐标值 offsetFloorX。通过将 floorX 乘以格子的宽度 cellWidth,再加上格子宽度的一半 cellWidth / 2,可以得到当前列横坐标网格线中点的横坐标值。
double offsetFloorX = floorX * cellWidth + cellWidth / 2;
//判断点击点在哪一列,并将结果赋值给变量 x。如果 offsetFloorX 大于点击点的 x 坐标 clickX,则说明点击点在 floorX 列;否则,说明点击点在 floorX + 1 列。如果点击点在 floorX + 1 列,则通过 ++floorX 来获取 floorX + 1 的值。
int x = offsetFloorX > clickX ? floorX : ++floorX;

//y轴同理
double clickY = details.localPosition.dy;
int floorY = clickY ~/ cellHeight;
double offsetFloorY = floorY * cellHeight + cellHeight / 2;
int y = offsetFloorY > clickY ? floorY : ++floorY;

//触发落子
fallChessman(Offset(x.toDouble(), y.toDouble()));
}
落子函数
void fallChessman(Offset position) {
if (winResult.isNotEmpty) {
return;
}
//创建棋子
Chessman newChessman;
//棋子的颜色
if (chessmanList.isEmpty || chessmanList.length % 2 == 0) {
newChessman = firstPlayer == Player.black
? Chessman.black(position)
: Chessman.white(position);
} else {
newChessman = firstPlayer == Player.black
? Chessman.white(position)
: Chessman.black(position);
}
//判断是否能落子
bool canFall = canFallChessman(newChessman);
if (canFall) {
//可以落子
//打印下落子棋子的信息
printFallChessmanInfo(newChessman);
//此处还需完成:
//1.棋子估值、ai相关逻辑
//2.对游戏胜利的校验,对游戏和棋的校验
}else{
print(“此处无法落子!”);
}
}

void printFallChessmanInfo(Chessman newChessman) {
print(
“[落子成功], 棋子序号: n e w C h e s s m a n . n u m b e r I d , 颜色 : {newChessman.numberId} ,颜色: newChessman.numberId,颜色:{newChessman.owner == Player.WHITE ? “白色” : “黑色”} , 位置 :(${newChessman.position.dx.toInt()} , ${newChessman.position.dy.toInt()})”);
}
该坐标能否落子的判断
bool canFallChessman(Chessman chessman) {
//定义一个不可能生成到棋盘上的棋子
Chessman defaultChessman = Chessman(Offset(-1, 0), Player.black);
if (chessmanList.isNotEmpty) {
Chessman cm = chessmanList.firstWhere((Chessman c) {
//如果找到位置相同的棋子,那么cm就等于这棋子的信息
return c.position.dx == chessman.position.dx &&
c.position.dy == chessman.position.dy;
}, orElse: () {
//没找到就把该棋子添加到列表中,然后返回一个不可能在棋盘上的棋子用作校验
chessmanList.add(chessman);
return defaultChessman;
});
// 如果找到了相同位置的棋子,这里就会返回false;否则返回true
return cm == defaultChessman;
} else {
//如果为空直接添加
chessmanList.add(chessman);
return true;
}
}
棋盘校验规则
相较于棋子估值和ai的实现,对棋子胜利、和棋的校验会比较简单,从简到难,让我们先完成对游戏规则的定义:
胜利判断
bool checkResult(Chessman newChessman) {
int currentX = newChessman.position.dx.toInt();
int currentY = newChessman.position.dy.toInt();

int count = 0;

///横
/// o o o o o
/// o o o o o
/// x x x x x
/// o o o o o
/// o o o o o
winResult.clear();
// 循环遍历当前行的前后四个位置(如果存在),检查是否有特定的棋子连成五子相连
//判断 currentX - 4 > 0 时,它的意思是判断左侧第 4 个位置是否在棋盘内。
//如果 currentX - 4 大于 0,则表示左侧第 4 个位置在棋盘内;
//否则,即 currentX - 4 <= 0,表示左侧第 4 个位置已经超出了棋盘边界。
for (int i = (currentX - 4 > 0 ? currentX - 4 : 0);
i <= (currentX + 4 < LINE_COUNT ? currentX + 4 : LINE_COUNT);
i++) {
// 计算当前位置的坐标
Offset position = Offset(i.toDouble(), currentY.toDouble());

// 检查当前位置是否存在胜利的棋子
if (existSpecificChessman(position, newChessman.owner)) {// 将该棋子添加到胜利结果列表中,并增加计数器winResult.add(Chessman(position, newChessman.owner));count++;
} else {// 如果不存在特定的棋子,清空胜利结果列表,并将计数器重置为0winResult.clear();count = 0;
}// 解析:如果计数器达到5,表示有五子相连,输出胜利者信息并返回true
if (count >= 5) {print("胜利者产生: ${newChessman.owner == Player.white ? "白色" : "黑色"}");//游戏胜利的提示弹窗winDialog("胜利者产生: ${newChessman.owner == Player.white ? "白色" : "黑色"}");return true;
}

}

//竖、正斜、反斜的逻辑代码请查看源码,和横的校验差不多


winResult.clear();
return false;
}

// 检查给定位置是否存在特定的棋子,并且这个棋子的所有者是否与指定玩家相同
bool existSpecificChessman(Offset position, Player player) {
//定义一个不可能生成到棋盘上的棋子
Chessman defaultChessman = Chessman(Offset(-1, 0), Player.black);
// 检查棋子列表是否非空
if (chessmanList.isNotEmpty) {
// 在棋子列表中查找匹配给定位置的棋子
var cm = chessmanList.firstWhere((Chessman c) {
return c.position.dx == position.dx && c.position.dy == position.dy;
}, orElse: () {
return defaultChessman;
});

// 如果找到匹配的棋子,检查其所有者是否是指定的玩家
return cm != defaultChessman && cm.owner == player;

}
// 如果棋子列表为空或不存在棋子匹配给定位置,则返回false
return false;
}
existSpecificChessman函数看起来和前面判断该坐标能否落子的canFallChessman函数差不多,这两个函数的主要区别在于作用和调用时机不同:existSpecificChessman校验的是当前位置是否存在特定棋子且所有者是否相符,而canFallChessman校验的是当前位置是否可以落子。
和棋判断
判断是否和棋其实非常简单,只要没有胜利,同时棋盘满了,就代表和棋了。
//判断棋盘是否满了
bool isHaveAvailablePosition() {
return chessmanList.length <= 255;
}
到这里为止呢已经完成了五子棋的基本玩法,你可以邀请你的朋友和你一起对战了

棋子估值
对每颗棋子进行打分,是完成一切算法的基础条件,如果没有分数,那么算法也就无法生效。
估值算法也是本文的核心,个人觉得估价函数比MinMax算法和Alpha-Beta剪枝算法这两个算法的难度大多了,本文的算法部分主要参考了这几篇文章:
五子棋估值算法
基于博弈树的五子棋 AI 算法及其 C++ 实现
前提条件:本文的规则只涉及无禁手的五子棋
大部分的棋类游戏,先手都有一个优势。以五子棋为例,先达成五子连珠者胜,由于黑方先走了一步,五子棋几乎是先手必胜的局面。所以假设五子棋的胜负条件会变成:如果黑方达成五子连珠之后,白棋也可在一步之内达成五子连珠,判定平手。这样的话就公平了,但是也失去了对弈的一些乐趣和意义,因为白棋只要一直跟着黑棋下,最后一定会为平局。所以为了平衡先手优势,大部分棋类都有一个补偿规则。如五子棋的禁手以及三手交换五手两打。在此不作过多解释,有兴趣可以自行百度,本文的规则及算法对先手无任何限制。
相较于象棋、围棋,五子棋的局面并不复杂,估值还算比较简单,我们简单的用一个整数表示当前局势,分数越大,则自己优势越大,分数越小,则对方优势越大,分数为0是表示双方局势相当。可以先把几种情况定义出来:
其中的解释中,x代表白棋,o代表黑棋,我们从黑棋的角度去评分
static const int WIN = 10000;

//低级死二 xoox
static const int DEEP_DEATH2 = 2;

//死二 xoo
static const int LOWER_DEATH2 = 4;
//低级死三 xooox
static const int DEEP_DEATH3 = 3;
//死三 xooo
static const int LOWER_DEATH3 = 6;

//低级死四 xoooox
static const int DEEP_DEATH4 = 4;
//死四 xoooo
static const int LOWER_DEATH4 = 32;

//活二 oo
static const int ALIVE2 = 10;
//跳活二 o o
static const int JUMP_ALIVE2 = 2;
//活三 ooo
static const int ALIVE3 = 100;
//跳活三 oo o
static const int JUMP_ALIVE3 = 10;
//活四 oooo
static const int ALIVE4 = 5000;
//跳活四 (1跳3或者3跳1或2跳2) o ooo || ooo o || oo oo
static const int JUMP_ALIVE4 = 90;
在实现估值算法前,我们还需要实现一个泛型类BufferMap,实现一个缓冲区的功能,BufferMap的用处在于记录和管理最近的几个棋盘状态。借助它可以用于实现游戏的一些功能,例如:
● 悔棋功能:如果玩家想要悔棋,可以通过BufferMap中的历史记录回退到之前的棋盘状态,从而实现悔棋操作。
● 撤销操作:当玩家进行某些操作后,发现操作结果不符合预期,可以利用BufferMap中的历史记录撤销该操作,恢复到之前的棋盘状态。
● 历史记录展示:通过BufferMap中保存的棋盘状态,可以展示游戏的历史记录,供玩家回顾以及分析棋局发展。
● AI训练:对于AI算法的训练过程中,可以使用BufferMap来保存训练数据中的棋盘状态,以便进行样本回放、经验重放等技术。
class BufferMap {
//设置缓冲区为3
num maxCount = 3;
final Map<num, V> buffer = {};

BufferMap();

BufferMap.maxCount(this.maxCount);

// 添加元素(key存的是每个棋子的分数,value是每个棋子的offset)
void put(num key, V value) {
buffer.update(key, (V val) {
return value;
},
//当缓冲区中不存在指定键时,会执行该回调函数来添加新的键值对。
ifAbsent: () {
return value;
});
_checkSize();
}

// 批量添加元素
void putAll(BufferMap map) {
for (var entry in map.buffer.entries) {
buffer[entry.key] = entry.value;
}
}

// 检查并缩减缓冲区大小
void _checkSize() {
//将缓冲区的所有键转换成列表,并赋值给变量 list,按照从大到小排列
var list = buffer.keys.toList()
…sort((num a, num b) {
return b.compareTo(a);
});
while (buffer.length > maxCount) {
buffer.remove(list.last);
}
}

// 将缓冲区转为Map
Map<num, V> toMap() {
return Map<num, V>.from(buffer);
}

// 获取所有元素的值
Iterable values() {
return buffer.values;
}

// 获取缓存元素个数
int size() {
return buffer.length;
}

// 转为字符串表示
@override
String toString() {
StringBuffer sb = StringBuffer();
sb.write(“{”);
var keys = buffer.keys.toList()
…sort((num a, num b) {
return b.compareTo(a);
});

for (var i in keys) {sb.write("[$i , ${buffer[i]}] ,");
}return "${sb.toString().substring(0, sb.toString().length - 2)}}";

}

// 获取第一个元素的值
V? get first => buffer[buffer.keys.toList()
…sort((num a, num b) {
return b.compareTo(a);
})
…first];

// 获取键的最小值
num minKey() {
if (buffer.isEmpty) {
return double.negativeInfinity;
}
var list = buffer.keys.toList()
…sort((num a, num b) {
return b.compareTo(a);
});
return list.isNotEmpty ? list.last : double.negativeInfinity;
}

// 获取键值最小的元素
MapEntry<num, V>? min() {
if (buffer.isEmpty) {
return null;
}
var list = buffer.keys.toList()
…sort((num a, num b) {
return b.compareTo(a);
});
return list.isNotEmpty ? MapEntry(list.last, buffer[list.last]!) : null;
}

// 获取所有键的列表
List get keySet {
if (buffer.isEmpty) return [];

var sortedKeys = buffer.keys.toList()..sort((num a, num b) {return (b - a).toInt();});return sortedKeys;

}

// 通过键访问元素的值
V? operator [](Object? key) {
return buffer[key];
}

// 获取键的最大值
// 最优位置得分
num maxKey() {
if (buffer.isEmpty) {
return double.negativeInfinity;
}
var list = buffer.keys.toList()
…sort((num a, num b) {
return b.compareTo(a);
});
return list.isNotEmpty ? list.first : 0;
}

// 获取键值最大的元素
// MapEntry 提供了 key 和 value 两个只读属性来获取键和值,分别返回对应键值对的键和值。在 Map 中使用迭代器遍历时,每个元素都是 MapEntry 类型的实例。
MapEntry<num, V>? max() {
if (buffer.isEmpty) {
return null;
}
var list = buffer.keys.toList()
…sort((num a, num b) {
return b.compareTo(a);
});
return list.isNotEmpty ? MapEntry(list.first, buffer[list.first]!) : null;
}
}
判断是那种棋局情况
需要对活二、跳活二、活三…这些不同的棋局状态定义校验规则,规则太多,文章中只看活二的校验规则,其余请查看源码。
bool isAlive2(List list) {
assert(list.length == 2);
//把两颗棋子传入
Offset offset1 = nextChessman(list[1], list[0]);
Offset offset2 = nextChessman(list[0], list[1]);

return isEffectivePosition(offset1) &&
isEffectivePosition(offset2) &&
isBlankPosition(offset1) &&
isBlankPosition(offset2);
}

//输入的first和second返回下一个棋子的位置偏移量。
Offset nextChessman(Offset first, Offset second) {
//检查first和second的dy值是否相等。
//如果相等,表示棋子在水平方向上移动。那么下一个棋子的位置偏移量将在水平方向上向右或向左移动一格,取决于first的dx是否大于second的dx。
//如果first.dx > second.dx,则向左移动一格,即second.dx - 1;否则,向右移动一格,即second.dx + 1。纵坐标保持不变,即为first.dy
if (first.dy == second.dy) {
return Offset(
first.dx > second.dx ? second.dx - 1 : second.dx + 1, first.dy);
}
//如果first.dx和second.dx相等,表示棋子在垂直方向上移动。那么下一个棋子的位置偏移量将在垂直方向上向上或向下移动一格,取决于first的dy是否大于second的dy。如果first.dy > second.dy,则向上移动一格,即second.dy - 1;否则,向下移动一格,即second.dy + 1。横坐标保持不变,即为first.dx。
//如果以上两种情况都不满足,那么表示棋子在斜对角线方向上移动。根据first.dx和second.dx的大小关系,以及first.dy和second.dy的大小关系,决定下一个棋子的位置偏移量。
else if (first.dx == second.dx) {
return Offset(
first.dx, first.dy > second.dy ? second.dy - 1 : second.dy + 1);
} else if (first.dx > second.dx) {
if (first.dy > second.dy) {
return Offset(second.dx - 1, second.dy - 1);
} else {
return Offset(second.dx - 1, second.dy + 1);
}
} else {
if (first.dy > second.dy) {
return Offset(second.dx + 1, second.dy - 1);
} else {
return Offset(second.dx + 1, second.dy + 1);
}
}
}

//判断该位置是否有效。
bool isEffectivePosition(Offset offset) {
return offset.dx >= 0 &&
offset.dx <= LINE_COUNT &&
offset.dy >= 0 &&
offset.dy <= LINE_COUNT;
}

//isBlankPosition是用于判断某个位置上是否没有棋子,写法逻辑和用户交互能否落子差不多
bool isBlankPosition(Offset position) {
if (chessmanList.isNotEmpty) {
Chessman defaultChessman = Chessman(Offset(-1, 0), Player.black);
var cm = chessmanList.firstWhere((Chessman c) {
return c.position.dx == position.dx && c.position.dy == position.dy;
}, orElse: () {
return defaultChessman;
});
return cm != defaultChessman;
}
return true;
}
对每一种情况进行估分
这里只展示了两颗棋子的情况。
//将给定的数限制在最大值为2的范围内
int limitMax(int num) {
return num >= 2 ? 2 : num;
}
//对每种棋局加分
int scoring(Offset first, List myChessman, Player player,
{required String printMsg, bool isCanPrintMsg = false}) {
if (myChessman.length >= 5) {
return WIN;
}
int score = 0;
switch (myChessman.length) {
case 1:
break;
case 2:
if (isAlive2(myChessman)) {
score += ALIVE2;
score +=
limitMax(getJumpAlive3Count(myChessman, player)) * JUMP_ALIVE3;
score +=
limitMax(getJumpAlive4Count(myChessman, player)) * JUMP_ALIVE4;

    if (isCanPrintMsg) {print("$printMsg 活2成立, 得分+$ALIVE2");}} else if (isLowerDeath2(myChessman)) {score += LOWER_DEATH2;if (isCanPrintMsg) {print("$printMsg 低级死2成立 ,得分+$LOWER_DEATH2");}} else {score += DEEP_DEATH2;if (isCanPrintMsg) {print("$printMsg 死2成立 ,得分+$DEEP_DEATH2");}}break;
case 3:...case 4:...case 5:
default:score += WIN;

}
return score;
}
对单颗棋子估分
在棋盘中某一块范围内只有一颗棋子时,就都不能满足上方的几种棋局,那我们还需要对单颗棋子进行一个打分。
///位置得分(越靠近中心得分越高)
int positionScore(Offset offset) {
//这个值是通过对(offset.dx - 7.5)^2 + (offset.dy - 7.5)^2进行运算得到的。
//其中,^表示乘方操作,即取平方,可以把棋盘上每颗棋子的位置想成一个圆锥,越靠近中心位置越高
//参考点被设定为(7.5, 7.5),棋盘的中心
double z = -(pow(offset.dx - 7.5, 2) + pow(offset.dy - 7.5, 2)) + 112.5;
z /= 10;
return z.toInt();
}

///孤子价值
int scoringAloneChessman(Offset offset) {
int score = 0;
List list = [
Offset(offset.dx - 1, offset.dy),
Offset(offset.dx + 1, offset.dy),
Offset(offset.dx, offset.dy + 1),
Offset(offset.dx, offset.dy - 1),
Offset(offset.dx - 1, offset.dy - 1),
Offset(offset.dx - 1, offset.dy + 1),
Offset(offset.dx + 1, offset.dy - 1),
Offset(offset.dx + 1, offset.dy + 1),
];
for (offset in list) {
if (offset.dx > 0 && offset.dy > 0 && isBlankPosition(offset)) {
score++;
}
}

return score + positionScore(offset);
}
计算某一颗棋子对于玩家的评分
只分析横向上的棋子,其他方向的代码请查看源码。
///计算某个棋子对于 ownerPlayer 的分值
int chessmanGrade(Offset chessmanPosition,
{required Player ownerPlayer, bool isCanPrintMsg = false}) {
int score = 0;
List myChenssman = [];
Offset offset;
Offset first = chessmanPosition;
Player player = ownerPlayer;
player ??= computerPlayer;

///横向
//横向(左)
offset = Offset(first.dx - 1, first.dy);
myChenssman
…clear()
…add(first);
while (existSpecificChessman(offset, player)) {
myChenssman.add(offset);
offset = Offset(offset.dx - 1, offset.dy);
}

//横向(右)
offset = Offset(first.dx + 1, first.dy);
while (existSpecificChessman(offset, player)) {
myChenssman.add(offset);
offset = Offset(offset.dx + 1, offset.dy);
}
myChenssman.sort((a, b) {
return (a.dx - b.dx).toInt();
});
score += scoring(first, myChenssman, player,
printMsg: “横向”, isCanPrintMsg: isCanPrintMsg);

...

int ss = score + scoringAloneChessman(first);
if (isCanPrintMsg) {
print(“该子分值为: s s , 其中单子得分 : ss ,其中单子得分: ss,其中单子得分:{scoringAloneChessman(first)}, 组合得分:$score”);
}

int jumpAlive4Count = getJumpAlive4Count([first], player);
int jumpAlive3Count = getJumpAlive3Count([first], player);
int jumpAlive2Count = getJumpAlive2Count([first], player);
score += limitMax(jumpAlive4Count) * JUMP_ALIVE4 +
limitMax(jumpAlive3Count) * JUMP_ALIVE3 +
limitMax(jumpAlive2Count) * JUMP_ALIVE2;

return score + scoringAloneChessman(first);
}
计算我方下一步较好的位置
BufferMap ourBetterPosition({maxCount = 5}) {
Offset offset = Offset.zero;
BufferMap ourMap = BufferMap.maxCount(maxCount);
for (int i = 0; i <= LINE_COUNT; i++) {
for (int j = 0; j <= LINE_COUNT; j++) {
offset = Offset(i.toDouble(), j.toDouble());
if (isBlankPosition(offset)) {
int score = chessmanGrade(offset, ownerPlayer: Player.black);
if (ourMap.minKey() < score) {
ourMap.put(score, Offset(offset.dx, offset.dy));
}
}
}
}
return ourMap;
}
计算敌方下一步较好的位置
BufferMap enemyBetterPosition({maxCount = 5}) {
Offset offset = Offset.zero;
BufferMap enemyMap = BufferMap.maxCount(5);
print(“查找敌方最优落子位置”);

int count = 0;
for (int i = 0; i <= LINE_COUNT; i++) {
for (int j = 0; j <= LINE_COUNT; j++) {
offset = Offset(i.toDouble(), j.toDouble());
if (isBlankPosition(offset)) {
DateTime start = DateTime.now();
int score = chessmanGrade(offset,
ownerPlayer:
computerPlayer == Player.black ? Player.white : Player.black);
DateTime end = DateTime.now();
count++;
int time = end.millisecondsSinceEpoch - start.millisecondsSinceEpoch;
if (time > 5) {
print(“查找敌方最优落子位置耗时:KaTeX parse error: Expected 'EOF', got '}' at position 17: …ime"); }̲ if (en…count”);
return enemyMap;
}
基础版本AI
Future nextByAI({bool isPrintMsg = false}) async {
//如果评分出现ALIVE4的级别,直接下
Offset pos = needDefenses();
if (pos != const Offset(-1, 0)) {
return pos;
}

// 取我方,敌方 各5个最优点位置,
// 防中带攻: 如果判断应该防守,则在敌方5个最优位置中找出我方优势最大的点落子
// 攻中带防: 如果判断应该进攻,则在己方5个最优位置中找出敌方优势最大的点落子
BufferMap ourPositions = ourBetterPosition();
BufferMap enemyPositions = enemyBetterPosition();

Offset position = bestPosition(ourPositions, enemyPositions);
return position;
}

Offset needDefenses() {
BufferMap enemy = enemyBetterPosition();
late Offset defensesPosition;
for (num key in enemy.keySet) {
print(“key:${key}”);
if (key >= ALIVE4) {
defensesPosition = enemy[key]!;
break;
} else {
defensesPosition = const Offset(-1, 0);
}
}
return defensesPosition;
}

//基础AI,没有涉及算法

//遍历当前棋盘上的空位置,然后逐个计算该空位的得分(位置分+组合分),然后取分数最高的点落子
Offset bestPosition(
BufferMap ourPositions, BufferMap enemyPositions) {
late Offset position;
double maxScore = 0;

///当对手的最优位置得分 / 我方最优位置得分 > 1.5 防守,反之进攻
if (enemyPositions.maxKey() / ourPositions.maxKey() > 1.5) {for (num key in enemyPositions.keySet) {int attackScore =chessmanGrade(enemyPositions[key]!, ownerPlayer: computerPlayer);double score = key * 1.0 + attackScore * 0.8;if (score >= maxScore) {maxScore = score;position = enemyPositions[key]!;}}
} else {for (num key in ourPositions.keySet) {int defenseScore =chessmanGrade(ourPositions[key]!, ownerPlayer: computerPlayer);double score = key * 1.0 + defenseScore * 0.8;if (score >= maxScore) {maxScore = score;position = ourPositions[key]!;}}
}
return position;

}

这个时候,一个基础的五子棋AI就实现啦,它也能和五子棋入门的选手碰一碰了!(玩了3把,稍微没注意就输了一把给它…)
基于Max-Min算法
本文算法内容,参考多篇与Max-Min算法相关文章:
井字游戏/一字棋——Max-Min智能算法
AI MinMax算法
计算机博弈 基本算法 极大极小算法
在基础版本的AI中,我们已经取得了下一步较好的maxCount个位置,有每个位置有着对应的分数,那么我们就可以把这些位置都落子一次,这个时候我们需要给每一种结果一个分数,就是下图中的Utility(下图是井字棋游戏,整体逻辑差不多)。这个分数是站在Max的角度评估的,比如上图中我赢了就是+1,输了是-1,平局时0。所以,我希望最大化这个分数,而我的对手希望最小化这个分数。(MaxMin算法在有限深度的范围内进行搜索,假定博弈双方都是最精明的,也就是每次都会选择可能获胜的最大值。那么对于我方来说,对方每次都会选取使我方获胜的最小值MIN;我方会选择使我方获胜的最大值MAX。)

大部分游戏是不太可能把所有结果都列出来的,因为计算量会过于庞大,所以我们可能只能往前推7,8步(根据算力),所以这个时候分数就不只-1,0,1这么简单了。那么我们如何如何确定最后的落子地点呢?就是模拟棋盘,往后模拟几步,生成这颗博弈树,再向上反推,找到双方最优的落子地点。
具体的算法细节可以看下上面参考的几篇文章,在看这个算法之前需要了解基础的广度优先搜索(BFS),深度优先搜索(DFS)。

回到我们的编码部分
在开始具体的算法编写前,我们还需要一些前置的参数:
enum ChildType {
/// 标记当前节点为对手节点,会选择使我方得分最小的走势
MIN,

/// 标记当前节点为我方节点,会选择使我方得分最大的走势
MAX
}

class ChessNode{
/// 当前节点的棋子
Chessman current;
/// 当前节点的父节点
ChessNode parentNode;
/// 当前节点的所有子节点
List childrenNode = [];
/// 当前节点的值
num value = double.nan;
/// 当前节点的类型(我方/敌方)
ChildType type;
/// 当前节点值的上限
num maxValue;
/// 当前节点值的下限
num minValue;
/// 当前节点的层深度
int depth = 0;
/// 用于根节点记录选择的根下子节点
Chessman checked;
}
使用算法相较于前面的基础版本AI就是多了模拟棋盘的步骤:
生成临时棋局
/// 生成临时棋局
List createTempChessmanList(ChessNode node) {
//growable是一个可选参数,用于指定是否允许在列表中添加或删除元素。
//当growable为false时,列表的长度是固定的,并且不能添加或删除元素;当growable为true时,列表的长度是可变的,可以随时添加或删除元素。
List temp = List.from(chessmanList, growable: true);
temp.add(node.current!);

ChessNode? current = node.parentNode;
while (current != null && current.current != null) {
temp.add(current.current!);
current = current.parentNode;
}
return temp;
}
生成博弈树子节点
/// 生成博弈树子节点
void createChildren(ChessNode parent) {
if (parent == null) {
return null;
}

// 判断是否达到最大深度,如果是则计算棋局估值并返回
if (parent.depth > maxDepth) {
List list = createTempChessmanList(parent);
var start = DateTime.now();
parent.value = statusScore(our, list);
var value = DateTime.now();
return;
}

// 确定当前玩家和子节点类型
Player currentPlayer = parent.current!.owner == Player.black ? Player.white : Player.black;
ChildType type = parent.type == ChildType.MAX ? ChildType.MIN : ChildType.MAX;

// 创建临时棋子列表
var list = createTempChessmanList(parent);

// 查找最优落子位置
var start = DateTime.now();
BufferChessmanList enemyPosList = enemyBestPosition(list, maxCount: 5);
var value = DateTime.now();

// 将最优落子位置放入列表中
OffsetList offsetList = OffsetList()…addAll(enemyPosList.toList());
List result = offsetList.toList();

// 遍历最优落子位置,生成子节点
for (Offset position in result) {
Chessman chessman = Chessman(position, currentPlayer);

ChessNode node = ChessNode()..parentNode = parent..current = chessman..type = type..depth = parent.depth + 1..maxValue = parent.maxValue..minValue = parent.minValue;parent.childrenNode.add(node);// 递归调用 createChildren 方法生成子节点的子节点,直到达到最大深度或无法再生成子节点为止。
createChildren(node);

}
}
生成五子棋博弈树
//生成五子棋博弈树
ChessNode createGameTree() {
//创建根节点 root,设置其属性值:深度为0,估值为NaN,节点类型为 ChildType.MAX,最小值为负无穷,最大值为正无穷。
ChessNode root = ChessNode()
…depth = 0
…value = double.nan
…type = ChildType.MAX
…minValue = double.negativeInfinity
…maxValue = double.infinity;

//确定当前玩家 currentPlayer
//如果棋子列表 chessmanList 为空,则当前玩家为黑色
//否则,根据棋子列表中最后一个棋子的颜色设置当前玩家为另一个颜色。
Player currentPlayer;
if (chessmanList.isEmpty) {
currentPlayer = Player.black;
} else {
currentPlayer =
chessmanList.last.owner == Player.black ? Player.white : Player.black;
}

//查找敌方最优落子位置,并将结果存储在 enemyPosList 变量中。
//然后,将 enemyPosList 转换为 OffsetList 对象
//再将其转换为普通列表类型 List 对象。这些位置将用于创建第一层子节点。
BufferChessmanList enemyPosList =
enemyBestPosition(chessmanList, maxCount: 5);

OffsetList list = OffsetList()…addAll(enemyPosList.toList());
List result = list.toList();

int index = 0;
//通过遍历 result 列表,为每个位置 position 创建一个新的棋子 chessman 和一个新的子节点 node
//然后将子节点 node 添加到根节点的子节点列表 root.childrenNode 中
for (Offset position in result) {
Chessman chessman = Chessman(position, currentPlayer);

ChessNode node = ChessNode()..parentNode = root..depth = root.depth + 1..maxValue = root.maxValue..minValue = root.minValue..type = ChildType.MIN..current = chessman;root.childrenNode.add(node);
var start = DateTime.now();
createChildren(node);
var create = DateTime.now();print('创建第一层第$index个节点耗时:${create.millisecondsSinceEpoch - start.millisecondsSinceEpoch}');
index++;

}
return root;
}
Max-Min算法实现
num maxMinSearch(ChessNode root) {
if (root.childrenNode.isEmpty) {
return root.value; // 返回叶子节点的估值
}
List children = root.childrenNode;
if (root.type == ChildType.MIN) {
// 如果是对手执行操作
for (ChessNode node in children) {
if (maxMinSearch(node) < root.maxValue) {
// 判断子节点的估值是否小于当前节点的最大值
root.maxValue = node.value; // 更新当前节点的最大值
root.value = node.value; // 更新当前节点的估值
root.checked = node.current!; // 更新当前节点的选择步骤
} else {
continue; // 否则继续遍历下一个子节点
}
}
} else {
// 如果是自己执行操作
for (ChessNode node in children) {
if (maxMinSearch(node) > root.minValue) {
// 判断子节点的估值是否大于当前节点的最小值
root.minValue = node.value; // 更新当前节点的最小值
root.value = node.value; // 更新当前节点的估值
root.checked = node.current!; // 更新当前节点的选择步骤
} else {
continue; // 否则继续遍历下一个子节点
}
}
}
return root.value; // 返回当前节点的估值
}
基于alpha-beta剪枝算法
如果在比赛中,假设使用极小极大的算法,计算机能往前评估7步,加上剪枝算法,计算机就能往前评估14步!
num alphaBetaSearch(ChessNode current) {
count++; // 搜索次数累加

if (current.childrenNode.isEmpty) { // 如果当前节点没有子节点,即为叶子节点
return current.value; // 返回该节点的值
}

if (current.parentNode != null && !current.parentNode!.childrenNode.contains(current)) {
ChessNode parent = current.parentNode!;

// 如果父节点存在且父节点的子节点不包含当前节点,说明该枝已经被剪掉,返回父节点的最大/最小值
return parent.type == ChildType.MAX ? parent.minValue : parent.maxValue;

}

List children = current.childrenNode; // 获取当前节点的子节点

if (current.type == ChildType.MIN) { // 当前节点为MIN节点
num parentMin = current.parentNode?.minValue ?? double.negativeInfinity; // 获取父节点的最小值,若不存在父节点则设置为负无穷大
int index = 0; // 索引计数器

for (ChessNode node in children) {index++; // 索引递增num newCurrentMax = min(current.maxValue, alphaBetaSearch(node)); // 计算当前子节点的最大值if (newCurrentMax <= parentMin) {// 如果当前子节点的最大值小于等于父节点的最小值,则说明该枝可以被完全剪掉current.childrenNode = current.childrenNode.sublist(0, index); // 将当前节点的子节点列表截断至当前索引位置return parentMin; // 返回父节点的最小值}if (newCurrentMax < current.maxValue) {// 如果当前子节点的最大值小于当前节点的最大值,则更新当前节点的最大值、值和经过路径的位置信息current.maxValue = newCurrentMax;current.value = node.value;current.checked = node.current!;}
}if (current.maxValue > parentMin) {// 如果当前节点的最大值大于父节点的最小值,则更新父节点的最小值、值和经过路径的位置信息current.parentNode?.minValue = current.maxValue;current.parentNode?.value = current.value;current.parentNode?.checked = current.current!;
}return current.maxValue; // 返回当前节点的最大值作为该节点在搜索树中的价值

} else { // 当前节点为MAX节点
num parentMax = current.parentNode?.maxValue ?? double.infinity; // 获取父节点的最大值,若不存在父节点则设置为正无穷大
int index = 0; // 索引计数器

for (ChessNode node in children) {index++; // 索引递增num newCurrentMin = max(current.minValue, alphaBetaSearch(node)); // 计算当前子节点的最小值if (parentMax < newCurrentMin) {// 如果父节点的最大值小于当前子节点的最小值,则说明该枝可以被完全剪掉current.childrenNode = current.childrenNode.sublist(0, index); // 将当前节点的子节点列表截断至当前索引位置return parentMax; // 返回父节点的最大值}if (newCurrentMin > current.minValue) {// 如果当前子节点的最小值大于当前节点的最小值,则更新当前节点的最小值、值和经过路径的位置信息current.minValue = newCurrentMin;current.value = node.value;current.checked = node.current!;}
}if (current.minValue < parentMax) {// 如果当前节点的最小值小于父节点的最大值,则更新父节点的最大值、值和经过路径的位置信息current.parentNode?.maxValue = current.minValue;current.parentNode?.value = current.value;current.parentNode?.checked = current.current!;
}return current.minValue; // 返回当前节点的最小值作为该节点在搜索树中的价值

}
}
Max-Min和剪枝算法曾在IBM开发的国际象棋超级电脑,深蓝(Deep Blue)中被应用,并且两次打败当时的世界国际象棋冠军。文章到这里,五子棋的AI版本就完成了!

关于我
Hello,我是Taxze,如果您觉得文章对您有价值,希望您能给我的文章点个❤️,有问题需要联系我的话:我在这里 。如果您觉得文章还差了那么点东西,也请通过关注督促我写出更好的文章~万一哪天我进步了呢?😝

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/125113.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

竞赛 机器视觉人体跌倒检测系统 - opencv python

0 前言 &#x1f525; 优质竞赛项目系列&#xff0c;今天要分享的是 &#x1f6a9; 机器视觉人体跌倒检测系统 该项目较为新颖&#xff0c;适合作为竞赛课题方向&#xff0c;学长非常推荐&#xff01; &#x1f947;学长这里给一个题目综合评分(每项满分5分) 难度系数&…

JVM篇---第三篇

系列文章目录 文章目录 系列文章目录一、什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”?二、Java内存结构三、说说对象分配规则一、什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”? Java虚拟机是一个可以执行Java字节码的虚拟机进程。Java源文…

C#捕捉全局异常

1.运行图片 2.源码 using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using System.Windows.Forms;namespace 捕捉全局异常 {internal static class Program{/// <summary>/// 应用程序的主入口点。/// </summary…

【算法|动态规划No.9】leetcodeLCR 091. 粉刷房子

个人主页&#xff1a;兜里有颗棉花糖 欢迎 点赞&#x1f44d; 收藏✨ 留言✉ 加关注&#x1f493;本文由 兜里有颗棉花糖 原创 收录于专栏【手撕算法系列专栏】【LeetCode】 &#x1f354;本专栏旨在提高自己算法能力的同时&#xff0c;记录一下自己的学习过程&#xff0c;希望…

MongoDB数据库网站网页实例-编程语言Python+Django

程序示例精选 PythonDjangoMongoDB数据库网站网页实例 如需安装运行环境或远程调试&#xff0c;见文章底部个人QQ名片&#xff0c;由专业技术人员远程协助&#xff01; 前言 这篇博客针对《PythonDjangoMongoDB数据库网站网页实例》编写代码&#xff0c;代码整洁&#xff0c;…

波奇学C++:AVL树

AVL解决二叉搜索树退化成链表&#xff0c;保证左右子树高度不差过1&#xff0c;尽可能接近满二叉树 AVL树的性质&#xff1a;高度差&#xff08;平衡因子&#xff09;的绝对值不超过1&#xff08;-1/0/1&#xff09; 平衡因子&#xff1a;右子树高度-左子树高度 用平衡因子控…

【计算机网络】HTTPS协议详解

文章目录 一、HTTPS协议 介绍 1、1 HTTP协议不安全的体现 1、2 什么是 HTTPS协议 二、加密的一些概念 2、1 怎么理解加密 2、2 为什么要加密 2、3 常见的加密方式 2、2、1 对称加密 2、2、2 非对称加密 三、HTTPS协议探究加密过程 3、1 只使用对称加密 3、2 只是用非对称加密 3…

javaee spring整合mybatis

案例一 包含dao层 创建maven webapp项目 maven仓库需要改为阿里云 引入依赖 <?xml version"1.0" encoding"UTF-8"?><project xmlns"http://maven.apache.org/POM/4.0.0" xmlns:xsi"http://www.w3.org/2001/XMLSchema-inst…

HarmonyOS/OpenHarmony原生应用-ArkTS万能卡片组件Badge

可以附加在单个组件上用于信息标记的容器组件。该组件从API Version 7开始支持。 支持单个子组件。子组件类型&#xff1a;系统组件和自定义组件&#xff0c;支持渲染控制类型&#xff08;if/else、ForEach和LazyForEach&#xff09;。 一、接口 方法1&#xff1a; Badge(value…

【MySql】Mysql之备份与恢复

目录 一、mysql日志概述 1、日志类型与作用 2、日志配置文件 3、日志配置文件的查询 二、备份的类型❤ 1、物理备份 1.1 冷备份 1.2 热备份 1.3 温备份 2、逻辑备份❤ 2.1 完全备份 2.2 差异备份 2.3 增量备份 2.5 如何选择逻辑备份策略 3、常见的备份方法 3…

ARMv8如何读取cache line中MESI 状态以及Tag信息(tag RAM dirty RAM)并以Cortex-A55示例

Cortex-A55 MESI 状态获取 一&#xff0c;系统寄存器以及读写指令二&#xff0c;Cortex-A55 Data cache的MESI信息获取&#xff08;AARCH 64&#xff09;2.1 将Set/way信息写入Data Cache Tag Read Operation Register2.2 读取Data Register 1和Data Register 0数据并解码 参考…

【Docker Compose】Docker Compose 的安装,使用以及实现微服务集群的部署

文章目录 前言&#xff1a;Docker 部署存在的问题一、 初识 Docker Compose1.1 什么是 Docker Compose1.2 在 CentOS 上安装 Docker Compose 二、深入了解 Docker Compose 文件2.1 Docker Compose 文件概述2.2 Docker Compose 文件详解2.3 示例&#xff1a;编写 Docker Compose…