Project_WL/Assets/WL/Combat/Weapon/WLWeaponClasses.cs

242 lines
16 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// WLWeaponClasses.cs — WL 추가 직업(모션 세트) 런타임 등록 (#825 · 2026-10-09 · PD 「창을 넣어서 6종」)
//
// 힘민지 ClassConfig 에 없는 직업(창 10701)을 원본 표 파일을 고치지 않고 런타임에 보탠다:
// - Resources/WL/WLWeaponClasses.json 의 행 → 템플릿 직업 행(한손검 10101)을 복제해 ID·컨트롤러·무기 모델·사거리만 바꿔 table_classconfig 사전에 넣는다
// - 창 클립(Shinabro)에는 타격 이벤트가 없다 → 그 직업으로 바뀐 뒤 애니메이터 클립에 Projectile 이벤트를 런타임으로 붙인다(Hit1LookAtTarget AddEvent 선례 · 세션 동안 유지)
// 자산(컨트롤러·무기 프리팹·Addressables 등록)은 AgentScripts/WL825_SpearSetup.cs 가 에디터에서 1회 만든다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using System.Reflection;
using Newtonsoft.Json;
using UnityEngine;
namespace WL.Combat.Weapon
{
public class WLWeaponClassDef
{
public int classId; public int templateClassId = 10101; public string name = "";
/// <summary>true = 힘민지 기존 직업 행을 수정(무기 모델·사거리만) · false = 템플릿 복제로 새 직업 추가.</summary>
public bool overrideExisting;
public bool skillsFromTemplate; // #839 true = 템플릿 직업의 액티브 스킬을 이 직업이 쓴다(Actor.Use_Skill 훅 · 대검 10801)
public string animController = ""; public string weaponRHPrefab = ""; public int weaponRHIndex = 2; public string weaponLHPrefab = ""; public int weaponLHIndex = -1;
public float attackRange = 1.5f; public float attackSpd = -1f;
public List<string> attackClipTokens = new List<string>(); // 타격 이벤트를 붙일 클립 이름 조각
public List<float> projectileTimes = new List<float>(); // 클립별 이벤트 시점(0~1 · 길이 비율)
public string projectilePrefab = "Projectile_10101"; public float projectileAddDmg = 1f;
// #833 — 베기/찌르기 이펙트(ShowEffect · 검기 트레일 창 시작) 시점·프리팹(클립별 · 비우면 안 붙인다).
// 프리팹 이름은 존재하는 원본(템플릿 직업의 Effect_Slash_10101_N)을 쓴다 — 트레일 경로가 못 받으면 원본 ShowEffect 가 그 프리팹을 띄운다.
public List<float> effectTimes = new List<float>();
public List<string> effectPrefabs = new List<string>();
}
/// <summary>#834 무기 인스턴스 로컬 회전 보정(소켓 방향 규약이 팩 손 자세와 다를 때 · 원본 프리팹 0줄).</summary>
public class WLWeaponOrient { public string prefab = ""; public float[] euler; }
class WLWeaponClassesJson { public List<WLWeaponClassDef> classes; public List<WLWeaponOrient> weaponOrient; }
/// <summary>보정을 이미 적용한 무기 인스턴스 표식(중복 회전 방지).</summary>
public sealed class WLWeaponOrientMark : MonoBehaviour { public string rule = ""; }
public static class WLWeaponClasses
{
public static bool Loaded { get; private set; }
public static string LastLog { get; private set; } = "";
public static int Injected, EventsAdded, Overridden;
static readonly Dictionary<int, WLWeaponClassDef> s_defs = new Dictionary<int, WLWeaponClassDef>();
/// <summary>#839 — myClass 가 WL 추가 직업이고 json 에 skillsFromTemplate 가 켜져 있으면, 템플릿 직업(templateClassId)의 스킬(skillClass)을 허용한다. Actor.Use_Skill 훅 1줄이 부른다.</summary>
public static bool IsTemplateSkillClass(int myClass, int skillClass)
{
WLWeaponClassDef d;
if (!s_defs.TryGetValue(myClass, out d) || d == null || !d.skillsFromTemplate) return false;
return d.templateClassId > 0 && d.templateClassId == skillClass;
}
static readonly HashSet<int> s_eventsDone = new HashSet<int>(); // 클립 인스턴스 ID
static readonly HashSet<int> s_overridden = new HashSet<int>(); // 수정이 끝난 기존 직업 ID
static readonly HashSet<int> s_modelRefreshed = new HashSet<int>(); // 모델 재부착이 끝난 PC 인스턴스 ID
static readonly List<WLWeaponOrient> s_orients = new List<WLWeaponOrient>(); // #834 무기 방향 보정 규칙
public static int OrientApplied;
/// <summary>
/// #834 — 소켓에 붙은 무기 인스턴스에 표의 로컬 회전을 1회 더한다(PD 「활이 던지는 느낌」 = 활이 팔 방향으로 누움 → X+90°).
/// Change_Weapon 이 새 인스턴스를 만들 때마다 표식이 없으므로 다시 적용된다. 매 폴링마다 불러도 싸다(소켓 7개 자식 훑기).
/// </summary>
public static int EnsureWeaponOrient(PCActor pc)
{
EnsureLoaded();
if (pc == null || pc.tfs_weapon == null || s_orients.Count == 0) return 0;
int n = 0;
for (int s = 0; s < pc.tfs_weapon.Length; s++)
{
var socket = pc.tfs_weapon[s];
if (socket == null) continue;
for (int c = 0; c < socket.childCount; c++)
{
var child = socket.GetChild(c);
if (child == null || child.GetComponent<WLWeaponOrientMark>() != null) continue;
for (int r = 0; r < s_orients.Count; r++)
{
var o = s_orients[r];
if (o == null || string.IsNullOrEmpty(o.prefab) || o.euler == null || o.euler.Length < 3 || !child.name.StartsWith(o.prefab)) continue;
child.localRotation = Quaternion.Euler(o.euler[0], o.euler[1], o.euler[2]) * child.localRotation;
child.gameObject.AddComponent<WLWeaponOrientMark>().rule = o.prefab;
OrientApplied++; n++;
LastLog = "무기 방향 보정 " + child.name + " @" + socket.name + " euler(" + o.euler[0] + "," + o.euler[1] + "," + o.euler[2] + ")";
break;
}
}
}
return n;
}
/// <summary>기존 직업 행을 바꿨는가(무기 모델 재부착이 필요한 직업).</summary>
public static bool IsOverridden(int classId) { return s_overridden.Contains(classId); }
/// <summary>
/// 수정된 직업으로 이미 살아 있는 PC(시작 직업 10501 등)는 무기 모델이 원래 행으로 붙어 있다 → Set_Obj 로 한 번 다시 붙인다
/// (섬은 로비 맵이라 WLIslandAvatar.ApplyCombatForm 과 같이 호출 동안만 Stage 로). PC 인스턴스당 1회.
/// </summary>
public static bool RefreshModelIfNeeded(PCActor pc, int classId)
{
if (pc == null || !s_overridden.Contains(classId)) return false;
int id = pc.GetInstanceID();
if (s_modelRefreshed.Contains(id)) return false;
s_modelRefreshed.Add(id);
var info = InGameInfo.Ins;
var gf = info != null ? typeof(InGameInfo).GetField("m_GameMode", BindingFlags.Instance | BindingFlags.NonPublic) : null;
object prev = gf != null ? gf.GetValue(info) : null;
bool flipped = false;
try
{
if (gf != null && prev is eGameMode && (eGameMode)prev == eGameMode.Lobby) { gf.SetValue(info, eGameMode.Stage); flipped = true; }
pc.Set_Obj();
}
catch (System.Exception ex) { LastLog = "모델 재부착 실패 — " + ex.Message; return false; }
finally { if (flipped) gf.SetValue(info, prev); }
LastLog = "직업 " + classId + " 무기 모델 재부착(수정 행)";
return true;
}
public static bool EnsureLoaded()
{
if (Loaded) return true;
var cfg = WLWeaponSettings.Instance;
string path = cfg != null && !string.IsNullOrEmpty(cfg.classesPath) ? cfg.classesPath : "WL/WLWeaponClasses";
try
{
var ta = Resources.Load<TextAsset>(path);
if (ta == null) { Loaded = true; LastLog = "추가 직업 표 없음(" + path + ") — 건너뜀"; return true; }
var j = JsonConvert.DeserializeObject<WLWeaponClassesJson>(ta.text);
s_defs.Clear();
if (j != null && j.classes != null) foreach (var d in j.classes) if (d != null && d.classId > 0) s_defs[d.classId] = d;
s_orients.Clear();
if (j != null && j.weaponOrient != null) s_orients.AddRange(j.weaponOrient);
Loaded = true;
LastLog = "추가 직업 표 로드 — " + s_defs.Count + " · 방향 보정 " + s_orients.Count;
}
catch (System.Exception ex) { LastLog = "추가 직업 표 로드 실패 — " + ex.Message; Debug.LogError("[WL-825] " + LastLog); Loaded = true; }
return true;
}
public static WLWeaponClassDef Def(int classId) { EnsureLoaded(); WLWeaponClassDef d; return s_defs.TryGetValue(classId, out d) ? d : null; }
/// <summary>표의 직업이 힘민지 ClassConfig 사전에 없으면 템플릿을 복제해 넣는다(멱등).</summary>
public static string Ensure()
{
EnsureLoaded();
if (s_defs.Count == 0 || table_classconfig.Ins == null) return LastLog;
var f = typeof(table_classconfig).GetField("dic_Data", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
var dic = f != null ? f.GetValue(table_classconfig.Ins) as Dictionary<int, ClassConfigTableData> : null;
if (dic == null) { LastLog = "ClassConfig 사전 접근 실패"; return LastLog; }
var clone = typeof(object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic);
foreach (var kv in s_defs)
{
var d = kv.Value;
if (d.overrideExisting)
{
// 기존 직업 행 수정(무기 모델·소켓·사거리) — PD 2026-10-09 「해머는 방패를 들고 있는 모션 → 두손 해머 모델」
ClassConfigTableData cur;
if (s_overridden.Contains(d.classId) || !dic.TryGetValue(d.classId, out cur) || cur == null) continue;
cur.s_WeaponRHPrefab = d.weaponRHPrefab ?? ""; cur.n_WeaponRHIndex = d.weaponRHIndex;
cur.s_WeaponLHPrefab = d.weaponLHPrefab ?? ""; cur.n_WeaponLHIndex = d.weaponLHIndex;
if (d.attackRange > 0f) cur.f_AttackRange = d.attackRange;
if (!string.IsNullOrEmpty(d.animController)) cur.s_AnimationController = d.animController;
s_overridden.Add(d.classId); Overridden++;
LastLog = "직업 " + d.classId + "(" + d.name + ") 행 수정 — RH " + d.weaponRHPrefab + "@" + d.weaponRHIndex + " · LH " + (string.IsNullOrEmpty(d.weaponLHPrefab) ? "없음" : d.weaponLHPrefab);
continue;
}
if (dic.ContainsKey(kv.Key)) continue;
ClassConfigTableData tpl;
if (!dic.TryGetValue(d.templateClassId, out tpl) || tpl == null) { LastLog = "템플릿 직업 없음 " + d.templateClassId; continue; }
var row = clone.Invoke(tpl, null) as ClassConfigTableData;
if (row == null) continue;
row.n_ClassID = d.classId;
if (!string.IsNullOrEmpty(d.animController)) row.s_AnimationController = d.animController;
row.s_WeaponRHPrefab = d.weaponRHPrefab ?? ""; row.n_WeaponRHIndex = d.weaponRHIndex;
row.s_WeaponLHPrefab = d.weaponLHPrefab ?? ""; row.n_WeaponLHIndex = d.weaponLHIndex;
if (d.attackRange > 0f) row.f_AttackRange = d.attackRange;
if (d.attackSpd > 0f) row.f_AttackSPD = d.attackSpd;
dic.Add(d.classId, row);
Injected++;
LastLog = "직업 " + d.classId + "(" + d.name + ") 등록 — 템플릿 " + d.templateClassId + " · " + d.animController;
var cfg = WLWeaponSettings.Instance;
if (cfg != null && cfg.verboseLog != 0) Debug.Log("[WL-825] " + LastLog);
}
return LastLog;
}
/// <summary>그 직업의 공격 클립에 타격 이벤트(Projectile · 프리팹 이름 · 피해 배율)를 런타임으로 붙인다. 컨트롤러가 아직 안 올라왔으면 false.</summary>
public static bool EnsureClipEvents(PCActor pc, int classId)
{
var d = Def(classId);
if (d == null || pc == null || d.attackClipTokens == null || d.attackClipTokens.Count == 0) return true;
var an = pc.GetComponentInChildren<Animator>();
var rac = an != null ? an.runtimeAnimatorController : null;
if (rac == null || !string.IsNullOrEmpty(d.animController) && !d.animController.Contains(rac.name)) return false; // 비동기 로드 전
var clips = rac.animationClips;
if (clips == null) return false;
int matched = 0;
for (int i = 0; i < clips.Length; i++)
{
var c = clips[i]; if (c == null) continue;
int ti = -1;
for (int k = 0; k < d.attackClipTokens.Count; k++) if (c.name.Contains(d.attackClipTokens[k])) { ti = k; break; }
if (ti < 0) continue;
matched++;
if (s_eventsDone.Contains(c.GetInstanceID())) continue;
bool has = false, hasFx = false;
var evs = c.events;
if (evs != null) for (int e = 0; e < evs.Length; e++) { if (evs[e].functionName == "Projectile") has = true; if (evs[e].functionName == "ShowEffect") hasFx = true; }
if (!has)
{
float t = d.projectileTimes != null && ti < d.projectileTimes.Count ? d.projectileTimes[ti] : 0.4f;
c.AddEvent(new AnimationEvent
{
functionName = "Projectile",
time = Mathf.Clamp(c.length * t, 0f, Mathf.Max(c.length - 0.0001f, 0f)),
stringParameter = d.projectilePrefab,
floatParameter = d.projectileAddDmg,
messageOptions = SendMessageOptions.DontRequireReceiver,
});
EventsAdded++;
}
// #833 — 베기/찌르기 이펙트(검기 트레일 창 시작). 표에 시점·프리팹이 있을 때만.
if (!hasFx && d.effectTimes != null && d.effectPrefabs != null && ti < d.effectTimes.Count && ti < d.effectPrefabs.Count && !string.IsNullOrEmpty(d.effectPrefabs[ti]))
{
c.AddEvent(new AnimationEvent
{
functionName = "ShowEffect",
time = Mathf.Clamp(c.length * d.effectTimes[ti], 0f, Mathf.Max(c.length - 0.0001f, 0f)),
stringParameter = d.effectPrefabs[ti],
intParameter = 1,
messageOptions = SendMessageOptions.DontRequireReceiver,
});
EventsAdded++;
}
s_eventsDone.Add(c.GetInstanceID());
}
return matched > 0;
}
}
}