简介:一套用C语言实现的中国象棋游戏完整源码,适合C语言初学者、程序设计课程学生及对棋类游戏开发感兴趣的开发者,用以理解数组、指针、函数调用、规则判断与简单AI等核心编程概念。压缩包共含85个文件,涵盖cpp源文件、h头文件、dsp/dsw工程文件、ico图标、cur光标、exe可执行程序以及txt说明文档等,结构完整,压缩后仅143KB,便于直接查看与编译运行。项目实现了棋盘棋子绘制、车马炮兵等棋子移动规则、吃子与合法性校验、胜负判定等,并提供命令行交互与可执行演示程序,包含棋力思考相关模块,可作课设或自学练手。目前已有997人学习下载,较适合需要完整参考源码而非零散代码片段的用户。
1. 中国象棋C语言源代码:能编译只是起点,能改得动才是门槛
拿到一份中国象棋C语言源代码,我的习惯是先翻走法生成和评估函数这两个文件,而不是急着编译。因为这两个位置决定了它是“能跑”还是“能玩”。不少公开的棋类源码要么只能在固定局面演示几步,要么搜索深度写死、参数没法调,改一行就要牵连三个文件。这份源码用二维数组建棋盘、按棋子类型拆分走法生成、搜索走Alpha-Beta剪枝,层次上比较完整,适合做C语言课程设计,也适合第一次接触博弈树搜索的读者当练手材料。下面从数据结构讲到搜索实现,再把我踩过的坑一并列出来。
2. 棋盘与棋子建模:二维数组、正负编码与走法生成的落地
2.1 为什么用 int board[10][9] 而不是位棋盘
中国象棋棋盘10行9列,一共90个交叉点。这份源码用的是最直白的表示法:一个二维数组装下全盘。
#define ROWS 10 #define COLS 9 int board[ROWS][COLS]; void init_board(void) { int r, c; for (r = 0; r < ROWS; r++) for (c = 0; c < COLS; c++) board[r][c] = 0; // 0 表示空位 }为什么不学国际象棋引擎用位棋盘(bitboard)?位棋盘的最大优势是并行检测快,一个64位整数能同时判定一行、一列甚至一条斜线上的棋子分布。但中国象棋需要90位才能覆盖整个棋盘,C标准里没有内置128位整数类型,用两个64位整数拼又要手动处理跨整数移位,代码复杂度直接上一个台阶。对于课程设计和学习向程序,位棋盘带来的性能收益远小于编码成本,所以我一般不建议在练手阶段上位棋盘。
二维数组的三个实际好处:第一,调试时打印局面只需要两重循环;第二,落子、吃子、悔棋都是读写单个下标,不容易出指针野访问;第三,搜索递归需要反复保存和恢复局面,int数组用内存拷贝就能完整复制,省心。需要注意行和列的方向约定。以红方视角为准,红方在下、黑方在上时,第0行是黑方底线,第9行是红方底线,列从左到右是0到8。这个约定影响后面所有走法生成和评估位置表,一开始统一好,后面不用返工。
2.2 棋子编码:正负号区分红黑,abs() 取类型
棋盘里每个值不只表示“有子”,还要同时表达“是什么子”和“属于哪一方”。这份源码采用带符号整数方案:红方为正,黑方为负。
enum { EMPTY = 0, RED_KING = 1, // 帅 RED_ADVISOR = 2, // 仕 RED_ELEPHANT = 3, // 相 RED_HORSE = 4, // 马 RED_ROOK = 5, // 车 RED_CANNON = 6, // 炮 RED_PAWN = 7, // 兵 BLACK_KING = -1, // 将 BLACK_ADVISOR = -2, BLACK_ELEPHANT = -3, BLACK_HORSE = -4, BLACK_ROOK = -5, BLACK_CANNON = -6, BLACK_PAWN = -7 };这套编码的直接收益有三个:判断阵营只看符号,p > 0 是红方,p < 0 是黑方;判断棋子类型只看绝对值 abs(p);吃子校验只需要判断“目标棋子符号与己方相反”,也就是两数相乘小于0,不用写一大串枚举比较。评估函数查子力价值表时也直接拿 abs(p) 当下标。
有教学代码会用两个数组分别记录颜色和类型,看起来更直观,但代价是搜索里每一处都要同时维护两个数组,吃子、悔棋、局面复制时必须保证两者同步,漏改一处就出“马能吃自己车”的玄学问题。我实际改过这种代码,调试两小时才发现是颜色数组没更新。统一成带符号单值编码后,局面复制只做一次内存拷贝,同步问题从根源消失。
2.3 走法生成:车的直线、炮的翻山、马的蹩腿
走法生成是棋类程序的第一个核心模块,负责给出某方当前所有合法落点。先看最直观的车:
typedef struct { int from_row, from_col; int to_row, to_col; } Move; void generate_rook_moves(int b[ROWS][COLS], int r, int c, Move *moves, int *cnt) { static const int dirs[4][2] = { {1, 0}, {-1, 0}, {0, 1}, {0, -1} }; int d, nr, nc; for (d = 0; d < 4; d++) { nr = r + dirs[d][0]; nc = c + dirs[d][1]; while (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS) { if (b[nr][nc] == 0) { // 空位:记录为可走点,继续沿方向扫描 moves[*cnt].from_row = r; moves[*cnt].from_col = c; moves[*cnt].to_row = nr; moves[*cnt].to_col = nc; (*cnt)++; } else { // 遇到棋子:是敌方则记录吃子,然后停止 if (b[nr][nc] * b[r][c] < 0) { moves[*cnt].from_row = r; moves[*cnt].from_col = c; moves[*cnt].to_row = nr; moves[*cnt].to_col = nc; (*cnt)++; } break; } nr += dirs[d][0]; nc += dirs[d][1]; } } }逻辑说明:四个方向逐格外扩,空位记一步并继续延伸,遇到棋子先判断敌我、记录吃子后立刻停止。参数说明:b是当前棋盘,r、c是车所在位置,moves是结果数组,cnt是走法数量的指针。所有走法生成函数复用同一个Move结构,后续搜索循环可以统一处理,不用为每种棋子写不同的调用接口。
炮的走法要比车多一个“翻山状态”:同方向扫描时,翻山之前只能平移不能吃子,翻过第一座山之后只能吃子不能平移。马的走法要检查蹩马腿:马跳日字时,起跳方向相邻的横或纵点若被占则不能跳。象要检查塞象眼,同时不能过河。兵卒在过河前后方向不同,红兵在己方半场只能前进,过河后才能横走;黑卒方向完全镜像。将帅只能在九宫三路直格内移动,而且两帅不能照面。
写走法生成时我强烈建议按“车 → 炮 → 马 → 象 → 士 → 将 → 兵”的顺序逐类验证,每类单独跑边界用例,而不是一次性写完再联调。因为炮的翻山和马的蹩脚最容易漏分支,等程序能跑起来了再排查,根本分不清是生成器问题还是搜索问题。
2.4 将军判定与合法走法过滤
走法生成完毕之后还要做一道过滤:走完会让自己被将军的走法必须剔除,否则会出现“送了帅还能继续下”的荒谬局面。
int is_in_check(int b[ROWS][COLS], int side) { int r, c, target; // 先定位己方将帅的位置 target = (side > 0) ? RED_KING : BLACK_KING; for (r = 0; r < ROWS; r++) for (c = 0; c < COLS; c++) if (b[r][c] == target) return is_attacked(b, r, c, -side); return 1; // 将帅不在棋盘,视为已被将死 }逻辑说明:找到己方将帅坐标后,调用反向攻击检测,判断对方任意一枚棋子是否能一步到达该位置。参数说明:side为1表示红方,-1表示黑方;返回1表示当前被将军。is_attacked的实现可以直接复用走法生成器,也可以写一套精简的反向攻击检测,只判断“是否攻击到目标点”,不必真的生成完整走法列表。
过滤走法的位置一般在搜索主循环里:make_move 之后立即调 is_in_check,如果当前走棋方被将军,就 unmake_move 并跳过这个走法。这样能保证搜索树上每个合法节点都是“走完不出事”的安全局面。
3. 评估与搜索:电脑怎么从“能走”变成“会下”
3.1 评估函数:子力价值与位置权重怎么配
电脑判断局面好不好,靠的是评估函数。最简单的版本只算子力总和,这份源码预设了一套参考分值:
static const int piece_value[8] = { 0, // 空位 10000, // 帅/将:被吃即输,分值必须远大于其他子力之和 120, // 士 120, // 象 270, // 马 600, // 车 285, // 炮 30 // 兵/卒 }; int evaluate(int b[ROWS][COLS]) { int score = 0; int r, c; for (r = 0; r < ROWS; r++) { for (c = 0; c < COLS; c++) { int p = b[r][c]; if (p == 0) continue; score += (p > 0) ? piece_value[p] : -piece_value[-p]; } } return score; // 正值表示红方占优 }逻辑说明:遍历全盘,红方棋子加正值,黑方棋子加负值,最终结果从红方视角解读。搜索层里约定红方是最大值方,黑方是最小值方。参数说明:piece_value的下标就是棋子编号的绝对值,帅设成10000是因为它一旦被吃游戏直接结束,这个分值必须压过所有子力总和,否则搜索可能为了保住一个车而放弃防守帅。
光有子力评估的电脑很“木”,它不知道同样的车站在底线和站在对方卒林线是完全不同的价值。进阶做法是加位置权重表:给每类棋子准备一张10行9列的加分表,走到特定位置就加对应奖励。红方查原表,黑方查行坐标镜像后的表。位置表不必设计得很精细,优先处理两个点:马的位置(别被蹩腿,能往中心靠)和车的开放线(没有被自家兵卒挡住的纵线)。这两项做好,棋力提升肉眼可见。
3.2 Alpha-Beta 剪枝的递归实现
评估函数只是静态打分,真正决定走哪一步的是搜索。核心是极大极小搜索配合剪枝:
#define INF 100000000 int alpha_beta(int b[ROWS][COLS], int depth, int alpha, int beta, int side) { Move moves[MAX_MOVES]; int cnt = 0, i, score; if (depth == 0) return evaluate(b); // 到达预设深度,静态评估 generate_all_moves(b, side, moves, &cnt); if (cnt == 0) { // 无走法可走:被将死(己方被将军)或困毙 return (side > 0) ? -INF : INF; } for (i = 0; i < cnt; i++) { make_move(b, moves[i]); // 负极大值形式:换边后视角取负号 score = -alpha_beta(b, depth - 1, -beta, -alpha, -side); unmake_move(b, moves[i]); if (score > alpha) { alpha = score; if (alpha >= beta) break; // beta 剪枝 } } return alpha; }逻辑说明:递归每层换边,返回值始终从当前走棋方的视角计算,所以每层递归都取负号。剪枝发生条件是“当前节点已知下界alpha已经不小于上界beta”,此时这棵子树继续搜索不可能影响最终决策。参数说明:depth是剩余搜索深度,alpha初始为-INF,beta初始为INF;side为1表示红方走棋,-1表示黑方走棋。
make_move和unmake_move必须严格配对。make_move把棋子从from挪到to,被吃掉的目标棋子要存进一个临时栈;unmake_move执行反向操作,把被吃棋子放回原位。最容易出问题的是吃子路径:只移动了进攻子没恢复被吃子,搜索几层之后棋盘上棋子越来越少,甚至出现两个帅同时存在的诡异画面。我用这个坑写过一次很深刻的调试,之后每次都是在make_move里push、unmake_move里pop,强制配对。
3.3 搜索参数:深度、走法排序与将军延伸
剪枝效果极其依赖走法搜索顺序。如果每次第一个搜的就是最优走法,剪枝率会非常高;如果顺序很差,搜索近乎全量展开。最常见的优化是把吃子走法排到前面:
int move_priority(const Move *m, int b[ROWS][COLS]) { int captured = b[m->to_row][m->to_col]; if (captured == 0) return 0; // 吃子优先,且被吃子价值越高越优先 return 10000 + piece_value[abs(captured)]; }逻辑说明:吃子走法先搜,能更快地抬高alpha值,后面所以走法被剪掉的概率更大。参数说明:m是待评估走法,b是当前棋盘。如果你发现同一深度下剪枝率低于40%,基本就是没做走法排序。
深度设置上,纯C终端程序建议默认4层。4层意味着电脑模拟“我走、你走、我再走、你再走”四步,绝大多数互吃陷阱在这个深度下都能被识别。直接调成6层不是不行,但没有排序优化时搜索规模指数膨胀,单步思考可能从一秒以内飙到十几秒。比加深度更划算的是做将军延伸:检测到当前局面存在将军时,强制多搜一层,因为被将军时可选走法很少,额外开销有限但能显著避免“送将”类昏招。我一般把搜索流程写成:先走法排序,再检测将军做延伸,最后才是调深度。
4. 避坑指南:C语言写中国象棋常踩的五个典型问题
4.1 马的日字跳出棋盘边界,程序崩溃
现象:运行到某一回合程序直接段错误,gdb定位到走法生成函数,报数组越界。原因:马走“日”字,目标坐标由当前坐标加减1或2得到,很多实现只检查目标坐标是否越界,没检查蹩脚位置是否越界,或者先访问数组后判断下标,顺序写反。解决:在访问 b[nr][nc] 之前先把 nr、nc 全量检查一遍,范围必须同时满足 0 <= nr < 10 和 0 <= nc < 9。固定写法是生成器函数第一段先写边界检查,再写蹩腿检查,最后才读写棋盘。
4.2 炮的翻山逻辑写错
现象:炮隔一个子吃不掉对方,或者隔两个子反倒能吃掉。原因:炮扫描需要记录“是否已经翻过一座山”的状态,常见实现里用一个变量标记翻山状态,但变量定义在方向循环外,换方向时没有重置。解决:翻山状态变量必须在每个方向循环的内部声明并初始化,每换一个方向必须归零。另一个容易错的点是炮在不翻山时只能平移不能吃子,翻山之后只能吃子不能平移,这两个分支要分开写,不要用同一套条件取反。
4.3 make_move 和 unmake_move 不配对,局面越搜越少
现象:搜索深度超过3层后,棋盘上的棋子数量明显减少,有时同一方出现两个帅,或者对方棋子神秘消失。原因:make_move 吃子后没有把被吃子压入恢复栈,unmake_move 只恢复了进攻子的原位置,被吃子没有放回去。更深一层的原因是局面深拷贝和恢复用了结构体赋值,但结构体内部含指针或变长数组,复制不完整。解决:把被吃子写进 Move 结构体或者独立的棋局栈,make_move 压栈、unmake_move 弹栈,严格配对。我后来习惯在每次 make_move 后立刻用 assert 验证棋盘总棋子数,恢复后再次断言,能在调试期快速暴露不配对问题。
4.4 命令行下中文字符乱码
现象:Linux 终端编译后棋盘上的“帅”“车”“炮”显示成乱码方块,Windows 的 cmd 下则是另一套乱码。原因:源文件用 UTF-8 保存,但终端默认按 GBK 或 Latin1 解释;或者用 char 数组存了多字节汉字,printf 按字节输出导致错位。解决:命令行版直接用纯 ASCII 字符画棋盘最稳,例如大写字母表示红方、小写字母表示黑方,R/r 表示车、H/h 表示马、C/c 表示炮、K/k 表示将帅。如果你一定要显示汉字,主需要做平台相关编码转换,但跨平台的课程设计没必要在这个上耗时间。ASCII 棋盘还能顺便避免后续做自动对弈、日志回放时遇到编码问题。
4.5 将军与将死判定时机不对
现象:电脑在己方帅已经被吃后仍继续走棋,或者明明已经被将死却不认输,继续下好几步。原因:搜索终止条件只写了“走法列表为空时返回”,没覆盖“帅已经被吃”的情况。将死的检测只处理了“无子可走”,但某些情况下帅被吃后走法列表可能仍然不为空。解决:把“将帅不在棋盘”作为极端优势值处理,在评估函数和搜索终止处双重判断。更稳妥的做法是走法过滤阶段就拦截:make_move 之后立即调用 is_in_check,若当前走棋方被将军则跳过这个走法。这样即使某一步把帅送出去了,搜索也不会在这条线上继续展开。
5. 验证与扩展:从跑通到电脑真正走出水平
5.1 三个自测步骤
第一,手动对弈验证基础规则:进入人机对战后,先用红方故意走非法走法,程序必须拒绝;再验证每个棋子的移动范围和吃子边界。第二,走法计数测试:在开局局面下调用 generate_all_moves 统计红方第一步的走法数量,中国象棋开局红方第一步合法走法总数为44,用这个数字校验走法生成器有没有漏掉分支。第三,搜索稳定性测试:同一局面给定相同深度,连续运行5次应得到完全相同的走法,如果结果变来变去,基本可以断定是局面复制或恢复逻辑有状态残留。
5.2 值得优先做的三个扩展
从这份源码继续往上有三个性价比比较高的方向。第一个是开局库,把常见的开局走法硬编码成开局阶段前几步直接查表,代码量小但对棋力提升明显。第二个是置换表,把已经搜索过的局面和结果缓存到哈希表中,搜索到相同局面直接查询,避免重复计算。哈希时要处理Zobrist键的增量更新,这是进深一步的好练习题。第三个是图形界面,用 GTK 或 SDL 把 ASCII 棋盘替换成真正的棋子图片,核心逻辑不变,只改输入输出层,适合做课程答辩的展示亮点。
从拿到这份源码到真正改出自己的对战程序,我最后保留的习惯是每次改动后强制跑一遍开局走法计数测试。无论改的是评估函数还是搜索深度,44这个固定数字能一键暴露走法生成器是否被改坏。那次因为改评估顺手动了方向数组、结果把车的四个方向减成三个的教训,让我此后每次都把断言写在测试脚本里,而不是靠肉眼对局发现。希望帮到你。
本文还有配套的精品资源,点击获取