Unity更好用。例如,我现在信赖FPS计数器。使用property drawer的功能可以降低编写customeditors的必要性。同时Prefab的工作方式也降低了显式嵌套Prefab或替代件的需求。Scriptable objects更为友好                                                                                          

VisualStudio集成度更佳,从而使调试操作更简便,同时减少了对于大量Gorilla调试操作的需求。

第三方工具和库更优化。AssetStore里现有许多可用的对于可视化调试和更佳日志记录等事物的辅助工具。我们自带(免费)的扩展插件有大量初始发布中所述的代码(以及本次版本发布所述的许多代码)。

版本控制更好。(或者可以说,我现在知道如何更有效地使用它)。比方说,无需对Prefab执行多个副本或者备份。

个人经验的积累。在过去四年里,我参与了许多Unity项目;包括大量的游戏原型制作,Father.IO等的游戏制作,以及我们的旗舰工具Unity asset Grids

本文是在考虑上述所有内容的基础上对初始版本进行的修订版本。

在继续讨论技巧前,本人现发布以下免责声明(与初始版本基本相同):

这些技巧并不适用于每个Unity项目。

这些技巧是基于本人与3到20人的小型团队协作参与项目所获得的经验。在结构性,可重用性,清晰度等方面存在费用——根据团队规模,项目规模和项目目标来确定是否此费用。比方说,你可能不会在游戏制作环节使用所有内容。      

开发流程

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”:

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值的类)。

(但切勿直接使用泛型类,你必须扩展特定T值的类)。
[Serializable] 
public class Optional<t> 

   public bool useCustomValue; 
   public T value; 

</t> 

在你的代码中,你可以采取这种使用途径:

health= healthMax.useCustomValue ? healthMax.Value : DefaultHealthMax;

15. 如果你使用“协程”,学习如何有效地使用它。

“协程”是解决许多问题的一种最有效的方法。但是难以对“协程”进行调式,同时你可以很容易地对它进行混乱的编码,从而使其他人,甚至包括你自己也无法理解其意义。

你应该知道:

  1. 如何并发执行协程。
  2. 如何按序执行协程。
  3. 如何从现有程序中创建新的协程。
  4. 如何使用“CustomYieldInstruction”创建自定义协程。


//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却可以。下面的方法将其整洁地套入通用方法之中。

public static TInterface GetInterfaceComponent<tinterface>(thisComponent thisComponent)
   where TInterface : class
{
   return thisComponent.GetComponent(typeof(TInterface)) asTInterface;
}</tinterface>

17. 利用扩展法使语法更简洁。例如:

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扩展。

public static T GetRequiredComponent(thisGameObject 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。

其大概形式如下:

public void Call<t>(stringcall, 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);
}
 
publicAction<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信息方法。如果你这样做,切勿使他们虚拟化。如果需要,你可以定义一个从信息方法调用的空的虚拟函数,子类可以覆盖此方法来执行其他工作。

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事件。

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。

通过分离这些变量类型,你可以更容易知道哪些是可以更改的,哪些是需要保存的,哪些是需要通过网络发送/检索的,并允许你在某种程度上强制执行此类操作。下面给出了一个关于此设置的简单示例。

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索引耦合数组。例如,不要定义任何武器数组,任何子弹数组,以及任何颗粒数组,从而使你的代码类似于:

public void SelectWeapon(int index)
{
   currentWeaponIndex = index;
   Player.SwitchWeapon(weapons[currentWeapon]);
}
  
public void Shoot()
{
   Fire(bullets[currentWeapon]);
   FireParticles(particles[currentWeapon]);
}

这类问题不出在代码中,而是在inspector进行设置时不发出错误。相反,定义封装三个变量的类,并创建下述数组:

[Serializable]
public class Weapon
{
   public GameObject prefab;
   public ParticleSystem particles;
   public Bullet bullet;
}

此代码看起来更整洁,但最重要的一点是,在inspector中设置数据更难以出错。

33. 避免使用除序列以外的结构数组。例如,玩家可能有三种攻击类型。每种类型使用当前武器,但生成不同的子弹和不通过的行为。

你可能会尝试将三个子弹转储到某个数组中,然后使用此类逻辑:

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]);
}

最好使用分离变量以便于名称辅助显示将放入的内容。使用一类使其整洁。

[Serializable]
public class Bullets
{
   public Bullet fireBullet;
   public Bullet iceBullet;
   public Bullet windBullet;
}

它假设不存在其他火、冰和风的数据。

34. 将数据集中在可序列化类中,以使inspector中的事项更整洁。一些实体可能有几十个可调分。对于在inspector寻找正确的变量,它可能成为一个噩梦。要使事项更简便,请遵循以下步骤:

对于各变量组定义分离类。使它们公开化和可序列化。

在主类中,对上述每个类型的变量定义为公开。

       切勿在Awake或Start中初始化这些变量;由于它们是可序列化的,Unity会对它进行处理。你可以通过在定义中分配值来指定先前的默认值;

这将变量集中到inspector中的可折叠单元,从而更容易进行管理。

[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中可调整的变量即为配置变量,且不应该视为运行期间的常量,更不能作为一个状态变量。按照这种操作使得将组件状态重置为初始状态的编写方法更加简便,同时使变量动作更清楚。

public class Actor : MonoBehaviour
{
   public float initialHealth = 100;
    
   private float currentHealth;
 
   public void Start()
   {
      ResetState();
   }  
 
   private void Respawn()
   {