使用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

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值