简介:这份资源是一套用C语言编写的中国象棋游戏完整源代码,面向希望以棋类项目巩固C语言基础、锻炼逻辑思维的初学者与进阶开发者。代码围绕棋盘二维数组建模、各棋子走法规则、吃子与走法合法性判断、将军与将死等结束条件展开,并实现命令行交互,同时预留AI搜索、图形界面与棋谱记录回放等扩展方向。压缩包共85个文件,约143KB,以38个ico图标、14个h头文件、10个cpp源文件为主,另含cur光标、txt说明、dsp与dsw工程文件及可执行程序,工程结构完整,可直接编译运行。目前已有997人学习下载。借助这套源码,读者能对照实现细节理解数据结构与算法在棋类游戏中的落地方式,并在此基础上尝试加入Minimax搜索或图形化改造,是学习C语言项目实践的实用参考。
1. 一份能直接编译运行的中国象棋 C 语言源码,到底能拿来做什么
很多开发者第一次接触棋类 AI,都是从一份能跑起来的完整源码开始的。这份中国象棋 C 语言源代码,核心价值不在于界面多华丽,而在于它把棋盘表示、走法生成、局面评估、搜索算法这条完整链路用纯 C 写透了。你拿到手就能编译,跑起来就能跟电脑对弈,改几行参数就能调整 AI 强度。它适合两类人:一类是刚学完 C 语言数据结构、想找个真实项目练手的开发者;另一类是想搞懂棋类 AI 搜索机制、但不想被庞大工程劝退的熟手。整个项目没有第三方依赖,标准 C 库就能编译,这在今天动辄拉一堆包的环境里反而成了优点。
2. 项目架构拆解:棋盘、走法、评估、搜索四层怎么串起来
2.1 棋盘表示与数据结构选型
这类项目最常见也最稳妥的做法,是用一个一维或二维整型数组存棋盘。一维数组的好处是索引计算简单,index = row * 9 + col,走法生成时偏移量直接加减,不用处理二维数组的行列边界判断。棋子编码一般用正负号区分红黑,绝对值表示兵种,比如1是帅/将,2是仕/士,3是相/象,4是马,5是车,6是炮,7是兵/卒。这样判断敌我只需要看符号,判断兵种只需要看绝对值,代码里到处都是这种小技巧。
// 棋盘定义:10 行 9 列,一维数组存储 #define BOARD_ROWS 10 #define BOARD_COLS 9 #define BOARD_SIZE (BOARD_ROWS * BOARD_COLS) // 棋子编码:正数红方,负数黑方,绝对值表示兵种 // 1=帅/将 2=仕/士 3=相/象 4=马 5=车 6=炮 7=兵/卒 int board[BOARD_SIZE]; // 索引转换:行列转一维下标 static inline int pos(int row, int col) { return row * BOARD_COLS + col; } // 判断某位置是否在棋盘内 static inline int in_board(int row, int col) { return row >= 0 && row < BOARD_ROWS && col >= 0 && col < BOARD_COLS; }这段代码里pos和in_board是两个高频调用的内联函数,走法生成时每走一步都要判断目标位置是否合法。用static inline是为了避免函数调用开销,在搜索深度上去之后,这点优化会累积成可观的性能差异。棋盘数组直接开全局,省去传参,这在棋类程序里是常见做法,因为整个程序生命周期只有一个棋盘实例。
2.2 走法生成:每个兵种一套规则
走法生成是整个项目里代码量最大、也最容易写错的部分。每个兵种都要单独处理,而且红黑方向相反,边界条件各不相同。常见做法是写一个generate_moves函数,遍历棋盘上所有己方棋子,对每个棋子调用对应的生成逻辑,把合法走法存进一个走法数组。
// 走法结构:起点、终点、被吃棋子 typedef struct { int from; int to; int captured; } Move; // 马走法偏移:8 个方向,先判断蹩马腿 static const int horse_dr[8] = {-2, -2, -1, -1, 1, 1, 2, 2}; static const int horse_dc[8] = {-1, 1, -2, 2, -2, 2, -1, 1}; // 对应的马腿位置偏移 static const int leg_dr[8] = {-1, -1, 0, 0, 0, 0, 1, 1}; static const int leg_dc[8] = {0, 0, -1, 1, -1, 1, 0, 0}; void gen_horse_moves(int r, int c, int side, Move *moves, int *count) { for (int i = 0; i < 8; i++) { int nr = r + horse_dr[i]; int nc = c + horse_dc[i]; if (!in_board(nr, nc)) continue; // 蹩马腿判断:马腿位置有棋子则不能走 int lr = r + leg_dr[i]; int lc = c + leg_dc[i]; if (board[pos(lr, lc)] != 0) continue; int target = board[pos(nr, nc)]; // 目标位置不能是己方棋子 if (target * side > 0) continue; moves[*count].from = pos(r, c); moves[*count].to = pos(nr, nc); moves[*count].captured = target; (*count)++; } }马的走法有两个关键点:一是八个方向的偏移量要跟马腿偏移量一一对应,写反了就会出现“马穿墙”的玄学 bug;二是蹩马腿判断必须在目标位置判断之前,因为马腿被蹩时无论目标是什么都不能走。炮的走法更特殊,需要单独处理“翻山”逻辑:沿直线扫描,遇到第一个棋子后继续找第二个棋子,第二个如果是敌方才能吃。兵/卒过河前只能前进,过河后可以左右移动,这个状态判断依赖当前行号。
2.3 局面评估函数:子力价值加位置修正
评估函数决定了 AI 的“棋风”。最简单的做法是只算子力价值,车 9 分、马 4 分、炮 4.5 分、兵 1 分,仕相 2 分。但这样 AI 会显得很笨,因为它不知道棋子该往哪放。常见改进是加位置分表,比如马在中心比在边线得分高,兵过河后加分,车占开放线加分。
// 基础子力价值(红方视角,黑方取负) static const int piece_value[8] = {0, 10000, 200, 200, 400, 900, 450, 100}; // 兵过河后的位置奖励:越靠近对方底线越高 static const int pawn_bonus[BOARD_ROWS] = { 0, 0, 0, 0, 10, 20, 30, 40, 50, 60 }; int evaluate() { int score = 0; for (int i = 0; i < BOARD_SIZE; i++) { int p = board[i]; if (p == 0) continue; int abs_p = p > 0 ? p : -p; int val = piece_value[abs_p]; // 兵/卒过河奖励 if (abs_p == 7) { int row = i / BOARD_COLS; if (p > 0 && row <= 4) val += pawn_bonus[row]; if (p < 0 && row >= 5) val += pawn_bonus[9 - row]; } score += (p > 0 ? val : -val); } return score; }评估函数返回的是红方视角的分数,搜索层用 negamax 时每一层都要取负。这里有个血泪经验:子力价值别用太小的数,否则位置分容易盖过子力差异,AI 会为了占个好位置白丢一个车。我一般把车定在 900 以上,兵定在 100,位置奖励控制在子力价值的 10% 以内。
2.4 搜索算法:Alpha-Beta 剪枝与迭代加深
搜索层是整个 AI 的大脑。最基础的是极小极大搜索,但节点数随深度指数增长,不加剪枝根本跑不动。Alpha-Beta 剪枝能把有效分支砍掉一半以上,配合走法排序效果更明显。迭代加深则是从深度 1 开始搜,逐层加深,好处是随时可以按时间中断,返回当前最优解。
// Alpha-Beta 搜索,depth 为剩余深度 int alphabeta(int depth, int alpha, int beta, int side) { if (depth == 0) return evaluate() * side; Move moves[128]; int count = 0; generate_all_moves(side, moves, &count); // 走法排序:吃子走法优先,提高剪枝效率 sort_moves(moves, count); int best = -INF; for (int i = 0; i < count; i++) { make_move(&moves[i]); int score = -alphabeta(depth - 1, -beta, -alpha, -side); unmake_move(&moves[i]); if (score > best) best = score; if (score > alpha) alpha = score; if (alpha >= beta) break; // 剪枝 } return best; }make_move和unmake_move必须严格配对,前者修改棋盘并返回被吃棋子信息,后者恢复。这里翻车最多的地方是忘记恢复被吃棋子,导致搜索几层后棋盘上棋子凭空消失。走法排序用简单的“吃子价值高者优先”就能显著提升剪枝率,不需要上复杂的置换表。搜索深度一般设 4 到 6 层,再深在普通机器上等待时间会超过 3 秒,体验反而下降。
3. 从源码到可执行文件:编译、运行与参数调整
3.1 编译环境与命令
项目没有第三方依赖,Linux 下用 gcc 直接编译即可。Windows 下用 MinGW 或 Visual Studio 建空项目把源文件加进去也能编。常见做法是写一个简单的 Makefile,把源文件列表和编译选项固定下来。
# Linux / macOS 下编译 gcc -O2 -o chess main.c board.c movegen.c evaluate.c search.c # 如果源文件较多,用通配符 gcc -O2 -o chess *.c -lm # Windows MinGW gcc -O2 -o chess.exe main.c board.c movegen.c evaluate.c search.c-O2优化对搜索性能影响很大,实测能提升 30% 到 50% 的节点搜索速度。-lm是链接数学库,如果代码里用了abs之外的数学函数需要加上。编译报错最常见的是函数隐式声明,检查头文件是否都 include 了,以及函数定义顺序是否在调用之前。
3.2 运行流程与交互方式
程序启动后一般先初始化棋盘,然后进入主循环:打印棋盘、等待玩家输入、校验走法、执行走法、调用 AI 搜索、执行 AI 走法、判断胜负。输入格式常见的是“起点坐标 终点坐标”,比如7 1 7 4表示把炮从第 8 行第 2 列移到第 8 行第 5 列。坐标从 0 开始还是从 1 开始要看源码里的约定,不一致会导致“明明能走却提示非法”。
// 主循环简化示意 void game_loop() { init_board(); int side = 1; // 红方先走 while (1) { print_board(); if (side == 1) { // 人类玩家输入 int from_r, from_c, to_r, to_c; scanf("%d %d %d %d", &from_r, &from_c, &to_r, &to_c); Move m = {pos(from_r, from_c), pos(to_r, to_c), 0}; if (!is_legal_move(&m, side)) { printf("非法走法,请重新输入\n"); continue; } make_move(&m); } else { // AI 搜索最佳走法 Move best = search_best_move(side, 5); make_move(&best); } if (is_checkmate(-side)) { printf("%s 方获胜\n", side == 1 ? "红" : "黑"); break; } side = -side; } }is_legal_move需要做两层校验:先检查走法是否符合兵种规则,再检查走完后己方是否被将军。很多新手写的版本只做了第一层,结果 AI 会走出“送帅”的走法。is_checkmate判断对方是否无子可走且被将军,注意困毙(无子可走但未被将军)在象棋里也是输棋。
3.3 调整 AI 强度的几个参数
想让 AI 变强或变弱,改三个地方就够了。搜索深度在search_best_move的调用参数里,从 3 改到 6 会明显变强但耗时增加。评估函数里的子力价值表可以调,把车的价值从 900 调到 1000,AI 会更舍不得丢车。走法排序的权重也可以调,吃子优先的权重越高,剪枝越激进,但可能漏掉一些安静的好棋。
提示:改搜索深度后一定要实测每步耗时,深度 6 在低配机器上可能超过 5 秒,玩家会以为程序卡死。
4. 常见问题排查:编译报错、走法异常、AI 卡死怎么查
4.1 编译时报“未定义引用”
现象:链接阶段报undefined reference to 'evaluate'之类的错误。原因通常是函数声明了但没实现,或者实现所在的源文件没加入编译命令。解决:检查 Makefile 或编译命令里的源文件列表,确认每个声明的函数都有对应的.c文件,并且该文件在编译列表里。如果函数在头文件里声明为static,那它只能在定义它的文件里用,跨文件调用要去掉static。
4.2 走法生成出现“马穿墙”或“炮隔空吃子”
现象:马从棋盘一侧跳到另一侧,或者炮中间没有炮架却吃掉了对方棋子。原因:马的八个方向偏移量和马腿偏移量没有严格对应,或者炮的扫描逻辑在遇到第一个棋子后没有正确切换到“找第二个棋子”的状态。解决:把马的偏移量打印出来逐一核对,确保每个方向的马腿位置在起点和目标点之间。炮的逻辑用两个标志位,found_screen为假时遇到棋子置真,为真时遇到棋子判断敌我。
4.3 AI 思考时间过长或程序无响应
现象:玩家走完后程序卡住,长时间不返回。原因:搜索深度设得太高,或者 Alpha-Beta 剪枝没生效导致节点爆炸。解决:先用深度 3 测试,确认能正常返回再逐步加深。检查剪枝条件alpha >= beta是否写对,以及走法排序是否真的把吃子走法排在了前面。如果用了迭代加深,加一个时间上限,超时直接返回当前层最优解。
4.4 棋盘显示错位或棋子符号混乱
现象:打印出来的棋盘行列对不齐,或者红黑棋子符号反了。原因:printf格式化时中文字符宽度和英文字符不一致,或者棋子编码的正负号判断写反。解决:用等宽字体,或者把棋子符号换成单个英文字母(如K代表帅,k代表将)。正负号判断统一用p > 0表示红方,p < 0表示黑方,不要混用。
4.5 将军判断遗漏导致 AI 走出非法棋
现象:AI 走完后己方帅/将被对方吃掉,但程序没有判负。原因:is_legal_move只检查了兵种走法规则,没有模拟走完后检查己方是否被将军。解决:在is_legal_move里先make_move,然后调用is_king_attacked检查己方帅/将是否处于被攻击状态,最后unmake_move恢复。这个检查会增加一些开销,但合法性必须保证。
5. 二次开发改造:换评估函数、加开局库、接图形界面
5.1 替换评估函数提升棋力
默认评估函数只考虑了子力和简单位置分,改造成本最低、效果最明显的是加“棋子间协同”评分。比如车和马配合、炮和兵配合,可以给额外加分。做法是在evaluate里遍历己方棋子,对特定组合查表加分。
// 协同加分示例:车马配合 int synergy_bonus() { int bonus = 0; for (int i = 0; i < BOARD_SIZE; i++) { if (board[i] != 5 && board[i] != -5) continue; // 只看车 for (int j = 0; j < BOARD_SIZE; j++) { if (board[j] != 4 && board[j] != -4) continue; // 只看马 if (board[i] * board[j] > 0) { // 同方 int dist = abs(i / 9 - j / 9) + abs(i % 9 - j % 9); if (dist <= 3) bonus += 15; // 车马靠近加分 } } } return bonus; }这个函数直接加进evaluate的返回值里,注意红黑视角要对称。改完后用同一局面反复测试,看 AI 选点是否更合理。我一般会存几个典型中局局面,改一次评估就跑一遍,对比 AI 的选点变化,避免改出“负优化”。
5.2 加开局库避免千篇一律
搜索深度有限时,AI 开局几步容易重复。加一个简单开局库,用哈希表存“局面哈希 -> 最佳走法”,开局阶段先查库,查不到再搜索。局面哈希用 Zobrist 哈希,每个棋子在每个位置对应一个随机数,异或起来就是当前局面哈希。
// Zobrist 哈希表初始化 unsigned long long zobrist[BOARD_SIZE][15]; void init_zobrist() { srand(20240501); for (int i = 0; i < BOARD_SIZE; i++) for (int j = 0; j < 15; j++) zobrist[i][j] = ((unsigned long long)rand() << 32) | rand(); } // 计算当前局面哈希 unsigned long long compute_hash() { unsigned long long h = 0; for (int i = 0; i < BOARD_SIZE; i++) { if (board[i] == 0) continue; int idx = board[i] > 0 ? board[i] : board[i] + 8; h ^= zobrist[i][idx]; } return h; }开局库可以用文本文件存,每行“哈希值 起点 终点”,程序启动时加载进内存。注意哈希冲突概率虽然低,但开局库条目少的时候可以直接用完整局面字符串做 key,省去哈希计算。
5.3 接图形界面或网络对战
控制台版本跑通后,想接图形界面,最省事的是用 SDL2 或 EasyX 画棋盘和棋子。把print_board换成绘制函数,把scanf输入换成鼠标点击坐标转换。核心搜索逻辑完全不用动,只改输入输出层。如果想做网络对战,把make_move前后的棋盘状态序列化,通过 socket 发给对方,对方反序列化后重建棋盘。注意网络传输要加校验,防止非法走法。
注意:接图形界面后,AI 搜索如果放在主线程会阻塞界面刷新,常见做法是开一个单独线程跑搜索,搜完后通过消息通知主线程更新棋盘。
5.4 一个验证改造效果的小技巧
每次改完评估函数或搜索参数,别凭感觉判断好坏。我习惯用“自对弈”跑 20 局,让新旧两个版本互相对战,统计胜率。如果新版胜率明显高于 50%,说明改动有效;如果低于 50%,大概率是改出了负优化。自对弈时关掉开局库随机性,保证同一局面可复现。这个习惯帮我省了很多次“感觉变强了其实变弱了”的后悔药。从那以后我每次改评估函数都强制走一遍自对弈验证,再决定要不要保留改动。希望帮到你。
本文还有配套的精品资源,点击获取