// ───────────────────────────────────────────────────────────────────────────── // CombatAnimMapping.cs — 클래스 ↔ 전투 애니메이션 세트 매핑 데이터 (ScriptableObject) // // PD 지시 #760 (2026-09-06) 1단계 산출물 · **스테이징**. // 2단계에 PM 지시로 Assets/WL/Combat/ 로 옮기고 에셋(CombatAnimMapping.asset)을 만든다. // // 왜 ScriptableObject 인가 (C45 하드코딩 금지): // "어떤 클래스가 어떤 Shinabro 세트를 쓰는가" · "이벤트를 몇 초에 심는가" 는 // 설계 판단이지 코드 상수가 아니다. 세트 교체·타이밍 보정이 코드 재컴파일 없이 // 되어야 라이브에서 감당된다. 값의 SOT 는 이 에셋 하나다. // // 왜 클립 GUID 가 아니라 에셋 경로인가: // 에디터 전용 셋업 스크립트(WL_CombatAnimSetup)만 이 데이터를 읽는다. // 런타임은 이 에셋을 참조하지 않는다(컨트롤러가 이미 클립을 물고 있다). // 경로는 사람이 읽고 검증할 수 있고, 이동 시 즉시 에러로 드러난다. // ───────────────────────────────────────────────────────────────────────────── using System; using System.Collections.Generic; using UnityEngine; namespace WL.Combat { /// 애니메이션 이벤트 1개. 시간은 **클립 로컬 초**(정규화 아님 — Unity AnimationEvent 와 같은 단위). [Serializable] public sealed class AnimEventSpec { [Tooltip("함수명. Actor 계열의 public 메서드여야 한다: Projectile / ShowEffect / LookAtTarget / Move / Hit / SkillEvent / FootL / FootR")] public string functionName = "Projectile"; [Tooltip("클립 시작 기준 초. 0 이상 · 클립 길이 미만.")] public float time; [Tooltip("정규화 값으로 지정하고 싶을 때 켠다. 켜면 time 을 무시하고 normalizedTime × 클립길이 로 계산한다.")] public bool useNormalized; [Range(0f, 1f)] public float normalizedTime; [Tooltip("stringParameter. {CID} 는 대상 클래스 ID 로 치환된다. 예: Projectile_{CID} · Effect_Slash_{CID}_1")] public string stringParameter = ""; public int intParameter; public float floatParameter; /// {CID} 치환. 매핑 데이터를 클래스마다 복사하지 않기 위한 최소한의 템플릿이다. public string ResolveString(int classId) { return string.IsNullOrEmpty(stringParameter) ? string.Empty : stringParameter.Replace("{CID}", classId.ToString()); } public float ResolveTime(float clipLength) { return useNormalized ? normalizedTime * clipLength : time; } } /// 한 컨트롤러 상태에 물릴 클립 1개와 그 클립에 심을 이벤트들. [Serializable] public sealed class StateClipSpec { [Tooltip("AnimatorController 의 상태 이름. 실측 규약: attack / Attack2 / Attack3 / Bow_Attack2 / Sword&Shield_Attack2 …")] public string stateName = "attack"; [Tooltip("소스 FBX 에셋 경로 (Assets/ 부터). 예: Assets/Res_Addr/Animations/Animation/Paladin/attack.FBX")] public string sourceFbxPath = ""; [Tooltip("그 FBX 안에서 만들/쓸 서브클립 이름. {CID} 치환 가능. " + "예: Sword&Shield_Attack1_{CID}. 비우면 sourceClipName 을 그대로 쓴다(이벤트 편집 없음).")] public string subClipName = ""; [Tooltip("서브클립을 뜰 원본 클립 이름(임포터의 기존 clipAnimations 중 하나). " + "비우면 FBX 의 첫 클립을 쓴다.")] public string sourceClipName = ""; [Tooltip("이 상태로 들어오는 전이의 exitTime 을 이 값으로 맞춘다. 0 이면 건드리지 않는다. " + "실측: 힘민지 기존 콤보 구간 0.70~0.84 · 구 WL 규칙 0.78")] [Range(0f, 1f)] public float incomingExitTime; [Tooltip("새로 만들 상태인가. true 면 컨트롤러에 상태를 추가하고 직전 상태에서 전이를 잇는다.")] public bool createIfMissing; public List events = new List(); } /// 클래스 1개의 전투 애니메이션 매핑. [Serializable] public sealed class ClassCombatAnimSpec { [Tooltip("ClassConfig.json 의 n_ClassID")] public int classId; [Tooltip("사람이 읽기 위한 메모 (예: OneHand → 01_Sword&Shield)")] public string note = ""; [Tooltip("적용 대상 컨트롤러 경로. 비우면 ClassConfig 의 s_AnimationController 를 읽는다(권장 — SOT 이중화 방지).")] public string controllerPathOverride = ""; [Tooltip("끄면 이 클래스는 건드리지 않는다. 이미 WL 과 같은 세트인 클래스는 꺼 둔다.")] public bool enabled = true; public List states = new List(); } /// /// 전 클래스 전투 애니메이션 매핑 SOT. /// 에디터 셋업 스크립트(WL_CombatAnimSetup)만 읽는다 — 런타임 참조 없음. /// [CreateAssetMenu(fileName = "CombatAnimMapping", menuName = "WL/Combat Anim Mapping", order = 310)] public sealed class CombatAnimMapping : ScriptableObject { [Tooltip("이 매핑이 만들어진 근거 문서 (추적성 · P16)")] [TextArea(2, 4)] public string source = "PD 지시 #760 · AgentScripts/staging/WL_Combat2/MAPPING.md"; [Tooltip("컨트롤러 백업을 남길 폴더. 비우면 백업하지 않는다(권장하지 않음).")] public string backupRoot = "공유/개발팀_백업/WL/Animators_{yyyyMMdd_HHmm}"; [Tooltip("모든 전이 블렌드 시간(정규화). 실측: 힘민지 전 컨트롤러 0.25 로 통일돼 있다.")] public float transitionDuration = 0.25f; public List classes = new List(); public ClassCombatAnimSpec Find(int classId) { for (int i = 0; i < classes.Count; i++) if (classes[i] != null && classes[i].classId == classId) return classes[i]; return null; } } }