Project_WL/Assets/WL/PartSkill/WLPartSkillClone.cs

215 lines
13 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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<string> stripChildren = new List<string>();
public List<string> shaderNames = new List<string>();
}
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<Vector3> onImpact;
}
sealed class Req { public Actor target; public string anim; public System.Action<Vector3> onImpact; }
static readonly List<Inst> s_pool = new List<Inst>();
// 기본 공격 타격은 물리 충돌 콜백 안에서 들어온다 → 그 안에서는 DestroyImmediate 가 막혀 복제본의 스크립트를 못 뗀다
// (#881 실측: 원본 WLStatusRunner 가 살아남아 오류 997 건). 새 분신은 요청만 쌓고 러너 Update(Tick) 에서 만든다.
static readonly List<Req> s_pending = new List<Req>();
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();
}
/// <summary>PC 를 복제해 분신 1개를 만든다(비활성 부모 아래에서 복제 → Awake · OnEnable 이 돌지 않는다).
/// 러너 Update 에서만 부른다. 스크립트 · 충돌체가 하나라도 남으면 버리고 null(살아 있는 PC 복제본을 켜지 않는다).</summary>
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<MonoBehaviour>(true)) { try { Object.DestroyImmediate(mb); } catch (System.Exception ex) { err = ex.Message; } }
foreach (var c in go.GetComponentsInChildren<UnityEngine.AI.NavMeshAgent>(true)) { try { Object.DestroyImmediate(c); } catch (System.Exception ex) { err = ex.Message; } }
foreach (var c in go.GetComponentsInChildren<Rigidbody>(true)) { try { Object.DestroyImmediate(c); } catch (System.Exception ex) { err = ex.Message; } }
foreach (var c in go.GetComponentsInChildren<Collider>(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<MonoBehaviour>(true).Length, leftCol = go.GetComponentsInChildren<Collider>(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<Animator>(), mpb = new MaterialPropertyBlock() };
if (inst.anim != null) { inst.anim.fireEvents = false; inst.anim.applyRootMotion = false; inst.anim.cullingMode = AnimatorCullingMode.AlwaysAnimate; }
inst.rends = go.GetComponentsInChildren<Renderer>(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;
}
/// <summary>대상 옆에 분신을 세워 animState 를 재생 · impactSec 에 onImpact(내려찍은 지점) 호출. 못 세우면 false.
/// 쉬는 분신이 있으면 바로 세우고, 새로 만들어야 하면 요청만 쌓아 다음 Tick 에서 만든다.</summary>
public static bool Spawn(Actor target, string animState, System.Action<Vector3> 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<Vector3> 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);
}
/// <summary>러너 Update 가 부른다 — 내려찍는 순간 · 사라지기.</summary>
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); } }
}
}