// ───────────────────────────────────────────────────────────────────────────── // TelegraphDecal.cs — 지면 예고 판(① 기준서 §G-1) · 판정 범위와 100 % 같은 크기 · 풀링 · GC 0 // // PD 지시 #815-5 · 발주서 WL-815f §1-2 // // ■ 왜 DecalProjector 가 아닌가 (기준서 §G-1 실측) // URP-Balanced-Renderer 의 RendererFeatures 는 **SSAO 1개뿐**이라 Decal Renderer Feature 가 없다 → // DecalProjector 는 화면에 아예 안 나온다. 보유 인디케이터 20종이 나오는 이유는 그것들이 **파티클/쿼드**이기 때문이다. // → 여기서도 같은 방식(런타임 쿼드 2장 · 새 셰이더/머티리얼/메시 에셋 0)을 쓴다. // // ■ 판 구성 (드로우 +2 / 판 · 동시 상한으로 묶는다) // 바깥 판(범위) = 콜라이더 크기 그대로 · 낮은 알파 → 「여기 맞는다」 // 안쪽 판(진행) = 같은 자리에서 0 → 100 % 로 **채워진다** · 높은 알파 → 「언제 맞는다」 // 두 판 모두 몹의 회전을 따라간다(원본 Play_Attack 이 transform.LookAt 으로 계속 돌린다). // // ■ 크기의 출처 = TelegraphShape (투사체 프리팹 BoxCollider) — 코드 상수 0. // 근접: size.x × size.z 사각형, 중심 = 발사 위치 + 회전된 collider.center 의 XZ. // 원거리: 폭 = size.x, 길이 = 투사체 속도 × 수명(기준서 §G-6 「직선 데칼」 · rangedMaxLength 로 자른다). // // ■ 색 = 위험 색 규약(기준서 §G-3): 노랑 = 피할 수 있다 · 빨강 = 못 피한다. // ■ IndicatorInfo.Show_Indicator 재사용 = SO 의 indicatorPrefabName 이 비어 있지 않을 때 덧칠로 켠다(§G-1 「원본 공개 API」). // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; namespace WL.Combat.Telegraph { public static class TelegraphDecal { sealed class Plate { public GameObject root; public Transform tf, baseTf, fillTf; public MeshRenderer baseMr, fillMr; public bool inUse; public bool ranged; public Vector3 localSize; // x = 폭 · z = 길이(실측 콜라이더에서 온다) public Vector3 localOffset; // 몹 로컬 기준 판 중심(콜라이더 center + 전방 오프셋) public GameObject indicator; // IndicatorInfo 풀에서 빌린 덧칠(없으면 null) } static Plate[] s_pool; static int s_inUse; static Mesh s_quad; static Material[] s_matBase, s_matFill; // [0] = Avoidable · [1] = Unavoidable static Shader s_shader; static int s_colorPropId; static bool s_hasColorProp, s_shaderTried; static Transform s_root; // ── 진단(프로브가 읽는다) public static int AcquireCount, ReleaseCount, BudgetSkipCount, PoolGrowCount, IndicatorBorrowCount, IndicatorMissCount; public static string LastInfo = ""; public static int InUse { get { return s_inUse; } } public static int PoolSize { get { return s_pool == null ? 0 : s_pool.Length; } } public static bool ShaderReady { get { return s_shader != null; } } public static string ShaderName { get { return s_shader == null ? "(없음)" : s_shader.name; } } static WLTelegraphSettings St { get { return WLTelegraphSettings.Instance; } } // ───────────────────────────────────────── 공개 API /// 판을 하나 켠다. 반환 = 풀 인덱스(-1 = 예산 초과 또는 비활성). public static int Acquire(Actor actor, in TelegraphBox box, bool ranged, float projectileLifetime, TelegraphDanger danger) { var st = St; if (st == null || !st.decalEnabled || actor == null) return -1; if (!EnsureShader(st)) return -1; if (s_inUse >= Mathf.Max(1, st.maxConcurrent)) { BudgetSkipCount++; return -1; } int idx = Take(st); if (idx < 0) return -1; var p = s_pool[idx]; // ── 크기(판정과 같은 값) ──────────────────────────────────────── float scale = st.sizeScale <= 0f ? 1f : st.sizeScale; float width = Mathf.Max(0.05f, box.size.x * scale); float length; if (ranged) { float travel = box.speed > 0f ? box.speed * Mathf.Max(0f, projectileLifetime) : box.size.z; length = Mathf.Clamp(travel * scale, box.size.z * scale, Mathf.Max(1f, st.rangedMaxLength)); } else length = Mathf.Max(0.05f, box.size.z * scale); p.ranged = ranged; p.localSize = new Vector3(width, 1f, length); // 판 중심 = 발사 위치(전방 forwardOffset) + 콜라이더 center 의 XZ. // 원거리는 투사체가 앞으로 나아가므로 진행 구간의 한가운데로 민다. float centerZ = st.forwardOffset + box.center.z * scale + (ranged ? length * 0.5f : 0f); p.localOffset = new Vector3(box.center.x * scale, 0f, centerZ); int di = danger == TelegraphDanger.Unavoidable ? 1 : 0; p.baseMr.sharedMaterial = s_matBase[di]; p.fillMr.sharedMaterial = s_matFill[di]; p.baseTf.localScale = new Vector3(width, 1f, length); p.root.SetActive(true); Follow(idx, actor); SetFill(idx, 0f); // ── IndicatorInfo.Show_Indicator 재사용(덧칠 · 원본 공개 API · 로드/풀링은 원본이 한다) BorrowIndicator(st, p, Mathf.Max(width, length)); AcquireCount++; return idx; } /// 몹의 현재 위치·회전을 따라간다(회전 추종). public static void Follow(int idx, Actor actor) { if (idx < 0 || s_pool == null || idx >= s_pool.Length) return; var p = s_pool[idx]; if (!p.inUse || actor == null) return; var st = St; if (st == null) return; var atf = actor.transform; Quaternion rot = st.followRotation ? Quaternion.Euler(0f, atf.eulerAngles.y, 0f) : p.tf.rotation; Vector3 pos = atf.position + rot * p.localOffset; pos.y = atf.position.y + st.groundYOffset; p.tf.SetPositionAndRotation(pos, rot); if (p.indicator != null) p.indicator.transform.SetPositionAndRotation(pos, rot); } /// 진행도 0~1 을 판에 반영한다(채워지는 연출). public static void SetFill(int idx, float t01) { if (idx < 0 || s_pool == null || idx >= s_pool.Length) return; var p = s_pool[idx]; if (!p.inUse) return; var st = St; if (st == null) return; float t = Mathf.Clamp01(t01); float w = p.localSize.x, l = p.localSize.z; if (st.fillMode == TelegraphFill.CenterExpand) { p.fillTf.localScale = new Vector3(w * t, 1f, l * t); p.fillTf.localPosition = Vector3.zero; } else // ForwardSweep — 몹 쪽 가장자리에서 앞으로 채워진다 { p.fillTf.localScale = new Vector3(w, 1f, Mathf.Max(0.0001f, l * t)); p.fillTf.localPosition = new Vector3(0f, 0.01f, -l * 0.5f + l * t * 0.5f); } } /// 판을 끈다(풀 반납). public static void Release(int idx) { if (idx < 0 || s_pool == null || idx >= s_pool.Length) return; var p = s_pool[idx]; if (!p.inUse) return; p.inUse = false; s_inUse--; if (p.root != null) p.root.SetActive(false); if (p.indicator != null) { p.indicator.SetActive(false); p.indicator = null; } ReleaseCount++; } /// 전부 끈다(씬 전환 · C8 · 프로브 정리). public static void ReleaseAll() { if (s_pool == null) return; for (int i = 0; i < s_pool.Length; i++) Release(i); s_inUse = 0; } /// 풀 오브젝트까지 파기한다(프로브 종료 · 에디트 모드 잔재 0). public static void DestroyAll() { ReleaseAll(); if (s_pool != null) for (int i = 0; i < s_pool.Length; i++) if (s_pool[i] != null && s_pool[i].root != null) DestroyObj(s_pool[i].root); s_pool = null; if (s_root != null) { DestroyObj(s_root.gameObject); s_root = null; } if (s_matBase != null) for (int i = 0; i < s_matBase.Length; i++) if (s_matBase[i] != null) DestroyObj(s_matBase[i]); if (s_matFill != null) for (int i = 0; i < s_matFill.Length; i++) if (s_matFill[i] != null) DestroyObj(s_matFill[i]); if (s_quad != null) DestroyObj(s_quad); s_matBase = s_matFill = null; s_quad = null; s_shader = null; s_shaderTried = false; } public static void ResetDiagnostics() { AcquireCount = ReleaseCount = BudgetSkipCount = PoolGrowCount = IndicatorBorrowCount = IndicatorMissCount = 0; LastInfo = ""; } /// 프로브용 — 판의 월드 크기(x = 폭 · z = 길이)와 중심을 돌려준다(콜라이더 대조용). public static bool TryGetPlate(int idx, out Vector3 size, out Vector3 center, out float yaw) { size = Vector3.zero; center = Vector3.zero; yaw = 0f; if (idx < 0 || s_pool == null || idx >= s_pool.Length) return false; var p = s_pool[idx]; if (!p.inUse) return false; size = new Vector3(p.baseTf.lossyScale.x, 0f, p.baseTf.lossyScale.z); center = p.tf.position; yaw = p.tf.eulerAngles.y; return true; } // ───────────────────────────────────────── 내부 static int Take(WLTelegraphSettings st) { EnsurePool(st); for (int i = 0; i < s_pool.Length; i++) if (!s_pool[i].inUse && s_pool[i].root != null) { s_pool[i].inUse = true; s_inUse++; return i; } return -1; } static void EnsurePool(WLTelegraphSettings st) { int want = Mathf.Max(1, Mathf.Max(st.maxConcurrent, st.poolWarmCount)); if (s_pool != null && s_pool.Length >= want) return; if (s_root == null) { var rootGo = new GameObject("__WLTelegraphDecals"); rootGo.hideFlags = HideFlags.DontSave; s_root = rootGo.transform; if (Application.isPlaying) Object.DontDestroyOnLoad(rootGo); } int from = s_pool == null ? 0 : s_pool.Length; var next = new Plate[want]; if (s_pool != null) System.Array.Copy(s_pool, next, s_pool.Length); for (int i = from; i < want; i++) next[i] = MakePlate(i); s_pool = next; PoolGrowCount++; } static Plate MakePlate(int i) { var p = new Plate(); p.root = new GameObject("WLTelegraphPlate_" + i); p.root.hideFlags = HideFlags.DontSave; p.root.transform.SetParent(s_root, false); p.tf = p.root.transform; p.baseTf = MakeQuad("Range", p.tf, out p.baseMr); p.fillTf = MakeQuad("Fill", p.tf, out p.fillMr); p.fillTf.localPosition = new Vector3(0f, 0.01f, 0f); p.root.SetActive(false); return p; } static Transform MakeQuad(string name, Transform parent, out MeshRenderer mr) { var go = new GameObject(name); go.hideFlags = HideFlags.DontSave; go.transform.SetParent(parent, false); var mf = go.AddComponent(); mf.sharedMesh = EnsureQuad(); mr = go.AddComponent(); mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; mr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; mr.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion; return go.transform; } /// XZ 평면 1×1 쿼드(위를 본다). 내장 Quad.fbx 의존 0 · 메시 에셋 0. static Mesh EnsureQuad() { if (s_quad != null) return s_quad; var m = new Mesh(); m.name = "__WLTelegraphQuad"; m.hideFlags = HideFlags.DontSave; m.vertices = new[] { new Vector3(-0.5f, 0f, -0.5f), new Vector3(-0.5f, 0f, 0.5f), new Vector3( 0.5f, 0f, 0.5f), new Vector3( 0.5f, 0f, -0.5f), }; m.uv = new[] { new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(1f, 0f) }; m.normals = new[] { Vector3.up, Vector3.up, Vector3.up, Vector3.up }; m.triangles = new[] { 0, 1, 2, 0, 2, 3 }; m.RecalculateBounds(); s_quad = m; return m; } static bool EnsureShader(WLTelegraphSettings st) { if (s_shader != null && s_matBase != null) return true; if (s_shaderTried && s_shader == null) return false; s_shaderTried = true; var names = st.decalShaderNames; if (names != null) for (int i = 0; i < names.Length && s_shader == null; i++) if (!string.IsNullOrEmpty(names[i])) s_shader = Shader.Find(names[i]); if (s_shader == null) { LastInfo = "셰이더 후보를 못 찾음 — 데칼 비활성"; return false; } s_hasColorProp = false; var props = st.decalColorProperties; if (props != null) for (int i = 0; i < props.Length; i++) if (!string.IsNullOrEmpty(props[i]) && s_shader.FindPropertyIndex(props[i]) >= 0) { s_colorPropId = Shader.PropertyToID(props[i]); s_hasColorProp = true; break; } s_matBase = new Material[2]; s_matFill = new Material[2]; for (int d = 0; d < 2; d++) { Color c = d == 1 ? st.colorUnavoidable : st.colorAvoidable; s_matBase[d] = MakeMat(st, "__WLTelegraphBase" + d, c, st.plateAlpha); s_matFill[d] = MakeMat(st, "__WLTelegraphFill" + d, c, st.fillAlpha); } LastInfo = "셰이더 " + s_shader.name + " · 색 프로퍼티 " + (s_hasColorProp ? "O" : "X"); return true; } static Material MakeMat(WLTelegraphSettings st, string name, Color c, float alpha) { var m = new Material(s_shader); m.name = name; m.hideFlags = HideFlags.DontSave; // URP Unlit 반투명 세팅(런타임) — 렌더링 배관이라 SO 튜닝 값이 아니다(811gh 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); if (m.HasProperty("_AlphaClip")) m.SetFloat("_AlphaClip", 0f); if (m.HasProperty("_Cull")) m.SetFloat("_Cull", 0f); m.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); m.DisableKeyword("_ALPHATEST_ON"); m.SetOverrideTag("RenderType", "Transparent"); m.renderQueue = st.decalRenderQueue > 0 ? st.decalRenderQueue : 3050; var col = new Color(c.r, c.g, c.b, Mathf.Clamp01(alpha)); if (s_hasColorProp) m.SetColor(s_colorPropId, col); m.color = col; return m; } static void BorrowIndicator(WLTelegraphSettings st, Plate p, float footprint) { if (string.IsNullOrEmpty(st.indicatorPrefabName)) return; if (!IndicatorInfo.isIns || IndicatorInfo.Ins == null) { IndicatorMissCount++; return; } var plate = p; float want = footprint; IndicatorInfo.Ins.Make_Indicator(st.indicatorPrefabName, go => { if (go == null || !plate.inUse) return; go.transform.SetPositionAndRotation(plate.tf.position, plate.tf.rotation); float s = st.indicatorAutoScale ? IndicatorScale(go, want) : 1f; go.transform.localScale = new Vector3(s, s, s); go.SetActive(true); plate.indicator = go; IndicatorBorrowCount++; }); } /// 인디케이터의 파티클 시작 크기를 「고유 지름」으로 보고, 원하는 발자국 크기에 맞는 배수를 낸다(실측 · 811s 방식의 축약). static float IndicatorScale(GameObject go, float wantDiameter) { float native = 0f; var systems = go.GetComponentsInChildren(true); for (int i = 0; i < systems.Length; i++) { var main = systems[i].main; float sz = main.startSizeMultiplier * Mathf.Max(0.0001f, systems[i].transform.lossyScale.x); if (sz > native) native = sz; } if (native <= 0.0001f) return 1f; return Mathf.Clamp(wantDiameter / native, 0.05f, 20f); } static void DestroyObj(Object o) { if (o == null) return; if (Application.isPlaying) Object.Destroy(o); else Object.DestroyImmediate(o); } } }