425 lines
16 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|