搜索 Unity

节约时间的高级编辑器编程黑科技(第二部)

2022年11月8日 类别 Engine & platform | 15 分 阅读
Tech from the Trenches | Advanced Editor scripting hacks to save you time, part 2 – Hero image
Tech from the Trenches | Advanced Editor scripting hacks to save you time, part 2 – Hero image
分享

Is this article helpful for you?

Thank you for your feedback!

我又回来写第二部了!如果你还没看过编辑器编程黑科技的第一部,可以在这里查看。这篇两部分的文章将带你了解高级的编辑器使用技巧、改善你的工作流程,让你下一个项目进展更顺利。

每条技巧都建立在一个类RTS(即时战略)的游戏原型上,游戏里的每个单位都会自动攻击敌方建筑及其他单位。下方是最初版的原型:

最初版本

上一篇里,我介绍了怎样为项目导入并设置好美术资产。现在我们再到游戏中使用这些资产,同时尽可能地节省时间。

首先是游戏元素的拆包。在准备游戏里的各个元素时,我们通常会遇到以下情况:

一方面,我们从美术团队那拿到预制件,它可以是FBX Importer生成的预制件,也可能是手动用材质和动画小心建立的预制件,用于给层级结构添加道具等。要在游戏里使用预制件,合理的做法是创建这个预制体的预制件变体(Prefab Variant),再把所有游戏相关的组件加进去。这样一来,当美术团队修改或更新预制件时,所有改动可以立即应用到游戏中。这种方法的确能奏效,如果对象的组件较少、设置较简单。但如果它非常复杂,每次都要从头开始配置就非常的麻烦。

另一方面,许多的组件其实会有同样的属性,比如所有的“汽车”预制件或相似的敌人。这时,用同一个基础预制件来制作所有变体是可行的。也就是说,如果预制件的美术设置起来很方便(即模型网格与材质),那这种方法就很理想。

接着来看看怎样简化游戏玩法组件的设置过程,以便快速添加并直接使用。

技巧七:提早设立好组件

对于游戏里较为复杂的元素,最常见的方法是用一个“主要”组件(比如“enemy”、“pickup”或“door”)作为与对象互动的接口,用一堆可重复使用的小组件来实现各种功能,比如“selectable”、“CharacterMovement”或“UnitHealth”,以及renderer、collider这些Unity内置组件。

有些组件依赖于其他组件工作。比如,角色可能要有一个NavMeshAgent(代理)才能移动。而Unity的[RequireComponent]特性正好能写入这些依赖项。如果特定对象有一个“主要”组件,你可以用[RequireComponent]来添加对象所必须的组件。

例如,我原型的单位们有以下特性:

[AddComponentMenu("My Units/Soldier")]
[RequireComponent(typeof(Locomotion))]
[RequireComponent(typeof(AttackComponent))]
[RequireComponent(typeof(Vision))]
[RequireComponent(typeof(Armor))]
public class Soldier : Unit
{
}

并且,我还能在AddComponentMenu轻松找到其他需要的额外组件。这里,Locomotion负责移动,AttackComponent负责攻击其他单位。

另外,该类还会继承基础类(与建筑共享)的其他RequireComponent特性,比如Health(血量)组件。有了这些,我只需再手动加上Soldier组件,其他组件都会自动被添加。如果我再为某个组件添加一条新的RequireComponnet特性,Unity会将新组件更新到所有现存的游戏对象上,帮助扩展现有对象。

[RequireComponent]还有一个隐蔽的好处:如果“组件A”需要“组件B”,则添加A不仅能保证B会被添加,还能让B先于A被添加。如果组件A调用了Reset方法,组件B依旧会存在并且对数据的访问仍会保留。我们可以引用这个组件,记录持续性的UnityEvents,再完成对象的设置。同时使用RequireComponent特性与Reset方法,我们可以只加一个组件就完成对象的配置。

初始设置

技巧八:与没有关联的预制件分享数据

上个方法最大的缺点在于,如果我们想修改某个值,就必须一个个手动更改。如果所有的设置都用代码完成,设计师们要改起来会非常困难。

在前一篇文章里,我们讨论了怎样用AssetPostprocessor在导入期间添加依赖项、修改对象。我们同样能用它在预制件上应用数值。

为了让设计师们能轻松修改这些数值,我们需要从预制件上读取它们,以便通过修改预制件来更改整个项目中的数值。

如果是编辑器代码,你可以利用Preset类把一个组件的数值复制到另一个。

像这样用原组件创建一个预设,再应用到另一个组件:

private static void ApplyTemplate(GameObject go, GameObject template)
{
    // Get all the components in the object
    foreach (var comp in go.GetComponents<Component>())
    {
        // Try to get the corresponding component in the teplate
        if (!template.TryGetComponent(comp.GetType(), out var templateComp))
            continue;

        // Create the preset
        var preset = new Preset(templateComp);
        // Apply it
        preset.ApplyTo(comp);
    }
}

在生效时,它会覆盖预制件的数值,不过这并不是我们的目的。我们只想复制部分数值,其他的不动。此时,我们可以用Preset.ApplyTo覆盖方法,它接收一个一定要应用的属性列表。虽然我们能硬写出一份待覆盖的属性列表,这对大部分项目来说都没问题,但如何让这个流程更为通用呢。

我先是创建了一个带有所有组件的基础预制件,然后以其作为模板创建了一个变体。接着我再从变体的覆盖列表里确定需要应用的数值。

你可以用PrefabUtility.GetPropertyModifications来获取覆盖值。该方法会抓取整个预制件上的所有覆盖值,你需要筛选出与组件相关的那几个。要注意的是我们修改的是基础预制件的组件,而非变体,所以得用GetCorrespondingObjectFromSource来引用它。

private static void ApplyTemplate(GameObject go, GameObject template)
{
    // Get all the components in the object
    foreach (var comp in go.GetComponents<Component>())
    {
        // Try to get the corresponding component in the template
        if (!template.TryGetComponent(comp.GetType(), out var templateComp))
            continue;

        // Get all the modifications
        var overrides = new List<string>();
        var changes = PrefabUtility.GetPropertyModifications(templateComp);
        if (changes == null || changes.Length == 0)
            continue;

        // Filter only the ones that are for this component
        var target = PrefabUtility.GetCorrespondingObjectFromSource(templateComp);
        foreach (var change in changes)
        {
            if (change.target == target)
                overrides.Add(change.propertyPath);
        }

        // Create the preset
        var preset = new Preset(templateComp);
        // Apply only the selected ones
        if (overrides.Count > 0)
            preset.ApplyTo(comp, overrides.ToArray());
    }
}

这一段会将模板的所有覆盖值应用到预制件上。另一个细节问题是模板可能是一个变体的变体,所以我们也得应用上一层变体的覆盖值。

为此,我们得让这段操作循环往复:

private static void ApplyTemplateRecursive(GameObject go, GameObject template)
{
    // If this is a variant, apply the base prefab first
    var templateSource = PrefabUtility.GetCorrespondingObjectFromSource(template);
    if (templateSource)
        ApplyTemplateRecursive(go, templateSource);

    // Apply the overrides from this prefab
    ApplyTemplate(go, template);
}

接着我们找到预制件的模板。理想情况下,不同类型的对象会有不同的模板。我们可以将模板及待修改的对象放在同一文件夹下来提高效率。

在放预制件的文件夹下查找名为Template.prefab的对象。如果没有,就在上级文件夹里重复查找:

private void OnPostprocessPrefab(GameObject gameObject)
{
    // Recursive call to apply the template
    SetupAllComponents(gameObject, Path.GetDirectoryName(assetPath), context);
}

private static void SetupAllComponents(GameObject go, string folder, AssetImportContext context = null)
{
    // Don't apply templates to the templates!
    if (go.name == "Template" || go.name.Contains("Base"))
        return;

    // If we reached the root, stop
    if (string.IsNullOrEmpty(folder))
        return;

    // We add the path as a dependency so this gets reimported if the prefab changes
    var templatePath = string.Join("/", folder, "Template.prefab");
    if (context != null)
        context.DependsOnArtifact(templatePath);

    // If the file doesn't exist, check in our parent folder
    if (!File.Exists(templatePath))
    {
        SetupAllComponents(go, Path.GetDirectoryName(folder), context);
        return;
    }

    // Apply the template
    var template = AssetDatabase.LoadAssetAtPath<GameObject>(templatePath);
    if (template)
        ApplyTemplateSourceRecursive(go, template);
}

到这里,只要模板预制件被修改,所有改动都能自动应用到同一文件夹内的预制件上,即便它们并非模板的变体。在例中,我修改了默认的玩家颜色(当单位未被指派给任意玩家时)。注意看所有对象都更新了:

修改后的组件

技巧九:用ScriptableObject(可编程对象)和表格来平衡游戏数据

在平衡游戏时,需要调整的数据往往散布在各个组件上,存在每个角色的预制件或ScriptableObject里。这使得细节调整非常低效。

使用表格来简化平衡过程是一种常见的做法。它可以搜集所有的数据,还能用公式来计算一些额外数据。手动将数据输入到Unity里可能会非常麻烦。

而表格此时就能发挥一定作用。表格可以被导出为CSV (.csv)或TSV (.tsv),再用ScriptedImporter导入。下方截图展示了原型单位的统计数据:

Example of a spreadsheet | Tech from the Trenches
表格示例

这段代码非常简单:用单位的所有统计数据创建一个ScriptableObject,再读取文件。你可以根据表的每一行创建一个ScriptableObject的实例,填入行内的数据。

最后,利用上下文将ScriptableObject添加到导入后的资产上。另外,我们还得添加一个主资产,这里我创建了一个空的TextAsset(我们不会真的用这个对象干什么)。

这个方法同时适用于建筑和单位,你只需要留心那些数据更多的单位。

[ScriptedImporter(0, "tsv")]
public class UnitStatsImporter : ScriptedImporter
{
    public override void OnImportAsset(AssetImportContext ctx)
    {
        var file = File.OpenText(assetPath);

        // Check if this is a Unit
        bool isUnit = !assetPath.Contains("Buildings");

        // The first line is the header, ignore
        file.ReadLine();

        var main = new TextAsset();
        ctx.AddObjectToAsset("Main", main);
        ctx.SetMainObject(main);

        while (!file.EndOfStream)
        {
            // Read the line and divide at the tabs
            var line = file.ReadLine();
            var lineElements = line.Split('\t');

            var name = lineElements[0].ToLower();
            if (isUnit)
            {
                // Fill all the values
                var entry = ScriptableObject.CreateInstance<SoldierStats>();
                entry.name = name;
                entry.HP = float.Parse(lineElements[1]);
                entry.attack = float.Parse(lineElements[2]);
                entry.defense = float.Parse(lineElements[3]);
                entry.attackRatio = float.Parse(lineElements[4]);
                entry.attackRange = float.Parse(lineElements[5]);
                entry.viewRange = float.Parse(lineElements[6]);
                entry.speed = float.Parse(lineElements[7]);

                ctx.AddObjectToAsset(name, entry);
            }
            else
            {
                // Fill all the values
                var entry = ScriptableObject.CreateInstance<BuildingStats>();
                entry.name = name;
                entry.HP = float.Parse(lineElements[1]);

                ctx.AddObjectToAsset(name, entry);
            }
        }

        // Close the file
        file.Close();
    }
}

这步完成后,你就有了包含所有表格数据的ScriptableObject。

Imported data from the spreadsheet
从表格内导入数据

生成的ScriptableObject可以随时被用到游戏里。你也能借助之前编写的PrefabPostprocessor来应用它们。

OnPostprocessPrefab方法里,我们能加载该资产,并自动在组件的参数上填入输入。不仅如此,如果数据资产设有依赖项,预制件会在数据被修改时重新导入,自动更新所有内容。

在tsv文件里修改骨兵的速度,修改可立即生效。

技巧十:加快编辑器内的迭代

在搭建关卡时,快速修改并测试、重复微调并实验非常关键。因此,快速迭代、简化测试步骤十分重要。

在讨论Unity迭代时间时,我们最先想到的可能就是Domain Reload(域重载)。Domain Reload与两种关键情形相关:代码编译完成、加载动态链接库(DLL)时,以及进入与退出Play Mode(运行模式)时。编译产生的域重载不可避免,但你可以在Project Settings > Editor > Enter Play Mode Settings里禁用Play Mode的重载。

如果你的代码编写得不合适,禁用这部分重载会产生一些问题,最常见的有静态变量在运行后不会重置。如果你的代码能适应,那就禁用它吧。在我的原型里,Domain Reload已被禁用,你可以瞬间进入Play Mode。

技巧十一:自动生成数据

迭代时间的另一个问题在于重新计算运行所需的数据。我们需要选中这些组件,点击对应的按钮来触发重新计算。比如,在我的原型里,每支队伍都有一个TeamController。这个控制程序以列表列出了所有敌方建筑,并会派出单位攻击建筑。要想自动填写这些数据,我们可以用IProcessSceneWithReport接口。我在两种情形下调用这个接口:游戏打包时,在Play Mode里加载场景时。这时我有机会创建、摧毁或修改任意对象。不过,这些改动只会影响运行版和Play Mode。

这次回调会创建控制器、设定建筑列表。而我就不必再手动调整任何东西。当游戏开始时,控制器会带有一份更新后的建筑列表,任何对列表的修改也会被自动更新。

在原型里,我编写了一个方法来获取场景内某一组件的所有实例。你能用它来抓取所有的建筑:

private List<T> FindAllComponentsInScene<T> (Scene scene) where T : Component
{
    var result = new List<T>();
    var roots = scene.GetRootGameObjects();
    foreach (var root in roots)
    {
        result.AddRange(root.GetComponentsInChildren<T>());
    }
    return result;
}

剩下的就简单了:抓取所有建筑,找到建筑的所有所属队伍,为每支队伍创建一个带有敌方建筑列表的控制器。

public void OnProcessScene(Scene scene, BuildReport report)
{
    // Find all the targets
    var targets = FindAllComponentsInScene<Building>(scene);

    if (targets.Count == 0)
        return;

    // Get a list with the teams of all the buildings
    var allTeams = new List<Team>();
    foreach (var target in targets)
    {
        if (!allTeams.Contains(target.team))
            allTeams.Add(target.team);
    }

    // Create the team controllers
    foreach (var team in allTeams)
    {
        var obj = new GameObject(team.name + " Team", typeof(TeamController));
        var controller = obj.GetComponent<TeamController>();
        controller.team = team;

        foreach (var target in targets)
        {
            if (target.team != team)
                controller.allTargets.Add(target);
        }
    }
}
简化后的迭代

技巧十二:处理多个场景

除了编辑中的场景,游戏还会加载其他场景(每个场景都包含管理程序、UI等等)。编辑这些场景会占据一定的宝贵时间。在我的原型里,展示血条的Canvas被放在了另一个称为InGameUI的场景中。

为了高效地利用好这个场景,我在场景里加了一个组件,其中以列表列出了需要一并加载的场景。如果你在Awake里同时加载这些场景,UI场景也会被加载,它所有的Awake方法都会被触发。等到调用Start方法时,所有的场景就已经完成了加载和初始化,让你能访问其中的数据,比如管理器单例。

当然,在进入Play Mode时部分场景是已经加载了的,所以有必要在加载前检查个别场景是否已加载:

private void Awake()
{
    foreach (var scene in m_scenes)
    {
        if (!SceneManager.GetSceneByBuildIndex(scene.idx).IsValid())
        {
            SceneManager.LoadScene(scene.idx, LoadSceneMode.Additive);
        }
    }
}

总结

第一部和第二部两篇文章里,我已经展示了怎样利用起那些鲜为人知的Unity特色功能。这里所列出的方法只是一个个小步骤,但我希望它们能在你的下一个项目里发挥作用,或至少成为候选。

原型所用到的资产都能在资源商店上免费下载:

如果你有兴趣讨论下文章,或者分享自己的感想,请前往我们的Scripting论坛。这里我先下线了,但你可以在Twitter(@CaballolD)联系我。未来将有更多Unity开发者发布Tech from the Trenches系列技术博文,请持续关注

2022年11月8日 类别 Engine & platform | 15 分 阅读

Is this article helpful for you?

Thank you for your feedback!

相关文章