
여러 몬스터들을 구현하기 전에, 대량으로 생산하기 위해서 해야할 작업이 있습니다.
하나의 몬스터를 만들기 위한 작업 순서는 다음과 같습니다.
1, 2 번은 각 캐릭터마다 특징이 다르기 때문에 직접 만들어야 하지만, 3번은 직접 할 경우 여러가지 문제점이 있습니다.
위와 같은 문제점들 때문에 Unity의 기능인 Custom Editor를 제작해서 3번 과정을 자동화시키도록 하겠습니다.
우선 어떻게 만들어야 하는지 Input과 Output을 정리해보겠습니다.
일단 입력 받는 부분부터 만들어보겠습니다. 우선 Monobehavior 대신 EditorWindow를 상속받습니다.
OnGUI 함수 내에 입력받는 부분부터 만들어보겠습니다.
public enum AnimDir { SE = 0, SW, NE, NW}
public enum AnimName { Attack = 0, DieSoul, Dmg, Idle, Walk}
public class SpriteAnimatorEditor : EditorWindow
{
private string characterName = "NewAnimator";
private string storePath;
private Texture2D[] spriteSheets = new Texture2D[5];
private AnimationClip[,] animationClips = new AnimationClip[5, 4];
private int widthPx = 32;
private int heightPx = 32;
private AnimatorOverrideController overrideController;
private UnityEditor.Animations.AnimatorController overriddenConroller;
private AnimationClipOverrides clipOverrides;
[MenuItem("MyEditor/Sprite Animator Editor")]
public static void ShowWindow()
{
GetWindow<SpriteAnimatorEditor>("Sprite Animator Editor");
}
...
}
상속 및 변수 선언부 입니다.
5개의 애니메이션 스프라이트와 각 스프라이트 당 최대 4개의 방향을 가지므로, AnimationClip[5, 4] 를 통해 애니메이션 클립들을 저장해둡니다.
아래에 있는 [MenuItem(...)] 은 화면 상단 패널을 통해 에디터를 사용할 수 있게 합니다.

private void OnGUI()
{
GUILayout.Label("Animator Settings", EditorStyles.boldLabel);
GUILayout.Space(20);
characterName = EditorGUILayout.TextField("Character Name", characterName);
overriddenConroller = (UnityEditor.Animations.AnimatorController)EditorGUILayout.ObjectField("Overridden Controller", overriddenConroller, typeof(UnityEditor.Animations.AnimatorController), false);
GUILayout.Space(20);
if (GUILayout.Button("Set Store path"))
{
storePath = EditorUtility.OpenFolderPanel("Store path", "", "");
storePath = storePath.Substring(storePath.IndexOf('A'));
}
GUILayout.Label("Store Path : " + storePath);
GUILayout.Space(20);
spriteSheets[0] = (Texture2D)EditorGUILayout.ObjectField("Attack Sprite", spriteSheets[0], typeof(Texture2D), false);
spriteSheets[1] = (Texture2D)EditorGUILayout.ObjectField("Die Sprite", spriteSheets[1], typeof(Texture2D), false);
spriteSheets[2] = (Texture2D)EditorGUILayout.ObjectField("Dmg Sprite", spriteSheets[2], typeof(Texture2D), false);
spriteSheets[3] = (Texture2D)EditorGUILayout.ObjectField("Idle Sprite", spriteSheets[3], typeof(Texture2D), false);
spriteSheets[4] = (Texture2D)EditorGUILayout.ObjectField("Walk Sprite", spriteSheets[4], typeof(Texture2D), false);
if (GUILayout.Button("Create Animator"))
{
CreateAnimator();
}
}
에디터 창의 입력 및 레이아웃을 조정하는 함수입니다.
맨 위부터 캐릭터 이름, 덮어 쓸 컨트롤러, 애니메이터를 저장할 경로 및 사용할 스프라이트들을 받습니다.

OnGUI를 통해 생성한 에디터 화면은 다음과 같습니다.
특히, Path 부분은 OpenFolderPanel 로 선택 시 절대경로를 반환하고, 에셋에 접근하기 위해서는 Asset부터 시작하는 상대경로가 필요합니다. Substring과 IndexOf 함수로 절대경로로 변환합니다.
애니메이션 스프라이트들은 자르기 위해서 Texture2D 상태로 받습니다.
애니메이션 클립 제작 때는 자른 에셋들을 로드해서 사용합니다.
private void CreateAnimator()
{
// 예외처리. 시트는 전부 지정되어있어야 합니다.
for (int i = 0; i < spriteSheets.Length; i++)
{
if (spriteSheets[i] == null)
{
Debug.LogError("Sprite Sheet is null.");
return;
}
}
for (int i = 0; i < Enum.GetValues(typeof(AnimName)).Length; i++)
{
SliceSpriteSheet(spriteSheets[i], i);
}
overrideController = new AnimatorOverrideController(overriddenConroller);
clipOverrides = new AnimationClipOverrides(overrideController.overridesCount);
overrideController.GetOverrides(clipOverrides);
for (int i = 0; i < Enum.GetValues(typeof(AnimName)).Length; i++)
{
if (i == 1)
{
clipOverrides["Human_DieSoul"] = animationClips[1, 0];
}
else
{
for (int j = 0; j < 4; j++)
{
clipOverrides["Human_" + Enum.GetName(typeof(AnimName), i) + "_" + Enum.GetName(typeof(AnimDir), j)] = animationClips[i, j];
Debug.Log(animationClips[i, j].name);
}
}
}
overrideController.ApplyOverrides(clipOverrides);
AssetDatabase.CreateAsset(overrideController, storePath + "/" + characterName + "_Animator.overrideController");
AssetDatabase.SaveAssets();
Debug.Log("Animator and Override Controller created successfully.");
}
메인 기능을 구현한 함수입니다.
각 스프라이트가 null 인지 확인한 후에 자르고 animationClips 에 저장해 둡니다.
AnimatorOverrideController 를 만든 후에 각 클립에 Override 해줍니다.
Override 하는 부분은 이 문서의 도움을 받았습니다. 참고하시기 바랍니다.
private void SliceSpriteSheet(Texture2D texture, int sheetIdx)
{
List<SpriteMetaData> mData = new List<SpriteMetaData>();
Rect[] rects = InternalSpriteUtility.GenerateGridSpriteRectangles(
texture, Vector2.zero, new Vector2(widthPx, heightPx), Vector2.zero);
string path = AssetDatabase.GetAssetPath(texture);
TextureImporter ti = AssetImporter.GetAtPath(path) as TextureImporter;
//
// TextureImporter Settings..
for (int i = 0; i < rects.Length; i++)
{
SpriteMetaData smd = new SpriteMetaData();
smd.rect = rects[i];
smd.alignment = (int)SpriteAlignment.Custom;
smd.pivot = new Vector2(0.5f, 0.4f);
smd.name = texture.name + "_" + i;
mData.Add(smd);
}
ti.spritesheet = mData.ToArray();
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
함수가 너무 길어서 나눠서 보겠습니다.
잘린 스프라이트들의 세팅을 위해 List<SpriteMetaData> 와 자를 틀 정보를 저장할 Rect[] 를 선언합니다.
texture의 위치를 저장하는 path 와 ( 위에서 봤던 spritePath 와는 별개의 경로입니다. )
스프라이트의 설정 및 저장을 위해 TextureImporter 를 선언해줍니다.
그 후에 반복문을 통해서 하나씩 잘라주고 설정 후 mData 에 저장합니다.
이 정보는 ti.spritesheet 에 저장하고 임포트해야 실제 Project 창에서 확인하실 수 있습니다.

UnityEngine.Object[] assets = AssetDatabase.LoadAllAssetsAtPath(path);
Array.Sort(assets, (a, b)=>
{
var tmp1 = a.name.Split('_');
var tmp2 = b.name.Split('_');
int part1, part2;
bool check1 = int.TryParse(System.IO.Path.GetFileNameWithoutExtension(tmp1[tmp1.Length - 1]), out part1);
bool check2 = int.TryParse(System.IO.Path.GetFileNameWithoutExtension(tmp2[tmp2.Length - 1]), out part2);
if (!check1) part1 = -1;
if (!check2) part2 = -1;
if (part1 == part2) return 0;
else if (part1 < part2) return -1;
else return 1;
});
방금 전에 저장했던 스프라이트들을 Object[] 로 받아옵니다.
해당 에셋들을 이름별로 정렬해줍니다.
끝의 숫자만 비교하기 위해 split 및 GetFileNameWithoutExtension 을 사용했습니다.
List<Sprite> sprites = new List<Sprite>();
// column 개수. 애니메이션 당 들어가야 할 이미지 개수를 셉니다.
int animFrameCount = texture.width / widthPx;
int dir = 0;
int cnt = 0;
for (int i = 1; i < assets.Length; i++)
{
if (assets[i] is Sprite)
{
sprites.Add(assets[i] as Sprite);
cnt++;
if (cnt % animFrameCount == 0)
{
CreateAnimationClip(sprites, sheetIdx, dir++);
sprites.Clear();
}
}
}
return;
}
1차원 배열로 저장되어있는 스프라이트들을 각 row 마다 한 개의 애니메이션으로 만들어야합니다.
따라서, column 개수를 구하고 List<Sprite> 에 넣어서 CreateAnimationClip 함수에 보냅니다.
private void CreateAnimationClip(List<Sprite> sprites, int sheetIdx, int dir)
{
AnimationClip clip = new AnimationClip();
string clipName = "";
clipName = characterName + "_" + Enum.GetName(typeof(AnimName), sheetIdx) + "_" + (sheetIdx != 1 ? Enum.GetName(typeof(AnimDir), dir) : "");
EditorCurveBinding curveBinding = new EditorCurveBinding();
curveBinding.type = typeof(SpriteRenderer);
curveBinding.path = "";
curveBinding.propertyName = "m_Sprite";
ObjectReferenceKeyframe[] keyFrames = new ObjectReferenceKeyframe[sprites.Count+1];
for (int i = 0; i < sprites.Count; i++)
{
keyFrames[i] = new ObjectReferenceKeyframe();
keyFrames[i].time = i / 6f;
keyFrames[i].value = sprites[i];
}
keyFrames[keyFrames.Length-1] = new ObjectReferenceKeyframe();
keyFrames[keyFrames.Length - 1].time = (keyFrames.Length - 1) / 6f;
keyFrames[keyFrames.Length - 1].value = sprites[0];
AnimationUtility.SetObjectReferenceCurve(clip, curveBinding, keyFrames);
if (sheetIdx == (int)AnimName.Idle || sheetIdx == (int)AnimName.Walk)
{
AnimationClipSettings setting = AnimationUtility.GetAnimationClipSettings(clip);
setting.loopTime = true;
AnimationUtility.SetAnimationClipSettings(clip, setting);
}
animationClips[sheetIdx, (sheetIdx == (int)AnimName.DieSoul) ? 0 : dir] = clip;
// 에셋으로 생성 후 저장
AssetDatabase.CreateAsset(clip, storePath + "/" + clipName + ".anim");
AssetDatabase.SaveAssets();
return;
}
새로운 클립을 만들고 설정을 시작합니다.
각 프레임에 스프라이트를 넣기 위해서는 EditorCurveBinding을 사용합니다.
설정 후에는 SetObjectReferenceCurve 로 설정을 저장하고
애니메이션 설정이 필요한 경우 애니메이션 설정도 설정 및 저장합니다.
이렇게 만든 클립을 위에서 선언했던 animationClips 에 대입하고 AssetDatabase 로 저장하면 프로젝트 창에서 볼 수 있습니다.
위의 에디터 창에서 Create Animator 버튼을 눌러 잘 실행되는지 확인해보겠습니다.

스프라이트가 16개로 잘 쪼개졌고, pivot 및 이름도 제대로 작성되었습니다.

애니메이션도 각 방향에 맞게 만들어졌고, 따로 애니메이션 설정한 부분 (Loop TIme) 도 잘 저장되어 있습니다.
생성된 애니메이터를 사용해서 적을 생성해보겠습니다.


이전과 다르지않게 정상적으로 작동하는걸 볼 수 있습니다.
에디터를 처음 만들다보니 모르는 부분을 검색하느라 시간이 생각보다 오래걸렸습니다.
그래도 30분~1시간 정도 걸리던 작업을 클릭 한 번으로 해결할 수 있게 되었습니다.
앞으로도 반복적인 작업은 커스텀 에디터로 만들어서 전체적인 개발 속도를 높일 수 있도록 하겠습니다.
https://forum.unity.com/threads/solved-slicing-a-sprite-through-script-on-importing.701294/
https://docs.unity3d.com/ScriptReference/EditorWindow.OnGUI.html
https://docs.unity3d.com/ScriptReference/AnimatorOverrideController.ApplyOverrides.html