using System; using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEditor.Animations; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.Rendering; /// /// Red 캐릭터의 애니메이션 클립 / 애니메이터 컨트롤러 / 플레이어 프리팹을 한 번에 생성한다. /// 몸(Body)과 무기(Weapon)는 각각 자식 SpriteRenderer이고, 클립 하나가 두 경로를 동시에 구동한다. /// 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 DirVectors = new Dictionary { { "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(); 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(); } } /// 스프라이트 시트를 잘린 순서대로 읽는다. private static Sprite[] LoadSheet(string path) { Sprite[] sprites = AssetDatabase.LoadAllAssetsAtPath(path).OfType().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; } /// /// 클립 하나를 만든다. weapon이 null이면 해당 동작 내내 무기 렌더러를 꺼서 /// 직전 공격의 칼이 화면에 남아있지 않게 한다. /// 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"); } /// /// 프레임을 fps 간격으로 찍는다. 마지막에 종료 키를 따로 넣지 않는다 — /// Unity가 클립 길이를 "마지막 키 + 1프레임"으로 잡아주기 때문에, /// 중복 키를 두면 마지막 프레임만 2배로 길어져서 루프가 끊긴다. /// 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); } /// 값이 일정한 커브. 스프라이트 커브의 마지막 키 시각까지만 깔아 클립 길이를 늘리지 않는다. 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(path); if (existing != null) { EditorUtility.CopySerialized(clip, existing); EditorUtility.SetDirty(existing); return existing; } AssetDatabase.CreateAsset(clip, path); return clip; } private static AnimatorController BuildController(Dictionary clips) { //씬의 Animator가 이 컨트롤러를 참조하므로 에셋을 지우지 않고 내용만 비운다 (GUID 유지) var controller = AssetDatabase.LoadAssetAtPath(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; } /// 컨트롤러 에셋은 그대로 두고 파라미터/스테이트만 전부 비운다. 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); } } } /// 8방향(혹은 4방향) 클립을 2D 블렌드 트리 하나로 묶은 스테이트를 만든다. private static AnimatorState AddDirectionalState(AnimatorController controller, string name, string[] dirs, Func 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; } /// /// 열려있는 씬에서 Body/Weapon 자식을 가진 Animator를 찾아 정렬과 오프셋을 맞춘다. /// 전부 Undo로 되돌릴 수 있다. /// private static void FixUpSceneObject(AnimatorController controller, Sprite defaultBody) { Animator[] animators = UnityEngine.Object.FindObjectsByType( 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() == null) { Undo.AddComponent(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(); if (renderer == null) { renderer = Undo.AddComponent(target.gameObject); } Undo.RecordObject(renderer, "Red 셋업"); renderer.sortingOrder = order; renderer.enabled = enabled; if (fallbackSprite != null && renderer.sprite == null) { renderer.sprite = fallbackSprite; } } }