diff --git a/AgentScripts/WL811def_Probe.cs b/AgentScripts/WL811def_Probe.cs new file mode 100644 index 000000000..061993c6b --- /dev/null +++ b/AgentScripts/WL811def_Probe.cs @@ -0,0 +1,585 @@ +// WL811def_Probe.cs — #811d/e/f 전투 연출 3종 검증 프로브 (읽기 전용 · 에셋 무수정 · 가짜 이벤트 발행) +// +// 헤드리스에서는 PlayFab 초기화가 실패해 인게임에 못 들어간다(811b 실측) → Play 의존 검증은 **Title Play 에서 가짜 이벤트**로 대체한다. +// 가짜 = RealCamera 1개(빈 GO) · MobActor/PCActor 컴포넌트(비활성 · Awake 만 · 실 몹 머티리얼 T_Aspethis_D = MK Toon) · CombatEvents.Raise* 직접 호출. +// +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Dump // 3 SO 값 · 구독 상태 (Edit/Play) +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Props // ⓔ 몹 프리팹 299 → 셰이더별 프로퍼티 실측 표 (Edit) +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.KillCam // ⓑ 줌·슬로모 발동/복원 ms · 쿨 · 히트스톱 스킵 (Title Play) +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.BossCam // ⓒ Spawned(isBoss) → 오빗 1.2 s (Title Play) +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.ComboLead // 3타 리드 ×1.3 → 복원 (Title Play) +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Chain // ⓓ 3 s 안 2·3·5킬 티어 · 폭발 슬롯 ≤1 (Title Play) +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Merge // ⓓ 합산 윈도우 0.25 s (Title Play) +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Blink // ⓔ 10회 피격 → MPB on/off 20 · 머티리얼 인스턴스 0 · 잔류 0 (Title Play) +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.GcCheck // ⓕ 구독자별 100회 GC 증가 (Title Play) +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Toggle // ⓖ 3 SO 런타임 off → 연출 0 (Title Play) +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Status // 카운터 전부 +// unity command run_script --file AgentScripts/WL811def_Probe.cs --entry WL811def_Probe.Budget --args '[5.0]' // 병합 후 실전투(메인 에디터): ON/OFF fps·setPass 비교 +// 결과: Screenshots_WL/WL811def/<이름>_.txt + Console + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; +using UnityEngine; +using UnityEngine.AI; +using WL.Combat.Core; +using WL.Combat.Reaction; +using WL.CombatCamera; + +public static class WL811def_Probe +{ + const string OutDir = "Screenshots_WL/WL811def"; + const string SampleMat = "Assets/ResWork/Suriyun/Monster Pack Pyramid/Materials/Aspethis/T_Aspethis_D.mat"; + const string DissolveMat = "Assets/ThirdParty/Dissolve Edge by Moonflower Carnivore/Materials/mat_DissolveEdge.mat"; + + public static void KillCam() { Launch(h => h.Co_KillCam()); } + public static void BossCam() { Launch(h => h.Co_BossCam()); } + public static void ComboLead() { Launch(h => h.Co_ComboLead()); } + public static void Chain() { Launch(h => h.Co_Chain()); } + public static void Merge() { Launch(h => h.Co_Merge()); } + public static void Blink() { Launch(h => h.Co_Blink()); } + public static void GcCheck() { Launch(h => h.Co_Gc()); } + public static void Toggle() { Launch(h => h.Co_Toggle()); } + public static void Budget(float seconds) { Launch(h => h.Co_Budget(seconds <= 0f ? 5f : seconds)); } + + public static object Dump() + { + var sb = new StringBuilder(); + sb.AppendLine("# WL811def Dump " + DateTime.Now.ToString("HH:mm:ss") + " playing=" + Application.isPlaying + " core=" + WLCombatCoreSettings.Enabled); + var cam = WLCombatCameraSettings.Instance; var re = WLReactionSettings.Instance; var bl = WLHitBlinkSettings.Instance; + sb.AppendLine("camera asset=" + (cam != null ? "found" : "NULL") + " Enabled=" + WLCombatCameraSettings.Enabled + " subscribed=" + CombatCameraDirector.Subscribed); + sb.AppendLine("reaction asset=" + (re != null ? "found" : "NULL") + " Enabled=" + WLReactionSettings.Enabled + " chainSub=" + KillChain.Subscribed + " mergeSub=" + DamageAggregator.Subscribed); + sb.AppendLine("blink asset=" + (bl != null ? "found" : "NULL") + " Enabled=" + WLHitBlinkSettings.Enabled + " subscribed=" + MobHitFlash.Subscribed); + sb.AppendLine("subs: Killed=" + CombatEvents.Killed.Count + " Spawned=" + CombatEvents.Spawned.Count + " ComboStage=" + CombatEvents.ComboStage.Count + " HitConfirmed=" + CombatEvents.HitConfirmed.Count); + var core = WLCombatCoreSettings.Instance; + if (core != null) sb.AppendLine("core: cinematicDefaultScale=" + core.cinematicDefaultScale + " killCamCooldownSeconds=" + core.killCamCooldownSeconds + " clusterBurstMax=" + core.clusterBurstMax + " dissolveMax=" + core.dissolveMax); + if (cam != null) sb.AppendLine("## WLCombatCameraSettings\n" + JsonUtility.ToJson(cam, true)); + if (re != null) sb.AppendLine("## WLReactionSettings\n" + JsonUtility.ToJson(re, true)); + if (bl != null) sb.AppendLine("## WLHitBlinkSettings\n" + JsonUtility.ToJson(bl, true)); + Save("dump", sb); return sb.ToString(); + } + + /// ⓔ 몹 프리팹 전수(Res_Addr/Mobs) → 비파티클 렌더러의 셰이더별 프로퍼티 존재(Shader.FindPropertyIndex) 표. Edit 모드. + public static object Props() + { + var sb = new StringBuilder(); + sb.AppendLine("# WL811def Props " + DateTime.Now.ToString("HH:mm:ss")); + string[] names = { "_AlbedoColor", "_BaseColor", "_Color", "_EmissionColor", "_EdgeAroundHDR", "_EdgeAround", "_Progress" }; + var byShader = new Dictionary(); // [rendererCount, prefabCount, prop0..] + var lastPrefab = new Dictionary(); + int prefabs = 0, renderers = 0, particle = 0; + foreach (var folder in new[] { "Assets/Res_Addr/Mobs/Mob", "Assets/Res_Addr/Mobs/Elite", "Assets/Res_Addr/Mobs/FieldBoss" }) + { + var guids = UnityEditor.AssetDatabase.FindAssets("t:Prefab", new[] { folder }); + foreach (var g in guids) + { + var path = UnityEditor.AssetDatabase.GUIDToAssetPath(g); + var go = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (go == null) continue; + prefabs++; + var seen = new HashSet(); + foreach (var r in go.GetComponentsInChildren(true)) + { + if (r is ParticleSystemRenderer) { particle++; continue; } + renderers++; + var m = r.sharedMaterial; var sh = m != null ? m.shader : null; + string key = sh != null ? sh.name : "(null shader)"; + int[] row; + if (!byShader.TryGetValue(key, out row)) + { + row = new int[2 + names.Length]; + if (sh != null) for (int i = 0; i < names.Length; i++) row[2 + i] = sh.FindPropertyIndex(names[i]) >= 0 ? 1 : 0; + byShader[key] = row; + } + row[0]++; + if (seen.Add(key)) { row[1]++; lastPrefab[key] = System.IO.Path.GetFileNameWithoutExtension(path); } + } + } + } + sb.AppendLine("prefabs=" + prefabs + " renderers(non-particle)=" + renderers + " particleRenderers(skipped)=" + particle); + sb.AppendLine(string.Format("{0,-45} {1,5} {2,5} | {3}", "shader", "rend", "pref", string.Join(" ", names))); + foreach (var kv in byShader) + { + var row = kv.Value; var cols = new StringBuilder(); + for (int i = 0; i < names.Length; i++) cols.Append(string.Format("{0,-" + names[i].Length + "} ", row[2 + i] == 1 ? "Y" : "-")); + sb.AppendLine(string.Format("{0,-45} {1,5} {2,5} | {3} e.g. {4}", kv.Key, row[0], row[1], cols.ToString().TrimEnd(), lastPrefab[kv.Key])); + } + var dis = UnityEditor.AssetDatabase.LoadAssetAtPath(DissolveMat); + if (dis != null && dis.shader != null) + { + var cols = new StringBuilder(); + for (int i = 0; i < names.Length; i++) cols.Append(names[i] + "=" + (dis.shader.FindPropertyIndex(names[i]) >= 0 ? "Y" : "-") + " "); + sb.AppendLine("dissolve mat=" + dis.name + " shader=" + dis.shader.name + " | " + cols + "| _EdgeAroundHDR=" + dis.GetFloat("_EdgeAroundHDR") + " _EdgeAround=" + dis.GetFloat("_EdgeAround")); + } + var found = Shader.Find(WLHitBlinkSettings.Instance != null ? WLHitBlinkSettings.Instance.dissolveShaderName : "MoonflowerCarnivore/Dissolve Edge"); + sb.AppendLine("Shader.Find(dissolveShaderName)=" + (found != null ? found.name : "NULL")); + Save("props", sb); return sb.ToString(); + } + + public static object Status() + { + var sb = new StringBuilder(); + sb.AppendLine("# WL811def Status " + DateTime.Now.ToString("HH:mm:ss") + " timeScale=" + Time.timeScale.ToString("F2") + " arbiter=" + TimeScaleArbiter.Active); + sb.AppendLine("camera: killCam=" + CombatCameraDirector.KillCamCount + " skip(role/cool/runcap/guard/nocam/busy)=" + CombatCameraDirector.KillCamSkippedRole + "/" + CombatCameraDirector.KillCamSkippedCooldown + "/" + CombatCameraDirector.KillCamSkippedRunCap + "/" + CombatCameraDirector.KillCamSkippedGuard + "/" + CombatCameraDirector.KillCamSkippedNoCam + "/" + CombatCameraDirector.KillCamSkippedBusy + + " abandoned=" + CombatCameraDirector.KillCamAbandoned + " boss=" + CombatCameraDirector.BossCamCount + " bossSkip=" + CombatCameraDirector.BossCamSkipped + " preset=" + CombatCameraDirector.BossPresetApplied + " lead=" + CombatCameraDirector.ComboLeadCount + + " active(kill/boss)=" + CombatCameraDirector.KillCamActive + "/" + CombatCameraDirector.BossCamActive + " zoom=" + CombatCameraDirector.CurrentZoomScale.ToString("F3") + " lastKillCam=" + CombatCameraDirector.LastKillCamMeasured.ToString("F3") + " lastBoss=" + CombatCameraDirector.LastBossCamMeasured.ToString("F3") + " slowmo=" + CombatCameraDirector.LastSlowMoResult + " run=" + CombatCameraDirector.RunCount + " last=" + CombatCameraDirector.LastInfo); + sb.AppendLine("chain: kills=" + KillChain.KillCount + " tiers=" + KillChain.TierCount + " chains=" + KillChain.ChainCount + " best=" + KillChain.BestChainEver + " cur=" + KillChain.ChainKills + " burst=" + KillChain.BurstCount + " denied=" + KillChain.BurstDeniedBudget + " outOfView=" + KillChain.BurstSkippedView + " noInfo=" + KillChain.BurstNoInGameInfo + " held=" + KillChain.BurstHeld + " budget=" + EffectBudget.Count(EffectBudgetKind.ClusterBurst) + "/" + EffectBudget.Limit(EffectBudgetKind.ClusterBurst) + " last=" + KillChain.LastInfo); + sb.AppendLine("merge: hits=" + DamageAggregator.HitCount + " flush=" + DamageAggregator.FlushCount + " maxMerged=" + DamageAggregator.MaxHitsMerged + " evict=" + DamageAggregator.EvictCount + " active=" + DamageAggregator.ActiveSlots); + sb.AppendLine("blink: on=" + MobHitFlash.OnCount + " off=" + MobHitFlash.OffCount + " extend=" + MobHitFlash.ExtendCount + " throttled=" + MobHitFlash.ThrottledCount + " tint=" + MobHitFlash.TintCount + " clear=" + MobHitFlash.ClearCount + " skipFilter=" + MobHitFlash.SkippedFilter + " noProp=" + MobHitFlash.SkippedNoProperty + " states=" + MobHitFlash.StateCount + " active=" + MobHitFlash.ActiveCount + " reflect=" + MobHitFlash.RendererFieldViaReflection + " dissolve(resolved/hdr/range)=" + MobHitFlash.DissolveResolved + "/" + MobHitFlash.DissolveHasHdr + "/" + MobHitFlash.DissolveHasRange); + sb.AppendLine("lead multiplier=" + CameraMoveLead.LeadMultiplier.ToString("F2") + " runner(reaction)=" + WLReactionRunner.Exists); + Save("status", sb); return sb.ToString(); + } + + // ───────────────────────────────────────────── 공용 + static void Save(string name, StringBuilder sb) + { + try + { + System.IO.Directory.CreateDirectory(OutDir); + string path = System.IO.Path.Combine(OutDir, name + "_" + DateTime.Now.ToString("HHmmss") + ".txt"); + System.IO.File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); + Debug.Log("[WL811def] 저장 " + path); + } + catch (Exception e) { Debug.LogWarning("[WL811def] 저장 실패 " + e.Message); } + Debug.Log(sb.ToString()); + } + + static void Launch(Func make) + { + if (!Application.isPlaying) { Debug.LogWarning("[WL811def] Play 모드에서만(Title Play). Edit 모드는 Dump/Props/Status 만."); return; } + var old = GameObject.Find("__wl811def_probe"); + if (old != null) UnityEngine.Object.DestroyImmediate(old); + var go = new GameObject("__wl811def_probe"); + var h = go.AddComponent(); + h.StartCoroutine(make(h)); + } + + /// 가짜 몹: 비활성 GO 에 컴포넌트를 얹고(NavMeshAgent 는 비활성 → 네비메시 에러 0) 활성화해 Awake(arr_Renderer 캐시)만 돌린다. Start/Update 없음. + static MobActor MakeMob(string name, eSubRol sub, Vector3 pos, Material mat) + { + var go = new GameObject(name); + go.SetActive(false); + go.transform.position = pos; + go.AddComponent().isKinematic = true; + go.AddComponent(); + go.AddComponent(); + var agent = go.AddComponent(); agent.enabled = false; + var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); + cube.name = "Body"; cube.transform.SetParent(go.transform, false); + UnityEngine.Object.Destroy(cube.GetComponent()); + if (mat != null) cube.GetComponent().sharedMaterial = mat; + var mob = go.AddComponent(); mob.enabled = false; + mob.m_SubRole = sub; + go.SetActive(true); + return mob; + } + + static PCActor MakePC(string name, Vector3 pos) + { + var go = new GameObject(name); + go.SetActive(false); + go.transform.position = pos; + go.AddComponent().isKinematic = true; + go.AddComponent(); + go.AddComponent(); + var agent = go.AddComponent(); agent.enabled = false; + var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); + cube.transform.SetParent(go.transform, false); + UnityEngine.Object.Destroy(cube.GetComponent()); + var pc = go.AddComponent(); pc.enabled = false; + go.SetActive(true); + return pc; + } + + static Material LoadSampleMat() + { + return UnityEditor.AssetDatabase.LoadAssetAtPath(SampleMat); // 에디터 전용 프로브(Roslyn run_script 에 UNITY_EDITOR 심볼이 없을 수 있어 #if 미사용) + } + + static DamageInfo MakeDamage(double dmg, bool cri, bool kill) + { + var d = new DamageInfo { Beater = null, Critical = cri ? eStat.CRI : eStat.None, IsDeadByThisDamage = kill }; + d.Damage = dmg; d.BaseDmg = dmg; + return d; + } + + public class Host : MonoBehaviour + { + StringBuilder _sb; float _t0; + RealCamera _cam; Transform _dummy; bool _camInstalled; RealCamera _prevCam; + readonly List _trash = new List(); + + string Pre() { return string.Format("{0,7:F0} | f{1,-5} | ts={2:F2} | ", (Time.unscaledTime - _t0) * 1000f, Time.frameCount, Time.timeScale); } + static string N(UnityEngine.Object o) { return o != null ? o.name : "null"; } + IEnumerator Wait(float s) { float t = Time.unscaledTime; while (Time.unscaledTime - t < s) yield return null; } + + Vector3 Front(float dist) + { + var c = Camera.main; + return c != null ? c.transform.position + c.transform.forward * dist : Vector3.zero; + } + + /// 가짜 RealCamera(빈 GO · 카메라 컴포넌트 없음): 원본 프리팹 [0] 값(3/3/2/78 → 기준서 65) · CamStatus 기본 1 · Set_Target(1인자)로 Update_Cam 만 돈다. + void InstallCamera() + { + _prevCam = MyValue._RealCamera; + var go = new GameObject("__wl811def_cam"); _trash.Add(go); + _cam = go.AddComponent(); + _cam.arr_cCamData = new[] { new cCamData { minDistance = 3f, maxDistance = 3f, DistanceUp = 2f, cameraHeight = 65f }, new cCamData { minDistance = 2f, maxDistance = 3f, DistanceUp = 3.5f, cameraHeight = 78f } }; + _cam.minDistance = 3f; _cam.maxDistance = 3f; _cam.DistanceUp = 2f; _cam.cameraHeight = 65f; _cam.rotateAround = 30f; _cam.smooth = 4f; + var d = new GameObject("__wl811def_pc"); _trash.Add(d); d.transform.position = Front(6f); + _dummy = d.transform; + _cam.Set_Target(_dummy); + MyValue._RealCamera = _cam; _camInstalled = true; + } + + void Cleanup() + { + CombatCameraDirector.ReleaseAll(); + MobHitFlash.RestoreAll(); + KillChain.ClearAll(); DamageAggregator.ClearAll(); + if (_camInstalled) { MyValue._RealCamera = _prevCam; _camInstalled = false; } + TimeScaleArbiter.ReleaseAll(); Time.timeScale = 1f; + for (int i = 0; i < _trash.Count; i++) if (_trash[i] != null) Destroy(_trash[i]); + _trash.Clear(); + Destroy(gameObject); + } + + string CamRow() { return "min=" + _cam.minDistance.ToString("F3") + " max=" + _cam.maxDistance.ToString("F3") + " up=" + _cam.DistanceUp.ToString("F3") + " yaw=" + _cam.rotateAround.ToString("F2") + " target=" + N(_cam.target) + " zoom=" + CombatCameraDirector.CurrentZoomScale.ToString("F3") + " arb=" + TimeScaleArbiter.Active + " camPos=" + _cam.transform.position.ToString("F2"); } + + /// ⓑ 킬캠: 엘리트 Killed → 줌(3→2.25→3)·슬로모(0.35→1) 타임라인 · 히트스톱 SkippedHigher · 쿨 안 재발동 0 · ResetRun 뒤 재발동 · 일반 몹 = 스킵. + public IEnumerator Co_KillCam() + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; + _sb.AppendLine("# WL811def KillCam " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLCombatCameraSettings.Enabled + " core=" + WLCombatCoreSettings.Enabled + " arbiter=" + TimeScaleArbiter.Enabled); + var st = WLCombatCameraSettings.Instance; var core = WLCombatCoreSettings.Instance; + _sb.AppendLine("# 값: zoom=" + st.killCamZoomScale + " in/hold/out=" + st.killCamZoomInSeconds + "/" + st.killCamHoldSeconds + "/" + st.killCamZoomOutSeconds + " slowmo=" + core.cinematicDefaultScale + "×" + st.killCamSlowMoSeconds + " s cool=" + core.killCamCooldownSeconds + " guardY=" + st.killCamBottomGuardViewportY + " maxPerRun=" + st.killCamMaxPerRun); + CombatCameraDirector.ResetDiagnostics(); CombatCameraDirector.ResetRun(); TimeScaleArbiter.ResetDiagnostics(); + InstallCamera(); + yield return null; yield return null; + var elite = MakeMob("__wl811def_elite", eSubRol.Elite, Front(5f), null); _trash.Add(elite.gameObject); + var normal = MakeMob("__wl811def_normal", eSubRol.None, Front(5f), null); _trash.Add(normal.gameObject); + yield return null; + _sb.AppendLine(Pre() + "before " + CamRow()); + + // ① 일반 몹 → 역할 스킵 + CombatEvents.RaiseKilled(normal); + _sb.AppendLine(Pre() + "① Normal Killed → killCam=" + CombatCameraDirector.KillCamCount + " skipRole=" + CombatCameraDirector.KillCamSkippedRole + " (기대 0 / 1)"); + + // ② 엘리트 → 발동 · 타임라인 + float minSeen = 99f, tsMin = 9f; float zoomReach = -1f, tsRestore = -1f, camRestore = -1f; + float tStart = Time.unscaledTime; + CombatEvents.RaiseKilled(elite); + _sb.AppendLine(Pre() + "② Elite Killed → killCam=" + CombatCameraDirector.KillCamCount + " slowmo=" + CombatCameraDirector.LastSlowMoResult + " viewportY=" + CombatCameraDirector.LastVictimViewportY.ToString("F2") + " last=" + CombatCameraDirector.LastInfo + " " + CamRow()); + bool hsTried = false; ArbiterResult hs = ArbiterResult.Invalid; + float tEnd = tStart + st.killCamZoomInSeconds + st.killCamHoldSeconds + st.killCamZoomOutSeconds + 0.4f; + int rows = 0; + while (Time.unscaledTime < tEnd) + { + yield return null; + float t = Time.unscaledTime - tStart; + if (!hsTried && t > 0.08f) { hs = TimeScaleArbiter.Request(TimeLayer.HitStop, 0f, 0.04f); hsTried = true; _sb.AppendLine(Pre() + " 슬로모 중 HitStop 요청 → " + hs + " (" + TimeScaleArbiter.LastInfo + ") 기대=SkippedHigher"); } + minSeen = Mathf.Min(minSeen, _cam.minDistance); tsMin = Mathf.Min(tsMin, Time.timeScale); + if (zoomReach < 0f && Mathf.Approximately(_cam.minDistance, 3f * st.killCamZoomScale)) zoomReach = t; + if (tsRestore < 0f && tsMin < 0.99f && Time.timeScale >= 0.999f) tsRestore = t; + if (camRestore < 0f && zoomReach >= 0f && !CombatCameraDirector.KillCamActive) camRestore = t; + if ((rows++ % 4) == 0 || !CombatCameraDirector.KillCamActive) _sb.AppendLine(Pre() + " " + CamRow()); + if (camRestore >= 0f && tsRestore >= 0f && t > 0.9f) break; + } + _sb.AppendLine(Pre() + " 결과: minDistance 최소=" + minSeen.ToString("F3") + " (기대 " + (3f * st.killCamZoomScale).ToString("F3") + ") 줌 도달 " + (zoomReach * 1000f).ToString("F0") + " ms · timeScale 최소=" + tsMin.ToString("F2") + " 복원 " + (tsRestore * 1000f).ToString("F0") + " ms(기대 ≈" + (st.killCamSlowMoSeconds * 1000f).ToString("F0") + ") · 카메라 복원 " + (camRestore * 1000f).ToString("F0") + " ms · measured=" + CombatCameraDirector.LastKillCamMeasured.ToString("F3") + " s · abandoned=" + CombatCameraDirector.KillCamAbandoned + " · 종료 " + CamRow() + " arbiterMeasured=" + TimeScaleArbiter.LastMeasured.ToString("F3")); + + // ③ 쿨 안 재발동 → 스킵 + CombatEvents.RaiseKilled(elite); + _sb.AppendLine(Pre() + "③ 쿨 안 Elite Killed → killCam=" + CombatCameraDirector.KillCamCount + " skipCooldown=" + CombatCameraDirector.KillCamSkippedCooldown + " (기대 1 / 1) last=" + CombatCameraDirector.LastInfo); + + // ④ ResetRun(쿨·런 초기화) → 재발동 · 보스 킬 + var boss = MakeMob("__wl811def_boss", eSubRol.Boss, Front(5f), null); _trash.Add(boss.gameObject); + CombatCameraDirector.ResetRun(); + CombatEvents.RaiseKilled(boss); + _sb.AppendLine(Pre() + "④ ResetRun 후 Boss Killed → killCam=" + CombatCameraDirector.KillCamCount + " (기대 2) slowmo=" + CombatCameraDirector.LastSlowMoResult + " " + CamRow()); + yield return Wait(st.killCamZoomInSeconds + st.killCamHoldSeconds + st.killCamZoomOutSeconds + 0.3f); + _sb.AppendLine(Pre() + " 복원 확인 " + CamRow() + " active=" + CombatCameraDirector.KillCamActive + " measured=" + CombatCameraDirector.LastKillCamMeasured.ToString("F3")); + + // ⑤ 런 상한: ResetRun 뒤 쿨만 매번 지우며(ResetCooldown) maxPerRun 회 발동 → 다음은 runcap 스킵 + int cap = st.killCamMaxPerRun; + if (cap > 0) + { + CombatCameraDirector.ResetRun(); + int before = CombatCameraDirector.KillCamCount, cool0 = CombatCameraDirector.KillCamSkippedCooldown; + for (int i = 0; i < cap; i++) { CombatCameraDirector.ResetCooldown(); CombatEvents.RaiseKilled(elite); CombatCameraDirector.ReleaseAll(); TimeScaleArbiter.ReleaseAll(); yield return null; } + CombatCameraDirector.ResetCooldown(); + CombatEvents.RaiseKilled(elite); + _sb.AppendLine(Pre() + "⑤ 런 상한 " + cap + ": 발동+" + (CombatCameraDirector.KillCamCount - before) + " (기대 " + cap + ") skipRunCap=" + CombatCameraDirector.KillCamSkippedRunCap + " (기대 1) skipCool+" + (CombatCameraDirector.KillCamSkippedCooldown - cool0) + " (기대 0) run=" + CombatCameraDirector.RunCount); + } + _sb.AppendLine("# 종료 timeScale=" + Time.timeScale.ToString("F2") + " arbiter=" + TimeScaleArbiter.Active + " " + CamRow()); + Save("killcam", _sb); Cleanup(); + } + + /// ⓒ 보스 캠: Spawned(isBoss) → Set_Target(보스) · sin 오빗 · 거리 배율 → 1.2 s 뒤 전부 복원 · arr_cCamData[1] 프리셋. + public IEnumerator Co_BossCam() + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; + var st = WLCombatCameraSettings.Instance; + _sb.AppendLine("# WL811def BossCam " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLCombatCameraSettings.Enabled + " sec=" + st.bossCamSeconds + " orbit=" + st.bossCamOrbitDegrees + "° dist×" + st.bossCamDistanceScale + " retarget=" + st.bossCamRetarget + " preset=" + st.bossPresetApply + "(" + st.bossPresetDistance + " m · " + st.bossPresetCameraHeight + "°)"); + CombatCameraDirector.ResetDiagnostics(); + InstallCamera(); + yield return null; yield return null; + var boss = MakeMob("__wl811def_boss", eSubRol.Boss, Front(5f) + Vector3.right * 2f, null); _trash.Add(boss.gameObject); + var normal = MakeMob("__wl811def_normal", eSubRol.None, Front(5f), null); _trash.Add(normal.gameObject); + yield return null; + float yaw0 = _cam.rotateAround; var preset0 = _cam.arr_cCamData[1]; + _sb.AppendLine(Pre() + "before " + CamRow() + " preset[1]=" + preset0.minDistance + "/" + preset0.maxDistance + "/" + preset0.cameraHeight + "°"); + CombatEvents.RaiseSpawned(normal); + _sb.AppendLine(Pre() + "① Normal Spawned → boss=" + CombatCameraDirector.BossCamCount + " (기대 0)"); + float tStart = Time.unscaledTime; float yawMax = -999f, yawMin = 999f; bool sawBossTarget = false; + CombatEvents.RaiseSpawned(boss); + _sb.AppendLine(Pre() + "② Boss Spawned → boss=" + CombatCameraDirector.BossCamCount + " active=" + CombatCameraDirector.BossCamActive + " preset[1]=" + preset0.minDistance + "/" + preset0.maxDistance + "/" + preset0.cameraHeight + "° up=" + preset0.DistanceUp + " " + CamRow()); + int rows = 0; float tRestore = -1f; + while (Time.unscaledTime - tStart < st.bossCamSeconds + 0.5f) + { + yield return null; + float rel = Mathf.DeltaAngle(yaw0, _cam.rotateAround); + yawMax = Mathf.Max(yawMax, rel); yawMin = Mathf.Min(yawMin, rel); + if (ReferenceEquals(_cam.target, boss.transform)) sawBossTarget = true; + if (tRestore < 0f && !CombatCameraDirector.BossCamActive) tRestore = Time.unscaledTime - tStart; + if ((rows++ % 6) == 0 || tRestore >= 0f && rows % 6 == 1) _sb.AppendLine(Pre() + " yawRel=" + rel.ToString("F2") + " " + CamRow()); + if (tRestore >= 0f && Time.unscaledTime - tStart > st.bossCamSeconds + 0.2f) break; + } + _sb.AppendLine(Pre() + " 결과: yaw 진폭 max=" + yawMax.ToString("F2") + " min=" + yawMin.ToString("F2") + " (기대 ≈+" + st.bossCamOrbitDegrees + ") · 종료 yawRel=" + Mathf.DeltaAngle(yaw0, _cam.rotateAround).ToString("F3") + " (기대 0) · 보스 타겟 관측=" + sawBossTarget + " · 복원 " + (tRestore * 1000f).ToString("F0") + " ms(기대 ≈" + (st.bossCamSeconds * 1000f).ToString("F0") + ") · measured=" + CombatCameraDirector.LastBossCamMeasured.ToString("F3") + " · target 복원=" + ReferenceEquals(_cam.target, _dummy) + " · " + CamRow()); + Save("bosscam", _sb); Cleanup(); + } + + /// 3타 리드: ComboStage(isLast) → LeadMultiplier 1.3 → comboLeadSeconds 뒤 1. + public IEnumerator Co_ComboLead() + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; + var st = WLCombatCameraSettings.Instance; + _sb.AppendLine("# WL811def ComboLead " + DateTime.Now.ToString("HH:mm:ss") + " ×" + st.comboLeadMultiplier + " " + st.comboLeadSeconds + " s"); + var pc = MakePC("__wl811def_pcactor", Front(5f)); _trash.Add(pc.gameObject); + yield return null; + CombatEvents.RaiseComboStage(pc, 2, "Attack2_S", 0f, false); + _sb.AppendLine(Pre() + "① stage2(isLast=false) → mult=" + CameraMoveLead.LeadMultiplier.ToString("F2") + " (기대 1.00) count=" + CombatCameraDirector.ComboLeadCount); + float t0 = Time.unscaledTime; + CombatEvents.RaiseComboStage(pc, 3, "Attack3_S", 0f, true); + _sb.AppendLine(Pre() + "② stage3(isLast) → mult=" + CameraMoveLead.LeadMultiplier.ToString("F2") + " (기대 " + st.comboLeadMultiplier.ToString("F2") + ") count=" + CombatCameraDirector.ComboLeadCount); + float tBack = -1f; + while (Time.unscaledTime - t0 < st.comboLeadSeconds + 0.3f) { yield return null; if (tBack < 0f && Mathf.Approximately(CameraMoveLead.LeadMultiplier, 1f)) tBack = Time.unscaledTime - t0; } + _sb.AppendLine(Pre() + " 복원 " + (tBack * 1000f).ToString("F0") + " ms (기대 ≈" + (st.comboLeadSeconds * 1000f).ToString("F0") + ") mult=" + CameraMoveLead.LeadMultiplier.ToString("F2")); + Save("combolead", _sb); Cleanup(); + } + + int _tier, _ended; string _tierLog = ""; + void OnTier(in KillChainTierEvent e) { _tier++; _tierLog += Pre() + " TierReached " + e.name + " tier=" + e.tier + " kills=" + e.kills + " centroid=" + e.centroid.ToString("F1") + " burst=" + e.burstSpawned + "\n"; } + void OnEnded(in KillChainEndedEvent e) { _ended++; _tierLog += Pre() + " Ended kills=" + e.kills + " best=" + e.bestTier + " dur=" + e.duration.ToString("F2") + "\n"; } + + /// ⓓ 연쇄: 3 s 안 3킬 → DOUBLE·TRIPLE · 폭발 슬롯 1(홀드) · 윈도우 만료 Ended · 5킬 RAMPAGE · 홀드 중 폭발 거부. + public IEnumerator Co_Chain() + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; _tier = 0; _ended = 0; _tierLog = ""; + var st = WLReactionSettings.Instance; + _sb.AppendLine("# WL811def Chain " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLReactionSettings.Enabled + " window=" + st.killChainWindowSeconds + " burstMinTier=" + st.clusterBurstMinTier + " prefab=" + st.clusterBurstPrefab + " hold=" + st.clusterBurstHoldSeconds + " InGameInfo=" + (InGameInfo.Ins != null)); + KillChain.ResetDiagnostics(); KillChain.ClearAll(); EffectBudget.ResetAll(); + KillChain.TierReached.Add(OnTier); KillChain.Ended.Add(OnEnded); + var mobs = new MobActor[5]; + for (int i = 0; i < 5; i++) { mobs[i] = MakeMob("__wl811def_mob" + i, eSubRol.None, Front(5f) + Vector3.right * (i - 2), null); _trash.Add(mobs[i].gameObject); } + yield return null; + for (int i = 0; i < 3; i++) { CombatEvents.RaiseKilled(mobs[i]); yield return Wait(0.15f); } + _sb.AppendLine(Pre() + "① 0.45 s 안 3킬 → tiers=" + _tier + " (기대 2: DOUBLE·TRIPLE) chain=" + KillChain.ChainKills + " burstHeld=" + KillChain.BurstHeld + " budget=" + EffectBudget.Count(EffectBudgetKind.ClusterBurst) + " burst/noInfo/denied=" + KillChain.BurstCount + "/" + KillChain.BurstNoInGameInfo + "/" + KillChain.BurstDeniedBudget); + _sb.Append(_tierLog); _tierLog = ""; + yield return Wait(st.clusterBurstHoldSeconds + 0.1f); + _sb.AppendLine(Pre() + " 홀드 만료 → burstHeld=" + KillChain.BurstHeld + " budget=" + EffectBudget.Count(EffectBudgetKind.ClusterBurst) + " (기대 false/0)"); + yield return Wait(st.killChainWindowSeconds - st.clusterBurstHoldSeconds); + _sb.AppendLine(Pre() + "② 윈도우 만료 → ended=" + _ended + " (기대 1) chain=" + KillChain.ChainKills + " (기대 0)"); + _sb.Append(_tierLog); _tierLog = ""; + int denied0 = KillChain.BurstDeniedBudget; + for (int i = 0; i < 5; i++) { CombatEvents.RaiseKilled(mobs[i]); yield return null; } + _sb.AppendLine(Pre() + "③ 5킬 연속 → tiers+=" + _tier + " (누적 기대 5) best=" + KillChain.BestChainEver + " burstDenied+" + (KillChain.BurstDeniedBudget - denied0) + " (TRIPLE 슬롯 홀드 중 RAMPAGE 폭발 거부 = 동시 ≤1) budget=" + EffectBudget.Count(EffectBudgetKind.ClusterBurst)); + _sb.Append(_tierLog); + KillChain.TierReached.Remove(OnTier); KillChain.Ended.Remove(OnEnded); + Save("chain", _sb); Cleanup(); + } + + int _flush; string _flushLog = ""; + void OnFlush(in DamageMergeEvent e) { _flush++; _flushLog += Pre() + " Flushed victim=" + N(e.victim) + " total=" + e.total.ToString("F0") + " hits=" + e.hits + " max=" + e.maxSingle.ToString("F0") + " cri=" + e.anyCritical + " kill=" + e.isKill + " window=" + ((e.time - e.firstTime) * 1000f).ToString("F0") + " ms\n"; } + + /// ⓓ 합산: 0.25 s 안 3타(10+20+30 · 2타 크리) → 플러시 1(60 · 3타) · 처치 타격 → 즉시 플러시. + public IEnumerator Co_Merge() + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; _flush = 0; _flushLog = ""; + var st = WLReactionSettings.Instance; + _sb.AppendLine("# WL811def Merge " + DateTime.Now.ToString("HH:mm:ss") + " window=" + st.damageMergeWindowSeconds + " flushOnKill=" + st.damageMergeFlushOnKill); + DamageAggregator.ResetDiagnostics(); DamageAggregator.ClearAll(); + DamageAggregator.Flushed.Add(OnFlush); + var mob = MakeMob("__wl811def_mob", eSubRol.None, Front(5f), null); _trash.Add(mob.gameObject); + yield return null; + CombatEvents.RaiseHitConfirmed(mob, MakeDamage(10, false, false)); yield return null; + CombatEvents.RaiseHitConfirmed(mob, MakeDamage(20, true, false)); yield return null; + CombatEvents.RaiseHitConfirmed(mob, MakeDamage(30, false, false)); + double pend; int ph; bool has = DamageAggregator.TryGetPending(mob, out pend, out ph); + _sb.AppendLine(Pre() + "① 3타 → pending=" + has + " total=" + pend.ToString("F0") + " hits=" + ph + " flush=" + _flush + " (기대 true/60/3/0)"); + yield return Wait(st.damageMergeWindowSeconds + 0.1f); + _sb.AppendLine(Pre() + " 윈도우 만료 → flush=" + _flush + " (기대 1) active=" + DamageAggregator.ActiveSlots); + _sb.Append(_flushLog); _flushLog = ""; + CombatEvents.RaiseHitConfirmed(mob, MakeDamage(5, false, false)); yield return null; + CombatEvents.RaiseHitConfirmed(mob, MakeDamage(999, false, true)); + _sb.AppendLine(Pre() + "② 처치 타격 → 즉시 flush=" + _flush + " (기대 2) active=" + DamageAggregator.ActiveSlots); + _sb.Append(_flushLog); + DamageAggregator.Flushed.Remove(OnFlush); + Save("merge", _sb); Cleanup(); + } + + /// ⓔ 점멸: MK Toon 실 머티리얼 큐브 몹 10회 피격(0.1 s 간격) → on/off 20 · HasPropertyBlock 잔류 0 · 머티리얼 수 증가 0 · sharedMaterial 불변 · Killed 엣지 → Spawned 정리. + public IEnumerator Co_Blink() + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; + var st = WLHitBlinkSettings.Instance; + _sb.AppendLine("# WL811def Blink " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLHitBlinkSettings.Enabled + " on/off=" + st.blinkOnSeconds + "/" + st.blinkOffSeconds + " color=" + st.blinkColor + " emission=" + st.useEmission); + MobHitFlash.ResetDiagnostics(); + var mat = LoadSampleMat(); + _sb.AppendLine("# 머티리얼 " + (mat != null ? mat.name + " shader=" + mat.shader.name : "NULL(기본 큐브 머티리얼)")); + var mob = MakeMob("__wl811def_mob", eSubRol.Elite, Front(5f), mat); _trash.Add(mob.gameObject); + yield return null; + var rend = mob.GetComponentInChildren(); + int matsBefore = Resources.FindObjectsOfTypeAll().Length; + int sharedId = rend.sharedMaterial != null ? rend.sharedMaterial.GetInstanceID() : 0; + _sb.AppendLine(Pre() + "before block=" + rend.HasPropertyBlock() + " materials=" + matsBefore + " sharedId=" + sharedId); + int onDuringSeen = 0; + for (int i = 0; i < 10; i++) + { + CombatEvents.RaiseHitConfirmed(mob, MakeDamage(10, false, false)); + bool hasNow = rend.HasPropertyBlock(); + if (hasNow) onDuringSeen++; + if (i < 3) _sb.AppendLine(Pre() + " hit" + (i + 1) + " on=" + MobHitFlash.OnCount + " off=" + MobHitFlash.OffCount + " block=" + hasNow); + yield return Wait(0.1f); + } + yield return Wait(0.1f); + int matsAfter = Resources.FindObjectsOfTypeAll().Length; + int sharedAfter = rend.sharedMaterial != null ? rend.sharedMaterial.GetInstanceID() : 0; + _sb.AppendLine(Pre() + "① 10회 피격 → on=" + MobHitFlash.OnCount + " off=" + MobHitFlash.OffCount + " (기대 10/10 = 20) extend=" + MobHitFlash.ExtendCount + " throttled=" + MobHitFlash.ThrottledCount + " 켜진 직후 block 관측=" + onDuringSeen + "/10 · 잔류 block=" + rend.HasPropertyBlock() + " (기대 False) · 머티리얼 " + matsBefore + "→" + matsAfter + " (증가 " + (matsAfter - matsBefore) + " · 기대 0) · sharedId " + sharedId + "→" + sharedAfter + " (기대 동일) · noProp=" + MobHitFlash.SkippedNoProperty + " reflect=" + MobHitFlash.RendererFieldViaReflection); + // 같은 프레임 다중 히트 → 1회 점멸(연장) + int on0 = MobHitFlash.OnCount, ext0 = MobHitFlash.ExtendCount; + for (int i = 0; i < 5; i++) CombatEvents.RaiseHitConfirmed(mob, MakeDamage(10, false, false)); + yield return Wait(0.15f); + _sb.AppendLine(Pre() + "② 같은 프레임 5히트 → on+" + (MobHitFlash.OnCount - on0) + " extend+" + (MobHitFlash.ExtendCount - ext0) + " (기대 1 / 4) block=" + rend.HasPropertyBlock()); + // Killed → 디졸브 엣지 MPB(Shader.Find 폴백) → Spawned → 정리 + CombatEvents.RaiseKilled(mob); + _sb.AppendLine(Pre() + "③ Killed(Elite) → tint=" + MobHitFlash.TintCount + " dissolve resolved/hdr/range=" + MobHitFlash.DissolveResolved + "/" + MobHitFlash.DissolveHasHdr + "/" + MobHitFlash.DissolveHasRange + " block=" + rend.HasPropertyBlock() + " (기대 True) noProp=" + MobHitFlash.SkippedNoProperty); + var mpb = new MaterialPropertyBlock(); rend.GetPropertyBlock(mpb); + _sb.AppendLine(Pre() + " MPB _EdgeAroundHDR=" + mpb.GetFloat("_EdgeAroundHDR").ToString("F2") + " _EdgeAround=" + mpb.GetFloat("_EdgeAround").ToString("F3") + " _Progress 포함=" + mpb.HasFloat("_Progress") + " (기대 False)"); + CombatEvents.RaiseSpawned(mob); + _sb.AppendLine(Pre() + "④ Spawned → clear=" + MobHitFlash.ClearCount + " block=" + rend.HasPropertyBlock() + " (기대 False)"); + _sb.AppendLine("# GC: 100 피격 → " + GcOf(() => CombatEvents.RaiseHitConfirmed(mob, MakeDamage(10, false, false)), 100) + " (DamageInfo 생성 포함 · 프로브 측 할당)"); + Save("blink", _sb); Cleanup(); + } + + static string GcOf(Action act, int n) + { + for (int i = 0; i < 10; i++) act(); + long m0 = GC.GetTotalMemory(false); long p0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); + for (int i = 0; i < n; i++) act(); + long m1 = GC.GetTotalMemory(false); long p1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); + return "GC.GetTotalMemory delta=" + (m1 - m0) + " B · MonoUsed delta=" + (p1 - p0) + " B"; + } + + /// ⓕ 구독자별 100회 GC: DamageInfo 는 미리 1개 만들어 재사용(프로브 측 할당 0). 카메라/반응/점멸을 RuntimeDisabled 로 하나씩 켜서 잰다. + public IEnumerator Co_Gc() + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; + _sb.AppendLine("# WL811def GcCheck " + DateTime.Now.ToString("HH:mm:ss") + " (기준 각 < 2048 B)"); + InstallCamera(); yield return null; yield return null; + var mat = LoadSampleMat(); + var elite = MakeMob("__wl811def_elite", eSubRol.Elite, Front(5f), mat); _trash.Add(elite.gameObject); + var boss = MakeMob("__wl811def_boss", eSubRol.Boss, Front(5f), mat); _trash.Add(boss.gameObject); + var pc = MakePC("__wl811def_pcactor", Front(5f)); _trash.Add(pc.gameObject); + yield return null; + var dmg = MakeDamage(10, false, false); + bool c0 = WLCombatCameraSettings.RuntimeDisabled, r0 = WLReactionSettings.RuntimeDisabled, b0 = WLHitBlinkSettings.RuntimeDisabled; + Action set = (c, r, b) => { WLCombatCameraSettings.RuntimeDisabled = !c; WLReactionSettings.RuntimeDisabled = !r; WLHitBlinkSettings.RuntimeDisabled = !b; }; + // 워밍업(상태·버퍼 1회 할당) + set(true, true, true); + CombatEvents.RaiseKilled(elite); CombatEvents.RaiseHitConfirmed(elite, dmg); CombatEvents.RaiseComboStage(pc, 3, "Attack3_S", 0f, true); CombatEvents.RaiseSpawned(boss); + yield return null; CombatCameraDirector.ReleaseAll(); TimeScaleArbiter.ReleaseAll(); yield return null; + + set(true, false, false); CombatCameraDirector.ResetRun(); + _sb.AppendLine("camera Killed×100(쿨로 1회 발동+99 스킵): " + GcOf(() => CombatEvents.RaiseKilled(elite), 100)); + CombatCameraDirector.ReleaseAll(); TimeScaleArbiter.ReleaseAll(); yield return null; + _sb.AppendLine("camera ComboStage×100: " + GcOf(() => CombatEvents.RaiseComboStage(pc, 3, "Attack3_S", 0f, true), 100)); + _sb.AppendLine("camera Spawned(boss)×100(1회 발동+99 busy): " + GcOf(() => CombatEvents.RaiseSpawned(boss), 100)); + CombatCameraDirector.ReleaseAll(); yield return null; + + set(false, true, false); KillChain.ClearAll(); DamageAggregator.ClearAll(); + _sb.AppendLine("reaction Killed×100(연쇄 100 · 티어 4): " + GcOf(() => CombatEvents.RaiseKilled(elite), 100)); + _sb.AppendLine("reaction HitConfirmed×100(합산 1슬롯): " + GcOf(() => CombatEvents.RaiseHitConfirmed(elite, dmg), 100)); + KillChain.ClearAll(); DamageAggregator.ClearAll(); yield return null; + + set(false, false, true); + _sb.AppendLine("blink HitConfirmed×100(on 1+연장 99): " + GcOf(() => CombatEvents.RaiseHitConfirmed(elite, dmg), 100)); + _sb.AppendLine("blink Killed×100(엣지 세팅): " + GcOf(() => CombatEvents.RaiseKilled(elite), 100)); + _sb.AppendLine("blink Spawned×100(정리): " + GcOf(() => CombatEvents.RaiseSpawned(elite), 100)); + yield return null; + + set(true, true, true); CombatCameraDirector.ResetRun(); + _sb.AppendLine("all3 Killed×100: " + GcOf(() => CombatEvents.RaiseKilled(elite), 100)); + _sb.AppendLine("all3 HitConfirmed×100: " + GcOf(() => CombatEvents.RaiseHitConfirmed(elite, dmg), 100)); + WLCombatCameraSettings.RuntimeDisabled = c0; WLReactionSettings.RuntimeDisabled = r0; WLHitBlinkSettings.RuntimeDisabled = b0; + _sb.AppendLine("# 원복 RuntimeDisabled cam/re/blink=" + c0 + "/" + r0 + "/" + b0); + Save("gc", _sb); Cleanup(); + } + + /// ⓖ C8: 3 SO 런타임 off → 같은 이벤트에 카운터 0 · 카메라 필드 불변 · MPB 0 · timeScale 1 → on 복귀. + public IEnumerator Co_Toggle() + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; + _sb.AppendLine("# WL811def Toggle " + DateTime.Now.ToString("HH:mm:ss")); + InstallCamera(); yield return null; yield return null; + var mat = LoadSampleMat(); + var elite = MakeMob("__wl811def_elite", eSubRol.Elite, Front(5f), mat); _trash.Add(elite.gameObject); + var boss = MakeMob("__wl811def_boss", eSubRol.Boss, Front(5f), mat); _trash.Add(boss.gameObject); + var pc = MakePC("__wl811def_pcactor", Front(5f)); _trash.Add(pc.gameObject); + yield return null; + var rend = elite.GetComponentInChildren(); + CombatCameraDirector.ResetDiagnostics(); CombatCameraDirector.ResetRun(); KillChain.ResetDiagnostics(); KillChain.ClearAll(); DamageAggregator.ResetDiagnostics(); DamageAggregator.ClearAll(); MobHitFlash.ResetDiagnostics(); + WLCombatCameraSettings.RuntimeDisabled = true; WLReactionSettings.RuntimeDisabled = true; WLHitBlinkSettings.RuntimeDisabled = true; + float minB = _cam.minDistance, yawB = _cam.rotateAround; + CombatEvents.RaiseKilled(elite); CombatEvents.RaiseSpawned(boss); CombatEvents.RaiseComboStage(pc, 3, "Attack3_S", 0f, true); + for (int i = 0; i < 3; i++) CombatEvents.RaiseHitConfirmed(elite, MakeDamage(10, false, false)); + yield return null; yield return null; + _sb.AppendLine(Pre() + "OFF: killCam=" + CombatCameraDirector.KillCamCount + " boss=" + CombatCameraDirector.BossCamCount + " lead=" + CombatCameraDirector.ComboLeadCount + " mult=" + CameraMoveLead.LeadMultiplier.ToString("F2") + " chainKills=" + KillChain.KillCount + " tiers=" + KillChain.TierCount + " mergeHits=" + DamageAggregator.HitCount + " blinkOn=" + MobHitFlash.OnCount + " block=" + rend.HasPropertyBlock() + " cam(min/yaw) " + minB.ToString("F2") + "→" + _cam.minDistance.ToString("F2") + " / " + yawB.ToString("F2") + "→" + _cam.rotateAround.ToString("F2") + " timeScale=" + Time.timeScale.ToString("F2") + " (기대 전부 0 · 불변 · 1.00)"); + WLCombatCameraSettings.RuntimeDisabled = false; WLReactionSettings.RuntimeDisabled = false; WLHitBlinkSettings.RuntimeDisabled = false; + CombatEvents.RaiseKilled(elite); CombatEvents.RaiseComboStage(pc, 3, "Attack3_S", 0f, true); + for (int i = 0; i < 3; i++) CombatEvents.RaiseHitConfirmed(elite, MakeDamage(10, false, false)); + yield return null; + _sb.AppendLine(Pre() + "ON : killCam=" + CombatCameraDirector.KillCamCount + " lead=" + CombatCameraDirector.ComboLeadCount + " mult=" + CameraMoveLead.LeadMultiplier.ToString("F2") + " chainKills=" + KillChain.KillCount + " mergeHits=" + DamageAggregator.HitCount + " blinkOn=" + MobHitFlash.OnCount + " block=" + rend.HasPropertyBlock() + " timeScale=" + Time.timeScale.ToString("F2") + " (기대 1/1/1.30/1/3/1/True/0.35)"); + yield return Wait(1.2f); + _sb.AppendLine(Pre() + "복원: timeScale=" + Time.timeScale.ToString("F2") + " block=" + rend.HasPropertyBlock() + " min=" + _cam.minDistance.ToString("F2") + " mult=" + CameraMoveLead.LeadMultiplier.ToString("F2")); + Save("toggle", _sb); Cleanup(); + } + + /// 병합 후 실전투(메인 에디터 · Lead QA): 3 SO ON n초 / OFF n초 UnityStats 비교(WL785_Budget 스타일). + public IEnumerator Co_Budget(float sec) + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; + _sb.AppendLine("# WL811def Budget " + DateTime.Now.ToString("HH:mm:ss") + " " + sec + " s ×2"); + for (int pass = 0; pass < 2; pass++) + { + bool on = pass == 0; + WLCombatCameraSettings.RuntimeDisabled = !on; WLReactionSettings.RuntimeDisabled = !on; WLHitBlinkSettings.RuntimeDisabled = !on; + yield return Wait(0.3f); + int n = 0, mSet = 0, mDraw = 0; long aSet = 0, aDraw = 0; float worst = 0f, sum = 0f; long gc0 = GC.GetTotalMemory(false); + int kc0 = CombatCameraDirector.KillCamCount, on0 = MobHitFlash.OnCount, fl0 = DamageAggregator.FlushCount, tr0 = KillChain.TierCount; + float t = Time.unscaledTime; + while (Time.unscaledTime - t < sec) + { + yield return null; + float dt = Time.unscaledDeltaTime; worst = Mathf.Max(worst, dt); sum += dt; n++; + int sp = UnityEditor.UnityStats.setPassCalls, d = UnityEditor.UnityStats.drawCalls; + mSet = Mathf.Max(mSet, sp); mDraw = Mathf.Max(mDraw, d); aSet += sp; aDraw += d; + } + _sb.AppendLine((on ? "연출 ON " : "연출 OFF") + " avgFps=" + (n / Mathf.Max(sum, 0.001f)).ToString("F1") + " worstFrame=" + (worst * 1000f).ToString("F1") + " ms · setPass max/avg=" + mSet + "/" + (n > 0 ? aSet / n : 0) + " draw max/avg=" + mDraw + "/" + (n > 0 ? aDraw / n : 0) + " · GC+" + (GC.GetTotalMemory(false) - gc0) + " B · killCam+" + (CombatCameraDirector.KillCamCount - kc0) + " blinkOn+" + (MobHitFlash.OnCount - on0) + " flush+" + (DamageAggregator.FlushCount - fl0) + " tier+" + (KillChain.TierCount - tr0) + " frames=" + n); + } + WLCombatCameraSettings.RuntimeDisabled = false; WLReactionSettings.RuntimeDisabled = false; WLHitBlinkSettings.RuntimeDisabled = false; + Save("budget", _sb); Destroy(gameObject); + } + } +} diff --git a/Assets/WL/Camera/CameraMoveLead.cs b/Assets/WL/Camera/CameraMoveLead.cs index 19c4db8d5..4f43532be 100644 --- a/Assets/WL/Camera/CameraMoveLead.cs +++ b/Assets/WL/Camera/CameraMoveLead.cs @@ -57,6 +57,12 @@ public class CameraMoveLead /// 에디터/진단에서 에셋을 갈아끼울 때. public static void ReloadSettings() { s_Loaded = false; s_Settings = null; } + /// + /// WL #811d — 런타임 리드 배율(전진·후진·측면 리드 전부에 곱한다 · 기본 1). 3타 ComboStage(isLast) 에 + /// CombatCameraDirector 가 잠시 1.3 으로 올렸다가 되돌린다(값은 WLCombatCameraSettings). 1 이면 기존 동작과 동일. + /// + public static float LeadMultiplier = 1f; + // ── 상태 Transform _bound; // 현재 추적 중인 타겟(바뀌면 리셋) Vector3 _prevPos; @@ -174,11 +180,11 @@ public class CameraMoveLead } // ── 리드 거리비 스케일 (실내·보스에서 카메라가 가까우면 리드도 함께 줄어든다) - float leadScale = 1f; + float leadScale = LeadMultiplier; // #811d 3타 리드 배율(기본 1) if (st.scaleLeadByDistance) { float refDist = st.leadReferenceDistance > 0.0001f ? st.leadReferenceDistance : _bindDistance; - if (refDist > 0.0001f) leadScale = distanceAway / refDist; + if (refDist > 0.0001f) leadScale *= distanceAway / refDist; } // ── 전진 리드(진행 방향) + 후진 리드(camFwd 축 · backwardLeadDistance 음수면 카메라 쪽으로 당김) diff --git a/Assets/WL/Camera/CombatCameraDirector.cs b/Assets/WL/Camera/CombatCameraDirector.cs new file mode 100644 index 000000000..7ce454072 --- /dev/null +++ b/Assets/WL/Camera/CombatCameraDirector.cs @@ -0,0 +1,361 @@ +// ───────────────────────────────────────────────────────────────────────────── +// CombatCameraDirector.cs — 전투 연출 카메라: 킬캠(줌인+슬로모) · 보스 등장 캠(오빗) · 3타 카메라 리드 +// +// PD 지시 #813 · 발주서 WL-811def §1-1 · 설계안 WL-811a §C 811d 행 · 기준서 v1 §B 요소 6·7 · §F-4 (2026-09-09) +// +// ■ 무엇을 하나 (전부 코어 이벤트 **구독** · 원본 훅 0 · RealCamera 무수정) +// Killed(Elite/Boss) → TimeScaleArbiter.Request(Cinematic · cinematicDefaultScale · killCamSlowMoSeconds) +// + RealCamera 궤도 거리(minDistance/maxDistance[/DistanceUp]) × killCamZoomScale 램프 → 유지 → 복원 +// 쿨 = 코어 killCamCooldownSeconds · 런당 killCamMaxPerRun · 하단 40% 가드(처치 위치 뷰포트 y) +// Spawned(isBoss) → arr_cCamData[1] 보스 프리셋 덮어쓰기(옵션) · Set_Target(보스) · Add_Rot 로 sin 오빗 · 거리 배율 · 끝나면 전부 복원 +// ComboStage(isLast) → CameraMoveLead.LeadMultiplier = comboLeadMultiplier (comboLeadSeconds 뒤 1 로) +// +// ■ 왜 RealCamera 공개 필드를 매 프레임 쓰는가 (사후 가산 기각 근거 = CameraMoveLead.cs / WLHitFeelCameraShake.cs 주석) +// RealCamera.Update_Cam 은 자기 출력을 다시 입력으로 읽는 저역통과 필터라 transform 에 오프셋을 더하면 13배 반대 방향으로 튄다. +// 대신 그 필터의 **입력**(궤도 거리 파라미터)을 바꾼다: DistanceAway 는 매 LateUpdate 마다 [minDistance, maxDistance] 로 클램프되므로 +// 두 값을 같이 줄이면 그 프레임부터 궤도가 줄고 Lerp(smooth 4)가 자연스러운 줌을 만든다. 드로우 +0 · 프리팹 무수정. +// +// ■ 안전장치 (TimeScaleArbiter 규약 승계) +// · 복원은 "우리가 마지막에 쓴 값이 그대로 남아 있을 때만" — Init_Cam/Set_InsideBuilding 등 외부가 덮어썼으면 포기(덮어쓰지 않음) +// · 인트로(CamStatus 0 = IsEnter) 중에는 줌·오빗을 걸지 않는다 · RealCamera 가 없으면(타이틀 등) 슬로모만 +// · 보스 캠 중 킬캠 줌은 스킵(슬로모만) · 킬캠 중 보스 스폰은 킬캠을 즉시 복원하고 보스 캠 시작 +// · 씬 전환 시 상태·런 카운터 초기화 · 러너 파괴/앱 종료 시 전부 복원 +// · 시간은 전부 unscaled(슬로모 중에도 램프가 늘어지지 않는다) · 핸들러 GC 0(verboseLog 때만 문자열) +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using UnityEngine.SceneManagement; +using WL.Combat.Core; + +namespace WL.CombatCamera +{ + public static class CombatCameraDirector + { + enum Phase { None = 0, ZoomIn = 1, Hold = 2, ZoomOut = 3 } + + // ── 킬캠 상태 + static Phase s_phase; + static float s_phaseStart, s_killStart; + static float s_scale = 1f; + static float s_baseMin, s_baseMax, s_baseUp; // 발동 시점 스냅샷 + static float s_wMin, s_wMax, s_wUp; // 마지막으로 우리가 쓴 값(외부 덮어쓰기 감지) + static bool s_scaleUp; + static float s_lastKillCam = -999f; + static int s_runCount; + static RealCamera s_cam; + + // ── 보스 캠 상태 + static bool s_bossActive; + static float s_bossStart; + static Transform s_bossTf, s_prevTarget; + static bool s_bossRetargeted; + static float s_bossYawApplied; + static float s_bossBaseMin, s_bossBaseMax, s_bossWMin, s_bossWMax; + + // ── 3타 리드 상태 + static float s_leadUntil = -1f; + + static CombatCameraRunner s_runner; + + // ── 진단(프로브가 읽는다) + public static bool Subscribed; + public static int KillCamCount, KillCamSkippedRole, KillCamSkippedCooldown, KillCamSkippedRunCap, KillCamSkippedGuard, KillCamSkippedNoCam, KillCamSkippedBusy, KillCamAbandoned; + public static int BossCamCount, BossCamSkipped, BossPresetApplied, ComboLeadCount; + public static ArbiterResult LastSlowMoResult; + public static float LastVictimViewportY = -1f; + public static float LastKillCamMeasured; // 줌 시작 → 완전 복원까지(초 · unscaled) + public static float LastBossCamMeasured; + public static string LastInfo = ""; + + public static bool KillCamActive { get { return s_phase != Phase.None; } } + public static bool BossCamActive { get { return s_bossActive; } } + public static float CurrentZoomScale { get { return s_scale; } } + public static int RunCount { get { return s_runCount; } } + public static float LeadUntil { get { return s_leadUntil; } } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void Register() + { + CombatEvents.Killed.Add(OnKilled); + CombatEvents.Spawned.Add(OnSpawned); + CombatEvents.ComboStage.Add(OnComboStage); + SceneManager.activeSceneChanged += OnSceneChanged; + Subscribed = true; + } + + static WLCombatCameraSettings St { get { return WLCombatCameraSettings.Instance; } } + + static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } + + static void Log(string msg) + { + var st = St; + if (st != null && st.verboseLog) Debug.Log("[CombatCameraDirector] " + msg); + } + + static CombatCameraRunner Runner + { + get + { + if (s_runner == null) + { + var go = new GameObject("__WLCombatCamera"); + Object.DontDestroyOnLoad(go); + s_runner = go.AddComponent(); + } + return s_runner; + } + } + + static RealCamera ResolveCamera() { return MyValue.m_RealCamera; } + + // ───────────────────────────────────────── Killed → 킬캠 + static void OnKilled(in KilledEvent e) + { + if (!WLCombatCameraSettings.Enabled) return; + var st = St; + if (!st.killCamEnabled) return; + + bool roleOk = e.subRole == eSubRol.Boss ? st.killCamOnBoss : e.subRole == eSubRol.Elite ? st.killCamOnElite : st.killCamOnNormal; + if (!roleOk || (st.killCamDirectHitOnly && !e.byDirectHit)) { KillCamSkippedRole++; return; } + + float now = Time.unscaledTime; + var core = WLCombatCoreSettings.Instance; + float cooldown = core != null ? core.killCamCooldownSeconds : 0f; + if (now - s_lastKillCam < cooldown) { KillCamSkippedCooldown++; LastInfo = "cooldown"; if (Verbose) Log("킬캠 스킵(쿨) " + (now - s_lastKillCam).ToString("F2") + " < " + cooldown); return; } + if (st.killCamMaxPerRun > 0 && s_runCount >= st.killCamMaxPerRun) { KillCamSkippedRunCap++; LastInfo = "runcap"; if (Verbose) Log("킬캠 스킵(런 상한 " + st.killCamMaxPerRun + ")"); return; } + + s_lastKillCam = now; + s_runCount++; + KillCamCount++; + + // ① 슬로모 — 중재자 Cinematic 층(히트스톱은 이 동안 SkippedHigher · D-1) + float slow = core != null ? core.cinematicDefaultScale : 1f; + LastSlowMoResult = TimeScaleArbiter.Request(TimeLayer.Cinematic, slow, st.killCamSlowMoSeconds); + + // ② 줌 — RealCamera 가 있고 인트로가 아니고 보스 캠 중이 아닐 때 + var cam = ResolveCamera(); + if (cam == null) { KillCamSkippedNoCam++; LastInfo = "slowmo-only(no cam)"; if (Verbose) Log("킬캠 줌 스킵(RealCamera 없음) 슬로모=" + LastSlowMoResult); return; } + if (cam.IsEnter() || s_bossActive) { KillCamSkippedBusy++; LastInfo = "slowmo-only(busy)"; if (Verbose) Log("킬캠 줌 스킵(인트로/보스 캠) 슬로모=" + LastSlowMoResult); return; } + + if (st.killCamBottomGuardViewportY > 0f) + { + var uc = Camera.main; + if (uc != null) + { + var v = uc.WorldToViewportPoint(e.position); + LastVictimViewportY = v.y; + if (v.z > 0f && v.y < st.killCamBottomGuardViewportY) + { + KillCamSkippedGuard++; LastInfo = "slowmo-only(bottom guard)"; + if (Verbose) Log("킬캠 줌 스킵(하단 가드 y=" + v.y.ToString("F2") + " < " + st.killCamBottomGuardViewportY + ") 슬로모=" + LastSlowMoResult); + return; + } + } + } + + if (s_phase != Phase.None) EndZoom(true); // (쿨 안이라 사실상 없음) 안전 + s_cam = cam; + s_baseMin = cam.minDistance; s_baseMax = cam.maxDistance; s_baseUp = cam.DistanceUp; + s_scaleUp = st.killCamScaleDistanceUp; + s_phase = Phase.ZoomIn; s_phaseStart = s_killStart = now; + WriteZoom(1f); + Runner.enabled = true; + LastInfo = "killcam"; + if (Verbose) Log("킬캠 발동 victim=" + (e.victim != null ? e.victim.name : "?") + " sub=" + e.subRole + " 슬로모=" + LastSlowMoResult + " base=" + s_baseMin.ToString("F2") + "/" + s_baseMax.ToString("F2") + "/" + s_baseUp.ToString("F2")); + } + + static void WriteZoom(float k) + { + var cam = s_cam; + s_scale = k; + cam.minDistance = s_wMin = s_baseMin * k; + cam.maxDistance = s_wMax = s_baseMax * k; + if (s_scaleUp) cam.DistanceUp = s_wUp = s_baseUp * k; + } + + static bool ZoomStillOurs() + { + var cam = s_cam; + if (cam == null) return false; + if (!Mathf.Approximately(cam.minDistance, s_wMin) || !Mathf.Approximately(cam.maxDistance, s_wMax)) return false; + if (s_scaleUp && !Mathf.Approximately(cam.DistanceUp, s_wUp)) return false; + return true; + } + + static void EndZoom(bool restore) + { + if (s_phase == Phase.None) return; + if (restore && ZoomStillOurs()) + { + var cam = s_cam; + cam.minDistance = s_baseMin; cam.maxDistance = s_baseMax; + if (s_scaleUp) cam.DistanceUp = s_baseUp; + LastKillCamMeasured = Time.unscaledTime - s_killStart; + } + else if (restore) { KillCamAbandoned++; LastKillCamMeasured = -1f; } + s_phase = Phase.None; s_scale = 1f; s_cam = null; + if (Verbose) Log("킬캠 종료 restore=" + restore + " measured=" + LastKillCamMeasured.ToString("F3")); + } + + static void TickKillCam(float now) + { + if (s_phase == Phase.None) return; + var st = St; + if (s_cam == null || st == null || !WLCombatCameraSettings.Enabled) { EndZoom(true); return; } + if (!ZoomStillOurs()) { KillCamAbandoned++; s_phase = Phase.None; s_scale = 1f; s_cam = null; LastKillCamMeasured = -1f; if (Verbose) Log("킬캠 포기(외부가 거리 필드를 바꿈)"); return; } + + float t = now - s_phaseStart; + float zoom = st.killCamZoomScale; + switch (s_phase) + { + case Phase.ZoomIn: + if (t >= st.killCamZoomInSeconds) { WriteZoom(zoom); s_phase = Phase.Hold; s_phaseStart = now; } + else WriteZoom(Mathf.Lerp(1f, zoom, Mathf.SmoothStep(0f, 1f, t / Mathf.Max(st.killCamZoomInSeconds, 0.0001f)))); + break; + case Phase.Hold: + if (t >= st.killCamHoldSeconds) { s_phase = Phase.ZoomOut; s_phaseStart = now; } + break; + case Phase.ZoomOut: + if (t >= st.killCamZoomOutSeconds) EndZoom(true); + else WriteZoom(Mathf.Lerp(zoom, 1f, Mathf.SmoothStep(0f, 1f, t / Mathf.Max(st.killCamZoomOutSeconds, 0.0001f)))); + break; + } + } + + // ───────────────────────────────────────── Spawned(isBoss) → 보스 캠 + static void OnSpawned(in SpawnedEvent e) + { + if (!WLCombatCameraSettings.Enabled) return; + var st = St; + if (!st.bossCamEnabled || !e.isBoss || e.actor == null) return; + var cam = ResolveCamera(); + if (cam == null) { BossCamSkipped++; LastInfo = "bosscam(no cam)"; return; } + + if (st.bossPresetApply && cam.arr_cCamData != null && cam.arr_cCamData.Length > 1 && cam.arr_cCamData[1] != null) + { + var d = cam.arr_cCamData[1]; + d.minDistance = d.maxDistance = st.bossPresetDistance; + d.cameraHeight = st.bossPresetCameraHeight; + if (st.bossPresetDistanceUp >= 0f) d.DistanceUp = st.bossPresetDistanceUp; + BossPresetApplied++; + } + + if (cam.IsEnter() || s_bossActive) { BossCamSkipped++; LastInfo = "bosscam(busy)"; return; } + if (s_phase != Phase.None) EndZoom(true); + + float now = Time.unscaledTime; + s_cam = cam; + s_bossActive = true; s_bossStart = now; + s_bossTf = e.actor.transform; + s_prevTarget = cam.target; + s_bossRetargeted = false; + if (st.bossCamRetarget) { cam.Set_Target(s_bossTf); s_bossRetargeted = true; } + s_bossBaseMin = cam.minDistance; s_bossBaseMax = cam.maxDistance; + cam.minDistance = s_bossWMin = s_bossBaseMin * st.bossCamDistanceScale; + cam.maxDistance = s_bossWMax = s_bossBaseMax * st.bossCamDistanceScale; + s_bossYawApplied = 0f; + BossCamCount++; + Runner.enabled = true; + LastInfo = "bosscam"; + if (Verbose) Log("보스 캠 시작 boss=" + e.actor.name + " prevTarget=" + (s_prevTarget != null ? s_prevTarget.name : "null") + " retarget=" + s_bossRetargeted); + } + + static void EndBossCam(bool restore) + { + if (!s_bossActive) return; + var cam = s_cam; + if (restore && cam != null) + { + if (Mathf.Abs(s_bossYawApplied) > 1e-4f) cam.Add_Rot(-s_bossYawApplied); + if (Mathf.Approximately(cam.minDistance, s_bossWMin) && Mathf.Approximately(cam.maxDistance, s_bossWMax)) + { + cam.minDistance = s_bossBaseMin; cam.maxDistance = s_bossBaseMax; + } + if (s_bossRetargeted && ReferenceEquals(cam.target, s_bossTf)) cam.Set_Target(s_prevTarget); // 외부가 대상을 바꿨으면 손대지 않음 + LastBossCamMeasured = Time.unscaledTime - s_bossStart; + } + s_bossActive = false; s_bossTf = null; s_prevTarget = null; s_bossYawApplied = 0f; s_cam = null; + if (Verbose) Log("보스 캠 종료 restore=" + restore + " measured=" + LastBossCamMeasured.ToString("F3")); + } + + static void TickBossCam(float now) + { + if (!s_bossActive) return; + var st = St; + if (s_cam == null || st == null || !WLCombatCameraSettings.Enabled) { EndBossCam(true); return; } + float dur = Mathf.Max(st.bossCamSeconds, 0.0001f); + float t = (now - s_bossStart) / dur; + if (t >= 1f) { EndBossCam(true); return; } + float yaw = Mathf.Sin(t * Mathf.PI) * st.bossCamOrbitDegrees; + s_cam.Add_Rot(yaw - s_bossYawApplied); + s_bossYawApplied = yaw; + } + + // ───────────────────────────────────────── ComboStage(isLast) → 3타 리드 + static void OnComboStage(in ComboStageEvent e) + { + if (!WLCombatCameraSettings.Enabled) return; + var st = St; + if (!st.comboLeadEnabled || !e.isLast) return; + CameraMoveLead.LeadMultiplier = st.comboLeadMultiplier; + s_leadUntil = Time.unscaledTime + st.comboLeadSeconds; + ComboLeadCount++; + Runner.enabled = true; + if (Verbose) Log("3타 리드 ×" + st.comboLeadMultiplier + " " + st.comboLeadSeconds + " s clip=" + e.clip); + } + + static void TickLead(float now) + { + if (s_leadUntil >= 0f && now >= s_leadUntil) { CameraMoveLead.LeadMultiplier = 1f; s_leadUntil = -1f; } + } + + // ───────────────────────────────────────── 공용 + internal static void Tick() + { + float now = Time.unscaledTime; + TickKillCam(now); + TickBossCam(now); + TickLead(now); + } + + /// 활성 연출을 전부 끝내고 카메라 값을 복원한다(러너 파괴 · 앱 종료 · 프로브 정리). + public static void ReleaseAll() + { + EndZoom(true); + EndBossCam(true); + CameraMoveLead.LeadMultiplier = 1f; s_leadUntil = -1f; + } + + static void OnSceneChanged(Scene from, Scene to) + { + // 카메라 오브젝트가 사라졌을 수 있다 — 복원 없이 상태만 버린다(복원은 ReleaseAll 이 러너 OnDisable 에서 시도) + s_phase = Phase.None; s_scale = 1f; s_cam = null; + s_bossActive = false; s_bossTf = null; s_prevTarget = null; s_bossYawApplied = 0f; + CameraMoveLead.LeadMultiplier = 1f; s_leadUntil = -1f; + s_runCount = 0; s_lastKillCam = -999f; + } + + /// 프로브용: 런 카운터·쿨 초기화(연출 상태는 유지). + public static void ResetRun() { s_runCount = 0; s_lastKillCam = -999f; } + + /// 프로브·QA 용: 쿨만 초기화(런 카운터 유지 → 런 상한 검증). + public static void ResetCooldown() { s_lastKillCam = -999f; } + + /// 프로브용 카운터 초기화. + public static void ResetDiagnostics() + { + KillCamCount = KillCamSkippedRole = KillCamSkippedCooldown = KillCamSkippedRunCap = KillCamSkippedGuard = KillCamSkippedNoCam = KillCamSkippedBusy = KillCamAbandoned = 0; + BossCamCount = BossCamSkipped = BossPresetApplied = ComboLeadCount = 0; + LastKillCamMeasured = LastBossCamMeasured = 0f; LastVictimViewportY = -1f; LastInfo = ""; + } + } + + /// 연출 카메라 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad). 파괴·비활성·종료 시 카메라 값을 반드시 되돌린다. + public sealed class CombatCameraRunner : MonoBehaviour + { + void Update() { CombatCameraDirector.Tick(); } + void OnDisable() { CombatCameraDirector.ReleaseAll(); } + void OnApplicationQuit() { CombatCameraDirector.ReleaseAll(); } + } +} diff --git a/Assets/WL/Camera/CombatCameraDirector.cs.meta b/Assets/WL/Camera/CombatCameraDirector.cs.meta new file mode 100644 index 000000000..a8fc4b5bb --- /dev/null +++ b/Assets/WL/Camera/CombatCameraDirector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f5f5a016593441f8b731faec5b8b5713 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Camera/Resources.meta b/Assets/WL/Camera/Resources.meta new file mode 100644 index 000000000..88a6cbce5 --- /dev/null +++ b/Assets/WL/Camera/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2ee1594512e54743a94bfd3a27d641d1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Camera/Resources/WL.meta b/Assets/WL/Camera/Resources/WL.meta new file mode 100644 index 000000000..5bcf51275 --- /dev/null +++ b/Assets/WL/Camera/Resources/WL.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5800c235ea2f48768956ede4e6d35e2c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Camera/Resources/WL/WLCombatCameraSettings.asset b/Assets/WL/Camera/Resources/WL/WLCombatCameraSettings.asset new file mode 100644 index 000000000..485c4b0ec --- /dev/null +++ b/Assets/WL/Camera/Resources/WL/WLCombatCameraSettings.asset @@ -0,0 +1,41 @@ +%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: de274e4d2cb745929ac9d07882450330, type: 3} + m_Name: WLCombatCameraSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.CombatCamera.WLCombatCameraSettings + enabled: 1 + verboseLog: 0 + killCamEnabled: 1 + killCamOnElite: 1 + killCamOnBoss: 1 + killCamOnNormal: 0 + killCamDirectHitOnly: 0 + killCamZoomScale: 0.75 + killCamScaleDistanceUp: 1 + killCamZoomInSeconds: 0.12 + killCamHoldSeconds: 0.28 + killCamZoomOutSeconds: 0.35 + killCamSlowMoSeconds: 0.35 + killCamBottomGuardViewportY: 0.4 + killCamMaxPerRun: 6 + bossCamEnabled: 1 + bossCamSeconds: 1.2 + bossCamRetarget: 1 + bossCamOrbitDegrees: 35 + bossCamDistanceScale: 1.15 + bossPresetApply: 1 + bossPresetDistance: 3.75 + bossPresetCameraHeight: 67 + bossPresetDistanceUp: -1 + comboLeadEnabled: 1 + comboLeadMultiplier: 1.3 + comboLeadSeconds: 0.45 diff --git a/Assets/WL/Camera/Resources/WL/WLCombatCameraSettings.asset.meta b/Assets/WL/Camera/Resources/WL/WLCombatCameraSettings.asset.meta new file mode 100644 index 000000000..2eb63fdac --- /dev/null +++ b/Assets/WL/Camera/Resources/WL/WLCombatCameraSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9eeeb1d0aa754a3f9aafa9a3786605fc +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Camera/WLCombatCameraSettings.cs b/Assets/WL/Camera/WLCombatCameraSettings.cs new file mode 100644 index 000000000..2161462e4 --- /dev/null +++ b/Assets/WL/Camera/WLCombatCameraSettings.cs @@ -0,0 +1,144 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLCombatCameraSettings.cs — 전투 연출 카메라(킬캠 · 보스 등장 캠 · 3타 리드) 값의 단일 출처(SOT · C45) +// +// PD 지시 #813 · 발주서 WL-811def §1-1 · 설계안 WL-811a §C 811d 행 · 기준서 v1 §B 요소 6·7 · §F-4 (2026-09-09) +// +// 에셋 경로(고정): Assets/WL/Camera/Resources/WL/WLCombatCameraSettings.asset +// → Resources.Load("WL/WLCombatCameraSettings") 로 1회 로드 후 캐시(기존 설정 SO 관례). +// 에셋이 없거나 enabled = false 또는 RuntimeDisabled 면 CombatCameraDirector 의 모든 구독자가 즉시 반환 = 원본 동작 100%(C8). +// +// 코어 SO(WLCombatCoreSettings)와 나눠 갖는 값 — 811b 가 811d 소비자용으로 예약해 둔 필드는 여기서 **다시 두지 않는다**(SOT 1곳): +// · 킬캠 슬로모 배율 = WLCombatCoreSettings.cinematicDefaultScale (기획안 제안값 0.35) +// · 킬캠 최소 간격(쿨) = WLCombatCoreSettings.killCamCooldownSeconds (기획안 F-0A 제안값 8 s) +// +// RealCamera(원본 · S 소유)는 수정하지 않는다 — 공개 필드 minDistance/maxDistance/DistanceUp · Add_Rot · Set_Target · arr_cCamData 만 쓴다. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.CombatCamera +{ + [CreateAssetMenu(fileName = "WLCombatCameraSettings", menuName = "WL/Combat Camera Settings", order = 321)] + public sealed class WLCombatCameraSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLCombatCameraSettings"; + + static WLCombatCameraSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null. + public static WLCombatCameraSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치(WLHitFeel.RuntimeDisabled 선례). 에셋을 건드리지 않고 이 연출만 끈다. Play 종료 시 자동 false. + public static bool RuntimeDisabled; + + /// 연출 카메라가 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off. + public static bool Enabled + { + get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; } + } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다. + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + // ─────────────────────────────────────────── 전체 스위치 + [Header("전체 스위치")] + [Tooltip("끄면 킬캠·보스 캠·3타 리드가 전부 비활성 — RealCamera 는 100% 기존 동작. 롤백 1순위.")] + public bool enabled = true; + + [Tooltip("연출 결정(발동·스킵 사유·복원)을 Console 에 남긴다(검증용 · 기본 off · GC 발생).")] + public bool verboseLog = false; + + // ─────────────────────────────────────────── 킬캠 + [Header("킬캠 — Killed(엘리트/보스) → 줌인 + 슬로모(중재자 Cinematic 층)")] + [Tooltip("킬캠 사용 여부.")] + public bool killCamEnabled = true; + + [Tooltip("엘리트(eSubRol.Elite) 처치에 발동.")] + public bool killCamOnElite = true; + + [Tooltip("보스(eSubRol.Boss) 처치에 발동.")] + public bool killCamOnBoss = true; + + [Tooltip("일반 몹 처치에도 발동(기본 off · 존 마지막 신호는 813e 뒤에 붙는다).")] + public bool killCamOnNormal = false; + + [Tooltip("직접 타격 처치(byDirectHit)에만 발동. off 면 도트·연쇄 피해 처치도 발동.")] + public bool killCamDirectHitOnly = false; + + [Tooltip("줌 배율 — RealCamera 궤도 거리(minDistance/maxDistance)에 곱한다. 기준서 §B 요소 7 제안값 0.75.")] + public float killCamZoomScale = 0.75f; + + [Tooltip("궤도 중심 높이(DistanceUp)도 같은 배율로 줄인다 = 시야각을 유지한 균일 줌. off 면 거리만 줄어 내려다보는 각이 조금 가팔라진다.")] + public bool killCamScaleDistanceUp = true; + + [Tooltip("줌인 램프 시간(초 · unscaled). RealCamera 자체 Lerp(smooth 4 · τ≈0.25 s)가 더해지므로 짧게.")] + public float killCamZoomInSeconds = 0.12f; + + [Tooltip("줌 유지 시간(초 · unscaled). 줌인+유지 ≈ 기준서 0.25~0.4 s.")] + public float killCamHoldSeconds = 0.28f; + + [Tooltip("줌아웃 복원 시간(초 · unscaled).")] + public float killCamZoomOutSeconds = 0.35f; + + [Tooltip("슬로모 유지 시간(초 · unscaled). 배율은 코어 SO cinematicDefaultScale(0.35). 기준서 §B 요소 6 = 0.35 · 0.4 s.")] + public float killCamSlowMoSeconds = 0.35f; + + [Tooltip("세로 하단 HUD 영역 보호 — 처치 위치의 뷰포트 y 가 이 값 미만(화면 아래쪽)이면 줌은 건너뛰고 슬로모만 건다(줌인이 하단으로 더 밀어 넣는 것을 막는다). 0 = 검사 안 함.")] + public float killCamBottomGuardViewportY = 0.4f; + + [Tooltip("한 런(씬 로드 사이)당 킬캠 최대 횟수. 기준서 「런당 ≤6회」 제안값. 0 = 무제한.")] + public int killCamMaxPerRun = 6; + + // ─────────────────────────────────────────── 보스 등장 캠 + [Header("보스 등장 캠 — Spawned(isBoss) → 오빗")] + [Tooltip("보스 등장 캠 사용 여부.")] + public bool bossCamEnabled = true; + + [Tooltip("오빗 길이(초 · unscaled). 기준서 §B 요소 6 = 1.2 s.")] + public float bossCamSeconds = 1.2f; + + [Tooltip("오빗 동안 카메라 추적 대상을 보스로 바꾼다(Set_Target) — 끝나면 원래 대상으로 복원(외부가 바꿨으면 복원하지 않음).")] + public bool bossCamRetarget = true; + + [Tooltip("오빗 요(yaw) 진폭(도). sin 곡선으로 0 → +진폭 → 0 이라 끝에서 원래 각으로 돌아온다(점프 0).")] + public float bossCamOrbitDegrees = 35f; + + [Tooltip("오빗 동안 궤도 거리 배율(1 = 그대로 · >1 = 멀리서 보스 전신).")] + public float bossCamDistanceScale = 1.15f; + + [Tooltip("보스 스폰 때 RealCamera.arr_cCamData[1](보스/락타겟 프리셋)을 아래 값으로 덮어쓴다. 기준서 §F-4 「보스전 3.5~4 m · 65~70°」 제안값. 프리셋은 플레이어가 보스를 락타겟할 때 적용된다.")] + public bool bossPresetApply = true; + + [Tooltip("arr_cCamData[1].minDistance = maxDistance 로 쓸 거리(m).")] + public float bossPresetDistance = 3.75f; + + [Tooltip("arr_cCamData[1].cameraHeight(궤도 파라미터 · 원본 78).")] + public float bossPresetCameraHeight = 67f; + + [Tooltip("arr_cCamData[1].DistanceUp. 음수면 프리팹 값을 유지한다.")] + public float bossPresetDistanceUp = -1f; + + // ─────────────────────────────────────────── 3타 리드 + [Header("3타 카메라 리드 — ComboStage(isLast) → 이동 리드 배율")] + [Tooltip("콤보 마지막 타에 카메라 이동 리드(CameraMoveLead)를 잠시 키운다.")] + public bool comboLeadEnabled = true; + + [Tooltip("리드 배율(CameraMoveLead.LeadMultiplier). 설계안 §C 811d = ×1.3.")] + public float comboLeadMultiplier = 1.3f; + + [Tooltip("배율 유지 시간(초 · unscaled). 3타 클립 길이(0.42~0.47 s)에 맞춘 값.")] + public float comboLeadSeconds = 0.45f; + } +} diff --git a/Assets/WL/Camera/WLCombatCameraSettings.cs.meta b/Assets/WL/Camera/WLCombatCameraSettings.cs.meta new file mode 100644 index 000000000..5282d56ff --- /dev/null +++ b/Assets/WL/Camera/WLCombatCameraSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: de274e4d2cb745929ac9d07882450330 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Reaction.meta b/Assets/WL/Combat/Reaction.meta new file mode 100644 index 000000000..aa2416aef --- /dev/null +++ b/Assets/WL/Combat/Reaction.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ea3834211d3342ab87554cf8b8430517 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Reaction/DamageAggregator.cs b/Assets/WL/Combat/Reaction/DamageAggregator.cs new file mode 100644 index 000000000..d31f2676c --- /dev/null +++ b/Assets/WL/Combat/Reaction/DamageAggregator.cs @@ -0,0 +1,155 @@ +// ───────────────────────────────────────────────────────────────────────────── +// DamageAggregator.cs — 데미지 텍스트 합산 윈도우(0.25 s · 대상별) 데이터 API — 표시는 813g(U) +// +// PD 지시 #813 · 발주서 WL-811def §1-2 · 기준서 v1 §B 요소 2 「합산 윈도우 0.25 s · 숫자죽 방지」 (2026-09-09) +// +// ■ 무엇을 하나 (HitConfirmed 구독 · 원본 훅 0 · HUDDMGUI 무수정) +// 같은 대상에 첫 타격이 들어온 순간부터 damageMergeWindowSeconds(unscaled) 안의 타격을 한 슬롯에 더한다. +// 윈도우가 끝나면(또는 처치 타격이면 즉시) Flushed(대상 · 합계 · 타수 · 최대 단타 · 크리 여부 · 처치 여부 · 위치)를 1회 낸다. +// 813g 는 Flushed 만 구독하면 "초당 3~5개 겹치는 숫자" 대신 합산 1개를 띄울 수 있다. 원본 HUDDMGUI 는 그대로 돈다(병행). +// ■ GC 0 — 슬롯 배열(고정 · 첫 사용 때 1회 할당) · 선형 탐색(≤ 32) · 구조체 이벤트. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Reaction +{ + /// 합산 결과(윈도우 만료 또는 처치 즉시). + public struct DamageMergeEvent + { + public Actor victim; public double total; public int hits; public double maxSingle; public bool anyCritical; public bool isKill; public bool byMainPC; + public Vector3 position; public float firstTime; public float time; public int frame; + } + + public static class DamageAggregator + { + public static readonly CombatEventList Flushed = new CombatEventList(4); + + struct Slot + { + public bool used; public Actor victim; public double total, maxSingle; public int hits; + public bool anyCritical, isKill, byMainPC; public float start; public Vector3 pos; + } + + static Slot[] s_slots; + static int s_used; + + // ── 진단 + public static bool Subscribed; + public static int HitCount, FlushCount, MaxHitsMerged, EvictCount; + public static string LastInfo = ""; + public static int ActiveSlots { get { return s_used; } } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void Register() + { + CombatEvents.HitConfirmed.Add(OnHitConfirmed); + Subscribed = true; + } + + static WLReactionSettings St { get { return WLReactionSettings.Instance; } } + + static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } + + static void Log(string msg) + { + var st = St; + if (st != null && st.verboseLog) Debug.Log("[DamageAggregator] " + msg); + } + + static void EnsureSlots(WLReactionSettings st) + { + int n = Mathf.Max(4, st.damageMergeSlots); + if (s_slots == null || s_slots.Length != n) { s_slots = new Slot[n]; s_used = 0; } + } + + static void OnHitConfirmed(in HitConfirmedEvent e) + { + if (!WLReactionSettings.Enabled) return; + var st = St; + if (!st.damageMergeEnabled || e.victim == null) return; + if (st.damageMergeMainPCOnly && !e.byMainPC) return; + + EnsureSlots(st); + WLReactionRunner.Ensure(); + float now = Time.unscaledTime; + HitCount++; + + int idx = -1, free = -1, oldest = -1; float oldestStart = float.MaxValue; + for (int i = 0; i < s_slots.Length; i++) + { + if (!s_slots[i].used) { if (free < 0) free = i; continue; } + if (ReferenceEquals(s_slots[i].victim, e.victim)) { idx = i; break; } + if (s_slots[i].start < oldestStart) { oldestStart = s_slots[i].start; oldest = i; } + } + if (idx < 0) + { + if (free < 0) { Flush(oldest, now); EvictCount++; free = oldest; } + idx = free; + s_slots[idx].used = true; s_slots[idx].victim = e.victim; s_slots[idx].total = 0d; s_slots[idx].maxSingle = 0d; + s_slots[idx].hits = 0; s_slots[idx].anyCritical = false; s_slots[idx].isKill = false; s_slots[idx].byMainPC = e.byMainPC; s_slots[idx].start = now; + s_used++; + } + s_slots[idx].total += e.damage; + if (e.damage > s_slots[idx].maxSingle) s_slots[idx].maxSingle = e.damage; + s_slots[idx].hits++; + s_slots[idx].anyCritical |= e.critical; + s_slots[idx].isKill |= e.isKill; + s_slots[idx].byMainPC |= e.byMainPC; + s_slots[idx].pos = e.victim.Get_position(); + + if (e.isKill && st.damageMergeFlushOnKill) Flush(idx, now); + } + + static void Flush(int i, float now) + { + if (i < 0 || !s_slots[i].used) return; + var ev = new DamageMergeEvent + { + victim = s_slots[i].victim, total = s_slots[i].total, hits = s_slots[i].hits, maxSingle = s_slots[i].maxSingle, + anyCritical = s_slots[i].anyCritical, isKill = s_slots[i].isKill, byMainPC = s_slots[i].byMainPC, + position = s_slots[i].pos, firstTime = s_slots[i].start, time = now, frame = Time.frameCount + }; + if (ev.hits > MaxHitsMerged) MaxHitsMerged = ev.hits; + s_slots[i].used = false; s_slots[i].victim = null; s_used--; + FlushCount++; + Flushed.Dispatch(in ev); + if (Verbose) Log("플러시 victim=" + (ev.victim != null ? ev.victim.name : "?") + " total=" + ev.total.ToString("F0") + " hits=" + ev.hits + " cri=" + ev.anyCritical + " kill=" + ev.isKill); + } + + internal static void Tick(float now) + { + if (s_used <= 0 || s_slots == null) return; + var st = St; + float win = st != null ? st.damageMergeWindowSeconds : 0.25f; + for (int i = 0; i < s_slots.Length; i++) + if (s_slots[i].used && now - s_slots[i].start >= win) Flush(i, now); + } + + /// 데이터 API — 대상의 합산 중 값(813g 가 미리 읽고 싶을 때). 슬롯이 없으면 false. + public static bool TryGetPending(Actor victim, out double total, out int hits) + { + total = 0d; hits = 0; + if (s_slots == null || victim == null) return false; + for (int i = 0; i < s_slots.Length; i++) + if (s_slots[i].used && ReferenceEquals(s_slots[i].victim, victim)) { total = s_slots[i].total; hits = s_slots[i].hits; return true; } + return false; + } + + /// 씬 전환 · 프로브 정리: 슬롯을 비운다(이벤트 없음). + public static void ClearAll() + { + if (s_slots == null) return; + for (int i = 0; i < s_slots.Length; i++) { s_slots[i].used = false; s_slots[i].victim = null; } + s_used = 0; + } + + /// 프로브용 카운터 초기화. + public static void ResetDiagnostics() + { + HitCount = FlushCount = MaxHitsMerged = EvictCount = 0; LastInfo = ""; + Flushed.Raised = Flushed.Dispatched = 0; + } + } +} diff --git a/Assets/WL/Combat/Reaction/DamageAggregator.cs.meta b/Assets/WL/Combat/Reaction/DamageAggregator.cs.meta new file mode 100644 index 000000000..fbb6217eb --- /dev/null +++ b/Assets/WL/Combat/Reaction/DamageAggregator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fed24dd078714febae907d850dabd04f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Reaction/KillChain.cs b/Assets/WL/Combat/Reaction/KillChain.cs new file mode 100644 index 000000000..f8545d3ce --- /dev/null +++ b/Assets/WL/Combat/Reaction/KillChain.cs @@ -0,0 +1,178 @@ +// ───────────────────────────────────────────────────────────────────────────── +// KillChain.cs — 연쇄 처치 카운터(3 s 윈도우 · DOUBLE/TRIPLE/RAMPAGE/MASSACRE 티어) + 군집 폭발 VFX 1개 +// +// PD 지시 #813 · 발주서 WL-811def §1-2 · 설계안 WL-811a §C 811e 행 · 기준서 v1 §B 요소 2 (2026-09-09) +// +// ■ 무엇을 하나 (Killed 구독 · 원본 훅 0 · HUD 표시 0 = 데이터·이벤트만 · 표시는 813g U) +// · 마지막 처치로부터 killChainWindowSeconds 안에 다음 처치가 오면 연쇄가 이어진다(아케이드 킬 스트릭 · 시간은 unscaled). +// · 연쇄 수가 tiers[i].kills 와 같아지는 순간 TierReached(티어 · 이름 · 무게중심)를 1회 낸다 · 윈도우가 끝나면 Ended. +// · 티어 인덱스 ≥ clusterBurstMinTier 면 처치 무게중심에 군집 폭발 VFX 1개(InGameInfo.Show_Effect · 풀) — +// EffectBudget.ClusterBurst 슬롯(상한 1)을 clusterBurstHoldSeconds 동안 잡아 동시 폭발을 막는다 · 화면 밖이면 스킵. +// ■ GC 0 — 위치 링 버퍼(고정 크기 · 첫 사용 때 1회 할당) · 이벤트 구조체 · 문자열은 verboseLog 때만. +// ■ 값은 전부 WLReactionSettings 에셋 · enabled=false 면 이벤트 0(C8). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Reaction +{ + /// 티어 도달 통보(813g 가 구독해 문구를 띄운다). + public struct KillChainTierEvent { public int tier; public string name; public int kills; public Vector3 centroid; public Actor lastVictim; public bool burstSpawned; public float time; public int frame; } + /// 연쇄 종료(윈도우 만료) 통보. + public struct KillChainEndedEvent { public int kills; public int bestTier; public float duration; public float time; public int frame; } + + public static class KillChain + { + public static readonly CombatEventList TierReached = new CombatEventList(4); + public static readonly CombatEventList Ended = new CombatEventList(4); + + // ── 연쇄 상태 + static int s_chainKills, s_bestTier = -1; + static float s_chainStart, s_lastKill = -999f; + static Vector3[] s_pos; // 최근 처치 위치 링 + static int s_head, s_stored; + static Vector3 s_centroid; + + // ── 폭발 슬롯 + static bool s_burstHeld; + static float s_burstReleaseAt; + + // ── 진단(프로브가 읽는다) + public static bool Subscribed; + public static int KillCount, TierCount, ChainCount, BurstCount, BurstDeniedBudget, BurstSkippedView, BurstNoInGameInfo, SkippedFilter; + public static int BestChainEver; + public static string LastInfo = ""; + public static int ChainKills { get { return s_chainKills; } } + public static int BestTier { get { return s_bestTier; } } + public static Vector3 Centroid { get { return s_centroid; } } + public static bool BurstHeld { get { return s_burstHeld; } } + public static float LastKillTime { get { return s_lastKill; } } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void Register() + { + CombatEvents.Killed.Add(OnKilled); + Subscribed = true; + } + + static WLReactionSettings St { get { return WLReactionSettings.Instance; } } + + static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } + + static void Log(string msg) + { + var st = St; + if (st != null && st.verboseLog) Debug.Log("[KillChain] " + msg); + } + + static void EnsureBuffer(WLReactionSettings st) + { + int n = Mathf.Max(4, st.killChainBufferSize); + if (s_pos == null || s_pos.Length != n) { s_pos = new Vector3[n]; s_head = 0; s_stored = 0; } + } + + static void OnKilled(in KilledEvent e) + { + if (!WLReactionSettings.Enabled) return; + var st = St; + if (!st.killChainEnabled || e.victim == null) return; + if (st.killChainRequireMainPCKiller && (e.killer == null || !e.killer.IsMainPC())) { SkippedFilter++; return; } + if (!st.killChainCountSummons && e.subRole == eSubRol.Summon) { SkippedFilter++; return; } + + EnsureBuffer(st); + WLReactionRunner.Ensure(); + float now = Time.unscaledTime; + if (s_chainKills > 0 && now - s_lastKill >= st.killChainWindowSeconds) EndChain(now); // 늦게 온 처치 = 새 연쇄 + + if (s_chainKills == 0) { s_chainStart = now; s_bestTier = -1; s_stored = 0; s_head = 0; ChainCount++; } + s_chainKills++; + s_lastKill = now; + KillCount++; + + // 위치 링 + 무게중심(연쇄 안 최근 N개) + s_pos[s_head] = e.position; + s_head = (s_head + 1) % s_pos.Length; + if (s_stored < s_pos.Length) s_stored++; + Vector3 sum = Vector3.zero; + for (int i = 0; i < s_stored; i++) sum += s_pos[i]; + s_centroid = sum / s_stored; + + // 티어 판정(오름차순 · kills 와 정확히 같아지는 순간 1회) + var tiers = st.tiers; + if (tiers == null) return; + for (int i = 0; i < tiers.Length; i++) + { + if (tiers[i].kills != s_chainKills) continue; + s_bestTier = i; + if (s_chainKills > BestChainEver) BestChainEver = s_chainKills; + bool burst = i >= st.clusterBurstMinTier && TrySpawnBurst(st, now); + var ev = new KillChainTierEvent { tier = i, name = tiers[i].name, kills = s_chainKills, centroid = s_centroid, lastVictim = e.victim, burstSpawned = burst, time = now, frame = Time.frameCount }; + TierCount++; + LastInfo = tiers[i].name; + TierReached.Dispatch(in ev); + if (Verbose) Log("티어 " + tiers[i].name + " kills=" + s_chainKills + " centroid=" + s_centroid.ToString("F1") + " burst=" + burst); + break; + } + if (s_chainKills > BestChainEver) BestChainEver = s_chainKills; + } + + static bool TrySpawnBurst(WLReactionSettings st, float now) + { + if (!st.clusterBurstEnabled) return false; + if (st.clusterBurstRequireInView && !EffectBudget.IsInView(s_centroid)) { BurstSkippedView++; return false; } + if (s_burstHeld && now < s_burstReleaseAt) { BurstDeniedBudget++; return false; } // 우리 슬롯이 아직 살아 있음(동시 ≤ 1) + if (!EffectBudget.TryAcquire(EffectBudgetKind.ClusterBurst)) { BurstDeniedBudget++; return false; } + s_burstHeld = true; + s_burstReleaseAt = now + Mathf.Max(0.05f, st.clusterBurstHoldSeconds); + var info = InGameInfo.Ins; + if (info == null) { BurstNoInGameInfo++; if (Verbose) Log("폭발 스킵(InGameInfo 없음 · 슬롯은 잡음)"); return false; } + info.Show_Effect(st.clusterBurstPrefab, s_centroid + Vector3.up * st.clusterBurstHeightOffset); + BurstCount++; + return true; + } + + /// 폭발 슬롯을 돌려준다(홀드 만료 · 러너 파괴 · 씬 전환). + public static void ReleaseBurst() + { + if (!s_burstHeld) return; + s_burstHeld = false; + EffectBudget.Release(EffectBudgetKind.ClusterBurst); + } + + static void EndChain(float now) + { + if (s_chainKills <= 0) return; + var ev = new KillChainEndedEvent { kills = s_chainKills, bestTier = s_bestTier, duration = now - s_chainStart, time = now, frame = Time.frameCount }; + if (Verbose) Log("연쇄 종료 kills=" + s_chainKills + " best=" + s_bestTier + " dur=" + ev.duration.ToString("F2")); + s_chainKills = 0; s_bestTier = -1; s_stored = 0; s_head = 0; + Ended.Dispatch(in ev); + } + + internal static void Tick(float now) + { + if (s_chainKills > 0) + { + var st = St; + float win = st != null ? st.killChainWindowSeconds : 3f; + if (now - s_lastKill >= win) EndChain(now); + } + if (s_burstHeld && now >= s_burstReleaseAt) ReleaseBurst(); + } + + /// 씬 전환 · 프로브 정리: 연쇄와 슬롯을 비운다(이벤트 없음). + public static void ClearAll() + { + s_chainKills = 0; s_bestTier = -1; s_stored = 0; s_head = 0; s_lastKill = -999f; + ReleaseBurst(); + } + + /// 프로브용 카운터 초기화. + public static void ResetDiagnostics() + { + KillCount = TierCount = ChainCount = BurstCount = BurstDeniedBudget = BurstSkippedView = BurstNoInGameInfo = SkippedFilter = 0; + BestChainEver = 0; LastInfo = ""; + TierReached.Raised = TierReached.Dispatched = 0; Ended.Raised = Ended.Dispatched = 0; + } + } +} diff --git a/Assets/WL/Combat/Reaction/KillChain.cs.meta b/Assets/WL/Combat/Reaction/KillChain.cs.meta new file mode 100644 index 000000000..1346f0a0d --- /dev/null +++ b/Assets/WL/Combat/Reaction/KillChain.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cbbfa4d9bc8142ccae241c21c0a417ca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Reaction/MobHitFlash.cs b/Assets/WL/Combat/Reaction/MobHitFlash.cs new file mode 100644 index 000000000..6e44abb3a --- /dev/null +++ b/Assets/WL/Combat/Reaction/MobHitFlash.cs @@ -0,0 +1,325 @@ +// ───────────────────────────────────────────────────────────────────────────── +// MobHitFlash.cs — 몹 피격 점멸(MaterialPropertyBlock · 머티리얼 인스턴스 0 · GC 0) + 처치 디졸브 엣지 세팅 +// +// PD 지시 #813 · 발주서 WL-811def §1-3 · 구 발주서 WL-802d(주석 처리된 Actor.HitEffect_Color 의 MPB 복원) · 설계안 §C 811f 행 (2026-09-09) +// +// ■ 무엇을 하나 (HitConfirmed / Killed / Spawned 구독 · 원본 훅 0 · Actor.cs/MobActor.cs 무수정) +// HitConfirmed(몹) → 렌더러별 MPB 에 알베도 색(HDR 흰색) + 에미션을 써서 blinkOnSeconds 뒤 비운다. +// 같은 프레임 다중 히트(스윙 1회 5~7타)는 켜진 동안 연장 · 꺼진 뒤 blinkOffSeconds 안은 재점멸 안 함. +// Killed(몹) → 같은 MPB 에 디졸브 엣지 세기/범위(등급별)를 얹는다. 0.5 s 뒤 원본 Co_Dissolve 가 렌더러 머티리얼을 +// 디졸브 머티리얼로 바꾸는 순간 그 프로퍼티가 적용된다(MPB 는 렌더러에 붙어 머티리얼 교체와 무관). +// Spawned(몹) → 풀 재사용 몹의 MPB 를 비운다(잔류 0). +// ■ 왜 원본 방식(머티리얼 Add/Remove)이 아니라 MPB 인가 +// 원본 HitEffect_Color 는 sharedMaterials.ToList()+materials= 대입으로 매 타격 2.3 KB GC + 머티리얼 인스턴스가 생겨 주석 처리됐다. +// MPB 는 렌더러당 1개를 캐시해 재사용하므로 타격당 할당 0 · 머티리얼 인스턴스 0 · 드로우 +0(프로퍼티만). +// 대신 MPB 가 붙은 렌더러는 그 동안 SRP Batcher 에서 빠진다(≤ 0.03 s · 비운 뒤 복귀 · 디졸브 중 사망체 ≤ 6). +// ■ 렌더러 캐시 = Actor.arr_Renderer(protected · Awake 에서 ParticleSystemRenderer 를 뺀 캐시)를 리플렉션으로 1회 읽는다(원본 무수정 · +// 같은 필터 결과). 필드를 못 찾으면 GetComponentsInChildren 로 같은 필터를 1회 적용. 액터당 상태는 처음 볼 때 1회만 할당(풀이라 유한). +// ■ 프로퍼티는 셰이더별로 1회 Shader.FindPropertyIndex 로 실측해 캐시 · 없으면 그 렌더러는 건너뛴다(예외 0). +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Reaction +{ + public static class MobHitFlash + { + struct Resolved { public bool hasColor, hasEmission; public int colorId, emissionId; } + + sealed class State + { + public Actor actor; + public Renderer[] renderers; + public MaterialPropertyBlock[] blocks; + public Resolved[] resolved; + public bool blinkOn, tint; + public float blinkOffAt, blinkReadyAt, tintHdr, tintRange; + public int activeIndex = -1; + } + + static readonly Dictionary s_states = new Dictionary(64); + static readonly Dictionary s_byShader = new Dictionary(8); + static State[] s_active = new State[32]; + static int s_activeCount; + + static FieldInfo s_rendererField; + static bool s_rendererFieldLooked; + + static bool s_dissolveResolved, s_dissolveHasHdr, s_dissolveHasRange; + static int s_edgeHdrId, s_edgeRangeId; + static readonly List s_tmpRenderers = new List(16); + + // ── 진단(프로브가 읽는다) + public static bool Subscribed; + public static int OnCount, OffCount, ExtendCount, ThrottledCount, TintCount, ClearCount, SkippedFilter, SkippedNoProperty, StatesCreated, RendererFieldViaReflection; + public static string LastInfo = ""; + public static int ActiveCount { get { return s_activeCount; } } + public static int StateCount { get { return s_states.Count; } } + public static bool DissolveResolved { get { return s_dissolveResolved; } } + public static bool DissolveHasHdr { get { return s_dissolveHasHdr; } } + public static bool DissolveHasRange { get { return s_dissolveHasRange; } } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void Register() + { + CombatEvents.HitConfirmed.Add(OnHitConfirmed); + CombatEvents.Killed.Add(OnKilled); + CombatEvents.Spawned.Add(OnSpawned); + Subscribed = true; + } + + static WLHitBlinkSettings St { get { return WLHitBlinkSettings.Instance; } } + + static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } + + static void Log(string msg) + { + var st = St; + if (st != null && st.verboseLog) Debug.Log("[MobHitFlash] " + msg); + } + + // ───────────────────────────────────────── 이벤트 + static void OnHitConfirmed(in HitConfirmedEvent e) + { + if (!WLHitBlinkSettings.Enabled) return; + var st = St; + var victim = e.victim; + if (victim == null) return; + if (st.targetMobsOnly ? !(victim is MobActor) : victim.IsMainPC()) { SkippedFilter++; return; } + if (st.onlyByMainPC && !e.byMainPC) { SkippedFilter++; return; } + if (st.skipWhenDamageZero && e.damage <= 0d) { SkippedFilter++; return; } + + var s = GetState(victim, st); + if (s == null) return; + float now = Time.unscaledTime; + if (s.blinkOn) { s.blinkOffAt = now + st.blinkOnSeconds; ExtendCount++; return; } // 같은 프레임 다중 히트 → 연장 + if (now < s.blinkReadyAt) { ThrottledCount++; return; } + if (s.tint && !victim.IsDead()) s.tint = false; // 부활·재사용 뒤 남은 엣지 세팅 정리 + + WLReactionRunner.Ensure(); + s.blinkOn = true; s.blinkOffAt = now + st.blinkOnSeconds; + Apply(s, st); + Activate(s); + OnCount++; + LastInfo = "on"; + if (Verbose) Log("점멸 on " + victim.name + " renderers=" + s.renderers.Length); + } + + static void OnKilled(in KilledEvent e) + { + if (!WLHitBlinkSettings.Enabled) return; + var st = St; + if (!st.dissolveEdgeEnabled) return; + var victim = e.victim; + if (victim == null || !(victim is MobActor)) return; + ResolveDissolve(st); + if (!s_dissolveHasHdr && !s_dissolveHasRange) { SkippedNoProperty++; return; } + var s = GetState(victim, st); + if (s == null) return; + float hdr = e.subRole == eSubRol.Boss ? st.dissolveEdgeHdrBoss : e.subRole == eSubRol.Elite ? st.dissolveEdgeHdrElite : st.dissolveEdgeHdrNormal; + float range = e.subRole == eSubRol.Boss ? st.dissolveEdgeRangeBoss : e.subRole == eSubRol.Elite ? st.dissolveEdgeRangeElite : st.dissolveEdgeRangeNormal; + if (hdr < 0f && range < 0f) return; + s.tint = true; s.tintHdr = hdr; s.tintRange = range; + Apply(s, st); + TintCount++; + if (Verbose) Log("디졸브 엣지 " + victim.name + " sub=" + e.subRole + " hdr=" + hdr + " range=" + range); + } + + static void OnSpawned(in SpawnedEvent e) + { + if (e.actor == null) return; + State s; + if (!s_states.TryGetValue(e.actor, out s)) return; + if (!s.blinkOn && !s.tint) return; + s.blinkOn = false; s.tint = false; s.blinkReadyAt = 0f; + var st = St; + if (st != null) Apply(s, st); + Deactivate(s); + ClearCount++; + } + + // ───────────────────────────────────────── 상태 + static State GetState(Actor actor, WLHitBlinkSettings st) + { + State s; + if (s_states.TryGetValue(actor, out s)) return s; + var renderers = FindRenderers(actor); + if (renderers == null || renderers.Length == 0) return null; + s = new State { actor = actor, renderers = renderers, blocks = new MaterialPropertyBlock[renderers.Length], resolved = new Resolved[renderers.Length] }; + for (int i = 0; i < renderers.Length; i++) + { + s.blocks[i] = new MaterialPropertyBlock(); + var r = renderers[i]; + var mat = r != null ? r.sharedMaterial : null; + s.resolved[i] = Resolve(mat != null ? mat.shader : null, st); + } + s_states.Add(actor, s); + StatesCreated++; + return s; + } + + /// Actor.arr_Renderer(protected 캐시)를 리플렉션으로 1회 읽는다. 실패하면 같은 필터로 직접 수집. + static Renderer[] FindRenderers(Actor actor) + { + if (!s_rendererFieldLooked) + { + s_rendererFieldLooked = true; + s_rendererField = typeof(Actor).GetField("arr_Renderer", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + } + if (s_rendererField != null) + { + var arr = s_rendererField.GetValue(actor) as Renderer[]; + if (arr != null && arr.Length > 0) { RendererFieldViaReflection++; return arr; } + } + s_tmpRenderers.Clear(); + actor.GetComponentsInChildren(true, s_tmpRenderers); + int n = 0; + for (int i = 0; i < s_tmpRenderers.Count; i++) if (!(s_tmpRenderers[i] is ParticleSystemRenderer)) n++; + var result = new Renderer[n]; + n = 0; + for (int i = 0; i < s_tmpRenderers.Count; i++) if (!(s_tmpRenderers[i] is ParticleSystemRenderer)) result[n++] = s_tmpRenderers[i]; + s_tmpRenderers.Clear(); + return result; + } + + static Resolved Resolve(Shader shader, WLHitBlinkSettings st) + { + Resolved r; + if (shader == null) return default(Resolved); + if (s_byShader.TryGetValue(shader, out r)) return r; + r = default(Resolved); + var names = st.colorProperties; + if (names != null) + for (int i = 0; i < names.Length; i++) + if (!string.IsNullOrEmpty(names[i]) && shader.FindPropertyIndex(names[i]) >= 0) { r.hasColor = true; r.colorId = Shader.PropertyToID(names[i]); break; } + names = st.emissionProperties; + if (names != null) + for (int i = 0; i < names.Length; i++) + if (!string.IsNullOrEmpty(names[i]) && shader.FindPropertyIndex(names[i]) >= 0) { r.hasEmission = true; r.emissionId = Shader.PropertyToID(names[i]); break; } + s_byShader.Add(shader, r); + if (!r.hasColor && !r.hasEmission) SkippedNoProperty++; + if (Verbose) Log("셰이더 실측 " + shader.name + " color=" + r.hasColor + " emission=" + r.hasEmission); + return r; + } + + static void ResolveDissolve(WLHitBlinkSettings st) + { + if (s_dissolveResolved) return; + Shader sh = null; + var info = InGameInfo.Ins; + if (info != null && info.mat_Dissolve != null) sh = info.mat_Dissolve.shader; + if (sh == null && !string.IsNullOrEmpty(st.dissolveShaderName)) sh = Shader.Find(st.dissolveShaderName); + if (sh == null) return; // 아직 못 찾음(타이틀 등) — 다음 Killed 때 재시도 + s_dissolveResolved = true; + s_dissolveHasHdr = !string.IsNullOrEmpty(st.dissolveEdgeHdrProperty) && sh.FindPropertyIndex(st.dissolveEdgeHdrProperty) >= 0; + s_dissolveHasRange = !string.IsNullOrEmpty(st.dissolveEdgeRangeProperty) && sh.FindPropertyIndex(st.dissolveEdgeRangeProperty) >= 0; + if (s_dissolveHasHdr) s_edgeHdrId = Shader.PropertyToID(st.dissolveEdgeHdrProperty); + if (s_dissolveHasRange) s_edgeRangeId = Shader.PropertyToID(st.dissolveEdgeRangeProperty); + if (Verbose) Log("디졸브 셰이더 실측 " + sh.name + " hdr=" + s_dissolveHasHdr + " range=" + s_dissolveHasRange); + } + + /// 상태(blinkOn · tint)로 렌더러별 MPB 를 다시 구성해 쓴다. 둘 다 꺼져 있으면 빈 블록 = 오버라이드 0. + static void Apply(State s, WLHitBlinkSettings st) + { + for (int i = 0; i < s.renderers.Length; i++) + { + var r = s.renderers[i]; + if (r == null) continue; + var mpb = s.blocks[i]; + mpb.Clear(); + if (s.blinkOn) + { + var res = s.resolved[i]; + if (res.hasColor) mpb.SetColor(res.colorId, st.blinkColor); + if (st.useEmission && res.hasEmission) mpb.SetColor(res.emissionId, st.blinkEmission); + } + if (s.tint) + { + if (s_dissolveHasHdr && s.tintHdr >= 0f) mpb.SetFloat(s_edgeHdrId, s.tintHdr); + if (s_dissolveHasRange && s.tintRange >= 0f) mpb.SetFloat(s_edgeRangeId, s.tintRange); + } + r.SetPropertyBlock(mpb); + } + } + + 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) + { + 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; + } + + 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.blinkOn && (now >= s.blinkOffAt || st == null || !WLHitBlinkSettings.Enabled)) + { + s.blinkOn = false; + s.blinkReadyAt = now + (st != null ? st.blinkOffSeconds : 0f); + if (st != null) Apply(s, st); + OffCount++; + LastInfo = "off"; + } + if (!s.blinkOn) Deactivate(s); + } + } + + /// 모든 몹의 MPB 를 비운다(러너 파괴 · 앱 종료 · 프로브 정리). 상태 캐시는 유지. + public static void RestoreAll() + { + var st = St; + foreach (var kv in s_states) + { + var s = kv.Value; + if (!s.blinkOn && !s.tint) continue; + s.blinkOn = false; s.tint = false; + if (st != null) Apply(s, st); + else for (int i = 0; i < s.renderers.Length; i++) if (s.renderers[i] != null) { s.blocks[i].Clear(); s.renderers[i].SetPropertyBlock(s.blocks[i]); } + } + for (int i = 0; i < s_activeCount; i++) { s_active[i].activeIndex = -1; s_active[i] = null; } + s_activeCount = 0; + } + + /// 씬 전환: MPB 를 비우고 상태 캐시(파괴된 Actor 참조)를 버린다. + public static void ClearAll() + { + RestoreAll(); + s_states.Clear(); + } + + /// 프로브용: 특정 액터의 렌더러가 지금 프로퍼티 블록을 갖고 있는가(잔류 검사). + public static bool HasAnyBlock(Actor actor) + { + State s; + if (actor == null || !s_states.TryGetValue(actor, out s)) return false; + for (int i = 0; i < s.renderers.Length; i++) if (s.renderers[i] != null && s.renderers[i].HasPropertyBlock()) return true; + return false; + } + + /// 프로브용 카운터 초기화. + public static void ResetDiagnostics() + { + OnCount = OffCount = ExtendCount = ThrottledCount = TintCount = ClearCount = SkippedFilter = SkippedNoProperty = StatesCreated = RendererFieldViaReflection = 0; + LastInfo = ""; + } + } +} diff --git a/Assets/WL/Combat/Reaction/MobHitFlash.cs.meta b/Assets/WL/Combat/Reaction/MobHitFlash.cs.meta new file mode 100644 index 000000000..6d3a95770 --- /dev/null +++ b/Assets/WL/Combat/Reaction/MobHitFlash.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 82e8c6c95c9a4773ac3f79cdef83a5c5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs b/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs new file mode 100644 index 000000000..5c146bae2 --- /dev/null +++ b/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs @@ -0,0 +1,124 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLHitBlinkSettings.cs — 몹 피격 점멸(MaterialPropertyBlock) · 처치 디졸브 엣지 값의 단일 출처(SOT · C45) +// +// PD 지시 #813 · 발주서 WL-811def §1-3 · 구 발주서 WL-802d §3 ⓕ 규격 · 설계안 §C 811f 행 · 기준서 v1 §B 요소 2 (2026-09-09) +// +// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset +// → Resources.Load("WL/WLHitBlinkSettings") 로 1회 로드 후 캐시. +// 에셋이 없거나 enabled = false 또는 RuntimeDisabled 면 MobHitFlash 가 아무 것도 하지 않는다(C8 · 원본 = 점멸 꺼짐 상태 그대로). +// +// 【프로퍼티명 실측 · 2026-09-09 · Assets/Res_Addr/Mobs/{Mob,Elite,FieldBoss} 299 프리팹 · 머티리얼 참조 355】 +// 312 = MK Toon `Assets/_MK/MKToon/Shader/URP/Standard/StandardSimple.shader` → [MainColor] _AlbedoColor · _EmissionColor +// (_EmissionColor 는 Config.hlsl 이 MK_EMISSION 을 항상 정의해 키워드 없이 가산된다 · _BaseColor 없음 · _Color 는 HideInInspector 미사용) +// 나머지 41 = EricWang Alpha Blended / Additive · MasterStylizedFX CommonParticleOneShot(파티클 · arr_Renderer 필터 대상 밖) +// 디졸브 = `ThirdParty/Dissolve Edge by Moonflower Carnivore/Shaders/Transparent_Unlit_DissolveEdge.shader`("MoonflowerCarnivore/Dissolve Edge") +// → 엣지 **색** 프로퍼티는 없고 램프 텍스처(_EdgeAroundRamp) × _EdgeAroundHDR(1~3) · _EdgeAround(범위 0~0.5) 가 색·세기를 정한다. +// _Progress 는 원본 Co_Dissolve 가 sharedMaterial.SetFloat 로 구동하므로 MPB 에 넣지 않는다(넣으면 디졸브가 멈춘다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Reaction +{ + [CreateAssetMenu(fileName = "WLHitBlinkSettings", menuName = "WL/Hit Blink Settings", order = 323)] + public sealed class WLHitBlinkSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLHitBlinkSettings"; + + static WLHitBlinkSettings s_cached; + static bool s_lookupDone; + + public static WLHitBlinkSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 점멸만 끈다. Play 종료 시 자동 false. + public static bool RuntimeDisabled; + + public static bool Enabled + { + get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; } + } + + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + // ─────────────────────────────────────────── 전체 스위치 + [Header("전체 스위치")] + [Tooltip("끄면 점멸·디졸브 엣지 세팅이 전부 비활성(MPB 0) = 원본(점멸 꺼짐) 동작. 롤백 1순위.")] + public bool enabled = true; + + [Tooltip("점멸 on/off · 엣지 세팅을 Console 에 남긴다(검증용 · GC 발생).")] + public bool verboseLog = false; + + // ─────────────────────────────────────────── 대상 + [Header("대상")] + [Tooltip("몬스터(MobActor 계열)만 점멸. off 면 PC 를 뺀 모든 Actor.")] + public bool targetMobsOnly = true; + + [Tooltip("메인 PC 가 때린 것만 점멸. off 면 펫·소환수 타격도 점멸.")] + public bool onlyByMainPC = false; + + [Tooltip("피해 0(무적·회피)은 점멸하지 않는다.")] + public bool skipWhenDamageZero = true; + + // ─────────────────────────────────────────── 점멸 + [Header("점멸 (HitConfirmed → MPB on → blinkOnSeconds 뒤 off)")] + [Tooltip("점멸 색(HDR · 알베도 색 프로퍼티에 쓴다). 1 보다 크면 텍스처가 밝게 뜬다 = 흰 번쩍임. 기준서 「흰색 0.03~0.06 s」.")] + [ColorUsage(true, true)] public Color blinkColor = new Color(2.2f, 2.2f, 2.2f, 1f); + + [Tooltip("에미션 프로퍼티도 함께 올린다(MK Toon 은 키워드 없이 가산 · URP Lit 은 _EMISSION 키워드가 켜진 머티리얼만).")] + public bool useEmission = true; + + [Tooltip("점멸 에미션 색(HDR).")] + [ColorUsage(true, true)] public Color blinkEmission = new Color(0.6f, 0.6f, 0.6f, 1f); + + [Tooltip("켜져 있는 시간(초 · unscaled). Feel 프리셋 Blink On 0.03.")] + public float blinkOnSeconds = 0.03f; + + [Tooltip("꺼진 뒤 다시 켤 수 있기까지의 최소 간격(초 · unscaled). Feel 프리셋 Blink Off 0.03 — 같은 프레임 다중 히트(스윙 1회 5~7타)를 1회 점멸로 묶는다.")] + public float blinkOffSeconds = 0.03f; + + [Tooltip("알베도/베이스 색 프로퍼티 후보(앞에서부터 셰이더에 있는 첫 것을 쓴다 · Shader.FindPropertyIndex 실측). 없으면 그 렌더러는 건너뜀.")] + public string[] colorProperties = new string[] { "_AlbedoColor", "_BaseColor" }; + + [Tooltip("에미션 색 프로퍼티 후보.")] + public string[] emissionProperties = new string[] { "_EmissionColor" }; + + // ─────────────────────────────────────────── 디졸브 엣지 + [Header("처치 디졸브 엣지 (Killed → MPB 로 엣지 세기·범위 · 원본 Co_Dissolve 무수정)")] + [Tooltip("처치 등급별 디졸브 엣지 세기/범위를 MPB 로 미리 얹는다(0.5 s 뒤 원본이 디졸브 머티리얼로 바꾸는 순간 적용).")] + public bool dissolveEdgeEnabled = true; + + [Tooltip("디졸브 셰이더 이름(InGameInfo.mat_Dissolve 가 없을 때 Shader.Find 폴백 · 실측 \"MoonflowerCarnivore/Dissolve Edge\").")] + public string dissolveShaderName = "MoonflowerCarnivore/Dissolve Edge"; + + [Tooltip("엣지 세기 프로퍼티(실측 _EdgeAroundHDR · 머티리얼 기본 1.8 · 범위 1~3).")] + public string dissolveEdgeHdrProperty = "_EdgeAroundHDR"; + + [Tooltip("엣지 범위 프로퍼티(실측 _EdgeAround · 머티리얼 기본 0.157 · 범위 0~0.5).")] + public string dissolveEdgeRangeProperty = "_EdgeAround"; + + [Tooltip("일반 몹 엣지 세기(음수 = 머티리얼 값 유지).")] + public float dissolveEdgeHdrNormal = 2f; + [Tooltip("엘리트 엣지 세기.")] + public float dissolveEdgeHdrElite = 2.5f; + [Tooltip("보스 엣지 세기.")] + public float dissolveEdgeHdrBoss = 3f; + + [Tooltip("일반 몹 엣지 범위(음수 = 머티리얼 값 유지).")] + public float dissolveEdgeRangeNormal = -1f; + [Tooltip("엘리트 엣지 범위.")] + public float dissolveEdgeRangeElite = 0.22f; + [Tooltip("보스 엣지 범위.")] + public float dissolveEdgeRangeBoss = 0.3f; + } +} diff --git a/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs.meta b/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs.meta new file mode 100644 index 000000000..832e43cf4 --- /dev/null +++ b/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cc36afee831a43ba801df0563266287a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Reaction/WLReactionRunner.cs b/Assets/WL/Combat/Reaction/WLReactionRunner.cs new file mode 100644 index 000000000..197514d1c --- /dev/null +++ b/Assets/WL/Combat/Reaction/WLReactionRunner.cs @@ -0,0 +1,48 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLReactionRunner.cs — 전투 반응(연쇄 처치 · 데미지 합산 · 몹 점멸) 공용 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad) +// +// 발주서 WL-811def · 새 Manager/Singleton 을 만들지 않는다(WLHitFeel __WLHitFeel · TimeScaleArbiter 러너 선례). 코루틴 없음 · GC 0. +// 각 시스템은 처음 틱이 필요할 때 Ensure() 를 부르고, Update 는 unscaledTime 하나를 세 시스템에 넘긴다. +// 파괴·비활성·앱 종료 시 몹 MPB 를 원복하고 폭발 슬롯을 돌려준다. 씬 전환 시 세 시스템의 상태를 비운다(파괴된 Actor 참조 방지). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace WL.Combat.Reaction +{ + public sealed class WLReactionRunner : MonoBehaviour + { + static WLReactionRunner s_runner; + static bool s_sceneHooked; + + public static bool Exists { get { return s_runner != null; } } + + public static void Ensure() + { + if (s_runner != null) return; + var go = new GameObject("__WLReaction"); + DontDestroyOnLoad(go); + s_runner = go.AddComponent(); + if (!s_sceneHooked) { SceneManager.activeSceneChanged += OnSceneChanged; s_sceneHooked = true; } + } + + static void OnSceneChanged(Scene from, Scene to) + { + KillChain.ClearAll(); + DamageAggregator.ClearAll(); + MobHitFlash.ClearAll(); + } + + void Update() + { + float now = Time.unscaledTime; + KillChain.Tick(now); + DamageAggregator.Tick(now); + MobHitFlash.Tick(now); + } + + void OnDisable() { MobHitFlash.RestoreAll(); KillChain.ReleaseBurst(); } + void OnApplicationQuit() { MobHitFlash.RestoreAll(); KillChain.ReleaseBurst(); } + } +} diff --git a/Assets/WL/Combat/Reaction/WLReactionRunner.cs.meta b/Assets/WL/Combat/Reaction/WLReactionRunner.cs.meta new file mode 100644 index 000000000..442517408 --- /dev/null +++ b/Assets/WL/Combat/Reaction/WLReactionRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa403268af584b5aa87eaec082cf01be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Reaction/WLReactionSettings.cs b/Assets/WL/Combat/Reaction/WLReactionSettings.cs new file mode 100644 index 000000000..a07ab1144 --- /dev/null +++ b/Assets/WL/Combat/Reaction/WLReactionSettings.cs @@ -0,0 +1,129 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLReactionSettings.cs — 전투 반응(연쇄 처치 · 군집 폭발 · 데미지 합산 윈도우) 값의 단일 출처(SOT · C45) +// +// PD 지시 #813 · 발주서 WL-811def §1-2 · 설계안 WL-811a §C 811e 행 · 기준서 v1 §B 요소 2 (2026-09-09) +// +// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLReactionSettings.asset +// → Resources.Load("WL/WLReactionSettings") 로 1회 로드 후 캐시. +// 에셋이 없거나 enabled = false 또는 RuntimeDisabled 면 KillChain · DamageAggregator 가 아무 것도 하지 않는다(C8). +// HUD 표시는 하지 않는다 — 이 파일의 시스템은 이벤트(KillChain.TierReached · DamageAggregator.Flushed)만 낸다(표시 = 813g U). +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using UnityEngine; + +namespace WL.Combat.Reaction +{ + [Serializable] + public struct KillChainTier + { + [Tooltip("티어 이름(데이터 · HUD 문구는 813g 가 이 이름을 키로 쓴다).")] + public string name; + [Tooltip("이 티어에 도달하는 연쇄 처치 수.")] + public int kills; + } + + [CreateAssetMenu(fileName = "WLReactionSettings", menuName = "WL/Reaction Settings", order = 322)] + public sealed class WLReactionSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLReactionSettings"; + + static WLReactionSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null. + public static WLReactionSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 이 반응만 끈다. Play 종료 시 자동 false. + public static bool RuntimeDisabled; + + public static bool Enabled + { + get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; } + } + + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + // ─────────────────────────────────────────── 전체 스위치 + [Header("전체 스위치")] + [Tooltip("끄면 연쇄 처치 카운터 · 군집 폭발 · 데미지 합산이 전부 비활성(이벤트 0). 롤백 1순위.")] + public bool enabled = true; + + [Tooltip("티어 도달 · 폭발 · 합산 플러시를 Console 에 남긴다(검증용 · GC 발생).")] + public bool verboseLog = false; + + // ─────────────────────────────────────────── 연쇄 처치 + [Header("연쇄 처치 (Killed · 윈도우 안 연속 처치 카운터)")] + [Tooltip("연쇄 처치 카운터 사용 여부.")] + public bool killChainEnabled = true; + + [Tooltip("연쇄 유지 시간(초 · unscaled). 마지막 처치로부터 이 시간 안에 다음 처치가 오면 연쇄가 이어진다. 기준서 §B 요소 2 = 3 s.")] + public float killChainWindowSeconds = 3f; + + [Tooltip("메인 PC 가 직접 처치한 것만 센다(killer == 메인 PC). off 면 도트·펫·소환수 처치도 센다.")] + public bool killChainRequireMainPCKiller = false; + + [Tooltip("몬스터 소환수(eSubRol.Summon) 처치도 센다.")] + public bool killChainCountSummons = true; + + [Tooltip("연쇄 위치(무게중심) 계산에 기억하는 최근 처치 수(버퍼 용량 · 첫 사용 때 할당).")] + public int killChainBufferSize = 16; + + [Tooltip("티어 표(오름차순). 연쇄 수가 kills 와 같아지는 순간 TierReached 를 1회 낸다. 기준서 제안 2/3/5/8.")] + public KillChainTier[] tiers = new KillChainTier[] + { + new KillChainTier { name = "DOUBLE", kills = 2 }, + new KillChainTier { name = "TRIPLE", kills = 3 }, + new KillChainTier { name = "RAMPAGE", kills = 5 }, + new KillChainTier { name = "MASSACRE", kills = 8 }, + }; + + // ─────────────────────────────────────────── 군집 폭발 + [Header("군집 폭발 (티어 도달 → 처치 무게중심에 VFX 1개 · EffectBudget.ClusterBurst 상한 1)")] + [Tooltip("군집 폭발 사용 여부.")] + public bool clusterBurstEnabled = true; + + [Tooltip("폭발을 내는 최소 티어 인덱스(tiers 배열 기준 · 0 = DOUBLE · 1 = TRIPLE).")] + public int clusterBurstMinTier = 1; + + [Tooltip("Assets/Res_Addr/Effect/<이름>.prefab — InGameInfo.Show_Effect 로 풀에서 꺼낸다(에셋 인벤토리 실존 확인 = BoomHit · 대안 FireballHit · Effect_STR_EXTRA_SPLASH).")] + public string clusterBurstPrefab = "BoomHit"; + + [Tooltip("폭발 슬롯(EffectBudget.ClusterBurst)을 잡아 두는 시간(초 · unscaled). 이 시간 안의 다음 폭발은 스킵(동시 ≤ 1).")] + public float clusterBurstHoldSeconds = 1f; + + [Tooltip("무게중심에서 위로 띄우는 높이(m).")] + public float clusterBurstHeightOffset = 0.3f; + + [Tooltip("무게중심이 화면 밖(EffectBudget.IsInView)이면 스킵.")] + public bool clusterBurstRequireInView = true; + + // ─────────────────────────────────────────── 데미지 합산 + [Header("데미지 합산 윈도우 (HitConfirmed → 대상별 합산 → Flushed 이벤트 · 표시는 813g)")] + [Tooltip("합산 사용 여부.")] + public bool damageMergeEnabled = true; + + [Tooltip("첫 타격부터 이 시간(초 · unscaled) 안의 같은 대상 타격을 하나로 합쳐 Flushed 를 낸다. 기준서 §B 요소 2 = 0.25 s.")] + public float damageMergeWindowSeconds = 0.25f; + + [Tooltip("처치 타격(isKill)은 윈도우를 기다리지 않고 즉시 플러시한다.")] + public bool damageMergeFlushOnKill = true; + + [Tooltip("동시에 합산 중일 수 있는 대상 수(슬롯 · 첫 사용 때 할당). 초과하면 가장 오래된 슬롯을 먼저 플러시한다.")] + public int damageMergeSlots = 32; + + [Tooltip("메인 PC 가 때린 것만 합산한다. off 면 모든 타격(PC 피격 포함)을 대상별로 합산.")] + public bool damageMergeMainPCOnly = false; + } +} diff --git a/Assets/WL/Combat/Reaction/WLReactionSettings.cs.meta b/Assets/WL/Combat/Reaction/WLReactionSettings.cs.meta new file mode 100644 index 000000000..fb3b6ab8f --- /dev/null +++ b/Assets/WL/Combat/Reaction/WLReactionSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: acd8e1332fa14f8f8d4d07b310526841 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset new file mode 100644 index 000000000..19ef0801c --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset @@ -0,0 +1,39 @@ +%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: cc36afee831a43ba801df0563266287a, type: 3} + m_Name: WLHitBlinkSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Reaction.WLHitBlinkSettings + enabled: 1 + verboseLog: 0 + targetMobsOnly: 1 + onlyByMainPC: 0 + skipWhenDamageZero: 1 + blinkColor: {r: 2.2, g: 2.2, b: 2.2, a: 1} + useEmission: 1 + blinkEmission: {r: 0.6, g: 0.6, b: 0.6, a: 1} + blinkOnSeconds: 0.03 + blinkOffSeconds: 0.03 + colorProperties: + - _AlbedoColor + - _BaseColor + emissionProperties: + - _EmissionColor + dissolveEdgeEnabled: 1 + dissolveShaderName: MoonflowerCarnivore/Dissolve Edge + dissolveEdgeHdrProperty: _EdgeAroundHDR + dissolveEdgeRangeProperty: _EdgeAround + dissolveEdgeHdrNormal: 2 + dissolveEdgeHdrElite: 2.5 + dissolveEdgeHdrBoss: 3 + dissolveEdgeRangeNormal: -1 + dissolveEdgeRangeElite: 0.22 + dissolveEdgeRangeBoss: 0.3 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset.meta new file mode 100644 index 000000000..25f5c81b4 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b989e067da174c77b7938e1ceda09391 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLReactionSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLReactionSettings.asset new file mode 100644 index 000000000..413098d06 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLReactionSettings.asset @@ -0,0 +1,41 @@ +%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: acd8e1332fa14f8f8d4d07b310526841, type: 3} + m_Name: WLReactionSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Reaction.WLReactionSettings + enabled: 1 + verboseLog: 0 + killChainEnabled: 1 + killChainWindowSeconds: 3 + killChainRequireMainPCKiller: 0 + killChainCountSummons: 1 + killChainBufferSize: 16 + tiers: + - name: DOUBLE + kills: 2 + - name: TRIPLE + kills: 3 + - name: RAMPAGE + kills: 5 + - name: MASSACRE + kills: 8 + clusterBurstEnabled: 1 + clusterBurstMinTier: 1 + clusterBurstPrefab: BoomHit + clusterBurstHoldSeconds: 1 + clusterBurstHeightOffset: 0.3 + clusterBurstRequireInView: 1 + damageMergeEnabled: 1 + damageMergeWindowSeconds: 0.25 + damageMergeFlushOnKill: 1 + damageMergeSlots: 32 + damageMergeMainPCOnly: 0 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLReactionSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLReactionSettings.asset.meta new file mode 100644 index 000000000..d580047e9 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLReactionSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0ddcc5cdfc4643a88c2faeeed6c532c9 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: