// ─────────────────────────────────────────────────────────────────────────────
// 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);
}
}
}