diff --git a/Assets/WL/Combat/RangeRing.meta b/Assets/WL/Combat/RangeRing.meta
new file mode 100644
index 000000000..7d661912a
--- /dev/null
+++ b/Assets/WL/Combat/RangeRing.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 816f0a1b2c3d4e5f60718293a4b5c6d7
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/WL/Combat/RangeRing/WLRangeRing.cs b/Assets/WL/Combat/RangeRing/WLRangeRing.cs
new file mode 100644
index 000000000..3e2a26063
--- /dev/null
+++ b/Assets/WL/Combat/RangeRing/WLRangeRing.cs
@@ -0,0 +1,382 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 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;
+ }
+ }
+}
diff --git a/Assets/WL/Combat/RangeRing/WLRangeRing.cs.meta b/Assets/WL/Combat/RangeRing/WLRangeRing.cs.meta
new file mode 100644
index 000000000..8bbefd539
--- /dev/null
+++ b/Assets/WL/Combat/RangeRing/WLRangeRing.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 816e1a2b3c4d5e6f708192a3b4c5d6e7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/WL/Combat/RangeRing/WLRangeRingSettings.cs b/Assets/WL/Combat/RangeRing/WLRangeRingSettings.cs
new file mode 100644
index 000000000..34c2d0e02
--- /dev/null
+++ b/Assets/WL/Combat/RangeRing/WLRangeRingSettings.cs
@@ -0,0 +1,102 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// WLRangeRingSettings.cs — 플레이어 공격 사거리 원(반투명 흰색)의 튜닝 값(SO)
+//
+// PD 지시 #816 · 발주서 WL-816p
+// 「적 공격 가능한 사정거리에 있을 때는 플레이어의 주변에 공격 가능한 범위를 반투명한 흰색 원이
+// 발생되고, 플레이어의 공격 사정거리 밖에 적이 3초 이상 있을 경우 흰색 원이 소멸되도록 해줘.」
+//
+// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLRangeRingSettings.asset
+// → Resources.Load("WL/WLRangeRingSettings") 로 1회 로드 후 캐시.
+// 에셋이 없거나 enabled = false 면 WLRangeRing 은 아무 것도 하지 않는다(원본 동작 100 %).
+//
+// 🔴 C45 — 사거리(반지름)는 이 에셋이 갖지 않는다. 게임이 실제 공격 판정에 쓰는 값
+// (NavMeshAgent.stoppingDistance = table_classconfig.f_AttackRange + eStat.AttackRange_Up,
+// + WLTargetingSettings.immediateAttackMargin)을 런타임에 읽는다. 여기 있는 것은 배율·가산뿐이다.
+//
+// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
+// ─────────────────────────────────────────────────────────────────────────────
+
+using UnityEngine;
+
+namespace WL.Combat.RangeRing
+{
+ [CreateAssetMenu(fileName = "WLRangeRingSettings", menuName = "WL/Range Ring Settings", order = 327)]
+ public sealed class WLRangeRingSettings : ScriptableObject
+ {
+ public const string ResourcesPath = "WL/WLRangeRingSettings";
+
+ static WLRangeRingSettings s_cached;
+ static bool s_lookupDone;
+
+ public static WLRangeRingSettings Instance
+ {
+ get
+ {
+ if (!s_lookupDone)
+ {
+ s_cached = Resources.Load(ResourcesPath);
+ s_lookupDone = true;
+ }
+ return s_cached;
+ }
+ }
+
+ /// 테스트용 — 에셋 캐시를 버린다(에디터에서 값을 바꾸고 다시 읽을 때).
+ public static void ClearCache() { s_cached = null; s_lookupDone = false; }
+
+ [Header("① 켜기")]
+ [Tooltip("0 이면 원을 만들지 않는다(원본 동작 100 %).")]
+ public bool enabled = true;
+
+ [Tooltip("1 이면 표시/소멸 시각을 Debug.Log 로 남긴다(검증용).")]
+ public bool verboseLog = false;
+
+ [Header("② 모양 · 색")]
+ [Tooltip("원의 색. a = 반투명 정도(PD: 반투명한 흰색).")]
+ public Color color = new Color(1f, 1f, 1f, 0.25f);
+
+ [Tooltip("1 이면 테두리 링만, 0 이면 원판을 채운다.")]
+ public bool ringOnly = false;
+
+ [Tooltip("ringOnly = 1 일 때 링 두께(m).")]
+ public float ringWidth = 0.18f;
+
+ [Tooltip("원의 분할 수(많을수록 매끈하고 삼각형이 늘어난다). 모바일 예산 = 64.")]
+ [Range(12, 256)] public int segments = 64;
+
+ [Tooltip("바닥에서 띄우는 높이(m). Z-fighting 방지.")]
+ public float yOffset = 0.03f;
+
+ [Tooltip("렌더 큐(반투명 · 3000 이상). 지면 위·캐릭터 아래.")]
+ public int renderQueue = 3040;
+
+ [Tooltip("셰이더 후보(앞에서부터 Shader.Find).")]
+ public string[] shaderNames = new[] { "Universal Render Pipeline/Unlit", "Unlit/Transparent", "Sprites/Default" };
+
+ [Tooltip("색 프로퍼티 후보(앞에서부터 FindPropertyIndex).")]
+ public string[] colorProperties = new[] { "_BaseColor", "_Color", "_TintColor" };
+
+ [Header("③ 반지름 (SOT = 게임의 공격 판정 값 · 여기는 보정만)")]
+ [Tooltip("실측 사거리에 곱하는 배율. 1 = 판정과 같은 크기.")]
+ public float radiusScale = 1f;
+
+ [Tooltip("실측 사거리에 더하는 값(m). 0 = 판정과 같은 크기.")]
+ public float radiusBonus = 0f;
+
+ [Tooltip("적 탐색 반경에 더하는 여유(m). 사거리 + 이 값 안의 적만 검사한다(전수 스캔 방지).")]
+ public float searchPad = 2f;
+
+ [Header("④ 시간")]
+ [Tooltip("사거리 안에 적이 없어진 뒤 원이 버티는 시간(s). PD: 3초.")]
+ public float graceSeconds = 3f;
+
+ [Tooltip("나타나는 시간(s).")]
+ public float fadeIn = 0.15f;
+
+ [Tooltip("사라지는 시간(s).")]
+ public float fadeOut = 0.4f;
+
+ [Tooltip("적 검사 주기(s). 위치 추종은 매 프레임이라 이 값과 무관하다.")]
+ public float pollInterval = 0.1f;
+ }
+}
diff --git a/Assets/WL/Combat/RangeRing/WLRangeRingSettings.cs.meta b/Assets/WL/Combat/RangeRing/WLRangeRingSettings.cs.meta
new file mode 100644
index 000000000..f2c7bef58
--- /dev/null
+++ b/Assets/WL/Combat/RangeRing/WLRangeRingSettings.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 816d2a3b4c5d6e7f8091a2b3c4d5e6f7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLRangeRingSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLRangeRingSettings.asset
new file mode 100644
index 000000000..b88311f94
--- /dev/null
+++ b/Assets/WL/Combat/Settings/Resources/WL/WLRangeRingSettings.asset
@@ -0,0 +1,37 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 816d2a3b4c5d6e7f8091a2b3c4d5e6f7, type: 3}
+ m_Name: WLRangeRingSettings
+ m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.RangeRing.WLRangeRingSettings
+ enabled: 1
+ verboseLog: 1
+ color: {r: 1, g: 1, b: 1, a: 0.25}
+ ringOnly: 0
+ ringWidth: 0.18
+ segments: 64
+ yOffset: 0.03
+ renderQueue: 3040
+ shaderNames:
+ - Universal Render Pipeline/Unlit
+ - Unlit/Transparent
+ - Sprites/Default
+ colorProperties:
+ - _BaseColor
+ - _Color
+ - _TintColor
+ radiusScale: 1
+ radiusBonus: 0
+ searchPad: 2
+ graceSeconds: 3
+ fadeIn: 0.15
+ fadeOut: 0.4
+ pollInterval: 0.1
diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLRangeRingSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLRangeRingSettings.asset.meta
new file mode 100644
index 000000000..3a7d94845
--- /dev/null
+++ b/Assets/WL/Combat/Settings/Resources/WL/WLRangeRingSettings.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 816c3a4b5c6d7e8f9012a3b4c5d6e7f8
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant: