diff --git a/AgentScripts/WL815f_Probe.cs b/AgentScripts/WL815f_Probe.cs new file mode 100644 index 000000000..41c362bd4 --- /dev/null +++ b/AgentScripts/WL815f_Probe.cs @@ -0,0 +1,698 @@ +// 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; + } +} diff --git a/Assets/WL/Combat/Reaction/ImpactTier.cs b/Assets/WL/Combat/Reaction/ImpactTier.cs index e42cc1067..717a019b8 100644 --- a/Assets/WL/Combat/Reaction/ImpactTier.cs +++ b/Assets/WL/Combat/Reaction/ImpactTier.cs @@ -54,6 +54,10 @@ namespace WL.Combat.Reaction public static int LastTierIndex { get { return (int)LastTier; } } public static readonly int[] TierCount = new int[4]; public static int DowngradedCount, ResolvedCount, StampHitCount; + // WL #815f — 예고 중 히트스톱 스킵(기준서 §G-4). s_skipHitStop 은 Resolve 가 매 타격 갱신한다. + static bool s_skipHitStop; + public static int TelegraphHitStopSkipped; + public static bool LastSkipHitStop { get { return s_skipHitStop; } } public static float LastDamage, LastAverage; public static bool LastCritical, LastSkill, LastKill, LastBoss; @@ -100,6 +104,9 @@ namespace WL.Combat.Reaction public static ImpactTierKind Resolve(Actor victim, DamageInfo dinfo, bool killHint) { if (!Active || victim == null) return ImpactTierKind.Light; + // WL #815f(기준서 §G-4) — 예고 중인 몹을 때렸으면 이 타격의 히트스톱을 건너뛴다. + // 히트스톱이 예고 구간에 겹치면 timeScale 이 바뀌어 예고 길이가 흔들린다. SO 플래그로 끌 수 있다(기본 on). + s_skipHitStop = WL.Combat.Telegraph.WLTelegraphSettings.SkipHitStop && WL.Combat.Telegraph.Telegraph.IsTelegraphing(victim); if (s_stampVictim == victim && s_stampFrame == Time.frameCount) { StampHitCount++; LastTier = s_stampTier; return s_stampTier; } float dmg = dinfo != null ? (float)dinfo.Damage : 0f; @@ -214,6 +221,7 @@ namespace WL.Combat.Reaction { var st = St; if (st == null) return 0f; + if (s_skipHitStop) { TelegraphHitStopSkipped++; return 0f; } // WL #815f — 직전 Resolve 가 「예고 중」으로 판정한 타격 float sec = Get(st.hitStopMs, (int)tier, 0f) * 0.001f; return Mathf.Clamp(sec, 0f, Mathf.Max(0f, st.hitStopMaxSeconds)); } @@ -262,6 +270,7 @@ namespace WL.Combat.Reaction LastCritical = LastSkill = LastKill = LastBoss = false; s_sampleHead = s_sampleCount = 0; s_sampleSum = 0f; s_stampVictim = null; s_stampFrame = -1; + TelegraphHitStopSkipped = 0; s_skipHitStop = false; // WL #815f } } } diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset new file mode 100644 index 000000000..52764c5f6 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset @@ -0,0 +1,78 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 26b2beddb0dd9fa498cecef15d5e4cdd, type: 3} + m_Name: WLTelegraphSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Telegraph.WLTelegraphSettings + enabled: 1 + verboseLog: 0 + telegraphSecondsNormal: 0.5 + telegraphSecondsRanged: 0.6 + telegraphSecondsElite: 0.8 + bossUntouched: 1 + speedCurveEnabled: 1 + minSpeedMultiplier: 0.35 + hitEventNames: + - Projectile + - BossProjectile2 + - BossProjectile3 + defaultHitSeconds: 0.16 + correctFromLiveClip: 1 + maxTelegraphSeconds: 2.5 + decalEnabled: 1 + maxConcurrent: 6 + poolWarmCount: 8 + groundYOffset: 0.06 + forwardOffset: 0.3 + sizeScale: 1 + defaultBoxSize: {x: 3, y: 3, z: 3} + rangedLifetimeThreshold: 0.5 + rangedMaxLength: 12 + defaultProjectileSpeed: 5 + followRotation: 1 + fillMode: 0 + plateAlpha: 0.28 + fillAlpha: 0.62 + decalShaderNames: + - Universal Render Pipeline/Unlit + - Unlit/Transparent + - Sprites/Default + decalColorProperties: + - _BaseColor + - _Color + - _TintColor + decalRenderQueue: 3050 + indicatorPrefabName: + indicatorAutoScale: 1 + colorAvoidable: {r: 1, g: 0.82, b: 0.15, a: 1} + colorUnavoidable: {r: 1, g: 0.16, b: 0.12, a: 1} + dangerNormalMelee: 0 + dangerNormalRanged: 0 + dangerElite: 0 + tintEnabled: 1 + tintAlbedoAvoidable: {r: 1.2, g: 1.05, b: 0.6, a: 1} + tintEmissionAvoidable: {r: 0.55, g: 0.34, b: 0.02, a: 1} + tintAlbedoUnavoidable: {r: 1.3, g: 0.6, b: 0.55, a: 1} + tintEmissionUnavoidable: {r: 0.75, g: 0.08, b: 0.04, a: 1} + flashEnabled: 1 + flashLeadSeconds: 0.06 + flashSeconds: 0.05 + flashAlbedo: {r: 2.2, g: 2.2, b: 2.2, a: 1} + flashEmission: {r: 1.6, g: 1.6, b: 1.6, a: 1} + pulseEnabled: 1 + pulseScale: 1.12 + pulseRestoreGuardSeconds: 0.2 + sfxEnabled: 1 + sfxIndex: 9 + sfxVolume: 0.35 + sfxMaxConcurrent: 3 + sfxWindowSeconds: 0.35 + skipHitStopDuringTelegraph: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset.meta new file mode 100644 index 000000000..ec122342e --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f63ebf08aa5f3064fa437216e481a5ef +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Telegraph.meta b/Assets/WL/Combat/Telegraph.meta new file mode 100644 index 000000000..674598847 --- /dev/null +++ b/Assets/WL/Combat/Telegraph.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c85ac270a98d7324d97c35f7116a3d31 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Telegraph/Telegraph.cs b/Assets/WL/Combat/Telegraph/Telegraph.cs new file mode 100644 index 000000000..b2b29a099 --- /dev/null +++ b/Assets/WL/Combat/Telegraph/Telegraph.cs @@ -0,0 +1,617 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Telegraph.cs — 적 공격 예고 1차(§G-7 1차 = ① 데칼 + ⓐ 속도 곡선 + ⓓ 펄스 + ③ 색 규약 + ④ SFX) +// +// PD 지시 #815-5 「적의 공격을 확실히 유저가 인지하고 피할 수 있는 매커니즘」 · 발주서 WL-815f +// SOT = 기준서 v1 §G-0~§G-4 · §G-7 +// +// ■ 원본 수정 0줄 — 이미 있는 배선 두 개만 구독한다 +// CombatEvents.AttackStarted (Actor.Play_Attack 안 · Actor.cs:1957) → 예고 시작 +// CombatEvents.HitboxSpawned (Actor.Shoot_Projectile 첫 줄 · :2342) → 히트 = 예고 끝 +// 데미지는 공격 클립의 애니메이션 이벤트 `Projectile` 이 낸다. 그래서 **애니메이터를 늦추면 데미지도 같이 늦어진다** +// (별도 동기화 0 · 기준서 §G-2 ⓐ). 프로브가 이것을 실측으로 증명한다(정규화 시간 진행 = 속도에 정확히 반비례). +// +// ■ 한계(기준서 §G-2 ⓐ 그대로) — 속도 곡선 단독으로 0.5 s 는 안 된다 +// Batty 히트 시각 0.081 s 에서 0.5 s 를 만들려면 k = 0.16 인데 이는 사실상 정지 화면이다. +// 자연스러운 하한 k = 0.35 를 지키면 예고는 0.23 s 까지만 늘어난다 → **나머지는 데칼·펄스·색·소리가 채운다.** +// 즉 「몹 동작이 0.5 s 느려진다」가 아니라 「0.5 s 동안 바닥 판이 채워지고 몹이 커지며 소리가 난다」가 이번 1차의 실체다. +// +// ■ 보스는 건드리지 않는다 — Boss_Anubis 가 이미 코드로 3 s 차징을 건다(기준서 §G-0). +// +// ■ 다른 WL 시스템과의 관계 +// · 색: 813s 가 만든 MobHitFlash.SetEliteTint/ClearEliteTint 경로를 **그대로** 쓴다(렌더러 MPB 소유자는 하나여야 한다는 813s 교훈). +// 엘리트였다면 예고가 끝날 때 813s 의 상시 색을 다시 걸어 준다(MobHitFlash.cs 수정 0줄). +// · 크기: 813s EliteMarker 가 대입한 스케일을 「기준」으로 잡고 곱한 뒤 그 기준으로 되돌린다(누적 0). +// · 히트스톱: 811gh ImpactTier 가 예고 중인 몹에게는 히트스톱을 건너뛴다(기준서 §G-4 · ImpactTier.cs +7줄). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Telegraph +{ + public static class Telegraph + { + enum Phase { None = 0, Charging = 1, Fading = 2 } + + sealed class State + { + public Actor actor; + public Animator anim; + public Phase phase; + public float startTime, plannedSeconds, hitSeconds, origSpeed, appliedSpeed, targetSeconds; + public float fadeUntil, guardUntil; + public bool speedApplied, tinted, pulsed, flashed, wasElite, corrected; + public Vector3 baseScale; + public int decalIdx; + public int attackIndex; + public TelegraphDanger danger; + public RuntimeAnimatorController controller; + public int activeIndex; + } + + static readonly Dictionary s_states = new Dictionary(32); + static State[] s_active = new State[16]; + static int s_activeCount; + + static readonly Dictionary s_hitTimes = new Dictionary(16); + static readonly Dictionary s_clipHit = new Dictionary(32); + static readonly List s_clipInfo = new List(4); + static readonly List s_tmpClips = new List(8); + + static FieldInfo s_tableField; + static bool s_tableLooked; + + static readonly float[] s_sfxTimes = new float[8]; + static int s_sfxHead; + + /// + /// 815b 스테이지 진행기가 스테이지별 예고 배수를 여기에 넣는다(기본 1 = 무변경). + /// 🔴 815b 가 아직 병합되지 않았으므로 지금 값은 항상 1 이다 — 리플렉션/옵션 참조를 쓰지 않는다(같은 어셈블리라 병합 뒤 한 줄로 연결된다). + /// + public static float StageScale = 1f; + + // ── 진단(프로브가 읽는다) + public static bool Subscribed; + public static int StartCount, EndCount, SkippedBoss, SkippedNotMob, SkippedDisabled, SkippedNoTable, TimeoutCount, + SpeedApplied, SpeedClamped, SpeedRestored, TintApplied, TintRestoredElite, PulseApplied, PulseRestored, + FlashCount, SfxPlayed, SfxThrottled, SfxNoManager, DecalOn, DecalSkipped, HitTimeFallback, HitTimeCorrected; + public static float LastPlannedSeconds = -1f, LastHitSeconds = -1f, LastOrigSpeed = -1f, LastAppliedSpeed = -1f, LastTargetSeconds = -1f; + public static bool LastWasRanged, LastWasClamped; + public static string LastInfo = ""; + public static int ActiveCount { get { return s_activeCount; } } + public static int StateCount { get { return s_states.Count; } } + + static WLTelegraphSettings St { get { return WLTelegraphSettings.Instance; } } + static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void Register() + { + CombatEvents.AttackStarted.Add(OnAttackStarted); + CombatEvents.HitboxSpawned.Add(OnHitboxSpawned); + CombatEvents.Killed.Add(OnKilled); + CombatEvents.Spawned.Add(OnSpawned); + Subscribed = true; + } + + /// 프로브용 — 에디트 모드에서 구독을 강제한다(RuntimeInitializeOnLoadMethod 가 안 도는 환경). + public static void EnsureSubscribed() + { + if (Subscribed) return; + Register(); + } + + /// 811gh ImpactTier 가 부른다 — 이 몹이 지금 예고(충전) 중인가. + public static bool IsTelegraphing(Actor actor) + { + State s; + return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging; + } + + /// 프로브용 — 이 몹에 적용된 애니메이터 속도(예고 중이 아니면 -1). + public static float AppliedSpeedOf(Actor actor) + { + State s; + return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging ? s.appliedSpeed : -1f; + } + + /// 프로브용 — 계획된 예고 초(예고 중이 아니면 -1). + public static float PlannedSecondsOf(Actor actor) + { + State s; + return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging ? s.plannedSeconds : -1f; + } + + /// 프로브용 — 이 몹이 쓰는 데칼 풀 인덱스(-1 = 없음). + public static int DecalIndexOf(Actor actor) + { + State s; + return actor != null && s_states.TryGetValue(actor, out s) ? s.decalIdx : -1; + } + + // ───────────────────────────────────────── 이벤트 + + static void OnAttackStarted(in AttackStartedEvent e) + { + var st = St; + if (st == null || !WLTelegraphSettings.Enabled) { SkippedDisabled++; return; } + + var actor = e.actor; + if (actor == null) return; + if (!actor.IsRole(eRole.Mob)) { SkippedNotMob++; return; } + if (st.bossUntouched && actor.IsSubRole(eSubRol.Boss)) { SkippedBoss++; return; } + + var table = GetTable(actor); + if (table == null) { SkippedNoTable++; return; } + + string proj = table.s_Porjectile1; + float projLifetime = table.f_ProjectileLifeTime1; + bool ranged = projLifetime >= st.rangedLifetimeThreshold + || (!string.IsNullOrEmpty(proj) && proj.IndexOf("Range", System.StringComparison.OrdinalIgnoreCase) >= 0); + bool elite = actor.IsSubRole(eSubRol.Elite) || table.e_MonsterType == eSubRol.Elite; + + float target = elite ? st.telegraphSecondsElite : (ranged ? st.telegraphSecondsRanged : st.telegraphSecondsNormal); + target *= (StageScale > 0f ? StageScale : 1f); + if (target <= 0.01f) return; + + TelegraphRunner.Ensure(); // Play 에서 틱 러너 보장(에디트 모드에서는 아무것도 만들지 않는다) + + var s = GetState(actor); + EndInternal(s, false, true); // 이전 예고가 남아 있으면 먼저 정리(원복 보장) + + s.anim = actor.m_animation; + s.attackIndex = e.attackIndex; + s.controller = s.anim != null ? s.anim.runtimeAnimatorController : null; + s.hitSeconds = ResolveHitSeconds(s.controller, e.attackIndex, st); + s.origSpeed = e.animSpeed > 0.0001f ? e.animSpeed : (s.anim != null && s.anim.speed > 0.0001f ? s.anim.speed : 1f); + s.corrected = false; + s.danger = elite ? st.dangerElite : (ranged ? st.dangerNormalRanged : st.dangerNormalMelee); + s.startTime = TelegraphRunner.Now; + s.targetSeconds = target; + s.phase = Phase.Charging; + + ApplySpeed(s, st, target); + + // ── ① 지면 데칼 ──────────────────────────────────────────────── + s.decalIdx = -1; + if (st.decalEnabled) + { + var box = TelegraphShape.Get(proj); + s.decalIdx = TelegraphDecal.Acquire(actor, in box, ranged, projLifetime, s.danger); + if (s.decalIdx >= 0) DecalOn++; else DecalSkipped++; + } + + // ── ⓓ 스케일 펄스(813s EliteMarker 가 대입한 값을 기준으로 잡는다) ── + if (st.pulseEnabled && st.pulseScale > 1.0001f) + { + s.baseScale = actor.transform.localScale; + s.pulsed = true; + PulseApplied++; + } + + // ── ③ 색 규약(MobHitFlash MPB 경로 · 813s 와 같은 소유자) ─────── + if (st.tintEnabled) + { + s.wasElite = WL.Combat.Reaction.MobHitFlash.HasEliteTint(actor); + bool unavoid = s.danger == TelegraphDanger.Unavoidable; + s.tinted = WL.Combat.Reaction.MobHitFlash.SetEliteTint(actor, + unavoid ? st.tintAlbedoUnavoidable : st.tintAlbedoAvoidable, + unavoid ? st.tintEmissionUnavoidable : st.tintEmissionAvoidable); + if (s.tinted) TintApplied++; + } + + // ── ④ 예고 SFX(동시 상한) ───────────────────────────────────── + PlaySfx(st, actor); + + Activate(s); + StartCount++; + + LastTargetSeconds = target; LastWasRanged = ranged; + if (Verbose) + Debug.Log("[Telegraph] 시작 " + actor.name + " 등급=" + (elite ? "엘리트" : ranged ? "잡몹(원거리)" : "잡몹") + + " 목표 " + target.ToString("F2") + "s · 히트시각 " + s.hitSeconds.ToString("F3") + + "s · 속도 " + s.origSpeed.ToString("F2") + "→" + s.appliedSpeed.ToString("F2") + + " · 실제 예고 " + s.plannedSeconds.ToString("F3") + "s · 판 " + (s.decalIdx >= 0 ? "O" : "X")); + } + + static void OnHitboxSpawned(in HitboxSpawnedEvent e) + { + State s; + if (e.shooter == null || !s_states.TryGetValue(e.shooter, out s) || s.phase != Phase.Charging) return; + EndInternal(s, true, false); + } + + static void OnKilled(in KilledEvent e) + { + State s; + if (e.victim == null || !s_states.TryGetValue(e.victim, out s)) return; + EndInternal(s, false, true); + } + + static void OnSpawned(in SpawnedEvent e) + { + // 풀 재사용 몹 — 남아 있던 예고 상태를 지운다(스케일 감시 포함 · 813s EliteMarker 의 대입과 충돌 0) + State s; + if (e.actor == null || !s_states.TryGetValue(e.actor, out s)) return; + EndInternal(s, false, true); + s.guardUntil = 0f; + } + + // ───────────────────────────────────────── 틱 + + internal static void Tick(float now) + { + if (s_activeCount == 0) return; + var st = St; + + for (int i = s_activeCount - 1; i >= 0; i--) + { + var s = s_active[i]; + if (s == null) continue; + + if (s.actor == null) { HardClear(s); continue; } + + if (s.phase == Phase.Charging) + { + if (st == null) { EndInternal(s, false, true); continue; } + + float elapsed = now - s.startTime; + + // 히트가 안 온다(피격·사망·중단) → 강제 원복 + if (elapsed > Mathf.Max(0.1f, st.maxTelegraphSeconds) || !s.actor.gameObject.activeInHierarchy) + { TimeoutCount++; EndInternal(s, false, true); continue; } + + if (!s.corrected) CorrectFromLiveClip(s, st); + + float planned = Mathf.Max(0.0001f, s.plannedSeconds); + float t = Mathf.Clamp01(elapsed / planned); + + if (s.decalIdx >= 0) { TelegraphDecal.Follow(s.decalIdx, s.actor); TelegraphDecal.SetFill(s.decalIdx, t); } + + if (s.pulsed) + { + float k = t * t; // 뒤로 갈수록 빨리 커진다(선딜 느낌) + float mul = 1f + (st.pulseScale - 1f) * k; + s.actor.transform.localScale = s.baseScale * mul; + } + + // 히트 직전 흰 섬광 + if (st.flashEnabled && !s.flashed && (planned - elapsed) <= st.flashLeadSeconds) + { + if (WL.Combat.Reaction.MobHitFlash.SetEliteTint(s.actor, st.flashAlbedo, st.flashEmission)) + { s.flashed = true; s.tinted = true; FlashCount++; } + else s.flashed = true; + } + } + else if (s.phase == Phase.Fading) + { + if (now >= s.fadeUntil) { ClearTint(s, st); s.phase = Phase.None; } + } + + // 스케일 복원 감시 — 811b WLHitFeel 스케일 펀치가 예고 중에 잡은 기준값으로 되돌려 놓는 것을 되잡는다 + if (s.phase == Phase.None) + { + if (s.guardUntil > 0f && now < s.guardUntil && s.actor != null && s.baseScale.sqrMagnitude > 0.0001f) + { + var cur = s.actor.transform.localScale; + if (Mathf.Abs(cur.x - s.baseScale.x) > s.baseScale.x * 0.01f) s.actor.transform.localScale = s.baseScale; + } + else { Deactivate(s); } + } + } + } + + // ───────────────────────────────────────── 내부 + + static void ApplySpeed(State s, WLTelegraphSettings st, float target) + { + s.appliedSpeed = s.origSpeed; + s.plannedSeconds = s.hitSeconds / Mathf.Max(0.0001f, s.origSpeed); + s.speedApplied = false; + LastHitSeconds = s.hitSeconds; LastOrigSpeed = s.origSpeed; + + if (!st.speedCurveEnabled || s.anim == null) { LastAppliedSpeed = s.appliedSpeed; LastPlannedSeconds = s.plannedSeconds; return; } + + float desired = s.hitSeconds / target; // 절대 애니메이터 속도 + float floorSpeed = s.origSpeed * Mathf.Clamp(st.minSpeedMultiplier, 0.01f, 1f); + float applied = Mathf.Clamp(desired, floorSpeed, s.origSpeed); // 빨라지지는 않는다 + bool clamped = desired < floorSpeed - 0.0001f; + if (clamped) SpeedClamped++; + + s.anim.speed = applied; + s.appliedSpeed = applied; + s.speedApplied = true; + s.plannedSeconds = s.hitSeconds / Mathf.Max(0.0001f, applied); + SpeedApplied++; + + LastAppliedSpeed = applied; LastPlannedSeconds = s.plannedSeconds; LastWasClamped = clamped; + } + + /// 첫 틱에서 실제 재생 중인 클립을 읽어 히트 시각을 보정한다(이름 추정이 틀렸을 때 자가 치유 · 할당 0). + static void CorrectFromLiveClip(State s, WLTelegraphSettings st) + { + s.corrected = true; + if (!st.correctFromLiveClip || s.anim == null || s.controller == null) return; + + s_clipInfo.Clear(); + s.anim.GetCurrentAnimatorClipInfo(0, s_clipInfo); + AnimationClip best = null; float bestW = 0f; + for (int i = 0; i < s_clipInfo.Count; i++) + if (s_clipInfo[i].clip != null && s_clipInfo[i].weight >= bestW) { bestW = s_clipInfo[i].weight; best = s_clipInfo[i].clip; } + if (best == null) return; + + float ht = ClipHitTime(best, st); + if (ht <= 0f || Mathf.Abs(ht - s.hitSeconds) <= 0.005f) return; + + // 캐시(다음 공격부터는 처음부터 정확하다) + float[] arr; + if (s_hitTimes.TryGetValue(s.controller, out arr) && arr != null) + { + int idx = Mathf.Clamp(s.attackIndex, 0, arr.Length - 1); + if (arr.Length > 0) arr[idx] = ht; + } + + float elapsed = TelegraphRunner.Now - s.startTime; + s.hitSeconds = ht; + float target = s.targetSeconds > 0f ? s.targetSeconds : s.plannedSeconds; + ApplySpeed(s, st, target); + s.startTime = TelegraphRunner.Now - elapsed; // 이미 흐른 만큼은 유지 + HitTimeCorrected++; + } + + static void EndInternal(State s, bool byHit, bool forced) + { + if (s.phase == Phase.None && !s.speedApplied && !s.tinted && !s.pulsed && s.decalIdx < 0) return; + + var st = St; + float now = TelegraphRunner.Now; + + if (s.speedApplied && s.anim != null) + { + // 원본 값으로 되돌린다(Play_Attack 이 정한 FinalAttackSpeed 반영값). 원본이 그 사이 speed 를 바꿨으면(피격·사망) 건드리지 않는다. + if (Mathf.Abs(s.anim.speed - s.appliedSpeed) < 0.0001f) { s.anim.speed = s.origSpeed; SpeedRestored++; } + } + s.speedApplied = false; + + if (s.decalIdx >= 0) { TelegraphDecal.Release(s.decalIdx); s.decalIdx = -1; } + + if (s.pulsed && s.actor != null) + { + s.actor.transform.localScale = s.baseScale; + s.guardUntil = now + (st != null ? Mathf.Max(0f, st.pulseRestoreGuardSeconds) : 0f); + PulseRestored++; + } + s.pulsed = false; + + if (byHit && !forced && st != null && st.flashEnabled && st.flashSeconds > 0f && s.actor != null) + { + if (!s.flashed && WL.Combat.Reaction.MobHitFlash.SetEliteTint(s.actor, st.flashAlbedo, st.flashEmission)) + { s.tinted = true; FlashCount++; } + s.flashed = true; + s.phase = Phase.Fading; + s.fadeUntil = now + st.flashSeconds; + } + else + { + ClearTint(s, st); + s.phase = Phase.None; + } + + s.flashed = false; + EndCount++; + if (s.guardUntil <= now && s.phase == Phase.None) Deactivate(s); + } + + static void ClearTint(State s, WLTelegraphSettings st) + { + if (!s.tinted || s.actor == null) { s.tinted = false; return; } + s.tinted = false; + + // 813s 엘리트 상시 색이 원래 걸려 있었다면 그 색을 되돌려 준다(MobHitFlash.cs 수정 0줄) + var es = WL.Combat.Reaction.WLEliteSettings.Instance; + if (s.wasElite && es != null && WL.Combat.Reaction.WLEliteSettings.Enabled && es.tintEnabled + && WL.Combat.Reaction.MobHitFlash.SetEliteTint(s.actor, es.tintAlbedo, es.tintEmission)) + { TintRestoredElite++; return; } + + WL.Combat.Reaction.MobHitFlash.ClearEliteTint(s.actor); + } + + static void HardClear(State s) + { + if (s.decalIdx >= 0) { TelegraphDecal.Release(s.decalIdx); s.decalIdx = -1; } + s.phase = Phase.None; s.tinted = false; s.pulsed = false; s.speedApplied = false; s.guardUntil = 0f; + Deactivate(s); + } + + static void PlaySfx(WLTelegraphSettings st, Actor actor) + { + if (!st.sfxEnabled) return; + if (st.sfxIndex < 0 || st.sfxIndex >= (int)eSound.Max) return; + if (!TakeSfxToken(st)) return; // 기준서 §G-4 동시 상한 + if (!SoundInfo.isIns || SoundInfo.Ins == null) { SfxNoManager++; return; } + SoundInfo.Ins.Play_OneShot_byDistance((eSound)st.sfxIndex, actor.transform.position, st.sfxVolume); + SfxPlayed++; + } + + /// 예고 SFX 동시 상한 토큰(창 안에서 sfxMaxConcurrent 개까지). 프로브가 사운드 매니저 없이 상한만 검증할 때도 부른다. + public static bool TakeSfxToken(WLTelegraphSettings st) + { + if (st == null) return false; + float now = TelegraphRunner.Now; + float window = Mathf.Max(0.01f, st.sfxWindowSeconds); + int recent = 0; + for (int i = 0; i < s_sfxTimes.Length; i++) if (now - s_sfxTimes[i] < window) recent++; + if (recent >= Mathf.Max(1, st.sfxMaxConcurrent)) { SfxThrottled++; return false; } + s_sfxTimes[s_sfxHead] = now; + s_sfxHead = (s_sfxHead + 1) % s_sfxTimes.Length; + return true; + } + + static State GetState(Actor actor) + { + State s; + if (s_states.TryGetValue(actor, out s)) return s; + s = new State { actor = actor, decalIdx = -1, activeIndex = -1 }; + s_states.Add(actor, s); + return s; + } + + static void Activate(State s) + { + if (s.activeIndex >= 0) return; + if (s_activeCount == s_active.Length) System.Array.Resize(ref s_active, s_active.Length * 2); + s.activeIndex = s_activeCount; + s_active[s_activeCount++] = s; + } + + static void Deactivate(State s) + { + if (s == null) return; + int i = s.activeIndex; + if (i < 0) return; + int last = --s_activeCount; + s_active[i] = s_active[last]; + if (s_active[i] != null) s_active[i].activeIndex = i; + s_active[last] = null; + s.activeIndex = -1; + } + + // ── 몹 테이블(원본 protected 필드) — 리플렉션 1회 · 클래스 참조라 호출당 할당 0 + static MonsterTableData GetTable(Actor actor) + { + var mob = actor as MobActor; + if (mob == null) return null; + if (!s_tableLooked) + { + s_tableField = typeof(MobActor).GetField("m_MonsterTableData", BindingFlags.Instance | BindingFlags.NonPublic); + s_tableLooked = true; + } + if (s_tableField == null) return null; + return s_tableField.GetValue(mob) as MonsterTableData; + } + + // ── 공격 클립의 히트 이벤트 시각(초) — 컨트롤러당 1회 스캔 후 캐시 + static float ResolveHitSeconds(RuntimeAnimatorController rac, int attackIndex, WLTelegraphSettings st) + { + if (rac == null) { HitTimeFallback++; return Mathf.Max(0.01f, st.defaultHitSeconds); } + + float[] arr; + if (!s_hitTimes.TryGetValue(rac, out arr)) + { + arr = BuildHitTimes(rac, st); + s_hitTimes[rac] = arr; + } + if (arr == null || arr.Length == 0) { HitTimeFallback++; return Mathf.Max(0.01f, st.defaultHitSeconds); } + int i = Mathf.Clamp(attackIndex, 0, arr.Length - 1); + float t = arr[i]; + if (t <= 0f) { HitTimeFallback++; return Mathf.Max(0.01f, st.defaultHitSeconds); } + return t; + } + + static float[] BuildHitTimes(RuntimeAnimatorController rac, WLTelegraphSettings st) + { + s_tmpClips.Clear(); + var clips = rac.animationClips; // 컨트롤러당 1회 + if (clips != null) + for (int i = 0; i < clips.Length; i++) + { + var c = clips[i]; + if (c == null) continue; + string n = c.name; + if (n.IndexOf("attack", System.StringComparison.OrdinalIgnoreCase) < 0) continue; + if (n.IndexOf("skill", System.StringComparison.OrdinalIgnoreCase) >= 0) continue; // 스킬은 Play_Skill 경로 + if (ClipHitTime(c, st) <= 0f) continue; + if (!s_tmpClips.Contains(c)) s_tmpClips.Add(c); + } + + if (s_tmpClips.Count == 0) return null; + s_tmpClips.Sort(CompareClipName); + var res = new float[s_tmpClips.Count]; + for (int i = 0; i < s_tmpClips.Count; i++) res[i] = ClipHitTime(s_tmpClips[i], st); + return res; + } + + static int CompareClipName(AnimationClip a, AnimationClip b) + { + if (a == null) return b == null ? 0 : 1; + if (b == null) return -1; + return string.CompareOrdinal(a.name, b.name); + } + + static float ClipHitTime(AnimationClip clip, WLTelegraphSettings st) + { + float t; + if (s_clipHit.TryGetValue(clip, out t)) return t; + + t = -1f; + var evs = clip.events; // 클립당 1회 + var names = st.hitEventNames; + if (evs != null && names != null) + for (int i = 0; i < evs.Length; i++) + for (int k = 0; k < names.Length; k++) + if (!string.IsNullOrEmpty(names[k]) && evs[i].functionName == names[k]) + { if (t < 0f || evs[i].time < t) t = evs[i].time; } + + s_clipHit[clip] = t; + return t; + } + + // ───────────────────────────────────────── 정리 · 프로브 + + /// 전부 원복한다(씬 전환 · C8 · 러너 파괴 · 프로브 정리). + public static void RestoreAll() + { + var st = St; + foreach (var kv in s_states) + { + var s = kv.Value; + if (s.speedApplied && s.anim != null && Mathf.Abs(s.anim.speed - s.appliedSpeed) < 0.0001f) { s.anim.speed = s.origSpeed; SpeedRestored++; } + s.speedApplied = false; + if (s.decalIdx >= 0) { TelegraphDecal.Release(s.decalIdx); s.decalIdx = -1; } + if (s.pulsed && s.actor != null) { s.actor.transform.localScale = s.baseScale; PulseRestored++; } + s.pulsed = false; + ClearTint(s, st); + s.phase = Phase.None; s.guardUntil = 0f; s.flashed = false; + } + for (int i = 0; i < s_activeCount; i++) if (s_active[i] != null) s_active[i].activeIndex = -1; + System.Array.Clear(s_active, 0, s_active.Length); + s_activeCount = 0; + TelegraphDecal.ReleaseAll(); + } + + /// 상태를 통째로 버린다(파괴된 Actor 참조 방지 · 씬 전환). + public static void ClearAll() + { + RestoreAll(); + s_states.Clear(); + } + + public static void ResetDiagnostics() + { + StartCount = EndCount = SkippedBoss = SkippedNotMob = SkippedDisabled = SkippedNoTable = TimeoutCount = 0; + SpeedApplied = SpeedClamped = SpeedRestored = TintApplied = TintRestoredElite = PulseApplied = PulseRestored = 0; + FlashCount = SfxPlayed = SfxThrottled = SfxNoManager = DecalOn = DecalSkipped = HitTimeFallback = HitTimeCorrected = 0; + LastPlannedSeconds = LastHitSeconds = LastOrigSpeed = LastAppliedSpeed = LastTargetSeconds = -1f; + LastWasRanged = LastWasClamped = false; + LastInfo = ""; + for (int i = 0; i < s_sfxTimes.Length; i++) s_sfxTimes[i] = -999f; + } + + /// 프로브용 — 클립 히트 시각 캐시를 비운다(에셋을 갈아 끼운 뒤 재측정). + public static void ClearClipCache() { s_hitTimes.Clear(); s_clipHit.Clear(); } + + /// 프로브용 — 컨트롤러의 공격 클립 히트 시각 표(초). null = 못 찾음. + public static float[] HitSecondsTable(RuntimeAnimatorController rac) + { + var st = St; + if (rac == null || st == null) return null; + float[] arr; + if (!s_hitTimes.TryGetValue(rac, out arr)) { arr = BuildHitTimes(rac, st); s_hitTimes[rac] = arr; } + return arr; + } + } +} diff --git a/Assets/WL/Combat/Telegraph/Telegraph.cs.meta b/Assets/WL/Combat/Telegraph/Telegraph.cs.meta new file mode 100644 index 000000000..ada5de06b --- /dev/null +++ b/Assets/WL/Combat/Telegraph/Telegraph.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2aae7ce96988104094a9e98e882a66b \ No newline at end of file diff --git a/Assets/WL/Combat/Telegraph/TelegraphDecal.cs b/Assets/WL/Combat/Telegraph/TelegraphDecal.cs new file mode 100644 index 000000000..91636545e --- /dev/null +++ b/Assets/WL/Combat/Telegraph/TelegraphDecal.cs @@ -0,0 +1,387 @@ +// ───────────────────────────────────────────────────────────────────────────── +// TelegraphDecal.cs — 지면 예고 판(① 기준서 §G-1) · 판정 범위와 100 % 같은 크기 · 풀링 · GC 0 +// +// PD 지시 #815-5 · 발주서 WL-815f §1-2 +// +// ■ 왜 DecalProjector 가 아닌가 (기준서 §G-1 실측) +// URP-Balanced-Renderer 의 RendererFeatures 는 **SSAO 1개뿐**이라 Decal Renderer Feature 가 없다 → +// DecalProjector 는 화면에 아예 안 나온다. 보유 인디케이터 20종이 나오는 이유는 그것들이 **파티클/쿼드**이기 때문이다. +// → 여기서도 같은 방식(런타임 쿼드 2장 · 새 셰이더/머티리얼/메시 에셋 0)을 쓴다. +// +// ■ 판 구성 (드로우 +2 / 판 · 동시 상한으로 묶는다) +// 바깥 판(범위) = 콜라이더 크기 그대로 · 낮은 알파 → 「여기 맞는다」 +// 안쪽 판(진행) = 같은 자리에서 0 → 100 % 로 **채워진다** · 높은 알파 → 「언제 맞는다」 +// 두 판 모두 몹의 회전을 따라간다(원본 Play_Attack 이 transform.LookAt 으로 계속 돌린다). +// +// ■ 크기의 출처 = TelegraphShape (투사체 프리팹 BoxCollider) — 코드 상수 0. +// 근접: size.x × size.z 사각형, 중심 = 발사 위치 + 회전된 collider.center 의 XZ. +// 원거리: 폭 = size.x, 길이 = 투사체 속도 × 수명(기준서 §G-6 「직선 데칼」 · rangedMaxLength 로 자른다). +// +// ■ 색 = 위험 색 규약(기준서 §G-3): 노랑 = 피할 수 있다 · 빨강 = 못 피한다. +// ■ IndicatorInfo.Show_Indicator 재사용 = SO 의 indicatorPrefabName 이 비어 있지 않을 때 덧칠로 켠다(§G-1 「원본 공개 API」). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Telegraph +{ + public static class TelegraphDecal + { + sealed class Plate + { + public GameObject root; + public Transform tf, baseTf, fillTf; + public MeshRenderer baseMr, fillMr; + public bool inUse; + public bool ranged; + public Vector3 localSize; // x = 폭 · z = 길이(실측 콜라이더에서 온다) + public Vector3 localOffset; // 몹 로컬 기준 판 중심(콜라이더 center + 전방 오프셋) + public GameObject indicator; // IndicatorInfo 풀에서 빌린 덧칠(없으면 null) + } + + static Plate[] s_pool; + static int s_inUse; + static Mesh s_quad; + static Material[] s_matBase, s_matFill; // [0] = Avoidable · [1] = Unavoidable + static Shader s_shader; + static int s_colorPropId; + static bool s_hasColorProp, s_shaderTried; + static Transform s_root; + + // ── 진단(프로브가 읽는다) + public static int AcquireCount, ReleaseCount, BudgetSkipCount, PoolGrowCount, IndicatorBorrowCount, IndicatorMissCount; + public static string LastInfo = ""; + public static int InUse { get { return s_inUse; } } + public static int PoolSize { get { return s_pool == null ? 0 : s_pool.Length; } } + public static bool ShaderReady { get { return s_shader != null; } } + public static string ShaderName { get { return s_shader == null ? "(없음)" : s_shader.name; } } + + static WLTelegraphSettings St { get { return WLTelegraphSettings.Instance; } } + + // ───────────────────────────────────────── 공개 API + + /// 판을 하나 켠다. 반환 = 풀 인덱스(-1 = 예산 초과 또는 비활성). + public static int Acquire(Actor actor, in TelegraphBox box, bool ranged, float projectileLifetime, TelegraphDanger danger) + { + var st = St; + if (st == null || !st.decalEnabled || actor == null) return -1; + if (!EnsureShader(st)) return -1; + + if (s_inUse >= Mathf.Max(1, st.maxConcurrent)) { BudgetSkipCount++; return -1; } + + int idx = Take(st); + if (idx < 0) return -1; + var p = s_pool[idx]; + + // ── 크기(판정과 같은 값) ──────────────────────────────────────── + float scale = st.sizeScale <= 0f ? 1f : st.sizeScale; + float width = Mathf.Max(0.05f, box.size.x * scale); + float length; + if (ranged) + { + float travel = box.speed > 0f ? box.speed * Mathf.Max(0f, projectileLifetime) : box.size.z; + length = Mathf.Clamp(travel * scale, box.size.z * scale, Mathf.Max(1f, st.rangedMaxLength)); + } + else length = Mathf.Max(0.05f, box.size.z * scale); + + p.ranged = ranged; + p.localSize = new Vector3(width, 1f, length); + + // 판 중심 = 발사 위치(전방 forwardOffset) + 콜라이더 center 의 XZ. + // 원거리는 투사체가 앞으로 나아가므로 진행 구간의 한가운데로 민다. + float centerZ = st.forwardOffset + box.center.z * scale + (ranged ? length * 0.5f : 0f); + p.localOffset = new Vector3(box.center.x * scale, 0f, centerZ); + + int di = danger == TelegraphDanger.Unavoidable ? 1 : 0; + p.baseMr.sharedMaterial = s_matBase[di]; + p.fillMr.sharedMaterial = s_matFill[di]; + + p.baseTf.localScale = new Vector3(width, 1f, length); + p.root.SetActive(true); + Follow(idx, actor); + SetFill(idx, 0f); + + // ── IndicatorInfo.Show_Indicator 재사용(덧칠 · 원본 공개 API · 로드/풀링은 원본이 한다) + BorrowIndicator(st, p, Mathf.Max(width, length)); + + AcquireCount++; + return idx; + } + + /// 몹의 현재 위치·회전을 따라간다(회전 추종). + public static void Follow(int idx, Actor actor) + { + if (idx < 0 || s_pool == null || idx >= s_pool.Length) return; + var p = s_pool[idx]; + if (!p.inUse || actor == null) return; + var st = St; + if (st == null) return; + + var atf = actor.transform; + Quaternion rot = st.followRotation ? Quaternion.Euler(0f, atf.eulerAngles.y, 0f) : p.tf.rotation; + Vector3 pos = atf.position + rot * p.localOffset; + pos.y = atf.position.y + st.groundYOffset; + p.tf.SetPositionAndRotation(pos, rot); + if (p.indicator != null) p.indicator.transform.SetPositionAndRotation(pos, rot); + } + + /// 진행도 0~1 을 판에 반영한다(채워지는 연출). + public static void SetFill(int idx, float t01) + { + if (idx < 0 || s_pool == null || idx >= s_pool.Length) return; + var p = s_pool[idx]; + if (!p.inUse) return; + var st = St; + if (st == null) return; + + float t = Mathf.Clamp01(t01); + float w = p.localSize.x, l = p.localSize.z; + + if (st.fillMode == TelegraphFill.CenterExpand) + { + p.fillTf.localScale = new Vector3(w * t, 1f, l * t); + p.fillTf.localPosition = Vector3.zero; + } + else // ForwardSweep — 몹 쪽 가장자리에서 앞으로 채워진다 + { + p.fillTf.localScale = new Vector3(w, 1f, Mathf.Max(0.0001f, l * t)); + p.fillTf.localPosition = new Vector3(0f, 0.01f, -l * 0.5f + l * t * 0.5f); + } + } + + /// 판을 끈다(풀 반납). + public static void Release(int idx) + { + if (idx < 0 || s_pool == null || idx >= s_pool.Length) return; + var p = s_pool[idx]; + if (!p.inUse) return; + p.inUse = false; + s_inUse--; + if (p.root != null) p.root.SetActive(false); + if (p.indicator != null) { p.indicator.SetActive(false); p.indicator = null; } + ReleaseCount++; + } + + /// 전부 끈다(씬 전환 · C8 · 프로브 정리). + public static void ReleaseAll() + { + if (s_pool == null) return; + for (int i = 0; i < s_pool.Length; i++) Release(i); + s_inUse = 0; + } + + /// 풀 오브젝트까지 파기한다(프로브 종료 · 에디트 모드 잔재 0). + public static void DestroyAll() + { + ReleaseAll(); + if (s_pool != null) + for (int i = 0; i < s_pool.Length; i++) + if (s_pool[i] != null && s_pool[i].root != null) DestroyObj(s_pool[i].root); + s_pool = null; + if (s_root != null) { DestroyObj(s_root.gameObject); s_root = null; } + if (s_matBase != null) for (int i = 0; i < s_matBase.Length; i++) if (s_matBase[i] != null) DestroyObj(s_matBase[i]); + if (s_matFill != null) for (int i = 0; i < s_matFill.Length; i++) if (s_matFill[i] != null) DestroyObj(s_matFill[i]); + if (s_quad != null) DestroyObj(s_quad); + s_matBase = s_matFill = null; s_quad = null; s_shader = null; s_shaderTried = false; + } + + public static void ResetDiagnostics() + { + AcquireCount = ReleaseCount = BudgetSkipCount = PoolGrowCount = IndicatorBorrowCount = IndicatorMissCount = 0; + LastInfo = ""; + } + + /// 프로브용 — 판의 월드 크기(x = 폭 · z = 길이)와 중심을 돌려준다(콜라이더 대조용). + public static bool TryGetPlate(int idx, out Vector3 size, out Vector3 center, out float yaw) + { + size = Vector3.zero; center = Vector3.zero; yaw = 0f; + if (idx < 0 || s_pool == null || idx >= s_pool.Length) return false; + var p = s_pool[idx]; + if (!p.inUse) return false; + size = new Vector3(p.baseTf.lossyScale.x, 0f, p.baseTf.lossyScale.z); + center = p.tf.position; + yaw = p.tf.eulerAngles.y; + return true; + } + + // ───────────────────────────────────────── 내부 + + static int Take(WLTelegraphSettings st) + { + EnsurePool(st); + for (int i = 0; i < s_pool.Length; i++) + if (!s_pool[i].inUse && s_pool[i].root != null) { s_pool[i].inUse = true; s_inUse++; return i; } + return -1; + } + + static void EnsurePool(WLTelegraphSettings st) + { + int want = Mathf.Max(1, Mathf.Max(st.maxConcurrent, st.poolWarmCount)); + if (s_pool != null && s_pool.Length >= want) return; + + if (s_root == null) + { + var rootGo = new GameObject("__WLTelegraphDecals"); + rootGo.hideFlags = HideFlags.DontSave; + s_root = rootGo.transform; + if (Application.isPlaying) Object.DontDestroyOnLoad(rootGo); + } + + int from = s_pool == null ? 0 : s_pool.Length; + var next = new Plate[want]; + if (s_pool != null) System.Array.Copy(s_pool, next, s_pool.Length); + for (int i = from; i < want; i++) next[i] = MakePlate(i); + s_pool = next; + PoolGrowCount++; + } + + static Plate MakePlate(int i) + { + var p = new Plate(); + p.root = new GameObject("WLTelegraphPlate_" + i); + p.root.hideFlags = HideFlags.DontSave; + p.root.transform.SetParent(s_root, false); + p.tf = p.root.transform; + + p.baseTf = MakeQuad("Range", p.tf, out p.baseMr); + p.fillTf = MakeQuad("Fill", p.tf, out p.fillMr); + p.fillTf.localPosition = new Vector3(0f, 0.01f, 0f); + + p.root.SetActive(false); + return p; + } + + static Transform MakeQuad(string name, Transform parent, out MeshRenderer mr) + { + var go = new GameObject(name); + go.hideFlags = HideFlags.DontSave; + go.transform.SetParent(parent, false); + var mf = go.AddComponent(); + mf.sharedMesh = EnsureQuad(); + mr = go.AddComponent(); + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + mr.receiveShadows = false; + mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; + mr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; + mr.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion; + return go.transform; + } + + /// XZ 평면 1×1 쿼드(위를 본다). 내장 Quad.fbx 의존 0 · 메시 에셋 0. + static Mesh EnsureQuad() + { + if (s_quad != null) return s_quad; + var m = new Mesh(); + m.name = "__WLTelegraphQuad"; + m.hideFlags = HideFlags.DontSave; + m.vertices = new[] + { + new Vector3(-0.5f, 0f, -0.5f), new Vector3(-0.5f, 0f, 0.5f), + new Vector3( 0.5f, 0f, 0.5f), new Vector3( 0.5f, 0f, -0.5f), + }; + m.uv = new[] { new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(1f, 0f) }; + m.normals = new[] { Vector3.up, Vector3.up, Vector3.up, Vector3.up }; + m.triangles = new[] { 0, 1, 2, 0, 2, 3 }; + m.RecalculateBounds(); + s_quad = m; + return m; + } + + static bool EnsureShader(WLTelegraphSettings st) + { + if (s_shader != null && s_matBase != null) return true; + if (s_shaderTried && s_shader == null) return false; + s_shaderTried = true; + + var names = st.decalShaderNames; + if (names != null) + for (int i = 0; i < names.Length && s_shader == null; i++) + if (!string.IsNullOrEmpty(names[i])) s_shader = Shader.Find(names[i]); + if (s_shader == null) { LastInfo = "셰이더 후보를 못 찾음 — 데칼 비활성"; return false; } + + s_hasColorProp = false; + var props = st.decalColorProperties; + if (props != null) + for (int i = 0; i < props.Length; i++) + if (!string.IsNullOrEmpty(props[i]) && s_shader.FindPropertyIndex(props[i]) >= 0) + { s_colorPropId = Shader.PropertyToID(props[i]); s_hasColorProp = true; break; } + + s_matBase = new Material[2]; + s_matFill = new Material[2]; + for (int d = 0; d < 2; d++) + { + Color c = d == 1 ? st.colorUnavoidable : st.colorAvoidable; + s_matBase[d] = MakeMat(st, "__WLTelegraphBase" + d, c, st.plateAlpha); + s_matFill[d] = MakeMat(st, "__WLTelegraphFill" + d, c, st.fillAlpha); + } + LastInfo = "셰이더 " + s_shader.name + " · 색 프로퍼티 " + (s_hasColorProp ? "O" : "X"); + return true; + } + + static Material MakeMat(WLTelegraphSettings st, string name, Color c, float alpha) + { + var m = new Material(s_shader); + m.name = name; + m.hideFlags = HideFlags.DontSave; + // URP Unlit 반투명 세팅(런타임) — 렌더링 배관이라 SO 튜닝 값이 아니다(811gh DashAfterImage 와 같은 방식). + if (m.HasProperty("_Surface")) m.SetFloat("_Surface", 1f); + if (m.HasProperty("_Blend")) m.SetFloat("_Blend", 0f); + if (m.HasProperty("_SrcBlend")) m.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha); + if (m.HasProperty("_DstBlend")) m.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + if (m.HasProperty("_ZWrite")) m.SetFloat("_ZWrite", 0f); + if (m.HasProperty("_AlphaClip")) m.SetFloat("_AlphaClip", 0f); + if (m.HasProperty("_Cull")) m.SetFloat("_Cull", 0f); + m.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); + m.DisableKeyword("_ALPHATEST_ON"); + m.SetOverrideTag("RenderType", "Transparent"); + m.renderQueue = st.decalRenderQueue > 0 ? st.decalRenderQueue : 3050; + + var col = new Color(c.r, c.g, c.b, Mathf.Clamp01(alpha)); + if (s_hasColorProp) m.SetColor(s_colorPropId, col); + m.color = col; + return m; + } + + static void BorrowIndicator(WLTelegraphSettings st, Plate p, float footprint) + { + if (string.IsNullOrEmpty(st.indicatorPrefabName)) return; + if (!IndicatorInfo.isIns || IndicatorInfo.Ins == null) { IndicatorMissCount++; return; } + + var plate = p; + float want = footprint; + IndicatorInfo.Ins.Make_Indicator(st.indicatorPrefabName, go => + { + if (go == null || !plate.inUse) return; + go.transform.SetPositionAndRotation(plate.tf.position, plate.tf.rotation); + float s = st.indicatorAutoScale ? IndicatorScale(go, want) : 1f; + go.transform.localScale = new Vector3(s, s, s); + go.SetActive(true); + plate.indicator = go; + IndicatorBorrowCount++; + }); + } + + /// 인디케이터의 파티클 시작 크기를 「고유 지름」으로 보고, 원하는 발자국 크기에 맞는 배수를 낸다(실측 · 811s 방식의 축약). + static float IndicatorScale(GameObject go, float wantDiameter) + { + float native = 0f; + var systems = go.GetComponentsInChildren(true); + for (int i = 0; i < systems.Length; i++) + { + var main = systems[i].main; + float sz = main.startSizeMultiplier * Mathf.Max(0.0001f, systems[i].transform.lossyScale.x); + if (sz > native) native = sz; + } + if (native <= 0.0001f) return 1f; + return Mathf.Clamp(wantDiameter / native, 0.05f, 20f); + } + + static void DestroyObj(Object o) + { + if (o == null) return; + if (Application.isPlaying) Object.Destroy(o); else Object.DestroyImmediate(o); + } + } +} diff --git a/Assets/WL/Combat/Telegraph/TelegraphDecal.cs.meta b/Assets/WL/Combat/Telegraph/TelegraphDecal.cs.meta new file mode 100644 index 000000000..7771a8f9e --- /dev/null +++ b/Assets/WL/Combat/Telegraph/TelegraphDecal.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4365a2a5e7f90a945849c31ab081f81e \ No newline at end of file diff --git a/Assets/WL/Combat/Telegraph/TelegraphRunner.cs b/Assets/WL/Combat/Telegraph/TelegraphRunner.cs new file mode 100644 index 000000000..90498be4e --- /dev/null +++ b/Assets/WL/Combat/Telegraph/TelegraphRunner.cs @@ -0,0 +1,81 @@ +// ───────────────────────────────────────────────────────────────────────────── +// TelegraphRunner.cs — 815f 전용 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad · 코루틴 없음 · GC 0) +// +// PD 지시 #815-5 · 발주서 WL-815f +// +// ■ 왜 ImpactRunner(811gh) · WLReactionRunner(811def) 에 얹지 않았나 +// 같은 파일에 틱을 붙이면 **병합 충돌**이 난다(811gh 가 811i 때문에 파일을 나눈 것과 같은 이유). +// 새 Manager/Singleton 패러다임이 아니라 기존 러너와 **같은 패턴**을 하나 더 두는 것이다. +// +// ■ 무엇을 하나 +// Telegraph.Tick — 데칼 위치·회전 추종 · 채워지는 진행 · 스케일 펄스 · 히트 직전 흰 섬광 · 타임아웃 원복. +// 파괴·비활성·앱 종료·씬 전환 시 애니메이터 속도 / 스케일 / MPB 색 / 데칼을 전부 원복한다. +// +// ■ 에디트 모드 +// 프로브가 Play 없이 검증할 수 있게 ForceTick(delta) 를 둔다(러너 오브젝트 없이 같은 틱 1회 + 시뮬레이션 시계). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace WL.Combat.Telegraph +{ + public sealed class TelegraphRunner : MonoBehaviour + { + static TelegraphRunner s_runner; + static bool s_sceneHooked; + + public static bool Exists { get { return s_runner != null; } } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() { if (WLTelegraphSettings.Enabled) Ensure(); } + + public static void Ensure() + { + if (s_runner != null) return; + if (!Application.isPlaying) return; // 에디트 모드에서는 오브젝트를 만들지 않는다(프로브는 ForceTick 을 쓴다) + var go = new GameObject("__WLTelegraph"); + go.hideFlags = HideFlags.DontSave; + DontDestroyOnLoad(go); + s_runner = go.AddComponent(); + if (!s_sceneHooked) { SceneManager.activeSceneChanged += OnSceneChanged; s_sceneHooked = true; } + } + + static void OnSceneChanged(Scene from, Scene to) { Telegraph.ClearAll(); } + + // ── 에디트 모드 시뮬레이션 시계(811gh ImpactRunner 와 같은 방식) + static float s_simNow; + static bool s_simActive; + + /// 연출 타이밍의 기준 시각. Play = Time.unscaledTime · 프로브(ForceTick) = 시뮬레이션 시계. + public static float Now { get { return s_simActive ? s_simNow : Time.unscaledTime; } } + + void Update() + { + s_simActive = false; + Telegraph.Tick(Time.unscaledTime); + } + + /// 프로브(에디트 모드)용 — 러너 없이 같은 틱을 1회 돌리고 시뮬레이션 시계를 delta 만큼 민다. + public static void ForceTick(float unscaledDelta) + { + if (!s_simActive) { s_simActive = true; s_simNow = Time.unscaledTime; } + s_simNow += unscaledDelta; + Telegraph.Tick(s_simNow); + } + + /// 프로브용 — 시뮬레이션 시계를 켜기만 한다(이벤트를 올리기 전에 기준점을 고정). + public static void BeginForceClock() + { + if (!s_simActive) { s_simActive = true; s_simNow = Time.unscaledTime; } + } + + /// 프로브용 — 시뮬레이션 시계를 끄고 실제 시간으로 되돌린다. + public static void ResetForceClock() { s_simActive = false; s_simNow = 0f; } + + void OnDisable() { Telegraph.RestoreAll(); } + void OnApplicationQuit() { Telegraph.RestoreAll(); } + } +} diff --git a/Assets/WL/Combat/Telegraph/TelegraphRunner.cs.meta b/Assets/WL/Combat/Telegraph/TelegraphRunner.cs.meta new file mode 100644 index 000000000..aafd9b932 --- /dev/null +++ b/Assets/WL/Combat/Telegraph/TelegraphRunner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 228de922520081e44a9d68985af78bea \ No newline at end of file diff --git a/Assets/WL/Combat/Telegraph/TelegraphShape.cs b/Assets/WL/Combat/Telegraph/TelegraphShape.cs new file mode 100644 index 000000000..9ec258133 --- /dev/null +++ b/Assets/WL/Combat/Telegraph/TelegraphShape.cs @@ -0,0 +1,173 @@ +// ───────────────────────────────────────────────────────────────────────────── +// TelegraphShape.cs — 예고 판의 크기를 「투사체 프리팹의 BoxCollider」에서 읽어 온다(신규 데이터 0) +// +// PD 지시 #815-5 · 발주서 WL-815f §1-2 · 기준서 v1 §G-0 「공격 판정의 출처」 +// +// ■ 왜 콜라이더를 읽나 +// 원본은 근접도 투사체를 쏜다(MobActor.Projectile → Shoot_Projectile(s_Porjectile1, …)). +// 따라서 **맞는 범위 = 그 투사체 프리팹의 BoxCollider** 다. 데칼 크기를 코드 상수로 넣으면 판정과 어긋난다. +// 실측(기준서 §G-0): Mob/P_*_Meele 3×3×3 center 0 · Elite/P_Golem_A 3×3×3 center z 1.5 · FieldBoss/Anubis_Attack 2×2×4 center z 2. +// +// ■ 어떻게 읽나 (이름 1개당 1회 · 결과는 캐시 · 정상 흐름 GC 0) +// ① 에디터(에디트 모드·플레이 모드 공통) = AssetDatabase 로 **동기** 로드 → 프로브가 Play 없이 실측할 수 있다. +// ② 빌드/플레이 = ProjectileInfo 풀(원본이 미리 로드해 둔 인스턴스)을 이름으로 훑어 콜라이더를 읽는다. +// ③ 그래도 없으면 Addressables 비동기 로드를 1회 걸고(중복 방지), 그 사이에는 SO 기본값을 쓴다. +// +// ■ 원거리 판(기준서 §G-6 「직선 데칼」) +// 길이 = 투사체 속도 × 수명. 속도는 프리팹의 ProjectileBase.Speed(= 기본 5) 이고 +// Projectile_Strait.m_Speed 가 0 보다 크면 그 값이 이긴다(원본 Projectile_Strait.Set 실측). +// 수명은 몹 테이블의 f_ProjectileLifeTime1(근접 0.1 · 원거리 1.5 실측). +// +// 🔴 원본 무수정 — 읽기만 한다. +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using UnityEngine; + +namespace WL.Combat.Telegraph +{ + /// 투사체 1종의 판정 상자(로컬 기준) + 이동 정보. + public struct TelegraphBox + { + public Vector3 size; // BoxCollider.size × 프리팹 lossyScale + public Vector3 center; // BoxCollider.center × 프리팹 lossyScale + public float speed; // 실효 이동 속도(m/s · 0 이면 제자리) + public bool resolved; // 진짜 프리팹에서 읽었는가(false = SO 기본값) + public string source; // 진단 문자열(어디서 읽었나) + } + + public static class TelegraphShape + { + const string kPrefix = "Assets/Res_Addr/Projectile/"; + const string kSuffix = ".prefab"; + + static readonly Dictionary s_cache = new Dictionary(32); + static readonly HashSet s_inFlight = new HashSet(); + + // ── 진단(프로브가 읽는다) + public static int ResolvedCount, FallbackCount, PoolHitCount, AssetDbHitCount, AddressablesRequestCount; + public static string LastSource = ""; + + public static int CacheCount { get { return s_cache.Count; } } + + static WLTelegraphSettings St { get { return WLTelegraphSettings.Instance; } } + + /// 런타임 ProjectileData.Speed 의 기본값(원본 ProjectileBase.cs:19 실측 = 5). SO 로 바꿀 수 있다. + static float DefaultSpeed { get { var st = St; return st != null && st.defaultProjectileSpeed > 0f ? st.defaultProjectileSpeed : 5f; } } + + /// 이 투사체 이름의 판정 상자. 아직 못 읽었으면 SO 기본값(resolved = false)을 돌려주고 비동기 로드를 1회 건다. + public static TelegraphBox Get(string projectileName) + { + if (string.IsNullOrEmpty(projectileName) || projectileName == "None") return Fallback("(투사체 없음)"); + + TelegraphBox box; + if (s_cache.TryGetValue(projectileName, out box)) return box; + + // ① 에디터 — 동기 로드(프로브가 Play 없이 실측한다) +#if UNITY_EDITOR + var asset = UnityEditor.AssetDatabase.LoadAssetAtPath(kPrefix + projectileName + kSuffix); + if (asset != null && ReadFrom(asset.transform, "AssetDatabase", out box)) + { + s_cache[projectileName] = box; ResolvedCount++; AssetDbHitCount++; LastSource = box.source; return box; + } +#endif + // ② 원본 풀(ProjectileInfo 가 table_effectlist 로 미리 로드해 둔 인스턴스) + if (ProjectileInfo.isIns && ProjectileInfo.Ins != null) + { + var root = ProjectileInfo.Ins.transform; + string baseName = BaseName(projectileName); + for (int i = 0; i < root.childCount; i++) + { + var c = root.GetChild(i); + if (!NameMatches(c.name, baseName)) continue; + if (ReadFrom(c, "ProjectileInfo 풀", out box)) + { + s_cache[projectileName] = box; ResolvedCount++; PoolHitCount++; LastSource = box.source; return box; + } + } + } + + // ③ Addressables 비동기 로드 1회 — 이번 예고는 기본값으로 그리고 다음 예고부터 정확해진다 + if (!s_inFlight.Contains(projectileName) && AddrResourceMgr.isIns && AddrResourceMgr.Ins != null) + { + s_inFlight.Add(projectileName); + AddressablesRequestCount++; + string key = projectileName; + AddrResourceMgr.Ins.LoadObject(kPrefix + key + kSuffix, handle => + { + s_inFlight.Remove(key); + var go = handle.Result; + TelegraphBox b; + if (go != null && ReadFrom(go.transform, "Addressables", out b)) { s_cache[key] = b; ResolvedCount++; } + }); + } + + return Fallback(projectileName); + } + + /// 프로브/테스트용 — 실측값을 직접 주입한다(로드 경로 없이 크기 일치를 검증할 때). + public static void Prime(string projectileName, Vector3 size, Vector3 center, float speed, string source) + { + if (string.IsNullOrEmpty(projectileName)) return; + s_cache[projectileName] = new TelegraphBox { size = size, center = center, speed = speed, resolved = true, source = source }; + } + + public static void ClearCache() { s_cache.Clear(); s_inFlight.Clear(); } + + public static void ResetDiagnostics() + { + ResolvedCount = FallbackCount = PoolHitCount = AssetDbHitCount = AddressablesRequestCount = 0; + LastSource = ""; + } + + // ───────────────────────────────────────── 내부 + + static TelegraphBox Fallback(string why) + { + FallbackCount++; + var st = St; + var size = st != null ? st.defaultBoxSize : new Vector3(3f, 3f, 3f); + return new TelegraphBox { size = size, center = Vector3.zero, speed = 0f, resolved = false, source = "기본값 " + why }; + } + + static string BaseName(string projectileName) + { + int slash = projectileName.LastIndexOf('/'); + return slash >= 0 ? projectileName.Substring(slash + 1) : projectileName; + } + + /// 풀 인스턴스 이름은 "P_Batty_Meele" 또는 "P_Batty_Meele(Clone)" 이다. 접두 비교(부분 문자열 생성 0). + static bool NameMatches(string instanceName, string baseName) + { + if (instanceName == null || instanceName.Length < baseName.Length) return false; + return string.CompareOrdinal(instanceName, 0, baseName, 0, baseName.Length) == 0; + } + + static bool ReadFrom(Transform root, string source, out TelegraphBox box) + { + box = default(TelegraphBox); + if (root == null) return false; + + var bc = root.GetComponent(); + if (bc == null) bc = root.GetComponentInChildren(true); + if (bc == null) return false; + + // 프리팹 자체 스케일까지 반영한다(실측 = 전부 1 이지만 값이 바뀌어도 따라간다) + var ls = bc.transform.lossyScale; + var size = new Vector3(Mathf.Abs(bc.size.x * ls.x), Mathf.Abs(bc.size.y * ls.y), Mathf.Abs(bc.size.z * ls.z)); + var center = new Vector3(bc.center.x * ls.x, bc.center.y * ls.y, bc.center.z * ls.z); + + // 🔴 속도 실측 — 프리팹에는 Speed 필드가 없다(ProjectileBase 는 ColliderLayer/m_Pierce/str_HitEffect 만 직렬화). + // 실제 속도는 런타임 ProjectileData.Speed(기본 5)이고, Projectile_Strait.m_Speed 가 0 보다 크면 그 값이 이긴다 + // (Projectile_Strait.Set: `if (m_Speed > 0f) m_ProjecTileData.Speed = m_Speed;`). + // ProjectileInfo.Shoot_Projectile 은 Speed 를 건드리지 않으므로 m_Speed = 0 인 프리팹은 기본값 5 로 난다. + float speed = 0f; + var strait = root.GetComponent(); + if (strait == null) strait = root.GetComponentInChildren(true); + if (strait != null) speed = strait.m_Speed > 0f ? strait.m_Speed : DefaultSpeed; + + box = new TelegraphBox { size = size, center = center, speed = speed, resolved = true, source = source }; + return true; + } + } +} diff --git a/Assets/WL/Combat/Telegraph/TelegraphShape.cs.meta b/Assets/WL/Combat/Telegraph/TelegraphShape.cs.meta new file mode 100644 index 000000000..530ffb987 --- /dev/null +++ b/Assets/WL/Combat/Telegraph/TelegraphShape.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0da072705e595446984592bdb331e1e \ No newline at end of file diff --git a/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs b/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs new file mode 100644 index 000000000..1bcc4c8d6 --- /dev/null +++ b/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs @@ -0,0 +1,253 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLTelegraphSettings.cs — 적 공격 예고(텔레그래프) 값의 단일 출처(SOT · C45) +// +// PD 지시 #815-5 「적의 공격을 확실히 유저가 인지하고 피할 수 있는 매커니즘」 +// 발주서 WL-815f · 기준서 v1 §G-0~§G-4 · §G-7 1차(= ① 데칼 + ⓐ 속도 곡선 + ⓓ 펄스 + ③ 색 규약 + ④ SFX) +// +// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLTelegraphSettings.asset +// → Resources.Load("WL/WLTelegraphSettings") 로 1회 로드 후 캐시. +// 에셋이 없거나 enabled = false 또는 RuntimeDisabled 면 Telegraph 가 아무 것도 하지 않는다 +// (C8 · 애니메이터 속도 원본 · 데칼 0 · MPB 0 · 스케일 원본 = 원본 동작 100 %). +// +// 【기준서 §G-0 실측 요약 — 이 값들의 근거】 +// · 원본에는 선딜 개념이 0 이다. 데미지는 공격 클립 안의 애니메이션 이벤트 `Projectile` 이 낸다. +// 즉 「예고 시간 = 클립 시작 ~ 이벤트 시각」이고 실측 0.081~0.228 s(잡몹·엘리트 전원). +// · 그래서 ⓐ 속도 곡선이 성립한다 — 애니메이터를 늦추면 **히트(=데미지)도 같이 늦어진다**(별도 동기화 0). +// · 공격 판정 범위의 출처 = 투사체 프리팹의 BoxCollider 다(근접도 투사체를 쏜다). 실측: +// Mob/P_*_Meele 3×3×3 center 0 · Elite/P_Golem_A 3×3×3 center z 1.5 · FieldBoss/Anubis_Attack 2×2×4 center z 2 +// → 데칼 크기를 코드 상수로 넣지 않고 **그 프리팹의 콜라이더를 읽어** 그린다 = 판정과 100 % 일치. +// · URP Decal Renderer Feature 가 없다(URP-Balanced-Renderer 의 피처 = SSAO 1개뿐) → DecalProjector 는 화면에 안 나온다. +// 기존 인디케이터 20종이 쓰는 **파티클/쿼드 방식**을 따른다(여기서는 런타임 쿼드 2장). +// · 보스(Anubis)는 코드가 3 s 차징을 이미 건다(Boss_Anubis.cs) → **보스는 건드리지 않는다**(bossUntouched). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Telegraph +{ + /// 기준서 §G-3 위험 색 규약 「제안」. 빨강 = 못 피한다(막기) · 노랑 = 피할 수 있다(회피). + public enum TelegraphDanger { Avoidable = 0, Unavoidable = 1 } + + /// 데칼이 「채워지는」 방향. + public enum TelegraphFill { ForwardSweep = 0, CenterExpand = 1 } + + [CreateAssetMenu(fileName = "WLTelegraphSettings", menuName = "WL/Telegraph Settings", order = 326)] + public sealed class WLTelegraphSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLTelegraphSettings"; + + static WLTelegraphSettings s_cached; + static bool s_lookupDone; + + public static WLTelegraphSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 예고만 끈다. + public static bool RuntimeDisabled; + + public static bool Enabled { get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; } } + + /// 811gh ImpactTier 가 읽는다 — 예고 중인 몹에게 히트스톱을 걸지 않는다(기준서 §G-4). + public static bool SkipHitStop { get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled && i.skipHitStopDuringTelegraph; } } + + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + // ─────────────────────────────────────────── 전체 스위치 + [Header("전체 스위치")] + [Tooltip("끄면 속도 곡선·데칼·펄스·색·SFX 가 전부 비활성 = 원본 동작 100 %(C8 롤백 1순위).")] + public bool enabled = true; + + [Tooltip("예고 시작/종료를 Console 에 남긴다(검증용 · GC 발생).")] + public bool verboseLog = false; + + // ─────────────────────────────────────────── ⓐ 예고 시간 · 속도 곡선 + [Header("ⓐ 예고 시간 (기준서 §G-6 표 · AttackStarted → HitboxSpawned 사이 목표 초)")] + [Tooltip("잡몹 근접 목표 예고 초. 기준서 §G-6 = 0.5 s(원본 실측 0.081~0.167).")] + public float telegraphSecondsNormal = 0.5f; + + [Tooltip("잡몹 원거리(Batty_B/D 102·104) 목표 예고 초. 기준서 §G-6 = 0.6 s.")] + public float telegraphSecondsRanged = 0.6f; + + [Tooltip("엘리트 목표 예고 초. 기준서 §G-6 = 0.8 s(첫 타만).")] + public float telegraphSecondsElite = 0.8f; + + [Tooltip("🔴 보스는 건드리지 않는다 — 원본 코드가 이미 3 s 차징을 건다(Boss_Anubis.cs Skill1/2ChargingTime = 3).\n" + + "끄면 보스 평타에도 예고가 걸린다(발주 범위 밖 · 기본 켬 유지 권장).")] + public bool bossUntouched = true; + + [Header("ⓐ 속도 곡선 (animator.speed = 히트시각 ÷ 목표 · 하한 있음)")] + [Tooltip("끄면 애니메이터 속도를 건드리지 않는다(데칼·펄스·색만 남는다).")] + public bool speedCurveEnabled = true; + + [Tooltip("🔴 한계값. 원본 속도 대비 이 배수보다 더 느리게는 만들지 않는다.\n" + + "기준서 §G-2 ⓐ: k = 0.135 는 사실상 정지 화면이라 자연스러운 하한은 0.35 (Batty 예고 0.23 s).\n" + + "→ 속도 곡선 단독으로 0.5 s 는 안 된다. 데칼·펄스와 반드시 함께 쓴다.")] + public float minSpeedMultiplier = 0.35f; + + [Tooltip("공격 클립의 히트 애니메이션 이벤트 함수명(이 이름의 이벤트 시각 = 원본 예고 시간).")] + public string[] hitEventNames = new[] { "Projectile", "BossProjectile2", "BossProjectile3" }; + + [Tooltip("클립에서 히트 이벤트를 못 찾았을 때 쓰는 기본 히트 시각(초). 기준서 실측 중앙값 ≈ 0.16.")] + public float defaultHitSeconds = 0.16f; + + [Tooltip("첫 틱에서 실제 재생 중인 클립을 다시 읽어 히트 시각을 보정한다(이름 추정이 틀렸을 때 자가 치유 · 두 번째 공격부터 정확).")] + public bool correctFromLiveClip = true; + + [Tooltip("HitboxSpawned 가 이 시간 안에 안 오면 강제로 원복한다(피격·사망으로 공격이 끊긴 경우 · 초).")] + public float maxTelegraphSeconds = 2.5f; + + // ─────────────────────────────────────────── ① 지면 데칼 + [Header("① 지면 데칼 (투사체 BoxCollider 를 읽어 같은 크기 · 런타임 쿼드 2장 · 풀링)")] + [Tooltip("끄면 판을 그리지 않는다.")] + public bool decalEnabled = true; + + [Tooltip("동시에 켤 수 있는 판의 최대 수. 초과하면 그 예고는 판을 생략한다(811s 예산기 방식 · 군중에서 화면이 뭉개지는 것 방지).")] + public int maxConcurrent = 6; + + [Tooltip("풀 예열 개수(첫 전투에서의 순간 할당을 없앤다). maxConcurrent 이상 권장.")] + public int poolWarmCount = 8; + + [Tooltip("몹 발밑 기준 판의 높이(지면 Z-파이팅 방지 · m).")] + public float groundYOffset = 0.06f; + + [Tooltip("발사 위치 규약(Actor.Get_CenterPositionFoward 의 기본값)과 같은 전방 거리(m). 원본 = 0.3.")] + public float forwardOffset = 0.3f; + + [Tooltip("판 크기 = 콜라이더 크기 × 이 배수. 1 = 판정과 100 % 일치(기본값 · 바꾸지 말 것 권장).")] + public float sizeScale = 1f; + + [Tooltip("투사체 프리팹을 못 읽었을 때의 기본 판 크기(기준서 실측 잡몹 근접 = 3×3×3).")] + public Vector3 defaultBoxSize = new Vector3(3f, 3f, 3f); + + [Tooltip("투사체 수명(f_ProjectileLifeTime1)이 이 값 이상이면 원거리로 보고 직선 판을 쓴다.\n" + + "실측: 근접 0.1 s · 원거리 1.5 s(Batty_B/D 102·104 · Mummy_King 1024).")] + public float rangedLifetimeThreshold = 0.5f; + + [Tooltip("원거리 직선 판의 최대 길이(m). 길이 = 투사체 속도 × 수명 이지만 화면을 넘지 않게 자른다.")] + public float rangedMaxLength = 12f; + + [Tooltip("🔴 투사체 프리팹에는 속도 필드가 없다(실측) — 실제 속도는 런타임 ProjectileData.Speed 기본값이다.\n" + + "원본 ProjectileBase.cs:19 실측 = 5. Projectile_Strait.m_Speed 가 0 보다 크면 프리팹 값이 이긴다.")] + public float defaultProjectileSpeed = 5f; + + [Tooltip("몹이 회전하면 판도 따라 돈다(원본 Play_Attack 이 transform.LookAt 으로 계속 돌린다).")] + public bool followRotation = true; + + [Tooltip("채워지는 방향. ForwardSweep = 몹 쪽에서 앞으로 채워진다 · CenterExpand = 가운데서 퍼진다.")] + public TelegraphFill fillMode = TelegraphFill.ForwardSweep; + + [Tooltip("바깥 판(범위)의 알파.")] + [Range(0f, 1f)] public float plateAlpha = 0.28f; + + [Tooltip("채워지는 안쪽 판의 알파.")] + [Range(0f, 1f)] public float fillAlpha = 0.62f; + + [Tooltip("판 셰이더 후보(앞에서부터 Shader.Find · 새 셰이더/머티리얼 에셋 0 · 811gh DashAfterImage 와 같은 방식).")] + public string[] decalShaderNames = new[] { "Universal Render Pipeline/Unlit", "Unlit/Transparent", "Sprites/Default" }; + + [Tooltip("판 색 프로퍼티 후보(앞에서부터 FindPropertyIndex).")] + public string[] decalColorProperties = new[] { "_BaseColor", "_Color", "_TintColor" }; + + [Tooltip("판의 렌더 큐(반투명 · 3000 이상). 지면 위·캐릭터 아래로 보이게 한다.")] + public int decalRenderQueue = 3050; + + [Header("① IndicatorInfo.Show_Indicator 재사용 (원본 공개 API · 로드/풀링/자동 끄기 완비)")] + [Tooltip("비어 있지 않으면 Res_Addr/Obj/<이름>.prefab 인디케이터를 같이 띄운다(IndicatorInfo.Make_Indicator 로 풀에서 꺼내 WL 이 위치·회전·수명을 직접 준다).\n" + + "🔴 기본값이 빈 문자열인 이유 = 보유 인디케이터 20종은 전부 **원형 파티클**이라 사각 콜라이더와 크기를 100 % 맞출 수 없다.\n" + + "판정 일치는 위의 쿼드가 담당하고, 이 항목은 「연출 덧칠」이다. 예: Golem_Child_Skill1_Indicator.")] + public string indicatorPrefabName = ""; + + [Tooltip("인디케이터의 파티클 시작 크기를 실측해 콜라이더 최대 변에 맞춰 스케일을 잡는다(끄면 스케일 1).")] + public bool indicatorAutoScale = true; + + // ─────────────────────────────────────────── ③ 색 규약 + [Header("③ 위험 색 규약 (기준서 §G-3 「제안」 · 빨강 = 못 피함/막기 · 노랑 = 회피 가능)")] + [Tooltip("회피 가능(노랑) 판 색.")] + [ColorUsage(true, true)] public Color colorAvoidable = new Color(1f, 0.82f, 0.15f, 1f); + + [Tooltip("회피 불가(빨강) 판 색.")] + [ColorUsage(true, true)] public Color colorUnavoidable = new Color(1f, 0.16f, 0.12f, 1f); + + [Tooltip("잡몹 근접의 위험 등급. 기준서 §G-6 = 노랑(회피 가능).")] + public TelegraphDanger dangerNormalMelee = TelegraphDanger.Avoidable; + + [Tooltip("잡몹 원거리의 위험 등급. 기준서 §G-6 = 노랑(회피 가능).")] + public TelegraphDanger dangerNormalRanged = TelegraphDanger.Avoidable; + + [Tooltip("엘리트의 위험 등급. 기준서 §G-6 엘리트 연속기 = 「데칼 + 색(노랑)」.\n" + + "🔴 「막아야 하는 공격」을 도입할 때 Unavoidable(빨강)로 올리면 된다 = 값 한 줄.")] + public TelegraphDanger dangerElite = TelegraphDanger.Avoidable; + + // ─────────────────────────────────────────── ⓓ 몹 색/펄스 + [Header("ⓓ 몹 틴트 (MobHitFlash MPB 경로 재사용 · 813s 와 같은 소유자 = 머티리얼 인스턴스 0)")] + [Tooltip("끄면 몹 색을 건드리지 않는다.")] + public bool tintEnabled = true; + + [Tooltip("회피 가능(노랑) 예고 중 알베도 색.")] + [ColorUsage(true, true)] public Color tintAlbedoAvoidable = new Color(1.20f, 1.05f, 0.60f, 1f); + [Tooltip("회피 가능(노랑) 예고 중 에미션 색.")] + [ColorUsage(true, true)] public Color tintEmissionAvoidable = new Color(0.55f, 0.34f, 0.02f, 1f); + + [Tooltip("회피 불가(빨강) 예고 중 알베도 색.")] + [ColorUsage(true, true)] public Color tintAlbedoUnavoidable = new Color(1.30f, 0.60f, 0.55f, 1f); + [Tooltip("회피 불가(빨강) 예고 중 에미션 색.")] + [ColorUsage(true, true)] public Color tintEmissionUnavoidable = new Color(0.75f, 0.08f, 0.04f, 1f); + + [Tooltip("히트 직전 흰 섬광을 넣는다.")] + public bool flashEnabled = true; + + [Tooltip("히트까지 남은 시간이 이 값 이하가 되면 흰 섬광으로 바꾼다(초 · 「직전」).")] + public float flashLeadSeconds = 0.06f; + + [Tooltip("흰 섬광이 유지되는 시간(초 · 60 fps 기준 0.05 ≈ 3프레임).")] + public float flashSeconds = 0.05f; + + [ColorUsage(true, true)] public Color flashAlbedo = new Color(2.2f, 2.2f, 2.2f, 1f); + [ColorUsage(true, true)] public Color flashEmission = new Color(1.6f, 1.6f, 1.6f, 1f); + + [Header("ⓓ 스케일 펄스 (813s EliteMarker 와 같은 localScale 경로 · 원 스케일 복원)")] + [Tooltip("끄면 크기를 건드리지 않는다.")] + public bool pulseEnabled = true; + + [Tooltip("예고 끝(히트)에서의 최대 배수. 기준서 §G-2 ⓓ = 1.12.")] + public float pulseScale = 1.12f; + + [Tooltip("원복 뒤 이 시간 동안 원 스케일을 다시 확인한다(811b WLHitFeel 스케일 펀치와 겹칠 때 크기가 남는 것 방지 · 초).")] + public float pulseRestoreGuardSeconds = 0.2f; + + // ─────────────────────────────────────────── ④ SFX · 히트스톱 + [Header("④ 예고 SFX (보유 사운드 재사용 · 동시 상한)")] + [Tooltip("끄면 소리를 내지 않는다.")] + public bool sfxEnabled = true; + + [Tooltip("eSound 인덱스. 9 = s009_CastSpell(짧은 캐스팅 음). 프로브가 SoundInfo.arr_clip 실존을 확인한다.")] + public int sfxIndex = 9; + + [Tooltip("최대 볼륨(거리 감쇠는 원본 Play_OneShot_byDistance 가 한다).")] + [Range(0f, 1f)] public float sfxVolume = 0.35f; + + [Tooltip("기준서 §G-4 「동시 3발 상한」 — 이 창 안에서 이 개수까지만 낸다(군중에서 소리가 뭉개지는 것 방지).")] + public int sfxMaxConcurrent = 3; + + [Tooltip("동시 상한을 세는 창(초).")] + public float sfxWindowSeconds = 0.35f; + + [Header("④ 811gh 히트스톱과의 관계 (기준서 §G-4)")] + [Tooltip("🔴 예고 중인 몹을 때렸을 때 히트스톱을 걸지 않는다.\n" + + "이유: hitStopMs 가 예고 구간에 겹치면 timeScale 이 바뀌어 **예고 길이가 흔들린다**.\n" + + "끄면 811gh 기존 동작 그대로(히트스톱 유지).")] + public bool skipHitStopDuringTelegraph = true; + } +} diff --git a/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs.meta b/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs.meta new file mode 100644 index 000000000..4598299c7 --- /dev/null +++ b/Assets/WL/Combat/Telegraph/WLTelegraphSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 26b2beddb0dd9fa498cecef15d5e4cdd \ No newline at end of file