entity与component系统

游戏逻辑内为了方便的对场景内的各种对象做统一的管理,同时解耦各种内聚的逻辑,基本都会做一个自己的entity/component系统。entity就是游戏内的对象,是对象数据的载体,而且是component的容器;而component则是各种业务逻辑的载体。随着业务逻辑的扩张,entitycomponent的类型会呈一个无限增加的趋势,所以需要一个entity/component系统来对这些变动中的类型执行管理,维护这些类型的创建与运行。

entity系统

mosaic_game的客户端与服务端都维护了各自的entity系统,服务端的基类entityserver_entity,客户端的基类entityclient_entity。为了对这两种entity系统做统一化的管理,避免复制粘贴代码,mosaic_game中构造了一个模板化的entity_manager。由于这个entity_manager内部使用了太多的模板技巧,因此我们来循序渐进的介绍一下这些模板代码的演进过程。

工厂模式

在游戏业务代码中,对于重要的继承类型对象创建,一般都通过对象工厂进行委托。这样的好处是方便追溯对象的完整生命周期,实现对象的统一管理。mosaic_game中的EntityManager就是这样的一个工厂,这个工厂就负责了所有Entity的创建, 同时每个entity被创建之后,都会记录到EntityManager的内部map里,以方便其他模块根据entityid进行查找。

最基本的对象工厂代码可以简略为下面的例子:

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系统。主要问题包括两个:

  1. 人工字符串匹配有笔误的风险
  2. 每次添加新的子类型都需要更改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这个mapCreateAnimal分离开来,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_gameentity系统中,我们还给每个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_gameentity_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

  1. persist_entity_id, 这个id是字符串类型的唯一标识符,保证服务器重新启动之后不会出现相同id
  2. online_entity_id, 这个iduint64_t类型的唯一标识符, 保证服务器重新启动之前不会出现相同id
  3. local_entity_id, 这个iduint64_t类型的唯一标识符,保证本进程内不会出现相同id

创建entity的时候需要同时传入persist_entity_idonline_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的活动进行定位。除了这两个类型之外的entitypersist_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_entityaccount_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_keyentity_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则认为ab的子类,这样就省去了多次迭代。

有了这个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_ownerbase_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章节中进行介绍。