Search Unity

Advanced Editor scripting hacks to save you time, part 2

November 8, 2022 in Technology | 15 min. read
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

I’m back for part two! If you missed the first installment of my advanced Editor scripting hacks, check it out here. This two-part article is designed to walk you through advanced Editor tips for improving workflows so that your next project runs smoother than your last.

Each hack is based on a demonstrative prototype I set up – similar to an RTS – where the units of one team automatically attack enemy buildings and other units. For a refresher, here’s the initial build prototype:

Initial build

In the previous article, I shared best practices on how to import and set up the art assets in the project. Now let’s start using those assets in the game, while saving as much time as possible.

Let’s begin by unpacking the game’s elements. When setting up the elements of a game, we often encounter the following scenario:

On one hand, we have Prefabs that come from the art team – be it a Prefab generated by the FBX Importer, or a Prefab that has been carefully set up with all the appropriate materials and animations, adding props to the Hierarchy, etc. To use this Prefab in-game, it makes sense to create a Prefab Variant from it and add all the gameplay-related components there. This way, the art team can modify and update the Prefab, and all the changes are reflected immediately in the game. While this approach works if the item only requires a couple of components with simple settings, it can add a lot of work if you need to set up something complex from scratch every time.

On the other hand, many of the items will have the same components with similar values, like all the Car Prefabs or Prefabs for similar enemies. It makes sense that they’re all Variants of the same base Prefab. That said, this approach is ideal if setting up the art of the Prefab is straightforward (i.e., setting the mesh and its materials).

Next, let’s look at how to simplify the setup of gameplay components, so we can quickly add them to our art Prefabs and use them directly in the game.

Hack 7: Set up components from the start

The most common setup I’ve seen for complex elements in a game is having a “main” component (like “enemy,” “pickup,” or “door”) that behaves as an interface to communicate with the object, and a series of small, reusable components that implement the functionality itself; things like “selectable,” “CharacterMovement,” or “UnitHealth,” and Unity built-in components, like renderers and colliders.

Some of the components depend on other components in order to work. For instance, the character movement might need a NavMesh agent. That’s why Unity has the RequireComponent attribute ready to define all these dependencies. So if there’s a “main” component for a given type of object, you can use the RequireComponent attribute to add all the components that this type of object needs to have.

For example, the units in my prototype have these attributes:

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

Besides setting an easy-to-find location in the AddComponentMenu, include all the extra components it needs. In this case, I added the Locomotion to move around and the AttackComponent to attack other units.

Additionally, the base class unit (which is shared with the buildings) has other RequireComponent attributes that are inherited by this class, such as the Health component. With this, I only need to add the Soldier component to a GameObject so that all the other components are added automatically. If I add a new RequireComponent attribute to a component, Unity will update all the existing GameObjects with the new component, which facilitates extending the existing objects.

RequireComponent also has a more subtle benefit: If we have “component A” that requires “component B,” then adding A to a GameObject doesn’t just ensure that B is added as well – it actually ensures that B is added before A. This means that when the Reset method is called for component A, component B will already exist and we’ll readily have access to it. This enables us to set references to the components, register persistent UnityEvents, and anything else we need to do to set up the object. By combining the RequireComponent attribute and the Reset method, we can fully set up the object by adding a single component.

Initial setup

Hack 8: Share data in unrelated Prefabs

The main drawback of the method shown above is that, if we decide to change a value, we will need to change it for every object manually. And if all the setup is done through code, it becomes difficult for designers to modify it.

In the previous article, we looked at how to use AssetPostprocessor for adding dependencies and modifying objects at import time. Now let’s use this to enforce some values in our Prefabs.

To make it easier for designers to modify those values, we will read the values from a Prefab. Doing so allows the designers to easily modify that Prefab to change the values for the entire project.

If you’re writing Editor code, you can copy the values from a component in an object to another by taking advantage of the Preset class.

Create a preset from the original component and apply it to the other component(s) like this:

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

As it stands, it will override all the values in the Prefab, but this most probably isn’t what we want it to do. Instead, copy only some values, while keeping the rest intact. To do this, use another override of the Preset.ApplyTo that takes a list of the properties it must apply. Of course, we could easily create a hardcoded list of the properties we want to override, which would work fine for most projects, but let’s see how to make this completely generic.

Basically, I created a base Prefab with all the components, and then created a Variant to use as a template. Then I decided what values to apply from the list of overrides in the Variant.

To get the overrides, use PrefabUtility.GetPropertyModifications. This provides you with all the overrides in the entire Prefab, so filter only the ones necessary to target this component. Something to keep in mind here is that the target of the modification is the component of the base Prefab – not the component of the Variant – so we need to get the reference to it by using 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());
    }
}

Now this will apply all overrides of the template to our Prefabs. The only detail left is that the template might be a Variant of a Variant, and we will want to apply the overrides from that Variant as well.

To do this, we only need to make this recursive:

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

Next, let’s find the template for our Prefabs. Ideally, we will want to use different templates for different types of objects. One efficient way of doing this is by placing the templates in the same folder as the objects we want to apply them to.

Look for an object named Template.prefab in the same folder as our Prefab. If we can’t find it, we will look in the parent folder recursively:

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

At this point, we have the ability to modify the template Prefab, and all the changes will be reflected in the Prefabs in that folder, even though they aren’t Variants of the template. In this example, I changed the default player color (the color used when the unit isn’t attached to any player). Notice how it updates all the objects:

The changed setup

Hack 9: Balance game data with ScriptableObjects and spreadsheets

When balancing games, all the stats you’ll need to adjust are spread across various components, stored in one Prefab or ScriptableObject for every character. This makes the process of adjusting details rather slow.

A common way to make balancing easier is by using spreadsheets. They can be very handy as they bring all the data together, and you can use formulas to automatically calculate some of the additional data. But entering this data into Unity manually can be painfully long.

That’s where the spreadsheets come in. They can be exported to simple formats like CSV (.csv) or TSV (.tsv), which is exactly what ScriptedImporters are for. Below is a screen capture of the stats for the units in the prototype:

Example of a spreadsheet | Tech from the Trenches
Example of a spreadsheet

The code for this is pretty simple: Create a ScriptableObject with all the stats for a unit, then you can read the file. For every row of the table, create an instance of the ScriptableObject and fill it with the data for that row.

Finally, add all the ScriptableObjects to the imported asset by using the context. We also need to add a main asset, which I just set to an empty TextAsset (as we don’t really use the main asset for anything here).

This works for both buildings and units, but you should check which one you’re importing as units will have many more stats.

[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();
    }
}

With this complete, there are now some ScriptableObjects that contain all of the data from the spreadsheet.

Imported data from the spreadsheet
Imported data from the spreadsheet

The generated ScriptableObjects are ready to be used in the game as needed. You can also use the PrefabPostprocessor that was set up earlier.

In the OnPostprocessPrefab method, we have the capacity to load this asset and use its data to fill the parameters of the components automatically. Even more, if you set a dependency to this data asset, the Prefabs will be reimported every time you modify the data, keeping everything up to date automatically.

How to modify the speed of the skeletons in the TSV, which they pick up immediately

Hack 10: Speed up iteration in the Editor

When trying to create awesome levels, it’s crucial to be able to change and test things quickly, making small adjustments and trying again. That’s why fast iteration times and reducing the steps needed to start testing are so important.

One of the first things that we think of when it comes to iteration times in Unity is the Domain Reload. The Domain Reload is relevant in two key situations: after compiling code in order to load the new dynamically linked libraries (DLLs), and when entering and exiting Play Mode. Domain Reload that comes with compiling can’t be avoided, but you do have the option of disabling reloads related to Play Mode in Project Settings > Editor > Enter Play Mode Settings.

Disabling the Domain Reload when entering Play Mode can cause some issues if your code isn’t prepared for it, with the most usual issue being that static variables aren’t reset after playing. If your code can work with this disabled, go for it. For this prototype, Domain Reload is disabled, so you can enter Play Mode almost instantaneously.

Hack 11: Autogenerate data

A separate issue with iteration times has to do with recalculating data that is required in order to play. This often involves selecting some components and clicking on buttons to trigger the recalculations. For example, in this prototype, there is a TeamController for each team within the scene. This controller has a list of all the enemy buildings so that it can send the units to attack them. In order to fill this data automatically, use the IProcessSceneWithReport interface. This interface is called for the scenes on two different occasions: during builds and when loading a scene in Play Mode. With it comes the opportunity to create, destroy, and modify any object you want. Note, however, that these changes will only affect Builds and Play Mode.

It is in this callback that the controllers are created and the list of buildings is set. Thanks to this, there is no need to do anything manually. The controllers with an updated list of buildings will be there when play starts, and the list will be updated with the changes we’ve made.

For the prototype, a utility method was set up that allows you to get all the instances of a component in a scene. You can use this to get all the buildings:

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

The rest of the process is somewhat trivial: Get all the buildings, get all the teams that the buildings belong to, and create a controller for every team with a list of enemy buildings.

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);
        }
    }
}
Iteration simplified

Hack 12: Work across multiple scenes

Besides the scene being edited, you also need to load other scenes in order to play (i.e., a scene with the managers, with the UI, etc.) This can take up valuable time. In the case of the prototype, the Canvas with the healthbars is in a different scene called InGameUI.

An effective way of working with this is by adding a component to the scene with a list of the scenes that need to be loaded along with it. If you load those scenes synchronously in the Awake method, the scene will be loaded and all of its Awake methods will be invoked at that point. So by the time the Start method is called, you can be sure that all the scenes are loaded and initialized, which gives you access to the data in them, such as manager singletons.

Remember that you might have some of the scenes open when you enter Play Mode, so it’s important to check whether the scene is already loaded before loading it:

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

Wrapping up

Throughout parts one and two of this article, I’ve shown you how to leverage some of the lesser known features that Unity has to offer. Everything outlined is just a fraction of what can be done, but I hope that you’ll find these hacks useful for your next project, or – at the very least – interesting.

The assets used to create the prototype can be found for free in the Asset Store:

If you’d like to discuss this two-parter, or share your ideas after reading it, head on over to our Scripting forum. I’m signing off for now but you can still connect with me on Twitter at @CaballolD. Be sure to stay tuned for future technical blogs from other Unity developers as part of the ongoing Tech from the Trenches series.

November 8, 2022 in Technology | 15 min. read
Related Posts