本节给空壳工程装上心脏:
① 主窗口 + 消息循环(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;
}
坐标系的两个要点:
lParam里的坐标是"客户区坐标" —— 左上角是 (0,0),不含标题栏和边框;- 拆出来的是"有符号 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;
}
看到什么算成功:
- "帧"后面的数字飞快上涨(约每秒 60);不动鼠标它也涨 → 说明游戏循环在独立跑;
- 移动鼠标时,括号里的坐标跟着变 → 说明鼠标消息收到了;
- 窗口被别的窗口挡住再露出来时,画面立刻重画(帧数不归零) → 说明"每帧重画"是有效的。
读结果:
- 帧数"不动鼠标也涨"是最好的证据:如果用
GetMessage,这里会停在 0 —— 这就是本节第一个关键点的现场证明; - 鼠标坐标是"客户区坐标":把鼠标移到窗口左上角,会看到接近 (0, 0)(不是屏幕坐标);
- 拖动窗口后画面不花:因为每帧都整幅重画 —— 这就是"没有局部擦除"这种好事,但也没关系。
六、30 秒实验
- 把
PeekMessage换回GetMessage再跑 → 帧数停在 0 不动,动一下鼠标才跳一下。这就是两种循环的差别。 - 去掉那个
if (now - last < 16)限速 → 帧数暴涨(几千),且CPU 占用飙升。体会帧率不控会怎样。 - 把
WM_MOUSEMOVE里的mx改成LOWORD(lParam)但用short强转 ,把窗口拉到很宽再试 → 坐标可能变负数。这就是"有符号 16 位"的坑。
七、常见误区
- "游戏循环用
GetMessage就行" —— 不行。GetMessage没消息会睡着,游戏逻辑就不走了 —— 必须用PeekMessage。 - "在
while里直接调render(hdc)画" —— 拿不到合法的hdc。正确做法是InvalidateRect请求重画,让系统发WM_PAINT。 - "帧率越高越好" —— 不是。不限速会吃满一个 CPU 核,画面也不会更流畅(屏幕刷新率有上限)。限到 60 帧就够。
- "
Sleep(16)就能稳定 60 帧" —— 不稳。因为这一帧本身要花时间,一睡到底会越跑越慢;要用"目标时刻 − 当前时刻"补差。 - "鼠标坐标就是屏幕坐标" —— 不是。
lParam给的是【客户区坐标】(左上角 (0,0)、不含标题栏)。要屏幕坐标得调ClientToScreen。 - "菜单得用按钮控件" —— 不用。用"矩形 + 包含判断"自己做命中测试(第三部分),这样菜单长什么样完全由你画。
自测三问(点开对答案)
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),不含标题栏和边框** —— **直接当屏幕坐标用会整体偏移。**- ☐ 下一节(16.5 绘图库GDI+的使用)
相关笔记
- 创建项目 —— 上一节:建工程、备素材
- 绘图库GDI+的使用 —— 下一节:把"能画"升级成"画得漂亮"
- 系统功能设计 —— 游戏主循环的设计来源
- while 和 do...while 循环 —— 游戏循环本质还是
while - 多态 ——
for (auto &t : tanks) t.move()凭什么能调不同实现 - 向量类模板 —— 用
vector装子弹 - 🔷 C++基础 · 索引 —— 全课路线图