zoukankan      html  css  js  c++  java
  • 【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++部分只负责基础功能的实现。

    下图可以说明问题:

    介于 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)
    
    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镜像

  • 相关阅读:
    安全性测试的测试点
    Python基础,,小题目
    Python画小猪佩奇
    Python代码
    Python画圆
    python编写贪吃蛇游戏
    主键、外键的作用,索引的优点与不足
    LoadRunner分为哪三个模块?请简述各模块的主要功能。
    测试结束的标准
    坚持“5W”规则,明确内容与过程
  • 原文地址:https://www.cnblogs.com/wubugui/p/4525610.html
Copyright © 2011-2022 走看看