ECS框架-玩家蓝图和阻挡组件
玩家蓝图和阻挡组件
上节我们完成了敌人的蓝图和实体工厂,已经把“敌人创建”从硬编码 emplace<...>() 切到数据驱动。
这一节继续把同一套思想扩展到玩家单位:
- 新增 PlayerBlueprint / PlayerClassBlueprint(玩家蓝图)
- 在
BlueprintManager增加玩家蓝图解析与缓存 - 在
EntityFactory增加createPlayerUnit() - 新增
BlockSystem,让近战玩家可以“拦住”敌人
这样就把“玩家生成 + 阻挡行为”都从散落逻辑收拢到了统一结构里。
学习目标
- 理解玩家蓝图的最小必要字段:
type/healer/block/cost/skill - 学会把
player_data.json解析为PlayerClassBlueprint - 学会在工厂中一键装配玩家组件(渲染、动画、数值、职业标签、阻挡能力)
- 建立阻挡关系:
BlockerComponent(阻挡者) +BlockedByComponent(被阻挡者) - 通过
entt::dispatcher触发动画事件(walk/attack)
一、玩家蓝图数据结构
文件:src/game/data/entity_blueprint.h
在原有敌人蓝图的基础上新增了:
struct PlayerBlueprint {
game::defs::PlayerType type_ {game::defs::PlayerType::UNKNOWN};
entt::id_type skill_id_{entt::null};
bool healer_{false};
int block_{0};
int cost_{0};
};
struct PlayerClassBlueprint {
entt::id_type class_id_{entt::null};
std::string class_name_;
StatsBlueprint stats_;
SpriteBlueprint sprite_;
SoundBlueprint sound_;
PlayerBlueprint player_;
DisplayInfoBulueprint display_info_;
std::unordered_map<entt::id_type, AnimationBlueprint> animations_;
};
PlayerBlueprint--- 玩家专属的一些属性字段PlayerClassBlueprint复用已有通用块(stats/sprite/animations/sound/display)
二、玩家蓝图 JSON 与解析
类似的和enemy_data.json一样,我们有player_data.json
当前每个玩家条目都有这些核心键:
- 数值:
hp/atk/def/range/atk_interval - 玩家属性:
type/healer/block/cost/skill - 美术动画:
sprite_sheet/width/height/offset/animation - 音效:
sounds
例如:
"warrior": {
"type": "melee",
"healer": false,
"block": 3,
"cost": 10,
"skill": "shield"
}
1)BlueprintManager 新增玩家容器
文件:src/game/factory/blueprint_manager.h
std::unordered_map<entt::id_type, game::data::PlayerClassBlueprint> player_class_blueprints_;
并提供接口:
loadPlayerClassBlueprints(std::string_view path)getPlayerClassBlueprint(entt::id_type id)
2)loadPlayerClassBlueprints 主流程
文件:src/game/factory/blueprint_manager.cpp
实现流程是:
- 读入 JSON
- 遍历顶层
class_name -> data_json - 复用已有解析函数:
parseStats/parseSprite/parseAnimations/parseSounds/parseDisplayInfo - 新增
parsePlayer(data_json) - 组装
PlayerClassBlueprint入表
核心价值:玩家蓝图和敌人蓝图形成“平行管线”,工厂端用法一致,后面维护成本低。
3)parsePlayer 解析逻辑
data::PlayerBlueprint BlueprintManager::parsePlayer(const nlohmann::json &json)
{
auto type_str = json["type"].get<std::string>();
auto type = type_str == "melee" ? game::defs::PlayerType::MELEE
: type_str == "ranged" ? game::defs::PlayerType::RANGED
: game::defs::PlayerType::UNKNOWN;
entt::id_type skill_id = entt::null;
if(json.contains("skill")){
skill_id = entt::hashed_string(json["skill"].get<std::string>().c_str());
}
return data::PlayerBlueprint{
type, skill_id, json.value("healer", false),
json.value("block",0), json.value("cost", 0)
};
}
这里已经把“字符串类型 -> 枚举类型”做了收敛,对后面系统判断很有帮助。
三、实体工厂:createPlayerUnit
文件:src/game/factory/entity_factory.cpp
这次新增了玩家创建入口:
entt::entity EntityFactory::createPlayerUnit(entt::id_type class_id, const glm::vec2 &position, int level, int rarity)
装配流程:
- 取蓝图:
getPlayerClassBlueprint(class_id) - 基础组件:
Transform/Sprite/Animation/Audio - 玩法组件:
PlayerComponent + BlockerComponent - 数值组件:
StatsComponent - 标签组件:
MeleeUnitTag / RangedUnitTag / HealerTag - 通用标识:
ClassNameComponent + RenderComponent
addPlayerComponent 的细节
auto cost = static_cast<int>(std::round(player.cost_ * (0.9f + 0.1f * rarity)));
registry_.emplace<game::component::PlayerComponent>(entity, cost);
registry_.emplace<game::component::BlockerComponent>(entity, player.block_);
if(player.healer_) registry_.emplace<game::defs::HealerTag>(entity);
优点:
cost也接入稀有度缩放,和 stats 的成长思想一致- 近战/远程/治疗通过 Tag 输出给后续系统,扩展性很好
- 阻挡能力通过
block数据化,可直接在 JSON 调平衡
四、阻挡系统设计:Blocker + BlockedBy
新增组件:
src/game/component/blocker_component.hsrc/game/component/blocked_by_component.h
struct BlockerComponent {
int max_count_{};
int current_count_{};
};
struct BlockedByComponent {
entt::entity entity_{entt::null};
};
设计含义:
- 阻挡者(玩家)记录“最多能挡几人、当前挡了几人”
- 被阻挡者(敌人)记录“我被谁挡住了”
这个双向关系非常适合 ECS:轻量、明确、便于系统拆分。
BlockSystem 主流程
文件:src/game/system/block_system.cpp
void BlockSystem::update(entt::registry ®istry, entt::dispatcher &dispatcher)
{
// 阻挡者是否有效
auto view_blocked_by = registry.view<component::BlockedByComponent>();
for(auto blocked_by_entity : view_blocked_by) {
auto &blocked_by_comp = view_blocked_by.get<component::BlockedByComponent>(blocked_by_entity);
if(!registry.valid(blocked_by_comp.entity_)) {
// 如果BlockedBy指向实体无效(死亡)(阻挡者骑士死了),移除被阻挡组件(敌人应该会继续移动),并发送播放动画"walk"事件
registry.remove<game::component::BlockedByComponent>(blocked_by_entity);
dispatcher.enqueue(engine::utils::PlayAnimationEvent{blocked_by_entity, "walk"_hs, true});
spdlog::info("阻挡者无效, id: {} 移除阻挡者组件", entt::to_integral(blocked_by_entity));
}
}
// 建立阻挡关系
// 获取所有阻挡者
auto view_blocker = registry.view<game::component::BlockerComponent, engine::component::TransformComponent>();
// 获取所有敌人,并排除已经被阻挡的敌人
auto view_enemy = registry.view<game::component::EnemyComponent,
engine::component::TransformComponent,
engine::component::VelocityComponent>
(entt::exclude<game::component::BlockedByComponent>
);
// 遍历所有敌人
for(auto enemy_entity : view_enemy) {
const auto& enemy_transform = view_enemy.get<engine::component::TransformComponent>(enemy_entity);
auto& enemy_velocity = view_enemy.get<engine::component::VelocityComponent>(enemy_entity);
// 遍历所有阻挡者
for(auto blocker_entity : view_blocker) {
const auto& blocker_transform = view_blocker.get<engine::component::TransformComponent>(blocker_entity);
auto& blocker_blocker = view_blocker.get<game::component::BlockerComponent>(blocker_entity);
// 如果被阻挡(检查之间的距离 是否小于阻挡半径)
if(engine::utils::distanceSquared(enemy_transform.position_, blocker_transform.position_) < game::defs::BLOCK_RADIUS * game::defs::BLOCK_RADIUS){
// 检查是否还能阻挡
if(blocker_blocker.current_count_ >= blocker_blocker.max_count_){
continue;
}
blocker_blocker.current_count_++; // 增加阻挡计数
enemy_velocity.velocity_ = glm::vec2{0, 0}; // 停止移动
// 敌人添加被阻挡组件
registry.emplace<game::component::BlockedByComponent>(enemy_entity, blocker_entity);
spdlog::info("敌人: ID: {}, 被阻挡, 阻挡者: ID: {}", entt::to_integral(enemy_entity), entt::to_integral(blocker_entity));
// 播放动画"attack"
dispatcher.enqueue(engine::utils::PlayAnimationEvent{enemy_entity, "attack"_hs, true});
}
}
}
}
每帧做两件事:
- 清理失效阻挡关系
- 遍历
BlockedByComponent - 如果 blocker 实体无效,移除
BlockedByComponent - 发送
PlayAnimationEvent{entity, "walk"_hs, true}
- 建立新的阻挡关系
- 遍历所有“未被阻挡敌人”(
exclude<BlockedByComponent>) - 与所有 blocker 比距离
- 若距离小于
BLOCK_RADIUS且 blocker 未满,则:blocker.current_count_++- 敌人速度置 0
- 给敌人添加
BlockedByComponent - 发送
PlayAnimationEvent{entity, "attack"_hs, true}
常量定义:src/game/defs/constants.h
constexpr float BLOCK_RADIUS = 40.0f;
五、让路径系统感知“被阻挡”
文件:src/game/system/follow_path_system.cpp
void FollowPathSystem::update(entt::registry ®istry, entt::dispatcher &dispatcher, std::unordered_map<int, data::WaypointNode> &nodes)
{
auto view = registry.view<engine::component::VelocityComponent,
engine::component::TransformComponent,
game::component::EnemyComponent>(entt::exclude<game::component::BlockedByComponent>);
for (auto entity : view) {
auto &velocity = view.get<engine::component::VelocityComponent>(entity);
auto &transform = view.get<engine::component::TransformComponent>(entity);
auto &enemy = view.get<game::component::EnemyComponent>(entity);
// 1.根据enemy.target_waypoint_id_ 找到目标节点
auto &target_node = nodes[enemy.target_waypoint_id_];
// 2.计算当前节点和目标节点的方向向量
auto direction = target_node.position_ - transform.position_;
// 3.判断是否达到节点(这里设置大点,防止抽搐)
if(glm::length(direction) < 5.0f) {
// 4.如果节点中没有下一节点,说明到头了,发送一个事件
if(target_node.next_node_ids.empty()){
dispatcher.enqueue<game::defs::EnemyArriveHomeEvent>();
registry.emplace<game::defs::DeadTag>(entity); // 标记为死亡
continue;
}
// 5.如果达到节点,则切换到下一个节点(随机)
int next = engine::utils::randomInt(0, target_node.next_node_ids.size() - 1);
enemy.target_waypoint_id_ = target_node.next_node_ids[next];
// 到节点后重新设置一下direction
direction = nodes[enemy.target_waypoint_id_].position_ - transform.position_;
}
// 6.计算速度
velocity.velocity_ = glm::normalize(direction) * enemy.speed_;
}
}
关键一行:
auto view = registry.view<...>(entt::exclude<game::component::BlockedByComponent>);
效果是:已被阻挡的敌人不会再参与寻路移动更新
六、动画事件链路打通
为了让阻挡状态切换动画,动画系统需要做事件接入:
engine::utils::PlayAnimationEvent(src/engine/utils/events.h)AnimationSystem构造时订阅事件(src/engine/system/animation_system.cpp)- 收到事件后切换
current_animation_id_并重置帧索引
所以现在 BlockSystem 不直接操作动画组件,而是发事件:
- 被挡住:
attack - 解除阻挡:
walk
实现了系统间的解耦,后面战斗系统也可以复用同一事件。
// animation_system.h
#pragma once
#include <entt/entity/fwd.hpp>
#include <entt/signal/fwd.hpp>
#include "../utils/events.h"
namespace engine::system {
class AnimationSystem {
entt::registry& registry_;
entt::dispatcher& dispatcher_;
public:
AnimationSystem(entt::registry& registry, entt::dispatcher& dispatcher);
~AnimationSystem();
void update(entt::registry& registry, float delta_time);
private:
// 动画事件处理函数
void onPlayAnimationEvent(const engine::utils::PlayAnimationEvent& event);
};
}
// animation_system.cpp
#include "animation_system.h"
#include "../component/animation_component.h"
#include "../component/sprite_component.h"
#include <entt/entity/registry.hpp>
#include <entt/signal/dispatcher.hpp>
namespace engine::system{
AnimationSystem::AnimationSystem(entt::registry ®istry, entt::dispatcher &dispatcher)
: registry_(registry), dispatcher_(dispatcher){
dispatcher_.sink<engine::utils::PlayAnimationEvent>().connect<&AnimationSystem::onPlayAnimationEvent>(this);
}
AnimationSystem::~AnimationSystem()
{
dispatcher_.disconnect(this);
}
void AnimationSystem::update(entt::registry ®istry, float delta_time)
{
auto view = registry.view<component::AnimationComponent, component::SpriteComponent>();
for (auto entity : view){
auto &anim_comp = registry.get<component::AnimationComponent>(entity);
auto &sprite_comp = registry.get<component::SpriteComponent>(entity);
// 动画如果不存在就跳过
auto it = anim_comp.animations_.find(anim_comp.current_animation_id_);
if (it == anim_comp.animations_.end()){
continue;
}
// 获取当前动画
auto& current_animation = it->second;
// 如果没有帧就跳过
if (current_animation.frames_.empty()){
continue;
}
// 更新当前的播放时间
anim_comp.current_time_ms_ += delta_time * 1000 * anim_comp.speed_;
// 获取当前帧
const auto& current_frame = current_animation.frames_[anim_comp.current_frame_index_];
// 进行判断,如果播放时间超过当前帧的持续时间,就切换到下一帧
if(anim_comp.current_time_ms_ >= current_frame.duration_ms_){
anim_comp.current_time_ms_ -= current_frame.duration_ms_;
anim_comp.current_frame_index_++;
// 动画播放完成
if (anim_comp.current_frame_index_ >= current_animation.frames_.size()){
if(current_animation.loop_) {
anim_comp.current_frame_index_ = 0;
} else {
// 不循环,停在最后一帧
anim_comp.current_frame_index_ = current_animation.frames_.size() - 1;
}
}
}
// 更新精灵组件的纹理
const auto& next_frame = current_animation.frames_[anim_comp.current_frame_index_];
sprite_comp.sprite_.src_rect_ = next_frame.source_rect_;
}
}
void AnimationSystem::onPlayAnimationEvent(const engine::utils::PlayAnimationEvent &event)
{
if (auto anim = registry_.try_get<component::AnimationComponent>(event.entity_); anim) {
anim->current_animation_id_ = event.anim_id;
anim->current_frame_index_ = 0;
anim->current_time_ms_ = 0;
anim->animations_.at(event.anim_id).loop_ = event.loop_;
}
}
}
七、GameScene 接入:初始化和测试输入
文件:src/game/scene/game_scene.cpp
初始化拆成了几步:
initEventConnections()initInputConnections()initEntityFactory()(同时加载 enemy + player 蓝图)
并在 update 中加入:
block_system_->update(registry_, dispatch);
测试输入映射:
- 左键:创建近战玩家(
warrior) - 右键:创建远程玩家(
archer) pause:清空玩家
bool GameScene::onCreateTestPlayerMelee()
{
spdlog::info("创建测试玩家近战");
auto input_manager = context_.getInputManager();
auto mouse_position = input_manager.getLogicalMousePosition();
entity_factory_->createPlayerUnit("warrior"_hs, mouse_position);
return true;
}
bool GameScene::onCreateTestPlayerRanged()
{
spdlog::info("创建测试玩家远程");
auto input_manager = context_.getInputManager();
auto mouse_position = input_manager.getLogicalMousePosition();
entity_factory_->createPlayerUnit("archer"_hs, mouse_position);
return true;
}
bool GameScene::onClearAllPlayers()
{
spdlog::info("清除所有玩家");
auto view = registry_.view<game::component::PlayerComponent>();
for(auto entity : view){
registry_.destroy(entity);
}
return true;
}
整体上形成了一条闭环:输入 -> 创建玩家 -> 敌人进入阻挡半径 -> 停止移动并切攻击动画。
章节总结
这章完成了玩家蓝图的导入,进一步感受到了数据驱动以及蓝图、工厂这样一个完整框架带来的可观收益,便利性高的同时效率也十分优秀;进一步加深了信号事件系统对于不同系统间的去耦作用。
遇到的问题
1.编译发生错误,定位在这里
void AnimationSystem::onPlayAnimationEvent(const engine::utils::PlayAnimationEvent &event)
{
if (auto anim = registry_.try_get<component::AnimationComponent>(event.entity_); anim) {
anim->current_animation_id_ = event.anim_id;
anim->current_frame_index_ = 0;
anim->current_time_ms_ = 0;
anim->animations_[event.anim_id].loop_ = event.loop_; //这里
}
}
anim->animations_[event.anim_id].loop_ = event.loop_;
这句话在event.anim_id 对应的动画不存在,std::unordered_map::operator[] 会自动创建一个新的 Animation对象,但是Animation构造函数需要参数,但是没写默认的构造函数,Animation(std::vector<AnimationFrame> frames, bool loop = true),所以会失败,可以改用at()来解决这个问题。

浙公网安备 33010602011771号