现在开始来开发四国军棋的引擎,所谓引擎就是根据当前的局面给出最佳的下法,而界面只是一个显示的功能。目前由玩家控制自家和对家的棋,由引擎控制上家和下家的棋。
1.通信
和界面类似,socke通信放在一个单独的线程里,但是只接收数据不处理数据,接收到的数据通过消息队列发送给引擎模块处理:
while(1)
{
recvbytes=recvfrom(socket_fd, buf, 200, 0,NULL ,NULL);
mq_send(pJunqi->qid, (char*)buf, recvbytes, 0);
}
引擎也是一个单独的线程,一直等待通信线程传来的数据。
while (1)
{
len = mq_receive(pJunqi->qid, (char *)aBuf, REC_LEN, NULL);
if ( len > 0)
{
ProRecMsg(pJunqi, aBuf);
}
}
当界面先于引擎启动的时候,引擎会主动发送COMM_READY命令来请求初始化。
2.基本框架
当界面发送初始化命令过来时,引擎会初始化一些棋盘上固定的东西,如邻接表、铁路、九宫格等,这些只在引擎第一次启动时初始化,此后不再改变,而棋子和布阵每次新开局时都会重新初始化。
static int isInitBoard = 0;
... ...
case COMM_INIT:
InitLineup(pJunqi, data, isInitBoard);
InitChess(pJunqi, data);
if( !isInitBoard )
{
isInitBoard = 1;
InitBoard(pJunqi);
}
SendHeader(pJunqi, pHead->iDir, COMM_OK);
break;
在接收到COMM_START命令后引擎开始下棋,轮到自己下时发送行棋命令给界面,界面会把判定的结果重新发回给引擎。每收到一次界面的判定结果,更换下棋的方位。引擎虽然不能显示,但是自身维护着各棋子的状态,每收到一次行棋结果或事件的命令都会改变棋子的相应状态。
case COMM_EVNET:
event = *((u8*)&pHead[1]);
ProMoveEvent(pJunqi, pHead->iDir, event);
SendHeader(pJunqi, pHead->iDir, COMM_OK);
break;
case COMM_MOVE:
assert( pHead->iDir==pJunqi->eTurn );
data = (u8*)&pHead[1];
ProMoveResult(pJunqi, pHead->iDir, data);
SendHeader(pJunqi, pHead->iDir, COMM_OK);
break;
void ProMoveResult(Junqi* pJunqi, u8 iDir, u8 *data)
{
... ...
PlayResult(pJunqi, pSrc, pDst, pResult);
ChessTurn(pJunqi);
}
3.决策模块
这个是引擎的核心,也就是根据当前的局面,找出获胜概率最大的下法。由于现在刚开始开发,先从最开始的随机决策做起,慢慢的会增加一些其他提高胜率的算法。引擎控制上家和下家,轮到引擎时发送行棋命令。
if( !pJunqi->bStart || pJunqi->bStop )
{
return;
}
if( pJunqi->eTurn%2==1 )
{
sleep(1);
if( pJunqi->aInfo[pJunqi->eTurn].bDead )
{
ChessTurn(pJunqi);
}
SendRandMove(pJunqi);
}
轮到某一家下棋时,引擎会遍历这一家所有活着的棋子,随机选中一颗,然后再查看这颗棋子能否行棋,如果不能行棋再换下一颗棋子,无棋可走时发送跳过,此时界面已经判负了,将会发送投降的命令过来。
rand = random_()%30;
for(i=0; i<30; i++)
{
pLineup = &pJunqi->Lineup[pJunqi->eTurn][(rand+i)%30];
if( pLineup->bDead )
{
continue;
}
pSrc = pLineup->pChess;
if(pLineup->type!=NONE && pLineup->type!=JUNQI && pLineup->type!=DILEI )
{
pDst = GetMoveDst(pJunqi, pSrc);
if( pDst!=NULL )
{
SendMove(pJunqi, pSrc, pDst);
return;
}
}
}
SendEvent(pJunqi, pJunqi->eTurn, JUMP_EVENT);
下面是和我下棋的截图,当然现在很弱智,随便乱送都能赢
4.源代码
https://github.com/pfysw/JunQi
一个多线程引发的问题
接下来说明一下解决的一个非常棘手的程序崩溃问题,曾一度调的都要放弃。在软件和界面联调时,经常出现莫名奇妙的界面显示异常,也经常出现GTK内部函数的断言错误,还时不时的出现内存引起的程序崩溃而退出。
这个问题不是必现,出现的概率是很高,但是又琢磨不透。自己手工测试很多次,加上打印信息,基本上可以确定是在通信线程执行行棋(即PlayResult)时出现的,但是没发现什么规律,也想不通为什么会这样。
为了增加单位时间内的测试次数,修改代码让引擎自己和自己下,这样几秒就能下一盘棋,测试时间变短了很多。为了确定不是自己代码逻辑的问题,在PlayResult里把界面相关的东西都屏蔽掉,此时概率减小了很多,但是还是会出现崩溃,但是有一个重大的发现,崩溃基本都是出现在最后一方棋被吃光无棋可走5次跳过的时候,此时与界面相关的只有DestroyAllChess()函数,这个函数销毁战败方的棋子。
在下面代码处加入打印信息,查看到底是哪颗棋子的问题
for(i=0; i<30; i++)
{
if( pJunqi->Lineup[iDir][i].type!=NONE )
{
for(j=1; j<4; j++)
{
printf("desroy %d %d",i,j);
gtk_widget_destroy(pJunqi->Lineup[iDir][i].pImage[j]);
}
}
}
发现竟然不能复现了,此时很容易联想到是gtk_widget_destroy执行太快的问题,所以在后面加上1ms的延时函数Sleep(1),此时貌似问题解决了,但是经过10多次测试,还是有10分之一的概率会出现崩溃。为了确认的确是这个函数的问题,而不是其他问题,让引擎只发送跳过指令,测试结果是加了延时崩溃概率变小,不加延时立即崩溃,此时可以确认是这里出现的问题。
但是我们发现再新建菜单的回调函数里也有DestroyAllChess(),无论测试多少次都不会出现崩溃。
void ReSetChessBoard(Junqi *pJunqi)
{
for(int i=0; i<4; i++)
{
if( !pJunqi->aInfo[i].bDead )
{
DestroyAllChess(pJunqi, i);
}
SetChess(pJunqi,i);
}
......
}
由于SetChess(pJunqi,i);会重新画上棋子,为了保持相同的测试情境,把SetChess(pJunqi,i)去掉,这时点”新建”时,棋子会消失,但是略微有延迟感,也就是说gtk_widget_destroy执行后不是立即产生效果,而是在之后才产生效果,在后面加Sleep(2000)延时2s,这证实了我的猜想。但是为什么在通信线程里执行DestroyAllChess()也延时2s,棋子也还是立即消失呢?仔细想一下就知道了,gtk_widget_destroy和真正执行销毁控件的函数其实是在一个线程里,而其他线程执行gtk_widget_destroy后延时只是在自己的线程里延时,并不阻塞控件的销毁。
有了以上分析,解决的方案是怎么把界面有关的东西都放在主线程里执行,而不是在另外的线程里执行。一开始是做一个button按钮,绑定回调函数,然后通过g_signal_emit_by_name发送按钮消息,但是仍然没效果。怀疑是button的优先级太高导致的问题,改为做一个名为”通信“的空菜单绑定回调函数,同样没有效果。然后我把这个菜单显示到通信栏里用鼠标点击就没问题,仔细想一下难道g_signal_emit_by_name并不是向主线程发送消息吗?经过设断点调试发现竟然还在通信线程里,也就是说g_signal_emit_by_name仅仅相当于一个函数调用,而不是模拟鼠标发送事件。
上面方案行不通我又想到了主线程里有一个idle函数,通过g_idle_add() 来添加,通过断点调试确认添加的idle的确是在主线程里。如果idle函数返回0则只执行一次,返回1在空闲时反复执行。现在终于得到了最终的解决方案,把通信处理函数DealRecData()放在idle里,并且返回0,每次收到数据时就调用g_idle_add(),由于通信数据在2个线程里,所以要加上互斥锁对数据进行保护,这样就解决了这个程序崩溃的问题。