简介:基于C++与微软基础类库(MFC)实现的五子棋完整工程,面向学习Windows桌面编程或完成课程设计的开发者。项目包含棋盘棋子绘制、输赢判定、新建游戏、悔棋及棋盘背景样式修改等核心功能,代码结构清晰,便于观察消息处理与自定义绘图流程。包内共81个文件,约7.75MB,含C++源文件与头文件、工程配置文件、可执行的exe程序、PDF介绍文档、位图与图标素材及编译过程文件,目录完整,适合对照阅读和二次开发。已有42人学习,体量适中,重点逻辑集中在单文档视图的消息响应和绘制上,可快速定位输赢算法。整个工程可直接编译运行,通过调试梳理鼠标输入、胜负判断到界面反馈的完整流程,为扩展人机对战或网络功能预留基础。
1. 用MFC写五子棋,图的不是界面
很多人一听到 MFC 就觉得是上个世纪的产物,但拿 C++ MFC 写五子棋这种小游戏,恰恰是理解 Windows 桌面应用最直接的一条路:消息循环、GDI 绘制、控件交互、状态管理,全部都能在这一个项目里过一遍。它用不到任何第三方图形库,打开 Visual Studio 新建一个基于对话框的 MFC 工程,就能把棋盘画出来并开始对弈。整个项目涉及棋盘棋子绘制、输赢判定、新游戏、悔棋、修改棋盘背景样式等功能,正好把 MFC 程序里最容易踩坑的几个环节全部串了起来。适合正在学 C++ 基础、准备找练手项目的人照着敲一遍,也适合想复习 GDI 和消息映射的熟手快速捡起手感。
2. 棋盘棋子绘制:双缓冲与 GDI 落子重绘
2.1 先选工程类型和棋盘数据模型
MFC 里做五子棋,我一般直接建“基于对话框”的工程,而不是单文档或多文档。对话框工程不用处理 Document/View 那套视图切分,OnPaint 里画棋盘、OnLButtonDown 里处理落子,消息映射短,新手更容易看懂。新建工程时选“MFC 应用程序”,应用程序类型选“基于对话框”,记得勾选“使用 Unicode 字符集”,后续字符串用 _T() 包裹就省很多乱码问题。如果你用的 Visual Studio 默认没装 MFC 组件,安装器里勾上“使用 C++ 的桌面开发”工作负载,再选右侧的“适用于最新 v143 生成工具的 C++ MFC”即可,离线安装就挂载 ISO 后选对应组件。
数据模型用三个成员变量就够了:int m_board[15][15] 表示棋盘状态,0 是空、1 是黑棋、2 是白棋;int m_curColor 表示当前轮到谁下;再准备一个容器存落子记录,悔棋时从尾部弹出。棋盘数组既承担绘制职责,也承担输赢判定职责,后面所有逻辑都围绕它转。注意行列习惯:m_board[row][col] 中 row 是纵向行号,col 是横向列号,画图算坐标时容易把两者搞反,建议一开始就在代码注释里写明。
2.2 OnPaint 里只用双缓冲:一份可以直接抄的绘制代码
窗口在收到 WM_PAINT 消息时会触发 OnPaint,棋盘的所有内容都在这里绘制。最常用的绘图设备是 CPaintDC,它会在构造函数里自动调用 BeginPaint,析构时自动 EndPaint。若直接在窗口 DC 上画网格和棋子,每次重绘都会闪烁,所以我一律先用内存位图把画面画好,再一次 BitBlt 贴到屏幕。下面是对话框主窗口的 OnPaint 骨架:
void CGoBangDlg::OnPaint() { CPaintDC dc(this); // 窗口客户区设备上下文 CRect rc; GetClientRect(&rc); // 客户区矩形,决定画布大小 // 双缓冲:先画到内存位图,再一次性贴到屏幕 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); CBitmap* pOld = memDC.SelectObject(&bmp); // 1. 填充棋盘背景色 memDC.FillSolidRect(rc, m_bgColor); // 2. 画网格线 DrawBoard(&memDC); // 3. 画所有已落棋子 DrawPieces(&memDC); // 4. 把内存画布整体复制到窗口,避免闪烁 dc.BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); // 还原旧位图 bmp.DeleteObject(); // 释放 GDI 位图对象 }这里的核心是先把所有绘图操作落在内存位图上,最后一次 BitBlt 输出。参数上,rc.Width() 和 rc.Height() 决定了位图尺寸,窗口大小变化时每次 OnPaint 都重新创建位图,功能上没问题,但频繁触发会有点浪费,工程化一点可以把位图按窗口尺寸缓存起来。对于五子棋这种固定窗口的小程序,每次重建反而简单,还不容易出错。注意最后必须 SelectObject(pOld) 还原旧位图,再 DeleteObject,否则 GDI 对象没有被正确释放,任务管理器里的句柄数会慢慢上涨。
网格线和棋子的绘制单独拆成 DrawBoard 和 DrawPieces,避免 OnPaint 长得没法看。棋盘是 15 行 15 列,我定义边距 BOARD_MARGIN 为 30 像素、格子大小 CELL_SIZE 为 36 像素,这样整个棋盘占 30 到 30 + 14 * 36 = 534 像素,窗口客户区设成约 574 x 574 刚好放下。DrawBoard 的实现如下:
void CGoBangDlg::DrawBoard(CDC* pDC) { // 先画一个浅色底衬,模拟木质棋盘 CRect rc; GetClientRect(&rc); pDC->FillSolidRect(rc, RGB(210, 180, 140)); // 画网格:横线 + 竖线 CPen pen(PS_SOLID, 1, RGB(80, 60, 40)); CPen* pOldPen = pDC->SelectObject(&pen); int n = BOARD_SIZE; // 15 for (int i = 0; i < n; i++) { // 横线 pDC->MoveTo(BOARD_MARGIN, BOARD_MARGIN + i * CELL_SIZE); pDC->LineTo(BOARD_MARGIN + (n - 1) * CELL_SIZE, BOARD_MARGIN + i * CELL_SIZE); // 竖线 pDC->MoveTo(BOARD_MARGIN + i * CELL_SIZE, BOARD_MARGIN); pDC->LineTo(BOARD_MARGIN + i * CELL_SIZE, BOARD_MARGIN + (n - 1) * CELL_SIZE); } pDC->SelectObject(pOldPen); // 还原画笔,防止句柄泄漏 }CPen 是临时对象,函数结束会自动析构,但析构前没有从 DC 还原的话,MFC 的调试版会报“对象仍被选入设备上下文”的断言,所以每次 SelectObject 都要成对出现。网格间距 CELL_SIZE 取 36 时,棋子半径取 13 左右视觉上刚好,相邻棋子不会重叠。如果窗口放到高清屏上,可以考虑用 GetDeviceCaps 做 DPI 自适应,不过这个项目保持固定尺寸最省心。
DrawPieces 遍历 m_board,遇到非 0 就在对应交叉点画实心圆。黑棋画成黑色,白棋画成白色,为了在白底上分辨,我给白棋加一圈灰色描边,这个细节在浅色棋盘上很关键:
void CGoBangDlg::DrawPieces(CDC* pDC) { for (int row = 0; row < BOARD_SIZE; row++) { for (int col = 0; col < BOARD_SIZE; col++) { if (m_board[row][col] == 0) continue; int x = BOARD_MARGIN + col * CELL_SIZE; int y = BOARD_MARGIN + row * CELL_SIZE; // 白棋加灰边,黑棋加浅色边,避免深色棋盘上糊成一团 CBrush edgeBrush; edgeBrush.CreateSolidBrush(m_board[row][col] == 1 ? RGB(100, 100, 100) : RGB(180, 180, 180)); CBrush* pOldEdge = pDC->SelectObject(&edgeBrush); pDC->Ellipse(x - 14, y - 14, x + 14, y + 14); CBrush fillBrush; fillBrush.CreateSolidBrush(m_board[row][col] == 1 ? RGB(20, 20, 20) : RGB(245, 245, 245)); CBrush* pOldFill = pDC->SelectObject(&fillBrush); pDC->Ellipse(x - 12, y - 12, x + 12, y + 12); pDC->SelectObject(pOldFill); fillBrush.DeleteObject(); pDC->SelectObject(pOldEdge); edgeBrush.DeleteObject(); } } }这里的双层 Ellipse 是先画大圆当描边,再画小圆当填充,黑白棋子在木质色背景上都能看清。落子重绘时不要调用 Invalidate() 而是 Invalidate(FALSE),因为默认的 Invalidate() 会触发 WM_ERASEBKGND 把背景擦成白色再重画,棋盘会闪一下。用 FALSE 只标记客户区为无效,系统不会先擦背景,配合双缓冲基本看不到闪烁。
2.3 鼠标落子:从屏幕坐标到棋盘坐标的换算
落子逻辑放在 OnLButtonDown 里。鼠标点击的 point 是窗口客户区坐标,需要先减掉棋盘边距,再除以格子大小,得到行列号。这里有个翻车点:直接用除法会把靠近交叉点边缘的点击归到错误位置,所以先加 CELL_SIZE / 2 做四舍五入。
void CGoBangDlg::OnLButtonDown(UINT nFlags, CPoint point) { // 把客户区坐标换算成棋盘行列 int col = (point.x - BOARD_MARGIN + CELL_SIZE / 2) / CELL_SIZE; int row = (point.y - BOARD_MARGIN + CELL_SIZE / 2) / CELL_SIZE; // 点在棋盘网格范围之外,忽略 if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return; // 该位置已经有棋子,忽略,防止覆盖 if (m_board[row][col] != 0) return; // 游戏已结束,不再接受落子 if (m_gameOver) return; m_board[row][col] = m_curColor; m_steps.push_back(CPoint(col, row)); // 注意这里存的是 col, row m_stepCount++; // 判定当前手是否获胜 if (CheckWin(row, col, m_curColor)) { CString msg; msg.Format(_T("%s 获胜!"), m_curColor == 1 ? _T("黑棋") : _T("白棋")); MessageBox(msg); m_gameOver = TRUE; } else if (m_stepCount >= BOARD_SIZE * BOARD_SIZE) { MessageBox(_T("平局")); m_gameOver = TRUE; } else { // 1 <-> 2 互换,轮到对方 m_curColor = 3 - m_curColor; } Invalidate(FALSE); CDialogEx::OnLButtonDown(nFlags, point); }坐标换算公式里,BOARD_MARGIN 是棋盘第一根线的偏移量。假设格子大小 36,点击点落在某交叉点附近 18 像素范围内都能归到那个格子,手感比较宽容。m_steps 里我用 CPoint(col, row) 而不是 CPoint(row, col),因为 CPoint 的第一个参数习惯是 x(列)、第二个是 y(行),后面悔棋弹出时再按 m_board[pt.y][pt.x] 取回,这个约定最好注释出来,不然悔棋时早晚会在行列上晕一次。
提示:ON_WM_LBUTTONDOWN 的映射要写在 BEGIN_MESSAGE_MAP 里,如果用了类向导生成,它会自动加;手写时漏掉这条消息映射,鼠标点击就永远落不了子,这是新手最常见的“程序没反应”。
3. 输赢判定:五子连珠与平局状态
3.1 方向数组写四向检查,别全盘扫描
五子棋的输赢判定可以做得非常省:每次落子后只检查经过这个落子点的四条线——横向、纵向、主对角线、副对角线,而不是每次都把 15x15 棋盘全扫一遍。全盘扫描的代码写起来更短,但逻辑上浪费,而且还要额外处理重复统计的边界。只从当前落子点向两个相反方向延伸,数同色连续棋子数量,大于等于 5 就是赢。方向用方向数组表达,代码紧凑也好扩展。
bool CGoBangDlg::CheckWin(int row, int col, int player) { // 四个方向对:水平、垂直、主对角线、副对角线 const int dir[4][2] = { {1, 0}, // 水平:行不变,列递增 {0, 1}, // 垂直:列不变,行递增 {1, 1}, // 主对角线:行、列同时递增 {1, -1} // 副对角线:行递增,列递减 }; for (int i = 0; i < 4; i++) { int cnt = 1; // 当前落子本身算一个 // 正方向延伸 for (int k = 1; ; k++) { int nr = row + dir[i][0] * k; int nc = col + dir[i][1] * k; // 越界或遇到异色棋子就停止 if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (m_board[nr][nc] != player) break; cnt++; } // 反方向延伸 for (int k = 1; ; k++) { int nr = row - dir[i][0] * k; int nc = col - dir[i][1] * k; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (m_board[nr][nc] != player) break; cnt++; } // 正反两个方向加起来达到 5 连即胜 if (cnt >= 5) return true; } return false; }这段代码的边界判断顺序很重要:永远先判断越界再访问数组。如果先访问 m_board[nr][nc] 再判断越界,在棋盘最边缘落子时会直接访问到数组外的内存,轻则读到脏数据,重则崩溃。很多人在做 15x15 小棋盘时觉得不会越界,实际上副对角线方向 {1, -1} 特别容易踩到 col 变成 -1 的情况。
方向数组是四对方向而不是八个方向,因为正方向和反方向共用一次遍历就能完成,代码量最少。如果写成八个方向单独循环,逻辑会更啰嗦,还要额外去重。cnt 从 1 开始,因为落子点本身就是一颗同色棋子,这也是新手容易漏掉的:忘了把当前子算进去,导致五连只数出四颗,胜负判定变成玄学。
3.2 平局判断和新游戏重置:别只清空棋盘
五子棋没有和棋规则里常见的“禁手”复杂度,但棋盘填满时需要判定平局。我用 m_stepCount 记录总落子数,每下一步加一。当落子数达到 225 且没有人获胜时,弹平局提示,并把 m_gameOver 置位,禁止继续下棋。这一步在 OnLButtonDown 里已经体现,核心判断只有一行:
if (m_stepCount >= BOARD_SIZE * BOARD_SIZE) { MessageBox(_T("棋盘已满,平局")); m_gameOver = TRUE; }这里用 >= 而不是 ==,是为了防止某条逻辑路径漏加导致死循环或者重复触发。实际上正常流程每步只加一次,== 也能工作,但防御性写法能少很多边界烦恼。
新游戏功能是状态管理的一部分,代码非常简单,但有一个容易忽略的坑:除了 memset 棋盘,还要清空 m_steps 栈、把 m_stepCount 归零、把 m_curColor 重置成黑棋先手、把 m_gameOver 复位,最后 Invalidate(FALSE) 触发重绘。漏掉任何一个,都会出现“新游戏后还能悔掉上一局的棋”这类诡异现象。
void CGoBangDlg::OnNewGame() { memset(m_board, 0, sizeof(m_board)); // 清空棋盘 m_steps.clear(); // 清空悔棋记录 m_stepCount = 0; m_curColor = 1; // 约定黑棋先手 m_gameOver = FALSE; Invalidate(FALSE); }memset 只适合 int 数组清零,如果将来把 m_board 改成 vector 或其他结构,就得改用 fill 或循环赋值。m_curColor 用 1 表示黑棋、2 表示白棋,是因为后面要用 3 - m_curColor 在 1 和 2 之间互换,数学上最干净。
4. 新游戏与悔棋:状态管理的栈设计
4.1 落子记录存进栈:std::vector 比 CArray 更通用
悔棋本质上是一个撤销操作,天然匹配栈结构:后落子的先悔。MFC 里常见的容器有 CArray 和 std::vector,我一般用 std::vector ,因为 push_back、pop_back、clear 语义明确,而且在脱离 MFC 的 C++ 环境里也能复用。如果项目强依赖 MFC 风格,CArray<CPoint, CPoint> 也能用,但它的索引和删除接口没有 vector 顺手。选型上还有一个考虑:vector 的调试器可视化支持比 CArray 好,排查问题时一眼能看到栈里存了多少步。
// 在对话框类头文件中声明 std::vector<CPoint> m_steps; // 每个元素是 CPoint(col, row) int m_stepCount; // 当前已落子总数m_steps 只负责记录“哪一步被下在哪里”,不负责记录每一步是谁下的。当前执子颜色 m_curColor 永远表示“接下来的落子方”,悔棋时把 m_curColor 翻转一次,就等于把上一步还原了。如果将来要做连续悔棋,这个设计也能直接支持:悔两步就翻转两次。
4.2 悔棋实现:弹出上一步并换回执子颜色
悔棋按钮或菜单项触发 OnUndo。逻辑分三步:判断栈里有没有记录、弹出最后一步、恢复棋盘状态并重绘。同时要处理两个边界:没有棋可悔时要给提示;棋局已经结束时默认不允许悔棋,否则会出现“黑棋赢了,悔棋后变成白棋继续”这种状态错乱。
void CGoBangDlg::OnUndo() { if (m_steps.empty()) { MessageBox(_T("还没有落子,不能悔棋")); return; } if (m_gameOver) { MessageBox(_T("棋局已结束,请先开始新游戏")); return; } // 弹出最后一个落子点 CPoint pt = m_steps.back(); m_steps.pop_back(); // 注意 pt 是 CPoint(col, row),这里要按行列还原 m_board[pt.y][pt.x] = 0; m_stepCount--; // 当前执子颜色换回上一步 m_curColor = 3 - m_curColor; Invalidate(FALSE); }关键点在于 CPoint 的 x 和 y 分别对应 col 和 row,弹出后必须写成 m_board[pt.y][pt.x] 而不是 m_board[pt.x][pt.y],这两个写法在非正方形棋盘上效果不同,在 15x15 的方形棋盘上则刚好相等,导致这个坑极难被发现。这也是我坚持在代码注释里写明坐标约定的原因。如果不想依赖这个约定,可以直接存一个自定义结构体:
struct StepInfo { int row; int col; int player; // 冗余存下这一步是谁下的,悔棋时直接用 }; std::vector<StepInfo> m_steps;冗余存 player 字段后,悔棋时不必依赖 3 - m_curColor 的换算,容错性更好,代价是多存一个 int。这个取舍看个人习惯,我倾向于用冗余字段,因为“悔棋后 curColor 应该等于什么”这种逻辑在代码里更直白。
4.3 菜单和快捷键:消息映射写法
新游戏和悔棋通常放在菜单里,或直接做成两个按钮。MFC 的消息映射是宏驱动的,在类声明里加 afx_msg 函数声明,在 BEGIN_MESSAGE_MAP 和 END_MESSAGE_MAP 之间加 ON_COMMAND 宏,资源编辑器里菜单项的 ID 必须和宏里的 ID 一致,否则点击菜单没有反应。
BEGIN_MESSAGE_MAP(CGoBangDlg, CDialogEx) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_COMMAND(ID_NEW_GAME, &CGoBangDlg::OnNewGame) ON_COMMAND(ID_UNDO, &CGoBangDlg::OnUndo) END_MESSAGE_MAP()菜单项的 ID 建议在 resource.h 里统一规划,比如 ID_NEW_GAME 和 ID_UNDO,避免和系统预留 ID 冲突。对话框默认把 Enter 键绑定到确定按钮,如果不处理,用户按回车会直接关闭对话框。常见做法是把对话框上“确定”按钮的 ID 改成 IDCANCEL,或者重写 OnOK 让它什么都不做。这个细节和五子棋项目本身无关,但每个 MFC 对话框项目都会遇到。
快捷键可以加在加速键表里,ID 和菜单项共用,这样按 Ctrl+N 触发新游戏、Ctrl+Z 触发悔棋不需要额外代码。加速键表的资源编辑器里,命令 ID 填 ID_NEW_GAME,键填 Ctrl+N,系统会把键盘消息自动路由到对应的 ON_COMMAND 处理函数。
5. 调试与避坑:绘制闪烁、句柄泄漏和消息不响应的几个现场
这一章是排查笔记,每一条都是我在 MFC 小项目里遇到的真实问题,按“现象 → 原因 → 解决”的顺序写,方便你对照自己的代码去查。
5.1 落子时棋盘闪烁
现象:每点一次鼠标,整个棋盘白闪一下,落子不跟手,视觉上像“先擦掉再画”。
原因:窗口收到 WM_ERASEBKGND 消息时,系统默认用背景色填充客户区,把双缓冲绘制的成果先毁掉。另一个常见原因是 OnPaint 里没有用双缓冲,直接在窗口 DC 上逐条画线,重绘区域大时闪烁明显。
解决:双缓冲是基础,见第 2.2 节的 OnPaint 写法。同时把默认的 Invalidate() 改成 Invalidate(FALSE),并且重写 OnEraseBkgnd 返回 TRUE,告诉系统背景已经在 OnPaint 里画完了:
BOOL CGoBangDlg::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景统一在 OnPaint 里画,不交给系统擦除 }这一手对闪烁的改善最明显。如果仍然闪,检查是不是在 OnPaint 里调用了对话框的 UpdateWindow 或 SendMessage,重绘期间再次触发绘制会产生递归刷新。
5.2 任务管理器里 GDI 句柄数只增不减
现象:程序运行一段时间后界面变卡,打开任务管理器看“GDI 对象”列,数值持续上涨。
原因:OnPaint 或按钮响应里创建了画刷、画笔、位图,没有成对释放。MFC 的临时对象析构通常会释放,但如果对象被 SelectObject 选入 DC 后没有还原,或者 CBitmap 明明创建了却没有 DeleteObject,每次重绘都会泄漏一个 GDI 对象。GDI 对象有上限,超了之后所有绘制会失败,表现为棋盘越画越不全。
解决:每次 SelectObject 都保存返回值,用完立刻还原,再显式 DeleteObject。局部变量可以依赖析构,但依赖析构不如显式写清楚:
CBrush brush; brush.CreateSolidBrush(RGB(0, 0, 0)); CBrush* pOldBrush = pDC->SelectObject(&brush); // ... 绘制 ... pDC->SelectObject(pOldBrush); // 还原旧对象 brush.DeleteObject(); // 释放新对象位图对象更要注意,因为 CBitmap 在析构时如果仍被 DC 选中,MFC 会触发断言。养成“先还原,再删除”的习惯,GDI 泄漏基本不会发生。想验证是否泄漏,在 OnPaint 开头和结尾各调一次 GetObject 枚举当前 GDI 对象数量,或者干脆用任务管理器观察几分钟。
5.3 “新游戏”菜单点了没反应
现象:菜单项存在,点击后棋盘数据没变化,甚至一点反应都没有。
原因:消息映射缺失,或者资源里菜单项 ID 和 ON_COMMAND 里的 ID 不一致。最常见的是手写 ON_COMMAND 时把 ID 敲错,比如 resource.h 里定义的是 ID_NEW_GAME,代码里写成了 ID_NEWGAME。另一个坑是对话框类没有在类向导里绑定这个命令,BEGIN_MESSAGE_MAP 里只有系统消息没有命令消息。
解决:先打开资源视图,双击菜单项看属性里的 ID,再打开代码里的 BEGIN_MESSAGE_MAP 对比。确认无误后,在类声明里加 afx_msg void OnNewGame(); 确保函数签名是 void 无参数,ON_COMMAND 宏才能匹配。再加上 Invalidate(FALSE) 触发重绘,菜单就这么简单:
ON_COMMAND(ID_NEW_GAME, &CGoBangDlg::OnNewGame)调试时可以在 OnNewGame 第一行加 AfxMessageBox(_T("命令已进入")),如果弹窗说明消息路由正常,问题在函数内部;如果弹窗都不弹,问题在资源 ID 或消息映射上。
5.4 在棋盘边缘落子,程序崩溃
现象:点击最左边或最右边一列时,偶尔崩溃,报错位置在 CheckWin 里。
原因:坐标换算后,row 或 col 出现了 -1 或 15 等越界值。比如鼠标点在边距内侧,加 CELL_SIZE / 2 取整后可能计算出 15,而 m_board[15][x] 已经越界。CheckWin 里的方向数组遍历到棋盘边界时,没有先判断再访问,于是越界读内存。
解决:OnLButtonDown 开头先判断行列范围,CheckWin 里每次访问数组前先判边界。这两个防御位置缺一不可,因为 OnLButtonDown 检查只能拦住落子入口,如果未来从 AI 模块调用落子逻辑,入口检查不会生效,CheckWin 自身的边界判断才是最后一道防线。
if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; // 先判越界,再访问 m_board[nr][nc]顺序绝对不能反,这是数组越界里最常见的翻车姿势。
5.5 重复点击同一格子,棋子被覆盖
现象:黑子落下去后,再点同一位置,黑子变成白棋,或者又变成黑棋,棋局状态混乱。
原因:OnLButtonDown 里没有检查 m_board[row][col] 是否已经非 0,导致后落子的直接覆盖先落子的。另一个伴随原因是游戏结束后没有拦 m_gameOver,胜负已分还能继续点击落子。
解决:在写入棋盘前加两道判断:
if (m_board[row][col] != 0) return; // 已有棋子,忽略本次点击 if (m_gameOver) return; // 对局结束,忽略本次点击这个问题的隐蔽性在于,棋盘小、逻辑简单时,覆盖一颗棋子不一定立刻暴露,可能下了几步才发现黑白棋子数量对不上。所以落子入口的判断要写全,别只在 CheckWin 里做边界。
6. 修改棋盘背景样式:画刷与位图的两种切换技巧
棋盘背景样式是这个项目里最容易被忽略但观感提升最明显的功能。常见的做法是准备两种纯色背景风格,运行时用菜单切换,重绘生效。对话框里加一个成员变量 m_bgColor,OnInitDialog 里初始化成木纹色,切换时只改颜色再 Invalidated(FALSE) 即可:
void CGoBangDlg::OnBgStyleClassic() { m_bgColor = RGB(210, 180, 140); // 木纹底色 Invalidate(FALSE); } void CGoBangDlg::OnBgStyleLight() { m_bgColor = RGB(245, 245, 220); // 护眼米白 Invalidate(FALSE); }这种纯色切换是最省事的,唯一要注意的是棋子描边颜色要跟着背景调整。深色背景下白棋加灰边、黑棋加浅边;米白背景下则要把黑棋的边改成灰色。网格线颜色也可以跟着背景微调,否则浅色背景下网格线过淡会看不清。
想要更进一步,可以用一张真实棋盘的位图做背景。思路是在 OnPaint 里用 CreatePatternBrush 生成一个画刷,然后用 FillRect 填充,不再调用 FillSolidRect 画纯色。画刷要声明成对话框的成员变量,在 OnInitDialog 里创建,析构函数里 DeleteObject:
// OnInitDialog 里: CBitmap bmp; bmp.LoadBitmap(IDB_BOARD_BG); // 位图资源 m_bgBrush.CreatePatternBrush(&bmp); // 画刷引用位图 bmp.DeleteObject(); // 画刷已持有位图,这里可释放 // OnPaint 里: CBrush* pOld = memDC.SelectObject(&m_bgBrush); memDC.FillRect(rc, &m_bgBrush); memDC.SelectObject(pOld);位图画刷最大的坑是位图尺寸和填充区域不匹配时会出现明显的平铺接缝。纹理连续的木纹图可以平铺,带边框的棋盘位图则不适合用画刷填充,因为它会重复出现多套网格。如果要用完整棋盘位图,更合适的方式是在 DrawBoard 里直接 BitBlt 整张位图到窗口,然后把所有棋子和网格绘制改为叠放在这张位图之上。无论哪种改法,都要在析构里释放 m_bgBrush,避免 GDI 泄漏。
我自己的习惯是把背景样式做成菜单勾选项,经典木纹和护眼米白之间切换,顺便用勾选状态记录当前样式。这个项目做完回头看,收获最大的反而不是什么高级算法,而是把消息映射、重绘、GDI 对象生命周期这些 MFC 基本功磕扎实了。黑子配黑背景这种配色灾难我也踩过,从那以后每次改背景都会先确认棋子颜色和网格线的对比度。希望帮到你。
本文还有配套的精品资源,点击获取