277 lines
17 KiB
C#
277 lines
17 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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));
|
||
}
|
||
}
|
||
}
|