2026/10/15 12:28:53

C语言实现控制台中国象棋:从二维数组到AI博弈搜索的完整实战解析

C语言实现控制台中国象棋:从二维数组到AI博弈搜索的完整实战解析 最近整理代码仓库时翻到之前用 C 语言写的中国象棋小项目重新跑了一遍当年调规则调到头秃的场面又浮现在眼前。C/C 编程笔记这个系列我一直觉得控制台象棋是新手进阶绕不开的经典练手项目它不依赖图形库不需要第三方框架却能把二维数组、状态建模、函数拆分、递归搜索这些 C 语言的核心功底全部过一遍。这个项目做的东西其实很明确——一个能在命令行里完成对弈的中国象棋程序支持双方轮流走子、吃子、将军判断、胜负判定甚至还可以扩展一个简单的人机 AI。通篇我会把设计思路、关键代码片段、最容易踩的坑一次讲完给正在写棋类项目或者准备拿 C 语言练手的同学做个参考。1. 项目概览与整体设计思路1.1 为什么选 C 语言做象棋练手很多朋友一上来就想着用图形库做三国杀、做吃鸡但棋类游戏恰恰是 C 语言练功的好载体。棋类项目的核心难点不在画面而在规则判定和状态管理。中国象棋的规则足够丰富车炮兵马士象将各有各的走法蹩马腿、塞象眼、炮打隔子、将帅照面这些细节任何一个地方漏掉都会让对局变得荒诞。这种复杂度对新手非常友好——它不会难到让你放弃但又能逼着你认真设计数据结构。用 C 语言来做还有一层别的好处C 没有类和对象所有状态都得靠结构体、数组、函数手动组织。你没法靠class ChessBoard把一切都藏起来必须直面“棋盘就是一个 10 行 9 列的二维数组”这个朴素事实。等你把这个项目写顺了回头再去学 C 的封装、再去看设计模式会有一种“原来这些抽象是因为这些问题才出现的”顿悟感。我建议第一次做直接用控制台程序别碰图形界面。SDL、EGE 这类库虽然能让棋子变得好看但会分散注意力你在折腾窗口事件和贴图时规则代码反而写得稀烂。控制台版本跑通后面想加图形也只是数据层不动、换表现层的事。1.2 架构分层数据、规则、表现分离我写这个项目时吃过一次亏一开始把打印棋盘、判断规则、处理输入全堆在main函数里结果每一处改动都会引发连锁错误。后来重新整理强制自己分成四层数据层棋盘数组、当前走棋方、双方将帅位置统一塞进ChessState结构体。规则层每个棋子的走法合法性判断、将军检测、胜负判断。表现层打印棋盘、解析玩家输入只跟数据层打交道。策略层可选AI 走棋搜索。分层后的好处非常明显。AI 想走棋时它只需要调规则层提供的合法走法生成函数打印棋盘时只需要读数据层里的棋盘数组。每一层都像一个独立的小工具出了问题可以单独测试。这比“一个函数干所有事”的写法优雅太多也方便后续扩展悔棋、存档、网络对战。1.3 坐标约定的第一决策开写之前必须先定一个坐标方案否则后面全乱。我推荐一种很多实现都在用的约定数组下标 0 在屏幕顶部代表黑方一侧下标 9 在屏幕底部代表红方一侧。也就是说黑将的位置是(0, 4)红帅的位置是(9, 4)。列坐标统一用 0 到 8对应棋盘的 9 条列线。这么定的原因很简单你打印棋盘时直接按行从 0 到 9 输出显示效果就是上面黑方、下面红方跟真实棋盘视角一致不需要做任何坐标翻转。红方走棋方向是 row 减少黑方走棋方向是 row 增加。很多新手栽在“方向反了”的问题上根源往往是坐标约定没在一开始锁死。2. 核心数据结构与棋盘建模2.1 棋盘二维数组与棋子编码棋盘我直接用固定大小的二维数组#define BOARD_ROWS 10 #define BOARD_COLS 9 typedef struct { int board[BOARD_ROWS][BOARD_COLS]; int side; // 当前走棋方1 表示红方-1 表示黑方 int red_king_r, red_king_c; // 红帅位置 int black_king_r, black_king_c; // 黑将位置 } ChessState;为什么不动态分配因为棋盘就 90 个格子固定数组最简单直接不会出现指针错误和内存泄漏。等你以后要做引擎优化再考虑一维数组或位棋盘也不迟。棋子编码用正负数区分阵营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 };这套编码的精妙之处在于判断一个格子里的棋子是不是当前走棋方的只需要做一次乘法if (s-board[r][c] * s-side 0) { // 这是己方棋子 }因为红方 side 是 1黑方 side 是 -1己方棋子乘以 side 永远为正敌方棋子永远为负。别小看这个设计规则判定里到处都要判断敌我有一个统一的数学式会省掉大量if (side RED)分支。2.2 方向表马脚、象眼、车炮路径象棋里很多棋子都有固定方向的偏移最典型的是马和象。我强烈建议用方向表而不是在函数里堆if-else。typedef struct { int dr, dc; } Delta; // 马走日的 8 个目标点 static const Delta horse_deltas[8] { {-2,-1}, {-2,1}, {-1,-2}, {-1,2}, {1,-2}, {1,2}, {2,-1}, {2,1} }; // 对应的蹩马腿位置 static const Delta horse_leg_deltas[8] { {-1,0}, {-1,0}, {0,-1}, {0,1}, {0,-1}, {0,1}, {1,0}, {1,0} }; // 象走田的 4 个目标点 static const Delta elephant_deltas[4] { {-2,-2}, {-2,2}, {2,-2}, {2,2} }; // 对应的塞象眼位置 static const Delta elephant_eye_deltas[4] { {-1,-1}, {-1,1}, {1,-1}, {1,1} };注意马腿表里有些位置是重复的。比如马从(-2,-1)和(-2,1)两个方向跳的时候蹩腿位置都是(-1,0)。这不是 bug因为马腿就是马前进方向的正前方那一格方向不同时蹩腿位置自然可能重合。用方向表的理由有两个一是代码短一目了然二是方便排查。如果你用一串if (r fr - 2 c fc - 1)去判断改起来容易漏而方向表只是遍历数组每个偏移查一下边界和目标就行了。2.3 边界辅助函数棋类程序里所有走法都要做边界检查抽成公共函数能少写很多重复判断static int in_board(int r, int c) { return r 0 r 10 c 0 c 9; } static int in_palace(int r, int c, int side) { if (side 1) // 红方九宫在下方 7~9 行 return r 7 r 9 c 3 c 5; else // 黑方九宫在上方 0~2 行 return r 0 r 2 c 3 c 5; }配合前面“屏幕顶部黑方、底部红方”的坐标约定九宫判断写起来非常对称。红方九宫在底部三行黑方九宫在顶部三行列的区间都是 3 到 5。类似地象过河判断也可以写两个小函数红象行号必须 5黑象行号必须 4。虽然只是两个条件但放到语义明确的函数里读代码时不需要反应半天。3. 规则裁决与走法生成3.1 各棋子的合法走法拆解规则层是整个项目的灵魂。我先把每个棋子的走法逻辑拆开说明再给出一个统一的入口函数。士/仕斜走一格且目标必须在九宫范围内。判断条件就是行差和列差的绝对值都是 1。象/相走田字行差和列差的绝对值都是 2同时象眼位置必须为空且不能越过河界。马走日字从方向表取目标点然后检查对应马腿位置是否为空。车行相同或列相同中间所有格子必须为空。炮移动时和车一样中间不能有子吃子时中间必须恰好有一个炮架。兵/卒红兵只向上走未过河时不能横移过河后才能横移一格永远不能后退。黑卒方向相反。将/帅只能在九宫内横竖走一格另外还要检查将帅照面。炮的判断是一个经典考点我给出核心代码static int is_valid_cannon_move(ChessState *s, int fr, int fc, int tr, int tc) { if (fr ! tr fc ! tc) return 0; // 炮必须走直线 int cnt 0; int dr (tr fr) ? 1 : (tr fr) ? -1 : 0; int dc (tc fc) ? 1 : (tc fc) ? -1 : 0; int r fr dr, c fc dc; while (r ! tr || c ! tc) { if (s-board[r][c] ! EMPTY) cnt; r dr; c dc; } int target s-board[tr][tc]; if (target EMPTY) return cnt 0; // 移动中间不能有子 return cnt 1; // 吃子必须隔一个炮架 }这里有个小细节计数时用while (r ! tr || c ! tc)从起点后一格出发到终点前一格结束不会把起点或目标点本身算进去。很多新手写的时候把起点也算进去了结果炮永远找不到炮架这是个高频 bug。3.2 基本合法与完整合法的分层规则判断我强烈建议拆成两层基本合法is_basic_move_legal只判断“这个棋子按规则能不能走到目标格子”不考虑将军状态。完整合法is_full_move_legal在基本合法的基础上额外要求“走完这一步不能让自己的将帅暴露在对方攻击之下”。为什么要分开因为完整合法判断需要试走而试走过程中又要用到合法性判断。如果两层混在一起很容易出现互相调用、无限递归的问题。基本合法函数的核心逻辑大概长这样static int is_basic_move_legal(ChessState *s, int fr, int fc, int tr, int tc) { if (!in_board(fr, fc) || !in_board(tr, tc)) return 0; int p s-board[fr][fc]; if (p EMPTY) return 0; if (s-board[tr][tc] * p 0) return 0; // 目标是自己人非法 // 按棋子类型分发判断 switch (abs(p)) { case RED_KING: ... case RED_ADVISOR: ... case RED_ELEPHANT: ... case RED_HORSE: ... case RED_ROOK: ... case RED_CANNON: ... case RED_PAWN: ... default: return 0; } }这里用abs(p)拿到棋子类型编号就可以让红黑双方共用同一套规则逻辑方向差异再通过s-side或者行号判断。3.3 将军、应将与胜负判断完整合法的核心是“不能送将”。实现方式是试走也就是把当前局面保存一份副本在副本上执行走子然后检查走完后的棋盘里自己的将帅是否安全。static int is_full_move_legal(ChessState *s, int fr, int fc, int tr, int tc) { if (!is_basic_move_legal(s, fr, fc, tr, tc)) return 0; ChessState copy *s; // 结构体整体赋值相当于深拷贝 apply_move(copy, fr, fc, tr, tc); // 如果吃掉了对方的将帅直接算获胜 if (copy.board[tr][tc] RED_KING || copy.board[tr][tc] BLACK_KING) return 1; return !is_king_in_danger(copy, s-side); }apply_move很简单把目标格子的值改为源格子的值再把源格子置空。注意如果移动的是将帅本身还要同步更新red_king_r这类字段。is_king_in_danger的实现思路是找到当前方将帅的位置然后遍历棋盘上所有敌方棋子调用is_basic_move_legal判断这个敌方棋子能否直接走到将帅位置static int is_king_in_danger(ChessState *s, int side) { int kr, kc; find_king(s, side, kr, kc); for (int r 0; r 10; r) { for (int c 0; c 9; c) { int p s-board[r][c]; if (p ! EMPTY p * side 0) { if (is_basic_move_legal(s, r, c, kr, kc)) return 1; } } } // 将帅照面同一列且中间无子 int other_side -side; int ok_r, ok_c; find_king(s, other_side, ok_r, ok_c); if (kc ok_c) { int cnt 0; int r0 (kr ok_r) ? kr : ok_r; int r1 (kr ok_r) ? kr : ok_r; for (int r r0 1; r r1; r) { if (s-board[r][kc] ! EMPTY) cnt; } if (cnt 0) return 1; } return 0; }这里必须用is_basic_move_legal不能用完整合法函数否则完整合法函数内部又会调is_king_in_danger直接递归爆炸。这也是我坚持分层的最大原因。关于胜负初学者可以做简化版只要某一步吃掉了对方的将或帅这局就赢了。正规象棋里还有“被将军且无法应将”才算输的情况但吃将判胜对个人项目来说已经足够清爽。如果想做得更完整可以在每轮走子结束后统计当前方是否存在至少一个完整合法走法如果不存在且当前方正在被将军就判负。3.4 历史局面与长将检测进阶一点的话题是禁着和长将。很多个人项目会忽略这个但真要做得严谨建议维护一个历史局面表。最简单的方式是记录每一步走子后的棋盘快照和走棋方用哈希或直接字符串比对。如果某个局面在连续若干回合内重复出现可以按规则直接判和或要求变着。不过这次项目我建议先不做等基础规则跑通了再加优先级不高。4. 关键代码实现与实战记录4.1 主循环与走子流程程序主体是一个典型的“打印-输入-判定-换方”循环int main(void) { ChessState game; init_board(game); while (1) { print_board(game); char from[4], to[4]; printf(当前 %s 走棋例如输入 e4 e5\n, game.side 1 ? 红方 : 黑方); scanf(%3s %3s, from, to); int fr, fc, tr, tc; if (!parse_square(from, fr, fc) || !parse_square(to, tr, tc)) { printf(坐标格式不对请重新输入\n); continue; } if (!is_full_move_legal(game, fr, fc, tr, tc)) { printf(非法走法请重新输入\n); continue; } apply_move(game, fr, fc, tr, tc); if (game.board[tr][tc] RED_KING || game.board[tr][tc] BLACK_KING) { print_board(game); printf(对局结束%s 获胜\n, game.side 1 ? 红方 : 黑方); break; } game.side -game.side; } return 0; }这段主循环有一个值得注意的选择我把“吃将判胜”放在走子之后立刻判断而不是走到下一轮。4.2 打印棋盘与坐标输入解析棋盘打印用数组自然顺序即可因为坐标约定就是顶部黑方、底部红方void print_board(ChessState *s) { printf( a b c d e f g h i\n); for (int r 0; r BOARD_ROWS; r) { printf(%d , r); for (int c 0; c BOARD_COLS; c) { printf(%s , piece_to_name(s-board[r][c])); } printf(\n); } }列坐标用字母a到i而不是数字可以避免“列到底是从 0 开始还是从 1 开始”的混乱。解析函数如下static int parse_square(const char *s, int *r, int *c) { if (s[0] a || s[0] i) return 0; if (s[1] 0 || s[1] 9) return 0; *c s[0] - a; *r s[1] - 0; return 1; }中文棋子名的乱码是控制台编程的经典坑。在 Windows 控制台下建议程序开头调用setlocale(LC_ALL, );或者直接把终端代码页切到 UTF-8。如果不处理编码打印出来的棋子名全是乱码调试心情会爆炸。4.3 试走复原将军检测的最小实现前面提到试走时用结构体赋值做深拷贝这里把细节补全。由于ChessState内部有一个 90 个int的数组结构体整体赋值copy *s会把整个棋盘完整复制一份不会出现浅拷贝问题。试走函数这样写static void apply_move(ChessState *s, int fr, int fc, int tr, int tc) { s-board[tr][tc] s-board[fr][fc]; s-board[fr][fc] EMPTY; if (s-board[tr][tc] RED_KING) { s-red_king_r tr; s-red_king_c tc; } if (s-board[tr][tc] BLACK_KING) { s-black_king_r tr; s-black_king_c tc; } }这里有个容易忽略的细节吃子时目标格原本可能是红帅或黑将。比如红车吃掉黑将board[tr][tc]先被赋成红车然后你再用if (s-board[tr][tc] BLACK_KING)去判断就会失效。所以我上面对局结束判定用的是game.board[tr][tc] RED_KING || game.board[tr][tc] BLACK_KING但这里需要对吃将场景做特殊处理。推荐的做法是在apply_move之前先保存一下目标格原始棋子如果目标格原本是将帅直接认为当前方获胜。也可以单独写一个move_captures_king()函数在走子前判断目标值。逻辑上更干净。5. 常见问题与排查技巧实录5.1 高频 bug 速查表我把自己和身边同学踩过的坑汇总成一张表排查时直接对照现象可能原因修复思路车炮偶尔能“跳过”棋子直线路径遍历时把起点或目标点算进去了从起点后一格开始到目标前一格结束马会跳出棋盘或方向错乱方向表没检查边界遍历方向表后必须in_board判断炮隔了一个子但吃不了炮架计数从起点开始计多算一个确保计数区间不包含起点和终点红黑走子方向反了坐标约定不统一红方在上锁定“数组 0 行为黑方、9 行为红方”走一步后程序卡死或栈溢出完整合法与将军检测互相递归分开is_basic_move_legal与is_full_move_legal棋盘棋子名乱码控制台编码问题setlocale或切换 UTF-8 代码页将帅可以隔着九宫乱走缺少九宫边界判断用in_palace限制目标点5.2 两个隐蔽的设计坑第一个坑是象眼和马腿表格容易写反。比如马的 8 个方向对应 8 个蹩腿位置如果你方向表排序变了蹩腿表没同步改就会出现有时蹩腿有时不蹩的诡异行为。建议把方向表和蹩腿表定义在一起并且加注释说明对应关系测试时专门走一遍“马被堵”的用例。第二个坑是试走副本里的状态不同步。很多人试走时只更新了board忘记更新red_king_r/black_king_r。一旦正在移动的是将帅后续找王位置时就用了旧坐标将军检测直接失效。解决方法是把“更新将帅位置”这部分逻辑收进apply_move任何走子都走同一套入口不要在外面手动改棋盘。5.3 调试技巧可复现局面与日志写棋类程序最忌讳的是每次调试都要从开局一步步走几十步才能到目标局面。我建议写一个测试用初始化函数直接摆出任意局面static void setup_test_position(ChessState *s) { memset(s-board, 0, sizeof(s-board)); s-board[9][4] RED_KING; s-board[0][4] BLACK_KING; s-board[6][4] RED_PAWN; // 红兵 s-board[3][4] BLACK_PAWN; // 黑卒 // 摆一个马或炮专门测蹩腿/炮架 s-red_king_r 9; s-red_king_c 4; s-black_king_r 0; s-black_king_c 4; s-side 1; }然后单独验证某个走法是否合法不用打开游戏慢慢玩。再加一个日志宏会让排查事半功倍#ifdef DEBUG #define LOG(fmt, ...) printf([%s:%d] fmt \n, __func__, __LINE__, ##__VA_ARGS__) #else #define LOG(...) #endif在关键分支里打上LOG(尝试走子: (%d,%d) - (%d,%d), fr, fc, tr, tc);一旦走法判定不对直接看输出就能定位到具体函数和行号。6. 进阶路线从双人对弈走向人机对战6.1 局面评估函数怎么搭双人版跑通之后下一步自然是加 AI。AI 第一步是写评估函数给当前局面打分static const int piece_value[8] { 0, // EMPTY 10000, // 将 200, // 士 200, // 象 400, // 马 900, // 车 450, // 炮 100 // 兵 }; int evaluate(ChessState *s) { int score 0; for (int r 0; r 10; r) { for (int c 0; c 9; c) { int p s-board[r][c]; if (p EMPTY) continue; if (p 0) score piece_value[p]; else score - piece_value[-p]; } } return score; // 正数利于红方负数利于黑方 }还可以加位置表兵过河后加分、马在边线降分、车占中路加分。位置表一般用一个三维数组pos_table[8][10][9]初始化时填好评估时叠加。对新手来说先只做子力评估就能跑出像模像样的 AI位置表是后续打磨。6.2 极小极大搜索与剪枝AI 最朴素的做法是极小极大搜索。用负极大值negamax写法可以统一红黑双方int negamax(ChessState *s, int depth, int side) { if (depth 0) return side * evaluate(s); int best -1000000; // 枚举所有合法走法 for (int r 0; r 10; r) { for (int c 0; c 9; c) { if (s-board[r][c] EMPTY || s-board[r][c] * side 0) continue; for (int tr 0; tr 10; tr) { for (int tc 0; tc 9; tc) { if (!is_full_move_legal(s, r, c, tr, tc)) continue; ChessState copy *s; apply_move(copy, r, c, tr, tc); int score -negamax(copy, depth - 1, -side); if (score best) best score; } } } } return best; }深度一般设 3 到 4 层个人项目上速度可以接受。如果想更快就在递归里加入 alpha-beta 剪枝维护当前已知的最佳和对手能接受的最差一旦发现某一分支不可能优于已有答案就提前终止当前分支。这里有个关键点搜索时用到的合法性函数必须是完整函数也就是包含“不能送将”的检查否则 AI 会走出把自己将帅送到对方嘴里的昏招。6.3 再往下悔棋、存档与网络对战如果还想继续扩展分层架构的价值就体现出来了。悔棋维护一个历史栈每走一步把(from, to, 被吃的子, 双方将帅位置)压栈悔棋就是弹栈恢复。存档把棋盘数组、当前走棋方、走子历史写进文件下次启动时读入比从开局重走一遍方便得多。网络对战把一次走法封装成from和to两个坐标字符串通过网络发送给对方规则层完全不用改。无论扩哪个方向数据层和规则层的代码都可以原样保留。这也再次说明当初花时间做分层设计是值得的。最后说点个人体会。这类棋类项目最值钱的不是最终跑起来的那个程序而是调坐标方向、试走校验、把炮架计数漏掉的那几个瞬间。每次改完规则备几组固定局面跑一遍比打开游戏瞎试快得多。如果你正卡在某个规则判定上先把坐标约定写清楚把基本合法和完整合法拆开再往下推问题会小很多。