// WL-815f 프로브 — 적 공격 예고(텔레그래프) 1차 · 에디트 모드 전용(Play 0 · 로그인 0 · 커밋 0) // // MakeAsset — WLTelegraphSettings.asset 생성/갱신(손으로 YAML 쓰지 않는다) // Run — A 클립 히트 시각 표(몹 12종) · B 콜라이더 실측 · C 가짜 왕복(3종+보스) · D 동시 상한 // E 보스 무변경 · F GC 0 · G C8 · H 애니메이터 실측(데미지도 늦어짐 증명) · I 캡처 3장 // // 산출물 = AgentScripts/staging/WL815f/EDIT_PROBE.txt · 캡처 = Screenshots_WL/WL815f/ using UnityEngine; using UnityEditor; using System.Collections.Generic; using WL.Combat.Telegraph; public static class WL815f_Probe { const string kOutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL815f"; const string kSnap = "AgentScripts/staging/WL815f/EDIT_PROBE.txt"; const string kSo = "Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset"; const string kMobRoot = "Assets/Res_Addr/Mobs/"; const string kProjRoot = "Assets/Res_Addr/Projectile/"; const int W = 1080, H = 1920; static System.Text.StringBuilder sb; static int pass, fail; static readonly List s_errors = new List(); static void L(string s) { sb.Append(s).Append('\n'); } static void Chk(bool ok, string label, string detail) { if (ok) pass++; else fail++; sb.Append(ok ? " PASS " : " FAIL ").Append(label); if (!string.IsNullOrEmpty(detail)) sb.Append(" ").Append(detail); sb.Append('\n'); } static string F(float v, int d) { return v.ToString(d == 3 ? "F3" : d == 2 ? "F2" : "F4"); } // ───────────────────────────────────────────────────────────────────────── public static string MakeAsset() { sb = new System.Text.StringBuilder(); var dir = System.IO.Path.GetDirectoryName(kSo).Replace('\\', '/'); if (!System.IO.Directory.Exists(dir)) { L("FAIL 폴더 없음 " + dir); return sb.ToString(); } var so = AssetDatabase.LoadAssetAtPath(kSo); bool created = false; if (so == null) { so = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(so, kSo); created = true; } // 캡처 실측으로 조정한 값(몹이 통째로 노랗게 보여 에미션을 낮췄다) — 코드 기본값과 같게 유지한다 so.tintAlbedoAvoidable = new Color(1.20f, 1.05f, 0.60f, 1f); so.tintEmissionAvoidable = new Color(0.55f, 0.34f, 0.02f, 1f); so.tintAlbedoUnavoidable = new Color(1.30f, 0.60f, 0.55f, 1f); so.tintEmissionUnavoidable = new Color(0.75f, 0.08f, 0.04f, 1f); so.defaultProjectileSpeed = 5f; EditorUtility.SetDirty(so); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); L((created ? "CREATED " : "EXISTS ") + kSo); L(" enabled=" + so.enabled + " 잡몹 " + so.telegraphSecondsNormal + "s · 원거리 " + so.telegraphSecondsRanged + "s · 엘리트 " + so.telegraphSecondsElite + "s · 하한 k " + so.minSpeedMultiplier + " · 상한 " + so.maxConcurrent + " · 펄스 " + so.pulseScale + " · SFX idx " + so.sfxIndex); return sb.ToString(); } // ───────────────────────────────────────────────────────────────────────── public static string Run() { sb = new System.Text.StringBuilder(); pass = fail = 0; s_errors.Clear(); Application.logMessageReceived += OnLog; // 배치 에디터의 현재(제목 없는·저장 안 된) 씬을 그대로 작업장으로 쓴다. // 만드는 오브젝트는 전부 HideFlags.DontSave 이고 끝에서 파기한다 → 저장 0 · 커밋 0. var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); try { L("# WL-815f 프로브 — 적 공격 예고 1차 (에디트 모드 · Play 0)"); L("생성 " + System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); L(""); WLTelegraphSettings.ClearCache(); var st = WLTelegraphSettings.Instance; if (st == null) { L("FAIL — SO 를 못 읽었다: " + kSo); return Finish(); } Telegraph.EnsureSubscribed(); Telegraph.ResetDiagnostics(); Telegraph.ClearClipCache(); TelegraphShape.ClearCache(); TelegraphShape.ResetDiagnostics(); TelegraphDecal.ResetDiagnostics(); WL.Combat.Reaction.ImpactTier.ResetDiagnostics(); TelegraphRunner.BeginForceClock(); SectionA(st); SectionB(st); SectionC(st, scene); SectionD(st, scene); SectionE(st, scene); SectionF(st, scene); SectionG(st, scene); SectionH(scene); SectionJ(st, scene); SectionI(st, scene); } catch (System.Exception ex) { L("EXCEPTION " + ex.GetType().Name + " : " + ex.Message); L(ex.StackTrace); fail++; } finally { Telegraph.ClearAll(); TelegraphDecal.DestroyAll(); TelegraphRunner.ResetForceClock(); WLTelegraphSettings.RuntimeDisabled = false; CleanupStrays(); Application.logMessageReceived -= OnLog; } return Finish(); } static string Finish() { L(""); L("## 콘솔 Error/Exception " + s_errors.Count + "건"); for (int i = 0; i < s_errors.Count && i < 12; i++) L(" " + s_errors[i]); L(""); L("RESULT PASS " + pass + " / FAIL " + fail); var dir = System.IO.Path.GetDirectoryName(kSnap); if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); System.IO.File.WriteAllText(kSnap, sb.ToString()); return sb.ToString(); } /// 프로브가 만든 오브젝트가 씬에 남지 않게 한다(이름 접두 PROBE_ · 저장 0). static void CleanupStrays() { var all = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); int n = 0; for (int i = 0; i < all.Length; i++) { var g = all[i]; if (g == null || g.transform.parent != null) continue; if (g.name.StartsWith("PROBE_") || g.name.StartsWith("__WLTelegraph")) { Object.DestroyImmediate(g); n++; } } if (n > 0) L(" (정리) 프로브 오브젝트 " + n + "개 파기"); } static void OnLog(string cond, string stack, LogType t) { if (t == LogType.Error || t == LogType.Exception || t == LogType.Assert) if (s_errors.Count < 40) s_errors.Add(t + " : " + cond); } // ── 몹 표(기준서 §G-0 실측표와 대조할 12종) ──────────────────────────── struct MobRow { public string label, prefab, proj; public float lifetime; public bool elite, boss; } static readonly MobRow[] s_mobs = { new MobRow{ label="Batty_A(101) 존1 잡몹", prefab="Mob/Batty_A", proj="Mob/P_Batty_Meele", lifetime=0.1f }, new MobRow{ label="Batty_B(102) 존1 원거리", prefab="Mob/Batty_B", proj="Mob/P_Batty_Range", lifetime=1.5f }, new MobRow{ label="Porin_A(168) 존2 잡몹", prefab="Mob/Porin_A", proj="Mob/P_Porin_Meele", lifetime=0.1f }, new MobRow{ label="Racco_A(173) 존3 잡몹", prefab="Mob/Racco_A", proj="Mob/P_Racco_Meele", lifetime=0.1f }, new MobRow{ label="Rabby_Brown(146) 존3 잡몹", prefab="Mob/Rabby_Brown", proj="Mob/P_Rabby_Meele", lifetime=0.1f }, new MobRow{ label="Devilu_A(118) 존4 잡몹", prefab="Mob/Devilu_A", proj="Mob/P_Devilu_Meele", lifetime=0.1f }, new MobRow{ label="Golem_A(1014) 엘리트", prefab="Elite/Golem_A", proj="Elite/P_Golem_A", lifetime=0.1f, elite=true }, new MobRow{ label="Aspethis_D(1023) 엘리트", prefab="Elite/Aspethis_D", proj="Elite/P_Aspethis_D", lifetime=0.1f, elite=true }, new MobRow{ label="Beeto_A(1009) 엘리트", prefab="Elite/Beeto_A", proj="Elite/P_Beeto_A", lifetime=0.1f, elite=true }, new MobRow{ label="Cute_Crab_C(1028) 엘리트", prefab="Elite/Cute_Crab_C_Blue", proj="Elite/P_Cute_Crab_C_Blue",lifetime=0.1f, elite=true }, new MobRow{ label="Mummy_King(1024) 엘리트", prefab="Elite/Mummy_King", proj="Elite/P_Mummy_King", lifetime=1.5f, elite=true }, new MobRow{ label="Anubis(10006) 보스", prefab="FieldBoss/Anubis", proj="FieldBoss/Anubis_Attack", lifetime=0.1f, boss=true }, }; // ─────────────────────────────────────────────────────────── A. 클립 히트 시각 표 static void SectionA(WLTelegraphSettings st) { L("## A. 예고 시간 전/후 표 (몹 12종 · 클립 히트 이벤트 실측 · 목표 = SO 값)"); L("| 몹 | 컨트롤러 | 히트 이벤트 s(전) | 등급 목표 s | k(= 적용 속도) | 하한 걸림 | 예고 s(후) | 배수 |"); L("|---|---|---|---|---|---|---|---|"); int measured = 0; for (int i = 0; i < s_mobs.Length; i++) { var m = s_mobs[i]; var go = AssetDatabase.LoadAssetAtPath(kMobRoot + m.prefab + ".prefab"); if (go == null) { L("| " + m.label + " | (프리팹 없음) | | | | | | |"); continue; } var anim = go.GetComponentInChildren(true); var rac = anim != null ? anim.runtimeAnimatorController : null; string racName = rac != null ? rac.name : "(없음)"; var tbl = Telegraph.HitSecondsTable(rac); float before = tbl != null && tbl.Length > 0 ? tbl[0] : -1f; if (before > 0f) measured++; bool ranged = m.lifetime >= st.rangedLifetimeThreshold; float target = m.boss ? -1f : (m.elite ? st.telegraphSecondsElite : (ranged ? st.telegraphSecondsRanged : st.telegraphSecondsNormal)); if (m.boss) { L("| " + m.label + " | " + racName + " | " + (before > 0f ? F(before, 3) : "-") + " | **무변경**(코드 3.0 s 차징) | 1.000 | - | " + (before > 0f ? F(before, 3) : "-") + " | ×1.00 |"); continue; } if (before <= 0f) { L("| " + m.label + " | " + racName + " | (히트 이벤트 못 찾음) | " + F(target, 2) + " | | | | |"); continue; } float desired = before / target; float floorSpeed = 1f * Mathf.Clamp(st.minSpeedMultiplier, 0.01f, 1f); float applied = Mathf.Clamp(desired, floorSpeed, 1f); bool clamped = desired < floorSpeed - 0.0001f; float after = before / applied; L("| " + m.label + " | " + racName + " | " + F(before, 3) + " | " + F(target, 2) + " | " + F(applied, 3) + " | " + (clamped ? "🔴 O" : "-") + " | **" + F(after, 3) + "** | ×" + F(after / before, 2) + " |"); } L(""); Chk(measured >= 10, "12종 중 클립 히트 이벤트 실측 성공", measured + " / " + s_mobs.Length); L("→ 🔴 속도 곡선만으로는 목표 0.5/0.6/0.8 s 에 도달하지 못한다(하한 k=" + st.minSpeedMultiplier + ")."); L(" 나머지 시간은 **바닥 판이 채워지는 " + F(st.telegraphSecondsNormal, 2) + " s** 와 몹 크기/색/소리가 채운다(기준서 §G-2 ⓐ 한계 그대로)."); L(""); } // ─────────────────────────────────────────────────────────── B. 콜라이더 실측 static void SectionB(WLTelegraphSettings st) { L("## B. 데칼 크기의 출처 = 투사체 프리팹 BoxCollider (신규 데이터 0)"); L("| 투사체 | 읽은 size | 읽은 center | 속도 | 출처 | 기준서 §G-0 실측 |"); L("|---|---|---|---|---|---|"); string[] expect = { "3×3×3 c(0,0,0)", "1×3×1 c(0,0,0)", "3×3×3 c(0,0,0)", "3×3×3 c(0,0,1.5)", "2×2×4 c(0,0,2)" }; string[] names = { "Mob/P_Batty_Meele", "Mob/P_Batty_Range", "Mob/P_Porin_Meele", "Elite/P_Golem_A", "FieldBoss/Anubis_Attack" }; Vector3[] wantSize = { new Vector3(3, 3, 3), new Vector3(1, 3, 1), new Vector3(3, 3, 3), new Vector3(3, 3, 3), new Vector3(2, 2, 4) }; Vector3[] wantCenter = { Vector3.zero, Vector3.zero, Vector3.zero, new Vector3(0, 0, 1.5f), new Vector3(0, 0, 2f) }; int ok = 0; for (int i = 0; i < names.Length; i++) { var b = TelegraphShape.Get(names[i]); bool match = b.resolved && (b.size - wantSize[i]).sqrMagnitude < 0.0001f && (b.center - wantCenter[i]).sqrMagnitude < 0.0001f; if (match) ok++; L("| " + names[i] + " | " + b.size + " | " + b.center + " | " + F(b.speed, 2) + " | " + b.source + " | " + expect[i] + (match ? " ✅" : " ❌") + " |"); } Chk(ok == names.Length, "콜라이더 실측 = 기준서 §G-0 표와 완전 일치", ok + " / " + names.Length); L(""); } // ─────────────────────────────────────────────────────────── C. 가짜 왕복 static GameObject MakeMob(MobRow m, Vector3 pos, UnityEngine.SceneManagement.Scene scene, out MobActor actor) { actor = null; var prefab = AssetDatabase.LoadAssetAtPath(kMobRoot + m.prefab + ".prefab"); if (prefab == null) return null; var go = Object.Instantiate(prefab); go.name = "PROBE_" + m.prefab.Replace('/', '_'); go.hideFlags = HideFlags.DontSave; UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(go, scene); go.transform.position = pos; go.transform.rotation = Quaternion.identity; actor = go.GetComponent(); if (actor == null) { Object.DestroyImmediate(go); return null; } actor.m_Role = eRole.Mob; actor.m_SubRole = m.boss ? eSubRol.Boss : (m.elite ? eSubRol.Elite : eSubRol.None); actor.m_animation = go.GetComponentInChildren(true); var td = new MonsterTableData(); td.e_MonsterType = actor.m_SubRole; td.s_Porjectile1 = m.proj; td.f_ProjectileLifeTime1 = m.lifetime; td.e_AttackType = eAttackType.Physics; td.f_DefaultScale = go.transform.localScale.x; var fi = typeof(MobActor).GetField("m_MonsterTableData", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); if (fi != null) fi.SetValue(actor, td); return go; } static void SectionC(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene) { L("## C. 가짜 AttackStarted → HitboxSpawned 왕복 (몹 3종 + 보스 · 실측)"); int[] pick = { 0, 2, 6, 11 }; // Batty_A · Porin_A · Golem_A(엘리트) · Anubis(보스) for (int p = 0; p < pick.Length; p++) { var m = s_mobs[pick[p]]; MobActor actor; var go = MakeMob(m, new Vector3(p * 20f, 0f, 0f), scene, out actor); if (go == null) { Chk(false, "C 몹 인스턴스 " + m.label, "프리팹/MobActor 없음"); continue; } float scale0 = go.transform.localScale.x; float speed0 = actor.m_animation != null ? 1f : -1f; if (actor.m_animation != null) actor.m_animation.speed = 1f; Telegraph.ResetDiagnostics(); TelegraphDecal.ResetDiagnostics(); WL.Combat.Core.CombatEvents.RaiseAttackStarted(actor, 0, 1f, null); L(""); L("### " + m.label); if (m.boss) { bool untouched = Telegraph.AppliedSpeedOf(actor) < 0f && (actor.m_animation == null || Mathf.Abs(actor.m_animation.speed - 1f) < 0.0001f) && Mathf.Abs(go.transform.localScale.x - scale0) < 0.0001f && Telegraph.DecalIndexOf(actor) < 0; Chk(untouched, "보스 무변경(속도·크기·판 전부 원본)", "speed=" + (actor.m_animation != null ? F(actor.m_animation.speed, 3) : "-") + " scale=" + F(go.transform.localScale.x, 3) + " 판=" + Telegraph.DecalIndexOf(actor) + " SkippedBoss=" + Telegraph.SkippedBoss); Object.DestroyImmediate(go); continue; } float planned = Telegraph.PlannedSecondsOf(actor); float applied = Telegraph.AppliedSpeedOf(actor); int decal = Telegraph.DecalIndexOf(actor); L(" 히트 시각 " + F(Telegraph.LastHitSeconds, 3) + " s → 목표 " + F(Telegraph.LastTargetSeconds, 2) + " s · 속도 " + F(Telegraph.LastOrigSpeed, 3) + "→" + F(applied, 3) + (Telegraph.LastWasClamped ? " (🔴 하한 " + st.minSpeedMultiplier + " 로 잘림)" : "") + " · 실제 예고 " + F(planned, 3) + " s"); Chk(applied > 0f && actor.m_animation != null && Mathf.Abs(actor.m_animation.speed - applied) < 0.0001f, "animator.speed 가 실제로 낮아졌다", "anim.speed=" + (actor.m_animation != null ? F(actor.m_animation.speed, 3) : "-")); Chk(Mathf.Abs(applied - Mathf.Max(Telegraph.LastHitSeconds / Telegraph.LastTargetSeconds, st.minSpeedMultiplier)) < 0.001f, "속도 = max(히트시각 ÷ 목표, 하한)", "기대 " + F(Mathf.Max(Telegraph.LastHitSeconds / Telegraph.LastTargetSeconds, st.minSpeedMultiplier), 3)); // 판 크기 = 콜라이더 크기인가 Vector3 psize, pcenter; float yaw; var box = TelegraphShape.Get(m.proj); bool ranged = m.lifetime >= st.rangedLifetimeThreshold; if (TelegraphDecal.TryGetPlate(decal, out psize, out pcenter, out yaw)) { float wantW = box.size.x, wantL = ranged ? Mathf.Clamp(box.speed * m.lifetime, box.size.z, st.rangedMaxLength) : box.size.z; bool sizeOk = Mathf.Abs(psize.x - wantW) < 0.01f && Mathf.Abs(psize.z - wantL) < 0.01f; Chk(sizeOk, "판 크기 = 투사체 콜라이더 크기", "판 " + F(psize.x, 3) + "×" + F(psize.z, 3) + " · 콜라이더 " + F(wantW, 3) + "×" + F(wantL, 3)); float wantZ = st.forwardOffset + box.center.z + (ranged ? wantL * 0.5f : 0f); bool cenOk = Mathf.Abs((pcenter.z - go.transform.position.z) - wantZ) < 0.01f; Chk(cenOk, "판 중심 = 발사 위치 + collider.center", "판 z=" + F(pcenter.z - go.transform.position.z, 3) + " 기대 " + F(wantZ, 3)); } else Chk(false, "판이 켜졌다", "decalIdx=" + decal); // 회전 추종 go.transform.rotation = Quaternion.Euler(0f, 90f, 0f); TelegraphRunner.ForceTick(0.02f); if (TelegraphDecal.TryGetPlate(decal, out psize, out pcenter, out yaw)) Chk(Mathf.Abs(Mathf.DeltaAngle(yaw, 90f)) < 0.5f, "몹이 돌면 판도 따라 돈다", "판 yaw=" + F(yaw, 2)); // 채워지는 진행 + 펄스 float half = Mathf.Max(0.02f, planned * 0.5f - 0.02f); TelegraphRunner.ForceTick(half); float midScale = go.transform.localScale.x; Chk(midScale > scale0 * 1.0005f && midScale < scale0 * st.pulseScale, "예고 중 크기가 커진다(펄스)", "기본 " + F(scale0, 3) + " → 중간 " + F(midScale, 3) + " (상한 " + F(scale0 * st.pulseScale, 3) + ")"); Chk(WL.Combat.Reaction.MobHitFlash.HasEliteTint(actor) || !st.tintEnabled, "예고 중 몹 색(MPB)이 걸렸다", "TintApplied=" + Telegraph.TintApplied); // 811gh 히트스톱 스킵 조건 WL.Combat.Reaction.ImpactTier.ClearStamp(); var tier = WL.Combat.Reaction.ImpactTier.Resolve(actor, null, false); float hs = WL.Combat.Reaction.ImpactTier.HitStopSeconds(tier); Chk(!WL.Combat.Reaction.ImpactTier.Active || hs <= 0.0001f, "예고 중 히트스톱 = 0 (기준서 §G-4)", "tier=" + tier + " hitStop=" + F(hs, 4) + "s skip=" + WL.Combat.Reaction.ImpactTier.LastSkipHitStop + " (티어 활성=" + WL.Combat.Reaction.ImpactTier.Active + ")"); // 히트 → 원복 WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(actor, m.proj, 1f, m.lifetime, go.transform.position); Chk(actor.m_animation != null && Mathf.Abs(actor.m_animation.speed - 1f) < 0.0001f, "히트에서 animator.speed 원복", "speed=" + (actor.m_animation != null ? F(actor.m_animation.speed, 3) : "-") + " (원본 " + F(speed0, 3) + ")"); Chk(Mathf.Abs(go.transform.localScale.x - scale0) < 0.0001f, "히트에서 크기 원복", "scale=" + F(go.transform.localScale.x, 4) + " 기본 " + F(scale0, 4)); Chk(TelegraphDecal.InUse == 0, "판 반납", "InUse=" + TelegraphDecal.InUse); // 흰 섬광 만료 후 색 원복 TelegraphRunner.ForceTick(st.flashSeconds + 0.02f); TelegraphRunner.ForceTick(st.pulseRestoreGuardSeconds + 0.02f); Chk(!WL.Combat.Reaction.MobHitFlash.HasEliteTint(actor), "섬광 뒤 몹 색 원복(MPB 잔류 0)", "HasTint=" + WL.Combat.Reaction.MobHitFlash.HasEliteTint(actor) + " HasBlock=" + WL.Combat.Reaction.MobHitFlash.HasAnyBlock(actor)); Chk(Telegraph.SpeedRestored >= 1 && Telegraph.PulseRestored >= 1, "원복 카운터", "SpeedRestored=" + Telegraph.SpeedRestored + " PulseRestored=" + Telegraph.PulseRestored); Object.DestroyImmediate(go); } L(""); } // ─────────────────────────────────────────────────────────── D. 동시 상한 static void SectionD(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene) { L("## D. 동시 상한 " + st.maxConcurrent + " (초과 = 판 생략 · 811s 예산기 방식)"); Telegraph.ResetDiagnostics(); TelegraphDecal.ResetDiagnostics(); Telegraph.ClearAll(); int n = st.maxConcurrent + 3; var gos = new GameObject[n]; var actors = new MobActor[n]; for (int i = 0; i < n; i++) gos[i] = MakeMob(s_mobs[0], new Vector3(i * 6f, 0f, 0f), scene, out actors[i]); for (int i = 0; i < n; i++) if (actors[i] != null) WL.Combat.Core.CombatEvents.RaiseAttackStarted(actors[i], 0, 1f, null); Chk(TelegraphDecal.InUse == st.maxConcurrent, "동시에 켜진 판 = 상한", "InUse=" + TelegraphDecal.InUse + " / 상한 " + st.maxConcurrent); Chk(TelegraphDecal.BudgetSkipCount == n - st.maxConcurrent, "초과분은 판 생략", "생략 " + TelegraphDecal.BudgetSkipCount + " / 기대 " + (n - st.maxConcurrent)); int granted = Telegraph.SfxPlayed + Telegraph.SfxNoManager; Chk(granted == st.sfxMaxConcurrent && Telegraph.SfxThrottled == n - st.sfxMaxConcurrent, "예고 SFX 동시 상한 " + st.sfxMaxConcurrent + " (기준서 §G-4)", "토큰 " + granted + " · 스킵 " + Telegraph.SfxThrottled + " / " + n + "회 요청 · 실재생 " + Telegraph.SfxPlayed + " (에디트 모드라 SoundInfo 인스턴스=" + SoundInfo.isIns + " → 실재생은 「미확인」)"); L(" 풀 크기 " + TelegraphDecal.PoolSize + " · 풀 성장 " + TelegraphDecal.PoolGrowCount + "회 · 셰이더 " + TelegraphDecal.ShaderName); // 전부 반납 for (int i = 0; i < n; i++) if (actors[i] != null) WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(actors[i], s_mobs[0].proj, 1f, 0.1f, Vector3.zero); Chk(TelegraphDecal.InUse == 0, "전부 반납(풀 누수 0)", "InUse=" + TelegraphDecal.InUse); for (int i = 0; i < n; i++) if (gos[i] != null) Object.DestroyImmediate(gos[i]); Telegraph.ClearAll(); L(""); } // ─────────────────────────────────────────────────────────── E. 보스 + 타임아웃 static void SectionE(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene) { L("## E. 보스 무변경 · 히트가 안 올 때 강제 원복"); Telegraph.ResetDiagnostics(); Telegraph.ClearAll(); MobActor boss; var bgo = MakeMob(s_mobs[11], new Vector3(-30f, 0f, 0f), scene, out boss); if (bgo != null) { float s0 = bgo.transform.localScale.x; if (boss.m_animation != null) boss.m_animation.speed = 1f; for (int i = 0; i < 3; i++) WL.Combat.Core.CombatEvents.RaiseAttackStarted(boss, 0, 1f, null); Chk(Telegraph.SkippedBoss == 3 && Telegraph.StartCount == 0, "보스 AttackStarted 3회 → 예고 0", "SkippedBoss=" + Telegraph.SkippedBoss + " Start=" + Telegraph.StartCount); Chk(boss.m_animation == null || Mathf.Abs(boss.m_animation.speed - 1f) < 0.0001f, "보스 animator.speed 무변경", "speed=" + (boss.m_animation != null ? F(boss.m_animation.speed, 3) : "-")); Chk(Mathf.Abs(bgo.transform.localScale.x - s0) < 0.0001f, "보스 크기 무변경", "scale=" + F(bgo.transform.localScale.x, 4)); Object.DestroyImmediate(bgo); } Telegraph.ResetDiagnostics(); MobActor a; var go = MakeMob(s_mobs[0], new Vector3(-60f, 0f, 0f), scene, out a); if (go != null) { float s0 = go.transform.localScale.x; if (a.m_animation != null) a.m_animation.speed = 1f; WL.Combat.Core.CombatEvents.RaiseAttackStarted(a, 0, 1f, null); TelegraphRunner.ForceTick(st.maxTelegraphSeconds + 0.1f); // 히트가 안 온다(피격·사망) Chk(Telegraph.TimeoutCount == 1, "타임아웃 감지", "Timeout=" + Telegraph.TimeoutCount); Chk(a.m_animation != null && Mathf.Abs(a.m_animation.speed - 1f) < 0.0001f, "타임아웃 → 속도 원복", "speed=" + F(a.m_animation.speed, 3)); TelegraphRunner.ForceTick(st.pulseRestoreGuardSeconds + 0.05f); Chk(Mathf.Abs(go.transform.localScale.x - s0) < 0.0001f, "타임아웃 → 크기 원복", "scale=" + F(go.transform.localScale.x, 4)); Chk(TelegraphDecal.InUse == 0, "타임아웃 → 판 반납", "InUse=" + TelegraphDecal.InUse); Object.DestroyImmediate(go); } Telegraph.ClearAll(); L(""); } // ─────────────────────────────────────────────────────────── F. GC static void SectionF(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene) { L("## F. GC (워밍 후 차분 · GC.GetAllocatedBytesForCurrentThread)"); Telegraph.ClearAll(); Telegraph.ResetDiagnostics(); MobActor a; var go = MakeMob(s_mobs[0], new Vector3(-90f, 0f, 0f), scene, out a); if (go == null) { Chk(false, "F 몹 인스턴스", "없음"); return; } if (a.m_animation != null) a.m_animation.speed = 1f; for (int i = 0; i < 30; i++) // 워밍(캐시·풀·머티리얼) { WL.Combat.Core.CombatEvents.RaiseAttackStarted(a, 0, 1f, null); TelegraphRunner.ForceTick(0.02f); WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(a, s_mobs[0].proj, 1f, 0.1f, Vector3.zero); TelegraphRunner.ForceTick(0.3f); } long b0 = System.GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < 200; i++) { WL.Combat.Core.CombatEvents.RaiseAttackStarted(a, 0, 1f, null); TelegraphRunner.ForceTick(0.02f); WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(a, s_mobs[0].proj, 1f, 0.1f, Vector3.zero); TelegraphRunner.ForceTick(0.3f); } long d1 = System.GC.GetAllocatedBytesForCurrentThread() - b0; long b1 = System.GC.GetAllocatedBytesForCurrentThread(); WL.Combat.Core.CombatEvents.RaiseAttackStarted(a, 0, 1f, null); for (int i = 0; i < 5000; i++) TelegraphRunner.ForceTick(0.0001f); long d2 = System.GC.GetAllocatedBytesForCurrentThread() - b1; WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(a, s_mobs[0].proj, 1f, 0.1f, Vector3.zero); Chk(d1 == 0, "왕복 ×200 할당 0 B", d1 + " B"); Chk(d2 == 0, "틱 ×5000 할당 0 B", d2 + " B"); Object.DestroyImmediate(go); Telegraph.ClearAll(); L(""); } // ─────────────────────────────────────────────────────────── G. C8 static void SectionG(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene) { L("## G. C8 — enabled = 0 이면 전부 원본"); Telegraph.ClearAll(); Telegraph.ResetDiagnostics(); TelegraphDecal.ResetDiagnostics(); WL.Combat.Reaction.ImpactTier.ResetDiagnostics(); WLTelegraphSettings.RuntimeDisabled = true; // 에셋 enabled = 0 과 같은 게이트 MobActor a; var go = MakeMob(s_mobs[0], new Vector3(-120f, 0f, 0f), scene, out a); if (go != null) { float s0 = go.transform.localScale.x; if (a.m_animation != null) a.m_animation.speed = 1f; WL.Combat.Core.CombatEvents.RaiseAttackStarted(a, 0, 1f, null); TelegraphRunner.ForceTick(0.2f); Chk(Telegraph.StartCount == 0 && Telegraph.SkippedDisabled >= 1, "예고 0", "Start=" + Telegraph.StartCount + " SkippedDisabled=" + Telegraph.SkippedDisabled); Chk(a.m_animation != null && Mathf.Abs(a.m_animation.speed - 1f) < 0.0001f, "animator.speed 원본", "speed=" + F(a.m_animation.speed, 3)); Chk(Mathf.Abs(go.transform.localScale.x - s0) < 0.0001f, "크기 원본", "scale=" + F(go.transform.localScale.x, 4)); Chk(TelegraphDecal.InUse == 0 && !WL.Combat.Reaction.MobHitFlash.HasEliteTint(a), "판 0 · MPB 0", "InUse=" + TelegraphDecal.InUse); Chk(!WLTelegraphSettings.SkipHitStop, "히트스톱 스킵 조건도 꺼진다(811gh 기존 동작)", "SkipHitStop=" + WLTelegraphSettings.SkipHitStop); Object.DestroyImmediate(go); } WLTelegraphSettings.RuntimeDisabled = false; Telegraph.ClearAll(); L(""); } // ─────────────────────────────────────────────────────────── H. 애니메이터 실측 static void SectionH(UnityEngine.SceneManagement.Scene scene) { L("## H. 🔴 「데미지도 같이 늦어진다」 실측 — animator.speed 를 낮추면 클립 진행이 정확히 반비례한다"); L(" (히트 = 클립 안의 애니메이션 이벤트 `Projectile` 이므로 클립이 늦어지면 데미지도 늦어진다)"); L("| 몹 | 속도 | 0.2 s 동안 normalizedTime 진행 | 기대(= 0.2 ÷ 길이 × 속도) | 오차 |"); L("|---|---|---|---|---|"); int[] pick = { 0, 2, 6 }; int okCount = 0; for (int p = 0; p < pick.Length; p++) { var m = s_mobs[pick[p]]; MobActor actor; var go = MakeMob(m, new Vector3(p * 20f, 0f, 40f), scene, out actor); if (go == null || actor.m_animation == null) { if (go != null) Object.DestroyImmediate(go); continue; } var anim = actor.m_animation; anim.enabled = true; anim.cullingMode = AnimatorCullingMode.AlwaysAnimate; float len = ClipLength(anim, "attack"); for (int k = 0; k < 2; k++) { float sp = k == 0 ? 1f : 0.35f; anim.speed = 1f; anim.Play("attack", 0, 0f); anim.Update(0f); anim.speed = sp; for (int i = 0; i < 20; i++) anim.Update(0.01f); // 0.2 s float nt = anim.GetCurrentAnimatorStateInfo(0).normalizedTime; float want = len > 0f ? 0.2f / len * sp : -1f; float err = want > 0f ? Mathf.Abs(nt - want) : -1f; bool ok = want > 0f && err < 0.02f; if (ok) okCount++; L("| " + m.label + " | " + F(sp, 2) + " | " + F(nt, 4) + " | " + F(want, 4) + " | " + (err >= 0f ? F(err, 4) : "-") + (ok ? " ✅" : " ❌") + " |"); } anim.speed = 1f; Object.DestroyImmediate(go); } Chk(okCount >= 4, "속도 ↔ 클립 진행 반비례 실측", okCount + " / " + (pick.Length * 2)); L(""); } static float ClipLength(Animator anim, string state) { var rac = anim.runtimeAnimatorController; if (rac == null) return -1f; anim.Play(state, 0, 0f); anim.Update(0f); var infos = anim.GetCurrentAnimatorClipInfo(0); if (infos != null && infos.Length > 0 && infos[0].clip != null) return infos[0].clip.length; return -1f; } // ─────────────────────────────────────────────────────────── J. 보유 에셋 실측(SFX 클립 · 인디케이터 20종) static void SectionJ(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene) { L("## J. ④ 예고 SFX 클립 · ① IndicatorInfo 재사용 경로 (보유 에셋 실측)"); var sound = AssetDatabase.LoadAssetAtPath("Assets/ResWork/UIPrefabs/Title/SoundInfo.prefab"); var si = sound != null ? sound.GetComponent() : null; if (si != null && si.arr_clip != null) { bool inRange = st.sfxIndex >= 0 && st.sfxIndex < si.arr_clip.Length; var clip = inRange ? si.arr_clip[st.sfxIndex] : null; L(" SoundInfo.arr_clip 길이 " + si.arr_clip.Length + " · eSound.Max = " + (int)eSound.Max); Chk(clip != null, "예고 SFX 클립 실존 (index " + st.sfxIndex + " = " + (eSound)st.sfxIndex + ")", clip != null ? clip.name + " · " + F(clip.length, 3) + " s" : "null"); } else Chk(false, "SoundInfo 프리팹/클립 배열", sound == null ? "프리팹 없음" : "arr_clip null"); // ① Show_Indicator 재사용 — 원본 공개 API(Make_Indicator) 경로가 살아 있는지 int indicators = 0; var guids = AssetDatabase.FindAssets("_Indicator t:GameObject", new[] { "Assets/Res_Addr/Obj" }); if (guids != null) indicators = guids.Length; L(" Res_Addr/Obj 인디케이터 프리팹 " + indicators + "종 (기준서 §G-1 = 20종)"); Chk(indicators >= 20, "보유 인디케이터 20종 실존", indicators + "종"); string save = st.indicatorPrefabName; st.indicatorPrefabName = "Golem_Child_Skill1_Indicator"; int before = TelegraphDecal.IndicatorMissCount; MobActor ia; var igo = MakeMob(s_mobs[0], new Vector3(-150f, 0f, 0f), scene, out ia); var box = TelegraphShape.Get("Mob/P_Batty_Meele"); int idx = igo != null ? TelegraphDecal.Acquire(ia, in box, false, 0.1f, TelegraphDanger.Avoidable) : -1; st.indicatorPrefabName = save; Chk(TelegraphDecal.IndicatorMissCount > before || TelegraphDecal.IndicatorBorrowCount > 0, "IndicatorInfo 재사용 경로가 안전하게 분기한다(에디트 모드 = 싱글턴 없음)", "isIns=" + IndicatorInfo.isIns + " miss=" + TelegraphDecal.IndicatorMissCount + " borrow=" + TelegraphDecal.IndicatorBorrowCount + " → 실제 표시는 Play 전용 「미확인」"); if (idx >= 0) TelegraphDecal.Release(idx); if (igo != null) Object.DestroyImmediate(igo); L(" 🔴 기본값은 빈 문자열 = 인디케이터 덧칠 off. 보유 20종은 전부 **원형 파티클**이라 사각 콜라이더와 100 % 일치가 안 된다 →"); L(" 판정 일치는 런타임 쿼드가 담당하고, 덧칠이 필요하면 SO 한 줄(indicatorPrefabName)로 켠다."); L(""); } // ─────────────────────────────────────────────────────────── I. 캡처 static void SectionI(WLTelegraphSettings st, UnityEngine.SceneManagement.Scene scene) { L("## I. 캡처 3장 (1080×1920)"); if (!System.IO.Directory.Exists(kOutDir)) System.IO.Directory.CreateDirectory(kOutDir); Telegraph.ClearAll(); Telegraph.ResetDiagnostics(); // 조명 + 지면(판이 보이게) var lightGo = new GameObject("PROBE_Light"); lightGo.hideFlags = HideFlags.DontSave; UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(lightGo, scene); var light = lightGo.AddComponent(); light.type = LightType.Directional; light.intensity = 1.1f; lightGo.transform.rotation = Quaternion.Euler(45f, -30f, 0f); var ground = GameObject.CreatePrimitive(PrimitiveType.Plane); ground.name = "PROBE_Ground"; ground.hideFlags = HideFlags.DontSave; UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(ground, scene); ground.transform.localScale = new Vector3(6f, 1f, 6f); var camGo = new GameObject("PROBE_Cam"); camGo.hideFlags = HideFlags.DontSave; UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(camGo, scene); var cam = camGo.AddComponent(); cam.clearFlags = CameraClearFlags.SolidColor; cam.backgroundColor = new Color(0.18f, 0.2f, 0.24f); cam.fieldOfView = 60f; cam.nearClipPlane = 0.05f; cam.farClipPlane = 200f; int[] pick = { 0, 6, 11 }; string[] tags = { "1_잡몹_예고중", "2_엘리트_예고중", "3_보스_무변경" }; for (int p = 0; p < pick.Length; p++) { var m = s_mobs[pick[p]]; MobActor actor; var go = MakeMob(m, Vector3.zero, scene, out actor); if (go == null) { L(" (프리팹 없음) " + m.label); continue; } if (actor.m_animation != null) { actor.m_animation.speed = 1f; actor.m_animation.cullingMode = AnimatorCullingMode.AlwaysAnimate; } WL.Combat.Core.CombatEvents.RaiseAttackStarted(actor, 0, 1f, null); float planned = Telegraph.PlannedSecondsOf(actor); if (planned > 0f) TelegraphRunner.ForceTick(planned * 0.6f); // 60 % 채워진 순간 else TelegraphRunner.ForceTick(0.02f); // 판(3×3 또는 3×3+center 1.5)과 몹이 화면에 꽉 차게 — 세로 1080×1920 구도 Vector3 focus = new Vector3(0f, 0f, m.boss ? 0.6f : 1.0f); float span = Mathf.Max(3.2f, go.transform.localScale.x * 1.8f); cam.transform.position = focus + new Vector3(0f, span * 1.15f, -span * 1.45f); cam.transform.LookAt(focus + new Vector3(0f, span * 0.18f, 0f)); string path = Shot(cam, tags[p]); L(" " + tags[p] + " — " + m.label + " · 예고 " + F(planned, 3) + " s · 판 " + (Telegraph.DecalIndexOf(actor) >= 0 ? "O" : "X") + " → " + path); WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(actor, m.proj, 1f, m.lifetime, Vector3.zero); TelegraphRunner.ForceTick(0.5f); Object.DestroyImmediate(go); } Object.DestroyImmediate(ground); Object.DestroyImmediate(camGo); Object.DestroyImmediate(lightGo); int n = System.IO.Directory.GetFiles(kOutDir, "*.png").Length; Chk(n >= 3, "캡처 3장", n + "장 · " + kOutDir); L(""); } static string Shot(Camera cam, string tag) { var rt = new RenderTexture(W, H, 24, RenderTextureFormat.ARGB32); cam.aspect = (float)W / H; cam.targetTexture = rt; cam.Render(); var prev = RenderTexture.active; RenderTexture.active = rt; var tex = new Texture2D(W, H, TextureFormat.RGB24, false); tex.ReadPixels(new Rect(0, 0, W, H), 0, 0); tex.Apply(); RenderTexture.active = prev; cam.targetTexture = null; var path = System.IO.Path.Combine(kOutDir, tag + ".png"); System.IO.File.WriteAllBytes(path, ImageConversion.EncodeToPNG(tex)); Object.DestroyImmediate(tex); rt.Release(); Object.DestroyImmediate(rt); return path; } }