纯C语言实现中国象棋:从数据结构到人机AI的完整指南
做C语言项目中国象棋是个绕不开的经典选题。无论是课程设计还是自己练手一套完整的象棋游戏几乎能把C语言的数组、指针、函数指针、状态机、简单搜索算法全部串起来。网上搜象棋源码不少但多数是零散片段能讲清楚“为什么这么设计”的不多。这篇文章就用一个纯C写的控制台象棋项目为主线从数据结构、规则判断、人机AI到主循环把整个实现脉络和踩坑记录完整拆一遍源码关键部分直接贴出来你可以照着抄。这个项目适合三类人正在找C语言综合练习项目的学生想用课程设计交差但不想只写个学生管理系统的朋友以及对棋类AI机制感兴趣的入门玩家。项目最终的完成度是——双人对战和简单人机对战都能玩胜负判定、将军检测、非法走棋拦截全部可用AI能在1秒内完成思考整个代码量控制在1000行左右。1. 为什么用C语言写中国象棋需求拆解与技术选型1.1 象棋游戏的核心需求拆解写代码第一步永远是搞清楚“必须做出来什么”。中国象棋游戏拆到底逃不开下面几件事棋盘要能显示出来双方棋子位置要一目了然。玩家能选择棋子给它下达移动指令。程序能判断这步棋合不合法比如马不能蹩马腿、象不能塞象眼、老将不能出九宫。吃子规则要正确炮隔山打、车直行横走、兵过河才能横移。要能识别将军状态并且当一方被将死时游戏能正确结束。如果加了人机模式程序还得能自动生成走法、评估局面、选择一个“看起来不错”的落子。这六条里前五条属于“规则引擎”是项目的核心工程量第六条是附加价值。很多初学者上来就想写AI结果棋盘都画不利索这是本末倒置。我的建议是先做纯双人对战规则全部跑通再加AI。1.2 技术选型控制台还是图形界面C语言做界面有两条路一条是控制台字符界面用printf在终端里画棋盘另一条是接图形库比如EasyX、SDL。对于学C语言的核心阶段我非常推荐控制台方案。原因有三。第一控制台方案不依赖任何第三方库Windows、Linux、macOS都能编译运行跨平台零坑。第二字符界面天然逼着你把“界面渲染”和“游戏逻辑”分离开——棋盘只是数据的一种展示形式而不是数据本身这个认知对后续做图形版本很有帮助。第三控制台程序调试方便printf一打状态一目了然。图形库版本等你把逻辑吃透了再换不迟换起来其实就是把printBoard替换成图片绘制规则引擎完全不需要动。这个分层设计本身就是最好的架构练习。1.3 项目整体模块划分我最终把代码分成了四个文件chess.h公共头文件定义棋盘大小、棋子编码、数据结构、函数声明。board.c棋盘初始化、棋盘打印、坐标映射。rules.c所有走棋合法性判断、将军检测、胜负判定。ai.c走法生成、局面评估、AI落子选择。main.c游戏主循环、玩家输入解析、悔棋接口预留。有人会嫌文件多麻烦尤其课程设计提交时老师可能要求“单文件”。但你写好模块后再合并成单文件很容易反过来就难了。模块化的意义在于每个文件只做一件事rules.c不关心棋盘怎么打印ai.c不关心玩家输入是什么。排查问题的时候定位极快。2. 棋盘与棋子的数据表示数据结构设计思路2.1 棋盘的二维数组编码方案中国象棋棋盘是10行9列最直接的数据结构就是一个二维数组。关键问题是每个格子存什么我采用的方案是用一个int数组board[10][9]0表示空正数表示红方棋子负数表示黑方棋子。具体编码用宏定义#define EMPTY 0 // 红方棋子正数 #define R_KING 1000 #define R_ROOK 500 #define R_KNIGHT 400 #define R_CANNON 450 #define R_BISHOP 200 #define R_ELEPHANT 200 #define R_PAWN 100 // 黑方棋子负数 #define B_KING (-R_KING) #define B_ROOK (-R_ROOK) #define B_KNIGHT (-R_KNIGHT) #define B_CANNON (-R_CANNON) #define B_BISHOP (-R_BISHOP) #define B_ELEPHANT (-R_ELEPHANT) #define B_PAWN (-R_PAWN)为什么用1000、500这种“带权重的编码”因为后面写AI评估函数时棋子的价值直接可以复用这套数字——R_KING是1000却没人敢真吃但实际上它是被将死判定的目标车500、炮450、马400、士象200、兵100这套权重是象棋AI里非常经典的初始估值。这样定义一份规则模块和AI模块用的就是同一套常量不会出现两处维护的混乱。用正负区分红黑还有一个额外好处判断“这个棋子是不是自己的”只需要看符号。走棋时if (board[x][y] * currentPlayer 0) 就能确认操作者的身份非常干净。2.2 走棋指令的数据结构玩家输入走棋后程序内部需要用一个结构体来承载“从哪到哪”的信息typedef struct { int fromX, fromY; // 起点坐标 int toX, toY; // 终点坐标 int score; // AI评估用玩家输入时置0 int chessId; // 被吃掉的棋子编号悔棋用 } Move;这个结构体贯穿整个程序。玩家输入解析完填充一个MoveAI思考完也返回一个Move执行走棋函数接收Move合法性判断接收Move。score字段一开始你可能觉得多余但AI生成走法时会大量排序比较把它放在Move里排序时直接按score比较代码会非常简洁。2.3 坐标系统与界面显示的对应关系这里有个初学者最容易踩的坑屏幕显示的行列和数组下标不一致。棋盘的显示逻辑是红方在下方第9行黑方在上方第0行列则是传统的九条线。但数组索引arr[x][y]天然是x行y列x0是顶部。我用的约定是数组第一维是行范围0~90在棋盘顶端黑方底线9在底端红方底线。数组第二维是列范围0~8对应棋盘从左到右。打印棋盘时行号直接用0~9输出列号用1~9输出。玩家输入“e2-e4”这种格式时列字母转下标就是 e-a行数字转下标就是 9 - 数字。这个转换逻辑写成一个函数所有输入输出都走它不要在地里到处散落 1 或 -1 的魔法数字。注意如果你做到网络对战版本还要小心黑方视角的棋盘旋转问题。本地单机版可以统一用红方视角AI内部也用同一坐标系避免一切混乱。3. 核心难点一走棋规则与合法性判断3.1 不同兵种的移动规则实现思路合法性判断是所有棋类程序的心脏。我的做法是写一个统一的入口函数int canMove(int board[10][9], Move *move, int player) { int fromX move-fromX, fromY move-fromY; int toX move-toX, toY move-toY; int piece board[fromX][fromY]; // 边界检查和目标位置检查 if (fromX 0 || fromX 9 || fromY 0 || fromY 8) return 0; if (toX 0 || toX 9 || toY 0 || toY 8) return 0; if (piece EMPTY) return 0; if (piece * player 0) return 0; // 不是操作者自己的棋子 if (board[toX][toY] * player 0) return 0; // 目标位置是自己的棋子 // 按棋子类型分派 switch (abs(piece)) { case R_KING: return canMoveKing(board, move, player); case R_ROOK: return canMoveRook(board, move, player); case R_KNIGHT: return canMoveKnight(board, move, player); case R_CANNON: return canMoveCannon(board, move, player); case R_BISHOP: return canMoveBishop(board, move, player); case R_ELEPHANT: return canMoveElephant(board, move, player); case R_PAWN: return canMovePawn(board, move, player); } return 0; }因为棋子编码利用了绝对值所以红黑双方走法规则完全一致只需要在“方向”上用player参数做判断。3.2 马脚与象眼特殊限制的处理马的走法对初学者来说有一定迷惑性。马的移动是“先直走一格再斜走一格”也就是目标位置和当前位置的坐标差是(1,2)或(2,1)的组合。但马有“蹩马腿”限制如果直走的那一格被占马就不能往那个方向跳。int canMoveKnight(int board[10][9], Move *move, int player) { int dx move-toX - move-fromX; int dy move-toY - move-fromY; if (abs(dx) 1 abs(dy) 2) { // 蹩马腿的格子在纵向 int legX move-fromX; int legY move-fromY (dy 0 ? 1 : -1); if (board[legX][legY] EMPTY) return 1; } if (abs(dx) 2 abs(dy) 1) { // 蹩马腿的格子在横向 int legX move-fromX (dx 0 ? 1 : -1); int legY move-fromY; if (board[legX][legY] EMPTY) return 1; } return 0; }核心思路是先判断马腿的位置是由dx和dy的方向决定的再检查那个位置是否为空。这里不需要考虑目标位置是否是对方棋子因为统一入口已经处理过了。象相的走法是“田字”步长为2x2对角线限制有两条不能过河象走田只能在自己半场象眼不能塞。int canMoveElephant(int board[10][9], Move *move, int player) { int dx move-toX - move-fromX; int dy move-toY - move-fromY; if (abs(dx) ! 2 || abs(dy) ! 2) return 0; // 象不能过河红象行0~4黑象行5~9 if (player 1 move-toX 4) return 0; if (player -1 move-toX 5) return 0; // 象眼 int eyeX (move-fromX move-toX) / 2; int eyeY (move-fromY move-toY) / 2; if (board[eyeX][eyeY] ! EMPTY) return 0; return 1; }3.3 炮的跳吃逻辑与直线走法车和炮都是直线运动但有一个核心差异车在路径上不能有任何棋子阻挡炮在移动时也不能有阻挡但吃子时中间必须恰好有一个棋子俗称“隔山打”。这个差异实现起来可以用一个共同的路径检测函数int countBetween(int board[10][9], Move *move) { int count 0; int minX move-fromX move-toX ? move-fromX : move-toX; int maxX move-fromX move-toX ? move-fromX : move-toX; int minY move-fromY move-toY ? move-fromY : move-toY; int maxY move-fromY move-toY ? move-fromY : move-toY; if (move-fromX move-toX) { for (int y minY 1; y maxY; y) { if (board[move-fromX][y] ! EMPTY) count; } } else { for (int x minX 1; x maxX; x) { if (board[x][move-fromY] ! EMPTY) count; } } return count; } int canMoveRook(int board[10][9], Move *move, int player) { if (move-fromX ! move-toX move-fromY ! move-toY) return 0; return countBetween(board, move) 0; } int canMoveCannon(int board[10][9], Move *move, int player) { if (move-fromX ! move-toX move-fromY ! move-toY) return 0; int target board[move-toX][move-toY]; int between countBetween(board, move); if (target EMPTY between 0) return 1; // 炮的普通移动 if (target ! EMPTY between 1) return 1; // 炮吃子 return 0; }这个countBetween函数是可复用的宝贝后面判断将帅照面也能用它。3.4 将帅移动限制与照面判定将帅只能在九宫内移动九宫范围是列3~5红将为行7~9黑将为行0~2。它每次只能走一步不能出九宫。将帅之间还有一个特殊规则——“将帅不能照面”。也就是两个老将在同一条竖线上、中间没有任何棋子时属于非法局面。这个规则实际上是为了防止“对脸杀”这种特殊形态。我实现了一个全局的“合法性检查”函数在每次走棋后调用int isKingInCheck(int board[10][9], int player) { // 找到player的将位置 int kingX -1, kingY -1; for (int i 0; i 10; i) { for (int j 0; j 9; j) { if (board[i][j] * player 0 abs(board[i][j]) R_KING) { kingX i; kingY j; } } } if (kingX -1) return 1; // 将没了已经被吃 // 遍历对方所有棋子看谁能一步吃到将 for (int i 0; i 10; i) { for (int j 0; j 9; j) { if (board[i][j] * (-player) 0) { Move m {i, j, kingX, kingY, 0, 0}; if (canMove(board, m, -player)) return 1; } } } return 0; }这里有个容易被忽略的递归问题canMove内部会用到isKingInCheck吗如果你考虑的是“走这一步后自己的老将会不会处于被将军状态”就有递归依赖。我的处理方式是执行走棋后调用isKingInCheck检查当前走棋方是否被将军是则说明这步棋把自己将死了属于非法走法回滚。规则内部不嵌套调用避免死循环。3.5 胜负判定将军、将死与困毙将军检测在上面已经实现真正难的是“将死”判定。将死的意思是当前被将军的一方尝试所有合法走法没有任何一步能让自己脱离将军状态。int isCheckmate(int board[10][9], int player) { if (!isKingInCheck(board, player)) return 0; // 生成player所有合法走法 Move moves[200]; int count generateAllMoves(board, moves, player); for (int i 0; i count; i) { // 模拟走棋 int oldTarget board[moves[i].toX][moves[i].toY]; board[moves[i].toX][moves[i].toY] board[moves[i].fromX][moves[i].fromY]; board[moves[i].fromX][moves[i].fromY] EMPTY; int stillInCheck isKingInCheck(board, player); // 回滚 board[moves[i].fromX][moves[i].fromY] board[moves[i].toX][moves[i].toY]; board[moves[i].toX][moves[i].toY] oldTarget; if (!stillInCheck) return 0; // 找到了解围办法 } return 1; // 所有走法都被将军将死 }一个细节这里isKingInCheck内部会再次遍历所有棋子并调用canMovecanMove里不会递归调用isKingInCheck所以性能可控。而且只有将军状态才需要做将死判定普通局面不需要每步全盘模拟不会拖慢游戏。4. 核心难点二人机对战的简单AI实现4.1 走法生成器AI的第一个模块是走法生成器。遍历棋盘上当前方的所有棋子对每个棋子尝试所有可能的目标位置0~9行0~8列用canMove过滤一遍得到的就是所有合法走法。int generateAllMoves(int board[10][9], Move *moves, int player) { int count 0; for (int x 0; x 10; x) { for (int y 0; y 9; y) { if (board[x][y] * player 0) { for (int tx 0; tx 10; tx) { for (int ty 0; ty 9; ty) { Move m {x, y, tx, ty, 0, 0}; if (canMove(board, m, player)) { moves[count] m; } } } } } } return count; }这个生成器在AI评估中会反复调用每次生成大约40到60个走法性能开销可以接受。如果以后要提速可以对每个棋子单独预计算可移动方向但对于入门项目来说全量遍历90个格子已经足够。4.2 局面评估函数的设计评估函数是AI的“直觉”它把一个棋盘局面映射成一个分数。正数表示红方优势负数表示黑方优势。最简单的评估方式就是子力价值求和int evaluateBoard(int board[10][9]) { int score 0; for (int x 0; x 10; x) { for (int y 0; y 9; y) { score board[x][y]; // 直接利用正负编码红黑- } } return score; }就这么简单一行核心逻辑。因为编码时红方为正、黑方为负、棋子价值正好是权重评估函数直接累加整张棋盘就能得到局面分。不过仅靠子力价值AI会表现得“有子就吃没子乱走”缺乏位置意识。我加了一个小优化给兵卒加了过河奖励给马加了“贴近中心”的微小奖励。这些加分写成一个可调的配置数组这样调整AI棋风时就改一组数字不用动逻辑。int evaluateBoard(int board[10][9]) { int score 0; for (int x 0; x 10; x) { for (int y 0; y 9; y) { int piece board[x][y]; if (piece EMPTY) continue; score piece; // 兵卒过河奖励 if (abs(piece) R_PAWN) { if (piece 0 x 4) score 30; // 红兵过河 if (piece 0 x 5) score - 30; // 黑卒过河 } } } return score; }注意兵卒奖励的方向红方在行9过河是去行0方向即x变小所以x 4视为过河黑方相反。这里的方向判断又是一个典型的坐标系陷阱写错之后AI会“偏爱”往回跑的棋子。4.3 搜索策略贪心、一层前瞻与极大极小AI的搜索策略决定了它的棋力上限。我最终实现了两个版本第一个是纯贪心遍历全部走法直接选评估分最高的那个。这个版本下AI会果断吃子但完全不会防御。你送一个车给它它毫不犹豫就吃掉下一秒你的马也能白吃它的车——因为它看不到对手的下一步。第二个是一层前瞻模拟自己走一步之后再模拟对手的最佳回应综合两步来评估。这版AI已经有基本的“别送子”意识。Move aiThink(int board[10][9], int player) { Move bestMove; int bestScore -99999; Move moves[200]; int count generateAllMoves(board, moves, player); for (int i 0; i count; i) { // 模拟走棋 int oldTarget board[moves[i].toX][moves[i].toY]; board[moves[i].toX][moves[i].toY] board[moves[i].fromX][moves[i].fromY]; board[moves[i].fromX][moves[i].fromY] EMPTY; int score evaluateBoard(board); // 对手最佳回应后的局面分 Move oppMoves[200]; int oppCount generateAllMoves(board, oppMoves, -player); int oppBest 99999; for (int j 0; j oppCount; j) { // 模拟对手走棋... // 计算局面对player来说的分值 // 取最小值即对手最让我难受的一步 } score score - oppBest; // 我方的收益减去对手能给我们的损失 // 回滚 board[moves[i].fromX][moves[i].fromY] board[moves[i].toX][moves[i].toY]; board[moves[i].toX][moves[i].toY] oldTarget; if (score bestScore) { bestScore score; bestMove moves[i]; } } return bestMove; }实际上如果没有评估函数的“将军加分”一层前瞻很容易把老将的生死忽略掉。所以在模拟对手走后我加上一条如果对手走完后将军了给对手一个很高的奖励分这样AI就会优先选择避将的走法。三层极大极小搜索在纯C控制台程序里也能跑但走法生成量和评估次数是指数级增长。真实项目里我这个一层前瞻每次思考大约10到30毫秒体验非常流畅。三层搜索加上Alpha-Beta剪枝能做到秒出代码量会多200行这里就不展开了——你先把一层前瞻跑通才有资格谈剪枝。5. 实操过程从零搭建游戏主循环5.1 主循环与流程控制游戏主循环是整个程序的骨架我用一个状态机来控制typedef enum { GAME_PLAYER_MOVE, // 等待玩家输入 GAME_AI_THINK, // AI思考中 GAME_OVER_RED_WIN, // 红方胜 GAME_OVER_BLACK_WIN,// 黑方胜 GAME_QUIT // 退出 } GameState;主循环的逻辑用while配合switch每次循环都重绘棋盘和当前状态。玩家模式下读取输入并执行AI模式下调用aiThinkAI思考完后切换回玩家模式。这个结构后续加“悔棋”“保存复盘”都很容易因为每一步都发生在明确的state里。int main() { initBoard(); int currentPlayer 1; // 红方先行 while (1) { printBoard(board); if (currentPlayer 1) { // 红方玩家输入 Move move parsePlayerInput(); if (!canMove(board, move, currentPlayer)) { printf(非法走法请重新输入\n); continue; } applyMove(board, move); } else { // 黑方AI printf(AI思考中...\n); Move move aiThink(board, -1); applyMove(board, move); } // 将军/胜负判断 if (isCheckmate(board, currentPlayer)) { printf(%s被将死游戏结束\n, currentPlayer 1 ? 红方 : 黑方); break; } currentPlayer -currentPlayer; } return 0; }5.2 输入解析与缓冲区问题玩家输入我用的是“字母数字横杠字母数字”的格式比如“c2-c4”。列用a到i表示行用0到9表示。解析函数直接用sscanf提取四个字符Move parsePlayerInput() { char input[16]; fgets(input, sizeof(input), stdin); Move m {0}; if (sscanf(input, %c%d-%c%d, col1, row1, col2, row2) ! 4) { printf(输入格式错误应为 列行-列行如 e2-e4\n); return m; } m.fromY col1 - a; m.fromX 9 - row1; m.toY col2 - a; m.toX 9 - row2; return m; }这里有一个输入缓冲区的经典坑。初学者往往会用scanf(%c%d-%c%d...)来解析但用户如果输入了一个多余的换行或者空格scanf会立刻失败。最稳妥的做法是先用fgets把整行读进来再用sscanf解析。fgets和scanf混用也是重灾区我自己在这个项目里就被混合输入搞崩过两次后来统一改成全部用fgets就再也没出过问题。5.3 棋盘打印的实现控制台打印棋盘我用了纯ASCII字符加上简单的控制台坐标偏移不依赖中文编码避免了Windows和Linux字符输出不一致的问题。棋子用字母表示红方大写黑方小写char pieceToChar(int piece) { switch (piece) { case R_KING: return K; case R_ROOK: return R; case R_KNIGHT: return N; case R_CANNON: return C; case R_BISHOP: return B; case R_ELEPHANT: return E; case R_PAWN: return P; case B_KING: return k; case B_ROOK: return r; case B_KNIGHT: return n; case B_CANNON: return c; case B_BISHOP: return b; case B_ELEPHANT: return e; case B_PAWN: return p; default: return .; } }打印函数本身就是一个双重循环外层遍历10行内层遍历9列每次输出两个空格位。如果当前格子有棋子就输出棋子字符否则输出用纵横线拼接的棋盘格符号。棋盘格线我用的是加减号和竖线虽然不如Unicode字符美观但胜在稳定。如果你用的是Windows 10以上并且终端设置为UTF-8也可以直接输出“┌─┐”这类制表符效果好很多。但是作为教程我强烈建议先跑通ASCII版本。5.4 关键函数调用关系给出一张核心函数地图方便你对照理解整个项目函数名所属模块职责被谁调用initBoardboard.c初始化初始局面mainprintBoardboard.c打印棋盘mainparsePlayerInputboard.c解析玩家输入maincanMoverules.c统一走法校验main / ai.cisKingInCheckrules.c检测将军状态main / ai.cisCheckmaterules.c检测将死maingenerateAllMovesai.c生成所有合法走法aiThink / isCheckmateevaluateBoardai.c局面评估aiThinkaiThinkai.cAI决策入口main你写完rules.c之后先用双人对战模式把所有走法合法性都测一遍再开始写AI。AI的调试难度远高于规则如果规则本身有bugAI的“聪明”就无从谈起。6. 调试与排坑我实际遇到的典型问题6.1 坐标反了、越界、老将“瞬移”这个项目里我遇到最多的问题就是坐标错误。典型表现是车从左边走到右边显示棋子在原地不动或者AI走了一部棋之后棋盘上棋子数量减一。排查方法是写一个专门打印坐标的小工具函数。在applyMove前后分别打印from和to坐标确认解析层、显示层、规则层用的是同一个坐标系。我用的统一约定是“行0-9列0-8”所有外部输入输出都在边界转换内部绝不做二次偏移。另一个高频问题就是数组越界。马走日、象走田坐标偏移动辄是2。如果初始位置在棋盘边上偏移后就跑出棋盘了。这段代码一定要放在所有逻辑判断的最前面而且判断要是完整的if (toX 0 || toX 9 || toY 0 || toY 8) return 0;顺序上必须先做越界检查再做规则判断。否则规则判断里访问board[x][y]会直接越界可能读到随机的内存数据表现成“棋子消失了”或者“出现了幽灵棋子”。6.2 马腿和象眼判断的边界错误马腿判断里最容易犯的错是只判断了“目标是否在合理方位”却忘了校验马腿格子在数组里是否越界。比如马在棋盘边缘时某个方向的马腿坐标可能是负数直接访问board数组就会出问题。这个bug的狡猾之处在于它并不总是触发——只有马走到边界时才崩溃课设演示时往往正好走到边界场面一度很尴尬。我的建议是所有坐标计算无论多简单都先过一遍边界判断。别嫌啰嗦象棋程序里90%的崩溃都是这类问题。6.3 炮吃子把控条件弄反炮是最容易写错规则的棋子。我最初把炮写成“移动时也必须有炮架子”结果炮在空旷区域寸步难行后来改成“移动时中间不能有挡”结果炮可以随便隔两个子吃人。根本原因在于炮的移动和吃子是两种不同的判断逻辑必须在同一个函数里分开处理不能共用一套条件。建议用explicit的代码写明两种情况if (target EMPTY) { return between 0; // 移动路径必须干净 } else { return between 1; // 吃子恰好隔一个 }不要用“between 1 between 0”这种看似聪明的写法它会把炮的移动和吃子逻辑混在一起之后AI评估时容易出怪招。6.4 AI疯狂吃自己棋子AI走棋时如果出现“把自己的马走到另一个自己棋子的位置”说明canMove里“目标位置是自己棋子”的判断没有生效。排查方向是检查棋子编码是否用了绝对值。我见过最奇怪的一次是红黑双方棋子应该用正负区分但代码里写成了board[m-toX][m-toY] * player 0结果目标位置是对方棋子才合法——正好反了。这个问题在玩家模式下偶尔也能触发玩家明明选了自己的车去吃自己的兵却被程序放行。看到这个现象第一反应就去看符号判断。6.5 输入缓冲区和死循环问题玩家输入非法走法时主循环用continue跳过但缓冲区还残留着换行符。如果后面用scanf读取可能直接读到空字符串导致死循环。我在整个项目里统一用fgets读行从根上杜绝了这个问题。另外还要注意AI思考的循环里如果generateAllMoves返回0意味着当前玩家没有任何合法走法这就是“困毙”——在象棋里同样判负。我的isCheckmate函数处理的是“被将军且无法解围”而“无子可动但没被将军”是另一种情况。如果没处理游戏会直接卡死。所以主循环里必须检查走法数量Move moves[200]; int cnt generateAllMoves(board, moves, currentPlayer); if (cnt 0) { printf(无子可动%s输\n, currentPlayer 1 ? 红方 : 黑方); break; }6.6 常见问题速查表现象可能原因排查与解法棋子瞬移/消失坐标数组越界检查所有坐标偏移的边界判断马腿判断失效马腿坐标越界未处理先做边界判断再访问数组炮能隔多个子吃炮的移动和吃子共用条件分targetEMPTY和!EMPTY两种情况AI吃自己棋子符号判断写反检查piece * player 0 的运算符游戏卡死无子可动未判负主循环补充困毙检测输入命令无效scanf与fgets混用统一用fgets读行并解析老将照面不判负缺少照面检测isKingInCheck里增加同列无子检测这些坑看起来琐碎但每一个都对应着象棋程序里最容易出错的地方。你写的时候遇到同样的问题直接对照这张表查能省不少时间。写完这个项目之后的几点体会这个项目的工程量其实比看上去要大。棋盘编码、规则系统、AI评估、输入解析每个模块单独拿出来都不难但拼在一起时模块之间的协调才是真正的挑战。我做完之后最大的感受是数据结构选好了后面全部顺坐标系定死了调试省一半。如果你打算在这个基础上继续扩展我有几个建议方向加悔棋功能需要保存历史局面栈加复盘功能需要记录走法序列升级界面可以对接SDL的图形渲染规则引擎一行不用改想让AI更强可以继续增加搜索深度并引入Alpha-Beta剪枝棋力会有质的提升。最后再分享一个小技巧给AI加一个“思考中...”的延时效果用Sleep函数或者usleep停顿几百毫秒体验会好很多。我第一次做的时候AI秒回棋玩家还没看清棋盘它就走完了观感非常差。加了这个延时之后整个对局节奏才像个正经象棋游戏。