Connect6框架学习
在大三学年的秋季学期,人工智能导论课堂上开发了一个AI六子棋的项目,整个开发依托于一个开源的框架,在这里对框架与实现思路做一些简单的记录
基本框架
比赛如何开始?
主函数AITester()->oucLeague()->GameEvent根据HostID调用函数hostGames(),开始一场比赛;
关于hostGames:
public void hostGames(int hostId) throws CloneNotSupportedException {
ArrayList<Game> games = new ArrayList();
Player host = this.getHost(hostId);
Iterator var4 = this.players.iterator();
while(var4.hasNext()) {
Player player = (Player)var4.next();
if (!player.equals(host)) {
games.add(new Game((Player)host.clone(), (Player)player.clone()));
}
}
this.runGames(games);
}
- 简单来说就是读取properties文件中的hostId,作为host;
- 读取Players列表,选择除了host以外的作为自己的对手;
- games.add()创建对应比赛,最终形成一个比死列表,然后在runGames()中依次完成比赛,得到结果列表;
配置文件中相关项如下:
#the Ids of each player. corresponding ClassName, for example "stud.g77.AI"
Player_Ids = 2,14
#the group who hosts the games with each other groups.
#at every game, the host plays first, i.e. who uses the white stone.
Host = 2
- runGames会遍历当前的games列表,对于其中的每个元素转换为Game对象,使用game.start()开始一次对局;
- 上述函数在Game类中,最终的效果是将一局游戏启动为一个Thread,并运行该game.run();
Game类中相关函数如下:
public void run() {
if (Configuration.GUI) {
this.referee.setUi(this);
}
int steps = 1;
Move move;
for(Move currMove = null; this.running(); currMove = move) { //running()布尔值判断游戏是否还在运行
//处理游戏结束的逻辑,不同的结束原因给出对应的提示;
if (this.referee.gameOver()) {
this.endingGame("F", (Move)null);
break;
}
if (steps > Configuration.MAX_STEP) {
this.endingGame("M", (Move)null);
break;
}
//检查当前是谁在下棋,创建对应的Player实例以应用对应的算法
Player currPlayer = this.referee.whoseMove();
currPlayer.startTimer();
move = null;
try {
move = currPlayer.findMove(currMove);
} catch (Exception var6) {
//由于Player实例出错导致的游戏结束;
this.endingGame("E", (Move)null);
break;
}
currPlayer.stopTimer();
if (Thread.interrupted()) {
break;
}
//由于当前Player实例犯规导致的游戏结束;
if (!this.referee.legalMove(move)) {
this.endingGame("N", move);
break;
}
//观战者相关的逻辑,或许与GUI的绘制有关;
this.setChanged();
this.notifyObservers(move);
++steps;
}
}
- anyway,最核心的方法实际上就是我们的AI要继承Player类,去按照六子棋的合法规则,实现findMove()方法;
- 那么首要的,要明确findMove()的返回值,Move类是怎样的;
作为一个框架,其内容纷繁,我们看它的核心部分:
public class Move {
public static final int SIDE = 19;//规定棋盘大小
public static final char MAXCHAR = 'S';//字符索引最大值,19*19的棋盘,用'A'到'S'来对印索引
static final int MAX_INDEX = 360;//二维棋盘19*19 = 361后压缩到一维数组中(从0开始)对应的最大索引值,这个360就是(19,19)的位置
private static final int STEP_C = 1;//数字索引最小值
private static final int STEP_R = 19;//数字索引最大值
private static final int INDEX_ORIGIN = -1300;
private int _index0;//表示当前Move的数字索引,一维数组的,一个数字就可以代表二维的一个坐标
private int _index1;
private char _col0;//表示当前Move的字符索引,二维数组的,结合_row0,一起代表一个坐标
private char _row0;
private char _col1;
private char _row1;
关键点是明确一个Move对象的属性值是如何对应到具体的落子位置的,有两种规则:
- 字符规则col,row分别取A到S的某个字符
- 整型规则index,取一个数字直接对应到转换后的一维数组
(index计算规则也很简单,不用看代码就可以想到:index = 列号 + (行号-1)*19 - 1,注意行列号从1开始最大到19,最后再减1是因为一维数组从0开始)
明确了Move之后,我们还应该知道,每个Player实际上自己维护了一个Board;
在findNextMove()中会用如下规则更新:
board.makeMove(opponentMove);
BoardPro board = getBoard();
bestMoveCandidate = board.findwinMoves();
if (bestMoveCandidate != null) {
board.makeMove(bestMoveCandidate);
return bestMoveCandidate;
}
进一步查看makeMove()也可以体会到前面关于Move到说明
public void makeMove(Move mov) {
assert this.legalMove(mov);
this.moveList.add(mov);
this.set(mov.col0(), mov.row0(), this._whoseMove);
this.set(mov.col1(), mov.row1(), this._whoseMove);
this._whoseMove = this._whoseMove.opposite();
}
当然Board肯定也是一个绕不过去的数据结构,接下里对其进行说明。
Board对象中比较关键的数据结构如下:
private final PieceColor[] _board;//这就是实际代表board的一维数组;
上述数组是一个PieceColer的对象,这个类型就是界定了当前的黑方棋子、白方棋子、空格;
public enum PieceColor {
EMPTY,
BLACK,
WHITE;
//...
在这一节的最后在简单说明一下findNextMove()的逻辑;
- 维护board,使用opponentMove更新自己的board;
- 维护board,用自己决定的Move,更新自己的board;
- 将自己的Move返回;
(自己的Move自己更新,将自己的返回給对手,对手用来更新)
博弈算法
判断是否有必胜招式,逻辑集成在findWinMoves()中;
在开始具体的算法之前,我们要明确一个关键的数据结构,即路Road与路表RoadTable
先从一个高度的概念范畴理解:
- Road:在六子棋中,任何连续的 6 个位置(横、竖、斜)都可能连成六子。
- RoadTable:RoadTable 预先计算并存储了棋盘上所有这些可能的 6 连位置。
- 好处:这使得 AI 无需遍历整个棋盘去寻找连珠,而是通过 O(1) 的时间复杂度直接从集合中获取威胁信息。
好处的例子(结合后面的说明很好理解);
roadSetsByStoneCount[blackCount][whiteCount] 存储了所有包含 blackCount 个黑子和 whiteCount 个白子的“路”。
即时威胁识别:
如果 roadSetsByStoneCount[5][0] 不为空,说明黑方已经有“五连”,下一手必胜。
如果 roadSetsByStoneCount[0][4] 不为空,说明白方有“四连”,黑方必须防守。
关于Road,从其数据结构进行把握
//主要是根据 起始位置 方向 黑白子数量来界定一个路;
//默认的前提是一个路最多6个棋子;
public Road(int startPos, int dir, int blackNum, int whiteNum, boolean active) {
super();
this.startPos = startPos;//起始位置,对应的就是一维board的index
this.dir = dir;//方向,数字0~3,后面的路表初始化也可以体现(分别是横、竖、左斜、右斜)
//不用考虑前后,仅管往前,因为不同的startPos会覆盖到当前位置“往后的情况”
this.blackNum = blackNum;//黑子数量
this.whiteNum = whiteNum;//白子数量
this.isLegal = active;//是否合法
}
关于RoadTable
//基本路表
private Road[][] roadsByStartPos = new Road[SIDE * SIDE][4];
// 初始化路表 (伪代码)
function iniRoadTable():
for i from 0 to SIDE-1: // 遍历行
for j from 0 to SIDE-1: // 遍历列
for k from 0 to 3: // 遍历4个方向 (横, 纵, 斜上, 斜下)
// 1. 预判终点:计算当前点往方向k延伸5步后的坐标
endCol = i + FORWARD[k].x * 5
endRow = j + FORWARD[k].y * 5
// 2. 合法性检查:判断终点是否还在棋盘内
active = isValidSquare(endCol, endRow)
// 3. 索引转换:二维坐标转一维
startIndex = getIndex(i, j)
// 4. 创建路对象:记录起点、方向、初始棋子数(0,0)、是否激活
road = new Road(startIndex, k, 0, 0, active)
// 5. 存入全量表:无论是否合法都存入 roadsByStartPos
roadsByStartPos[startIndex][k] = road
// 6. 存入分类表:只有合法的路才加入"0黑0白"的集合中
if active is true:
roadSetsByStoneCount[0][0].add(road)
}