【2D游戏引擎】那些年对游戏对象的思考

WIP源代码:

Github

OSC镜像

对象系统以对象为中心,对象系统的最基本设计策略是基于组件的设计。对象系统将尽量避免使用继承方式来拓展游戏对象,恰当的使用Mix-in来来最属性做拓展,单个属性可以适当使用继承。每个游戏对象都是由属性组装起来的。

组件分为两种,c++组件和脚本组件,脚本组件是在脚本中定义的。一般来讲某些脚本组件是 c++组建的封装,这时仅仅是吧 c++组件实例的指针关联到脚本中,所有通信都由此指针链接。

在 c++中当前的主要对象就是 sprite,这个 sprite 在 lua 中对应 GameObject 类,也就是说每一个 lua 中的游戏实例都会对应一个 c++中的 sprite。Sprite 在 c++中会被储存到 scene 中的 Object 层,这个层仅仅会涉及到游戏对象的渲染相关操作,不会涉及到任何游戏逻辑的更新。在 lua 中,GameObject 被保存到一个列表中,每帧都会更新逻辑。也就是说,整个引擎的对象逻辑都是在 lua 中实现的,与 c++没有任何关系,c++部分只负责基础功能的实现。

下图可以说明问题:

【2D游戏引擎】那些年对游戏对象的思考

介于 Lua 的灵活性,所有的游戏对象都是数据驱动的。一个对象的组成由下图所示:

【2D游戏引擎】那些年对游戏对象的思考

必要组件:必要组件都是 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)

end
function 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源代码:

Github

OSC镜像

原文链接: https://www.cnblogs.com/wubugui/p/4525610.html

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/216573

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年2月13日 上午9:28
下一篇 2023年2月13日 上午9:29

相关推荐