【2D游戏引擎】那些年对游戏对象的思考
WIP源代码:
对象系统以对象为中心,对象系统的最基本设计策略是基于组件的设计。对象系统将尽量避免使用继承方式来拓展游戏对象,恰当的使用Mix-in来来最属性做拓展,单个属性可以适当使用继承。每个游戏对象都是由属性组装起来的。
组件分为两种,c++组件和脚本组件,脚本组件是在脚本中定义的。一般来讲某些脚本组件是 c++组建的封装,这时仅仅是吧 c++组件实例的指针关联到脚本中,所有通信都由此指针链接。
在 c++中当前的主要对象就是 sprite,这个 sprite 在 lua 中对应 GameObject 类,也就是说每一个 lua 中的游戏实例都会对应一个 c++中的 sprite。Sprite 在 c++中会被储存到 scene 中的 Object 层,这个层仅仅会涉及到游戏对象的渲染相关操作,不会涉及到任何游戏逻辑的更新。在 lua 中,GameObject 被保存到一个列表中,每帧都会更新逻辑。也就是说,整个引擎的对象逻辑都是在 lua 中实现的,与 c++没有任何关系,c++部分只负责基础功能的实现。
下图可以说明问题:
介于 Lua 的灵活性,所有的游戏对象都是数据驱动的。一个对象的组成由下图所示:
必要组件:必要组件都是 c++定义的组件,这些组件是几乎是每个类都需要的组件。包括id,name,transform 之类的东西。
自定义组件:自定义组件有一部分是 c++定义的组件,另外一部分是数据驱动的通用组件,这些组件由脚本或者其他外部数据定义。在对象初始化的时候才被填充。
目前采用的数据驱动方案如下:
初始化场景需要的数据:
• 所有的组件类型
• 激活标记
• 组件数量
[Sprite 数据]
• 静态纹理
• z_order
[Mesh]
• mesh 尺寸
[transform]
• 世界坐标
• 缩放
• 锚点
• 旋转 [所有的 Lua 组件]
格式如:
[组件名] 数据个数 = n
[组件初始化数据表]
[数据名 1]
[数据名 2]
:
:
[数据名 n] [数据] 数据名 1 = n1 数据名 2 = n2 ::数据名 n = nn
初始化过程:
读取场景文件 ----> 解析初始化数据 ----> 将初始化好的对象全部加入 Lua
GameObject 队列 ----> 每个对象调用 ComponentInit 函数初始化所有组件
----> 进入主循环
GameObject 中有一个表是专门用来放置“Component name”-“Component”对的,这些 Component 特指那些 Lua 定义的 Components,访问这些 Components 只能通过这个表使用组件名字来访问。一般对一个 go 读写组件的时候形式如下:
function c0:set_component(component_name,component)
components[component_name] = component end
--调用组件只能使用这种方案,否则无法判断组件是否存在 function c0:get_component(component_name) return components[component_name] end
注意一般不会在 update 中每帧都访问 get_component(),一般是在 init 中取得这个组件,然后在 update 中使用。
Lua 自定义组件每个组件必须实现一个数据初始化回调函数,此函用用于在初始化时候的组件数据初始化。在场景文件中储存了所有需要初始化的 lua 组件的所有数据,这些数据都是“数据名”-“数据”对,初始化的时候为了使得这些数据初始化到正确的位置,必须调用组件的数据初始化回调函数,一般的回调函数形式如下:
function c1:set_data(data_name,data_val) if data_name == XXX then xxx = data_val end if data_name == XXX1 then xxx1 = data_val end --... end
注意:上述方案只是暂时替代方案,有违背数据驱动的思想。
一种组件在一个对象中只能有一个,这在为 go 添加组件的时候会检查,在初始化的时候也首先会检查此组件是否存在(对于固有组件直接 c==nil,对于 lua 组件为 components[name]==nil),如果此组件已经存在会发出警告或者报错。但是推荐在添加组件的时候控制。
初始化的时候,固定初始化一个 go,然后对这个对象加上指定的组件即可。
所有的组件(不管是 c++组件还是 lua 组件,实际上都是 Lua 写好的),每一个 Lua 写出来的组件必须实现一个无参数的 new 方法,这个方法用于在初始化的时候创建此组件对象。
组件应该实现的方法:
• new():构造方法,一般会在构造的时候调用,此方法仅用于构造,仅
初始化很小一部分的必要元数据
• init():初始化一个组件,所有的初始化都在这个函数中执行
• game_init():游戏逻辑初始化,与 init 不同的是,此初始化仅用于初
始化游戏逻辑,而 init 更多用于系统上的初始化
• update(dt):更新函数,所有的更新都在这里,dt 是当前帧的时间
• game_exit():关卡退出时调用,仅仅是游戏逻辑退出
• exit():关卡卸载时调用,用于系统退出,会清理一些垃圾之类的
bin/engine/script/GameObject.lua 以及 bin/engine/script/Componnents、bin/engine/script/Utilities/SceneLoader.lua 是对这个方案的初步实现。
下面是以前设计的时候瞎写的一份文档,权当参考不对的地方还请高手前辈斧正:
每个对象的属性都是批量更新的,也就是说所有游戏对象的同一个属性将会集中统一到一起更新。不会使用下列风格的更新模:
virtual void Tank::Update(float dt)
{
// Update the state of the tank itself.
MoveTank(dt);
DeflectTurret(dt);
FireIfNecessary();
// Now update low-level engine subsystems on behalf // of this tank. (NOT a good idea... see below!) m_pAnimationComponent->Update(dt); m_pCollisionComponent->Update(dt); m_pPhysicsComponent->Update(dt); m_pAudioComponent->Update(dt); m_pRenderingComponent->draw();
}
while (true)
{
PollJoypad();
float dt = g_gameClock.CalculateDeltaTime(); for (each gameObject)
{
// This hypothetical Update() function updates // all engine subsystems! gameObject.Update(dt);
}
g_renderingEngine.SwapBuffers();
}
取而代之,采用批次更新,使用如下风格,一个优点是可以提高缓存一致性:
virtual void Tank::Update(float dt)
{
// Update the state of the tank itself.
MoveTank(dt);
DeflectTurret(dt);
FireIfNecessary();
// Control the properties of my various engine // subsystem components, but do NOT update // them here...
if (justExploded)
{
m_pAnimationComponent->PlayAnimation("explode");
}
if (isVisible)
{
m_pCollisionComponent->Activate();
m_pRenderingComponent->Show();
} else
{
m_pCollisionComponent->Deactivate();
m_pRenderingComponent->Hide();
}
// etc.
}
while (true)
{
PollJoypad();
float dt = g_gameClock.CalculateDeltaTime(); for (each gameObject)
{
gameObject.Update(dt);
}
g_animationEngine.Update(dt); g_physicsEngine.Simulate(dt);
g_collisionEngine.DetectAndResolveCollisions(dt); g_audioEngine.Update(dt);
g_renderingEngine.RenderFrameAndSwapBuffers();
}
批次更新即是最基本的更新原则,可以根据具体的情况调节更新顺序。
其他引用:
{
使用Variant数据结构作为消息公共参数:
struct Variant
{
enum Type
{
TYPE_INTEGER,
TYPE_FLOAT,
TYPE_BOOL,
TYPE_STRING_ID,
TYPE_COUNT//类型总数
}
Type m_type;
union
{
int m_asInteger; float m_asFloat;
bool m_asBool;
unsigned int m_asStringId;
}
}
另外需要关注的是,对象的依赖关系,有必要按照依赖关系更新对象,可以采用树的结构,会有森林出现。
}
对象消息系统备选方案 1
对象之间的消息传递和事件处理采用消息传递模式,把单个时间封装成类,使用消息队列进行职责链方式传递(类似windows消息队列以及MFC逐级消息传递处理机制)。将事件登记到关联的对象里面去。内存分配解决方案见内存设计方案。
每个事件应该是完全可重入的,也就是在同一帧执行n次和执行1次的效果相同。
对象消息系统备选方案2
数据驱动的事件消息传递系统。即是仅考虑游戏对象传递数据流到其他对象,每个对象含有一个或者多个输入/输出端口。这一点可以参考Unreal Engine的可视化编程系统。但是这种方案实行起来需要更多的工作量。也许可以在选择第一种方案的同时,逐步迭代添加方案二。
脚本系统也将加入到对象系统中,目前选定的方案有两个:
1、回调脚本:使用函数在宿主语言和目标语言之间进行相互调用。
2、组件/属性脚本:在基于组件的设计中,允许脚本或者部分脚本创建新的组件或者属性对象。
参考:数据驱动的设计方案一个对象的组成图如下: 游 戏对象类目前的设计是,它具有一个动态数组
①,这个动态数组用于储存所有游戏对象需要的组件的指针。数组的大小被存放在
②一个静态变量中,这个变量使用一 次性初始化在整个程序开始之前就已经根据外部文件(也许是关卡文件或者是资源数据库)初始化好了,或许也在这个时候申请了此数组,但是不会在此处实例化组 件,实例化组件放在明确的 init阶段或者是构造函数阶段(也许构造函数阶段并不安全,所以放在明确的初始化阶段)。组件的实例化根据外部资源或者其他信 息使用工厂模式进行。
必要组件:必要组件都是c++定义的组件,这些组件是几乎是每个类都需要的组件。包括id,name,transform之类的东西。
自定义组件:自定义组件有一部分是c++定义的组件,另外一部分是数据驱动的通用组件,这些组件由脚本或者其他外部数据定义。在对象初始化的时候才被填充。
③是组件接口,提供组件所有需要的方式,以及或许有组件之间的通信接口。
关于游戏对象间通信:目前的设计是游戏对象之间靠一个消息收发器组件通信。
关于游戏对象内部组件之间的通信:
IComponent是所有组件的基类,这个基类为所有组件提供了首发消息的方法。
关于游戏对象的查询:
典型的查询方法是依次调用:
//游戏对象.组件.方法
Object.transform.position();
对于c++类,这些调用不足为惧;但是对于自定义对象这些方法往往都是脚本方法,所以是否可以使用“.”运算符号来调用还需要更多的思考。
关于脚本属性对c++属性的查询:
这个问题有些棘手,目前想到的解决方案是使用脚本(lua)实现类
(class),然后把游戏对象整体传给脚本,然后由脚本调用对象实例的数据。
关于脚本如何获得数据驱动游戏对象的实例,依然是一个问题。
关于脚本属性对脚本属性的查询:
这个可以在脚本内部实现查询。但这是有问题的,因为脚本无法知道查到的属性属于哪一个游戏对象。所以此问题准备归结到上一个问题。
关于数据流的传递接口:
数据流方法用于实现图形化对象逻辑编程,但是此系统颇为复杂,还尚未设计。
⑤类型的对象分为c++定义类和脚本定义类。
脚本定义类还未设计完成,主要包括以下遗留问题:
1、自定义的脚本组件被设计为由数据和函数组成。数据就是一个组件所包含的数据,函数就是一个组件所包含的功能。类似于一个类的组成,数据成员和函数成员。
现在问题是:
脚本数据如何映射到c++类中,使用Variant类型是一种解决方案,用于动态的创建一组属性,但是新问题是,脚本每次更新数据之后如何传回c++类成员,是每帧都交换一次还是仅在调用时交换。
一种正在考虑的解决方案是:
脚 本定义的组件仅提供函数调用。读写一个数据也只能通过getter和setter 来实现。仅仅只在需要的时候才执行那些函数。这样,在初始化的时候需要在 c++类中注册那些所有在脚本中定义的函数。这样脚本函数只需要执行c++操作和返回数据即可。问题是如何在初始化的时候自动的注册那些函数,引擎怎么知 道需要注册哪些函数?注册的函数又怎么储存?前一个问题可以考虑在脚本中植入一个自定义的初始化函数,这个函数用于在初始化c++类的时候提供所有需要注 册的函数的函数名以及参数个数和类型。但是需要详细思考。
临时辅助方案:
初始化场景需要的数据:
- 对象类型
- 当前所有c++组件类型
- 当前所有Lua组件类型
- 激活标记
[Sprite数据]
- 世界坐标
- 静态纹理
[各c++必要组件的初始化数据]
- Mesh
- Transform
- Collider
[各c++非必要组件]
- Animation
- AudioSource
- AudioListener
- Camera(必须指定主相机,否则无法运行)
- ParticleEmittter
- RigidBody
- ...
[Lua组件]
- ...
目前采用的方案(优先选择树状结构的文件格式):
初始化场景需要的数据:
- 所有的组件类型
- 激活标记
- 组件数量
[Sprite数据]
- 静态纹理
- z_order
[Mesh]
- mesh尺寸
[transform]
- 世界坐标
- 缩放
- 锚点
- 旋转 [所有的Lua组件] 格式如:
[组件名] 数据个数 = n [组件初始化数据表]
[数据名1]
[数据名2]
:
:
[数据名n] [数据] 数据名1 = n1 数据名2 = n2 ::数据名n = nn 初始化过程:
读取场景文件 ----> 解析初始化数据 ----> 将初始化好的对象全部加入Lua GameObject队列 ----> 每个对象调用ComponentInit函数初始化所有组件 ---> 进入主循环
GameObject中有一个表是专门用来放置“Component name”-“Component”对的,这些Component特指那些Lua定义的Components,访问这些Components 只能通过这个表使用组件名字来访问。一般对一个go读写组件的时候形式如下:
function c0:set_component(component_name,component) components[component_name] = component end
--调用组件只能使用这种方案,否则无法判断组件是否存在
function c0:get_component(component_name) return components[component_name] end
注意一般不会在update中每帧都访问get_component(),一般是在init中取得这个组件,然后在update中使用。
Lua自定义组件每个组件必须实现一个数据初始化回调函数,此函用用于在初始化时候的组件数据初始化。在场景文件中储存了所有需要初始化的lua组件的所有数据,这些数据都是“数据名”-“数据”对,初始化的时候为了使得这些数据初始化到正确的位置,必须调用组件的数据初始化回调函数,一般的回调函数形式如下:
function c1:set_data(data_name,data_val) if data_name == XXX then xxx = data_val
end
if data_name == XXX1 then xxx1 = data_val end --... end
注意:上述方案只是暂时替代方案,有违背数据驱动的思想。
一种组件在一个对象中只能有一个,这在为go添加组件的时候会检查,在初始化的时候也首先会检查此组件是否存在(对于固有组件直接c==nil,对于
lua组件为components[name]==nil),如果此组件已经存在会发出警告或者报错。但是推荐在添加组件的时候控制。初始化的时候,固定初始化一个go,然后对这个对象加上指定的组件即可。
所有的组件(不管是c++组件还是lua组件,实际上都是Lua写好的),每一个 Lua写出来的组件必须实现一个无参数的new方法,这个方法用于在初始化的时候创建此组件对象。
组件应该实现的方法:
- new():构造方法,一般会在构造的时候调用,此方法仅用于构造,仅初始化很小一部分的必要元数据
- init():初始化一个组件,所有的初始化都在这个函数中执行
- game_init():游戏逻辑初始化,与init不同的是,此初始化仅用于初始化游戏逻辑,而init更多用于系统上的初始化
- update(dt):更新函数,所有的更新都在这里,dt是当前帧的时间
- game_exit():关卡退出时调用,仅仅是游戏逻辑退出
- exit():关卡卸载时调用,用于系统退出,会清理一些垃圾之类的
下面是一个参考的读取场景的代码段:
(严重注意:这个代码段中有的变量不属 于组件而是属于GameObject对象,这些对象是无法别对其他组件调用的,因为每一个可调用的数据都必须是一个组件的成员,否则此数据无法被组件访 问,如果需要访问这些组件,必须将这些组件打包到一个单独的组件中,比如打包到go组件中。)
function get_component_port(comp_name) s = "local _ = require \""..comp_name.."\";return _:new()" return loadstring(s) endfunction SceneLoader.load_scene(file) --g_game_objcts local scene_ptr = app.scene_create() local go_pak = {} xml.loadxml(file) local go_n = SceneLoader.get_go_total() for i=1,go_n do local ns = tostring(i) if SceneLoader.get_go_type(i)=="object" then --n-th object creating local sprite_ptr = SceneLoader.load_component_sprite(ns) local cmesh = SceneLoader.load_component_mesh(ns) local ctransform = SceneLoader.load_component_transform(ns,sprite_ptr) local canimation = SceneLoader.load_component_animation(ns) local ccollider = SceneLoader.load_component_collider(ns) local go = GameObject:new() local name = SceneLoader.get_go_name(i) canimation:internal_init(sprite_ptr) go:init(sprite_ptr,name) go:set_active(SceneLoader.get_go_active(i)) go:add_mesh(cmesh) go:add_animation(canimation) ccollider:internal_init(sprite_ptr) ccollider:setActive(SceneLoader.get_go_collider(ns,"active")) ccollider:resetType(SceneLoader.get_go_collider(ns,"type")) go:add_collider(ccollider) go:add_transform(ctransform) --load custom components local names = {} local cn = SceneLoader.get_go_components(ns,"n") for i=1,cn do local name = SceneLoader.get_go_components(ns,"e"..tostring(i)) table.insert(names,name) end SceneLoader.load_component_custom(ns,go,names) app.scene_add_object(scene_ptr,go.sprite_ptr) table.insert(go_pak,go) --读取UI对象 elseif SceneLoader.get_go_type(i)=="ui" then local name = SceneLoader.get_wip_node("e"..ns..".name") local uitype = SceneLoader.get_wip_node("e"..ns..".ui") local x = SceneLoader.get_wip_node("e"..ns..".x") local y = SceneLoader.get_wip_node("e"..ns..".y") local w = SceneLoader.get_wip_node("e"..ns..".w") local h = SceneLoader.get_wip_node("e"..ns..".h") local uiret = nil if uitype=="PictureWidget" then uiret = SceneLoader.load_picture(x,y,w,h,ns) elseif uitype=="ButtonWidget" then uiret = SceneLoader.load_button(x,y,w,h,ns) elseif uitype=="ScrollerWidget" then uiret = SceneLoader.load_scroller(x,y,w,h,ns) end uiret.name = name app.scene_add_ui(scene_ptr,uiret.ptr) UI.addObject(uiret) end end local scenepak = {} scenepak.scene_ptr = scene_ptr scenepak.objects = go_pak table.insert(g_running_scenes,scenepak) end
WIP源代码: