基于UCCI引擎的中国象棋人机对战实现
1、中国象棋通用引擎协议基础知识
1.1、着法表示
一、棋盘的标记
象棋的着法表示,简而言之就是某个棋子从什么位置走到什么位置。通常,表示方法可以分为“纵线方式”和“坐标方式”两种,现在作简要说明:
(1) 纵线方式,它是中国象棋常用的表示方法,即棋子从棋盘的哪条线走到哪条线。中国象棋规定,对于红方来说的纵线从右到左依次用“一”到“九”表示,黑方则是“1”到“9”(如图一所示),这种表示方式体现了古代中国象棋研究者的智慧。
(2) 坐标方式,它是国际象棋常用的表示方法,把每个格子按坐标编号(如图二所示),只要知道起始格子和到达格子,就确定了着法,这种表示方式更方便也更合理,而且还可以移植到其他棋类游戏中。中国象棋也可以用这种方法来表示,按照国际象棋的规则,纵线从左到右(红方)依次为a b c d e f g h i,横线从下到上(红方)依次为0 1 2 3 4 5 6 7 8 9(如图三所示)。
图1
图2
图3
二、棋子的名称
为方便表示,中国象棋的棋子名称除了用汉字以外,还可以用字母,字母可从国际象棋中稍加改动得到,而数字是为了方便棋谱的输入(以便用在数字小键盘上)(见表一):
|
三、ICCS坐标格式
ICCS是中国象棋互联网服务器(Internet Chinese Chess Server)的缩写。在网络对弈服务器处理着法时,把着法表示成起点和终点的坐标是最方便的 ,因此这种格式最早在计算机上使用。
在“中国象棋通用引擎协议”(UCCI协议)中,坐标格式得到进一步简化,例如H2-E2记作h2e2,把符号限制在一个32位数据中,处理起来速度更快。 | |
四、中文纵线格式
这种格式对于广大象棋爱好者来说再熟悉不过了,但是这里还是要说明两个重要的细节。
一、仕(士)和相(象)如果在同一纵线上,不用“前”和“后”区别,因为能退的一定在前,能进的一定在后。
二、兵要按情况讨论:
(1) 三个兵在一条纵线上:用“前”、“中”和“后”来区别;
(2) 三个以上兵在一条纵线上:最前面的兵用“一”代替“前”,以后依次是“二”、“三”、“四”和“五”;
(3) 在有两条纵线,每条纵线上都有一个以上的兵:按照“先从右到左,再从前到后”(即先看最左边一列,从前到后依次标记为“一”和“二”,可能还有“三”,再看右边一列)的顺序,把这些兵的位置标依次标记为“一”、“二”、“三”、“四”和“五”,不在这两条纵线上的兵不参与标记。
如右图局面,四个兵分别位于四线和六线,下表列举了几种走法的坐标格式和纵线格式。
| |
另外需要注意的是:
(1) 如果黑方出现数字,不管数字代表纵线标号还是前进或后退的格数,都用阿拉伯数字表示,在计算机中显示全角的数字。但是代表同一纵线上不同兵的“一二三四五”(它们类似于“前中后”的作用)例外,例如例局面红黑互换,那么某步着法就应该写成“一卒平5”。
(2) 在传统的象棋记谱中,如果发生以上这种情况,通常用五个字来表示,例如“前兵四平五”等,在计算机处理过程中就比较麻烦,因为4个汉字(一个汉字占16位)的着法可以储存在一个64位的字当中(在C语言中数据类型为__int64或long long),而增加到5个汉字就比较麻烦了。黑方用全角的数字是同一个道理。
五、WXF纵线格式
WXF是世界象棋联合会(World XiangQi Federation)的缩写,在该组织建议的记谱方式中,汉字被字母和数字所代替,即“进”、“退”和“平”分别用符号“+”、“-”和“.”表示,“前”、“中”和“后”也分别用符号“+”、“-”和“.”表示,并且写在棋子的后面(例如“前炮退二”写成“C+-2”而不是“+C-2”),多个兵位于一条纵线时,代替“前中后”的“一二三四五”分别用“abcde”表示(这种情况极少发生)。
另外,代表棋子名称的第一个字母,还可以用数字1到7表示,这是为了方便数字小键盘的输入,例如“炮二平五”可以记作“62.5“(6代表炮)选用符号“+”、“-”和“.”也是出于这个考虑。
六、棋子代号问题
符号纵线格式是世界象棋联合会(WXF)推荐的记谱格式,但是现在棋子的字母代号用法不一,主要问题集中在仕(士)、相(象)和马的翻译上。仕(士)译成Advisor、Bachelor、Guard等,相(象)译成Bishop、Elephant、Minister等,马译成Horse或Knight。
出于这点考虑,很多象棋软件都能识别不同的代号,例如ElephantBoard将B和E都解释为相(象),H和N都解释为马,SaoLa则可以由用户自定义字母代号,等等。
七、简体和繁体的识别问题
无论采取哪种格式记谱,棋谱最终应该被象棋软件所识别。通常象棋软件都应支持以上三种格式,除“中文纵线格式”以外,其他两种格式都用英文和数字表示,不涉及中文字符,所以处理起来比较容易。而中文格式却非常麻烦,除了涉及到简体还是繁体外,还有字符集的问题。
象棋软件应该充分考虑这个问题,例如ElephantBoard在这个问题上就考虑得十分周到。在GB字符集(大陆地区)里,可识别简体和繁体字符,而在Big5字符集(港台地区)里,由字符集转换引起的问题也考虑进去了——表示棋子位置的“后”,由GB字符转换为Big5字符时,仍然写为“后”,但正确的写法是“後”,无论如何这两个“后(後)”都会被ElephantBoard认可。
1.2、 FEN文件格式
一、FEN的优势
FEN(Forsyth-Edwards Notation)是专门用来表示象棋局面的记录规范,在这个规范下,一个局面可以简单地用一行“FEN格式串”来表示,而不必烦琐地说:“红帅在四路次底线,黑将在5路底线……”。由于它是文本格式的(只牵涉到很有限的几个字母、数字和符号),因此网上传递棋局就非常方便,不用把棋图画在纸上或用文字笨拙地描述了,而是用棋谱编辑软件摆好局面,自动得到FEN格式串,再贴到诸如Web网页、BBS、电子论坛等能够传递文字的网络应用平台上。读者面对FEN格式传,只要借助于棋谱编辑软件,用“赋值”和“粘贴”就可以完全重现局面,使得棋谱交流快速而高效。
二、FEN格式串
国际象棋的FEN格式串是由6段ASCII字符串组成的代码(彼此5个空格隔开),这6段代码的意义依次是:
(1) 棋盘上的棋子,这是FEN格式串的主要部分;
(2) 轮到哪一方走子;
(3) 每方及该方的王翼和后翼是否还存在“王车易位”的可能;
(4) 是否存在吃过路兵的可能,过路兵是经过哪个格子的;
(5) 最近一次吃子或者进兵后棋局进行的步数(半回合数),用来判断“50回合自然限着”;
(6) 棋局的回合数。
中国象棋没有“王车易位”和“吃过路兵”的着法,所以FEN格式串的这两项空缺,以最初局面为例说明:
rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1
(1) 红色区域,表示棋盘布局,小写表示黑方,大写表示红方,其他规则同国际象棋的FEN规范(请参阅《国际象棋译文苑》文摘——关于PGN和FEN记谱规范(下)一文)。这里要注意两点,一是中国象棋棋盘有10行,所以要用9个“/”把每一行隔开;二是棋子名称用英文字母表示,国际象棋中没有的棋子是仕(士)和炮,这里分别用字母A(a)和C(c)表示。
(2) 绿色区域,表示轮到哪一方走子,“w”表示红方,“b”表示黑方。(有人认为红方应该用“r”表示,很多象棋软件确实是这样表示的。ElephantBoard尽管用“w”表示,但识别时采取灵活的办法,即“b”表示黑方,除此以外都表示红方。)
(3) 深紫色区域,空缺,始终用“-”表示。
(4) 紫红色区域,空缺,始终用“-”表示。
(5) 蓝色区域,表示双方没有吃子的走棋步数(半回合数),通常该值达到120就要判和(六十回合自然限着),一旦形成局面的上一步是吃子,这里就标记“0”。(这个参数对于普通局面的意义不大,ElephantBoard的规则处理器并不是根据这一项来判断和棋的,所以总是计为“0”。)
(6) 棕色区域,表示当前的回合数,在研究中局或排局时,作为研究对象的局面,这一项可以写1,随着局势的发展逐渐增加。
三、FEN文件
FEN格式串有很多载体,通常的做法是把它保存成后缀为“.FEN”的文件。FEN文件是ASCII码文件,可以用任何文本编辑软件来建立和修改,也可以通过象棋棋谱软件来产生。
四、历史局面的表示
这是只在“中国象棋通用引擎协议”(UCCI协议)中采用的方法,用来表示历史局面。在UCCI协议中,局面用position fen <fen_string>来传递,由于FEN格式串无法记录历史局面,所以该指令后面必须用moves选项来说明。例如右图局面,发生时,界面向引擎传递局面信息: position fen 9/9/3k5/9/9/9/4R4/3A5/8r/4K4 b - - 0 1 此时引擎走车9进1是没有问题的。然而当它是某个长将局面的延续时,界面向引擎传递的局面信息就必须用moves选项来说明: position fen 9/9/3k5/9/9/9/4R4/3A5/4K4/8r b - - 0 1 moves i0i1 e1e0 i1i0 e0e1 i0i1 e1e0 i1i0 e0e1 i0i1 e1e0 尽管也是右图的局面,但意义已经发生了变化,此时引擎不可能再走车9进1,因为紧接着一步帅五进一,导致局面重复第三次,黑方被判长将作负。 | |
ElephantBoard向引擎传递局面时,<fen_string>总是最近一次吃过子的局面(或开始局面),后面所有的着法都用moves选项来传递给引擎,这样就包含了判断自然限着和长打的历史信息,这些信息可由引擎来处理。
1.3、 中国象棋通用引擎协议
一、概述
中国象棋通用引擎协议(Universal Chinese Chess Protocol,简称UCCI),是一种象棋界面和象棋引擎之间的基于文本的通讯协议。设立中国象棋通用引擎协议的目的有:
(1) 使一个“可视化象棋软件”可以使用不同的“核心智能部件”,这些核心智能部件称为“引擎”,凡是遵循UCCI的引擎,都可以被该可视化象棋软件(也称为“界面”)所调用;
(2) 针对所有遵循UCCI的引擎,都可以开发不同的界面,使其具有不同的功能。
这样,“可视化象棋软件”和“核心智能部件”实现了分离,使得一部分程序设计师能专注于前者(界面)的开发,而另一部分程序设计师能专注于后者(引擎)的开发,让中国象棋软件的设计工作系统化、分工化,提高软件设计效率。
UCCI是模仿国际象棋的UCI来制定的。UCCI是开放式的协议,并且具有UCI的所有特点,具体反映在象棋百科全书网所收录的以下文章中:
(1) 国际象棋引擎:穿越困惑(转载自《国际象棋译文苑》);
(3) 国际象棋通用引擎协议。
UCCI自诞生以来不断在发展和更新,但保持了对早期版本的兼容。
3.0版较2.3版改进的内容有:
- 建议取消option反馈中的repetition和drawmoves选项,将selectivity选项改成randomness,增加promotion选项。
2.3版较2.2版改进的内容有:
- 建议采用“毫秒”作为唯一的时间单位,参阅option反馈中的usemillisec选项。
以后UCCI还会不定期地更新,并继续保持对早期版本的兼容。UCCI界面和引擎设计者可访问以下资源,来获得最新的UCCI版本:
二、通讯方法
不管是Windows还是UNIX平台,能被界面调用的引擎都必须是编译过的可执行文件,它跟界面之间通过“标准输入”和“标准输出”(即C/C++语言中的stdin和stdout)通道来通讯。如果引擎从Windows平台移植到UNIX平台,那么需要重新编译源代码(管道操作的程序也需要作适当修改),或使用跨平台接口。
作为界面的设计,要启动一个引擎,Windows平台下可用CreateProcess()函数,UNIX平台下可用fork()和exec()函数,然后重定向到一个输入管道和一个输出管道,具体操作可参阅WinBoard/XBoard源程序的StartChildProcess()函数,或参阅中国象棋引擎ElephantEye源程序的<pipe.cpp>模块。
作为引擎的设计,通讯比界面略为简单(只需要对stdin和stdout操作),只在检查stdin是否有输入时较为麻烦,具体操作可参阅Crafty源程序的<utility.c>模块的CheckInput()函数,或参阅中国象棋引擎ElephantEye源程序的<pipe.cpp>模块。
通常,界面向引擎发送的信息称为“指令”,而引擎向界面发送的信息称为“反馈”。在UCCI中,不管是指令还是反馈,都是以“行”为单位的,即每条指令和反馈都必须以“回车”(即C/C++语言中的'\n')结束。
注意:引擎用缓冲方式发出反馈(即C/C++语言中直接将字符串写入stdout),那么每输出一行都必须用fflush()语句刷新缓冲区。
三、引擎的状态
UCCI引擎在启动后,有三种状态。
(1) 引导状态。
引擎启动时,即进入引导状态。此时引擎只是等待和捕捉界面的输入,而界面必须用ucci指令让引擎进入接收其他UCCI指令的空闲状态(稍后会提到)。当然,引擎也可以保留使用其他协议的权利,例如引擎允许第一条有效指令是cxboard,这样引擎就转而进入CXBoard状态。
收到ucci只后,引擎要完成一系列初始化工作,以输出ucciok的反馈作为初始化结束的标志,进入空闲状态。如果引导状态下UCCI引擎收到其他指令,则可以退出。
(2) 空闲状态。
该状态下引擎没有思考(即几乎不占用CPU资源),而只是等待和捕捉界面的输入(和引导状态类似),接收这样几类指令:A. 设置引擎选项(setoption指令),B. 设置引擎的内置局面(即让引擎思考的局面)及其禁止着法(position和banmoves指令),C. 让引擎思考(go指令),D. 退出(quit指令)。
(3) 思考状态。
引擎收到go指令后,即进入思考状态,以输出bestmove或nobestmove的反馈作为思考状态结束的标志(回到空闲状态)。该状态下引擎将满负荷运转(CPU资源占用率接近100%),但仍旧需要捕捉界面的输入(只有在批处理模式下不会捕捉界面的输入),接收两类指令:A. 中止思考(stop指令),B. 改变思考方式(ponderhit指令)。
go指令只决定了引擎将按照什么思考方式来思考(即限定思考的深度,或限定思考的局面个数,或限定思考的时间),而思考的局面则必须通过前面输入的position指令来告诉引擎。
其他注意事项有:
(1) 引擎只有在接收到go指令后才开始思考。即便引擎支持后台思考,在输出着法(反馈bestmove)后也不会自动进行,而是要由界面发送go ponder指令,让引擎以后台思考方式进行思考。
(2) bestmove的反馈并不改变引擎的内置局面,如果界面让引擎走棋,就必须读取bestmove反馈的着法,并在界面的局面上走完这一步(当然,界面也可以走别的着法),再由position指令把新的局面告诉引擎。
(3) 如果对局是计时的,那么每次思考时都必须用go指令设定时钟,引擎仅仅根据时钟来决定分配多少时间来思考,回到空闲状态后时钟就失效了,必须由界面扣去引擎思考的时间(从发送go指令起到收到bestmove反馈结束),在下次发送go指令时把新的时钟告诉引擎。
(4) 启用“批处理”模式时,引擎在思考状态下就不接收指令。批处理模式适合用重定向方式调试引擎,例如一个输入文件含有以下指令集:
1: ucci
2: setoption batch true
3: position fen <fen_1>
4: go depth 10
5: position fen <fen_2>
6: go depth 10
7: quit
第4行以后引擎即进入思考状态,由于处于批处理模式,引擎反馈bestmove后回到空闲状态,才会继续接收以后的指令。如果没有第2行的启用批处理模式,那么第4行以后的指令都将在思考状态接收,而对于思考状态,这些指令都是无效的。
(5) 如果界面搞错了引擎的状态,在引擎的思考状态向界面发送quit指令,那么引擎最好能终止思考并立即退出,以避免界面无休止地等待引擎的退出。
(6) 如果界面搞错了引擎的状态,在引擎的空闲状态向引擎发送stop指令,那么引擎最好能反馈一个nobestmove,以避免界面无休止地等待引擎的反馈。
四、着法和棋盘的表示
界面告诉引擎哪些着法是禁手(banmoves指令),或者引擎回答界面应该走哪个着法(bestmove反馈),这样的着法都用4个字符(简化的ICCS格式,参阅《中国象棋电脑应用规范(二):着法表示》一文)表示,即ICCS格式去掉中间的横线,并改成小写,例如h2e2。
界面用position指令把局面告诉引擎时,应该使用FEN串(写法参阅《中国象棋电脑应用规范(三):FEN文件格式》一文)。但是对局中会遇到循环局面,引擎也必须考虑其对策,因此FEN串并不能完全反映局面信息,必须使用FEN串(当前局面前第一个不吃子的局面)和后续着法相结合的方法表示局面。例如,开局以后走了以下4步:
1. 炮二平五 |
| 炮8平5 |
| 2. 炮五进四 |
| 士4进5 |
如果把这4步棋涉及的5个局面都告诉引擎,那么指令依次是:
1: position fen rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1
2: position fen rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1 moves h2e2
3: position fen rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1 moves h2e2 h7e7
4: position fen rnbakabnr/9/1c2c4/p1p1C1p1p/9/9/P1P1P1P1P/1C7/9/RNBAKABNR b - - 0 2
5: position fen rnbakabnr/9/1c2c4/p1p1C1p1p/9/9/P1P1P1P1P/1C7/9/RNBAKABNR b - - 0 2 moves d9e8
其中第4行更换了FEN串,因为该局面前一个着法是吃子着法。
五、指令和反馈
按照惯例,指令用红色表示,反馈用蓝色表示。
1. ucci
引导状态的指令。这是引擎启动后,界面需要给引擎发送的第一条指令,通知引擎现在使用的协议是UCCI。
2. id {name | copyright | author | user} <信息>
引导状态的反馈。显示引擎的版本号、版权、作者和授权用户,例如:
id name ElephantEye 1.6 Beta,说明引擎的版本号是ElephantEye 1.6 Beta;
id copyright 2004-2006 www.xqbase.com,说明引擎的版权属于www.xqbase.com所有;
id author Morning Yellow,说明引擎的作者是Morning Yellow;
id user ElephantEye Test Team,说明引擎授权给用户ElephantEye Test Team使用。
3. option <选项> type <类型> [min <最小值>] [max <最大值>] [var <可选项> [var <可选项> [...]]] [default <默认值>]
引导状态的反馈。显示引擎所支持的选项,<option>指选项的名称(后面会介绍),选项的类型是label(标签,非选项)、button(指令)、check(是或非)、combo(多选项)、spin(整数)、string(字符串)中的一种。
通常的UCCI引擎支持以下选项:
(1) usemillisec(check),通知界面采用毫秒模式。建议引擎始终采用毫秒模式(即go指令的时间单位是“毫秒”),并总是在ucciok前输出option usemillisec ... 的反馈信息。除非引擎不发送 option usemillisec ... 的反馈信息,否则界面将自动使用毫秒模式,并向引擎发送 setoption usemillisec true 的指令。目前已知的UCCI界面程序(如象棋巫师、UCCI引擎联赛模拟器等)都采用这种做法;
(2) batch(check),批处理模式(前面介绍过),默认是关闭的;
(3) debug(check),调试模式,默认是关闭的,打开后引擎会输出更多的信息(info反馈),以帮助调试;
(4) ponder(check),是否使用后台思考的时间策略,默认是关闭的,设定该参数的目的仅仅是让引擎改变时间分配策略,而后台思考则仍然需要界面发出指令,参阅go ponder和ponderhit指令;
(5) usebook(check),是否使用开局库的着法,默认是启用的,如果关闭的话,即便当前局面在开局库中有着法,引擎也会不顾开局库而思考的;
(6) useegtb(check),是否使用残局库,默认是启用的,和usebook类似;
(7) bookfiles(string),设定开局库文件的名称,可指定多个开局库文件,用分号“;”隔开,如不让引擎使用开局库,除了可以关闭usebook选项外,还可以把bookfiles设成空值;
(8) egtbpaths(string),设定残局库路径的名称,和bookfiles类似;
(9) evalapi(string),设定局面评价API函数库文件的名称,和bookfiles类似,但只能是一个文件(例如,Windows下默认值是EVALUATE.DLL,Linux下默认值是libeval.so);
(10) hashsize(spin),以MB为单位规定Hash表的大小,0表示让引擎自动分配Hash表;
(11) threads(spin),支持多处理器并行运算(SMP)的引擎可指定线程数(即最多可运行在多少处理器上),0表示让引擎自动分配线程数;
(12) idle(combo),设定处理器的空闲状态,通常有none(满负荷)、small(高负荷)、medium(中负荷)、large(低符合)四种选项,引擎默认总是以满负荷状态运行的,而设置比较大的空闲状态,可以在人机对弈时留出适当的处理器资源,让用户运行其他程序;
(13) promotion(check),是否允许仕(士)相(象)升变成兵(卒),这是一种中国象棋的改良玩法,默认是不允许的(即默认采用常规走法);
(14) pruning(combo),设定裁剪程度,裁剪越多则引擎的搜索速度越快,但搜索结果不准确的可能性越大,通常有none(无)、small(小)、medium(中)、large(大)四种,一般都设为large以充分展示引擎的搜索速度,但在处理一些刁难性的排局时,用large或medium不一定能解出,可尝试small或none;
(15) knowledge(combo),设定知识大小,通常知识量越多则程序的静态局面评价越准确,但的运算速度会变慢,该选项和pruning一样有四种设定,一般都使用large,但在解杀局时不需要静态局面评价,可以把知识量设置得小些;
(16) randomness(combo),设定随机性系数,和pruning一样有四种设定,一般都设为none,以保证引擎走出它认为最好的着法,但为了增强走棋的趣味性,可以把这个参数调高,允许引擎走出它认为不是最好的着法,以丰富走棋的样式;
(17) style(combo),设定下棋的风格,通常有solid(保守)、normal(均衡)和risky(冒进)三种;
(18) newgame(button),设置新局或新的局面,引擎收到该指令时,可以执行导入开局库、清空Hash表等操作,UCCI界面《象棋巫师》在每次新建棋局或重新编辑局面时都会发送 setoption newgame 这条指令。
需要注意的是,各种引擎提供的选项内容是不一样的,也并不是所有的UCCI界面支持这些选项的,例如目前的UCCI界面《象棋巫师》没有对batch、debug等选项的设置(它们只供调试时使用)。
4. ucciok
引导状态的反馈,此后引擎进入空闲状态。
5. isready
空闲状态和思考状态的指令。检测引擎是否处于就绪状态,其反馈总是readyok,该指令仅仅用来检测引擎是否能够正常接收指令。
6. readyok
空闲状态和思考状态的反馈。表明引擎处于就绪状态(可正常接收指令)。
7. setoption <选项> [<值>]
空闲状态的指令。设置引擎参数,这些参数都应该是option反馈的参数,例如:
setoption usebook false,不让引擎使用开局库;
setoption selectivity large,把选择性设成最大;
setoption style risky,指定冒进的走棋风格;
setoption loadbook,初始化开局库。
但是,设置option反馈没有给出的参数,并不会出错。例如UCCI界面《象棋巫师》就从不识别option反馈,而直接根据用户的设置发送setoption指令。
8. position {fen <FEN串> | startpos} [moves <后续着法列表>]
空闲状态的指令。设置“内置棋盘”的局面,用fen来指定FEN格式串,moves后面跟的是随后走过的着法,例如:
position fen rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1 moves h2e2 h9g7
FEN格式串的写法参阅《中国象棋电脑应用规范(三):FEN文件格式》一文。
moves选项是为了防止引擎着出长打着法而设的,UCCI界面传递局面时,通常fen选项为最后一个吃过子的局面(或开始局面),然后moves选项列出该局面到当前局面的所有着法。
startpos表示开始局面,它等价于 fen rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1 。
9. banmoves <禁止着法列表>
空闲状态的指令。为当前局面设置禁手,以解决引擎无法处理的长打问题。当出现长打局面时,棋手可以操控界面向引擎发出禁手指令。例如:
position fen 1r2kab1r/2c1a4/n1c1b1n2/4p2N1/p1p6/1C4P2/P1P1P4/2N1B3C/4A4/1RBAK2R1 w - - 0 1 moves h6i4 i9h9 i4h6 h9i9
banmoves h6i4
本例取自《象棋竞赛规则》(1999年版)棋例图三,由于大多数象棋引擎无法识别红方这种方式的长捉,所以在采用中国象棋协会的比赛规则时,遇到这种局面就必须给引擎发出禁手指令。下一次发送position指令后,前面设置过的禁止着法就取消了,需要重新设置禁止着法。
目前UCCI界面《象棋巫师》不识别长打禁手,所以不会向引擎发送banmoves指令。
10. go [ponder | draw] <思考模式>
空闲状态的指令,此后引擎进入思考状态。让引擎根据position指令设定的棋盘来思考,各选项为思考方式,有三种模式可供选择:
(1) depth <深度> | infinite:限定搜索深度,infinite表示无限制思考(直到找到杀棋或用stop指令中止)。如果深度设定为0,那么引擎可以只列出当前局面静态评价的分数,并且反馈nobestmove。
(2) nodes <结点数>:限定搜索结点数。
(3) time <时间> [movestogo <剩余步数> | increment <每步加时>] [opptime <对方时间> [oppmovestogo <对方剩余步数> | oppincrement <对方每步加时>]]:限定时间,时间单位是秒(默认)或毫秒(启用毫秒制时),movestogo适用于时段制,increment适用于加时制。opptime、oppmovestogo和oppincrement可以让界面把对方的用时情况告诉引擎。
如果指定ponder选项,则引擎思考时时钟不走,直到接受到ponderhit指令后才计时,该选项用于后台思考,它只对限定时间的思考模式有效。
指定draw选项表示向引擎提和,引擎以bestmove提供的选项作为反馈,参阅bestmove指令。
注意:ponder和draw选项不能同时使用,如果界面向正在后台思考中的引擎求和,则使用ponderhit draw指令。
11. info <思考信息>
思考状态的反馈。显示引擎思考信息,通常有以下几种信息:
(1) time <已花费的时间> nodes <已搜索的结点数>:思考信息中给出的时间通常以毫秒为单位,结点数和时间相除就是引擎的速度(NPS值),单位是K。
(2) depth <当前搜索深度> [score <分值> pv <主要变例>]:输出引擎思考到的深度及其思考路线和好坏。例如在起始局面下,《象棋巫师》收到引擎的反馈:info depth 6 score 4 pv b0c2 b9c7 c3c4 h9i7 c2d4 h7e7,那么界面上应该输出:6 (+4) 马八进七 马2进3 兵七进一 马8进9 马七进六 炮8平5。分值通常以一个轻子(马或炮)为100分记,以上信息说明此时当前要走的一方占有相当于0.04个轻子的优势。
(3) currmove <当前搜索着法>:输出引擎正在思考的着法。
(4) message <提示信息>:输出引擎要直接告诉用户的信息,建议界面程序直接将提示信息显示在界面上。
12. ponderhit [draw]
思考状态的指令。告诉引擎后台思考命中,现在转入正常思考模式(引擎继续处于思考状态,此时go指令设定的时限开始起作用)。
指定draw选项表示向引擎提和,引擎以bestmove提供的选项作为反馈,参阅bestmove指令。
13. stop
思考状态的指令。中止引擎的思考。另外,后台思考没有命中时,就用该指令来中止思考,然后重新输入局面。
注意:发出该指令并不意味着引擎将立即回到空闲状态,而是要等到引擎反馈bestmove或nobestmove后才表示回到空闲状态,引擎应尽可能快地作出这样的反馈。
14. bestmove <最佳着法> [ponder <后台思考的猜测着法>] [draw | resign]
思考状态的反馈,此后引擎返回空闲状态。显示思考结果,即引擎认为在当前局面下的最佳着法,以及猜测在这个着法后对手会有怎样的应对(即后台思考的猜测着法)。通常,最佳着法是思考路线(主要变例)中的第一个着法,而后台思考的猜测着法则是第二个着法。
在对手尚未落子时,可以根据该着法来设定局面,并作后台思考。当对手走出的着法和后台思考的猜测着法吻合时,称为“后台思考命中”。
draw选项表示引擎提和或者接受界面向引擎发送的提和请求,参阅go draw和ponderhit draw指令。resign选项表示引擎认输。UCCI界面在人机对弈方式下,根据不同情况,可以对引擎的bestmove反馈中的draw和resign选项作出相应的处理:
(1) 如果用户提和,界面向引擎发出go draw或ponderhit draw指令,而引擎反馈带draw的bestmove,那么界面可终止对局并判议和;
(2) 如果用户没有提和,而引擎反馈带draw的bestmove,那么界面可向用户提和,用户接受提和则可终止对局并判议和;
(3) 如果引擎反馈带resign的bestmove,那么界面可终止对局并判引擎认输。
引擎应该根据当前局面的情况(由position指令给出),以及界面是否发送了带draw的go或ponderhit指令,来考虑是否反馈带draw或resign的bestmove。
15. nobestmove
思考状态的反馈,此后引擎返回空闲状态。显示思考结果,但引擎一步着法也没计算,表示当前局面是死局面,或者接收到诸如 go depth 0 等只让引擎给出静态局面评价的指令。
16. probe {fen <FEN串> | startpos} [moves <后续着法列表>]
空闲状态和思考状态的指令。获取Hash表中指定局面的信息,引擎必须立刻在Hash表中查找该局面的状态,由pophash指令反馈。
该指令仅用于引擎的调试,设计者可以向引擎发送一系列probe指令,捕获到搜索树的大致信息。
17. pophash [bestmove <最佳着法>] [lowerbound <Beta值> depth <深度>] [upperbound <Alpha值> depth <深度>]
空闲状态和思考状态的反馈。输出由probe所指定的局面在Hash表中信息。
如果该局面没有记录在Hash表中,那么只反馈pophash即可。
18. quit
空闲状态的指令。让引擎退出运转。
19. bye
接收到quit指令后的反馈。引擎完成了退出运转前的准备工作,通知界面,引擎将在瞬间正常退出运转。界面收到该指令后,即可关闭输入输出通道。
六、用例
下面是一个后台思考的例子,描述了UCCI引擎中最难处理的部分。(从界面到引擎的)指令用红色表示,(从引擎到界面的)反馈用蓝色表示。
ucci
id name ElephantEye Demo
option usemillisec type check default false
option usebook type check default true
ucciok
setoption usemillisec true
setoption usebook false
position fen rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1
go time 300000 increment 0
info depth 6 score 4 pv b0c2 b9c7 c3c4 h9i7 c2d4 h7e7
info nodes 5000000 time 5000
bestmove b0c2 ponder b9c7
在这个例子中,引擎执红,用户执黑,采用5分钟包干的时限。引擎启动后,界面即让引擎分析初始局面(不用开局库),引擎给出最佳着法“马八进七”,然后是用户走子,引擎按照猜测着法“马2进3”作后台思考。此时,引擎消耗了5秒钟,还剩余295秒的时间。
position fen rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1 moves b0c2 b9c7
go ponder time 295000 increment 0
info depth 6 score 4 pv c3c4 h9i7 c2d4 h7e7 h0g2 i9h9
此时用户走子了,下面分两种情况讨论。
(1) 如果用户走了引擎的猜测着法“马2进3”,那么后台思考命中:
ponderhit
info nodes 10000000 time 10000
info depth 7 score 4 pv c3c4 h9i7 c2d4 h7e7 h0g2 i9h9 i0h0
info nodes 15000000 time 15000
bestmove c3c4 ponder h9i7
现在引擎走“兵七进一”,并且猜测对方会走“马8进9”。尽管这着棋引擎思考了15秒钟,但是前10秒钟用的是对手的时间,自己的时间是从ponderhit到bestmove的部分,所以自己的时间只消耗了5秒钟,因此还剩余290秒的时间。然后引擎继续后台思考。
position fen rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1 moves b0c2 b9c7 c3c4 h9i7
go ponder time 290000 increment 0
……
(2) 如果用户走了其他的着法(比如“卒3进1”),那么后台思考没有命中,必须根据这个着法重新思考。
stop
info nodes 10000000 time 10000
bestmove c3c4 ponder h9i7
position fen rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1 moves b0c2 c6c5
go time 295000 increment 0
……
如希望获得更详细的关于UCCI操作上的细节,可参考UCCI引擎ElephantEye的源程序及其说明材料,这里不再作过多的介绍。
七、电脑象棋联赛
电脑象棋联赛使用UCCI引擎,但是参赛引擎并不一定要支持UCCI的全部内容,只需要能跟“UCCI引擎联赛模拟器”正常通讯就可正常比赛了,模拟器相当于一个支持UCCI的界面。参赛引擎必须能够识别并正确处理的指令有:
(1) ucci;
(2) position fen ... [moves ...];
(3) banmoves ...;
(4) go [draw] time ... increment ... [opptime ... oppincrement ...];
(5) quit。
参赛引擎必须能够反馈的信息有:
(1) ucciok;
(2) bestmove ... [draw | resign]。
为了更好地让引擎适应模拟器,引擎最好能够实现以下功能:
(1) 支持毫秒制。即启动时有option usemillisec的反馈,能够识别并处理setoption usemillisec true的指令。启用毫秒制以后,在时间非常紧缺的情况下,模拟器就会准确地把时间告诉引擎,否则只会粗略地给出秒数。
(2) 支持认输和提和,即当引擎觉得没有机会获胜时,可以用bestmove ... draw提和或接受提和,当引擎觉得没有能力抵抗时,可以用bestmove ... resign认输,节约比赛时间。
(3) 支持stop指令。当引擎超时后,模拟器会发送stop指令让引擎立即给出着法(立即反馈bestmove),超过一定时间(如超过0.2秒)才判超时负。
另外,识别setoption指令不是必须的,但在联赛中也会有用。例如,如果引擎需要用setoption bookfiles ...来导入开局库,而模拟器不会自动向引擎发送这条指令,那么引擎必须建立配置文件,其中有setoption bookfiles ...这行指令,模拟器在启动引擎后,会把配置文件中的每行都作为指令发送给引擎的。
八、和UCI的区别
UCCI是从国际象棋通用引擎协议UCI移植过来的,沿用了大部分UCI的指令和反馈,但是为了适应中国象棋软件的需要,作了以下几点改动:
(1) 增加了banmoves指令,因为中国象棋有长打作负的规则。
(2) 把UCI的反馈option name <选项> type <类型> ...简化为option <选项> type <类型> ...。例如,UCI中有option name Hash Size type spin这条反馈,而在UCCI中则是option hashsize type spin。这里去掉了name关键字,因为它总是跟在option后面,显得多此一举。另外,UCI允许选项由多个有大小写的单词组成(如Hash Size),这是因为UCI界面会直接把这个选项名称显示在对话框上。而UCCI中的选项只用一个全部由小写字母组成的单词,因为中国象棋的UCCI界面使用中文,因此界面上不能输出Hash Size,而用“置换表”或其他可以看得懂的中文术语。这样,界面还不如识别一个更简单的hashsize(一个全部由小写字母组成的单词),再翻译成“置换表”。
(3) 把UCI的指令setoption name <选项> [value <值>]简化为setoption <选项> [<值>]。由于UCCI的选项是一个单词,所以第二个空格后的内容肯定是选项的值,因此name和value就显得多此一举了。
(4) UCCI明确了引擎所处的三种状态,以及这三种状态下适用的指令和反馈,为界面和引擎的程序设计提供了清晰的思路。
(5) UCCI以go time <时间> opptime <时间>的形式把双方的时间信息传达给引擎,而不是UCI的go wtime <时间> btime <时间>,这样可以简化引擎解析时间的操作,如果引擎在指定用时策略时不考虑对方的用时,那么可以不理会opptime <时间>。
(6) UCCI明确了4种思考模式,使得指令的解析简单化了。
(7) UCCI规定position fen ... [moves ...]指令中的FEN串是当前局面前第一个不吃子的局面,后面再跟该局面的后续着法。而UCI则是用position startpos moves ...指令,把棋局从头到尾的着法全都列出来,会增加通讯通道的压力。
2、Java图形界面开发基础知识
绘图是在JPanel类的paintComponent()方法中完成的。 在绘图过程中,我们使用Graphics2D对象,Graphics2D类可以理解为画笔。它是java为我们提供的一个绘图类。使用它可以画:
直线 drawLine(int x1, int y1, int x2, int y2)
矩形 drawRect(int x, int y, int width, int height)
圆形 drawOval(int x, int y, int width,int height)
填充矩形 fillRect(int x,int y, int width, int height)
填充圆形 fillOval(int x,int y, int width, int height)
图片 drawImage(Image img, int dx1, int dy1, int dx2, int dy2, int sx1, int sy1, int sx2, int s ImageObserver observer)
字符串 drawString(AttributedCharacterIterator iterator, int x, int y)
设置画笔颜色setColor(Color c)
设置字体setFont(Font font)
等功能
2.1、绘制形状
(1)drawLine(int x1, int y1, int x2, int y2)
此方法的作用是在(x1,y1)与(x2,y2)之间画出一条直线,当然两个坐标相等就是点。
示例:
@Override
public void paintComponent(Graphics g)
{//绘制一个蓝色的直线
g.setColor(Color.blue);
g.drawLine(50,50,100,100);
}
(2)drawRect(int x,int y,int width,int height) 此方法的作用是绘制矩形,x,y是其左上角的位置,其他是矩形的长和宽。
(3)fillRect(int x,int y,int width,int height) 此方法的作用是绘制矩形,并可以对矩形的颜色进行填充。
示例:
@Override
public void paintComponent(Graphics g)
{//绘制一个蓝色的方框,位置在x轴20,y轴50处
g.setColor(Color.blue);
g.drawRect(0,10,50,100);
}
(4)drawRoundRect(int x,int y,int width, int height, int arcWidth, int arcHeight) 此方法的作用是绘制圆角矩形。其中参数x和y指定矩形左上角的位置;参数width和heigth是矩形的宽和高;arcWidth和arcHeight分别是圆角弧的横向直径和圆角弧的纵向直径。
(5)fillRoundRect(int x,int y,int width,int height,int arcWidth,int archeight) 此方法的作用是绘制以某种颜色填充的圆角矩形。各参数的意义同drawRoundRect方法。
示例:
@Override
public void paintComponent(Graphics g)
{
g.setColor(Color.blue);
g.drawRoundRect(0,0,200,100,50,50);
g.fillRoundRect(100,100,200,50,20,20);
}
(6)draw3DRect(int x,int y,int width,int height, boolean raised) 画一个突出显示的三维矩形。其中x和y指定矩形左上角的位置,参数width和height是矩形的宽和高,参数raised是突出与否。
(7)fill3DRect(int x,int y,int width,int height,boolean raised) 用预定的颜色填充一个突出显示的三维矩形矩形。
示例:
@Override
public void paintComponent(Graphics g)
{
g.setColor(Color.blue);
g.draw3DRect(80,100,40,25,true);
g.setColor(Color.yellow);
g.fill3DRect(20,70,20,30,true);
}
(8)drawArc(int x,int y,int width,int height,int startAngle, int arcAngle) 此方法用于绘制圆弧,椭圆的中心是它的外接矩形的中心;其中参数是外接矩形的左上角坐标(x,y),宽是width,高是heigh。参数startAngle的单位是 “度”,起始角度0度是指3点钟方位.参数startAngle和arcAngle表示从startAngle角度开始,逆时针方向画arcAngle度的弧,约定,正值度数是逆时针方向,负值度数是顺时针方向,例如-90度是6点钟方位。
(9)fillArc(int x,int y,int width, int height, int startAngle, int arcAngle) 此方法用于绘制填充颜色的圆弧,用setColor()方法设定的颜色,画着色椭圆的一部分,x和y指定矩形左上角的位置,width和height是宽和高。
示例:
@Override
public void paintComponent(Graphics g)
{
//画圆弧线
g.drawArc(10,40,90,50,0,180);
//填充缺右上角的四分之三的椭圆
g.setColor(Color.yellow);
g.fillArc(10,100,40,40,0,-270);
}
(10)drawOval(int x, int y, int width, int height) 此方法用于绘制椭圆,x和y指定矩形左上角的位置,width和height是宽和高。
(11)fillOval(int x, int y, int width, int height) 此方法用于绘制可以填充颜色的椭圆,x和y指定矩形左上角的位置,width和height是宽和高。
示例:
@Override
public void paintComponent(Graphics g)
{
g.drawOval(10,40,90,50);
g.setColor(Color.yellow);
g.fillOval(10,100,40,70);
}
2.2、绘制图片
绘制图片使用以下方法绘制指定图像中已缩放到适合指定矩形内部的图像,图像绘制在此图形上下文坐标空间的指定矩形内部,并且如果需要,则进行缩放。透明像素不影响该处已存在的像素。
public abstract boolean drawImage(Image img,int x,int y,int width,int height,ImageObserver observer)
此方法在任何情况下都立刻返回,甚至在整个图像没有为当前输出设备完成缩放、抖动或转换的情况下也是如此。如果当前的输出表示尚未完成,则 drawImage 返回 false。随着更多的图像可用,装入图像的进程将通过调用图像观察者的 imageUpdate 方法来通知它。
图像的缩放版本不一定立刻是可用的,因为已经为此输出设备构造了图像的非缩放版本。每种大小的图像可以被分别缓存,并由各自图像产生序列中的原始数据生成。
参数:
img - 要绘制的指定图像。如果 img 为 null,则此方法不执行任何动作。
x - x 坐标。
y - y 坐标。
width - 矩形的宽度。
height - 矩形的高度。
observer - 当转换了更多图像时要通知的对象。
2.3、绘制文字
使用graphics.drawString(word, x, y)绘制文字,需要注意的是,此方法是在坐标(x,y)处从左向右绘制文字,如果想在(x,y)处居中绘制文字,则需要使用FontMetrics获得文字的宽度,再向左偏移文字宽度的一半。
2.4、界面点击事件处理
要在Panel上触发点击事件,我们需要为Panel添加一个鼠标监听器。鼠标监听器是一个接口,定义了处理鼠标事件的方法。我们可以创建一个鼠标监听器的实现类,并将其添加到Panel上。
以下是一个简单的鼠标监听器的实现类的示例:
class MyMouseListener implements MouseListener
{
public void mouseClicked(MouseEvent e)
{
System.out.println("Panel clicked");
}
public void mousePressed(MouseEvent e) {}
public void mouseReleased(MouseEvent e) {}
public void mouseEntered(MouseEvent e) {}
public void mouseExited(MouseEvent e) {}
}
然后,我们可以将鼠标监听器添加到Panel上:
panel.addMouseListener(new MyMouseListener());
现在,当我们点击Panel时,控制台将输出"Panel clicked"。
2.5、获取用户鼠标点击位置
用户点击鼠标时,我们可以在监听鼠标点击事件的处理代码里获得点击位置的x和y坐标,这里的x和y坐标都是相对于左上角而言的,我们需要将点击的位置与棋盘的交叉点对应起来,这样在后续的处理中就知道用户点击的位置,再结合表示棋子的数组,就知道用户点击的棋子了。
2.6、界面坐标到棋盘数组坐标转换
当我们拿到用户点击的位置之后,需要将点击的位置与表示棋子的数组的某个位置的棋子关联起来,这样才能继续处理走棋和界面更新的逻辑。我们很容易想到,表示象棋棋子的方式:创建一个9*10的二维数组,二维数组的每一个位置表示棋盘的每一个交叉点,在程序初始化的时候,将棋子的资源数组与每一个位置关联起来,在走棋的时候,将用户的动作,如点击,走一步棋等产生的变化,更新到数组对应下标的位置,然后再重新绘制界面,就实现了走棋的效果。
在本项目里,为了减小计算量和简化逻辑,没有采用二维数组,而是使用一个总长度为90的一位数组,依次表示从棋盘的左上角到右下角的交叉点,在用户点击鼠标的时候,将点击位置的x和y坐标,转换为一维数组的序号,这里的转换的规则是:
假设用户点击的位置坐标是x和y,对应数组的序号是pos,那么有如下公式:
pos= (y * 9) + x
相反的操作是,根据pos,计算点击的位置坐标公式为:
int x = pos % 9;
int y = pos / 9;
参考以下图片,坐标系原点在左上角,从左到右是x坐标的正方向,从上到下是y坐标的正方向。
2.7界面绘制
有了前面的程序绘图基础之后,我们就开始正式的绘图。
首先需要绘制棋盘,这里有两种方式,直接使用现成的棋盘图片,或者使用程序绘制棋盘,本项目同时采用了这两种方式。
2.7.1、绘制背景图片
首先先声明一个变量imgBoard
/**
* 棋盘背景图片
*/
private BufferedImage imgBoard;
然后在JPanel的构造方法中初始化这个变量,
imgBoard = getImage(getCodeBase(), "boards/" + ChessConstant.BOARD_NAME[Config.get().getBoard()]);
最后在绘图方法里绘制棋盘或棋盘背景图片
@Override
public void paintComponent(Graphics g)
{
// 去清除运动的痕迹
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g;
// 显示棋盘背景图片
g2.drawImage(imgBoard, 0, 0, this);
}
paintComponent 方法是JPanel的绘图方法,会被系统自动调用,我们也可以在程序里通过调用repaint()方法的形式手动调用。
2.7.2、绘制棋盘
这里的绘制棋盘是指通过绘制直线的方式绘制棋盘。
首先需要外面的方框,使用的方法如下:
g2.drawRect(ChessConstant.CHESSBOARD_MARGIN, ChessConstant.CHESSBOARD_MARGIN, ChessConstant.GRID_WIDTH * 8, ChessConstant.GRID_WIDTH * 9);
接着,绘制10条横线:
for (int i = 0; i <= 9; i++)
{
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN, ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH,
ChessConstant.CHESSBOARD_MARGIN + ChessConstant.GRID_WIDTH * 8, ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH);
}
这里的x和y坐标是根据每个棋盘格子的宽度和棋盘的边距来计算的,棋盘格子的宽度是可以动态调整的,这样做主要是为了方便适配不同风格的棋盘图片,同时也方便调整显示效果,ChessConstant类中这两个属性的定义如下:
/**
* 棋盘每个格子的大小
*/
public static final int GRID_WIDTH = 68;
/**
* 棋盘距离边框的距离
*/
public static final int CHESSBOARD_MARGIN = GRID_WIDTH;
接着绘制中间的“楚河汉界”四个汉字,
Font f2 = new Font(ChessConstant.FONT_NAME[2], Font.PLAIN, 32);
g2.setFont(f2);
g2.drawString("楚河", ChessConstant.CHESSBOARD_MARGIN + ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 4 * ChessConstant.GRID_WIDTH + (int) (ChessConstant.GRID_WIDTH * 2 / 3));
g2.drawString("汉界", ChessConstant.CHESSBOARD_MARGIN + ChessConstant.GRID_WIDTH * 6, ChessConstant.CHESSBOARD_MARGIN + 4 * ChessConstant.GRID_WIDTH + (int) (ChessConstant.GRID_WIDTH * 2 / 3));
接着绘制棋盘的竖线,注意竖线要分上下两部分来画,不能覆盖文字区域,代码如下:
// 上半部分
for (int i = 0; i < 9; i++)
{
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN,
ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 4 * ChessConstant.GRID_WIDTH);
}
// 下半部分
for (int i = 0; i < 9; i++)
{
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 5 * ChessConstant.GRID_WIDTH,
ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 9 * ChessConstant.GRID_WIDTH);
}
最后是绘制九宫格,这里也要注意区分红方和黑方,代码如下:
// 上半部分
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + 3 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN, ChessConstant.CHESSBOARD_MARGIN + 5 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 2 * ChessConstant.GRID_WIDTH);
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + 5 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN, ChessConstant.CHESSBOARD_MARGIN + 3 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 2 * ChessConstant.GRID_WIDTH);
// 下半部分
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + 3 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 7 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 5 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 9 * ChessConstant.GRID_WIDTH);
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + 5 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 7 * ChessConstant.GRID_WIDTH,ChessConstant.CHESSBOARD_MARGIN + 3 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 9 * ChessConstant.GRID_WIDTH);
到这里棋盘就绘制完了,完整的方法如下:
/**
* 绘制棋盘刻度线
*/
private void drawGrid(Graphics2D g2)
{
// 设置绘制笔颜色
g2.setColor(ChessConstant.LINE_COLOR);
g2.setStroke(new BasicStroke(2.0f));
Font f = new Font(ChessConstant.FONT_NAME[2], Font.BOLD, 30);
g2.setFont(f);
// 绘制外面的框
g2.drawRect(ChessConstant.CHESSBOARD_MARGIN, ChessConstant.CHESSBOARD_MARGIN, ChessConstant.GRID_WIDTH * 8, ChessConstant.GRID_WIDTH * 9);
// 绘制横线
for (int i = 0; i <= 9; i++)
{
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN, ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH,
ChessConstant.CHESSBOARD_MARGIN + ChessConstant.GRID_WIDTH * 8, ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH);
}
// 绘制中间的文字
Font f2 = new Font(ChessConstant.FONT_NAME[2], Font.PLAIN, 32);
g2.setFont(f2);
g2.drawString("楚河", ChessConstant.CHESSBOARD_MARGIN + ChessConstant.GRID_WIDTH,
ChessConstant.CHESSBOARD_MARGIN + 4 * ChessConstant.GRID_WIDTH + (int) (ChessConstant.GRID_WIDTH * 2 / 3));
g2.drawString("汉界", ChessConstant.CHESSBOARD_MARGIN + ChessConstant.GRID_WIDTH * 6,
ChessConstant.CHESSBOARD_MARGIN + 4 * ChessConstant.GRID_WIDTH + (int) (ChessConstant.GRID_WIDTH * 2 / 3));
// 绘制竖线
// 上半部分
for (int i = 0; i < 9; i++)
{
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN,
ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 4 * ChessConstant.GRID_WIDTH);
}
// 下半部分
for (int i = 0; i < 9; i++)
{
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 5 * ChessConstant.GRID_WIDTH,
ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 9 * ChessConstant.GRID_WIDTH);
}
// 绘制九宫格斜线
// 上半部分
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + 3 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN,
ChessConstant.CHESSBOARD_MARGIN + 5 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 2 * ChessConstant.GRID_WIDTH);
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + 5 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN,
ChessConstant.CHESSBOARD_MARGIN + 3 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 2 * ChessConstant.GRID_WIDTH);
// 下半部分
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + 3 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 7 * ChessConstant.GRID_WIDTH,
ChessConstant.CHESSBOARD_MARGIN + 5 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 9 * ChessConstant.GRID_WIDTH);
g2.drawLine(ChessConstant.CHESSBOARD_MARGIN + 5 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 7 * ChessConstant.GRID_WIDTH,
ChessConstant.CHESSBOARD_MARGIN + 3 * ChessConstant.GRID_WIDTH, ChessConstant.CHESSBOARD_MARGIN + 9 * ChessConstant.GRID_WIDTH);
}
2.7.3、绘制棋子
再绘制棋子之前,需要先定义一个一位数组,用来表示棋子棋盘的每一个位置,有棋子的位置,就置为对应的棋子标记,没有棋子则标记为空,定义如下:
/**
* 棋子数组
*/
private Piece[] posArray = new Piece[90];
然后还需要定义一个存储棋子图片的一维数组,定义如下:
/**
* 棋子的图片数组
*/
private BufferedImage[] pieceImageArray;
pieceImageArray数组需要在构造方法里进行初始化,其完整代码如下:
public void loadPieces()
{
BufferedImage bufImage = null;
pieceImageArray = new BufferedImage[16];
try
{
for (int i = 0; i < pieceImageArray.length; i++)
{
File imageFolder = new File(getCodeBase() + "pieces/" + ChessConstant.PIECES_NAME[Config.get().getPieces()]);
File[] listFile = imageFolder.listFiles();
String suffix = listFile[0].getName().substring(listFile[0].getName().lastIndexOf("."));
String filePath = getCodeBase() + "pieces/" + ChessConstant.PIECES_NAME[Config.get().getPieces()] + "/" + ChessConstant.PIECE_ARRAY[i] + suffix;
bufImage = ImageIO.read(new File(filePath));
pieceImageArray[i] = bufImage;
}
}
catch (IOException e)
{
System.out.println("初始化棋子资源出错:" + e.getMessage());
e.printStackTrace();
}
}
loadPieces()需要再构造方法里调用。
这里的ChessConstant.PIECES_NAME类用于存储棋子数组的定义,Config类用于选择不同风格棋子后系统配置项的保存和加载,其定义分别如下:
/**
* 棋子风格名称,资源来自于网络
*/
public static final String[] PIECES_NAME = {"经典木质棋子", "经典抛光棋子", "经典精致棋子", "魏碑体象棋", "精品红黑2", "精美黄金杉木棋子", "旋风倚天介面套装2", "精品红黑", "精品黑白", "纽扣圆棋子", "木板雕刻", "色彩增强", "精美黄金杉木棋子", "印度风格1", "印度风格2","印度风格3", "圆形黄底棋子", "卡通摆正", "黑白棋子", "悟空象棋棋子", "冰河五四棋子", "2D白木棋子", "2D金边", "2D经典", "2D精致棋子", "2D木颜棋子", "2D木颜棋子", "213QQ最新改版棋子", "2D终极红黑", "3D光滑","3D原木棋子", "3D木", "3D木质清爽棋子", "3D楷棋子", "3D玉化塑棋子", "3D玉化石棋子", "3D白木红黑", "3D立体白木", "3D精致棋子(金边)", "3D紫罗兰棋子", "3D经典梦幻棋子", "JJ旋律棋子2", "Piece01","Piece02", "QQ棋子", "冰河五四(立体感)棋子", "古典版棋子", "名手一旋风棋子(立体感)", "土豪金终结者", "无双棋子", "木板棋子", "正3D细楷棋子2", "水晶玻璃棋子", "清爽棋子2", "清纯棋子", "玉化石棋子", "玉化石棋子2","精致红黑棋子", "精致经典", "终极2D红黑", "终极塑钢红黑棋子", "行皆棋子", "透光棋子", "金边2D", "魏碑钮扣棋子", "2014精品棋子", "2014金棋子新改版1", "2014金棋子新改版2", "2014金棋子新改版3", "2014金棋子新改版4","214新改版经典黑白棋子", "3DQQ木棋子", "3D_清爽棋子", "3D彩虹棋子", "3D祥玉棋子", "3D鼓形棋子", "彩色棋子", "JJ旋律棋子", "仿真弈天棋子", "刻石头", "古典铜棋子", "名手-旋风通用棋盘棋子", "无雪棋子", "清爽白棋子","清爽黑白", "牛角棋子", "玉佩棋子1", "玉佩精致棋子3", "玉石棋子4", "玻璃钢棋子3", "玻璃钢红黑1", "玻璃钢红黑2", "白棋子", "白色钮扣棋子", "石头玉棋子", "祥和棋子", "精武门", "精灵棋子", "精灵棋子(经典)", "精灵棋子1","精灵棋子2", "精美棋子", "精美终结者", "终结者2", "终结者棋子", "经典", "经典3D木棋子", "经典古典铜棋子", "经典木纹", "经典石头棋子", "翠石棋子", "翡翠棋子", "翡翠金边", "金刚钻棋子", "钮扣黄棋子", "隋形棋子", "隋形红黑经典","仿QQ棋子" };
棋子资源初始化之后,就可以在paintComponent方法中绘制棋子了,具体的绘制逻辑如下:
/**
* 绘制所有象棋棋子
*
* @param g2
*/
private void drawPieces(Graphics2D g2)
{
for (int pos = 0; pos < posArray.length; pos++)
{
Piece chessId = posArray[pos];
if (chessId != null && !chessId.isEmpty())
{
drawPiece(g2, getPiece(chessId.getResIndex()), pos);
}
}
}
/**
* 绘制单个象棋棋子
*
* @param g2
* @param bitmap
* @param pos
*/
private void drawPiece(Graphics2D g2, BufferedImage bitmap, int pos)
{
int x = pos % 9;
int y = pos / 9;
if (diverted)
{
x = 8 - x;
y = 9 - y;
}
int dx = (ChessConstant.GRID_WIDTH / 2 - ChessConstant.PIECE_WIDTH / 2);
int dy = (ChessConstant.GRID_WIDTH / 2 - ChessConstant.PIECE_WIDTH / 2);
g2.drawImage(bitmap, ChessConstant.CHESSBOARD_MARGIN + x * ChessConstant.GRID_WIDTH - ChessConstant.GRID_WIDTH / 2 + dx, ChessConstant.CHESSBOARD_MARGIN + y * ChessConstant.GRID_WIDTH - ChessConstant.GRID_WIDTH / 2 + dy,this);
}
到这里,棋盘和棋子都绘制完了,我们来看一下效果:
这里选用的棋盘背景为“bg_自制.png”,棋子风格为“精美黄金杉木棋子”,资源来自于一位网友的无私分享,原本是“冰河五四”的棋子,我对棋子大小做了一定的调整,以完全适应当前的程序。
2.7.4、绘制棋盘坐标系
绘制棋盘坐标系主要是为了方便记录棋谱和程序调试,坐标系有三种:"传统方式", "ICCS格式", "Swing坐标系"。传统方式也就是“炮二平五 , 马8进7”这种中文纵线格式,其形式如图:
ICCS是中国象棋互联网服务器(Internet Chinese Chess Server)的缩写。在网络对弈服务器处理着法时,把着法表示成起点和终点的坐标是最方便的,其形式如下:
1. H2-E2 (炮二平五) H7-E7 (炮8平5)
2. E2-E6 (炮五进四) D9-E8 (士4进5)
3. H0-G2 (马二进三) H9-G7 (马8进7)
4. B2-E2 (炮八平五) B9-C7 (马2进3)
5. E6-E4 (前炮退二) I9-H9 (车9平8)
6. ……
ICCS格式如图:
Swing坐标系则是为了方便程序调试的,其能让用户直观的看到点击位置的x和y坐标,如下图所示:
完整的方法如下:
@Override
public void paintComponent(Graphics g)
{
// 去清除运动的痕迹
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g;
//……其他代码省略
// 绘制棋盘坐标
if (Config.get().getCoordinate() == 0)
{
drawNumbers(g2);
}
else if (Config.get().getCoordinate() == 1)
{
drawICCSNumbers(g2);
}
else
{
drawICoordinates(g2);
}
//……其他代码省略
}
/**
* 绘制棋盘坐标线,传统记谱方式
*/
private void drawNumbers(Graphics2D g2)
{
// 设置绘制笔颜色和字体
g2.setColor(ChessConstant.WORD_COLOR);
Font font = new Font(ChessConstant.FONT_NAME[0], Font.PLAIN, 25);
g2.setFont(font);
FontMetrics fm = g2.getFontMetrics(font);
// 绘制黑方坐标
for (int i = 0; i < 9; i++)
{
fontWidth = fm.stringWidth(ChessConstant.blackMarkNumbers[i]);
fontHeight = fm.getHeight();
int width = fontWidth;
// 绘制字符串,x坐标即字符串左边位置,y坐标是指baseline的y坐标,即字体所在矩形的左上角y坐标+ascent
g2.drawString(ChessConstant.blackMarkNumbers[i], ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH - width / 2,
ChessConstant.CHESSBOARD_MARGIN / 4 + ChessConstant.GRID_WIDTH / 4);
}
// 绘制红方坐标
for (int i = 0; i < 9; i++)
{
fontWidth = fm.stringWidth(ChessConstant.redMarkNumbers[i]);
fontHeight = fm.getHeight();
int width = fontWidth;
g2.drawString(ChessConstant.redMarkNumbers[i], ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH - width / 2,
ChessConstant.CHESSBOARD_MARGIN + ChessConstant.GRID_WIDTH * 9 + ChessConstant.GRID_WIDTH / 4 + ChessConstant.CHESSBOARD_MARGIN / 2);
}
}
/**
* 绘制棋盘坐标线,ICCS着法格式
*/
private void drawICCSNumbers(Graphics2D g2)
{
g2.setColor(ChessConstant.WORD_COLOR);
Font font = new Font(ChessConstant.FONT_NAME[0], Font.PLAIN, 25);
g2.setFont(font);
FontMetrics fm = g2.getFontMetrics(font);
// 绘制上方横坐标
for (int i = 0; i < 9; i++)
{
fontWidth = fm.stringWidth(ChessConstant.iccsHorizontalNumbers[i]);
fontHeight = fm.getHeight();
int width = fontWidth;
g2.drawString(ChessConstant.iccsHorizontalNumbers[i], ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH - width / 2,
ChessConstant.CHESSBOARD_MARGIN / 4 + ChessConstant.GRID_WIDTH / 4);
}
// 绘制下方横坐标
for (int i = 0; i < 9; i++)
{
fontWidth = fm.stringWidth(ChessConstant.iccsHorizontalNumbers[i]);
fontHeight = fm.getHeight();
int width = fontWidth;
g2.drawString(ChessConstant.iccsHorizontalNumbers[i], ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH - width / 2,
ChessConstant.CHESSBOARD_MARGIN + ChessConstant.GRID_WIDTH * 9 + ChessConstant.GRID_WIDTH / 4 + ChessConstant.CHESSBOARD_MARGIN / 2);
}
// 绘制竖坐标
for (int i = 0; i <= 9; i++)
{
fontWidth = fm.stringWidth(ChessConstant.iccsVerticalNumbers[i]);
fontHeight = fm.getHeight();
int width = fontWidth;
g2.drawString(ChessConstant.iccsVerticalNumbers[9 - i], ChessConstant.CHESSBOARD_MARGIN / 4,
ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH + width / 2);
}
}
/**
* 绘制棋盘坐标线,Swing坐标系方式
*/
private void drawICoordinates(Graphics2D g2)
{
g2.setColor(ChessConstant.WORD_COLOR);
Font font = new Font(ChessConstant.FONT_NAME[0], Font.PLAIN, 25);
g2.setFont(font);
FontMetrics fm = g2.getFontMetrics(font);
// 绘制上方横坐标
for (int i = 0; i < 9; i++)
{
// 字符串绘制宽度
fontWidth = fm.stringWidth(ChessConstant.xIndex[i]);
fontHeight = fm.getHeight();
int width = fontWidth;
g2.drawString(ChessConstant.xIndex[i], ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH - width / 2,
ChessConstant.CHESSBOARD_MARGIN / 4 + ChessConstant.GRID_WIDTH / 4);
}
// 绘制下方横坐标
for (int i = 0; i < 9; i++)
{
fontWidth = fm.stringWidth(ChessConstant.xIndex[i]);
fontHeight = fm.getHeight();
int width = fontWidth;
g2.drawString(ChessConstant.xIndex[i], ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH - width / 2,
ChessConstant.CHESSBOARD_MARGIN + ChessConstant.GRID_WIDTH * 9 + ChessConstant.GRID_WIDTH / 4 + ChessConstant.CHESSBOARD_MARGIN / 2);
}
// 绘制竖坐标
for (int i = 0; i <= 9; i++)
{
fontWidth = fm.stringWidth(ChessConstant.yIndex[i]);
fontHeight = fm.getHeight();
int width = fontWidth;
g2.drawString(ChessConstant.yIndex[i], ChessConstant.CHESSBOARD_MARGIN / 4,
ChessConstant.CHESSBOARD_MARGIN + i * ChessConstant.GRID_WIDTH + width / 2);
}
}
2.7.5、绘制棋盘走棋位置
绘制棋子走棋位置用于提示用户最后一步的行动路径,需要在加载完棋子资源后才能绘制。这里需要先准备两张透明背景的图片,具体代码如下:
@Override
public void paintComponent(Graphics g)
{
// 去清除运动的痕迹
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g;
//……其他代码省略
// 绘制点击指示图
drawIndicators(g2);
//……其他代码省略
}
/**
* 绘制指示标记图片,用于显示上一个位置和当前位置
*
* @param g2
*/
private void drawIndicators(Graphics2D g2)
{
if (from != -1)
{
BufferedImage bitmap;
if (to != -1)
{
bitmap = getPiece(15);
}
else
{
bitmap = getPiece(14);
}
drawPiece(g2, bitmap, from);
}
if (to != -1)
{
drawPiece(g2, getPiece(14), to);
}
}
2.7.6、绘制棋子的可落子位置提示
这个功能主要是针对一些入门用户,提示当前选择棋子的合理落子位置,属于可选功能。首先需要定义一个数组,用于保存棋子的合法落子位置,定义如下:
// 棋子可移动路线图
public List<ChessPoint> moveIndicitorList = new ArrayList<>();
接着在点击事件的处理方法里,更新这个列表,详细代码如下:
/**
* 鼠标点击事件
*/
public void mousePressed(MouseEvent e)
{
// 将鼠标点击位置转换为棋盘坐标
int y = Math.round(1.0f * (e.getY() - ChessConstant.CHESSBOARD_MARGIN - ChessConstant.Y_INIT) / ChessConstant.GRID_WIDTH);
int x = Math.round(1.0f * (e.getX() - ChessConstant.CHESSBOARD_MARGIN - ChessConstant.X_INIT) / ChessConstant.GRID_WIDTH);
// 处理鼠标点击棋盘上交叉点的点击事件
if ((y >= 0 && y <= 9) && (x >= 0 && x <= 8))
{
onBoardCrossClicked((y * 9) + x);
}
}
private void onBoardCrossClicked(int pos)
{
//……其他代码省略
int x = pos % 9;
int y = pos / 9;
if (diverted)
{
x = 8 - x;
y = 9 - y;
}
updateMoveIndicitorList(y, x, controller.currentPosition().getPosArray());
//……其他代码省略
}
private void updateMoveIndicitorList(int yy, int xx, Piece[] posArray)
{
controller.moveIndicitorList.clear();
for (int y = 0; y < 10; y++)
{
for (int x = 0; x < 9; x++)
{
int from = (yy * 9) + xx;
int to = (y * 9) + x;
Move move = new Move(from, to);
if (controller.legalMove(move))
{
controller.moveIndicitorList.add(new ChessPoint(x, y));
}
}
}
}
这里的controller对象和Move类的定义见源代码,在这里就不展开了,这里主要关注更新moveIndicitorList的逻辑,也就是遍历当前棋局数组,判断当前棋盘上每一个位置到当前位置的走法是否合法,如果合法则加到列表里,然后在后续的绘图方法中绘制这里的位置列表。绘制具体合法落子位置的代码如下:
@Override
public void paintComponent(Graphics g)
{
// 去清除运动的痕迹
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g;
//……其他代码省略
// 画可移动线路图
if (controller.moveIndicitorList.size() > 0)
{
g2.setColor(Color.BLUE);
for (int i = 0; i < controller.moveIndicitorList.size(); i++)
{
ChessPoint chessPoint = controller.moveIndicitorList.get(i);
// 消除画图锯齿
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_DEFAULT);
g2.fillOval(ChessConstant.CHESSBOARD_MARGIN + chessPoint.getX() * ChessConstant.GRID_WIDTH - ChessConstant.RADIUS / 2,
ChessConstant.CHESSBOARD_MARGIN + chessPoint.getY() * ChessConstant.GRID_WIDTH - ChessConstant.RADIUS / 2,
ChessConstant.RADIUS, ChessConstant.RADIUS);
}
}
//……其他代码省略
}
3、象棋软件开发基础知识
3.1、象棋的基本规则
这里简要说明行棋的规则和一般的输赢判断规则。
着法合法性检查规则:
车走直线、马踩日,炮打翻山、象飞田,兵卒过河左右走,仕象不离老将边;
行棋着法不能吃已方棋子;
车只能在一条线上走子,直到遇到第一个障碍棋子,如果棋子是敌方的,可以吃掉它;
马走一个日字,但有「蹩腿」的行动限制;
象走一个田字,不能过河界,且有被「填心」的行动限制;
炮可以在一条直线上走子,直至遇到第一个障碍棋子,如果越过障碍棋子后在直线上遇到的第一个棋子是敌方棋子,可以吃掉它;
兵卒在冲过对方的河界之前,只能一次向前行走一步,过了敌方河界以后,可以向前方或左右方向行走一步;
一步着法过后,不能造成双方的老将对面,即两方老将都在同一列,且中间无格挡棋子;
胜负检查判定方法:
如果无论已方走什么可行着法都无法解除对方下一步对已方老将的攻击,则表示已方已经输了;
如果对方行棋后,已方无合法的着法可走,则表示已方已经输了;
如果一方在进棋过程中反复捉对方的将或大子,造成了三次以上的连续重复局面,则判长捉和长将方为负;
如果超过 60 个回合无吃子,则判和棋;
以下是详细的中国象棋游戏规范
棋盘
棋子活动的场所,叫作“棋盘”。在方形的平面上,由九条平行的竖线和十条平行的横线相交组成,共有九十个交叉点,棋子就摆在交叉点上。中间部分,也就是棋盘的第五,第六两横线之间未画竖线的空白地带称为“河界”。两端的中间,也就是两端第四条到第六条竖线之间的正方形部位,以斜交叉线构成“米”字方格的地方,叫作“九宫”(它恰好有九个交叉点)。整个棋盘以“河界”分为相等的两部分。为了比赛记录和学习棋谱方便起见,现行规则规定:按九条竖线从右至左用中文数字一至九来表示红方的每条竖线,用阿拉伯数字“1”~“9”来表示黑方的每条竖线。对弈开始之前,红黑双方应该把棋子摆放在规定的位置。任何棋子每走一步,进就写“进”,退就写“退”,如果像车一样横着走,就写“平”。
直线
棋盘上较长的平行排列的边称为直线,共有9条,其中7条被河界隔断。用红色棋子一方的直线从右往左依次用中文数字一、二、三、四、五、六、七、八、九表示;用黑色棋于一方的直线在红方对面也从右往左依次用阿拉伯数字1、2、3、4、5、6、7、8、9表示。
横线
棋盘上较短的平行排列的边称为横线。横线共有10条,用红色棋子一方的横线从红方底线算起,从下往上依次用中文数字一至十表示;用黑色棋子一方的横线则从黑方底线开始,依次用阿拉伯数字1-10表示。
交叉点
直线与横线相交的地方称为“交叉点”。整个棋盘共有90个交叉点,棋子就摆放和活动在这些交叉点上。
河界
棋盘中央没有画直线的地方称为“河界”。它代表弃战双方的分界线,确定了各自的地域。
九宫
棋盘两端各画有斜交叉线的地方称为“九宫”。将则只能在各自“九宫”的9个交叉点上活动。
“楚汉界河”指的是河南省荥阳市黄河南岸广武山上的鸿沟。沟口宽约800米,深达200米,是古代的一处军事要地。西汉初年楚汉相争时,汉高祖刘邦和西楚霸王项羽仅在荥阳一带就爆发了“大战七十,小战四十”,因种种原因项羽“乃与汉约,中分天下,割鸿沟以西为汉,以东为楚”,鸿沟便成了楚汉的边界。如今鸿沟两边还有当年两军对垒的城址,东边是霸王城,西边是汉王城。现汉霸王城面临坍塌的危险,有关部门正在保卫我国的古战场。
棋子
棋子共有三十二个,分为红、黑两组,每组共十六个,各分七种,其名称和数目如下:
红棋子:帅一个,车、马、炮、相、仕各两个,兵五个。
黑棋子:将一个,车、马、炮、象、士各两个,卒五个。
帅/将
红方为“帅”,黑方为“将”。帅和将是棋中的首脑,是双方竭力争夺的目标。
它只能在"九宫"之内活动,可上可下,可左可右,每次走动只能按竖线或横线走动一格。帅与将不能在同一直线上直接对面,否则走方判负。
仕/士
红方为“仕”,黑方为“士”。它也只能在九宫内走动。它的行棋路径只能是九宫内的斜线。士一次只能走一个斜格。
象/相
红方为“相”,黑方为“象”。它的走法是每次循对角线走两格,俗称“象飞田”。相(象)的活动范围限于“河界”以内的本方阵地,不能过河,且如果它走的“田”字中央有一个棋子,就不能走,俗称“塞象眼”。
车(jū )
车在象棋中威力最大,无论横线、竖线均可行走,只要无子阻拦,步数不受限制。俗称“车行直路”。因此,一车最多可以控制十七个点,故有“一车十子寒”之称。
炮
炮在不吃子的时候,走动与车完全相同,但炮在吃子时,必须跳过一个棋子,我方的和敌方的都可以,俗称“炮打隔子”、“翻山”。
马
马走动的方法是一直一斜,即先横着或直着走一格,然后再斜着走一个对角线,俗称“马走日”。马一次可走的选择点可以达到四周的八个点,故有“八面威风”之说。如果在要去的方向有别的棋子挡住,马就无法走过去,俗称“蹩马腿”。
兵/卒
红方为“兵”,黑方为“卒”。
兵(卒)只能向前走,不能后退,在未过河前,不能横走。过河以后还可左、右移动,但也只能一次一步,即使这样,兵(卒)的威力也大大增强,故有“小卒过河顶大车”之说。
行棋规则
中国象棋是由两人轮流走子,在战法上遵循古代孙子兵法中的“不战而屈人之兵,善之善者也”的作战思想,以“将死”或“困毙”对方将(帅)为胜的一种二人对抗性游戏。对局时,由执红棋的一方先走,双方轮流各走一招,直至分出胜、负、和,对局即终了。在棋战中,人们可以从攻与防、虚与实、整体与局部等复杂关系的变化中提升思维能力。
- 对局开始前,双方棋子在棋盘上的摆法见下图:
- 对局时,由执红棋的一方先走,双方轮流走一步。
- 轮到走棋的一方,将某个棋子从一个交叉点走到另一个交叉点,或者吃掉对方的棋子而占领其交叉点,都算走了一着。
- 双方各走一着,称为一个回合。
- 走一着棋时,如果己方棋子能够走到的位置有对方棋子存在,就可以把对方棋子吃掉而占领那个位置。
- 一方的棋子攻击对方的帅(将),并在下一着要把它吃掉,称为“照将”,或简称“将”。“照将”不必声明。被“照将”的一方必须立即“应将”,即用自己的着法去化解被“将”的状态。如果被“照将”而无法“应将”,就算被“将死”。
棋例总纲
棋例释义
- 对局中有时出现双方着法循环不变的重复局面。据以裁处这种局面的规则条例,称为《棋例》。
- 长将、长杀、长捉、一将一杀、一将一捉、一杀一捉等循环重复的攻击手段,统称为“禁止着法”。
- 闲着(含:兑、献、拦、跟)、数将一闲、数杀一闲、数捉一闲等着法,无论是否重复,统称为“允许着法”。
- 车、马、炮、过河兵(卒)、士、相(象),均算“子力”。帅(将)、未过河兵(卒),不算“子力”。“子力”简称“子”。
- 子力价值是衡量子力得失的尺度,也是判断是否“捉子”的依据之一。原则上,一车相当于双马、双炮或一马一炮;马炮相等;士相(象)相等;过河兵(卒)价值浮动,一兵换取数子或一子换取数兵均不算得子。
棋例总则
- 在任何情况下,均不允许单方面长将。
- 双方均为允许着法,双方不变作和。
- 双方均为禁止着法(不包括一方为长将),双方不变作和。
- 一方为禁止着法,另一方为允许着法,应由前者变着,不变判负。
棋例通则
- 允许帅(将)本身步步叫吃对方的棋子,按闲着处理。
- 其他棋子和帅(将)同时捉吃或借帅(将)之力捉吃对方的棋子,均按捉处理。
- 兵(卒)本身捉吃对方的棋子(不含将杀),按闲处理;如形成相互长打局面,则应按捉处理。
- 占据防守要点,立即构成简明和棋,附带产生的捉士相(象),按闲处理。
- 凡走子兼具多种作用时,应从重称呼和裁处。如杀兼捉,按杀;捉兼况,按捉等,依此类推。
棋例细则
- 在任何情况下,均不许可单方面长将。
- 凡是走子前不存在捉,而走子后造成捉的(即从没捉到捉),一律按捉处理。
- 凡走子后,预计下一着能在子力价值上构成得子者,均按捉处理。
- 凡用作为根的子捉吃对方的子,也按捉处理。
- 凡捉子兼具兑献。送吃,也按捉处理。
- 凡是原来已经捉着子的子或相关子,走动后无新的捉子行为(即从捉到捉)均捉闲着处理。
- 凡形式上捉子,一旦吃子立即会被对方将死者,均闲着处理。
- 双方均为禁止着法(不包括单方面长将),双方不变作和。
- 其他棋子和帅(将)同时捉吃对方的棋子,均按捉处理。
- 过河兵(卒)子力价值浮动。
- 发生互打时,兵(卒)叫吃子均按捉处理。
- 凡走子兼具多种作用,从重定性。
- 占据防守要点,立即形成简明和棋,附带产生的捉士、相(象),按闲处理。 [16]
胜负规则
胜负
对局中,出现下列情况之一,本方算输,对方赢:
- 己方的帅(将)被对方棋子将死或吃掉;
- 己方无子可走(被困毙);
- 己方对对方“长将”或“长捉”;
- 己方发出认输请求;
- 有步时要求的,己方走棋超出步时限制;
- 有时间要求的,超过规定时间;
- 违反比赛规则。
和棋
出现下列情况之一,为和棋:
- 双方均无可能取胜的简单局势;
- 一方提议作和,另一方表示同意;
- 双方走棋出现循环反复三次,符合《棋例》中“不变作和”的有关规定;
- 符合自然限着的回合规定,即在连续60个回合中(也可根据比赛等级酌减),双方都没有吃过一个棋子。
记谱方法
方法
中国象棋的记谱方法一般由四个字组成:
- 第 1 字是棋子的名称。如“马”或“车”。
- 第 2 字是棋子所在纵线的数码。
- 第 3 字表示棋子移动的方向:横走用“平”,向前走用“进”或“上”,向后走用“退”或“下”。
- 第 4 字是车、炮等前后进退的格数,或者马、象等斜走到达纵线的数码。
举例
“炮二平五”,表示红炮从纵线二平移到纵线五。
“马8进7”,表示黑马从纵线8向前走到纵线7。
“车2退3”,表示黑车沿纵线2向后移动3格。
当一方有2个以上名称相同的棋子位于同一纵线时,需要用“前”或“后”来加以区别。例如,“前马退六”(表示前面的红马退到直线六)、“后炮平4”(表示后面的黑炮平移到直线4)。
兵卒在特殊局面下的记谱方法(主要创作排局时遇到的多,实战也可遇到):
当兵卒在同一纵线达到3个,用前、中、后来区分,达到4个,用前、二、三、四区分,达到5个,用前、二、三、四、五区分。
当兵卒在两个纵线都达到两个以上时,按照旧的记谱方式举例:前兵九平八,此时可省略数字九,记做前兵平八,以达到都用4个汉字记谱的要求,是对中文记谱方法的一个重要完善。
象棋术语
非着法类
- 河界线
棋盘 [18] 中间未画直线的空白地带称为“河界”,构成河界的两条横线即为“河界线”。
- 中线
棋盘中第五条直线。是有关将、帅安危的生命线,亦为双方必争的战略要点。对局时,须力争控制该线,使之不被对方棋子侵入或牵制。
- 肋道
也称“两肋线”,名词。棋盘中的第四、第六条直线。因在中线将位左右,形似人体的两肋,故名。均为对局双方攻守的要道线,控制肋道,应以对方上士的动向为进攻目标。
- 兵线
也称“卒林线”、“兵行线”。名词。棋盘中靠近河界线的第一条横线,供双方置兵、卒。兵、卒有巩固阵地、绊马阻子的作用,已方棋子如能占据对方的兵线,就能予对方以压力和威胁。
- 九宫
也称“九宫禁区”。名词,指棋盘上由斜交叉线构成的“ 米”字形方格。是将(帅)、士活动的地区。如被对方车、兵占据,就易发生危险。
- 局面
指对局中某一阶段双方棋子颁的状态,通常包括“ 先手”、“优势”、“平稳”、“对攻”、“复杂”、“简化” 、“均势”等。
- 对局评注
在报刊或棋谱上,对比赛中一些著名棋手的实战对局,用复盘形式加以评述,分析双方战略战术中的优劣得失,与各种攻防着法的变化,以供爱好者欣赏、借鉴、参考,从中汲取有益的经验和技巧。
- 复盘
也称“复局”。术语。指对局完毕后,复演该盘棋的记录,以检查对局中着法的优劣与得失关键。一般用以自学,或请高手给予指导分析。如按照棋排演,类如复盘,称“打谱”或 “研阅棋谱”。
- 棋谱
用图和文字记述棋局的书籍。按所记述的棋局排演,可参考吸收其着法,或探讨研究名手的棋艺风格。
- 先手
有二义:1、也称“先走”。开局时的先走者。2、也称“得先”。棋局形势中的主动者。
- 后手
有二义:1、开局时后者。2、也称“失先”、“落后”。棋局形势中的被动者。
- 均势
也称“并先”。术语。指对局中双方局势均衡,兵力相等。
- 着
对局中轮到走棋的一方,把某个棋从一个交叉点走到另一个交叉点,或吃掉对方的棋子而占领其交叉点,即为走了一着。
- 回合
对局中,双方各走一着,称为一个回合。
- 闲着
也称“停着”,一种适宜于对局相持阶段的着法。走子不起进攻作用,目的在于等待时机。
- 强子
指车、马、炮等战斗力较强的各类子。兵、卒须视形势而定,一般以过河界的为强,在自界的为弱。
- 吃子
对局中,轮到走棋的一方,把某一棋子从棋盘的这一交叉点走到另一交叉点而吃掉对方棋子,并占领后一交叉点,称为“吃子”。
- 弃子
对局中,舍弃某一子,称为“弃子”。常作为一种战术。主动而有计划的弃子,可得先而占优势或攻主局。
- 胜势
对局中,局势大体已定,于胜利在望一方,称为“胜势”。
- 绝杀
指对局,下一着要将死,而对方又无法解救。
- 入局
一般指攻入对方阵地而能构成杀局的着法。常见于中局阶段,多数为“弃子入局”。
- 例胜
实用残局结尾时,攻方可以必胜守方,称为“例胜”。
- 例和
实用残局结尾时,守方对攻方可以弈成必和的棋势,称为“例和”。
- 巧胜
指实用残局结尾时,由于守方未能及时弈成例和的棋势,被攻方乘机取胜,称为“巧胜”。
- 巧和
实用残局结尾时,守方以巧着弈和攻方,称“巧和”。
着法类
- 两头蛇
指马二进三再马八进七后,再三兵与七兵齐挺一步后的形势。此时双马显得灵活,其状如“两头蛇”,故名。
- 仙人指路
一种开局着法,指第一步走兵三进一或兵七进一。因一子当先,意向莫测,有试探对方棋路的意图,故得名。
- 窝心马
也称“塞心马”、“入宫马”。指马走入己方的九宫花心,通常此着走后使将、士壅塞,易受到对方子力的牵制。
- 盘头马
也称“中炮连环夹马”,指开局先走一方架中炮挺进中兵(卒),双马则从中路连环策应。
- 鸳鸯马
指对弈中一方两马结成连环,互保互助,因而得名。又称“连环马”。
- 屏风马
一种开局着法,在双炮尚未开动时,一方双马并踞(炮二平五、马8进7,马二进三、马2进3),保护中卒(兵),状似屏风,故名。
- 单提马
布局术语,指开局阶段一方一马正起,一马屯边,多用于先手五七炮开局和后手起横车布局。
- 反宫马
布局术语,开局一方双马正起,士角有一炮相隔,故亦称“夹炮屏风”。
- 拐角马
布局术语,上象后马二进四从象田出动,跳到己方士前位置,再穿上角奔河头。
- 先锋马
先挺三兵或七兵,随即进马河口,如军队行军时的先头部队,故名。
- 当头炮
也称中炮,一种开局着法。起着把炮放在正中线位,是先走一方采取主动攻势的一种布局。又称炮二平五。
- 五六炮
是一种开局着法,先手一方走成中炮六路炮。多数用于对屏风马、反宫马,属于稳健缓攻型布局。
- 巡河炮
象棋中利用开局利用炮的一种形式。在开局时,一方的炮从原始位置直升至己方河线,然后利用挺兵作为像中路一侧的炮架。
- 过宫炮 一种开局着法,走炮二平六或炮八平四,因经过将(帅)的中宫而得名。
- 五七炮
一种开局着法,先手一方(后手)走成中炮七路炮。是缓攻型布局的典范。
- 卒底炮
一种开局着法,指后手方用炮2平3或炮8平7,对付对方兵七进一或兵三进一。
- 金钩炮
布局着法,和卒底炮类似,一方用另一架炮制约对方的兵或相。
- 担子炮
指双炮在一条直线上中间隔一子,两炮互相联络、保护,其状如扁担,故名。
- 天地炮
象棋专业术语,一炮镇中路,一炮压底线,使对方的士象将五子都受牵制。
- 横车
一方走“车一进一(车1进1)”或“车九进一(车9进1)”,以后横向出动,称为“横车”。
- 直车
一方走“车一平二”或“车九平八”,以后从二路或八路两直线出动,称为“直车”。
- 肋车
指把车开到四路(4路)和六路(6路)。因其紧贴将门要道,酷似人的双肋而得名。
- 骑河车
一方的车进到对方河口,称为“骑河车”。可限制对方兵、马等各子的活动,禁子有力。
- 三步虎
开盘伊始一方走上马、平炮、亮车,三步开出主力车。因状如虎爪揪地,又棋谚有云“车强如虎”,故称之为“三步虎”。
3.2、象棋着法枚举和有效性判断
一个走法包括起点和终点,分别用from和to表示一维棋局数组中的起点和终点。很容易想到,定义一个对象Move,并使用两个变量from和to表示一个走法,同时记录起点和终点的坐标位置。Move对象的定义如下:
public class Move
{
public int from;
public int fromX;
public int fromY;
public int to;
public int toX;
public int toY;
}
棋子的着法判断思路和代码如下:
将帅:将的走法有四个方向,如图所示:
将(帅)的走法需要满足下面这两个条件:(1)终点位于九宫;(2)走法只能在九宫内按上图标识的四个方向。
可以借助一个辅助数组来检测将帅的活动范围,定义如下:int[][] array = new int[][] { { -1, 0 }, { 0, -1 }, { 1, 0 }, { 0, 1 } };
这样在遍历棋盘的时候,只需要根据当前点击的位置和辅助数组,就能找出其走法列表,完整代码如下:
public static List<Move> enumKingMoves(Position position, int y, int x, int from)
{
Side side = position.getSide();
Piece[] posArray = position.getPosArray();
List<Move> moves = new ArrayList<>();
// 上下左右
int[][] array = new int[][] { { -1, 0 }, { 0, -1 }, { 1, 0 }, { 0, 1 } };
int[] range = (side == Side.Red ? ChessConstant.RED_KING_RANGE : ChessConstant.BLACK_KING_RANGE);
for (int[] offset : array)
{
int newX = x + offset[0];
int newY = y + offset[1];
int to = newY * 9 + newX;
if (!ChessRules.posOnBoard(to) || posArray[to].getSide() == side)
{
continue;
}
if (Arrays.binarySearch(range, to) > -1)
{
moves.add(new Move(from, to));
}
}
return moves;
}
士(仕):走法与将帅类似,也是4个方向,如下图所示:
士的走法需要满足下面这两个条件:(1)终点位于九宫;(2)只能在九宫内沿着斜线走,也就是上图标示的位置。
这里同样也是用一个辅助的二维数组来检测其行棋方向:int[][] offsetList = { { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } }
完整代码如下:
public static List<Move> enumAdvisorMoves(Position position, int y, int x, int from)
{
Side side = position.getSide();
Piece[] posArray = position.getPosArray();
List<Move> moves = new ArrayList<>();
int[][] offsetList = { { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } };
int[] range = (side == Side.Red ? ChessConstant.ADVISOR_RED_RANGE : ChessConstant.ADVISOR_BLACK_RANGE);
for (int i = 0; i < 4; i++)
{
int[] offset = offsetList[i];
int newX = x + offset[0];
int newY = y + offset[1];
int to = newY * 9 + newX;
if (!ChessRules.posOnBoard(to) || posArray[to].getSide() == side)
{
continue;
}
if (Arrays.binarySearch(range, to) > -1)
{
moves.add(new Move(from, to));
}
}
return moves;
}
相(象):象的四个走法如下图所示,黑三角是相应的象眼位置
象的走法需要满足下面三个条件:
(1)象不能过河
(2)终点位置只能是图中的4个位置
(3)象眼无棋子,也就是图中黑色三角位置无棋子
这里使用两个辅助数组来检测其终点位置和象眼位置,定义如下:
// 中心位置:
int[][] heartOffsetList = { { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } };
// 活动范围: 左下,左上,右上,右下
int[][] offsetList = new int[][] { { -2, -2 }, { 2, -2 }, { -2, 2 }, { 2, 2 } };
完整的代码如下:
public static List<Move> enumBishopMoves(Position position, int y, int x, int from)
{
Side side = position.getSide();
Piece[] posArray = position.getPosArray();
// 中心位置:
int[][] heartOffsetList = { { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } };
// 活动范围: 左下,左上,右上,右下
int[][] offsetList = new int[][] { { -2, -2 }, { 2, -2 }, { -2, 2 }, { 2, 2 } };
// 红方相和黑方象的活动范
int[] range = (side == Side.Red ? ChessConstant.BISHOP_RED_RANGE : ChessConstant.BISHOP_BLACK_RANGE);
List<Move> moves = new ArrayList<>();
for (int i = 0; i < 4; i++)
{
// 中心位置坐标
int[] heartOffset = heartOffsetList[i];
// 中心位置
int heart = (y + heartOffset[0]) * 9 + (x + heartOffset[1]);
// 象眼被堵塞
if (!ChessRules.posOnBoard(heart) || !posArray[heart].isEmpty())
{
continue;
}
final int[] offset = offsetList[i];
final int to = (y + offset[0]) * 9 + (x + offset[1]);
if (!ChessRules.posOnBoard(to) || posArray[to].getSide() == side)
{
continue;
}
if (Arrays.binarySearch(range, to) > -1)
{
moves.add(new Move(from, to));
}
}
return moves;
}
马:马的8个走法如下图所示,黑三角是相应的马脚位置:
马的走法需要满足下面两个条件:
(1)、起点与终点的x和y坐标之差的绝对值在1和2之间。
(2)、对应马脚的位置没有棋子。
这里用两个辅助数组来判断马的终点位置是否合法以及是否会被别马腿;
// 终点位置
int[][] offsetList = new int[][] { { -1, 2 }, { -2, 1 }, { -2, -1 }, { -1, -2 }, { 1, -2 }, { 2, -1 }, { 2, 1 }, { 1, 2 } };
// 辅助判断是否被别马腿,注意此处需要重复枚举别马腿的位置
int[][] footOffsetList = new int[][] { { 0, 1 }, { -1, 0 }, { -1, 0 }, { 0, -1 }, { 0, -1 }, { 1, 0 }, { 1, 0 }, { 0, 1 } };
完整代码如下:
public static List<Move> enumKnightMoves(Position position, int y, int x, int from)
{
Side side = position.getSide();
Piece[] posArray = position.getPosArray();
List<Move> moves = new ArrayList<>();
// 终点位置
int[][] offsetList = new int[][] { { -1, 2 }, { -2, 1 }, { -2, -1 }, { -1, -2 }, { 1, -2 }, { 2, -1 }, { 2, 1 }, { 1, 2 } };
// 辅助判断是否被别马腿,注意此处需要重复枚举别马腿的位置
int[][] footOffsetList = new int[][] { { 0, 1 }, { -1, 0 }, { -1, 0 }, { 0, -1 }, { 0, -1 }, { 1, 0 }, { 1, 0 }, { 0, 1 } };
for (int i = 0; i < offsetList.length; i++)
{
int[] temp = offsetList[i];
int toX = x + temp[0];
int toY = y + temp[1];
if (toY < 0 || toY > 9 || toX < 0 || toX > 9)
{
continue;
}
int to = toY * 9 + toX;
if (!ChessRules.posOnBoard(to) || posArray[to].getSide() == side)
{
continue;
}
int[] tempFoot = footOffsetList[i];
int footToX = x + tempFoot[0];
int footToY = y + tempFoot[1];
int footTo = footToY * 9 + footToX;
if (!ChessRules.posOnBoard(footTo) || !posArray[footTo].isEmpty())
{
continue;
}
moves.add(new Move(from, to));
}
return moves;
}
校验车、炮的走法:车的走法如下图所示:
沿着四条直线走,每个方向可一直向前走,直到:
(1)、走出棋盘
(2)、碰到本方棋子
(3)、吃掉对方棋子
例如方向1,每走一步都是在起点基础上-9;方向2是在起点基础上-1;方向3是在起点基础上+1;方向4是在起点基础上+9。
炮与车的行棋规则类似,也可以沿一个方向一直向前走,不过遇到棋子时,要越过去(也就是翻山)。翻山后,炮只能吃对方棋子,不能落到空位置。
具体校验算法如下:
public static List<Move> enumRookMoves(Position position, int y, int x, int from)
{
Side side = position.getSide();
Piece[] posArray = position.getPosArray();
List<Move> moves = new ArrayList<>();
// 左边
for (int dx = x - 1; dx >= 0; dx--)
{
int to = y * 9 + dx;
Piece target = posArray[to];
if (target == Piece.noPiece)
{
moves.add(new Move(from, to));
}
else
{
if (posArray[to].getSide() != side)
{
moves.add(new Move(from, to));
}
break;
}
}
// 上边
for (int dy = y - 1; dy >= 0; dy--)
{
int to = dy * 9 + x;
Piece target = posArray[to];
if (target == Piece.noPiece)
{
moves.add(new Move(from, to));
}
else
{
if (posArray[to].getSide() != side)
{
moves.add(new Move(from, to));
}
break;
}
}
// 往右
for (int dx = x + 1; dx < 9; dx++)
{
int to = y * 9 + dx;
Piece target = posArray[to];
if (target == Piece.noPiece)
{
moves.add(new Move(from, to));
}
else
{
if (posArray[to].getSide() != side)
{
moves.add(new Move(from, to));
}
break;
}
}
// 往下
for (int dy = y + 1; dy < 10; dy++)
{
int to = dy * 9 + x;
Piece target = posArray[to];
if (target == Piece.noPiece)
{
moves.add(new Move(from, to));
}
else
{
if (posArray[to].getSide() != side)
{
moves.add(new Move(from, to));
}
break;
}
}
return moves;
}
public static List<Move> enumCanonMoves(Position position, int y, int x, int from)
{
Side side = position.getSide();
Piece[] posArray = position.getPosArray();
List<Move> moves = new ArrayList<>();
// 往左
boolean overPiece = false;
for (int dx = x - 1; dx >= 0; dx--)
{
int to = y * 9 + dx;
Piece target = posArray[to];
if (!overPiece)
{
if (target == Piece.noPiece)
{
// System.out.println("【炮】enumCanonMoves, side=" + side + ", from=" + from + ", to=" + to);
moves.add(new Move(from, to));
}
else
{
overPiece = true;
}
}
else
{
if (target != Piece.noPiece)
{
if (posArray[to].getSide() != side)
{
// System.out.println("【炮】enumCanonMoves, side=" + side + ", from=" + from + ", to=" + to);
moves.add(new Move(from, to));
}
break;
}
}
}
// 往上
overPiece = false;
for (int dy = y - 1; dy >= 0; dy--)
{
int to = dy * 9 + x;
Piece target = posArray[to];
if (!overPiece)
{
if (target == Piece.noPiece)
{
// System.out.println("【炮】enumCanonMoves, side=" + side + ", from=" + from + ", to=" + to);
moves.add(new Move(from, to));
}
else
{
overPiece = true;
}
}
else
{
if (target != Piece.noPiece)
{
if (posArray[to].getSide() != side)
{
// System.out.println("【炮】enumCanonMoves, side=" + side + ", from=" + from + ", to=" + to);
moves.add(new Move(from, to));
}
break;
}
}
}
// 往右
overPiece = false;
for (int dx = x + 1; dx < 9; dx++)
{
int to = y * 9 + dx;
Piece target = posArray[to];
if (!overPiece)
{
if (target == Piece.noPiece)
{
// System.out.println("【炮】enumCanonMoves, side=" + side + ", from=" + from + ", to=" + to);
moves.add(new Move(from, to));
}
else
{
overPiece = true;
}
}
else
{
if (target != Piece.noPiece)
{
if (posArray[to].getSide() != side)
{
// System.out.println("【炮】enumCanonMoves, side=" + side + ", from=" + from + ", to=" + to);
moves.add(new Move(from, to));
}
break;
}
}
}
// 往下
overPiece = false;
for (int dy = y + 1; dy < 10; dy++)
{
int to = dy * 9 + x;
Piece target = posArray[to];
if (!overPiece)
{
if (target == Piece.noPiece)
{
// System.out.println("【炮】enumCanonMoves, side=" + side + ", from=" + from + ", to=" + to);
moves.add(new Move(from, to));
}
else
{
overPiece = true;
}
}
else
{
if (target != Piece.noPiece)
{
if (posArray[to].getSide() != side)
{
// System.out.println("【炮】enumCanonMoves, side=" + side + ", from=" + from + ", to=" + to);
moves.add(new Move(from, to));
}
break;
}
}
}
// System.out.println("【炮】enumCanonMoves, side=" + side + ", y=" + y + ", x=" + x +", from="+ from + ", moves=" + moves);
return moves;
}
兵(卒):红兵的走法如下图所示:
过河前,只能向前走。过河后,可以左右走。
(1)、判断是否过河
以红方为例,红方是向上走。如果红方过河,则会走到棋盘0到44的位置;对于红兵,过河之后,y的取值范围为0-4;对于黑卒,过河之后,y的取值范围为5-9,因此根据比较当前位置与y坐标的位置就能知道是否过河。
(2)、判断兵(卒)是否是向前走了一步
完整校验代码如下:
public static List<Move> enumPawnMoves(Position position, int y, int x, int from)
{
Side side = position.getSide();
Piece[] posArray = position.getPosArray();
List<Move> moves = new ArrayList<>();
int dy = (side.isRed() ? -1 : 1);
// 过河之前
int to = (y + dy) * 9 + x;
if (ChessRules.posOnBoard(to) && posArray[to].getSide() != side)
{
moves.add(new Move(from, to));
}
// 过河之后
if ((side == Side.Red && y < 5) || (side == Side.Black && y > 4))
{
if (x > 0)
{
to = y * 9 + x - 1;
if (ChessRules.posOnBoard(to) && posArray[to].getSide() != side)
{
moves.add(new Move(from, to));
}
}
if (x < 8)
{
to = y * 9 + x + 1;
if (ChessRules.posOnBoard(to) && posArray[to].getSide() != side)
{
moves.add(new Move(from, to));
}
}
}
return moves;
}
3.3、长捉和胜负检测
中国象棋游戏的胜负判断是非常复杂的,但是人机对战的胜负判断要比真实比赛的裁判简单很多。在人机对战游戏中,我们要实现的逻辑是有限的:
如果 A 方行棋后,无论对方走什么棋,下一步都能吃掉对方的老将,则可判断 A 方获胜
如果 A 方行棋后,对方无棋可走,则可判断 A 方获胜
如果 A 方连续将军,造成三个连续的重复局面,则判断 A 方败阵
无吃子超过 60 个回合,判和棋
其它捉、闲等情况,我们不在项目中实现。
完整的方法,见scanLongCatch方法
3.3、常用象棋棋谱文件
常用的棋谱文件格式有以下几种:
PGN:文本格式、基于FEN字符串表示,可读性好,体积略大。此格式最早用于国际象棋格式。
XQF:软件“象棋演播室”提出的一种加密的象棋格式,支持较多功能,XQF是二进制文件。象棋演播室作者“过河象”董世伟。象棋演播室是研究象棋很好用的一款软件。
CBL、CBR:象棋桥采用的一种格式。象棋桥实现了中国象棋通用引擎协议(Universal Chinese Chess Protocol,简称UCCI),支持多种引擎,功能比“象棋演播室”更丰富,界面也更加美观,并且自带大量棋谱。
MXQ:弈天棋谱
CHE:QQ象棋棋谱
pgn棋谱的文件格式解析具体见这篇文章:《中国象棋电脑应用规范》
4、奕心象棋整体架构解析
4.1 、棋子设计
棋子用一个一维对象数组表示,大小为16。棋子数组是一个一维数组,其下标为棋子的ID,下标中的数组元素存放的是棋子的Java对象,此对象中保存了该棋子在棋盘的位置,不同的对象继承自同一个基类,通过多态来实现不同的走法枚举和走法合法性判断。
(1)黑方:ID号大的棋子共16个棋子,摆放在棋盘界面的上方(表示计算机的棋子)。
(2)红方:ID号小的棋子共16个棋子,摆放在棋盘界面的下方(表示用户的棋子)。
棋子数组:棋子数组中的棋子对象使用ID号作为下标索取,黑方棋子16-31
因为我们都是用棋子的下标来索取棋子,所以上面的一维数组我们永远都只会遍历下标在16~31和32~47的那些元素。数组的其他元素都被设置成了空指针。
4.2、 棋盘设计
棋盘用一个大小为90的一维数组表示,其下标与棋盘的交叉点一一对应,顺序为从上到下,从左到右。在对某个走法进行合法性判断和查找某个棋子的所有合理走法的时候,都需要遍历这个数组。
4.3、 整体设计
该项目的整体UML类图如下所示:
4.4、位置表示
每一个棋子都有以下几个属性:x坐标,y坐标和在棋盘数组中的序号,其中序号和坐标的转换关系为:
根据坐标判断当前数组下标:pos=(y * 9) + x
根据位置判断坐标,点击位置为pos,则x = pos % 9; y = pos / 9;
4.5、走法表示
走法使用Move类表示,每一个走法都包含六个变量,分别是起点位置序号,起点位置x坐标,起点位置y坐标和终点位置序号,终点位置x坐标和y坐标。
4.6、棋谱记录
有两个地方需要记录棋步,也就是红方走棋和获取到黑方走棋之后,这里我们用一个列表记录棋局的走法,并且按照界面上选择的棋谱格式,再生成棋谱的时候,首先写入一些头信息,然后遍历记录的棋步,再翻译为pgn格式,再写入本次棋局的结果,然后以文本文件的方式写入到磁盘即可。
4.7、UCCI引擎文件的调用
Java调用UCCI引擎的方式有两种,
第一种是通过JNI本地方法调用的方式直接调用C/c++代码,并得到返回结果,需要将C/C++代码封装为函数;
第二种是通过管道流的方式,读取exe程序在命令行的输出,这里使用第二章方法。
当要执行一个本地机器上的可执行文件时,可以使用java.lang包中的Runtime类,首先使用Runtime类,首先使用Runtime类声明一个对象
{
Runtime sc =Runtime.getRuntime();
sc可以调用exec(String command) 方法打开本地的可执行文件或执行一个操作。
}
这里给出一个简单的示例:
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
public class transferExe
{
public static void main(String[] args)
{
//需要传入的参数(多个参数用空格隔开)
String paras = " --task C:\\Users\\Admin\\.cesiumlab\\tasks\\terrain_99254c80b1ef11ea8edfe728504fc83a.json --taskserver tcp://127.0.0.1:9001 --taskname 99254c80b1ef11ea8edfe728504fc83a --log_dir C:\\Users\\Admin\\.cesiumlab\\logs";
//调用的exe可执行文件(调用可执行文件和参数拼接必须要用空格隔开)
String cmd = "C:\\Program Files\\cesiumlab2\\tools\\terrainbuilder.exe" + paras;
openExe(cmd);
}
public static void openExe(String cmd)
{
BufferedReader br = null;
BufferedReader brError = null;
try
{
//执行exe cmd可以为字符串(exe存放路径)也可为数组,调用exe时需要传入参数时,可以传数组调用(参数有顺序要求)
Process p = Runtime.getRuntime().exec(cmd);
String line = null;
//获得子进程的输入流。
br = new BufferedReader(new InputStreamReader(p.getInputStream()));
//获得子进程的错误流。
brError = new BufferedReader(new InputStreamReader(p.getErrorStream()));
while ((line = br.readLine()) != null || (line = brError.readLine()) != null)
{
//输出exe输出的信息以及错误信息
System.out.println(line);
}
}
catch (Exception e)
{
e.printStackTrace();
}
finally
{
if (br != null)
{
try
{
br.close();
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
}
}
我们在此基础之后,需要做几点改进:首先引擎文件的地址需要改成读取配置文件的方式,其次是需要封装执行命令和读取引擎返回的招数信息,最后是需要在恰当的地方初始化和关闭引擎。