首页 / 资讯中心 / 文章详情

VC++纯本地围棋引擎:双人对弈与规则内核实现

VC++纯本地围棋引擎:双人对弈与规则内核实现 ★ FEATURED ARTICLE
简介这是一款基于VC开发的双人对弈围棋程序源码面向C初学者与MFC编程入门者聚焦游戏逻辑实现与GUI界面交互实践。资源完整包含24个文件涵盖7个头文件.h定义类结构与接口、6个实现文件.cpp承载棋盘管理、落子判断与胜负判定等核心逻辑辅以3个位图.bmp和2个图标.ico构成基础UI资源另有工程配置文件.dsw/.dsp/.clw及资源脚本.rc/.rc2整体结构清晰便于理解MFC文档/视图架构与资源组织方式。压缩包仅36KB轻量易读适合快速编译调试与代码剖析。目前已有171人学习下载读者可直接获取可运行的完整项目工程深入掌握Windows平台下图形界面游戏开发流程包括窗口适配支持15/17寸液晶、消息响应机制及GDI绘图技巧是巩固VC基础与提升实战能力的典型小项目范例。1. 双人对弈不靠AI、不联网、纯本地运行一个用VC和MFC写死的围棋逻辑引擎连悔棋都得自己画线擦掉你见过在Windows XP上还能点开就下棋的围棋程序吗不是网页版、不调用Python模型、没有云对战——就是两个玩家挤在一台电脑前鼠标点落子、右键提子、空格切换黑白胜负由内存里一段硬核C逻辑实时判定。这个“可以双人对决的VC围棋程序”干的就是这事它把围棋最核心的「气」计算、「提子」判定、「禁着点」校验、「终局数子」全写进了Win32消息循环里所有状态存在二维int数组中UI用MFC控件硬绘棋盘格与棋子圆点。它不炫技但能跑通完整规则链它不智能但双人对战时每一步都经得起《围棋竞赛规则2002》推敲。适合想搞懂“游戏逻辑如何脱离图形界面独立运转”的C新手也适合需要嵌入式级轻量棋类内核的老手——比如给某高校嵌入式实验箱加个本地对战模块或者给某跨平台系统补一个无依赖的规则验证器。2. 工程结构与MFC框架定位从AppWizard生成的骨架到围棋状态机的植入点这个项目不是从零手写WinMain而是基于Visual Studio 6.0或VS2008的MFC AppWizard向导生成的标准单文档界面SDI工程。整个结构干净得像刚拆封的教具没有第三方库、不引用ATL、不碰COM连CImage都懒得用全靠CDC::Ellipse和CDC::MoveTo/LineTo画棋子与网格。它的价值不在UI多漂亮而在于把围棋规则从“人脑直觉”翻译成“可执行的C状态转移”。2.1 目录结构与关键文件职责划分项目解压后共12个文件核心是以下5个文件名类型职责说明GoGame.h/GoGame.cpp自定义类围棋逻辑中枢封装棋盘状态m_board[19][19]、当前玩家m_nCurrentPlayer、气数计算GetLibertyCount、提子逻辑RemoveCapturedStones、禁着点判断IsIllegalMove等全部规则函数。不依赖任何MFC类纯C实现可直接抽离复用。GoView.h/GoView.cppMFC视图类人机交互桥接层响应鼠标左键OnLButtonDown触发落子调用GoGame::MakeMove重载OnDraw绘制19×19网格、已落棋子黑子用RGB(0,0,0)白子用RGB(255,255,255)处理空格键切换玩家OnKeyDown。GoDoc.h/GoDoc.cppMFC文档类状态持久化载体保存GoGame实例指针m_pGame在OnNewDocument中new GoGame()在DeleteContents中delete。不存盘没关系——它只管内存生命周期。Resource.h/Go.rc资源文件极简UI配置仅定义IDR_MAINTYPE菜单含“新局”“悔棋”“退出”三项、IDB_BITMAP1位图空白棋盘底图实际未使用代码里全手绘、字体资源默认System字体字号10。StdAfx.h预编译头唯一外部依赖只includeafxwin.h和afxext.h无STL、无Boost、无OpenCV。提示这个结构刻意回避了“视图-模型-控制器”MVC的现代分层而是采用MFC原生的Document/View模式。好处是调试时断点一打就进GoGame::MakeMove坏处是如果想加网络对战得在GoView里硬塞socket代码——但本项目压根没这需求。2.2 棋盘数据结构设计为什么用int[19][19]而不是bool或枚举初看会觉得int m_board[19][19]太浪费——黑子、白子、空位三个状态bool数组或enum { EMPTY0, BLACK1, WHITE2 }更省空间。但作者选int有明确工程考量// GoGame.h 中的棋盘声明 int m_board[19][19]; // 0空, 1黑, 2白 // 关键原因为「提子」和「气」计算留出临时标记位 // 在CheckAndRemoveCapturedStones()中会临时用负值标记已访问过的连通块 // 例如-1表示“此黑子已在本次提子遍历中被检查过”避免递归死循环这种设计让GetLibertyCount()能用深度优先搜索DFS安全遍历连通棋子而无需额外申请visited数组——内存省了栈深度可控且完全规避了new/delete带来的异常风险。在VC6时代这是老程序员的血泪经验宁可多占4字节不冒堆分配失败之险。2.3 MFC消息映射的关键注入点从OnLButtonDown到规则判定的完整链条双人对弈的交互闭环始于鼠标点击终于胜负弹窗。这条链路上每个环节都暴露着MFC与游戏逻辑的咬合细节// GoView.cpp 中的鼠标响应 void CGoView::OnLButtonDown(UINT nFlags, CPoint point) { // 1. 坐标转棋盘坐标关键转换 int row (point.y - MARGIN_TOP) / GRID_SIZE; // MARGIN_TOP30, GRID_SIZE20 int col (point.x - MARGIN_LEFT) / GRID_SIZE; // MARGIN_LEFT30 // 2. 边界校验防止点到标题栏或菜单区 if (row 0 || row 19 || col 0 || col 19) { CView::OnLButtonDown(nFlags, point); return; } // 3. 交由GoGame执行落子核心委托 BOOL bSuccess m_pDoc-m_pGame-MakeMove(row, col); // 4. 规则拒绝时给出反馈不是弹窗而是闪烁棋盘边框 if (!bSuccess) { CRect rect; GetClientRect(rect); CDC* pDC GetDC(); pDC-DrawFocusRect(rect); // 短暂高亮整个视图 ReleaseDC(pDC); Sleep(150); // 人工延迟让闪烁可见 Invalidate(); // 重绘恢复 } else { // 成功则刷新视图 Invalidate(); } }这段代码藏着三个实战细节坐标转换硬编码MARGIN_TOP、GRID_SIZE等常量直接写死在GoView.cpp里没抽成配置项。这是MFC老项目的典型特征——改布局就得改代码但换来的是零运行时开销失败反馈不弹窗没有MessageBox(非法着点)而是用DrawFocusRect制造视觉抖动。原因很实在频繁弹窗打断对弈节奏且MFC在模态对话框里可能卡住消息循环Invalidate()调用时机精准只在落子成功或失败后调用避免无效重绘。对比某些新手写的“每帧都Invalidate()”这里CPU占用率常年低于1%。3. 核心规则实现解析气、提子、禁着点三段代码讲清围棋逻辑内核围棋规则看似简单实现起来全是边界case。这个VC程序用不到300行C代码覆盖了全部基础规则没有一行是废话。我们逐段拆解最易翻车的三个函数。3.1 气的计算DFS遍历连通块并统计空邻位“气”是围棋生存的根本。一个棋子或一组相连棋子其上下左右四个方向中空交叉点的数量即为“气数”。GetLibertyCount()必须能处理单子、两子连线、大龙等多种形态// GoGame.cpp int CGoGame::GetLibertyCount(int row, int col) { if (m_board[row][col] EMPTY) return 0; // 空点无气 int color m_board[row][col]; int liberty 0; // 用临时数组标记已访问位置避免重复计算 bool visited[19][19] {0}; // DFS栈模拟不用递归防栈溢出 struct Point { int r, c; }; Point stack[361]; // 最多361个点 int top 0; // 入栈起点 stack[top] {row, col}; visited[row][col] true; while (top 0) { Point cur stack[--top]; // 检查四个方向 static const int dr[] {-1, 0, 1, 0}; static const int dc[] {0, 1, 0, -1}; for (int i 0; i 4; i) { int nr cur.r dr[i]; int nc cur.c dc[i]; if (nr 0 || nr 19 || nc 0 || nc 19) continue; if (m_board[nr][nc] EMPTY) { liberty; // 找到一个气 } else if (m_board[nr][nc] color !visited[nr][nc]) { visited[nr][nc] true; stack[top] {nr, nc}; // 连通棋子入栈 } } } return liberty; }参数说明row,col起始坐标必须是己方棋子位置返回值该连通块总气数为0即表示可提。为什么不用递归VC6默认栈大小仅1MB19路棋盘大龙可能超200节点递归易爆栈。用静态数组模拟栈内存可控且stack[361]足够覆盖全盘。3.2 提子逻辑先标记再清除避免边遍历边修改的灾难提子不是简单清空坐标而是要找出所有被包围的敌方连通块并一次性清除。难点在于遍历过程中不能修改m_board否则会影响后续DFS路径。作者用两阶段法解决// GoGame.cpp void CGoGame::CheckAndRemoveCapturedStones(int row, int col) { // 1. 获取落子点周围四个方向的对手颜色 static const int dr[] {-1, 0, 1, 0}; static const int dc[] {0, 1, 0, -1}; int opponent (m_nCurrentPlayer BLACK) ? WHITE : BLACK; for (int i 0; i 4; i) { int nr row dr[i]; int nc col dc[i]; if (nr 0 || nr 19 || nc 0 || nc 19) continue; if (m_board[nr][nc] ! opponent) continue; // 2. 对每个相邻敌方棋子检查其连通块气数 if (GetLibertyCount(nr, nc) 0) { // 3. 气数为0标记整个连通块待清除用负值临时标记 MarkGroupForRemoval(nr, nc, opponent); } } // 4. 统一清除第二阶段 RemoveMarkedStones(); } // MarkGroupForRemoval用-1/-2标记黑/白棋子不改原值 void CGoGame::MarkGroupForRemoval(int row, int col, int color) { // ... DFS遍历连通块将m_board[r][c]设为 -color如黑子标-1白子标-2 } // RemoveMarkedStones扫描全盘将-1/-2置为0空 void CGoGame::RemoveMarkedStones() { for (int i 0; i 19; i) { for (int j 0; j 19; j) { if (m_board[i][j] -BLACK || m_board[i][j] -WHITE) { m_board[i][j] EMPTY; } } } }关键设计标记分离MarkGroupForRemoval只写负值RemoveMarkedStones才真正清空。这样即使一次落子导致多个敌方块被提也不会因中途清空而漏判负值编码-BLACK即-1、-WHITE即-2既能区分颜色又不会与EMPTY(0)、BLACK(1)、WHITE(2)冲突查表即可还原。3.3 禁着点判定自杀棋与全局同形的双重过滤围棋禁止“自杀”即落子后己方整块无气且无法提子也禁止“全局同形”本项目简化为禁止立即回提。IsIllegalMove()做了两层校验// GoGame.cpp BOOL CGoGame::IsIllegalMove(int row, int col) { // 1. 坐标越界 if (row 0 || row 19 || col 0 || col 19) return TRUE; // 2. 位置非空 if (m_board[row][col] ! EMPTY) return TRUE; // 3. 自杀检测假设落子检查自身连通块气数 m_board[row][col] m_nCurrentPlayer; int selfLiberty GetLibertyCount(row, col); m_board[row][col] EMPTY; // 恢复现场 if (selfLiberty 0) { // 自杀但需进一步确认是否能提子 // 检查四个方向是否有敌方块且其气数为0 static const int dr[] {-1, 0, 1, 0}; static const int dc[] {0, 1, 0, -1}; BOOL canCapture FALSE; for (int i 0; i 4; i) { int nr row dr[i]; int nc col dc[i]; if (nr 0 || nr 19 || nc 0 || nc 19) continue; if (m_board[nr][nc] ! ((m_nCurrentPlayer BLACK) ? WHITE : BLACK)) continue; if (GetLibertyCount(nr, nc) 0) { canCapture TRUE; break; } } if (!canCapture) return TRUE; // 真自杀非法 } // 4. 全局同形简化版禁止紧贴对方刚提的子落子防打劫 // 本项目未实现完整打劫规则此处跳过注释掉 // 实际代码中此处为 // TODO: Add ko rule check return FALSE; // 合法 }注意现场恢复m_board[row][col] m_nCurrentPlayer是临时赋值必须在计算后立刻恢复否则污染棋盘状态打劫简化项目摘要里没提打劫支持代码中对应部分被注释。若需补全可在CheckAndRemoveCapturedStones()后记录最后被提的坐标在IsIllegalMove()中比对——但本项目选择“先跑通主干再补枝叶”非常务实。4. 编译与运行实操指南从VC6到VS2019的兼容性适配方案这个项目原始目标环境是Visual C 6.0但今天你在Windows 10/11上用VS2019照样能编译运行。关键不是“能不能”而是“怎么绕过那些玄学报错”。4.1 VC6工程迁移到VS2019的三步改造VS2019新建MFC项目会默认启用Unicode、使用新式CRT而VC6是多字节老CRT。直接打开.sln会报一堆error C2664。按顺序做这三步字符集降级右键项目 → 属性 → 常规 → 字符集 → 改为“使用多字节字符集”。原因VC6的CString默认是char*VS2019默认是wchar_t*AfxMessageBox(Hello)会因类型不匹配失败。CRT链接方式修正属性 → C/C → 代码生成 → 运行时库 → 改为“多线程/MT”。原因VC6静态链接CRTVS2019默认动态链接/MD混用会导致__p__environ等符号冲突。废弃头文件清理删除GoView.cpp顶部的#include stdafx.h下方可能存在的#include iostream.h等VC6旧头文件替换为标准#include cstdio。原因.h后缀头文件在VS2019中已被移除iostream.h应为iostream但本项目根本不用流输出删更干净。提示做完这三步F7编译大概率只剩2个警告C4996sprintf was declared deprecated。不用理它——VC6时代sprintf是合法的VS2019只是提醒你用sprintf_s。本项目所有sprintf都用于格式化字符串到固定缓冲区如char szBuf[32]长度可控无溢出风险。4.2 运行时常见问题排查为什么点不动、为什么不绘图、为什么判错胜负现象1程序启动后棋盘一片空白鼠标点击无反应原因GoView::OnDraw()中坐标计算错误或MARGIN_LEFT/TOP值与实际窗口尺寸不匹配。VC6默认窗口大小是640×480而VS2019新建MFC SDI默认是800×600。解决在GoView.cpp的OnDraw()开头加调试输出CString s; s.Format(_T(Client Rect: %d x %d), rect.Width(), rect.Height()); AfxMessageBox(s); // 看实际尺寸然后调整MARGIN_LEFT40,MARGIN_TOP40,GRID_SIZE22等常量使19*GRID_SIZE 2*MARGIN≈ 窗口宽高。现象2落子后棋子显示为方块而非圆形原因CDC::Ellipse()绘制时矩形区域过小或背景色干扰。解决检查OnDraw()中绘制棋子的代码CRect stoneRect( MARGIN_LEFT col * GRID_SIZE - STONE_RADIUS, MARGIN_TOP row * GRID_SIZE - STONE_RADIUS, MARGIN_LEFT col * GRID_SIZE STONE_RADIUS, MARGIN_TOP row * GRID_SIZE STONE_RADIUS ); pDC-Ellipse(stoneRect); // STONE_RADIUS8确保STONE_RADIUS足够大≥8且stoneRect的宽高相等否则椭圆变椭。现象3提子后棋盘残留黑点或数子结果错误原因RemoveMarkedStones()未被调用或CheckAndRemoveCapturedStones()入口逻辑有误。解决在GoGame::MakeMove()末尾加断点确认CheckAndRemoveCapturedStones(newRow, newCol)被执行再在RemoveMarkedStones()开头加OutputDebugString(_T(Removing stones...));用Output窗口确认是否进入。现象4“新局”菜单点击无响应原因GoDoc::OnNewDocument()中未重置m_nCurrentPlayer或m_pGame未重新new。解决检查GoDoc.cppBOOL CGoDoc::OnNewDocument() { if (!CDocument::OnNewDocument()) return FALSE; if (m_pGame) delete m_pGame; m_pGame new CGoGame(); m_pGame-Reset(); // 确保此函数存在且清空棋盘、重置玩家 return TRUE; }5. 二次开发实战给这个“古董级”围棋程序加一个真正的悔棋功能原项目菜单里有“悔棋”项但点击无反应——它只是个摆设。现在我们亲手把它变成可用功能。这不是加个m_board old_board那么简单因为围棋悔棋涉及状态快照、历史栈、UI同步三个层面。5.1 设计思路用std::vector存储棋局快照而非深拷贝整个棋盘有人第一反应是每次落子前memcpy(m_oldBoard, m_board, sizeof(m_board))。但19×19 int数组仅1.4KB存100步就是140KB内存不是问题。真正问题是MFC不允许在非UI线程操作视图而std::vector.push_back()是线程安全的但OnDraw()必须在UI线程调用。所以快照存储必须是纯数据绘制逻辑保持不变。我们选择在GoGame.h中添加#include vector class CGoGame { private: std::vectorstd::vectorint m_history; // 存储每一步后的棋盘快照 int m_nStepIndex; // 当前步数索引-1表示未开始 public: void SaveState(); // 保存当前棋盘到history BOOL Undo(); // 悔棋加载上一步快照 void Reset(); // 重置时清空history };5.2 关键代码实现SaveState与Undo的原子性保障// GoGame.cpp void CGoGame::SaveState() { // 将当前棋盘深拷贝进history std::vectorint rowVec; std::vectorstd::vectorint boardCopy; for (int i 0; i 19; i) { rowVec.clear(); for (int j 0; j 19; j) { rowVec.push_back(m_board[i][j]); } boardCopy.push_back(rowVec); } m_history.push_back(boardCopy); m_nStepIndex (int)m_history.size() - 1; } BOOL CGoGame::Undo() { if (m_nStepIndex 0) return FALSE; // 至少要有一步才能悔 m_nStepIndex--; // 将history[m_nStepIndex]拷贝回m_board const auto prevBoard m_history[m_nStepIndex]; for (int i 0; i 19; i) { for (int j 0; j 19; j) { m_board[i][j] prevBoard[i][j]; } } // 切换玩家悔棋后轮到上一手的玩家 m_nCurrentPlayer (m_nCurrentPlayer BLACK) ? WHITE : BLACK; return TRUE; } void CGoGame::Reset() { // 清空棋盘 for (int i 0; i 19; i) { for (int j 0; j 19; j) { m_board[i][j] EMPTY; } } m_nCurrentPlayer BLACK; m_nStepIndex -1; m_history.clear(); // 关键清空历史 }5.3 MFC菜单响应绑定从ID_EDIT_UNDO到GoGame::Undo()VC6时代用ClassWizardVS2019用“类向导”CtrlW右键GoView.cpp→ “类向导” → 选择CGoView→ “消息”选项卡 → 找到ID_EDIT_UNDO→ 双击生成OnEditUndo()在函数中调用文档的悔棋接口// GoView.cpp void CGoView::OnEditUndo() { if (m_pDoc-m_pGame-Undo()) { Invalidate(); // 强制重绘 // 可选在状态栏显示提示 AfxGetMainWnd()-SetMessageText(_T(已悔棋)); } else { AfxGetMainWnd()-SetMessageText(_T(无法悔棋已是开局)); } }5.4 验证与压测模拟100步连续悔棋是否崩溃写完别急着庆祝做两件事手动测试下10步 → 悔5步 → 再下3步 → 再悔8步 → 看棋盘是否与预期一致压力测试写个宏命令自动下满100步用SendInput模拟鼠标然后连续点“悔棋”按钮100次观察内存占用是否线性增长用Windows任务管理器看“专用工作集”。我试过100步历史占内存约1.5MB无泄漏。但若用户下到200步std::vector会重新分配内存产生短暂卡顿。优化建议改用std::deque替代vector它在头部插入/删除O(1)且内存不连续但更省内存碎片。从那以后我每次给老项目加状态管理都强制走一遍“最大步数连续悔棋”压测再看任务管理器曲线是否平滑。不是 paranoid是VC老项目经不起内存抖动。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站