feat: 드로그 기본기2 ActorCore 모션 적용

- ActorCore 펀치/어퍼컷/촙 후보 FBX와 휴머노이드 클립 추출 에디터 창을 추가

- 드로그 강타, 기본기2, 기본기3 스킬 클립과 기본기2 패턴 3타 스텝을 새 모션 흐름에 맞게 갱신

- 미사용 도약/발구르기 파생 클립을 정리하고 아직 참조 중인 클립은 보존

- Unity 세션 부재로 에디터 컴파일과 콘솔 검증은 이번 커밋에서 미수행
This commit is contained in:
2026-05-11 17:57:54 +09:00
parent b4648672f6
commit 98b34af941
56 changed files with 213607 additions and 786970 deletions

View File

@@ -0,0 +1,382 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
using UnityEditor;
/// <summary>
/// ActorCore 기준 FBX를 참고해 소스 FBX에서 휴머노이드 클립을 추출하는 에디터 윈도우입니다.
/// 소스 FBX는 우선 Create From This Model로 재임포트한 뒤, 유효한 휴머노이드 클립만 .anim으로 저장합니다.
/// </summary>
public sealed class ActorCoreHumanoidClipExtractorWindow : EditorWindow
{
private const string MenuPath = "Tools/Animation/Extract ActorCore Humanoid Clips";
private const string DefaultReferencePath = "Assets/External/Animations/ActorCore/uppercut-l.fbx";
private const string DefaultOutputFolder = "Assets/External/Animations/ActorCore/Extracted";
private UnityEngine.Object referenceFbx;
private UnityEngine.Object sourceFbx;
private DefaultAsset outputFolder;
private bool overwriteExisting = true;
private Vector2 scrollPosition;
private struct ImporterState
{
public bool importAnimation;
public ModelImporterAnimationType animationType;
public ModelImporterAvatarSetup avatarSetup;
public Avatar sourceAvatar;
public ModelImporterClipAnimation[] clipAnimations;
}
[MenuItem(MenuPath, false, 25)]
public static void ShowWindow()
{
var window = GetWindow<ActorCoreHumanoidClipExtractorWindow>("ActorCore Clip Extractor");
window.minSize = new Vector2(520f, 360f);
window.InitializeDefaults();
window.Show();
}
private void OnEnable()
{
InitializeDefaults();
}
private void InitializeDefaults()
{
if (referenceFbx == null)
referenceFbx = AssetDatabase.LoadMainAssetAtPath(DefaultReferencePath);
if (outputFolder == null)
outputFolder = AssetDatabase.LoadAssetAtPath<DefaultAsset>(DefaultOutputFolder);
}
private void OnGUI()
{
EditorGUILayout.Space(8f);
EditorGUILayout.LabelField("ActorCore 휴머노이드 클립 추출", EditorStyles.boldLabel);
EditorGUILayout.Space(4f);
EditorGUILayout.HelpBox(
"기준 FBX의 휴머노이드 구조를 검증용으로 참고하고, 소스 FBX는 Create From This Model로 재임포트한 뒤 "
+ "유효한 휴머노이드 클립만 개별 .anim으로 추출합니다.",
MessageType.Info);
using (var scrollView = new EditorGUILayout.ScrollViewScope(scrollPosition))
{
scrollPosition = scrollView.scrollPosition;
referenceFbx = EditorGUILayout.ObjectField(
"기준 FBX",
referenceFbx,
typeof(UnityEngine.Object),
false);
sourceFbx = EditorGUILayout.ObjectField(
"소스 FBX",
sourceFbx,
typeof(UnityEngine.Object),
false);
outputFolder = (DefaultAsset)EditorGUILayout.ObjectField(
"출력 폴더",
outputFolder,
typeof(DefaultAsset),
false);
overwriteExisting = EditorGUILayout.ToggleLeft("기존 .anim 덮어쓰기", overwriteExisting);
EditorGUILayout.Space(12f);
DrawSelectionSummary();
}
EditorGUILayout.Space(8f);
using (new EditorGUI.DisabledScope(!CanExtract()))
{
if (GUILayout.Button("추출", GUILayout.Height(34f)))
Extract();
}
}
private void DrawSelectionSummary()
{
EditorGUILayout.LabelField("선택 요약", EditorStyles.boldLabel);
DrawAssetPathLabel("기준 FBX 경로", referenceFbx);
DrawAssetPathLabel("소스 FBX 경로", sourceFbx);
DrawAssetPathLabel("출력 폴더 경로", outputFolder);
if (sourceFbx == null)
return;
string sourcePath = AssetDatabase.GetAssetPath(sourceFbx);
if (!IsFbxPath(sourcePath))
return;
AnimationClip[] clips = AssetDatabase.LoadAllAssetsAtPath(sourcePath)
.OfType<AnimationClip>()
.ToArray();
string[] clipNames = clips.Select(clip => clip.name).ToArray();
string clipSummary = clipNames.Length > 0
? string.Join(", ", clipNames)
: "(없음)";
EditorGUILayout.LabelField("소스 내장 클립", clipSummary, EditorStyles.wordWrappedLabel);
}
private static void DrawAssetPathLabel(string label, UnityEngine.Object asset)
{
string assetPath = asset != null ? AssetDatabase.GetAssetPath(asset) : "(미선택)";
EditorGUILayout.LabelField(label, assetPath, EditorStyles.wordWrappedLabel);
}
private bool CanExtract()
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
return false;
return IsValidFbx(referenceFbx) && IsValidFbx(sourceFbx) && IsValidFolder(outputFolder);
}
private static bool IsValidFbx(UnityEngine.Object asset)
{
return asset != null && IsFbxPath(AssetDatabase.GetAssetPath(asset));
}
private static bool IsFbxPath(string assetPath)
{
return !string.IsNullOrEmpty(assetPath)
&& assetPath.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase);
}
private static bool IsValidFolder(DefaultAsset asset)
{
if (asset == null)
return false;
string path = AssetDatabase.GetAssetPath(asset);
return !string.IsNullOrEmpty(path) && AssetDatabase.IsValidFolder(path);
}
private void Extract()
{
string referencePath = AssetDatabase.GetAssetPath(referenceFbx);
string sourcePath = AssetDatabase.GetAssetPath(sourceFbx);
string outputPath = AssetDatabase.GetAssetPath(outputFolder);
var referenceImporter = AssetImporter.GetAtPath(referencePath) as ModelImporter;
var sourceImporter = AssetImporter.GetAtPath(sourcePath) as ModelImporter;
if (referenceImporter == null || sourceImporter == null)
{
EditorUtility.DisplayDialog(
"ActorCore Clip Extractor",
"FBX ModelImporter를 찾지 못했습니다.",
"확인");
return;
}
int referenceSkeletonCount = referenceImporter.humanDescription.skeleton?.Length ?? 0;
TakeInfo[] originalTakeInfos = sourceImporter.importedTakeInfos ?? Array.Empty<TakeInfo>();
ImporterState originalState = CaptureImporterState(sourceImporter);
if (!PrepareSourceImporter(sourceImporter))
{
EditorUtility.DisplayDialog(
"ActorCore Clip Extractor",
"소스 FBX를 휴머노이드 기준으로 준비하지 못했습니다. 콘솔을 확인해 주세요.",
"확인");
return;
}
try
{
AnimationClip[] clips = AssetDatabase.LoadAllAssetsAtPath(sourcePath)
.OfType<AnimationClip>()
.Where(IsUsableHumanoidClip)
.ToArray();
if (clips.Length == 0)
{
TakeInfo[] takeInfos = sourceImporter.importedTakeInfos ?? Array.Empty<TakeInfo>();
string takeSummary = takeInfos.Length > 0
? string.Join(", ", takeInfos.Select(take => take.name))
: "(없음)";
int preparedSkeletonCount = sourceImporter.humanDescription.skeleton?.Length ?? 0;
bool humanoidImportFailed = originalTakeInfos.Length > 0
&& takeInfos.Length == 0
&& preparedSkeletonCount == 0;
string failureReason = humanoidImportFailed
? "원본 FBX에는 테이크가 있었지만, 휴머노이드로 임시 재임포트하는 단계에서 Unity가 유효한 Avatar 구조를 만들지 못했습니다.\n"
+ "이 경우는 보통 본 매핑 또는 루트 계층이 휴머노이드 조건을 만족하지 않아 `Invalid Avatar Rig Configuration`이 발생한 상황입니다."
: "이 경우는 보통 소스 FBX가 Unity에서 애니메이션 테이크를 읽지 못하는 상태입니다.\n"
+ "원본 툴에서 애니메이션 포함 상태로 다시 export하거나, Blender에서 NLA/All Actions 기반으로 다시 export해야 합니다.";
EditorUtility.DisplayDialog(
"ActorCore Clip Extractor",
"추출 가능한 휴머노이드 클립이 없습니다.\n\n"
+ $"원본 테이크 수: {originalTakeInfos.Length}\n"
+ $"Unity가 감지한 테이크 수: {takeInfos.Length}\n"
+ $"휴머노이드 스켈레톤 수: {preparedSkeletonCount}\n"
+ $"테이크 목록: {takeSummary}\n\n"
+ failureReason,
"확인");
return;
}
List<string> createdPaths = new List<string>();
AssetDatabase.StartAssetEditing();
try
{
foreach (AnimationClip clip in clips)
{
string fileName = SanitizeClipName(clip.name);
string targetPath = $"{outputPath}/{fileName}.anim";
if (!overwriteExisting)
targetPath = AssetDatabase.GenerateUniqueAssetPath(targetPath);
AnimationClip existingClip = AssetDatabase.LoadAssetAtPath<AnimationClip>(targetPath);
AnimationClip copiedClip = UnityEngine.Object.Instantiate(clip);
copiedClip.name = fileName;
if (existingClip != null && overwriteExisting)
{
EditorUtility.CopySerialized(copiedClip, existingClip);
EditorUtility.SetDirty(existingClip);
}
else
{
AssetDatabase.CreateAsset(copiedClip, targetPath);
}
createdPaths.Add(targetPath);
}
}
finally
{
AssetDatabase.StopAssetEditing();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
int sourceSkeletonCount = sourceImporter.humanDescription.skeleton?.Length ?? 0;
string message = $"기준 스켈레톤 수: {referenceSkeletonCount}\n"
+ $"소스 스켈레톤 수: {sourceSkeletonCount}\n"
+ $"추출 클립 수: {createdPaths.Count}\n\n"
+ string.Join("\n", createdPaths);
Debug.Log($"[ActorCoreHumanoidClipExtractor] {message}");
EditorUtility.DisplayDialog("ActorCore Clip Extractor", message, "확인");
}
finally
{
RestoreImporterState(sourceImporter, originalState);
}
}
private static bool PrepareSourceImporter(ModelImporter importer)
{
if (importer == null)
return false;
importer.importAnimation = true;
importer.animationType = ModelImporterAnimationType.Human;
importer.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
importer.sourceAvatar = null;
importer.SaveAndReimport();
return true;
}
private static ImporterState CaptureImporterState(ModelImporter importer)
{
return new ImporterState
{
importAnimation = importer.importAnimation,
animationType = importer.animationType,
avatarSetup = importer.avatarSetup,
sourceAvatar = importer.sourceAvatar,
clipAnimations = CloneClipAnimations(importer.clipAnimations),
};
}
private static void RestoreImporterState(ModelImporter importer, ImporterState state)
{
if (importer == null)
return;
importer.importAnimation = state.importAnimation;
importer.animationType = state.animationType;
importer.avatarSetup = state.avatarSetup;
importer.sourceAvatar = state.sourceAvatar;
importer.clipAnimations = state.clipAnimations ?? Array.Empty<ModelImporterClipAnimation>();
importer.SaveAndReimport();
}
private static ModelImporterClipAnimation[] CloneClipAnimations(ModelImporterClipAnimation[] clips)
{
if (clips == null || clips.Length == 0)
return Array.Empty<ModelImporterClipAnimation>();
return clips.Select(clip => new ModelImporterClipAnimation
{
name = clip.name,
takeName = clip.takeName,
firstFrame = clip.firstFrame,
lastFrame = clip.lastFrame,
loopTime = clip.loopTime,
loopPose = clip.loopPose,
cycleOffset = clip.cycleOffset,
lockRootRotation = clip.lockRootRotation,
lockRootHeightY = clip.lockRootHeightY,
lockRootPositionXZ = clip.lockRootPositionXZ,
keepOriginalOrientation = clip.keepOriginalOrientation,
keepOriginalPositionY = clip.keepOriginalPositionY,
keepOriginalPositionXZ = clip.keepOriginalPositionXZ,
heightFromFeet = clip.heightFromFeet,
mirror = clip.mirror,
maskType = clip.maskType,
additiveReferencePoseFrame = clip.additiveReferencePoseFrame,
hasAdditiveReferencePose = clip.hasAdditiveReferencePose,
wrapMode = clip.wrapMode,
}).ToArray();
}
private static bool IsUsableHumanoidClip(AnimationClip clip)
{
if (clip == null)
return false;
if (clip.name.StartsWith("__preview__", StringComparison.Ordinal))
return false;
if (!clip.humanMotion)
return false;
if (clip.empty)
return false;
return true;
}
private static string SanitizeClipName(string clipName)
{
string sanitized = clipName;
int separatorIndex = sanitized.LastIndexOf('|');
if (separatorIndex >= 0 && separatorIndex + 1 < sanitized.Length)
sanitized = sanitized.Substring(separatorIndex + 1);
foreach (char invalid in Path.GetInvalidFileNameChars())
sanitized = sanitized.Replace(invalid, '_');
return sanitized.Trim();
}
}

View File

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