简介一份可让两名玩家在同一台电脑上轮流对弈的VC围棋程序源码包适合初学Visual C/MFC或对游戏编程感兴趣的开发者参考。程序基于MFC文档视图框架通过自绘棋盘与棋子响应鼠标落子实现黑白双方交替对局并可根据15、17寸液晶屏切换窗口尺寸运行速度快、交互直接。压缩包体量小巧仅36KB左右共收录24个文件包括7个头文件、6个CPP实现文件、3张位图、2个图标以及dsp/dsw/rc等工程与资源脚本完整覆盖声明、实现、界面元素和项目配置目录简单明了。目前已有171人学习/下载这份源码适合作为入门级游戏编程练手。读者不但能完整看到从消息映射、绘制函数到资源定义的代码组织方式还可借作者的小型程序体会界面尺寸适配、对弈逻辑拆解等实战细节对熟悉MFC项目结构和提升C动手能力均有帮助。1. 双人对决的VC围棋程序先看清你要的是哪种“对战”拿 VC 写一个可以双人对决的围棋程序听起来像是课程设计但真做起来你会发现麻烦的从来不是“画棋盘”而是怎么让一盘棋从头走到尾——落子、提子、劫争、悔棋、终局判定哪一环断了双人就没法痛快下一盘。这类程序在网上能找到的版本大多停留在控制台输出黑子白子用字符串打出来能下但谈不上“对决”而换成 MFC 对话框界面后事情就变成了“一套规则引擎 一套界面交互 一套对局流程管理”。本文按这个拆法讲目标是让你在本地跑通一个 19 路棋盘、支持黑棋白棋轮流落子、能吃子能悔棋能保存棋谱的桌面程序。顺带把从 VC 6.0 时代的老工程到 VC 2015-2022 新工具链之间常见的编译坑一起解决掉。2. 棋盘逻辑层怎么写落子、提子、劫争与历史回滚2.1 数据模型二维数组还是 vector为什么我选原生数组棋盘逻辑层的第一件事是定数据模型。常见的做法是用int board[19][19]0 表示空、1 表示黑、2 表示白索引从 0 到 18。19 路棋盘一共 361 个交叉点就算每个点存一个 int整个棋盘也才 1444 字节拷贝一次不到 1.5KB。所以我不推荐在这个场景里为了“现代感”硬上vectorvectorintVC 6.0 时代的老编译器对多层 vector 的模板支持不算稳定且每次悔棋快照还要复制多层结构代码更啰嗦。原生二维数组配合memcpy做整盘快照简单直接性能完全够用。const int BOARD_SIZE 19; const int EMPTY 0, BLACK 1, WHITE 2; class CGobanRule { public: CGobanRule() { memset(m_board, 0, sizeof(m_board)); m_bKo false; m_koX m_koY -1; } private: int m_board[BOARD_SIZE][BOARD_SIZE]; bool m_bKo; int m_koX, m_koY; };落子前保存全盘快照落子后如果发现是禁着点直接整盘回滚。这种做法的好处是判断“自杀”时不需要逆推提子序列也天然支持后面要做的悔棋功能。很多初学者倾向于只保存增量信息比如“刚才第几行第几列落了一颗子、提掉了哪几颗”结果处理到连环提、打劫时历史状态越来越难回溯最终逼着重写。struct HistoryFrame { int board[BOARD_SIZE][BOARD_SIZE]; bool koValid; int koX, koY; }; std::vectorHistoryFrame m_history;每一手成功落子后把落子前的整盘棋和历史劫标记 push 进m_history。悔棋时 pop 出来恢复即可。一盘棋一般不超过 400 手vector 的扩容开销可以忽略。2.2 落子判定气、连通组和提子的实现给棋盘落子前先要搞清楚“气”这个概念。某个空点相邻的上下左右四个方向如果是空的就算一口气一个连通棋子组的“气”是组内所有棋子共享的气所以要先把同色连在一起的棋子找出来。我用 BFS 遍历连通组同时用libSeen数组避免同一个空点被重复计入气数。const int dx[4] {-1, 1, 0, 0}; const int dy[4] {0, 0, -1, 1}; void CGobanRule::GetGroupInfo(int x, int y, int count, int libs) const { int color m_board[x][y]; bool visited[BOARD_SIZE][BOARD_SIZE] {}; bool libSeen[BOARD_SIZE][BOARD_SIZE] {}; std::queuestd::pairint, int q; q.push({x, y}); visited[x][y] true; count 0; libs 0; while (!q.empty()) { auto p q.front(); q.pop(); count; for (int k 0; k 4; k) { int nx p.first dx[k]; int ny p.second dy[k]; if (nx 0 || ny 0 || nx BOARD_SIZE || ny BOARD_SIZE) continue; if (m_board[nx][ny] EMPTY) { if (!libSeen[nx][ny]) { libSeen[nx][ny] true; libs; } } else if (m_board[nx][ny] color !visited[nx][ny]) { visited[nx][ny] true; q.push({nx, ny}); } } } }这个函数是整个规则引擎的地基。判断一气吃多个子、判断禁着点、终局数子全都要靠它。如果你发现提子的数量不对先检查这里是不是把同一个空点重复计入气了。我在早期版本里就犯过这个错一颗子被两组黑棋共享一口气BFS 到第二组时又把那口气加了一次导致该提的子没提掉。2.3 PlaceStone先提对方再查自己自杀就整盘回滚落子动作的核心顺序是先把棋子放到目标点然后检查相邻的对手棋子组是否无气无气则提掉再检查自己所在的组是否无气无气则说明这是禁着点整盘回滚。很多错误实现把顺序写反了先查自己再查对手结果遇到“落子后提掉对方一颗子、同时自己的子还有气”这种常见棋形时会误判成自杀。bool CGobanRule::PlaceStone(int x, int y, int color, int capturedCount, CPoint koPoint) { if (x 0 || y 0 || x BOARD_SIZE || y BOARD_SIZE) return false; if (m_board[x][y] ! EMPTY) return false; // 全盘快照用于自杀回滚 HistoryFrame backup; memcpy(backup.board, m_board, sizeof(m_board)); backup.koValid m_bKo; backup.koX m_koX; backup.koY m_koY; m_board[x][y] color; int other (color BLACK) ? WHITE : BLACK; capturedCount 0; koPoint CPoint(-1, -1); for (int k 0; k 4; k) { int nx x dx[k]; int ny y dy[k]; if (nx 0 || ny 0 || nx BOARD_SIZE || ny BOARD_SIZE) continue; if (m_board[nx][ny] ! other) continue; int cnt 0, libs 0; GetGroupInfo(nx, ny, cnt, libs); if (libs 0) { RemoveGroup(nx, ny); capturedCount cnt; koPoint CPoint(nx, ny); } } int myCnt 0, myLibs 0; GetGroupInfo(x, y, myCnt, myLibs); if (myLibs 0) { memcpy(m_board, backup.board, sizeof(m_board)); m_bKo backup.koValid; m_koX backup.koX; m_koY backup.koY; return false; } // 简化劫争只提一子时把被提子位置标记为暂时禁着 m_bKo (capturedCount 1); if (m_bKo) { m_koX koPoint.x; m_koY koPoint.y; } else { m_koX m_koY -1; } m_history.push_back(backup); return true; }这里RemoveGroup的实现同样用 BFS 遍历整个连通组把所有m_board[nx][ny] color的点置为EMPTY。值得注意的一个细节是只有先结束后再 own 提子判断capturedCount 才能反映真实提子数如果落子后双方都没有气按照围棋规则对方先无气先提所以先提后查的顺序是唯一正确的。很多人为了省事把capturedCount1当作劫争的充分条件这确实是个近似——真正的劫还要判断全局局面是否循环。但双人同屏对弈的场景里单劫占绝大多数这个近似足够用。如果之后要接 AI 或做严格规则校验需要再实现全局哈希来判定全局同型。2.4 劫争和悔棋快照不一起存就会埋雷刚才的历史帧里除了棋盘还存了koValid、koX、koY。这个设计是血泪经验。很多围棋程序做好之后发现“悔棋后再下同一手劫争标记丢了”或者“打劫循环非法通过”根源都是悔棋时只恢复了棋盘没有恢复上一手的劫状态。因为历史帧里存的是落子前的状态悔棋时把这一帧整块恢复上一手的“能否提回”判断自然也被恢复一步到位。bool CGobanRule::Undo() { if (m_history.empty()) return false; HistoryFrame frame m_history.back(); m_history.pop_back(); memcpy(m_board, frame.board, sizeof(m_board)); m_bKo frame.koValid; m_koX frame.koX; m_koY frame.koY; return true; }注意RemoveGroup和GetGroupInfo都要用相同的 BFS 遍历方向数组最好不要在界面层再写一遍同样的逻辑。界面层只调用PlaceStone和Undo这样规则引擎可以独立编译测试。3. MFC界面层怎么做双人同屏落子的绘制与鼠标命中3.1 双缓冲绘制棋盘OnPaint 里先画布再贴图MFC 对话框程序里棋盘绘制最忌讳直接往CPaintDC上画。每次Invalidate都会触发WM_ERASEBKGND系统先刷背景再重画一落子就闪屏观感很差。标准做法是先把整个棋盘画到一块内存兼容位图上再一次BitBlt贴到窗口。void CGobanDlg::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(rc); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); DrawBoard(memDC); // 画网格、星位、坐标字母 DrawStones(memDC); // 根据 m_board 画黑白子 DrawLastMove(memDC); // 标记最后一手位置 dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); bmp.DeleteObject(); memDC.DeleteDC(); }DrawBoard里画 19 条横线和 19 条竖线。棋盘左上角留出 4 个格子的边距用来写坐标字母和数字这个边距叫m_boardLeft和m_boardTop。星位按围棋标准画在 (3,3)、(3,9)、(3,15) 等九个位置用实心小圆点即可。void CGobanDlg::DrawBoard(CDC* pDC) { CPen penLine(PS_SOLID, 1, RGB(60, 40, 20)); CPen* pOldPen pDC-SelectObject(penLine); for (int i 0; i BOARD_SIZE; i) { // 竖线 pDC-MoveTo(m_boardLeft i * m_cell, m_boardTop); pDC-LineTo(m_boardLeft i * m_cell, m_boardTop 18 * m_cell); // 横线 pDC-MoveTo(m_boardLeft, m_boardTop i * m_cell); pDC-LineTo(m_boardLeft 18 * m_cell, m_boardTop i * m_cell); } // 九个星位 for (int i 0; i 3; i) for (int j 0; j 3; j) { int x m_boardLeft 3 * m_cell i * 6 * m_cell; int y m_boardTop 3 * m_cell j * 6 * m_cell; pDC-Ellipse(x - 3, y - 3, x 3, y 3); } pDC-SelectObject(pOldPen); }绘制棋子的函数要区分黑白黑子用纯黑填充加一圈灰色高光白子用浅灰填充加白色高光看起来才像棋子而不是色块。圆可以用Ellipse确保直径比格子边长小 2 到 4 个像素否则相邻子会挤在一起。void CGobanDlg::DrawStones(CDC* pDC) { for (int x 0; x BOARD_SIZE; x) { for (int y 0; y BOARD_SIZE; y) { if (m_board[x][y] EMPTY) continue; CPoint center(m_boardLeft x * m_cell, m_boardTop y * m_cell); int radius m_cell * 0.45; CBrush brush(m_board[x][y] BLACK ? RGB(20, 20, 20) : RGB(240, 240, 240)); pDC-SelectObject(brush); pDC-Ellipse(center.x - radius, center.y - radius, center.x radius, center.y radius); } } }3.2 鼠标命中与棋盘坐标换算从客户区点到 19 路交叉点鼠标点击要转换成棋盘坐标这里有一个很容易踩的坑OnLButtonDown的参数CPoint是相对客户区的坐标不是屏幕坐标。如果你的棋盘原点是用GetWindowRect算出来的那么点击位置整体会偏移标题栏的高度。正确做法是统一用GetClientRect配合客户区坐标。bool CGobanDlg::GetBoardIndex(CPoint pt, int ix, int iy) { int rx pt.x - m_boardLeft; int ry pt.y - m_boardTop; ix (rx m_cell / 2) / m_cell; iy (ry m_cell / 2) / m_cell; if (ix 0 || ix BOARD_SIZE || iy 0 || iy BOARD_SIZE) return false; // 计算最近交叉点与点击点的距离太远不接受 int dx rx - ix * m_cell; int dy ry - iy * m_cell; if (abs(dx) m_cell / 2 || abs(dy) m_cell / 2) return false; return true; }这个换算看起来简单但有个隐藏问题(rx m_cell / 2) / m_cell在rx为 0 到m_cell/2之间时能得到 0在接近棋盘最右端时结果可能等于 19所以必须在后面做范围校验。如果你发现点击棋盘边缘老是被拒绝检查一下这个边界条件。另一个常见问题是m_cell在不同 DPI 缩放下会变化导致高分屏上棋盘整体偏移后文避坑章节专门说。3.3 黑白交替与界面刷新落子入口和状态更新界面层最忌讳把“当前轮到谁”散落在多个函数里。我在对话框类里维护一个m_currentPlayer变量落子成功后立刻切换然后Invalidate触发重绘。void CGobanDlg::OnLButtonDown(UINT nFlags, CPoint pt) { if (m_phase ! PHASE_PLAYING) return; int ix 0, iy 0; if (!GetBoardIndex(pt, ix, iy)) return; int captured 0; CPoint koPoint; bool ok m_rule.PlaceStone(ix, iy, m_currentPlayer, captured, koPoint); if (!ok) { AfxMessageBox(_T(该位置不能落子)); return; } // 追加到棋谱字符串切换回合 m_sgfMoves MoveToSGF(ix, iy); m_currentPlayer (m_currentPlayer BLACK) ? WHITE : BLACK; m_lastMove CPoint(ix, iy); if (m_currentPlayer BLACK) SetWindowText(_T(轮黑棋落子)); else SetWindowText(_T(轮白棋落子)); Invalidate(FALSE); CPropertyPage::OnLButtonDown(nFlags, pt); }这里的m_phase是终局状态标记。双人同屏对弈的关键是黑棋和白棋共用同一个鼠标交替输入状态机不需要区分“当前棋手身份”只需要知道轮到哪一方。如果你要把程序扩展成“两边各一个鼠标”或“局域网对战”这个落子入口就是唯一需要改的地方之一后面第 4.4 节会讲扩展思路。4. 对局管理怎么接回合状态机、悔棋与SGF棋谱4.1 回合状态机从就绪到下棋到终局Pass 也要算一手围棋对弈流程比象棋多一个特殊动作虚着也就是 Pass。双人程序里必须支持“对方 Pass我方也 Pass 则终局”。我在对话框里维护一个枚举状态机enum GamePhase { PHASE_READY, PHASE_PLAYING, PHASE_END };开始新对局时进入PHASE_PLAYING黑棋先手。每次落子成功不切换m_phase只有“一方 Pass、另一方也 Pass”时才进入PHASE_END。如果一方 Pass 而另一方落子对局继续但要清除上一手 Pass 的标记。这个逻辑用两个短整型就能实现int m_passCount; // 连续 Pass 次数落子时清零落子成功时m_passCount 0Pass 时m_passCount当m_passCount 2进入终局。这里的细节是双方都需要有独立 Pass 按钮不能把 Pass 做成普通落子动作。我见过一些程序把 Pass 做成“在棋盘外点一下”结果棋子差一口气时玩家误触导致对局意外结束体验很差。宁可放两个按钮“黑方 Pass”和“白方 Pass”同一时间只启用当前回合对应按钮。4.2 悔棋与重来历史栈里要一起回滚的还有劫争悔棋在第 2 章已经给出了Undo的实现但界面层接入时还要处理棋谱字符串、最后一手标记、对局计时这些附属状态。我的做法是每次成功落子后除了把历史帧压入CGobanRule还要在界面层同步保存当前m_sgfMoves的长度和m_lastMove。悔棋时先调Undo如果返回true再按保存的长度截断棋谱字符串并恢复最后一手坐标。void CGobanDlg::OnUndo() { if (!m_rule.Undo()) return; if (!m_moveSizes.empty()) { m_sgfMoves.erase(m_moveSizes.back()); m_moveSizes.pop_back(); } // 从历史帧恢复最后一手坐标 m_lastMove m_rule.GetLastMove(); m_currentPlayer (m_currentPlayer BLACK) ? WHITE : BLACK; Invalidate(FALSE); }这里我建议界面层不要自己维护棋盘副本只维护棋谱字符串长度悔棋后棋盘状态完全由规则引擎历史栈决定。两个模块各管一摊互相不信任是常见 bug 来源。另外重开一局时除了清空m_board还要把m_history清空、m_passCount归零、m_koX/m_koY置为 -1缺一个都会造成“新对局莫名禁着点”的诡异现象。4.3 SGF 棋谱保存与回放坐标映射和头部字段SGF 是围棋棋谱的标准文本格式双人程序至少要把落子序列记下来。一个最小可用的 SGF 记录长这样(;GM[1]FF[4]SZ[19]PB[Black]PW[White] ;B[pd];W[dd];B[dp])SGF 的坐标从a到s对应棋盘 0 到 18 列黑棋落子用B[pd]白棋用W[dd]。有个细节SGF 标准里没有跳过字母ia到s连续使用别按中文棋盘“不用 I”的习惯去映射否则棋谱对不上。std::string MoveToSGF(int x, int y) { char buf[4]; buf[0] a x; buf[1] a y; buf[2] \0; return std::string(buf); }保存文件时用CStdioFile写入整个 SGF 字符串即可。注意 SGF 文件本质是纯 ASCII 文本不要在头部写中文名除非你明确处理编码。回放 SGF 是验证规则引擎最有效的测试手段具体做法放在第 6 章但你可以先实现一个简单的解析逐个字符查找B[或W[提取后面两个字母映射回行列坐标然后调用PlaceStone。4.4 把“双人同屏”扩展成“双人联网”落子消息怎么做如果标题里的“双人对决”指的是两台电脑对战直接做网络传输的话不要在你的OnLButtonDown里塞 socket 代码。正确做法是保留第 2 章的规则引擎不动把一次落子抽象成一个消息结构发送方调用PlaceStone得到结果后把“行、列、颜色、序列号”发给对端对端收到消息后直接调用同一个PlaceStone并比对提子数是否与消息一致不一致就说明两端棋盘不同步。struct MovePacket { int x, y; int color; int seq; // 对局第几手 int captured; // 提子数用于一致性校验 };消息格式用文本也行用二进制结构体也行。文本的好处是方便抓包调试“MOVE|15|4|1|1234”这种一眼能看懂。真正要谨慎的是“悔棋”的同步联网对弈时悔棋必须经过对方确认通常一局只能悔固定次数否则一端悔棋另一端棋盘没回滚整个对局就废了。我的习惯是第一版只同步落子和 Pass悔棋留到后续再扩展避免把状态机复杂度一次拉满。5. 编译和运行排查从 VC 6.0 到 VC 2015-2022 的常见坑5.1 老工程用新编译器编译字符集变了字符串全乱现象一个原本在 VC 6.0 下能跑的工程用 VC 2015-2022 打开后编译通过但所有CString和char*拼接的地方都乱码或者直接编译报错“无法从const char *转换为LPCTSTR”。原因VC 6.0 的 MFC 默认字符集是多字节字符集MBCS而新版 Visual Studio 创建的项目默认使用 Unicode。老代码里大量直接写B[pd]这样的字符串字面量在 Unicode 工程里会被解释成const wchar_t*和CString混用就炸了。解决老工程迁移时不急着改代码先把项目属性里的“字符集”改成“使用多字节字符集”通常能解决 90% 的字符串问题。如果改完之后标点符号或者中文棋手名显示不对再考虑统一改成CStringA处理纯 ASCII 的 SGF 棋谱。另外如果你还在 VC 6.0 上调试至少补上 SP6 补丁否则 IDE 在 Debug 状态下频繁崩溃这个问题在 XP 之后尤其明显。Visual Studio 2008VC 9.0的工程默认也是 MBCS迁到新版时同样要检查这个设置。5.2 一落子就闪屏没关背景擦除画布画在了窗口上现象每次点击棋盘落子整个窗口闪一下白棋黑棋都看不清。原因OnPaint直接画到窗口 DC系统先发WM_ERASEBKGND擦背景再调OnPaint。两步分开一擦一画之间屏幕就闪了。这是 MFC 绘制的老生常谈。解决除了第 3 章的内存 DC 双缓冲还要在消息映射里拦掉WM_ERASEBKGNDBOOL CGobanDlg::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; }注意OnEraseBkgnd返回TRUE表示背景已经擦过了MFC 不会再擦。配合内存 DC整块画完再贴落子、悔棋、拖动窗口都不会闪。如果贴图后棋盘边缘出现残影检查OnPaint里BitBlt的区域是不是GetClientRect返回的完整客户区。5.3 鼠标点不准窗口坐标与客户区坐标混用现象点击棋盘上的交叉点落子位置总是偏右上角或者越靠近边缘偏移越大。原因OnLButtonDown的CPoint已经相对客户区但有些人在计算棋盘原点时用了GetWindowRect拿到的坐标包含标题栏和边框。屏幕坐标和客户区坐标相差一个固定的标题栏高度落子就整体错位。解决棋盘原点计算只用GetClientRect和GetWindowRect之间的差值做好换算或者干脆在OnInitDialog里用GetClientRect算出客户区宽度推导出m_boardLeft和m_boardTop。还有一个细节对话框有工具栏或状态栏时客户区不包含这些控件区域但OnLButtonDown的坐标天然就是客户区所以只要棋盘原点也基于客户区计算就不会有问题。5.4 提子提不全RemoveGroup 忘了 BFS只提了一颗现象一块黑棋被白棋围到最后一口气落子后黑棋整块应该被提掉但界面上只消失了紧邻落子点的那一颗。原因RemoveGroup实现只把传入点置空没有向四周扩散找同一连通组的其他棋子。围棋提子必须“整组提”而不是“单子提”。解决RemoveGroup使用和被提子组相同的 BFS 逻辑把所有同色连通点全部置为EMPTY。这个函数最好复用GetGroupInfo的遍历方式避免两处方向数组不一致。我当时就是单独写了一套遍历把dx/dy的顺序抄反了结果 BFS 只往两个方向跑右上角的棋子老是被留下。void CGobanRule::RemoveGroup(int x, int y) { int color m_board[x][y]; bool visited[BOARD_SIZE][BOARD_SIZE] {}; std::queuestd::pairint, int q; q.push({x, y}); visited[x][y] true; while (!q.empty()) { auto p q.front(); q.pop(); m_board[p.first][p.second] EMPTY; for (int k 0; k 4; k) { int nx p.first dx[k]; int ny p.second dy[k]; if (nx 0 || ny 0 || nx BOARD_SIZE || ny BOARD_SIZE) continue; if (m_board[nx][ny] color !visited[nx][ny]) { visited[nx][ny] true; q.push({nx, ny}); } } } }5.5 悔棋后劫争状态丢了历史帧没存 ko 标记现象对局中打劫黑棋提劫白棋不能立刻提回悔棋几手后重新走一个相似的局部原本应该禁着的点却可以落子了。原因历史帧只存了棋盘数组没有存m_bKo、m_koX、m_koY。悔棋时棋盘恢复了但“上一步是劫争被提点暂时禁入”这个状态没有恢复规则引擎误以为全局没有劫。解决把 ko 状态和历史帧一起存悔棋时一起恢复。这个改动很小但要记住“任何影响后续落子合法性的状态都必须进历史帧”。同样的道理适用于 Pass 连续次数——如果你觉得连续两次 Pass 判终局那么悔棋也要撤回到上一次 Pass 的状态否则会出现“上一手是 Pass、悔棋后却还能继续 Pass 两次”的逻辑漏洞。6. 用SGF回放验证整盘棋规则引擎自测的落地做法双人围棋程序做完以后最想验证的是“提子、禁手、悔棋、终局”这一串规则是否在所有棋形下都正确。人工一盘一盘点太慢也容易漏。我的习惯是先用程序手动下完一整盘棋保存成 SGF 文件把这份文件当作回归基线。以后每次改规则引擎都重放一遍这份棋谱对比每一步是否合法、提子数是否和当初一致。回放代码不需要单独做界面直接写一个测试函数。核心逻辑是把 SGF 字符串里的B[xx]和W[xx]逐个拆出来映射成坐标调用PlaceStone。如果某一步返回false说明规则引擎把当时合法的棋判成了禁着点这时打印出步数。void ReplaySGFVerify(const std::string sgf, CGobanRule rule) { int pos 0; int turn 0; // 0 表示黑 int step 0; while (pos (int)sgf.size() - 3) { if (sgf[pos] B sgf[pos 1] [) { int x sgf[pos 2] - a; int y sgf[pos 3] - a; int captured 0; CPoint ko; bool ok rule.PlaceStone(x, y, BLACK, captured, ko); if (!ok) { TRACE(_T(第 %d 手黑棋 %c%c 被判定为非法\n), step 1, a x, a y); } step; pos 5; turn 1; } else if (sgf[pos] W sgf[pos 1] [) { int x sgf[pos 2] - a; int y sgf[pos 3] - a; int captured 0; CPoint ko; bool ok rule.PlaceStone(x, y, WHITE, captured, ko); if (!ok) { TRACE(_T(第 %d 手白棋 %c%c 被判定为非法\n), step 1, a x, a y); } step; pos 5; turn 0; } else { pos; } } }除了回放还可以给规则引擎加一个“终局快速判定”的小函数帮助双人玩家不用手动数子就能知道胜负。常见的做法是先让双方手工标出死子程序清掉死子后对每个空区域做 BFS如果一片空点的邻居只有黑子就归黑方地盘只有白子就归白方两边都有算公活不计入胜负。这种方法能覆盖绝大多数双人娱乐场景真遇到盘角曲四或者双活之类特殊棋形再完全手动点目也不迟。我以前写越南这类棋类程序时总喜欢在界面上反复点击测试改一次提子规则就要重新下几十手效率很低。后来强制自己用 SGF 回放做回归测试每次改完规则先把基线棋谱跑一遍很多劫争和禁着点的坑都在回放阶段就暴露了不用麻烦朋友坐下来陪你试棋。如果你也准备在这个方向投入建议先把这条测试链路搭起来它能让你后续加网络对战、加 AI 对弈时都更有底气。希望这篇文章能帮到你。本文还有配套的精品资源点击获取