Java实现中国象棋(人机对战)

目录

简介

成品视频

实现思路

界面实现分为了三块

棋盘抽象类

按钮组抽象类

棋子绘制接口

棋盘界面实现

棋子的实现

按钮组的实现

监听工厂和监听类

棋盘绘制类的实现

开始游戏实现

停止游戏实现

游戏抽象类

游戏实现类

可走路线和吃棋判断实现

车(ju)

兵/卒

相/象

仕/士

人机AI实现

实现思路

结尾



简介

Hello,I'm Shendi

花了五天时间用 Java 写了一个中国象棋.

拥有大概如下功能

  1. 象棋基本功能
  2. 可走路线点显示
  3. 人机对战
  4. 移动动画
  5. 我方永远是下方

成品视频

Java制作的中国象棋+简单AI

更多实战内容请进入我的实战专栏https://blog.csdn.net/qq_41806966/category_9656338.html

点个关注吧~

需要源码点这里: https://github.com/1711680493/Application

右上角有个小星星(star),点一下~


实现思路

刚开始写的时候没想太多,想得很简单(于是最终我写了五天才写完)

如往常一样,我写桌软喜欢用两个类,一个类用于启动,一个代表窗体

于是启动类代码就如下

我们初始化都在构造方法中完成,初始化完成后在显示.

但是有一些东西是不能在构造方法内使用的(比如需要在类初始化完成在用的东西)

所以我格外写了一个onCreate函数,在类创建完后调用此函数


界面实现分为了三块

为了扩展,我将界面实现分为了三块

  1. 棋盘
  2. 绘制棋子
  3. 按钮组

这三大界面在 MainView 中创建初始化,并以静态的方式提供出去(因为不需要运行时改变)

通过反射+配置文件形式获取到对应的三个类(对扩展开放)

窗体布局为绝对布局,设置了棋盘颜色和按钮组颜色,并给三块都进行了初始化

既然分了三块,那就需要三个接口/抽象类


棋盘抽象类

棋盘继承JPanel,所以需要是抽象类.

主要功能是绘制棋盘

代码如下


按钮组抽象类

按钮组就是右边那一块,用于显示和实现功能按钮

代码如下


棋子绘制接口

主要功能是绘制和保存棋子,以及开始游戏和结束游戏逻辑实现,里面包含具体游戏逻辑类

代码如下


棋盘界面实现

因为三大界面都是可扩展的,所以我只做了一套默认的

绘制其实没什么难度,棋盘如下

不管背景颜色(背景颜色是设置JPanel的),具体的就是画线条

棋盘是 9*10的

有十条横线,所以可以直接循环

竖线也是,但是要在和中间停一次,下面继续绘制

中间的文字就是直接写上去的,设置一下字体,位置

代码如下


棋子的实现

棋子也容易实现

通过观察象棋,其实就是两个实心圆+一个空心圆+一个字

我做了可以自适应大小的象棋,有一段测试代码,在 ChessFactory 类里,我没有删掉,运行起来结果是这样的

并且改一下大小,绘制的棋子也会跟着改变大小

测试的代码如下

/**
 * 测试 棋子.
 * @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
 * @param args null
 */
public static void main(String[] args) {
	JFrame frame = new JFrame("Test");
	JLabel chess = new JLabel();
	chess.setBounds(0, 0, 200, 200);
	int width = 100;
	int height = 100;
	BufferedImage img = new BufferedImage(200,200,BufferedImage.TYPE_INT_ARGB);
	Graphics g = img.getGraphics();
	g.setColor(Color.BLACK);
	g.fillRoundRect(0, 0, width, height, 180, 180);
	g.setColor(Color.YELLOW);
	int backgroundX = (int)(width - width * 0.9) / 2;
	int backgroundWidth = (int)(width * 0.9);
	int backgroundHeight = (int)(height * 0.8);
	g.fillRoundRect(backgroundX, 0, backgroundWidth, backgroundHeight, 180, 180);
	g.setColor(Color.RED);
	g.drawRoundRect(backgroundX << 1, backgroundX, backgroundHeight, backgroundHeight - (backgroundX << 1), 180, 180);
	g.setFont(new Font("仿宋", Font.BOLD, (width + height) >> 2));
	g.drawString("车", width >> 2, (int)(height * 0.6));
	g.dispose();
	chess.setIcon(new ImageIcon(img));
	
	BufferedImage img2 = new BufferedImage(200,200,BufferedImage.TYPE_INT_ARGB);
	Graphics g2 = img2.getGraphics();
	g2.setColor(Color.RED);
	g2.fillRoundRect(0, 0, 200, 200, 180, 180);
	g2.drawString("車", 0, 0);
	
	chess.addMouseListener(new MouseAdapter() {
		
		@Override
		public void mouseClicked(MouseEvent e) {
			chess.setIcon(new ImageIcon(img2));
		}
		
	});
	
	frame.setLayout(null);
	frame.add(chess);
	frame.setVisible(true);
	frame.setSize(300, 300);
	frame.setDefaultCloseOperation(3);
}

因为象棋绘制都是一样的,除了个别颜色不同,所以我做了一个工厂类.

这个工厂类专门生产象棋,有一个生产默认象棋的方法如下

至于除了棋子的文字为什么还需要另外两个文字则是游戏所需


按钮组的实现

按钮组就两个按钮,一个开始,一个停止.


监听工厂和监听类

因为后期需要很多监听

所以做了一个监听工厂类,用于方便的获取对应监听

其中ButtonActionListener是专门处理按钮事件的监听

目前的按钮只包含开启和停止按钮,停止按钮默认灰色,当点击开始按钮的时候就将状态改变(开始灰,停止亮)

并且开启和停止对应于棋子绘制类的start和stop

代码如下

上面的开启完成后需要重新绘制是因为我之前写的时候在 start 方法中将棋子添加进棋盘,后面改进了,在init初始化的时候就将棋子添加进棋盘,所以重新绘制这两行代码可以舍去

LabelMouseListener是label的鼠标监听,我们的象棋都是一个JLabel,所以这个类很重要,但是因为除了我们的棋子,可能还有其他JLabel来使用此类,所以我们没有进行处理的点击事件丢给另外一个类进行处理---Game

代码如下

只有玩家才会有点击事件


棋盘绘制类的实现

这个类是游戏中最重要的一部分.

在初始化方法 init() 的时候会创建对应棋子保存起来(享元模式)

并且包含很多操作,实现... 主要用于管理游戏数据

代码四百多行

说下游戏思路,我们的游戏场景实际上是一个二维数组,里面每一个棋子都在一个具体的位置.

我将数组空白地方的每一个点做成了一个JLabel,并且是一个小红点,name和text中都包含 冒号:,所以我们可以通过这个来实现当前棋子可走路线,并且可以知道是吃棋还是走棋

并且还有四个JLabel用于表示棋子是否是可吃的(每次可吃的棋子不会超过四个),不存入数组,而是直接移动位置

通过界面上的x,y来进行一定计算得到具体数组的位置,所以封装了一个方法在这个类里

因为位置都是固定的,为了节省开销,避免每次计算,所以将计算结果保存起来,直接获取即可

获取不到需要用近似值获取(因为计算的时候可能损失精度 -1,+1)

以及还有选择框

此类处理开始和停止游戏,所以会将初始状态进行保存.

这里上一部分代码

定义的一些变量

构造方法,初始化选择框,位置和可走路线图片

可走路线图片就是一个小圆点,通过上面这种计算可以保持位置在组件的中间

init 方法,创建所有的棋子,因为使用的HashMap,所以棋子名都必须唯一(人机也需要用到)

 

对应的小圆点组件

 


开始游戏实现

在开始游戏的时候我们会随机分配队伍,并且我方队伍永远在下方

如何让我方队伍永远在棋盘的下方?

只需要做一个判断,然后对y轴取反就ok了

代码如下,redBlack=true代表我方是红队反之黑队

停止游戏实现

将一些状态恢复.

代码如下


游戏抽象类

在开始游戏后,我们的棋子就有了对应的监听,并且我们制作的是人机对战的,红棋先走

所以我们需要一个游戏抽象类,除了用户使用之外,人机AI也需要使用

游戏类里具体干嘛的?

具体实现游戏逻辑,比如棋子的点击事件,吃棋行走等,因为有人机,所以游戏类里需要知道人机是什么阵营,玩家是什么阵营

并且点击事件需要传递这个阵营过去(比如自己方不能吃自己方)

抽象类有以下属性

在被创建的时候就将对应AI也创建了出来,从配置文件中使用反射获取(我用的自己写的类获取配置文件内容)

在抽象类中也需要初始化,游戏开始的时候就进行初始化,需要知道玩家是什么阵营,然后定义人机是什么阵营

并且我们实现此抽象类的子类可能也需要初始化,所以提供一个抽象无参方法init

只有玩家才有点击事件,只有玩家的回合才可以执行点击事件

我们人机也需要模仿点击事件,所以新增一个抽象方法,多了一个阵营参数

我们游戏结束的时候需要调用一下Game的stop方法

我们在执行完一次有效操作后棋子都会移动,所以在移动后我们就知道某一方下完了,如果是玩家下完我们就要让人机去下棋

并且移动的时候做了个动画(不是干巴巴的瞬移),具体动画效果给子类实现


游戏实现类

此类代码有1300多行,包含所有棋子的逻辑...

首先,我们在点击棋子的时候需要显示可走路线(这一点人机也用到了),然后点击可走路线进行操作,或者直接点击棋子进行吃棋

一个有效的操作必须是最少执行了两次 onClick 方法(选棋和执行操作)

所以我们有一段代码如下

如果不是选棋则执行了这段代码后就不会往下执行

每一个棋子都有不同的走法,所以我们需要一个个去实现

我们在场景中通过判断数组元素是否带 冒号 : 来区分是否为棋子(不带冒号为棋子)

所以不是选棋默认为走棋

然后移动的动画代码实现如下


可走路线和吃棋判断实现

因为代码过多,所以这里着重思路

我们的可走路线都在场景中,并且是隐藏的,当点击棋子后,会显示出来

吃棋方法的共有参数

车(ju)

车只能走直线,直线吃,不能越棋子,所以这个可走路线比较好实现

只要循环,左,右,上,下

上面这部分代码是左边的可走路线,一个for循环一个方向(这里只贴出一个方向,其余的类似)

场景中带冒号的为空点,所以循环到不带冒号的就停下,并且如果这个棋子是地方的,就要用格外的点来显示出来(可以吃的,上面说过,有四个格外的点用于显示可以吃的路线)

在吃棋的时候我们要判断是否走斜线了(不判断就可以飞),并且是否中间有棋子

炮和车差不多,不同的是炮不能直线吃,但是可以隔子吃.

实现思路就是就是循环...碰到一个子的时候就继续循环,看还有没有下一个子,如果有那就是可以打的

这里也只上左边的代码

在吃棋的时候我们只用计算目标棋子和当前棋子中隔了几个棋子

将军只能走一格,并且只能在对应格子内出不去,将军如果碰面可以直接吃

上面的代码左右走是这样的,上下走就需要判断是人机还是玩家(玩家的在下方,人机的在上方)

并且需要判断是否碰面

// 玩家上下[y|7 <= y <= 9]
if (redBlack == dChess.redBlack) {
	if (y > 7) {
		var text = scene[y - 1][x];
		if (!text.contains(":")) {
			if (isShowNullChess(text)) {
				JLabel nullChess = dChess.getNullChess(String.valueOf(eatChess));
				eatChess++;
				nullChess.setLocation(chess.getX(), dChess.getPos(y - 1));
				nullChess.setVisible(true);
				nullChesses.add(nullChess);
			}
		} else {
			JLabel nullChess = dChess.getNullChess(text);
			nullChess.setVisible(true);
			nullChesses.add(nullChess);
		}
		// 先看对方将是否和己方在同一直线上,是则看中间有无别的棋子.
		F:for (int i = 0;i < 3;i++) {
			var chessName = scene[i][x];
			if ("红帅".equals(chessName) || "黑将".equals(chessName)) {
				for (int j = i + 1;j < y;j++) {
					if (!scene[j][x].contains(":")) {
						break F;
					}
				}
				JLabel eatNullChess = dChess.getNullChess(String.valueOf(eatChess));
				eatChess++;
				eatNullChess.setLocation(chess.getX(), dChess.getPos(i));
				eatNullChess.setVisible(true);
				nullChesses.add(eatNullChess);
				break;
			}
		}
	}
	if (y < 9) {
		var text = scene[y + 1][x];
		if (!text.contains(":")) {
			if (isShowNullChess(text)) {
				JLabel nullChess = dChess.getNullChess(String.valueOf(eatChess));
				eatChess++;
				nullChess.setLocation(chess.getX(), dChess.getPos(y + 1));
				nullChess.setVisible(true);
				nullChesses.add(nullChess);
			}
		} else {
			JLabel nullChess = dChess.getNullChess(text);
			nullChess.setVisible(true);
			nullChesses.add(nullChess);
		}
	}
// 人机上下[y|0 <= y <= 2]
} else {
	if (y < 2) {
		var text = scene[y + 1][x];
		if (!text.contains(":")) {
			if (isShowNullChess(text)) {
				JLabel nullChess = dChess.getNullChess(String.valueOf(eatChess));
				eatChess++;
				nullChess.setLocation(chess.getX(), dChess.getPos(y + 1));
				nullChess.setVisible(true);
				nullChesses.add(nullChess);
			}
		} else {
			JLabel nullChess = dChess.getNullChess(text);
			nullChess.setVisible(true);
			nullChesses.add(nullChess);
		}
		// 先看对方将是否和己方在同一直线上,是则看中间有无别的棋子.
		F:for (int i = 9;i > 6;i--) {
			var chessName = scene[i][x];
			if ("红帅".equals(chessName) || "黑将".equals(chessName)) {
				for (int j = i - 1;j > 0;j--) {
					if (!scene[j][x].contains(":")) {
						break F;
					}
				}
				JLabel eatNullChess = dChess.getNullChess(String.valueOf(eatChess));
				eatChess++;
				eatNullChess.setLocation(chess.getX(), dChess.getPos(i));
				eatNullChess.setVisible(true);
				nullChesses.add(eatNullChess);
				break;
			}
		}
	}
	if (y > 0) {
		var text = scene[y - 1][x];
		if (!text.contains(":")) {
			if (isShowNullChess(text)) {
				JLabel nullChess = dChess.getNullChess(String.valueOf(eatChess));
				eatChess++;
				nullChess.setLocation(chess.getX(), dChess.getPos(y - 1));
				nullChess.setVisible(true);
				nullChesses.add(nullChess);
			}
		} else {
			JLabel nullChess = dChess.getNullChess(text);
			nullChess.setVisible(true);
			nullChesses.add(nullChess);
		}
	}
}

吃棋的时候同样也要判断

// 将/帅 只走一格,碰面可直接吃
case "红帅":
case "黑将":
	// 左右吃,要在指定格子内只能在指定范围 [x|3 <= x <= 5]
	if (xOffset != 0 && yOffset != 0) return;
	if (yOffset == 0) {
		if (xOffset != -1 && xOffset != 1 || (x < 3 || x > 5)) return;
	} else {
		// 判断有无碰面
		boolean isEat = true;
		if (redBlack == dChess.redBlack) {
			if (yOffset < 0) {
				for (int i = upY - 1;i >= y;i--) {
					if (!scene[i][x].contains(":")) {
						var chessName = scene[i][x];
						if ("红帅".equals(chessName) || "黑将".equals(chessName)) {
							isEat = false;
						}
						break;
					}
				}
			}
			// 上下吃,在指定范围内[y|7 <= y <= 9]
			if (isEat)
				if (yOffset != -1 || yOffset != 1 && (y < 7 || y > 9)) return;
		} else {
			if (yOffset > 0) {
				for (int i = upY + 1;i <= y;i++) {
					if (!scene[i][x].contains(":")) {
						var chessName = scene[i][x];
						if ("红帅".equals(chessName) || "黑将".equals(chessName)) {
							isEat = false;
						}
						break;
					}
				}
			}
			// 上下吃,在指定范围内[y|0 <= y <= 2]
			if (isEat)
				if (yOffset != -1 || yOffset != 1 && (y < 0 || y > 2)) return;
		}
	}
	break;

马的可走路线代码比较多,马可以走八个点,左边两个(左上左下),右边两个,上边两个,下边两个

被拦住不能走,比如说往上面跳,马上面有棋就不能跳

下面是左边两个点的代码,其余几个方向都是差不多

private void selectMa(JLabel chess,String[][] scene) {
	// 马跳日,路前不能有棋子.
	int x = dChess.getPos(chess.getX());
	int y = dChess.getPos(chess.getY());
	
	// 左右上下,每一边都有两个点,左边的话,棋子左边不能有棋,右上下同样.
	if (x >= 2 && scene[y][x - 1].contains(":")) {
		// 左边的上面
		if (y > 0) {
			var text = scene[y - 1][x - 2];
			if (isShowNullChess(text)) {
				if (!text.contains(":")) {
					JLabel nullChess = dChess.getNullChess(String.valueOf(eatChess));
					eatChess++;
					nullChess.setLocation(dChess.getPos(x - 2), dChess.getPos(y - 1));
					nullChess.setVisible(true);
					nullChesses.add(nullChess);
				} else {
					JLabel nullChess = dChess.getNullChess(text);
					nullChess.setVisible(true);
					nullChesses.add(nullChess);
				}
			}
		}
		// 左边的下面,竖排有十格
		if (y < 9) {
			var text = scene[y + 1][x - 2];
			if (isShowNullChess(text)) {
				if (!text.contains(":")) {
					JLabel nullChess = dChess.getNullChess(String.valueOf(eatChess));
					eatChess++;
					nullChess.setLocation(dChess.getPos(x - 2), dChess.getPos(y + 1));
					nullChess.setVisible(true);
					nullChesses.add(nullChess);
				} else {
					JLabel nullChess = dChess.getNullChess(text);
					nullChess.setVisible(true);
					nullChesses.add(nullChess);
				}
			}
		}
	}

马吃棋就是一堆判断了.只能吃指定的点

兵/卒

兵和卒只能往前走,过河可以横着走

过河需要分人机和玩家进行对应处理(判断格子)

这里是玩家过河左边可走路线显示的代码

吃棋的时候判断是不是离自己一个格子,并且是不是上方,过河了可以左右

相/象

相飞田,田中间不能有棋子,并且不能过河,所以也要区分玩家和人机进行分别处理

代码如下

 吃棋判断如下

仕/士

士和将军一样只能在对应格子里,所以也要区分人机和玩家,并且士走斜线

 吃棋判断就比较简单了,只要不出格就行


人机AI实现

首先为了代码的扩展性肯定需要写一个接口,所以AI接口代码如下

实现思路

上面我们写的代码已经可以让ai使用我们的onClick了,我们现在只需要让ai下棋即可

这里我的思路非常简单(不那么聪明的 AI)

有棋则吃棋,无棋则随机行走.

在实现这种ai的时候需要把我方棋子和对方棋子存起来

然后循环遍历我方棋子,并且每一个棋子遍历对方棋子判断是否可以吃(棋子是否消失)

不能吃就随机行走(使用递归)


结尾

亲手制作了象棋后才知道象棋和贪吃蛇,推箱子这种不是一个级别的

第一次做这种游戏会踩很多坑,以至于浪费很多宝贵的时间去修复bug(比如我的棋子莫名其妙的就飞了,棋子不能吃什么的...

在人机AI方面可以优化一下,将所有棋子设置对应权重,比如将军最高,其他棋子需要保护...等

需要源码的可以盗我的github中,ChineseChess项目,github链接在顶端

点击关注,进我专栏有惊喜.

猜你喜欢

转载自blog.csdn.net/qq_41806966/article/details/107380943
今日推荐