Files
TopDownRPG/Assets/02_Scripts/Editor/RedCharacterSetup.cs
2026-09-24 00:30:35 +09:00

425 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.Animations;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
/// <summary>
/// Red 캐릭터의 애니메이션 클립 / 애니메이터 컨트롤러 / 플레이어 프리팹을 한 번에 생성한다.
/// 몸(Body)과 무기(Weapon)는 각각 자식 SpriteRenderer이고, 클립 하나가 두 경로를 동시에 구동한다.
/// </summary>
public static class RedCharacterSetup
{
private const string Root = "Assets/03_Models/Characters/Red";
private const string CharDir = Root + "/Character";
private const string SwordDir = Root + "/Weapon";
private const string AnimDir = Root + "/Animations";
private const string ControllerPath = AnimDir + "/RedAnimController.controller";
//프레임레이트. 공격과 구르기는 조금 빠르게
private const int MoveFps = 8;
private const int RollFps = 12;
private const int SlashFps = 12;
//이동/구르기는 8방향, 공격은 대각 4방향 시트만 존재한다
private static readonly string[] Dirs8 =
{
"Down", "DownLeft", "DownRight", "Left", "Right", "Up", "UpLeft", "UpRight"
};
private static readonly string[] Dirs4 =
{
"DownLeft", "DownRight", "UpLeft", "UpRight"
};
//블렌드 트리에서 각 방향 클립이 놓일 좌표
private static readonly Dictionary<string, Vector2> DirVectors = new Dictionary<string, Vector2>
{
{ "Down", new Vector2( 0f, -1f) },
{ "Up", new Vector2( 0f, 1f) },
{ "Left", new Vector2(-1f, 0f) },
{ "Right", new Vector2( 1f, 0f) },
{ "DownLeft", new Vector2(-0.7071f, -0.7071f) },
{ "DownRight", new Vector2( 0.7071f, -0.7071f) },
{ "UpLeft", new Vector2(-0.7071f, 0.7071f) },
{ "UpRight", new Vector2( 0.7071f, 0.7071f) }
};
//애니메이션이 꽂히는 대상. path가 프리팹의 자식 이름과 정확히 일치해야 한다
private static EditorCurveBinding BodySprite =>
new EditorCurveBinding { path = "Body", type = typeof(SpriteRenderer), propertyName = "m_Sprite" };
private static EditorCurveBinding WeaponSprite =>
new EditorCurveBinding { path = "Weapon", type = typeof(SpriteRenderer), propertyName = "m_Sprite" };
private static EditorCurveBinding WeaponEnabled =>
EditorCurveBinding.FloatCurve("Weapon", typeof(SpriteRenderer), "m_Enabled");
[MenuItem("Tools/Red/애니메이션 셋업 다시 만들기")]
public static void Rebuild()
{
try
{
var clips = new Dictionary<string, AnimationClip>();
foreach (string dir in Dirs8)
{
Sprite[] walk = LoadSheet(CharDir + "/Character_" + dir + ".png");
Sprite[] roll = LoadSheet(CharDir + "/Character_Roll" + dir + ".png");
//대기: 걷기 시트의 첫 프레임만 사용
clips["Idle_" + dir] = BuildClip("Idle_" + dir, new[] { walk[0] }, null, MoveFps, true);
clips["Move_" + dir] = BuildClip("Move_" + dir, walk, null, MoveFps, true);
clips["Roll_" + dir] = BuildClip("Roll_" + dir, roll, null, RollFps, false);
}
foreach (string dir in Dirs4)
{
Sprite[] body = LoadSheet(CharDir + "/Character_Slash" + dir + ".png");
Sprite[] sword = LoadSheet(SwordDir + "/Sword_" + dir + ".png");
if (body.Length != sword.Length)
{
Debug.LogWarning("[RedSetup] Slash" + dir + ": 몸 " + body.Length + "프레임 / 검 "
+ sword.Length + "프레임. 짧은 쪽은 마지막 스프라이트를 유지한다.");
}
//몸과 검을 같은 클립 안에서 같이 구동한다
clips["Slash_" + dir] = BuildClip("Slash_" + dir, body, sword, SlashFps, false);
}
AnimatorController controller = BuildController(clips);
FixUpSceneObject(controller, LoadSheet(CharDir + "/Character_Down.png")[0]);
Debug.Log("[RedSetup] 클립 " + clips.Count + "개 + 컨트롤러 생성 완료.");
}
finally
{
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
}
/// <summary>스프라이트 시트를 잘린 순서대로 읽는다.</summary>
private static Sprite[] LoadSheet(string path)
{
Sprite[] sprites = AssetDatabase.LoadAllAssetsAtPath(path).OfType<Sprite>().ToArray();
if (sprites.Length == 0)
{
throw new InvalidOperationException(
"스프라이트가 없다: " + path + " (Sprite Mode가 Multiple인지, 슬라이스했는지 확인할 것)");
}
return sprites.OrderBy(TrailingIndex).ToArray();
}
//"Sword_DownLeft_3" -> 3
private static int TrailingIndex(Sprite sprite)
{
int underscore = sprite.name.LastIndexOf('_');
if (underscore >= 0 && int.TryParse(sprite.name.Substring(underscore + 1), out int index))
{
return index;
}
return 0;
}
/// <summary>
/// 클립 하나를 만든다. weapon이 null이면 해당 동작 내내 무기 렌더러를 꺼서
/// 직전 공격의 칼이 화면에 남아있지 않게 한다.
/// </summary>
private static AnimationClip BuildClip(string name, Sprite[] body, Sprite[] weapon, int fps, bool loop)
{
//CopySerialized가 이름까지 덮어쓰므로 여기서 반드시 지정한다
var clip = new AnimationClip { name = name, frameRate = fps };
SetSpriteCurve(clip, BodySprite, body, fps);
//길이는 몸과 검 중 긴 쪽을 따라간다
int frameCount = weapon == null ? body.Length : Mathf.Max(body.Length, weapon.Length);
float lastKeyTime = (frameCount - 1) / (float)fps;
if (weapon != null)
{
SetSpriteCurve(clip, WeaponSprite, weapon, fps);
AnimationUtility.SetEditorCurve(clip, WeaponEnabled, ConstantCurve(1f, lastKeyTime));
}
else
{
AnimationUtility.SetEditorCurve(clip, WeaponEnabled, ConstantCurve(0f, lastKeyTime));
}
AnimationClipSettings settings = AnimationUtility.GetAnimationClipSettings(clip);
settings.loopTime = loop;
AnimationUtility.SetAnimationClipSettings(clip, settings);
return SaveClip(clip, AnimDir + "/" + name + ".anim");
}
/// <summary>
/// 프레임을 fps 간격으로 찍는다. 마지막에 종료 키를 따로 넣지 않는다 —
/// Unity가 클립 길이를 "마지막 키 + 1프레임"으로 잡아주기 때문에,
/// 중복 키를 두면 마지막 프레임만 2배로 길어져서 루프가 끊긴다.
/// </summary>
private static void SetSpriteCurve(AnimationClip clip, EditorCurveBinding binding, Sprite[] frames, int fps)
{
var keys = new ObjectReferenceKeyframe[frames.Length];
for (int i = 0; i < frames.Length; i++)
{
keys[i] = new ObjectReferenceKeyframe { time = i / (float)fps, value = frames[i] };
}
AnimationUtility.SetObjectReferenceCurve(clip, binding, keys);
}
/// <summary>값이 일정한 커브. 스프라이트 커브의 마지막 키 시각까지만 깔아 클립 길이를 늘리지 않는다.</summary>
private static AnimationCurve ConstantCurve(float value, float lastKeyTime)
{
if (lastKeyTime <= 0f)
{
return new AnimationCurve(new Keyframe(0f, value));
}
return AnimationCurve.Constant(0f, lastKeyTime, value);
}
//이미 있으면 내용만 덮어써서 GUID를 유지한다
private static AnimationClip SaveClip(AnimationClip clip, string path)
{
var existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
if (existing != null)
{
EditorUtility.CopySerialized(clip, existing);
EditorUtility.SetDirty(existing);
return existing;
}
AssetDatabase.CreateAsset(clip, path);
return clip;
}
private static AnimatorController BuildController(Dictionary<string, AnimationClip> clips)
{
//씬의 Animator가 이 컨트롤러를 참조하므로 에셋을 지우지 않고 내용만 비운다 (GUID 유지)
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(ControllerPath);
if (controller == null)
{
controller = AnimatorController.CreateAnimatorControllerAtPath(ControllerPath);
}
else
{
ClearController(controller);
}
//MoveX/MoveY는 "마지막으로 바라본 방향"이다. 둘 다 0이면 블렌드 트리가 방향을 못 고르므로
//기본값을 아래쪽(0,-1)으로 둬서 시작하자마자 Idle_Down이 잡히게 한다
controller.AddParameter(new AnimatorControllerParameter
{
name = "MoveX", type = AnimatorControllerParameterType.Float, defaultFloat = 0f
});
controller.AddParameter(new AnimatorControllerParameter
{
name = "MoveY", type = AnimatorControllerParameterType.Float, defaultFloat = -1f
});
controller.AddParameter("Speed", AnimatorControllerParameterType.Float);
controller.AddParameter("Attack", AnimatorControllerParameterType.Trigger);
controller.AddParameter("Roll", AnimatorControllerParameterType.Trigger);
AnimatorState idle = AddDirectionalState(controller, "Idle", Dirs8, d => clips["Idle_" + d]);
AnimatorState move = AddDirectionalState(controller, "Move", Dirs8, d => clips["Move_" + d]);
AnimatorState roll = AddDirectionalState(controller, "Roll", Dirs8, d => clips["Roll_" + d]);
AnimatorState slash = AddDirectionalState(controller, "Slash", Dirs4, d => clips["Slash_" + d]);
AnimatorStateMachine stateMachine = controller.layers[0].stateMachine;
stateMachine.defaultState = idle;
//도트 애니메이션이라 전이 블렌딩은 전부 0
AnimatorStateTransition transition = idle.AddTransition(move);
transition.hasExitTime = false;
transition.duration = 0f;
transition.AddCondition(AnimatorConditionMode.Greater, 0.1f, "Speed");
transition = move.AddTransition(idle);
transition.hasExitTime = false;
transition.duration = 0f;
transition.AddCondition(AnimatorConditionMode.Less, 0.1f, "Speed");
AddAnyStateTrigger(stateMachine, slash, "Attack");
AddAnyStateTrigger(stateMachine, roll, "Roll");
AddExitToIdle(slash, idle);
AddExitToIdle(roll, idle);
EditorUtility.SetDirty(controller);
return controller;
}
/// <summary>컨트롤러 에셋은 그대로 두고 파라미터/스테이트만 전부 비운다.</summary>
private static void ClearController(AnimatorController controller)
{
while (controller.parameters.Length > 0)
{
controller.RemoveParameter(0);
}
//Base Layer 하나만 남긴다
while (controller.layers.Length > 1)
{
controller.RemoveLayer(controller.layers.Length - 1);
}
if (controller.layers.Length == 0)
{
controller.AddLayer("Base Layer");
}
AnimatorStateMachine stateMachine = controller.layers[0].stateMachine;
foreach (AnimatorStateTransition transition in stateMachine.anyStateTransitions.ToArray())
{
stateMachine.RemoveAnyStateTransition(transition);
}
foreach (AnimatorTransition transition in stateMachine.entryTransitions.ToArray())
{
stateMachine.RemoveEntryTransition(transition);
}
foreach (ChildAnimatorState child in stateMachine.states.ToArray())
{
stateMachine.RemoveState(child.state);
}
foreach (ChildAnimatorStateMachine child in stateMachine.stateMachines.ToArray())
{
stateMachine.RemoveStateMachine(child.stateMachine);
}
//스테이트를 지워도 블렌드 트리 서브에셋은 남으므로 직접 정리한다
foreach (UnityEngine.Object sub in AssetDatabase.LoadAllAssetsAtPath(ControllerPath))
{
if (sub is BlendTree)
{
UnityEngine.Object.DestroyImmediate(sub, true);
}
}
}
/// <summary>8방향(혹은 4방향) 클립을 2D 블렌드 트리 하나로 묶은 스테이트를 만든다.</summary>
private static AnimatorState AddDirectionalState(AnimatorController controller, string name,
string[] dirs, Func<string, AnimationClip> pick)
{
AnimatorState state = controller.CreateBlendTreeInController(name, out BlendTree tree, 0);
tree.name = name;
tree.blendType = BlendTreeType.SimpleDirectional2D;
tree.blendParameter = "MoveX";
tree.blendParameterY = "MoveY";
foreach (string dir in dirs)
{
tree.AddChild(pick(dir), DirVectors[dir]);
}
return state;
}
private static void AddAnyStateTrigger(AnimatorStateMachine stateMachine, AnimatorState target, string trigger)
{
AnimatorStateTransition transition = stateMachine.AddAnyStateTransition(target);
transition.hasExitTime = false;
transition.duration = 0f;
transition.canTransitionToSelf = false;
transition.AddCondition(AnimatorConditionMode.If, 0f, trigger);
}
private static void AddExitToIdle(AnimatorState from, AnimatorState idle)
{
AnimatorStateTransition transition = from.AddTransition(idle);
transition.hasExitTime = true;
transition.exitTime = 1f;
transition.duration = 0f;
}
/// <summary>
/// 열려있는 씬에서 Body/Weapon 자식을 가진 Animator를 찾아 정렬과 오프셋을 맞춘다.
/// 전부 Undo로 되돌릴 수 있다.
/// </summary>
private static void FixUpSceneObject(AnimatorController controller, Sprite defaultBody)
{
Animator[] animators = UnityEngine.Object.FindObjectsByType<Animator>(
FindObjectsInactive.Include, FindObjectsSortMode.None);
foreach (Animator animator in animators)
{
Transform body = animator.transform.Find("Body");
Transform weapon = animator.transform.Find("Weapon");
if (body == null || weapon == null)
{
continue;
}
Undo.RecordObject(animator, "Red 셋업");
animator.runtimeAnimatorController = controller;
animator.applyRootMotion = false;
//루트에 SortingGroup이 있어야 몸+검이 한 덩어리로 타일맵과 정렬된다
if (animator.GetComponent<SortingGroup>() == null)
{
Undo.AddComponent<SortingGroup>(animator.gameObject);
}
//핵심: 두 시트의 캔버스 중심이 같으므로 로컬 좌표가 정확히 0이어야 픽셀이 맞물린다
ResetLocal(body);
ResetLocal(weapon);
//검이 몸 위에 그려지도록 order를 1 높인다
SetupRenderer(body, 0, true, defaultBody);
SetupRenderer(weapon, 1, false, null);
EditorSceneManager.MarkSceneDirty(animator.gameObject.scene);
Debug.Log("[RedSetup] 씬의 '" + animator.name + "' 계층을 정리했다.");
return;
}
Debug.LogWarning("[RedSetup] Body/Weapon 자식을 가진 Animator를 씬에서 못 찾았다. 계층 정리는 건너뛴다.");
}
private static void ResetLocal(Transform target)
{
Undo.RecordObject(target, "Red 셋업");
target.localPosition = Vector3.zero;
target.localRotation = Quaternion.identity;
target.localScale = Vector3.one;
}
private static void SetupRenderer(Transform target, int order, bool enabled, Sprite fallbackSprite)
{
var renderer = target.GetComponent<SpriteRenderer>();
if (renderer == null)
{
renderer = Undo.AddComponent<SpriteRenderer>(target.gameObject);
}
Undo.RecordObject(renderer, "Red 셋업");
renderer.sortingOrder = order;
renderer.enabled = enabled;
if (fallbackSprite != null && renderer.sprite == null)
{
renderer.sprite = fallbackSprite;
}
}
}