C++游戏开发之旅 6

问题概要

经过前面的学习,我们已经将引擎的底层基础设施搭建完毕。完成了游戏主框架的设计game_appTime类实现帧率控制;资源管理模块ResourceManager类作为大哥,手下三个小弟TextureManagerAduioManagerFontManager分别管理各自的资源;渲染器Renderer作为画笔,相机Camera作为显示,当然还有颜料Sprite精灵类;为了解决硬编码的不方便和灵活性,加入了Config配置类,提高配置灵活性和扩展性;最后为了和游戏世界进行互动,加入了输入管理类InputManager和游戏进行交互。

现在,是时候正式构建我们的游戏"原子"了,之前的游戏通过创建不同的类比如Enemy、Bullet、Player,然后通过继承来共享功能。

传统继承的问题

比如我们有一个原始基类Oject,然后后面的类继承自这个类,Object_Screen继承自Object,而Object_World又继承自Object_Screen,在后面又有Actor、Enemy、Player,写到后面,这个继承链是很深的,水很深,我这种小菜鸟可能把握不住。这就凸显了这种设计的问题:

  • 功能难以复用
  • 继承层过深,修改困难
  • 代码耦合度高,不够灵活

这章将学习组合优于继承的设计哲学,搭建其整个引擎的核心 — 游戏对象(GameObject)与组件(Component)系统

1.核心思想

组件化设计(Component-Based Design)

image-20260121112100141

GameObject(游戏对象)

本身不是任何具体的东西,可以想象成一个"空的容器"或是底座

  • 其管理一堆“功能模块”
  • 有一个名字(name)和标签(tag)标识自己
Component(组件)

这就是"功能模块",充当积木,每一个组件都负责一项单一的功能。

组件使用

让一个GameObject显示在屏幕上 ------> 添加一个SpriteComponent

想拥有物理特性并能与世界碰撞 ------> 添加PhysicsComponent

想响应玩家输入 ------> 添加PlayerInputComponent

组合的优势

通过这种方式,我们的玩家就不再是一个Player类了,而是:

GameObject("Player")

|--- SpriteComponent

|--- PhysicsComponent

|--- PlayerInputComponent

|--- HealthComponent

|--- AnimationComponent

这种设计可以使我们更灵活的创建一个游戏对象,通过添加Component模块就可以构建一个想要的类

2.新项目构建

src/engine/下创建两个新的文件夹object、component,分别用于存放GameObjectComponent相关代码

3.Component基类

首先我们需要定义所有组件的基类——Component。这是一个简单抽象类,定义了所有组件都必须遵守的"生命周期"约定。

component.h

#pragma once

namespace engine::object {
class GameObject;
}

namespace engine::component {
class Component {
    friend class engine::object::GameObject;
protected:
    engine::object::GameObject* owner_ = nullptr;  // 指向拥有该组件的游戏对象
public:
    Component() = default;
    virtual ~Component() = default; // 虚析构函数,确保派生类析构函数被调用

    // 禁止拷贝和移动语义
    Component(const Component&) = delete;
    Component& operator=(const Component&) = delete;
    Component(Component&&) = delete;
    Component& operator=(Component&&) = delete;

    void setOwner(engine::object::GameObject* owner) { owner_ = owner; } // 设置拥有该组件的游戏对象
    engine::object::GameObject* getOwner() const { return owner_; } // 获取拥有该组件的游戏对象

protected:
    virtual void init() {} // 保留两段初始化机制,GameObject添加组件时自动调用,不需要外部调用
    virtual void handleInput() {} // 处理输入
    virtual void update(float) {} // 更新 
    virtual void render() {} // 渲染
    virtual void clean() {} // 清理
        
};

}

关键设计

特性 说明
owner_ 指向拥有这个组件的GameObject对象,让这个组件可以在需要时访问它的"主人"信息
virtual ~Component() 虚析构函数是必要的,以确保我们通过基类指针删除派生类对象时,能够正确地调用派生类的析构函数
生命周期函数 init()handleInput()update(),render(),clean()。这些虚函数将在适当的时候被GameObject调用
protected 生命周期函数设为protected,因为只有GameObject才有权调用(通过friend声明)

4.GameObject类:组件的管理者

GameObject是这个系统的核心部分,也是技术上较为复杂的部分,不多话,重要就对了,其需要能够动态地、类型安全地添加、获取、检查和移除任何类型的组件。

game_object.h

GameObject头文件,这块有大量的 C++模板元编程很重要、重要、要

#pragma once
#include "../component/component.h"
#include <memory>
#include <unordered_map>
#include <typeindex>
#include <utility>
#include <string>

namespace engine::object {
/**
 * @brief 游戏对象类,负责管理游戏对象的组件
 * 管理游戏对象的组件,提供添加、移除、检查、获取组件的方法
 * 还提供更新和渲染游戏对象的方法
 */
class GameObject final {
private:
    std::string name_; // 游戏对象的名称
    std::string tag_; // 游戏对象的标签
    std::unordered_map<std::type_index, std::unique_ptr<engine::component::Component>> components_; // 组件列表
    bool need_remove_ = false; // 延迟删除标识,之后由场景类进行删除

public:
    /**
     * @brief 构造函数
     * @param name 游戏对象的名称
     * @param tag 游戏对象的标签
     * 默认为空
     */
    GameObject(const std::string& name = "", const std::string& tag = ""); 

    // 禁止拷贝和移动语义
    GameObject(const GameObject&) = delete;
    GameObject& operator=(const GameObject&) = delete;
    GameObject(GameObject&&) = delete;
    GameObject& operator=(GameObject&&) = delete;

    // set and get
    void setName(const std::string& name) {name_ = name;}
    void setTag(const std::string& tag) {tag_ = tag;}
    const std::string& getName() const {return name_;}
    const std::string& getTag() const {return tag_;}
    void setNeedRemove(bool need_remove) {need_remove_ = need_remove;}
    bool needRemove() const {return need_remove_;}

    /**
     * @brief 添加组件(会完成组件的init())
     * 
     * @tparam T 组件类型
     * @tparam Args 组件构造函数参数类型
     * @param args 组件构造函数参数
     * @return T* 组件指针
     */
    template <typename T, typename... Args>
    T* addComponent(Args&&... args) {
        // 使用静态断言,在编译期间判断T是否继承自Component.
        static_assert(std::is_base_of<engine::component::Component, T>::value, "T必须继承自Component");
        /* 获取类型标识符 type_index  typeid(T) -- 用于获取一个表达式或类型的Runtime type identificator (RTTI)
        返回的是std::type_info& 这个不能作为容器的键,我们需要一层包装,通过std::type_index(typeid(T)) */
        auto type_index = std::type_index(typeid(T));
        // 组件存在则返回组件指针
        if(hasComponent<T>()) {
            return getComponent<T>();
        }
        // 组件不存在则创建组件并添加到components_中 std::forward 实现完美转发,传递多个参数使用...
        auto new_component = std::make_unique<T>(std::forward<Args>(args)...);
        T* ptr = new_component.get();  // 获取组件指针
        new_component->setOwner(this); // 设置组件的拥有者
        components_[type_index] = std::move(new_component); // 将组件添加到components_中
        ptr->init(); // 初始化组件  
        spdlog::debug("GameObject::addComponent:{} added component {}", name_, typeid(T).name());
        return ptr;
    }

    /**
     * @brief 获取组件
     * 
     * @tparam T 组件类型
     * @return T* 组件指针
     */
    template <typename T>
    T* getComponent() const {
        static_assert(std::is_base_of<engine::component::Component, T>::value, "T必须继承自Component");
        auto type_index = std::type_index(typeid(T));
        auto it = components_.find(type_index);
        if(it != components_.end()) {
            return static_cast<T*>(it->second.get()); // static_cast是必要的
        }
        return nullptr;
    }
    
    /**
     * @brief 检查是否存在组件
     * 
     * @tparam T 组件类型
     * @return 是否存在组件
     */
    template <typename T>
    bool hasComponent() const {
        static_assert(std::is_base_of<engine::component::Component, T>::value, "T必须继承自Component");
        // auto type_index = std::type_index(typeid(T));
        // return components_.find(type_index) != components_.end();
        return components_.contains(std::type_index(typeid(T)));
    }

    /**
     * @brief 移除组件
     * 
     * @tparam T 组件类型
     */
    template <typename T>
    void removeComponent() {
        static_assert(std::is_base_of<engine::component::Component, T>::value, "T必须继承自Component");
        auto type_index = std::type_index(typeid(T));
        auto it = components_.find(type_index);
        if(it != components_.end()) {
            it->second->clean();
            components_.erase(it);
        }
    }

    void update(float delta_time);
    void render();
    void clean();
    void handleInput();

};
}

关键内容解析

1.组件存储
std::unordered_map<std::type_index,std::unique<engine::component::Component>> components_;

使用unordered_map来存储组件:

元素 类型 说明
Key std::type_index 使用组件的类型本身作为键。std::type_index(typeid(T))可以为任何类型T(如SpriteComponent)生成一个唯一的、可哈希的标识
Value std::unique_ptr<Component> GameObject拥有它的所有组件。使用std::unique_ptr可以确保当GameObject被销毁时,它所拥有的所有组件都会自动、安全地销毁,完美符合RAII原则。

这里简单介绍一下typeid(T),其返回的是type_info的引用,这并不能作为容器键,所以需要通过type_index的一层包装,而后边使用智能指针,当然是保证GameObject可以接手其容器中组件的生命周期,让其安全的销毁。

2.addComponent<T,...>

模板函数:

template <typename T, typename... Args>
T* addComponent(Arg&&... args);

模板参数解析:

  • template <typename T, typename... Args>:这是一个可变参数模板,意味着我们可以用任意数量和类型的参数来构造一个组件。
// 无参数构造
obj.addComponent<HealthComponent>();
// 单个参数构造
obj.addComponent<SpriteComponent>();
// 多参数构造
obj.addComponent<SpriteComponent>("player.png",rect,layer);

用到的知识

static_assert(...) --- 静态断言,在编译期进行检查。确保任何试图添加到GameObject的类型T都必须是Component的派生类。如果不是,编译将直接失败并给出明确的错误信息。

std::forward<Args>(args)... --- 完美转发。能够将参数以原始的值类别(左值/右值)转发给组件的构造函数,保证了效率和正确性。

执行流程:

  1. 检查组件是否存在
  2. 创建新的 unique_ptr
  3. 获取其裸指针(用于返回和调用init)
  4. 设置组件的owner
  5. 将unique_ptr的所有权移动到map中,智能指针不可拷贝,适用std::move
  6. 调用组件的init()方法
3.getComponent< T >() & hasComponent< T >()

这两个函数利用type_index在map中进行快速查找:

template<typename T>
T* getComponent(){
    static_assert(std::is_base_of<engine::component::Component, T>::value,"T必须继承自Component!");
	auto type_index = std::type_index(typeid(T));
    auto it = components_.find(type_index);
    if(it != components_.end()){
        return static_cast<T*>(it->second.get());
    }
    return nullptr;
}

template<typename T>
bool hasComponent(){
    static_assert(std::is_base_of<engine::component::Component,T>::value,"T必须继承自Component!");
    return components_.contains(std::type_index(typeid(T)));    
}

// 使用:
auto* sprite = player->getComponent<SpriteComponent>();
if(sprite) {
    sprite->setTexture("new_texture.png");
}

// 检查组件是否存在
if(player->hasComponent<HealthComponent>()){
    // TODO
}

当然这里有个细节不能忘记,就是在getComponent()返回的时候,static_cast<T*>是不能省略的,为什么?首先知道我们的容器是以键为std::type_index和值为std::unique_ptr组成的,所以it->secondstd::unique_ptr<Component>;而我们的it->second.get()返回的是Component*(基类指针);而期望返回的是T*TComponent的派生类),C++中,基类指针不能隐式转换为派生类指针(这是单向的:派生类指针可以隐式转换为基类指针,即向上转型;但基类指针转派生类指针是向下转型,必须显式转换)。但从业务逻辑上确实是类型正确的,因为我们是 std::type_index(typeid(T)) 查找组件,但编译器只看类型声明,不分析逻辑,我们需要显式的通过static_cast填补。

game_object.cpp

实现相对简单,主要负责将GameObject的update,render,handleInput等调用转发给它拥有的每一个组件。

#include "game_object.h"
#include "../component/component.h"

namespace engine::object {
    GameObject::GameObject(const std::string &name, const std::string &tag)
    : name_(name), tag_(tag)
    {
        spdlog::trace("Created GameObject: {}", name_);
    }
    

    void GameObject::update(float delta_time)
    {
        // 遍历所有组件并调用它们的update方法
        for(auto& pair : components_) {
            pair.second->update(delta_time);
        }
    }
    
    void GameObject::render()
    {
        // 遍历所有组件并调用它们的render方法
        for(auto& pair : components_) {
            pair.second->render();
        }
    }

    void GameObject::clean()
    {
        // 遍历所有组件并调用它们的clean方法
        for(auto& pair : components_) {
            pair.second->clean();
        }
        // 清空 map, unique_ptr 会自动释放内存
        components_.clear();
    }

    void GameObject::handleInput()
    {
        for(auto& pair : components_) {
            pair.second->handleInput();
        }
    }

}
关键点:
  • 遍历components_这个map
  • 调用每个组件对应的生命周期函数
  • 自动管理组件的生命周期(创建、销毁)

5.集成和测试

GameApp中添加一个简单的testGameObject()函数。

//game_app.cpp
void GameApp::testGameObject()
{
    engine::object::GameObject game_object("test1");
    game_object.addComponent<engine::component::Component>();
}


image-20260124165620443

系统框架总结

对比

传统继承方式 组件化方式
Player类中包含所有功能 GameObject+多个独立组件
通过继承共享代码 通过组合复用组件
修改困难 灵活组装,随意搭配
继承层次深,维护困难 扁平化结构

设计优势

  • 高度灵活:任意组件的组合可以创建不同的对象
  • 易于扩展:新增功能只需添加新组件
  • 代码复用率高:组件之间相互独立
  • 类型安全:编译期间进行类型检查
  • 内存安全:RAII原则,自动管理生命周期

遇到的问题

这一节有些许难度,所以花了较多的时间,问题也很多。

1.关于什么时候显式定义虚构函数。

  • 基类实现多态需要定义虚的析构函数 — 可以看到,Component是一个基类,基类需要实现多态,那么就需要将基类析构函数声明为 virtual(虚析构),这是多态的核心需求。
class base {
public:
    base() : b(new int[5]) {
        std::cout << "base constructor\n";
    }

    // 关键:先故意不写 virtual,等会儿再解释后果
    ~base() {
        std::cout << "base destructor\n";
        delete[] b;
    }

private:
    int* b;
};

class derived : public base {
public:
    derived() : d(new int[8]) {
        std::cout << "derived constructor\n";
    }

    ~derived() {
        std::cout << "derived destructor\n";
        delete[] d;
    }

private:
    int* d;
};

int main() {
    base* p = new derived;
    std::cout << "-----\n";
    delete p;
}
// 输出
base constructor
derived constructor
-----
base destructor

可以看到,如果没有基类没有写virtual,那么其就无法正确释放内存。其实这好理解,定义为虚函数,后面继承的类就可以实现动态绑定,先调用derived:~derived()然后自动调用base::~base()(析构链向上回收)

  • 智能指针对前向声明的类型 — 这部分我们在前面已经说过了,std::unique_ptr的析构逻辑需要知道指向类型的 完整定义(比如调用delete时需要析构该类型)。如果仅前向声明类型(无完整定义),编译器在生成默认析构函数时会报错/未定义行为,所以需要:1.在.h显式声明析构函数(仅声明,不实现);2.在.cpp实现虚构函数(此时能看到类型的完整定义)
// Config.h(头文件)
#pragma once
#include <memory>

// 前向声明:无完整定义
class TextureManager; 

class Config {
public:
    // 显式声明析构函数(仅声明,不实现)
    ~Config(); 

    // 智能指针指向前向声明的类型
    std::unique_ptr<TextureManager> texture_mgr_;
};

// Config.cpp(源文件)
#include "Config.h"
#include "TextureManager.h" // 此时能看到 TextureManager 的完整定义

// 实现析构函数(编译器能正确处理 unique_ptr 的析构)
Config::~Config() = default; // 或自定义逻辑,=default 表示用默认行为
  • 类有非智能指针管理的资源 — 比如我们的AudioManager类,因为我们在构造函数Mix_Init()初始化SDL_mixer、Mix_OpenAudio()打开音频设备 — 这些全局状态,智能指针无法感知,必须通过析构函数进行手动清理。但是我们通过指针指针管理音效、音乐却没有关系,比如我们通过智能指针unique_ptr<Mix_Chunk,SDLMixChunkDeleter>进行音效管理,unique_ptr析构时会自动调用自定义删除器SDLMixChunkDeleter,执行Mix_FreeChunk释放单个音效资源;容器sound_析构时会遍历销毁所有unique_ptr
//.h
#pragma once
#include <memory> // for std::unique_ptr
#include <string> // for std::string
#include <unordered_map> // for std::unordered_map

#include <SDL3_mixer/SDL_mixer.h> // 使用到两种音效,Mix_Chunk(短音效)和Mix_Music(长音乐)


namespace engine::resource {

class AudioManager final{
friend class ResourceManager;
private:
    // Mix_Chunk 的自定义删除器
    struct SDLMixChunkDeleter {
        void operator()(Mix_Chunk* chunk) const{
            if(chunk){
                Mix_FreeChunk(chunk);
            }
        }
    };

    // 音频资源缓存 path + unique_ptr()
    std::unordered_map<std::string, std::unique_ptr<Mix_Chunk, SDLMixChunkDeleter>> sounds_;
public:
    /**
     * @brief 构造函数。初始化 SDL_mixer 并打开音频设备
     * @throw std::runtime_error SDL_mixer 初始化失败
     */
    AudioManager();

    ~AudioManager(); // 清理资源并关闭 SDL_mixer  TextureManager 不需要是因为没有IMG_QUIT等需要清理的
}

// cpp
#include "audio_manager.h"
#include <stdexcept>
#include <spdlog/spdlog.h>

namespace engine::resource {
    AudioManager::AudioManager()
    {
        // 使用所需的格式初始化SDL_mixer(OGG、MP3)
        MIX_InitFlags flags = MIX_INIT_OGG | MIX_INIT_MP3;
        if((Mix_Init(flags) & flags) != flags){
            throw std::runtime_error("Failed to initialize audio");
        }

        if(!Mix_OpenAudio(0,nullptr)){
            Mix_Quit(); // 如果打开音频失败,先清理Mix_Init,再给异常
            throw std::runtime_error("Failed to initialize audio");
        }
        spdlog::trace("AduioManager 构造成功");
    }

    AudioManager::~AudioManager()
    {
        // 关闭的时候一般是先停止所有音乐,再清理缓存
        Mix_HaltChannel(-1); //-1 to halt all channels. 音效停止
        Mix_HaltMusic(); // 音乐停止

        // 清理资源映射 (我们使用了智能指针进行了管理, 会调用删除器)
        clearSounds();
        clearMusic();

        // 关闭音频设备
        Mix_CloseAudio();
        
        // 退出SDL_mixer
        Mix_Quit();
        spdlog::trace("AduioManager 析构成功");
    }
}

2.对于拷贝和移动语义的总结

这里主要是对前期没太搞懂的知识做个小结。

运算符类型 参数类型 核心行为 对应构造函数 适用场景
拷贝赋值(左值) String& operator=(const String& other) 深拷贝:开辟新内存,复制对方的资源 拷贝构造String(const String&) 给左值对象赋值(a=b,b是有名字的左值)
移动赋值(右值) string& operator=(String&& other) 浅拷贝(资源窃取):直接接管对方的内存,无需新开辟 移动构造String(String&&) 给对象赋右值(比如a=std::move(b)、a=String("hello"))
  • 拷贝构造(String(const String&)) --- 需要用到另一个对象,需要有名字的左值;然后对其资源进行复制,会开辟内存,是深拷贝;无需清理自身资源(对象刚创建,无旧资源)。
  • 移动构造(String(String&&)) --- 接收右值引用(临时右值/std::move转换的左值);是进行资源的窃取操作,无需进行资源复制,只是接管对方内存;当然接管后需要设为空指针nullptr,因为对方被掏空了,这样更规范,避免析构重复释放;必须加上noexcept
  • 拷贝赋值(String& operator=(const String&)) --- 接收const左值引用;区别于拷贝构造就是需要先清理自己的资源,防止内存泄漏;然后开辟内存复制资源,深拷贝;当然需要保证不能赋值自己,因为你先清空了自己的资源,防止自赋值导致野指针访问(m_Data = other.m_Data,other.m_Data已释放了)。
  • 移动赋值(String& operator=(String&&)) --- 接受的是右值引用(临时右值/std::move转换的左值);区别于移动构造也是要先清理自己的资源;然后夺取对方的资源,接管对方内存,无需开辟新内存;值得注意的是也要有置空指针nullptr的操作;需要加上noexcept还有不能自赋值。
posted @ 2026-01-28 21:16  wenyiGamecpp  阅读(11)  评论(0)    收藏  举报