// ───────────────────────────────────────────────────────────────────────────── // WLEffectScaleSettings.cs — 원본 풀 스폰 이펙트(상태이상·히트) 크기 값의 단일 출처(SOT · C45) // // PD 지시 #813 추가(2026-09-10) 「상태이상 효과(스턴) 크기 및 플레이어 스킬이 몬스터·플레이어 크기에 비해 // 비정상적으로 큼 → 캐릭터/몬스터 크기를 고려해 1차로 조절」 · 발주서 WL-811s §1-3 // // ■ 왜 필요한가 (전부 실측 · `AgentScripts/staging/WL811s/MEASURE.txt`) // 액터 실높이 = PC(Ai01 × ClassConfig.f_Scale 0.7) **1.191 m** · 존 잡몹 **0.54~0.73 m** · // 엘리트(× 1.3) 1.22~2.07 m · 보스 Anubis(× 1.5) **3.13 m** // 이펙트 원 지름(프리팹 인스턴스 → ParticleSystem.Simulate t 스윕 → 파티클 실좌표 90 백분위) = // Effect_Stun **5.58 m**(PC 높이의 4.7배 · 잡몹 높이의 10배) · Effect_Hit_10501 8.84 m · // Effect_Barrier 22.0 m … → PD 지적 그대로 「대상보다 이펙트가 크다」가 수치로 확인된다. // // ■ 규칙(Lead 제안 · 이 에셋은 그 규칙을 **상한**으로 적용한다 = 하향만 · 이미 작은 이펙트는 그대로) // 목표 최대 지름 = 기준 높이 × 계수 (상태이상 0.6 · 착탄 0.8) → 배율 = 목표 지름 ÷ 프리팹 원 지름 // 보스 **패턴**(Effect_*Charging · Effect_Anubis_Skill1End 등)은 규칙 없음 = 원본 100 %(발주서 §1-1). // // ■ 어떻게 (813m3 NullMaterialGuard 의 「원본 이펙트 풀 루트 스윕」 방식 그대로 · 원본 훅 0) // 상태이상·히트 이펙트는 전부 원본 `InGameInfo.Show_Effect` → `dic_str_Effect` 풀이 스폰한다(WL 코드가 아니다). // 그래서 스폰 지점 훅 대신 **풀 루트(`InGameInfo.tf_Effects`)의 직속 자식**을 LateUpdate 에서 훑어 // 활성화된 인스턴스에 `localScale = 원 스케일 × 배율` 을 **대입**(누적 곱이 아니라 대입 = 멱등)하고, // 원본 `TurnOff_GO.Off_Imm` 이 `SetActive(false)` 로 반납하면 원 스케일로 되돌린다. // // 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLEffectScaleSettings.asset // C8 롤백: 에셋이 없거나 enabled = false → 러너도 스윕도 없고 적용분은 전부 원복 = 원본 100 %. // // 🔴 원본 프리팹·머티리얼은 고치지 않는다(발주서 §2 금지). 런타임 인스턴스의 localScale 만 만진다. // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System; using UnityEngine; namespace WL.Combat.Reaction { /// 인스턴스 이름(끝의 "(Clone)" 제외)과 규칙 문자열을 어떻게 맞추나. public enum EffectScaleMatch { /// 이름 전체가 같다(기본). Exact = 0, /// 이름이 이 문자열로 시작한다(계열 일괄 규칙). Prefix = 1, /// 이름 안에 이 문자열이 있다. Contains = 2 } [Serializable] public struct EffectScaleRule { [Tooltip("프리팹 이름(Assets/Res_Addr/Effect/<이름>.prefab). 인스턴스는 '<이름>(Clone)' 이다.")] public string prefab; [Tooltip("매칭 방식. 규칙은 배열 순서대로 검사해 처음 맞는 것을 쓴다(Exact 를 앞에, Prefix 를 뒤에).")] public EffectScaleMatch match; [Tooltip("고정 배율(프리팹 원 localScale × 이 값). 1 = 원본 그대로 · 0 이하 = 이 규칙 무시.")] public float scale; [Tooltip("scaleByTargetHeight 가 켜졌고 대상 액터를 찾았을 때 targetRatio·baseDiameter 로 배율을 다시 계산한다.")] public bool useTargetHeight; [Tooltip("대상 높이 × 이 계수 = 목표 최대 지름(m). Lead 규칙: 상태이상 0.6 · 착탄 0.8.")] public float targetRatio; [Tooltip("프리팹 원 지름 실측(m · 파티클 실좌표 90 백분위 · MEASURE.txt [B]). 0 이면 높이 기반 계산을 못 한다.")] public float baseDiameter; [Tooltip("표시용 메모(데이터 · 코드가 읽지 않는다).")] public string note; } [CreateAssetMenu(fileName = "WLEffectScaleSettings", menuName = "WL/Effect Scale Guard Settings", order = 327)] public sealed class WLEffectScaleSettings : ScriptableObject { public const string ResourcesPath = "WL/WLEffectScaleSettings"; static WLEffectScaleSettings s_cached; static bool s_lookupDone; /// 설정 에셋(1회 로드 후 캐시). 없으면 null. public static WLEffectScaleSettings Instance { get { if (!s_lookupDone) { s_cached = Resources.Load(ResourcesPath); s_lookupDone = true; } return s_cached; } } /// QA·A/B 전용 런타임 스위치. 0 = 에셋의 enabled · 1 = 강제 on · -1 = 강제 off. public static int RuntimeOverride; /// 가드가 살아 있는가. false 면 스윕도 러너도 없다(C8). public static bool Enabled { get { var i = Instance; if (i == null || RuntimeOverride < 0) return false; return RuntimeOverride > 0 || i.enabled; } } /// 테스트용: 캐시를 비운다. public static void ClearCache() { s_cached = null; s_lookupDone = false; } [Header("전체 스위치")] [Tooltip("끄면 러너도 스윕도 없고 이미 적용된 배율은 전부 원복된다 = 원본 100 %. C8 롤백 1순위.")] public bool enabled = true; [Tooltip("적용·원복을 Console 에 남긴다(인스턴스당 1줄 · GC 발생 · 기본 off).")] public bool verboseLog = false; [Header("스윕 (813m3 NullMaterialGuard 와 같은 풀 루트 스윕)")] [Tooltip("원본 이펙트 풀 `InGameInfo.tf_Effects` 를 훑는다 — 상태이상·히트가 전부 여기다. 끄면 이 발주는 무효.")] public bool sweepIngameEffects = true; [Tooltip("원본 오브젝트 풀 `InGameInfo.tf_Objs` 도 훑는다(드랍·813f 등급 빔 · 기본 off).")] public bool sweepIngameObjs = false; [Tooltip("811i SkillSpectacle 의 Live/Pool 루트도 훑는다. 🔴 811i 는 이미 행마다 scale 을 곱하므로 " + "켜면 이중 축소가 된다 — 기본 off 를 유지할 것.")] public bool sweepSkillSpectacle = false; [Tooltip("메인 PC 하위를 훑는다(813k 레벨업 폭발이 PC 에 붙는다 · 813w4 가 이미 0.5 로 낮췄으므로 기본 off).")] public bool sweepMainPc = false; [Tooltip("스윕 주기(초 · unscaled). 0 = 매 프레임(권장) — 활성화 프레임에 바로 적용해야 1프레임 원본 크기 깜빡임이 없다.")] public float sweepIntervalSeconds = 0f; [Tooltip("한 스윕에서 **처음 보는** 인스턴스를 몇 개까지 등록하나(이름 읽기 = 유일한 할당 지점). 남으면 다음 스윕.")] public int maxNewPerSweep = 24; [Tooltip("추적 슬롯 수(사전 할당). 넘으면 죽은 인스턴스를 정리하고, 그래도 모자라면 새 인스턴스를 건너뛴다.")] public int trackCapacity = 512; [Tooltip("원본 `TurnOff_GO` 가 반납(SetActive(false))할 때 원 스케일로 되돌린다. 끄면 적용된 채로 풀에 남는다(멱등이라 안전하지만 C8 이 더 깔끔해진다).")] public bool restoreOnReturn = true; [Header("대상 높이 기반 (옵션 · 기본 off = 아래 rules[].scale 고정 배율)")] [Tooltip("켜면 이펙트가 붙은 대상 액터의 실높이(발밑→머리)로 배율을 다시 계산한다. " + "대상을 못 찾으면(위치 지정 스폰 등) 고정 배율로 떨어진다.")] public bool scaleByTargetHeight = false; [Tooltip("고정 배율을 역산할 때 쓴 기준 높이(m). 실측 PC(Ai01 × f_Scale 0.7) = 1.191 m.")] public float referenceHeight = 1.191f; [Tooltip("대상 높이 기반 계산의 배율 하한(너무 작아져 안 보이는 것 방지).")] public float minScale = 0.05f; [Tooltip("대상 높이 기반 계산의 배율 상한(보스처럼 큰 대상에서 원본보다 커지는 것 방지).")] public float maxScale = 1.5f; // ── 피격 이펙트 위치·크기 (#816x) ──────────────────────────────────────── // PD 지시 #816 (2026-09-15): "적 타격시 발생하는 피격 이펙트가 적 상단에 발생하게 하고, 크기를 50% 키워줘." // 원본은 `ProjectileBase.Show_HitEffect → Get_HitEffectPos`(= target.Get_Center_position() 또는 투사체 위치) // 로 **몸 중심**에 스폰한다. 원본 1줄도 못 고치므로 EffectScaleGuard 의 풀 스윕이 // '활성화된 그 프레임에'(LateUpdate · 렌더 앞) 위치를 적 상단으로 올리고 배율을 곱한다. // '적 상단' = 게임이 eEffectLocation.Top 에 쓰는 그 앵커(Actor.tf_Top) = 머리 높이. [Header("피격 이펙트 위치·크기 (PD #816x)")] [Tooltip("켜면 이름이 hitNamePrefix 로 시작하는 이펙트를 적 상단(Actor.tf_Top)으로 올린다. 끄면 원본 위치(몸 중심) 그대로")] public bool hitTopEnabled = true; [Tooltip("대상 이름 접두사. 원본 히트 이펙트는 전부 Effect_Hit_<클래스ID> · Effect_Hit_<보스스킬> 이다")] public string hitNamePrefix = "Effect_Hit_"; [Tooltip("피격 이펙트 크기 배율(위 rules 로 정해진 배율에 추가로 곱한다). 1.5 = PD 지시 '50% 크게'")] public float hitScaleMultiplier = 1.5f; [Tooltip("적 상단(머리 끝)에서 더 올릴 높이(m). 0 이면 정확히 머리 끝")] public float hitTopExtraY = 0.1f; [Tooltip("이펙트가 어느 적의 것인지 찾는 반경(m). 원본이 그 적의 몸 중심에 스폰하므로 짧아도 된다")] public float hitOwnerSearchRange = 2.5f; [Tooltip("켜면 보스에게도 적용한다. 끄면 보스 피격 이펙트는 원본 위치·크기 그대로")] public bool hitApplyToBoss = true; [Header("규칙 (배열 순서대로 검사 · 처음 맞는 것 · 안 맞으면 손대지 않는다 = 원본 100 %)")] public EffectScaleRule[] rules = new EffectScaleRule[0]; /// 인스턴스 이름으로 규칙을 찾는다(선형 · alloc 0). 없으면 −1. public int FindRule(string instanceName, int coreLength) { if (rules == null || string.IsNullOrEmpty(instanceName)) return -1; for (int i = 0; i < rules.Length; i++) { if (rules[i].scale <= 0f && !rules[i].useTargetHeight) continue; if (NameMatches(instanceName, coreLength, rules[i].prefab, rules[i].match)) return i; } return -1; } /// "<이름>(Clone)" 에서 "(Clone)" 을 뺀 유효 길이(부분 문자열을 만들지 않는다 = alloc 0). public static int CoreLength(string n) { if (string.IsNullOrEmpty(n)) return 0; int len = n.Length; if (len > 7 && string.CompareOrdinal(n, len - 7, "(Clone)", 0, 7) == 0) len -= 7; return len; } /// 부분 문자열 생성 없이 이름을 맞춘다(alloc 0). public static bool NameMatches(string n, int coreLength, string pattern, EffectScaleMatch match) { if (string.IsNullOrEmpty(pattern) || coreLength <= 0) return false; int pl = pattern.Length; if (pl > coreLength) return false; switch (match) { case EffectScaleMatch.Exact: return pl == coreLength && string.CompareOrdinal(n, 0, pattern, 0, pl) == 0; case EffectScaleMatch.Prefix: return string.CompareOrdinal(n, 0, pattern, 0, pl) == 0; default: for (int i = 0; i + pl <= coreLength; i++) if (string.CompareOrdinal(n, i, pattern, 0, pl) == 0) return true; return false; } } } }