215 lines
13 KiB
C#
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); } }
|
||
|
|
}
|
||
|
|
}
|