☰
用C语言实现控制台中国象棋:从棋盘数据结构到AI博弈搜索
2026/10/15 18:07:07 网站建设 项目流程

1. 开局前的问题:为什么写一个控制台象棋而不是图形界面

1.1 这个项目到底在练什么

先说结论:C语言能做的实战项目其实不多,但中国象棋几乎是完美的那一个。规则明确、逻辑密度高、状态可穷举,而且不需要依赖任何第三方库就能把运行逻辑写完整——这对学C语言的人来说简直是练基本功的黄金案例。

我是在一次周末整理代码笔记时动念做这件事的。起因很简单,当时刚读完一本讲数据结构和算法的书,手痒想找个项目练手,刷题又刷得腻。象棋的棋盘是一个10行9列的固定网格,棋子种类固定,走法规则虽然比五子棋复杂,但还远没到围棋那种需要大量AI知识才能做"能玩"的程度。更重要的是,它逼着你去处理数组边界、状态判断、递归回溯、极小化极大搜索这些C语言里最硬核的东西。

所以这篇笔记不是写给那种想做一个完整商业产品的人看的,而是给像我一样、想用C语言把数据结构"用起来"的编程学习者。你不需要懂图形学,不需要搭界面框架,甚至不需要装额外的库,一个终端窗口加上标准C库就够。

1.2 控制台、Win32还是图形库:我的选型理由

动手之前我在三个方案之间犹豫过:纯控制台字符界面、Windows窗口程序、加SDL或raylib这类图形库做可视化。

最后选了纯控制台,核心原因是——这个项目的难点根本不在画面上,而在于规则引擎和AI逻辑。在控制台里用ASCII字符画棋盘,把注意力全部集中在"棋子的移动与判定"上,代码量反而最少,调试也最方便。图形界面是锦上添花,但一旦引入,意味着要多写几百行窗口事件处理和绘制代码,对学C语言的初衷来说属于跑偏。

而且控制台版本有一个天然优势:逻辑核心和渲染层是分离的。我把棋盘的输出单独抽成一个函数,之后如果想换成图形界面,只需要替换那个渲染函数,规则引擎完全不用动。这个架构思路在后面的开发中帮了大忙。

2. 棋盘的底层表示:数据结构的第一个决定

2.1 二维数组和索引规则

象棋棋盘是9列×10行,我直接用char board[10][9]这个二维数组来存。这里有个细节值得单独说:很多教程喜欢用board[9][10]或一维数组加坐标换算,但经过实际比较,二维数组配合"行号0代表最上方、列号0代表最左方"的约定,写走法时最不容易出错。

#define BOARD_ROWS 10 #define BOARD_COLS 9 char board[BOARD_ROWS][BOARD_COLS];

下标board[row][col]中,row范围是0到9,col范围是0到8。黑方棋子初始在第0行和第1行,红方棋子初始在第9行和第8行。这个约定后面做走法生成时非常重要,因为"前进"方向对红黑双方是相反的——黑方兵往row增大的方向走,红方兵往row减小的方向走,稍不留神就会搞混。

我还定义了三个辅助函数,后面所有走法逻辑都要依赖它们:

int in_board(int row, int col) { return row >= 0 && row < BOARD_ROWS && col >= 0 && col < BOARD_COLS; } int is_empty(int row, int col) { return board[row][col] == 0; } int is_same_color(int p1, int p2) { return (p1 > 0 && p2 > 0) || (p1 < 0 && p2 < 0); }

in_board负责拦住所有越界访问,is_empty判断某格有没有子,is_same_color用一个简单的正负号规则判断两个棋子是不是同一方。这三件事看起来不起眼,但它们在后续走法校验和AI搜索里会被调用成百上千次,写对了能省下大量调试时间。

2.2 棋子编码:用正负号区分红黑

棋子的编码我参考了五子棋里常用的正负号技巧:黑方棋子用正整数,红方棋子用负整数,0表示空格。这样做的直接好处是判断敌我一目了然,甚至不用额外写查表逻辑。

#define EMPTY 0 #define BLACK_KING 1 #define BLACK_GUARD 2 #define BLACK_BISHOP 3 #define BLACK_HORSE 4 #define BLACK_ROOK 5 #define BLACK_CANNON 6 #define BLACK_PAWN 7 #define RED_KING -1 #define RED_GUARD -2 #define RED_BISHOP -3 #define RED_HORSE -4 #define RED_ROOK -5 #define RED_CANNON -6 #define RED_PAWN -7

为啥不用枚举而用宏?枚举在C里本质是int,也能用,但宏配合取绝对值的写法更顺手。比如判断一个格子是不是红方的"车",直接board[r][c] == RED_ROOK就行;判断是黑方棋子,board[r][c] > 0就行;想取它的类型做分值计算,abs(board[r][c])一步到位。

当然,宏也有缺点,不占类型检查的便宜,但在这个规模的项目里完全够用。我更看重的是这套编码让代码读起来像在描述现实棋子,而不是一堆难懂的魔法数字。

2.3 棋盘初始化的代码思路

初始布局我写了一个单独的init_board()函数,绝不放在main里。原因很实际——后续调试时我需要反复重置棋盘,单独成函数随时可以调用。

void init_board(void) { for (int r = 0; r < BOARD_ROWS; r++) for (int c = 0; c < BOARD_COLS; c++) board[r][c] = EMPTY; for (int c = 0; c < 4; c++) { board[0][c] = BLACK_ROOK; board[0][8 - c] = BLACK_ROOK; } // 简化版:手动摆向 board[0][0] = BLACK_ROOK; board[0][1] = BLACK_HORSE; board[0][2] = BLACK_BISHOP; board[0][3] = BLACK_GUARD; board[0][4] = BLACK_KING; board[0][5] = BLACK_GUARD; board[0][6] = BLACK_BISHOP; board[0][7] = BLACK_HORSE; board[0][8] = BLACK_ROOK; board[2][1] = BLACK_CANNON; board[2][7] = BLACK_CANNON; for (int c = 0; c < 9; c += 2) board[3][c] = BLACK_PAWN; // 红方布局:行8=兵,行7=炮,行9=大子 board[9][0] = RED_ROOK; board[9][1] = RED_HORSE; board[9][2] = RED_BISHOP; board[9][3] = RED_GUARD; board[9][4] = RED_KING; board[9][5] = RED_GUARD; board[9][6] = RED_BISHOP; board[9][7] = RED_HORSE; board[9][8] = RED_ROOK; board[7][1] = RED_CANNON; board[7][7] = RED_CANNON; for (int c = 0; c < 9; c += 2) board[6][c] = RED_PAWN; }

这个代码里有个值得注意的点:黑方兵在第3行,红方兵在第6行,炮在2行和7行,这样棋盘布局就是对称的。如果哪次不小心把黑兵写到了第6行,游戏一开局AI就会乱走,所以初始化函数必须反复检查,这是整个项目里最容易出低级错误的地方。

3. 一招一式背后的规则引擎:走法生成与合法性校验

3.1 车的直线搜索与炮的隔子吃子

棋子的走法生成是象棋项目的灵魂,我把它写成generate_moves(int row, int col, Move moves[], int *count),统一给每个在(row,col)位置的棋子生成所有合法走法,存入moves数组并计数。主循环、人机AI、将军判断都要调用这个函数,所以它的效率直接决定了游戏流畅度。

车的走法是最典型的直线搜索:从当前位置朝上下左右四个方向逐个格子探测,遇到空格就加入走法,遇到友军则停止,遇到敌军则吃掉并停止。代码用方向数组组织,比写四个重复的循环干净得多:

int rook_dirs[4][2] = {{-1,0},{1,0},{0,-1},{0,1}}; for (int d = 0; d < 4; d++) { int nr = row + rook_dirs[d][0]; int nc = col + rook_dirs[d][1]; while (in_board(nr, nc)) { if (is_empty(nr, nc)) { moves[(*count)++] = (Move){row, col, nr, nc}; } else { if (!is_same_color(board[row][col], board[nr][nc])) moves[(*count)++] = (Move){row, col, nr, nc}; break; } nr += rook_dirs[d][0]; nc += rook_dirs[d][1]; } }

炮的走法比车多一层"隔子"判定:不吃子时和车一样直线走,但中间不能有子;吃子时则要求目标位置之前恰好有一个"炮架"。这里最容易踩坑的地方在于——检查炮架时,那个中间子不能是目标位置的子。我的处理是在循环里维护一个skip计数变量,遇到第一个棋子时标记为炮架并继续找下一个。

3.2 马、象、士、将的斜线规律

马走日,这个"日"在代码里怎么表达?核心是8个方向的目标点位数组,同时要处理蹩马腿。马在行进方向上紧挨着的那一格如果站了任何棋子,就无法往那个方向跳:

int horse_dirs[8][2] = { {-2,-1}, {-2,1}, {-1,-2}, {-1,2}, {1,-2}, {1,2}, {2,-1}, {2,1} }; int horse_leg[8][2] = { {-1,0}, {-1,0}, {0,-1}, {0,1}, {0,-1}, {0,1}, {1,0}, {1,0} };

这两个数组必须一一对应:horse_dirs[i]是最终落点,horse_leg[i]是蹩腿位置。写这个数组时尤其要细心,我第一版就把第三组的{0,-1}和第四组的{0,1}写反了,结果马只能往左走不能往右走,整盘棋直接废掉。

象走田,走法相对少:四个斜向落点,但两个约束——不能过河、塞象眼。用代码表达就是先检查落点是否在己方半场,再检查"田"字中心的象眼是否为空。

士和将的活动范围都在九宫格内。士的四个斜线落点必须同时满足3 <= row && row <= 7 && 3 <= col && col <= 5,将的上下左右移动同理。这里有个细节,红黑双方的九宫格行范围相同(3到7),因为棋盘10行以行5为中线上下对称,这让代码省了不少事。

3.3 兵卒方向与过河状态

兵卒的走法看着简单,方向处理却最容易出幺蛾子。黑方在上半场,向下(row增大)是前进;红方在下半场,向上(row减小)是前进。我的做法是写一个函数判断当前棋子在己方视角里是否过河:

int is_pawn_crossed(int piece, int row, int col) { if (piece == BLACK_PAWN && row > 5) return 1; if (piece == RED_PAWN && row < 4) return 1; return 0; }

棋盘中线的界线在行4和行5之间,黑方过河意味着row从4变成5之后的区域,红方相反。过河之前只能前进一个格,过河之后可以前进和左右平移,但永远不能后退。判断条件写成row > 5和row < 4是因为恰好落在边界上时,行军方向的正负号直接决定了合法性。

3.4 合法性校验:不能送将的递归验证

这一步是整个规则引擎里最容易被忽略、也最容易让棋局出现"灵异事件"的地方。光生成走法还不够,还必须确保这一步走完之后,己方的将帅没有被将军。否则就会出现"我把自己送对面炮口里"的非法局面。

我的做法是在所有走法生成完毕后,增加一个is_legal_move()过滤函数:

int is_legal_move(Move m) { // 先模拟走子 char captured = board[m.to_r][m.to_c]; board[m.to_r][m.to_c] = board[m.from_r][m.from_c]; board[m.from_r][m.from_c] = EMPTY; int king_alive = !is_in_check(m.to_r, m.to_c, board[m.to_r][m.to_c]); // 还原棋子 board[m.from_r][m.from_c] = board[m.to_r][m.to_c]; board[m.to_r][m.to_c] = captured; return king_alive; }

is_in_check()的实现最直接的方式是扫描全盘所有敌方的合法走法,看有没有哪一步能直接吃掉本方将帅。虽然效率不高,但10×9的棋盘一共没多少子,每次判定撑死几十次走法生成,完全够快。这个递归验证的思想,后来在AI搜索里也派上了用场——搜索每一步时同样需要过滤出合法走法,否则AI会计算出"把自己将死"的鬼步。

4. 让人机对战不显得弱智:评估函数与两层搜索

4.1 子力价值怎么定

象棋AI的最小可行版本不需要神经网络,一个子力价值表加上简单的搜索深度就够了。我参考了一些开源象棋引擎的做法,把基础分值定成这样:

棋子分值
将/帅10000
车900
马400
炮450
象/相250
士/仕250
兵/卒100(过河后150)

车为什么不是1000而是900?马炮之间为什么有50的差值?这些数字本质上是人对棋力的主观量化,不需要太纠结精确值。关键是保持相对大小合理:车约等于马加炮,士象是弱子,兵在过河后升值一截。评估函数就是把这些分值带符号累加,黑方为正、红方为负:

int evaluate(void) { int score = 0; for (int r = 0; r < BOARD_ROWS; r++) { for (int c = 0; c < BOARD_COLS; c++) { int p = board[r][c]; if (p == EMPTY) continue; int value = piece_value(abs(p)); if (p > 0) score += value; else score -= value; } } return score; }

这么简单的评估函数当然有缺陷,比如它完全不管棋子位置好不好、有没有被牵制,但作为"能玩"级别的AI已经足够。象棋不是只有子力对比,不过在这个项目里,"子力领先"和"能吃子"已经能赢过很多棋力不熟的对手了。

4.2 一层贪心与两层Minimax

单层AI的逻辑最简单:遍历己方所有棋子的所有合法走法,对每个局面调用evaluate()打分,选分数最高的那个走法。这能做出来,但AI会显得"近视"——它只能看到吃子,看不到两步之后会被反吃。

于是我加了一层,用经典的Minimax搜索到两层深度。所谓两层,就是"我走一步,然后对手走一步",我把我方视角的分数差分拿来做决策:

int minimax(int depth, int maximizing) { if (depth == 0) return evaluate(); Move moves[128]; int count = 0; // 假设当前局面轮到maximizing方走 for (int r = 0; r < BOARD_ROWS; r++) { for (int c = 0; c < BOARD_COLS; c++) { if (board[r][c] == EMPTY) continue; if ((board[r][c] > 0) != maximizing) continue; generate_moves(r, c, moves, &count); // 需要过滤合法走法 } } int best = maximizing ? -INF : INF; for (int i = 0; i < count; i++) { // 模拟走子、递归、还原 if (maximizing) best = max(best, minimax(depth - 1, !maximizing)); else best = min(best, minimax(depth - 1, !maximizing)); } return best; }

两层搜索的意义在实战中非常明显:AI会在走到第4回合时发现自己下一步会被炮打掉车,从而主动避让,而不是像单层那样傻乎乎去吃子。虽然三层四层能更强,但每加深一层,搜索时间可能爆炸式增长。在我这个纯C实现里,两层平均响应时间在几十毫秒到一两百毫秒之间,我认为这是控制台象棋最舒服的节奏。

4.3 搜索中的细节:走法排序与剪枝

搜索慢的一个重要原因是每个节点都要重新生成走法并做合法性过滤。我做了两个优化,加起来效果立竿见影。

第一个是走法排序:优先搜索吃子走法,尤其是吃大子的走法(比如吃车)。因为好的走法往往能很快让评估分数发生变化,让搜索尽早找到一个足够好的值,从而加快后续节点的剪枝判断。

第二个是简单的Alpha-Beta剪枝。虽然两层深度的收益有限,但加上这个之后搜索速度还是快了一倍多。核心思路是:如果在某一步已经发现了足够好的走法,就不必再看剩下那些明显更差的走法。C语言的函数栈天然适合这种递归回溯,这也是为什么我说这个AI用C写比用脚本语言要顺畅。

5. 主循环的节奏感:从落子到判负的状态流转

5.1 坐标输入与中文提示

主循环是整个游戏的发动机,我把它写成了标准的有限状态机:等待输入、处理输入、执行走子、判断胜负、切换玩家。

控制台象棋的交互和图形界面完全不一样——你不能用鼠标点棋子,只能用键盘输入坐标。最常见的输入格式是"从哪到哪"的四段式,比如红方要动第9行第4列的将,输入9 4 8 4。我额外做了一步容错:允许用户输入"q"直接退出,输入"u"悔棋。这两条命令在主循环里用strcmp判断后单独处理。

棋盘输出我用了两套字符映射表:黑方棋子用大写字母加中文,红方棋子用中文字面。在Windows的GBK编码环境下直接打印中文没问题,但在Linux终端下会乱码。我的处理方式是编译时用条件宏判断操作系统,在生产环境里用UTF-8编码输出。

5.2 悔棋怎么实现

悔棋功能很多人觉得是鸡肋,但实践中它对棋力较弱的玩家特别重要——下错一步直接全盘崩,挫败感太强。实现思路很简单:维护一个历史栈,每次走子前把当前的棋盘快照压栈,悔棋时弹栈恢复即可。

typedef struct { char snapshot[BOARD_ROWS][BOARD_COLS]; } BoardSnapshot; BoardSnapshot history[256]; int history_top = 0;

悔棋按钮一次只回退一步。人机对战时,如果玩家悔棋,AI刚走的那步也要一起回退,否则两边步数就不对称了。这里有个细节常被忽略:AI走完后必须立刻入栈,否则用户悔棋会回退到AI走之前的局面,导致"AI白走一步"的错觉。实际测试中这是最容易反馈"奇怪"的地方。

5.3 胜负与将军状态的即时反馈

判断胜负的关键是在每次走子后扫描整个棋盘,看将帅是否还在棋盘上。如果被吃掉了,直接宣告结束。另一个隐藏规则是"将帅不能照面"——也就是将帅之间不能形成直线对视且中间无子。这是象棋特有规则,漏掉它会出现"两边隔着空线却还能互相走动"的诡异局面。

我实现了一个check_winner()函数,主循环每一轮直接调用:

int check_winner(void) { int black_king = 0, red_king = 0; for (int r = 0; r < BOARD_ROWS; r++) { for (int c = 0; c < BOARD_COLS; c++) { if (board[r][c] == BLACK_KING) black_king = 1; if (board[r][c] == RED_KING) red_king = 1; } } if (!black_king) return RED_WIN; if (!red_king) return BLACK_WIN; return NO_WINNER; }

"将军"状态的提示我之前一直想做但没做完整。简化方案是在AI走完后调用is_in_check(当前将的位置)来显示"将军"文案,但多步将军时容易显示不准确。后来我放弃了对"是否被将军"的完整推导,只在对手将帅直接位于攻击线路上时才提示,范围小很多,也足够提供战术提示。

6. 最折腾我的四个bug:踩坑实录与修复思路

6.1 马腿数组越界

马腿数组是我踩的第一个大坑。我一开始写的horse_leg和horse_dirs对应关系错位,导致马在走"日"字形时,蹩腿的位置全算到了下一位,结果就是马可以无视蹩腿直接跳跃。修复方法很简单,但排查过程很痛苦——我先是用printf打印每个落点目标的走法列表,一个个对比,最后把两行代码交换才正常。这件事教训深刻:凡是两个数组一一对应的地方,写完就要拿一个小例子手工跑一遍,别指望编译器帮你发现这种逻辑错误。

6.2 黑红方向混乱

第二个bug是在写红方兵卒走法时,把前进方向用了黑方的判断逻辑。现象是红方兵刚开局就能向下走,直接过河去抓黑方炮。问题的根源是我一开始只用row的正负判断前进方向,但黑方和红方的"前进"在同一个坐标系里正好是相反的。后来我把方向数组按颜色做成两个,并封装成get_forward_dir(piece),从此再没出过这类错误:

int get_forward_dir(int piece) { if (piece == BLACK_PAWN) return 1; if (piece == RED_PAWN) return -1; return 0; }

这个bug告诉我一个道理:该抽象的东西就要提前抽出来,哪怕只是一个小小的方向常量,硬编码在逻辑里只会让你在写下一段代码时忘掉它。

6.3 杀棋误判与反杀

我在实现将军判断时遇到过一个非常隐蔽的问题:杀掉对方的将之后,评估函数会因为己方"优势太大"而选择不杀,因为评分函数不会认为"将死"是终局。实际上,在搜索到将帅被吃面临的局面时应该直接返回极端分值,而不是用子力差去评估。

修复方式是给evaluate()或minimax()加入终局判断。搜索到敌方将帅消失时,直接返回+INF或-INF,这样AI才会坚定地追杀将帅,而不是贪吃一个车而放走对方将帅。

6.4 将帅照面被无视

最后一个bug跟规则理解有关。我一开始实现的规则里,明明将帅已经在同一条直线上且中间没有棋子,却还能下令让帅往上走一步。后来看了规则说明才知道,中国象棋有"将帅不能互相照面"的硬性规定,一旦照面就意味着"正在被将军"。

修复方案是在is_legal_move()里增加一条额外检查:两将之间不能出现"直线无遮挡"的情况。这个检查要在模拟走完之后做,否则会漏掉"我刚躲开,对面的将还在追我"的状态。加上这个判断后,所有规则终于完全符合真实象棋了。

这套代码前后断断续续写了一个多星期,核心文件加起来不到一千行。把玩了一阵之后,我觉得最大的收获不是"我会写象棋了",而是真正理解了数据结构和递归在现代编程里是如何被用进实战的——棋盘是二维数组,规则是数组遍历加边界判断,AI是递归搜索。C语言没有那么多花哨的框架,但它把一个游戏逻辑需要的一切都诚实地摊在你面前,让你读懂每一步到底在干什么。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询