☰
C++自创棋类小游戏:规则引擎、状态机与AI搜索实战
2026/10/27 7:58:14 网站建设 项目流程

做 C++ 自创棋类小游戏,最容易踩的坑不是语法,而是把棋盘、规则、界面三件事揉成一团。刚开始写的时候,落子、胜负判断、打印棋盘、AI 搜索全塞进main里,程序确实能跑,但加一条新规则就牵一发动全身,改一个坐标就崩一次。这个项目我建议按“小引擎”来做:先定一个足够简单的自创玩法闭环,把棋盘表示、合法性校验、状态转移、胜负判定拆开,再接终端界面和简单 AI。它适合刚学完 C++ 基础、想练类设计和调试的人,也适合想快速做一个可玩小作品的人。下面按我实际搭这类小项目的顺序拆开讲,规则采用虚构的“连四占点棋”,代码以 C++17 为标准,所有实现细节都可以直接参考复现。

这个玩法不复杂:6×6 棋盘,双方各 8 枚棋子;前 16 手是落子阶段,轮流在空格放子;双方棋子放完后进入移动阶段,每回合把自己的一枚棋子移动到上下左右相邻的空格;任意一方先形成横、竖、斜连续 4 枚己方棋子就获胜。它比普通连子棋多了移动阶段,走子生成、合法性判断、状态机都有东西可讲,又不会一上来就陷入复杂规则。下面所有模块都围绕这个虚构规则展开,重点不是棋本身多好玩,而是借它把 C++ 小游戏的结构、调试和性能取舍讲透。

1. 先把棋盘想清楚:C++ 自创棋类小游戏的整体设计与取舍

1.1 从玩法闭环倒推类结构

我先不写类,而是把一局棋的闭环写在纸上:显示棋盘,读入一步,校验这一步,应用到棋盘,判断胜负,切换玩家,继续或结束。自创棋类最怕“规则还没定完就开始堆代码”,因为你会一边写一边改接口,最后每个文件都互相依赖。我的做法是先固定最小闭环,把每一步对应的数据流列出来,再决定哪些东西需要成为类,哪些只是普通函数。

按这个闭环,我把它拆成五块:Board只负责棋盘格子和落子/撤子;RuleEngine负责生成合法走法和判断胜负;GameState保存当前玩家、阶段、历史记录和结果;AI只依赖规则接口做搜索;UI只负责输入输出。这样拆的好处很直接:AI 不需要知道棋盘怎么打印,终端界面也不需要知道连四怎么判断。以后把终端换成图形界面,规则层不用动;以后加“悔棋”,只需要在GameState里回放历史,不必改规则。

模块主要职责不该做的事
Board存格子、边界判断、落子与撤销不判断轮到谁,不打印界面
RuleEngine生成走法、校验走法、胜负检测不读键盘,不保存历史
GameState阶段、当前玩家、历史、结果不直接做搜索
AI评估局面、搜索最佳走法不修改真实对局状态
UI解析输入、显示棋盘、提示错误不实现规则

这张表看起来像“教科书分层”,但实际写的时候非常有用。比如你调试 AI 时发现它走了非法步,先看RuleEngine::legalMoves有没有生成这步,再看 AI 有没有在搜索中直接改棋盘。如果所有东西混在一起,你只能从头打印日志。拆开之后,规则层可以单独写测试,AI 层可以用固定局面复现,问题定位会快很多。

1.2 棋盘表示与规则解耦的取舍

棋盘表示我选一维std::array<int8_t, 36>,而不是二维int board[6][6],也不是一开始就上 bitboard。原因很实际:6×6 很小,性能不是第一矛盾;一维数组在保存、复制、序列化时更省事;坐标转换只在一个函数里做,后面想换表示也只改这一处。int8_t足够存空、黑、白三种状态,还能省一点内存。有人会问为什么不直接用char,其实都可以,关键是别在业务代码里到处写魔法数字 0、1、2。

坐标统一用row、col,范围 0 到 5,索引公式固定为row * 6 + col。所有边界判断、输入解析、打印输出都走这一套,不允许某处用 1 起始、某处用 0 起始。这个细节听起来小,实际是新手项目最常见的崩溃来源:界面输入a1转成了(0,0),但打印棋盘时又从 1 开始数,调试时人眼看到的位置和程序内部位置对不上,最后把合法走法判断写反。

规则参数也不要散落在代码里。棋盘边长、获胜连子数、双方棋子数、最大回合数,统一放在一个RuleConfig结构里。当前规则是 6×6、连 4、双方各 8 子。为什么是 8 子?因为 36 个格子,双方共 16 子后还剩 20 个空格,移动阶段有足够空间,不会一进入移动阶段就无路可走。这个数不是拍脑袋,我试过 6 子和 10 子:6 子时棋盘太松,连四太容易;10 子时移动空间偏挤,容易出现大量被迫走法。8 子是一个比较适合小棋盘的中间值。

这里还有一个取舍:要不要上 bitboard。bitboard 用 64 位整数表示棋盘,走子生成和胜负判断都能用位运算加速,但可读性会下降,尤其对刚练 C++ 的人不友好。我的建议是先写清晰版本,等 AI 搜索深度真的成为瓶颈,再用 profile 结果决定是否替换。6×6 棋盘上,普通数组配合 alpha-beta 已经够用,过早优化只会让调试成本翻倍。

2. 核心数据结构和规则引擎的落地细节

2.1 Board、Position、GameState 怎么划分

Board我做成值语义的小对象,内部就是一个定长数组。值语义的好处是 AI 搜索时可以放心复制,不用担心共享状态被改坏;缺点是每层递归复制 36 字节,但在这个规模下完全可以接受。Position可以简单用int索引表示,也可以包一个struct Position { int row; int col; }。我倾向后者,因为输入解析和走法生成时更直观,最后再通过toIndex转成一维索引。

GameState负责保存“这一局进行到哪了”:当前玩家、当前阶段、已落子数量、历史走法、胜负结果。阶段用枚举Phase::Placing和Phase::Moving,不要用布尔值isMoving,因为后面可能加“布局阶段”“禁手阶段”,枚举扩展更自然。历史记录用std::vector<Move>,每一步包含from、to、player,落子阶段from为 -1。这样悔棋就是弹出最后一步并恢复棋盘,回放就是从头执行历史。

enum class Player : int8_t { None = 0, Black = 1, White = 2 }; enum class Phase { Placing, Moving, Finished }; struct Move { int from = -1; // 落子阶段为 -1 int to = -1; Player player = Player::None; }; struct GameState { Board board; Player current = Player::Black; Phase phase = Phase::Placing; int placedCount = 0; int turnCount = 0; Player winner = Player::None; std::vector<Move> history; };

Board只暴露几个必要方法:at(index)、set(index, player)、inBounds(row, col)、isEmpty(index)、apply(move)、undo(move)。apply和undo必须成对,并且undo要能处理落子和移动两种走法。我的经验是,undo不要靠“反向推理”,而是让Move里保存足够信息,比如移动走法要记住原来格子上的玩家。当前规则里移动只是把己方棋子从 A 挪到 B,A 变空、B 变己方,撤销时反向即可;如果以后加“翻面”或“吃子”,就要在Move里加被影响格子的旧状态,否则悔棋一定出错。

2.2 走子生成与合法性校验

走子生成分阶段处理。落子阶段,遍历 36 个格子,所有空格都是候选走法,from设为 -1。移动阶段,遍历棋盘上属于当前玩家的棋子,再检查上下左右四个邻格,邻格在界内且为空就生成一步。这里不要图省事用“所有空格”当候选,因为移动阶段必须从己方棋子出发,否则 AI 会生成“凭空出现”的走法。

分支因子要心里有数。落子阶段前几手空格多,候选大约 30 多个;到中后段大约 20 个。移动阶段最多 8 枚棋子乘 4 个方向,理论上 32 个候选,实际有边界和阻挡,通常 12 到 20 个。这个数量级决定了搜索深度:分支因子约 20,深度 4 的最坏叶子数约 16 万,配合 alpha-beta 剪枝实际会少很多。6×6 棋盘上跑深度 3 到 4 是合理的,深度 5 要看评估函数开销和机器情况。

合法性校验我建议返回错误码,而不是只返回bool。原因很实际:界面需要告诉玩家“现在不是落子阶段”“目标格已经有棋子”“这枚棋子不是你的”,测试也需要断言具体错误。可以定义enum class MoveError { Ok, OutOfBounds, NotYourPiece, TargetNotEmpty, NotAdjacent, WrongPhase },校验函数按顺序检查。顺序也有讲究:先检查阶段,再检查坐标,再检查归属和目标格,最后检查相邻关系。顺序稳定之后,测试用例才好写。

MoveError validateMove(const GameState& s, const Move& m) { if (s.phase == Phase::Finished) return MoveError::WrongPhase; if (m.to < 0 || m.to >= 36) return MoveError::OutOfBounds; if (s.phase == Phase::Placing) { if (m.from != -1) return MoveError::WrongPhase; if (!s.board.isEmpty(m.to)) return MoveError::TargetNotEmpty; return MoveError::Ok; } if (m.from < 0 || m.from >= 36) return MoveError::OutOfBounds; if (s.board.at(m.from) != s.current) return MoveError::NotYourPiece; if (!s.board.isEmpty(m.to)) return MoveError::TargetNotEmpty; if (!isAdjacent4(m.from, m.to)) return MoveError::NotAdjacent; return MoveError::Ok; }

isAdjacent4不要用曼哈顿距离直接算,因为一维索引在换行处会出错。比如索引 5 和 6 在 6 列棋盘上其实是两行,直接算差值为 1 会误判相邻。正确做法是先转成行列,再判断abs(r1-r2) + abs(c1-c2) == 1,或者用预计算的邻接表。这个小坑我见过不止一次,表现是棋子能从最右边“瞬移”到下一行最左边。

注意:所有“应用走法”之前必须先校验,校验失败时不能修改任何状态。状态转移要么完整成功,要么完全不变,否则会出现棋盘和历史记录不一致。

2.3 胜负判定与状态机

胜负判定只检查最后一步落点,不需要全盘扫描。落子或移动之后,从目标格出发,沿横、竖、两条斜线四个方向,分别向两边数连续同色棋子,总长度达到 4 就获胜。这个做法比每次遍历所有行、列、斜线快,也不容易漏掉斜线。注意移动走法下,棋子可能从某个位置移走,理论上会不会破坏对方已经连成的四子?当前规则没有“移走对方棋子”,己方移动也不会拆掉对方连线,所以只检查最后一步的目标格足够。如果以后加“推子”规则,就要额外检查被影响区域。

状态机要明确:Placing阶段落子,落满 16 手后切到Moving,切换时把当前玩家设为后手或先手都可以,我的做法是保持轮换,谁刚落下最后一子,下一手由对方移动。Finished之后不再接受走法。平局处理要提前想好:移动阶段可能来回走子形成循环,所以设一个最大回合数,比如 120 手,超过判和;或者检测重复局面。前者简单可靠,后者需要 Zobrist 哈希。小项目先用回合上限,等真正需要再上重复检测。

bool checkWin(const Board& b, int lastIndex, Player p) { static const int dr[4] = {1, 0, 1, 1}; static const int dc[4] = {0, 1, 1, -1}; int r0 = lastIndex / 6, c0 = lastIndex % 6; for (int d = 0; d < 4; ++d) { int count = 1; for (int dir = -1; dir <= 1; dir += 2) { int r = r0 + dr[d] * dir; int c = c0 + dc[d] * dir; while (r >= 0 && r < 6 && c >= 0 && c < 6 && b.at(r * 6 + c) == p) { ++count; r += dr[d] * dir; c += dc[d] * dir; } } if (count >= 4) return true; } return false; }

阶段切换有一个容易忽略的细节:落子阶段最后一手落下后,也可能直接连四获胜,所以判定顺序应该是“先判胜,再判是否切阶段”。如果先切阶段再判胜,虽然结果也能出来,但状态会变成Moving且winner已定,后面 UI 容易显示混乱。我的做法是每次应用走法后统一走afterMove:先检查胜负,再检查落子数是否达到 16,再切换玩家,最后检查回合上限。

3. 从零跑起来:一个可复现的实操流程

3.1 工程结构与构建

文件不要全堆在main.cpp。我建议至少拆成board.h/.cpp、rules.h/.cpp、ai.h/.cpp、main.cpp,测试可以放tests/rules_test.cpp。小项目不需要复杂框架,但目录清楚会明显降低后期维护成本。构建用 CMake,C++ 标准设 17,打开常用警告。Release 构建对 AI 搜索影响很大,Debug 下深度 4 可能明显变慢,这不是算法问题,是没开优化。

cmake_minimum_required(VERSION 3.16) project(mini_chess LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(mini_chess src/main.cpp src/board.cpp src/rules.cpp src/ai.cpp ) target_compile_options(mini_chess PRIVATE -Wall -Wextra -Wpedantic )

构建命令我一般固定成两行:cmake -S . -B build -DCMAKE_BUILD_TYPE=Release,然后cmake --build build -j。第一次跑通之前不要急着接 AI,先用两个人类玩家轮流输入,把落子、移动、胜负、重开全部走一遍。规则层没稳定就接搜索,最后 AI 的 bug 和规则的 bug 会混在一起,排查非常痛苦。

3.2 棋盘初始化与落子交互

终端输入我建议用类似a1到f6的坐标,内部转成 0 到 5。输入解析单独写函数,不要在主循环里直接cin >> row >> col,否则玩家输入a1会失败,cin进入失败状态,后面所有输入都被跳过。解析函数要处理大小写、长度、越界,并把错误返回给 UI。主循环只做四件事:打印棋盘、读命令、校验并应用、检查结束。

bool parseCoord(const std::string& s, int& index) { if (s.size() != 2) return false; char colChar = std::tolower(static_cast<unsigned char>(s[0])); char rowChar = s[1]; if (colChar < 'a' || colChar > 'f') return false; if (rowChar < '1' || rowChar > '6') return false; int col = colChar - 'a'; int row = rowChar - '1'; index = row * 6 + col; return true; }

落子阶段输入一个坐标即可,移动阶段输入两个坐标,比如b2 b3。为了减少输入负担,可以支持u悔棋、r重开、q退出。悔棋的实现依赖历史栈:弹出最后一步,调用board.undo(move),恢复当前玩家和阶段。注意阶段恢复不能只靠“已落子数减一”,因为移动阶段也可能悔棋到落子阶段。稳妥做法是每一步历史里同时保存走法前的phase和placedCount,或者干脆用“从头回放历史”的方式恢复。小项目里后者更简单,也不容易错。

棋盘打印建议带行列标号,空格用.,黑子用X,白子用O,最后一步用括号或不同颜色标出。打印函数只读状态,不修改任何东西。千万不要在打印函数里顺手切换玩家或清空棋盘,这种“顺手逻辑”是后期最难查的 bug 来源。

3.3 简单AI与搜索剪枝

AI 不需要一上来就写得很复杂。当前规则下,一个负极大值搜索加 alpha-beta 剪枝就够用。评估函数按所有长度为 4 的连续窗口打分:某个窗口里只有己方棋子,按数量给分;只有对方棋子,按数量扣分;双方都有则记 0。6×6 棋盘上,四格窗口总数是 54 个:水平 6 行乘 3 个起点,18 个;垂直同样 18 个;两条对角线各 9 个。每次评估遍历 54 个窗口,开销很小。

权重可以先用这套:四连直接给极大值,三连 500,二连 50,一连 5,中心格额外加 3。对方棋子取负值。搜索深度从 3 开始,跑通后试 4。分支因子按 20 估算,深度 4 最坏约 16 万个叶子节点,alpha-beta 在走法排序较好的情况下能砍掉不少。走法排序可以先按“是否靠近中心”排,后面再考虑历史启发。不要一上来就上置换表,先把符号和终局判断写对。

int evaluate(const Board& b, Player me) { int score = 0; // 这里省略窗口预计算,实际项目中把 54 个窗口存成静态数组 for (const auto& win : allWindows()) { int mine = 0, theirs = 0; for (int idx : win) { if (b.at(idx) == me) ++mine; else if (b.at(idx) != Player::None) ++theirs; } if (mine > 0 && theirs > 0) continue; if (mine == 4) score += 100000; else if (mine == 3) score += 500; else if (mine == 2) score += 50; else if (mine == 1) score += 5; if (theirs == 4) score -= 100000; else if (theirs == 3) score -= 500; else if (theirs == 2) score -= 50; else if (theirs == 1) score -= 5; } return score; }

负极大值搜索里最容易错的是视角。当前玩家走完一步后,递归调用要把视角换成对手,返回值取负。终局分数要足够大,比如胜 1000000 减去深度,避免 AI 为了晚赢而选择奇怪走法。搜索函数接收GameState的副本,在副本上应用走法,递归结束后副本自然丢弃。6×6 棋盘复制成本很低,这种写法比手动 undo 更不容易错。

int negamax(GameState state, int depth, int alpha, int beta) { if (state.phase == Phase::Finished) { if (state.winner == state.current) return 1000000 + depth; if (state.winner == Player::None) return 0; return -1000000 - depth; } if (depth == 0) return evaluate(state.board, state.current); auto moves = generateLegalMoves(state); orderMoves(moves, state.board); int best = -2000000; for (const Move& m : moves) { GameState next = state; applyMove(next, m); int score = -negamax(next, depth - 1, -beta, -alpha); best = std::max(best, score); alpha = std::max(alpha, score); if (alpha >= beta) break; } return best; }

这段代码只是骨架,实际还要处理“无合法走法”的平局情况、最大回合数、以及 AI 选步时的随机打破平局。否则 AI 每次都在相同局面走同一步,玩起来很死板。随机只需在分数相同的候选里随机选一个,不影响搜索正确性。

3.4 存档与回放

存档我建议用文本格式,不要一上来就二进制。文本便于人眼检查,也方便复现 bug。格式可以很简单:第一行版本号,后面每行一步。落子写P a1,移动写M b2 b3,玩家颜色可以不存,因为回放时按规则自动轮换。读取时逐行解析,每步都走一次validateMove,非法就报错并停止。这样存档文件本身也成了一种测试用例。

bool saveGame(const GameState& s, const std::string& path) { std::ofstream out(path); if (!out) return false; out << "mini_chess_v1\n"; for (const Move& m : s.history) { if (m.from == -1) { out << "P " << indexToCoord(m.to) << "\n"; } else { out << "M " << indexToCoord(m.from) << " " << indexToCoord(m.to) << "\n"; } } return true; }

回放时不要直接信任存档里的玩家字段,按当前GameState自动轮换更稳。如果存档里记录了玩家,也要和当前玩家比对,不一致就报“存档与规则不匹配”。这个检查能挡住手工改存档导致的状态错乱。存档路径、读取失败、格式错误都要给明确提示,不要静默失败。

4. 常见问题与排查技巧实录

4.1 走子越界、重复落子与状态错乱

越界问题的根因几乎都是坐标转换不统一。我的排查顺序是:先打印输入字符串,再打印解析出的row、col,再打印一维索引,最后打印inBounds结果。四步下来,问题基本藏不住。另一个高频问题是“相邻判断”用了一维差值,导致换行处误判,这个前面已经提过,修法就是统一转行列再算曼哈顿距离。

重复落子通常不是棋盘判断写错,而是状态没有及时更新。比如应用走法时只改了棋盘,忘了把current切换;或者切换了玩家,但历史记录里存的还是旧玩家。我的做法是applyMove只做一件事:校验、改棋盘、压历史、更新阶段和玩家、判胜负。所有状态变化都在这一个函数里完成,UI 和 AI 不允许绕过它直接改board。这样即使出 bug,也只需要查一个入口。

状态错乱的另一个来源是悔棋。悔棋时如果只恢复棋盘,不恢复阶段和已落子数,下一次落子就会在错误的阶段执行。稳妥方案是前面说的“从头回放”,或者每一步保存完整快照。6×6 棋盘快照很小,直接存Board副本也不是不行,代码会更直观。

4.2 AI与性能问题

AI 乱走先查三件事:评估函数符号、终局分数、当前玩家视角。最常见的错误是评估函数永远按黑方打分,但搜索时白方也调用同一个函数,结果 AI 一直在帮对手。修法是评估函数显式接收me,所有己方窗口加正分,对方窗口加负分。第二个常见错误是终局判断用了winner == current,但递归里current已经切换,导致胜负分数反了。终局判断要看winner和“刚刚走完的人”,或者直接在applyMove后检查winner != None,递归返回时统一取负。

性能问题先看构建类型。Debug 下深度 4 慢是正常的,Release 会差很多。然后看走法生成有没有不必要的动态分配。每个节点都新建std::vector<Move>不算大问题,但如果评估函数里反复构造窗口数组,就会拖慢。把 54 个窗口预计算成静态数组,评估时只遍历索引,速度会明显好一些。最后才考虑置换表和走法排序。

提示:AI 调参时固定随机种子,保存几个典型局面。每次改评估权重,先跑这几个局面看选步是否合理,不要只靠“下几盘感觉一下”。

4.3 常见问题速查表

现象可能原因排查动作修复建议
输入后程序卡住cin进入失败状态打印cin.fail()清空状态并忽略当前行
棋子落到已有格校验顺序错误或 UI 绕过了校验在applyMove入口打日志所有走法统一走validateMove
移动阶段能凭空落子阶段判断缺失打印phase和from落子阶段要求from == -1
斜线胜负漏判只检查横竖手动构造斜线局面四方向都要检查,注意负斜率
AI 帮对手下棋评估符号反了固定局面打印评分评估显式接收当前玩家
悔棋后阶段错乱只恢复了棋盘打印placedCount回放历史或保存快照
回放中途崩溃存档坐标越界逐步校验并打印行号读取时执行合法性校验

这张表建议放在项目笔记里,每次遇到新问题就补一行。小项目最值钱的不是代码量,而是这些“下次别再踩”的记录。

5. 手感与扩展:让自创棋真正能玩下去

5.1 终端界面与输入手感

终端界面不需要花哨,但必须让玩家一眼看懂当前阶段、轮到谁、最后一步在哪。我的打印格式是顶部一行状态,中间棋盘带行列标号,底部一行提示。最后一步用方括号标出,比如[X],这样回看时很清楚。颜色可以用 ANSI 转义,但要提供关闭开关,避免某些终端显示乱码。清屏不要每步都做,频繁清屏会闪,保留历史输出反而方便调试。

输入命令要宽容一点。大小写都接受,前后空格去掉,非法输入给出具体原因,不要只打印“错误”。玩家输入b2 b3时,如果b2不是自己的棋子,要提示“这枚棋子不是你的”;如果目标格有子,要提示“目标格已被占用”。这些提示看起来是小事,实际决定了别人愿不愿意多玩两盘。

void drawBoard(const Board& b, int lastIndex) { std::cout << " a b c d e f\n"; for (int r = 0; r < 6; ++r) { std::cout << r + 1 << " "; for (int c = 0; c < 6; ++c) { int idx = r * 6 + c; char ch = '.'; if (b.at(idx) == Player::Black) ch = 'X'; if (b.at(idx) == Player::White) ch = 'O'; if (idx == lastIndex) std::cout << '[' << ch << ']'; else std::cout << ' ' << ch << ' '; } std::cout << '\n'; } }

界面层还要负责“重开”和“退出”的资源清理。重开时不要只清棋盘,阶段、玩家、历史、胜负结果都要重置。最稳妥的做法是构造一个新的GameState替换旧对象,而不是手动逐个字段清零。退出时正常返回,不要用exit(0)从深层函数直接跳走,否则以后加资源管理会很难受。

5.2 规则平衡与测试方法

自创棋最怕“看起来能玩,实际先手必胜”。平衡性不要靠感觉,做个简单统计:让 AI 对 AI 跑 200 局,交换先后手,记录胜率和平均回合数。如果先手胜率明显偏高,可以调整规则,比如后手在移动阶段先动,或者限制先手第一手不能下中心。每次只改一个参数,不然你无法判断是哪个改动起了作用。

测试要覆盖规则边界,不要只测“正常下一盘”。我至少会写这些用例:四个方向各一个连四;三连不算胜;移动阶段把棋子移出后不误判;落子阶段最后一手直接获胜;非法走法不改变状态;悔棋后状态与走之前一致。用简单的断言函数就行,不需要引入重框架。每次改规则先跑测试,比手动下十盘可靠。

测试项构造方式期望结果
横向连四同一行放四子判胜
纵向连四同一列放四子判胜
主斜线连四从左上到右下判胜
反斜线连四从右上到左下判胜
三连只放三子不判胜
非法移动移动到已有格返回错误且状态不变
阶段切换落满 16 子进入移动阶段

规则平衡还有一个隐性因素:移动阶段的存在会让“堵四”变得复杂。落子阶段形成的三连,进入移动阶段后可能被对方移动棋子破坏。测试时专门构造“落子阶段结束前三连、移动阶段第一手被堵”的局面,看看规则是否符合预期。如果不符合,要么改规则说明,要么改判定时机,不要含糊。

5.3 把规则抽成接口,后续加棋种不返工

如果只做这一个棋,当前的RuleEngine自由函数就够了,不必为了“扩展性”硬造抽象类。但如果你确实想以后加第二种棋,可以抽一个很小的接口:legalMoves(state)、applyMove(state, move)、checkWin(state, move)、evaluate(state, player)。接口不要设计太大,否则每个新棋种都要实现一堆用不上的函数。我的经验是先让第一个棋跑通,再把稳定不变的四个函数抽出来,而不是一开始就设计“万能棋类框架”。

性能优化也一样。当前规模下,数组棋盘加 alpha-beta 已经够用。真要提速,顺序是:先开 Release,再预计算窗口,再做走法排序,最后才考虑 bitboard 和置换表。每次优化前记录节点数和耗时,优化后对比,不要凭感觉。小项目里最容易被浪费的时间,就是给一个还没跑通的规则写复杂优化。

我现在每加一条规则,都会先写一个失败用例,再改引擎。这个习惯比任何架构图都省事:失败用例会逼你把“阶段、玩家、历史、胜负”这些状态想清楚,改完之后跑一遍,至少不会把已经能玩的部分弄坏。C++ 自创棋类小游戏做到最后,棋好不好玩是一回事,能把一个小状态机写得可测、可回放、可调试,才是这类项目最值得带走的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询