七:服务端资产库文件夹结构
http://kbengine.org/cn/docs/concepts/directorys.html
看assets, 注意:demo使用的不是默认的assets资产目录,而是上面章节下载的kbengine_demos_assets,但文件夹结构与意义是一致的。
八:客户端文件夹结构
kbengine_unity3d_demo
-> Assets // Unity3d资产库
-> Plugins
-> kbengine // KBEngine插件层(包含了网络消息处理、客户端实体维护、与服务端对接层)
-> Scripts
-> kbe_scripts // 客户端逻辑脚本层(https://github.com/kbengine/kben ... e_scripts/README.md)
-> Account.cs // 对应于服务端的账号实体的客户端部分实现
-> Avatar.cs // 对应于服务端的角色实体的客户端部分实现
-> clientapp.cs // 按照服务端的概念cellapp、baseapp、etc,这里我们抽象出一个clientapp
-> Combat.cs // 对应于服务端的def interfaces/Combat的客户端部分实现
-> GameObject.cs // 对应于服务端的def interfaces/GameObject的客户端部分实现
-> Gate.cs // 对应于服务端的Gate实体的客户端部分实现
-> Monster.cs // 对应于服务端的Monster实体的客户端部分实现
-> NPC.cs // 对应于服务端的NPC实体的客户端部分实现
-> Skill.cs // 一个简单的不能再简单的技能执行类,服务端cell/skill下面也有,而客户端主要是进行一些检查
-> SkillBox.cs // 玩家的技能列表,对应于服务端的def interfaces/Skillbox的客户端部分实现
-> SkillObject.cs // 技能对象(施法者、目标、受术者等),服务端cell/skill下面也有
-> u3d_scripts // 客户端UI等表现层
-> UI.cs // 处理UI部分
-> World.cs // 处理场景世界部分
-> GameEntity.cs // 所有服务端同步过来的实体在表现层都必须继承该类,完成统一的表现(头顶名称、血条等)与控制(实体状态、移动)
------------------------------------------
基本设计结构:
-游戏-
| |
表现层u3d_scripts(UI && 世界) KBE层kbe_scripts(插件 && 逻辑)
1: 表现层与KBE层可以配置为不同线程也能配置为同一个线程跑(单线程)
2: 表现层与KBE层使用事件交互, 向KBE层触发的事件使用fireIn(...),KBE层向外部触发的事件使用fireOut(...)。 那么表现层想要监听KBE触发的Out事件,需要注册监听Event.registerOut, KBE需要监听外部触发进来的事件则反之。
3: 使用unity3D插件与服务端配套则服务端中的scripts/client文件夹可以忽略(https://github.com/kbengine/kben ... e_scripts/README.md)
九:游戏配置
服务端demo所有的配置都存放于kbengine_demos_assets\scripts\data之下。
scripts\data\
d_avatar_inittab.py // 角色初始化表, 用于新建立的角色设置初始值, 由kbengine\kbe\tools\xlsx2py\rpgdemo\avatar_init.bat导出。
d_dialogs.py // NPC对话表, 其中'menu1'对于的是一个对话协议的ID,服务端根据不同的协议ID执行不同的对话功能, 由kbengine\kbe\tools\xlsx2py\rpgdemo\dialogs.bat导出。
d_entities.py // 实体类型表,描述某类型怪移动速度,攻击力等,由kbengine\kbe\tools\xlsx2py\rpgdemo\NPC.bat导出。
d_skills.py // 技能表,描述某类型技能判定条件,输出等,由kbengine\kbe\tools\xlsx2py\rpgdemo\skils.bat导出。
d_spaces.py // 场景副本表,描述space是大地图还是副本,以及地图名称等,由kbengine\kbe\tools\xlsx2py\rpgdemo\spaces.bat导出。
d_spaces_spawns.py // NPC、Monster等出生点信息,目前是手填的,也可以采用工具布点导出。
spawnpoints\
xinshoucun_spawnpoints.xml // 这个出生点信息主要用于warring这个demo,(NPC、Monster等出生点信息,采用Unity3d布点导出, 可以在unity打开warring这个demo,
// 在unity3d(菜单上)->Publish->Build Publish AssetBundles(打包所有需要动态加载资源),然后在Assets->StreamingAssets目录下会得到 "场景名称_spawnpoints.xml"的出生点表)。
十:创建账号
客户端部分:
1: kbengine_unity3d_demo\Assets\Scripts\u3d_scripts\UI.cs
1.1 点击登录按钮导致createAccount()被调用, createAccount中向KBE层触发了一个创建账号事件,参数是账号名与密码。
注意:KBEngine插件kbengine_unity3d_demo\Assets\Plugins\kbengine\kbengine_unity3d_plugins\KBEngine.cs中已经注册了这个“createAccount”事件,对应于KBEngineApp.createAccount函数。
- public void createAccount()
- {
- KBEngine.Event.fireIn("createAccount", new object[]{stringAccount, stringPasswd});
- }
复制代码
2. 事件在KBE插件中kbengine_unity3d_demo\Assets\Plugins\kbengine\kbengine_unity3d_plugins\KBEngine.cs, KBEngineApp.process()中被正式处理
- /*
- 插件的主循环处理函数
- */
- public virtual void process()
- {
- // 处理网络
- _networkInterface.process();
- // 处理外层抛入的事件
- Event.processInEvents();
- // 向服务端发送心跳以及同步角色信息到服务端
- sendTick();
- }
复制代码
3. 创建账号函数被调用, createAccount_loginapp函数表示请求向服务端loginapp进程要求创建一个账号,而此时可能还没有连接服务器,需要先连接,如果已经连接上了则向loginapp发送一个包“bundle.send”。
可以看到向Bundle中写入了相关需要的数据,而Bundle会将数据序列化成二进制流,服务端会采用相同的协议将其归原并将调用服务端协议所绑定的方法(后面会讲到服务端具体方法)。
- public void createAccount(string username, string password)
- {
- KBEngineApp.app.username = username;
- KBEngineApp.app.password = password;
- KBEngineApp.app.createAccount_loginapp(true);
- }
- /*
- 创建账号,通过loginapp
- */
- public void createAccount_loginapp(bool noconnect)
- {
- if(noconnect)
- {
- reset();
- _networkInterface.connectTo(_args.ip, _args.port, onConnectTo_createAccount_callback, null);
- }
- else
- {
- Bundle bundle = new Bundle();
- bundle.newMessage(Message.messages["Loginapp_reqCreateAccount"]);
- bundle.writeString(username);
- bundle.writeString(password);
- bundle.writeBlob(new byte[0]);
- bundle.send(_networkInterface);
- }
- }
复制代码
创建返回结果:
UI.cs -> onCreateAccountResult
服务端部分:
1. 通过上面可以得知客户端向服务端发送了一条创建账号的协议, 协议名称为“Loginapp_reqCreateAccount”(注意,所有的协议名称都能在服务端找到对应的方法, Loginapp_代表了协议的作用域仅为Loginapp, 方法名称为reqCreateAccount)
- void Loginapp::reqCreateAccount(Network::Channel* pChannel, MemoryStream& s)
- {
- std::string accountName, password, datas;
- s >> accountName >> password;
- s.readBlob(datas);
- if(!_createAccount(pChannel, accountName, password, datas, ACCOUNT_TYPE(g_serverConfig.getLoginApp().account_type)))
- return;
- }
复制代码
服务端解析出了账号名与密码,在_createAccount函数中会将这条请求最终送到dbmgr,dbmgr检查之后决定是否创建数据库账号,并最终将结果返回到loginapp,然后由loginapp将结果中转至客户端。
十一:登录账号
1: kbengine_unity3d_demo\Assets\Scripts\u3d_scripts\UI.cs, 向KBE层触发了登陆事件
- public void login()
- {
- info("connect to server...(连接到服务端...)");
- KBEngine.Event.fireIn("login", new object[]{stringAccount, stringPasswd});
- }
复制代码
2: kbengine_unity3d_demo\Assets\Plugins\kbengine\kbengine_unity3d_plugins\KBEngine.cs, 插件触发登陆函数,并最终向loginapp发送了一个登陆包“Loginapp_login”
- public void login(string username, string password)
- {
- KBEngineApp.app.username = username;
- KBEngineApp.app.password = password;
- KBEngineApp.app.login_loginapp(true);
- }
- /*
- 登录到服务端(loginapp), 登录成功后还必须登录到网关(baseapp)登录流程才算完毕
- */
- public void login_loginapp(bool noconnect)
- {
- if(noconnect)
- {
- reset();
- _networkInterface.connectTo(_args.ip, _args.port, onConnectTo_loginapp_callback, null);
- }
- else
- {
- Dbg.DEBUG_MSG("KBEngine::login_loginapp(): send login! username=" + username);
- Bundle bundle = new Bundle();
- bundle.newMessage(Message.messages["Loginapp_login"]);
- bundle.writeInt8((sbyte)_args.clientType); // clientType
- bundle.writeBlob(new byte[0]);
- bundle.writeString(username);
- bundle.writeString(password);
- bundle.send(_networkInterface);
- }
- }
复制代码
服务端部分:
1:服务端loginapp.cpp中“void Loginapp::login(Network::Channel* pChannel, MemoryStream& s)”被触发, 这个函数进行了一系列的检查,
确定合法后向dbmgr发送一个登陆请求包“(*pBundle).newMessage(DbmgrInterface::onAccountLogin);”, dbmgr也会进行一系列的检查并将登陆结果返回到loginapp。
- void Loginapp::login(Network::Channel* pChannel, MemoryStream& s)
- {
- ...
- ...
- if(loginName.size() > ACCOUNT_NAME_MAX_LENGTH)
- {
- INFO_MSG(fmt::format("Loginapp::login: loginName is too long, size={}, limit={}.\n",
- loginName.size(), ACCOUNT_NAME_MAX_LENGTH));
- _loginFailed(pChannel, loginName, SERVER_ERR_NAME, datas, true);
- s.done();
- return;
- }
- if(password.size() > ACCOUNT_PASSWD_MAX_LENGTH)
- {
- INFO_MSG(fmt::format("Loginapp::login: password is too long, size={}, limit={}.\n",
- password.size(), ACCOUNT_PASSWD_MAX_LENGTH));
- ...
- ...
- ...
- // 向dbmgr查询用户合法性
- Network::Bundle* pBundle = Network::Bundle::ObjPool().createObject();
- (*pBundle).newMessage(DbmgrInterface::onAccountLogin);
- (*pBundle) << loginName << password;
- (*pBundle).appendBlob(datas);
- dbmgrinfos->pChannel->send(pBundle);
- }
复制代码
1.1: loginapp得到dbmgr的登录合法结果后向baseappmgr发送了分配网关(baseapp)请求(registerPendingAccountToBaseapp), 通常是负载较低的一个baseapp进程.
- void Loginapp::onLoginAccountQueryResultFromDbmgr(Network::Channel* pChannel, MemoryStream& s)
- {
- ...
- ...
- ...
- // 如果大于0则说明当前账号仍然存活于某个baseapp上
- if(componentID > 0)
- {
- Network::Bundle* pBundle = Network::Bundle::ObjPool().createObject();
- (*pBundle).newMessage(BaseappmgrInterface::registerPendingAccountToBaseappAddr);
- (*pBundle) << componentID << loginName << accountName << password << entityID << dbid << flags << deadline << infos->ctype;
- baseappmgrinfos->pChannel->send(pBundle);
- return;
- }
- else
- {
- // 注册到baseapp并且获取baseapp的地址
- Network::Bundle* pBundle = Network::Bundle::ObjPool().createObject();
- (*pBundle).newMessage(BaseappmgrInterface::registerPendingAccountToBaseapp);
- (*pBundle) << loginName;
- (*pBundle) << accountName;
- (*pBundle) << password;
- (*pBundle) << dbid;
- (*pBundle) << flags;
- (*pBundle) << deadline;
- (*pBundle) << infos->ctype;
- baseappmgrinfos->pChannel->send(pBundle);
- }
- }
复制代码
1.2:baseappmgr最终返回所分配的baseapp的ip地址等信息,loginapp将其转发给客户端(登录成功协议onLoginSuccessfully,包含baseapp的ip和端口信息)
- void Loginapp::onLoginAccountQueryBaseappAddrFromBaseappmgr(Network::Channel* pChannel, std::string& loginName,
- std::string& accountName, std::string& addr, uint16 port)
- {
- ...
- ...
- ...
- Network::Bundle* pBundle = Network::Bundle::ObjPool().createObject();
- (*pBundle).newMessage(ClientInterface::onLoginSuccessfully);
- uint16 fport = ntohs(port);
- (*pBundle) << accountName;
- (*pBundle) << addr;
- (*pBundle) << fport;
- (*pBundle).appendBlob(infos->datas);
- pClientChannel->send(pBundle);
- SAFE_RELEASE(infos);
- }
复制代码
2: 客户端插件得到返回结果后调用KBEngineApp.cs->login_baseapp()函数开始正式登录到baseapp。
3:baseapp收到登录请求
- void Baseapp::loginGateway(Network::Channel* pChannel,
- std::string& accountName,
- std::string& password)
复制代码
进行了一系列的检查,包括:账号是否已经在线,是否可以在这里登录等等。
当检查合法后,向dbmgr发送了一个查询账号信息的请求“DbmgrInterface::queryAccount”,dbmgr将查询到的账号数据(包括属性等)返回到baseapp, Baseapp::onQueryAccountCBFromDbmgr
当函数结果为合法时,根据配置中定义的账号实体脚本名称“g_serverConfig.getDBMgr().dbAccountEntityScriptType”创建了Account实体, 同时还创建了一个clientMailbox,账号实体中调用clientMailbox->方法()即可与客户端通讯了。
Account实体被创建后, 首先__init__被调用, 接着onEntitiesEnabled被调用, 此时实体正式可用了。
账号登陆成功后, 客户端Account.cs中会调用__init__() -> baseCall("reqAvatarList");来请求获得角色列表,
UI.cs中onReqAvatarList得到结果。
十二:创建角色与选择角色进入游戏
1. 创建角色UI.cs -> void onSelAvatarUI()中
account.reqCreateAvatar(1, stringAvatarName);
UI.cs中onCreateAvatarResult得到结果。
2.选择角色进入游戏
UI.cs -> onSelAvatarUI()中
account.selectAvatarGame(selAvatarDBID);
这里使用角色的数据库ID作为标识,服务端上Account实体有角色列表属性,角色列表的数据结构大概为
AvatarList <Dict<AvatarDBID(UINT64), INFOS>>
十三:创建世界(大地图与副本)
1. 创建世界管理器服务端启动之后,baseapp与cellapp准备完毕、准备关闭等事件都会通知到kbengine_defs.xml配置中指定的个性化脚本。kbe默认个性化脚本为kbengine.py, baseapp进程准备好之后会调用kbengine.py的onBaseAppReady回调函数, demo在这个函数中判定是否为第一个启动的baseapp(假如启动了很多baseapps),
如果是第一个baseapp,脚本创建了一个世界管理实体“spaces”:
- def onBaseAppReady(isBootstrap):
- """
- KBEngine method.
- baseapp已经准备好了
- @param isBootstrap: 是否为第一个启动的baseapp
- @type isBootstrap: BOOL
- """
- INFO_MSG('onBaseAppReady: isBootstrap=%s' % isBootstrap)
- # 安装监视器
- Watcher.setup()
- if isBootstrap:
- # 创建spacemanager
- KBEngine.createBaseLocally( "Spaces", {} )
复制代码
2. 世界管理器创建出所有的场景
在spaces.py中, spaces通过initAlloc函数根据配置中scripts/data/d_spaces.py创建出space实体,space实体描述的是一个抽象空间,一个空间可以被逻辑定义为大地图、场景、房间、宇宙等等。
- def initAlloc(self):
- # 注册一个定时器,在这个定时器中我们每个周期都创建出一些Space,直到创建完所有
- self._spaceAllocs = {}
- self.addTimer(3, 1, SCDefine.TIMER_TYPE_CREATE_SPACES)
- self._tmpDatas = list(d_spaces.datas.keys())
- for utype in self._tmpDatas:
- spaceData = d_spaces.datas.get(utype)
- if spaceData["entityType"] == "SpaceDuplicate":
- self._spaceAllocs[utype] = SpaceAllocDuplicate(utype)
- else:
- self._spaceAllocs[utype] = SpaceAlloc(utype)
复制代码
SpaceAlloc: 普通地图,可以理解为大地图,但整个世界中只能有一个这样类型的地图。
SpaceAllocDuplicate:副本地图,可以复制出很多个
上面函数注册了一个定时器, 这里是定时器的回调, 每一秒回调一次。
self._spaceAllocs[spaceUType].init(), 这里真正开始创建这些space实体, 里面调用的createBaseAnywhere函数来创建实体, 如果启动了多个baseapp这个函数根据负载情况将实体选择到合适的进程中创建。
- def createSpaceOnTimer(self, tid, tno):
- """
- 创建space
- """
- if len(self._tmpDatas) > 0:
- spaceUType = self._tmpDatas.pop(0)
- self._spaceAllocs[spaceUType].init()
- if len(self._tmpDatas) <= 0:
- del self._tmpDatas
- self.delTimer(tid)
复制代码
Space实体创建出来之后,此时还没有真正创建出空间, 这个实体仅仅是将要与某个真正空间关联的实体, 可以通过它来操控那个空间。
但空间只能在cellapp上存在, 因此我们需要调用API让实体在cell上创建出一个空间,并在cell上创建出一个实体与空间关联, 这个实体就像一个空间的句柄。
- class Space(KBEngine.Base, GameObject):
- def __init__(self):
- self.createInNewSpace(None)
复制代码
此功能由createInNewSpace完成, __init__可以理解为Space的构造函数。
3. 为这个抽象的空间增加几何数据
有指定几何数据的空间可以被看做是一个特定的场景, 这些几何数据与客户端对应的场景表现相关联, 例如:导航网格(navmesh), 服务端通过这些数据让NPC进行正确的移动,碰撞等。
上面Space创建cell部分之后, cell上的Space._init__也会被调用, 其中addSpaceGeometryMapping API接口完成几何数据加载工作
(注意:为了加载大量数据不让进程卡顿,这个数据加载是多线程的,它会通过一些回调来告诉开发者加载状态,具体参考API手册)。
- class Space(KBEngine.Entity, GameObject):
- def __init__(self):
- KBEngine.addSpaceGeometryMapping(self.spaceID, None, resPath)
复制代码