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

实现流程是:

  1. 读入 JSON
  2. 遍历顶层 class_name -> data_json
  3. 复用已有解析函数:parseStats/parseSprite/parseAnimations/parseSounds/parseDisplayInfo
  4. 新增 parsePlayer(data_json)
  5. 组装 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)

装配流程:

  1. 取蓝图:getPlayerClassBlueprint(class_id)
  2. 基础组件:Transform/Sprite/Animation/Audio
  3. 玩法组件:PlayerComponent + BlockerComponent
  4. 数值组件:StatsComponent
  5. 标签组件:MeleeUnitTag / RangedUnitTag / HealerTag
  6. 通用标识: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.h
  • src/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 &registry, 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});
            }
        }
    }


}

每帧做两件事:

  1. 清理失效阻挡关系
  • 遍历 BlockedByComponent
  • 如果 blocker 实体无效,移除 BlockedByComponent
  • 发送 PlayAnimationEvent{entity, "walk"_hs, true}
  1. 建立新的阻挡关系
  • 遍历所有“未被阻挡敌人”(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 &registry, 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::PlayAnimationEventsrc/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 &registry, 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 &registry, 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()来解决这个问题。

posted @ 2026-04-08 20:23  wenyiGamecpp  阅读(25)  评论(0)    收藏  举报