ECS框架-蓝图和实体工厂

蓝图和实体工厂

上节课我们完成了从Tiled解析出路径节点,并让敌人沿着规划的路径移动,但我们在创建敌人的时候是"手动创建测试实体",registry.create(); emplace<Transform/Velocity/Enemy/Sprite/Render...>(),这种方法虽然可以跑通,但是也有很大的问题:创建逻辑分散:不同敌人/不同单位会把GameScene中填满emplace,难以维护;调参不便:stats/动画/精灵/音效都在代码中,改一次就要重新进行编译,这不但浪费大量时间,而且平衡性的调整难度较高。

这节我们需要引入两套 数据驱动 的基础设施:

  • BlueprintManager(蓝图管理器):从JSON加载并保存单位蓝图
  • EntityFactory(实体工厂):使用蓝图来创建实体,按步骤添加必要组件

学习目标

  • 理解“蓝图(Blueprint)”在 ECS 项目里的定位:纯数据、可配置、可复用
  • 设计蓝图结构体(entity_blueprint.h),并从 enemy_data.json 解析出敌人蓝图
  • 实现 BlueprintManager:加载 JSON → 解析子蓝图 → 存到 map(用 entt::hashed_string 做 key)
  • 实现 EntityFactory::createEnemyUnit():通过蓝图创建敌人实体,并补齐组件与标签
  • GameScene 中接入蓝图与工厂:从“手写 emplace”切到“按蓝图生成”

架构设计 蓝图管理器 + 实体工厂

image-20260401190212129

核心原则:

  • BlueprintManager --- 只负责把 JSON 数据解析并将其数据变成蓝图(纯数据)保存起来
  • EntityFactory --- 使用蓝图构建entity实体

这样将来要新增单位类型/改数值/换动画,只需要改 assets/data/*.json,不需要改游戏逻辑代码。

蓝图数据 json

其结构是一个对象,key是"敌人类型名",value是该类型的配置数据,比如slime

"slime": {
        "name": "史莱姆",
        "hp": 200,
        "atk": 70,
        "def": 20,
        "range": 20,
        "atk_interval": 2.0,
        "speed": 60,
        "ranged": false,
        "sprite_sheet": "assets/textures/Enemy/slime.png",
        "face_right": false,
        "width": 192,
        "height": 192,
        "offset_x": -96,
        "offset_y": -148,
        "animation": {
            "idle": {"duration": 50, "row":0, "frames":[0,1,2,3,4,5,6,7,8,9,10,11,12,13]},
            "walk": {"duration": 25, "row":1, "frames":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27]},
            "damage": {"duration": 50, "row":2, "frames":[0,1,2,3,4,5,6,7,8,9,10,11]},
            "attack": {"duration": 50, "row":3, "frames":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]}
        }
    }

大体有:

  • 属性(stats)hp/atk/def/range/atk_interval
  • 移动与类型speedranged
  • 显示信息name(可选 description
  • 精灵与动画sprite_sheet/width/height/offset/animation{...}
  • 音效(可选)sounds{ emit: ... }

蓝图结构体

蓝图拆分多层结构体(每个负责一小块数据):

// 属性蓝图
struct StatsBlueprint { 
    float hp_ {0};
    float atk_{0};
    float def_{0};
    float range_{0};
    float atk_interval_{0}; 
};

// 精灵蓝图
struct SpriteBlueprint {
    entt::id_type texture_id_{entt::null}; // 纹理id
    std::string texture_path_; // 纹理路径
    engine::utils::Rect src_rect_{}; // 纹理矩形
    glm::vec2 size_ {};
    glm::vec2 offset_ {};
    bool face_right_{true}; // 是否朝右
};

// 动画蓝图
struct AnimationBlueprint {
    float frame_time_ms_{0}; // 帧间隔
    int row_{0}; // 行数
    std::vector<int> frames_; // 帧
};

// 声音蓝图
struct SoundBlueprint {
    std::unordered_map<entt::id_type, entt::id_type> sounds_;
};

struct EnemyBlueprint {
    bool ranged_{false};
    float speed_{0};
};

struct DisplayInfoBulueprint {
    std::string name_;
    std::string description_;
};

struct EnemyClassBlueprint {
    entt::id_type class_id_{entt::null};
    std::string class_name_;

    StatsBlueprint stats_;
    SpriteBlueprint sprite_;
    SoundBlueprint sound_;
    EnemyBlueprint enemy_;
    DisplayInfoBulueprint display_info_;

    std::unordered_map<entt::id_type, AnimationBlueprint> animations_;
};

BlueprintManager 蓝图管理器

蓝图管理负责存储从json文件中读取的实体蓝图(本节只有敌人)EnemyClassBlueprint,采用unorder_map存储。

#pragma once
#include "../data/entity_blueprint.h"
#include <nlohmann/json_fwd.hpp>

namespace engine::resource{
    class ResourceManager;
}

namespace game::factory {
class BlueprintManager {
private:
    std::unordered_map<entt::id_type, game::data::EnemyClassBlueprint> enemy_class_blueprints_;
    engine::resource::ResourceManager& resource_manager_;

public:
    BlueprintManager(engine::resource::ResourceManager& resource_manager);
    ~BlueprintManager() = default;

    [[nodiscard]] bool loadEnemyClassBlueprints(std::string_view ememy_dat_json_path);
    const data::EnemyClassBlueprint& getEnemyClassBlueprint(entt::id_type id) const {
        return enemy_class_blueprints_.at(id);
    }

private:
    data::StatsBlueprint parseStats(const nlohmann::json& json);
    data::SpriteBlueprint parseSprite(const nlohmann::json& json);
    std::unordered_map<entt::id_type, data::AnimationBlueprint> parseAnimations(const nlohmann::json& json);
    data::SoundBlueprint parseSounds(const nlohmann::json& json);
    data::DisplayInfoBulueprint parseDisplayInfo(const nlohmann::json& json);
    data::EnemyBlueprint parseEnemy(const nlohmann::json& json);

};

}

解析函数 loadEnemyClassBlueprints

遍历 Json 顶层每一项:

  • class_name:如wolf
  • class_id:用entt::hashed_string把字符串变成稳定的整数key
for (const auto& [class_name, data_json] : j.items()) {
    // enemy_id
    auto enemy_id = entt::hashed_string(class_name.c_str());
    // 解析 Stats
    data::StatsBlueprint stats = parseStats(data_json);
    // 解析 Sprite
    data::SpriteBlueprint sprite = parseSprite(data_json);
    // 解析 Animation
    std::unordered_map<entt::id_type, data::AnimationBlueprint> animations = parseAnimations(data_json);
    // 解析Sound
    data::SoundBlueprint sounds = parseSounds(data_json);
    // 解析Enemy数据
    data::EnemyBlueprint enemy = parseEnemy(data_json);
    // 解析DisplayInfo
    data::DisplayInfoBulueprint display_info = parseDisplayInfo(data_json);

    enemy_class_blueprints_.emplace(enemy_id, 
                                    data::EnemyClassBlueprint{
                                        enemy_id, 
                                        std::move(class_name),
                                        std::move(stats), 
                                        std::move(sprite), 
                                        std::move(sounds), 
                                        std::move(enemy), 
                                        std::move(display_info),
                                        std::move(animations)
                                    });
}

解析拆分:每个子蓝图一个 parseXXX

解析拆分成多个小函数

data::StatsBlueprint BlueprintManager::parseStats(const nlohmann::json& j)
{
    float hp_ = j.value("hp", 100.0f); // 默认100
    float atk_ = j.value("atk", 10.0f); // 默认10
    float def_ = j.value("def", 5.0f); // 默认5
    float range_ = j.value("range", 10.0f); // 默认10.0
    float atk_interval_ = j.value("atk_interval", 2.0f); // 默认1.0秒
    return data::StatsBlueprint{hp_, atk_, def_, atk_interval_, range_};
}

data::SpriteBlueprint BlueprintManager::parseSprite(const nlohmann::json &json)
{
    auto width = json["width"].get<float>();
    auto height = json["height"].get<float>();
    auto path_str = json["sprite_sheet"].get<std::string>();
    auto path_id = entt::hashed_string(path_str.c_str());

    // 可选部分:源矩形的起点默认值为 0,0,渲染目标大小默认值为 width,height
    // (如果指定,起点为 x,y,渲染目标大小为 size_x,size_y)
    return data::SpriteBlueprint{path_id, 
                                 path_str, 
                                 engine::utils::Rect{glm::vec2(json.value("x", 0), json.value("y", 0)), glm::vec2(width, height)}, 
                                 glm::vec2(json.value("size_x", width), json.value("size_y", height)),
                                 glm::vec2(json.value("offset_x", 0), json.value("offset_y", 0)),
                                 json.value("face_right", true)
                                };

}

std::unordered_map<entt::id_type, data::AnimationBlueprint> BlueprintManager::parseAnimations(const nlohmann::json &json)
{
    std::unordered_map<entt::id_type, data::AnimationBlueprint> animations;
    if(json.contains("animation") && json["animation"].is_object()){
        auto anims = json["animation"];
        for(auto& [key, value] : anims.items()){
            entt::id_type anim_id = entt::hashed_string(key.c_str());
            data::AnimationBlueprint anim {
                value.value("duration",100),
                value.value("row", 0),
                value["frames"].get<std::vector<int>>()
            };
            animations.emplace(anim_id, std::move(anim));
        }
    }
    return animations;
}

data::SoundBlueprint BlueprintManager::parseSounds(const nlohmann::json &json)
{
    auto sounds = data::SoundBlueprint(); // 音效,id_type, id_type,对应的音效id
    if(json.contains("sounds")){
        for(auto& [key, value] : json["sounds"].items()){
            std::string sound_path_or_ref = value.get<std::string>();
            entt::id_type sound_id = entt::hashed_string(sound_path_or_ref.c_str());
            // "shell_shoot" 转成 sound_id,而之前的resourcemanager初始化载入过了,所以会正常返回
            resource_manager_.loadSound(sound_id, sound_path_or_ref);
            sounds.sounds_.emplace(entt::hashed_string(key.c_str()), sound_id);
        }
    }
    return sounds;
}

data::DisplayInfoBulueprint BlueprintManager::parseDisplayInfo(const nlohmann::json &json)
{
    return data::DisplayInfoBulueprint{
        json.value("name", "none"),
        json.value("description", "无")
    };
}

data::EnemyBlueprint BlueprintManager::parseEnemy(const nlohmann::json &json)
{
    return data::EnemyBlueprint{
        json.value("ranged", false),
        json.value("speed", 20.0f)
    };
}

EntityFactory:实体工厂 用蓝图创建实体

实体工厂,创建实体,这节使用了敌人单位的创建接口:

entt::entity createEnemyUnit(
    entt::id_type class_id,
    const glm::vec2& position,
    int target_way_id,
    int level = 1,
    int rarity = 1
);

entt::entity EntityFactory::createEnemyUnit(entt::id_type class_id, const glm::vec2 &position, int target_way_id, int level, int rarity)
{
    const auto &blueprint = blueprint_manager_.getEnemyClassBlueprint(class_id);
    auto entity = registry_.create();
    addTransformComponent(entity, position);
    addSpriteComponent(entity, blueprint.sprite_);
    addAnimationComponent(entity, blueprint.animations_, blueprint.sprite_, "walk"_hs);
    addAudioComponent(entity, blueprint.sound_);
    addEnemyComponent(entity, blueprint.enemy_, target_way_id);
    addStatsComponent(entity, blueprint.stats_, level, rarity);

    registry_.emplace<game::component::ClassNameComponent>(entity, blueprint.class_id_, blueprint.display_info_.name_);
    registry_.emplace<engine::component::RenderComponent>(entity);

    return entity;
}

创建实体 ---> 通过蓝图管理器拿到蓝图 ---> 添加必要组件

新增数值组件 + 数值缩放

struct StatsComponent {
    float hp_{};
    float max_hp_{};
    float atk{};
    float def{};
    float range_{}; // range of attack
    float atk_interval_{};
    float atk_timer_{};
    int level_{1};    // level of the entity, higher level means more stats
    int rarity_{1};   // rarity of the entity, higher rarity means more stats
};

并在引擎工具里加入了一个非常“游戏化”的数值缩放函数:

// src/engine/utils/math.h
inline float statModify(float base, int level = 1, int rarity = 1) {
    return base * (0.95f + 0.05f * level) * (0.9f + 0.1f * rarity);
}

新增方向与类型标签FaceLeft / Melee / Ranged

本节在 src/game/defs/tags.h 里新增了几个标签组件:

  • FaceLeftTag:某些 sprite 默认朝左(face_right=false),可用标签驱动“翻转显示”
  • MeleeUnitTag / RangedUnitTag:区分近战/远程,为后续攻击系统铺路

工厂会在创建敌人时自动补这些标签:

if (!sprite.face_right_) registry_.emplace<game::defs::FaceLeftTag>(entity);
if (enemy.ranged_) registry_.emplace<game::defs::RangedUnitTag>(entity);
else registry_.emplace<game::defs::MeleeUnitTag>(entity);

GameScene 接入:从手动创建测试敌人到工厂批量生成

GameScene 新增了初始化流程 initEnemyBlueprints()

// 初始化蓝图
blueprint_manager_ = std::make_unique<game::factory::BlueprintManager>(context_.getResourceManager());
// 初始化实体工厂
entity_factory_ = std::make_unique<game::factory::EntityFactory>(registry_,*blueprint_manager_);

bool GameScene::initEnemyBlueprints()
{
    if(blueprint_manager_->loadEnemyClassBlueprints("assets/data/enemy_data.json")){
        spdlog::info("敌人蓝图加载成功");
        return true;
    }
    return false;
}

然后测试函数 createTestEnemy() 就变得非常干净:只需要指定类型与起点即可:

// src/game/scene/game_scene.cpp(节选)
entity_factory_->createEnemyUnit("wolf"_hs, position, start_index);
entity_factory_->createEnemyUnit("slime"_hs, position, start_index);
entity_factory_->createEnemyUnit("goblin"_hs, position, start_index);
entity_factory_->createEnemyUnit("dark_witch"_hs, position, start_index);

这样,后期新增敌人的成本收益就很高了,只要往enemy_data.json里加一段配置,然后在需要生成的地方调用createEnemyUnit("new_enemy"_hs,...)

章节总结

学习了蓝图和实体工厂;了解了数据驱动的强大;职责分离的重要性,蓝图管理器只负责解析数据,保存蓝图,实体工厂负责创建实体;

posted @ 2026-04-01 19:43  wenyiGamecpp  阅读(38)  评论(0)    收藏  举报