文 / 李成,郑鑫
移动互联网浪潮正在彻底改变人们日常的生活习惯和生活方式。相应的,基于移动终端和感应交互的游戏,也为人们带来了全新的游戏体验。本文,我们将结合目前流行的cocos2d-x引擎,使用C++语言,基于iOS平台,和大家分享iPhone、iPad上游戏客户端的构架与实现。
游戏架构与实现
目前,很多基于cocos2d-x的代码基本上仅是对引擎功能的使用,完全不能按照游戏项目的标准来参考。作为游戏项目代码,不仅需要实现游戏的诸多功能,还需要从架构层面,从模块设计的角度来思考和设计,使代码具有更好的复用性和拓展性。
对于游戏客户端,按照功能模块的区别可分为:引擎封装层模块、游戏数据管理模块、应用程序配置模块、日志记录模块、网络管理模块、消息事件机制模块、输入输出控制模块、音效管理模块、UI系统模块、逻辑系统处理模块、调试器控制模块等。针对不同类型的游戏,通常只需要单独实现最上层的游戏逻辑系统,而剩余的模块完全可以复用。下面将详细讲解各个模块的职能与实现(暂不包含游戏逻辑系统)。
引擎封装模块(EngineSystem)
为了减少客户端代码对cocos2d-x引擎的依赖程度和降低耦合度,我们建立了引擎封装层模块,将引擎必要的初始化、逻辑更新、渲染和资源管理等操作全部交给引擎封装层处理,使客户端的其他模块不需要过于依赖引擎层。同时,为了避免客户端代码中频繁、直接地调用平台相关的诸多功能,我们还将一些平台相关的功能全部封装在引擎封装层模块内。
cocos2d-x功能很多很强大,但在开发时,需要根据项目需要有条件选择引擎功能(当然,cocos2d-x本身设计实现的很好)。例如,在引擎封装层内部,我们仅使用了一个CCScene对象,在设计之初就刻意避免处理多个CCScene之间的初始化、跳转、销毁、更新等操作,极大地简化了逻辑层代码,降低了复杂度,且到目前为止,在表现效果上没有什么影响。
数据管理模块(DBSystem)
在开发过程中经常会存储和读取大量的静/动态数据,对比我们针对这部分设计了DBSystem模块,专门进行游戏数据层的管理。每种类型的游戏数据都会派生出一个具体的类,如音效数据管理器、图片数据管理器等。这些数据管理器都在DBSystem内部统一进行初始化、更新和销毁,并各自使用单例模式。外层使用时,直接通过其类进行数据读取即可,无须关心其初始化、逻辑更新、销毁等操作。同时,为了时刻对游戏的静态数据进行监控,所有的数据模块都暴露了获取其所包含数据的接口,这样我们就可以在游戏中随时获取数据层的信息,方便进行统计和监控。
应用配置系统(VariableSystem)
开发者一般需要对应用属性进行可配置化处理:一方面可以方便开发者快速开启/屏蔽某些功能;另一方面能更人性化地支持用户偏好设置。目前,我们根据类型的不同,建立了账号、网络、日志三种配置文件,分别对游戏账号、游戏功能信息、游戏网络配置信息、游戏日志配置信息进行动态设置,全部使用XML进行数据存储和读取。
在开发中通常需要保存大量临时数据,这些数据往往被放在各个模块内部,如果其他模块需要使用,就造成两个模块间强行依赖,增加了耦合度。所以,我们将所有临时需要的数据统一定位为内存配置数据,放在我们的应用配置系统中,其和账号、网络、日志配置文件的区别在于:基于文件配置的属性数据都需要在程序退出时强行写回文件,而基于内存配置的属性数据无须保存。
音效控制模块(SoundSystem)
iOS平台并没有十分完善的音效引擎,而一般自行实现的音效库都难以进行拓展和支持跨平台,所以我们直接选取流行的FmodEx引擎,进行游戏音效的播放和管理。同时,结合FmodEx提供的强大接口,可以很方便地实现声音大小设置、暂停、循环、3D音效等操作,完全满足一般游戏的需求。
在游戏中,一般都需要频繁的播放多个音效,为了提高效率并节省内存,我们在逻辑层对每一个音效文件都使用了引用计数技术,对同一种音效文件仅需通过计数的方式维持一份实例即可,同时播放的多个相同音效,实际上都是使用同样的一份实例而已,无须单独创建音效实例;另外,通过引用计数,很好地解决了音效资源回收的问题,当音效资源计数为零时,即表示其可以被回收,对应的资源,占用的内存也将被释放。
日志系统模块(LoggerSystem)
为了方便在开发、运营期对出现的问题及时进行定位和排查,对游戏中关键的处理流程都需要进行日志记录。在客户端,我们仿照Log4J的方式,实现了分级(Trace级、Info级、Error级等)、分文件、分输出方式的强大日志管理。游戏的日志模块,结合了应用配置系统,完全实现了动态化配置,通过对日志配置文件进行设置修改,开发者可以很方便地设置日志的开启等级、输出方式、大小拆分、输出名称等。另外,对于客户端日志模块,无须过多考虑其性能问题,所以我们的日志模块,完全是简单地在主线程里进行文件写入,没有多开线程进行文件操作。
消息事件系统模块(EventSystem)
考虑到客户端框架总体的拓展性,我们完全使用事件驱动模型(Event-driven)来设计和开发,将客户端中事件的触发时机和具体处理逻辑彻底分隔开。游戏的各个模块,仅需要注册、监听和实现其关心的消息事件,而无须关心事件何时被触发,降低了总体耦合度。目前游戏中所有UI面板的隐藏/显示、事件响应、音效的播放/停止、游戏流程的切换、游戏角色状态迁移等,完全通过事件驱动方式开发;同时这种基于事件的处理方式,为项目使用动态脚本拓展提供了支持:脚本层省去对逻辑代码的大量直接调用,通过消息事件完成脚本层和逻辑层的交互调度,大大简化了开发的复杂度。
UI系统(GUISystem)
cocos2d、cocos2d-x这两种引擎本身并没有提供太多UI控件,仅提供了按钮、进度条等基础控件,如果想使用更多的UI控件,需要开发者借鉴或使用其他成熟的GUI引擎,如CEGUI等。
在我们的UI系统中,充分借鉴了CEGUI的设计思想。整体上,将游戏中有关联的UI控件集中到一个个单独的CCLayer上,组成多个独立的Layout,也就是我们在代码中定义的IWindow类。每一个IWindow类,都包含其自身的根面板(CCLayer)和众多依附在其上的子UI控件。通过IWindow,我们实现了对所有UI布局Layout进行统一的接口调用和处理,如初始化加载、消息注册与响应、隐藏/显示、销毁等。
虽然我们在之前的多个项目开发中提倡将窗口的逻辑实现全部交给动态语言(Python&Lua)来实现,但对于某一些UI功能,并不适合使用脚本来进行逻辑拓展。
对于这类难度比较大、复杂度特别高的UI处理,使用原生的C++开发可能更为合适,所以,在UI系统中,针对这两种不同的需求,我们对IWindow进行了拓展:对需要使用脚本来拓展逻辑的Layout,派生出UIWindowByScript类,其内部主要通过消息事件机制将对应窗口的初始化、加载、逻辑更新、事件处理、销毁等操作传递给对应的脚本逻辑处理;对于需要使用C++来进行处理的Layout,直接根据功能需要,从IWindow上派生出各个具体的实现类。
目前的UI布局还未完全通过外部配置文件动态实现,我们将借鉴CEGUI的处理方式,将所有UI的布局信息、控件属性与事件响应处理等全部使用外层配置文件实现,从而将这些静态信息和程序分隔开,达到动态配置的目的。
网络管理模块(NetSystem)
考虑到传统类型游戏和即时竞技类游戏的差异性,在设计网络模块之初,我们就同时支持了UDP和TCP两种通信方式。
为了支持UDP通信方式,我们使用监听器模型,在客户端中设计了UDPAcceptor管理器,专门进行UDP通信方式的初始化、操作和销毁。同时,考虑到UDP通信方式的特殊性,在UDPAcceptor内部,我们对收到的数据进行了杂乱包的过滤,并且使用序列号技术(Sequence Number),实现了UDP数据包先后顺序的管理和纠正,确保最终交给逻辑层处理的数据包,都是完整可靠有序的。
对于TCP通信方式,我们使用连接器模型,在客户端中封装了TCPConnector管理器。为了解决粘包、拼接、临时数据拷贝等问题,我们在内部设计了特殊的MemNode存储结构,将读取的数据全部存储到MemNode里,其内部根据当前数据的读、写指针位置来进行有效数据定位,确保最终交给上层逻辑使用的数据包,都是完整独立的数据。
网络模块面临着频繁的数据接受和发送,如果不进行控制,频繁的new/delete对性能会有一定影响。因此,我们在网络系统内部使用链表式内存池技术,对接收和发送的数据包都通过该内存池进行统一分配、回收和管理,从根本上解决了频繁的new/delete。另外,在NetSystem内部,使用网络编程中传统的Selector模式进行网络连接的监听、轮询、读取和发送。目前市面上很多游戏对网络模块都是单开线程进行处理,而在我们考虑到多线程同步、数据串行化等问题,所以尽量避免了多线程的方式,使用非阻塞式I/O,全部在逻辑主线程里面进行网络控制管理。
输入控制系统(InputSystem)
因为cocos2d-x本身提供的UI控件都有其自身的输入响应机制,所以我们很难直接修改游戏的输入控制系统。此处我们讨论的输入控制系统,主要就是针对CCLayer进行的Touch和Accelerate事件控制与管理。
在游戏中,为了对Touch和Accelerate事件进行统一的管理和处理,在整个客户端的窗口上,我们特意设计了一个最底层的UILayer(也就是所有UI布局Layout的根窗口)。整个游戏中,仅这个根窗口的CCLayer监听了Touch和Accelerate事件,其内部对两种事件进行捕获和处理,然后将对应的事件,存储在内部的输入消息队列中,通过消息事件,通知当前各个游戏管理器调度和处理。
调试器系统(DebugerSystem)
在游戏过程中,需要时刻对游戏内的各项性能指标进行监控,判断各个模块、各个环节是否存在着重大的性能问题。因此,我们在客户端中,彻底将调试、监控作为一个核心模块来设计开发,目前主要分为网络模块调试器(NetDebuger)、渲染模块调试器(RenderDebuger)、逻辑模块调试器(LogicDebuger)三大模块(可根据需要动态增加)。对于NetDebuger,主要监控当前网络上下行数据量、接受和发送的数据包个数、网络接受和发送的耗时信息等,并通过曲线图展示;对于RenderDebuger,主要用于监控当前客户端渲染的具体信息,例如DIP数量、像素填充率、渲染顶点数量等,开发者可以通过这些渲染引擎判断当前渲染是否存在性能问题;对于LogicDebuger ,管理和统计所有逻辑对象个数、大小、逻辑处理耗时等。我们在实际开发中,通过自行开发的游戏逻辑层调试器系统,结合XCODE本身提供的强大监控工具,可以非常完善仔细地监控各个模块的详细数据、内存、处理耗时等信息,完全满足一般的开发监控需求。
引擎改进与公共代码库游戏中的UI面板,难免会使用到模态窗口,但cocos2d-x引擎并没有提供类似的功能,为了避免在逻辑层编写大量冗余代码来实现该功能,我们需要对cocos2d-x引擎进行必要的修改:在cocos2d-x消息事件处理(CCTouchDispatcher)内部,存在一个事件响应队列,对于所有关心Touch事件的对象,按照优先级从小到大排序,优先级越小,则越优先调度。所以,如果我们需要实现模态窗口的功能,需要自己管理所有UI控件的消息响应级别,并且按照控件之间的父子依赖关系,实现一个类似树的优先级结构,每次需要实现模态窗口时,只要确保其对应的事件优先级在最顶层,并且在处理完消息之后,屏蔽掉该消息,避免继续传递到下面的窗口。
考虑到不同开发者对操作系统、底层接口等熟悉程度不同,为了降低开发成本,我们开发了一套基于iOS平台的基础代码库cobra_ios,其内部封装了开发中常用的各种接口:整套完善的线程安全容器,如数组、单链表、双向循环列表、队列、二叉树等;各种线程控制模型和串行化接口、各种内存管理技术、数据解包器DPacket和数据组包器EPacket等。在开发中,所有开发者都统一使用该基础库,使用相同的接口处理,方便开发且提升效率。
拓展
cocos2d-x VS. Cocoa
前文提到,cocos2d-x引擎本身没有提供太多UI控件,除开发者自行实现外,我们还可以使用iOS标准的UI控件。熟悉Win32、MFC的朋友都知道,Win32标准控件很难与DirectX结合,因为两者是完全相同的渲染机制,但在iOS平台,cocos2d-x与Cocoa自带的UI控件完全兼容,并可以相互调用,例如在游戏登录界面,我们就可以使用Cocoa自带的NSTextField控件来实现账号和密码输入框。因此,对于游戏开发者来说,学好Cocoa很有必要。
其他
由于iOS平台不支持以动态链接库的方式使用第三方库,所以我们不可能选择Boost+Python的方式进行脚本拓展,不过我们还可以选择Lua等其他可行性比较高的动态语言。“工欲善其事,必先利其器”,对于中大型项目,除开发客户端外,一般还需要单独开发编辑器进行关卡、场景等编辑处理。对于编辑器的开发,由于引擎cocos2d-x本身完全跨平台,所以我们完全可以使用自己熟悉的语言和平台来开发,选择使用Cocoa、MFC、C#等熟悉的语言,只要确保所有最终的关卡场景数据可被跨平台读取即可。
总结
移动时代,游戏的操作方式,已不仅仅限于传统的鼠标键盘模式,随着触摸、摄像、语音、重力感应等更多操作形式的普及,也为游戏带来了前所未有的机遇和挑战。
作者李成,网名关中刀客,曾参与制作过多款端类MMO、Web Game等游戏项目,目前热衷于移动设备的游戏制作。
作者郑欣,游戏客户端程序员,参与制作多款游戏项目,曾供职于GameLoft北京手游部门、盛大游戏老牌游戏工作室,现任职于腾讯游戏,负责拓展国际业务。