(免责声明:我从未用 Java 编写过游戏,只用 C++ 编写过游戏。但总体思路也应该适用于 Java。
我提出的想法不是我自己的,而是我在书籍或“互联网”上找到的解决方案的混搭,请参阅参考资料部分。
我自己使用了所有这些,到目前为止,它产生了一个简洁的设计,我确切地知道在哪里添加我添加的新功能。)
恐怕这会是一个很长的答案,第一次阅读时可能不清楚,因为我不能很好地描述它只是自上而下,所以会来回参考,这是由于我缺乏解释技巧,而不是因为设计有缺陷。事后看来,我做得太过分了,甚至可能跑题了。但是现在我已经写了这一切,我不能让自己把它扔掉。有什么不清楚的就问一下。
在开始设计任何包和类之前,请先进行分析。您希望在游戏中拥有哪些功能。不要计划“也许我稍后会添加”,因为几乎可以肯定,在您开始认真添加此功能之前预先做出的设计决策,您为它计划的存根是不够的。
为了激励,我从这里的经验谈起,不要把你的任务看作是写一个游戏引擎,写一个游戏!无论你对未来项目有什么很酷的想法,都要拒绝它,除非你把它放在你现在正在编写的游戏中。没有未经测试的死代码,也没有由于无法解决对当前项目来说甚至不是问题的问题而导致的动机问题。没有完美的设计,但有一个足够好的。值得牢记这一点。
如上所述,我认为 MVC 在设计游戏时没有任何用处。模型/视图分离不是问题,控制器的东西相当复杂,以至于被称为“控制器”。
如果您想拥有名为模型、视图、控制的子包,请继续。以下内容可以集成到此封装方案中,尽管其他内容至少同样合理。
我的解决方案很难找到起点,所以我从最顶层开始:
在主程序中,我只是创建 Application 对象,初始化它并启动它。应用程序的init() 将创建功能服务器(见下文)并初始化它们。还创建了第一个游戏状态并将其推送到顶部。 (另见下文)
功能服务器封装正交游戏功能。这些可以独立实现,并通过消息松散耦合。示例功能:声音、视觉表示、碰撞检测、人工智能/决策、物理等等。下面描述了功能本身的组织方式。
输入、控制流和游戏循环
游戏状态提供了一种组织输入控制的方法。我通常有一个类来收集输入事件或捕获输入状态并稍后轮询它(InputServer/InputManager)。如果使用基于事件的方法,则将事件分配给单个已注册的活动游戏状态。
开始游戏时,这将是主菜单游戏状态。游戏状态具有init/destroy 和resume/suspend 函数。 Init() 将初始化游戏状态,如果是主菜单,它将显示最顶层的菜单级别。 Resume() 将控制此状态,它现在从 InputServer 获取输入。 Suspend() 将从屏幕上清除菜单视图,destroy() 将释放主菜单所需的任何资源。
GameStates 可以堆叠,当用户使用“新游戏”选项启动游戏时,MainMenu 游戏状态将暂停,PlayerControlGameState 将被放入堆栈并接收输入事件。这样您就可以根据游戏的状态处理输入。在任何给定时间都只有一个控制器处于活动状态,您可以极大地简化控制流程。
输入集合由游戏循环触发。游戏循环基本上确定当前循环的帧时间,更新特征服务器,收集输入并更新游戏状态。帧时间要么提供给它们中的每一个的更新函数,要么由 Timer 单例提供。这是用于确定自上次更新调用以来持续时间的规范时间。
游戏对象和功能
此设计的核心是游戏对象和功能的交互。
如上所示,这种意义上的功能是可以相互独立实现的游戏功能。游戏对象是以任何方式与玩家或任何其他游戏对象交互的任何东西。示例:玩家头像本身就是一个游戏对象。火炬是游戏对象,NPC 是游戏对象,照明区域和声源或这些的任意组合也是如此。
传统上,RPG 游戏对象是一些复杂类层次结构的顶层,但实际上这种方法是错误的。许多正交方面不能放入层次结构中,即使最终使用接口也必须有具体的类。一个项目是一个游戏对象,一个可拾取的项目是一个游戏对象,一个箱子是一个容器是一个项目,但是用这种方法是否可以选择一个箱子是一个非此即彼的决定,因为你必须有一个等级制度。当你想要一个只有在回答了谜语时才会打开的会说话的魔法谜语箱时,它会变得更加复杂。没有一个完全适合的层次结构。
更好的方法是只拥有一个游戏对象类,并将通常在类层次结构中表示的每个正交方面放入其自己的组件/功能类中。游戏对象可以容纳其他物品吗?然后给它添加ContainerFeature,它会说话吗,给它添加TalkTargetFeature等等。
在我的设计中,游戏对象只有一个固有的唯一 ID、名称和位置属性,其他所有内容都作为功能组件添加。可以通过调用 addComponent()、removeComponent() 在运行时通过 GameObject 接口添加组件。所以要让它可见,添加一个 VisibleComponent,让它发出声音,添加一个 AudableComponent,使它成为一个容器,添加一个 ContainerComponent。
VisibleComponent 对您的问题很重要,因为这是提供模型和视图之间链接的类。并非所有事物都需要古典意义上的观点。触发区域将不可见,环境声音区域也不可见。只有具有 VisibleComponent 的游戏对象才会可见。
当 VisibleFeatureServer 更新时,视觉表示在主循环中更新。然后它根据注册到它的 VisibleComponents 更新视图。它是查询每个消息的状态,还是仅仅将接收到的消息排队,取决于您的应用程序和底层可视化库。
就我而言,我使用 Ogre3D。在这里,当 VisibleComponent 附加到游戏对象时,它会创建一个附加到场景图的 SceneNode 和一个实体(3d 网格的表示)到场景节点。每个 TransformMessage(见下文)都会立即处理。 VisibleFeatureServer 然后让 Ogre3d 将场景重绘到 RenderWindow(本质上,细节更复杂,一如既往)
消息
那么这些功能和游戏状态以及游戏对象是如何相互通信的呢?
通过消息。此设计中的 Message 只是 Message 类的任何子类。每个具体的消息都可以有自己的接口,方便其任务。
消息可以从一个 GameObject 发送到其他 GameObject,从 GameObject 发送到其组件,从 FeatureServer 发送到它们负责的组件。
当一个 FeatureComponent 被创建并添加到一个游戏对象时,它通过为它想要接收的每条消息调用 myGameObject.registerMessageHandler(this, MessageID) 将自己注册到游戏对象。它还会为它想要从那里接收的每条消息将自己注册到其功能服务器。
如果玩家试图与其焦点所在的角色交谈,那么用户会以某种方式触发交谈动作。例如:如果焦点中的角色是友好的 NPC,则按下鼠标按钮会触发标准交互。通过向其发送 GetStandardActionMessage 来查询目标游戏对象的标准动作。目标游戏对象接收消息,并从第一个注册的游戏对象开始,通知其想要了解该消息的特征组件。然后,此消息的第一个组件会将标准操作设置为将触发自身的操作(TalkTargetComponent 会将标准操作设置为 Talk,它也将首先接收。)然后将消息标记为已使用。 GameObject 将测试消费并查看它是否确实被消费并返回给调用者。然后评估现在修改的消息并调用结果操作
是的,这个例子看起来很复杂,但它已经是更复杂的例子之一。其他诸如用于通知位置和方向变化的 TransformMessage 更容易处理。许多功能服务器都对 TransformMassage 感兴趣。 VisualisationServer 需要它来更新 GameObject 在屏幕上的视觉表示。 SoundServer 用于更新 3d 声音位置等。
使用消息而不是调用方法的优势应该很明显。组件之间的耦合度较低。调用方法时,调用者需要知道被调用者。但是通过使用消息,这是完全解耦的。如果没有接收器,那也没关系。如果根本不是呼叫者关心的,那么接收者如何处理消息也是如此。
也许委托在这里是一个不错的选择,但是 Java 缺少一个干净的实现,在网络游戏的情况下,您需要使用某种具有相当高延迟的 RPC。低延迟对于互动游戏至关重要。
持久性和编组
这使我们了解如何通过网络传递消息。通过将 GameObject/Feature 交互封装到消息中,我们只需要担心如何通过网络传递消息。理想情况下,您将消息带入通用形式并将它们放入 UDP 包中并发送。 Receiver 将消息解包到适当类的实例并将其引导到接收器或广播它,具体取决于消息。
我不知道Java的内置序列化是否能胜任这项任务。但即使没有,也有很多库可以做到这一点。
游戏对象和组件通过属性使其持久状态可用(C++ 没有内置序列化。)
它们有一个类似于 Java 中的 PropertyBag 的接口,可以通过该接口检索和恢复它们的状态。
参考文献