// ───────────────────────────────────────────────────────────────────────────── // EffectWarmup.cs — 맵 진입 시 이펙트 사전 워밍(프레임 분산) · 히치 1차 완화 ⓑ // // PD 지시 #813(버벅임) · 발주서 WL-811q §1-2 ⓑ (2026-09-10) // // ■ 관측된 원인 (코드·테이블 실측) // ① 원본은 맵 로드 때 `EffectList.json` 90행을 돌며 **인스턴스 514개**를 미리 만든다 // (`InGameInfo.cs:83-88` · `n_PreLoadCount` 합 = 이펙트 312 + 투사체 202). // 그래서 `Effect_MonsterSpawn`(프리로드 20)·`Effect_Hit_*`(각 10) 같은 것은 **이미 워밍돼 있다**. // ② 🔴 그런데 **WL 이 쓰는 원본 풀 이펙트 22종**(검기 21 + `BoomHit`)은 그 표에 **한 종도 없다**(실측). // → 전투 중 첫 사용에서 `InGameInfo.Get_Effect` → `Load_Effect` → **Addressables 로드 + Instantiate**. // ③ 🔴 811i 스킬 이펙트 **33종**도 그 표에 없다 — 지금은 **첫 `AttackStarted`** 에 그 클래스 4행이 // 한꺼번에 로드되고(811i §1 ④), 완료 콜백이 몰린 프레임에 워밍 인스턴스가 같이 만들어진다. // // ■ 무엇을 하나 (원본 훅 0 · 원본 파일 수정 0) // 맵 진입 경계(메인 PC 교체 또는 맵 ID 변경 — `AutoCombatOnEnter` 와 **같은 판정**)를 보고, // `warmStartDelaySeconds` 뒤부터 **프레임당 N 개씩** 나눠서 // ⓐ 811i 행 → `SkillSpectacle.PreloadRows(warmRowsPerFrame, classId)` // ⓑ 원본 풀 22종 → `InGameInfo.Show_Effect(name, Vector3.one * warmFarDistance)` // (원본 자신의 사전 로드 `InGameInfo.cs:87` 과 **같은 좌표·같은 경로**. 프리팹의 `TurnOff_GO.OnEnable` // → `Set_Off()` 가 수명 뒤 스스로 꺼서 풀로 돌려놓는다 = 화면에 보이지 않고 잔류도 없다.) // ⇒ 첫 전투에서 터지던 로드·Instantiate 를 **로딩 직후 조용한 구간**으로 옮기고, 그 구간에서도 나눈다. // // ■ 🔴 안전 // · `warmPoolPrefabs` 의 이름은 프리팹에 `TurnOff_GO` 가 있어야 한다(없으면 원본 `Show_Effect` 가 // `GetComponent()` null 에서 NRE). **기본 22종은 디스크에서 전수 확인**했다(811q 보고 §4). // · 워밍은 맵 경계당 1회. 실패해도 런을 죽이지 않게 전체를 try/catch 로 감싸고 한 번 실패하면 그 판은 멈춘다. // · `Application.isPlaying` 이 아니면 아무것도 하지 않는다(에디트 모드 오염 0 · 프로브는 별도 진입점). // // ■ C8 롤백 // `WLHitchSettings.enabled = 0` → 러너 자체가 없다 · `warmOnMapEnter = 0` → 워밍 0 // (그러면 811i 는 예전처럼 첫 `AttackStarted` 에 프리로드하고 22종은 첫 사용에 로드된다 = 100 % 기존 동작). // // 🔴 어셈블리 주의: Assets/WL/ 에 .asmdef 를 만들지 말 것. // 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙). // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using WL.Combat.Reaction; namespace WL.Tools { /// 맵 진입 이펙트 워밍. 정적 · 러너는 HitchRecorderRunner 가 겸한다(새 오브젝트 0). public static class EffectWarmup { enum Phase { Idle = 0, Waiting = 1, Rows = 2, Pool = 3, Done = 4, Faulted = 5 } static Phase s_phase; static PCActor s_lastPc; static int s_lastMapId = int.MinValue; static float s_nextPoll, s_startAt; static int s_poolIndex, s_classId = -1; // ───────────────────────────────────────── 진단(프로브·레코더가 읽는다) /// 맵 경계를 본 횟수 · 워밍한 811i 행 수 · 워밍한 원본 풀 이름 수. public static int Boundaries, RowsWarmed, PoolWarmed; /// 워밍 완료(=Done) 도달 횟수 · 실패 횟수. public static int Completions, Faults; /// 레코더가 프레임 델타로 쓰는 진행 총계. public static int WarmedTotal { get { return RowsWarmed + PoolWarmed; } } /// 현재 단계 이름(파일 요약에 그대로 들어간다). public static string LastState { get { return s_phase.ToString(); } } public static string LastLog = ""; static WLHitchSettings St { get { return WLHitchSettings.Instance; } } // ───────────────────────────────────────────────────────────────────── // 틱 (HitchRecorderRunner.Update 가 부른다) // ───────────────────────────────────────────────────────────────────── /// 1프레임. = Time.unscaledTime. public static void Tick(float now) { var st = St; if (st == null || !WLHitchSettings.Enabled || !st.warmOnMapEnter) return; if (s_phase == Phase.Faulted) return; // 🔴 에디트 모드에서는 경계 감시·원본 풀 접근을 하지 않는다(오염 0). 행 워밍만 프로브가 돌릴 수 있다. try { if (now >= s_nextPoll) { s_nextPoll = now + Mathf.Max(0.05f, st.warmPollSeconds); PollBoundary(st, now); } switch (s_phase) { case Phase.Waiting: if (now >= s_startAt) { s_phase = Phase.Rows; } break; case Phase.Rows: StepRows(st); break; case Phase.Pool: StepPool(st); break; } } catch (System.Exception ex) { s_phase = Phase.Faulted; Faults++; LastLog = "중단(예외) — " + ex.Message; if (st.verboseLog) Debug.Log("[EffectWarmup] " + LastLog); // 🔴 LogError 금지(813z) } } /// 맵 진입 경계 판정 — AutoCombatOnEnter 와 같은 규칙(메인 PC 교체 또는 맵 ID 변경). static void PollBoundary(WLHitchSettings st, float now) { if (!Application.isPlaying) return; // 에디트 모드 오염 0(프로브는 ForceStart 로 들어온다) var pc = MyValue.MyPC; if (DSUtil.CheckNull(pc)) { s_lastPc = null; return; } // InGameInfo 가 아직 없으면 경계를 「소비」하지 않는다 — 다음 폴링에서 같은 경계를 다시 본다. var info = InGameInfo.Ins; if (DSUtil.CheckNull(info)) return; int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue; bool pcChanged = !ReferenceEquals(pc, s_lastPc); bool mapChanged = map != s_lastMapId; if (!pcChanged && !mapChanged) return; s_lastPc = pc; s_lastMapId = map; Boundaries++; s_classId = st.warmAllClasses ? -1 : pc.Get_ID(); s_poolIndex = 0; s_startAt = now + Mathf.Max(0f, st.warmStartDelaySeconds); s_phase = Phase.Waiting; LastLog = "맵 진입 경계 — 워밍 예약"; if (st.verboseLog) Debug.Log("[EffectWarmup] " + LastLog + " map=" + map + " class=" + s_classId); } /// 811i 행 워밍 1프레임분. static void StepRows(WLHitchSettings st) { int n = SkillSpectacle.PreloadRows(Mathf.Max(0, st.warmRowsPerFrame), s_classId); RowsWarmed += n; if (n == 0 || SkillSpectacle.PreloadRemaining(s_classId) <= 0) s_phase = Phase.Pool; } /// 원본 풀 워밍 1프레임분. static void StepPool(WLHitchSettings st) { var names = st.warmPoolPrefabs; if (names == null || names.Length == 0) { Finish(st); return; } if (!Application.isPlaying) { Finish(st); return; } // 에디트 모드는 원본 풀을 만들지 않는다 var info = InGameInfo.Ins; if (DSUtil.CheckNull(info)) { Finish(st); return; } int per = st.warmPoolPerFrame; if (per <= 0) per = names.Length; Vector3 far = Vector3.one * st.warmFarDistance; for (int k = 0; k < per && s_poolIndex < names.Length; k++) { string name = names[s_poolIndex++]; if (string.IsNullOrEmpty(name)) continue; // 원본 경로 그대로 — 풀에 없으면 Load_Effect(Addressables) 후 인스턴스 1개가 남는다. info.Show_Effect(name, far); PoolWarmed++; } if (s_poolIndex >= names.Length) Finish(st); } static void Finish(WLHitchSettings st) { s_phase = Phase.Done; Completions++; LastLog = "워밍 완료 — 행 " + RowsWarmed + " · 풀 " + PoolWarmed; if (st.verboseLog) Debug.Log("[EffectWarmup] " + LastLog); } // ───────────────────────────────────────────────────────────────────── // 프로브 진입점 · 초기화 // ───────────────────────────────────────────────────────────────────── /// 프로브용: 경계 폴링을 건너뛰고 지금 바로 워밍을 시작한다(classId < 0 = 전 클래스). public static void ForceStart(int classId, float now) { s_classId = classId; s_poolIndex = 0; s_startAt = now; s_phase = Phase.Waiting; Boundaries++; } /// 프로브용: 지금 단계(0 Idle · 1 Waiting · 2 Rows · 3 Pool · 4 Done · 5 Faulted). public static int PhaseIndex { get { return (int)s_phase; } } /// 프로브·재시작용. public static void ResetAll() { s_phase = Phase.Idle; s_lastPc = null; s_lastMapId = int.MinValue; s_nextPoll = s_startAt = 0f; s_poolIndex = 0; s_classId = -1; Boundaries = RowsWarmed = PoolWarmed = Completions = Faults = 0; LastLog = ""; } } }