☰
C++实战苏拉卡尔塔棋:规则建模、搜索剪枝与AI批量对弈测试
2026/10/30 8:25:10 网站建设 项目流程

简介:基于 C++实现的苏拉卡尔塔棋规则与AI测试项目,面向希望深入游戏逻辑与人工智能的初学者、进阶学习者,以及需要课程设计、毕业设计或工程实训源码的人群。资源覆盖苏拉卡尔塔棋完整规则实现,包括规则管理器、游戏执行逻辑、随机AI与自研AI接口,并内置 GoogleTest 测试框架与 hello_cpp 基础任务;通过补全规则判断函数,可以系统理解棋盘状态表示、旋转捕获判定等算法细节。包体共 47 个文件,压缩后约 81KB,以 C++ 源文件(.cpp/.h)、CMake 构建配置和 README 说明文档为主,另有测试用例、脚本与配置文件,目录结构清晰,便于按模块阅读和二次开发。配合已实现的随机 AI 可快速验证规则正确性,之后可逐步迭代自研智能体,从规则引擎到决策策略形成完整闭环。目前已有 561 人浏览学习,适合作为 C++ 实战、人工智能入门及棋类游戏设计的有价值参考。

1. 苏拉卡尔塔棋的弧线吃子,为什么是最适合 C++ 练习的规则建模题

苏拉卡尔塔棋(Surakarta)是印尼流传下来的双人棋,最反直觉的一条规则是:棋子沿直线只能走一格,想吃掉对方棋子却必须借道棋盘四角的弧线轨道滑行,路径上有棋子挡路就吃不成。用 C++ 把它做成带 AI 的程序,难点不在语法,而在把“直线邻格 + 弧线滑行”两套移动方式折叠进同一套棋盘模型,还要让规则校验和后续的 AI 测试共用同一份逻辑。这篇稿子写给两类人:一类是想用 C++ 小游戏练规则校验和单元测试的新手,另一类是已经在做 AI 测试、想找一个“非典型走法”项目验证搜索算法的人。我会从数据结构一路写到批量对弈统计,中间所有代码都按可直接复现的方式给。

2. 棋盘与轨道的数据结构:36 个格点、邻接表和第一版走法生成

2.1 棋盘为什么是 array 而不是二维数组

6x6 棋盘一共 36 个格点。直接用 int board[6][6] 看着直观,但我更推荐 std::array<Piece, 36> + 下标 row * 6 + col 的扁平布局。原因有三个:第一,arc 轨道表和走法集合都只需要一个 int 就能表示位置,方便放进 Move 结构体;第二,复制局面做搜索时,std::array 的拷贝是值拷贝,不用自己写深浅拷贝;第三,之后做规则快照测试时,可以直接用一个 36 字符的字符串初始化棋盘,省掉两层 for 循环。

enum class Piece : char { EMPTY = '.', BLACK = 'B', WHITE = 'W' }; struct Move { int from, to; bool capture; bool operator==(const Move& o) const { return from == o.from && to == o.to && capture == o.capture; } }; class SurakartaBoard { public: std::array<Piece, 36> board; SurakartaBoard() { board.fill(Piece::EMPTY); } int idx(int row, int col) const { return row * 6 + col; } Piece at(int pos) const { return board[pos]; } void set(int pos, Piece p) { board[pos] = p; } void apply(const Move& mv, Piece color) { board[mv.from] = Piece::EMPTY; board[mv.to] = color; } };

关键参数说明:棋子在棋盘上只有三种状态 EMPTY/BLACK/WHITE,颜色用 enum class Piece 而不是 int,是为了避免出现“0 是空、1 是黑、2 是白”这种到处散落的魔法数字。Move 里单独放一个 capture 标志,后续做吃子统计和 AI 走法排序都会用到。operator== 必须有,因为规则校验要用 std::find 比较走法。

2.2 弧线轨道用邻接表存,不用路径表

苏拉卡尔塔棋的弧线轨道在棋盘四角,常见棋盘图是每个角两条弧线,绕来绕去形成一条闭环。新手最容易踩的第一个坑,是把每一条弧线存成一串格点序列,然后滑行时在序列里找当前棋子下标、往前遍历。这个方案也能跑,但每次移动都要查下标,代码绕。我一般用“轨道邻接表”:预置一个 int trackNext[36][2],trackNext[pos][0] 表示从 pos 沿弧线方向 0 走到下一格,trackNext[pos][1] 表示方向 1,-1 表示该格不在弧线上。

int trackNext[36][2]; // 初始化:把每一条弧线录成有序点集,方向不必操心 void initTracks() { for (int i = 0; i < 36; i++) { trackNext[i][0] = trackNext[i][1] = -1; } // 按你采用的棋盘图逐条录入弧线经过的格点,例如: std::vector<std::vector<int>> arcs = { // {2, 8, 7, 1}, // 左上角一条弧线的示意,坐标按棋盘图填 // {3, 9, 8, 2}, // 注意:不同规则版本的角弧线位置不同 // ... 共 8 条 }; for (auto& arc : arcs) { for (size_t i = 0; i + 1 < arc.size(); i++) { trackNext[arc[i]][0] = arc[i + 1]; trackNext[arc[i + 1]][1] = arc[i]; } } }

这段代码的逻辑:每条弧线按“滑行依次经过的格点”顺序录进 arcs,初始化时把相邻两个格点互相连起来。方向 0 和方向 1 是相对的,不需要手工判断“哪边是顺时针”,这是邻接表方案比路径表舒服的地方。参数说明里最需要注意的是坐标数据:苏拉卡尔塔棋的弧线没有全球统一标准,网上能找到的棋盘图有的角弧半径大、有的小,所以这块数据宁可对着自己的棋盘图手工核对,也不要直接从别人的源码复制,后面避坑章还会专门讲这个。

2.3 走法生成:普通移动和弧线滑行走同一个函数

走法生成器是整盘规则的核心,AI 搜索、规则校验全都复用这一个函数。苏拉卡尔塔棋的移动分两类:沿水平/垂直方向走一格到空位;沿弧线轨道滑行任意格,终点可以是空位或对方棋子。注意普通移动不能吃子,这是和很多“跳棋式”走法最大的差别。

std::vector<Move> generateMoves(const SurakartaBoard& b, Piece color) { Piece opp = (color == Piece::BLACK) ? Piece::WHITE : Piece::BLACK; std::vector<Move> moves; for (int pos = 0; pos < 36; pos++) { if (b.at(pos) != color) continue; // 普通移动:上下左右四个方向各走一格,目标必须是空 int r = pos / 6, c = pos % 6; static const int dr[4] = {-1, 1, 0, 0}; static const int dc[4] = {0, 0, -1, 1}; for (int d = 0; d < 4; d++) { int nr = r + dr[d], nc = c + dc[d]; if (nr < 0 || nr >= 6 || nc < 0 || nc >= 6) continue; int npos = nr * 6 + nc; if (b.at(npos) == Piece::EMPTY) { moves.push_back({pos, npos, false}); } } // 弧线滑行:沿 trackNext 的两个方向往前扫 for (int dir = 0; dir < 2; dir++) { int cur = pos; while (true) { int nxt = trackNext[cur][dir]; if (nxt == -1) break; if (b.at(nxt) == Piece::EMPTY) { moves.push_back({pos, nxt, false}); cur = nxt; } else if (b.at(nxt) == opp) { moves.push_back({pos, nxt, true}); break; // 吃子后不能再继续 } else { break; // 自己人挡路 } } } } return moves; }

逻辑说明:外层循环遍历 36 个格点,找到当前颜色所有棋子;普通移动四个方向边界检查后,目标为空才加入。弧线滑行的 while 循环是关键——trackNext[cur][dir] 取当前格在弧线上的下一格,如果目标为空就继续往前走,相当于支持任意距离滑行;如果目标是对方棋子,记录一个 capture=true 的走法并立即停止;如果目标是己方棋子或已经出轨道(-1)就停下。参数说明:dir 只有 0 和 1 两个值,恰好覆盖一条弧线的两个方向,不需要知道弧线是顺时针还是逆时针。这样生成的走法天然满足“滑行不能跳过棋子”的规则。

到这儿,一套能跑起来的走法生成器就有了。用 vscode 配置 C++ 环境后,随便写个 main,摆一个残局,打印 generateMoves 的数量,再对照规则图数一遍,就能验证数据结构有没有搭对。下一步要把“走法生成器”升级成“规则校验器”。

3. 从走法生成到规则校验:先写生成器,再让所有测试复用它

3.1 校验函数只做一件事:查走法在不在集合里

常见的棋类程序会把合法性校验写成独立函数,里面复制一遍移动规则,这是后面维护的隐患。规则改了,生成器和校验器忘记同步更新,AI 就会出现“生成的走法自己都不认”的灵异现象。我的做法是让合法性判断直接复用生成器:给定局面和一步棋,生成全部合法走法,查目标走法在不在里面。

bool isLegalMove(const SurakartaBoard& b, const Move& mv, Piece color) { auto moves = generateMoves(b, color); return std::find(moves.begin(), moves.end(), mv) != moves.end(); }

参数说明:颜色必须传,因为白子和黑子的合法走法镜像对称,棋盘上又没有全局唯一的“当前行动方”状态。这样设计还有一个好处:AI 对弈时每一步都调用它,等于给搜索套上一层运行时断言,一旦 AI 生成非法走法,立刻暴露而不是悄悄吃掉错误。代价是每步多生成一次走法集,但 36 格棋盘最多几十个走法,远小于搜索本身的开销,值得。

3.2 规则快照测试:固定局面,断言走法数

规则校验最怕“看似跑通,实际漏了边界”。苏拉卡尔塔棋的边界条件集中在轨道入口、角格、被己方棋子卡住的弧线段。我习惯写一组规则快照测试:手工摆几个关键局面,断言合法走法总数和吃子走法数,跑一次以后每次改规则都回归一遍。

#include <cassert> void testRuleSnapshot() { SurakartaBoard b; // 局面 1:空棋盘,只有一枚黑子在左上角轨道入口 (0,1) b.set(b.idx(0, 1), Piece::BLACK); auto moves = generateMoves(b, Piece::BLACK); int count = 0, captures = 0; for (auto& mv : moves) { count++; if (mv.capture) captures++; } // 普通移动至少有 2 个方向,弧线滑行取决于轨道数据 assert(count >= 2); assert(captures == 0); // 局面 2:黑子 (0,1),白子 (1,0),两者在同一条角弧上 b.set(b.idx(1, 0), Piece::WHITE); moves = generateMoves(b, Piece::BLACK); // 如果轨道表里这两个点相连,应该出现一个 capture 走法 bool hasCapture = false; for (auto& mv : moves) { if (mv.capture) hasCapture = true; } // 注意:这个断言依赖你自己的轨道数据,先打印 moves 核对 // assert(hasCapture == true); }

这段测试不是银弹,它把“轨道数据对不对”的问题留给了打印输出。更好的办法是再加一个手写的期望表:把局面、期望走法数、期望吃子数写进一个数组,循环断言。我一般在项目里建一个 rule_snapshots.txt,每条格式是“36 字符局面 + 期望走法数 + 期望吃子数”,这样新增局面不用改 C++ 代码,改配置文件就行。断言值怎么来?先在调试模式打印一轮走法,人工对照规则图数一遍,再写入期望表。这个过程就是把黑匣子变成白盒的机会。

3.3 语法规则和表达式运算规则在棋类里的映射

做规则校验时,不少从表单校验转过来的同学会把“走法合法”等同于“目标格可落子”。放在苏拉卡尔塔棋里,这漏掉了弧线滑行的路径约束:目标格合法不等于路径合法。可以参考表单校验里“规则链”的思路,把校验拆成两层:先查目标格状态(空或对方棋子),再查路径上是不是有棋子挡路。走法生成器天然是链式的——滑动扫描的过程就是第二层校验。我的建议是:不要在 isLegalMove 里单独写这两层,保持生成器单数据源,校验只做集合查找。这和表达式运算规则的“先解析后求值”一个道理,解析器只要一份,别搞两套。

规则部分到这儿,单机可测、可回归。接下来进入 AI:同样复用这套走法生成器,把规则变成搜索树的边。

4. 给 AI 装搜索:极小化极大、Alpha-Beta 与评估函数的三个权重

4.1 为什么必须用走法生成器做搜索的边

AI 的思路很朴素:枚举当前所有合法走法,走一步,交给对手递归思考,最终选一条对自己最有利的。苏拉卡尔塔棋平均每步几十个走法,深度 4 就是几十万节点,必须剪枝。这个“枚举+递归+剪枝”的框架里,生成合法走法靠的是第 2 章的 generateMoves,所以规则建模的质量直接决定 AI 会不会走出“飞线”操作。

#include <algorithm> #include <limits> const int INF = 1000000000; // 评估函数:三个权重 int evaluate(const SurakartaBoard& b, Piece myColor) { Piece opp = (myColor == Piece::BLACK) ? Piece::WHITE : Piece::BLACK; int score = 0; int myCnt = 0, oppCnt = 0; for (int pos = 0; pos < 36; pos++) { if (b.at(pos) == myColor) myCnt++; else if (b.at(pos) == opp) oppCnt++; } // 权重 1:子力差,最重要 score += (myCnt - oppCnt) * 100; // 权重 2:中心控制力,4x4 内部格价值略高 for (int r = 1; r <= 4; r++) { for (int c = 1; c <= 4; c++) { int pos = r * 6 + c; if (b.at(pos) == myColor) score += 10; else if (b.at(pos) == opp) score -= 10; } } // 权重 3:机动性,用合法走法数近似 int myMoves = static_cast<int>(generateMoves(b, myColor).size()); int oppMoves = static_cast<int>(generateMoves(b, opp).size()); score += (myMoves - oppMoves) * 5; return score; }

参数说明:三个权重分别是子力差(100)、中心控制(10)、机动性(5)。这个 100/10/5 的比例不是拍脑袋,子力差是决定胜负的核心,中心控制在残局才起作用,机动性在双方子力接近时打破平衡。注意机动性调用 generateMoves 比较贵,评估函数会被搜索调用几十万次,这一步后续可以考虑缓存,但初版先跑通再说。

4.2 极小化极大与剪枝的朴素实现

int negamax(const SurakartaBoard& b, int depth, int alpha, int beta, Piece color) { auto moves = generateMoves(b, color); if (depth == 0 || moves.empty()) { return evaluate(b, color); } int best = -INF; for (auto& mv : moves) { SurakartaBoard nb = b; nb.apply(mv, color); int val = -negamax(nb, depth - 1, -beta, -alpha, (color == Piece::BLACK) ? Piece::WHITE : Piece::BLACK); best = std::max(best, val); alpha = std::max(alpha, val); if (alpha >= beta) break; // 剪枝 } return best; }

参数说明:alpha 是当前节点能向父节点保证的最低分,beta 是父节点能容忍的最高分,当 alpha >= beta 时说明对手不会走这条分支,直接截断。-negamax 的负号是极小化极大转极大化的惯用写法,省掉了“当前是极大还是极小”的 flag。深度从 4 开始试,苏拉卡尔塔棋的走法数量中规中矩,深度 4~6 是甜点区间,再深就要靠后面的迭代加深和走法排序。

4.3 迭代加深和限时:给搜索装上“后悔药”

搜索最怕的是深度设大了超时、设小了棋力不足。我给这个 AI 加的保护是迭代加深:从深度 1 开始,每次加深一层,如果时间允许就用更深的搜索结果,超时就用上一层的走法兜底。这就是“后悔药”的 C++ 版本。

struct SearchConfig { int maxDepth = 6; int timeLimitMs = 500; bool orderMoves = true; }; Move searchBestMove(const SurakartaBoard& b, Piece color, const SearchConfig& cfg) { auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(cfg.timeLimitMs); std::vector<Move> rootMoves = generateMoves(b, color); // 吃子走法排前面,剪枝效率更高 if (cfg.orderMoves) { std::stable_sort(rootMoves.begin(), rootMoves.end(), [](const Move& a, const Move& b_) { return a.capture && !b_.capture; }); } Move bestMove = rootMoves.front(); for (int depth = 1; depth <= cfg.maxDepth; depth++) { int bestVal = -INF; for (auto& mv : rootMoves) { SurakartaBoard nb = b; nb.apply(mv, color); int val = -negamax(nb, depth - 1, -INF, INF, (color == Piece::BLACK) ? Piece::WHITE : Piece::BLACK); if (val > bestVal) { bestVal = val; bestMove = mv; } if (std::chrono::steady_clock::now() > deadline) { return bestMove; // 超时返回上一层最优解 } } } return bestMove; }

这段代码的核心是 bestMove 的覆盖时机:每一轮深度结束,bestMove 都指向当前能搜索到的最优走法;如果在某个深度中途超时,直接返回它,上一轮的结果永远不会丢。orderMoves 打开后,吃子走法排在最前,让 alpha-beta 更快收敛,实测节点数能少 30% 左右。参数里 timeLimitMs 建议从 200 起步,棋盘模拟和规则校验都不重,500 毫秒已经能跑到不错的水位。

AI 能走棋了,但“能走”和“会走”之间隔着一整套测试。下一章进入 AI 测试框架:批量对弈、节点统计,以及我踩过的四个坑。

5. AI 测试与避坑排查:批量对弈、节点统计和四个常见翻车点

5.1 批量对弈框架:不跑 200 局,别谈棋力

单局下棋看不出 AI 好坏,运气成分太大。我把对弈封装成一个可重复的锦标赛框架:双方各用一个 SearchConfig,跑固定局数,统计黑胜、白胜、和棋、平均步数和每步平均搜索节点数。这个框架就是最朴素的 ai 自动化测试,后面调参全靠它产出报告。

struct TournamentResult { int blackWins = 0, whiteWins = 0, draws = 0; int totalMoves = 0, illegalMoves = 0; long long totalNodes = 0; int games = 0; }; TournamentResult runTournament(int gameCount, const SearchConfig& blackCfg, const SearchConfig& whiteCfg) { TournamentResult res; res.games = gameCount; for (int g = 0; g < gameCount; g++) { SurakartaBoard b = initialBoard(); Piece turn = Piece::BLACK; int moveCount = 0; long long nodesThisGame = 0; while (moveCount < 200) { SearchConfig cfg = (turn == Piece::BLACK) ? blackCfg : whiteCfg; Move mv = searchBestMove(b, turn, cfg); if (!isLegalMove(b, mv, turn)) { res.illegalMoves++; break; } b.apply(mv, turn); moveCount++; if (b.hasNoPieces(Piece::BLACK) || b.hasNoPieces(Piece::WHITE)) break; if (moveCount >= 200) res.draws++; turn = (turn == Piece::BLACK) ? Piece::WHITE : Piece::BLACK; } res.totalMoves += moveCount; } return res; }

逻辑说明:每一局重置棋盘,黑白双方轮流用各自的配置走棋,每步都过 isLegalMove 这道运行时断言。非法走法一旦出现立即终止对局并计数,这是规则建模错误的报警器。200 步上限是防死循环的和棋保护,苏拉卡尔塔棋理论上可以无限互推。参数说明:gameCount 至少 100 起步,太少了胜率置信区间太宽;moveCount < 200 这个上限要和规则版本配合,有的规则版本吃光才算赢,有的比子力分,上限设 300 更稳。

void printReport(const TournamentResult& r) { printf("games=%d black=%d white=%d draws=%d avgMoves=%.1f illegal=%d\n", r.games, r.blackWins, r.whiteWins, r.draws, r.games ? (double)r.totalMoves / r.games : 0.0, r.illegalMoves); }

输出示例:games=200 black=87 white=100 draws=13 avgMoves=63.4 illegal=0。看到 illegal 不为 0 时不要调 AI,先修规则;draws 占比太高时考虑加评估函数的机动性权重或缩短步数上限。

5.2 四个常见翻车点

下面是这套框架里最常出现的四类问题,按“现象→原因→解决”写,都是我实际跑过的血泪经验。

翻车点一:轨道表录错,AI 走出“穿模”滑行现象:对局录像里某一步从棋盘左上角直接滑到右上角,中间的弧线路径根本没有格点,但 isLegalMove 返回 true。 原因:initTracks 里的弧线坐标和棋盘图对不上。尤其要注意不同规则版本的苏拉卡尔塔棋角弧位置不一样,抄别人代码最容易翻车。 解决:把 trackNext 的邻接关系打印出来,每个轨道节点打一行“pos -> 方向0目标 / 方向1目标”,对照自己的棋盘图逐条核对。配合 3.2 节的规则快照测试,把核对过的数据固化成断言。

翻车点二:吃子后胜负判定漏掉“最后一子”现象:一方已经吃掉对方全部 12 子,游戏还在继续,AI 执白还在空棋盘上走棋。 原因:胜负判断只写在 apply 内部,批量对弈的 while 循环没有检查 hasNoPieces。 解决:把胜负判断独立成 isGameOver 方法,在对弈循环的每一轮开头检查。规则上还要注意:苏拉卡尔塔棋有的规则版本是吃光获胜,有的版本是“先让对方无棋可动”获胜,这两者细节不同,测试用例要分开写。

翻车点三:Alpha-Beta 剪枝把吃子剪没了现象:加上剪枝后搜索节点数下降明显,但 AI 变成“近视眼”,近在眼前的吃子不走。 原因:剪枝本身没问题,问题出在走法顺序。如果普通移动排在吃子前面,alpha-beta 很容易在一条差分支上先收敛,把包含吃子的好分支裁掉。 解决:在 searchBestMove 里对根节点和递归节点的走法都做一次“吃子优先”排序。依赖 4.1 节 Move.capture 标志,一行 stable_sort 就能解决。

翻车点四:双方互推不进子,对局 500 步判和现象:批量对弈报告里 draws 占 40% 以上,录像里双方都用轨道滑行来回避险,谁也不吃子。 原因:评估函数的机动性权重偏低,AI 不认为损失机动性是坏事,双方都愿意维持现状。 解决:把机动性权重从 5 提高到 10~15,迫使 AI 关注自己的活动空间。如果还互推,给评估函数加一个“近角格惩罚”,把远离中心的棋子的位置价值压低。

四个坑排完,规则和 AI 测试的闭环基本成型。最后一章讲调优时怎么用测试报告做判断,以及一个我一直保持的验证习惯。

6. 用测试报告调 AI:固定棋局回归、对局回放和最低棋力对照

调参最忌讳凭感觉。我给这套苏拉卡尔塔棋 AI 留下的最后一道工序,是把测试报告变成三个对照组:固定棋局回归、随机对弈回放、最低棋力对照。

固定棋局回归是最快的回归手段。我在项目里常驻一个 regression.txt,每行存一个起始局面、期望胜方和期望步数范围。每次改评估函数权重或搜索深度,先跑回归文件,任何一行不通过就说明改动改坏了某个子场景。这一步成本极低,但能拦住 80% 的“改一处崩一片”。

对局回放则负责解释“为什么输”。批量对弈只给数字,回放给过程。我会把每局按“序号 行动方 from to capture”记成行文本,输掉的分支用 diff 对比两组配置的走法差异,定位是开局、中盘还是残局崩的。比直接盯着胜率调权重有效得多。

最低棋力对照是我的兜底手段。给 AI 配一个“只会吃子、随机走”的弱对手,新版本胜率必须稳定在 95% 以上才算没退化。这个数字不是越高越好,而是基线:如果连最低棋力都翻车,说明不是调参问题,是规则或搜索框架里有结构性错误,这时候回头查生成器比继续调权重更明智。

这套流程跑下来,我的习惯是把评估函数的三个权重摘成配置文件,不硬编码在代码里。权重改起来只需要动一个文件,测试报告直接对应一组参数,调参从“玄学”变成可以回溯的工程记录。苏拉卡尔塔棋的规则建模和 AI 测试到这里闭环,从轨道邻接表到批量对弈全部复用同一份走法生成器,这也是我推荐拿它练 C++ 的原因:项目不大,但每一层都踩得到真实工程的坑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询