Project_WL/Assets/WL/Mobs/Editor/WLMobBuilder.cs

277 lines
17 KiB
C#
Raw Permalink Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// WLMobBuilder.cs — 기본 몬스터 라인업 프리팹 생성기(에디터) (#907 · 2026-10-17)
//
// PD 원문: 「RPG Monster BUNDLE PBR 몬스터 에셋을 추가했어. 우리 게임의 기본 몬스터 구성을 이 에셋으로 구성해줘.」
// ■ 입력 = Resources/WL/WLMobLineup.json (런타임 라인업과 같은 파일 · C45)
// ■ 출력(항목마다)
// ① 클립 복사본 <generatedFolder>/<이름>/<상태>.anim — 번들 FBX 클립 + 템플릿 몹 클립의 이벤트(Event_Idle · Projectile · SkillEvent)
// ② 컨트롤러 <generatedFolder>/<이름>/<이름>.controller — 템플릿 몹 컨트롤러와 같은 상태 이름 8개 · 공격 / 피격 / 스킬 끝 → 대기 전이
// ③ 프리팹 Assets/Res_Addr/Mobs/<prefab>.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<TextAsset>(JsonAsset);
if (ta == null) return "JSON 없음 " + JsonAsset;
var cfg = JsonUtility.FromJson<WLMobLineupJson>(ta.text);
var template = AssetDatabase.LoadAssetAtPath<GameObject>(cfg.templatePrefab);
var tplAnim = template != null ? template.GetComponent<Animator>() : null;
var tplCtrl = tplAnim != null ? tplAnim.runtimeAnimatorController as UAC : null;
if (tplCtrl == null) return "템플릿 컨트롤러 없음 " + cfg.templatePrefab;
var tplClips = new Dictionary<string, AnimationClip>();
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<string, AnimationClip> tplClips)
{
var src = AssetDatabase.LoadAssetAtPath<GameObject>(e.source);
var srcAnim = src != null ? src.GetComponent<Animator>() : null;
if (srcAnim == null || srcAnim.runtimeAnimatorController == null) return e.monsterId + " 원본 애니메이터 없음 " + e.source;
var byName = new Dictionary<string, AnimationClip>();
foreach (var c in srcAnim.runtimeAnimatorController.animationClips) if (c != null && !byName.ContainsKey(c.name)) byName[c.name] = c;
var want = new Dictionary<string, string> { { "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<string>();
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<string, AnimationClip>();
foreach (var s in States)
{
var srcClip = byName[want[s]];
tplClips.TryGetValue(s, out var tpl);
var evs = new List<AnimationEvent>();
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<Object>(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<string, UState>();
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<Transform>();
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<Animator>();
an.avatar = srcAnim.avatar; an.runtimeAnimatorController = ac; an.applyRootMotion = false;
if (root.GetComponent<WLMobBodySize>() == null) root.AddComponent<WLMobBodySize>(); // 켜진 뒤 몸 크기(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<BoxCollider>();
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<UnityEngine.AI.NavMeshAgent>();
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<SkinnedMeshRenderer>(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<AnimationClip>(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;
}
/// <summary>클립을 Samples 등분 샘플 — 정점이 space 의 +Z 로 가장 멀리 간 순간(정규화)과 그 거리.</summary>
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);
}
/// <summary>렌더러마다 경계 기준 뼈(rootBone) = 몹 루트 · localBounds = 클립들을 12 등분 샘플한 정점 전체(몹 루트 공간) + 여유 10 %.
/// 번들 루트 뼈(Body)는 몹 축과 틀어져 있어 그 공간의 상자를 월드로 돌리면 높이가 부풀어(슬라임 실측 ObjSize 높이 1.51 m · 실제 ≈ 0.7 m) 몹 루트 기준으로 바꾼다.
/// (rootBone 은 경계 계산 기준일 뿐 — 뼈대 변형은 bones · bindpose 로 정해져 그대로다.)</summary>
static void FitBounds(GameObject root, IEnumerable<AnimationClip> clips)
{
var mesh = new Mesh();
var space = root.transform;
foreach (var r in root.GetComponentsInChildren<SkinnedMeshRenderer>(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);
}
/// <summary>게임과 같은 방식(Animator + 아바타 · 루트 모션 미적용)으로 clip 의 t 초 자세를 뼈에 쓴다.
/// AnimationClip.SampleAnimation 은 곡선을 그대로 써서 번들 클립의 루트 뼈 이동(제자리로 빠지는 전진 · 점프)이 남아 크기 · 판정 시점이 틀어진다.</summary>
static void Pose(GameObject go, AnimationClip clip, float t)
{
var an = go.GetComponent<Animator>();
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<SkinnedMeshRenderer>(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));
}
}
}