本文概述
初学者通常是通过经典的Hello World程序开始学习交易的。从那里开始, 越来越多的任务必将随之而来。每个新的挑战都会带给我们一个重要的教训:
项目越大, 意大利面条越大。
很快, 很容易看出, 无论是大团队还是小团队, 一个人都不能鲁ck地做自己想做的事情。代码必须得到维护, 并且可能会持续很长时间。你所服务的公司不仅会查找你的联系信息, 而且每次他们想修改或改进代码库时都会问你(你也不希望他们俩都这么做)。
这就是为什么存在软件设计模式的原因。他们强加了简单的规则来支配软件项目的整体结构。它们可以帮助一个或多个程序员将大型项目的核心部分分开, 并以标准化的方式组织它们, 从而避免在遇到一些不熟悉的代码库部分时造成混乱。
这些规则在每个人都遵循时, 可以更好地维护和导航旧代码, 并更快地添加新代码。在计划开发方法上花费的时间更少。由于问题不是一种味道, 所以没有银弹设计模式。必须仔细考虑每种模式的优点和缺点, 并找到最适合当前挑战的方法。
在本教程中, 我将把我的经验与流行的Unity游戏开发平台和用于游戏开发的Model-View-Controller(MVC)模式联系起来。在我的七年开发过程中, 我一直为游戏开发者意大利面条而战, 我一直使用这种设计模式实现了出色的代码结构和开发速度。
首先, 我将解释一些Unity的基本架构, 即Entity-Component模式。然后, 我将继续说明MVC如何适合它, 并以一个模拟项目作为示例。
动机
在软件文献中, 我们将找到大量的设计模式。即使他们有一套规则, 开发人员通常也会做一些规则折弯, 以更好地适应特定问题的模式。
这种”编程自由”证明了我们还没有找到一种确定的软件设计方法。因此, 本文并不是要最终解决你的问题, 而是要展示两种众所周知的模式的好处和可能性:实体组件和模型视图控制器。
实体组件模式
实体组件(EC)是一种设计模式, 我们首先定义组成应用程序的元素的层次结构(实体), 然后定义每个将包含的功能和数据(组件)。用更多”程序员”的术语来说, 实体可以是具有0或更多组件数组的对象。让我们描述一个这样的实体:
some-entity [component0, component1, ...]
这是EC树的一个简单示例。
- app [Application]
- game [Game]
- player [KeyboardInput, Renderer]
- enemies
- spider [SpiderAI, Renderer]
- ogre [OgreAI, Renderer]
- ui [UI]
- hud [HUD, MouseInput, Renderer]
- pause-menu [PauseMenu, MouseInput, Renderer]
- victory-modal [VictoryModal, MouseInput, Renderer]
- defeat-modal [DefeatModal, MouseInput, Renderer]
EC是缓解多重继承问题的好模式, 复杂的类结构可以引入诸如菱形问题的问题, 其中钻石类D继承具有相同基类A的两个类B和C可以引入冲突, 因为B和C对A的功能有不同的修改。
在继承被广泛使用的游戏开发中, 这类问题可能很常见。
通过将功能和数据处理程序分解为更小的组件, 它们可以在不同实体中附加和重用, 而无需依赖多个继承(顺便说一句, 它甚至不是C#或Javascript的功能, 这是Unity使用的主要语言。 )。
实体组件不足之处
作为OOP之上的一级, EC有助于进行碎片整理并更好地组织你的代码体系结构。但是, 在大型项目中, 我们仍然”太自由”, 我们会发现自己处于”特征海洋”中, 很难找到合适的实体和组件, 或者很难确定它们应该如何交互。对于给定任务, 有无数种组装实体和组件的方法。
避免混乱的一种方法是在Entity-Component之上强加一些其他准则。例如, 我喜欢考虑软件的一种方法是将其分为三个不同的类别:
- 有些处理原始数据, 从而可以创建, 读取, 更新, 删除或搜索原始数据(即CRUD概念)。
- 其他人则实现了与其他元素进行交互的接口, 以检测与其范围相关的事件并在事件发生时触发通知。
- 最后, 某些元素负责接收这些通知, 做出业务逻辑决策以及决定如何操作数据。
幸运的是, 我们已经有了一个以这种精确方式运行的模式。
模型-视图-控制器(MVC)模式
“模型-视图-控制器”模式(MVC)将软件分为三个主要组件:模型(数据CRUD), 视图(接口/检测)和控制器(决策/操作)。 MVC具有足够的灵活性, 甚至可以在ECS或OOP之上实施。
游戏和UI的开发通常具有以下工作流程:等待用户的输入或其他触发条件, 在适当的位置发送有关这些事件的通知, 确定响应方式, 然后相应地更新数据。这些动作清楚地表明了这些应用程序与MVC的兼容性。
这种方法引入了另一个抽象层, 这将有助于软件规划, 并且还允许新程序员即使在更大的代码库中也能导航。通过将思考过程分为数据, 界面和决策, 开发人员可以减少为了添加或修复功能而必须搜索的源文件的数量。
Unity和EC
首先, 让我们仔细看看Unity给我们带来的好处。
Unity是一个基于EC的开发平台, 其中所有实体都是GameObject的实例, 而使它们成为”可见”, “可移动”, “可交互”等的功能由扩展Component的类提供。
Unity编辑器的”层次结构面板”和”检查器面板”提供了一种强大的方式来组装你的应用程序, 附加组件, 配置它们的初始状态以及使用比通常少得多的源代码来引导游戏。
右侧有四个GameObjects的层次面板
带有GameObject组件的检查器面板
但是, 正如我们已经讨论过的, 我们仍然可以解决”功能过多”的问题, 并发现自己处在庞大的层次结构中, 功能散布在各处, 使开发人员的生活更加艰苦。
相反, 我们可以以MVC的方式思考, 首先根据事物的功能划分事物, 然后像下面的示例所示构建应用程序:
使MVC适应游戏开发环境
现在, 我想对通用MVC模式进行两个小的修改, 以使其适应我在使用MVC构建Unity项目时遇到的独特情况:
- MVC类引用很容易分散在整个代码中。 -在Unity中, 开发人员通常必须拖放实例以使其可访问, 或者通过诸如GetComponent(…)之类的笨拙的find语句来访问它们。 -如果Unity崩溃或某些错误使所有拖动的引用消失, 则会丢失引用地狱。 -这使得必须有一个根引用对象, 通过它可以访问和恢复应用程序中的所有实例。
- 一些元素封装了应该高度可重用的常规功能, 并且这些自然功能自然不属于模型, 视图或控制器这三个主要类别之一。这些我仅称为组件。从实体组件的意义上讲, 它们也是”组件”, 但仅在MVC框架中充当助手。 -例如, “旋转器组件”, 它仅以给定的角速度旋转事物, 而不通知, 存储或决定任何事情。
为了帮助缓解这两个问题, 我想出了一种改进的模式, 称为AMVCC, 即Application-Model-View-Controller-Component。
- 应用程序-你的应用程序和所有关键实例以及与应用程序相关的数据的容器的单一入口点。
- MVC-你现在应该知道这一点。 🙂
- 组件-可以重复使用的小型, 完备的脚本。
这两项修改满足了我使用它们的所有项目的需求。
示例:10跳
举一个简单的例子, 让我们看一个名为10 Bounces的小游戏, 在这里我将利用AMVCC模式的核心元素。
游戏的设置很简单:一个带有SphereCollider和Rigidbody的球(将在” Play”之后开始掉落), 一个Cube作为地面和5个组成AMVCC的脚本。
层次结构
在编写脚本之前, 我通常从层次结构开始, 并为我的课程和资产创建大纲。始终遵循这种新的AMVCC样式。
如我们所见, 视图GameObject包含所有视觉元素以及其他View脚本的元素。对于小型项目, 模型和控制器GameObject通常仅包含其各自的脚本。对于较大的项目, 它们将包含带有更特定脚本的GameObjects。
当浏览你的项目的人想要访问时:
- 数据:转到应用程序>模型> …
- 逻辑/工作流:转到应用程序>控制器> …
- 渲染/界面/检测:转到应用程序>视图> …
如果所有团队都遵循这些简单规则, 那么旧项目就不会成为问题。
请注意, 没有Component容器, 因为如我们所讨论的, 它们更加灵活, 可以在开发人员闲暇时附加到不同的元素。
脚本编写
注意:下面显示的脚本是实际实现的抽象版本。详细的实现方式不会给读者带来太多好处。但是, 如果你想探索更多, 这里是我个人的Unity MVC框架Unity MVC的链接。你将找到实现大多数应用程序所需的AMVCC结构框架的核心类。
让我们看一下10 Bounce的脚本结构。
在开始之前, 对于不熟悉Unity工作流程的人员, 让我们简要说明一下脚本和GameObjects如何一起工作。在Unity中, 实体组件意义上的”组件”由MonoBehaviour类表示。为了使它在运行时存在, 开发人员应将其源文件拖放到GameObject(这是Entity-Component模式的” Entity”)中, 或使用命令AddComponent <YourMonobehaviour>()。此后, 脚本将被实例化并准备在执行期间使用。
首先, 我们定义Application类(在AMVCC中为” A”), 它将是包含所有实例化游戏元素的引用的主类。我们还将创建一个名为Element的帮助程序基类, 它使我们能够访问Application实例及其子MVC实例。
考虑到这一点, 让我们定义Application类(在AMVCC中为” A”), 它将具有唯一的实例。在其中, 三个变量(模型, 视图和控制器)将在运行时为我们提供所有MVC实例的访问点。这些变量应该是具有对所需脚本的公共引用的MonoBehaviours。
然后, 我们还将创建一个名为Element的辅助基类, 该基类使我们可以访问Application的实例。此访问权限将允许每个MVC类相互访问。
请注意, 这两个类都扩展了MonoBehaviour。它们是将被附加到游戏对象”实体”的”组件”。
// BounceApplication.cs
// Base class for all elements in this application.
public class BounceElement : MonoBehaviour
{
// Gives access to the application and all instances.
public BounceApplication app { get { return GameObject.FindObjectOfType<BounceApplication>(); }}
}
// 10 Bounces Entry Point.
public class BounceApplication : MonoBehaviour
{
// Reference to the root instances of the MVC.
public BounceModel model;
public BounceView view;
public BounceController controller;
// Init things here
void Start() { }
}
通过BounceElement, 我们可以创建MVC核心类。 BounceModel, BounceView和BounceController脚本通常充当更专用实例的容器, 但是由于这是一个简单的示例, 因此仅View将具有嵌套结构。可以在一个脚本中为每个模型和控制器完成:
// BounceModel.cs
// Contains all data related to the app.
public class BounceModel : BounceElement
{
// Data
public int bounces;
public int winCondition;
}
// BounceView .cs
// Contains all views related to the app.
public class BounceView : BounceElement
{
// Reference to the ball
public BallView ball;
}
// BallView.cs
// Describes the Ball view and its features.
public class BallView : BounceElement
{
// Only this is necessary. Physics is doing the rest of work.
// Callback called upon collision.
void OnCollisionEnter() { app.controller.OnBallGroundHit(); }
}
// BounceController.cs
// Controls the app workflow.
public class BounceController : BounceElement
{
// Handles the ball hit event
public void OnBallGroundHit()
{
app.model.bounces++;
Debug.Log("Bounce "+app.model.bounce);
if(app.model.bounces >= app.model.winCondition)
{
app.view.ball.enabled = false;
app.view.ball.GetComponent<RigidBody>().isKinematic=true; // stops the ball
OnGameComplete();
}
}
// Handles the win condition
public void OnGameComplete() { Debug.Log("Victory!!"); }
}
创建所有脚本后, 我们可以继续进行附加和配置。
层次结构布局应如下所示:
- application [BounceApplication]
- model [BounceModel]
- controller [BounceController]
- view [BounceView]
- ...
- ball [BallView]
- ...
以BounceModel为例, 我们可以看到它在Unity编辑器中的外观:
具有bounces和winCondition字段的BounceModel。
设置好所有脚本并运行游戏后, 我们应该在控制台面板中获得此输出。
通知事项
如上面的示例所示, 当球撞击地面时, 其视图将执行app.controller.OnBallGroundHit()方法。无论如何, 对应用程序中的所有通知执行此操作都不是”错误的”。但是, 根据我的经验, 我使用AMVCC Application类中实现的简单通知系统取得了更好的结果。
为此, 我们将BounceApplication的布局更新为:
// BounceApplication.cs
class BounceApplication
{
// Iterates all Controllers and delegates the notification data
// This method can easily be found because every class is "BounceElement" and has an "app"
// instance.
public void Notify(string p_event_path, Object p_target, params object[] p_data)
{
BounceController[] controller_list = GetAllControllers();
foreach(BounceController c in controller_list)
{
c.OnNotification(p_event_path, p_target, p_data);
}
}
// Fetches all scene Controllers.
public BounceController[] GetAllControllers() { /* ... */ }
}
接下来, 我们需要一个新脚本, 所有开发人员都将在其中添加通知事件的名称, 该名称可以在执行期间分派。
// BounceNotifications.cs
// This class will give static access to the events strings.
class BounceNotification
{
static public string BallHitGround = "ball.hit.ground";
static public string GameComplete = "game.complete";
/* ... */
static public string GameStart = "game.start";
static public string SceneLoad = "scene.load";
/* ... */
}
显而易见, 这样一来, 代码的可读性得到了改善, 因为开发人员无需在源代码中搜索controller.OnSomethingComplexName方法即可了解执行期间可能发生的动作。通过仅检查一个文件, 就可以了解应用程序的整体行为。
现在, 我们只需要修改BallView和BounceController即可处理这个新系统。
// BallView.cs
// Describes the Ball view and its features.
public class BallView : BounceElement
{
// Only this is necessary. Physics is doing the rest of work.
// Callback called upon collision.
void OnCollisionEnter() { app.Notify(BounceNotification.BallHitGround, this); }
}
// BounceController.cs
// Controls the app workflow.
public class BounceController : BounceElement
{
// Handles the ball hit event
public void OnNotification(string p_event_path, Object p_target, params object[] p_data)
{
switch(p_event_path)
{
case BounceNotification.BallHitGround:
app.model.bounces++;
Debug.Log("Bounce "+app.model.bounce);
if(app.model.bounces >= app.model.winCondition)
{
app.view.ball.enabled = false;
app.view.ball.GetComponent<RigidBody>().isKinematic=true; // stops the ball
// Notify itself and other controllers possibly interested in the event
app.Notify(BounceNotification.GameComplete, this);
}
break;
case BounceNotification.GameComplete:
Debug.Log("Victory!!");
break;
}
}
}
较大的项目将有很多通知。因此, 为避免获得较大的切换用例结构, 建议创建不同的控制器并使它们处理不同的通知范围。
现实世界中的AMVCC
此示例显示了AMVCC模式的简单用例。应根据MVC的三个要素调整思维方式, 并学习将实体可视化为有序的层次结构, 这是应当加以完善的技能。
在较大的项目中, 开发人员将面临更复杂的场景, 并怀疑某个对象应该是View还是Controller, 或者是否应该将给定的类更彻底地分成较小的类。
经验法则(由Eduardo撰写)
任何地方都没有任何” MVC排序通用指南”。但是我通常遵循一些简单的规则来帮助我确定是否将某些东西定义为模型, 视图或控制器, 以及何时将给定的类拆分为较小的部分。
通常, 这是在我考虑软件体系结构或编写脚本时自然发生的。
类排序
楷模
- 保留应用程序的核心数据和状态, 例如播放器的健康状况或枪支弹药。
- 在类型之间进行序列化, 反序列化和/或转换。
- 加载/保存数据(本地或在网络上)。
- 通知控制器操作进度。
- 为游戏的有限状态机存储游戏状态。
- 从不访问视图。
观看次数
- 可以从模型获取数据, 以便向用户表示最新的游戏状态。例如, 一个View方法player.Run()可以在内部使用model.speed来体现玩家的能力。
- 永远不要改变模型。
- 严格实现其类的功能。例如:
- PlayerView不应实施输入检测或修改游戏状态。
- 视图应充当具有接口并通知重要事件的黑匣子。
- 不存储核心数据(例如速度, 运行状况, 生命等等)。
控制器
- 不要存储核心数据。
- 有时可以过滤来自不需要的视图的通知。
- 更新并使用模型的数据。
- 管理Unity的场景工作流程。
类层次结构
在这种情况下, 我没有采取很多步骤。通常, 我认为当变量开始显示太多”前缀”或同一元素的太多变体开始出现时(例如MMO中的Player类或FPS中的Gun类型), 需要拆分某些类。
例如, 包含Player数据的单个Model将具有很多playerDataA, playerDataB等。或者处理Player通知的Controller会具有OnPlayerDidA, OnPlayerDidB等。和OnPlayer前缀。
让我演示使用Model类, 因为仅使用数据更容易理解。
在编程过程中, 我通常从一个包含所有游戏数据的Model类开始。
// Model.cs
class Model
{
public float playerHealth;
public int playerLives;
public GameObject playerGunPrefabA;
public int playerGunAmmoA;
public GameObject playerGunPrefabB;
public int playerGunAmmoB;
// Ops Gun[C D E ...] will appear...
/* ... */
public float gameSpeed;
public int gameLevel;
}
不难看出, 游戏越复杂, 获得的变量就越多。有了足够的复杂性, 我们最终可以得到一个包含model.playerABCDFoo变量的巨型类。嵌套元素将简化代码完成过程, 并为在数据变体之间切换提供空间。
// Model.cs
class Model
{
public PlayerModel player; // Container of the Player data.
public GameModel game; // Container of the Game data.
}
// GameModel.cs
class GameModel
{
public float speed; // Game running speed (influencing the difficulty)
public int level; // Current game level/stage loaded
}
// PlayerModel.cs
class PlayerModel
{
public float health; // Player health from 0.0 to 1.0.
public int lives; // Player "retry" count after he dies.
public GunModel[] guns; // Now a Player can have an array of guns to switch ingame.
}
// GunModel.cs
class GunModel
{
public GunType type; // Enumeration of Gun types.
public GameObject prefab; // Template of the 3D Asset of the weapon.
public int ammo; // Current number of bullets
public int clips; // Number of reloads possible
}
通过这种类的配置, 开发人员可以一次直观地在源代码中导航一个概念。让我们假设一个第一人称射击游戏, 其中的武器及其配置可能真的很多。 GunModel包含在类中的事实允许为每个类别创建Prefabs列表(预配置的GameObjects可以快速复制并在游戏中重复使用)并存储以备后用。
相反, 如果将枪支信息全部存储在单个GunModel类中, 并且存储在gun0Ammo, gun1Ammo, gun0Clips等变量中, 那么当用户需要存储Gun数据时, 用户将需要存储整个枪支数据。包含不需要的Player数据的模型。在这种情况下, 很明显, 新的GunModel类会更好。
改进类层次结构。
与所有事物一样, 硬币有两个方面。有时, 可能会不必要地过度分隔并增加代码复杂性。只有经验可以磨练你的技能, 以便为你的项目找到最佳的MVC排序。
新游戏开发者特殊能力已解锁:具有MVC模式的Unity游戏。
鸣叫
总结
那里有大量的软件模式。在这篇文章中, 我试图展示在过去的项目中对我最大的帮助。开发人员应始终吸收新知识, 但也要始终提出质疑。我希望本教程可以帮助你学习新知识, 同时也可以作为你发展自己的风格的垫脚石。
另外, 我真的鼓励你研究其他模式, 并找到最适合你的模式。维基百科的这篇文章是一个很好的起点, 它提供了一系列出色的模式及其特征。
如果你喜欢AMVCC模式并想对其进行测试, 请不要忘记尝试一下我的库Unity MVC, 该库包含启动AMVCC应用程序所需的所有核心类。
评论前必须登录!
注册