news 2026/10/26 7:49:12

纯C++ Win32 GDI+实现飞机大战:零引擎、低延迟、可嵌入

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯C++ Win32 GDI+实现飞机大战:零引擎、低延迟、可嵌入

简介:这是一份面向C++初学者与游戏开发入门者的经典飞机大战项目源码,聚焦于面向对象编程实践与基础图形交互逻辑实现。资源包含完整的VS2019工程(.sln/.vcxproj),核心由4个头文件与4个CPP文件构成游戏主框架,30张PNG素材涵盖玩家飞机、敌机、子弹及背景,辅以字体(.ttf)、图标(.ico)、资源脚本(.rc)和README说明文档,结构清晰、模块职责分明。压缩包共46个文件,总计587KB,轻量易部署,适合本地编译运行与代码逐行研读。已有371人学习下载,读者可直接获取可运行的完整游戏工程,深入理解WSAD移动控制、空格键炸弹释放、函数模板复用机制等关键设计,并通过图像资源组织方式与资源头文件(resource.h)调用逻辑,掌握Windows平台下C++小游戏的典型开发范式。

1. 为什么用纯 C++ 写飞机大战,反而比用 Unity 或 Python 更快跑通第一帧?

这不是一个“怀旧复古”的玩具项目,而是一次对 C++ 底层图形控制能力的硬核验证:不依赖任何游戏引擎、不调用 SDL/Allegro 封装层、仅靠 Windows GDI+ 原生 API + 标准 C++17,从零构建可运行、可调试、可逐帧观测的飞机大战主循环。它解决的不是“怎么做出炫酷特效”,而是“当你要在嵌入式设备上跑一个轻量级交互逻辑、或在没有 OpenGL 支持的工控机上做状态可视化时,如何用最确定、最可控的方式把位图贴到屏幕上”。适合三类人:刚学完《C++ Primer》想落地练手的新人;需要给老旧工业终端写简易人机界面的嵌入式工程师;以及正在为毕业设计选题纠结——既怕 Unity 太重、又嫌 Python PyGame 性能不可控的学生。这个源码包里没有 cmake 配置、没有跨平台抽象、没有资源管理器,只有main.cpp、Sprite.h、GameLoop.h三个核心文件,加一个res/目录存 4 张 BMP 图(玩家飞机、敌机、子弹、爆炸),总代码行数 863 行(不含空行和注释),编译后 EXE 仅 124KB。它不教你怎么设计架构,但教会你:GDI 的双缓冲怎么避闪烁、WM_PAINT 消息怎么被正确截断、GetTickCount64 如何替代 Sleep 实现帧率锁定、以及为什么 SetTimer 在游戏主循环里是毒药。


2. 用原生 Win32 API 在本地跑通飞机大战的最小命令链

2.1 环境准备:VS2019/2022 + Windows SDK 10.0,拒绝 redistributable 依赖陷阱

很多人卡在第一步:下载了 “Microsoft Visual C++ 2015-2022 Redistributable (x64)” 后发现程序还是报错“MSVCP140.dll 丢失”。这是典型误解——redistributable 是运行时库,不是开发环境。你真正需要的是:

  • 安装Visual Studio 2019 或 2022(Community 版免费)
  • 在安装时勾选“使用 C++ 的桌面开发”工作负载
  • 确保Windows 10/11 SDK(10.0.19041.0 或更高)已安装(VS 安装器里默认勾选)
  • 不要手动下载或安装任何单独的 redistributable 包——VS 编译时会自动链接静态版 CRT(/MT),生成的 EXE 可直接拷到无 VS 的 Windows 机器上运行

提示:若你坚持用 VS Code + MinGW,会立刻掉进 GDI 头文件缺失、#include <wingdi.h>报错、CreateCompatibleDC找不到定义等坑里。MinGW 对 Windows 原生 GUI 支持极弱,这不是工具链问题,是生态定位问题。本方案只认 MSVC 工具链。

2.2 创建空项目并配置 GDI+ 支持:三步绕过 GDI+ 初始化黑匣子

新建 Win32 项目 → 选择“空项目” → 右键项目 → 属性 → 配置属性 → 常规 → 字符集 →“使用 Unicode 字符集”(必须!否则LoadImageW加载 BMP 会失败)

接着,在main.cpp顶部添加:

#include <windows.h> #include <gdiplus.h> #pragma comment(lib, "gdiplus.lib")

但这还不够。GDI+ 必须显式初始化,且必须在 WinMain 入口函数开头、CreateWindow 之前完成:

// main.cpp 开头全局变量 Gdiplus::GdiplusStartupInput gdiplusStartupInput; ULONG_PTR gdiplusToken; // WinMain 函数内第一行 Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, nullptr); // ... 创建窗口、消息循环 ... // 程序退出前最后一行 Gdiplus::GdiplusShutdown(gdiplusToken);

为什么不能放在main()里?因为 Win32 GUI 程序入口是WinMain,不是main;为什么必须早于CreateWindow?因为LoadImageW内部会调用 GDI+ 接口,未初始化就调用会返回 NULL 并静默失败——你只会看到黑屏,连错误提示都没有。

2.3 加载位图资源:用LoadImageW而非CreateBitmap,避开 24 位 BMP 解析玄学

CreateBitmap只能创建空白位图,无法加载磁盘文件。而LoadImageW是唯一能直接从.bmp文件加载、且支持 Alpha 通道(虽然本项目不用)的标准 API。关键参数如下:

HBITMAP hPlayerBmp = (HBITMAP)LoadImageW( nullptr, L"res/player.bmp", // 宽字符路径,必须用 L"" 前缀 IMAGE_BITMAP, 0, 0, // 宽高设为 0 表示按原图尺寸加载 LR_LOADFROMFILE | LR_CREATEDIBSECTION | LR_DEFAULTSIZE );
  • LR_LOADFROMFILE:从磁盘读取,不是从资源段
  • LR_CREATEDIBSECTION:强制创建 DIBSECTION 类型位图,确保后续BitBlt能正确拉伸/缩放(普通HBITMAP在某些显卡驱动下会失真)
  • LR_DEFAULTSIZE:忽略宽高参数,用文件实际尺寸(避免手动读 BMP 头解析biWidth/biHeight)

注意:BMP 文件必须是24 位真彩色(RGB),不能是 32 位带 Alpha 的 PNG 转 BMP(Windows GDI 不原生支持 PNG)。用 Photoshop 或 GIMP 导出时,明确选择“24 位”、“无 Alpha 通道”。


3. 游戏主循环:用GetTickCount64锁帧率,彻底抛弃Sleep和SetTimer

3.1 为什么Sleep(16)是伪帧率控制?三行代码暴露本质

初学者常写:

while (gameRunning) { Update(); // 更新逻辑 Render(); // 绘制画面 Sleep(16); // 期望 60 FPS }

但Sleep的精度在 Windows 上是10–15ms,且受系统调度干扰极大。实测:Sleep(16)实际休眠时间在 12–22ms 波动,导致帧率在 45–83 FPS 之间抖动,玩家操作响应延迟忽长忽短,手感崩坏。

正确做法是:记录上一帧时间戳,计算本次该休眠多久,再用Sleep补足剩余时间:

static ULONGLONG lastTick = GetTickCount64(); const ULONGLONG frameTime = 1000 / 60; // 60 FPS → 16.666... ms ULONGLONG now = GetTickCount64(); ULONGLONG elapsed = now - lastTick; if (elapsed < frameTime) { Sleep(static_cast<DWORD>(frameTime - elapsed)); // 再次获取当前时间,避免 Sleep 被打断 now = GetTickCount64(); } lastTick = now;

这叫“自适应休眠”,它让实际帧率稳定在 59–61 FPS,波动小于 ±0.5 FPS。

3.2WM_PAINT消息必须被拦截:双缓冲防闪烁的底层实现

GDI 绘图默认直接画到屏幕 DC,每BitBlt一次就刷新一次,造成严重闪烁。标准解法是:在内存中创建兼容 DC 和兼容位图,所有绘制先到内存,最后一次性BitBlt到屏幕。

关键步骤:

  1. 在WM_CREATE中创建内存 DC 和位图:
case WM_CREATE: { HDC hdc = GetDC(hWnd); hdcMem = CreateCompatibleDC(hdc); hbmMem = CreateCompatibleBitmap(hdc, clientRect.right, clientRect.bottom); SelectObject(hdcMem, hbmMem); ReleaseDC(hWnd, hdc); break; }
  1. 在WM_PAINT中,禁止调用BeginPaint/EndPaint(它们会强制擦除背景),改用GetDC/ReleaseDC:
case WM_PAINT: { HDC hdc = GetDC(hWnd); // 先清空内存位图(填黑色背景) PatBlt(hdcMem, 0, 0, clientRect.right, clientRect.bottom, BLACKNESS); // 绘制所有精灵 DrawPlayer(hdcMem); DrawEnemies(hdcMem); DrawBullets(hdcMem); // 一次性拷贝到屏幕 BitBlt(hdc, 0, 0, clientRect.right, clientRect.bottom, hdcMem, 0, 0, SRCCOPY); ReleaseDC(hWnd, hdc); break; }

注意:PatBlt用BLACKNESS清屏比FillRect更快,且不会触发背景擦除重绘;BitBlt的SRCCOPY是唯一安全的光栅操作码,MERGEPAINT等混合模式在双缓冲下易出错。


4. 精灵系统与碰撞检测:用结构体数组 + AABB,拒绝 OOP 过度设计

4.1Sprite结构体:8 个字段撑起全部行为,不继承、不虚函数

本项目不写class Sprite : public GameObject,因为游戏对象极少需要多态。一个结构体 + 一组纯函数更高效、更易调试:

struct Sprite { int x, y; // 屏幕坐标(左上角) int width, height; // 像素尺寸 HBITMAP hBmp; // 位图句柄 bool active; // 是否存活(用于池化复用) int speedX, speedY; // 每帧移动像素数(正右/下,负左/上) int health; // 生命值(玩家 3,敌机 1) };

所有精灵(玩家、敌机、子弹)共用此结构,区别仅在于初始化时赋的speedY和health。例如:

// 初始化玩家 player = {400, 500, 64, 64, hPlayerBmp, true, 0, 0, 3}; // 生成敌机(随机 X,顶部 Y) int enemyX = rand() % (clientRect.right - 64); enemies[enemyCount++] = {enemyX, -64, 48, 48, hEnemyBmp, true, 0, 2, 1};

4.2 AABB 碰撞检测:四行 if 判断,比sqrt((x1-x2)^2+(y1-y2)^2)快 17 倍

圆形碰撞需开方运算,AABB(轴对齐包围盒)只需整数比较:

bool CheckCollision(const Sprite& a, const Sprite& b) { return !(a.x + a.width <= b.x || // a 在 b 左侧 b.x + b.width <= a.x || // b 在 a 左侧 a.y + a.height <= b.y || // a 在 b 上方 b.y + b.height <= a.y); // b 在 a 上方 }

逻辑是:“不相交”的四种情况取反 → 即相交。没有浮点运算、没有函数调用开销、CPU 流水线友好。实测:每帧检测 50 个敌机 vs 10 发子弹,耗时 < 0.02ms(i5-8250U)。

血泪经验:别用abs(a.x - b.x) < (a.width + b.width)/2这种“中心距”算法——它在精灵尺寸不同时会漏判(比如大飞机撞小子弹),AABB 是工业界事实标准。


5. 避坑:GDI+ 飞机大战的 4 个必踩雷区与血泪解法

5.1 现象:程序启动后黑屏,LoadImageW返回 NULL

原因:路径字符串没用宽字符("res/player.bmp"而非L"res/player.bmp"),或 BMP 文件不是 24 位真彩色,或res/目录不在 EXE 同级路径
解决:

  • 用MessageBoxW(nullptr, L"Loading...", L"Debug", 0)打印路径确认;
  • 用dumpbin /headers your.exe查看是否链接了gdiplus.lib;
  • 用 IrfanView 打开 BMP → 属性 → 确认“Bits per Pixel”=24。

5.2 现象:敌机移动卡顿,像幻灯片一样一跳一跳

原因:WM_TIMER消息被用于更新逻辑(常见误用),但SetTimer最低精度 10ms,且消息队列可能堆积
解决:彻底删除所有SetTimer和WM_TIMER相关代码,把更新逻辑全部塞进主循环的Update()函数里,用GetTickCount64控制节奏。

5.3 现象:子弹打中敌机后,爆炸动画一闪即逝

原因:爆炸精灵也用了active = false标记,但DrawExplosion()没做“存活帧计数”,一帧就消失
解决:给爆炸添加int life;字段,初始化为 10(表示显示 10 帧),每次Update()递减,life <= 0才设active = false。

5.4 现象:窗口缩放后,所有精灵位置错乱、绘制区域溢出

原因:clientRect在WM_SIZE消息中未更新,内存位图hbmMem尺寸未重置
解决:

  • 在WM_SIZE中重新获取GetClientRect(hWnd, &clientRect);
  • DeleteObject(hbmMem);
  • hbmMem = CreateCompatibleBitmap(hdc, clientRect.right, clientRect.bottom);
  • SelectObject(hdcMem, hbmMem)。

6. 进阶技巧:用StretchBlt实现动态缩放 + 键盘映射优化,让老代码跑出新体验

6.1StretchBlt替代BitBlt:让游戏适配不同分辨率屏幕

BitBlt只能等比例拷贝,StretchBlt支持拉伸缩放。修改WM_PAINT中的最终输出:

// 假设设计分辨率为 800x600,但用户屏幕是 1920x1080 const int DESIGN_WIDTH = 800; const int DESIGN_HEIGHT = 600; double scaleX = (double)clientRect.right / DESIGN_WIDTH; double scaleY = (double)clientRect.bottom / DESIGN_HEIGHT; StretchBlt( hdc, 0, 0, clientRect.right, clientRect.bottom, // 目标矩形(全窗口) hdcMem, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT, // 源矩形(设计尺寸) SRCCOPY );

这样,所有Sprite.x/y仍按 800x600 坐标系计算,StretchBlt自动完成坐标映射。无需修改任何游戏逻辑,就能在 4K 屏上全屏运行。

6.2 键盘映射:用GetAsyncKeyState替代WM_KEYDOWN,解决连按失效

WM_KEYDOWN消息在长按键盘时,默认 500ms 后才开始重复发送,导致飞机左右移动“起步慢、停不住”。GetAsyncKeyState可每帧轮询:

void UpdatePlayer() { if (GetAsyncKeyState(VK_LEFT) & 0x8000) player.x -= 5; if (GetAsyncKeyState(VK_RIGHT) & 0x8000) player.x += 5; if (GetAsyncKeyState(VK_UP) & 0x8000) player.y -= 5; if (GetAsyncKeyState(VK_DOWN) & 0x8000) player.y += 5; // 边界检查 player.x = std::max(0, std::min(player.x, clientRect.right - player.width)); player.y = std::max(0, std::min(player.y, clientRect.bottom - player.height)); }

0x8000是最高位,表示“按键当前被按下”(而非“刚按下”),完美支持连按。

6.3 性能压测表:不同精灵数量下的帧率实测(i5-8250U, 16GB RAM)

场景敌机数子弹数爆炸数平均帧率CPU 占用
初始状态000120 FPS1.2%
中等压力2015598 FPS4.7%
高压满屏50301062 FPS12.3%
极限压力100502033 FPS28.6%

关键发现:当敌机数 > 80 时,CheckCollision耗时从 0.02ms 暴涨到 0.15ms(O(n²) 复杂度),此时应引入空间分区(如网格划分)优化。但对飞机大战这种百级对象规模,AABB 已足够——过早优化是万恶之源,先跑通,再测瓶颈,最后针对性切分。

我带实习生做这个项目时,第一周全在调LoadImageW路径和GetTickCount64时间差,第二周才真正把碰撞逻辑跑通。后来发现,最花时间的从来不是算法,而是搞懂 Windows 消息循环里哪一行该写、哪一行绝不能写。现在我新建 Win32 项目,第一件事就是粘贴那三行 GDI+ 初始化代码,第二件事是删掉所有SetTimer。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 7:47:51

OpenMontage:开源自动化剪辑工具,用算法帮你搞定重复劳动

剪辑这事儿干久了&#xff0c;你会发现最磨人的不是剪片子本身&#xff0c;而是那些"剪完还要再剪一遍"的重复劳动。尤其做 vlog、赛事集锦、活动回顾这类内容&#xff0c;素材几十段&#xff0c;每段都要挑高光、卡节奏、加转场&#xff0c;一套流程下来大半天就没了…

作者头像 李华
网站建设 2026/10/26 7:46:30

达梦数据库DM8安装配置与优化全指南

1. 为什么选择达梦数据库DM8&#xff1f;作为国产数据库的领军产品&#xff0c;达梦数据库DM8近年来在金融、政务、能源等关键领域展现出强劲的技术实力。我在某省级政务云项目中首次接触DM8时&#xff0c;其与Oracle高度兼容的语法特性、完善的集群方案和符合等保要求的安全体…

作者头像 李华
网站建设 2026/10/26 7:46:28

OpenMontage:声明式视频自动化剪辑框架实战指南

1. 项目缘起与核心定位OpenMontage 这个名字&#xff0c;第一次看到的时候我脑子里蹦出来的是两个词&#xff1a;Open 和 Montage。Montage 在影视剪辑里指的是“蒙太奇”&#xff0c;也就是通过镜头的组接来创造新的含义。把这个词用在项目命名上&#xff0c;基本可以判断这是…

作者头像 李华
网站建设 2026/10/26 7:46:27

Multi-Agent系统在电商数据分析中的实践与优化

1. Multi-Agent系统如何重塑电商数据分析格局去年双十一期间&#xff0c;某头部电商平台的技术团队悄悄上线了一套新型数据分析系统。令人惊讶的是&#xff0c;这套系统在零代码改动的情况下&#xff0c;仅通过调整Agent协作策略就将异常订单识别率提升了37%。这正是Multi-Agen…

作者头像 李华
网站建设 2026/10/26 7:45:57

HTML外部链接标注规范与最佳实践

1. HTML外部链接灵感来源标注的必要性与场景在网页开发中&#xff0c;引用外部资源或借鉴其他网站的设计思路时&#xff0c;标注来源不仅是基本的职业操守&#xff0c;更是避免版权纠纷的重要措施。我见过太多开发者因为忽略这个小细节而陷入法律风险。最常见的场景包括&#x…

作者头像 李华
网站建设 2026/10/26 7:45:20

Java毕业设计实战:高校智能浴室预约系统全链路实现

简介&#xff1a;本资源是一套面向计算机专业本科生的毕业设计级Java全栈项目&#xff0c;聚焦高校后勤服务智能化场景&#xff0c;解决传统浴室资源调度低效、支付不便与设备报修滞后等实际问题。压缩包共9个文件&#xff0c;含5个功能模块压缩包&#xff08;前后端源码、修改…

作者头像 李华