// ───────────────────────────────────────────────────────────────────────────── // WLTargetingSettings.cs — 플레이어 타겟팅 규칙 단일 출처(SOT) // // PD 지시 #770 (2026-09-06): // 1. 공격 시작 시점에 가장 가까운 대상을 타겟팅하되, 캐릭터가 바라보는 방향을 우선한다. // 2. 콤보 중 적이 사망하면 그 시점에 가장 가까운 적으로 다시 타겟팅해 공격한다. // 3. 타겟을 바꿀 때 이동부터 하지 말고, 공격 사거리 안이면 즉시 공격한다. // 4. 플레이어 자동 공격 AI 에 "자신을 때린 대상 우선"(어그로) 개념이 있어서는 안 된다. // // 목적: // 타겟팅 규칙의 수치·스위치를 코드 상수로 두지 않는다(C45). 값 조정·롤백은 이 에셋의 // 필드 하나만 고치면 되고, 코드 되돌리기·재빌드가 필요 없다(C8 롤백 경로). // // 로드 관례: // 같은 폴더의 WLGameplaySettings 와 동일하게 Resources 로 1회 로드 후 캐시한다. // 에셋 경로 = Assets/WL/Settings/Resources/WL/WLTargetingSettings.asset // // 🔴 에셋이 없을 때의 기본값 = "PD 지시대로 동작하는 값". // (WLGameplaySettings #763 과 같은 원칙 — SOT 누락으로 PD 지시가 무력화되면 안 된다. // 게임 데이터 값의 코드 하드코딩이 아니라 SOT 부재 시의 실패 처리다.) // // 🔴 어셈블리 주의: 힘민지 Assets/Script/ 에는 .asmdef 가 없다(전부 Assembly-CSharp). // Assets/WL/Settings/ 에도 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; namespace WL.Settings { /// /// 플레이어(PC) 타겟팅 규칙 설정. 코드 상수 금지(C45) — 모든 값을 이 에셋에서 읽는다. /// 펫(PetActor)·소환수·몬스터의 타겟팅에는 관여하지 않는다. /// [CreateAssetMenu(fileName = "WLTargetingSettings", menuName = "WL/Targeting Settings")] public sealed class WLTargetingSettings : ScriptableObject { // ── 로드 경로 (게임 데이터 값이 아니라 에셋 주소다 · WLGameplaySettings 와 동일 규약) ── public const string ResourcesPath = "WL/WLTargetingSettings"; private static WLTargetingSettings s_cached; private static bool s_lookupDone; /// 설정 에셋(1회 로드 후 캐시). 에셋이 없으면 null. public static WLTargetingSettings Instance { get { if (s_lookupDone) return s_cached; s_lookupDone = true; s_cached = Resources.Load(ResourcesPath); return s_cached; } } /// 캐시를 비운다(에디터에서 에셋을 새로 만들었을 때 · 테스트용). public static void ClearCache() { s_cached = null; s_lookupDone = false; } // ── 규칙 1 : 정면 우선 타겟팅 ──────────────────────────────────────────── [Header("■ 규칙 1 — 정면 우선 타겟팅 (PD #770-1)")] [Tooltip("정면 원뿔의 전체 각도(도). 이 원뿔 안에 후보가 하나라도 있으면 그 안에서만 타겟을 고른다. " + "0 이면 원뿔 제한 없이 아래 가중치 점수만 쓴다. 360 이면 사실상 전방위.")] [Range(0f, 360f)] public float frontConeDeg = 90f; [Tooltip("점수 = 거리 x (1 + 가중치 x (1 - cosθ)). θ = 캐릭터 정면과 적 방향의 수평 각. " + "0 이면 거리만 본다(기존 동작). 값이 클수록 등 뒤 적을 더 강하게 배제한다. " + "1.0 이면 바로 등 뒤(180도) 적의 점수가 거리의 3배가 된다.")] [Min(0f)] public float facingWeight = 1f; [Tooltip("공격 콤보를 시작하는 순간에 타겟을 다시 고를지(PD #770-1 '공격 시작 시점'). " + "후보는 지금 바로 때릴 수 있는 거리 안으로 제한하며, 그 안에 아무도 없으면 기존 타겟을 유지한다.")] public bool attackStartRetarget = true; // ── 규칙 2 : 콤보 중 적 사망 시 재타겟 ─────────────────────────────────── [Header("■ 규칙 2 — 처치 시 즉시 재타겟 (PD #770-2)")] [Tooltip("타겟이 죽는 즉시(공격/스킬 애니메이션 재생 중이어도) 가장 가까운 적으로 다시 타겟팅한다. " + "새 타겟이 사거리 안이면 콤보를 끊지 않고 몸만 돌려 계속 때린다.")] public bool retargetOnKill = true; // ── 규칙 3 : 사거리 안이면 즉시 공격 ───────────────────────────────────── [Header("■ 규칙 3 — 사거리 안이면 이동 없이 즉시 공격 (PD #770-3)")] [Tooltip("공격 판정 여유(m). 정지 거리(stoppingDistance)에 이 값을 더한 범위 안이면 접근하지 않고 바로 때린다. " + "실제 타격 판정 박스가 정지 거리보다 약간 길어서 생기는 '한 걸음 다가갔다 때리는' 동작을 없앤다. " + "너무 키우면 닿지 않는 거리에서 헛스윙하므로 0.5 m 이상은 권하지 않는다.")] [Min(0f)] public float immediateAttackMargin = 0.2f; [Tooltip("재타겟 탐색 거리(m). 0 이면 기존 탐색 거리(table_GlobalValue f_PC_FindEnemyRange + AttackRange_Up)를 그대로 쓴다.")] [Min(0f)] public float retargetSearchRange = 0f; // ── 규칙 4 : 어그로 금지 ───────────────────────────────────────────────── [Header("■ 규칙 4 — 피격 어그로 금지 (PD #770-4)")] [Tooltip("플레이어가 맞았을 때 때린 상대를 타겟으로 잡을지. PD 지시로 꺼 둔 상태다(= 어그로 없음). " + "🔴 끄면 자동 전투(Auto)가 꺼진 계정은 맞아도 스스로 공격을 시작하지 않는다 — " + "공격 버튼을 누르거나 Auto 를 켜야 타겟을 잡는다. 펫·소환수·몬스터에는 영향이 없다.")] public bool pcAggroOnHit = false; // ── #773 : 자동 교전 (계정 Auto 설정과 무관하게 탐색 거리 안의 적을 자동 타겟) ── [Header("■ #773 — 자동 교전 (PD 2026-09-06 '플레이어가 공격을 못함' 회귀 수정)")] [Tooltip("켜면 계정의 자동 전투(Auto) 설정과 무관하게 탐색 거리 안의 적을 규칙 1(정면 우선 최근접)대로 자동 타겟·공격한다. " + "규칙 4(피격 어그로 금지) 때문에 Auto OFF 계정이 교전을 시작하지 못하던 문제의 영구 수정. " + "끄면 #770 검증 시점 동작(Auto OFF 계정은 공격 버튼/Auto ON 이 시작점)으로 돌아간다. 펫·소환수·몬스터 무관.")] public bool autoEngage = true; // ── #813t3 : 시야(탐색) · 추적(이동) 반경 ──────────────────────────────── [Header("■ #813t3 — 시야·추적 반경 (PD 2026-09-11 「반경 3 m 로 제한」)")] [Tooltip("추적(이동) 반경(m). 이미 잡은 타깃이 이 거리를 넘어가면 놓는다 = 「3 m 밖 적은 무시」. " + "위 retargetSearchRange(탐색)는 '새로 고르는' 거리이고 이 값은 '계속 쫓는' 거리다 — " + "탐색만 줄이면 한 번 잡은 적은 죽을 때까지 따라간다(PD 관측 「한참 동안 적을 추적」). " + "0 이면 놓지 않는다(813t3 이전 동작). 보통 retargetSearchRange 와 같은 값으로 둔다.")] [Min(0f)] public float chaseRange = 0f; [Tooltip("타깃을 놓을 때 원본 「락 타깃」(Actor.m_IsLockTarget)까지 무시할지. " + "꺼 두면 813w 가이드가 보스에 건 락은 유지된다(= 락이 걸린 보스는 반경 밖이어도 계속 쫓는다).")] public bool chaseDropIgnoresLock = false; [Tooltip("타깃을 놓는 순간 달려가던 NavMesh 경로도 끊고 Idle 로 세울지. " + "🔴 조이스틱 조작 중이거나 813w 가이드가 이동을 쥐고 있을 때는 건드리지 않는다(둘이 싸우지 않게).")] public bool stopChaseMoveOnDrop = true; [Tooltip("시야 가드 폴링 간격(초).")] [Min(0.02f)] public float chaseCheckSeconds = 0.1f; [Tooltip("시야 가드가 타깃을 놓을 때 로그 1줄.")] public bool chaseVerboseLog = false; // ── 정적 접근자 (에셋 부재 시 = PD 지시대로 동작하는 값) ───────────────── public static bool AutoEngage { get { var i = Instance; return i == null || i.autoEngage; } } public static float FrontConeDeg { get { var i = Instance; return i != null ? i.frontConeDeg : 90f; } } public static float FacingWeight { get { var i = Instance; return i != null ? i.facingWeight : 1f; } } public static bool AttackStartRetarget { get { var i = Instance; return i == null || i.attackStartRetarget; } } public static bool RetargetOnKill { get { var i = Instance; return i == null || i.retargetOnKill; } } public static float ImmediateAttackMargin { get { var i = Instance; return i != null ? i.immediateAttackMargin : 0.2f; } } public static float RetargetSearchRange { get { var i = Instance; return i != null ? i.retargetSearchRange : 0f; } } public static bool PcAggroOnHit { get { var i = Instance; return i != null && i.pcAggroOnHit; } } // #813t3 — 0 = 기존 동작(놓지 않음). 값은 에셋(SO)이 갖는다. public static float ChaseRange { get { var i = Instance; return i != null ? i.chaseRange : 0f; } } public static bool ChaseDropIgnoresLock { get { var i = Instance; return i != null && i.chaseDropIgnoresLock; } } public static bool StopChaseMoveOnDrop { get { var i = Instance; return i == null || i.stopChaseMoveOnDrop; } } public static float ChaseCheckSeconds { get { var i = Instance; return i != null ? i.chaseCheckSeconds : 0.1f; } } public static bool ChaseVerboseLog { get { var i = Instance; return i != null && i.chaseVerboseLog; } } /// /// 프로브 전용(813w5 `WLBossArenaSettings.SetInstanceForProbe` 선례) — 임시 SO 를 잠시 Instance 자리에 끼운다. /// C8 진리표를 실 에셋을 건드리지 않고 검증하기 위한 것. null 을 주면 캐시를 비우고 실 에셋으로 돌아간다. /// public static void SetInstanceForProbe(WLTargetingSettings so) { if (so == null) { ClearCache(); return; } s_cached = so; s_lookupDone = true; } } }