Unity を検索

時間を節約するための高度なエディタースクリプティングハック(その 2)

2022年11月8日 カテゴリ: テクノロジー | 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

お久しぶりです。第 2 回の記事をお送りします!高度なエディタースクリプティングハックに関する第 1 回の記事をまだお読みでない方は、ぜひこちらからお読みください。この連載記事では、ワークフローを改善し、次のプロジェクトを前回よりもスムーズに進められるようにするための、エディターにまつわる上級者向けのヒントを 2 回に分けてご紹介しています。

紹介されているハックはそれぞれ私が作った、ユニットを集めてできたチームが自動的に敵の建物や他のユニットを攻撃するリアルタイムストラテジーゲーム(RTS)に似たデモのプロトタイプに基づいています。復習のために、最初にビルドしたプロトタイプを振り返りましょう。

最初のビルド

前回は、プロジェクトにアートアセットをインポートして設定する方法のベストプラクティスを紹介しました。今度はできるだけ時間を節約しつつ、それらのアセットをゲーム内で使ってみましょう。

まずゲームの要素を紐解いてみましょう。ゲームの要素を設定する際、次のようなシナリオに遭遇する場合がよくあります。

一方のシナリオは、アートチームから提供されたプレハブがある場合です。それは FBX インポーターで生成されたプレハブだったり、適切なマテリアルやアニメーション、ヒエラルキーへの小道具の追加など、慎重に設定されたプレハブだったりします。このプレハブをゲーム内で使うには、プレハブバリアントを作成し、ゲームプレイに関連するすべてのコンポーネントをそこに追加するという方法があります。こうすることで、アートチームはプレハブを修正・更新することができ、すべての変更がゲームに即座に反映されます。この方法は、いくつかのコンポーネントと簡単な設定だけで済むアイテムであれば有効ですが、毎回複雑なものを一から設定する必要がある場合は、多くの追加作業が発生することになります。

もう一方のシナリオは、「車のプレハブ」や「似たような敵のプレハブ」のように、似た値を持つ同じ部品で構成されたアイテムが多数ある場合もあります。これはすべて、同じベースとなるプレハブのバリアントにして問題ありません。つまり、プレハブのアート設定(メッシュやそのマテリアルの設定など)が簡単な場合には、この方法が理想的ということです。

次に、ゲームプレイコンポーネントのセットアップを簡素化し、アートプレハブにそれらのコンポーネントを素早く追加して、ゲーム内で直接使う方法について説明します。

ハック 7:最初からコンポーネントをセットアップする

ゲーム内の複雑な要素について私が見てきた最も一般的な組み立ては、オブジェクトと通信するためのインターフェースとして動作する「メイン」コンポーネント(「Enemy(敵)」「Pickup(ピックアップ)」「Door(ドア)」など)と、機能そのものを実装する一連の小さな再利用可能コンポーネント(「Selectable(選択可能)」「CharacterMovement(キャラクターの動き)」「UnitHealth(ユニットの体力)」など)、それにレンダラー、コライダーなどの Unity のビルトインコンポーネントを持っているというものでした。

一部のコンポーネントは、他のコンポーネントに依存して動作しています。例えば、キャラクターの移動に NavMesh エージェントが必要になることがあります。こうした依存関係をすべて定義できるように、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 を追加しました。

さらに、基本クラスのユニット(建物と共用)には、Health コンポーネントなど、このクラスが継承する他の RequireComponent 属性があります。これを使えば、ゲームオブジェクトに Soldier コンポーネントを追加するだけで、他のコンポーネントはすべて自動的に追加されます。コンポーネントに対して新たに RequireComponent 属性を追加すると、Unity は既存のゲームオブジェクトをすべて新しいコンポーネントで更新するので、既存のオブジェクトを拡張しやすくなります。

RequireComponent には他にも目立たない利点があります。「コンポーネント B」を必要とする「コンポーネント A」がある場合、ゲームオブジェクトに A を追加すると B も追加されるようになるだけでなく、実際には B が A よりに追加されるようになります。つまり、コンポーネント A に対して Reset メソッドが呼ばれた時にはすでにコンポーネント B が存在しているので、コンポーネント B にアクセスする準備が整っているということです。これにより、コンポーネントへの参照を設定し、永続的な UnityEvent を登録し、その他オブジェクトを設定するために必要なことを行うことができます。RequireComponent 属性と Reset メソッドを組み合わせることで、1 つのコンポーネントを追加するだけでオブジェクトを完全に設定することができます。

初期設定

ハック 8:無関係なプレハブでデータを共有する

上記の方法の主な欠点は、値を変更することにした場合、すべてのオブジェクトに対して手動で変更する必要があることです。また、すべての設定をコードで行うと、デザイナーが修正することが難しくなります。

前回は、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());
    }
}

これで、テンプレートのすべてのオーバーライドがプレハブに適用されます。あと細かい点が 1 つ残っています。それはテンプレートがバリアントのバリアントである可能性があり、そのバリアントからのオーバーライドを同様に適用したいことです。

そのためには、これを再帰にすればよいのです。

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

この時点でテンプレートのプレハブを修正することができるようになり、テンプレートのバリアントでなくても、すべての変更はそのフォルダーのプレハブに反映されるようになります。この例では、デフォルトのプレイヤーの色(ユニットがどのプレイヤーにも属していないときに使われる色)を変更しました。すべてのオブジェクトが更新されることに注目してください。

変更後の設定

ハック 9:ScriptableObject とスプレッドシートを使ったゲームデータのバランス調整

ゲームのバランスをとる時、調整する必要のあるすべてのステータスはさまざまなコンポーネントに散らばっており、それらは各キャラクターに対して 1 つのプレハブまたは 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 を変更してスケルトンの速度を修正しているところ。変更がすぐに反映される。

ハック 10:エディターでのイテレーションを高速化する

素晴らしいステージを作ろうとする場合、素早く変更し、テストし、細かな調整を行い、再び試せることが肝になります。そのため、イテレーション時間の短縮や、テスト開始までに必要なステップを減らすことが非常に重要です。

Unity でイテレーション時間と言えば、まず思いつくのがドメインの再ロードです。ドメイン再ロードは、新しいダイナミックリンクライブラリ(DLL)を読み込むためにコードをコンパイルした後と、再生モードを出入りする時の、2 つの重要な場面に関連しています。コンパイル時に発生するドメイン再ロードは回避できませんが、再生モードに入った時の再ロードについては、Project Settings > Editor > Enter Play Mode Settings にこれを無効にするオプションが用意されています。

再生モードに入る時のドメイン再ロードを無効にすると、コードがそれに備えたものになっていない場合にいくつかの問題が発生します。最もよくある問題は、プレイ後に静的変数がリセットされないことです。もしドメイン再ロードが無効化された状態でコードが動作するのであれば、そのまま再ロードを利用してください。今回のプロトタイプではドメイン再ロードを無効にしているので、ほぼ瞬時に再生モードに入ることができます。

ハック 11:データの自動生成

イテレーション時間とは別の問題として、再生に必要なデータの再計算があります。この場合、いくつかのコンポーネントを選択し、ボタンをクリックして再計算を開始することがよくあります。例えばこのプロトタイプでは、シーン内の各チームに対して TeamController が用意されています。このコントローラーは、敵の建物のリストを持っていて、それを攻撃するユニットを送ることができます。このデータを自動的に埋めるには、IProcessSceneWithReport インターフェースを使います。このインターフェースは、ビルド時と再生モードでシーンを読み込む時の 2 つの場面で、シーンに対して呼び出されます。そのため、あらゆるオブジェクトを作成し、破壊し、修正することができます。ただし、この変更はビルドと再生モードにのみ影響します。

このコールバックで、コントローラーが作成され、建物のリストが設定されます。このため、手動で何かをする必要はありません。建物のリストが更新されたコントローラーは、プレイ開始時にそこにあり、私たちが行った変更が反映されてリストが更新されることになります。

プロトタイプでは、シーン内のコンポーネントの全インスタンスを取得できるユーティリティメソッドを設定しました。これを使ってすべての建物を取得することができます。

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);
        }
    }
}
イテレーションの簡略化

ハック 12:複数のシーンをまたいで作業する

編集中のシーン以外にも、再生するために他のシーンを読み込む必要があります(マネージャーのあるシーン、UI のあるシーンなど)。これを行うために貴重な時間を使うことになります。プロトタイプの場合、体力バーのある Canvas は、InGameUI という別のシーンにあります。

この作業を行う効率的な方法は、シーンにコンポーネントを追加し、そのコンポーネントと一緒に読み込む必要があるシーンのリストを追加することです。Awake メソッドでそれらのシーンを同期的に読み込むと、その時点でシーンが読み込まれ、シーンの Awake メソッドがすべて呼び出されることになります。そのため、Start メソッドが呼ばれるまでにすべてのシーンが読み込まれ、初期化されている状態を確保でき、マネージャーシングルトンなどについて、その中のデータにアクセスできるようになります。

再生モードに入った時にいくつかのシーンが開かれている可能性があるので、シーンを読み込む前に、すでに読み込まれているかを確認することが重要であることを忘れないでください。

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

まとめ

この連載記事の第 1 回と第 2 回を通して、Unity が提供するあまり知られていない機能を活用する方法を紹介しました。ご紹介したのはほんの一部ですが、今回ご紹介したハックを皆さんの次のプロジェクトに役立てていただければ、少なくとも興味を持っていただければ幸いです。

プロトタイプの作成に使用したアセットは、アセットストアで無料で入手することができます。

この 2 回にわたる記事について議論したり、記事を読んだ後の感想を共有したりしたい場合は、私たちのスクリプティングフォーラムをご覧ください。私の記事は一旦終了となりますが、Twitter で私のアカウント @CaballolD ともつながっていただければ幸いです。現在連載中の Tech from the Trenches シリーズで、他の Unity 開発者が投稿する今後の技術ブログにもぜひご期待ください。

2022年11月8日 カテゴリ: テクノロジー | 15 分 で読めます
関連する投稿