大约四年前,我发布了关于Unity开发的50个技巧的初始版本。
虽然最新版本与初始版本仍有许多关联,但在初始版本之后,我修改了许多内容:
Unity更好用。例如,我现在信赖FPS计数器。使用property drawer的功能可以降低编写customeditors的必要性。同时Prefab的工作方式也降低了显式嵌套Prefab或替代件的需求。Scriptable objects更为友好。
VisualStudio集成度更佳,从而使调试操作更简便,同时减少了对于大量Gorilla调试操作的需求。
第三方工具和库更优化。AssetStore里现有许多可用的对于可视化调试和更佳日志记录等事物的辅助工具。我们自带(免费)的扩展插件有大量初始发布中所述的代码(以及本次版本发布所述的许多代码)。
版本控制更好。(或者可以说,我现在知道如何更有效地使用它)。比方说,无需对Prefab执行多个副本或者备份。
个人经验的积累。在过去四年里,我参与了许多Unity项目;包括大量的游戏原型制作,Father.IO等的游戏制作,以及我们的旗舰工具Unity asset Grids。
本文是在考虑上述所有内容的基础上对初始版本进行的修订版本。
在继续讨论技巧前,本人现发布以下免责声明(与初始版本基本相同):
这些技巧并不适用于每个Unity项目。
这些技巧是基于本人与3到20人的小型团队协作参与项目所获得的经验。在结构性,可重用性,清晰度等方面存在费用——根据团队规模,项目规模和项目目标来确定是否此费用。比方说,你可能不会在游戏制作环节使用所有内容。
许多技巧涉及个人喜好问题(虽然这里列出的技巧之间可能有竞争,但都是很好的技巧)。
此外,Unity也在其官网上发布了一些最佳操作实例(虽然它们中大多数是从效能角度出发):
1)http://unity3d.com/learn/tutorials/topics/best-practices
2)基于物理的内容创建的最佳操作实例:https://youtu.be/OeEYEUCa4tI
3)Unity中的最佳2D操作实例:https://youtu.be/HM17mAmLd7k
4)Unity内部技巧和技巧: https://youtu.be/Ozc_hXzp_KU
5)Unity提示和技巧:https://youtu.be/2S6Ygq58QF8
6)http://docs.unity3d.com/Manual/HOWTO-ArtAssetBestPracticeGuide.html
开发流程
1. 确定开始的缩放比例,并以相同缩放比例构建所有原型。否则,你可能需要后续重做assets(例如,无法总是正确地缩放动画)。对于3D游戏,采用1 Unity单位= 1m通常是最佳的。对于不使用照明或物理的2D游戏,采用1 Unity单位 = 1 像素(在“设计”分辨率阶段)通常是较好的。对于UI(以及2D游戏),选择设计分辨率(我们使用HD或2xHD,并将所有assets设计为以此分辨率缩放。
2. 使每个场景都可以运行。这样可以避免为了运行游戏而必须转换场景,从而加快了测试速度。如果要在所有场景中必需的场景加载之间持续存在对象,这可能需要技巧。一种方法是当持续对象不存在于场景中时,使它们作为可自行加载的单例模式。另一个技巧中将详述单例模式。
3. 使用源代码控制,并学习如何有效地使用它。
将assets序列化为文本。实际上,它并不会提高场景和Prefab的可合并性,但它会使变化更容易观测。
采用场景和Prefab共享策略。一般来说,多个人不应在同一场景或Prefab工作。对于小型制作团队,只要在开始工作前确保没有人制作场景或Prefab即可。交换表示场景所有权的物理标记可能很有用(如果桌面上有场景标记,你仅可以在某一场景中工作)。
将标签作为书签。
确定并坚持采用分支策略。由于场景和Prefab不能平滑地合并,分支稍显复杂。然而当你决定使用分支时,它应该结合场景和Prefab共享策略使用。
使用子模块时要小心。子模型可能是维护可重用代码的最佳途径。但需注意几个警告事项:
元数据文件通常在多个项目中不一致。对于非Monobehaviour或非Scriptable object代码而言,这通常不是问题,但对于MonoBehaviours和Scriptable objects使用子模块可能会导致代码丢失。
如果你参与许多项目(包括一个或多个子模块项目),倘若你必须对几次迭代中的多个项目执行获取—合并—提交—推送操作以稳定所有项目的代码,有时会发生更新崩溃(并且如果其他人同时进行变更,它可能会转变为持续崩溃)。一种最大程度上降低此效应的方法是在项目初始阶段对子模块进行更改。如此一来,总是需要推送仅使用子模块的项目;它们从来无需推回。
4. 保持测试场景和代码分离。向存储库提交临时资源和脚本,并在完成后将它们移出项目。
5. 如果你要更新工具(尤其是Unity),必须同时进行。当你使用一个与先前不同的版本打开项目时,Unity能够更好地保留链接,但倘若人们使用不同的版本,有时仍然会丢失链接。
6. 在一个干净的项目中导入第三方assets,并从中导出一个可供自己使用的新的资源包。当你直接向项目导入这些资源,它们有时会导致问题:
可能存在冲突(文件或文件名),尤其对于在插件目录根中存在文件或者在实例中使用StandardAssets中assets的资源。
这些资源可能被无序地放入到自有项目的文件中。如果你决定不使用或者想要移除这些assets,这可能成为一个重要问题。
请按照下述步骤使assets导入更安全:
1)创建一个新项目,然后导入asset。
2)运行实例并确保它们能够工作。
3)将asset排列为一个更合适的目录结构。(我通常不对一个资源强制排列自有的目录结构。但是我确保所有文件均在一个目录中,同时在重要位置不存在任何可能会覆盖项目中现有文件的文件。
4)运行实例并确保它们仍可以工作。(有时,当我移动事物时会导致assets损坏,但这通常不应该是一个问题)。
5)现要移除所有无需的事物(如实例)。
6)确保asset仍可编译,并且Prefab仍然拥有所有自身的链接。若留下任何需运行的事项,则对它进行测试。
7)现选定所有assets,并导出一个资源包。
8)导入到你的项目中。
7. 自动构建进程。甚至对于小型项目,这步很有用,但对于以下情况尤为适用:
你需要构建许多不同的游戏版本。
其他拥有不同程度技术知识的团队成员需要进行构建,或者
你需要对项目进行小幅调整后才能进行构建。
详见Unity构建编译:对于如何执行的较好指导的基本和高级可能性。
8. 为你的设置建立文档。大部分记录应在代码中,但是某些事项应记录在代码外。制作设计师通过耗时的设置来筛选代码。文档化的设置可以提高效率(若文档是最新的)。
对下述内容建立文档:
标签使用。
图层使用(对于碰撞,剔除和光线投射—从本质上来说,每个图层对应的使用)。
图层的GUI深度(每个图层对应的显示)
场景设置。
复杂Prefab的Prefab结构。
常用语偏好。
构建设置。
通用编码
9. 将所有代码放入一个命名空间中。这避免了自有库和第三方代码之间可能发生的代码冲突。但不要依赖于命名空间以避免与重要类冲突。即使你会使用不同的命名空间,也不要将“对象”、“动作”或“事件”作为类名称。
10. 使用断言。断言对于代码中不变量的测试非常有用,它能够辅助清除逻辑错误。Unity.Assertions.Assert类提供了可用的断言。它们都可以测试一些条件,但如果不符合条件,则在控制台中写入错误信息。如果你不熟悉如何有效地使用断言,请参考使用断言编程的优点(a.k.a.断言语句)。
11. 切勿对显示文本以外的任何事项使用字符串。尤其应注意,不要使用字符串来标识对象或Prefab。但存在一些例外情形(仍然有一些内容只能通过Unity中的名称访问)。在这种情形下,将这些字符串定义为“AnimationNames”或 “AudioModuleNames”等文件中的常量。倘若这些类变为不可管理,使用嵌套类后便可类似命名AnimationNames.Player.Run。
12. 不要使用“Invoke”和“SendMessage”。这些MonoBehaviour方法通过名称调用其他方法。通过名称调用的方法难以在代码中追踪(无法找到“Usages”,而“发SendMessage”的范围更宽,因此更难以追踪)。
较简便的方法是使用Coroutines和C#操作推出“Invoke”:
1 2 3 4 5 6 7 8 9 10 11 |
public static Coroutine Invoke(this MonoBehaviour monoBehaviour, Action action, float time) { return monoBehaviour.StartCoroutine(InvokeImpl(action, time)); } private static IEnumerator InvokeImpl(Action action, float time) { yield return new WaitForSeconds(time); action(); } |
你可以参考monoBehaviour模式:
this.Invoke(ShootEnemy); //其中ShootEnemy是一个无参数的void法。
如果你实现自己的基础MonoBehaviour,你可以向其中添加自己的“Invoke”。
另一种较安全的“SendMessage”方法更难以实施。与之相反,我通常使用“GetComponent”变量以获取父对象,当前游戏对象或子对象的组件,并直接执行调用。
13. 当游戏运行时,不要让派生对象混乱层次结构。将它们的父对象设为场景对象,以便在游戏运行时更容易找到内容。你可以使用一个空游戏对象,或者甚至使用一个无行为的单例模式(详见本文后面的部分),从而更容易地从代码进行访问。将此对象命名为“DynamicObjects”。
14. 明确是否要将空值(null)作为一个合法值,并尽量避免这么做
空值可辅助检测错误代码。但是,如果你使“if”默默地通过空值成为一种习惯,错误代码将很快运行,同时你只能在很久之后才会注意到错误。此外,随着每个图层通过空变量,它可以在代码深度暴露。我尝试避免将空值整体作为一个合法值。
我优先采用的常用语不是进行任何空检查,倘若它是一个问题,让代码失败。有时,在“可重用”方法中,我将检查出一个值为空的变量,并抛出一个异常,而不是将它传递至其它可能失败的方法。
在某些情形下,值可以合法为空,并且需要采取不同的方式处理。在此类情况下,添加注释来解释什么时候某些内容可能为空,并说明为什么可能为空。
常见场景通常用于inspector配置的值。用户可以指定一个值,但如果未指定任何值,则使用一个默认值。最好结合包含T值的可选类。(这有点像“可为空”)。你可以使用一个特殊的属性渲染器来渲染一个勾选框,若勾选,则仅显示数值框。
(但切勿直接使用泛型类,你必须扩展特定T值的类)。
1 2 3 4 5 6 7 |
[Serializable] public class Optional<t> { public bool useCustomValue; public T value; } </t> |
在你的代码中,你可以采取这种使用途径:
health= healthMax.useCustomValue ? healthMax.Value : DefaultHealthMax;
15. 如果你使用“协程”,学习如何有效地使用它。
“协程”是解决许多问题的一种最有效的方法。但是难以对“协程”进行调式,同时你可以很容易地对它进行混乱的编码,从而使其他人,甚至包括你自己也无法理解其意义。
你应该知道:
1)如何并发执行协程。
2)如何按序执行协程。
3)如何从现有程序中创建新的协程。
4)如何使用“CustomYieldInstruction”创建自定义协程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
//This is itself a coroutine IEnumerator RunInSequence() { yield return StartCoroutine(Coroutine1()); yield return StartCoroutine(Coroutine2()); } public void RunInParallel() { StartCoroutine(Coroutine1()); StartCoroutine(Coroutine1()); } Coroutine WaitASecond() { return new WaitForSeconds(1); } |
16. 利用扩展法来协同共享接口的组件。有时可以方便地获取实施某个接口的组件,或者找到这些组件相应的对象。
下述实例使用typeof而不是这些函数的通用版本。通用版本无法协同接口使用,但typeof却可以。下面的方法将其整洁地套入通用方法之中。
1 2 3 4 5 |
public static TInterface GetInterfaceComponent<tinterface>(this Component thisComponent) where TInterface : class { return thisComponent.GetComponent(typeof(TInterface)) as TInterface; }</tinterface> |
17. 利用扩展法使语法更简洁。例如:
1 2 3 4 5 6 7 8 9 10 11 |
public static class TransformExtensions { public static void SetX(this Transform transform, float x) { Vector3 newPosition = new Vector3(x, transform.position.y, transform.position.z); transform.position = newPosition; } ... } |
18. 使用另一种防御性GetComponent方法。有时通过RequiredComponent强制组件关系可能难以操作,但是这总是可能和可取的,特别是当你调用其它类上的GetComponent。作为一种替代方法,但需要某个组件打印找到的错误信息时,可以使用下述GameObject扩展。
1 2 3 4 5 6 7 8 9 10 11 12 |
public static T GetRequiredComponent(this GameObject obj) where T : MonoBehaviour { T component = obj.GetComponent(); if(component == null) { Debug.LogError("Expected to find component of type " + typeof(T) + " but found none", obj); } return component; } |
19. 避免对相同的事项使用不同的常用语。在许多情况下,有多种常用法。此时,对整个项目选择一种常用法。其原因在于:
1)某些常用语不能一起工作。在某个方向中使用一种常用语强行设 计可能不适合另一种常用语。
2)对于整个项目使用相同的常用语能够使团队成员更容易理解进展。它使结构和代码更容易理解。这样就更难犯错。
常用语组示例:
协程与状态机。
嵌套的Prefab,互相链接的Prefab和超级Prefab
数据分离策略。
对2D游戏中状态使用sprites的方法。
Prefab结构。
派生策略。
定位对象的方法:按类型,按名称,按标签,按图层和按引用关系(“链接”)。
分组对象的方法:按类型,按名称,按标签,按图层和按引用数组(“链接”)。
调用其他组件方法的途径。
查找对象组和自注册。
控制执行次序(使用Unity的执行次序设置,还是使用yield逻辑,利用Awake / Start和Update / Late Update依赖,还是使用纯手动的方法,或者采用次序无关的架构)。
在游戏中使用鼠标选择对象/位置/目标:SelectionManager或者对象自主管理。
在场景变换时保存数据:通过PlayerPrefs,或者是在新场景加载时未毁损的对象。
组合(混合、添加和分层)动画的方法。
输入处理(中央和本地)
20. 维护一个自有的Time类,这可以更容易实现游戏暂停。包装一个“Time.DeltaTime”和“Time.TimeSinceLevelLoad”来实现暂停和游戏速度的缩放。它使用时有点麻烦,但是当对象运行在不同的时钟速率下就容易多了(例如界面动画和游戏动画)。
21. 需要更新的自定义类不应该访问全局静态时间。相反,它们应将增量时间作为它们Update方法的一个参数。当你如上所述实施一个暂停系统,或者当你想要加快或减慢自定义类的行为时,这样使这些类变为可用。
22. 使用常见结构进行WWW调用。在拥有很多服务器通信的游戏中,通常有几十个WWW调用。无论你是使用Unity的原始WWW类还是使用某个插件,你可以从生成样板文件的顶部写入一个薄层获益。
我通常定义一个Call方法(分别针对Get和Post),即CallImpl协程和MakeHandler。从本质上来说,Call方法通过采用MakeHandler法,从一个解析器,成功和失败的处理器构建出一个super hander。此外,它也调用CallImpl协程,创建一个URL,进行调用,等待直至完成,然后调用super handler。
其大概形式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
public void Call<t>(string call, Func<string, t=""> parser, Action<t> onSuccess, Action<string> onFailure) { var handler = MakeHandler(parser, onSuccess, onFailure); StartCoroutine(CallImpl(call, handler)); } public IEnumerator CallImpl<t>(string call, Action<t> handler) { var www = new WWW(call); yield return www; handler(www); } public Action<www> MakeHandler<t>(Func<string, t=""> parser, Action<t> onSuccess, Action<string> onFailure) { return (WWW www) => { if(NoError(www)) { var parsedResult = parser(www.text); onSuccess(parsedResult); } else { onFailure("error text"); } } }</string></t></string,></t></www></t></t></string></t></string,></t> |
它具有一些优点:
它允许你避免编写大量样板代码。
它允许你在中央位置处理某些事项(例如显示加载的UI组件或处理某些通用错误)。
23. 如果你有大量文本,将它们放在同一个文件中。不要将它们放入inspector将编辑的字段中。使其在无需打开Unity编辑器,尤其是无需保存场景的前提下易于更改。
24. 如果你想执行本地化,将所有字符串分离到同一个位置。有很多方法可以实现这一点。一种方法是针对每个字符串定义一个具有public字符串字段的Text类,例如默认设为英文。其他语言将其子类化,并使用同等语言重新初始化这些字段。
一些更复杂的技术(其适用情形是正文本较大和/或语言数量较多时)将读取到一个电子表格中,并基于所选语言提供选择正确字符串的逻辑。
类的设计
25. 确定实现可检查字段的方法,并将其确立为标准。有两种方法:使字段public,或者使它们private并标记为[可序列化]。后者“更正确”但不太方便(当然不是Unity本身常用的方法)。无论你选择哪种方式,将它确立为标准,以便于团队中开发人员知道如何解释一个public字段。
可检查字段是public的。在这种情况下,public表示“设计师在 运行时更改此变量是安全的。避免在代码中设置该值”。
可检查字段是private,并被标记为“可序列化”。 在这种情 况下,public表示“在代码中更改此变量是安全的”(因此,你不应该看到太多,并且在MonoBehaviours 和ScriptableObjects中不应该有任何public字段)。
26. 对于组件,切勿使不应在inspector中调整的变量成为public。否则,它们将被设计师调整,特别是当不清楚它是什么时。在某些罕见的情况下,这是无法避免的。此时,使用两条,甚至四条下划线对变量名添加前缀以警告调整人员:
public float __aVariable;
27. 使用Property Drawers使字段更加用户友好。可以使用Property Drawers自定义inspector中的控制。这样可以使你能够创建更适合数据性质的控制,并实施某些安全保护(如限定变量范围)。
28. 相较于Custom Editors,更偏好采用PropertyDrawers。Property Drawers是根据字段类型实现的,因此涉及的工作量要少得多。另外,它们的重用性更佳—一旦实现某一类型,它们可应用于包含此类型的任何类。而Custom Editors是根据MonoBehaviour实现的,因此重用性更少,涉及的工作量更多。
29. 默认密封MonoBehaviours。一般来说,UnityMonoBehaviours的继承友好不高:
类似于Start和Update,Unity调用信息的方式使得在子类中难以使用这些方法。你稍不注意就可能调用错误内容,或者忘记调用一个基本方法。当你使用custom editors时,通常需要对editors复制继承层次结构。任何人在扩展某一类时,必须提供自己的editor,或者凑合着使用你提供的editor。
在调用继承的情况下,如果你可以避免,不要提供任何Unity信息方法。如果你这样做,切勿使他们虚拟化。如果需要,你可以定义一个从信息方法调用的空的虚拟函数,子类可以覆盖此方法来执行其他工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public class MyBaseClass { public sealed void Update() { CustomUpdate(); ... // This class‘s update } //Called before this class does its own update //Override to hook in your own update code. virtual public void CustomUpdate(){}; } public class Child : MyBaseClass { override public void CustomUpdate() { //Do custom stuff } } |
这样可以防止某一类意外地覆盖你的代码,但是仍能够赋予其挂钩连接Unity信息的功能。我不喜欢这种模式的一个原因是事项次序发生问题。在上述示例中,子类可能想在此类自行更新后直接执行。
30. 从游戏逻辑分离接口。一般来说,接口组件不应该知道任何关于所应用游戏的任何内容。向它们提供需要可视化的数据,并订阅事件以查出用户与它们交互的时间。接口组件不应该创建gamelogic。它们可以筛选输入,从而确认其有效性,但是主规则处理不应在其他位置发生。在许多拼图游戏中,拼图块是接口的扩展,同时不应该包含任何规则。
(例如,棋子不应该计算自身的合法移动)。
类似地,输入应该从作用于此输入的逻辑分离。使用一个通知你的actor移动意图的输入控制器;由actor处理是否实际移动。
这里是一个允许用户从选项列表中选择武器的UI组件的简化示例。这些类知晓的唯一游戏内容是武器类(并且只是因为武器是这个容器需要显示数据的有用源)。此外,游戏也对容器一无所知;它所要做的是注册OnWeaponSelect事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
public WeaponSelector : MonoBehaviour { public event Action OnWeaponSelect {add; remove; } //the GameManager can register for this event public void OnInit(List weapons) { foreach(var weapon in weapons) { var button = ... //Instantiates a child button and add it to the hierarchy buttonOnInit(weapon, () => OnSelect(weapon)); // child button displays the option, // and sends a click-back to this component } } public void OnSelect(Weapon weapon) { if(OnWepaonSelect != null) OnWeponSelect(weapon); } } public class WeaponButton : MonoBehaviour { private Action<> onClick; public void OnInit(Weapon weapon, Action onClick) { ... //set the sprite and text from weapon this.onClick = onClick; } public void OnClick() //Link this method in as the OnClick of the UI Button component { Assert.IsTrue(onClick != null); //Should not happen onClick(); } } |
31. 分离配置,状态和簿记。
配置变量是指一类被inspector调整从而通过其属性定义对象的变量。如maxHealth。
状态变量是指一类可完全确定对象当前状态的变量,以及如果你的游戏支持保存操作,你需要保存的一类变量。如currentHealth。
簿记变量是指用于速度、方便或过度状态。它们总是完全可以通过状态变量确定。如previousHealth。
通过分离这些变量类型,你可以更容易知道哪些是可以更改的,哪些是需要保存的,哪些是需要通过网络发送/检索的,并允许你在某种程度上强制执行此类操作。下面给出了一个关于此设置的简单示例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public class Player { [Serializable] public class PlayerConfigurationData { public float maxHealth; } [Serializable] public class PlayerStateData { public float health; } public PlayerConfigurationData configuration; private PlayerState stateData; //book keeping private float previousHealth; public float Health { public get { return stateData.health; } private set { stateData.health = value; } } } |
32. 避免使用public索引耦合数组。例如,不要定义任何武器数组,任何子弹数组,以及任何颗粒数组,从而使你的代码类似于:
1 2 3 4 5 6 7 8 9 10 11 |
public void SelectWeapon(int index) { currentWeaponIndex = index; Player.SwitchWeapon(weapons[currentWeapon]); } public void Shoot() { Fire(bullets[currentWeapon]); FireParticles(particles[currentWeapon]); } |
这类问题不出在代码中,而是在inspector进行设置时不发出错误。相反,定义封装三个变量的类,并创建下述数组:
1 2 3 4 5 6 7 |
[Serializable] public class Weapon { public GameObject prefab; public ParticleSystem particles; public Bullet bullet; } 此代码看起来更整洁,但最重要的一点是,在inspector中设置数据更难以出错。 |
33. 避免使用除序列以外的结构数组。例如,玩家可能有三种攻击类型。每种类型使用当前武器,但生成不同的子弹和不通过的行为。
你可能会尝试将三个子弹转储到某个数组中,然后使用此类逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public void FireAttack() { /// behaviour Fire(bullets[0]); } public void IceAttack() { /// behaviour Fire(bullets[1]); } public void WindAttack() { /// behaviour Fire(bullets[2]); } Enums can make things look better in code… public void WindAttack() { /// behaviour Fire(bullets[WeaponType.Wind]); } |
最好使用分离变量以便于名称辅助显示将放入的内容。使用一类使其整洁。
1 2 3 4 5 6 7 |
[Serializable] public class Bullets { public Bullet fireBullet; public Bullet iceBullet; public Bullet windBullet; } |
它假设不存在其他火、冰和风的数据。
34. 将数据集中在可序列化类中,以使inspector中的事项更整洁。一些实体可能有几十个可调分。对于在inspector寻找正确的变量,它可能成为一个噩梦。要使事项更简便,请遵循以下步骤:
对于各变量组定义分离类。使它们公开化和可序列化。
在主类中,对上述每个类型的变量定义为公开。
切勿在Awake或Start中初始化这些变量;由于它们是可序列化的,Unity会对它进行处理。你可以通过在定义中分配值来指定先前的默认值;
这将变量集中到inspector中的可折叠单元,从而更容易进行管理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
[Serializable] public class MovementProperties //Not a MonoBehaviour! { public float movementSpeed; public float turnSpeed = 1; //default provided } public class HealthProperties //Not a MonoBehaviour! { public float maxHealth; public float regenerationRate; } public class Player : MonoBehaviour { public MovementProperties movementProeprties; public HealthPorperties healthProeprties; } |
35. 使非MonoBehaviours的类可序列化,即使它们不用于public字段。当 Inspector处于Debug模式下,它允许你查看inspector中的类字段。这同样适用于嵌套的类(私密或公开)。
36. 避免通过代码修改那些在Inspector中可编辑的变量。Inspector中可调整的变量即为配置变量,且不应该视为运行期间的常量,更不能作为一个状态变量。按照这种操作使得将组件状态重置为初始状态的编写方法更加简便,同时使变量动作更清楚。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class Actor : MonoBehaviour { public float initialHealth = 100; private float currentHealth; public void Start() { ResetState(); } private void Respawn() { ResetState(); } private void ResetState() { currentHealth = initialHealth; } } |
模式
模式是指一种按标准方法解决常见问题的途径。Bob Nystrom著有的《游戏编程模式》(免费在线阅读)为如何将模式应用于游戏编程中出现的问题提供了一种有效的观察资源。Unity本身使用了许多模式:Instantiate是原型模式的一个示例;MonoBehaviours遵循样板模式的一个版本,UI和动画使用了观察者模式,而新的动画引擎利用了状态机。
这些技巧均涉及到Unity模式的具体应用。
37.为了方便考虑,使用单例模式。下述类将从其自身继承的任何类自动转换为单例模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public class Singleton<t> : MonoBehaviour where T : MonoBehaviour { protected static T instance; //Returns the instance of this singleton. public static T Instance { get { if(instance == null) { instance = (T) FindObjectOfType(typeof(T)); if (instance == null) { Debug.LogError("An instance of " + typeof(T) + " is needed in the scene, but there is none."); } } return instance; } } }</t> |
单例模式对于ParticleManager or AudioManager or GUIManager等管理器很有用。
(许多程序员对模糊命名为XManager的类报警,这是因为它指向一个命名不当,或者设计有太多不相关任务的类)。一般来说,我同意这种做法。但是,我们在每个游戏中只有少量的管理器,并且它们在每个游戏中都做同样的事情,因此这些类实际上是常用语。)
避免对非管理器(如玩家)的Prefabs独特示例使用单例模式。若不遵守这一原则会使继承分层复杂化,并使某些变更类别更困难。而是保持引用你的GameManager(或者其他合适的超级类)。针对常在类外部使用的public变量和方法定义静态属性和方法。这允许你编写GameManager.Player,而不是GameManager.Instance.player。
如其他技巧中所述,单例模式也可用于创建持续在追踪全局数据的场景加载之间的默认派生点和对象。
38.使用状态机获取不同状态下的不同行为或者执行状态转换时的代码。一个轻量级状态机具有多种状态,并且每个状态允许你指定进入或存在状态的运行动作,以及更新动作。这可以使代码更清洁,同时具有较少的错误倾向。如果你的Update方法代码有一个改变其动作或者下面的变量的if-或者switch语句,那么你将从状态机受益:
hasShownGameOverMessage.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public void Update() { if(health <= 0) { if(!hasShownGameOverMessage) { ShowGameOverMessage(); hasShownGameOverMessage = true; //Respawning resets this to false } } else { HandleInput(); } } |
若存在更多状态,这种类型的代码可能变得非常混乱;状态机可以使它变得非常清洁。
39.使用类型UnityEvent的字段在inspector中设置观察者模式。UnityEvent类允许你将占用四个参数的方法链接到使用与Buttons上事件相同UI界面的inspector。
40.当一个字段值发生变化时,使用观察者模式以检测。只有当游戏中频繁发生变量变化时才会发生执行代码的问题。我们已经在一个通用类中创建一种关于此模式的通用解决方案,这样允许你无论何时发生值变化时注册事件。以下是一个health示例。其创建方式为:
1 2 |
/*ObservedValue*/ health = new ObservedValue(100); health.OnValueChanged += () => { if(health.Value <= 0) Die(); }; |
你现在可以在任何位置更改它,而无需在每个检查位置执行检查,例如:
if(hit)health.Value -= 10;
无论何时health值低于0,调用Die方法。更多讨论和实施,请参考此发布。
41.在prefabs上使用Actor模式。(这不是一个“标准”模式。其基本理念来自于本文所提及的Kieran Lord。)
Actor是Prefab中的主要组件;通常是提供prefabs“标识”的组件,较高级的代码将与其经常交互。Actor使用同一对象上(有时在子类上)的其他组件—Helpers—执行工作。如果你通过Unity的菜单创建一个Button对象,它将使用Sprite和Button组件创建一个游戏对象(用Text组件创建一个子类)。在这种情况下,Button是一个actor组件。同样,除了附连的Camera组件之外,主摄像机一般有多个组件(GUI图层,Flare图层,音频监听器)。Camera即为一个actor。
Actor可能需要结合其他组件才能正常工作。你可以通过使用下述在actor组件上属性使prefab更稳健和有用:
1)使用RequiredComponent来指示actor对于相同游戏对象所需的所有组件。(然后你的actor总是安全地调用GetComponent,而无需检查返回的值是否为空。)
2)使用DisallowMultipleComponent防止附加相同组件的多个实例。然后你的actor总是可以调用GetComponent,而无需担心当有多个组件附加时应产生什么行为)。
3)若你的actor对象有子类时,使用SelectionBase。这会使你在场景试图更容易选择。
1 2 3 4 5 6 7 |
[RequiredComponent(typeof(HelperComponent))] [DisallowMultipleComponent] [SelectionBase] public class Actor : MonoBehaviour { ...// } |
42.对随机和模式化数据流使用Generators。(虽然这不是一个标准模式,但我们发现它非常有用。)
Generator类似于随机生成器:它是一种具有可以被调用获取特定类型新项目的Next方法的对象。在构建期间可以操纵Generators生成各种模式或不同类型的随机性。它们很有用,因为它们保持生成新道具的逻辑与你需要的项目分离,从而使代码清洁多了。
这里有几个实例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
var generator = Generator .RamdomUniformInt(500) .Select(x => 2*x); //Generates random even numbers between 0 and 998 var generator = Generator .RandomUniformInt(1000) .Where(n => n % 2 == 0); //Same as above var generator = Generator .Iterate(0, 0, (m, n) => m + n); //Fibonacci numbers var generator = Generator .RandomUniformInt(2) .Select(n => 2*n - 1) .Aggregate((m, n) => m + n); //Random walk using steps of 1 or -1 one randomly var generator = Generator .Iterate(0, Generator.RandomUniformInt(4), (m, n) => m + n - 1) .Where(n >= 0); //A random sequence that increases on average |
我们使用Generators派生障碍,改变背景色,程序性音乐,生成可能在文字游戏中生成字母的字母序列,等等。此外,Generators在控制以非恒定间隔重复的协程方面也有效,其构造如下:
1 2 3 4 5 6 |
while (true) { //Do stuff yield return new WaitForSeconds(timeIntervalGenerator.Next()); } |
更多关于Generators的讨论,请参考此发布。
Prefabs和Scriptable object
43. 对任何事物使用prefabs。你的场景中唯一的游戏对象不应该是prefabs(或者prefabs的一部分),而应该是目录。即使仅使用一次的唯一对象应该是prefabs。这使得更容易进行无需场景变换的变更。
44. 对prefabs之间互相链接;而不要对实例对象互相链接。当prefab放置到某个场景中时,维护prefabs链接;对于实例链接则无需保持。尽可能的使用Prefab之间的链接可以减少场景创建的操作,并且减少场景的修改。
如有可能,在实例对象之间自动创建链接。如果你需要在实例之间链接,则在程序代码中创建链接。例如,玩家prefab在启动时需要把自己注册到GameManager,或者GameManager可以在启动时去查找玩家prefab。
45. 若需要添加其他脚本,不要将Mesh放置在prefabs的根节点上。当你需要从Mesh创建一个prefab时,首先创建一个空的GameObject作为父对象,并用来做根节点。把脚本放到根节点上,而不要放到Mesh节点上。通过采用这种方法,更容易替换Mesh,而不会丢失所有你在Inspector中设置的值。
46. 对共享配置数据,而不是prefabs使用Scriptableobject
若是如此:
1)场景较小
2)你不能错误地对单个场景(prefab实例上)进行更改。
47. 对level数据使用scriptableobjects。关卡数据常存储在XML或JSON中,但使用scriptable objects具有一些优点:
1)它可以在Editor中编辑。这样更容易验证数据,并且对非技术领域的设计师更友好。此外,你可以使用自定义编辑器使编辑更容易。
2)你不必操心读取/编写和解析数据。
3)它更容易分拆和嵌套,同时管理生成的assets,因此是从构建块,而非大型配置组成关卡。
48. 使用scriptable objects配置inspector中的行为。Scriptableobjects通常与数据配置相关,但它们也支持将“方法”用作数据。
考虑一个场景,其中你有一个Enemy类型,并且每个敌人有一堆SuperPowers。如果它们在Enemy类中,你可以创建这些常规类,并生成一个列表……若没有自定义编辑器,你便无法在inspector中设置一个包含不同superpowers的列表(每个具有自身属性)。但如果你创建这些super powers assets(将它们实现为ScriptableObjects),你就可以进行上述设置!
其构造为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public class Enemy : MonoBehaviour { public SuperPower superPowers; public UseRandomPower() { superPowers.RandomItem().UsePower(this); } } public class BasePower : ScriptableObject { virtual void UsePower(Enemy self) { } } [CreateAssetMenu("BlowFire", "Blow Fire") public class BlowFire : SuperPower { public strength; override public void UsePower(Enemy self) { ///program blowing fire here } } |
当遵循这一模式时,需注意以下几点:
1)无法可靠地使Scriptable objects抽象化。相反,需要使用具体的基类,并使用抽象方法抛出NotImplementedExceptions。此外,你也可以定义Abstract属性,并标记应为抽象的类和方法。
2)Scriptableobjects是指无法序列化的通用对象。然而,你可以使用通用基类,并且只对指定所有通用对象的子类抽象化。
49. 使用scriptable objects对prefabs特殊化。若两个对象的配置仅在某些属性上不同,则通常在场景中放置两个实例,并调整这些实例上的属性。通常较好的做法是创建一个单独的属性类,它可以区别两种类型为一个单独的scriptableobject类。
这可以提供更多的灵活性:
1)你可以利用从特殊类的继承,向不同对象类型提供更具体的特定属性。
2)场景设置更安全(你只要选择正确的scriptable object,而无需调整所有属性,便可以创建所需类型的对象)。
3)运行期间,通过代码更容易操纵这些对象。
4)如果你有这两种类型的多个实例,你就会知道当进行更改时,它们的属性将总是保持一致。
5)你可以将配置变量集分拆为可以混合和匹配的集合。
下面举出了一个关于此设置的简要示例:
1 2 3 4 5 6 7 8 9 10 11 |
[CreateAssetMenu("HealthProperties.asset", "Health Properties")] public class HealthProperties : ScriptableObject { public float maxHealth; public float resotrationRate; } public class Actor : MonoBehaviour { public HealthProperties healthProperties; } |
如果特殊化类的数量较大,你可能要将特殊化类定义为普通类,并使用链接到一个包含某些特殊化类的列表,这些特殊化类是链接到你可以获取的适当位置的scriptable object中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
public enum ActorType { Vampire, Wherewolf } [Serializable] public class HealthProperties { public ActorType type; public float maxHealth; public float resotrationRate; } [CreateAssetMenu("ActorSpecialization.asset", "Actor Specialization")] public class ActorSpecialization : ScriptableObject { public List healthProperties; public this[ActorType] { get { return healthProperties.First(p => p.type == type); } //Unsafe version! } } public class GameManager : Singleton { public ActorSpecialization actorSpecialization; ... } public class Actor : MonoBehaviour { public ActorType type; public float health; //Example usage public Regenerate() { health += GameManager.Instance.actorSpecialization[type].resotrationRate; } } |
50. 使用CreateAssetMenu属性自动向Asset/Create菜单添加ScriptableObject创建。
调试
51. 学习如何有效地使用Unity的调试工具。
1)向Debug.Log语句添加上下文对象以查看它们的生成位置。
2)在编辑器中使用Debug.Break暂停游戏(例如,当你想产生错误条件,并且在该帧上检查部件属性时,它很有用)。
3)针对可视化调试使用Debug.DrawRay和Debug.DrawLine功能(例如,当调试为什么没有光影投射时,DrawRay非常有效)。
4)针对可视化调试使用Gizmos。此外,你可以通过使用DrawGizmo属性提供mono behaviours外部的gizmo渲染器。
5)使用debug inspector试图(使用inspector查看运行中的私密字段的值)。
52. 学习如何有效地使用调试器。详见Visual Studio中的“调试Unity游戏示例”。
53. 使用一个随着时间的推移绘制数值图形的可视化调试器。这对于调试物理,动画和其他动态进程,尤其是偶然性错误非常有用。你将能够从图中找出错误,并能够同时有哪些其他变量发生了变化。另外,可视化检查也使某些异常行为变得更明显,比如说数值变化太频繁,或者不具明显原因地发生偏移。我们使用的是Monitor Components,但也有几种可用的方案。
54. 使用改进的控制台记录。使用一个可以根据类别进行颜色编码输出,同时可以根据这些类别筛选输出的编辑器扩展。我们使用的是Editor Console Pro,但也有几种可用的方案。
55. 使用Unity的测试工具,特别是测试算法和数学代码。详见Unity测试工具教程,或者使用Unity测试工具以光速进行事后单元测试。
56. 使用Unity的测试工具以运行“scratchpad”测试。
Unity的测试工具不仅适合正式测试,而且还可以便于进行可以在编辑器中运行,同时无需场景运行的scratch-pad测试。
57. 实现截屏快捷键。当你截屏拍照时,许多错误是可见的,并且更容易报告。理想化的系统应该在PlayerPrefs保持一个计数器,从而使连续截屏不会被覆盖。截屏应保存在项目文件夹外,以避免人员将它们误提交到存储库。
58. 实现打印重要变量快照的快捷方式。当你可以检查的游戏期间发生未知错误,这样更容易记录一些信息。当然,记录哪些变量是取决于你的游戏。实例是玩家和敌人的位置,或者AI演员的“思维状态”(例如尝试行走的路径)。
59. 实现一些方便测试的调试选项。下面举出了一些示例:
解锁所有道具。
禁用敌人。
禁用GUI。
让玩家无敌。
禁用所有游戏逻辑。
要注意,切勿不慎提交调试选项;更改调试选项可能会迷惑团队中的其他开发人员。
60. 定义一些Debug快捷键常量,并将它们保存到同一个位置。通常(为方便起见)在一个位置处理Debug键,如同其它的游戏输入一样。为了避免快捷键冲突,在一个中心位置定义所有常量。另一种方法是在某个位置处理所有按键输入,无论它是否是Debug键。(其负面效果在于,此类可能需要引用更多的其它对象)。
61. 在程序网格生成时,在顶点绘制或派生小球体。这将帮助你在使用三角形和UVs以显示网格之前,确定顶点处在期预期的位置,并且网格是正确的尺寸。
性能
62. 请注意关于效能原因设计和构造的通用建议。
1)这些建议通常是基于虚构的,而不是由测试支持的。
2)即便有时建议是由测试支持的,但测试存在错误。
3)有时建议是由正确的测试支持,但它们处在不真实的或不同的环境之中。(例如,很容易展现如何比通用列表更快地使用数组。然而,在真实游戏环境中,这种差异几乎总是可以忽略不计。同样,若测试适用于除目标设备以外的不同硬件时,它们的结果可能对你无意义。)
4)有时建议是良好的,但却过时。
5)有时,建议是适用的。然而,存在权衡关系。航运慢速游戏有时要好于非航运快速游戏。而高度优化的游戏更可能包含可以延迟航运的复杂代码。
效能建议可能有助于记忆,帮助你通过下述进程更快地追踪实际问题源。
63. 从早期阶段对目标设备进行定期测试。
不同的设备可能具有显著不同的效能特性;不要对它们感到吃惊。越早知道问题,你就能越有效地解决问题。
64. 知道如何更有效地使用效能评测器以追踪导致效能问题的原因。
如果你刚接触效能分析,请参阅效能评测器简介。
学习如何针对精细度分析来定义你自己的框架(使用Profiler.BeginFrame 和Profiler.EndFrame)。
学习如何使用平台特定的效能分析,如iOS系统的内置效能分析器。
学习分析内置玩家中的文件,并显示效能分析器中的数据。
65. 在必要时,使用自定义分析器进行更准确的分析。有时,Unity的效能分析器无法清楚地展示发生的事物;它可能消耗完分析框架,否则深度分析可能减慢游戏速度,以致于测试没有意义。我们对此使用自有的内部分析器,但应该可以在Asset Store中找到其他替代工具。
66. 衡量效能增强的影响。
当你作出更改提升效能时,衡量它确保该更改着实有效。如果这个更改是不可衡量或凌乱的,请撤销更改。
67. 不要编写可读度减低的代码,以保证更佳的效能。除非有下述任一情况:
你碰到了一个问题,使用效能分析器识别出问题源,同时相较于可维护性损失,获得的增益足够高。或者你清楚自己在做什么。
命名规范和目录结构
68.遵循一个命名规范和目录结构。保持命名和目录结构的一致性可以方便查找,并明确指出具体内容。
你很有可能想要创建自己的命名规范和目录结构。下面举出了一个例子。
命名的一般原则
1.按事物本身命名。例如,鸟应该称为Bird。
2. 选择可以发音,方便记忆的名字。如果你在制作一个与玛雅文化相关的游戏,不要把关卡命名为QuetzalcoatisReturn。
3. 保持一致性。如果你选择了一个名字,就坚持用它。不要在一处命名buttonHolder,而在其它位置命名buttonContainer。
4. 使用Pascal风格的大小写,例如ComplicatedVerySpecificObject。
不要使用空格,下划线,或者连字符,但有一个例外
(详见为同一事物的不同方面命名一节)。
5. 不要使用版本数字,或者表示其进度的名词(WIP,final)。
6. 不要使用缩写:[email protected]应该写成[email protected]。
7. 使用设计文档中的术语:如果文档中将一个动画命名为Die,则使用[email protected],而不要用[email protected]。
8. 保持细节修饰词在左侧:DarkVampire,而不是VampireDark;PauseButton,而不是ButtonPaused。举个例子,在Inspector中查找PauseButton,这要比所有按钮都以Button开头更加方便。(很多人倾向于相反次序,认为这样可以使名称自然分组。然而,名称不是用来分组的,目录才是。名称是用于在同一类对象中快速辨识的。)
9.某些名称形成一个序列。在这些名称中使用数字。例如PathNode0, PathNode1。永远从0开始,而不是1。
10. 对于非序列的情况,不要使用数字。例如 Bird0, Bird1, Bird2,本应该是Flamingo, Eagle,Swallow。
11.为临时对象添加双下划线前缀,例如__Player_Backup
命名同一事物的不同方面
在核心名称与描述“对象”的事物之间添加下划线。例如:
GUIbuttons states EnterButton_Active,EnterButton_Inactive
Textures DarkVampire_Diffuse,DarkVampire_Normalmap
Skybox JungleSky_Top,JungleSky_North
LODGroups DarkVampire_LOD0, DarkVampire_LOD1
不要只是为了区分不同类型的项目而使用此类规范,例如Rock_Small, Rock_Large,本应该是SmallRock,LargeRock。
结构
场景,项目目录和脚本目录的结构应遵循一个类似的模式。下面列举了一些精简示例。
目录结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
MyGame Helper Design Scratchpad Materials Meshes Actors DarkVampire LightVampire ... Structures Buildings ... Props Plants ... ... Resources Actors Items ... Prefabs Actors Items ... Scenes Menus Levels Scripts Tests Textures UI Effects ... UI MyLibray ... Plugins SomeOtherAsset1 SomeOtherAsset2 ... |
场景结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
Main Debug Managers Cameras Lights UI Canvas HUD PauseMenu ... World Ground Props Structures ... Gameplay Actors Items ... Dynamic Objects |
脚本目录结构
1 2 3 4 5 6 7 8 9 |
Debug Gameplay Actors Items ... Framework Graphics UI ... |
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权;
原文地址:https://www.cnblogs.com/kubll/p/10804333.html