本节解决一件事:怎么往窗口上画画。 ① 引入 GDI+(16.5.1);② 初始化与关闭(16.5.2); ③ 用 GDI+ 画字(帧数)(16.5.3);④ 加载图片(16.5.4);⑤ 画菜单背景(16.5.5)。 一句话记住:GDI 是"老的画法",GDI+ 是"新的画法" —— GDI 只能画直线硬边,GDI+ 能画抗锯齿、渐变、半透明、还能读 PNG。
16.4 让窗口"动起来"了,但画面上还只有它自己的那点内容,本节才真正把"画笔"交到你手里。
- 和 16.1 的关系:那节说的"GDI+ 管绘图和图片",本节就是它的正片;
- 和 15.3.4「二进制文件的读-写」的关系:图片本身就是二进制文件,
DrawImage 背后就是"读文件 → 解码 → 画出来" —— 只不过这段脏活 GDI+ 替你干了;
- 和 10.3「析构函数」的关系:Image、Font、Brush 都是对象,出作用域自动清理
—— 这又是"资源交给对象管"(RAII)的味道。
一句话:本节之后,游戏才开始"好看"。
一、绘图库的引入(16.5.1)
GDI 与 GDI+ 的差别
| GDI(老) | GDI+(新) | |
|---|---|---|
| 线条质量 | 硬边、锯齿明显 | 抗锯齿(看起来很顺) |
| 支持格式 | 主要是 BMP | PNG、JPG、GIF、BMP… |
| 渐变/透明 | 很麻烦 | 一个画刷搞定 |
| 用法 | 一堆句柄(HPEN/HBRUSH) |
面向对象(Pen/Brush/Graphics) |
本章用 GDI+ —— 理由很简单:画出来的游戏好看不只一点点,而且用法是"面向对象"的,跟你学的 C++ 一脉相承。
引入只做两件事
#include <gdiplus.h> // ① 头文件
#pragma comment(lib, "gdiplus.lib") // ② 链接库(VS 专用写法)
using namespace Gdiplus; // 省得处处写 Gdiplus::
如果编译器不认
#pragma comment(lib, ...)(非 MSVC), 就到 项目属性 → 链接器 → 输入 → 附加依赖项 里手动加gdiplus.lib。 两种做法等价,#pragma只是省事。
二、绘图库的初始化与关闭(16.5.2)
GDI+ 用之前必须初始化,用完必须关闭 —— 而且位置很讲究:
int APIENTRY wWinMain(...) {
// ── ① 程序一开始:初始化 GDI+ ─────────────────────
GdiplusStartupInput gdiplusStartupInput;
ULONG_PTR gdiplusToken;
GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, NULL);
// ── ② 中间:正常跑你的窗口和游戏循环 ───────────────
// ... WinMain 的原有内容 ...
// ── ③ 程序退出前:关闭 GDI+ ────────────────────────
GdiplusShutdown(gdiplusToken);
return (int) msg.wParam;
}
三条纪律:
GdiplusStartup要放在"建窗口之前" —— 因为窗口一建就可能要画;GdiplusShutdown要放在"程序真正退出之前" —— 早关了,后面一画就崩;- 必须成对:启动一次、关闭一次 —— 忘记关闭,资源不回收(和 15.5 的"打开要关"是一个道理)。
ULONG_PTR gdiplusToken是什么? —— 一个"句柄/令牌":GdiplusStartup把它的值填好,关闭时再把它交回去 —— 这就是 C 风格 API 里"记住我是谁"的惯例。
三、在屏幕上"画"游戏帧数(16.5.3)
画字四件套:Graphics + FontFamily + Font + Brush
case WM_PAINT: {
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
Graphics g(hdc); // ① 画布(把 hdc 包起来)
g.SetSmoothingMode(SmoothingModeAntiAlias); // 开抗锯齿
FontFamily ff(L"Microsoft YaHei"); // ② 字体家族
Font font(&ff, 16, FontStyleRegular, UnitPixel); // 字号 16 像素
SolidBrush brush(Color(255, 255, 255, 0)); // ③ 画刷(不透明黄色)
wchar_t buf[64];
swprintf_s(buf, L"帧: %d", g_frame);
g.DrawString(buf, -1, &font, PointF(10, 10), &brush); // ④ 画字
EndPaint(hWnd, &ps);
return 0;
}
四个参数的顺序别记反:画什么(字)→ 画多长(-1 = 到 \0)→ 用什么字体 → 画在哪 → 用什么颜色。
Color(255, 255, 255, 0) 的四个数字是什么
A, R, G, B —— 四个 0~255 的字节:
| 位置 | 含义 | Color(255, 255, 255, 0) |
|---|---|---|
| 第 1 个 | Alpha(透明度) | 255 = 完全不透明 |
| 第 2 个 | Red | 255 = 红拉满 |
| 第 3 个 | Green | 255 = 绿拉满 |
| 第 4 个 | Blue | 0 = 蓝不给 → 红+绿 = 黄色 |
"画刷"和"画笔"别搞混:
Brush= 填充(填形状、填字),Pen= 描边(画线、画轮廓)。 画字用SolidBrush,画坦克轮廓用Pen。
四、引入图片资源(16.5.4)
加载并画一张图
// 全局(或成员):图片对象
Image *g_tankImg = nullptr;
void LoadImages() {
g_tankImg = Image::FromFile(L"res\\tank.png"); // ★ 相对路径:相对 exe
if (g_tankImg->GetLastStatus() != Ok) { // ★ 15.1.2 的纪律:立刻检查
delete g_tankImg;
g_tankImg = nullptr;
MessageBoxW(NULL, L"tank.png 加载失败,检查 res 目录", L"提示", MB_OK);
}
}
// 画的时候
void DrawTank(Graphics &g, double x, double y) {
if (g_tankImg) {
g.DrawImage(g_tankImg, (int)x, (int)y, 32, 32); // 画在 (x,y),缩成 32×32
} else {
SolidBrush b(Color(255, 200, 200, 200)); // ★ 降级方案:画个灰方块
g.FillRectangle(&b, (int)x, (int)y, 32, 32);
}
}
两个必须养成的习惯:
- 加载后立刻检查
GetLastStatus() != Ok—— 图片加载失败不会抛异常、不会崩,只会"画不出来"(和 15.1.2 打开文件"安静失败"一模一样); - 准备"降级方案" —— 图画不出来时用图形代替,游戏照样能跑(这比崩掉或白屏强得多)。
Image::FromFile返回的是Image *(指针) —— 所以用完要delete(或交给智能指针/容器管)。Image不可拷贝(内部管着系统资源,跟 15 章的文件流一个道理)。
想换图片但不想改代码?
把"文件名"放进配置(比如一个 res\\assets.txt,一行一个文件名),
加载时循环读(15.3.3 的账)—— 换素材只改文本文件,代码一个字不动。
五、画出游戏菜单背景(16.5.5)
两种最容易出效果的背景
// ── 方式一:垂直渐变(从深蓝到浅蓝)──────────────────
void DrawMenuBackground(Graphics &g, int w, int h) {
LinearGradientBrush bg(
Point(0, 0), Point(0, h), // 从顶部到底部
Color(255, 20, 30, 60), // 起点:深蓝
Color(255, 80, 140, 200)); // 终点:浅蓝
g.FillRectangle(&bg, 0, 0, w, h);
}
// ── 方式二:平铺一张背景图(像贴瓷砖)────────────────
void DrawTiledBackground(Graphics &g, Image *bg, int w, int h) {
if (!bg) return;
TextureBrush tb(bg, WrapModeTile); // ★ 平铺画刷
g.FillRectangle(&tb, 0, 0, w, h);
(void)w; (void)h;
}
然后再在背景上"摆"标题和选项:
void DrawMenu(Graphics &g) {
FontFamily ff(L"Microsoft YaHei");
Font title(&ff, 48, FontStyleBold, UnitPixel);
Font item(&ff, 24, FontStyleRegular, UnitPixel);
SolidBrush white(Color(255, 255, 255, 255));
g.DrawString(L"坦 克 动 荡", -1, &title, PointF(180, 80), &white);
g.DrawString(L"单人游戏", -1, &item, PointF(240, 220), &white);
g.DrawString(L"双人对战", -1, &item, PointF(240, 280), &white);
g.DrawString(L"退出", -1, &item, PointF(240, 340), &white);
}
注意:上面每个选项的坐标(240, 220)(240, 280) (240, 340)就是 16.4 那个"命中测试"要用的矩形左上角 —— 画在哪、点哪里,两处坐标必须对得上(16.6.3 会把它们统一起来,免得改一处忘一处)。
六、动手 Demo:给 16.4 的画面"上妆"
把 16.4 的 Demo 改造成:
- 背景:一条垂直渐变;
- 左上角帧数:改用 GDI+ 的
DrawString(抗锯齿,比TextOut好看); - 中间画一个"坦克":用
FillRectangle画车身 + 用Pen画一条炮管(先不用图片); - 鼠标位置画一个十字准星。
g.Clear(Color(255, 30, 30, 30)); // 清成深灰底
LinearGradientBrush bg(Point(0, 0), Point(0, 600),
Color(255, 10, 20, 40), Color(255, 40, 80, 120));
g.FillRectangle(&bg, 0, 0, 800, 600); // 渐变背景
// 坦克:车身 + 炮管(代替图片)
SolidBrush body(Color(255, 60, 180, 90));
g.FillRectangle(&body, 300, 300, 28, 28);
Pen pen(Color(255, 230, 230, 230), 4.0f);
g.DrawLine(&pen, 314, 314, 314 + 30, 314); // 炮管朝右
// 帧数
FontFamily ff(L"Microsoft YaHei");
Font font(&ff, 16, FontStyleRegular, UnitPixel);
SolidBrush yellow(Color(255, 255, 255, 0));
wchar_t buf[64];
swprintf_s(buf, L"帧: %d", g_frame);
g.DrawString(buf, -1, &font, PointF(10, 10), &yellow);
// 准星
Pen cross(Color(255, 255, 80, 80), 1.5f);
g.DrawLine(&cross, g_mx - 8, g_my, g_mx + 8, g_my);
g.DrawLine(&cross, g_mx, g_my - 8, g_mx, g_my + 8);
看到什么算成功:
- 背景是一条深蓝到浅蓝的渐变(不是纯色)→ 说明 GDI+ 生效了;
- 帧数文字边缘平滑、不毛刺(比
TextOut明显好看)→ 抗锯齿生效; - 中间有个绿方块 + 一条白线(当炮管);
- 鼠标处有个红色小十字,跟着鼠标跑。
读结果:
- "渐变"和"抗锯齿"这两个效果,是 GDI 做不到(或很麻烦)的 —— 这就是换 GDI+ 的直接回报;
- 十字跟着鼠标走 —— 说明鼠标坐标(16.4)和绘制(本节)已经接上了;
- 把
res\tank.png(随便放一张图)接进LoadImages试试 —— 画出来的就是图了,而其他代码一行不用改(这就是"画"和"贴图"只差一个函数的含义)。
七、30 秒实验
- 把
GdiplusStartup那一行注释掉再跑 → 画的时候直接崩或用不了。证明"初始化不能省"。 - 把
Color(255, 255, 255, 0)的第一个 255 改成 80 → 字变成半透明。这就是 Alpha 通道。 - 把
SmoothingModeAntiAlias去掉 → 文字和线条边缘出现锯齿。对比一眼就看出抗锯齿的价值。
八、常见误区
- "
GdiplusStartup可以写在WM_PAINT里" —— 不行。它要在程序开始时做一次,每帧都启动会出问题。 - "图片加载失败会报错" —— 不会。跟文件流一样"安静失败" —— 必须自己查
GetLastStatus() != Ok(15.1.2 的老纪律)。 - "
Brush和Pen是一回事" —— 不是。Brush管填充(填形状、填字),Pen管描边(画线)。 - "画了就画了,不用管释放" —— 要管。
Image是new出来的(Image::FromFile返回指针),要delete;Font/Brush/Pen是栈对象,出作用域自动清理。 - "画图坐标可以随便写" —— 不行。菜单选项"画在哪"和"点哪里"必须是同一套坐标 —— 两边各写一遍,迟早对不上(16.6.3 会讲怎么统一)。
- "GDI+ 画得慢,游戏不能用" —— 本章规模完全够。真追求性能才需要 Direct2D / OpenGL,那是另一本书的事。
自测三问(点开对答案)
1. **用 GDI+ 之前必须做哪两件事?分别放在程序的什么位置?** —— **① `GdiplusStartup` 初始化** ——**放在程序开始、建窗口之前**;**② `GdiplusShutdown` 关闭** ——**放在程序真正退出之前**。**两者必须成对,不能漏也不能颠倒。** 2. **`Color(255, 255, 255, 0)` 四个数字各是什么?怎么得到"半透明的红色"?** —— **依次是 A(透明度)、R、G、B**。**半透明红 = `Color(128, 255, 0, 0)`**(**Alpha 取 128 就约等于半透明**)。 3. **为什么"画"和"贴图"只差一个函数?加载图片又要注意什么?** —— **因为坐标、位置、大小的代码都一样,只是把 `FillRectangle`(画方块)换成 `DrawImage`(贴图)**。**注意:加载后必须检查 `GetLastStatus() != Ok`,并准备降级方案** —— **图片加载失败是"安静失败",不检查就只会看到空白。**- ☐ 下一节(16.6 游戏功能的详细设计及使用举例)
相关笔记
- 创建游戏框架 —— 上一节:窗口与游戏循环
- 游戏功能的详细设计及使用举例 —— 下一节:坦克、子弹、地图、碰撞
- 类库的使用 —— "安静失败"与立刻检查
- 二进制文件的读-写 —— 图片本质是二进制文件
- 析构函数 ——
Font/Brush出作用域自动清理 - 🔷 C++基础 · 索引 —— 全课路线图