本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:本文介绍如何使用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; // 加个立体边框

这样出来的窗口既有边界感又不失简洁,适合游戏场景。

📏 固定尺寸怎么做?不让用户拉伸!

玩家要是把窗口拖变形,棋盘比例就毁了。怎么锁死大小?

有两种方式:

  1. 创建时指定固定风格 (如去掉 WS_THICKFRAME
  2. 拦截消息动态限制

第二种更灵活。我们在 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) ,结果点了关闭按钮没反应。为啥?

因为正确流程是这样的:

  1. 用户点 × → 系统发 WM_CLOSE
  2. 你在 WM_CLOSE 中弹确认框
  3. 用户点“是” → 调 DestroyWindow(hwnd)
  4. 系统发 WM_DESTROY
  5. 在这里调 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要这样设计?每一步背后都有它的道理

下次当你看到某个软件界面卡顿、闪烁严重,你就知道问题出在哪了。而这,正是掌握底层技术的魅力所在。🌟

现在,要不要试着给你的跳棋加上音效、网络对战,甚至做个漂亮的粒子特效?世界已在你手中~🎮

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:本文介绍如何使用C/C++结合Windows API实现一个完整的跳棋游戏。通过调用Windows API,开发者可以创建图形界面、处理用户输入并绘制棋盘与棋子,从而构建一个交互式桌面应用。项目涵盖窗口类注册、窗口创建、消息循环、GDI绘图、事件处理和游戏逻辑实现等核心技术,帮助深入理解Windows平台下的GUI编程机制。该跳棋游戏实现了合法移动判断、吃子连跳、游戏状态管理和资源释放等功能,具备良好的结构设计与可维护性,适合用于学习Windows API编程与游戏开发基础。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐