383 lines
18 KiB
C#
383 lines
18 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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;
|
|
|
|
/// <summary>진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 원만 끈다.</summary>
|
|
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<WLRangeRing>();
|
|
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); }
|
|
|
|
/// <summary>프로브(배치·에디트)용 — 같은 틱을 1회 돌린다.</summary>
|
|
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<Actor> 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<MeshFilter>();
|
|
_mr = go.AddComponent<MeshRenderer>();
|
|
_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;
|
|
}
|
|
|
|
/// <summary>반지름 1 의 XZ 원판(또는 링) 메시. 스케일로 실제 반지름을 준다.</summary>
|
|
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<MeshFilter>() : null;
|
|
if (mf != null) mf.sharedMesh = _mesh;
|
|
}
|
|
}
|
|
}
|