游戏架构/设计问题-在避免全局实例的同时构建高效的引擎(C ++游戏)


28

我对游戏体系结构有一个疑问:使不同组件相互通信的最佳方法是什么?

如果这个问题已经被问了一百万遍,我真的很抱歉,但是我找不到任何与我正在寻找的信息完全相同的信息。

我一直在尝试从头开始构建游戏(如果需要的话,请使用C ++),并观察了一些开源游戏软件的灵感(超级玛丽纪事报,OpenTTD等)。我注意到许多此类游戏设计在整个地方使用全局实例和/或单例(用于渲染队列,实体管理器,视频管理器等)。我试图避免使用全局实例和单例,并构建一个尽可能松散耦合的引擎,但是由于我对有效设计的缺乏经验,我遇到了一些障碍。(此项目的部分动机是解决这个问题:)

我已经建立了一个设计,其中有一个主要GameCore对象,该对象的成员类似于我在其他项目中看到的全局实例(即,它具有一个输入管理器,一个视频管理器,一个GameStage控制所有实体和游戏玩法的对象)对于当前加载的任何阶段等)。问题在于,由于一切都集中在GameCore对象中,所以我没有一种简单的方法来使不同的组件相互通信。

例如,以《超级玛丽纪事》为例,每当游戏的某个组件需要与另一个组件进行通信时(即,一个敌对对象想要将其自身添加到要在渲染阶段绘制的渲染队列中),它只会与全局实例。

对我来说,我必须让我的游戏对象将相关信息传递回该GameCore对象,以便该GameCore对象可以将该信息传递给需要它的系统的其他组件(即:对于上述情况,每个敌人对象会将其渲染信息传递回GameStage对象,对象将收集所有信息并将其传递回GameCore,然后依次将其传递给视频管理器进行渲染)。这确实感觉像是一个非常可怕的设计,而我正试图考虑一个解决方案。我对可能的设计的想法:

  1. 全局实例(Super Maryo Chronicles设计,OpenTTD等)
  2. GameCore对象充当中间对象,所有对象都通过该中间人进行通信(上述当前设计)
  3. 为组件提供指向它们将要与之交谈的所有其他组件的指针(即,在上面的Maryo示例中,敌人类将具有一个与之交谈的视频对象的指针)
  4. 将游戏分为子系统-例如,在对象中具有管理器对象,以GameCore处理子系统中对象之间的通信
  5. (其他选项?...。)

我以为上面的选项4是最好的解决方案,但是我在设计它时遇到了一些麻烦……也许是因为我一直在考虑使用全局变量的设计。感觉就像我正在处理当前设计中存在的相同问题,并在每个子系统中以较小的比例复制它。例如,GameStage上面描述的对象为此在某种程度上做了尝试,但是该GameCore对象仍然参与该过程。

有人可以在这里提供任何设计建议吗?

谢谢!


1
我了解您的直觉,认为单例不是很好的设计。以我的经验,它们是管理系统间通信的最简单方法
Emmett Butler

4
添加为评论,因为我不知道这是否是最佳做法。我有一个中央GameManager,它由子系统(例如InputSystem,GraphicsSystem等)组成。每个子系统都将GameManager作为构造函数中的参数,并将对引用的引用存储到类私有成员。那时,我可以通过GameManager引用访问任何其他系统。
Inisheer 2013年

我更改了标签,因为这个问题与代码有关,而不是游戏设计。
Klaim 2013年

这个线程有点旧,但是我有完全一样的问题。我使用OGRE,并尝试使用最佳方法,我认为选项4是最佳方法。我已经建立了类似Advanced Ogre Framework的东西,但这不是很模块化。我认为我需要一个子系统输入处理程序,该处理程序只能获得键盘点击和鼠标移动。我不知道的是,如何在子系统之间创建这样的“通信”管理器?
Dominik2000

1
@ Dominik2000,您好,这是一个问答网站,而不是论坛。如果您有问题,则应发布实际问题,而不是现有问题的答案。有关更多详细信息,请参见常见问题解答
2013年

Answers:


19

我们在游戏中用于组织全局数据的东西是ServiceLocator设计模式。与Singleton模式相比,此模式的优点在于,全局数据的实现可以在应用程序运行时更改。同样,您的全局对象也可以在运行时更改。另一个优点是,更容易管理全局对象的初始化顺序,这在C ++中尤其重要。

例如(可轻松转换为C ++或Java的C#代码)

假设您有一个渲染后端接口,该接口具有一些用于渲染素材的常用操作。

public interface IRenderBackend
{
    void Draw();
}

并且您具有默认的渲染后端实现

public class DefaultRenderBackend : IRenderBackend
{
    public void Draw()
    {
        //do default rendering stuff.
    }
}

在某些设计中,似乎能够全局访问渲染后端是合法的。在Singleton模式中,这意味着每个IRenderBackend实现都应作为唯一的全局实例实现。但是使用ServiceLocator模式不需要这样做

这是如何做:

public class ServiceLocator<T>
{
    private static T currGlobalInstance;

    public static T Service
    {
        get { return currGlobalInstance; }
        set { currGlobalInstance = value; }
    }
}

为了能够访问您的全局对象,您需要首先对其进行初始化。

//somewhere during program initialization
ServiceLocator<IRenderBackend>.Service = new DefaultRenderBackend();

//somewhere else in the code
IRenderBackend currentRenderBackend = ServiceLocator<IRenderBackend>.Service;

只是为了演示实现在运行时如何变化,我们假设您的游戏有一个微型游戏,其中渲染是等轴测的,而您实现了IsometricRenderBackend

public class IsometricRenderBackend : IRenderBackend
{
    void draw()
    {
        //do rendering using an isometric view
    }
}

当您从当前状态过渡到迷你游戏状态时,您只需要更改服务定位器提供的全局渲染后端即可。

ServiceLocator<IRenderBackend>.Service = new IsometricRenderBackend();

另一个优点是您也可以使用空服务。例如,如果我们有一个ISoundManager服务,而用户想关闭声音,则可以实现一个NullSoundManager,该方法在调用其方法时不执行任何操作,因此通过将ServiceLocator的service对象设置为NullSoundManager对象,我们可以实现这个结果几乎不需要任何工作。

总而言之,有时可能无法消除全局数据,但这并不意味着您不能正确地以面向对象的方式组织它们。


我之前对此进行了研究,但并未在我的任何设计中实现它。这次,我打算。谢谢您:)
Awesomania

3
@Erevis因此,基本上,您正在描述对多态对象的全局引用。反过来,这只是双重间接寻址(指针->接口->实现)。在C ++中,它很容易实现为std::unique_ptr<ISomeService>
雨中的阴影'2013年

1
您可以将初始化策略更改为“首次访问时进行初始化”,并避免需要一些外部代码序列来分配定位器并将其推送给定位器。您可以在服务中添加一个“依赖于”列表,以便在初始化一个服务时将自动设置它需要的其他服务,而不必在main.cpp中祈祷有人记得这样做。一个很好的答案,可以灵活地进行将来的调整。
Patrick Hughes

4

设计游戏引擎的方法有很多种,而且实际上都归结为偏好。

为了使基础变得更简单,一些开发人员更喜欢像金字塔一样进行设计,在金字塔中有一些顶级核心类(通常称为内核,核心或框架类)来创建,拥有和初始化一系列子系统,例如包括音频,图形,网络,物理,人工智能以及任务,实体和资源管理。通常,这些子系统由该框架类提供给您,并且通常您会在适当的情况下将此框架类作为构造函数参数传递给您自己的类。

我相信您在选择方案4时走在正确的道路上。

在通信本身时要记住,这并不总是意味着直接调用函数本身。可以通过多种间接方法进行通信,无论是通过某种间接方法使用Signal and Slots还是使用Messages

有时候在游戏中,重要的是要允许动作异步发生,以使我们的游戏循环尽可能快地移动,以使帧频对肉眼可见。玩家不喜欢缓慢而断断续续的场景,因此我们必须找到办法让事情顺其自然,但要保持逻辑顺畅,但还要加以检查和安排。尽管异步操作占有一席之地,但它们也不是每个操作的答案。

只知道您将同时拥有同步和异步通信。选择合适的,但是知道您需要在子系统之间同时支持两种样式。为这两者提供支持将为您提供良好的服务。


1

您只需要确保没有反向或周期性依赖关系即可。例如,如果您有一个class Core,它Core具有一个Level,并且Level具有一个的列表Entity,则依赖关系树应类似于:

Core --> Level --> Entity

因此,在有了这个初始依赖关系树的情况下,您永远不应Entity依赖LevelCoreLevel也永远不应依赖Core。如果有一个LevelEntity需要访问依赖关系树中更高级别的数据,则应通过引用将其作为参数传递。

考虑以下代码(C ++):

class Core;
class Entity;
class Level;

class Level
{
    public:
        Level(Core& coreIn) : core(coreIn) {}

        Core& core;
}

class Entity
{
    public:
        Entity(Level& levelIn) : level(levelIn) {}

        Level& level;
}

使用此技术,您可以看到每个人Entity都可以访问Level,而则每个人都可以Level访问Core。注意,每个Entity存储对相同的Level浪费内存的引用。注意到这一点后,您应该质疑每个人是否Entity真的需要访问Level

以我的经验,或者有A)避免反向依赖的真正明显的解决方案,或者B)没有避免全局实例和单例的可能方法。


我想念什么吗?您提到“您永远不应该让实体依赖级别”,但随后将其描述为“实体(Level&levelIn)”。我知道依赖关系是由ref传递的,但它仍然是一个依赖关系。
亚当·内洛

@AdamNaylor关键是有时您确实需要反向依赖,并且可以通过传递引用来避免全局变量。但是,通常最好是完全避免这些依赖关系,而且并不总是清楚如何做到这一点。
苹果

0

因此,基本上,您想避免全局可变状态吗?您可以将其设置为本地,不可变或完全不设置状态。imo是最新的,最灵活的。这被称为实施隐藏。

class ISomeComponent // abstract base class
{
    //...
};

extern ISomeComponent & g_SomeComponent; // will be defined somewhere else;

0

实际上,问题似乎在于如何在不牺牲性能的情况下减少耦合。所有全局对象(服务)通常形成一种上下文,该上下文在游戏运行时是可变的。从这个意义上讲,服务定位器模式将上下文的不同部分分散到应用程序的不同部分,这可能是您想要的,也可能不是您想要的。另一个现实世界的方法是声明这样的结构:

struct sEnvironment
{
    owning<iAudio*> m_Audio;
    owning<iRenderer*> m_Renderer;
    owning<iGameLevel*> m_GameLevel;
    ...
}

并将其作为非所有者原始指针传递sEnvironment*。这里的指针指向接口,因此与服务定位器相比,耦合减少了。但是,所有服务都集中在一个地方(可能不好,也可能不好)。这只是另一种方法。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.