// ───────────────────────────────────────────────────────────────────────────── // WLPartSkillClone.cs — 해머 무기 시너지 「분신」 (PD 지시 #881 · 2026-10-16) // // PD 원문: 「해머 시너지 스킬 같은 경우, 플레이어가 해당 모션과 이펙트를 쓰는게 아니라 분신이 나와서 공격하는 형태의 스킬로 구현해줘.」 // - 분신 = 지금 PC 를 비활성 상태로 복제 → 스크립트 · 충돌체 · 내비 · HUD 자식 제거 → 반투명 분신 재질(공유 1장 · 색 = MaterialPropertyBlock). // PC 와 같은 애니메이터 컨트롤러로 「내려찍기」(무기 공격 표의 Area 단계 · 없으면 마지막 단계)를 재생 · 애니메이션 이벤트 끔. // - 대상 옆에 나타나 내려찍는 순간(impactSec) 360° 파동(FR 110011 수치 · 동작 = SkWeaponSyn) → life 뒤 흐려지며 사라진다. // - 모바일: 분신은 풀(maxAlive)에서 재사용 · PC · 컨트롤러가 바뀌면 다시 만든다 · 그림자 끔. // 값 = Resources/WL/PartSkill/WLPartSkill.json clone(C45). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; namespace WL.PartSkill { public sealed class WLPartSkillCloneCfg { public float[] tint = { 0.55f, 0.8f, 1f, 0.55f }; public float life = 1.0f, impactSec = 0.67f, fadeSec = 0.4f, offset = 1.3f, animSpeed = 1f, impactForward = 0.9f, spawnFxRadius = 0.8f; public int maxAlive = 2; public string spawnFx = "fx_hit_star"; public List stripChildren = new List(); public List shaderNames = new List(); } public static class WLPartSkillClone { sealed class Inst { public GameObject go; public Animator anim; public Renderer[] rends; public MaterialPropertyBlock mpb; public float t0; public bool busy, hit; public Vector3 impact; public System.Action onImpact; } sealed class Req { public Actor target; public string anim; public System.Action onImpact; } static readonly List s_pool = new List(); // 기본 공격 타격은 물리 충돌 콜백 안에서 들어온다 → 그 안에서는 DestroyImmediate 가 막혀 복제본의 스크립트를 못 뗀다 // (#881 실측: 원본 WLStatusRunner 가 살아남아 오류 997 건). 새 분신은 요청만 쌓고 러너 Update(Tick) 에서 만든다. static readonly List s_pending = new List(); static Object s_poolPc; static string s_poolCtrl; static Material s_mat; static bool s_matTried; static readonly int s_colorId = Shader.PropertyToID("_BaseColor"), s_colorId2 = Shader.PropertyToID("_Color"); public static int Spawned, Built, Impacts; public static string LastLog = ""; static WLPartSkillCloneCfg C { get { var c = WLPartSkillData.S.clone; return c ?? (WLPartSkillData.S.clone = new WLPartSkillCloneCfg()); } } static bool EnsureMaterial() { if (s_mat != null) return true; if (s_matTried) return false; s_matTried = true; Shader sh = null; foreach (var n in C.shaderNames) { if (string.IsNullOrEmpty(n)) continue; sh = Shader.Find(n); if (sh != null) break; } if (sh == null) { LastLog = "분신 셰이더 없음"; return false; } var m = new Material(sh) { name = "__WLCloneGhostMat", hideFlags = HideFlags.DontSave }; // URP Unlit 반투명(런타임 배관 값 · DashAfterImage 와 같은 방식) if (m.HasProperty("_Surface")) m.SetFloat("_Surface", 1f); if (m.HasProperty("_Blend")) m.SetFloat("_Blend", 0f); if (m.HasProperty("_SrcBlend")) m.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha); if (m.HasProperty("_DstBlend")) m.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); if (m.HasProperty("_ZWrite")) m.SetFloat("_ZWrite", 0f); m.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); m.SetOverrideTag("RenderType", "Transparent"); m.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent; s_mat = m; return true; } static Color Tint(float alphaMul) { var t = C.tint; var c = t != null && t.Length >= 4 ? new Color(t[0], t[1], t[2], t[3]) : new Color(0.55f, 0.8f, 1f, 0.55f); c.a *= Mathf.Clamp01(alphaMul); return c; } static void Clear() { foreach (var i in s_pool) if (i.go != null) Object.Destroy(i.go); s_pool.Clear(); s_pending.Clear(); } /// PC 를 복제해 분신 1개를 만든다(비활성 부모 아래에서 복제 → Awake · OnEnable 이 돌지 않는다). /// 러너 Update 에서만 부른다. 스크립트 · 충돌체가 하나라도 남으면 버리고 null(살아 있는 PC 복제본을 켜지 않는다). static Inst Build(PCActor pc) { var holder = new GameObject("WL_CloneHolder"); holder.SetActive(false); var go = Object.Instantiate(pc.gameObject, holder.transform); go.name = "WL_PartSkillClone"; string err = null; // 스크립트 → 내비 · 물리 → HUD 자식 순으로 제거(RequireComponent 순서 때문에 3번까지 돈다) for (int pass = 0; pass < 3; pass++) foreach (var mb in go.GetComponentsInChildren(true)) { try { Object.DestroyImmediate(mb); } catch (System.Exception ex) { err = ex.Message; } } foreach (var c in go.GetComponentsInChildren(true)) { try { Object.DestroyImmediate(c); } catch (System.Exception ex) { err = ex.Message; } } foreach (var c in go.GetComponentsInChildren(true)) { try { Object.DestroyImmediate(c); } catch (System.Exception ex) { err = ex.Message; } } foreach (var c in go.GetComponentsInChildren(true)) { try { Object.DestroyImmediate(c); } catch (System.Exception ex) { err = ex.Message; } } for (int i = go.transform.childCount - 1; i >= 0; i--) { var ch = go.transform.GetChild(i); if (C.stripChildren != null && C.stripChildren.Contains(ch.name)) Object.DestroyImmediate(ch.gameObject); } int leftMb = go.GetComponentsInChildren(true).Length, leftCol = go.GetComponentsInChildren(true).Length; if (leftMb > 0 || leftCol > 0) { LastLog = "분신 만들기 실패 — 남은 스크립트 " + leftMb + " · 충돌체 " + leftCol + (err != null ? " · " + err : ""); Debug.LogWarning("[WL-881] " + LastLog); Object.Destroy(holder); // 비활성 그대로 버린다 return null; } var inst = new Inst { go = go, anim = go.GetComponent(), mpb = new MaterialPropertyBlock() }; if (inst.anim != null) { inst.anim.fireEvents = false; inst.anim.applyRootMotion = false; inst.anim.cullingMode = AnimatorCullingMode.AlwaysAnimate; } inst.rends = go.GetComponentsInChildren(true); foreach (var r in inst.rends) { var mats = r.sharedMaterials; var arr = new Material[mats.Length]; for (int k = 0; k < arr.Length; k++) arr[k] = s_mat; r.sharedMaterials = arr; r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; r.receiveShadows = false; } go.SetActive(false); // 부모에서 떼기 전에 끈다(남은 컴포넌트도 깨어나지 않게) go.transform.SetParent(null, false); go.transform.localScale = pc.transform.lossyScale; Object.DontDestroyOnLoad(go); Object.Destroy(holder); Built++; return inst; } /// 대상 옆에 분신을 세워 animState 를 재생 · impactSec 에 onImpact(내려찍은 지점) 호출. 못 세우면 false. /// 쉬는 분신이 있으면 바로 세우고, 새로 만들어야 하면 요청만 쌓아 다음 Tick 에서 만든다. public static bool Spawn(Actor target, string animState, System.Action onImpact) { var pc = WLPartSkillCombat.PC; if (pc == null || target == null || pc.m_animation == null || !EnsureMaterial()) return false; string ctrl = pc.m_animation.runtimeAnimatorController != null ? pc.m_animation.runtimeAnimatorController.name : ""; if (!ReferenceEquals(s_poolPc, pc) || s_poolCtrl != ctrl) { Clear(); s_poolPc = pc; s_poolCtrl = ctrl; } Inst inst = null; foreach (var i in s_pool) if (!i.busy && i.go != null) { inst = i; break; } if (inst == null) { if (s_pool.Count + s_pending.Count >= Mathf.Max(1, C.maxAlive)) return false; s_pending.Add(new Req { target = target, anim = animState, onImpact = onImpact }); return true; } Place(inst, pc, target, animState, onImpact, ctrl); return true; } static void Place(Inst inst, PCActor pc, Actor target, string animState, System.Action onImpact, string ctrl) { var tp = target.Get_position(); var pcPos = pc.Get_position(); var dir = tp - pcPos; dir.y = 0f; if (dir.sqrMagnitude < 0.01f) dir = pc.transform.forward; dir.Normalize(); var side = Quaternion.Euler(0f, Random.value < 0.5f ? 90f : -90f, 0f) * dir; // 대상의 옆에서 나타난다 var pos = tp + side * C.offset; pos.y = tp.y; var face = tp - pos; face.y = 0f; if (face.sqrMagnitude < 0.01f) face = dir; inst.go.transform.SetPositionAndRotation(pos, Quaternion.LookRotation(face.normalized, Vector3.up)); inst.go.SetActive(true); if (inst.anim != null && !string.IsNullOrEmpty(animState)) { inst.anim.speed = C.animSpeed; inst.anim.Play(animState, 0, 0f); } inst.t0 = Time.time; inst.busy = true; inst.hit = false; inst.onImpact = onImpact; inst.impact = pos + face.normalized * C.impactForward; SetAlpha(inst, 1f); var run = WLPartSkillRunner.Instance; // 나타날 때 반짝임 — 반경(m) → 이펙트 배율(fxRadius 기준) if (!string.IsNullOrEmpty(C.spawnFx) && run != null) WLPartSkillFx.Play(C.spawnFx, pos + Vector3.up * 0.8f, run.FxScale(C.spawnFx, C.spawnFxRadius), 1f); Spawned++; LastLog = "분신 " + animState + " @" + pos.ToString("F1") + " ctrl=" + ctrl; } static void SetAlpha(Inst i, float a) { var c = Tint(a); i.mpb.SetColor(s_colorId, c); i.mpb.SetColor(s_colorId2, c); foreach (var r in i.rends) if (r != null) r.SetPropertyBlock(i.mpb); } /// 러너 Update 가 부른다 — 내려찍는 순간 · 사라지기. public static void Tick() { if (s_pending.Count > 0) { var pc = WLPartSkillCombat.PC; var reqs = s_pending.ToArray(); s_pending.Clear(); foreach (var q in reqs) { if (pc == null || q.target == null || !ReferenceEquals(s_poolPc, pc)) continue; var inst = Build(pc); if (inst == null) continue; s_pool.Add(inst); Place(inst, pc, q.target, q.anim, q.onImpact, s_poolCtrl); } } if (s_pool.Count == 0) return; float now = Time.time; foreach (var i in s_pool) { if (!i.busy || i.go == null) continue; float t = now - i.t0; if (!i.hit && t >= C.impactSec) { i.hit = true; Impacts++; try { i.onImpact?.Invoke(i.impact); } catch (System.Exception ex) { Debug.LogWarning("[WL-881] 분신 타격 예외 — " + ex.Message); } } if (t >= C.life) { float f = C.fadeSec > 0f ? 1f - (t - C.life) / C.fadeSec : 0f; if (f <= 0f) { i.busy = false; i.go.SetActive(false); } else SetAlpha(i, f); } } } public static void ReleaseAll() { s_pending.Clear(); foreach (var i in s_pool) { i.busy = false; if (i.go != null) i.go.SetActive(false); } } } }