ECS框架-设置移动路径

设定移动路径

前面我们完成了将关卡从Tiled载入到ECS世界,并完成了背景色,渲染顺序,gid翻转等细节,这节我们将完成敌人从开始点出发,沿着预设路径走到终点。我们需要实现路径点的定义、路径加载、敌人移动逻辑以及路径跟随系统的实现。

  • 利用Tiled中点对象画出路径节点,并用属性把节点连成图
  • 关卡加载时解析点对象,生成waypoint_nodes_(节点表)与start_points_(起点列表)
  • 为敌人引入 EnemyComponent(目标节点 id + 移动速度)
  • 新增 FollowPathSystem:每帧根据路径节点更新敌人的速度,让 MovementSystem 驱动位移

章节目标

  • 理解两种路径表示:单一路径(vector<point>)与分叉路径(节点图)
  • 定义路径节点结构 WaypointNode { id, position, next_node_ids },并维护 unordered_map<int, WaypointNode>
  • 通过自定义 Builder(EntityBuilderMW)在关卡加载阶段解析路径点对象
  • 实现 FollowPathSystem:到达节点后切换下一个节点(若有多个则随机选择),到达终点则发送事件并标记删除
  • 引入“延迟删除”模式:DeadTag + RemoveDeadSystem

路径节点数据结构设计

如果只有一条固定路线,那么只要记录所有点,然后敌人遍历朝点移动就行了;最简单的结构确实是:

  • std::vector<glm::vec2> waypoints;
  • 敌人只要保存当前下标i,到点了就i++

但是塔防游戏中经常出现 分叉路径,这时候,下标记录递增就不行了,我们创建了 WaypointNode 结构体,包含:

  • id_: 节点唯一标识符
  • position_: 节点在世界坐标系中的位置
  • next_node_ids: 下一个可到达节点的ID列表,支持分支路径

这样我们可以将路径看成一张有向图,更符合塔防的设计

// src/game/data/waypoint_node.h
struct WaypointNode {
    int id_;
    glm::vec2 position_;
    std::vector<int> next_node_ids_;
};

我们的游戏场景GameScene中维护两份运行数据

std::unordered_map<int, game::data::WaypointNode> waypoint_nodes_;
std::vector<int> start_points_;

Tiled里画路径:用点对象+属性连成图

新建对象图层path,每个节点都是一个 point对象.tmj中会出现"point":true

类似这样:

{
    "height":0,
    "id":83,
    "name":"",
    "point":true,
    "properties":[
        {
            "name":"next",
            "type":"object",
            "value":84
        }, 
        {
            "name":"start",
            "type":"bool",
            "value":true
        }],
    "rotation":0,
    "type":"",
    "visible":true,
    "width":0,
    "x":-44,
    "y":986
}
  • id:Tiled 为对象分配的唯一编号(我们直接拿它当 waypoint id)
  • next:对象类型属性,value 会填“目标对象的 id”
  • start:可选,标记这个节点是一个起点(出怪口)

当然也有可能是多分支,next/next2/...,表示该节点可能会随机选一条路。

对象 id 是 Tiled 自动维护的。一般“改位置/改属性”不会变,但如果你删除对象再新建,id 可能会变化;这会影响 next 的引用关系。

关卡加载器扩展

之前我们引入建造者模式,LevelLoader负责解析JSON,Builder负责把数据变成实体/组件。这节我们更进一步,我们需要让游戏侧可以解析知道自定义的形状对象,所以我们扩展了 EntityBuilderMW 类,继承于BasicEntityBuilder,持有游戏场景的两份节点的引用,支持从地图文件加载路径:

  • 解析Tiled地图编辑器导出的JSON格式
  • 识别路径点对象并构建路径网络
  • 支持标记起始点作为刷怪位置
  • 建立节点间的连接关系
// src/game/loader/entity_builder_mw.h
#pragma once
#include <unordered_map>
#include <vector>
#include "../../engine/loader/basic_entity_builder.h"
#include "../data/waypoint_node.h"


namespace game::loader {

class EntityBuilderMW : public engine::loader::BasicEntityBuilder {
private:
    // 存放场景中的位置点的引用
    std::unordered_map<int, data::WaypointNode>& waypoint_nodes_;
    std::vector<int>& start_nodes_;
public:
    // 构造函数
    EntityBuilderMW(
        engine::loader::LevelLoader& loader, 
        engine::core::Context& context, 
        entt::registry& registry,
        std::unordered_map<int, data::WaypointNode>& waypoint_nodes,
        std::vector<int>& start_nodes
    );
    
    EntityBuilderMW* build() override;

private:
    void buildPath(); // 构建路径

};

}
// src/game/loader/entity_builder_mw.cpp
#include "entity_builder_mw.h"
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>

namespace game::loader {
    EntityBuilderMW::EntityBuilderMW(
        engine::loader::LevelLoader &loader, 
        engine::core::Context &context, 
        entt::registry &registry, 
        std::unordered_map<int, data::WaypointNode> &waypoint_nodes,
        std::vector<int> &start_nodes)
    : BasicEntityBuilder(loader, context,registry),
      waypoint_nodes_(waypoint_nodes),
      start_nodes_(start_nodes)
    {}

    EntityBuilderMW *EntityBuilderMW::build()
    {
        if(object_json_ && !tile_info_){
            buildPath();
        } else {
            BasicEntityBuilder::build();
        }
        return this;
    }

    void EntityBuilderMW::buildPath()
    {
        if(object_json_->value("point",false)){
            glm::vec2 position = glm::vec2(object_json_->value("x",0),object_json_->value("y",0));
            int id = object_json_->value("id",0);
            std::vector<int> next_point_ids; 
            for(auto &property : object_json_->at("properties")){
                if(property.value("name","").starts_with("next")){
                    // 如果有下一个点,则添加到路径中
                    int next_id = property.value("value",0);
                    if(next_id != 0){
                        next_point_ids.push_back(next_id);
                    }
                } else if(property.value("name","") == "start"){
                    // 如果有开始标志,则添加到开始点列表中,表示刷怪点
                    if(property.value("value",false)){
                        start_nodes_.push_back(object_json_->value("id",0));
                    }
                }
            }
            waypoint_nodes_[id] = data::WaypointNode{id, std::move(position), std::move(next_point_ids)};
        }
    }
}

我们的关键函数就是重写的build函数了,形状对象走buildPath,其他走父类的正常构建。

接入GameScene,将自定义 Builder 注入 LevelLoader

GameScene::loadLevel()通过setEntityBuilder()把Builder换成子类建造器。

bool GameScene::loadLevel()
{
    engine::loader::LevelLoader loader;
    loader.setEntityBuilder(std::make_unique<game::loader::EntityBuilderMW>(loader,
                                                                            context_, registry_, waypoint_nodes_, start_nodes_));
    if(loader.levelLoad("assets/maps/level1.tmj", this)){
        spdlog::info("关卡加载成功");
        return true;
    }
    return false;
}

这样关卡加载完毕时,我们就同时拥有:

  • 地图/装饰等 ECS 实体(由引擎 Builder 负责)
  • 路径节点表 waypoint_nodes_ 与起点列表 start_points_(由游戏 Builder 负责)

敌人组件设计

敌人的数据结构需要知道目标节点和速度:

  • target_waypoint_id_: 当前目标路径点ID
  • speed_: 敌人移动速度

路径跟随系统

FollowPathSystem 系统,主要功能:

  • 根据敌人当前位置和目标路径点计算移动方向
  • 检测敌人是否到达目标路径点
  • 自动切换到下一个路径点(支持随机选择分支)
  • 处理敌人到达终点的逻辑

所以该系统筛选条件也很清晰,必须同时拥有:TransformComponent、VelocityComponent、EnemyComponent

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>();

    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_;

    }
}

随机选择的功能:

// src/engine/utils/math.h
// 随机生成一个在[min, max]范围内的整数,采用梅森旋转算法
inline int randomInt(int min, int max) {
    // static thread_local 表示该变量在每个线程中各自独立,互不影响,避免多线程下的竞争条件
    static thread_local std::mt19937 generator{std::random_device{}()};
    std::uniform_int_distribution<int> distribution(min, max);
    return distribution(generator);
}

延迟删除:DeadTag + RemoveDeadSystem

当敌人到达终点时,我们希望可以摧毁敌人,并产生相应游戏逻辑,所以我们做了两件事:

  1. dispatcher.enqueue<EnemyArriveHomeEvent>():发一个事件(后续加基地扣血/失败判定)
  2. registry.emplace<DeadTag>(entity):先打上“死亡标签”,不要立刻 destroy

DeadTag是一个空结构体,用作EnTT的标签组件,其实我们之前用的速度、位置、精灵等等也是纯粹的结构体,这里只不过是空的。

struct DeadTag{};

然后,我们需要一个清理系统,在"下一帧开头"清理:

void RemoveDeadSystem::update(entt::registry &registry){
    auto view = registry.view<game::defs::DeadTag>();
    for(auto entity : view){
        registry.destroy(entity);
    }
}

这样做的好处是:

  • 避免在系统遍历 view 的过程中直接销毁实体导致迭代器问题
  • 事件是 enqueue 的,会在 dispatcher.update() 时统一分发;“下一帧清理”更符合事件队列的时序

GameScene 创建敌人

GameScene::init() 里新增了三件事:

  • 初始化事件连接(监听 EnemyArriveHomeEvent
  • 创建测试敌人(每个起点来一个)
  • 把新系统插入 update 链路

事件连接:敌人到家了先打日志

// 添加回调函数
context_.getDispatcher().sink<game::defs::EnemyArriveHomeEvent>()
    .connect<&GameScene::onEnemyArriveHome>(this);

创建测试敌人 从start_points_出发

void GameScene::createEnemy()
{
    for (auto start_index : start_nodes_) {
        auto position = waypoint_nodes_[start_index].position_;

        auto enemy = registry_.create();
        registry_.emplace<engine::component::TransformComponent>(enemy, position);
        registry_.emplace<engine::component::VelocityComponent>(enemy, glm::vec2(0, 0));
        registry_.emplace<game::component::EnemyComponent>(enemy, start_index, 100.0f);

        auto sprite = engine::component::Sprite("assets/textures/Enemy/wolf.png", engine::utils::Rect{0, 0, 192, 192});
        // 设置精灵组件时,需设置偏移量以调整中心点位置(否则会默认以左上角为中心点)
        registry_.emplace<engine::component::SpriteComponent>(enemy, std::move(sprite), glm::vec2(192, 192), glm::vec2(-96, -128));
        // 暂定主战斗图层编号为10
        registry_.emplace<engine::component::RenderComponent>(enemy, 10);
    }
}

Sprite 的 offset 用来调整“锚点”,让敌人的逻辑位置更贴近脚底/中心(方便路径点对齐)

系统更新顺序

  1. 先清理死亡实体(上一帧标记的 DeadTag)
  2. FollowPathSystem 更新速度(依赖当前位置)
  3. MovementSystem 更新位置(依赖速度)
  4. YSortSystem 更新深度(依赖新位置)
void GameScene::update(float delta_time)
{
    auto &dispatch = context_.getDispatcher();

    remove_dead_system_->update(registry_);
    follow_path_system_->update(registry_, dispatch, waypoint_nodes_);
    movement_system_->update(registry_, delta_time);
    animation_system_->update(registry_, delta_time);
    ysort_system_->update(registry_); // 注意顺序

    Scene::update(delta_time);
}

章节小结

  • WaypointNode + unordered_map 表示分叉路径,并用 start_points_ 支持多起点
  • 在 Tiled 用 point 对象画节点,用 next/next2...(object 类型)表示连线,用 start 标记起点
  • 通过自定义 Builder EntityBuilderMW 在关卡加载阶段解析路径点对象
  • FollowPathSystem 计算方向并更新 VelocityComponent,让 MovementSystem 驱动位移
  • DeadTag + RemoveDeadSystem 实现延迟删除;终点触发 EnemyArriveHomeEvent

遇到的问题

1.链接错误,randomInt函数在头文件src/engine/utils/math.h中被定义为非内联函数,导致多个源文件包含该头文件时产生重复定义。将int randomInt(int min, int max)改为inline int randomInt(int min,int max),使函数成为内联函数,允许多个翻译单元包含相同的定义。顺带提一下,math.h中还有一个parseHexColor的函数,那为什么它没有事?因为constexpr函数的特性,自动具有 inline 的行为,编译器求值 constexpr

posted @ 2026-03-31 11:07  wenyiGamecpp  阅读(46)  评论(0)    收藏  举报