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

本节解决一件事:怎么往窗口上画画。 ① 引入 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;
}

三条纪律:

  1. GdiplusStartup 要放在"建窗口之前" —— 因为窗口一建就可能要画;
  2. GdiplusShutdown 要放在"程序真正退出之前" —— 早关了,后面一画就崩;
  3. 必须成对:启动一次、关闭一次 —— 忘记关闭,资源不回收(和 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);
    }
}

两个必须养成的习惯:

  1. 加载后立刻检查 GetLastStatus() != Ok —— 图片加载失败不会抛异常、不会崩,只会"画不出来"(和 15.1.2 打开文件"安静失败"一模一样);
  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 改造成:

  1. 背景:一条垂直渐变;
  2. 左上角帧数:改用 GDI+ 的 DrawString(抗锯齿,比 TextOut 好看);
  3. 中间画一个"坦克":用 FillRectangle 画车身 + 用 Pen 画一条炮管(先不用图片);
  4. 鼠标位置画一个十字准星。
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);

看到什么算成功:

读结果:

七、30 秒实验

  1. 把 GdiplusStartup 那一行注释掉再跑 → 画的时候直接崩或用不了。证明"初始化不能省"。
  2. 把 Color(255, 255, 255, 0) 的第一个 255 改成 80 → 字变成半透明。这就是 Alpha 通道。
  3. 把 SmoothingModeAntiAlias 去掉 → 文字和线条边缘出现锯齿。对比一眼就看出抗锯齿的价值。

八、常见误区

  1. "GdiplusStartup 可以写在 WM_PAINT 里" —— 不行。它要在程序开始时做一次,每帧都启动会出问题。
  2. "图片加载失败会报错" —— 不会。跟文件流一样"安静失败" —— 必须自己查 GetLastStatus() != Ok(15.1.2 的老纪律)。
  3. "Brush 和 Pen 是一回事" —— 不是。Brush 管填充(填形状、填字),Pen 管描边(画线)。
  4. "画了就画了,不用管释放" —— 要管。Image 是 new 出来的(Image::FromFile 返回指针),要 delete;Font/Brush/Pen 是栈对象,出作用域自动清理。
  5. "画图坐标可以随便写" —— 不行。菜单选项"画在哪"和"点哪里"必须是同一套坐标 —— 两边各写一遍,迟早对不上(16.6.3 会讲怎么统一)。
  6. "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`,并准备降级方案** —— **图片加载失败是"安静失败",不检查就只会看到空白。**

相关笔记