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 ®istry,
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_: 当前目标路径点IDspeed_: 敌人移动速度
路径跟随系统
FollowPathSystem 系统,主要功能:
- 根据敌人当前位置和目标路径点计算移动方向
- 检测敌人是否到达目标路径点
- 自动切换到下一个路径点(支持随机选择分支)
- 处理敌人到达终点的逻辑
所以该系统筛选条件也很清晰,必须同时拥有:TransformComponent、VelocityComponent、EnemyComponent。
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>();
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
当敌人到达终点时,我们希望可以摧毁敌人,并产生相应游戏逻辑,所以我们做了两件事:
dispatcher.enqueue<EnemyArriveHomeEvent>():发一个事件(后续加基地扣血/失败判定)registry.emplace<DeadTag>(entity):先打上“死亡标签”,不要立刻destroy
DeadTag是一个空结构体,用作EnTT的标签组件,其实我们之前用的速度、位置、精灵等等也是纯粹的结构体,这里只不过是空的。
struct DeadTag{};
然后,我们需要一个清理系统,在"下一帧开头"清理:
void RemoveDeadSystem::update(entt::registry ®istry){
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 用来调整“锚点”,让敌人的逻辑位置更贴近脚底/中心(方便路径点对齐)
系统更新顺序
- 先清理死亡实体(上一帧标记的 DeadTag)
- FollowPathSystem 更新速度(依赖当前位置)
- MovementSystem 更新位置(依赖速度)
- 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

浙公网安备 33010602011771号