尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

用C++实现同化棋:从命令行对战到AI自对弈的完整指南

发布时间:2026/9/28 3:03:57

资讯中心
01
ARTICLE

用C++实现同化棋:从命令行对战到AI自对弈的完整指南

用C++实现同化棋:从命令行对战到AI自对弈的完整指南
简介面向C课程设计的一款同化棋游戏项目适合正在学习面向对象编程、算法设计或游戏开发的学生可作为课程设计或期末实践参考。项目覆盖同化棋核心规则、用户输入校验、基于极大极小算法或Alpha-Beta剪枝的AI下法、棋盘二维数组表示与更新、文件流实现自动存档读档以及异常处理等关键知识点。压缩包共7个文件包括2个C源文件、可直接运行的exe程序、棋盘数据文本、说明文档、运行截图和许可证整体仅494KB结构清晰。目前已有407人学习下载。通过该资源读者既能查看完整课设代码理解AI算法与棋盘状态持久化的具体实现也能直接运行程序体验人机对弈借助说明文档快速理清各文件作用是一份兼顾实践与参考价值的C课程设计范例。1. 同化棋这个棋盘游戏为什么值得用 C 从头写一遍同化棋Ataxx是我见过最适合练 C 的小型棋盘游戏。规则一句话能讲完7×7 棋盘四角放棋子每步要么在相邻格复制一枚要么跳到两格外的空位落子后周围一圈敌方棋子全部变成你的最后棋子数多的一方获胜。但把这句话变成能跑的代码你得处理二维数组越界、合法走法判断、棋盘状态翻转、无棋可走和输入输出难度曲线正好卡在「C 入门刚结束、数据结构还没学透」的位置上。这篇不是抄现成源码而是把棋盘建模、走法生成、扩散同化、命令行交互和避坑经验按可复现的顺序讲清楚。用它练手比刷一百道语法选择题有用。适合刚学完 C 基础的读者也适合想给棋盘游戏加个 AI 玩伴的人。2. 棋盘数据结构与走法生成用 std::array 把 7×7 棋盘管明白2.1 固定棋盘为什么不用 vectorstd::array 与 7×7 的选择同化棋的棋盘尺寸是固定的最常见是 7×7也有 8×8 变体。先别急着敲vectorvectorCell棋盘大小不会变std::array更合适。C 里std::arraystd::arrayCell, 7, 7是连续内存随机访问和遍历都简单还能避免嵌套 vector 逐行分配堆内存带来的性能噪音。嵌套 vector 不是不能用但每行一个堆块走法生成时反复读内存cache 不友好写起来还多一层at()的防御心理。定义棋盘状态的代码只需要四行#include array enum class Cell { Empty, // 空格 Black, // 黑方 White // 白方 }; using Board std::arraystd::arrayCell, 7, 7;enum class比裸int或char安全得多编译器会阻止你把Cell当整数乱加减。using Board ...给这个二维数组起了个名字后面printBoard、genMoves、applyMove的函数签名都写const Board读代码的人一眼就知道传的是什么。开局摆子也有讲究四角对称放保证黑方白方初始机会均等。左上黑、右上白、左下白、右下黑Board initBoard() { Board b{}; // 花括号初始化把所有格置为 Cell::Empty b[0][0] Cell::Black; // 左上角 b[0][6] Cell::White; // 右上角 b[6][0] Cell::White; // 左下角 b[6][6] Cell::Black; // 右下角 return b; }Board b{}里的空花括号很容易被新手跳过。如果写成Board b;std::array的默认初始化会把每个元素都留成未初始化状态栈上可能是垃圾值棋盘上就会出现随机棋子。这种问题第一次遇到通常要查半天所以我习惯所有容器一律花括号初始化宁可多写一个空括号。2.2 走法生成相邻复制与两格跳跃的判定同化棋一步棋的落点只有两种情况源棋子相邻八格这是复制走法源棋子保留新位置多一个己方棋子源棋子两格距离内的空位这是跳跃走法源棋子清空新位置长出一个棋子。这里“两格距离”按切比雪夫距离算也就是行差、列差里最大的那个等于 2水平两格、竖直两格、对角两格、甚至“日字跳”都算。走法生成的代码是整个规则引擎的地基const int dr[8] {-1, -1, -1, 0, 0, 1, 1, 1}; const int dc[8] {-1, 0, 1, -1, 1, -1, 0, 1}; struct Move { int fromR, fromC; // 源格子坐标 int toR, toC; // 目标格子坐标 bool isCopy; // true相邻复制false两格跳跃 }; bool inBounds(int r, int c) { return r 0 r 7 c 0 c 7; } std::vectorMove genMoves(const Board b, Cell side) { std::vectorMove moves; for (int r 0; r 7; r) { for (int c 0; c 7; c) { if (b[r][c] ! side) continue; // 相邻八格复制源格不动 for (int d 0; d 8; d) { int nr r dr[d]; int nc c dc[d]; if (inBounds(nr, nc) b[nr][nc] Cell::Empty) moves.push_back({r, c, nr, nc, true}); } // 两格跳跃行差、列差里较大的那个等于 2 for (int rr -2; rr 2; rr) { for (int cc -2; cc 2; cc) { if (std::max(std::abs(rr), std::abs(cc)) ! 2) continue; int nr r rr; int nc c cc; if (inBounds(nr, nc) b[nr][nc] Cell::Empty) moves.push_back({r, c, nr, nc, false}); } } } } return moves; }走法生成的逻辑可以拆成三层看。外层是两层for遍历整个 7×7 棋盘找到所有属于当前行动方的棋子这是“以子找步”不是遍历空位找哪个子能落过来。中间是相邻八格的dr/dc偏移数组八个方向都检查一遍目标格为空才生成复制走法。内层是跳跃扫描rr和cc各自从 -2 到 2用切比雪夫距离筛掉距离不是 2 的格子这样自然把水平、竖直、对角和日字跳全部覆盖。这个双重循环看起来重复检查了很多格实际每次走法生成最多也就检查 49×8 49×24 次对 7×7 小棋盘根本没压力。如果你想在更大棋盘上跑 AI可以把从每个格子出发的合法跳跃目标预先算成一张静态偏移表比如std::vectorstd::pairint,int jumpOffsets[7][7]生成走法时直接查表。7×7 规模不需要但如果哪天改成 19×19 棋盘这个预计算优化就是刚需。2.3 一维下标 vs 二维下标性能与可读性怎么取舍有人会把棋盘压成一维数组std::arrayCell, 49下标r * 7 c。一维好处是拷贝棋盘更快Board copy curBoard;就是一块连续内存的复制AI 里大量模拟走法时能省一点时间。坏处也很明显代码里到处都是r * 7 c和idx / 7状态多了以后看着晕相邻格子的偏移量还得单独算。我的经验是第一版先用二维下标把规则调通让 bug 少一点。等 AI 模块跑起来用性能分析工具确认瓶颈真的在棋盘拷贝上再决定要不要改成std::arrayCell, 49。同化棋这种 7×7 小棋盘一维和二维的差距在普通命令行对局里人根本感知不到。把规则写对、把边界情况处理干净比省几个时钟周期重要得多。3. 同化规则引擎落子、扩散同化与胜负判定3.1 先落子还是先扩散这一步的顺序就是胜负手同化棋的核心机制是“同化”新棋子落到目标格之后目标格周围八格里所有敌方棋子立刻变成己方棋子。这个翻转范围只算目标格的邻居跟源格在哪无关。所以 applyMove 里必须按顺序处理三件事清源格如果是跳跃、落子、扩散同化。Cell opponent(Cell c) { return c Cell::Black ? Cell::White : Cell::Black; } void applyMove(Board b, const Move m) { // 跳跃走法源格清空不保留原棋子 if (!m.isCopy) b[m.fromR][m.fromC] Cell::Empty; // 目标格落子 b[m.toR][m.toC] m.side? b[m.fromR][m.fromC] : Cell::Empty; // 占位后面改 }上面这段我故意写得有问题实际写的时候要更小心落子前先想清楚目标格从哪取棋子。正确写法是记录当前行动方然后把棋子直接放进目标格void applyMove(Board b, const Move m, Cell side) { if (!m.isCopy) b[m.fromR][m.fromC] Cell::Empty; b[m.toR][m.toC] side; for (int d 0; d 8; d) { int nr m.toR dr[d]; int nc m.toC dc[d]; if (inBounds(nr, nc) b[nr][nc] opponent(side)) b[nr][nc] side; } }注意side必须从外面传入不能用b[m.fromR][m.fromC]在清源格之后再去取因为跳跃时源格已经是Empty了。这也是为什么applyMove的签名里要带Cell side而不是靠棋盘状态反推。同化翻转的顺序看起来无所谓反正目标格周围八格最终都会变成当前方颜色。但如果你在翻转过程中把一个本要被翻转的敌方棋子又当成“源棋”来处理就会产生连锁翻转的错觉。常见翻车是顺手写了递归翻转结果一步棋像多米诺骨牌一样把半个棋盘都翻了。同化棋不是这样只翻目标格一圈不连坐。3.2 胜负判定棋盘占满、无子可动与平局同化棋的对局在两种情况下结束棋盘全部占满或者双方都没有合法的落子点。后一种情况在残局里很常见棋盘还剩零星几个空格但所有空格距当前位置都超过两格双方都动不了。int countPieces(const Board b, Cell side) { int cnt 0; for (int r 0; r 7; r) for (int c 0; c 7; c) if (b[r][c] side) cnt; return cnt; } bool isGameOver(const Board b, Cell winner) { int blackCnt countPieces(b, Cell::Black); int whiteCnt countPieces(b, Cell::White); bool blackHasMove !genMoves(b, Cell::Black).empty(); bool whiteHasMove !genMoves(b, Cell::White).empty(); if (blackCnt whiteCnt 49 || (!blackHasMove !whiteHasMove)) { if (blackCnt whiteCnt) winner Cell::Black; else if (whiteCnt blackCnt) winner Cell::White; else winner Cell::Empty; // 平局 return true; } return false; }这里给winner塞了一个Cell::Empty表示平局虽然语义上有点奇怪但省了再定义一个枚举类型。判定终局写在一起有个好处主循环里只需要问一次isGameOver不会出现在一个函数里判断棋盘满了另一个函数里判断无子可动两边标准不一致的问题。3.3 合法性校验别把判断逻辑散落在主循环里C 命令行程序最怕的是玩家乱输入输错一个坐标不是越界就是崩溃。合法性的判定要单独一个函数输入解析、AI 模拟走法、自对弈测试都要复用这一份逻辑。bool isLegal(const Board b, const Move m, Cell side) { if (!inBounds(m.fromR, m.fromC) || !inBounds(m.toR, m.toC)) return false; if (b[m.fromR][m.fromC] ! side) return false; if (b[m.toR][m.toC] ! Cell::Empty) return false; if (m.fromR m.toR m.fromC m.toC) return false; int distR std::abs(m.toR - m.fromR); int distC std::abs(m.toC - m.fromC); int len std::max(distR, distC); return len 1 || len 2; }isCopy字段在合法性校验里不参与判断只看距离。距离为 1 就是复制距离为 2 就是跳跃距离既不是 1 也不是 2 直接拒绝。这样 AI 生成走法时就算不小心把isCopy设错了applyMove也能按距离自动修正吗不行isCopy错了会灾难。所以更稳的做法是isLegal校验后在applyMove里重新赋值m.isCopy (len 1);这算是一种“不信任调用方”的防御姿势。劝你也这么干AI 写多了难免有手滑设错标志位的时候规则引擎主动兜底能省一个小时的调试时间。4. 把同化棋跑成一个能玩的程序命令行交互与最小闭环4.1 主循环与输入解析让玩家用 a1 g7 这种坐标走棋命令行版本的交互不需要花哨核心是一个while循环打印棋盘、让当前玩家输入、判定合法性、落子、切换行动方。这里有个小设计值得说玩家输入坐标用a1到g7这种棋谱记法比输入0 0直观得多也更符合棋盘游戏习惯。bool parseInput(const std::string s, int r, int c) { if (s.size() ! 2) return false; char colCh s[0]; char rowCh s[1]; if (!(colCh a colCh g)) return false; if (!(rowCh 1 rowCh 7)) return false; c colCh - a; r rowCh - 1; return true; }主循环里这样用Board board initBoard(); Cell cur Cell::Black; Cell winner Cell::Empty; while (!isGameOver(board, winner)) { printBoard(board); std::cout (cur Cell::Black ? 黑方 : 白方) 走棋格式a1 b2a 到 g1 到 7\n; std::string fromStr, toStr; std::cin fromStr toStr; int r1, c1, r2, c2; if (!parseInput(fromStr, r1, c1) || !parseInput(toStr, r2, c2)) { std::cout 坐标格式不对重新输入\n; continue; } Move m{r1, c1, r2, c2, false}; m.isCopy (std::max(std::abs(r2 - r1), std::abs(c2 - c1)) 1); if (isLegal(board, m, cur)) { applyMove(board, m, cur); cur opponent(cur); } else { std::cout 这步不合法重新输入\n; } } std::cout 对局结束 (winner Cell::Black ? 黑方胜 : winner Cell::White ? 白方胜 : 平局) \n;主循环一定要处理“当前玩家没有任何合法走法”的情况。上面这段看起来没问题但如果某个玩家这一步根本无棋可走isGameOver又还没判定终局循环就会卡在等输入。正确做法是先检查genMoves为空就自动换人并提示一句。这个坑后面专门讲。4.2 编译与运行VSCode 和 g 命令行两条路在 VSCode 里配置 C/C 环境重点不是装扩展而是生成一份能用的tasks.json。我的习惯是用g -stdc17 -Wall -g做编译-Wall打开绝大多数警告-g生成调试信息VSCode 的断点调试才能工作。只装了 C/C 插件不了解编译参数点半天三角形都跑不起来这是入门阶段最常见的挫败感来源。不想折腾编辑器配置就用命令行编译。最简单的命令是g -stdc17 -Wall -o ataxx main.cpp ./ataxxWindows 上如果编译器报g不是内部或外部命令通常是 MinGW 没装或者没把bin目录加进 PATH。还有一个容易踩的坑拿到别人编译好的 exe 双击没反应提示缺VCRUNTIME140.dll这是缺 Visual C Redistributable 运行库。新版 Dev-C 一般自带 MinGW可以编译运行但它的调试器配置有时候别别扭扭的遇到问题别怀疑代码先确认编译命令里没有漏-g。编译参数里我特意加了 C17。其实这份代码用 C11 也够但std::array和enum class在 C11 就齐了。用 C17 是给自己留余地后面加 AI 时如果要上std::optional、结构化绑定之类的新特性不用回头改编译选项。4.3 把棋盘打印出来坐标提示与棋子标识打印棋盘是命令行游戏的脸面做得清楚能省不少输入错误。我用两行式输出坐标沿上下两条边标好棋盘格用符号表示黑子X白子O空格.。void printBoard(const Board b) { std::cout a b c d e f g\n; for (int r 0; r 7; r) { std::cout (r 1) ; for (int c 0; c 7; c) { char ch (b[r][c] Cell::Black) ? X : (b[r][c] Cell::White) ? O : .; std::cout ch ; } std::cout (r 1) \n; } std::cout a b c d e f g\n; }上面这种两侧都标列号的做法方便横向对齐时看列。很多命令行棋盘只标一边列号终端里行距不同容易看歪。多打一行坐标几乎不耗成本体验好很多。X和O是国际象棋棋谱的常见表示玩家一眼能区分比用数字 1 和 2 直观得多。5. 同化棋避坑笔记5 个最常见的翻车现场5.1 越界访问跳格到边角时数组越界现象程序跑着跑着突然崩溃报段错误或者更隐蔽的落子后棋盘边缘多出怪异的棋子。原因走法生成里跳跃判断用了rr和cc从 -2 到 2 的双重循环没有在每个候选落点前检查是否还在棋盘内。玩到棋盘左上角附近时r rr变成 -1直接访问b[-1][c]越界。解决任何读写棋盘的地方都先过inBounds。我在 genMoves 里已经写进去了但要注意applyMove的扩散循环也可能越界目标格在边缘时八个邻居里有一半跑出棋盘。所以applyMove里扩散前也要判一次。// applyMove 内部扩散循环的正确姿势 for (int d 0; d 8; d) { int nr m.toR dr[d]; int nc m.toC dc[d]; if (!inBounds(nr, nc)) continue; if (b[nr][nc] opponent(side)) b[nr][nc] side; }血泪经验别在循环里写近似边界比如“反正从边角附近开始越界就跳过”。少写一个inBounds的代价是可能花一小时用调试器定位b[-1][5] 2这种魔改。5.2 同化时机先扩散后清空格导致棋盘棋子总数超过 49现象几轮走棋后棋盘上棋子总数明显超过 49或者白方的棋子数量总和对不上棋盘占用格数。原因applyMove里如果先做扩散同化、再清源格跳跃走法的源格本来要变空却在同化阶段被算成目标格的新棋子被“复制”了一遍。更常见的是跳跃时忘了清源格源格的棋子和目标格的新棋子同时存在棋盘上凭空多了一枚。解决严格执行固定顺序“清源格 → 落子 → 扩散”。并且在每次落子后加一个不变量检查int total countPieces(b, Cell::Black) countPieces(b, Cell::White); assert(total 49); // 实战时换成 ifrelease 版本别留着 assert建议正式版代码把assert换成if (total 49)并打印中间棋盘因为有些环境会关闭assert宏你以为是保护网其实根本没生效。我一般在调试阶段用assert跑自对弈迭代到 AI 阶段时换成显式if输出错误状态。5.3 单方无棋可走程序死循环的根因现象游戏进行到残局某个玩家已经没有合法走法但程序还停在那等人输入输入什么都提示不合法看起来像卡死。原因主循环里只判断了isGameOver的终局条件但终局条件要求“双方都无棋可走”。如果只有一方无棋可走游戏仍在进行中却没有任何人在代码层面帮他跳过这一步。解决在主循环的等待输入之前先检查当前玩家是否有棋可走没有就让过轮流换边if (genMoves(board, cur).empty()) { std::cout (cur Cell::Black ? 黑方 : 白方) 无棋可走跳过\n; cur opponent(cur); continue; }更聪明的做法是给isGameOver增加一个分支当前玩家无棋可走且对手也无棋可走立即终局。上面主循环的continue会先换人换人后如果对手也无棋可走isGameOver就满足条件了。两条路径是等价的我推荐在主循环里先判“当前玩家无棋可走就跳过”因为这样提示信息更友好玩家知道自己为什么被跳过。5.4 行列与 x/y 混淆镜像位置的错误走法现象输入坐标后棋子落到了完全相反的位置比如想从a1走到c3结果棋盘上c3动的是3c那个位置。原因代码里到处用(r, c)或(row, col)打印棋盘时先打行再打列但输入解析用了colCh在前、row在后的顺序。一旦某处省略参数名或调换了变量顺序坐标就镜像了。解决统一接口。parseInput解析出来的就是(r, c)内部所有地方都按行在前、列在后不要在中间某一层悄悄改成(c, r)。我用下面这种命名方式强制约束int row, col; // 永远是行在前 Move m{row, col, newRow, newCol, false};另外建议在printBoard之后加一行列号对照的调试输出比如打印a1对应的(0,0)方便一眼确认坐标映射没写反。这不是测试代码就当棋盘游戏界面的一部分玩家看着也有底。5.5 调试没固定随机数种子AI 走法每次都不一样现象给 AI 加上随机策略后同一个局面跑两次落子位置完全不同查 bug 时复现不出上一次的问题现场。原因代码里用了std::rand()且没有设置种子或者每次开局都调用std::srand(time(nullptr))。随机策略的 AI 每次从不同随机序列取数结果自然不稳定没法复现。解决调研和复现 bug 阶段把随机数种子固定成常量正式游玩阶段再放开随机种子。推荐用std::mt19937而不是老式std::rand()质量好且可控std::mt19937 rng(20240601); // 固定种子调试用 // 正式版再改成 std::random_device{}() 作为种子调试时固定种子最大的价值是“可复现”。发现一个 AI 逻辑错误用固定种子能稳定复现同一盘棋改一行代码后跑同一局面立刻知道这行代码修没修对。等全部逻辑稳定了再把种子换成随机值让每局棋不重样。C 随机数这块的惯例是测试用固定种子生产用熵源种子同一个 RNG 对象别到处传。6. 给同化棋加一个能打的 AI评估函数与自对弈验证6.1 评估函数棋子数与潜在走法怎么组合一个最简单的 AI靠贪心选棋子多的一方就已经能下过完全不会的玩家。评估函数把棋盘状态变成一个分数int evaluate(const Board b, Cell me) { int mine countPieces(b, me); int theirs countPieces(b, opponent(me)); return mine - theirs; }同化棋的翻盘机制意味着棋子数领先不代表安全因为对方一次跳跃可能同化你一整片棋子。所以评估函数里可以加一个走法数量项有更多合法走法的一方通常掌控局面能力更强。int evaluate(const Board b, Cell me) { int mine countPieces(b, me); int theirs countPieces(b, opponent(me)); int myMoves (int)genMoves(b, me).size(); int theirMoves (int)genMoves(b, opponent(me)).size(); return (mine - theirs) * 10 (myMoves - theirMoves); }系数 10 是拍脑袋定的但原则清楚棋子差是主目标走法差是辅助目标。我一般先按这个跑自对弈观察 AI 是不是总在一个局部绕圈如果发现 AI 只顾扩张不防守就把走法数的权重压下去。调权重没有银弹只能靠对局观察法。6.2 自对弈验证固定种子的回归测试AI 写完先别急着跟人下先让它跟自己下几十盘重点看三个指标有没有非法落子、有没有棋子总数超过 49、有没有出现双方无棋却还在空等。我习惯把 fixed seed 的自对弈结果打印成走子序列每步记录前后棋盘出 bug 就能精确回放。int minimax(Board b, Cell side, int depth) { if (depth 0) return evaluate(b, side); std::vectorMove moves genMoves(b, side); if (moves.empty()) return evaluate(b, side); int best -9999; for (const Move m : moves) { Board bt b; applyMove(bt, m, side); int score -minimax(bt, opponent(side), depth - 1); best std::max(best, score); } return best; }这段极小极大搜索用的是“负极大值”写法省掉了极大极小两层对称逻辑。depth2在 7×7 棋盘上一秒能跑完depth3会明显变慢因为走法分支数量很大。想提速就把评估函数里的countPieces在搜索过程里增量维护或者用 bitboard 压成 64 位整数做状态表示但那是性能优化的另一个故事。我现在的习惯是每次改动规则引擎后固定种子重跑一遍自对弈回归确认 100 局内没有非法状态出现再去调整 AI 的搜索深度和评估权重。规则引擎是 AI 的地基地基出错AI 再聪明也白搭。这套做法听起来朴素但它才是真的后悔药总比线上对局突然翻车再回头查好得多。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。