简介:本资源是一份基于Windows SDK开发象棋程序的完整实践项目,面向C++初学者及Windows平台GUI开发入门者,聚焦GDI绘图、Windows消息机制、棋局状态管理与基础AI逻辑实现等核心技能。压缩包共38个文件,含18个ICO图标资源(用于棋子与界面图标)、6个头文件(.h,定义棋盘结构、函数接口与宏常量)、4个CPP源码文件(实现绘图、思考、标准逻辑等模块)、3个光标文件(.cur,增强交互体验)及配套说明文档(.txt/.htm),整体仅24KB,轻量易读,结构清晰,便于逐模块分析学习。已有123人下载学习,适合通过小而完整的项目理解SDK底层编程范式——从窗口创建、鼠标事件响应、棋子拖放绘制,到将军检测与简易AI走法生成,所有代码均围绕Windows原生API展开,无第三方框架依赖,是掌握传统Win32开发路径的优质入门范例。
1. Windows SDK 象棋:一个不依赖 MFC、Qt 或 .NET 的纯原生 Win32 棋类 Demo,适合想啃透窗口消息循环与 GDI 绘图的新手练手
你可能试过用 Python 写个控制台象棋,也见过 Electron 打包的网页版中国象棋——但当你真正想搞懂「为什么点击棋盘某格,程序就知道该选马还是吃炮」,或者「双击红方‘车’后,怎么只在合法位置画上半透明圆点」,那些封装层就变成了黑匣子。这个 Windows SDK 象棋项目,就是一把没包浆的钢锉:它用纯 C 语言 + Win32 API(CreateWindowEx、GetMessage、BeginPaint、BitBlt 等)从零搭起整个界面,不调用任何第三方 UI 库,连资源文件都只用 .rc 定义图标和菜单。它不追求 AI 对战或网络联机,核心价值是把「鼠标坐标 → 逻辑坐标映射 → 棋子状态更新 → GDI 重绘」这条链路掰开揉碎,每一行代码都对应一个 Win32 编程基本功。如果你正卡在「学完《Windows 程序设计》第五章却写不出完整窗口」,或者想为后续开发 DirectX 游戏打下底层坐标与消息处理直觉,这个项目就是你该下载、编译、断点跟进去跑三遍的起点。
2. 项目结构与技术栈:Win32 API + GDI 绘图 + 纯 C 实现,无外部依赖
2.1 目录结构与文件职责划分
项目解压后共 7 个核心文件,全部位于根目录,无子文件夹:
| 文件名 | 类型 | 关键职责 |
|---|---|---|
chess.c | C 源码 | 主窗口过程(WndProc)、消息分发主干、全局变量定义(如g_hInst,g_hWnd)、WinMain 入口 |
board.c | C 源码 | 棋盘逻辑核心:InitBoard()初始化 10×9 数组、IsLegalMove()判断走法合法性(含马腿、象眼、将帅照面等规则)、MakeMove()执行落子并触发重绘 |
render.c | C 源码 | GDI 渲染中枢:DrawBoard()绘制背景网格与楚河汉界、DrawPiece()根据棋子类型/颜色绘制汉字(“車”“砲”“士”等)、DrawValidMoves()在合法目标格绘制半透明圆点 |
input.c | C 源码 | 鼠标交互层:OnLButtonDown()捕获点击坐标、ScreenToBoard()将客户区像素坐标转为棋盘行列索引(关键转换函数)、HandleClick()协调选子/走子状态机 |
resource.h | 头文件 | 资源 ID 定义:菜单项 ID(IDM_NEWGAME, IDM_EXIT)、加速键 ID(VK_F2)、图标 ID(IDI_CHESS) |
chess.rc | 资源脚本 | 定义菜单栏(“游戏”“帮助”)、图标、字符串表(如“新游戏”“退出”) |
makefile | 构建脚本 | 使用 MinGW-w64 的gcc编译:链接user32.lib,gdi32.lib,comctl32.lib,生成chess.exe |
提示:项目未使用 Visual Studio 解决方案(.sln),而是面向命令行构建。这意味着你无需安装庞大 IDE,只要本地有 MinGW-w64(推荐 x86_64-10.2.0-release-win32-seh-rt_v9-rev1)或 Windows SDK 自带的
cl.exe,就能直接make编译。这是 Win32 原生开发最轻量的启动姿势。
2.2 技术选型逻辑:为什么坚持纯 SDK?三个硬约束
这个项目的技术栈不是为了“复古”,而是由三个不可妥协的工程约束倒推出来的:
- 零运行时依赖:exe 运行时不需 .NET Framework、VC++ Redistributable 或 Qt DLL。
chess.exe在 Windows 7 SP1 及以上系统双击即启,适合嵌入教学 U 盘或离线实训环境。 - 可调试性优先:所有逻辑都在 C 源码中,没有 MFC 的
CView::OnDraw()封装、没有 Qt 的信号槽隐式连接。你在WndProc里下断点,能清晰看到WM_LBUTTONDOWN → WM_PAINT → WM_COMMAND的完整流转。 - 教学粒度可控:GDI 绘图(
SelectObject,TextOut,Ellipse)比 DirectX 或 OpenGL 更易理解坐标系与设备上下文(HDC)概念;而 Win32 消息循环(GetMessage/TranslateMessage/DispatchMessage)正是理解 Windows GUI 本质的“第一课”。
所以当你看到render.c里反复出现GetDC(hWnd)和ReleaseDC(hWnd, hdc),这不是冗余代码,而是刻意暴露 HDC 生命周期管理——这正是很多新手在切换到 GDI+ 或 Direct2D 前必须亲手踩过的坑。
2.3 核心数据结构:棋盘状态如何用二维数组表达
整个游戏逻辑围绕一个int board[10][9]展开,每个元素存储整数编码的棋子:
// board.c 中定义 #define EMPTY 0 #define RED_KING 1 // 红帅 #define RED_ADVISOR 2 // 红士 #define RED_ELEPHANT 3 // 红象 #define RED_HORSE 4 // 红马 #define RED_CHARIOT 5 // 红车 #define RED_CANNON 6 // 红炮 #define RED_PAWN 7 // 红兵 #define BLACK_KING -1 // 黑将 #define BLACK_ADVISOR -2 // 黑士 // ... 同理,黑方为负值注意:这种正负号区分红黑的设计,让
IsLegalMove()函数能用signbit(board[x][y])快速判断归属方,避免冗长 if-else。更关键的是,它天然支持“吃子”逻辑——移动后直接赋值board[newX][newY] = board[oldX][oldY],旧位置清零,无需额外删除操作。
棋盘坐标系采用标准中国象棋约定:
- 行(y):0~9,0 为红方底线(帅所在行),9 为黑方底线(将所在行)
- 列(x):0~8,0 为左边界,8 为右边界
- 屏幕像素坐标系(客户区)与棋盘逻辑坐标系的转换,由
input.c中的ScreenToBoard()完成,其核心是:
// input.c void ScreenToBoard(POINT pt, int* row, int* col) { const int CELL_WIDTH = 40; // 每格宽高 40px(可配置) const int CELL_HEIGHT = 40; const int OFFSET_X = 20; // 左边距 20px(预留边框) const int OFFSET_Y = 20; // 上边距 20px *col = (pt.x - OFFSET_X) / CELL_WIDTH; *row = (pt.y - OFFSET_Y) / CELL_HEIGHT; }这个函数看似简单,却是整个交互的基础——如果OFFSET_X/Y设错,或CELL_*与render.c中实际绘制尺寸不一致,就会出现“点哪不响应哪”的玄学问题。我们后面避坑章节会重点讲这个。
3. 编译与运行:MinGW-w64 一键构建,绕过 Visual Studio 依赖
3.1 环境准备:只需两步,5 分钟搞定
第一步:安装 MinGW-w64(推荐)
下载 MinGW-w64 Online Installer ,选择:
- Architecture:
x86_64 - Threads:
posix - Exception:
seh - Version:
10.2.0(稳定兼容 Win7+)
安装路径建议纯英文无空格,例如C:\mingw64,并把C:\mingw64\bin加入系统 PATH。
第二步:验证 gcc 版本
打开 CMD,执行:
gcc --version # 应输出类似:gcc.exe (x86_64-posix-seh-rev1, Built by MinGW-W64 project) 10.2.0若报错“不是内部命令”,请重启 CMD 并确认 PATH 设置正确。
提示:不要用 MSVC 的
cl.exe编译此项目——虽然可行,但makefile中的链接参数(-luser32 -lgdi32)是 GCC 风格,MSVC 需改为/link user32.lib gdi32.lib,徒增配置成本。坚持 MinGW 是本项目“开箱即用”的设计前提。
3.2 编译命令详解:makefile 如何驱动整个流程
项目自带makefile,内容精简到 12 行,我们逐行拆解其作用:
# makefile CC = gcc CFLAGS = -Wall -Wextra -O2 -mwindows LDFLAGS = -luser32 -lgdi32 -lcomctl32 OBJS = chess.o board.o render.o input.o chess.exe: $(OBJS) $(CC) $(CFLAGS) -o $@ $(OBJS) $(LDFLAGS) %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f *.o chess.exe .PHONY: cleanCC = gcc:指定编译器为 GCCCFLAGS = -Wall -Wextra -O2 -mwindows:-Wall -Wextra开启全部警告(新手必开,避免隐式类型转换错误);-O2二级优化(不影响调试);-mwindows关键!告诉链接器生成 Windows GUI 程序(而非控制台),避免运行时弹出黑框。LDFLAGS = -luser32 -lgdi32 -lcomctl32:链接 Win32 核心库,comctl32用于后续可能扩展的公共控件(如状态栏)。OBJS = ...:列出所有需编译的目标文件,chess.o依赖chess.c,以此类推。
执行编译只需一条命令:
make成功后生成chess.exe,双击即可运行。
3.3 运行时行为与基础操作
启动后你会看到一个 400×440 像素的窗口(客户区),顶部是标准菜单栏(游戏→新游戏/退出,帮助→关于),中央是 10×9 棋盘网格,红黑双方棋子以红色/黑色汉字显示(“帥”“將”“車”“砲”等)。
基础操作逻辑:
- 单击选子:点击己方棋子,该棋子高亮(边框加粗),同时在其所有合法移动位置绘制半透明灰色圆点(由
DrawValidMoves()实现)。 - 单击走子:再点击一个带圆点的位置,执行移动;若点击非圆点位置,则取消选择。
- 新游戏:菜单→游戏→新游戏,重置棋盘为初始布局。
- 退出:菜单→游戏→退出,或 Alt+F4。
注意:该项目暂未实现“将死”判定与胜利提示,属于教学精简版。但
board.c中的IsKingInCheck()函数已预留接口,二次开发时可在此基础上扩展。
4. 避坑指南:新手编译与运行时最常遇到的 4 个血泪问题
4.1 现象:编译通过,但运行时窗口一闪而逝,或弹出“找不到 libgcc_s_seh-1.dll”
原因:MinGW-w64 默认动态链接运行时库(libgcc、libstdc++),而目标机器未安装对应 DLL。-mwindows参数虽隐藏控制台,但未解决 DLL 依赖。
解决:修改makefile中的CFLAGS,添加静态链接标志:
CFLAGS = -Wall -Wextra -O2 -mwindows -static-libgcc -static-libstdc++重新make clean && make。此时生成的chess.exe体积增大(约 1.2MB),但彻底摆脱 DLL 依赖,可在任意 Win7+ 机器运行。
4.2 现象:棋盘绘制错位,网格线偏移,或点击位置与高亮格完全不匹配
原因:input.c中的ScreenToBoard()坐标转换参数(OFFSET_X/Y,CELL_WIDTH/HEIGHT)与render.c中DrawBoard()的实际绘制尺寸不一致。
解决:统一检查两处代码。render.c中DrawBoard()的关键绘制代码如下:
// render.c void DrawBoard(HDC hdc) { const int CELL_W = 40, CELL_H = 40; const int OFFSET_X = 20, OFFSET_Y = 20; // 绘制 10 行 × 9 列网格线... for (int i = 0; i <= 10; i++) { // 横线 11 条 MoveToEx(hdc, OFFSET_X, OFFSET_Y + i * CELL_H, NULL); LineTo(hdc, OFFSET_X + 9 * CELL_W, OFFSET_Y + i * CELL_H); } // ... 纵线同理 }确保input.c中ScreenToBoard()的CELL_*和OFFSET_*与之完全相同。这是 Win32 GDI 开发中最经典的“坐标不同步”翻车点,务必手动核对,不要相信注释。
4.3 现象:点击棋子无反应,WndProc中WM_LBUTTONDOWN消息未被捕获
原因:窗口注册时未正确设置CS_HREDRAW | CS_VREDRAW风格,或CreateWindowEx的dwStyle参数遗漏WS_VISIBLE。
解决:检查chess.c中WinMain里的窗口创建代码:
// chess.c hwnd = CreateWindowEx( 0, // dwExStyle szWindowClass, // lpClassName L"Windows SDK 象棋", // lpWindowName WS_OVERLAPPEDWINDOW | WS_VISIBLE, // dwStyle ← 必须含 WS_VISIBLE! CW_USEDEFAULT, CW_USEDEFAULT, // x, y 480, 520, // width, height(含边框) NULL, NULL, hInstance, NULL);同时确认RegisterClassEx中wcex.style = CS_HREDRAW | CS_VREDRAW;已设置。缺少CS_HREDRAW/VREDRAW会导致窗口缩放时无法自动重绘,间接影响鼠标捕获。
4.4 现象:中文棋子显示为方块(□)或乱码
原因:DrawPiece()中TextOut使用的字体不支持 GB2312 或 Unicode,或未正确设置字符集。
解决:render.c中DrawPiece()的字体创建必须指定GB2312字符集:
// render.c HFONT hFont = CreateFont( 24, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE, GB2312_CHARSET, // ← 关键!必须是 GB2312_CHARSET OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, L"Microsoft YaHei");若系统无微软雅黑,可降级为L"SimSun"(宋体)。切勿使用ANSI_CHARSET,否则中文必然乱码。
5. 核心模块代码深度解析:从鼠标点击到棋子重绘的完整链路
5.1input.c:鼠标事件如何被翻译成棋盘坐标
input.c是整个交互的入口,其OnLButtonDown函数是第一个被触发的环节:
// input.c void OnLButtonDown(HWND hWnd, int x, int y, UINT keyFlags) { POINT pt = {x, y}; int row, col; ScreenToBoard(pt, &row, &col); // ← 第一步:像素→逻辑坐标 // 边界检查:确保点击在 0~9 行、0~8 列内 if (row < 0 || row > 9 || col < 0 || col > 8) return; int piece = g_board[row][col]; // ← 第二步:查棋盘状态 if (piece == EMPTY) { // 点击空格:尝试走子 if (g_selectedRow != -1 && g_selectedCol != -1) { if (IsLegalMove(g_selectedRow, g_selectedCol, row, col)) { MakeMove(g_selectedRow, g_selectedCol, row, col); g_selectedRow = g_selectedCol = -1; // 重置选择 InvalidateRect(hWnd, NULL, TRUE); // ← 第三步:触发重绘 } } } else { // 点击棋子:尝试选子 if (IsOwnPiece(piece, g_turn)) { // g_turn 为 1=红方回合,-1=黑方 g_selectedRow = row; g_selectedCol = col; InvalidateRect(hWnd, NULL, TRUE); } } }逻辑说明:
ScreenToBoard()是坐标翻译的唯一入口,其精度决定交互体验。g_selectedRow/Col是全局状态变量,记录当前选中的棋子位置(-1 表示未选)。IsOwnPiece()通过piece * g_turn > 0判断归属(红方g_turn=1,黑方g_turn=-1),简洁高效。InvalidateRect(..., TRUE)是关键:它向系统发送WM_PAINT消息,强制重绘整个客户区,从而触发render.c的绘制逻辑。
5.2board.c:走法合法性校验的实现细节
IsLegalMove()是规则引擎的核心,以“马走日”为例,展示其如何处理“蹩马腿”:
// board.c bool IsLegalMove(int fromRow, int fromCol, int toRow, int toCol) { int piece = g_board[fromRow][fromCol]; if (piece == EMPTY) return false; int dRow = toRow - fromRow; int dCol = toCol - fromCol; // 红马(5)与黑马(-5)统一处理 if (abs(piece) == RED_HORSE) { // 检查是否为“日”字:(dRow,dCol) ∈ {(±1,±2), (±2,±1)} if (!((abs(dRow)==1 && abs(dCol)==2) || (abs(dRow)==2 && abs(dCol)==1))) return false; // 蹩马腿检查:马脚位置必须为空 int legRow = fromRow + dRow/2; // 整数除法,如 (2,1)→(1,0.5)→(1,0) int legCol = fromCol + dCol/2; if (g_board[legRow][legCol] != EMPTY) return false; // 目标位置:不能吃己方棋子 int targetPiece = g_board[toRow][toCol]; if (targetPiece != EMPTY && ((piece > 0) == (targetPiece > 0))) return false; return true; } // 其他棋子(车、炮、士等)的规则分支... return false; }参数说明:
fromRow/Col:起始坐标(已知选中棋子位置)toRow/Col:目标坐标(鼠标点击位置转换而来)dRow/dCol:行列差值,用于快速匹配“日”“田”“一”等模式legRow/legCol:马腿坐标,利用整数除法自动取中(如从 (0,0) 走到 (2,1),马腿为 (1,0))
注意:此实现未处理“马被拴住”(马脚被己方棋子挡住但目标格是敌方)的边界情况,属于教学简化。真实项目需增加
IsOwnPiece(g_board[legRow][legCol], piece > 0)判断。
5.3render.c:GDI 绘图如何分层渲染棋盘、棋子与提示点
render.c的OnPaint函数是重绘总调度:
// render.c void OnPaint(HWND hWnd) { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 1. 绘制背景(棋盘网格 + 楚河汉界) DrawBoard(hdc); // 2. 绘制所有棋子 DrawAllPieces(hdc); // 3. 若有选中棋子,绘制其合法移动点 if (g_selectedRow != -1 && g_selectedCol != -1) { DrawValidMoves(hdc, g_selectedRow, g_selectedCol); } EndPaint(hWnd, &ps); }其中DrawValidMoves()的实现体现了 GDI 的“半透明”技巧(虽无 Alpha 混合,但用灰度模拟):
void DrawValidMoves(HDC hdc, int row, int col) { const int CELL_W = 40, CELL_H = 40; const int OFFSET_X = 20, OFFSET_Y = 20; HBRUSH hBrush = CreateSolidBrush(RGB(180, 180, 180)); // 浅灰色刷子 HPEN hPen = CreatePen(PS_SOLID, 1, RGB(120, 120, 120)); // 灰色边框 for (int r = 0; r < 10; r++) { for (int c = 0; c < 9; c++) { if (IsLegalMove(row, col, r, c)) { int x = OFFSET_X + c * CELL_W + CELL_W/2 - 5; int y = OFFSET_Y + r * CELL_H + CELL_H/2 - 5; // 绘制 10×10 像素圆点 SelectObject(hdc, hBrush); SelectObject(hdc, hPen); Ellipse(hdc, x, y, x+10, y+10); } } } DeleteObject(hBrush); DeleteObject(hPen); }关键点:
Ellipse绘制圆点,尺寸固定为 10×10 像素,居中于格子中心。RGB(180,180,180)的浅灰与棋盘底色(白色)形成柔和对比,既提示又不刺眼。- 每次调用都新建/删除 GDI 对象,避免资源泄漏——这是 Win32 GDI 编程的铁律。
6. 二次开发实战:给原生象棋加上“悔棋”功能与自定义棋子样式
6.1 悔棋功能:用栈结构保存历史步数
原项目无悔棋,但board.c中的MakeMove()已是原子操作,扩展成本极低。我们只需增加一个移动历史栈:
第一步:定义历史结构体与栈
// board.h(新增头文件) typedef struct { int fromRow, fromCol; int toRow, toCol; int captured; // 被吃的棋子(用于还原) } MoveRecord; #define MAX_UNDO 50 extern MoveRecord g_moveHistory[MAX_UNDO]; extern int g_undoTop; void PushMove(int fromRow, int fromCol, int toRow, int toCol, int captured); MoveRecord PopMove(); bool CanUndo();第二步:修改MakeMove(),记录每一步
// board.c int g_undoTop = -1; MoveRecord g_moveHistory[MAX_UNDO]; void MakeMove(int fromRow, int fromCol, int toRow, int toCol) { int captured = g_board[toRow][toCol]; // 执行移动... g_board[toRow][toCol] = g_board[fromRow][fromCol]; g_board[fromRow][fromCol] = EMPTY; // 记录到栈 PushMove(fromRow, fromCol, toRow, toCol, captured); } void PushMove(int fromRow, int fromCol, int toRow, int toCol, int captured) { if (g_undoTop < MAX_UNDO - 1) { g_undoTop++; g_moveHistory[g_undoTop] = (MoveRecord){ fromRow, fromCol, toRow, toCol, captured }; } }第三步:实现UndoMove()并绑定菜单
// board.c bool UndoMove() { if (!CanUndo()) return false; MoveRecord last = PopMove(); // 还原:目标格变回原棋子,起始格恢复被吃棋子(或清空) g_board[last.fromRow][last.fromCol] = g_board[last.toRow][last.toCol]; g_board[last.toRow][last.toCol] = last.captured; return true; } MoveRecord PopMove() { return g_moveHistory[g_undoTop--]; } bool CanUndo() { return g_undoTop >= 0; }第四步:在chess.c的WndProc中响应菜单命令
// chess.c case WM_COMMAND: switch (LOWORD(wParam)) { case IDM_UNDO: // 新增菜单项 ID if (UndoMove()) { InvalidateRect(hWnd, NULL, TRUE); // 可选:播放音效或弹窗提示 } break; // ... 其他 case } break;提示:
IDM_UNDO需在resource.h中定义,并在chess.rc的菜单资源中添加。这是 Win32 菜单编程的标准流程,一次配好,终身受用。
6.2 自定义棋子样式:从汉字切换为 PNG 图片
原项目用TextOut绘制汉字,要换 PNG,需引入LoadImage与TransparentBlt:
第一步:加载图片资源
// render.c HBITMAP g_hbmRedKing, g_hbmBlackKing; // 全局位图句柄 void LoadPieceImages() { g_hbmRedKing = (HBITMAP)LoadImage( NULL, L"red_king.png", IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE | LR_CREATEDIBSECTION); g_hbmBlackKing = (HBITMAP)LoadImage( NULL, L"black_king.png", IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE | LR_CREATEDIBSECTION); }第二步:修改DrawPiece(),用TransparentBlt替代TextOut
void DrawPiece(HDC hdc, int row, int col, int piece) { const int CELL_W = 40, CELL_H = 40; const int OFFSET_X = 20, OFFSET_Y = 20; int x = OFFSET_X + col * CELL_W + 5; // 左边距 5px int y = OFFSET_Y + row * CELL_H + 5; // 上边距 5px HBITMAP hbm = NULL; switch (abs(piece)) { case RED_KING: hbm = (piece > 0) ? g_hbmRedKing : g_hbmBlackKing; break; // ... 其他棋子 } if (hbm) { HDC memDC = CreateCompatibleDC(hdc); HBITMAP hOld = (HBITMAP)SelectObject(memDC, hbm); TransparentBlt(hdc, x, y, 30, 30, memDC, 0, 0, 30, 30, RGB(255,0,255)); SelectObject(memDC, hOld); DeleteDC(memDC); } }关键参数说明:
TransparentBlt最后一个参数RGB(255,0,255)是 Magenta(品红),作为透明色键。PNG 图片需用此色填充背景,才能实现透明效果。30×30是图片实际尺寸,需与棋盘格留白(x+5,y+5)匹配,避免溢出。
从那以后我每次做 Win32 图形项目,都会先写一个LoadPieceImages()和DrawPiece()的 stub,哪怕暂时用TextOut占位——因为图片替换只是改两行代码的事,而架构提前对齐,能省下后期重构的八成时间。希望帮到你。
本文还有配套的精品资源,点击获取