// ───────────────────────────────────────────────────────────────────────────── // WLRangeRing.cs — 플레이어 공격 사거리 원(반투명 흰색) · 표시/소멸 // // PD 지시 #816 · 발주서 WL-816p // 「적 공격 가능한 사정거리에 있을 때는 플레이어의 주변에 공격 가능한 범위를 반투명한 흰색 원이 // 발생되고, 플레이어의 공격 사정거리 밖에 적이 3초 이상 있을 경우 흰색 원이 소멸되도록 해줘.」 // // ■ 반지름의 출처 (실측 · 추정 아님 · C45) // 게임이 「지금 이 자리에서 때릴 수 있는가」를 판정하는 식은 MyActor.Get_AttackReach / Update 다: // Assets/Script/Character/MyActor.cs:44 stop = m_NavStopDistance + eStat.AttackRange_Up // Assets/Script/Character/MyActor.cs:207 agent.stoppingDistance = m_NavStopDistance + AttackRange_Up // Assets/Script/Character/MyActor.cs:47 reach = stop + target.Get_ScaleSizeDist() + immediateAttackMargin // Assets/Script/Character/MyActor.cs:222 if (distToTarget > reach) 접근 / else 즉시 공격 // Assets/Script/Character/PCActor.cs:381 m_NavStopDistance = m_ClassData.f_AttackRange ← 테이블 SOT // Assets/ResWork/Table/Export/ClassConfig.json f_AttackRange (클래스별 값) // Assets/WL/Settings/Resources/WL/WLTargetingSettings.asset:19 immediateAttackMargin // 원본(Assets/Script/**)은 1줄도 고치지 않는다. Get_AttackReach 는 private 이라 // 같은 값을 **런타임 NavMeshAgent.stoppingDistance 에서 읽어** 쓴다. // // 🔴 stoppingDistance 는 상태에 따라 흔들린다 — 스킬 시전 중에는 f_SkillRange 로, // 덩치 큰 타겟 앞에서는 (타겟 agent.radius + 0.5) 로 잠깐 커진다(MyActor.cs:211~213). // 그래서 **양수 최솟값**(= 보정이 없는 평시 값 = f_AttackRange)을 기억해 원의 반지름으로 쓰고, // AttackRange_Up(버프)과 immediateAttackMargin 은 매번 다시 더한다. 상수는 0 개다. // // ■ 그리는 법 // URP Balanced 렌더러에 Decal 피처가 없어 DecalProjector 는 보이지 않는다 // (Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs:19). 그래서 815f 공격 예고 판과 같은 방식 — // 런타임 생성 바닥 메시 + URP Unlit 반투명 머티리얼(TelegraphDecal.MakeMat 와 같은 세팅) 1장. // 새 셰이더 0 · 새 에셋 0 · MeshRenderer 1 개(드로우콜 +1). // // ■ 섬에서는 0 // 적(MobActor)이 사거리 안에 들어온 적이 없으면 아무 것도 그리지 않는다. // 섬(FarmingIsland)에는 MobActor 가 없으므로 자동으로 0 이다(씬 이름 분기 없음). // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; using UnityEngine.SceneManagement; namespace WL.Combat.RangeRing { public sealed class WLRangeRing : MonoBehaviour { const string RootName = "__WLRangeRing"; static WLRangeRing s_ins; static bool s_sceneHooked; /// 진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 원만 끈다. public static bool RuntimeDisabled; // ── 검증용 실측 값(프로브가 읽는다) public static string LastInfo = "(none)"; public static float LastRadius; // 마지막으로 그린 반지름(m) public static float LastBaseStop; // 관측한 stoppingDistance 최솟값(= f_AttackRange) public static float LastShowTime = -1f; // 마지막 표시 시각(Time.time) public static float LastHideTime = -1f; // 마지막 완전 소멸 시각(Time.time) public static float LastInRangeTime = -1f; public static bool Visible; public static float Alpha; public static bool Exists { get { return s_ins != null; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { var s = WLRangeRingSettings.Instance; if (s != null && s.enabled) Ensure(); } public static void Ensure() { if (s_ins != null) return; if (!Application.isPlaying) return; var go = new GameObject(RootName); go.hideFlags = HideFlags.DontSave; DontDestroyOnLoad(go); s_ins = go.AddComponent(); if (!s_sceneHooked) { SceneManager.activeSceneChanged += OnSceneChanged; s_sceneHooked = true; } } static void OnSceneChanged(Scene from, Scene to) { if (s_ins == null) return; s_ins._pc = null; s_ins._agent = null; s_ins._baseStop = 0f; s_ins._lastInRange = -9999f; s_ins._alpha = 0f; s_ins.Apply(0f, 0f); } // ── 상태 PCActor _pc; NavMeshAgent _agent; float _baseStop; // 관측한 stoppingDistance 양수 최솟값 = f_AttackRange float _nextPoll; float _lastInRange = -9999f; // 마지막으로 사거리 안에 적이 있던 시각 float _alpha; float _radius; bool _wasVisible; Transform _disc; MeshRenderer _mr; Material _mat; Mesh _mesh; int _meshSegments; bool _meshRingOnly; float _meshInner = -1f; int _colorPropId; bool _hasColorProp; bool _shaderTried; Shader _shader; void Update() { Tick(Time.time, Time.deltaTime); } void OnDisable() { Apply(0f, 0f); } void OnApplicationQuit() { Apply(0f, 0f); } /// 프로브(배치·에디트)용 — 같은 틱을 1회 돌린다. public static void ForceTick(float now, float delta) { if (s_ins != null) s_ins.Tick(now, delta); } void Tick(float now, float delta) { var s = WLRangeRingSettings.Instance; if (s == null || !s.enabled || RuntimeDisabled) { FadeTo(0f, now, delta, 0f); return; } // ── 적 검사(주기) ───────────────────────────────────────────── if (now >= _nextPoll) { _nextPoll = now + Mathf.Max(0.02f, s.pollInterval); Poll(s, now); } // ── 표시 여부 : 사거리 안에 적이 있었고, 그 뒤 graceSeconds 가 지나지 않았다 ── float grace = Mathf.Max(0f, s.graceSeconds); bool want = _pc != null && !_pc.IsDead() && _radius > 0f && _lastInRange > -9998f && (now - _lastInRange) <= grace; FadeTo(want ? Mathf.Clamp01(s.color.a) : 0f, now, delta, want ? s.fadeIn : s.fadeOut); // ── 위치 추종(매 프레임) ───────────────────────────────────── if (_alpha > 0f && _pc != null && _disc != null) { EnsureMesh(s); var p = _pc.Get_position(); _disc.position = new Vector3(p.x, p.y + s.yOffset, p.z); _disc.localScale = new Vector3(_radius, 1f, _radius); LastRadius = _radius; } } void Poll(WLRangeRingSettings s, float now) { // ① 메인 PC var pc = MyValue.MyPC; if (pc == null || pc.IsDead()) { _pc = null; _agent = null; return; } if (!ReferenceEquals(pc, _pc)) { _pc = pc; _agent = pc.Get_Agent(); _baseStop = 0f; } if (_agent == null) _agent = pc.Get_Agent(); // ② 사거리(실측) — stoppingDistance 의 양수 최솟값 = 보정 없는 f_AttackRange if (_agent != null) { float sd = _agent.stoppingDistance; if (sd > 0.01f && (_baseStop <= 0f || sd < _baseStop)) _baseStop = sd; } if (_baseStop <= 0f) { _radius = 0f; return; } float up = 0f; var stat = pc.Get_StatInfo(); if (stat != null) up = (float)stat.Get_Stat(eStat.AttackRange_Up); float margin = WL.Settings.WLTargetingSettings.ImmediateAttackMargin; float baseReach = _baseStop + up; // MyActor.cs:44 의 stop float drawReach = baseReach + margin; // MyActor.cs:47 의 reach (타겟 보정 제외) _radius = Mathf.Max(0f, drawReach * s.radiusScale + s.radiusBonus); LastBaseStop = _baseStop; // ③ 사거리 안에 살아 있는 적(MobActor · 보스 포함)이 있는가 var info = ActorInfo.Ins; if (info == null) return; float search = _radius + Mathf.Max(0f, s.searchPad) + 1f; List list = info.Get_EnemyActors(pc.IsEnemy(), pc.Get_position(), search); bool inRange = false; if (list != null) { var me = pc.Get_position(); for (int i = 0; i < list.Count; i++) { var a = list[i]; if (a == null || a.IsDead()) continue; if (!(a is MobActor)) continue; // 적 = MobActor(보스 포함) // MyActor.Get_AttackReach(MyActor.cs:42~48) 와 같은 식 float stop = baseReach; var ag = a.Get_Agent(); if (ag != null && ag.radius > stop) stop = ag.radius + 0.5f; float reach = stop + a.Get_ScaleSizeDist() + margin; var d = a.Get_position() - me; d.y = 0f; if (d.sqrMagnitude <= reach * reach) { inRange = true; break; } } info.Release_Actors(list); } if (inRange) { _lastInRange = now; LastInRangeTime = now; } } void FadeTo(float target, float now, float delta, float duration) { float prev = _alpha; if (duration <= 0.0001f) _alpha = target; else _alpha = Mathf.MoveTowards(_alpha, target, delta / duration); if (prev <= 0f && _alpha > 0f) { LastShowTime = now; _wasVisible = true; Visible = true; var s = WLRangeRingSettings.Instance; if (s != null && s.verboseLog) Debug.Log(string.Format("[WL-816p] 사거리 원 표시 t={0:F2} r={1:F2}m base={2:F2}", now, _radius, _baseStop)); } else if (prev > 0f && _alpha <= 0f && _wasVisible) { LastHideTime = now; _wasVisible = false; Visible = false; var s = WLRangeRingSettings.Instance; if (s != null && s.verboseLog) Debug.Log(string.Format("[WL-816p] 사거리 원 소멸 t={0:F2} (마지막 사거리 안 t={1:F2} · 경과 {2:F2}s)", now, LastInRangeTime, now - LastInRangeTime)); } Alpha = _alpha; Apply(_alpha, now); } void Apply(float alpha, float now) { if (alpha <= 0f) { if (_mr != null && _mr.enabled) _mr.enabled = false; return; } var s = WLRangeRingSettings.Instance; if (s == null) return; if (!EnsureVisual(s)) return; _mr.enabled = true; var c = new Color(s.color.r, s.color.g, s.color.b, alpha); if (_hasColorProp) _mat.SetColor(_colorPropId, c); _mat.color = c; } // ── 비주얼 생성 (815f TelegraphDecal 과 같은 방식) ─────────────── bool EnsureVisual(WLRangeRingSettings s) { if (_mr != null && _mat != null) { EnsureMesh(s); return true; } if (!EnsureShader(s)) return false; var go = new GameObject("Ring"); go.hideFlags = HideFlags.DontSave; go.transform.SetParent(transform, false); var mf = go.AddComponent(); _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; _mat = new Material(_shader); _mat.name = "__WLRangeRingMat"; _mat.hideFlags = HideFlags.DontSave; // URP Unlit 반투명 세팅(런타임) — 렌더링 배관이라 SO 튜닝 값이 아니다(TelegraphDecal.MakeMat 와 동일). if (_mat.HasProperty("_Surface")) _mat.SetFloat("_Surface", 1f); if (_mat.HasProperty("_Blend")) _mat.SetFloat("_Blend", 0f); if (_mat.HasProperty("_SrcBlend")) _mat.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha); if (_mat.HasProperty("_DstBlend")) _mat.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); if (_mat.HasProperty("_ZWrite")) _mat.SetFloat("_ZWrite", 0f); if (_mat.HasProperty("_AlphaClip")) _mat.SetFloat("_AlphaClip", 0f); if (_mat.HasProperty("_Cull")) _mat.SetFloat("_Cull", 0f); _mat.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); _mat.DisableKeyword("_ALPHATEST_ON"); _mat.SetOverrideTag("RenderType", "Transparent"); _mat.renderQueue = s.renderQueue > 0 ? s.renderQueue : 3040; _mr.sharedMaterial = _mat; _disc = go.transform; _meshInner = -1f; EnsureMesh(s); mf.sharedMesh = _mesh; LastInfo = "셰이더 " + _shader.name + " · 색 프로퍼티 " + (_hasColorProp ? "O" : "X"); return true; } bool EnsureShader(WLRangeRingSettings s) { if (_shader != null) return true; if (_shaderTried) return false; _shaderTried = true; var names = s.shaderNames; if (names != null) for (int i = 0; i < names.Length && _shader == null; i++) if (!string.IsNullOrEmpty(names[i])) _shader = Shader.Find(names[i]); if (_shader == null) { LastInfo = "셰이더 후보를 못 찾음 — 사거리 원 비활성"; return false; } var props = s.colorProperties; if (props != null) for (int i = 0; i < props.Length; i++) if (!string.IsNullOrEmpty(props[i]) && _shader.FindPropertyIndex(props[i]) >= 0) { _colorPropId = Shader.PropertyToID(props[i]); _hasColorProp = true; break; } return true; } /// 반지름 1 의 XZ 원판(또는 링) 메시. 스케일로 실제 반지름을 준다. void EnsureMesh(WLRangeRingSettings s) { int seg = Mathf.Clamp(s.segments, 12, 256); float inner = 0f; if (s.ringOnly && _radius > 0.01f) inner = Mathf.Clamp01(1f - Mathf.Max(0.01f, s.ringWidth) / _radius); inner = Mathf.Round(inner * 100f) / 100f; // 미세 변화로 매 프레임 재생성하지 않는다 if (_mesh != null && seg == _meshSegments && s.ringOnly == _meshRingOnly && Mathf.Approximately(inner, _meshInner)) return; _meshSegments = seg; _meshRingOnly = s.ringOnly; _meshInner = inner; if (_mesh == null) { _mesh = new Mesh(); _mesh.name = "__WLRangeRingMesh"; _mesh.hideFlags = HideFlags.DontSave; } _mesh.Clear(); if (!s.ringOnly || inner <= 0f) { var v = new Vector3[seg + 1]; var uv = new Vector2[seg + 1]; var n = new Vector3[seg + 1]; var t = new int[seg * 3]; v[0] = Vector3.zero; uv[0] = new Vector2(0.5f, 0.5f); n[0] = Vector3.up; for (int i = 0; i < seg; i++) { float a = (float)i / seg * Mathf.PI * 2f; float cx = Mathf.Cos(a), cz = Mathf.Sin(a); v[i + 1] = new Vector3(cx, 0f, cz); uv[i + 1] = new Vector2(cx * 0.5f + 0.5f, cz * 0.5f + 0.5f); n[i + 1] = Vector3.up; t[i * 3] = 0; t[i * 3 + 1] = i + 1; t[i * 3 + 2] = (i + 1) % seg + 1; } _mesh.vertices = v; _mesh.uv = uv; _mesh.normals = n; _mesh.triangles = t; } else { var v = new Vector3[seg * 2]; var uv = new Vector2[seg * 2]; var n = new Vector3[seg * 2]; var t = new int[seg * 6]; for (int i = 0; i < seg; i++) { float a = (float)i / seg * Mathf.PI * 2f; float cx = Mathf.Cos(a), cz = Mathf.Sin(a); v[i * 2] = new Vector3(cx * inner, 0f, cz * inner); v[i * 2 + 1] = new Vector3(cx, 0f, cz); uv[i * 2] = new Vector2(0f, (float)i / seg); uv[i * 2 + 1] = new Vector2(1f, (float)i / seg); n[i * 2] = Vector3.up; n[i * 2 + 1] = Vector3.up; int j = (i + 1) % seg; t[i * 6] = i * 2; t[i * 6 + 1] = i * 2 + 1; t[i * 6 + 2] = j * 2 + 1; t[i * 6 + 3] = i * 2; t[i * 6 + 4] = j * 2 + 1; t[i * 6 + 5] = j * 2; } _mesh.vertices = v; _mesh.uv = uv; _mesh.normals = n; _mesh.triangles = t; } _mesh.RecalculateBounds(); var mf = _disc != null ? _disc.GetComponent() : null; if (mf != null) mf.sharedMesh = _mesh; } } }