ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零实现同化棋C++小游戏:棋盘规则与AI搜索全解析

从零实现同化棋C++小游戏:棋盘规则与AI搜索全解析 简介面向 C 课程设计学习者这份同化棋游戏项目以棋盘策略为核心完整演示了从用户输入、棋盘状态维护到 AI 最优落子、自动存档恢复的闭环开发流程适合作为面向对象编程与小型游戏开发的练习范本项目代码结构清晰便于课上讲解或自学复现。压缩包共 7 个文件包含 2 个 cpp 源码含带 AI 的改进版与常规版、可直接运行的 exe、棋盘数据 txt、说明文档 md、许可文件及界面截图整体约 494KB。项目重点覆盖 iostream 输入校验与异常提示、fstream 棋盘持久化、游戏主循环与结束判定并通过 Minimax/Alpha-Beta 剪枝思路实现电脑下法计算帮助读者对照代码理解搜索算法、AI 决策和回合制流程。已有 407 人下载学习适合希望以真实项目巩固 C 语法、数据结构、算法设计和调试能力的初学者。1. 同化棋这个 C 小游戏为什么值得从零写一遍你搜“基于C开发的同化棋游戏”大概率不是想买成品而是想确认两件事这个玩法在 C 里到底能不能写出来以及写出来的东西有没有资格进简历。同化棋的规则很短玩家从己方任一棋子复制一格到相邻空格并同化落点周边一圈敌子。实现它需要过棋盘数据结构、移动生成、AI 搜索和 C 构建配置几道关恰好是 C 小游戏代码里最常拿来当面试题的几个点。这套代码跑起来之后落子、跳过、胜负判定一个不少AI 也能陪你下满一整盘。下面把所有实现细节、参数选择和一线调试现场摊开讲。2. 先从棋盘和落子规则落地同化棋的 C 数据结构与核心逻辑棋盘游戏开发的第一件事不是画界面而是把“状态”定清楚。同化棋的棋盘是一个 8×8 网格每个格子只有三种状态黑子、白子、空。这里我选最朴素的做法二维数组 枚举类型不引入任何图形库。这样不管是做控制台还是后续接 Qt/Unity规则引擎都能原样搬走。2.1 用一个二维枚举数组定义棋盘零状态也要先占位#include array #include vector #include iostream constexpr int kBoardSize 8; enum class Player { Empty 0, Black 1, White 2 }; using Board std::arraystd::arrayPlayer, kBoardSize, kBoardSize; void initBoard(Board board) { for (auto row : board) { row.fill(Player::Empty); } // 黑方占左上、右下白方占右上、左下保证开局对称 board[0][0] Player::Black; board[kBoardSize - 1][kBoardSize - 1] Player::Black; board[0][kBoardSize - 1] Player::White; board[kBoardSize - 1][0] Player::White; }这里选std::arraystd::arrayPlayer, 8, 8不是std::vector。原因是棋盘尺寸编译期固定array 的存储连续且分配在栈上以后递归搜索时复制整个棋盘的成本很低也不会出现动态分配带来的碎片化。Player::Empty 0也有实际意义默认初始化后每个格子都是 0调试时不会出现无法解释的棋子类型。开局布局必须对称否则先手方会因为初始位置优势占便宜。黑方在左上和右下白方在右上和左下四个角互相对角谁都不占中间。想让开局更有变化也可以改成每方两个棋子都放在同一边但那样先手方靠近中心的棋子会有明显扩张优势对测试不公平。2.2 生成合法落子方向增量表把八方向收进一个循环落子规则我采用最简单的一版选择一个己方棋子把它“复制”到相邻的 8 个方向中某一个空位原棋子保留。也就是说这不是移动是增殖。一个棋盘上黑子从四个变成几十个靠的就是这种复制。合法的落子必须满足两个条件起点是当前玩家棋子终点是空格且在棋盘内。struct Move { int fromRow, fromCol; int toRow, toCol; }; constexpr int kDirRow[8] {-1, -1, -1, 0, 0, 1, 1, 1}; constexpr int kDirCol[8] {-1, 0, 1, -1, 1, -1, 0, 1}; inline bool inBoard(int r, int c) { return r 0 r kBoardSize c 0 c kBoardSize; } std::vectorMove generateMoves(const Board board, Player player) { std::vectorMove moves; for (int r 0; r kBoardSize; r) { for (int c 0; c kBoardSize; c) { if (board[r][c] ! player) continue; for (int d 0; d 8; d) { int nr r kDirRow[d]; int nc c kDirCol[d]; if (!inBoard(nr, nc)) continue; if (board[nr][nc] Player::Empty) { moves.push_back({r, c, nr, nc}); } } } } return moves; }方向增量表是最省事的写法8 个方向的差量分别放进两个数组循环 8 次就能覆盖“上下左右 四条斜线”。如果每方向写一堆if后面加上“同化后继续判定”这类规则时代码会立刻失控。新手常犯的错是只写 4 个方向斜向落子全部漏掉。同化棋必须支持斜向因为角落棋子向中心扩张时第一步几乎都是斜着走。2.3 落子即同化只处理落点周围一圈不递归同化是本游戏最关键的机制也是最容易写错的地方。我的定义是落子后以落点为中心的周围 8 格内所有敌方棋子全部变成当前玩家颜色。原位置棋子保留。Player opponent(Player p) { return (p Player::Black) ? Player::White : Player::Black; } void applyMove(Board board, Player player, const Move move) { // 落子前防御检查源格必须是当前玩家目标格必须是空 // include cassert 后放开下面两行 // assert(board[move.fromRow][move.fromCol] player); // assert(board[move.toRow][move.toCol] Player::Empty); board[move.toRow][move.toCol] player; for (int d 0; d 8; d) { int ar move.toRow kDirRow[d]; int ac move.toCol kDirCol[d]; if (inBoard(ar, ac) board[ar][ac] opponent(player)) { board[ar][ac] player; } } }这段代码里只做一层循环不同化“被同化棋子的邻居”。很多人第一次按直觉写会把同化实现成递归或 BFS结果一颗子放下去半张棋盘变颜色。正确规则是落点只辐射一圈这样局面变化可控也方便 AI 搜索预估收益。assert是防御式检查在 Debug 版生效Release 版会被编译掉不会影响性能。到这里棋盘状态、合法落子和落子生效已经闭环。下一步是把这个规则引擎放进一局完整的游戏循环里让人真的能“玩起来”。3. 跑起来的一局游戏循环、ASCII 渲染与基于 C 随机数的 AI核心规则写完还只是静态库得有一个人机互动的外壳。同化棋不需要高显卡控制台就够这也是很多 C 小游戏项目的标准做法。这一章把主循环、输入处理、AI 和渲染一次讲完。3.1 主循环先转起来状态切换、回合控制和结束条件游戏主循环的职责只有四件事显示棋盘、生成当前玩家合法落子、让人类或 AI 选一步、切换下一个玩家。需要注意的是“无子可走”的情况同化棋虽然很少出现但一旦某方所有棋子都被同化掉或者周围全是空格但没有任何己方棋子就必须跳过或结束。bool hasLegalMoves(const Board board, Player player) { return !generateMoves(board, player).empty(); } bool isTerminal(const Board board) { bool blackCan hasLegalMoves(board, Player::Black); bool whiteCan hasLegalMoves(board, Player::White); if (!blackCan !whiteCan) return true; for (const auto row : board) for (Player cell : row) if (cell Player::Empty) return false; return true; }主循环是这样串起来的int main() { Board board; initBoard(board); Player current Player::Black; while (true) { printBoard(board); if (countPieces(board, Player::Black) 0 || countPieces(board, Player::White) 0) { break; } if (isTerminal(board)) break; auto moves generateMoves(board, current); if (moves.empty()) { std::cout 当前玩家无子可走跳过\n; current opponent(current); continue; } Move chosen (current Player::Black) ? humanMove(moves) : aiMove(board, current); applyMove(board, current, chosen); current opponent(current); } int blackCount countPieces(board, Player::Black); int whiteCount countPieces(board, Player::White); std::cout 黑方 blackCount : whiteCount 白方\n; }这里我故意把黑方设为人类白方设为 AI方便演示不同入口。countPieces的扫描虽然看着笨但棋盘只有 64 格每次扫描都是纳秒级不要提前优化。3.2 一个不丢人的 AI蒙特卡洛模拟 mt19937同化棋的 AI 不需要一上来就写 alpha-beta。最简单的做法是蒙特卡洛模拟对当前每个合法落子模拟随机下完 N 局统计哪个落子最后获胜次数最多选胜率最高的那个。这正好用上 C 随机数记住别再用srand和rand用std::mt19937和std::uniform_int_distribution。#include random #include algorithm Move aiMove(const Board board, Player ai, int simulations 200) { auto moves generateMoves(board, ai); if (moves.size() 1) return moves[0]; std::mt19937 rng(std::random_device{}()); std::vectorint wins(moves.size(), 0); for (size_t i 0; i moves.size(); i) { for (int sim 0; sim simulations; sim) { Board simBoard board; Player simPlayer ai; // 先走这步棋之后双方随机落子到终局 applyMove(simBoard, simPlayer, moves[i]); simPlayer opponent(simPlayer); while (!isTerminal(simBoard)) { auto simMoves generateMoves(simBoard, simPlayer); if (!simMoves.empty()) { std::uniform_int_distributionint dist(0, simMoves.size() - 1); applyMove(simBoard, simPlayer, simMoves[dist(rng)]); } simPlayer opponent(simPlayer); } if (countPieces(simBoard, ai) countPieces(simBoard, opponent(ai))) { wins[i]; } } } size_t bestIdx std::max_element(wins.begin(), wins.end()) - wins.begin(); return moves[bestIdx]; }这里每一步都完整复制一份Board在 8×8 棋盘的规模下是可接受的。simulations 200的意思是每一步棋要跑 200 局随机模拟落子越多总耗时越高。如果觉得卡优先把这个参数降到 50而不是改 AI 结构。使用std::mt19937而不是rand是因为现代 C 里rand的分布质量太差而且范围上限跟平台相关在虚拟机里跑的时候尤其不稳定。3.3 像素游戏观感从哪来控制台棋盘渲染的要领控制台渲染的核心不是画得漂亮而是让玩家一眼看懂谁在哪个位置。我是用“行列坐标 B/W/.”三字符方案void printBoard(const Board board) { std::cout ; for (int c 0; c kBoardSize; c) std::cout c ; std::cout \n; for (int r 0; r kBoardSize; r) { std::cout r ; for (int c 0; c kBoardSize; c) { switch (board[r][c]) { case Player::Empty: std::cout . ; break; case Player::Black: std::cout B ; break; case Player::White: std::cout W ; break; } } std::cout \n; } }这已经是最入门的像素游戏输出方式用字符代表 tile用空格和换行组成网格。不用 Unicode 符号的原因很简单Windows 控制台默认代码页对●○的兼容性太差在部分虚拟机和远程终端上会输出乱码。先保住可读性再谈美观。人类输入部分要单独写一个函数不然非法输入会把控制台搞死bool readPos(int r, int c) { if (!(std::cin r c)) { std::cin.clear(); std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); return false; } return true; }这个函数的具体坑下一章单独讲。4. 同化棋开发中躲不开的五个坑编译、运行库、输入与 AI 卡顿规则和主循环写完你大概率会立刻碰到几个和环境强相关的现象。这些不是同化棋特有的 bug但开发小游戏时几乎都会遇到我按踩坑顺序整理成五条。4.1 VSCode 配置 C/C 环境编辑器报错但命令行能编译现象VSCode 里打开main.cpp#include array一行标红按下编译却又能成功产出 exe。原因VSCode 的 C/C 插件走的是c_cpp_properties.json里的 Intellisense 配置而你是从终端里执行g两套环境不一致。最常见的是插件找不到 g或者 includePath 没指向编译器自带的头文件目录。解决在.vscode/c_cpp_properties.json里把编译器路径和标准写明确{ configurations: [ { name: Win64, includePath: [ ${workspaceFolder}/** ], compilerPath: C:/mingw64/bin/g.exe, intelliSenseMode: windows-gcc-x64, cStandard: c17, cppStandard: c17 } ], version: 4 }compilerPath必须和你在终端里g --version看到的路径一致。这一项不影响最终编译但决定了你在 VSCode 里写代码时会不会被一堆假的报错干扰。我第一次做同化棋时忽略了这个配置结果一边看红色波浪线一边在终端里编译成功浪费了半天时间排查。4.2 换个电脑就报缺少运行库MinGW 还是 MSVC 得先说清现象开发机上编译好的同化棋 exe复制到同事电脑后双击报错要么弹“缺少 libgcc_s_seh-1.dll”要么弹“libstdc-6.dll 不存在”甚至直接 0xc000007b。原因MinGW 的 g 默认动态链接 GCC 运行时。如果目标机器没装 MinGW 环境exe 就缺依赖。换用 MSVC 构建也一样目标机器缺少 Microsoft Visual C Redistributable 时会报运行库错误。解决C/C 构建这步要提前想清楚分发方式。用 MinGW 的话我一般直接静态链接g -stdc17 -O2 -static-libgcc -static-libstdc main.cpp -o assimilation.exe加了-static-libgcc -static-libstdc后两个关键的 C 运行库被揉进 exe复制到大多数 Windows 机器都能跑。如果还是报错再考虑装 Visual C Redistributable。这个坑在虚拟机里最容易翻车虚拟机里装了完整开发环境测试时表现正常但真实用户机器往往干净得多所以发布前一定要在没装过编译器的环境拷一次。4.3 输入流卡死或跳过回合清掉 fail 位和缓冲区残留现象第一回合输入坐标正常第二回合输入时程序不等玩家直接跳过或者输入一个字母后游戏进入死循环怎么敲都没反应。原因std::cin r c读到非法字符时cin 进入 fail 状态后续所有读取直接失败。如果按回车时残留了换行符下一次读取也可能读到空值导致落子落在一个非法坐标上。解决所有输入必须走统一入口并处理 fail 位和缓冲区bool readPos(int r, int c) { if (!(std::cin r c)) { std::cin.clear(); std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); return false; } return true; }在humanMove里只有readPos返回 true 且坐标在合法落子列表里才接受这步棋。否则继续提示玩家输入。这是控制台小游戏最经典的“玄学”问题本质上就是流状态没清干净。如果你用std::getline读整行再sscanf可以绕开一部分但处理空格坐标还是要谨慎。4.4 AI 每步思考好几秒蒙特卡洛模拟参数没控制住现象棋局进行到中盘白方 AI 每步要思考 3-5 秒玩家以为程序卡死。原因simulations固定为 200但合法落子数量会随着棋盘局势变化。早期只有几个己方棋子落子可能八九个中后期棋盘接近满盘落子反而变少。问题在于模拟中的每一局都要复制整块棋盘还要反复调用generateMoves这一步是 O(64×8)叠加起来就是肉眼可见的卡顿。解决给模拟次数加上动态上限并在输入前先显示一行“AI 思考中”避免玩家误判int simCount 100; if (moves.size() 20) simCount 60; Move aiMove(const Board board, Player ai, int simCount);这种“游戏延迟高”的问题大部分不是 CPU 不行而是搜索没做剪枝。同一招在虚拟机里跑会更明显因为虚拟机的定时器和调度并不完全等价于宿主机。先压模拟次数再想优化。4.5 同化扩散写成递归一步棋吞掉半张棋盘的翻车现场现象随便在中央落一子结果点击后对手一片棋子全变色棋局瞬间失去平衡。原因我把“同化落点周围敌人”理解成“被同化的敌人也会继续同化它的邻居”用递归处理相邻敌方结果像传染病一样扩散。但同化棋的规则设计是扁平辐射只有落子点周围一圈会被转化不继续传播。解决重新核对applyMove确保里面只有一层循环没有递归没有 BFS。我在代码里加了一个醒目的注释// 只同化落点直接相邻的一圈禁止递归扩散 for (int d 0; d 8; d) { ... }这个坑坑了我不止一次也是后面写规则测试最重要的用例。接下来就用评估函数和自动搜索把 AI 从“随机下棋”升级到“有策略地扩张”。5. 让 AI 有强度评估函数、alpha-beta 裁剪与游戏测试蒙特卡洛 AI 用来当默认对手没问题但想让同化棋有点策略性得把搜索和评估补上。这一章给出一个可直接替换的评估函数、一套 alpha-beta 搜索骨架以及防止把规则改坏的测试方法。5.1 从棋子差到位置权重评估函数怎么调才能不偏同化棋的胜负最终看棋子数量但 AI 不能只数棋子因为同样的棋子数放在中心还是边角后续扩张能力完全不同。我常用的评估函数是“棋子数 位置权重”#include cstdlib int evaluate(const Board board, Player me) { int score 0; for (int r 0; r kBoardSize; r) { for (int c 0; c kBoardSize; c) { if (board[r][c] Player::Empty) continue; // 距离中心越近权重越高 int dist std::max(std::abs(r - 3), std::abs(c - 3)); int w 1; if (dist 0) w 3; else if (dist 1) w 2; if (board[r][c] me) score w; else score - w; } } return score; }中心权重为 2四角权重为 1中心到四角的切比雪夫距离刚好 0、1、2、3 四档。这种手工权重不需要精确只要让 AI 明显倾向占中即可。如果发现 AI 总是把棋子堆在中心导致边角失控可以把中心权重调到 1边角权重调大再跑自对弈观察结果。5.2 alpha-beta 搜索深度 4 以内别急着上位棋盘优化评估函数有了就能用负极大值negamax搜索。棋盘只有 64 格用std::array复制是完全够的不要一上来就写位棋盘。int negamax(Board board, int depth, Player me, Player cur, int alpha, int beta) { auto moves generateMoves(board, cur); if (depth 0 || moves.empty()) { return evaluate(board, me); } int best -999999; for (const auto m : moves) { Board next board; applyMove(next, cur, m); int val -negamax(next, depth - 1, me, opponent(cur), -beta, -alpha); best std::max(best, val); alpha std::max(alpha, best); if (alpha beta) break; } return best; } Move aiMoveAB(const Board board, Player me, int depth 3) { auto moves generateMoves(board, me); Move best moves[0]; int bestVal -999999; for (const auto m : moves) { Board next board; applyMove(next, me, m); int val -negamax(next, depth - 1, me, opponent(me), -999999, 999999); if (val bestVal) { bestVal val; best m; } } return best; }这段代码的返回值是“从 me 视角看到的分数”所以在递归调用前要取负。alpha-beta 的关键是alpha beta时剪枝它能让你在深度 3 时只遍历一小部分节点。深度 4 也能跑但建议先把合法落子按中心距离排序剪枝效率会明显提高std::sort(moves.begin(), moves.end(), [](const Move a, const Move b) { int da std::max(std::abs(a.toRow - 3), std::abs(a.toCol - 3)); int db std::max(std::abs(b.toRow - 3), std::abs(b.toCol - 3)); return da db; });优先搜索靠中心的落子往往能更快触发剪枝。这个操作不是同化棋特有但放在小棋盘上效果意外地好。5.3 用断言做游戏测试把棋盘状态当作黑盒输入AI 搜索改到第三版时我开始担心自己会把规则改坏。于是写了最小规则测试把所有核心断言固定下来#include cassert void runRuleTests() { Board b{}; b[3][3] Player::Black; b[3][5] Player::White; Move m{3, 3, 3, 4}; applyMove(b, Player::Black, m); assert(b[3][3] Player::Black); // 原位保留 assert(b[3][4] Player::Black); // 新落子 assert(b[3][5] Player::Black); // 被同化 auto moves generateMoves(b, Player::White); assert(!moves.empty()); std::cout 规则测试通过\n; }这里故意让落点在白子旁边验证同化只影响落点周围一圈。以后修改评估函数或搜索顺序只要先跑一次runRuleTests()就能挡住“同化扩散成递归”这类回归。游戏测试不是图形项目专属控制台游戏先把状态机测死后面写什么功能都不慌。6. 最值得养成的项目习惯让 AI 自我对弈一百局再改下一版规则测试能保证“没错”但不能保证“好玩”。给我带来最大收益的验证方法是给同化棋加一个--selfplay模式让两个 AI 以固定随机种子自动下 100 局把每局的棋子数和胜率打印出来。这样改一次评估函数不需要人守在屏幕前也能发现 AI 是否退化。void selfPlay(int games, unsigned seed) { std::mt19937 rng(seed); for (int i 0; i games; i) { Board b; initBoard(b); Player turn Player::Black; while (!isTerminal(b)) { auto moves generateMoves(b, turn); if (!moves.empty()) { std::uniform_int_distributionint dist(0, moves.size() - 1); applyMove(b, turn, moves[dist(rng)]); } turn opponent(turn); } int black countPieces(b, Player::Black); int white countPieces(b, Player::White); std::cout game i B black W white \n; } }固定种子这份“后悔药”比肉眼盯棋局可靠得多。我遇到过一个问题某一步 move 顺序的 bug 只在第 43 步触发手动玩根本注意不到。后来用固定种子重放同样的game 42每次都崩几秒钟就定位了。自对弈结果也有参考价值如果黑方胜率稳定在 50% 附近说明规则对先手公平如果某套评估函数让黑方胜率冲到 70%多半是位置权重偏向中心过度。我现在每改一版同化棋 AI都会先跑一轮自对弈再看平均步数有没有异常变长。这也是我做所有 C 小游戏都会保留的最小验证模块一个能自动验证的入口胜过十次肉眼试玩。希望帮到你。本文还有配套的精品资源点击获取
返回列表