基于C/C++与Windows API开发的跳棋游戏实战项目
简介:本文介绍如何使用C/C++结合Windows API实现一个完整的跳棋游戏。通过调用Windows API,开发者可以创建图形界面、处理用户输入并绘制棋盘与棋子,从而构建一个交互式桌面应用。项目涵盖窗口类注册、窗口创建、消息循环、GDI绘图、事件处理和游戏逻辑实现等核心技术,帮助深入理解Windows平台下的GUI编程机制。该跳棋游戏实现了合法移动判断、吃子连跳、游戏状态管理和资源释放等功能,具备良好的结构设计与可维护性,适合用于学习Windows API编程与游戏开发基础。
Windows API与GDI实战:从零构建跳棋游戏界面
哎呀,你有没有遇到过这样的情况——明明代码逻辑都对了,但窗口一动就闪得像老式电视机?😅 我第一次写Win32程序时,简直怀疑人生。后来才明白,这背后其实是Windows那套“消息驱动+设备上下文”的设计哲学在作祟。今天咱们不整那些干巴巴的理论,直接上手做个 完整的跳棋游戏 ,边做边聊清楚: 为什么 WndProc 这么重要?GDI绘图怎么才能不闪?双缓冲到底是啥玩意儿?
准备好了吗?来吧!🚀
说实话,现在谁还用纯Win32 API写应用啊?前端框架、Qt、WPF满天飞……但你知道吗?当你打开任务管理器,看到某个进程CPU占用突然飙高,然后UI卡成PPT——这时候你就得知道底层发生了什么。而这一切,都得从一个叫 WinMain 的函数说起。
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd) {
// ……我们马上就要填满它了
return 0;
}
别看这个函数长得平平无奇,它是整个Windows原生程序的起点。就像你家大门钥匙,没它进不去。而且注意哦,它的参数不是随便定义的,是操作系统启动你的程序时 主动传进来 的。比如 hInstance 就是你这个可执行文件在内存中的句柄,后面加载图标、菜单都要靠它定位资源。
那接下来第一步该干啥?注册一个“窗口类”(Window Class)。等等,你说啥?类?这不是C语言吗?
没错!虽然C没有面向对象语法,但Windows的设计思想早早就用了“类-实例”模式。你可以把 窗口类 想象成一个模具,比如“标准对话框模具”、“主窗口模具”。一旦注册成功,你就能拿着这个模具批量生产窗口实例。
🧱 窗口类不是对象,而是模板
来看关键结构体: WNDCLASSEX 。它长得有点吓人:
typedef struct tagWNDCLASSEX {
UINT cbSize;
UINT style;
WNDPROC lpfnWndProc;
int cbClsExtra;
int cbWndExtra;
HINSTANCE hInstance;
HICON hIcon;
HCURSOR hCursor;
HBRUSH hbrBackground;
LPCWSTR lpszMenuName;
LPCWSTR lpszClassName;
HICON hIconSm;
} WNDCLASSEX;
一个个字段解释反而容易晕,咱挑几个最核心的说:
🔧 lpfnWndProc :每个窗口的大脑中枢
这是个函数指针,指向你的“窗口过程函数”,也就是所有消息的终点站。想象一下,鼠标点了、键盘按了、窗口要重绘了……这些事件不会自动处理,全得通过这个函数逐一派发。
LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
记住这个名字,后面我们会反复见到它。可以说, 写Win32程序就是在写 WndProc 。
🎨 hbrBackground :背景刷子,别乱设!
很多人喜欢这样写:
wc.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH); // ❌ 危险!
听着挺合理,白色背景嘛。但问题来了: GetStockObject 在现代系统中已被弃用,尤其在高DPI或多显示器环境下可能出错。官方推荐做法是:
wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); // ✅ 正确姿势
那个 +1 不是笔误!因为系统颜色是从1开始索引的宏偏移。如果你不信邪非要用旧方法,恭喜你,将来调试闪烁问题的时候有得忙了 😵💫
💡 图标和光标:小细节决定专业感
你想让你的游戏看起来像个玩具还是正式产品?差的就是这几个资源。
wc.hIcon = LoadIcon(hInstance, MAKEINTRESOURCE(IDI_CHECKERS));
wc.hIconSm = (HICON)LoadImage(hInstance, IDI_CHECKERS, IMAGE_ICON,
GetSystemMetrics(SM_CXSMICON),
GetSystemMetrics(SM_CYSMICON), LR_DEFAULTCOLOR);
注意到没?大图标和小图标最好分开加载,尤其是小图标,用 LoadImage 可以确保不同分辨率下清晰显示。至于 IDI_CHECKERS ,那是你在 .rc 资源脚本里定义的ID:
IDI_CHECKERS ICON "res/checkers.ico"
别偷懒直接用 IDI_APPLICATION ,用户一眼就能看出你是“练手项目”。
classDiagram
class WNDCLASSEX {
+UINT cbSize
+UINT style
+WNDPROC lpfnWndProc
+int cbClsExtra
+int cbWndExtra
+HINSTANCE hInstance
+HICON hIcon
+HCURSOR hCursor
+HBRUSH hbrBackground
+LPCWSTR lpszMenuName
+LPCWSTR lpszClassName
+HICON hIconSm
}
note right of WNDCLASSEX
这不是一个运行时对象,
而是一个描述“如何创建窗口”的元数据模板。
注册后可用于创建多个具有一致行为的窗口。
end note
搞定这个结构体之后,调用 RegisterClassEx(&wc) 完成注册。失败怎么办?别急着崩溃,先调 GetLastError() 查原因。最常见的就是 cbSize 没设对,或者 hInstance 传错了。
⚠️ 提示:如果在同一进程中重复注册同名类,会失败。所以记得给你的类起个唯一的名字,比如
L"CheckersGame_V1"。
好了,模具造好了,下一步就是用它造出真正的窗口——也就是那个我们可以看见、能点击的跳棋主界面。
🛠 创建窗口:CreateWindowEx才是真·主角
你以为 CreateWindow 是主要函数?其实人家只是个包装器,真正干活的是 CreateWindowEx :
HWND hwnd = CreateWindowEx(
WS_EX_APPWINDOW,
L"CheckersGameClass",
L"中国跳棋",
WS_POPUP | WS_VISIBLE,
x, y, w, h,
NULL, NULL, hInstance, NULL
);
参数太多记不住?没关系,抓住几个关键点就行:
🖼 样式组合的艺术:想要沉浸式体验?
默认的 WS_OVERLAPPEDWINDOW 带标题栏、最大化按钮……但对于游戏来说太碍眼了。我们更想要一个干净的画面。
DWORD dwStyle = WS_POPUP | WS_VISIBLE | WS_CLIPCHILDREN | WS_CLIPSIBLINGS;
WS_POPUP:弹出式窗口,无边框WS_CLIPCHILDREN和WS_CLIPSIBLINGS:防止子控件绘制重叠,提升GDI效率(虽然我们现在还没子控件)
再加个扩展样式让边缘更好看:
DWORD dwExStyle = WS_EX_CLIENTEDGE; // 加个立体边框
这样出来的窗口既有边界感又不失简洁,适合游戏场景。
📏 固定尺寸怎么做?不让用户拉伸!
玩家要是把窗口拖变形,棋盘比例就毁了。怎么锁死大小?
有两种方式:
- 创建时指定固定风格 (如去掉
WS_THICKFRAME) - 拦截消息动态限制
第二种更灵活。我们在 WndProc 里捕获 WM_GETMINMAXINFO :
case WM_GETMINMAXINFO: {
LPMINMAXINFO lpMMI = (LPMINMAXINFO)lParam;
lpMMI->ptMinTrackSize.x = lpMMI->ptMaxTrackSize.x = 640;
lpMMI->ptMinTrackSize.y = lpMMI->ptMaxTrackSize.y = 640;
break;
}
这样一来,无论用户怎么拖,窗口永远保持640×640像素。完美适配8×8棋盘。
📍 居中显示的小技巧
别让用户自己找窗口在哪,咱贴心一点,让它一启动就居中屏幕:
int screenW = GetSystemMetrics(SM_CXSCREEN);
int screenH = GetSystemMetrics(SM_CYSCREEN);
int x = (screenW - 640) / 2;
int y = (screenH - 640) / 2;
多显示器环境下也稳得很。别用 CW_USEDEFAULT ,那个有时候会跑到副屏去……
窗口建好了,但它还是藏在幕后。想让它出现在屏幕上,还得两个兄弟出手: ShowWindow 和 UpdateWindow 。
👁 显示与更新:顺序不能错!
很多新手以为只要 CreateWindowEx 成功,窗口就出来了。错!此时它还在“隐藏状态”。
ShowWindow(hwnd, nShowCmd); // 设置为可见
UpdateWindow(hwnd); // 强制触发首次绘制
这两个函数分工明确:
ShowWindow:只是打个标记,“我要显示”UpdateWindow:立刻发送WM_PAINT,逼系统马上画出来
如果你只调了 ShowWindow 而忘了 UpdateWindow ,可能会发现窗口出现了,但是白的,或者内容乱七八糟。这就是因为没人通知它去重绘。
flowchart LR
Reg[注册WNDCLASSEX] --> Create[CreateWindowEx]
Create --> Show[ShowWindow]
Show --> Update[UpdateWindow]
Update --> Loop[进入消息循环]
到这里为止,我们的空窗口已经可以正常弹出来了。🎉 下一步才是真正的大戏——让它动起来。
消息循环:Windows的灵魂所在
敲黑板⚠️: Windows不是你写的代码在跑,而是系统在等你的消息循环去“取包裹” 。
什么意思?简单说,操作系统把所有的输入事件(鼠标、键盘)打包放进一个队列,你的程序需要不断去检查有没有新消息,拿到后再分发处理。
标准写法长这样:
MSG msg = {};
while (GetMessage(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
三连击:拿消息 → 翻译 → 分发。这三个API缺一不可。
🔄 GetMessage vs PeekMessage:阻塞与非阻塞的选择
上面这段代码有个致命问题:它是 阻塞式 的。当队列为空时, GetMessage 会让线程挂起,直到有新消息到来。
这对普通软件没问题,比如记事本。但游戏不一样啊!我们需要持续刷新画面,哪怕没人操作也要播放动画、检测AI思考进度。
怎么办?换 PeekMessage !
while (gameRunning) {
if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
} else {
Game_Update(); // 没消息?继续跑逻辑
Game_Render(); // 继续渲染
}
}
看懂了吗?这才是游戏主循环的正确打开方式。 PeekMessage 是“偷偷看一眼”,有消息就拿走,没消息也不停机。
| 对比项 | GetMessage | PeekMessage |
|---|---|---|
| 是否阻塞 | 是 | 否 |
| 适用场景 | 普通桌面应用 | 实时游戏、多媒体 |
| CPU占用 | 低(空闲时休眠) | 较高(轮询) |
所以结论是:
👉 做工具软件?用 GetMessage ,省电又稳定。
👉 做游戏?必须 PeekMessage ,不然动画全卡住。
🧠 WndProc:一切消息的终点站
前面说了, WndProc 是窗口的大脑。所有消息最终都会流到这里:
LRESULT CALLBACK WndProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
我们来重点看看几个关键生命周期消息该怎么处理。
🟢 WM_CREATE :初始化的好地方
这个消息在窗口刚创建时发一次,非常适合放一些初始化代码:
case WM_CREATE:
OutputDebugString(L"【调试】窗口创建成功\n");
InitGameBoard(); // 初始化棋盘数组
LoadGameResources(); // 加载图片、字体等
return 0;
注意返回值!对于已处理的消息,通常返回 0 ;未处理的交给 DefWindowProc 处理。
🔴 WM_DESTROY 与 WM_CLOSE :退出流程别搞混!
很多人直接在 WM_DESTROY 里调 PostQuitMessage(0) ,结果点了关闭按钮没反应。为啥?
因为正确流程是这样的:
- 用户点 × → 系统发
WM_CLOSE - 你在
WM_CLOSE中弹确认框 - 用户点“是” → 调
DestroyWindow(hwnd) - 系统发
WM_DESTROY - 在这里调
PostQuitMessage(0)结束消息循环
错误示范 ❌:
case WM_CLOSE:
PostQuitMessage(0); // 直接退出?连问都不问!
break;
正确做法 ✅:
case WM_CLOSE:
if (MessageBox(hwnd, L"真的要退出吗?", L"确认", MB_YESNO) == IDYES) {
DestroyWindow(hwnd); // 触发 WM_DESTROY
}
return 0;
case WM_DESTROY:
CleanupResources(); // 释放GDI对象、关闭文件等
PostQuitMessage(0);
return 0;
一个小细节,用户体验差一大截。
鼠标交互:让棋子跟着鼠标走
好了,现在窗口有了,消息循环跑了,接下来最激动人心的部分来了—— 实现棋子拖拽 !
🖱 拖拽三部曲:按下 → 移动 → 抬起
第一步:按下选中棋子
case WM_LBUTTONDOWN:
{
int x = GET_X_LPARAM(lParam);
int y = GET_Y_LPARAM(lParam);
// 转为客户区坐标
POINT pt = {x, y};
ScreenToClient(hwnd, &pt);
// 计算落在哪个格子
int col = pt.x / CELL_SIZE;
int row = pt.y / CELL_SIZE;
if (IsValidCell(row, col) && IsOurPiece(row, col)) {
g_dragging = TRUE;
g_selRow = row;
g_selCol = col;
SetCapture(hwnd); // 关键!锁定鼠标输入
}
return 0;
}
重点来了: SetCapture(hwnd) 。这意味着即使鼠标移到窗口外面,所有 WM_MOUSEMOVE 和 WM_LBUTTONUP 仍然会发给这个窗口。否则你一移出去,拖拽就断了,体验极差。
第二步:移动时实时更新位置
case WM_MOUSEMOVE:
{
if (g_dragging) {
g_mouseX = GET_X_LPARAM(lParam);
g_mouseY = GET_Y_LPARAM(lParam);
// 只刷新局部区域,避免全局重绘导致闪烁
RECT updateRect;
SetRect(&updateRect,
g_mouseX - 30, g_mouseY - 30,
g_mouseX + 30, g_mouseY + 30);
InvalidateRect(hwnd, &updateRect, FALSE);
}
return 0;
}
这里用了个小技巧: InvalidateRect 传了个矩形,表示只需要重绘鼠标周围的区域。加上最后一个参数 FALSE ,告诉系统不要擦背景,减少闪烁。
第三步:抬起时判断落点
case WM_LBUTTONUP:
{
if (g_dragging) {
POINT pt = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
ScreenToClient(hwnd, &pt);
int toCol = pt.x / CELL_SIZE;
int toRow = pt.y / CELL_SIZE;
if (IsValidMove(g_selRow, g_selCol, toRow, toCol)) {
ExecuteMove(g_selRow, g_selCol, toRow, toCol);
SwitchPlayer();
}
g_dragging = FALSE;
ReleaseCapture(); // 必须释放!否则其他窗口收不到鼠标消息
InvalidateRect(hwnd, NULL, TRUE); // 重绘整个客户区
}
return 0;
}
别忘了最后调 ReleaseCapture() ,否则整个系统的鼠标都归你管了,别的程序就废了 😂
GDI绘图:怎样画出一个不闪的棋盘?
终于到了视觉部分。你是不是也有过这种经历:每次重绘,整个窗口“唰”地一下变白,然后再慢慢画出来?这就是传说中的 闪烁问题 。
罪魁祸首是单缓冲绘图。解决办法只有一个: 双缓冲 。
🔄 双缓冲原理一句话讲清:
先在内存里画好一整帧画面,然后一口气拷贝到屏幕上,用户始终看到完整画面,看不到中间过程。
具体怎么实现?
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdcScreen = BeginPaint(hwnd, &ps);
HDC hdcMem = CreateCompatibleDC(hdcScreen);
// 创建与屏幕兼容的位图
RECT rcClient;
GetClientRect(hwnd, &rcClient);
HBITMAP hBitmap = CreateCompatibleBitmap(hdcScreen,
rcClient.right,
rcClient.bottom);
SelectObject(hdcMem, hBitmap);
// 清背景
FillRect(hdcMem, &rcClient, (HBRUSH)(COLOR_WINDOW + 1));
// 绘制棋盘和所有棋子
DrawCheckerboard(hdcMem);
DrawAllPieces(hdcMem);
// 一次性复制到位
BitBlt(hdcScreen, 0, 0, rcClient.right, rcClient.bottom,
hdcMem, 0, 0, SRCCOPY);
// 清理资源
DeleteObject(hBitmap);
DeleteDC(hdcMem);
EndPaint(hwnd, &ps);
}
虽然代码多了几行,但换来的是丝滑般的体验。再也不闪了!
顺带提一句:每次重绘都 Create/Delete 位图有点浪费。优化方案是把它做成全局变量,在窗口创建时分配,销毁时释放。不过对于小游戏,影响不大。
跳棋规则引擎:不只是画画那么简单
图形只是皮囊,真正的灵魂是规则。
🤖 吃子与连跳:递归才是王道
跳棋最特别的地方就是“连跳”——吃完一个还能接着吃。这就必须用递归来模拟所有可能路径。
Direction dirs[4] = {{-1,-1}, {-1,1}, {1,-1}, {1,1}}; // 四个对角方向
BOOL CanJump(int r, int c, int dir, int* outR, int* outC) {
int midR = r + dirs[dir].dr;
int midC = c + dirs[dir].dc;
int newR = midR + dirs[dir].dr;
int newC = midC + dirs[dir].dc;
if (InRange(newR, newC) && Board[midR][midC] != 0 &&
IsEnemy(Board[midR][midC]) && Board[newR][newC] == 0) {
*outR = newR; *outC = newC;
return TRUE;
}
return FALSE;
}
连跳检测可以用回溯法:
int FindMaxJumps(int r, int c, MovePath* path) {
int maxJumps = 0;
for (int i = 0; i < 4; i++) {
int nr, nc;
if (CanJump(r, c, i, &nr, &nc)) {
SimulateJump(r, c, nr, nc); // 执行跳跃
path->steps[path->count++] = (Step){nr, nc};
int jumps = 1 + FindMaxJumps(nr, nc, path);
maxJumps = max(maxJumps, jumps);
UndoJump(r, c, nr, nc); // 回溯
path->count--;
}
}
return maxJumps;
}
这样就能找出最长连跳路径啦!
👑 王棋机制:升变与双向移动
当兵走到对面底线,就变成“王棋”,可以在四个方向自由行动。
我们用数值编码区分类型:
| 类型 | 编码 |
|---|---|
| 红兵 | 1 |
| 黑兵 | 2 |
| 红王 | 3 |
| 黑王 | 4 |
判断是否升变:
if ((color == RED && row == 0) || (color == BLACK && row == 7)) {
Board[row][col] += 2; // 升变为王
}
方向控制自然支持:红兵只能用前两个方向,王棋四个都能用。
多线程优化:别让AI卡住UI
如果我们要加AI对手,搜索算法一跑起来,界面直接卡住怎么办?
答案: 新开线程跑AI逻辑,用 PostMessage 通知主线程更新 。
DWORD WINAPI AIThread(LPVOID param) {
HWND hwnd = (HWND)param;
Move bestMove = ThinkBestMove();
// 不能直接绘图!跨线程访问UI危险!
PostMessage(hwnd, WM_USER + 101, 0, (LPARAM)&bestMove);
return 0;
}
// 主线程接收
case WM_USER + 101:
ExecuteAIMove((Move*)lParam);
InvalidateRect(hwnd, NULL, FALSE);
break;
安全又高效,UI流畅如初。
stateDiagram-v2
[*] --> GAME_RUNNING
GAME_RUNNING --> GAME_PAUSED : 用户按P键
GAME_PAUSED --> GAME_RUNNING : 再次按键
GAME_RUNNING --> GAME_RED_WIN : 黑方无子或无法行动
GAME_RUNNING --> GAME_BLACK_WIN : 红方无子或无法行动
GAME_RED_WIN --> [*]
GAME_BLACK_WIN --> [*]
你看,从一个空白的 WinMain ,到现在拥有完整交互逻辑的跳棋游戏,我们经历了:
- 窗口类注册 → 创建窗口 → 消息循环驱动
- GDI双缓冲绘图 → 消除闪烁
- 鼠标拖拽反馈 → 提升操作感
- 规则引擎实现 → 支持连跳、升变
- 多线程AI → 保证UI响应性
这一整套流程下来,你不仅学会了Win32编程的核心范式,更重要的是理解了: 为什么Windows要这样设计?每一步背后都有它的道理 。
下次当你看到某个软件界面卡顿、闪烁严重,你就知道问题出在哪了。而这,正是掌握底层技术的魅力所在。🌟
现在,要不要试着给你的跳棋加上音效、网络对战,甚至做个漂亮的粒子特效?世界已在你手中~🎮
简介:本文介绍如何使用C/C++结合Windows API实现一个完整的跳棋游戏。通过调用Windows API,开发者可以创建图形界面、处理用户输入并绘制棋盘与棋子,从而构建一个交互式桌面应用。项目涵盖窗口类注册、窗口创建、消息循环、GDI绘图、事件处理和游戏逻辑实现等核心技术,帮助深入理解Windows平台下的GUI编程机制。该跳棋游戏实现了合法移动判断、吃子连跳、游戏状态管理和资源释放等功能,具备良好的结构设计与可维护性,适合用于学习Windows API编程与游戏开发基础。
更多推荐


所有评论(0)