用MFC写五子棋:GDI绘制、双缓冲与状态管理实战

发布时间:2026/9/28 16:13:29

用MFC写五子棋:GDI绘制、双缓冲与状态管理实战 简介基于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 风格CArrayCPoint, CPoint 也能用但它的索引和删除接口没有 vector 顺手。选型上还有一个考虑vector 的调试器可视化支持比 CArray 好排查问题时一眼能看到栈里存了多少步。// 在对话框类头文件中声明 std::vectorCPoint 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::vectorStepInfo 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 和菜单项共用这样按 CtrlN 触发新游戏、CtrlZ 触发悔棋不需要额外代码。加速键表的资源编辑器里命令 ID 填 ID_NEW_GAME键填 CtrlN系统会把键盘消息自动路由到对应的 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_bgColorOnInitDialog 里初始化成木纹色切换时只改颜色再 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 基本功磕扎实了。黑子配黑背景这种配色灾难我也踩过从那以后每次改背景都会先确认棋子颜色和网格线的对比度。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/9/28 16:13:29

用Substrate搭建你的第一条应用链:从环境配置到自定义Pallet

1. Substrate到底是什么:一个被多个行业共用、却被开发者盯上的关键词1.1 同一个词,在不同语境里代表完全不同的东西substrate这个词,在不同圈子里指代的东西完全不一样。学生物的人看到它,想到的是酶促反应里的“底物”&#xff…

2026/9/28 16:13:29

ax调度实战解析:WiFi 6的OFDMA、MU-MIMO与TWT调优指南

1. 从“ax”聊起:为什么换了WiFi 6路由器,网速还是老样子最近在无线网络交流群里,“ax调度”这个词出现频率很高。第一次看到的人多半会愣一下,ax是什么?其实就是802.11ax,也就是WiFi联盟改名之后所说的WiF…

2026/9/28 17:13:33

Substrate入门实战:从模板到自定义Pallet的完整拆解

看到 substrate 这个词,不同背景的人会想到完全不同的东西:做材料的想到基板或底材,学生物的想到酶反应里的底物,而搞区块链开发的,多半会直接反应到那套用 Rust 写的区块链开发框架——Substrate。我第一次接触它时其…

2026/9/28 17:13:33

Substrate区块链开发框架实战:从零搭建第一条自定义链

substrate这个词在技术圈里一扔出来,懂行的人大概都知道你在说区块链领域里的那个模块化开发框架,而不是化学实验里的“底物”。作为Polkadot生态的核心技术底座,Substrate被越来越多想做链的团队盯上,过去要花一年半载从零手写一…

2026/9/28 17:13:33

harness-sdk 详解:Java 服务端功能开关与灰度发布实践

做后端十多年,功能开关这玩意儿从自己写 Redis 开关,到用开源方案,再到现在大部分项目放进 Harness 平台,算是一路踩坑踩过来的。harness-sdk 这个名字乍一看像某个内部项目代号,实际它就是 Harness 平台向开发者暴露的…

2026/9/28 17:13:33

森林害虫目标检测数据集实战:YOLO标注格式与训练全流程解析

简介:森林害虫目标检测数据集是一套面向林业害虫智能监测与农业生态保护的YOLO格式目标检测数据,覆盖松毛虫、松墨天牛、卷叶蛾三类常见且危害严重的害虫,适用于森林健康监测系统、无人机巡检、精准施药等AI模型的训练与验证。数据来源于实际…

2026/9/28 17:13:33

Substrate区块链开发框架:模块化造链原理与实操避坑指南

去年有个朋友拉我聊,说团队准备发一条自己的链,问我从哪下手。我给的回答很直接:去研究 Substrate,别从零造轮子。后来他花了两个月,真把一条带自定义业务模块的链跑起来了,跟我说这个框架把造链门槛拉低了…

2026/9/28 17:08:33

Substrate Runtime:WASM驱动的可验证执行基础设施

1. Substrate 不是“另一个区块链框架”:它本质是一套可验证的运行时编译基础设施很多人第一次听到 Substrate,第一反应是:“哦,又一个做公链的 Rust 框架,和 Cosmos SDK、Tendermint 差不多?”——这个理解…

2026/9/28 3:03:23

东莞市品牌网站建设报价常见报错与解决

东莞品牌网站建设报价单背后:一份保姆级建站教程避坑实录 网站做好了没人访问,这大概是很多老板最头疼的事。花了大几万做的品牌站,上线后流量惨淡,比路边摊还冷清。别急着骂外包公司,很多“东莞品牌网站建设报价”里藏着不少猫腻,比如用模板站冒充定制…

2026/9/28 6:05:15

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解 【免费下载链接】spirula-studio Cross-vendor 3D Gaussian Splatting trainer - video to splat to mesh, Vulkan or CUDA. 项目地址: https://gitcode.com/GitHub_Trending/sp/spirula-studio Sp…

2026/9/28 6:07:41

SEO怎么推广速查手册新手避坑实战指南

SEO怎么推广速查手册新手避坑实战指南 模板网站太丑不够用?别急着加滤镜,那是治标不治本。很多老板盯着后台流量掉得眼红,却还在纠结首页Banner的圆角是不是3像素。这就像穿着西装去挖土,姿势不对,努力白费。我整理这份 速查手册…

2026/9/28 0:02:03

广州外贸网站建设推广:从零搭建全流程拆解与真实报价避坑

广州外贸网站建设推广:从零搭建全流程拆解与真实报价避坑 改个需求建站公司拖一周,后台改个文案还得再交一笔“技术维护费”。这种憋屈事儿,做外贸的朋友太熟悉了。很多老板在找广州外贸网站建设推广服务商时,光盯着首页好不好看,却忽略了从零搭建一个能…

2026/9/28 0:02:04

搞懂百度竞价推广价格,网站性能优化别掉链子

搞懂百度竞价推广价格,网站性能优化别掉链子 网站突然打不开,浏览器弹出红色警告“此网站存在安全风险”,后台一看全是乱码代码和奇怪的跳转链接。这种网站被黑挂马的绝望感,很多刚转行做网站的朋友都经历过,尤其是那些为了省几百块钱服务器费用的新手。…

2026/9/25 20:55:38

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/26 19:58:38

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/28 1:59:25

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑