目录
简介
成品视频
实现思路
界面实现分为了三块
棋盘抽象类
按钮组抽象类
棋子绘制接口
棋盘界面实现
棋子的实现
按钮组的实现
监听工厂和监听类
棋盘绘制类的实现
开始游戏实现
停止游戏实现
游戏抽象类
游戏实现类
可走路线和吃棋判断实现
车(ju)
炮
将
马
兵/卒
相/象
仕/士
人机AI实现
实现思路
结尾
简介
Hello,I'm Shendi
花了五天时间用 Java 写了一个中国象棋.
拥有大概如下功能
- 象棋基本功能
- 可走路线点显示
- 人机对战
- 移动动画
- 我方永远是下方
成品视频
Java制作的中国象棋+简单AI
更多实战内容请进入我的实战专栏: https://blog.csdn.net/qq_41806966/category_9656338.html
点个关注吧~
需要源码点这里: https://github.com/1711680493/Application
右上角有个小星星(star),点一下~
实现思路
刚开始写的时候没想太多,想得很简单(于是最终我写了五天才写完)
如往常一样,我写桌软喜欢用两个类,一个类用于启动,一个代表窗体
于是启动类代码就如下
我们初始化都在构造方法中完成,初始化完成后在显示.
但是有一些东西是不能在构造方法内使用的(比如需要在类初始化完成在用的东西)
所以我格外写了一个onCreate函数,在类创建完后调用此函数
界面实现分为了三块
为了扩展,我将界面实现分为了三块
- 棋盘
- 绘制棋子
- 按钮组
这三大界面在 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() {@Overridepublic 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链接在顶端
点击关注,进我专栏有惊喜.