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부로 구성된 이 포스팅에서는 새로운 프로젝트의 워크플로를 개선하는 고급 에디터 팁을 소개합니다.

각 팁은 제가 만든 프로토타입을 기반으로 정리했습니다. 실시간 전략 게임과 유사한 해당 프로토타입에서는 플레이어 팀의 유닛이 적의 건물과 상대 유닛을 자동으로 공격합니다. 1부의 내용을 다시 떠올려 보면, 초기 빌드 프로토타입은 다음과 같습니다.

초기 빌드

이전 포스팅에서는 프로젝트에서 아트 에셋을 임포트하고 설정하는 방법에 관한 베스트 프랙티스를 알려드렸습니다. 이제 게임에서 시간을 최대한 절약하며 임포트한 에셋을 사용해 보겠습니다.

우선 게임의 요소부터 살펴봅시다. 게임의 요소를 설정할 때는 일반적으로 다음과 같은 상황이 주어집니다.

먼저 아트 팀이 제작한 프리팹이 준비되어 있습니다. 이는 FBX 임포터에서 생성되었거나, 적절한 머티리얼 및 애니메이션 전체를 사용하여 미리 설정된 프리팹이며 계층 구조에 프랍이 추가됩니다. 게임 내에서 이 프리팹을 사용하려면 프리팹 배리언트를 생성하고 게임플레이와 관련된 모든 컴포넌트를 추가해야 합니다. 이렇게 하면 아트 팀에서 프리팹을 수정하거나 업데이트할 때 모든 변경 사항이 게임에 즉시 반영됩니다. 하지만 이 방식은 간단한 설정을 갖춘 컴포넌트 몇 개만 필요한 경우에 효과적이며, 매번 처음부터 복잡하게 설정해야 하는 경우라면 많은 추가 작업이 필요할 수 있습니다.

반면, 다수의 오브젝트에 Car 프리팹 또는 유사한 적의 프리팹과 같이 비슷한 값을 가진 동일한 컴포넌트가 존재할 수 있습니다. 이 모두는 동일한 기본 프리팹의 배리언트라고 봐도 무방합니다. 하지만 이 방법은 간단하게 프리팹 아트를 설정할 수 있는 경우(즉, 메시 및 그 머티리얼 설정)에 적합합니다.

다음으로, 게임플레이 컴포넌트 설정을 간소화하여 아트 프리팹에 빠르게 추가하고 게임에서 바로 사용할 수 있는 방법을 살펴보겠습니다.

팁 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가 이미 존재하여 쉽게 액세스할 수 있습니다. 이 방법으로 컴포넌트에 대한 레퍼런스와 영구적인 UnityEvents를 설정할 수 있으며 그 외 오브젝트를 설정하는 데 필요한 모든 것을 처리할 수 있습니다. RequireComponent 속성과 Reset 메서드를 결합함으로써 하나의 컴포넌트만 추가하여 오브젝트를 완전히 설정할 수 있습니다.

초기 설정

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

이제 템플릿의 모든 오버라이드가 프리팹에 적용됩니다. 또한 템플릿이 배리언트의 배리언트일 수도 있으니, 해당 배리언트에서도 오버라이드를 적용해야 합니다.

이렇게 하려면 다음과 같이 재귀적으로 만들면 됩니다.

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: 스크립터블 오브젝트 및 스프레드시트로 게임 데이터 밸런싱

게임을 밸런싱할 때 조정해야 할 스탯은 다양한 컴포넌트에 흩어져 있고 각 캐릭터의 프리팹 또는 스크립터블 오브젝트에 저장되어 있습니다. 이는 세부 사항을 조정하는 과정을 다소 늦춥니다.

더 쉽게 균형을 달성하는 일반적인 방법은 스프레드시트를 사용하는 것입니다. 스프레드시트를 사용하면 모든 데이터를 모을 수 있으므로 매우 간편하며, 공식을 사용하여 추가 데이터를 자동으로 계산할 수 있습니다. 하지만 이 데이터를 Unity에 수동으로 입력하려면 지나치게 많은 시간이 걸릴 수 있습니다.

이때 스프레드시트를 활용할 수 있습니다. CSV (.csv) 또는 TSV (.tsv)와 같은 단순한 형식으로 익스포트할 수 있으며, ScriptedImporters에서 바로 이 형식을 사용합니다. 다음은 프로토타입의 유닛에 대한 스탯의 화면 캡처입니다.

Example of a spreadsheet | Tech from the Trenches
스프레드시트 예시

아주 간단한 코드로 가능합니다. 유닛의 모든 스탯으로 스크립터블 오브젝트를 생성하면 파일을 읽을 수 있습니다. 표의 각 행에서 스크립터블 오브젝트의 인스턴스를 생성하여 해당 행의 데이터로 채웁니다.

마지막으로 컨텍스트를 사용하여 모든 스크립터블 오브젝트를 임포트된 에셋에 추가합니다. 메인 에셋도 추가해야 하지만, 여기서는 메인 에셋을 어디에도 사용하지 않으므로 빈 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();
    }
}

이 작업이 완료되면 이제 스프레드시트의 모든 데이터가 포함된 스크립터블 오브젝트들이 생깁니다.

Imported data from the spreadsheet
스프레드시트에서 임포트된 데이터

생성된 스크립터블 오브젝트는 필요에 따라 게임에서 사용할 수 있습니다. 앞서 설정한 PrefabPostprocessor도 사용할 수도 있습니다.

OnPostprocessPrefab 메서드에서는 이 에셋을 로드하고 그 데이터를 사용하여 컴포넌트의 파라미터를 자동으로 채울 수 있습니다. 아울러, 이 데이터 에셋에 종속 관계를 설정하면 데이터를 수정할 때마다 프리팹이 다시 임포트되어 모든 것이 자동으로 최신 상태로 유지됩니다.

TSV에서 스켈레톤의 이동 속도를 즉시 수정하는 방법

팁 10: 에디터에서 반복 작업 속도 향상

멋진 레벨을 제작하려면 기능과 값 등을 미세 조정하며 빠르게 테스트할 수 있어야 합니다. 따라서 반복 작업 시간을 줄이고 테스트에 필요한 단계를 단축해야 합니다.

Unity에서 반복 시간과 관련해 가장 먼저 생각해야 할 점은 도메인 리로드입니다. 도메인 리로드는 두 가지 주요 상황과 관련이 있습니다. 하나는 새로운 동적 연결 라이브러리(DLL)를 로드하기 위한 코드 컴파일 이후의 시점이고 다른 하나는 플레이 모드 진입 및 종료 시점입니다. 컴파일로 인한 도메인 리로드는 피할 수 없지만, 플레이 모드와 관련된 리로드는 Project Settings > Editor > Enter Play Mode Settings에서 비활성화할 수 있습니다.

플레이 모드 진입 시 도메인 리로드를 비활성화하는 경우 관련 코드가 준비되어 있지 않다면 문제가 발생할 수 있으며, 가장 흔하게는 플레이 이후 정적 변수가 재설정되지 않는 문제가 있습니다. 코드가 작동한다면 이 경우에 리로드를 비활성해도 괜찮습니다. 이 프로토타입에서는 도메인 리로드가 비활성화되어 있으므로 플레이 모드에 거의 즉시 진입할 수 있습니다.

팁 11: 데이터 자동 생성

반복 작업에 소요되는 시간에 관한 또 다른 문제는 플레이에 필요한 데이터를 재계산하는 것과 관련이 있습니다. 이 작업을 하려면 컴포넌트를 선택하고 버튼을 클릭하여 재계산을 트리거해야 하는 경우가 많습니다. 예를 들어 이 프로토타입의 경우 씬 내의 각 팀에 대한 TeamController가 있습니다. 이 컨트롤러에는 유닛을 보내 공격할 수 있는 모든 적 건물의 목록이 있습니다. 이 데이터를 자동으로 채우기 위해 IProcessSceneWithReport 인터페이스를 사용합니다. 이 인터페이스는 두 가지 경우, 즉 빌드 중에 그리고 플레이 모드에서 씬을 로드할 때 씬에 호출됩니다. 이를 통해 원하는 오브젝트를 생성하고 파괴하고 수정할 수 있습니다. 하지만 이러한 변경 사항은 빌드와 플레이 모드에만 영향을 미칩니다.

이 콜백에서 컨트롤러가 생성되고 건물 목록이 설정되므로 무엇이든 수동으로 작업할 필요가 없습니다. 플레이를 시작하면 업데이트된 건물 목록이 있는 컨트롤러가 생기며, 이 목록은 변경 사항에 따라 업데이트됩니다.

프로토타입의 경우 씬에서 컴포넌트의 모든 인스턴스를 얻을 수 있는 유틸리티 메서드가 설정되었습니다. 이를 사용해 모든 건물을 가져올 수 있습니다.

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가 있는 씬 등 다른 여러 씬들도 로드해야 하며 이로 인해 귀중한 시간이 허비될 수 있습니다. 프로토타입의 경우에는 헬스바가 있는 캔버스가 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 시리즈의 향후 포스팅에도 많은 관심 부탁드립니다.

2022년 11월 8일 테크놀로지 | 15 분 소요
관련 게시물