entity与component系统
游戏逻辑内为了方便的对场景内的各种对象做统一的管理,同时解耦各种内聚的逻辑,基本都会做一个自己的entity/component系统。entity就是游戏内的对象,是对象数据的载体,而且是component的容器;而component则是各种业务逻辑的载体。随着业务逻辑的扩张,entity与component的类型会呈一个无限增加的趋势,所以需要一个entity/component系统来对这些变动中的类型执行管理,维护这些类型的创建与运行。
entity系统
在mosaic_game的客户端与服务端都维护了各自的entity系统,服务端的基类entity为server_entity,客户端的基类entity为client_entity。为了对这两种entity系统做统一化的管理,避免复制粘贴代码,mosaic_game中构造了一个模板化的entity_manager。由于这个entity_manager内部使用了太多的模板技巧,因此我们来循序渐进的介绍一下这些模板代码的演进过程。
工厂模式
在游戏业务代码中,对于重要的继承类型对象创建,一般都通过对象工厂进行委托。这样的好处是方便追溯对象的完整生命周期,实现对象的统一管理。mosaic_game中的EntityManager就是这样的一个工厂,这个工厂就负责了所有Entity的创建, 同时每个entity被创建之后,都会记录到EntityManager的内部map里,以方便其他模块根据entity的id进行查找。
最基本的对象工厂代码可以简略为下面的例子:
struct Animal
{
virtual void make_voice() = 0;
};
struct cat: public Animal
{
void make_voice()
{
std::cout<<"miaomiaomiao"<<std::endl;
}
};
struct dog: public Animal
{
void make_voice()
{
std::cout<<"wangwangwang"<<std::endl;
}
};
Animal* CreateAnimal(const std::string& name)
{
if(name == "cat")
{
return new cat();
}
else if(name == "dog")
{
return new dog();
}
else
{
return nullptr;
}
}
这种手动根据字符串进行if else判断的方式,完全基于人工,对于子类型少的时候还是比较方便的但是对于快速扩展的代码来说,就可能不够用了,例如unreal qt等框架都有自己的Object系统。主要问题包括两个:
- 人工字符串匹配有笔误的风险
- 每次添加新的子类型都需要更改
CreateAnimal函数,同时需要引入新对象的头文件,容易忘,需要提供自动化的机制
所以实践中一般来说不是手工输入类型名字的,而是在所有继承类型里都提供static std::string StaticClassName()这么一个方法,同时基类里声明virtual std::string ClassName() const的虚方法。前面的代码可以改成这样:
struct Animal
{
virtual void make_voice() = 0;
virtual std::string ClassName() const = 0;
static std::string StaticClassName();
};
struct cat: public Animal
{
static std::string StaticClassName()
{
return "cat";
}
};
struct dog: public Animal
{
static std::string StaticClassName()
{
return "dog";
}
};
Animal* CreateAnimal(const std::string& name)
{
if(name == cat::StaticClassName())
{
return new cat();
}
else if(name == dong::StaticClassName())
{
return new dog();
}
else
{
return nullptr;
}
}
这个CreateAnimal函数可以写成模板类型,避免外部的手动输入:
template <typename T>
Animal* CreateAnimal()
{
auto name = T::StaticClassName();
if(name == cat::StaticClassName())
{
return new cat();
}
else if(name == dong::StaticClassName())
{
return new dog();
}
else
{
return nullptr;
}
}
使用的时候就直接CreateAnimal<cat>()即可,非常方便。
在子类开始增多之后,一个个的if else去判定类型名字显得特别繁琐,且性能很差,所以我们可以进一步的修改为使用map来存储所有类型的构造信息,查找的时候根据map来查找即可。为此,我们需要对每个类型提供一个静态的create函数:
struct cat: public Animal
{
static Animal* create()
{
return new cat();
}
};
struct dog: public Animal
{
static Animal* create()
{
return new dog();
}
};
using creator_func_t = std::function<Animal*()>;
template <typename T>
Animal* CreateAnimal()
{
static std::unordered_map<std::string, creator_func_t> all_creators =
{
{cat::StaticClassName(), cat::create()},
{dog::StaticClassName(), dog::create()},
}
auto name = T::StaticClassName();
auto cur_iter = all_creators.find(name);
if(cur_iter == all_creators.end())
{
return nullptr;
}
else
{
return cur_iter->second();
}
}
事实上,我们可以把all_creators这个map与CreateAnimal分离开来,CreatorAnimal并不关心all_creators的内容是什么。这样我们就可以把all_creators的初始化与CreatorAnimal的实现分离开,头文件之间的耦合也就可以解开了。
using creator_func_t = std::function<Animal*()>;
using creator_map = std::unordered_map<std::string, creator_func_t>;
static creator_map& getCreatorMap()
{
static creator_map map_data;
return map_data;
}
template <typename T>
bool addToMap()
{
auto& map_data = getCreatorMap();
auto name = T::StaticClassName();
map_data[name] = []()
{
return new T();
};
return true;
}
template <typename T>
Animal* CreateAnimal()
{
auto name = T::StaticClassName();
auto& all_creators = getCreatorMap();
auto cur_iter = all_creators.find(name);
if(cur_iter == all_creators.end())
{
return nullptr;
}
else
{
return cur_iter->second();
}
}
在上面的定义下,每个继承的子类都只需要分别调用addToMap<T>这个函数注册当前类型进去即可,调用的位置并不需要中心化,可以散落在各个独立的编译单元。
所以剩下的问题就是,如何让这个类型在main函数执行之前调用各自的addtoMap<T>。这个时候我们就需要求救于类静态变量,因为类的静态变量的初始化是在main执行之前的。因此,我们对cat做进一步改造:
// in header file
struct cat: public Animal
{
static bool is_registered;
}
// in cpp file
bool cat::is_registered = addtomap<cat>;
这样只要cat类被引用到,他的is_registered就会初始化, 然后就会调用addtomap<cat>进行注册。
类型自动注册
每次继承一个子类,都需要声明is_registered这个静态变量,然后在cpp文件里利用addtomap来初始化一番,其实在类型多的时候也比较容易忘记。而且这个对于所有子类来说都是重复劳动,所以一般工程上来说,都是有宏来处理这种继承自动注册的。
#define REGISTER_CHAIN(T) bool isRegistered_##T = ObjectFactory::instance()->reg<T>(#T, T::create)
大家经常用的Gtest其实也用的是这种模式, 比方说下面的gtest代码:
Test(MyTest) {
int a = 3;
assert(a == 3);
}
展开后就成这样了:
class Test_MyTest {
public:
void execute();
static Test_MyTest create() { return Test_MyTest(); }
static bool registered = TestFactory::Register("MyTest", &Test_MyTest::create)
};
void Test_MyTest::execute() {
int a = 3;
assert(a == 3);
}
新时代的cpp爱好者不会止步于基于宏的解决方案,力争把宏都用template 和constexpr换掉:
template <class T>
struct sub_class : Animal
{
friend T;
static bool trigger()
{
return addtomap<T>;
}
static bool registered;
public:
sub_class() : Animal()
{
(void)registered;
}
};
template <class T>
sub_class<T>::registered = sub_class<T>::tigger();
上面的模板类,每次被实例化之后,都会调用对应的trigger函数来初始化,这样就通过模板解决了自动注册的问题。使用的时候,需要稍微绕一下:
class cat: public sub_class<cat>
{
}
class dog: public sub_class<dog>
{
}
这样,我们通过crtp模式,构造了cat->sub_class<cat>->Animal的继承链, sub_class<cat>这一层只负责注册,逻辑都在cat这个类里。
在mosaic_game内的entity_manager采用了同样的设计来实现自动注册,也提供了一个sub_class注册类型:
template <typename base_entity>
class entity_manager
{
template <class T, class B = base_entity>
class sub_class : public B
{
friend T;
public:
static bool trigger()
{
// static_assert(std::is_final_v<T>, "sub class should be final");
inherit_mapper<base_entity>::template record_sub_class<T, B>();
return entity_manager::instance().template register_entity<T>();
}
static bool registered;
private:
sub_class(const entity_construct_key& key, const entity_desc& in_base_desc, std::shared_ptr<spdlog::logger> in_logger)
: B(key, in_base_desc, in_logger)
{
(void) registered;
}
~sub_class()
{
}
};
};
在声明一个新的entity类型的时候,都需要采用这样的形式:
class actor_entity : public entity_manager::sub_class<actor_entity>
{
};
class player_entity final: public entity_manager::sub_class<player_entity, actor_entity>
{
};
这样就能触发正确的自动注册了。
编译期类型名字
上面我们通过模板解决了基于宏的自动注册问题,现在还是有一点不方便之处,对于每个类型我们都需要定义一个静态方法StaticClassName,来标注这个类型的名字,显得也有点繁琐。利用现代编译器的机制,其实我们可以通过模板来获取类型名字的,只需要下面的一坨代码:
template <typename T>
constexpr auto type_name() noexcept
{
std::string_view name, prefix, suffix;
#ifdef __clang__
name = __PRETTY_FUNCTION__;
prefix = "auto type_name() [T = ";
suffix = "]";
#elif defined(__GNUC__)
name = __PRETTY_FUNCTION__;
prefix = "constexpr auto type_name() [with T = ";
suffix = "]";
#elif defined(_MSC_VER)
name = __FUNCSIG__;
prefix = "auto __cdecl type_name<";
suffix = ">(void) noexcept";
#endif
name.remove_prefix(prefix.size());
name.remove_suffix(suffix.size());
return name;
}
这坨代码的核心就是利用编译器的扩展__PRETTY_FUNCTION__ __FUNCSIG__来获取当前函数体的函数签名,对于无参数模板函数而言,他的头尾字符串都是确定的,中间则是编译器输出的类型名字。所以我们只需要把头尾的多余字符串删掉,剩下的就是类型名字了。
通过这个模板函数,之前的注册和使用代码可以修改为:
using creator_func_t = std::function<Animal*()>;
using creator_map = std::unordered_map<std::string_view, creator_func_t>;
template <typename T>
bool addToMap()
{
auto& map_data = getCreatorMap();
auto name = type_name<T>();
map_data[name] = []()
{
return new T();
};
return true;
}
template <typename T>
Animal* CreateAnimal()
{
auto name = type_name<T>();
auto& all_creators = getCreatorMap();
auto cur_iter = all_creators.find(name);
if(cur_iter == all_creators.end())
{
return nullptr;
}
else
{
return cur_iter->second();
}
}
至此,一切可以自动化的工作,都已经被模板自动化了。
编译期类型id
游戏逻辑中经常会有一些entity类型判定操作,例如遍历指定类型的所有entity,判定类型是否相等,判定类型A是否是类型B的子类型等。此时使用类型名字来做判断的话,会涉及到字符串的比较与hash操作,这些操作在频繁调用的时候有一定的性能损失,例如下面的entity_manager根据类型名字创建entity的工厂接口,就涉及到了上述字符串操作:
base_entity* create_entity(const std::string& type_name, const std::string& in_persist_entity_id, std::uint64_t in_online_entity_id)
{
auto cur_ctor_iter = ctor_maps().find(type_name);
if(cur_ctor_iter == ctor_maps().end())
{
return nullptr;
}
else
{
return cur_ctor_iter->second.operator()(in_persist_entity_id, in_online_entity_id);
}
}
因此在mosaic_game的entity系统中,我们还给每个entity类型都分配了一个编译期的size_t类型id。这个id分配器使用了base_type_hash这个模板辅助类
template <typename B>
class base_type_hash
{
static std::size_t last_used_id;
public:
template <typename T>
static std::enable_if_t<std::is_base_of_v<B, T>, std::size_t> hash()
{
if constexpr (std::is_same_v<B, T>)
{
// zero is reserved for self
return 0;
}
else
{
static const std::size_t id = last_used_id++;
return id;
}
}
static std::size_t max_used()
{
return last_used_id;
}
};
template <typename B>
std::size_t base_type_hash<B>::last_used_id = 1;
在mosaic_game的entity_manager中,注册一个entity类型的时候会显示的触发这个字段的初始化:
template<typename T>
static bool register_entity()
{
std::string cur_type_name = T::static_type_name();
auto cur_type_id = utility::base_type_hash<base_entity>::template hash<T>();
(void)cur_type_id;
if(ctor_maps().count(cur_type_name) != 0)
{
return false;
}
entity_ctor_func cur_type_ctor = [=](const std::string& in_persist_entity_id, std::uint64_t in_online_entity_id)
{
return static_cast<base_entity*>(instance().template create_entity<T>(in_persist_entity_id, in_online_entity_id));
};
ctor_maps()[cur_type_name] = cur_type_ctor;
return true;
}
由于我们通过base_type_hash生成的type_id是一个递增的整数,所以我们可以利用这个性质来优化entity的分类存储。在没有这个类型id的帮助下,我们可能会使用下面的类型来作为所有entity的容器:
std::unordered_map<std::string, std::unordered_map<std::uint64_t, entity*>> m_entities_by_type;
每次查询这个容器都需要执行字符串的hash与比较操作,在有type_id的辅助下,我们将这个容器切换为数组模式:
std::vector<std::unordered_map<std::uint64_t, entity*>> m_entities_by_type;
指定类型T的数组索引就是base_type_hash<base_entity>::template hash<T>(), 这是一个编译期常量,因此相对于前述的字符串unordered_map容器来说有一个非常大的效率上的优化。这里需要注意的点就是需要正确的初始化这个vector的大小,因此base_type_hash里会暴露出max_used作为最大索引:
void init()
{
m_logger = utility::get_logger("entity_mgr");
spdlog::register_logger(m_logger);
for(const auto& one_pair: ctor_maps())
{
auto cur_entity_logger = utility::get_logger(one_pair.first);
spdlog::register_logger(cur_entity_logger);
cur_entity_logger->info("register_entity to entity_manager");
}
m_entities_by_type = std::vector<std::unordered_set<entity_slot>>(utility::base_type_hash<base_entity>::max_used());
}
运行时实例id
为了对一个entity做唯一表示,entity都会有一个唯一id字段。在创建这个entity的时候分配一个唯一id,并将这个id与对应entity的映射存储在entity_manager之中。在查找这个id对应的entity则需要通过entity_manager提供的查询接口来定位。在mosaic_game中,为了应对不同情况下的效率需求,同时提供了三套运行时id:
persist_entity_id, 这个id是字符串类型的唯一标识符,保证服务器重新启动之后不会出现相同idonline_entity_id, 这个id是uint64_t类型的唯一标识符, 保证服务器重新启动之前不会出现相同idlocal_entity_id, 这个id是uint64_t类型的唯一标识符,保证本进程内不会出现相同id
创建entity的时候需要同时传入persist_entity_id与online_entity_id:
template <typename T>
T* create_entity(const std::string& in_persist_entity_id, std::uint64_t in_online_entity_id);
这里的persist_entity_id主要是为了玩家entity和账号entity使用的,因为这两个entity要在停服起服之后保持一致的唯一标识符,这样可以方便在日志中对这些entity的活动进行定位。除了这两个类型之外的entity的persist_entity_id都只保证服务器重启之前不会出现相同id,因为这个id的分配是通过下面这个函数来进行的:
std::string basic_stub::gen_unique_str(bool persist)
{
m_unique_id_counter++;
std::ostringstream oss;
oss << *m_local_name_ptr;
oss << "_";
if(persist)
{
oss << std::to_string(m_start_ts);
oss << "_";
}
oss << std::to_string(m_unique_id_counter);
return oss.str();
}
只有player_entity与account_entity创建的时候这个persist变量会设置为true,这样就会带上进程启动的时间戳,以保证重启之后不会出现相同的id。
由于entity查询操作在rpc分发以及异步回调中被经常使用,使用字符串persist_entity_id来做唯一id的话效率不高,所以我们还提供了更加轻量化的基于计数器的online_entity_id,这个online_entity_id的分配是进程内维护了一个递增计数器,space_entity在创建actor_entity的时候会使用这个计数器:
std::uint64_t space_server::gen_online_entity_id()
{
assert(m_online_counter != 0);
m_online_counter++;
return m_online_counter;
}
actor_entity* space_entity::create_entity(const std::string& entity_type, const std::string& entity_id, json::object_t& init_info, const json::object_t& enter_info, std::uint64_t online_entity_id)
{
if(online_entity_id == 0)
{
online_entity_id = gen_online_entity_id();
}
// 省略后续代码
}
由于服务器一般会有数十个进程,为了避免服务器不同进程之间分配了同样的online_entity_id,我们在mgr_server上提供了一个唯一号段分配接口:
void mgr_server::on_request_allocate_counter(std::shared_ptr<network::net_connection> con, std::shared_ptr<const std::string> dest, const json& msg)
{
std::string server_name;
std::string server_type;
std::string counter_type;
std::string errcode;
json::object_t reply_msg, reply_param;
reply_msg["cmd"] = "reply_allocate_counter";
try {
msg.at("from_server_name").get_to(server_name);
msg.at("from_server_type").get_to(server_type);
msg.at("counter_type").get_to(counter_type);
}
catch (std::exception& e)
{
m_logger->warn("invalid msg {} for on_request_allocate_counter error {}", msg.dump(4), e.what());
errcode = "invalid format";
return;
}
reply_param["counter_type"] = counter_type;
reply_param["counter_value"] = 0;
if(errcode.empty())
{
auto counter_value = m_counter_resource[counter_type] + 1;
m_counter_resource[counter_type] = counter_value;
m_logger->info("on_request_allocate_counter server_type {} server_name {} counter_type {} counter_value {}", server_type, server_name, counter_type, counter_value);
reply_param["counter_value"] = counter_value;
}
reply_param["errcode"] = errcode;
reply_msg["param"] = reply_param;
m_router->push_msg(con.get(), m_local_name_ptr, {}, std::make_shared<std::string>(json(reply_msg).dump()), enums::packet_cmd_helper::encode(enums::packet_cmd::server_control, 0));
}
每个进程启动的时候都会向mgr_server申请号段,每个号段都有1<<32的可用分配空间:
void space_server::on_reply_allocate_counter(std::shared_ptr<network::net_connection> con, std::shared_ptr<const std::string> dest, const json& msg)
{
m_logger->info("on_reply_allocate_counter with msg {}", msg.dump());
server_info cur_resource_svr;
std::string counter_type;
std::uint32_t counter_value;
std::string cur_err;
try
{
msg.at("counter_type").get_to(counter_type);
msg.at("errcode").get_to(cur_err);
msg.at("counter_value").get_to(counter_value);
if(!cur_err.empty())
{
m_logger->warn("on_reply_allocate_counter errcode {} msg {}", cur_err, msg.dump());
notify_stop();
return;
}
}
catch (std::exception& e)
{
m_logger->warn("on_reply_allocate_counter msg invalid {} error {}", msg.dump(4), e.what());
notify_stop();
return;
}
m_logger->info("on_reply_allocate_counter counter_type {} counter_value {}", counter_type, counter_value);
if(counter_type == "online_session")
{
if(m_online_counter != 0)
{
m_logger->error("on_reply_allocate_counter get online_session while local counter is {}", m_online_counter);
notify_stop();
return;
}
m_online_counter = std::uint64_t(counter_value)<<32;
}
}
local_entity_id的存在是为了加速entity查找使用的,因为不管是persist_entity_id还是online_entity_id,使用这两个id执行的查询都要通过unordered_map:
base_entity* get_entity_by_online_id(std::uint64_t online_id) const
{
auto slot_iter = m_online_entity_id_to_idx.find(online_id);
if (slot_iter == m_online_entity_id_to_idx.end())
{
return nullptr;
}
return m_total_entities_vec[slot_iter->second].first;
}
base_entity* get_entity(const std::string& eid) const
{
auto slot_iter = m_persist_entity_id_to_idx.find(eid);
if (slot_iter == m_persist_entity_id_to_idx.end())
{
return nullptr;
}
return m_total_entities_vec[slot_iter->second].first;
}
进一步来加速这个查找效率的话,只有vector能用来作为存储容器,此时local_entity_id使用元素所在的数组索引:
std::vector<std::pair<base_entity*,std::uint32_t>> m_total_entities_vec;
由于我们无法保证一个entity在不同进程上的entity_manager::m_total_entities_vec占据相同的索引,所以这个local_entity_id只能在本进程中使用。同时由于entity会不断的创建销毁,如果vector中一个位置存储的entity指针无法在entity销毁后释放的话,这个数组就会无限膨胀,占据巨量内存。所以我们需要在entity销毁的时候对索引进行回收,分配索引的时候优先使用回收的结果:
std::vector<std::uint32_t> m_avail_indexes;
但是使用索引回收机制来复用元素又可能导致之前某个entity对应的local_entity_id在一段时间后会查询到一个新的entity,这样逻辑层会错误的使用这个新的entity执行后续的流程,带来错误。为了避免这种问题发生,我们将这个local_entity_id设计为两个字段,封装为一个结构体entity_slot:
struct entity_slot
{
std::uint32_t salt;
std::uint32_t slot; // slot为0 代表不是有效的entity
};
这里的slot就是entity数组里的偏移量,而salt则是这个元素被修改的次数。同时entity数组里的值不仅仅存储entity的指针,也存储对应的修改序列号。这样在创建entity的时候对数组里的元素序列号执行自增,查询entity的时候需要判定修改序列号是否相等:
entity_slot get_avail_slot()
{
if(m_avail_indexes.empty())
{
std::uint32_t result = std::uint32_t(m_total_entities_vec.size());
// 初始化修改序列号为0
m_total_entities_vec.push_back(std::make_pair<base_entity*, std::uint32_t>(nullptr, 0));
return entity_slot{0, result};
}
else
{
auto result = m_avail_indexes.back();
m_avail_indexes.pop_back();
assert(m_total_entities_vec[result].first == nullptr);
m_total_entities_vec[result].second++; //自增修改序列号
return entity_slot{m_total_entities_vec[result].second, result};
}
}
base_entity* get_entity(const entity_slot& entity_slot) const
{
if(entity_slot.slot >= m_total_entities_vec.size())
{
return nullptr;
}
if(m_total_entities_vec[entity_slot.slot].second != entity_slot.salt)
{
return nullptr;
}
return m_total_entities_vec[entity_slot.slot].first;
}
实际使用中的local_entity_id就是将entity_slot中的两个字段合并起来的uint64_t:
std::uint64_t entity_slot::to_uint64() const
{
std::uint64_t result = salt;
result <<= 32;
result += slot;
return result;
}
using local_entity_id = utility::handler_wrapper<std::uint64_t, entity_desc>;
class entity_desc
{
public:
const entity_slot m_slot_index;
const std::uint64_t m_type_id;
const std::string m_type_name;
const std::string m_persist_entity_id;
const local_entity_id m_local_entity_id;
const std::uint64_t m_online_entity_id;
public:
entity_desc(entity_slot in_slot_index, std::uint64_t in_type_id, const std::string& in_type_name, const std::string& in_persist_entity_id, std::uint64_t in_online_entity_id)
: m_slot_index(in_slot_index)
, m_type_id(in_type_id)
, m_type_name(in_type_name)
, m_persist_entity_id(in_persist_entity_id)
, m_local_entity_id(in_slot_index.to_uint64())
, m_online_entity_id(in_online_entity_id)
{
}
};
这里的handler_wrapper是一个值类型保护类,保证外部只能拿到只读值,只有指定类型可以对内部只进行修改:
template <typename Value, typename Friend>
class handler_wrapper
{
Value m_value;
friend Friend;
handler_wrapper(Value in_value)
: m_value(in_value)
{
}
public:
handler_wrapper()
: m_value(0)
{
}
handler_wrapper(const handler_wrapper& other)
: m_value(other.m_value)
{
}
Value value() const
{
return m_value;
}
bool operator==(const handler_wrapper& other) const
{
return m_value == other.m_value;
}
};
生命周期管理
由于entity的创建与销毁都被entity_manager托管了,所以我们必须禁止外部手动通过new来创建entity的实例,同时要禁止外部手动的销毁一个entity实例。为了达到这样的效果,常规的实现是将构造函数与析构函数都声明为protected,同时在这些entity类型中声明entity_manager为其friend class。不过friend class的权限有点过于宽泛了,可能导致不经意间修改entity内部的数据,我们在mosaic_game中使用了一种更加安全的做法,这种做法需要创建一个中间类型key:
class entity_construct_key
{
entity_construct_key(std::size_t in_type_id)
: m_type_id(in_type_id)
{
}
template <typename T>
friend class entity_manager;
public:
const std::size_t m_type_id;
};
class entity_destroy_key
{
entity_destroy_key()
{
}
template <typename T>
friend class entity_manager;
};
这里entity_construct_key与entity_destroy_key都是一个构造函数为private的类型,同时声明entity_manager为其友元函数,这样就保证了这两个类型只能在entity_manager内部被创建。然后在server_entity基类上定义一个需要entity_construct_key作为参数的构造函数,以及一个需要entity_destroy_key的析构辅助函数:
server_entity(const utility::entity_construct_key& key, const utility::entity_desc& in_base_desc, std::shared_ptr<spdlog::logger> logger)
: m_base_desc(in_base_desc)
, m_shared_global_id(std::make_shared<std::string>(in_base_desc.m_persist_entity_id))
, m_logger(logger)
{
}
void final_destroy(const utility::entity_destroy_key& key)
{
delete this;
}
这两个函数都是public的,这样就避免了去声明friend的同时,限制了只有entity_manager能调用到这两个函数。对于析构函数还有一个额外步骤,即在所有的子类型里都声明一个proeteced的析构函数,因为如果不声明的话编译器会自动生成一个public的析构函数,导致外部可以直接执行delete actor这样的操作。最终通过这两个额外类型,就达到了控制entity的创建与销毁的执行上下文必须是entity_manager。
类型层级判定
游戏中有些逻辑需要对运行时的entity类型做具体的判定才能决定后续的逻辑分发,主要是判定entity是否是A类型或者是A类型的子类型。
在常规的cpp类型系统实现中,判定基类指针是否是某个子类型的实例一般会使用dynamic_cast<A*>(P)来尝试强制转换,如果转换成功则获得了子类的有效指针。不过dynamic_cast的实现上依赖于RTTI,在执行转换时会经过很多std::type_info的相等判定操作,内部主要是类型名字的比较,整体耗时是比较高的,具体的耗时分析可以参考这篇知乎文章。
由于我们在entity_manager创建entity的时候已经为每个具体的类型构造了一个单独的活动对象集合,所以判定指针A的类型是否是P时只需要执行一次这个集合的查询即可。此外我们在create_entity<T>时,会将T类型对应的编译期类型id放入到entity的数据成员中,所以判定entity类型恰好是P类型只需要将这个类型id字段执行比较即可,比集合查找是否包含这个指针来判定快很多:
template <typename T>
bool is_exact_type() const
{
auto dest_type_id = spiritsaway::utility::base_type_hash<server_entity>::template hash<T>();
return dest_type_id == m_base_desc.m_type_id;
}
这个接口只能判定指针指向的对象的类型是否是T,无法判定是否是T类型的子类。为了执行快速的子类判断,我们考虑继续在base_type_hash生成的类型id上做文章,尝试一下静态的记录类型A的直接父类型id。如果能够方便的构造这个映射关系的话,判定id是否是A的子类就只需要递归的查询id的父类型即可。
为了达到这样的记录结果,我们构造了一个辅助类型inherit_mapper, 在这个类型上提供一个unordered_map来记录某个类型A对应的直接父类型P:
template <typename T>
class inherit_mapper
{
static std::unordered_map<std::size_t, std::size_t>& parent_map()
{
static std::unordered_map<std::size_t, std::size_t> m_parent_map;
return m_parent_map;
}
public:
template <typename A, typename P>
static std::enable_if_t<std::is_base_of_v<T, A>&& std::is_base_of_v<T, P>&& std::is_base_of_v<P, A> && !std::is_same_v<A, P>, void> record_sub_class()
{
parent_map()[base_type_hash<T>::template hash<A>()] = base_type_hash<T>::template hash<P>();
}
};
同时我们在entity_manager的自动注册相关代码里加入对record_sub_class的调用:
template <class T, class B = base_entity>
class sub_class : public B
{
friend T;
public:
static bool trigger()
{
// static_assert(std::is_final_v<T>, "sub class should be final");
inherit_mapper<base_entity>::template record_sub_class<T, B>();
return entity_manager::instance().template register_entity<T>();
}
};
构造完成这个unordered_map映射之后,查询类型id_a是否是类型id_b的子类就可以以下面的代码来实现了:
static bool inherit_mapper::is_sub_class(std::uint64_t id_a, std::uint64_t id_b)
{
const auto& cur_parent_map = parent_map();
auto temp_iter = cur_parent_map.find(id_a);
while(temp_iter != cur_parent_map.end())
{
if(temp_iter->second == id_b)
{
return true;
}
temp_iter = cur_parent_map.find(temp_iter->second);
}
return false;
}
在继承链路长的时候,这里的while循环可能会执行多次,考虑到base_type_hash生成的类型id是连续递增整数,我们可以通过数组来存储这个映射:
static std::vector<std::size_t>& inherit_mapper::parent_vec()
{
static std::vector<std::size_t> m_parent_vec;
return m_parent_vec;
}
static void inherit_mapper::flat_map_to_vec()
{
auto& cur_parent_vec = parent_vec();
auto& cur_parent_map = parent_map();
cur_parent_vec.resize(base_type_hash<T>::max_used(), 0);
for (const auto& one_pair : cur_parent_map)
{
cur_parent_vec[one_pair.first] = one_pair.second;
}
}
用数组存储映射的好处就是更好的内存局部性,相对于unordered_map有明显的性能提升。此时判定子类型的代码依然很简单:
static bool inherit_mapper::is_sub_class(std::size_t s_type_id, std::size_t p_type_id)
{
auto& cur_parent_vec = parent_vec();
if (cur_parent_vec.empty())
{
flat_map_to_vec();
}
if (p_type_id == 0) // 0 代表最顶层的基类 因此总是返回true
{
return true;
}
if (s_type_id >= cur_parent_vec.size() || p_type_id >= cur_parent_vec.size())
{
return false;
}
do
{
if (s_type_id == p_type_id)
{
return true;
}
s_type_id = cur_parent_vec[s_type_id];
} while (s_type_id);
return false;
}
效率上更优的方法是直接构造一个N*N的矩阵bool parent_mat[N][N],parent_mat[a][b]的值为true则认为a是b的子类,这样就省去了多次迭代。
有了这个inherit_mapper的辅助之后,判定子类型就非常的简单了:
template <typename T>
bool server_entity::is_sub_type() const
{
auto dest_type_id = spiritsaway::utility::base_type_hash<server_entity>::hash<T>();
return utility::inherit_mapper<server_entity>::is_sub_class(m_base_desc.m_type_id, dest_type_id);
}
bool server_entity::is_sub_type(const server_entity& other) const
{
return utility::inherit_mapper<server_entity>::is_sub_class(m_base_desc.m_type_id, other.m_base_desc.m_type_id);
}
上述类型层级判定的优化其实是有很大局限性的,完全依赖于所有的类型在编译期都已知,同时所有相关类型都在同一个可执行文件中。如果entity的类型存在多个动态库之中,则这些利用静态变量的相关逻辑都会出现问题,因为静态变量在不同的动态库中会出现多个副本。
所以比较易用且万能的方法是给entity带上一个flags变量,这个变量负责记录entity的一些特性,例如是否需要往客户端同步、是否是玩家、是否是陷阱等。这些特性都以枚举值来定义,这样判断是否有这个特性就只需要执行一下位操作即可:
std::uint64_t entity_flag() const
{
return m_entity_flag;
}
bool has_entity_flag(enums::entity_flag test_flag) const
{
return !!(m_entity_flag & (1ull <<std::uint8_t(test_flag)));
}
void add_entity_flag(enums::entity_flag new_flag)
{
m_entity_flag = m_entity_flag | (1ull<<int(new_flag));
}
void remove_entity_flag(enums::entity_flag old_flag)
{
m_entity_flag = m_entity_flag & (~(1ull<<int(old_flag)));
}
这个变量是一个uint64_t,所以可以容纳很多的特性标记位,目前mosaic_game中使用了这个枚举来定义这些标记位:
enum class entity_flag
{
is_client_visible = 0,
is_dead,
is_player,
is_monster,
is_trap,
is_global,
is_combat,
is_ghost,
support_ghost,
support_migrate,
support_aoi_max_limit,
is_main_player,
is_other_player,
is_observer_player,
};
在类型的构造函数中,使用add_entity_flag接口来给当前entity加上相关的flag,然后运行时的时候使用has_entity_flag来执行快速判定。这样的实现可以完全跳过复杂的类型系统设计,实现最优的过滤效率。
component系统
一个entity上可以挂载很多的component,每个component负责承担一些比较独立的逻辑。为了更加方便的对这些component进行管理,我们将管理component的基础逻辑构造成为了一个独立的类型,使之与entity进行解耦,相关代码见于common/server_utility/include/component.h。
这个代码里声明了两个类型,组件基类base_component,以及宿主基类component_owner:
template <typename Component, typename Owner>
class component_owner
{
};
template <typename BaseOwner, typename BaseInterface>
class base_component: public BaseInterface
{
};
这两个类型都是模板类型,这样就可以更好的应对不同的component类型,这里的BaseInterface声明了一些基础的需要实现的虚接口。看上去这两个类的模板参数出现了互相引用,不怎么好理解,这里我们就以actor_entity为例来介绍使用方法。
首先我们需要明确所有的actor_component都需要继承的接口,定义一个相关的actor_component_interface:
class actor_component_interface
{
public:
virtual void on_leave_space(space_entity* cur_space)
{
}
virtual void on_enter_space()
{
}
// 省略更多的接口
};
有了这个接口类型声明之后,我们才可以给出actor_component的具体类型,注意这里我们使用了一个前置声明actor_entity,这样才能执行解耦:
class actor_entity;
using actor_component = utility::base_component<actor_entity, actor_component_interface>;
这里可以使用前置声明的原因是actor_component中并不需要知道BaseOwner的类型大小,因为其内部只是保存了BaseOwner的指针,这个字段的大小可以在编译期确定:
BaseOwner* m_owner = nullptr;
同时由于base_component是一个模板类,在类型实例化的时候其内部函数并不会直接实例化,而是等到函数被使用的时候才真正的实例化。因此我们不需要担心下面的两个成员函数由于引用了m_owner上的接口而出现编译错误:
virtual void on_set_owner()
{
m_owner->add_component_rpcs(get_rpc_indexes(), m_component_type_id);
}
virtual void on_remove_owner()
{
m_owner->remove_component_rpcs(get_rpc_indexes(), m_component_type_id);
}
有了actor_component的类型声明之后,我们才能给出actor_entity的声明:
class actor_entity : public entity_manager::sub_class<actor_entity>, public utility::component_owner<actor_component, actor_entity>
{
};
这里使用了多继承,不过这里不是菱型继承,暂时不要慌。entity_manager::sub_class代表当前actor_entity继承自server_entity,同时会自动执行类型的自动注册。而后面的utility::component_owner代表当前actor_entity继承自component_owner,拥有管理actor_component的能力。
搞清楚如何声明这两个模板类型之后,我们再来看可以在这两个类型上做哪些操作。首先需要明确的是component_owner是base_component的一个容器,所以内部需要存储多个不同base_component的指针,同时需要提供一系列的component查询接口来获取component_owner上的特定base_component。为了支持对base_component执行查询,我们需要为base_component设计一套id系统。这里我们从之前的entity的编译期id,给每个base_component的子类型提供了唯一编译期计数器。实现机制也基本照抄自之前的sub_class:
template <typename BaseOwner, typename BaseInterface>
class base_component: public BaseInterface
{
template <typename T, typename B = base_component<BaseOwner, BaseInterface>>
struct sub_class: public B
{
friend T;
static bool trigger()
{
static_assert(std::is_final_v<T>, "sub class should be final");
auto cur_type_id = utility::base_type_hash<B>:: template hash<T>();
// std::cout<<"register type name "<<T::static_type_name()<<" with id "<<cur_type_id<<" for Base "<<BaseOwner::static_type_name()<<std::endl;
return !!cur_type_id;
}
static bool registered;
private:
sub_class(std::uint32_t component_type_id, const std::string& component_name)
: B(component_type_id, component_name)
{
(void) registered;
}
};
};
registered变量的初始化代码相对以前来说语法更加复杂了,模板套模板套模板套模板:
template <class BaseOwner, class BaseInterface>
template <class T, class B>
bool base_component<BaseOwner, BaseInterface>::template sub_class<T, B>::registered = base_component<BaseOwner, BaseInterface>::template sub_class<T, B>::trigger();
base_component的构造函数里我们需要传递两个参数,一个是这个component具体类型的id,一个是这个component的名字。这两个参数都不需要手动传递,在执行add_component的时候会自动设置好:
template <typename Component, typename Owner>
class component_owner
{
std::vector<Component*> m_components;
public:
template <typename C>
component_add_result add_component(const json& data)
{
auto cur_hash_id = utility::base_type_hash<Component>::template hash<C>();
if (cur_hash_id >= m_components.size())
{
return component_add_result::hash_sz_fail;
}
auto cur_val = m_components[cur_hash_id];
if (cur_val)
{
return component_add_result::duplicated;
}
auto comp = new C(cur_hash_id, C::static_type_name());
comp->set_owner(m_owner);
if(!comp->init(data))
{
delete comp;
return component_add_result::init_fail;
}
m_components[cur_hash_id] = dynamic_cast<Component*>(comp);
return component_add_result::suc;
}
};
这里的m_components是一个数组,提供基于base_type_hash生成类型索引的快速定位,这样查询起来就方便很多了:
template<typename C>
C* get_component()
{
auto cur_hash_id = utility::base_type_hash<Component>::template hash<C>();
if(cur_hash_id >= m_components.size())
{
return {};
}
auto cur_val = m_components[cur_hash_id];
if(!cur_val)
{
return {};
}
return dynamic_cast<C*>(cur_val);
}
Component* get_component(std::uint32_t cur_hash_id)
{
if(cur_hash_id >= m_components.size())
{
return {};
}
auto cur_val = m_components[cur_hash_id];
return cur_val;
}
component被添加之后,便永久挂载到这个owner上,直到owner销毁的时候调用clear_component来销毁挂载过来的component:
void clear_components()
{
deactivate_components();
for(auto& one_component: m_components)
{
if(one_component)
{
delete one_component;
one_component = nullptr;
}
}
m_components.clear();
}
virtual void destroy()
{
clear_components();
}
virtual ~component_owner()
{
destroy();
}
到这里一个基本的component系统就算差不多了,应用层只需要执行类似于下面的代码将所有所需的component子类添加进去就好了:
bool player_entity::init_components(const json::object_t& components_data)
{
if(!actor_entity::init_components(components_data))
{
return false;
}
if(!add_components<
player_debug_component,
player_space_component,
player_aoi_component,
player_move_component,
player_observer_component,
player_chat_component,
player_offline_msg_component,
player_notify_component,
player_rank_component,
player_redis_component,
player_stuff_component,
player_team_component,
player_trap_component,
player_combat_component,
player_group_component
>(components_data))
{
return false;
}
return true;
}
在mosaic_game中的component中还有一定量的代码负责处理RPC的分发,这部分代码的作用将在后续的RPC章节中进行介绍。