news 2026/10/27 7:57:53

C++自创棋类小游戏:规则设计、走法生成与Alpha-Beta搜索

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++自创棋类小游戏:规则设计、走法生成与Alpha-Beta搜索

写过多款棋类之后我发现,复刻一款现成棋类顶多算"照着菜谱做菜",真正把手感和逻辑一起练到位的,是自己从零造一局棋。这个C++ 自创棋类小游戏就是这么一个东西:它不照搬象棋、围棋、五子棋的现成规则,而是从规则草案开始,自己定义棋盘、走子方式、吃子机制和胜负条件,然后用 C++ 把整套规则变成可执行、可对弈、可让电脑接手的程序。它解决的问题很具体——很多人写游戏只写界面不写逻辑,或者写逻辑只写死规则不敢改参数,而这个项目逼着你在"规则可判定"和"玩法有张力"之间做取舍,同时把搜索算法、状态管理、撤销栈、评估函数这一串硬功夫串起来。适合谁看:学过 C++ 基础语法、能写类和容器的朋友;想入门博弈搜索但被一堆公式劝退的朋友;以及单纯想给自己或者身边人做一个"只有你会玩"的小游戏的朋友。下面全部是我自己在写这个项目时的顺序、取舍和踩过的坑,代码可以直接抄去改。

1. 先把整体设计想清楚,再动手敲第一行

动手之前我犯过一个典型错误:先建了工程,写了main,画了棋盘格子,然后卡住了——因为我根本不知道自己这局棋要"怎么赢"。后来我把顺序倒过来,先纸上推演规则,再定数据结构,最后才写渲染。这个顺序救了我至少两天返工时间。

1.1 复刻象棋是练手,自创棋才是练脑

复刻一款成熟棋类,你要解决的问题几乎全是"翻译":把已知规则翻译成代码。规则是别人验证过的,边界是现成的,甚至连 AI 的评估表都有人写好了,你抄一份就行。这条路能练工程能力,但练不到设计能力。

自创棋类不一样。你必须自己回答几个问题:这局棋的信息是全部公开还是带隐藏?每回合只能落子还是也能移动?吃子是替换、翻转还是移除?先手优势靠什么补偿?每一个选择都会在后面的代码里放大成具体的实现成本。我印象很深的一次,我最初设计了"落子后可以连锁触发夹吃"的玩法,听起来很带感,写的时候发现连锁判定会让每步棋的分支因子从 30 涨到 200 以上,搜索深度直接掉两层,AI 变傻,玩家体验反而更差。最后我把连锁砍掉,只保留单次夹吃,玩法照样成立,代码量少了三分之一。

这就是自创棋的核心价值:它逼你在"设计"和"实现成本"之间做真实的交易,而不是无脑堆功能。你写的每一行代码背后都有一个明确的"为什么这么设计",这种思考习惯迁移到任何项目上都管用。

1.2 自创棋必须满足的三个硬性约束

规则可以天马行空,但落到代码上必须过三道闸门,我把它叫做"三约束":

第一,可判定。任何一局棋都必须在有限步内结束,或者有明确的终止条件。你设计的规则里如果出现"双方都可以无限循环走子"的可能,就必须补一条和棋规则,比如"连续 N 回合无吃子判和"或者"重复局面三次判和"。否则 AI 搜索会遇到无限递归,玩家对战会陷入互相耗时间。

第二,有张力。每回合都要有值得纠结的选择。如果存在一步"明显最优解",那这局棋就退化成单人谜题了。检验方法很简单:自己跟自己下十局,如果十局的开局三手都一样,说明开局张力不足,需要增加落子区域的限制或者改变初始布局。

第三,可编码。规则描述必须能用"状态 + 动作"的有限集合表达。凡是需要裁判主观判断的规则(比如"这步棋是否精彩")一律砍掉。我给自己定的标准是:任何一条规则,我都能用不超过三十行的纯函数实现,超了就是规则太复杂,先简化再说。

注意:这三条里最容易被忽视的是第二条。很多人设计规则时只想着"新颖",忘了"平衡"。新颖但不平衡的棋,玩两局就腻,而且你自己也很难判断是 AI 太强还是规则太烂。

1.3 技术选型和工程结构:为什么坚持用 C++

有人会问,这种小游戏用脚本语言半小时就能写完,为什么非要 C++。我的答案有两个半理由。第一,博弈搜索是计算密集型任务,同样一棵搜索树,C++ 的节点吞吐量比解释型语言高一个数量级,这个差距在你想把搜索深度从 4 层推到 7 层时是决定性的。第二,C++ 强制你面对内存布局和值语义,用std::array存棋盘意味着一次拷贝就是 64 个字节的连续内存,缓存友好,AI 搜索时这一点点优势会累积成明显的速度差。剩下半个理由是,我就是想练手,不想用语言糖把算法的细节盖住。

工程结构上我没有搞得多复杂,五个文件就够:

  • types.h:常量、枚举、坐标、Move、Undo这些轻量结构。
  • board.h / board.cpp:棋盘容器、走法生成、合法性判定、夹吃结算、胜负判定。
  • eval.h / eval.cpp:评估函数和局面特征统计。
  • search.h / search.cpp:negamax + Alpha-Beta + 置换表。
  • main.cpp:控制台交互、渲染、主循环。

编译选项我固定用-std=c++17 -O2 -Wall -Wextra。-Wall -Wextra不是摆设,它会帮我抓到大量"隐式类型转换"和"比较有符号无符号"的问题,在棋类这种满屏int8_t的代码里尤其救命。有一次我把棋子状态写成int8_t,循环里用int遍历,一个-1的比较漏了,导致白方整片棋子判定全错,就是靠编译警告定位的。

提示:不要一上来就引入图形库。控制台字符渲染足以验证规则正确性,而且调试信息打印极其方便。等你确认规则和 AI 都没问题,再考虑套界面。我见过太多人卡在界面事件循环里,规则逻辑一行没写。

2. 规则设计:把"好玩"翻译成"能算"

这一部分是整个项目里最烧脑也最过瘾的阶段。我最后的成品叫"十六子夹棋",名字是随口起的,规则本身经过了四轮简化才定型。

2.1 一份可以直接落地的规则草案

我把规则写成下面这样,你可以直接拿去用,也可以当模板改:

棋盘:8×8 格,一维索引,行从上到下 0 到 7,列从左到右 0 到 7。上半区(行 0 到 2)是黑方落子区,下半区(行 5 到 7)是白方落子区。

棋子:单一种类,没有兵种差异。初始局面各放 4 枚,黑方在第三行排开,白方在第六行排开。

每回合二选一:

  • 落子:把一枚己方棋子放到本方落子区内的空格上,且盘面己方棋子总数不超过 10 枚。
  • 移动:把盘面上一枚己方棋子沿横、竖、斜八个方向走 1 格或 2 格,走 2 格时中间格必须为空。

吃子(夹吃):无论落子还是移动,结算后都从"落点"出发沿八个方向检查。若某方向上相邻第一格是对方棋子、第二格是己方棋子,则中间那枚对方棋子被移除。八个方向可以同时触发,一次最多吃 8 枚。

胜负:

  • 对方盘面棋子数为 0,胜。
  • 对方没有任何合法走法,胜。
  • 连续 40 个半回合没有发生吃子,判和;若需要分胜负,按盘面棋子数多者胜,相同再按中心控制数。

这套规则的设计意图很明确:落子区限制让开局不会直接贴脸,移动机制带来中局的调度感,夹吃则提供了"一步多吃"的爆发点。三样东西叠起来,既有围空的味道,也有走子的战术。

我在第一版里其实允许落子落满全场,结果先手第一手就能在中心落子并立刻形成威胁,白方永远被动。加了落子区限制之后,开局双方必须先在各自半场做布置,节奏自然就慢下来了。这个改动只用了三行代码(一个行号范围判断),但游戏体验的变化是质变的。

2.2 棋盘表示:一维数组、二维数组还是位棋盘

这个问题我认真对比过三种方案,结论是先用一维数组,别急着上位棋盘。

二维数组board[8][8]最直观,写起来像在纸上画图,但每次传参都退化成指针,拷贝、比较、哈希都得手写循环,而且做"从某点出发沿方向扫描"这类操作时要频繁算行列。我现在用一维std::array<int8_t, 64>,idx(r, c) = r * 8 + c,好处是一次拷贝就是 64 字节的memcpy级别操作,放进std::vector做撤销栈非常省心,哈希时直接遍历一遍就行。

位棋盘(用uint64_t的每一位表示一个格子是否有子)是高性能棋类引擎的标准做法,走法生成可以用位移和掩码一次性算出来,速度极快。但我没在初版用它,原因是:调试成本高。位运算出错时你看到的是一个十六进制数,得翻译成棋盘才知道哪一格错了;而数组表示出错时直接打印棋盘就能定位。我的建议是,先把规则跑通、把 AI 跑通,等你确认真需要更快(比如想在手机上跑深度 8 的搜索),再考虑把棋盘换成位表示——而且可以只换内部表示,接口不变,改动是局部的。

2.3 合法性校验要分层,不要写成一个巨型 if

合法性判定是 bug 高发区。我第一版写了一个两百行的isLegal,里面嵌套了六层if,改一次规则就要重新读一遍。后来我把它拆成四层,每层只回答一个问题:

层级检查内容失败后果
第 1 层 边界目标格是否在棋盘内、是否为空越界崩溃或覆盖己方棋子
第 2 层 归属起点是不是自己的棋子允许"操控"对方棋子
第 3 层 几何方向是否合法、距离是否超限、跳格中间是否为空出现瞬移、穿子
第 4 层 规则落子区限制、棋子总数上限开局失衡、无限落子

分层之后每个函数都短到可以一眼看完,加规则时只需要在对应层插入判断。更重要的是,AI 生成走法时可以只调用前三层做"伪合法"筛选,最后在真正搜索前再补第 4 层,这样能省掉大量重复计算。

注意:几何层那个"走 2 格中间必须为空"的检查,是最容易漏的。我漏过一次,结果 AI 发现自己可以隔着棋子跳到对方阵地,一路屠棋,当时还以为是自己评估函数写得好,白高兴了半天。

3. 核心实现:从棋盘数据结构到胜负判定

规则定了就可以写代码了。这一段我把关键片段贴出来,配套说明每一步的意图。

3.1 基础类型:让编译器帮你防错

#pragma once #include <array> #include <cstdint> #include <cstdlib> constexpr int N = 8; constexpr int CELLS = N * N; enum : int8_t { EMPTY = 0, BLK = 1, WHT = -1 }; using Board = std::array<int8_t, CELLS>; inline int idx(int r, int c) { return r * N + c; } inline int rowOf(int i) { return i / N; } inline int colOf(int i) { return i % N; } inline bool onBoard(int r, int c) { return r >= 0 && r < N && c >= 0 && c < N; } struct Move { int8_t from; // -1 表示落子 int8_t to; bool isDrop() const { return from < 0; } };

棋子状态用1 / -1 / 0而不是1 / 2 / 0,是我有意为之。这样"对方棋子"就是-me,"己方棋子"就是me,夹吃判定里省掉一次分支,而且写b[i] == -me这种表达式时不会写错。这种小技巧在棋类代码里收益很高,因为这类比较在搜索里会被执行上百万次。

3.2 走法生成:先求全,再求快

走法生成我分了两个函数。genPseudoMoves只做几何和归属检查,速度快,给 AI 用;genLegalMoves在伪合法基础上补第 4 层规则,给玩家输入校验和终局判定用。

const int DR[8] = {-1,-1,-1, 0, 0, 1, 1, 1}; const int DC[8] = {-1, 0, 1,-1, 1,-1, 0, 1}; void genPseudoMoves(const Board& b, int8_t side, std::vector<Move>& out) { out.clear(); int mine = 0; for (int8_t v : b) if (v == side) ++mine; // 落子候选 if (mine < 10) { int r0 = (side == BLK) ? 0 : 5; for (int r = r0; r < r0 + 3; ++r) for (int c = 0; c < N; ++c) if (b[idx(r, c)] == EMPTY) out.push_back({-1, (int8_t)idx(r, c)}); } // 移动候选 for (int i = 0; i < CELLS; ++i) { if (b[i] != side) continue; int r = rowOf(i), c = colOf(i); for (int d = 0; d < 8; ++d) { for (int step = 1; step <= 2; ++step) { int nr = r + DR[d] * step, nc = c + DC[d] * step; if (!onBoard(nr, nc)) break; int ni = idx(nr, nc); if (b[ni] != EMPTY) break; // 被挡住,更远也不用看 out.push_back({(int8_t)i, (int8_t)ni}); } } } }

这里有一个细节值得说:移动的那层循环里,遇到非空格直接break,而不是continue。因为沿一个方向走,前面有子后面就一定过不去(2 格限制下),break省掉无意义的计算。这种"提前退出"在搜索里累积起来很可观。

分支因子实测:开局大约 60 到 70 个伪合法走法,中局因为盘面棋子多,会涨到 90 左右。这个数字对 Alpha-Beta 来说偏高,所以排序很重要,第 4 章会讲。

3.3 夹吃结算:一次扫描,八个方向

int applyCapture(Board& b, int to, int8_t me, int* caps) { int cnt = 0; int r = rowOf(to), c = colOf(to); for (int d = 0; d < 8; ++d) { int r1 = r + DR[d], c1 = c + DC[d]; int r2 = r + 2 * DR[d], c2 = c + 2 * DC[d]; if (!onBoard(r2, c2)) continue; int i1 = idx(r1, c1), i2 = idx(r2, c2); if (b[i1] == -me && b[i2] == me) { b[i1] = EMPTY; if (caps) caps[cnt] = i1; ++cnt; } } return cnt; }

这段代码只有十几行,但是整个游戏的核心手感来源。我用的是"端点夹",也就是只检查落点作为一端的情况。为什么不做"全线夹"(一次移动后整行重新结算)?因为全线夹会让每步棋的后果难以预测,玩家很难在脑子里算出吃子结果,体验会变成"随便走走看能吃掉什么",这不符合"有张力"的要求。端点夹只关心落点周围,人脑可以算清楚,AI 也能算清楚,规则和实现达成了一致。

caps数组是为了撤销准备的。搜索时需要一个Undo结构记录"这步棋吃了哪些子",回溯时原样恢复。

3.4 终局判定与和棋规则

int gameResult(const Board& b, int8_t sideToMove, int quietHalfMoves) { int blk = 0, wht = 0; for (int8_t v : b) { if (v == BLK) ++blk; else if (v == WHT) ++wht; } if (blk == 0) return -1; // 黑方被吃光,白胜 if (wht == 0) return 1; // 白方被吃光,黑胜 std::vector<Move> mv; genPseudoMoves(b, sideToMove, mv); bool any = false; for (const auto& m : mv) if (isLegal(b, sideToMove, m)) { any = true; break; } if (!any) return -sideToMove; // 无路可走,判负 if (quietHalfMoves >= 80) { // 40 回合无吃子 if (blk != wht) return blk > wht ? 1 : -1; return 0; } return 0xFF; // 未结束 }

返回值我用0xFF表示"还在进行",1 / -1 / 0分别表示黑胜、白胜、和棋。用哨兵值而不是bool加int的组合,是为了让搜索函数里能用一个int搞定所有情况,减少分支。

提示:和棋阈值这个数字不能拍脑袋。我一开始设的是 20 回合,结果中局稍微保守一点的走法就会触发和棋,AI 学会了"躺平求和"。改成 40 回合(80 个半回合)之后正常了,但你会发现有些对局会拖到八十多步,体感偏长。合理区间大概在 60 到 100 个半回合之间,具体要看你的吃子频率。

3.5 控制台渲染:够用、能调试就行

void printBoard(const Board& b) { printf(" "); for (int c = 0; c < N; ++c) printf("%d ", c); printf("\n"); for (int r = 0; r < N; ++r) { printf("%2d ", r); for (int c = 0; c < N; ++c) { int8_t v = b[idx(r, c)]; putchar(v == BLK ? 'X' : v == WHT ? 'O' : '.'); putchar(' '); } printf("\n"); } }

字符渲染我保留了行号和列号,这点很重要。调试时你会频繁手算坐标,没有标尺会浪费大量时间。棋盘上方的两行(0 到 2)和下方两行(5 到 7)是落子区,可以在渲染时用不同背景色区分,控制台里用[]包裹落子区格子也行。

渲染函数我一开始写成返回std::string的版本,想着以后好扩展,结果发现调试时printf直接输出更省事,字符串拼接反而慢。最后还是用回最简单的输出。这里的心得是:调试期的渲染就按"能最快看到结果"来写,别提前设计。

4. 电脑对手:搜索与评估函数

有了规则,AI 其实就是"在一棵树上找一个对自己最有利的叶子"。听起来简单,实际调起来有一堆细节。

4.1 negamax + Alpha-Beta,先跑通再谈优化

我用的是 negamax 形式,比 minimax 少一半代码,逻辑上更统一:每一层都站在"当前走子方"的角度,分数取反。

int negamax(Board& b, int8_t side, int depth, int alpha, int beta, int ply, int quiet, std::vector<Undo>& stk) { int res = gameResult(b, side, quiet); if (res != 0xFF) return res * 100000; if (depth == 0) return evaluate(b, side); std::vector<Move> mv; genPseudoMoves(b, side, mv); orderMoves(b, side, mv); // 见 4.2 for (const Move& m : mv) { if (!isLegal(b, side, m)) continue; Undo u; if (!doMove(b, side, m, u)) continue; stk.push_back(u); int cap = u.capturedCount; int score = -negamax(b, (int8_t)-side, depth - 1, -beta, -alpha, ply + 1, cap ? 0 : quiet + 1, stk); stk.pop_back(); undoMove(b, side, u); if (score > alpha) alpha = score; if (alpha >= beta) break; // 剪枝 } return alpha; }

doMove和undoMove是一对,必须严格对称。我的Undo里存了起点、终点、被吃掉的格子列表。撤销时先恢复被吃的棋子,再把终点棋子放回起点(如果是落子则直接清空终点)。这里有个隐蔽的坑:如果一步棋同时吃掉了多枚棋子,撤销顺序不能影响结果,但因为每枚都独立恢复,所以顺序无所谓——只要你别在恢复过程中重算夹吃。

quiet参数是"连续静默半回合数",用来触发和棋判定。它在搜索里的意义不止于此:当quiet超过阈值时,说明局面进入了没有进展的循环,可以提前剪掉,避免搜索在这类分支上浪费时间。

4.2 评估函数:四块打分,权重靠实测

评估函数我拆成四项,每一项都有明确意图:

项目含义初始权重调节方向
子力差盘面己方棋子数减对方100提高会让 AI 更贪吃
中心控制距离中心越近分越高4提高会让 AI 更爱占中
机动性伪合法走法数量差2提高会让 AI 更主动
落子区威胁落子区内可落点的数量差3提高会让 AI 更重视补子
int evaluate(const Board& b, int8_t me) { int mat = 0, ctr = 0; for (int i = 0; i < CELLS; ++i) { if (b[i] == EMPTY) continue; int s = (b[i] == me) ? 1 : -1; mat += s; int dr = std::min(rowOf(i), N - 1 - rowOf(i)); int dc = std::min(colOf(i), N - 1 - colOf(i)); ctr += s * (3 - std::min(dr, dc)); } std::vector<Move> a, c; genPseudoMoves(b, me, a); genPseudoMoves(b, (int8_t)-me, c); int mob = (int)a.size() - (int)c.size(); return mat * 100 + ctr * 4 + mob * 2; }

调权重这件事,我踩过一个很典型的坑:一开始子力权重给到 300,AI 变得极度短视,开局就冲到一块去吃小便宜,结果被人类包夹。原因是子力权重太高,压过了所有位置信息,AI 看不到"虽然这步没吃到,但下步会被吃两个"的远期风险。后来降到 100,搜到第 5 层时,AI 才开始表现出"先布势再动手"的行为。

判断权重是否合理的土办法是:让它自己跟自己下二十局,统计每局的吃子次数和总步数。如果平均吃子次数低于 5,说明 AI 太保守,位置权重过高;如果平均步数低于 15 就分出胜负,说明太激进,子力权重过高。我的参考区间是每局吃子 8 到 15 次、总步数 40 到 70 步。

4.3 置换表与迭代加深:让搜索别做重复功

Alpha-Beta 有个毛病:同一个局面可能通过不同的走子顺序到达,每到达一次就要重算一棵子树。置换表就是一张哈希表,记住"这个局面我搜过,价值是 X,深度是 D"。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 7:57:25

大文件分片上传实战:WebUploader与Flask断点续传

简介&#xff1a;围绕大文件上传这一常见后端难题&#xff0c;这份演示工程以 Python 配合 Flask 搭建服务端&#xff0c;前端借助 WebUploader 完成分片切分与并发提交&#xff0c;并用 Bootstrap 渲染上传进度条&#xff0c;适合已掌握 Python 基础、想弄清分片上传与流式下载…

作者头像 李华
网站建设 2026/10/27 7:56:34

波士顿房价预测机器学习期末项目:完整源代码与文档说明

简介&#xff1a;这是一份面向高校学生与机器学习初学者的回归分析实战作业包&#xff0c;围绕经典波士顿房价数据集展开&#xff0c;帮助读者完整走通从数据预处理、模型训练到结果评估与可视化分析的全流程。项目样本506个、特征13个&#xff0c;目标变量为房屋中位数价值&am…

作者头像 李华
网站建设 2026/10/27 7:56:04

从零构建游戏AI:基于强化学习的《星际争霸2》智能体开发

1. 项目概述&#xff1a;AI自动打游戏的完整实现路径"让AI自己打游戏"这个想法在2013年DeepMind用Atari游戏演示深度强化学习时就开始流行。如今随着大语言模型和智能体(Agent)技术的发展&#xff0c;普通开发者也能搭建自己的游戏AI系统。不同于简单的脚本外挂&…

作者头像 李华
网站建设 2026/10/27 7:55:45

Evd.rar_EVD取证数据解析:从文件识别到时间线提取的完整流程

简介&#xff1a;这份资源围绕EVD&#xff08;增强型视频光盘&#xff09;技术展开&#xff0c;面向对高清视频格式、编解码原理及播放器实现感兴趣的学习者与开发者。压缩包共26个文件&#xff0c;约40KB&#xff0c;以C源码&#xff08;cpp、h&#xff09;为主体&#xff0c;…

作者头像 李华
网站建设 2026/10/27 7:54:40

AI编程三年进化史:从自动补全玩具到超越中高级程序员的Agent

这三年只要你的工作跟代码沾边&#xff0c;应该都逃不开AI编程这个话题。从最初被当成“自动补全玩具”&#xff0c;到如今不少团队直接让AI Agent独立接需求、开PR、修bug&#xff0c;甚至连“超越中高级程序员”这种话都开始有人在认真讨论。我自己是从GitHub Copilot刚出预览…

作者头像 李华
网站建设 2026/10/27 7:54:00

开源组件组装本地语音工作室:声音克隆与工作流引擎实战

前阵子我在搭一套自己的语音生产链路时&#xff0c;遇到一个很现实的麻烦&#xff1a;声音克隆模型、TTS合成器、音频后处理工具&#xff0c;GitHub上全是好东西&#xff0c;但每一样都是一座孤岛。导出一次成品&#xff0c;要在三四个脚本之间来回倒腾&#xff0c;改一次参数又…

作者头像 李华