// ───────────────────────────────────────────────────────────────────────────── // WLMobBuilder.cs — 기본 몬스터 라인업 프리팹 생성기(에디터) (#907 · 2026-10-17) // // PD 원문: 「RPG Monster BUNDLE PBR 몬스터 에셋을 추가했어. 우리 게임의 기본 몬스터 구성을 이 에셋으로 구성해줘.」 // ■ 입력 = Resources/WL/WLMobLineup.json (런타임 라인업과 같은 파일 · C45) // ■ 출력(항목마다) // ① 클립 복사본 /<이름>/<상태>.anim — 번들 FBX 클립 + 템플릿 몹 클립의 이벤트(Event_Idle · Projectile · SkillEvent) // ② 컨트롤러 /<이름>/<이름>.controller — 템플릿 몹 컨트롤러와 같은 상태 이름 8개 · 공격 / 피격 / 스킬 끝 → 대기 전이 // ③ 프리팹 Assets/Res_Addr/Mobs/.prefab — 템플릿 프리팹의 루트 컴포넌트는 그대로 두고 자식(뼈 · 메시)만 번들 모델로 교체 · // 루트 Animator = 번들 아바타 + ② · 피격 박스 · 길찾기 반경 · Center · 렌더러 경계(몹 루트 기준) = 대기 자세 모델 크기에서 계산 · // WLMobBodySize(켜진 뒤 몸 크기 보정) 부착 // (Assets/Res_Addr/Mobs 폴더가 어드레서블 Mobs 그룹에 폴더째 등록되어 있어 추가 등록이 필요 없다) // ■ 판정 시점 -1 = 자동: 클립을 48 등분 샘플 → 모델 정점이 앞(+Z)으로 가장 멀리 뻗는 순간 // ■ 실행 = 메뉴 WL/Mobs/Build Lineup 또는 WL.Mobs.EditorTools.WLMobBuilder.Build() // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEngine; using UnityEngine.Animations; using UnityEngine.Playables; using UAC = UnityEditor.Animations.AnimatorController; using UState = UnityEditor.Animations.AnimatorState; namespace WL.Mobs.EditorTools { public static class WLMobBuilder { const string JsonAsset = "Assets/WL/Mobs/Resources/WL/WLMobLineup.json"; static readonly string[] States = { "run", "idle", "attack", "hit", "die", "stun", "casting", "casting_end" }; const int Samples = 48; [MenuItem("WL/Mobs/Build Lineup")] static void Menu() { Debug.Log(Build()); } public static string Build() { var sb = new StringBuilder(); var ta = AssetDatabase.LoadAssetAtPath(JsonAsset); if (ta == null) return "JSON 없음 " + JsonAsset; var cfg = JsonUtility.FromJson(ta.text); var template = AssetDatabase.LoadAssetAtPath(cfg.templatePrefab); var tplAnim = template != null ? template.GetComponent() : null; var tplCtrl = tplAnim != null ? tplAnim.runtimeAnimatorController as UAC : null; if (tplCtrl == null) return "템플릿 컨트롤러 없음 " + cfg.templatePrefab; var tplClips = new Dictionary(); foreach (var st in tplCtrl.layers[0].stateMachine.states) tplClips[st.state.name] = st.state.motion as AnimationClip; foreach (var e in cfg.mobs) { if (e == null || string.IsNullOrEmpty(e.source) || string.IsNullOrEmpty(e.prefab)) continue; try { sb.AppendLine(BuildOne(cfg, e, tplClips)); } catch (System.Exception ex) { sb.AppendLine(e.monsterId + " 실패 — " + ex); } } AssetDatabase.SaveAssets(); return sb.ToString(); } static string BuildOne(WLMobLineupJson cfg, WLMobLineupEntry e, Dictionary tplClips) { var src = AssetDatabase.LoadAssetAtPath(e.source); var srcAnim = src != null ? src.GetComponent() : null; if (srcAnim == null || srcAnim.runtimeAnimatorController == null) return e.monsterId + " 원본 애니메이터 없음 " + e.source; var byName = new Dictionary(); foreach (var c in srcAnim.runtimeAnimatorController.animationClips) if (c != null && !byName.ContainsKey(c.name)) byName[c.name] = c; var want = new Dictionary { { "run", e.run }, { "idle", e.idle }, { "attack", e.attack }, { "hit", e.hit }, { "die", e.die }, { "stun", e.stun }, { "casting", e.casting }, { "casting_end", e.castingEnd } }; foreach (var kv in want) if (!byName.ContainsKey(kv.Value ?? "")) return e.monsterId + " 클립 없음 " + kv.Key + " = '" + kv.Value + "' (있는 것: " + string.Join(", ", byName.Keys) + ")"; string name = Path.GetFileName(e.prefab), dir = cfg.generatedFolder + "/" + name, outPrefab = "Assets/Res_Addr/Mobs/" + e.prefab + ".prefab"; EnsureFolder(dir); EnsureFolder(Path.GetDirectoryName(outPrefab).Replace('\\', '/')); var log = new StringBuilder(e.monsterId + " " + name + " ← " + src.name); var root = PrefabUtility.LoadPrefabContents(cfg.templatePrefab); try { float tplScale = root.transform.localScale.x; root.transform.localScale = Vector3.one; log.Append(" · 템플릿 높이 " + (Measure(root, root.transform).max.y * tplScale).ToString("F2") + " m"); var removed = new List(); for (int i = root.transform.childCount - 1; i >= 0; i--) { var c = root.transform.GetChild(i); if (c.name == "Center") continue; removed.Add(c.name); Object.DestroyImmediate(c.gameObject); } var model = (GameObject)PrefabUtility.InstantiatePrefab(src, root.transform); PrefabUtility.UnpackPrefabInstance(model, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); model.transform.localPosition = Vector3.zero; model.transform.localRotation = Quaternion.identity; model.transform.localScale = Vector3.one; // ── 판정 시점(정규화) · 앞 뻗음 ── var srcIdle = byName[e.idle]; float atkN = e.attackEvent >= 0f ? e.attackEvent : PeakForward(model, byName[e.attack], out _); float sklN = e.skillEvent >= 0f ? e.skillEvent : PeakForward(model, byName[e.castingEnd], out _); Pose(model, srcIdle, 0f); var b = Measure(model, model.transform); PeakForward(model, byName[e.attack], out float reach); Pose(model, srcIdle, 0f); // ── ① 클립 복사본 ── var clips = new Dictionary(); foreach (var s in States) { var srcClip = byName[want[s]]; tplClips.TryGetValue(s, out var tpl); var evs = new List(); if (tpl != null) foreach (var te in AnimationUtility.GetAnimationEvents(tpl)) { float n = s == "attack" ? atkN : s == "casting_end" ? sklN : (tpl.length > 0f ? te.time / tpl.length : 0f); evs.Add(new AnimationEvent { functionName = te.functionName, intParameter = te.intParameter, floatParameter = te.floatParameter, stringParameter = te.stringParameter, objectReferenceParameter = te.objectReferenceParameter, time = Mathf.Clamp01(n) * srcClip.length }); } bool loop = s == "run" || s == "idle" || s == "stun" || s == "casting"; clips[s] = CopyClip(srcClip, dir + "/" + s + ".anim", loop, evs.ToArray()); } // ── ② 컨트롤러 ── var ctrlPath = dir + "/" + name + ".controller"; if (AssetDatabase.LoadAssetAtPath(ctrlPath) != null) AssetDatabase.DeleteAsset(ctrlPath); var ac = UAC.CreateAnimatorControllerAtPath(ctrlPath); ac.AddParameter("animation", AnimatorControllerParameterType.Int); var sm = ac.layers[0].stateMachine; var states = new Dictionary(); for (int i = 0; i < States.Length; i++) { var st = sm.AddState(States[i], new Vector3(300f, i * 60f, 0f)); st.motion = clips[States[i]]; st.writeDefaultValues = true; states[States[i]] = st; } sm.defaultState = states["idle"]; ToIdle(states["attack"], states["idle"], e.attackExit, cfg.transitionSeconds); ToIdle(states["hit"], states["idle"], e.hitExit, cfg.transitionSeconds); ToIdle(states["casting_end"], states["idle"], e.attackExit, cfg.transitionSeconds); EditorUtility.SetDirty(ac); // ── ③ 프리팹 ── var kids = new List(); foreach (Transform c in model.transform) kids.Add(c); foreach (var c in kids) c.SetParent(root.transform, false); Object.DestroyImmediate(model); var an = root.GetComponent(); an.avatar = srcAnim.avatar; an.runtimeAnimatorController = ac; an.applyRootMotion = false; if (root.GetComponent() == null) root.AddComponent(); // 켜진 뒤 몸 크기(ObjSize) 보정 // 번들 메시의 렌더러 경계 상자는 실제 정점보다 훨씬 크다(몬스터 플랜트 높이 ≈ 2 배) — 원본 Actor.Set 이 몸 크기(ObjSize = 렌더러 bounds)로 // 몸 중심 높이(투사체 출발점 · Get_Center_position)를 정하므로 대기 자세의 실제 경계로 맞춘다. // (공격 · 피격 클립까지 합치면 돌진 · 젖힘 자세 때문에 플랜트 높이 4.15 · 깊이 4.56 단위로 다시 부풀어 대기만 쓴다 — 컬링은 경계가 화면에 걸리면 메시 전체를 그려 영향 없음) FitBounds(root, new[] { clips["idle"] }); Pose(root, clips["idle"], 0f); var box = root.GetComponent(); if (box != null) { box.center = b.center; box.size = new Vector3(b.size.x * e.colliderWidthScale, b.size.y, b.size.z * e.colliderWidthScale); } var ag = root.GetComponent(); if (ag != null) { ag.radius = Mathf.Max(b.size.x, b.size.z) * e.agentRadiusRatio; ag.height = b.max.y; } var center = root.transform.Find("Center"); if (center != null) center.localPosition = new Vector3(0f, b.center.y, 0f); float sc = e.scale > 0f ? e.scale : 1f; root.transform.localScale = Vector3.one * sc; PrefabUtility.SaveAsPrefabAsset(root, outPrefab, out bool ok); int tris = root.GetComponentsInChildren(true).Sum(r => r.sharedMesh ? r.sharedMesh.triangles.Length / 3 : 0); log.Append(" · 저장 " + ok + " · 지운 자식 [" + string.Join(",", removed) + "] · 원본 높이 " + b.size.y.ToString("F2") + " → ×" + sc + " = " + (b.max.y * sc).ToString("F2") + " m") .Append(" · 폭 " + (Mathf.Max(b.size.x, b.size.z) * sc).ToString("F2") + " m · 공격 앞 뻗음 " + (reach * sc).ToString("F2") + " m · 판정 " + atkN.ToString("F2") + " / 스킬 " + sklN.ToString("F2")) .Append(" · 클립 길이 공격 " + clips["attack"].length.ToString("F2") + " s · 피격 " + clips["hit"].length.ToString("F2") + " s · 삼각형 " + tris); } finally { PrefabUtility.UnloadPrefabContents(root); } return log.ToString(); } static void ToIdle(UState from, UState to, float exit, float seconds) { var t = from.AddTransition(to); t.hasExitTime = true; t.exitTime = exit; t.hasFixedDuration = true; t.duration = seconds; } static AnimationClip CopyClip(AnimationClip src, string path, bool loop, AnimationEvent[] events) { var c = Object.Instantiate(src); c.name = Path.GetFileNameWithoutExtension(path); var s = AnimationUtility.GetAnimationClipSettings(c); s.loopTime = loop; AnimationUtility.SetAnimationClipSettings(c, s); AnimationUtility.SetAnimationEvents(c, events); var old = AssetDatabase.LoadAssetAtPath(path); if (old == null) { AssetDatabase.CreateAsset(c, path); return c; } EditorUtility.CopySerialized(c, old); old.name = c.name; Object.DestroyImmediate(c); EditorUtility.SetDirty(old); return old; } /// 클립을 Samples 등분 샘플 — 정점이 space 의 +Z 로 가장 멀리 간 순간(정규화)과 그 거리. static float PeakForward(GameObject model, AnimationClip clip, out float reach) { float best = float.MinValue; int bi = 0; for (int i = 0; i <= Samples; i++) { Pose(model, clip, clip.length * i / Samples); float z = Measure(model, model.transform).max.z; if (z > best) { best = z; bi = i; } } reach = best; return Mathf.Clamp((float)bi / Samples, 0.05f, 0.9f); } /// 렌더러마다 경계 기준 뼈(rootBone) = 몹 루트 · localBounds = 클립들을 12 등분 샘플한 정점 전체(몹 루트 공간) + 여유 10 %. /// 번들 루트 뼈(Body)는 몹 축과 틀어져 있어 그 공간의 상자를 월드로 돌리면 높이가 부풀어(슬라임 실측 ObjSize 높이 1.51 m · 실제 ≈ 0.7 m) 몹 루트 기준으로 바꾼다. /// (rootBone 은 경계 계산 기준일 뿐 — 뼈대 변형은 bones · bindpose 로 정해져 그대로다.) static void FitBounds(GameObject root, IEnumerable clips) { var mesh = new Mesh(); var space = root.transform; foreach (var r in root.GetComponentsInChildren(true)) { bool any = false; var b = new Bounds(); foreach (var c in clips) for (int i = 0; i <= 12; i++) { Pose(root, c, c.length * i / 12f); r.BakeMesh(mesh, true); var tr = r.transform; foreach (var v in mesh.vertices) { var p = space.InverseTransformPoint(tr.position + tr.rotation * v); if (!any) { b = new Bounds(p, Vector3.zero); any = true; } else b.Encapsulate(p); } } if (any) { b.Expand(b.size * 0.1f); r.rootBone = space; r.localBounds = b; } } Object.DestroyImmediate(mesh); } /// 게임과 같은 방식(Animator + 아바타 · 루트 모션 미적용)으로 clip 의 t 초 자세를 뼈에 쓴다. /// AnimationClip.SampleAnimation 은 곡선을 그대로 써서 번들 클립의 루트 뼈 이동(제자리로 빠지는 전진 · 점프)이 남아 크기 · 판정 시점이 틀어진다. static void Pose(GameObject go, AnimationClip clip, float t) { var an = go.GetComponent(); if (an == null) { clip.SampleAnimation(go, t); return; } an.applyRootMotion = false; var cull = an.cullingMode; an.cullingMode = AnimatorCullingMode.AlwaysAnimate; // 미리보기 장면엔 카메라가 없어 컬링되면 뼈에 안 쓴다 var g = PlayableGraph.Create("WLMobBuilder"); try { g.SetTimeUpdateMode(DirectorUpdateMode.Manual); var o = AnimationPlayableOutput.Create(g, "pose", an); var p = AnimationClipPlayable.Create(g, clip); p.SetApplyFootIK(false); o.SetSourcePlayable(p); p.SetTime(t); g.Evaluate(); } finally { g.Destroy(); an.cullingMode = cull; } } static Bounds Measure(GameObject model, Transform space) { var mesh = new Mesh(); bool any = false; var b = new Bounds(); foreach (var r in model.GetComponentsInChildren(true)) { r.BakeMesh(mesh, true); var tr = r.transform; foreach (var v in mesh.vertices) { var p = space.InverseTransformPoint(tr.position + tr.rotation * v); if (!any) { b = new Bounds(p, Vector3.zero); any = true; } else b.Encapsulate(p); } } Object.DestroyImmediate(mesh); return b; } static void EnsureFolder(string path) { if (AssetDatabase.IsValidFolder(path)) return; var parent = Path.GetDirectoryName(path).Replace('\\', '/'); EnsureFolder(parent); AssetDatabase.CreateFolder(parent, Path.GetFileName(path)); } } }