Skip to content

VisualGMQ/gecs

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

58 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

GECS

gecs 是一个参考了EnTT源码结构,和Bevy-ECSAPI的用于游戏开发的ECS系统。采用C++17。

使用方法

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

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

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和resource

querier

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

resource则是对资源的获取。资源是一种在ECS中唯一的组件:

void system(resource<Name> res) {
    // 通过operator->直接获得。不存在资源会导致程序崩溃!
    res->name = "ent";
}

commands

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将会拥有Comp1Comp2两个组件。

registry

registry是当前world中的registry类型,保存着和此registry有关的所有entity,component,system的信息。并且可以对其进行任意操作。 不到万不得已不推荐使用此类型。

要想使用可以在system中通过gecs::registry得到,多个registry底层是同一个(当前registry

void system(gecs::registry reg);

state

state用于切换registry中的状态。每个state都存储着一系列的system。通过切换state可以快速在同一个registry中切换不同的功能。

使用registry.add_state(numeric)来创建一个statestate由整数或者枚举表示(推荐枚举):

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

来切换stateswitch_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系统

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

来触发。

Demo

为了测试ECS的稳定性,编写了一个Demo。默认是不编译的,需要SDL2库。请在根目录下运行。

demo

About

An ECS framework referenced Bevy-ECS, EnTT

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published