简介:一份可以直接编译运行的VC++双人围棋对弈程序源码,基于MFC设计,面向刚开始学习VC++或对游戏编程感兴趣的开发者。程序支持双人在同一台电脑上轮流落子,自动完成棋盘绘制与胜负判断,并针对15/17寸液晶屏幕提供了窗口尺寸选择,运行反应迅速。资源共24个文件,以.h头文件与.cpp实现文件为核心,辅以位图、图标等界面资源,还包含标准MFC工程配置文件,压缩包仅36KB,结构典型完整。已有171人学习浏览。借助该源码,读者可深入理解MFC文档/视图架构、消息映射机制、GDI绘图操作,以及围棋棋盘状态管理、鼠标坐标换算、落子规则判定等实用算法,同时掌握双人轮流操作的状态切换、边界条件处理等细节。适合作为继入门之后的第一个完整小项目进行研读与二次开发,亦可直接编译后用于朋友间的娱乐对战。
1. 双人对决围棋程序:为什么这个题目到现在仍值得用 VC++ 做
两个人共用一台电脑,轮流点击鼠标,在 19 路棋盘上完成一局围棋。所谓“可以双人对决的 VC++ 围棋程序”,就是不依赖服务器、不匹配在线对手、全部规则逻辑在本机完成的围棋对弈工具。它既是很多初学者练手 MFC/GDI 的经典项目,也是验证提子、禁着点、劫这些围棋规则算法的捷径。选择 VC++ 而不是 Web 技术,是因为 GDI 能直接控制每个像素,消息循环天然适合鼠标事件,规则代码也能和界面代码分层得特别清楚。这篇文章就从坐标映射一路讲到劫的判断,给出可复现的代码骨架和几个实战里容易翻车的点。
2. 棋盘与棋子的绘制:从 19 路网格到双人落子交互
2.1 先定坐标体系:屏幕像素和棋盘交叉点之间隔着一个舍入
围棋棋盘是 19 条横线和 19 条竖线交叉出的 361 个落子点,不是 19 乘 19 个格子。绘制时最容易犯的错是把格子中心当成交叉点。设棋盘边距margin = 30,交叉点间距cell = 24,第 x 列交叉点的屏幕 X 坐标就是margin + x * cell。鼠标点击后换算成棋盘坐标,关键是舍入:
int ScreenToBoardX(int screenX) { int x = (int)floor((screenX - margin) / cell + 0.5); if (x < 0 || x > 18) return -1; // 棋盘外返回无效 return x; }这里的+0.5再floor就是四舍五入,点击偏差在半格以内都能落到正确交叉点。返回 -1 而不是夹紧到边界,是为了防止玩家在棋盘外误点导致落子。另一个容易忽略的点是:屏幕 Y 坐标增长方向向下,ScreenToBoardY只需要同样公式,不要像数学坐标系那样翻转。如果你偷懒不写无效判断,就会出现“点棋盘外面也能下棋”的体验,双人对决时两个人会不断为这种误触吵架。
设计窗口大小时,客户区宽高可以直接按公式算:margin * 2 + cell * 18。若还要显示状态栏,高度再额外加 40 像素左右。很多人先把窗口拉大再居中棋盘,结果窗口拉伸时棋盘偏到一侧,WM_SIZE 里也没有做适配。我一般把视图的最小尺寸固定下来,并在 OnSize 里重新计算margin,让棋盘始终保持在客户区中间。对于 19 路棋盘,cell取 22 到 30 之间都合适,过小人眼难分辨,过大棋子显得空洞。
2.2 用 GDI 绘制棋盘:最小可跑通的 OnDraw 代码
MFC 的视图类在 WM_PAINT 时触发 OnDraw。我把棋盘状态放在一个 19x19 的二维数组里,绘制函数只根据数组把画面画出来,不在绘制时改状态,这样重绘不会丢数据。
void CGoView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(&rc); pDC->FillSolidRect(rc, RGB(230, 180, 120)); // 木色底 CPen pen(PS_SOLID, 1, RGB(10, 10, 10)); pDC->SelectObject(&pen); for (int i = 0; i < 19; i++) { int x = margin + i * cell; int y = margin + i * cell; pDC->MoveTo(margin, y); pDC->LineTo(margin + 18 * cell, y); pDC->MoveTo(x, margin); pDC->LineTo(x, margin + 18 * cell); } // 星位:19 路的八个星位坐标固定 int star[][2] = { {3,3},{3,9},{3,15}, {9,3}, {9,15}, {15,3},{15,9},{15,15} }; for (int i = 0; i < 8; i++) { CPoint c(margin + star[i][1] * cell, margin + star[i][0] * cell); pDC->Ellipse(c.x - 2, c.y - 2, c.x + 2, c.y + 2); } for (int row = 0; row < 19; row++) { for (int col = 0; col < 19; col++) { if (g_board[row][col] == 0) continue; CBrush brush(g_board[row][col] == 1 ? RGB(10, 10, 10) : RGB(245, 245, 245)); CPoint c(margin + col * cell, margin + row * cell); pDC->SelectObject(&brush); pDC->Ellipse(c.x - 11, c.y - 11, c.x + 11, c.y + 11); } } }cell为 24,棋子直径 22,比间隔小一点,视觉上不会粘连。白棋用RGB(245,245,245)而不是纯白,在浅色木纹底上更容易分辨边界。先画网格再画棋子,能避免棋子压过网格线后留下断线。星位坐标我用了star[i][0]表示行、star[i][1]表示列,避免和屏幕坐标混在一起。这段代码没有恢复旧画刷,实际项目里最好在函数末尾把pen.OldObj和brush.OldObj还原,否则 GDI 对象会越攒越多,这在 5.4 里会专门说。
2.3 鼠标消息与双人轮转:把点击变成一次有效落子
双人对决的交互核心是:左键点击后判断落点是否有效,有效则写入数组,然后换对方走。用 ClassWizard 给视图类添加 WM_LBUTTONDOWN 处理:
void CGoView::OnLButtonDown(UINT nFlags, CPoint point) { int col = ScreenToBoardX(point.x); int row = ScreenToBoardY(point.y); if (col < 0 || row < 0) return; // 点在棋盘外 if (g_board[row][col] != 0) return; // 已有棋子的交叉点 if (!IsLegalMove(row, col, g_turn)) return; // 规则校验 g_board[row][col] = g_turn; g_turn = (g_turn == 1) ? 2 : 1; // 黑 1 白 2 交替 Invalidate(); // 请求重绘 }把鼠标处理和规则校验分开后,后面加禁着点、劫的时候都不需要再动这个函数。Invalidate是异步重绘,比RedrawWindow更平滑,不至于在鼠标消息里直接绘制。nFlags未使用会出 C4100 警告,如果团队规范严格,可以加UNREFERENCED_PARAMETER(nFlags)。这里还要注意:双人对决不需要保存“当前是黑方还是白方”之外的任何状态,所以g_turn就是整个轮转逻辑的全部。
2.4 状态区与提子计数:让双方都知道现在该谁走
双人对决里至少要明确提示“黑棋落子”还是“白棋落子”,否则两个人会为了谁走错争半天。我在视图底部画一块状态区,同时显示双方吃子数:
void DrawStatus(CDC* pDC, CRect rc) { CString s; s.Format(_T("当前:%s 黑吃%d 白吃%d"), g_turn == 1 ? _T("黑棋") : _T("白棋"), g_blackCaptures, g_whiteCaptures); pDC->TextOut(rc.left + 10, rc.bottom - 30, s); }用_T包裹字符串是为了兼容 VC++ 6.0 和 Visual Studio 2008 之后默认的 Unicode 工程。CString::Format里的中文字符串如果不加_T,在 Unicode 编译下会报类型不匹配。状态区也可以在 MainFrame 的 CStatusBar 里做,但画在视图底部更直观,而且双缓冲绘制时能和棋盘一起刷新,不用额外处理消息。
3. 围棋规则落地:提子、禁着点与劫,双人对决最容易翻车的地方
3.1 用深度优先找“气”:规则实现的地基
围棋里一颗棋子的生死看“气”,也就是相邻的空交叉点。一个连通块只要还有一口气,这块棋就活着;没气就该被提掉。实现时要先写一个相邻点生成函数,然后 DFS 标记整个连通块并统计气。注意气不能重复计数。
const int dx[] = {-1, 1, 0, 0}; const int dy[] = {0, 0, -1, 1}; void CollectBreath(int board[19][19], int row, int col, int color, bool visited[19][19], bool breath[19][19]) { visited[row][col] = true; for (int k = 0; k < 4; k++) { int nr = row + dx[k], nc = col + dy[k]; if (nr < 0 || nr >= 19 || nc < 0 || nc >= 19) continue; if (board[nr][nc] == 0) { breath[nr][nc] = true; // 空点就是气,标记去重 } else if (board[nr][nc] == color && !visited[nr][nc]) { CollectBreath(board, nr, nc, color, visited, breath); } } }相邻点用 dx/dy 数组表达,避免写四次 if。breath是标记数组,不是集合,遇到同一个空点只写一次,自然去重。递归最多穿透整块棋,19 路棋盘上连通块最多 361 个点,不会爆栈。如果你把代码改成 25 路棋盘,建议换成显式栈的 DFS,否则 Windows 线程栈默认 1MB 也可能扛不住极端棋形。这里还有一个细节:breath必须每块棋单独清零,我见过有人把它放在函数外累计,导致上一块的气算到下一块头上,吃子判断跟着错。
这段代码里我特意加了board参数,因为后面判断提子时要用临时盘面模拟,不能直接操作g_board。很多初学者把CollectBreath写死成访问全局数组,临时盘面一进来就全乱套。
3.2 提子逻辑:先模拟落子再清理无气方
围棋行棋规则是:落子后先看对方有没有被提,再看自己有没有气。我习惯先复制一份棋盘做模拟,这样判断禁着点、劫都容易回滚。
bool IsLegalMove(int row, int col, int color) { int temp[19][19]; memcpy(temp, g_board, sizeof(g_board)); temp[row][col] = color; // 第一步:提掉所有无气的对方棋子 for (int k = 0; k < 4; k++) { int nr = row + dx[k], nc = col + dy[k]; if (nr < 0 || nr >= 19 || nc < 0 || nc >= 19) continue; if (temp[nr][nc] != 3 - color) continue; // 3-color 是对手颜色 bool visited[19][19] = {false}; bool breath[19][19] = {false}; CollectBreath(temp, nr, nc, temp[nr][nc], visited, breath); if (!HasAnyBreath(breath)) { RemoveGroup(nr, nc, temp[nr][nc], temp); // 从临时盘面提掉 } } // 第二步:看自己刚落下的子是否有气 bool visited[19][19] = {false}; bool breath[19][19] = {false}; CollectBreath(temp, row, col, color, visited, breath); return HasAnyBreath(breath); }3 - color是个常用技巧:黑 1、白 2,3 - 1 = 2,3 - 2 = 1。HasAnyBreath遍历breath数组,只要有 true 就说明还有气。所有提子逻辑都放到模拟盘面上执行,最后检查自己是否有气的顺序不能反:先提对方,再看自己,这个顺序正好对应围棋规则。RemoveGroup同样用 DFS 把同色连通块清成空位,同时统计提子数量,存到g_blackCaptures或g_whiteCaptures里。
这段代码有个容易被忽略的点:在判断提对方时,visited和breath数组应该对每个相邻的对方连通块重新清零。我上面是在循环内定义的数组,天然每轮重置。如果你把数组定义在循环外,就必须在每次CollectBreath前 memset,否则上一块棋的气会被下一块棋看到,导致吃子判断时有时无。
3.3 禁着点与劫:不处理就等着双人对决翻车
禁着点是落子后自己没气、也提不了对方棋子的点。上面的IsLegalMove已经处理了这个情况:模拟落子、先提对方、再看自己,自己无气就返回 false。绝大多数新手写的围棋程序都栽在这一步,因为他们只检查“这个点是不是空的”。
劫是更隐蔽的问题。经典劫形:黑方提白一子,白方如果立刻在原位置提回黑一子,盘面会回到上一步,规则禁止这种全局同形再现。简化实现可以只处理单劫:
struct LastMoveInfo { int row, col; // 上一步落子位置 int takenRow, takenCol; // 上一步被提单子的位置 int capturedCount; // 上一步提子数 }; bool IsKo(int row, int col, int color) { if (lastMove.capturedCount != 1) return false; if (row != lastMove.takenRow || col != lastMove.takenCol) return false; // 当前落子还要正好提掉对方一颗,才会形成循环 return currentCapturedCount == 1; }这里currentCapturedCount需要在执行提子逻辑时统计。简化劫判断对日常双人对决够用,但覆盖不了双重劫、盘角曲四这类循环劫。完整解法是对整个局面做 Zobrist 哈希,每次落子后把新局面的哈希值和历史记录比较,重复就禁止。Zobrist 哈希给每个交叉点黑/白各分配一个 64 位随机数,落子时异或进去,提子时异或出去,计算成本极低。给自己的程序先实现单劫,玩到“提回被禁止”的反馈后再升级到 Zobrist,会顺很多。
漏掉劫判断的程序不会崩溃,但会让双人对决变成“两个人不断提同一颗子”的无限循环,这是最典型的翻车现场。所以哪怕先只做一步劫,也比完全不做好。
3.4 吃子统计与死子标记:让提子看得见
双人对决需要给玩家即时反馈:提掉对方几颗子、当前双方被吃多少。实现时在RemoveGroup里计数,存到g_blackCaptures和g_whiteCaptures。如果不在界面显示吃子数,双人会很快忘记这一局谁占了上风。可以把两个数字画到棋盘右侧或状态栏,随重绘更新。还有一个细节:提子后g_dead标记(终局时用的死子)要同步清掉,否则会出现棋盘上已经没有子,但计数仍认为它存在的怪事。
吃子统计会影响后面的胜负判定,所以数据结构上我建议把g_board、g_blackCaptures、g_whiteCaptures、lastMove全部放在一个GameState结构体里,悔棋和序列化都方便。如果你只用一个裸全局数组,后面写 SGF 导出和回放时会发现到处都要传参,代码很快臭。
4. 数子与胜负判定:双人对决结束怎么算出谁赢
4.1 终局与死子:别让玩家手动清子
双人对决程序要提供一个“结束”按钮,进入死子确认。最简单可靠的方案是让玩家点击对方死子,程序把标记的死子拿掉后,按当前盘面数空白交叉点。死子判断比想象中麻烦,因为有些棋形有气但被围住,算死还是算活需要棋理判断,程序很难自动。所以我不建议做全自动判定,常见做法是让玩家在终局后依次点击对方死子,程序把它们当作已提掉处理。
int blackScore = 0, whiteScore = 0; // 被标记为死子的子先移除 for (int r = 0; r < 19; r++) { for (int c = 0; c < 19; c++) { if (g_dead[r][c]) g_board[r][c] = 0; } } // 对每个空点做区域归属 for (int r = 0; r < 19; r++) { for (int c = 0; c < 19; c++) { if (g_board[r][c] != 0) continue; int owner = FindAreaOwner(r, c); // 1黑 2白 0公共 if (owner == 1) blackScore++; else if (owner == 2) whiteScore++; } }FindAreaOwner用 DFS 扩展同一块空区域,同时记录区域边界上出现了哪几种棋子。边界既有黑又有白时,这块空域算公共点,不归属任何一方。一个简化实现是用栈做区域生长:
int FindAreaOwner(int row, int col) { std::vector<CPoint> stack; stack.push_back({row, col}); g_visitedArea[row][col] = true; int areaType = 0; // 位0黑,位1白 while (!stack.empty()) { CPoint p = stack.back(); stack.pop_back(); for (int k = 0; k < 4; k++) { int nr = p.x + dx[k], nc = p.y + dy[k]; if (nr < 0 || nr >= 19 || nc < 0 || nc >= 19) continue; if (g_board[nr][nc] == 1) areaType |= 1; else if (g_board[nr][nc] == 2) areaType |= 2; else if (!g_visitedArea[nr][nc]) { g_visitedArea[nr][nc] = true; stack.push_back({nr, nc}); } } } if (areaType == 1) return 1; if (areaType == 2) return 2; return 0; // 同时接触黑白,公共空点 }这里g_visitedArea需要在数子前整体清零。areaType用位标记,如果有黑有白就是公共点。这个实现没有处理禁入点,但终局空域里不会有禁入点,可以接受。
死子确认阶段最怕误点击。我的做法是先进入“标记模式”,玩家每点一个棋子,那个子就高亮(比如画红圈),双方都认可后点“确认提走”,程序才真正清空并进入数子。这样比只点一次更公平,也避免手滑点错导致整盘重算。
4.2 终局流程的状态机:别让玩家在胜负界面里迷路
死子确认和数子不是一次性操作,通常要走几步:点击“结束”进入死子标记;标记完成后点“确认”;然后显示胜负。如果在第一步就数子,玩家误点一个棋子导致整个局面不对。我建议用一个小的状态枚举:
enum EndState { kPlaying, kMarkDead, kConfirmed };kMarkDead时,鼠标点击改死子标记而不是落子;kConfirmed后,禁止再改棋盘。每个状态入口都有对应的提示文字,比如标记死子时标题栏显示“点击对方死子,右键取消标记”,确认后显示“黑 x 子 vs 白 y 子”。这种状态机代码不多,但能避免很多终局误操作。
还有一点:死子标记不能标记活棋。程序可以做个简单检查,当玩家点击一个仍有气的连通块时弹提示“该连通块还有气,是否强制标记?”这样做虽然不能解决所有棋理争议,但至少不会因为手滑把整块活棋当死子。
4.3 中国规则和日韩规则的差别:参数要可调
双人对决程序如果只算“围到的空点”,日韩规则和中国规则在大多数场景结果一致,但在有吃子的局面会不同。日韩规则要数双方提掉的死子、俘虏,填到对方地盘里再数;中国规则数活子加空点。实际程序需要把贴目/贴子做成可调参数,不能写死一个值。下表是常用的配置:
| 规则 | 计算方式 | 贴目/贴子 | 典型胜负线 |
|---|---|---|---|
| 中国规则 | 数活子 + 空点 | 黑贴 3.75 子(7.5 目) | 黑须 185 子 |
| 日韩规则 | 数空点 + 提子数 | 黑贴 6.5 目 | 黑 184 胜,185 胜多 |
| 自定义 | 空点 + 固定贴目 | 可设置 0 到 7.5 | 可设置 |
这里的“目”和“子”换算关系是 1 子等于 2 目。黑贴 3.75 子是 7.5 目,对应黑收完单官,盘面领先 7 目半时数子才能赢半子。双人对决常见场景是朋友之间随便下,不贴目也可以,所以终局时显示“黑 x 子 vs 白 y 子(未贴目)”比强行出一个胜负更友好。我实现时把这些参数放在一个结构体里,开局对话框能改,终局结算时再读取,不要让数子函数自己猜。
4.4 让子设置与贴目换算
朋友之间下让子棋很常见,双人对决程序也应该支持。在新建对局对话框里设置让子数(0、2、3、4、5、6、7、8、9),程序按让子规则预先在星位放子:让 2 子放边上两个星位,让 3 子加左下星位,让 4 子加右下星位,让 5 子及以上再加边星。这个并非绝对标准,但符合现实让子棋习惯。让子后黑棋仍然先走,终局时黑方的贴目补偿要相应调整。最简单的方式是让子数每多 1,黑方贴目减少 1 目,最终保留半目或整数目由玩家选。写一个CalculateWinner()函数返回枚举,内部读取规则参数,别在 OnLButtonDown 里做胜负计算。
5. VC++ 版本与界面实现的常见坑:从 VC++ 6.0 到 Visual Studio 2008 再到 2015-2022
5.1 现象:高 DPI 下棋盘模糊、点击坐标漂移
系统缩放设为 150%,棋盘看起来比实际小,鼠标点击位置和落子位置错半个格。原因:MFC 默认没有感知 DPI,系统对窗口进行位图拉伸缩放,坐标映射失真。解决:在 Visual Studio 2008 或 vc++ 2015-2022 工程里,给项目加 manifest 声明 dpiAware,或者在 InitInstance 里调用SetProcessDPIAware()。常见做法是项目属性里勾选“Per Monitor DPI Aware”。VC++ 6.0 老工程没有这个选项,可以把画布固定按 96 DPI 设计,不接受缩放,界面小但位置准。
这里要提醒的是:只设 DPI 感知还不够,棋盘绘制的cell最好根据GetDpiForWindow动态调整,否则字体和棋子大小在 150% 缩放下仍然偏小。但从双人对决角度,位置准确优先级最高,大小其次。
5.2 现象:OnDraw 里每次重绘都闪成流眼泪
双人对决时落一子闪一下,甚至拖动窗口像下雪。原因:默认 WM_ERASEBKGND 先擦背景再画,擦和画之间暴露底色。解决:重写 OnEraseBkgnd 直接返回 TRUE,不擦背景;再加双缓冲。在 OnDraw 里创建内存 DC,先画到内存位图,再一次 BitBlt 到屏幕。
BOOL CGoView::OnEraseBkgnd(CDC*) { return TRUE; // 不要擦背景,交给 OnDraw 整体覆盖 } void CGoView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(&rc); if (rc.IsRectEmpty()) return; CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOld = memDC.SelectObject(&bmp); DrawBoard(&memDC); // 所有绘制都画到内存 DC pDC->BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }这里注意一定要在 OnEraseBkgnd 返回真,否则系统在 OnDraw 之前会发 WM_ERASEBKGND 把客户区填成白色,双缓冲也救不回来。还有一个隐蔽坑:窗口最小化时 GetClientRect 返回 0,CreateCompatibleBitmap 会失败,所以先判断rc.IsRectEmpty()。如果你画出的是内存 DC 但忘了用 BitBlt 贴回屏幕,白忙一场。
5.3 现象:VC++ 6.0 的 CString 到 Visual Studio 2008 后代码编译不过
原因:VC++ 6.0 默认使用 ANSI 字符集,CString 是CStringA;VS 2008 或 vc++ 2015-2022 的 MFC 默认是 Unicode,CString 解析为CStringW。如果字符串字面量没加_T()或 L,就会出现“无法从 char* 转换为 CString”或“LPCTSTR 与 char* 类型冲突”。解决:全工程字符串用_T("")、TCHAR、_tprintf,不要写裸 char。老代码迁移过来后第一件事就是改字符串,这个跑不掉。可参考老一代 MFC 程序里常见的写法:AfxMessageBox(_T("当前轮到黑棋"));
这个坑在双人对决程序里特别明显,因为要显示“黑棋落子”“白棋落子”“黑方胜”“白方胜”这类中文,几乎每个界面字符串都要处理。我见过不少从网上下载的老工程,在 VS2015 里打开后报几十个错误,几乎全和字符串编码有关,规则算法本身反而没问题。
5.4 现象:Release 版没问题,Debug 版一落子就断言失败
常见于 VC++ 6.0 SP6 或 VS2008 的老工程。表现是断言指向CreateSolidBrush或SelectObject失败。原因:在 OnDraw 里创建了画刷,但忘记删除对象或让 CBrush 对象生命周期异常。解决:把画刷作为栈对象在局部作用域内使用,或使用CBrush brush(...)后记得pDC->SelectObject(pOld)还原。在循环里反复创建画刷而不释放会耗尽 GDI 对象,越下到后面越容易触发。从 VC++ 6.0 到 vc++ 2015-2022,这个问题一直在,Debug 版还会额外计数句柄,所以表现更明显。写一个绘制函数时,尽量把所有 GDI 对象限制在函数内部创建和释放,不要让它们活到下一帧。
5.5 现象:vc++ 2015-2022 编译老代码时报一堆 C4996 警告
原因:VS2008 后的 CRT 默认把fopen、strcpy等标记为 deprecated,要求用_s版本。双人对决程序里写棋谱文件时会用到fprintf,这时候最容易刷屏。解决:项目属性里预处理器加_CRT_SECURE_NO_WARNINGS,或者逐个改成安全版本。我一般在 stdafx.h 里加一行#define _CRT_SECURE_NO_WARNINGS,老工程能少几百条警告。如果以后还想维护,建议还是改成fopen_s,毕竟警告也是一种噪音。这个坑不影响运行,但会让新手误以为自己代码写错,浪费很多排查时间。
6. 让双人对决更顺手:悔棋与对局记录
6.1 悔棋:用状态栈实现“后悔药”
双人对决两个人都会有看漏的时候,悔棋是刚需。最直接的做法是把每次落子后的整个棋盘压栈。
struct MoveState { int board[19][19]; int turn; int lastRow, lastCol; }; std::vector<MoveState> g_history; void Undo() { if (g_history.empty()) return; MoveState st = g_history.back(); memcpy(g_board, st.board, sizeof(g_board)); g_turn = st.turn; g_history.pop_back(); Invalidate(); }这个方案内存占用很小,361 字节一步,一盘棋 300 手也才 100 KB,比记录增量修改更省心。悔棋后要重置提子统计和劫状态,否则会出现上一步劫位置残留影响后面落子的情况。具体做法是在 MoveState 里也保存g_blackCaptures、g_whiteCaptures和lastMove,悔棋时一并还原。
6.2 对局记录:导出 SGF 文件可以复盘
SGF(Smart Game Format)是围棋棋谱通用格式。双人对决程序导出 SGF 后可以在任何围棋软件里复盘。基本格式是(;GM[1]FF[4]SZ[19]PB[Black]PW[White]PL[B];B[pd];W[dd];B[pq])。每个落子用坐标编码,用字母 a 到 s 表示 0 到 18。程序里维护一个std::vector<std::string>,每步合法落子追加;B[pd]或;W[dd],终局后拼接写文件。注意 SGF 坐标是先列后行,且从 a 开始,不是从零开始,新手很容易把pd写成dp。让子棋还要写AB[dd][dq][pd][pq];PL[W]之类的起始标记,否则复盘软件不认。我一般会在导出时顺手写上DT日期和EV对局名,方便归档。
做完了这些,你会发现双人对决围棋程序的难点早已不是“双人”而是“围棋”。我自己第一次写完时遇到的最荒诞的 bug 是劫判断里忘了看提子数量,导致黑提白后白不能立刻提回,但可以提同一连通块的另一颗子,这在规则上其实是允许的,只是玩家会以为是 bug。后来我养成一个习惯:每改完一个规则函数,就用“黑先、白再、黑白重复走到某个局部”的固定棋谱做一遍回归,确认提子数、禁着点、劫在这三步里都和真棋理一致。这个习惯帮我节省了不知道多少血泪时间,也希望帮到你。
本文还有配套的精品资源,点击获取