🔷 第十六章 坦克动荡游戏
📄 一句话概括

本节给空壳工程装上心脏: ① 主窗口 + 消息循环(16.4.1);② 游戏主循环与 CGame 类(16.4.2); ③ 鼠标响应(16.4.3);④ 把游戏核心接进窗口(16.4.4)。 最关键的一句:把"等消息"的 GetMessage 换成"看一眼就走"的 PeekMessage —— 这样程序才有空去跑游戏逻辑,否则它只会傻等。

📝 本节和前面几节的关系

16.3 建好了工程和类骨架,本节让它们真的动起来。 - 和 16.2「游戏主循环」的关系:那节画的"输入 → 更新 → 绘制"循环,本节把它落成真正的 while 循环; - 和 5.1「while 循环」的关系:这就是一个 while,只不过条件永远是"游戏还在跑"; - 和 13.3.3 的关系:绘制是每帧重画整个画面(没有"只擦掉子弹"这种好事), 所以"擦除上一次"最简单的方式是每帧把整块背景重画一遍。 一句话:本节之后,屏幕上的东西才第一次"活"过来。

一、建立游戏主窗口(16.4.1)

一个窗口程序的骨架,就四步

① 注册窗口类(RegisterClass)  → 告诉系统"我要一个这样的窗口"
② 创建窗口(CreateWindow)      → 真的要一个窗口出来
③ 显示窗口(ShowWindow)        → 让它出现在屏幕上
④ 消息循环(while + 分派消息)  → 不停地处理系统送来的消息

前 3 步 VS 模板都帮你写好了,本节真正要动的是第 4 步。

第 4 步的两种写法

// ── 写法 A:普通窗口程序(VS 模板给的就是这个)──────────
MSG msg;
while (GetMessage(&msg, NULL, 0, 0)) {     // ★ GetMessage:没消息就【卡住等】
    TranslateMessage(&msg);
    DispatchMessage(&msg);
}
// ↑ 问题:玩家不动鼠标键盘时,线程就在这里睡着,游戏逻辑根本不会跑

// ── 写法 B:游戏主循环(本节要的)──────────────────────
MSG  msg;
bool running = true;
while (running) {
    if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {   // ★ 看一眼:有消息吗
        if (msg.message == WM_QUIT) running = false;  //    收到退出 → 结束
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    } else {
        game.update();                                // ★ 没消息 → 跑游戏逻辑
        game.render();                                //    更新一帧 + 画一帧
    }
}

GetMessage 和 PeekMessage 的差别(本节第一个关键点)

GetMessage PeekMessage
没消息时 阻塞等待(睡着了) 立刻返回 0(不睡)
适合 普通窗口程序(点一下动一下) 游戏(必须一直跑)
返回值 0 表示收到 WM_QUIT 非 0 = 有消息;0 = 没消息

一句话记住:GetMessage 是"有事叫我",PeekMessage 是"我看一眼"。游戏要一直跑,所以用后者。

二、游戏核心框架类的建立(16.4.2)

CGame 就把 16.2 的设计图"接上电"

// CGame.h
#pragma once
#include <vector>
#include "CMap.h"
#include "CTank.h"
#include "CBullet.h"

class CGame {
public:
    CMap                  map;        // 地图
    CTank                 tanks[2];   // 两台坦克
    std::vector<CBullet>  bullets;    // 子弹(会越来越多)
    bool                  over;       // 这局结束了吗

    void init();                      // 开局:加载地图、摆好坦克、清空子弹
    void update();                    // 更新一帧:读状态 → 算移动 → 判碰撞
    void render(HDC hdc);             // 绘制一帧:把当前世界画出来
};
// CGame.cpp(先写"能动"的最小版本)
#include "CGame.h"

void CGame::init() {
    map.load("res\\map.txt");         // 15 章的账:从文件读地图
    tanks[0] = CTank(100, 100, 0);
    tanks[1] = CTank(500, 400, 180);
    bullets.clear();
    over = false;
}

void CGame::update() {
    for (auto &t : tanks) t.move();   // 11.4:多态——调的是各子类的 move
    for (auto &b : bullets) b.move();
    // 碰撞判定留到 16.6
}

void CGame::render(HDC hdc) {
    map.draw(hdc);                    // 先画地图
    for (auto &t : tanks) t.draw();   // 再画坦克
    for (auto &b : bullets) b.draw(); // 最后画子弹(子弹应该压在最上面)
}

绘制顺序有个通用规律:背景 → 角色 → 特效。 后画的盖住先画的。

每帧要多快?—— 帧率

"一秒画几十次"到底几十次? 需要给循环限速,否则会把 CPU 一个核吃满:

// 最简单的限速:用"上一帧的时间"算出的时间片卡一下
DWORD last = GetTickCount();
const DWORD FRAME_MS = 16;            // 约 60 帧/秒(1000 ÷ 60 ≈ 16)

while (running) {
    DWORD now = GetTickCount();
    if (now - last < FRAME_MS) { Sleep(1); continue; }   // 还没到点,歇一下
    last = now;

    // ... 收消息 / update / render ...
    ++frameCount;                      // 计帧数(16.5.3 要把它画到屏幕上)
}

为什么不用 Sleep(FRAME_MS) 一睡到底? —— 因为"这一帧跑了多久"是不固定的,一睡到底会越跑越慢。 用"目标时刻 - 当前时刻"来补差,帧率才稳。

三、增加鼠标响应(16.4.3)

鼠标消息就三类(本节够用了)

消息 什么时候来 lParam 里是什么
WM_MOUSEMOVE 鼠标在客户区移动 低 16 位 = x 坐标,高 16 位 = y 坐标
WM_LBUTTONDOWN 左键按下 同上(按下的位置)
WM_LBUTTONUP 左键松开 同上

取出坐标要"拆两半"

case WM_MOUSEMOVE: {
    int x = LOWORD(lParam);           // ★ 低 16 位 = x
    int y = HIWORD(lParam);           // ★ 高 16 位 = y
    g_mouseX = x;  g_mouseY = y;      // 存起来,render 时要用
    return 0;
}
case WM_LBUTTONDOWN: {
    int x = LOWORD(lParam);
    int y = HIWORD(lParam);
    game.onClick(x, y);               // 交给游戏逻辑:点到哪个菜单项?发射子弹?
    return 0;
}

坐标系的两个要点:

  1. lParam 里的坐标是"客户区坐标" —— 左上角是 (0,0),不含标题栏和边框;
  2. 拆出来的是"有符号 16 位" —— 坐标超过 32767 会变负数,所以要用 LOWORD/HIWORD(无符号)再转 int,别直接强转 short。

菜单为什么要有鼠标响应

因为主菜单的选项是"图片上的一块区域" —— 玩家点的是位置,不是按钮控件。 所以要自己判断:(x, y)落在哪个选项的矩形里:

bool hitTest(int x, int y, int rx, int ry, int rw, int rh) {
    return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
}

这就是"自制按钮" —— 一个矩形 + 一个包含判断(16.6.3 会用它做菜单)。

四、在游戏窗口中使用游戏核心功能(16.4.4)

把 CGame 挂进窗口程序

最小做法:一个全局对象(本章规模够用):

CGame g_game;                          // 全局的游戏对象

LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) {
    static int  mx = 0, my = 0;        // 鼠标位置(static:跨消息保住值)

    switch (msg) {
    case WM_MOUSEMOVE:  mx = LOWORD(lParam); my = HIWORD(lParam); return 0;
    case WM_LBUTTONDOWN: g_game.onClick(LOWORD(lParam), HIWORD(lParam)); return 0;

    case WM_PAINT: {
        PAINTSTRUCT ps;
        HDC hdc = BeginPaint(hWnd, &ps);
        g_game.render(hdc);            // ★ 把一帧画出来
        EndPaint(hWnd, &ps);
        return 0;
    }
    case WM_DESTROY:
        PostQuitMessage(0);
        return 0;
    }
    return DefWindowProc(hWnd, msg, wParam, lParam);
}

然后主循环里这样接:

g_game.init();
while (running) {
    // ① 收消息(PeekMessage 版)
    // ② g_game.update();                       ← 更新一帧
    // ③ InvalidateRect(hWnd, NULL, FALSE);     ← 告诉系统"该重画了"
    //    (系统随后会发 WM_PAINT,里面调 render)
}

这里藏着本节最重要的一个"套娃"

你不在循环里直接 render,而是让系统去发 WM_PAINT —— 为什么?

做法 问题
循环里直接 g_game.render(hdc) 拿不到 hdc(设备上下文只有在 WM_PAINT 里才合法)
InvalidateRect → 系统发 WM_PAINT → 里面 render 每次都在合法的时机画

一句话:"请求重画"和"真的重画"是两件事 —— 你只能请求,系统决定什么时候画(这就是 16.1 说的"系统叫你来画")。

五、动手 Demo:帧数计数器 + 鼠标坐标显示

目标:证明"游戏循环在跑、鼠标能收到、画面每帧在刷新" —— 三件事一次验完。

// 全局
CGame g_game;
int   g_frame = 0;                     // 累计帧数
int   g_mx = 0, g_my = 0;              // 鼠标位置

// ── 主循环(WinMain 里)────────────────────────────────
g_game.init();
DWORD last = GetTickCount();
bool  running = true;
MSG   msg;

while (running) {
    if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
        if (msg.message == WM_QUIT) running = false;
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    } else {
        DWORD now = GetTickCount();
        if (now - last < 16) { Sleep(1); continue; }   // 限速 ~60 帧
        last = now;

        g_game.update();
        ++g_frame;                                     // 帧数 +1
        InvalidateRect(hwnd, NULL, FALSE);             // 请求重画
    }
}

// ── 绘制(WM_PAINT 里)────────────────────────────────
case WM_PAINT: {
    PAINTSTRUCT ps;
    HDC hdc = BeginPaint(hWnd, &ps);

    g_game.render(hdc);

    // 左上角打出帧数和鼠标位置(这就是 16.5.3 的"帧数显示")
    wchar_t buf[64];
    swprintf_s(buf, L"帧: %d    鼠标: (%d, %d)", g_frame, g_mx, g_my);
    TextOut(hdc, 10, 10, buf, wcslen(buf));

    EndPaint(hWnd, &ps);
    return 0;
}

看到什么算成功:

读结果:

六、30 秒实验

  1. 把 PeekMessage 换回 GetMessage 再跑 → 帧数停在 0 不动,动一下鼠标才跳一下。这就是两种循环的差别。
  2. 去掉那个 if (now - last < 16) 限速 → 帧数暴涨(几千),且CPU 占用飙升。体会帧率不控会怎样。
  3. 把 WM_MOUSEMOVE 里的 mx 改成 LOWORD(lParam) 但用 short 强转 ,把窗口拉到很宽再试 → 坐标可能变负数。这就是"有符号 16 位"的坑。

七、常见误区

  1. "游戏循环用 GetMessage 就行" —— 不行。GetMessage 没消息会睡着,游戏逻辑就不走了 —— 必须用 PeekMessage。
  2. "在 while 里直接调 render(hdc) 画" —— 拿不到合法的 hdc。正确做法是 InvalidateRect 请求重画,让系统发 WM_PAINT。
  3. "帧率越高越好" —— 不是。不限速会吃满一个 CPU 核,画面也不会更流畅(屏幕刷新率有上限)。限到 60 帧就够。
  4. "Sleep(16) 就能稳定 60 帧" —— 不稳。因为这一帧本身要花时间,一睡到底会越跑越慢;要用"目标时刻 − 当前时刻"补差。
  5. "鼠标坐标就是屏幕坐标" —— 不是。lParam 给的是【客户区坐标】(左上角 (0,0)、不含标题栏)。要屏幕坐标得调 ClientToScreen。
  6. "菜单得用按钮控件" —— 不用。用"矩形 + 包含判断"自己做命中测试(第三部分),这样菜单长什么样完全由你画。
自测三问(点开对答案) 1. **游戏主循环为什么不能用 `GetMessage`?** —— **因为 `GetMessage` 在没有消息时会【阻塞等待】**,**线程睡在那里,游戏逻辑(update)根本没机会跑**。**要用 `PeekMessage`:没消息就立刻返回,正好拿这段时间跑游戏逻辑。** 2. **为什么不在循环里直接 `render`,而要 `InvalidateRect` 一下?** —— **因为合法的 `hdc`(设备上下文)只在 `WM_PAINT` 里拿得到**。**`InvalidateRect` 是"请求重画",系统随后发 `WM_PAINT`,在那里画才合法**。**你只能请求,系统决定什么时候画。** 3. **鼠标消息里的 `lParam` 怎么变成坐标?为什么强调"客户区"?** —— **`LOWORD(lParam)` 是 x,`HIWORD(lParam)` 是 y**(**低/高 16 位各存一个**)。**而且这是"客户区坐标":窗口左上角为 (0,0),不含标题栏和边框** —— **直接当屏幕坐标用会整体偏移。**

相关笔记