ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从零构建C++ 2D游戏框架:毕业设计实战与核心架构解析

从零构建C++ 2D游戏框架:毕业设计实战与核心架构解析 1. 项目概述为什么选择从零构建一个2D游戏框架又到了一年一度的毕业设计季后台和社群里收到不少计算机相关专业同学的私信核心问题高度一致“学长我想做个游戏当毕设Unity和Cocos Creator哪个更好” 我的回答往往让他们有点意外“如果你时间充裕且目标是真正深入理解游戏引擎的运作机制我建议你试试用C从零开始构建一个自己的2D游戏框架。” 这听起来像是个“地狱难度”的挑战远不如直接使用成熟的商业引擎来得轻松。但恰恰是这份“不轻松”构成了它作为毕业设计的独特价值。一个可扩展的2D游戏框架本质上是一个简化版的游戏引擎核心。它不追求Unity那样庞大的功能集也不像Cocos Creator那样提供完整的可视化编辑器它的核心目标是为你的特定游戏玩法提供稳定、高效、且易于迭代的底层支持。对于毕设而言这意味着你需要亲手实现游戏循环、资源管理、场景图、渲染抽象、输入处理、物理或碰撞检测等核心模块。这个过程会让你对“游戏是如何跑起来的”这个问题获得教科书和现成引擎无法给予的、刻骨铭心的理解。你不再是一个只会调用GameObject.Instantiate和Rigidbody.AddForce的“脚本小子”而会成为能够洞悉其背后数据流与生命周期管理的“架构师”。选择C作为实现语言是这个项目的另一个关键决策。C提供了无与伦比的性能控制能力这对于游戏这种实时性要求极高的软件至关重要。你可以精细地管理内存避免垃圾回收带来的不确定卡顿你可以使用标准模板库STL构建高效的数据结构你可以利用多态和模板实现灵活的架构。更重要的是C是工业级游戏引擎如Unreal Engine、早期Unity的底层的基石掌握它意味着你拿到了进入游戏工业核心研发领域的敲门砖。当然我们不会一开始就陷入到C17/20的现代特性或复杂的模板元编程中而是采用C11/14的核心特性在保证代码清晰可维护的前提下逐步构建框架。这个项目适合谁首先当然是计算机科学、软件工程、数字媒体技术等相关专业正在为毕设选题发愁的同学。其次是那些对游戏开发有浓厚兴趣不满足于停留在脚本层面渴望深入引擎底层原理的开发者。最后它也适合有一定C基础想通过一个综合性项目来巩固和提升工程能力的程序员。整个构建过程就像搭积木从最基础的窗口和事件循环开始一块块添加上去最终看到一个由自己代码驱动的游戏世界运转起来这种成就感是无与伦比的。2. 核心架构设计与模块拆解构建一个框架最忌讳的就是一开始就埋头写代码。我们必须先进行顶层设计明确框架的边界、核心模块以及它们之间的协作关系。一个典型的、可扩展的2D游戏框架可以抽象为以下几个层次分明的模块。2.1 分层架构从底层到高层的清晰边界一个良好的架构应该像千层蛋糕每一层都有明确的职责下层为上层提供服务上层无需关心下层的具体实现细节。我们的框架可以大致分为四层平台层这是最底层负责与操作系统打交道。它的核心职责是创建和管理应用程序窗口、处理系统消息如鼠标、键盘、窗口事件、提供基础的计时功能高精度计时器以及文件系统访问。我们将使用跨平台的库如SDL2或GLFW来实现这一层这样我们的框架代码在Windows、macOS和Linux上都能编译运行无需为每个平台写不同的窗口创建代码。核心系统层这是框架的“骨架”和“循环系统”。它包含几个最关键的子系统应用层封装平台层的初始化、主循环和清理工作提供一个干净的Application类作为整个程序的入口和总控。游戏循环这是游戏的心脏。一个经典的游戏循环遵循“处理输入 - 更新状态 - 渲染输出”的模式。我们需要设计一个固定时间步长或可变时间步长的循环确保游戏逻辑更新的稳定性和渲染的流畅性。内存管理虽然C有new/delete但对于频繁创建销毁的游戏对象如子弹、特效直接使用它们可能导致内存碎片。我们可以实现一个简单的对象池或内存分配器来优化高频小对象的分配。资源管理器统一加载、缓存和释放游戏资源如图片纹理、音频、字体、配置文件等。它应该提供类似ResourceManager::GetTexture(player.png)的接口内部处理重复加载和引用计数。功能模块层这是框架的“肌肉”提供了游戏开发所需的各种功能。渲染抽象定义一个统一的渲染接口如Renderer将具体的图形API调用如OpenGL、DirectX甚至软件渲染封装在后面。这样我们的游戏逻辑只与Renderer接口交互未来要切换或支持多套图形后端会容易得多。场景图管理游戏世界中所有对象的层次结构和空间关系。一个典型的场景图节点SceneNode可以包含变换位置、旋转、缩放、一个可渲染组件、一个碰撞体组件以及子节点列表。通过遍历场景图我们可以高效地完成渲染和碰撞检测。输入系统抽象键盘、鼠标、手柄等输入设备提供状态查询如IsKeyPressed和事件回调如OnKeyDown两种模式。物理/碰撞系统对于2D游戏一个基于轴对齐包围盒AABB或圆形碰撞体的轻量级碰撞检测系统就足够应对大部分需求。我们可以实现基本的碰撞查询和简单的物理响应如反弹、摩擦力。游戏逻辑层这是最上层是使用我们框架的具体游戏代码。开发者在这里定义自己的游戏实体如Player、Enemy类这些实体继承自框架的Entity或Component并实现特定的行为逻辑。设计心得依赖方向牢记“依赖倒置原则”。高层模块游戏逻辑不应依赖低层模块如OpenGL调用二者都应依赖于抽象接口如Renderer。这极大地提升了框架的可测试性和可维护性。2.2 关键设计模式的应用为了让框架灵活可扩展我们需要引入一些经典的设计模式组件模式这是现代游戏引擎的基石。与其让Player类继承自Renderable,Collidable,Updatable等多个类多重继承的噩梦不如让Player作为一个空的Entity容器然后挂载上SpriteComponent负责渲染、BoxColliderComponent负责碰撞、PlayerControllerComponent负责输入控制等。这样我们可以通过组合而非继承来构建千变万化的游戏对象灵活性极高。单例模式对于全局唯一的管理器如ResourceManager、InputManager使用单例模式可以方便地在任何地方访问。但需谨慎使用避免造成隐藏的耦合。一种更优雅的方式是将其作为Application类的成员通过Application::GetInstance().GetResourceManager()来访问。观察者模式用于处理事件。例如输入系统可以作为一个事件发布者而游戏中的UI按钮或角色可以订阅特定的按键事件。当按键按下时输入系统通知所有订阅者。状态模式非常适合管理游戏实体的复杂状态如玩家的“站立”、“奔跑”、“跳跃”、“攻击”状态。每个状态是一个独立的类负责在该状态下的输入处理、更新和渲染。实体只需持有当前状态对象的指针并在状态间切换。2.3 工具链与第三方库选型我们不可能一切从零开始造轮子明智地使用成熟的第三方库能让我们专注于框架架构本身。窗口与输入SDL2是首选。它轻量、跨平台、文档丰富完美处理窗口、OpenGL上下文、输入、音频甚至线程。GLFW是另一个优秀选择更专注于OpenGL上下文管理。图形渲染为了聚焦框架架构我们初期可以使用SDL2的2D渲染APISDL_Renderer它简单易用能快速绘制纹理、几何图形。在框架稳定后可以抽象一层底层切换为OpenGL以实现更高级的渲染效果如着色器、粒子系统。数学库线性代数向量、矩阵是游戏开发的血液。强烈建议使用GLM。它是一个只有头文件的C数学库语法与GLSL着色器语言高度相似功能强大且性能优异。音频SDL2_mixer是SDL2的扩展库足以处理背景音乐和音效。字体渲染SDL2_ttf可以加载TTF字体并将其渲染为纹理。日志与调试可以使用spdlog这样高性能的日志库方便输出不同级别的日志信息便于调试。构建系统CMake是现代C项目的标准构建工具。它可以帮助我们管理复杂的依赖关系并生成跨平台的IDE项目文件如Visual Studio的.slnXcode的.xcodeproj或Makefile。避坑指南库的集成在项目早期就用CMake或vcpkg/conan等包管理器管理好这些第三方库。手动拷贝.dll/.so文件到输出目录是初级做法且难以维护。确保你的项目在克隆后能通过一条命令如cmake --build build完成所有依赖下载、编译和链接。3. 核心模块的逐步实现与编码实战有了清晰的架构图我们就可以开始动手编码了。我们从最基础的平台和循环开始逐步向上构建。3.1 搭建项目基石Application与主循环首先我们创建Application类它是整个程序的指挥官。// Application.h #pragma once #include memory #include string class Window; class Renderer; class InputManager; class ResourceManager; class Application { public: Application(const std::string title, int width, int height); virtual ~Application(); // 运行应用进入主循环 int Run(); // 子类可重写的生命周期函数 virtual bool Initialize(); virtual void Shutdown(); virtual void Update(float deltaTime); // deltaTime: 上一帧到这一帧的时间差秒 virtual void Render(); // 获取各子系统实例单例访问点 static Application GetInstance(); Window* GetWindow() const; Renderer* GetRenderer() const; InputManager* GetInputManager() const; ResourceManager* GetResourceManager() const; private: // 私有构造函数确保单例 Application(const Application) delete; Application operator(const Application) delete; bool m_IsRunning; float m_LastFrameTime; std::unique_ptrWindow m_Window; std::unique_ptrRenderer m_Renderer; std::unique_ptrInputManager m_InputManager; std::unique_ptrResourceManager m_ResourceManager; static Application* s_Instance; };Application::Run()方法实现了经典的游戏循环。这里我推荐使用固定时间步长的循环它能让游戏逻辑更新与渲染帧率解耦保证在不同性能的电脑上物理和逻辑模拟的一致性。// Application.cpp (部分) int Application::Run() { if (!Initialize()) { return -1; } m_IsRunning true; m_LastFrameTime GetHighResolutionTime(); // 获取当前高精度时间 const float MS_PER_UPDATE 0.016f; // 目标每帧更新时间约60FPS对应的秒数 float lag 0.0f; while (m_IsRunning) { float currentTime GetHighResolutionTime(); float deltaTime currentTime - m_LastFrameTime; m_LastFrameTime currentTime; lag deltaTime; // 处理输入和系统事件如退出请求 ProcessInput(); // 以固定时间步长更新游戏逻辑可能一次循环内更新多次 while (lag MS_PER_UPDATE) { Update(MS_PER_UPDATE); // 传入固定的时间步长 lag - MS_PER_UPDATE; } // 渲染渲染时间不影响逻辑更新 Render(); // 可选的帧率限制 // LimitFrameRate(60); } Shutdown(); return 0; }Window类封装SDL窗口的创建与管理ProcessInput()内部会调用InputManager来轮询SDL事件。3.2 实现组件化实体系统这是框架灵活性的核心。我们定义Entity作为一个简单的ID或容器Component是所有组件的基类。// Component.h #pragma once #include cstdint class Entity; class Component { public: virtual ~Component() default; virtual void Update(float deltaTime) {} virtual void Render() {} // ... 其他虚函数如 OnCreate, OnDestroy void SetOwner(Entity* owner) { m_Owner owner; } Entity* GetOwner() const { return m_Owner; } private: Entity* m_Owner nullptr; }; // Entity.h #pragma once #include vector #include memory #include unordered_map #include typeindex class Component; class Entity { public: templatetypename T, typename... Args T* AddComponent(Args... args) { static_assert(std::is_base_ofComponent, T::value, T must be a Component); auto comp std::make_uniqueT(std::forwardArgs(args)...); comp-SetOwner(this); T* rawPtr comp.get(); m_Components[typeid(T)] std::move(comp); // 如果是首次添加此类型组件也加入到更新/渲染列表 if (auto it m_ComponentTypeMap.find(typeid(T)); it m_ComponentTypeMap.end()) { m_ComponentTypeMap[typeid(T)] rawPtr; // 这里可以根据需要将组件添加到不同的处理列表中 } return rawPtr; } templatetypename T T* GetComponent() { auto it m_Components.find(typeid(T)); if (it ! m_Components.end()) { return static_castT*(it-second.get()); } return nullptr; } void Update(float deltaTime) { for (auto [type, comp] : m_ComponentTypeMap) { comp-Update(deltaTime); } } void Render() { for (auto [type, comp] : m_ComponentTypeMap) { comp-Render(); } } private: std::unordered_mapstd::type_index, std::unique_ptrComponent m_Components; std::unordered_mapstd::type_index, Component* m_ComponentTypeMap; // 用于快速遍历 };现在我们可以创建具体的组件了。例如一个简单的SpriteComponent// SpriteComponent.h #pragma once #include “Component.h” #include “Texture.h” #include glm/glm.hpp class SpriteComponent : public Component { public: SpriteComponent(const std::string texturePath); virtual void Render() override; void SetPosition(const glm::vec2 pos) { m_Position pos; } void SetScale(const glm::vec2 scale) { m_Scale scale; } private: std::shared_ptrTexture m_Texture; glm::vec2 m_Position{0.0f, 0.0f}; glm::vec2 m_Scale{1.0f, 1.0f}; };在游戏逻辑中创建一个“玩家”实体就变得非常清晰auto player std::make_uniqueEntity(); auto* sprite player-AddComponentSpriteComponent(assets/player.png); auto* controller player-AddComponentPlayerControllerComponent(); auto* collider player-AddComponentBoxColliderComponent(glm::vec2(32, 48));3.3 构建资源管理与渲染抽象层ResourceManager使用std::unordered_map缓存已加载的资源。这里以纹理为例// ResourceManager.h #pragma once #include unordered_map #include memory #include string class Texture; class ResourceManager { public: std::shared_ptrTexture GetTexture(const std::string filePath); private: std::unordered_mapstd::string, std::weak_ptrTexture m_TextureCache; };GetTexture的实现逻辑是先查缓存如果找到且资源未被释放weak_ptr可lock则返回否则加载新纹理存入缓存weak_ptr并返回shared_ptr。这样当所有持有该纹理shared_ptr的组件都销毁时纹理内存会自动释放同时缓存中的weak_ptr会过期下次加载时会重新加载。Renderer是一个抽象接口// Renderer.h #pragma once #include glm/glm.hpp class Texture; class Renderer { public: virtual ~Renderer() default; virtual void Clear(const glm::vec4 color) 0; virtual void DrawTexture(const Texture texture, const glm::vec2 position, const glm::vec2 size, float rotation 0.0f) 0; virtual void DrawRect(const glm::vec2 min, const glm::vec2 max, const glm::vec4 color) 0; virtual void Present() 0; // 交换缓冲区 };然后我们提供具体实现比如SDLRenderer// SDLRenderer.h #pragma once #include “Renderer.h” struct SDL_Renderer; struct SDL_Texture; class SDLTexture; // 对SDL_Texture的包装 class SDLRenderer : public Renderer { public: SDLRenderer(SDL_Window* window); virtual ~SDLRenderer(); virtual void Clear(const glm::vec4 color) override; virtual void DrawTexture(const Texture texture, const glm::vec2 position, const glm::vec2 size, float rotation) override; // ... 其他方法实现 private: SDL_Renderer* m_SDLRenderer; };在Application::Initialize()中我们根据配置决定实例化SDLRenderer还是未来的OpenGLRenderer。游戏逻辑代码只调用Renderer接口完全不知道底层是SDL还是OpenGL。3.4 实现简单的2D碰撞检测系统一个高效的2D碰撞系统是游戏交互的基础。我们从最简单的轴对齐包围盒开始。// ColliderComponent.h #pragma once #include “Component.h” #include glm/glm.hpp enum class ColliderType { Box, Circle }; class ColliderComponent : public Component { public: virtual ColliderType GetType() const 0; virtual bool CheckCollision(const ColliderComponent* other) const 0; virtual void ResolveCollision(ColliderComponent* other) {} // 简单的碰撞响应 glm::vec2 GetPosition() const; void SetPosition(const glm::vec2 pos); protected: glm::vec2 m_Position; Entity* m_Owner nullptr; }; // BoxColliderComponent.h #pragma once #include “ColliderComponent.h” class BoxColliderComponent : public ColliderComponent { public: BoxColliderComponent(const glm::vec2 size); virtual ColliderType GetType() const override { return ColliderType::Box; } virtual bool CheckCollision(const ColliderComponent* other) const override; glm::vec2 GetSize() const { return m_Size; } glm::vec2 GetMin() const { return m_Position - m_Size * 0.5f; } glm::vec2 GetMax() const { return m_Position m_Size * 0.5f; } private: glm::vec2 m_Size; };CheckCollision的实现Box vs Boxbool BoxColliderComponent::CheckCollision(const ColliderComponent* other) const { if (other-GetType() ! ColliderType::Box) { // 可以在这里处理Box vs Circle的碰撞暂时返回false return false; } const BoxColliderComponent* otherBox static_castconst BoxColliderComponent*(other); glm::vec2 aMin GetMin(); glm::vec2 aMax GetMax(); glm::vec2 bMin otherBox-GetMin(); glm::vec2 bMax otherBox-GetMax(); // 分离轴定理在轴对齐情况下简化为比较最大最小值 bool collisionX aMax.x bMin.x aMin.x bMax.x; bool collisionY aMax.y bMin.y aMin.y bMax.y; return collisionX collisionY; }在游戏更新循环中我们需要一个CollisionSystem来管理所有ColliderComponent并进行宽阶段Broad Phase和窄阶段Narrow Phase检测。宽阶段可以使用空间划分如网格或四叉树来快速筛选出可能发生碰撞的对象对避免两两检测的O(n²)复杂度。对于毕设级别的项目如果实体数量不多100简单的两两检测在性能上也是可接受的。4. 项目整合、测试与毕设文档撰写要点当核心模块都实现后我们需要将它们整合起来制作一个小的演示游戏来测试框架的完整性和可用性。一个经典的“打飞机”或“推箱子”游戏就是很好的测试用例。4.1 创建演示游戏一个简单的2D射击游戏定义游戏实体PlayerShip包含SpriteComponent、BoxColliderComponent、PlayerControllerComponent监听键盘WASD或方向键。Enemy包含SpriteComponent、BoxColliderComponent、AIControllerComponent简单的移动逻辑如朝玩家移动或沿固定路径巡逻。Bullet包含SpriteComponent、BoxColliderComponent、ProjectileComponent定义速度方向和生命周期。实现游戏逻辑在Application的子类如MyGame的Update方法中管理游戏状态开始、进行中、结束更新所有实体并调用碰撞系统进行检测。当Bullet的ColliderComponent与Enemy的ColliderComponent发生碰撞时触发销毁事件将实体标记为待删除并在下一帧清理。实现简单的分数系统和生命值系统。资源与场景使用ResourceManager加载玩家、敌人、子弹的图片以及背景音乐、音效。可以硬编码初始化一批敌人或者实现一个简单的关卡加载器从JSON或自定义格式的文件中读取敌人位置和类型。通过这个小型游戏你可以全面测试框架的渲染、输入、碰撞、资源管理、实体组件系统等所有模块是否协同工作正常。4.2 性能优化与调试技巧性能分析在游戏循环中记录Update和Render函数的耗时。如果某一帧耗时突然飙升很可能是有密集的运算或资源加载卡住了主线程。可以使用简单的std::chrono进行测量。内存泄漏检查确保所有new都有对应的delete所有SDL_CreateXXX都有对应的SDL_DestroyXXX。在Visual Studio中可以使用“诊断工具”窗口在Linux/macOS下可以使用Valgrind工具来检测内存泄漏。渲染优化批处理即使使用SDL的2D渲染器也应尽量将相同纹理的绘制调用集中在一起减少状态切换。更高级的做法是实现一个精灵批处理器Sprite Batch。视锥裁剪只渲染在屏幕范围内的物体。为每个SpriteComponent计算其世界包围盒与摄像机视口进行比较。日志系统集成spdlog在关键流程如资源加载、实体创建销毁、碰撞事件处输出日志这是线上调试的利器。4.3 毕设文档与答辩准备要点一个出色的毕设代码只占一半清晰的文档和流畅的答辩同样重要。技术选型论证在文档开篇详细阐述为什么选择C而不是C#/Unity或TypeScript/Cocos。重点突出对底层原理的探究、性能的控制以及作为计算机专业学生夯实基础的价值。架构图与UML图使用Draw.io或PlantUML绘制清晰的框架分层架构图、核心类图展示Application、Entity、Component、Renderer等的关系、以及关键场景的序列图如“一帧的游戏循环流程”。模块详细设计说明为每个核心模块应用循环、ECS、资源管理、渲染抽象、碰撞系统单独设立章节解释其设计思路、关键数据结构、核心算法如碰撞检测的分离轴定理和接口设计。测试与结果分析展示你的演示游戏运行截图或视频。提供性能测试数据比如在实体数量达到100、500、1000时游戏的平均帧率FPS是多少。分析瓶颈可能在哪里是渲染、碰撞检测还是逻辑更新。可扩展性论证这是“可扩展的”这个题眼的关键。详细说明如何轻松地添加新组件只需继承Component基类并实现虚函数。更换渲染后端实现一个新的Renderer子类如OpenGLRenderer并在应用初始化时替换即可游戏逻辑无需改动。支持新的资源类型在ResourceManager中为新的资源类型如骨骼动画添加对应的加载和缓存逻辑。集成新的物理引擎将Box2D或Chipmunk2D封装成一个PhysicsSystem组件替代自研的简单碰撞系统。代码规范与工程结构确保代码有良好的注释、遵循一致的命名规范如Google C Style Guide。工程目录结构清晰将头文件、源文件、资源文件、第三方库、生成文件等分门别类放置。答辩演示准备一个简短的、无错误的演示程序。演练如何从零编译运行你的项目。准备回答诸如“你的ECS和Unity的GameObject组件模式有什么区别”、“固定时间步长循环相比可变时间步长有什么优劣”、“如果让你支持3D渲染架构需要做哪些调整”等深度问题。从零构建一个游戏框架是一次充满挑战的旅程它会暴露出你在语言特性、数据结构、设计模式、软件工程等多方面的知识短板但同时也是弥补这些短板最有效的方式。当你看到自己编写的代码驱动起一个充满生机的游戏世界时那份对系统掌控的自信和解决问题的成就感将是使用任何现成引擎都无法替代的。这份经历和最终产出的代码、文档也必将成为你求职简历上极具分量的一笔。
返回列表