由于2020的特殊情况,导致了一个被拖了挺久的大作业。。。。
五子棋其实大家很多时候会在闲暇时刻和朋友随便玩玩,这不仅让我回忆起了高中时候摸鱼休息就喜欢和同学在自己打的格子中用铅笔来一盘五子棋,回想起来确实是至今以来最快乐的一段时光。
高考前最后一次晚自习,当时离开时好像没什么不舍,但不知是什么驱使着我在临走之前留下了这样一张照片,小小的教室里承载了许多光阴,或许我们只是这间教室匆匆岁月中的一位过客,但于我而言,这段时光却是无法取代的。
OK,言归正传,咱们来谈谈五子棋。
先来看看什么是五子棋,源自百度百科——五子棋是全国智力运动会竞技项目之一,是一种两人对弈的纯策略型棋类游戏。通常双方分别使用黑白两色的棋子,下在棋盘直线与横线的交叉点上,先形成五子连线者获胜。
然后我们大家应该也都能知道一些基本棋形,但是这里我们来具体讲一讲他们的术语是什么。
阳线:直线,棋盘上可见的横纵直线。
阴线:斜线,由交叉点构成的与阳线成45°夹角的隐形斜线。
长连:五枚以上同色棋子在一条阳线或阴线上相邻成一排。
五连:只有五枚同色棋子在一条阳线或阴线上相邻成一排。
活四:有两个点可以成五的四。
冲四:只有一个点可以成五的四。
死四:不能成五的四。
活三:再走一着可以形成活四的三。
连活三:连续、中间不隔空点的活三,即同色棋子在一条阳线或阴线上相邻成一排的活三。简称“连三”。
跳活三:中间隔有一个空点的活三。简称“跳三”。
眠三:再走一着可以形成冲四的三。
死三:不能成五的三。
说到这里,其实对五子棋稍有一些了解的人,都应该能发现先手执黑的胜率异常的大,但实际上,如果没有禁手,先手执黑其实是必胜的,大家有兴趣的话可以去网上搜索一番,什么花月蒲月之类的东西。
所以我们不能避开的就是聊聊什么是禁手。
整个对局过程中黑方有禁手,白方无禁手。黑方禁手有三三禁手、四四禁手和长连禁手三种。
规定:
好了,五子棋的知识大致讲了出来了,那么接下来我们就来看看整个项目核心所在吧——智能。
其实说起来,大家所熟知的深蓝或者阿尔法狗之类的人工智能应该说是算力极强,甚至可以说是拿钱堆出来的,当然一些棋谱之类的东西也是不可少的,机器学习之类的。我这里只做一些简单的工作,即搜索、评估、剪枝。
评估函数用以估计节点的重要性,被定义为从初始节点 S0 出发,经节点 n 到达目标节点Sg的所有路径中最小路径代价的估计值。一般形式为
f ( n ) = g ( n ) + h ( n ) f(n) = g(n) + h(n) f(n)=g(n)+h(n)
式中,g(n) 是从 S0 到节点n的实际代价,h(n) 是从节点n到目标节点 Sg 的最优路径的估计代价。
在五子棋项目中,根据评分表对节点n处的局面进行一个评分,以这个分数作为由节点n到达目标节点的最小路径代价的估计值。
博弈算法,在极大极小值搜索中应用alpha-beta剪枝。使用这种算法,就是估计几步之内放在哪个位置最有利。根据当前局面,评估每一个可以落子的位置,看看在这儿落子后的得分怎样。极大极小值搜索就是在估计自己走的时候,选得分高的,估计别人走的时候,选得分低的,这里的低是对自己而言的,也就是假设对手选了对他最有利的位置,当然就是让我们得分最低的位置了。然后这样假设几步,选最好的。因为这样的搜索空间往往太大了,则我们需要用alpha-beta剪枝,采用深度优先搜索策略,在应用评估函数计算出叶节点的估值后,估算该叶节点祖先的估值范围,若从某节点或分枝不能得到更大利益,则剪枝,对没有必要的走步就不分析了。
这里为了便于理解附一下图(图源老师课件,如果有侵权行为请告诉我)。
OK,我们来再看看更大的树的情况
好了,分析一下,我们大致将项目分为业务逻辑与图形界面两部分。
一个五子棋游戏,需要一个图形化界面,以允许用户在这个游戏中进行操作。其中最基础的就是一个棋盘了,所以我们需要一个相应的棋盘,对应提供人类当前棋局信息和他的落子策略的实现。根据当前大部分情况,采用15*15的棋盘,并提供重玩和执黑执白选项给玩家选择。
先初始化一个UI,将一些必要的组件添加进容器中,然后将其实例化即可。
package myGoBang;
import java.awt.*;
import javax.swing.*;
import javax.swing.event.*;
import java.awt.event.*;
import java.lang.*;
public class UI {
private JFrame frame;//五子棋游戏窗口
//五子棋盘
private ChessBoard chessboard = new ChessBoard();//五子棋盘
//五子棋逻辑
private Chess chess = new Chess();
private JMenuBar menu;//菜单栏
private JMenu option;//菜单栏中的OPTION菜单
private JMenuItem replayOption;//OPTION下拉项中的RESTART选项,重玩一局
private JMenuItem AIFirstOption;//OPTION下拉项中的WHITE选项,机器先手
private JMenuItem HumanFirstOption;//OPTION下拉项中的BLACK选项,人类先手
//游戏运行入口
public static void main(String[] args){
new UI().init();
}
public void init(){
frame = new JFrame("GOBANG");
frame.setSize(518, 565);
frame.setLocationRelativeTo(null);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
menu = new JMenuBar();
option = new JMenu("OPTION");
menu.add(option);
replayOption = new JMenuItem("RESTART");
AIFirstOption = new JMenuItem("WHITE");
HumanFirstOption = new JMenuItem("BLACK");
option.add(replayOption);
option.addSeparator();
option.add(AIFirstOption);
option.add(HumanFirstOption);
replayOption.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
chess.init();
chessboard.init();
}
});
AIFirstOption.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
//如果此时棋盘无子,则机器执黑先行
if(chessboard.isEmpty()) {
chess.FIRST = -1;
//机器先手,则先在中间位置下一个棋子
chessboard.addChessman(7, 7, -1);
chess.putChess(7, 7, -1);
}
else
JOptionPane.showMessageDialog(frame, "If you want to play as WHITE, please restart the game.","Message", JOptionPane.PLAIN_MESSAGE);
}
});
HumanFirstOption.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
if(chessboard.isEmpty()){
chess.FIRST = 1;
}
else
JOptionPane.showMessageDialog(frame, "If you want to play as BLACK, please restart the game.", "Message", JOptionPane.PLAIN_MESSAGE);
}
});
frame.setJMenuBar(menu);
frame.add(chessboard);
chess.init();
chessboard.init();
chessboard.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
play(e);
}
private void play(MouseEvent e) {
int cellSize = chessboard.getCellSize();//每个格子的边长
int x = (e.getX() - 5) / cellSize;//像素值转换成棋盘坐标
int y = (e.getY() - 5) / cellSize;//像素值转换成棋盘坐标
//判断落子是否合法
boolean isLegal = chess.isEmpty(x, y);
//如果落子合法
if(isLegal){
chessboard.addChessman(x, y, 1);//界面方面加一个棋子
chess.putChess(x, y, 1);//逻辑业务方面加一个棋子
//判断是否违反长连禁手
if (chess.longLink(x, y)){
JOptionPane.showMessageDialog(frame, "lose.", "Sorry, you lose.", JOptionPane.PLAIN_MESSAGE);
chessboard.init();
chess.init();
return;
}
//判断人类是否胜利
if(chess.isWin(x, y, 1)){
JOptionPane.showMessageDialog(frame, "win!", "Congratulations, you win!", JOptionPane.PLAIN_MESSAGE);
chessboard.init();
chess.init();
return;
}
//只有五连与禁手同时出现,才算胜利
if(chess.ban(x, y)){
JOptionPane.showMessageDialog(frame, "lose.", "Sorry, you lose.", JOptionPane.PLAIN_MESSAGE);
chessboard.init();
chess.init();
return;
}
//机器落子
Location loc = chess.search(x,y);
chessboard.addChessman(loc);
chess.putChess(loc.getX(), loc.getY(), loc.getOwner());
//判断是否违反长连禁手
if (chess.longLink(x, y)){
JOptionPane.showMessageDialog(frame, "win!", "Congratulations, you win!", JOptionPane.PLAIN_MESSAGE);
chessboard.init();
chess.init();
return;
}
//判断机器是否胜利
if(chess.isWin(loc.getX(), loc.getY(), -1)){
JOptionPane.showMessageDialog(frame, "lose.", "Sorry, you lose.", JOptionPane.PLAIN_MESSAGE);
chessboard.init();
chess.init();
return;
}
if(chess.ban(loc.getX(), loc.getY())){
JOptionPane.showMessageDialog(frame, "win!", "Congratulations, you win!", JOptionPane.PLAIN_MESSAGE);
chessboard.init();
chess.init();
return;
}
}
}
});
frame.setVisible(true);
}
}
程序中需要有一个数据结构存储棋子的信息,这些信息包括棋子坐标,棋子颜色,以及这一点的分数。这样一个数据结构的实例与图形化界面中的一个棋子对应,反映该棋子的信息。
棋子类中包含四个属性,分别表示这一棋子的横坐标、纵坐标、占据该点的棋手(人或电脑)和这一点的分数。
private int x;//棋盘上点的横坐标0-14
private int y;//棋盘上点的纵坐标0-14
private int owner;//棋盘上占据该点的棋手,1表示人类,-1表示电脑,0表示空
private int score;//棋盘上该点的得分
Location类中还需要有set get方法,以便获取和修改值。
在图形化界面中需要显示一个棋盘提供给用户使用,那么在程序中就需要有一个数据结构存储棋盘的信息,这个数据结构存储的信息需要与图像化界面显示的信息实时一致。这个数据结构需要存储的基本信息包括棋盘大小,棋盘线间距,当前棋盘上哪些位置有落子以及这些棋子的颜色等信息。
棋盘类中包含五个属性,分别是棋盘大小(表示棋盘函数行数,列数)、棋盘背景色、棋盘线条色、已落子集合和棋盘边线距离。
public static final int Chessboard_Size = 15;//棋盘大小为15行15列
private Color backgroundColor = new Color(255, 245, 186);//棋盘背景色
private Color lineColor = new Color(66, 66, 66);//棋盘线条色
private ArrayList<Location> LocationList = new ArrayList<>();//棋盘上已落子的集合
private int edge = 20;//棋盘边缘到棋盘第一条线的距离
类中还有若干方法,不一一赘述了。
五子棋游戏当中需要有一个评估函数,这个评估函数可以对于当前的每一个空点进行评估,然后赋给这一点一个分数。一个点得分越高,那么电脑在这一点落子能获得的利益就越大,相应的玩家获得的利益就越小。这个评估函数用来指导电脑面对一个特点的棋局该如何下棋,简单来讲就是电脑应该选择分数最高的空点落子,这样对于电脑越有利,也就是电脑越有可能赢。
我们选择了一种叫做五元组评分法的评估方法。五子棋要赢,必然要有五个棋子连成一线,那么我们就可以计算棋盘中每一个五格相连的线,以下称为五元组。一般情况下棋盘是15*15的,那么应该是572个五元组。针对五元组中黑子和白子的数量(可以不考虑相对位置)的不同,给该五元组评不同的分。然后棋盘上每一个位置的得分就是包含这个位置的所有五元组的得分之和。
棋盘上的一个五元组可以是横向的、纵向的、右上左下的或左上右下的。评估函数需要遍历棋盘上所有五元组,计算出每个五元组的分数,然后将该分数加到组成五元组的每一个点的分数上。
写几个for循环即可,不再赘述。
这个游戏中还需要进行搜索,电脑需要针对当前局面向前搜索几层,比较各种情况,然后针对搜索后能得到最好的结果选择落子位置。相比与只面对当前棋局选择落子位置,在五子棋当中引入搜索后电脑面对棋局考虑的更多,也更远,这样做棋力能够得到明显的提升。
全局搜索影响搜索速度,因为当部分情况下没有必要搜索全棋盘,而且新一局落子往往不影响距离它较远的位置分数,所以这里我们不仅是用了alpha-beta剪枝,还使用了在每层展开时对应只展开分数前几名的情况。这样就可以进一步提升搜索速度。如图,对应极大极小搜索(仅为示意图,搜索节点比这个多),其中红色为人类落子蓝色为机器落子。对应第五层每个节点都会评估一次返回一个最大值给人类层,然后选取最小的一个作为人类的策略返回给第四层,但是第四层其他节点在搜索第五层时若遇到一个比前兄弟节点小的枝就剪掉,因为没必要,人类会选择对机器来说分数小的而机器是不希望走到这样的节点状态的。同理在向上,第二层节点在搜索第三层时,若遇到比前兄弟节点大的枝也剪掉,因为机器会选对他来讲最优的局面,而人类是不希望走到这样的节点状态的。
因为在进行评估的分数,我们希望机器的攻杀性能更优一些,所以调高了机器棋子的分数,所以在有一些情况下,机器会忽略掉防守,而去进攻。所以在这样的情况下,我们需要人为的去判断这一步杀棋是否会滞后于对方的杀棋,所以需要判断这一步是否需要防守,对于机器执白只需判断黑棋是否能胜利或活三等;而对于机器执黑还需要判断白棋是否能形成双三等无法围堵的状态。若果需要防守,就要放弃杀棋而去防守。
判断棋形,看有无违反禁手规则的情况发生。
因为只是由搜索评估算法搞定的,没有机器学习等,所以棋力并没有特殊的强,但是绝对是有的,并不是随随便便就能下赢。
事实上,我们寻找尝试了许多可以使用的评分标准,经过试验我们选择了其中一个较为良好的评分方法,但理论上应该还存在更好的评分方法,使用更好的评分方法还能进一步的提升棋力。而且可以使用一些经典开局之类的固定套路,使得早期开局较为合理。也可以进行一些随机化的算法,防止人类在赢了一次后利用同样的方式再次胜利。
同时,在搜索时,我们进行了一些限定来加速搜索,例如在叶节点进行局面评估时,只针对最新下棋的一点周围的方格评估,这是因为考虑到落子肯定是对这附近的一些局面产生影响,而对远处的局面影响较小;例如alpha-beta剪枝策略等等。但事实上还有一些可以考虑的方法来提高搜索速度。其实也还可以利用多核技术,利用多个线程,让算法实现并行计算,提高AI的速度。我们在第一层用一个线程分配器把第二层的候选节点分配给多个线程,每个线程包含着从第二层一个候选节点开始的搜索,然后等所有线程结束后,将所有线程的结果进行汇总,选出最大值。
最后的最后,也许是我们的结果略有问题,在某些时候,当人类某一步棋没体现出较强杀伤力的时候,机器也许因为思考很深,下在一个令人无法理解的位置,还有改正空间。
好了,这就是全部内容了,非常感谢我的队友兼队长的鼎力相助,才能完成这样一个项目吧,感恩感恩。