Project_WL/AgentScripts/WL815f_Probe.cs

699 lines
41 KiB
C#
Raw Permalink Normal View History

// 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<string> s_errors = new List<string>();
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<WLTelegraphSettings>(kSo);
bool created = false;
if (so == null)
{
so = ScriptableObject.CreateInstance<WLTelegraphSettings>();
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();
}
/// <summary>프로브가 만든 오브젝트가 씬에 남지 않게 한다(이름 접두 PROBE_ · 저장 0).</summary>
static void CleanupStrays()
{
var all = Object.FindObjectsByType<GameObject>(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<GameObject>(kMobRoot + m.prefab + ".prefab");
if (go == null) { L("| " + m.label + " | (프리팹 없음) | | | | | | |"); continue; }
var anim = go.GetComponentInChildren<Animator>(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<GameObject>(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<MobActor>();
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<Animator>(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<GameObject>("Assets/ResWork/UIPrefabs/Title/SoundInfo.prefab");
var si = sound != null ? sound.GetComponent<SoundInfo>() : 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>(); 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<Camera>();
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;
}
}