Compare commits

...

4 Commits

Author SHA1 Message Date
52b0e682a8 feat: 스킬 애니메이션 N클립 순차 재생 및 이름 기반 자동 매칭 시스템
- SkillData: skillClip/endClip 단일 필드를 animationClips 리스트로 통합
  - Data_Skill_ 접두사 애셋 이름과 Anim_{key}_{순서} 클립을 자동 매칭
  - 레거시 skillClip/endClip 데이터 자동 마이그레이션
- SkillController: 클립 시퀀스 내 순차 재생 로직 (TryPlayNextClipInSequence)
  - baseSkillClip을 컨트롤러 Skill state에서 OnValidate로 자동 발견
  - waitingForEndAnimation / IsInEndAnimation 제거
- BuildSimulationEngine: 전체 클립 duration 합산 및 모든 클립 OnEffect 이벤트 파싱
- PlayerAbnormalityVerificationRunner: GetSkillDuration 전체 클립 길이 합산으로 변경
- EnemyBase: IsInEndAnimation 참조 제거
- AnimationClipExtractor: animationClips 리스트 기반 relink/collect로 변경
- AnimationClipSkillDataMatcher: 클립 변경 시 관련 SkillData 자동 갱신 (AssetPostprocessor)
- BaseSkillClipAssigner: 모든 컨트롤러의 Skill state에 base clip 일괄 할당 에디터 메뉴
- pre-commit hook: Anim_ 네이밍 규칙에 {순서} 패턴 추가 및 Anim_↔Data_Skill_ 매칭 검증
2026-04-02 18:57:03 +09:00
08b1e3d95a fix: Section Speed Editor에서 본 곡선 없는 클립 length 0으로 표시되는 문제 수정
- Mixamo export 클립 등 m_FloatCurves만 있는 경우 bone transform 곡선이 없어 length가 0으로 계산되던 문제 수정
- bone 곡선이 없으면 m_StopTime을 기준으로 폴백하도록 effectiveLength 로직 추가
2026-04-02 13:45:06 +09:00
d6d120cb61 chore: 애니메이션 클립 네이밍 체계화 및 에셋 정리
- Player 클립에 한손/양손/공용 프리픽스 추가 (강타→양손_강타, 베기→한손_베기, 구르기→공용_구르기 등)
- Drog 점프 클립을 점프시작/점프공중/점프착지로 분리
- 사용하지 않는 클립 삭제 (방어태세, 보호막, 철벽, _End 클립들 등)
- Common에 공중, 걷기, 점프 클립 추가
2026-04-02 13:34:53 +09:00
0402ca9b6c fix: SkillController 클립 자동 등록 필터 추가 및 Drog 프리팹 적용
- clipAutoRegisterFilter 필드 추가로 각 캐릭터별 클립 자동 등록 필터 분리
- 필터 좌우 _ 제거 로직 추가 (예: "_Drog_" → "Drog" 매칭)
- AutoRegisterPlayerClips → AutoRegisterClips으로 리네임
- Drog 프리팹에 clipAutoRegisterFilter = "_Drog_" 설정
- AC_Boss_Default의 Skill 상태 Motion을 baseSkillClip과 동일 에셋으로 수정
2026-04-02 12:45:11 +09:00
63 changed files with 898248 additions and 952776 deletions

View File

@@ -6,7 +6,7 @@ AnimationClip:
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: "Anim_Player_\uACF5\uC911"
m_Name: "Anim_Common_\uACF5\uC911"
serializedVersion: 7
m_Legacy: 0
m_Compressed: 0
@@ -36709,5 +36709,5 @@ AnimationClip:
m_EditorCurves: []
m_EulerEditorCurves: []
m_HasGenericRootTransform: 0
m_HasMotionFloatCurves: 0
m_HasMotionFloatCurves: 1
m_Events: []

View File

@@ -6,7 +6,7 @@ AnimationClip:
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: "Anim_Player_\uC810\uD504"
m_Name: "Anim_Common_\uC810\uD504"
serializedVersion: 7
m_Legacy: 0
m_Compressed: 0
@@ -17989,5 +17989,5 @@ AnimationClip:
m_EditorCurves: []
m_EulerEditorCurves: []
m_HasGenericRootTransform: 0
m_HasMotionFloatCurves: 0
m_HasMotionFloatCurves: 1
m_Events: []

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ AnimationClip:
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: "Anim_Drog_\uC810\uD504"
m_Name: "Anim_Drog_\uC810\uD504\uACF5\uC911"
serializedVersion: 7
m_Legacy: 0
m_Compressed: 0
@@ -28819,5 +28819,5 @@ AnimationClip:
m_EditorCurves: []
m_EulerEditorCurves: []
m_HasGenericRootTransform: 0
m_HasMotionFloatCurves: 0
m_HasMotionFloatCurves: 1
m_Events: []

View File

@@ -6,7 +6,7 @@ AnimationClip:
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: "Anim_Drog_\uC810\uD504 1"
m_Name: "Anim_Drog_\uC810\uD504\uC2DC\uC791"
serializedVersion: 7
m_Legacy: 0
m_Compressed: 0
@@ -27586,5 +27586,5 @@ AnimationClip:
m_EditorCurves: []
m_EulerEditorCurves: []
m_HasGenericRootTransform: 0
m_HasMotionFloatCurves: 0
m_HasMotionFloatCurves: 1
m_Events: []

View File

@@ -6,7 +6,7 @@ AnimationClip:
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: "Anim_Drog_\uC810\uD504 2"
m_Name: "Anim_Drog_\uC810\uD504\uCC29\uC9C0"
serializedVersion: 7
m_Legacy: 0
m_Compressed: 0

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 16f9ef4ddd32aba44a2d0c691957e72f
guid: 920ea8a73bbf84849b01d3875ff4e4c3
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 7a180d15c7b07a64485c8dd4ec7a1fa7
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: a8845febff04ecb48b25dac5321c4481
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 190622b0cba48234ba7fc295facac207
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: f43438b6095588f4fb4715bd6df16df8
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 38a21eded51c5b24bb70a48d387aa565
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 92a17c8d63463f741a0e9d305a838993
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 79ef70f9bb079cf4799a4f6935b8d984
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -6,7 +6,7 @@ AnimationClip:
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: "Anim_Player_\uAC15\uD0C0"
m_Name: "Anim_Player_\uC591\uC190_\uAC15\uD0C0"
serializedVersion: 7
m_Legacy: 0
m_Compressed: 0
@@ -147393,5 +147393,5 @@ AnimationClip:
m_EditorCurves: []
m_EulerEditorCurves: []
m_HasGenericRootTransform: 0
m_HasMotionFloatCurves: 0
m_HasMotionFloatCurves: 1
m_Events: []

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 15519f6d5b8e5ba4194d4b8256ebb60d
guid: d6622f6bb6d593c48b903000286de0b6
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ AnimationClip:
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: "Anim_Common_\uCC0C\uB974\uAE30"
m_Name: "Anim_Player_\uC591\uC190_\uCC0C\uB974\uAE30"
serializedVersion: 7
m_Legacy: 0
m_Compressed: 0

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 27f34978bd8e5174cb07562401cea581
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 12bfabc84bb078b41b91dcb0e73034ff
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 47db64c106703ee498e4495d1c434b77
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 665885351f6fc9d4c8b188498edb3d7d
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: c2676ac491a6fc94eb042b76a9c3406e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 6e73cca667dfcd4499c84d1b6ba9d531
guid: 30b04e3f2e060db4aabfd308ac605a62
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: fee9942923e37e64eb04557cd4e28cdf
guid: 9b19fd7edff49ae4681b653285f3a162
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000

View File

@@ -2144,15 +2144,16 @@ MonoBehaviour:
ShowTopMostFoldoutHeaderGroup: 1
animator: {fileID: 4019041888965840580}
baseController: {fileID: 9100000, guid: 4bd980f1a222c5b468136f7e717925d5, type: 2}
baseSkillClip: {fileID: -7717634560727564301, guid: 4005a77aa7d531742b1de1bec27001b1, type: 3}
baseSkillClip: {fileID: 7400000, guid: ace5d7e8f405a8846b98bb956f0a9313, type: 2}
clipAutoRegisterFilter: _Drog_
registeredClips:
- {fileID: -242498254790479478, guid: 585e8961b6c6e9f4ba96bdb4ffb2cbc3, type: 3}
- {fileID: 3627526391332626453, guid: 39aaec38fc96c4842b972f1e991e5a46, type: 3}
- {fileID: -7717634560727564301, guid: 4005a77aa7d531742b1de1bec27001b1, type: 3}
- {fileID: -8265974341663887746, guid: d3e4690f866332b43b86ee7005291cd0, type: 3}
- {fileID: 712281148059590495, guid: b590c58b50c3b554687b172862fa5d9d, type: 3}
- {fileID: 6888780564265376159, guid: 827dfeae95fdf6b41b78698f2e846b5f, type: 3}
- {fileID: -8752051743343580635, guid: 5eaeca917bbeb494eb14ad0e0552c42f, type: 3}
- {fileID: 7400000, guid: 58847e89d27d1b140b1075bba68445c0, type: 2}
- {fileID: 7400000, guid: 6e73cca667dfcd4499c84d1b6ba9d531, type: 2}
- {fileID: 7400000, guid: ace5d7e8f405a8846b98bb956f0a9313, type: 2}
- {fileID: 7400000, guid: 006b9541ce7dcb3469d79f9d9df3d6df, type: 2}
- {fileID: 7400000, guid: 0b70d6464b876144c84f2410c0359a4f, type: 2}
- {fileID: 7400000, guid: e35d6eb3ae2c5a146801c9dd399acd52, type: 2}
- {fileID: 7400000, guid: 4ff85a68bb491e143a001f3af82639ed, type: 2}
- {fileID: 7400000, guid: c8fdea7dee0c6f04bbd27fe565071682, type: 2}
debugMode: 1
showAreaDebug: 1

View File

@@ -257,15 +257,14 @@ namespace Colosseum.Combat.Simulation
};
float resolvedAnimationSpeed = loadoutEntry.GetResolvedAnimationSpeed();
float mainClipDuration = ResolveClipDuration(skill.SkillClip, resolvedAnimationSpeed);
float endClipDuration = ResolveClipDuration(skill.EndClip, 1f);
float totalClipDuration = ResolveTotalClipDuration(skill.AnimationClips, resolvedAnimationSpeed);
int repeatCount = loadoutEntry.GetResolvedRepeatCount();
snapshot.castDuration = Mathf.Max(MinimumActionDuration, (mainClipDuration * repeatCount) + endClipDuration + ruleSet.MovementLossSecondsPerCast);
snapshot.castDuration = Mathf.Max(MinimumActionDuration, (totalClipDuration * repeatCount) + ruleSet.MovementLossSecondsPerCast);
Dictionary<int, List<SkillEffect>> effectMap = new Dictionary<int, List<SkillEffect>>();
loadoutEntry.CollectTriggeredEffects(effectMap);
BuildDamageEvents(snapshot, effectMap, context, weaponDamageMultiplier, ruleSet, mainClipDuration, resolvedAnimationSpeed, repeatCount, warnings);
BuildDamageEvents(snapshot, effectMap, context, weaponDamageMultiplier, ruleSet, totalClipDuration, resolvedAnimationSpeed, repeatCount, warnings);
snapshots[slotIndex] = snapshot;
}
@@ -278,7 +277,7 @@ namespace Colosseum.Combat.Simulation
SimulationContext context,
float weaponDamageMultiplier,
SimulationRuleSet ruleSet,
float mainClipDuration,
float totalClipDuration,
float resolvedAnimationSpeed,
int repeatCount,
List<string> warnings)
@@ -286,15 +285,30 @@ namespace Colosseum.Combat.Simulation
if (snapshot == null || effectMap == null || effectMap.Count == 0)
return;
// 모든 클립에서 OnEffect 이벤트를 수집합니다.
List<AnimationEvent> effectEvents = new List<AnimationEvent>();
AnimationClip clip = snapshot.skill.SkillClip;
if (clip != null)
IReadOnlyList<AnimationClip> clips = snapshot.skill.AnimationClips;
if (clips != null)
{
AnimationEvent[] clipEvents = clip.events;
for (int i = 0; i < clipEvents.Length; i++)
float timeOffset = 0f;
for (int clipIndex = 0; clipIndex < clips.Count; clipIndex++)
{
if (string.Equals(clipEvents[i].functionName, "OnEffect", StringComparison.Ordinal))
effectEvents.Add(clipEvents[i]);
AnimationClip clip = clips[clipIndex];
if (clip == null) continue;
AnimationEvent[] clipEvents = clip.events;
for (int i = 0; i < clipEvents.Length; i++)
{
if (string.Equals(clipEvents[i].functionName, "OnEffect", StringComparison.Ordinal))
{
// 이벤트 시간에 이전 클립들의 누적 길이를 더합니다.
AnimationEvent offsetEvent = clipEvents[i];
offsetEvent.time += timeOffset;
effectEvents.Add(offsetEvent);
}
}
timeOffset += clip.length;
}
}
@@ -303,7 +317,7 @@ namespace Colosseum.Combat.Simulation
for (int iteration = 0; iteration < repeatCount; iteration++)
{
float iterationOffset = mainClipDuration * iteration;
float iterationOffset = totalClipDuration * iteration;
for (int eventIndex = 0; eventIndex < effectEvents.Count; eventIndex++)
{
@@ -546,6 +560,23 @@ namespace Colosseum.Combat.Simulation
return clip.length / Mathf.Max(0.05f, speed);
}
/// <summary>
/// 클립 목록 전체의 재생 시간을 합산합니다.
/// </summary>
private static float ResolveTotalClipDuration(IReadOnlyList<AnimationClip> clips, float speed)
{
if (clips == null || clips.Count == 0)
return 0f;
float total = 0f;
for (int i = 0; i < clips.Count; i++)
{
total += ResolveClipDuration(clips[i], speed);
}
return total;
}
private static List<int> CollectValidPrioritySlots(RotationPolicy rotationPolicy, SkillRuntimeSnapshot[] snapshots)
{
List<int> validSlots = new List<int>();

View File

@@ -345,13 +345,28 @@ public static class AnimationClipExtractor
SerializedObject so = new SerializedObject(skillData);
bool modified = false;
if (TryRemapClip(so.FindProperty("skillClip"), fbxToAnimMap, path, "skillClip"))
// animationClips 리스트의 각 요소를 remap합니다.
SerializedProperty clipsProp = so.FindProperty("animationClips");
if (clipsProp != null)
{
for (int i = 0; i < clipsProp.arraySize; i++)
{
if (TryRemapClip(clipsProp.GetArrayElementAtIndex(i), fbxToAnimMap, path, $"animationClips[{i}]"))
{
modified = true;
relinkCount++;
}
}
}
// 레거시 필드도 remap (마이그레이션 전 애셋 보호)
if (TryRemapClip(so.FindProperty("legacySkillClip"), fbxToAnimMap, path, "legacySkillClip"))
{
modified = true;
relinkCount++;
}
if (TryRemapClip(so.FindProperty("endClip"), fbxToAnimMap, path, "endClip"))
if (TryRemapClip(so.FindProperty("legacyEndClip"), fbxToAnimMap, path, "legacyEndClip"))
{
modified = true;
relinkCount++;
@@ -567,8 +582,11 @@ public static class AnimationClipExtractor
SkillData skillData = AssetDatabase.LoadAssetAtPath<SkillData>(path);
if (skillData == null) continue;
if (skillData.SkillClip != null) clips.Add(skillData.SkillClip);
if (skillData.EndClip != null) clips.Add(skillData.EndClip);
IReadOnlyList<AnimationClip> skillClips = skillData.AnimationClips;
for (int i = 0; i < skillClips.Count; i++)
{
if (skillClips[i] != null) clips.Add(skillClips[i]);
}
}
// ── BossPhaseData ──

View File

@@ -0,0 +1,101 @@
using System;
using UnityEditor;
using Colosseum.Skills;
namespace Colosseum.Editor
{
/// <summary>
/// Animations 폴더 내 애니메이션 클립이 변경(생성/수정/삭제/이동)되면
/// 관련 SkillData의 애니메이션 클립 목록을 자동 갱신합니다.
/// </summary>
public class AnimationClipSkillDataMatcher : AssetPostprocessor
{
private const string AnimationsFolderPath = "Assets/_Game/Animations";
private static void OnPostprocessAllAssets(
string[] importedAssets,
string[] deletedAssets,
string[] movedAssets,
string[] movedFromAssetPaths)
{
bool hasAnimationChange = false;
for (int i = 0; i < importedAssets.Length; i++)
{
if (IsAnimationClipPath(importedAssets[i]))
{
hasAnimationChange = true;
break;
}
}
if (!hasAnimationChange)
{
for (int i = 0; i < deletedAssets.Length; i++)
{
if (IsAnimationClipPath(deletedAssets[i]))
{
hasAnimationChange = true;
break;
}
}
}
if (!hasAnimationChange)
{
for (int i = 0; i < movedAssets.Length; i++)
{
if (IsAnimationClipPath(movedAssets[i]) || IsAnimationClipPath(movedFromAssetPaths[i]))
{
hasAnimationChange = true;
break;
}
}
}
if (!hasAnimationChange)
return;
RefreshAllSkillDataClips();
}
/// <summary>
/// 지정한 경로가 Animations 폴더 내의 애니메이션 클립인지 확인합니다.
/// </summary>
private static bool IsAnimationClipPath(string assetPath)
{
return assetPath != null
&& assetPath.StartsWith(AnimationsFolderPath)
&& assetPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 모든 SkillData의 애니메이션 클립 목록을 갱신합니다.
/// </summary>
private static void RefreshAllSkillDataClips()
{
string[] skillGuids = AssetDatabase.FindAssets("t:SkillData");
int refreshedCount = 0;
for (int i = 0; i < skillGuids.Length; i++)
{
string path = AssetDatabase.GUIDToAssetPath(skillGuids[i]);
SkillData skillData = AssetDatabase.LoadAssetAtPath<SkillData>(path);
if (skillData == null) continue;
int clipCountBefore = skillData.AnimationClips.Count;
skillData.RefreshAnimationClips();
if (skillData.AnimationClips.Count != clipCountBefore)
refreshedCount++;
}
if (refreshedCount > 0)
{
AssetDatabase.SaveAssets();
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c4524bdb16f89724b96d588200b8a3e8

View File

@@ -54,18 +54,28 @@ public class AnimationSectionSpeedEditor : EditorWindow
// ── 클립 정보 (실제 키프레임 기반) ──
float actualLength = GetActualLastKeyframeTime(clip);
bool hasBoneCurves = actualLength > 0f;
// 본 트랜스폼 곡선이 없으면 m_StopTime을 기준으로 사용
// (Mixamo export 클립 등 m_FloatCurves만 있는 경우)
float effectiveLength = hasBoneCurves ? actualLength : clip.length;
EditorGUILayout.LabelField("Clip Info", EditorStyles.boldLabel);
EditorGUI.indentLevel++;
EditorGUILayout.LabelField("Length (keyframes)", $"{actualLength:F3}s");
EditorGUILayout.LabelField("m_StopTime", $"{clip.length:F3}s");
EditorGUILayout.LabelField("Length", $"{effectiveLength:F3}s");
if (Mathf.Abs(clip.length - actualLength) > 0.01f)
if (hasBoneCurves && Mathf.Abs(clip.length - actualLength) > 0.01f)
{
EditorGUILayout.LabelField("m_StopTime", $"{clip.length:F3}s");
EditorGUILayout.HelpBox($"m_StopTime({clip.length:F3}s)이 실제 키프레임 길이({actualLength:F3}s)와 다릅니다.\n" +
"스피드 변경 시 실제 키프레임 길이를 기준으로 계산합니다.", MessageType.Warning);
}
else if (!hasBoneCurves)
{
EditorGUILayout.LabelField(" (본 곡선 없음, m_StopTime 기준)", EditorStyles.miniLabel);
}
EditorGUILayout.LabelField("Total Frames ({fps}fps)", $"{Mathf.Max(1, Mathf.FloorToInt(actualLength * fps))}");
EditorGUILayout.LabelField("Total Frames ({fps}fps)", $"{Mathf.Max(1, Mathf.FloorToInt(effectiveLength * fps))}");
EditorGUI.indentLevel--;
EditorGUILayout.Space();
@@ -74,7 +84,7 @@ public class AnimationSectionSpeedEditor : EditorWindow
fps = EditorGUILayout.IntField("FPS", fps);
fps = Mathf.Max(1, fps);
int totalFrames = Mathf.Max(1, Mathf.FloorToInt(actualLength * fps));
int totalFrames = Mathf.Max(1, Mathf.FloorToInt(effectiveLength * fps));
// ── 프레임 범위 ──
EditorGUILayout.LabelField("Target Frame Range", EditorStyles.boldLabel);
@@ -108,7 +118,7 @@ public class AnimationSectionSpeedEditor : EditorWindow
EditorGUILayout.Space();
// ── 타임라인 시각화 ──
DrawTimeline(startTime, endTime, actualLength);
DrawTimeline(startTime, endTime, effectiveLength);
EditorGUILayout.Space();
@@ -133,7 +143,7 @@ public class AnimationSectionSpeedEditor : EditorWindow
// ── 미리보기 ──
float newDuration = duration / speedMultiplier;
float timeDelta = newDuration - duration;
float newLength = actualLength + timeDelta;
float newLength = effectiveLength + timeDelta;
EditorGUILayout.LabelField("Preview", EditorStyles.boldLabel);
EditorGUI.indentLevel++;
@@ -301,6 +311,7 @@ public class AnimationSectionSpeedEditor : EditorWindow
float newDuration = duration / speedMultiplier;
float timeDelta = newDuration - duration;
float actualLength = GetActualLastKeyframeTime(clip);
float effectiveLength = actualLength > 0f ? actualLength : clip.length;
Undo.RegisterCompleteObjectUndo(clip, $"Section Speed {speedMultiplier}x (frames {startFrame}~{endFrame})");
@@ -392,7 +403,7 @@ public class AnimationSectionSpeedEditor : EditorWindow
}
// ── 5. m_StopTime 갱신 (SerializedObject로) ──
float newStopTime = actualLength + timeDelta;
float newStopTime = effectiveLength + timeDelta;
var serializedClip = new SerializedObject(clip);
SerializedProperty stopTimeProp = serializedClip.FindProperty("m_StopTime");
if (stopTimeProp != null)
@@ -433,7 +444,7 @@ public class AnimationSectionSpeedEditor : EditorWindow
Debug.Log($"[SectionSpeedEditor] {clip.name}: frames {startFrame}~{endFrame} → {speedMultiplier}x " +
$"({duration:F3}s → {newDuration:F3}s) " +
$"| clip: {actualLength:F3}s → {newStopTime:F3}s " +
$"| clip: {effectiveLength:F3}s → {newStopTime:F3}s " +
$"| curves: {modifiedCurves}, keyframes: {modifiedKeyframes}, skipped: {skippedCurves}" +
$" | events: {(eventsModified ? "modified" : "none")}");
}

View File

@@ -0,0 +1,103 @@
using System;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace Colosseum.Editor
{
/// <summary>
/// AnimatorController의 Skill 상태에 base clip을 일괄 할당하는 에디터 도구입니다.
/// </summary>
public static class BaseSkillClipAssigner
{
private const string SkillStateName = "Skill";
/// <summary>
/// 선택한 애니메이션 클립을 모든 AnimatorController의 Skill 상태에 할당합니다.
/// </summary>
[MenuItem("Tools/Animation/Assign Base Skill Clip to All Controllers")]
private static void AssignBaseClipToAllControllers()
{
AnimationClip selectedClip = Selection.activeObject as AnimationClip;
if (selectedClip == null)
{
EditorUtility.DisplayDialog(
"Base Skill Clip 할당",
"Animations 폴더에서 할당할 AnimationClip을 선택한 후 다시 실행하세요.",
"확인");
return;
}
string[] controllerGuids = AssetDatabase.FindAssets("t:AnimatorController", new[] { "Assets/_Game" });
int assignedCount = 0;
for (int i = 0; i < controllerGuids.Length; i++)
{
string path = AssetDatabase.GUIDToAssetPath(controllerGuids[i]);
AnimatorController ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(path);
if (ac == null) continue;
if (TryAssignClipToState(ac, SkillStateName, selectedClip))
assignedCount++;
}
if (assignedCount > 0)
{
AssetDatabase.SaveAssets();
EditorUtility.DisplayDialog(
"Base Skill Clip 할당 완료",
$"{selectedClip.name}을(를) {assignedCount}개 컨트롤러의 Skill 상태에 할당했습니다.",
"확인");
}
else
{
EditorUtility.DisplayDialog(
"Base Skill Clip 할당",
"Skill 상태를 가진 컨트롤러를 찾지 못했습니다.",
"확인");
}
}
/// <summary>
/// AnimatorController의 지정한 상태에 클립을 할당합니다.
/// </summary>
private static bool TryAssignClipToState(AnimatorController ac, string stateName, AnimationClip clip)
{
for (int i = 0; i < ac.layers.Length; i++)
{
if (TryAssignClipInStateMachine(ac.layers[i].stateMachine, stateName, clip))
{
EditorUtility.SetDirty(ac);
return true;
}
}
return false;
}
/// <summary>
/// StateMachine을 재귀적으로 탐색하여 지정한 이름의 상태에 클립을 할당합니다.
/// </summary>
private static bool TryAssignClipInStateMachine(AnimatorStateMachine sm, string stateName, AnimationClip clip)
{
for (int i = 0; i < sm.states.Length; i++)
{
if (sm.states[i].state.name == stateName)
{
sm.states[i].state.motion = clip;
return true;
}
}
for (int i = 0; i < sm.stateMachines.Length; i++)
{
if (TryAssignClipInStateMachine(sm.stateMachines[i].stateMachine, stateName, clip))
return true;
}
return false;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1a6c131eb50620a47ab0250c9d46d438

View File

@@ -178,7 +178,6 @@ namespace Colosseum.Enemy
var skillCtrl = GetComponent<Colosseum.Skills.SkillController>();
bool needsYMotion = skillCtrl != null
&& skillCtrl.IsPlayingAnimation
&& !skillCtrl.IsInEndAnimation
&& skillCtrl.UsesRootMotion
&& !skillCtrl.IgnoreRootMotionY;

View File

@@ -394,10 +394,18 @@ namespace Colosseum.Player
private float GetSkillDuration(SkillData skill)
{
if (skill == null || skill.SkillClip == null)
if (skill == null || skill.AnimationClips.Count == 0)
return settleDelay;
return Mathf.Max(settleDelay, skill.SkillClip.length / Mathf.Max(0.1f, skill.AnimationSpeed));
float totalLength = 0f;
var clips = skill.AnimationClips;
for (int i = 0; i < clips.Count; i++)
{
if (clips[i] != null)
totalLength += clips[i].length;
}
return Mathf.Max(settleDelay, totalLength / Mathf.Max(0.1f, skill.AnimationSpeed));
}
private SkillData FindCancellableSkill()

View File

@@ -35,17 +35,18 @@ namespace Colosseum.Skills
public class SkillController : NetworkBehaviour
{
private const string SKILL_STATE_NAME = "Skill";
private const string END_STATE_NAME = "SkillEnd";
[Header("애니메이션")]
[SerializeField] private Animator animator;
[Tooltip("기본 Animator Controller (스킬 종료 후 복원용)")]
[SerializeField] private RuntimeAnimatorController baseController;
[Tooltip("Skill 상태에 연결된 기본 클립 (Override용)")]
[Tooltip("Skill 상태에 연결된 기본 클립 (Override용). baseController의 Skill state에서 자동 발견됩니다.")]
[SerializeField] private AnimationClip baseSkillClip;
[Header("네트워크 동기화")]
[Tooltip("\"_Player_\" 이름이 포함된 클립이 자동 등록됩니다. 서버→클라이언트 클립 동기화에 사용됩니다.")]
[Tooltip(" 이름이 포함된 클립이 자동 등록됩니다. 서버→클라이언트 클립 동기화에 사용됩니다.")]
[SerializeField] private string clipAutoRegisterFilter = "_Player_";
[Tooltip("자동 등록된 클립 목록 (서버→클라이언트 클립 인덱스 동기화용)")]
[SerializeField] private List<AnimationClip> registeredClips = new();
[Header("설정")]
@@ -69,7 +70,7 @@ namespace Colosseum.Skills
private readonly List<AbnormalityData> currentCastStartAbnormalities = new();
private readonly Dictionary<int, List<AbnormalityData>> currentTriggeredAbnormalities = new();
private readonly List<GameObject> currentTriggeredTargetsBuffer = new();
private bool waitingForEndAnimation; // EndAnimation 종료 대기 중
private int currentClipSequenceIndex; // 현재 재생 중인 클립 순서 (animationClips 내 인덱스)
private int currentRepeatCount = 1;
private int currentIterationIndex = 0;
private GameObject currentTargetOverride;
@@ -80,7 +81,6 @@ namespace Colosseum.Skills
public bool IsExecutingSkill => currentSkill != null;
public bool IsPlayingAnimation => currentSkill != null;
public bool IsInEndAnimation => waitingForEndAnimation;
public bool UsesRootMotion => currentSkill != null && currentSkill.UseRootMotion;
public bool IgnoreRootMotionY => currentSkill != null && currentSkill.IgnoreRootMotionY;
public SkillData CurrentSkill => currentSkill;
@@ -110,15 +110,73 @@ namespace Colosseum.Skills
#if UNITY_EDITOR
private void OnValidate()
{
AutoRegisterPlayerClips();
AutoDiscoverBaseSkillClip();
AutoRegisterClips();
}
/// <summary>
/// "_Player_"가 포함된 모든 AnimationClip을 registeredClips에 자동 등록합니다.
/// baseController의 Skill 상태에 연결된 클립을 baseSkillClip으로 자동 발견합니다.
/// </summary>
private void AutoDiscoverBaseSkillClip()
{
if (baseSkillClip != null) return;
if (baseController == null) return;
var ac = baseController as UnityEditor.Animations.AnimatorController;
if (ac == null) return;
AnimationClip foundClip = FindClipInState(ac, SKILL_STATE_NAME);
if (foundClip != null)
{
baseSkillClip = foundClip;
UnityEditor.EditorUtility.SetDirty(this);
}
}
/// <summary>
/// AnimatorController의 지정한 상태에 연결된 AnimationClip을 찾습니다.
/// </summary>
private static AnimationClip FindClipInState(UnityEditor.Animations.AnimatorController ac, string stateName)
{
for (int i = 0; i < ac.layers.Length; i++)
{
AnimationClip clip = FindClipInStateMachine(ac.layers[i].stateMachine, stateName);
if (clip != null) return clip;
}
return null;
}
/// <summary>
/// StateMachine을 재귀적으로 탐색하여 지정한 이름의 상태에서 클립을 찾습니다.
/// </summary>
private static AnimationClip FindClipInStateMachine(UnityEditor.Animations.AnimatorStateMachine sm, string stateName)
{
for (int i = 0; i < sm.states.Length; i++)
{
if (sm.states[i].state.name == stateName && sm.states[i].state.motion is AnimationClip clip)
return clip;
}
for (int i = 0; i < sm.stateMachines.Length; i++)
{
AnimationClip clip = FindClipInStateMachine(sm.stateMachines[i].stateMachine, stateName);
if (clip != null) return clip;
}
return null;
}
/// <summary>
/// clipAutoRegisterFilter 이름이 포함된 모든 AnimationClip을 registeredClips에 자동 등록합니다.
/// 서버→클라이언트 클립 동기화 인덱스의 일관성을 위해 이름순으로 정렬합니다.
/// </summary>
private void AutoRegisterPlayerClips()
private void AutoRegisterClips()
{
string trimmedFilter = clipAutoRegisterFilter.Trim('_');
if (string.IsNullOrEmpty(trimmedFilter))
return;
string[] guids = AssetDatabase.FindAssets("t:AnimationClip", new[] { "Assets/_Game/Animations" });
var clips = new List<AnimationClip>();
@@ -127,7 +185,7 @@ namespace Colosseum.Skills
string path = AssetDatabase.GUIDToAssetPath(guid);
string clipName = Path.GetFileNameWithoutExtension(path);
if (clipName.IndexOf("_Player_", StringComparison.OrdinalIgnoreCase) >= 0)
if (clipName.IndexOf(trimmedFilter, StringComparison.OrdinalIgnoreCase) >= 0)
{
AnimationClip clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
if (clip != null)
@@ -155,7 +213,7 @@ namespace Colosseum.Skills
{
registeredClips.Clear();
registeredClips.AddRange(clips);
Debug.Log($"[SkillController] 자동 등록: {clips.Count}개 Player 클립", this);
Debug.Log($"[SkillController] 자동 등록: {clips.Count}개 클립 (필터: {clipAutoRegisterFilter})", this);
}
}
#endif
@@ -167,38 +225,21 @@ namespace Colosseum.Skills
var stateInfo = animator.GetCurrentAnimatorStateInfo(0);
// EndAnimation 종료 감지
if (waitingForEndAnimation)
{
if (stateInfo.normalizedTime >= 1f)
{
if (debugMode) Debug.Log($"[Skill] EndAnimation complete: {currentSkill.SkillName}");
RestoreBaseController();
ClearCurrentSkillState();
}
return;
}
// 애니메이션 종료 시 처리 (OnSkillEnd 여부와 관계없이 애니메이션 끝까지 재생)
// 애니메이션 종료 시 처리
if (stateInfo.normalizedTime >= 1f)
{
// 같은 반복 차수 내에서 다음 클립이 있으면 재생
if (TryPlayNextClipInSequence())
return;
// 다음 반복 차수가 있으면 시작
if (TryStartNextIteration())
return;
if (currentSkill.EndClip != null)
{
// EndAnimation 재생 후 종료 대기
if (debugMode) Debug.Log($"[Skill] SkillAnimation done, playing EndAnimation: {currentSkill.SkillName}");
PlayEndClip(currentSkill.EndClip);
waitingForEndAnimation = true;
}
else
{
// EndAnimation 없으면 바로 종료
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
RestoreBaseController();
ClearCurrentSkillState();
}
// 모든 클립과 반복이 끝나면 종료
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
RestoreBaseController();
ClearCurrentSkillState();
}
}
@@ -263,7 +304,6 @@ namespace Colosseum.Skills
currentLoadoutEntry = loadoutEntry != null ? loadoutEntry.CreateCopy() : SkillLoadoutEntry.CreateTemporary(skill);
currentSkill = skill;
waitingForEndAnimation = false;
lastCancelReason = SkillCancelReason.None;
BuildResolvedEffects(currentLoadoutEntry);
currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount();
@@ -379,7 +419,7 @@ namespace Colosseum.Skills
return;
currentIterationIndex++;
waitingForEndAnimation = false;
currentClipSequenceIndex = 0;
if (debugMode && currentRepeatCount > 1)
{
@@ -388,18 +428,41 @@ namespace Colosseum.Skills
TriggerCastStartEffects();
if (currentSkill.SkillClip != null && animator != null)
if (currentSkill.AnimationClips.Count > 0 && animator != null)
{
float resolvedAnimationSpeed = currentLoadoutEntry != null
? currentLoadoutEntry.GetResolvedAnimationSpeed()
: currentSkill.AnimationSpeed;
animator.speed = resolvedAnimationSpeed;
PlaySkillClip(currentSkill.SkillClip);
PlaySkillClip(currentSkill.AnimationClips[0]);
}
TriggerImmediateSelfEffectsIfNeeded();
}
/// <summary>
/// 시퀀스 내 다음 클립이 있으면 재생합니다.
/// </summary>
private bool TryPlayNextClipInSequence()
{
if (currentSkill == null)
return false;
int nextIndex = currentClipSequenceIndex + 1;
if (nextIndex >= currentSkill.AnimationClips.Count)
return false;
currentClipSequenceIndex = nextIndex;
PlaySkillClip(currentSkill.AnimationClips[currentClipSequenceIndex]);
if (debugMode)
{
Debug.Log($"[Skill] Playing clip {currentClipSequenceIndex + 1}/{currentSkill.AnimationClips.Count}: {currentSkill.AnimationClips[currentClipSequenceIndex].name}");
}
return true;
}
/// <summary>
/// 반복 시전이 남아 있으면 다음 차수를 시작합니다.
/// </summary>
@@ -445,31 +508,6 @@ namespace Colosseum.Skills
PlaySkillClipClientRpc(registeredClips.IndexOf(clip));
}
/// <summary>
/// 종료 클립 재생
/// </summary>
private void PlayEndClip(AnimationClip clip)
{
if (baseSkillClip == null)
{
Debug.LogError("[SkillController] Base Skill Clip is not assigned!");
return;
}
var overrideController = new AnimatorOverrideController(baseController);
overrideController[baseSkillClip] = clip;
animator.runtimeAnimatorController = overrideController;
// 애니메이터 완전 리셋 후 재생
animator.Rebind();
animator.Update(0f);
animator.Play(SKILL_STATE_NAME, 0, 0f);
// 클라이언트에 클립 동기화
if (IsServer && IsSpawned)
PlaySkillClipClientRpc(registeredClips.IndexOf(clip));
}
/// <summary>
/// 기본 컨트롤러로 복원
/// </summary>
@@ -646,7 +684,7 @@ namespace Colosseum.Skills
currentTriggeredAbnormalities.Clear();
currentTriggeredTargetsBuffer.Clear();
currentTargetOverride = null;
waitingForEndAnimation = false;
currentClipSequenceIndex = 0;
currentRepeatCount = 1;
currentIterationIndex = 0;
}

View File

@@ -56,6 +56,120 @@ namespace Colosseum.Skills
[CreateAssetMenu(fileName = "NewSkill", menuName = "Colosseum/Skill")]
public class SkillData : ScriptableObject
{
private const string SkillAssetPrefix = "Data_Skill_";
private const string ClipAssetPrefix = "Anim_";
private const string AnimationsSearchPath = "Assets/_Game/Animations";
#if UNITY_EDITOR
/// <summary>
/// 레거시 마이그레이션 및 애니메이션 클립 자동 매칭.
/// 에디터에서 애셋 로드/수정 시 자동 실행됩니다.
/// </summary>
private void OnValidate()
{
MigrateLegacyClips();
RefreshAnimationClips();
}
/// <summary>
/// 기존 skillClip/endClip 데이터를 animationClips 리스트로 이관합니다.
/// </summary>
private void MigrateLegacyClips()
{
if (legacySkillClip == null && legacyEndClip == null)
return;
if (animationClips.Count > 0)
return;
if (legacySkillClip != null)
{
animationClips.Add(legacySkillClip);
legacySkillClip = null;
}
if (legacyEndClip != null)
{
animationClips.Add(legacyEndClip);
legacyEndClip = null;
}
UnityEditor.EditorUtility.SetDirty(this);
Debug.Log($"[SkillData] 레거시 클립을 animationClips로 이관 완료: {SkillName ?? name}", this);
}
/// <summary>
/// 애셋 이름 기반으로 매칭되는 애니메이션 클립을 자동 수집합니다.
/// SkillData 이름이 'Data_Skill_'으로 시작하면 'Anim_{key}_{순서}' 클립을 찾아 animationClips에 채웁니다.
/// </summary>
public void RefreshAnimationClips()
{
if (!name.StartsWith(SkillAssetPrefix))
return;
string key = name.Substring(SkillAssetPrefix.Length);
if (string.IsNullOrEmpty(key))
return;
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:AnimationClip", new[] { AnimationsSearchPath });
var matchedClips = new List<(AnimationClip clip, int order)>();
for (int i = 0; i < guids.Length; i++)
{
string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]);
AnimationClip clip = UnityEditor.AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
if (clip == null) continue;
string clipName = clip.name;
if (!clipName.StartsWith(ClipAssetPrefix))
continue;
string remaining = clipName.Substring(ClipAssetPrefix.Length);
if (remaining == key)
{
// 정확 매칭 (순서 번호 없음) → 최우선
matchedClips.Add((clip, -1));
}
else if (remaining.StartsWith(key + "_"))
{
string suffix = remaining.Substring(key.Length + 1);
if (int.TryParse(suffix, out int order))
{
matchedClips.Add((clip, order));
}
}
}
if (matchedClips.Count == 0)
return;
// 정렬: 순서 번호 없음(-1) → 순서 번호 오름차순
matchedClips.Sort((a, b) => a.order.CompareTo(b.order));
// 변경이 있는 경우만 갱신 (무한 루프 방지)
bool changed = matchedClips.Count != animationClips.Count;
if (!changed)
{
for (int i = 0; i < matchedClips.Count; i++)
{
if (matchedClips[i].clip != animationClips[i])
{
changed = true;
break;
}
}
}
if (!changed) return;
animationClips.Clear();
for (int i = 0; i < matchedClips.Count; i++)
animationClips.Add(matchedClips[i].clip);
UnityEditor.EditorUtility.SetDirty(this);
}
#endif
[Header("기본 정보")]
[SerializeField] private string skillName;
[TextArea(2, 4)]
@@ -73,13 +187,15 @@ namespace Colosseum.Skills
[SerializeField] private SkillBaseType baseTypes = SkillBaseType.None;
[Header("애니메이션")]
[Tooltip("기본 Animator Controller의 'Skill' 상태에 덮어씌워질 클립")]
[SerializeField] private AnimationClip skillClip;
[Tooltip("종료 애니메이션 (선택)")]
[SerializeField] private AnimationClip endClip;
[Tooltip("순차 재생할 클립 목록. 애셋 이름이 Data_Skill_ 접두사면 Anim_{이름}_{순서} 클립을 자동 수집합니다.")]
[SerializeField] private List<AnimationClip> animationClips = new();
[Tooltip("애니메이션 재생 속도 (1 = 기본, 2 = 2배속)")]
[Min(0.1f)] [SerializeField] private float animationSpeed = 1f;
// 레거시 마이그레이션 (기존 skillClip/endClip 데이터 보존)
[SerializeField, HideInInspector] private AnimationClip legacySkillClip;
[SerializeField, HideInInspector] private AnimationClip legacyEndClip;
[Header("루트 모션")]
[Tooltip("애니메이션의 이동/회전 데이터를 캐릭터에 적용")]
[SerializeField] private bool useRootMotion = false;
@@ -123,8 +239,15 @@ namespace Colosseum.Skills
public SkillRoleType SkillRole => skillRole;
public SkillActivationType ActivationType => activationType;
public SkillBaseType BaseTypes => baseTypes;
public AnimationClip SkillClip => skillClip;
public AnimationClip EndClip => endClip;
/// <summary>
/// 순차 재생할 클립 목록입니다.
/// </summary>
public IReadOnlyList<AnimationClip> AnimationClips => animationClips;
/// <summary>
/// 첫 번째 클립 (레거시 호환성). 기존 코드에서 SkillClip을 참조하는 곳에서 사용됩니다.
/// </summary>
public AnimationClip SkillClip => animationClips.Count > 0 ? animationClips[0] : null;
public float AnimationSpeed => animationSpeed;
public float Cooldown => cooldown;
public float ManaCost => manaCost;