☰
C语言中国象棋源代码解析:规则引擎与AI设计
2026/10/15 17:07:48 网站建设 项目流程

简介:一份面向C语言初学者与游戏开发爱好者的中国象棋完整源代码工程,涵盖棋盘二维数组建模、棋子走法与吃子规则、将死判定、用户交互及基础AI思路,完整演示了C语言核心语法在真实项目中的落地方式。压缩包共85个文件,约143KB,其中图标资源38个、头文件14个、C++源文件10个、文本说明7个、光标文件6个,并包含可直接运行的exe程序;头文件与源文件便于阅读和二次开发,图标与光标素材用于界面呈现,txt文档则收录版权与使用说明。目前已有997人浏览学习,适合用来对照理解结构体、数组、指针在游戏逻辑中的应用,也可作为课程设计或实训项目的改造蓝本。整个工程采用Visual C++ 6.0工作区结构,目录划分清晰,Release目录下可直接运行体验,从主控源码入手还能逐步掌握人工智能走子算法的实现脉络。

1. 为什么搜「中国象棋 c语言源代码」的人,一半会半途而废

搜索「中国象棋 c语言源代码」的人,绝大多数不是来找算法论文,而是想拿到一份能编译、能走棋、最好还能赢自己一把的小程序。但真把手头的源码包一个个编过去,你会发现至少一半根本不完整:有的只能走兵和炮,有的将帅能隔着河对脸互吃,还有的一编译就是二十多个报错。所谓「源代码」,其实分成两类——一类是课程设计里带图形界面、事件循环的完整程序,另一类是纯控制台输出、把规则引擎放在核心的演示工程。它们的差别不只在界面,而在代码结构:界面随时能换,规则引擎错了就全盘皆输。这篇文章要做的就是帮你分清这两种形态,先跑通一个纯控制台的最小版本,再把规则写严谨,最后加一个能陪你练棋的 AI。正在做课程设计、或者想用 C 语言练手的人,可以直接照着走。

2. 动手前先定棋盘落点:控制台方案还是图形库方案

很多人拿到源码第一件事就是找图形界面,但实际评估一份 C 语言象棋代码的可维护性,最该先看的不是界面好不好看,而是「有没有独立的规则引擎」。图形界面只是棋盘的一个绘制器,走棋逻辑必须和它分开。先定外层方案,后面换渲染层时不至于重写规则。

2.1 控制台方案:一个光标定位函数解决重绘问题

控制台象棋看起来简陋,调试时却是最省心的。不需要任何第三方库,只要标准 C 库和一个能正常显示 UTF-8 的终端,就能把棋盘画出来。关键在于重绘方式:走子后不要用 system("cls") 清屏,那样屏幕会闪烁,而且走棋记录会全部消失。正确做法是用 ANSI 转义序列把光标定位回棋盘起点,整个棋盘重新打印一遍。这个机制是控制台象棋源码里最常见的刷新方案,也是判断一份代码是不是成熟作品的第一步。

// 控制台光标定位:兼容 ANSI 终端(Linux/macOS 直接可用) #include <stdio.h> void gotoxy(int row, int col) { printf("\033[%d;%dH", row, col); // ANSI 转义:把光标移到 (row, col) } int main(void) { gotoxy(5, 1); printf("楚河 汉界"); gotoxy(1, 1); // 回到第 1 行第 1 列,实现“原位重绘” return 0; }

逻辑说明:printf 里的 \033[%d;%dH 是 ANSI 标准的光标定位序列,前面的 \033 是 ESC 字符。gotoxy(5,1) 把光标放到第 5 行第 1 列,输出“楚河 汉界”之后,再用 gotoxy(1,1) 把光标拉回顶部。这样下一轮 draw_board() 重新打印时,新内容直接覆盖旧内容,屏幕不会滚动。参数含义:第一个参数是行号从 1 开始,第二个是列号从 1 开始。如果你在 Windows 的旧版 CMD 里跑,需要在 main 开头启用 ANSI 支持,或者改用 Windows API 的 SetConsoleCursorPosition,否则转义序列会原样打印出来。这块可以放到后面第 5 章统一排查。

2.2 图形库方案:graphics.h 与 SDL 的取舍

graphics.h 是老一代教学常用的图形库,画棋盘只需要画矩形和圆,代码很短,很多老教材里都有完整范例。它的代价是平台绑定太强:当前主流编译器默认不带这个头文件,你得先手动配置库路径,这一步就会劝退一半新手。就算配置成功,图形模式下调试规则引擎也比控制台麻烦,因为你看不到 printf 日志。

SDL 则是跨平台、事件驱动,鼠标点击拿到的坐标可以换算成棋盘行列,窗口缩放也不容易闪屏。代价是初始化代码长:建窗口、建渲染器、处理退出事件,光这套骨架就上百行。对一个目标只是「把象棋引擎跑起来」的人,这一步不是收益而是负担。常见做法是先写一个不依赖界面的规则引擎,再套一个简单 SDL 外壳;如果只是为了课程设计交差,用控制台方案两小时就能做完,用 SDL 可能要折腾两天。

2.3 选型决策表:三种方案的适用边界

方案上手成本平台依赖适合场景
控制台 + ANSI 转义低全平台,Windows 需处理代码页课程设计、规则引擎调试
graphics.h中基本限 Windows 老环境短期演示项目
SDL高跨平台作品展示、要鼠标交互

我一般建议先控制台后图形。先把规则引擎在控制台里测到「怎么走都不出错」,再花一晚上套一个 SDL 界面,风险最低。反过来,一开始就怼图形界面,规则出错了你还要分心怀疑是不是绘制层的问题。这个决策表和后面的数据结构、规则引擎章节是直接挂钩的——不管选哪种界面,核心还是那个 10×9 的棋盘数组。

3. 棋盘数据结构定生死:10×9 数组怎么存、坐标怎么换算

3.1 为什么是 char board[10][9]

中国象棋棋盘是 10 行 9 列,行是纵向前进方向,列是横向。最直接的表示就是一个字符数组 board[10][9]。为什么用字符而不是结构体?因为调试方便:你把整个数组按 10 行 9 列打出来,一眼就能看出哪个位置有子、是什么子。用结构体存坐标反而要额外写打印函数,初始化的代码也更啰嗦,对一份以「跑通」为目标的源码不值得。

// 棋盘:10 行 9 列,board[0] 为红方底线 // 大写 = 红方,小写 = 黑方 // R/r 车 N/n 马 C/c 炮 B/b 象 A/a 仕 K/k 帅(将) P/p 兵(卒) '.' 空位 char board[10][9]; void init_board(void) { for (int r = 0; r < 10; r++) for (int c = 0; c < 9; c++) board[r][c] = '.'; // 红方底线:车 马 象 仕 帅 仕 象 马 车 char red_line[] = "RNBAKABNR"; // 黑方底线:车 马 象 士 将 士 象 马 车(小写) char black_line[] = "rnbakabnr"; for (int c = 0; c < 9; c++) { board[0][c] = red_line[c]; board[9][c] = black_line[c]; } // 红兵在第 3 行,黑卒在第 6 行 for (int c = 0; c < 9; c += 2) { board[3][c] = 'p'; // 红兵 board[6][c] = 'P'; // 黑卒 } // 炮的初始位置 board[2][1] = board[2][7] = 'c'; board[7][1] = board[7][7] = 'C'; }

逻辑说明:字符串 "RNBAKABNR" 只有 9 个字符,正好对应 9 列,循环里直接用 red_line[c] 赋值,不用再手写 9 个等号。红兵用 'p'、红炮用 'c',与「大写 = 红方」的约定保持一致。参数说明:行号 0 是红方底线,行号 9 是黑方底线,红方向前是行号减小,黑方向前是行号增大。这个方向约定一旦定下,后面兵卒规则、AI 评估都要跟着用,中途改会造成全方位翻车。

3.2 用户输入的坐标与数组下标

控制台版输入,建议固定为「行列坐标」。比如红车在 (0,0),想走到 (0,4) 就输入 0 0 0 4。这样规则引擎不需要解析中文,输入层、规则层、显示层各干各的。很多人想直接支持「车二平五」,但中文走法翻译涉及汉字、数字、方向词混合解析,scanf 根本处理不了,是典型的后期才该做的功能。

typedef struct { int fr, fc; // 起点行、起点列 int tr, tc; // 终点行、终点列 } Move; // 读取一步棋,成功返回 1,失败返回 0 int parse_move(Move *m) { char line[64]; if (fgets(line, sizeof(line), stdin) == NULL) return 0; int n = sscanf(line, "%d %d %d %d", &m->fr, &m->fc, &m->tr, &m->tc); return (n == 4) ? 1 : 0; }

逻辑说明:用 fgets 读整行,再用 sscanf 解析四个整数,而不是直接 scanf("%d %d %d %d")。直接 scanf 读数字之后,输入缓冲区里会残留换行符,下一轮循环 fgets 会立刻读到空行,主循环直接死循环。sscanf 的返回值是成功匹配的字段数,只有四个字段都读到了才返回 1。这里的关键是:所有坐标都直接用数组下标,从 0 开始,不要和屏幕显示的行号混在一起。显示层打印棋盘时可以加列标号,但进入规则引擎的 Move 必须是原始下标。

3.3 主循环:走棋、校验、落子、换手

规则引擎和交互层怎么串起来?一个 while 主循环就够了:打印棋盘、读输入、校验合法性、落子、切换回合。这个循环是整个程序的骨架,所有功能都挂在这五个步骤上。

int turn = 0; // 0 = 红方,1 = 黑方 int game_over = 0; while (!game_over) { print_board(board); printf("%s 走棋:", turn == 0 ? "红" : "黑"); Move m; if (!parse_move(&m)) { printf("输入格式:起点行 起点列 终点行 终点列\n"); continue; } if (!is_legal_move(board, m, turn)) { printf("非法走法,重新输入\n"); continue; } apply_move(board, m); turn = !turn; // 换手 }

逻辑说明:turn 用整数 0/1 表示红黑,取反就是换手,这个写法比用字符 'R'/'B' 方便得多。非法输入和非法走法都是 continue,让玩家重新输入,而不是 return 0 退出——很多入门代码在这里直接退出,一点都不耐错。is_legal_move 是第 4 章要重点讲的规则入口。apply_move 内部会先记录被吃子信息,为悔棋留后路,这个函数在下一个小节展开。

3.4 落子函数与历史记录

很多同学写完走棋就不管历史记录了,等想加悔棋功能时发现无从下手。悔棋的本质是逆操作:把起点放回原来的子,把终点放回被吃的子。所以 apply_move 里必须先保存被吃子,再覆盖棋盘。一份设计得当的 C 语言象棋源码,从一开始就会把历史栈带上。

typedef struct { Move m; // 这步棋 char captured; // 被吃掉的子,'.' 表示没吃子 int turn; // 走棋前的回合 } History; History hist[512]; int hist_len = 0; void apply_move(char b[10][9], Move m) { // 先记录,再落子 hist[hist_len].m = m; hist[hist_len].captured = b[m.tr][m.tc]; hist[hist_len].turn = (hist_len % 2 == 0) ? 0 : 1; hist_len++; b[m.tr][m.tc] = b[m.fr][m.fc]; b[m.fr][m.fc] = '.'; }

逻辑说明:历史栈容量 512 足够正常下完一整盘棋。captured 保存目标格原来的字符,悔棋时把它放回目标格,把起点格重新写成原来的棋子。turn 记录走棋前的回合,这样悔棋后可以正确恢复当前该谁走。参数说明:hist_len 是全局计数器,悔棋时先 hist_len--,再从 hist[hist_len] 取值做逆操作。这段代码看着简单,却是整个程序扩展到「复盘」「悔棋」「AI 搜索」的地基。

4. 规则引擎是象棋的灵魂:走法合法性与胜负判定

4.1 统一入口 is_legal_move()

规则引擎的核心是一个统一入口函数。不管走棋来自键盘还是 AI,都要经过同一个 is_legal_move(),棋种分发用 switch 完成。这样写的好处是调试时只要盯住一个入口,出 bug 不用满文件找;AI 搜索时也只需要调用这一个函数就能枚举所有走法。

#define RED 0 #define BLACK 1 // 判断 side 方的棋子从 (fr,fc) 走到 (tr,tc) 是否合法 int is_legal_move(char b[10][9], Move m, int side) { char p = b[m.fr][m.fc]; if (p == '.') return 0; // 起点没子 // 大小写约定:大写是红方,小写是黑方 if (side == RED && p >= 'a' && p <= 'z') return 0; if (side == BLACK && p >= 'A' && p <= 'Z') return 0; // 不能吃自己的子 char target = b[m.tr][m.tc]; if (target != '.') { if ((side == RED && target >= 'A' && target <= 'Z') || (side == BLACK && target >= 'a' && target <= 'z')) return 0; } // 按棋子类型分发 switch (tolower(p)) { case 'r': return legal_rook(b, m); case 'n': return legal_knight(b, m); case 'c': return legal_cannon(b, m); case 'b': return legal_elephant(b, m); case 'a': return legal_guard(b, m); case 'k': return legal_king(b, m); case 'p': return legal_pawn(b, m); } return 0; }

逻辑说明:先用 ASCII 大小写判断回合与棋子的归属,再判断目标格是否是己方棋子,最后按棋种分发到各自的规则函数。tolower(p) 把大写统一转成小写,switch 只需要写小写分支。参数说明:side 只有 RED/BLACK 两个值,不能在函数里自己猜。所有子规则函数的签名统一为 int legal_xxx(char b[10][9], Move m),接收完整 Move,内部自己算差值,接口一致便于维护。

4.2 车、马、象、仕的单子规则

各棋种规则是这份源码最容易被写错的部分,先看规则总表:

棋子移动模式特殊约束
车横直任意距离路径上不能有子
马日字形(1×2)蹩马腿时不合法
象田字形(2×2)塞象眼时不合法,不能过河
仕斜走一格不能出九宫

马和象的规则最典型,也是踩坑重灾区。马的「日字」位移是四个方向里的 (1,2) 或 (2,1),蹩马腿的本质是「先直走一格,再斜走一格」——那直走的第一个位置被占了就动不了。

// 马:日字,蹩马腿 int legal_knight(char b[10][9], Move m) { int dr = m.tr - m.fr; int dc = m.tc - m.fc; // 判断是否为日字位移 if (!((dr == 2 && dc == 1) || (dr == 2 && dc == -1) || (dr == -2 && dc == 1) || (dr == -2 && dc == -1) || (dr == 1 && dc == 2) || (dr == 1 && dc == -2) || (dr == -1 && dc == 2) || (dr == -1 && dc == -2))) return 0; // 马腿:先直走的那一格 int leg_r = m.fr, leg_c = m.fc; if (dr == 2 || dr == -2) leg_r += dr / 2; // 行方向先走一格 else leg_c += dc / 2; // 列方向先走一格 return b[leg_r][leg_c] == '.'; }

逻辑说明:dr/dc 是终点相对起点的位移。判断日字形用了穷举八种组合的方式,比数学公式 drdr + dcdc == 5 更直观,也方便初学者核对。马腿位置的计算:如果行位移是 ±2,说明先直走的是行方向,马腿在 (fr + dr/2, fc);否则先直走列方向,马腿在 (fr, fc + dc/2)。这个写法直接对应「马先直后斜」的规则定义,不会出现查表查错的玄学问题。

象的规则类似,但多了「不能过河」和「塞象眼」两个约束:

// 象:田字,塞象眼,不能过河 int legal_elephant(char b[10][9], Move m) { int dr = m.tr - m.fr; int dc = m.tc - m.fc; if (dr != 2 && dr != -2) return 0; if (dc != 2 && dc != -2) return 0; // 田字中心:行走的必经格,即象眼 int eye_r = (m.fr + m.tr) / 2; int eye_c = (m.fc + m.tc) / 2; if (b[eye_r][eye_c] != '.') return 0; // 红象只能在 0..4 行,黑象只能在 5..9 行 char p = b[m.fr][m.fc]; if (p >= 'A' && p <= 'Z' && m.tr >= 5) return 0; if (p >= 'a' && p <= 'z' && m.tr < 5) return 0; return 1; }

注意象眼坐标就是田字中心,而河界判定直接看目标行号加棋子的红黑属性。这套写法里「象不能过河」不是行号写死,而是按棋子本身的归属判断,换成红黑视角翻转也不会出错。

4.3 炮的翻山与兵卒过河

炮是象棋里最容易被写错的子,因为它的合法条件和「吃子」绑在一起:不吃子时中间必须干干净净,吃子时中间必须正好隔一个子,这个子就是炮架。同一个终点,有子和没子的判定逻辑完全不同,必须分开写。

// 炮:直线走,吃子要隔一个炮架,不吃子要空路径 int legal_cannon(char b[10][9], Move m) { if (m.fr != m.tr && m.fc != m.tc) return 0; int cnt = 0; if (m.fr == m.tr) { int step = (m.tc > m.fc) ? 1 : -1; for (int c = m.fc + step; c != m.tc; c += step) if (b[m.fr][c] != '.') cnt++; } else { int step = (m.tr > m.fr) ? 1 : -1; for (int r = m.fr + step; r != m.tr; r += step) if (b[r][m.fc] != '.') cnt++; } if (b[m.tr][m.tc] == '.') return cnt == 0; // 不吃子:路径必须干净 return cnt == 1; // 吃子:恰好一个炮架 }

逻辑说明:cnt 统计路径中间的子数,循环起点和终点都不包括目标格本身,所以不会把目标格上的棋子误算成炮架。step 根据移动方向取 1 或 -1,保证循环从小往大走。参数说明:这个函数只关心「能不能到目标格」,目标格上是自己人还是敌人由统一入口 is_legal_move 提前拦截,这里不用重复判断。

兵卒的规则是过河前后行为不一样,还要区分红黑方向:

// 兵/卒:过河前后行为不同 int legal_pawn(char b[10][9], Move m) { char p = b[m.fr][m.fc]; int forward; // 前进方向:红方向行号减小,黑方向行号增大 int crossed; // 是否过河 if (p >= 'A' && p <= 'Z') { // 红兵 forward = -1; crossed = (m.fr >= 5); } else { // 黑卒 forward = 1; crossed = (m.fr < 5); } int dr = m.tr - m.fr; int dc = m.tc - m.fc; if (dr == forward && dc == 0) return 1; // 向前走一格 if (crossed && dr == 0 && (dc == 1 || dc == -1)) return 1; // 过河后可以平走一格 return 0; // 永远不能后退 }

逻辑说明:forward 表达的是行号变化方向。红方底线在 board[0],红兵向前走行号减小,所以 forward = -1;黑卒从 board[9] 出发向前行号增大,forward = 1。crossed 的判断同样和行号方向绑定:红兵走到行号 5 及更远才算过河,黑卒走到行号 4 及以下才算过河。这个函数是全局方向约定的直接体现,如果你把 board[0] 定义成黑方底线,这里的前进方向全部要反过来。

4.4 将军、将死与困毙

胜负判定是所有规则函数的汇总,is_check 用于检测当前局面下 side 方的将帅是否被将军。实现思路很直白:先找到己方将帅位置,再遍历对方所有棋子,看有没有任何一步合法走法能吃到将帅。另外「王不见王」是中国象棋特有的规则,双方将帅在同一列且中间无子时也算被将军。

// 检测 side 方是否处于被将军状态 int is_check(char b[10][9], int side) { // 1. 找己方将帅 char king = (side == RED) ? 'K' : 'k'; int kr = -1, kc = -1; for (int r = 0; r < 10 && kr < 0; r++) for (int c = 0; c < 9; c++) if (b[r][c] == king) { kr = r; kc = c; break; } if (kr < 0) return 1; // 将帅被吃,已经输 // 2. 王不见王:同列且中间无子 char opp_king = (side == RED) ? 'k' : 'K'; for (int r = 0; r < 10; r++) { if (b[r][kc] == opp_king) { int clear = 1; for (int rr = kr + 1; rr < r; rr++) if (b[rr][kc] != '.') { clear = 0; break; } if (clear) return 1; } } // 3. 遍历对方所有棋子的合法走法 int opp = (side == RED) ? BLACK : RED; for (int r = 0; r < 10; r++) { for (int c = 0; c < 9; c++) { char p = b[r][c]; if (p == '.') continue; if (opp == RED && p >= 'A' && p <= 'Z' || opp == BLACK && p >= 'a' && p <= 'z') { Move m = {r, c, kr, kc}; if (is_legal_move(b, m, opp)) return 1; } } } return 0; }

逻辑说明:找将帅位置用双重循环加提前跳出,九宫行数很少,不用优化。王不见王的检查独立于走法合法性,因为将军检测里用得着,而且这是中国象棋区别于其他棋类的重要规则,单独写清晰。第三步性能完全不用担忧,棋盘 90 个格,枚举每个对方棋子最多几十次合法走法判断,在 C 语言里是微秒级操作。

将死的判定比将军多一步:不仅被将军,而且己方所有合法走法都无法解除将军。实现方式是枚举己方所有走法,尝试走子后检测是否仍然被将军。配合历史栈的 undo 操作,这个判断写起来很干净:

int is_checkmate(char b[10][9], int side) { // 枚举 side 方所有合法走法 for (int r = 0; r < 10; r++) { for (int c = 0; c < 9; c++) { char p = b[r][c]; if (p == '.') continue; if (side == RED && !(p >= 'A' && p <= 'Z')) continue; if (side == BLACK && !(p >= 'a' && p <= 'z')) continue; for (int tr = 0; tr < 10; tr++) { for (int tc = 0; tc < 9; tc++) { Move m = {r, c, tr, tc}; if (!is_legal_move(b, m, side)) continue; apply_move(b, m); int still_check = is_check(b, side); undo_move(b); if (!still_check) return 0; // 有棋可解 } } } } return 1; // 所有走法都逃不开,将死 }

逻辑说明:枚举两层,外层是己方每个棋子,内层是每个可能的目标格。apply_move 和 undo_move 必须成对出现,否则局面会被污染。如果某一步走完 not check,说明这步能解围,直接返回 0;枚举完所有走法都解不了围,就是将死。这个函数同时也能判断困毙——只要把 is_check 的前提去掉,或者对「无子可动」做单独判断即可,很多源码把将死和困毙分开写,这里合在一起理解。

5. 编译、乱码和死棋:C 语言象棋代码最常见的 5 个坑

这一章的坑几乎每份课程设计代码都会踩一遍,而且踩坑顺序都差不多:先编译不过,再显示乱码,然后规则出玄学 bug,最后输入和悔棋崩溃。按「现象 → 原因 → 解决」写下来,你对照着自己的代码排查就好。

5.1 编译报错 graphics.h 不存在

现象:gcc 编译时直接报 fatal error: graphics.h: No such file or directory,后面的代码全部无法构建。

原因:graphics.h 是旧教学图形库,只在特定老 IDE 里内置,现代编译器默认不装。一份源码只要引用了它,换台机器就得先折腾库文件。

解决:对「先把象棋跑起来」的目标,最稳的是放弃图形库,改用第 2 章的控制台方案,零依赖编译。如果你确实要图形界面,就先把图形库装好并让编译器能找到头文件路径,这个问题只和编译环境有关,和你的象棋规则代码无关。判断一份源码是否易于复现,先看它有没有第三方依赖,依赖越少越容易跑通。

5.2 控制台中文全部乱码

现象:编译通过,运行后棋盘上的「楚河 汉界」和棋子名变成一串问号或乱码。

原因:源码文件是 UTF-8 编码,而 Windows 命令行默认代码页是 GBK(936),两边字符编码对不上。Linux 终端绝大多数是 UTF-8,所以同样代码在两个平台表现完全不同,这并不是代码逻辑问题。

解决:在 main 开头加 setlocale:

#include <stdio.h> #include <locale.h> int main(void) { setlocale(LC_ALL, ""); // 让宽字符输出跟随系统代码页 // 棋盘初始化、主循环 return 0; }

setlocale(LC_ALL, "") 会按当前系统区域设置切换字符处理方式。如果加了还乱,检查源码文件本身是不是按 UTF-8 保存的,IDE 默认编码不同会造成同样的症状。另一个常用办法是编译时加 -finput-charset=UTF-8 -fexec-charset=GBK,但两个平台写法不一致,不如 setlocale 通用。

5.3 将帅能斜着走、能飞出九宫

现象:测试时帅一步斜走到旁边格子吃了对方车;或者帅直接从底线走到河界对面。

原因:把帅的规则写成了国际象棋的「王」,只判断了横竖斜走一格,没有限制九宫。这是拿国际象棋思维写中国象棋规则时最典型的一个错。

解决:帅的合法移动必须同时满足:目标格在九宫之内、横竖走一格、不与对方将帅同列照面。九宫判断可以单独写一个小函数:

char p = b[m.fr][m.fc]; if (p >= 'A' && p <= 'Z') { // 红帅九宫:行 0..2,列 3..5 if (m.tr < 0 || m.tr > 2) return 0; if (m.tc < 3 || m.tc > 5) return 0; } else { // 黑将九宫:行 7..9,列 3..5 if (m.tr < 7 || m.tr > 9) return 0; if (m.tc < 3 || m.tc > 5) return 0; }

这个判断可以放在 legal_king 函数的开头,配合横竖一格和「王不见王」检查。九宫范围是硬编码,红黑双方各写一次,别用一个公式试图覆盖双方,容易出错。

5.4 输入「车二平五」永远提示非法走法

现象:照着棋谱的中文走法输入,程序一直提示非法,甚至直接卡死。

原因:scanf 处理中文加数字混合的字符串非常脆弱;更根本的问题是「车二平五」这种走法需要一套翻译逻辑:先根据「二」找到列号线,再根据「车」找到同列的车,再按平/进/退判断目标格。这套逻辑属于显示层和输入层的结合,不该让规则引擎承担。

解决:内部输入统一用行列坐标,先把引擎跑通。等你觉得有必要了,再写一个 translator 函数把「车二平五」解析成 Move 结构体,放在 parse_move 入口处转换。如果硬要支持中文,也要用 fgets 读整行再做字符串匹配,而不是 scanf 逐字段读。

5.5 悔棋一按就崩溃,或者棋盘纹丝不动

现象:走几步后点悔棋,程序直接退出,或者棋盘没有变回去。

原因:没写历史记录,悔棋无从下手;或者写了记录但只还原了起点没还原终点。后一种情况最隐蔽——棋子从终点回到了起点,但终点的被吃子没有恢复,棋盘上就「少」了一个子。

解决:用第 3 章的 History 栈做逆操作,给出 undo 函数:

void undo_move(char b[10][9]) { if (hist_len <= 0) return; // 边界:没棋可悔 hist_len--; History *h = &hist[hist_len]; b[h->m.fr][h->m.fc] = b[h->m.tr][h->m.tc]; // 起点放回原棋子 b[h->m.tr][h->m.tc] = h->captured; // 终点放回被吃子 }

逻辑说明:逆操作必须两行都做,只做第一行是「复制」不是「还原」。hist_len 减到负数会数组越界,所以先检查再操作。这个函数是检验你的历史栈设计是否合格的标尺:如果 apply_move 里没有保存 captured,这里就永远写不对。

6. 加一个能赢你的 AI:两层极小化搜索与评估函数

6.1 先写子力评估函数

AI 不需要很复杂,先把评估函数写对。评估函数给当前局面打分:红方优势为正,黑方优势为负。子力价值用整型常量,车比马炮贵,帅给极大值防止 AI 送帅。

// 棋子基础分:车 1000,马 600,炮 500,象 400,仕 400,兵 200,帅 10000 int piece_value(char p) { switch (tolower(p)) { case 'r': return 1000; case 'n': return 600; case 'c': return 500; case 'b': return 400; case 'a': return 400; case 'p': return 200; case 'k': return 10000; } return 0; } // 局面评分:正值代表红方优势 int evaluate(char b[10][9]) { int score = 0; for (int r = 0; r < 10; r++) { for (int c = 0; c < 9; c++) { char p = b[r][c]; if (p == '.') continue; int v = piece_value(p); if (p >= 'A' && p <= 'Z') score += v; // 红方棋子加 else score -= v; // 黑方棋子减 } } return score; }

逻辑说明:大小写的判定和第 3 章完全一致,红方正分黑方负分。这些分数可按你的棋感微调,比如过河兵可以额外加 50 分,被保护的车加 80 分,这类位置分能让棋风明显变化。

6.2 两层搜索就能应对新手

搜索的原理是「我走一步,再模拟你最好的应手」。深度为 2 的意思是:遍历我所有合法走法,对每个走法遍历对手所有合法走法,然后取对自己最有利的分支。这个两层搜索已经能发现「送车」这种明显的亏棋,对普通初学者有威胁,但还不凶。

// 返回 side 方走完一步后局面的评分(红方为正) int search(char b[10][9], int side, int depth) { if (depth == 0) return evaluate(b); int best = (side == RED) ? -100000 : 100000; for (int r = 0; r < 10; r++) { for (int c = 0; c < 9; c++) { char p = b[r][c]; if (p == '.') continue; if (side == RED && !(p >= 'A' && p <= 'Z')) continue; if (side == BLACK && !(p >= 'a' && p <= 'z')) continue; for (int tr = 0; tr < 10; tr++) { for (int tc = 0; tc < 9; tc++) { Move m = {r, c, tr, tc}; if (!is_legal_move(b, m, side)) continue; apply_move(b, m); int s = search(b, !side, depth - 1); undo_move(b); if (side == RED && s > best) best = s; if (side == BLACK && s < best) best = s; } } } } return best; }

逻辑说明:depth 为 0 时直接返回局面分。递归里 apply_move 和 undo_move 成对出现,这个历史和递归是配套的。best 初始值取正负十万,保证第一次比较必然更新。这个函数写完后,AI 走棋就是遍历所有走法,选 search(b, !side, 1) 最优的那一步。要更强就加深到 depth=4,并加上 alpha-beta 剪枝和走法排序——把能吃车的走法排在前面,剪枝效率至少翻一倍。

我第一次写这个 AI 时,评估函数只算了子力分,结果 AI 在局面优势的情况下会把马送到炮口换回一个卒,因为它觉得子力总值没亏。后来在评估函数里加了「兵过河 +50」「马在河沿 +30」这类位置修正,棋才下得像样。这个经历让我养成一个习惯:任何规则和评估的改动,都先拿一个残局局面去验证,而不是信手写几招就上。你的源码包也按这个顺序折腾一遍:先把规则引擎测到无可挑剔,再加上最小 AI,最后才考虑界面。这套代码如果一时调不通,从历史栈和规则入口两个位置查起,九成问题都在这两处。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询