简介:本资源是面向计算机专业本科生的C++课程设计项目,完整实现双人对弈策略棋类游戏——亚马逊棋(Amazons),聚焦于游戏逻辑建模、状态管理与交互式开发实践。压缩包共91个文件,含41张界面与流程图PNG(涵盖程序框图、UI布局及实验报告配图)、9个核心源码文件(4个.cpp + 5个.h)、4份Markdown文档(含README、实验报告与程序说明)、以及编译生成的exe可执行文件、调试符号pdb、动态链接库dll等,整体大小为40.07MB,结构清晰,便于分模块学习与调试。已有442人下载学习,适用于算法课设、C++综合实训或游戏编程入门实践。读者可直接运行exe体验完整对战流程,参考源码掌握棋盘状态表示、棋子/箭行为封装、合法移动判定、命令行交互设计等关键实现;配套实验报告与程序框图文档进一步厘清开发思路与模块职责,显著降低理解门槛。 亚马逊棋这个题目,我在课程设计列表里第一眼看到就有点好奇。10x10的棋盘,黑白各4枚棋子,没有吃子环节,却要下到“对手无棋可走”才算赢——当时直觉告诉我这游戏不简单。真正动手用C++实现之后,发现它确实值得认真写一写:覆盖了二维数组建模、八个方向遍历、组合状态生成、博弈树搜索和alpha-beta剪枝,知识量刚好卡在课设需要的那个位置,不会太简单,也不会复杂到失控。
这篇文章就按我实际开发的过程来写,从规则拆解、数据结构选型,到走法生成、AI设计和最后的排坑记录,一步步讲完。项目编号100010662的常见要求是控制台人机对弈,开发环境我用的是Visual Studio 2022,也顺手在VS Code里用g++编译验证过,C++17标准没有兼容问题。如果你也在做这个题目,或者想用C++练手棋类博弈算法,这篇可以直接当一份完整的技术参考来用。
1. 规则先理清:亚马逊棋为什么是“移动+射箭”的组合
1.1 棋子与初始布局
亚马逊棋的棋盘是标准10x10方格,黑白双方各4枚棋子。棋子走法和国际象棋的皇后完全一致——横、竖、对角线方向不限格数,但不能跳过其他棋子和火焰,也不能走进被占据的格子。
初始布局不是随便摆的,双方棋子呈对称分布:
- 黑方(先手):(0,3)、(0,6)、(3,0)、(6,0)
- 白方(后手):(9,6)、(9,3)、(6,9)、(3,9)
坐标从0开始,第一个值是行,第二个值是列。这样布局的用意是让双方棋子从开局就形成对角线交叉的视野,中间地带完全开放,前几步就充满博弈味道。我第一次在控制台把这个棋盘打印出来的时候就觉得,这布局比五子棋或者黑白棋的固定开局有意思多了。
1.2 一回合的两段操作
每个回合不是简单动一个子,而是连续完成两个动作:
- 选择己方一枚棋子,沿皇后走法移动到一个空格;
- 从移动后的位置,再沿皇后走法射出一支箭。
箭射中后,那个格子变成一个永久火焰障碍。火焰会一直留在棋盘上,任何棋子以后都不能经过、不能落入。这个规则我一开始理解得有点轻,以为火焰只是多一个不能走的格子而已,但后来下多了才意识到,火焰是这游戏真正的核心武器——它不只是障碍,更是分割棋盘、封锁对手行动空间的工具。
每回合两段操作都完成之后,才轮到对手。如果有一方在轮到自己的时候,无论如何也找不到一组合法的“移动+射箭”,这方就输了。
1.3 最容易误解的两个规则点
我在写代码和测试过程中,发现规则里有两个点特别容易被新手想岔。
第一个点:移动后的位置必须还能射箭,否则这次移动本身非法。也就是说,你选了一个棋子,移动到某个空位之后,如果这个空位被火焰、棋子包围得严严实实,连一支箭都射不出去,那么这个移动就不合法。合法走法的基本单位不是“一次移动”,而是“一次移动加一次射箭”的组合。
第二个点:箭只能射向空格,不能落在任何棋子上。有朋友问过我“能不能故意把箭射向自己的棋子来封路”,答案是不行——箭的飞行路径会被棋子挡住,目标格上有棋子时,它根本射不过去。所以本质上,射箭目标的合法集合,就是移动目标集合的同样一套逻辑。
这两点如果搞混,写出来的走法生成器会多出一堆非法分支,后面AI搜索也会跟着出各种诡异结果。我建议第一步先把这两个规则钉死,再动代码。
2. 棋盘建模与核心数据结构:10x10格子怎么放最省心
2.1 二维数组加枚举,状态一目了然
棋盘规模只有10x10,完全没有必要用位运算或者压缩一维数组去硬优化,代码可读性才是第一位。我用的是最直接的方案:int board[10][10]加一个枚举。
enum Cell { EMPTY = 0, BLACK = 1, WHITE = 2, FIRE = 3 };判断某个格子能不能走,就查它是不是 EMPTY。判断某个格子是不是障碍,就看它是不是不等于 EMPTY。因为火焰和棋子都会挡路,所以“障碍判定”统一写成 board[x][y] != EMPTY 就对了。
这个枚举看起来简单,但它在后面所有模块里都是最基础的约定。我有一个小习惯:凡是涉及棋盘的函数,入口处都用 assert 校验一下坐标,防止越界访问把棋盘数据搞坏,调试的时候这种断言能帮你快速定位问题。
2.2 棋子坐标表:不能只更新棋盘,不更新棋子对象
棋盘数组之外,我还维护了双方棋子的坐标表:
struct Point { int x, y; }; Point blackPieces[4]; Point whitePieces[4];初始化时和棋盘状态同步。每次真正落子后,除了改棋盘数组,还要同步更新对应棋子的坐标。这个细节很多人会漏:棋子移动了,board 改了,但 pieces 数组里的坐标还是旧的,后面生成走法、做AI评估、判断终局的时候,用的全是过期数据,查起来非常痛苦。
我调试时候就因此卡过一次:黑棋明明已经在棋盘中央,可AI一直认为它在初始位置,导致走法全错。后来把棋子在棋盘上和pieces里的位置一起打印出来,两秒就发现了问题。
火焰的位置不需要单独维护列表,因为火焰只增不减,棋盘值为3的格子就是火焰。如果画界面的时候想高亮显示火焰,再另外用一个 vector<Point> 收集也行,但核心逻辑不需要它。
2.3 坐标系统与输入输出转换
坐标换算要统一。我的内部坐标用 (row, col),row是从上往下的行号,col是从左往右的列号。打印棋盘给用户看的时候,列用A-J表示,行用数字1-10表示。用户输入“D1 D3 A1”这样的三个坐标,分别代表起点、移动终点、射箭目标。
解析函数是这么写的:
Point parsePoint(const string& token) { char colCh = toupper(token[0]); int col = colCh - 'A'; int row = token[1] - '1'; return {row, col}; }用户输入可能五花八门:小写字母、中英文逗号、连字符、多余空格,甚至全角符号。我的做法是在解析前先把整行做一次清洗,把所有分隔符统一替换成空格,再按空白切分成三段,切出来正好三段才继续解析。如果用户输错了,就提示再输一次,不要直接崩溃。
这段代码虽然不起眼,但它是整个程序的门面。输入解析做得够宽容,人机对弈体验会好很多,演示的时候也能少很多尴尬。
3. 走法生成:八个方向遍历的两个关键细节
3.1 方向向量表:皇后走法的统一实现
皇后走法的本质,就是从当前点往8个方向连续延伸,直到碰到边界或者障碍。C++里最自然的实现是方向向量表:
const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; const int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};然后写一个函数,从起点出发收集所有可达空格:
void getReachable(const Point& start, vector<Point>& out) { for (int d = 0; d < 8; ++d) { int nx = start.x + dx[d]; int ny = start.y + dy[d]; while (inBoard(nx, ny) && board[nx][ny] == EMPTY) { out.push_back({nx, ny}); nx += dx[d]; ny += dy[d]; } } }这里有个顺序问题必须强调:每次前进之前,先判断 inBoard,再访问棋盘数组。顺序一旦写反,访问越界的一瞬间可能不会立即报错,但棋盘上会出现随机数字,AI判断跟着全乱。我第一次写斜向遍历时就是先访问再判界,结果棋盘上莫名其妙多了几个“火焰”,查了半天才发现是这个原因。
3.2 移动阶段和射箭阶段共用同一套扫描
既然移动和射箭都遵循“皇后走法+不能穿越障碍”的规则,区别只在于移动后棋子到了新位置,射箭后目标格变成火焰,那这两个阶段的“可达区域扫描”就可以共用同一个 getReachable 函数。
移动阶段很好理解,对每枚棋子调用 getReachable 就能得到所有合法移动目标。射箭阶段稍微绕一点:必须先把棋盘临时更新成“棋子已经移动过去”之后的状态,再从移动目标点调用 getReachable。因为箭是从移动后的新位置射出去的,新位置周边的障碍布局会影响箭的射程,不能拿旧位置去算。
这一步的逻辑我写成这样:
// 对每一枚棋子 for (int i = 0; i < 4; ++i) { vector<Point> moveTargets; getReachable(pieces[i], moveTargets); for (const auto& mt : moveTargets) { // 临时模拟移动 board[pieces[i].x][pieces[i].y] = EMPTY; board[mt.x][mt.y] = player; vector<Point> arrowTargets; getReachable(mt, arrowTargets); for (const auto& at : arrowTargets) { // 记录完整走法 } // 回溯棋盘 board[pieces[i].x][pieces[i].y] = player; board[mt.x][mt.y] = EMPTY; } }你能看到,我在这里没有更新 pieces 数组里的坐标,因为这只是临时模拟,回溯后坐标不变,真正的落子才需要同步坐标表。
3.3 箭不会落在棋子上,前提是棋盘状态没被污染
getReachable 的循环条件已经保证了目标格必须为 EMPTY,所以箭落点不可能是棋子。但这里藏着一个隐患:如果模拟移动时,忘了把原位置改成 EMPTY,或者回溯时没有把原位置恢复成棋子,那 getReachable 从新位置往外扫的时候,会把自己的原位置当成一个障碍,得到的射箭目标就会缺一块。
这类“状态污染”问题在AI搜索里特别容易反复出现,因为搜索会频繁模拟走棋又回溯。我处理的办法,是把模拟一整套操作封装成一个带状态快照的类,每次 apply 之前保存整个棋盘和棋子表中的一份拷贝,undo 时直接整体恢复。10x10的棋盘拷贝成本很低,换来的是逻辑上的绝对安全,非常划算。
4. 所有合法走法怎么生成:移动目标是“一半”,完整走法才是“全部”
4.1 为什么不能把移动和射箭分开判定
我之前犯过一个典型错误:先列出所有可以移动的位置,再单独列出所有可以射箭的位置,然后做笛卡尔积,觉得这样就够了。但规则里有一条——移动后必须还能射箭——导致不是每个移动目标都能和任意射箭目标组合。如果在移动阶段没有检查“移动后能不能射箭”,就会出现一批非法走法混进搜索列表,让AI做出明显违反规则的决策。
正确的做法是把合法走法的原子单位定义成三元组:源点、移动目标、射箭目标。这三个信息缺一不可,而且射箭目标必须在“棋子移动后”的棋盘上生成,不能拿移动前的棋盘算。
4.2 generateAllMoves:核心函数的完整实现
我实现的 generateAllMoves 是整棵博弈搜索树的基石,它的结构如下:
struct Move { Point from; Point moveTo; Point arrowTo; }; vector<Move> generateAllMoves(int player) { vector<Move> result; Point* pieces = (player == BLACK) ? blackPieces : whitePieces; for (int i = 0; i < 4; ++i) { vector<Point> moveTargets; getReachable(pieces[i], moveTargets); for (const auto& mt : moveTargets) { // 模拟移动 board[pieces[i].x][pieces[i].y] = EMPTY; board[mt.x][mt.y] = player; vector<Point> arrowTargets; getReachable(mt, arrowTargets); for (const auto& at : arrowTargets) { result.push_back({pieces[i], mt, at}); } // 回溯 board[pieces[i].x][pieces[i].y] = player; board[mt.x][mt.y] = EMPTY; } } return result; }这个函数每一局都会被调用很多次,尤其是在AI搜索中。它看起来简单,但性能直接影响搜索深度。棋盘越空旷,合法走法数量越多,开局阶段一次可能生成五六百个完整走法,中后期棋盘被火焰分割后,走法数量会明显下降。
4.3 终局判定:合法走法列表为空,就是输
胜负判断其实是个很干净的逻辑:轮到当前玩家,如果 generateAllMoves 返回空列表,当前玩家就输了。
bool hasAnyMove(int player) { return !generateAllMoves(player).empty(); }每步落子之后,切换当前玩家,然后调用 hasAnyMove 判断对方有没有合法走法。没有就结束,当前玩家获胜。
这里有一个容易踩的坑:判断胜负时,必须先对当前玩家生成完整的移动+射箭组合,而不是只判断“有没有棋子可以移动”。一个棋子能移动,但移动后无箭可射的格子很多,只看移动会误判成还能走。这个逻辑顺序一旦写反,整个对局会永远无法结束。
5. 电脑对手的AI:评估函数、搜索深度与剪枝实践
5.1 先把随机走棋跑通,再谈智能
AI的第一版,最省事的就是从合法走法列表里随机选一个。这个AI水平极差,连基本的封堵意识都没有,但它有一个很重要的价值:帮你验证规则模块的稳定性。让两个随机AI自动对局,跑几百盘不崩溃,基本说明棋盘更新、走法生成、胜负判断这些底层逻辑是可靠的。
随机AI跑通之后,再去做真正的搜索AI。跳跃式开发容易出那种“明明走法生成错了,AI却看似正常”的混乱局面,到时候你根本不知道是该查AI还是查规则。
5.2 评估函数:不只数棋子,更要数行动力
亚马逊棋没有吃子机制,棋子数目永远固定,所以评估函数的核心在于“行动力”——当前玩家有多少种合法走法,以及能控制多少空间。
我试过几种评估指标,最后留下三个:
- 行动力:当前玩家合法走法总数减去对手的合法走法总数。这个指标最直接,能反映出谁的手脚更灵活。
- 可达格数:统计所有棋子移动可达的空格总数,不如完整行动力精确,但计算速度快很多,在搜索中可以用它做粗评估。
- 空间分割:用BFS对棋盘做连通块分析,把被火焰和棋子隔开的区域识别出来,统计每个区域的大小和棋子归属,作为后期优势的判断依据。
评估函数大致是这样的:
int evaluate(int viewer) { int score = 0; int myMob = generateAllMoves(viewer).size(); int oppMob = generateAllMoves(1 - viewer).size(); score += 10 * (myMob - oppMob); int myReach = reachableCount(viewer); int oppReach = reachableCount(1 - viewer); score += 3 * (myReach - oppReach); score += spaceScore(viewer) - spaceScore(1 - viewer); return score; }注意这里我用 viewer 而不是固定黑方视角。搜索树里,上层是AI在走,下层是对手在走,同一局面的“优势方向”是不同的,评估函数必须能动态切换视角,否则会出现AI时而激进时而保守的奇怪现象。
5.3 极小化极大与alpha-beta剪枝:深度2已经能打
我用的是典型的minimax加alpha-beta剪枝。深度设多少?亚马逊棋的分支因子很大,开局阶段一次走法生成可能产生几百个完整走法,两层搜索就是几十万次节点评估,在C++里还能接受;三层在开局阶段就会明显卡顿。所以我的默认深度是2,开局到中盘响应都在一两秒内,体验比较好。
核心搜索代码:
const int INF = 1e9; int search(int depth, int alpha, int beta, int player) { auto moves = generateAllMoves(player); if (moves.empty()) { return (player == AI_SIDE) ? -INF : INF; } if (depth == 0) { return evaluate(player); } if (player == AI_SIDE) { int best = -INF; for (const auto& m : moves) { applyMove(m); best = max(best, search(depth - 1, alpha, beta, 1 - player)); undoMove(m); alpha = max(alpha, best); if (beta <= alpha) break; } return best; } else { int best = INF; for (const auto& m : moves) { applyMove(m); best = min(best, search(depth - 1, alpha, beta, 1 - player)); undoMove(m); beta = min(beta, best); if (beta <= alpha) break; } return best; } }贪心地讲,alpha-beta剪枝的效果在分支因子大的棋类里特别明显,前提是走法排序比较好。我试过在进入搜索前,把走法按“射箭后自己行动力减少得最少”这个启发序排一下,剪枝效率有很大提升,搜索时间差不多能降低一半。
5.4 迭代加深和限时保护
为了让程序更实用,我加了迭代加深:先从深度1开始搜,搜完保存结果,如果时间还有富余,再搜深度2。博弈树搜索一旦展开,单层搜索时间可能超预期,所以需要设定一个时间上限。我的实现方式是每次搜索前记录起始时间,搜索过程中若超过设定上限,就直接返回当前已经搜完的最佳走法。
实际效果是:默认深度2在绝大多数情况下响应很快,偶尔遇到棋盘上火焰特别少、走法特别多的极端局面,迭代加深会保护程序不卡死。演示的时候,AI基本保持一两秒内落子,观感比较舒服。
6. 实测中的几个坑和修复方案
6.1 斜向遍历的越界
这个坑在前面已经提过,但值得单独记录。我第一次写的 getReachable 是先访问数组再判断边界,结果在斜向移动时出现了数组越界。内存越界不一定立刻崩溃,但会让棋盘数据被随机值污染。调试时我发现某个格子的值变成了奇怪的负数,顺着数据流找回去,才发现问题出在 while 循环里的判断顺序。修复很简单,把边界判断提到数组访问之前。
6.2 棋盘状态污染
AI搜索过程中,如果 applyMove 和 undoMove 写得不严格,棋盘和棋子表就会慢慢“漂移”。我最初只改棋盘数组,忘了同步 pieces 坐标表,导致搜索进行到深层时,AI拿到的棋子位置是错的。这个bug的表现是:AI偶尔会选择一个看起来位置的棋子,但棋盘上那个位置根本没有棋子。查了很久才发现,模拟走棋时坐标表没跟着更新。
修复方案前面说了,用状态快照。每次模拟前保存一个完整副本,回溯时整体恢复。虽然多了一点拷贝开销,但10x10棋盘完全无所谓,换来的是正确的逻辑。
6.3 用户输入“宽容度”不够
控制台界面最容易让体验崩坏的地方是输入解析。用户可能输入“D1,D3,A1”带中文逗号,也可能输入“d1 d3 a1”小写,还可能输入“D1-D3-A1”带连字符。我一开始只接受以空格分隔的大写字母坐标
本文还有配套的精品资源,点击获取