使用java swing制作人机五子棋
背景
算法老师要求交个大作业什么的,自己就选择了制作“利用所学算法知识设计一个人机对弈程序或软件”这个课题,顺便首次记录一下自己独自写一个小项目的过程,中间花费了不少心思,果然只有亲身经历才能深刻体会算法和设计模式的魅力,写下这篇文章也算对自己的努力有个交代。
算法原理
使用四种符号代表棋盘上各个点的状态:X代表黑棋(AI),O代表白棋(人类),L代表无人下棋,E代表越界(棋盘范围之外)。
想要AI下棋,就必须掌握当前局势,知道自己应该或更倾向走哪个位置。这里采用贪婪策略,计算AI走这个点的分值,每个点都算一次,最后选取分值最大的点,标记为X,即完成下棋,等待玩家下棋,重复循环。
棋盘分值更新范围
想要实现贪婪,就必须使得每个点的分值都可以获取到,然而,这并不代表每下一个棋子,都必须更新n*n的棋盘,这是因为每个棋子影响的范围是有限的,你下最左上角的一个棋子,再怎么样也不可能和最右下角的棋子凑成五连,或是阻碍右下角的棋子凑成五连,这里定义一个词语“米围”:以自己为中心,上面四个,下面四个,左边四个,右边四个,左上四个,左下四个,右上四个,右下四个,共二十八个位置,这就是一个点的影响范围,如图所示,白点是下的点,而红色区域是因为白点的存在而战局意义改变的点,而除红色区域外的点白点都无法阻止或凑成五连。
// 计算刚才下的点的"米围"点的分值
public void calculateScore(int a, int b) {
for (int i = a - 4; i <= a + 4; i++) {
updatePoint(i, b, "|");//从上往下
}
for (int i = b - 4; i <= b + 4; i++) {
updatePoint(a, i, "-");//从左往右
}
for (int i = a - 4, j = b - 4; i <= a + 4; i++, j++) {
updatePoint(i, j, "\\");//从左上往右下
}
for (int i = a - 4, j = b + 4; i <= a + 4; i++, j--) {
updatePoint(i, j, "/");//从右上往左下
}
}
更新分值方法
首先如果这个点已经有白棋或黑棋占领,即符号为X或O,则将其分值变为-1,就永远也不可能下这个点了。如果还是空位,就必须观察自己周围的局势,判断自己下这个点有何意义:是为了凑成五连,还是为了阻止五连?可以凑成四连进攻一手,还是堵个四连防守一下?
这个点的分值被四个方向的点影响着,正好也是“米围”的点,因为如果下了这个点,就可能让和“米围”的点凑成五连,因此就只需要观察“米围”的点就行了。此处可以拆成四处,即“——”,“|”,“\”,“/”四个方向。
这里由于计算分值总是被动的(只有每次玩家和AI下完棋后才会更新分值),因此计算分值时可以得知“刚下的点”在“要计算的点”的哪一个方向,这样就可以每次更新一个方向,只不过需要一个nn4的数组记录一下,当然也可以每次都计算四个方向(反正人反应不过来,还能节省内存),这里使用前者,空间换时间。
private void updatePoint(int a, int b, String string) {
char c = battle.getBattle(a, b);
if (c != 'L') {
setPoint(a, b, -1);
return;
}
String s = "";
switch (string) {
case "|":
for (int i = a - 4; i <= a + 4; i++) {
s += battle.getBattle(i, b);//从上往下
}
this.pointDetil[a][b][0] = getScore(s);//代表丨的局势
break;
case "-":
for (int i = b - 4; i <= b + 4; i++) {
s += battle.getBattle(a, i);//从左往右
}
this.pointDetil[a][b][1] = getScore(s);//代表——的局势
break;
case "\\":
for (int i = a - 4, j = b - 4; i <= a + 4; i++, j++) {
s += battle.getBattle(i, j);//从左上往右下
}
this.pointDetil[a][b][2] = getScore(s);//代表\的局势
break;
case "/":
for (int i = a - 4, j = b + 4; i <= a + 4; i++, j--) {
s += battle.getBattle(i, j);//从右上往左下
}
this.pointDetil[a][b][3] = getScore(s);//代表/的局势
break;
}
this.point[a][b] = getSumPoint(a, b);//计算总分值
return;
}
// 计算总的分值(横竖斜加起来)
private int getSumPoint(int a, int b) {
return this.pointDetil[a][b][0] + this.pointDetil[a][b][1] + this.pointDetil[a][b][2]
+ this.pointDetil[a][b][3];
}
判断分值方法
现在我们得到了代表局势的字符串,接下来就该考虑如何计算分值了,例如我们取得了“LXXXLOOLL”,由于“米围”的范围,所以这必是个长度为9的字符串,由于只有第四位为L时才能下棋,所以第四位必为L。
“LXXXLOOLL”这9个字符中,我们可以知道下这个点就可以凑成一个四连,并阻止白棋的二连,这是对己方有利的,因此就可以给特定的分值。
可以很轻易的用正则表达式实现:
// 计算分值
protected int getScore1(String str) {
int L_num = 0;
int O_num = 0;
int X_num = 0;
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) == 'L') {
L_num++;
} else if (str.charAt(i) == 'X') {
X_num++;
} else if (str.charAt(i) == 'O') {
O_num++;
}
}
String strR = new StringBuilder(str).reverse().toString();// 字符串反转
if (X_num > 3) {
if (str.matches("^..XXLXX.*") || str.matches("^.XXXLX.*") || strR.matches("^.XXXLX.*")
|| str.matches("^XXXXL.*") || strR.matches("^XXXXL.*")) {
return 40000;// 获胜分
}
}
if (O_num > 3) {
if (str.matches("^..OOLOO.*") || str.matches("^.OOOLO.*") || strR.matches("^.OOOLO.*")
|| str.matches("^OOOOL.*") || strR.matches("^OOOOL.*")) {
return 10000;// 救命分
}
}
if (L_num > 2) {
if (X_num >= O_num) // 优先进攻
if (str.matches("^.XXXLL.*") || strR.matches("^.XXXLL.*")) {
return 2500;// 进攻分
} else if (str.matches("^..XXL[LX][LX].*") || strR.matches("^..XXL[LX][LX].*")) {
return 1000;
} else if (str.matches("^.[LX][LX][LX]LX.*") || strR.matches("^.[LX][LX][LX]LX.*")) {
return 400;
}
} else {
if (str.matches("^.OOOLL.*") || strR.matches("^.OOOLL.*")) {
return 2100;// 防守分
} else if (str.matches("^..OOL[LO][LO].*") || strR.matches("^..OOL[LO][LO].*")) {
return 900;
} else if (str.matches("^.[LO][LO][LO]LO.*") || strR.matches("^.[LO][LO][LO]LO.*")) {
return 300;
}
}
return 1;
}
不过上述使用正则表达式的方法并非明智之举,因为它匹配时间长(时间复杂度大约为平方级n2,此处n为9,不过我加了"^",所以可能优化为线性级n的了),还要许多次(取决于你想要的精度m),还不一定匹配得到,需要的时间O(m*n2)。下面介绍一种只要O(n)的算法:
以黑棋为例,首先从中间向两边检测,判断其连续为X或L的最大长度,只有其大于等于5时,黑棋才有可能在此条路线上五连,白棋同理,假如黑白棋都无法五连,那这个点(在这个方向的局势上)便没有任何下的意义,甚至连阻挡白棋都不需要,因为谁都无法五连。然后通过占比确定此路径上是白棋占优还是黑棋占优,以此决定是进入防御模式还是攻击模式:1)防御模式:假如此点下白棋,白棋组成的连数越高,得分越高;2)攻击模式:假如此点下黑棋,黑棋组成的连数越高,得分越高。此方法就只要数数量就可以看出局势的状况了
下面是代码,详细见注释:
protected int getScore(String str) {
int lxLength = 0;// 从中间向两边为X或L的最大长度,该数字大于等于5时,X下此位置才有机会通过此线获胜
int xNum = 0;// lxLength中X的个数,lxLength大于大于等于5且其中xNum占比越高,X下次位置进攻性越强
int loLength = 0;// 从中间向两边为O或L的最大长度,该数字大于等于5时,O下此位置才有机会通过此线获胜
int oNum = 0;// loLength中X的个数,loLength大于大于等于5且其中oNum占比越高,X越需要下次位置进行防守
boolean canXAdd = true;// 表示lxLength是否还能增长,遇到O变false
boolean canOAdd = true;// 表示loLength是否还能增长,遇到X变false
for (int i = 4; i < str.length(); i++) {
if (str.charAt(i) == 'L') {
if (canXAdd) {
lxLength++;
}
if (canOAdd) {
loLength++;
}
} else if (str.charAt(i) == 'X') {
canOAdd = false;
if (canXAdd) {
lxLength++;
xNum++;
}
} else if (str.charAt(i) == 'O') {
canXAdd = false;
if (canOAdd) {
loLength++;
oNum++;
}
}
}
canXAdd = true;
canOAdd = true;
for (int i = 3; i > 0; i--) {
if (str.charAt(i) == 'L') {
if (canXAdd) {
lxLength++;
}
if (canOAdd) {
loLength++;
}
} else if (str.charAt(i) == 'X') {
canOAdd = false;
if (canXAdd) {
lxLength++;
xNum++;
}
} else if (str.charAt(i) == 'O') {
canXAdd = false;
if (canOAdd) {
loLength++;
oNum++;
}
}
}
if (lxLength > 4 || loLength > 4) {
StringBuilder stringBuilder = new StringBuilder(str);
if (((float) xNum / (float) lxLength) >= ((float) oNum / (float) loLength)) {//判断谁占比高
stringBuilder.setCharAt(4, 'X');//攻击模式:假定下黑棋,局势对自己越好,分数越高
} else {
stringBuilder.setCharAt(4, 'O');//防御模式:站在敌人的角度思考,假定下白棋,局势对白棋越优,就越需要由黑棋破环,分数越高
}
return getContinueCharNum(stringBuilder.toString()).getValue();//返回判断分数
}
return 1;
}
// 返回代表局势状态的枚举类型,weight中含有每个关键局势权重
private Weight getContinueCharNum(String str) {
boolean boundaryIsLRight = false;
boolean boundaryIsLLeft = false;
char c = str.charAt(4);
int num = 0;
for (int i = 4; i < str.length(); i++) {
if (str.charAt(i) != c) {
if (str.charAt(i) == 'L') {
boundaryIsLRight = true;
}
break;
}
num++;
}
for (int i = 3; i >= 0; i--) {
if (str.charAt(i) != c) {
if (str.charAt(i) == 'L') {
boundaryIsLRight = true;
}
break;
}
num++;
}
if (c == 'O') {
if (num >= 5) {
return Weight.OOOOO;
} else if (num == 4) {
if (boundaryIsLLeft && boundaryIsLRight) {
return Weight.LOOOOL;
}
return Weight.OOOO;
} else if (num == 3) {
if (boundaryIsLLeft && boundaryIsLRight) {
return Weight.LOOOL;
}
return Weight.OOO;
} else if (num == 2) {
return Weight.OO;
} else {
return Weight.O;
}
} else {
if (num >= 5) {
return Weight.XXXXX;
} else if (num == 4) {
if (boundaryIsLLeft && boundaryIsLRight) {
return Weight.LXXXXL;
}
return Weight.XXXX;
} else if (num == 3) {
if (boundaryIsLLeft && boundaryIsLRight) {
return Weight.LXXXL;
}
return Weight.XXX;
} else if (num == 2) {
return Weight.XX;
} else {
return Weight.X;
}
}
}
局势分数权重
分数的多少,对AI的判断至关重要,是AI胜率的直接因素。可以判断当黑棋可以五连时XXXXX分数应该是最高的,因为可以直接赢得比赛,假设为40000分。其次应该是OOOOO当白棋可以五连时,此处要是不下黑棋,导致白棋下了,就直接输了,因此也很重要,但是即便是四条路都被将军(虽然实际不可能),也不应该使其分数超过黑棋五连分,因为后者可以赢,而前者只能苟命,所以这里设为10000分。LXXXXL代表两边有空位的四连,可以说也是将军了,一旦下出来白棋无法挡住,这回合白棋不赢,下回合黑棋必赢,因此此分数适合设为2500分,同理LOOOOL设为625分合适,逻辑依次类推。
下面是经过测试,个人认为适合的分数,通过枚举实现:
public enum Weight {
X(1), XX(11), XXX(45), LXXXL(180), XXXX(720), LXXXXL(2880), XXXXX(46080), O(1), OO(10), OOO(40), LOOOL(160),
OOOO(600), LOOOOL(2400), OOOOO(11520);
private final int value;
private Weight(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
设计模式
战局类Battle
属性有个15x15的char数组,只会填L(left 空位置),X(黑棋,AI使用的棋子),O(白棋,玩家使用的棋子)。前面提到过会出现E(error 表示越界)是因为在获取当前战局信息的时候会使用字符串,而第(1,2)位置(第二行第三列)的四条战局信息分别为:|:EEEL L LLLL,——:EELL L LLLL,\:EEEL L LLLL,/:EEEL L LLLL,这样可以保证即便是偏僻的位置,返回的字符串信息也是9位,方便处理。方法的话要一个下棋方法和一个返回战局的方法。
public class Battle {
protected char battle[][];//战局
protected Integration AI;//AI
public Battle() {
this.battle = new char[15][15];
for (int i = 0; i < battle.length; i++) {
for (int j = 0; j < battle.length; j++) {
battle[i][j] = 'L';
}
}
}
public void setAI(Integration AI) {
this.AI = AI;
}
public char getBattle(int a, int b) {
if (a < 0 || a > 14 || b < 0 || b > 14) {
return 'E';//越界返回E
}
return this.battle[a][b];
}
public void show() {
System.out.print("\n\n\n");
for (int i = 0; i < battle.length; i++) {
for (int j = 0; j < battle.length; j++) {
System.out.print(battle[i][j] + " ");
}
System.out.print("\n");
}
}
//下黑棋或白棋
public void play(boolean isblack, int a, int b) {
if (a < 0 || a > 14 || b < 0 || b > 14) {
return;
}
if (isblack) {
battle[a][b] = 'X';
} else {
battle[a][b] = 'O';
if (AI != null) {
AI.calculateScore(a, b);
}
}
}
}
AI类 Integration
首先他肯定要关联一个战局类Battle,时刻关注局势,然后自己再有一个分值表15x15的数组point,之前介绍过我们以空间换时间,因此这里还创建一个15x15x4的数组pointDetail 存放一个点横竖斜的分值,point就是四条线累加起来的最终分值。当然里面还需要一些方法:下棋,更新point表(每次白棋黑棋下完后立即更新),需求有这两个就够了,不过我写了好几个函数,命名我自己都分不清,代码太长了,这里就不贴了,下面给个链接下载,自己看吧。
UI设计
窗口类UIinterface (继承JFrame)
可以想到这个窗口是有多层的,最底下是棋盘,用张图片表示就行了,中间是棋子层,这里使用一个棋子Panel类,然后这个Panel里面塞15x15个按钮表示棋子就行了,最上层可以弹出获胜或是失败的标志。
关于分层,可以使用JLayeredPane类分层网格。通过在不同的层级插入容器就可以实现覆盖效果。
public class UIinterface extends JFrame {
JLayeredPane layeredPane = new JLayeredPane(); // 分层网格
JPanel chessboard;// 棋盘层(下层)
PiecePanel piecePanel;// 棋子层(中层)
BattlePane battlePane;
ImageIcon image;
public UIinterface() {
image = new ImageIcon("./src/image/qipan.jpg");
Integration AI = new Integration();
piecePanel = new PiecePanel(image.getIconWidth(), image.getIconHeight());
piecePanel.setOpaque(false);
battlePane = new BattlePane();
battlePane.setPanel(piecePanel);
battlePane.setAI(AI);
battlePane.setUI(this);
AI.setBattle(battlePane);
piecePanel.setBattlePane(battlePane);
chessboard = new ChessBoard(image.getImage());
JLabel label = new JLabel(image); // 把背景图片添加到标签里
chessboard.setBounds(0, 0, image.getIconWidth(), image.getIconHeight()); // 把标签设置为和图片等高等宽
chessboard.add(label);
layeredPane.add(chessboard, JLayeredPane.DEFAULT_LAYER);// 棋盘层(下层)
layeredPane.add(piecePanel, JLayeredPane.MODAL_LAYER);// 棋子层(中层)
this.setTitle("五子棋");
this.setBounds(0, 0, image.getIconWidth() + 15, image.getIconHeight() + 35);
this.setDefaultCloseOperation(DISPOSE_ON_CLOSE);
this.setLayeredPane(layeredPane);
this.setResizable(false);
this.setVisible(true);
}
public void showWin() {
JLabel label = new JLabel("YOU WIN!!!");
label.setBounds(10, 180, 100, 100);
label.setFont(new Font("Dialog", 1, 90));
label.setSize(1000, 100);
this.layeredPane.add(label, JLayeredPane.POPUP_LAYER);
}
public void showLose() {
JLabel label = new JLabel("YOU LOSE!!!");
label.setBounds(10, 180, 100, 100);
label.setFont(new Font("Dialog", 1, 80));
label.setSize(1000, 100);
this.layeredPane.add(label, JLayeredPane.POPUP_LAYER);
}
class ChessBoard extends JPanel {
// 绘制容器
Image img;
public ChessBoard(Image img) {
this.img = img;
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);// 调用父类的高度和宽度
g.drawImage(img, 0, 0, this.getWidth(), this.getHeight(), this);
}
}
}
棋子层类(继承JPanel)
里面塞15x15个棋子按钮就行,还要提供一个禁用所有按钮的方法,防止用户输赢后再下棋。
//棋子层类
public class PiecePanel extends JPanel {
private static final long serialVersionUID = 1L;
BattlePane battle;
Piece pieces[][];
public PiecePanel(int width, int height) {
pieces = new Piece[15][15];
for (int i = 0; i < pieces.length; i++) {
for (int j = 0; j < pieces.length; j++) {
pieces[i][j] = new Piece(i, j, this);
this.add(pieces[i][j]);
}
}
this.setBounds(0, 0, width, height);
GridLayout gridLayout = new GridLayout(15, 15, 2, 2);
this.setLayout(gridLayout);
this.setOpaque(false);
}
public void setBattlePane(BattlePane battle) {
this.battle = battle;
}
public void xiaqi(int a, int b) {
this.battle.userPlay(a, b);
}
public void setDisable() {
for (int i = 0; i < pieces.length; i++) {
for (int j = 0; j < pieces.length; j++) {
pieces[i][j].setEnabled(false);
}
}
}
}
棋子按钮类(继承JButton)
这按钮有三种不同的状态,分别是空,白,黑。得注意一下如何往按钮里塞图片,让他变好看。this.setContentAreaFilled(false);可以清空填充,this.setOpaque(false);可以设置透明。点击时要设置图片(白棋或黑棋的图片),然后在禁用此按钮,防止用户往此位置下棋。不过在禁用的时候一定也要设置禁用图片,不然禁用按钮的图片会变色,也就是说setDisabledIcon和setIcon都要设置。
public class Piece extends JButton {
int a, b;
PiecePanel piecePanel;
public Piece(int a, int b, PiecePanel piecePanel) {
this.a = a;
this.b = b;
this.piecePanel = piecePanel;
this.setContentAreaFilled(false);// 清空填充物
this.setOpaque(false);// 设置透明
this.addActionListener(new MyAction(this));
}
// 下棋触发的逻辑:更换按钮的图片,禁用按钮
public void setPiece(boolean isBlack) {
if (isBlack) {
Icon blankIcon = new ImageIcon("./src/image/black.png");
this.setDisabledIcon(blankIcon);
this.setIcon(blankIcon);
this.setEnabled(false);
} else {
Icon whiteIcon = new ImageIcon("./src/image/white.png");
this.setDisabledIcon(whiteIcon);
this.setIcon(whiteIcon);
this.setEnabled(false);
}
}
//按下按钮触发的事件:下棋
class MyAction implements ActionListener {
Piece p;
public MyAction(Piece p) {
this.p = p;
}
@Override
public void actionPerformed(ActionEvent e) {
p.setPiece(false);
p.removeActionListener(this);
this.p.piecePanel.xiaqi(this.p.a, this.p.b);
}
}
}
总结
到这里所有的逻辑就介绍完了,还是有一些遗憾的:悔棋,重开之类的操作没写,而且代码有一点混乱,设计模式的使用也十分糟糕,命名也不太行,总感觉功能说不清楚又或是太相近,路漫漫其修远兮呀,不过这是算法作业来着,所以总体来说偏向算法的说明,如果您是冲着Swing来的话容我说声抱歉,可能交代的不是太清楚。
项目截图
下了许多把,总体是输多赢少,只有偶然才能“声东击西”赢得一把,说明算法权值还可以继续调整。对此AI对手的感受是:难缠,但有时候又会下一些让人意想不到的地方(直接跳到很远的地方下棋)。
项目下载
链接:人机五子棋
提取码:1je3