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”切到“按蓝图生成”
架构设计 蓝图管理器 + 实体工厂

核心原则:
- 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 - 移动与类型:
speed、ranged - 显示信息:
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:如wolfclass_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,...)
章节总结
学习了蓝图和实体工厂;了解了数据驱动的强大;职责分离的重要性,蓝图管理器只负责解析数据,保存蓝图,实体工厂负责创建实体;

浙公网安备 33010602011771号