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

277 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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));
}
}
}