ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++实战:亚马逊棋规则建模与MCTS人工智能实现

2026/8/31 9:29:35 拓冰建站 浏览量
C++实战:亚马逊棋规则建模与MCTS人工智能实现 简介本资源是一份面向计算机专业本科生的C课程设计实践项目完整实现了策略性双人棋类游戏——亚马逊棋Amazons聚焦于游戏逻辑建模、状态管理与交互式程序开发。资源包共91个文件含41张界面与流程图PNG涵盖程序框图、UI布局及实验报告配图、9个核心源码文件4个cpp 5个h、4份Markdown文档含README、实验报告结构化版本、2个Word实验报告文档以及编译生成的exe可执行文件、调试符号pdb、动态链接库dll等整体压缩包大小为40.07MB。已有442人学习下载。读者可直接运行exe体验完整游戏流程通过清晰分层的源码如ui_gameboard.h、ui_mainwindow.h等Qt界面头文件掌握MVC架构实践结合实验报告.doc与程序框图.md深入理解移动/射箭规则判定、棋盘状态更新及非法操作拦截等关键实现细节是巩固C类设计、二维数组状态表示与命令行/图形界面协同开发的优质参考范例。 最近把一个挺耐人寻味的小项目完整做完了——基于C从零实现亚马逊棋。这个棋种在国内不算热门但在AI博弈和算法课程设计里经常被翻牌子项目编号100010662对应的就是一套典型实现。如果你正在找C练手项目、准备博弈树相关的课程设计或者单纯想看看“规则不复杂但写起来全是细节”的棋类游戏怎么做这篇总结应该能帮你少走不少弯路。亚马逊棋有个很妙的设计每回合既要走自己的“皇后”又要射出一支永久留在棋盘上的箭。走子规则和国际象棋皇后一样横竖斜任意格数不能越子射箭规则同样如此但箭射出去之后会变成障碍物永久封锁那个格子。这个“边走边封”的机制让棋局越到后面越拥挤策略深度完全不输国际象棋。用C来实现它正好能覆盖数据结构设计、状态搜索、评估函数、UI交互这几大块是一个非常完整的“C小游戏”项目。这篇内容我会按照实际开发顺序来写先讲整体设计思路再拆棋盘建模和规则判断然后是AI搜索算法最后是工程化实操和问题排查。代码片段我会挑核心部分贴出来方便你直接对照。1. 整体设计为什么选亚马逊棋做C项目1.1 亚马逊棋的规则拆解开始写代码之前我先把规则彻底捋了一遍因为后面所有设计都建立在规则之上。亚马逊棋棋盘是10x10双方各有4枚亚马逊棋子初始位置固定白方在a1、b1、c1、d1黑方在g10、h10、i10、j10。每回合的流程分两步玩家选择自己的一枚棋子按皇后走法移动到任意空格不能跨越任何棋子包括自己的棋子和箭。移动完成后必须从新位置射出一支箭箭的移动规则和走子完全一样也是皇后走法不能穿越任何棋子箭停留在目标格后变成永久障碍。胜负判定的核心是“无子可动即负”如果某方回合开始时4枚棋子全部无法移动则该方输棋。注意这里不判断谁被困住只判断当前轮到谁、谁动不了谁输。这个规则比国际象棋简单但实际操作上有个大坑每回合的状态变更不是一个动作而是“移动射箭”两个动作的组合。这对搜索算法的状态空间影响非常大后面我会详细说。1.2 技术选型命令行起步GUI后置我的实现思路是把项目分成两层核心逻辑层和界面层。核心逻辑层用纯C17编写不依赖任何第三方库界面层最后才接先用命令行输出棋盘跑通全部功能和AI再决定是否接图形界面。为什么这么选因为我做项目有个习惯——先把最难、最容易出错的规则和搜索逻辑跑稳再去碰界面。很多同学一上来就研究Qt、EasyX或者SFML结果界面按钮做了好几套核心规则却全是bug最后调都调不过来。核心逻辑层是纯C这意味着你在任何环境都能编译也能很方便地写单元测试。我平时用VS Code配好C/C环境然后用CMake管理项目调试用gdb效率很高。搜索算法这块我直接选了MCTS蒙特卡洛树搜索没有用传统的Minimax加Alpha-Beta剪枝。原因也很实际亚马逊棋的分支因子太大每回合移动和射箭是组合动作粗略估算合法动作数经常是几百甚至上千Minimax要评估的节点数量非常恐怖。而MCTS用随机模拟来估计胜率不需要精确遍历整个博弈树在这类高分支因子的棋类里反而更实用。整个项目我规划了几个模块Board棋盘状态管理包括棋子位置、箭的位置、合法动作生成。Action动作定义区分移动步和射箭步。Game游戏主流程负责回合切换、胜负判断、历史记录。AIMCTS搜索引擎接收棋盘状态返回最优动作。UI命令行打印和Qt界面可选。模块之间单向依赖Board不依赖AIAI只通过Board的接口拿数据和生成动作。这样设计的好处是以后如果想把核心逻辑抽出来做web版或者加入网络对战只需要把UI层换掉核心代码一行不用动。2. 棋盘建模与核心规则实现2.1 棋盘数据结构设计用一维数组还是二维数组棋盘建模是第一步也是后续所有代码的地基。我最初的方案是int board[10][10]用-1表示空、0表示白色亚马逊、1表示黑色亚马逊、2表示箭。写起来倒是直观但传参和动态访问不太方便而且不好扩展。后来我改成了一维数组方案std::arrayint, 100 board用索引row*10col来定位格子。一维数组的好处是遍历速度快、内存紧凑传给AI模块做拷贝时开销也更小。坏处是可读性稍微差一点但你可以封装一个idx(r, c)函数把所有换算集中起来。棋子状态我用枚举而不是魔法数字enum class Piece : int { Empty 0, White 1, Black 2, Arrow 3 };这里有个细节值得注意箭的格子实际上一旦落子就不会再变它属于“永久障碍”在后续实现搜索时需要把它当作“不可通行”的对象。我一开始想单独用std::set arrows来存箭的位置但实测发现直接塞进board数组里更划算因为判断某个格子是否可通行直接查数组就行不需要再去set里查一遍。这个微优化在MCTS动辄几千次模拟的时候能省不少时间。2.2 合法动作生成移动和射箭要分开写亚马逊棋每回合是一个组合动作生成起来比一般棋类麻烦。我的实现是分两步生成第一步生成所有合法移动。对每个棋子从当前位置出发沿八个方向逐格扫描遇到空格就加入移动候选遇到障碍就停止该方向。这一步逻辑类似国际象棋皇后走子但注意亚马逊棋子不能吃子所以目标格必须是空的。第二步对每个合法移动模拟移动后的棋盘再从新位置生成所有合法射箭位置。这两个是嵌套关系一个完整动作 一个移动 一个射箭。最终动作列表的大小是移动数乘以平均射箭数这个数在开局随便都是几百。直接用一个结构体表示完整动作struct Move { int from; int to; int arrow; };from是出发格索引to是目标格索引arrow是箭射到的格子索引。生成动作时先枚举from和to然后枚举arrow。这样AI搜索时扩展节点直接遍历Move列表就行。方向遍历是我这个项目里被复用最多的代码八个方向用两个数组先定义好const int dr[8] {-1, -1, -1, 0, 0, 1, 1, 1}; const int dc[8] {-1, 0, 1, -1, 1, -1, 0, 1};然后写一个通用函数给定起始格子和棋盘返回该格子沿八个方向能走到的所有空格集合。这个函数同时服务于走子和射箭因为两种动作的移动规则完全一致只是“谁在走”的语义不同。区别在于走子时起始格是棋子的位置射箭时起始格是棋子新到的位置。复用这个生成器可以少写一大半重复代码。2.3 位运算优化让MCTS跑得更快如果你只是做一个能玩的命令行版本用std::vector存合法动作完全够用。但我在跑MCTS时发现一个问题每次模拟要执行几百次动作生成而动作生成里大量使用push_back、clear这种操作内存分配会拖慢速度。一个比较实用的优化方案是预分配一个std::vector 每次生成动作前clear不要频繁reserve和释放。另外用uint64_t来表示“某一方向上可达的格子”的掩码可以避免逐格循环。亚马逊棋棋盘有100格用两个uint64_t就能表示所有格子。我实际做了个折中移动生成和射箭生成仍然用方向循环但把dfs/bfs性质的重复函数调用去掉改成直接线性扫描。比如从某个位置沿方向走就i从1到9步进检查有没有越界和障碍。C对线性循环的优化已经很好了这种写法比递归更可控也更容易排查边界错误。实测下来纯命令行版本不做任何深度优化生成一次完整动作列表的开销大概是0.1到0.5毫秒MCTS做1000次模拟不会卡顿。如果你想要更强性能可以进一步用位棋盘bitboard加速但这属于后期调优初版不必一上来就搞太高深。3. 人机对弈AI从评估函数到MCTS3.1 为什么不用Minimax分支因子太高很多棋类AI教程上来就是Minimax加Alpha-Beta剪枝但亚马逊棋有个非常不友好的特性——组合动作导致状态爆炸。国际象棋每一步平均合法走法大约30种亚马逊棋单个棋子的移动选择加上射箭选择组合出来的完整动作数量经常是几百。Minimax即使有剪枝深度到4或5就已经非常慢了而且还需要高质量评估函数来引导剪枝对初学者不友好。MCTS的思路就不一样。它的核心是四个阶段选择Selection、扩展Expansion、模拟Simulation、回传Backpropagation。它不断在当前棋局的树上“向下探索”通过大量随机模拟来统计每个动作的胜率最后选择访问次数最多或者胜率最高的动作。它不需要完整展开整棵博弈树也不需要特别精确的静态评估函数只要模拟策略不是太离谱就能在有限时间内给出不错的决策。3.2 评估函数哪些特征真正影响亚马逊棋的胜负虽然MCTS靠随机模拟但评估函数仍然重要它主要用于两个地方一是加速模拟结束后的局势判断早期剪枝二是在模拟阶段对撞车概率不太高的随机动作做偏向性选择。我的评估函数关注三个特征移动力Mobility当前玩家所有亚马逊棋子的合法移动数总和。封锁度当前玩家棋子周围的空格数量空格越少越危险。位置价值棋盘外侧空格对棋子未来的活动空间影响比较大中心区域活动范围大边角容易被封死。综合评估分数简单加权即可不需要太复杂。比如double evaluate(const Board board, Piece side) { double score 0; int mobility board.countMoves(side) - board.countMoves(opponent(side)); int trapped board.countTrapped(side) - board.countTrapped(opponent(side)); score mobility * 1.0; score - trapped * 3.0; return score; }这个评估函数非常粗糙但和纯随机模拟相比已经能看到明显提升。我在测试里让“评估函数MCTS”和“纯MCTS”分别执白执黑下了一晚前者胜率超过六成。对于项目展示来说这个收益比已经非常划算。3.3 MCTS实现要点与参数调优MCTS的代码不复杂但有几个地方最容易写错我踩过的坑一个一个说。第一节点结构别建太复杂。我最开始的节点结构包含父子指针、子节点列表、访问次数、胜场数、动作列表写出来特别漂亮但每次扩展都要new和delete2000次模拟下来内存碎片严重。后来改成用vector存储节点池每个节点只存一个表示动作的Move、一个父节点索引、一个子节点索引列表。内存分配次数少了性能提升非常明显。第二选择阶段的UCB1公式要理解透。UCB1 胜率 C * sqrt(log(parentVisits) / nodeVisits)。C是探索常数默认1.4左右。C调太大AI偏向随机探索下棋到处乱碰C调太小AI容易陷入局部最优总是走某几个动作。我最终把C调到了2.0因为亚马逊棋组合动作多前期需要更多探索才能发现好走法。第三模拟rollout阶段不要完全随机。完全随机会导致AI表现得像个新手经常走出送死棋。我在模拟阶段加了一个简单规则如果当前走子能直接让对手无路可走就立刻选它否则在移动力较大的动作里随机挑。这样做能让模拟更快结束评估结果也更有参考性。第四胜负判断一定要在模拟阶段做好。亚马逊棋不是以吃子取胜而是以“无子可动”判负。模拟阶段一旦发现某方动不了立即终止并返回结果。如果模拟跑得太久可以设置最大步数比如200步超过后按评估函数定胜负。3.4 MCTS核心代码简化版给你看一下我MCTS最核心的搜索入口去掉了很多工程细节但结构完全一致Move MCTS::search(Board board, Piece side, int iterations) { Node* root new Node(); root-board board; // 简化实际应该用智能指针或池化管理 root-side side; for (int i 0; i iterations; i) { Node* node root; std::vectorMove path; // Selection while (!node-untriedMoves.empty() !node-children.empty()) { node selectBestChild(node); path.push_back(node-move); } // Expansion node expand(node); // Simulation double result simulate(node, side); // Backpropagation while (node ! nullptr) { node-visits; node-wins result; node node-parent; } } int bestIdx 0; for (int i 1; i root-children.size(); i) { if (root-children[i]-visits root-children[bestIdx]-visits) { bestIdx i; } } return root-children[bestIdx]-move; }这个版本为了可读性牺牲了部分性能实际项目里我把root也放进对象池管理避免内存泄漏。MCTS的调参体验非常“玄学”我建议你先跑一个默认参数然后开一局棋观察前几十步看看AI是不是总往同一个方向走或者总是忽略某些好位置再针对性调整C值和迭代次数。4. 实操过程从命令行原型到可视化界面4.1 第一步先把命令行版本跑通我的开发顺序是这样的先写Board和Move生成器写一个最简单的主循环支持两个玩家交替输入坐标然后在控制台打印棋盘状态。这步跑通后核心规则基本上就稳了。棋盘打印我用的是10x10的格子字母a到j表示列数字1到10表示行白棋用W黑棋用B箭用#空格用点。控制台输出类似a b c d e f g h i j 1 W W W W . . . . . . 2 . . . . . . . . . . ... 10 . . . . . . . B B B B玩家输入这里我留了一个小坑坐标输错、输入格式不符合预期都要给出明确错误提示而不是崩溃。处理方式是把输入解析单独封装成一个函数用std::cin读一行然后用std::istringstream拆分两个坐标。这个方法虽然简单但能挡住80%的非法输入问题。命令行版本的一个附加功能是“命令记录”把每回合的完整动作from、to、arrow输出到文件这样后面调试AI时可以回放整个棋局。这个功能看似不起眼但对日后定位问题极有帮助。4.2 第二步接入Qt实现图形界面命令行版本功能完整后我开始考虑界面。选Qt是因为它对C支持最成熟跨平台也方便。但是我对图形界面有个原则只做展示层不把界面逻辑混进游戏核心。具体做法是棋盘绘制采用QPainter画10x10网格棋子用圆形或自定义图片。鼠标点击事件第一次点自家棋子高亮显示所有合法移动位置第二次点合法目标格进入射箭阶段第三次点合法射箭位置完成整步操作。每次落子或射箭后调用核心Board更新状态并从界面层刷新棋盘。和很多同学做Qt项目必卡的坑一样我一开始也陷入了“槽函数里写业务逻辑”的泥潭。比如在mousePressEvent里直接调用game.move(...)然后更新UI看起来没什么问题但写了几百行后发现很难调试。后来我用了一个简单的MVC思路界面只负责捕获输入和渲染所有状态变更都经由Game类处理然后通过信号通知界面刷新。这样的话如果以后想加网络对战只需要把Game类换成远程调用界面代码基本不用大改。4.3 第三步让“人机模式”可用在我接入AI之前命令行版本已经可以双人对战。AI接入的过程其实很短——游戏主循环里当轮到AI方时不等待玩家输入而是直接调用MCTS搜索得到一个Move然后执行。这里有个小细节AI搜索需要耗时界面层应该显示“AI思考中”或至少禁止玩家乱点不然容易造成状态错乱。我加了一个isAiTurn标志在搜索期间屏蔽所有输入。我实际测试过不同迭代次数对AI水平的影响这个表格可以作为参考同样条件下对抗纯随机AI的胜率迭代次数每步耗时秒对随机AI胜率棋风表现1000.0552%偶尔走出妙手但经常失误10000.568%基本不送必输棋但缺少长线封锁50002.581%会主动封堵对手活动空间200001087%全局意识明显但单步时间偏长最让我意外的是从1000次到5000次迭代胜率只提升了13个点但耗时翻了5倍。这其实反映了博弈类AI的通性后期性能边际递减严重。如果你的项目只是演示1000到2000次迭代已经足够唬人了如果做更严肃的研究可以考虑用神经网络替代随机模拟那是另一个维度的问题。5. 常见问题与排查技巧实录5.1 三个我自己调试时卡住的实际案例第一个问题棋子能“跳”过另一颗棋子。排查时发现我在方向扫描时用了do-while循环先移动再检查越界导致即使第一步就遇到障碍也会先“踩”上去再判断。修正办法是先判断下一个格子是否合法再决定是否移动。第二个问题射箭位置列表把棋子所在格也算进去了。后来发现是生成射箭位置时起点格没有排除导致箭可以原地落在起点上。这在规则上不允许修正方法是生成射箭候选时过滤掉起点格本身。第三个问题MCTS胜率到了某个节点后不涨。原因是我的模拟阶段随机策略太均匀导致胜率数据方差大收敛缓慢。加了“优先选择移动力大的动作”的启发式后模拟质量上升胜率判断稳定了很多。这个调优过程让我意识到随机模拟不是越随机越好而是要在随机基础上注入一点点知识。5.2 常见问题速查表症状可能原因解决方案棋子可以穿过其他棋子移动方向扫描未处理障碍格遇到非空格立即break当前方向射箭位置包含当前棋子所在格未排除起点格生成射箭列表时过滤fromAI总走同一个动作UCB1的C值太小探索不足增大探索常数或调整随机种子模拟耗时太长模拟阶段没有限制步数设置最大步数超过按评估函数判胜负棋盘打印显示错位坐标换算逻辑不一致统一用row*10col索引避免混用二维坐标Qt界面无法点击信号槽没有绑定点击事件检查QGraphicsScene或mousePressEvent是否正确重写5.3 一些值得留意的工程实践调试棋类AI有个好帮手强制随机开局自动对弈。我写了一个自动对局脚本让两个AI或AI对人类固定开局下100局统计胜负数据。这个脚本帮我发现了不少只在长对局后期才暴露的逻辑bug比如“玩家轮到自己但无动作时没有判负”这种边界场景。另外如果你的项目要求提交源码最好加一个简单的CMakeLists.txt让评审老师一条命令就能编译运行。我的构建配置大概是这样的cmake_minimum_required(VERSION 3.16) project(AmazonChess) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(amazon_chess src/main.cpp src/board.cpp src/game.cpp src/ai.cpp) target_include_directories(amazon_chess PRIVATE include)Qt版本需要在CMake里额外find_package(Qt6 COMPONENTS Widgets)这个网上教程多不再展开了。最后的一点个人体会这个项目做下来我最深的感受是亚马逊棋的规则看起来简单但真正把“移动射箭”的组合动作、胜负判定、AI搜索全部串起来比想象中要费更多功夫。尤其是MCTS的模拟策略看似只是“随机走几步”实际上微小的改动就能明显影响棋力。我前前后后调了差不多两周才让AI从“乱走”变成“会主动封路”。如果你也想拿这个项目练手我建议先别急着上图形界面把命令行版本、AI搜索、自动对局脚本这三个模块完整做出来体验会好很多。等到核心逻辑稳定了再考虑根据自己的能力加点新鲜玩意。本文还有配套的精品资源点击获取