- makefile
- lua与c++的互调
- zt技能系统
- zt任务系统
- Cocos的内存回收机制
- tableview、scrollview
- 主界面技能轮盘
- 小地图实现
- 九屏消息
- 菜单红点
- 侧边下拉菜单
- 获取键盘输入
- 打图,读图
- 场景管理
- UI管理
- 热更
- tolua
此笔记以Mac系统为平台。
makefile
读makefile的时候看到这么一句:
if ls &(SOME_PATH)/*.xml > /dev/null 2>&1; then xxx; fi
首先是条件判断if,后面跟的是ls
显示当前目录文件的命令,只要当前目录有文件,那么条件判断就会通过,执行then后的语句,如果有多条语句要执行需要用分号隔开,并在结束时加上fi(finish if)。SOME_PATH是前面定义的变量,这里是一个路径,makefile中调用变量需要用$(VAR)格式,变量名一般为大写。
最后一部分是控制输出的重定向,shell命令分为标准输入(stdin)、标准输出(stdout)和标准错误(stderr),描述符分别为0、1、2,也就是说我们可以用这三个数字在命令中重定向标准输出和标准错误,它们默认是显示在终端上,但我们可以用>
操作把它们写入文件,/dev/null是一个特殊文件,所有传给它的信息都会被丢弃,我们也不能从中读取任何信息,相当于一个垃圾箱,然后我们可以用&
操作符指定通道名,那么2>&1
就表示2通道的信息走和通道1一样的路,在上面的例子中就一起传入/dev/null被丢弃了。另外1通道描述符可以省略。
lua与c++的互调
lua脚本中可以用形如seal.SealUtilToLua:someFunc()
的语句来调用C++中的方法,原因是初始化时C++中使用了tolua绑定若干需要在lua层调用的方法,并且使用了嵌套层级来根据类型区分要绑定的方法。
zt技能系统
选定目标
攻击检查
伤害结算
远程攻击反馈
角色攻击时如果是远程攻击,当弹道到达目标点时,C++会调用lua脚本PopNumberManager
的onMagicBoom
方法显示上漂数值,需要显示的数字根据attacker、target、skillid作为索引储存在一个表中,其中还存有当前的服务器时间,最后准备显示时,根据与服务器的时间戳比对,只显示与当前时间相同的数字,具体显示的方法在cCharacterExt.cpp
中定义,由脚本SceneNpc.lua
调用,最终在GameScene.cpp
的Update
方法中每帧更新要显示的数字(如果有)。
zt任务系统
类似于很多其他rpg游戏,界面左侧会有一个任务列表,根据任务的优先级由上往下显示若干任务描述,点击后角色会开始自动寻路,如果是敌人则自动攻击,如果是NPC则开启对话,并在结束后(击杀敌人数量达到任务要求或完成对话)自动开始该任务的下一个流程,期间如果用户有移动输入,会打断寻路,这应该和寻路的逻辑有关。
响应点击
首先九屏消息中会发来地图信息数据,如果没有创建场景则用sealp.GameSceneExt:createScene()
创建,然后将创建的实例保存在GameScene脚本中,并为其layer注册onTouchEvent
方法(这里有个问题,注册时是用一个layer实例调用onTouch方法,但我并没有在文档中找到这个方法的说明和实现),这样玩家点击屏幕时就会调用它。
正是因为上面注册了处理点击事件的方法,GameScene脚本中的update才能检测屏幕上有无输入,如果有,则用onProcessInputEvent
处理点击的坐标,它会调用C++层的方法遍历场景中的NPC和其他玩家,看看是否点击了其中任何一个,如果是则返回那个实例,然后在lua脚本中处理点击的事件,如果没有选中东西,则播放点击特效,然后让主角移动过去(如果可以),注意不能直接调用移动接口让主角移动,而应该在事件队列中加入一个移动的事件,交给由主角脚本去处理。
用户直接点击任务标签应该就是调用了前去任务npc处的寻路接口。
寻路
寻路实现在GodConfig
脚本中,其中goto
方法是通过pathID
读取路线中NPC等配置,通过一系列的条件判断,最后在回调函数中调用moveTo
方法开启寻路。注意如果在调用事件(addCallEvent)中使用了多于一个的事件,那除第一个事件的其他事件都要继续使用add***Event
方法加入事件处理队列。
如果在副本中要先去pathID对应的一个坐标处,再调用一次goto
或moveTo
去最终的目标点。如果目标国家不是当前国家,就要跨国寻路,跨国寻路时要先寻路到边境,寻路接口在TransportData
脚本中,如果返回为true
则说明寻路成功,调用gotoTransport
方法去到目标点(存疑)。如果目标点就在当前地图,当与目标点距离比期望值大时,调用主角脚本的addMoveEvent
方法。
如果是同一个国家的不同地图,就要考虑地图之间传送点的问题,TransportData
中的方法findPath
就是搜索不同地图间的传送点,算法就是简单的广度优先搜索,得到从当前地图到达目标地图所需的最少传送点的列表,然后去列表中第一个传送点的位置,即递归的调用goto
或moveTo
,知道到达目标点位置。
主角脚本中有一个成员mInputCommandQueue
用来储存输入的事件,然后每帧会被processInput
方法处理,MoveEvent会被on_MoveEvent
方法处理,最后调用的也是god.moveTo
方法。
角色本身的移动逻辑是在自身脚本中的moveTo
方法中实现,这个方法继承自SceneNpc
脚本,最后调用的是c++中的cCharacterExt.requestPath
方法,经过一系列传递后在CAStar.cpp中的FindPath
方法实现寻路,实现是基本的A*寻路,用map作为开闭表记录搜索的节点,通过计算节点xy坐标的哈希值来索引。寻路完成后,路径会保存在成员m_Path
中,然后每次查询下一个节点,就会把该节点储存到成员m_PathPreNode
中,再用专门的方法去访问它的xy坐标。
接任务
接任务需要去到相应NPC对话,无论是通过点击NPC还是调用专门前往接任务的接口,如果目标位置存在NPC,则GameScene中会用Util中的分发接口去根据NPC类型调用自己脚本处理NPC逻辑的方法(很迷),该方法会在玩家事件列表中加入一个移动过去的事件和访问NPC的事件,SceneManager中的update每帧会让主角跑处理事件的脚本,队列会按上面提到的逻辑调用寻路接口前往NPC,到达后会调用finishFirstEvent
方法(关于注册寻路时回调的方式还没有弄清楚,只知道是SceneNpc脚本中使用了ScriptHandlerMgr),然后处理下一个事件,也就是访问NPC事件。访问NPC会给服务器发一条消息请求访问,服务器回了之后告诉客户端访问的是哪个NPC,根据NPC出发相应的对话,接受任务。
zt中找NPC接任务的逻辑这是接收了服务器发过来的lua脚本字符串,也就是说和某个NPC对话时弹框的信息、按钮list、点击响应全都由服务器决定,客户端本地只提供显示对话ui和刷新ui的接口,我认为这么做的理由是保证任务的完整性,否则破解客户端就意味着可以跳过流程直接领取任务奖励了。任务对话框的确定按钮会激活自动做任务的状态,并像向服务器发送下一个任务分支的id和npcid,服务器会告知客户端当前该干什么应该去哪,到了后再次和NPC对话,循环往复,直至任务结束。
以一个护送任务为例,护送对象会跟随玩家,但移动速度较慢,玩家需要走走停停带领护送对象到达目的地
跨地图寻路
最短路径快速算法(SPFA)
任务栏
是一个和UIOperation一样常驻的ui实例,任务信息会根据配置显示当前的任务分支下的信息,点击后调用寻路方法寻路至目标NPC处,根据任务进行的进度会跳转不同的分支,直至这个任务完成,任务完成前它一直会占用任务栏的一个cell,也可以创建为某些活动显示的临时cell,代码中称为虚拟任务,另一篇笔记有写。
Cocos的内存回收机制
以下内容部分为《我所理解的Cocos2d-x》的读书笔记,部分为wiki词条的翻译。
Cocos2d-x没有使用C++11的智能指针来管理内存,原因有二,一是智能指针在性能上会有比较大的损失,shared_ptr为了保证线程安全,必须使用一定形式的互斥锁来保证所有线程访问时其引用计数正确,这对游戏这种实时性很高的程序而言是不可接受的;二是在显式的创建智能指针和引用的语法不够自然(我对这点存疑)。
引用计数:常用的垃圾回收方法,记录对象被引用的次数,被引用时计数加一,引用被销毁时计数减一,当计数为0时就认为这个对象可以被回收了。它的优点是可以尽快回收不被使用的对象,对CPU的负担较小。
基于追踪:最常用的垃圾回收方法,简单来说就是区分哪些对象已经可以被释放空间(deallocate),哪些是从某些根对象(root objects)可以被拿到的(reachable),通常是正在使用的对象,剩下的就是可以被收集的垃圾了。
引用计数之于追踪的优点是对象会在没有被引用时马上就被回收,不需要等到回收周期再回收,这在实时性和有限的内存下非常重要,引用计数实现起来也较为简单。但它相比较追踪策略有两个主要缺陷:
- 频繁的更新是低效率的源头,而追踪法可以通过上下文切换(context switching)等方法极大的影响性能,收集垃圾的过程往往不频繁。其次,引用计数法对每个内存管理的对象都要预留储存计数的空间,而在追踪法中,这些信息被隐性(implicitly)的储存在指向对象的引用中;
- 循环引用,即两个或以上的对象互相引用形成循环的话,因引用计数就永远不会归零了,对于这个问题可以用弱引用(weak reference)策略,让那些往回指的指针(backpointers)不计入引用计数,此外,弱引用是安全的,因为当引用对象变为垃圾时,对其的任何弱引用都会失效,而不是允许其保持悬空状态,这意味着它变成了可预测的值,例如空引用。
Cocos2d-x中的所有对象几乎都继承自Ref
基类,它的主要职责就是对对象进行引用计数管理,当用new
运算符分配内存时,引用计数为1,调用retain()
方法引用计数加1,调用release()
方法则会使引用计数减1,当引用计数归零时release()
方法会调用delete
运算符删除对象并释放内存。同时,我们可以通过autorelease()
方法声明一个指向对象的“智能指针”,这些指针会全部加入AutoreleasePool中,每一帧结束时会对pool中的对象进行清理,也就是说该指针的生命周期为从创建到当前帧结束。清理的机制是,AutoreleasePool对池中每个对象执行一次release操作,因此如果一个对象被创建,但它在这一帧从未被使用,那么执行该操作后会马上被释放。Ref的引用计数并不是线程安全的,在多线程中,我们需要通过处理互斥锁来保证线程的安全。
为了简化声明(先new
再autorelease()
),Cocos2d-x使用static方法create()
来返回一个指针对象,大部分类都可以用这个方法创建。
这就解释了,在zt项目中,用UIManager的getOrCreatePanel
方法创建的ui如果不马上调用showToScene
方法的话,会下一帧就会被析构。
tableview、scrollview
先来看一下背包道具格子实现,zt中背包格子并没有使用tableview,而是直接用了scrollview,根据背包的布局一个个的创建格子UIItemIconBg
,并把它们设置到正确的位置,然后根据背包数据去初始化有道具格子的表现。
再来看福利菜单页签,它由左边的列表和右边的若干界面组成,左边的列表使用了tableview,创建tableview实例后根据左边列表节点的预设大小初始化,经过其他常规初始化后将实例添加为节点的孩子节点,然后调用registerScriptHandler
为tableview注册回调函数,一般来说有四个需要注册,被触摸的回调(cc.TABLECELL_TOUCHED)、tableview中cell的尺寸(cc.TABLECELL_SIZE_FOR_INDEX)、tableview某个位置的cell(cc.TABLECELL_AT_INDEX)、cell的数量(cc.NUMBER_OF_CELLS_IN_TABLEVIEW)。第一个很直观,就是点击回调,具体看要什么功能了,如果一行需要显示多个item每个item有单独的点击回调,就不需要定义这个回调,而对每个item单独设置。第二个和第四个比较好理解,尺寸一般不会变,回调的返回值可以直接写死,数量可以根据需要显示的item布局的高度来返回,因为滑动的每个cell就是单独的一行。第三个回调需要根据idx正确的返回cell的实例,由于tableview为了节省空间只会生成总量多于可视范围内若干数量的cell,实际上是通过这个方法来知道滑动时应该显示哪些cell,如果是第一次调用这个方法,则需要创建tableviewCell,然后将要显示在cell的ui界面加到它的孩子节点。
主界面技能轮盘
主要是旋转的操作,通过比较横纵坐标从点击起始点和后面移动点变化的差值,来决定是顺时针旋转还是逆时针旋转,还是回到起始点,旋转时如果玩家按住轮盘移动,轮盘会跟着手指同步移动,此时是用横纵坐标之和除以二来近似旋转角度,实时设置旋转角,松开手指后根据幅度将轮盘归位,这里使用了rotateTo
方法,使轮盘根据输入的index转到指定位置。注意旋转的是轮盘九个技能icon的父节点,因此旋转后技能icon会偏移,要依次将它们朝相反反向旋转回正确的朝向。
小地图实现
ClippingNode是cocos中的一个用于裁剪节点的类,它继承自Node
,使用时用setStencil
方法设置裁剪的形状(或模块stencil),用addChild
方法设置底图,这样玩家就能看到被裁剪成圆形的小地图。下一步是实时更新地图下的格子坐标,主角每次移动小地图下方的格子坐标都会实时更新,这是先拿到Director上的scheduler,然后用scheduleScriptFunc
注册一个根据主角目前位置刷新格子坐标text的方法,每隔一小段时间就会自动调用这个方法更新显示的坐标。小地图的实例储存在主界面成员中,因此更新小地图表现的方法要通过主界面ui脚本调用。
裁减了地图还不够,小地图还应随玩家的移动而相应的移动,这个功能同样通过上面注册在scheduler中的方法实现,每隔一小段时间根据任务的位置信息修改小地图底图的锚点,然后将其设置在小地图边框的中心,在cocos2dx中改变节点的锚点并不会改变它显示的位置,却会改变它的坐标!因为锚点的位置变了,以前的坐标相当于就表示了不同的位置,例如如果将锚点朝左上角移动,那么如果保持坐标不变,节点会向相反方向也就是右下角移动相应的距离。改锚点而不直接改坐标的理由,我认为是从小地图的表现来说,主角的点必须永远在中心,如果要设置位置,那么主角往上走地图就要往下走,比较反直觉容易出错,直接用锚点表示主角当前在地图上的位置,然后总是把地图坐标设在小地图边框的中心,这样的实现其实是符合直觉的。
锚点坐标的大小被限制在0-1区间内,因此要得到是当前玩家位置相对于地图长(或宽)的比例,地图是由格子组成的,角色的坐标并不是格子坐标,而地图的宽度又是以格子为单位,因此中间涉及到一些计算格子坐标的步骤,还要根据地图两边可能的空隙进行缩放,刚开始看可能有点晕,但只要搞清楚我们最后要的是什么,代码就很好理解了。地图信息在MinMap
配置文件中。总体看来这部分代码还算可以,只是有些api实现了重复的东西,一些和格子坐标转换的api设计不是很合理,好多地方除了GridWidth过会又乘回去,做一些重复操作,很蛋疼,然后很多不同意义的长宽变量都命名成xy,阅读时容易造成混淆。
九屏消息
九屏消息由服务器发出,当主角九屏范围内有NPC进入时会有消息告知,通常调用SceneNpc
中的createEntity
方法创建NPC实例,由SceneManager管理。
小地图刷新npc位置
如果观察未裁剪的小地图,会发现上面npc代表的点会在玩家靠近时才显示,如果离开一定距离就不显示,这应该就是使用了九屏消息刷新npc与玩家的位置关系,避免不必要的开销。
菜单红点
例如设置->账号->绑定手机这三级菜单会因为绑定手机这个状态而显示红点,每个ui脚本都有自己的查询是否应该显示红点的方法,首先ui创建的时候查一次,点击后显示下级菜单前再查一次,最里面的按钮点击后再查一次,如需要实时刷新所有ui,则需要根据父子关系或拿到实例调用刷新红点的方法,或是各ui直接根据服务器发来的消息刷。
侧边下拉菜单
商店侧边有商品分类的若干标签,点击后中间显示商品,如果有其他子菜单则显示下拉子菜单,将其他cell挤下若干格,再次点击则收回。实现上还是使用的tableview,在tableCellTouched和tableCellAtIndex这两个方法中定义了相应的行为
获取键盘输入
首先创建EventListenerKeyboard,然后注册相应按键输入的方法,这个方法会根据键盘的输入传入相应的值,我们根据这个值来决定执行什么行为。
打图,读图
本地编译的时候游戏中的图形资源是通过makefile从策划目录拉下来的,图片资源会按照文件夹的层级从散图做成整图plist和pvr.ccz格式的文件,使用的工具是TexturePacker,但CocosStudio工程导出的lua脚本中读取的还是散图,因此要用一个python脚本将其中的内容改为读取整图。
场景管理
场景中的玩家、NPC是调用了C++中cCharacterExt的构造函数,生成了cCharacter的实例,而生成实例后NPC在什么位置,长什么样,由服务器发过来的消息决定,一般为九屏消息。和NPC对话内容是执行服务器发来的lua脚本,使用loadstring(lua5.3已经弃用)读取字符串内容,保存到一个local变量func,执行func()
后如果没有错,那么其中定义的函数已经可以使用了。zt中没有直接去调用这些函数,而是定义了一个空的表evn,然后将储存了全局变量的_G作为evn的__index元方法,再用setfevn(set function environment)将evn设置为func的环境,最后调用evn.dlgMain(self)
,其中self为ui脚本的实例。
场景切换,场景加载
UI管理
由UIManager管理的ui实例存在UIManager脚本中,update函数中去遍历检查其是否需要销毁以及该ui是否有update需要执行,关闭界面函数实际上是调用了UIManager中的方法把该ui实例的标志位置为true,这样它就会在下一次update循环中被销毁。
红点刷新
界面icon的面板刷新时,会刷新活动icon,活动icon储存了若干子icon,若其中有需要显示的,则显示自身,且检查是否有需要显示的红点,若子icon有红点,则活动icon也显示红点,如果子icon都不显示,则自己也不显示