news 2026/10/15 12:27:25

纯C语言实现中国象棋:从数据结构到人机AI的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯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;

这个结构体贯穿整个程序。玩家输入解析完填充一个Move,AI思考完也返回一个Move,执行走棋函数接收Move,合法性判断接收Move。score字段一开始你可能觉得多余,但AI生成走法时会大量排序比较,把它放在Move里,排序时直接按score比较,代码会非常简洁。

2.3 坐标系统与界面显示的对应关系

这里有个初学者最容易踩的坑:屏幕显示的行列和数组下标不一致。

棋盘的显示逻辑是:红方在下方(第9行),黑方在上方(第0行),列则是传统的九条线。但数组索引arr[x][y]天然是x行y列,x=0是顶部。我用的约定是:

  • 数组第一维是行,范围0~9,0在棋盘顶端(黑方底线),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内部会再次遍历所有棋子并调用canMove,canMove里不会递归调用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模式下调用aiThink,AI思考完后切换回玩家模式。这个结构后续加“悔棋”“保存复盘”都很容易,因为每一步都发生在明确的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初始化初始局面main
printBoardboard.c打印棋盘main
parsePlayerInputboard.c解析玩家输入main
canMoverules.c统一走法校验main / ai.c
isKingInCheckrules.c检测将军状态main / ai.c
isCheckmaterules.c检测将死main
generateAllMovesai.c生成所有合法走法aiThink / isCheckmate
evaluateBoardai.c局面评估aiThink
aiThinkai.cAI决策入口main

你写完rules.c之后,先用双人对战模式把所有走法合法性都测一遍,再开始写AI。AI的调试难度远高于规则,如果规则本身有bug,AI的“聪明”就无从谈起。

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 常见问题速查表

现象可能原因排查与解法
棋子瞬移/消失坐标数组越界检查所有坐标偏移的边界判断
马腿判断失效马腿坐标越界未处理先做边界判断再访问数组
炮能隔多个子吃炮的移动和吃子共用条件分target==EMPTY和!=EMPTY两种情况
AI吃自己棋子符号判断写反检查piece * player > 0 的运算符
游戏卡死无子可动未判负主循环补充困毙检测
输入命令无效scanf与fgets混用统一用fgets读行并解析
老将照面不判负缺少照面检测isKingInCheck里增加同列无子检测

这些坑看起来琐碎,但每一个都对应着象棋程序里最容易出错的地方。你写的时候遇到同样的问题,直接对照这张表查,能省不少时间。

写完这个项目之后的几点体会

这个项目的工程量其实比看上去要大。棋盘编码、规则系统、AI评估、输入解析,每个模块单独拿出来都不难,但拼在一起时模块之间的协调才是真正的挑战。我做完之后最大的感受是:数据结构选好了,后面全部顺;坐标系定死了,调试省一半。

如果你打算在这个基础上继续扩展,我有几个建议方向:加悔棋功能需要保存历史局面栈;加复盘功能需要记录走法序列;升级界面可以对接SDL的图形渲染,规则引擎一行不用改;想让AI更强,可以继续增加搜索深度并引入Alpha-Beta剪枝,棋力会有质的提升。

最后再分享一个小技巧:给AI加一个“思考中...”的延时效果,用Sleep函数或者usleep停顿几百毫秒,体验会好很多。我第一次做的时候AI秒回棋,玩家还没看清棋盘它就走完了,观感非常差。加了这个延时之后,整个对局节奏才像个正经象棋游戏。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 12:27:04

IEC 104客户端/服务端仿真工具实战:从总召到遥控的调试全攻略

简介&#xff1a;面向电力系统自动化领域的IEC 104规约客户端与服务端仿真工具&#xff0c;可供规约开发、联调测试及SCADA集成人员使用。工具内含SCADA客户端模拟和RTU/保护设备服务端模拟&#xff0c;支持遥测、遥信、遥控、遥调等数据交互&#xff0c;覆盖ASDU&#xff08;A…

作者头像 李华
网站建设 2026/10/15 12:26:52

函数式编程中的函数组合技术解析与实践

1. 函数组合的本质与核心价值 函数组合&#xff08;Function Composition&#xff09;作为函数式编程范式的核心支柱之一&#xff0c;其本质是将多个基础函数像管道一样连接起来&#xff0c;形成新的功能单元。这种编程方式彻底改变了传统命令式代码的组织形式——不再关注&quo…

作者头像 李华
网站建设 2026/10/15 12:26:45

STM32F103精准驱动步进电机:STEP/DIR/VREF协同控制实战

简介&#xff1a;本资源是一份基于STM32F103单片机实现PWM驱动42步进电机的完整嵌入式开发工程&#xff0c;面向嵌入式初学者、电子设计爱好者及高校电类专业学生&#xff0c;解决步进电机精准调速与相序控制这一典型运动控制问题。压缩包共136个文件&#xff0c;含35个头文件&…

作者头像 李华
网站建设 2026/10/15 12:26:27

STM32F407驱动C610电调控制M2006电机:CAN总线与PID闭环实战

简介&#xff1a;面向需快速上手大疆动力系统的嵌入式开发者&#xff0c;这是一份基于正点原子探索者STM32F407的完整CAN通信控制工程&#xff0c;实现对大疆C610电调与M2006电机的驱动控制&#xff0c;可广泛应用于机器人竞赛、智能车底盘、云台动力等场景。工程内部分层清晰&…

作者头像 李华
网站建设 2026/10/15 12:24:59

张量工程实战:工程师的多维数据扳手

1. 这不是数学课&#xff0c;是工程师手里的“多维数据扳手”很多人第一次听到“张量”两个字&#xff0c;下意识就缩了——以为又要掉进线性代数的抽象迷宫里&#xff1a;什么协变逆变、指标升降、黎曼曲率……其实大可不必。我在某高校实验室带过三年图像处理项目&#xff0c…

作者头像 李华