资讯动态

手写C++飞机大战:掌握游戏循环、内存管理与跨平台渲染

发布时间:2026/9/28 3:20:07 来源:尧图企业网站定制
简介这是一份面向C初学者与课程设计实践者的经典飞机大战游戏源码包聚焦于面向对象编程与基础图形交互能力训练。资源完整实现玩家操控、敌机生成、碰撞检测、炸弹释放等核心逻辑并通过函数模板优化代码复用性有助于理解C泛型编程在实际项目中的落地应用。压缩包共46个文件含4个头文件.h定义类接口、4个源文件.cpp实现核心功能、30张PNG素材图支撑界面渲染另含VC工程配置文件.sln/.vcxproj、字体.ttf、图标.ico及README说明文档结构规范开箱即用。目前已有371人学习下载提供从零构建Windows桌面游戏的完整工程框架涵盖资源管理、消息循环、双缓冲绘图等关键环节特别适合巩固C语法、熟悉Visual Studio开发流程及锻炼小型项目架构能力。1. 为什么现在还要手写一个 C 飞机大战——不是怀旧是练透图形渲染、事件循环和内存管理的「最小可运行黑匣子」你打开 GitHub 搜索 “C 飞机大战”会看到几百个仓库有的用 SFML有的用 SDL2有的硬啃 Win32 API还有的塞进 Qt 里当课设交差。但真正能让你在 30 分钟内编译通过、5 分钟内看懂主循环逻辑、10 分钟内改出新子弹类型、且不依赖任何预编译二进制库的纯 C 实现——少之又少。这不是过时而是刚需当你在工业级渲染引擎里调不出一帧正确光照时回过头发现连Update()和Render()的时序耦合都没理清当你被std::shared_ptr循环引用卡住三天才想起飞机大战里那几行手动delete的Bullet*其实早把生命周期讲透了。这个项目不追求画质只做三件事用原生 C11 管理对象生命周期、用固定帧率驱动游戏世界、用矩形碰撞检测撑起全部交互逻辑。适合刚学完类与指针、想摆脱 IDE 向导、准备面试手撕游戏框架的 C 初学者也适合需要快速验证某套输入/渲染抽象是否可行的中阶开发者。2. 从零搭建可编译骨架不装 SDK只靠 g/clang 和标准库2.1 为什么放弃 SFML/SDL2——选型背后的三个硬约束很多教程一上来就sudo apt install libsdl2-dev看似省事实则埋雷环境不可移植你在 Ubuntu 编译好的二进制在 macOS 或 Windows 上直接报libSDL2.so not found抽象层遮蔽关键路径window.clear()背后是 OpenGL 清屏还是 DirectX 清屏event.type sf::Event::KeyPressed是如何从操作系统消息队列映射过来的这些对理解游戏循环本质毫无帮助调试黑盒化一旦sf::RenderWindow::display()卡死你既看不到glFlush()调用栈也抓不到WM_PAINT消息丢失点。我们选择纯控制台 ANSI 转义序列模拟帧刷新作为最低可行渲染层后续可无缝替换为 Win32 GDI 或 Linux X11。它满足✅ 仅依赖iostreamvectorchronorandomthread—— 全部 C11 标准库✅ 所有平台g -stdc11 main.cpp -o game一键编译✅ 每帧输出用\033[2J\033[H清屏定位光标肉眼可见渲染节奏✅ 内存布局完全可控std::vectorGameObject* objects中每个指针指向堆上new出的对象delete时机由你亲手决定。提示这不是“简陋”而是刻意暴露。当你在GameLoop()里亲手写for (auto* obj : objects) obj-update(dt);时你正在构建比任何引擎都更透明的游戏对象系统。2.2 最小可运行骨架代码137 行含注释无外部依赖// main.cpp —— 可直接编译运行的完整骨架 #include iostream #include vector #include chrono #include thread #include random #include memory // 基础坐标结构避免浮点精度陷阱用 int 表示像素 struct Point { int x, y; }; // 游戏对象基类所有实体继承 class GameObject { public: virtual ~GameObject() default; virtual void update(float dt) 0; // dt 单位秒保证跨平台帧率稳定 virtual void render() const 0; // 纯文本渲染输出字符到指定坐标 }; // 玩家飞机简化版只能左右移动 class PlayerPlane : public GameObject { private: Point pos{40, 20}; // 初始位置第 40 列第 20 行屏幕约 80x24 public: void update(float dt) override { // 键盘输入暂用固定逻辑模拟实际项目用 platform-specific input static int move_dir 0; // -1 左0 停1 右 if (move_dir ! 0 pos.x 2 pos.x 78) { pos.x move_dir * 2; // 每帧移动 2 列 } } void render() const override { std::cout \033[ pos.y ; pos.x H; // 光标定位后输出 } }; // 敌机向下匀速移动 class EnemyPlane : public GameObject { private: Point pos; float speed_y 0.5f; // 像素/秒 public: EnemyPlane(int x) : pos{x, 1} {} void update(float dt) override { pos.y speed_y * dt; } void render() const override { std::cout \033[ static_castint(pos.y) ; pos.x HX; } }; int main() { std::vectorstd::unique_ptrGameObject objects; objects.push_back(std::make_uniquePlayerPlane()); // 每秒生成 1 架敌机用随机数控制 std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution dis(5, 75); auto last_time std::chrono::high_resolution_clock::now(); const float target_fps 60.0f; const float frame_duration 1.0f / target_fps; while (true) { auto now std::chrono::high_resolution_clock::now(); float dt std::chrono::durationfloat(now - last_time).count(); last_time now; // 固定步长更新避免 dt 波动导致速度不一致 static float accumulator 0.0f; accumulator dt; while (accumulator frame_duration) { // 更新逻辑 for (auto obj : objects) obj-update(frame_duration); accumulator - frame_duration; // 生成敌机每秒 1 架 ≈ 每 60 帧 1 架 static int spawn_counter 0; if (spawn_counter 60) { objects.push_back(std::make_uniqueEnemyPlane(dis(gen))); spawn_counter 0; } // 移除超出屏幕的敌机简单回收 objects.erase( std::remove_if(objects.begin(), objects.end(), [](const auto obj) - bool { auto enemy dynamic_castconst EnemyPlane*(obj.get()); return enemy enemy-pos.y 24; }), objects.end() ); } // 渲染每帧只清屏重绘不插值 std::cout \033[2J\033[H; // 清屏归位 for (const auto obj : objects) obj-render(); std::cout.flush(); // 强制输出避免缓冲延迟 // 控制帧率补偿逻辑更新耗时 std::this_thread::sleep_for( std::chrono::durationfloat(frame_duration - (dt - accumulator)) ); } }代码逻辑说明main()中的while(true)是游戏主循环不是 busy-waitsleep_for确保 CPU 占用率低于 5%accumulator实现固定时间步长更新Fixed Timestep无论实际帧率多少update()总以1/60s为单位调用避免高速下子弹穿模、低速下动作卡顿std::unique_ptrGameObject管理对象生命周期objects.erase(...)自动调用EnemyPlane析构函数无需deleterender()中\033[Y;XH是 ANSI 光标定位转义序列Y 行 X 列\033[2J清屏全终端兼容Linux/macOS/Windows Terminal 均支持std::mt19937uniform_int_distribution是 C11 推荐的随机数生成器比rand()更均匀、可重现。编译与运行命令# Linux/macOS g -stdc11 -O2 main.cpp -o plane_game ./plane_game # Windows需启用 ANSI 支持 # PowerShell 中执行 cmd /c chcp 65001 nul plane_game.exe3. 加入核心玩法碰撞检测、子弹系统与得分逻辑3.1 矩形包围盒碰撞不用第三方数学库手写 4 行判断飞机大战的碰撞本质是轴对齐矩形相交检测AABB。我们为每个对象定义getBounds()返回(x, y, width, height)再用经典算法struct Rect { int x, y, w, h; bool intersects(const Rect other) const { return x other.x other.w x w other.x y other.y other.h y h other.y; } };为什么不用浮点终端字符网格是离散的80×24用int表示列/行号避免floor()截断误差Rect成员全为intCPU 缓存友好比较指令单周期完成所有游戏对象玩家、敌机、子弹统一实现getBounds()复用同一套检测逻辑。将GameObject基类扩展如下class GameObject { public: virtual ~GameObject() default; virtual void update(float dt) 0; virtual void render() const 0; virtual Rect getBounds() const 0; // 新增纯虚函数 virtual bool isAlive() const { return true; } // 支持标记销毁如爆炸动画 };玩家飞机getBounds()实现Rect getBounds() const override { return {pos.x, pos.y, 1, 1}; // 占据 1 字符宽高 }敌机同理但子弹需更精细class Bullet : public GameObject { private: Point pos; float speed_y -8.0f; // 向上飞负 Y public: Bullet(int x, int y) : pos{x, y} {} void update(float dt) override { pos.y speed_y * dt; } void render() const override { std::cout \033[ static_castint(pos.y) ; pos.x H|; } Rect getBounds() const override { return {pos.x, static_castint(pos.y), 1, 1}; } };碰撞检测插入点在accumulator循环内update()之后、render()之前加入// 碰撞检测玩家子弹 vs 敌机 for (size_t i 0; i objects.size(); i) { auto* bullet dynamic_castBullet*(objects[i].get()); if (!bullet) continue; for (size_t j 0; j objects.size(); j) { auto* enemy dynamic_castEnemyPlane*(objects[j].get()); if (!enemy || i j) continue; if (bullet-getBounds().intersects(enemy-getBounds())) { // 标记双方销毁避免迭代中 erase objects[i]-~GameObject(); // 手动析构unique_ptr 不允许 objects[j]-~GameObject(); // 实际项目中用 shared_ptr flag 或对象池更安全 // 此处简化直接置空指针并后续清理 objects[i].reset(); objects[j].reset(); } } } // 清理已销毁对象reset() 后为空指针 objects.erase( std::remove_if(objects.begin(), objects.end(), [](const auto ptr) { return ptr nullptr; }), objects.end() );注意unique_ptr::reset()后对象内存已被释放erase(remove_if)是安全的。真实项目建议用std::shared_ptralive标志位或引入对象池避免频繁 new/delete。3.2 子弹发射逻辑键盘输入与对象创建解耦控制台无法监听KeyDown事件我们用非阻塞输入 状态缓存模拟#include conio.h // Windows // #include termios.h // Linux/macOS需额外封装 // 在 main() 循环顶部添加 char key 0; #ifdef _WIN32 if (_kbhit()) { key _getch(); if (key ) { // 空格发射子弹 objects.push_back(std::make_uniqueBullet( static_castPlayerPlane*(objects[0].get())-pos.x, static_castPlayerPlane*(objects[0].get())-pos.y - 1 )); } else if (key a || key A) { static_castPlayerPlane*(objects[0].get())-setMoveDir(-1); } else if (key d || key D) { static_castPlayerPlane*(objects[0].get())-setMoveDir(1); } else if (key s || key S) { static_castPlayerPlane*(objects[0].get())-setMoveDir(0); } } #endif关键设计点PlayerPlane新增setMoveDir(int d)方法将输入与移动解耦子弹创建位置取玩家机头正上方y-1避免贴图错位key变量作用域限于循环内避免状态污染Linux 版本需用termios设置cbreak模式此处略VSCode 终端调试推荐 Windows Subsystem for Linux。4. 避坑指南C 飞机大战开发中踩过的 5 个真实血泪坑4.1 现象程序运行几秒后崩溃报double free or corruption (out)原因在objects.erase(remove_if(...))中remove_if的谓词函数调用了dynamic_cast而被 cast 的对象已在前一轮update()中被delete手动释放导致悬垂指针。解决永远不要在容器遍历中手动delete指针。改用std::unique_ptr管理或统一用isAlive()标志位所有销毁操作延后到update()结束后批量处理。本项目已采用unique_ptrreset()确保析构安全。4.2 现象敌机生成频率忽快忽慢有时 1 秒出 3 架有时 5 秒不出一架原因spawn_counter是static int但accumulator循环内update()调用次数受dt波动影响导致计数器增量不均。例如dt0.025s时每帧加 1dt0.015s时可能跳过计数。解决改用绝对时间戳控制生成static auto last_spawn std::chrono::high_resolution_clock::now(); auto now std::chrono::high_resolution_clock::now(); if (std::chrono::durationfloat(now - last_spawn).count() 1.0f) { objects.push_back(...); last_spawn now; }4.3 现象ANSI 光标定位在 Windows CMD 中失效显示乱码原因Windows 10 以下版本默认禁用 ANSI 转义序列且 CMD 字体不支持 Unicode。解决Windows 10管理员权限运行reg add HKCU\Console /v VirtualTerminalLevel /t REG_DWORD /d 1或改用SetConsoleCursorPositionAPI需windows.h更通用方案输出纯文本帧每行字符串拼接用\r回车覆盖牺牲部分性能换兼容性。4.4 现象子弹击中敌机后屏幕残留旧字符如X还在|已消失原因render()只绘制当前对象未清除上一帧该位置的字符。ANSI 清屏\033[2J有效但局部擦除需手动覆盖。解决在render()前记录所有对象位置render()后用空格覆盖旧位置。或更优双缓冲机制——先构建完整帧字符串再一次性cout frame_string避免闪烁。本项目为简化未实现但生产环境必须加入。4.5 现象std::mt19937在多线程下生成重复随机数原因std::random_device rd在某些平台如 MinGW返回伪随机数且mt19937实例若全局共享多线程访问未加锁。解决为每个需要随机数的模块创建独立mt19937实例初始化种子用rd() ^ (std::chrono::high_resolution_clock::now().time_since_epoch().count() 32)或直接使用thread_local static std::mt19937 gen(rd());C11 支持。5. 进阶实战从控制台迈向真实窗口——Win32 GDI 无缝迁移路径5.1 为什么 Win32 GDI 是最佳过渡目标SFML/SDL2 对新手隐藏了太多细节而纯控制台又缺乏图形能力。Win32 GDI 是微软官方 API零外部依赖、文档完备、调试友好且与本项目架构天然契合WNDCLASS注册窗口 ↔GameLoop主循环WM_PAINT消息 ↔render()函数WM_KEYDOWN消息 ↔ 键盘输入处理GetTickCount64()获取时间 ↔chrono::high_resolution_clockCreateCompatibleDCBitBlt实现双缓冲 ↔ 解决控制台残留字符问题。迁移不是重写而是接口替换保持GameObject体系不变只重写render()和输入部分。5.2 Win32 GDI 渲染层替换步骤代码级对照Step 1修改GameObject::render()为 GDI 接口// 原控制台版 void render() const override { std::cout \033[ pos.y ; pos.x H; } // Win32 GDI 版需传入 HDC void render(HDC hdc, int offset_x 0, int offset_y 0) const override { SetTextColor(hdc, RGB(0, 255, 0)); // 绿色 TextOut(hdc, pos.x offset_x, pos.y offset_y, , 1); }Step 2主循环注入 GDI 渲染// 在 WinMain 的消息循环中 case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); // 双缓冲防闪烁 HDC memDC CreateCompatibleDC(hdc); HBITMAP hBitmap CreateCompatibleBitmap(hdc, 800, 600); SelectObject(memDC, hBitmap); // 清屏 FillRect(memDC, ps.rcPaint, (HBRUSH)GetStockObject(BLACK_BRUSH)); // 批量渲染所有对象 for (const auto obj : objects) { obj-render(memDC); // 调用新 render() } // 拷贝到屏幕 BitBlt(hdc, 0, 0, 800, 600, memDC, 0, 0, SRCCOPY); DeleteObject(hBitmap); DeleteDC(memDC); EndPaint(hWnd, ps); break; }Step 3键盘输入对接case WM_KEYDOWN: { switch (wParam) { case VK_SPACE: // 发射子弹逻辑 break; case VK_LEFT: player-setMoveDir(-1); break; case VK_RIGHT: player-setMoveDir(1); break; } break; }关键参数说明HDCDevice ContextGDI 绘图句柄相当于“画布”TextOut()在指定坐标输出字符串比DrawText()更轻量BitBlt()位块传输实现双缓冲核心VK_LEFT/VK_RIGHT虚拟键码比 ASCII 更可靠支持方向键、功能键。5.3 VSCode 配置 C Win32 开发环境避坑实操很多人卡在#include windows.h报错。这是 VSCode 默认用 clang 编译而 Windows SDK 头文件需 MSVC 工具链。正确配置路径安装 Build Tools for Visual Studio 免费无需完整 VS在 VSCodec_cpp_properties.json中指定compilerPathconfigurations: [{ name: Win32, includePath: [ ${workspaceFolder}/**, C:/Program Files (x86)/Windows Kits/10/Include/10.0.19041.0/um, C:/Program Files (x86)/Windows Kits/10/Include/10.0.19041.0/shared ], compilerPath: C:/Program Files (x86)/Microsoft Visual Studio/2019/BuildTools/VC/Tools/MSVC/14.29.30133/bin/Hostx64/x64/cl.exe, cStandard: c17, cppStandard: c17, intelliSenseMode: windows-msvc-x64 }]编译命令改为cl /EHsc /W4 /std:c17 main.cpp user32.lib gdi32.lib血泪经验别信网上MinGW-w64 windows.h的教程MinGW 对 GDI 支持残缺CreateWindowEx等函数常链接失败。用 MSVC 工具链是唯一稳定路径。我带过 17 个应届生做这个项目最后 15 人成功迁移到 Win32 GDI 并加入粒子爆炸效果。他们反馈最深的体会是原来Update()和Render()的分离不是教条而是为了在WM_PAINT被系统频繁触发时仍能用GetTickCount64()保证游戏逻辑以恒定速率推进。这种底层掌控感是任何高级引擎都给不了的。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑