gecs 是一个参考了EnTT源码结构,和Bevy-ECSAPI的用于游戏开发的ECS系统。采用C++17。
V1.0.0版本使用EnTT一样的SparseSet为核心数据结构。
demo下有一个完整的例子
gecs的API大量借鉴了bevy游戏引擎,下面是一个简单例子:
// 包含头文件
#include "gecs/gecs.hpp"
using namespace gecs;
// 一个component类型
struct Name {
std::string name;
};
// 一个resource类型
struct Res {
int value;
};
// 每帧都会更新的system
void update_system(commands cmds, querier<Name> querier, resource<Res> res) {
for (auto& [_, name] : querier) {
std::cout << name.name << std::endl;
}
std::cout << res->value << std::endl;
}
int main() {
world world;
// 得到Lambda对应的函数指针
constexpr auto startup = +[](commands cmds) {
auto entity1 = cmds.create();
cmds.emplace<Name>(entity1, Name{"ent1"});
auto entity2 = cmds.create();
cmds.emplace<Name>(entity2, Name{"ent2"});
cmds.emplace_resource<Res>(Res{123});
};
// 注册这个函数指针
world.regist_startup_system<startup>();
// 使用普通函数
world.regist_update_system<update_system>();
// 使用函数指针也可
// world.regist_update_system<&update_system>();
world.startup();
world.update();
return 0;
}world 是整个ECS的核心类,管理几乎所有ECS数据。
使用默认构造函数创建一个即可:
gecs::world world;一个典型的ECS程序一般如下:
// 创建world
gecs::world world;
// 声明一个registry
auto& reg = gaming_world.regist_registry("gaming");
// 注册startup system
reg.regist_starup_system<your_startup_system1>();
reg.regist_starup_system<your_startup_system2>();
...
// 注册update system
reg.regist_update_system<your_update_system1>();
reg.regist_update_system<your_update_system2>();
...
// 启动ECS
world.startup();
// 游戏循环中每帧更新ECS
while (shouldClose()) {
world.update();
}
// 结束ECS
// 也可以不调用,world析构时会自动调用
world.shutdown(); world是由多个registry组成的。registry中存储着有关的entity,component,system。只有resource是在各个registry间是通用的。
使用world.regist_registry(name)注册一个registry。然后可以将系统注册在此registry上。
一般来说你不会使用超过一个的registry。
system分为两种:
startup system:在启动时执行一次,主要用于初始化数据update system:每帧运行一次
system不是std::function类型,而是普通函数类型。所以若想使用lambda,则不能有任何捕获。
system没有固定的函数声明,但只能包含零个或多个querier/resource/commands。参数顺序没有要求。
startup system使用regist_startup_system即可注册:
world.regist_startup_system<your_startup_system>();update system使用regist_update_system即可注册:
// 使用lambda,无捕获的lambda会被转换为普通函数,在lambda前面加`+`可以获得对应函数类型
// 含有两个querier和一个resource
world.regist_update_system<+[](querier<Name> q1, querier<Family> q2, resource<FamilyBook> res)>();
// 含有一个commands和一个q1
world.regist_update_system<+[](commands cmd, querier<Name> q1)>();querier用于从world中查询拥有某种组件的实体,一般作为system的参数:
// q1查询所有含有Name实体的组件,q2查询所有函数Family实体的组件。并且Name组件不可变,Family可变
void update_system(querier<Name> q1, querier<mut<Family>> q2) { ... }只有使用mut<T>模板包裹组件类型时,才能够得到可变组件。这是为了之后对各个系统进行并行执行打下基础。
可以直接遍历querier来得到所有实体和对应组件:
for (auto& [entity, name] : q1) {
...
}
// 组件很多时按querier类型中声明的顺序得到
for (auto& [entity, comp1, comp2, comp3] : multi_queirer) {
...
}可以使用一些条件来进行查询:
only<Ts...>:要求实体只能拥有指定的组件,使用此条件时不能有其他参数:void update_system(querier<only<Comp1, Comp2>> q); // 会查询所有只含有Comp1, Comp2的组件 void update_system(querier<Comp1, only<Comp2, Comp3>>); // 非法!only只能单独存在且只有一个
without<T>:要求实体不能拥有此组件,语句中只能有一个without,并且语句中必须含有其他的无条件查询类型:void system(querier<Comp1, without<Comp2>>); //查询所有含有Comp1但不含有Comp2的组件 void system(querier<Comp1, without<Comp2, Comp3>>); // 查询所有含Comp1,但不含有Comp2和Comp3的组件 void system(querier<without<Comp2>>); // 非法!必须含有至少一个无查询条件的类型
resource则是对资源的获取。资源是一种在ECS中唯一的组件:
void system(resource<Name> res) {
// 通过operator->直接获得。不存在资源会导致程序崩溃!
res->name = "ent";
}commands是用于向world中添加/删除实体/组件/资源的类:
void system(commands cmds) {
// 创建entity
auto entity = cmds.create();
// 附加组件到实体
cmds.emplace<Name>(entity, Name{"ent"});
// 从实体上删除组件
cmds.remove<Name>(entity);
// 删除实体及其所有组件
cmds.destroy(entity);
// 设置资源
cmds.emplace_resource<Res>(Res{});
// 移除并释放资源
cmds.remove_resource<Res>();
}有些组件必须一起创建才能正常工作,而Bundle可以一次性创建多个组件以防遗忘:
Bundle不是一个具体类,而是用户自定义的POD类。类中的所有成员变量会被作为component附加在entity上:
struct Comp1 {};
struct Comp2 {};
// 定义一个bundle
struct CompBundle {
Comp1 comp1;
Comp2 comp2;
};
// in main():
cmds.emplace_bundle<CompBundle>(entity, CompBundle{...});创建之后entity将会拥有Comp1和Comp2两个组件。
registry是当前world中的registry类型,保存着和此registry有关的所有entity,component,system的信息。并且可以对其进行任意操作。
不到万不得已不推荐使用此类型。
要想使用可以在system中通过gecs::registry得到,多个registry底层是同一个(当前registry)
void system(gecs::registry reg);state用于切换registry中的状态。每个state都存储着一系列的system。通过切换state可以快速在同一个registry中切换不同的功能。
使用registry.add_state(numeric)来创建一个state。state由整数或者枚举表示(推荐枚举):
enum class States {
State1,
State2,
};
registry.add_state(States::State1);使用如下方法向state中添加一个系统:
// 添加一个开始系统
registry.regist_enter_system_to_state<OnEnterWelcome>(GameState::Welcome)
// 添加一个退出系统
.regist_exit_system_to_state<OnExitWelcome>(GameState::Welcome)
// 添加一个更新系统
.regist_update_system_to_state<FallingStoneGenerate>(GameState::Welcome);每次切换state的时候,都会调用当前state的所有exit system,并调用新state的所有enter system。切换state使用:
registry.switch_state_immediatly(state);
registry.switch_state(state);来切换state。switch_state()会延迟到这一帧结束时切换。
state的系统和registry中的系统执行顺序如下:
registry::startup
|
state::enter
|
|<--------------
| |
registry::update |
| game loop
state::update |
| |
|--------------|
|
state::exit
|
registry::shutdown使用registry可以直接增加/删除系统:
// 为registry增加/删除系统
registry.regist_startup_system<Sys>();
registry.remove_startup_system<Sys>();
// 为state增加/删除系统
registry.regist_enter_system_to_state<Sys>(State::State1);
registry.remove_enter_system_to_state<Sys>(State::State1);注意:为registry增加/删除的系统会立刻应用上,而为state增加/删除的系统会在world.update()末尾附加上。
这意味着在某个startup系统中,可以为registry的startup系统增加新系统,增加的系统在之后会被执行。而若在state的某个enter system中新增另一个enter system,则毫无意义,因为新增的system会在state的所有enter system调用完毕后再附加在state上。除非你从另一个state切换到这个state,这样此state会再次调用所有的enter system(包括后附加的)。
exit system同理。
目前暂不支持在system中提供增加/删除registry system的方法,因为这样做会导致system混乱。原则上来说,registry的system只能在ECS启动前完成全部初始化。但是可以在运行时改变state的system(通过registry)。
signal类似于Qt的信号槽或Godot的signal。用于更好地实现观察者模式。
在system声明中,可以使用event_dispatcher<T>来注册/触发/缓存一个T类型事件:
void Startup(gecs::commands cmds, gecs::event_dispatcher<SDL_QuitEvent> quit,
gecs::event_dispatcher<SDL_KeyboardEvent> keyboard);event_dispatcher可以链接多个回调函数,以便于在事件触发时自动调用此函数:
constexpr auto f = +[](const SDL_QuitEvent& event,
gecs::resource<gecs::mut<GameContext>> ctx) {
ctx->shouldClose = true;
};
// 使用sink()函数获得信号槽,然后增加一个函数
quit.sink().add<f>();函数会按照加入的顺序被调用。
函数的声明和system一样,只是第一个参数必须是事件类型T相关的const T&。
删除事件回调函数也是通过信号槽删除,这里不再演示。
想要缓存新事件,可以使用enqueue()函数:
void EventDispatcher(gecs::resource<gecs::mut<GameContext>> ctx,
gecs::event_dispatcher<SDL_QuitEvent> quit,
gecs::event_dispatcher<SDL_KeyboardEvent> keyboard) {
while (SDL_PollEvent(&ctx->event)) {
if (ctx->event.type == SDL_QUIT) {
// 放入一个SDL_QuitEvent
quit.enqueue(ctx->event.quit);
}
if (ctx->event.type == SDL_KEYDOWN || ctx->event.type == SDL_KEYUP) {
// 放入一个SDL_KeyboardEvent
keyboard.enqueue(ctx->event.key);
}
}
}缓存的事件会被放在缓存列表里,可以被一次性全部触发:
quit.trigger_cached();
// 如果有必要,触发完不要忘了删除所有缓存事件
quit.clear_cache();
// 或者,也可以调用update()自动做上面两个事情
quit.update();如果不触发,在world.update()的最后(所有update system调用后)会自动触发所有事件并删除。
如果想要立刻触发,使用:
quit.trigger(YourQuitEvent);来触发。
为了测试ECS的稳定性,编写了一个Demo。默认是不编译的,需要SDL2库。请在根目录下运行。
